C5 — Graphes et paradigmesDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 1 · Algorithmique 2

Cours 5Graphes et paradigmes

Modéliser ce qui n'est ni linéaire ni hiérarchique, puis prendre du recul sur trois grandes manières de chercher une solution.

2 chapitres · 8 h de travail estimé

  1. 1. Graphes5 h
  2. 2. Paradigmes algorithmiques3 h

Chapitre 1 · 5 h

Graphes

Matrice et listes d'adjacence, parcours en profondeur et en largeur, connexité, détection de cycle, plus court chemin en nombre d'arêtes, Dijkstra en introduction.

Combien de correspondances au minimum entre deux stations de métro ? Un ami commun existe-t-il entre deux personnes d'un réseau social ? Quelles pages web sont atteignables depuis celle-ci ? Ces trois questions n'ont ni hiérarchie ni ordre : elles portent sur des objets reliés arbitrairement, et aucune structure vue jusqu'ici ne les modélise.

Le graphe est cet objet, et il généralise tout le semestre. Une liste est un graphe où chaque sommet a un successeur ; un arbre, un graphe sans cycle avec une racine. Retirer ces contraintes ouvre un champ immense — et introduit une difficulté nouvelle : on peut revenir sur ses pas.

Vocabulaire

Un graphe est un ensemble de sommets et un ensemble d'arêtes reliant des paires de sommets.

Il est orienté quand les liens ont un sens — on parle alors d'arcs : les liens hypertexte, les dépendances entre tâches, les relations « suit » d'un réseau social. Il est non orienté quand ils n'en ont pas : un réseau routier, une amitié réciproque.

Il est pondéré quand chaque arête porte un nombre — distance, durée, coût, capacité. C'est ce qui distinguera le chapitre en deux moitiés : sans poids, la longueur d'un chemin est son nombre d'arêtes ; avec poids, c'est la somme.

TermeDéfinition
Degré d'un sommetnombre d'arêtes qui y aboutissent
Cheminsuite de sommets reliés deux à deux
Cyclechemin qui revient à son point de départ
Connexetout sommet est atteignable depuis tout autre
Composante connexemorceau maximal connexe d'un graphe qui ne l'est pas
Acycliquesans aucun cycle

Deux notations utiles : VV pour le nombre de sommets, EE pour le nombre d'arêtes. Un graphe est dit dense quand EE approche V2V^2 — presque tout est relié — et creux quand EE est de l'ordre de VV. Cette distinction décide de la représentation.

Deux représentations

La matrice d'adjacence est un tableau V×VV \times V où la case (i,j)(i,j) vaut 1 s'il existe une arête de ii vers jj — ou le poids, s'il y en a un.

        A  B  C  D              A ── B     A  0  1  1  0              │    │     B  1  0  1  0              C ───┘     C  1  1  0  1              │     D  0  0  1  0              D

Tester l'existence d'une arête est en O(1)O(1), ce qui est imbattable. Mais la mémoire est O(V2)O(V^2) quel que soit le nombre d'arêtes, et surtout, énumérer les voisins d'un sommet demande de parcourir toute une ligne, soit O(V)O(V) — même s'il n'a que deux voisins.

Les listes d'adjacence associent à chaque sommet la liste de ses voisins.

A : B, CB : A, CC : A, B, DD : C

La mémoire est O(V+E)O(V + E), et énumérer les voisins d'un sommet coûte exactement son degré. En revanche, tester une arête précise demande de parcourir une liste.

MatriceListes
MémoireO(V2)O(V^2)O(V+E)O(V + E)
Tester une arêteO(1)O(1)O(deg)O(\deg)
Énumérer les voisinsO(V)O(V)O(deg)O(\deg)

Le choix se tranche par la densité, et par ce que fait l'algorithme. Les parcours de ce chapitre énumèrent les voisins en boucle et ne testent presque jamais une arête isolée : sur un graphe creux, les listes d'adjacence sont donc le bon choix — et les graphes réels sont massivement creux. Un réseau social de dix millions de comptes n'a pas 101410^{14} relations ; il en a peut-être un milliard, et la matrice serait de toute façon impossible à loger.

Parcourir : la difficulté nouvelle

Les parcours du chapitre 7 se transposent presque tels quels — avec une modification qui n'est pas optionnelle.

Dans un arbre, on ne peut pas revenir sur un nœud déjà visité : il n'y a qu'un chemin depuis la racine. Dans un graphe, un cycle ramène sur ses pas, et un parcours naïf boucle indéfiniment. Il faut donc marquer les sommets visités, et tester ce marquage avant chaque descente. C'est la seule différence de fond avec le chapitre 7, et c'est l'oubli qui produit les boucles infinies du TD.

Le parcours en profondeur (DFS) s'enfonce aussi loin que possible avant de revenir en arrière. Il s'écrit récursivement en cinq lignes — la pile d'appels du bloc I fait le travail — ou itérativement avec une pile explicite.

fonction profondeur(s)    marquer s    traiter s    pour chaque voisin v de s        si v n'est pas marqué alors profondeur(v)

Le parcours en largeur (BFS) explore par couches : d'abord les voisins immédiats, puis leurs voisins, et ainsi de suite. Il emploie une file, exactement comme au chapitre 7.

fonction largeur(s)    marquer s ; enfiler s    tant que la file n'est pas vide        u ← défiler        traiter u        pour chaque voisin v de u non marqué            marquer v ; enfiler v

Un détail qui compte : on marque à l'enfilement, pas au défilement. Sinon un sommet accessible par deux chemins serait enfilé deux fois.

Les deux parcours coûtent O(V+E)O(V + E) avec des listes d'adjacence : chaque sommet est traité une fois, chaque arête examinée une fois (deux en non orienté).

Ce qu'un parcours résout

La connexité et les composantes. Un parcours depuis un sommet atteint exactement sa composante connexe. Pour toutes les trouver, on relance un parcours depuis chaque sommet non encore marqué, en comptant les relances : c'est le nombre de composantes, obtenu en O(V+E)O(V+E).

La détection de cycle. En non orienté, un cycle existe si le parcours rencontre un sommet déjà marqué qui n'est pas le père immédiat — la nuance est essentielle, sans quoi toute arête serait vue comme un cycle. En orienté, c'est plus subtil : il faut repérer un arc vers un sommet encore en cours de traitement dans la pile de récursion, et non simplement déjà visité.

Le plus court chemin en nombre d'arêtes. C'est la propriété remarquable du BFS : il donne le plus court chemin, sans effort supplémentaire. La raison tient à l'ordre de visite — il explore tous les sommets à distance 1, puis tous ceux à distance 2, et ainsi de suite. Quand il atteint un sommet pour la première fois, il l'a donc fait par le chemin le plus court, et il suffit de mémoriser le prédécesseur pour reconstituer l'itinéraire. C'est la réponse à la question des correspondances de métro — à condition que toutes les arêtes se valent.

Le DFS n'a pas cette propriété : il peut atteindre un sommet voisin par un long détour, parce qu'il plonge avant d'explorer les alternatives.

Quiz · 1 question

Un étudiant écrit un parcours en profondeur sans marquer les sommets visités. Que se passe-t-il, et pourquoi la question ne se posait-elle pas au chapitre 7 ?

  • Certains sommets sont visités plusieurs fois, ce qui ralentit le parcours sans le faussersimple ralentissement
  • Le parcours boucle indéfiniment dès qu'il existe un cycle, car rien ne l'empêche d'y retourner ; dans un arbre le problème n'existe pas puisqu'il n'y a qu'un chemin depuis la racine et aucun cycleboucle infinie
  • Le parcours s'arrête trop tôt, en manquant les sommets de degré élevéarrêt prématuré

Réponse : Un arbre est acyclique et chaque nœud n'a qu'un parent : une descente ne peut pas revenir en arrière, le marquage est donc inutile. Un graphe peut contenir un cycle, et rien n'empêche alors le parcours d'y tourner : A visite B, qui visite C, qui revisite A, et ainsi de suite jusqu'au débordement de pile — le même symptôme qu'un cas de base manquant au chapitre 1, pour une cause différente. Même sans cycle, un graphe orienté acyclique où plusieurs chemins mènent au même sommet ferait exploser le nombre de visites de façon exponentielle. Le marquage n'est donc pas une optimisation, c'est ce qui rend le parcours correct — et pour le BFS, il faut marquer à l'ENFILEMENT, pas au défilement, sans quoi un sommet accessible par deux chemins serait enfilé deux fois.

Dijkstra, en introduction

Le BFS répond aux arêtes de poids égal. Dès qu'une route est deux fois plus longue qu'une autre, il ne convient plus : le chemin ayant le moins d'arêtes n'est pas forcément le plus court en distance.

L'algorithme de Dijkstra (1959) traite le cas pondéré, à une condition : tous les poids doivent être positifs. Son principe est glouton, au sens du chapitre suivant — il fait à chaque étape le choix qui paraît le meilleur sur le moment, sans jamais revenir dessus.

distance[départ] ← 0, toutes les autres ← infinitant qu'il reste des sommets non traités    u ← le sommet non traité de plus petite distance      ← le choix glouton    marquer u comme traité    pour chaque voisin v de u        si distance[u] + poids(u,v) < distance[v] alors            distance[v] ← distance[u] + poids(u,v)        ← relâchement

Deux remarques suffisent à ce niveau.

Le sommet extrait est définitif. Quand on choisit le sommet non traité le plus proche, aucun chemin passant par les sommets restants ne pourra faire mieux — puisqu'ils sont tous plus loin et que les poids sont positifs, tout détour ne peut qu'allonger. C'est ce qui justifie de ne jamais revenir en arrière, et c'est aussi ce qui s'effondre avec un poids négatif : un arc de poids 5-5 rencontré plus tard pourrait raccourcir un chemin déjà figé.

Le coût dépend de la structure choisie. Chercher le minimum en parcourant tous les sommets donne O(V2)O(V^2). Le prendre dans une file de priorité — le tas du chapitre 8 — donne O((V+E)logV)O((V+E)\log V), bien meilleur sur un graphe creux. C'est le plus bel emploi du tas du semestre : Dijkstra a besoin, à chaque étape, du minimum d'un ensemble qui change, ce qui est exactement le contrat de cette structure.

Quiz · 1 question

Pourquoi le parcours en largeur donne-t-il le plus court chemin en nombre d'arêtes, et pourquoi ne suffit-il plus sur un graphe pondéré ?

  • Parce qu'il essaie tous les chemins et garde le meilleur ; sur un graphe pondéré il faudrait comparer les sommes, ce qu'il ne fait pasessai exhaustif
  • Parce qu'il explore par couches de distance croissante : quand il atteint un sommet pour la première fois, c'est forcément par le chemin le plus court. Avec des poids, le chemin ayant le moins d'arêtes n'est plus le plus court en distanceexploration par couches
  • Parce qu'il utilise une file, structure qui trie automatiquement les chemins par longueurtri par la file

Réponse : Le BFS n'essaie pas tous les chemins — il visite chaque sommet une seule fois, en O(V+E). Sa propriété vient de l'ORDRE : il traite tous les sommets à distance 1, puis tous ceux à distance 2, etc. La première fois qu'il atteint un sommet, aucune couche antérieure n'y menait, donc le chemin trouvé est minimal en nombre d'arêtes ; mémoriser le prédécesseur suffit à le reconstituer. La file ne trie rien : elle garantit simplement l'ordre d'arrivée, ce qui suffit à préserver l'ordre des couches. Avec des poids, cette équivalence tombe : deux arêtes de poids 1 valent mieux qu'une seule de poids 10, alors que le BFS préférerait la seconde. Il faut alors extraire non plus le plus anciennement enfilé mais le plus proche — d'où le remplacement de la file par une file de PRIORITÉ, et c'est Dijkstra.

À vous

L'exercice construit un petit graphe en listes d'adjacence, puis enchaîne les applications : DFS et BFS, comptage des composantes connexes, détection de cycle, et plus court chemin en nombre d'arêtes avec reconstitution de l'itinéraire — c'est le tableau des prédécesseurs qui fait le travail, et c'est la partie qu'on oublie le plus souvent.

Le squelette contient un DFS sans marquage : lancez-le d'abord sur le graphe cyclique fourni, avec la garde qui l'empêche de tourner à l'infini, et regardez combien de fois chaque sommet est visité. C'est plus convaincant que la mise en garde du cours.

Une dernière partie, facultative, implémente Dijkstra avec une recherche linéaire du minimum, puis vous invite à le rebrancher sur le tas du chapitre 8.

Exercice de code

Écrivez DFS et BFS avec marquage, comptez les composantes, détectez un cycle, puis lancez Dijkstra.

Point de départ

// Listes d'adjacence. Deux composantes connexes, et un cycle dans la première.
const G = {
  A: ["B", "C"],
  B: ["A", "D"],
  C: ["A", "D"],
  D: ["B", "C"],      // A-B-D-C-A : un cycle
  E: ["F"],
  F: ["E"],           // seconde composante
};

// ── Le parcours SANS marquage, pour voir ──────────────────────────────────
function dfsSansMarquage(g, s, visites = {}, garde = { n: 0 }) {
  if (garde.n++ > 60) return visites;         // sans cette garde : pile pleine
  visites[s] = (visites[s] ?? 0) + 1;
  for (const v of g[s]) dfsSansMarquage(g, v, visites, garde);
  return visites;
}

// ── Parcours en profondeur ────────────────────────────────────────────────
function dfs(g, depart, vus = new Set(), ordre = []) {
  // ← à écrire : marquer, traiter, puis descendre chez les voisins non vus
  return ordre;
}

// ── Parcours en largeur, avec prédécesseurs ───────────────────────────────
function bfs(g, depart) {
  const vus = new Set([depart]);
  const pere = { [depart]: null };
  const file = [depart];
  const ordre = [];
  while (file.length > 0) {
    const u = file.shift();
    ordre.push(u);
    for (const v of g[u]) {
      // ← à écrire : marquer À L'ENFILEMENT, noter le père, enfiler
    }
  }
  return { ordre, pere };
}

// Reconstitue le chemin en remontant les pères depuis l'arrivée.
function chemin(pere, arrivee) {
  if (!(arrivee in pere)) return null;
  const c = [];
  for (let s = arrivee; s !== null; s = pere[s]) c.unshift(s);
  return c;
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Écrivez dfs() et complétez bfs().
// 2. Comptez les composantes connexes de G.
// 3. Détectez le cycle : un voisin déjà vu qui n'est PAS le père immédiat.

console.log("sans marquage :", JSON.stringify(dfsSansMarquage(G, "A")));

Solution

const G = {
  A: ["B", "C"], B: ["A", "D"], C: ["A", "D"], D: ["B", "C"],
  E: ["F"], F: ["E"],
};

// Un graphe pondéré pour Dijkstra.
const P = {
  A: [["B", 4], ["C", 1]],
  B: [["A", 4], ["D", 1]],
  C: [["A", 1], ["B", 2], ["D", 6]],
  D: [["B", 1], ["C", 6]],
};

function dfsSansMarquage(g, s, visites = {}, garde = { n: 0 }) {
  if (garde.n++ > 60) return visites;
  visites[s] = (visites[s] ?? 0) + 1;
  for (const v of g[s]) dfsSansMarquage(g, v, visites, garde);
  return visites;
}

function dfs(g, depart, vus = new Set(), ordre = []) {
  vus.add(depart);          // marquer AVANT de descendre
  ordre.push(depart);
  for (const v of g[depart]) if (!vus.has(v)) dfs(g, v, vus, ordre);
  return ordre;
}

function bfs(g, depart) {
  const vus = new Set([depart]);
  const pere = { [depart]: null };
  const file = [depart];
  const ordre = [];
  while (file.length > 0) {
    const u = file.shift();
    ordre.push(u);
    for (const v of g[u]) {
      if (vus.has(v)) continue;
      // Marquer À L'ENFILEMENT : sinon un sommet accessible par deux chemins
      // serait enfilé deux fois avant d'être défilé une première fois.
      vus.add(v);
      pere[v] = u;
      file.push(v);
    }
  }
  return { ordre, pere };
}

function chemin(pere, arrivee) {
  if (!(arrivee in pere)) return null;
  const c = [];
  for (let s = arrivee; s !== null; s = pere[s]) c.unshift(s);
  return c;
}

function composantes(g) {
  const vus = new Set();
  const morceaux = [];
  for (const s of Object.keys(g)) {
    if (vus.has(s)) continue;
    morceaux.push(dfs(g, s, vus, []));   // une relance = une composante
  }
  return morceaux;
}

function aUnCycle(g) {
  const vus = new Set();
  function explorer(s, pere) {
    vus.add(s);
    for (const v of g[s]) {
      // La nuance qui compte : en non orienté, l'arête par laquelle on est
      // arrivé mène évidemment à un sommet déjà vu. Il faut l'exclure.
      if (v === pere) continue;
      if (vus.has(v)) return true;
      if (explorer(v, s)) return true;
    }
    return false;
  }
  for (const s of Object.keys(g)) if (!vus.has(s) && explorer(s, null)) return true;
  return false;
}

function dijkstra(g, depart) {
  const distance = {}, pere = {}, traites = new Set();
  for (const s of Object.keys(g)) distance[s] = Infinity;
  distance[depart] = 0;
  pere[depart] = null;

  while (traites.size < Object.keys(g).length) {
    // Le choix glouton : le non traité le plus proche. Une file de priorité
    // (le tas du chapitre 8) remplacerait cette recherche linéaire.
    let u = null;
    for (const s of Object.keys(g)) {
      if (!traites.has(s) && (u === null || distance[s] < distance[u])) u = s;
    }
    if (u === null || distance[u] === Infinity) break;
    traites.add(u);
    for (const [v, poids] of g[u]) {
      if (distance[u] + poids < distance[v]) {
        distance[v] = distance[u] + poids;
        pere[v] = u;
      }
    }
  }
  return { distance, pere };
}

console.log("— sans marquage, sur un graphe cyclique —");
console.log("   visites par sommet :", JSON.stringify(dfsSansMarquage(G, "A")));
console.log("   (la garde a coupé à 60 appels ; sans elle, la pile déborde)");

console.log("");
console.log("— avec marquage —");
console.log("   profondeur depuis A :", dfs(G, "A").join(" "));
const b = bfs(G, "A");
console.log("   largeur    depuis A :", b.ordre.join(" "));

console.log("");
console.log("composantes connexes :", composantes(G).map((c) => c.join("")).join("  |  "));
console.log("contient un cycle    :", aUnCycle(G));
console.log("chemin le plus court A -> D :", chemin(b.pere, "D").join(" -> "),
            "  (" + (chemin(b.pere, "D").length - 1) + " arêtes)");

console.log("");
console.log("— Dijkstra sur le graphe pondéré —");
const d = dijkstra(P, "A");
for (const s of Object.keys(P)) {
  console.log("   A -> " + s + " : distance " + String(d.distance[s]).padStart(2) +
              "  par " + chemin(d.pere, s).join(" -> "));
}
// A -> B coûte 3 en passant par C (1 + 2), pas 4 en direct : c'est
// exactement ce que le BFS ne saurait pas voir, puisqu'il compterait
// une arête contre deux et choisirait la mauvaise.

En travaux pratiques

Travaux pratiques 9 · 4 h

Le calculateur d'itinéraires

Faire converger toute la bibliothèque du semestre sur un problème réel : charger un réseau de transport, le parcourir, et calculer le plus court chemin avec la file de priorité du TP 8.

Avant de commencer

  • Les TP 5 à 8 : liste, file, tas, file de priorité
  • Un fichier de réseau : arrêts et liaisons avec durées — le vôtre, ou celui d'une ville ouverte

Énoncé

  1. Choisir la représentationReprésentez le graphe de deux façons : matrice d'adjacence et listes d'adjacence. Mesurez la mémoire de chacune sur votre réseau, et calculez sa densité. Indice : Comptez le rapport entre le nombre d'arêtes et le carré du nombre de sommets.
  2. Charger le réseauLisez le fichier et construisez le graphe. Comptez sommets, arêtes, et vérifiez qu'aucun arrêt cité dans une liaison n'est absent de la liste des arrêts.
  3. Parcours en largeurÉcrivez le parcours en largeur avec votre file. Utilisez-le pour trouver l'itinéraire en un minimum de CORRESPONDANCES entre deux arrêts, et reconstituez le chemin.
  4. Parcours en profondeurÉcrivez-le avec votre pile, puis en récursif. Utilisez-le pour compter les composantes connexes du réseau, et identifiez les arrêts isolés.
  5. Détecter un cycleSur un graphe orienté de dépendances — par exemple l'ordre des travaux d'un chantier —, détectez un cycle, puis produisez un ordre topologique quand il n'y en a pas.
  6. DijkstraImplémentez le plus court chemin en TEMPS, avec la file de priorité du TP 8. Comparez le résultat à celui du parcours en largeur et expliquez la différence.
  7. Mesurer ce que le tas apporteÉcrivez aussi la version de Dijkstra qui cherche le minimum par balayage linéaire. Comparez les deux sur un réseau de mille, puis de cent mille sommets.
  8. Le piègeAjoutez au réseau une liaison de durée négative — une correspondance qui ferait gagner du temps. Exécutez Dijkstra et expliquez pourquoi le résultat est faux.

C'est réussi quand

  • Votre chargeur signale un arrêt manquant plutôt que de planter
  • Le parcours en largeur et Dijkstra donnent des chemins DIFFÉRENTS, et vous savez expliquer lequel répond à quelle question
  • La version à tas bat nettement la version linéaire à cent mille sommets
  • Vous savez dire précisément quelle hypothèse de Dijkstra une arête négative viole

Correction

Les deux représentations
réseau : 4 200 arrêts, 9 800 liaisons
densité = 9 800 / 4 200² = 0,00055        → graphe CREUX

matrice d'adjacence : 4200² × 4 o = 70 Mo   (99,9 % de zéros)
listes d'adjacence  : (4200 + 2×9800) × 16 o = 380 Ko

matrice : test d'arête en O(1), parcours des voisins en O(n)
listes  : test en O(degré),     parcours des voisins en O(degré)

Un facteur 180 en mémoire. La règle est la densité : matrice pour un graphe dense ou quand on teste souvent l'existence d'une arête, listes pour un graphe creux — et les graphes réels — routes, réseaux sociaux, dépendances — sont presque toujours creux. Le choix de représentation précède le choix d'algorithme, et le contraint.

Le parcours en largeur, avec reconstitution du chemingraphe.c
void largeur(Graphe *g, int depart, int *distance, int *precedent) {
  for (int i = 0; i < g->n; i++) { distance[i] = -1; precedent[i] = -1; }
  File f; file_init(&f);
  distance[depart] = 0;
  enfiler(&f, depart);

  while (!file_vide(&f)) {
      int u = defiler(&f);
      for (Arete *a = g->voisins[u]; a; a = a->suivant)
          if (distance[a->vers] == -1) {          /* pas encore vu */
              distance[a->vers] = distance[u] + 1;
              precedent[a->vers] = u;             /* pour reconstituer */
              enfiler(&f, a->vers);
          }
  }
}
/* le chemin se relit à l'ENVERS depuis l'arrivée, via precedent */

Le tableau precedent est ce qui transforme « à quelle distance » en « par où passer ». On le remplit sans coût supplémentaire, et on relit le chemin à rebours puis on l'inverse — la fonction d'inversion du TP 5 resservant ici. Marquer le sommet au moment de l'ENFILEMENT, et non du défilement, évite de l'enfiler plusieurs fois.

Dijkstra
void dijkstra(Graphe *g, int depart, int *dist, int *prec) {
  for (int i = 0; i < g->n; i++) dist[i] = INT_MAX;
  dist[depart] = 0;
  FilePrio f; fp_init(&f);
  fp_inserer(&f, depart, 0);

  while (!fp_vide(&f)) {
      Element e = fp_extraire_min(&f);
      if (e.priorite > dist[e.sommet]) continue;   /* entrée périmée */
      for (Arete *a = g->voisins[e.sommet]; a; a = a->suivant) {
          int nouveau = dist[e.sommet] + a->duree;
          if (nouveau < dist[a->vers]) {
              dist[a->vers] = nouveau;
              prec[a->vers] = e.sommet;
              fp_inserer(&f, a->vers, nouveau);
          }
      }
  }
}

Le test « entrée périmée » remplace la diminution de clé, que le tas simple ne sait pas faire : on réinsère et on ignore les vieilles entrées à l'extraction. C'est la mise en œuvre standard, et elle est correcte parce qu'un sommet est traité la première fois qu'il sort, avec sa plus petite distance. Le parcours en largeur est exactement Dijkstra avec toutes les arêtes de poids 1 — d'où deux chemins différents : le moins de correspondances n'est pas le plus rapide.

Ce que le tas apporte
                     1 000 sommets   100 000 sommets
minimum par balayage     0,004 s          38 s
file de priorité         0,001 s           0,42 s

O(n²) contre O((n + m) log n)

Quatre-vingt-dix fois plus rapide, et l'écart croît avec la taille. C'est le retour sur investissement du TP 8 : la structure de données n'accélère pas l'algorithme d'un facteur constant, elle change sa CLASSE de complexité. Sur un graphe dense, en revanche, le balayage linéaire redevient compétitif — la densité décide encore.

L'arête négative
A --(5)--> B --(-4)--> C
A --(2)--> C

Dijkstra traite C en premier (distance 2), le déclare DÉFINITIF,
et ne le reconsidère jamais.
La vraie distance passe par B : 5 - 4 = 1.

→ résultat FAUX, sans aucune erreur signalée

Dijkstra repose sur une hypothèse précise : allonger un chemin ne peut pas le raccourcir, donc le sommet le plus proche non traité a sa distance définitive. Un poids négatif détruit cette hypothèse, et l'algorithme se trompe SILENCIEUSEMENT — le pire des comportements. Avec des poids négatifs, il faut Bellman-Ford, plus lent en O(n×m), qui détecte en prime les cycles absorbants.

Le tri topologique
/* ordre topologique par degrés entrants, avec une file */
compter les degrés entrants
enfiler tous les sommets de degré entrant nul
tant que la file n'est pas vide :
  défiler u, l'ajouter au résultat
  pour chaque voisin v : décrémenter son degré entrant,
                         l'enfiler s'il tombe à zéro

si le résultat contient moins de n sommets → il y a un CYCLE

Le même parcours en largeur, appliqué à un problème qui n'a rien d'un itinéraire : ordonner des tâches dont certaines dépendent d'autres. C'est ce que font make, un gestionnaire de paquets, ou un ordonnanceur de pipeline — et la détection de cycle en est le sous-produit gratuit. Reconnaître qu'un problème est un graphe est plus utile que connaître dix algorithmes de graphes.

Ce que la suite en fait

Le chapitre 10 clôt le semestre en prenant de la hauteur sur les manières de chercher, et Dijkstra y servira d'exemple : c'est un algorithme glouton, dont la correction n'est garantie que sous une hypothèse précise — les poids positifs. C'est exactement ce que le chapitre dira des algorithmes gloutons en général : ils sont rapides et souvent faux, et il faut prouver qu'ils ne le sont pas.

Le parcours en profondeur y reviendra aussi, sous un autre nom : le retour sur trace est un DFS dans un arbre de choix qu'on ne construit jamais explicitement.

À retenir

Flashcards · 5 cartes

Quand choisir une matrice d'adjacence, quand des listes ?
Matrice : mémoire O(V²) quel que soit le nombre d'arêtes, test d'arête en O(1), mais énumérer les voisins coûte O(V). Listes : mémoire O(V+E), énumérer les voisins coûte leur degré, mais tester une arête précise demande de parcourir une liste. Comme les parcours ÉNUMÈRENT les voisins et testent rarement une arête isolée, et comme les graphes réels sont massivement creux, les listes sont le choix par défaut.
Quelle est la seule différence de fond entre parcourir un arbre et parcourir un graphe ?
Le MARQUAGE des sommets visités. Un arbre est acyclique et chaque nœud n'a qu'un parent : on ne peut pas revenir en arrière. Un graphe peut contenir un cycle, et un parcours non marqué y tourne indéfiniment jusqu'au débordement de pile. Le marquage n'est pas une optimisation, c'est ce qui rend le parcours correct — et en largeur, il faut marquer À L'ENFILEMENT, pas au défilement.
Comment détecte-t-on un cycle, et comment compte-t-on les composantes connexes ?
COMPOSANTES : un parcours depuis un sommet atteint exactement sa composante ; on relance depuis chaque sommet non marqué et on compte les relances, en O(V+E). CYCLE en non orienté : le parcours rencontre un sommet déjà marqué QUI N'EST PAS SON PÈRE IMMÉDIAT (sans cette nuance, toute arête serait vue comme un cycle). En orienté : il faut un arc vers un sommet ENCORE EN COURS de traitement dans la pile de récursion, pas simplement déjà visité.
Pourquoi le BFS donne-t-il le plus court chemin en nombre d'arêtes ?
Parce qu'il explore par COUCHES de distance croissante : tous les sommets à distance 1, puis tous ceux à distance 2, etc. La première fois qu'il atteint un sommet, aucune couche antérieure n'y menait : le chemin est donc minimal en nombre d'arêtes. Mémoriser le prédécesseur de chaque sommet suffit à reconstituer l'itinéraire. Le DFS n'a pas cette propriété : il plonge avant d'explorer les alternatives, et peut atteindre un voisin par un long détour.
Sur quel principe repose Dijkstra, et quelle est sa condition de validité ?
Un principe GLOUTON : à chaque étape on extrait le sommet non traité de plus petite distance, et on le déclare définitif, puis on relâche ses arêtes. C'est valide parce qu'avec des poids POSITIFS, aucun chemin passant par les sommets restants — tous plus loin — ne peut faire mieux. Un poids NÉGATIF casse l'argument : un arc rencontré plus tard pourrait raccourcir un chemin déjà figé. Coût : O(V²) avec une recherche linéaire du minimum, O((V+E) log V) avec un tas.

Chapitre 2 · 3 h

Paradigmes algorithmiques

Algorithme glouton et rendu de monnaie, retour sur trace, et première approche de la programmation dynamique par mémoïsation.

Rendre 6 avec des pièces de 1, 3 et 4. La méthode naturelle — prendre la plus grosse possible, recommencer — donne 4, puis 1, puis 1 : trois pièces. La solution optimale en emploie deux : 3 et 3.

Cette méthode naturelle a un nom, elle est employée partout, et elle vient de donner une réponse fausse sur un exemple de six pièces. Ce dernier chapitre prend de la hauteur sur trois manières de chercher une solution, sur ce que chacune garantit, et surtout sur ce qu'elle ne garantit pas.

L'algorithme glouton

Un algorithme glouton construit la solution par étapes, en faisant à chaque étape le choix qui paraît le meilleur sur le moment, et sans jamais revenir dessus.

Ses qualités sont évidentes : il est simple à écrire, rapide — généralement linéaire ou en nlognn \log n après un tri — et il ne consomme presque rien. Son défaut l'est moins : rien ne garantit que la suite de choix localement optimaux donne un optimum global.

Le rendu de monnaie en est l'illustration parfaite, parce qu'il montre que la réponse dépend du système de pièces. Avec l'euro — 1, 2, 5, 10, 20, 50 — le glouton est toujours optimal. Avec 1, 3, 4, il échoue dès 6. Un système où le glouton est optimal est dit canonique, et le vérifier n'est pas trivial.

D'où la règle du chapitre, qui vaut pour toute votre pratique : un algorithme glouton se prouve ou se rejette, il ne se suppose jamais. Le fait qu'il donne la bonne réponse sur les exemples testés ne dit rien — l'échec de 1, 3, 4 n'apparaît qu'à 6, et il faudrait chercher pour le trouver par hasard.

Trois gloutons célèbres sont prouvés, et le semestre en a déjà croisé un.

Dijkstra (chapitre 9) extrait le sommet non traité le plus proche et le déclare définitif. La preuve tient à la positivité des poids : aucun détour ne peut raccourcir. Changez cette hypothèse, et le glouton devient faux.

Huffman construit un code de compression en fusionnant à chaque étape les deux symboles les moins fréquents. Kruskal construit un arbre couvrant minimal en ajoutant les arêtes par poids croissant, en sautant celles qui créeraient un cycle. Les deux se démontrent, et c'est ce qui les distingue du rendu de monnaie.

Le retour sur trace

Quand le glouton échoue, la solution suivante est d'essayer, mais intelligemment.

Le retour sur trace (backtracking) explore systématiquement l'espace des solutions en construisant une solution partielle, et en revenant en arrière dès qu'elle s'avère impossible à compléter.

fonction explorer(solutionPartielle)    si complète alors enregistrer et retourner    pour chaque choix possible à cette étape        si le choix est compatible avec ce qui est déjà posé            ajouter le choix            explorer(solutionPartielle)            RETIRER le choix          ← le retour sur trace proprement dit

Deux remarques rendent le procédé familier.

C'est un parcours en profondeur du chapitre 9, dans un arbre de choix qu'on ne construit jamais : chaque nœud est une solution partielle, chaque branche un choix, chaque feuille une solution complète ou une impasse. L'arbre n'existe qu'implicitement, sous forme de la pile d'appels du chapitre 1.

Et la ligne qui compte est la dernière : retirer le choix avant d'essayer le suivant. L'oublier laisse l'état pollué par la branche précédente, et c'est la faute numéro un du TD.

Ce qui rend le procédé viable est l'élagage : on abandonne une branche dès qu'elle est condamnée, sans descendre jusqu'aux feuilles. Aux huit dames — placer huit dames sur un échiquier sans qu'aucune n'en attaque une autre — l'exploration brute examinerait 64864^8, soit plus de 101410^{14} dispositions. En plaçant une dame par colonne et en abandonnant dès qu'une attaque apparaît, on descend à quelques milliers de nœuds. L'élagage ne change pas la nature exponentielle du problème ; il déplace la limite du praticable, et cela suffit souvent.

La programmation dynamique

Le retour sur trace essaie tout. La programmation dynamique évite d'essayer deux fois la même chose — et l'on a déjà vu le procédé au chapitre 2, sous le nom de mémoïsation.

Elle s'applique quand deux conditions sont réunies, et il faut les vérifier avant de se lancer.

Sous-structure optimale : la solution optimale du problème se construit à partir des solutions optimales de ses sous-problèmes. C'est ce qui permet de composer.

Chevauchement des sous-problèmes : les mêmes sous-problèmes reviennent un grand nombre de fois. C'est ce qui rend la mémorisation rentable — et c'est ce qui distingue la programmation dynamique de « diviser pour régner » du bloc II. Le tri fusion découpe en moitiés disjointes : aucune sous-question n'est posée deux fois, donc une table ne servirait à rien.

Deux mises en œuvre, pour un même résultat.

Descendante (mémoïsation) : la récursion naturelle, plus une table consultée avant tout calcul. C'est le fibMemo du chapitre 2, et son avantage est de ne calculer que les sous-problèmes réellement atteints.

Ascendante (tabulation) : on remplit la table dans l'ordre croissant des tailles, sans récursion. Plus économe — pas de pile d'appels — mais elle calcule tout, y compris l'inutile.

Reprenons le rendu de monnaie, correctement cette fois. Soit M[k]M[k] le nombre minimal de pièces pour rendre kk :

M[0]=0M[k]=1+minpkM[kp]M[0] = 0 \qquad\qquad M[k] = 1 + \min_{p \,\le\, k} M[k - p]

pp parcourt les valeurs de pièces. Sur 1, 3, 4 avec k=6k=6, la table donne bien 2, en retenant M[3]+1M[3] + 1 plutôt que M[2]+1M[2] + 1. Le glouton, lui, s'était engagé sur le 4 et ne pouvait plus revenir.

Quiz · 1 question

Pourquoi la programmation dynamique n'apporte-t-elle rien au tri fusion, alors qu'elle transforme Fibonacci ?

  • Parce que le tri fusion est déjà en n log n, ce qui est optimal et ne peut pas être améliorédéjà optimal
  • Parce que ses sous-problèmes sont DISJOINTS : les deux moitiés d'un tableau ne se recouvrent pas, aucune sous-question n'est posée deux fois, et une table de mémorisation ne serait jamais consultéesous-problèmes disjoints
  • Parce que le tri fusion n'a pas de sous-structure optimalepas de sous-structure

Réponse : La programmation dynamique exige DEUX conditions, et c'est la seconde qui manque ici. Le tri fusion possède bien la sous-structure optimale — trier les deux moitiés puis fusionner donne le tri complet — mais ses sous-problèmes ne se CHEVAUCHENT pas : trier T[0..n/2] et trier T[n/2..n] portent sur des données disjointes, et aucun sous-problème n'est demandé deux fois. Une table de mémorisation existerait sans jamais servir, en pure perte de mémoire. C'est exactement ce qui sépare « diviser pour régner » de la programmation dynamique : le premier découpe en parties disjointes, la seconde traite des sous-problèmes qui se recoupent. Fibonacci naïf est de la seconde famille — fib(n−1) et fib(n−2) recouvrent presque tout —, d'où le gain spectaculaire de la mémoïsation.

Les quatre paradigmes en regard

ParadigmePrincipeGarantit l'optimumCoût typique
Diviser pour régnerdécouper en parties disjointesouinlognn \log n
Gloutonchoix localement optimal, sans retourseulement si prouvénn ou nlognn \log n
Retour sur traceessayer, élaguer, reveniroui, il explore toutexponentiel
Programmation dynamiquemémoriser les sous-problèmes qui se répètentouipolynomial

La lecture de ce tableau est la conclusion du semestre. On préfère toujours le glouton quand on peut le prouver, la programmation dynamique quand les sous-problèmes se répètent, et le retour sur trace quand rien d'autre ne s'applique — en sachant qu'on paie alors le prix exponentiel, et que l'élagage décide de la taille traitable.

Il existe une cinquième voie, hors programme mais qu'il faut connaître de nom : quand même le retour sur trace est hors de portée, on renonce à l'optimum et l'on emploie une heuristique ou un algorithme d'approximation, qui rend une solution correcte à un facteur près, en temps raisonnable. C'est la réponse pratique à la plupart des problèmes réellement difficiles.

Quiz · 1 question

Un algorithme glouton donne la bonne réponse sur les cinquante jeux de tests d'un TD. Que peut-on en conclure ?

  • Qu'il est correct : cinquante cas couvrent largement l'espace des entréesprouvé par les tests
  • Rien du tout : un glouton se prouve ou se réfute par un contre-exemple. Le rendu de monnaie sur 1, 3, 4 est optimal jusqu'à 5 et échoue à 6 — un jeu de tests ne rencontre pas forcément le cas fautifrien
  • Qu'il est correct sur ce type de données, et qu'il suffit de documenter cette restrictioncorrect sous condition

Réponse : C'est le message central du chapitre. Le rendu de monnaie sur le système 1, 3, 4 donne la réponse optimale pour 1, 2, 3, 4 et 5 : cinq cas consécutifs justes, et le sixième est faux. Un jeu de tests écrit sans connaître le point de rupture a toutes les chances de le manquer, et la confiance qu'il inspire est trompeuse. Un algorithme glouton n'est pas correct parce qu'il passe des tests : il l'est parce qu'on DÉMONTRE que le choix localement optimal ne ferme jamais la porte à l'optimum global — c'est ce qu'on fait pour Dijkstra en s'appuyant sur la positivité des poids, et pour Kruskal en s'appuyant sur une propriété des cycles. À défaut de preuve, il faut chercher un contre-exemple, et à défaut des deux, ne pas conclure.

À vous

L'exercice met les trois paradigmes sur le même problème, ce qui est la meilleure façon de les comparer.

Le rendu de monnaie d'abord : version gloutonne, puis version dynamique, puis recherche automatique du plus petit montant où les deux divergent sur un système donné. Vous retrouverez 6 pour 1, 3, 4 — et vous pourrez vérifier que le système de l'euro ne diverge jamais.

Les huit dames ensuite, en retour sur trace, avec un compteur de nœuds explorés. Vous comparerez le nombre de nœuds avec et sans élagage, sur des échiquiers de 4 à 8 cases de côté, et vous verrez la limite du praticable se déplacer.

Exercice de code

Opposez glouton et programmation dynamique sur le rendu de monnaie, puis mesurez l'élagage aux n dames.

Point de départ

// ── 1. Rendu de monnaie, version gloutonne ────────────────────────────────
function gloutonRendu(pieces, montant) {
  const tri = [...pieces].sort((a, b) => b - a);   // de la plus grosse
  const rendu = [];
  for (const p of tri) {
    while (montant >= p) { rendu.push(p); montant -= p; }
  }
  return montant === 0 ? rendu : null;
}

// ── 2. Rendu de monnaie, programmation dynamique ──────────────────────────
// M[k] = nombre minimal de pièces pour rendre k.
function dynamiqueRendu(pieces, montant) {
  const M = new Array(montant + 1).fill(Infinity);
  M[0] = 0;
  // ← à écrire : pour chaque k de 1 à montant, pour chaque pièce p <= k,
  //   M[k] = min(M[k], 1 + M[k - p])
  return M[montant] === Infinity ? null : M[montant];
}

// ── 3. Cherche le plus petit montant où le glouton se trompe ──────────────
function premierEcart(pieces, jusqua) {
  for (let k = 1; k <= jusqua; k++) {
    const g = gloutonRendu(pieces, k);
    const d = dynamiqueRendu(pieces, k);
    if (g !== null && d !== null && g.length !== d) {
      return { montant: k, glouton: g, gloutonN: g.length, optimal: d };
    }
  }
  return null;
}

// ── 4. Les n dames, en retour sur trace ───────────────────────────────────
function dames(n, avecElagage) {
  let solutions = 0, noeuds = 0;
  const colonnes = [];   // colonnes[i] = colonne de la dame de la ligne i

  function compatible(ligne, col) {
    for (let l = 0; l < ligne; l++) {
      const c = colonnes[l];
      if (c === col || Math.abs(c - col) === ligne - l) return false;
    }
    return true;
  }

  function explorer(ligne) {
    noeuds++;
    if (ligne === n) { solutions++; return; }
    for (let col = 0; col < n; col++) {
      // ← sans élagage on descend même sur une position attaquée, et on ne
      //   teste qu'arrivé au bout : écrivez les deux variantes.
      colonnes[ligne] = col;
      explorer(ligne + 1);
      colonnes.length = ligne;        // LE retour sur trace : retirer le choix
    }
  }

  explorer(0);
  return { solutions, noeuds };
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Écrivez dynamiqueRendu.
// 2. Trouvez le plus petit montant où le glouton échoue sur [1, 3, 4].
//    Vérifiez que le système de l'euro ne diverge jamais.
// 3. Complétez dames() pour comparer le nombre de nœuds avec et sans élagage.

console.log("glouton [1,3,4] pour 6 :", gloutonRendu([1, 3, 4], 6));

Solution

function gloutonRendu(pieces, montant) {
  const tri = [...pieces].sort((a, b) => b - a);
  const rendu = [];
  for (const p of tri) {
    while (montant >= p) { rendu.push(p); montant -= p; }
  }
  return montant === 0 ? rendu : null;
}

function dynamiqueRendu(pieces, montant) {
  const M = new Array(montant + 1).fill(Infinity);
  M[0] = 0;
  // Tabulation ascendante : on remplit dans l'ordre croissant, donc M[k−p]
  // est toujours déjà connu quand on en a besoin. Aucune récursion, aucune
  // pile — et surtout, on n'est engagé par aucun choix antérieur.
  for (let k = 1; k <= montant; k++) {
    for (const p of pieces) {
      if (p <= k && M[k - p] + 1 < M[k]) M[k] = M[k - p] + 1;
    }
  }
  return M[montant] === Infinity ? null : M[montant];
}

function premierEcart(pieces, jusqua) {
  for (let k = 1; k <= jusqua; k++) {
    const g = gloutonRendu(pieces, k);
    const d = dynamiqueRendu(pieces, k);
    if (g !== null && d !== null && g.length !== d) {
      return { montant: k, glouton: g, gloutonN: g.length, optimal: d };
    }
  }
  return null;
}

function dames(n, avecElagage) {
  let solutions = 0, noeuds = 0;
  const colonnes = [];

  function compatible(ligne, col) {
    for (let l = 0; l < ligne; l++) {
      const c = colonnes[l];
      if (c === col || Math.abs(c - col) === ligne - l) return false;
    }
    return true;
  }

  function explorer(ligne) {
    noeuds++;
    if (ligne === n) {
      // Sans élagage, la vérification n'a lieu qu'ici, au bout : on a
      // descendu n niveaux pour découvrir une impasse évidente dès le second.
      if (avecElagage) solutions++;
      else {
        let bon = true;
        for (let l = 1; l < n && bon; l++) if (!compatible(l, colonnes[l])) bon = false;
        if (bon) solutions++;
      }
      return;
    }
    for (let col = 0; col < n; col++) {
      // L'élagage : on abandonne la branche AVANT de descendre.
      if (avecElagage && !compatible(ligne, col)) continue;
      colonnes[ligne] = col;
      explorer(ligne + 1);
      colonnes.length = ligne;   // retirer le choix avant d'essayer le suivant
    }
  }

  explorer(0);
  return { solutions, noeuds };
}

console.log("— rendu de monnaie —");
for (const [nom, pieces] of [["1, 3, 4", [1, 3, 4]], ["euro", [1, 2, 5, 10, 20, 50]]]) {
  const e = premierEcart(pieces, 200);
  if (e) {
    console.log("   système " + nom.padEnd(20) + " : le glouton échoue dès " + e.montant +
      " -> " + e.glouton.join("+") + " (" + e.gloutonN + " pièces) contre " + e.optimal + " optimales");
  } else {
    console.log("   système " + nom.padEnd(20) + " : aucun écart jusqu'à 200 (système canonique)");
  }
}
// Le système de l'euro est canonique — le glouton y est toujours optimal, et
// cela se démontre. Le système 1, 3, 4 est juste sur 1, 2, 3, 4, 5 et faux à
// 6 : cinq cas de test consécutifs justes ne prouvent rien.

console.log("");
console.log("— les n dames : ce que l'élagage change —");
console.log("   n | solutions |  nœuds sans élagage |  nœuds avec élagage");
for (const n of [4, 5, 6, 7, 8]) {
  const sans = dames(n, false);
  const avec = dames(n, true);
  console.log("   " + n + " | " + String(avec.solutions).padStart(9) +
    " | " + String(sans.noeuds).padStart(19) +
    " | " + String(avec.noeuds).padStart(19) +
    "   (" + Math.round(sans.noeuds / avec.noeuds) + " fois moins)");
}
// Les deux versions explorent le MÊME arbre de choix et trouvent les mêmes
// solutions. La seule différence est le moment où l'on teste : au bout pour
// l'une, à chaque niveau pour l'autre. L'élagage ne rend pas le problème
// polynomial — il reste exponentiel — mais il déplace de plusieurs cases la
// limite du praticable, et c'est souvent tout ce qu'on demande.

En travaux pratiques

Travaux pratiques 10 · 2 h

Le même problème, trois stratégies

Attaquer un unique problème par le glouton, le diviser-pour-régner et la programmation dynamique, puis constater lequel donne une solution optimale et à quel prix.

Avant de commencer

  • Tous les TP précédents
  • Le TP 2 : mémoïsation

Énoncé

  1. Le gloutonÉcrivez le rendu de monnaie glouton : prendre à chaque fois la plus grosse pièce possible. Testez-le sur le système de pièces courant.
  2. Le faire échouerTrouvez un système de pièces où le glouton ne donne PAS le minimum. Exhibez la somme et les deux solutions. Indice : Essayez un système où une pièce n'est pas un multiple des plus petites.
  3. La programmation dynamiqueÉcrivez la version dynamique ascendante. Vérifiez qu'elle donne l'optimum sur le système où le glouton échouait, et comparez les temps.
  4. Reconstituer la solutionModifiez la version dynamique pour rendre non seulement le nombre de pièces mais la liste. Comparez à la reconstitution de chemin du TP 9.
  5. Diviser pour régnerSur un autre problème — la sous-séquence de somme maximale — écrivez la version en force brute, celle en diviser-pour-régner, puis la version linéaire. Mesurez les trois.
  6. Reconnaître le paradigmePour cinq problèmes que vous choisirez, dites lequel des trois paradigmes s'applique et à quoi vous l'avez reconnu.
  7. La limiteÉcrivez le sac à dos en dynamique, mesurez son temps pour une capacité de 100, 10 000, un million. Expliquez pourquoi cette complexité n'est pas vraiment polynomiale.

C'est réussi quand

  • Vous exhibez un système de pièces et une somme où le glouton perd
  • Votre version dynamique rend la LISTE des pièces, pas seulement leur nombre
  • Vous nommez le critère qui rend un problème accessible à la programmation dynamique

Correction

Là où le glouton échoue
système {1, 3, 4}, somme 6

glouton  : 4 + 1 + 1        → 3 pièces
optimal  : 3 + 3            → 2 pièces

le glouton fonctionne sur les systèmes CANONIQUES
(euro, dollar), et pas sur un système quelconque —
et rien dans le code ne prévient

Le glouton fait un choix localement optimal en espérant qu'il mène à l'optimum global. Quand cette propriété est démontrée — arbre couvrant minimal, code de Huffman, ordonnancement par échéance — le glouton est optimal ET rapide. Sinon il donne une approximation, ce qui est souvent acceptable, à condition de le SAVOIR.

La version dynamique
int rendu(int somme, int *pieces, int np, int *choix) {
  int *m = malloc((somme+1) * sizeof *m);
  int *utilisee = malloc((somme+1) * sizeof *utilisee);
  m[0] = 0;
  for (int s = 1; s <= somme; s++) {
      m[s] = INT_MAX;
      for (int i = 0; i < np; i++)
          if (pieces[i] <= s && m[s-pieces[i]] != INT_MAX
                            && m[s-pieces[i]] + 1 < m[s]) {
              m[s] = m[s-pieces[i]] + 1;
              utilisee[s] = pieces[i];        /* pour reconstituer */
          }
  }
  /* reconstitution, comme le tableau precedent du TP 9 */
  int s = somme, n = 0;
  while (s > 0) { choix[n++] = utilisee[s]; s -= utilisee[s]; }
  return m[somme];
}

On résout chaque sous-problème une seule fois, du plus petit au plus grand, et on garde le trait du choix fait — exactement le tableau precedent de Dijkstra. C'est le motif général : la table donne la VALEUR de l'optimum, un second tableau permet de le reconstituer.

Les trois versions de la somme maximale
force brute       O(n³)  : n = 10 000 → 6 min
diviser-régner    O(n log n) : n = 10 000 → 0,002 s
Kadane, linéaire  O(n)   : n = 10 000 → 0,00004 s

/* Kadane : une seule boucle, deux variables */
int max_courant = t[0], max_global = t[0];
for (int i = 1; i < n; i++) {
  max_courant = (t[i] > max_courant + t[i]) ? t[i] : max_courant + t[i];
  if (max_courant > max_global) max_global = max_courant;
}

Le diviser-pour-régner est ici une étape intermédiaire, pas la fin de l'histoire : une observation supplémentaire — la meilleure sous-séquence finissant en i se déduit de celle finissant en i−1 — donne une solution linéaire de cinq lignes. Le paradigme oriente la recherche ; il ne dispense pas de réfléchir au problème lui-même.

Reconnaître le paradigme
DIVISER POUR RÉGNER
le problème se coupe en sous-problèmes INDÉPENDANTS
→ tris fusion et rapide, dichotomie, transformée de Fourier

PROGRAMMATION DYNAMIQUE
les sous-problèmes se RECOUVRENT, et l'optimum global se
compose des optimums locaux (sous-structure optimale)
→ rendu de monnaie, sac à dos, plus longue sous-séquence,
  distance d'édition

GLOUTON
un choix local optimal est PROUVÉ mener à l'optimum global
→ arbre couvrant, Huffman, Dijkstra (qui est un glouton !)

La question qui trie : les sous-problèmes se recouvrent-ils ? Si oui, la mémoïsation est payante et c'est de la programmation dynamique ; sinon c'est du diviser-pour-régner. Et Dijkstra est un glouton — il fixe définitivement le sommet le plus proche à chaque étape — ce qui explique en une phrase pourquoi une arête négative le met en défaut.

La limite : pseudo-polynomial
sac à dos dynamique : O(n × C), C = capacité

C = 100        : 0,0001 s
C = 10 000     : 0,01 s
C = 1 000 000  : 1,2 s

mais C s'écrit en log2(C) BITS : passer de 10^6 à 10^9
multiplie le temps par 1000 en n'ajoutant que 10 bits à l'entrée

La complexité est polynomiale en la VALEUR de C, pas en sa TAILLE d'écriture : c'est ce qu'on appelle pseudo-polynomial, et le sac à dos reste NP-difficile. La leçon finale du cours tient là — savoir mesurer un algorithme, c'est aussi savoir reconnaître quand aucun algorithme efficace n'existe, et passer alors à une heuristique ou à une approximation en assumant le compromis.

Ce que ce semestre laisse

Dix chapitres plus tôt, la question était de passer de « comment écrire un algorithme » à « quelle structure choisir ». Le parcours a été le suivant.

La récursivité a donné le moyen de traiter des objets définis en fonction d'eux-mêmes, et la pile d'appels a montré ce que cela coûte. Diviser pour régner en a tiré une stratégie, et l'analyse des récurrences a permis de la chiffrer — jusqu'à démontrer que nlognn \log n est une limite et non une performance. Les structures linéaires ont introduit le vrai sujet du semestre : une opération n'a pas de coût en soi, elle a un coût dans une structure donnée. Les arbres ont fait passer ce coût de nn à logn\log n, sous condition d'équilibre. Les graphes ont montré que tout cela n'était que des cas particuliers.

Reste une idée qui traverse les cinq blocs, et c'est peut-être ce qu'il faut en garder : la plupart des gains du semestre viennent d'une meilleure organisation des données, pas d'un code plus astucieux. Le tri rapide bat le tri par insertion parce qu'il divise, pas parce qu'il compare plus vite. Un ABR bat une liste parce que sa forme élimine la moitié des candidats à chaque comparaison. Et le même parcours donne la profondeur ou la largeur selon qu'on lui donne une pile ou une file.

À retenir

Flashcards · 5 cartes

Qu'est-ce qu'un algorithme glouton, et quelle règle s'impose ?
Il construit la solution par étapes, en faisant à chaque fois le choix qui paraît le meilleur SUR LE MOMENT, sans jamais revenir dessus. Simple et rapide, mais rien ne garantit que des choix localement optimaux donnent un optimum global. RÈGLE : un glouton se prouve ou se rejette, il ne se suppose jamais. Le rendu de monnaie sur 1, 3, 4 est optimal jusqu'à 5 et faux à 6 — les tests ne suffisent pas.
Comment fonctionne le retour sur trace, et quelle ligne oublie-t-on toujours ?
On construit une solution partielle et on revient en arrière dès qu'elle est impossible à compléter. C'est un parcours en PROFONDEUR dans un arbre de choix qu'on ne construit jamais : la pile d'appels le matérialise. La ligne oubliée est la dernière : RETIRER le choix après l'appel récursif, avant d'essayer le suivant — sans quoi l'état reste pollué par la branche précédente. Sa viabilité tient à l'ÉLAGAGE : abandonner une branche condamnée sans descendre aux feuilles.
Quelles sont les deux conditions de la programmation dynamique ?
SOUS-STRUCTURE OPTIMALE : la solution optimale se construit à partir des solutions optimales des sous-problèmes. CHEVAUCHEMENT : les mêmes sous-problèmes reviennent un grand nombre de fois — c'est ce qui rend la mémorisation rentable. La seconde condition est ce qui la distingue de « diviser pour régner », qui découpe en parties DISJOINTES : au tri fusion, une table ne serait jamais consultée.
Quelle différence entre mémoïsation descendante et tabulation ascendante ?
DESCENDANTE : la récursion naturelle, plus une table consultée avant tout calcul — elle ne calcule que les sous-problèmes réellement atteints, au prix d'une pile d'appels. ASCENDANTE : on remplit la table dans l'ordre croissant des tailles, sans récursion — plus économe en mémoire de pile, mais elle calcule tout, y compris l'inutile. Même résultat, même complexité.
Comment choisir entre les quatre paradigmes ?
DIVISER POUR RÉGNER quand les sous-problèmes sont disjoints. GLOUTON quand on peut le PROUVER (Dijkstra grâce aux poids positifs, Huffman, Kruskal) — le plus rapide. PROGRAMMATION DYNAMIQUE quand les sous-problèmes se répètent — polynomial. RETOUR SUR TRACE quand rien d'autre ne s'applique, en payant le prix exponentiel que l'élagage rend parfois praticable. Et au-delà : heuristiques et algorithmes d'approximation, qui renoncent à l'optimum.