Zum Inhalt springen

Algorithmen und Datenstrukturen in C/ KMP-Algorithmus

Aus Wikibooks

Bei der algorithmischen Problemstellung, die in diesem Kapitel behandelt werden soll, handelt es sich um das Suchen von Mustern (engl. Pattern) in einem Text. Algorithmen, die dieses Problem lösen werden String-Matching-Algorithmen genannt.

Naiver Algorithmus

[Bearbeiten]

Beim naiven Algorithmus zum Lösen des Problems wird, beginnend mit dem ersten Zeichen, das Pattern solange zeichenweise mit dem Text verglichen, bis an einer Position Ungleichheit festgestellt wird, oder das gesamte Pattern erfolgreich mit einem Textteil verglichen wurde. Dieses Verfahren wird solange beginnend am folgenden Zeichen des Textes wiederholt, bis das Ende des Textes erreicht ist. Bei einer Patternlänge von und einer Textlänge von hat dieser Algorithmus eine worst-case-Komplexität von

Im folgenden ist eine Beispielimplementation des naiven Algorithmus gegeben.

int naiv(char Pattern[], char Text[]){
	int i, j;					// Die Indizes i und j werden definiert.
	for(i=0; Text[i]!= 0;i++){			// Der zu durchsuchende Text wird durchlaufen.
		for(j=0;Text[i+j] == Pattern[j];j++){	// Der Text wird Zeichen für Zeichen mit dem Pattern verglichen.
			if(Pattern[j+1] == 0)		// Wenn das Ende des Patterns erreicht wurde
				return i;		// gib die Position des ersten übereinstimmenden Zeichens zurück.
		}
	}
	return -1;					// Wenn diese Stelle erreicht wird, gibt es keine Übereinstimmungen.
}

Problemstellung

[Bearbeiten]

Bevor es daran geht, nach konkreten Alternativen für das Suchen von Strings Ausschau zu halten, ist es eine gute Idee, das gestellte Problem erst einmal genau zu beschreiben.

Gesucht ist ein Algorithmus, der die folgenden Forderungen erfüllt.

Eingabe:

  • eine Zeichenkette pattern der Länge m
  • eine Zeichenkette text der Länge n

Ausgabe:

  • falls die Zeichenkette text den Teilstring pattern enthält:

    der kleinste Index mit der Eigenschaft: text[i+j] == pattern[j] für

  • sonst -1

Beobachtung

[Bearbeiten]

Solange alle Zeichen des Musters mit dem Text übereinstimmen, gibt es im Vergleich zum naiven Algorithmus keine Verbesserungsmöglichkeit. Muster und Text müssen an jeder Stelle Zeichen für Zeichen verglichen werden.

Betrachten wir deshalb die Situation, an der ein Zeichen des Musters nicht mehr mit dem Text übereinstimmt. Um besser über die Situation sprechen zu können, wählen wir dabei folgende Bezeichnungen:

  • Das Muster wurde an der Stelle P angelegt.
  • Von dort aus haben j Zeichen des Musters mit dem Text übereingestimmt.
  • Erst an der Stelle T stimmen Text und Muster nicht mehr überein.
text ... b a n a n a n a s ...
pattern a n a n a s

Die naive Implementierung legt das Muster an der Stelle P+1 an und setzt die Suche von dort aus fort. Die Information, dass bis hierhin bereits j Zeichen des Musters mit dem Text übereingestimmt haben, wird dabei nicht genutzt. Genau an diesem Punkt setzt die Idee des KMP-Algorithmus an. Von zentraler Bedeutung ist dabei die folgende Beobachtung:

Wenn das Muster noch vor der Stelle T passt, dann müssen sich Anfang und Ende der ersten j Zeichen des Musters, die bereits gepasst haben "überlappen". Formal formuliert müssen die ersten j Zeichen des Musters ein echtes Suffix enthalten, dass zugleich Präfix des Musters ist.

Suffix
a n a n a
a n a n a
Präfix

Die erste Stelle, an der das Muster frühestens passen kann, ist somit durch die Länge des längsten echten Suffix gegeben, das zugleich Präfix des Musters ist. Diese Länge hängt nicht vom durchsuchten Text ab, sondern nur vom Muster.

Für jedes kann die Länge des längsten echten Suffix, das zugleich Präfix ist, also vorberechnet werden. Ein Beispiel für das Muster "ananas":

j 1 2 3 4 5 6
π(j) 0 0 1 2 3 0

Um die Berechnung der Tabelle werden wir uns später kümmern. Zunächst wird es darum gehen, einen Suchalgorithmus zu schreiben, der mit einer solchen Tabelle arbeitet.

KMP Algorithmus

[Bearbeiten]

Der KMP Algorithmus wurde erstmals 1977 von Donald E. Knuth, James H. Morris Jr. und Vaughan R. Pratt veröffentlicht.[1] Er kann auf verschiedene Weise implementiert werden.

Bei der Arbeit mit entsprechender Fachliteratur ist darauf zu achten, dass die Indizes von Arrays in der Programmiersprache C bei 0 beginnen. Die Literatur arbeitet häufig mit Arrays, bei denen die Zählung erst mit dem Wert 1 beginnt.

Algorithmus: Suche mit Präfix-Tabelle

[Bearbeiten]

Als Eingabe stehen dem Algorithmus folgende Daten zur Verfügung:

  • Der Text liegt im Array text der Länge n vor.
  • Das Muster liegt im Array pattern der Länge m vor.
  • Zudem steht die Tabelle pi zur Verfügung.

Für die Formulierung des Algorithmus werden zwei weitere Variablen verwendet:

  • Die Variable i gibt die Position im Text an.
  • Die Variable j gibt die Anzahl Zeichen an, in denen Text und Muster vor der Position i übereinstimmen
text
pattern

Es folgt die Beschreibung der Arbeitsschritte des Algorithmus:

Zu Beginn wurde noch kein Zeichen des Musters mit dem Text verglichen. Der Zähler j wird mit dem Wert 0 initialisiert. Der Text wird nun beginnend vom Anfang bis zum Ende von links nach rechts durchlaufen.

int j = 0
for (int i=0; i<n; ++i) {

In jedem Schritt: Prüfe ob text[i] != pattern[j] ist. Solange es noch Zeichen gibt, die zwischen Text und Muster übereinstimmen, also j > 0, gibt es eine Chance, ein echtes Suffix zu finden. Der jeweils nächste Kandidat kann der Tabelle pi entnommen werden. Bei einem Treffer brechen wir ab:

   while ( (j > 0) && (text[i] != pattern[j]) )
      j = pi[j];

Nun sind entweder text[i] und pattern[j] identisch, oder es wurden alle Möglichkeiten erschöpft. Falls text[i] und pattern[j] identisch sind, so hat sich die Zahl der Zeichen, die zwischen Muster und Text übereinstimmen, um eins erhöht.

   if (text[i] == pattern[j])
      j += 1;

Eventuell haben wir damit bereits das letzte fehlende Zeichen für einen vollständigen Treffer erreicht. Der Index, ab dem Text und Muster übereinstimmen, kann dann berechnet und ausgegeben werden.

   if (j==m)
      return i - (j-1)

Falls kein vollständiger Treffer erzielt wurde, setzten wir die Suche im nächsten Schleifendurchlauf fort.

}

Quellcode

[Bearbeiten]

Zwecks besser Übersicht hier der vollständige Quelltext des Algorithmus in einem Stück:

int kmp_find(char pattern[], int m, char text[], int n, int pi[]) {

   int j = 0; // passende Zeichen bisher

   for (int i=0; i<n; ++i) {                         // Schleife {1}
      while ( (j > 0) && (text[i] != pattern[j]) )   // Schleife {2}
         j = pi[j];
      if (text[i] == pattern[j])
         j += 1;
      if (j == m) {
         return i - (j-1);
      }
   }
   return -1 // kein Treffer gefunden
}

Laufzeitanlyse

[Bearbeiten]

Auf den ersten Blick scheint nicht klar, ob im Vergleich zum naiven Algorithmus tatsächlich eine Verbesserung erzielt wurde. Die äußere Schleife {1} durchläuft den Text zwar in n Schritten. In jedem Durchlauf von Schleife {1} wird aber zudem auch die Schleife {2} einmal durchlaufen.

Hier ist es hilfreich, die Durchläufe von Schleife {2} nicht je Durchlauf der äußeren Scheife {1} zu betrachten, sondern in Summe über alle Durchläufe der äußeren Schleife {1}. Die Position, an der das Muster an den Text angelegt wird, wird in jedem Durchlauf der Schleife {2} um mindestens eine Position nach rechts verschoben. Maximal kann sie dabei an n Positionen des Texts angelegt werden.

Die Bilanz aus äußerer Schleife {1} und innerer Schleife {2} liefert somit maximal Operationen. Die Laufzeit des Algorithmus liegt somit bei . Eine Antwort auf die Frage, ob wir im Vergleich zum naiven Algorithmus etwas gewonnen haben, oder nicht, steht und fällt mit der Frage, wie teuer uns die Vorberechnung der Tabelle pi zu stehen kommt.

Präfix-Tabelle

[Bearbeiten]

Es bleibt zu zeigen, dass die benötigten Werte der Präfix-Tabelle für in "vernünftiger" Zeit berechnet werden können:

Die "Berechnung" von ist trivial. Eine Zeichenkette der Länge 1 hat kein echtes Suffix. Es ist also immer Die restlichen Werte können wir sukzessive berechnen, indem wir in jedem Schritt versuchen das längste echte Suffix des Präfix der Länge um ein Zeichen zu verlängern.

pattern
pattern

Stimmen die Zeichen und nicht überein, so kann das Suffix nicht verlängert werden. Wir müssen versuchen, das Muster an einer anderen Stelle anzulegen.

Weiter links passen kann es nicht, denn war bereits maximal gewählt. Wenn es noch vor der aktuell untersuchten Stelle passt, dann müssen sich Anfang und Ende des Präfix der Länge überlappen. Wir können also analog zum Such-Algorithmus verfahren. Die dafür benötigten Werte von für alle haben wir bereits berechnet.

Algorithmus: Berechnung Präfix-Tabelle

[Bearbeiten]

Als Eingabe steht dem Algorithmus zur Verfügung:

  • Das Muster liegt im Array pattern der Länge m vor.
  • Ein Array pi der Länge m+1, in dem die berechneten Werte gespeichert werden können.

Für die Formulierung des Algorithmus werden zwei weitere Variablen verwendet:

  • Die Variable i gibt die Länge des längsten Teilstrings des Musters an, dessen Wert pi[i] bereits bestimmt wurde.
  • Die Variable j gibt die aktuelle Länge des Suffix an, für das wir überprüfen, ob es fortgesetzt werden kann.
pattern
pattern

Der Algorithmus selbst kann analog zum Such-Algorithmus implementiert werden. Als einzige Besonderheit müssen wir darauf achten, am Ende jedes Durchlaufs der äußeren Schleife den berechneten Wert für an entsprechender Stelle im Array pi abzulegen.

Quellcode

[Bearbeiten]
void kmp_prepare(char pattern[], int m, int pi[]) {
   
   int j = 0;
   pi[1] = 0; // String der Länge 1 hat kein echtes Suffix
   
   for (int i=1; i<m; ++i) {                        // Schleife {1}
      while ( (j>0) && (pattern[i] != pattern[j]) ) // Schleife {2}
         j = pi[j];
      if (pattern[i] == pattern[j])
         j += 1;
      pi[i+1] = j;
   }
}

Laufzeitanalyse

[Bearbeiten]

Die äußere Schleife {1} durchläuft das Muster in m Schritten. Analog zum Such-Algorithmus kann das Muster nur an m Positionen angelegt werden. Jeder Durchlauf der inneren Schleife {2} verschiebt die Position mindestens um eine Position nach rechts. Die Bilanz der Laufzeit aus äußerer Schleife {1} und innerer Schleife {2} liefert somit eine

Laufzeit

[Bearbeiten]

Die komplette Laufzeit für die Suche eines Musters der Länge in einem Text der Länge ergibt sich aus der Summe der Laufzeit für die Berechnung der Präfix-Tabelle und der Laufzeit der Suche mit der berechneten Präfix-Tabelle. Berechnung der Präfix-Tabelle benötigt eine Laufzeit von . Suche mit der berechneten Tabelle benötigt eine Laufzeit von . Die Laufzeit des KMP-Algorithmus liegt somit bei .

Quellcode

[Bearbeiten]

Mit zwei Hilfsfunktionen dump_pi() und print_match() können wir die Arbeitsweise des Algorithmus überprüfen. Der zugehörige Quellcode findet sich hier:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

void dump_pi(char pattern[], int m, int pi[]) {
   printf("\n\"%s\"\n", pattern);

   printf("j     ");
   for (int i=1; i<=m; ++i) {
      printf("%2d ", i);
   }
   printf("\n");
   
   printf("pi(j) ");
   for (int i=1; i<=m; ++i) {
      printf("%2d ", pi[i]);
   }
   printf("\n\n");
}

void kmp_prepare(char pattern[], int m, int pi[]) {
   
   int j = 0;
   pi[1] = 0; // String der Länge 1 hat kein echtes Suffix
   
   for (int i=1; i<m; ++i) {
      while ( (j>0) && (pattern[i] != pattern[j]) )
         j = pi[j];
      if (pattern[i] == pattern[j])
         j += 1;
      pi[i+1] = j;
   }
}

void print_match(char pattern[], char text[], int i) {
   printf("%s\n", text);
   printf("%*s%s\n", i, "", pattern);
}

void kmp_find(char pattern[], int m, char text[], int n, int pi[]) {

   int j = 0; // übereinstimmende Zeichen bisher

   for(int i=0; i<n; ++i) {
      while ( (j>0) && (text[i] != pattern[j]) )
         j = pi[j];
      if (text[i] == pattern[j])
         j += 1;
      if (j == m) {
         print_match(pattern, text, i-(j-1));
         j = pi[j];
      }
   }
}

int main(int argc, char* argv[]) {
   char pattern[] = "ababcabab";
   int m          = strlen(pattern);

   char text[]    = "abababcbababcababcabbababcababcab";
   int n          = strlen(text);

   int *pi        = malloc((m+1) * sizeof(int));
   
   kmp_prepare(pattern, m, pi);
   dump_pi(pattern, m, pi);

   kmp_find(pattern, m, text, n, pi);

   free(pi);
   return 0;
}

Nach dem Übersetzten liefert das Programm folgende Ausgabe:

 "ababcabab"
 j      1  2  3  4  5  6  7  8  9 
 pi(j)  0  0  1  2  0  1  2  3  4 
 
 abababcbababcababcabbababcababcab
         ababcabab
 abababcbababcababcabbababcababcab
                      ababcabab

Wir haben also tatsächlich alle Treffer im Text gefunden.

Fußnoten

[Bearbeiten]
  1. siehe: Donald E. Knuth, James H. Morris Jr., Vaughan R. Pratt: Fast Pattern Matching in Strings. In: SIAM Journal on Computing. Vol 6, Nr. 2, Juni 1977.