Fakultät für Informatik Lehrstuhl 11 / Algorithm Engineering Prof. Dr. Petra Mutzel, Carsten Gutwenger Sommersemester 2009 DAP2 Praktikum – Blatt 4 Ausgabe: 12. Mai — Abgabe: 27.–29. Mai Bei diesem Übungsblatt werden wir Priority-Queues dazu benutzen, um Huffman-Codes zur Komprimierung von Texten zu berechnen. Unsere Eingabe ist ein Text aus m 8-Bit Ascii-Zeichen, d.h. wir benötigen eigentlich m Bytes um diesen Text zu speichern. Kommen in dem Text lediglich n unterschiedliche Zeichen vor, dann benötigen wir natürlich nur k = dlog ne Bits zur Kodierung eines Zeichens und entsprechend dm ∗ k/8e Bytes zur Speicherung des Text (plus eine kleine “Übersetzungstabelle” für die Zeichen). In jedem dieser Fällen kodieren wir aber alle Zeichen mit einer festen Bitlänge, also mit 8 oder k Bits. Wir können die Komprimierung jedoch deutlich verbessern, wenn wir die Zeichen mit variabler Länge kodieren: Häufig vorkommende Zeichen im Text bekommen eine kurze Kodierung und selten vorkommende Zeichen dafür eine längere Kodierung. Damit wir den Text später wieder dekodieren können, stellen wir sicher, dass es keine Zeichenkodierung gibt, die ein Präfix einer anderen Zeichenkodierung ist. Mit Hilfe des Huffman-Algorithmus können wir optimale Zeichenkodierungen berechnen, d.h. der entsprechend kodierte Text besitzt minimale Länge. Der Algorithmus berechnet zunächst für jedes Zeichen, wie oft es im Eingabetext vorkommt. Danach konstruiert er einen geordneten binären Baum, dessen Blätter die im Text vorkommenden Zeichen sind und dessen innere Knoten zwei Kinder besitzen. Die Kodierung eines Zeichens ist dann gegeben durch den Pfad von der Wurzel zu diesem Zeichen: Immer wenn wir zu einem linken Kind gehen, hängen wir eine ’0’ an die Kodierung an, und wenn wir zu einem rechten Kind gehen eine ’1’. Das folgende Beispiel zeigt einen optimalen Baum für einen Text mit 45 ’a’, 13 ’b’, 12 ’c’, 16 ’d’, 9 ’e’ und 5 ’f’. Die Kodierung für das häufigste Zeichen ’a’ wäre dann 0 und die für das seltenste Zeichen ’f’ wäre 1100. Jeder innere Knoten ist mit der Summe der Vorkommen der Zeichen in seinem Unterbaum beschriftet, z.B. 30 = 5 + 9 + 16. 100 0 1 a:45 55 0 1 25 0 c:12 30 0 1 b:13 1 d:16 14 0 f:5 1 e:9 Sei C die Menge der Zeichen und count[c] die Anzahl der Vorkommen des Zeichens c im Text. Dann kann der Algorithmus mit Hilfe einer Priority-Queue wie folgt implementiert werden. 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: n := |C| PriorityQueue pq for all c ∈ C do x := new Node x.character := c; x.priority := count[c] pq.Insert(x) end for for i := 1 to n − 1 do x := pq.ExtractMin(); y := pq.ExtractMin() z := new Node z.left := x; z.right := y z.priority := x.priority + y.priority pq.Insert(z) end for return pq.ExtractMin() Wir speichern hier die Prioritäten einfach in den Knoten. Die erste for-Schleife erzeugt alle Blätter und die zweite for-Schleife die inneren Knoten. Es werden einfach (greedy) jeweils die beiden Knoten mit geringster Priorität genommen und darüber ein neuer innerer Knoten als Elter gesetzt, dessen Priorität die Summe der Prioritäten seiner beiden Kinder ist. In unserem Beispiel würden die inneren Knoten also in der Reihenfolge 14, 25, 30, 55, 100 erzeugt. Im Verzeichnis /home/gutwen00/DAP2/Zusatzmaterial/PB_04/Code befindet sich das Grundgerüst für das zu implementierende Testprogramm inklusive Makefile. Der Aufruf des Programms sieht wie folgt aus: hcode filename [ -mode m ] [ -log logfile ] Dabei gibt filename die einzulesende Textdatei an, der Modus m ist entweder sort (für Sortieren) oder code (für die Berechnung des Huffman-Code) und mit logfile kann ein Filename für das Logfile angegeben werden (standardmäßig wird Dateiname ohne Pfad plus .log genommen). • Im Modus sort sollen die Zeichen c mit Hilfe einer Priority-Queue nach ihrer Häufigkeit count[c] sortiert werden. Es wird kein Logfile erzeugt. • Im Modus code soll mit Hilfe des Huffman-Algorithmus der Code für jedes Zeichen berechnet werden. Das Programm gibt außerdem die Größe des resultierenden Huffman-Codes aus und schreibt die Zeichencodes und die Bitlänge des kodierten Textes in das Logfile. In /home/gutwen00/DAP2/Zusatzmaterial/PB_04 findet man Eingabetexte mit den zugehörigen Logfiles. Das Programm lässt sich bereits mit make erzeugen, allerdings werden Aufrufe im Modus sort oder code zu Fehlern führen, da noch Teile der Implementierung fehlen. Langaufgabe 4.1 Implementiere die Priority-Queue für das Testprogramm (Quelldateien PriorityQueue.h und PriorityQueue.cpp) mit Hilfe eines Min-Heaps. Die Elemente in der Priority-Queue sind Zeiger auf Instanzen vom Typ Node, welche später die Knoten unseres Binärbaums repräsentieren werden und auch die Prioritäten speichern. Wir benötigen folgende Methoden in der Klasse PriorityQueue: • PriorityQueue(Node **nodes, int n) Konstruktor: Bekommt als Eingabe ein Array von n Zeigern auf Node und erzeugt eine Priority-Queue, die diese Elemente enthält. Die Priority-Queue wird auch nie mehr als n Elemente enthalten. Die Laufzeit soll linear in n sein. Welche Operation auf Min-Heaps muss man dazu verwenden? • Node *extractMin() Entfernt ein Element mit minimaler Priorität aus der Priority-Queue und gibt es zurück. • void insert(Node *v) Fügt das Element v in die Priority-Queue ein. Teste, ob das Programm im Modus sort jetzt die Elemente korrekt sortiert ausgibt. Hinweis: Da unsere Priority-Queue nur die oben genannten Operationen unterstützen muss, benötigen wir nicht die zusätzliche Verwaltung des Index im HeapElement, wie sie in Kapitel 3.1.6 des Skripts beschrieben ist. Langaufgabe 4.2 Vervollständige die Implementierungen des Huffman-Algorithmus und der Berechnung der Zeichencodes. Dazu müssen zwei Funktionen in huffman.cpp implementiert werden: • Node *huffman(Node **inputNodes, int n) Diese Funktion bekommt als Eingabe bereits ein Array mit Zeigern auf die Blätter des Baumes. Konstruiere nun eine Priority-Queue mit diesen Elementen und führe den zweiten Teil des Huffman-Algorithmus (ab Zeile 8) durch, so dass der Binärbaum zur Repräsentation der Zeichencodes erzeugt wird. Die Funktion gibt die Wurzel dieses Baumes zurück. • void constructCodewords(Node *v, const string &str, string *codeword) Diese Funktion berechnet rekursiv den Zeichencode codeword[c] für jedes Zeichen c. Dabei ist str der Binärstring, der sich durch den Pfad von der Wurzel bis zum Knoten v ergibt. Falls v ein Blatt ist, dann weist die Funktion v seinen Zeichencode zu, sonst ruft sie sich rekursiv für die beiden Kinder von v auf. Teste dein Programm im Modus code auf den Texten im Zusatzmaterial. Dort liegt auch für jede Instanz das entsprechende Logfile, so dass du schnell mit einem diff die Korrektheit deiner Implementierungen überprüfen kannst. Hinweise und Tipps 4.1 Klassen in C++ Im Unterschied zu Java trennt man bei C++ die Deklaration einer Klasse von der Implementierung: Die Deklaration der Klasse wird in eine Header-Datei (.h) und die Implementierung in eine .cpp-Datei geschrieben. Anderer Quellcode, der diese Klasse verwenden will, inkludiert lediglich die Header-Datei. Die .cpp-Datei wird für sich compiliert und die resultierende Objektdatei (.o) wird später dazugelinkt. Betrachten wir als Beispiel eine Klasse Feld, die ein Array kapselt. Die Deklaration könnte wie folgt aussehen: class Feld { int m_size; float *m_array; public: Feld(int n); ~Feld(); int groesse() const { return m_size; } }; Hier haben wir eine private Variable m size, einen Konstruktor, einen Destruktor und eine Methode groesse() deklariert. Für groesse() ist bereits die Implementierung bei der Deklaration angegeben. Das macht man üblicherweise, wenn die Implementierung sehr kurz ist. Der Destruktor wird durch die führende Tilde (~) gekennzeichnet und wird normalerweise benutzt, um allozierten Speicher wieder freizugeben oder sonstige Aufräumarbeiten durchzuführen. Die Implementierung in Feld.cpp könnte so aussehen: #include "Feld.h" Feld::Feld(int n) : m_size(n) { m_array = new float[n]; } Feld::~Feld() { delete [] m_array; } Zu beachten ist, dass die Methoden mit vorangestelltem Feld:: geschrieben werden müssen, damit der Compiler weiß, zu welcher Klasse die Methode gehört. Die Initialisierung von MemberVariablen im Konstruktor kann direkt nach einem : aufgelistet werden (bei mehreren Variablen durch Komma getrennt); in unserem Beispiel wird m size mit n initialisiert. 4.2 Der C++-Präprozessor Der C++-Präprozessor wird immer automatisch vor dem Compiler aufgerufen und führt textuelle Ersetzungen auf dem Quellcode durch. Alle Präprozessor-Kommandos beginnen mit #. Das Kommando #include haben wir schon häufig benutzt: Es fügt den Inhalte der zu inkludierenden Datei an dieser Stelle in den Quellcode ein. Weitere häufig benutzte Kommandos dienen zur Definition von Symbolen und zur bedingten Compilierung. Das Kommando #define symbol wert definiert, dass das Symbol symbol den Wert wert bekommt und ersetzt jedes Vorkommen des Textes symbol im Quelltext durch wert. Dabei muss der Wert nicht unbedingt angegeben werden, in dem Fall ist das Symbol quasi mit einem leeren Wert definiert. Eine wichtige Anwendung dieser Defines ist die bedingte Compilierung, denn wir können mit #ifdef symbol testen, ob ein Symbol definiert ist, und mit #ifndef symbol, ob es nicht definiert ist. Das benutzt man immer in Header-Dateien, um Mehrfach-Includes zu verhindern, z.B. in Feld.h könnten wir schreiben: #ifndef _FELD_H #define _FELD_H ... hier kommt die Deklaration von Feld #endif Beim ersten Include von Feld.h ist FELD H noch nicht definiert und die Deklaration wird an den Compiler weitergegeben, bei einem weiteren Include ist dann aber FELD H definiert und die Deklaration wird einfach ausgelassen. Man kann Defines übrigens auch direkt beim Aufruf des Compilers angeben, indem man das Flag -Dsymbol oder -Dsymbol =wert benutzt. Darüber hinaus kann man sogar Makros mit Parametern definieren, z.B. #define maximum(a,b) (((a) > (b)) ? (a) : (b)) Die vielen Klammern um die Parameter schließen übrigens eine häufige Fehlerquelle aus, denn wir bekommen die Parameter ja nur rein textuell geliefert, und dieser Text ersetzt lediglich jedes Vorkommen des Parameters. Zur Definition solcher kleiner Funktionen verwendet man in C++ allerdings stattdessen parametrisierte Inline-Funktionen, die dem Compiler die Typprüfung zur Compile-Zeit erlauben: template<typename T> inline bool maximum(const T &a, const T&b) { return (a > b) ? a : b; } 4.3 Zeiger und Referenzen Ein Zeiger auf eine Variable x speichert die Adresse des für x angelegten Speichers und erlaubt es, über den Zeiger auf die Variable zuzugreifen. Im Zusammenhang mit Zeigern sind zwei Operatoren sehr wichtig: der Adressoperator (&) und der Dereferenzierungsoperator (*). Wir betrachten folgendes Beispiel: int x = 17; int *p = &x; *p = 20; cout << x; // // // // eine Variable vom Typ int ein Zeiger auf einen int weist der Variablen x den Wert 20 zu gibt 20 aus Der Typ “Zeiger” wird durch Anhängen von * an den jeweiligen Datentyp gekennzeichnet. Mit dem Dereferenzierungsoperator erhalten wir die Variable, auf die der Zeiger zeigt. Falls unsere Variable eine Klasse ist, so gibt es die abkürzende Schreibweise ->, um auf die Member der Klasse zuzugreifen: class A { public: int x, y; }; A a; A *p = &a; p->x = 15; // weist a.x 15 zu // äquivalent: (*p).x = 15 Eine Referenz ist ein Stellvertreter für eine Variable. Ähnlich wie bei Zeigern muss intern lediglich die Adresse der Variablen gespeichert werden, jedoch kann bei der Referenz die gleiche Syntax verwendet werden wie bei der Variablen selbst, d.h. es ist kein Dereferenzieren notwendig. Außerdem muss eine Referenz immer initialisiert werden. Eine Referenz wird durch Anhängen von & an den jeweiligen Datentyp gekennzeichnet. int x = 17; int &y = x; y = 20; // weist der Variablen x den Wert 20 zu Insbesondere bei Parametern von Funktionen benutzt man gerne const-Referenzen. Bei einer const-Referenz ist es nicht erlaubt, der referenzierten Variablen über die Referenz einen Wert zuzuweisen: int x = 17; const int &y = x; cout << y; // gibt 17 aus y = 20; // Compile-Fehler: Zuweisung an const-Referenz nicht erlaubt 4.4 C++-Strings konkatenieren C++-Strings lassen sich sehr elegant unter Verwendung des +-Operators konkatenieren: string x = "C++ ist"; string y = "super!"; string z = x + " " + y; // z = "C++ ist super!" 4.5 Links- und rechtsbündige Ausgabe Um Ausgabe schön zu formatieren, kann man mit setw(b) die Breite eines Ausgabefeldes angeben und mit setiosflags(ios::left) bzw. setiosflags(ios::right) links- bzw. rechtsbündige Ausgabe erzeugen. Die Funktionen werden einfach in den Ausgabe-Stream eingefügt (<iomanip> muss inkludiert werden): for(int i = 0; i < 20; ++i) cout << setiosflags(ios::right) << setw(4) << i << endl; Lykke til! – das DAP2-Team