TP — Séance 2 — Analyse algorithmique
Compter, chercher, mesurer : vérifier une analyse en Java
Instrumenter des fragments pour compter leurs opérations, écrire soi-même le test d’un fragment, écrire la recherche séquentielle et la recherche dichotomique avec un compteur de cases examinées, compter les paires de somme nulle d’un tableau par deux algorithmes de classes différentes, puis confronter ces analyses à des durées mesurées par l’expérience du doublement, à des comptes exacts, et à la file de la Séance 1. Durée : une séance de TP.
À rendre. Le dépôt Git du projet (les classes complétées, les tests au vert) et un compte rendu de deux à trois pages qui répond aux questions marquées Observer, Expliquer et Modifier, en recopiant les sorties obtenues.
Préparation
Le TP se fait dans l’environnement Docker du cours (Java 17, Gradle 8, JUnit 5). Sur la machine, décompresser le code de départ, TP_S02_code_depart.zip, dans ~/dsa : le dossier TP_S02_code_depart/ apparaît. Le conteneur se lance depuis ce dossier :
cd ~/dsa
docker run -it --rm -v "$(pwd):/workspace" elbachirtoure/dsa-java:v2.0Sous Windows (Git Bash), la commande docker run est précédée de MSYS_NO_PATHCONV=1. Dans le conteneur :
cd /workspace
new-project tp-s02
cd tp-s02
cp -r ../TP_S02_code_depart/. .
rm src/test/java/MainTest.javaLe code de départ contient dix fichiers, sept dans src/main/java/ et trois dans src/test/java/ : la copie les range dans le projet, en remplaçant le Main.java créé par new-project, et rm supprime le MainTest.java créé par new-project. Le dépôt Git du projet se crée dans tp-s02 : git init -b main et l’identité locale, comme à l’étape 7 du TP-1.
| Fichier | Rôle |
|---|---|
Compteur.java |
le compteur d’opérations du cours (ajouter, total, remise) ; fourni |
Chrono.java |
mediane(tache) : médiane de cinq durées d’exécution, en millisecondes ; fourni |
Fragments.java |
produit et moities, deux fragments à instrumenter (étape 1) |
Paliers.java |
paliers, un fragment à instrumenter, dont le test est à écrire (étape 2) |
Recherche.java |
le compteur examinees ; sequentielle et dichotomie à écrire (étape 3) |
Paires.java |
le compteur comparaisons ; compterNaif et compterRapide à écrire (étape 4) |
Main.java |
programmes d’observation, fourni |
FragmentsTest.java, RechercheTest.java, PairesTest.java |
treize tests JUnit, fournis ; PaliersTest.java est à créer (étape 2) |
Les méthodes à écrire contiennent une consigne en commentaire et lèvent UnsupportedOperationException. Les deux commandes du TP sont gradle --no-daemon test et gradle --no-daemon run --args="mode", où mode vaut compter, recherche, doublement, comparer, file ou acces. Les opérations se comptent selon la convention du cours ; i = i / 2 et j += 2 comptent chacune pour 2 (un calcul, une affectation).
Observer. Lancer les tests avant d’écrire la moindre ligne : combien échouent, et avec quelle exception ?
Étape 1 : deux fragments comptés
Écrire produit et moities de Fragments : chacune exécute le fragment de sa javadoc et appelle op.ajouter(k) pour chaque opération exécutée, dans l’ordre où elle l’est.
- Observer. Les quatre tests de
FragmentsTestdoivent passer. Lancer le modecompteret recopier sa sortie. - Expliquer. Établir à la main, instruction par instruction, la fonction de coût \(f(n)\) de
produit, et vérifier qu’elle redonne les valeurs attendues parproduitCompteSesOperations. Quelle est la classe \(\Theta\) deproduit, alors qu’il contient deux boucles imbriquées ? - Expliquer. Combien de tours fait la boucle de
moitiespour \(n=1\,000\), et pour \(n=1\,024\) ? Donner le nombre de tours en fonction de \(n\geq1\), puis \(f(n)\), et la vérifier sur les valeurs demoitiesCompteSesOperations. - Modifier. Supprimer provisoirement, dans
moities, l’appel qui compte le test qui fait sortir de la boucle. Quel test échoue, avec quel message, et pour quelle valeur de \(n\) en premier ? Revenir au code correct.
Étape 2 : écrire soi-même le test d’un fragment
La méthode paliers de Paliers exécute le fragment de sa javadoc, dont la boucle interne part de j = i et avance de 2 en 2. Aucun test n’est fourni : c’est à vous de l’écrire.
Observer. Écrire
paliers, en appelantop.ajouter(k)pour chaque opération exécutée, comme à l’étape 1. Sans test, comment savoir si elle est juste ?Expliquer. Combien de tours fait la boucle interne pour la ligne \(i\) ? Calculer à la main le nombre total de tours (la valeur rendue) pour \(n=1, 2, \dots, 6\), puis l’exprimer en fonction de \(n\), d’abord pour \(n\) pair, puis pour \(n\) impair, puis en une seule formule écrite en arithmétique entière.
\(n\) 1 2 3 4 5 6 tours de chaque ligne \(i=0, 1, \dots\) nombre total de tours \(f(n)\), à remplir en (c) Expliquer. Établir \(f(n)\), le nombre d’opérations, en fonction de \(n\) et du nombre total de tours. En déduire la classe \(\Theta\) de
paliers.Observer. Créer
src/test/java/PaliersTest.java, avec une méthode@TestnomméepaliersSuitSaFormulequi, pour chaque \(n\) de 0 à 3 000, compare la valeur rendue parpaliersetop.total()aux formules de (b) et (c) parassertEquals. Les formules sont calculées enlong, sansdouble. Le test doit passer. Squelette, à compléter :import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; public class PaliersTest { @Test void paliersSuitSaFormule() { for (int n = 0; n <= 3000; n++) { Compteur op = new Compteur(); long c = Paliers.paliers(n, op); // tours : la formule de (b), en long // assertEquals(attendu, obtenu) pour c, puis pour op.total() } } }Expliquer. Pourquoi éviter le type
doubledans ce test ? Pourquoilongplutôt queint? Pourquoi tester 3 001 valeurs de \(n\), et non deux ou trois ?Modifier. Supprimer provisoirement, dans
paliers, l’appel qui compte le test qui fait sortir de la boucle interne. Votre test échoue-t-il, avec quel message, et pour quelle valeur de \(n\) en premier ? Pourquoi pas pour \(n=0\) ? Revenir au code correct.
Étape 3 : chercher, et compter les cases examinées
Écrire sequentielle et dichotomie de Recherche. Chacune incrémente examinees à chaque case comparée à x ; la dichotomie est celle du cours, écrite avec une boucle while.
- Observer. Les cinq tests de
RechercheTestdoivent passer. Lancer le moderechercheet recopier sa sortie. - Expliquer. Dans
dichotomieExamineLesBonnesCases, donner les cases examinées pour \(x=40\) et pour \(x=50\) (valeurs de \(lo\), \(hi\) et \(mid\) à chaque tour). - Expliquer. Pourquoi la dichotomie examine-t-elle 11 cases pour \(n=1\,024\) et 21 pour \(n=1\,048\,576\) ? Relier au plus petit nombre de cases qui garantit de conclure. Comparer la moyenne obtenue pour \(n=1\,500\) (valeur présente) à \(\log_2 1\,500\).
- Modifier. Remplacer provisoirement
lo + (hi - lo) / 2par(lo + hi) / 2. Les tests échouent-ils ? Pour quelles tailles de tableau les deux écritures diffèrent-elles, et que se passe-t-il alors ? Revenir au code correct.
Étape 4 : paires de somme nulle, deux algorithmes
Les tableaux de Paires contiennent des valeurs distinctes. compterNaif compte les paires d’indices \(i<j\) telles que a[i] + a[j] == 0 par deux boucles imbriquées, et incrémente comparaisons avant chaque test de somme nulle ; compterRapide trie une copie du tableau, puis cherche pour chaque case la valeur opposée par la dichotomie de l’étape 3.
- Observer. Les quatre tests de
PairesTestdoivent passer, et les quatorze tests du projet aussi (les treize fournis et le vôtre). - Expliquer. Combien de paires d’indices
compterNaifexamine-t-elle pour un tableau de \(n\) cases ? Donner la classe \(\Theta\) de chaque méthode, en comptant le tri parArrays.sortpour \(\Theta(n\log n)\). - Expliquer. Pourquoi
compterRapidetravaille-t-elle sur une copie ? Quel test le vérifie ? - Modifier. Remplacer provisoirement
if (j > i) c++;parif (j != -1) c++;. Quels tests échouent, avec quelles valeurs ? Expliquer le facteur observé, et dire ce qui changerait si le tableau contenait la valeur 0. Revenir au code correct.
Étape 5 : l’expérience du doublement
Le mode doublement chronomètre les deux méthodes de Paires sur des tableaux de valeurs distinctes mélangées (graine fixe) : compterNaif de 2 000 à 32 000 cases, compterRapide de 64 000 à 2 048 000 cases. Chaque durée est une médiane de cinq exécutions (Chrono.mediane), après une chauffe ; le rapport est celui de la durée à la durée précédente.
- Observer. Lancer le mode
doublementet recopier les deux tableaux. - Expliquer. En déduire l’exposant \(b\) du modèle \(T(n)\approx a\,n^b\) pour chaque méthode. Les rapports de
compterRapidesont-ils plus proches de 2 ou de \(2\cdot\log_2(2n)/\log_2 n\) ? Peut-on les distinguer avec ces mesures ? - Expliquer. Prévoir la durée de
compterNaifpour \(n=1\,024\,000\), d’après la dernière mesure et le modèle. Combien de foiscompterRapideest-elle plus rapide à \(n=2\,048\,000\), d’après les deux modèles ? - Modifier. Dans
compterRapide, remplacer provisoirementRecherche.dichotomieparRecherche.sequentielle; dansMain.doublement, ramener la seconde boucle à \(n=2\,000, \dots, 32\,000\) (for (int n = 2000; n <= 32000; n *= 2)) et la chauffe dew2à 4 000 cases. Les tests passent-ils ? Relancer le modedoublement: que deviennent les rapports et les durées, et pourquoi ? Revenir au code correct. - Expliquer. Relancer deux fois le mode
doublement: quelles valeurs changent, et lesquelles ne changent pas ?
Étape 6 : compter au lieu de chronométrer
Le mode comparer ne chronomètre rien : pour des tableaux construits comme à l’étape 5, de \(n=2\,000\) à \(n=64\,000\), il affiche le nombre de tests de somme nulle de compterNaif (le compteur comparaisons) et le nombre de cases examinées par les \(n\) dichotomies de compterRapide (le compteur examinees), chacun suivi de son rapport au doublement, avec trois décimales.
Expliquer. Avant de lancer le mode, prévoir le nombre de comparaisons de
compterNaifpour \(n=2\,000\) et pour \(n=4\,000\), et leur rapport exact, puis comparer à la sortie de (b).\(n\) comparaisons prévues comparaisons affichées rapport 2 000 4 000 Observer. Lancer le mode
compareret recopier la sortie. Le lancer une seconde fois : qu’est-ce qui change ?Expliquer. Les rapports de
compterNaifne sont pas exactement 4 pour les premières lignes. Pourquoi, et vers quoi tendent-ils ?Expliquer. Comparer les rapports des cases examinées à 2 et à \(2\log_2(2n)/\log_2 n\). Comment évoluent-ils quand \(n\) grandit ? Ces comptes distinguent-ils un coût en \(n\) d’un coût en \(n\log n\), là où les durées de l’étape 5 ne le pouvaient pas ?
Expliquer. Que mesure un compte que ne mesure pas une durée, et inversement ? Citer un coût réel de
compterRapideque le compteurexamineesne voit pas.
Étape 7 : retour à la file de la Séance 1
La Séance 1 a jugé mauvaise une file servie par supprimer(0) sur la liste sur tableau. Le mode file sert \(n\) clients, de \(n=12\,500\) à \(n=200\,000\), de deux façons : par remove(0) sur une ArrayList (méthode servirListe), puis par poll() sur une java.util.ArrayDeque, un tableau circulaire (méthode servirDeque). Chaque durée est une médiane de cinq exécutions, après une chauffe.
Expliquer. Avant de lancer le mode, donner le nombre total d’éléments décalés par les \(n\) appels
remove(0), la classe \(\Theta\) de chaque façon de servir, et le rapport attendu au doublement pour chacune.\(n\) éléments décalés par remove(0)rapport prévu rapport mesuré poll(), ms12 500 — — 25 000 200 000 Observer. Lancer le mode
fileet recopier la sortie (compter quelques secondes pour la dernière ligne).Expliquer. Quels rapports de
remove(0)confirment la prévision ? Pourquoi le premier s’en écarte-t-il ?Expliquer. Les durées de
poll()permettent-elles de lire un rapport ? Que montrent-elles quand même ?Expliquer. Pour \(n=200\,000\), par combien la file sur tableau circulaire divise-t-elle la durée ? Ce facteur est-il fixe quand \(n\) double ? Conclure sur le jugement de la Séance 1.
Étape 8 : l’accès par rang, sans regarder le code
Le mode acces remplit une ArrayList et une LinkedList de \(n\) entiers, puis chronomètre \(n\) appels get(k), avec des rangs \(k\) tirés au hasard, de \(n=2\,500\) à \(n=40\,000\).
- Expliquer. Avant de lancer le mode, prévoir la classe \(\Theta\) du temps total de chaque liste, et le rapport attendu quand \(n\) double. La
LinkedListd’OpenJDK atteint le rang \(k\) depuis l’extrémité la plus proche : combien de nœuds parcourt en moyenne unget(k)avec \(k\) uniforme ? - Observer. Lancer le mode
acceset recopier la sortie. La prévision est-elle confirmée ? - Expliquer. Les durées de l’
ArrayListne croissent pas régulièrement avec \(n\). Pourquoi ne permettent-elles pas de lire un rapport ? Que faudrait-il changer pour les mesurer ?
Pour cette séance
Le cours
Les slides
À lire en ligne
À télécharger
- Code de départ du TP (ZIP, 7 Ko)