← Toutes les fiches

Fiche 1 Parcours : les fondamentaux Lecture : 20 minutes

Les algorithmes à connaître

Reconnaître le type de problème, choisir le bon outil, et savoir quand ne pas s'en servir.

Tu utilises déjà des algorithmes tous les jours : chaque foreach, chaque sort(), chaque requête SQL en exécute un. Un algorithme, c'est simplement une suite d'étapes précises qui résout un problème.

Le but de cette fiche n'est pas de les réciter. Un développeur expérimenté ne connaît pas tout par cœur : il reconnaît la forme d'un problème (« je cherche le chemin le plus court », « je dois essayer des combinaisons ») et sait quel outil y répond. C'est une compétence qui s'apprend, comme le reste.

Les sections suivent un ordre de priorité : les trois premières te serviront chaque semaine, les dernières quelques fois par an. Si tu dois t'arrêter en route, arrête-toi après la section 3, tu auras déjà l'essentiel.

1.La complexité : compter le travail, pas les secondes

Commençons par un problème réel : retirer d'une newsletter les personnes qui se sont désinscrites. Voici deux versions qui donnent le même résultat.

exemples/01/lookup.php
<?php

declare(strict_types=1);

// 20 000 inscrits, 10 000 désinscrits : qui faut-il retirer de la newsletter ?
$subscribers = [];
$unsubscribed = [];
for ($i = 0; $i < 20_000; $i++) {
    $subscribers[] = "user$i@example.com";
    if ($i % 2 === 0) {
        $unsubscribed[] = "user$i@example.com";
    }
}

// Version 1 : in_array relit toute la liste à chaque tour → O(n²)
$start = hrtime(true);
$toRemove = [];
foreach ($subscribers as $email) {
    if (in_array($email, $unsubscribed, true)) {
        $toRemove[] = $email;
    }
}
printf("in_array : %d trouvés en %.1f ms\n", count($toRemove), (hrtime(true) - $start) / 1e6);

// Version 2 : on construit un index une seule fois, puis isset est en O(1) → O(n)
$start = hrtime(true);
$index = array_flip($unsubscribed);
$toRemove = [];
foreach ($subscribers as $email) {
    if (isset($index[$email])) {
        $toRemove[] = $email;
    }
}
printf("isset    : %d trouvés en %.1f ms\n", count($toRemove), (hrtime(true) - $start) / 1e6);
Sortie
in_array : 10000 trouvés en 440.2 ms
isset    : 10000 trouvés en 0.6 ms

Même résultat, mais la première version est des centaines de fois plus lente. Pourquoi ? in_array parcourt toute la liste des désinscrits à chaque tour de la boucle : c'est une boucle cachée dans une boucle. Avec 20 000 inscrits, cela fait jusqu'à 200 millions de comparaisons.

La complexité décrit comment le travail grandit quand la quantité de données grandit. On l'écrit avec la notation O (« grand O ») : O(n) se lit « de l'ordre de n », où n est le nombre d'éléments. On regarde le pire cas et on ignore les détails : seul l'ordre de grandeur compte.

Nombre d'opérations selon la complexité et la taille des données
ComplexitéExemplen = 10n = 1 000n = 1 000 000
O(1) constanteisset($index[$key])111
O(log n) logarithmiquerecherche dichotomique≈ 3≈ 10≈ 20
O(n) linéaireun foreach, in_array101 0001 million
O(n log n)sort()≈ 33≈ 10 000≈ 20 millions
O(n²) quadratiqueune boucle dans une boucle1001 million1 000 milliards
O(2ⁿ) exponentielleessayer toutes les combinaisons1 024impossibleimpossible

À 1 000 milliards d'opérations, un script PHP tourne pendant des heures. C'est pour ça qu'un code qui marche très bien en développement, avec 50 lignes en base, peut s'effondrer en production avec 100 000 lignes.

La règle pratique

  1. Repère les boucles imbriquées sur les mêmes données : c'est souvent du O(n²).
  2. Repère les boucles cachées : in_array, array_search, array_diff appelés dans une boucle, ou une requête SQL dans une boucle (le problème « N+1 », fiche 8).
  3. Demande-toi combien de données il y aura en production dans deux ans, pas aujourd'hui.
Quand ne pas s'en soucier

Sur une liste de 20 éléments, O(n²) fait 400 opérations : c'est instantané. Dans ce cas, choisis le code le plus lisible. Et avant d'optimiser, mesure (avec hrtime() ou le profiler de Symfony) : l'intuition sur la lenteur se trompe souvent.

2.Chercher : boucle ou tableau associatif

Tu as vu les deux façons de chercher dans l'exemple précédent.

  1. La recherche linéaire parcourt les éléments un par un jusqu'à trouver : foreach, in_array, array_search, ou array_find (nouveau en PHP 8.4). C'est du O(n).
  2. Le tableau associatif range chaque valeur sous une clé. En interne, PHP utilise une table de hachage : il calcule directement l'emplacement de la clé à partir de la clé elle-même, sans parcourir les autres. Lire $index[$key] ou tester isset($index[$key]) coûte donc O(1), quelle que soit la taille du tableau.

La technique à retenir : construire un index une seule fois, puis faire toutes les recherches dedans.

Quand l'utiliser, quand s'en passer

Utile dès que tu cherches plusieurs fois dans la même liste, surtout dans une boucle.

Inutile pour une seule recherche : construire l'index parcourt déjà toute la liste, donc autant chercher directement. Et si les données sont en base, laisse la base chercher avec un WHERE sur une colonne indexée (fiche 7) plutôt que de tout charger en PHP.

3.Trier

N'écris jamais ton propre algorithme de tri en production. Les fonctions de PHP sont en O(n log n), testées depuis des années, et stables depuis PHP 8.0 : deux éléments égaux gardent leur ordre d'origine. Ton travail se limite à dire comment comparer deux éléments.

exemples/01/sort.php
<?php

declare(strict_types=1);

$products = [
    ['name' => 'Clavier', 'price' => 4990],   // prix en centimes
    ['name' => 'Souris', 'price' => 1990],
    ['name' => 'Moniteur', 'price' => 19900],
    ['name' => 'Tapis', 'price' => 1990],
];

// Par prix croissant, puis par nom si deux prix sont égaux.
// <=> compare les deux tableaux case par case et renvoie -1, 0 ou 1.
usort(
    $products,
    fn (array $a, array $b): int => [$a['price'], $a['name']] <=> [$b['price'], $b['name']],
);

foreach ($products as $product) {
    printf("%-9s %7.2f €\n", $product['name'], $product['price'] / 100);
}
Sortie
Souris      19.90 €
Tapis       19.90 €
Clavier     49.90 €
Moniteur   199.00 €

La fonction de comparaison reçoit deux éléments et renvoie un nombre négatif si $a doit passer avant, positif s'il doit passer après, zéro s'ils sont égaux. L'opérateur <=> (« vaisseau spatial ») calcule exactement ça. Pour trier par ordre décroissant, inverse $a et $b.

Quand ne pas trier en PHP

Si les données viennent de la base, utilise ORDER BY avec LIMIT : la base peut s'appuyer sur un index et ne renvoie que ce qui sera affiché. Charger 100 000 lignes pour les trier en PHP et en afficher 20, c'est payer en mémoire et en temps pour rien.

4.La recherche dichotomique

Tu connais le jeu « plus ou moins » : on cherche un nombre entre 1 et 1 000. La meilleure stratégie est de proposer 500, puis 250 ou 750, et ainsi de suite. Chaque réponse élimine la moitié des possibilités. En 10 essais, tu trouves n'importe quel nombre. Entre 1 et 1 million, il en faut seulement 20.

C'est la recherche dichotomique (en anglais binary search), en O(log n). Elle a une condition : les données doivent être triées. Ici, on cherche la tranche de frais de port qui correspond à un poids.

La recherche dichotomique coupe la liste en deux à chaque étape On cherche 41 dans une liste triée de 15 valeurs. Étape 1 : le milieu vaut 37, 41 est plus grand, on garde la moitié droite. Étape 2 : le milieu vaut 60, on garde la gauche. Étape 3 : le milieu vaut 48, on garde la gauche. Étape 4 : le milieu vaut 41, trouvé. Quatre comparaisons au lieu de neuf en lisant la liste du début. On cherche 41 dans 15 valeurs triées 1. milieu : 37 41 > 37 : à droite 3 8 12 15 21 26 30 37 41 48 52 60 67 73 85 2. milieu : 60 41 < 60 : à gauche 3 8 12 15 21 26 30 37 41 48 52 60 67 73 85 3. milieu : 48 41 < 48 : à gauche 3 8 12 15 21 26 30 37 41 48 52 60 67 73 85 4. milieu : 41 trouvé 3 8 12 15 21 26 30 37 41 48 52 60 67 73 85 En pointillés : les cases éliminées. Chaque comparaison écarte la moitié de ce qui reste : 15 valeurs demandent 4 comparaisons au plus, 1 000 en demandent 10, et 1 million 20.
Schéma 1.1 · La recherche dichotomique coupe la liste en deux à chaque étape
exemples/01/binary_search.php
<?php

declare(strict_types=1);

/**
 * Renvoie l'indice du plus grand seuil inférieur ou égal à $value, ou -1.
 * $thresholds DOIT être trié par ordre croissant.
 *
 * @param list<int> $thresholds
 */
function findTier(array $thresholds, int $value): int
{
    $low = 0;
    $high = count($thresholds) - 1;
    $found = -1;

    while ($low <= $high) {
        $middle = intdiv($low + $high, 2);
        if ($thresholds[$middle] <= $value) {
            $found = $middle;      // candidat possible : on cherche plus grand à droite
            $low = $middle + 1;
        } else {
            $high = $middle - 1;   // trop grand : on cherche à gauche
        }
    }

    return $found;
}

$thresholds = [0, 500, 1000, 2000, 5000];   // poids en grammes
$fees = [450, 600, 850, 1200, 2000];        // frais de port en centimes

foreach ([250, 500, 1999, 7000] as $weight) {
    $tier = findTier($thresholds, $weight);
    printf("%5d g → %5.2f €\n", $weight, $fees[$tier] / 100);
}
Sortie
  250 g →  4.50 €
  500 g →  6.00 €
 1999 g →  8.50 €
 7000 g → 20.00 €

Tu t'en sers déjà sans le savoir : un index de base de données (un « B-tree ») applique la même idée, et git bisect (fiche 4) trouve le commit qui a introduit un bug en coupant l'historique en deux à chaque étape.

Quand l'utiliser, quand s'en passer

Utile pour une grande liste triée consultée souvent : des plages d'adresses IP, un barème de plusieurs milliers de lignes.

À éviter avec 5 tranches comme ici : une simple boucle est plus lisible, et la différence ne se mesure pas. L'exemple est volontairement petit, pour rester court. Si la liste n'est pas triée, la trier coûte O(n log n), plus cher qu'une seule recherche linéaire. Enfin, c'est l'algorithme où l'on se trompe le plus d'une case (< au lieu de <=, oublier le + 1) : teste toujours les bords (premier seuil, dernier seuil, valeur exacte d'un seuil).

5.La récursivité

Une fonction récursive est une fonction qui s'appelle elle-même sur un morceau plus petit du problème. Elle a toujours deux parties :

  1. Le cas de base : le cas assez simple pour répondre sans s'appeler. Sans lui, la fonction ne s'arrête jamais.
  2. L'appel récursif : la fonction s'appelle sur un problème plus petit, qui se rapproche du cas de base.

Elle est naturelle pour les données en arbre : chaque élément a des enfants, qui ont eux-mêmes des enfants, sans profondeur fixée à l'avance. Par exemple des catégories :

exemples/01/recursion.php
<?php

declare(strict_types=1);

// Les lignes telles qu'elles sortent d'une table SQL avec une colonne parent_id
$categories = [
    ['id' => 1, 'parent' => null, 'name' => 'Informatique'],
    ['id' => 2, 'parent' => 1, 'name' => 'Ordinateurs'],
    ['id' => 3, 'parent' => 2, 'name' => 'Portables'],
    ['id' => 4, 'parent' => 1, 'name' => 'Accessoires'],
    ['id' => 5, 'parent' => null, 'name' => 'Maison'],
];

// Index « parent → enfants » : encore un tableau associatif
$children = [];
foreach ($categories as $category) {
    $children[$category['parent'] ?? 0][] = $category;
}

function render(array $children, int $parentId, int $depth = 0): void
{
    // Cas de base : une catégorie sans enfant → la boucle ne tourne pas, on s'arrête
    foreach ($children[$parentId] ?? [] as $category) {
        echo str_repeat('  ', $depth), '- ', $category['name'], "\n";
        render($children, $category['id'], $depth + 1);   // l'appel récursif
    }
}

render($children, 0);
Sortie
- Informatique
  - Ordinateurs
    - Portables
  - Accessoires
- Maison

Remarque que l'index $children est un tableau associatif (section 2) : sans lui, il faudrait reparcourir toutes les catégories à chaque appel.

Quand l'utiliser, quand s'en passer

Utile pour les arbres : catégories, commentaires imbriqués, menus, dossiers, JSON imbriqué.

À éviter pour une simple liste, où un foreach est plus clair. Et chaque appel garde ses variables en mémoire jusqu'à ce qu'il se termine : sur une profondeur de plusieurs centaines de milliers, le script s'arrête avec l'erreur Allowed memory size exhausted. Dans ce cas, une boucle avec une pile (section 6) fait le même travail. Si les données sont dans PostgreSQL, une requête WITH RECURSIVE peut aussi parcourir l'arbre directement en base.

Sécurité

Si une catégorie est, par erreur, son propre parent (ou celui de son grand-parent), la récursion ne s'arrête plus et le script consomme toute sa mémoire. Valide ces données à l'enregistrement. Pour des données qui viennent de l'extérieur, limite la profondeur : c'est le rôle du paramètre $depth de json_decode(), qui refuse par défaut un JSON imbriqué sur plus de 512 niveaux.

6.Parcourir un graphe : en largeur ou en profondeur

Un graphe, c'est un ensemble d'éléments (les nœuds) reliés entre eux (par des arêtes). Les pages d'un site et leurs liens, des utilisateurs qui se suivent, les paquets Composer et leurs dépendances, des stations de métro : ce sont des graphes. Un arbre est un graphe particulier, sans boucle.

Il existe deux grandes façons de parcourir un graphe.

  1. En largeur (BFS, breadth-first search) : on visite d'abord tous les voisins directs, puis les voisins des voisins, niveau par niveau. On range les nœuds à visiter dans une file d'attente (le premier arrivé est le premier servi). Comme on avance niveau par niveau, le premier chemin trouvé est le plus court en nombre d'étapes.
  2. En profondeur (DFS, depth-first search) : on suit un chemin le plus loin possible, puis on revient en arrière pour essayer le suivant. On utilise une pile (le dernier arrivé est le premier servi) ou la récursivité. Utile pour tout explorer, détecter une boucle, ou trouver dans quel ordre installer des dépendances.

Exemple : combien de clics faut-il au minimum pour aller de l'accueil au panier ?

exemples/01/bfs.php
<?php

declare(strict_types=1);

// Chaque page et les pages vers lesquelles elle a un lien
$links = [
    '/' => ['/blog', '/produits'],
    '/blog' => ['/blog/php', '/contact'],
    '/blog/php' => ['/produits/clavier'],
    '/produits' => ['/produits/clavier'],
    '/produits/clavier' => ['/panier'],
    '/contact' => [],
    '/panier' => [],
];

/**
 * Parcours en largeur : renvoie le chemin le plus court en nombre de clics.
 *
 * @return list<string>|null
 */
function shortestPath(array $links, string $from, string $to): ?array
{
    $queue = new SplQueue();          // file d'attente : premier entré, premier sorti
    $queue->enqueue([$from]);
    $visited = [$from => true];       // pour ne jamais traiter deux fois la même page

    while (!$queue->isEmpty()) {
        $path = $queue->dequeue();
        $page = $path[array_key_last($path)];
        if ($page === $to) {
            return $path;
        }
        foreach ($links[$page] ?? [] as $next) {
            if (!isset($visited[$next])) {
                $visited[$next] = true;
                $queue->enqueue([...$path, $next]);
            }
        }
    }

    return null;                      // la page n'est pas atteignable
}

echo implode(' → ', shortestPath($links, '/', '/panier') ?? ['aucun chemin']), "\n";
echo implode(' → ', shortestPath($links, '/contact', '/') ?? ['aucun chemin']), "\n";
Sortie
/ → /produits → /produits/clavier → /panier
aucun chemin
Le parcours en largeur avance niveau par niveau Les pages de l'exemple et leurs liens, rangées par nombre de clics depuis l'accueil. Le parcours visite d'abord l'accueil, puis /blog et /produits (1 clic), puis /blog/php, /contact et /produits/clavier (2 clics), puis /panier (3 clics). Le lien de /blog/php vers /produits/clavier est ignoré, car cette page est déjà visitée. Le chemin le plus court passe par /produits et /produits/clavier. départ 1 clic 2 clics 3 clics déjà vue : ignorée / 1 /blog 2 /produits 3 /blog/php 4 /contact 5 /produits/clavier 6 /panier 7 le chemin le plus court : / → /produits → /produits/clavier → /panier, 3 clics 1 l'ordre de visite : la file d'attente traite tout un niveau avant le suivant
Schéma 1.2 · Le parcours en largeur avance niveau par niveau

Le tableau $visited est indispensable. Dès qu'un graphe contient une boucle (la page A a un lien vers B, qui a un lien vers A), un parcours sans mémoire tourne à l'infini. Pour passer en profondeur, remplace SplQueue par SplStack, avec push() et pop() : le parcours trouvera un chemin, mais plus forcément le plus court.

Quand l'utiliser, quand s'en passer

Utile pour un chemin le plus court en nombre d'étapes, des recommandations (« amis d'amis »), des dépendances, des redirections en chaîne.

Inutile pour une relation sur un seul niveau : une jointure SQL suffit. Si les arêtes ont un coût différent (des kilomètres, des minutes), le parcours en largeur ne donne plus le bon résultat : il faut l'algorithme de Dijkstra, qui sort du cadre de cette fiche.

7.Le backtracking (retour sur trace)

Imagine un labyrinthe. Tu avances. À un carrefour, tu choisis un couloir. S'il mène à une impasse, tu reviens au dernier carrefour et tu essaies le couloir suivant. C'est le backtracking : construire une solution pas à pas, et revenir en arrière dès qu'un choix ne mène nulle part. Il se résume en trois gestes :

  1. Choisir une option.
  2. Explorer la suite avec ce choix (par un appel récursif).
  3. Annuler le choix si l'exploration échoue, puis essayer l'option suivante.

Exemple : quels articles prendre pour dépenser exactement une carte cadeau de 50 € ?

exemples/01/backtracking.php
<?php

declare(strict_types=1);

/**
 * Cherche des articles dont le total fait exactement $target (en centimes).
 *
 * @param list<int> $prices
 * @param list<int> $chosen indices déjà choisis
 * @return list<int>|null
 */
function findExact(array $prices, int $target, int $start = 0, array $chosen = []): ?array
{
    if ($target === 0) {
        return $chosen;                   // solution trouvée
    }
    for ($i = $start; $i < count($prices); $i++) {
        if ($prices[$i] > $target) {
            continue;                     // élagage : trop cher, inutile d'explorer
        }
        $chosen[] = $i;                                                   // 1. choisir
        $result = findExact($prices, $target - $prices[$i], $i + 1, $chosen); // 2. explorer
        if ($result !== null) {
            return $result;
        }
        array_pop($chosen);                                               // 3. annuler
    }

    return null;                          // impasse : on revient en arrière
}

$names = ['Livre', 'Jeu', 'Mug', 'Casque', 'Stylo', 'Agenda'];
$prices = [1990, 2490, 1250, 3500, 990, 1520];

$solution = findExact($prices, 5000);     // une carte cadeau de 50 €
foreach ($solution ?? [] as $i) {
    printf("%-6s %5.2f €\n", $names[$i], $prices[$i] / 100);
}
echo $solution === null ? "Aucune combinaison\n" : '';
Sortie
Jeu    24.90 €
Stylo   9.90 €
Agenda 15.20 €
Le backtracking essaie, échoue, revient en arrière L'arbre des choix pour dépenser exactement 50 €. Premier choix : le livre, il reste 30,10 €. Toutes les suites échouent : le jeu, le mug, le stylo et l'agenda mènent à des impasses, et le casque est élagué car il coûte plus que le reste. On annule le livre et on essaie le jeu : le mug échoue, le casque est élagué, le stylo laisse 15,20 €, et l'agenda complète exactement. Carte cadeau 50,00 € Livre 19,90 reste 30,10 Jeu 24,90 reste 25,10 Jeu → reste 5,20 impasse Mug → reste 17,60 impasse Casque : 35,00 > 30,10 élagué Stylo → reste 20,20 impasse Agenda → reste 14,90 impasse Mug → reste 12,60 impasse Casque : 35,00 > 25,10 élagué Stylo → reste 15,20 Agenda → 0 ✓ Tout échoue sous Livre : on annule ce choix, et on essaie Jeu. Solution : Jeu + Stylo + Agenda = 50,00 €. Élaguer, c'est ne pas explorer une branche dont on sait déjà qu'elle ne peut pas aboutir.
Schéma 1.3 · Le backtracking essaie, échoue, revient en arrière

Le test $prices[$i] > $target est un élagage : on coupe une branche dès qu'on sait qu'elle ne peut pas aboutir, sans l'explorer. C'est ce qui rend le backtracking utilisable en pratique. Note aussi que les prix sont en centimes : avec des nombres à virgule (float), 0.1 + 0.2 ne vaut pas exactement 0.3, et un total « exact » ne tomberait jamais juste.

Quand l'utiliser, quand s'en passer

Utile pour les problèmes à contraintes où il faut essayer des combinaisons : un planning sans conflit de créneaux, un sudoku, un placement d'éléments.

Dangereux sur de grandes entrées, car c'est du O(2ⁿ) : avec 30 articles, il y a plus d'un milliard de combinaisons. Limite la taille des entrées, ou cherche une autre approche (sections 8 et 9).

Sécurité : le ReDoS

Le moteur des expressions régulières de PHP (PCRE) fonctionne par backtracking. Une regex mal écrite, comme un groupe répété dans un groupe répété, peut exploser sur un texte choisi par un attaquant. C'est le ReDoS (déni de service par expression régulière).

exemples/01/redos.php
<?php

declare(strict_types=1);

// (a+)+ : un groupe répété dans un groupe répété. Sur un texte qui ÉCHOUE,
// le moteur essaie toutes les façons de découper les « a » : 2^n essais.
$input = str_repeat('a', 30) . '!';

$start = hrtime(true);
$result = preg_match('/^(a+)+$/', $input);
printf(
    "résultat : %s, erreur : %s, durée : %d ms\n",
    var_export($result, true),
    preg_last_error_msg(),
    (hrtime(true) - $start) / 1e6,
);
Sortie
résultat : false, erreur : Backtrack limit exhausted, durée : 1 ms

PHP s'arrête après un nombre d'étapes fixé (1 million par défaut, réglage pcre.backtrack_limit), mais preg_match renvoie alors false, pas 0. Un code qui écrit if (preg_match($forbidden, $input)) pour bloquer un contenu interdit le laisse donc passer. Teste toujours === false, évite les quantificateurs imbriqués, et limite la longueur du texte avant d'appliquer une regex.

8.Mémoïsation et programmation dynamique

Dans la suite de Fibonacci, chaque nombre est la somme des deux précédents. La version récursive naïve recalcule sans cesse les mêmes valeurs : fibonacci(25) calcule fibonacci(23) deux fois, fibonacci(22) trois fois, et ainsi de suite.

exemples/01/memoization.php
<?php

declare(strict_types=1);

// Suite de Fibonacci : chaque nombre est la somme des deux précédents.
// $calls compte les appels, pour voir le travail réellement fait.
function fibonacci(int $n, int &$calls): int
{
    $calls++;

    return $n < 2 ? $n : fibonacci($n - 1, $calls) + fibonacci($n - 2, $calls);
}

function fibonacciMemo(int $n, int &$calls, array &$cache = []): int
{
    $calls++;
    if (isset($cache[$n])) {
        return $cache[$n];                // déjà calculé : on relit le résultat
    }

    return $cache[$n] = $n < 2
        ? $n
        : fibonacciMemo($n - 1, $calls, $cache) + fibonacciMemo($n - 2, $calls, $cache);
}

$calls = 0;
printf("sans cache : %d en %d appels\n", fibonacci(25, $calls), $calls);
$calls = 0;
printf("avec cache : %d en %d appels\n", fibonacciMemo(25, $calls), $calls);
Sortie
sans cache : 75025 en 242785 appels
avec cache : 75025 en 49 appels
Sans cache, la récursion refait les mêmes calculs L'arbre des appels de f(5), où f est la fonction de Fibonacci. Sans cache, il compte 15 appels : f(3) est calculé deux fois et f(2) trois fois, soit 8 appels qui refont un travail déjà fait. Avec la mémoïsation, chaque valeur n'est calculée qu'une fois : f(5), f(4), f(3), f(2), f(1), f(0), plus trois lectures du cache, soit 9 appels. Sans cache : 15 appels Avec cache : 9 appels f(5) f(4) f(3) f(3) f(2) f(2) f(1) f(2) f(1) f(1) f(0) f(1) f(0) f(1) f(0) f(5) f(4) f(3) f(3) f(2) f(2) f(1) f(1) f(0) calcul refait : 8 appels sur 15 lu dans le cache Pour fibonacci(25), l'exemple compte 242 785 appels sans cache, et 49 avec.
Schéma 1.4 · Sans cache, la récursion refait les mêmes calculs
  1. La mémoïsation consiste à garder en mémoire le résultat d'un appel pour ne jamais le recalculer. C'est un cache à l'échelle d'une fonction. Le composant Cache de Symfony applique la même idée à l'échelle d'une application (fiche 13).
  2. La programmation dynamique va plus loin : elle résout d'abord les petits sous-problèmes, range leurs solutions dans un tableau, et construit la solution du grand problème à partir d'eux. La fonction optimalChange de la section suivante en est un exemple. La fonction levenshtein() de PHP, qui compte les modifications pour passer d'un mot à un autre, en est un autre.
Quand l'utiliser, quand s'en passer

Utile quand les mêmes sous-problèmes reviennent plusieurs fois : c'est le signe à repérer.

Inutile s'ils ne se répètent pas : le cache consomme de la mémoire sans rien faire gagner. Risqué si le résultat dépend d'autre chose que des paramètres (la date, la base de données) : le cache renverra une valeur périmée. Et un cache sans limite de taille peut finir par remplir la mémoire.

9.Les algorithmes gloutons

Un algorithme glouton fait, à chaque étape, le choix qui paraît le meilleur tout de suite, sans jamais revenir en arrière. Il est simple et rapide. Exemple : rendre la monnaie en prenant toujours la plus grosse pièce possible.

exemples/01/greedy_vs_dp.php
<?php

declare(strict_types=1);

/**
 * Glouton : prend toujours la plus grosse pièce possible.
 *
 * @param list<int> $coins
 * @return list<int>
 */
function greedyChange(array $coins, int $amount): array
{
    rsort($coins);
    $result = [];
    foreach ($coins as $coin) {
        while ($amount >= $coin) {
            $amount -= $coin;
            $result[] = $coin;
        }
    }

    return $result;
}

/**
 * Programmation dynamique : calcule la meilleure solution pour 1, 2, 3… jusqu'à $amount,
 * en réutilisant les solutions déjà calculées pour les montants plus petits.
 *
 * @param list<int> $coins
 * @return list<int>|null
 */
function optimalChange(array $coins, int $amount): ?array
{
    $best = [0 => []];                    // $best[$x] = moins de pièces pour faire $x
    for ($x = 1; $x <= $amount; $x++) {
        $best[$x] = null;                 // null = impossible pour l'instant
        foreach ($coins as $coin) {
            if ($coin > $x || $best[$x - $coin] === null) {
                continue;
            }
            $candidate = [...$best[$x - $coin], $coin];
            if ($best[$x] === null || count($candidate) < count($best[$x])) {
                $best[$x] = $candidate;
            }
        }
    }

    return $best[$amount];
}

$euros = [1, 2, 5, 10, 20, 50, 100, 200];   // les pièces en centimes
echo 'Euros, 3,87 € glouton : ', implode(' + ', greedyChange($euros, 387)), "\n";

$odd = [1, 3, 4];                           // un système de pièces inventé
echo 'Pièces 1/3/4, glouton : ', implode(' + ', greedyChange($odd, 6)), "\n";
echo 'Pièces 1/3/4, optimal : ', implode(' + ', optimalChange($odd, 6) ?? []), "\n";
Sortie
Euros, 3,87 € glouton : 200 + 100 + 50 + 20 + 10 + 5 + 2
Pièces 1/3/4, glouton : 4 + 1 + 1
Pièces 1/3/4, optimal : 3 + 3

Avec les pièces en euros, le glouton donne la meilleure réponse. Mais avec des pièces de 1, 3 et 4 pour rendre 6, il prend 4 puis doit compléter avec 1 + 1 : trois pièces, alors que 3 + 3 en demande deux. La programmation dynamique, elle, compare toutes les possibilités pour chaque montant de 1 à 6, et trouve la vraie meilleure réponse.

Quand l'utiliser, quand s'en passer

Utile quand on sait que le meilleur choix local donne le meilleur résultat final (les pièces en euros, ou placer un maximum de réunions dans une salle en prenant toujours celle qui finit le plus tôt). Utile aussi quand une bonne réponse suffit et que la meilleure coûterait trop cher.

À éviter quand il faut la réponse exacte et que rien ne garantit que le glouton la trouve. Cherche un contre-exemple, comme 1/3/4 ici : s'il en existe un, passe à la programmation dynamique.

10.Quel outil pour quel problème ?

Si tu te dis…Pense à…Coût
« Je cherche souvent dans la même liste »Un tableau associatif comme indexO(1) par recherche
« Je dois afficher dans un certain ordre »usort, ou ORDER BY en SQLO(n log n)
« Je cherche dans une grande liste triée »La recherche dichotomiqueO(log n)
« Mes données sont imbriquées sur plusieurs niveaux »La récursivitéO(n)
« Je cherche le chemin le plus court »Le parcours en largeurO(nœuds + arêtes)
« Je dois essayer des combinaisons sous contraintes »Le backtracking, avec élagageJusqu'à O(2ⁿ)
« Je recalcule plusieurs fois la même chose »La mémoïsation, la programmation dynamiqueSelon le problème
« Le meilleur choix immédiat suffit »Un algorithme gloutonSouvent O(n log n)

Pièges fréquents

PiègeCe qui arriveLa parade
in_array dans une boucleO(n²) caché, lent avec beaucoup de donnéesUn index avec array_flip puis isset
isset sur une valeur nullisset(['a' => null]['a']) renvoie falsearray_key_exists() si la valeur peut être null
$sorted = sort($list)sort trie le tableau sur place et renvoie true, pas le tableau triéAppeler sort($list) puis utiliser $list
Récursion sans cas de base, ou données en boucleLe script consomme toute sa mémoire et s'arrêteÉcrire le cas de base en premier, mémoriser les nœuds visités
Montants en floatDes totaux qui ne tombent jamais justeDes entiers en centimes
Erreur d'une case en recherche dichotomiqueUn élément jamais trouvé, ou une boucle infinieTester le premier, le dernier et chaque valeur exacte d'un seuil
preg_match testé sans === falseUne erreur de regex passe pour « pas de correspondance »Tester === false et lire preg_last_error_msg()
Optimiser sans mesurerDu code compliqué pour un gain invisibleMesurer d'abord, avec des données de taille réelle

Checklist avant de coder

Exercice : les redirections en chaîne

Un site garde une table de redirections : quand on visite une ancienne adresse, on est envoyé vers la nouvelle. Crée le fichier exemples/01/exercice.php avec ces données :

$redirects = [
    '/ancien-blog' => '/blog',
    '/blog' => '/actualites',
    '/promo' => '/soldes',
    '/soldes' => '/promo',
    '/contact-us' => '/contact',
    '/noel' => '/promo',
];
  1. Écris une fonction resolve(array $redirects, string $url): string qui suit les redirections jusqu'à l'adresse finale. Vérifie que resolve($redirects, '/ancien-blog') renvoie '/actualites' et que resolve($redirects, '/contact') renvoie '/contact'.
  2. Appelle resolve($redirects, '/promo'). Que se passe-t-il ? Modifie ta fonction pour qu'elle lance une LogicException quand elle détecte une boucle, y compris pour '/noel'. Indice : quelle structure de la section 2 dit en O(1) si tu es déjà passé par une adresse ?
  3. Quelle est la complexité de resolve pour une chaîne de n redirections ? Et si tu appelles resolve pour chaque adresse de la table ? Bonus : utilise la mémoïsation (section 8) pour que résoudre toute la table reste en O(n).
  4. Question de sécurité : ces redirections sont saisies dans un formulaire d'administration. Que se passe-t-il si quelqu'un y enregistre https://site-malveillant.example ? Quelle vérification ajouterais-tu ?

Pour exécuter ton fichier :

docker compose run --rm php php exemples/01/exercice.php

Envoie-moi ton code et tes réponses : on en fera la revue ensemble avant la fiche 2.

Conseils

  1. Commence simple. Écris d'abord la version la plus lisible, puis mesure. Optimise seulement ce qui est réellement lent.
  2. Dessine le problème. Sur papier, un arbre ou un graphe se voit tout de suite. Une fois dessiné, tu reconnais souvent la forme du problème.
  3. Teste avec de vrais volumes. Génère 100 000 lignes avec une boucle (comme dans l'exemple de la section 1) : les problèmes de complexité n'apparaissent qu'à cette échelle.
  4. Pratique par petites doses. L'Advent of Code propose chaque décembre un problème par jour, de plus en plus difficile. Les premiers jours sont abordables, et la plupart des sections de cette fiche y servent.
  5. Ne te compare pas. Ces notions s'oublient quand on ne s'en sert pas, chez tout le monde. Savoir qu'elles existent et où les retrouver, c'est déjà l'essentiel.

Documentation officielle