Adloun

Algorithmique des textes

Cours complet · informatique (MP2I/MPI), chapitre 18 · MP2I et MPI

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

18.1 Deux questions sur les mots

Un texte est une suite de caractères, et deux questions reviennent sans cesse : où se trouve ce motif ? et comment ranger ce texte en moins de place ? Le programme les traite dans la même section, et elles ont un point commun : la solution naïve est déjà correcte, et l'on ne cherche qu'à aller plus vite.

Définition 18.1Vocabulaire

Sur un alphabet , un mot est une suite finie de lettres. Pour :

  • un préfixe est un , un suffixe un ;
  • un facteur est un — des lettres consécutives ;
  • un sous-mot s'obtient en supprimant des lettres, sans changer l'ordre des autres.

Le mot vide se note . Facteur et sous-mot ne se confondent pas : abc est un sous-mot de axbyc, il n'en est pas un facteur. Ce vocabulaire est repris tel quel au chapitre chap:automates.

18.2 Chercher un motif

Définition 18.2Le problème

Étant donné un texte de longueur et un motif de longueur , trouver les positions où apparaît comme facteur de .

18.2.1 La méthode naïve, et son coût


/* Affiche les positions où p apparaît dans t.
   Préconditions : n >= m >= 1. Complexité : O(n·m) dans le pire cas. */
void recherche_naive(const char t[], int n, const char p[], int m) {
    for (int i = 0; i + m <= n; i = i + 1) {
        int j = 0;
        while (j < m && t[i + j] == p[j]) { j = j + 1; }
        if (j == m) { printf("%d ", i); }
    }
}
Exemple 18.3Le pire cas est atteint

Sur ( lettres) et ( lettres), chaque position échoue à la dernière lettre : décalages, comparaisons chacun, soit . Sur du texte naturel, en revanche, le premier caractère suffit presque toujours à conclure, et la méthode est en pratique — ce qui explique qu'elle survive dans bien des programmes.

18.2.2 Boyer-Moore : comparer par la fin

Le programme autorise une simplification : « on peut se restreindre à une version simplifiée de l'algorithme de Boyer-Moore, avec une seule fonction de décalage. L'étude précise de la complexité de ces algorithmes n'est pas exigible. » C'est cette version qu'on présente, avec la règle du mauvais caractère.

ImportantL'idée : la fin renseigne davantage que le début

On aligne le motif, et l'on compare de droite à gauche. Si la lettre du texte qui provoque l'échec ne figure pas du tout dans le motif, alors aucun alignement chevauchant cette position ne peut réussir : on peut sauter le motif tout entier, soit positions d'un coup.

Méthode : La règle du mauvais caractère

On précalcule, pour chaque lettre de l'alphabet, sa dernière position dans le motif — ou si elle n'y figure pas. En cas d'échec en position du motif sur la lettre du texte, on décale de , en s'assurant d'avancer d'au moins un cran.


#define ALPHABET 256

/* Affiche les positions où p apparaît dans t (Boyer-Moore, mauvais caractère).
   Préconditions : n >= m >= 1. */
void boyer_moore(const char t[], int n, const char p[], int m) {
    int derniere[ALPHABET];
    for (int c = 0; c < ALPHABET; c = c + 1) { derniere[c] = -1; }
    for (int j = 0; j < m; j = j + 1) { derniere[(unsigned char) p[j]] = j; }

    int i = 0;
    while (i + m <= n) {
        int j = m - 1;
        while (j >= 0 && p[j] == t[i + j]) { j = j - 1; }   /* de DROITE à gauche */
        if (j < 0) {
            printf("%d ", i);
            i = i + 1;
        } else {
            int saut = j - derniere[(unsigned char) t[i + j]];
            i = i + (saut > 1 ? saut : 1);      /* JAMAIS moins d'un cran */
        }
    }
}
AttentionLe `saut > 1 ? saut : 1` n'est pas une précaution : c'est la terminaison

Si la lettre fautive apparaît dans le motif après la position , alors est négatif : le décalage ferait reculer l'algorithme, qui boucherait indéfiniment. Le variant de la boucle est , et il ne décroît que si l'on avance d'au moins un cran. C'est la démonstration de terminaison du chapitre chap:algo-prog, appliquée ici.

iRemarqueCe que cela rapporte

Sur un texte en alphabet large — de la langue naturelle — les sauts sont proches de : l'algorithme lit moins de caractères qu'il n'y en a, ce que la méthode naïve ne peut pas faire. Sur un alphabet à deux lettres, en revanche, les sauts sont petits et l'avantage s'évanouit. Le pire cas reste avec cette seule règle.

18.2.3 Rabin-Karp : comparer des empreintes

ImportantL'idée : comparer des nombres au lieu de comparer des mots

Comparer deux mots de longueur coûte . Comparer deux nombres coûte . On calcule donc une empreinte numérique — un hachage — de chaque facteur de longueur du texte, et on la compare à celle du motif.

Tout repose sur une propriété : l'empreinte doit se recalculer en quand la fenêtre glisse d'un cran. C'est le hachage roulant.

Définition 18.4Empreinte polynomiale

Pour un mot et deux entiers (base) et (module premier) :

Quand la fenêtre glisse de à , on retranche la contribution de la lettre sortante et l'on ajoute la lettre entrante :

Trois opérations : .


/* Affiche les positions où p apparaît dans t (Rabin-Karp).
   Préconditions : n >= m >= 1. Complexité : O(n + m) en moyenne, O(nm) au pire. */
void rabin_karp(const char t[], int n, const char p[], int m) {
    const long long B = 256, Q = 1000000007LL;
    long long hp = 0, ht = 0, puissance = 1;
    for (int k = 0; k < m - 1; k = k + 1) { puissance = (puissance * B) % Q; }
    for (int k = 0; k < m; k = k + 1) {
        hp = (hp * B + (unsigned char) p[k]) % Q;
        ht = (ht * B + (unsigned char) t[k]) % Q;
    }
    for (int i = 0; i + m <= n; i = i + 1) {
        if (hp == ht) {
            /* ATTENTION : EMPREINTES ÉGALES NE VEUT PAS DIRE MOTS ÉGAUX : on VÉRIFIE. */
            int j = 0;
            while (j < m && t[i + j] == p[j]) { j = j + 1; }
            if (j == m) { printf("%d ", i); }
        }
        if (i + m < n) {
            /* + Q, et NON + Q*Q : Q*Q vaut 1e18, et (Q*Q)*B vaut 2,6e20,
               bien au-delà de LLONG_MAX. Le terme retranché vit dans (-Q, Q),
               donc un seul Q suffit à le rendre positif. */
            ht = ((ht - (unsigned char) t[i] * puissance % Q + Q) * B
                  + (unsigned char) t[i + m]) % Q;
        }
    }
}
AttentionUne collision d'empreintes n'est pas une occurrence

C'est le point à ne jamais oublier, et c'est exactement le problème du chapitre chap:hachage : deux mots différents peuvent avoir la même empreinte. L'algorithme ne peut donc pas se fier au test numérique — il doit vérifier caractère par caractère à chaque égalité d'empreintes.

D'où sa complexité : en moyenne, si les collisions sont rares ; au pire, si elles sont systématiques. C'est un algorithme probabiliste de type Monte-Carlo rendu exact par vérification — le chapitre chap:probabilistes nommera ce schéma.

Bonne pratique (Rabin-Karp brille quand on cherche PLUSIEURS motifs)

Pour un seul motif, Boyer-Moore est généralement plus rapide. Mais Rabin-Karp cherche motifs de même longueur pour presque le même prix : on range leurs empreintes dans une table de hachage, et chaque fenêtre du texte se teste en . C'est ainsi que fonctionnent les détecteurs de plagiat et les antivirus par signatures.

18.3 Compresser

18.3.1 Huffman : coder les fréquences

L'algorithme a été construit et démontré au chapitre chap:gloutons. Le programme demande ici ce qui y manquait : « on explicite les méthodes de décompression associées ».

Méthode : Décompresser du Huffman

On descend dans l'arbre au rythme des bits — à gauche, à droite — et l'on émet un caractère chaque fois qu'on atteint une feuille, en repartant de la racine.


(* Décode la suite de bits selon l'arbre de Huffman a.
   Précondition : bits est le codage d'un texte par CE MÊME arbre. *)
let decoder a bits =
  let sortie = Buffer.create 256 in
  let rec descendre n = function
    | [] -> ()
    | b :: reste ->
        match n with
        | Feuille (c, _) -> Buffer.add_char sortie c; descendre a (b :: reste)
        | Interne (g, d, _) -> descendre (if b = 0 then g else d) reste
  in
  descendre a bits;
  (match a with Feuille (c, _) -> Buffer.add_char sortie c | _ -> ());
  Buffer.contents sortie
AttentionDeux pièges du décodeur, mesurés

La forme ci-dessus filtre sur la liste de bits avant de regarder le nœud. Deux conséquences :

  • la dernière lettre est perdue : arrivé au dernier bit, on descend sur une feuille mais la liste est vide, et l'on sort sans émettre. Mesuré : 0110 rend &quot;abb&quot; au lieu de &quot;abba&quot; ;
  • un arbre à une seule feuille fait boucler : aucun bit n'est consommé, et la ligne finale prévue pour ce cas est placée après l'appel qui boucle.

La forme correcte teste le nœud d'abord — feuille : on émet et l'on repart de la racine ; interne : on consomme un bit — et prend le nombre de bits restants pour variant.

Pourquoi cela marche sans ambiguïté : parce que le code est préfixe. Aucun mot de code n'étant préfixe d'un autre, atteindre une feuille signifie que le caractère est complet — il n'y a jamais à hésiter, ni à revenir en arrière. C'est toute la raison d'être de la structure d'arbre.

AttentionL'arbre fait partie du fichier compressé

On oublie souvent que le décodeur a besoin de l'arbre. Il faut donc le transmettre — sérialisé, comme au chapitre chap:hachage — ou transmettre la table des fréquences qui permet de le reconstruire. Sur un fichier court, ce surcoût peut dépasser le gain : Huffman n'est rentable qu'à partir d'une certaine taille.

18.3.2 Lempel-Ziv-Welch : coder les répétitions

ImportantUne idée orthogonale à celle de Huffman

Huffman exploite la fréquence des caractères. lzw exploite la répétition des séquences : dans un texte, les ou tion reviennent en bloc. Il construit un dictionnaire de séquences au fil de la lecture, et le décodeur reconstruit exactement le même dictionnaire — de sorte qu'il n'a pas besoin d'être transmis.

Méthode : Compression

Le dictionnaire contient au départ les caractères seuls, numérotés. Puis :

  • lire la plus longue séquence déjà présente dans le dictionnaire ;
  • émettre son numéro ;
  • ajouter au dictionnaire suivi du caractère suivant ;
  • reprendre à ce caractère.
Exemple 18.5Compresser `ABABABA`

Dictionnaire initial : A, B.

LectureÉmisAjouté au dictionnaireReste
`A`1`AB` `BABABA`
`B`2`BA` `ABABA`
`AB`3`ABA` `ABA`
`ABA`5---

Sept caractères deviennent quatre codes. Et le dictionnaire s'enrichit d'autant plus vite que le texte se répète : sur un fichier réel, les gains sont considérables.

Méthode : Décompression, et le cas qui piège

Le décodeur reconstruit le dictionnaire en appliquant la même règle avec un temps de retard : à la réception du code , il émet la séquence associée, puis ajoute au dictionnaire la séquence précédente suivie du premier caractère de la nouvelle.

Le cas particulier, celui qui fait échouer les mises en œuvre naïves : il arrive que le code reçu ne soit pas encore dans le dictionnaire du décodeur — car le compresseur vient tout juste de l'y ajouter. Cela se produit exactement quand la séquence est de la forme . Le décodeur la reconstitue alors lui-même :


let sequence =
  match Hashtbl.find_opt dico code with
  | Some s -> s
  | None -> precedente ^ String.make 1 precedente.[0]   (* LE CAS QUI PIÈGE *)

Dans l'exemple ci-dessus, le code est émis alors que le décodeur n'a encore que à : il doit deviner ABA en recollant AB et son premier caractère. C'est une reconstitution, pas une convention arbitraire — et elle est toujours correcte, parce que le compresseur ne peut émettre un code neuf que dans cette configuration précise.

ImportantDeux compressions, et on peut les composer
Huffmanlzw
Ce qu'il exploitefréquence des caractèresrépétition des séquences
Deux passes ?oui — il faut les fréquencesnon, une seule passe
Dictionnaire transmis ?oui, l'arbrenon, reconstruit
Bon surtexte quelconquetexte très répétitif

Les deux idées étant indépendantes, on les enchaîne dans les formats réels : lzw d'abord, Huffman sur sa sortie. C'est le principe du format zip.

18.4 Ce qu'il faut retenir

ImportantTextes : quatre points
  • Naïf : au pire, mais en pratique sur du texte naturel. À ne pas mépriser.
  • Boyer-Moore compare par la fin et saute jusqu'à positions. La ligne qui force à avancer d'un cran au moins n'est pas une précaution : c'est la terminaison.
  • Rabin-Karp compare des empreintes en par fenêtre — et vérifie toujours, car une collision n'est pas une occurrence. Sa force est de chercher motifs à la fois.
  • Huffman code les fréquences et transmet son arbre ; lzw code les répétitions et n'a rien à transmettre. Le décodeur lzw doit savoir reconstituer le code qu'il n'a pas encore.

Continuer sur Adloun : animation, QCM, fiches, exercices