FichesCarte › Partie 09 · Coding › coding 00

Le TAP — invariant, variant, conclusion, complexité

Un problème résolu n'est pas fini tant que quatre choses ne sont pas dites : l'invariant (ce qui est vrai à chaque tour), le variant (pourquoi ça s'arrête), la conclusion (pourquoi l'invariant à la sortie donne le résultat), la complexité (composée, pas devinée). C'est la sheet-mère du coding : le format que les neuf familles c01–c09 rejouent à l'identique. Fil rouge : Two Sum, nums = [2, 7, 11, 15], target = 9. Jamais la solution complète en clair — le squelette, la trace, les quatre phrases.

Ce que cette sheet suppose acquis
  • Lire une boucle Python. for i, v in enumerate(nums) donne l'indice et la valeur ; while avec un ou deux indices ; d[k] = v écrit, k in d teste.
  • Tranche demi-ouverte. nums[a:b] contient les indices a, a+1, …, b−1 : la borne droite est exclue. On la note [a:b) et on dit « exclue » à voix haute.
  • O(·) comme ordre de grandeur. O(f(n)) = « borné par c·f(n) pour n assez grand ». On compare des ordres, on ne compte pas les constantes.
  • Dictionnaire et ensemble. Test d'appartenance et insertion en O(1) amorti (table de hachage) ; le même test sur une liste est en O(n). La différence porte toute la complexité du fil rouge.
Hypothèses posées
H1L'entrée est finie, de taille n connue avant la boucle. Tout ce qui suit parle de n, jamais des valeurs. H2Le coût se compte en opérations élémentaires — comparaison, accès indexé, accès haché amorti — toutes à coût unitaire. Pas de coût des grands entiers, pas de constantes. H3On énonce le squelette (les lignes de contrôle) et les quatre phrases ; le corps se remplit devant l'interlocuteur. Une sheet de coding ne contient jamais la solution complète : ce qui s'apprend ici n'est pas le code, c'est la preuve.

La chaîne

Le rituel : cinq cases, dans l'ordre tronc

Devant un énoncé, on ne code pas d'abord. On remplit cinq cases : signal (ce que la phrase impose), pattern (la structure qui répond au signal), squelette (les lignes de contrôle, pas le corps), TAP, trace (l'exemple déroulé, invariant affiché).

signal → pattern → squelette → TAP → trace

Les quatre lettres du TAP ne sont pas un ordre d'écriture, c'est un ordre de preuve : l'invariant dit ce qui est vrai, le variant dit qu'on s'arrête, la conclusion branche l'un sur l'autre, la complexité chiffre le prix.

« L'énoncé donne un signal, donc le signal donne un pattern, donc le pattern donne un squelette, donc le squelette se défend par quatre phrases et se vérifie sur une trace. »

Application — Two Sum

Signal. « Deux éléments », « somme égale à une cible », « renvoyer les indices », tableau non trié. Le mot « indices » interdit de trier ; « deux éléments » appelle un complément.

Pattern. Un seul passage + un dictionnaire valeur → indice des déjà vus. On échange de la mémoire contre du temps.

Squelette — les lignes de contrôle seulement :

seen = {} pour i, v dans enumerate(nums): si (target − v) dans seen : renvoyer (seen[target − v], i) seen[v] = i

seen est le conteneur de l'invariant, ni est le variant, et seen[v] = i est la ligne qui préserve l'invariant. Deux sorties — par renvoyer ou par épuisement — donc deux conclusions (pas 4).

L'invariant : conteneur, tranche, « exactement » tronc

Un invariant est une propriété inductive : vraie avant le premier tour, préservée par un tour quelconque. Trois exigences de formulation, sans quoi il n'est pas démontrable :

le conteneur a un nom  ·  la tranche est en indices [a:b)  ·  le mot exactement y est

Sans « exactement », « seen contient les valeurs de nums[0:i) » est encore vrai si seen contient tout le tableau — l'invariant ne sert plus à rien. Et « seen contient ce qu'on a vu » n'est pas un invariant du tout : aucun énoncé, donc aucune conclusion.

« L'invariant est vrai avant le premier tour et préservé par un tour, donc il est vrai à l'entrée de chaque tour, donc il l'est encore à la sortie — et c'est cette dernière instance qui sert de preuve. »

Application — l'invariant de Two Sum

À l'entrée du tour i (indice courant exclu) :

I1. seen contient exactement les valeurs de nums[0:i), chacune associée à son dernier indice.
I2. Aucune paire (j, k) avec k < i ne somme à target.

Initialisation : i = 0, nums[0:0) est vide et seen = {} — I1 et I2 tiennent. Préservation : le tour i teste toutes les paires (·, i) d'un coup, puis écrit seen[v] = i ; la tranche passe de [0:i) à [0:i+1).

Vérifié à l'exécution sur 20 000 entrées aléatoires (longueurs 0 à 7, doublons compris) : I1 et I2 tiennent à l'entrée de chaque tour, sans exception.

Le « dernier indice » n'est pas un détail. Avec un doublon, seen[v] = i écrase l'entrée précédente. La paire renvoyée reste valide et son k reste le premier qui ferme une paire, mais son j n'est pas forcément le plus petit — sur les mêmes 20 000 tirages, 358 cas où j diffère de la force brute. Un énoncé qui exige la première paire exige un autre invariant, donc un autre code.

Figure 1 — la trace, l'invariant à chaque image

Two Sum sur nums = [2, 7, 11, 15], target = 9, quatre images. Le pointeur est i ; les cases ambrées sont celles dont la valeur est dans seen ; la dernière image encadre en vert la paire renvoyée. La ligne du bas est l'invariant, réécrit à chaque image : il se lit à chaque tour, pas seulement à la fin. Fais « pas › » et dis l'invariant avant de le lire.

Le variant : l'entier qui décroît

Le variant est un entier, ≥ 0, qui décroît strictement à chaque tour. Les trois conditions ensemble interdisent une suite infinie de tours : un entier positif ne peut décroître qu'un nombre fini de fois.

ni  ·  (nr) + (nl)  ·  rl  ·  hi − lo  ·  |sommets non visités|

S'il n'existe pas, la boucle peut ne pas terminer — et c'est le seul endroit où une boucle infinie se cache. L'écrire force à vérifier que chaque branche du corps le fait décroître.

« Le variant est un entier positif qui décroît strictement à chaque tour, donc la suite des tours est finie, donc la boucle termine — et sans variant, on n'a rien dit sur la terminaison. »

Application — un variant par forme de boucle
bouclevariantdécroît : à chaque tour…
for i in range(n)nii augmente de 1
fenêtre glissante (l, r avancent)(nr) + (nl)on avance l ou r
deux pointeurs qui se croisentrll monte ou r descend
dichotomiehi − lol'intervalle est coupé au moins en deux
BFS / DFSsommets non visitésun dépilement marque un sommet neuf

Two Sum : ni vaut 4, 3, 2, 1 aux tours 0 à 3 — entier, positif, −1 par tour. Le fil rouge renvoie au tour 1 ; le variant garantit juste qu'on ne peut pas dépasser 4 tours.

Le piège du while. Si une branche du corps ne bouge ni l ni r, le variant ne décroît pas et la boucle tourne pour toujours. C'est là, et nulle part ailleurs, que se cachent les boucles infinies de deux pointeurs et de dichotomie.

La conclusion : « donc », sur le résultat

La conclusion commence par donc et porte sur le résultat, pas sur le déroulement. « donc on a parcouru tout le tableau » est vrai et inutile : personne n'en doutait, et ça ne dit pas pourquoi la réponse est juste.

conclusion = invariant instancié à la valeur finale du variant

Le variant dit la boucle s'arrête, l'invariant dit ce qui est vrai à cet endroit : leur conjonction est l'énoncé du problème. Deux sorties ⇒ deux conclusions : la sortie par renvoyer et la sortie par épuisement.

« Le variant donne la valeur finale de l'indice, donc l'invariant instancié à cette valeur est vrai, donc il dit exactement ce que le problème demandait — la conclusion porte sur le résultat, pas sur le parcours. »

Application — les deux sorties de Two Sum

Sortie par renvoyer, au tour i = k. I1 dit que seen contient exactement les valeurs de nums[0:k) avec leur indice ; le test qui vient de réussir dit que target − nums[k] y est, à un indice j < k. Donc nums[j] + nums[k] = target avec j < k : la paire renvoyée est valide.

Sortie par épuisement, i = n (variant nul). I2 sur [0:n) dit qu'aucune paire ne somme à target. Donc None est la bonne réponse.

Complétude. Si une paire (j, k) existe, au tour i = k son complément est dans seen par I1, donc la boucle ne peut pas atteindre i = n sans avoir renvoyé. Confronté à une force brute sur les 20 000 tirages : même existence, même k, paire toujours valide.

À ne pas dire : « donc on a parcouru tout le tableau ». C'est le déroulement.

La complexité : composée, pas devinée tronc

On ne devine pas une complexité, on la compose. Hors boucle, les coûts s'ajoutent ; dans une boucle, le coût du corps se multiplie par le nombre de tours — que le variant a déjà donné.

T(n) = Σ coûts hors boucle + (nombre de tours) × (coût du corps)

Un coût amorti se justifie (« chaque élément entre et sort au plus une fois »), il ne se décrète pas. L'espace se donne en fonction de la taille de l'entrée, jamais en valeur d'exécution : « 4 entrées » n'est pas une complexité, min(n, |Σ|) en est une.

« Le nombre de tours vient du variant et le coût du corps se lit ligne à ligne, donc le total est leur produit augmenté des coûts hors boucle, donc la complexité se compose au lieu de se deviner. »

Application — une boucle, deux complexités

Temps. n tours (variant ni) ; corps = un test d'appartenance + une insertion dans un dict, O(1) amorti chacun. Donc n × O(1) = O(n).

Le même squelette avec une liste de vus : le test parcourt les vus, coût i au tour i, donc Σi = n(n−1)/2 = O(n²). Une boucle, deux complexités : c'est le corps qui décide, jamais le nombre de boucles.

Opérations comptées exactement sur le code instrumenté, pire cas (aucune paire, la boucle va au bout) :

ndict — 2nliste — n(n−1)/2 + 2n
102065
1002005 150
1 0002 000501 500

Espace. seen a au plus une entrée par valeur distincte rencontrée, donc O(min(n, |valeurs distinctes|)). Sur le fil rouge il finit à 1 entrée : c'est une valeur d'exécution, pas une complexité. Les autres formes utiles : O(h) pour la pile de récursion sur un arbre de hauteur h, O(n) pour un set de vus, O(1) pour deux pointeurs.

Figure 2 — le corps décide, pas la boucle

Opérations élémentaires comptées sur les deux versions du même squelette, pire cas : dict (2n) et liste (n(n−1)/2 + 2n). Déplace n : la courbe du dict reste collée à l'axe. Le nombre de boucles est le même dans les deux cas — seul le coût du corps change, et c'est lui qui fait passer de O(n) à O(n²).

Où le TAP se rate casse

Chacune de ces erreurs produit une réponse qui sonne juste et ne prouve rien. C'est exactement ce que l'interlocuteur écoute : pas si tu sais coder Two Sum, mais si ce que tu dis démontre quelque chose.

« Un invariant sans conteneur ni tranche n'est pas démontrable, une conclusion sur le déroulement ne porte pas sur le résultat, une complexité devinée ignore le coût du corps, donc la réponse sonne juste et ne prouve rien. »

Les cinq façons de rater
  • Invariant sans conteneur ni tranche. « On a vu les éléments » : rien à initialiser, rien à préserver, rien à instancier à la sortie. Indémontrable, donc faux.
  • Conclusion sur le déroulement. « On a tout parcouru » : vrai, et sans rapport avec la justesse du résultat.
  • Espace en valeur d'exécution. « 4 entrées » au lieu de min(n, |valeurs distinctes|) : un nombre au lieu d'une fonction de n.
  • Complexité devinée. « O(n) parce qu'il y a une boucle » — sans lire le corps. Un in sur une liste dans la boucle donne O(n²) avec exactement la même boucle.
  • Variant oublié sur un while. Deux pointeurs, dichotomie : sans variant, rien ne dit que la boucle s'arrête, et c'est là que la boucle infinie attend.
Pièges Python — les mêmes erreurs, en code
  • in ne dit pas sa structure. x in vus coûte O(1) amorti si vus est un set ou un dict, O(n) si c'est une list. La ligne est la même, la complexité de la boucle ne l'est pas.
  • Muter ce qu'on itère. for x in a: a.remove(x) casse l'invariant : la tranche parcourue n'est plus celle qu'on croit. Itérer sur une copie, ou construire une nouvelle liste.
  • d[k], d.get(k), k in d : trois lignes, trois invariants. La première lève KeyError, la deuxième renvoie None — et None peut être une valeur légitime, auquel cas d.get(k) is None ne veut pas dire « absent ».
  • defaultdict insère à la lecture. Lire d[k] crée l'entrée : l'invariant « d contient exactement les clés vues » devient faux sans qu'aucune ligne d'écriture n'apparaisse.
  • // et le milieu. mid = (lo + hi) // 2 ne déborde pas en Python (entiers non bornés), mais garder lo + (hi − lo) // 2 transporte l'habitude aux langages où il déborde — et c'est la même ligne qu'on écrit en entretien Java ou C++.
  • enumerate plutôt que range(len(...)). Le TAP est identique ; les occasions de se tromper d'indice sont doublées dans la seconde forme, et un invariant faux sur un indice ne se voit pas à la relecture.

Le même rituel, neuf familles

Ce qui change d'une famille à l'autre est le conteneur de l'invariant et la forme du variant. Le rituel, lui, ne change pas : c'est cette sheet, neuf fois.

Apprendre le coding, c'est donc apprendre à lire le signal dans l'énoncé — les trois ou quatre mots qui nomment le pattern — pas à mémoriser du code.

« Le signal de l'énoncé nomme le pattern, donc le pattern fixe le conteneur de l'invariant et la forme du variant, donc les quatre phrases s'écrivent avant la première ligne de code. »

Cartes « signal → pattern »
signal dans l'énoncépatternconteneur de l'invariantvariant
« sous-tableau contigu », « au plus k … »c01 · fenêtre glissantela fenêtre [l:r) et son agrégat(nr) + (nl)
« trié », « une paire », « sans mémoire auxiliaire »c02 · deux pointeursle segment [l:r] encore possiblerl
« trié », « le premier indice tel que »c03 · dichotomie[lo:hi) contient la réponsehi − lo
« en place », « inverser », « cycle »c04 · listes chaînéesle préfixe déjà retournénœuds non visités
« sous-arbre », « profondeur », « BST »c05 · arbresle chemin racine → nœud, la pile d'appelsnœuds non visités
« composantes », « plus court en nombre d'arêtes »c06 · graphesl'ensemble des visitéssommets non visités
« les k plus grands », « un flux »c07 · tasle tas des k meilleurs de [0:i)ni
« fusionner », « chevauchement »c08 · intervallesles fusionnés de [0:i), triésni
« nombre de façons », « maximum sur des choix »c09 · DP 1Ddp[0:i) = réponse sur chaque préfixeni

Trois variants sur neuf sont ni : la boucle simple est la forme par défaut, et les six autres lignes sont exactement les cas où elle ne suffit pas.

Résumé

À retenir
  1. Un problème n'est fini que quand quatre phrases sont dites : invariant, variant, conclusion, complexité.
  2. Invariant : inductif (vrai avant, préservé par un tour), conteneur nommé, tranche en indices demi-ouverts, le mot « exactement ».
  3. Variant : un entier ≥ 0 qui décroît strictement. Sans lui, rien n'a été dit sur la terminaison.
  4. Conclusion : commence par « donc », porte sur le résultat, = l'invariant instancié à la valeur finale du variant. Une conclusion par sortie.
  5. Complexité : + hors boucle, × dans la boucle, amortissement justifié. C'est le coût du corps qui décide, pas le nombre de boucles.
  6. Espace en fonction de la taille de l'entrée — min(n, |Σ|), O(h), O(n) — jamais en valeur d'exécution.
  7. D'une famille à l'autre, seuls changent le conteneur de l'invariant et la forme du variant. Le rituel est le même neuf fois.
« Avant d'écrire, je dis ce qui est vrai à chaque tour, sur quel conteneur et quelle tranche ; ce qui décroît et garantit l'arrêt ; pourquoi l'invariant à la sortie est le résultat ; et je compose la complexité au lieu de la deviner. »

Chaîne verbalisée — une prise, à voix haute

5 maillons · clique pour révéler après avoir dit
  1. Énonce l'invariant de Two Sum.
    À l'entrée du tour i : seen contient exactement les valeurs de nums[0:i), chacune avec son dernier indice ; et aucune paire (j, k) avec k < i ne somme à target.
  2. Le variant ?
    n − i : entier, ≥ 0, décroît de 1 à chaque tour, donc la boucle termine en au plus n tours.
  3. La conclusion, en commençant par donc.
    Donc au tour k le complément de la paire est dans seen, donc elle est renvoyée ; et si la boucle finit, l'invariant sur [0:n) dit qu'aucune paire n'existe, donc None.
  4. La complexité, composée.
    n tours × O(1) par tour (test + insertion dans un dict) = O(n) en temps ; espace O(min(n, |valeurs distinctes|)). Avec une liste au lieu du dict : Σi = n(n−1)/2, donc O(n²).
  5. Ton invariant tient-il avec des doublons ?
    Oui — c'est pour ça que I1 dit « son dernier indice » : seen[v] = i écrase. La paire renvoyée reste valide et son k est le premier qui ferme, mais son j n'est pas forcément le plus petit.