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.
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
É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); }
}
}
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.
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 */
}
}
}
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.
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
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.
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;
}
}
}
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
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é :
0110rend"abb"au lieu de"abba"; - 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.
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
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.
Dictionnaire initial : A, B.
| Lecture | Émis | Ajouté au dictionnaire | Reste |
|---|---|---|---|
| `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.
| Huffman | lzw | |
|---|---|---|
| Ce qu'il exploite | fréquence des caractères | répétition des séquences |
| Deux passes ? | oui — il faut les fréquences | non, une seule passe |
| Dictionnaire transmis ? | oui, l'arbre | non, reconstruit |
| Bon sur | texte quelconque | texte 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
- 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.