Retour à la page d'accueilNSI · Terminale (spé maths) · Changer de matière
Commencer gratuitement
Terminale (spé maths) · NSI
Structures de données, bases de données et algorithmique · Algorithmique avancée et programmation

Diviser pour régner et programmation dynamique

Dans la médiathèque de ta ville, 1 000 livres sont rangés par ordre alphabétique. Pour trouver un titre, tu ne les feuillettes pas tous : tu ouvres au milieu, tu regardes si le titre est avant ou après, et tu élimines la moitié des livres d'un coup. Combien d'essais faut-il au maximum ? Et comment trier 1 000 livres sans y passer la journée ? Tu vas voir que la même idée, découper le problème, résout ces deux questions. Une autre idée, ne jamais calculer deux fois la même chose, résout les problèmes où les découpages se recoupent.

Récursivité : une fonction qui s'appelle elle-même

Une fonction est récursive quand elle s'appelle elle-même sur un problème plus petit. Elle comporte toujours deux parties :

  • un cas de base : un cas assez simple pour répondre directement, sans nouvel appel ;

  • un appel récursif : on se ramène à un problème plus petit, qui se rapproche du cas de base.

def somme(n):

    if n == 0:          # cas de base

        return 0

    return n + somme(n - 1)   # appel récursif

Chaque appel attend le résultat du suivant : les appels s'empilent, puis les résultats remontent.

Empilement des appels de somme(3) puis remontée des résultats somme(3) = 3 + somme(2) somme(2) = 2 + somme(1) somme(1) = 1 + somme(0) somme(0) = 0 (cas de base) ↑ 1 + 0 = 1 ↑ 2 + 1 = 3 ↑ 3 + 3 = 6
Entraîne-toi★☆☆Pas commencé
On définit en Python : a) Identifier le cas de base et l'appel récursif. b) Détailler le calcul de f(4). c) Dire ce qui se passe si l'on appelle f(-1) et pourquoi.

On définit en Python :

def f(n):

    if n == 0:

        return 1

    return n * f(n - 1)
  1. a) Identifier le cas de base et l'appel récursif.
  1. b) Détailler le calcul de f(4).
  1. c) Dire ce qui se passe si l'on appelle f(-1) et pourquoi.
Résoudre

Diviser pour régner et recherche dichotomique

Diviser pour régner est une méthode en trois temps :

  1. diviser le problème en sous-problèmes plus petits, de même nature ;

  2. régner : résoudre chaque sous-problème (souvent par récursivité) ;

  3. combiner les résultats pour obtenir la solution.

Le cas le plus simple : la recherche dichotomique dans une liste triée. On compare la valeur cherchée à l'élément du milieu. Selon le résultat, on garde la moitié gauche ou la moitié droite, et on recommence.

Liste triée de sept éléments dont l'élément du milieu, d'indice 3, est mis en évidence 3 7 11 15 20 31 40 0 1 2 3 4 5 6 milieu
def dichotomie(t, x):

    debut, fin = 0, len(t) - 1

    while debut <= fin:

        milieu = (debut + fin) // 2

        if t[milieu] == x:

            return milieu

        elif t[milieu] < x:

            debut = milieu + 1

        else:

            fin = milieu - 1

    return None

Pourquoi c'est efficace ? À chaque étape, le nombre d'éléments restants est divisé par 2. Avec $n$ éléments, il faut environ $\log_2 n$ étapes (le nombre de fois qu'on peut diviser $n$ par 2 avant d'arriver à 1). Pour 1 000 éléments : $2^{10} = 1\,024 > 1\,000$, donc au plus 10 comparaisons environ, contre 1 000 pour une recherche séquentielle dans le pire des cas.

Entraîne-toi★☆☆Pas commencé
On étudie la recherche dichotomique dans la liste triée [2, 5, 8, 12, 16, 23, 38] de la valeur 23. a) Donner les indices et valeurs examinés à chaque étape. b) Donner le…

On étudie la recherche dichotomique dans la liste triée [2, 5, 8, 12, 16, 23, 38] de la valeur 23.

  1. a) Donner les indices et valeurs examinés à chaque étape.
  1. b) Donner le nombre maximal de comparaisons pour une liste de 1 000 éléments.
  1. c) Comparer avec une recherche séquentielle.
Résoudre

Le tri fusion

Le tri fusion applique diviser pour régner au tri d'une liste :

  1. diviser la liste en deux moitiés ;

  2. trier chaque moitié (par récursivité) ;

  3. fusionner les deux listes triées en une seule liste triée.

Le cas de base est une liste de 0 ou 1 élément : elle est déjà triée.

Arbre des divisions du tri fusion de la liste 4, 7, 1, 3 sur trois niveaux [4, 7, 1, 3] [4, 7] [1, 3] [4] [7] [1] [3]

L'étape clé est la fusion de deux listes déjà triées : on compare les deux premiers éléments restants et on prend le plus petit.

def fusion(a, b):

    i, j = 0, 0

    res = []

    while i < len(a) and j < len(b):

        if a[i] <= b[j]:

            res.append(a[i])

            i += 1

        else:

            res.append(b[j])

            j += 1

    return res + a[i:] + b[j:]

def tri_fusion(t):

    if len(t) <= 1:

        return t

    m = len(t) // 2

    return fusion(tri_fusion(t[:m]), tri_fusion(t[m:]))
Entraîne-toi★☆☆Pas commencé
On considère la liste [6, 2, 9, 1]. a) Appliquer l'étape de division du tri fusion jusqu'à obtenir des listes à un élément. b) Fusionner deux listes triées [2, 6] et [1,…

On considère la liste [6, 2, 9, 1].

  1. a) Appliquer l'étape de division du tri fusion jusqu'à obtenir des listes à un élément.
  1. b) Fusionner deux listes triées [2, 6] et [1, 9] en détaillant les comparaisons.
  1. c) Donner la liste triée finale.
Résoudre
Entraîne-toi★★☆Pas commencé
Le tri fusion est appliqué à la liste [7, 3, 5, 1, 8, 2, 6, 4]. a) Représenter l'arbre des divisions successives et donner sa hauteur. b) Donner le résultat des fusions…

Le tri fusion est appliqué à la liste [7, 3, 5, 1, 8, 2, 6, 4].

  1. a) Représenter l'arbre des divisions successives et donner sa hauteur.
  1. b) Donner le résultat des fusions à chaque niveau.
  1. c) Justifier que le coût total est proportionnel à $n \log_2 n$ pour $n = 2^k$ éléments.
Résoudre

Le coût en n log₂ n

Pourquoi le tri fusion est-il plus rapide que les tris simples ? Comptons les comparaisons, pour $n = 2^k$ éléments.

  • L'arbre des divisions a $k = \log_2 n$ niveaux (on divise par 2 jusqu'à obtenir des listes à 1 élément).

  • À chaque niveau, les fusions parcourent au total les $n$ éléments : au plus $n$ comparaisons par niveau.

  • Total : environ $n \times \log_2 n$ comparaisons.

Niveau Listes Taille de chaque liste Coût de fusion du niveau
1 1 $n$ $\le n$
2 2 $n/2$ $\le n$
… … … …
$k$ $n/2$ 2 $\le n$

Pour $n = 1\,000$ : $n^2 = 1\,000\,000$ alors que $n \log_2 n \approx 9\,970$ (environ $10\,000$). Ici $\log_2$ n'est qu'un outil de comptage : le nombre de fois qu'on peut couper en deux.

Entraîne-toi★★★Pas commencé
Soit $T(n)$ le nombre de comparaisons du tri fusion dans le pire des cas pour $n = 2^k$ éléments, avec $T(1) = 0$ et $T(n) \le 2T(n/2) + n$. a) Montrer par récurrence…

Soit $T(n)$ le nombre de comparaisons du tri fusion dans le pire des cas pour $n = 2^k$ éléments, avec $T(1) = 0$ et $T(n) \le 2T(n/2) + n$.

  1. a) Montrer par récurrence sur $k$ que $T(2^k) \le k \cdot 2^k$.
  1. b) En déduire que $T(n) = O(n \log_2 n)$.
  1. c) Pour $n = 1\,024$, comparer cette borne avec $n^2$ et calculer le rapport.
  1. d) Expliquer pourquoi le tri par insertion peut être plus rapide sur une liste déjà triée.
Résoudre

Programmation dynamique : ne pas recalculer

Parfois, diviser un problème donne des sous-problèmes qui se recoupent : on recalcule plusieurs fois la même chose. La programmation dynamique consiste à calculer chaque sous-problème une seule fois et à conserver son résultat.

Un exemple : Fibonacci

La suite de Fibonacci vérifie $F_0 = 0$, $F_1 = 1$ et $F_n = F_{n-1} + F_{n-2}$. La version récursive naïve :

def fib(n):

    if n <= 1:

        return n

    return fib(n - 1) + fib(n - 2)

Voici l'arbre des appels pour fib(4) :

Arbre des appels de fib(4) : fib(2) est calculé deux fois fib(4) fib(3) fib(2) fib(2) fib(1) fib(1) fib(0) fib(1) fib(0)

fib(2) est calculé deux fois (cadres orange). Pour de grands $n$, le nombre d'appels croît de façon exponentielle : fib(40) devient déjà très lent.

Mémoïsation

La mémoïsation consiste à ranger chaque résultat dans un dictionnaire et à le réutiliser :

def fib_memo(n, memo=None):

    if memo is None:

        memo = {}

    if n in memo:

        return memo[n]

    if n <= 1:

        memo[n] = n

    else:

        memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)

    return memo[n]

Chaque valeur $F_0, \dots, F_n$ n'est calculée qu'une fois : le coût devient linéaire, en $O(n)$, au prix d'un peu de mémoire. On peut aussi remplir un tableau de bas en haut (approche « ascendante »).

Le rendu de monnaie

Rendre 10 € avec des pièces de 1 €, 5 € et 6 €, avec le moins de pièces possible.

  • Algorithme glouton : on prend toujours la plus grande pièce possible. Ici 6 + 1 + 1 + 1 + 1 : 5 pièces.

  • Optimal : 5 + 5, soit 2 pièces.

Le glouton ne donne donc pas toujours l'optimum. La programmation dynamique, si. On note $T[s]$ le nombre minimal de pièces pour rendre la somme $s$. Pour chaque pièce $p \le s$, on peut rendre $p$ puis la somme $s - p$ :

$$T[0] = 0 \qquad T[s] = 1 + \min_{p \le s} T[s - p]$$
$s$ 0 1 2 3 4 5 6 7 8 9 10
$T[s]$ 0 1 2 3 4 1 1 2 3 4 2

Par exemple $T[8] = 1 + \min(T[7], T[3], T[2]) = 1 + \min(2, 3, 2) = 3$ et $T[10] = 1 + \min(T[9], T[5], T[4]) = 1 + \min(4, 1, 4) = 2$.

def rendu(pieces, somme):

    T = [0] + [None] * somme

    for s in range(1, somme + 1):

        T[s] = 1 + min(T[s - p] for p in pieces if p <= s)

    return T[somme]

Le coût est de l'ordre de $\text{somme} \times \text{nombre de pièces}$.

Entraîne-toi★★☆Pas commencé
La suite de Fibonacci est définie par $F_0 = 0$, $F_1 = 1$ et $F_n = F_{n-1} + F_{n-2}$. a) Écrire une fonction récursive naïve fib(n) et dessiner l'arbre des appels…

La suite de Fibonacci est définie par $F_0 = 0$, $F_1 = 1$ et $F_n = F_{n-1} + F_{n-2}$.

  1. a) Écrire une fonction récursive naïve fib(n) et dessiner l'arbre des appels pour fib(5).
  1. b) Compter le nombre d'appels à fib(2) dans cet arbre.
  1. c) Écrire une version avec mémoïsation (dictionnaire) et indiquer son coût.
Résoudre
Entraîne-toi★★☆Pas commencé
Un boulanger de Toulouse dispose de pièces de 1 €, 3 € et 4 €. Il veut rendre 6 € avec un minimum de pièces. a) Appliquer l'algorithme glouton et donner le résultat. b)…

Un boulanger de Toulouse dispose de pièces de 1 €, 3 € et 4 €. Il veut rendre 6 € avec un minimum de pièces.

  1. a) Appliquer l'algorithme glouton et donner le résultat.
  1. b) Déterminer la solution optimale.
  1. c) Remplir le tableau $T[s]$ du nombre minimal de pièces pour $s = 0, \dots, 6$ par programmation dynamique.
Résoudre

Plus longue sous-séquence commune

Une sous-séquence d'une chaîne s'obtient en effaçant des caractères, sans changer l'ordre des autres (ils ne sont pas forcément consécutifs). Par exemple « AC » est une sous-séquence de « AGC ». La PLSC de deux chaînes est la plus longue sous-séquence qu'elles ont en commun. On s'en sert pour comparer deux textes, ou deux séquences d'ADN (alignement de séquences).

On note $L(i, j)$ la longueur de la PLSC des $i$ premiers caractères de X et des $j$ premiers de Y.

Pourquoi ? Si les derniers caractères sont égaux, on peut les ajouter à une PLSC des préfixes précédents. Sinon, l'un des deux ne sert pas : on teste les deux possibilités et on garde la meilleure.

Coût. Le tableau a $(p+1) \times (q+1)$ cases, chacune calculée en temps constant : le coût est en $O(p \cdot q)$. La récursivité sans mémoïsation recalcule les mêmes $L(i, j)$ de nombreuses fois et peut demander un nombre d'appels exponentiel.

Entraîne-toi★★★Pas commencé
On cherche la plus longue sous-séquence commune (PLSC) des chaînes X = "ABCB" et Y = "BDCAB". a) Établir la relation de récurrence sur $L(i, j)$, longueur de la PLSC des…

On cherche la plus longue sous-séquence commune (PLSC) des chaînes X = "ABCB" et Y = "BDCAB".

  1. a) Établir la relation de récurrence sur $L(i, j)$, longueur de la PLSC des $i$ premiers caractères de X et des $j$ premiers de Y.
  1. b) Remplir le tableau complet de $L(i, j)$.
  1. c) Donner une PLSC et sa longueur.
  1. d) Donner le coût en fonction de $|X| = p$ et $|Y| = q$ et le comparer avec une approche récursive sans mémoïsation.
Résoudre

Recherche d'un motif : Boyer-Moore-Horspool

Chercher le motif « RUE » dans un texte : la méthode naïve teste chaque position, en comparant le motif caractère par caractère depuis la gauche. L'algorithme de Boyer-Moore (ici sa version simplifiée de Horspool) accélère la recherche en sautant des positions, grâce à une table calculée à l'avance. Il compare le motif de droite à gauche.

Table de décalage. Pour un motif $M$ de longueur $m$ et un caractère $c$ :

  • si $c$ apparaît dans $M$ en dehors du dernier caractère, le décalage est la distance de sa dernière occurrence à la fin du motif, soit $m - 1 - (\text{indice de cette occurrence})$ ;

  • sinon, le décalage est $m$.

Pour $M$ = « RUE » ($m = 3$) : R (indice 0) donne $3 - 1 - 0 = 2$ ; U (indice 1) donne $3 - 1 - 1 = 1$ ; tout autre caractère donne 3.

def table_decalage(motif):

    m = len(motif)

    table = {}

    for i in range(m - 1):

        table[motif[i]] = m - 1 - i

    return table

def horspool(texte, motif):

    m = len(motif)

    table = table_decalage(motif)

    pos = 0

    while pos <= len(texte) - m:

        k = m - 1

        while k >= 0 and texte[pos + k] == motif[k]:

            k -= 1

        if k < 0:

            return pos

        pos += table.get(texte[pos + m - 1], m)

    return None

Après un échec, on décale selon le caractère du texte situé sous le dernier caractère du motif.

Entraîne-toi★★★Pas commencé
On considère la recherche d'un motif dans un texte par l'algorithme de Boyer-Moore-Horspool, avec le texte T = "LES ARBRES DE LA RUE" et le motif M = "ARBRE". a)…

On considère la recherche d'un motif dans un texte par l'algorithme de Boyer-Moore-Horspool, avec le texte T = "LES ARBRES DE LA RUE" et le motif M = "ARBRE".

  1. a) Construire la table de décalage des caractères du motif (distance de la dernière occurrence à la fin, hors dernier caractère).
  1. b) Dérouler l'algorithme en donnant les positions testées et les décalages.
  1. c) Donner le nombre de comparaisons de caractères effectuées et le comparer avec une recherche naïve.
  1. d) Expliquer dans quel cas l'algorithme est le plus efficace et dans quel cas il se comporte comme la recherche naïve.
Résoudre

Retour au début : avec la dichotomie, 1 000 livres demandent au plus 10 comparaisons ($2^{10} = 1\,024$). Pour les trier, le tri fusion coupe la pile en deux à chaque étape et fusionne, soit environ $n \log_2 n$ opérations au lieu de $n^2$.

L'essentiel

Voir tous les exercices de cette partie