Files
2026_TD3_DEV51_Qualite_Algo/COMPTE_RENDU.md
T

13 KiB
Raw Blame History

Compte rendu du TD de complexité algorithmique

Ce compte rendu traite le palier 1 et l'étape 4 du TD (page 68 de R5A04_Algorithmique_MM.pdf) : analyser la version initiale, puis atteindre les objectifs avec un commit par optimisation.

Les mesures ont été réalisées le 21 octobre 2026 sur Linux x86_64, avec GCC 16.2.1 et GNU gprof 2.47. Les sections 1 à 3 décrivent la version initiale. Les données sont reproductibles grâce à la graine fixe du générateur.

Paramètre Valeur
Transactions, n 30 000
Clients, C 2 000
Jours, J 1 000
Requêtes par identifiant, Q 30 000
Somme à rendre, S 400 centimes
Types de pièces, k 8

1. Mesurer la version initiale

Le Makefile compile le programme avec gcc -std=c11 -Wall -Wextra -Wpedantic -O2 banque.c -o build/banque. La compilation s'est terminée sans avertissement. Les commandes utilisées pour compiler et enregistrer une mesure sont les suivantes, depuis la racine :

make -B all
(cd build && { time -p ./banque > sortie.txt; } 2> temps_avant.txt)
cat build/sortie.txt build/temps_avant.txt

Résultat de l'exécution mesurée :

Clients distincts   : 2000
Somme des requêtes  : -2624578
Meilleure période   : jours 650 à 986 (3548830)
Paires opposées     : 5832
Façons de rendre 400 : 1960497
Empreinte           : ff68d260a7a58441
real 2.43
user 2.39
sys 0.00

Le temps réel est de 2,43 s. Le temps CPU passé dans le programme est de 2,39 s ; le temps système est arrondi à 0,00 s. Cette mesure porte sur une exécution et peut varier selon la charge de la machine.

L'empreinte obtenue est bien ff68d260a7a58441, celle attendue dans le TD. Le rapport produit contient 6 017 lignes, soit 120 348 octets. Les résultats sont conservés dans sortie.txt et temps_avant.txt.

2. Profiler et comparer aux complexités

Le profil a été généré avec les commandes suivantes :

make profile
cp build/profil/profil.txt build/profil/profil_avant.txt

La cible profile utilise une compilation en -O0 -g -pg, exécute le programme, puis lance gprof -b ./banque gmon.out dans build/profil. Le profil initial est conservé dans profil_avant.txt.

Cette exécution produit également l'empreinte attendue. Les fichiers build/rapport.txt et build/profil/rapport.txt ont été comparés : ils sont identiques octet par octet.

Résultats de gprof

Le tableau reprend les temps propres du profil plat : ils correspondent au temps passé dans chaque fonction, hors fonctions appelées. Les pourcentages portent sur environ 6,44 s de temps CPU échantillonné.

Fonction Appels Temps propre (s) Part du temps
F1 — trier_par_montant 1 3,13 48,60 %
F6 — rechercher_par_id 30 000 0,85 13,20 %
F7 — nb_paires_opposees 1 0,76 11,80 %
F8 — nb_facons_rendre 1 appel initial 0,66 10,17 %
longueur, appelée par F10 6 017 0,41 6,37 %
F5 — meilleure_periode 1 0,32 4,97 %
F2 — nb_clients_distincts 1 0,11 1,71 %
F3 — total_par_client 1 0,11 1,71 %
F4 — total_par_jour 1 0,05 0,78 %
F9 — categorie 30 000 0,03 0,39 %
F10 — ajouter_ligne 6 017 0,00 0,00 %

Le graphe d'appels précise que F8 effectue 266 723 738 appels récursifs, en plus de l'appel initial. Le compteur du profil plat ne montre que cet appel initial. Les écarts entre les pourcentages et les temps affichés s'expliquent par les arrondis ; quelques échantillons sont aussi attribués à _init.

Le temps cumulé de F10, en incluant longueur, est d'environ 0,41 s. Son temps propre affiché à 0,00 s ne signifie donc pas que la construction du rapport est gratuite. Plus généralement, une valeur nulle dans ce profil ne prouve pas l'absence de coût : la période d'échantillonnage est de 0,01 s.

Ces temps ne se comparent pas directement aux 2,43 s de la première étape : le profil utilise -O0 et l'instrumentation -pg, tandis que la mesure initiale utilise -O2. Le profil décrit le comportement de cette version instrumentée, pour ces données et sur cette machine.

Les fonctions les plus coûteuses ont-elles les pires complexités ?

Non, pas systématiquement. La complexité décrit la croissance du nombre d'opérations lorsque la taille des entrées augmente. Le profil mesure le coût d'une exécution précise. Ce coût dépend aussi des tailles effectivement utilisées, du nombre d'appels, des données et du travail effectué à chaque itération.

  • F1 : le tri quadratique domine ici. Le tri à bulles effectue n(n - 1) / 2, soit 449 985 000 comparaisons de paires voisines, avec des échanges de structures lorsque les transactions sont mal ordonnées. Sa complexité est O(n²) et il représente 48,60 % du temps échantillonné.

  • F5 : une complexité cubique ne suffit pas à en faire la fonction la plus lente. Ses trois boucles donnent une complexité O(J³), mais elles portent sur 1 000 jours, alors que F1 traite 30 000 transactions. F5 effectue J(J + 1)(J + 2) / 6, soit 167 167 000 additions, et prend environ 0,32 s, contre 3,13 s pour F1. Les variables et les tailles diffèrent : comparer uniquement les exposants serait trompeur.

  • F6 : le nombre d'appels compte. Une recherche par identifiant est en O(n) au pire cas, mais main l'effectue 30 000 fois. Le coût de l'ensemble des recherches est donc O(Q × n), soit O(n²) lorsque Q = n. Avec 0,85 s au total, F6 arrive devant F5 malgré sa complexité linéaire par appel.

  • F7 : une même classe de complexité ne donne pas un même temps. F7 examine elle aussi 449 985 000 paires, en O(n²), mais se limite à additionner deux montants, comparer le résultat à zéro et éventuellement incrémenter un compteur. Elle prend 0,76 s, environ quatre fois moins que F1, dont les itérations peuvent déplacer des transactions entières.

  • F8 : les sous-problèmes recalculés multiplient les appels. Le rendu de monnaie utilise une récursion sans mémorisation et dépasse 266 millions d'appels récursifs. Son coût dépend de S et de k, ici 400 et 8. Malgré cet arbre d'appels volumineux, son temps de 0,66 s reste inférieur à celui du tri ; le nombre d'appels ne suffit pas non plus à classer les temps.

  • F10 : il faut tenir compte des fonctions appelées et des appels répétés. Chaque ajout parcourt tout le rapport existant avec longueur, puis copie la nouvelle ligne. Pour un préfixe de longueur p et une ligne de longueur l, un appel coûte O(p + l). Sur m ajouts de lignes de taille bornée, les parcours successifs coûtent O(m²), soit O(L²) dans ce cas, où L est la longueur finale du rapport. Pour le rapport obtenu, les recherches de fin de chaîne relisent 340 702 496 caractères de préfixes, ce qui explique les 0,41 s attribuées à longueur.

Pour cette exécution instrumentée, F1, F6 et F7 représentent ensemble 73,60 % du temps échantillonné : ce sont les premières pistes d'optimisation indiquées par le profil.

3. Calculer les complexités de F1 à F10

Le tableau reprend l'analyse initiale de notes.md, avec la complexité cyclomatique de chaque fonction. Les complexités temporelles sont données au pire cas, en faisant varier les tailles des données, même si leurs valeurs sont fixées par des constantes dans le programme. Les objectifs sont ceux du tableau de la page 67 du TD ; ils restent à atteindre.

ID Fonction Calcule Actuelle M Objectif
F1 trier_par_montant tri des transactions O(n²) 6 O(n log n)
F2 nb_clients_distincts clients différents O(n²) 5 O(n + C)
F3 total_par_client solde par client O(n × C) 4 O(n + C)
F4 total_par_jour solde par jour O(n × J) 4 O(n + J)
F5 meilleure_periode période la plus rentable O(J³) 5 O(J)
F6 rechercher_par_id recherche par identifiant O(n) par requête 3 O(1) par requête
F7 nb_paires_opposees paires a + b = 0 O(n²) 4 O(n log n)
F8 nb_facons_rendre rendu de monnaie O(2^(S + k)), borne générale 4 O(S × k)
F9 categorie catégorie d'une transaction O(1) 14 M ≤ 10
F10 ajouter_ligne construction du rapport O(p + l) par ajout ; O(L²) au total pour des lignes de taille bornée 2 O(L) au total

n : transactions ; C : clients ; J : jours ; Q : requêtes ; S : somme à rendre ; k : types de pièces ; L : longueur finale du rapport ; p : longueur du rapport avant un ajout ; l : longueur de la ligne ajoutée. F5, F7 et F8 sont signalées comme les plus difficiles dans le TD.

Justification des complexités temporelles

  • F1 et F7 : les deux boucles parcourent n(n - 1) / 2 paires. Le travail par paire est constant, d'où O(n²).
  • F2 : pour chaque transaction d'indice i, la recherche d'un client déjà rencontré peut parcourir les i transactions précédentes. La somme 0 + 1 + … + (n - 1) donne la borne O(n²), atteinte si les clients sont tous différents (C ≥ n).
  • F3 et F4 : chaque client, puis chaque jour, provoque un parcours complet des n transactions : respectivement O(C × n) et O(J × n).
  • F5 : chaque intervalle de jours est essayé, et sa somme est recalculée par une troisième boucle. Le nombre d'additions est J(J + 1)(J + 2) / 6, d'où O(J³).
  • F6 : une recherche peut lire les n transactions, soit O(n). Les Q recherches de main coûtent donc O(Q × n) au pire cas.
  • F8 : sans mémorisation, le coût suit la récurrence T(S, k) = T(S - pièce[k - 1], k) + T(S, k - 1) + O(1). Chaque appel non terminal engendre deux appels ; chaque branche diminue S ou k d'au moins 1. La profondeur est donc au plus S + k, ce qui donne la borne générale O(2^(S + k)). Cette borne est large : pour k fixé, on peut affiner en O(S^k). La notation O(Sⁿ) des notes initiales ne convient pas, car n désigne les transactions.
  • F9 : le nombre de tests est borné, indépendamment de n, d'où O(1). L'objectif porte ici sur la complexité cyclomatique, pas sur le temps.
  • F10 : longueur lit les p caractères du rapport, puis la fonction copie les l caractères de la ligne : O(p + l) par ajout. Pour m lignes non vides de taille bornée, les préfixes relus ont des longueurs proportionnelles à 0, 1, …, m - 1 : le coût total est O(m²), soit O(L²) dans ce cas.

Calcul manuel de la complexité cyclomatique

La convention du cours (pages 31–32) est M = nombre de décisions + 1. Chaque if, else if, for, while, ainsi que chaque opérateur && ou ||, ajoute une décision. else, return et break n'en ajoutent pas. Les décisions d'une fonction appelée se comptent dans cette fonction.

  • F1 : 2 for + 1 if + 1 || + 1 && = 5 décisions, donc M = 6.
  • F2 : 2 for + 2 if = 4 décisions, donc M = 5.
  • F3, F4 et F7 : 2 for + 1 if = 3 décisions chacune, donc M = 4.
  • F5 : 3 for + 1 if = 4 décisions, donc M = 5.
  • F6 : 1 for + 1 if = 2 décisions, donc M = 3.
  • F8 : 2 if + 1 || = 3 décisions, donc M = 4.
  • F9 : 11 if ou else if + 1 || + 1 && = 13 décisions, donc M = 14. C'est la plus grande valeur de F1 à F10.
  • F10 : 1 while = 1 décision, donc M = 2. La fonction auxiliaire longueur contient elle aussi un while et a M = 2, compté séparément.

4. Atteindre les objectifs

Chaque optimisation est vérifiée avant son commit : compilation avec les avertissements du Makefile, tests de cas limites via make test, exécution en -O2, empreinte ff68d260a7a58441 et comparaison du rapport complet avec celui de la version initiale. Les constantes, alea, generer_transactions et le format du rapport sont conservés.

F1 — Tri fusion

Le tri à bulles est remplacé par un tri fusion : chaque moitié est triée, puis les deux moitiés sont fusionnées en un passage. La comparaison porte sur le montant, puis sur l'identifiant, sans soustraction susceptible de déborder. La récurrence T(n) = 2T(n/2) + O(n) donne O(n log n) au pire cas, avec O(n) de mémoire auxiliaire et O(log n) de pile.

La complexité cyclomatique de F1 passe de 6 à 3 ; les auxiliaires comparer_transactions et trier_intervalle ont respectivement M = 2 et M = 6. Les tests comparent le résultat à un tri de référence pour les tailles 0 à 128, des doublons, des entrées déjà triées ou inversées, et les bornes des types int et long.