cours1 min de lecture

Recherche textuelle — l'algorithme de Boyer-Moore

Prétraiter le motif pour effectuer des sauts intelligents dans le texte — la stratégie qui rend la recherche textuelle parfois sous-linéaire.
programme

Introduction

Rechercher un motif dans un texte est une opération omniprésente : la barre de recherche d'un éditeur de texte, la commande grep sous Unix, les filtres anti-spam, la bio-informatique… Plutôt que de comparer le motif caractère par caractère à toutes les positions du texte (force brute, coût O(nm)\mathcal{O}(nm) avec nn longueur du texte et mm longueur du motif), l'algorithme de Boyer-Moore prétraite le motif pour pouvoir effectuer de grands sauts lors d'un échec, et descend parfois en O(n/m)\mathcal{O}(n/m) — il saute des caractères du texte sans les regarder.

Pensez à la façon dont vous cherchez un mot dans un livre papier : si votre œil tombe sur une lettre qui n'appartient pas du tout au mot recherché, vous tournez la page sans relire les mots autour — c'est l'intuition exacte de la règle du mauvais caractère introduite plus bas.

Recherche naïve — pour comparer

L'algorithme naïf glisse le motif sur le texte, position par position, et compare. Dès qu'un caractère diffère, on décale d'un cran et on recommence.

def recherche_naive(texte, motif):
    n, m = len(texte), len(motif)
    for i in range(n - m + 1):
        if texte[i:i+m] == motif:
            return i
    return -1

Coût : jusqu'à nm+1n - m + 1 tentatives, chacune jusqu'à mm comparaisons, soit O(nm)\mathcal{O}(nm) en pire cas. Acceptable pour des petits textes, prohibitif sur des génomes ou des corpus.

L'intuition de Boyer-Moore

Deux idées fondatrices, contre-intuitives :

  1. Comparer de droite à gauche : on aligne le motif à une position du texte, puis on compare ses caractères en partant de la fin.
  2. Prétraiter le motif pour savoir, en cas d'échec, combien de cases on peut sauter sans risquer de manquer une occurrence.

Le prétraitement consiste à construire une table du mauvais caractère : pour chaque lettre de l'alphabet, on note sa position la plus à droite dans le motif.

L'intérêt majeur du prétraitement : on le calcule une seule fois, en O(m+Σ)\mathcal{O}(m + |\Sigma|)Σ\Sigma est l'alphabet, et il est réutilisé à chaque échec. Le coût amortir vaut largement les comparaisons épargnées.

La règle du mauvais caractère

Supposons qu'on compare le motif et le texte en partant de la droite, et que le premier caractère discordant soit c dans le texte, à la position ii du texte alignée avec la position jj du motif.

  • Si c n'apparaît pas du tout dans le motif, on peut décaler de j+1j + 1 : aucune occurrence possible qui contiendrait c à cette position.
  • Si c apparaît dans le motif à la position kk la plus à droite (k<jk < j), on décale pour aligner cette occurrence sur le texte : décalage de jkj - k.
def table_mauvais_caractere(motif):
    """Pour chaque caractère du motif, sa dernière position (0-indexée)."""
    table = {}
    for i, c in enumerate(motif):
        table[c] = i
    return table

def boyer_moore(texte, motif):
    n, m = len(texte), len(motif)
    if m == 0: return 0
    table = table_mauvais_caractere(motif)
    i = 0
    while i <= n - m:
        j = m - 1
        # Compare de droite à gauche.
        while j >= 0 and motif[j] == texte[i + j]:
            j -= 1
        if j < 0:
            return i                        # occurrence trouvée
        # Décalage par règle du mauvais caractère.
        c = texte[i + j]
        saut = j - table.get(c, -1)
        i += max(1, saut)                   # garantit la progression
    return -1
Le max(1, saut) garantit qu'on avance toujours d'au moins une case — sans cela, on pourrait boucler si saut était négatif (cas où la dernière occurrence du caractère dans le motif est à droite de la position d'échec).

Boyer-Moore compare le motif et le texte…

Un exemple déroulé

Recherchons ABCDE dans XYZQABCDE.

Position 0 :   X Y Z Q A B C D E
               A B C D E             ← échec en j=4 : E ≠ A
                                       texte[i+j]='A', table['A']=0
                                       saut = 4 - 0 = 4 → i passe à 4
Position 4 :   X Y Z Q A B C D E
                       A B C D E     ← match complet, retourne 4

L'astuce, c'est que dès le premier échec, on saute directement 4 cases au lieu d'1 — un seul échec a remplacé 4 comparaisons inutiles.

Coût — sans entrer dans la preuve

Le BO le mentionne explicitement : « l'étude du coût, difficile, ne peut être exigée ». On retient cependant :

CasCoût
Meilleur casO(n/m)\mathcal{O}(n/m) — sauts maximaux à chaque échec
Pire casO(nm)\mathcal{O}(nm) — comparable à la recherche naïve
En pratiqueexcellent, surtout pour des grands alphabets

En pratique, plus l'alphabet est grand (texte naturel, ADN), plus la table permet des sauts longs — Boyer-Moore est l'algorithme utilisé par grep classique pour cette raison.

Le BO demande-t-il la preuve du coût de Boyer-Moore ?

Pour aller plus loin

D'autres algorithmes prétraitent le motif différemment : Knuth-Morris-Pratt (KMP) garantit O(n+m)\mathcal{O}(n + m) en pire cas, Rabin-Karp utilise des fonctions de hachage. Sur de très grandes bases de données textuelles (génome humain de 3 milliards de paires de bases), on prétraite plutôt le texte : c'est le rôle des index (suffix arrays, FM-index) qui permettent des recherches en temps quasi-constant après un prétraitement initial coûteux.