Adloun

Algorithmes dichotomiques

Cours complet · informatique (tronc commun des prépas scientifiques), chapitre 5 · prépas scientifiques, tronc commun

Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre

<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>5.1 Introduction et motivation

Pour trouver un mot dans le dictionnaire — le vrai, celui en papier — personne ne lit les pages une à une : on ouvre au milieu, on compare, et l'on sait aussitôt dans quelle moitié continuer. Chaque regard divise par deux l'espace de recherche : trente regards suffisent pour un milliard de pages. Ce principe, la dichotomie (littéralement : couper en deux), est la première grande idée algorithmique du cours, et la plus spectaculaire : elle fait passer la recherche du coût linéaire du chapitre 2 au coût logarithmique — à condition, et c'est tout le prix à payer, que le tableau soit trié.

Mais la dichotomie est aussi l'algorithme le plus traître du semestre : deux indices, des bornes incluses ou exclues, un milieu à arrondir — « bien que l'idée de base de la recherche dichotomique soit relativement simple, les détails peuvent être étonnamment retors », écrivait Donald Knuth. C'est donc ici que les outils de validation du chapitre 1 — variant, invariant, jeux de tests — cessent d'être des exercices d'école : sans eux, on n'écrit pas une dichotomie juste. Le chapitre culmine avec une seconde dichotomie déguisée, l'exponentiation rapide, qui calcule en multiplications.

5.2 Le coût logarithmique

Définition 5.1Logarithme binaire

Le logarithme binaire de , noté , est l'exposant tel que . Pour l'algorithmique, une seule propriété compte : est le nombre de divisions par 2 (avec arrondi) nécessaires pour passer de à — ou, ce qui revient au même, le nombre de chiffres de en binaire.

Complexité : L'échelle, complétée

Un algorithme est de complexité logarithmique si son coût dans le cas le pire est . L'échelle du chapitre 2 s'enrichit d'une ligne qui change tout :

opérations

Trente opérations pour chercher dans un milliard d'éléments : le logarithme croît si lentement qu'à l'échelle humaine, il est presque constant. Doubler n'ajoute qu'une seule opération.

5.3 La recherche dichotomique

5.3.1 L'algorithme

Définition 5.2Recherche dichotomique

Dans un tableau trié , on cherche en maintenant une fenêtre (bornes incluses) où peut encore se trouver. À chaque étape, on compare à l'élément du milieu et l'on jette la moitié inutile :


def dichotomie(v, t: list):
    """Renvoie un indice i tel que t[i] == v, ou None si v est absent.
    Précondition : t est trié en ordre croissant."""
    g, d = 0, len(t) - 1
    while g <= d:
        # Invariant : si v est dans t, alors v est dans t[g..d]
        m = (g + d) // 2
        if t[m] == v:
            return m
        elif t[m] < v:
            g = m + 1          # v, s'il existe, est strictement à droite de m
        else:
            d = m - 1          # v, s'il existe, est strictement à gauche de m
    return None
Exemple 5.3Déroulé sur un exemple

Cherchons dans () :

décision
: à droite,
: à gauche,
trouvé : renvoyer

Trois comparaisons au lieu de six pour la recherche séquentielle — et l'écart se creuse avec : pour , vingt comparaisons contre un million.

5.3.2 La preuve complète

Démonstration (Terminaison)

La quantité (la largeur de la fenêtre) est un variant : positive tant que la boucle tourne ( donne ), elle décroît strictement à chaque tour. En effet, par construction du milieu ; le cas donne une nouvelle largeur , et le cas donne . La boucle termine.

Démonstration (Correction)

Invariant : « si apparaît dans , alors apparaît dans ».

Initialisation : est le tableau entier. ✓

Conservation : supposons vraie au début d'un tour, et . Si : le tableau étant trié, pour tout — ne peut pas se trouver dans , donc s'il est dans , il est dans : la nouvelle fenêtre convient. Le cas est symétrique. ✓

Conclusion : si la fonction renvoie , alors — correct. Si la boucle s'arrête, c'est que : la fenêtre est vide, et l'invariant donne que n'apparaît pas dans — renvoyer None est correct. (Noter où l'hypothèse « trié » travaille : uniquement dans la conservation. Sur un tableau non trié, l'algorithme termine et renvoie toujours quelque chose — mais l'invariant s'effondre, et la réponse ne vaut rien : une précondition violée ne fait pas planter, elle fait mentir.)

Complexité : Recherche dichotomique

Chaque tour coûte et divise la largeur de fenêtre par au moins (de on passe à au plus ). Partant de , la fenêtre est vide ou réduite à rien après au plus tours : le coût dans le cas le pire est . Concrètement : tours, , .

5.3.3 Tester une dichotomie

Méthode : Le jeu de tests canonique de la dichotomie

Les bogues de dichotomie vivent tous aux bornes. Le jeu de tests minimal exerce donc :


t = [2, 5, 8, 12, 16]
assert dichotomie(8, t) == 2            # présent, au milieu
assert dichotomie(2, t) == 0            # présent, PREMIÈRE position
assert dichotomie(16, t) == 4           # présent, DERNIÈRE position
assert dichotomie(1, t) is None         # absent, plus petit que tout
assert dichotomie(99, t) is None        # absent, plus grand que tout
assert dichotomie(9, t) is None         # absent, entre deux éléments
assert dichotomie(7, []) is None        # tableau vide
assert dichotomie(7, [7]) == 0          # un seul élément, présent
assert dichotomie(3, [7]) is None       # un seul élément, absent

Et le test de propriété (chapitre 1) compare à l'oracle lent mais sûr — la recherche séquentielle — sur des milliers de tableaux triés aléatoires :


import random
for essai in range(2000):
    t = sorted(random.randint(0, 30) for _ in range(random.randint(0, 20)))
    v = random.randint(0, 30)
    res = dichotomie(v, t)
    if res is None:
        assert v not in t
    else:
        assert t[res] == v

Pourquoi ne pas exiger l'égalité avec l'indice de la recherche séquentielle ? Parce qu'en présence de doublons, la dichotomie peut renvoyer n'importe quelle occurrence — notre contrat dit « un indice », pas « le premier ». Le test vérifie le contrat, rien que le contrat.

AttentionLes trois bogues classiques

Presque toutes les dichotomies fausses le sont d'une de ces trois façons :

  • condition while g &lt; d au lieu de g &lt;= d : la fenêtre de largeur n'est jamais examinée — dichotomie(7, [7]) renvoie None ;
  • bornes g = m au lieu de g = m + 1 : sur une fenêtre de largeur , et la fenêtre ne rétrécit plus — boucle infinie (le variant ne décroît plus : la preuve l'aurait vu !) ;
  • initialisation d = len(t) au lieu de len(t) - 1 : accès t[m] hors bornes possible — convention « borne droite exclue » mélangée avec la nôtre (les deux conventions existent, il faut en choisir une et s'y tenir).

Chacun est attrapé par le jeu de tests canonique — et le deuxième est prouvé impossible par le variant : voilà pourquoi on exige les deux outils.

5.4 L'exponentiation rapide

5.4.1 L'idée : élever au carré plutôt que multiplier

Calculer naïvement, c'est multiplications ( répété). Mais se calcule en quatre multiplications : , puis , , . Le cas général repose sur la parité :

L'exposant est divisé par deux à chaque étape : c'est une dichotomie sur .

Définition 5.4Exponentiation rapide, version itérative

La version itérative parcourt l'écriture binaire de l'exposant :


def puissance(x: float, n: int) -> float:
    """Renvoie x**n. Précondition : n >= 0."""
    r, b, m = 1, x, n
    while m > 0:
        # Invariant : r * b**m == x**n     (variant : m)
        if m % 2 == 1:
            r = r * b
        b = b * b
        m = m // 2
    return r
Démonstration (Correction et terminaison)

Invariant : . Initialisation : . ✓ Conservation : notons les valeurs en début de tour, vérifiant . Si est pair, : le tour produit et . ✓ Si est impair, : le tour produit et . ✓ Conclusion : à la sortie, et donne . Terminaison : est un variant — entier positif, divisé par deux (donc strictement décroissant dès ) à chaque tour.

Complexité : Exponentiation rapide

Le variant passe de à par divisions par deux : tours, chacun coûtant au plus deux multiplications. Soit multiplications, contre pour la méthode naïve : pour , environ multiplications au lieu d'un million. (C'est l'algorithme qui rend praticables les puissances modulaires géantes de la cryptographie — se calcule en quelques milliers d'opérations.)

iRemarque

Le même schéma « diviser l'exposant par deux » s'applique à tout produit associatif : puissances de matrices (et avec elles, le calcul du -ième terme de Fibonacci en ), composition de fonctions, concaténations. L'exponentiation rapide n'est pas une astuce sur les nombres : c'est un théorème sur l'associativité.

<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>5.5 Exercices résolus

Niveau (Application directe du cours)

Exercice 1 : Manier le logarithme

Sans calculatrice : combien de tours de dichotomie au plus pour ? pour ? Combien faut-il de divisions par deux pour passer de à ? Si une dichotomie effectue au plus tours, que peut-on dire de ?

Démonstration (Solution)

On encadre par les puissances de . Pour : , donc et au plus tours. Pour : , donc au plus tours (retenir : chaque facteur coûte environ tours). De à : , donc divisions. Enfin, tours au plus signifie , soit . (L'équivalence à retenir : tours mille éléments, un million, un milliard.)

Exercice 2 : Dérouler, dans les deux issues

Dérouler dichotomie dans pour puis pour : donner à chaque tour , , , et la décision.

Démonstration (Solution)

Pour : , — trouvé au premier coup, la fonction renvoie . (Le milieu exact : meilleur cas.)

Pour : , : . Puis , : . Puis , : . Alors : fenêtre vide, la boucle s'arrête, la fonction renvoie None. À chaque tour, l'invariant se vérifie : la fenêtre contient toujours la zone où pourrait être (entre et ), jusqu'à ce qu'elle se vide — preuve vivante que est absent. (Dérouler le cas « absent » est plus instructif que le cas « présent » : c'est lui qui exerce la sortie de boucle, là où vivent les bogues.)

Exercice 3 : La racine carrée entière

Écrire racine_entiere(n) qui renvoie (le plus grand tel que ) par dichotomie sur , en — sans aucun flottant.

Démonstration (Solution)

On cherche dans le dernier vérifiant — une dichotomie sur les réponses possibles, pas sur un tableau :


def racine_entiere(n: int) -> int:
    """Renvoie le plus grand k tel que k*k <= n. Précondition : n >= 0."""
    g, d = 0, n
    while g < d:
        # Invariant : g*g <= n  et  (d+1)*(d+1) > n   (la réponse est dans [g, d])
        m = (g + d + 1) // 2          # milieu ARRONDI AU-DESSUS
        if m * m <= n:
            g = m
        else:
            d = m - 1
    return g

assert racine_entiere(0) == 0 and racine_entiere(1) == 1
assert racine_entiere(15) == 3 and racine_entiere(16) == 4
assert racine_entiere(10**18) == 10**9

Deux différences avec la dichotomie du cours, toutes deux essentielles. La condition est g &lt; d et l'on garde le milieu dans la fenêtre (g = m, pas m + 1) : on ne cherche pas une égalité, on cerne une frontière. Et le milieu est arrondi au-dessus ((g + d + 1) // 2) : avec l'arrondi habituel, la fenêtre donnerait et l'affectation g = m ne ferait rien — boucle infinie. Le variant ne décroît qu'avec le bon arrondi : la preuve impose ce détail, l'intuition ne le voit pas. (Cette « dichotomie sur la réponse » — chercher la frontière vrai/faux d'un prédicat monotone — est plus générale que la recherche dans un tableau : on la réutilise dès l'exercice suivant.)

Niveau (Application avec raisonnement intermédiaire)

Exercice 4 : Zéro d'une fonction continue

Une fonction continue vérifie . Écrire zero(f, a, b, eps) qui renvoie un réel tel que s'annule dans , par dichotomie. L'appliquer à pour approcher à près, et compter les tours.

Démonstration (Solution)

C'est le théorème des valeurs intermédiaires rendu algorithme : on garde toujours un intervalle dont les bornes encadrent un zéro.


def zero(f, a: float, b: float, eps: float) -> float:
    """Précondition : f continue, f(a) <= 0 <= f(b), a <= b, eps > 0."""
    while b - a > eps:
        # Invariant : f(a) <= 0 <= f(b)
        m = (a + b) / 2
        if f(m) <= 0:
            a = m
        else:
            b = m
    return a

x = zero(lambda x: x**3 - 2, 0, 2, 1e-10)
assert abs(x**3 - 2) < 1e-9

L'invariant est conservé quel que soit le signe de , et la largeur est divisée par à chaque tour : partant de , il faut tours. (Ici le « variant » est une quantité réelle qui décroît géométriquement vers le seuil eps — la version continue du variant entier ; chaque tour offre un chiffre binaire de précision supplémentaire, et trois tours et demi environ donnent une décimale.)

Exercice 5 : La première occurrence

En présence de doublons, dichotomie renvoie une occurrence quelconque. Écrire premiere_occurrence(v, t) qui renvoie le plus petit indice avec (ou None), toujours en .

Démonstration (Solution)

On ne s'arrête plus quand on trouve : on continue à gauche, en mémorisant la meilleure trouvaille.


def premiere_occurrence(v, t: list):
    """Précondition : t trié croissant."""
    g, d = 0, len(t) - 1
    meilleur = None
    while g <= d:
        # Invariant : toute occurrence d'indice < g est exclue ; meilleur est
        # la plus petite occurrence trouvée parmi les indices > d
        m = (g + d) // 2
        if t[m] == v:
            meilleur = m          # une occurrence ! mais peut-être pas la première
            d = m - 1             # ... on cherche encore À GAUCHE
        elif t[m] < v:
            g = m + 1
        else:
            d = m - 1
    return meilleur

t = [1, 3, 3, 3, 7]
assert premiere_occurrence(3, t) == 1
assert premiere_occurrence(7, t) == 4
assert premiere_occurrence(2, t) is None

Le coût reste : chaque tour rétrécit la fenêtre, trouvaille ou pas. La variante symétrique (g = m + 1 après une trouvaille) donne la dernière occurrence, et la différence des deux compte les occurrences de en — sans jamais les parcourir. (Transformer « s'arrêter dès que » en « continuer en mémorisant » : le même geste qui, au chapitre 3, transformait la première occurrence d'un facteur en liste de toutes les positions.)

Exercice 6 : Puissances de matrices et Fibonacci

En admettant la fonction matmul(A, B) (produit de matrices ), adapter l'exponentiation rapide aux matrices, et l'utiliser pour calculer , le centième nombre de Fibonacci, via l'identité .

Démonstration (Solution)

Le code du cours fonctionne tel quel : seules changent la « multiplication » et son neutre.


def matmul(A, B):
    return [[A[0][0]*B[0][0] + A[0][1]*B[1][0], A[0][0]*B[0][1] + A[0][1]*B[1][1]],
            [A[1][0]*B[0][0] + A[1][1]*B[1][0], A[1][0]*B[0][1] + A[1][1]*B[1][1]]]

def matpow(A, n: int):
    """A**n par exponentiation rapide. Précondition : n >= 0."""
    R = [[1, 0], [0, 1]]                  # la matrice identité : le neutre
    B, m = A, n
    while m > 0:
        # Invariant : R @ B**m == A**n
        if m % 2 == 1:
            R = matmul(R, B)
        B = matmul(B, B)
        m = m // 2
    return R

F = matpow([[1, 1], [1, 0]], 100)
assert F[0][1] == 354224848179261915075        # F_100, exact (entiers Python !)

La preuve du cours se transpose mot pour mot — l'invariant n'utilise que l'associativité du produit, vraie pour les matrices. Coût : produits de matrices, soit au plus produits pour ( en pratique : mises au carré et accumulations), là où la récurrence demanderait additions — et l'écart devient décisif pour . (Attention au piège symétrique : le produit de matrices n'est pas commutatif, mais la preuve n'a jamais utilisé la commutativité — vérifier ce qu'une preuve utilise vraiment, c'est savoir jusqu'où elle s'étend.)

Exercice 7 : L'exponentiation modulaire

La cryptographie calcule sans cesse pour des exposants géants. Adapter l'exponentiation rapide en puissance_mod(a, n, p), expliquer pourquoi on réduit modulo à chaque étape plutôt qu'à la fin, et calculer à la main par la méthode.

Démonstration (Solution)

def puissance_mod(a: int, n: int, p: int) -> int:
    """Renvoie a**n modulo p. Préconditions : n >= 0, p >= 1."""
    r, b, m = 1, a % p, n
    while m > 0:
        # Invariant : (r * b**m) % p == (a**n) % p
        if m % 2 == 1:
            r = (r * b) % p
        b = (b * b) % p
        m = m // 2
    return r

assert puissance_mod(7, 2025, 13) == 8

Réduire à chaque étape garde tous les nombres plus petits que : sans cela, atteindrait des tailles astronomiques ( a plus de chiffres) et chaque multiplication sur ces géants deviendrait elle-même coûteuse. La réduction est licite car le reste d'un produit ne dépend que des restes des facteurs (compatibilité des congruences, vue en mathématiques) : l'invariant du cours devient simplement « », et la preuve se transpose mot pour mot.

À la main : le petit théorème de Fermat donne , et , donc . Par carrés successifs : , puis , puis , d'où

La machine confirme : . (Le calcul à la main et le programme se contrôlent mutuellement — c'est le meilleur jeu de tests qui soit, et Fermat y a réduit l'exposant de à avant même que les carrés ne commencent : mathématiques et algorithmique font équipe, comme dans le cours d'arithmétique.)

Niveau (Raisonnement subtil ou plusieurs étapes)

Exercice 8 : Le bogue du milieu, quarante ans dans les bibliothèques

Dans la plupart des langages, les entiers sont bornés (par exemple ) et la ligne m = (g + d) / 2 peut déborder quand et sont grands — un bogue resté vingt ans dans la bibliothèque standard Java. Expliquer le phénomène, donner l'écriture sûre, et expliquer pourquoi Python est immunisé.

Démonstration (Solution)

Si et valent chacun environ , leur somme dépasse : sur un entier 32 bits, l'addition déborde et produit un nombre négatif ; le milieu calculé devient absurde, l'accès au tableau plante ou, pire, lit n'importe où. Le bogue ne se déclenche que sur des tableaux de plus d'un milliard d'éléments — c'est pourquoi il a dormi des décennies dans des bibliothèques pourtant massivement testées : aucun jeu de tests n'avait de tableau assez grand. L'écriture sûre calcule le milieu sans jamais former la somme :


m = g + (d - g) / 2        # d - g tient toujours dans le type

Python est immunisé car ses entiers sont de taille arbitraire : (g + d) // 2 est exact quels que soient et — un confort réel, mais qui ne dispense pas de connaître le piège : les épreuves et la vie professionnelle utilisent aussi C, Java ou des tableurs, où il mord encore. (Triple leçon : une preuve de correction faite dans peut être fausse dans les entiers machine ; les tests ne couvrent que les tailles qu'on leur donne ; et un détail d'une ligne peut survivre à des millions de relectures — la forme g + (d - g)/2 mérite d'être un réflexe.)

Exercice 9 : Combien de comparaisons, au mieux ?

Montrer que tout algorithme qui recherche une valeur dans un tableau trié de taille en n'utilisant que des comparaisons effectue, dans le cas le pire, au moins comparaisons — autrement dit, la dichotomie est essentiellement optimale.

Démonstration (Solution)

L'algorithme doit pouvoir produire réponses distinctes : les indices possibles, plus « absent ». Chaque comparaison à trois issues (, , ) — ou, pour simplifier, considérons des comparaisons binaires : chaque réponse de comparaison apporte au plus un bit d'information, donc après comparaisons, l'algorithme ne peut distinguer qu'au plus situations. Pour séparer les réponses, il faut , soit

Plus précisément : l'exécution de l'algorithme sur une entrée est entièrement déterminée par la suite des résultats de comparaisons ; deux entrées exigeant des réponses différentes doivent produire des suites différentes ; il y a au plus suites de longueur — d'où la borne. La dichotomie, avec ses comparaisons, atteint cette borne à une unité près : aucun algorithme par comparaisons ne fera mieux qu'au facteur près. (Première rencontre avec une borne inférieure : on ne majore plus le coût d'un algorithme, on minore le coût de tous — l'argument de comptage des réponses possibles resservira pour les tris, au chapitre 9. Et il indique l'unique échappatoire : pour battre , il faut plus que des comparaisons — c'est le dictionnaire du chapitre 2.)

Exercice 10 : Chercher dans l'infini — la recherche par doublement

On dispose d'une suite croissante illimitée (donnée par une fonction u(i)), et l'on cherche si une valeur y figure — mais on ne connaît pas de borne sur l'indice. Concevoir un algorithme en où est l'indice de (ou de la première valeur qui la dépasse) : doubler d'abord, dichotomiser ensuite.

Démonstration (Solution)

Phase 1 — l'échappée par doublement : on cherche une fenêtre finie contenant la réponse, en doublant l'indice jusqu'à dépasser .


def recherche_infinie(u, v):
    """u : fonction croissante de N dans R. Renvoie i tel que u(i) == v, ou None."""
    if u(0) == v:
        return 0
    if u(0) > v:
        return None
    d = 1
    while u(d) < v:            # variant : v - u(d) ... non : d double, voir texte
        d = d * 2
    g = d // 2                  # désormais u(g) < v <= u(d) : fenêtre finie !
    while g <= d:               # phase 2 : dichotomie ordinaire sur [g, d]
        m = (g + d) // 2
        if u(m) == v:
            return m
        elif u(m) < v:
            g = m + 1
        else:
            d = m - 1
    return None

Terminaison de la phase 1 : la suite étant strictement croissante et illimitée… ne l'est pas forcément (« illimitée » au sens : définie pour tout ) ; on suppose , sinon peut majorer toute la suite et la boucle ne termine pas — la précondition doit le dire, et c'est elle que l'énoncé cachait. Sous cette hypothèse, il existe un premier avec , et le doublement atteint en tours.

Coût total : la phase 1 fait évaluations, et la fenêtre qu'elle livre est de largeur : la dichotomie de la phase 2 coûte encore . Total — l'algorithme s'adapte à une taille qu'il ne connaît pas, en la devinant par puissances de deux. (Ce schéma doublement-puis-dichotomie est partout : tableaux à taille inconnue, flux, et c'est exactement ainsi que append gère en interne la mémoire d'une liste — doubler la capacité quand elle déborde.)

Synthèse du chapitre (à retenir)
  • Logarithmique : diviser le problème par à chaque étape ; divisions de à ; repères : tours , , — doubler coûte une opération de plus.
  • Recherche dichotomique (tableau trié) : fenêtre bornes incluses ; invariant « si est dans , il est dans » (le tri ne sert que là) ; variant ; pire cas . Précondition violée réponse qui ment sans planter.
  • Les trois bogues : while g &lt; d (fenêtre 1 jamais vue), g = m (boucle infinie — le variant l'interdit), borne droite excluse mélangée ; jeu de tests canonique : présent (première, dernière, milieu), absent (avant, après, entre), vide, singleton + propriété contre l'oracle séquentiel.
  • Dichotomie sur la réponse : chercher la frontière d'un prédicat monotone (racine entière — milieu arrondi au-dessus quand on garde !, zéro d'une fonction continue avec invariant , première/dernière occurrence en mémorisant la trouvaille).
  • Exponentiation rapide : par carrés, multiplications ; invariant , variant ; ne requiert que l'associativité — matrices (Fibonacci en ), modulaire (réduire à chaque étape : nombres bornés par ).
  • Bornes et limites : toute recherche par comparaisons coûte au moins — la dichotomie est optimale (argument : suites de réponses pour issues) ; entiers machine bornés : milieu en g + (d - g)/2 (Python immunisé, pas les autres) ; taille inconnue : doubler puis dichotomiser.

5.6 Exercices d'entraînement

Cette banque d'exercices, classée par thème, couvre l'intégralité du chapitre. La numérotation prolonge celle des dix exercices résolus. Légende : application directe, raisonnement intermédiaire, approfondissement ; le symbole signale un classique incontournable.

A. Le logarithme et les ordres de grandeur

B. Variantes de la recherche dichotomique

C. Dichotomie sur la réponse

D. Exponentiation rapide

Continuer sur Adloun : animation, QCM, fiches, exercices