Komprimierung

Huffman-Algorithmus

Dieser Text ist als Hintergrundinformation ausschliesslich für die Lehrperson gedacht. Der Text ist deshalb eher technisch. Er lehnt sich an das entsprechende Kapitel in "Turing Omnibus" von Dewdney A.K. (1989, 320ff) an. Allerdings ist es auch für die Lehrperson nicht notwendig, den Algorithmus bis ins letzte Detail zu verstehen. Insbesondere die Abschnitte "Was wäre ein optimaler Code?" und "Der Huffman-Code ist ein optimaler Code!" können von weniger versierten LeserInnen übersprungen werden.

Übrigens: um den Algorithmus im Unterricht einzuführen, sind keine Formeln notwendig!

Warum reicht die normale ASCII-Codierung nicht aus?

Wenn wir einen "reinen" Text als ASCII-Datei speichern, so belegt jedes gespeicherte Zeichen 8 Bit. Wir könnten auch sagen, es wird mit 8 Bit codiert. Ob dieses Zeichen nun 1000 Mal oder nur ein einziges Mal im Text vorkommt, spielt dabei keine Rolle. Das ist im Hinblick auf den Speicherbedarf einer Datei sicherlich keine effektive Codierungsmethode!

Effizienter wäre eine Codierung, welche die Häufigkeiten der Zeichen im Text berücksichtigt. Wenn ein Zeichen häufig im Text auftritt, wählen wir einen möglichst kurzen Code dafür. Wenn ein Zeichen hingegen nur ganz selten vorkommt, macht es nichts, wenn sein Code etwas länger ist. Das Ziel ist also ein Code mit folgender Eigenschaft: je grösser die Wahrscheinlichkeit, dass ein Zeichen im Text auftritt, desto kürzer soll sein Code sein (im Vergleich zu den Codes der anderen Zeichen). Wir könnten auch sagen: je häufiger ein Zeichen im Text vorkommt, desto kürzer soll sein Code sein. Die Wahrscheinlichkeiten bzw. die Häufigkeiten der Zeichen spielen also eine zentrale Rolle.

Die "Elemente" der Huffman-Codierung

Die Huffman-Codierung besitzt genau die oben gewünschte Eigenschaft! Betrachten wir nun, wie die Huffman-Codierung abläuft. Etwas formal ausgedrückt, hantieren wir dabei mit folgenden Elementen:

  • Wir möchten einen Text komprimieren. Die Länge diese Textes sei M Zeichen.
  • Die Zeichen, aus denen der Text besteht, nennen wir "Symbole" und bezeichnen sie mit s1, s2, s3,..., sn. Diese Symbole sind üblicherweise eine Untermenge der ASCII-Zeichen. Wesentlich ist, dass wir nur diejenigen ASCII-Zeichen betrachten, die im Text überhaupt auftreten. Alle anderen Zeichen spielen keine Rolle!
  • Die Wahrscheinlichkeit des Symbols si (seine relative Häufigkeit im Text) nennen wir pi. Berechnet wird sie wie folgt:

    Von der Wahrscheinlichkeitsrechnung wissen wir, dass die Summe aller relativen Häufigkeiten Eins beträgt.

Was wäre ein optimaler Code?

Bei der Codierung ersetzen wir jedes Zeichen im Originaltext durch einen Code für dieses Zeichen. Formaler ausgedrückt: das Symbol si im Originaltext wird durch ein binäres Codewort der Länge li ersetzt. Gesucht ist ein optimaler Code. Optimal bedeutet, dass der Code die mittlere (durchschnittliche) Codewortlänge minimiert. Berechnet wird die mittlere Codewortlänge L wie folgt:

Der Huffman-Code ist ein optimaler Code!

Wir können annehmen, die Symbole s1, s2, s3,..., sn seien bereits so nummeriert, dass die Wahrscheinlichkeiten der Symbole absteigend sortiert sind: p1 >= p2 >= p3 >= ... >= pn. Wir hatten schon zu Beginn gefordert, dass die wahrscheinlicheren Symbole kürzere Codes haben sollten. Das heisst, es soll gelten: l1 <= l2 <= l3 <= ... <= ln. Genau das erreicht die Huffman-Codierung; wie, das zeigt der Algorithmus weiter unten. Warum ist das so wichtig? Nun, es ist anhand der Formel zur Berechnung von L relativ leicht einzusehen, dass die mittlere Codewortlänge L nur dann minimal sein kann, wenn eben die Codewortlängen aufsteigend sortiert sind. Ergo ist der Huffman-Code optimal!

Anmerkung: Der Algorithmus ist natürlich kein Beweis für die Optimalität des Huffman-Codes! Der Beweis baut aber auf den Eigenschaften des Algorithmus auf. Daher kann der Algorithmus als "Plausibilitätsbetrachtung" gelten.

Grafische Repräsentation des Huffman-Codes: der Code-Baum

Der Huffman-Algorithmus baut einen sogenannten "Code-Baum" auf. Mit Hilfe dieses Baumes werden die Codewörter für die einzelnen Symbole erzeugt. Ausserdem dient der Baum als visuelle Repräsentation der Symbole, ihrer Wahrscheinlichkeiten und ihrer Codes.

Beispiel. Nehmen wir an, wir hätten einen Text, in dem nur die Symbole A, B, C, D, E, F, G vorkommen. Was der Text genau ist, spielt für die Konstruktion der Huffman-Codes keine Rolle. Wichtig sind einzig und allein die Wahrscheinlichkeiten der Symbole. Diese seien 0.25, 0.21, 0.18, 0.14, 0.09, 0.07 respektive 0.06. Die Huffman-Codes für diese Symbole können wir im Code-Baum grafisch darstellen:

Die Blätter dieses Baumes repräsentieren die Symbole des Originaltextes; in ihnen steht die Wahrscheinlichkeit des jeweiligen Symbols. Jeder Knoten des Baumes hat auch eine gewisse Wahrscheinlichkeit. Diese ist einfach die Summe der Wahrscheinlichkeiten seiner Kinder. Das bedeutet, dass die Wahrscheinlichkeit der Wurzel Eins sein muss - es ist einfach die Summe der Wahrscheinlichkeiten aller Symbole!

Codewörter - einfach ablesen aus dem Code-Baum

Aus diesem Code-Baum können die eigentlichen Code-Wörter abgelesen werden. Dazu sucht man sich den (eindeutigen) Weg von der Wurzel des Baumes zu einem Symbol-Blatt, und macht folgendes:

  • Wir starten mit einem leeren String.

  • Bei jedem Knoten können wir uns entscheiden, ob wir nach links oder rechts weitergehen. Steigen wir in den linken Teilbaum ab, so fügen dem String hinten eine "0" an. Steigen wir in den rechten Teilbaum ab, so fügen dem String hinten eine "1" an.

  • Sind wir in dem Blatt angelangt, so nehmen wir den String als Codewort für das entsprechende Symbol.

Beispiel (Fortsetzung). Der obige Code-Baum liefert uns folgende Codewörter:

A B C D E F G
00 10 010 011 111 1100 1101

Die mittlere Codelänge L beträgt hier 2.67 Bit. Besser geht es nicht!

Decodierung - einfach dank Code-Baum

Warum verwendet der Huffman-Algorithmus einen Code-Baum? Der Grund dafür ist die effiziente Decodierung. Will man einen Binärstring anhand eines Code-Baumes decodieren, geht man analog zur oben beschriebenen Codewort-Erzeugung vor. Man startet bei der Wurzel. Ist das erste Zeichen im Binärstring eine "0", steigt man in den linken Teilbaum ab, sonst in den rechten. Dieses Prozedere wiederholt man, bis man in einem Blatt ankommt. Und schon hat man das Symbol gefunden!

Etwas technischer ausgedrückt, garantiert der Code-Baum, dass der daraus resultierende Code präfixfrei ist. Das heisst, kein Codewort ist der Anfang eines anderen Codewortes. Wäre dem nicht so, könnte man nicht so einfach wie oben beschrieben decodieren: man wüsste nicht, ob man in einem Knoten oder schon in einem Blatt ist!

Konstruktion des Huffman Code-Baums - der Huffman-Algorithmus

Der Huffman-Algorithmus verfolgt das Ziel, weniger häufigen Symbolen längere Codewörter zuzuweisen. Daher geht er wie folgt vor.

  1. Er startet mit einer Liste von n Blättern. Jedes Blatt repräsentiert ein Symbol.
  2. Dann entfernt er die zwei Blätter mit den kleinsten Wahrscheinlichkeiten aus der Liste.
  3. Diese beiden Blätter fasst er zu einem Knoten zusammen. Der Knoten wird in die Liste eingefügt.

Und jetzt macht der Algorithmus bei 2. weiter: er entfernt die zwei Blätter oder Knoten mit den kleinsten Wahrscheinlichkeiten aus der Liste, fasst sie zusammen zu einem neuen Knoten und fügt diesen in die Liste ein. Und so weiter, bis die Liste nur noch einen Knoten enthält. Dieser Knoten ist die Wurzel des Code-Baums - und daraus können die Codewörter ganz einfach, wie oben beschrieben, erzeugt werden!

Die Datenstruktur

Den Huffman-Algorithmus zur Erzeugung der Codewörter zeigen wir im folgenden als Pascal-ähnliches Programm. Der Code-Baum spielt dabei eine zentrale Rolle. Deshalb brauchen wir folgende Baum-Datenstruktur:

    TYPE Subtrees = ARRAY n+1 of Node;  (* Array mit Indizes 0 bis n *)
         Node = POINTER TO NodeDesc;
         NodeDesc = RECCORD
             prob:   REAL;              (* Wahrscheinlichkeit *)
             symbol: CHAR;              (* Symbol *)
             left:   Node;              (* linker Teilbaum *)
             right:  Node;              (* rechter Teilbaum *)
         END;

In einem Baum unterscheiden wir zwischen Knoten und Blättern. Graphisch dargestellt:

In einem Blatt sind left und right nicht definiert. Daher setzen wir sie auf NIL. In einem Knoten hingegen gibt es kein symbol - dieses Feld wird bei Knoten einfach ignoriert.

Initialisierung

Der Algorithmus startet mit einer Liste (Array) von Blättern, für jedes Symbol ein Blatt. Entsprechend initialisieren wir eine Variable array vom Typ SubTrees:

    FOR i := 1 TO n DO
        array[i] := NEW (Node);
        array[i].symbol := si;
        array[i].prob := pi;
        array[i].left := NIL;
        array[i].right := NIL;
    END;

Anmerkung: das Element array[0] benutzen wir als Hilfselement. Daher wird es nicht initialisiert. In array sind nach dieser Initialisierung n "Teilbäume" gespeichert, die nur aus je einem Blatt bestehen.

Der eigentliche Algorithmus

Nach der Initialisierung kann der eigentliche Algorithmus beginnen. Wie oben beschrieben, fassen wir solange Teilbäume in der Liste array zusammen, bis die Liste nur noch ein Element enthält. Wieviele "Zusammenfassungen" gibt es? Wir starten mit n Elementen und fassen in jedem Schritt 2 Elemente zusammen. Nach dem ersten Schritt sind es nur noch n-1 Elemente, nach dem zweiten n-2... Nach dem i-ten Schritt sind es noch n-i Elemente. Also fassen wir insgesamt n-1 Mal je zwei Elemente zusammen.

Vor der i-ten Zusammenfassung hat es noch n-i+1 Teilbäume in der Liste. Diese sortieren wir in absteigender Reihenfolge. Als Sortierkriterium verwenden wir die Wahrscheinlichkeit der Wurzeln der Teilbäume (array[i].prob). Die beiden Teilbäume mit den kleinsten Schlüsseln werden verschmolzen. Dank der absteigenden Sortierung wissen wir, dass es sich bei den gesuchten Teilbäumen um die beiden letzten in der Liste handelt (array[n-i].prob und array[n-i+1].prob). Diese werden mit Hilfe des Arrayelements array[0] verschmolzen. Übrig bleiben noch n-i Elemente.

    FOR i := 1 TO (n-1) DO
        sortiere Teilbäume im Teilarray von 1 bis (n-i+1) 
            nach absteigender Reihenfolge der Wahrscheinlichkeiten;
        array[0] := NEW (Node);
        array[0].prob := array[n-i].prob + array[n-i+1].prob;
        array[0].left := array[n-i];
        array[0].right := array[n-i+1];
        array[n-i] := array[0];
    END;

Beispiel (Fortsetzung):

Initialisierung

 

Nach der ersten Iteration, und neu sortiert

 

Nach der zweiten Iteration, und neu sortiert

 

Bei dieser grafischen Darstellung lässt sich schön verfolgen, wie bei jeder Iteration die beiden Teilbäume mit der kleinsten Wahrscheinlichkeit verschmolzen werden. Nach dem Verschmelzen sind alle Blätter dieser beiden Teilbäume im neuen Baum eine Etage tiefer angesiedelt als vorher. Das heisst, ihr Code verlängert sich ebenfalls um ein Bit. So stellt der Huffman-Algorithmus sicher, dass die seltensten Codewörter die längsten werden!

Anmerkung: In der Realität wird der Algorithmus nicht wie beschrieben implementiert - das wäre viel zu ineffizient. Es ging uns hier nur um das Prinzip des Algorithmus.

Zusammenfassung

Komprimierung eines ASCII-Textes mit Huffman

Die Komprimierung läuft in 5 Schritten ab:

  1. Schritt: Lege eine Tabelle mit allen im Originaltext vorhandenen Symbolen und deren relativen Häufigkeiten an.

  2. Schritt: Konstruiere den Huffman-Baum und erzeuge daraus eine Code-Tabelle.

  3. Schritt: Durchlaufe den Text und ersetzte jedes Symbol mit dem entsprechenden Code. Das Resultat ist ein grosser binärer String.

  4. Schritt: Damit dieser String platzsparend gespeichert werden kann, zerlegen wir ihn. Die meisten Computer arbeiten heute mit Ganzzahlen (Integers), die 32 Bit lang sind. Daher zerlegen wir den String in Blöcke der Länge 32.

  5. Schritt: Jeder dieser Blöcke kann nun einfach in einen Integer umgewandelt werden. Diese speichern wir. Zusätzlich speichern wir für die Decodierung den Code-Baum ab.

Dekomprimieren

  1. Schritt: Lade den Code-Baum.

  2. Schritt: Lies die Integers und interpretiere sie wiederum als einen grossen binären String. Starte bei der Wurzel im Code-Baum. Ist das erste Zeichen im Binärstring eine "0", steige in den linken Teilbaum ab, sonst in den rechten. Wiederhole, bis in einem Blatt angekommen. Das gefundene Symbol wird ausgegeben. Wiederhole dieses Prozedere, bis der ganze Binärstring abgearbeitet ist.