Wie Algorithmen funktionieren
Chapter 8 Algorithm Analysis Techniques

Kapitel 8: Algorithmus-Analyse-Techniken

In den vorherigen Kapiteln haben wir eine breite Palette grundlegender Algorithmen und Datenstrukturen erforscht und Themen wie Sortieren, Suchen, Graphen, Zeichenketten und verschiedene Anwendungen behandelt. Während die Implementierung und das Verständnis dieser Algorithmen entscheidend sind, ist es ebenso wichtig, ihre Leistungsmerkmale zu analysieren, um fundierte Entscheidungen bei der Auswahl von Algorithmen für spezifische Probleme treffen zu können. In diesem Kapitel vertiefen wir die Techniken, die zur Analyse und Bewertung von Algorithmen verwendet werden, mit Schwerpunkt auf mathematischen Modellen, empirischen Studien und Algorithmus-Visualisierung.

Mathematische Modelle und Analyse

Die mathematische Analyse ist ein leistungsfähiges Werkzeug zum Verständnis des Verhaltens und der Leistung von Algorithmen. Durch die Entwicklung mathematischer Modelle, die die wesentlichen Merkmale eines Algorithmus erfassen, können wir über seine Effizienz und Skalierbarkeit nachdenken. Diese Modelle ermöglichen es uns, Vorhersagen über die Laufzeit, den Speicherverbrauch und andere Leistungskennzahlen eines Algorithmus zu treffen, was es uns wiederum erlaubt, verschiedene Algorithmen zu vergleichen und den am besten geeigneten für eine bestimmte Aufgabe auszuwählen.

Big-O-Notation

Eine der am weitesten verbreiteten Notationen zur Beschreibung der Leistung eines Algorithmus ist die Big-O-Notation. Die Big-O-Notation liefert eine obere Schranke für die Wachstumsrate einer Funktion und ermöglicht es uns, das Worst-Case-Szenario der Laufzeit oder Speicherkomplexität eines Algorithmus zu charakterisieren.

In der Big-O-Notation drücken wir die Leistung eines Algorithmus als Funktion der Eingabegröße, üblicherweise mit n bezeichnet, aus. Betrachten wir zum Beispiel die folgende einfache Funktion, die die Summe der ersten n positiven ganzen Zahlen berechnet:

public static int sum(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        sum += i;
    }
    return sum;
}

Die Laufzeit dieser Funktion wächst linear mit der Eingabegröße n. Wir können dies in der Big-O-Notation als O(n) ausdrücken, was bedeutet, dass die Laufzeit proportional zur Eingabegröße ist. Das heißt, dass sich die Laufzeit des AlgorithmusHier ist die deutsche Übersetzung der Markdown-Datei. Für den Code wurden nur die Kommentare übersetzt, der Code selbst blieb unverändert.

Wenn die Eingabegröße zunimmt, steigt die Laufzeit des Algorithmus höchstens linear.

Die Big-O-Notation ermöglicht es uns, konstante Faktoren und niedrigere Ordnungsterme zu ignorieren und uns auf den dominanten Term zu konzentrieren, der die Wachstumsrate der Funktion bestimmt. Betrachten wir zum Beispiel die folgende Funktion:

public static int sumOfSquares(int n) {
    int sum = 0;
    // Führe eine Schleife von 1 bis n aus
    for (int i = 1; i <= n; i++) {
        // Führe eine Schleife von 1 bis i aus
        for (int j = 1; j <= i; j++) {
            sum += j;
        }
    }
    return sum;
}

Die Laufzeit dieser Funktion ist proportional zum Quadrat von N. Genauer gesagt, ist die Anzahl der Ausführungen der Anweisung sum += j 1 + 2 + ... + N ~ N^2/2.

Im Allgemeinen können wir die Laufzeit eines Programms in Bezug auf die Eingabegröße mithilfe der Big-O-Notation ausdrücken. Dies ermöglicht es uns, führende Koeffizienten und niedrigere Ordnungsterme zu unterdrücken und uns auf den wichtigen Teil zu konzentrieren: die Wachstumsordnung der Laufzeit.

Knuth-Morris-Pratt-Algorithmus

Der Knuth-Morris-Pratt (KMP)-Algorithmus ist ein effizienter Teilzeichenketten-Suchalgorithmus, der eine vorkompilierte "Fehlerfunktion" verwendet, um unnötige Vergleiche zu vermeiden.

Die Fehlerfunktion sagt uns die Länge des längsten richtigen Präfix des Musters, das auch ein Suffix des bisher abgeglichenen Teilstrings des Musters ist. Dies ermöglicht es uns, beim Finden einer Nichtübereinstimmung "vorzuspringen", anstatt zurückzugehen.

Hier ist ein Beispiel für den KMP-Algorithmus:

Text:    "abacadabrabracabracadabrabrabracad"
Muster: "abracadabra"
Ausgabe: [13]

Der KMP-Algorithmus hat eine Laufzeit von O(n + m), was ihn für große Texte und Muster viel effizienter macht als der brute-force-Ansatz.

Boyer-Moore-Algorithmus

Der Boyer-Moore-Algorithmus ist ein weiterer effizienter Teilzeichenketten-Suchalgorithmus, der durch Vorverarbeitung der Musterzeichenkette funktioniert. Im Gegensatz zum KMP, der die Vergleiche vom Anfang des Musters aus beginnt, startet Boyer-Moore vom Ende und arbeitet rückwärts.

Die Schlüsselidee hinter Boyer-Moore ist die Verwendung von zwei vorkompilierten Funktionen: der "gute Suffix"Hier ist die deutsche Übersetzung der Markdown-Datei, wobei die Kommentare in den Codeblöcken übersetzt wurden:

Die "gute Zeichen"- und die "schlechte Zeichen"-Funktion. Diese Funktionen ermöglichen es uns, im Text vorwärts zu springen, wenn wir eine Nichtübereinstimmung finden, ähnlich wie bei KMP.

Hier ist ein Beispiel für den Boyer-Moore-Algorithmus:

Text:    "abacadabrabracabracadabrabrabracad"
Muster: "abracadabra"
Ausgabe: [13]

Boyer-Moore hat eine beste Laufzeit von O(n/m) und eine schlechteste Laufzeit von O(n * m), in der Praxis ist es jedoch oft der schnellste Teilstringsuche-Algorithmus für große Alphabete.

Reguläre Ausdrücke

Reguläre Ausdrücke sind ein leistungsfähiges Werkzeug zum Beschreiben von Mustern in Zeichenketten. Sie bieten eine prägnante und flexible Notation zum Abgleichen, Suchen und Manipulieren von Text.

Ein regulärer Ausdruck ist eine Folge von Zeichen, die ein Suchmuster definieren. Die einfachste Form eines regulären Ausdrucks ist eine wörtliche Zeichenkette wie "hallo", die genau die Zeichenkette "hallo" abgleicht. Reguläre Ausdrücke enthalten jedoch auch Sonderzeichen und Konstrukte, die komplexere Muster ermöglichen:

  • . (Punkt) stimmt mit jedem einzelnen Zeichen überein.
  • * (Stern) stimmt mit null oder mehr Vorkommen des vorhergehenden Zeichens oder der vorhergehenden Gruppe überein.
  • + (Plus) stimmt mit einem oder mehr Vorkommen des vorhergehenden Zeichens oder der vorhergehenden Gruppe überein.
  • ? (Fragezeichen) stimmt mit null oder einem Vorkommen des vorhergehenden Zeichens oder der vorhergehenden Gruppe überein.
  • ^ (Dach) stimmt mit dem Beginn einer Zeile überein.
  • $ (Dollar) stimmt mit dem Ende einer Zeile überein.
  • [] (eckige Klammern) definieren eine Zeichenklasse, die mit einem einzelnen Zeichen innerhalb der Klammern übereinstimmt.

Hier sind einige Beispiele für reguläre Ausdrücke und die Muster, die sie abgleichen:

  • a.b stimmt mit jeder Zeichenkette von drei Zeichen überein, die mit "a" beginnt und mit "b" endet, wie "acb", "a5b" oder "a b".
  • a*b stimmt mit jeder Zeichenkette überein, die aus null oder mehr "a"-Zeichen gefolgt von einem einzelnen "b"-Zeichen besteht, wie "b", "ab", "aab" oder "aaab".
  • a+b stimmt mit jeder Zeichenkette überein, die aus einem oder mehr "a"-Zeichen gefolgt von einem einzelnen "b"-Zeichen besteht, wie "ab", "aab" oder "aaab", aber nicht "b".
  • a?b stimmt entweder mit "ab" oder mit "b" überein.
  • ^a stimmt mit jeder Zeichenkette überein, die mit "a" beginnt.Hier ist die deutsche Übersetzung der Markdown-Datei. Für den Code wurden nur die Kommentare übersetzt, der Code selbst blieb unverändert.

ts mit "a".

  • a$ entspricht jeder Zeichenkette, die mit "a" endet.
  • [aeiou] entspricht einem einzelnen Vokalzeichen.

Reguläre Ausdrücke werden von vielen Programmiersprachen und Tools unterstützt, darunter Java, Python und Unix-Dienstprogramme wie grep und sed. Sie werden häufig für Aufgaben wie Dateneingabevalidierung, Textverarbeitung und Protokollanalyse verwendet.

Datenkompression

Datenkompression ist der Prozess der Codierung von Informationen unter Verwendung von weniger Bits als in der ursprünglichen Darstellung. Dies ist nützlich, um Speicheranforderungen und Übertragungszeiten zu reduzieren. Es gibt zwei Hauptarten der Kompression: verlustfrei und verlustbehaftet. Bei der verlustfreien Kompression kann die ursprüngliche Datei perfekt aus den komprimierten Daten rekonstruiert werden, während bei der verlustbehafteten Kompression ein gewisser Informationsverlust gegen höhere Kompressionsraten in Kauf genommen wird.

Laufzeitcodierung

Laufzeitcodierung (RLE) ist eine einfache verlustfreie Kompressionstechnik, die wiederholte Sequenzen identischer Symbole (Läufe) durch eine einzelne Instanz des Symbols und eine Zählung der Anzahl der Wiederholungen ersetzt.

Hier ist ein Beispiel für RLE:

Eingabe:  "AAAABBBCCCDDDEEE"
Ausgabe: "4A3B3C3D3E"

RLE ist effektiv für Daten mit vielen langen Läufen, wie einfache Grafikbilder. Es kann jedoch tatsächlich die Größe der Daten erhöhen, wenn es nur wenige oder keine Läufe gibt.

Huffman-Codierung

Huffman-Codierung ist ein verlustfreier Kompressionsalgorithmus, der variabel lange Codes an Symbole basierend auf ihrer Häufigkeit im Eingabedatensatz zuweist. Häufigere Symbole erhalten kürzere Codes, während weniger häufige Symbole längere Codes erhalten.

Der Algorithmus funktioniert, indem er einen binären Baum, genannt Huffman-Baum, von unten nach oben aufbaut. Jeder Blattknoten repräsentiert ein Symbol und seine Häufigkeit, während jeder innere Knoten die Summe der Häufigkeiten seiner Kinder darstellt. Der Baum wird konstruiert, indem wiederholt die beiden Knoten mit den niedrigsten Häufigkeiten zusammengeführt werden, bis nur noch ein Knoten übrig bleibt.

Sobald der Baum erstellt ist, wird der Code für jedes Symbol durch den Pfad von der Wurzel zuHier ist die deutsche Übersetzung der Markdown-Datei. Für den Codebereich wurden nur die Kommentare übersetzt, der Code selbst blieb unverändert.

Der entsprechende Blattknoten, mit einem linken Zweig, der für eine "0" steht, und einem rechten Zweig, der für eine "1" steht.

Hier ist ein Beispiel für Huffman-Codierung:

Eingabe:  "AAAABBBCCCDDDEEE"
Ausgabe: "000010011001011100101"

Huffman-Baum:
      (15)
     /    \
   (7)    (8)
   / \    / \
 (4) (3) (3) (5)
 /\  /\  /\  /\
A B  C D E

In diesem Beispiel wird "A" der Code "00" zugewiesen, "B" erhält "01", "C" erhält "10", "D" erhält "110" und "E" erhält "111". Die komprimierte Ausgabe wird erhalten, indem jedes Symbol in der Eingabe durch seinen entsprechenden Code ersetzt wird.

Huffman-Codierung ist ein optimaler Präfixcode, was bedeutet, dass kein Code ein Präfix eines anderen Codes ist. Dies ermöglicht eine eindeutige Dekodierung der komprimierten Daten. Huffman-Codierung wird in verschiedenen Dateiformaten und Kompressionstools wie JPEG, MP3 und ZIP weit verbreitet eingesetzt.

Lempel-Ziv-Welch (LZW) Kompression

Lempel-Ziv-Welch (LZW) Kompression ist ein wörterbuchbasierter Kompressionsalgorithmus, der während der Kompression ein Wörterbuch (oder Codebuch) von Zeichenketten aufbaut. LZW wird in Dateikompressionsprogrammen weit verbreitet eingesetzt und wurde im GIF-Bildformat verwendet.

Die Schlüsselidee hinter LZW ist es, Zeichenketten durch einzelne Codes zu ersetzen. Es liest die Eingabezeichenkette Zeichen für Zeichen und codiert die Zeichenkette in eine kompakte Darstellung, indem es jeden festlängigen Code durch einen variabellängigen Code ersetzt. Je länger die Zeichenkette, desto mehr Platz wird durch die Codierung als einzelne Zahl eingespart.

Hier ist eine schrittweise Beschreibung, wie LZW-Kompression funktioniert:

  1. Initialisiere das Wörterbuch, um alle Einzelzeichenketten zu enthalten.
  2. Finde die längste Zeichenkette W im Wörterbuch, die mit der aktuellen Eingabe übereinstimmt.
  3. Gib den Wörterbuchindex für W als Ausgabe aus und entferne W aus der Eingabe.
  4. Füge W gefolgt vom nächsten Symbol in der Eingabe zum Wörterbuch hinzu.
  5. Gehe zu Schritt 2.

Betrachten wir ein Beispiel. Angenommen, wir wollen die Zeichenkette "ABABABABA" mit LZW komprimieren.

  1. Initialisiere das Wörterbuch mit "A" und "B".

  2. Die längste Übereinstimmung ist "A". Gib ihren Index als Ausgabe aus und entferne sie aus der Eingabe.

  3. Füge "A" gefolgt vom nächsten Symbol "B" zum Wörterbuch hinzu.

  4. Gehe zu Schritt 2.Hier ist die deutsche Übersetzung der Markdown-Datei. Für den Code wurden nur die Kommentare übersetzt, der Code selbst blieb unverändert.

  5. Der längste Treffer ist "A". Geben Sie seinen Index (0) aus und entfernen Sie ihn aus der Eingabe. Das Wörterbuch enthält jetzt "A", "B" und "AB".

  6. Der längste Treffer ist "B". Geben Sie seinen Index (1) aus und entfernen Sie ihn aus der Eingabe. Das Wörterbuch enthält jetzt "A", "B", "AB" und "BA".

  7. Der längste Treffer ist "AB". Geben Sie seinen Index (2) aus und entfernen Sie ihn aus der Eingabe. Das Wörterbuch enthält jetzt "A", "B", "AB", "BA" und "ABA".

  8. Der längste Treffer ist "ABA". Geben Sie seinen Index (4) aus und entfernen Sie ihn aus der Eingabe. Das Wörterbuch enthält jetzt "A", "B", "AB", "BA", "ABA" und "ABAB".

  9. Der längste Treffer ist "BA". Geben Sie seinen Index (3) aus. Die Eingabe ist jetzt leer.

Die komprimierte Darstellung von "ABABABABA" ist somit die Folge der Indizes [1], die weniger Bits zur Darstellung benötigt als die ursprüngliche ASCII-Darstellung.

Die Dekompression funktioniert ähnlich, aber in umgekehrter Reihenfolge:

  1. Initialisieren Sie das Wörterbuch, um alle Zeichenketten mit einem Zeichen zu enthalten.
  2. Lesen Sie einen Code X aus der Eingabe.
  3. Geben Sie die Zeichenkette für X aus dem Wörterbuch aus.
  4. Wenn der vorherige Code vorhanden ist, fügen Sie die vorherige Zeichenkette, die mit dem ersten Zeichen der Zeichenkette für X verkettet ist, dem Wörterbuch hinzu.
  5. Gehen Sie zu Schritt 2.

LZW-Kompression ist einfach und schnell, was sie zu einer guten Wahl für viele Anwendungen macht. Sie hat jedoch auch einige Einschränkungen. Die Größe des Wörterbuchs kann recht groß werden und einen erheblichen Speicherverbrauch verursachen. Außerdem wird das Wörterbuch nach jedem Eingabeblock zurückgesetzt, was das Kompressionsverh ältnis für kleine Dateien reduzieren kann.

Trotz dieser Einschränkungen bleibt LZW ein beliebter und effektiver Kompressionsalgorithmus, insbesondere für Anwendungen, bei denen Geschwindigkeit wichtiger ist als die Erzielung der höchstmöglichen Kompressionsraten.

Zusammenfassung

In diesem Kapitel haben wir mehrere wichtige String-Verarbeitungsalgorithmen untersucht, darunter String-Sortierung, Tries, Substring-Suche, reguläre Ausdrücke und Datenkompression. Diese Algorithmen bilden die Grundlage für viele reale Anwendungen und sind unerlässliche Werkzeuge für jeden Programmierer, der mit Textdaten arbeitet.

Wir haben zunächst die String-Sortierung besprochen, die eine Schlüsselrolle bei vielen Anwendungen spielt.Hier ist die deutsche Übersetzung der Markdown-Datei. Für den Code wurden nur die Kommentare übersetzt:

Optimierte Sortieralgorithmen, die die besonderen Eigenschaften von Zeichenketten ausnutzen. Key-indexed counting, LSD-Radixsortierung und MSD-Radixsortierung bieten effiziente Methoden zum Sortieren von Zeichenketten basierend auf ihren einzelnen Zeichen.

Als Nächstes haben wir Tries, eine baumähnliche Datenstruktur zum Speichern und Abrufen von Zeichenketten, untersucht. Tries ermöglichen schnelles Präfixmatching und werden häufig in Anwendungen wie Autovervollständigung und IP-Routing-Tabellen verwendet.

Substring-Suchalgorithmen wie der Knuth-Morris-Pratt- und der Boyer-Moore-Algorithmus ermöglichen es uns, effizient nach Mustern in größeren Zeichenketten zu suchen. Diese Algorithmen haben zahlreiche Anwendungen in der Textbearbeitung, der Bioinformatik und dem Information Retrieval.

Reguläre Ausdrücke bieten eine leistungsfähige und flexible Möglichkeit, Zeichenkettenstrukturen zu beschreiben. Wir haben die grundlegende Syntax regulärer Ausdrücke besprochen und wie sie in verschiedenen Programmiersprachen und Tools für Musterabgleich und Zeichenkettenmanipulation verwendet werden können.

Schließlich haben wir Datenkompressionsalgorithmen untersucht, die die Größe von Daten durch Ausnutzung von Redundanz und Mustern im Eingabematerial reduzieren. Wir haben Run-Length-Encoding, Huffman-Codierung und Lempel-Ziv-Welch-Kompression behandelt, von denen jede ihre eigenen Stärken und Anwendungen hat.

Das Verständnis dieser Zeichenkettenverarbeitungsalgorithmen und Datenstrukturen ist für jeden, der mit Textdaten arbeitet, von entscheidender Bedeutung. Da die Menge an unstrukturierten Daten weiter wächst, wird die Fähigkeit, Zeichenketten effizient zu manipulieren, zu durchsuchen und zu komprimieren, immer wertvoller. Durch die Beherrschung der in diesem Kapitel behandelten Techniken werden Sie gut gerüstet sein, eine Vielzahl von Zeichenkettenverarbeitungsherausforderungen in Ihren eigenen Projekten und Anwendungen zu bewältigen.