LYCÉE → PRÉPA · L04

Module L04 · Partie F · Coder comme un professionnel

Quatre façons d’attaquer un problème.

Face à un problème nouveau, un informaticien ne cherche pas « l’algorithme » : il reconnaît une stratégie. Diviser pour régner quand les sous-problèmes sont indépendants. Programmation dynamique quand ils se chevauchent. Glouton quand un choix local suffit. Backtracking quand il faut explorer. Ce module apprend à reconnaître, appliquer et prouver.

Durée : 3 séances · Prérequis : récursivité, complexité, L03. Objectifs : maîtriser les quatre paradigmes, savoir passer d’une récursion exponentielle à une table en O(n²), reconnaître les problèmes classiques (sac à dos, distance d’édition, plus longue sous-suite, intervalles, Huffman, n reines, sudoku).

Ce que vous saurez faire à la fin
  • Écrire la récurrence d’un problème puis la mémoïser ou la tabuler.
  • Prouver qu’un glouton est correct (argument d’échange) ou trouver un contre-exemple.
  • Élaguer un backtracking pour qu’il termine en secondes au lieu d’années.
  • Résoudre les exercices d’algorithmique des concours X-ENS (épreuve d’informatique).

Références : CLRS chapitres 15-16, Algorithm Design (Kleinberg-Tardos), sujets d’informatique du concours Centrale/Mines/X.

Fiche de cours · Définitions

Les quatre stratégies et leur vocabulaire

Définition (diviser pour régner). Découper l’instance en sous-instances indépendantes de même nature, les résoudre récursivement, combiner. Le coût vérifie une récurrence T(n) = a·T(n/b) + f(n).
Définition (programmation dynamique). Résoudre un problème dont les sous-problèmes se chevauchent en les mémorisant (table ou mémoïsation) pour ne les calculer qu’une fois. Elle exige la sous-structure optimale : une solution optimale est composée de solutions optimales de sous-problèmes.
Définition (algorithme glouton). Construire la solution par choix locaux irrévocables (le « meilleur » à chaque étape). Il est correct seulement si le problème a la propriété du choix glouton : un choix localement optimal fait partie d’une solution globalement optimale.
Définition (retour sur trace, backtracking). Explorer l’arbre des choix partiels en profondeur, en abandonnant une branche dès qu’elle viole une contrainte (élagage). Complexité exponentielle dans le pire cas, souvent excellente en pratique.
Définition (réduction). Transformer une instance du problème A en une instance de B telle que la réponse soit la même : si l’on sait résoudre B, on sait résoudre A. Sert à réutiliser un algorithme (A ≤ B) comme à prouver une difficulté (L23).

Fiche de cours · Formules

Récurrences : le théorème maître et ses cas usuels

T(n) = a·T(n/b) + Θ(nd) ⟹ T(n) = Θ(nd) si a < bd ; Θ(nd log n) si a = bd ; Θ(nlogb a) si a > bdcomparer le nombre de feuilles alogb n = nlogb a au travail de la racine nd
RécurrenceSolutionAlgorithme
T(n) = T(n/2) + O(1)Θ(log n)Dichotomie
T(n) = 2T(n/2) + O(n)Θ(n log n)Tri fusion, tri rapide (moyenne)
T(n) = 2T(n/2) + O(1)Θ(n)Parcours d’arbre équilibré
T(n) = T(n − 1) + O(n)Θ(n²)Tri rapide (pire cas), sélection naïve
T(n) = T(n − 1) + O(1)Θ(n)Récursion linéaire (somme, factorielle)
T(n) = 2T(n − 1) + O(1)Θ(2ⁿ)Fibonacci naïf, sous-ensembles
T(n) = 8T(n/2) + O(n²) ; 7T(n/2) + O(n²)Θ(n³) ; Θ(n2,81)Produit matriciel naïf ; Strassen

Formules de dénombrement pour la force brute : n! permutations, 2ⁿ sous-ensembles, C(n,k) = n!/(k!(n−k)!) parties à k éléments, kⁿ suites de longueur n sur k symboles. Formule du binôme : (a+b)ⁿ = Σ C(n,k)akbn−k.

Fiche de cours · Théorèmes et démonstrations

Démonstrations à savoir refaire (1/2)

Théorème 1 (tri fusion en Θ(n log n)). Le tri fusion trie n éléments en Θ(n log n) comparaisons.
Fusionner deux listes triées de tailles p et q coûte au plus p + q − 1 comparaisons (chaque comparaison place un élément définitivement). D’où T(n) ≤ 2T(n/2) + n, T(1) = 0. Déroulons pour n = 2k : T(n) ≤ n + 2·(n/2) + 4·(n/4) + … (k termes) = k·n = n log₂ n. L’arbre de récursion a log₂ n niveaux, chacun coûtant n au total. Borne inférieure du même ordre car chaque niveau fait au moins n/2 comparaisons. Donc Θ(n log n), quel que soit l’ordre initial.
Théorème 2 (borne inférieure des tris par comparaison). Tout algorithme qui trie n éléments distincts en ne les examinant que par comparaisons effectue Ω(n log n) comparaisons dans le pire cas.
L’exécution de l’algorithme est un arbre de décision binaire : chaque nœud est une comparaison, chaque feuille une permutation de sortie. Comme l’algorithme doit produire une sortie différente pour chacune des n! permutations possibles de l’entrée, l’arbre a au moins n! feuilles. Un arbre binaire de hauteur h a au plus 2h feuilles, donc 2h ≥ n!, soit h ≥ log₂(n!) ≥ log₂((n/2)n/2) = (n/2)·log₂(n/2) = Ω(n log n). La hauteur est le nombre de comparaisons dans le pire cas. (Le tri par comptage contourne la borne : il n’est pas un tri par comparaison.)
Théorème 3 (propriété de sous-structure optimale du plus court chemin). Si P = (s, …, u, …, t) est un plus court chemin de s à t, alors sa portion de s à u est un plus court chemin de s à u.
Par l’absurde : s’il existait un chemin de s à u strictement plus court, on le concaténerait avec la portion (u, …, t) de P pour obtenir un chemin de s à t strictement plus court que P — contradiction. C’est ce qui autorise la programmation dynamique (Bellman-Ford, Floyd-Warshall) et Dijkstra (L05). Remarque : le plus long chemin simple n’a pas cette propriété (un sous-chemin d’un plus long chemin simple peut ne pas être le plus long, à cause de la contrainte « sans répétition ») — d’où sa difficulté (NP-difficile).

Fiche de cours · Théorèmes et démonstrations

Démonstrations à savoir refaire (2/2) : les gloutons

Théorème 4 (ordonnancement d’intervalles). Étant donné n intervalles [di, fi[, choisir toujours l’intervalle compatible qui finit le plus tôt donne un ensemble maximal d’intervalles deux à deux disjoints.
Argument d’échange. Soit G = (g₁, …, gk) les choix gloutons (par fin croissante) et O = (o₁, …, om) une solution optimale triée par fin. Montrons par récurrence que f(gj) ≤ f(oj) pour j ≤ k. j = 1 : g₁ est l’intervalle qui finit le plus tôt de tous. Hérédité : oj+1 commence après f(oj) ≥ f(gj), donc oj+1 est compatible avec g₁…gj ; le glouton choisit parmi les compatibles celui qui finit le plus tôt, donc f(gj+1) ≤ f(oj+1). Si m > k, ok+1 commencerait après f(ok) ≥ f(gk) et serait donc encore disponible pour le glouton, qui ne se serait pas arrêté — contradiction. Donc k = m.
Théorème 5 (rendu de monnaie). Avec les pièces {1, 2, 5, 10, 20, 50, 100, 200}, le glouton (plus grande pièce possible) est optimal. Avec {1, 3, 4}, il ne l’est pas.
Contre-exemple pour {1, 3, 4} : 6 = 4 + 1 + 1 (glouton, 3 pièces) mais 6 = 3 + 3 (2 pièces). Pour le système européen, on vérifie que dans toute solution optimale, le nombre de pièces de chaque valeur est borné (au plus une pièce de 1, 2, 5 sauf combinaisons… : deux 1 → un 2 ; deux 2 et un 1 → un 5 ; deux 5 → un 10 ; etc.), de sorte que la somme des pièces strictement inférieures à une valeur v ne dépasse jamais v − 1 ; le glouton doit donc prendre la plus grande pièce ≤ montant, comme l’optimal. La preuve générale se fait par cette vérification exhaustive des « petits » montants — et la programmation dynamique (table des montants) sert d’oracle pour tester n’importe quel système de pièces.

Fiche de cours · Méthodes

Méthodes et pièges

Méthode — concevoir une programmation dynamique. (1) Définir précisément le sous-problème avec ses paramètres : « D[i][j] = coût minimal pour … les i premiers … avec capacité j ». (2) Écrire la récurrence en énumérant le dernier choix. (3) Cas de base. (4) Ordre de remplissage (dépendances vers des indices plus petits) ou mémoïsation. (5) Complexité = nombre de sous-problèmes × coût d’une récurrence. (6) Reconstruire la solution en mémorisant le choix (argmin).
Méthode — prouver ou réfuter un glouton. Chercher d’abord un petit contre-exemple (3 à 5 éléments) ; s’il n’y en a pas, tenter l’argument d’échange : « toute solution optimale peut être transformée, sans perdre en qualité, en une solution qui fait le même premier choix que le glouton ».
Méthode — écrire un backtracking. def explorer(partiel): if complet(partiel): enregistrer ; else: for choix in candidats(partiel): if valide(partiel + choix): explorer(partiel + choix). Élaguer tôt (vérifier les contraintes au plus tôt), ordonner les candidats (les plus contraints d’abord), et borner (branch and bound : abandonner si la meilleure extension possible ne bat pas le record).

Pièges : mémoïser une fonction dont les arguments sont des listes (non hachables : convertir en tuple) ; récurrence dont un cas fait appel au même sous-problème (boucle infinie) ; oublier que la PD sur les valeurs (sac à dos, sous-somme) est pseudo-polynomiale ; croire qu’un glouton « qui marche sur mes exemples » est prouvé.

Fiche de cours · Exercices corrigés

Exercices corrigés

Exercice 1. Résoudre T(n) = 3T(n/2) + n et T(n) = 4T(n/2) + n² par le théorème maître, puis donner un exemple d’algorithme pour chacune.
Correction. (a) a = 3, b = 2, d = 1 : bd = 2 < 3 = a, cas « feuilles dominantes » : T(n) = Θ(nlog₂ 3) = Θ(n1,585) — multiplication de Karatsuba (3 produits de moitiés + additions linéaires). (b) a = 4, b = 2, d = 2 : bd = 4 = a : T(n) = Θ(n² log n) — par exemple un algorithme qui divise une image n×n en 4 quarts et fait un travail quadratique à chaque niveau.
Exercice 2. Plus longue sous-suite commune (LCS) de deux chaînes A (longueur n) et B (longueur m) : définir le sous-problème, la récurrence, la complexité, et calculer LCS("ABCBDAB", "BDCABA").
Correction. L[i][j] = longueur de la LCS de A[:i] et B[:j]. Récurrence : L[i][j] = L[i−1][j−1] + 1 si A[i−1] = B[j−1], sinon max(L[i−1][j], L[i][j−1]) ; L[0][·] = L[·][0] = 0. Justification (dernier choix) : si les derniers caractères coïncident, une LCS optimale peut les apparier (échange) ; sinon au moins l’un des deux n’est pas utilisé. Θ(nm) cases, O(1) chacune : Θ(nm) temps, Θ(min(n, m)) mémoire en ne gardant que deux lignes. Pour l’exemple, la table donne L[7][6] = 4, par exemple « BCBA » ou « BDAB ».
Exercice 3. Le problème du sac à dos fractionnaire (on peut prendre une fraction d’un objet) admet un glouton optimal ; le sac à dos 0/1 non. Énoncer le glouton et prouver/réfuter dans chaque cas.
Correction. Glouton : trier par valeur/poids décroissant, remplir dans cet ordre. Fractionnaire — preuve par échange : si une solution optimale ne remplit pas entièrement l’objet de meilleur ratio r₁ alors qu’elle contient une quantité d’un objet de ratio r ≤ r₁, on déplace du poids ε de ce dernier vers le premier : la valeur varie de ε(r₁ − r) ≥ 0, sans dépasser la capacité ; par récurrence on transforme toute solution optimale en la solution gloutonne sans perte. 0/1 — contre-exemple : capacité 10, objets (poids 6, valeur 6 ; ratio 1), (5, 4,5 ; 0,9), (5, 4,5 ; 0,9). Le glouton prend le premier (6) puis rien ne rentre : valeur 6 ; l’optimal prend les deux autres : 9. La PD D[i][c] = max(D[i−1][c], D[i−1][c−pi] + vi) résout le 0/1 en O(n·C).

01 / Diviser pour régner

Couper en deux, résoudre, recoller

Le théorème maître, en pratique

Une récurrence T(n) = a·T(n/b) + O(nd) se résout ainsi : si a < bd → O(nd) ; si a = bd → O(nd log n) ; si a > bd → O(nlog_b a). Tri fusion : a = 2, b = 2, d = 1 → O(n log n). Recherche dichotomique : a = 1, b = 2, d = 0 → O(log n). Multiplication de Karatsuba : 3 produits de taille n/2 au lieu de 4, a = 3, b = 2, d = 1 → O(n1,585) : c’est ainsi que Python multiplie les grands entiers. Strassen fait de même pour les matrices (7 produits au lieu de 8 → O(n2,807)).

01 / Diviser pour régner

Puissance rapide et exponentiation matricielle : Fibonacci en O(log n)

Le même code sert aux entiers, aux entiers modulo p (RSA, Diffie-Hellman) et aux matrices : la seule hypothèse est l’associativité. Passer un paramètre mul est le patron Stratégie du module L02.

02 / Programmation dynamique

Quand les sous-problèmes se recoupent : mémoriser

La recette
  1. Définir l’état : « escalier(n) = nombre de façons de monter n marches ». L’état doit contenir exactement l’information nécessaire pour la suite.
  2. Écrire la récurrence : escalier(n) = escalier(n−1) + escalier(n−2) + escalier(n−3), avec les cas de base.
  3. Choisir : mémoïsation (récursif + cache, simple, ordre automatique) ou tabulation (itératif, plus rapide, mémoire contrôlable).
  4. Compter : nombre d’états × coût par état = complexité. Ici n états × O(1) = O(n) au lieu de O(3n).

La programmation dynamique s’applique quand le problème a une sous-structure optimale (la solution se construit à partir de solutions de sous-problèmes) et des sous-problèmes qui se chevauchent (sinon, diviser pour régner suffit).

02 / Programmation dynamique

Sac à dos et rendu de monnaie

Le sac à dos est NP-difficile en général, mais O(n·C) quand les poids sont entiers et C raisonnable : on dit pseudo-polynomial. C’est un exemple parfait pour le module L23.

02 / Programmation dynamique

Distance d’édition : ce que fait votre correcteur orthographique

La même table sert à git diff (plus longue sous-suite commune de lignes), à l’alignement de séquences en biologie (Needleman-Wunsch) et à la reconnaissance vocale (DTW). O(n·m) en temps ; la mémoire peut descendre à O(min(n, m)) en ne gardant que deux lignes.

03 / Gloutons

Un choix local suffit… parfois

Prouver un glouton : l’argument d’échange

Pour la planification : soit une solution optimale O qui ne commence pas par l’activité a₁ qui finit le plus tôt. Remplaçons la première activité de O par a₁ : comme a₁ finit plus tôt, elle est compatible avec le reste de O. On obtient une solution optimale qui commence par a₁. Par récurrence sur le reste, le glouton est optimal. Cet « échange » sans perte est la structure de toutes les preuves de gloutons (Huffman, Kruskal, Dijkstra). Quand l’échange peut faire perdre quelque chose — rendu de monnaie avec pièces {1, 3, 4}, sac à dos 0/1 — le glouton échoue et il faut la programmation dynamique.

04 / Backtracking

Explorer, et surtout élaguer

Les trois leviers
  1. Vérifier tôt : ne pas remplir toute la grille avant de tester ; refuser un candidat dès qu’il viole une contrainte.
  2. Choisir la variable la plus contrainte (MRV : minimum remaining values) : une case à 1 candidat se remplit sans risque ; une case à 0 candidat fait rebrousser immédiatement.
  3. Ordonner les valeurs : essayer d’abord celles qui contraignent le moins les voisins (LCV).

Sans ces leviers, un sudoku difficile prend des heures ; avec, des millisecondes. Le backtracking est la base de la résolution de contraintes (CSP), de SAT (module L17) et de la planification. Il est exponentiel dans le pire cas — mais le pire cas est rare quand l’élagage est bon.

04 / Backtracking

Générer : permutations, sous-ensembles, partitions

Les générateurs (yield) rendent l’exploration paresseuse : on peut s’arrêter à la première solution sans avoir calculé les autres. itertools fournit permutations, combinations, product en C.

05 / Reconnaître

Quelle stratégie ? Le guide de décision

Indice dans l’énoncéStratégieExemples
Sous-problèmes indépendants de taille n/bDiviser pour régnerTri fusion, dichotomie, Karatsuba, plus proche paire de points
« Nombre de façons », « minimum/maximum sur une séquence », sous-problèmes réutilisésProgrammation dynamiqueSac à dos, monnaie, édition, LCS, chemins dans une grille, découpe de tige
Un choix « évident » à chaque étape ; structure d’intervalles ou de matroïdeGloutonActivités, Huffman, Kruskal, Dijkstra, monnaie canonique
Contraintes à satisfaire, espace de recherche combinatoireBacktrackingSudoku, n reines, coloration, SAT, énumération
Optimum d’une fonction continueMéthodes numériques (L12)Descente de gradient, Newton
Réseau, relations entre entitésGraphes (L05)Plus court chemin, flot, couplage

Méthode d’examen : 1) reformuler le problème en une phrase mathématique ; 2) chercher une récurrence ; 3) tester un glouton sur un petit contre-exemple ; 4) si rien ne marche en polynomial, backtracking élagué et se demander si le problème est NP-difficile.

Cours

Cours 1 — Méthode : de l’énoncé à la récurrence

La programmation dynamique se rate presque toujours au même endroit : la définition de l’état. Voici une procédure en cinq questions, à écrire sur papier avant tout code.

  1. Quel est le sous-problème ? Il doit être paramétré par peu d’entiers (un préfixe, un intervalle, une capacité restante, un sommet). Notation : f(i), f(i, j), f(i, c).
  2. Quelle est la question posée à f ? « nombre de façons », « valeur maximale », « est-ce possible », « coût minimal ». La question détermine l’opérateur de combinaison : +, max, ou, min.
  3. Quelle est la dernière décision ? Pour f(i), que se passe-t-il pour l’élément i : pris / pas pris, coupé ici / pas coupé, apparié avec j / sauté. La récurrence énumère ces choix.
  4. Cas de base ? f(0), f(i, 0) : souvent 0, 1, vrai ou +∞ — le cas où « il n’y a plus rien à décider ».
  5. Ordre de calcul et coût ? Nombre d’états × coût par état. Si un état dépend d’états « plus petits », la tabulation en ordre croissant marche ; sinon mémoïser.
ProblèmeÉtatRécurrenceCoût
Rendu de monnaie (nombre de façons)f(m, k) : montant m avec les k premières piècesf(m, k) = f(m, k−1) + f(m − p_k, k)O(M·K)
Plus longue sous-suite croissantef(i) : longueur de la meilleure finissant en if(i) = 1 + max f(j) pour j < i, t[j] < t[i]O(n²) (O(n log n) avec dichotomie)
Multiplication de matrices en chaînef(i, j) : coût minimal pour A_i…A_jmin sur k de f(i,k) + f(k+1,j) + d_{i−1}d_k d_jO(n³)
Chemins dans une grille avec obstaclesf(i, j)f(i−1, j) + f(i, j−1) si libreO(n·m)

Cours

Cours 2 — Exemple travaillé : la plus longue sous-suite croissante, de O(2ⁿ) à O(n log n)

Lecture. La récurrence f(i) répond à la question 3 du cours 1 (« i est le dernier élément »). L’algorithme rapide n’est plus une PD classique : il maintient un invariant (queue est triée et queue[k] est optimal) — la preuve se fait comme au module L09. Reconstruire la sous-suite elle-même demande de mémoriser les prédécesseurs (défi).

Cours

Cours 3 — Prouver un glouton, ou le réfuter : deux exemples rédigés

Exemple 1 (correct) — Ordonnancement pour minimiser la somme des temps d’attente. n tâches de durées d_i ; ordre gloutton : durées croissantes (SPT). Preuve par échange : supposons un ordre optimal où deux tâches consécutives ont d_a > d_b avec a avant b. Échanger a et b ne change les temps d’attente que de ces deux tâches : avant l’échange, b attend d_a de plus ; après, a attend d_b de plus. Comme d_b < d_a, la somme diminue strictement — contradiction avec l’optimalité. Donc l’optimal est trié par durées croissantes. ∎

Exemple 2 (faux) — Sac à dos 0/1 par ratio valeur/poids décroissant. Contre-exemple : capacité 10 ; objets (poids, valeur) = (6, 60), (5, 50), (5, 50). Ratios 10, 10, 10 ; le glouton prend le premier (6) puis ne peut plus rien ajouter : 60. L’optimal prend les deux derniers : 100. Un seul contre-exemple suffit à réfuter ; il faut alors la programmation dynamique (cours L04 §02) — ou accepter une garantie d’approximation (le glouton par ratio, complété par « le meilleur objet seul », donne au moins la moitié de l’optimal : preuve en défi).

TP guidé

TP — Un solveur de sac à dos complet, du naïf à l’industriel (sur PC, 2 h)

Exercices

Exercices auto-corrigés — récurrences et programmation dynamique

Exercice 1 — Nombre de façons de rendre la monnaie

Écrivez nb_facons(pieces, montant) : nombre de multi-ensembles de pièces totalisant le montant (l’ordre ne compte pas : {1, 2} et {2, 1} sont la même façon). Indice : récurrence f(m, k) du cours 1 ; tabulez en parcourant les pièces à l’extérieur.

Correction
def nb_facons(pieces, montant):
    f = [1] + [0] * montant                      # f[m] : façons avec les pièces vues jusqu'ici
    for p in pieces:                             # pièces à l'extérieur : chaque combinaison comptée une fois
        for m in range(p, montant + 1): f[m] += f[m - p]
    return f[montant]

Inverser les boucles (montant à l’extérieur) compterait les suites ordonnées : 9 au lieu de 4 pour le premier test.

Exercice 2 — Découpe de chaîne en mots du dictionnaire

segmenter(texte, dico) renvoie une segmentation de texte en mots du dictionnaire (liste de mots), ou None. État : « le préfixe de longueur i est segmentable ». Reconstruisez la solution.

Correction
def segmenter(texte, dico):
    n = len(texte); prec = [None] * (n + 1); ok = [False] * (n + 1); ok[0] = True
    for i in range(1, n + 1):
        for j in range(i):
            if ok[j] and texte[j:i] in dico: ok[i] = True; prec[i] = j; break
    if not ok[n]: return None
    mots, i = [], n
    while i > 0: mots.append(texte[prec[i]:i]); i = prec[i]
    return mots[::-1]

Exercices

Exercices auto-corrigés — gloutons, diviser pour régner, backtracking

Exercice 3 — Intervalles : couverture minimale

Des capteurs couvrent chacun un intervalle [a, b] d’une route [0, L]. couverture(intervalles, L) renvoie le nombre minimal de capteurs pour couvrir [0, L], ou −1 si impossible. Glouton : à chaque étape, parmi les intervalles qui commencent avant la position courante, prendre celui qui va le plus loin. Justifiez-le par échange (en commentaire).

Correction
def couverture(intervalles, L):
    pos, n, i = 0, 0, 0; I = sorted(intervalles)
    while pos < L:
        loin = pos
        while i < len(I) and I[i][0] <= pos: loin = max(loin, I[i][1]); i += 1
        if loin == pos: return -1
        pos = loin; n += 1
    return n

Exercice 4 — Compter les inversions et placer 8 reines

a) inversions(t) en O(n log n) par tri fusion (cours L04 §01, à réécrire de mémoire). b) reines(n) renvoie le nombre de solutions du problème des n reines par backtracking avec trois ensembles (colonnes, diagonales) pour tester une case en O(1).

Correction
def inversions(t):
    def rec(t):
        if len(t) <= 1: return t, 0
        m = len(t) // 2; g, a = rec(t[:m]); d, b = rec(t[m:]); r, i, j, inv = [], 0, 0, a + b
        while i < len(g) and j < len(d):
            if g[i] <= d[j]: r.append(g[i]); i += 1
            else: r.append(d[j]); j += 1; inv += len(g) - i
        return r + g[i:] + d[j:], inv
    return rec(list(t))[1]
def reines(n):
    cols, d1, d2 = set(), set(), set()
    def _rec(l):
        if l == n: return 1
        total = 0
        for c in range(n):
            if c in cols or l + c in d1 or l - c in d2: continue
            cols.add(c); d1.add(l + c); d2.add(l - c)
            total += _rec(l + 1)
            cols.discard(c); d1.discard(l + c); d2.discard(l - c)
        return total
    return _rec(0)

06 / Défis

Défi ★ — Chemins dans une grille

Consigne

Un robot va du coin (0, 0) au coin (n−1, m−1) d’une grille en ne se déplaçant que vers la droite ou le bas. Certaines cases sont des obstacles. 1) Nombre de chemins (programmation dynamique). 2) Chemin de coût minimal si chaque case a un coût. 3) Reconstruire ce chemin et l’afficher.

Correction
n, m = len(grille), len(grille[0])
libre = lambda i, j: grille[i][j] != "#"
N = [[0] * m for _ in range(n)]; N[0][0] = 1
for i in range(n):
    for j in range(m):
        if not libre(i, j): N[i][j] = 0; continue
        if i: N[i][j] += N[i - 1][j]
        if j: N[i][j] += N[i][j - 1]
print("chemins :", N[n - 1][m - 1])

INF = float("inf")
C = [[INF] * m for _ in range(n)]; C[0][0] = couts[0][0]
for i in range(n):
    for j in range(m):
        if not libre(i, j) or (i, j) == (0, 0): continue
        C[i][j] = couts[i][j] + min(C[i - 1][j] if i else INF, C[i][j - 1] if j else INF)
i, j, chemin = n - 1, m - 1, []
while (i, j) != (0, 0):
    chemin.append((i, j))
    if i and C[i - 1][j] <= (C[i][j - 1] if j else INF): i -= 1
    else: j -= 1
chemin.append((0, 0)); chemin.reverse()
print("coût min :", C[n - 1][m - 1], chemin)
for i in range(n):
    print("".join("*" if (i, j) in chemin else grille[i][j] for j in range(m)))

06 / Défis

Défi ★★ — Découpe optimale et mémoire

Consigne

Une barre de longueur n se vend prix[k] pour un morceau de longueur k. Quel découpage maximise le revenu ? 1) Récursif naïf : mesurer le nombre d’appels pour n = 20. 2) Mémoïsé. 3) Tabulé avec reconstruction. 4) Question esprit prépa : montrer que le nombre de découpages possibles est 2n−1, et que la PD en examine seulement O(n²) combinaisons.

Correction
appels = 0
def naif(n):
    global appels; appels += 1
    if n == 0: return 0
    return max(p(k) + naif(n - k) for k in range(1, n + 1))
print(naif(20), "appels :", appels)         # 2^20 environ

from functools import lru_cache
@lru_cache(None)
def memo(n):
    return 0 if n == 0 else max(p(k) + memo(n - k) for k in range(1, n + 1))
print(memo(20), memo(200))

def tabule(n):
    R, choix = [0] * (n + 1), [0] * (n + 1)
    for l in range(1, n + 1):
        for k in range(1, l + 1):
            if p(k) + R[l - k] > R[l]: R[l], choix[l] = p(k) + R[l - k], k
    morceaux, l = [], n
    while l: morceaux.append(choix[l]); l -= choix[l]
    return R[n], morceaux
print(tabule(20))

Chaque découpage correspond à un choix « couper ou non » à chacune des n−1 positions : 2n−1. La PD n’a que n états, chacun examinant n choix : O(n²). C’est la différence entre 5·105 et 400 pour n = 20, entre l’âge de l’univers et une milliseconde pour n = 100.

06 / Défis

Défi ★★★ — Esprit prépa : le voyageur de commerce, trois façons

Consigne

n villes à coordonnées aléatoires, un robot doit toutes les visiter et revenir. 1) Backtracking avec élagage par borne (abandonner dès que la longueur partielle dépasse la meilleure trouvée) : jusqu’à n = 10. 2) Programmation dynamique de Held-Karp sur les sous-ensembles (états : (ensemble visité, dernière ville), O(2n·n²)) : jusqu’à n = 15. 3) Heuristique gloutonne « plus proche voisin » puis amélioration 2-opt : n = 200, comparer à l’optimal pour n = 10. Discuter : pourquoi personne ne connaît d’algorithme polynomial ?

Correction
def tsp_backtracking(V):
    n = len(V); meilleur = [float("inf"), None]
    def rec(chemin, longueur, restants):
        if longueur >= meilleur[0]: return                           # élagage par borne
        if not restants:
            total = longueur + d(V[chemin[-1]], V[chemin[0]])
            if total < meilleur[0]: meilleur[0], meilleur[1] = total, list(chemin)
            return
        for v in sorted(restants, key=lambda v: d(V[chemin[-1]], V[v])):   # les proches d'abord
            chemin.append(v); rec(chemin, longueur + d(V[chemin[-2]], V[v]), restants - {v}); chemin.pop()
    rec([0], 0.0, set(range(1, n))); return meilleur[0]

def tsp_held_karp(V):
    n = len(V); INF = float("inf")
    C = {(1, 0): 0.0}                                                # (masque, dernière) → coût
    for masque in range(1, 1 << n):
        if not masque & 1: continue
        for j in range(1, n):
            if not masque & (1 << j) or (masque, j) in C: continue
            prev = masque ^ (1 << j)
            C[(masque, j)] = min((C.get((prev, k), INF) + d(V[k], V[j]) for k in range(n) if prev & (1 << k)), default=INF)
    plein = (1 << n) - 1
    return min(C[(plein, j)] + d(V[j], V[0]) for j in range(1, n))

def tsp_heuristique(V):
    n = len(V); t = [0]; restants = set(range(1, n))
    while restants:                                                   # plus proche voisin
        v = min(restants, key=lambda v: d(V[t[-1]], V[v])); t.append(v); restants.remove(v)
    def longueur(t): return sum(d(V[t[i]], V[t[(i + 1) % n]]) for i in range(n))
    ameliore = True
    while ameliore:                                                   # 2-opt : décroiser deux arêtes
        ameliore = False
        for i in range(1, n - 1):
            for j in range(i + 1, n):
                if d(V[t[i-1]], V[t[j]]) + d(V[t[i]], V[t[(j+1) % n]]) < d(V[t[i-1]], V[t[i]]) + d(V[t[j]], V[t[(j+1) % n]]):
                    t[i:j+1] = reversed(t[i:j+1]); ameliore = True
    return longueur(t)

V = villes(10)
print("exact (backtracking) :", round(tsp_backtracking(V), 4))
print("exact (Held-Karp)    :", round(tsp_held_karp(V), 4))
print("heuristique 2-opt    :", round(tsp_heuristique(V), 4))
print("n = 200, 2-opt       :", round(tsp_heuristique(villes(200)), 3))

Held-Karp est en O(2n n²) : exponentiel, mais infiniment mieux que n! (pour n = 20 : 4·108 contre 2·1018). Le TSP est NP-difficile : on ne connaît pas d’algorithme polynomial et on conjecture qu’il n’en existe pas (P ≠ NP, module L23). En pratique, les heuristiques (2-opt, Lin-Kernighan, recuit simulé) donnent des tours à 2-3 % de l’optimal pour des millions de villes, et les solveurs exacts (Concorde) résolvent des instances de 85 900 villes par séparation-évaluation.

07 / Vérification

« Nombre de façons de rendre 100 € avec des pièces de 1, 2, 5, 10 » : stratégie ?

Deux questions supplémentaires

1. Pourquoi le glouton fonctionne-t-il pour les pièces {1, 2, 5, 10, 20, 50} et pas {1, 3, 4} ? Parce que le premier système est « canonique » : chaque pièce vaut au moins le double… en réalité la condition exacte est plus subtile (test de Kozen-Zaks), mais l’intuition est que rendre la plus grosse pièce n’empêche jamais de finir de façon optimale.

2. Complexité de la mémoïsation de Fibonacci ? O(n) états × O(1) = O(n), contre O(φn) naïf.

Référence

Les mots à retenir

MotDéfinition
Diviser pour régnerSous-problèmes indépendants, résolus récursivement puis combinés.
Théorème maîtreRésout T(n) = aT(n/b) + O(nd).
Programmation dynamiqueRésoudre chaque sous-problème une fois et mémoriser.
MémoïsationPD récursive avec cache (lru_cache).
TabulationPD itérative en remplissant une table.
Sous-structure optimaleL’optimum contient des optimums de sous-problèmes.
GloutonChoix localement optimal à chaque étape, jamais remis en cause.
Argument d’échangePreuve qu’un glouton est optimal.
BacktrackingExplorer en profondeur, défaire, élaguer.
MRVChoisir la variable la plus contrainte.
Pseudo-polynomialPolynomial en la valeur des nombres, pas en leur taille (sac à dos O(nC)).

Pour continuer

Vous reconnaissez les problèmes

Module suivant : les graphes — la structure qui modélise les réseaux, les cartes, les dépendances, et les algorithmes qui les parcourent (BFS, DFS, Dijkstra, A*, Kruskal, tri topologique).

À faire chez soi

← L03SommaireL05 : Graphes →