Algorithme de Knuth-Morris-Pratt
L'algorithme de Knuth-Morris-Pratt (ou d'une manière plus courte l'algorithme KMP) est un algorithme de recherche de sous-chaîne (de caractères), permettant de trouver les occurrences d'une chaîne dans un texte avec une complexité linéaire dans le pire cas. Sa particularité réside en un pré-traitement de la chaîne, qui fournit une information suffisante pour déterminer où continuer la recherche en cas de non-correspondance. Ainsi l'algorithme ne ré-examine pas les caractères qui ont été vus précédemment, et donc limite le nombre de comparaisons nécessaires.
L'algorithme a été conçu en 1970[1] par Knuth et Pratt (en), et dans un autre contexte, par Morris (en), et publié conjointement en 1977[2]. Indépendamment Matiyasevich[3] - [4] avait déjà obtenu dès 1969 un algorithme similaire, codé par une machine de Turing à deux dimensions, en étudiant un problème de reconnaissance d'occurrence de chaîne.
Principe de fonctionnement
Approche naïve
Afin de comprendre la logique de l'algorithme de Knuth-Morris-Pratt, il est judicieux de se pencher sur l'approche naïve de la recherche de chaîne.
La chaîne P peut être trouvée dans le texte S en utilisant l'algorithme suivant :
- Soit ;
- Tant qu'il reste des positions à vérifier
- Comparer lettre à lettre la chaîne P et le texte S à partir de la position ;
- Si la chaîne correspond, terminer le traitement et retourner comme position du début de l'occurrence ;
- Sinon fixer ;
- Terminer le traitement, aucune occurrence n'a été trouvée.
Cette procédure peut être améliorée en arrêtant la comparaison de la deuxième étape dès qu'un caractère différent est détecté.
Cette approche a un inconvénient : après une comparaison infructueuse, la comparaison suivante débutera à la position , sans tenir aucun compte de celles qui ont déjà eu lieu à l'itération précédente, à la position . L'algorithme de Knuth-Morris-Pratt examine d'abord la chaîne P et en déduit des informations permettant de ne pas comparer chaque caractère plus d'une fois.
Phases
- La première phase de l'algorithme construit un tableau, indiquant pour chaque position un « décalage », c'est-à-dire la prochaine position où peut se trouver une occurrence potentielle de la chaîne.
- La deuxième phase effectue la recherche à proprement parler, en comparant les caractères de la chaîne et ceux du texte. En cas de différence, il utilise le tableau pour connaître le décalage à prendre en compte pour continuer la recherche sans retour en arrière.
Exemple
Pour présenter le principe de fonctionnement de l'algorithme, un exemple particulier est considéré : la chaîne vaut ABCDABD et le texte est ABC ABCDAB ABCDABCDABDE.
Notations : Pour représenter les chaînes de caractères, cet article utilise des tableaux dont les indices débutent à zéro. Ainsi, la lettre C de la chaîne sera notée .
On désigne par la position dans le texte à laquelle la chaîne est en cours de vérification (c'est-à-dire la position de la partie gauche de ), et la position du caractère actuellement vérifié dans .
L'algorithme démarre en testant la correspondance des caractères les uns après les autres. Au début, et . A la quatrième étape, et . A ce moment là, est un espace et , la correspondance n'est donc pas possible.
1 2 01234567890123456789012 m : v S : ABC ABCDAB ABCDABCDABDE P : ABCDABD i : ^ 0123456
Plutôt que de recommencer avec , l'algorithme remarque qu'aucun A n'est présent dans entre les positions 1 et 3. En conséquence, après avoir testé tous les caractères précédents, l'algorithme sait qu'il n'a aucune chance de trouver le début d'une correspondance s'il vérifie à nouveau. De ce fait, l'algorithme avance jusqu'au caractère suivant, en posant et .
1 2 01234567890123456789012 m : v S : ABC ABCDAB ABCDABCDABDE P : ABCDABD i : ^ 0123456
Une correspondance presque complète est obtenue. Mais lorsque , la vérification échoue.
1 2 01234567890123456789012 m : v S : ABC ABCDAB ABCDABCDABDE P : ABCDABD i : ^ 0123456
Toutefois, juste avant la fin de cette correspondance partielle, l'algorithme est passé sur le motif AB, qui pourrait correspondre au début d'une autre correspondance. Cette information doit donc être prise en compte. Comme l'algorithme sait déjà que ces deux premiers caractères correspondent aux deux caractères précédant la position courante, il n'est pas nécessaire de les revérifier. Ainsi, l'algorithme reprend son traitement au caractère courant, avec et .
1 2 01234567890123456789012 m : v S : ABC ABCDAB ABCDABCDABDE P : ABCDABD i : ^ 0123456
Cette vérification échoue immédiatement (C ne correspond pas à l'espace en ). Comme la chaîne ne contient aucune espace (comme dans la première étape), l'algorithme poursuit la recherche avec et en réinitialisant .
1 2 01234567890123456789012 m : v S : ABC ABCDAB ABCDABCDABDE P : ABCDABD i : ^ 0123456
À nouveau, l'algorithme trouve une correspondance partielle ABCDAB, mais le caractère suivant C ne correspond pas au caractère final D de la chaîne.
1 2 01234567890123456789012 m : v S : ABC ABCDAB ABCDABCDABDE P : ABCDABD i : ^ 0123456
Avec le même raisonnement que précédemment, l'algorithme reprend avec , pour démarrer à la chaîne de deux caractères AB menant à la position courante, en fixant , nouvelle position courante.
1 2 01234567890123456789012 m : v S : ABC ABCDAB ABCDABCDABDE P : ABCDABD i : ^ 0123456
Cette fois, la correspondance est complète, l'algorithme retourne la position 15 (c'est-à-dire ) : à partir de la position 15, on peut lire le motif ABCDABD.
1 2 01234567890123456789012 m : v S : ABC ABCDAB ABCDABCDABDE P : ABCDABD i : ^ 0123456
L'algorithme de recherche
L'exemple précédent illustre de façon instructive le principe de l'algorithme. Il suppose l'existence d'un tableau donnant les « correspondances partielles » (décrit plus bas), indiquant où chercher le début potentiel de la prochaine occurrence, dans le cas où la vérification de l'occurrence potentielle actuelle échoue. Pour le moment, ce tableau, désigné par , peut être considéré comme une boîte noire ayant la propriété suivante : si l'on dispose d'une correspondance partielle de à , mais qui échoue lors de la comparaison entre et , alors la prochaine occurrence potentielle démarre à la position . En particulier, existe et est défini à . Étant donné ce tableau, l'algorithme est relativement simple :
- Fixer . Supposons que a une longueur de caractères, et , de caractères ;
- Si , alors terminer le traitement, aucune correspondance n'a été trouvée. Sinon, comparer et ;
- S'ils sont égaux, fixer . Si , alors la correspondance est complète. Terminer le traitement et retourner comme position du début de la correspondance ;
- S'ils sont différents, fixer . Fixer , et si , fixer ;
- Reprendre à l'étape no 2.
Cette description met en œuvre l'algorithme appliqué dans l'exemple précédent. À chaque échec de la vérification, le tableau est consulté pour trouver le début de la prochaine occurrence potentielle, et les compteurs sont mis à jour en conséquence. De ce fait, la vérification des caractères n'est jamais effectuée vers l'arrière. En particulier, chaque caractère n'est vérifié qu'une seule fois (bien qu'il puisse être possiblement écarté plusieurs fois à la suite de l'échec de correspondances. Voir plus bas pour l'analyse de l'efficacité de l'algorithme).
Exemple de code de l'algorithme de recherche
Le morceau de code C qui suit est une mise en œuvre de cet algorithme, pour des chaines de caractères 8 bits. Afin de pallier les limitations intrinsèques des tableaux en C, les indices sont décalés d'une unité, c'est-à-dire que dans le code est équivalent à dans la description ci-dessus.
int kmp_recherche(char *P, char *S)
{
extern int T[];
int m = 0;
int i = 0;
// Tant que l'on n'a pas atteint la fin de la chaîne S ou de la chaîne P.
while (S[m + i] != '\0' && P[i] != '\0')
{
if (S[m + i] == P[i])
{
// Si on a encore une correspondance
++i; // alors on regarde la lettre suivante
}
else
{
// sinon
m += i - T[i]; /* la prochaine correspondance partielle possible est à T[i]
lettre avant celle qu'on vient de regarder. */
if (i > 0)
i = T[i]; /* Puisque l'on sait que les T[i]-1 premières lettres à partir
de m sont bonnes, il suffit de regarder P à partir de la T[i]ème
position. Pour S on peut remarquer que on m+i est maintenant
égale à (m+i-T[i])+(T[i]) avec les anciennes valeurs. Ce qui
montre qu'on ne retourne pas en arrière. */
}
}
//Quand on a fini de parcourir une des deux chaines
if (P[i] == '\0')
{
//si la chaine P est finie alors on a trouvé une correspondance à la place m
return m;
}
else
{ /* Sinon c'est qu'il n'existe pas de correspondance de
P dans S donc on renvoie un nombre impossible */
return m + i; /* m est forcément le nombre de caractères de S, donc
m+1 est impossible. On pourrait aussi renvoyer -1 */
}
}
Efficacité de l'algorithme de recherche
En supposant l'existence préalable du tableau , la phase « recherche » de l'algorithme de Knuth-Morris-Pratt est de complexité O, où désigne la longueur de . Si l'on exclut les traitements supplémentaires fixes induits par l'entrée et la sortie de la fonction, tous les traitements sont effectués dans la boucle principale. Pour calculer une limite sur le nombre d'itérations, une première observation à propos de la nature de est nécessaire. Par définition, il est construit de sorte que si une correspondance partielle débutant à échoue lors de la comparaison entre et , alors la prochaine correspondance potentielle ne débute pas avant . En particulier, la prochaine correspondance potentielle doit se trouver à une position supérieure à , de sorte que .
Partant de ce fait, on montre que la boucle s'exécute au plus fois. À chaque itération, elle exécute une des deux branches de l'instruction if.
- La première branche augmente invariablement et ne modifie pas , ce qui fait que l'index du caractère actuellement vérifié dans la chaîne est augmenté.
- La seconde branche ajoute à . Comme nous l'avons vu, est toujours positif. Ainsi, la position du début de la correspondance potentielle actuelle est augmentée.
La boucle prend fin si , ce qui signifie, en tenant compte de la convention C spécifiant qu'un caractère NUL désigne la fin d'une chaîne, que . En conséquence, chaque branche de l'instruction if peut être parcourue au plus fois, puisqu'elles augmentent respectivement ou , et que , ainsi si , alors , et comme l'augmentation à chaque itération est au minimum d'une unité, a nécessairement été vérifié par le passé.
Ainsi, la boucle est exécutée au plus fois, établissant par là-même une complexité algorithmique en .
Le tableau des « correspondances partielles »
L'objectif de ce tableau est de permettre à l'algorithme de ne pas tester chaque caractère du texte plus d'une fois. L'observation-clé, à propos de la nature linéaire de la recherche, qui permet à cet algorithme de fonctionner, est qu'en ayant vérifié une partie du texte avec une « première portion » de la chaîne, il est possible de déterminer à quelles positions peuvent commencer les possibles occurrences qui suivent, et qui continuent de correspondre à la position courante dans le texte. En d'autres termes, les motifs (les sous-parties de la chaîne) sont « pré-recherchés » dans la chaîne, et une liste est établie, indiquant toutes les positions possibles auxquelles continuer pour sauter un maximum de caractères inutiles, sans toutefois sacrifier aucune occurrence potentielle.
Pour chaque position dans la chaîne, il faut déterminer la longueur du motif initial le plus long, qui se termine à la position courante, mais qui ne permet pas une correspondance complète (et qui vient donc très probablement d'échouer). Ainsi, désigne exactement la longueur du motif initial le plus long se terminant à . Par convention, la chaîne vide est de longueur nulle. Comme un échec au tout début de la chaîne est un cas particulier (la possibilité de backtracking n'existe pas), on pose , tel que discuté précédemment.
En re-considérant l'exemple ABCDABD présenté précédemment, on peut établir qu'il fonctionne sur ce principe, et qu'il bénéficie de la même efficacité pour cette raison. On fixe .
-1 0 1 2 3 4 5 6 i : v P[i] : A B C D A B D T[i] : -1
Comme n'apparaît qu'à la fin du motif initial complet, on fixe également .
-1 0 1 2 3 4 5 6 i : v P[i] : A B C D A B D T[i] : -1 0
Pour déterminer , l'algorithme doit trouver un motif terminal dans AB qui soit aussi un motif initial de la chaîne. Mais le seul motif terminal possible de AB est B, qui n'est pas un motif initial de la chaîne. De ce fait, .
-1 0 1 2 3 4 5 6 i : v P[i] : A B C D A B D T[i] : -1 0 0
En poursuivant avec C, on remarque qu'il existe un raccourci pour vérifier tous les motifs terminaux. Considérons que l'algorithme ait trouvé un motif terminal de deux caractères de long, prenant fin sur le C ; alors le premier caractère de ce motif est un motif initial d'un motif initial de la chaîne, et par conséquent, un motif initial lui-même. De plus, il prend fin sur le B, pour lequel nous savons que la correspondance n'est pas possible. Ainsi, il n'est pas nécessaire de se soucier des motifs de deux caractères de longueur, et comme dans le cas précédent, l'unique motif de longueur unitaire ne correspond pas. Donc .
De même pour D, on obtient .
-1 0 1 2 3 4 5 6 i : v P[i] : A B C D A B D T[i] : -1 0 0 0 0
Pour le A suivant, le principe précédent nous montre que le motif le plus long à prendre en compte contient 1 caractère, et dans ce cas, A correspond. Ainsi, .
-1 0 1 2 3 4 5 6 i : v P[i] : A B C D A B D T[i] : -1 0 0 0 0 1 P : A B C D A B D P : A B C D A B D
La même logique est appliquée sur B. Si l'algorithme avait trouvé un motif démarrant avant le A précédent, et se poursuivant avec le B actuellement considéré, alors il aurait lui-même un motif initial correct se terminant par A bien que débutant avant A, ce qui contredit le fait que l'algorithme a déjà trouvé que A est la première occurrence d'un motif s'y terminant. En conséquence, il n'est pas nécessaire de regarder avant le A pour y chercher un motif pour B. En fait, en le vérifiant, l'algorithme trouve qu'il continue par B et que B est la deuxième lettre du motif dont A est la première lettre. De ce fait, l'entrée pour B dans est supérieur d'une unité à celle de A, c'est-à-dire .
-1 0 1 2 3 4 5 6 i : v P[i] : A B C D A B D T[i] : -1 0 0 0 0 1 2 P : A B C D A B D P : A B C D A B D
Enfin, le motif ne continue pas de B vers D. Le raisonnement précédent montre que si un motif d'une longueur supérieure à 1 était trouvé sur D, alors il devrait contenir un motif se terminant sur B. Comme le motif courant ne correspond pas, il doit être plus court. Mais le motif courant est un motif initial de la chaîne se terminant à la deuxième position. Donc ce nouveau motif potentiel devrait lui aussi se terminer à la deuxième position, et nous avons déjà vu qu'il n'y en avait aucun. Comme D n'est pas lui-même un motif, .
-1 0 1 2 3 4 5 6 i : v P[i] : A B C D A B D T[i] : -1 0 0 0 0 1 2 0
D'où le tableau suivant :
-1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | |
A | B | C | D | A | B | D | ||
-1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
Algorithme de construction du tableau
L'exemple précédent illustre la technique générale pour produire le tableau avec le moins de soucis possible. Le principe est le même que pour la recherche générale : la majorité du traitement est déjà fait lors de l'arrivée sur une nouvelle position, il ne reste que peu de traitement pour passer à la suivante. Suit la description de l'algorithme. Pour éliminer des cas particuliers, la convention suivante est appliquée : existe et sa valeur est différente de tous les caractères possibles de .
- Fixer . Supposons que contienne caractères ;
- Fixer et ;
- Si , terminer le traitement. Sinon, comparer et .
- S'ils sont égaux, fixer , et ;
- Sinon, et si , fixer ;
- Sinon, fixer , et .
- Reprendre à l'étape no 3.
Exemple de code de l'algorithme de construction du tableau
Le morceau de code C qui suit est une mise en œuvre de cet algorithme. Comme pour l'algorithme de recherche, les indices de ont été augmentés de 1 afin de rendre le code C plus naturel. La variable supplémentaire c permet de simuler l'existence de . Il est supposé que cette fonction, ainsi que la fonction de recherche, sont appelées au sein d'une fonction de niveau supérieur, qui gère convenablement l'allocation de la mémoire pour le tableau .
void kmp_tableau(char *P)
{
extern int T[];
int i = 0;
int j = -1;
char c = '\0'; //Donc c=P[-1]
T[0] = j; //c'est-à-dire -1
while (P[i] != '\0') { //Tant que l'on n'a pas atteint la fin de la chaine
/* ATTENTION la condition suivante est fausse, contre exemple avec la chaine "ABABABAB", il faut plutot mettre if((P[i] == P[j]) && j < ((i+(i%2))/2)) */
if (P[i] == c) { /* Si P[i]==P[j] donc si le caractère qu'on regarde est le même que le caractère suivant la fin
* du dernier motif initial trouvé */
T[i + 1] = j + 1; //alors le motif est continué, et on incrémente i et j.
++j;
++i;
} else if (j > 0) { //Sinon si au caractère précédent il existe un motif
j = T[j]; //on va regarder le motif initial précédent qui peut correspondre a la lettre où l'on était.
}
else { /* Sinon j=0 ou -1, i.e. si les lettres qui précèdent la ième suivie de la ième ne peuvent
* correspondre a aucun marquage initiale */
T[i + 1] = 0; //alors on indique qu'il n'y a aucun motif initial pour cette lettre.
++i;
j = 0; //Cette affectation ne sert en fait que lorsque j=-1.
}
c = P[j];
}
}
Cependant, remplacer int j=-1; par int j=0;, T[0] = j; par T[0] = -1; permettrait de supprimer l'affectation j = 0; sans rien changer au résultat de l'algorithme. On gagne un petit peu de temps d'exécution mais on perd de la cohérence parmi les variables.
Efficacité de l'algorithme de construction du tableau
La complexité de l'algorithme de construction du tableau est , où désigne la longueur de . À l'exception des initialisations, tout le traitement est effectué dans l'étape no 3. Ainsi, il suffit de montrer que cette étape s'exécute en , ce qui est fait par la suite en examinant simultanément les quantités et .
- Dans la première branche, est préservé, car et sont augmentés simultanément. La quantité , elle, est donc augmentée.
- Dans la deuxième branche, est remplacé par , qui est toujours strictement inférieur à (voir plus haut), ce qui augmente .
- Dans la troisième branche, est augmenté, mais pas , donc et sont tous deux augmentés.
Comme , cela signifie qu'à chaque étape, soit , soit une quantité inférieure à augmente. Par conséquent, puisque l'algorithme s'arrête quand , il s'arrête après au plus itérations de l'étape no 3, car commence à . Ainsi, La complexité de cet algorithme est .
Efficacité de l'algorithme de Knuth-Morris-Pratt
Comme les deux parties de l'algorithme ont respectivement des complexités de et , la complexité de l'algorithme dans sa totalité est .
Notes et références
- (en) « A linear pattern-matching algorithm, », Tech. Rep. University of California Berkeley, no 40,
- (en) Donald Knuth, James H. Morris Jr. et Vaughan Pratt, « Fast pattern matching in strings », SIAM Journal on Computing, vol. 6, no 2, , p. 323–350
- (ru) Юрий Матиясевич, « О распознавании в реальное время отношения вхождения », Записки научных семинаров Ленинградского отделения Математического института им. В.А.Стеклова, vol. 20, , p. 104--114 (lire en ligne), dans sa version russe, traduite en anglais comme (en) Yuri Matiyasevich, « Real-time recognition of the inclusion relation », Journal of Soviet Mathematics, vol. 1, , p. 64--70 (lire en ligne)
- Knuth le mentionne dans les errata de son livre Selected Papers on Design of Algorithms : « I learned in 2012 that Yuri Matiyasevich had anticipated the linear-time pattern matching and pattern preprocessing algorithms of this paper, in the special case of a binary alphabet, already in 1969. He presented them as constructions for a Turing machine with a two-dimensional working memory. »
Voir aussi
Articles connexes
Bibliographie
- (ru) Юрий Матиясевич, « О распознавании в реальное время отношения вхождения », Записки научных семинаров Ленинградского отделения Математического института им. В.А.Стеклова, vol. 20, , p. 104--114 (lire en ligne)Version en russe
- (en) Yuri Maiyasevich, « Real-time recognition of the inclusion relation », Journal of Soviet Mathematics, vol. 1, , p. 64--70 (lire en ligne)
- (en) Donald Knuth, James H. Morris Jr. et Vaughan Pratt, « Fast pattern matching in strings », SIAM Journal on Computing, vol. 6, no 2, , p. 323–350 Citations. Publication originale.
- (en) Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest et Clifford Stein, Introduction to Algorithms, MIT Press et McGraw-Hill, , 2e éd. [détail de l’édition]. Section 32.4: The Knuth-Morris-Pratt algorithm, p. 923–931.
Liens externes
- Olivier Carton, « Algorithmes de Knuth, Morris et Pratt », sur LIAFA (Université Paris Diderot)
- (en) David Eppstein, « Knuth-Morris-Pratt string matching », sur ICS (University of California), une explication de l'algorithme.
- (en) J Strother Moore (co-inventeur de l'algorithme de Boyer-Moore), « KPM example », sur CS (Univesity of Texas), un exemple de l'algorithme Knuth-Morris-Pratt.