Partie III - Algorithmique · Chapitre 13

Algorithmes de recherches

3 sections de cours · fiche d'exercices corrigee

1Recherche sequentielle et recherche dichotomique

Rechercher un element dans une collection est l'operation la plus courante en informatique. Selon que les donnees sont ou non ordonnees, le cout passe de \(\Theta(n)\) a \(\Theta(\log n)\) — un ecart qui, sur un million d'elements, separe un million d'operations de vingt.

Le probleme

Etant donne une liste \(t\) de \(n\) elements et une valeur \(v\), determiner si \(v\) appartient a \(t\), et le cas echeant renvoyer un indice \(i\) tel que \(t_i = v\).

Recherche sequentielle

def recherche_sequentielle(t, v):
    """Indice de la premiere occurrence de v dans t, ou -1. Theta(n)."""
    for i in range(len(t)):
        if t[i] == v:
            return i
    return -1

def appartient(t, v):
    """Version booleenne."""
    for x in t:
        if x == v:
            return True
    return False
Complexite
CasSituationComparaisons
meilleur\(v = t_0\)1
pire\(v\) absent, ou en derniere position\(n\)
moyen (presence equiprobable)\(v\) present \(\frac{1}{n}\sum_{i=1}^{n} i = \frac{n+1}{2}\)

Dans tous les cas \(\Theta(n)\) : diviser le temps moyen par deux ne change pas l'ordre de grandeur.

Variante — la sentinelle

La boucle teste deux conditions a chaque tour : « suis-je dans les bornes ? » et « ai-je trouve ? ». En ajoutant \(v\) a la fin de la liste, on garantit de le trouver et l'on supprime le test de bornes.

def recherche_sentinelle(t, v):
    """Un seul test par tour. Modifie temporairement t."""
    t.append(v)                     # sentinelle
    i = 0
    while t[i] != v:
        i += 1
    t.pop()
    return i if i < len(t) else -1

Le gain est d'environ 30 a 40 % en pratique, mais la complexite reste \(\Theta(n)\). C'est une optimisation de constante, pas d'ordre de grandeur : elle ne remplace jamais un meilleur algorithme.

Recherche dichotomique

Principe

Sur une liste triee, comparer \(v\) a l'element median : si \(v\) est plus petit, il ne peut se trouver que dans la moitie gauche ; s'il est plus grand, dans la moitie droite. Chaque comparaison divise l'espace de recherche par deux.

def dichotomie(t, v):
    """Indice de v dans la liste TRIEE t, ou -1. Theta(log n).

    Precondition : t est triee par ordre croissant.
    """
    g, d = 0, len(t) - 1
    while g <= d:
        m = (g + d) // 2             # indice median
        if t[m] == v:
            return m
        if t[m] < v:
            g = m + 1                # v est dans la moitie droite
        else:
            d = m - 1                # v est dans la moitie gauche
    return -1
Les quatre erreurs classiques
  1. while g < d au lieu de <= : l'intervalle a un seul element n'est jamais teste, la fonction echoue quand \(v\) s'y trouve.
  2. g = m au lieu de g = m + 1 : si \(d = g+1\), alors \(m = g\) et l'intervalle ne diminue plus : boucle infinie.
  3. Oublier que la liste doit etre triee : le resultat est alors arbitraire, sans message d'erreur.
  4. En C ou Java, (g + d) / 2 peut deborder pour de grands indices ; on ecrit g + (d - g) // 2. En Python les entiers sont non bornes, le probleme ne se pose pas.
Complexite

Notons \(n_k\) la taille de l'intervalle apres \(k\) tours. On a \(n_0 = n\) et \(n_{k+1} \le \lfloor n_k/2 \rfloor\), d'ou \(n_k \le n/2^k\). La boucle s'arrete des que \(n_k < 1\), soit \(2^k > n\), c'est-a-dire \(k > \log_2 n\). Le nombre de tours est donc au plus \(\lfloor\log_2 n\rfloor + 1 = \Theta(\log n)\).

\(n\)Sequentielle (pire cas)Dichotomie (pire cas)
\(10^3\)1 00010
\(10^6\)1 000 00020
\(10^9\)1 000 000 00030

Doubler la taille des donnees n'ajoute qu'une seule comparaison.

Quand trier d'abord ?

Trier coute \(\Theta(n\log n)\). Pour \(q\) recherches :

  • sans tri : \(\Theta(qn)\) ;
  • avec tri prealable : \(\Theta(n\log n + q\log n)\).

Le tri devient rentable des que \(q \gtrsim \log n\), soit une vingtaine de recherches pour \(n = 10^6\). Si la liste change constamment, on prefere une table de hachage (\(\Theta(1)\) par operation, mais sans ordre).

2Dichotomie interactive, preuve et variantes

Recherche dichotomique : chaque etape divise l'intervalle par deux
Choisir une valeur puis « Initialiser ».

Preuve de la dichotomie

Terminaison

Variant : \(v_k = d - g + 1\), la taille de l'intervalle. C'est un entier, positif tant que la condition \(g \le d\) est vraie. Montrons qu'il decroit strictement. Avec \(m = \lfloor (g+d)/2 \rfloor\), on a \(g \le m \le d\). Deux cas :

  • si \(g\) devient \(m+1\), le nouveau variant vaut \(d - m \le d - g < d-g+1\) ;
  • si \(d\) devient \(m-1\), il vaut \(m - g \le d - g < d-g+1\).

Dans les deux cas la decroissance est stricte. Une suite d'entiers naturels strictement decroissante etant finie, la boucle termine.

Correction

Invariant : si \(v\) figure dans \(t\), alors il figure dans \(t[g..d]\).

  • Initialisation : \(t[0..n-1]\) est toute la liste. Vrai.
  • Conservation : supposons \(t[m] < v\). Comme \(t\) est triee, on a \(t[i] \le t[m] < v\) pour tout \(i \le m\) : aucun indice de \(g\) a \(m\) ne peut convenir. Si \(v\) est present dans \(t[g..d]\), il l'est donc dans \(t[m+1..d]\) : l'invariant est preserve par g = m + 1. Le cas \(t[m] > v\) est symetrique.
  • Conclusion : deux sorties possibles. Soit \(t[m] = v\) et l'on renvoie \(m\), ce qui est correct. Soit \(g > d\) : l'intervalle est vide, et l'invariant garantit alors que \(v\) n'est pas dans \(t\) ; renvoyer \(-1\) est correct.

C'est la propriete « \(t\) est triee » qui fait fonctionner la conservation. Sans elle, l'invariant est faux et l'algorithme donne n'importe quoi.

Version recursive

def dichotomie_rec(t, v, g=0, d=None):
    """Version recursive. Recursivite TERMINALE : la profondeur est Theta(log n)."""
    if d is None:
        d = len(t) - 1
    if g > d:
        return -1
    m = (g + d) // 2
    if t[m] == v:
        return m
    if t[m] < v:
        return dichotomie_rec(t, v, m + 1, d)
    return dichotomie_rec(t, v, g, m - 1)

Attention : passer des tranches t[:m] plutot que des indices couterait \(\Theta(n)\) par appel (copie), detruisant l'interet de la methode. On passe donc toujours des indices.

Recherche de bornes

Deux variantes essentielles quand la liste comporte des doublons ou que l'on veut inserer :

def borne_inf(t, v):
    """Plus petit indice i tel que t[i] >= v (len(t) si aucun). Theta(log n)."""
    g, d = 0, len(t)
    while g < d:
        m = (g + d) // 2
        if t[m] < v:
            g = m + 1
        else:
            d = m
    return g

def borne_sup(t, v):
    """Plus petit indice i tel que t[i] > v."""
    g, d = 0, len(t)
    while g < d:
        m = (g + d) // 2
        if t[m] <= v:
            g = m + 1
        else:
            d = m
    return g

t = [1, 3, 3, 3, 7, 9]
assert borne_inf(t, 3) == 1
assert borne_sup(t, 3) == 4
assert borne_sup(t, 3) - borne_inf(t, 3) == 3      # nombre d'occurrences
assert borne_inf(t, 5) == 4                        # position d'insertion

Le module standard fournit ces deux fonctions sous les noms bisect.bisect_left et bisect.bisect_right.

3Dichotomier une propriete monotone

Dichotomie sur une fonction : recherche de seuil

La dichotomie ne s'applique pas qu'aux listes. Des qu'une propriete est monotone (fausse puis vraie), on peut chercher le seuil par dichotomie.

Schema general
def premier_vrai(predicat, g, d):
    """Plus petit entier k de [g, d] tel que predicat(k) soit vrai.

    Precondition : predicat est croissant (faux ... faux vrai ... vrai).
    Theta(log(d - g)) appels au predicat.
    """
    while g < d:
        m = (g + d) // 2
        if predicat(m):
            d = m
        else:
            g = m + 1
    return g
# racine carree entiere sans utiliser sqrt
def racine_entiere(n):
    """Plus grand k tel que k*k <= n. Theta(log n)."""
    return premier_vrai(lambda k: k*k > n, 0, n + 1) - 1

assert racine_entiere(0) == 0
assert racine_entiere(15) == 3 and racine_entiere(16) == 4
for n in range(10000):
    r = racine_entiere(n)
    assert r*r <= n < (r+1)*(r+1)
# zero d'une fonction continue : dichotomie sur les reels
def zero(f, a, b, eps=1e-12):
    """Un zero de f dans [a, b], avec f(a) et f(b) de signes opposes.

    Theta(log((b-a)/eps)) evaluations de f.
    """
    fa = f(a)
    if fa == 0:
        return a
    while b - a > eps:
        m = (a + b) / 2
        fm = f(m)
        if fm == 0:
            return m
        if fa * fm < 0:          # le zero est dans [a, m]
            b = m
        else:
            a, fa = m, fm
    return (a + b) / 2

from math import cos
r = zero(lambda x: cos(x) - x, 0, 1)
print(r)                      # 0.7390851332151607
assert abs(cos(r) - r) < 1e-10

Le nombre d'iterations pour atteindre une precision \(\varepsilon\) sur un intervalle de longueur \(L\) est \(\lceil \log_2(L/\varepsilon)\rceil\) : environ 40 iterations pour \(L = 1\) et \(\varepsilon = 10^{-12}\). Cette methode est reprise et comparee a celle de Newton au chapitre 21.

Recherche dans un espace de solutions

Application — repartir une charge

On doit repartir \(n\) taches de durees \(d_i\) sur \(k\) machines, en respectant l'ordre, de facon a minimiser la duree du travail le plus long. La reponse n'est pas directement calculable, mais on sait verifier une reponse candidate.

def realisable(durees, k, capacite):
    """Peut-on repartir les taches en k groupes consecutifs de somme <= capacite ?"""
    if capacite < max(durees):
        return False
    groupes, courant = 1, 0
    for d in durees:
        if courant + d > capacite:
            groupes += 1                  # on ouvre un nouveau groupe
            courant = d
        else:
            courant += d
    return groupes <= k

def charge_minimale(durees, k):
    """Plus petite capacite realisable. Theta(n log(somme)) ."""
    g, d = max(durees), sum(durees)
    while g < d:
        m = (g + d) // 2
        if realisable(durees, k, m):
            d = m
        else:
            g = m + 1
    return g

assert charge_minimale([10, 20, 30, 40], 2) == 60      # [10,20,30] et [40]
assert charge_minimale([1, 1, 1, 1], 4) == 1
assert charge_minimale([7], 3) == 7

La cle : realisable est monotone en la capacite (si une capacite convient, toute capacite superieure convient aussi). On peut donc dichotomiser sur la reponse elle-meme. C'est une technique standard en concours, souvent appelee « dichotomie sur le resultat ».

Comparaison des methodes de recherche

MethodePrerequisRechercheInsertion Parcours ordonne
Liste non trieeaucun\(\Theta(n)\)\(\Theta(1)\) non
Liste triee + dichotomietri\(\Theta(\log n)\) \(\Theta(n)\)oui
Table de hachagecles hachables\(\Theta(1)\) moyen \(\Theta(1)\) moyennon
Arbre binaire de recherche equilibreordre total \(\Theta(\log n)\)\(\Theta(\log n)\)oui

Le choix depend du profil d'utilisation : beaucoup de recherches et peu de modifications (liste triee), beaucoup des deux sans besoin d'ordre (hachage), les deux avec besoin d'ordre (arbre equilibre, hors programme de premiere annee).

L'essentiel du chapitre

  • Recherche sequentielle : \(\Theta(n)\), aucun prerequis. Dichotomie : \(\Theta(\log n)\), mais la liste doit etre triee.
  • La dichotomie se prouve par le variant \(d-g+1\) et l'invariant « si \(v\) est present, il est dans \(t[g..d]\) ».
  • Pieges : while g <= d, g = m+1 (jamais g = m), passer des indices et non des tranches.
  • borne_inf / borne_sup donnent la position d'insertion et le nombre d'occurrences.
  • Toute propriete monotone se dichotomise : racine entiere, zero d'une fonction, dichotomie sur le resultat.
  • Trier pour dichotomiser devient rentable des \(q \gtrsim \log n\) recherches.
Fiche d'exercices — chapitre 13Enonces gradues, du calcul d'application au niveau concours, avec corriges detailles.
Ouvrir la fiche →