351 104 47MB
German Pages 992 Year 2014
Algorithmen
Algorithmen Algorithmen und Datenstrukturen 4., aktualisierte Auflage
Robert Sedgewick Kevin Wayne
Bibliografische Information Der Deutschen Nationalbibliothek Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.dnb.de abrufbar. Die Informationen in diesem Buch werden ohne Rücksicht auf einen eventuellen Patentschutz veröffentlicht. Warennamen werden ohne Gewährleistung der freien Verwendbarkeit benutzt. Bei der Zusammenstellung von Texten und Abbildungen wurde mit größter Sorgfalt vorgegangen. Trotzdem können Fehler nicht ausgeschlossen werden. Verlag, Herausgeber und Autoren können für fehlerhafte Angaben und deren Folgen weder eine juristische Verantwortung noch irgendeine Haftung übernehmen. Für Verbesserungsvorschläge und Hinweise auf Fehler sind Verlag und Herausgeber dankbar. Alle Rechte vorbehalten, auch die der fotomechanischen Wiedergabe und der Speicherung in elektronischen Medien. Die gewerbliche Nutzung der in diesem Produkt gezeigten Modelle und Arbeiten ist nicht zulässig. Fast alle Produktbezeichnungen und weitere Stichworte und sonstige Angaben, die in diesem Buch verwendet werden, sind als eingetragene Marken geschützt. Da es nicht möglich ist, in allen Fällen zeitnah zu ermitteln, ob ein Markenschutz besteht, wird das ®-Symbol in diesem Buch nicht verwendet. Authorized translation from the English language edition, entitled ALGORITHMS, 4th edition by Robert Sedgewick and Kevin Wayne, published by Pearson Education, Inc., publishing as Addison-Wesley, Copyright © 2011. All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc. GERMAN language edition published by PEARSON DEUTSCHLAND GMBH, Copyright © 2014.
10 9 8 7 6 5 4 3 2 1
16 15 14
ISBN 978-3-86894-184-5 (Buch) ISBN 978-3-86326-758-2 (E-Book)
© 2014 by Pearson Deutschland GmbH Lilienthalstr. 2, D-85399 Hallbergmoos/Germany Alle Rechte vorbehalten www.pearson.de A part of Pearson plc worldwide Programmleitung: Birger Peil, [email protected] Korrektorat: Katharina Pieper, Berlin Übersetzung: Petra Alm, Saarbrücken Fachlektorat: Andrea Baumann Herstellung: Philipp Burkart, [email protected] Satz: Nadine Krumm, mediaService, Siegen (www.mediaservice.tv) Coverdesign: Martin Horngacher, München Coverillustration: Shutterstock.com Druck und Verarbeitung: Drukarnia Dimograf, Bielsko-Biala Printed in Poland
Inhaltsverzeichnis Vorwort
11 Besondere Merkmale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Die Website zum Buch . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Das Buch als Unterrichtsmittel . . . . . . . . . . . . . . . . . . . . . . . . . . . Kontext . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Danksagung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Vorwort zur deutschen Ausgabe
Kapitel 1
1.1
1.2
1.3
11 13 14 14 15 16
Webinhalte zum vorliegenden Buch . . . . . . . . . . . . . . . . . . . . . . .
17
Grundlagen
19
Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Zusammenfassung der Themen . . . . . . . . . . . . . . . . . . . . . . . . . . . Das grundlegende Programmiermodell . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.1 Grundlegende Struktur eines Java-Programms . . . . . . . . . . . . . . . 1.1.2 Primitive Datentypen und Ausdrücke . . . . . . . . . . . . . . . . . . . . . . 1.1.3 Anweisungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.4 Kurzschreibweisen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.5 Arrays. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.6 Statische Methoden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.7 APIs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.8 Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.9 Ein- und Ausgabe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.10 Binäre Suche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1.11 Ausblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Datenabstraktion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.2.1 Abstrakte Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.2.2 Beispiele abstrakter Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.2.3 Abstrakte Datentypen implementieren. . . . . . . . . . . . . . . . . . . . . . 1.2.4 Weitere Implementierungen abstrakter Datentypen . . . . . . . . . . . 1.2.5 Datentypdesign . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Multimengen, Warteschlangen und Stapel . . . . . . . . . . . . . . . . . . . . . . . . . 1.3.1 APIs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.3.2 Collections implementieren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.3.3 Verkettete Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.3.4 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
20 23 25 27 28 31 34 36 39 47 52 54 65 69 81 82 92 104 110 116 139 140 151 162 176
Inhaltsverzeichnis
1.4
1.5
Analyse der Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.1 Die wissenschaftliche Methode . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.2 Beobachtungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.3 Mathematische Modelle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.4 Klassifikationen der Wachstumsordnung . . . . . . . . . . . . . . . . . . . 1.4.5 Schnellere Algorithmen entwerfen . . . . . . . . . . . . . . . . . . . . . . . . 1.4.6 Experimente zum Verdopplungsverhältnis . . . . . . . . . . . . . . . . . . 1.4.7 Fallstricke . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.8 Die Abhängigkeit von Eingaben reduzieren. . . . . . . . . . . . . . . . . . 1.4.9 Speicherbedarf . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.4.10 Ausblick. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Fallstudie Union-Find . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.1 Verwaltung von Zusammenhangskomponenten . . . . . . . . . . . . . . 1.5.2 Implementierungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.5.3 Ausblick. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Kapitel 2 2.1
2.2
2.3
2.4
2.5
6
Sortieren
Elementare Sortierverfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.1.1 Spielregeln. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.1.2 Selectionsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.1.3 Insertionsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.1.4 Sortieralgorithmen grafisch darstellen . . . . . . . . . . . . . . . . . . . . . . 2.1.5 Zwei Sortieralgorithmen vergleichen. . . . . . . . . . . . . . . . . . . . . . . 2.1.6 Shellsort. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Mergesort. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2.1 Abstraktes In-Place-Mergen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2.2 Top-Down-Mergesort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2.3 Bottom-Up-Mergesort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2.4 Die Komplexität des Sortierens . . . . . . . . . . . . . . . . . . . . . . . . . . . Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3.1 Der grundlegende Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3.2 Laufzeitverhalten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3.3 Algorithmische Verbesserungen. . . . . . . . . . . . . . . . . . . . . . . . . . . Vorrangwarteschlangen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4.1 API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4.2 Einfache Implementierungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4.3 Heap-Definitionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4.4 Algorithmen für Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4.5 Heapsort. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Anwendungen. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.5.1 Verschiedene Datentypen sortieren . . . . . . . . . . . . . . . . . . . . . . . . 2.5.2 Welchen Sortieralgorithmus soll ich verwenden?. . . . . . . . . . . . . 2.5.3 Reduktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.5.4 Sortieranwendungen im kurzen Überblick . . . . . . . . . . . . . . . . . .
191 191 192 198 206 209 212 216 218 221 227 238 238 245 257 265 267 267 272 274 276 277 281 294 294 296 301 304 313 313 318 320 333 334 336 339 341 350 363 364 369 372 375
Inhaltsverzeichnis
Kapitel 3 3.1
3.2
3.3
3.4
3.5
Symboltabellen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.1.1 API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.1.2 Geordnete Symboltabellen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.1.3 Beispielclients . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.1.4 Sequenzielle Suche in einer ungeordneten verketteten Liste . . . . 3.1.5 Binäre Suche in einem geordneten Array . . . . . . . . . . . . . . . . . . . 3.1.6 Analyse der binären Suche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.1.7 Ausblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Binäre Suchbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.2.1 Grundlegende Implementierung . . . . . . . . . . . . . . . . . . . . . . . . . . 3.2.2 Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.2.3 Ordnungsbasierte Methoden und Löschen . . . . . . . . . . . . . . . . . . Balancierte Suchbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3.1 2-3-Suchbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3.2 Rot-Schwarz-Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3.3 Implementierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3.4 Löschen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3.5 Eigenschaften von Rot-Schwarz-Bäumen. . . . . . . . . . . . . . . . . . . . Hashtabellen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.4.1 Hashfunktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.4.2 Hashing mit Verkettung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.4.3 Hashing mit linearer Sondierung . . . . . . . . . . . . . . . . . . . . . . . . . . 3.4.4 Größenanpassung von Arrays . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.4.5 Speicher . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Anwendungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.1 Welche Symboltabellen-Implementierung soll ich verwenden?. . 3.5.2 Mengen-APIs (Set) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.3 Wörterbuch-Anwendungen. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.4 Indizierungsclients . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5.5 Dünn besetzte Vektoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Kapitel 4 4.1
Suchen
Graphen
Ungerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1.1 Glossar . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1.2 Datentyp für ungerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . 4.1.3 Tiefensuche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1.4 Pfadsuche. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1.5 Breitensuche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1.6 Zusammenhangskomponenten . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1.7 Symbolgraphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1.8 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
387 389 390 393 398 402 405 411 413 424 425 432 435 453 453 461 470 473 475 489 490 496 501 506 509 519 519 522 526 531 537 549 553 554 557 566 572 577 583 589 597
7
Inhaltsverzeichnis
4.2
4.3
4.4
Gerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2.1 Glossar . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2.2 Datentyp für Digraphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2.3 Erreichbarkeit in Digraphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2.4 Zyklen und azyklische Digraphen. . . . . . . . . . . . . . . . . . . . . . . . . . 4.2.5 Starker Zusammenhang in Digraphen. . . . . . . . . . . . . . . . . . . . . . . 4.2.6 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Minimale Spannbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3.1 Zugrunde liegende Prinzipien. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3.2 Datentyp eines kantengewichteten Graphen . . . . . . . . . . . . . . . . . 4.3.3 API und Testclient für minimale Spannbäume . . . . . . . . . . . . . . . 4.3.4 Der Algorithmus von Prim. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3.5 Eager-Version des Prim-Algorithmus . . . . . . . . . . . . . . . . . . . . . . . 4.3.6 Der Algorithmus von Kruskal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3.7 Ausblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Kürzeste Pfade . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.4.1 Eigenschaften der kürzeste Pfade . . . . . . . . . . . . . . . . . . . . . . . . . . 4.4.2 Datentypen für kantengewichtete Digraphen . . . . . . . . . . . . . . . . . 4.4.3 Theoretische Grundlagen für Kürzeste-Pfade-Algorithmen . . . . . 4.4.4 Algorithmus von Dijkstra. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.4.5 Azyklische kantengewichtete Digraphen . . . . . . . . . . . . . . . . . . . . 4.4.6 Kürzeste Pfade in allgemeinen kantengewichteten Digraphen . . . 4.4.7 Ausblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Kapitel 5 5.1
5.2
8
Strings
Stringsortierverfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.1.1 Schlüsselindiziertes Zählen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.1.2 LSD-Sortierverfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.1.3 MSD-Sortierverfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.1.4 3-Wege-Quicksort für Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.1.5 Welchen Stringsortieralgorithmus soll ich verwenden? . . . . . . . . Tries . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.2.1 Tries . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.2.2 Eigenschaften von Tries. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.2.3 Ternäre Suchtries . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.2.4 TST-Eigenschaften . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.2.5 Welche Symboltabellen-Implementierung soll ich für Strings verwenden? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
607 607 609 612 617 628 638 646 648 651 655 658 663 668 671 680 682 684 692 694 701 711 726 737 745 746 749 752 762 767 773 775 785 789 792 795
Inhaltsverzeichnis
5.3
5.4
5.5
Teilstringsuche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.3.1 Ein kurzer geschichtlicher Abriss. . . . . . . . . . . . . . . . . . . . . . . . . . 5.3.2 Brute-Force-Teilstringsuche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.3.3 Teilstringsuche nach Knuth-Morris-Pratt. . . . . . . . . . . . . . . . . . . . 5.3.4 Teilstringsuche nach Boyer-Moore. . . . . . . . . . . . . . . . . . . . . . . . . 5.3.5 Fingerprint-Suche nach Rabin-Karp. . . . . . . . . . . . . . . . . . . . . . . . 5.3.6 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Reguläre Ausdrücke . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.4.1 Muster mit regulären Ausdrücken . . . . . . . . . . . . . . . . . . . . . . . . . 5.4.2 Abkürzungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.4.3 Reguläre Ausdrücke in Anwendungen. . . . . . . . . . . . . . . . . . . . . . 5.4.4 Nichtdeterministische endliche Automaten . . . . . . . . . . . . . . . . . 5.4.5 Simulation eines NEA . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.4.6 Konstruktion eines NEA für einen regulären Ausdruck . . . . . . . . Datenkomprimierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5.1 Spielregeln . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5.2 Binärdaten lesen und schreiben . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5.3 Beschränkungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5.4 Aufwärmübung: Genomik. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5.5 Lauflängencodierung. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5.6 Huffman-Komprimierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.5.7 LZW-Komprimierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Kapitel 6
Register
800 800 801 804 812 817 822 829 830 832 834 836 839 842 851 852 853 857 860 863 868 882
Im Kontext
895
Ereignisgesteuerte Simulation . . . . . . . . . . . . . . . . . . . . . . . . . . . . B-Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Suffixarrays . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Netzwerkflussalgorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Reduktion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Nicht effizient lösbare Probleme . . . . . . . . . . . . . . . . . . . . . . . . . . Allgemeine Übungen zu der Kollisionssimulation . . . . . . . . . . . . Allgemeine Übungen zu B-Bäumen . . . . . . . . . . . . . . . . . . . . . . . . Allgemeine Übungen zu Suffixarrays . . . . . . . . . . . . . . . . . . . . . . Allgemeine Übungen zu Max-Fluss . . . . . . . . . . . . . . . . . . . . . . . . Allgemeine Übungen zu Reduktionen und scheinbarer Unlösbarkeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
899 909 918 928 946 953 966 968 969 971 973 975
9
Vorwort Dieses Buch präsentiert der wachsenden Zahl angehender Programmierer die wichtigsten Computeralgorithmen und die dafür erforderlichen grundlegenden Techniken. Es ist als Fachbuch für Informatikstudenten im zweiten Studienjahr konzipiert, die bereits über genügend Programmierkenntnisse verfügen und mit verschiedenen Computersystemen vertraut sind. Das Buch eignet sich aber auch zum Selbststudium oder als Referenz für alle, die Computersysteme oder Anwendungsprogramme entwickeln, da es Implementierungen von nützlichen Algorithmen sowie ausführliche Informationen zu Performancedaten und Clients enthält. Aufgrund der umfassenden Behandlung der Themen bietet sich dieses Buch als Einstieg in die Materie an. Algorithmen und Datenstrukturen sind Teil des Curriculums in der Informatik, doch darüber hinaus nicht nur für Programmierer und Informatikstudenten interessant. Denn jeder, der einen Computer nutzt, wünscht sich schnellere Laufzeiten und die Lösung komplexerer Probleme. Die Algorithmen, die wir in diesem Buch betrachten, repräsentieren ein über die letzten 50 Jahre angehäuftes Wissen, das inzwischen unentbehrlich geworden ist. Aus Wissenschaft und Forschung sind diese algorithmischen Werkzeuge nicht mehr wegzudenken. Ihr Anwendungsspektrum reicht von Physik (Kollisionssimulation) über Molekularbiologie (Gensequenzierungen) und Konstruktion (Modellerstellung, Flugsimulatoren) bis zu Softwaresystemen wie Datenbanksysteme und Internetsuchmaschinen. Bevor wir auf unser Hauptthema eingehen, entwickeln wir Datenstrukturen für Stapel, Warteschlangen und andere Low-Level-Abstraktionen, die wir in diesem Buch verwenden. Anschließend betrachten wir die grundlegenden Algorithmen zum Sortieren, Suchen, sowie zur Graphen- und Stringverarbeitung. Das letzte Kapitel bietet einen Überblick, durch den der Rest des Stoffes in diesem Buch in einen größeren Kontext eingebettet wird.
Besondere Merkmale Dieses Buch untersucht Algorithmen, die sich in der Praxis als eminent nützlich erwiesen haben. Es beschreibt und erläutert die verschiedensten Algorithmen und Datenstrukturen so ausführlich, dass die Leser sie ohne Weiteres in jeder Systemumgebung implementieren, debuggen und ausführen können. Unser Ansatz umfasst die folgenden Bereiche:
Algorithmen Die Beschreibungen der Algorithmen basieren auf vollständigen Implementierungen und umfassen eine Diskussion der Ausführung dieser Programme auf einer passenden Beispielmenge. Anstatt Pseudocode präsentieren wir Ihnen realen Code, sodass die Pro-
11
Vorwort
gramme schnell in der Praxis angewendet werden können. Unsere Programme sind in Java geschrieben, aber so allgemein gehalten, dass ein Großteil unseres Codes für die Entwicklung von Implementierungen in anderen modernen Programmiersprachen wiederverwendet werden kann.
Datentypen Unser Programmierstil ist modern, das heißt, er basiert auf Datenabstraktion, sodass Algorithmen und Datenstrukturen zusammen gekapselt sind.
Anwendungen Jedes Kapitel umfasst eine ausführliche Diskussion der Anwendungen, in denen die beschriebenen Algorithmen eine wichtige Rolle spielen. Diese reichen von Anwendungen in der Physik und Molekularbiologie über Konstruktion von Systemen bis zu vertrauten Aufgaben wie Datenkompression und Websuche.
Ein wissenschaftlicher Ansatz Wir legen Wert darauf, mathematische Modelle zu entwickeln, die die Performance der Algorithmen beschreiben, um mithilfe dieser Modelle Hypothesen über die Performance anzustellen und dann diese Hypothesen durch Ausführen der Algorithmen in realistischen Kontexten zu prüfen.
Themenumfang Wir behandeln in diesem Buch abstrakte Datentypen, Sortieralgorithmen, Suchalgorithmen, Graphenverarbeitung und Stringverarbeitung. Wir achten darauf, Datenstrukturen, Algorithmendesignparadigmen, Reduktion und Problemlösungsmodelle immer im Kontext der dazugehörigen Algorithmen zu betrachten. Neben den klassischen Verfahren, die es seit den 1960ern gibt, stellen wir neue Verfahren vor, die erst in den letzten Jahren entdeckt und entwickelt wurden. Unser primäres Ziel ist es, die derzeit wichtigsten Algorithmen einem möglichst breiten Leserkreis nahezubringen. Diese Algorithmen sind im Allgemeinen geniale Entwicklungen, die sich mit erstaunlich wenig Code ausdrücken lassen – manche bestehen nur aus zwei Codezeilen. Zusammengenommen sind sie unglaublich leistungsfähig. Ihnen verdanken wir die Möglichkeit zur Programmerstellung, die Lösung komplexer wissenschaftlicher Probleme und die Entwicklung kommerzieller Anwendungen – alles Aufgaben, die ohne diese Algorithmen nicht lösbar gewesen wären.
12
Vorwort
Die Website zum Buch Dieses Buch zeichnet sich dadurch aus, dass es durch eine Website ergänzt wird, die unter algs4.cs.princeton.edu frei verfügbar ist und eine Fülle an Materialien zu Algorithmen und Datenstrukturen für Dozenten, Studenten und professionelle Programmierer enthält. Sie finden dort unter anderem:
Eine Online-Zusammenfassung Der Text auf der Website zum Buch orientiert sich vom Aufbau her an dem Buch und wird ergänzt durch Links, um Ihnen die Navigation durch das Material zu erleichtern.
Vollständige Implementierungen Alle Codebeispiele im Buch finden Sie in aufbereiteter Form auch auf unserer Website. Darüber hinaus gibt es viele weitere Beispiele, einschließlich anspruchsvolleren Implementierungen, im Buch besprochenen Verbesserungen, Antworten auf ausgewählte Übungen und Client-Code für verschiedene Anwendungen. Unser Schwerpunkt liegt auf dem Testen der Algorithmen im Rahmen realistischer Anwendungen.
Übungen und Antworten Sie finden auf der Website zum Buch nicht nur die Übungen zum Buch, sondern auch ein breites Spektrum an Beispielen, die die ganze Bandbreite des behandelten Stoffes veranschaulichen, Programmierübungen mit Codelösungen sowie programmiertechnische Herausforderungen.
Dynamische Visualisierungen Dynamische Simulationen sind in der Druckausgabe eines Buches nicht möglich. Aus diesem Grund finden Sie auf der Website zum Buch eine Fülle an Implementierungen, die eine Grafik-Klasse verwenden, um ansprechende Visualisierungen zu präsentieren.
Kursunterlagen Das Buch und die dazugehörige Website werden durch einen vollständigen Satz an Vorlesungsfolien ergänzt. Darüber hinaus gibt es eine Fülle an Programmieraufgaben mit Checklisten, Testdaten und vorbereitenden Materialien.
Verweise auf verwandtes Material Hunderte von Links bieten den Lesern und Studenten Zugriff auf Hintergrundinformationen zu den Anwendungen sowie auf weiterführende Literatur zu den einzelnen Kapiteln und Themenschwerpunkten. Unser Ziel bei der Zusammenstellung dieser Materialien war es, den im Buch behandelten Stoff sinnvoll zu ergänzen. Generell sollten Sie zuerst das Buch lesen, wenn Sie bestimmte Algorithmen kennenlernen wollen oder sich einen allgemeinen Überblick verschaffen möchten, und die Website zum Buch nur als Referenz für die Programmierung heranziehen beziehungsweise als Ausgangspunkt verwenden, wenn Sie online nach weiterführenden Informationen suchen.
13
Vorwort
Das Buch als Unterrichtsmittel Dieses Buch ist als Lehrbuch für Informatikstudenten im zweiten Studienjahr konzipiert. Es deckt die wichtigsten Themen vollständig ab und eignet sich hervorragend, um Studenten einfache und fortgeschrittene Kenntnisse zur Programmierung, quantitativem Denken und zur Problemlösung zu vermitteln. In der Regel reicht es, wenn der Student das erste Jahr erfolgreich absolviert hat – der Leser sollte mindestens eine moderne Programmiersprache beherrschen und mit den Grundlagen moderner Computersysteme vertraut sein. Die Algorithmen und Datenstrukturen sind in Java geschrieben, aber durchaus auch für diejenigen verständlich, die in einer anderen modernen Programmiersprache zu Hause sind. Wir verwenden moderne Java-Abstraktionen (einschließlich Generics), verzichten aber bewusst auf exotische Features dieser Sprache. Die Mathematik, die Sie zur Analyse der Algorithmen und Beispiele benötigen, wird zum größten Teil im Buch erläutert (oder Sie werden darauf hingewiesen, dass genauere Ausführungen den Rahmen des Buches sprengen), sodass Sie für einen Großteil des Buches mit normalen Mathematikkenntnisse auskommen, auch wenn fortgeschrittene Kenntnisse definitiv hilfreich sind. Die Anwendungen beschäftigen sich mit grundlegenden Problemen der Wissenschaften, die ebenfalls selbsterklärend sind. Die hier behandelten Themen gehören zum Rüstzeug aller Studenten, die ihren Abschluss in Informatik machen möchten, und sind von großem Nutzen für Studenten, die sich für Naturwissenschaften, Mathematik oder Technik interessieren.
Kontext Dieses Buch ist die Fortsetzung unseres Werkes „Einführung in die Programmierung mit Java“, das die Grundlagen für die Programmierung in Java legt. Zusammen bilden diese beiden Bücher einen zwei- bis dreisemestrigen Einführungskurs in die Informatik, der jedem Studenten das notwendige Hintergrundwissen vermittelt, um in allen naturwissenschaftlichen, technischen oder sozialwissenschaftlichen Bereichen erfolgreich zu programmieren. Dieses Buch basiert größtenteils auf den Vorläuferbüchern von Sedgewick zu dieser Auflage. Vom Aufbau und Inhalt ist es der ersten und zweiten Auflage dieses Buches sehr ähnlich, profitiert jedoch von den jahrzehntelangen Unterrichtserfahrungen der Autoren. Die dritte Auflage, das aktuelle Vorläuferbuch „Algorithmen in C/C++/Java, Dritte Auflage“ eignet sich dagegen eher als Referenz oder Fachbuch für einen Fortgeschrittenenkurs. Das hier vorgestellte Werk ist speziell als Fachbuch für einen einsemestrigen Kurs für Studenten im ersten oder zweiten Studienjahr gedacht sowie als moderne Einführung in die Grundlagen und als Referenz für professionelle Programmierer.
14
Vorwort
Danksagung Dieses Buch gibt es seit fast 40 Jahren, sodass wir nicht alle, die daran mitgewirkt haben, namentlich erwähnen können. Stellvertretend für die Dutzenden von Mitwirkenden an früheren Auflagen seien hier (in alphabetischer Reihenfolge) unter anderem Andrew Appel, Trina Avery, Marc Brown, Lyn Dupré, Philippe Flajolet, Tom Freeman, Dave Hanson, Janet Incerpi, Mike Schidlowsky, Steve Summit und Chris Van Wyk genannt. Danke ihnen allen, auch wenn einige ihrer Beiträge Jahrzehnte zurückliegen. Bedanken möchten wir uns in dieser 4. Auflage aber vor allem bei den Hunderten von Studenten der Princeton University und mehrerer anderer Institute, die sich durch die Vorabversionen dieses Werkes gequält haben, sowie bei den Lesern auf der ganzen Welt, die uns über die Website zum Buch ihre Anregungen und Korrekturen zugesandt haben. Dankbar sind wir auch für die Unterstützung durch die Princeton University, die mit ihren hohen Anforderungen an Bildung und Lehre die Grundlage für dieses Werk geschaffen hat. Und, last not least, was wäre diese 4. Auflage ohne die exzellente Betreuung bei Pearson. Peter Gordon stand uns fast von Anfang an bei der Entstehung dieses Werkes mit seinen klugen Ratschlägen zur Seite. Ihm verdanken wir, dass in dieser Auflage der Schwerpunkt wieder mehr auf den Grundlagen liegt. Ein Dankeschön auch an Barbara Wood für ihr sorgfältiges und professionelles Lektorat, an Julie Nahil für die Produktion und an alle anderen, die im Verlag an der Erstellung und Vermarktung dieses Buches beteiligt waren. Sie alle haben sehr darauf geachtet, dass die Einhaltung des ziemlich engen Zeitrahmens nicht zulasten der Qualität ging.
Robert Sedgewick Kevin Wayne
Princeton, NJ Januar 2011
15
Vorwort zur deutschen Ausgabe
Vorwort zur deutschen Ausgabe Den Autoren, Robert Sedgewick und Kevin Wayne, ist mit der 4. Auflage des Klassikers „Algorithmen“ wieder ein umfassender Überblick über die wichtigsten Datenstrukturen und Algorithmen gelungen. Die neueste Auflage enthält zusätzlich zur Aktualisierung der bisherigen Inhalte auch neue mächtige Algorithmen aus unterschiedlichen Anwendungsbereichen. Außerdem finden sich alle Datenstrukturen und Algorithmen wieder in einem Band. Die Übersetzung der 4. Auflage dieses Klassikers bietet nun auch dem deutschsprachigen Leser wieder einen Zugang zu den wichtigsten Datenstrukturen und Algorithmen. Das erste Kapitel ist eine Einführung in das Fachgebiet Datenstrukturen und Algorithmen. Hier lernen Sie die Grundlagen zum Verständnis der folgenden Kapitel kennen. Außerdem werden hilfreiche Bibliotheken vorgestellt, die Sie später für die Lösung der im Buch gestellten Aufgaben verwenden können. Über die grundlegende Terminologien und Datenstrukturen hinaus findet auch eine erste Fallstudie anhand des Union-Find-Algorithmus statt. Lesern, die eine ausführlichere Einführung benötigen oder die Programmiersprache Java vorab kennenlernen wollen, empfehle ich das Werk „Einführung in die Programmierung mit Java“. Dieses Buch wurde ebenfalls von Robert Sedgewick und Kevin Wayne verfasst. Dort werden schon einige Konzepte und Algorithmen vorgestellt und es ist ein nahtloser Übergang in das vorliegende Buch möglich. Kapitel zwei bis fünf enthalten unentbehrliches Basiswissen und decken ein breites Spektrum an Datenstrukturen und Algorithmen zu den Themen Sortieren, Suchen, Graphen- oder Zeichenkettenverarbeitung ab. Das letzte Kapitel stellt die Beziehung zu vielen anderen Algorithmen her, die sich an die hier vorgestellten anschließen oder diese auch ergänzen. Ein weiterer Schwerpunkt ist die Diskussion über Komplexität von Algorithmen. Hier wird z.B. gezeigt, dass schon eine kleine Änderung an der Problemstellung die Berechnung der Lösung dieses Problems in vertretbarer Zeit unmöglich macht. Für alle vorgestellten Datenstrukturen und Algorithmen liegen im Buch Java-Implementierungen vor, die der Leser nachvollziehen und sofort verwenden kann. Besonders interessant ist der konkrete Einsatz der Algorithmen an realen Anwendungsbeispielen aus Forschung, Technik und Wirtschaft. Dabei kommt auch hier nicht Pseudocode, sondern eine funktionstüchtige Implementierung in Java zum Einsatz. Die didaktisch hervorragend aufbereiteten Kapitel mit historischem Hintergrundwissen, wissenschaftlichen Analysen, klar strukturiertem Code, visualisierten Ablaufprotokollen und Übungen in verschiedenen Schwierigkeitsstufen eignen sich sehr gut zum Einsatz in der Lehre und für ein Selbststudium.
16
Vorwort zur deutschen Ausgabe
Webinhalte zum vorliegenden Buch Für alle vorgestellten Datenstrukturen und Algorithmen liegen im Buch, wie schon erwähnt, die Java-Implementierungen vor, die der Leser nachimplementieren kann. Diese Implementierungen stehen aber auch online unter http://www.cs.princeton.edu/ algs4/top50 zur Verfügung. Darüber hinaus stehen dem Leser unter dieser Adresse weitere englischsprachige Webinhalte zur Verfügung, die jedoch nicht übersetzt wurden. Die Struktur der Webseite entspricht der Kapitelstruktur des Buches. Auch der deutschsprachige Leser wird sich schnell auf dieser Webseite zurecht finden und die gewünschten Java-Implementierungen finden, da die Programmtexte des vorliegenden Buches unverändert übernommen wurden und lediglich die Kommentare übersetzt wurden. Darüber hinaus findet der Leser auf den englischsprachigen Webseiten die Testdaten, die im Buch verwendet werden. Zum Selbststudium in Englisch bietet sich auch ein Link auf die Lehrplattform www.coursera.org an. Auch für Lehrende bieten die englischsprachigen Webinhalte zum Buch viele Anregungen, wie Sie Ihre Vorlesungen strukturieren oder Ihre Folien gestalten können, außerdem finden Sie hier viele Animationen der besprochenen Algorithmen. Die Folien auf den Webseiten sind ebenfalls auf Englisch. Das Onlinematerial ist sehr umfangreich, ich kann das Material aus eigener Erfahrung empfehlen. Ich wünsche Ihnen viel Spaß beim Lesen, Lernen und Lehren. Andrea Baumann
17
Grundlagen 25
1.2 Datenabstraktion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
81
1.3 Multimengen, Warteschlangen und Stapel . . . . . . . 139 1.4 Analyse der Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . 191 1.5 Fallstudie Union-Find. . . . . . . . . . . . . . . . . . . . . . . . . . . . 238
1 ÜBERBLICK
1.1 Das grundlegende Programmiermodell . . . . . . . . . . .
1
Grundlagen
»
EINLEITUNG
Ziel dieses Buches ist das Studium und die Analyse eines breiten Spektrums an wichtigen und nützlichen Algorithmen – Verfahren zum Lösen von Problemen, die sich auf einem Computer implementieren lassen. Eng verbunden mit den Algorithmen sind die sogenannten Datenstrukturen, deren Aufgabe es ist, Daten so zu organisieren, dass sie sich effizient von den Algorithmen verarbeiten lassen. Die grundlegenden Werkzeuge, die für die Untersuchung der Algorithmen und Datenstrukturen benötigt werden, sind Thema dieses Kapitels. Als Erstes stellen wir Ihnen unser grundlegendes Programmiermodell vor. Alle unsere Programme werden mithilfe einer kleinen Teilmenge der Java-Programmiersprache sowie ein paar eigenen Bibliotheken (Ein-/Ausgabe und statistische Berechnungen) erstellt. Abschnitt 1.1 gibt einen Überblick über die Sprachkonstrukte, Features und Bibliotheken, die wir in diesem Buch verwenden.
Danach wenden wir uns dem Thema Datenabstraktion zu, bei dem es um die Definition abstrakter Datentypen (ADTs) zur Unterstützung der modularen Programmierung geht. In Abschnitt 1.2 zeigen wir Ihnen, wie man abstrakte Datentypen in Java in zwei Schritten implementieren kann: durch Angabe einer API, d.h. einer Schnittstelle für die Anwendungsprogrammierung (Applications Programming Interface), und die Implementierung mithilfe des Java-Klassenmechanismus zur Verwendung im Client-Code. In Abschnitt 1.3 lernen Sie dann drei überaus wichtige und nützliche Beispiele für abstrakte Datentypen kennen: Multimenge (bag), Warteschlange (queue) und Stapel (stack). Wir betrachten deren APIs und Implementierungen unter Verwendung von Arrays, Arrays variabler Größe und verketteten Listen, die im weiteren Verlauf des Buches als Modell und Ausgangsbasis für weitere Algorithmusimplementierungen dienen. Das Laufzeitverhalten ist ein wichtiger Gesichtspunkt bei der Untersuchung und Entwickelung von Algorithmen. Abschnitt 1.4 beschreibt unseren Ansatz, die Performance eines Algorithmus zu analysieren. Dabei gehen wir von der wissenschaftlichen Methode aus: Wir entwickeln Hypothesen zum Laufzeitverhalten, erstellen mathematische Modelle und testen diese anhand von Experimenten – ein Prozess, den wir bei Bedarf wiederholen. Den Abschluss dieses Kapitels bildet eine Fallstudie mit Lösungen zu einem Zusammenhangsproblem. In der Fallstudie kommen Algorithmen und Datenstrukturen zum Einsatz, die den klassischen abstrakten Union-Find-Datentyp implementieren.
Algorithmen Ein Computerprogramm zu schreiben bedeutet im Allgemeinen nichts anderes, als ein Verfahren zu implementieren, das zuvor dafür entwickelt wurde, ein bestimmtes Problem zu lösen. Dieses Verfahren ist meistens unabhängig von der eingesetzten Programmiersprache – mit großer Wahrscheinlichkeit dürfte es für viele Computer und viele Programmiersprachen geeignet sein. Und es ist das Verfahren und nicht das Computerprogramm, welches die Schritte vorgibt, mit denen wir das Problem lösen können. Lösungsverfahren, die endlich, deterministisch und effektiv sind und als Computerprogramm implementiert
20
Einleitung
werden können, werden in der Informatik als Algorithmen bezeichnet. Algorithmen sind ein wichtiges Teilgebiet der Informatik und Gegenstand zahlreicher Untersuchungen. Man kann einen Algorithmus definieren, indem man eine Anleitung zur Lösung eines Problems in natürlicher Sprache aufsetzt oder indem man ein Computerprogramm schreibt, das diese Anleitung implementiert – wie in Abbildung 1.1 am Beispiel des euklidischen Algorithmus demonstriert. (Der euklidische Algorithmus berechnet den größten gemeinsamen Teiler von zwei Zahlen und wurde in etwas anderer Form bereits vor 2300 Jahren formuliert. Wenn Sie mit dem euklidischen Algorithmus nicht vertraut sind, sollten Sie vielleicht schon nach dem ersten Abschnitt dieses Kapitels die Übungen 1.1.24 und 1.1.25 durcharbeiten.) In diesem Buch beschreiben wir Algorithmen mittels Computerprogrammen. Ein wichtiger Grund dafür ist, dass wir dann leichter prüfen können, ob die Algorithmen wie gefordert endlich, deterministisch und effektiv sind. Dennoch sollte Ihnen klar sein, dass ein Programm – in welcher Sprache auch immer – nur eine Möglichkeit ist, einen Algorithmus auszudrücken. Die Tatsache, dass viele der hier beschriebenen Algorithmen im Laufe der letzten Jahrzehnte in vielen Programmiersprachen ausgedrückt worden sind, stützt unsere Behauptung, dass ein Algorithmus ein Verfahren ist, das sich zur Implementierung auf jedem Computer und in jeder Programmiersprache eignet. Beschreibung in Deutsch Berechne den größten gemeinsamen Teiler von zwei nicht-negativen ganzen Zahlen p und q wie folgt: Wenn q gleich 0 ist, lautet die Antwort p. Wenn nicht, teile p durch q und nimm den Rest r. Die Antwort ist der größte gemeinsame Teiler von q und r. Beschreibung in Java public static int gcd(int p, int q) { if (q == 0) return p; int r = p % q; return gcd(q, r); } Abbildung 1.1: Euklidischer Algorithmus
Die meisten interessanten Algorithmen setzen voraus, dass die Daten vor der Berechnung in irgendeiner Form organisiert werden. Eine solche Organisation führt zu Datenstrukturen, die ebenfalls wissenschaftlicher Untersuchungsgegenstand der Informatik sind. Algorithmen und Datenstrukturen gehören also mehr oder weniger untrennbar zusammen. In diesem Buch gehen wir davon aus, dass Datenstrukturen die Neben- oder Endprodukte von Algorithmen sind. Wir müssen sie sorgfältig studieren, um die Algorithmen zu verstehen. Einfache Algorithmen können in komplizierte Datenstrukturen resultieren und umgekehrt können komplizierte Algorithmen einfache Datenstrukturen einsetzen. Wir werden uns in diesem Buch eingehend mit den Eigenschaften vieler Datenstrukturen beschäftigen, sodass der Titel dieses Buches genauso gut auch Algorithmen und Datenstrukturen hätte lauten können.
21
1
Grundlagen
Wenn wir einen Computer zur Lösung eines Problems einsetzen, gibt es normalerweise mehrere Ansätze. Bei kleinen Problemen spielt es kaum eine Rolle, wie wir vorgehen, solange das Problem korrekt gelöst wird. Bei sehr großen Problemen (oder Anwendungen, in denen wir eine riesige Anzahl kleiner Probleme lösen müssen) steigt dagegen der Druck, eigene Ansätze zu entwickeln, die Laufzeit und Speicher effizienter nutzen. Der Hauptgrund, sich mit Algorithmen zu beschäftigen, ist, dass sich damit riesige Einsparungen erzielen lassen, ja, dass wir sogar Aufgaben in Angriff nehmen können, die ohne einen effizienten Algorithmus überhaupt nicht durchführbar wären. In Anwendungen, die Millionen von Objekten verarbeiten, ist es durchaus möglich, Programme mithilfe eines gut entworfenen Algorithmus um den Faktor eine Million und mehr zu beschleunigen. Sie werden in diesem Buch viele Beispiele hierzu sehen. Im Gegensatz dazu erreichen Sie durch Kauf und Installation eines neuen Rechners lediglich eine potenzielle Verbesserung der Programmleistung um den Faktor 10 bis 100. Ein sorgfältiger Algorithmusentwurf spielt eine extrem wichtige Rolle beim Lösen eines riesigen Problems – gleichgültig in welchem Anwendungsbereich. Bei der Entwicklung eines sehr umfangreichen oder komplexen Computerprogramms muss viel Mühe darauf verwendet werden, das zu lösende Problem zu verstehen und zu definieren, seine Komplexität in den Griff zu bekommen und das Problem in kleinere Teilprobleme zu zerlegen, die sich leichter implementieren lassen. Oft lassen sich viele der Algorithmen, die nach der Zerlegung benötigt werden, problemlos implementieren. Daneben gibt es in den meisten Fällen aber auch Algorithmen, deren Wahl kritisch ist, da die Ausführung dieser Algorithmen einen Großteil der Systemressourcen bindet. Und genau auf diese Algorithmen wollen wir uns in diesem Buch konzentrieren. Wir werden uns mit grundlegenden Algorithmen befassen, die herangezogen werden können, um anspruchsvolle Probleme in vielen verschiedenen Anwendungsbereichen zu lösen. Die gemeinsame Nutzung von Programmen und Code gewinnt immer größere Bedeutung, weshalb davon auszugehen ist, dass Sie von den Algorithmen in diesem Buch zwar viele verwenden, aber wohl nur einen kleinen Teil davon jemals selbst implementieren werden. So enthalten zum Beispiel die Java-Bibliotheken bereits Implementierungen einer Vielzahl von grundlegenden Algorithmen. Trotzdem ist es von Vorteil, einfache Versionen von grundlegenden Algorithmen selbst zu implementieren, da dies hilft, die Algorithmen besser zu verstehen und später anspruchsvollere Versionen aus den Bibliotheken effektiver nutzen und an die eigenen Bedürfnisse anpassen zu können. Noch wichtiger ist allerdings, dass die grundlegenden Algorithmen häufig neu implementiert werden müssen. Der Hauptgrund dafür ist, dass in viel zu kurzen Abständen vollkommen neue Computerumgebungen (Hardware und Software) mit neuen Features auf den Markt kommen, die von den alten Implementierungen nicht optimal genutzt werden. In diesem Buch konzentrieren wir uns auf die am leichtesten nachzuvollziehenden Implementierungen der besten Algorithmen. Wir schenken dabei der Codierung der kritischen Teile der Algorithmen besondere Beachtung und bemühen uns, Sie darauf hinzuweisen, wo Low-Level-Optimierungen am sinnvollsten ist. Die Wahl des besten Algorithmus für eine bestimmte Aufgabe kann ein komplizierter Prozess sein, der unter Umständen einer anspruchsvollen mathematischen Analyse
22
Einleitung
bedarf. Der Zweig der Informatik, der sich mit diesen Fragen befasst, wird auch Algorithmenanalyse genannt. Für viele der Algorithmen, die wir hier vorstellen, hat die mathematische Analyse ausgezeichnete theoretische Laufzeitwerte ermittelt; von anderen Algorithmen wissen wir lediglich durch Erfahrung, dass sie gut funktionieren. Neben unserem primären Ziel, das darin besteht, Ihnen vernünftige Algorithmen für wichtige Aufgaben vorzustellen, werden wir daher immer auch großen Wert auf die vergleichende Analyse des Laufzeitverhaltens der vorgestellten Verfahren legen. Schließlich sollten Sie keinen Algorithmus verwenden, ohne eine Vorstellung davon zu haben, wie viele Ressourcen er bindet und wie effizient er arbeitet.
Zusammenfassung der Themen Um Ihnen einen Überblick zu geben, finden Sie nachfolgend eine kurze Übersicht über die Hauptkapitel des Buches, die die darin behandelten Themen angibt und unsere Sicht auf das Material beschreibt. Bei der Auswahl der Themen haben wir sehr darauf geachtet, möglichst viele grundlegende Algorithmen abzudecken. So gehören etliche der behandelten Bereiche zu den klassischen Kernbereichen der Informatik, die wir hier ausführlich untersuchen werden, da sie elementare Algorithmen mit einer großen Anwendungsbreite enthalten. Andere der hier diskutierten Algorithmen entstammen fortgeschrittenen Feldern der Informatik oder verwandter Disziplinen. Die untersuchten Algorithmen sind das Ergebnis jahrzehntelanger Forschung und Entwicklung und werden auch in Zukunft eine wichtige Rolle in den immer anspruchsvoller werdenden Computeranwendungen spielen.
Grundlagen (Kapitel 1) In diesem Buch verstehen wir darunter die grundlegenden Prinzipien und Methoden, mit denen wir Algorithmen implementieren, analysieren und vergleichen. Wir betrachten neben unserem Java-Programmiermodell noch Datenabstraktion, grundlegende Datenstrukturen, abstrakte Datentypen für Sammlungen, Methoden zur Analyse des Laufzeitverhaltens von Algorithmen und eine Fallstudie.
Sortieren (Kapitel 2) Sortieralgorithmen, die die Reihenfolge der Elemente in Arrays ändern, sind von großer Bedeutung. Wir beschäftigen uns ausführlich mit einer Auswahl einschlägiger Algorithmen, einschließlich Insertionsort, Selectionsort, Shellsort, Quicksort, Mergesort und Heapsort. Wir werden aber auch auf Algorithmen für verwandte Probleme zu sprechen kommen, wie Vorrangwarteschlangen (priority queues), Auswahl (selection) und Mischen (merging). Viele dieser Algorithmen werden Sie später im Buch als Grundlage für andere Algorithmen wiederfinden.
Suchen (Kapitel 3) Ebenfalls von großer Bedeutung sind Suchalgorithmen, um bestimmte Elemente in einer großen Sammlung von Elementen zu finden. Wir stellen grundlegende und fortgeschrittene Methoden zum Suchen vor, einschließlich binäre Suchbäume,
23
1
Grundlagen
balancierte Suchbäume und Hashing. Wir werden feststellen, dass es Beziehungen zwischen ihnen gibt, und wir werden ihre Performance vergleichen.
Graphen (Kapitel 4) Hinter Graphen verbergen sich Mengen von Objekten und Verbindungen, eventuell gewichtet und gerichtet. Graphen sind nützliche Modelle für eine Vielzahl von schwierigen und wichtigen Problemen, und der Entwurf von Algorithmen zur Verarbeitung von Graphen ist ein bedeutendes Forschungsfeld. Wir beschäftigen uns mit Tiefensuche, Breitensuche, Zusammenhangsproblemen und verschiedenen Algorithmen und Anwendungen, einschließlich der Algorithmen von Kruskal und Prim zur Ermittlung des minimalen Spannbaums und der Algorithmen von Dijkstra und Bellman-Ford zur Lösung der Probleme der kürzesten Pfade.
Strings (Kapitel 5) Strings sind ein unentbehrlicher Datentyp in modernen Computeranwendungen. Wir betrachten eine Reihe von Verfahren zur Verarbeitung von Zeichenketten. Wir beginnen mit schnelleren Algorithmen zum Sortieren und Suchen, wenn die Schlüssel Strings sind. Anschließend wenden wir uns der Teilstringsuche, der musterbasierten Suche (pattern matching) mit regulären Ausdrücken und den Algorithmen zur Datenkomprimierung zu. Auch hier erhalten Sie eine Einführung in fortgeschrittene Themen, indem wir einige elementare Probleme behandeln, die für sich betrachtet schon wichtig und interessant sind.
Kontext (Kapitel 6) Dieses Kapitel rundet das Thema ab, indem es Ihnen hilft, den Stoff dieses Buches auf andere Studiengebiete, wie wissenschaftliches Rechnen, Operations Research und die theoretische Informatik, zu übertragen. Wir geben Ihnen eine kurze Einführung in ereignisbasierte Simulation, B-Bäume, Suffixarrays, maximalen Fluss und andere fortgeschrittene Themen, die Ihnen einen Eindruck davon vermitteln sollen, in wie vielen interessanten, aktuellen Forschungsbereichen Algorithmen eine wichtige Rolle spielen. Zum Schluss gehen wir noch auf Suchprobleme, Reduktion und NP-Vollständigkeit ein, die das theoretische Fundament des Studiums der Algorithmen sind und in Beziehung zu dem Stoff dieses Buches stehen. Das Studium der Algorithmen ist interessant und aufregend, weil es ein junges Forschungsgebiet (fast alle hier vorgestellten Algorithmen sind jünger als 50 Jahre, einige wurden gerade erst entdeckt) mit einer großen Vergangenheit ist (einige Algorithmen sind seit Hunderten von Jahren bekannt). Ständig werden neue Entdeckungen gemacht, auch wenn letztlich nur wenige Algorithmen vollständig verstanden werden. In diesem Buch betrachten wir neben komplizierten und schwierigen Algorithmen auch elegante, einfache. Die Herausforderung für uns liegt darin, Erstere zu verstehen und Letztere im Rahmen wissenschaftlicher und wirtschaftlicher Anwendungen schätzen zu lernen. Dabei werden wir eine Vielfalt an nützlichen Werkzeugen kennenlernen und eine Form des algorithmischen Denkens entwickeln, die uns bei zukünftigen Herausforderungen als Programmierer gute Dienste leisten wird.
»
24
1.1 Das grundlegende Programmiermodell
1.1
Das grundlegende Programmiermodell
Die hier betrachteten Algorithmen werden ausnahmslos als Programme in der Programmiersprache Java implementiert. Für diese Vorgehensweise gibt es mehrere Gründe:
Unsere Programme sind präzise, elegante und vollständige Beschreibungen der Algorithmen.
Sie können die Programme ausführen, um die Eigenschaften der Algorithmen zu studieren.
Sie können die Algorithmen direkt in Ihre Anwendungen einbauen. Dies sind wichtige und evidente Vorteile verglichen mit der Alternative, mit natürlichsprachlichen Beschreibungen der Algorithmen arbeiten zu müssen. Ein möglicher Nachteil dieses Ansatzes ist, dass wir mit einer konkreten Programmiersprache arbeiten müssen, was es schwierig machen kann, den Entwurf eines Algorithmus von den Details seiner Implementierung zu trennen. Unsere Implementierungen zielen jedoch darauf ab, diesem Problem entgegenzuwirken, indem Programmkonstrukte zum Einsatz kommen, die in vielen modernen Programmiersprachen zu finden sind und die benötigt werden, um die Algorithmen angemessen zu beschreiben. Wir verwenden nur einen kleinen Teil von Java, wollen hier jedoch davon absehen, diesen Teil formal zu definieren. Sicherlich werden Sie bemerken, dass nur relativ wenige Java-Konstrukte zum Einsatz kommen und wir diejenigen bevorzugen, die in vielen modernen Programmiersprachen zu finden sind. Der hier präsentierte Code ist vollständig und wir hoffen, dass Sie ihn herunterladen und auf unseren (oder eigenen) Testdaten ausführen. Wir bezeichnen das Zusammenspiel aus Programmkonstrukten, Software-Bibliotheken und Betriebssystemfeatures, die wir zur Implementierung und Beschreibung der Algorithmen verwenden, als unser Programmiermodell, das wir in diesem und dem folgenden Abschnitt ausführlich beschreiben. Die Abhandlung dieses Themas ist in sich abgeschlossen und dient primär der Dokumentation und Referenzzwecken, damit Sie die einzelnen Codebeispiele in diesem Buch nachvollziehen können. Das Modell, das wir hier beschreiben, ist das gleiche, das wir in unserem Buch Einführung in die Programmierung mit Java vorgestellt haben – dort allerdings wesentlich ausführlicher. Abbildung
1.2 zeigt ein vollständiges Java-Programm, das viele der grundlegenden Bestandteile unseres Programmmodells veranschaulicht. Wir verwenden diesen Code für Beispiele, wenn wir Sprachelemente besprechen, werden ihn jedoch erst in Abschnitt 1.1.10 im Detail betrachten (das Java-Programm in Abbildung 1.2 implementiert einen klassischen Algorithmus, die sogenannte Binäre Suche, und testet ihn anhand einer Anwendung, dem Filtern mit Positivliste, auch Weiße Liste (whitelist) genannt). Wir gehen davon aus, dass Sie Programmiererfahrungen in mindestens einer modernen Programmiersprache mitbringen, sodass Ihnen viele der Elemente in diesem Code bekannt sein sollten. Die Beschriftungen zum Code in Abbildung 1.2 enthalten Seitenverweise, damit Sie leichter Antworten zu eventuellen Fragen finden. Da unser Code etwas stili-
25
1
Grundlagen
siert ist, wir uns aber bemühen, die verschiedenen Java-Idiome und -Konstrukte konsistent zu verwenden, lohnt es sich auch für erfahrene Java-Programmierer die Hinweise in diesem Abschnitt zu lesen. importiert eine Java-Bibliothek (s. S. 45)
Code muss in der Datei BinarySearch.java gespeichert sein (s. S. 44) Parametervariablen
Deklaration einschließlich Initialisierung (s. S. 34)
Rückgabetyp
statische Methode (s. S. 39)
Parametertyp
Ausdruck (s. S. 28)
Schleifenanweisung (s. S. 33)
Rückgabeanweisung System ruft main() auf
Modultest-Client (s. S. 45)
kein Rückgabewert; nur Nebeneffekte (s. S. 42)
ruft eine Methode in einer Java-Bibliothek auf (s. S. 45) ruft eine Methode in unserer Standardbibliothek auf; Code muss heruntergeladen werden (s. S. 45) ruft eine lokale Methode auf (s. S. 45)
bedingte Anweisung (s. S. 33)
System übergibt "whitelist.txt" an main()
Befehlszeile (s. S. 55)
Dateiname (args[0])
% java BinarySearch largeW.txt < largeT.txt StdOut
(s. S. 55)
499569 984875 ...
StdIn
aus Datei lesen (s. S. 59)
Abbildung 1.2: Aufbau eines Java-Programms und dessen Aufruf von der Befehlszeile
26
1.1 Das grundlegende Programmiermodell
1.1.1 Grundlegende Struktur eines Java-Programms Ein Java-Programm (Klasse) ist entweder eine Bibliothek von statischen Methoden (Funktionen) oder eine Datentypdefinition. Um Bibliotheken von statischen Methoden und Datentypdefinitionen zu erzeugen, verwenden wir die folgenden Komponenten – die die Basis der Programmierung in Java und vielen anderen modernen Programmiersprachen bilden:
Primitive Datentypen legen in einem Computerprogramm die genaue Bedeutung von Begriffen wie Integer (ganze Zahlen), reelle Zahlen und boolesche Werte fest. Ihre Definition umfasst den jeweiligen Bereich möglicher Werte und die Operationen, die auf den Werten ausgeführt werden können. Werte und Operationen können zu Ausdrücken zusammengefasst werden, wie mathematische Ausdrücke, die Werte definieren.
Anweisungen erlauben uns, eine Berechnung zu definieren, indem wir Variablen erzeugen und ihnen Werte zuweisen, den Programmablauf steuern oder Nebeneffekte verursachen. Wir verwenden sechs Arten von Anweisungen: Deklarationen, Zuweisungen, bedingte Anweisungen, Schleifen, Aufrufe und Rückgabeanweisungen.
Arrays erlauben uns, mit mehreren Werten des gleichen Typs zu arbeiten. Statische Methoden erlauben uns, Code zu kapseln und wiederzuverwenden und Programme als einen Satz von unabhängigen Modulen zu entwickeln.
Strings sind Folgen von Zeichen. Einige Operationen für Strings sind in Java bereits integriert.
Ein-/Ausgabe sorgt für die Kommunikation zwischen Programmen und der Außenwelt.
Datenabstraktion erweitert das Prinzip der Kapselung und Wiederverwendung, sodass wir die Möglichkeit haben, nicht primitive Datentypen zu definieren, und unterstützt damit die objektorientierte Programmierung. In diesem Abschnitt werden wir die ersten sechs Punkte der Reihe nach betrachten. Im nächsten Abschnitt werden wir uns dann dem Thema Datenabstraktion zuwenden. Das Ausführen eines Java-Programms setzt die Interaktion mit einem Betriebssystem oder einer Programmentwicklungsumgebung voraus. Um uns hier nicht unnötig in systemspezifische Details zu verstricken, beschreiben wir solche Aktionen mithilfe eines virtuellen Terminals, welches uns erlaubt, mit Programmen zu interagieren, indem wir Befehle eintippen und an das System schicken. Auf der Website zum Buch finden Sie nähere Angaben, wie Sie ein virtuelles Terminal auf Ihrem System einrichten oder eine der vielen umfangreichen Programmentwicklungsumgebungen verwenden, die auf modernen Systemen zur Verfügung stehen. Unser Beispielprogramm BinarySearch besteht aus zwei statischen Methoden: rank() und main(). Die erste statische Methode, rank(), weist vier Anweisungen auf: zwei Deklarationen, eine Schleife (die selbst aus einer Zuweisung und zwei bedingten
27
1
Grundlagen
Anweisungen besteht) und eine Rückgabeanweisung. Die zweite, main(), weist drei Anweisungen auf: eine Deklaration, einen Aufruf und eine Schleife (die selbst aus einer Zuweisung und einer bedingten Anweisung besteht). Um ein Java-Programm aufzurufen, kompilieren wir es zuerst mit dem Befehl javac und führen es dann mit dem Befehl java aus. Für die Ausführung von BinarySearch geben wir beispielsweise zuerst den Befehl javac BinarySearch.java ein (der eine Datei namens BinarySearch.class erzeugt, die eine maschinennähere Version des Programms in Bytecode enthält). Dann geben wir java BinarySearch ein (gefolgt von einem Dateinamen für eine Positivliste), um die Kontrolle an die Bytecode-Version des Programms zu übergeben. Um die Auswirkungen dieser Aktionen besser zu verstehen, wollen wir uns als Nächstes ausführlich mit primitiven Datentypen und Ausdrücken befassen, gefolgt von den verschiedenen Arten von Java-Anweisungen, Arrays, statischen Methoden, Strings sowie der Ein- und Ausgabe.
1.1.2 Primitive Datentypen und Ausdrücke Ein Datentyp besteht aus einem Satz von Werten und einem Satz von Operationen auf diesen Werten. Beginnen wollen wir mit den folgenden vier primitiven Datentypen, die die Grundlage der Programmiersprache Java bilden:
Integer (ganze Zahlen), mit arithmetischen Operationen (int) Reelle Zahlen, ebenfalls mit arithmetischen Operationen (double) Boolesche Werte, die beiden Werte true und false mit ihren logischen Operationen (boolean)
Zeichen, die alphanumerischen Zeichen und Symbole, die Sie eingeben (char) Als Nächstes betrachten wir Mechanismen, mit denen wir Werte und Operationen für diese Typen angeben. Ein Java-Programm manipuliert Variablen, die durch Bezeichner eindeutig benannt sind. Jede Variable wird durch einen Datentyp definiert und speichert einen der erlaubten Datentypwerte. In Java-Code verwenden wir Ausdrücke wie vertraute mathematische Ausdrücke und wenden darauf die Operationen an, die den jeweiligen Datentypen zugeordnet sind. Bei primitiven Typen verwenden wir Bezeichner, um auf Variablen Bezug zu nehmen, Operatorsymbole wie +, -, * und / für Operationen, Literale wie 1 oder 3.14 zur Angabe von Werten und Ausdrücke wie (x + 2.236)/2, um Operationen auf Werte auszuführen. Der Zweck eines Ausdrucks ist es, einen der Datentypwerte zu definieren.
28
1.1 Das grundlegende Programmiermodell
Tabelle 1.1
Grundlegende Bestandteile für Java-Programme
Begriff
Beispiel
Definition
Primitiver Datentyp
int double boolean char
Ein Satz von Werten und ein Satz von Operationen auf diesen Werten (integriert in Java).
Bezeichner
a abc Ab$ a_b ab123 lo hi
Eine Folge von Buchstaben, Ziffern, _ und $, wobei an erster Stelle keine Ziffer steht.
Variable
[ jeder Bezeichner ]
Benennt einen Datentypwert.
Operator
+-*/
Benennt eine Datentypoperation.
Literal
int double boolean char
1 0 -42 2.0 1.0e-15 3.14 true false 'a' '+' '9' '\n'
Quellcoderepräsentation eines Wertes.
Ausdruck
int double boolean
lo + (hi – lo)/2 1.0e-15 * t lo ) um anzuzeigen, dass wir überall dort, wo dies angegeben ist, eine beliebige Instanz dieses Konstrukts verwenden können. In diesem Fall steht für einen Ausdruck, der einen booleschen Wert zurückliefert, da er zum Beispiel eine Vergleichsoperation beinhaltet, und steht für eine Folge von Java-Anweisungen. Es wäre durchaus möglich, und formal zu definieren, aber wir wollen hier nicht allzu sehr ins Detail gehen. Die Bedeutung der if-Anweisung ist nahezu selbsterklärend: Die Anweisung(en) im Anweisungsblock sind genau dann auszuführen, wenn der Ausdruck true ergibt. Die folgende if-elseAnweisung if () { } else { }
erlaubt es, zwischen zwei alternativen Anweisungsblöcken zu wählen.
Schleifen Viele Berechnungen sind inhärent iterativ. Das grundlegende Java-Konstrukt für solche Berechnungen hat das folgende Format: while () { }
Die while-Anweisung hat die gleiche Form wie die if-Anweisung (der einzige Unterschied besteht in der Verwendung des Schlüsselwortes while anstelle von if), aber die Bedeutung ist eine ganz andere. Der Computer wird angewiesen, sich wie folgt zu verhalten: Wenn der Ausdruck false ist, mache nichts; wenn der Ausdruck true ist, führe die Folge der Anweisungen (wie bei if) aus, prüfe dann aber nochmals den Ausdruck und führe die Folge der Anweisungen erneut aus, wenn der Ausdruck weiterhin true ist. Wiederhole diesen Schritt so oft, wie der Ausdruck true ergibt. Wir bezeichnen den Anweisungsblock in einer Schleife auch als Rumpf der Schleife.
33
1
Grundlagen
Break- und Continue-Anweisungen Manche Situationen verlangen eine etwas kompliziertere Ablaufsteuerung, die mit einfachen if- und while-Anweisungen nur schwer erzeugt werden kann. Deshalb unterstützt Java zwei weitere Anweisungen, die in while-Schleifen Verwendung finden:
die break-Anweisung, die die Schleife direkt verlässt die continue-Anweisung, die sofort mit dem nächsten Durchlauf (Iteration) der Schleife beginnt Wir werden diese Anweisungen in diesem Buch weitestgehend vermeiden (manche Programmierer verwenden sie nie), aber in bestimmten Fällen vereinfachen sie den Code erheblich.
1.1.4 Kurzschreibweisen Es gibt mehrere Möglichkeiten, eine gegebene Berechnung auszudrücken, wobei wir uns stets um klaren, eleganten und effizienten Code bemühen. Ein solcher Code macht oft Gebrauch von den folgenden weit verbreiteten Kurzformen (die in vielen Sprachen, nicht nur in Java zu finden sind).
Deklarationen mit Initialisierung Wir können eine Deklaration mit einer Zuweisung kombinieren, um eine Variable bei ihrer Deklaration (Erzeugung) zu initialisieren. Zum Beispiel erzeugt der Code int i = 1 eine Variable vom Typ int und weist ihr den Anfangswert 1 zu. Am besten bedienen Sie sich dieses Mechanismus möglichst kurz, bevor Sie die Variable das erste Mal verwenden (um den Gültigkeitsbereich zu beschränken).
Implizite Zuweisungen Die folgenden Kurzformen stehen Ihnen zur Verfügung, wenn Sie den Wert einer Variablen relativ zu ihrem aktuellen Wert ändern wollen:
Inkrement-/Dekrement-Operatoren: i++ ist identisch mit i = i + 1 und hat in einem Ausdruck den Wert i. Entsprechend ist i-- identisch mit i = i - 1. Der Code ++i und --i ist ähnlich, nur dass der Wert des Ausdrucks nach dem Inkrementieren/Dekrementieren zurückgeliefert wird und nicht vorher.
Weitere zusammengesetzte Operationen: Wenn Sie in einer Zuweisung einen binären Operator vor das Gleichheitszeichen (=) stellen, ist der Code der gleiche, wie wenn Sie die Variable zur Linken als ersten Operanden verwenden. So ist zum Beispiel der Code i/=2; gleich dem Code i = i/2;. Beachten Sie, dass i += 1; das gleiche Ergebnis liefert wie i = i+1; (und i++).
Blöcke mit nur einer Anweisung Wenn ein Anweisungsblock in einer bedingten Anweisung oder einer Schleife nur aus einer Anweisung besteht, können die geschweiften Klammern weggelassen werden.
34
1.1 Das grundlegende Programmiermodell
For-Notation Viele Schleifen weisen folgendes Muster auf: Sie initialisieren eine Indexvariable mit einem Wert und bedienen sich dann einer while-Schleife, um eine Schleifenabbruchbedingung unter Verwendung einer Indexvariablen zu testen, wobei die letzte Anweisung in der while-Schleife die Indexvariable inkrementiert. Solche Schleifen können Sie in Java auch direkt mit der for-Notation ausdrücken: for ( y) max = x; else max = y;
Führt in Abhängigkeit von einem booleschen Ausdruck die eine oder andere Anweisung aus.
double c = 3.141592625;
Weist bei einer Deklaration der Variablen gleich einen Anfangswert zu.
35
1
Grundlagen
Tabelle 1.3
Java-Anweisungen (Forts.)
Anweisung
Beispiele
Definition
Schleife
int v = 0; while (v 1e-15*t) t = (c/t + t) / 2.0;
Schleife (for)
for (int i = 1; i err * t) t = (c/t + t) / 2.0; return t;
}
Rückgabeanweisung
Aufruf einer anderen Methode
Abbildung 1.4: Aufbau einer statischen Methode
Statische Methoden aufrufen Der Aufruf einer statischen Methode umfasst den Namen der Methode gefolgt von Ausdrücken in Klammern, die die Argumentwerte getrennt durch Kommata angeben. Wenn der Methodenaufruf Teil eines Ausdrucks ist, berechnet die Methode einen Wert und dieser Wert wird anstelle des Aufrufs im Ausdruck verwendet. So liefert beispielsweise der Aufruf von rank() von BinarySearch einen int-Wert zurück. Ein Methodenaufruf gefolgt von einem Semikolon ist eine Anweisung, die im Allgemeinen Nebeneffekte hat. So ist zum Beispiel der Aufruf von Arrays.sort() in main() von BinarySearch ein Aufruf der Systemmethode Arrays.sort(), die bewirkt, dass die Einträge im Array sortiert werden. Wenn eine Methode aufgerufen wird, werden die Argumentvariablen mit den Werten der
40
1.1 Das grundlegende Programmiermodell
entsprechenden Ausdrücke in dem Aufruf initialisiert. Eine return- oder Rückgabeanweisung beendet eine statische Methode und übergibt die Kontrolle wieder an den Aufrufer. Wenn die statische Methode einen Wert berechnen soll, muss dieser Ergebniswert in einer return-Anweisung angegeben werden (wenn eine solche statische Methode das Ende ihrer Anweisungsfolge erreichen kann, ohne dass eine return-Anweisung erfolgt, gibt der Compiler eine Fehlermeldung aus).
Tabelle 1.5
Typische Implementierungen von statischen Methoden
Aufgabe
Implementierung
Absolutwert eines int-Wertes
public static int abs(int x) { if (x < 0) return -x; else return x; }
Absolutwert eines double-Wertes
public static double abs(double x) { if (x < 0.0) return -x; else return x; }
Primzahl-Test
public static boolean isPrime(int N) { if (N < 2) return false; for (int i = 2; i*i err * t) t = (c/t + t) / 2.0; return t; }
Hypotenuse eines rechtwinkligen Dreiecks
public static double hypotenuse(double a, double b) { return Math.sqrt(a*a + b*b); }
Harmonische Reihe (Tabelle 1.63 und 1.64)
public static double H(int N) { double sum = 0.0; for (int i = 1; i hi) return -1; int mid = lo + (hi - lo) / 2; if (key < a[mid]) return rank(key, a, lo, mid - 1); else if (key > a[mid]) return rank(key, a, mid + 1, hi); else return mid; } Listing 1.1: Rekursive Implementierung der binären Suche
43
1
Grundlagen
Grundlegendes Programmiermodell Eine Bibliothek statischer Methoden ist im Grunde ein Satz statischer Methoden, definiert in einer Java-Klasse. Dazu wird eine Datei mit den Schlüsselwörtern public class gefolgt von dem Klassennamen, gefolgt von den statischen Methoden in geschweiften Klammern angelegt und unter dem gleichen Namen wie die Klasse mit der Extension .java gespeichert. (Ein grundlegendes Java-Programmiermodell besteht darin, ein Programm, das einer bestimmten Berechnung gewidmet ist, dadurch zu entwickeln, dass man eine Bibliothek statischer Methoden aufsetzt, von denen eine main() heißt.) Durch die Eingabe von java gefolgt von einem Klassennamen, gefolgt von einer Reihe von Strings rufen Sie die main()-Methode aus dieser Klasse auf, und zwar mit einem Array als Argument, das die Strings enthält. Nachdem die letzte Anweisung in main() ausgeführt ist, wird das Programm beendet. Wenn wir in diesem Buch von einem Java-Programm sprechen, um eine Aufgabe zu erledigen, gehen wir von Code aus, der so oder so ähnlich entwickelt wurde (eventuell unter Hinzufügen einer Datentypdefinition, wie in Abschnitt 1.2 beschrieben). So ist BinarySearch beispielsweise ein JavaProgramm, das aus den beiden statischen Methoden rank() und main() besteht, deren Aufgabe es ist, die Zahlen aus der Eingabe, die nicht in einer Positivliste enthalten sind, wieder auszugeben.
Modulare Programmierung Von besonderer Bedeutung an diesem Modell ist, dass Bibliotheken statischer Methoden eine modulare Programmierung ermöglichen, das heißt, wir erstellen Bibliotheken von statischen Methoden (Module), die statische Methoden in anderen Bibliotheken aufrufen können. Dieser Ansatz hat viele Vorteile:
Wir können mit Modulen überschaubarer Größe arbeiten, auch in Programmen, die viel Code umfassen.
Wir können Code teilen und wiederverwenden, ohne ihn neu implementieren zu müssen.
Wir können Implementierungen problemlos durch verbesserte Implementierungen ersetzen.
Wir können geeignete abstrakte Modelle entwickeln, um Programmierprobleme zu bewältigen.
Wir können das Debuggen eingrenzen (siehe den nachfolgenden Abschnitt zu den Modultests). Zum Beispiel macht BinarySearch Gebrauch von drei anderen unabhängig voneinander entwickelten Bibliotheken: unsere Bibliotheken StdIn und In und die Java-Bibliothek Arrays. Jede dieser Bibliotheken macht wiederum Gebrauch von weiteren Bibliotheken.
44
1.1 Das grundlegende Programmiermodell
Modultests In der Java-Programmierung ist es allgemein üblich, jede Bibliothek von statischen Methoden mit einer main()-Methode zu versehen, die die Methoden in der Bibliothek testet (einige Programmiersprachen verbieten mehrere main()-Methoden und unterstützen diesen Ansatz nicht). Anspruchsvolle Modultests können eine große Programmierherausforderung sein. Jedes Modul sollte mindestens eine main()-Methode enthalten, die den Code in dem Modul ausführt und gewährleistet, dass er funktioniert. Im Laufe der Erweiterung eines Moduls nehmen wir oft Verbesserungen an der main()-Methode vor und bauen sie entweder zu einem Entwicklungsclient aus, der uns hilft, den Code während der Entwicklung im Detail zu testen, oder zu einem Testclient, der den gesamten Code ausgiebig testet. Wird der Client komplizierter, können wir ihn in einem eigenen unabhängigen Modul kapseln. In diesem Buch verwenden wir main(), um Ihnen den Zweck jedes Moduls zu veranschaulichen, und überlassen Ihnen die Testclients als Übung.
Externe Bibliotheken Wir verwenden statische Methoden aus vier verschiedenen Arten von Bibliotheken, die jeweils (leicht) abweichende Prozeduren für die Wiederverwendung von Code erfordern. Die meisten dieser Bibliotheken bestehen ausschließlich aus statischen Methoden, einige sind jedoch Datentypdefinitionen, die auch einige statische Methoden umfassen.
Die Standardsystembibliotheken java.lang.*: Hierzu gehören Math, die Methoden für häufig verwendete mathematische Funktionen enthält, Integer und Double, die wir nutzen, um Zeichenketten in int- und double-Werte oder vice versa umzuwandeln, String und StringBuilder, die wir weiter hinten in diesem Abschnitt und in Kapitel 5 noch näher besprechen, sowie Dutzende von weiteren Bibliotheken, die wir hier nicht verwenden.
Importierte Systembibliotheken wie java.util.Arrays: Es gibt Tausende solcher Bibliotheken in einem Standard-Java-Release, die wir allerdings nur sehr begrenzt nutzen. Sie können auf solche Bibliotheken erst zugreifen, nachdem Sie sie mit einer import-Anweisung am Anfang des Programms eingebunden haben.
Andere Bibliotheken in diesem Buch: Zum Beispiel kann ein anderes Programm die Methode rank() von BinarySearch nutzen. Um solch ein Programm zu verwenden, laden Sie den Quellcode einfach von der Website zum Buch in Ihr Arbeitsverzeichnis herunter.
Die Standardbibliotheken Std*, die wir speziell für dieses Buch (und unser Einsteigerbuch Einführung in die Programmierung mit Java: Ein interdisziplinärer Ansatz) entwickelt haben: Diese Bibliotheken werden in den folgenden Seiten vorgestellt. Den Quellcode und Anweisungen zum Herunterladen finden Sie auf der Website zum Buch.
45
1
Grundlagen
Tabelle 1.6
Die in diesem Buch verwendeten Bibliotheken mit statischen Methoden
Standardsystembibliotheken Math Integer+ Double+ String+ StringBuilder System
Importierte Systembibliotheken java.util.Arrays
Unsere Standardbibliotheken StdIn StdOut StdDraw StdRandom StdStats In+ Out+ +
Datentypdefinitionen, die einige statische Methoden enthalten.
Um eine Methode einer anderen Bibliothek aufzurufen (sei dies eine Bibliothek im gleichen oder einem explizit angegebenen Verzeichnis, eine Standardsystembibliothek oder eine Systembibliothek, die vor der Klassendefinition mittels einer import-Anweisung eingebunden wird), setzen wir bei jedem Aufruf den Bibliotheksnamen vor den Namen der Methode. So ruft zum Beispiel die main()-Methode von BinarySearch die sort()Methode der Systembibliothek java.util.Arrays, die readInts()-Methode unserer Bibliothek In und die println()-Methode unserer Bibliothek StdOut auf. Bibliotheken von Methoden, die wir selbst oder andere in einer modularen Programmierumgebung implementiert haben, können den Anwendungsbereich unseres Programmiermodells extrem erweitern. So gibt es zusätzlich zu den Bibliotheken des Standard-Java-Release noch Tausende von weiteren Bibliotheken im Web zu Anwendungen aller Arten. Da wir uns in diesem Buch aber auf Algorithmen konzentrieren wollen, sind wir bemüht, den Anwendungsbereich unseres Programmiermodells überschaubar zu halten, und verwenden deshalb nur die Bibliotheken aus Tabelle 1.6, und davon auch nur eine Teilmenge der Methoden, die in den nachfolgend beschriebenen APIs aufgelistet sind.
46
1.1 Das grundlegende Programmiermodell
1.1.7 APIs Eine wichtige Komponente der modularen Programmierung ist die Dokumentation. Sie erläutert die Funktionsweise der Bibliotheksmethoden, die anderen zur Nutzung zur Verfügung stehen. Wir werden konsequent alle Bibliotheksmethoden, die wir in diesem Buch verwenden, in sogenannten Programmierschnittstellen oder APIs (für Applications Programming Interface) beschreiben – unter Angabe des Bibliotheksnamens, der Signatur und einer Kurzbeschreibung. Mit dem Begriff Client beziehen wir uns auf ein Programm, das eine Methode einer anderen Bibliothek aufruft, und der Begriff Implementierung beschreibt den Java-Code, der die Methoden in einer API implementiert.
Beispiel Das folgende Beispiel – die API für häufig verwendete statische Methoden der Standardbibliothek Math in java.lang – soll unsere Konventionen für APIs veranschaulichen:
Tabelle 1.7
API für die Java-Bibliothek Math (Auszug)
public class Math static double abs(double a)
Absolutwert von a
static double max(double a, double b)
Maximum von a und b
static double min(double a, double b)
Minimum von a und b
Hinweis 1: abs(), max() und min() sind auch für int, long und float definiert. static double sin(double theta)
Sinusfunktion
static double cos(double theta)
Kosinusfunktion
static double tan(double theta)
Tangensfunktion
Hinweis 2: Winkel werden in Bogenmaß angegeben. Verwenden Sie für die Umwandlung toDegrees() und toRadians(). Hinweis 3: Verwenden Sie asin(), acos() und atan() für die Umkehrfunktionen. static double exp(double a)
Exponentialfunktion (ea)
static double log(double a)
Natürlicher Logarithmus (loge a oder ln a)
static double pow(double a, double b)
a hoch b (ab)
static double random()
Zufallszahl zwischen [0, 1)
static double sqrt(double a)
Quadratwurzel von a
static double E
Wert von e (Konstante)
static double PI
Wert von π (Konstante)
Auf der Website zum Buch finden Sie weitere Funktionen, auf die Sie zurückgreifen können.
47
1
Grundlagen
Alle diese Methoden implementieren mathematische Funktionen, d.h., sie nehmen Argumente entgegen und berechnen daraus einen Ergebniswert eines vorgegebenen Typs (außer random(), die keine mathematische Funktion implementiert, weil sie keine Argumente übernimmt). Da alle Methoden double-Werte einsetzen und ein double-Ergebnis berechnen, können Sie sie sozusagen als Erweiterung des Datentyps double betrachten – eine solche Erweiterbarkeit ist eines der Hauptmerkmale moderner Programmiersprachen. Jede Methode wird in der API mit nur einer Zeile beschrieben, die alle Informationen enthält, die Sie benötigen, um diese Methode einsetzen zu können. Die Bibliothek Math definiert außerdem die relativ genauen konstanten Werte PI (für π) und E (für e), sodass Sie diese Namen in Ihren Programmen verwenden können, um auf diese Konstanten Bezug zu nehmen. So ist zum Beispiel der Wert von Math.sin(Math.PI/2) gleich 1.0 und der Wert von Math.log(Math.E) gleich 1.0 (da Math.sin() sein Argument als Bogenmaß übernimmt und Math.log() die natürliche Logarithmusfunktion implementiert).
Java-Bibliotheken Jedes Java-Release umfasst ausführliche Onlinebeschreibungen zu Tausenden von Bibliotheken. Wir beschränken uns in diesem Buch aber auf nur einige wenige Methoden, um unser Programmiermodell möglichst übersichtlich zu halten. Zum Beispiel verwendet BinarySearch die Methode sort() aus der Java-Bibliothek Arrays, die wir folgendermaßen dokumentieren:
Tabelle 1.8
Auszug aus der Java-Bibliothek Arrays (java.util.Arrays)
public class Arrays static void sort(int[] a)
Das Array in aufsteigender Reihenfolge sortieren
Hinweis: Diese Methode ist auch für andere primitive Datentypen und Object definiert.
Da die Bibliothek Arrays nicht Teil von java.lang ist, wird eine import-Anweisung benötigt, um eine Verwendung wie in BinarySearch zu ermöglichen. In Kapitel 2 dieses Buches gehen wir ausführlich auf die Implementierungen von sort() für Arrays ein, einschließlich der Mergesort- und Quicksort-Algorithmen, die in Arrays.sort() implementiert sind. Viele der grundlegenden Algorithmen, die wir in diesem Buch betrachten, sind in Java und vielen anderen Programmierumgebungen implementiert – wie zum Beispiel Arrays, das eine Implementierung der binären Suche enthält. Um Verwirrungen zu vermeiden, verwenden wir im Allgemeinen unsere eigenen Implementierungen, obwohl nichts dagegen spricht, auf gut abgestimmte Bibliotheksimplementierungen zurückzugreifen, sofern man diese richtig verstanden hat.
Unsere Standardbibliotheken Wir haben eine Reihe von Bibliotheken entwickelt, deren Funktionalität vor allem für den Einstieg in die Java-Programmierung, für wissenschaftliche Anwendungen sowie für das Entwickeln, Studieren und Anwenden von Algorithmen von Nutzen ist. Die
48
1.1 Das grundlegende Programmiermodell
meisten dieser Bibliotheken sind mit der Ein- und Ausgabe verbunden. Darüber hinaus arbeiten wir mit den folgenden beiden Bibliotheken, um unsere Implementierungen zu testen und zu analysieren. Die erste erweitert Math.random(), sodass wir Zufallswerte von verschiedenen Verteilungen erzeugen können, die zweite unterstützt statistische Berechnungen:
Tabelle 1.9
API für unsere Bibliothek der statischen Methoden für Zufallszahlen
Public class StdRandom static
void
setSeed(long seed)
Initialisieren
static
double
random()
Reelle Zahl zwischen 0 und 1
static
int
uniform(int N)
Integer zwischen 0 und N-1
static
int
uniform(int lo, int hi)
Integer zwischen lo und hi-1
static
double
uniform(double lo, double hi)
Reelle Zahl zwischen lo und hi
static
boolean
bernoulli(double p)
true mit der Wahrscheinlich-
keit p static
double
gaussian()
Normal, Mittelwert 0, Standardabweichung 1
static
double
gaussian(double m, double s)
Normal, Mittelwert m, Standardabweichung s
static
int
discrete(double[] a)
i mit der Wahrscheinlichkeit a[i]
static
void
shuffle(double[] a)
Mischen des Arrays [a]
Hinweis: Es gibt überladene Implementierungen von shuffle() für andere primitive Datentypen und Object.
Tabelle 1.10 API für unsere Bibliothek der statischen Methoden zur Datenanalyse public class StdStats static
double
max(double[] a)
Größter Wert
static
double
min(double[] a)
Kleinster Wert
static
double
mean(double[] a)
Mittelwert
static
double
var(double[] a)
Stichprobenvarianz
static
double
stddev(double[] a)
Stichproben-Standardabweichung
static
double
median(double[] a)
Zentralwert
49
1
Grundlagen
Die Methode setSeed() in StdRandom erlaubt die Initialisierung des Zufallszahlengenerators, damit wir Experimente mit Zufallszahlen wiederholen können. (In Tabelle 1.11 sind für viele dieser Methoden die Implementierungen als Referenz angegeben.) Einige dieser Methoden sind extrem leicht zu implementieren. Warum also sich die Mühe machen, sie in eine Bibliothek aufzunehmen? Für gut entworfene Bibliotheken lauten die Antworten auf diese Frage normalerweise:
Die Methoden implementieren eine Abstraktionsebene, die es uns erlaubt, uns auf das Implementieren und Testen der Algorithmen im Buch zu konzentrieren, und nicht auf das Erzeugen von Zufallsobjekten oder das Berechnen statistischer Werte. Client-Code, der diese Methoden verwendet, ist klarer und verständlicher als selbst aufgesetzter Code, der die gleichen Berechnungen anstellt.
Bibliotheksimplementierungen testen auf außergewöhnliche Bedingungen, decken seltene Situationen ab und sind intensiv getestet, sodass wir uns auf ein einwandfreies Funktionieren verlassen können. Solche Implementierungen bestehen teilweise aus sehr viel Code – auch weil man manchmal für verschiedene Datentypen gerne individuelle Implementierungen hätte. Aus diesem Grund enthält die JavaBibliothek Arrays mehrere überladene Implementierungen von sort() – eine für jeden Datentyp, den Sie möglicherweise sortieren müssen. Solche Überlegungen sind von grundlegender Bedeutung für die modulare Programmierung in Java, aber in diesem Fall vielleicht etwas übertrieben. Auch wenn die Methoden der beiden Bibliotheken mehr oder weniger selbsterklärend sind und viele davon sich relativ leicht implementieren lassen, sind einige von ihnen interessante algorithmische Übungen. Deshalb täten Sie gut daran, den Code in StdRandom.java und StdStats.java auf der Website zum Buch nicht nur zu studieren, sondern diese bewährten Implementierungen auch direkt einzusetzen. Wer diese Bibliotheken nutzen (und den Code untersuchen) möchte, sollte am besten den Quellcode von der Website zum Buch herunterladen und ihn in seinem Arbeitsverzeichnis ablegen. Auf der Website finden Sie außerdem die Beschreibung mehrerer systemabhängiger Mechanismen, wie Sie diese Bibliotheken nutzen können, ohne unnötig Kopien zu erstellen.
Ihre eigenen Bibliotheken Es empfiehlt sich, jedes Programm, das Sie schreiben, als Bibliotheksimplementierung zu betrachten, die wiederverwendet werden kann.
Schreiben Sie Code für den Client – eine Toplevel-Implementierung, die die Berechnung in leichter zu handhabende Teile aufbricht.
Formulieren Sie eine API für eine Bibliothek von statischen Methoden (oder mehrere APIs für mehrere Bibliotheken), mit denen sich die einzelnen Teile lösen lassen.
Entwickeln Sie eine Implementierung der API, mit einer main()-Methode, die die Methoden unabhängig vom Client testet. Mit diesem Ansatz entwickeln Sie nicht nur wertvolle Software, die Sie später wiederverwenden können, sondern machen auch bestmöglichen Gebrauch von der modularen Programmierung – der Schlüssel zur erfolgreichen Lösung komplexer Programmieraufgaben.
50
1.1 Das grundlegende Programmiermodell
Tabelle 1.11 Implementierungen von statischen Methoden der Bibliothek StdRandom Angestrebtes Ergebnis
Implementierung
double-Zufallswert in [a, b)
public static double uniform(double a, double b) { return a + StdRandom.random() * (b-a); }
int-Zufallswert in [0..N)
public static int uniform(int N) { return (int) (StdRandom.random() * N);
int-Zufallswert in [lo..hi)
public static int uniform(int lo, int hi) { return lo + StdRandom.uniform(hi - lo);
int-Zufallswert aus
public static int discrete(double[] a) { // Elemente in a[] müssen als Summe 1 ergeben. double r = StdRandom.random(); double sum = 0.0; for (int i = 0; i < a.length; i++) { sum = sum + a[i]; if (sum >= r) return i; } return -1;
einer diskreten Verteilung (i mit der Wahrscheinlichkeit a[i])
} }
}
Mischen der Elemente in einem Array von double-Werten (siehe Übung 1.1.36)
public static void shuffle(double[] a) { int N = a.length; for (int i = 0; i < N; i++) { // Tausche a[i] mit Zufallselement in a[i..N-1] int r = i + StdRandom.uniform(N-i); double temp = a[i]; a[i] = a[r]; a[r] = temp; } }
Sinn und Zweck einer API ist es, den Client von der Implementierung zu trennen: Der Client sollte von der Implementierung lediglich das wissen, was die API an Informationen bietet, und die Implementierung sollte keine Eigenschaften von irgendwelchen Clients berücksichtigen. APIs bieten uns die Möglichkeit, Code für die verschiedensten Anwendungsbereiche zu entwickeln und dann vielfach wiederzuverwenden. Keine Java-Bibliothek kann alle Methoden enthalten, die wir zur Lösung eines komplexen Problems benötigen, sodass die Wiederverwendung ein wichtiger Schritt zur Lösung komplexer Aufgaben ist. Programmierer betrachten eine API als eine Art Vertrag zwischen dem Client und der Implementierung, der klar angibt, was jede Methode macht. Wenn wir an einer Implementierung arbeiten, ist es unser oberstes Ziel, die Vertragsbedingungen einzuhalten. Oft kann dies auf verschiedenen Wegen erreicht werden, und wir haben dank der Trennung von Client-Code und Implementierung die Freiheit, im Laufe
51
1
Grundlagen
der Zeit immer wieder ältere Implementierungen durch neue, verbesserte Versionen zu ersetzen. Beim Studium der Algorithmen ist diese Möglichkeit von großer Bedeutung, um festzustellen, inwieweit sich unsere Verbesserungen an den Algorithmen auswirken.
1.1.8 Strings Ein String ist eine Folge von Zeichen (char-Werten). Ein String-Literal ist eine Folge von Zeichen in doppelten Anführungszeichen, wie "Hello, World". Der Datentyp String ist zwar ein integrierter, aber kein primitiver Datentyp, auch wenn Java ihn manchmal so behandelt. Wir gehen schon hier auf String ein, da es ein wichtiger Datentyp ist, den fast jedes Java-Programm verwendet.
Verkettung In Java gibt es neben den integrierten Operatoren für primitive Datentypen auch einen integrierten Verkettungsoperator (+) für String, weswegen man die Zeile aus Tabelle 1.12 auch in Tabelle 1.2 der primitiven Datentypen aufnehmen könnte. Das Ergebnis beim Verketten zweier String-Werte ist ein einzelner String-Wert: die erste Zeichenkette gefolgt von der zweiten.
Tabelle 1.12 Der Java-Datentyp String Typ
Wertesatz
Typische Literale
Operatoren
String
Zeichenfolgen
"AB" "Hello" "2.5"
+
(verketten)
Typische Ausdrücke Ausdruck
Wert
"Hi, " + "Bob" "12" + "34" "1" + "+" + "2"
"Hi, Bob" "1234" "1+2"
Umwandlung Strings spielen vor allem in zwei Situationen eine wichtige Rolle: um Werte, die wir über eine Tastatur eintippen, in Datentypwerte umzuwandeln und um Datentypwerte in Werte umzuwandeln, die wir auf einer Anzeige lesen können. Java verfügt in seinen Bibliotheken bereits über Methoden für String, die diese Operationen erleichtern. Erwähnenswert sind hierbei die Bibliotheken Integer und Double mit ihren statischen Methoden, um String-Werte in int-Werte (und umgekehrt) bzw. String-Werte in doubleWerte (und umgekehrt) umzuwandeln.
Tabelle 1.13 APIs für die Umwandlung zwischen Zahlen und StringWerten public class Integer
52
static
int
parseInt(String s)
Wandelt s in einen int-Wert um.
static
String
toString(int i)
Wandelt i in einen String-Wert um.
1.1 Das grundlegende Programmiermodell
Tabelle 1.13 APIs für die Umwandlung zwischen Zahlen und StringWerten (Forts.) public class Double static
double
parseDouble(String s)
Wandelt s in einen double-Wert um.
static
String
toString(double x)
Wandelt x in einen String-Wert um.
Automatische Umwandlung Wir rufen die oben beschriebenen statischen toString()-Methoden nur selten auf, da Java über einen eingebauten Mechanismus verfügt, um durch Verkettung einen beliebigen Datentypwert in einen String-Wert umzuwandeln: Wenn einer der Argumente von + ein String ist, wandelt Java automatisch das andere Argument ebenfalls in einen String um (sofern es nicht bereits ein String ist). Dieser Mechanismus erlaubt uns nicht nur Formulierungen wie "Die Quadratwurzeln von 2.0 ist " + Math.sqrt(2.0), sondern ermöglicht es uns auch, einen Wert jeden beliebigen Datentyps durch Verkettung mit dem leeren String "" in einen String umzuwandeln.
Befehlszeilenargumente Strings kommen in der Java-Programmierung noch eine besondere Rolle zu, weil sie an dem Mechanismus beteiligt sind, mit dem Daten von der Befehlszeile an das Programm übergeben werden. Der Mechanismus selbst ist relativ simpel. Wenn Sie den Befehl java mit einem Bibliotheksnamen gefolgt von einem oder mehreren Strings eintippen, ruft das Java-System die Methode main() in dieser Bibliothek auf und übergibt ihr ein Array von Strings als Argument: eben die Strings, die auf den Bibliotheksnamen folgen. Die main()-Methode von BinarySearch übernimmt z.B. genau ein Befehlszeilenargument, sodass das System für die Übergabe ein Array der Größe eins erzeugt. Das Programm verwendet diesen Wert, args[0], um die Datei mit der Positivliste zu benennen, die als Argument an In.readInts() übergeben wird. Für den Fall, dass ein Befehlszeilenargument eine Zahl repräsentieren soll, haben wir es mit einem weiteren typischen Paradigma zu tun, das wir oft in unserem Code verwenden: mit parseInt() wandeln wir das Argument in einen int-Wert um und mit parseDouble() in einen double-Wert. Rechnen mit Strings ist ein wichtiger Bestandteil moderner Programmierung. Im Moment nutzen wir String nur, um zwischen der externen Repräsentation von Zahlen als Zeichenfolgen und der internen Repräsentation numerischer Datentypwerte umzuwandeln. In Abschnitt 1.2 werden wir sehen, dass Java viele weitere Operationen auf String-Werten unterstützt, die wir in diesem Buch ebenfalls verwenden. In Abschnitt 1.4 werden wir uns mit der internen Repräsentation von String-Werten beschäftigen und in Kapitel 5 werden wir ausführlich auf Algorithmen eingehen, die String-Daten verarbeiten. Diese Algorithmen sind die interessantesten, kompliziertesten und bedeutsamsten Methoden, die wir in diesem Buch betrachten.
53
1
Grundlagen
1.1.9 Ein- und Ausgabe Unsere Standardbibliotheken für Eingabe, Ausgabe und Grafik sind vornehmlich dazu gedacht, unseren Java-Programmen ein einfaches Modell für die Kommunikation mit der Außenwelt zur Verfügung zu stellen. Diese Bibliotheken stützen sich dabei auf die umfangreichen Möglichkeiten, die die Java-Bibliotheken bieten, dort aber im Allgemeinen relativ kompliziert und schwer zu verstehen und zu nutzen sind. Sehen wir uns zunächst kurz an, wie das Modell aufgebaut ist. Befehlszeilenargumente
Standardeingabe
Standardausgabe
Datei-E/A Standardgrafik Abbildung 1.5: Ein Java-Programm aus der Vogelperspektive
In unserem Modell nimmt ein Java-Programm Eingabewerte aus Befehlszeilenargumenten oder einem abstrakten Zeichenstrom (der sogenannten Standardeingabe) entgegen und schreibt in einen anderen abstrakten Zeichenstrom, der sogenannten Standardausgabe. Aus diesem Grund müssen wir die Schnittstelle zwischen Java und dem Betriebssystem näher untersuchen, das heißt, wir müssen auf die grundlegenden Mechanismen der meisten modernen Betriebssysteme und Entwicklungsumgebungen eingehen. Wenn Sie ausführlichere Informationen zu einem speziellen Betriebssystem benötigen, finden Sie diese auf der Website zum Buch. Standardmäßig sind Befehlszeilenargumente sowie Standardeingabe und -ausgabe mit Anwendungen verbunden, die entweder durch das Betriebssystem unterstützt werden oder durch eine Programmentwicklungsumgebung, die die Befehle entgegennimmt. Das Fenster der Anwendung, in das wir Text eingeben und in dem wir Textausgaben lesen, bezeichnen wir allgemein als Konsolenfenster. Seit den ersten Unix-Systemen in den 1970ern hat sich dieses Modell bewährt, um auf einem bequemen und direkten Weg mit unseren Programmen und Daten zu interagieren. Wir ergänzen das klassische Modell um die Standardgrafik, sodass wir unsere Daten zur Datenanalyse auch visuell aufbereiten können.
54
1.1 Das grundlegende Programmiermodell
Befehle und Argumente Das Konsolenfenster zeigt eine Eingabeaufforderung (prompt), hinter dem wir Befehle an das Betriebssystem eingeben, je nach Bedarf mit oder ohne Argumente. Wir verwenden nur sehr wenige Befehle in diesem Buch, die kurz in Tabelle 1.14 zusammengefasst werden. Den Befehl, den wir mit Sicherheit am häufigsten verwenden, ist der Befehl java, mit dem wir unsere Programme ausführen. Wie in Abschnitt Befehlszeilenargumente erwähnt, haben Java-Klassen eine statische Methode main(), die ein String-Array args[] als Argument übernimmt. Dieses Array enthält die Folge der von uns eingegebenen Befehlszeilenargumente, die das Betriebssystem an Java weiterleitet. Per Konvention verarbeiten Java und das Betriebssystem die Argumente als Strings. Wenn eines der Argumente als Zahl interpretiert werden soll, rufen wir eine Methode wie Integer.parseInt() auf, um es von einem String in den entsprechenden Datentyp umzuwandeln.
Tabelle 1.14 Typische Betriebssystembefehle Befehl
Argumente
Zweck
javac
Name einer .java-Datei
Kompiliert ein Java-Programm.
java
Name einer .class-Datei (ohne Erweiterung) und Befehlszeilenargumente
Führt ein Java-Programm aus.
more
Jeder Textdateiname
Gibt den Dateiinhalt aus.
Standardausgabe Unsere Bibliothek StdOut unterstützt die Standardausgabe. Per Voreinstellung verbindet das Betriebssystem die Standardausgabe mit dem Konsolenfenster. Die Methode print() gibt die ihr übergebenen Argumente auf der Standardausgabe aus. Die Methode println() fügt einen Zeilenumbruch hinzu und die Methode printf() unterstützt die nachfolgend beschriebene formatierte Ausgabe. Java verfügt über eine ähnliche Methode in ihrer Bibliothek System.out. Wir verwenden jedoch StdOut, um Standardeingabe und Standardausgabe gleich zu behandeln (und um einige technische Verbesserungen vorzunehmen).
Eingabeaufforderung
Aufruf der statischen Methode main() von RandomSeq
% java RandomSeq 5 100.0 200.0 Aufruf der Java-Laufzeitumgebung
args[0] args[1] args[2]
Abbildung 1.6: Aufbau eines Befehls
55
1
Grundlagen
Tabelle 1.15 API für unsere Bibliothek der statischen Standardausgabemethoden public class StdOut static void print(String s)
Gibt s aus.
static void println(String s)
Gibt s gefolgt von einem Zeilenumbruch aus.
static void println()
Gibt einen Zeilenumbruch aus.
static void printf(String f, …)
Formatiert die Ausgabe.
Hinweis: Für primitive Datentypen und für Object gibt es überladene Implementierungen.
Um diese Methoden zu nutzen, müssen Sie StdOut.java von der Website zum Buch in Ihr Arbeitsverzeichnis herunterladen und die Methoden dann mit Code wie StdOut. println("Hello, World"); aufrufen. In Listing 1.2 wird ein Beispielclient vorgestellt. public class RandomSeq { public static void main(String[] args) { // Gibt N Zufallswerte aus dem Bereich (lo, hi) aus. int N = Integer.parseInt(args[0]); double lo = Double.parseDouble(args[1]); double hi = Double.parseDouble(args[2]); for (int i = 0; i < N; i++) { double x = StdRandom.uniform(lo, hi); StdOut.printf("%.2f\n", x); } } } Listing 1.2: StdOut-Beispielclient
% java RandomSeq 5 100.0 200.0 123.43 153.13 144.38 155.18 104.02
Formatierte Ausgabe In ihrer einfachsten Form übernimmt die Methode printf() zwei Argumente. Das erste Argument ist ein Formatstring, der beschreibt, wie das zweite Argument für die Ausgabe in einen String umzuwandeln ist. Der einfachste Formatstring beginnt mit einem % und endet mit einem einbuchstabigen Umwandlungscode. Die geläufigsten Umwandlungscodes sind d (für Dezimalwerte aus Javas Integertypen), f (für Gleitkommawerte) und s (für String-Werte). Zwischen dem % und dem Umwandlungscode steht
56
1.1 Das grundlegende Programmiermodell
ein Integer, der die Feldbreite des umgewandelten Wertes angibt (die Anzahl der Zeichen in dem erzeugten Ausgabestring). Standardmäßig werden linksseitig Leerzeichen eingefügt, um die Länge der erzeugten Ausgabe an die gewünschte Feldbreite anzupassen. Wenn die Leerzeichen rechts stehen sollen, können wir ein Minuszeichen vor die Feldbreite setzen. (Wenn der umgewandelte Ausgabestring größer als die Feldbreite ist, wird die Feldbreite ignoriert.) Im Anschluss an die Breite haben wir die Möglichkeit, einen Punkt und dann – für double-Werte – die Anzahl der Ziffern nach dem Dezimalpunkt (die Genauigkeit) beziehungsweise – für String-Werte – die Anzahl der Zeichen ab Beginn des Strings einzugeben. Das Wichtigste, was Sie sich für printf() merken müssen, ist, dass der Umwandlungscode im Formatstring und der Typ des dazugehörigen Arguments übereinstimmen müssen. Das bedeutet, Java muss in der Lage sein, vom Typ des Arguments in den vom Umwandlungscode geforderten Typ umzuwandeln. Das erste Argument von printf() ist ein String, der neben einem Formatstring auch andere Zeichen enthalten kann. Jeder Teil des Arguments, der nicht Teil eines Formatstrings ist, wird direkt an die Ausgabe weitergereicht, während der Formatstring durch den Argumentwert ersetzt wird (wie angegeben, umgewandelt in einen String). So gibt zum Beispiel die Anweisung StdOut.printf("PI ist ungefähr %.2f\n", Math.PI);
die folgende Zeile aus: PI ist ungefähr 3.14
Beachten Sie, dass wir im Argument explizit ein Zeilenumbruch-Zeichen (\n) angeben müssen, um mit printf() einen Zeilenumbruch auszugeben. Die printf()-Funktion kann mehr als zwei Argumente übernehmen. In diesem Fall enthält der Formatstring einen Formatspezifizierer für jedes weitere Argument, optional getrennt durch weitere, direkt an die Ausgabe weiterzuleitende Zeichen. Wenn Sie der statischen Methode String.format() die gleichen Argumente übergeben wie gerade printf(), erhalten Sie einen formatierten String, ohne ihn auszugeben. Die formatierte Ausgabe ist ein bequemer Mechanismus, um mithilfe von kompaktem Code Messdaten tabellarisch aufzubereiten (unser Hauptzweck in diesem Buch).
Tabelle 1.16 Formatkonventionen für printf() (viele weitere Optionen finden Sie auf der Website zum Buch) Typ
Code
Typisches Literal
FormatstringBeispiele
Konvertierte StringWerte für die Ausgabe
int
d
512
"%14d" "%-14d"
" "512
double
f
1595.1680010754388
"%14.2f" "%.7f" "%14.4e"
" 1595.17" "1595.1680011" " 1.5952e+03"
"Hello, World"
"%14s" "%-14s" "%-14.5s"
" Hello, World" "Hello, World " "Hello "
e String
s
512" "
57
1
Grundlagen
Standardeingabe Unsere StdIn-Bibliothek übernimmt die Daten aus der Standardeingabe, der leer sein oder eine Folge von Werten enthalten kann, die durch Whitespace (Leerzeichen, Tabulatoren, Zeilenumbruchzeichen usw.) getrennt sind. Per Voreinstellung verbindet das Betriebssystem die Standardausgabe mit dem Konsolenfenster – was Sie eingeben, ist der Eingabestrom (der je nach Ihrer Konsolenanwendung mit (Strg)+(d) oder (Strg)+(z) beendet wird). Jeder Wert ist ein String oder ein Wert eines primitiven Java-Typs. Eines der Hauptmerkmale der Standardeingabe ist, dass Ihr Programm die Werte beim Lesen verbraucht. Sobald Ihr Programm einen Wert gelesen hat, kann es nicht mehr zurück und den Wert erneut lesen. Dieses Verhalten ist recht restriktiv, spiegelt aber die physikalischen Eigenschaften einiger Eingabegeräte wider und vereinfacht das Implementieren der Abstraktion. Im Rahmen des Eingabestrommodells sind die statischen Methoden dieser Bibliothek selbsterklärend (beschrieben durch ihre Signaturen). public class Average { public static void main(String[] args) { // Berechnet den Mittelwert der Zahlen in StdIn. double sum = 0.0; int cnt = 0; while (!StdIn.isEmpty()) { // Liest eine Zahl und addiert sie zu der Summe. sum += StdIn.readDouble(); cnt++; } double avg = sum / cnt; StdOut.printf("Average is %.5f\n", avg); } } Listing 1.3: StdIn-Beispielclient % java Average 1.23456 2.34567 3.45678 4.56789
Average is 2.90123
Tabelle 1.17 API für unsere Bibliothek der statischen Standardeingabemethoden public class StdIn
58
static
boolean
isEmpty()
true, wenn es keine weiteren Werte gibt, ansonsten false.
static
int
readInt()
Liest einen Wert vom Typ int.
1.1 Das grundlegende Programmiermodell
Tabelle 1.17 API für unsere Bibliothek der statischen Standardeingabemethoden (Forts.) public class StdIn static
double
readDouble()
Liest einen Wert vom Typ double.
static
float
readFloat()
Liest einen Wert vom Typ float.
static
long
readLong()
Liest einen Wert vom Typ long.
static
boolean
readBoolean()
Liest einen Wert vom Typ boolean.
static
char
readChar()
Liest einen Wert vom Typ char.
static
byte
readByte()
Liest einen Wert vom Typ byte.
static
String
readString()
Liest einen Wert vom Typ String.
static
boolean
hasNextLine()
Gibt es eine weitere Zeile im Eingabestrom?
static
String
readLine()
Liest den Rest der Zeile.
static
String
readAll()
Liest den Rest des Eingabestroms.
Umleitung und Pipelining Standardeingabe und -ausgabe erlauben uns, Befehlszeilenerweiterungen zu nutzen, die von vielen Betriebssystemen unterstützt werden. Durch eine einfache zusätzliche Direktive im Programmaufruf können wir die Standardausgabe des Programms in eine Datei umleiten, um sie dort permanent zu speichern oder später als Eingabe für ein anderes Programm zu verwenden. Der Befehl % java RandomSeq 1000 100.0 200.0 > data.txt
zum Beispiel gibt an, dass der Standardausgabestrom nicht im Konsolenfenster ausgegeben, sondern in eine Textdatei namens data.txt geschrieben werden soll. Jeder Aufruf von System.out.print() oder System.out.println() hängt Text an das Ende dieser Datei an. In diesem Fall ist das Endergebnis eine Datei, die 1000 Zufallswerte enthält. Im Konsolenfenster erscheint keine Ausgabe; alle Ausgaben werden direkt in die Datei umgeleitet, die hinter dem >-Symbol angegeben ist. Auf diese Weise können wir Informationen für eine spätere Verwendung speichern. Beachten Sie, dass wir keinerlei Änderungen an RandomSeq vornehmen müssen, damit dieser Mechanismus funktioniert – RandomSeq verwendet einfach die Standardausgabeabstraktion und bleibt davon unberührt, dass wir eine andere Implementierung dieser Abstraktion verwenden. Auf gleiche Weise können wir auch die Standardeingabe umleiten, sodass StdIn die Daten aus einer Datei und nicht von der Konsolenanwendung liest: % java Average < data.txt
Dieser Befehl liest eine Folge von Zahlen aus der Datei data.txt und berechnet deren Mittelwert. Hierbei ist das Symbol < eine Direktive, die das Betriebssystem anweist, den Standardeingabestrom aus dem Inhalt der Textdatei data.txt aufzubauen, anstatt darauf
59
1
Grundlagen
zu warten, dass der Benutzer irgendetwas in das Konsolenfenster eingibt. Wenn das Programm StdIn.readDouble() aufruft, liest das Betriebssystem den Wert aus der Datei ein. Die Kombination beider Mechanismen, mit dem Ziel die Ausgabe eines Programms so umzuleiten, dass sie zur Eingabe eines anderen wird, bezeichnen wir als Pipelining: % java RandomSeq 1000 100.0 200.0 | java Average
Dieser Befehl gibt zum Beispiel an, dass die Standardausgabe von RandomSeq und die Standardeingabe von Average derselbe Strom sind – so als würde RandomSeq die von ihm erzeugten Zahlen direkt in das Konsolenfenster des gerade ausgeführten Programms Average eingeben. Dies ist ein gravierender Unterschied zu dem zuvor geschilderten Verfahren, da es uns von der Beschränkung hinsichtlich der Größe der von uns verarbeiteten Ein- und Ausgabeströme befreit. Wir könnten beispielsweise den Wert 1000 in unserem Beispiel durch 1000000000 ersetzen, selbst wenn wir nicht genug Platz haben, um eine Milliarde Zahlen auf unserem Computer zu speichern (allerdings benötigen wir die Rechenzeit, um sie zu verarbeiten). Wenn RandomSeq die Methode StdOut.println() aufruft, wird ein String an das Ende des Stroms angehängt, und wenn Average dann StdIn.readInt() aufruft, wird ein String vom Anfang des Stroms entfernt. Für die genaue zeitliche Abstimmung ist dabei das Betriebssystem zuständig: Es führt unter Umständen RandomSeq aus, bis es eine Ausgabe erzeugt, und führt dann Average aus, um diese Ausgabe weiterzuverarbeiten; oder es führt Average aus, bis dieses Programm eine Eingabe benötigt, und führt dann RandomSeq aus, bis es die benötigte Ausgabe erzeugt. Das Endergebnis ist das gleiche, aber unsere Programme müssen sich jetzt um solche Details keine Gedanken mehr machen, denn sie arbeiten allein mit den Abstraktionen der Standardeingabe und -ausgabe. Von einer Datei in die Standardeingabe umleiten
% java Average < data.txt data.txt
Standardeingabe Average
Die Standardausgabe in eine Datei umleiten
% java RandomSeq 1000 100.0 200.0 > data.txt RandomSeq data.txt
Standardausgabe
Die Ausgabe eines Programms mittels Pipelining zur Eingabe eines anderen Programms machen
% java RandomSeq 1000 100.0 200.0 | java Average RandomSeq
Standardausgabe
Standardeingabe Average
Abbildung 1.7: Umleitung und Pipelining von der Befehlszeile aus
60
1.1 Das grundlegende Programmiermodell
Eingabe und Ausgabe aus einer Datei Unsere Bibliotheken In und Out bieten statische Methoden zur Implementierung der Abstraktion, den Inhalt eines Arrays von Werten eines primitiven Datentyps (oder String) aus einer Datei zu lesen und in eine Datei zu schreiben. Wir verwenden die Methoden readInts(), readDoubles() und readStrings() der Bibliothek In und die Methoden writeInts(), writeDoubles() und writeStrings() der Bibliothek Out. Das benannte Argument kann eine Datei oder eine Webseite sein. Dieser Mechanismus erlaubt uns beispielsweise, eine Datei und die Standardeingabe für zwei verschiedene Zwecke im selben Programm zu verwenden (wie in BinarySearch). Die Bibliotheken In und Out implementieren darüber hinaus Datentypen mit Instanzmethoden, die es erlauben, Dateien als Einund Ausgabeströme und Webseiten als Eingabeströme zu behandeln. Aus diesem Grund werden wir in Abschnitt 1.2 noch einmal darauf zu sprechen kommen.
Tabelle 1.18 APIs für unsere statischen Methoden zum Lesen und Schreiben von Arrays public class In static
int[]
readInts(String name)
Liest int-Werte.
static
double[]
readDoubles(String name)
Liest double-Werte.
static
String[]
readString(String name)
Liest String-Werte.
public class Out static
void
write(int[] a, String name)
Schreibt int-Werte.
static
void
write(double[] a, String name)
Schreibt double-Werte.
static
void
write(String[] a, String name)
Schreibt String-Werte.
Hinweis 1: Es werden auch andere primitive Datentypen unterstützt. Hinweis 2: StdIn und StdOut werden unterstützt (Argument name weglassen).
Standardgrafik (grundlegende Methoden) Bisher haben sich unsere Eingabe-/Ausgabeabstraktionen ausschließlich auf Textstrings beschränkt. Jetzt führen wir eine Abstraktion ein, die als Ausgabe Grafiken produziert. Die zugrunde liegende Bibliothek ist einfach und ermöglicht uns, von einem visuellen Medium Gebrauch zu machen, sodass wir mit wesentlich mehr Informationen zurecht kommen, als wenn wir nur Text hätten. Wie im Fall der Standardeingabe ist unsere Standardgrafik-Abstraktion in einer eigenen Bibliothek namens StdDraw implementiert (StdDraw.java), die Sie von der Website zum Buch in Ihr Arbeitsverzeichnis herunterladen müssen. Standardgrafik ist von der Konzeption her sehr einfach: Stellen Sie sich ein simples abstraktes Zeichengerät vor, das Linien und Punkte auf eine zweidimensionale Leinwand zeichnen kann und in der Lage ist, auf Befehle zu reagieren, die von unseren Programmen in Form von Aufrufen statischer StdDraw-Methoden an das Ge rät gesendet werden. Hierzu gehören Methoden zum Zeichnen von Linien, Punkten,
61
1
Grundlagen
Textstrings, Kreisen, Rechtecken und Polygonen. Wie die Methoden für die Standardeingabe und -ausgabe sind diese Methoden fast selbsterklärend: StdDraw.line() zeichnet ein gerades Liniensegment, das die Punkte (x0, y0) und (x1, y1) miteinander verbindet. Die Koordinaten der Punkte werden als Argumente übergeben. StdDraw.point() zeichnet einen Punkt an der Stelle (x, y). Die Koordinaten des Punkts werden auch hier als Argumente übergeben. (Siehe hierzu auch die Diagramme in Abbildung 1.8.) Die zugrunde liegende Standardskala ist das Einheitsquadrat (alle Koordinaten zwischen 0 und 1). Die Standardimplementierung zeigt die Leinwand in einem Fenster auf Ihrem Bildschirm, mit schwarzen Linien und Punkten auf einem weißen Hintergrund. StdDraw.point(x0, y0); StdDraw.line(x1, y1, x2, y2);
StdDraw.square(x, y, r);
(1, 1) (x1, y1)
r
(x0, y0)
r
(x, y) (0, 0)
(x2, y2)
StdDraw.circle(x, y, r);
double[] x = {x0, x1, x2, x3}; double[] y = {y0, y1, y2, y3}; StdDraw.polygon(x, y);
(x0, y0) r
(x1, y1)
(x, y) (x3, y3)
(x2, y2)
Abbildung 1.8: Beispiele für StdDraw
Tabelle 1.19 API für unsere Bibliothek der statischen StandardgrafikMethoden public class StdDraw
62
static
void
line(double x0, double y0, double x1, double y1)
static
void
point(double x, double y)
static
void
text(double x, double y, String s)
static
void
circle(double x, double y, double r)
static
void
filledCircle(double x, double y, double r)
static
void
ellipse(double x, double y, double rw, double rh)
static
void
filledEllipse(double x, double y, double rw, double rh)
1.1 Das grundlegende Programmiermodell
Tabelle 1.19 API für unsere Bibliothek der statischen StandardgrafikMethoden (Forts.) public class StdDraw static
void
square(double x, double y, double r)
static
void
filledSquare(double x, double y, double r)
static
void
rectangle(double x, double y, double rw, double rh)
static
void
filledRectangle(double x, double y, double rw, double rh)
static
void
polygon(double[] x, double[] y)
static
void
filledPolygon(double[] x, double[] y)
Standardgrafik (Konfigurationsmethoden) In der Bibliothek finden Sie auch Methoden, mit denen Sie die Skalierung und Größe der Leinwand, die Farbe und Stärke der Linien, die Schriftart und den Zeitpunkt der Zeichenaktion (wichtig für Animationen) ändern können. Als Argumente für setPenColor() stehen Ihnen vordefinierte Farben wie BLACK, BLUE, CYAN, DARK_GRAY, GRAY, GREEN, LIGHT_GRAY, MAGENTA, ORANGE, PINK, RED, BOOK_RED, WHITE und YELLOW zur Verfügung, die als Konstanten in StdDraw definiert sind. (So schreiben wir beispielsweise StdDraw.RED, um auf eine der Farben Bezug zu nehmen.) Das Fenster verfügt außerdem über eine Menüoption, mit der Sie Ihre Grafik in einer Datei speichern können, und zwar in einem Format, das die Veröffentlichung im Web erlaubt.
Tabelle 1.20 API für unsere Bibliothek der statischen StandardgrafikMethoden (Konfigurationsmethoden) public class StdDraw static
void
setXscale(double x0, double x1)
Setzt den x-Bereich auf (x0 , x1) zurück.
static
void
setYscale(double y0, double y1)
Setzt den y-Bereich auf (y0 , y1) zurück.
static
void
setPenRadius(double r)
Setzt den Stiftradius auf r.
static
void
setPenColor(Color c)
Setzt die Stiftfarbe auf c.
static
void
setFont(Font f)
Setzt die Schriftart auf f.
static
void
setCavasSize(int w, int h)
Setzt die Leinwand auf ein w×hFenster.
static
void
clear(Color c)
Löscht die Leinwand und färbt sie mit der Farbe c.
static
void
show(int dt)
Zeigt alles; pausiert dt Millisekunden.
63
1
Grundlagen
Wir verwenden StdDraw in diesem Buch für die Datenanalyse und die Erstellung visueller Repräsentationen von Algorithmen, die sich in der Ausführung befinden. Die nachfolgende Tabelle 1.21 zeigt einige Beispiele; weitere werden wir im weiteren Verlauf des Buches und in den Übungen betrachten. Die Bibliothek unterstützt darüber hinaus Animationen – ein Thema, das hauptsächlich auf der Website zum Buch behandelt wird.
Tabelle 1.21 StdDraw-Beispiele zum Plotten Daten
Plotimplementierung (Codefragment)
Funktionswerte
int N = 100; StdDraw.setXscale(0, N); StdDraw.setYscale(0, N*N); StdDraw.setPenRadius(.01); for (int i = 1; i verwenden, um String-Variablen zu vergleichen? Antwort: Nein. Diese Operatoren sind nur für primitive Datentypen definiert worden. (Tabelle 1.29.) Frage: Was ist das Ergebnis von Divisions- und Modulo-Berechnungen mit negativen Integern? Antwort: Der Quotient von a / b wird abgerundet; der Rest von a % b ist so definiert, dass (a/b) * b + a % b immer gleich a ist. So sind zum Beispiel -14/3 und 14/-3 beide -4, aber -14 % 3 ist -2 und 14 % -3 ist 2. Frage: Warum sagen wir (a && b) und nicht (a & b)? Antwort: Die Operatoren &, | und ^ gehören zu den bitweisen logischen Operationen für Integer-Typen, die auf jede Bitposition Und-, Oder- oder Xor-Operationen ausführen. Demzufolge ist der Wert von 10|6 gleich 14 und der Wert von 10^6 gleich 12. Wir verwenden diese Operatoren in diesem Buch nur selten. Die nur für die booleschen Ausdrücke gültigen Operatoren && und || wurden gesondert aufgenommen und sind Kurzschlussoperatoren: Ein Ausdruck wird von links nach rechts ausgewertet und die Auswertung ist beendet, sobald der logische Wert bekannt ist. Frage: Ist Doppeldeutigkeit in verschachtelten if-Anweisungen ein Problem? Antwort: Ja. Wenn Sie in Java schreiben if if else
entspricht dies dem Code if if { else }
auch wenn Sie vielleicht gedacht hätten, es hieße if { if } else
Auch hier gilt, dass geschweifte Klammern erheblich dazu beitragen können, Fehler dieser Art zu vermeiden.
71
1
Grundlagen
Frage: Was ist der Unterschied zwischen einer for-Schleife und ihrer while-Formulierung? Antwort: Der Code im Kopf der for-Schleife wird dem gleichen Block zugerechnet wie der Rumpf der for-Schleife, weswegen in einer typischen for-Schleife die Inkrementvariable – anders als in einer äquivalenten while-Schleife – nicht für weitere Anweisungen zur Verfügung steht. Dies ist oft ein Grund, eine whileSchleife statt einer for-Schleife zu verwenden. Frage: Einige Java-Programmierer verwenden int a[] anstelle von int[] a, um Arrays zu deklarieren. Wo liegt der Unterschied? Antwort: In Java sind beide Schreibweisen äquivalent und zulässig. Die erste Schreibweise entspricht der Arraydeklaration in C, während letztere der bevorzugte Stil in Java ist, da der Typ der Variablen int[] deutlicher anzeigt, dass es sich dabei um ein Array von Integerwerten handelt. Frage: Warum beginnen Arrayindizes mit 0 anstatt mit 1? Antwort: Diese Konvention stammt noch aus der Zeit der maschinennahen Programmierung, als die Adresse eines Arrayelements berechnet wurde, indem der Index zur Adresse des Arrayanfangs addiert wurde. Hätte man die Indizes mit 1 begonnen, wäre damit entweder eine Menge Platz am Anfang des Arrays oder eine Menge Zeit für die Subtraktion der 1 verschwendet worden. Frage: Wenn a[] ein Array ist, warum gibt StdOut.println(a) einen hexadezimalen Wert wie @f62373 aus anstelle der Elemente des Arrays? Antwort: Gute Frage. Der Befehl gibt die Speicheradresse des Arrays aus, was wohl in den seltensten Fällen gewünscht ist. Frage: Warum verwenden wir nicht die Standardbibliotheken von Java für Eingabe und Grafik? Antwort: Wir verwenden sie ja, aber wir ziehen es vor, mit einfacheren abstrakten Modellen zu arbeiten. Die Java-Bibliotheken hinter StdIn, StdDraw und StdAudio sind auf die produktive Programmierung ausgerichtet, weswegen die Bibliotheken und ihre APIs etwas unhandlich sind. Wenn Sie sich einen Eindruck davon verschaffen wollen, werfen Sie einen Blick in den Code von StdIn.java und StdDraw.java. Frage: Kann mein Programm Daten von der Standardeingabe erneut einlesen? Antwort: Nein. Sie haben nur einen Versuch. Sie können ja auch einen println()Befehl nicht zurücknehmen.
72
Fragen und Antworten
Frage: Was passiert, wenn mein Programm versucht, Daten aus einer Standardeingabe zu lesen, die leer ist? Antwort: Sie erhalten einen Fehler. Die Methode StdIn.isEmpty() ermöglicht Ihnen, solche Fehler zu vermeiden, indem sie prüft, ob noch weitere Eingaben verfügbar sind. Frage: Was bedeutet die Fehlermeldung: Exception in thread "main" java.lang. NoClassDefFoundError: StdIn? Antwort: Sie haben wahrscheinlich vergessen, StdIn.java in Ihrem Arbeitsverzeichnis abzulegen. Frage: Kann eine statische Methode in Java eine andere statische Methode als Argument übernehmen? Antwort: Nein. Gute Frage, da viele andere Programmiersprachen diese Möglichkeit unterstützen.
73
1
Grundlagen
1.1 1.
Allgemeine Übungen Wie lautet jeweils der Wert der folgenden Ausdrücke? a. (0 + 15) / 2 b. 2.0e-6 * 100000000.1 c. true && false || true && true
2.
Wie lauten jeweils der Typ und der Wert der folgenden Ausdrücke? a. (1 + 2.236) / 2 b. 1 + 2 + 3 + 4.0 c. 4.1 >= 4 d. 1 + 2 + "3"
3.
Schreiben Sie ein Programm, das drei Integer als Befehlszeilenargumente übernimmt und gleich ausgibt, wenn alle drei gleich sind, beziehungsweise ansonsten nicht gleich.
4.
Was (wenn überhaupt) ist falsch an den folgenden Anweisungen? a. if (a > b) then c = 0; b. if a > b { c = 0; } c. if (a > b) c = 0; d. if (a > b) c = 0 else b = 0;
5.
Schreiben Sie ein Codefragment, das true ausgibt, wenn die double-Variablen x und y beide zwischen 0 und 1 liegen, und im anderen Fall false.
6.
Was gibt das folgende Programm aus? int f = 0; int g = 1; for (int i = 0; i .001) t = (9.0/t + t) /2.0; StdOut.printf("%.5f\n", t);
b. int sum = 0; for (int i = 1; i < 1000; i++) for (int j = 0; j < i; j++) sum++; StdOut.println(sum);
74
Allgemeine Übungen
c. int sum = 0; for (int i = 1; i < 1000; i *= 2) for (int j = 0; j < 1000; j++) sum++; StdOut.println(sum); 8.
Wie lautet die Ausgabe der folgenden Anweisungen? a. System.out.println('b'); b. System.out.println('b' + 'c'); c. System.out.println((char) ('a' + 4)); Erläutern Sie die jeweiligen Ergebnisse.
9.
Schreiben Sie ein Codefragment, das die binäre Darstellung einer positiven ganzen Zahl N in einem String s ablegt. Lösung: Java stellt hierfür die Methode Integer.toBinaryString(N) zur Verfügung, aber Sinn und Zweck der Übung ist es natürlich zu sehen, wie eine solche Methode implementiert werden könnte. Eine besonders kurze Lösung könnte folgendermaßen aussehen: String s = ""; for (int n = N; n > 0; n /= 2) s = (n % 2) + s;
10.
Was ist falsch an dem folgenden Codefragment? int[] a; for (int i = 0; i < 10; i++) a[i] = i * i;
Lösung: Es weist a[] keinen Speicher mit new zu. Dieser Code führt zu einem Kompilierfehler der Sorte variable a might not have been initialized. 11.
Schreiben Sie ein Codefragment, das den Inhalt eines zweidimensionalen booleschen Arrays ausgibt, wobei * den Wert true und ein Leerzeichen den Wert false repräsentieren soll. Geben Sie Zeilen- und Spaltennummern mit aus.
12.
Was gibt das folgende Codefragment aus? int[] a = new int[10]; for (int i = 0; i < 10; i++) a[i] = 9 - i; for (int i = 0; i < 10; i++) a[i] = a[a[i]]; for (int i = 0; i < 10; i++) System.out.println(i);
13.
Schreiben Sie ein Codefragment, das die Zeilen und Spalten eines zweidimensionalen Arrays mit M Zeilen und N Spalten vertauscht (transponiert) und ausgibt.
14.
Schreiben Sie eine statische Methode lg(), die einen int-Wert N als Argument übernimmt und den größten Integer zurückliefert, der nicht größer ist als der Logarithmus von N zur Basis 2. Verwenden Sie nicht Math.
75
1
Grundlagen
15.
Schreiben Sie eine statische Methode histogram(), die ein Array a[] von intWerten und einen Integer M als Argumente übernimmt und ein Array der Länge M zurückliefert, dessen i-ter Eintrag die Anzahl der Vorkommen des Integers i im Argumentarray angibt. Wenn die Werte in a[] alle zwischen 0 und M-1 liegen, sollte die Summe der Werte in dem zurückgelieferten Array gleich a.length sein.
16.
Wie lautet der Wert von exR1(6): public static String exR1(int n) { if (n c) { t = a; a = c; c = t; } if (b > c) { t = b; b = c; c = t; }
27.
Binomialverteilung: Schätzen Sie die Anzahl der rekursiven Aufrufe, die der folgende Code benötigen würde, um binomial(100, 50, 0.2) zu berechnen: public static double binomial(int N, int k, double p) { if (N == 0 || k == 0) return 1.0; if (N < 0 || k < 0) return 0.0; return (1.0 - p)*binomial(N-1, k, p) + p*binomial(N-1, k-1, p); }
Entwickeln Sie eine bessere Implementierung, die darauf beruht, berechnete Werte in einem Array zu speichern.
78
28.
Duplikate entfernen: Ändern Sie den Testclient in BinarySearch so, dass alle doppelten Schlüssel in der Positivliste nach dem Sortieren entfernt werden.
29.
Gleiche Schlüssel: Ergänzen Sie BinarySearch um eine statische Methode rank(), die einen Schlüssel und ein sortiertes Array von int-Werten (von denen einige gleich sein können) entgegennimmt und die Anzahl der Elemente zurückliefert, die kleiner als der Schlüssel sind. Fügen Sie noch eine weitere ähnliche Methode namens count() hinzu, die die Anzahl der Elemente zurückliefert, die gleich dem Schlüssel sind. Hinweis: Wenn i und j die Werte sind, die von rank(key, a) und count(key a) zurückgeliefert werden, dann sind a[i..i+j-1] die Werte im Array, die gleich dem Schlüssel sind.
30.
Arrayübung: Schreiben Sie ein Codefragment, das ein boolesches N×N-Array a[][] erzeugt, sodass a[i][j] true ist, wenn i und j relativ prim oder teilerfremd sind (d.h. keine gemeinsamen Faktoren haben), beziehungsweise im anderen Fall false.
31.
Zufallsverbindungen: Schreiben Sie ein Programm, das als Befehlszeilenargumente einen Integer N und einen double-Wert p (zwischen 0 und 1) entgegennimmt, N Punkte der Größe 0,05 in gleichmäßigen Abständen auf den Umfang eines Kreises zeichnet und dann mit der Wahrscheinlichkeit p für jedes Punktepaar eine graue Verbindungslinie zwischen den Punkten zeichnet.
32.
Histogramm: Angenommen, der Standardeingabestrom besteht aus einer Folge von double-Werten. Schreiben Sie ein Programm, das einen Integer N und zwei double-Werte l und r über die Befehlszeile entgegennimmt und unter Verwendung von StdDraw ein Histogramm erstellt, das den Bereich (l, r) in N gleich große
Knifflige Aufgaben
Intervalle teilt und dessen Balkenhöhen anzeigen, wie viele Zahlen aus der Standardeingabe jeweils in die einzelnen Intervalle fallen. 33.
Matrixbibliothek: Schreiben Sie eine Bibliothek Matrix, die die folgende API implementiert: public class Matrix static
double
dot(double[] x, double[] y)
Skalarprodukt
static
double[][]
mult(double[][] a, double[][] b)
Matrizenprodukt
static
double[][]
transpose(double[][] a)
Transponierte
static
double[]
mult(double[][] a, double[] x)
Matrix-Vektor-Produkt
static
double[]
mult(double[] y, double[] a)
Vektor-Matrix-Produkt
Entwickeln Sie einen Testclient, der Werte von der Standardeingabe einliest und alle Methoden testet. 34.
Filtern: Welche der folgenden Aufgaben erfordern, dass alle Werte aus der Standardeingabe gespeichert werden (zum Beispiel in einem Array), und welche könnten nur unter Verwendung einer festen Anzahl von Variablen und Arrays fester Größe (nicht abhängig von N) als Filter implementiert werden? Für jede Aufgabe kommt die Eingabe von der Standardeingabe und besteht aus N reellen Zahlen zwischen 0 und 1. – Geben Sie den Maximal- und Minimalwert aus. – Geben Sie den Median der Zahlen aus. – Geben Sie den kt-kleinsten Wert aus (für k kleiner als 100). – Geben Sie die Summe der Quadrate der Zahlen aus. – Geben Sie den Mittelwert von N Zahlen aus. – Geben Sie den Prozentsatz der Zahlen aus, die größer als der Mittelwert sind. – Geben Sie die N Zahlen in aufsteigender Reihenfolge aus. – Geben Sie die N Zahlen in einer zufälligen Reihenfolge aus.
79
1
Grundlagen
1.1 35.
Experimente Würfelsimulation: Der folgende Code berechnet die genaue Wahrscheinlichkeitsverteilung für die Summe zweier Würfel: int SIDES = 6; double[] dist = for (int i = 1; for (int j = dist[i+j]
new double[2*SIDES+1]; i = 0 : "Negativer Index in Methode X";
hinzufügen, die Ihnen genau mitteilt, wo der Fehler aufgetreten ist. Assertionen sind standardmäßig deaktiviert, aber Sie können sie von der Befehlszeile aus aktivieren, indem Sie das Flag -enableassertions (oder kurz -ea) eingeben. Assertionen dienen allein dem Debuggen: Verlassen Sie sich bei der normalen Ausführung Ihres Programms nicht darauf, da sie deaktiviert sein könnten. Wenn Sie sich zum Systemprogrammierer ausbilden lassen, werden Sie lernen, wie Sie mit Assertionen gewährleisten, dass Ihr Code niemals mit einem Systemfehler endet oder in eine Endlosschleife eintritt. Dieses Konzept findet sich im sogenannten Design-by-Contract-Programmiermodell. Der Entwickler eines Datentyps formuliert eine Vorbedingung (die Bedingung, die der Client verspricht, bei Aufruf einer Methode zu erfüllen), eine Nachbedingung (die Bedingung, die die Implementierung verspricht, bei Rückkehr aus einer Methode einzuhalten) und Nebeneffekte (alle anderen Zustandsänderungen, die die Methode verursachen könnte). Während der Entwicklung können diese Bedingungen mit Assertionen getestet werden. Viele Programmierer verwenden Assertionen bewusst als Unterstützung beim Debuggen.
Zusammenfassung Die in diesem Abschnitt besprochenen Sprachmechanismen belegen, dass effektives Datentypdesign mit nicht trivialen Problemen verbunden ist, die nicht leicht zu lösen sind. Experten diskutieren immer noch, wie man einige der hier besprochenen DesignKonzepte am besten unterstützt. Warum erlaubt Java keine Funktionen als Argumente? Warum kopiert Matlab Arrays, die als Argumente an Funktionen übergeben wurden? Häufig sind Beschwerden über Features in einer Programmiersprache der erste Schritt in Richtung, selbst ein Programmiersprachen-Designer zu werden. Sollten Sie Ihre beruflichen Ziele woanders sehen, empfiehlt es sich, möglichst nur auf weitverbreitete Sprachen zurückzugreifen. Die meisten Systeme verfügen über umfangreiche Bibliotheken, die Sie – wo möglich – unbedingt verwenden sollten. Darüber hinaus können Sie Ihren Client-Code in vielen Fällen dadurch vereinfachen – und sich selbst das Leben erleichtern –, dass Sie eigene Abstraktionen entwickeln, die sich leicht auf andere Sprachen übertragen lassen. Ihr Hauptziel sollte es jedenfalls sein, Datentypen zu entwickeln, sodass der Großteil Ihrer Arbeit auf einer Abstraktionsebene erfolgt, die dem vorliegenden Problem angemessen ist.
129
1
Grundlagen
Tabelle 1.42 fasst die verschiedenen Arten der von uns betrachteten Java-Klassen zusammen.
Tabelle 1.42 Java-Klassen (Datentypimplementierungen) Art der Klasse
Beispiele
Merkmale
Statische Methoden
Math StdIn StdOut
Keine Instanzvariablen
Unveränderlicher abstrakter Datentyp
Date Transaction String Integer
Instanzvariablen alle private Instanzvariablen alle final Defensive Kopie für Referenztypen
Veränderlicher abstrakter Datentyp
Counter Accumulator
Instanzvariablen alle private Nicht alle Instanzvariablen final
Abstrakter Datentyp mit E/A-Nebeneffekten
130
VisualAccumulator In Out Draw
Instanzvariablen alle private Instanzmethoden für Ein-/ Ausgabe
Fragen und Antworten
1.2
Fragen und Antworten
Frage: Warum machen wir uns die Mühe mit der Datenabstraktion? Antwort: Sie hilft uns, zuverlässigen und korrekten Code zu schreiben. In der Präsidentschaftswahl 2000 beispielsweise erhielt Al Gore an einer elektronischen Wahlurne in Volusia County, Florida, 16022 negative Stimmen. Die Zählervariable war in der Software der Wahlurne nicht ordnungsgemäß gekapselt! Frage: Warum wird zwischen primitiven Datentypen und Referenzdatentypen unterschieden? Warum gibt es nicht nur Referenztypen? Antwort: Leistung. Java stellt uns die Referenztypen Integer, Double usw. zur Verfügung, die den integrierten primitiven Datentypen entsprechen und von Programmierern verwendet werden können, die es vorziehen, nicht zwischen primitiven Datentypen und Referenztypen zu unterscheiden. Primitive Datentypen sind jedoch näher an den Datentypen, die von der Computerhardware unterstützt werden, weswegen Programme, die mit primitiven Datentypen arbeiten, sich in der Regel schneller ausführen lassen als solche, die die entsprechenden Referenztypen verwenden. Frage: Müssen Datentypen abstrakt sein? Antwort: Nein. In Java können Datentypen auch public und protected sein, um bei Bedarf Clients die Möglichkeit zu bieten, Instanzvariablen direkt zu referenzieren. Wie bereits im Text beschrieben, werden die Vorteile, dem Client-Code die direkte Referenzierung der Daten zu ermöglichen, durch die Nachteile der Abhängigkeit von einer bestimmten Repräsentation zunichte gemacht. Deshalb werden alle Instanzvariablen in unserem Code als private deklariert. Wir verwenden auch gelegentlich private-Instanzmethoden, um Code zwischen public-Methoden zu teilen. Frage: Was passiert, wenn ich new bei der Erzeugung eines Objekts vergesse? Antwort: Für Java sieht das aus, als ob Sie eine statische Methode aufrufen wollten, die einen Wert von dem angegebenen Objekttyp zurückliefert. Da Sie keine solche Methode definiert haben, lautet die Fehlermeldung genauso, wie wenn Sie auf ein nicht definiertes Symbol verweisen würden. Wenn Sie zum Beispiel folgenden Code kompilieren: Counter c = Counter("test");
erhalten Sie diese Fehlermeldung: cannot find symbol symbol : method Counter(String)
Sie erhalten die gleiche Art von Fehlermeldung, wenn Sie die falsche Anzahl von Argumenten für einen Konstruktor übergeben.
131
1
Grundlagen
Frage: Was passiert, wenn ich bei der Erzeugung eines Arrays von Objekten new vergesse? Antwort: Sie müssen new für jedes Objekt verwenden, das Sie erzeugen, sodass Sie, wenn Sie ein Array von N Objekten erzeugen, new N+1-mal verwenden müssen: einmal für das Array und einmal für jedes der N Objekte. Wenn Sie vergessen, das Array zu erzeugen: Counter[] a; a[0] = new Counter("test");
erhalten Sie die gleiche Fehlermeldung, wie wenn Sie versuchen, einer nicht initialisierten Variablen einen Wert zuzuweisen: variable a might not have been initialized a[0] = new Charge(0.51, 0.63, 21.3); ^
Wenn Sie dagegen vergessen, new bei der Erzeugung eines Arrayelements zu verwenden, und dann versuchen, für das Element eine Methode aufzurufen Counter[] a = new Counter [2]; a[0].increment();
erhalten Sie eine NullPointerException. Frage: Warum schreiben wir nicht StdOut.println(x.toString()), um Objekte auszugeben, die keine Strings sind? Antwort: Der obige Code funktioniert einwandfrei, aber Java erspart uns die damit verbundene Tipparbeit, indem es toString() automatisch für jedes Objekt aufruft, da println() eine Methode aufweist, die ein Object als Argument übernimmt. Frage: Was ist ein Zeiger? Antwort: Gute Frage. Vielleicht sollte diesem eine NullReferenceException entsprechen. Wie bei dem Konzept der Java-Referenzen können Sie sich einen Zeiger als eine Maschinenadresse vorstellen. In vielen Programmiersprachen ist der Zeiger ein primitiver Datentyp, den Programmierer auf verschiedene Art und Weise manipulieren können. Die Programmierung mit Zeigern ist allerdings von Natur aus sehr fehleranfällig, weswegen Sprachdesigner beim Entwerfen der Operationen für Zeiger sehr behutsam sein müssen, wollen sie die Programmierer vor ungewolltem Missbrauch und Fehlern schützen. Java vertritt hier einen ziemlich extremen Standpunkt (der heutzutage aber von vielen Sprachdesignern bevorzugt wird). In Java gibt es nur einen Weg, eine Referenz zu erzeugen (new), und nur einen Weg, eine Referenz zu ändern (mittels einer Zuweisung). Für die Programmierer bedeutet dies, dass sie Referenzen lediglich erzeugen und kopieren können. Hinzu kommt, dass Java garantieren kann, dass jede Referenz auf ein Objekt des angegebenen Typs zeigt (und für die Aufräumarbeiten festlegen kann, welche Objekte nicht mehr benötigt werden), weswegen Java-Referenzen im Programmiersprachen-Jargon auch als sichere Zeiger bezeichnet werden. Programmierer, die
132
Fragen und Antworten
gewohnt sind, in ihrem Code Zeiger direkt zu manipulieren, sind daher meistens der Ansicht, dass es in Java gar keine echten Zeiger gibt. Gleichzeitig wird immer noch darüber gestritten, ob es überhaupt wünschenswert ist, mit unsicheren Zeigern zu arbeiten. Frage: Wo finde ich nähere Informationen darüber, wie in Java Referenzen und die Speicherbereinigung implementiert sind? Antwort: Java-Systeme können sich stark voneinander unterscheiden. Ein naheliegendes Konzept wäre, Zeiger (Maschinenadressen) zu verwenden; ein anderes Konzept basiert auf dem Einsatz von Handles (Zeiger auf Zeiger). Ersteres bietet schnelleren Zugriff auf die Daten, Letzteres eine bessere Speicherbereinigung. Frage: Was bedeutet es eigentlich, einen Namen zu importieren? Antwort: Nicht viel: Es erspart Ihnen lediglich ein wenig Tipparbeit. Anstatt die import-Anweisung zu verwenden, könnten Sie auch überall in Ihrem Code java.util.Arrays schreiben. Frage: Welche Probleme entstehen durch die Implementierungsvererbung? Antwort: Subtyping erschwert die modulare Programmierung in zweierlei Hinsicht. Zum einen betrifft jede Änderung in der Superklasse auch alle Subklassen. Die Subklasse kann also nicht unabhängig von der Superklasse entwickelt werden; sie ist im Gegenteil vollständig von der Superklasse abhängig. Dieses Problem wird auch als FBC-Problem, d.h. Problem der fragilen Basisklasse (fragile base class), bezeichnet. Zum anderen kann der Subklassencode, der Zugriff auf die Instanzvariablen hat, die Absicht des Superklassencodes untergraben. Der Autor einer Klasse wie Counter für ein Wahlsystem könnte zum Beispiel viel Mühe darauf verwendet haben, dass Counter immer nur um eins erhöht werden kann (erinnern wir uns an das Al-Gore-Problem). Doch eine Subklasse mit vollem Zugriff auf die Instanzvariablen kann sie trotzdem in jeden beliebigen Wert ändern. Frage: Wie mache ich eine Klasse unveränderlich? Antwort: Um die Unveränderlichkeit eines Datentyps sicherzustellen, der eine Instanzvariable eines veränderlichen Typs enthält, müssen wir eine lokale Kopie, auch defensive Kopie (defensive copy) genannt, erstellen. Die Erstellung der Kopie ist eine Herausforderung; aber es gilt auch noch sicherzustellen, dass keine der Instanzmethoden die Werte ändert. Frage: Was ist null? Antwort: Es ist ein Literal, das sich auf kein Objekt bezieht. Der Aufruf einer Methode unter Verwendung der null-Referenz ist sinnlos und führt zu einer NullPointerException. Wenn Sie diese Fehlermeldung erhalten, prüfen Sie, ob Ihr Konstruktor alle seine Instanzvariablen ordnungsgemäß initialisiert hat.
133
1
Grundlagen
Frage: Kann eine Klasse, die einen Datentyp implementiert, statische Methoden aufweisen? Antwort: Natürlich, beispielsweise enthalten all unsere Klassen eine main()Methode. So bietet es sich zum Beispiel an, statische Methoden für Operationen hinzuzufügen, die mehrere Objekte betreffen, von denen sich aber keines als dasjenige Objekt aufdrängt, für das die Methode aufgerufen werden sollte. So könnten wir beispielsweise innerhalb von Point eine statische Methode wie die folgende definieren: public static double distance(Point a, Point b) { return a.distTo(b); }
Oft können wir mit solchen Methoden den Code klarer halten. Frage: Gibt es in Klassen neben den Parametern, lokalen Variablen und Instanzvariablen noch andere Arten von Variablen? Antwort: Wenn Sie eine Variable mit dem Schlüsselwort static deklarieren (außerhalb irgendeines Typs), erzeugen Sie damit einen gänzlich anderen Typ von Variable, die als statische Variable bezeichnet wird. Wie Instanzvariablen kann auf statische Variablen von jeder Methode in der Klasse aus zugegriffen werden. Sie sind jedoch mit keinem Objekt verbunden. In älteren Programmiersprachen werden solche Variablen als globale Variablen bezeichnet, da ihr Gültigkeitsbereich global ist. In modernen Programmiersprachen liegt unser Hauptaugenmerk darauf, den Gültigkeitsbereich einzuschränken, sodass wir solche Variablen äußerst selten verwenden. Und wenn wir sie einsetzen, werden wir Sie darauf aufmerksam machen. Frage: Was ist eine „deprecated-Methode“? Antwort: Eine „deprecated-Methode“ ist eine veraltete Methode, die nicht länger voll unterstützt wird, aber aus Kompatibilitätsgründen in der API belassen wird. Zum Beispiel gab es in Java früher einmal eine Methode Character.isSpace() und Programmierer schrieben Programme, die sich auf das Verhalten dieser Methode verließen. Als die Java-Designer später zusätzlich Unicode-Whitespacezeichen unterstützen wollten, konnten sie das Verhalten von isSpace() nicht ändern, ohne die Client-Programme unbrauchbar zu machen. Deshalb fügten sie stattdessen eine neue Methode Character.isWhiteSpace() hinzu und stuften die alte Methode als „deprecated“ ab. Diese Vorgehensweise garantiert die Abwärtskompatibilität, führt allerdings auf die Dauer zu komplizierten APIs. Manchmal werden ganze Klassen als „deprecated“ erklärt. So hat Java beispielsweise die Klasse java.util.Date als veraltet eingestuft, um die Internationalisierung besser zu unterstützen.
134
Allgemeine Übungen
1.2
Allgemeine Übungen
1.
Schreiben Sie einen PointD-Client, der einen int-Wert N über die Befehlszeile entgegennimmt, N Zufallspunkte im Einheitsquadrat erzeugt und die Entfernung des Punktepaares berechnet, das am nächsten zusammensteht.
2.
Schreiben Sie einen Interval1D-Client, der einen int-Wert N als Befehlszeilenargument entgegennimmt, N Intervalle (jeweils definiert durch ein Paar von doubleWerten) von der Standardeingabe einliest und alle Paare ausgibt, die sich schneiden.
3.
Schreiben Sie einen Interval2D-Client, der N, min und max als Befehlszeilenargumente entgegennimmt und N zufällige 2-D-Intervalle erzeugt, deren Breite und Höhe im Einheitsquadrat gleichmäßig zwischen min und max verteilt sind. Zeichnen Sie diese mit StdDraw und geben Sie die Anzahl der Intervallpaare aus, die sich schneiden, und die Anzahl der Intervalle, die ineinanderliegen.
4.
Was gibt das folgende Codefragment aus? String string1 = "hello"; String string2 = string1; string1 = "world"; StdOut.println(string1); StdOut.println(string2);
5.
Was gibt das folgende Codefragment aus? String s = "Hello World"; s.toUpperCase(); s.substring(6, 11); StdOut.println(s);
Antwort: "Hello World". String-Objekte sind unveränderlich – String-Methoden liefern jeweils ein neues String-Objekt mit dem gewünschten Wert (aber sie ändern nicht den Wert des Objekts, das verwendet wurde, um sie aufzurufen). Der obige Code ignoriert die zurückgelieferten Objekte und gibt einfach den ursprünglichen String aus. Um "WORLD" auszugeben, müssten Sie s = s.toUpperCase() und s = s.substring(6, 11) schreiben. 6.
Ein String s ist eine zyklische Rotation eines Strings t, wenn die Zeichen um eine beliebige Anzahl von Positionen im Kreis verschoben werden. So ist zum Beispiel ACTGACG eine zyklische Verschiebung von TGACGAC und umgekehrt. Diesen Zustand festzustellen, ist wichtig bei der Untersuchung von Genomsequenzen. Schreiben Sie ein Programm, das prüft, ob zwei Strings s und t zyklische Verschiebungen voneinander sind. Hinweis: Die Lösung besteht aus einer einzigen Zeile und arbeitet mit indexOf(), length() und Stringverkettung.
135
1
Grundlagen
7.
Wie lautet der Rückgabewert der folgenden rekursiven Funktion? public static String mystery(String s) { int N = s.length(); if (N Integer) int i = stack.pop(); // Autounboxing (Integer -> int)
Die automatische Umwandlung eines primitiven Typs in einen Wrappertyp wird als Autoboxing bezeichnet und die automatische Umwandlung eines Wrappertyps in einen primitiven Typ als Autounboxing. In diesem Beispiel wandelt Java automatisch den primitiven Wert 17 in den Typ Integer um, wenn wir ihn der Methode push() übergeben (Autoboxing). Die Methode pop() liefert einen Integer zurück, den Java in einen intWert umwandelt, bevor er der Variablen i zugewiesen wird (Autounboxing).
Iterierbare Collections In vielen Anwendungen ist es für den Client nur wichtig, dass jedes der Elemente in irgendeiner Art und Weise verarbeitet wird beziehungsweise dass über die Elemente in der Collection iteriert wird. Dieses Paradigma ist so wichtig, dass es in Java und vielen anderen modernen Programmiersprachen höchste Priorität genießt (d.h., es wird durch einen speziellen Mechanismus der Programmiersprache und nicht nur durch die Bibliotheken unterstützt). Es erlaubt uns, klaren und kompakten Code zu schreiben, der nicht von den Details der Implementierung einer Collection abhängig ist. Angenommen ein Client erzeugt wie folgt eine Collection von Transaktionen in einer Queue: Queue collection = new Queue();
Ist die Collection iterierbar, so kann der Client eine Liste der Transaktionen mit nur einer Anweisung ausgeben: for (Transaction t : collection) { StdOut.println(t); }
Dieses Konstrukt wird als foreach-Anweisung bezeichnet: Sie können die for-Anweisung lesen als Für jede (for each) Transaktion t in der Collection führe den folgenden Codeblock aus. Dieser Client-Code muss nichts über die Repräsentation oder die Implementierung der Collection wissen, er möchte lediglich jedes Element in der Collection verarbeiten. Die gleiche for-Schleife würde auch mit einer Bag von Transaktionen oder irgendeiner anderen iterierbaren Collection funktionieren. Wir können uns kaum klareren und kompakteren Code vorstellen. Eine Collection zu implementieren, die diesen Code unterstützt, erfordert allerdings einiges an Mehrarbeit. Es fällt auf, dass die einzigen Unterschiede zwischen den APIs für Stack und Queue ihre Namen und die Namen ihrer Methoden sind. Diese Feststellung legt nahe, dass wir nicht einfach alle Eigenschaften eines Datentyps durch eine Liste von Methodensignaturen ausdrücken können. In diesem Fall steht die eigentliche Spezifikation in
142
1.3 Multimengen, Warteschlangen und Stapel
den Beschreibungen, nach welchen Regeln ein Element zum Löschen (oder zur nächsten Verarbeitung in der foreach-Anweisung) ausgewählt wird. Unterschiede in diesen Regeln sind von grundlegender Bedeutung, Teil der API und vor allem für die Entwicklung von Client-Code besonders wichtig.
Multimengen Bags, die im Deutschen auch Multimengen genannt wer- ein Beutel den, sind Collections, die das Entfernen von Elementen Murmeln nicht unterstützen – ihr Zweck ist es, Clients die Möglichkeit zu bieten, Elemente zu sammeln und dann darüber zu iterieren (der Client kann auch prüfen, ob eine Bag leer ist, oder feststellen, wie viele Elemente sich darin befinden). Die Reihenfolge beim Iterieren ist nicht vorgegeben und sollte für den Client auch keine Rolle spielen. add( ) Zur Veranschaulichung dieses Konzepts stellen Sie sich einen passionierten Murmelsammler vor, der seine Murmeln nacheinander in einen Beutel (bag) steckt und im Bedarfsfall im Beutel nach einer bestimmten Murmel sucht, die eine bestimmte Eigenschaft aufweist. Mit unserer Bag-API können Clients Elemente zu einer Multimenge add( ) hinzufügen und sie alle bei Bedarf mit einer foreachAnweisung verarbeiten. Ein solcher Client könnte auch einen Stapel oder eine Warteschlange verwenden, aber um zu betonen, dass die Reihenfolge, in der die Elemente verarbeitet werden, unerheblich ist, eignet sich eine Bag besser. Die Klasse Stats aus Listing 1.26 veranschaulicht einen typischen Bag-Client. Die Aufgabe besteht lediglich for (Marble m : bag) darin, den Mittelwert und die Standardabweichung der double-Werte aus der Standardeingabe zu berechnen. Werden über die Standardeingabe N Zahlen eingelesen, berechnen Sie deren Mittelwert, indem Sie die Zahlen verarbeitet jede Murmel m (in beliebiger Reihenfolge) addieren und dann die Gesamtsumme durch N teilen; für die Stichproben-Standardabweichung addieren Sie die Abbildung 1.30: Operationen auf Quadrate der Differenz zwischen jeder Zahl und dem Mit- einer Bag telwert, teilen das Ganze durch N−1 und ziehen daraus die Quadratwurzel. Die Reihenfolge, in der die Zahlen betrachtet werden, spielt für keine dieser Berechnungen eine Rolle, sodass wir sie in einer Bag speichern und mit einem foreach-Konstrukt beide Summen berechnen. Hinweis: Es wäre möglich, die Standardabweichung zu berechnen, ohne alle Zahlen zu speichern (wie wir es für den Mittelwert in Accumulator gemacht haben – siehe Übung 1.2.18). Für kompliziertere statistische Berechnungen ist es jedoch erforderlich, alle Zahlen in einer Bag zu halten.
143
1
Grundlagen
public class Stats { public static void main(String[] args) { Bag numbers = new Bag(); while (!StdIn.isEmpty()) numbers.add(StdIn.readDouble()); int N = numbers.size(); double sum = 0.0; for (double x : numbers) sum += x; double mean = sum/N; sum = 0.0; for (double x : numbers) sum += (x - mean)*(x - mean); double std = Math.sqrt(sum/(N-1)); StdOut.printf("Mean: %.2f\n", mean); StdOut.printf("Std dev: %.2f\n", std); } } Listing 1.26: Typischer Bag-Client
% java Stats 100 99 101 120 98 107 109 81 101 90 Mean: 100.60 Std dev: 10.51 Listing 1.27: Anwendung
FIFO-Warteschlangen Eine FIFO-Warteschlange (queue) ist eine Collection, die auf der First-in-first-out-Strategie (FIFO) basiert. Die Strategie, Aufgaben in der Reihenfolge zu erledigen, in der sie anfallen, begegnet uns jeden Tag: Menschenschlangen vor der Theaterkasse, Autos vor dem Mautschalter bis hin zu den Aufgaben, die darauf warten, von einer Anwendung auf Ihrem Computer abgearbeitet zu werden. Ein fundamentales Prinzip jeder Dienstleistungsstrategie ist die Fairness. Der erste Gedanke, der den meisten im Zusammen-
144
1.3 Multimengen, Warteschlangen und Stapel
hang mit Fairness einfällt, ist, dass derjenige, der am längsten gewartet hat, als Erstes bedient werden sollte. Das entspricht genau dem FIFO-Verhalten. Warteschlangen sind zudem ein natürliches Modell für viele alltägliche Phänomene und spielen in vielen Bereichen eine wichtige Rolle. Wenn ein Client mit dem foreach-Konstrukt über die Elemente in einer Warteschlange iteriert, werden die Elemente in der Reihenfolge verarbeitet, in der sie der Warteschlange hinzugefügt wurden. Warteschlangen werden in Anwendungen immer dann eingesetzt, wenn Sie Elemente unter Wahrung ihrer relativen Reihenfolge in einer Collection speichern wollen: Sie werden in der gleichen Reihenfolge entnommen, in der sie eingefügt wurden. So ist beispielsweise der Client in Listing 1.28 eine mögliche Implementierung der statischen Methode readInts() aus unserer In-Klasse. Diese Methode löst für den Client das Problem, dass er Zahlen aus einer Datei in ein Array einlesen kann, ohne zuvor die Dateigröße zu kennen. Wir stellen die Zahlen aus der Datei in eine Warteschlange (enqueue), ermitteln mit der Queue-Methode size() die Größe, die das Array haben muss, erzeugen das Array und entfernen die Zahlen aus der Warteschlange (dequeue), um sie im Array zu speichern. Eine Warteschlange ist für dieses Szenario bestens geeignet, da sie sicherstellt, dass die Zahlen in der Reihenfolge im Array abgelegt werden, in der sie auch in der Datei stehen. (Würde diese Reihenfolge keine Rolle spielen, hätten wir auch eine Bag verwenden können.) Der Code wandelt mit Autoboxing bzw. Autounboxing den primitiven int-Typ des Clients in den Integer-Wrappertyp der Warteschlange um und vice versa. Kundenschalter
0
Kundenschlange 1
2
neuer Kunde am Ende enqueue 3
0
1
2
3
neuer Kunde am Ende enqueue 0
4
1
2
3
4
der Erste verlässt die Schlange dequeue 0
0
1
2
3
2
3
4
4
der Nächste verlässt die Schlange dequeue 1
1
Abbildung 1.31: Eine typische FIFO-Warteschlange
145
1
Grundlagen
public static int[] readInts(String name) { In in = new In(name); Queue q = new Queue(); while (!in.isEmpty()) q.enqueue(in.readInt()); int N = q.size(); int[] a = new int[N]; for (int i = 0; i < N; i++) a[i] = q.dequeue(); return a; } Listing 1.28: Ein Queue-Beispielclient
Stapel Ein Stapel (oder auch Kellerspeicher oder Dokumentenstapel Stack) ist eine Collection, die auf dem LIFOPrinzip (Last-in-First-out) basiert. Post auf dem Schreibtisch wird meist in Form eines neues (rotes) Stapels abgelegt. Die neu eingegangene Post Dokument wird wird obenauf gelegt und dann jeweils zum oben abgelegt ) push( Lesen von oben herunter geholt. Heutzutage ist nicht mehr so viel Papier im Umlauf wie früher, aber das hier beschriebene Organisaneues (schwarzes) Dokument wird tionsprinzip liegt einer Reihe von Anwenoben abgelegt dungen zugrunde, die Sie regelmäßig auf push( ) Ihrem Computer nutzen. So ist zum Beispiel auch die E-Mail-Ablage häufig als Stapel organisiert, wobei die neuesten Nachrichten schwarzes oben angezeigt und auch von dort entnomDokument men werden (last in, first out). Der Vorteil = pop() wird oben entnommen dieser Strategie ist, dass wir die neuen, interessanten E-Mails möglichst sofort sehen. Der Nachteil ist, dass wir den Stapel rotes Dokument vielleicht niemals ganz abarbeiten und wird oben = pop() daher so manche alte E-Mail ungelesen entnommen bleibt. Wahrscheinlich kennen Sie vom Surfen im Web noch ein weiteres gutes Beispiel für einen Stapel. Wenn Sie einen Abbildung 1.32: Operationen auf einem Stapel Hyperlink anklicken, zeigt Ihr Browser eine neue Seite an (und legt sie oben auf einem Stapel ab). Sie können weitere Hyperlinks anklicken, um neue Seiten zu besuchen, aber Sie können immer zur vorherigen Seite zurückkehren, indem Sie auf den Zurück-Schalter klicken (und so die Seite aus dem Stapel entfernen). Die Last-in-First-out-Strategie eines Stapels bietet genau das Verhal-
146
1.3 Multimengen, Warteschlangen und Stapel
ten, das Sie in diesem Fall benötigen. Wenn ein Client mit dem foreach-Konstrukt über die Elemente in einem Stapel iteriert, werden die Elemente in der umgekehrten Reihenfolge verarbeitet, in der sie hinzugefügt wurden. Ein typisches Einsatzgebiet für einen Stapel-Iterator ist, wenn Sie Elemente in einer Sammlung speichern und dabei gleichzeitig ihre relative Reihenfolge umkehren wollen. So kehrt beispielsweise der Client Reverse aus Listing 1.29 die Reihenfolge der Integer von der Standardeingabe um. Auch in diesem Fall müssen Sie im Vorfeld nicht wissen, wie viele Integer es gibt. Stapel sind von grundlegender Bedeutung und aus der Programmierung nicht mehr wegzudenken, wie das ausführliche Beispiel zeigt, das wir als Nächstes betrachten wollen. public class Reverse { public static void main(String[] args) { Stack stack; stack = new Stack(); while (!StdIn.isEmpty()) stack.push(StdIn.readInt()); for (int i : stack) StdOut.println(i); } } Listing 1.29: Ein Stack-Beispielclient
Auswertung arithmetischer Ausdrücke Als weiteres Beispiel für einen Stapel-Client betrachten wir ein klassisches Beispiel, das zudem zeigt, wie nützlich Generics sind. Einige der ersten Programme, die wir in Abschnitt 1.1 vorgestellt haben, dienten der Berechnung von arithmetischen Ausdrücken wie: ( 1 + ( ( 2 + 3 ) * ( 4 * 5 ) ) )
Wenn Sie 4 mit 5 multiplizieren, 3 und 2 addieren, die Ergebnisse multiplizieren und dann 1 hinzufügen, erhalten Sie den Wert 101. Doch wie geht das Java-System bei dieser Berechnung vor? Ohne allzu sehr auf die Details des Java-Systems eingehen zu müssen, können wir die wesentlichen Konzepte erarbeiten, indem wir einfach ein Java-Programm schreiben, das einen String als Eingabe entgegennimmt (den Ausdruck) und als Ausgabe die Zahl erzeugt, die durch den Ausdruck repräsentiert wird. Der Einfachheit halber beginnen wir mit der folgenden expliziten rekursiven Definition: Ein arithmetischer Ausdruck ist entweder eine Zahl oder eine linke Klammer gefolgt von einem arithmetischen Ausdruck gefolgt von einem Operator gefolgt von einem weiteren arithmetischen Ausdruck gefolgt von einer rechten Klammer. Um die Aufgabe nicht unnötig zu komplizieren, haben wir hier die Definition für vollständig geklammerte arithmetische Ausdrücke gewählt, die klar angibt, welche Operatoren auf welche Operanden anzuwenden sind – im Gegensatz zu der Ihnen sicherlich etwas vertrauteren Schreibweise der Form 1 + 2 ∗ 3, in der Prioritätsregeln die Klammern ersetzen. Zwar könnten wir mithilfe der gleichen grundlegenden Mechanismen,
147
1
Grundlagen
die wir in der Folge betrachten werden, auch Prioritätsregeln verarbeiten, doch wir wollen das Beispiel bewusst einfach halten. Konkret unterstützen wir die bekannten Binäroperatoren *, +, – und / sowie den Quadratwurzeloperator sqrt, der nur ein Argument übernimmt. Wir könnten problemlos weitere Operatoren oder Arten von Operatoren zulassen, um eine größere Klasse von bekannten mathematischen Ausdrücken zu unterstützen, einschließlich trigonometrischer, exponentieller und logarithmischer Funktionen. Unser Schwerpunkt liegt aber auf der richtigen Interpretation des Strings aus Klammern, Operatoren und Zahlen, damit wir die arithmetischen Lowlevel-Operationen, die auf jedem Computer zur Verfügung stehen, in der rechten Reihenfolge ausführen. Wie können wir also einen arithmetischen Ausdruck – einen String von Zeichen – in den Wert umwandeln, den er repräsentiert? Ein bemerkenswert einfacher Algorithmus, der von Edsgar Dijkstra in den 1960ern entwickelt wurde, verwendet für diese Aufgabe zwei Stapel (einen für Operanden und einen für Operatoren). Ein Ausdruck besteht aus Klammern, Operatoren und Operanden (Zahlen). Wenn wir von links nach rechts vorgehen und uns die Entitäten einzeln vornehmen, setzen wir die Stapel gemäß der vier möglichen Fälle wie folgt ein:
Wir legen Operanden auf dem Operandenstapel ab. Wir legen die Operatoren auf dem Operatorstapel ab. Wir ignorieren die linken Klammern. Wenn wir auf eine rechte Klammer treffen, löschen wir einen Operator und die dafür erforderliche Anzahl an Operanden und legen auf dem Operandenstapel das Ergebnis ab, nachdem wir den Operator auf die Operanden angewendet haben. Nachdem die letzte rechte Klammer verarbeitet wurde, liegt nur noch ein Wert auf dem Stapel, und zwar der Wert des Ausdrucks. Diese Methode mag auf dem ersten Blick mysteriös erscheinen, aber Sie können sich leicht davon zu überzeugen, dass sie den korrekten Wert zurückliefert: Jedes Mal, wenn der Algorithmus auf einen Teilausdruck trifft, der aus zwei Operanden besteht, die durch einen Operator getrennt und in Klammern gefasst sind, wird das Ergebnis der Operation auf diese Operanden auf dem Operandenstapel abgelegt. Das Ergebnis ist das gleiche, wie wenn in der Eingabe anstelle des Teilausdrucks dieser Wert gestanden hätte, sodass wir den Teilausdruck gedanklich durch den Wert ersetzen können, um einen Ausdruck zu erhalten, der das gleiche Ergebnis liefert. Wir können diese Ersetzung wiederholt anwenden, bis wir nur noch einen Wert übrig haben. Folglich berechnet der Algorithmus für die folgenden Ausdrücke den immer gleichen Wert: ( 1 ( 1 ( 1 ( 1 101
+ + + +
( ( ( 5 ( 5 100
2 + 3 ) * ( 4 * 5 ) ) ) * ( 4 * 5 ) ) ) * 20 ) ) )
Evaluate (Listing 1.30) ist eine Implementierung dieses Verfahrens. Der Code ist ein
einfaches Beispiel für einen Interpreter: ein Programm, das eine als String angegebene Berechnung deutet und ausführt, um das gewünschte Ergebnis zu erhalten.
148
1.3 Multimengen, Warteschlangen und Stapel
Listing 1.30: Dijkstras Algorithmus zur Ausdrucksauswertung mit zwei Stapeln public class Evaluate { public static void main(String[] args) { Stack ops = new Stack(); Stack vals = new Stack(); while (!StdIn.isEmpty()) { // Falls das eingelesene Token ein Operator ist, push-Operation ausführen. String s = StdIn.readString(); if (s.equals("(")) ; else if (s.equals("+")) ops.push(s); else if (s.equals("-")) ops.push(s); else if (s.equals("*")) ops.push(s); else if (s.equals("/")) ops.push(s); else if (s.equals("sqrt")) ops.push(s); else if (s.equals(")")) { // Falls das Token ")" ist, Token entfernen, auswerten // und Ergebnis im Stapel einfügen. String op = ops.pop(); double v = vals.pop(); if (op.equals("+")) v = vals.pop() + v; else if (op.equals("-")) v = vals.pop() - v; else if (op.equals("*")) v = vals.pop() * v; else if (op.equals("/")) v = vals.pop() / v; else if (op.equals("sqrt")) v = Math.sqrt(v); vals.push(v); } // Wenn Token weder Operator noch Klammer, double-Wert einfügen. else vals.push(Double.parseDouble(s)); } StdOut.println(vals.pop()); } }
Dieser Stack-Client verwendet zwei Stapel, um arithmetische Ausdrücke auszuwerten, und veranschaulicht dabei ein für die Programmierung wichtiges Konzept: die Interpretation eines Strings als Programm und die Ausführung dieses Programms, um das gewünschte Ergebnis zu erhalten. Dank Generics können wir den Code einer einzigen Stack-Implementierung dazu nutzen, sowohl einen Stapel von String-Werten als auch einen weiteren Stapel von Double-Werten zu erzeugen. Der Einfachheit halber geht dieser Code davon aus, dass der Ausdruck vollständig geklammert ist und die Zahlen und Zeichen durch Leerzeichen getrennt sind. % java Evaluate ( 1 + ( ( 2 + 3 ) * ( 4 * 5 ) ) ) 101.0 % java Evaluate ( ( 1 + sqrt ( 5.0 ) ) / 2.0 ) 1.618033988749895
149
1
Grundlagen
linke Klammer: ignorieren
(1+((2 + 3 ) * ( 4 * 5 ) ) ) Operandenstapel
Operand: auf dem Operandenstapel ablegen
1+((2+ 3 ) * ( 4 * 5 ) ) ) Operator: auf dem Operatorstapel ablegen
Operatorstapel
+((2+3 ) * ( 4 * 5 ) ) ) ((2+3) * ( 4 * 5 ) ) ) (2+3)* ( 4 * 5 ) ) ) 2+3)*( 4 * 5 ) ) ) +3)*(4 * 5 ) ) ) 3)*(4* 5 ) ) ) rechte Klammer: Operator und Operanden löschen und Ergebnis ablegen )*(4*5 ) ) ) *(4*5) ) ) (4*5)) ) 4*5))) *5))) 5))) ))) )) )
Abbildung 1.33: Ablaufprotokoll von Dijkstras Algorithmus zur Auswertung arithmetischer Ausdrücke mittels zweier Stapeln
150
1.3 Multimengen, Warteschlangen und Stapel
1.3.2 Collections implementieren Um Ihnen zu zeigen, wie Sie Bag, Stack und Queue implementieren, beginnen wir mit einer einfachen klassischen Implementierung und gehen dann auf Verbesserungen ein, die uns zu den Implementierungen der APIs aus Abbildung 1.29 führen.
Stapel fester Kapazität Als Beispiel betrachten wir einen abstrakten Datentyp für einen Stapel (stack) fester Größe von Strings (Tabelle 1.48). Die API unterscheidet sich von unserer Stack-API: Sie lässt sich nur auf String-Werte anwenden, der Client muss eine Größe angeben und sie unterstützt keine Iteration. Die erste Aufgabe beim Entwickeln einer API-Implementierung ist es, eine Repräsentation für die Daten zu wählen. Für FixedCapacityStackOfStrings bietet es sich an, ein Array von String-Werten zu verwenden. Diese Entscheidung führt zu der Implementierung von Listing 1.33, die kaum einfacher sein könnte (jede Methode besteht aus einer einzigen Zeile). Die Instanzvariablen bestehen aus einem Array a[] für die Elemente im Stapel und einem Integer N, der die Anzahl der Elemente im Stapel festhält. Um ein Element zu entfernen, dekrementieren wir N und liefern dann a[N] zurück. Um ein neues Element einzufügen, setzen wir a[N] auf das neue Element und inkrementieren anschließend N. Diese Operationen garantieren, dass die folgenden Eigenschaften gewahrt bleiben:
Die Elemente im Array stehen in der Reihenfolge, in der sie eingefügt wurden. Der Stapel ist leer, wenn N gleich 0 ist. Das oberste Element im Stapel befindet sich an Position a[N1] (wenn der Stapel nicht leer ist). In Form von Invarianten dieser Art zu denken, ist der einfachste Weg zu verifizieren, dass eine Implementierung wie beabsichtigt funktioniert. Vergewissern Sie sich, dass Sie diese Implementierung vollständig verstanden haben. Die vielleicht beste Vorgehensweise hierfür ist eine Nachverfolgung des Stapelinhalts für eine Folge von Operationen – wie Tabelle 1.47 für den Testclient zeigt, der Strings von der Standardeingabe einliest und jeden String auf einem Stapel ablegt, es sei denn, es handelt sich um den String "-", woraufhin er das zuletzt hinzugefügte Element vom Stapel entfernt und als Ergebnis ausgibt. Bezüglich des Laufzeitverhaltens gilt für diese Implementierung, dass die Ausführungszeit der push()- und pop()-Operationen unabhängig von der Größe des Stapels ist. Für viele Anwendungen ist sie aufgrund ihrer Einfachheit die Methode der Wahl. Allerdings ist sie auch mit einigen Schwächen behaftet, was ihren potenziellen Einsatz als Allzweckwaffe einschränkt. Mit ein wenig Aufwand (und Unterstützung einiger Java-Sprachmechanismen) können wir eine Implementierung entwickeln, die vielseitig einsetzbar ist. Diese Mühe lohnt sich, da die von uns entwickelten Implementierungen als Modell für Implementierungen von anderen mächtigeren abstrakten Datentypen im Buch dienen.
151
1
Grundlagen
Tabelle 1.47 Ablaufprotokoll des FixedCapacityStackOfStringsTestclients StdIn
StdOut
(push)
(pop)
N
a[] 0
1
2
3
4
0 to
1
to
be
2
to
be
or
3
to
be
or
not
4
to
be
or
not
to
5
to
be
or
not
to
4
to
be
or
not
to
5
to
be
or
not
be
-
to
be -
be
4
to
be
or
not
be
-
not
3
to
be
or
not
be
4
to
be
or
that
be
that -
that
3
to
be
or
that
be
-
or
2
to
be
or
that
be
-
be
1
to
be
or
that
be
2
to
is
or
that
be
is
Tabelle 1.48 API von FixedCapacityStackOfStrings public class FixedCapacityStackOfStrings
void String boolean int
152
FixedCapacityStackOfStrings(int cap)
Erzeugt einen leeren Stapel der Größe cap.
push(String item)
Fügt einen String hinzu.
pop()
Entfernt den zuletzt hinzugefügten String.
isEmpty()
Ist der Stapel leer?
size()
Anzahl der Strings im Stapel.
1.3 Multimengen, Warteschlangen und Stapel
public static void main(String[] args) { FixedCapacityStackOfStrings s; s = new FixedCapacityStackOfStrings(100); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals("-")) s.push(item); else if (!s.isEmpty()) StdOut.print(s.pop() + " "); } StdOut.println("(" + s.size() + " left on stack)"); } Listing 1.31: Testclient % more tobe.txt to be or not to - be - - that - - - is % java FixedCapacityStackOfStrings < tobe.txt to be not that or be (2 left on stack) Listing 1.32: Anwendung public class FixedCapacityStackOfStrings { private String[] a; // Elemente im Stapel private int N; // Größe public FixedCapacityStackOfStrings(int cap) { a = new String[cap]; } public boolean isEmpty() { public int size() {
return N == 0; } return N; }
public void push(String item) { a[N++] = item; } public String pop() { return a[--N]; } } Listing 1.33: Implementierung Abbildung 1.34: Ein abstrakter Datentyp für einen Stapel fester Größe von Strings
Generics Ein Nachteil von FixedCapacityStackOfStrings ist, dass nur String-Objekte aufgenommen werden können. Wenn wir mit einem Stapel von double-Werten arbeiten wollen, müssen wir eine neue Klasse mit ähnlichem Code entwickeln, wobei wir im Wesentlichen String überall durch double ersetzen. Dies ist zwar nicht schwer, aber lästig – zumal wir irgendwann ja vielleicht auch noch einen Stapel von Transaction-Werten oder eine Warteschlange von Date-Werten usw. erzeugen möchten. Wie in Abschnitt
153
1
Grundlagen
Generics beschrieben, wurden in Java die parametrisierten Typen (Generics) speziell für Situationen wie diese entworfen; außerdem haben wir bereits die Bekanntschaft mit einigen Beispielclients gemacht (Listing 1.26, Listing 1.28, Listing 1.29 und Listing 1.30). Wie aber implementieren wir einen generischen Stapel? Der Code in Listing 1.35 zeigt, wie dies geht. Er implementiert eine Klasse FixedCapacityStack, die sich von FixedCapacityStackOfStrings nur in den hervorgehobenen Codestellen unterscheidet. Dazu haben wir alle Vorkommen von String durch Item ersetzt (mit einer Ausnahme, die wir nachfolgend erläutern) und die Klasse mit der folgenden ersten Codezeile deklariert: public class FixedCapacityStack
Der Name Item ist ein Typparameter, ein symbolischer Platzhalter für irgendeinen konkreten Typ, wie ihn der Client verwendet. Sie können FixedCapacityStack als Stapel von Elementen lesen, was genau das ist, was wir wollen. Bei der Implementierung von FixedCapacityStack kennen wir den tatsächlichen Typ von Item nicht, aber ein Client kann unseren Stapel für jeden beliebigen Datentyp verwenden, indem er bei der Erzeugung des Stapels einen konkreten Typ angibt. Konkrete Typen müssen Referenztypen sein, aber Clients können sich darauf verlassen, dass die primitiven Typen mittels Autoboxing in ihre entsprechenden Wrappertypen umgewandelt werden. Java verwendet den Typparameter Item, um auf Typdiskrepanz zu prüfen – auch wenn der konkrete Typ noch nicht bekannt ist, müssen Variablen vom Typ Item Werte vom Typ Item zugewiesen werden usw. Es gibt jedoch einen Haken bei dieser Sache: Wir würden gern den Konstruktor von FixedCapacityStack mit folgendem Code implementieren a = new Item[cap];
mit dem wir ein generisches Array erzeugen. Aus historischen und technischen Gründen, die im Rahmen dieses Buches nicht behandelt werden können, ist die Erzeugung von generischen Arrays in Java jedoch nicht erlaubt. Stattdessen müssen wir eine Umwandlung vornehmen: a = (Item[]) new Object[cap];
Mit diesem Code erhalten Sie den gewünschten Effekt (obwohl der Java-Compiler eine Warnung ausgibt, die wir getrost ignorieren können); wir werden dieses Idiom ab jetzt immer wieder verwenden (die Java-Bibliotheksimplementierungen von ähnlichen abstrakten Datentypen verwenden das gleiche Idiom).
Tabelle 1.49 API von FixedCapacityStack public class FixedCapacityStack FixedCapacityStack (int cap)
Erzeugt einen leeren Stapel der Größe cap.
void
push(Item item)
Fügt ein Element hinzu.
Item
pop()
Entfernt das zuletzt hinzugefügte Element.
isEmpty()
Ist der Stapel leer?
size()
Anzahl der Elemente im Stapel.
boolean int
154
1.3 Multimengen, Warteschlangen und Stapel
public static void main(String[] args) { FixedCapacityStack s; s = new FixedCapacityStack(100); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals("-")) s.push(item); else if (!s.isEmpty()) StdOut.print(s.pop() + " "); } StdOut.println("(" + s.size() + " left on stack)"); } Listing 1.34: Testclient % more tobe.txt to be or not to - be - - that - - - is % java FixedCapacityStack < tobe.txt to be not that or be (2 left on stack) Listing 1.35: Anwendung public class FixedCapacityStack { private Item[] a; // Elemente im Stapel private int N; // Größe public FixedCapacityStack(int cap) { a = (Item[]) new Object[cap]; } public boolean isEmpty() { public int size() {
return N == 0; } return N; }
public void push(Item item) { a[N++] = item; } public Item pop() { return a[--N]; } } Listing 1.36: Implementierung Abbildung 1.35: Ein abstrakter Datentyp für einen generischen Stapel fester Größe
Größenanpassung von Arrays Wenn für die Repräsentation des Stapelinhalts ein Array verwendet wird, müssen die Clients die maximale Größe des Stapels vorab schätzen. In Java können wir die Größe eines einmal erzeugten Arrays nicht ändern, sodass der Stapel immer Speicher belegt, der proportional zu diesem Maximum ist. Ein Client, der eine große Kapazität angibt,
155
1
Grundlagen
riskiert, viel Speicher zu verschwenden, wenn die Collection leer oder fast leer ist. Ein Transaktionssystem könnte zum Beispiel Milliarden von Elementen und Tausende von Collections umfassen. Ein solcher Client müsste dafür Sorge tragen, dass jede dieser Collections alle diese Elemente aufnehmen kann, auch wenn in solchen Systemen normalerweise die Beschränkung gilt, dass jedes Element nur in einer Collection vorkommen kann. Gravierender noch ist das Risiko, dass es im Client zu einem Überlauf kommt, wenn die Sammlung größer wird als das Array. Zum Schutz gegen einen solchen Überlauf muss push() um Code erweitert werden, der testet, ob der Stapel voll ist, und die API sollte eine zusätzliche Methode isFull() vorsehen, die es auch den Clients erlaubt, diese Bedingung zu testen. Anstatt jedoch diesen Code zu ergänzen, wählen wir einen ganz anderen Weg: Wie in unserer ursprünglichen Stack-API formuliert, wollen wir die Clients davon befreien, sich mit dem Problem eines vollen Stapels befassen zu müssen. Dazu ändern wir die Arrayimplementierung so ab, dass die Größe des Arrays a[] dynamisch angepasst wird. Sie soll groß genug sein, um alle Elemente aufzunehmen, aber nie so groß, dass unnötig Speicherplatz verschwendet wird. Diese Ziele lassen sich auf erstaunlich einfache Art und Weise erreichen. Zuerst implementieren wir eine Methode, die einen Stapel in ein Array einer anderen Größe verschiebt: private void resize(int max) { // Verschiebt Stapel der Größe N 0 && N == a.length/4) resize(a.length/2); return item; }
156
1.3 Multimengen, Warteschlangen und Stapel
Mit dieser Implementierung läuft der Stapel niemals über und wird nie kleiner als ein Viertel (es sei denn, der Stapel ist leer, wenn die Arraygröße 1 beträgt). Wir werden auf die Laufzeitanalyse dieses Ansatzes ausführlich in Abschnitt 1.4 eingehen.
Loitering Die Speicherbereinigung von Java gibt automatisch jeden Speicher frei, der mit Objekten verbunden ist, auf die nicht mehr zugegriffen werden kann. In unseren pop()-Implementierungen verbleibt die Referenz auf das entfernte Element jedoch im Array. Das Element ist verwaist – wir werden es in der Klasse nie wieder verwenden –, aber die JavaSpeicherbereinigung erkennt dies nicht, bis das Element überschrieben wird. Wir haben also die Situation, dass die Referenz im Array das Element womöglich am Leben erhält, obwohl der Client das Element nicht mehr benötigt. Dieser Zustand (eine Referenz auf ein Element zu bewahren, das nicht mehr benötigt wird) wird als Loitering („Herumlungern“) bezeichnet. In unserem Fall lässt sich Loitering leicht vermeiden, indem wir das Arrayelement, das dem gelöschten Element entspricht, auf null setzen und so die nicht verwendete Referenz überschreiben. Dadurch ermöglichen Sie es dem System, den Speicher des gelöschten Elements zurückzufordern, wenn der Client es nicht mehr benötigt.
Tabelle 1.50 Nachverfolgung der Größenanpassung von Arrays für eine Folge von push()- und pop()-Operationen push()
pop()
N
a.length
a[] 0
1
2
3
4
5
6
7
0
1
null
to
1
1
to
be
2
2
to
be
or
3
4
to
be
or
null
not
4
4
to
be
or
not
to
5
8
to
be
or
not
to
null
null
null
4
8
to
be
or
not
null
null
null
null
5
8
to
be
or
not
be
null
null
null
-
to
be -
be
4
8
to
be
or
not
null
null
null
null
-
not
3
8
to
be
or
null
null
null
null
null
4
8
to
be
or
that
null
null
null
null
null
null
null
null
that -
that
3
8
to
be
or
null
-
or
2
4
to
be
null
null
-
be
1
2
to
null
to
is
is
2
157
1
Grundlagen
Iteration Wie bereits früher in diesem Abschnitt erwähnt, ist eine der grundlegenden Operationen auf Collections die Java-Anweisung foreach, mit der wir alle Elemente in der Collection durchlaufen und bearbeiten. Dieses Paradigma führt zu klarem und kompaktem Code, der nicht von den Details der Implementierung einer Collection abhängig ist. Um zu zeigen, wie eine Iteration implementiert wird, beginnen wir mit einem kleinen Client-Codefragment, der alle Elemente in einer String-Collection auf jeweils einer eigenen Zeile ausgibt: Stack collection = new Stack(); ... for (String s : collection) StdOut.println(s); ...
Diese foreach-Anweisung ist eine Kurzform für ein while-Konstrukt (ebenso wie die normale for-Anweisung). Sie ist im Wesentlichen äquivalent zu der folgenden whileAnweisung: Iterator i = collection.iterator(); while (i.hasNext()) { String s = i.next(); StdOut.println(s); }
Dieser Code weist die Bestandteile auf, die wir in jeder iterierbaren Collection implementieren müssen:
Die Collection muss eine iterator()-Methode implementieren, die ein IteratorObjekt zurückliefert.
Die Klasse Iterator muss über zwei Methoden verfügen: hasNext() (die einen booleschen Wert zurückliefert) und next() (die ein generisches Element aus der Collection zurückliefert). In Java verwenden wir den interface-Mechanismus, um auszudrücken, dass eine Klasse eine spezielle Methode implementiert (siehe Abschnitt Schnittstellenvererbung). Im Fall von iterierbaren Collections sind die notwendigen Schnittstellen bereits für uns in Java definiert. Der erste Schritt, um eine Klasse iterierbar zu machen, besteht darin, die Deklaration um die Klausel implements Iterable zu erweitern, die auf die Schnittstelle public interface Iterable { Iterator iterator(); }
Bezug nimmt (die in java.lang.Iterable zu finden ist), und in der Klasse eine Methode iterator() zu implementieren, die ein Iterator zurückliefert. Iteratoren sind generisch. Wir können also mit unserem parametrisierten Typ Item Clients die Möglichkeit bieten, über Objekte eines vom Client festgelegten beliebigen Typs zu iterieren. Im Falle der von uns verwendeten Arrayrepräsentation müssen wir in umgekehrter Reihen-
158
1.3 Multimengen, Warteschlangen und Stapel
folge über das Array iterieren, sodass wir den Iterator ReverseArrayIterator nennen und folgende Methode hinzufügen public Iterator iterator() { return new ReverseArrayIterator();
}
Was genau ist ein Iterator? Es ist ein Objekt einer Klasse, die die Methoden hasNext() und next() implementiert, wie in der folgenden Schnittstelle definiert (zu finden in java.util.Iterator): public interface Iterator { boolean hasNext(); Item next(); void remove(); }
Obwohl die Schnittstelle eine remove()-Methode verlangt, implementieren wir in diesem Buch remove() immer nur als leere Methode, denn es sollte am besten vermieden werden, die Iteration mit Operationen zu vermischen, die die Datenstruktur ändern. In ReverseArrayIterator sind diese Methoden alles Einzeiler, die in einer eingebetteten Klasse in unserer Stapelklasse implementiert sind: private class ReverseArrayIterator implements Iterator { private int i = N; public boolean hasNext() { public Item next() { public void remove() {
return i > 0; return a[--i];
} } }
}
Beachten Sie, dass diese eingebettete Klasse auf die Instanzvariablen der umschließenden Klasse (in diesem Fall a[] und N) zugreifen kann. Diese Eigenschaft ist der Hauptgrund, warum wir für Iteratoren eingebettete Klassen schreiben. Um der Iterator-Spezifikation zu entsprechen, sollten wir theoretisch in zwei Fällen Ausnahmen werfen: eine UnsupportedOperationException, wenn ein Client remove() aufruft, und eine NoSuchElementException, wenn im Falle, dass i den Wert 0 hat, ein Client next() aufruft. Da wir jedoch Iteratoren nur in der foreach-Konstruktion verwenden, in der diese Bedingungen nicht auftreten, verzichten wir hier auf diesen Code. Eines bleibt allerdings noch zu tun: Wir müssen am Anfang des Programms die Import-Anweisung import java.util.Iterator;
einfügen, da Iterator (aus historischen Gründen) nicht Teil von java.lang ist (auch wenn Iterable selbst Teil von java.lang ist). Wenn jetzt ein Client für diese Klasse die foreach-Anweisung verwendet, erhält er das gleiche Verhalten wie bei den normalen for-Schleifen für Arrays – ohne dass der Client etwas von der Arrayrepräsentation (ein Implementierungsdetail) wissen muss. Dieses Arrangement ist von großer Bedeutung für die Implementierung grundlegender Datentypen wie die Collections, die wir in diesem Buch betrachten, und die, die in den Java-Bibliotheken enthalten sind, und gestattet uns zum Beispiel den Wechsel zu einer völlig anderen Repräsentation, ohne
159
1
Grundlagen
dass wir dazu irgendwelchen Client-Code ändern müssten. Und – was aus der Sicht des Clients noch wichtiger ist – Clients können die Iteration verwenden, ohne mit den Details der Klassenimplementierung vertraut sein zu müssen. Algorithmus 1.1 (Listing 1.37) ist eine Implementierung unserer Stack-API, die die Größe des Arrays bei Bedarf ändert und es den Clients ermöglicht, für beliebige Datentypen Stapel zu erstellen und deren Elemente mit foreach in LIFO-Reihenfolge zu durchlaufen. Die Implementierung nutzt spezielle Aspekte der Programmiersprache Java inklusive Iterator und Iterable, die Sie jedoch nicht bis ins Detail kennen müssen, da der Code nicht sonderlich kompliziert ist und als Vorlage für andere Collection-Implementierungen herangezogen werden kann. So können wir beispielsweise die Queue-API implementieren, indem wir in Instanzvariablen zwei Indizes speichern: die Variable head für den Anfang der Warteschlange und die Variable tail für das Ende der Warteschlange. Um ein Element zu entfernen, greifen Sie über head darauf zu und inkrementieren dann head; zum Einfügen eines Elements speichern Sie es in der Variablen tail, die Sie anschließend inkrementieren. Wenn der Index nach der Inkrementierung über das Ende des Arrays hinausläuft, setzen Sie ihn auf 0 zurück. Die Entwicklung des Codes, der prüft, wann die Warteschlange leer und das Array voll ist bzw. in seiner Größe angepasst werden muss, ist eine interessante und lohnenswerte Programmierübung (siehe Übung 1.3.14).
Tabelle 1.51 Ablaufprotokoll des ResizingArrayQueue-Testclients StdIn (enqueue )
-
StdOut (dequeue )
to
be
N
head
tail
a[] 0
1
2
3
4
5
5
0
5
to
be
or
not
to
4
1
5
to
be
or
not
to
5
1
6
to
be
or
not
to
be
-
be
4
2
6
to
be
or
not
to
be
-
or
3
3
6
to
be
or
that
to
be
6
7
Für das Studium der Algorithmen ist Algorithmus 1.1 (Listing 1.37) insofern wichtig, als er für jede beliebige Collection-Implementierung fast optimale Laufzeitwerte erreicht:
Jede Operation sollte in einer Zeit ausgeführt werden, die unabhängig von der Collection-Größe ist.
Der verwendete Speicher sollte immer innerhalb eines konstanten Faktors der Collection-Größe liegen. Nachteilig an ResizingArrayStack ist, dass einige push()- und pop()-Operationen mit einer Größenanpassung verbunden sind und der Zeitaufwand hierfür proportional zur Größe des Stapels ist. Deshalb betrachten wir als Nächstes, wie sich dieses Problem umgehen lässt, indem wir einen ganz anderen Weg einschlagen, um die Daten zu strukturieren.
160
1.3 Multimengen, Warteschlangen und Stapel
Listing 1.37: LIFO-Stapel (Implementierung mit Arrays variabler Größe)(Algorithmus 1.1) import java.util.Iterator; public class ResizingArrayStack implements Iterable { private Item[] a = (Item[]) new Object[1]; // Elemente im Stapel. private int N = 0; // Anzahl der Elemente. public boolean isEmpty() public int size()
{ {
return N == 0; } return N; }
private void resize(int max) { // Verschiebt den Stapel in ein neues Array der Größe max. Item[] temp = (Item[]) new Object[max]; for (int i = 0; i < N; i++) temp[i] = a[i]; a = temp; } public void push(Item item) { // Legt ein Element oben auf den Stapel. if (N == a.length) resize(2*a.length); a[N++] = item; } public Item pop() { // Entfernt das oberste Element aus dem Stapel. Item item = a[--N]; a[N] = null; // Vermeidet Loitering (siehe Text). if (N > 0 && N == a.length/4) resize(a.length/2); return item; } public Iterator iterator() { return new ReverseArrayIterator();
}
private class ReverseArrayIterator implements Iterator { // Unterstützt LIFO-Iteration. private int i = N; public boolean hasNext() { return i > 0; } public Item next() { return a[--i]; } public void remove() { } } }
Diese generische iterierbare Implementierung unserer Stack-API ist ein Modell für Collection-ADTs, die Elemente in einem Array speichern. Sie passt die Größe des Arrays an, damit diese innerhalb eines konstanten Faktors der Stapelgröße liegt.
161
1
Grundlagen
1.3.3 Verkettete Listen In diesem Abschnitt werden wir uns eine grundlegende Datenstruktur anschauen, die sich hervorragend eignet, um in Implementierungen von Collection-ADTs die Daten zu repräsentieren. Dies ist zudem unser erstes Beispiel einer Datenstruktur, die ohne den direkten Rückgriff auf Elemente der Sprache Java erstellt wird. Auf Basis dieser Implementierung werden wir im weiteren Verlauf des Buches noch etliche komplexere Datenstrukturen erstellen, sodass Sie diesen Abschnitt sorgfältig lesen sollten, auch wenn Sie bereits Erfahrungen in der Arbeit mit verketteten Listen sammeln konnten.
Definition: Verkettete Liste Eine verkettete Liste ist eine rekursive Datenstruktur, die wie folgt definiert ist: Sie ist entweder leer (null) oder besteht aus einer Referenz auf einen Knoten, der ein generisches Element und eine Referenz auf eine verkettete Liste hält.
Der Knoten (node) in dieser Definition ist eine abstrakte Entität, die neben einer Knotenreferenz, der für den Aufbau verketteter Listen unerlässlich ist, auch noch Daten beliebiger Art enthalten kann. Rekursive Datenstrukturen können – wie das Konzept eines rekursiven Programms – bei der ersten Begegnung durchaus etwas Kopfzerbrechen bereiten, sind aber aufgrund ihrer strukturellen Klarheit äußerst nützlich.
Knoten-Verbund Mit den Techniken der objektorientierten Programmierung ist die Implementierung von verketteten Listen nicht weiter schwierig. Wir beginnen mit einer eingebetteten Klasse, die die Knotenabstraktion definiert: private class Node { Item item; Node next; }
Ein Node-Objekt besitzt zwei Instanzvariablen: eine vom Typ Item (ein parametrisierter Typ) und eine vom Typ Node. Wir definieren Node direkt in der Klasse, in der wir den Typ verwenden wollen, und zwar als private, da Clients keinen Zugriff darauf erhalten sollen. Wie bei jedem Datentypen erzeugen wir ein Objekt vom Typ Node, indem wir den (argumentlosen) Konstruktor mit new Node() aufrufen. Das Ergebnis ist eine Referenz auf ein Node-Objekt, dessen Instanzvariablen beide mit dem Wert null initialisiert sind. Die Item-Variable ist in diesem Beispiel ein Platzhalter für beliebige Daten, die wir mithilfe einer verketteten Liste strukturieren wollen (wir verwenden den generischen Mechanismus von Java, sodass sie jeden beliebigen Referenztyp repräsentieren kann); die Instanzvariable vom Typ Node charakterisiert die Art der Verkettung unserer Datenstruktur. Um zu betonen, dass die Klasse Node allein der Strukturierung der Daten dient, definieren wir keine Methoden und referenzieren die Instanzvariablen im Code direkt: Wenn first eine Variable ist, die mit einem Objekt vom Typ Node verbunden ist, kön-
162
1.3 Multimengen, Warteschlangen und Stapel
nen wir die Instanzvariablen mit dem Code first.item und first.next referenzieren. Klassen dieser Art werden manchmal auch Records oder Strukturen genannt. Sie implementieren keine abstrakten Datentypen, da wir direkt auf die Instanzvariablen zugreifen. Da jedoch Node und sein Client-Code in allen unseren Implementierungen in derselben Klasse stehen und Clients keinen Zugriff darauf haben, profitieren wir immer noch von den Vorteilen der Datenabstraktion.
Verkettete Listen erstellen Aufgrund der rekursiven Definition können wir jetzt eine verkettete Liste durch eine Variable vom Typ Node repräsentieren, indem wir einfach sicherstellen, dass ihr Wert entweder null ist oder eine Referenz auf ein Node-Objekt, dessen next-Feld eine Referenz auf eine verkettete Liste ist. Um zum Beispiel eine verkettete Liste zu erstellen, die die Elemente to, be und or enthält, erzeugen wir für jedes Element ein Node-Objekt: Node first = new Node(); Node second = new Node(); Node third = new Node();
weisen den item-Feldern der Node-Objekte die gewünschten Werte zu (der Einfachheit halber gehen wir davon aus, dass Item gleich String ist): first.item = "to"; second.item = "be"; third.item = "or";
und stellen über die next-Felder die Verkettung her: first.next = second; second.next = third;
(Beachten Sie, dass third.next weiterhin null ist – der Wert, mit dem es bei der Erzeugung initialisiert wurde.) Jetzt ist third eine verkettete Liste (third ist eine Referenz auf einen Knoten, der eine Referenz auf null enthält, also quasi die Null-Referenz auf eine leere verkettete Liste), second ist eine verkettete Liste (eine Referenz auf einen Knoten, der eine Referenz auf third, d.h. eine verkettete Liste, enthält) und first ist eine verkettete Liste (eine Referenz auf einen Knoten, der eine Referenz auf second, d.h. eine verkettete Liste, enthält). Der Code, den wir untersuchen, führt diese Zuweisungen in einer bestimmten Reihenfolge durch (Abbildung 1.36).
Node first first.item
= new Node(); = "to";
first to null
Node second = new Node(); second.item = "be"; first.next = second; first
second to be null
Node third = new Node(); third.item = "or"; second.next = third; first
second third to be
or null
Abbildung 1.36: Eine Liste verketten
163
1
Grundlagen
Eine verkettete Liste repräsentiert eine Folge von Elementen. In dem gerade betrachteten Beispiel repräsentiert first die Folge to be or. Wir können eine Folge von Elementen auch durch ein Array repräsentieren. Wir könnten zum Beispiel für unsere Stringfolge auch das Array String[] s = { "to", "be", "or" };
verwenden. Der Unterschied ist, dass es in verketteten Listen leichter ist, Elemente einzufügen oder daraus zu entfernen. Wie der Code hierfür aussieht, wollen wir uns im Folgenden genauer ansehen. Zur Nachverfolgung von Code, der verkettete Listen oder ähnliche Strukturen einsetzt, verwenden wir eine visuelle Darstellung, in der
jedes Objekt durch ein Rechteck repräsentiert wird, die Werte der Instanzvariablen im Rechteck stehen, Pfeile verwendet werden, um die Referenzen auf die referenzierten Objekte auszudrücken. Diese visuelle Darstellung veranschaulicht die Hauptmerkmale von verketteten Listen. Der Kürze halber verwenden wir für die Knotenverweise den Begriff Referenz. Außerdem schreiben wir die Elementwerte – sofern es sich wie in unserem Beispiel nur um Strings handelt – der Einfachheit halber in das Objektrechteck und verzichten somit auf die exaktere Darstellung als Stringobjekt und Zeichenarray, die wir in Abschnitt 1.2 gewählt haben. Diese vereinfachte visuelle Darstellung hat den Vorteil, dass wir uns ganz auf die Referenzen konzentrieren können.
Am Anfang einfügen Angenommen, Sie möchten einen neuen Knoten in eine verkettete Liste einfügen. Dies geht am leichtesten am Anfang der Liste. Um zum Beispiel den String not am Anfang einer gegebenen verketteten Liste einzufügen, deren erster Knoten first lautet, speichern wir first in oldfirst, weisen first ein neues Node-Objekt zu und setzen dessen item-Feld auf not und das next-Feld auf oldfirst. Das Einfügen eines Knotens am Anfang einer verketteten Liste erfordert nur einige wenige Zuweisungen, sodass die dafür benötigte Zeit unabhängig von der Länge der Liste ist.
164
1.3 Multimengen, Warteschlangen und Stapel
Speichert eine Referenz auf die Liste
Node oldfirst = first; oldfirst
first
to
be
or null
Erzeugt einen neuen Knoten für den Anfang
first = new Node(); oldfirst first
to
be
or null
Setzt die Instanzvariablen in dem neuen Knoten
first.item = "not"; first.next = oldfirst; first
not
to
be
or null
Abbildung 1.37: Einen neuen Knoten am Anfang einer verketteten Liste einfügen
Vom Anfang entfernen Als Nächstes betrachten wir den Fall, dass Sie first = first.next; den ersten Knoten aus einer Liste entfernen first to möchten. Diese Operation ist sogar noch einbe or facher: Weisen Sie einfach first den Wert null first.next zu. Normalerweise würden Sie den first Wert des Elements ermitteln (indem Sie ihn to be irgendeiner Item-Variablen zuweisen), bevor or null Sie diese Zuweisung vornehmen, denn sobald Sie den Wert von first geändert haben, haben Abbildung 1.38: Den ersten Knoten in einer verSie keinen Zugriff mehr auf den Knoten, auf ketteten Liste entfernen den er verweist. In der Regel wird das Knotenobjekt zu einer Waise und der von ihm belegte Speicher wird irgendwann von Javas Speicherverwaltungssystem zurückgefordert. Auch diese Operation erfordert nur eine Zuweisung, sodass ihre Ausführungszeit ebenfalls unabhängig von der Länge der Liste ist.
Am Ende einfügen Wie aber fügen wir einen Knoten am Ende einer verketteten Liste ein? Dazu benötigen wir eine Referenz auf den letzten Knoten in der Liste, da die next-Referenz dieses Knotens geändert werden muss, um auf einen neuen Knoten zu zeigen, der das einzufügende Element enthält. Die Verwaltung einer zusätzlichen Referenz im Code für ver-
165
1
Grundlagen
kettete Listen sollte nicht zu leicht genommen werden, da jede Methode, die die Liste verändert, Code benötigt, der prüft, ob diese Variable geändert werden muss (und die erforderlichen Änderungen vornimmt). Zum Beispiel könnte der gerade untersuchte Code zum Entfernen des ersten Knotens in der Liste mit einer Änderung der Referenz auf den letzten Knoten der Liste verbunden sein, denn für den Fall, dass es nur einen Knoten in der Liste gibt, ist dieser sowohl der erste als auch der letzte! Außerdem lässt sich dieser Code nicht ausführen (er folgt einer null-Referenz), wenn die Liste leer ist. Spezialfälle wie diese erschweren das Debuggen von Code zu verketteten Listen enorm. Speichert eine Referenz auf den letzten Knoten
Node oldlast = last; oldlast last first
to
be
or null
Erzeugt einen neuen Knoten für das Ende
last = new Node(); last.item = "not"; oldlast first
to
last be
or null
not null
Erzeugt eine Referenz vom Ende der Liste auf den neuen Knoten
oldlast.next = last; oldlast first
to
be
last or
not null
Abbildung 1.39: Einen neuen Knoten am Ende einer verketteten Liste einfügen
An anderen Positionen einfügen/entfernen Zusammengefasst lässt sich sagen, dass wir die folgenden Operationen auf verkettete Listen mit nur wenigen Anweisungen implementieren können, vorausgesetzt wir haben mit der Referenz first Zugriff auf das erste Element in der Liste und mit last Zugriff auf das letzte Element in der Liste:
Am Anfang einfügen Vom Anfang entfernen Am Ende einfügen Andere Operationen wie die folgenden lassen sich nicht so einfach realisieren:
Einen gegebenen Knoten entfernen Einen neuen Knoten vor einem gegebenen Knoten einfügen
166
1.3 Multimengen, Warteschlangen und Stapel
Wie können wir beispielsweise den letzten Knoten aus der Liste entfernen? Die Referenz last ist hierbei keine Hilfe, da wir die next-Referenz des vorherigen Knotens der Liste auf null setzen müssen (der mit dem gleichen Wert wie last). Angesichts fehlender anderer Informationen ist die einzige Lösung, die ganze Liste zu durchlaufen und nach dem Knoten zu suchen, der eine Referenz auf last hält (siehe unten und Übung 1.3.19). Eine solche Lösung ist nicht akzeptabel, da die Ausführungszeit proportional zu der Länge der Liste ist. Die Standardlösung für beliebige Einfügungen und Löschungen besteht in einer doppelt verketteten Liste, in der jeder Knoten zwei Referenzen hat – einen in jede Richtung. Wir überlassen Ihnen den Code für diese Operationen als Übung (siehe Übung 1.3.31). Wir setzen in unseren Implementierungen allerdings keine doppelt verketteten Listen ein.
Traversieren Um jedes Element in einem Array zu besuchen, verwenden wir vertrauten Code wie die folgende Schleife, mit der wir die Elemente in einem Array a[] verarbeiten: for (int i = 0; i < N; i++) { // Verarbeitet a[i]. }
Es gibt ein äquivalentes Idiom für das Ansteuern der Elemente in einer verketteten Liste: Wir initialisieren eine Schleifenvariable x, die auf das erste Node-Objekt der verketteten Liste verweist. Wir ermitteln das Element, das mit x verbunden ist, indem wir auf x.item zugreifen. Anschließend aktualisieren wir x, sodass es auf das nächste Node-Objekt in der verketteten Liste zeigt. Dieser Vorgang wird wiederholt, bis x den Wert null annimmt (was ein Zeichen dafür ist, dass wir das Ende der verketteten Liste erreicht haben). Dieses Durchlaufen der Liste wird als Traversierung bezeichnet und kurz und bündig in Code durch die folgende Schleife ausgedrückt, die die Elemente in einer verketteten Liste verarbeitet, deren erstes Element mit der Variablen first verbunden ist: for (Node x = first; x != null; x = x.next) { // Verarbeitet x.item. }
Dieses Idiom wird Ihnen bald ebenso geläufig sein wie das Standardidiom zum Durchlaufen der Elemente in einem Array. In unseren Implementierungen verwenden wir es als Grundlage für Iteratoren, denn mit ihnen kann Client-Code über die Elemente iterieren, ohne die genauen Einzelheiten der Implementierung der verketteten Liste kennen zu müssen.
Stack-Implementierung Nach diesen einleitenden Worten ist die Entwicklung einer Implementierung für unsere Stack-API ganz einfach, wie Algorithmus 1.2 in Listing 1.39 zeigt. Sie verwaltet den Stapel als eine verkettete Liste, wobei das obere Ende des Stapels der Anfang der Liste
167
1
Grundlagen
ist, auf den eine Instanzvariable first zeigt. Um dem Stapel mit push() ein Element hinzuzufügen, fügen wir das Element mit dem Code aus Abbildung 1.37 am Anfang der Liste ein. Um ein Element mit pop() zu löschen, entfernen wir es vom Anfang der Liste mit dem Code aus Abbildung 1.39. Für die Implementierung von size() speichern wir die Anzahl der Elemente in einer Instanzvariablen N, die wir beim Einfügen inkrementieren und beim Löschen dekrementieren. Für die Implementierung von isEmpty() prüfen wir, ob first gleich null ist (alternativ könnten wir auch prüfen, ob N den Wert 0 hat). Diese Implementierung verwendet den generischen Datentyp Item – stellen Sie sich einfach vor, der Code nach dem Klassennamen bedeutet, dass der Client jedes Vorkommen von Item in der Implementierung durch seinen eigenen Datentypnamen ersetzt (siehe Abschnitt Generics). Wir verzichten hier auf den Code für die Iteration, da wir ihn in Listing 1.42 eingehend behandeln werden. Ein Ablaufprotokoll für den von uns verwendeten Testclient finden Sie in Abbildung 1.40. Mit den verketteten Listen können wir alle unsere Entwurfsziele erreichen:
Sie kann für jeden Datentyp verwendet werden. Der erforderliche Speicherplatz ist immer proportional zur Größe der Collection. Die pro Operation benötigte Zeit ist immer unabhängig von der Größe der Collection. Diese Implementierung ist ein Prototyp für viele Algorithmusimplementierungen, die wir betrachten. Sie definiert die Datenstruktur der verketteten Liste und implementiert die Client-Methoden push() und pop(), die den gewünschten Effekt mit nur wenigen Codezeilen erreichen. Algorithmen und Datenstruktur gehen Hand in Hand. In diesem Fall ist der Code für die Algorithmusimplementierungen zwar recht einfach, aber die Eigenschaften der Datenstruktur sind relativ anspruchsvoll, weswegen sich ihre Erläuterungen über die letzten Seiten erstreckt haben. Diese Interaktion zwischen Definition der Datenstruktur und Algorithmusimplementierung ist typisch und steht im Mittelpunkt unserer ADT-Implementierungen in diesem Buch. public static void main(String[] args) { // Erzeugt einen Stapel und ergänzt/entfernt Strings wie in StdIn // vorgegeben. Stack s = new Stack(); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals("-")) s.push(item); else if (!s.isEmpty()) StdOut.print(s.pop() + " "); } StdOut.println("(" + s.size() + " left on stack)"); } Listing 1.38: Testclient für Stack
168
1.3 Multimengen, Warteschlangen und Stapel
StdIn
StdOut
to
to null
be
be
to null
or
or
not
not
to
to
-
to
be
be
-
-
be
not
not
that
not
or
not
or
be
or
-
or
be
-
be
to null
is
or
to null
be
or
be
or
be
to null
be
to null
to null
be
to null
to null
or
that
that
-
not
be
is
or
be
to null
be
to null
to null
to null
to null
Abbildung 1.40: Ablaufprotokoll des Stack-Entwicklungsclients
169
1
Grundlagen
Listing 1.39: Stapel als Implementierung mit verketteter Liste (Algorithmus 1.2) public class Stack { private Node first; // Oberster Knoten im Stapel (zuletzt hinzugefügt). // Anzahl der Elemente. private int N; private class Node { // Eingebettete Klasse zur Definition von Knoten. Item item; Node next; } public boolean isEmpty() { public int size() {
return first == null; } return N; }
// Oder: N == 0.
public void push(Item item) { // Legt ein Element oben auf den Stapel. Node oldfirst = first; first = new Node(); first.item = item; first.next = oldfirst; N++; } public Item pop() { // Entfernt oberstes Element vom Stapel. Item item = first.item; first = first.next; N--; return item; } // iterator()-Implementierung siehe Listing 1.42. // Testclient main() siehe Listing 1.38. }
Diese generische Stack-Implementierung basiert auf der Datenstruktur einer verketteten Liste. Mit ihr können Sie Stapel für jeden beliebigen Datentyp erzeugen. Um Iteration zu unterstützen, müssen Sie den in Listing 1.42 (Bag) hervorgehobenen Code hinzufügen. % more tobe.txt to be or not to - be - - that - - - is % java Stack < tobe.txt to be not that or be (2 left on stack)
170
1.3 Multimengen, Warteschlangen und Stapel
Queue-Implementierung Die Implementierung unserer Queue-API auf der Basis einer verketteten Liste ist ebenfalls nicht sonderlich kompliziert, wie Algorithmus 1.3 in Listing 1.41 zeigt. Sie verwaltet die Warteschlange als eine verkettete Liste von Elementen, geordnet von zuerst hinzugefügt bis zuletzt hinzugefügt. Der Anfang der Warteschlange wird von der Instanzvariablen first referenziert und das Ende von der Instanzvariablen last. Mit enqueue() fügen wir ein Element hinten in der Liste ein (unter Verwendung des Codes aus Abbildung 1.39, wobei zusätzlich die beiden Variablen first und last so gesetzt werden, dass beide auf den neuen Knoten verweisen, wenn die Liste leer ist) und mit dequeue() entfernen wir ein Element vom Anfang der Liste (unter Verwendung des gleichen Codes wie für pop() in Stack, wobei zusätzlich last aktualisiert wird, wenn die Liste leer wird). Die Implementierungen von size() und isEmpty() entsprechen denen für Stack. Wie in Stack verwendet die Implementierung den generischen Typparameter Item und verzichtet auf den Code für die Iteration, die wir in unserer BagImplementierung in Listing 1.42 betrachten. Einen Entwicklungsclient ähnlich dem für Stack finden Sie in Listing 1.40 und des Ablaufprotokolls für diesen Client in Abbildung 1.41. Diese Implementierung verwendet die gleiche Datenstruktur wie Stack – eine verkettete Liste –, aber sie implementiert andere Algorithmen, um Elemente hinzuzufügen und zu entfernen, was für den Client den Unterschied macht zwischen LIFO und FIFO. Auch in diesem Fall können wir dank des Einsatzes einer verketteten Liste unsere wichtigsten Entwurfsziele erreichen. Die Implementierung kann für jeden Datentyp verwendet werden, der benötigte Speicherplatz ist proportional der Anzahl der Elemente in der Collection und die erforderliche Zeit pro Operation ist immer unabhängig von der Größe der Collection. public static void main(String[] args) { // Erzeugt eine Warteschlange und ergänzt/entfernt Strings. Queue q = new Queue(); while (!StdIn.isEmpty()) { String item = StdIn.readString(); if (!item.equals("-")) q.enqueue(item); else if (!q.isEmpty()) StdOut.print(q.dequeue() + " "); } StdOut.println("(" + q.size() + " left on queue)"); } Listing 1.40: Testclient für Queue
% more tobe.txt to be or not to - be - - that - - - is % java Queue < tobe.txt to be or not to be (2 left on queue)
171
1
Grundlagen
Listing 1.41: FIFO-Warteschlange (Algorithmus 1.3) public class Queue { private Node first; // Referenz auf den zuerst hinzugefügten Knoten. private Node last; // Referenz auf den zuletzt hinzugefügten Knoten. // Anzahl der Elemente in der Warteschlange. private int N; private class Node { // Eingebettete Klasse zur Definition von Knoten. Item item; Node next; } public boolean isEmpty() { public int size() {
return first == null; return N; }
}
// Oder: N == 0.
public void enqueue(Item item) { // Fügt Element am Ende der Liste ein. Node oldlast = last; last = new Node(); last.item = item; last.next = null; if (isEmpty()) first = last; else oldlast.next = last; N++; } public Item dequeue() { // Entfernt Element vom Anfang der Liste. Item item = first.item; first = first.next; if (isEmpty()) last = null; N--; return item; } // iterator()-Implementierung siehe Listing 1.42. // Testclient main() siehe Listing 1.40. }
Diese generische Queue-Implementierung basiert auf der Datenstruktur einer verketteten Liste. Mit ihr können Sie Warteschlangen für jeden beliebigen Datentyp erzeugen. Um Iteration zu unterstützen, müssen Sie den in Listing 1.42 (Bag) hervorgehobenen Code hinzufügen.
172
1.3 Multimengen, Warteschlangen und Stapel
StdIn
StdOut
to
to null
be
to
be null
to
or
to
not
to
to
-
to
be
be
-
-
be
or
or
not
not
that
-
be
not
to
-
to
be
-
be
that null
is
that
be
be
be
or
or
not
to
to
be
or null
or
or
not
not
to
not null
not
to null
to null
to
be null
be null
be null
be
that null
that null
that null
is null
Abbildung 1.41: Ablaufprotokoll des Queue-Entwicklungsclients
Verkettete Listen sind für die Strukturierung einer Datensammlung eine wichtige Alternative zu den Arrays. Aus historischer Sicht steht uns Programmierern diese Alternative schon seit vielen Jahrzehnten zur Verfügung, genau genommen seit den 1950ern, als John McCarthy mit der Entwicklung von LISP ein Meilenstein in der Geschichte der Programmiersprachen gelang. In LISP waren verkettete Listen noch die wichtigste Struktur für Programme und Daten. Aber inzwischen hat man erkannt, dass die Programmierung mit verketteten Listen auch viele Gefahren birgt und der Code bekannterweise schwer zu
173
1
Grundlagen
debuggen ist (wie Sie den Übungen entnehmen können). Moderner Code setzt deshalb auf sichere Zeiger, automatische Speicherbereinigung und abstrakte Datentypen, sodass wir Code zur Listenverarbeitung nur in einigen wenigen Klassen (wie den hier vorgestellten) kapseln müssen.
Multimengen-Implementierung Für die Multimengen-Implementierung unserer Bag-API mit einer verketteten Liste müssen wir lediglich in Stack den Namen von push() in add() ändern und die Implementierung von pop() entfernen (siehe Algorithmus 1.4). Wir könnten dies auch in Queue machen, würden dafür aber etwas mehr Code benötigen. Unsere Implementierung hebt außerdem den Code hervor, der erforderlich ist, wenn wir Stack, Queue und Bag iterierbar machen wollen – durch Traversieren der Liste. Im Falle von Stack liegt die Liste in LIFO-Reihenfolge vor, bei Queue in FIFO-Reihenfolge. Bei Bag liegt zufälligerweise ebenfalls LIFO-Reihenfolge vor, aber hier spielt die Reihenfolge keine Rolle. Wie an dem hervorgehobenen Code von Algorithmus 1.4 zu erkennen ist, besteht der erste Schritt zur Implementierung der Iteration für eine Collection darin, die Bibliothek import java.util.Iterator;
einzubinden, damit unser Code die Java-Schnittstelle Iterator referenzieren kann. Als zweiten Schritt müssen Sie implements Iterable
zur Klassendeklaration hinzufügen, womit wir versprechen, eine iterator()-Methode bereitzustellen. Die iterator()-Methode selbst liefert lediglich ein Objekt von der Klasse zurück, die die Iterator-Schnittstelle implementiert: public Iterator iterator() { return new ListIterator(); }
Dieser Code ist ein Versprechen, eine Klasse zu implementieren, die die Methoden hasNext(), next() und remove() implementiert (diese Methoden werden aufgerufen, wenn ein Client das foreach-Konstrukt verwendet). Um diese Methoden zu implementieren, verwaltet die eingebettete Klasse ListIterator von Algorithmus 1.4 eine Instanzvariable current, die immer auf den aktuellen Knoten in der Liste weist. Anschließend prüft die Methode hasNext(), ob current null ist, und die Methode next() speichert eine Referenz auf das aktuelle Element, aktualisiert current, damit die Variable auf den nächsten Knoten in der Liste zeigt, und liefert die gespeicherte Referenz zurück.
174
1.3 Multimengen, Warteschlangen und Stapel
Listing 1.42: Bag (Algorithmus 1.4) import java.util.Iterator; public class Bag implements Iterable { private Node first; // erster Knoten in der Liste private class Node { Item item; Node next; } public void add(Item item) { // das gleiche wie push() in Stack Node oldfirst = first; first = new Node(); first.item = item; first.next = oldfirst; } public Iterator iterator() { return new ListIterator(); } private class ListIterator implements Iterator { private Node current = first; public boolean hasNext() { return current != null; } public void remove() { } public Item next() { Item item = current.item; current = current.next; return item; } } }
Diese Bag-Implementierung verwaltet eine verkettete Liste von Elementen, die über Aufruf von add() hinzugefügt werden. Der Code für isEmpty() und size() ist der gleiche wie für Stack und wurde deshalb weggelassen. Der Iterator durchläuft die Liste und speichert den aktuellen Knoten in current. Wir können auch Stack und Queue iterierbar machen. Da ihnen die gleiche Datenstruktur zugrunde liegt und Stack und Queue die Liste in LIFO- bzw. FIFO-Reihenfolge verwalten, müssen wir die Algorithmen 1.1 und 1.2 nur um den hier hervorgehobenen Code erweitern.
175
1
Grundlagen
1.3.4 Zusammenfassung Die in diesem Abschnitt vorgestellten Implementierungen für Multimengen, Warteschlangen und Stapel unterstützen Generics und Iteration und bieten damit eine Abstraktionsebene zur Erstellung kompakter Client-Programme, die Collections von Objekten verarbeiten. Als Vorbereitung auf das Studium der Algorithmen und Datenstrukturen sollten Sie sich aus drei Gründen mit diesen abstrakten Datentypen intensiv beschäftigen. Erstens werden wir diese Datentypen als Bausteine für weitere höhere Datenstrukturen einsetzen. Zweitens veranschaulichen sie das Zusammenspiel zwischen Datenstrukturen und Algorithmen und die Herausforderung, gleichzeitig naheliegende Leistungsziele zu erreichen, die sich eigentlich gegenseitig ausschließen. Drittens liegt der Schwerpunkt etlicher unserer Implementierungen auf abstrakten Datentypen, die noch viel mächtigere Operationen auf Collections von Objekten unterstützen und für die wir die hier vorgestellten Implementierungen als Ausgangsbasis verwenden.
Datenstrukturen Wir kennen jetzt zwei Möglichkeiten, Collections von Objekten zu repräsentieren: Arrays und verkettete Listen. Arrays sind in Java integriert; verkettete Listen sind mithilfe einfacher Java-Strukturen (records) leicht zu erzeugen. Beide Alternativen, die oft als sequenzielle Allokation und verkettete Allokation bezeichnet werden, sind von grundlegender Bedeutung. Weiter hinten im Buch entwickeln wir Implementierungen von abstrakten Datentypen, die diese Grundstrukturen auf vielfältige Weise kombinieren und erweitern. Eine wichtige Erweiterung sind Datenstrukturen mit mehreren Referenzen. Zum Beispiel werden wir in den Abschnitten 3.2 und 3.3 Datenstrukturen kennenlernen, die als Binärbäume bezeichnet werden und aus Knoten mit jeweils zwei Referenzen bestehen. Eine weitere wichtige Erweiterung ist, Datenstrukturen zu kombinieren, wie z.B. eine Multimenge von Stapeln oder eine Warteschlange von Arrays usw. So beschäftigen wir uns beispielsweise in Kapitel 4 mit Graphen, die wir als Arrays von Multimengen repräsentieren. Es ist sehr einfach, auf diese Weise Datenstrukturen beliebiger Komplexität zu definieren. Ein wichtiger Grund, warum wir uns auf abstrakte Datentypen konzentrieren, ist der Versuch, diese Komplexität in den Griff zu bekommen.
Tabelle 1.52 Grundlegende Datenstrukturen
176
Datenstruktur
Vorteil
Nachteil
Array
Index bietet direkten Zugriff auf jedes beliebige Element.
Die Größe muss bei der Initialisierung bekannt sein.
Verkettete Liste
Verwendet Speicher, der proportional zur Größe ist.
Benötigt eine Referenz, um auf ein Element zuzugreifen.
1.3 Multimengen, Warteschlangen und Stapel
Unsere Behandlung von Multimengen, Warteschlangen und Stapeln in diesem Abschnitt ist typisch für den Ansatz, den wir in diesem Buch bei der Beschreibung von Datenstrukturen und Algorithmen verfolgen. Wir nähern uns einer neuen Anwendungsdomäne, indem wir die programmiertechnischen Herausforderungen identifizieren und die Probleme mittels Datenabstraktion lösen. Dabei gehen wir folgendermaßen vor:
Wir spezifizieren eine API. Wir entwickeln Client-Code für spezielle Anwendungen. Wir beschreiben eine Datenstruktur (Repräsentation der Wertemenge), die als Grundlage für die Instanzvariablen einer Klasse dienen kann, die einen abstrakten Datentyp implementiert, der die Spezifikationen der API erfüllt.
Wir beschreiben Algorithmen (Ansätze zur Implementierung der Operationen), die als Grundlage für die Implementierung der Instanzmethoden in der Klasse dienen können.
Wir analysieren das Laufzeitverhalten der Algorithmen. Im nächsten Abschnitt werden wir uns ausführlich mit dem letzten Schritt beschäftigen, da er oft ausschlaggebend ist bei der Entscheidung, welche Algorithmen und Implementierungen in realen Anwendungen am nützlichsten sind.
Tabelle 1.53 Beispiele für Datenstrukturen, die in diesem Buch entwickelt wurden Datenstruktur
Abschnitt
ADT
Repräsentation
Gewurzelter Baum
1.5
UnionFind
Arrays von Integer
Binärer Suchbaum
3.2, 3.3
BST
Zwei Referenzen pro Knoten
String
5.1
String
Array, Offset und Länge
Binärer Heap
2.4
PQ
Array von Objekten
Hashtabelle (separate Verkettung)
3.4
SeparateChainingHashST
Arrays von verketteten Listen
Hashtabelle (lineare Sondierung)
3.4
LinearProbingHashST
Zwei Arrays von Objekten
Adjazenzliste eines Graphen
4.1, 4.2
Graph
Array von Bag-Objekten
Trie
5.2
TrieST
Knoten mit Array von Referenzen
Ternärer Such-Trie
5.3
TST
Drei Referenzen pro Knoten
177
1
Grundlagen
1.3
Fragen und Antworten
Frage: Nicht alle Programmiersprachen unterstützen Generics, nicht einmal die ersten Java-Versionen. Welche Alternativen gibt es? Antwort: Eine bereits im Text erwähnte Alternative ist, für jeden Datentyp eine eigene Implementierung vorzusehen. Sie können aber auch einen Stapel von ObjectWerten erstellen und dann bei Verwendung von pop() im Client-Code das zurückgelieferte Objekt in den gewünschten Typ umwandeln. Dieser Ansatz hat jedoch den Nachteil, dass Fehler aufgrund nicht übereinstimmender Datentypen erst zur Laufzeit auffallen. Wenn Sie jedoch mit Generics arbeiten und Code schreiben, der ein Objekt des falschen Typs auf dem Stapel ablegt: Stack stack = new Stack(); Apple a = new Apple(); ... Orange b = new Orange(); ... stack.push(a); ... stack.push(b); // Kompilierfehler
dann erhalten Sie einen Kompilierfehler: push(Apple) in Stack cannot be applied to (Orange)
Die Fähigkeit, solche Fehler bereits beim Kompilieren festzustellen, ist Grund genug, Generics zu verwenden. Frage: Warum sind in Java generische Arrays nicht erlaubt? Antwort: Dieser Punkt ist sogar unter Experten umstritten. Sie müssten schon selbst zum Experten werden, um die Diskussionen zu verstehen! Für den Anfang reicht es, sich mit kovarianten Arrays und Typlöschung (type erasure) zu beschäftigen. Frage: Wie erzeuge ich ein Array von Stapeln von Strings? Antwort: Arbeiten Sie mit Typumwandlung: Stack[] a = (Stack[]) new Stack[N];
Warnung: Diese Typumwandlung in Client-Code unterscheidet sich von der, die in Abschnitt Generics beschrieben ist. Vielleicht haben Sie erwartet, Object zu verwenden und nicht Stack. Bei der Verwendung von Generics prüft Java beim Kompilieren auf Typsicherheit, verwirft aber die Informationen zur Laufzeit, sodass der Typ Stack[], oder kurz Stack[], übrig bleibt, den wir in Stack[] umwandeln müssen. Frage: Was passiert, wenn mein Programm pop() für einen leeren Stapel aufruft? Antwort: Das hängt von der Implementierung ab. Bei unserer Implementierung in Listing 1.39 erhalten Sie eine NullPointerException. In unserer Implementierung
178
Fragen und Antworten
auf der Website zum Buch werfen wir eine Ausnahme zur Laufzeit, damit Benutzer den Fehler besser finden. Im Allgemeinen ist es ratsam, in Code, der von vielen Personen verwendet wird, so viele Prüfungen wie möglich einzubauen. Frage: Warum sich die Mühe machen, die Größe von Arrays anzupassen, wo es doch die verketteten Listen gibt? Antwort: Wir werden noch auf einige Beispiele für ADT-Implementierungen stoßen, die Arrays verwenden, um Operationen durchzuführen, die mit verketteten Listen nur schwer zu realisieren sind. ResizingArrayStack ist ein Modell, wie die Speicherbelegung dieser ADTs kontrolliert werden kann. Frage: Warum empfiehlt es sich, Node als eingebettete Klasse zu deklarieren? Warum als private? Antwort: Indem wir die eingebettete Klasse Node als private deklarieren, beschränken wir den Zugriff auf die Methoden und Instanzvariablen der eingebetteten Klasse. Eines der besonderen Merkmale einer als private deklarierten eingebetteten Klasse ist, dass ihre Instanzvariablen für die umschließende Klasse direkt zugänglich sind, der direkte Zugriff für alle anderen aber verwehrt ist. Es ist also nicht nötig, die Instanzvariablen als public oder private zu deklarieren. Hinweis für Profis: Eine eingebettete Klasse, die nicht static ist, wird als innere Klasse bezeichnet, sodass technisch gesehen unsere Node-Klassen innere Klassen sind, obwohl diejenigen, die nicht generisch sind, auch static sein könnten. Frage: Wenn ich javac Stack.java eingebe, um Algorithmus 1.2 und ähnliche Programme auszuführen, erhalte ich zusätzlich zu der Datei Stack.class die Datei Stack$Node.class. Was ist das für eine Datei? Antwort: Diese Datei wird für die eingebettete Klasse Node angelegt. Die Namensgebung von Java sieht dabei so aus, dass mittels eines $-Zeichens der Name der eingebetteten Klasse an den Namen der äußeren Klasse angehängt wird. Frage: Gibt es Java-Bibliotheken für Stapel und Warteschlangen? Antwort: Ja und nein. Java verfügt über eine integrierte Bibliothek namens java.util.Stack, aber Sie sollten darauf verzichten, sie zu verwenden, wenn Sie
einen Stapel benötigen. Sie finden dort nämlich mehrere zusätzliche Operationen, die normalerweise nicht mit Stapeln in Zusammenhang gebracht werden, zum Beispiel das i-te Element ermitteln. Sie erlaubt sogar, ein Element unten im Stapel (und nicht oben) hinzuzufügen, sodass sie auch eine Warteschlange implementieren kann! Auch wenn diese zusätzlichen Operationen auf den ersten Blick ein Bonus zu sein scheinen, sind sie eigentlich ein Fluch. Wir verwenden Datentypen ja nicht deshalb, weil sie jede verfügbare Operation bereitstellen, sondern weil sie uns erlauben, genau die Operationen auszudrücken, die wir benötigen. Der große Vorzug dieser Vorgehensweise ist, dass uns das System dann davon abhalten kann, Operationen auszuführen, die wir eigentlich gar nicht wollen. Die API java.util.Stack ist ein
179
1
Grundlagen
Beispiel für eine breite Schnittstelle. Breite Schnittstellen versuchen wir im Allgemeinen zu meiden. Frage: Sollte es einem Client erlaubt sein, null-Elemente in einen Stapel oder eine Warteschlange einzufügen? Antwort: Diese Frage wird häufig im Zusammenhang mit der Implementierung von Collections in Java gestellt. Unsere Implementierung (und die Java-Bibliotheken zu Stapeln und Warteschlangen) erlauben das Einfügen von null-Werten. Frage: Wie sollte sich der Stack-Iterator verhalten, wenn der Client während der Iteration push() oder pop() aufruft? Antwort: Eine Ausnahme des Typs java.util.ConcurrentModificationException werfen, um ihn zu einem Fail-Fast-Iterator (Fail-Fast bedeutet soviel wie „Schneller Abbruch“) zu machen (siehe Übung 1.3.50). Frage: Kann ich die foreach-Schleife mit Arrays verwenden? Antwort: Ja (auch wenn Arrays nicht die Schnittstelle Iterable implementieren). Der folgende Einzeiler gibt die Befehlszeilenargumente aus: public static void main(String[] args) { for (String s : args) StdOut.println(s); }
Frage: Kann ich eine foreach-Schleife mit Strings verwenden? Antwort: Nein. String implementiert nicht Iterable. Frage: Warum gibt es nicht einfach einen einzigen Datentyp Collection, der Methoden implementiert, um Elemente hinzuzufügen, das zuletzt eingefügte Element zu entfernen, das zuerst eingefügte Element zu entfernen, ein zufälliges Element zu entfernen, über die Collection zu iterieren, die Anzahl der Elemente in der Collection zurückzuliefern und was auch immer für Operationen wir benötigen? Dann wäre alles in einer einzigen Klasse implementiert, die vielen Clients von Nutzen wäre. Antwort: Dies wäre ein Beispiel für eine breite Schnittstelle. In Java finden Sie solche Implementierungen in den Klassen java.util.ArrayList und java.util.LinkedList. Einer der Gründe, auf breite Schnittstellen zu verzichten, ist, dass es keine Gewissheit gibt, dass alle Operationen effizient implementiert sind. In diesem Buch verwenden wir immer die API als Ausgangspunkt für den Entwurf effizienter Algorithmen und Datenstrukturen, was sicherlich einfacher für Schnittstellen mit nur wenigen Operationen ist als für eine Schnittstelle mit vielen Operationen. Ein weiterer Grund ist, dass schmale Schnittstellen von den Client-Programmen mehr Disziplin fordern, was den Client-Code verständlicher macht. Wenn ein Client Stack verwendet und ein anderer Queue, können wir eigentlich davon ausgehen, dass für den ersten die LIFO-Strategie wichtig ist und für den zweiten die FIFOStrategie.
180
Allgemeine Übungen
1.3
Allgemeine Übungen
1.
Fügen Sie FixedCapacityStackOfStrings eine Methode isFull() hinzu.
2.
Welche Ausgabe erhalten Sie, wenn Sie java Stack für die folgende Eingabe ausführen? it was - the best - of times - - - it
3.
was - the - -
Angenommen, ein Client führt eine abwechselnde Folge von push- und pop-Operationen (auf dem Stapel) aus. Die push-Operationen legen die ganzen Zahlen von 0 bis 9 der Reihenfolge nach auf dem Stapel ab. Die pop-Operationen geben den Rückgabewert aus. Welche der folgenden Sequenzen kann es nicht geben? a. 4 3 2 1 0 9 8 7 6 5 b. 4 6 8 7 5 3 2 9 0 1 c. 2 5 6 7 4 8 9 3 1 0 d. 4 3 2 1 0 5 6 7 8 9 e. 1 2 3 4 5 6 9 8 7 0 f. 0 4 6 5 3 8 1 7 2 9 g. 1 4 7 9 8 6 5 3 0 2 h. 2 1 4 3 6 5 8 7 9 0
4.
Schreiben Sie einen Stack-Client Parentheses, der einen Text von der Standardeingabe einliest und mithilfe eines Stapels prüft, ob die Klammersetzung korrekt ist. Ihr Programm sollte beispielsweise true ausgeben für [()]{}{[()()]()} und false für [(]).
5.
Was gibt das folgende Codefragment aus, wenn N gleich 50 ist? Genereller gefragt: Was macht das unten stehende Codefragment, wenn ihm ein positiver Integer N präsentiert wird? (Geben Sie eine allgemeine Beschreibung, ohne sich auf Implementierungsdetails zu beziehen.) Stack stack = new Stack(); while (N > 0) { stack.push(N % 2); N = N / 2; } for (int d : stack) StdOut.print(d); StdOut.println();
Antwort: Gibt die Binärdarstellung von N aus (110010, wenn N gleich 50 ist). 6.
Was macht das folgende Codefragment mit der Warteschlange q? Stack stack = new Stack(); while (!q.isEmpty()) stack.push(q.dequeue()); while (!stack.isEmpty()) q.enqueue(stack.pop());
7.
Ergänzen Sie Stack um eine Methode peek(), die das zuletzt auf den Stapel gelegte Element zurückliefert (ohne es zu entfernen).
181
1
Grundlagen
8.
Geben Sie Inhalt und Größe des Arrays aus DoublingStackOfStrings an für die Eingabe it was - the best - of times - - - it was - the - -
9.
Schreiben Sie ein Programm, das einen Ausdruck mit fehlenden öffnenden Klammern von der Befehlszeile entgegennimmt und den äquivalenten Infix-Ausdruck mit den ergänzten Klammern ausgibt. Lautet zum Beispiel die Eingabe 1 + 2 ) * 3
4 ) * 5
6 ) ) )
so sollte Ihr Programm Folgendes ausgeben: ( ( 1 + 2 ) * ( ( 3
4 ) * ( 5
6 ) ) )
10.
Schreiben Sie einen Filter InfixToPostfix, der einen arithmetischen Ausdruck von Infix- in Postfix-Schreibweise umwandelt.
11.
Schreiben Sie ein Programm EvaluatePostfix, das einen Postfix-Ausdruck von der Standardeingabe einliest, ihn auswertet und den Ergebniswert ausgibt. (Wenn Sie die Ausgabe Ihres Programms der vorherigen Übung als Eingabe für dieses Programm nehmen, erhalten Sie das Verhalten von Evaluate.)
12.
Schreiben Sie einen iterierbaren Stack-Client mit einer statischen Methode copy(), die einen Stapel von Strings als Argument entgegennimmt und eine Kopie des Stapels zurückliefert. Hinweis: Diese Übung belegt eindrucksvoll, wie wertvoll Iteratoren sind. Ohne Iterator wäre es nämlich nicht möglich, die gewünschte Funktionalität zu implementieren, ohne die elementare API zu ändern.
13.
Angenommen, ein Client führt abwechselnd enqueue- und dequeue-Operationen (für Warteschlangen) durch. Die enqueue-Operationen legen die ganzen Zahlen von 0 bis 9 der Reihe nach in der Warteschlange ab. Die dequeue-Operationen geben den Rückgabewert aus. Welche der folgenden Sequenzen kann es nicht geben? a. 0 1 2 3 4 5 6 7 8 9 b. 4 6 8 7 5 3 2 9 0 1 c. 2 5 6 7 4 8 9 3 1 0 d. 4 3 2 1 0 5 6 7 8 9
182
14.
Entwickeln Sie eine Klasse ResizingArrayQueueOfStrings, die die Warteschlangenabstraktion mithilfe eines Arrays fester Größe implementiert. Verbessern Sie anschließend Ihre Implementierung, indem Sie die Größenbeschränkung mithilfe der Technik der Arrayverdoppelung beseitigen.
15.
Schreiben Sie einen Queue-Client, der ein Befehlszeilenargument k entgegennimmt und den k-letzten String ausgibt, der auf der Standardeingabe gefunden wird (unter der Voraussetzung, dass die Standardeingabe k oder mehr Strings hat).
16.
Schreiben Sie eine Methode readDates() für Date (mit der Methode readInts() in Listing 1.28 als Vorlage), die Datumsangaben in einem der Formate der Tabelle 1.43 von der Standardeingabe einliest und ein Array dieser Datumsangaben zurückliefert.
17.
Machen Sie Übung 1.3.16 für Transaction.
Übungen zu verketteten Listen
1.3
Übungen zu verketteten Listen
Mit den Übungen in diesem Abschnitt sollen Sie Erfahrungen in der Arbeit mit verketteten Listen sammeln. Am besten fertigen Sie grafische Darstellungen an, die sich der visuellen Darstellung aus dem Text bedienen. 18.
Angenommen, x ist ein Knoten in einer verketteten Liste und nicht der letzte. Was bewirkt das folgende Codefragment? x.next = x.next.next;
Antwort: Es löscht den Knoten, der direkt auf x folgt, aus der Liste. 19.
Erstellen Sie ein Codefragment, das den letzten Knoten in einer verketteten Liste entfernt, deren erster Knoten first lautet.
20.
Schreiben Sie eine Methode delete(), die ein int-Argument k übernimmt und das k-te Element in einer verketteten Liste löscht, sofern dieses existiert.
21.
Schreiben Sie eine Methode find(), die eine verkettete Liste und einen String key als Argumente übernimmt und true zurückliefert, wenn einer der Knoten in der Liste key in seinem item-Feld gespeichert hat, bzw. ansonsten false.
22.
Angenommen, x ist ein Knoten in einer verketteten Liste. Was macht das folgende Codefragment? t.next = x.next; x.next = t;
Antwort: Es fügt den Knoten t direkt hinter dem Knoten x ein. 23.
Warum bewirkt das folgende Codefragment nicht das Gleiche wie das Fragment aus der vorherigen Frage? x.next = t; t.next = x.next;
Antwort: Wenn es Zeit ist, t.next zu aktualisieren, ist x.next nicht mehr der ursprüngliche Knoten, der auf x folgte, sondern vielmehr t selbst! 24.
Schreiben Sie eine Methode removeAfter(), die ein Node-Objekt einer verketteten Liste als Argument übernimmt und den darauffolgenden Knoten entfernt (bzw. nichts macht, wenn das Argument oder das next-Feld in dem Argumentknoten null ist).
25.
Schreiben Sie eine Methode insertAfter(), die zwei Node-Objekte einer verketteten Liste als Argumente übernimmt und das zweite nach dem ersten in die Liste einfügt (bzw. nichts macht, wenn eines der Argumente null ist).
26.
Schreiben Sie eine Methode remove(), die eine verkettete Liste und einen String key als Argumente übernimmt und alle Knoten in der Liste entfernt, die key im item-Feld gespeichert haben.
183
1
Grundlagen
27.
Schreiben Sie eine Methode max(), die eine Referenz auf den ersten Knoten in einer verketteten Liste als Argument übernimmt und den Wert des maximalen Schlüssels in der Liste zurückliefert. Gehen Sie davon aus, dass alle Schlüssel positive Integer sind, und liefern Sie 0 zurück, wenn die Liste leer ist.
28.
Entwickeln Sie eine rekursive Lösung für die vorherige Übung.
29.
Schreiben Sie eine Queue-Implementierung, die eine ringförmig verkettete Liste verwendet. Diese Datenstruktur entspricht einer verketteten Liste, weist aber keine null-Referenzen auf. Weiterhin gilt: immer wenn die Liste nicht leer ist, ist der Wert von last.next gleich first. Richten Sie nur eine Node-Instanzvariable ein (last).
30.
Schreiben Sie eine Funktion, die das erste Node-Objekt in einer verketteten Liste als Argument übernimmt, die Liste (destruktiv) umkehrt und das erste Node-Objekt als Ergebnis zurückliefert. Iterative Lösung: Für diese Aufgabe verwalten wir Referenzen auf drei aufeinanderfolgende Knoten in der verketteten Liste: reverse, first und second. Bei jeder Iteration extrahieren wir den Knoten first aus der ursprünglichen Liste und fügen ihn am Anfang der umgekehrten Liste ein. Wir formulieren die Invariante, dass first der erste Knoten der restlichen ursprünglichen Liste ist, second der zweite Knoten der restlichen ursprünglichen Liste und reverse der erste Knoten der resultierenden umgekehrten Liste. public Node reverse(Node x) { Node first = x; Node reverse = null; while (first != null) { Node second = first.next; first.next = reverse; reverse = first; first = second; } return reverse; }
Wenn Sie Code mit verketteten Listen schreiben, müssen Sie immer sorgfältig darauf achten, dass Sie die Spezialfälle (eine leere verkettete Liste, eine Liste mit nur einem oder zwei Knoten) und die Grenzfälle (das erste oder letzte Element) korrekt behandeln. Meist sind diese Fälle weit komplizierter als die Behandlung des Standardfalls. Rekursive Lösung: Angenommen, die verkettete Liste hat N Knoten, dann kehren wir rekursiv die letzten N−1 Knoten um und hängen anschließend sorgfältig den ersten Knoten ans Ende an.
184
Übungen zu verketteten Listen
public Node reverse(Node first) { if (first == null) return null; if (first.next == null) return first; Node second = first.next; Node rest = reverse(second); second.next = first; first.next = null; return rest; } 31.
Implementieren Sie eine eingebettete Klasse DoubleNode zur Erzeugung doppelt verketteter Listen, deren Knoten jeweils eine Referenz auf das vorangehende und das folgende Element in der Liste enthalten (null, wenn es kein solches Element gibt). Implementieren Sie dann statische Methoden für die folgenden Aufgaben: am Anfang einfügen, am Ende einfügen, vom Anfang entfernen, vom Ende entfernen, vor einem gegebenen Knoten einfügen, nach einem gegebenen Knoten einfügen und einen gegebenen Knoten entfernen.
185
1
Grundlagen
1.3
Knifflige Aufgaben
32.
Steque: Steque (stack-ended queue) ist ein Datentyp, der die Operationen push(), pop() und enqueue() unterstützt. Formulieren Sie eine API für diesen abstrakten Datentyp. Entwickeln Sie eine Implementierung auf der Basis einer verketteten Liste.
33.
Deque: Deque (double-ended queue) ist ein Datentyp, der sich wie ein Stapel oder eine Warteschlange verhält, aber das Hinzufügen und Entfernen von Elementen an beiden Enden unterstützt. Ein Deque speichert eine Collection von Elementen und unterstützt die folgende API:
Tabelle 1.54 API für eine generische Warteschlange mit zwei Enden (Deque) public class Deque implements Iterable Deque()
Erzeugt einen leeren Deque.
isEmpty()
Ist der Deque leer?
size()
Anzahl der Elemente im Deque.
void
pushLeft(Item item)
Fügt ein Element am linken Ende hinzu.
void
pushRight(Item item)
Fügt ein Element am rechten Ende hinzu.
Item
popLeft()
Entfernt ein Element vom linken Ende.
Item
popRight()
Entfernt ein Element vom rechten Ende.
boolean int
Schreiben Sie eine Klasse Deque, die eine doppelt verkettete Liste verwendet, um diese API zu implementieren, und eine Klasse ResizingArrayDeque, die ein Array variabler Größe verwendet. 34.
Zufallsmultimenge: Eine Zufallsmultimenge speichert eine Collection von Elementen und unterstützt die folgende API:
Tabelle 1.55 API für eine generische Zufallsmultimenge public class RandomBag implements Iterable
boolean int void
186
RandomBag()
Erzeugt eine leere Zufallsmultimenge.
isEmpty()
Ist die Multimenge leer?
size()
Anzahl der Elemente in der Multimenge.
add(Item item)
Fügt ein Element hinzu.
Knifflige Aufgaben
Schreiben Sie eine Klasse RandomBag, die diese API implementiert. Beachten Sie, dass diese API die gleiche ist wie für Bag, abgesehen von dem Zusatz „Zufall“ (random), der Ihnen verrät, dass die Iteration die Elemente in zufälliger Reihenfolge durchläuft (alle N! Permutationen gleich wahrscheinlich, unabhängig von dem verwendeten Iterator). Hinweis: Legen Sie die Elemente in einem Array ab und randomisieren Sie deren Reihenfolge im Konstruktor des Iterators. 35.
Zufallswarteschlange: Eine Zufallswarteschlange speichert eine Sammlung von Elementen und unterstützt die folgende API:
Tabelle 1.56 API für eine generische Zufallswarteschlange public class RandomQueue RandomQueue()
Erzeugt eine leere Zufallswarteschlange.
isEmpty()
Ist die Warteschlange leer?
void
enqueue(Item item)
Fügt ein Element hinzu.
Item
dequeue()
Entfernt ein zufälliges Element und liefert es zurück (Ziehen ohne Zurücklegen).
Item
sample()
Liefert ein zufälliges Element zurück, ohne es zu entfernen (Ziehen mit Zurücklegen).
boolean
Schreiben Sie eine Klasse RandomQueue, die diese API implementiert. Hinweis: Verwenden Sie eine Arrayrepräsentation (variabler Größe). Um ein Element zu entfernen, vertauschen Sie ein Element an einer beliebigen Position (Index läuft von 0 bis N1) mit dem Element an der letzten Position (Index N1). Löschen Sie dann das letzte Element und liefern Sie es wie in ResizingArrayStack zurück. Schreiben sie einen Client, der Bridgeblätter (jeweils 13 Karten) unter Verwendung von RandomQueue ausgibt. 36.
Zufallsiterator: Schreiben Sie einen Iterator für RandomQueue aus der vorherigen Übung, der die Elemente in zufälliger Reihenfolge zurückliefert.
37.
Josephus-Problem: Das Josephus-Problem ist bereits seit der Antike bekannt: N Personen sind in einer Notlage und einigen sich auf folgende Strategie, um die Anzahl der Bevölkerung zu verringern. Sie stellen sich in einem Kreis auf (ihre Positionen sind nummeriert von 0 bis N−1) und eliminieren bei jedem Durchgang die jeweils M-te Person im Kreis, bis nur noch eine Person übrig ist. Die Legende besagt, dass Josephus ausgerechnet hat, wo er sitzen muss, um nicht eliminiert zu werden. Schreiben Sie einen Queue-Client Josephus, der N und M aus der Befehlszeile entgegennimmt und die Reihenfolge ausgibt, in der die Personen eliminiert werden (und so Josephus zeigen würde, wo er im Kreis zu sitzen hätte). % java Josephus 7 2 1 3 5 0 4 2 6
187
1
Grundlagen
38.
Lösche das k-te Element: Implementieren Sie eine Klasse, die die folgende API unterstützt:
Tabelle 1.57 API für eine generische verallgemeinerte Warteschlange public class GeneralizedQueue GeneralizedQueue()
Erzeugt eine leere Warteschlange.
isEmpty()
Ist die Warteschlange leer?
void
insert(Item x)
Fügt ein Element hinzu.
Item
delete(int k)
Entfernt das Element, das als k-letztes eingefügt wurde, und liefert es zurück.
boolean
Entwickeln Sie zuerst eine Implementierung, die eine Arrayimplementierung verwendet, und dann eine Implementierung mit einer verketteten Liste. Hinweis: Die Algorithmen und Datenstrukturen, die wir in Kapitel 3 vorstellen, ermöglichen die Entwicklung einer Implementierung, bei der die Ausführungszeiten von insert() und delete() garantiert proportional zum Logarithmus der Anzahl der Elemente in der Warteschlange sind – siehe Übung 3.5.27. 39.
Ringpuffer: Ein Ringpuffer (oder eine kreisförmige Warteschlange) ist eine FIFODatenstruktur einer festen Größe N. Sie eignet sich besonders gut für die Übertragung von Daten zwischen asynchronen Prozessen und für die Speicherung von Logdateien. Wenn der Puffer leer ist, wartet der Konsument, bis Daten abgelegt werden; wenn der Puffer voll ist, wartet der Produzent, bevor er neue Daten ablegt. Entwickeln Sie eine API für einen RingBuffer und eine Implementierung, die eine Arrayrepräsentation verwendet (wobei die Füllung des Arrays bei Erreichen des Arrayendes automatisch am Anfang fortzusetzen ist).
40.
Nach vorne schieben: Übernehmen Sie eine Folge von Zeichen aus der Standardeingabe und speichern Sie die Zeichen ohne Duplikate in einer verketteten Liste. Wenn Sie ein Zeichen einlesen, das Sie zuvor noch nicht gesehen haben, fügen Sie es am Anfang der Liste ein. Wenn Sie ein bereits vorhandenes Zeichen (Duplikat) einlesen, löschen Sie es aus der Liste und fügen es danach erneut am Anfang ein. Nennen Sie Ihr Programm MoveToFront: Es implementiert die sogenannte Move-to-Front-Strategie, die sich besonders gut für Caching, Datenkomprimierung und viele andere Anwendungen eignet, bei denen die Wahrscheinlichkeit, mit der auf ein Element zugegriffen wird, für Elemente, auf die gerade kürzlich zugegriffen wurde, größer ist als für die anderen Elemente.
41.
Eine Warteschlange kopieren: Erzeugen Sie einen neuen Konstruktor, sodass durch Queue r = new Queue(q); r zu einem Verweis auf eine neue und unabhängige Kopie der Warteschlange q
wird. Es sollte möglich sein, von q oder r Elemente zu entfernen oder welche hinzuzufügen, ohne dass es jeweils die andere Warteschlange stört. Hinweis:
188
Knifflige Aufgaben
Löschen Sie alle Elemente aus q und stellen Sie diese Elemente sowohl in q als auch in r ein. 42.
Einen Stapel kopieren: Erzeugen Sie einen neuen Konstruktor für die Implementierung von Stack mithilfe einer verketteten Liste, sodass durch Stack t = new Stack(s); t zu einem Verweis auf eine neue und unabhängige Kopie des Stapels s wird.
43.
Dateien auflisten: Ein Ordner ist eine Liste von Dateien und Ordnern. Schreiben Sie ein Programm, das den Namen eines Ordners als Befehlszeilenargument entgegennimmt und alle im Ordner enthaltenen Dateien ausgibt. Die Inhalte etwaiger Unterordner sollen rekursiv (und eingerückt) aufgelistet werden. Hinweis: Verwenden Sie eine Warteschlange und informieren Sie sich über java.io.File.
44.
Texteditor-Puffer: Entwickeln Sie einen Datentyp für einen Puffer in einem Texteditor, der die folgende API implementiert.
Tabelle 1.58 API für einen Textpuffer public class Buffer Buffer()
Erzeugt einen leeren Puffer.
void
insert(char c)
Fügt c an der Cursorposition ein.
char
delete()
Löscht das Zeichen an der Cursorposition und liefert es zurück.
void
left(int k)
Verschiebt den Cursor k Positionen nach links.
void
right(int k)
Verschiebt den Cursor k Positionen nach rechts.
size()
Anzahl der Zeichen im Puffer.
int
Hinweis: Verwenden Sie zwei Stapel. 45.
Erzeugbarkeit von Stapeln: Angenommen wir haben, wie bei unserem Stack-Testclient, eine Folge von abwechselnden push- und pop-Operationen, wobei die Integer 0, 1, …, N-1 in dieser Reihenfolge (push-Anweisungen) sich abwechseln mit N Minuszeichen (pop-Anweisungen). Entwickeln Sie einen Algorithmus, der feststellt, ob die abwechselnde Folge zu einem Unterlauf des Stapels führt. (Der von Ihnen verwendete Speicherplatz muss unabhängig von N sein und Sie können die Integer nicht in einer Datenstruktur speichern.) Entwickeln Sie einen Algorithmus mit linearer Laufzeit, der feststellt, ob eine gegebene Permutation von unserem Testclient als Ausgabe erzeugt werden kann (in Abhängigkeit davon, wo die pop-Anweisungen auftreten). Lösung: Es kommt zu einem Unterlauf im Stapel, wenn es einen Integer k gibt, sodass die ersten k pop-Operationen vor den ersten k push-Operationen auftreten. Wenn eine gegebene Permutation erzeugt werden kann, wird sie eindeutig wie
189
1
Grundlagen
folgt erzeugt: Wenn der nächste Integer in der Ausgabepermutation oben im Stapel ist, entferne ihn; andernfalls lege ihn auf dem Stapel ab. 46.
Verbotenes Tripel zum Erzeugen von Stapeln: Beweisen Sie, dass von einem Stapel genau dann eine Permutation wie in der vorherigen Übung erzeugt werden kann, wenn es keine verbotenen Tripel (a, b, c) gibt, sodass a N, die innere Schleife besteht aus den Anweisungen in der whileSchleife, das Kostenmodell ist die Vergleichsoperation (geerbt von der binären Suche) und die Analyse ergibt sich direkt aus der Analyse der binären Suche – die Anzahl der Vergleiche beträgt höchstens M (lg N + 1). Daraus schließen wir, dass die Wachstumsordnung der Laufzeit für die Berechnung der Positivliste höchstens M lg N beträgt, in Abhängigkeit von folgenden Überlegungen:
Wenn N klein ist, können die Eingabe-Ausgabe-Kosten dominieren. Die Anzahl der Vergleiche hängt von der Eingabe ab – sie liegt zwischen ∼M und ∼M lg N, je nachdem wie viele der Zahlen auf der Standardeingabe in der Positivliste sind und wie lange die binäre Suche benötigt, diese zu finden (der Wert liegt normalerweise bei ∼M lg N).
Wir nehmen an, dass die Kosten von Arrays.sort() klein sind im Vergleich zu M lg N. Arrays.sort() implementiert den Mergesort-Algorithmus; in Abschnitt 2.2 werden wir lernen, dass die Wachstumsordnung der Laufzeit von Mergesort N log N beträgt (siehe Satz G in Kapitel 2), sodass die Annahme berechtigt ist.
204
1.4 Analyse der Algorithmen
Damit unterstützt das Modell unsere Hypothese aus Abschnitt 1.1, dass mit dem BinäreSuche-Algorithmus auch Berechnungen für große M und N möglich sind. Wenn wir die Länge des Standardeingabestroms verdoppeln, können wir erwarten, dass sich auch die Laufzeit verdoppelt. Wenn wir die Größe der Positivliste verdoppeln, können wir davon ausgehen, dass sich die Laufzeit nur geringfügig erhöht. Mathematische Modelle für die Analyse von Algorithmen zu entwickeln, ist ein vielversprechender Forschungsbereich, der im Rahmen dieses Buches leider nicht ausführlich behandelt werden kann. Wie Sie jedoch im Zusammenhang mit der binären Suche, Mergesort und vielen anderen Algorithmen sehen werden, ist es wichtig, mathematische Modelle zu verstehen, um die Effizienz grundlegender Algorithmen beurteilen zu können. Aus diesem Grund gehen wir häufig ins Detail und/oder zitieren die Ergebnisse klassischer Studien. Dabei werden uns verschiedene interessante Funktionen und Approximationen begegnen, die in der mathematischen Analyse weit verbreitet sind. Zu Referenzzwecken haben wir einige dieser Informationen in den folgenden Tabellen zusammengefasst.
Tabelle 1.63 Häufige Funktionen in der Algorithmusanalyse Beschreibung
Notation
Definition
Abrundungsfunktion (floor )
⎢x ⎦ ⎥ ⎣
größte ganze Zahl nicht größer als x
Aufrundungsfunktion (ceiling )
⎡ ⎢x ⎤ ⎥
kleinste ganze Zahl nicht kleiner als x
Natürlicher Logarithmus
ln N
logeN (x, sodass ex = N)
Binärer Logarithmus
lg N
log2N (x, sodass 2x = N)
Ganzzahliger binärer Logarithmus
⎢ lg N ⎦ ⎥ ⎣
größte ganze Zahl nicht größer als lg N (# Bits in der Binärrepräsentation von N) − 1
Harmonische Reihe
HN
1+1 2+1 3+1 4 + ...+1 N
Fakultät
N!
1× 2×3× 4 × ...× N
Tabelle 1.64 Nützliche Approximationen für die Algorithmusanalyse Beschreibung
Approximation
Harmonische Reihe
H N = 1+1 2+1 3+1 4 + ...+1 N ~ ln N
Dreieckszahlen
1+ 2+ 3+ 4 + ...+ N ~ N 2 2
Geometrische Reihe
1+ 2+ 4 + 8 + ...+ N = 2N −1 ~ 2N , wenn N = 2n
Stirlingformel
lg N ! = lg1+ lg2+ lg3+ lg 4 + ...lg N ~ N lg N
Binomialkoeffizienten
⎛N ⎞ k ⎜ ⎟ ~ N k !, wenn k eine kleine Konstante ist ⎝k⎠
Exponential
(1−1 x )
x
~1 e
205
1
Grundlagen
1.4.4 Klassifikationen der Wachstumsordnung Die Implementierung von Algorithmen basiert auf einigen wenigen primitiven Strukturen (Anweisungen, Bedingungen, Schleifen und Methodenaufrufe), weswegen uns eine relativ kleine Auswahl von Funktionen der Problemgröße N genügt, um die Wachstumsordnung der Kosten ausdrücken zu können. Diese Funktionen sind in der nachfolgenden Liste zusammengefasst, einschließlich ihrer Bezeichnungen, typischem Code zu jeder Funktion und Beispielen.
Konstante Wachstumsordnung Programme mit Laufzeiten einer konstanten Wachstumsordnung führen zur Erledigung der ihnen gestellten Aufgabe eine feste Anzahl von Anweisungen aus. Folglich hängt die Laufzeit nicht von der Problemgröße ab. Die meisten Java-Operationen weisen eine konstante Laufzeit auf.
Logarithmische Wachstumsordnung Programme mit Laufzeiten einer logarithmischen Wachstumsordnung sind kaum langsamer als Programme konstanter Laufzeit. Das klassische Beispiel für Programme, deren Laufzeit logarithmisch mit der Problemgröße zunimmt, ist die binäre Suche (siehe BinarySearch in Listing 1.4). Da die Basis des Logarithmus für die Wachstumsordnung unerheblich ist (Logarithmen zu einer konstanten Basis unterscheiden sich nur durch einen konstanten Faktor), verwenden wir gewöhnlich log N, um die Wachstumsordnung anzugeben.
Lineare Wachstumsordnung Sehr verbreitet sind Programme, die für die Verarbeitung jeder einzelnen Eingabe eine konstante Zeit benötigen oder auf einer einzigen for-Schleife basieren. Die Wachstumsordnung eines solchen Programms wird als linear bezeichnet – seine Laufzeit ist direkt proportional zur Problemgröße N.
Leicht überlineare Wachstumsordnung Wir verwenden den Begriff leicht überlinear, um Programme zu beschreiben, deren Laufzeit für ein Problem der Größe N die Wachstumsordnung N log N hat. Auch hier spielt die Basis des Logarithmus keine Rolle. Typische Beispiele für leicht überlineare Algorithmen sind Merge.sort() (siehe Algorithmus 2.4) und Quick.sort() (siehe Algorithmus 2.5).
Quadratische Wachstumsordnung Ein typisches Programm, dessen Laufzeit die Wachstumsordnung N 2 hat, enthält zwei ineinander verschachtelte for-Schleifen, mit deren Hilfe auf allen Paare, die aus N Elementen gebildet werden können, irgendwelche Berechnungen ausgeführt werden. Die elementaren Sortieralgorithmen Selection.sort() (siehe Algorithmus 2.1) und Insertion.sort() (siehe Algorithmus 2.2) sind typische Beispiele für Programme dieser Kategorie.
206
1.4 Analyse der Algorithmen
Kubische Wachstumsordnung Ein typisches Programm, dessen Laufzeit die Wachstumsordnung N 3 hat, verfügt über drei verschachtelte for-Schleifen, mit deren Hilfe auf allen Tripeln, die aus N Elementen gebildet werden können, irgendwelche Berechnungen ausgeführt werden. Ein typisches Beispiel hierfür ist ThreeSum in diesem Abschnitt.
Exponentielle Wachstumsordnung In Kapitel 6 (und nicht früher!) werden wir uns mit Programmen beschäftigen, deren Laufzeiten proportional zu 2N sind. Im Allgemeinen verwenden wir den Begriff exponentiell für Algorithmen mit der Wachstumsordnung bN für alle Konstanten b>1, auch wenn verschiedene Werte für b zu extrem unterschiedlichen Laufzeiten führen. Exponentielle Algorithmen sind extrem langsam – Sie werden sie nie für größere Probleme ausführen. In der Algorithmentheorie kommt ihnen allerdings eine wichtige Rolle zu, da es eine große Gruppe von Problemen gibt, für die ein exponentieller Algorithmus die beste Wahl zu sein scheint.
Tabelle 1.65 Zusammenfassung der häufigsten Hypothesen zu den Wachstumsordnungen Beschreibung
Wachstumsordnung
Typischer Coderahmen
Beschreibung
Beispiel
Konstant
1
a = b + c;
Anweisung
Addiert zwei Zahlen
Logarithmisch
log N
[siehe Listing 1.4 ]
Halbieren
Binäre Suche
Linear
N
double max = a[0]; for (int i = 1; i < N; i++) if (a[i] > max) max = a[i];
Schleife
Maximum ermitteln
Leicht überlinear
N log N
[siehe Algorithmus 2.4 ]
Teile und Herrsche
Mergesort
Quadratisch
N2
for (int i = 0; i < N; i++) for (int j = i+1; j < N; j++) if (a[i] + a[j] == 0) cnt++;
Doppelte Schleife
Prüft alle Paare
Kubisch
N3
for (int i = 0; i < N; i++) for (int j = i+1; j < N; j++) for (int k = j+1; k < N; k++) if (a[i] + a[j] + a[k] == 0) cnt++;
Dreifache Schleife
Prüft alle Tripel
Exponentiell
2N
[siehe Kapitel 6 ]
Ausgiebige Suche
Prüft alle Teilmengen
207
1
Grundlagen
Die hier vorgestellte Klassifikation umfasst die häufigsten Wachstumsordnungen, ist aber natürlich nicht vollständig. Die Wachstumsordnung der Algorithmuskosten kann N 2logN oder N 3/2 sein oder eine ähnliche Funktion. Für eine detaillierte Analyse der Algorithmen müsste man sich allerdings der gesamten Bandbreite mathematischer Instrumente bedienen, die über die Jahrhunderte entwickelt wurden. Die überwiegende Zahl der Algorithmen, die wir betrachten, weist ein simples Laufzeitverhalten auf, das sich mit einer der hier vorgestellten Wachstumsordnungen akkurat beschreiben lässt. Das bedeutet, dass wir im Kostenmodell meist spezielle Behauptungen aufstellen können, wie Mergesort benötigt zwischen ½N lg N und N lg N Vergleiche, was direkt Hypothesen (Behauptungen) wie Die Wachstumsordnung der Laufzeit von Mergesort ist leicht überlinear impliziert. Meist drücken wir Aussagen wie diese allerdings etwas kürzer aus, d.h., wir sagen einfach Mergesort ist leicht überlinear.
r
ea
ar
ne
h
lin
be
tü
le i
ch
ea r
r li
t is c
s ch
kubi
Zeit
qua
dra
exponentiell
Zeit
le i
lin
ch
tü
be
r li
ne
ar
Die grafischen Darstellungen in lineare Darstellung exponentiell Abbildung 1.47 zeigen, wie bedeu500T kubisch tend die Wachstumsordnungen in der quadratisch Praxis sind. Auf der x-Achse wird die Problemgröße abgetragen und auf der y-Achse die Laufzeit. Diese Diagramme zeigen deutlich, dass quadratische und kubische Algorithmen sich für große Probleme nicht beson200T ders gut eignen. Wie sich herausstellt, können viele wichtige Probleme, deren natürliche Lösungen quadrati100T logarithmisch sche Laufzeiten haben, mithilfe intelkonstant ligenter Algorithmen auch in leicht überlinearer Zeit berechnet werden. 100K 200K 500K Problemgröße Für die Praxis sind solche Algorithmen, zu denen auch Mergesort gehört, doppelt logarithmische Darstellung von unschätzbarem Wert, da sie uns 512T gestatten, mit Problemgrößen zu arbeiten, die zu umfangreich sind, um mit quadratischen Lösungen bewältigt werden zu können. Deshalb liegt unser 64T Schwerpunkt in diesem Buch darauf, für die grundlegenden Probleme logarithmische, lineare und leicht über8T lineare Algorithmen zu entwickeln. 4T 2T
logarithmisch
T
konstant
Abbildung 1.47: Typische Wachstumsordnungen
208
1K
2K
4K
8K Problemgröße
512K
1.4 Analyse der Algorithmen
1.4.5 Schnellere Algorithmen entwerfen Wer sich mit der Wachstumsordnung eines Programms beschäftigt, hat vor allem ein Ziel vor Augen – einen schnelleren Algorithmus zur Lösung seines Problems zu entwerfen. Dies wollen wir im Folgenden anhand eines schnelleren Algorithmus für das 3-Summen-Problem veranschaulichen. Dabei stellt sich die Frage, wie wir einen schnelleren Algorithmus entwickeln können, ohne überhaupt mit dem Studium der Algorithmen begonnen zu haben? Die Antwort hierauf lautet, dass wir bereits zwei klassische Algorithmen (Mergesort und Binäre Suche) kennengelernt haben und wissen, dass Mergesort eine leicht überlineare und die binäre Suche eine logarithmische Laufzeit aufweist. Wie können wir uns diese Algorithmen zunutze machen, um das 3Summen-Problem zu lösen?
Aufwärmübung: 2-Summen-Problem Lassen Sie uns zuerst das Problem vereinfachen und in einer Eingabedatei die Integerpaare ermitteln, die sich zu 0 aufsummieren. Um Ihnen die Arbeit zu erleichtern, gehen wir außerdem davon aus, dass die Integer alle unterschiedlich sind. Dieses Problem lässt sich leicht in quadratischer Zeit lösen, indem wir die k-Schleife und a[k] aus ThreeSum.count() löschen und dann alle Paare mit der noch übrigen doppelten Schleife untersuchen – nachzulesen in Tabelle 1.65 unter dem Eintrag quadratisch (wir bezeichnen diese Implementierung als TwoSum). Die Implementierung in Listing 1.49 zeigt, wie Mergesort und die binäre Suche (Listing 1.4) als Basis für eine leicht überlineare Lösung des 2-Summen-Problems herangezogen werden können. Der verbesserte Algorithmus basiert darauf, dass ein Element a[i] Teil eines Integerpaares ist, das sich genau dann zu 0 aufsummiert, wenn der Wert -a[i] ebenfalls im Array enthalten ist (und a[i] nicht null ist). Wir lösen das Problem, indem wir das Array sortieren (um eine binäre Suche zu ermöglichen) und dann für jedes Arrayelement a[i] mit rank() in BinarySearch eine binäre Suche nach –a[i] durchführen. Wenn das Ergebnis ein Index j mit j > i ist, inkrementieren wir die Zählung. Dieser kleine Test deckt die folgenden drei Fälle ab:
Eine erfolglose binäre Suche liefert -1 zurück, sodass wir die Zählung nicht inkrementieren.
Wenn die binäre Suche j > i zurückliefert, gilt a[i] + a[j] = 0, sodass wir die Zählung inkrementieren.
Wenn die binäre Suche j zwischen 0 und i zurückliefert, gilt ebenfalls a[i] + a[j] = 0, aber wir inkrementieren die Variable nicht, um eine doppelte Zählung zu ver-
meiden. Das Ergebnis der Berechnung entspricht dem Ergebnis des quadratischen Algorithmus, benötigt aber viel weniger Zeit. Die Laufzeit von Mergesort ist proportional zu N log N und die N binären Suchen benötigen jeweils eine Laufzeit proportional zu log N, sodass die Laufzeit des gesamten Algorithmus proportional zu N log N ist. Die Entwicklung eines schnelleren Algorithmus geschieht jedoch nicht nur aus rein akademischem Interesse – dank des schnelleren Algorithmus können wir Probleme bewältigen, an die vor-
209
1
Grundlagen
her nicht zu denken gewesen wäre. So lässt sich das 2-Summen-Problem für 1 Million Integer (1Mints.txt) sicherlich in einer vertretbaren Zeit auf Ihrem Computer ausführen, während Sie wahrscheinlich lange warten müssten, wenn Sie dafür den quadratischen Algorithmus wählten (siehe Übung 1.4.41). import java.util.Arrays; public class TwoSumFast { public static int count(int[] a) { // Zählt Paare, die sich zu 0 aufsummieren. Arrays.sort(a); int N = a.length; int cnt = 0; for (int i = 0; i < N; i++) if (BinarySearch.rank(-a[i], a) > i) cnt++; return cnt; } public static void main(String[] args) { int[] a = In.readInts(args[0]); StdOut.println(count(a)); } } Listing 1.49: Leicht überlineare Lösung zu dem 2-Summen-Problem
Schneller Algorithmus für das 3-Summen-Problem Obige Vorgehensweise lässt sich auf das 3-Summen-Problem übertragen. Auch hier wollen wir davon ausgehen, dass die Integer alle unterschiedlich sind. Ein Paar von Arrayelementen (a[i] und a[j]) ist Teil eines Tripels, das sich genau dann zu 0 aufsummiert, wenn der Wert –(a[i]+a[j]) auch im Array vorhanden ist (und nicht a[i] oder a[j]). Der Code in Listing 1.50 sortiert das Array und führt dann N(N−1)/2 binäre Suchen durch, deren jeweilige Ausführungszeiten proportional zu log N sind, für eine Gesamtlaufzeit proportional zu N 2 log N. Beachten Sie, dass in diesem Fall die Kosten für das Sortieren unerheblich sind. Auch diese Lösung führt dazu, dass wir viel größere Probleme in Angriff nehmen können (siehe Übung 1.4.42). Die grafischen Darstellungen in Abbildung 1.48 zeigen, wie weit die Kosten dieser vier Algorithmen für Problemgrößen auseinanderklaffen, die in dem von uns betrachteten Bereich liegen. Solche Diskrepanzen sind zweifelsohne Ansporn genug, nach schnelleren Algorithmen zu suchen.
210
1.4 Analyse der Algorithmen
import java.util.Arrays; public class ThreeSumFast { public static int count(int[] a) { // Zählt Tripel, die sich zu 0 aufsummieren. Arrays.sort(a); int N = a.length; int cnt = 0; for (int i = 0; i < N; i++) for (int j = i+1; j < N; j++) if (BinarySearch.rank(-a[i]-a[j], a) > j) cnt++; return cnt; } public static void main(String[] args) { int[] a = In.readInts(args[0]); StdOut.println(count(a)); } } Listing 1.50: N 2 lg N-Lösung für das 3-Summen-Problem
Untere Grenzwerte Die Tabelle 1.66 fasst die Diskussion dieses Abschnitts kurz zusammen. Dabei stellt sich sofort folgende Frage: Gibt es Algorithmen für das 2-Summen- und 3-SummenProblem, die wesentlich schneller sind als TwoSumFast und ThreeSumFast? Gibt es einen linearen Algorithmus für das 2-Summen- oder einen leicht überlinearen Algorithmus für das 3-Summen-Problem? Die Antwort auf diese Frage lautet Nein für das 2-Summen-Problem (basierend auf dem Modell, das nur Vergleiche von linearen und quadratischen Funktionen der Zahlen zählt und zulässt) und Keine Ahnung für das 3-Summen-Problem, obwohl Experten der Ansicht sind, dass der bestmögliche Algorithmus für das 3-Summen-Problem quadratisch ist. Die Vorstellung eines unteren Grenzwertes für die Wachstumsordnung der Worst-Case-Laufzeit für alle möglichen Algorithmen ist so interessant, dass wir ihn in Abschnitt 2.2 im Zusammenhang mit dem Sortieren noch einmal aufgreifen werden. Nicht-triviale untere Grenzwerte sind schwer festzulegen, aber sehr hilfreich bei der Suche nach effizienten Algorithmen.
Tabelle 1.66 Zusammenfassung der Laufzeiten Algorithmus
Wachstumsordnung der Laufzeit
TwoSum
N2
TwoSumFast
N log N
ThreeSum
N3
ThreeSumFast
N 2 log N
211
1
Grundlagen
Die Beispiele in diesem Abschnitt bereiten Sie auf unsere Behandlung der Algorithmen in diesem Buch vor. Generell gehen wir bei neuen Problemen in diesem Buch folgendermaßen vor:
Wir implementieren und analysieren eine einfache Lösung zu dem Problem. In der Regel bezeichnen wir solche Lösungen (wie ThreeSum und TwoSum) als Brute-ForceLösung.
Wir untersuchen algorithmische Verbesserungen, die primär darauf abzielen, die Wachstumsordnung der Laufzeit zu reduzieren (wie TwoSumFast und ThreeSumFast).
Wir führen Experimente aus, um die Hypothesen zu bestätigen, dass die neuen Algorithmen schneller sind. In vielen Fällen untersuchen wir mehrere Algorithmen für das gleiche Problem, da die Laufzeit nur einer von mehreren Faktoren ist, die bei der Wahl eines Algorithmus für ein praktisches Problem eine Rolle spielen. Wir werden diese Vorgehensweise im Zusammenhang mit grundlegenden Problemen in diesem Buch verfeinern. N2
100
N 3/2
1000
N 2 lgN
60
TwoSum 40
20
Arrayzugriffe (Millionen)
Arrayzugriffe (Tausende)
ThreeSum 80
800
600
400
200
ThreeSumFast
TwoSumFast 4N lgN 1K
2K
4K Problemgröße N
8K
1K
2K
4K Problemgröße N
8K
Abbildung 1.48: Kosten der Algorithmen zur Lösung des 2-Summen- und 3-Summen-Problems
1.4.6 Experimente zum Verdopplungsverhältnis Im Folgenden zeigen wir Ihnen einen einfacheren, aber effektiven Weg, wie Sie das Laufzeitverhalten vorhersagen und die ungefähre Wachstumsordnung der Laufzeit für jedes Programm bestimmen können:
Entwickeln Sie einen Generator zur Erzeugung von Eingaben, die die in der Praxis erwarteten Eingaben modellieren (wie die Zufallsinteger in timeTrial() in DoublingTest).
Führen Sie das nachfolgende Programm DoublingRatio (eine Variante von DoublingTest) aus und berechnen Sie damit das Verhältnis jeder Laufzeit zur jeweils vorheri-
gen.
Führen Sie das Programm aus, bis die Verhältnisse sich einem Grenzwert 2b nähern.
212
1.4 Analyse der Algorithmen
Dieser Test ist nicht erfolgreich, wenn die Verhältnisse sich keinem Grenzwert annähern, was jedoch bei sehr vielen Programmen der Fall ist und folgende Schlussfolgerung zulässt:
Die Wachstumsordnung der Laufzeit ist ungefähr Nb. Um die Laufzeiten vorherzusagen, multiplizieren Sie die zuletzt beobachtete Laufzeit mit 2b und verdoppeln N – und wiederholen dies, sooft Sie wollen. Wenn Sie die Laufzeit für eine Eingabegröße vorhersagen wollen, die keine Zweierpotenz mal N ist, können Sie die Verhältnisse entsprechend anpassen (siehe Übung 1.4.9). Wie nachfolgend veranschaulicht, beträgt das Verhältnis für ThreeSum ungefähr 8. Wir können also vorhersagen, dass die Laufzeiten für N = 16000, 32000, 64000 bei 408,8, 3270,4 bzw. 26163,2 liegen – einfach indem wir die jeweils letzte Zeit (die z.B. für N = 8000 bei 51,1 liegt) mit 8 multiplizieren. public class DoublingRatio { public static double timeTrial(int N) // wie fuer DoublingTest (Listing 1.47) public static void main(String[] args) { double prev = timeTrial(125); for (int N = 250; true; N += N) { double time = timeTrial(N); StdOut.printf("%6d %7.1f ", N, time); StdOut.printf("%5.1f\n", time/prev); prev = time; } } } Listing 1.51: Programm zur Ausführung der Experimente
% java DoublingRatio 250 0.0 2.7 500 0.0 4.8 1000 0.1 6.9 2000 0.8 7.7 4000 6.4 8.0 8000 51.1 8.0 Listing 1.52: Ergebnisse der Experimente
16000 408.8 32000 3270.4 64000 26163.2
8.0 8.0 8.0
Listing 1.53: Vorhersagen
213
1
Grundlagen
Dieser Test entspricht grob dem Verfahren, das in Abschnitt Analyse der Messdaten beschrieben ist (Experimente durchführen, Werte in einer doppelt logarithmischen Darstellung einzeichnen, um daraus die Hypothese abzuleiten, dass die Laufzeit aNb ist, den Wert von b aus der Steigung der Geraden ablesen und dann nach a auflösen). Allerdings ist der Test wesentlich simpler. Ja, Sie können sogar das Laufzeitverhalten von Hand genau vorhersagen, wenn Sie DoublingRatio ausführen. Nähert sich das Verhältnis einem Grenzwert, multiplizieren Sie einfach mit diesem Verhältnis, um die Tabelle mit weiteren Werten zu füllen. Ihr Näherungsmodell der Wachstumsordnung ist ein Potenzgesetz mit dem binären Logarithmus dieses Verhältnisses als Potenz. Warum nähert sich das Verhältnis einem konstanten Wert? Eine einfache mathematische Berechnung zeigt, dass dies für alle hier besprochenen Wachstumsordnungen gilt (außer für die exponentielle):
Satz C (Verdopplungsverhältnis): Wenn T(N) ~ aNb lg N ist, dann ist T(2N)/T(N) ~ 2b. Beweis: Ergibt sich direkt aus der folgenden Berechnung:
T (2N ) / T (N ) = a(2N )b lg(2N ) / aN b lg N = 2b (1+ lg2 / lg N ) 2b
Im Allgemeinen sollte der logarithmische Faktor bei der Entwicklung eines mathematischen Modells nicht ignoriert werden; aber für die Vorhersage des Laufzeitverhaltens mit einer Verdopplungshypothese spielt er keine so große Rolle. Für Programme, deren Performance Sie als kritisch einstufen, sollten Sie stets erwägen, Experimente zum Verdoppelungsverhältnis auszuführen. Sie können damit auf sehr einfache Art und Weise die Wachstumsordnung der Laufzeit abschätzen und unter Umständen Performance-Fehler aufdecken, wenn ein Programm nicht so effizient arbeitet wie gedacht. Allgemeiner ausgedrückt können wir mithilfe der Hypothesen zur Wachstumsordnung der Programmlaufzeit das Laufzeitverhalten in bestimmten Situationen vorhersagen.
Die Lösbarkeit großer Probleme abschätzen Sie sollten in der Lage sein, für alle Programme, die Sie schreiben, die folgende grundlegende Frage zu klären: Verarbeitet dieses Programm seine Eingabedaten in einem vernünftigen Zeitrahmen? Um Fragen wie diese für eine große Datenmenge zu beantworten, extrapolieren wir auf einen viel größeren Faktor als bei der Verdoppelung, sagen wir 10 (siehe vierte Spalte in Tabelle 1.67). Investmentbanker, die täglich ihre Finanzierungsmodelle berechnen, Wissenschaftler, die mit ihren Programmen Versuchsdaten analysieren, oder Ingenieure, die Simulationen ausführen, um ein Design zu testen – es ist gar nicht so ungewöhnlich, dass Benutzer regelmäßig Programme ausführen, die für einen einzigen Lauf mehrere Stunden benötigen, sodass wir uns in dieser Tabelle auf
214
1.4 Analyse der Algorithmen
diese Situationen konzentrieren. Die Wachstumsordnung sagt Ihnen, welche Problemgrößen Sie bewältigen können. Dieses Verständnis zu entwickeln, ist ganz wichtig und es ist auch der wichtigste Grund, warum wir uns überhaupt mit der Laufzeit beschäftigen. Wem dieses Verständnis fehlt, der hat vermutlich auch keine Vorstellung davon, wie lange seine Programme laufen werden; wohingegen Programmierer, die die Wachstumsordnung ihrer Programme kennen, die Kosten abschätzen und entsprechende Maßnahmen ergreifen können.
Den Nutzen schnellerer Computer abschätzen Hin und wieder wird man auch vor die folgende grundlegende Frage gestellt: Um wie viel schneller kann ich das Problem lösen, wenn ich einen schnelleren Computer zur Verfügung habe? Im Regelfall können Sie Ihre Laufzeit um einen Faktor x verbessern, wenn Sie einen neuen Computer verwenden, der x-mal schneller ist als der alte. Wie wirkt sich diese Änderung auf die Laufzeit aus? Auch in diesem Fall liefert uns die Wachstumsordnung der Laufzeit die benötigten Informationen. Einer berühmten Faustregel zufolge, dem sogenannten Moore’schen Gesetz, kommt alle 18 Monate ein Computer mit doppelter Geschwindigkeit und doppeltem Speicher auf den Markt, beziehungsweise alle fünf Jahre ein Computer mit zehnfacher Geschwindigkeit und zehnfachem Speicher. Tabelle 1.67 zeigt, dass Sie mit dem Moore’schen Gesetz nicht Schritt halten können, wenn Sie einen quadratischen oder kubischen Algorithmus verwenden – und ob dies der Fall ist oder nicht, können Sie am schnellsten dadurch feststellen, dass Sie das Verdoppelungsverhältnis berechnen und prüfen, ob das Verhältnis der Laufzeiten sich beim Verdoppeln der Eingabegröße dem Wert 2 nähert, und nicht 4 oder 8.
Tabelle 1.67 Vorhersagen auf der Basis von Wachstumsordnungsfunktionen Wachstumsordnung der Zeit
Für ein Programm, das einige Stunden für Eingabe der Größe N benötigt
Beschreibung
Funktion
2x Faktor
10x Faktor
Vorhergesagte Zeit für 10N
Vorhergesagte Zeit für 10N auf einem 10 mal schnelleren Computer
linear
N
2
10
ein Tag
einige Stunden
leicht überlinear
N log N
2
10
ein Tag
einige Stunden
quadratisch
N2
4
100
einige Wochen
ein Tag
kubisch
N3
8
1000
mehrere Monate
einige Wochen
exponentiell
2N
2N
29N
nie
nie
215
1
Grundlagen
1.4.7 Fallstricke Es gibt viele Gründe, warum Sie bei Ihren Versuchen, das Laufzeitverhalten von Programmen detailliert zu analysieren, inkonsistente und irreführende Ergebnisse erhalten könnten. Alle haben damit zu tun, dass einzelne oder mehrere der Grundannahmen, auf denen wir unsere Hypothesen aufbauen, nicht ganz korrekt sind. Wir können zwar neue Hypothesen entwickeln, die auf neuen Annahmen basieren, aber je mehr Einzelheiten wir berücksichtigen müssen, desto sorgfältiger müssen wir bei der Analyse vorgehen.
Große Konstanten Bei einer Approximation an den Leitterm ignorieren wir konstante Koeffizienten in Termen niederer Ordnung, was nicht immer gerechtfertigt ist. Wenn wir beispielsweise die Funktion 2N 2 + cN durch ~2N 2 annähern, gehen wir davon aus, dass c klein ist, Wenn dies nicht der Fall ist (z.B. für c gleich 103 oder 106), ist die Approximation irreführend. Wir müssen also in Betracht ziehen, dass es auch große Konstanten geben kann.
Nicht-dominante innere Schleife Die Annahme, dass die innere Schleife dominiert, muss nicht immer richtig sein. Unter Umständen erkennt das Kostenmodell die eigentliche innere Schleife nicht oder die Problemgröße N ist nicht groß genug, um den Leitterm in der mathematischen Beschreibung der Befehlshäufigkeiten für die innere Schleife im Vergleich zu den Termen niederer Ordnung so groß zu machen, dass wir Letztere ignorieren können. In anderen Programmen ist der Code außerhalb der inneren Schleife so umfangreich, dass er ebenfalls berücksichtigt werden muss. Mit anderen Worten, das Kostenmodell muss eventuell verfeinert werden.
Befehlszeit Die Annahme, dass für jeden Befehl die immer gleiche Ausführungszeit benötigt wird, ist leider nicht immer korrekt. Die meisten modernen Computersysteme arbeiten zum Beispiel mit der Technik des Caching, um den Speicher zu organisieren. Dies kann dazu führen, dass für sehr große Arrays der Zugriff auf Elemente, die weit voneinander entfernt liegen, viel länger dauert als für nahe beieinander liegende Elemente. Sie können den Effekt des Caching am Beispiel von ThreeSum beobachten, indem Sie DoublingRatio für eine Weile ausführen lassen. Nachdem das Verhältnis der Ausführungszeiten anfänglich gegen 8 zu konvergieren scheint, springt es für große Arrays aufgrund des Caching auf einen größeren Wert.
Systembetrachtungen In der Regel laufen auf Ihrem Rechner viele Dinge parallel ab, d.h., Java ist nur eine von vielen Anwendungen, die um die zur Verfügung stehenden Ressourcen wetteifern. Zudem kennt Java etliche Optionen und Steuerungsmöglichkeiten, über die Sie das Laufzeitverhalten signifikant beeinflussen können. Die Speicherbereinigung, ein
216
1.4 Analyse der Algorithmen
JIT-Compiler oder ein Download aus dem Internet können die Ergebnisse der Experimente drastisch verzerren. Das Fazit solcher Überlegungen ist, dass sich das, was zu einem bestimmten Zeitpunkt gerade in Ihrem Computer geschieht, niemals mehr reproduzieren lässt – was dem Grundprinzip der wissenschaftlichen Methode widerspricht, wonach Experimente reproduzierbar sein sollten. Was immer sonst in Ihrem System abläuft (das außerhalb Ihrer Kontrolle ist), sollte daher prinzipiell vernachlässigbar sein.
Schwer zu entscheiden Wenn wir zwei Programme vergleichen, die die gleiche Aufgabe auf unterschiedliche Weise lösen, kann es passieren, dass in bestimmten Situationen das eine Programm und in anderen Situationen das andere Programm schneller ist. Ausschlaggebend hierfür könnte einer (oder mehrere) der oben angeführten Gründe sein. Manche Programmierer (und Studenten) neigen dazu, in solchen Fällen extrem viel Zeit und Energie darauf zu verwenden, die „beste“ Implementierung zu ermitteln. Doch diese Arbeit sollte am besten den Experten vorbehalten bleiben.
Starke Abhängigkeit von Eingaben Eine der ersten Annahmen, die wir aufgestellt haben, um die Wachstumsordnung der Laufzeit eines Programms bestimmen zu können, war, dass die Laufzeit relativ unabhängig von den Eingabewerten sein sollte. Wo dies nicht der Fall ist, erhalten wir möglicherweise inkonsistente Ergebnisse oder können unsere Hypothesen nicht bestätigen. Angenommen, wir würden ThreeSum dahingehend abwandeln, dass es die Frage beantwortet: Enthält die Eingabe ein Tripel, das sich zu 0 aufsummieren lässt? Dann müssten wir als Rückgabewert einen booleschen Wert definieren, cnt++ durch return true ersetzen und als letzte Anweisung return false hinzufügen. Die Wachstumsordnung der Ausführungszeit dieses Programms ist konstant, wenn die ersten drei Integer sich zu 0 aufsummieren und kubisch, wenn die Eingabe keine solchen Tripel enthält.
Mehrere Problemparameter Bisher haben wir uns darauf beschränkt, das Laufzeitverhalten in Abhängigkeit von nur einem Parameter zu messen – im Allgemeinen dem Wert eines Befehlszeilenarguments oder der Größe der Eingabe. Häufig haben wir es jedoch mit mehreren Parametern zu tun. Ein typisches Beispiel ist ein Algorithmus, in dem eine Datenstruktur erzeugt und dann eine Reihe von Operationen auf dieser Datenstruktur ausgeführt wird. Sowohl die Größe der Datenstruktur als auch die Anzahl der Operationen sind Parameter für solche Anwendungen. Ein Beispiel hierzu ist uns bereits bei der Analyse des Positivlistenproblems mit binärer Suche begegnet, wo wir es mit N Zahlen in der Positivliste und M Zahlen von der Standardeingabe und einer typischen Ausführungszeit proportional zu M log N zu tun haben. Trotz all dieser Vorbehalte sind Kenntnisse der Wachstumsordnung von Programmlaufzeiten für jeden Programmierer äußerst hilfreich und die hier beschriebenen Verfahren sind leistungsfähig und vielseitig anwendbar. Knuth zufolge können wir diese
217
1
Grundlagen
Verfahren prinzipiell bis ins kleinste Detail durchführen und so ausführliche, absolut akkurate Vorhersagen erhalten. Computersysteme sind jedoch in der Regel äußerst komplex und genaue Analysen sollten am besten Experten vorbehalten bleiben; nichtsdestotrotz lassen sich mit diesen Verfahren ungefähre Schätzwerte für die Laufzeit eines jeden Programms ermitteln. So wie ein Raumfahrtwissenschaftler eine ungefähre Vorstellung davon haben sollte, ob Testflüge im Meer oder in einer Stadt landen werden, so wie ein Pharmakologe wissen sollte, ob der Test eines Arzneimittels die Testpersonen heilen oder töten wird, so sollte jeder Wissenschaftler oder Ingenieur, der ein Computerprogramm verwendet, ungefähr wissen, ob das Programm eine Sekunde oder ein Jahr für die Ausführung benötigt.
1.4.8 Die Abhängigkeit von Eingaben reduzieren Für viele Probleme ist ein wesentlicher Fallstrick die Abhängigkeit von den Eingaben, die zu stark voneinander abweichenden Ausführungszeiten führen können. So reicht die Ausführungszeit von ThreeSum nach der Änderung gemäß Abschnitt Starke Abhängigkeit von Eingaben von konstant bis kubisch, und ist damit nicht zuletzt eine Frage der Eingabe, sodass es einer genaueren Analyse bedarf, wenn wir Aussagen über das Laufzeitverhalten machen wollen. Nachfolgend stellen wir einige der effektiveren Ansätze kurz vor, auf die wir später im Zusammenhang mit bestimmten Algorithmen noch einmal zurückkommen werden.
Eingabemodelle Ein Ansatz besteht darin, die Art der Eingaben, die wir zur Lösung der uns gestellten Probleme verarbeiten, sorgfältiger zu modellieren. Wir könnten beispielsweise annehmen, dass die Zahlen in der Eingabe für ThreeSum zufällige int-Werte sind. Dieser Ansatz kann aus zwei Gründen problematisch sein:
Das Modell könnte unrealistisch sein. Die Analyse könnte extrem schwierig sein und mathematische Kenntnisse voraussetzen, die ein normaler Student oder Programmierer nicht hat. Der erste Punkt ist von größerer Bedeutung, da Computerberechnungen oft das Ziel haben, charakteristische Eigenschaften der Eingaben aufzudecken. Angenommen, wir schreiben an einem Programm, das ein gegebenes Genom verarbeitet. Wie können wir dann seine Performance abschätzen, wenn wir es auf ein anderes Genom anwenden? Im Übrigen ist es genau dies, woran Wissenschaftler interessiert sind: ein leistungsfähiges Modell, das die natürlichen Genome korrekt und effektiv beschreibt. Indem wir die Laufzeit unserer Programme zur Verarbeitung natürlicher Daten abschätzen, tragen wir also letztlich auch zu diesem Modell bei! Der zweite Punkt legt nahe, sich nur für die wichtigsten Algorithmen auf mathematische Ergebnisse zu fokussieren. Wir werden noch einige Beispiele kennenlernen, in denen ein einfaches und nachvollziehbares Eingabemodell zusammen mit der klassischen mathematischen Analyse uns bei der Vorhersage des Laufzeitverhaltens unterstützt.
218
1.4 Analyse der Algorithmen
Worst-Case-Laufzeitgarantien Einige Anwendungen erfordern, dass die Laufzeit, unabhängig von der Eingabe, einen bestimmten Wert nicht überschreitet. Um solche Laufzeitgarantien abgeben zu können, betrachten Theoretiker das Laufzeitverhalten von Algorithmen extrem pessimistisch: Wie würde die Laufzeit im schlimmsten Fall (worst case) aussehen? Ein solch konservativer Ansatz wäre zum Beispiel für Software angebracht, die Atomreaktoren, Herzschrittmacher oder die Bremsen in Ihrem Auto steuert. Unser Ziel ist es zu garantieren, dass die Software ihre Aufgabe innerhalb der von uns gesetzten Schranken erfüllt, da es sonst zu einer Katastrophe kommen würde. Wissenschaftler ziehen Worst-Case-Szenarien bei ihrem Studium der realen Welt normalerweise nicht in Betracht. Die Gründe liegen auf der Hand: In der Biologie wäre der schlimmste Fall das Aussterben der menschlichen Rasse und in der Physik wäre es das Ende des Universums. In Computersystemen, deren Eingaben ja nicht von der Natur kommen, sondern von einem anderen (potenziell bösartigen) Benutzer, kann der schlimmste Fall dagegen ein sehr reales und ernst zu nehmendes Problem darstellen. So sind zum Beispiel Websites, die auf Algorithmen mit Laufzeitgarantien verzichten, anfällig für Denial-of-Service-Attacken, d.h. für Hacker, die die Website mit einer Flut von pathologischen Anfragen überschwemmen und damit die Software so extrem ausbremsen, dass sie viel langsamer ausgeführt wird, als die Entwickler es je erwartet hätten. Folglich werden viele unserer Algorithmen so entworfen, dass sie Laufzeitgarantien bieten.
Satz D: In den Implementierungen von Bag (Algorithmus 1.4 ), Stack (Algorithmus 1.2 ) und Queue (Algorithmus 1.3 ), die auf verketteten Listen basieren, benötigen alle Operationen im schlimmsten Fall konstante Zeit. Beweis: Ergibt sich direkt aus dem Code. Die Anzahl der Anweisungen, die für jede Operation ausgeführt werden, wird durch eine kleine Konstante begrenzt. Fallstrick : Dieses Argument hängt von der (realistischen) Annahme ab, dass das Java-System ein neues Node-Objekt in konstanter Zeit erzeugt.
Randomisierte Algorithmen Laufzeitgarantien erhalten Sie oft auch dadurch, dass Sie den Faktor Wahrscheinlichkeit berücksichtigen. So ist zum Beispiel der (wahrscheinlich am weitesten verbreitete) Sortieralgorithmus Quicksort, mit dem wir uns in Abschnitt 2.3 noch ausführlich beschäftigen werden, im schlimmsten Fall quadratisch. Wenn Sie aber die Eingabe nach dem Zufallsprinzip anordnen, erhalten Sie eine probabilistische Garantie, dass die Laufzeit leicht überlinear ist. Jedes Mal, wenn Sie den Algorithmus ausführen, erhalten Sie eine andere Laufzeit, aber die Gefahr, dass die Zeit nicht leicht überlinear ist, ist so gering, dass sie vernachlässigt werden kann. Ähnlich sieht es mit den (wahrscheinlich ebenfalls häufigen) Hashingalgorithmen für Symboltabellen aus, die wir in Abschnitt 3.4 näher beleuchten. Diese weisen im schlimmsten Fall eine lineare Laufzeit auf, aber eine konstante Laufzeit als probabilistische Garantie. Diese Garantien
219
1
Grundlagen
sind nicht absolut, aber die Wahrscheinlichkeit, dass sie nicht eingehalten werden, ist geringer als die Wahrscheinlichkeit, dass Ihr Computer von einem Blitz getroffen wird. Somit sind solche Garantien in der Praxis so nützlich wie Worst-Case-Garantien.
Abfolge der Operationen In vielen Anwendungen besteht die Algorithmus-Eingabe nicht nur aus Daten, sondern auch aus einer Folge von Operationen, die der Client auf den Daten ausführt. So hat zum Beispiel ein Stapel, auf dem der Client N Werte ablegt, um sie dann alle auf einmal wieder zu entfernen, unter Umständen ein ganz anderes Laufzeitverhalten als ein Stapel, auf dem der Client eine abwechselnde Folge von push()- und pop()-Operationen ausführt. Unsere Analyse muss beide Situationen berücksichtigen (oder auf einem realistischen Modell von der Abfolge der Operationen basieren).
Amortisierte Laufzeitanalyse Es gibt es noch einen weiteren Weg, eine Laufzeitgarantie abzugeben – und zwar durch Amortisieren der Kosten. Hierbei werden die einzelnen Kosten aller Operationen aufaddiert und die Gesamtkosten dann durch die Anzahl der Operationen geteilt. In diesem Szenario sind durchaus einige teure Operationen erlaubt, solange die Durchschnittskosten der Operationen niedrig gehalten werden. Ein typisches Beispiel für diese Art von Analyse ist die Untersuchung der Datenstruktur eines Arrays variabler Größe für Stack, die wir in Abschnitt 1.3 (Algorithmus 1.1 in Listing 1.39) besprochen haben. Der Einfachheit halber gehen wir davon aus, dass N eine Zweierpotenz ist. Ausgehend von einer leeren Struktur stellt sich die Frage, auf wie viele Arrayelemente für N aufeinanderfolgende Aufrufe von push() zugegriffen wird? Dieser Wert ist einfach zu berechnen: N + 4 + 8 + 16 + ...+ 2N = 5N − 4
Der erste Term berücksichtigt den Arrayzugriff in jedem der N Aufrufe von push(); die nachfolgenden Terme stehen für die Arrayzugriffe, um die Datenstruktur bei jeder Verdoppelung der Größe zu initialisieren. Damit ist die durchschnittliche Zahl der Arrayzugriffe pro Operation konstant, auch wenn die letzte Operation eine lineare Laufzeit aufweist. Dies wird als „amortisierte“ Analyse bezeichnet, da wir die Kosten der wenigen teuren Operationen verteilen, indem wir sie der großen Anzahl der billigen Operationen zuschlagen. Anhand von VisualAccumulator lässt sich diese Vorgehensweise leicht veranschaulichen. Kosten (Arrayverweise)
256 ein grauer Punkt für jede Operation
128
64 rote Punkte für den kumulativen Durchschnitt
5
0 0
Anzahl der push()-Operationen
Abbildung 1.49: Amortisierte Kosten beim Hinzufügen zu Stack
220
128
1.4 Analyse der Algorithmen
Satz E: In der Stack-Implementierung mit Arrays variabler Größe (Algorithmus 1.1 ) ist die durchschnittliche Anzahl der Arrayzugriffe für jede beliebige Folge von Operationen, ausgehend von einer leeren Datenstruktur, im schlimmsten Fall konstant. Beweisskizze: Für jede push()-Operation, die das Array anwachsen lässt (sagen wir von N auf 2N), betrachten wir die N/2−1 push()-Operationen, die zuletzt die Stapelgröße auf k anwachsen ließen, für k von N/2+2 bis N. Durch Berechnung des Durchschnitts der 4N Arrayzugriffe, um das Array mit N/2 Arrayzugriffen zu erweitern (einen für jede push()-Operation), erhalten wir Durchschnittskosten von 9 Arrayzugriffen pro Operation. Der Beweis, dass die Anzahl der Arrayzugriffe bei einer beliebigen Folge von M Operationen proportional M ist, ist da schon komplizierter (siehe Übung 1.4.32 ).
Diese Art der Analyse lässt sich vielfältig einsetzen. Nicht zuletzt auch deshalb, weil wir Arrays variabler Größe für etliche Algorithmen, die wir weiter hinten in diesem Buch noch besprechen werden, als grundlegende Datenstruktur verwenden. Es ist die Aufgabe des Algorithmen-Analytikers, so viele relevante Informationen über einen Algorithmus zu sammeln wie möglich, und die Aufgabe des Anwendungsprogrammierers ist es, auf Basis dieser Informationen Programme zu entwickeln, die die gestellten Probleme effektiv lösen. Im Idealfall sollen unsere Algorithmen zu klarem, kompaktem Code führen, der beides bietet: eine zufriedenstellende Garantie und eine gute Laufzeit für die uns interessierenden Eingabewerte. Viele der klassischen Algorithmen, die wir in diesem Kapitel betrachten, sind genau deshalb von so großer Bedeutung für die Anwendungsentwicklung, weil sie exakt diese Eigenschaften besitzen. Lassen Sie sich vom Beispiel dieser Algorithmen anleiten und Sie werden bald in der Lage sein, selbst ausgezeichnete Lösungen für die typischen Probleme des Programmieralltags zu entwickeln.
1.4.9 Speicherbedarf Wie die Laufzeit so steht auch der Speicherbedarf eines Programms in direkter Verbindung zur physikalischen Welt: Spezielle Schaltkreise in Ihrem Computer ermöglichen es Ihren Programmen, Werte zu speichern und später wieder abzurufen. Die Zahl dieser Schaltkreise ist beachtlich, zum Glück, denn je mehr Werte Sie zu einem gegebenen Zeitpunkt speichern müssen, umso mehr Schaltkreise benötigen Sie. Vermutlich haben Sie beim Kauf Ihres Rechners einen Aufpreis bezahlt, um mehr Speicher zur Verfügung zu haben, und kennen daher die Grenzen, die Ihnen die Speicheraustattung Ihres Computers setzt, nur zu gut (vermutlich besser als die Grenzen, die Ihnen bezüglich der Ausführungsgeschwindigkeit gesetzt sind). Für den einzelnen Computer ist der Speicherbedarf durch Java-Programme wohldefiniert (jeder Wert belegt bei jeder Programmausführung immer genau den gleichen Speicher), aber Java-Implementierungen gibt es für eine Vielzahl verschiedener Geräte, und der tatsächliche Speicherverbrauch ist implementierungsabhängig. Der Kürze halber verwenden wir den Begriff typisch, um Werte zu kennzeichnen, die anfällig für Maschinenabhängigkeiten sind.
221
1
Grundlagen
Eines der hervorstechendsten Features von Java ist sein Speicherzuweisungssystem, das Sie von den Sorgen der Speicherverwaltung befreien soll. Natürlich sind Sie gut beraten, dieses Feature, wann immer möglich, zu nutzen. Das entbindet Sie jedoch nicht von der Verantwortung, abschätzen zu können, wann der Speicherbedarf eines Programms die erfolgreiche Lösung eines gegebenen Problems verhindert. Die Analyse des Speicherbedarfs verläuft etwas anders und ist viel einfacher als die Analyse der Laufzeit – was vor allem daran liegt, dass nicht so viele Programmanweisungen betroffen sind (sondern nur Deklarationen) und dass die Analyse komplexe Objekte auf primitive Typen reduziert, deren Speicherbedarf gut definiert und leicht zu verstehen ist: Zählen Sie einfach die Variablen und gewichten Sie jede Variable mit der Anzahl Bytes, die ihrem Typ entsprechen. Da zum Beispiel in Java der Datentyp int die Menge ganzer Zahlen zwischen -2147483648 und 2147483647 repräsentiert, was einer Gesamtsumme von 232 verschiedenen Werten entspricht, ist es logisch, dass Implementierungen 32 Bit verwenden, um int-Werte zu repräsentieren. Ähnliche Überlegungen lassen sich auch für andere primitive Typen anstellen. Typische JavaImplementierungen verwenden 8-Bit-Bytes und repräsentieren jeden char-Wert mit 2 Byte (16 Bit), jeden int-Wert mit 4 Byte (32 Bit), jeden double- und long-Wert mit 8 Byte (64 Bit) und jeden boolean-Wert mit 1 Byte (da die meisten Computer in ByteSchritten auf den Speicher zugreifen). Wenn Sie dann noch wissen, wie viel Speicher Ihnen insgesamt zur Verfügung steht, können Sie leicht ableiten, wo die Grenzen Ihres Systems liegen. Verfügt Ihr Computer zum Beispiel über 1 GB Speicher (ungefähr 1 Milliarde Byte), dann können Sie nicht mehr als 256 Millionen int-Werte oder 128 Millionen double-Werte gleichzeitig speichern. Auf der anderen Seite hängt die Analyse des Speicherbedarfs von den Unterschieden in der Rechnerhardware und in den Java-Implementierungen ab, weswegen Sie die konkreten Beispiele, die wir hier präsentieren, nur als Anleitung verstehen sollten, wie man im Bedarfsfall den Speicherbedarf ermittelt – und nicht als endgültige Lösung für Ihren Computer. Zum Beispiel spielt in vielen Datenstrukturen die Repräsentation von Maschinenadressen eine Rolle und der Speicherplatz, der für eine Maschinenadresse benötigt wird, schwankt von Rechner zu Rechner. Der Einfachheit halber gehen wir davon aus, dass eine Maschinenadresse 8 Byte belegt, wie es für die heutigen weitverbreiteten 64-Bit-Architekturen üblich ist, im vollen Bewusstsein, dass viele ältere Rechner eine 32-Bit-Architektur aufweisen, die nur 4 Byte pro Maschinenadresse verwendet.
Tabelle 1.68 Typische Speicheranforderungen für primitive Datentypen
222
Typ
Bytes
boolean
1
byte
1
char
2
int
4
1.4 Analyse der Algorithmen
Tabelle 1.68 Typische Speicheranforderungen für primitive Datentypen (Forts.) Typ
Bytes
float
4
long
8
double
8
Objekte Um den Speicherbedarf eines Objekts zu ermitteln, addieren wir den Speicher, den jede Instanzvariable belegt, zu dem Overhead, den jedes Objekt erzeugt, in der Regel 16 Byte. Der Overhead umfasst eine Referenz auf die Klasse des Objekts sowie Daten zur Speicherbereinigung und Synchronisierung. Außerdem wird der Speicher normalerweise mit Füllbits aufgefüllt (padding), bis er ein Vielfaches von 8 Byte belegt (Maschinenwort auf einem 64-Bit-Rechner). Zum Beispiel belegt ein Integer-Objekt 24 Byte (16 Byte Overhead, 4 Byte für seine int-Instanzvariable und 4 Byte zum Auffüllen). Entsprechend belegt ein Date-Objekt (Abbildung 1.23) 32 Byte: 16 Byte Overhead, jeweils 4 Byte für jede der drei int-Instanzvariablen und 4 Byte zum Auffüllen. Eine Referenz auf ein Objekt ist in der Regel eine Speicheradresse und belegt damit 8 Byte Speicher. Ein weiteres Beispiel ist ein CounterObjekt (Abbildung 1.22), das ebenfalls 32 Byte belegt: 16 Byte Overhead, 8 Byte für die String-Instanzvariable (eine Referenz), 4 Byte für die int-Instanzvariable und 4 Byte zum Auffüllen. Der Speicher für Referenzen wird dabei ohne den Speicher für das Objekt berechnet, sodass die Gesamtsumme nicht den Speicher für den String-Wert beinhaltet.
Integer-Wrapperobjekt public class Integer { private int x; ... }
24 Byte ObjektOverhead x Füllbits
Date-Objekt public class Date { private int day; private int month; private int year; ... }
Counter-Objekt public class Counter { private String name; private int count; ... }
ObjektOverhead day month year Füllbits
intWerte
32 Byte
ObjektOverhead String-
count Füllbits
public class Node { private Item item; private Node next; ... }
Wert
32 Byte
name
Node-Objekt (innere Klasse)
int-
Referenz
int-
Wert
40 Byte
ObjektOverhead ExtraOverhead item
Referenzen next
Abbildung 1.50: Typischer Speicherbedarf diverser Objekte
223
1
Grundlagen
Verkettete Listen Eine eingebettete, nicht-statische (innere) Klasse wie Node (Abschnitt Knoten-Verbund) erfordert einen zusätzlichen Overhead von 8 Byte (für eine Referenz auf die umschließende Instanz). Somit belegt ein Node-Objekt 40 Byte (16 Byte für den Objekt-Overhead, jeweils 8 Byte für die Referenzen auf die Item- und Node-Objekte und 8 Byte für den zusätzlichen Overhead). In Anbetracht der Tatsache, dass ein Integer-Objekt 24 Byte belegt, muss für einen Stapel mit N Integern, der mittels einer verketteten Liste erzeugt wird, 32 + 64N Byte reserviert werden: die 16 Byte für Objekt-Overhead für Stack, 8 Byte für die zum Referenzieren benötigte Instanzvariable, 4 Byte für die int-Instanzvariable, 4 Byte zum Auffüllen und 64 Byte für jedes Elemente (40 für ein Node- und 24 für ein Integer-Objekt).
Arrays Der typische Speicherbedarf für verschiedene Arten von Arrays in Java ist in Abbildung 1.51 übersichtlich dargestellt. Arrays werden in Java als Objekte implementiert, und zwar in der Regel mit zusätzlichem Overhead für die Länge. Ein Array von Werten primitiver Datentypen belegt 24 Byte für die Metainformationen (16 Byte für den Objekt-Overhead, 4 Byte für die Länge und 4 Byte zum Auffüllen) plus die Anzahl der Byte, die für die Speicherung der Werte benötigt wird. So belegt zum Beispiel ein Array von N int-Werten 24 + 4N Byte (aufgerundet auf ein Vielfaches von 8), während ein Array von N double-Werten 24 + 8N Byte belegt. Ein Array von Objekten ist ein Array von Referenzen auf die Objekte, sodass wir den Speicher für die Referenzen zu dem Speicher für die Objekte addieren müssen. So benötigt zum Beispiel ein Array von N Date-Objekten (Abbildung 1.23) 24 Byte (Array-Overhead) plus 8N Byte (Referenzen) plus 32 Byte für jedes Objekt, was einer Gesamtzahl von 24 + 40N entspricht. Ein zweidimensionales Array ist ein Array von Arrays (jedes Array ist ein Objekt). So belegt beispielsweise ein zweidimensionales M×N-Array von double-Werten 24 Byte (Overhead für das Array von Arrays) plus 8M Byte (Referenzen auf die Zeilen-Arrays) plus M mal 24 Byte (Overhead von den Zeilen-Arrays) plus M×N 8 Byte (für die N double-Werte in jede der M Zeilen), was sich zu einer Gesamtzahl von 8NM + 32M + 24 ∼ 8NM Byte zusammenfassen lässt. Wenn die Arrayelemente keine double-Werte, sondern Objekte sind, ergibt sich aus der entsprechenden Berechnung eine Gesamtsumme von 8NM + 32M + 24 ∼ 8NM Byte für das Array von Arrays gefüllt mit Referenzen auf Objekte, plus dem Speicher für die Objekte selbst.
224
1.4 Analyse der Algorithmen
Array von double-Werten
Array von int-Werten int[] a = new int[N];
double[] c = new double[N];
a
c ObjektOverhead
int-Wert
(4 Byte)
16 Byte
ObjektOverhead
N Füllbits
int-Wert
16 Byte
N Füllbits
(4 Byte) N int-Werte (4N Byte)
N double-Werte (8N Byte)
Gesamt: 24 + 4N (N gerade)
24 + 8N Byte ObjektOverhead
16 Byte
N Füllbits
4 Byte
Gesamt: 24 + 8N
Array von Objekten
Array von Arrays (zweidimensionales Array)
32 Byte
t
16 Byte int-Wert
d
12 Byte int-Wert
(4 Byte)
N doubleWerte (8N Byte)
double[][] t; t = new double[M][N];
Date[] d; d = new Date[N]; for (int k = 0; k < N; k++) { ... a[k] = new Date (...); }
(4 Byte)
ObjektOverhead
ObjektOverhead M Füllbits
length Füllbits
ObjektOverhead N Füllbits
M Referenzen (8M Byte) N Referenzen (8N Byte) . . .
Gesamt: 24 + 8N + N32 = 24 + 40N
. . .
ObjektOverhead ObjektOverhead
day month year Füllbits
N Füllbits
Gesamt: 24 + 8M + M(24 + 8N ) = 24 + 32M + 8MN
Abbildung 1.51: Typischer Speicherbedarf für Arrays von int-Werten, double-Werten, Objekten und Arrays
Tabelle 1.69 Zusammenfassung Typ
Byteanzahl
int[]
~ 4N
double[]
~ 8N
Date[]
~ 40N
double[][]
~ 8NM
225
1
Grundlagen
String-Objekte Wir berechnen den Speicherbedarf für String-Objekte auf die gleiche Weise wie für jedes andere Objekt, abgesehen vom Aliasing, das bei Strings häufig auftritt. Die Standardimplementierung von String weist vier Instanzvariablen auf: eine Referenz auf ein Zeichenarray (8 Byte) und drei int-Werte (jeweils 4 Byte). Der erste int-Wert ist ein Offset in das Zeichenarray, der zweite gibt die Stringlänge an (count). Unter Verwendung der Instanzvariablennamen aus Abbildung 1.52 besteht der dargestellte String aus den Zeichen value[offset] bis value[offset + count – 1]. Der dritte intWert der String-Objekte ist ein Hashcode, der uns in bestimmten, im Moment irrelevanten Fällen die erneute Berechnung erspart. Somit belegt jedes String-Objekt 40 Byte (16 Byte für den Objekt-Overhead plus 4 Byte für jede der drei int-Instanzvariablen plus 8 Byte für die Arrayreferenz plus 4 Byte zum Auffüllen). Zusätzlich zu diesem Bedarf müssen wir den Speicher berücksichtigen, den die Zeichen selbst belegen, da das char-Array oft von mehreren Strings genutzt wird. Da String-Objekte unveränderlich sind, kann die Implementierung auf diese Weise Speicher sparen, wenn die String-Objekte das gleiche value[]-Array zugrunde legen.
String-Werte und Teilstrings Ein String der Länge N belegt typischerweise 40 Byte (für das String-Objekt) plus 24 + 2N Byte (für das Array, das die Zeichen enthält), was einer Gesamtzahl von 64 + 2N Byte entspricht. Code, der Strings verarbeitet, erzeugt üblicherweise auch Teilstrings, und dank der von Java gewählten String-Darstellung können wir dies tun, ohne dass die Zeichen im String kopiert werden. Wenn Sie die Methode substring()1 verwenden, erzeugen Sie ein neues String-Objekt (40 Byte), verwenden dazu aber das gleiche value[]-Array, sodass ein Teilstring eines bestehenden Strings nur 40 Byte belegt. Das Zeichenarray, das den Originalstring enthält, wird über ein Alias im Objekt für den Teilstring referenziert: die Datenfelder offset und length identifizieren den Teilstring. Mit anderen Worten, der zusätzliche Speicherbedarf für einen Teilstring ist konstant und die Zeit, einen Teilstring zu erstellen, ebenfalls, auch wenn der String und der Teilstring riesig sind. Eine naivere String-Darstellung, die beim Erstellen eines Teilstrings die Zeichen kopiert, würde linearen Zeit- und Speicherplatzbedarf erfordern. Die Fähigkeit, Teilstrings unter Verwendung eines konstanten Speichers und in konstanter Zeit, d.h. unabhängig von seiner Länge, zu erzeugen, ist der Schlüssel zur Effizienz in vielen stringverarbeitenden Algorithmen.
1
226
Anmerkung des Übersetzers: Diese Performanceangaben gelten nur für ältere Java-Versionen. Seit Java 7 Update 6 benötigt die Methode substring() der Klasse String zusätzlichen linearen Speicher und Zeit, da eine Kopie des Teilstrings angelegt wird.
1.4 Analyse der Algorithmen
40 Byte Mit den vorgestellten Techniken lässt sich String-Objekt (Java-Bibliothek) der Speicherbedarf vieler Programme gut public class String Objekt{ abschätzen; doch gibt es eine Vielzahl von Overhead private char[] value; private int offset; komplizierenden Faktoren, die die Aufgabe private int count; Referenz value erheblich erschweren können. Den potenprivate int hash; ... offset ziellen Effekt des Aliasing haben wir bereits } intcount Werte angesprochen. Hinzu kommt, dass der hash Füllbits Speicherverbrauch – sobald Funktionsaufrufe berücksichtigt werden müssen – ein Teilstring-Beispiel komplizierter, dynamischer Prozess wird, String genome = "CGCCTGGCGTCTGTAC"; bei dem der SpeicherallokationsmechanisString codon = genome.substring(6, 3); mus des Systems eine immer wichtigere genome 40 Byte Rolle spielt und es zu zunehmenden SysObjekttemabhängigkeiten kommt. Betrachten wir Overhead hierzu den Fall, dass Ihr Programm eine value Methode aufruft. Das System reserviert Objekt0 daraufhin in einem besonderen Bereich Overhead 16 des Arbeitsspeichers, dem sogenannten Hash 16 Füllbits Stapel (stack), so viel Speicher, wie für C G C C die Methode (für ihre lokalen Variablen) T G codon . charbenötigt wird. Kehrt die Methode später . G C . Werte G T zum Aufrufer zurück, dann wird der SpeiObjektC T Overhead cher wieder an den Stapel zurückgegeben. G T Die Erzeugung von Arrays oder anderen A C 36 Byte value Füllbits umfangreichen Objekten in rekursivem 6 Code ist daher nicht ungefährlich, denn es 3 Hash bedeutet, dass jeder rekursive Aufruf mit Füllbits beträchtlichen Speicheranforderungen ver40 Byte bunden ist. Wenn Sie hingegen mit new ein Abbildung 1.52: Ein String und ein Teilstring Objekt erzeugen, weist das System den für das Objekt benötigten Speicher aus einem anderen speziellen Speicherbereich zu, dem sogenannten Heap (nicht zu verwechseln mit der Datenstruktur eines binären Heap, den wir in Abschnitt 2.4 betrachten). Diese Objekte existieren so lange, wie es mindestens eine Referenz auf sie gibt. Erst wenn keine Referenz mehr existiert, die auf ein gegebenes Objekt verweist, fordert ein spezieller Systemprozess, die sogenannte Garbage Collection (Speicherbereinigung), den Speicher wieder für den Heap zurück. Diese Dynamik kann die Aufgabe, den Speicherbedarf eines Programms exakt zu bestimmen, zu einer echten Herausforderung machen.
1.4.10 Ausblick Gutes Laufzeitverhalten ist wichtig. Ein extrem langsames Programm ist fast genauso nutzlos wie ein fehlerhaftes, weswegen es wichtig ist, von Anfang an auf die Kosten zu achten und eine ungefähre Vorstellung von den Problemen zu haben, denen man
227
1
Grundlagen
sich sinnvollerweise zuwenden sollte. Vor allem ist es immer ratsam, eine Vorstellung davon zu haben, welcher Code die innere Schleife eines Programms bildet. Der vielleicht häufigste Fehler beim Programmieren ist jedoch, dem Laufzeitverhalten zu viel Aufmerksamkeit zu schenken. Ihre erste Priorität sollte klarer und korrekter Code sein. Ein Programm nur deshalb zu überarbeiten, damit es schneller wird, ist eine Aufgabe, die Sie besser den Experten überlassen. Maßnahmen in diese Richtung sind sogar häufig kontraproduktiv, da sie zu Code führen, der nur unnötig kompliziert und schwer zu verstehen ist. C.A.R. Hoare (der Erfinder von Quicksort und ein führender Verfechter von klarem, korrektem Code) fasste seine Kritik hierzu einmal in dem Satz zusammen „Verfrühte Optimierung ist die Wurzel allen Übels“, den Knuth folgendermaßen einschränkte „(oder zumindest einen Großteil davon) in der Programmierung“. Darüber hinaus lässt sich sagen, dass die Verbesserung der Ausführungszeit sich nicht lohnt, wenn die Kostenvorteile unbedeutend sind. Zum Beispiel ist es wenig zweckmäßig, die Laufzeit eines Programms um den Faktor 10 zu verbessern, wenn die Laufzeit extrem kurz ist. Und sogar wenn ein Programm einige Minuten benötigt, dürfte der Aufwand, einen verbesserten Algorithmus zu implementieren und zu debuggen, wesentlich größer sein, als einfach eine etwas langsamere Version auszuführen – eine Arbeit, die ohnehin der Computer erledigt. Schlimmstenfalls kann es Ihnen sogar passieren, dass Sie viel Zeit und Mühe aufwenden, Ideen zu implementieren, die sich als falsch erweisen und gar nicht zu der angestrebten Verbesserung des Programms führen. Der vermutlich zweithäufigste Fehler beim Entwickeln von Algorithmen ist, das Laufzeitverhalten zu ignorieren. Schnellere Algorithmen sind oft komplizierter als BruteForce-Lösungen, sodass man leicht in Versuchung gerät, einen langsameren Algorithmus zu akzeptieren, um die Auseinandersetzung mit komplizierterem Code zu vermeiden. Doch manchmal lassen sich mit nur wenigen Codezeilen erhebliche Einsparungen erzielen. Benutzer von einer erstaunlichen Vielzahl von Computersystemen verlieren viel Zeit, weil sie darauf warten, dass einfache quadratische Algorithmen ein Problem lösen, für das es längst effiziente lineare oder leicht überlineare Algorithmen gibt, die nur unwesentlich komplizierter sind und das Problem in einem Bruchteil der Zeit lösen können. Wenn wir es mit riesigen Problemen zu tun haben, haben wir oft keine andere Wahl, als nach besseren Algorithmen zu suchen. Ansonsten gehen wir davon aus, dass Sie fortan die in diesem Abschnitt beschriebenen Vorgehensweisen nutzen, um den Speicherbedarf abschätzen, auf Basis einer Tilde-Approximation, die das Ergebnis einer mathematischen Analyse im Rahmen eines Kostenmodells ist, eine Hypothese zur Wachstumsordnung der Laufzeit erstellen und diese Hypothesen mit Experimenten prüfen. Wann immer Sie an einem Programm arbeiten, sollte Ihr Ziel sein, das Programm zu verbessern, um es klarer, effizienter und eleganter zu machen. Wenn Sie während der ganzen Programmentwicklung die Kosten im Auge behalten, werden Sie jedes Mal, wenn Sie mit dem Programm arbeiten, davon profitieren.
228
Fragen und Antworten
1.4
Fragen und Antworten
Frage: Warum erzeugen wir zufällige Werte nicht mit StdRandom, anstatt die Datei 1Mints.txt zu verwenden. Antwort: Weil es das Debuggen des Codes während der Entwicklung und das Reproduzieren der Tests erleichtert. StdRandom erzeugt bei jedem Aufruf andere Zufallswerte, sodass Sie beim wiederholten Ausführen des Programms nach der Fehlerbehebung nicht wirklich eruieren können, ob der Fehler behoben wurde! Sie könnten das Problem umgehen, indem Sie die StdRandom-Methode setSeed() verwenden, aber mit einer Referenzdatei wie 1Mints.txt können Sie leichter Testfälle für das Debuggen hinzufügen. Außerdem können Programmierer so das Laufzeitverhalten auf verschiedenen Rechnern vergleichen, ohne sich Gedanken um das Eingabemodell machen zu müssen. Haben Sie Ihr Programm erfolgreich debuggt und eine Vorstellung davon, wie lange es zur Ausführung benötigt, ist es durchaus sinnvoll, es mit zufälligen Daten zu testen. Dieser Ansatz wird zum Beispiel bei DoublingTest und DoublingRatio verfolgt. Frage: Ich habe DoublingRatio auf meinem Rechner ausgeführt, aber die Ergebnisse stimmten nicht mit denen im Buch überein. Einige der Verhältnisse lagen nicht in der Nähe von 8. Warum? Antwort: Aus diesem Grund haben wir die „Fallstricke“ in Abschnitt 1.4.7 besprochen. Am wahrscheinlichsten ist, dass Ihr Betriebssystem während des Tests noch andere Aufgaben erledigt hat. Problemen wie diesem begegnet man am besten, indem man noch mehr Zeit für noch mehr Tests aufwendet. Sie könnten DoublingTest beispielsweise dahingehend ändern, dass die Tests 1000-mal für jedes N ausgeführt werden, was Ihnen einen wesentlich genaueren Schätzwert der Ausführungszeit für jede Problemgröße liefert (siehe Übung 1.4.39). Frage: Was genau bedeutet „mit zunehmenden N “ in der Definition der Tilde-Notation? Antwort: Die formale Definition von ƒ(N) ~ g(N) lautet limN →∞ f (N ) / g (N ) = 1. Frage: Mir sind noch andere Notationen zur Beschreibung der Wachstumsordnung begegnet. Was hat es mit diesen Notationen auf sich? Antwort: Weit verbreitet ist die „Groß-O“-Notation: Wir sagen, dass ƒ(N) gleich O( g (N )) ist, wenn es Konstanten c und N 0 gibt, sodass f (N ) < cg (N ) für alle N > N 0. Diese Notation zeichnet sich dadurch aus, dass sie uns asymptotische obere Schranken für die Ausführungszeit von Algorithmen liefert, was vor allem in der Algorithmentheorie wichtig ist. Zum Abschätzen der Laufzeit oder zum Vergleichen der Algorithmen ist sie allerdings nicht erforderlich.
229
1
Grundlagen
Frage: Warum nicht? Antwort: Der Hauptgrund ist, dass diese Notation nur eine obere Schranke für die Ausführungszeit beschreibt. Die tatsächliche Laufzeit könnte um vieles besser sein. Die Ausführungszeit eines Algorithmus könnte beides sein: O(N 2 ) und ~ a N log N. Daher eignet sich die Notation nicht, um Tests wie unseren Verdopplungsverhältnistest zu begründen (siehe Satz C). Frage: Warum ist die Groß-O-Notation dann trotzdem so weit verbreitet? Antwort: Sie erleichtert die Angabe von Grenzwerten für die Wachstumsordnung – selbst für komplizierte Algorithmen, bei denen eine genauere Analyse nicht besonders sinnvoll wäre. Außerdem ist sie kompatibel zu der „Groß-Omega“- und der „Groß-Theta“-Notation, die theoretische Informatiker zur Klassifikation von Algorithmen verwenden, wenn sie nach Schranken für deren Worst-Case-Laufzeit suchen. Wir sagen, dass ƒ(N) gleich Ω( g (N )) ist, wenn es Konstanten c und N 0 gibt, sodass f (N ) > cg (N ) für N > N 0; und wenn ƒ(N) gleich O( g (N )) und Ω( g (N )) ist, sagen wir, dass ƒ(N) gleich Θ( g (N )) ist. Die Groß-Omega-Notation wird normalerweise verwendet, um eine untere Schranke für den schlimmsten Fall zu beschreiben, und die Groß-Theta-Notation beschreibt das Laufzeitverhalten von Algorithmen, die optimal sind, insofern als es keinen Algorithmus gibt, der eine bessere asymptotische Worst-Case-Wachstumsordnung aufweist. Optimale Algorithmen sind in praktischen Anwendungen selbstverständlich erwägenswert, aber darüber hinaus gibt es noch viele weitere Aspekte zu berücksichtigen, wie Sie noch sehen werden. Frage: Sind nicht obere Schranken für die asymptotische Laufzeit wichtig? Antwort: Ja, aber wir ziehen es für unsere Kostenmodelle vor, bezüglich der Ausführungshäufigkeit von Anweisungen präzise Ergebnisse zu diskutieren, da sie mehr Informationen über die Algorithmuslaufzeit liefern und ihre Herleitung für die hier diskutierten Algorithmen keinerlei Probleme bereitet. Wir sagen beispielsweise „ThreeSum verwendet ~N 3/2 Arrayzugriffe“ und „cnt++ wird in ThreeSum im schlimmsten Fall ~N 3/6-mal ausgeführt“, was ein wenig wortreich ist, aber wesentlich informativer als die Aussage „die Ausführungszeit von ThreeSum ist O(N 3 )“. Frage: Wenn die Wachstumsordnung eines Algorithmus N log N ist, führt der Verdopplungstest zu der Hypothese, dass die Laufzeit ~ aN ist, für eine Konstante a. Stellt das nicht ein Problem dar? Antwort: Wir sollten nicht der Versuchung erliegen, aus den Testdaten auf ein bestimmtes mathematisches Modell zu schließen – und solange wir nur die Laufzeit vorhersagen, sollte dies auch kein Problem darstellen. Wenn beispielsweise N zwischen 16000 und 32000 liegt, liegen die grafischen Darstellungen von 14N und N lg N sehr nahe beieinander und die Daten stimmen mit beiden Kurven überein. Je größer N wird, desto enger liegen die Kurven beieinander. Es bedarf schon einiges an Sorgfalt, um experimentell die Hypothese zu prüfen, dass die Ausführungszeit eines Algorithmus leicht überlinear und nicht linear ist.
230
Allgemeine Übungen
Frage: Ist int[] a = new int[N] gleichbedeutend mit N Arrayzugriffen (um Elemente mit 0 zu initialisieren)? Antwort: Höchstwahrscheinlich ja, sodass wir in diesem Buch von dieser Annahme ausgehen, auch wenn eine anspruchsvolle Implementierung versuchen könnte, diese Kosten für riesige dünn besetzte Arrays zu vermeiden.
1.4
Allgemeine Übungen
1.
Zeigen Sie, dass die Anzahl der verschiedenen Tripel, die aus N Elementen gewählt werden können, genau N (N − 1)(N − 2) / 6 ist. Hinweis: Verwenden Sie vollständige Induktion oder ein Zählerargument.
2.
Überarbeiten Sie ThreeSum so, dass es auch dann ordnungsgemäß funktioniert, wenn die int-Werte so groß werden, dass eine Addition von zweien von ihnen zu einem Überlauf führen könnte.
3.
Erweitern Sie DoublingTest so, dass Sie mithilfe von StdDraw grafische Darstellungen wie die lineare und die doppelt logarithmische Grafik im Text erzeugen können. Nehmen Sie im Bedarfsfall eine Neuskalierung vor, sodass die Grafik immer einen großen Teil des Fensters einnimmt.
4.
Stellen Sie für TwoSum eine Tabelle wie Tabelle 1.62 auf.
5.
Wie lauten die Tilde-Approximationen für die folgenden Größen? a. N +1 b. 1+1 / N c. (1+1 / N )(1+ 2 / N ) d. 2N 3 −15N 2 + N e. lg(2N ) / lg N f. lg(N 2 +1) / lg N g. N 100 / 2N
6.
Wie lautet die Wachstumsordnung (als eine Funktion von N) der Ausführungszeit von jedem der folgenden Codefragmente? a. int sum = 0; for (int n = N; n > 0; n /= 2) for(int i = 0; i < n; i++) sum++;
b. int sum = 0; for (int i = 1; i < N; i *= 2) for (int j = 0; j < i; j++) sum++;
c. int sum = 0; for (int i = 1; i < N; i *= 2) for (int j = 0; j < N; j++) sum++;
231
1
Grundlagen
7.
Analysieren Sie ThreeSum unter Zugrundelegung eines Kostenmodells, das die arithmetischen Operationen (und Vergleiche) auf den Eingabezahlen zählt.
8.
Schreiben Sie ein Programm, das in einer Eingabedatei mit Werten die Zahlenpaare ermittelt, die gleich sind. Wenn Ihr erster Versuch quadratisch ist, denken Sie noch einmal nach und entwickeln Sie mithilfe von Arrays.sort() eine leicht überlineare Lösung.
9.
Entwickeln Sie eine Formel, die die Ausführungszeit eines Programms für die Problemgröße N vorhersagt, wenn Verdopplungsexperimente gezeigt haben, dass der Verdopplungsfaktor 2b und die Ausführungszeit für Probleme der Größe N0 gleich T ist.
10.
Überarbeiten Sie die binäre Suche so, dass sie immer das Element mit dem kleinsten Index zurückliefert, das mit dem Suchelement übereinstimmt (und trotzdem leicht überlineare Ausführungszeit garantiert).
11.
Ergänzen Sie StaticSETofInts (Listing 1.24) um eine Instanzmethode howMany(), die die Anzahl der Vorkommen eines gegebenen Schlüssels in einer Zeit ermittelt, die im schlimmsten Fall proportional log N ist.
12.
Schreiben Sie ein Programm, das zwei sortierte Arrays von N int-Werten entgegennimmt und alle Elemente sortiert ausgibt, die in beiden Arrays vorhanden sind. Die Ausführungszeit Ihres Programms sollte im schlimmsten Fall proportional N sein.
13.
Geben Sie unter Verwendung der im Text entwickelten Annahmen an, wie viel Speicher benötigt wird, um ein Objekt der folgenden Typen zu repräsentieren: a. Accumulator b. Transaction c. FixedCapacityStackOfStrings mit der Kapazität C und N Einträgen d. Point2D e. Interval1D f. Interval2D g. Double
232
Knifflige Aufgaben
1.4
Knifflige Aufgaben
14.
4-Summen-Problem: Entwickeln Sie einen Algorithmus für das 4-Summen-Problem.
15.
Schnellere 3-Summen-Lösung: Entwickeln Sie als Aufwärmübung eine Implementierung TwoSumFaster, die einen linearen Algorithmus verwendet, um nach der Sortierung des Arrays die Paare zu zählen, die sich zu null aufsummieren (anstelle eines leicht überlinearen Algorithmus, der auf der binären Suche basiert). Entwickeln Sie dann auf ähnliche Weise einen quadratischen Algorithmus für das 3-Summen-Problem.
16.
Das am nächsten zusammen liegende Paar (in einer Dimension): Schreiben Sie ein Programm, das ein Array a[] von N double-Werten übernimmt und das am nächsten zusammenliegende Paar findet: Zwei Werte, deren Differenz nicht größer ist als die Differenz irgendeines anderen Paares (als Absolutwert). Die Ausführungszeit Ihres Programms sollte im schlimmsten Fall leicht überlinear sein.
17.
Das am weitesten auseinanderliegende Paar (in einer Dimension): Schreiben Sie ein Programm, das ein Array a[] von N double-Werten übernimmt und das Paar findet, das am weitesten auseinanderliegt: zwei Werte, deren Differenz nicht kleiner ist als die Differenz irgendeines anderen Paares (als Absolutwert). Die Ausführungszeit Ihres Programms sollte im schlimmsten Fall linear sein.
18.
Lokales Minimum eines Arrays: Schreiben Sie ein Programm, das ein Array a[] von N verschiedenen int-Werten übernimmt und ein lokales Minimum findet: einen Index i, sodass a[i] < a[i-1] und a[i] < a[i+1]. Ihr Programm sollte im schlimmsten Fall ~ 2 lg N Vergleiche benötigen. Antwort: Untersuchen Sie den mittleren Wert a[N/2] und seine beiden Nachbarn a[N/2-1] und a[N/2+1]. Wenn a[N/2] ein lokales Minimum ist, hören Sie auf; an-
dernfalls suchen Sie in der Hälfte mit dem kleineren Nachbarn. 19.
Lokales Minimum einer Matrix: Entwickeln Sie für ein gegebenes N×N-Array a[] von N 2 verschiedenen Integer einen Algorithmus, der in einer Ausführungszeit proportional N ein lokales Minimum findet: ein Paar von Indizes i und j, sodass a[i][j] < a[i+1][j], a[i][j] < a[i][j+1], a[i][j] < a[i-1][j] und a[i][j] < a[i][j-1]. Die Ausführungszeit Ihres Programms sollte im schlimmsten Fall proportional N sein.
20.
Bitonische Suche: Ein Array ist bitonisch, wenn es eine zunehmende Folge von Integern umfasst, direkt gefolgt von einer abnehmenden Folge von Integern. Schreiben Sie ein Programm, das ein bitonisches Array von N verschiedenen int-Werten übernimmt und feststellt, ob ein gegebener Integer im Array ist. Ihr Programm sollte im schlimmsten Fall ~ 3 lg N Vergleiche benötigen.
21.
Binäre Suche auf verschiedenen Werten: Entwickeln Sie eine Implementierung der binären Suche für StaticSETofInts (Listing 1.24), in der die Ausführungszeit von contains() garantiert ~lg R und R die Anzahl der verschiedenen Integer im Array ist, die als Argument im Konstruktor übergeben wird.
233
1
Grundlagen
22.
Binäre Suche nur mit Addition und Subtraktion [Mihai Patrascu]: Schreiben Sie ein Programm, das ein Array von N verschiedenen int-Werten in aufsteigender Reihenfolge übernimmt und ermittelt, ob es einen gegebenen Integer im Array gibt. Sie dürfen nur Additionen und Subtraktionen und einen konstanten zusätzlichen Speicher verwenden. Die Ausführungszeit Ihres Programms sollte im schlimmsten Fall proportional log N sein. Antwort: Anstatt die Suche auf Zweierpotenzen (binäre Suche) basieren zu lassen, verwenden Sie eine Fibonacci-Folge (die auch exponentiell anwächst). Wählen Sie als Suchbereich das Intervall [i, i + Fk] und speichern Sie Fk und Fk−1 in zwei Variablen. Berechnen Sie in jedem Schritt Fk−2 mittels Subtraktion, prüfen Sie das Element i + Fk−2 und aktualisieren Sie den aktuellen Bereich auf entweder [i, i + Fk−2] oder [i + Fk−2, i + Fk−2 + Fk−1].
234
23.
Binäre Suche nach einem Bruch: Entwickeln Sie eine Methode, die eine logarithmische Zahl von Abfragen der Form Ist die Zahl kleiner als x? verwendet, um eine rationale Zahl p/q zu finden, sodass 0 < p < q < N. Hinweis: Zwei Brüche mit einem Nenner kleiner als N können sich nicht um weniger als 1/N 2 unterscheiden.
24.
Eier von einem Gebäude werfen: Angenommen, Sie haben ein Gebäude mit N Stockwerken und sehr viele Eier. Weiterhin angenommen, dass Eier, die aus Stockwerk F oder höher geworfen werden, zerbrechen und Eier, die aus Stockwerken darunter geworfen werden, intakt bleiben. Entwickeln Sie zuerst eine Strategie, um den Wert von F zu ermitteln, sodass bei ~lg N Würfen die Anzahl der zerbrochenen Eier ~lg N ist, und suchen Sie dann nach einer Möglichkeit, die Kosten auf ~2 lg F zu reduzieren.
25.
Zwei Eier von einem Gebäude werfen: Legen Sie die vorherige Frage zugrunde, aber gehen Sie diesmal davon aus, dass Sie nur zwei Eier haben und Ihr Kostenmodell die Anzahl der Würfe ist. Entwickeln Sie eine Strategie, um den Wert von F zu ermitteln, sodass die Anzahl der Würfe höchstens 2 N ist, und suchen Sie dann nach einer Möglichkeit, die Kosten auf ~c F zu reduzieren. Dies entspricht der Situation, in der erfolgreiches Suchen (Ei intakt) viel billiger ist als erfolgloses (Ei zerbrochen).
26.
3-Kollinearität: Angenommen Sie haben einen Algorithmus, der als Eingabe N verschiedene Punkte in der Ebene übernimmt und die Anzahl der Tripel zurückliefern kann, die auf der gleichen Gerade liegen. Zeigen Sie, dass Sie mithilfe dieses Algorithmus das 3-Summen-Problem lösen können. Starker Hinweis: Beweisen Sie mithilfe der Algebra, dass (a, a3), (b, b3) und (c, c3) kollinear sind, genau dann wenn a + b + c = 0.
27.
Warteschlange mit zwei Stapeln: Implementieren Sie eine Warteschlange mit zwei Stapeln, sodass jede Warteschlangenoperation eine konstante amortisierte Anzahl von Stapeloperationen benötigt. Hinweis: Wenn Sie Elemente auf einem Stapel ablegen und diese dann alle entfernen, erscheinen sie in umgekehrter Reihenfolge. Wenn Sie diesen Prozess wiederholen, stehen sie wieder in der richtigen Reihenfolge.
Knifflige Aufgaben
28.
Stapel mit einer Warteschlange: Implementieren Sie einen Stapel mit nur einer Warteschlange, sodass jede Stapeloperation eine lineare Anzahl von Warteschlangenoperationen benötigt. Hinweis: Um ein Element zu löschen, entnehmen Sie alle Elemente nacheinander aus der Warteschlange und fügen sie am Ende ein, bis auf das letzte, das Sie löschen und zurückliefern sollten. (Diese Lösung ist zugegebenermaßen sehr ineffizient.)
29.
Steque mit zwei Stapeln: Implementieren Sie einen Steque mit zwei Stapeln, sodass jede Steque-Operation (siehe Übung 1.3.32) eine konstante amortisierte Anzahl von Stapeloperationen benötigt.
30.
Deque mit einem Stapel und einem Steque: Implementieren Sie einen Deque mit einem Stapel und einem Steque (siehe Übung 1.3.32), sodass jede Deque-Operation eine konstante amortisierte Anzahl von Stapel- und Steque-Operationen benötigt.
31.
Deque mit drei Stapeln: Implementieren Sie einen Deque mit drei Stapeln, sodass jede Deque-Operation eine konstante amortisierte Anzahl von Stapeloperationen benötigt.
32.
Amortisierte Analyse: Beweisen Sie, dass ausgehend von einem leeren Stapel die Anzahl der Arrayzugriffe, die von einer beliebigen Folge von M Operationen in der Stack-Implementierung mit einem Array variabler Größe benötigt wird, proportional M ist.
33.
Speicherbedarf auf einem 32-Bit-Rechner: Geben Sie den Speicherbedarf für Integer, Date, Counter, int[], double[], double[][], String, Node und Stack
(Repräsentation mit verketteter Liste) auf einem 32-Bit-Rechner an. Gehen Sie davon aus, dass die Referenzen 4 Byte und der Objekt-Overhead 8 Byte belegen und dass auf ein Vielfaches von 4 Byte aufgefüllt wird. 34.
Heiß oder kalt: Ihr Ziel ist es, durch wiederholte Versuche einen unbekannten Integer zwischen 1 und N zu raten. Nach jedem Versuch erfahren Sie, ob die von Ihnen geratene Zahl gleich der unbekannten Zahl ist (woraufhin das Spiel stoppt) oder ob die geratene Zahl heißer oder kälter, d.h. näher oder weiter von der unbekannten Zahl entfernt ist als Ihr vorheriger Versuch. Entwickeln Sie einen Algorithmus, der die unbekannte Zahl in höchstens ~2 lg N Versuchen errät. Entwickeln Sie anschließend einen Algorithmus, der die unbekannte Zahl in ~1 lg N Versuchen findet
35.
Zeitkosten für Stapel: Erläutern Sie die Einträge in Tabelle 1.70, die die typischen Zeitkosten für verschiedene Stapelimplementierungen zeigen. Verwenden Sie dazu ein Kostenmodell, das die Datenreferenzen (Referenzen auf die im Stapel abgelegten Daten, entweder eine Arrayreferenz oder eine Referenz auf die Instanzvariable eines Objekts) und die erzeugten Objekte zählt.
235
1
Grundlagen
Tabelle 1.70 Zeitkosten von Stapeln (verschiedene Implementierungen) Datenstruktur
Elementtyp
Datenreferenz
Erzeugte Objekte
int
2N
N
Integer
3N
2N
int
~5 N
lg N
Integer
~5 N
~N
Verkettete Liste
Array variabler Größe
36.
Kosten zum Einfügen von N int-Werten
Speicherbedarf für Stapel: Erläutern Sie die Einträge in Tabelle 1.71, die den typischen Speicherbedarf für verschiedene Stapelimplementierungen zeigen. Verwenden Sie eine statische eingebettete Klasse für Knoten in einer verketteten Liste, um den Overhead nicht statischer eingebetteter Klassen zu vermeiden.
Tabelle 1.71 Speicherbedarf in Stapeln (verschiedene Implementierungen) Datenstruktur Verkettete Liste
Array variabler Größe
1.4
236
Elementtyp
Speicherbedarf für N int-Werte (Byte)
int
~32 N
Integer
~56 N
int
zwischen ~4 N und ~16 N
Integer
zwischen ~32 N und ~56 N
Experimente
37.
Leistungseinbußen durch Autoboxing: Führen Sie Experimente durch, um die durch Autoboxing oder Autounboxing bedingten Leistungseinbußen auf Ihrem Rechner zu ermitteln. Entwickeln Sie eine Implementierung FixedCapacityStackOfInts und verwenden Sie einen Client wie DoublingRatio, um die Performance dieser Implementierung mit der generischen Implementierung FixedCapacityStack für eine große Anzahl von push()- und pop()- Operationen zu vergleichen.
38.
Naive 3-Summen-Implementierung: Führen Sie Experimente durch, um die folgende Implementierung der inneren Schleife von ThreeSum zu bewerten:
Experimente
for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) for (int k = 0; k < N; k++) if (i < j && j < k) if (a[i] + a[j] + a[k] == 0) cnt++;
Entwickeln Sie dazu eine Version von DoublingTest, die das Verhältnis der Ausführungszeiten von diesem Programm und von ThreeSum berechnet. 39.
Verbesserte Genauigkeit für Verdopplungstests: Überarbeiten Sie DoublingRatio so, dass es ein zweites Befehlszeilenargument übernimmt, das die Anzahl der Aufrufe von timeTrial() für jeden Wert von N angibt. Führen Sie Ihr Programm 10-, 100- und 1000-mal aus und kommentieren Sie die Genauigkeit der Ergebnisse.
40.
3-Summen für zufällige Werte: Formulieren und validieren Sie eine Hypothese, die die Anzahl der Tripel von N zufälligen int-Werten angibt, die sich zu 0 aufsummieren. Wenn Sie gute Kenntnisse der mathematischen Analyse besitzen, entwickeln Sie ein entsprechendes mathematisches Modell für dieses Problem, in dem die Werte gleichmäßig zwischen −M und M verteilt sind, wobei M keine kleine Zahl ist.
41.
Ausführungszeiten: Schätzen Sie die Zeit, die die Ausführung von TwoSumFast, TwoSum, ThreeSumFast und ThreeSum auf Ihrem Computer benötigt, wenn die Eingabe eine Datei mit einer Million Zahlen ist. Verwenden Sie hierzu DoublingRatio.
42.
Problemgrößen: Schätzen Sie die Größe des größten Wertes von P, für den Sie TwoSumFast, TwoSum, ThreeSumFast und ThreeSum auf Ihrem Computer ausführen können, um eine Datei von 2P Tausend Zahlen zu verarbeiten. Verwenden Sie hierzu DoublingRatio.
43.
Arrays variabler Größe versus verkettete Listen: Führen Sie Experimente durch, um die Hypothese zu validieren, dass für Stapelimplementierungen Arrays variabler Größe schneller sind als verkettete Listen (siehe Übungen 1.4.35 und 1.4.36). Entwickeln Sie dazu eine Version von DoublingRatio, die das Verhältnis der Ausführungszeiten der beiden Programme berechnet.
44.
Geburtstagsproblem: Schreiben Sie ein Programm, das einen Integer N von der Befehlszeile entgegennimmt und mit StdRandom.uniform() eine zufällige Folge von Integern zwischen 0 und N−1 erzeugt. Führen Sie Experimente durch, um die Hypothese zu validieren, dass die Anzahl der Integer, die vor Wiederholung des ersten Wertes erzeugt werden, gleich ~ πN / 2 ist.
45.
Sammelbilderproblem: Erzeugen Sie zufällige Integer wie in der vorherigen Übung und führen Sie Experimente durch, um die Hypothese zu validieren, dass die Anzahl der Integer, die erzeugt werden, bevor jeder Wert mindestens einmal erzeugt wurde, ~NHN ist.
237
1
Grundlagen
1.5
Fallstudie Union-Find
Zur Veranschaulichung unserer Vorgehensweise bei der Entwicklung und Analyse von Algorithmen werden wir nachfolgend ein ausführliches Beispiel betrachten. Dabei sollen uns die folgenden Aussagen als Leitmotive dienen.
Gute Algorithmen können darüber entscheiden, ob ein Problem sich schnell lösen lässt oder überhaupt nicht in Angriff genommen werden kann.
Ein effizienter Algorithmus kann so einfach zu codieren sein wie ein ineffizienter. Das Laufzeitverhalten einer Implementierung zu verstehen, kann eine interessante und zufriedenstellende intellektuelle Herausforderung sein.
Die wissenschaftliche Methode ist ein wichtiges Tool, das hilft, zwischen verschiedenen Verfahren zur Lösung des gleichen Problems zu wählen.
Ein iterativer Verfeinerungsprozess kann zu immer effizienteren Algorithmen führen. Die Gültigkeit dieser Aussagen zeigt sich an vielen Stellen im Buch. Außerdem haben wir für dieses typische Beispiel eine exemplarische Vorgehensweise gewählt, die sich später auf viele andere Probleme anwenden lässt. Das hier betrachtete Problem ist nicht trivial, sondern gehört vielmehr zu den grundlegenden Problemen der Informatik. Und unsere Lösung ist in einer Vielzahl von Anwendungen von Nutzen, von der Perkolation in der physikalischen Chemie bis zur Konnektivität in Kommunikationsnetzwerken. Wir entwickeln zuerst eine einfache Lösung und versuchen dann, das Laufzeitverhalten dieser Lösung zu ermitteln, was Ihnen helfen wird herauszufinden, wie sich der Algorithmus verbessern lässt.
1.5.1 Verwaltung von Zusammenhangskomponenten Wir beginnen mit der folgenden Problemspezifikation: Die Eingabe besteht aus einer Folge von Integerpaaren, in denen jeder Integer ein Objekt eines Typs repräsentiert und wir das Paar p q interpretieren sollen als „p ist verbunden mit q“. Wir gehen davon aus, dass „ist verbunden mit“ eine Äquivalenzrelation ist, was bedeutet, sie ist
reflexiv: p ist verbunden mit p. symmetrisch: Wenn p mit q verbunden ist, dann ist q mit p verbunden. transitiv: Wenn p mit q und q mit r verbunden ist, dann ist auch p mit r verbunden. Eine Äquivalenzrelation teilt die Objekte in Äquivalenzklassen. In diesem Fall sind zwei Objekte genau dann in der gleichen Äquivalenzklasse, wenn sie verbunden sind. Unser Ziel ist es, ein Programm zu schreiben, das alle nicht relevanten Paare (d.h. Paare, deren beide Objekte in der gleichen Äquivalenzklasse sind) aus der Folge herausfiltert. Mit anderen Worten, wenn das Programm ein Paar p q von der Eingabe einliest, sollte es das Paar nur dann ausgeben, wenn die zuvor betrachteten Paare nicht implizieren, dass p mit q verbunden ist. Wenn die vorherigen Paare implizieren, dass p und q verbunden sind, sollte das Programm das Paar p q ignorieren und das nächste Paar einlesen. Abbildung 1.53 veranschaulicht diesen Prozess anhand eines Beispiels. Um dieses
238
1.5 Fallstudie Union-Find
Ziel zu erreichen, müssen wir eine Datenstruktur entwickeln, die genügend Informationen über alle zuvor betrachteten Paare speichert, um entscheiden zu können, ob ein neues Paar von Objekten miteinander verbunden ist oder nicht. Gemeinhin sprechen wir bei der Entwicklung einer solchen Methode von dem Problem der Verwaltung von Zusammenhangkomponenten, das in vielen Anwendungen eine Rolle spielt.
Netzwerke Die Integer könnten für Computer in einem großen Netzwerk stehen und die Paare dementsprechend für die Verbindungen in diesem Netzwerk. In diesem Fall ermittelt unser Programm, ob wir zum Kommunizieren eine neue direkte Verbindung zwischen p und q herstellen müssen oder ob wir zum Aufbau einer Verbindung auf einen bereits bestehenden Verbindungspfad zurückgreifen können. Oder die Integer könnten elektrische Kontaktstellen auf einem integrierten Schaltkreis sein und die Paare die Drähte, die die Stellen verbinden. Oder die Integer repräsentieren Menschen in einem sozialen Netzwerk und die Paare Freundschaften. In solchen Anwendungen müssen wir unter Umständen Millionen von Objekten und Milliarden von Verbindungen verarbeiten.
Namensäquivalenz
0
1
2
3
4
5
6
7
8
9
4 3 3 8 6 5 9 4 2 1 8 9 5 0 7 2
keine Paare ausgeben, die bereits verbunden sind
In bestimmten Programmierumgebungen ist es möglich, zwei Variablennamen als äquivalent zu deklarieren 6 1 (d.h., sie referenzieren dasselbe Objekt). Nach einer Folge von solchen Deklarationen muss das System 1 0 also in der Lage sein festzustellen, ob zwei gegebene 6 7 Namen äquivalent sind. Diese Anwendung zählt zu den frühen Programmen (konzipiert für die Program2 components miersprache FORTRAN) und gab den Anstoß zu der Abbildung 1.53: Beispiel für die VerwalEntwicklung von Algorithmen, wie wir sie hier tung von Zusammenhangskomponenten betrachten.
Mathematische Mengen Auf einer abstrakteren Ebene können Sie sich die Integer als Teil von mathematischen Mengen vorstellen. Wenn wir ein Paar p q verarbeiten, fragen wir, ob die einzelnen Elemente zu der gleichen Menge gehören. Wenn nicht, vereinigen wir die Menge von p mit der Menge von q, sodass beide der gleichen Menge angehören. Zum besseren Verständnis verwenden wir im Rest dieses Abschnitts eine Netzwerkterminologie, d.h., wir bezeichnen die Objekte als Knoten, die Paare als Verbindungen und die Äquivalenzklassen als Zusammenhangskomponenten oder kurz als Kompo-
239
1
Grundlagen
nenten. Der Einfachheit halber gehen wir davon aus, dass wir N Knoten mit IntegerNamen von 0 bis N-1 haben. Dies alles geschieht ohne Verlust der Allgemeingültigkeit, da wir in Kapitel 3 noch eine große Zahl an Algorithmen betrachten werden, die jeden beliebigen Namen effizient mit einem Integer verbinden können. Ein größeres Beispiel, das Ihnen einen kleinen Eindruck von der Schwierigkeit des Zusammenhangsproblems gibt, ist in Abbildung 1.54 zu sehen. Direkt zu erkennen ist die Komponente in der Mitte links, die aus einem einzigen Knoten besteht, und die Komponente unten links, die aus fünf Knoten besteht. Wesentlich schwieriger zu erkennen ist jedoch, dass alle anderen Knoten miteinander verbunden sind und eine Zusammenhangskomponente bilden. Für ein Programm ist die Aufgabe sogar noch schwieriger, da es nur mit Knotennamen und Verbindungen arbeiten kann und keinen Zugriff auf die geometrische Anordnung der Knoten im Diagramm hat. Wie können wir schnell und zuverlässig feststellen, ob zwei beliebige gegebene Knoten in einem solchen Netzwerk zusammenhängen?
Zusammenhangskomponente
Abbildung 1.54: Mittelgroßes Beispiel für den Zusammenhang (625 Knoten, 900 Kanten, 3 Zusammenhangskomponenten)
240
1.5 Fallstudie Union-Find
Unsere erste Aufgabe bei der Entwicklung eines Algorithmus besteht darin, das Problem sehr genau zu spezifizieren. Je mehr wir einem Algorithmus abverlangen, desto mehr Zeit und Speicher wird er erwartungsgemäß benötigen, um die Aufgabe zu erledigen. Es ist unmöglich, diese Beziehung a priori zu quantifizieren; folglich werden wir eine Problemspezifikation oft überarbeiten, sei es, dass wir feststellen, dass die Lösung des Problems zu schwierig oder zu teuer ist oder dass – im glücklicheren Fall – ein Algorithmus nützlichere Daten liefert, als in der ursprünglichen Spezifikation verlangt. Gemäß unserer Spezifikation des Zusammenhangsproblems muss unser Programm lediglich in der Lage sein festzustellen, ob ein gegebenes Paar p q miteinander verbunden ist; es muss nicht den konkreten Verbindungspfad für dieses Paar zurückliefern. Eine solche Anforderung würde das Problem beträchtlich erschweren und zu einer gänzlich anderen Familie von Algorithmen führen, die wir erst in Abschnitt 4.1 betrachten. Um das Problem zu spezifizieren, entwickeln wir eine API für die grundlegenden Operationen: initialisieren, eine Verbindung zwischen zwei Knoten hinzufügen, die Komponente identifizieren, die einen Knoten enthält, feststellen, ob zwei Knoten in der gleichen Komponente liegen und die Anzahl der Komponenten zählen. Daraus ergibt sich die folgende API:
Tabelle 1.72 Union-Find-API public class UF
void int boolean int
UF(int N)
Initialisiert N Knoten mit Integernamen (0 bis N-1).
union(int p, int q)
Fügt eine Verbindung zwischen p und q hinzu.
find(int p)
Komponentenbezeichner für p (0 bis N-1)
connected(int p, int q)
Liefert true zurück, wenn p und q in der gleichen Komponente sind.
count()
Anzahl der Komponenten
Die Operation union() vereinigt zwei Komponenten, wenn die beiden Knoten sich in verschiedenen Komponenten befinden. Die Operation find() liefert für einen gegebenen Knoten eine Komponenten-ID in Form eines Integers zurück. Die Operation connected() stellt fest, ob zwei Knoten in der gleichen Komponente liegen, und die Methode count() liefert die Anzahl der Komponenten zurück. Wir beginnen mit N Komponenten und jede union()-Operation, die zwei verschiedene Komponenten vereinigt, reduziert die Anzahl der Komponenten um 1.
241
1
Grundlagen
Wie wir bald sehen werden, lässt sich so die Entwicklung einer algorithmischen Lösung für die Verwaltung von Zusammenhangskomponenten auf die Entwicklung einer Implementierung dieser API reduzieren. Jede Implementierung muss:
eine Datenstruktur definieren (für die Repräsentation der bekannten Verbindungen), effiziente union()-, find()-, connected()- und count()-Implementierungen entwickeln, die auf dieser Datenstruktur basieren. Wie gewohnt, hat die Art der Datenstruktur direkte Auswirkungen auf die Effizienz der Algorithmen, sodass Datenstruktur und Algorithmusentwurf Hand in Hand gehen. Die API spezifiziert bereits, dass Knoten als auch Komponenten durch int-Werte zwischen 0 und N-1 identifiziert werden. Folglich ist es sinnvoll, ein knotenindiziertes Array id[] als grundlegende Datenstruktur zur Repräsentation der Komponenten zu verwenden. Wir verwenden immer den Namen von einem der Knoten in einer Komponente als Bezeichner der Komponente, sodass jede Komponente mehr oder weniger durch einen ihrer Knoten repräsentiert wird. Am Anfang haben wir N Komponenten, die jeweils einen Knoten repräsentieren, d.h., wir initialisieren id[i] mit i für alle i von 0 bis N-1. Für jeden Knoten i speichern wir in id[i] die Information, die von find() benötigt wird, um die Komponente zu identifizieren, die i enthält, und zwar unter Verwendung verschiedener algorithmusabhängiger Strategien. Alle unsere Implementierungen verwenden eine einzeilige Implementierung von connected(), die den booleschen Wert find(p) == find(q) zurückliefert. % more tinyUF.txt 10 4 3 3 8 6 5 9 4 2 1 8 9 5 0 7 2 6 1 1 0 6 7 % more mediumUF.txt 625 528 503 548 523 ... [900 Verbindungen] % more largeUF.txt 1000000 786321 134521 696834 98245 ... [2000000 Verbindungen]
242
1.5 Fallstudie Union-Find
Unsere Ausgangsbasis ist Algorithmus 1.5 aus Listing 1.54. Dort richten wir zwei Instanzvariablen ein: count zum Zählen der Komponenten und das Array id[]. Die Implementierungen von find() und union() behandeln wir in den folgenden Abschnitten. Zum Testen der API und als Grundlage für die Entwicklung fügen wir einen Client main() hinzu, der mithilfe der API das Problem der Verwaltung von Zusammenhangskomponenten löst. Er liest den Wert von N ein, gefolgt von mehreren Integerpaaren (jeweils im Bereich 0 bis N-1) und ruft dann für jedes Paar connected() auf: Wenn die beiden Knoten eines Paares bereits verbunden sind, wird das Paar übergangen und das nächste Paar eingelesen; wenn nicht, wird union() aufgerufen und das Paar ausgegeben. Bevor wir Implementierungen betrachten, bereiten wir Testdaten vor: die Datei tinyUF.txt enthält 11 Verbindungen zwischen 10 Knoten (siehe Abbildung 1.53), die Datei mediumUF.txt 900 Verbindungen zwischen 625 Knoten (siehe Abbildung 1.54) und die Datei largeUF.txt ist ein Beispiel mit 2 Millionen Verbindungen zwischen 1 Million Knoten. Unser Ziel ist es, Eingaben wie die in largeUF.txt in einer vertretbaren Zeit verarbeiten zu können. Bei der Analyse der Algorithmen konzentrieren wir uns auf die Häufigkeit, mit der jeder Algorithmus auf ein Arrayelement zugreift. Dabei gehen wir implizit von der Hypothese aus, dass die Ausführungszeiten der Algorithmen auf einem bestimmten Rechner innerhalb eines konstanten Faktors dieses Wertes liegen. Diese Hypothese ergibt sich direkt aus dem Code, ist nicht schwer mit Experimenten zu validieren und bietet, wie Sie sehen werden, einen guten Ausgangspunkt, um die Algorithmen zu vergleichen.
Union-Find-Kostenmodell Wenn wir Algorithmen zur Implementierung der Union-Find-API untersuchen, zählen wir die Arrayzugriffe (die Häufigkeit, mit der auf ein Arrayelement zum Lesen oder Schreiben zugegriffen wird).
243
1
Grundlagen
Listing 1.54: Union-Find-Implementierung (Algorithmus 1.5) public class UF { private int[] id; private int count;
// Zugriff auf Komponenten-ID (knotenindiziert). // Anzahl der Komponenten.
public UF(int N) { // Initialisiert Komponentenarray id. count = N; id = new int[N]; for (int i = 0; i < N; i++) id[i] = i; } public int count() { return count; } public boolean connected(int p, int q) { return find(p) == find(q); } public int find(int p) public void union(int p, int q) // Siehe S. 245 (Quick-Find),S. 247 (Quick-Union) und S. 252(gewichtet). public static void main(String[] args) { // Löst das Problem der Zusammenhangskomponentenverwaltung auf StdIn. int N = StdIn.readInt(); // Liest Anzahl der Knoten ein. UF uf = new UF(N); // Initialisiert N Komponenten. while (!StdIn.isEmpty()) { int p = StdIn.readInt(); int q = StdIn.readInt(); // Liest zu verbindendes Paar ein. if (uf.connected(p, q)) continue; // Ignoriert es, wenn bereits // verbunden. uf.union(p, q); // Vereinigt die Komponenten // und gibt die Verbindung aus. StdOut.println(p + " " + q); } StdOut.println(uf.count() + " components"); } }
% 4 3 6 9 2 5 7 6 2
java UF < tinyUF.txt 3 8 5 4 1 0 2 1 components
Dieser Code bildet die Basis für unsere UF-Implementierungen. Er speichert ein Integerarray id[], sodass die Methode find() für jeden Knoten einer zusammenhängenden Komponente den gleichen Integer zurückliefert. Die Methode union() muss diese Invariante aufrechterhalten.
244
1.5 Fallstudie Union-Find
1.5.2 Implementierungen Wir werden drei verschiedene Implementierungen betrachten, die alle auf dem knotenindizierten Array id[] basieren; Ziel ist es jeweils festzustellen, ob zwei Knoten der gleichen Komponente angehören.
Quick-Find Ein Ansatz besteht darin, die Invariante aufrecht- find untersucht id[5] und id[9] zuerhalten, dass p und q genau dann verbunden p q 0 1 2 3 4 5 6 7 8 9 sind, wenn id[p] gleich id[q] ist. Mit anderen 5 9 1 1 1 8 8 1 1 1 8 8 Worten, alle Knoten in einer Komponente müssen in id[] den gleichen Wert haben. Dieses Verfahren union muss alle 1en in 8en ändern wird Quick-Find genannt, da find(p) sich darauf p q 0 1 2 3 4 5 6 7 8 9 beschränkt, id[p] zurückzuliefern. Die Implementierung von connected(p, q) reduziert sich folg8 8 8 8 8 8 lich auf den Test id[p] == id[q] und liefert genau Abbildung 1.55: Quick-Find-Übersicht dann true zurück, wenn p und q der gleichen Komponente angehören. Um die Invariante für den Aufruf von union(p, q) aufrechtzuerhalten, prüfen wir zuerst, ob die beiden Knoten bereits der gleichen Komponente angehören, woraufhin wir nichts weiter machen müssen. Im anderen Fall stehen wir vor dem Problem, dass alle Elemente in id[], die Knoten der Komponente p repräsentieren, einen bestimmten Wert haben, und alle Elemente in id[], die Knoten der Komponente q repräsentieren, einen anderen Wert haben. Um die beiden Komponenten zu vereinen, müssen wir dafür sorgen, dass die Elemente der beiden Komponenten in id[] den gleichen Wert haben (siehe das Beispiel in Abbildung 1.55). Dazu durchlaufen wir das Array und ändern alle Elemente mit dem Wert id[p] in den Wert id[q]. Wir hätten auch alle Elemente id[q] in den Wert id[p] ändern können – diese Entscheidung ist rein willkürlich. Der auf diesen Beschreibungen basierende Code für find() und union() ist relativ simpel (Listing 1.55). Ein vollständiges Ablaufprotokoll für unseren Entwicklungsclient anhand unserer Testdatei tinyUF.txt finden Sie in Abbildung 1.56. public int find(int p) { return id[p]; } public void union(int p, int q) { // Vereinigt p und q in derselben Komponente. int pID = find(p); int qID = find(q); // Nichts tun, wenn p und q bereits derselben Komponente angehören. if (pID == qID) return; // Ändert den Namen der Komponente von p in den Namen der Komponente von q. for (int i = 0; i < id.length; i++) if (id[i] == pID) id[i] = qID; count--; } Listing 1.55: Quick-Find
245
1
Grundlagen
Quick-Find-Analyse Die Operation find() ist zweifelsohne schnell, da sie nur einmal auf das Array id[] zugreift, und zwar um die Operation abzuschließen. Große Probleme lassen sich jedoch mit Quick-Find nicht lösen, da union() für jedes Eingabepaar das ganze Array id[] durchgehen muss.
Satz F: Der Quick-Find-Algorithmus benötigt einen Arrayzugriff für jeden Aufruf von find() und zwischen N + 3 und 2N + 1 Arrayzugriffe für jeden Aufruf von union(), der zwei Komponenten vereinigt. Beweis: Ergibt sich direkt aus dem Code. Jeder Aufruf von connected() prüft zwei Elemente im Array id[], eines für jeden der beiden Aufrufe von find(). Jeder Aufruf von union(), der zwei Komponenten kombiniert, erledigt dies, indem er zweimal find() aufruft, jedes der N Elemente in id[] prüft und zwischen 1 und N − 1 von ihnen ändert.
Nehmen wir beispielsweise an, wir verwenden Quick-Find für die Verwaltung der Zusammenhangskomponenten und enden mit einer einzigen Komponente als Ergebnis. In diesem Fall werden mindestens N−1 Aufrufe von union() und folglich mindestens (N+3)(N−1) ~N 2 Arrayzugriffe benötigt, was direkt zu der Hypothese führt, dass die Verwaltung von Zusammenhangskomponenten mit Quick-Find ein quadratischer Prozess sein könnte. Diese Analyse drückt verallgemeinernd aus, dass Quick-Find für typische Anwendungen, bei denen sehr wenige Komponenten übrig bleiben, quadratisch ist. Sie können diese Hypothese mit einem Verdopplungstest leicht auf Ihrem Rechner bestätigen (ein Lehrbeispiel finden Sie in Übung 1.5.23). Moderne Rechner können Hunderte von Millionen oder Milliarden Anweisungen pro Sekunde ausführen, wobei diese Kosten nicht ins Gewicht fallen, wenn N klein ist; es könnte aber auch sein, dass wir mit einer modernen Anwendung Millionen oder Milliarden von Knoten und Verbindungen verarbeiten müssen (wie in unserer Testdatei largeUF.txt). id[p] und id[q] unterscheiden sich, sodass union() Elemente Wenn Sie dies immer noch nicht überzeugt und Sie gleich id[p] in id[q] Ihren Computer für besonders schnell halten, versuändert (in rot) id[p] und id[q] chen Sie, mittels Quick-Find herauszufinden, wie sind identisch, vielen Komponenten die Paare in largeUF.txt ange- deshalb keine Änderung hören – und Sie werden erkennen, dass wir ein sol- Abbildung 1.56: Quick-Find-Ablaufprotoches Problem mit dem Quick-Find-Algorithmus koll
246
1.5 Fallstudie Union-Find
nicht in einem vernünftigen Zeitrahmen lösen können. Wir sollten also nach besseren Algorithmen suchen.
Quick-Union Der nächste Algorithmus, den wir betrachten, ist eher als Ergänzung anzusehen, die sich auf die Beschleunigung der union()-Operation konzentriert. Er basiert auf der gleichen Datenstruktur – dem knotenindizierten Array id[] –, allerdings interpretieren wir die Werte anders, um kompliziertere Strukturen zu definieren. Insbesondere ist das id[]-Element eines Knotens der Name eines anderen Knotens in derselben Komponente (oder der eigene Name) – eine Verbindung, die wir als Referenz bezeichnen. Um find() zu implementieren, beginnen wir an dem gegebenen Knoten, folgen seiner Referenz zu einem anderen Knoten, folgen der Referenz dieses Knotens zu einem weiteren Knoten usw., bis wir eine Wurzel erreichen, d.h. einen Knoten, der eine Referenz auf sich selbst hat (was garantiert irgendwann der Fall ist, wie Sie sehen werden). Zwei Knoten gehören genau dann derselben Komponente an, wenn dieser Prozess für beide zur gleichen Wurzel führt. Zur Validierung dieses Prozesses muss union(p, q) diese Invariante erfüllen, was nicht schwer ist: Wir folgen den Referenzen, um die Wurzeln zu p und q zu finden, und benennen dann eine der Komponenten um, indem wir eine dieser Wurzeln mit der anderen verbinden. Daher rührt auch der Name Quick-Union. Auch in diesem Fall spielt es keine Rolle, ob wir die Komponente, die p enthält, oder die Komponente, die q enthält, umbenennen; in unserer Implementierung wird die Komponente umbenannt, die p enthält. Abbildung 1.58 zeigt ein Ablaufprotokoll des Quick-Union-Algorithmus für tinyUF.txt. Der Ablauf lässt sich am besten anhand der grafischen Darstellung von Abbildung 1.57 veranschaulichen, die wir nachfolgend beschreiben. public int find(int p) { // Findet Komponentenname. while (p != id[p]) p = id[p]; return p; } public void union(int p, int q) { // p und q unter einer Wurzel zusammenfassen. int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) return; id[pRoot] = qRoot; count--; } Listing 1.56: Quick-Union
247
1
Grundlagen
id[] ist die ElternknotenReferenz-Repräsentation für einen Wald von Bäumen
find muss den Referenzen bis zur Wurzel folgen
Wurzel 1 0
2
8 7
3
p q
0 1 2 3 4 5 6 7 8 9
5 9
1 1 1 8 3 0 5 1 8 8
9 find(5)
5
4
entspricht
id[id[id[5]]]
find(9)
entspricht
id[id[9]]
6 8
wird zum Elternknoten von 1 8
1 0
2
3 7
union ändert nur eine Referenz 9
p q
0 1 2 3 4 5 6 7 8 9
4
5
8
6
Abbildung 1.57: Überblick über Quick-Union
Repräsentation durch einen Wald von Bäumen Der Code für Quick-Union ist kompakt, aber nicht ganz leicht zu verstehen. Durch die Darstellung von Objekten als Knoten (beschriftete Kreise) und Referenzen als Pfeile von einem Knoten zum anderen erhalten wir eine grafische Repräsentation der Datenstruktur, die die Funktionsweise des Algorithmus anschaulich vermittelt. Die resultierenden Strukturen sind Bäume – in technischer Hinsicht ist unser Array id[] eine Elternknoten-Referenz-Repräsentation für einen Wald (Menge) von Bäumen. Zur Vereinfachung unserer Diagramme verzichten wir oft auf die Pfeilspitzen in den Referenzen (da sie alle nach oben weisen) und auf die – auf sich selbst verweisenden – Referenzen in den Wurzeln der Bäume. Die Wälder, die dem Array id[] für tinyUF.txt entsprechen, sehen Sie in Abbildung 1.58. Wenn wir mit einem beliebigen Knoten beginnen und den Referenzen folgen, landen wir am Ende bei der Wurzel des Baums, der den Knoten enthält. Wir können durch Induktion beweisen, dass diese Eigenschaft wahr ist: Es stimmt, dass nach der Initialisierung des Arrays jeder Knoten eine Referenz auf sich selbst hat, und wenn dies vor einer gegebenen union()-Operation wahr ist, ist es mit Sicherheit auch hinterher wahr. So liefert die Methode find() in Listing 1.56 den Namen des Knotens an der Wurzel zurück (sodass connected() prüft, ob zwei Knoten im gleichen Baum liegen). Diese Repräsentation ist für das gestellte Problem hilfreich, da die Knoten für die beiden Objekte genau dann im gleichen Baum liegen, wenn die Objekte der gleichen Komponente angehören. Außerdem sind die Bäume nicht schwer zu erstellen: die Implementierung von union() in Listing 1.56 vereint zwei Bäume zu einem in nur einer einzigen Anweisung, indem es die Wurzel eines Baums zum Elternknoten des anderen macht.
248
1.5 Fallstudie Union-Find
Abbildung 1.58: Quick-Union-Ablaufprotokoll (mit entsprechenden Wäldern von Bäumen)
Quick-Union-Analyse Der Quick-Union-Algorithmus müsste eigentlich schneller sein als der Quick-Find-Algorithmus, da er nicht das ganze Array für jedes Eingabepaar durchlaufen muss; aber um wie viel? Die Kosten von Quick-Union zu analysieren ist viel schwieriger als für QuickFind, da die Kosten viel stärker von der Art der Eingabe abhängen. Im besten Fall benötigt find() wie in Quick-Find nur einen einzigen Arrayzugriff, um den Bezeichner zu finden, der mit einem Knoten verbunden ist. Im schlimmsten Fall werden 2N−1 Arrayzugriffe benötigt, wie für 0 in Abbildung 1.59 (diese Zählung ist konservativ, da kompilierter Code normalerweise in der while-Schleife für die zweite Verwendung von id[p] nicht auf das Array zugreift). Infolgedessen ist es nicht schwer, eine Eingabe für den besten Fall zu konstruieren, bei der die Ausführungszeit für unseren Client zur Verwaltung der Zusammenhangskomponenten linear ist; andererseits ist es auch nicht schwer, eine Eingabe für den schlimmsten Fall zu konstruieren, bei der die Ausführungszeit quadratisch ist (siehe
249
1
Grundlagen
Abbildung 1.59 und Satz G). Glücklicherweise müssen wir uns nicht näher mit dem Problem der Analyse von Quick-Union befassen und werden hier auch nicht das Laufzeitverhalten von Quick-Union mit dem von Quick-Find vergleichen, da wir als Nächstes eine weitaus effizientere Variante kennenlernen werden. Im Moment können Sie Quick-Union als eine Verbesserung gegenüber Quick-Find betrachten, da es das Hauptproblem von Quick-Find beseitigt (den linearen Zeitbedarf von union()). Dieser Unterschied ist für typische Daten sicherlich eine Verbesserung, aber Quick-Union hat immer noch den Nachteil, dass wir die verbesserte Leistung nicht für alle Fälle garantieren können (für bestimmte Eingabedaten ist Quick-Union nicht schneller als Quick-Find). 0
1
2
3
4
...
1
2
3
4
...
2
3
4
...
3
4
...
4
...
0
1 0
2 1 0
3 2 1
Tiefe 4
0
Abbildung 1.59: Schlimmster Fall bei Quick-Union
Definition: Baumeigenschaften Die Größe eines Baums ergibt sich aus der Anzahl der Knoten. Die Tiefe eines Knotens in einem Baum ist gegeben durch die Anzahl der Kanten bzw. Referenzen auf dem Pfad vom Knoten zur Wurzel. Die Höhe eines Baums ist gegeben durch die maximale Tiefe seiner Knoten.
Satz G: Die Anzahl der Arrayzugriffe von find() in Quick-Union ist 1 plus zweimal die Tiefe des Knotens, der dem gegebenen Objekt entspricht. Die Anzahl der Arrayzugriffe von union() und connected() ist gleich der Kosten der beiden find()-Operationen (plus 1 für union(), wenn sich die gegebenen Objekte in verschiedenen Bäumen befinden). Beweis: Ergibt sich direkt aus dem Code.
Nehmen wir auch hier an, dass wir Quick-Union für die Verwaltung der Zusammenhangskomponenten verwenden und am Ende eine einzige Komponente als Ergebnis
250
1.5 Fallstudie Union-Find
erhalten. Aus Satz G lässt sich direkt ableiten, dass die Ausführungszeit im schlimmsten Fall quadratisch ist. Weiterhin angenommen, die Eingabepaare kommen in der Reihenfolge 0-1, 0-2, 0-3 usw. Nach N − 1 solchen Paaren haben wir N Knoten, die alle derselben Menge angehören, und der vom Quick-Union-Algorithmus gebildete Baum hat die Höhe N − 1, wobei 0 eine Referenz auf 1 hat, 1 eine Referenz auf 2, 2 eine Referenz auf 3 und so weiter (siehe Abbildung 1.59). Gemäß Satz G ist die Anzahl der Arrayzugriffe für die union()-Operation für das Paar 0 i genau 2i + 3 (Knoten 0 hat die Tiefe i und Knoten i die Tiefe 0). Damit beläuft sich die Gesamtsumme der Arrayzugriffe für die find()-Operationen für diese N Paare auf 2(1 + 2 + 3 + … + N) ~ N 2.
Gewichtetes Quick-Union Zum Glück bedarf es nur einer kleinen Änderung an Quick-Union, um zu garantieren, dass ungünstige Fälle wie diese nicht auftreten. Anstatt mit union() willkürlich den zweiten Baum mit dem ersten zu verbinden, speichern wir die Größe eines jeden Baums und verbinden immer den kleineren Baum mit dem größeren. Diese Änderung erfordert nur etwas mehr Code sowie ein weiteres Array für die Zählung der Knoten (Listing 1.54), steigert die Effizienz aber enorm. Wir bezeichnen diesen Algorithmus als gewichteten Quick-Union-Algorithmus. Der Wald von Bäumen, den Sie mit diesem Algorithmus für tinyUF.txt erstellen, ist in Abbildung 1.61 links abgebildet. Sogar bei diesem kleinen Beispiel ist die Höhe des Baums wesentlich geringer als die Höhe bei der ungewichteten Version. Quick-Union
q p
größerer Baum
p
kleinerer Baum
q
kleinerer Baum könnte den größeren Baum tiefer setzen
größerer Baum
gewichtet
p
größerer Baum
wählt immer die bessere Alternative
p
q
q
kleinerer Baum
kleinerer Baum
größerer Baum
Abbildung 1.60: Quick-Union – gewichtet
Analyse des gewichteten Quick-Union-Algorithmus Abbildung 1.61 zeigt rechts den schlimmsten Fall für gewichtete Quick-Union-Algorithmen, und zwar für den Fall, dass die Größe der Bäume, die union() vereinigen soll, immer gleich (und eine Zweierpotenz) ist. Diese Baumstrukturen sehen komplex aus, haben jedoch die einfache Eigenschaft, dass die Höhe eines Baums von 2n Knoten n ist. Außerdem erhalten wir bei der Kombination von zwei Bäumen mit 2n Knoten einen Baum mit 2n+1 Knoten und erhöhen die Baumhöhe auf n+1. Diese Beobachtung lässt sich zu dem Beweis verallgemeinern, dass der gewichtete Algorithmus eine logarithmische Laufzeit garantieren kann.
251
1
Grundlagen
% java WeightedQuickUnionUF < mediumUF.txt 528 503 548 523 ... 3 components % java WeightedQuickUnionUF < largeUF.txt 786321 134521 696834 98245 ... 6 components
Listing 1.57: Union-Find-Implementierung (Quick-Union – gewichtet) (Fortsetzung von Algorithmus 1.5) public class WeightedQuickUnionUF { private int[] id; // Elternknoten-Referenz (knotenindiziert). private int[] sz; // Größe der Komponente für Wurzeln // (knotenindiziert). private int count; // Anzahl der Komponenten. public WeightedQuickUnionUF(int N) { count = N; id = new int[N]; for (int i = 0; i < N; i++) id[i] = i; sz = new int[N]; for (int i = 0; i < N; i++) sz[i] = 1; } public int count() { return count; } public boolean connected(int p, int q) { return find(p) == find(q); } public int find(int p) { // Den Referenzen bis zu einer Wurzel folgen. while (p != id[p]) p = id[p]; return p; } public void union(int p, int q) { int i = find(p); int j = find(q); if (i == j) return; // Die kleinere Wurzel auf die größere zeigen lassen. if (sz[i] < sz[j]) { id[i] = j; sz[j] += sz[i]; } else { id[j] = i; sz[i] += sz[j]; } count--; } }
Dieser Code lässt sich am besten verstehen, wenn Sie ihn durch einen Wald von Bäumen repräsentieren (siehe Beschreibung im Text). Wir fügen ein knotenindiziertes Array sz[] als Instanzvariable hinzu, sodass union() die Wurzel des kleineren Baums mit der Wurzel des größeren Baums verbinden kann. Dank dieser kleinen Ergänzung ist es möglich, auch große Probleme in Angriff zu nehmen.
252
1.5 Fallstudie Union-Find
Referenzeingabe
Worst-Case-Eingabe
p q
p q
4 3
0 1
3 8
2 3
6 5
4 5
9 4
6 7
2 1
0 2
8 9 5 0
4 6
7 2 0 4 6 1 1 0 6 7
Abbildung 1.61: Ablaufprotokoll des gewichteten Quick-Union-Algorithmus (Wälder von Bäumen)
Satz H: Die Tiefe eines jeden Knotens in einem Wald, der mittels eines gewichteten Quick-UnionAlgorithmus für N Knoten erstellt wurde, beträgt höchstens lg N. Beweis: Wir beweisen durch (starke) Induktion eine noch stärkere Behauptung: Die Höhe eines Baums der Größe k im Wald ist höchstens lg k. Die Abbruchbedingung ergibt sich aus der Tatsache, dass die Baumhöhe 0 ist, wenn k gleich 1 ist. Gemäß der Induktionsannahme gehen wir davon aus, dass die Baumhöhe von einem Baum der Größe i höchsten lg i ist für alle i < k. Wenn wir einen Baum der Größe i mit einem Baum der Größe j mit i ≤ j und i + j = k kombinieren, erhöhen wir die Tiefe von jedem Knoten in der kleineren Menge um 1, aber sie sind jetzt in einem Baum der Größe i + j = k, sodass die Eigenschaft gewahrt bleibt, da 1 + lg i = lg (i + i) ≤ lg (i + j) = lg k ist.
253
1
Grundlagen
Quick-Union
Durchschnittstiefe: 5,11
gewichtet
Durchschnittstiefe: 1,52
Abbildung 1.62: Quick-Union und gewichtetes Quick-Union (100 Knoten, 88 union()-Operationen)
Korollar: Bei einem gewichteten Quick-Union-Algorithmus mit N Knoten ist die Wachstumsordnung der Kosten von find(), connected() und union() im schlimmsten Fall log N. Beweis: Jede Operation benötigt höchstens eine konstante Anzahl von Arrayzugriffen für jeden Knoten auf dem Pfad vom Knoten zur Wurzel im Wald.
Für die Verwaltung von Zusammenhangskomponenten lassen Satz H und das dazugehörige Korollar die logische Schlussfolgerung zu, dass der gewichtete Quick-UnionAlgorithmus der einzige unter den drei Algorithmen ist, der in der Praxis für riesige Probleme geeignet ist. Er benötigt höchstens cM lg N Arrayzugriffe, um M Verbindungen zwischen N Knoten für eine kleine Konstante c zu verarbeiten. Dieses Ergebnis steht in starken Kontrast zu unserer Feststellung, dass Quick-Find immer (und QuickUnion manchmal) mindestens MN Arrayzugriffe benötigt. Somit können wir mit dem gewichteten Quick-Union-Algorithmus garantieren, dass wir riesige Probleme zur Verwaltung von Zusammenhangskomponenten in einer vernünftigen Zeit lösen können. Für den Preis einiger Extra-Codezeilen erhalten wir ein Programm, das Millionen Mal schneller ist als die einfacheren Algorithmen für die riesigen Probleme zur Verwaltung von Zusammenhangskomponenten, die uns in praktischen Anwendungen immer wieder begegnen. Ein Beispiel mit 100 Knoten finden Sie in Abbildung 1.62. Dieses Diagramm zeigt deutlich, dass bei einem gewichteten Quick-Union-Algorithmus nur wenige Knoten weit von der Wurzel entfernt sind. Tatsächlich tritt sogar häufig der Fall ein, dass ein 1-Knoten-Baum mit einem größeren Baum kombiniert wird, sodass der Knoten nur eine Referenz von der Wurzel entfernt ist. Empirische Studien zu riesigen Problemen belegen, dass ein gewichteter Quick-Union-Algorithmus praktische Probleme normalerweise in konstanter Zeit pro Operation löst. Es ist daher wohl kaum zu erwarten, dass wir einen noch effizienteren Algorithmus finden werden.
254
1.5 Fallstudie Union-Find
Tabelle 1.73 Laufzeitverhalten von Union-Find-Algorithmen Algorithmus
Wachstumsordnung für N Knoten (schlimmster Fall) Konstruktor
Union
Find
Quick-Find
N
N
1
Quick-Union
N
Baumhöhe
Baumhöhe
Gewichtetes Quick-Union
N
lg N
lg N
Gewichtetes Quick-Union mit Pfadkompression
N
sehr, sehr nahe an, aber nicht ganz 1 (amortisiert) (siehe Übung 1.5.13)
Unmöglich
N
1
1
Optimale Algorithmen Gibt es einen Algorithmus, der eine konstante Laufzeit pro Operation garantiert? Diese Frage ist extrem schwer zu beantworten und beschäftigt die Wissenschaftler seit vielen Jahren. Auf der Suche nach einer Antwort wurden verschiedene Varianten des QuickUnion- und gewichteten Quick-Union-Algorithmus studiert. Einfach zu implementieren ist beispielsweise das folgende Verfahren, das als Pfadkompression bezeichnet wird. Im Idealfall hätten wir gern, dass jeder Knoten direkt mit der Wurzel seines Baums verbunden ist, wollen aber nicht den Preis dafür zahlen, eine große Anzahl der Referenzen zu ändern, wie wir es beim Quick-Find-Algorithmus gemacht haben. Wir können uns diesem Ideal annähern, indem wir dafür sorgen, dass alle Knoten, die wir auf dem Weg zur Wurzel untersuchen, direkt mit der Wurzel verbunden werden. Dieser Schritt scheint auf den ersten Blick sehr drastisch zu sein, ist aber einfach zu implementieren, und außerdem ist die Struktur dieser Bäume nicht heilig: Wenn wir den Algorithmus durch Ändern der Baumstruktur effizienter machen können, sollten wir dies tun. Wir implementieren die Pfadkompression, indem wir einfach eine weitere Schleife in find() einbauen, die das id[]-Element zu jedem Knoten, den wir auf unserem Weg zur Wurzel antreffen, so setzt, dass der Knoten direkt mit der Wurzel verbunden wird. Dadurch erhalten wir möglichst flache Bäume, womit wir uns dem Ideal annähern, das vom Quick-Find-Algorithmus erreicht wird. Diese Vorgehensweise ist einfach und effektiv, aber Sie werden in der Praxis wahrscheinlich keine Verbesserung gegenüber dem gewichteten Quick-Union-Algorithmus feststellen (siehe Übung 1.5.24). Theoretische Ergebnisse hierzu sind einerseits extrem kompliziert und andererseits äußerst bemerkenswert. Ein gewichteter Quick-Union-Algorithmus mit Pfadkompression ist optimal, aber nicht ganz konstant pro Operation. Das bedeutet, ein gewichteter QuickUnion-Algorithmus mit Pfadkompression weist nicht nur im schlimmsten Fall (amortisiert) keine konstante Zeit pro Operation auf, sondern es gibt gar keinen Algorithmus, der eine Union-Find-Operation in garantiert amortisiert konstanter Zeit ausführen kann (unter dem sehr allgemeinen „Cell Probe“-Computermodell). Ein gewichteter Quick-Union-Algorithmus mit Pfadkompression ist schon ziemlich nah an der optimalen Lösung für dieses Problem.
255
1
Grundlagen
Grafische Darstellungen der amortisierten Kosten
Anzahl der Arrayzugriffe
Wie bei jeder Datentypimplementie- Quick-Find rung lohnt es sich, mit Experimenten 1300 die Gültigkeit unserer Laufzeithypothesen für typische Clients zu testen (siehe Abschnitt 1.4). Abbildung 1.63 ein grauer Punkt für zeigt im Detail die Laufzeiten der jede Verbindung, die vom Client verarbeitet wird Algorithmen für unseren Entwicklungsclient beim Lösen des Problems union()-Operationen benötigen der Verwaltung von Zusammenhangsmindestens 625 Referenzen komponenten für unser 625-KnotenBeispiel (mediumUF.txt). Solche Diagramme lassen sich einfach erzeugen 458 (siehe Übung 1.5.16): Für die i-te verrote Punkte geben den kumulierten Durchschnitt an arbeitete Verbindung richten wir eine Variable cost ein, die die Anzahl der Arrayzugriffe (auf id[] oder sz[]) zählt, und eine Variable total für die Summe der bisherigen Arrayzugriffe. connected()-Operationen Dann zeichnen wir einen grauen Punkt benötigen genau 2 Arrayzugriffe bei (i, cost) und einen roten Punkt 0 bei (i, total/i), wobei die roten Anzahl von Verbindungen 0 900 Punkte die Durchschnittskosten pro Operation bzw. amortisierten Kosten angeben. Mit diesen grafischen Dar- Quick-Union find()-Operationen 100 werden teuer stellungen lässt sich das Algorithmus20 verhalten sehr gut veranschaulichen. 0 Bei Quick-Find benötigt jede union()Operation mindestens 625 Zugriffe Quick-Union – gewichtet (plus 1 für jede zusammengeführte keine teuren Operationen 8 20 Komponente, bis maximal 625) und 0 jede connected()-Operation 2 Zugriffe. Abbildung 1.63: Kosten aller Operationen (625 Knoten) Am Anfang führen die meisten Verbindungen zu einem Aufruf von union(), sodass der kumulierte Durchschnitt bei ungefähr 625 liegt; später sind die meisten Verbindungen Aufrufe von connected(), bei denen der Aufruf von union() übersprungen wird. Als Folge nimmt der kumulierte Durchschnitt ab, wenngleich er immer noch relativ hoch ist. (Eingaben, die mit einer großen Anzahl von connected()-Aufrufen verbunden sind und dadurch den Aufruf von union() überspringen, zeigen eine erheblich bessere Laufzeit – siehe Übung 1.5.23). Bei Quick-Union benötigen alle Operationen am Anfang nur einige wenige Arrayzugriffe; doch irgendwann wird die Höhe der Bäume zu einem bedeutenden Faktor und die amortisierten Kosten steigen deutlich. Bei einem gewichteten Quick-Union-Algorithmus bleibt die Baumhöhe klein, keine der Operationen ist teuer und die amortisierten Kosten sind niedrig. Diese Expe-
256
1.5 Fallstudie Union-Find
rimente bestätigen unsere Schlussfolgerung, dass ein gewichteter Quick-Union-Algorithmus auf alle Fälle eine Implementierung wert ist und kaum noch Raum für Verbesserungen bietet.
1.5.3 Ausblick Jede der hier betrachteten UF-Implementierungen stellt in gewisser Weise eine Verbesserung zur jeweils vorherigen dar, auch wenn dieser Prozess historisch gesehen nicht so glatt verlief, wie es hier den Anschein hat. (Wir haben den Vorteil, aus der Gegenwart heraus die Entwicklung der Algorithmen zu verfolgen, wie sie von den Wissenschaftlern über die Jahre studiert wurden.) Die Implementierungen sind einfach und das Problem ist gut spezifiziert, sodass wir die verschiedenen Algorithmen direkt durch empirische Studien evaluieren können. Außerdem können wir mit diesen Studien die mathematischen Ergebnisse bestätigen, die das Laufzeitverhalten der Algorithmen quantifizieren. Wenn möglich halten wir uns bei den grundlegenden Problemen in diesem Buch an die gleichen Schritte, wie wir sie für die Union-Find-Algorithmen in diesem Abschnitt durchgegangen sind. Einige davon sind in der folgenden Liste noch einmal zusammengefasst:
Wir legen uns auf eine möglichst vollständige und konkrete Problemstellung fest, einschließlich der Identifizierung grundlegender abstrakter Operationen, die untrennbar mit dem Problem verbunden sind, sowie der Erstellung einer API.
Wir entwickeln eine kurze, klare Implementierung für einen einfachen Algorithmus, wobei wir einen wohldurchdachten Entwicklungsclient und realistische Eingabedaten verwenden.
Wir erkennen, wenn eine Implementierung die Probleme nicht mehr im vorgesehenen Rahmen lösen kann und verbessert oder aufgegeben werden muss.
Wir verbessern unsere Implementierungen anhand eines Prozesses der schrittweisen Verfeinerung, wobei wir die Effizienz der Verbesserungsansätze mithilfe empirischer Analyse, mathematischer Analyse oder beidem validieren.
Wir suchen nach abstrakten High-Level-Repräsentationen von Datenstrukturen oder bereits vorhandenen Algorithmen, die einen High-Level-Entwurf der verbesserten Versionen erlauben.
Wir streben, wo immer möglich, nach Laufzeitgarantien für den schlimmsten Fall, aber geben uns auch mit gutem Laufzeitverhalten bei typischen Daten zufrieden, sofern verfügbar.
Wir wissen, wann wir weitere Verbesserungen erfahrenen Wissenschaftlern zum genaueren Studium überlassen sollten, und wenden uns dem nächsten Problem zu. Algorithmendesign bietet bei praktischen Problemen, wie wir sie für Union-Find kennengelernt haben, die Möglichkeit zu spektakulären Leistungssteigerungen und ist aus diesem Grund äußerst interessant für die Forschung. Mit welchen anderen DesignEntscheidungen ließen sich wohl Einsparungsfaktoren von 1 Million, 1 Milliarde oder sogar noch mehr erzielen?
257
1
Grundlagen
Die Entwicklung eines effizienten Algorithmus ist eine intellektuell befriedigende Aufgabe, die sich in der Praxis häufig direkt auszahlt. Wie das Problem der Verwaltung von Zusammenhangskomponenten zeigt, kann eine einfache Problemstellung dazu führen, dass wir zahlreiche Algorithmen studieren, die nicht nur nützlich und interessant, sondern durchaus auch kompliziert und schwer verständlich sind. Wir werden im weiteren Verlauf viele geniale Algorithmen kennenlernen, die über die Jahre für eine Vielzahl von praktischen Problemen entwickelt wurden. Je stärker sich der Anwendungsbereich von Computerprogrammen im natur- und wirtschaftswissenschaftlichen Sektor ausdehnt, desto wichtiger wird es, bekannte Probleme mithilfe von effizienten Algorithmen lösen und für neue Probleme effiziente Lösungen entwickeln zu können.
258
Fragen und Antworten
1.5
Fragen und Antworten
Frage: Ich möchte die API gerne um eine delete()-Methode erweitern, um Clients die Möglichkeit zu bieten, Verbindungen zu löschen. Können Sie mir einen Tipp geben, wie ich dabei vorgehen sollte? Antwort: Niemand hat bisher einen Algorithmus entwickelt, der einfacher und effizienter ist als die, die wir in diesem Abschnitt zum Thema Löschoperationen betrachtet haben. Löschoperationen werden Ihnen im Verlauf dieses Buches immer wieder begegnen. Mehrere der hier betrachteten Datenstrukturen zeichnen sich dadurch aus, dass es wesentlich schwieriger ist, darin etwas zu löschen, als etwas hinzuzufügen. Frage: Was ist unter dem Cell-Probe-Modell zu verstehen? Antwort: Dabei handelt es sich um ein Rechenmodell, bei dem wir nur die Zugriffe auf einen RAM-Speicher zählen, der groß genug ist, die Eingabedaten zu speichern, und alle anderen Operationen als kostenlos betrachten.
1.5
Allgemeine Übungen
1.
Geben Sie den Inhalt des Arrays id[] sowie die Anzahl der Arrayzugriffe für jedes Eingabepaar an. Verwenden Sie dazu einen Quick-Find-Algorithmus für die Folge 9-0 3-4 5-8 7-2 2-1 5-7 0-3 4-2.
2.
Wiederholen Sie Übung 1.5.1, diesmal jedoch mit einem Quick-Union-Algorithmus (Listing 1.56). Zeichnen Sie außerdem den Wald von Bäumen, wie er von dem Array id[] nach der Verarbeitung jedes Eingabepaares repräsentiert wird.
3.
Wiederholen Sie Übung 1.5.1, diesmal jedoch mit einem gewichteten QuickUnion-Algorithmus (Listing 1.57).
4.
Geben Sie den Inhalt der Arrays id[] und sz[] sowie die Anzahl der Arrayzugriffe für jedes Eingabepaar an, das in den Beispielen für den gewichteten Quick-UnionAlgorithmus im Text angeführt wird (Referenzeingabe als auch Worst-Case-Eingabe).
5.
Schätzen Sie den Mindestzeitaufwand (in Tagen), den ein Quick-Find-Algorithmus benötigt, um ein Problem zur Verwaltung der Zusammenhangskomponenten mit 109 Knoten und 106 Eingabepaaren zu lösen, und zwar auf einem Computer, der 109 Anweisungen pro Sekunden ausführen kann. Gehen Sie davon aus, dass jede Iteration der inneren for-Schleife 10 Maschinenbefehle benötigt.
6.
Wiederholen Sie Übung 1.5.5 für einen gewichteten Quick-Union-Algorithmus.
7.
Entwickeln Sie die Klassen QuickUnionUF und QuickFindUF, die Quick-Union bzw. Quick-Find implementieren.
259
1
Grundlagen
8.
Liefern Sie ein Gegenbeispiel, das zeigt, warum die folgende intuitive Implementierung von union() für Quick-Find nicht korrekt ist: public void union(int p, int q) { if (connected(p, q)) return; // Ändert die Komponente von p in den Namen von q. for (int i = 0; i < id.length; i++) if (id[i] == id[p]) id[i] = id[q]; count--; }
9.
10.
Zeichnen Sie den Baum für das nachfolgende Array id[]. Ist dieses Ergebnis möglich, wenn Sie einen gewichteten Quick-Union-Algorithmus ausführen? Erläutern Sie, warum dies unmöglich ist oder nennen Sie eine Folge von Operationen, die dieses Array zum Ergebnis hat. i
0
1
2
3
4
5
6
7
8
9
id[i]
1
1
3
1
5
6
1
3
4
5
Angenommen, wir setzen in dem gewichteten Quick-Union-Algorithmus id[find(p)] auf q und nicht auf id[find(q)]. Wäre der resultierende Algorithmus korrekt? Antwort: Ja, aber er würde die Baumhöhe vergrößern, sodass die Laufzeitgarantie ungültig würde.
11.
1.5
260
Implementieren Sie einen gewichteten Quick-Find-Algorithmus, bei dem Sie immer die id[]-Elemente der kleineren Komponente in den Bezeichner der größeren Komponente ändern. Wie wirkt sich diese Änderung auf die Laufzeit aus?
Knifflige Aufgaben
12.
Quick-Union-Algorithmus mit Pfadkompression: Erweitern Sie Quick-Union (Listing 1.56) um eine Pfadkompression, indem Sie zu find() eine Schleife hinzufügen, die jeden Knoten auf dem Pfad von p zur Wurzel direkt mit der Wurzel verbindet. Nennen Sie eine Folge von Eingabepaaren, für das dieses Verfahren einen Pfad der Länge 4 erzeugt. Hinweis: Die amortisierten Kosten pro Operation für diesen Algorithmus sind logarithmisch.
13.
Gewichteter Quick-Union-Algorithmus mit Pfadkompression: Überarbeiten Sie den gewichteten Quick-Union-Algorithmus (Algorithmus 1.5) und implementieren Sie wie in Übung 1.5.12 Pfadkompression. Nennen Sie eine Folge von Eingabepaaren, für die dieses Verfahren einen Baum der Höhe 4 erzeugt. Hinweis: Die amortisierten Kosten pro Operation für diesen Algorithmus sind begrenzt durch eine Umkehrfunktion der Ackermannfunktion und kleiner als 5 für jeden nur denkbaren Wert von N.
Knifflige Aufgaben
14.
Gewichteter Quick-Union-Algorithmus nach Höhe: Entwickeln Sie eine UF-Implementierung, die die gleiche grundlegende Strategie verfolgt wie ein gewichteter Quick-Union-Algorithmus, aber die Baumhöhe speichert und immer den kleineren Baum mit dem größeren verbindet. Beweisen Sie, dass es mit Ihrem Algorithmus eine logarithmische obere Schranke für die Höhe der Bäume mit N Knoten gibt.
15.
Binomialbäume: Zeigen Sie, dass die Anzahl der Knoten auf jeder Ebene eines Worst-Case-Baums für gewichtete Quick-Union-Algorithmen Binomialkoeffizienten sind. Berechnen Sie die durchschnittliche Tiefe eines Knotens in einem WorstCase-Baum mit N = 2n Knoten.
16.
Grafische Darstellungen der amortisierten Kosten: Überarbeiten Sie Ihre Implementierungen aus Übung 1.5.7, um die amortisierten Kosten wie im Text grafisch darzustellen.
17.
Zufallsverbindungen: Entwickeln Sie einen UF-Client ErdosRenyi, der einen Integer N von der Befehlszeile entgegennimmt und Zufallspaare aus Integern zwischen 0 und N-1 erzeugt. Er soll mit connected() prüfen, ob sie verbunden sind, und, wenn dies nicht der Fall ist (wie in unserem Entwicklungsclient), union() aufrufen und die Schleife so lange durchlaufen, bis alle Knoten verbunden sind. Zum Schluss soll die Anzahl der erzeugten Verbindungen ausgegeben werden. Verwenden Sie für Ihr Programm eine statische Methode count(), die N als Argument übernimmt und die Anzahl der Verbindungen zurückliefert, sowie eine Methode main(), die N von der Befehlszeile übernimmt, count() aufruft und den zurückgegebenen Wert ausgibt.
18.
Zufallsgittergenerator: Schreiben Sie ein Programm RandomGrid, das einen intWert N von der Befehlszeile entgegennimmt, alle Verbindungen in einem N×N-Gitter erzeugt, sie in eine zufällige Reihenfolge bringt, nach dem Zufallsprinzip ausrichtet (sodass p q und q p mit gleicher Wahrscheinlichkeit auftreten) und das Ergebnis auf der Standardausgabe ausgibt. Verwenden Sie RandomBag, um die Verbindungen in eine zufällige Reihenfolge zu bringen (siehe Übung 1.3.34). Um p und q in einem einzigen Objekt zu kapseln, verwenden Sie die nachfolgende eingebettete Klasse Connection. Realisieren Sie Ihr Programm mit zwei statischen Methoden: generate(), die N als Argument übernimmt und ein Array von Verbindungen zurückliefert, und main(), die N von der Befehlszeile entgegennimmt, generate() aufruft und über das zurückgelieferte Array iteriert, um die Verbindungen auszugeben. private class Connection { int p; int q; public Connection(int p, int q) { this.p = p; this.q = q; } } Listing 1.58: Datensatz zum Kapseln von Verbindungen
261
1
Grundlagen
19.
Animation: Schreiben Sie einen RandomGrid-Client (Übung 1.5.18), der wie in unserem Entwicklungsclient mittels UnionFind die Konnektivität prüft und mit StdDraw die Verbindungen zeichnet, wenn sie verarbeitet werden.
20.
Dynamisches Wachstum: Entwickeln Sie unter Verwendung verketteter Listen oder eines Arrays variabler Größe eine gewichtete Quick-Union-Implementierung, die die Beschränkung aufhebt, die Anzahl der Objekte im Voraus zu kennen. Fügen Sie zu der API eine Methode newSite() hinzu, die einen int-Bezeichner zurückliefert.
1.5
262
Experimente
21.
Erdös-Renyi-Modell: Testen Sie mit Ihrem Client aus Übung 1.5.17 die Hypothese, dass die Anzahl der Paare, die erzeugt werden, um eine Komponente zu erhalten, ~½ N ln N ist.
22.
Verdopplungstest für das Erdös-Renyi-Modell: Entwickeln Sie einen Client zum Testen der Laufzeit, der einen int-Wert T von der Befehlszeile entgegennimmt und T Versuche des folgenden Experiments durchführt: Erzeugen Sie mit Ihrem Client aus Übung 1.5.17 Zufallsverbindungen, stellen Sie wie in unserem Entwicklungsclient mit UnionFind die Konnektivität fest und durchlaufen Sie die Schleife so lange, bis alle Knoten verbunden sind. Geben Sie für jedes N den Wert von N, die durchschnittliche Anzahl der verarbeiteten Verbindungen und das Verhältnis der Ausführungszeit zur vorherigen Ausführungszeit an. Validieren Sie mithilfe Ihres Programms die Hypothese im Text, dass die Ausführungszeiten für Quick-Findund Quick-Union-Algorithmen quadratisch und für gewichtete Quick-Union-Algorithmen beinahe linear sind.
23.
Vergleichen Sie Quick-Find mit Quick-Union für das Erdös-Renyi-Modell: Entwickeln Sie einen Client zum Testen der Laufzeit, der einen int-Wert T von der Befehlszeile entgegennimmt und T Versuche des folgenden Experiments durchführt: Erzeugen Sie mit Ihrem Client aus Übung 1.5.17 Zufallsverbindungen. Speichern Sie die Verbindungen, sodass Sie sowohl Quick-Union als auch Quick-Find verwenden können, um wie in unserem Entwicklungsclient die Konnektivität festzustellen, und durchlaufen Sie die Schleife so lange, bis alle Knoten verbunden sind. Geben Sie für jedes N den Wert von N und das Verhältnis der beiden Ausführungszeiten aus.
24.
Schnelle Algorithmen für das Erdös-Renyi-Modell: Fügen Sie zu Ihren Tests in Übung 1.5.23 einen gewichteten Quick-Union-Algorithmus und einen gewichteten Quick-Union-Algorithmus mit Pfadkomprimierung hinzu. Können Sie einen Unterschied zwischen diesen beiden Algorithmen feststellen?
25.
Verdopplungstest für Zufallsgitter: Entwickeln Sie einen Client zum Testen der Laufzeit, der einen int-Wert T von der Befehlszeile entgegennimmt und T Versuche des folgenden Experiments durchführt: Erzeugen Sie mit Ihrem Client aus Übung 1.5.18 die Verbindungen in einem quadratischen N×N-Gitter, die zufällig ausgerich-
Experimente
tet und zufällig angeordnet sind, und stellen Sie dann wie in unserem Entwicklungsclient mit UnionFind den Zusammenhang fest, wobei Sie die Schleife so oft durchlaufen, bis alle Knoten verbunden sind. Geben Sie für jedes N den Wert von N, die durchschnittliche Anzahl der verarbeiteten Verbindungen und das Verhältnis der Ausführungszeit zur vorherigen Ausführungszeit an. Validieren Sie mithilfe Ihres Programms die Hypothesen im Text, dass die Ausführungszeiten für QuickFind- und Quick-Union-Algorithmen quadratisch sind und für gewichtete QuickUnion-Algorithmen beinahe linear. Hinweis: Wenn N verdoppelt wird, vervierfacht sich die Zahl der Knoten im Gitter, sodass Sie einen Verdopplungsfaktor von 16 für quadratisch und von 4 für linear erwarten dürfen. 26.
Grafische Darstellung der amortisierten Kosten für Erdös-Renyi: Entwickeln Sie einen Client, der einen int-Wert N von der Befehlszeile entgegennimmt und eine grafische Darstellung der amortisierten Kosten aller Operationen wie im Text für die Erzeugung zufälliger Integerpaare zwischen 0 und N-1 ausgibt. Stellen Sie mit connected() fest, ob die Integer verbunden sind, und wenn dies nicht der Fall sein sollte, rufen Sie union() auf (wie in unserem Entwicklungsclient). Durchlaufen Sie die Schleife, bis alle Knoten verbunden sind.
263
Sortieren
2
2.2 Mergesort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 294 2.3 Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 313 2.4 Vorrangwarteschlangen . . . . . . . . . . . . . . . . . . . . . . . . . . 333 2.5 Anwendungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 363
ÜBERBLICK
2.1 Elementare Sortierverfahren . . . . . . . . . . . . . . . . . . . . . 267
2
Sortieren
»
EINLEITUNG
Beim Sortieren geht es darum, einen Satz von Objekten durch Umordnen in eine logische Reihenfolge zu bringen. So werden auf Ihrer Kreditkartenabrechnung Ihre Transaktionen in der Regel nach Datum aufgelistet – eine Reihenfolge, die sehr wahrscheinlich auf einen Sortieralgorithmus zurückzuführen ist. Anfangs ging man in der Informatik davon aus, dass 30 Prozent aller Rechenzyklen nur dem Sortieren dienen. Wenn dieser Anteil heute geringer ist, liegt das nicht daran, dass Sortieren keine so große Rolle mehr spielt, sondern dass die Sortieralgorithmen relativ effizient geworden sind. Heutzutage müssen wir damit leben, dass die Allgegenwart der Computer uns eine Flut von Daten beschert, und der erste Schritt, die Daten zu organisieren, besteht häufig darin, diese zu sortieren. Aus diesem Grunde gibt es auf praktisch jedem Computersystem Implementierungen von Sortieralgorithmen, die dem System und seinen Benutzern zur Verfügung stehen. Aus Sicht eines Praktikers gibt es drei gute Gründe, warum man sich intensiver mit den Sortieralgorithmen beschäftigen sollte – selbst wenn man mit einem der vorhandenen Systemsortierverfahren auskommt:
Die Analyse von Sortieralgorithmen ist eine gute Möglichkeit, um sich eingehender mit der Methodik vertraut zu machen, mit der wir in diesem Buch die Performance von Algorithmen vergleichen.
Techniken, wie die hier besprochenen, lassen sich häufig auch auf andere Probleme übertragen.
Oft ist der Einsatz eines Sortieralgorithmus der erste Schritt oder die Grundlage für die Lösung eines anderen Problems. Wichtiger noch als diese praktischen Erwägungen ist aber, dass die Algorithmen elegant, klassisch und effektiv – und damit jedes Studiums wert – sind. Sortieren ist für die kommerzielle Datenverarbeitung und das moderne wissenschaftliche Rechnen von großer Bedeutung. In der Transaktionsverarbeitung, kombinatorischen Optimierung, Astrophysik, Molekulardynamik, Linguistik, Genomik, Wettervorhersage und vielen anderen Bereichen finden Sie eine Fülle an Anwendungen. Es gibt sogar einen Sortieralgorithmus (Quicksort in Abschnitt 2.3), der zu den zehn wichtigsten Algorithmen für Wissenschaft und Technik im 20. Jahrhundert zählt. In diesem Abschnitt stellen wir mehrere klassische Sortierverfahren sowie eine effiziente Implementierung eines grundlegenden Datentyps (namens Vorrangwarteschlange) vor. Wir vermitteln Ihnen die theoretischen Grundlagen zum Vergleichen der Sortieralgorithmen und beenden das Kapitel mit einem Überblick über Anwendungen, in denen Sortieralgorithmen und Vorrangwarteschlangen eine Rolle spielen.
»
266
2.1 Elementare Sortierverfahren
2.1
Elementare Sortierverfahren
Auf unserem ersten Ausflug in das weite Feld der Sortieralgorithmen werden wir zwei elementare Sortierverfahren und zu jedem dieser Sortierverfahren noch eine Variante untersuchen. Wir werden diese relativ einfachen Algorithmen unter anderem aus folgenden Gründen ausführlich untersuchen: Erstens bieten sie uns den nötigen Kontext, um uns mit der Terminologie und den grundlegenden Mechanismen vertraut zu machen. Zweitens lassen sich diese einfachen Algorithmen in manchen Anwendungen effektiver einsetzen als die anspruchsvolleren Algorithmen, die wir weiter hinten diskutieren. Und drittens kann man mit ihrer Hilfe, wie Sie noch sehen werden, die Effizienz von anspruchsvolleren Algorithmen steigern.
2.1.1 Spielregeln Unser Hauptaugenmerk in diesem Kapitel liegt auf Algorithmen zum Umordnen von Arrays von Elementen, bei denen die Elemente über einen Schlüssel (key) verfügen. Ziel des Sortieralgorithmus ist es, die Elemente so anzuordnen, dass deren Schlüssel einer wohldefinierten Sortierregel gehorchen (normalerweise einer numerischen oder alphabetischen Ordnung). Das heißt, wir wollen das Array so umordnen, dass der Schlüssel jedes Elements nicht kleiner ist als der Schlüssel jedes Elements mit einem niedrigeren Index und nicht größer als der Schlüssel jedes Elements mit einem größeren Index. Die speziellen Eigenschaften der Schlüssel und der Elemente können sich von Anwendung zu Anwendung stark unterscheiden. In Java sind die Elemente einfach Objekte und der abstrakte Begriff eines Schlüssels ist durch den Mechanismus der Schnittstelle Comparable ( Listing 2.3) in Java integriert. Die Klasse Example aus Listing 2.1 veranschaulicht unsere Vorgehensweise: Wir packen unseren Code in eine Klasse, die neben der Methode sort(), die das Sortieren übernimmt, aus den beiden private Hilfsmethoden less() und exch() (möglicherweise ergänzt um weitere Hilfsmethoden) und einem Beispielclient main() besteht. Example zeigt auch wie Code aussieht, der für das erste Debuggen nützlich sein kann: Der Testclient main() sortiert die Strings von der Standardeingabe und gibt den Inhalt des Arrays mit der private-Methode show() aus. Im weiteren Verlauf dieses Kapitels werden wir noch andere Testclients zum Vergleichen der Algorithmen und zum Analysieren ihrer Performance kennenlernen. Um die verschiedenen Sortierverfahren auseinanderhalten zu können, geben wir unseren Sortierklassen individuelle Namen. Clients können die verschiedene Implementierungen dann über diese Namen aufrufen: Insertion.sort(), Merge.sort(), Quick.sort() und so weiter. Bis auf wenige Ausnahmen greift unser Sortiercode nur über zwei Operationen auf die Daten zu: die Methode less() vergleicht die Elemente und die Methode exch() vertauscht sie. Die Methode exch() ist einfach zu implementieren, und dank der Schnittstelle Comparable ist auch die Implementierung von less() nicht schwer. Indem wir den Datenzugriff auf diese beiden Operationen beschränken, wird unser Code lesbarer und portierbarer und wir können uns leichter davon überzeugen, dass die Algorithmen korrekt funktionieren. Außerdem können wir so besser die Performance unter-
267
2
Sortieren
suchen und die Algorithmen vergleichen. Bevor wir jedoch die Sortierimplementierungen näher betrachten, wollen wir auf eine Reihe von wichtigen Punkten eingehen, die bei jedem Sortierverfahren zu berücksichtigen sind.
Listing 2.1: Vorlage für Sortierklassen public class Example { public static void sort(Comparable[] a) { /* Siehe Algorithmen 2.1, 2.2, 2.3, 2.4, 2.5 oder 2.7. */
}
private static boolean less(Comparable v, Comparable w) { return v.compareTo(w) < 0; } private static void exch(Comparable[] a, int i, int j) { Comparable t = a[i]; a[i] = a[j]; a[j] = t; } private static void show(Comparable[] a) { // Gibt das Array auf einer einzigen Zeile aus. for (int i = 0; i < a.length; i++) StdOut.print(a[i] + " "); StdOut.println(); } public static boolean isSorted(Comparable[] a) { // Prüft, ob die Arrayelemente sortiert sind. for (int i = 1; i < a.length; i++) if (less(a[i], a[i-1])) return false; return true; } public static void main(String[] args) { // Liest Strings von der Standardeingabe, sortiert sie und gibt sie aus. String[] a = In.readStrings(); sort(a); assert isSorted(a); show(a); } }
% more tiny.txt S O R T E X A M P L E % java Example < tiny.txt A E E L M O P R S T X % more words3.txt bed bug dad yes zoo ... all bad yet % java Example < words3.txt all bad bed bug dad ... yes yet zoo
268
2.1 Elementare Sortierverfahren
Diese Klasse veranschaulicht unsere Konventionen für das Implementieren von Sortieralgorithmen für Arrays. Zu jedem Sortieralgorithmus, den wir betrachten, definieren wir eine sort()-Methode in einer Klasse wie Example – wobei wir den Namen Example durch einen Namen ersetzen, der auf den implementierten Algorithmus hinweist. Der vorgestellte Testclient sortiert Strings, die von der Standardeingabe entgegengenommen werden. Unsere Implementierung ist aber nicht auf Strings beschränkt, sondern erlaubt es, das Sortierverfahren auf jeden Datentyp anzuwenden, der Comparable implementiert.
Verifizierung Kann die Sortierimplementierung das Array immer in die gewünschte Ordnung bringen, unabhängig von der Ausgangsordnung? Als defensive Absicherung fügen wir in unserem Testclient die Anweisung assert isSorted(a); ein, die uns darüber informiert, ob die Elemente im Array nach dem Sortieren in der gewünschten Reihenfolge stehen. Es wird empfohlen, diese Anweisung in jede Sortierimplementierung aufzunehmen – trotz des Umstands, dass wir unseren Code üblicherweise testen sowie mathematisch nachweisen, dass unsere Algorithmen korrekt sind. Beachten Sie, dass dieser Test nur dann wirklich aussagekräftig ist, wenn wir die Arrayelemente ausschließlich mit exch() ändern. Wenn wir Code verwenden, der Werte direkt im Array speichert, ist die Aussage des Tests zweifelhaft (zum Beispiel würde Code, der das ursprüngliche Eingabearray zerstört, indem er alle Einträge auf den gleichen Wert setzt, den Test anstandslos bestehen).
Laufzeit Zusätzlich beschäftigen wir uns mit der Performance der Algorithmen. Wir beginnen damit, dass wir die Anzahl der grundlegenden Operationen ermitteln (Vergleichs- und Tauschoperationen oder vielleicht die Häufigkeit der Schreib- und Lesezugriffe auf das Array), die die verschiedenen Sortieralgorithmen für verschiedene natürliche Eingabemodelle ausführen. Anschließend lassen wir diese Werte in unsere Hypothesen über das jeweilige Laufzeitverhalten der einzelnen Algorithmen einfließen und stellen Ihnen Tools vor, mit denen Sie experimentell die Gültigkeit dieser Hypothesen prüfen können. Mit unserem konsistenten Codierstil erleichtern wir die Entwicklung gültiger Hypothesen zur Performance, die für alle typischen Implementierungen gelten.
Kostenmodell für das Sortieren Bei der Analyse der Sortieralgorithmen zählen wir die Vergleichs- und Tauschoperationen. Bei Algorithmen, die keine Tauschoperationen durchführen, zählen wir die Arrayzugriffe.
269
2
Sortieren
Zusätzlicher Speicherbedarf Der zusätzliche Speicherbedarf eines Sortieralgorithmus ist oft ein genauso wichtiger Faktor wie die Ausführungszeit. Dabei wird grundsätzlich zwischen zwei Arten von Sortieralgorithmen unterschieden: Algorithmen, die in-place sortieren und keinen zusätzlichen Speicher benötigen (außer vielleicht für einen kleinen Funktionsaufrufstapel oder eine konstante Anzahl von Instanzvariablen), und Algorithmen, die so viel zusätzlichen Speicher benötigen, dass sie eine Kopie des zu sortierenden Arrays anlegen können.
Datentypen Unser Sortiercode eignet sich für jeden Elementtyp, der die Schnittstelle Comparable implementiert. Die Anlehnung an diese Java-Konvention lohnt sich, da viele Datentypen, die Sie eventuell sortieren möchten, bereits Comparable implementiert haben – wie z.B. Javas numerische Wrappertypen wie Integer und Double, oder auch String und verschiedene weiterführende Typen wie File oder URL. Sie können unsere Sortiermethoden also auch problemlos mit einem Array von Elementen eines dieser Typen als Argument aufrufen (beispielsweise nutzt der Code aus Listing 2.2 den Quicksort-Algorithmus (siehe Abschnitt 2.3), um N Zufallswerte vom Typ Double zu sortieren). Wenn wir umgekehrt eigene Typen schreiben, die von entsprechendem Client-Code sortierbar sein sollen, brauchen wir nur die Schnittstelle Comparable zu implementieren. Dazu müssen wir lediglich eine Methode compareTo() implementieren, die eine sogenannte natürliche Ordnung der Objekte dieses Typs definiert (wie in Listing 2.3 für unseren Datentyp Date demonstriert). Der Java-Konvention zufolge liefert der Aufruf von v.compareTo(w) einen Integer zurück, der negativ, null oder positiv ist (normalerweise -1, 0 oder +1), je nachdem ob vw ist. (Aus Platzgründen und der besseren Lesbarkeit wegen verwenden wir für den Rest dieses Abschnitts die Schreibweise v>w als Kurzform für den Code v.compareTo(w)>0.) Per Konvention löst v.compareTo(w) eine Ausnahme aus, wenn v und w vom Typ nicht kompatibel sind oder einer von beiden null ist. Außerdem muss compareTo() eine totale Ordnung implementieren und dafür die folgenden Eigenschaften aufweisen:
reflexiv (für alle v gilt v=v) antisymmetrisch (für alle v und w gilt, wenn vv, und wenn v=w ist, dann ist w=v)
transitiv (für alle v, w und x gilt, dass aus v that.day ) if (this.day < that.day ) return 0; }
} } }
return return return return return return
+1; -1; +1; -1; +1; -1;
public String toString() { return month + "/" + day + "/" + year; } } Listing 2.3: Einen Comparable-Datentyp definieren
Im restlichen Kapitel werden wir eine Reihe von Algorithmen betrachten, die Arrays von Objekten sortieren, für die es eine natürliche Ordnung gibt. Um die Algorithmen zu vergleichen und gegenüberzustellen, werden wir ihre wichtigsten Eigenschaften untersuchen, einschließlich der Anzahl der Vergleichs- und Tauschoperationen für die verschiedenen Arten von Eingaben und ihrem zusätzlichen Speicherbedarf. Die Ergebnisse dieser Untersuchungen führen uns zu der Entwicklung von Hypothesen zur Laufzeitcharakteristik, von denen viele in den letzten Jahrzehnten wieder und wieder auf unzähligen Rechnern bestätigt wurden. Da individuelle Implementierungen grundsätzlich extra geprüft werden müssen, werden wir auch auf die hierfür notwendigen Tools eingehen. Und nachdem wir die klassischen Algorithmen Selectionsort, Insertionsort, Shellsort, Mergesort, Quicksort und Heapsort betrachtet haben, werden wir in Abschnitt 2.5 noch einige praktische Probleme und direkte Anwendungen vorstellen.
271
2
Sortieren
2.1.2 Selectionsort Einer der einfachsten Sortieralgorithmen funktioniert wie folgt: Zuerst suchen Sie das kleinste Element im Array und vertauschen es mit dem ersten Element (mit sich selbst, wenn das erste Element bereits das kleinste ist). Dann suchen Sie das nächstkleinere Element und vertauschen es mit dem zweiten Element. Damit fahren Sie fort, bis das ganze Array sortiert ist. Diese Vorgehensweise wird Selectionsort (Sortieren durch Auswählen) genannt, da wiederholt das kleinste verbleibende Element ausgewählt wird. Wie Sie der Implementierung in Algorithmus 2.1 entnehmen können, besteht die innere Schleife von Selectionsort lediglich aus einem Vergleich zwischen dem aktuellen Element und dem bisher kleinsten gefundenen Element (plus dem Code, der nötig ist, um den aktuellen Index zu inkrementieren und zu prüfen, dass er nicht die Arraygrenzen überschreitet). Der Algorithmus könnte kaum einfacher sein. Das Verschieben der Elemente erfolgt außerhalb der inneren Schleife; jeder Tausch legt ein Element an seiner endgültigen Position ab, sodass die Anzahl der Tauschoperationen genau N ist. Das bedeutet, dass die Ausführungszeit von der Anzahl der Vergleiche bestimmt wird.
Satz A: Selectionsort benötigt ∼N 2/2 Vergleichs- und N Tauschoperationen, um ein Array der Länge N zu sortieren. Beweis: Sie können dies anhand des Ablaufprotokolls, einer N×N-Tabelle, beweisen, in dem die nicht schattierten Buchstaben für Vergleichsoperationen stehen. Ungefähr die Hälfte der Einträge in der Tabelle ist nicht schattiert – und zwar die Einträge auf und über der Diagonalen. Die Einträge auf der Diagonalen entsprechen jeweils einer Tauschoperation. Die Untersuchung des Codes enthüllt, dass es für jedes i aus 0 bis N − 1 genau einen Tausch und N − 1 − i Vergleiche gibt. Insgesamt sind das N Tauschoperationen und (N − 1) + (N − 2) + … + 2 + 1 + 0 = N(N − 1)/2 ∼N 2/2 Vergleichsoperationen.
Zusammengefasst lässt sich sagen, dass Selectionsort eine einfache Sortiermethode ist, die leicht zu verstehen und implementieren ist und durch die nachfolgend beschriebenen beiden Eigenschaften charakterisiert wird.
Die Ausführungszeit ist unabhängig von der Eingabe Die Technik, das kleinste Element durch einmaliges Durchlaufen des Arrays zu finden, verrät nicht viel darüber, wo das nächstkleinere Element beim nächsten Durchlauf zu finden ist. Diese Eigenschaft kann in einigen Situationen von Nachteil sein. Zum Beispiel könnte die Person, die den Sortier-Client verwendet, überrascht darüber sein, dass das Sortieren eines bereits sortierten Arrays genau so lange dauert wie eines Arrays mit lauter gleichen Schlüsseln oder eines Arrays mit zufällig angeordneten Schlüsseln! Wie wir bald sehen werden, sind andere Algorithmen besser geeignet, die Ausgangsordnung der Eingabe zu nutzen.
272
2.1 Elementare Sortierverfahren
Die Datenverschiebung ist minimal Jede der N Tauschoperationen ändert den Wert von zwei Arrayelementen. Das bedeutet, dass Selectionsort N Tauschoperationen benötigt und die Anzahl der Tauschoperationen eine lineare Funktion der Arraygröße ist. Keiner der anderen hier betrachteten Sortieralgorithmen weist diese Eigenschaft auf (die meisten haben ein leicht überlineares oder quadratisches Wachstum).
Listing 2.4: Selectionsort (Algorithmus 2.1) public class Selection { public static void sort(Comparable[] a) { // Sortiert a[] in aufsteigender Reihenfolge. int N = a.length; // Arraylänge for (int i = 0; i < N; i++) { // Vertauscht a[i] mit dem kleinsten Element in a[i+1...N). int min = i; // Index des kleinsten Elements. for (int j = i+1; j < N; j++) if (less(a[j], a[min])) min = j; exch(a, i, min); } } // Siehe Listing 2.1 für less(), exch(), isSorted() und main().
} Für jedes i legt diese Implementierung das it-kleinste Element in a[i] ab. Die Elemente links der Position i sind die i kleinsten Elemente im Array und werden nicht wieder untersucht. schwarze Einträge werden untersucht, um das Minimum zu finden rote Einträge sind a[min]
graue Einträge sind an ihrer endgültigen Position
Abbildung 2.1: Ablaufprotokoll von Selectionsort (Arrayinhalte direkt nach jedem Tausch)
273
2
Sortieren
2.1.3 Insertionsort Beim Kartenspielen sortieren die meisten Menschen ihre Handkarten nach folgendem Algorithmus: Sie betrachten die Spielkarten eine nach der anderen und verschieben jede an die Position, die sie in der Folge der bereits betrachteten Karten gemäß der Sortierung einzunehmen hat (sodass die bereits betrachteten Karten stets sortiert sind). In einer Computerimplementierung müssen wir Platz für das einzufügende Element schaffen, indem wir die größeren Elemente um eine Position nach rechts verschieben, bevor wir das aktuelle Element an der leer gewordenen Position einfügen. Algorithmus 2.2 ist eine Implementierung dieses Verfahrens, das auch Insertionsort (Sortieren durch Einfügen) genannt wird. Wie beim Selectionsort sind die Elemente links des aktuellen Index geordnet, aber sie befinden sich noch nicht an ihrer endgültigen Position, da sie eventuell noch verschoben werden müssen, um Platz für kleinere Elemente zu machen, die erst später betrachtet werden. Hat der Index das rechte Ende erreicht, ist das Array vollständig sortiert, Im Gegensatz zu Selectionsort hängt die Ausführungszeit von Insertionsort von der Ausgangsordnung der Elemente in der Eingabe ab. Ist das Array beispielsweise sehr groß mit Elementen, die bereits vollständig (oder größtenteils) sortiert sind, dann ist Insertionsort viel, viel schneller, als für Elemente, die zufällig oder in umgekehrter Reihenfolge angeordnet sind.
Satz B: Insertionsort benötigt im Schnitt ∼N 2/4 Vergleichs- und ∼N 2/4 Tauschoperationen, um ein zufällig geordnetes Array der Länge N mit verschiedenen Schlüsseln zu sortieren. Im schlimmsten Fall sind es ∼N 2/2 Vergleichs- und ∼N 2/2 Tauschoperationen und im besten Fall N − 1 Vergleichs- und 0 Tauschoperationen. Beweis: Wie in Satz A ist die Anzahl der Vergleichs- und Tauschoperationen leicht in einem N×NDiagramm grafisch darzustellen. Wir zählen die Einträge unter der Diagonalen – im schlimmsten Fall alle und im besten Fall keine. Bei zufällig geordneten Arrays gehen wir davon aus, dass jedes Element im Schnitt um die Hälfte der Strecke verschoben wird, sodass wir nur die Hälfte der Einträge unter der Diagonalen zählen. Die Anzahl der Vergleichsoperationen entspricht der Anzahl der Tauschoperationen plus einem zusätzlichen Term, der gleich N ist minus der Häufigkeit, mit der das eingefügte Element bisher das kleinste war. Im schlimmsten Fall (Array in umgekehrter Reihenfolge) ist dieser Term vernachlässigbar im Verhältnis zur Gesamtzahl; im besten Fall (Array geordnet) ist der Term gleich N − 1.
Insertionsort eignet sich besonders gut für bestimmte Arten von nicht zufällig angeordneten Arrays, wie sie häufig in der Praxis vorkommen, selbst wenn diese sehr groß sind. Überlegen Sie beispielsweise was passiert, wenn Sie wie oben erwähnt Insertionsort auf einem Array ausführen, das bereits sortiert vorliegt. Bei jedem Element wird sofort erkannt, dass es bereits an seiner korrekten Position im Array steht, was uns eine lineare Ausführungszeit beschert. (Die Ausführungszeit von Selectionsort für so ein Array wäre quadratisch.) Das Gleiche gilt für Arrays, deren Schlüssel alle gleich sind (deshalb auch die Bedingung in Satz B, dass alle Schlüssel verschieden sein müssen).
274
2.1 Elementare Sortierverfahren
Listing 2.5: Insertionsort (Algorithmus 2.2) public class Insertion { public static void sort(Comparable[] a) { // Sortiert a[] in aufsteigender Reihenfolge. int N = a.length; for (int i = 1; i < N; i++) { // Fügt a[i] zwischen a[i-1], a[i-2], a[i-3]... ein. for (int j = i; j > 0 && less(a[j], a[j-1]); j--) exch(a, j, j-1); } } // Siehe Listing 2.1 für less(), exch(), isSorted() und main(). }
Für jedes i von 0 bis N-1 vertauschen wir a[i] mit den Elementen im Bereich a[0] bis a[i-1], die kleiner sind. Während der Index i dabei von links nach rechts wandert, sind die Array-Elemente zur Linken immer sortiert, sodass das Array vollständig sortiert ist, wenn i das rechte Ende erreicht hat.
graue Elemente werden nicht verschoben
rotes Element ist a[j]
schwarze Elemente werden beim Einfügen eine Position nach rechts verschoben
Abbildung 2.2: Ablaufprotokoll von Insertionsort (Arrayinhalte direkt nach jeder Einfügung)
Lassen Sie uns in diesem Zusammenhang das Konzept eines teilweise sortierten Arrays beschreiben: Eine Inversion ist ein Paar von Elementen, die im Array nicht in der richtigen Reihenfolge stehen. E X A M P L E hat beispielsweise 11 Inversionen: E-A, X-A, X-M, X-P, X-L, X-E, M-L, M-E, P-L, P-E und L-E. Wenn die Anzahl der Inversionen in einem Array kleiner als ein konstantes Mehrfaches der Arraygröße ist, sprechen wir davon, dass das Array teilweise sortiert ist. Typische Beispiele für teilweise sortierte Arrays sind unter anderem
275
2
Sortieren
Arrays, bei denen jedes Element nicht weit von seiner endgültigen Position entfernt ist; Arrays, die durch Anhängen von einem kleinen Array an ein großes sortiertes Array entstanden sind;
Arrays, bei denen nur einige wenige Elemente nicht an ihrer korrekten Position sind. Für alle diese Arrays eignet sich Insertionsort wesentlich besser als Selectionsort. Vor allem, wenn die Anzahl der Inversionen gering ist, ist Insertionsort mit großer Wahrscheinlichkeit schneller als jede andere Sortiermethode, die wir in diesem Kapitel betrachten.
Satz C: Die Anzahl der Tauschoperationen von Insertionsort ist gleich der Anzahl der Inversionen in dem Array, und die Anzahl der Vergleichsoperationen ist mindestens gleich der Anzahl der Inversionen und höchstens gleich der Anzahl der Inversionen plus der Arraygröße minus 1. Beweis: Jede Tauschoperation betrifft zwei invertierte nebeneinanderliegende Elemente und reduziert so die Anzahl der Inversionen um eins. Das Array ist sortiert, wenn die Anzahl der Inversionen null erreicht. Jede Tauschoperation entspricht einer Vergleichsoperation plus einer eventuellen zusätzlichen Vergleichsoperation für jeden Wert von i von 1 bis N-1 (wenn a[j] nicht das linke Ende des Arrays erreicht).
Insertionsort lässt sich ganz einfach beschleunigen, indem man die innere Schleife darauf beschränkt, die größeren Elemente um eine Position nach rechts zu verschieben, anstatt eine vollständige Tauschoperation durchzuführen (wodurch die Anzahl der Arrayzugriffe halbiert wird). Diese Verbesserung überlassen wir Ihnen als Übung (siehe Übung 2.1.25). Zusammengefasst lässt sich sagen, dass sich Insertionsort hervorragend für teilweise sortierte und sehr gut für kleine Arrays eignet. Dieser Tatbestand ist nicht nur deshalb wichtig, weil diese Arrays häufig in der Praxis vorkommen, sondern auch weil beide Arten von Arrays ein Zwischenergebnis fortgeschrittener Sortieralgorithmen sein können. Aus diesem Grunde werden wir zu Insertionsort zurückkehren, wenn wir diese Algorithmen behandeln.
2.1.4 Sortieralgorithmen grafisch darstellen Um die Charakteristika von Sortieralgorithmen etwas anschaulicher zu machen, greifen wir in diesem Kapitel zu einer einfachen grafischen Darstellung. Anstatt den Fortschritt beim Sortieren mit Schlüsselwerten wie Buchstaben, Zahlen oder Wörtern zu demonstrieren, verwenden wir senkrechte Balken, die nach der Höhe sortiert werden. Der Vorteil dieser Darstellung ist, dass sie Ihnen Einblicke in das Verhalten einer Sortiermethode gewährt.
276
2.1 Elementare Sortierverfahren
In Abbildung 2.3 können Sie beispielsweise direkt erkennen, dass Insertionsort keine Elemente rechts vom Suchzeiger verarbeitet und Selectionsort keine Elemente links vom Suchzeiger. Außerdem zeigt das grafische Ablaufprotokoll deutlich, dass Insertionsort im Schnitt nur halb so viele Vergleiche benötigt wie Selectionsort, da keine Elemente angesprochen werden, die kleiner sind als das eingefügte Element.
graue Elemente bleiben unberührt
Mit unserer StdDraw-Bibliothek ist die Erstellung eines grafischen Ablaufprotokolls nicht viel schwieriger als der Aufbau eines Standard-Ablaufprotokolls. Wir sortieren Double-Werte, ergänzen den Algorithmus um den Aufruf von show() (wie bei dem Standard-Ablaufprotokoll) und entwickeln eine Version von show(), die schwarze Elemente sind mit StdDraw die Balken zeichnet, anstatt von den Vergleichen die Ergebnisse auszugeben. Am komplibetroffen ziertesten dabei ist, die Skala für die yAchse so zu setzen, dass die Striche des Ablaufprotokolls in der erwarteten Größenordnung angezeigt werden. Wir empfehlen Ihnen, Übung 2.1.18 durchzuInsertionsort Selectionsort arbeiten, um ein besseres Verständnis für Abbildung 2.3: Grafisches Ablaufprotokoll elementarer den Wert grafischer Ablaufprotokolle zu Sortieralgorithmen bekommen und selbst festzustellen, wie einfach sich diese erstellen lassen. Noch einfacher ist die Aufgabe, das Ablaufprotokoll zu animieren, sodass Sie direkt sehen können, wie das Array dynamisch sortiert wird. Die Entwicklung eines animierten Ablaufprotokolls umfasst im Wesentlichen die gleichen Schritte wie das oben skizzierte Verfahren, nur dass Sie sich nicht um die y-Achse kümmern müssen (Sie löschen einfach das Fenster und zeichnen die Balken jedes Mal neu). Auch wenn wir dies auf einer gedruckten Seite nicht demonstrieren können, sind solche animierten Darstellungen gut geeignet, um Ihnen Einblicke in die Funktionsweise eines Algorithmus zu gewähren. Überzeugen Sie sich durch Übung 2.1.17 selbst davon.
2.1.5 Zwei Sortieralgorithmen vergleichen Jetzt, da wir zwei Implementierungen kennengelernt haben, sind wir natürlich daran interessiert zu erfahren, welche von ihnen schneller ist: Selectionsort (Algorithmus 2.1) oder Insertionsort (Algorithmus 2.2). Fragen wie diese tauchen immer wieder beim Studium von Algorithmen auf und bilden einen der Schwerpunkte dieses Buches. Einige
277
2
Sortieren
grundlegende Ideen hierzu haben wir bereits in Kapitel 1 besprochen; wir wollen dieses erste Beispiel aber noch einmal zum Anlass nehmen, unseren Ansatz zur Beantwortung solcher Fragen eingehender vorzustellen. Im Allgemeinen vergleichen wir Algorithmen wie in unserem Ansatz in Abschnitt 1.4, indem wir
sie implementieren und debuggen, ihre grundlegenden Eigenschaften analysieren, eine Hypothese über die vergleichende Performance formulieren und Experimente durchführen, um die Hypothese zu validieren. Hinter diesen Schritten verbirgt sich nichts anderes als die klassische wissenschaftliche Methode, angewandt auf die Untersuchung der Algorithmen. Im gegenwärtigen Fall entsprechen Algorithmus 2.1 und Algorithmus 2.2 dem ersten Schritt, die Sätze A, B und C dem zweiten Schritt und die nachfolgende Behauptung D dem dritten Schritt; zusätzlich ermöglicht die Klasse SortCompare aus Listing 2.7 den vierten Schritt. Alle diese Aktivitäten stehen in Beziehung zueinander. Unsere kurzen Beschreibungen täuschen darüber hinweg, wie aufwendig es ist, Algorithmen ordentlich zu implementieren, zu analysieren und zu testen. Jeder Programmierer weiß, dass solcher Code das Ergebnis langwieriger Debugsitzungen und vieler Verfeinerungen ist, jeder Mathematiker weiß, dass eine ordentliche Analyse sehr schwierig sein kann und jeder Wissenschaftler weiß, dass das Formulieren von Hypothesen und das Entwerfen und Ausführen von Experimenten zur Validierung der Hypothesen größte Sorgfalt erfordert. Die vollständige Analyse und Auswertung sei daher Experten vorbehalten, die die wichtigsten Algorithmen untersuchen. Nichtsdestotrotz sollte sich jeder Programmierer, der einen Algorithmus nutzt, über den wissenschaftlichen Kontext im Klaren sein, der den Performance-Eigenschaften des eingesetzten Algorithmus zugrunde liegt. Nachdem wir die Implementierungen entwickelt haben, besteht unsere nächste Aufgabe darin, uns für ein geeignetes Modell für die Eingabe zu entscheiden. Ein naheliegendes Eingabemodell für das Sortieren, welches auch den Sätzen A, B und C zugrunde liegt, sieht so aus, dass die Arrays nach dem Zufallsprinzip sortiert sind und dass die Schlüsselwerte unterschiedlich sind. Für Anwendungen mit einer signifikanten Anzahl von gleichen Schlüsselwerten benötigen wir ein komplizierteres Modell. Wie formulieren wir eine Hypothese über die Laufzeiten von Insertionsort und Selectionsort für zufällig sortierte Arrays? Gehen wir von den Algorithmen 2.1 und 2.2 und den Sätzen A und B aus, sollte die Laufzeit von beiden Algorithmen quadratisch für zufällig geordnete Arrays sein. Das heißt, die Laufzeit von Insertionsort ist für solch eine Eingabe proportional einer kleinen Konstante mal N 2 und die Laufzeit von Selectionsort ist proportional einer anderen kleinen Konstante mal N 2. Die Werte der beiden Konstanten hängen von den Kosten der Vergleichs- und Tauschoperationen auf dem verwendeten Computer ab. Bei vielen Datentypen und typischen Computern können wir realistischerweise davon ausgehen, dass diese Kosten ähnlich sind (obwohl wir noch einige wichtige Ausnahmen kennenlernen werden). Daraus lässt sich direkt die folgende Hypothese ableiten.
278
2.1 Elementare Sortierverfahren
Behauptung D: Die Laufzeiten von Insertionsort und Selectionsort sind für Arrays mit zufällig angeordneten Elementen unterschiedlicher Werte quadratisch und unterscheiden sich nur in einem kleinen konstanten Faktor. Nachweis: Diese Behauptung wurde in den letzten 50 Jahren auf vielen verschiedenen Rechnern bestätigt. Als dieses Buch 1980 in der ersten Auflage erschien, war Insertionsort ungefähr doppelt so schnell wie Selectionsort und daran hat sich nichts geändert, auch wenn es damals mehrere Stunden dauerte, 100000 Elemente mit diesen Algorithmen zu sortieren, und heute nur ein paar Sekunden. Ist Insertionsort auch auf Ihrem Computer ein bisschen schneller als Selectionsort? Sie können dies leicht mit der Klasse SortCompare aus Listing 2.7 überprüfen, die die sort()Methoden der Klassen verwendet, die als Befehlszeilenargumente übergeben werden, um die angegebene Anzahl an Experimenten (Arrays der gegebenen Größe zu sortieren) durchzuführen, und das Verhältnis der beobachteten Laufzeiten der Algorithmen ausgibt.
public static double time(String alg, Comparable[] a) { Stopwatch timer = new Stopwatch(); if (alg.equals("Insertion")) Insertion.sort(a); if (alg.equals("Selection")) Selection.sort(a); if (alg.equals("Shell")) Shell.sort(a); if (alg.equals("Merge")) Merge.sort(a); if (alg.equals("Quick")) Quick.sort(a); if (alg.equals("Heap")) Heap.sort(a); return timer.elapsedTime(); } Listing 2.6: Zeitnahme für einen der in diesem Kapitel beschriebenen Sortieralgorithmen mit vorgegebener Eingabe
Zur Validierung dieser Hypothese führen wir die Experimente mithilfe von SortCompare (Listing 2.7) durch. Wie üblich benutzen wir zur Berechnung der Laufzeit Stopwatch. Die Implementierung von time() aus Listing 2.6 erledigt die Aufgabe für die grundlegenden Sortierverfahren aus diesem Kapitel. Das „zufällig angeordnete“ Eingabemodell ist in der Methode timeRandomInput() von SortCompare umgesetzt, die zufällige Double-Werte erzeugt, diese sortiert und die gemessene Gesamtzeit für das Sortieren der gewünschten Anzahl Arrays zurückliefert. Mit zufälligen Double-Werten zwischen 0.0 und 1.0 zu arbeiten, ist viel einfacher, als alternativ eine Bibliotheksfunktion wie StdRandom.shuffle() zu verwenden, und außerdem effektiv, da gleiche Schlüsselwerte sehr unwahrscheinlich sind (siehe Übung 2.5.31). Wie in Kapitel 1 beschrieben, wird die Anzahl der Versuche (hier die Sortierläufe) als Argument übernommen, um einerseits Nutzen aus dem Gesetz der großen Zahlen zu ziehen (je mehr Versuche, desto akkurater die Schätzung der tatsächlichen mittleren Laufzeit, die sich aus der Gesamtlaufzeit geteilt durch die Anzahl der Versuche errechnet) und andererseits Systemeffekte weitestgehend abzupuffern. Wir empfehlen Ihnen, ein wenig mit SortCompare auf Ihrem Computer herumzuspielen, um in Erfahrung zu bringen, wie zuverlässig deren Schlussfolgerung zu Insertionsort und Selectionsort ist.
279
2
Sortieren
Listing 2.7: Zwei Sortieralgorithmen vergleichen public class SortCompare { public static double time(String alg, Double[] a) { /* Siehe Text. */ } public static double timeRandomInput(String alg, int N, int T) { // Verwendet alg, um T zufällige Arrays der Länge N zu sortieren. double total = 0.0; Double[] a = new Double[N]; for (int t = 0; t < T; t++) { // Führt ein Experiment durch (erzeugt und sortiert ein Array). for (int i = 0; i < N; i++) a[i] = StdRandom.uniform(); total += time(alg, a); } return total; } public static void main(String[] args) { String alg1 = args[0]; String alg2 = args[1]; int N = Integer.parseInt(args[2]); int T = Integer.parseInt(args[3]); double t1 = timeRandomInput(alg1, N, T); // Gesamt für alg1 double t2 = timeRandomInput(alg2, N, T); // Gesamt für alg2 StdOut.printf("For %d random Doubles\n %s is", N, alg1); StdOut.printf(" %.1f times faster than %s\n", t2/t1, alg2); } }
Dieser Client führt die beiden Sortieroperationen, deren Namen als die beiden ersten Befehlszeilenargumente übergeben werden, auf Arrays von N (drittes Befehlszeilenargument) zufälligen Double-Werten zwischen 0.0 und 1.0 durch. Dabei wird das Experiment T-mal (viertes Befehlszeilenelement) wiederholt und dann das Verhältnis der Gesamtlaufzeiten ausgegeben. % java SortCompare Insertion Selection 1000 100 For 1000 random Doubles Insertion is 1.7 times faster than Selection
Behauptung D ist absichtlich etwas vage formuliert – weder wird der Wert des kleinen konstanten Faktors angegeben noch die Annahme geprüft, dass die Kosten der Vergleichs- und Tauschoperationen ähnlich sind – sodass sie sich auf viele verschiedene Situationen anwenden lässt. Wenn möglich, versuchen wir, die wesentlichen Aspekte der Performance eines jeden hier untersuchten Algorithmus in Aussagen wie diese
280
2.1 Elementare Sortierverfahren
zusammenzufassen. Wie in Kapitel 1 erwähnt, muss jede von uns betrachtete Behauptung in einer gegebenen Situation wissenschaftlich getestet werden, eventuell ergänzt um eine verbesserte Hypothese, die auf einem verwandten Satz (mathematische Wahrheit) basiert. Für praktische Anwendungen ist noch ein weiterer Schritt von großer Bedeutung: Experimente durchführen, um die Hypothese anhand der vorliegenden Daten zu validieren. Wir heben uns diesen Schritt für Abschnitt 2.5 und die Übungen auf. Beachten Sie aber, dass in Fällen, wo die Sortierschlüssel nicht verschieden sind und/oder nicht zufällig angeordnet sind, die Behauptung D möglicherweise nicht mehr gültig ist. Um die Elemente eines Arrays in eine zufällige Reihenfolge zu bringen, können Sie StdRandom.shuffle() verwenden, aber für Anwendungsfälle mit einer beträchtlichen Anzahl von gleichen Schlüsseln ist eine sorgfältigere Analyse erforderlich. Unsere Diskussionen der Algorithmenanalyse sind als Ausgangsbasis zu verstehen und nicht als Endlösung. Wenn irgendeine andere Frage bezüglich der Performance der Algorithmen auftaucht, können Sie sie mit einem Tool wie SortCompare beantworten. In den Übungen erhalten Sie dazu ausreichend Gelegenheit. Damit wollen wir den Leistungsvergleich von Insertionsort und Selectionsort beenden, denn von weitaus größerem Interesse sind die Algorithmen, die nicht nur hundert- oder tausendmal, sondern sogar millionenmal schneller sind. Aber vergessen Sie nicht: Gewisse Grundkenntnisse über diese elementaren Algorithmen sind aus mehreren Gründen wichtig:
Sie helfen uns, Grundregeln zu erarbeiten. Sie liefern uns Performance-Benchmarks. Sie sind in bestimmten Situationen oft die Methode der Wahl. Sie können als Basis zur Entwicklung besserer Algorithmen dienen. Aus oben genannten Gründen verwenden wir für jedes Problem, das wir in diesem Buch untersuchen (nicht nur beim Sortieren), den immer gleichen grundlegenden Ansatz und beziehen auch die elementaren Algorithmen in die Betrachtung mit ein. Bei unserer schrittweisen Algorithmusentwicklung spielen Programme wie SortCompare eine wichtige Rolle, da wir mit ihrer Hilfe prüfen können, ob ein neuer Algorithmus oder eine verbesserte Version eines bekannten Algorithmus die von uns erwarteten Leistungssteigerungen erzielt.
2.1.6 Shellsort Um zu demonstrieren, wie wertvoll Kenntnisse der Eigenschaften elementarer Sortierverfahren sind, betrachten wir als Nächstes einen schnellen Algorithmus, der auf Insertionsort basiert. Insertionsort ist bei großen ungeordneten Arrays äußerst ineffizient, da bei jedem Tausch nur nebeneinanderliegende Elemente vertauscht werden, sodass die Elemente bei jedem Durchgang nur um eine Position im Array verschoben werden. Wenn sich beispielsweise das Element mit dem kleinsten Schlüssel zufällig am Ende
281
2
Sortieren
des Arrays befindet, werden N−1 Tauschoperationen benötigt, bis das Element an seiner korrekten Position ist. Schneller geht es mit Shellsort, einer einfachen Erweiterung von Insertionsort, die sich dadurch auszeichnet, dass auch Arrayelemente vertauscht werden können, die weiter auseinanderliegen. Mit diesem Verfahren lassen sich Arrays so vorsortieren, dass sie am Ende durch Insertionsort effizient sortiert werden können. h=4
L L
E
E
A
E
M M
H
L
E
H E
S
O
L
S L
A
P P
S
X
R
S O
E
T T
X L
R
Abbildung 2.4: Eine h-sortierte Sequenz besteht aus h ineinander verschachtelten sortierten Teilsequenzen
Die Idee dahinter ist, dem Array durch Umsortieren die Eigenschaft zu verleihen, dass die Folge der jeweils h-ten Elemente (bei beliebigem Anfangselement) eine sortierte Teilsequenz ergibt. Ein solches Array wird als h-sortiert bezeichnet. Mit anderen Worten, ein h-sortiertes Array besteht aus h unabhängigen, sortierten Teilsequenzen, die ineinander verschachtelt sind. Durch h-Sortieren für große Werte von h können wir Elemente im Array über große Entfernungen verschieben und damit das h-Sortieren für kleinere Werte von h erleichtern. Die gleiche Technik, umgesetzt für eine beliebige Folge von Werten für h, die mit 1 endet, erzeugt ein sortiertes Array – das ist Shellsort. Die Implementierung in Algorithmus 2.3 ( Listing 2.8) verwendet die Folge absteigender Werte ½(3k−1), die ⎢ N / 3⎦ ⎥ beginnt und mit 1 endet. Wir mit dem kleinsten Inkrement größer oder gleich ⎣ bezeichnen eine solche Folge als Abstandsfolge bzw. Inkrementfolge oder Schrittweitenfolge (increment sequence). In Algorithmus 2.3 wird die Abstandsfolge berechnet; alternativ können Sie eine solche Folge aber auch in einem Array speichern. Eine Möglichkeit, Shellsort zu implementieren, besteht darin, für jedes h Insertionsort unabhängig auf jede der h Teilsequenzen anzuwenden. Da die Teilsequenzen unabhängig sind, können wir einen noch einfacheren Ansatz verfolgen: Beim h-Sortieren des Arrays fügen wir jedes Element unter die vorherigen Elemente in seiner h-Teilsequenz ein – einfach indem wir es mit den Elementen vertauschen, die größere Schlüssel haben (d.h., wir verschieben sie in ihrer Teilsequenz jeweils um eine Position nach rechts). Für diese Aufgabe verwenden wir den Code von Insertionsort, nur dass der Abstand bei jedem Durchlauf durch das Array um h und nicht um 1 dekrementiert wird. Dies reduziert die Shellsort-Implementierung auf einen Insertionsort-ähnlichen Durchlauf durch das Array für jeden Abstand. Shellsort verdankt seine Effizienz dem Kompromiss zwischen Größe und teilweiser Ordnung der Teilsequenzen. Am Anfang sind die Teilsequenzen kurz; später sind sie bereits teilweise sortiert. In beiden Fällen ist Insertionsort das Verfahren der Wahl. Inwieweit die Teilsequenzen vorsortiert sind, ist ein variabler Faktor, der stark von der Abstandsfolge abhängt. Die Performance von Shellsort zu verstehen, ist eine echte Herausforderung, vor allem da Algorithmus 2.3 das einzige von uns vorgestellte Sor-
282
2.1 Elementare Sortierverfahren
tierverfahren ist, dessen Performance bei zufällig geordneten Arrays bisher noch niemand genau beschrieben hat.
Listing 2.8: Shellsort (Algorithmus 2.3) public class Shell { public static void sort(Comparable[] a) { // Sortiert a[] in aufsteigender Reihenfolge. int N = a.length; int h = 1; while (h < N/3) h = 3*h + 1; // 1, 4, 13, 40, 121, 364, 1093, ... while (h >= 1) { // h-sortiert das Array. for (int i = h; i < N; i++) { // Fügt a[i] unter a[i-h], a[i-2*h], a[i-3*h]... ein. for (int j = i; j >= h && less(a[j], a[j-h]); j -= h) exch(a, j, j-h); } h = h/3; } } // Siehe Listing 2.1 für less(), exch(), isSorted() und main(). }
Wenn wir Insertionsort (Algorithmus 2.2) so überarbeiten, dass alle h-ten Elemente des Arrays sortiert werden, und eine äußere Schleife hinzufügen, in der wir h mittels einer Abstandsfolge sukzessive verringern (wobei wir mit einem Abstand beginnen, der ein fester Bruchteil der Arraylänge ist, und mit 1 enden), erhalten wir die hier wiedergegebene, kompakte Shellsort-Implementierung. % java SortCompare Shell Insertion 100000 100 For 100000 random Doubles Shell is 600 times faster than Insertion Eingabe
S
H
E
L
L
S
O
R
T
E
X
A
M
P
L
E
13-sortiert P
H
E
L
L
S
O
R
T
E
X
A
M
S
L
E
4-sortiert L
E
E
A
M
H
L
E
P
S
O
L
T
S
X
R
1-sortiert A
E
E
E
H
L
L
L
M
O
P
R
S
S
T
X
Abbildung 2.5: Ablaufprotokoll von Shellsort (Arrayinhalt nach jedem Durchlauf)
Nach welchen Kriterien entscheiden wir, welche Abstandsfolge die beste ist? Im Allgemeinen ist diese Frage schwer zu beantworten. Die Performance des Algorithmus hängt nicht nur von der Anzahl der Abstände, sondern auch von den arithmetischen Beziehungen zwischen den Abständen ab, wie der Größe ihrer gemeinsamen Teiler und ande-
283
2
Sortieren
ren Eigenschaften. In der Literatur wurden viele verschiedene Abstandsfolgen untersucht, aber bislang wurde noch keine nachweislich optimale Folge gefunden. Die Abstandsfolge in Algorithmus 2.3 ist leicht zu berechnen und zu verwenden und von der Leistung her fast so gut wie alle bisher entdeckten anspruchsvolleren Folgen mit einer nachweislich besseren Worst-Case-Performance. Dennoch gibt es sicherlich noch weitaus bessere Abstandsfolgen, die nur darauf warten, von Ihnen entdeckt zu werden. Shellsort eignet sich im Gegensatz zu Selectionsort und Insertionsort sogar für große Arrays. Außerdem ist die Leistung auch bei Arrays gut, die eine beliebige (nicht notwendigerweise zufällige) Ordnung aufweisen. Tatsächlich dürfte es Ihnen sogar ausgesprochen schwerfallen, für eine vorgegebene Abstandsfolge ein Array zu „konstruieren“, für das Shellsort nur eine mäßige Performance aufweist. Eingabe 13-sortiert
4-sortiert
1-sortiert
Ergebnis
Abbildung 2.6: Ausführliches Ablaufprotokoll von Shellsort (Einfügungen)
284
2.1 Elementare Sortierverfahren
Wie Sie anhand von SortCompare feststellen werden, ist Shellsort viel schneller als Insertionsort und Selectionsort, wobei sich der Geschwindigkeitsvorteil mit zunehmender Arraygröße immer stärker auswirkt. Bevor Sie weiterlesen, versuchen Sie ruhig einmal selbst, Shellsort mittels SortCompare auf Ihrem Computer mit Insertionsort und Selectionsort zu vergleichen, und zwar für Arraygrößen, die steigende Zweierpotenzen sind (siehe Übung 2.1.27). Sie werden sehen, dass Sie mit Shellsort Sortierprobleme in Angriff nehmen können, die sich mit den eher elementaren Algorithmen nicht lösen lassen. Mit diesem praxisbezogenen Beispiel veranschaulichen wir erstmals ein wichtiges Prinzip, das sich wie ein roter Faden durch dieses Buch zieht: Einer der Hauptgründe, sich mit der Performance und dem Design von Algorithmen zu beschäftigen, ist das Streben nach Geschwindigkeitssteigerungen, die es erlauben, Probleme in Angriff zu nehmen, die ansonsten nicht gelöst werden können. Eingabe
40-sortiert
13-sortiert
4-sortiert
Ergebnis
Abbildung 2.7: Grafisches Ablaufprotokoll von Shellsort
Die Analyse des Laufzeitverhaltens von Shellsort ist mit mathematischen Beweisführungen verbunden, die im Rahmen dieses Buches nicht behandelt werden können. Wer tiefer in die Materie einsteigen möchte, sollte einmal darüber nachdenken, wie er folgende Tatsache beweisen würde: Wenn ein h-sortiertes Array k-sortiert wird, bleibt es h-sortiert. Was die Laufzeit von Algorithmus 2.3 angeht, reicht es im Moment zu wissen, dass die Ausführungszeit von Shellsort nicht zwangsläufig quadratisch ist – zum Beispiel ist bekannt, dass die Anzahl der Vergleiche für Algorithmus 2.3 im schlimmsten Fall proportional N 3/2 ist. Dass solch eine einfache Änderung bereits dazu führt, dass die quadratische Laufzeit unterboten werden kann, ist ziemlich interessant, da dies eines der Hauptziele vieler Probleme im Algorithmendesign ist.
285
2
Sortieren
Noch gibt es keine mathematischen Angaben zu der durchschnittlichen Anzahl von Vergleichen, die Shellsort für zufällig geordnete Eingaben benötigt. Es wurden Abstandsfolgen entwickelt, die das asymptotische Wachstum der Worst-Case-Anzahl der Vergleiche auf N 4/3, N 5/4, N 6/5 ... drücken, aber viele dieser Ergebnisse sind primär von akademischem Interesse, da diese Funktionen für Werte von N, wie sie in der Praxis vorkommen, nur sehr schwer voneinander (und von einem konstanten Faktor von N) zu unterscheiden sind. Scheuen Sie sich nicht, in der Praxis von der obigen wissenschaftlichen Analyse von Shellsort Gebrauch zu machen und die Abstandsfolge aus Algorithmus 2.3 zu übernehmen (oder eine der Abstandsfolgen aus den Übungen am Ende dieses Abschnitts, mit denen sich die Performance um 20 bis 40 Prozent verbessern lässt). Außerdem können Sie leicht die folgende Hypothese validieren.
Behauptung E: Die Anzahl der Vergleiche von Shellsort mit den Abständen 1, 4, 13, 40, 121, 364 … wird begrenzt durch ein kleines Vielfaches von N mal der Anzahl der verwendeten Abstände. Nachweis: Die Überarbeitung von Algorithmus 2.3 mit dem Ziel, die Vergleiche zu zählen und durch die Anzahl der Abstände zu teilen, gehört zu den leichten Übungen (siehe Übung 2.1.12 ). Umfangreiche Tests legen nahe, dass die durchschnittliche Anzahl der Vergleiche pro Abstand N 1/5 sein könnte, aber ein Wachstum lässt sich in dieser Funktion nur schwer wahrnehmen, es sei denn N ist riesig. Diese Eigenschaft scheint ebenfalls relativ unabhängig vom Eingabemodell zu sein.
Erfahrene Programmierer wählen manchmal Shellsort, da es auch für mittelgroße Arrays akzeptable Laufzeiten aufweist, nur wenig Code erfordert und keinen zusätzlichen Speicherplatz belegt. In den nächsten Abschnitten werden wir effizientere Verfahren kennenlernen, die aber bestenfalls nur doppelt so schnell sind (außer für sehr große N), dafür aber wesentlich komplizierter. Wenn Sie eine Lösung für ein Sortierproblem benötigen und sich in einer Arbeitssituation befinden, in der kein Systemsortierverfahren verfügbar ist (zum Beispiel bei Code, der für Hardware oder eingebettete Systeme bestimmt ist), können Sie ruhig Shellsort verwenden und dann später entscheiden, ob es sich lohnt, dieses Verfahren durch ein anspruchsvolleres zu ersetzen.
286
Fragen und Antworten
2.1
Fragen und Antworten
Frage: Sortieren scheint mir ein ziemlich triviales Problem zu sein. Gibt es nicht etliche andere Themen rund um die Arbeit mit Computern, die interessanter wären? Antwort: Vielleicht, aber viele interessante Dinge sind durch schnelle Sortieralgorithmen erst möglich. In Abschnitt 2.5 sowie im Rest des Buches finden Sie zahlreiche Beispiele hierzu. Wir beschäftigen uns schon jetzt mit dem Sortieren, da das Problem leicht zu verstehen ist und Sie die Genialität der schnelleren Algorithmen besser würdigen können. Frage: Warum gibt es so viele Sortieralgorithmen? Antwort: Ein Grund ist, dass die Performance vieler Algorithmen von den Eingabewerten abhängt, d.h., verschiedene Anwendungen mit verschiedenen Arten von Eingaben verlangen eventuell nach verschiedenen Algorithmen. So ist beispielsweise Insertionsort besonders gut geeignet für teilweise sortierte oder kleine Arrays. Aber auch andere Beschränkungen wie Speicherplatz und die Behandlung gleicher Schlüssel spielen eine Rolle. Wir werden in Abschnitt 2.5 erneut auf diese Frage zu sprechen kommen. Frage: Warum machen wir uns die Mühe, die winzigen Hilfsmethoden less() und exch() zu verwenden? Antwort: Es sind grundlegende abstrakte Operationen, die von jedem Sortieralgorithmus benötigt werden, und der Code ist mithilfe dieser Abstraktionen einfacher zu verstehen. Außerdem machen sie den Code direkt auf andere Umgebungen portierbar. Zum Beispiel ist ein Großteil des Codes in den Algorithmen 2.1 und 2.2 auch in einigen anderen Programmiersprachen gültig. Sogar in Java können wir diesen Code als Grundlage zum Sortieren primitiver Typen (die nicht Comparable sind) verwenden: Implementieren Sie einfach less() durch den Code v < w. Frage: Wenn ich SortCompare ausführe, erhalte ich jedes Mal andere Werte (die sich außerdem von den Werten in diesem Buch unterscheiden). Warum? Antwort: Erst einmal arbeiten Sie auf einem anderen Rechner als wir, ganz abgesehen von einem anderen Betriebssystem, einer anderen Java-Laufzeitumgebung usw. Alle diese Unterschiede können zu kleinen Unterschieden im Maschinencode für die Algorithmen führen. Die unterschiedlichen Ausführungszeiten von SortCompare auf Ihrem Computer sind eventuell auf im Hintergrund laufende Anwendungen oder verschiedene andere Bedingungen zurückzuführen. Mit einer großen Anzahl von Versuchen sollte sich dieser Effekt reduzieren lassen. Die Lektion ist, dass kleine Unterschiede in der Algorithmuslaufzeit heutzutage schwer festzustellen sind. Das ist einer der Hauptgründe, warum wir uns auf große Unterschiede konzentrieren!
287
2
Sortieren
2.1
288
Allgemeine Übungen
1.
Zeigen Sie analog dem Ablaufprotokoll zu Algorithmus 2.1, wie Selectionsort das Array E A S Y Q U E S T I O N sortiert.
2.
Wie groß ist die maximale Anzahl von Tauschoperationen für ein bestimmtes Element bei Selectionsort? Wie groß ist die durchschnittliche Anzahl der Tauschoperationen für ein Element?
3.
Geben Sie ein Beispiel für ein Array von N Elementen, das beim Ausführen von Selectionsort (Algorithmus 2.1) die höchste Anzahl an erfolgreichen Tests a[j] < a[min] liefert (woraufhin min aktualisiert wird).
4.
Zeigen Sie analog dem Ablaufprotokoll zu Algorithmus 2.2, wie Insertionsort das Array E A S Y Q U E S T I O N sortiert.
5.
Beschreiben Sie für jede der beiden Bedingungen in der inneren for-Schleife von Insertionsort (Algorithmus 2.2) ein Array von N Elementen, bei dem diese Bedingung immer falsch ist, wenn die Schleife terminiert.
6.
Welches Verfahren ist bei einem Array mit komplett identischen Schlüsseln in der Ausführung schneller: Selectionsort oder Insertionsort?
7.
Welches Verfahren ist bei einem Array mit Schlüsseln in umgekehrter Reihenfolge in der Ausführung schneller: Selectionsort oder Insertionsort?
8.
Angenommen, wir wenden Insertionsort auf ein zufällig geordnetes Array an, in dem die Elemente nur einen von drei Werten haben. Ist die Ausführungszeit linear, quadratisch oder liegt sie irgendwo dazwischen?
9.
Zeigen Sie analog dem Ablaufprotokoll zu Algorithmus 2.3, wie Shellsort das Array E A S Y S H E L L S O R T Q U E S T I O N sortiert.
10.
Warum wird nicht Selectionsort für das h-Sortieren in Shellsort verwendet?
11.
Implementieren Sie eine Version von Shellsort, die die Abstandsfolge in einem Array speichert, anstatt sie zu berechnen.
12.
Überarbeiten Sie Shellsort, sodass für jeden Abstand die Anzahl der Vergleiche geteilt durch die Arraygröße ausgegeben wird. Schreiben Sie einen Testclient, der die Hypothese prüft, dass diese Anzahl eine kleine Konstante ist, indem Sie Arrays von zufälligen Double-Werten sortieren und Arraygrößen verwenden, die ausgehend von 100 zunehmende Zehnerpotenzen sind.
Knifflige Aufgaben
2.1
Knifflige Aufgaben
13.
Kartenstapel sortieren: Erläutern Sie, wie Sie einen Stapel Karten nach Farbe (in der Reihenfolge Pik, Herz, Kreuz, Karo) und innerhalb der Farbe nach Wert sortieren. Dabei gilt die Beschränkung, dass die Karten in einer Reihe mit dem Bild nach unten ausgelegt werden müssen und nur zwei Operationen erlaubt sind: die Werte von zwei Karten prüfen und zwei Karten vertauschen (weiterhin mit dem Bild nach unten).
14.
Dequeue-Sortieren: Erläutern Sie, wie Sie einen Stapel Karten sortieren, mit der Beschränkung, dass nur drei Operationen erlaubt sind: die Werte der obersten beiden Karten betrachten, die obersten Karten vertauschen und die oberste Karte nach unten im Stapel verschieben.
15.
Teurer Tausch: Ein Mitarbeiter eines Transportunternehmens hat die Aufgabe, eine große Anzahl von Kisten nach Auslieferungszeitpunkt neu zu ordnen. Dabei sind die Kosten der Vergleiche sehr niedrig (ein Blick auf die Etiketten reicht) im Vergleich zu den Kosten der Tauschoperationen (Verschieben der Kisten). Das Lager ist ziemlich voll – es gibt gerade noch Platz genug, um eine der Kisten zu versetzen, aber nicht zwei. Welche Sortiermethode sollte der Mitarbeiter anwenden?
16.
Verifizierung: Schreiben Sie eine Methode check(), die sort() für ein gegebenes Array aufruft und true zurückliefert, wenn sort() das Array sortiert und die gleiche Menge von Objekten im Array lässt, wie zuvor dort zu finden waren, beziehungsweise ansonsten false. Gehen Sie nicht davon aus, dass sort() darauf beschränkt ist, Daten nur mit exch() zu verschieben. Sie können bei der Verwendung von Arrays.sort() davon ausgehen, dass diese Methode korrekt arbeitet.
17.
Animation: Fügen Sie zu Insertion und Selection Code hinzu, um den Inhalt des Arrays analog den grafischen Ablaufprotokollen in diesem Abschnitt als vertikale Balken zu zeichnen. Achten Sie darauf, die Balken nach jedem Durchgang neu zu zeichnen, um einen animierten Effekt zu erzielen, und enden Sie mit einem „sortierten“ Bild, d.h., die Balken werden der Höhe nach sortiert angezeigt. Hinweis: Verwenden Sie einen Client, wie den im Text beschriebenen, der Zufallswerte vom Typ Double erzeugt, fügen Sie Aufrufe von show() an die entsprechenden Stellen im Sortiercode ein und implementieren Sie eine Methode show(), die die Zeichenfläche löscht und die Balken zeichnet.
18.
Grafisches Ablaufprotokoll: Überarbeiten Sie Ihre Lösung zu der vorherigen Übung, damit Insertion und Selection grafische Ablaufprotokolle erzeugen können, wie sie in diesem Abschnitt dargestellt wurden. Hinweis: Überlegter Einsatz von setYscale() macht die Aufgabe leichter. Zusatzpunkte: Fügen Sie Code hinzu, mit dem Sie rote und graue Farbakzente erzeugen.
19.
Schlimmster Fall für Shellsort: Konstruieren Sie ein Array von 100 Elementen mit den Zahlen 1 bis 100, für das Shellsort mit den Abständen 1 4 13 und 40 eine größtmögliche Anzahl von Vergleichen benötigt.
289
2
Sortieren
20.
Bester Fall für Shellsort: Was ist der beste Fall für Shellsort? Begründen Sie Ihre Antwort.
21.
Vergleichbare Transaktionen: Erweitern Sie Ihre Implementierung von Transaction (Übung 1.2.13) so, dass sie Comparable implementiert, mit dem Ziel, die Transaktionen nach Betrag zu ordnen. Orientieren Sie sich dabei an unserem Code für Date (Listing 2.3). Lösung: public class Transaction implements Comparable { ... private final double amount; ... public int compareTo(Transaction that) { if (this.amount > that.amount) return +1; if (this.amount < that.amount) return -1; return 0; } ... }
22.
Testclient zum Sortieren von Transaktionen: Schreiben Sie eine Klasse SortTransactions mit einer statischen Methode main(), die eine Folge von Transaktionen
von der Standardeingabe einliest, diese sortiert und das Ergebnis auf der Standardausgabe ausgibt (siehe Übung 1.3.17). Lösung: public class SortTransactions { public static Transaction[] readTransactions() { // Siehe Übung 1.3.17 } public static void main(String[] args) { Transaction[] transactions = readTransactions(); Shell.sort(transactions); for (Transaction t : transactions) StdOut.println(t); } }
290
Experimente
2.1
Experimente
23.
Kartenstapel sortieren: Bitten Sie ein paar Freunde, einen Stapel Karten zu sortieren (siehe Übung 2.1.13). Beobachten Sie sie genau und notieren Sie sich, welche Technik(en) sie verwenden.
24.
Insertionsort mit Wächter: Entwickeln Sie eine Implementierung von Insertionsort, die in der inneren Schleife den Test j>0 entfernt, indem zuerst das kleinste Element an seine Position gesetzt wird. Evaluieren Sie mit SortCompare die Effektivität dieser Maßnahme. Hinweis: Auf diese Weise lässt sich oft ein Test auf Überschreiten der Arraygrenzen vermeiden – dieses kleinste Element wird Wächter (sentinel) genannt.
25.
Insertionsort ohne Tauschoperationen: Entwickeln Sie eine Implementierung von Insertionsort, die mit einem Arrayzugriff pro Element größere Elemente um eine Position nach rechts verschiebt, anstatt exch() zu verwenden. Evaluieren Sie mit SortCompare die Effektivität dieser Maßnahme.
26.
Primitive Typen: Entwickeln Sie eine Version von Insertionsort, die Arrays von int-Werten sortiert, und vergleichen Sie ihre Performance mit der Implementierung im Text (die Integer-Werte sortiert und implizit Autoboxing und Autounboxing zur Typumwandlung verwendet).
27.
Shellsort ist subquadratisch: Vergleichen Sie Shellsort auf Ihrem Computer mit Insertionsort und Selectionsort. Verwenden Sie dazu SortCompare mit Arraygrößen, die zunehmende Zweierpotenzen sind, beginnend bei 128.
28.
Gleiche Schlüssel: Formulieren und validieren Sie Hypothesen zur Laufzeit von Insertionsort und Selectionsort für Arrays, die nur zwei Schlüsselwerte enthalten. Gehen Sie dabei davon aus, dass die Werte die gleiche Auftretenswahrscheinlichkeit haben.
29.
Shellsort-Abstände: Führen Sie Tests durch, um die Abstandsfolge aus Algorithmus 2.3 mit der Folge 1, 5, 19, 41, 109, 209, 505, 929, 2161, 3905, 8929, 16001, 36289, 146305, 260609 zu vergleichen (die sich ergibt, wenn man die Folgen 9⋅4k−9⋅2k+1 und 4k−3⋅2k+1 zusammenmischt). Siehe Übung 2.1.11.
30.
Geometrische Abstände: Führen Sie Tests durch, um einen Wert von t zu bestimmen, der zu der niedrigsten Laufzeit von Shellsort für Zufallsarrays führt, und ⎢t ⎦ ⎥, ⎣ ⎢t 3⎦ ⎥, ⎣ ⎢t 2⎦ ⎥, ⎣ ⎢t 4⎦ ⎥, … für N = 106. Nennen Sie die zwar für die Abstandsfolge 1, ⎣ Werte von t und die Abstandsfolgen für die besten drei Werte, die Sie finden.
291
2
Sortieren
Die folgenden Übungen beschreiben verschiedene Clients, die Ihnen beim Evaluieren von Sortierverfahren von Nutzen sein können. Sie sind als Ausgangsbasis gedacht und sollen Ihnen helfen, das Laufzeitverhalten mithilfe von Zufallsdaten besser zu verstehen. Verwenden Sie in allen Clients (wie in SortCompare) die Methode time(), damit Sie genauere Ergebnisse erzielen können, wenn Sie als zweites Befehlszeilenargument einen höheren Wert für die Anzahl der Versuche angeben. Wir kommen in den folgenden Abschnitten, wenn wir anspruchsvollere Verfahren evaluieren, noch einmal auf diese Übungen zurück. 31.
Verdopplungstest: Schreiben Sie einen Client, der einen Verdopplungstest für Sortieralgorithmen ausführt. Beginnen Sie mit N gleich 1000 und geben Sie N, die vorausgesagte Anzahl von Sekunden, die tatsächliche Anzahl von Sekunden und das Verhältnis beim Verdoppeln von N aus. Validieren Sie mit Ihrem Programm, dass Insertionsort und Selectionsort für zufällige Eingaben quadratisch sind, und formulieren und testen Sie eine Hypothese für Shellsort.
32.
Ausführungszeiten zeichnen: Schreiben Sie einen Client, der mit StdDraw die durchschnittlichen Ausführungszeiten des Algorithmus für Zufallseingaben und verschiedene Arraygrößen zeichnet. Sie können ein oder zwei Befehlszeilenargumente ergänzen. Versuchen Sie, ein möglichst nützliches Tool zu entwickeln.
33.
Verteilung: Schreiben Sie einen Client, der in eine Endlosschleife eintritt und dort sort() auf Arrays ausführt, deren Größe als drittes Befehlszeilenargument übergeben wird. Außerdem soll der Client die Zeit für jeden Durchlauf messen und die durchschnittlichen Laufzeiten mit StdDraw zeichnen. Das Ergebnis sollte ein Bild der Verteilung der Laufzeiten sein.
34.
Ausnahmefälle: Schreiben Sie einen Client, der sort() auf schwierige oder pathologische Fälle ausführt, die in praktischen Anwendungen auftauchen können. Beispiele wären Arrays, die bereits geordnet sind, Arrays, die umgekehrt geordnet sind, Arrays mit lauter gleichen Schlüsseln, Arrays, die nur aus zwei verschiedenen Werten bestehen, und Arrays der Größe 0 oder 1.
35.
Keine Gleichverteilung: Schreiben Sie einen Client, der Testdaten erzeugt, indem er Objekte nach dem Zufallsprinzip ordnet, und zwar unter Verwendung von anderen Verteilungen als die Gleichverteilung, einschließlich der folgenden: – Gauß – Poisson – Geometrische – Diskrete (siehe Sonderfall in Übung 2.1.28) Entwickeln und testen Sie Hypothesen zu den Auswirkungen solcher Eingaben auf die Performance der Algorithmen in diesem Abschnitt.
292
Experimente
36.
Nicht einheitliche Daten: Schreiben Sie einen Client, der Testdaten erzeugt, die nicht einheitlich sind, einschließlich: – Die Hälfte der Daten sind Nullen, die andere Hälfte sind Einsen. – Die Hälfte der Daten sind Nullen, die Hälfte des Rests sind Einsen, die Hälfte des Rests sind Zweien usw. – Die Hälfte der Daten sind Nullen, die andere Hälfte sind zufällige int-Werte. Entwickeln und testen Sie Hypothesen zu den Auswirkungen solcher Eingaben auf die Performance der Algorithmen in diesem Abschnitt.
37.
Teilweise sortiert: Schreiben Sie einen Client, der teilweise sortierte Arrays erzeugt, einschließlich: – 95 Prozent sortiert, das letzte Prozent Zufallswerte – Alle Elemente nicht weiter als 10 Positionen von ihrer endgültigen Position im Array entfernt – Sortiert, bis auf 5 Prozent der Elemente, die zufällig über das Array verteilt sind. Entwickeln und testen Sie Hypothesen zu den Auswirkungen solcher Eingaben auf die Performance der Algorithmen in diesem Abschnitt.
38.
Elemente verschiedener Datentypen: Schreiben Sie einen Client, der Arrays von Elementen verschiedener Datentypen mit zufälligen Schlüsselwerten erzeugt, einschließlich: – String-Schlüssel (mindestens zehn Zeichen), ein double-Wert – double-Schlüssel, zehn String-Werte (alle mindestens zehn Zeichen) – int-Schlüssel, ein int[20]-Wert Entwickeln und testen Sie Hypothesen zu den Auswirkungen solcher Eingaben auf die Performance der Algorithmen in diesem Abschnitt.
293
2
Sortieren
2.2
Mergesort
Die Algorithmen, die wir in diesem Abschnitt näher betrachten wollen, basieren auf einer einfachen Operation, dem Mergen (Vereinigen durch geordnetes Mischen), d.h., wir fassen zwei geordnete Arrays zu einem größeren, ebenfalls geordneten Array zusammen – eine Technik, die uns direkt zu einem einfachen, rekursiven Sortierverfahren führt, dem sogenannten Mergesort (Mischsortieren): Um ein Array nach dieser Strategie zu sortieren, teilen wir es in zwei Hälften, sortieren (rekursiv) diese beiden Hälften und führen dann die Ergebnisse im Reißverschlussverfahren zusammen. Wie Sie sehen werden, zeichnet sich Mergesort vor allem durch die Eigenschaft aus, dass jedes Array von N Elementen garantiert in einer Zeit proportional N log N sortiert werden kann. Der größte Nachteil von Mergesort ist, dass zusätzlicher Speicher benötigt wird, der proportional N ist. Eingabe
M
E
R
G
E
S
O
R
T
E
X
A
M
P
L
E
sortierte linke Hälfte
E
E
G
M
O
R
R
S
T
E
X
A
M
P
L
E
sortierte rechte Hälfte
E
E
G
M
O
R
R
S
A
E
E
L
M
P
T
X
Merge-Ergebnis
A
E
E
E
E
G
L
M
M
O
P
R
R
S
T
X
Abbildung 2.8: Mergesort im Überblick
2.2.1 Abstraktes In-Place-Mergen Der naheliegendste Weg zur Implementierung des Merge besteht darin, ein Verfahren zu entwickeln, das zwei disjunkte geordnete Arrays von Comparable-Objekten zu einem dritten Array verschmilzt. Diese Strategie ist einfach zu implementieren: Erzeugen Sie ein Ausgabearray der benötigten Größe und wählen Sie dann sukzessive das jeweils kleinste Element der beiden Eingabearrays und fügen Sie es als nächstes Element in das Ausgabearray ein. Wenn wir auf diese Weise mittels Rekursion ein großes Array sortieren, führen wir eine riesige Anzahl von Merge-Operationen durch, sodass die Kosten für die Erzeugung eines neuen Ausgabearrays für jede Merge-Operation kritisch werden können. Wesentlich günstiger wäre es, ein In-Place-Verfahren zur Hand zu haben, das es uns erlaubt, zuerst die erste Hälfte des Arrays an Ort und Stelle (in-place) zu sortieren, dann die zweite und anschließend beim Zusammenführen die beiden Hälften durch Verschieben der Elemente innerhalb des Arrays zu sortieren, sodass wir mit nur wenig zusätzlichem Speicherplatz auskommen. Es lohnt sich, ein wenig über mögliche Lösungswege nachzudenken. Auf den ersten Blick scheint dieses Problem recht einfach zu sein, aber die allgemein bekannten Lösungen sind ziemlich kompliziert, vor allem im Vergleich zu Alternativen, die zusätzlichen Speicher nutzen können. Dennoch ist die Abstraktion von In-Place-Mergen durchaus nützlich. Folglich spezifizieren wir mit der Signatur merge(a, lo, mid, hi) eine Methode, die das Ergebnis des Merge von Teilarray a[lo..mid] mit Teilarray a[mid+1..hi] in einem einzigen Array sortiert ablegt und das Ergebnis in a[lo..hi] zurückliefert. Der Code in Listing 2.9 imple-
294
2.2 Mergesort
mentiert diese Methode mit nur wenigen Zeilen: Zuerst wird alles in ein Hilfsarray kopiert und dann im Original-Array zusammengeführt. Einen anderen Ansatz beschreibt Übung 2.2.10.
Listing 2.9: Abstraktes In-Place-Mergen public static void merge(Comparable[] a, int lo, int mid, int hi) { // Vereinigt a[lo..mid] mit a[mid+1..hi]. int i = lo, j = mid+1; for (int k = lo; k hi ) a[k] = aux[i++]; else if (less(aux[j], aux[i])) a[k] = aux[j++]; else a[k] = aux[i++];
}
Diese Methode kopiert zuerst alles in ein Hilfsarray aux[] und führt dann die Elemente sortiert wieder in a[] zusammen. Beim Mergen (die zweite for-Schleife) sind vier Bedingungen zu beachten: linke Hälfte erschöpft (von rechts nehmen), rechte Hälfte erschöpft (von links nehmen), aktueller Schlüssel rechts kleiner als aktueller Schlüssel links (von rechts nehmen) und aktueller Schlüssel rechts größer gleich aktueller Schlüssel links (von links nehmen).
Eingabe Kopie
MergeErgebnis
Abbildung 2.9: Ablaufprotokoll des abstrakten In-Place-Mergens
295
2
Sortieren
2.2.2 Top-Down-Mergesort Algorithmus 2.4 ( Listing 2.10) ist eine rekursive Mergesort-Implementierung, die auf dem abstrakten In-Place-Mergen basiert – und überdies einer der bekanntesten Belege, wie nützlich das Teile-und-Herrsche-Paradigma (divide-and-conquer) für effizientes Algorithmendesign ist. Angelehnt an unseren rekursiven Code lässt sich ein induktiver Beweis führen, dass der Algorithmus das Array sortiert: Wenn er die beiden Teilarrays sortiert, sortiert er auch das ganze Array durch das geordnete Mischen der beiden Teilarrays. Um Mergesort zu verstehen, sollten Sie die Dynamik der Methodenaufrufe im Ablaufprotokoll von Abbildung 2.10 sorgfältig studieren. Zum Sortieren des Arrays a[0..15] ruft sort() sich erst selbst auf, um a[0..7] zu sortieren, dann um a[0..3] und a[0..1] zu sortieren, bevor schließlich die erste Vereinigung von a[0] mit a[1] vorgenommen wird – wobei allerdings sort() sich vorher noch aufruft, um a[0] und dann a[1] zu sortieren (der Kürze halber verzichten wir im Ablaufprotokoll auf die Aufrufe zum Sortieren von Teilarrays mit nur einem Element – der Basisfall). Die nächste Vereinigung erfolgt für a[2] mit a[3] und dann für a[0..1] mit a[2..3] und so weiter. Dieses Aufrufprotokoll zeigt, dass der Sortiercode vor allem dazu dient, die Aufrufe von merge() zu organisieren. Dieses Wissen wird uns später in diesem Abschnitt noch von Nutzen sein. Der rekursive Code bietet uns außerdem die Möglichkeit, die Laufzeit von Mergesort zu analysieren. Da Mergesort ein Musterbeispiel des Teile-und-Herrsche-Paradigmas für Algorithmendesign ist, werden wir uns mit seiner Analyse etwas ausführlicher beschäftigen.
sort(a, 0, 15) sort(a, 0, 7) sort(a, 0, 3) sort(a, 0, 1) merge(a, 0, 0, 1) sort(a, 2, 3) merge(a, 2, 2, 3) merge(a, 0, 1, 3) sort(a, 4, 7) sort(a, 4, 5) merge(a, 4, 4, 5) sort(a, 6, 7) merge(a, 6, 6, 7) merge(a, 4, 5, 7) merge(a, 0, 3, 7) sortiert sort(a, 8, 15) rechte Hälfte sort(a, 8, 11) sort(a, 8, 9) merge(a, 8, 8, 9) sort(a, 10, 11) merge(a, 10, 10, 11) merge(a, 8, 9, 11) sort(a, 12, 15) sort(a, 12, 13) merge(a, 12, 12, 13) sort(a, 14, 15) merge(a, 14, 14,15) merge(a, 12, 13, 15) merge(a, 8, 11, 15) MergeErgebnisse merge(a, 0, 7, 15) sortiert linke Hälfte
Abbildung 2.10: Ablaufprotokoll der Aufrufe von Top-Down-Mergesort
296
2.2 Mergesort
Satz F: Top-Down-Mergesort benötigt zwischen ½ N lg N und N lg N Vergleiche, um ein beliebiges Array der Länge N zu sortieren. Beweis: C(N) sei die Anzahl der Vergleiche, die benötigt werden, um ein Array der Länge N zu sortieren. Dann ist C(0)=C(1)=0 und wir können für N>0 eine Rekursionsgleichung aufstellen, die die rekursive Methode sort() direkt widerspiegelt, um eine obere Schranke festzulegen: ⎢ N / 2⎦ ⎥)+ N ⎡ N / 2⎥ ⎤) +C (⎣ C (N ) ≤ C (⎢
Der erste Term auf der rechten Seite gibt die Anzahl der Vergleiche zum Sortieren der linken Hälfte des Arrays an, der zweite Term die Anzahl der Vergleiche zum Sortieren der rechten Hälfte und der dritte Term die Anzahl der Vergleiche für die Vereinigung. Die untere Schranke ⎢ N / 2⎦ ⎥)+⎣ ⎢ N / 2⎦ ⎥ C (N ) ≥ C (⎡ ⎢ N / 2⎤ ⎥) +C (⎣ ⎢ N / 2⎦ ⎥ist. ergibt sich daraus, dass die Anzahl der Vergleiche für die Vereinigung mindestens⎣
Wir erhalten eine exakte Lösung für die Rekursion, wenn die Gleichheit gewahrt bleibt und N ⎢ N / 2⎦= ⎥ ⎢ ⎡ N / 2⎥ ⎤= 2n−1 gilt anfangs: einen Zweierpotenz ist (sagen wir N=2n). Da⎣ C (2n ) = 2C (2n−1 ) + 2n
Indem wir beide Seiten durch 2n teilen, erhalten wir C (2n ) / 2n = C (2n−1 ) / 2n−1 +1
Wenn wir diese Gleichung auf sich selbst, d.h. auf den ersten Term zur Rechten anwenden, ergibt sich C (2n ) / 2n = C (2n−2 ) / 2n−2 +1+1
Durch n−1-maliges Wiederholen dieses Schritts erhalten wir C (2n ) / 2n = C (20 ) / 20 + n
was uns nach der Multiplikation beider Seiten mit 2n zu folgender Lösung führt: C (N ) = C (2n ) = n2n = N lg N
Genaue Lösungen für allgemeine N sind aufwendiger; aber es ist nicht schwer, die gleiche Beweisführung auf die Ungleichheiten anzuwenden, die die Schranken für die Anzahl der Vergleiche beschreiben, um die Aussage für alle Werte von N zu beweisen. Dieser Beweis ist gültig, unabhängig von den Eingabewerten und der Reihenfolge, in der sie erscheinen.
297
2
Sortieren
Listing 2.10: Top-Down-Mergesort (Algorithmus 2.4) public class Merge { private static Comparable[] aux;
// Hilfsarray für Vereinigungen.
public static void sort(Comparable[] a) { aux = new Comparable[a.length]; // Weist nur einmal Speicher zu. sort(a, 0, a.length - 1); } private static void sort(Comparable[] a, int lo, int hi) { // Sortiert a[lo..hi]. if (hi C(N) für alle N > 0).
8.
Angenommen, Algorithmus 2.4 wird so überarbeitet, dass der Aufruf von merge() übersprungen wird, wenn a[mid] 0. for (int i = 1; i < a.length; i++) if (a[i].compareTo(a[i-1]) != 0) count++; Listing 2.25: Die verschiedenen Schlüssel in a[] zählen
372
2.5 Anwendungen
Rangfolgen Eine Permutation oder Rangfolge (ranking) ist ein Array von N Integern, in dem jeder Integer zwischen 0 und N−1 genau einmal vorkommt. Die Kendall-Tau-Distanz zwischen zwei Rangfolgen gibt die Anzahl der Paare an, deren relative Positionen sich in den Rangfolgen unterscheiden. So ist zum Beispiel die Kendall-Tau-Distanz für 0 3 1 6 2 5 4 und 1 0 3 6 4 2 5 vier, da die Paare 0-1, 3-1, 2-4, 5-4 in den beiden Rangfolgen vertauscht sind, während alle anderen Paare die gleiche relative Ordnung aufweisen. Die Kendall-Tau-Distanz hat ein breites Anwendungsspektrum, so unter anderem in der Soziologie, um die Sozialwahltheorie zu analysieren, in der Molekularbiologie, um Gene anhand von Expressionsprofilen zu vergleichen, und im Web, um den Page Rank der Suchmaschinenergebnisse festzulegen, um nur einige zu nennen. Die Kendall-TauDistanz zwischen einer Permutation und der Identitätspermutation (bei der jedes Element gleich seinem Index ist) gibt die Anzahl der Inversionen in der Permutation an; es ist nicht besonders schwer, dafür einen quadratischen Algorithmus zu entwickeln, der auf Insertionsort basiert (als Hilfestellung lesen Sie noch einmal Satz C in Abschnitt 2.1). Die effiziente Berechnung der Kendall-Tau-Distanz ist eine interessante Übung für einen Programmierer (oder einen Studenten!), der sich mit den hier untersuchten klassischen Sortieralgorithmen auskennt (siehe Übung 2.5.19).
Reduktion auf Operationen mit Vorrangwarteschlangen In Abschnitt 2.4 haben wir zwei Beispiele von Problemen betrachtet, die sich auf eine Folge von Operationen auf Vorrangwarteschlangen reduzieren lassen. TopM (Listing 2.15) findet in einem Eingabestrom die M Elemente mit den größten Schlüsseln und Multiway (Listing 2.20) führt M sortierte Eingabeströme zu einem sortierten Ausgabestrom zusammen. Beide Probleme lassen sich leicht mit einer Vorrangwarteschlange der Größe M lösen.
Median- und Ordnungsstatistik Ein wichtiges Anwendungsfeld für Sortierverfahren, bei dem eine vollständige Sortierung nicht erforderlich ist, ist z.B. die Ermittlung des Medians einer Menge von Schlüsseln (also eines Wertes, für den gilt, dass die eine Hälfte der Schlüssel nicht größer und die andere Hälfte nicht kleiner ist). Die Bestimmung des Medians kommt in der Statistik und etlichen anderen Anwendungen zur Datenverarbeitung häufig vor und ist eine spezielle Form der Auswahl (selection): Bestimmen Sie die kt-kleinste Zahl in einer Menge von Zahlen. Diese Operation findet vielseitig Anwendung in der Verarbeitung von experimentellen und anderen Daten. Dabei spielen vor allem der Median und andere Ordnungsstatistiken eine Rolle, die ein Array in kleinere Einheiten zerlegen. Oft muss nur ein kleiner Teil eines großen Arrays zur Weiterverarbeitung gespeichert werden. In solchen Fällen könnte ein Programm, das beispielsweise die obersten zehn Prozent der Elemente im Array auswählt, sinnvoller sein als ein Programm, das das Array vollständig sortiert. Unsere Anwendung TopM aus Abschnitt 2.4 löst dieses Problem für einen unbegrenzten Eingabestrom mittels einer Vorrangwarteschlange. Eine effektive Alternative zu TopM wäre, sofern die Elemente in einem Array vorliegen, dieses
373
2
Sortieren
einfach zu sortieren: Nach dem Aufruf von Quick.sort(a) befinden sich die k kleinsten Werte im Array an den ersten k Arraypositionen für alle k kleiner als die Arraylänge. Da bei diesem Ansatz eine Sortierung anfällt, ist die Laufzeit leicht überlinear. Müssen wir uns damit zufrieden geben? Die k kleinsten Werte in einem Array zu ermitteln ist einfach, wenn k sehr klein oder sehr groß ist. Komplizierter wird es, wenn k ein konstanter Bruchteil der Arraygröße ist, wie beim Berechnen des Medians (k=N/2). Es dürfte Sie vielleicht überraschen zu erfahren, dass es möglich ist, dieses Problem in linearer Zeit zu lösen, wie die Methode select() aus Listing 2.26 zeigt (die im Buch abgedruckte Implementierung zwingt den Client zu einer Typumwandlung; wie man diese vermeidet, demonstriert der Code auf der Website zum Buch). Für diese Aufgabe definiert select() die Variablen lo und hi, um das Teilarray zu begrenzen, das den Index k des auszuwählenden Elements enthält, und reduziert die Größe des Teilarrays mittels Quicksort-Partitionierung. Zur Erinnerung: partition() sortiert ein Array a[lo] bis a[hi] und liefert einen Integer j zurück, sodass a[lo] bis a[j-1] kleiner gleich a[j] und a[j+1] bis a[hi] größer gleich a[j] sind. Wenn k gleich j ist, dann sind wir fertig. Andernfalls, wenn k < j, dann müssen wir mit dem linken Teilarray weiterarbeiten (indem wir den Wert von hi auf j-1 ändern), und wenn k > j, dann müssen wir mit dem rechten Teilarray weiterarbeiten (indem wir den Wert von lo auf j+1 ändern). Die Schleife erhält die Invariante aufrecht, dass kein Element links von lo größer ist und kein Element rechts von hi kleiner ist als irgendein Element in a[lo..hi]. Nach der Partitionierung kümmern wir uns um die Invariante und verkleinern das Intervall, bis es nur noch aus k besteht. Zum Schluss enthält a[k] das (k+1)tkleinste Element, a[0] bis a[k-1] sind alle kleiner (oder gleich) a[k] und a[k+1] bis zum Ende des Arrays sind alle größer (oder gleich) a[k]. Um zu verstehen, warum dies ein linearer Algorithmus ist, nehmen wir einfach an, dass die Partitionierung das Array jedes Mal genau in der Mitte teilt. Dann ist die Anzahl der Vergleiche N + N/2 + N/4 + N/8 + ..., die enden, wenn das kt-kleinste Element gefunden wurde. Diese Summe ist kleiner als 2N. Wie bei Quicksort erfordert es ein wenig Mathematik, um die tatsächliche Schranke zu finden, die etwas höher liegt. Und ebenso wie bei Quicksort hängt die Analyse davon ab, dass die Partitionierung für ein zufälliges Element ausgeführt wird, sodass die Garantie probabilistisch ist. public static Comparable select(Comparable[] a, int k) { StdRandom.shuffle(a); int lo = 0, hi = a.length - 1; while (hi > lo) { int j = partition(a, lo, hi); if (j == k) return a[k]; else if (j > k) hi = j - 1; else if (j < k) lo = j + 1; } return a[k]; } Listing 2.26: Die k kleinsten Elemente aus a[] auswählen
374
2.5 Anwendungen
lo
i
hi
Median
Abbildung 2.35: Partitionierung zur Berechnung des Medians
Satz U: Die partitionierungsbasierte Auswahl ist im Durchschnitt ein linearer Algorithmus. Nachweis: Eine Analyse, die ähnlich ist wie, aber wesentlich komplexer als der Beweis zu Satz K für Quicksort, führt zu dem Ergebnis, dass die durchschnittliche Anzahl der Vergleiche ∼ 2N + 2k ln(N/k) + 2(N−k) ln(N/(N−k)) ist, was für jeden zulässigen Wert von k linear ist. Diese Formel besagt zum Beispiel, dass die Berechnung des Medians (k=N/2) im Durchschnitt ∼ (2 + 2ln 2)N Vergleiche erfordert. Beachten Sie, dass der schlimmste Fall quadratisch ist, wovor die Randomisierung wie bei Quicksort allerdings schützt.
Die Entwicklung eines Auswahl-Algorithmus, der selbst für den schlimmsten Fall eine lineare Anzahl von Vergleichen garantiert, ist ein klassisches Ergebnis der Komplexitätstheorie, das aber noch nicht in einen nützlichen Algorithmus für die Praxis umgesetzt werden konnte.
2.5.4 Sortieranwendungen im kurzen Überblick Sortieralgorithmen sind allgegenwärtig und haben so viele direkte Anwendungen, dass wir sie hier nicht alle aufführen können. So werden beispielsweise Songs nach Titel oder Interpret, E-Mails oder Telefonanrufe nach Zeit oder Absender und Fotos nach
375
2
Sortieren
Datum sortiert. Universitäten sortieren die Studentendaten nach Namen oder Immatrikulationsnummer, Kreditkartenunternehmen verwalten Millionen oder gar Milliarden von Transaktionen sortiert nach Datum oder Betrag und Wissenschaftler ordnen ihre experimentellen Daten nach Zeit oder anderen Kriterien, unter anderem mit dem Ziel einer möglichst genauen Simulation der natürlichen Welt, von der Bewegung von Teilchen und Himmelskörpern über die Struktur von Werkstoffen bis zu sozialen Wechselwirkungen und Beziehungen. Eigentlich ist es viel schwieriger, eine Rechenanwendung zu finden, in der nicht sortiert wird! Zur Veranschaulichung führen wir in diesem Abschnitt einige Anwendungen als Beispiele an, die komplizierter sind als die gerade betrachteten Reduktionen (auf einige dieser Anwendungen werden wir später noch ausführlich eingehen).
Kommerzielle Datenverarbeitung Unsere Welt ertrinkt in einer Flut von Daten. Regierungsorganisationen, Finanzinstitute und Wirtschaftsunternehmen versuchen, dieser Daten zum Teil durch Sortieren Herr zu werden. Egal, ob es sich dabei um Konten handelt, die nach Name oder Nummer, Postsendungen, die nach Postleitzahl oder Adresse, Transaktionen, die nach Datum oder Betrag, oder Dateien, die nach Name oder Datum sortiert werden – hinter der Verarbeitung solcher Daten verbirgt sich mit Sicherheit irgendwo ein Sortieralgorithmus. Normalerweise sind solche Informationen in riesigen Datenbanken gespeichert, wo sie nach mehreren Schlüsseln sortiert werden, um eine effiziente Suche zu ermöglichen. Eine effektive und weit verbreitete Strategie ist es, neue Daten zu sammeln, sie der Datenbank hinzuzufügen, nach den gewünschten Schlüsseln zu sortieren und das sortierte Ergebnis schlüsselweise mit den anderen Daten in der bestehenden Datenbank zusammenzuführen. Die Verfahren, die wir hier besprochen haben, werden seit den Anfängen der elektronischen Datenverarbeitung mit Erfolg eingesetzt, um eine riesige Infrastruktur aufzubauen, die aus sortierten Daten und Methoden zu ihrer Verarbeitung besteht und als Basis für alle hier genannten kommerziellen Aktivitäten dient. Heutzutage bestehen die Arrays, die verarbeitet werden, aus Millionen oder gar Milliarden von Elementen – ohne leicht überlineare Sortieralgorithmen könnten wir solche Arrays nicht sortieren, was die Verarbeitung extrem schwierig oder unmöglich machen würde.
Suche nach Informationen Daten, die sortiert sind, können mit dem klassischen Binärsuche-Algorithmus effizient durchsucht werden (siehe Kapitel 1). Außerdem lassen sich viele andere Arten von Abfragen formulieren und schnell darauf ausführen: Wie viele Elemente sind kleiner als ein gegebenes Element? Welche Elemente fallen in einen gegebenen Bereich? In Kapitel 3 werden wir Antworten auf diese Fragen geben. Auch werden wir ausführlich auf verschiedene Erweiterungen eingehen, die das Sortieren und die Binärsuche optimieren und uns erlauben, Abfragen mit Operationen zum Einfügen und Entfernen von Objekten aus einer Menge zu kombinieren, wobei für alle Operationen weiterhin eine logarithmische Performance garantiert wird.
376
2.5 Anwendungen
Operations Research Operations Research (OR) ist eine Disziplin, die mathematische Modelle zur Problemlösung und Entscheidungsfindung entwickelt und einsetzt. Wir werden später mehrere Beispiele betrachten, die sich mit den Beziehungen zwischen OR und Algorithmenanalyse befassen. Doch zuerst zeigen wir, wie nützlich Sortieren in einem klassischen OR-Problem, der Zeitplanung (Scheduling), ist. Angenommen, wir haben N Jobs zu erledigen und benötigen für jeden Job j eine Verarbeitungszeit von tj Sekunden. Einerseits müssen wir alle Jobs beenden, wollen aber andererseits die Kundenzufriedenheit maximieren, indem wir die durchschnittliche Verarbeitungszeit der Jobs minimieren. Bekanntermaßen erreichen wir dieses Ziel mit der Regel Kürzeste Verarbeitungszeit zuerst, bei der wir die Jobs in aufsteigender Reihenfolge ihrer Verarbeitungszeit ausführen. Wir können also die Jobs nach Verarbeitungszeit sortieren oder sie in einer minimumorientierten Vorrangwarteschlange ablegen. Verschiedene andere Beschränkungen und Restriktionen führen zu verschiedenen anderen Scheduling-Problemen, die häufig in industriellen Anwendungen auftreten und gut untersucht sind. Ein weiteres Beispiel ist das Problem der Lastverteilung, bei dem wir es mit M identischen Prozessoren und N zu erledigenden Jobs zu tun haben. Unser Ziel ist es, alle Jobs so auf die Prozessoren zu verteilen, dass sie in kürzestmöglicher Zeit abgearbeitet werden. Dieses spezielle Problem ist NP-schwer (siehe Kapitel 6), sodass wir nicht erwarten, einen praktischen Weg zur Berechnung einer optimalen Lastverteilung zu finden. Ein Verfahren, von dem wir wissen, dass es ein gutes Ergebnis liefert, folgt der Regel Längste Verarbeitungszeit zuerst. Dabei führen wir die Jobs in absteigender Reihenfolge ihrer Verarbeitungszeit aus, wobei wir jeden Job dem Prozessor zuweisen, der als Erstes verfügbar ist. Um diesen Algorithmus zu implementieren, sortieren wir die Jobs zuerst in umgekehrter Reihenfolge. Dann richten wir eine Vorrangwarteschlange von M Prozessoren ein, bei der die Priorität die Summe der Verarbeitungszeiten ihrer Jobs ist. Bei jedem Schritt löschen wir den Prozessor mit der kleinsten Priorität, weisen dem Prozessor den nächsten Job zu und fügen diesen Prozessor wieder in die Vorrangwarteschlange ein.
Ereignisgesteuerte Simulation Teil vieler wissenschaftlicher Anwendungen ist die Simulation, die anhand von Berechnungen einen Aspekt der realen Welt modelliert, um ihn so verständlicher zu machen. Vor dem Aufkommen der Computer hatten Wissenschaftler keine andere Wahl, als hierfür mathematische Modelle heranzuziehen, die heutzutage hervorragend durch Rechenmodelle ergänzt werden. Solche Simulationen effizient durchzuführen, kann eine große Herausforderung sein und der richtige Algorithmus kann darüber entscheiden, ob die Simulation in einem vernünftigen Zeitrahmen beendet werden kann oder ob man vor der Wahl steht, sich entweder mit ungenauen Ergebnissen zufriedenzugeben oder ewig zu warten, bis die Simulation die Berechnung beendet hat, die für genaue Ergebnisse erforderlich ist. In Kapitel 6 werden wir ein ausführliches Beispiel hierzu vorstellen.
377
2
Sortieren
Numerische Berechnungen Einer der wichtigsten Aspekte des wissenschaftlichen Rechnens ist die Genauigkeit (wie nahe sind wir an der tatsächlichen Antwort?). Genauigkeit ist extrem wichtig, wenn wir Millionen von Berechnungen mit Schätzwerten ausführen – zu denen z.B. die Gleitkommarepräsentationen der reellen Zahlen gehören, mit denen unsere Rechner arbeiten. Einige numerische Algorithmen verwenden Vorrangwarteschlangen und Sortierverfahren, um die Genauigkeit der Berechnungen zu verbessern. Nehmen wir zum Beispiel die numerische Integration (Quadratur), deren Ziel es ist, die Fläche unter einer Kurve näherungsweise zu berechnen. Eine Möglichkeit der Berechnung besteht darin, eine Vorrangwarteschlange mit Genauigkeitswerten für die Teilintervalle des ganzen Intervalls zu verwalten. Die Vorgehensweise sieht so aus, dass wir das am wenigsten genaue Teilintervall entfernen, dann halbieren (was für die beiden Hälften eine höhere Genauigkeit bedeutet) und anschließend die beiden Hälften zurück in die Vorrangwarteschlange stellen. Damit fahren wir fort, bis die gewünschte Genauigkeit erreicht ist.
Kombinatorische Suche Ein klassisches Paradigma der künstlichen Intelligenz und der Bewältigung scheinbar unlösbarer Probleme ist die Definition einer Menge von Konfigurationen (configurations) mit wohldefinierten Schritten (moves) von einer Konfiguration zu nächsten, wobei jeder Schritt mit einer Priorität assoziiert ist. Ebenfalls definiert werden eine Startkonfiguration und eine Endkonfiguration (die der Lösung des Problems entspricht). Der bekannte A*-Algorithmus ist ein Problemlösungsprozess, bei dem wir die Startkonfiguration in die Vorrangwarteschlange stellen und dann folgende Schritte ausführen, bis wir das Ziel erreichen: Wir entfernen die Konfiguration mit der höchsten Priorität und fügen zu der Warteschlange alle Konfigurationen hinzu, die von dort aus mit einem Schritt erreicht werden können (ausgenommen die gerade entfernte). Wie die ereignisgesteuerte Simulation ist dieser Prozess förmlich auf Vorrangwarteschlangen zugeschnitten. Er reduziert das Lösen des Problems auf die Definition einer effektiven Prioritätsfunktion. In Übung 2.5.32 können Sie dies an einem Beispiel nachvollziehen. Neben diesen direkten Anwendungen (von denen wir hier nur einen Bruchteil erwähnen) sind Sortiermechanismen und Vorrangwarteschlangen eine sehr wichtige Abstraktion in Algorithmendesign, sodass sie uns im Buch immer wieder begegnen werden. Nachfolgend führen wir einige Beispiele auf, die wir später noch ausführlich besprechen. Alle diese Anwendungen basieren auf den effizienten Implementierungen der Sortieralgorithmen und dem Datentyp für die Vorrangwarteschlange, den wir in diesem Kapitel betrachtet haben.
Prim-Algorithmus und Dijkstra-Algorithmus Diese beiden klassischen Algorithmen behandeln wir in Kapitel 4 in Zusammenhang mit der Verarbeitung von Graphen (einem grundlegenden Modell für Elemente und Kanten, die Paare von Elementen verbinden). Basis für diese und einige andere Algorithmen ist die Graphensuche, bei der wir entlang der Kanten von einem Element (auch Knoten genannt) zum anderen wandern. Vorrangwarteschlangen spielen eine wichtige Rolle bei der Graphensuche und erlauben es, effiziente Algorithmen zu entwickeln.
378
2.5 Anwendungen
Kruskal-Algorithmus Von Kruskal stammt ein weiterer klassischer Algorithmus für Graphen mit gewichteten Kanten, der die Kanten in der Reihenfolge ihrer Gewichte verarbeitet. Seine Laufzeit wird von den Kosten des Sortierens bestimmt.
Huffman-Kompression Huffman-Kompression ist ein klassischer Algorithmus zur Datenkomprimierung, der darauf basiert, eine Menge von Integer-gewichteten Elementen zu verarbeiten, indem die beiden kleinsten zu einem neuen Element kombiniert werden, dessen Gewicht die Summe seiner beiden Bestandteile ist. Die Implementierung dieser Operation ergibt sich direkt bei Verwendung von Vorrangwarteschlangen. Es basieren noch einige andere Mechanismen zur Datenkomprimierung auf dem Sortieren.
Stringverarbeitung Auch Algorithmen zur Stringverarbeitung, die von großer Bedeutung für moderne Anwendungen der Kryptologie und Genomik sind, basieren häufig auf dem Sortieren der Daten (im Allgemeinen unter Verwendung eines der speziellen Stringsortierverfahren, die in Kapitel 5 besprochen werden). Wir werden zum Beispiel in Kapitel 6 Algorithmen zur Suche des längsten sich wiederholenden Teilstrings in einem gegebenen String besprechen, die darauf basieren, dass die Suffixe der Strings zuerst sortiert werden.
379
2
Sortieren
2.5
Fragen und Antworten
Frage: Gibt es in der Java-Bibliothek einen Datentyp für die Vorrangwarteschlange? Antwort: Ja, sehen Sie unter java.util.PriorityQueue nach.
2.5 1.
Allgemeine Übungen Betrachten Sie die folgende Implementierung der Methode compareTo() für String. Inwiefern wirkt sich die dritte Zeile auf die Effizienz aus? public int compareTo(String that) { if (this == that) return 0; // um diese Zeile geht es int n = Math.min(this.length(), that.length()); for (int i = 0; i < n; i++) { if (this.charAt(i) < that.charAt(i)) return -1; else if (this.charAt(i) > that.charAt(i)) return +1; } return this.length() - that.length(); }
2.
Schreiben Sie ein Programm, das eine Liste von Wörtern von der Standardeingabe einliest und alle enthaltenen Komposita, die sich aus zwei Wörtern zusammensetzen, ausgibt. Wenn zum Beispiel after, thought und afterthought in der Liste stehen, dann ist afterthought ein Kompositum.
3.
Betrachten Sie kritisch die folgende Implementierung einer Klasse, die Kontostände repräsentieren soll. Warum ist compareTo() eine fehlerhafte Implementierung der Schnittstelle Comparable? public class Balance implements Comparable { ... private double amount; public int compareTo(Balance that) { if (this.amount < that.amount - 0.005) return -1; if (this.amount > that.amount + 0.005) return +1; return 0; } ... }
Beschreiben Sie einen Weg, dieses Problem zu beheben.
380
4.
Implementieren Sie eine Methode String[] dedup(String[] a), die die Objekte sortiert und ohne Duplikate in a[] zurückliefert.
5.
Erläutern Sie, warum Selectionsort nicht stabil ist.
Allgemeine Übungen
6.
Implementieren Sie eine rekursive Version von select().
7.
Wie viele Vergleiche werden im Schnitt ungefähr benötigt, um mit select() das kleinste von N Elementen zu finden?
8.
Schreiben Sie ein Programm Frequency, das Strings von der Standardeingabe einliest und in absteigender Reihenfolge ausgibt, wie oft jeder String vorkommt.
9.
Entwickeln Sie einen Datentyp, der es Ihnen erlaubt, einen Client zu schreiben, der eine Datei wie die nachfolgende sortiert. Eingabe (DJI-Volumina für jeden Tag) 1-Oct-28 2-Oct-28 3-Oct-28 4-Oct-28 5-Oct-28 ... 30-Dec-99 31-Dec-99 3-Jan-00 4-Jan-00 5-Jan-00
3500000 3850000 4060000 4330000 4360000 554680000 374049984 931800000 1009000000 1085500032
Ausgabe 19-Aug-40 26-Aug-40 24-Jul-40 10-Aug-42 23-Jun-42 ... 23-Jul-02 17-Jul-02 15-Jul-02 19-Jul-02 24-Jul-02
130000 160000 200000 210000 210000 2441019904 2566500096 2574799872 2654099968 2775559936
10.
Erzeugen Sie einen Datentyp Version, der eine Software-Versionsnummer wie 115.1.1, 115.10.1, 115.10.2 repräsentiert. Implementieren Sie die Schnittstelle Comparable, sodass 115.1.1 kleiner ist als 115.10.1 usw.
11.
Eine Möglichkeit, das Ergebnis eines Sortieralgorithmus zu beschreiben, ist die Spezifikation einer Permutation p[] der Zahlen 0 bis a.length-1, sodass p[i] angibt, wo der Schlüssel, der ursprünglich in a[i] stand, landet. Nennen Sie die Permutationen, die die Ergebnisse von Insertionsort, Selectionsort, Shellsort, Mergesort, Quicksort und Heapsort für ein Array von sieben gleichen Schlüsseln beschreiben.
381
2
Sortieren
2.5
Knifflige Aufgaben
12.
Scheduling: Schreiben Sie ein Programm SPT.java, das Jobbezeichnungen und Verarbeitungszeiten von der Standardeingabe einliest und einen Zeitplan ausgibt, der unter Verwendung der Regel Kürzeste Verarbeitungszeit zuerst (Abschnitt Operations Research) die durchschnittliche Bearbeitungszeit minimiert.
13.
Lastverteilung: Schreiben Sie ein Programm LPT.java, das einen Integer M als Befehlszeilenargument übernimmt, die Jobbezeichnungen und Verarbeitungszeiten von der Standardeingabe einliest und einen Zeitplan ausgibt, der die Jobs den M Prozessoren unter Verwendung der Regel Längste Verarbeitungszeit zuerst (Abschnitt Operations Research) zuweist und damit die Zeit, bis der letzte Job beendet ist, weitgehend minimiert.
14.
Nach umgekehrter Domäne sortieren: Schreiben Sie einen Datentyp Domain, der Domänennamen repräsentiert, einschließlich einer passenden Methode compareTo(), in der die natürliche Ordnung der umgekehrten Reihenfolge der Domänennamen entspricht. Die umgekehrte Domäne von cs.princeton.edu lautet beispielsweise edu.princeton.cs. Dies ist vor allem für die Analyse von Webprotokollen nützlich. Hinweis: Verwenden Sie s.split("\\."), um den String s in Tokens zu zerlegen, die durch Punkte getrennt sind. Schreiben Sie einen Client, der Domänennamen von der Standardeingabe einliest und die umgekehrten Domänen sortiert ausgibt.
15.
Spam-Kampagne: Um eine illegale Spam-Kampagne zu starten, benötigen Sie eine Liste von E-Mail-Adressen von verschiedenen Domänen (den Teil der E-MailAdresse, der auf das @-Symbol folgt). Um die Absenderadressen besser zu fälschen, wollen Sie die E-Mail von einem anderen Benutzer der gleichen Domäne senden. Zum Beispiel wollen Sie vielleicht eine E-Mail von [email protected] in [email protected] fälschen. Wie würden Sie die E-Mail-Liste verarbeiten, um diese Aufgabe effizient zu erledigen?
16.
Unverzerrte Wahl: Um Verzerrungen für Kandidaten zu vermeiden, deren Namen am Ende des Alphabets stehen, hat Kalifornien die Kandidaten auf dem Wahlzettel zur Gouverneurswahl 2003 nach folgender Zeichenfolge sortiert aufgeführt: R W Q O J M V A H B S G Z X N T C I E K U P D Y F L
Erzeugen Sie einen Datentyp, für den dies die natürliche Ordnung ist, und schreiben Sie einen Client California mit einer einzelnen statischen Methode main(), die Strings gemäß dieser Zeichenfolge sortiert. Gehen Sie davon aus, dass jeder String ausschließlich aus Großbuchstaben besteht.
382
17.
Auf Stabilität prüfen: Erweitern Sie Ihre Methode check() aus Übung 2.1.16 um einen Aufruf von sort() für ein gegebenes Array und liefern Sie true zurück, wenn sort() das Array stabil sortiert, bzw. im anderen Fall false. Gehen Sie nicht davon aus, dass sort() darauf beschränkt ist, die Daten nur mit exch() zu verschieben.
18.
Stabilität erzwingen: Schreiben Sie eine Wrappermethode, die jedes Sortierverfahren stabil macht, indem sie einen neuen Schlüsseltyp erzeugt, der es Ihnen erlaubt,
Knifflige Aufgaben
an jeden Schlüssel den dazugehörigen Index anzuhängen, sort() aufzurufen und dann nach dem Sortieren den ursprünglichen Schlüssel wiederherzustellen. 19.
Kendall-Tau-Distanz: Schreiben Sie ein Programm KendallTau.java, das die Kendall-Tau-Distanz zwischen zwei Permutationen in leicht überlinearer Zeit berechnet.
20.
Leerlaufzeiten: Angenommen, ein Parallelrechner verarbeitet N Jobs. Schreiben Sie ein Programm, das nach Übergabe der Jobanfangs- und -endzeiten das größte Intervall findet, an dem der Rechner im Leerlauf ist, und das größte Intervall, an dem der Rechner nicht im Leerlauf ist.
21.
Mehrdimensionales Sortieren: Schreiben Sie einen Datentyp Vector, mit dem Sortierverfahren mehrdimensionale Vektoren bestehend aus d ganzzahligen Werten sortieren können. Dabei werden die Vektoren zuerst nach der ersten Komponente sortiert; Vektoren, deren erste Komponente gleich ist, werden nach der zweiten Komponente sortiert; Vektoren, deren erste und zweite Komponenten gleich sind, werden nach der dritten Komponente sortiert und so weiter.
22.
Börsenhandel: Investoren platzieren Kauf- und Verkaufsaufträge für eine bestimmte Aktie an einer Computerbörse und geben einen maximalen Kaufpreis oder einem Mindestverkaufspreis vor sowie die Anzahl der Aktien, die sie zu diesem Preis handeln wollen. Entwickeln Sie ein Programm, das Vorrangwarteschlangen einsetzt, um Käufer und Verkäufer zusammenzubringen, und testen Sie es mittels Simulation. Verwalten Sie zwei Vorrangwarteschlangen, eine für Käufer und eine für Verkäufer, und führen Sie den Auftrag aus, sobald eine Kauforder mit einem oder mehreren bestehenden Verkaufsordern übereinstimmt.
23.
Stichprobennahme für Auswahl: Untersuchen Sie die Idee, mittels Stichprobennahme die Auswahl zu verbessern. Hinweis: Der Median ist nicht immer eine Hilfe.
24.
Stabile Vorrangwarteschlangen: Entwickeln Sie eine stabile Vorrangwarteschlangen-Implementierung (die doppelte Schlüssel in der gleichen Reihenfolge zurückliefert, in der sie eingefügt wurden).
25.
Punkte in der Ebene: Schreiben Sie drei static Comparator-Klassen für den Datentyp Point2D aus Kapitel 1: eine, die Punkte nach ihrer x-Koordinate vergleicht, eine, die Punkte nach ihrer y-Koordinate vergleicht, und eine, die sie nach ihrem Abstand vom Ursprung vergleicht. Schreiben Sie zwei Nicht-static Comparator-Klassen für den Datentyp Point2D: eine, die Punkte nach ihrem Abstand zu einem bestimmten Punkt vergleicht, und eine, die sie nach ihrem Polarwinkel hinsichtlich eines spezifizierten Punkts vergleicht.
26.
Einfaches Polygon: Gegeben seien N Punkte in der Ebene; zeichnen Sie ein einfaches Polygon mit N Eckpunkten. Hinweis: Finden Sie den Punkt p mit der kleinsten x-Koordinate; falls es mehrere gibt, dann wählen sie aus diesen den Punkt mit der kleinsten y-Koordinate. Verbinden Sie die Punkte in aufsteigender Reihenfolge des Polarwinkels, den sie mit p bilden.
383
2
Sortieren
27.
Parallele Arrays sortieren: Beim Sortieren paralleler Arrays ist es nützlich, eine Version einer Sortierroutine zu haben, die eine Permutation, sagen wir index[], der Indizes in sortierter Reihenfolge zurückliefert. Ergänzen Sie Insertion um eine Methode indirectSort(), die ein Array a[] von Comparable-Objekten als Argument übernimmt und, anstatt die Elemente in a[] neu anzuordnen, ein IntegerArray index[] zurückliefert, sodass a[index[0]] bis a[index[N-1]] die Elemente in aufsteigender Reihenfolge sind.
28.
Dateien nach Name sortieren: Schreiben Sie ein Programm FileSorter, das den Namen eines Verzeichnisses als Befehlszeilenargument übernimmt und alle Dateien in dem aktuellen Verzeichnis sortiert nach Dateinamen ausgibt. Hinweis: Verwenden Sie den Datentyp File.
29.
Dateien nach Größe und Datum der letzten Änderung sortieren: Definieren Sie Comparator-Klassen für den Typ File, um auf-/absteigend nach Dateigröße, Dateinamen und letztem Änderungsdatum sortieren zu können. Verwenden Sie diese in einem Programm LS, das ein Befehlszeilenargument übernimmt und die Dateien in dem aktuellen Verzeichnis nach einer spezifizierten Ordnung auflistet, zum Beispiel "-t", um nach Zeitstempel zu sortieren. Unterstützen Sie mehrere Flags. Achten Sie darauf, ein stabiles Sortierverfahren zu verwenden.
30.
Satz von Börner: Wahr oder falsch: Wenn Sie jede Spalte einer Matrix sortieren und dann jede Zeile, bleiben die Spalten sortiert. Begründen Sie Ihre Antwort.
2.5
384
Experimente
31.
Duplikate: Schreiben Sie einen Client, der die Integer M, N und T als Befehlszeilenargumente übernimmt und dann den Code aus dem Text verwendet, um T Versuche des folgenden Experiments durchzuführen: Erzeugen Sie N zufällige int-Werte zwischen 0 und M − 1 und zählen Sie die Anzahl der Duplikate. Führen Sie Ihr Programm für T = 10 und N = 103, 104, 105 und 106, mit M = N/2, N und 2N, aus. Der Wahrscheinlichkeitstheorie zufolge sollte die Anzahl der Duplikate ungefähr (1 − e−α) betragen, wobei α = N/M. Geben Sie eine Tabelle aus, die Ihnen hilft zu bestätigen, dass Ihre Experimente die Formel validieren.
32.
8-Puzzle: Das 8-Puzzle ist ein Spiel aus den 1870ern, das seine Popularität S. Loyd verdankt. Es besteht aus 8 quadratischen Zahlenplättchen mit den Nummern 1 bis 8 sowie einer quadratischen Leerstelle, angeordnet in einem 3-mal-3Quadrat. Ihre Aufgabe ist es, die einzelnen Plättchen in die korrekte Reihenfolge zu bringen. Dazu können Sie die Plättchen horizontal oder vertikal (aber nicht diagonal) in die Leerstelle schieben. Schreiben Sie ein Programm, das das Puzzle mithilfe des A*-Algorithmus löst. Verwenden Sie zu Beginn als Priorität die Summe aus den Verschiebungen, die Sie gemacht haben, um zu dieser Brettposition zu gelangen, und der Anzahl der Plättchen an der falschen Position. (Beachten Sie, dass die Anzahl der Verschiebungen, die Sie von einer gegebenen Brett-
Experimente
position aus machen müssen, mindestens so groß ist wie die Anzahl der Plättchen an der falschen Stelle.) Untersuchen Sie, welche Auswirkung es hat, die Anzahl der Plättchen in der falschen Position durch andere Funktionen zu ersetzen, wie die Summe der Manhattan-Distanz von jedem Plättchen zu seiner korrekten Position oder die Summen der Quadrate dieser Distanzen. 33.
Zufällige Transaktionen: Entwickeln Sie einen Generator, der ein Argument N übernimmt und N zufällige Transaction-Objekte erzeugt (siehe Übung 2.1.21 und 2.1.22); Ihre Annahmen über die Transaktionen sollten Sie verteidigen können. Vergleichen Sie anschließend die Performance von Shellsort, Mergesort, Quicksort und Heapsort beim Sortieren von N Transaktionen für N = 103, 104, 105 und 106.
385
Suchen
3
3.2 Binäre Suchbäume. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 424 3.3 Balancierte Suchbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . 453 3.4 Hashtabellen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 489 3.5 Anwendungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 519
ÜBERBLICK
3.1 Symboltabellen. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 389
3
Suchen
»
EINLEITUNG
Dank moderner Computertechnik und Internet können wir heute auf Unmengen von Daten und Informationen zugreifen. Doch um diese Daten verarbeiten und nutzen zu können, sind effiziente Suchtechniken essenziell. Das vorliegende Kapitel beschreibt klassische Suchalgorithmen, die sich seit Jahrzehnten in der Praxis bewährt haben. Ohne Algorithmen wie diese hätte die Computerinfrastruktur, die wir so schätzen, niemals entstehen können.
Mit dem Begriff Symboltabelle beschreiben wir einen abstrakten Mechanismus, bei dem wir Daten (Werte) so speichern, dass wir später danach suchen und einzelne Werte durch Angabe von Schlüsseln abfragen können. Wie die Schlüssel und Werte dabei konkret beschaffen sind, hängt von der jeweiligen Anwendung ab. Zu einer besonderen Herausforderung wird die Implementierung einer effizienten Symboltabelle dadurch, dass man es nicht selten mit einer großen Anzahl an Schlüsseln und einer riesige Menge an Daten zu tun hat. Statt von Symboltabellen spricht man manchmal auch von Wörterbüchern – in Anspielung auf das altehrwürdige System, alphabetisch sortierte Wörter in einem Nachschlagewerk mit Definitionen zu verbinden. In einem deutschsprachigen Wörterbuch beispielsweise ist der Schlüssel ein Wort und seine Werte sind der dazugehörige Eintrag, der Definition, Aussprache und etymologische Angaben umfasst. Daneben werden Symboltabellen hin und wieder auch als Index bezeichnet, in Anspielung auf ein ebenso altehrwürdiges System, das Zugriff auf Begriffe bietet, indem es sie am Ende eines Buches (z.B. eines Fachbuches) alphabetisch aufführt. In einem solchen Stichwortverzeichnis oder Buchindex ist ein Schlüssel der gesuchte Begriff und der dazugehörige Wert ist die Angabe der Seitenzahlen, die dem Leser verraten, wo im Buch der Begriff ausführlich behandelt wird. Wir werden nun zunächst die wichtigsten APIs und zwei grundlegende Implementierungen besprechen. Danach betrachten wir drei klassische Datenstrukturen, die bei der Implementierung effizienter Symboltabellen helfen können: binäre Suchbäume, Rot-Schwarz-Bäume und Hashtabellen. Wir beenden das Thema mit der Besprechung diverser Erweiterungen und Anwendungen, von denen viele ohne die effizienten Algorithmen, die Sie in diesem Kapitel kennenlernen werden, nicht denkbar wären.
»
388
3.1 Symboltabellen
3.1
Symboltabellen
Der Hauptzweck einer Symboltabelle ist es, Werte (value) mit Schlüsseln (key) zu verbinden. Der Client fügt seine Schlüssel-Wert-Paare in die Symboltabelle ein, weil er darauf vertraut, dass er später unter all den Schlüssel-Wert-Paaren in der Tabelle gezielt nach dem Wert suchen kann, der mit einem gegebenen Schlüssel verbunden ist. Dieses Kapitel beschreibt verschiedene Varianten, wie man die Daten so strukturiert, dass nicht nur Einfüge- und Suchoperationen, sondern auch etliche andere praktische Operationen effizient ausgeführt werden können. Insgesamt müssen wir für die Implementierung einer Symboltabelle eine zugrunde liegende Datenstruktur definieren und anschließend Algorithmen zum Suchen und Einfügen, aber auch zum Erzeugen und Manipulieren der Datenstruktur vorsehen. Suchoperationen sind für viele Computeranwendungen so essentiell, dass Symboltabellen in vielen Programmierumgebungen als höhere Abstraktionen zur Verfügung stehen – auch in Java, dessen Symboltabellen-Implementierungen wir in Abschnitt 3.5 besprechen werden. In Tabelle 3.1 finden Sie Beispiele für Schlüssel und Werte, die typisch für bestimmte Anwendungen sind. Weiter unten werden wir uns dann einige anschauliche Referenz-Clients ansehen und in Abschnitt 3.5 beschäftigen wir uns mit der Frage, wie Sie in Ihren eigenen Clients Symboltabellen effektiv einsetzen. Schließlich sei noch darauf hingewiesen, dass wir in diesem Buch auch bei der Entwicklung etlicher anderer Algorithmen auf Symboltabellen zurückgreifen.
Definition: Symboltabelle Eine Symboltabelle ist eine Datenstruktur für Schlüssel-Wert-Paare, die zwei Operationen unterstützt: Einfügen (put ) eines neuen Paares in die Tabelle und Suchen (get ) nach dem Wert, der mit einem gegebenen Schlüssel verbunden ist.
Tabelle 3.1
Typische Anwendungen für Symboltabellen
Anwendung
Zweck der Suche
Schlüssel
Wert
Wörterbuch
Definition finden
Wort
Definition
Buchindex
Wichtige Seiten finden
Begriff
Liste der Seitenzahlen
Internettauschbörse
Song zum Herunterladen finden
Titel des Songs
Computer-ID
Kontenverwaltung
Prozesstransaktionen
Kontonummer
Transaktionsdetails
Websuche
Wichtige Webseiten finden
Schlüsselwort
Liste der Seitennamen
Compiler
Typ und Wert finden
Variablenname
Typ und Wert
389
3
Suchen
3.1.1 API Die Symboltabelle ist der Prototyp eines abstrakten Datentyps (Kapitel 1): Sie repräsentiert einen wohldefinierten Satz von Werten und Operationen auf diesen Werten und erlaubt uns, Clients und Implementierungen unabhängig voneinander zu entwickeln. Wie gewohnt definieren wir die Operationen in Form einer Anwendungsschnittstelle (API), die den Vertrag zwischen Client und Implementierung darstellt.
Tabelle 3.2
API für eine einfache generische Symboltabelle
public class ST ST()
Erzeugt eine Symboltabelle.
put(Key key, Value val)
Fügt ein Schlüssel-Wert-Paar in die Tabelle ein bzw. entfernt den Schlüssel, wenn der Wert null ist.
get(Key key)
Liefert den Wert zurück, der zu dem Schlüssel key gehört, bzw. null, wenn key nicht in der Tabelle vorhanden ist.
delete (Key key)
Entfernt den Schlüssel (und seinen Wert) aus der Tabelle.
boolean
contains(Key key)
Gibt es einen Wert, der zu dem Schlüssel key gehört?
boolean
isEmpty()
Ist die Tabelle leer?
int
size()
Liefert die Anzahl der Schlüssel-Wert-Paare in der Tabelle zurück.
Iterable
keys()
Liefert alle Schlüssel in der Tabelle zurück.
void
Value
void
Bevor wir uns dem Client-Code zuwenden, betrachten wir einige Entwurfsentscheidungen, die helfen sollen, den Code unserer Implementierung konsistent, kompakt und nützlich zu halten.
Generics Wie wir es beim Sortieren getan haben, betrachten wir die Methoden, ohne die Datentypen der zu verarbeitenden Elemente festzulegen, d.h., wir verwenden Generics (parametrisierbare Typen). Im Falle der Symboltabellen betonen wir die getrennten Rollen, die Schlüssel und Werte bei der Suche spielen, indem wir die Schlüssel- und Werttypen explizit angeben – anstatt die Schlüssel implizit in den Elementen vorauszusetzen, wie wir es bei den Vorrangwarteschlangen in Abschnitt 2.4 gemacht haben. Wir werden nun zunächst einige Charakteristika dieser grundlegenden API betrachten (beachten Sie z.B., dass die Reihenfolge der Schlüssel nirgends erwähnt wird). Anschließend sehen wir uns eine typische Erweiterung an: den Fall, dass die Schlüssel vergleichbar (Comparable) sind, was uns zahlreiche zusätzliche Methoden beschert.
390
3.1 Symboltabellen
Doppelte Schlüssel Wir halten uns in allen unseren Implementierungen an die folgenden Konventionen:
Jeder Schlüssel ist immer nur mit einem Wert verbunden (keine doppelten Schlüssel in einer Tabelle).
Wenn ein Client ein Schlüssel-Wert-Paar in eine Tabelle einfügt, in der dieser Schlüssel (und ein damit verbundener Wert) bereits vorhanden ist, ersetzt der neue Wert den alten. Diese Konventionen definieren die Abstraktion eines assoziativen Arrays, die es uns erlaubt, eine Symboltabelle als ein einfaches Array zu betrachten, bei dem die Schlüssel die Indizes und die Werte die Arrayeinträge sind. In einem normalen Array sind die Schlüssel Integer-Indizes, mit deren Hilfe wir schnell auf Arraywerte zugreifen können. In einem assoziativen Array (Symboltabelle) haben Schlüssel einen beliebigen Typ, eignen sich aber immer noch, um schnell auf Werte zuzugreifen. Einige Programmiersprachen (allerdings nicht Java) verfügen über eine spezielle Unterstützung für assoziative Arrays, die es dem Programmierer erlaubt, st[key] zu schreiben statt st.get(key) und st[key] = val für st.put(key, val) – wobei key und val Objekte eines beliebigen Typs sind.
null-Schlüssel Schlüssel dürfen nicht null sein. Wie so oft in Java, führt die Verwendung eines nullSchlüssels dazu, dass zur Laufzeit eine Ausnahme geworfen wird (siehe auch die hierzu gehörende dritte Frage im Fragen- und Antwortteil).
null-Werte Wir folgen außerdem der Konvention, dass kein Schlüssel mit dem Wert null verknüpft sein darf. Diese Konvention ist direkt mit der Angabe in unserer API-Spezifikation verbunden, wonach get() für alle Schlüssel, die nicht in der Tabelle enthalten sind, den Wert null zurückliefern soll – was den Wert null sozusagen mit jedem Schlüssel verknüpft, der nicht in der Tabelle enthalten ist. Diese Konvention hat zwei (beabsichtigte) Konsequenzen: Zum einen brauchen wir zum Testen, ob die Symboltabelle einen Wert definiert, der mit einem gegebenen Schlüssel verbunden ist, nur abzufragen, ob get() den Wert null zurückliefert. Zweitens können wir put() mit null als zweitem Argument (Wert) zum Löschen verwenden (siehe nachfolgenden Abschnitt).
Löschen Für das Löschen in Symboltabellen gibt es grundsätzlich zwei Strategien: das verzögerte Löschen (lazy deletion), wobei wir die Schlüssel in der Tabelle mit dem Wert null assoziieren und eventuell irgendwann später entfernen, sowie das sofortige Löschen (eager deletion), bei dem wir den Schlüssel sofort aus der Tabelle entfernen. Das oben angesprochene Verfahren – mit put(key, null) zu löschen – implementiert demnach eine simples (verzögerte) Löschen von delete(key). Wo uns dieser Standard nicht genügt, bieten
391
3
Suchen
wir eine (sofortige Löschen) Implementierung von delete() an. In unseren Symboltabellen-Implementierungen, die ein vorgegebenes delete() nicht verwenden, beginnen die put()-Implementierungen auf der Website zum Buch mit den defensiven Code if(val == null) { delete(key); return; }
um sicherzustellen, dass kein Schlüssel in der Tabelle mit null verknüpft ist. In den Listings im Buch verzichten wir aus Platzgründen auf diesen Code (und rufen put() nicht mit einem null-Wert im Client-Code auf).
Abkürzungsmethoden Um die Lesbarkeit des Client-Codes zu verbessern, erweitern wir die API um die Methoden contains() und isEmpty() mit den hier wiedergegebenen einzeiligen Standardimplementierungen. Aus Platzgründen und um unnötige Wiederholungen zu vermeiden, stellen wir diese Methoden nur hier vor und gehen ansonsten davon aus, dass sie in allen Implementierungen der Symboltabellen-API enthalten sind, so dass wir sie nach Belieben im Client-Code verwenden können.
Tabelle 3.3
Standardimplementierungen
Methode void
Standardimplementierung delete(Key key)
put(key, null);
boolean
contains(Key key)
return get(key) != null;
boolean
isEmpty()
return size() == 0;
Iteration Um Clients eine Möglichkeit zu bieten, alle Schlüssel und Werte in der Tabelle zu durchlaufen und zu verarbeiten, könnten wir der ersten Zeile der API noch die Klausel implements Iterable hinzufügen – und damit festlegen, dass jede Implementierung eine iterator()-Methode implementieren muss, die einen Iterator mit entsprechenden Implementierungen von hasNext() und next() zurückliefert (siehe die Beschreibungen zu Stacks und Warteschlangen in Abschnitt 1.3). Für Symboltabellen verfolgen wir jedoch einen alternativen, einfacheren Ansatz: Wir spezifizieren eine Methode keys(), die ein Iterable-Objekt zurückliefert, mit dem Clients über die Schlüssel iterieren können. Der Grund dafür ist, dass wir die Konsistenz zu Methoden wahren wollen, die wir für geordnete Symboltabellen definieren werden, mit denen Clients über eine spezifizierte Teilmenge der Schlüssel in der Tabelle iterieren können.
Schlüsselgleichheit Die Feststellung, ob ein gegebener Schlüssel in einer Symboltabelle enthalten ist oder nicht, basiert auf dem Konzept der Objektgleichheit, das wir bereits ausführlich in Abschnitt 1.2 beschrieben haben. Die Java-Konvention, dass alle Objekte eine equals()Methode erben, verbunden mit Javas Implementierung von equals() für Standarddaten-
392
3.1 Symboltabellen
typen wie Integer, Double sowie String und kompliziertere Datentypen wie File und URL ist ein guter Anfang – wenn Sie diese Datentypen verwenden, können Sie einfach auf die vorgegebene Implementierung zurückgreifen. Wenn beispielsweise x und y StringWerte sind, dann ergibt x.equals(y) nur dann true, wenn x und y die gleiche Länge haben und in jeder Zeichenposition identisch sind. Für entsprechende vom Client definierte Schlüssel müssen Sie equals() – wie in Abschnitt 1.2 beschrieben – überschreiben. Unsere equals()-Implementierung für Date (Listing 1.14 in Abschnitt 1.2) kann Ihnen dabei als Vorlage dienen. Wie bereits im Zusammenhang mit indizierten Vorrangwarteschlangen auf Seite 341 diskutiert, ist es am besten, die Key-Datentypen unveränderlich zu machen, da ansonsten keine Konsistenz garantiert werden kann.
3.1.2 Geordnete Symboltabellen In den meisten Anwendungen sind Schlüssel vergleichbare Objekte (Comparable), sodass es möglich ist, zwei Schlüssel a und b mit dem Code a.compareTo(b) zu vergleichen. Etliche Symboltabellen-Implementierungen nutzen die von der Schnittstelle Comparable vorgegebene Ordnung der Schlüssel, um effiziente Implementierungen der put()- und get()-Operationen anzubieten. Wichtiger noch ist, dass wir im Falle einer solchen Implementierung die Verantwortung für die Einhaltung der korrekten Reihenfolge der Schlüssel bei der Symboltabelle sehen und uns Gedanken über eine erheblich erweiterte API machen können, die eine Vielzahl selbstverständliche und nützliche Operationen definiert, bei denen die relative Reihenfolge der Schlüssel eine Rolle spielt. Angenommen, Ihre Schlüssel sind Zeitangaben für einen Tag. Sie könnten an der frühesten und spätesten Zeitangabe interessiert sein, an den Schlüsseln, die zwischen zwei gegebenen Zeitpunkten liegen, und so weiter. In den meisten Fällen können solche Operationen mit Hilfe der gleichen Datenstrukturen und Methoden, die den put()- und get()-Implementierungen zugrunde liegen, ohne große Schwierigkeiten implementiert werden. Für Anwendungen, in denen die Schlüssel vergleichbar sind, implementieren wir daher in diesem Kapitel die folgende API:
Tabelle 3.4
API für eine generische geordnete Symboltabelle
public class ST
void
Value
void
ST()
Erzeugt eine geordnete Symboltabelle.
put(Key key, Value val)
Fügt ein Schlüssel-Wert-Paar in die Tabelle ein bzw. entfernt den Schlüssel, wenn der Wert null ist.
get(Key key)
Liefert den Wert zurück, der zu dem Schlüssel key gehört, bzw. null, wenn key nicht in der Tabelle vorhanden ist.
delete (Key key)
Entfernt den Schlüssel (und seinen Wert) aus der Tabelle.
393
3
Suchen
Tabelle 3.4
API für eine generische geordnete Symboltabelle (Forts.)
public class ST boolean
contains(Key key)
Gibt es einen Wert, der zu dem Schlüssel key gehört?
boolean
isEmpty()
Ist die Tabelle leer?
int
size()
Liefert die Anzahl der Schlüssel-Wert-Paare in der Tabelle zurück.
Key
min()
Liefert den kleinsten Schlüssel zurück.
Key
max()
Liefert den größten Schlüssel zurück.
Key
floor(Key key)
Liefert den größten Schlüssel kleiner oder gleich key zurück.
Key
ceiling(Key key)
Liefert den kleinsten Schlüssel größer oder gleich key zurück.
int
rank(Key key)
Liefert die Anzahl der Schlüssel kleiner als key zurück.
Key
select(int k)
Liefert den Schlüssel an Position k zurück.
void
deleteMin()
Löscht den kleinsten Schlüssel.
void
deleteMax()
Löscht den größten Schlüssel.
size(Key lo, Key hi)
Liefert die Anzahl der Schlüssel in [lo..hi] zurück.
Iterable
key(Key lo, Key hi)
Liefert die Anzahl der Schlüssel in [lo..hi] in sortierter Reihenfolge zurück.
Iterable
keys()
Liefert alle Schlüssel in der Tabelle in sortierter Reihenfolge zurück.
int
Sie erkennen, dass eines unserer Programme diese API implementiert, an dem Vorhandensein der generischen Typvariable Key extends Comparable in der Klassendeklaration, die anzeigt, dass der Code von der Vergleichbarkeit der Schlüssel abhängt und den umfangreicheren Satz an Operationen implementiert. Zusammen definieren diese Operationen für Client-Programme eine geordnete Symboltabelle.
Minimum und Maximum Die vielleicht häufigsten Abfragen für eine Menge von geordneten Schlüsseln sind die nach dem kleinsten und größten Schlüssel. Wir sind diesen Operationen bereits in unserer Diskussion der Vorrangwarteschlangen in Abschnitt 2.4 begegnet. In geordneten Symboltabellen haben wir außerdem Methoden, um den größten und kleinsten Schlüssel (und seinen jeweiligen Wert) zu löschen. Damit kann die Symboltabelle wie die Klasse IndexMinPQ() verwendet werden, die wir in Abschnitt 2.4 besprochen haben. Der Hauptunterschied ist, dass in Vorrangwarteschlangen im Gegensatz zu Symboltabel-
394
3.1 Symboltabellen
len gleiche Schlüssel erlaubt sind und dass geordnete Symboltabellen einen viel größeren Satz an Operationen unterstützen.
Abrundung und Aufrundung Für einen gegebenen Schlüssel ist es oft nützlich, Auf- und Abrundungsoperationen ausführen zu können. floor() findet den größten Schlüssel, der kleiner oder gleich dem gegebenen Schlüssel ist, und ceiling() den kleinsten Schlüssel, der größer oder gleich dem gegebenen Schlüssel ist. Die Nomenklatur geht auf Funktionen zurück, die für reelle Zahlen definiert wurden (die Abrundungsfunktion (floor) ordnet einer reellen Zahl x die größte ganze Zahl zu, die kleiner oder gleich x ist, und die Aufrundungsfunktion (ceiling) ordnet einer reellen Zahl die kleinste ganze Zahl zu, die größer oder gleich x ist).
Rang und Auswahl Die grundlegenden Operationen, die festlegen, wo ein neuer Schlüssel in der Reihenfolge einzuordnen ist, sind die Rangoperation rank() (bestimmt die Anzahl der Schlüssel, die kleiner sind als ein gegebener Schlüssel) und die Auswahloperation select() (findet den Schlüssel mit einem gegebenen Rang). Testen Sie Ihr Verständnis der Operationen und überzeugen Sie sich, dass i == rank(select(i)) für alle i zwischen 0 und size()-1 gilt und dass alle Schlüssel in der Tabelle der Gleichung key == select(rank(key)) genügen. Die Notwendigkeit dieser Operationen kennen wir bereits von der Beschäftigung mit den Sortieranwendungen in Abschnitt 2.5. Bei Symboltabellen besteht die Herausforderung darin, diese Operationen schnell in Kombination mit Einfüge-, Lösch- und Suchoperationen durchzuführen. Schlüssel
Werte
min()
get(09:00:13) floor(09:05:00) select(7)
keys(09:15:00, 09:25:00)
ceiling(09:30:00) max() size(09:15:00, 09:25:00) ist 5 rank(09:10:25) ist 7
Abbildung 3.1: Beispiele für Operationen auf geordnete Symboltabellen
395
3
Suchen
Bereichsabfragen Wie viele Schlüssel liegen in einem bestimmten Bereich (der durch zwei Schlüssel angegeben wird)? Welche Schlüssel liegen in einem bestimmten Bereich? Die beiden Methoden size() und keys(), die jeweils zwei Argumente übernehmen und diese Fragen beantworten, sind in vielen Anwendungen nützlich, vor allem in großen Datenbanken. Die Möglichkeit, solche Abfragen zu behandeln, ist einer der Hauptgründe, warum geordnete Symboltabellen in der Praxis so weit verbreitet sind.
Ausnahmefälle Wenn eine Methode einen Schlüssel zurückliefern soll, es aber in der Tabelle keinen Schlüssel gibt, der der Beschreibung entspricht, werfen wir per Konvention eine Ausnahme (ein alternativer, ebenfalls vertretbarer Ansatz wäre es, in solchen Fällen null zurückzuliefern). So lösen beispielsweise min(), max(), deleteMin(), deleteMax(), floor() und ceiling() alle Ausnahmen aus, wenn die Tabelle leer ist, ebenso wie select(k), wenn k kleiner ist als 0 oder nicht kleiner als size().
Abkürzungsmethoden Wie bereits bei der Einführung von isEmpty() und contains() in unserer grundlegenden API angesprochen, definieren wir in der API einige redundante Methoden, die die Lesbarkeit des Client-Codes verbessern sollen. Um unnötige Wiederholungen zu vermeiden, gehen wir davon aus, dass die folgenden Standardimplementierungen in jeder Implementierung der API für geordnete Symboltabellen, sofern nichts anderes angegeben, enthalten sind.
Tabelle 3.5
Standardimplementierungen redundanter Methoden für geordnete Symboltabellen
Methode
Standardimplementierung void
deleteMin()
delete(min());
void
deleteMax()
delete(max());
size(Key lo, Key hi)
if(hi.compareTo(lo) < 0) return 0; else if(contains(hi)) return rank(hi)–rank(lo)+1; else return rank(hi)–rank(lo);
keys()
return keys(min(), max());
int
Iterable
Schlüsselgleichheit (überarbeitet) In Java sollte man bei Comparable-Datentypen stets dafür sorgen, dass compareTo() konsistent zu equals() ist. Das heißt, für jedes Wertepaar a und b eines gegebenen Comparable-Datentyps sollten (a.compareTo(b) == 0) und a.equals(b) den gleichen
396
3.1 Symboltabellen
Wert zurückliefern. Um potenzielle Doppeldeutigkeiten zu vermeiden, verzichten wir weitestgehend auf die Verwendung von equals() in Implementierungen geordneter Symboltabellen. Stattdessen verwenden wir ausschließlich compareTo(), um Schlüssel zu vergleichen: d.h., wir bedienen uns des booleschen Ausdrucks a.compareTo(b) == 0, um zu klären, ob a und b gleich sind. Normalerweise markiert ein solcher Test das erfolgreiche Ende einer Suche nach a in der Symboltabelle (durch Finden von b). Wie Sie bereits im Kapitel zu den Sortieralgorithmen gesehen haben, sind in Java zahlreiche Datentypen, die häufiger für Schlüssel verwendet werden, mit passenden Standardimplementierungen von compareTo() ausgestattet. Auch ist es nicht schwer, eine compareTo()-Implementierung für Ihre eigenen Datentypen zu entwickeln (Abschnitt 2.5).
Kostenmodell Ob wir jetzt equals() verwenden (für Symboltabellen, in denen Schlüssel nicht vergleichbar sind, d.h. nicht die Schnittstelle Comparable implementieren) oder compareTo() (für geordnete Symboltabellen mit vergleichbaren Schlüsseln, d.h. die Schlüssel implementieren die Schnittstelle Comparable) – wenn wir von compare-Operationen sprechen, meinen wir die Operation, die einen Symboltabellen-Eintrag mit einem Suchschlüssel vergleicht. In den meisten Symboltabellen-Implementierungen erfolgt diese Operation in der inneren Schleife. In den wenigen Fällen, in denen dies nicht der Fall ist, zählen wir auch die Arrayzugriffe.
Kostenmodell der Suche Bei der Analyse der Symboltabellen-Implementierungen zählen wir die compare -Operationen (Gleichheitstests oder Schlüsselvergleiche). In den (seltenen) Fällen, in denen die compare -Operationen nicht in der inneren Schleife erfolgen, zählen wir die Arrayzugriffe.
Symboltabellen-Implementierungen werden im Allgemeinen durch die ihnen zugrunde liegenden Datenstrukturen und ihre Implementierungen von get() und put() charakterisiert. Für die anderen Methoden verzichten wir gelegentlich auf den Abdruck der Implementierung, da diese oftmals eine gute Übung darstellt, anhand deren Sie Ihr Verständnis der zugrunde liegenden Datenstrukturen testen können. Um die verschiedenen Implementierungen unterscheiden zu können, stellen wir im Klassennamen von Symboltabellen-Implementierungen dem Kürzel ST ein beschreibendes Präfix voran, das die Implementierung beschreibt. In Clients verwenden wir ST, wenn wir keine spezifische, sondern eine Referenzimplementierung meinen. Was die Logik und die Prinzipien hinter den API-Methoden betrifft, so werden Sie nach und nach, während Sie die verschiedenen Clients und Symboltabellen-Implementierungen kennenlernen, die wir in diesem Kapitel und im Rest des Buches vorstellen und diskutieren, ein immer besseres Gefühl dafür entwickeln. Im Fragen- und Antworten-Teil und in den Übungen haben Sie zudem Gelegenheit, sich mit Alternativen zu den hier beschriebenen Entwurfsentscheidungen vertraut zu machen.
397
3
Suchen
3.1.3 Beispielclients Während wir zur ausführlicheren Analyse verschiedener Anwendungsfälle erst in Abschnitt 3.5 kommen, lohnt es sich vorab, bevor wir zur Implementierung schreiten, schon einmal einen ersten Blick auf den Client-Code zu werfen. Aus diesem Grunde betrachten wir hier zwei Clients: einen Testclient, anhand dessen wir das Algorithmusverhalten bei kleinen Eingaben verfolgen, und einen Performance-Client, mit dem wir die Entwicklung von effizienten Implementierungen vorantreiben wollen.
Testclient Zur Ablaufprotokollierung unserer Algorithmen bei kleinen Eingaben gehen wir davon aus, dass alle unsere Implementierungen den nachfolgenden Testclient verwenden, der eine Folge von Strings von der Standardeingabe entgegennimmt, eine Symboltabelle erstellt, die den Wert i mit dem i-ten String in der Eingabe verbindet, und dann die Tabelle ausgibt. Bei Ablaufprotokollen im Buch gehen wir davon aus, dass die Eingabe ein String aus mehreren aufeinanderfolgenden einzelnen Zeichen ist. Am häufigsten verwenden wir den String "S E A R C H E X A M P L E". Gemäß unseren Konventionen verbindet dieser Client den Schlüssel S mit dem Wert 0, den Schlüssel R mit dem Wert 3 usw. Beachten Sie aber, dass E mit dem Wert 12 (nicht 1 oder 6) und A mit dem Wert 8 (nicht 2) verbunden ist, da unsere Konvention für assoziative Arrays vorgibt, dass jeder Schlüssel mit dem Wert verbunden wird, der im letzten Aufruf von put() übergeben wird. Für einfache (ungeordnete) Implementierungen ist die Reihenfolge der Schlüssel in der Ausgabe des Testclients nicht festgelegt (sie hängt von den Besonderheiten der Implementierung ab). Bei einer geordneten Symboltabelle gibt der Testclient die Schlüssel in sortierter Reihenfolge aus. Der hier beschriebene Client ist ein Beispiel für einen Indizierungsclient, ein Sonderfall einer einfachen Symboltabellen-Anwendung, die wir in Abschnitt 3.5 besprechen. public static void main(String[] args) { ST st; st = new ST(); for (int i = 0; !StdIn.isEmpty(); i++) { String key = StdIn.readString(); st.put(key, i); } for (String s : st.keys()) StdOut.println(s + " " + st.get(s)); } Listing 3.1: Einfacher Testclient für Symboltabellen
398
3.1 Symboltabellen
Schlüssel S Werte
0
E
A
R
C
H
E
X
A
M
1
2
3
4
5
6
7
8
9 10 11 12
P
L
E
Ausgabe für eine Ausgabe für eine einfache Symboltabelle geordnete (eine Möglichkeit) Symboltabelle
L P M X H C R A E S
11 10 9 7 5 4 3 8 12 0
A C E H L M P R S X
8 4 12 5 11 9 10 3 0 7
Abbildung 3.2: Schlüssel, Werte und Ausgabe für den Testclient
Performance-Client FrequencyCounter (Listing 3.3) ist ein Symboltabellen-Client, der auszählt, wie oft jeder String (mit einer vorgegebenen Mindestanzahl von Zeichen) in der Folge von Strings, die über die Standardeingabe eingelesen werden, vorkommt. Danach iteriert er über die Schlüssel, um denjenigen String zu finden, der am häufigsten vorgekommen ist. Dieser Client ist ein Beispiel für einen Wörterbuch-Client – eine Anwendung, die wir ausführlicher in Abschnitt 3.5 besprechen. Er beantwortet eine einfache Frage: Welches Wort (das nicht kürzer als eine vorgegebene Mindestlänge ist) tritt am häufigsten in einem gegebenen Text auf? Im Verlauf dieses Kapitels messen wir die Performance dieses Clients mithilfe dreier Referenzeingaben: den ersten fünf Zeilen des Charles-Dickens-Werks A Tale of Two Cities (tinyTale.txt), des vollständigen Textes dieses Werks (tale.txt) und einer bekannten Datenbank mit einer Million Sätzen, die nach dem Zufallsprinzip dem Web entnommen wurden und unter der Bezeichnung Leipzig Corpora Collection (leipzig1M.txt) bekannt ist. Der Inhalt von tinytale.txt sieht beispielsweise folgendermaßen aus: % more it was it was it was it was it was
tinyTale.txt the best of times it was the worst of times the age of wisdom it was the age of foolishness the epoch of belief it was the epoch of incredulity the season of light it was the season of darkness the spring of hope it was the winter of despair
Listing 3.2: Kleine Testeingabe
Dieser Text mit einer Gesamtanzahl von 60 Wörtern besteht aus 20 verschiedenen Wörtern, von denen vier jeweils zehnmal vorkommen (die höchste Häufigkeit). Für diese Eingabe gibt FrequencyCounter eines der Wörter it, was, the oder of aus (welches konkret ausgegeben wird, hängt von den Eigenschaften der Symboltabellen-Implementierung ab), gefolgt von der Häufigkeitsangabe 10.
399
3
Suchen
Will man die Performance für größere Eingaben bewerten, werden zwei Größen interessant: die Gesamtzahl der Wörter im Text (da jedes Wort in der Eingabe einmal als Suchschlüssel verwendet wird) und die Gesamtzahl der unterschiedlichen Wörter im Eingabestrom (da jedes neue Wort aus der Eingabe in der Symboltabelle abgelegt wird und die Gesamtzahl der unterschiedlichen Wörter in der Eingabe nach Einfügung aller Schlüssel die Größe der Tabelle angibt). Wir müssen diese beiden Größen kennen, um die Laufzeit von FrequencyCounter abzuschätzen (als Einstieg bietet sich Übung 3.1.6 an). Einzelheiten besprechen wir später, wenn wir uns mit den Algorithmen beschäftigen. Sie sollten jedoch eine allgemeine Vorstellung von den Anforderungen typischer Anwendungen wie dieser haben. Die Ausführung von FrequencyCounter auf leipzig1M.txt für Wörter der Länge 8 oder größer umfasst Millionen von Suchläufen in einer Tabelle mit Hunderttausenden von Schlüsseln und Werten. Ein Server im Web muss eventuell Milliarden von Transaktionen auf Tabellen mit Millionen von Schlüsseln und Werten verarbeiten.
Tabelle 3.6
Gemessene Werte von verschieden großen Testeingabeströmen tinyTale.txt
tale.txt
leipzig1M.txt
Wörter
Verschieden
Wörter
Verschieden
Wörter
Verschieden
60
20
135.635
10.679
21.191.455
534.580
Min. 8 Buchstaben
3
3
14.350
5.131
4.239.597
299.593
Min. 10 Buchstaben
2
2
4.582
2.260
1.610.829
165.555
Alle Wörter
Die grundlegende Frage, die dieser Client und die Beispiele aufwerfen, ist folgende: Können wir eine Symboltabellen-Implementierung entwickeln, die eine riesige Anzahl von get()-Operationen auf einer großen Tabelle ausführen kann, die selbst mittels einer großen Anzahl von get()- und put()-Operationen erstellt wurde? Solange Sie nur einige wenige Suchläufe ausführen, ist die Implementierung egal. Wollen Sie aber einen Client wie FrequencyCounter für große Probleme einsetzen, benötigen Sie eine gute Symboltabellen-Implementierung. Unser FrequencyCounter-Client simuliert einen typischen Anwendungsfall und weist nebenbei die folgenden Eigenschaften auf, die auch bei vielen anderen Symboltabellen-Clients zu finden sind:
Such- und Einfügeoperationen wechseln sich ab. Die Anzahl der unterschiedlichen Schlüssel ist nicht klein. Im Großen und Ganzen gibt es wahrscheinlich mehr Such- als Einfügeoperationen. Such- und Einfügemuster, wenn auch unvorhersehbar, sind nicht zufällig.
400
3.1 Symboltabellen
Unser Ziel ist es, Symboltabellen-Implementierungen zu entwickeln, die es uns erlauben, mit solchen Clients typische praxisrelevante Probleme zu lösen. Als Nächstes betrachten wir zwei elementare Implementierungen und ihre Performance beim Einsatz in FrequencyCounter. In den nächsten Abschnitten werden Sie klassische Implementierungen kennenlernen, mit denen Sie eine exzellente Performance für solche Clients erreichen, sogar für riesige Eingabeströme und Tabellen.
Listing 3.3: Ein Symboltabellen-Client public class FrequencyCounter { public static void main(String[] args) { int minlen = Integer.parseInt(args[0]);
// Begrenzt die // Schlüssellänge. ST st = new ST(); while (!StdIn.isEmpty()) { // Erstellt eine Symboltabelle und zählt die Vorkommen. String word = StdIn.readString(); if (word.length() < minlen) continue; // Ignoriert kurze Schlüssel. if (!st.contains(word)) st.put(word, 1); else st.put(word, st.get(word) + 1); } // Finde einen Schlüssel mit der höchsten Häufigkeit. String max = ""; st.put(max, 0); for (String word : st.keys()) if (st.get(word) > st.get(max)) max = word; StdOut.println(max + " " + st.get(max));
} }
Dieser ST-Client zählt, wie häufig einzelne Strings in der Standardeingabe vorkommen, und gibt dann den am häufigsten vorkommenden String aus. Das Befehlszeilenargument gibt einen unteren Grenzwert für die Länge der zu betrachtenden Schlüssel an. % java FrequencyCounter 1 < tinyTale.txt it 10 % java FrequencyCounter 8 < tale.txt business 122 % java FrequencyCounter 10 < leipzig1M.txt government 24763
401
3
Suchen
3.1.4 Sequenzielle Suche in einer ungeordneten verketteten Liste
Sc
hl ü W ssel er t
Eine naheliegende Option für die Wahl der zugrunde liegenden Datenstruktur einer Symboltabelle ist eine verkettete Liste von Knoten, die jeweils einen Schlüssel und einen Wert speichern, siehe den Code in Listing 3.4. In der Implementierung von get() durchlaufen wir die Liste und vergleichen für jeden Knoten in der Liste mittels equals() den Suchschlüssel mit dem Schlüssel des Knotens. Wenn wir eine Übereinstimmung finden, liefern wir den zugehörigen Wert zurück; wenn nicht, liefern wir null zurück. Um put() zu implementieren, durchlaufen wir ebenfalls die Liste und vergleichen den Client-Schlüssel mithilfe von equals() mit dem Schlüssel eines jeden Knotens in der Liste. Wenn wir eine Übereinstimmung finden, überschreiben wir den Wert, der mit dem Schlüssel verknüpft ist, mit dem Wert, der als zweites Argument angegeben ist; wenn nicht, erzeugen wir einen neuen Knoten mit dem gegebenen Schlüssel und Wert und fügen ihn am Anfang der Liste ein. Diese Methode wird sequenzielle Suche genannt: Wir suchen, indem wir die Schlüssel in der Tabelle nacheinander betrachten und mithilfe von equals() testen, ob einer von ihnen mit unserem Suchschlüssel übereinstimmt.
first
S
0
S 0
rote Knoten sind neu
E
1
E 1
S 0
A
2
A 2
E 1
S 0
R
3
R 3
A 2
E 1
S 0
C
4
C 4
R 3
A 2
E 1
S 0
H
5
H 5
C 4
R 3
A 2
E 1
S 0
E
6
H 5
C 4
R 3
A 2
E 6
S 0
X
7
X 7
H 5
C 4
R 3
A 2
E 6
S 0
A
8
X 7
H 5
C 4
R 3
A 8
E 6
S 0
M
auf schwarze Knoten wurde bei der Suche zugegriffen
umkreiste Einträge sind geänderte Werte
9
M 9
X 7
H 5
C 4
R 3
A 8
E 6
graue Knoten wurden nicht berührt S 0
P 10
P 10
M 9
X 7
H 5
C 4
R 3
A 8
E 6
S 0
L 11
L 11
P 10
M 9
X 7
H 5
C 4
R 3
A 8
E 6
S 0
E 12
L 11
P 10
M 9
X 7
H 5
C 4
R 3
A 8
E 12
S 0
Abbildung 3.3: Ablaufprotokoll der ST-Implementierung mit verketteter Liste für den Testclient
Listing 3.4 (SequentialSearchST) ist eine Implementierung unserer einfachen Basis-API für Symboltabellen. Die Implementierung nutzt Listen verarbeitende Standardtechniken, wie wir sie bereits für die elementaren Datenstrukturen in Kapitel 1 verwendet haben. Die Implementierungen von size(), keys() und sofortigem delete() seien Ihnen zur Übung überlassen. Sie sollten diese Übungen auf alle Fälle machen, um Ihr Verständnis der einfachen Symboltabellen-API und der Datenstruktur der verketteten Liste zu festigen. Ist diese auf verketteten Listen basierende Implementierung für Anwendungen geeignet, die wie unsere Beispielclients mit großen Tabellen arbeiten müssen? Wie wir bereits angemerkt haben, ist die Analyse von Symboltabellen-Algorithmen komplizierter als die von Sortieralgorithmen, da es schwierig ist, die Folge der von einem gegebenen Cli-
402
3.1 Symboltabellen
ent aufgerufenen Operationen zu charakterisieren. Wie wir bereits bei der Besprechung von FrequencyCounter festgestellt haben, sieht die Praxis meist so aus, dass Such- und Einfügemuster zwar unvorhersehbar, aber mit Sicherheit nicht zufällig sind – weswegen wir besonderes Augenmerk auf die Worst-Case-Performance legen. Wir differenzieren allgemein zwischen einer erfolgreichen Suche bzw. einer erfolglosen Suche.
Listing 3.4: Sequenzielle Suche in einer ungeordneten verketteten Liste (Algorithmus 3.1) public class SequentialSearchST { private Node first; // erster Knoten in der verketteten Liste private class Node { // Knoten in der verketteten Liste Key key; Value val; Node next; public Node(Key key, Value val, Node next) { this.key = key; this.val = val; this.next = next; } } public Value get(Key key) { // Sucht Schlüssel, liefert dazugehörigen Wert. for (Node x = first; x != null; x = x.next) if (key.equals(x.key)) return x.val; // erfolgreiche Suche return null; // erfolglose Suche } public void put(Key key, Value val) { // Sucht Schlüssel. Gefunden: Wert aktualisieren; neu: // Tabelle erweitern. for (Node x = first; x != null; x = x.next) if (key.equals(x.key)) { x.val = val; return; } // erfolgreiche Suche: // Wert aktualisieren. first = new Node(key, val, first); // erfolglose Suche: // neuen Knoten hinzufügen. } }
Diese ST-Implementierung verwendet eine private innere Klasse namens Node, um die Schlüssel und Werte in einer ungeordneten verketteten Liste zu speichern. Die get()-Implementierung durchsucht die Liste sequenziell, um festzustellen, ob der Schlüssel in der Tabelle vorhanden ist (und liefert in diesem Fall den verknüpften Wert zurück). Auch die put()-Implementierung durchsucht die Liste sequenziell, um festzustellen, ob der Schlüssel in der Tabelle vorhanden ist. Wenn ja, aktualisiert sie den zugehörigen Wert; wenn nicht, erzeugt sie einen neuen Knoten mit dem gegebenen Schlüssel und Wert und fügt ihn am Anfang der Liste ein. Implementierungen von size(), keys() und sofortigem delete() bleiben Ihnen zur Übung überlassen.
403
3
Suchen
Satz A: Einfügungen und erfolglose Suchen in einer (ungeordneten) auf einer verketteten Liste basierenden Symboltabelle mit N Schlüssel-Wert-Paaren benötigen jeweils N Vergleiche; erfolgreiche Suchen benötigen im schlimmsten Fall ebenfalls N Vergleiche. Insbesondere werden für das Einfügen von N verschiedenen Schlüsseln in eine anfänglich leere, auf verketteter Liste basierende Symboltabelle ∼N 2/2 Vergleiche benötigt. Beweis: Wenn ein Schlüssel gesucht wird, der nicht in der Liste enthalten ist, prüfen wir jeden Schlüssel in der Tabelle gegen den Suchschlüssel. Da wir festgelegt haben, dass doppelte Schlüssel nicht erlaubt sind, müssen wir eine solche Suche vor jeder Einfügung durchführen.
Korollar: Das Einfügen von N verschiedenen Schlüsseln in eine anfänglich leere, auf verketteter Liste basierende Symboltabelle benötigt ∼N 2/2 Vergleiche.
Es stimmt, dass die Laufzeit von Suchläufen für Schlüssel, die in der Tabelle enthalten sind, nicht notwendigerweise linear ist. Eine nützliche Maßnahme ist, die Gesamtkosten einer Suche nach allen Schlüsseln in der Tabelle zu berechnen und dann durch N zu teilen. Dieser Wert entspricht exakt der erwarteten Anzahl von Vergleichen, die für einen Suchlauf benötigt wird, wenn man unterstellt, dass die Suchläufe nach den verschiedenen Schlüssel in der Tabelle gleich wahrscheinlich sind. Wir bezeichnen solch eine Suche als zufällig erfolgreiche Suche. Auch wenn Client-Suchmuster eher nicht zufällig sind, lassen sie sich doch in der Regel durch dieses Modell gut beschreiben. Es lässt sich leicht beweisen, dass die durchschnittliche Zahl von Vergleichen für eine zufällig erfolgreiche Suche ∼N/2 ist: Die get()-Methode in Listing 3.4 verwendet einen Vergleich, um den ersten Schlüssel zu finden, zwei Vergleiche, um den zweiten Schlüssel zu finden usw., was durchschnittliche Kosten von (1+2+…+N)/2=(N+1)/ 2∼N/2 bedeutet. Die obige Analyse lässt vermuten, dass eine auf einer verketteten Liste basierende Implementierung mit sequenzieller Suche zu langsam ist, um wirklich große Probleme – wie z.B. das Einlesen unserer Referenzeingaben in einen Client wie FrequencyCounter – lösen zu können. Bedenken Sie: Da die Gesamtzahl der Vergleiche proportional zu dem Produkt aus der Anzahl von Suchläufen und der Anzahl von Einfügungen ist, bedeutet dies mehr als 109 Vergleiche für A Tale of Two Cities und mehr als 1014 Vergleiche für das Leipzig Corpora. Wie üblich bedarf es zur Bewertung der Analyseergebnisse einiger praktischer Experimente. Betrachten wir z.B. die Ausführung von FrequencyCounter mit dem Befehlszeilenargument 8 für tale.txt – was 14350 put()-Operationen bedeutet (zur Erinnerung: jedes Wort in der Eingabe führt zu einem put(), um seine Häufigkeit zu aktualisieren; die Kosten von leicht zu vermeidenden contains()-Aufrufen lassen wir außer Acht). Die Symboltabelle wächst auf 5131 Einträge an, sodass ungefähr ein Drittel der Operationen die Tabelle erweitert; der Rest sind Suchläufe. Um die Performance grafisch
404
3.1 Symboltabellen
darzustellen, verwenden wir die Klasse VisualAccumulator (Abschnitt 1.2, Listing 1.20), die wie folgt zwei Punkte für jede put()-Operation zeichnet: Für die i-te put()-Operation zeichnen wir einen grauen Punkt mit i als x-Koordinate und der Anzahl der Schlüsselvergleiche als y-Koordinate sowie einen roten Punkt mit i als x-Koordinate und der kumulierten Durchschnittszahl von Schlüsselvergleichen für die ersten i put()-Operationen als y-Koordinate. Wie bei wissenschaftlichen Daten üblich erhalten wir auf diese Weise eine Unmenge von zu analysierenden Informationen (das Diagramm hat 14350 graue und 14350 rote Punkte). Hier interessiert uns jedoch vor allem, dass das Diagramm unsere Hypothese bestätigt, dass die put()-Operation im Durchschnitt auf ungefähr die halbe Liste zugreift. Die tatsächliche Gesamtzahl ist etwas niedriger als die Hälfte, aber diese Tatsache (und der genaue Verlauf der Kurven) lässt sich vermutlich besser durch die Charakteristika der Anwendung, nicht der Charakteristika unseres Algorithmus erklären (Übung 3.1.36). Während eine genaue Beschreibung der Performance für bestimmte Clients kompliziert sein kann, lassen sich spezielle Hypothesen für FrequencyCount leicht formulieren und unter Verwendung eines Clients wie DoublingTest aus Kapitel 1 mit Referenzeingaben oder zufällig geordneten Eingaben problemlos testen. Diese Tests finden Sie in den Übungen und den folgenden anspruchsvolleren Implementierungen. Wenn Sie noch nicht davon überzeugt sind, dass wir schnellere Implementierungen benötigen, sollten Sie diese Übungen nachvollziehen (oder führen Sie einfach FrequencyCounter mit SequentialSearchST auf leipzig1M.txt aus!).
Vergleiche
5131
2246
0 0
Operationen
14350
Abbildung 3.4: Kosten von java FrequencyCounter 8 < tale.txt unter Verwendung von SequentialSearchST
3.1.5 Binäre Suche in einem geordneten Array Als Nächstes betrachten wir eine vollständige Implementierung unserer geordneten Symboltabellen-API. Die zugrunde liegende Datenstruktur ist ein Paar paralleler Arrays, eines für die Schlüssel und eines für die Werte. Listing 3.5 (BinarySearchST) speichert in dem einen Array Comparable-Schlüssel in geordneter Reihenfolge und nutzt dann die Arrayindizierung für eine schnelle Implementierung von get() und anderen Operationen.
405
3
Suchen
Das Herz der Implementierung bildet die Methode rank(), die die Anzahl der Schlüssel zurückliefert, die kleiner als ein gegebener Schlüssel sind. Im Falle von get() verrät uns der Rang, wo der Schlüssel genau zu finden ist (bzw. dass er in der Tabelle nicht enthalten ist). Im Falle von put() verrät uns der Rang, wo genau der Wert zu aktualisieren ist, wenn der Schlüssel in der Tabelle enthalten ist, bzw. wo der Schlüssel einzufügen ist, wenn er noch nicht in der Tabelle enthalten ist. Wir verschieben dann alle größeren Schlüssel um eine Position (von hinten nach vorn), um Platz zu machen und den gegebenen Schlüssel und Wert an ihre korrekten Positionen in ihren jeweiligen Arrays einzufügen. Auch hier gilt: einer der besten Wege, um sich mit der Datenstruktur vertraut zu machen, ist, den Code von BinarySearchST zu studieren und parallel die Ausführung unseres Testclients nachzuvollziehen. Der Code arbeitet mit parallelen Arrays von Schlüsseln und Werten (für einen alternativen Ansatz siehe Übung 3.1.12). Lästig ist, dass wir – wie schon bei unseren Implementierungen generischer Stacks und Warteschlangen in Kapitel 1 – ein Key-Array vom Typ Comparable und ein Value-Array vom Typ Object erzeugen müssen, die wir im Konstruktor in die Typen Key[] und Value[] umwandeln. Damit sich die Clients nicht um die Größe des Arrays kümmern müssen, können wir wieder so vorgehen, dass wir die Arraygröße bei Bedarf anpassen. (Wobei zu beachten ist, dass – wie Sie noch sehen werden – diese Methode zu langsam ist, um zusammen mit großen Arrays verwendet zu werden.)
Schlüssel Wert
rote Einträge wurden eingefügt
schwarze Einträge wurden nach rechts verschoben
graue Einträge wurden nicht verschoben
Abbildung 3.5: Ablaufprotokoll der ST-Implementierung im geordneten Array für den Testclient
406
umkreiste Einträge sind geänderte Werte
3.1 Symboltabellen
Listing 3.5: Binäre Suche (in einem geordneten Array) (Algorithmus 3.2) public class BinarySearchST { private Key[] keys; private Value[] vals; private int N; public BinarySearchST(int capacity) { // Siehe Algorithmus 1.1 für Standardcode zur Größenanpassung // von Arrays. keys = (Key[]) new Comparable[capacity]; vals = (Value[]) new Object[capacity]; } public int size() { return N; } public Value get(Key key) { if (isEmpty()) return null; int i = rank(key); if (i < N && keys[i].compareTo(key) == 0) return vals[i]; else return null; } public int rank(Key key) // Siehe Listing 3.7. public void put(Key key, Value val) { // Sucht Schlüssel. Gefunden: Wert aktualisieren; neu: // Tabelle vergrößern. int i = rank(key); if (i < N && keys[i].compareTo(key) == 0) { vals[i] = val; return; } for (int j = N; j > i; j--) { keys[j] = keys[j-1]; vals[j] = vals[j-1]; } keys[i] = key; vals[i] = val; N++; } public void delete(Key key) // Diese Methode ist Bestandteil der Übung 3.1.16. }
Die ST-Implementierung speichert die Schlüssel und Werte in parallelen Arrays. Die put()-Implementierung verschiebt vor der Vergrößerung der Tabelle die größeren Schlüssel um eine Position nach rechts – wie in der auf Arrays basierenden Stack-Implementierung in Abschnitt 1.3. Der Code zur Größenveränderung des Arrays wurde hier ausgelassen.
407
3
Suchen
Binäre Suche Der Grund, warum wir die Schlüssel in einem geordneten Array speichern, ist, dass wir den Arrayindex heranziehen können, um die Anzahl der Vergleiche für jede Suche mithilfe des klassischen und ehrwürdigen Binärsuche-Algorithmus, den wir bereits in Kapitel 1 als Beispiel verwendet haben, dramatisch zu reduzieren. Wir speichern die Indizes des sortierten Schlüsselarrays, die das Teilarray, in dem der Suchschlüssel enthalten sein könnte, begrenzen. Bei der Suche vergleichen wir den Suchschlüssel mit dem Schlüssel in der Mitte des Teilarrays. Wenn der Suchschlüssel kleiner ist als der Schlüssel in der Mitte, suchen wir in der linken Hälfte des Teilarrays weiter, und wenn der Suchschlüssel größer ist als der Schlüssel in der Mitte, suchen wir in der rechten Hälfte des Teilarrays. Wenn keine der Möglichkeiten zutrifft, ist der Schlüssel in der Mitte gleich dem Suchschlüssel. Der rank()-Code aus Listing 3.7 nutzt die binäre Suche, um die gerade besprochene SymboltabellenImplementierung zu vervollständigen. Diese Implementierung lohnt einer eingehenderen Untersuchung. Dazu beginnen wir mit dem äquivalenten rekursiven Code aus Listing 3.6. Ein Aufruf von rank(key, 0, N-1) führt die gleiche Folge von Vergleichen aus wie ein Aufruf der nicht-rekursiven Implementierung in Listing 3.7, aber diese alternative Version lässt die Struktur des Algorithmus besser erkennen, wie er in Abschnitt 1.1 besprochen wurde. Die rekursive rank()-Methode sorgt dafür, dass die folgenden Eigenschaften gewahrt bleiben:
Wenn key in der Tabelle ist, liefert rank() den dazugehörigen Tabellenindex zurück, der gleich der Anzahl der Schlüssel in der Tabelle ist, die kleiner sind als key.
Wenn key nicht in der Tabelle ist, liefert rank() ebenfalls die Anzahl der Schlüssel in der Tabelle zurück, die kleiner sind als key. public int rank(Key key, int lo, int hi) { if (hi < lo) return lo; int mid = lo + (hi - lo) / 2; int cmp = key.compareTo(keys[mid]); if (cmp < 0) return rank(key, lo, mid-1); else if (cmp > 0) return rank(key, mid+1, hi); else return mid; } Listing 3.6: Rekursive binäre Suche
Nehmen Sie sich die Zeit, um sich davon zu überzeugen, dass die nicht-rekursive rank()-Methode aus Listing 3.7 wie erwartet arbeitet (indem Sie entweder beweisen, dass sie äquivalent der rekursiven Version ist, oder indem Sie direkt beweisen, dass die Schleife immer mit einem Wert von lo endet, der gleich der Anzahl der Schlüssel in der Tabelle ist, die kleiner sind als key). Es lohnt sich für jeden Programmierer. (Hinweis: Beachten Sie, dass lo bei 0 beginnt und niemals kleiner wird.)
408
3.1 Symboltabellen
Andere Operationen Da die Schlüssel in einem geordneten Array gespeichert werden, sind die meisten ordnungsbasierten Operationen relativ kompakt und nicht kompliziert, wie der Code in Listing 3.8 belegt. So liefert beispielsweise ein Aufruf von select(k) einfach keys[k] zurück. Die Methoden delete() und floor() seien Ihnen zur Übung überlassen. Wir empfehlen Ihnen, sich die Implementierungen von ceiling() und der Methode keys(), mit den zwei Argumenten, gut anzuschauen und auch die Übungen durchzuarbeiten, um Ihr Verständnis dieser Implementierung und der geordneten Symboltabellen-API zu verbessern und zu festigen.
Listing 3.7: Binäre Suche in einem geordneten Array (iterativ) (Fortsetzung von Algorithmus 3.2) public int rank(Key key) { int lo = 0, hi = N-1; while (lo 0) lo = mid + 1; else return mid; } return lo; }
Diese Methode verwendet die klassische Methode, wie sie im Text beschrieben wird, um die Anzahl der Schlüssel in der Tabelle zu berechnen, die kleiner als key sind: Vergleiche key mit dem Schlüssel in der Mitte. Ist er gleich, liefere dessen Index zurück; ist er kleiner, suche in der linken Hälfte weiter, und ist er größer, suche in der rechten Hälfte.
erfolgreiche Suche nach P
lo hi mid
schwarze Einträge sind a[lo..hi] roter Eintrag ist a[mid]
erfolglose Suche nach Q
lo hi mid
Schleife beendet bei keys[mid] = P: liefert 6 zurück
Schleife beendet bei lo > hi: liefert 7 zurück
Abbildung 3.6: Ablaufprotokoll einer binären Suche nach dem Rang in einem geordneten Array
409
3
Suchen
Listing 3.8: Geordnete Symboltabellen-Operationen für die binäre Suche (Fortsetzung von Algorithmus 3.2) public Key min() { return keys[0];
}
public Key max() { return keys[N-1];
}
public Key select(int k) { return keys[k]; } public Key ceiling(Key key) { int i = rank(key); return keys[i]; } public Key floor(Key key) // Siehe Übung 3.1.17. public Key delete(Key key) // Siehe Übung 3.1.16. public Iterable keys(Key lo, Key hi) { Queue q = new Queue(); for (int i = rank(lo); i < rank(hi); i++) q.enqueue(keys[i]); if (contains(hi)) q.enqueue(keys[rank(hi)]); return q; }
Diese Methoden zusammen mit den Methoden aus den Übungen 3.1.16 und 3.1.17 vervollständigen die Implementierung unserer (geordneten) Symboltabellen-API unter Verwendung der binären Suche in einem geordneten Array. Die Methoden min(), max() und select() sind trivial und tun nichts anderes, als den entsprechenden Schlüssel von seiner bekannten Position im Array zurückzuliefern. Die Methode rank() als Grundlage der binären Suche spielt in den anderen Methoden eine zentrale Rolle. Die Implementierungen floor() und delete(), die wir Ihnen als Übungen überlassen haben, sind zwar etwas komplexer, vom Ansatz her aber ebenfalls einfach.
410
3.1 Symboltabellen
3.1.6 Analyse der binären Suche Die rekursive Implementierung von rank() führt zu der direkten Argumentation, dass die binäre Suche eine schnelle Suche garantiert, da sie einer Rekursionsgleichung (oder auch Differenzengleichung) entspricht, die einen oberen Grenzwert für die Anzahl der Vergleiche beschreibt.
Satz B: Die binäre Suche in einem geordneten Array mit N Schlüsseln verwendet nicht mehr als lg N+1 Vergleiche für eine Suche (erfolgreich oder nicht). Beweis: Diese Analyse ist ähnlich, wenn auch einfacher als die Analyse von Mergesort (Satz F in Kapitel 2 ). Sei C(N) die Anzahl der Vergleiche, die benötigt werden, um nach einem Schlüssel in einer Symboltabelle der Größe N zu suchen. Bei C(0)=0, C(1)=1 und N>0 können wir eine Rekursionsgleichung aufschreiben, die die rekursive Methode direkt widerspiegelt: ⎣ N / 2⎥ ⎦)+1 C (N ) ≤ C (⎢
Unabhängig davon, ob die Suche links oder rechts fortgesetzt wird, ist die Größe des Teilarrays nicht ⎢ N / 2⎦ ⎥und wir benötigen genau einen Vergleich, um auf Gleichheit zu prüfen und zu entgrößer als⎣ scheiden, ob die Suche links oder rechts erfolgen soll. Wenn N um eins kleiner ist als eine Zweier⎢ N / 2⎦= ⎥ 2n−1 −1, potenz (sagen wir N = 2n −1), ist diese Rekursion nicht schwer zu lösen. Da ⎣ haben wir zum einen C (2n −1) ≤ C (2n−1 −1)+1
Wenn wir diese Gleichung auf den ersten Term zur Rechten anwenden, erhalten wir C (2n −1) ≤ C (2n−2 −1)+1+1
Indem wir den vorherigen Schritt n−2-mal wiederholen, ergibt sich C (2n −1) ≤ C (20 )+ n
was uns zu folgender Lösung führt C (N ) = C (2n ) ≤ n + 1 < lg N +1
Exakte Lösungen für allgemeine N sind weitaus komplizierter. Es ist aber nicht schwer, die obige Argumentation auf allgemeine Werte von N auszudehnen (siehe Übung 3.1.20 ). Die binäre Suche garantiert uns somit Suchläufe in logarithmischer Laufzeit.
Die gerade vorgestellte Implementierung für ceiling() basiert auf einem einzigen Aufruf von rank(). Die Standardimplementierung von size(), die zwei Argumente übernimmt, ruft rank() zweimal auf. Unser Beweis belegt also auch, dass diese Operationen (und floor()) garantiert in logarithmischer Zeit ausgeführt werden (min(), max() und select() sind Operationen in konstanter Zeit).
411
3
Suchen
Tabelle 3.7
Kosten von BinarySearchST
Methode
Wachstumsordnung der Laufzeit
put()
N
get()
log N
delete()
N
contains()
log N
size()
1
min()
1
max()
1
floor()
log N
ceiling()
log N
rank()
log N
select()
1
deleteMin()
N
deleteMax()
1
Trotz der garantierten logarithmischen Suche erlaubt uns BinarySearchST immer noch nicht, Clients wie FrequencyCounter zur Lösung riesiger Probleme heranzuziehen – dafür ist die put()-Methode zu langsam. Die Binärsuche reduziert zwar die Anzahl der Vergleiche, nicht aber die Laufzeit, denn sie ändert nichts an dem Fakt, dass die Anzahl der Arrayzugriffe, die erforderlich sind, um eine Symboltabelle in einem geordneten Array zu erstellen, für zufällig geordnete Schlüssel quadratisch zu der Größe des Arrays ist. (In der Praxis sind die Schlüssel zwar üblicherweise nicht zufällig, werden aber durch dieses Modell gut beschrieben.)
Satz B (Fortsetzung): Das Einfügen eines neuen Schlüssels in ein geordnetes Array der Größe N erfordert im schlimmsten Fall ∼2N Arrayzugriffe, sodass das Einfügen von N Schlüsseln in eine anfänglich leere Tabelle im schlimmsten Fall N 2 Arrayzugriffe erfordert. Beweis: Analog Satz A.
Für A Tale of Two Cities mit seinen mehr als 104 verschiedenen Schlüsseln betragen die Kosten zum Erstellen der Tabelle ungefähr 108 Arrayzugriffe und für das Leipzig-Projekt mit über 106 verschiedenen Schlüsseln 1011 Arrayzugriffe. Diese Kosten mögen auf modernen Computern durchaus im Bereich des Machbaren liegen, sind aber dennoch extrem (und unnötig) hoch.
412
3.1 Symboltabellen
Doch kehren wir zurück zu den Kosten der put()-Operationen für FrequencyCounter für Wörter der Länge 8 und mehr. Es zeigt sich, dass der Wechsel von SequentialSearchST zu BinarySearchST die durchschnittlichen Kosten von 2246 Vergleichen (plus Arrayzugriffe) pro Operation auf 484 Vergleiche reduziert. Wie zuvor sind auch hier die Kosten besser, als die Analyse vermuten lässt, und die zusätzliche Verbesserung ist vermutlich wieder auf die Eigenschaften der Anwendung (siehe Übung 3.1.36) zurückzuführen. Diese Verbesserung ist beeindruckend, aber wir können noch viel bessere Werte erreichen, wie Sie bald sehen werden.
Kosten
5131
484 0 0
Operationen
14350
Abbildung 3.7: Kosten von java FrequencyCounter 8 < tale.txt unter Verwendung von BinarySearchST
3.1.7 Ausblick Die Binärsuche ist normalerweise viel günstiger als die sequenzielle Suche und erweist sich in vielen praktischen Anwendungen als Methode der Wahl. Bei statischen Tabellen (die keine Einfügeoperationen zulassen) lohnt es sich, die Tabelle zu initialisieren und zu sortieren, wie wir es bei der Binärsuche-Version in Abschnitt 1.2 (Listing 1.24) gemacht haben. Selbst wenn nur ein Großteil der Schlüssel-Wert-Paare vor dem Gros der durchzuführenden Suchen bekannt ist (was in vielen Anwendungen der Fall ist), lohnt es sich, BinarySearchST um einen Konstruktor zu erweitern, der die Tabelle initialisiert und sortiert (Übung 3.1.12). Auf der anderen Seite muss man akzeptieren, dass die Binärsuche für viele Anwendungen ungeeignet ist. So versagt sie beispielsweise in unserer Leipzig-Corpora-Anwendung, da Such- und Einfügeoperationen sich abwechseln und die Tabelle zu groß ist. Wie wir bereits betont haben, benötigen typische moderne Such-Clients Symboltabellen, die schnelle Implementierungen zum Suchen und zum Einfügen unterstützen können. Das heißt, wir müssen in der Lage sein, riesige Tabellen zu erstellen, in denen wir nicht nur suchen, sondern auch nach unvorhersehbaren Mustern Schlüssel-Wert-Paare einfügen (und vielleicht auch entfernen) können. Tabelle
3.8 fasst die Performancedaten für die in diesem Abschnitt vorgestellten elementaren Symboltabellen-Implementierungen noch einmal zusammen. Die Tabelleneinträge geben den maßgeblichen Term der Kosten an (Anzahl der Arrayzugriffe für die Binärsuche, Anzahl der Vergleiche für die sequenzielle Suche), der die Ordnung des Laufzeitzuwachs beschreibt.
413
3
Suchen
Tabelle 3.8
Zusammenfassung der Kosten für grundlegende Symboltabellen-Implementierungen
Algorithmus (Datenstruktur)
Kosten für den schlimmsten Fall (nach N Einfügungen)
Kosten für den durchschnittlichen Fall (nach N zufälligen Einfügeoperationen)
Effiziente Unterstützung für ordnungsbasierte Operationen?
Suchen
Einfügen
Erfolgreiches Suchen
Einfügen
Sequenzielle Suche (ungeordnete verkettete Liste)
N
N
N/2
N
nein
Binäre Suche (geordnetes Array)
lg N
2N
lg N
N
ja
Die zentrale Frage, die sich stellt, ist, ob wir Algorithmen und Datenstrukturen entwickeln können, die sowohl für Einfüge- als auch für Suchoperationen eine logarithmische Performance aufweisen. Die Antwort ist ein eindeutiges Ja! Ihnen hierfür eine Lösung zu liefern, ist eines der Hauptanliegen dieses Kapitels. Zusammen mit der schnellen Sortierfähigkeit, die wir in Kapitel 2 diskutiert haben, ist das schnelle Einfügen und Suchen in Symboltabellen einer der wichtigsten Beiträge der Algorithmik und einer der wichtigsten Schritte zur Entwicklung einer leistungsstarken Computerinfrastruktur, wie wir sie heute kennen. Wie lässt sich dieses Ziel erreichen? Um effizientes Einfügen zu unterstützen, benötigen wir offensichtlich eine verkettete Struktur. Eine einfach verkettete Liste schließt allerdings die Verwendung der binären Suche aus, da die Effizienz der Binärsuche darauf beruht, dass wir die Mitte eines beliebigen Teilarrays schnell mithilfe von Indizes ermitteln (und die einzige Möglichkeit, die Mitte einer einfach verketteten Liste zu finden, besteht darin, den Listenelementen zu folgen). Um die Effizienz der binären Suche mit der Flexibilität von verketteten Strukturen zu kombinieren, benötigen wir kompliziertere Datenstrukturen. Datenstrukturen, die diese Anforderungen erfüllen, sind zum einen die binären Suchbäume, die Thema der nächsten beiden Abschnitte sein werden, und zum anderen die Hashtabellen, die wir in Abschnitt 3.4 behandeln. Wir betrachten in diesem Kapitel insgesamt sechs Symboltabellen-Implementierungen, weswegen ein kurzer Überblick durchaus angebracht ist. Tabelle 3.9 listet einige Datenstrukturen zusammen mit den wichtigsten Gründen für oder gegen ihren Einsatz in einer Anwendung auf. Die Datenstrukturen sind in der Reihenfolge aufgeführt, in der sie in diesem Kapitel behandelt werden.
414
3.1 Symboltabellen
Wir werden beim Besprechen der Algorithmen und Implementierungen ausführlicher auf ihre jeweiligen Eigenschaften eingehen, aber die kurzen Charakterisierungen in dieser Tabelle werden Ihnen helfen, einen groben Überblick zu behalten, wenn Sie sich näher damit beschäftigen. Fazit ist, dass wir es mit mehreren schnellen Symboltabellen-Implementierungen zu tun haben, die sehr erfolgreich in unzähligen Anwendungen eingesetzt werden bzw. werden können.
Tabelle 3.9
Für und Wider von Symboltabellen-Implementierungen
Zugrunde liegende Datenstruktur
Implementierung
Für
Wider
Verkettete Liste (sequenzielle Suche)
SequentialSearchST
am besten für kleine Suchbäume
zu langsam für große Suchbäume
Geordnetes Array (Binärsuche)
BinarySearchST
optimales Suchen und Speichern, ordnungsbasierte Operationen
langsames Einfügen
Binärer Suchbaum (BST)
BST
leicht zu implementieren, ordnungsbasierte Operationen
keine Garantien Speicher für Referenzen
Balancierter binärer Suchbaum
RedBlackBST
optimales Suchen und Einfügen, ordnungsbasierte Operationen
Speicher für Referenzen
Hashtabelle
SeparateChainingHashST LinearProbingHashST
schnelles Suchen/ Einfügen für allgemeine Datentypen
benötigt Hash für jeden Typ keine ordnungsbasierten Operationen Speicher für Referenzen/leer
415
3
Suchen
3.1
Fragen und Antworten
Frage: Warum verwenden wir für die Symboltabellen nicht einen Datentyp Item, der Comparable implementiert – so wie wir es für Vorrangwarteschlangen in Abschnitt 2.4 gemacht haben –, anstatt mit separaten Schlüsseln und Werten zu arbeiten? Antwort: Auch dies wäre eine vertretbare Möglichkeit. Beide Ansätze veranschaulichen gangbare Wege, wie man Daten mit Schlüsseln assoziieren kann. Wir können implizit vorgehen, indem wir einen Datentyp erstellen, der den Schlüssel beinhaltet, oder explizit, indem wir die Schlüssel von den Werten trennen. Im Falle der Symboltabellen haben wir uns für eine Erläuterung anhand der Abstraktion durch assoziierte Felder entschieden. Beachten Sie außerdem, dass ein Client bei der Suche nur einen Schlüssel angibt, und nicht ein Schlüssel-Wert-Paar. Frage: Warum die Mühe mit equals()? Warum verwenden wir nicht einfach immer compareTo()? Antwort: Nicht alle Datentypen führen zu Schlüsselwerten, die leicht zu vergleichen sind, auch wenn das Anlegen einer Symboltabelle immer noch sinnvoll sein kann. Ein extremes Beispiel sind Bilder oder Songs, die als Schlüssel verwendet werden. Es gibt keinen natürlichen Weg, diese zu vergleichen, wir können sie jedoch (mit einigem Aufwand) auf Gleichheit testen. Frage: Warum dürfen Schlüssel nicht den Wert null annehmen? Antwort: Wir gehen davon aus, dass Key ein Object ist, da wir es benutzen, um compareTo() oder equals() aufzurufen. Aber ein Aufruf von a.compareTo(b) würde
eine NullPointerException auslösen, wenn a den Wert null hätte. Indem wir diese Möglichkeit ausschließen, können wir den Client-Code einfacher halten. Frage: Warum verwenden wir nicht eine Methode wie less(), die wir zum Sortieren verwendet haben? Antwort: Gleichheit spielt eine besondere Rolle in Symboltabellen, sodass wir auch eine Methode benötigen würden, um auf Gleichheit zu testen. Um nun nicht unnötig Methoden zu kreieren, die im Wesentlichen die gleiche Funktion haben, halten wir uns an die in Java vorgegebenen Methoden equals() und compareTo(). Frage: Warum deklarieren wir keys im Konstruktor von BinarySearchST nicht vor der Typumwandlung als Object[] (statt als Comparable[]) – also auf die gleiche Weise wie vals? Antwort: Gute Frage. Wenn Sie dies machen, erhalten Sie eine Ausnahme vom Typ ClassCastException, da Schlüssel vergleichbar, d.h. Comparable sein müssen (um sicherzustellen, dass Einträge in keys eine compareTo()-Methode haben). Deshalb ist es notwendig, keys als Comparable[] zu deklarieren. Wir könnten tiefer in
416
Fragen und Antworten
das Design von Programmiersprachen eintauchen, um nach den Gründen hierfür zu suchen, doch würde uns dies ein gutes Stück vom Thema abbringen. Halten wir einfach fest, dass wir in diesem Buch genau dieses Idiom (und nichts Komplizierteres) in jedem Code verwenden, der mit Comparable-Generics und Arrays arbeitet. Frage: Was ist, wenn wir mehrere Werte mit dem gleichen Schlüssel assoziieren müssen? Angenommen wir würden zum Beispiel in einer Anwendung Datumsangaben vom Typ Date als Schlüssel verwenden. Müssten wir dann nicht gleiche Schlüssel verarbeiten? Antwort: Vielleicht, vielleicht auch nicht. Sie können beispielsweise nicht zwei Züge gleichzeitig auf dem gleichen Gleis im Bahnhof einfahren lassen (sie könnten jedoch gleichzeitig auf verschiedenen Gleisen einfahren). Es gibt zwei Möglichkeiten, mit einer solchen Situation umzugehen: eine weitere Information hinzuziehen, um die Situation eindeutig zu machen, oder den einzelnen Wert durch eine Warteschlange (Queue) von Werten ersetzen, die alle den gleichen Schlüssel haben. Anwendungen hierzu werden in Abschnitt 3.5 beschrieben. Frage: Das Vorsortieren der Tabelle, wie in Abschnitt 3.1.7 besprochen, scheint eine gute Idee zu sein. Warum wird dies in eine Übung (Übung 3.1.12) ausgelagert? Antwort: Für manche Anwendungen mag dies tatsächlich eine gute Wahl sein. Doch Vorsicht: „Aus Bequemlichkeit“ eine langsame Einfügemethode zu einer Datenstruktur hinzuzufügen, die für schnelles Suchen entworfen wurde, ist eine PerformanceFalle. Ein ahnungsloser Client könnte abwechselnd Such- und Einfügeoperationen auf einer riesigen Tabelle ausführen und hätte plötzlich mit einer quadratischen Performance zu kämpfen. Da derartige Fallen beileibe nicht selten sind, sollte man den Spruch „Caveat emptor“1 unbedingt auch beim Einsatz von Software beachten, die von anderen entwickelt wurde – vor allem wenn die Schnittstellen zu breit angelegt sind. Akut wird es dann, wenn „aus Bequemlichkeit“ viele solcher Methoden, die zu Performance-Fallen führen können, aufgenommen wurden, während der Client unter Umständen davon ausgeht, dass alle Methoden effizient implementiert wurden. Ein Beispiel hierfür ist die Java-Klasse ArrayList (Übung 3.5.27).
1
Der Käufer muss aufpassen.
417
3
Suchen
3.1 1.
Schreiben Sie einen Client, der eine Symboltabelle erstellt, die alphabetische Notenangaben2 auf numerische Werte abbildet (siehe nachstehende Tabelle), anschließend von der Standardeingabe eine Liste dieser alphabetischen Noten einliest und dann den GPA (den Durchschnitt der Zahlen, die den alphabetischen Noten entsprechen) berechnet und ausgibt. A+
A
A-
B+
B
B-
C+
C
C-
D
F
4,33
4,00
3,67
3,33
3,00
2,67
2,33
2,00
1,67
1,00
0,00
2.
Entwickeln Sie eine Symboltabellen-Implementierung ArrayST, die ein (ungeordnetes) Array als Datenstruktur verwendet, um unsere grundlegende Symboltabellen-API zu implementieren.
3.
Entwickeln Sie eine Symboltabellen-Implementierung OrderedSequentialSearchST, die eine geordnete verkettete Liste als Datenstruktur verwendet, um unsere geordnete Symboltabellen-API zu implementieren.
4.
Entwickeln Sie die abstrakten Datentypen Time und Event, mit denen Sie, wie auf Seite 395 veranschaulicht, Daten verarbeiten können.
5.
Implementieren Sie size(), delete() und keys() für SequentialSearchST.
6.
Formulieren Sie für FrequencyCounter die Anzahl der Aufrufe von put() und get() als Funktion der Anzahl W der Wörter und der Anzahl D der verschiedenen Wörter in der Eingabe.
7.
Wie hoch ist die durchschnittliche Anzahl an verschiedenen Schlüsseln, die FrequencyCounter in N zufälligen, nicht-negativen Integern kleiner als 1000 für N=10, 102, 103, 104, 105 und 106 finden wird?
8.
Wie lautet das am häufigsten verwendete Wort in A Tale of Two Cities, das zehn Buchstaben oder mehr umfasst?
9.
Ergänzen Sie FrequencyCounter um Code, mit dem Sie den letzten Aufruf von put() nachvollziehen können. Geben Sie das zuletzt eingefügte Wort aus sowie die Anzahl der Wörter, die im Eingabestrom vor dieser Einfügung verarbeitet wurden. Führen Sie Ihr Programm für tale.txt mit minimalen Zeichenlängen von 1, 8 und 10 durch.
10.
Wie sieht das Ablaufprotokoll für das Einfügen der Schlüssel E A S Y Q U E S T I O N in eine anfänglich leere SequentialSearchST-Tabelle aus? Wie viele Vergleiche werden benötigt?
2
418
Allgemeine Übungen
Anmerkung des Übersetzers: In den Vereinigten Staaten werden im Bildungswesen als Noten Buchstaben vergeben. Um über mehrere Fächer einen Durchschnittswert (GPA für Grade Point Average) zu ermitteln, wird jedem Buchstaben ein Zahlenwert zugeordnet.
Allgemeine Übungen
11.
Wie sieht das Ablaufprotokoll für das Einfügen der Schlüssel E A S Y Q U E S T I O N in eine anfänglich leere BinarySearchST-Tabelle aus? Wie viele Vergleiche werden benötigt?
12.
Schreiben Sie BinarySearchST so um, dass anstelle zweier paralleler Arrays nur ein Array von Item-Objekten, die Schlüssel und Werte enthalten, verwaltet wird. Fügen Sie einen Konstruktor hinzu, der ein Array von Item-Werten als Argument übernimmt und das Array mit Mergesort sortiert.
13.
Welche der Symboltabellen-Implementierungen in diesem Abschnitt würden Sie für eine Anwendung verwenden, die 103 put()-Operationen und 106 get()-Operationen in zufälliger Reihenfolge ausführt? Begründen Sie Ihre Antwort.
14.
Welche der Symboltabellen-Implementierungen in diesem Abschnitt würden Sie für eine Anwendung verwenden, die 106 put()-Operationen und 103 get()-Operationen in zufälliger Reihenfolge ausführt? Begründen Sie Ihre Antwort.
15.
Angenommen, ein BinarySearchST-Client hat 1000-mal mehr Suchoperationen als Einfügeoperationen. Schätzen Sie, wie viel Prozent der Gesamtzeit für die Einfügeoperationen aufgewendet werden muss, wenn die Anzahl der Suchoperationen 103, 106 und 109 beträgt.
16.
Implementieren Sie die delete()-Methode für BinarySearchST.
17.
Implementieren Sie die floor()-Methode für BinarySearchST.
18.
Beweisen Sie, dass die rank()-Methode von BinarySearchST korrekt ist.
19.
Schreiben Sie FrequencyCounter so um, dass alle Werte mit der höchsten Häufigkeit ausgegeben werden (nicht nur einer). Hinweis: Verwenden Sie eine Queue.
20.
Vervollständigen Sie den Beweis zu Satz B (zeigen Sie, dass er für alle Werte von N gilt). Hinweis: Beginnen Sie mit dem Beweis, dass C(N) monotonisch ist: C (N ) ≤ C (N +1) für alle N > 0.
419
3
Suchen
3.1
Knifflige Aufgaben
21.
Speicherbedarf: Vergleichen Sie den Speicherbedarf von BinarySearchST mit dem Speicherbedarf von SequentialSearchST für N Schlüssel-Wert-Paare und die in Abschnitt 1.4 formulierten Annahmen. Rechnen Sie nicht mit dem Speicherplatz für die Schlüssel und Werte, sondern nur mit den Verweisen darauf. Gehen Sie bei BinarySearchST davon aus, dass die Arraygröße veränderbar ist, sodass das Array zu 25 bis 100 Prozent voll ist.
22.
Selbstorganisierende Suche: Eine selbstorganisierende Suche ist ein Algorithmus, der die Elemente so umorganisiert, dass die Elemente, auf die am häufigsten zugegriffen wird, möglichst früh gefunden werden. Schreiben Sie Ihre Suchimplementierung für Übung 3.1.2 so um, dass die folgende Aktion bei jeder erfolgreichen Suche ausgeführt wird: Setzen Sie das gefundene Schlüssel-WertPaar an den Anfang der Liste und verschieben Sie alle Paare zwischen dem Anfang der Liste und der leeren Position um eine Position nach rechts. Dieses Verfahren wird Move-to-front-Heuristik (im Deutschen: Nach-vorn-verschieben-Heuristik) genannt.
23.
Analyse der Binärsuche: Beweisen Sie, dass die maximale Anzahl der Vergleiche für eine Binärsuche in einer Tabelle der Größe N genau der Anzahl von Bits in der Binärdarstellung von N entspricht, da die Operation, ein Bit nach rechts zu ⎢ N / 2⎦ ⎥ umverschieben, die Binärdarstellung von N in die Binärdarstellung von ⎣ wandelt.
24.
Interpolationssuche: Angenommen, es können arithmetische Operationen auf die Schlüssel ausgeführt werden (bei denen es sich beispielsweise um Werte vom Typ Double oder Integer handeln kann). Schreiben Sie eine Version der Binärsuche, die den Prozess nachahmt, relativ weit vorne in einem Wörterbuch zu suchen, wenn das Wort mit einem Buchstaben ziemlich am Anfang des Alphabets beginnt. Speziell sollten Sie, wenn kx der gesuchte Schlüsselwert, klo der Schlüsselwert des ersten Schlüssels in der Tabelle und khi der Schlüsselwert des letzten Schlüssels ist, zuerst ⎢ ⎣( k x − k lo ) / ( k hi − k lo )⎥ ⎦-weise die Tabelle durchsuchen und sie nicht halbieren. Testen Sie Ihre Implementierung gegen BinarySearchST für FrequencyCounter unter Verwendung von SearchCompare.
25.
Software-Caching: Da die Standardimplementierung von contains() die Methode get() aufruft, löst die innere Schleife von FrequencyCounter if (!st.contains(word)) st.put(word, 1); else st.put(word, st.get(word) + 1);
zwei oder drei Suchen nach dem gleichen Schlüssel aus. Damit klarer ClientCode wie dieser nicht zu Lasten der Effizienz geht, können wir eine Technik einsetzen, die als Software-Caching bezeichnet wird. Dabei speichern wir die Position des Schlüssels, auf den als Letztes zugegriffen wurde, in einer Instanzvariablen. Arbeiten Sie diese Idee in SequentialSearchST und BinarySearchST ein.
420
Knifflige Aufgaben
26.
Häufigkeitszählung aus einem Wörterbuch: Schreiben Sie FrequencyCounter so um, dass der Client den Namen einer Wörterbuchdatei als Argument übernimmt, zählt, wie häufig diejenigen Wörter aus der Standardeingabe vorkommen, die auch in der Datei stehen, und anschließend die Wörter zusammen mit ihren Häufigkeiten in zwei Tabellen ausgibt: eine sortiert nach der Häufigkeit, die andere sortiert nach der Reihenfolge, in der sie in der Wörterbuchdatei gefunden wurden.
27.
Kleine Tabellen: Angenommen, ein BinarySearchST-Client hat S Suchoperationen und N verschiedene Schlüssel. Geben Sie die Wachstumsordnung von S an, sodass die Kosten für die Erstellung der Tabelle die gleichen sind wie für alle Suchläufe.
28.
Geordnete Einfügungen: Ändern Sie BinarySearchST so ab, dass das Einfügen eines Schlüssels, der größer ist als alle anderen Schlüssel in der Tabelle, konstante Zeit benötigt (sodass das Erstellen einer Tabelle durch Aufrufen von put() auf geordnete Schlüssel lineare Zeit benötigt).
29.
Testclient: Schreiben Sie einen Testclient TestBinarySearch.java, den Sie zum Testen der im Text erwähnten Implementierungen von min(), max(), floor(), ceiling(), select(), rank(), deleteMin(), deleteMax() und keys() einsetzen. Beginnen Sie mit dem Testclient in Listing 3.1. Fügen Sie Code ein, um je nach Bedarf zusätzliche Befehlszeilenargumente zu übernehmen.
30.
Verifizierung: Fügen Sie assert-Anweisungen in BinarySearchST ein, um Algorithmusinvarianten und Datenstrukturintegrität nach jeder Einfügung und jeder Löschung zu prüfen. So sollte beispielsweise jeder Index i immer gleich rank(select(i)) sein und das Array sollte immer sortiert sein.
421
3
Suchen
3.1
Experimente
31.
Performance: Schreiben Sie ein Performance-Testprogramm, das mit put() eine Symboltabelle füllt, dann get() so verwendet, dass jeder Schlüssel in der Tabelle im Durchschnitt zehnmal gefunden wird und es ungefähr die gleiche Anzahl an erfolglosen Suchen gibt, dies mehrere Male für zufällige Folgen von Schlüsseln wiederholt, die aus Zeichenketten verschiedener Längen von 2 bis 50 Zeichen bestehen, dabei für jeden Lauf die Ausführungszeit misst und zum Schluss die durchschnittlichen Laufzeiten ausgibt oder grafisch darstellt.
32.
Extremfälle: Schreiben Sie ein Extremfall-Testprogramm, das die Methoden unserer geordneten Symboltabellen-API auf schwierige oder pathologische Fälle anwendet, wie sie in der Praxis vorkommen könnten. Einfache Beispiele wären z.B. bereits geordnete Schlüsselfolgen, Schlüsselfolgen in umgekehrter Reihenfolge, Schlüsselfolgen, in denen alle Schlüssel gleich sind, und Schlüssel, die nur aus zwei verschiedenen Werten bestehen.
33.
Testprogramm für die selbstorganisierende Suche: Schreiben Sie ein Testprogramm für Implementierungen mit selbstorganisierender Suche (Übung 3.1.22). Das Testprogramm soll mit get() eine Symboltabelle mit N Schlüsseln füllen und dann 10N erfolgreiche Suchen gemäß einer vordefinierten Wahrscheinlichkeitsverteilung ausführen. Verwenden Sie dieses Testprogramm, um die Laufzeit Ihrer Implementierung aus Übung 3.1.22 mit BinarySearchST für N=103, 104, 105 und 106 zu vergleichen – wobei Sie von einer Wahrscheinlichkeitsverteilung ausgehen, bei der die Suche nach dem it-kleinsten Schlüssel mit einer Wahrscheinlichkeit von 1/2i erfolgreich ist.
34.
Zipf’sches Gesetz: Führen Sie die vorherige Übung für eine Wahrscheinlichkeitsverteilung durch, bei der die Suche nach dem it-kleinsten Schlüssel mit der Wahrscheinlichkeit 1/(iHN) erfolgreich ist (HN ist hier eine Harmonische Zahl, siehe Tabelle 1.64 in Abschnitt 1.4). Diese Verteilung wird Zipf’sches Gesetz genannt. Vergleichen Sie die Move-to-front-Heuristik mit der optimalen Anordnung für die Verteilungen in der vorherigen Übung, die darin besteht, die Schlüssel in aufsteigender Reihenfolge (bzw. absteigender Reihenfolge ihrer erwarteten Häufigkeit) zu halten.
35.
Performance-Validierung I: Führen Sie Verdopplungstests durch, die die ersten N Wörter von A Tale of Two Cities für verschiedene Werte von N verwenden, um die Hypothese zu testen, dass die Laufzeit von FrequencyCounter quadratisch ist, wenn SequentialSearchST für die Symboltabelle verwendet wird.
36.
Performance-Validierung II: Erklären Sie, warum die Performance von BinarySearchST und SequentialSearchST für FrequencyCounter sogar besser ist als von
der Analyse vorhergesagt.
422
Experimente
37.
put/get-Verhältnis: Bestimmen Sie empirisch das Verhältnis der Zeit, die BinarySearchST für put()-Operationen aufwendet, zu der Zeit, die die Implementierung
für get()-Operationen aufwendet, wenn FrequencyCounter verwendet wird, um die Häufigkeit von Werten in einer Million zufälligen M-Bit int-Werten zu finden, für M = 10, 20 und 30. Beantworten Sie die gleiche Frage für tale.txt und vergleichen Sie die Ergebnisse. 38.
Grafiken der amortisierten Kosten: Entwickeln Sie FrequencyCounter, SequentialSearchST und BinarySearchSt so weiter, dass Sie Grafiken wie die in diesem Abschnitt erzeugen können, die die Kosten jeder put()-Operation während der Berechnung zeigen.
39.
Tatsächliche Zeitmessungen: Lassen Sie den FrequencyCounter-Client mithilfe von Stopwatch und StdDraw eine Grafik aufbauen, in der auf der x-Achse die Anzahl der Aufrufe von get() oder put() und auf der y-Achse die Gesamtausführungszeit aufgetragen wird. Zeichnen Sie nach jedem Aufruf einen Punkt für die kumulierte Zeit. Führen Sie Ihr Programm für A Tale of Two Cities und unter Verwendung von SequentialSearchST aus. Führen Sie es dann noch einmal unter Verwendung von BinarySearchST aus und diskutieren Sie die Ergebnisse. Hinweis: Große Sprünge in der Kurve lassen sich eventuell durch Caching erklären, was jedoch über den Rahmen dieser Frage hinausgeht.
40.
Übergang zur Binärsuche: Finden Sie die Werte von N, für die die Binärsuche in einer Symboltabelle der Größe N 10-, 100- und 1000-mal schneller ist als die sequenzielle Suche. Schätzen Sie die Werte mittels Analyse und bestätigen Sie sie experimentell.
41.
Übergang zur Interpolationssuche: Finden Sie die Werte von N, für die die Interpolationssuche in einer Symboltabelle der Größe N 1-, 2- und 10-mal schneller ist als die Binärsuche, unter der Voraussetzung, dass die Schlüssel zufällige 32-BitInteger sind (Übung 3.1.24). Schätzen Sie die Werte mittels Analyse und bestätigen Sie sie experimentell.
423
3
Suchen
3.2
Binäre Suchbäume
In diesem Abschnitt untersuchen wir eine SymbolWurzel tabellen-Implementierung, die die Flexibilität des eine linke Referenz Einfügens in eine verkettete Liste mit der Effizienz ein Teilbaum der Suche in einem geordneten Array kombiniert. Vor allem die Verwendung von zwei Referenzen pro rechter Knoten (anstelle einer Referenz pro Knoten, wie sie in Kindknoten der Wurzel verketteten Listen zu finden ist) führt zu einer äußerst null-Referenzen effizienten Symboltabellen-Implementierung, die auf Abbildung 3.8: Aufbau eines Binärbaums der Datenstruktur eines binären Suchbaums (Binary Search Tree, BST) basiert. Diese Implementierung gehört zu den wichtigsten Algorithmen der Informatik. Zum Einstieg sollten wir erst einmal die wichtigsten Begriffe definieren. Wir arbeiten mit Datenstrukturen, die aus Knoten bestehen, die über Referenzen (bzw. Kanten) miteinander verbunden sind. Diese Referenzen sind entweder null oder verweisen auf andere Knoten. Für einen binären Baum gilt die Beschränkung, dass auf jeden Knoten nur ein anderer Knoten zeigen darf, der als Elternknoten bezeichnet wird (außer dem sogenannten Wurzel-Knoten, auf den kein anderer Knoten zeigt). Außerdem gehen von jedem Knoten genau zwei Referenzen aus, die als rechte und linke Referenz bezeichnet werden und auf das rechte bzw. linke Kind zeigen. Wenngleich Referenzen auf Knoten zeigen, können wir es auch so sehen, dass jede Referenz auf einen binären Baum zeigt, dessen Wurzel der Knoten ist, von dem der Verweis ausgeht. Demzufolge können wir einen binären Baum entweder als null-Referenz definieren oder als Knoten mit einer linken und einer rechten Referenz, die jeweils auf (disjunkte) Teilbäume verweisen, die ihrerseits ebenfalls binäre Bäume sind. Darüber hinaus gilt für binäre Suchbäume, dass jeder Knoten über einen Schlüssel und einen Wert verfügt und einer vorgegebenen Ordnung unterliegt, um effizientes Suchen zu unterstützen. Bei der grafischen Darstellung binärer Suchbäume Elternknoten von A und R Schlüssel linke S geben wir in den Knoten die Schlüssel an und Referenz E X sprechen davon, dass „A der linke Kindknoten von E 9 A R Wert, der von E ist“, d.h., wir assoziieren Knoten mit ihren mit R verH C Schlüsseln. Linien, die die Knoten verbinden, bunden ist stellen die Referenzen dar und den Wert zu Schlüssel Schlüssel einem Schlüssel geben wir in Schwarz neben kleiner als E größer als E den Knoten an (wobei wir je nach Kontext den Abbildung 3.9: Aufbau eines binären SuchWert auch weglassen). Die Referenzen eines baums jeden Knotens verbinden diesen mit darunterliegenden Knoten, abgesehen von den null-Referenzen, bei denen es sich um kurze Endsegmente handelt. Wie gehabt, verwenden unsere Beispiele die einbuchstabigen Schlüssel, die von unserem Testclient erzeugt werden.
424
3.2 Binäre Suchbäume
Definition: Binärer Suchbaum Ein binärer Suchbaum (Binary Search Tree, BST ) zeichnet sich dadurch aus, dass jeder Knoten über einen Comparable-Schlüssel verfügt (sowie einen damit verbundenen Wert) und die Bedingung erfüllt wird, dass der Schlüssel eines jeden Knotens größer ist als die Schlüssel in allen Knoten im linken Teilbaum dieses Knotens und kleiner als die Schlüssel in allen Knoten im rechten Teilbaum dieses Knotens.
3.2.1 Grundlegende Implementierung Algorithmus 3.3 definiert die BST-Datenstruktur, mit deren Hilfe wir in diesem Abschnitt die geordnete Symboltabellen-API implementieren. Sehen wir uns also zunächst einmal die Definition dieser klassischen Datenstruktur an und der damit verbundenen typischen Implementierungen der Methoden get() (Suchen) und put() (Einfügen).
Repräsentation Für die Knoten der binären Suchbäume definieren wir eine Knotenanzahl N 8 S 1 private innere Klasse, wie wir es für verkettete Listen getan 6 E X haben. Jeder Knoten enthält einen Schlüssel, einen Wert, eine 3 2 A R 2 linke Referenz, eine rechte Referenz und die Anzahl der 1 H 1 C Knoten (wo wichtig, geben wir diesen Wert in den grafiM schen Darstellungen in Rot über den Knoten an). Die linke Referenz zeigt auf einen binären Suchbaum für Elemente A C E H M R S X mit kleineren Schlüsseln und die rechte Referenz auf einen binären Suchbaum für Elemente mit größeren Schlüsseln. 8 2 E 5 Die Instanzvariable N gibt die Anzahl der Knoten im TeilR baum eines Knotens an. Dieses Feld vereinfacht, wie Sie 1 C 2 2 A H 1 bald sehen werden, die Implementierung etlicher Opera1 X M S tionen für geordnete Symboltabellen. Die private Methode size() aus Algorithmus 3.3 wird so implementiert, dass A C E H M R S X null-Referenzen den Wert 0 zugewiesen bekommen. Wenn wir zudem sicherstellen, dass die Invariante Abbildung 3.10: Zwei binäre size(x) = size(x.left) + size(x.right) + 1
Suchbäume, die dieselbe Menge von Schlüsseln repräsentieren
für jeden Knoten x im Baum gilt, dann können wir size() zur Berechnung des Feldes N nutzen.
425
3
Suchen
Ein binärer Suchbaum repräsentiert eine Menge von Schlüsseln (und die damit verbundenen Werte) – wobei es für dieselbe Menge von Schlüsseln grundsätzlich viele verschiedene binäre Suchbäume gibt, die diese Menge repräsentieren. Wenn wir die Schlüssel in einem Binärsuchbaum so projizieren, dass alle Schlüssel im linken Teilbaum jedes Knotens links des Schlüssels im Knoten erscheinen und alle Knoten im rechten Teilbaum jedes Knotens rechts des Schlüssels im Knoten, erhalten wir die Schlüssel immer in sortierter Reihenfolge. Die Flexibilität, die sich dadurch ergibt, dass viele binäre Suchbäume diese sortierte Reihenfolge repräsentieren, nutzen wir für die Entwicklung effizienter Algorithmen zum Erstellen und Verwenden von binären Suchbäumen.
Suchen Wie immer, wenn wir nach einem Schlüssel in einer Symboltabelle suchen, erhalten wir eines von zwei möglichen Ergebnissen. Ist der Knoten mit dem gesuchten Schlüssel in der Tabelle enthalten, war die Suche erfolgreich und wir liefern den damit verbundenen Wert zurück. Andernfalls haben wir es mit einer erfolglosen Suche zu tun (und liefern null zurück). Ein rekursiver Algorithmus zur Schlüsselsuche in einem binären Suchbaum ergibt sich direkt aus der rekursiven Struktur: Wenn der Baum leer ist, war die Suche erfolglos; wenn der Suchschlüssel gleich dem Schlüssel an der Wurzel ist, war die Suche erfolgreich; andernfalls suchen wir (rekursiv) in dem entsprechenden Teilbaum, und zwar links, wenn der Suchschlüssel kleiner ist, bzw. rechts, wenn er größer ist. Die rekursive get()-Methode in Listing 3.10 ist eine direkte Umsetzung dieses Algorithmus. Sie bekommt einen Knoten (Wurzel eines Teilbaums) als erstes Argument und einen Schlüssel als zweites Argument, wobei mit der Wurzel des Baums und dem Suchschlüssel begonnen wird. Für den Code gilt die Invariante, dass nur der Teilbaum, der sich unterhalb des aktuellen Knotens befindet, einen Knoten enthalten kann, dessen Schlüssel gleich dem Suchschlüssel ist. So wie sich die Größe des Intervalls bei der Binärsuche nach jeder Iteration ungefähr halbiert, verkleinert sich auch die Größe des Teilbaums am aktuellen Knoten bei der Suche in einem Binärsuchbaum, wenn wir den Baum abwärts wandern (im Idealfall um ungefähr die Hälfte, aber mindestens um eins). Die Suche ist beendet, wenn ein Knoten gefunden wird, der den Suchschlüssel enthält (erfolgreiche Suche), oder wenn der aktuelle Teilbaum leer ist (erfolglose Suche). Ausgehend vom oberen Ende des Baums wird während der Suche an jedem Knoten ein rekursiver Aufruf für einen der Kindknoten gestartet, sodass die Suche einen Pfad durch den Baum definiert. Bei einer erfolgreichen Suche endet der Pfad bei dem Knoten, der den Schlüssel enthält. Bei einer erfolglosen Suche endet der Pfad an einer null-Referenz.
426
3.2 Binäre Suchbäume
Listing 3.9: Symboltabelle eines binären Suchbaums (Algorithmus 3.3) public class BST { private Node root; // Wurzel des Binärsuchbaums private class Node { private Key key; // Schlüssel private Value val; // damit verbundener Wert private Node left, right; // Referenzen zu den Teilbäumen private int N; // Anzahl Knoten im hier // beginnenden Teilbaum public Node(Key key, Value val, int N) { this.key = key; this.val = val; this.N = N; } } public int size() { return size(root);
}
private int size(Node x) { if (x == null) return 0; else return x.N; } public Value get(Key key) // Siehe Listing 3.10 auf Seite 428. public void put(Key key, Value val) // Siehe Listing 3.10 auf Seite 428. // Siehe Listing 3.11 auf Seite 436 für // ceiling(). // Siehe Listing 3.12 auf Seite 438 für // Siehe Listing 3.13 auf Seite 440 für // deleteMax(). // Siehe Listing 3.15 auf Seite 442 für
min(), max(), floor() und select() und rank(). delete(), deleteMin() und keys().
}
Diese Implementierung der geordneten Symboltabellen-API verwendet einen binären Suchbaum, der aus Node-Objekten besteht, die jeweils einen Schlüssel, einen damit verbundenen Wert, zwei Referenzen und eine Variable N für die Anzahl der Knoten enthalten. Jeder Node ist die Wurzel eines Teilbaums mit N Knoten, deren jeweilige linke Referenz auf einen Node zeigt, der die Wurzel eines Teilbaums mit kleineren Schlüsseln ist, und deren jeweilige rechte Referenz auf einen Node zeigt, der die Wurzel eines Teilbaums mit größeren Schlüsseln ist. Die Instanzvariable root zeigt auf den Node an der Wurzel des binären Suchbaums (der alle Schlüssel und die damit verbundenen Werte in der Symboltabelle enthält). Implementierungen weiterer Methoden werden im Laufe dieses Abschnitts nachgereicht.
427
3
Suchen
Listing 3.10: Suchen und Einfügen in binären Suchbäumen (Fortführung von Algorithmus 3.3) public Value get(Key key) { return get(root, key); } private Value get(Node x, Key key) { // Liefert den Wert, der mit dem Schlüssel verbunden ist. Der // Schlüssel wird im Teilbaum unter Knoten x gesucht; // Liefert null, wenn es den Schlüssel im Teilbaum unter Knoten x // nicht gibt. if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp < 0) return get(x.left, key); else if (cmp > 0) return get(x.right, key); else return x.val; } public void put(Key key, Value val) { // Sucht Schlüssel. Wird der Schlüssel gefunden, so wird der Wert // aktualisiert. // Ist der Schlüssel neu, wird die Tabelle vergrößert. root = put(root, key, val); } private Node put(Node x, Key key, Value val) { // Ändert den Wert des Schlüssels zu val, wenn Schlüssel im Teilbaum // unter Knoten x. // Andernfalls wird ein neuer Knoten im Teilbaum hinzugefügt, der den // Schlüssel mit val verbindet. if (x == null) return new Node(key, val, 1); int cmp = key.compareTo(x.key); if (cmp < 0) x.left = put(x.left, key, val); else if (cmp > 0) x.right = put(x.right, key, val); else x.val = val; x.N = size(x.left) + size(x.right) + 1; return x; }
Diese Implementierungen von get() und put() für die Symboltabellen-API sind typisch für rekursive BST-Methoden und bilden das Grundmodell für etliche weitere Implementierungen, die wir im Laufe dieses Kapitels noch betrachten werden. Beide Methoden stellen nicht nur funktionierenden Code dar, sondern können auch als Beweis mittels vollständiger Induktion der Induktionsbehauptung aus den Anfangskommentaren verstanden werden.
428
3.2 Binäre Suchbäume
erfolgreiche Suche nach R
erfolglose Suche nach T
S E A
R H
C
S
X R ist kleiner als S, M deshalb links suchen
schwarze Knoten könnten mit dem Suchschlüssel übereinstimmen E
E R
T ist größer als S, M deshalb rechts suchen
S
X E
R H
graue Knoten stimmen nicht mit dem Suchschlüssel überein S
M
R ist größer als E,
deshalb rechts suchen E
R C
R H M
X
A
X
A C
H
C
S
A C
X
A
H M
T ist kleiner als X, deshalb links suchen
Referenz ist null, desha ist T nicht im Baum (erfolglose Suche)
R wurde gefunden (erfolgreiche Suche), deshalb Wert zurückgeben
Abbildung 3.11: Erfolgreiche (links) und erfolglose Suche (rechts) in einem BST.
Einfügen Der Suchen-Code aus Algorithmus 3.3 (Listing L einfügen 9 S 3.9 und Listing 3.10) ist fast so einfach wie die 7 E X binäre Suche. Diese Einfachheit ist ein wesent4 A R liches Merkmal von binären Suchbäumen. Ein 3 H C noch viel wichtigeres Merkmal dieser Bäume ist, 2 M dass das Einfügen nicht viel schwerer zu impleP Suche nach L mentieren ist als das Suchen. Tatsache ist, dass endet an dieser eine Suche nach einem Schlüssel, der nicht im null-Referenz S Baum ist, bei einer null-Referenz endet, und wir E X lediglich diese Referenz durch einen neuen KnoA R ten ersetzen müssen, der den Schlüssel enthält H C (siehe Diagramm in Abbildung 3.12). Die rekurM sive put()-Methode aus Algorithmus 3.3 erle1 erzeugt einen P L neuen Knoten digt diese Aufgabe mithilfe einer Logik, die ähn10 lich der ist, die wir für die rekursive Suche S 8 verwendet haben: Wenn der Baum leer ist, lieE X 5 fern wir einen neuen Knoten zurück, der SchlüsA R 4 sel und Wert enthält; wenn der Suchschlüssel H C 3 kleiner ist als der Schlüssel an der Wurzel, weiM Setzt die Referenzen sen wir der linken Referenz das Ergebnis zu, das P neu und erhöht die L das Einfügen des Schlüssels in den linken TeilZähler auf dem Weg nach oben baum liefert; andernfalls weisen wir der rechten Abbildung 3.12: Einfügen in einen binären SuchReferenz das Ergebnis zu, das das Einfügen des baum Schlüssels in den rechten Teilbaum liefert.
429
3
Suchen
Rekursion Es lohnt sich, etwas Zeit zu investieren, um die Chronologie und Dynamik dieser rekursiven Implementierungen besser zu verstehen. Sie können es sich so vorstellen, dass der Code vor den rekursiven Aufrufen auf dem Weg nach unten im Baum ausgeführt wird: Er vergleicht den gegebenen Schlüssel mit dem Schlüssel eines jeden Knotens und verzweigt je nach Ergebnis nach rechts oder links. Entsprechend wird der Code nach den rekursiven Aufrufen auf dem Weg nach oben im Baum ausgeführt. Für get() sind damit eine Reihe von Rückgabeanweisungen verbunden, aber für put() bedeutet dies, dass auf dem Suchpfad die Referenzen von den Elternknoten zu ihrem Kindknoten neu gesetzt und die Zählvariablen auf dem Weg nach oben im Pfad inkrementiert werden müssen. In einfachen binären Suchbäumen gibt es nur eine einzige neue Referenz am Ende des Pfades und das Neusetzen der Referenzen auf dem Pfad nach oben ist so einfach wie das Testen, ob sich das Setzen vermeiden lässt. Die Zählvariable, die die Anzahl der Knoten angibt, müssten wir für jeden Knoten im Pfad eigentlich nur inkrementieren, doch wir wollen hier gleich allgemeinen Code verwenden, der die Variable für jeden Knoten auf eins plus der Summe der Knotenanzahl in den beiden untergeordneten Teilbäumen setzt. Weiter hinten in diesem und im folgenden Abschnitt werden wir leistungsfähigere Algorithmen kennenlernen, die das gleiche rekursive Schema verwenden und in der Lage sind, mehrere Referenzen im Suchpfad zu ändern – für diese ist der allgemeingültige Code zur Aktualisierung des Knotenzählers zwingend erforderlich. Elementare binäre Suchbäume werden oft durch nicht-rekursiven Code implementiert (Übung 3.2.12) – wir verwenden in unseren Implementierungen Rekursion, damit Sie sich leichter davon überzeugen können, dass der Code wie beschrieben funktioniert, und um die Grundlage für anspruchsvollere Algorithmen zu legen. Ein genaues Studium des Ablaufprotokolls für unseren Testclient (Abbildung 3.13) wird Ihnen ein Gefühl dafür geben, wie binäre Suchbäume wachsen. Neue Knoten werden an Stelle der null-Referenzen unten im Baum angehängt; andere Änderungen an der Baumstruktur gibt es nicht. Der erste Schlüssel wird zum Beispiel in die Wurzel eingefügt, in einem der Kindknoten der Wurzel der zweite Schlüssel und so weiter. Da jeder Knoten über zwei Referenzen verfügt, wächst der Baum eher in die Breite als nach unten. Außerdem werden nur die Schlüssel auf dem Pfad von der Wurzel bis zum gesuchten oder eingefügten Schlüssel untersucht, sodass die Anzahl der untersuchten Schlüssel in Relation zu der Gesamtzahl der Schlüssel immer kleiner wird, je stärker der Baum anwächst.
430
3.2 Binäre Suchbäume
Schlüssel Wert
S
0
Schlüssel Wert
S
A
S
8
E
X
A 8
R H
C E
S
1
geänderter Wert
E
S M
S A
2
9
auf die schwarzen Knoten wird bei der Suche zugegriffen
E A
E
X
A
R H
C
M S
R
3
E A
rote Knoten sind neu
R
P
S C
S E 10
E
4 A
R H
C R
C
X
A M
graue Knoten wurden nicht angefasst
P
S H
5
S
E A
R
6
L
H
C
11
X
A
R C
geänderter Wert
E
E H M
S L
6 E
A
P
R geänderter Wert
H
C
S 12 E
S X
7
E
E X
A
12
X
A
R C
H
R C
H
M L
P
Abbildung 3.13: BST-Ablaufprotokoll für den Testclient
431
3
Suchen
3.2.2 Analyse Die Laufzeiten der Algorithmen für binäre Suchbäume hängen von der Form des Baums ab, die wiederum von der Reihenfolge abhängt, in der die Schlüssel eingefügt werden. Im besten Fall könnte ein Baum mit N Knoten perfekt ausgeglichen sein: mit ∼ lg N Knoten zwischen der Wurzel und jeder nullReferenz. Im schlimmsten Fall könnten N Knoten auf dem Suchpfad liegen. Die Balance in typischen Bäumen ist in der Regel viel näher am besten als am schlechtesten Fall.
bester Fall
H S
C A
E
R
typischer Fall
X
S
E A
X R
H C Für viele Anwendungen trifft das folgende einfache Modell zu: Wir gehen davon aus, dass die Schlüssel (gleichmäßig) zufällig sind beziehungsweise dass sie in zufälliger ReihenA schlimmster Fall C folge eingefügt werden. Die Analyse dieses Modells geht auf E die Beobachtung zurück, dass binäre Suchbäume nach dem H gleichen Prinzip funktionieren wie Quicksort. Der Knoten an R der Wurzel des Baums entspricht dem ersten Trennelement S in Quicksort (keine Schlüssel links im Pfad sind größer und X keine Schlüssel rechts im Pfad sind kleiner) und die TeilAbbildung 3.14: Verschiebäume werden rekursiv erstellt, was der rekursiven Teilarraydene Ausprägungen eines binäsortierung von Quicksort entspricht. Diese Beobachtung führt ren Suchbaums uns zu folgender Analyse der Baumeigenschaften.
Satz C: Erfolgreiche Suchläufe in einem binären Suchbaum mit N zufälligen Schlüsseln benötigen im Durchschnitt ∼ ln N (ungefähr 1,39 lg N) Vergleiche. Beweis: Die Anzahl der Vergleiche für eine erfolgreiche Suche, die an einem gegebenen Knoten endet, beträgt 1 plus die Tiefe. Durch Addition der Tiefen aller Knoten erhalten wir eine Größe, die als interne Pfadlänge des Baums bezeichnet wird. Somit beträgt die gewünschte Größe 1 plus die durchschnittliche interne Pfadlänge des Binärsuchbaums, die wir mit dem gleichen Argument analysieren können, das wir für Satz K in Abschnitt 2.3 verwendet haben: Sei CN die gesamte interne Pfadlänge eines Binärsuchbaums, der durch Einfügen von N zufällig geordneten verschiedenen Schlüsseln erstellt wurde, so betragen die durchschnittlichen Kosten einer erfolgreichen Suche 1 + CN /N. Wir haben C0=C1=0 und für N>1 können wir eine Rekursionsgleichung schreiben, die direkt die rekursive Struktur des binären Suchbaums widerspiegelt: C N = N −1+ (C 0 +C N −1 ) / N + (C1 +C N −2 ) / N + ...(C N −1 −C 0 ) / N
Der Term N−1 berücksichtigt, dass die Wurzel die Pfadlänge aller anderen N−1 Knoten im Baum um 1 erhöht; der Rest des Ausdrucks berücksichtigt die Teilbäume, die alle gleich wahrscheinlich eine der N möglichen Größen haben können. Nach Umstellung der Terme ist diese Rekursion beinahe identisch zu unserer Lösung für Quicksort in Abschnitt 2.3 und wir können die Näherungslösung CN ∼ 2N ln N ableiten.
432
3.2 Binäre Suchbäume
Satz D: Einfügen und erfolgloses Suchen in einem binären Suchbaum, der aus N zufälligen Schlüsseln besteht, benötigen durchschnittlich ∼ 2 ln N (ungefähr 1,39 lg N ) Vergleiche. Beweis: Einfügen und erfolgloses Suchen benötigen im Durchschnitt einen Vergleich mehr als erfolgreiches Suchen. Dies lässt sich leicht durch Induktion beweisen (Übung 3.2.16 ).
Satz C besagt, dass wir damit rechnen sollten, dass die Kosten für die Suche nach zufälligen Schlüsseln in einem binären Suchbaum um ungefähr 39 Prozent höher liegen als die Kosten für die Binärsuche. Satz D besagt, dass sich die zusätzlichen Kosten lohnen, da die erwarteten Kosten für das Einfügen eines neuen Schlüssels ebenfalls logarithmisch sind – eine Eigenschaft, mit der die Binärsuche in einem geordneten Array nicht aufwarten kann, da dort die Anzahl der Arrayzugriffe, die für eine Einfügung benötigt wird, normalerweise linear ist. Wie im Falle von Quicksort ist die Standardabweichung für die Anzahl der Vergleiche bekannterweise niedrig, sodass diese Formeln mit zunehmenden N immer genauer werden.
Experimente Wie gut stimmt unser Zufallsschlüsselmodell mit der Situation überein, die man in realen Symboltabellen-Clients üblicherweise vorfindet? Wie gewohnt sollte diese Frage für konkrete praktische Anwendungen sorgfältig untersucht werden, da es große Schwankungen bei der Performance geben kann. Für viele Clients liefert das Modell aber, glücklicherweise, eine ziemlich gute Beschreibung der Binärsuchbäume. Bei unserer Beispieluntersuchung der Kosten der put()-Operationen von FrequencyCounter für Wörter der Länge 8 oder mehr sehen wir, dass sich die durchschnittlichen
Kosten – von 484 Arrayzugriffen oder Vergleichen pro Operation für BinarySearchST – durch Umstellung auf BST auf 13 reduzieren. Dies bestätigt die vom theoretischen Modell vorausgesagte logarithmische Performance. Umfangreichere Experimente für größere Eingaben sind in Tabelle 3.10 zusammengefasst. Gestützt auf Satz C und Satz D und unter Berücksichtigung des Fakts, dass die Operationen überwiegend aus Suchläufen in einer beinahe vollen Tabelle bestehen, lässt sich vorhersagen, dass die Kosten bei ungefähr dem Zweifachen des natürlichen Logarithmus der Tabellengröße liegen. Allerdings weist diese Vorhersage die folgenden Ungenauigkeiten auf:
Viele Operationen betreffen kleinere Tabellen. Die Schlüssel sind nicht zufällig. Die Tabellengröße kann zu klein sein, sodass die Annäherung 2 ln N nicht genau ist. Trotzdem ist diese Vorhersage, wie die Tabelle zeigt, für unsere FrequencyCounterTestfälle bis auf wenige Vergleiche genau. Die meisten Abweichungen lassen sich sogar durch eine verfeinerte Näherungsrechnung erklären (Übung 3.2.35).
433
3
Suchen
Abbildung 3.15: Typischer Binärsuchbaum aus 256 zufälligen Schlüsseln Maßstab um den Faktor 250 vergrößert, verglichen zu vorherigen Diagrammen
20
Kosten
13,9
0 Operationen
14350 Abbildung 3.16: Kosten für java FrequencyCounter 8 < tale.txt unter Verwendung von BST 0
Tabelle 3.10 Durchschnittliche Anzahl an Vergleichen pro put() – für FrequencyCounter unter Verwendung von BST tale.txt
434
Wörter insgesamt
Wörter verschieden
Alle Wörter
135635
8+ Buchstaben 10+ Buchstaben
leipzig1M.txt Vergleiche
Wörter insgesamt
Wörter verschieden
Modell
Konkret
10679
18,6
17,5
21191455
14350
5131
17,6
13,9
4582
2260
15,4
13,1
Vergleiche Modell
Konkret
534580
23,4
22,1
4239597
299593
22,7
21,4
1610829
165555
20,5
19,3
3.2 Binäre Suchbäume
3.2.3 Ordnungsbasierte Methoden und Löschen Binäre Suchbäume sind vor allem deshalb so beliebt, weil sie es dem Programmierer erlauben, die Schlüssel in einer geordneten Reihenfolge zu halten. Damit bilden sie eine hervorragende Basis für die Implementierung der zahlreichen Methoden unserer geordneten Symboltabellen-API (Tabelle 3.4), die es Clients erlauben, auf SchlüsselWert-Paare nicht nur über den Schlüssel, sondern auch über die relative Ordnung der Schlüssel zuzugreifen. Sehen wir uns nachfolgend die Implementierungen der verschiedenen Methoden unserer geordneten Symboltabellen-API an.
Minimum und Maximum Wenn die linke Referenz der Wurzel null ist, ist der kleinste Schlüssel in einem binären Suchbaum der Schlüssel an der Wurzel. Wenn die linke Referenz nicht null ist, ist der kleinste Schlüssel im binären Suchbaum der kleinste Schlüssel im Teilbaum unterhalb des Knotens, auf den die linke Referenz verweist. Diese Aussage ist sowohl eine Beschreibung der rekursiven min()-Methode aus Listing 3.11 als auch ein induktiver Beweis, dass sie den kleinsten Schlüssel im binären Suchbaum findet. Die Berechnung entspricht einer einfachen Iteration (links absteigen, bis eine null-Referenz erreicht wird), aber der Konsistenz wegen verwenden wir Rekursion. Wir könnten die rekursive Methode ein Key-Objekt anstelle eines Node-Objekts zurückliefern lassen, aber wir benötigen die Methode später noch dazu, auf das Node-Objekt zuzugreifen, das den kleinsten Schlüssel enthält. Die Suche nach dem größten Schlüssel erfolgt analog, nur dass wir uns auf dem Pfad rechts statt links halten.
Abrundung und Aufrundung Wenn ein gegebener Schlüssel key kleiner als der Schlüssel an der Wurzel eines binären Suchbaums ist, dann muss floor(key) (der größte Schlüssel im binären Suchbaum kleiner oder gleich key) im linken Teilbaum sein. Wenn key größer als der Schlüssel an der Wurzel ist, dann könnte floor(key)im rechten Teilbaum sein, aber nur, wenn es einen Schlüssel kleiner als oder gleich key im rechten Teilbaum gibt; wenn nicht (oder wenn key gleich dem Schlüssel an der Wurzel ist), dann ist der Schlüssel an der Wurzel floor(key). Auch diese Beschreibung dient nicht nur als Basis für die rekursive floor()-Methode, sondern auch als induktiver Beweis, dass die Methode das gewünschte Ergebnis berechnet. Wenn Sie in der Beschreibung rechts durch links austauschen (und kleiner durch größer) erhalten Sie ceiling().
floor(G) suchen
S E
X
A
R H
C
M
G ist kleiner als S, sodass floor(G) zur Linken sein muss
S E
X
A
R H
C
M
G ist größer als E, sodass floor(G) rechts sein könnte
S
E
X
A
R H
C
M floor(G)im linken Teilbaum ist null
S E A
Abbildung 3.17: Die floor()-Funktion berechnen
X R
Ergebnis H C M
435
3
Suchen
Listing 3.11: min, max, floor und ceiling in BST (Fortführung von Algorithmus 3.3) public Key min() { return min(root).key; } private Node min(Node x) { if (x.left == null) return x; return min(x.left); } public Key floor(Key key) { Node x = floor(root, key); if (x == null) return null; return x.key; } private Node floor(Node x, Key key) { if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp == 0) return x; if (cmp < 0) return floor(x.left, key); Node t = floor(x.right, key); if (t != null) return t; else return x; }
Jede Client-Methode ruft eine zugehörige private Methode auf, die eine zusätzliche Referenz (auf ein Node-Objekt) als Argument übernimmt und über die im Text beschriebene rekursive Prozedur null bzw. ein Node-Objekt zurückliefert, das den gewünschten Key enthält. Die Methoden max() und ceiling() entsprechen min() bzw. floor(), wobei rechts und links (sowie < und >) vertauscht werden.
Auswahl Die Auswahl (selection) in einem binären Suchbaum funktioniert analog dem Partitionierungsverfahren für Arrays, das wir in Abschnitt 2.5 untersucht haben. Zur Unterstützung genau dieser Operation speichern wir in dem BST-Knoten die Variable N, die die Anzahl der Schlüssel in dem Teilbaum dieses Knotens angibt. Angenommen wir suchen den Schlüssel mit dem Rang k (der Schlüssel, bei dem genau k andere Schlüssel im binären Suchbaum kleiner sind). Wenn die Anzahl der Schlüssel t im linken Teilbaum größer ist als k, suchen wir (rekursiv) nach dem Schlüssel mit dem Rang k im linken Teilbaum; wenn t gleich k ist, liefern wir den Schlüssel an der Wurzel zurück; und wenn t kleiner ist als k, suchen wir (rekursiv) im rechten Teilbaum nach dem Schlüssel mit dem Rang k − t − 1. Wie üblich, dient diese
436
3.2 Binäre Suchbäume
Beschreibung sowohl als Basis für die rekursive select()-Methode als auch als induktiver Beweis, dass die Methode korrekt arbeitet. sucht select(3), den Schlüssel von Rang 3
Knotenzähler N
S
6
E
X
A
R H
C
6 Schlüssel im linken
M
Teilbaum, sodass die Suche nach Schlüssel von Rang 3 links erfolgt S
E
2
X
A
R H
C
M 2 Schlüssel im linken
Teilbaum, sodass die Suche nach Schlüssel von Rang 3-2-1 = 0 rechts erfolgt
S
E A
X R
2
H
C
2 Schlüssel im linken M Teilbaum, sodass die Suche nach Schlüssel von Rang 0 links erfolgt
S E
X
A
R C
H
0 Schlüssel im linken Teilbaum und Suchen nach Schlüssel von Rang 0, deshalb H zurückliefern
M
Abbildung 3.18: Auswahl in einem BST
Rang Die inverse Methode rank(), die den Rang eines gegebenen Schlüssels zurückliefert, funktioniert analog: Wenn der gegebene Schlüssel gleich dem Schlüssel an der Wurzel ist, liefern wir die Anzahl der Schlüssel t im linken Teilbaum zurück; wenn der gegebene Schlüssel kleiner ist als der Schlüssel an der Wurzel, liefern wir den Rang des Schlüssels im linken Teilbaum (rekursiv berechnet) zurück; und wenn der gegebene Schlüssel größer als der Schlüssel an der Wurzel ist, liefern wir t plus eins (um den Schlüssel an der Wurzel mitzuzählen) plus dem Rang des Schlüssels im rechten Teilbaum (rekursiv berechnet) zurück.
437
3
Suchen
Listing 3.12: Auswahl und Rang im BST (Fortführung von Algorithmus 3.3) public Key select(int k) { return select(root, k).key; } private Node select(Node x, int k) { // Liefert das Node-Objekt, das den Schlüssel mit Rang k enthält. if (x == null) return null; int t = size(x.left); if (t > k) return select(x.left, k); else if (t < k) return select(x.right, k-t-1); else return x; } public int rank(Key key) { return rank(key, root);
}
private int rank(Key key, Node x) { // Liefert die Anzahl der Schlüssel kleiner als x.key im Teilbaum // mit der Wurzel x. if (x == null) return 0; int cmp = key.compareTo(x.key); if (cmp < 0) return rank(key, x.left); else if (cmp > 0) return 1 + size(x.left) + rank(key, x.right); else return size(x.left); }
Dieser Code implementiert die Methoden select() und rank() mittels des gleichen rekursiven Schemas, das wir bereits des Öfteren in diesem Kapitel verwendet haben. Zentrales Element des Codes ist die private size()-Methode, die zu Anfang dieses Abschnitts vorgestellt wurde und die die Anzahl der Teilbäume für jeden Knoten zurückliefert.
Das Minimum/Maximum löschen Die am schwersten zu implementierende Operation bei binären Suchbäumen ist die delete()-Methode, die ein Schlüssel-Wert-Paar aus der Symboltabelle entfernt. Als Aufwärmübung betrachten wir die Methode deleteMin() (die das Schlüssel-Wert-Paar mit dem kleinsten Schlüssel entfernt). Wie im Falle von put() setzen wir eine rekursive Methode auf, die eine Referenz auf ein Node-Objekt als Argument übernimmt und eine Referenz auf ein Node-Objekt zurückliefert. Auf diese Weise können wir Änderungen an dem Baum dadurch widerspiegeln, dass wir das Ergebnis der Referenz zuweisen, die als Argument verwendet wird. Für deleteMin() gehen wir nach links, bis wir ein Node-Objekt finden, das eine linke null-Referenz aufweist, und ersetzen dann die Referenz auf diesen Knoten durch dessen rechte Referenz (indem wir einfach in der rekursiven Methode die rechte Referenz zurückliefern). Der gelöschte Knoten, auf den jetzt keine Referenz mehr verweist, kann somit bei der Speicherbereinigung entfernt
438
3.2 Binäre Suchbäume
werden. Unser rekursiver Standardcode sorgt nach dem Löschen dafür, dass die entsprechende Referenz im Elternknoten gesetzt wird und er aktualisiert die Zähler für alle Knoten im Pfad bis zur Wurzel. Für deleteMax() gibt es eine analoge Methode.
Löschen
links gehen, bis die linke null-Referenz erreicht wird A
S X
E R H
C
M die rechte Referenz dieses Knotens zurückliefern E
S X
Auf ähnliche Weise können wir jeden Knoten löschen, der R A ein (bzw. kein) Kind hat. Aber wie können wir Knoten H C M löschen, die zwei Kindknoten haben? In diesem Fall sind verfügbar für die Speicherbereinigung zwei Referenzen aufzufangen, im Elternknoten ist aber nur Platz für einen. Eine Lösung für dieses Dilemma, erstmals Referenzen und Knotenzählung nach den rekursiven vorgeschlagen von T. Hibbard im Jahre 1962, besteht Aufrufen aktualisieren 7 darin, den Knoten x zu löschen, indem man ihn durch seiS 5 nen Nachfolger ersetzt. Da x einen rechten Kindknoten X E hat, ist sein Nachfolger der Knoten mit dem kleinsten R C Schlüssel in seinem rechten Teilbaum. Die Ersetzung H M bewahrt die Ordnung im Baum, da es keine Schlüssel zwischen x.key und dem Schlüssel des Nachfolgers gibt. Wir Abbildung 3.19: Das Minimum in können die Aufgabe, x durch seinen Nachfolger zu erset- einem BST löschen zen, in vier (!) einfachen Schritten erledigen:
Speichern Sie in t eine Referenz auf den zu löschenden Knoten x.
E löschen
zu löschender Knoten
Setzen Sie x so, dass x den Nachfolger min(t.right) referenziert.
Setzen Sie die linke Referenz von x (die null war) auf t.left (alle Schlüssel, die kleiner sind als der gelöschte
Schlüssel und sein Nachfolger).
X
A
R H
C
Setzen Sie die rechte Referenz von x (die auf den binären Suchbaum zeigen soll, der alle Schlüssel größer als x.key enthält) auf deleteMin(t.right), also die Referenz, die auf den binären Suchbaum weist, der alle Schlüssel enthält, die nach dem Löschen größer sind als x.key.
S
E
M
Suche nach Schlüssel E
t
S E R Nachfolger
H
C rechts gehen, dann links gehen, bis eine linke null-Referenz erreicht wird E x t.left
X
x
A
H
M
min(t.right)
S X deleteMin(t.right)
Unser rekursives Standardschema sorgt nach den rekursiR A M C ven Aufrufen dafür, die entsprechende Referenz im Eltern7 knoten zu setzen und die Knotenzähler in den Knoten S 5 auf dem Pfad zur Wurzel zu dekrementieren (wie zuvor H X aktualisieren wir die Werte der Knotenzähler, indem wir A R M aktualisiert C den Wert für jeden Knoten im Suchpfad auf eins plus der Referenzen und Summe der Anzahl seiner Kindknoten setzen). Dieses Knotenzählung nach den rekursiven Aufrufen Verfahren erfüllt zwar seinen Zweck, weist jedoch einen Abbildung 3.20: Löschen in einem Fehler auf, der in einigen Situationen zu Performance- BST
439
3
Suchen
Problemen führen kann: die Entscheidung, den Nachfolger zu verwenden, ist willkürlich und nicht symmetrisch. Warum der Nachfolger und nicht der Vorgänger? In der Praxis lohnt es sich jedenfalls, zufällig mal den Vorgänger, mal den Nachfolger zu verwenden. Einzelheiten entnehmen Sie Übung 3.2.42.
Listing 3.13: Löschen in einem BST (Fortführung von Algorithmus 3.3) public void deleteMin() { root = deleteMin(root); } private Node deleteMin(Node x) { if (x.left == null) return x.right; x.left = deleteMin(x.left); x.N = size(x.left) + size(x.right) + 1; return x; } public void delete(Key key) { root = delete(root, key);
}
private Node delete(Node x, Key key) { if (x == null) return null; int cmp = key.compareTo(x.key); if (cmp < 0) x.left = delete(x.left, key); else if (cmp > 0) x.right = delete(x.right, key); else { if (x.right == null) return x.left; if (x.left == null) return x.right; Node t = x; x = min(t.right); // Siehe Listing 3.11 Seite 436. x.right = deleteMin(t.right); x.left = t.left; } x.N = size(x.left) + size(x.right) + 1; return x; }
Diese Methoden implementieren das Löschen im BST nach Hibbard, wie es in diesem Abschnitt beschrieben ist. Der Code von delete() ist kompakt, aber kniffelig. Der vielleicht beste Weg, ihn zu verstehen, besteht darin, die Beschreibungen in diesem Abschnitt zu lesen, auf der Basis dieser Beschreibungen selbst zu versuchen, Code zu schreiben, und dann den eigenen Code mit unserem Code zu vergleichen. Die Performance ist grundsätzlich gut, kann aber in großen Anwendungen ein wenig problematisch werden (Übung 3.2.42). Die deleteMax()-Methode entspricht der Methode deleteMin(), nur dass rechts und links vertauscht werden.
440
3.2 Binäre Suchbäume
Bereichsabfragen Um die Methode keys() zu implementieren, die die Schlüssel in einem gegebenen Bereich zurückliefert, beginnen wir mit einem einfachen rekursiven Traversierungsverfahren für binäre Suchbäume, die als Inorder-Traversierung bezeichnet wird. private void print(Node x) { if (x == null) return; print(x.left); StdOut.println(x.key); print(x.right); } Listing 3.14: Die Schlüssel in einem BST geordnet ausgeben
Zur Veranschaulichung des Verfahrens betrachten wir die Aufgabe, alle Schlüssel in einem binären Suchbaum in geordneter Reihenfolge (in-order) auszugeben. Gemäß dem Code aus Listing 3.14 geben wir dazu zuerst alle Schlüssel im linken Teilbaum aus (die gemäß der Definition binärer Suchbäume kleiner sind als der Schlüssel an der Wurzel), dann den Schlüssel an der Wurzel und zum Schluss alle Schlüssel im rechten Teilbaum (die gemäß der Definition binärer Suchbäume größer sind als der Schlüssel an der Wurzel). Wie gehabt dient die Beschreibung als induktive Beweisführung, dass dieser Code die Schlüssel der Reihe nach geordnet ausgibt. Für die Implementierung der Methode keys(), die zwei Argumente übernimmt und dem Client alle Schlüssel in einem angegebenen Bereich zurückliefert, wandeln wir diesen Code so ab, dass wir alle Schlüssel in diesem Bereich zu einem Queue-Objekt hinzufügen und die rekursiven Aufrufe für Teilbäume überspringen, die keine Schlüssel in dem Bereich enthalten können. Wie im Falle von BinarySearchST bleibt die Tatsache, dass wir die Schlüssel in einer Warteschlange (Queue) sammeln, vor den Clients verborgen. Die Clients sollen die Schlüssel in dem sie interessierenden Bereich einfach mithilfe des Java-Konstrukts foreach verarbeiten und sich keine Gedanken darum machen müssen, welche Datenstruktur wir verwenden, um Iterable zu implementieren.
Analyse Wie effizient sind die ordnungsbasierten Operationen in binären Suchbäumen? Um dieser Frage nachzugehen, betrachten wir die Baumhöhe (die maximale Tiefe aller Knoten im Baum). Für einen gegebenen Baum bestimmt seine Höhe die im schlimmsten Fall zu erwartenden Kosten aller Operationen im binären Suchbaum (außer für die Bereichssuche, die zusätzliche Kosten verursacht, die proportional zur Anzahl der zurückgelieferten Schlüssel sind).
441
3
Suchen
Listing 3.15: Bereichssuche in binären Suchbäumen (Fortführung von Algorithmus 3.3) public Iterable keys() { return keys(min(), max());
}
public Iterable keys(Key lo, Key hi) { Queue queue = new Queue(); keys(root, queue, lo, hi); return queue; } private void keys(Node x, Queue queue, Key lo, Key hi) { if (x == null) return; int cmplo = lo.compareTo(x.key); int cmphi = hi.compareTo(x.key); if (cmplo < 0) keys(x.left, queue, lo, hi); if (cmplo = 0) queue.enqueue(x.key); if (cmphi > 0) keys(x.right, queue, lo, hi); }
Um alle in einen bestimmten Bereich fallenden Schlüssel eines Baums, dessen Wurzel ein gegebener Knoten ist, in eine Warteschlange zu stellen, fügen wir zuerst (rekursiv) alle Schlüssel des linken Teilbaums ein (falls einer davon in dem angegebenen Bereich liegt), dann den Knoten an der Wurzel (falls er in dem Bereich liegt) und schließlich (rekursiv) alle Schlüssel des rechten Teilbaums (falls einer davon in dem Bereich liegt). Suche im Bereich [F..T]
rote Schlüssel werden in den Vergleichen benutzt, fallen aber nicht in den Bereich S E
X
A
R C
H M L
P
schwarze Schlüssel fallen in den Bereich
Abbildung 3.21: Bereichssuche in einem binären Suchbaum
Satz E: In binären Suchbäumen haben alle Operationen im Worst Case eine Laufzeit, die proportional zur Höhe des Baums ist. Beweis: Alle diese Methoden laufen auf einem oder zwei Pfaden durch den Baum. Die Länge eines Pfades ist aber per Definition nicht länger als die Baumhöhe.
442
3.2 Binäre Suchbäume
Wir erwarten, dass die Baumhöhe (die Worst-Case-Kosten) höher ist als die durchschnittliche interne Pfadlänge, die wir in Satz C (Abschnitt 3.2.2) definiert haben (und die auch kurze Pfade einschließt). Aber um wie viel ist sie höher? Diese Frage mag ähnlich erscheinen wie die Fragen, die wir in Satz C und D beantwortet haben; aber hier eine Antwort zu finden, ist weitaus schwerer und geht mit Sicherheit über den Rahmen dieses Buches hinaus. Wie J. Robson 1979 bewies, ist die Durchschnittshöhe eines binären Suchbaums, der aus zufälligen Schlüsseln besteht, logarithmisch, und L. Devroye zeigte später, dass der Wert für große N sich 2,99 lg N nähert. Wenn also die Einfügungen in unserer Anwendung von dem Zufallsschlüsselmodell gut beschrieben werden, sind wir unserem Ziel, eine Symboltabellen-Implementierung zu entwickeln, die all diese Operationen in logarithmischer Zeit unterstützt, ein gutes Stück näher gekommen. Wir können erwarten, dass kein Pfad in einem Baum mit zufälligen Schlüsseln länger als 3 lg N ist, aber was können wir erwarten, wenn die Schlüssel nicht zufällig sind? Im nächsten Abschnitt werden Sie lesen, warum diese Frage aufgrund der balancierten binären Suchbäume in der Praxis irrelevant ist, da diese garantieren, dass die Höhe eines binären Suchbaums immer logarithmisch ist, unabhängig von der Reihenfolge, in der die Schlüssel eingefügt werden. Zusammengefasst lässt sich sagen, dass binäre Suchbäume nicht schwer zu implementieren sind und für praktische Anwendungen jedweder Art ein schnelles Suchen und Einfügen erlauben, sofern das Einfügen der Schlüssel gut von dem Zufallsschlüsselmodell approximiert wird. In unseren Beispielen (und vielen anderen praktischen Anwendungen) entscheidet der Einsatz binärer Suchbäume darüber, ob eine gegebene Aufgabe gelöst werden kann oder nicht. Viele Programmierer favorisieren binäre Suchbäume auch deshalb für ihre Symboltabellen-Implementierungen, weil sie zusätzlich schnelle Rang-, Auswahl-, Lösch- und Bereichsabfrage-Operationen erlauben. Wie wir jedoch erwähnt haben, ist zu berücksichtigen, dass die schlechte Worst-Case-Performance bei binären Suchbäumen in einigen Fällen nicht akzeptabel ist. Eine gute Performance der grundlegenden Binärsuchbaum-Implementierung hängt davon ab, dass die Schlüssel zufälligen Schlüsseln hinreichend ähnlich sind, sodass der Baum möglichst wenige lange Pfade enthält. Bei Quicksort hatten wir die Möglichkeit zu randomisieren. Diese Freiheit haben wir bei einer Symboltabellen-API nicht, da hier der Client die Abfolge der Operationen kontrolliert. Tatsächlich tritt das Worst-Case-Verhalten in der Praxis gar nicht so selten auf, zum Beispiel wenn ein Client Schlüssel in geordneter oder umgekehrt geordneter Reihenfolge einfügt – eine Abfolge von Operationen, die manche Clients mit Sicherheit probieren werden, sofern sie nicht durch explizite Hinweise davor gewarnt werden. Grund genug, nach besseren Algorithmen und Datenstrukturen zu suchen (siehe nächster Abschnitt).
443
3
Suchen
Tabelle 3.11 Zusammenfassung der Kosten für grundlegende Symboltabellen-Implementierungen (aktualisiert) Algorithmus (Datenstruktur)
444
Kosten für den schlimmsten Fall (nach N Einfügungen)
Kosten für den durchschnittlichen Fall (nach N zufälligen Einfügeoperationen)
Effiziente Unterstützung für ordnungsbasierte Operationen?
Suchen
Einfügen
Erfolgreiches Suchen
Einfügen
Sequenzielle Suche (ungeordnete verkettete Liste)
N
N
N/2
N
nein
Binäre Suche (geordnetes Array)
lg N
N
lg N
N/2
ja
Binärer Suchbaum (BST)
N
N
1,39 lg N
1,39 lg N
ja
Fragen und Antworten
3.2
Fragen und Antworten
Frage: Ich habe binäre Suchbäume bereits gesehen, aber nicht zusammen mit Rekursion. Welche Kompromisse sind damit verbunden? Antwort: Im Allgemeinen sind rekursive Implementierungen etwas leichter auf Korrektheit zu prüfen; nicht-rekursive Implementierungen sind etwas effizienter. In Übung 3.2.13 finden Sie eine Implementierung von get() – der einzige Fall, bei dem Ihnen die verbesserte Effizienz auffallen dürfte. Wenn die Bäume nicht balanciert sind, könnte die Stacktiefe der Aufrufe bei einer rekursiven Implementierung zum Problem werden. Der Hauptgrund aber, warum wir uns für die Rekursion entschieden haben, ist, dass wir Ihnen im nächsten Abschnitt den Übergang zu den balancierten Binärsuchbaum-Implementierungen erleichtern wollten, die mittels Rekursion definitiv leichter zu implementieren und zu debuggen sind. Frage: Die Verwaltung eines Attributs in Node, das die Knoten zählt, scheint viel Code zu benötigen. Ist es wirklich notwendig? Warum legen wir nicht eine einzige Instanzvariable an, die die Anzahl der Knoten im Baum speichert, um die Client-Methode size() zu implementieren? Antwort: Die Methoden rank() und select() müssen die Größe des Teilbaums für jeden Knoten kennen. Wenn Sie diese ordnungsbasierten Operationen nicht verwenden, können Sie den Code optimieren, indem Sie auf dieses Feld verzichten (Übung 3.2.12). Darauf zu achten, dass die Knotenzählung für alle Knoten immer korrekt ist, ist zugegebenermaßen fehleranfällig, aber eine nützliche Kontrollmöglichkeit beim Debuggen. Sie könnten auch eine rekursive Methode verwenden, um size() für Clients zu implementieren. Dies wäre dann aber eine recht gefährliche Entscheidung, denn die Methode würde für das Zählen aller Knoten lineare Zeit benötigen und Client-Programme, die nicht damit rechnen, dass eine so einfache Operation so teuer ist, würden unter schlechter Performance leiden.
445
3
Suchen
3.2
Allgemeine Übungen
1.
Zeichnen Sie den binären Suchbaum, den Sie erhalten, wenn Sie die Buchstaben E A S Y Q U E S T I O N in genau dieser Reihenfolge in einen anfänglich leeren Baum einfügen (wobei wie im Text vereinbart der Wert i mit dem i-ten Schlüssel verbunden wird). Wie viele Vergleiche werden benötigt, um den Baum zu erstellen?
2.
Wenn Sie die Schlüssel in der Reihenfolge A X C S E R H in einen anfänglich leeren binären Suchbaum einfügen, erhalten Sie den Worst Case eines Baums, bei dem jeder Knoten eine null-Referenz aufweist, bis auf dem untersten, der zwei nullReferenzen hat. Nennen Sie fünf weitere Reihenfolgen dieser Schlüssel, die ebenfalls Worst-Case-Bäume erzeugen.
3.
Nennen Sie fünf Reihenfolgen der Schlüssel A X C S E R H, die beim Einfügen in einen anfänglich leeren binären Suchbaum den Idealtypus (Best Case) eines Baums erzeugen.
4.
Angenommen, ein bestimmter binärer Suchbaum hat ganze Zahlen zwischen 1 und 10 als Schlüssel und wir suchen nach der 5. Welche der folgenden Sequenzen können nicht die untersuchte Schlüsselfolge sein? a. 10, 9, 8, 7, 6, 5 b. 4, 10, 8, 7, 3, 5 c. 1, 10, 2, 9, 3, 8, 4, 7, 6, 5 d. 2, 7, 3, 8, 4, 5 e. 1, 2, 10, 4, 8, 5
446
5.
Angenommen, wir hätten vorab eine Schätzung vorliegen, wie oft auf die Suchschlüssel in einem binären Suchbaum zugegriffen werden soll, und die Freiheit, die Suchschlüssel in jeder beliebigen Reihenfolge einzufügen. Sollte das Einfügen der Schlüssel in den Baum in aufsteigender Reihenfolge, in absteigender Reihenfolge der wahrscheinlichen Häufigkeit des Zugriffs oder in irgendeiner anderen Reihenfolge erfolgen? Erläutern Sie Ihre Antwort.
6.
Ergänzen Sie BST um eine Methode height(), die die Höhe des Baums berechnet. Setzen Sie zwei Implementierungen auf: eine rekursive Methode (mit linearer Laufzeit und Speicherbedarf proportional zur Höhe) und eine Methode wie size(), die zu jedem Knoten im Baum ein Feld hinzufügt (mit linearem Speicherbedarf und konstanter Zeit pro Abfrage).
7.
Ergänzen Sie BST um eine Methode avgCompares(), die berechnet, wie viele Vergleiche von einer zufälligen erfolgreichen Suche in einem gegebenen binären Suchbaum durchschnittlich benötigt werden (die interne Pfadlänge des Baums geteilt durch seine Größe plus eins). Setzen Sie zwei Implementierungen auf: eine rekursive Methode (mit linearer Laufzeit und Speicherbedarf proportional zur Höhe) und eine Methode wie size(), die zu jedem Knoten im Baum ein Feld hinzufügt (mit linearem Speicherbedarf und konstanter Zeit pro Abfrage).
Allgemeine Übungen
8.
Schreiben Sie eine statische Methode optCompares(), die ein ganzzahliges Argument N übernimmt und berechnet, wie viele Vergleiche von einer zufälligen erfolgreichen Suche in einem optimalen (perfekt balancierten) binären Suchbaum benötigt werden, bei dem alle null-Referenzen auf der gleichen Ebene liegen, wenn die Anzahl der Referenzen eine Zweierpotenz ist, oder ansonsten auf einer von zwei Ebenen.
9.
Zeichnen Sie alle Formen, die ein binärer Suchbaum annehmen kann, wenn N Schlüssel in einen anfänglich leeren Baum eingefügt werden, für N = 2, 3, 4, 5 und 6.
10.
Schreiben Sie einen Testclient TestBST.java zum Testen der Implementierungen von min(), max(), floor(), ceiling(), select(), rank(), delete(), deleteMin(), deleteMax() und keys(), die im Text besprochen wurden. Beginnen Sie mit dem Testclient in Listing 3.1. Fügen Sie Code hinzu, um nach Bedarf zusätzliche Befehlszeilenargumente zu übernehmen.
11.
Wie viele Binärbaum-Formen von N Knoten haben die Höhe N? Wie viele Möglichkeiten gibt es, um N verschiedene Schlüssel in einen anfänglich leeren binären Suchbaum einzufügen, der zu einem Baum der Höhe N anwächst? (Übung 3.2.2)
12.
Entwickeln Sie eine BST-Implementierung, die rank() und select() weglässt und kein Zählerfeld in Node verwendet.
13.
Schreiben Sie nicht-rekursive Implementierungen von get() und put() für BST.
Teillösung: Hier ist eine Implementierung von get(): public Value get(Key key) { Node x = root; while (x != null) { int cmp = key.compareTo(x.key); if (cmp == 0) return x.val; else if (cmp < 0) x = x.left; else if (cmp > 0) x = x.right; } return null; }
Die Implementierung von put() ist komplizierter, da ein Zeiger auf den Elternknoten gespeichert werden muss, um den neuen Knoten am Ende einzufügen. Außerdem benötigen Sie wegen der Aktualisierung der Zählerfelder einen zusätzlichen Durchlauf, um zu prüfen, ob der Schlüssel bereits in der Tabelle vorhanden ist. Da es in performancekritischen Implementierungen weitaus mehr Such- als Einfügeoperationen gibt, ist die Verwendung dieses Codes für get() gerechtfertigt; die entsprechende Änderung für put() fällt möglicherweise nicht ins Gewicht. 14.
Schreiben Sie nicht-rekursive Implementierungen von min(), max(), floor(), ceiling(), rank() und select().
447
3
Suchen
15.
Geben Sie die Folge der besuchten Knoten an, wenn mit den Methoden von BST die folgenden Größen für den nachfolgenden Baum berechnet werden. a. floor("Q")
E Q
D A
J
T M
S
b. select(5) c. ceiling("Q") d. rank("J") e. size("D", "T") f. keys("D", "T")
448
16.
Definieren Sie die externe Pfadlänge eines Baums als Summe der Anzahl der Knoten auf den Pfaden von der Wurzel zu allen null-Referenzen. Beweisen Sie, dass die Differenz zwischen der externen und internen Pfadlänge für einen beliebigen Binärbaum mit N Knoten gleich 2N ist (Satz C).
17.
Zeichnen Sie die Folge von binären Suchbäumen, die sich ergibt, wenn Sie die Schlüssel aus dem Baum von Übung 3.2.1 nacheinander in der Reihenfolge ihres Einfügens löschen.
18.
Zeichnen Sie die Folge von binären Suchbäumen, die sich ergibt, wenn Sie die Schlüssel aus dem Baum von Übung 3.2.1 nacheinander in alphabetischer Reihenfolge löschen.
19.
Zeichnen Sie die Folge von binären Suchbäumen, die sich ergibt, wenn Sie die Schlüssel aus dem Baum von Übung 3.2.1 löschen, indem Sie immer den Schlüssel an der Wurzel entfernen.
20.
Beweisen Sie, dass die Laufzeit der Methode keys(), die zwei Argumente übernimmt, in einem binären Suchbaum mit N Knoten bestenfalls proportional zur Baumhöhe plus der Anzahl der Schlüssel in dem Bereich ist.
21.
Fügen Sie eine BST-Methode randomKey() hinzu, die einen zufälligen Schlüssel aus der Symboltabelle in einer Zeit zurückliefert, die im schlimmsten Fall proportional der Baumhöhe ist.
22.
Beweisen Sie für einen BST-Knoten mit zwei Kindknoten, dass sein Nachfolger keinen linken Kindknoten und sein Vorgänger keinen rechten Kindknoten hat.
23.
Ist delete() kommutativ? (Erhalten Sie, wenn Sie zuerst x und dann y löschen, das gleiche Ergebnis, als wenn Sie zuerst y und dann x löschen?)
24.
Beweisen Sie, dass kein vergleichsbasierter Algorithmus einen binären Suchbaum mit weniger als lg (N!) ∼ N lg N Vergleichen erstellen kann.
Knifflige Aufgaben
3.2
Knifflige Aufgaben
25.
Perfekte Balance: Schreiben Sie ein Programm, das einen Satz von Schlüsseln in einen anfänglich leeren binären Suchbaum einfügt, sodass der erzeugte Baum äquivalent der Binärsuche ist, und zwar insofern als die Folge der Vergleiche bei der Suche nach einem beliebigen Schlüssel im binären Suchbaum die gleiche ist wie die Folge der Vergleiche bei der Binärsuche für den gleichen Satz von Schlüsseln.
26.
Genaue Wahrscheinlichkeiten: Ermitteln Sie die Wahrscheinlichkeit dafür, dass die einzelnen Bäume aus Übung 3.2.9 sich durch Einfügen von N zufälligen verschiedenen Elementen in einen anfänglich leeren Baum ergeben.
27.
Speicherbedarf: Vergleichen Sie den Speicherbedarf von BST mit dem Speicherbedarf von BinarySearchST und SequentialSearchST für N Schlüssel-Wert-Paare unter den Annahmen, die in Abschnitt 1.4 beschrieben wurden (Übung 3.1.21). Rechnen Sie nicht den Speicher für die Schlüssel und Werte mit ein, sondern berücksichtigen Sie lediglich die Verweise darauf. Zeichnen Sie anschließend ein Diagramm, das den genauen Speicherbedarf eines binären Suchbaums mit String-Schlüsseln und Integer-Werten darstellt (analog den Diagrammen, die von FrequencyCounter erstellt wurden) und schätzen Sie dann den Speicherbedarf (in Bytes) für den binären Suchbaum, der aufgebaut wird, wenn FrequencyCounter mit BST für A Tale of Two Cities ausgeführt wird.
28.
Software-Caching: Wandeln Sie BST so ab, dass der Node, auf den zuletzt zugegriffen wurde, in einer Instanzvariablen gespeichert wird, damit Sie in konstanter Zeit darauf zugreifen können, wenn die nächste put()- oder get()-Methode den gleichen Schlüssel verwendet (Übung 3.1.25).
29.
Binärbaumprüfung: Schreiben Sie eine rekursive Methode namens isBinaryTree(), die ein Node-Objekt als Argument übernimmt und true zurückliefert,
wenn das Zählerfeld N in der Datenstruktur, die an diesem Knoten beginnt, konsistent ist. Hinweis: Diese Prüfung stellt auch sicher, dass die Datenstruktur keine Kreisschlüsse enthält und darum einen Binärbaum darstellt(!). 30.
Prüfung der Reihenfolge: Schreiben Sie eine rekursive Methode isOrdered(), die ein Node-Objekt und zwei Schlüssel min und max als Argumente übernimmt. Die Methode soll true zurückliefern, wenn alle Schlüssel im Baum zwischen min und max liegen, wenn min und max der kleinste bzw. der größte Schlüssel im Baum sind und wenn die BST-Ordnungseigenschaft für alle Schlüssel im Baum gilt; andernfalls soll die Methode false zurückliefern.
31.
Prüfung auf gleiche Schlüssel: Schreiben Sie eine Methode hasNoDuplicates(), die ein Node-Objekt als Argument übernimmt und true zurückliefert, wenn es in dem Binärbaum, der mit dem Argumentknoten beginnt, keine gleichen Schlüssel gibt. Andernfalls soll die Methode false zurückliefern. Gehen Sie davon aus, dass der Test der vorherigen Übung bestanden wurde.
32.
Verifizierung: Schreiben Sie eine Methode isBST(), die ein Node-Objekt als Argument übernimmt und true zurückliefert, wenn der Argumentknoten die Wurzel ei-
449
3
Suchen
nes binären Suchbaums ist, ansonsten false. Hinweis: Diese Aufgabe ist schwieriger, als sie auf den ersten Blick scheint, da die Reihenfolge, in der Sie die Methoden der vorherigen drei Übungen aufrufen, wichtig ist.
Lösung: private boolean isBST() { if (!isBinaryTree(root)) return false; if (!isOrdered(root, min(), max())) return false; if (!hasNoDuplicates(root)) return false; return true; } 33.
Rang-/Auswahl-Prüfung: Schreiben Sie eine Methode, die für alle i von 0 bis size()-1 prüft, ob i gleich rank(select(i)) ist, und für alle Schlüssel im binären Suchbaum, ob key gleich select(rank(key)) ist.
34.
Threading: Ihr Ziel sei es, eine erweiterte API ThreadedST zu unterstützen, die die folgenden zusätzlichen Operationen in konstanter Zeit unterstützt: Key next(Key key)
// Schlüssel, der auf key folgt // (null, wenn key das Maximum ist)
Key prev(Key key)
// Schlüssel, der key vorausgeht // (null, wenn key das Minimum ist)
Hierfür müssen Sie das Node-Objekt um die Felder pred und succ ergänzen, die Referenzen zum Vorgänger- und Nachfolgerknoten enthalten, und die Methoden put(), deleteMin(), deleteMax() und delete() entsprechend ändern.
450
35.
Genauere Analyse: Verfeinern Sie das mathematische Modell, sodass es die Testergebnisse in der Tabelle im Text besser erklären kann. Zeigen Sie insbesondere, dass die durchschnittliche Anzahl von Vergleichen für eine erfolgreiche Suche in einem Baum, der aus zufälligen Schlüsseln erstellt wurde, sich bei zunehmendem N dem Grenzwert 2 ln N + 2 γ− 3 ≈ 1,39 lg N − 1,85 nähert, wobei γ = 0,57721… die Euler'sche Konstante ist. Hinweis: Nutzen Sie im Vertrauen auf die Quicksort-Analyse aus Abschnitt 2.3 die Tatsache, dass das Integral von 1/x sich ln N + γ nähert.
36.
Iterator: Ist es möglich, eine nicht-rekursive Version von keys() zu schreiben, deren Speicherbedarf proportional zur Baumhöhe ist (unabhängig von der Anzahl der Schlüssel im angegebenen Bereich)?
37.
Ebenen-Traversierung: Schreiben Sie eine Methode printLevel(), die ein NodeObjekt als Argument übernimmt und die Schlüssel des Teilbaums, der an diesem Knoten beginnt, Ebene für Ebene ausgibt (die einzelnen Ebenen sollen gemäß ihres Abstands von der Wurzel und die Knoten jeder Ebene von links nach rechts abgearbeitet werden). Hinweis: Verwenden Sie eine Queue.
38.
Baum zeichnen: Ergänzen Sie BST um eine Methode draw(), die Diagramme von binären Suchbäumen zeichnet. Die Diagramme sollen genauso aufgebaut sein, wie die Diagramme im Buchtext. Hinweis: Verwenden Sie Instanzvariablen, um die Knotenkoordinaten zu speichern, und eine rekursive Methode, um die Werte dieser Variablen zu setzen.
Experimente
3.2
Experimente
39.
Durchschnittsfall: Führen Sie empirische Studien aus, um Mittelwert und Standardabweichung für die Anzahl der Vergleiche zu schätzen, die für erfolgreiches und erfolgloses Suchen in einem binären Suchbaum benötigt werden. Führen Sie dazu je 100 Versuche durch, bei denen Sie N zufällige Schlüssel in einen anfänglich leeren Baum einfügen (für N=104, 105 und 106). Vergleichen Sie Ihre Ergebnisse mit der Formel für den Durchschnitt aus Übung 3.2.35.
40.
Höhe: Führen Sie empirische Studien aus, um die durchschnittliche Höhe eines binären Suchbaums zu schätzen, indem Sie 100-mal N zufällige Schlüssel in einen anfänglich leeren Baum einfügen (für N=104, 105 und 106). Vergleichen Sie Ihre Ergebnisse mit der Schätzung von 2,99 lg N, die im Text angegeben wurde.
41.
Arrayrepräsentation: Entwickeln Sie eine BST-Implementierung, die den binären Suchbaum mittels dreier Arrays repräsentiert (die zuvor im Konstruktor mit der maximalen Größe angelegt wurden): ein Array mit den Schlüsseln, ein Array mit den Arrayindizes, die den linken Referenzen entsprechen, und ein Array mit den Arrayindizes, die den rechten Referenzen entsprechen. Vergleichen Sie die Performance Ihres Programms mit der der Standardimplementierung.
42.
Entartung durch den Hibbard´schen Löschalgorithmus: Schreiben Sie ein Programm, das einen Integer N von der Befehlszeile entgegennimmt, einen zufälligen binären Suchbaum der Größe N aufbaut und dann in eine Schleife eintritt, in der es einen zufällig ausgewählten Schlüssel (mit dem Code delete(select(StdRandom.uniform(N)))) löscht und dafür anschließend einen zufälligen Schlüssel einfügt. Die Schleife soll N 2-mal durchlaufen werden. Nach Abarbeitung der Schleife messen Sie die durchschnittliche Länge eines Pfades im Baum (die interne Pfadlänge geteilt durch N plus 1) und geben sie aus. Führen Sie Ihr Programm für N=102, 103 und 104 aus, um die etwas kontraintuitive Hypothese zu testen, dass der beschriebene Prozess die durchschnittliche Pfadlänge des Baum so erhöht, dass sie proportional der Quadratwurzel von N ist. Führen Sie die gleichen Experimente für eine delete()-Implementierung durch, die zufällig entscheidet, ob der Vorgänger- oder ein Nachfolgerknoten verwendet wird.
43.
Put/Get-Verhältnis: Bestimmen Sie empirisch den relativen Zeitaufwand der BSTOperation put() zur BST-Operation get(), wenn der Client FrequencyCounter verwendet wird, um festzustellen, wie häufig einzelne Werte in einer Million zufällig erzeugter Integer vorkommen.
44.
Kostengrafiken: Überarbeiten Sie BST, sodass Sie Grafiken erzeugen können, die die Kosten jeder put()-Operation während der Berechnung ausdrücken (Übung 3.1.38). Orientieren Sie sich dabei an den Grafiken in diesem Abschnitt.
451
3
Suchen
45.
Tatsächliche Zeitmessungen: Ergänzen Sie FrequencyCounter um Stopwatch und StdDraw, um eine Grafik zu erstellen, bei der auf der x-Achse die Anzahl der Aufrufe von get() oder put() abgetragen wird und auf der y-Achse die Gesamtausführungszeit (wobei nach jedem Aufruf ein Punkt für die kumulierte Zeit gezeichnet wird). Führen Sie Ihr Programm für A Tale of Two Cities unter Verwendung von SequentialSearchST, von BinarySearchST und zum Schluss von BST aus und diskutieren Sie die Ergebnisse. Hinweis: Große Sprünge in der Kurve lassen sich eventuell durch Caching erklären, was jedoch über den Rahmen dieser Frage hinausgeht (siehe Übung 3.1.39).
46.
Übergang zu binären Suchbäumen: Finden Sie die Werte von N, für die die Verwendung eines binären Suchbaums zur Erstellung einer Symboltabelle von N zufälligen double-Schlüsseln 10-, 100- und 1000-mal schneller ist als die binäre Suche. Schätzen Sie die Werte auf der Basis einer Analyse und bestätigen Sie sie experimentell.
47.
Durchschnittliche Suchzeit: Berechnen Sie anhand empirischer Studien den Mittelwert und die Standardabweichung der durchschnittlichen Länge eines Pfades (interne Pfadlänge geteilt durch Baumgröße plus 1) zu einem zufälligen Knoten in einem binären Suchbaum, der durch Einfügen von N zufälligen Schlüsseln in einen anfänglich leeren Baum erstellt wurde (für N von 100 bis 10000). Führen Sie 1000 Versuche für jede Baumgröße aus. Zeichnen Sie die Ergebnisse in einer Tufte-Darstellung wie in Abbildung 3.22, komplettiert durch eine Kurve, die die Funktion 13,9 lg N − 1,85 zeichnet (siehe Übungen 3.2.35 und 3.2.39).
20 16
Vergleiche
1,39 lg N − 1,85
0 100
Operationen
10000
Abbildung 3.22: Durchschnittliche Pfadlänge zu einem zufälligen Knoten in einem binären Suchbaum aus zufälligen Schlüsseln
452
3.3 Balancierte Suchbäume
3.3
Balancierte Suchbäume
Die im vorherigen Abschnitt beschriebenen Algorithmen lassen sich gut für eine Vielzahl von Anwendungen einsetzen, haben aber eine schlechte Worst-Case-Performance. Deshalb stellen wir Ihnen in diesem Abschnitt einen binären Suchbaum vor, bei dem die Kosten garantiert logarithmisch sind, unabhängig davon, mit welcher Schlüsselfolge sie erstellt wurden. Im Idealfall hätten wir es am liebsten, wenn unsere binären Suchbäume immer perfekt balanciert wären. In einem Baum mit N Knoten sollte die Höhe ∼ lg N betragen, sodass wir garantieren können, dass keine Suche wie bei der binären Suche mehr als ∼ lg N Vergleiche benötigt (siehe Satz B). Leider ist die Wahrung einer vollständigen Balance bei dynamischen Einfügungen zu teuer. In diesem Abschnitt betrachten wir daher eine Datenstruktur, die den Anspruch auf perfekte Balance zugunsten einer garantiert logarithmischen Performance etwas lockert, und zwar nicht nur für Einfüge- und Suchoperationen in unserer Symboltabellen-API, sondern auch für alle ordnungsbasierten Operationen (außer der Bereichssuche).
3.3.1 2-3-Suchbäume Um die Balance in Suchbäumen zu garantieren, benöti- 3-Kind-Knoten M 2-Kind-Knoten gen wir eine gewisse Flexibilität, die wir zum einen R E J dadurch erreichen, dass wir in den Knoten unserer Bäume mehr als einen Schlüssel erlauben. Konkret S X P H L A C bedeutet dies, dass wir neben den in einem Standardnull-Referenz Binärsuchbaum üblichen 2-Kind-Knoten (die zwei Abbildung 3.23: Aufbau eines 2-3-SuchReferenzen und einen Schlüssel aufweisen) jetzt auch baums 3-Kind-Knoten erlauben, die drei Referenzen und zwei Schlüssel enthalten. Sowohl 2-Kind-Knoten als auch 3-Kind-Knoten haben eine Referenz für jedes Intervall, das durch ihre Schlüssel definiert wird.
Definition: 2-3-Suchbaum Ein 2-3-Suchbaum ist ein Baum, der entweder leer ist oder
ein 2-Kind-Knoten, mit einem Schlüssel (und dem damit verbundenen Wert) und zwei Referenzen: einer linken Referenz zu einem 2-3-Suchbaum mit kleineren Schlüsseln und einer rechten Referenz zu einem 2-3-Suchbaum mit größeren Schlüsseln.
ein 3-Kind-Knoten, mit zwei Schlüsseln (und den damit verbundenen Werten) und drei Referenzen: einer linken Referenz zu einem 2-3-Suchbaum mit kleineren Schlüsseln, einer mittleren Referenz zu einem 2-3-Suchbaum mit Schlüsseln zwischen den Schlüsseln des Knotens und einer rechten Referenz zu einem 2-3-Suchbaum mit größeren Schlüsseln. Wie bisher bezeichnen wir eine Referenz auf einen leeren Baum als null-Referenz.
453
3
Suchen
Ein perfekt balancierter 2-3-Suchbaum ist ein Baum, dessen null-Referenzen alle die gleiche Entfernung zur Wurzel haben. Um noch einmal darauf hinzuweisen: Wir verwenden den Begriff 2-3-Baum für einen perfekt balancierten 2-3-Suchbaum (in anderen Zusammenhängen bezeichnet dieser Begriff eine eher allgemeine Struktur). Später werden wir effiziente Möglichkeiten kennenlernen, die grundlegenden Operationen auf 2-Kind-Knoten, 3-Kind-Knoten und 2-3-Bäumen zu definieren und zu implementieren. Im Moment lassen Sie uns davon ausgehen, dass wir sie problemlos bearbeiten können, und sehen wir uns an, wie wir sie als Suchbäume verwenden können. erfolgreiche Suche nach H
erfolglose Suche nach B
H ist kleiner als M, deshalb links suchen
B ist kleiner als M, deshalb links suchen
M R
EJ AC
H
R
EJ AC
H
deshalb in der Mitte suchen SX
P
L
AC
H liegt zwischen E und J,
M
R
EJ SX
P
L
M
H
B ist kleiner als E, deshalb links suchen
M R
EJ AC
H
M R
EJ H
L
SX
P
L
M
AC
SX
P
L
P
H wurde gefunden, deshalb Wert zurückgeben (erfolgreiche Suche)
R
EJ SX
H
L
P
SX
B liegt zwischen A und C, deshalb in der Mitte suchen Referenz ist null, deshalb ist B nicht im Baum (erfolglose Suche)
Abbildung 3.24: Erfolgreiche Suche (links) und erfolglose Suche (rechts) in einem 2-3-Baum
Suchen Der Suchalgorithmus für Schlüssel in einem 2-3-Baum ist eine direkte Verallgemeinerung des Suchalgorithmus für binäre Suchbäume. Um festzustellen, ob ein Schlüssel im Baum enthalten ist, vergleichen wir ihn mit den Schlüsseln an der Wurzel. Wenn er mit einem von ihnen übereinstimmt, war die Suche erfolgreich; andernfalls folgen wir der Referenz von der Wurzel zu dem Teilbaum, dessen Schlüsselwerte-Intervall den Suchschlüssel enthalten könnte. Wenn die Referenz null ist, war die Suche erfolglos; andernfalls durchsuchen wir rekursiv diesen Teilbaum.
454
3.3 Balancierte Suchbäume
In einen 2-Kind-Knoten einfügen Um einen neuen Knoten in einem 2-3-Baum ein- K einfügen M zufügen, könnten wir nach einer erfolglosen Suche R E J den Knoten am Ende anhängen, wie wir es bei den binären Suchbäumen gemacht haben. Dann wäre S X P H L A C der neue Baum jedoch nicht mehr perfekt balanSuche nach K ciert. Der Hauptgrund, dass 2-3-Bäume so nützendet hier lich sind, ist, dass wir Einfügungen unter BeibeM haltung der perfekten Balance vornehmen können. R E J Dies ist leicht, wenn der Knoten, bei dem die K L S X P H A C Suche endet, ein 2-Kind-Knoten ist: Wir ersetzen einfach den Knoten mit einem 3-Kind-Knoten, ersetzt einen 2-Kind-Knoten mit einem der den ursprünglichen Schlüssel sowie den neu neuen 3-Kind-Knoten, der K enthält einzufügenden Schlüssel enthält. Wenn der Kno- Abbildung 3.25: In einen 2-Kind-Knoten einten, an dem die Suche endet, ein 3-Kind-Knoten fügen ist, ist der Aufwand etwas größer.
In einen Baum einfügen, der aus nur einem 3-Kind-Knoten besteht Bevor wir uns dem allgemeinen Fall zuwenden, wollen S einfügen wir als Aufwärmübung in einen winzigen 2-3-Baum kein Platz für S A E einfügen, der nur aus einem 3-Kind-Knoten besteht. erstellt einen Ein solcher Baum hat zwei Schlüssel, aber in seinem einA E S 4-Kind-Knoten zigen Knoten keinen Platz für einen weiteren Schlüssel. teilt 4-Kind-Knoten Um die Einfügung vorzunehmen, legen wir den neuen E in diesen 2-3-Baum Schlüssel in einem temporären 4-Kind-Knoten ab, eine A S natürliche Erweiterung unseres Knotentyps, der drei Schlüssel und vier Referenzen aufweist. Dieser tempo- Abbildung 3.26: In einen 3-KindKnoten einfügen räre 4-Kind-Knoten ist ganz praktisch, da er leicht in einen 2-3-Baum umgewandelt werden kann, der aus drei 2-Kind-Knoten besteht: einen mit dem mittleren Schlüssel (an der Wurzel), einen mit dem kleinsten der drei Schlüssel (auf den die linke Referenz der Wurzel zeigt) und einen mit dem größten der drei Schlüssel (auf den die rechte Referenz der Wurzel zeigt). Solch ein Baum ist ein binärer Suchbaum mit drei Knoten und gleichzeitig ein perfekt balancierter 2-3-Suchbaum, bei dem alle null-Referenzen die gleiche Entfernung zur Wurzel haben. Vor dem Einfügen ist die Höhe des Baums 0; nach dem Einfügen ist die Höhe des Baums 1. Dieser Fall ist einfach, aber dennoch einer Betrachtung wert, da er veranschaulicht, wie 2-3-Bäume in die Höhe wachsen.
455
3
Suchen
In einen 3-Kind-Knoten einfügen, dessen Elternknoten ein 2-Kind-Knoten ist Als zweites Einstiegsbeispiel gehen wir Z einfügen davon aus, dass die Suche unten im Baum Suche nach Z endet bei M diesem 3-Kind-Knoten in einem 3-Kind-Knoten endet, dessen R E J Elternknoten 2 Kinder aufweist. Auch in P H L A C S X diesem Fall können wir noch für den neuen Schlüssel Platz schaffen und gleichersetzt 3-Kind-Knoten mit temporärem zeitig die perfekte Balance im Baum wahM 4-Kind-Knoten, ren. Dazu legen wir wie eben beschrieben der Z enthält R E J einen 4-Kind-Knoten an, splitten diesen P S X Z H L A C dann genauso auf, aber anstatt einen neuen Knoten zu erzeugen, um den mittersetzt 2-Kind-Knoten mit neuem 3-Kind-Knoten, leren Schlüssel aufzunehmen, verschieM der mittleren ben wir den mittleren Schlüssel in den Schlüssel enthält E J R X Elternknoten. Die Transformation könS Z P H L A C nen Sie sich vorstellen als Ersetzen der Referenz zu dem alten 3-Kind-Knoten teilt 4-Kind-Knoten in zwei 2-Kind-Knoten durch den mittleren Schlüssel mit Refeverschiebt mittleren Schlüssel in Elternknoten renzen zu beiden Seiten zu den neuen 2- Abbildung 3.27: In einen 3-Kind-Knoten einfügen, desKind-Knoten. Gemäß unserer Vorausset- sen Elternknoten ein 2-Kind-Knoten ist zung bietet der Elternknoten genug Platz: Der Elternknoten war ein 2-Kind-Knoten (mit einem Schlüssel und zwei Referenzen) und wird zu einem 3-Kind-Knoten (mit zwei Schlüsseln und drei Referenzen). Außerdem hat diese Transformation keinen Einfluss auf die definierenden Eigenschaften eines (perfekt balancierten) 2-3-Baums. Die Ordnung des Baums bleibt erhalten, da der mittlere Schlüssel in den Elternknoten verschoben wurde, und er bleibt perfekt balanciert: Wenn alle null-Referenzen vor der Einfügung die gleiche Entfernung zur Wurzel aufweisen, hat sich hieran nach dem Einfügen nichts geändert. Diese Transformation sollten Sie auf alle Fälle verstanden haben – sie ist Dreh- und Angelpunkt der Dynamik von 2-3-Bäumen.
456
3.3 Balancierte Suchbäume
In einen 3-Kind-Knoten einfügen, dessen Elternknoten ein 3-Kind-Knoten ist Lassen Sie uns weiterhin annehmen, dass die Suche in einem Knoten endet, dessen Elternknoten ein 3-Kind-Knoten ist. Auch hier erweitern wir den Knoten wieder wie beschrieben in einen temporären 4-KindKnoten und teilen ihn dann, indem wir seinen mittleren Schlüssel in den Elternknoten einfügen. Dadurch wird der Elternknoten, bisher ein 3-Kind-Knoten, zu einem temporären neuen 4-Kind-Knoten, der den mittleren Schlüssel von der Teilung des 4-KindKnotens enthält. Dann führen wir auf diesen Knoten genau die gleiche Transformation aus. Das heißt, wir teilen den neuen temporären 4-Kind-Knoten und fügen seinen mittleren Schlüssel in seinen Elternknoten ein. Daraus kann eindeutig der allgemeine Fall abgeleitet werden, dass wir den Baum hochwandern, 4-Kind-Knoten aufteilen und ihre mittleren Schlüssel in ihre Elternknoten einfügen, bis wir einen 2-Kind-Knoten erreichen, den wir durch einen 3-Kind-Knoten ersetzen, der nicht weiter aufgeteilt werden muss, oder bis wir einen 3-Kind-Knoten an der Wurzel erreichen.
D einfügen
Suche nach D endet an diesem 3-Kind-Knoten A C
M R
E J H
fügt einen neuen Schlüssel D zu 3-Kind-Knoten hinzu und erstellt so temporären 4-Kind-Knoten E J A C D
P
L
M R
H
P
L
fügt mittleren Schlüssel C zu 3-Kind-Knoten hinzu und erstellt so temporären 4-Kind-Knoten
D
S X
M R
C E J A
S X
H
L
P
S X
teilt 4-Kind-Knoten in zwei 2-Kind-Knoten und übergibt mittleren Schlüssel an den Elternknoten fügt mittleren Schlüssel E zu dem 2-Kind-Knoten hinzu und erstellt so 3-Kind-Knoten E M C A
R
J D
H
L
P
S X
teilt 4-Kind-Knoten in zwei 2-Kind-Knoten und übergibt mittleren Schlüssel an den Elternknoten Abbildung 3.28: In einen 3-Kind-Knoten einfügen, dessen Elternknoten ein 3-Kind-Knoten ist
457
3
Suchen
Die Wurzel teilen Wenn wir es auf dem ganzen Pfad – vom Einfügepunkt bis zur Wurzel – mit 3-Kind-Knoten zu tun haben, bildet das Ende an der Wurzel einen temporären 4-Kind-Knoten. In diesem Fall können wir genauso vorgehen wie beim Einfügen in einen Baum, der nur aus einem 3Kind-Knoten besteht. Wir teilen den temporären 4-KindKnoten in drei 2-Kind-Knoten und erhöhen damit die Baumhöhe um 1. Beachten Sie, dass die letzte Transformation die perfekte Balance wahrt, da sie auf der Wurzel ausgeführt wird.
D einfügen
Suche nach D endet bei diesem 3-Kind-Knoten
E J H
A C
L
fügt neuen Schlüssel D zu 3-Kind-Knoten hinzu und erstellt so temporären 4-Kind-Knoten E J A C D
H
L
fügt mittleren Schlüssel C zu 3-Kind-Knoten hinzu und erstellt so temporären 4-Kind-Knoten
Lokale Transformationen
Zum Aufteilen eines temporären 4-Kind-Knotens in einen 2-3-Baum bedarf es einer von sechs TransformaC E J tionen, die in Abbildung 3.30 zusammengefasst werden. Der 4-Kind-Knoten kann die Wurzel sein, er kann der H L A D linke oder der rechte Kindknoten eines 2-Kind-Knotens teilt 4-Kind-Knoten in sein oder der linke, mittlere oder rechte Kindknoten zwei 2-Kind-Knoten und übergibt eines 3-Kind-Knotens. Die Grundlage eines Einfügealgomittleren Schlüssel an den Elternknoten rithmus für 2-3-Bäume ist, dass alle diese Transformationen rein lokal sind: Beim Baum müssen nur die angegeteilt 4-KindKnoten in drei benen Knoten und Referenzen untersucht oder geändert 2-Kind-Knoten und E werden. Die Anzahl der Referenzen, die für jede Transerhöht so die Baumhöhe um 1 formation geändert werden, ist durch eine kleine KonsC J tante begrenzt. Vor allem sind die Transformationen H L A D effektiv, wenn wir das beschriebene Muster irgendwo im Baum und nicht nur am Ende finden. Jede der Transfor- Abbildung 3.29: Die Wurzel teilen mationen reicht einen der Schlüssel eines 4-Kind-Knotens hoch zu dessen Elternknoten im Baum und passt die Referenzen dann entsprechend an, ohne dass irgendwelche anderen Teile des Baums davon berührt werden. Wurzel
Elternknoten ist ein 3-Kind-Knoten
b
a b c
a
links
c
d e
b d e
a
a b c
c
Elternknoten ist ein 2-Kind-Knoten
a b c
rechts
a
Mitte
b d
d
links
a
b
a c e
b c d
c a c
b c d
a e
rechts d
a b
d
a b d c d e
Abbildung 3.30: Teilen eines temporären 4-Kind-Knotens in einem 2-3-Baum (Zusammenfassung)
458
b
c
e
3.3 Balancierte Suchbäume
Globale Eigenschaften Außerdem wahren diese lokalen Transformationen die globalen Eigenschaften, dass der Baum geordnet und perfekt balanciert ist: Die Anzahl der Referenzen auf dem Pfad von der Wurzel zu jeder null-Referenz bleibt unverändert. Zur Veranschaulichung sehen Sie in Abbildung 3.31 ein vollständiges Diagramm für einen 4-Kind-Knoten, der das mittlere Kind eines 3-Kind-Knotens ist. Wenn die Länge eines jeden Pfades von einer Wurzel zu einer null-Referenz vor der Transformation h ist, so ist sie nach der Transformation ebenfalls h. Keine Transformation rüttelt an dieser Eigenschaft, auch nicht wenn der 4-Kind-Knoten in zwei 2-Kind-Knoten geteilt wird und der Elternknoten von einem 2-Kind-Knoten in einen 3-Kind-Knoten oder von einem 3-Kind-Knoten in einen temporären 4-Kind-Knoten geändert wird. Wenn die Wurzel in drei 2-Kind-Knoten geteilt wird, erhöht sich die Länge von jedem Pfad von der Wurzel zu einer null-Referenz um 1. In Übung 3.3.7 können Sie sich davon selbst überzeugen. In dieser Übung werden Sie aufgefordert, die Diagramme in Abbildung 3.31 um fünf weitere Fälle zu erweitern, um eben diesen Punkt zu veranschaulichen. Wer verstanden hat, dass jede lokale Transformation die Ordnung und die perfekte Balance im ganzen Baum wahrt, wird keine Schwierigkeit haben, den Algorithmus zu verstehen. a e b c d
kleiner als a ...
zwischen a und b ...
zwischen b und c ...
zwischen c und d ...
zwischen d und e ...
größer als e ...
zwischen d und e ...
größer als e ...
a c e b
kleiner als a ...
zwischen a und b ...
d
zwischen b und c ...
zwischen c und d ...
Abbildung 3.31: Teilen eines 4-Kind-Knotens ist eine lokale Transformation, die die Ordnung und die perfekte Balance bewahrt
Im Gegensatz zu den Standard-Binärsuchbäumen, die von oben nach unten wachsen, wachsen 2-3-Bäume von unten nach oben. Nehmen Sie sich die Zeit, Abbildung 3.32 sorgfältig zu studieren. Sie zeigt die Abfolge der 2-3-Bäume, die von unserem Testclient erzeugt wird, und die Abfolge der 2-3-Bäume, die erzeugt wird, wenn die gleichen Schlüssel in aufsteigender Reihenfolge eingefügt werden. Dadurch entwickeln Sie ein gutes Gespür für das Erstellen von 2-3-Bäumen. Zur Erinnerung sei noch einmal darauf hingewiesen, dass bei einem binären Suchbaum die Abfolge in aufsteigender Reihenfolge für 10 Schlüssel im schlimmsten Fall zu einen Baum der Höhe 9 führt, während bei 2-3-Bäumen die Höhe 2 beträgt.
459
3
Suchen
S
S einfügen
A
A einfügen C
E A A R
C
E
E
A
S
C
H
E
E
A
A C H C
L
E
A
E
L
R S C H E R
H A C
M
A
E
S
H
H X
P
E R A C
M
L
H
A
M E
S
M E
C S X
A
E
P
C S X
Testclient
P
L
S
H
R
A C
M R
X
M E
L
H
R P
P
L
C S X
H
E
R
A C P
M
A
H E R
A C
C
A
M R E
L
P
Gleiche Schlüssel in aufsteigender Reihenfolge
Abbildung 3.32: Ablaufprotokoll der Erstellung von 2-3-Bäumen
Die vorangehende Beschreibung reicht, um eine Symboltabellen-Implementierung mit 2-3-Bäumen als zugrunde liegende Datenstruktur zu definieren. Die Analyse von 2-3Bäumen unterscheidet sich von der Analyse von binären Suchbäumen, da unser Hauptinteresse der Worst-Case-Performance gilt, im Gegensatz zur Performance im durchschnittlichen Fall (bei der wir die erwartete Performance unter dem Zufallsschlüsselmodell analysieren). In Symboltabellen-Implementierungen haben wir normalerweise keine Kontrolle über die Reihenfolge, in der Clients Schlüssel in die Tabelle einfügen, und die Worst-Case-Analyse ist eine Möglichkeit, Performance-Garantien zu bieten.
460
3.3 Balancierte Suchbäume
Satz F: Bei Such- und Einfügeoperationen in einem 2-3-Baum mit N Schlüsseln werden garantiert nicht mehr als lg N Knoten besucht. ⎢ log3 N ⎦ ⎥=⎣ ⎢(lg N ) / (lg3)⎦ ⎥ Beweis: Die Höhe eines 2-3-Baums von N Knoten liegt zwischen ⎣ ⎢ lg N ⎦ ⎥(wenn der Baum nur aus 2-Kind-Kno(wenn der Baum nur aus 3-Kind-Knoten besteht) und⎣ ten besteht) (Übung 3.3.4 ).
Auf diese Weise können wir bei 2-3-Bäumen eine gute Worst-Case-Performance garantieren. Der Zeitaufwand, den jede Operation bei jedem Knoten benötigt, wird durch eine Konstante begrenzt, und beide Operationen prüfen Knoten auf nur einem Pfad, sodass die Gesamtkosten bei jeder Such- und Einfügeoperation garantiert logarithmisch sind. Wie Sie sehen können, wenn Sie den 2-3-Baum aus Abbildung 3.33 mit dem binären Suchbaum aus Abbildung 3.15 vergleichen, der aus den gleichen Schlüsseln aufgebaut ist, ist ein perfekt balancierter 2-3-Baum erstaunlich flach. So liegt zum Beispiel die Höhe eines 2-3-Baums, der eine Milliarde Schlüssel enthält, zwischen 19 und 30. Es ist ziemlich bemerkenswert, dass wir garantieren können, für beliebige Such- und Einfügeoperationen bei einer Milliarde Schlüssel nicht mehr als 30 Knoten untersuchen zu müssen. Das ist jedoch nur der halbe Weg zu einer Implementierung. Auch wenn es möglich ist, Code zu schreiben, der Transformationen auf verschiedenen Datentypen durchführt, die 2- bzw. 3-Kind-Knoten repräsentieren, sind die meisten der Aufgaben, die wir beschrieben haben, nur schwer in dieser direkten Repräsentation zu implementieren, da wir es mit einer Vielzahl von verschiedenen Fällen zu tun haben. Wir müssten zwei verschiedene Arten von Knoten verwalten, Suchschlüssel mit jedem der Schlüssel in den Knoten vergleichen, Referenzen und andere Informationen von einem Knotentyp in den anderen kopieren, Knoten eines Typs in einen anderen umwandeln usw. Dabei fällt nicht nur viel Code an, sondern der auftretende Overhead könnte die Algorithmen langsamer machen als die Such- und Einfügealgorithmen bei Standard-Binärsuchbäumen. Der Hauptzweck der Balancierung besteht darin, sich gegen einen schlechten Worst Case abzusichern, aber wir würden es vorziehen, wenn die Overheadkosten für diese Absicherung niedrig wären. Glücklicherweise können wir diese Transformationen, wie Sie sehen werden, einheitlich mit möglichst wenig Overhead vornehmen.
Abbildung 3.33: Typischer 2-3-Baum, der aus zufälligen Schlüsseln erstellt ist
3.3.2 Rot-Schwarz-Bäume Der im vorherigen Abschnitt beschriebene Einfügealgorithmus für 2-3-Bäume ist nicht schwer zu verstehen. Hier wollen wir uns davon überzeugen, dass er auch nicht schwer
461
3
Suchen
zu implementieren ist. Wir betrachten eine einfache Repräsentation, die als Rot-SchwarzBaum bezeichnet wird und zu einer natürlichen Implementierung führt. Eigentlich wird dazu nicht viel Code benötigt, aber um zu verstehen, wie und warum sich damit die Aufgabe lösen lässt, müssen Sie den Code etwas näher betrachten.
3-Kind-Knoten codieren Rot-Schwarz-Bäume basieren auf der Grundidee, 2-3- 3-Kind-Knoten ab Bäume auf der Basis von Standard-Binärsuchbäumen (die aus 2-Kind-Knoten bestehen) zu codieren und größer kleiner zwischen als b als a a und b zusätzliche Informationen hinzuzufügen, um 3-Kind... ... ... Knoten zu codieren. Bei den Referenzen gehen wir b davon aus, dass es zwei verschiedene Typen gibt: rote a Referenzen, die zwei 2-Kind-Knoten verbinden, um 3größer Kind-Knoten zu repräsentieren, und schwarze Refeals b kleiner zwischen ... renzen, die den 2-3-Baum verbinden. Genauer gesagt, als a a und b ... ... repräsentieren wir 3-Kind-Knoten als zwei 2-KindKnoten, die durch eine einzige rote Referenz verbun- Abbildung 3.34: Einen 3-Kind-Knoten mit zwei 2-Kind-Knoten codieren, die den sind, die nach links zeigt (einer der 2-Kind-Kno- durch eine nach links weisende rote Refeten ist der linke Kindknoten des anderen). Ein Vorteil renz verbunden sind einer solchen Repräsentation ist, dass sie uns erlaubt, unseren get()-Code, wie wir ihn für die Suche in Standard-Binärsuchbäumen definiert haben, ohne Änderungen zu übernehmen. Von jedem gegebenen 2-3-Baum können wir direkt einen entsprechenden binären Suchbaum ableiten, indem wir einfach jeden Knoten wie angegeben umwandeln. Wir bezeichnen binäre Suchbäume, die auf diese Weise 2-3-Bäume repräsentieren, als Rot-Schwarz-Bäume.
Eine äquivalente Definition Ein anderer Ansatz wäre, Rot-Schwarz-Bäume als binäre Suchbäume zu definieren, die rote und schwarze Referenzen aufweisen und den folgenden drei Beschränkungen genügen:
Rote Referenzen zeigen nach links. Von keinem Knoten gehen zwei rote Referenzen aus. Der Baum ist schwarz vollkommen ausgeglichen: Jeder Pfad von der Wurzel zu einer null-Referenz hat die gleiche Anzahl von schwarzen Referenzen. Es gibt eine Eins-zu-eins-Übereinstimmung zwischen solchermaßen definierten RotSchwarz-Bäumen und 2-3-Bäumen.
Abbildung 3.35: Ein Rot-Schwarz-Baum mit horizontalen roten Referenzen ist ein 2-3-Baum
462
3.3 Balancierte Suchbäume
Eine Eins-zu-eins-Übereinstimmung Wenn wir die roten Referenzen in Rot-Schwarz-Baum M einem Rot-Schwarz-Baum horizontal J R zeichnen, haben alle null-Referenzen P X E L die gleiche Entfernung zur Wurzel, und S C H wenn wir dann die Knoten, die durch A rote Referenzen verbunden sind, einklappen, erhalten wir einen 2-3-Baum. horizontale rote Referenzen M Umgekehrt gilt, wenn wir 3-Kind-KnoJ R E ten in einem 2-3-Baum als zwei 2-KindA C H P S X L Knoten zeichnen, die durch eine rote Referenz verbunden sind, die nach links weist, dann ist kein Knoten mit 2-3-Baum M zwei roten Referenzen verbunden und der Baum weist eine perfekte schwarze R E J Balance auf, da die schwarzen RefeS X P H L A C renzen den Referenzen des 2-3-Baums entsprechen, die per Definition per- Abbildung 3.36: Eine Eins-zu-eins-Übereinstimmung zwifekt balanciert sind. Wie auch immer schen Rot-Schwarz-Bäumen und 2-3-Bäumen wir sie definieren, Rot-Schwarz-Bäume sind beides: binärer Suchbaum und 2-3-Baum. Wenn es uns also gelingt, den Einfügealgorithmus des 2-3-Baums zu implementieren bei gleichzeitiger Wahrung der Einszu-eins-Übereinstimmung, erhalten wir von beiden das Beste: die einfache und effiziente Suchmethode der Standard-Binärsuchbäume und die effiziente Einfüge-Balancierungsmethode von 2-3-Bäumen.
Farbrepräsentation Der Einfachheit halber und weil jeder Knoten genau über eine Referenz mit seinem Elternknoten verbunden ist, codieren wir die Farbe der Referenzen in den Knoten. Dazu ergänzen wir unseren Datentyp Node durch eine boolesche Instanzvariable, die true ist, wenn die Referenz vom Elternknoten rot ist, und false, wenn sie schwarz ist. Gemäß Konvention sind null-Referenzen immer schwarz. Um unseren Code übersichtlich zu halten, definieren wir die Konstanten RED und BLACK, die uns beim Setzen und Testen dieser Variablen von Nutzen sind. Mit der privaten Methode isRed() testen wir, welche Farbe die Referenz eines Knotens zu seinem Elternknoten hat. Wenn wir von der Farbe eines Knotens sprechen, meinen wir eigentlich die Farbe der Referenz, die auf diesen Knoten zeigt, und umgekehrt.
463
3
Suchen
h
h.left.color ist RED
E
C A
J D
h.right.color ist BLACK
G
private static final boolean RED = true; private static final boolean BLACK = false; private class Node { Key key; Value val; Node left, right; int N; boolean color; Node(Key key, { this.key this.val this.N this.color }
// // // // // //
Schlüssel damit verbundene Daten Teilbäume # Knoten in diesem Teilbaum Farbe der Referenz vom Elternknoten zu diesem Knoten
Value val, int N, boolean color) = = = =
key; val; N; color;
} private boolean isRed(Node x) { if (x == null) return false; return x.color == RED; }
Abbildung 3.37: Knotenrepräsentation für Rot-Schwarz-Bäume
Rotationen Die Implementierung, die wir hier betrachten, weist während einer Operation unter Umständen nach rechts weisende rote Referenzen oder zwei aufeinanderfolgende rote Referenzen auf, aber sie achtet immer darauf, diese Bedingungen zu korrigieren, bevor die Operation abgeschlossen ist, und zwar durch penible Anwendung der sogenannten Rotation, die die Richtung der roten Referenzen ändert. Lassen Sie uns zuerst davon ausgehen, dass wir eine nach rechts zeigende rote Referenz haben, die „rotiert“ werden muss, um nach links zu zeigen (Abbildung 3.38). Diese Operation wird Linksrotation genannt. Wir führen diese Operation durch eine Methode aus, die eine Referenz auf einen Rot-Schwarz-Baum als Argument übernimmt und – unter der Voraussetzung, dass diese Referenz zu einem Node-Objekt h mit einer rechten roten Referenz führt – die notwendigen Anpassungen vornimmt und eine Referenz auf einen Knoten zurückliefert, der die Wurzel eines Rot-Schwarz-Baums für den gleichen Satz von Schlüsseln ist, dessen linke Referenz rot ist. Wenn Sie jede Codezeile mit den Vorher/Nachher-Zeichnungen im Diagramm vergleichen, werden Sie feststellen, dass diese Operation nicht schwer zu verstehen ist: Wir wechseln vom kleineren der beiden Schlüssel an der Wurzel zum größeren der beiden Schlüssel an der Wurzel. Die Implementierung einer Rechtsrotation, die eine nach links weisende rote Referenz in eine nach rechts weisende Referenz umwandelt, ist vom Code her fast identisch. Es werden lediglich links und rechts vertauscht (Abbildung 3.39).
464
3.3 Balancierte Suchbäume
könnte rechts oder links, rot oder schwarz sein h
h
E
x
x
E
S kleiner als E
zwischen E und S
größer als S
Node rotateLeft(Node h) { Node x = h.right; h.right = x.left; x.left = h; x.color = h.color; h.color = RED; x.N = h.N; h.N = 1 + size(h.left) + size(h.right); return x; } x
kleiner als E
E
größer als S
h
S
E
kleiner als E
zwischen S und E
Node rotateRight(Node h) { Node x = h.left; h.left = x.right; x.right = h; x.color = h.color; h.color = RED; x.N = h.N; h.N = 1 + size(h.left) + size(h.right); return x; } x
S
h
S
zwischen E und S
größer als S
Abbildung 3.38: Linksrotation (rechte Referenz von h)
kleiner als E
zwischen S und E
größer als S
Abbildung 3.39: Rechtsrotation (linke Referenz von h)
Neusetzen der Referenz im Elternknoten nach einer Rotation Unabhängig ob rechts oder links – jede Rotation liefert eine Referenz zurück. Und mit der von rotateRight() oder rotateLeft() zurückgelieferten Referenz setzen wir immer die entsprechende Referenz im Elternknoten (oder in der Wurzel des Baums) neu. Das kann eine rechte oder linke Referenz sein, aber wir können sie immer nutzen, um die Referenz im Elternknoten neu zu setzen. Diese Referenz kann rot oder schwarz sein, denn sowohl rotateLeft() als auch rotateRight() behalten durch Setzen von x.color auf h.color die Farbe bei. Auf diese Weise können im Baum zwei rote Referenzen aufeinanderfolgen, auch wenn unsere Algorithmen dies mittels Rotationen korrigieren. So rotiert beispielsweise der Code h = rotateLeft(h);
eine nach rechts weisende rote Referenz (die rechts am Knoten h hängt) nach links, sodass h auf die Wurzel des resultierenden Teilbaums zeigt (der genau die gleichen Knoten enthält wie der Teilbaum, auf den h vor der Rotation gezeigt hat, aber mit einer anderen Wurzel). Die Einfachheit dieses Codes ist der Hauptgrund, warum wir rekursive Implementierungen von BST-Methoden verwenden, denn damit werden Rotationen, wie Sie gleich sehen werden, eine einfache und bequeme Ergänzung zu normalen Einfügeoperationen. Wir können also mit den Rotationen dazu beitragen, die Eins-zu-eins-Übereinstimmung zwischen 2-3-Bäumen und Rot-Schwarz-Bäumen beim Einfügen neuer Schlüs-
465
3
Suchen
sel zu wahren, da sie die beiden definierenden Eigenschaften von Rot-Schwarz-Bäumen garantieren: Ordnung und perfekte schwarze Balance. Das heißt, wir können Rotationen in einem Rot-Schwarz-Baum einsetzen, ohne uns sorgen zu müssen, dass er seine Ordnung oder seine perfekte schwarze Balance verliert. Als Nächstes wollen wir zeigen, wie wir mit Rotationen die anderen beiden definierenden Eigenschaften von Rot-Schwarz-Bäumen bewahren (keine zwei aufeinanderfolgende rote Referenzen auf einem der Pfade und keine nach rechts weisende rote Referenz). Als Einstieg betrachten wir einige einfache Fälle.
In einen einzelnen 2-Kind-Knoten einfügen Ein Rot-Schwarz-Baum mit einem Schlüssel ist eigentlich nur ein 2-Kind-Knoten. Beim Einfügen eines zweiten Schlüssels offenbart sich direkt die Notwendigkeit einer Rotationsoperation. Wenn der neue Schlüssel kleiner ist als der Schlüssel im Baum, erstellen wir einfach einen neuen (roten) Knoten mit dem neuen Schlüssel und das war's: Wir haben jetzt einen Rot-Schwarz-Baum, der äquivalent einem 3-Kind-Knoten ist. Wenn aber der neue Schlüssel größer ist als der Schlüssel im Baum, dann erhalten wir durch das Anhängen eines neuen (roten) Knotens eine nach rechts weisende rote Referenz, und der Code root = rotateLeft(root);
links
Wurzel
b Suche endet bei dieser null-Referenz Wurzel
b
a
rote Referenz auf neuen Knoten, der a enthält, wandelt 2-Kind-Knoten in 3-Kind-Knoten um
rechts
Wurzel
Suche endet bei dieser null-Referenz
a a b
neuer Knoten mit roter Referenz angehängt
beendet das Einfügen, indem die rote Referenz nach links Wurzel rotiert und die Referenz der Baumwurzel aktualisiert wird. b Linksrotation, um a Das Ergebnis ist in beiden Fällen die Rot-Schwarz-Repräseneinen gültigen tation eines einzelnen 3-Kind-Knotens mit zwei Schlüsseln, 3-Kind-Knoten zu erzeugen einer nach links weisenden roten Referenz und einer SchwarzAbbildung 3.40: In einen einhöhe von 1. zelnen 2-Kind-Knoten einfügen
Unten in einen 2-Kind-Knoten einfügen Wir fügen Schlüssel in einen Rot-Schwarz-Baum ein, wie wir es normalerweise auch bei einem binären Suchbaum machen, indem wir am Ende einen neuen Knoten hinzufügen (unter Wahrung der Ordnung), der jedoch immer mit seinem Elternknoten über eine rote Referenz verbunden ist. Wenn der Elternknoten ein 2-Kind-Knoten ist, lassen sich die oben beschriebenen Fälle anwenden. Wird der neue Knoten an die linke Referenz angehängt, so wird aus dem Elternknoten einfach ein 3-Kind-Knoten; wird er an die rechte Referenz angehängt, dann erhalten wir einen 3-Kind-Knoten, der in die falsche Richtung weist, was allerdings durch eine Linksrotation behoben werden kann.
C einfügen
E A
S
neuen Knoten hier einfügen
R
rechte Referenz ist rot, deshalb nach links rotieren E A S C R
E C
Abbildung 3.41: Unten in einen 2-Kind-Knoten einfügen
466
A
S R
3.3 Balancierte Suchbäume
In einen Baum mit zwei Schlüsseln (in einen 3-Kind-Knoten) einfügen Dieser Fall lässt sich auf drei Teilfälle reduzieren: Der neue Schlüssel ist entweder kleiner als die beiden Schlüssel im Baum, er liegt dazwischen oder er ist größer. In allen drei Fällen wird ein Knoten mit zwei roten Referenzen eingefügt und unser Ziel ist es, diesen Zustand zu korrigieren.
Der einfachste Fall ist gegeben, wenn der neue Schlüssel größer als die beiden bereits vorhandenen Schlüssel im Baum ist und deshalb an die Referenz ganz rechts vom 3-Kind-Knoten angehängt wird. So entsteht ein balancierter Baum mit dem mittleren Schlüssel als Wurzel, der über rote Referenzen mit Knoten verbunden ist, die einen kleineren und einen größeren Schlüssel enthalten. Wenn wir die Farben dieser beiden Referenzen von Rot in Schwarz wechseln, erhalten wir einen balancierten Baum der Höhe 2 mit drei Knoten, was genau das ist, was wir für unsere Einszu-eins-Übereinstimmung zu 2-3-Bäumen benötigen. Die anderen beiden Fälle lassen sich schlussendlich auf diesen Fall reduzieren.
Wenn der neue Schlüssel kleiner ist als die anderen beiden Schlüssel, wird er links über eine Referenz angehängt, sodass wir zwei aufeinanderfolgende rote Referenzen erhalten, die beide nach links zeigen. Diese Situation lässt sich auf den vorherigen Fall reduzieren (mittlerer Schlüssel als Wurzel, verbunden mit den beiden anderen Schlüsseln über zwei rote Referenzen), indem die oberste Referenz nach rechts rotiert wird.
Wenn der neue Schlüssel zwischen den beiden Schlüsseln im Baum liegt, haben wir wieder zwei aufeinanderfolgende rote Referenzen, wobei diesmal der erste nach links und der darunterliegende nach rechts zeigt. Diese Situation können wir auf den vorherigen zweiten Fall reduzieren (zwei aufeinanderfolgende rote Referenzen, die nach links zeigen), indem wir die untere Referenz nach links rotieren. größer
a
c
c
Suche endet bei dieser null-Referenz
a
b Suche endet bei dieser null-Referenz
b a
b a
dazwischen
kleiner
Suche endet bei dieser null-Referenz
b
neuer Knoten mit roter Referenz b angehängt c a
Farben wechseln zu schwarz
b
c a
c
b neuer Knoten mit roter Referenz angehängt rotiert rechts
c b a
rotiert links
c
a
b
c b a
Farben wechseln zu schwarz
neuer Knoten mit roter Referenz angehängt
c
a
c
b a
rotiert rechts
Farben wechseln zu schwarz c
Abbildung 3.42: In einen einzelnen 3-Kind-Knoten einfügen (3 Fälle)
467
3
Suchen
Zusammengefasst lässt sich sagen, dass wir das gewünschte Resultat erzielen, indem wir keine, eine oder zwei Rotationen durchführen, gefolgt von einem Farbwechsel der beiden Kindknoten an der Wurzel. Wie bei den 2-3-Bäumen sollten Sie darauf achten, dass Sie diese Transformationen verstanden haben, da sie der Schlüssel zu der Dynamik von Rot-Schwarz-Bäumen sind.
Farben wechseln Um die Farben der beiden roten Kinder eines Knotens zu wechseln, verwenden wir die Methode flipColors() aus Abbildung 3.43. Doch wir wechseln nicht nur die Farbe der Kindknoten von Rot nach Schwarz, sondern auch die Farbe des Elternknotens von Schwarz nach Rot. Ein besonders wichtiges Merkmal dieser Operation ist, dass sie wie die Rotationen eine lokale Transformation ist, die die perfekte schwarze Balance im Baum wahrt. Außerdem führt uns diese Konvention direkt zu einer vollständigen Implementierung, die wir im Folgenden beschreiben werden.
h
E
könnte linke oder rechte Referenz sein
A
kleiner als A
S
zwischen A und E
zwischen E und S
größer als S
void flipColors(Node h) { h.color = RED; h.left.color = BLACK; h.right.color = BLACK; } rote Referenz verbindet
mittleren Knoten mit Elternknoten E
Die Wurzel schwarz halten
A
S
Bei dem Fall, den wir gerade betrachtet haben (in einen einzelnen 3-Kind-Knoten einfügen), kleiner zwischen zwischen größer A und E E und S als A als S färbt der Farbwechsel die Wurzel rot. Dies kann schwarze Referenzen werden in auch in größeren Bäumen passieren. Genau 2-Kind-Knoten getrennt genommen ist eine rote Wurzel ein Zeichen dafür, dass die Wurzel Teil eines 3-Kind-Kno- Abbildung 3.43: Farben wechseln, um einen 4Kind-Knoten aufzuteilen tens ist. Aber da dies nicht der Fall ist, färben wir die Wurzel nach jeder Einfügung schwarz. Beachten Sie, dass die Schwarzhöhe des Baums um 1 zunimmt, wenn die Farbe der Wurzel von Schwarz auf Rot wechselt.
Unten in einen 3-Kind-Knoten einfügen Oder stellen Sie sich vor, dass wir einen neuen Knoten unten am Ende in einen 3-Kind-Knoten einfügen. Dabei ergeben sich die gleichen drei Fälle wie bereits diskutiert. Entweder wird die neue Referenz mit der rechten Referenz des 3-Kind-Knotens verbunden (woraufhin wir einfach die Farben wechseln) oder mit dessen linker Referenz (woraufhin wir die darüberliegende Referenz nach rechts rotieren und die Farben wechseln) oder mit dessen mittlerer Referenz (woraufhin wir die untere Referenz nach links rotieren, dann die darüberliegende Referenz nach rechts und anschließend die Farbe wechseln). Durch den Farbwechsel wird die Referenz zu dem mittleren Knoten rot, was bedeutet, dass die Farbe nach oben an den Elternknoten weitergereicht wird. Damit befinden wir uns hinsichtlich des Elternknotens wieder in der gleichen Situation – was wir beheben können, indem wir den Baum hochwandern.
468
3.3 Balancierte Suchbäume
H einfügen
E
E A C
C R S
S
A
R
neuen Knoten hier einfügen zwei nach links weisende Referenzen hintereinander, also rechts rotieren E C S
A
R H
E A C
beide Kindknoten rot, also Farbe wechseln E
H R S C
R
A
S
H
rechte Referenz rot, also links rotieren
E C
R
A
S
H
E R A C
H
R
S
S
E C
H
A
Abbildung 3.44: In einen 3-Kind-Knoten unten einfügen
Eine rote Referenz im Baum nach oben weiterreichen Beim Einfügealgorithmus für 2-3-Bäume sind wir gehalten, den 3-Kind-Knoten zu teilen und den mittleren Schlüssel nach oben in den Elternknoten zu verschieben, womit wir so lange fortfahh h Linksren, bis wir auf einen 2-Kind-Knoten bzw. die rotation h Wurzel treffen. In jedem Fall, den wir bisher betrachtet haben, war unser Ziel folgendes: NachRechtsFarbdem wir erforderlichenfalls Rotationen vorrotation wechsel genommen haben, wechseln wir die Farben, wodurch sich der mittlere Knoten rot färbt. Aus dem Blickwinkel des Elternknotens dieses Kno- Abbildung 3.45: Rote Referenzen in einem tens kann die rot umgefärbte Referenz genauso Rot-Schwarz-Baum nach oben weiterreichen behandelt werden, als wäre sie die Folge eines neu hinzugefügten Knotens: Wir reichen eine rote Referenz auf den mittleren Knoten nach oben weiter. Die drei Fälle, die in Abbildung 3.45 zusammengefasst sind, veran-
469
3
Suchen
schaulichen die Operationen, die in einem Rot-Schwarz-Baum notwendig sind, um die Schlüsseloperation beim Einfügen in einen 2-3-Baum zu implementieren: Für Einfügungen in einen 3-Kind-Knoten erzeugen wir einen temporären 4-Kind-Knoten, teilen ihn und reichen eine rote Referenz als mittleren Schlüssel an den Elternknoten hoch. Durch Wiederholen dieses Prozesses reichen wir rote Referenzen im Baum hoch, bis wir auf einen 2-Kind-Knoten bzw. die Wurzel treffen. Zusammengefasst lässt sich sagen, dass wir unsere Eins-zu-eins-Übereinstimmung zwischen 2-3-Bäumen und Rot-Schwarz-Bäumen beim Einfügen durch die sorgfältige Verwendung von drei einfachen Operationen wahren können: Linksrotation, Rechtsrotation und Farbwechsel. Für ein erfolgreiches Einfügen führen wir die folgenden Operationen nacheinander auf jeden Knoten aus, der uns auf dem Weg vom Einfügepunkt nach oben begegnet:
Wenn der rechte Kindknoten rot ist und der linke schwarz, wird nach links rotiert. Wenn sowohl der linke Kindknoten und dessen linker Kindknoten rot sind, wird nach rechts rotiert.
Wenn beide Kindknoten rot sind, wird umgefärbt. Es lohnt sich zu prüfen, ob diese Folge von Operationen alle gerade beschriebenen Fälle abdeckt. Beachten Sie, dass die erste Operation sowohl die Rotation vornimmt, um den 3-Kind-Knoten nach links zu verlagern, wenn der Elternknoten ein 2-KindKnoten ist, als auch, um die unterste Referenz nach links zu verlagern, wenn die neue rote Referenz die mittlere Referenz in einem 3-Kind-Knoten ist.
3.3.3 Implementierung Da die Balancierungsoperationen vom Einfügepunkt aus auf dem Weg nach oben im Baum ausgeführt werden müssen, sind sie in unserer rekursiven Standardimplementierung einfach zu implementieren: Wir führen sie, wie in Listing 3.16 zu sehen, einfach nach den rekursiven Aufrufen aus. Die drei im vorherigen Abschnitt genannten Operationen lassen sich mit einer einzigen if-Anweisung ausführen, die die Farben von zwei Knoten im Baum prüft. Auch wenn hierfür ein wenig Code benötigt wird, wäre diese Implementierung ohne die beiden von uns für die Implementierung entwickelten Abstraktionsebenen (2-3-Bäume und Rot-Schwarz-Bäume) ziemlich schwer zu verstehen. Alles, was wir machen müssen, ist, drei bis fünf Knotenfarben zu testen (und vielleicht ein oder zwei Rotationen oder einen Farbwechsel vorzunehmen, wenn ein Test erfolgreich war), um binäre Suchbäume zu erhalten, die fast perfekt balanciert sind. Wie das Ablaufprotokoll für unseren Testclient aussieht, wenn wir die gleichen Schlüssel in aufsteigender Reihenfolge einfügen, zeigt Abbildung 3.46. Betrachten Sie diese Beispiele einfach als Anwendung unserer drei Operationen auf Rot-Schwarz-Bäume – Sie werden feststellen, wie lehrreich diese Übung ist. Ebenfalls sehr lehrreich ist es, die Übereinstimmung mit 2-3-Bäumen zu prüfen, die von unserem Algorithmus gewahrt bleibt (verwenden Sie dazu Abbildung 3.32 für die gleichen Schlüssel). In beiden Fällen können Sie testen, ob Sie den Algorithmus verstanden haben, indem Sie die Transfor-
470
3.3 Balancierte Suchbäume
mationen (zwei Farbwechsel und zwei Rotationen) nachvollziehen, die erforderlich sind, wenn P in den Rot-Schwarz-Baum eingefügt wird (Übung 3.3.12). Listing 3.16: Einfügung bei Rot-Schwarz-Bäumen (Algorithmus 3.4) public class RedBlackBST { private Node root; private class Node // BST-Knoten mit Farbbit (siehe Abbildung 3.37 // auf Seite 464) private private private private
boolean isRed(Node h) Node rotateLeft(Node h) Node rotateRight(Node h) void flipColors(Node h)
private int size()
// // // //
Siehe Siehe Siehe Siehe
Abbildung Abbildung Abbildung Abbildung
3.37 3.38 3.39 3.43
auf auf auf auf
Seite Seite Seite Seite
464. 465. 465. 468.
// Siehe Listing 3.9 auf Seite 427.
public void put(Key key, Value val) { // Sucht Schlüssel. Gefunden: Wert aktualisieren; neu: // Tabelle vergrößern. root = put(root, key, val); root.color = BLACK; } private Node put(Node h, Key key, Value val) { if (h == null) // Standardeinfügeoperation mit roter Referenz // zum Elternknoten. return new Node(key, val, 1, RED); int cmp = key.compareTo(h.key); if (cmp < 0) h.left = put(h.left, key, val); else if (cmp > 0) h.right = put(h.right, key, val); else h.val = val; if (isRed(h.right) && !isRed(h.left)) h = rotateLeft(h); if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h); if (isRed(h.left) && isRed(h.right)) flipColors(h); h.N = size(h.left) + size(h.right) + 1; return h; } }
Der Code der rekursiven put()-Methode für Rot-Schwarz-Bäume ist identisch zu der put()-Methode in elementaren binären Suchbäumen – sieht man einmal von den drei if-Anweisungen nach den rekursiven Aufrufen ab, die eine fast perfekte Balance im Baum herstellen, indem sie auf dem Weg nach oben im Suchpfad für eine Eins-zu-eins-Übereinstimmung zu 2-3-Bäumen sorgen. Die erste if-Anweisung rotiert jeden nach rechts weisenden 3-Kind-Knoten (oder eine nach rechts weisende untere rote Referenz in einem temporären 4-Kind-Knoten) nach links, die zweite rotiert die obere Referenz in einem temporären 4-Kind-Knoten mit zwei nach links weisenden roten Referenzen nach rechts und die dritte wechselt die Farbe, um eine rote Referenz im Baum hochzureichen (siehe Text).
471
3
Suchen
S einfügen
A einfügen
S S
E
E A
C
C
E
A
A
A C
E
S
A C
E R
E
A
H
S
A
H
R
E H
E C
C
S
A
L
L
C
R
A
E H
R S
E
H
C
H
M
C
M
A
L
E
A R
H X
E
X
C
P
S
H
C
A
M E
P
L
A H
R X
E
M
M
C A
C
R
S
A
M E
H R
E
P C
C
S
P
H
A
X
A
E
L H Testclient
P
H R
C
S
M L
M C
X
P
X
A
R E
X
M L
S
P
S
Gleiche Schlüssel in aufsteigender Reihenfolge
Abbildung 3.46: Ablaufprotokoll des Aufbaus von Rot-Schwarz-Bäumen
472
R
S
E A
R P
M
L
L
H
3.3 Balancierte Suchbäume
3.3.4 Löschen Da put() in Listing 3.16 bereits eine der kompliziertesten Methoden ist, die wir in diesem Buch betrachten werden, und die Implementierungen von deleteMin(), deleteMax() und delete() für Rot-Schwarz-Bäume etwas schwieriger sind, sparen wir uns deren vollständige Implementierungen für die Übungen auf. Der grundlegende Ansatz ist jedoch eine kurze Betrachtung wert. Wir beginnen, indem wir uns vorab noch einmal den 2-3-Bäumen zuwenden. Wie beim Einfügen können wir eine Folge von lokalen Transformationen definieren, die es uns erlauben, unter Wahrung der perfekten Balance einen Knoten zu löschen. Der Prozess ist etwas komplizierter als beim Einfügen, da wir die Transformationen sowohl auf dem Weg nach unten im Suchpfad vornehmen, wenn wir temporäre 4-Kind-Knoten einfügen (um die Basis zum Löschen eines Knotens zu schaffen), als auch auf dem Weg nach oben, wo wir alle übrig gebliebenen 4-Kind-Knoten aufteilen (wie wir es beim Einfügen gemacht haben).
Top-Down-2-3-4-Bäume Als erste Aufwärmübung für das Löschen betrachten wir einen einfacheren Algorithmus, der Transformationen sowohl auf dem Weg nach unten als auch nach oben im Suchpfad ausführt: einen Einfügealgorithmus für 2-3-4-Bäume, bei dem die temporären 4-Kind-Knoten, die wir in den 2-3-Bäumen kennengelernt haben, im Baum stehen bleiben. Der Einfügealgorithmus basiert darauf, Transformationen auf dem Weg nach unten im Pfad auszuführen, um die Invariante aufrechtzuerhalten, dass der aktuelle Knoten kein 4-Kind-Knoten ist (sodass wir die Gewissheit haben, dass Platz ist, um den neuen Schlüssel unten einzufügen), und Transformationen auf dem Weg nach oben im Pfad auszuführen, um alle eventuell erzeugten 4-Kind-Knoten auszubalancieren. Die Transformationen nach unten sind genau die gleichen, mit denen wir 4-KindKnoten in 2-3-Bäumen getrennt haben. Wenn die Wurzel ein 4-Kind-Knoten ist, teilen wir ihn in drei 2-Kind-Knoten, was die Höhe des Baums um 1 erhöht. Wenn wir auf dem Weg nach unten auf einen 4-Kind-Knoten stoßen, der einen 2-Kind-Knoten als Elternknoten hat, teilen wir den 4-Kind-Knoten in zwei 2-Kind-Knoten und übergeben den mittleren Schlüssel an den Elternknoten, der damit zu einem 3-Kind-Knoten wird. Wenn wir auf einen 4-Kind-Knoten mit einem 3-Kind-Knoten als Elternknoten stoßen, teilen wir den 4-Kind-Knoten in zwei 2-Kind-Knoten und übergeben den mittleren Schlüssel an den Elternknoten, der damit zu einem 4-Kind-Knoten wird. Kraft der Invariante brauchen wir nicht zu befürchten, dass wir einem 4-Kind-Knoten begegnen, der einen 4-Kind-Knoten als Elternknoten hat. Ganz unten im Baum haben wir – wieder kraft der Invariante – einen 2-Kind-Knoten oder einen 3-Kind-Knoten, sodass Platz vorhanden ist, um den neuen Schlüssel einzufügen. Um diesen Algorithmus für Rot-Schwarz-Bäume zu implementieren, gehen wir folgendermaßen vor:
Wir repräsentieren 4-Kind-Knoten als einen balancierten Teilbaum von drei 2-KindKnoten, wobei der linke und der rechte Kindknoten mit dem Elternknoten über eine rote Referenz verbunden ist.
Wie teilen 4-Kind-Knoten auf dem Weg nach unten im Baum mittels Farbwechsel. Wir balancieren 4-Kind-Knoten auf dem Weg nach oben im Baum mittels Rotationen (wie beim Einfügen).
473
3
Suchen
Bemerkenswerterweise brauchen Sie für die Implementierung von Top-Down-2-3-4-Bäumen in dem Code von put() aus Listing 3.16 lediglich eine Codezeile zu verschieben: Setzen Sie den Aufruf von flipColors() (und den dazugehörigen Test) vor die rekursiven Aufrufe (zwischen dem Test auf Null und dem Vergleich). Dieser Algorithmus hat einige Vorteile gegenüber 2-3-Bäumen in Anwendungen, in denen mehrere Prozesse Zugriff auf den gleichen Baum haben, da er immer innerhalb einer oder zweier Referenzen des aktuellen Knotens operiert. Die Löschalgorithmen, die wir im Folgenden beschreiben, basieren auf dem gleichen Verfahren und eignen sich sowohl für diese Bäume als auch für 2-3-Bäume.
an der Wurzel
auf dem Weg nach unten
Das Minimum löschen Als zweite Aufwärmübung betrachten wir die Operation zum Löschen des Minimums aus einem 2-3-Baum. Die Grundidee basiert auf der Beobachtung, dass wir unten ganz unten in einem Baum ohne Probleme einen Schlüssel aus einem 3-Kind-Knoten löschen können, aber nicht aus einem 2-Kind-Knoten. Wird der Schlüssel von einem 2-Kind-Knoten gelöscht, erhalten wir einen Knoten Abbildung 3.47: Transformationen ohne Schlüssel. Es liegt nahe, den Knoten durch eine beim Einfügen in Top-Down-2-3-4-Bäumen null-Referenz zu ersetzen, aber diese Operation würde die Bedingung der perfekten Balance verletzen. Aus diesem Grund verfolgen wir den folgenden Ansatz: Um sicherzustellen, dass wir nicht auf einem 2-Kind-Knoten enden, führen wir entsprechende Transformationen auf dem Weg nach unten im Baum durch, um die Invariante aufrechtzuerhalten, dass der aktuelle Knoten kein 2-Kind-Knoten ist (er kann ein 3-Kind-Knoten oder ein temporärer 4-Kind-Knoten sein). Beginnen wir mit der Wurzel, für die es zwei Möglichkeiten gibt: Wenn die Wurzel ein 2-Kind-Knoten ist und beide Kinder 2-Kind-Knoten sind, können wir einfach die drei Knoten in einen 4Kind-Knoten umwandeln. Andernfalls können wir im Bedarfsfall vom rechten Geschwisterknoten borgen, um sicherzustellen, dass der linke Kindknoten der Wurzel kein 2-Kind-Knoten ist. Danach muss auf dem Weg nach unten im Baum einer der folgenden Fälle gegeben sein:
Wenn der linke Kindknoten des aktuellen Knotens kein 2-Kind-Knoten ist, muss nichts unternommen werden.
Wenn der linke Kindknoten ein 2-Kind-Knoten ist und sein direkter Geschwisterknoten kein 2-Kind-Knoten, verschieben Sie einen Schlüssel von dem Geschwisterknoten in den linken Kindknoten.
Wenn der linke Kindknoten und sein direkter Geschwisterknoten 2-Kind-Knoten sind, kombinieren Sie sie mit dem kleinsten Schlüssel im Elternknoten zu einem 4Kind-Knoten. Dabei wird der Elternknoten von einem 3-Kind-Knoten zu einem 2Kind-Knoten oder von einem 4-Kind-Knoten zu einem 3-Kind-Knoten.
474
3.3 Balancierte Suchbäume
Wenn wir diesem Prozess beim Traversieren der linken Referenz nach unten folgen, landen wir auf einem 3-Kind-Knoten oder einem 4-Kind-Knoten mit dem kleinsten Schlüssel, sodass wir ihn einfach entfernen können und so den 3-Kind-Knoten in einen 2-Kind-Knoten oder den 4-Kind-Knoten in einen 3-Kind-Knoten umwandeln. Auf dem Weg nach oben im Baum teilen wir alle nicht benötigten temporären 4-Kind-Knoten.
Löschen Die gleichen Transformationen entlang des Suchpfades, die gerade zum Löschen des Minimums beschrieben wurden, lassen sich auch einsetzen, um bei der Suche nach einem beliebigen Schlüssel sicherzustellen, dass der aktuelle Knoten kein 2-KindKnoten ist. Wenn der Suchschlüssel ganz unten ist, können wir ihn einfach entfernen. Wenn der Schlüssel nicht ganz unten ist, müssen wir ihn mit seinem Nachfolger vertauschen, wie wir es bei normalen binären Suchbäumen gemacht haben. Da der aktuelle Knoten also kein 2-Kind-Knoten ist, haben wir damit das Problem auf das Löschen des Minimums in einem Teilbaum reduziert, dessen Wurzel kein 2-KindKnoten ist, und können die gerade beschriebene Prozedur auf den Teilbaum anwenden. Nach dem Löschen teilen wir wie gehabt alle verbleibenden 4-Kind-Knoten auf dem Suchpfad nach oben im Baum. Mehrere Übungen am Ende dieses Abschnitts beschäftigen sich mit Beispielen und Implementierungen zu diesen Löschalgorithmen. Wer daran interessiert ist, diese Implementierungen zu verstehen, und selbst welche entwickeln möchte, muss mit den Details vertraut sein, die in diesen Übungen zur Sprache kommen. Wer nur ein allgemeines Interesse am Studium der Algorithmen hat, sollte zumindest erkennen können, dass diese Methoden wichtig sind, da sie die erste Symboltabellen-Implementierung darstellen, in der die Such-, Einfüge- und Löschoperationen alle garantiert effizient sind, wie wir nachfolgend sehen werden.
3.3.5 Eigenschaften von Rot-Schwarz-Bäumen Für das Studium der Eigenschaften von Rot-Schwarz-Bäumen müssen Sie eigentlich nur die Übereinstimmung mit 2-3-Bäumen prüfen und dann die Analyse für 2-3-Bäume anwenden. Das Endergebnis ist, dass alle Symboltabellenoperationen in Rot-SchwarzBäumen garantiert logarithmisch zur Höhe des Baums sind (außer die Bereichssuche, die zusätzlich Zeit proportional zur Anzahl der zurückgelieferten Schlüssel kostet). Da dieser Punkt so wichtig ist, werden wir Sie immer wieder daran erinnern.
Analyse Zuerst halten wir fest, dass Rot-Schwarz-Bäume zwar nicht perfekt balanciert, aber doch beinah perfekt balanciert sind, und zwar unabhängig von der Reihenfolge, in der die Schlüssel eingefügt wurden. Dies folgt direkt aus der Eins-zu-eins-Übereinstimmung mit 2-3-Bäumen und der definierenden Eigenschaft von 2-3-Bäumen (perfekte Balance).
475
3
Suchen
Satz G: Die Höhe eines Rot-Schwarz-Baums mit N Knoten ist nicht höher als 2 lg N. Beweisskizze: Der Worst Case ist ein 2-3-Baum, der – bis auf den Pfad ganz links, der aus 3Kind-Knoten besteht – nur 2-Kind-Knoten aufweist. Der Pfad entlang der linken Referenz von der Wurzel aus ist zweimal so lang wie die Pfade der Länge ∼ lg N, die nur aus 2-Kind-Knoten bestehen. Es ist möglich, aber nicht einfach, Schlüsselfolgen zu entwickeln, die zu Rot-Schwarz-Bäumen führen, deren durchschnittliche Pfadlänge den Worst-Case-Wert 2 lg N annimmt. Wer Probleme gerne mathematisch löst, hat vielleicht Spaß daran, diesen Beweis in Übung 3.3.24 zu erbringen.
Der obere Grenzwert ist konservativ: Experimente mit Zufallseinfügungen und Einfügefolgen, wie sie für bestimmte Anwendungen typisch sind, stützen die Hypothese, dass jede Suche in einem Rot-Schwarz-Baum von N Knoten im Durchschnitt ungefähr 1,00 lg N – 0,5 Vergleiche benötigt. Außerdem wird Ihnen in der Praxis kaum eine wesentlich höhere Durchschnittszahl von Vergleichen begegnen.
Abbildung 3.48: Typischer Rot-Schwarz-Baum, der aus zufälligen Schlüsseln erstellt wurde (null-Referenzen weggelassen)
Tabelle 3.12 Durchschnittliche Anzahl an Vergleichen pro put() für FrequencyCounter unter Verwendung von RedBlackBST tale.txt
476
Wörter insgesamt
Wörter verschieden
Alle Wörter
135635
8+ Buchstaben 10+ Buchstaben
leipzig1M.txt Vergleiche
Wörter insgesamt
Wörter verschieden
Modell
Konkret
10679
13,6
13,5
21191455
14350
5131
12,6
12,1
4582
2260
11,4
11,5
Vergleiche Modell
Konkret
534580
19,4
19,1
4239597
299593
18,7
18,4
1610829
165555
17,5
17,3
3.3 Balancierte Suchbäume
Eigenschaft H: Die durchschnittliche Länge eines Pfades von der Wurzel zu einem Knoten in einem Rot-Schwarz-Baum mit N Knoten beträgt ~1,00 lg N. Nachweis: Typische Bäume wie der in Abbildung 3.48 (oder der in Abbildung 3.49, der durch Einfügen der Schlüssel in aufsteigender Reihenfolge erstellt wurde) sind ziemlich gut balanciert, wenn man sie mit typischen binären Suchbäumen vergleicht (wie den in Abbildung 3.15). Der Tabelle 3.12 können Sie entnehmen, dass die Pfadlängen (Suchkosten) für unsere FrequencyCounter-Anwendung wie erwartet um ungefähr 40 Prozent geringer sind als bei elementaren Binärsuchbäumen. Diese Performance wird von zahllosen Anwendungen und Experimenten seit der Erfindung der Rot-Schwarz-Bäume bestätigt.
In unserer Beispielstudie der Kosten der put()-Operationen für FrequencyCounter für Wörter der Länge 8 und länger stellen wir eine weitere Reduzierung der durchschnittlichen Kosten fest. Wieder einmal sehen wir also die logarithmische Performance, wie sie von dem theoretischen Modell vorhergesagt wurde, grob bestätigt, auch wenn diese Bestätigung aufgrund der durch Satz G gegebenen Garantie nicht so überraschend ist wie bei den binären Suchbäumen. Die Gesamteinsparungen bei den Suchkosten fallen geringer aus als die 40 Prozent, da wir neben den Vergleichen auch die Rotationen und Farbwechsel zählen.
Abbildung 3.49: Rot-Schwarz-Baum, der aus ansteigenden Schlüsseln erstellt wurde (null-Referenzen weggelassen)
Vergleiche
20
12
0 0
Operationen
14350
Abbildung 3.50: Kosten für java FrequencyCounter 8 < tale.txt unter Verwendung von RedBlackBST
477
3
Suchen
In der get()-Methode von Rot-Schwarz-Bäumen wird die Knotenfarbe nicht untersucht, sodass der Balancierungsmechanismus keinen Overhead verursacht. Die Suche ist schneller als bei elementaren Binärsuchbäumen, da der Baum balanciert ist. Jeder Schlüssel wird nur einmal eingefügt, kann aber Teil sehr vieler Suchoperationen sein, sodass wir am Ende Suchzeiten erhalten, die fast optimal sind (da die Bäume nahezu balanciert sind und während der Suche der Baum nicht balanciert werden muss) und zudem nur relativ geringe Kosten aufweisen (im Gegensatz zur Binärsuchbäumen sind Einfügungen garantiert logarithmisch). Die innere Schleife der Suche besteht aus einem Vergleich gefolgt von der Aktualisierung einer Referenz, was recht kompakt ist – vergleichbar der inneren Schleife bei der Binärsuche (Vergleich und Indexarithmetik). Diese Implementierung ist die erste, die logarithmische Garantien sowohl für das Suchen als auch für das Einfügen bietet. Außerdem weist sie eine stringente innere Schleife auf, was sie für den Einsatz in einer Vielzahl verschiedener Anwendungen, einschließlich Programmbibliotheken, prädestiniert.
Geordnete Symboltabellen-API Eines der reizvollsten Merkmale von Rot-Schwarz-Bäumen ist, dass der komplizierte Code auf put() (und die Löschmethoden) beschränkt ist. Unser Code für Minimum/ Maximum, Auswahl, Rang, Floor, Ceiling und Bereichsabfragen kann unverändert aus Standard-Binärsuchbäumen übernommen werden, da er auf binären Suchbäumen operiert und keinen Bezug auf die Knotenfarbe nimmt. Algorithmus 3.4 (Listing 3.16) bildet zusammen mit diesen Methoden (und den Löschmethoden) eine vollständige Implementierung unserer geordneten Symboltabellen-API. Außerdem profitieren alle Methoden von der fast perfekten Balance im Baum, da die benötigte Zeit im Höchstfall proportional zur Baumhöhe ist. Satz G in Kombination mit Satz E reicht, um eine logarithmische Performance-Garantie für alle von ihnen zu geben.
Satz I: In einem Rot-Schwarz-Baum benötigen die folgenden Operationen im schlimmsten Fall logarithmische Zeit: Suchen, Einfügen, Minimum finden, Maximum finden, Nächstkleineren finden, Nächstgrößeren finden, Rang, Auswahl, Minimum löschen, Maximum löschen, Löschen und Bereichszählung. Beweis: Die Methoden get(), put() und die Löschoperationen haben wir gerade diskutiert. Bei den anderen kann der Code aus Abschnitt 3.2 wortwörtlich übernommen werden (er ignoriert einfach die Knotenfarbe). Aus den Sätzen E und G lässt sich die garantiert logarithmische Performance ableiten sowie die Tatsache, dass jeder Algorithmus auf jeden untersuchten Knoten eine konstante Anzahl von Operationen ausführt.
478
3.3 Balancierte Suchbäume
Denkt man darüber nach, ist es schon ziemlich bemerkenswert, dass wir derartige Garantien geben können. In einer Welt, die von Informationen überflutet wird und in der es Tabellen mit Billionen oder Billiarden von Einträgen gibt, bedeutet dies nicht weniger, als dass wir auf diesen Tabellen für jede dieser Operationen garantiert nur einige Dutzend Vergleiche benötigen.
Tabelle 3.13 Zusammenfassung der Kosten für Symboltabellen-Implementierungen (aktualisiert) Algorithmus (Datenstruktur)
Kosten für den schlimmsten Fall (nach N Einfügungen)
Kosten für den durchschnittlichen Fall (nach N zufälligen Einfügeoperationen)
Effiziente Unterstützung für ordnungsbasierte Operationen?
Suchen
Einfügen
Erfolgreiches Suchen
Einfügen
Sequenzielle Suche (ungeordnete verkettete Liste)
N
N
N/2
N
nein
Binäre Suche (geordnetes Array)
lg N
N
lg N
N/2
ja
Binärer Suchbaum (BST)
N
N
1,39 lg N
1,39 lg N
ja
2-3-Baum (Rot-SchwarzBaum)
2 lg N
2 lg N
1,00 lg N
1,00 lg N
ja
479
3
Suchen
3.3
Fragen und Antworten
Frage: Warum lassen wir nicht 3-Kind-Knoten in beide Richtungen weisen und warum erlauben wir nicht auch 4-Kind-Knoten in Bäumen? Antwort: Beides sind attraktive Varianten und seit vielen Jahrzehnten in Verwendung. In den Übungen können Sie mehr darüber lernen. Die Konvention, nach links zu weisen, reduziert die Anzahl der Fälle und erfordert deshalb erheblich weniger Code. Frage: Warum verwenden wir kein Array von Key-Werten, um 2-, 3- und 4-Kind-Knoten mit einem einzigen Node-Typ zu repräsentieren? Antwort: Gute Frage. Das ist genau das, was wir bei B-Bäumen (siehe Kapitel 6) machen, bei denen wir viel mehr Schlüssel pro Knoten erlauben. Bei den kleinen Knoten in 2-3-Bäumen ist der Overhead des Arrays ein zu hoher Preis. Frage: Wenn wir einen 4-Kind-Knoten aufteilen, setzen wir manchmal die Farbe des rechten Knotens in rotateRight() auf RED und dann in flipColors() direkt wieder auf BLACK. Ist das nicht überflüssig? Antwort: Ja, und wir färben manchmal sogar unnötigerweise den mittleren Knoten um. Im Großen und Ganzen ist jedoch das Neusetzen einiger Extrabits absolut zu vernachlässigen angesichts der Verbesserung von linearer zu logarithmischer Performance, die wir für alle Operationen erhalten. In performancekritischen Anwendungen können Sie außerdem diese zusätzlichen Tests vermeiden, indem Sie den Code für rotateRight() und flipColors() als Inline-Code formulieren. Wir setzen diese Methoden auch zum Löschen ein und finden, dass sie leichter zu verwenden, zu verstehen und zu warten sind, indem wir sicherstellen, dass sie die perfekte schwarze Balance wahren.
480
Allgemeine Übungen
3.3
Allgemeine Übungen
1.
Zeichnen Sie den 2-3-Baum, den Sie erhalten, wenn Sie die Buchstaben E A S Y Q U T I O N in genau dieser Reihenfolge in einen anfänglich leeren Baum einfügen.
2.
Zeichnen Sie den 2-3-Baum, den Sie erhalten, wenn Sie die Buchstaben Y L P M X H C R A E S in genau dieser Reihenfolge in einen anfänglich leeren Baum einfügen.
3.
Finden Sie eine Einfügereihenfolge für die Schlüssel S E A R C H X M, die zu einem 2-3-Baum der Höhe 1 führt.
4.
Beweisen Sie, dass die Höhe eines 2-3-Baums mit N Schlüsseln zwischen ~⎢ ⎣ log3 N ⎥ ⎦≈ 0,63lg N (für einen Baum, der nur aus 3-Kind-Knoten besteht) und zwischen dem Wert ~⎢ ⎣ lg N ⎥ ⎦(für einen Baum, der nur aus 2-Kind-Knoten besteht) liegt.
5.
Die nebenstehende Abbildung zeigt alle strukturell verschiedenen 2-3-Bäume mit N Schlüsseln für N von 1 bis 6 (wobei die Ordnung der Teilbäume unberücksichtigt bleibt). Zeichnen Sie alle strukturell verschiedenen Bäume für N = 7, 8, 9 und 10.
6.
Ermitteln Sie die Wahrscheinlichkeit, dass jeder der 2-3Bäume aus Übung 3.3.5 sich dadurch ergibt, dass N verschiedene zufällige Schlüssel in einen anfänglich leeren Baum eingefügt werden.
7.
Zeichnen Sie Diagramme wie das aus Abbildung 3.31 für die anderen fünf Fälle aus Abbildung 3.30.
8.
Zeigen Sie alle Möglichkeiten, wie ein 4-Kind-Knoten mit drei 2-Kind-Knoten, die über rote Referenzen (nicht notwendigerweise nach links weisend) verbunden sind, dargestellt werden könnte.
9.
Welche der folgenden Strukturen sind Rot-Schwarz-Bäume?
(i)
C
(ii)
A
D
Y H D
(iii)
E C
H F
G
(iv) E
Z
H
B A
C
Y D
H
Z
A
Y T
A
10.
Zeichnen Sie den Rot-Schwarz-Baum, den Sie erhalten, wenn Sie Elemente mit den Schlüsseln E A S Y Q U T I O N in genau dieser Reihenfolge in einen anfänglich leeren Baum einfügen.
11.
Zeichnen Sie den Rot-Schwarz-Baum, den Sie erhalten, wenn Sie Elemente mit den Schlüsseln Y L P M X H C R A E S in genau dieser Reihenfolge in einen anfänglich leeren Baum einfügen.
12.
Zeichnen Sie den Rot-Schwarz-Baum, den Sie nach jeder Transformation (Farbwechsel oder Rotation) beim Einfügen von P für unseren Testclient erhalten.
481
3
Suchen
13.
Wahr oder falsch: Wenn Sie Schlüssel in aufsteigender Reihenfolge in einen RotSchwarz-Baum einfügen, steigt die Baumhöhe monoton.
14.
Zeichnen Sie den Rot-Schwarz-Baum, den Sie erhalten, wenn Sie die Buchstaben A bis K der Reihe nach in einen anfänglich leeren Baum einfügen, und beschreiben Sie dann, was im Allgemeinen passiert, wenn Bäume durch Einfügen von Schlüsseln in aufsteigender Reihenfolge erstellt werden (siehe Abbildung im Text).
15.
Beantworten Sie die vorherigen beiden Fragen für den Fall, dass die Schlüssel in absteigender Reihenfolge eingefügt werden.
16.
Wie sieht das Ergebnis aus, wenn Sie n in den nachfolgenden Rot-Schwarz-Baum einfügen (es wird nur der Suchpfad gezeigt und Sie müssen nur diese Knoten in Ihrer Antwort berücksichtigen)? j u t s r q p l k
o m
482
17.
Erzeugen Sie zwei zufällige Rot-Schwarz-Bäume aus 16 Knoten. Zeichnen Sie sie (entweder von Hand oder mit einem Programm). Vergleichen Sie sie mit den (unbalancierten) binären Suchbäumen, die aus den gleichen Schlüsseln erstellt wurden.
18.
Zeichnen Sie alle strukturell verschiedenen Rot-Schwarz-Bäume mit N Schlüsseln, für N von 2 bis 10 (siehe Übung 3.3.5).
19.
Mit einem Bit pro Knoten für die Farbe können wir 2-, 3- und 4-Kind-Knoten repräsentieren. Wie viele Bits pro Knoten benötigen wir, um 5-, 6-, 7- und 8-KindKnoten bei einem Binärbaum zu repräsentieren?
20.
Berechnen Sie die interne Pfadlänge in einem perfekt balancierten binären Suchbaum von N Knoten, wenn N eine Zweierpotenz minus 1 ist.
21.
Erzeugen sie einen Testclient TestRB.java, der auf Ihrer Lösung von Übung 3.2.10 basiert.
22.
Finden Sie eine Folge von Schlüsseln, die Sie in einen binären Suchbaum und in einen Rot-Schwarz-Baum einfügen, sodass die Höhe des binären Suchbaums geringer ist als die Höhe des Rot-Schwarz-Baums. Oder beweisen Sie, dass es keine solche Folge von Schlüsseln gibt.
Knifflige Aufgaben
3.3
Knifflige Aufgaben
23.
2-3-Bäume ohne Balancebeschränkungen: Entwickeln Sie eine Implementierung der grundlegenden Symboltabellen-API, die 2-3-Bäume als zugrunde liegende Datenstruktur verwendet, die nicht notwendigerweise balanciert sind. Erlauben Sie, dass 3-Kind-Knoten zu beiden Seiten weisen können. Hängen Sie den neuen Knoten unten mit einer schwarzen Referenz an, wenn Sie ihn unten in einen 3-Kind-Knoten einfügen. Führen Sie Experimente aus, um eine Hypothese über die durchschnittliche Pfadlänge in einem Baum bestehend aus N Zufallseinfügungen zu entwickeln.
24.
Worst Case für Rot-Schwarz-Bäume: Zeigen Sie, wie ein Rot-Schwarz-Baum erstellt wird, und beweisen Sie, dass im schlimmsten Fall fast alle Pfade von der Wurzel zu einer null-Referenz in einem Rot-Schwarz-Baum mit N Knoten die Länge 2 lg N aufweisen.
25.
Top-Down-2-3-4-Bäume: Entwickeln Sie eine Implementierung der grundlegenden Symboltabellen-API, die balancierte 2-3-4-Bäume als zugrunde liegende Datenstruktur verwendet. Nutzen Sie dazu die Rot-Schwarz-Repräsentation und die im Text beschriebene Einfügemethode, bei der 4-Kind-Knoten auf dem Weg nach unten im Suchpfad durch Farbwechsel geteilt werden und auf dem Weg nach oben balanciert werden.
26.
Einziger Top-Down-Durchgang: Entwickeln Sie eine modifizierte Version Ihrer Lösung zu Übung 3.3.25, die ohne Rekursion auskommt. Teilen und balancieren Sie alle 4-Kind-Knoten (und balancieren Sie 3-Kind-Knoten) auf dem Weg nach unten im Baum und schließen Sie mit einer Einfügung am Ende ab.
27.
Rechts weisende rote Referenzen erlauben: Entwickeln Sie eine modifizierte Version Ihrer Lösung zu Übung 3.3.25, die nach rechts weisende rote Referenzen im Baum erlaubt.
28.
Bottom-Up-2-3-4-Bäume: Entwickeln Sie eine Implementierung der grundlegenden Symboltabellen-API, die balancierte 2-3-4-Bäume als zugrunde liegende Datenstruktur verwendet. Nutzen Sie dazu die Rot-Schwarz-Repräsentation und eine BottomUp-Einfügemethode (von unten nach oben), die auf dem gleichen rekursiven Ansatz basiert wie Algorithmus 3.4 (Listing 3.16). Ihre Einfügemethode sollte nur die Folge von 4-Kind-Knoten (sofern vorhanden) ganz unten im Suchpfad teilen.
29.
Optimale Speicherung: Überarbeiten Sie RedBlackBST so, dass es keinen zusätzlichen Speicher für das Farbbit belegt, und zwar unter Anwendung des folgenden Tricks: Um einen Knoten rot zu färben, vertauschen Sie dessen beide Referenzen. Um anschließend zu testen, ob ein Knoten rot ist, testen Sie, ob sein linker Kindknoten größer ist als sein rechter Kindknoten. Sie müssen die Vergleiche modifizieren, um das potenzielle Vertauschen der Referenzen zu integrieren. Dieser Trick ersetzt die Bitvergleiche durch Schlüsselvergleiche, die wahrscheinlich teurer sind, aber er zeigt auch, dass das Bit in den Knoten im Bedarfsfall eliminiert werden kann.
483
3
Suchen
484
30.
Software-Caching: Wandeln Sie RedBlackBST so ab, dass der Node, auf den zuletzt zugegriffen wurde, in einer Instanzvariablen gespeichert wird, damit Sie in konstanter Zeit darauf zugreifen können, wenn die nächste put()- oder get()-Methode den gleichen Schlüssel verwendet (Übung 3.1.25).
31.
Baum zeichnen: Erweitern Sie RedBlackBST um eine Methode draw(), die Diagramme von Rot-Schwarz-Bäumen zeichnet (siehe Übung 3.2.38). Die Diagramme sollen genauso aufgebaut sein, wie die Diagramme im Buchtext.
32.
AVL-Bäume: Ein AVL-Baum ist ein binärer Suchbaum, bei dem die Höhe jedes Knotens von der seines Geschwisterknotens um höchstens 1 abweicht. (Die ältesten Algorithmen zu balancierten Bäumen basieren darauf, mit Rotationen die Höhenbalance in AVL-Bäumen zu wahren.) Zeigen Sie, dass das Färben von roten Referenzen, die von Knoten gerader Höhe zu Knoten ungerader Höhe in einem AVL-Baum zeigen, zu einem (perfekt balancierten) 2-3-4-Baum führt, bei dem rote Referenzen nicht unbedingt nach links weisen müssen. Zusatzpunkte: Entwickeln Sie eine Implementierung der Symboltabellen-API, die einen solchen Baum als zugrunde liegende Datenstruktur verwendet. Ein Ansatz besteht darin, ein Feld für die Höhe in jedem Knoten anzulegen und die Rotationen nach den rekursiven Aufrufen auszuführen, um die Höhe nach Bedarf anzupassen. Bei einem anderen Ansatz werden die Rot-Schwarz-Repräsentation und Methoden wie moveRedLeft() und moveRedRight() verwendet (Übung 3.3.39 und 3.3.40).
33.
Verifizierung: Ergänzen Sie RedBlackBST um eine Methode is23(), die nachprüft, dass kein Knoten mit zwei roten Referenzen verbunden ist und dass es keine nach rechts weisenden roten Referenzen gibt. Setzen Sie eine weitere Methode isBalanced() auf, die nachprüft, dass alle Pfade von der Wurzel zu einer nullReferenz die gleiche Anzahl von schwarzen Referenzen haben. Kombinieren Sie diese Methoden mit Code aus isBST() in Übung 3.2.32, um eine Methode isRedBlackBST() zu erzeugen, die prüft, dass der Baum ein Rot-Schwarz-Baum ist.
34.
Alle 2-3-Bäume: Schreiben Sie Code, um alle strukturell verschiedenen 2-3-Bäume der Höhe 2, 3 und 4 zu erzeugen. Es gibt 2, 7 beziehungsweise 122 solcher Bäume. (Hinweis: Verwenden Sie eine Symboltabelle.)
35.
2-3-Bäume: Schreiben Sie ein Programm TwoThreeST.java, das zwei Knotentypen verwendet, um 2-3-Suchbäume direkt zu implementieren.
36.
2-3-4-5-6-7-8-Bäume: Beschreiben Sie Algorithmen für Such- und Einfügeoperationen in balancierten 2-3-4-5-6-7-8-Bäumen.
37.
Erinnerungslos: Zeigen Sie, dass Rot-Schwarz-Bäume nicht ohne Erinnerung sind: Wenn Sie beispielsweise einen Schlüssel einfügen, der kleiner als alle anderen Schlüssel im Baum ist, und dann direkt das Minimum löschen, erhalten Sie unter Umständen einen anderen Baum.
Knifflige Aufgaben
38.
Grundsätzlicher Rotationssatz: Zeigen Sie, dass jeder binäre Suchbaum durch eine Folge von Links- und Rechtsrotationen in einen anderen binären Suchbaum mit dem gleichen Satz Schlüssel umgewandelt werden kann.
39.
Das Minimum löschen: Implementieren Sie die deleteMin()-Operation für RotSchwarz-Bäume, indem Sie die Übereinstimmung mit den Transformationen wahren, die im Text für das Herunterwandern am linken Rand des Baums beschrieben wurden. Achten Sie darauf, die Invariante nicht zu verletzen, dass der aktuelle Knoten kein 2-Kind-Knoten ist. Lösung: private Node moveRedLeft(Node h) { // Angenommen, h ist rot und sowohl h.left und h.left.left // sind schwarz, dann färbe h.left oder einen seiner Kindknoten rot. flipColors(h); if (isRed(h.right.left)) { h.right = rotateRight(h.right); h = rotateLeft(h); } return h; } public void deleteMin() { if (!isRed(root.left) && !isRed(root.right)) root.color = RED; root = deleteMin(root); if (!isEmpty()) root.color = BLACK; } private Node deleteMin(Node h) { if (h.left == null) return null; if (!isRed(h.left) && !isRed(h.left.left)) h = moveRedLeft(h); h.left = deleteMin(h.left); return balance(h); }
Dieser Code geht davon aus, dass es eine Methode balance() gibt, die aus der Codezeile if (isRed(h.right)) h = rotateLeft(h);
gefolgt von den letzten fünf Zeilen der rekursiven put()-Methode aus Algorithmus 3.4 (Listing 3.16) besteht und einer flipColors()-Implementierung, die die drei Farben ins Komplement ändert (anstelle der Methode, die im Text für Einfügungen vorgestellt wurde). Zum Löschen setzen wir den Elternknoten auf BLACK und die beiden Kindknoten auf RED.
485
3
Suchen
40.
Das Maximum löschen: Implementieren Sie die deleteMax()-Operation für RotSchwarz-Bäume. Beachten Sie, dass die notwendigen Transformationen sich etwas von denen der vorherigen Übung unterscheiden, da die roten Referenzen nach links weisen. Lösung: private Node moveRedRight(Node h) { // Angenommen, h ist rot und sowohl h.right und h.right.left // sind schwarz, dann färbe h.right oder einen seiner Kindknoten rot. flipColors(h) if (!isRed(h.left.left)) h = rotateRight(h); return h; } public void deleteMax() { if (!isRed(root.left) && !isRed(root.right)) root.color = RED; root = deleteMax(root); if (!isEmpty()) root.color = BLACK; } private Node deleteMax(Node h) { if (isRed(h.left)) h = rotateRight(h); if (h.right == null) return null; if (!isRed(h.right) && !isRed(h.right.left)) h = moveRedRight(h); h.right = deleteMax(h.right); return balance(h); }
41.
Löschen: Implementieren Sie die delete()-Operation für Rot-Schwarz-Bäume und kombinieren Sie dafür die Methoden der vorherigen zwei Übungen mit der delete()-Operation für binäre Suchbäume. Lösung: public void delete(Key key) { if (!isRed(root.left) && !isRed(root.right)) root.color = RED; root = delete(root, key); if (!isEmpty()) root.color = BLACK; } private Node delete(Node h, Key key) { if (key.compareTo(h.key) < 0) { if (!isRed(h.left) && !isRed(h.left.left)) h = moveRedLeft(h); h.left = delete(h.left, key); } else
486
Knifflige Aufgaben
{ if (isRed(h.left)) h = rotateRight(h); if (key.compareTo(h.key) == 0 && (h.right == null)) return null; if (!isRed(h.right) && !isRed(h.right.left)) h = moveRedRight(h); if (key.compareTo(h.key) == 0) { h.val = get(h.right, min(h.right).key); h.key = min(h.right).key; h.right = deleteMin(h.right); } else h.right = delete(h.right, key); } return balance(h); }
487
3
Suchen
3.3
Experimente
42.
Rote Knoten zählen: Schreiben Sie ein Programm, das den Prozentsatz der roten Knoten in einem gegebenen Rot-Schwarz-Baum berechnet. Testen Sie Ihr Programm, indem Sie 100 Versuche durchführen, bei denen Sie N zufällige Schlüssel in einen anfänglich leeren Baum für N=104, 105 und 106 einfügen, und formulieren Sie eine Hypothese.
43.
Kostengrafiken: Überarbeiten Sie RedBlackBST, um Grafiken wie in diesem Abschnitt zu erzeugen, die die Kosten der einzelnen im Zuge der Berechung ausgeführten put()-Operation darstellen (Übung 3.1.38).
44.
Durchschnittliche Suchzeit: Führen Sie empirische Studien aus, um Mittelwert und Standardabweichung der durchschnittlichen Pfadlänge zu einem zufälligen Knoten (interne Pfadlänge geteilt durch die Baumhöhe) in einem Rot-SchwarzBaum zu berechnen, der durch Einfügen von N zufälligen Schlüsseln in einen anfänglich leeren Baum für N von 1 bis 10000 erstellt wurde.
45.
Rotationen zählen: Überarbeiten Sie Ihr Programm für Übung 3.3.43 so, dass es die Anzahl der Rotationen und Knotenteilungen, die benötigt werden, um die Bäume zu erstellen, grafisch darstellt. Diskutieren Sie die Ergebnisse.
46.
Höhe: Überarbeiten Sie Ihr Programm für Übung 3.3.43 so, dass es die Höhe der Rot-Schwarz-Bäume grafisch darstellt. Diskutieren Sie die Ergebnisse.
20
13
Vergleiche
lg N − 0,5
0 100
Operationen
10000
Abbildung 3.51: Durchschnittliche Pfadlänge zu einem zufällige Knoten in einem Rot-Schwarz-Baum, der aus zufälligen Schlüsseln erstellt wurde
488
3.4 Hashtabellen
3.4
Hashtabellen
Wenn die Schlüssel kleine Integer-Werte sind, können wir für die Implementierung einer ungeordneten Symboltabelle ein Array verwenden mit dem Schlüssel als Arrayindex. In einer solchen Datenstruktur können wir den mit dem Schlüssel i verbundenen Wert in dem Arrayeintrag i speichern, wo er jederzeit direkt abgreifbar ist. In diesem Abschnitt beschäftigen wir uns mit Hashing, einer Erweiterung dieses einfachen Prinzips, mit der sich auch kompliziertere Typen von Schlüsseln handhaben lassen. Grundlage für den Zugriff auf Schlüssel-Wert-Paare über Arrays sind passende arithmetische Operationen, die die Schlüssel in Arrayindizes umwandeln. Suchalgorithmen, die Hashing verwenden, beste- Schlüssel Hash Wert 0 b pqr hen aus zwei Teilen. Im ersten Teil wird eine Hasha 2 xyz funktion berechnet, die den Suchschlüssel in einen b 0 pqr 1 c 3 ijk Arrayindex umwandelt. Im Idealfall lassen sich d 2 uvw voneinander verschiedene Schlüssel auf voneinana xyz 2 der verschiedene Indizes abbilden. Dieses Ideal ist d uvw im Allgemeinen allerdings nicht zu erreichen, Kollision 3 sodass wir mit der Möglichkeit rechnen müssen, c ijk dass zwei oder mehr Schlüssel auf den gleichen Arrayindex abgebildet werden. Deshalb besteht der . . zweite Teil einer Hashsuche aus der sogenannten . Kollisionsauflösung, die sich dieser Situation annimmt. Zuerst werden wir beschreiben, wie sich M-1 Hashfunktionen berechnen lassen, und danach werden wir zwei verschiedene Ansätze zur Kolli- Abbildung 3.52: Hashing: Kern des Probsionsauflösung kennenlernen: Hashing mit Verket- lems tung und mit linearer Sondierung. Hashing ist ein klassisches Beispiel für einen Kompromiss zwischen Rechenzeit und Speicherbedarf. Gäbe es keine Speicherbeschränkungen, könnten wir jede Suche mit nur einem Speicherzugriff ausführen, indem wir einfach den Schlüssel als Index in ein (potenziell riesiges) Array verwenden. Dieser Idealfall ist jedoch nicht oft gegeben, da der erforderliche Speicherbedarf nicht vertretbar ist, wenn die Anzahl der möglichen Schlüsselwerte riesig ist. Wenn es andererseits keine Beschränkung hinsichtlich der Rechenzeit gäbe, wäre bei einer sequenziellen Suche in einem ungeordneten Array der Speicherbedarf eventuell nur minimal. Hashing bietet eine Möglichkeit, Rechenzeit und Speicherbedarf in einem vertretbaren Rahmen zu halten, um ein Gleichgewicht zwischen diesen beiden Extremen herzustellen. Um in Hashingalgorithmen Zeit gegen Speicher abzuwägen, müssen wir lediglich die Parameter anpassen, ohne den Code neu zu schreiben. Für die richtige Wahl der Parameterwerte greifen wir auf die klassischen Ergebnisse der Wahrscheinlichkeitstheorie zurück. Auch wenn im Rahmen dieses Buches eine eingehende Diskussion der Wahrscheinlichkeitstheorie als Triumph der mathematischen Analyse nicht möglich ist, sind die von uns betrachteten Hashingalgorithmen, die sich die Erkenntnisse dieser Theorie zunutze machen, relativ einfach und weit verbreitet. Mit Hashing können Sie Such- und Einfü-
489
3
Suchen
geoperationen für Symboltabellen implementieren, die in typischen Anwendungen konstante (amortisierte) Zeit pro Operation benötigen, was Hashing in vielen Situationen zur Methode der Wahl zur Implementierung grundlegender Symboltabellen macht.
3.4.1 Hashfunktionen Das erste Problem, das wir meistern müssen, ist die Berechnung der Hashfunktion, die die Schlüssel in Arrayindizes umwandelt. Wenn wir ein Array haben, das M Schlüssel-Wert-Paare aufnehmen kann, dann benötigen wir eine Hashfunktion, die jeden gegebenen Schlüssel in einen Arrayindex umwandelt: und zwar in einen Integer im Bereich [0, M-1]. Wir suchen eine Hashfunktion, die sowohl leicht zu berechnen ist als auch die Schlüssel gleichmäßig verteilt: Für jeden Schlüssel sollte jeder Integer zwischen 0 und M−1 gleich wahrscheinlich sein (unabhängig vom jeweiligen Schlüssel) – ein Idealzustand, der sich nicht unbedingt gleich erschließt; um Hashing zu verstehen, sollten Sie deshalb zuerst sorgfältig darüber nachdenken, wie eine solche Funktion zu implementieren ist. Die Hashfunktion hängt von dem Schlüsseltyp ab. Genau genommen benötigen wir für jeden Schlüsseltyp, den wir verwenden, eine eigene Hashfunktion. Wenn der Schlüssel aus einer Zahl besteht, beispielsweise eine Sozialversicherungsnummer, könnten wir mit dieser Zahl direkt loslegen. Wenn der Schlüssel aus einem String besteht, beispielsweise dem Namen einer Person, müssen wir den String in eine Zahl umwandeln; und wenn der Schlüssel aus mehreren Teilen besteht, wie z.B. eine Mailadresse, müssen wir die Teile irgendwie kombinieren. Für viele häufiger benötigte Schlüsseltypen können wir auf die von Java angebotenen Standardimplementierungen zurückgreifen. Nichtsdestotrotz werden wir kurz auf potenzielle Implementierungen für diverse Schlüsseltypen eingehen, damit Sie wissen, was später auf Sie zukommt, wenn Sie für selbst erzeugte Schlüsseltypen eigene Implementierungen benötigen.
Ein typisches Beispiel Angenommen wir hätten es mit einer Anwendung zu tun, in der die Schlüssel amerikanische Sozialversicherungsnummern sind. Eine solche Nummer (z.B. 123-45-6789) ist eine neunstellige Zahl, die aus drei Feldern besteht. Das erste Feld identifiziert den geografischen Bereich, in der die Nummer ausgestellt wurde (Sozialversicherungsnummern, die im ersten Feld die Zahlen 035 aufweisen, stammen beispielsweise aus Rhode Island und Nummern mit den Zahlen 214 im ersten Feld aus Maryland). Die anderen beiden Felder identifizieren die jeweilige Person. Es gibt eine Milliarde (109) verschiedene Sozialversicherungsnummern; doch angenommen unsere Anwendung muss nur einige Hundert Schlüssel verarbeiten, dann kämen wir mit einer Hashtabelle der Größe M=1000 aus. Ein Ansatz zur Implementierung einer Hashfunktion besteht darin, drei Ziffern aus dem Schlüssel zu nutzen. Dabei ist es wahrscheinlich ratsamer, die drei Ziffern aus dem dritten Feld und nicht aus dem ersten Feld zu nehmen (da Kunden nicht immer gleichmäßig über alle geografischen Bereiche verteilt sind). Ein besserer Ansatz wäre es allerdings, alle neun Ziffern als Integer-Wert zu betrachten und dann die nachfolgend beschriebenen Hashfunktionen für Integer einzusetzen.
490
3.4 Hashtabellen
Positive Integer Hash Das am häufigsten verwendete Verfahren zum Hashen von Inte- Schlüssel Hash (M = 100) (M = 97) ger-Werten wird modulares Hashing genannt: Wir wählen für die Arraygröße M eine Primzahl und berechnen für jeden positiven Integer-Schlüssel k den Rest der Division von k durch M. Die Berechnung dieser Funktion ist sehr leicht (k % M, in Java) und sehr effektiv, um die Schlüssel gleichmäßig zwischen 0 und M−1 zu verteilen. Wenn M keine Primzahl ist, kann es passieren, dass nicht alle Bits des Schlüssels berücksichtigt werden – was bedeutet, dass wir eine Gelegenheit vertan haben, die Werte gleichmäßig zu verteilen. Nehmen wir z.B. an, die Schlüssel seien Zahlen zur Basis 10 und M sei gleich 10k, dann werden nur die k niedrigstwertigen Ziffern verwendet. Eine solche Wahl kann Probleme bereiten, beispielsweise wenn die Schlüssel Telefonvorwahlen sind und M=100 ist. Aus historischen Gründen haben die meisten Vorwahlen in den Vereinigten Staaten in der Mitte die Ziffer 0 oder 1, sodass diese Entscheidung die Werte kleiner 20 stark begünstigt, während die Verwendung der Primzahl 97 die Werte besser gestreut hätte (eine Primzahl, die nicht so nah an 100 ist, wäre sogar noch besser). Ein ähnliches Beispiel sind die IP-Adressen im Internet. Hierbei handelt es sich um binäre Zahlen, die aus den gleichen historischen Gründen wie die Telefonvorwahlen Abbildung 3.53: Modulares Hashing keine Zufallszahlen sind. Deshalb müssen wir eine Tabellengröße wählen, die eine Primzahl ist (vor allem jedoch keine Zweierpotenz), wenn wir sie mit modularem Hashing verteilen wollen.
Gleitkommazahlen Wenn die Schlüssel reelle Zahlen zwischen 0 und 1 sind, könnten wir einfach mit M multiplizieren und auf den nächsten Integer abrunden, um einen Index zwischen 0 und M−1 zu erhalten. Obwohl dieser Ansatz naheliegt, hat er den Nachteil, dass er den höchstwertigen Bits mehr Gewicht verleiht; die niedrigstwertigen Bits spielen keine Rolle. Diese Situation lässt sich unter anderem durch die Anwendung des modularen Hashing auf die Binärdarstellung des Schlüssels lösen (einen Weg, den Java eingeschlagen hat).
Strings Modulares Hashing lässt sich auch auf lange Schlüssel wie z.B. Strings anwenden: Wir behandeln sie einfach wie riesige Integer. So berechnet beispielsweise der Code aus Listing 3.17 eine modulare Hashfunktion für einen String s. Zur Erinnerung möchten wir Sie darüber informieren, dass charAt() in Java einen char-Wert zurückliefert, bei dem es sich um einen nicht-negativen 16-Bit-Integer handelt. Wenn R größer ist als irgendein Zeichenwert, besteht diese Berechnung darin, den String als N-
491
3
Suchen
stelligen Integer zur Basis R zu behandeln und den Rest der Division dieser Zahl durch M zu berechnen. Ein klassischer Algorithmus, der als Horner-Schema bekannt ist, erledigt diese Aufgabe mit N Multiplikationen, Additionen und Modulo-Operationen. Wurde der Wert von R ausreichend klein gewählt, sodass kein Überlauf auftritt, dann ist das Ergebnis wie gewünscht ein Integer zwischen 0 und M-1. Die Verwendung einer kleinen Primzahl wie 31 stellt sicher, dass die Bits aller Zeichen eine Rolle spielen. Java verwendet in seiner Standardimplementierung für String eine Methode wie diese. int hash = 0; for (int i = 0; i < s.length(); i++) hash = (R * hash + s.charAt(i)) % M; Listing 3.17: Hashing bei einem String-Schlüssel
Zusammengesetzte Schlüssel Wenn der Schlüsseltyp aus mehreren Integer-Feldern besteht, können wir diese normalerweise so mischen, wie gerade für String-Werte beschrieben. Angenommen die Suchschlüssel sind vom Typ Date, der drei Integer-Felder aufweist: day (zweistelliger Tag), month (zweistelliger Monat) und year (vierstelliges Jahr), dann berechnet sich die Zahl wie folgt: int hash = (((day * R + month) % M ) * R + year) % M;
Wurde der Wert von R ausreichend klein gewählt, sodass kein Überlauf auftritt, ist das Ergebnis wie gewünscht ein Integer zwischen 0 und M−1. In diesem Fall könnten wir die Kosten der inneren %M-Operation sparen, indem wir eine relativ niedrige Primzahl (wie 31) für R wählen. Wie bei Strings lässt sich mit dieser Methode so gut wie jede Anzahl von Feldern verarbeiten.
Java-Konventionen Java hilft uns, das grundlegende Problem zu lösen, dass jeder Datentyp eine Hashfunktion benötigt, indem sichergestellt ist, dass jeder Datentyp eine Methode namens hashCode() erbt, die einen 32-Bit-Integer zurückliefert. Dabei muss die Implementierung von hashCode() für einen Datentyp konsistent mit equals sein. Das heißt, wenn a.equals(b) wahr ist, dann muss a.hashCode() den gleichen numerischen Wert liefern wir b.hashCode(). Dies erlaubt den Umkehrschluss, dass bei unterschiedlichen hashCode()-Werten die Objekte nicht gleich sind. Wenn die hashCode()-Werte dagegen gleich sind, können die Objekte sowohl gleich als auch ungleich sein, und wir müssen mit equals() testen, welcher Fall vorliegt. Diese Konvention ist die Grundlage dafür, dass Clients eine hashCode()-Methode für Symboltabellen verwenden können. Beachten Sie, dass dies bedeutet, dass Sie sowohl hashCode() als auch equals() überschreiben müssen, wenn Sie Hashing auf einen benutzerdefinierten Typ anwenden wollen.
492
3.4 Hashtabellen
Die Standardimplementierung liefert die Maschinenadresse des Schlüsselobjekts zurück, was selten in Ihrem Interesse ist. Erfreulicherweise bietet Java bereits einige vordefinierte hashCode()-Implementierungen an, die die Standardimplementierungen für etliche häufiger verwendete Typen überschreiben (einschließlich String, Integer, Double, File und URL).
Einen hashCode() in einen Arrayindex umwandeln Da unser Ziel ein Arrayindex und kein 32-Bit-Integer ist, kombinieren wir in unseren Implementierungen hashCode() mit modularem Hashing, um Integer zwischen 0 und M−1 zu erzeugen: private int hash(Key x) { return (x.hashCode() & 0x7fffffff) % M;
}
Der Code maskiert das Vorzeichenbit (um die 32-Bit-Zahl in einen nicht-negativen 31Bit-Integer zu verwandeln) und berechnet dann, wie beim modularen Hashing, den Rest der Division durch M. Programmierer verwenden bei Code wie diesem in der Regel Primzahlen für die Größe der Hashtabelle M, um möglichst alle Bits des Hashcodes zu nutzen. Hinweis: Um Sie nicht zu verwirren, verzichten wir in unseren Hashing-Beispielen auf alle diese Berechnungen und verwenden stattdessen die Hashwerte aus Abbildung 3.54 Schlüssel
S
E
A
Hash (M = 5) Hash (M = 16)
R
C
H
2
0
6
10
X
M
0
4
4
4
14
5
P
L
4
2
4
15
4
3
3
1
14
6
Abbildung 3.54: Hashwerte für die Schlüssel in den Beispielen
Benutzerdefinierter hashCode() Client-Code erwartet, dass hashCode() die Schlüssel gleichmäßig unter den möglichen 32-Bit-Ergebniswerten verteilt. Für jedes Objekt x können Sie demnach x.hashCode() schreiben und im Prinzip erwarten, einen der 232 möglichen 32-Bit-Werte mit gleicher Wahrscheinlichkeit zu erhalten. Dies ist jedenfalls das Ziel von Javas hashCode()Implementierungen für String, Integer, Double, File und URL. Für Ihren eigenen Datentyp müssen Sie in dieser Hinsicht selbst Sorge tragen. Das Date-Beispiel, das wir auf Seite 492 betrachtet haben, veranschaulicht eine mögliche Vorgehensweise: die Instanzvariablen zu Integer umwandeln und darauf modulares Hashing anwenden. Die Java-Konvention, dass alle Datentypen eine hashCode()-Methode erben, erlaubt noch einen einfacheren Ansatz: die hashCode()-Methode auf die Instanzvariablen anwenden, um jede in einen 32-Bit-Integer-Wert umzuwandeln, und dann die arithmetischen Operationen von Transaction (Listing 3.18) ausführen. Beachten Sie, dass Sie Instanzvariablen eines primitiven Datentyps in einen Wrappertyp umwandeln müssen, um auf die Methode hashCode() zuzugreifen. Auch hier ist der genaue Wert des Multiplikators (31 in unserem Beispiel) nicht besonders wichtig.
493
3
Suchen
public class Transaction { ... private final String who; private final Date when; private final double amount; public int hashCode() { int hash = 17; hash = 31 * hash + who.hashCode(); hash = 31 * hash + when.hashCode(); hash = 31 * hash + ((Double) amount).hashCode(); return hash; } ... } Listing 3.18: Implementierung von hashCode() in einem benutzerdefinierten Typ
Software-Caching Wenn die Berechnung des Hashcodes teuer ist, könnte es sich lohnen, den Hashwert für jeden Schlüssel im Cache abzulegen. Das bedeutet, wir verwalten eine Instanzvariable hash vom Typ des Schlüssels, die den Wert von hashCode() für jedes Schlüsselobjekt enthält (siehe Übung 3.4.25). Beim ersten Aufruf von hashCode() müssen wir den vollen Hashcode berechnen (und den Wert von hash setzen); die nachfolgenden Aufrufe von hashCode() liefern anschließend einfach den Wert von hash zurück. Java bedient sich dieser Technik, um die Kosten der Berechnung von hashCode() für StringObjekte zu reduzieren. Zusammengefasst lässt sich sagen, dass wir es bei der Implementierung einer guten Hashfunktion für einen gegebenen Datentyp mit drei wichtigen Anforderungen zu tun haben:
Sie sollte konsistent sein – gleiche Schlüssel müssen den gleichen Hashwert erzeugen.
Sie sollte effizient zu berechnen sein. Sie sollte die Schlüssel gleichmäßig verteilen. Diese drei Anforderungen gleichzeitig zu erfüllen, ist eine Aufgabe für Experten. Wie bei vielen integrierten Funktionen gehen Java-Programmierer stillschweigend davon aus, dass hashCode() diese Aufgabe erledigt, solange nichts Gegenteiliges bekannt ist. Dennoch sollten Sie wachsam sein, wenn Sie Hashing in Situationen verwenden, in denen gute Performance wichtig ist, da eine schlechte Hashfunktion ein klassisches Beispiel für einen Performance-Fehler ist: Alles funktioniert bestens, aber viel langsamer als erwartet. Der vielleicht leichteste Weg, Gleichmäßigkeit zu garantieren, besteht darin sicherzustellen, dass alle Bits des Schlüssels eine gleich wichtige Rolle bei der Berechnung jedes einzelnen Hashwerts spielen. Der vielleicht häufigste Fehler bei der Implementierung von Hashfunktionen besteht darin, signifikante Anteile der
494
3.4 Hashtabellen
Schlüsselbits zu ignorieren. Gleichgültig wie eine Hashfunktion implementiert ist, wenn Sie sie in performancekritischem Code verwenden, sollten Sie die Hashfunktion auf jeden Fall testen. Was kostet mehr Zeit: die Hashfunktion zu berechnen oder zwei Schlüssel zu vergleichen? Verteilt Ihre Hashfunktion einen typischen Satz von Schlüsseln gleichmäßig auf die Werte zwischen 0 und M−1? Einfache Experimente, die diese Fragen beantworten, können zukünftige Clients vor bösen Überraschungen bewahren. Das Histogramm aus Abbildung 3.55 zeigt beispielsweise, dass unsere hash()-Implementierung, die die hashCode()-Methode vom Java-Datentyp String verwendet, eine vernünftige Verteilung der Wörter für unser Beispiel A Tale of Two Cities erzielt.
Häufigkeit
110 ⬇ 10679/97
0
Schlüsselwert
96
Abbildung 3.55: Hashwerthäufigkeiten für Wörter aus A Tale of Two Cities (10679 Schlüssel, M=97)
Unserer Diskussion des Hashing liegt eine wichtige Annahme zugrunde: Es gibt ein Idealmodell, das wir vermutlich nicht erreichen, das aber unser Denken leitet, wenn wir Hashingalgorithmen implementieren.
Annahme J (Annahme des gleichmäßigen Hashing): Die von uns verwendeten Hashfunktionen verteilen die Schlüssel gleichmäßig und unabhängig voneinander auf die Integer-Werte zwischen 0 und M−1. Diskussion: Aufgrund unserer zum Teil willkürlichen Entscheidungen erhalten wir mit Sicherheit keine Hashfunktionen, die in diesem strengen mathematischen Sinne die Schlüssel gleichmäßig und unabhängig verteilen. Tatsächlich führt die Idee, konsistente Funktionen zu implementieren, die Schlüssel garantiert gleichmäßig und unabhängig voneinander verteilen, zu tief greifenden theoretischen Studien, denen zufolge es so gut wie unmöglich ist, eine solche Funktion schnell und einfach zu berechnen. In der Praxis ist es ähnlich wie bei den Zufallszahlen, die von Math.random() erzeugt werden: Die meisten Programmierer sind mit Hashfunktionen zufrieden, die nur schwer von Zufallsfunktionen zu unterscheiden sind. Dagegen prüfen nur wenige Programmierer auf Unabhängigkeit, und diese Eigenschaft wird auch nur selten erfüllt.
Trotz der Schwierigkeit, Annahme J zu validieren, ist sie aus zwei Gründen nützlich. Zum einen ist sie beim Entwickeln von Hashfunktionen ein erstrebenswertes Ziel, da sie uns vor willkürlichen Entscheidungen bewahrt, die zu extrem vielen Kollisionen führen könnten. Zweitens erlaubt uns die Annahme – auch wenn sie nicht direkt zu validieren ist – mithilfe mathematischer Analysen Hypothesen über die Performance von Hashingalgorithmen aufzustellen, die wir mit Experimenten überprüfen können.
495
3
Suchen
3.4.2 Hashing mit Verkettung Eine Hashfunktion wandelt Schlüssel in Arrayindizes um. Die zweite Komponente eines Hashingalgorithmus ist die Kollisionsauflösung, d.h. eine Strategie für den Fall, dass zwei oder mehr einzufügende Schlüssel auf den gleichen Index abgebildet werden. Ein einfacher und allgemeiner Ansatz zur Kollisionsauflösung besteht darin, für jeden der M Arrayindizes eine verkettete Liste der Schlüssel-Wert-Paare zu erstellen, deren Schlüssel sich auf diesen Index abbilden. Diese Methode wird als Hashing mit Verkettung bezeichnet, da Elemente, die kollidieren, in separaten Listen verkettet werden. Die Grundidee ist, M ausreichend groß zu wählen, sodass die Listen ausreichend kurz sind, um eine effiziente Suche zu ermöglichen, die aus zwei Stufen besteht: mittels Hashing die Liste finden, die den Schlüssel enthalten könnte, und dann diese Liste sukzessive nach dem Schlüssel durchsuchen. Schlüssel Hash Wert
S
2
0
E
0
1
A
0
2
R
4
3
C
4
4
0
H
4
5
1
E
0
6
X
2
7
A
0
8
M
4
9
P
3
10
L
3
11
E
0
12
first
A 8
st
E 12
first
null
unabhängige SequentialSearchST-
Objekte
first
2
X 7
S 0
L 11
P 10
M 9
H 5
3 4
first
first
C 4
R 3
Abbildung 3.56: Hashing mit Verkettung für den Testclient
Eine mögliche Umsetzung wäre, SequentialSearchST (Algorithmus 3.1 in Listing 3.4) auszubauen und separate Verkettung durch den Einsatz verketteter Listen zu implementieren (siehe Übung 3.4.2). Ein einfacherer (wenn auch etwas ineffizienterer) Weg ist ein eher allgemeiner Ansatz: Wir erstellen für jeden der M Arrayindizes eine Symboltabelle der Schlüssel, die sich via Hashing auf diesen Index abbilden lassen, und können so Code wiederverwenden, den wir bereits entwickelt haben. Die Implementierung SeparateChainingHashST in Algorithmus 3.5 verwaltet ein Array von SequentialSearchST-Objekten und stellt zwei Zugriffsmethoden get() und put() zur Verfügung. Beide Methoden berechnen eine Hashfunktion, um das SequentialSearchST-Objekt zu ermitteln, das den Schlüssel enthalten kann, und nutzen anschließend get() beziehungsweise put() aus SequentialSearchST, um die Aufgabe abzuschließen. Da wir M Listen und N Schlüssel haben, beträgt die Durchschnittslänge der Listen immer N/M, unabhängig davon, wie die Schlüssel auf die Listen verteilt sind. Angenommen bei-
496
3.4 Hashtabellen
spielsweise, die Elemente befinden sich alle in der ersten Liste, dann ist die durchschnittliche Länge der Listen (N+0+0+0+…+0)/M= N/M. Tatsächlich aber sind die Schlüssel auf die Listen verteilt, die Summe der Listenlängen ist N und der Durchschnitt folglich N/M. In der Praxis ist Hashing mit Verkettung äußerst nützlich, da jede Liste mit hoher Wahrscheinlichkeit ungefähr N/M Schlüssel-Wert-Paare aufweist. Typische Situationen bestätigen diese Folgerung aus Annahme J und wir können mit schnellen Einfüge- und Suchoperationen rechnen.
Listing 3.19: Hashing mit Verkettung (Algorithmus 3.5) public class SeparateChainingHashST { private int N; // Anzahl der Schlüssel-Wert-Paare private int M; // Größe der Hashtabelle private SequentialSearchST[] st; // Array von ST-Objekten public SeparateChainingHashST() { this(997); } public SeparateChainingHashST(int M) { // Erzeugt M verkettete Listen. this.M = M; st = (SequentialSearchST[]) new SequentialSearchST[M]; for (int i = 0; i < M; i++) st[i] = new SequentialSearchST(); } private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % M; } public Value get(Key key) { return (Value) st[hash(key)].get(key); } public void put(Key key, Value val) { st[hash(key)].put(key, val); } public Iterable keys() // Siehe Übung 3.4.19. }
Diese grundlegende Symboltabellen-Implementierung verwaltet ein Array von verketteten Listen und wählt mithilfe einer Hashfunktion für jeden Schlüssel die zugehörige Liste aus. Der Einfachheit halber verwenden wir SequentialSearchST-Methoden. Der Cast bei der Erzeugung von st[] ist nötig, weil Java keine generischen Arrays kennt. Der Standardkonstruktor legt 997 Listen an, sodass dieser Code für große Tabellen um ungefähr den Faktor 1000 schneller ist als SequentialSearchST. Diese schnelle Lösung ist eine einfache Möglichkeit, um gute Performance zu erhalten, wenn Sie eine Vorstellung von der Anzahl der Schlüssel-Wert-Paare haben, die ein Client mit put() einfügen will. Eine robustere Lösung ist, die Größe der Arrays anzupassen, um sicherzustellen, dass die Listen kurz sind, unabhängig davon, wie viele Schlüssel-Wert-Paare in der Tabelle sind (siehe Abschnitt 3.4.4 und Übung 3.4.18).
497
3
Suchen
Satz K: In einer auf Verkettung basierenden Hashtabelle mit M Listen und N Schlüsseln ist die Wahrscheinlichkeit (unter Annahme J ), dass die Anzahl der Schlüssel in einer Liste bis auf einen kleinen konstanten Faktor bei N/M liegt, extrem nahe an 1. Beweisskizze: Dank Annahme J ist dies eine Anwendung der klassischen Wahrscheinlichkeitstheorie. Für Leser, die mit den Grundkenntnissen der probabilistischen Analyse vertraut sind, wollen wir deshalb den Beweis kurz skizzieren. Die Wahrscheinlichkeit, dass eine gegebene Liste genau k Schlüssel enthält, ist gegeben durch die Binomialverteilung ⎛ N ⎞⎛ 1 ⎞k ⎛ M −1 ⎞N −k ⎜ ⎟⎜ ⎟ ⎜ ⎟ ⎝ k ⎠⎝ M ⎠ ⎝ M ⎠ (10, 0,12511 …)
0,125
0 0
10
20
30
Binomialverteilung (N = 10 4 , M = 10 3 , = 10)
wie die folgende Argumentation zeigt: Wählen Sie aus den N Schlüsseln k Schlüssel aus. Diese k Schlüssel werden von der Hashfunktion mit der Wahrscheinlichkeit 1/M auf die gegebene Liste abgebildet und die anderen N−k Schlüssel werden mit der Wahrscheinlichkeit 1−(1/M) nicht auf die gegebene Liste abgebildet. Für α = N/M können wir diesen Ausdruck umschreiben als N −k ⎛ N ⎞⎛ α ⎞k ⎛ α⎞ ⎜ ⎟⎜ ⎟ ⎜1− ⎟ N⎠ ⎝ k ⎠⎝ N ⎠ ⎝
– ein Ausdruck, der (für kleine α) durch die klassische Poisson-Verteilung stark angenähert wird α k e−α k! (10, 0,12572 …)
0,125
0 0
10
20
30
Poisson-Verteilung (N = 10 4 , M = 10 3 , = 10)
Daraus folgt, dass die Wahrscheinlichkeit, dass eine Liste mehr als tα Schlüssel aufweist, durch die Größe ( αe / t )t e−α begrenzt wird. Diese Wahrscheinlichkeit ist für realistische Bereiche der Parameter extrem gering. Wenn beispielsweise die durchschnittliche Länge der Listen 10 beträgt, ist die Wahrscheinlichkeit, dass das Hashing zu einer Liste mit mehr als 20 Schlüssel führt, kleiner als (10e / 2)2 e−10 ≈ 0,0084, und wenn die durchschnittliche Länge der Listen 20 ist, ist die Wahrscheinlichkeit, dass das Hashing zu einer Liste mit mehr als 40 Schlüssel führt, kleiner als (20e / 2)2 e−20 ≈ 0,0000016. Dieses Ergebnis garantiert nicht, dass jede Liste kurz ist. Es ist sogar bekannt, dass, wenn α eine Konstante ist, die Durchschnittslänge der längsten Liste mit log N/log log N wächst.
498
3.4 Hashtabellen
Diese klassische mathematische Analyse ist überzeugend, aber Sie sollten sich immer bewusst sein, dass sie vollständig von Annahme J abhängt. Wenn die Hashfunktion nicht gleichmäßig und unabhängig ist, könnten die Kosten für Suchen und Einfügen proportional N sein und damit nicht besser als bei der sequenziellen Suche. Annahme J ist viel gewichtiger als Annahmen für andere probabilistische Algorithmen, die wir kennengelernt haben, und viel schwerer zu verifizieren. Beim Hashing gehen wir davon aus, dass wirklich jeder Schlüssel, egal wie komplex, mit gleicher Wahrscheinlichkeit auf einen von M Indizes abgebildet werden kann. Leider ist es nicht möglich, Experimente durchzuführen, die jeden möglichen Schlüssel testen. Wir müssen uns also ausgeklügeltere Experimente ausdenken, mit zufälligen Stichproben aus einem Satz möglicher Schlüssel, wie sie in einer Anwendung verwendet werden könnten, und zum Schluss die Ergebnisse statistisch auswerten. Wenn wir den Algorithmus selbst als Teil des Tests verwenden können, umso besser. Dann können wir sowohl die Annahme J als auch die davon abgeleiteten mathematischen Ergebnisse validieren.
Eigenschaft L: In einer auf Verkettung basierenden Hashtabelle mit M Listen und N Schlüsseln ist die Anzahl der Vergleiche (Gleichheitstests) für Einfügungen und erfolglose Suchen ∼N/M. Nachweis: Damit die Algorithmen in der Praxis eine gute Laufzeit aufweisen, ist es nicht zwingend erforderlich, dass die Hashfunktion absolut gleichmäßig im technischen Sinne von Annahme J ist. Zahllose Programmierer haben seit den 1950ern von Leistungssteigerungen profitiert, wie sie von Satz K vorhergesagt wurden, auch wenn die eingesetzten Hashfunktionen nicht gleichmäßig waren. Das Diagramm aus Abbildung 3.57 zeigt, dass die Verteilung der Listenlängen für unser FrequencyCounter-Beispiel (unter Verwendung unserer hash()-Implementierung, die auf der hashCode()-Methode des Java-Datentyps String basiert) exakt mit dem theoretischen Modell übereinstimmt. Eine Ausnahme, die häufig dokumentiert wurde, ist schlechte Performance aufgrund von Hashfunktionen, die nicht alle Bits der Schlüssel berücksichtigen. Ansonsten können wir aufgrund der eindeutigen praktischen Erfahrungen der Programmierer guten Gewissens behaupten, dass Hashing mit Verkettung unter Verwendung eines Arrays der Größe M das Suchen und Einfügen in eine Symboltabelle um den Faktor M beschleunigt.
= 10,711... 125 Häufigkeit
ke − k!
0 0
10
20
30
Listenlängen (10.679 Schlüssel, M = 997)
Abbildung 3.57: Listenlängen für java FrequencyCounter 8 < tale.txt unter Verwendung von SeparateChainingHashST
499
3
Suchen
Tabellengröße In einer Implementierung mit separater Verkettung ist es unser Ziel, die Tabellengröße M klein genug zu wählen, um nicht einen riesigen Bereich zusammenhängenden Speichers unnötig mit leeren Ketten zu belegen, doch gleichzeitig groß genug, um nicht Zeit mit dem Durchsuchen langer Ketten zu verschwenden. Einer der Vorteile von Hashing mit Verkettung ist, dass diese Entscheidung nicht kritisch ist: Wenn mehr Schlüssel vorliegen als erwartet, dann dauern die Suchen etwas länger, als wenn wir zuvor eine größere Tabellengröße gewählt hätten. Und wenn weniger Schlüssel in der Tabelle sind, haben wir eine extraschnelle Suche, bei der etwas Speicher verschwendet wird. Wenn Speicher keine kritische Ressource ist, kann M ausreichend groß gewählt werden, sodass die Suchzeit konstant ist. Wenn Speicher eine kritische Ressource ist, können wir immer noch eine Performance-Verbesserung um den Faktor M erreichen, indem wir M so groß wie möglich wählen. Für unser FrequencyCounterFallbeispiel stellen wir bei Verwendung von SeparateChainingHashST wie erwartet fest, dass sich die durchschnittlichen Kosten von Tausenden Vergleichen pro Operation für SequentialSearchST auf eine kleine Konstante reduzieren, Abbildung 3.58. Eine andere Möglichkeit ist, die Listen durch dynamische Größenanpassung von Arrays kurz zu halten (siehe Übung 3.4.18).
Löschen Um ein Schlüssel-Wert-Paar zu löschen, suchen Sie mit Hashing nach dem SequentialSearchST-Objekt, das den Schlüssel enthält, und rufen dann die Methode delete() für diese Tabelle auf (siehe Übung 3.1.5). Eine solche Wiederverwendung von Code ist der Neuimplementierung dieser grundlegenden Operation für verkettete Listen eindeutig vorzuziehen.
Ordnungsbasierte Operationen Da es beim Hashing darum geht, die Schlüssel gleichmäßig zu verteilen, zerstört das Hashen eine möglicherweise vorhandene Ordnung der Schlüssel. Wenn Sie also schnell den größten oder kleinsten Schlüssel oder Schlüssel in einem gegebenen Bereich finden müssen oder eine der anderen Operationen der geordneten Symboltabellen-API in Tabelle 3.4 implementieren wollen, dann ist Hashing nicht geeignet, da alle diese Operationen lineare Zeit benötigen. Hashing mit Verkettung ist leicht zu implementieren und wahrscheinlich die schnellste (und populärste) Symboltabellen-Implementierung für Anwendungen, bei denen die Reihenfolge der Schlüssel keine Rolle spielt. Wenn Ihre Schlüssel von einem vorgegebenen Java-Typ sind oder von einem eigenen Typ mit gut getesteten Implementierungen von hashCode(), dann bietet Algorithmus 3.5 (Listing 3.19) einen leichten Weg zu schnellen Such- und Einfügeoperationen. Als Nächstes betrachten wir eine weitere Möglichkeit zur Kollisionsauflösung, die ebenfalls effektiv ist.
500
3.4 Hashtabellen
Gleichheitstests
10
kumulativer Durchschnitt 3,9
0 0
Operationen
14350
Abbildung 3.58: Kosten für java FrequencyCounter 8 < tale.txt unter Verwendung von SeparateChainingHashST (M=997)
3.4.3 Hashing mit linearer Sondierung Ein anderer Ansatz zur Implementierung von Hashing besteht darin, N SchlüsselWert-Paare in einer Hashtabelle der Größe M>N zu speichern und die leeren Tabelleneinträge für die Kollisionsauflösung zu nutzen. Solche Verfahren werden Hashing mit offener Adressierung genannt. Die einfachste Hashing-Methode mit offener Adressierung ist die lineare Sondierung: Wenn es zu einer Kollision kommt (d.h., wenn wir beim Hashing auf einen Tabellenindex abbilden, der bereits mit einem Schlüssel belegt ist, der sich vom Suchschlüssel unterscheidet), dann prüfen wir einfach den nächsten Eintrag in der Tabelle (durch Inkrementieren des Index). Lineares Sondieren basiert auf drei möglichen Ergebnissen:
Schlüssel ist gleich dem Suchschlüssel: erfolgreiche Suche Leere Position (null-Schlüssel an der Indexposition): erfolglose Suche Schlüssel ist nicht gleich dem Suchschlüssel: nächsten Eintrag versuchen Wir bilden den Schlüssel auf einen Tabellenindex ab, prüfen, ob der Suchschlüssel mit dem dortigen Schlüssel übereinstimmt, und fahren fort (d.h. inkrementieren den Index und springen an den Anfang der Tabelle, wenn wir das Ende erreicht haben), bis wir entweder den Suchschlüssel oder einen leeren Tabelleneintrag gefunden haben. Die Operation, mit der festgestellt wird, ob ein gegebener Tabelleneintrag ein Element hält, dessen Schlüssel gleich dem Suchschlüssel ist, wird üblicherweise als Sondierung bezeichnet. Wir verwenden den Begriff synonym zu dem bereits bekannten Begriff Vergleich, auch wenn einige Sondierungen Tests auf null sind. Der Grundgedanke beim Hashing mit offener Adressierung ist folgender: Anstatt Speicher für die Verweise in verkettete Listen zu verwenden, nutzen wir ihn für die leeren Einträge in der Hashtabelle, die die Enden von Sondierungsfolgen markieren. Wie Sie anhand von LinearProbingHashST (Algorithmus 3.6 in Listing 3.20) sehen können, ist die Umsetzung dieser Idee zur Implementierung der Symboltabellen-API ziemlich einfach. Wie gerade beschrieben, implementieren wir die Tabelle mit parallelen Arrays (eines für die Schlüssel und eines für die Werte) und verwenden die Hashfunktion als Index, um auf die Daten zuzugreifen.
501
3
Suchen
Listing 3.20: Hashing mit linearer Sondierung (Algorithmus 3.6) public class LinearProbingHashST { private int N; // Anzahl der Schlüssel-Wert-Paare in der // Tabelle private int M = 16; // Größe der Tabelle mit linearer Sondierung private Key[] keys; // die Schlüssel private Value[] vals; // die Werte public LinearProbingHashST() { keys = (Key[]) new Object[M]; vals = (Value[]) new Object[M]; } private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % M; } private void resize() // Siehe Listing 3.22 auf Seite 507. public void put(Key key, Value val) { if (N >= M/2) resize(2*M); // double M (siehe Text) int i; for (i = hash(key); keys[i] != null; i = (i + 1) % M) if (keys[i].equals(key)) { vals[i] = val; return; } keys[i] = key; vals[i] = val; N++; } public Value get(Key key) { for (int i = hash(key); keys[i] != null; i = (i + 1) % M) if (keys[i].equals(key)) return vals[i]; return null; } }
Diese Symboltabellen-Implementierung verwaltet die Schlüssel und Werte in parallelen Arrays (wie in BinarySearchST) und verwendet leere Speicherplätze (markiert durch null), um die Schlüsselcluster abzuschließen. Wenn ein neuer Schlüssel durch das Hashing auf einen leeren Eintrag abgebildet wird, wird er dort gespeichert; wenn nicht, suchen wir sequenziell weiter, bis wir eine leere Position finden. Bei der Suche nach einem Schlüssel starten wir bei seinem Hashindex und durchsuchen von dort aus alle nachfolgenden Positionen, bis wir auf null (erfolglose Suche) oder den Schlüssel (erfolgreiche Suche) stoßen. Die Implementierung von keys() wird Ihnen in Übung 3.4.19 zum Selbststudium überlassen.
502
3.4 Hashtabellen
Schlüssel Hash Wert
S
6
0
E
10
1
A
4
2
R
14
3
C
5
4
H
4
5
E
10
6
X
15
7
A
4
8
M
1
9
P
14
10
L
6
11
E
10
12
0
1
2
3
4
5
6
7
8
9 10 11 12 13 14 15
rote Einträge sind neu
graue Einträge sind unberührt
schwarze Einträge sind Sondierungen
4 4 4 4 4
Sondierungsfolge beginnt wieder bei 0
4 10 10 P M 10 9
4
A 8
4 C 4
S 0
H 5
11 L
E
R 3
X 7
keys[] vals[]
Abbildung 3.59: Ablaufprotokoll der ST-Implementierung mit linearer Sondierung für den Testclient
Löschen Wie löschen wir ein Schlüssel-Wert-Paar aus einer Tabelle mit linearer Sondierung? Wenn Sie einen Augenblick über die Situation nachdenken, werden Sie feststellen, dass es nicht sinnvoll ist, die Tabellenposition des Schlüssels auf null zu setzen, da dies die Suche nach einem Schlüssel, der später in die Tabelle eingefügt wurde, vorzeitig beenden kann. Angenommen wir versuchen, C auf diese Weise in unserem Beispiel zu löschen, und suchen dann nach H. Der Hashwert von H ist 4, aber er befindet sich am Ende des Clusters an Position 7. Wenn wir Position 5 auf null setzen, wird get() den Schlüssel H nicht finden. Folglich müssen wir alle Schlüssel im Cluster rechts vom gelöschten Schlüssel neu einfügen. Dieser Prozess ist komplizierter als es scheint, sodass wir Ihnen zur Veranschaulichung eines Beispiels empfehlen, den Code aus Listing 3.21 nachzuvollziehen (siehe Übung 3.4.17).
503
3
Suchen
public void delete(Key key) { if (!contains(key)) return; int i = hash(key); while (!key.equals(keys[i])) i = (i + 1) % M; keys[i] = null; vals[i] = null; i = (i + 1) % M; while (keys[i] != null) { Key keyToRedo = keys[i]; Value valToRedo = vals[i]; keys[i] = null; vals[i] = null; N--; put(keyToRedo, valToRedo); i = (i + 1) % M; } N--; if (N > 0 && N == M/8) resize(M/2); } Listing 3.21: Löschen bei linearer Sondierung
Wie beim Hashing mit Verkettung hängt die Performance von Hashing mit offener Adressierung von dem Verhältnis α = N/M ab, aber wir interpretieren es anders. Wir bezeichnen α als Lastfaktor einer Hashtabelle. Beim Hashing mit Verkettung gibt α die durchschnittliche Anzahl der Schlüssel pro Liste an und ist damit im Allgemeinen größer als 1. Beim Hashing mit linearer Sondierung ist α der Prozentsatz der Tabelleneinträge, die besetzt sind; er kann nicht größer als 1 sein. Genau genommen müssen wir in LinearProbingHashST darauf achten, dass der Lastfaktor nicht 1 erreicht (absolut volle Tabelle), da eine erfolglose Suche in einer vollen Tabelle zu einer Endlosschleife führen würde. Der guten Performance zuliebe passen wir die Arraygröße an, um zu garantieren, dass der Lastfaktor zwischen ein Achtel und ein Halb liegt. Diese Strategie wird durch eine mathematische Analyse validiert, die wir betrachten wollen, bevor wir die Implementierungsdetails diskutieren.
Clusterbildung Die durchschnittlichen Kosten der linearen Sondierung hängen von der Art und Weise ab, in der sich die Einträge beim Einfügen zu Gruppen von belegten Tabelleneinträgen, sogenannten Clustern, zusammenschließen. Wenn beispielsweise der Schlüssel C in unserem Beispiel eingefügt wird, ist das Ergebnis ein Cluster (A C S) der Länge 3, was bedeutet, dass vier Sondierungen benötigt werden, um H einzufügen, da H auf die erste Position im Cluster abgebildet wird. Kurze Cluster sind zweifelsohne eine Voraussetzung für effiziente Performance. Diese Voraussetzung kann mit zunehmend gefüllter Tabelle problematisch werden, da große Cluster immer häufig vorkommen. Und da alle Tabellenpositionen mit gleicher Wahrscheinlichkeit den Hashwert des als Nächstes einzufügenden Schlüssels annehmen (unter der Annahme des gleichmäßigen Hashing), wachsen große Cluster schneller als kurze. Dies liegt daran, dass ein
504
3.4 Hashtabellen
neuer Schlüssel, der auf einen der Einträge im Cluster gehasht wird, den Cluster um 1 vergrößert (und möglicherweise um viel mehr, wenn nur ein Tabelleneintrag diesen Cluster vom nächsten trennt). Als Nächstes widmen wir uns der Herausforderung, den Effekt der Clusterbildung zu quantifizieren, um die Performance der linearen Sondierung zu schätzen und mit diesem Wissen die Entwurfsparameter unserer Implementierungen zu wählen. 9/64-Chance, dass der neue Schlüssel dieses Cluster vergrößert vorher
Schlüssel landet in diesem Fall hier
nachher
und bildet ein viel längeres Cluster
Abbildung 3.60: Clusterbildung bei linearer Sondierung (M=64) lineare Sondierung
zufällig
= 1/2
lange Cluster treten häufig auf
keys[0..127]
= 1/4
keys[8064..8192]
Abbildung 3.61: Tabellenbelegungsmuster (2048 Schlüssel, Tabellen bestehen aus Reihen mit 128 Positionen)
Analyse der linearen Sondierung Trotz der relativ einfachen Form der Ergebnisse ist die genaue Analyse der linearen Sondierung eine große Herausforderung. Knuths Ableitung der folgenden Formeln war 1962 ein Meilenstein in der Analyse von Algorithmen.
505
3
Suchen
Satz M: In einer auf linearer Sondierung basierenden Hashtabelle mit M Listen und N=αM Schlüssel ist die erforderliche durchschnittliche Anzahl von Sondierungen (unter Annahme J ) für erfolgreiche beziehungsweise erfolglose Suchen (oder Einfügungen): 1⎛ 2⎝
~ ⎜1 +
⎞ 1 ⎞ 1⎛ 1 ⎟ bzw. ~ ⎜ 1 + ⎟ 1− α ⎠ 2⎝ (1 − α )2 ⎠
Für α ungefähr gleich 1/2 liegt die durchschnittliche Anzahl der Sondierungen für eine erfolgreiche Suche also bei ungefähr 3/2 und für eine erfolglose Suche bei ungefähr 5/2. Diese Schätzungen werden etwas ungenau, wenn sich α der 1 nähert, aber wir benötigen diese Schätzungen nicht, da wir die lineare Sondierung nur für α kleiner 1/2 verwenden. Diskussion: Wir berechnen den Durchschnitt, indem wir die Kosten einer erfolglosen Suche von jeder Position in der Tabelle aus berechnen und dann die Gesamtsumme durch M teilen. Alle erfolglosen Suchen benötigen mindestens eine Sondierung, sodass wir erst bei der zweiten Sondierung mit dem Zählen beginnen. Betrachten wir die folgenden zwei Extreme in einer auf linearer Sondierung basierenden Tabelle, die halb voll ist (M=2N): Im besten Fall könnten die Tabellenpositionen mit geraden Indizes leer sein und die Tabellenpositionen mit ungeraden Indizes belegt. Im schlimmsten Fall wäre die erste Hälfte der Tabellenpositionen leer und die zweite Hälfte belegt. Die durchschnittliche Länge der Cluster ist in beiden Fällen N/(2N)=1/2, aber die durchschnittliche Zahl der Sondierungen für eine erfolglose Suche ist im besten Fall 1 (alle Suchen erfordern zumindest eine Sondierung) plus (0+1+0+1+…)/(2N)=1/2 und im schlimmsten Fall 1 plus (N+(N−1)+…)/(2N) ∼N/4. Diese Argumentation ist eine Verallgemeinerung, um zu zeigen, dass die durchschnittliche Zahl der Sondierungen für eine erfolglose Suche proportional den Quadraten der Längen der Cluster ist: Wenn ein Cluster die Länge t hat, dann gibt der Ausdruck (t+(t−1)+ …+2+1)/M = t(t+1)/(2M) den Beitrag dieses Clusters zu der Gesamtsumme. Die Summe der Clusterlängen ist N, sodass wir bei der Addition dieser Kosten für alle Einträge in der Tabelle feststellen, dass die durchschnittlichen Gesamtkosten für eine erfolglose Suche 1+N/(2M) sind plus der Summe der Quadrate von den Längen der Cluster geteilt durch 2M. Für eine gegebene Tabelle können wir also schnell die durchschnittlichen Kosten einer erfolglosen Suche in dieser Tabelle berechnen (Übung 3.4.21 ). Im Allgemeinen bilden sich Cluster nach einem komplizierten dynamischen Prozess (dem Algorithmus der linearen Sondierung), der nur schwer analytisch zu beschreiben ist und definitiv über den Rahmen dieses Buches hinausgeht.
Laut Satz M (unter unserer bekannten Annahme J) können wir erwarten, dass eine Suche in einer fast vollen Tabelle eine riesige Anzahl an Sondierungen benötigt (je mehr sich α dem Wert 1 nähert, desto größer werden die Werte der Formeln, die die Anzahl der Sondierungen beschreiben), aber dass die erwartete Anzahl der Sondierungen zwischen 1,5 und 2,5 liegt, wenn wir sicherstellen können, dass der Lastfaktor α kleiner als 1/2 ist. Als Nächstes betrachten wir in diesem Zusammenhang die Größenanpassung von Arrays.
3.4.4 Größenanpassung von Arrays Mit der Standardtechnik zur Größenanpassung von Arrays aus Kapitel 1 können wir sicherstellen, dass der Lastfaktor niemals 1/2 überschreitet. Zuerst benötigen wir
506
3.4 Hashtabellen
einen neuen Konstruktor für LinearProbingHashST, der eine feste Kapazität als Argument übernimmt (fügen Sie in Algorithmus 3.6 eine Zeile zum Konstruktor hinzu, der M auf den gegebenen Wert setzt, bevor die Arrays erzeugt werden). Als Nächstes benötigen wir die resize()-Methode aus Listing 3.22, die ein neues LinearProbingHashST-Objekt der gegebenen Größe erzeugt, alle Schlüssel und Werte der alten Tabelle in die neue Tabelle überträgt und dann das Hashing der Schlüssel für die neue Tabelle herstellt. Diese Ergänzungen erlauben uns, die Arrayverdopplung zu implementieren. Der Aufruf von resize() in der ersten Anweisung in put() stellt sicher, dass die Tabelle höchstens halb voll wird. Dieser Code erstellt eine doppelt so große Hashtabelle mit den gleichen Schlüsseln, was den Wert von α halbiert. Wie in anderen Anwendungen mit Größenanpassung der Arrays müssen wir zusätzlich den Code if (N > 0 && N =M/2) ist, sowie in delete() die Methode resize(M/2), wenn (N > 0 && N cM mit der Wahrscheinlichkeit 1−1 / c zwischen M − M und M + M liegen. 31.
Kuckucks-Hashing (Cuckoo-Hashing): Entwickeln Sie eine Symboltabellen-Implementierung, die zwei Hashtabellen und zwei Hashfunktionen unterhält. Jeder gegebene Schlüssel befindet sich in einer der Tabellen, aber nicht in beiden. Wenn Sie einen neuen Schlüssel einfügen, bilden Sie ihn auf eine der Tabellen ab. Ist die Tabellenposition belegt, ersetzen Sie den Schlüssel mit dem neuen Schlüssel und bilden Sie den alten Schlüssel auf die andere Tabelle ab (überschreiben Sie auch hier bereits vorhandene Schlüssel). Wenn es im Zuge dieses Prozesses zum Kreisschluss kommt, starten Sie neu. Die Tabellen sollten immer weniger als halb voll sein. Dieses Verfahren verwendet im schlimmsten Fall eine konstante Anzahl von Gleichheitstests für das Suchen (trivial) und eine amortisierte konstante Zeit für das Einfügen.
32.
Hashattacke: Finden Sie 2N Strings mit jeweils der Länge 2N, die den gleichen hashCode()-Wert haben. Dabei gehen wir davon aus, dass die hashCode()-Implementierung für String folgendermaßen lautet: public int hashCode() { int hash = 0; for (int i = 0; i < length(); i ++) hash = (hash * 31) + charAt(i); return hash; }
Deutlicher Hinweis: Aa und BB haben denselben Wert. 33.
Schlechte Hashfunktion: Betrachten Sie die folgende hashCode()-Implementierung für String, die in früheren Java-Versionen benutzt wurde: public int hashCode() { int hash = 0; int skip = Math.max(1, length()/8); for (int i = 0; i < length(); i += skip) hash = (hash * 37) + charAt(i); return hash; }
Erklären Sie, warum die Entwickler sich Ihrer Meinung nach für diese Implementierung entschieden haben und warum man sie später zugunsten der Implementierung der vorherigen Übung aufgegeben hat.
517
3
Suchen
3.4
518
Experimente
34.
Hashkosten: Ermitteln Sie empirisch das Verhältnis der für hash() benötigten Zeit zu der Zeit, die für compareTo() benötigt wird – und zwar für möglichst viele gängige Schlüsseltypen, sofern diese sinnvolle Ergebnisse liefern.
35.
Chi-Quadrat-Test: Verwenden Sie Ihre Lösung aus Übung 3.4.30, um die Annahme zu prüfen, dass die Hashfunktionen für häufig verwendete Schlüsseltypen Zufallswerte erzeugen.
36.
Bereich der Listenlängen: Schreiben Sie ein Programm, das unter Verwendung von Hashing mit Verkettung N zufällige int-Schlüssel in eine Tabelle der Größe N/100 einfügt und dann die Länge der kürzesten und längsten Liste für N=103, 104, 105, 106 ermittelt.
37.
Hybrid: Führen Sie experimentelle Untersuchungen durch, um festzustellen, welche Auswirkungen es hat, wenn Sie RedBlackBST anstelle von SequentialSearchST verwenden, um Kollisionen in SeparateChainingHashST zu behandeln. Diese Lösung hat den Vorteil, dass sogar eine schlechte Hashfunktion eine logarithmische Performance bietet, und den Nachteil, dass zwei verschiedene Symboltabellen-Implementierungen verwaltet werden müssen. Wie sehen die Folgen in der Praxis aus?
38.
Verteilung bei Hashing mit Verkettung: Schreiben Sie ein Programm, das unter Verwendung von Hashing mit Verkettung 105 nicht-negative Zufallsinteger kleiner als 106 in eine Tabelle der Größe 105 einfügt und die Gesamtzahl der Sondierungen für jede der 103 aufeinanderfolgenden Einfügungen grafisch darstellt. Diskutieren Sie, inwieweit Ihre Ergebnisse Satz K bestätigen.
39.
Verteilung bei Hashing mit linearer Sondierung: Schreiben Sie ein Programm, das unter Verwendung linearer Sondierung N/2 zufällige int-Schlüssel in eine Tabelle der Größe N einfügt und dann aus den Clusterlängen die durchschnittlichen Kosten einer erfolglosen Suche in der resultierenden Tabelle berechnet, für N=103, 104, 105, 106. Diskutieren Sie, inwieweit Ihre Ergebnisse Satz K bestätigen.
40.
Diagramme: Überarbeiten Sie LinearProbingHashST und SeparateChainingHashST, um Diagramme wie im Text zu erzeugen.
41.
Doppelte Sondierung: Führen Sie experimentelle Untersuchungen durch, um die Wirksamkeit der doppelten Sondierung zu evaluieren (siehe Übung 3.4.27).
42.
Doppel-Hashing: Führen Sie experimentelle Untersuchungen durch, um die Wirksamkeit des Doppel-Hashings zu evaluieren (siehe Übung 3.4.28).
43.
Parkproblem (D. Knuth): Führen Sie experimentelle Untersuchungen durch, um die Hypothese zu bestätigen, dass die Anzahl der Vergleiche, die benötigt werden, um M zufällige Schlüssel in eine auf linearer Sondierung basierende Tabelle der Größe M einzufügen, ~cM 3/2 ist, wobei c = π / 2 ist.
3.5 Anwendungen
3.5
Anwendungen
Von den Anfängen der Informatik, als Symboltabellen den Programmierern ermöglichten, numerische Adressen in Maschinensprache durch symbolischen Namen in Assemblersprache zu ersetzen, bis hin zu den Anwendungen des neuen Jahrtausends, wo symbolische Namen über weltweite Computernetzwerke hinweg eine Bedeutung haben, kam den Suchalgorithmen stets eine essentielle Rolle bei der Programmierung zu. Heute werden Symboltabellen für die Organisation von wissenschaftlichen Daten (von der Suche nach Markern oder Mustern in Genomdaten bis zur Kartografierung des Universums), für die Organisation von Wissen im Web (von der Suche im Onlinehandel bis zum Onlinestellen von Bibliotheken) und für die Implementierung der Internet-Infrastruktur eingesetzt (von Paket-Routing zwischen den Rechnern im Web bis zu gemeinsam genutzten Dateisystemen und Videostreaming). All diese und unzählige andere wichtige Anwendungen wurden erst durch effiziente Suchalgorithmen möglich, von denen wir in diesem Abschnitt einige repräsentative Beispiele herausgreifen wollen:
ein Wörterbuch-Client und ein Indizierungsclient, die schnellen und flexiblen Zugriff auf Informationen in Dateien mit kommaseparierten Werten bieten (.csv-Dateien und ähnliche Formate, die häufig verwendet werden, um Daten im Web zu speichern);
ein Indizierungsclient zur Erstellung eines invertierten Index für einen Satz von Dateien;
ein Datentyp für dünn besetzte Matrizen, der eine Symboltabelle verwendet, um Matrixgrößen zu verarbeiten, die mit der sonst üblichen Implementierung nicht zu bewältigten wären. In Kapitel 6 beschäftigen wir uns zudem mit einer Symboltabelle, die sich für Tabellen wie beispielsweise Datenbanken und Dateisysteme eignet, die eine riesige, gerade noch vertretbare Anzahl von Schlüsseln enthalten. Symboltabellen spielen aber auch in den Algorithmen, die wir im Rest des Buches betrachten, eine wichtige Rolle. So verwenden wir beispielsweise Symboltabellen zur Repräsentation von Graphen (Kapitel 4) und zur Verarbeitung von Strings (Kapitel 5). Wie wir im Verlauf dieses Kapitels erfahren haben, ist die Entwicklung von Symboltabellen-Implementierungen, die eine schnelle Performance für alle Operationen garantieren, definitiv eine große Herausforderung. Andererseits sind die hier betrachteten Implementierungen gut erforscht, weit verbreitet und in vielen Software-Umgebungen zu finden (einschließlich den Java-Bibliotheken). Und von nun an sollte die Symboltabellen-Abstraktion auch zu Ihrem Rüstzeug gehören.
3.5.1 Welche Symboltabellen-Implementierung soll ich verwenden? Tabelle
3.15 fasst die Performancedaten der Algorithmen, die wir in den Sätzen und Eigenschaften in diesem Kapitel besprochen haben, zusammen (mit Ausnahme der Worst-Case-Ergebnisse für Hashing, die der wissenschaftlichen Literatur entnommen wurden und Ihnen in der Praxis wahrscheinlich nicht begegnen werden). Die Tabelle zeigt deutlich, dass sich – für typische Anwendungen – die Auswahl im Grunde auf die Entscheidung zwischen Hashtabellen und binären Suchbäumen reduziert.
519
3
Suchen
Der Vorteil von Hashing gegenüber Implementierungen mit binärem Suchbaum ist, dass der Code einfacher ist und die Suchzeiten optimal (d.h. konstant) sind, wenn die Schlüssel einen Standardtyp aufweisen oder so einfach sind, dass wir dafür eine effiziente Hashfunktion entwickeln können, die (annähernd) die Annahme des gleichmäßigen Hashing erfüllt. Die Vorteile von binären Suchbäumen gegenüber Hashing sind, dass sie auf einer einfacheren abstrakten Schnittstelle basieren (es muss keine Hashfunktion entwickelt werden), dass Rot-Schwarz-Bäume garantierte Worst-Case-Performance bieten können und dass sie eine breitere Palette an Operationen unterstützen (wie Rang, Auswahl, Sortieren und Bereichssuche). Als Faustregel gilt, dass die meisten Programmierer mit Hashing arbeiten, es sei denn, dass einer oder mehrere der oben genannten Faktoren wichtig sind, was Rot-Schwarz-Bäume erforderlich macht. In Kapitel 5 werden wir eine Ausnahme zu dieser Faustregel kennenlernen: Wenn die Schlüssel lange Strings sind, können wir Datenstrukturen erstellen, die sogar noch flexibler sind als Rot-Schwarz-Bäume und sogar schneller als Hashing.
Tabelle 3.15 Asymptotische Kostenzusammenfassung für Symboltabellen-Implementierungen (* mit gleichmäßiger und unabhängiger Hashfunktion) Algorithmus (Datenstruktur)
520
Kosten für den schlimmsten Fall (nach N Einfügungen)
Kosten für den durchschnittlichen Fall (nach N zufälligen Einfügeoperationen)
Schlüsselschnittstelle
Speicher (Bytes)
Suchen
Einfügen
Erfolgreiches Suchen
Einfügen
Sequenzielle Suche (ungeordnete Liste)
N
N
N/2
N
equals()
48 N
Binäre Suche (geordnetes Array)
lg N
N
lg N
N/2
compareTo()
16 N
Binärer Suchbaum (BST)
N
N
1,39 lg N
1,39 lg N
compareTo()
64 N
2-3-Baum (Rot-SchwarzBaum)
2 lg N
2 lg N
1,00 lg N
1,00 lg N
compareTo()
64 N
Hashing mit separater Verkettung* (Array von Listen)
< lg N
< lg N
N / (2M)
N/M
equals() hashCode()
48 N + 64 M
Hashing mit linearer Sondierung* (parallele Arrays)
c lg N
c lg N
< 1,50
< 2,50
equals() hashCode()
zwischen 32 N und 128 N
3.5 Anwendungen
Unsere Symboltabellen-Implementierungen eignen sich für ein breites Spektrum von Anwendungen und unsere Algorithmen lassen sich leicht anpassen, um etliche andere Optionen zu unterstützen, die weit verbreitet sind und einer näheren Betrachtung lohnen.
Primitive Datentypen Betrachten wir den Fall einer Symboltabelle mit Standardimplementierung Daten werden in Key- und Integer-Schlüsseln und dazugehörigen GleitkommaValue-Objekten gespeichert zahlen. Unsere übliche Vorgehensweise wäre, die Schlüssel und Werte als Werte der Wrappertypen Integer und Double zu speichern, sodass wir zwei zusätzliche Speicherreferenzen benötigen, um auf jedes Schlüssel-Wert-Paar zuzugreifen. Diese Referenzen sind vielleicht kein Problem in Anwendungen mit Tausenden von Suchen nach Tausen- Implementierung mit primitiven Typen den von Schlüsseln, können aber in einer AnwenDaten werden in Knoten von verketteten Listen gespeichert dung mit Milliarden von Suchen nach Millionen von Schlüsseln riesige Kosten verursachen. Die Verwendung eines primitiven Typs anstelle von Key würde eine Referenz pro Schlüssel-Wert-Paar sparen. Wenn der damit verbundene Wert ebenfalls einen primitiven Typ hat, fällt eine weitere Referenz weg. Ein Diagramm dieser Situation für Abbildung 3.65: Speichernutzung bei Hashing mit Verkettung Hashing mit Verkettung finden Sie in Abbildung 3.65. Die gleichen Kompromisse lassen sich auf andere Implementierungen anwenden. Für performancekritische Anwendungen ist es durchaus lohnenswert und nicht besonders schwierig, eigene Versionen in Anlehnung an unsere Implementierungen zu entwickeln (siehe Übung 3.5.4).
Doppelte Schlüssel Wie in Symboltabellen-Implementierungen mit doppelten Schlüsseln zu verfahren ist, bedarf manchmal gründlicher Überlegung. In vielen Anwendungen ist es zulässig, mehrere Werte mit dem gleichen Schlüssel zu verbinden. Zum Beispiel werden in einem Transaktionssystem häufig dem gleichen Kunden-Schlüsselwert mehrere Transaktionen zugeordnet. Wenn wir keine doppelten Schlüssel erlauben, bedeutet dies, dass wir die Verwaltung doppelter Schlüssel dem Client überlassen. Wir werden weiter hinten in diesem Abschnitt ein Beispiel für einen solchen Client vorstellen. In den meisten unserer Implementierungen könnten wir als Alternative einfach die Schlüssel-Wert-Paare mit doppelten Schlüsseln in der primären Suchdatenstruktur belassen und bei einer Suche irgendeinen Wert zu dem gegebenen Schlüssel zurückzuliefern. Wir könnten aber auch Methoden hinzufügen, um alle Werte zu dem gegebenen Schlüssel zurückzuliefern. Unsere Implementierungen für binäre Suchbäume und Hashing lassen sich relativ einfach anpassen, um doppelte Schlüssel in der Datenstruktur zu speichern. Und auch bei Rot-Schwarz-Bäumen ist der Aufwand nur geringfügig größer (siehe Übungen 3.5.9 und 3.5.10). Solche Implementierungen sind in der Literatur (einschließlich früheren Ausgaben dieses Buches) häufig zu finden.
521
3
Suchen
Java-Bibliotheken Die Java-Bibliotheken java.util.TreeMap und java.util.HashMap sind Symboltabellen-Implementierungen, die auf Rot-Schwarz-Bäumen und Hashing mit separater Verkettung basieren. TreeMap unterstützt nicht direkt Operationen wie rank(), select() und ähnliche in unserer geordneten Symboltabellen-API, aber sie unterstützt Operationen, die deren effiziente Implementierung ermöglichen. HashMap entspricht in etwa unserer Implementierung LinearProbingST – sie bedient sich der Größenanpassung von Arrays, um einen Lastfaktor von ungefähr 75 Prozent zu erreichen. Wir verwenden in diesem Buch die Symboltabellen-Implementierung aus Abschnitt 3.3, die mit Rot-Schwarz-Bäumen arbeitet, bzw. die Implementierung aus Abschnitt 3.4, die auf Hashing mit linearer Sondierung basiert. Um die Namensungetüme zu vermeiden und um die Client-Unabhängigkeit von bestimmten Implementierungen zu betonen, verwenden wir im Client-Code den Namen ST als Kurzform für RedBlackBST für geordnete Symboltabellen und in Clients, bei denen die Ordnung nicht wichtig ist und Hashfunktionen verfügbar sind, den Namen HashST als Kurzform für LinearProbingHashST. Wir halten uns an diese Konventionen in dem vollen Bewusstsein, dass spezielle Anwendungen unter Umständen Anforderungen stellen, die nach einer Änderung oder Erweiterung eines Algorithmus und einer Datenstruktur verlangen. Welche Symboltabelle ist in Ihrem Fall am besten geeignet? Wie auch immer Sie sich entscheiden, testen Sie Ihre Wahl, um sicherzustellen, dass Sie die erwartete Performance erhalten.
3.5.2 Mengen-APIs (Set) Einige Symboltabellen-Clients benötigen nicht die Werte, sondern sind nur daran interessiert, Schlüssel in eine Tabelle einzufügen und zu testen, ob ein Schlüssel in der Tabelle vorhanden ist. Da wir doppelte Schlüssel ausschließen, entsprechen diese Operationen der folgenden API, bei der unser Interesse lediglich der Menge (set) der Schlüssel in der Tabelle gilt und nicht den damit verbundenen Werten.
Tabelle 3.16 API für einen grundlegenden Mengen-Datentyp public class SET SET()
Erzeugt eine leere Menge.
void
add(Key key)
Fügt einen key in die Menge ein.
void
delete(Key key)
Entfernt key aus der Menge.
boolean
contains(Key key)
Ist der key in der Menge enthalten?
boolean
isEmpty()
Ist die Menge leer?
size()
Anzahl der Schlüssel in der Menge.
toString()
Stringdarstellung der Menge.
int String
522
3.5 Anwendungen
Sie können jede Symboltabellen-Implementierung in eine SET-Implementierung umwandeln, indem Sie die Werte ignorieren oder eine einfache Wrapperklasse verwenden (siehe Übungen 3.5.1 bis 3.5.3). Die Erweiterung von SET um Methoden zur Berechnung von Vereinigungsmengen, Schnittmengen und Komplement sowie um weitere geläufige mathematische Mengenoperationen setzt eine anspruchsvollere API voraus (so verlangt die Komplement-Operation beispielsweise einen Mechanismus zum Spezifizieren eines Universums aller möglichen Schlüssel) und ist mit einer Reihe von interessanten algorithmischen Herausforderungen verbunden (siehe Übung 3.5.17). Wie schon bei ST gibt es auch bei SET geordnete und ungeordnete Versionen. Wenn die Schlüssel Comparable sind, definieren wir mit den Methoden min(), max(), floor(), ceiling(), deleteMin(), deleteMax(), rank(), select() sowie den beiden Methoden size() und get(), die jeweils zwei Argumente übernehmen, eine vollständige API für geordnete Schlüssel. Entsprechend unserer ST-Konventionen verwenden wir den Namen SET im Client-Code für geordnete Mengen und den Namen HashSET, wenn die Ordnung keine Rolle spielt. Als Beispiele für SET betrachten wir Filter-Clients, die eine Folge von Strings von der Standardeingabe einlesen und einige der Strings auf der Standardausgabe ausgeben. Solche Clients haben ihren Ursprung in früheren Systemen, als Speicher Mangelware war und selten ausreichte, um alle Daten aufzunehmen. Aber auch heute haben sie nichts von ihrer Bedeutung eingebüßt, vor allem wenn wir Programme schreiben, die ihre Eingaben vom Web entgegennehmen. Als Eingabe soll uns die Datei tinyTale.txt (Listing 3.2) dienen. Um die Lesbarkeit zu erhöhen, übernehmen wir in der Ausgabe unserer Beispiele den Zeilenumbruch von der Eingabe, auch wenn der Code dies nicht vorsieht.
Deduplizierung Der Prototyp eines Filterbeispiels ist ein SET- oder HashSET-Client, der Duplikate im Eingabestrom entfernt – eine Operation, die üblicherweise als dedup bezeichnet wird. Dazu verwalten wir eine Menge der bisher eingelesenen String-Schlüssel. Wenn der nächste Schlüssel in der Menge ist, ignorieren wir ihn; wenn er nicht in der Menge ist, fügen wir ihn der Menge hinzu und geben ihn aus. Die Schlüssel erscheinen auf der Standardausgabe in der gleichen Reihenfolge wie in der Standardeingabe, jedoch ohne die Duplikate. Dieser Prozess belegt Speicher proportional der Anzahl der verschiedenen Schlüssel im Eingabestrom (der normalerweise weitaus kleiner ist als die Gesamtzahl der Schlüssel).
523
3
Suchen
public class DeDup { public static void main(String[] args) { HashSET set; set = new HashSET(); while (!StdIn.isEmpty()) { String key = StdIn.readString(); if (!set.contains(key)) { set.add(key); StdOut.println(key); } } } } Listing 3.23: Dedup-Filter % java DeDup < tinyTale.txt it was the best of times worst age wisdom foolishness epoch belief incredulity season light darkness spring hope winter despair
Positiv- und Negativlisten Ein anderer klassischer Filter verwendet Schlüssel aus einer separaten Datei, um zu entscheiden, welche Schlüssel aus dem Eingabestrom an den Ausgabestrom weitergeleitet werden – ein Verfahren, das viele direkte Anwendungen hat. Das einfachste Beispiel ist eine Positivliste (whitelist), bei der jeder Schlüssel, der in der Liste steht, als „gut“ klassifiziert wird. Der Client könnte dann z.B. Schlüssel, die nicht in der Positivliste stehen, an die Standardausgabe weiterleiten und Schlüssel in der Positivliste ignorieren (wie im Beispiel, das wir in unserem ersten Programm in Kapitel 1 betrachtet haben); ein anderer Client könnten stattdessen die Schlüssel in der Positivliste an die Standardausgabe weiterleiten und die Schlüssel ignorieren, die nicht in der Positivliste zu finden sind (wie im HashSET-Client WhiteFilter aus Listing 3.24). Ihre E-Mail-Anwendung könnte beispielsweise einen solchen Filter dazu nutzen, um die von Ihnen eingetragenen Adressen Ihrer Freunde zu speichern und Mails von anderen Sendern als Spam zu betrachten. Wir erstellen ein HashSET der Schlüssel in der angegebenen Liste und lesen dann die Schlüssel von der Standardeingabe ein. Wenn der nächste Schlüssel in der Menge ist, geben Sie ihn aus; ist er nicht in der Menge, ignorieren Sie ihn. Eine Negativliste ist das genaue Gegenteil, d.h., die darin enthaltenen Schlüssel werden als „schlecht“ klassifiziert. Auch hier gibt es zwei naheliegende Filter für Clients, die mit einer Negativliste arbeiten. In unserem E-MailBeispiel könnten Sie die Adressen bekannter Spammer angeben und die E-MailAnwendung anweisen, nur die Mails durchzulassen, die nicht von einer dieser Adressen kommen. Wir können einen HashSET-Client BlackFilter implementieren, der die-
524
3.5 Anwendungen
sen Filter implementiert, indem er den Filtertest in WhiteFilter negiert. In der Praxis können typische Anwendungen – wie z.B. ein Kreditkartenunternehmen, das mit einer Negativliste gestohlene Kreditkartennummern herausfiltert, oder ein InternetRouter, der mit einer Positivliste eine Firewall implementiert – mit riesigen Listen, unbegrenzten Eingabeströmen und strengen Antwortanforderungen verbunden sein. Mit den Symboltabellen-Implementierungen, die wir bisher betrachtet haben, können wir solche Herausforderungen problemlos bewältigen. public class WhiteFilter { public static void main(String[] args) { HashSET set; set = new HashSET(); In in = new In(args[0]); while (!in.isEmpty()) set.add(in.readString()); while (!StdIn.isEmpty()) { String word = StdIn.readString(); if (set.contains(word)) StdOut.println(word); } } } Listing 3.24: Filter mit Positivliste % more list.txt was it the of % java it was it was it was it was it was
WhiteFilter list.txt < tinyTale.txt the of it was the of the of it was the of the of it was the of the of it was the of the of it was the of
% java BlackFilter list.txt < tinyTale.txt best times worst times age wisdom age foolishness epoch belief epoch incredulity season light season darkness spring hope winter despair
525
3
Suchen
3.5.3 Wörterbuch-Anwendungen Ein Symboltabellen-Service in seiner einfachsten Form baut mittels aufeinanderfolgender put-Operationen eine Symboltabelle auf, mit deren Hilfe er anschließende getAnfragen beantworten kann. Die meisten Anwendungen nutzen darüber hinaus den Umstand, dass Symboltabellen dynamische Wörterbücher darstellen, die nicht nur das Abfragen sondern auch das Aktualisieren von Informationen in der Tabelle erlauben. Die folgenden bekannten Beispiele sollen zeigen, wie vielseitig und nützlich dieser Ansatz ist.
Telefonbuch: Wenn die Namen von Personen als Schlüssel und ihre Telefonnummern als Werte dienen, bildet die Symboltabelle ein Telefonbuch ab. Ein sehr wichtiger Unterschied zu einem gedruckten Telefonbuch ist, dass wir neue Namen hinzufügen oder vorhandene Telefonnummern ändern können. Wir könnten aber auch die Telefonnummer als Schlüssel und den Namen als Wert vorgeben. Falls Sie es noch nicht gemacht haben, versuchen Sie einmal, Ihre Telefonnummer (mit Vorwahl) in das Suchfeld Ihres Browsers einzugeben.
Wörterbuch: Ein Wort mit seiner Definition zu verbinden, ist ein bekanntes Konzept, das wir unter dem Begriff „Wörterbuch“ kennen. Seit Jahrhunderten gibt es in vielen Haushalten und Büros Wörterbücher, um Definitionen und Schreibweisen (Werte) von Wörtern (Schlüssel) nachzuschlagen. Gute Symboltabellen-Implementierungen haben jedoch dazu geführt, dass viele Menschen heutzutage integrierte Rechtschreibprüfungen und direkten Zugriff auf Begriffsdefinitionen auf ihren Computern für selbstverständlich halten.
Kontodaten: Wer in Aktien investiert hat, kann heutzutage im Web den aktuellen Kurs ständig im Auge behalten. Verschiedene Dienste im Web ordnen einem Tickersymbol (Schlüssel) den aktuellen Kurs (Wert) und normalerweise eine Menge weiterer Informationen zu. Kommerzielle Anwendungen dieser Art gibt es zuhauf, seien es Finanzinstitute, die Kontodaten einem Namen oder einer Kontonummer zuordnen, oder Bildungseinrichtungen, die Noten einem Schülernamen oder einer Immatrikulationsnummer zuordnen.
Genomik: Symbole spielen in der modernen Genomik eine wichtige Rolle. Das einfachste Beispiel ist die Verwendung der Buchstaben A, C, T und G für die Nukleotide, die in der DNA lebender Organismen zu finden sind, gleich danach kommt die Übereinstimmung zwischen Codons (Nukleotid-Tripletts) und Aminosäuren (TTA entspricht Leucin, TCT Cystin und so weiter), gefolgt von der Übereinstimmung zwischen Aminosäuresequenzen und Proteinen und so weiter. Wissenschaftler im Bereich der Genomik verwenden normalerweise verschiedene Arten von Symboltabellen, um dieses Wissen zu organisieren.
Experimentelle Messdaten: Von der Astrophysik bis zur Zoologie – Wissenschaftler haben heutzutage mit einer Flut von experimentellen Daten zu tun, sodass die Organisation dieser Daten und der effiziente Zugriff darauf für die Untersuchung und Interpretation der Daten quasi unerlässlich ist. Symboltabellen sind hier eine wichtige Ausgangsbasis und fortgeschrittene Datenstrukturen und Algorithmen, die auf
526
3.5 Anwendungen
Symboltabellen basieren, sind zu einem wichtigen Teil der wissenschaftlichen Forschung geworden.
Compiler: Eines der ersten Einsatzgebiete für Symboltabellen war das Speichern von Programmdaten. Während nämlich die ersten Programme noch aus reinen Zahlenfolgen bestanden, fanden die Programmierer schnell heraus, dass symbolische Namen für Operationen und Speicherpositionen (Variablennamen) weitaus einprägsamer und effizienter sind. Für die Zuordnung dieser Namen zu den Zahlen bedarf es einer Symboltabelle. Mit zunehmender Programmgröße wurden die Kosten der Symboltabellen-Operationen jedoch zu einer Schwachstelle, was wiederum zu der Entwicklung von passenden Datenstrukturen und Algorithmen führte, wie die, die wir in diesem Kapitel näher betrachtet haben.
Dateisysteme: Wir verwenden gewöhnlich Symboltabellen, um die Daten auf unserem Computer zu organisieren. Das vielleicht bekannteste Beispiel ist das Dateisystem, das einen Dateinamen (Schlüssel) mit der Speicherposition seines Inhalts (Wert) verbindet. Ihr Musik-Player bedient sich des gleichen Systems, um die Titel der Lieder (Schlüssel) der Speicherposition der Musik (Wert) zuzuordnen.
Internet-DNS: Das Domain Name System (DNS) bildet die Grundlage für die Organisation der Informationen im Internet. Es verbindet URLs (Schlüssel), die für jedermann verständlich sind (wie www.princeton.edu oder www.wikipedia.org), mit IP-Adressen (Werte), die von den Netzwerk-Routern verstanden werden (wie 208.216.181.15 oder 207.142.131.206). Dieses System ist das „Telefonbuch“ der nächsten Generation. Auf diese Weise können wir Namen verwenden, die wir uns leicht merken können, und die Maschinen können mit Zahlen arbeiten, die sich effizient verarbeiten lassen. Die Anzahl der Symboltabellen-Suchen, die jede Sekunde zu diesem Zweck über Internet-Router in der ganzen Welt ausgeführt werden, ist gewaltig – Effizienz ist hier also von größter Bedeutung. Und da jedes Jahr Millionen neue Computer und andere Geräte mit dem Internet verbunden werden, müssen die Symboltabellen auf den Internet-Routern dynamisch sein.
Tabelle 3.17 Typische Wörterbuchanwendungen Domäne
Schlüssel
Wert
Telefonbuch
Name
Telefonnummer
Wörterbuch
Wort
Definition
Konto
Kontonummer
Kontostand
Genomik
Codon
Aminosäure
Daten
Daten/Zeit
Ergebnisse
Compiler
Variablenname
Speicherposition
Dateisysteme
Liedtitel
Rechner
Internet-DNS
Website
IP-Adresse
527
3
Suchen
Trotz ihres Umfangs stellt diese Liste nur eine repräsentative Auswahl dar, die Ihnen einen Vorgeschmack von dem breit gefächerten Anwendungsspektrum der Symboltabellen-Abstraktion geben soll. Wann immer Sie etwas über einen Namen identifizieren, steht dahinter eine Symboltabelle. Das Dateisystem Ihres Computers oder das Web mag Ihnen die Arbeit abnehmen, aber irgendwo da draußen verbirgt sich immer eine Symboltabelle dahinter. Als konkretes Beispiel betrachten wir einen Symboltabellen-Client, der nach Informationen sucht, die tabellarisch im .csv-Format (kommaseparierte Werte) in einer Datei oder einer Webseite gespeichert sind. Der (zugegebenermaßen bescheidene) Verdienst dieses einfachen Formats ist, dass die Tabellendaten in einer Form bleiben, dass man die Daten ohne die Hilfe einer speziellen Anwendung lesen kann (und dies wahrscheinlich auch noch in absehbarer Zukunft). Das Format ist so aufgebaut, dass die Daten als reiner Text gespeichert werden, eine Tabellenzeile pro Textzeile, und die einzelnen Einträge werden durch Kommata getrennt. Auf der Website zum Buch finden Sie eine Vielzahl von Dateien mit kommaseparierten Werten (.csv), die von verschiedenen bereits beschriebenen Anwendungen genutzt werden, darunter amino.csv (Codon-zu-Aminosäure-Codierungen), DJIA.csv (Eröffnungskurs, Menge und Schlusskurs des Aktiendurchschnitts, für jeden Tag seit Bestehen des New Yorker Aktienmarkts), ip.csv (eine Auswahl von Einträgen aus der DNSDatenbank) und upc.csv (die UPC-Strichcodes, mit denen in der Regel Konsumartikel gekennzeichnet werden). Tabellenkalkulationsprogramme und andere Anwendungen zur Datenverarbeitung können .csv-Dateien lesen und schreiben, und unser Beispiel zeigt, dass auch wir Java-Programme schreiben können, die die Daten auf jede beliebige Weise verarbeiten. LookupCSV (Listing 3.26) erstellt einen Satz von Schlüssel-Wert-Paaren aus einer über die Befehlszeile entgegengenommenen Datei, deren Daten durch Kommata getrennt sind, und gibt dann die Werte aus, die den über die Standardeingabe eingelesenen Schlüsseln entsprechen. Die Befehlszeilenargumente sind der Dateiname und zwei ganze Zahlen, eine für das Feld, das als Schlüssel dient, und eine für das Feld, das als Wert dient.
Dieses Beispiel soll Ihnen zeigen, wie nützlich und flexibel die Symboltabellen-Abstraktion ist. Welche Website hat die IP-Adresse 128.112.136.35? (www.cs.princeton.edu) Welche Aminosäure entspricht dem Codon TCA? (Serin) Wo stand der DJIA am 29. Oktober 1929? (252.38) Welches Produkt hat den UPC-Code 0002100001086? (Kraft Parmesan) Antworten auf Fragen wie diese erhalten Sie schnell und leicht mit LookupCSV und den entsprechenden .csv-Dateien.
528
3.5 Anwendungen
% more amino.csv TTT,Phe,F,Phenylalanine TTC,Phe,F,Phenylalanine TTA,Leu,L,Leucine TTG,Leu,L,Leucine TCT,Ser,S,Serine TCC,Ser,S,Serine ... GAA,Gly,G,Glutamic Acid GAG,Gly,G,Glutamic Acid GGT,Gly,G,Glycine GGC,Gly,G,Glycine GGA,Gly,G,Glycine GGG,Gly,G,Glycine % more DJIA.csv ... 20-Oct-87,1738.74,608099968,1841.01 19-Oct-87,2164.16,604300032,1738.74 16-Oct-87,2355.09,338500000,2246.73 15-Oct-87,2412.70,263200000,2355.09 ... 30-Oct-29,230.98,10730000,258.47 29-Oct-29,252.38,16410000,230.07 28-Oct-29,295.18,9210000,260.64 25-Oct-29,299.47,5920000,301.22 ... % more ip.csv ... www.ebay.com,66.135.192.87 www.princeton.edu,128.112.128.15 www.cs.princeton.edu,128.112.136.35 www.harvard.edu,128.103.60.24 www.yale.edu,130.132.51.8 www.cnn.com,64.236.16.20 www.google.com,216.239.41.99 www.nytimes.com,199.239.136.200 www.apple.com,17.112.152.32 www.slashdot.org,66.35.250.151 www.espn.com,199.181.135.201 www.weather.com,63.111.66.11 www.yahoo.com,216.109.118.65 ... % more UPC.csv ... 0002058102040,,"1 1/4"" STANDARD STORM DOOR" 0002058102057,,"1 1/4"" STANDARD STORM DOOR" 0002058102125,,"DELUXE STORM DOOR UNIT" 0002082012728,"100/ per box","12 gauge shells" 0002083110812,"Classical CD","'Bits and Pieces'" 002083142882,CD,"Garth Brooks - Ropin' The Wind" 0002094000003,LB,"PATE PARISIEN" 0002098000009,LB,"PATE TRUFFLE COGNAC-M&H 8Z RW" 0002100001086,"16 oz","Kraft Parmesan" 0002100002090,"15 pieces","Wrigley's Gum" 0002100002434,"One pint","Trader Joe's milk" ... Listing 3.25: Typische .csv-Dateien (CSV ist ein Akronym für „Comma Separated Values“)
529
3
Suchen
Listing 3.26: Wörterbuchsuche public class LookupCSV { public static void main(String[] args) { In in = new In(args[0]); int keyField = Integer.parseInt(args[1]); int valField = Integer.parseInt(args[2]); ST st = new ST(); while (in.hasNextLine()) { String line = in.readLine(); String[] tokens = line.split(","); String key = tokens[keyField]; String val = tokens[valField]; st.put(key, val); } while (!StdIn.isEmpty()) { String query = StdIn.readString(); if (st.contains(query)) StdOut.println(st.get(query)); } } }
Dieser datengesteuerte Symboltabellen-Client liest Schlüssel-Wert-Paare aus einer .csv-Datei ein. Anschließend liest er über die Standardeingabe Schlüssel ein und gibt die zugehörigen Werte aus. Schlüssel und Werte sind beides Strings. Das Trennzeichen wird als Befehlszeilenargument übernommen. % java LookupCSV ip.csv 1 0 128.112.136.35 www.cs.princeton.edu % java LookupCSV DJIA.csv 0 3 29-Oct-29 230.07 % java LookupCSV UPC.csv 0 2 0002100001086 Kraft Parmesan % java LookupCSV amino.csv 0 3 TCC Serine
Performance spielt bei der Verarbeitung von interaktiven Anfragen keine große Rolle (da Ihr Computer in der gleichen Zeit, in der Sie eine Anfrage eingeben, Millionen von Elementen durchsuchen kann); d.h., wenn Sie LookupCSV verwenden, kommen die Vorteile schneller Implementierungen von ST nicht zum Tragen. Wenn jedoch ein Programm die
530
3.5 Anwendungen
Suchen durchführt (und zwar eine gewaltige Menge von Suchen), ist Performance von großer Bedeutung. Stellen Sie sich beispielsweise einen Internet-Router vor, der Millionen von IP-Adressen pro Sekunde suchen muss. In diesem Buch haben wir bereits im Zusammenhang mit FrequencyCounter gesehen, wie wichtig gute Performance ist, und in diesem Abschnitt werden wir noch einige andere Beispiele kennenlernen. Beispiele ähnlicher, wenn auch anspruchsvollerer Testclients für .csv-Dateien finden Sie in den Übungen. Wir könnten beispielsweise das Wörterbuch dynamisch machen, indem wir Befehle über die Standardeingabe zulassen, um den Wert zu ändern, der mit einem Schlüssel verbunden ist, oder wir könnten Bereichssuchen ermöglichen oder sogar mehrere Wörterbücher für die gleiche Datei erstellen.
3.5.4 Indizierungsclients Wörterbücher zeichnen sich dadurch aus, dass es zu jedem Schlüssel genau einen Wert gibt, sodass sich in diesem Fall der direkte Einsatz unseres Datentyps ST anbietet, der auf der Abstraktion für assoziative Arrays basiert, die jedem Schlüssel genau einen Wert zuweist. Jede Kontonummer identifiziert einen Kunden, jeder UPC-Code eindeutig ein Produkt und so weiter. Grundsätzlich kann es aber natürlich auch mehrere Werte zu einem Schlüssel geben. In unserem Beispiel amino.csv identifiziert z.B. jedes Codon genau eine Aminosäure, aber jede Aminosäure ist mit einer Liste von Codons verbunden. Veranschaulicht wird dies in Abbildung 3.66, in der in jeder Zeile eine Aminosäure steht, gefolgt von der dazugehörigen Liste von Codons.
aminoI.txt
"," als Trennzeichen
Schlüssel
Werte
Wir bezeichnen Symboltabellen, die jeden Abbildung 3.66: Eine kleine Indexdatei (20 Zeilen) Schlüssel mit mehreren Werten verbinden, als Index. Einige weitere Beispiele umfassen:
Finanzielle Transaktionen: Eine Möglichkeit für ein Unternehmen, das Kundenkonten verwaltet und die Transaktionen der einzelnen Tage verfolgen will, besteht darin, einen Index der Transaktionen eines Tages zu erstellen. Der Schlüssel ist die Kontonummer, der Wert ist die Liste der Vorkommen besagter Kontonummer in der Transaktionenliste.
Websuche: Wenn Sie ein Schlüsselwort eingeben und eine Liste von Websites erhalten, in denen dieses Schlüsselwort vorkommt, nutzen Sie einen Index, der von Ihrer Websuchmaschine erzeugt wurde und der jedem Schlüssel (der Abfrage) genau einen Wert (die Liste der Seiten) zuordnet. Die Realität ist wie so oft ein wenig komplizierter, da bei der Suche oft mehrere Schlüssel angegeben werden.
531
3
Suchen
Filme und Schauspieler: Die Datei movies.txt auf der Website zum Buch (einen Auszug sehen Sie in Abbildung 3.67) wurde der Internet Movie Database (IMDB) entnommen. In jeder Zeile steht zuerst der Name eines Films (Schlüssel) und anschließend die Liste der darin mitwirkenden Schauspieler (Wert), die durch Schrägstrich voneinander getrennt werden. Um einen Index zu erstellen, brauchen wir nur alle Werte zu einem Schlüssel in einer Datenstruktur (z.B. einer Queue) abzulegen und dann den Schlüssel mit dieser Datenstruktur als Wert zu verbinden. LookupCSV entsprechend zu erweitern, ist nicht schwierig, sodass wir Ihnen dies als Übung überlassen wollen (siehe Übung 3.5.12). Wir werden stattdessen die Klasse LookupIndex (Listing 3.27) betrachten, die mithilfe einer Symboltabelle einen Index von Dateien wie aminoI.txt und movies.txt erstellt. Dabei muss das Trennzeichen nicht wie in .csv-Dateien ein Komma sein, sondern kann über die Befehlszeile vorgegeben werden. Nach der Erstellung des Index liest LookupIndex Schlüssel-Abfragen ein und gibt die Werte zu den Schlüsseln aus. Das Interessante an LookupIndex ist, dass zu jeder Datei auch ein invertierter Index erstellt wird, mit Werten und Schlüsseln in vertauschten Rollen. Im Falle unseres Aminosäure-Beispiels erhalten Sie damit die gleiche Funktionalität, wie sie auch Lookup bereitstellt (es wird die Aminosäure zu einem gegebenen Codon gesucht). Im Falle des Film/Darsteller-Beispiels besteht dadurch die zusätzliche Option, zu jedem gegebenen Schauspieler die Filme zu suchen, in denen er mitgewirkt hat – was implizit in den Daten enthalten ist, aber ohne eine Symboltabelle den Daten nicht zu entnehmen wäre. Studieren Sie dieses Beispiel sorgfältig, da es Ihnen gute Einblicke in die grundlegende Bedeutung von Symboltabellen gewährt.
Tabelle 3.18 Typische Indizierungsanwendungen Domäne
Schlüssel
Wert
Genomik
Aminosäure
Liste der Codons
Finanztransaktionen
Kontonummer
Liste der Transaktionen
Websuche
Suchbegriff
Liste der Webseiten
IMDB
Film
Liste der Schauspieler
movies.txt
"/" als Trennzeichen
Schlüssel
Werte
Abbildung 3.67: Kleiner Auszug aus einer großen Indexdatei (mehr als 250.000 Zeilen)
532
3.5 Anwendungen
Invertierter Index Von einem invertierten Index wird normalerweise gesprochen, wenn mit Werten nach Schlüsseln gesucht wird. Wir haben eine Riesenmenge an Daten und wollen wissen, wo bestimmte interessante Schlüssel auftreten. Diese Anwendung ist ein weiteres typisches Beispiel für einen Symboltabellen-Client, der get()- und put()-Aufrufe in beliebiger Reihenfolge verwendet. Auch hier verbinden wir jeden Schlüssel mit einer Menge (SET) von Speicherpositionen, an denen der Schlüssel zu finden ist. Was genau unter einer Position zu verstehen ist, hängt von der Anwendung ab: In einem Buch könnte eine Position eine Seitenzahl sein, in einem Programm eine Zeilennummer, in der Genomik eine Position in der genetischen Sequenz und so weiter.
Internet Movie Database (IMDB): In dem gerade betrachteten Beispiel ist die Eingabe ein Index, der jeden Film mit einer Liste der Schauspieler verbindet. Der invertierte Index verbindet jeden Schauspieler mit einer Liste seiner Filme.
Buchindex: Jedes Fachbuch besitzt einen Index, in dem Sie Stichwörter nachschlagen können und der Sie zu den Seiten führt, auf denen das Thema behandelt wird. Bei der Erstellung eines guten Index muss der Autor des Buches normalerweise gewöhnliche und unwichtige Wörter persönlich entfernen. Ein Dokumenterstellungssystem wird sicherlich eine Symboltabelle heranziehen, um den Prozess zu automatisieren. Einen interessanten Sonderfall bildet die sogenannte Konkordanz, die zu jedem Wort im Text alle Textstellen angibt, an denen das Wort vorkommt (siehe Übung 3.5.20).
Tabelle 3.19 Typische invertierte Indizes Domäne
Schlüssel
Wert
IMDB
Schauspieler
Menge der Filme
Buch
Begriff
Menge der Seiten
Compiler
Bezeichner
Menge der Vorkommen
Dateisuche
Suchbegriff
Menge der Dateien
Genomik
Teilsequenz
Menge der Positionen
Compiler: In einem großen Programm, das viele Symbole verwendet, ist es nützlich zu wissen, wo jeder Name verwendet wird. Früher war eine ausgedruckte, eindeutige Symboltabelle eines der wichtigsten Hilfsmittel eines jeden Programmierers, um große Programme zu verwalten. In modernen Systemen sind Symboltabellen die Grundlage von Software-Tools, mit denen Programmierer die Namen von Modulen in Systemen verwalten.
Dateisuche: Moderne Betriebssysteme bieten Ihnen die Möglichkeit, einen Begriff einzugeben und die Namen der Dateien zu ermitteln, die diesen Begriff enthalten. Der Schlüssel ist der Begriff und der Wert die Menge der Dateien, in denen dieser Begriff vorkommt.
533
3
Suchen
Genomik: In einem typischen (wenn auch stark vereinfachten) Szenario der Genomforschung möchte ein Wissenschaftler die Positionen einer gegebenen genetischen Sequenz in einem vorhandenen Genom oder in einem Satz von Genomen ermitteln. Das Vorhandensein von bestimmten Sequenzen oder die Nähe zueinander kann von wissenschaftlicher Bedeutung sein. Der Ausgangspunkt einer solchen Untersuchung ist ein Index wie eine Konkordanz, wobei natürlich zu beachten ist, dass Genome nicht in Wörter unterteilt sind (siehe Übung 3.5.15).
Listing 3.27: Im Index (und invertierten Index) suchen public class LookupIndex { public static void main(String[] args) { In in = new In(args[0]); // Index-Datenbank String sp = args[1]; // Trennzeichen ST st = new ST(); ST ts = new ST(); while (in.hasNextLine()) { String[] a = in.readLine().split(sp); String key = a[0]; for (int i = 1; i < a.length; i++) { String val = a[i]; if (!st.contains(key)) st.put(key, new Queue()); if (!ts.contains(val)) ts.put(val, new Queue()); st.get(key).enqueue(val); ts.get(val).enqueue(key); } } while (!StdIn.isEmpty()) { String query = StdIn.readLine(); if (st.contains(query)) for (String s : st.get(query)) StdOut.println(" " + s); if (ts.contains(query)) for (String s : ts.get(query)) StdOut.println(" " + s); } } }
Dieser datengesteuerte Symboltabellen-Client liest Schlüssel-Wert-Paare aus einer Datei ein. Anschließend liest er über die Standardeingabe Schlüssel ein und gibt die zugehörigen Werte aus. Schlüssel und Werte sind beides Strings. Die Schlüssel sind Strings und die Werte Listen von Strings. Das Trennzeichen wird als Befehlszeilenargument übernommen.
534
3.5 Anwendungen
% java LookupIndex aminoI.txt "," Serine TCT TCA TCG AGT AGC TCG Serine % java LookupIndex movies.txt "/" Bacon, Kevin Mystic River (2003) Friday the 13th (1980) Flatliners (1990) Few Good Men, A (1992) ... Tin Men (1987) Blumenfeld, Alan DeBoy, David ...
FileIndex (Listing 3.28) übernimmt Dateinamen von der Befehlszeile und erstellt mithilfe einer Symboltabelle einen invertierten Index, der jedes Wort, das in mindestens einer der angegebenen Dateien vorkommt, mit einer Menge (SET) von Dateinamen verbindet, in denen das Wort zu finden ist. Anschließend liest FileIndex über die Standardeingabe Schlüsselwörter ein und erstellt eine Liste der Dateien, in denen das Schlüsselwort gefunden wurde. Diesen Prozess kennen Sie von bekannten Software-Tools, mit denen Sie im Web oder auf Ihrem Rechner nach Daten suchen: Sie geben ein Schlüsselwort ein und erhalten eine Liste der Orte, an denen das Schlüsselwort vorkommt. Entwickler solcher Tools verfeinern den Prozess normalerweise, indem sie vor allem folgenden Komponenten besondere Aufmerksamkeit schenken:
der Form der Abfrage der Menge der Dateien/Seiten, die indiziert werden der Reihenfolge, in der die Dateien in der Antwort aufgelistet werden So sind Sie sicher gewohnt, bei Ihren Abfragen mehrere Schlüsselwörter in Ihre Websuchmaschine einzugeben (die auf der Indizierung einer großen Anzahl von Seiten im Web basiert) und als Ergebnis Antworten zu erhalten, die nach Relevanz oder Wichtigkeit (für Sie oder einen Werbetreibenden) sortiert sind. In den Übungen am Ende dieses Abschnitts werden Sie einige dieser Verbesserungsmöglichkeiten kennenlernen. Später werden wir noch auf verschiedene Algorithmusprobleme im Zusammenhang mit der Websuche zu sprechen kommen, aber die Symboltabelle ist zweifelsohne das Herzstück dieses Prozesses. Verfahren Sie mit FileIndex ebenso wie mit LookupIndex. Laden Sie sich den Code von der Website zum Buch herunter und indizieren Sie mit diesem Programm verschiedene Textdateien auf Ihrem Rechner oder einige interessante Websites, um sich davon zu überzeugen, wie nützlich Symboltabellen sind. Sie werden feststellen, dass Sie damit in kürzester Zeit große Indizes für riesige Dateien erstellen können, da jede put-Operation und get-Anfrage direkt bearbeitet wird. Diese direkte Reaktion für riesige dynamische Tabellen ist eine der klassischen Errungenschaften der Algorithmenforschung.
535
3
Suchen
Listing 3.28: Datei-Indizierung import java.io.File; public class FileIndex { public static void main(String[] args) { ST st = new ST(); for (String filename : args) { File file = new File(filename); In in = new In(file); while (!in.isEmpty()) { String word = in.readString(); if (!st.contains(word)) st.put(word, new SET()); SET set = st.get(word); set.add(file); } } while (!StdIn.isEmpty()) { String query = StdIn.readString(); if (st.contains(query)) for (File file : st.get(query)) StdOut.println(" " + file.getName()); } } }
Dieser Symboltabellen-Client indiziert eine Reihe von Dateien. Wir suchen in einer Symboltabelle nach jedem Wort in jeder Datei und erhalten eine Liste der Dateinamen (SET), die dieses Wort enthalten. Die Namen für In können auch Webseiten sein, sodass Sie mit diesem Code auch einen invertierten Index von Webseiten erstellen können. % more ex1.txt it was the best of times % more ex2.txt it was the worst of times % more ex3.txt it was the age of wisdom % more ex4.txt it was the age of foolishness % java FileIndex ex*.txt age ex3.txt ex4.txt best ex1.txt was ex1.txt ex2.txt ex3.txt ex4.txt
536
3.5 Anwendungen
3.5.5 Dünn besetzte Vektoren Unser nächstes Beispiel veranschaulicht, wie wichtig Symboltabellen für die Lösung wissenschaftlicher und mathematischer Probleme sind. Das Beispiel dreht sich um eine elementare, allgemein bekannte Berechnung, die in den entsprechenden praktischen Anwendungen zu einem Engpass werden kann. Anschließend zeigen wir, wie Sie mithilfe einer Symboltabelle nicht nur diesen Engpass beseitigen, sondern auch zu einer Lösung kommen, mit der sich noch weitaus größere Probleme angehen lassen. Die Berechnung, um die es dabei geht, bildete übrigens das Herzstück des PageRankAlgorithmus, der von S. Brin und L. Page entwickelt wurde und zum kometenhaften Aufstieg von Google Anfang dieses Jahrtausends führte (außerdem ist sie eine wohlbekannte mathematische Abstraktion, die in vielen anderen Kontexten nützlich ist). Die grundlegende Berechnung, die wir hier betrachten, ist eine Matrix-Vektor-Multiplikation: Für eine gegebene Matrix und einen gegebenen Vektor soll ein Ergebnisvektor berechnet werden, dessen i-ter Eintrag das Skalarprodukt des gegebenen Vektors und der i-ten Zeile der Matrix ist. Der Einfachheit halber betrachten wir den Fall einer quadratischen Matrix mit N Zeilen, N Spalten und Vektoren der Größe N. Diese Operation gehört zum elementaren Codeumfang von Java und benötigt Zeit proportional zu N 2, um die N Multiplikationen zur Berechnung jeder der N Einträge im Ergebnisvektor auszuführen. Dieser Wert entspricht dem Speicherbedarf zum Speichern der Matrix, der ebenfalls proportional zu N 2 ist. a[][]
x[]
b[]
0
.05
0
0 .36 .36 .18
.04
0
0
0 .90
0
.36
.90
0
0
0
0
.37
.045
.47
0 .47
0
0
.19
.1927
0 .90
0
0
.036 .297 =
.333
Abbildung 3.68: Matrix-Vektor-Multiplikation ... double[][] a = new double[N][N]; double[] x = new double[N]; double[] b = new double[N]; ... // Initialisiert a[][] und x[]. ... for (int i = 0; i < N; i++) { sum = 0.0; for (int j = 0; j < N; j++) sum += a[i][j]*x[j]; b[i] = sum; } Listing 3.29: Standardimplementierung einer Matrix-Vektor-Multiplikation
537
3
Suchen
In der Praxis kommt es sehr oft vor, dass N riesig ist. So ist zum Beispiel in der oben zitierten Google-Anwendung N gleich der Anzahl der Seiten im Web. Zur Zeit der Entwicklung von PageRank lag diese Zahl bei einigen zehn oder hundert Milliarden. Inzwischen ist diese Zahl immens in die Höhe geschnellt, sodass der Wert von N 2 weit über 1020 liegt. Und da sich niemand so viel Zeit oder Speicher leisten kann, wird dringend ein besserer Algorithmus benötigt. Listing 3.30: Datentyp für dünn besetzte Vektoren, inklusive Skalarprodukt public class SparseVector { private HashST st; public SparseVector() { st = new HashST(); public int size() { return st.size(); } public void put(int i, double x) { st.put(i, x); } public double get(int i) { if (!st.contains(i)) return 0.0; else return st.get(i); } public double dot(double[] that) { double sum = 0.0; for (int i : st.keys()) sum += that[i]*this.get(i); return sum; } }
}
Dieser Symboltabellen-Client ist die Minimalimplementierung eines Datentyps für dünn besetzte Vektoren und illustriert, wie das Skalarprodukt für dünn besetzte Vektoren effizient berechnet werden kann. Wir multiplizieren jeden Eintrag mit seinem Gegenstück im anderen Operanden und addieren das Ergebnis zu einer laufenden Summe. Die Anzahl der erforderlichen Multiplikationen entspricht der Anzahl der Nicht-Null-Einträge im dünn besetzten Vektor. Zum Glück kommt es oft vor, dass die Matrix dünn besetzt (sparse) ist, d.h., eine sehr große Anzahl ihrer Einträge ist 0. Bei der Google-Anwendung ist die durchschnittliche Anzahl der Nicht-Null-Einträge pro Zeile eine kleine Konstante: Praktisch alle Webseiten haben nur ein paar Links zu anderen Webseiten (und nicht zu allen). Deshalb können wir die Matrix als ein Array von dünn besetzten Vektoren repräsentieren, wobei wir eine SparseVector-Implementierung wie den HashST-Client aus Listing 3.30 verwenden. Anstelle des Codes a[i][j], um auf das Element in Zeile i und Spalte j Bezug zu nehmen, schreiben wir a[i].put(j, val), um einen Wert in der Matrix zu setzen, und a[i].get(j), um einen Wert abzufragen. Wie der Code aus Listing 3.31 zeigt, ist die Matrix-Vektor-Multiplikation mit dieser Klasse noch einfa-
538
3.5 Anwendungen
cher als mit der Arrayrepräsentation (und beschreibt die Berechnung noch präziser). Weitaus wichtiger ist jedoch, dass der Zeitbedarf nur proportional N plus der Anzahl der Nicht-Null-Elemente in der Matrix ist. Array von double[]-Objekten 0
1
0.0 0 a 1
1
0.0
0 0
0.0 2
0.0 1
0.0
2
2
.90
.36 2
0.0
0.0
Array von SparseVector-Objekten 3
0.0 3
.36 3
.90
1
0.0
.18 4
0.0
.90 Schlüssel Wert
4
3 4
st
4
a
st 2
.36
3
.90
0
.90
0
.45
3
.36
1
st
2
1
.90 0
2
0.0 1
.45
0.0 2
0.0
.45
3
0.0 3
0.0
4
0.0
.18
unabhängige Symboltabellenobjekte
3 0
4
0
4
st
4
0.0
st
2
.45
a[4][2]
Abbildung 3.69: Repräsentationen dünn besetzter Matrizen
Für kleine Matrizen oder Matrizen, die nicht dünn besetzt sind, kann der Overhead beim Verwalten von Symboltabellen recht hoch sein. Dennoch sollten Sie die Auswirkungen verstanden haben, die mit dem Einsatz von Symboltabellen für riesige dünn besetzte Matrizen verbunden sind. Lassen Sie uns zur besseren Einprägung eine riesige Anwendung (wie die von Brin und Page) betrachten, bei der N gleich 10 Milliarden oder 100 Milliarden ist, aber die durchschnittliche Anzahl der Nicht-Null-Elemente pro Zeile kleiner als 10 ist. Bei einer solchen Anwendung können Sie mit einer Symboltabelle die Matrix-Vektor-Multiplikation um den Faktor eine Milliarde oder mehr beschleunigen. Der elementare Charakter dieser Anwendung sollte Sie nicht über ihre Bedeutung hinwegtäuschen: Programmierer, die dieses Potenzial zum Sparen von Zeit und Speicher nicht nutzen, schränken sich in ihren Möglichkeiten beim Lösen praktischer Probleme extrem ein, während Programmierer, die von den Steigerungsmöglichkeiten bei der Rechenleistung um den Faktor eine Milliarde Gebrauch machen, Probleme in Angriff nehmen können, die bisher als unlösbar galten. .. SparseVector[] a; a = new SparseVector[N]; double[] x = new double[N]; double[] b = new double[N]; ... // Initialize a[] and x[]. ... for (int i = 0; i < N; i++) b[i] = a[i].dot(x); Listing 3.31: Dünn besetzte Matrix-Vektor-Multiplikation
539
3
Suchen
Die Erstellung der Matrix für die Google-Anwendung ist eine graphenverarbeitende Anwendung (und ein Symboltabellen-Client!), allerdings für eine riesige dünn besetzte Matrix. Für eine gegebene Matrix besteht die PageRank-Berechnung einfach darin, eine Matrix-Vektor-Multiplikation auszuführen, die den Quellvektor durch den Ergebnisvektor ersetzt, und diesen Prozess zu wiederholen, bis er konvergiert (wie es die grundlegenden Theoreme der Wahrscheinlichkeitstheorie garantieren). Auf diese Weise kann eine Klasse wie SparseVector den Zeit- und Speicherbedarf dieser Anwendung um den Faktor von 10 Milliarden bis 100 Milliarden oder mehr verbessern. Ähnliche Einsparungen lassen sich in vielen wissenschaftlichen Berechnungen erzielen, sodass dünn besetzte Vektoren und Matrizen weit verbreitet sind und Eingang in viele Systeme des Scientific Computing gefunden haben. Wo mit riesigen Vektoren und Matrizen gearbeitet wird, ist es grundsätzlich ratsam, vorab einige einfache Performance-Tests durchzuführen, um sich davon zu überzeugen, dass Ihnen die hier beschriebenen Performance-Gewinne nicht entgehen. Andererseits ist in den meisten Programmiersprachen Arrayverarbeitung für primitive Datentypen integriert, sodass Sie (wie in unserem Beispiel) mit der Verwendung von Arrays für Vektoren, die nicht dünn besetzt sind, weitere Leistungssteigerungen erzielen können. Bei solchen Anwendungen sollten Sie sich auf alle Fälle einen Überblick über die anfallenden Kosten verschaffen, um die richtigen Implementierungsentscheidungen zu treffen. Symboltabellen gehören mit zu den wichtigsten Beiträgen der Algorithmenforschung zur Entwicklung unserer modernen Rechner- und Software-Infrastruktur, da sie in vielen realen Anwendungen zu gigantischen Einsparungen führen – was nicht selten darüber entscheidet, ob ein Problem überhaupt sinnvoll und effektiv gelöst werden kann. In nur wenigen Bereichen der Wissenschaft und Technik können wir die Auswirkungen einer Erfindung untersuchen, die die Kosten um den Faktor 100 Milliarden und mehr reduziert – bei Symboltabellen-Anwendungen ist dies möglich, wie wir gerade anhand von Beispielen sehen konnten, und die beobachteten Effekte waren tief greifend. Und dabei stellen die Datenstrukturen und Algorithmen, die wir betrachtet haben, mit Sicherheit noch nicht das Ende der Entwicklung dar: Sie wurden alle in wenigen Jahrzehnten entwickelt und ihre Eigenschaften sind immer noch nicht vollständig verstanden. Aufgrund ihrer Bedeutung werden Symboltabellen-Implementierungen auch heute noch intensiv von Wissenschaftlern auf der ganzen Welt erforscht, und wir werden wohl in vielen Bereichen neue Entwicklungen sehen, mit denen immer umfangreichere und gewaltigere Aufgaben angegangen werden können.
540
Fragen und Antworten
3.5
Fragen und Antworten
Frage: Kann ein SET null enthalten? Antwort: Nein. Die Schlüssel sind wie bei den Symboltabellen immer Nicht-NullObjekte. Frage: Kann ein SET null sein? Antwort: Nein. Ein SET kann leer sein (d.h. keine Objekte enthalten), aber nicht null. Wie bei jedem anderen Java-Datentyp kann eine Variable vom Typ SET den Wert null haben, aber das weist nur darauf hin, dass sie nicht auf ein SET verweist. Das Ergebnis von new zur Erzeugung eines SET ist immer ein Objekt, das nicht null ist. Frage: Wenn sich alle meine Daten im Speicher befinden, gibt es keinen wirklichen Grund, einen Filter zu verwenden, oder? Antwort: Genau. Filtern ist vor allem dann sinnvoll, wenn Sie keine Vorstellung davon haben, wie viele Daten zu erwarten sind. Andernfalls kann es eine nützliche Sichtweise sein, aber kein Allheilmittel. Frage: Ich arbeite viel mit Tabellendaten. Bestünde die Möglichkeit, eine ähnliche Lösung wie LookupCSV zu entwickeln, mit deren Hilfe ich in den Daten suchen kann? Antwort: Ihr Tabellenkalkulationsprogramm hat wahrscheinlich eine Option, um die Daten in eine .csv-Datei zu exportieren, sodass Sie LookupCSV direkt verwenden können. Frage: Wozu brauche ich eigentlich FileIndex? Löst mein Betriebssystem nicht dieses Problem? Antwort: Wenn Ihr Betriebssystem bereits alle Ihre Bedürfnisse erfüllt, nutzen Sie es auch weiterhin. Wie bei vielen unserer Programme soll FileIndex Ihnen lediglich die grundlegenden Mechanismen solcher Anwendungen zeigen und Möglichkeiten aufweisen. Frage: Warum übergeben wir der Methode dot() von SparseVector nicht ein SparseVector-Objekt als Argument und lassen die Methode ein SparseVector-Objekt zurück-
liefern? Antwort: Das ist eine sehr gute Designalternative und eine schöne Programmierübung, die etwas komplizierteren Code erfordert als unser Design (siehe Übung 3.5.16). Daneben könnte es auch interessant sein, für die allgemeine Matrixverarbeitung einen SparseMatrix-Typ hinzuzufügen.
541
3
Suchen
3.5
542
Allgemeine Übungen
1.
Implementieren Sie SET und HashSET als Wrapperklassen-Clients von ST beziehungsweise HashST (geben Sie Dummy-Werte an und ignorieren Sie sie).
2.
Entwickeln Sie eine SET-Implementierung SequentialSearchSET. Legen Sie den Code für SequentialSearchST zugrunde und entfernen Sie alle Codezeilen, die mit Werten zu tun haben.
3.
Entwickeln Sie eine SET-Implementierung BinarySearchSET. Legen Sie den Code für BinarySearchST zugrunde und entfernen Sie alle Codezeilen, die mit Werten zu tun haben.
4.
Schreiben Sie zwei Klassen HashSTint und HashSTdouble, mit denen Schlüssel des primitiven Typs int beziehungsweise double verwaltet werden können. (Wandeln Sie im Code von LinearProbingHashST die Stellen mit Java Generics in Code mit primitiven Typen um.)
5.
Schreiben Sie zwei Klassen STint und STdouble zur Verwaltung geordneter Symboltabellen, bei denen die Schlüssel vom primitiven Typ int beziehungsweise double sind. (Wandeln Sie im Code von RedBlackBST die Stellen mit Java Generics in Code mit primitiven Typen um.) Testen Sie Ihre Lösung mit einer Version von SparseVector als Client.
6.
Schreiben Sie zwei Klassen HashSETint und HashSETdouble, mit denen Schlüssel des primitiven Typs int beziehungsweise double verwaltet werden können. (Entfernen Sie Codezeilen mit Werten in Ihrer Lösung zu Übung 3.5.4.)
7.
Schreiben Sie zwei Klassen SETint und SETdouble, mit denen Schlüssel des primitiven Typs int beziehungsweise double verwaltet werden können. (Entfernen Sie in Ihrer Lösung zu Übung 3.5.5. Codezeilen, die mit Werten zu tun haben.)
8.
Überarbeiten Sie LinearProbingHashST so, dass auch doppelte Schlüssel in der Tabelle gespeichert werden. Liefern Sie mit get() irgendeinen Wert zu dem gegebenen Schlüssel zurück und entfernen Sie mit delete() alle Elemente, deren Schlüssel dem gegebenen Schlüssel entsprechen.
9.
Überarbeiten Sie BST so, dass auch doppelte Schlüssel im Baum gespeichert werden. Liefern Sie mit get() irgendeinen Wert zu dem gegebenen Schlüssel zurück und entfernen Sie mit delete() alle Knoten, deren Schlüssel dem gegebenen Schlüssel entsprechen.
10.
Überarbeiten Sie RedBlackBST so, dass auch doppelte Schlüssel im Baum gespeichert werden. Liefern Sie mit get() irgendeinen Wert zu dem gegebenen Schlüssel zurück und entfernen Sie mit delete() alle Knoten, deren Schlüssel dem gegebenen Schlüssel entsprechen.
11.
Schreiben Sie eine Klasse MultiSET, die der Klasse SET entspricht, aber gleiche Schlüssel erlaubt und damit eine mathematische Multimenge implementiert.
Allgemeine Übungen
12.
Wandeln Sie LookupCSV so ab, dass ein Schlüssel mit allen Werten verbunden wird, die in Schlüssel-Wert-Paaren mit diesem Schlüssel in der Eingabe auftauchen (statt wie in der Abstraktion des assoziativen Arrays nur den zuletzt angetroffenen zu verwenden).
13.
Überarbeiten Sie LookupCSV zu einem Programm RangeLookupCSV, das zwei Schlüsselwerte von der Standardeingabe einliest und alle Schlüssel-Wert-Paare in der .csv-Datei ausgibt, deren Schlüssel in dem angegebenen Bereich liegen.
14.
Entwickeln und testen Sie eine statische Methode invert(), die als Argument ein ST übernimmt und den Kehrwert der gegebenen Symboltabelle zurückliefert (eine Symboltabelle des gleichen Typs).
15.
Schreiben Sie ein Programm, das einen String von der Standardeingabe und einen Integer k als Befehlszeilenargument einliest und auf der Standardausgabe eine sortierte Liste der im String gefundenen k-Gramme ausgibt, jeweils gefolgt von dem Index im String.
16.
Ergänzen Sie SparseVector um eine Methode sum(), die einen SparseVector als Argument übernimmt und einen SparseVector zurückliefert, der diesen Vektor und den Argumentvektor komponentenweise summiert. Hinweis: Sie benötigen delete() (und ein Auge für die Genauigkeit) für den Fall, dass ein Eintrag 0 wird.
543
3
Suchen
3.5 17.
Knifflige Aufgaben Mathematische Mengen: Ihr Ziel ist es, eine Implementierung der folgenden MathSET-API zur Verarbeitung (veränderlicher) mathematischer Mengen zu entwickeln:
Tabelle 3.20 API für einen grundlegenden Mengen-Datentyp public class MathSET MathSET(Key[] universe)
Erzeugt eine Menge.
void
add(Key key)
Fügt einen key in die Menge ein.
MathSET
complement()
Menge der Schlüssel im Universum, die nicht in dieser Menge enthalten sind.
void
union(MathSET a)
Fügt alle Schlüssel von a in die Menge ein, die noch nicht enthalten sind.
void
intersection(MathSET a)
Entfernt alle Schlüssel aus dieser Menge, die nicht in a enthalten ist.
void
delete(Key key)
Entfernt key aus der Menge.
boolean
contains(Key key)
Ist key in der Menge enthalten?
boolean
isEmpty()
Ist die Menge leer?
size()
Anzahl der Schlüssel in der Menge.
int
Verwenden Sie eine Symboltabelle. Zusatzpunkte: Repräsentieren Sie Mengen durch Arrays mit booleschen Elementen. 18.
Multimengen: Nachdem Sie die Übungen 3.5.2 und 3.5.3 sowie die vorherige Übung nachvollzogen haben, entwickeln Sie APIs MultiHashSET und MultiSET für Multimengen (Mengen, die gleiche Schlüssel haben können) und schreiben Sie zwei Implementierungen SeparateChainingMultiSET und BinarySearchMultiSET sowohl für ungeordnete als auch geordnete Multimengen.
19.
Gleiche Schlüssel in Symboltabellen: Angenommen die API MultiSET (ungeordnet oder geordnet) entspräche den Symboltabellen-APIs in Tabelle 3.2 und Tabelle 3.4, erlaube aber gleiche Schlüssel und die Semantik von get() sähe folglich so aus, dass die Methode irgendeinen der Werte zurückliefert, die mit dem angegebenen Schlüssel verbunden sind. Nehmen Sie weiter an, dass wir eine neue Methode hinzugefügt hätten Iterable getAll(Key key)
die alle Werte zurückliefert, die mit dem angegebenen Schlüssel verbunden sind. Entwickeln Sie dann ausgehend von unserem Code für SeparateChainingST und BinarySearchST die Implementierungen SeparateChainingMultiST und BinarySearchMultiST für diese APIs.
544
Knifflige Aufgaben
20.
Konkordanz: Schreiben Sie einen ST-Client Concordance, der auf der Standardausgabe eine Konkordanz der Strings aus der Standardeingabe ausgibt (siehe Seite 533).
21.
Invertierte Konkordanz: Schreiben Sie ein Programm InvertedConcordance, das eine Konkordanz von der Standardeingabe einliest und den ursprünglichen String über die Standardausgabe ausgibt. Hinweis: Zu dieser Berechnung gibt es eine berühmte Geschichte: Das Team, das die Schriftrollen vom Toten Meer entdeckte, verpflichtete sich zur Geheimhaltung und präsentierte der Öffentlichkeit nur eine Konkordanz der Texte auf den Originaltafeln. Nach einiger Zeit fanden Wissenschaftler heraus, wie sie die Konkordanz invertieren konnten, und veröffentlichten daraufhin den vollständigen Text.
22.
Voll indizierter CSV: Implementieren Sie einen ST-Client FullLookupCSV, der ein Array von ST-Objekten erstellt (eines für jedes Feld), mit einem Testclient, der dem Benutzer erlaubt, in jeder Abfrage die Schlüssel- und Wert-Felder anzugeben.
23.
Dünn besetzte Matrizen: Entwickeln Sie eine API und eine Implementierung für dünn besetzte 2D-Matrizen. Unterstützen Sie Matrixaddition und Matrixmultiplikation. Sehen Sie Konstruktoren für Zeilen- und Spaltenvektoren vor.
24.
Nicht überlappende Intervallsuche: Gegeben sei eine Liste nicht überlappender Intervalle von Elementen. Schreiben Sie eine Funktion, die ein Element als Argument übernimmt und feststellt, in welchem dieser Intervalle, wenn überhaupt, das übergebene Element liegt. Sind die Elemente zum Beispiel Integer und die Intervalle lauten 1643–2033, 5532–7643, 8999–10332 und 5666653–5669321, so liegt der Abfragepunkt 9122 im dritten Intervall und 8122 ist in keinem Intervall vorhanden.
25.
Vorlesungsverzeichnis: An einer namhaften Universität passierte es vor Kurzem, dass ein Dozent für zwei zeitgleich stattfindende Kurse eingeteilt wurde. Helfen Sie, solche Fehler in Zukunft zu vermeiden, indem Sie ein Verfahren beschreiben, wie man solche Überschneidungen feststellen könnte. Gehen Sie der Einfachheit halber davon aus, dass alle Kurse 50 Minuten dauern und jeweils zur vollen Stunde beginnen (9, 10, 11, 13, 14 oder 15).
26.
LRU-Cache: Erzeugen Sie eine Datenstruktur, die die folgenden Operationen unterstützt: Zugriff und Entfernen. Die Zugriff-Operation fügt das Element in die Datenstruktur ein, wenn es dort noch nicht vorhanden ist. Die Entfernen-Operation löscht das am längsten nicht mehr benutzte Element und liefert es zurück. Hinweis: Verwalten Sie die Elemente in der Reihenfolge ihres Zugriffs in einer doppelt verketteten Liste, zusammen mit Zeigern und den ersten und letzten Knoten. Verwenden Sie eine Symboltabelle, in der die Elemente die Schlüssel und die Positionen in der verketteten Liste die Werte sind. Wenn Sie auf ein Element zugreifen, löschen Sie es aus der verketteten Liste und fügen es am Anfang der Liste wieder ein. Wenn Sie ein Element entfernen, löschen Sie es am Ende und entfernen es aus der Symboltabelle.
545
3
Suchen
27.
Liste: Entwickeln Sie eine Implementierung der folgenden API:
Tabelle 3.21 API für einen Listen-Datentyp public class List implements Iterable List()
Erzeugt eine Liste.
void
addFront(Item item)
Fügt item am Anfang der Liste ein.
void
addBack(Item item)
Fügt item am Ende der Liste ein.
Item
deleteFront()
Entfernt vom Anfang der Liste.
Item
deleteBack()
Entfernt am Ende der Liste.
void
delete(Item item)
Entfernt item aus der Liste.
void
add(int i, Item item)
Fügt item als i-tes Element in die Liste ein.
Item
delete(int i)
Entfernt das i-te Element aus der Liste.
boolean
contains(Item item)
Ist key in der Liste enthalten?
boolean
isEmpty()
Ist die Liste leer?
size()
Anzahl der Schlüssel in der Menge.
int
Hinweis: Verwenden Sie zwei Symboltabellen, eine, um das i-te Element schnell in der Liste zu finden, und die andere, um schnell nach einem Element zu suchen. (Das Java-Interface java.util.List enthält solche Methoden, bietet aber keine Implementierung, die alle Operationen effizient unterstützt.)
546
28.
UniQueue: Erzeugen Sie einen Datentyp, der eine Warteschlange repräsentiert, jedoch mit der Abweichung, dass ein Element nur einmal in die Warteschlange eingefügt werden kann. Verwenden Sie eine Symboltabelle, um alle Elemente nachzuverfolgen, die je eingefügt wurden, und Abfragen zu ignorieren, die solche Elemente erneut einzufügen versuchen.
29.
Symboltabelle mit wahlfreiem Zugriff: Erzeugen Sie einen Datentyp, der folgende Operationen unterstützt: ein Schlüssel-Wert-Paar einfügen, nach einem Schlüssel suchen und den damit verbunden Wert zurückliefern sowie einen zufälligen Schlüssel löschen und zurückliefern. Hinweis: Kombinieren Sie eine Symboltabelle mit einer randomisierten Warteschlange.
Experimente
3.5
Experimente
30.
Duplikate (überarbeitet): Wiederholen Sie Übung 2.5.31 und verwenden Sie diesmal den Dedup-Filter aus Listing 3.23. Vergleichen Sie die Ausführungszeiten der beiden Ansätze. Führen Sie dann mit Dedup die Experimente für N=107, 108 und 109 aus, wiederholen Sie die Experimente für long-Zufallswerte und diskutieren Sie die Ergebnisse.
31.
Rechtschreibprüfung: Übergeben Sie die Datei dictionary.txt von der Website zum Buch als Befehlszeilenargument, damit der Client BlackFilter (als Negation von WhiteFilter in Listing 3.24) alle Wörter ausgibt, die in einer von der Standardeingabe eingelesenen Datei falsch geschrieben wurden. Vergleichen Sie die Performance von RedBlackBST, SeparateChainingHashST und LinearProbingHashST für die Datei WarAndPeace.txt (verfügbar auf der Website zum Buch) unter Verwendung dieses Clients und diskutieren Sie die Ergebnisse.
32.
Wörterbuch: Untersuchen Sie die Performance eines Clients wie LookupCSV in einem Szenario, wo Performance eine Rolle spielt. Entwickeln Sie insbesondere ein Modell zum Erzeugen von Abfragen, anstatt Befehle von der Standardeingabe entgegenzunehmen, und führen Sie Performance-Tests für große Eingaben und sehr viele Abfragen durch.
33.
Indizierung: Untersuchen Sie einen Client wie LookupIndex in einem Szenario, wo Performance eine Rolle spielt. Entwickeln Sie insbesondere ein Modell zum Erzeugen von Abfragen, anstatt Befehle von der Standardeingabe entgegenzunehmen, und führen Sie Performance-Tests für große Eingaben und sehr viele Abfragen durch.
34.
Dünn besetzter Vektor: Führen Sie Experimente durch, um die Performance der Matrix-Vektor-Multiplikation unter Verwendung von SparseVector mit der einer Standardimplementierung zu vergleichen, die Arrays verwendet.
35.
Primitive Datentypen: Evaluieren Sie für LinearProbingHashST und RedBlackBST, wie nützlich es ist, primitive Typen statt Integer- und Double-Werten zu verwenden. Wie viel Speicher und Zeit werden bei sehr vielen Suchen in großen Tabellen eingespart?
547
Graphen
4
4.2 Gerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 607 4.3 Minimale Spannbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . 646 4.4 Kürzeste Pfade . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 680
ÜBERBLICK
4.1 Ungerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 553
4
Graphen
»
EINLEITUNG
Paarweise Verbindungen von Elementen spielen in unzähligen Rechenanwendungen eine wichtige Rolle. Die durch diese Verbindungen angedeuteten Beziehungen werfen sofort etliche naheliegende Fragen auf: Gibt es eine Möglichkeit, von einem Element zu einem anderen zu gelangen, indem wir den Verbindungen folgen? Wie viele Elemente sind mit einem gegebenen Element verbunden? Wie lang ist die kürzeste Verbindungskette zwischen diesem und dem anderen Element? Zur Modellierung solcher Situationen bedienen wir uns abstrakter mathematischer Objekte, die als Graphen bezeichnet werden. Wir werden uns in diesem Kapitel ausführlich mit den grundlegenden Eigenschaften von Graphen beschäftigen und uns auf diese Weise das theoretische Fundament schaffen, das wir zum Studium von Algorithmen benötigen, mit denen sich Fragen wie die oben gestellten beantworten lassen. Und diese Algorithmen bilden dann wiederum die Basis, um wichtige Probleme in Angriff nehmen zu können, deren Lösung sich ohne gutes Algorithmendesign nicht einmal in Erwägung ziehen ließe. Die Graphentheorie ist ein wichtiger Zweig der Mathematik, der seit Hunderten von Jahren intensiv erforscht wird. In dieser Zeit wurden viele wichtige und nützliche Grapheneigenschaften entdeckt, viele wichtige Algorithmen entwickelt und es wurde so manches Problem gelöst. Viele schwierige Probleme werden aber noch heute eingehend untersucht. Wir stellen in diesem Kapitel einige fundamentale Graphenalgorithmen vor, die in unterschiedlichen Anwendungen wichtig sind. Wie so viele andere Problemstellungen, mit denen wir uns hier beschäftigen, ist die algorithmische Untersuchung von Graphen ein relativ junger Forschungszweig. Denn auch wenn einige der grundlegenden Algorithmen Jahrhunderte alt sind, wurde das Gros der interessanten Algorithmen erst in den letzten Jahrzehnten entdeckt, was sicherlich darauf zurückzuführen ist, dass Algorithmenforschung und -design, wie wir es hier kennengelernt haben, erheblich an Bedeutung gewonnen haben. Sogar die einfachsten Graphenalgorithmen führen zu nützlichen Computerprogrammen, und die nicht-trivialen Algorithmen, die wir hier untersuchen, gehören zu den elegantesten und interessantesten Algorithmen, die wir kennen. Um zu untermauern, wie vielfältig der Anwendungsbereich der Graphenverarbeitung ist, wollen wir Ihnen zu Beginn unserer Untersuchung der Algorithmen in diesem produktiven Bereich einige Beispiele vorstellen.
Straßenkarten Wer eine Kurzreise plant, sucht vielleicht Antworten auf Fragen wie „Welches ist die kürzeste Strecke von Berlin nach Paris?“ Erfahrene Reisende, die wissen, dass es auf der kürzesten Strecke zu Verkehrsbehinderungen kommen kann, stellen vielleicht eher Fragen wie „Wie komme ich am schnellsten von Berlin nach Paris?“ Um Fragen wie diese zu beantworten, verarbeiten wir Informationen über Verbindungen (Straßen) zwischen Elementen (Kreuzungen).
Webinhalt Wenn wir im Web browsen, besuchen wir auch Seiten, die Verweise (Links) auf andere Seiten enthalten. Durch Anklicken dieser Links können wir von Seite zu Seite springen.
550
Einleitung
Das gesamte Web ist ein Graph, bei dem die Seiten die Elemente sind und die Links die Verbindungen. Graphenverarbeitende Algorithmen sind wesentlicher Bestandteil von Suchmaschinen, die uns helfen, Informationen im Web zu finden.
Schaltungen Elektronische Schaltungen enthalten Bauteile wie Transistoren, Widerstände und Kondensatoren, die kompliziert miteinander verdrahtet sind. Wir setzen Computer ein, um Maschinen zu steuern, die Schaltungen herstellen, und um zu prüfen, ob die Schaltungen wie gewünscht funktionieren. Wir müssen einfache Fragen beantworten wie „Kann es irgendwo zu einem Kurzschluss kommen?“ oder komplizierte wie „Können wir diese Schaltung auf einem Chip unterbringen, ohne dass sich die Drähte kreuzen?“ Die Antwort auf die erste Frage hängt nur von den Eigenschaften der Verbindungen (Drähte) ab, während die Antwort auf die zweite Frage genaue Kenntnisse der Drähte, der mit den Drähten verbundenen Bauteile und der physikalischen Beschränkungen des Chips voraussetzen.
Ablaufpläne Jeder Herstellungsprozess besteht aus einer Kette von Teilprozessen, von denen einige diversen Beschränkungen unterliegen. Zum Beispiel können bestimmte Aufgaben erst in Angriff genommen werden, wenn andere zuvor erfolgreich beendet wurden. Wie könnte ein Prozessablaufplan aussehen, der sowohl die Beschränkungen berücksichtigt als auch versucht, den Gesamtprozess in kürzestmöglicher Zeit zu beenden?
Handel Handelsunternehmen und Finanzdienstleister verfolgen die Kauf- und Verkaufsaufträge, die am Markt platziert werden. Dabei repräsentiert eine Verbindung die Geld- und Warenströme zwischen einem Dienstleister und einem Kunden. Kenntnisse der Verbindungsstruktur können in diesem Fall dazu beitragen, unser Verständnis des Marktes zu verbessern.
Abgleich Studenten bewerben sich um die Aufnahme in Clubs, Universitäten oder Fachhochschulen. Elemente entsprechen den Studenten und den Institutionen, Verbindungen den Bewerbungen. Wir suchen nach Methoden, Bewerber und freie Stellen durch effizienten Abgleich zusammenzubringen.
Rechnernetz Ein Rechnernetz besteht aus miteinander verbundenen Systemen, die Nachrichten verschiedenster Art senden, weiterleiten und empfangen. Je mehr wir über die Art der Verbindungsstruktur wissen, desto intelligenter können wir Kommunikationsleitungen verlegen und Vermittlungsstellen einrichten, die den Datenverkehr möglichst effizient verteilen.
Software Ein Compiler erstellt Graphen zur Repräsentation von Beziehungen zwischen Modulen in einem großen Softwaresystem. Die Elemente werden von den verschiedenen Klassen oder
551
4
Graphen
Modulen gebildet, die das System ausmachen, und die Verbindungen werden entweder mit der Möglichkeit assoziiert, dass eine Methode in einer Klasse eine andere aufrufen kann (statische Analyse) oder mit den tatsächlichen Aufrufen während der Ausführung des Systems (dynamische Analyse). Durch die Analyse des Graphen können wir entscheiden, wie wir dem Programm die Ressourcen am besten und effizientesten zuteilen.
Soziale Netzwerke Wenn Sie ein soziales Netzwerk nutzen, stellen Sie explizite Verbindungen zu Ihren Freunden her. Elemente sind in diesem Fall die Personen; die Verbindungen werden zu Freunden oder Followern (Anhängern) aufgebaut. Die Eigenschaften dieser Netzwerke zu verstehen, ist nicht nur für die Netzwerkbetreiber von größtem Interesse, sondern auch für Politik, Diplomatie, Unterhaltung, Bildung, Marketing und viele andere Domänen. Auch hierfür bieten sich moderne graphenverarbeitende Anwendungen an. Diese Beispiele sollen Ihnen einen Eindruck davon geben, wie breit gefächert der Bereich der Anwendungen ist, in denen Graphen die Abstraktion der Wahl sind, und auf welche Probleme wir bei unserer Arbeit mit Graphen stoßen können. Tausende solcher Probleme sind untersucht worden, von denen viele im Kontext eines von mehreren grundlegenden Graphenmodellen betrachtet werden können – die wichtigsten werden wir in diesem Kapitel behandeln. In praktischen Anwendungen ist die zu verarbeitende Datenmenge häufig so groß, dass effiziente Algorithmen darüber entscheiden, ob eine Lösung gefunden werden kann. Um das Thema möglichst organisiert zu präsentieren, behandeln wir die vier wichtigsten Graphenmodelle nacheinander: ungerichtete Graphen (mit einfachen Verbindungen), gerichtete Graphen (directed graphs), auch Digraphen genannt (wo die Richtung jeder Verbindung von Bedeutung ist), kantengewichtete Graphen (wo jede Verbindung mit einem Gewicht versehen ist) und kantengewichtete Digraphen (wo jede Verbindung sowohl eine Richtung als auch ein Gewicht hat).
Tabelle 4.1
Typische Graphenanwendungen
Anwendung
Element
Verbindung
Straßenkarte
Straßenkreuzung
Straße
Webinhalte
Seite
Link
Schaltung
Bauteil
Draht
Zeitablaufplan
Aufgabe
Beschränkung
Handel
Kunde
Transaktion
Abgleich
Student
Bewerbung
Rechnernetz
Rechner
Verbindung
Software
Methode
Aufruf
Soziales Netzwerk
Person
Freundschaft
» 552
4.1 Ungerichtete Graphen
4.1
Ungerichtete Graphen
Als Einstieg in das Thema dieses Unterkapitels werden wir einige Graphenmodelle analysieren, deren Kanten (edges) nichts weiter sind als Verbindungen zwischen Knoten (vertices). Wir verwenden den Begriff ungerichteter Graph vor allem dann, wenn wir dieses Modell von anderen Modellen abgrenzen wollen (wie im Titel dieses Abschnitts). Da es aber zugleich das einfachste Graphenmodell überhaupt ist, beginnen wir mit der folgenden allgemeinen Definition:
Definition: Graph Ein Graph besteht aus einer Menge von Knoten und einer Menge von Kanten, die jeweils zwei Knoten miteinander verbinden.
Knotennamen spielen für die Definition keine Rolle, sind aber nötig, um auf die Knoten Bezug nehmen zu können. Per Konvention verwenden wir in einem Graphen mit V Knoten die Knotennamen 0 bis V−1. Wir haben uns für diese Systematik entschieden, weil es das Schreiben von Code erleichtert, der mittels indizierter Arrays effizient auf die Informationen in den einzelnen Knoten zugreift. Wo nötig, fällt es nicht schwer, mithilfe einer Symboltabelle eine 1:1-Abbildung einzurichten, die V beliebige Knotennamen mit den V ganzen Zahlen zwischen 0 und V−1 assoziiert (siehe Abschnitt 4.1.7) – d.h., die bequeme Verwendung von Indizes als Knotennamen geht nicht zu Lasten der Allgemeingültigkeit (und nur geringfügig zu Lasten der Effizienz). Mit der Schreibweise v-w (oder alternativ auch w-v) bezeichnen wir eine Kante, die v und w verbindet. In unseren Graphen werden die Knoten durch Kreise dargestellt und die Kanten, die die Knoten verbinden, durch Linien. Eine Zeichnung gibt uns eine ungefähre Vorstellung von der Struktur des Graphen, aber diese Vorstellung kann irreführend sein, da der Graph unabhängig von der Zeichnung definiert ist. Zum Beispiel repräsentieren die beiden grafischen Darstellungen in Abbildung 4.1 denselben Graphen, da der Graph nicht mehr ist als seine (ungeordnete) Menge von Knoten und seine (ungeordnete) Menge von Kanten (Knotenpaaren).
Anomalien Unsere Definition erlaubt zwei einfache Anomalien:
Eine reflexive Kante (self-loop), auch Schlinge genannt, ist Abbildung 4.1: Zwei grafieine Kante, die einen Knoten mit sich selbst verbindet.
sche Darstellungen desselben
Zwei Kanten, die das gleiche Knotenpaar verbinden, sind Graphen parallel.
553
4
Graphen
Mathematiker bezeichnen Graphen mit parallelen Kanten manch- reflexive parallele Kante Kanten mal als Multigraphen und Graphen ohne parallele oder reflexive Kanten als einfache Graphen. In der Regel erlauben unsere Implementierungen auch reflexive und parallele Kanten (da sie in Anwendungen häufig zu finden sind), aber in unseren Beispielen Abbildung 4.2: Anokommen sie nicht vor. Wir können also auf jede Kante Bezug neh- malien men, indem wir einfach die beiden Knoten angeben, die von der Kante verbunden werden.
4.1.1 Glossar Die Graphentheorie ist mit einer recht umfangreichen Nomenklatur verbunden, wobei die meisten Zyklus der Begriffe von der Definition her einfach sind. Um Länge 5 Ihnen das Nachschlagen zu erleichtern, werden wir sie hier möglichst alle vorab beschreiben.
Knoten Kante
Pfad der Länge 4
Wenn eine Kante zwei Knoten verbindet, sprechen Knoten wir davon, dass die beiden Knoten benachbart dritten (adjacent) sind und die Kante mit beiden Knoten Grades inzidiert (incident). Der Grad eines Knotens gibt an, mit wie vielen Kanten der Knoten inzidiert. Ein ZusammenTeilgraph (subgraph) ist eine Teilmenge von Kanhangskomponenten ten (und assoziierten Knoten) eines Graphen. Viele Rechenaufgaben erfordern die Identifikation von Teilgraphen verschiedenster Art. Von besonderem Abbildung 4.3: Darstellung eines Graphen Interesse sind dabei die Kanten, die uns durch eine Folge (sequence) von Knoten in einem Graphen führen.
Definition: Pfad und Zyklus Ein Pfad (path) in einem Graphen besteht aus einer Folge von Knoten, die durch Kanten verbunden sind. In einem einfachen Pfad kommt jeder Knoten nur einmal vor. Im Vergleich dazu ist ein Zyklus (cycle) ein Pfad, der im selben Knoten beginnt und endet. Als einfachen Zyklus bezeichnen wir dabei einen Zyklus, in dem sich keine Kanten oder Knoten wiederholen (bis auf die erforderliche Wiederholung des ersten und letzten Knotens). Die Länge eines Pfades oder eines Zyklus ergibt sich aus der Anzahl der Kanten.
Meistens arbeiten wir mit einfachen Zyklen und einfachen Pfaden und verzichten auf das Attribut „einfach“. Wenn wir die Wiederholung von Knoten zulassen wollen, sprechen wir von allgemeinen Pfaden und Zyklen. Wir sagen, dass ein Knoten mit einem anderen verbunden ist, wenn es einen Pfad gibt, der beide Knoten enthält. Eine Notation wie u-v-w-x repräsentiert einen Pfad von u zu x und u-v-w-x-u stellt einen Zyklus von u zu v zu w zu x und zurück zu u dar. Mehrere der von uns betrachteten
554
4.1 Ungerichtete Graphen
Algorithmen haben die Aufgabe, Pfade und Zyklen zu finden, anhand derer wir die strukturellen Eigenschaften eines Graphen als Ganzes ermitteln können.
Definition: Zusammenhang Ein Graph ist zusammenhängend, wenn es von jedem Knoten aus einen Pfad zu allen anderen Knoten im Graphen gibt. Ein nicht zusammenhängender Graph besteht aus einer Menge von Zusammenhangskomponenten, d.h. mehreren maximal zusammenhängenden Teilgraphen.
Wären die Knoten konkrete Objekte wie Perlen und die Kanten konkrete Verbindungen wie Schnüre, dann bliebe ein zusammenhängender Graph, den wir an einem Knoten hochheben, in einem Stück, während ein nicht zusammenhängender Graph aus zwei oder mehr Teilen bestünde. Folglich müssen wir bei der Verarbeitung eines Graphen dessen Zusammenhangskomponenten eine nach der anderen verarbeiten. Ein azyklischer Graph ist ein kreisfreier Graph, d.h. ein Graph ohne Zyklen. Mehrere unserer Algorithmen haben zur Aufgabe, in einem gegebenen Graphen azyklische Teilgraphen zu finden, die bestimmte Eigenschaften erfüllen. Bevor wir uns jedoch mit diesen Strukturen befassen, wollen wir ein paar weitere Begriffe klären.
Definition: Baum, Wald, Spannbaum und Spannwald Ein Baum ist ein azyklischer zusammenhängender Graph. Eine disjunkte Menge von Bäumen wird Wald genannt. Ein Spannbaum eines zusammenhängenden Graphen ist ein Teilgraph, der alle Knoten des Graphen enthält und ein Baum ist. Ein Spannwald eines (nicht zusammenhängenden) Graphen ist die Vereinigung der Spannbäume seiner Zusammenhangskomponenten.
azyklisch
19 Knoten 17 Kanten
zusammenhängend Abbildung 4.4: Ein Baum
Abbildung 4.5: Ein Spannwald
Diese Definition eines Baums ist eher allgemein gehalten; mit entsprechenden Ergänzungen beschreibt sie die Bäume, mit denen wir normalerweise Programmverhalten (Funk-
555
4
Graphen
tionsaufrufhierarchien) und Datenstrukturen (binäre Suchbäume, 2-3-Bäume usw.) modellieren. Die mathematischen Eigenschaften von Bäumen sind gut untersucht und intuitiv verständlich, sodass wir sie hier auflisten, ohne die Beweise zu liefern. Zum Beispiel ist ein Graph G mit V Knoten genau dann ein Baum, wenn er die folgenden fünf Bedingungen erfüllt:
G hat V−1 Kanten und keine Zyklen. G hat V−1 Kanten und ist zusammenhängend. G ist zusammenhängend, wird aber durch Entfernen einer beliebigen Kante zu einem nicht zusammenhängenden Graphen.
G ist azyklisch, aber durch Hinzufügen einer beliebigen Kante erzeugen Sie einen Zyklus.
Jedes Knotenpaar in G ist genau durch einen einfachen Pfad verbunden. Mehrere der Algorithmen, die wir hier untersuchen, dienen dem Finden von Spannbäumen und -wäldern und die oben genannten Eigenschaften spielen eine wichtige Rolle in deren Analyse und Implementierung. Die Dichte (density) eines Graphen gibt an, wie viele der möglichen Knotenpaare tatsächlich durch Kanten verbunden sind. Ein dünner (sparse) Graph hat relativ wenige der möglichen Kanten, während bei einem dichten (dense) Graphen nur wenige der möglichen Kanten fehlen. Im Allgemeinen bezeichnen wir einen Graphen als dünn, wenn die Anzahl seiner verschiedenen Kanten innerhalb eines kleinen konstanten Faktors von V liegt, und ansonsten als dicht. Diese Faustregel lässt einen Graubereich offen (zum Beispiel wenn die Anzahl der Kanten bei ∼cV3/2 liegt), aber die Unterscheidung zwischen dünn und dicht ist in Anwendungen normalerweise sehr deutlich. Die hier betrachteten Anwendungen betreffen fast immer dünne Graphen. dünn (E = 200)
dicht (E = 1000)
Abbildung 4.6: Zwei Graphen (V = 50)
Ein bipartiter Graph ist ein Graph, dessen Knoten wir in zwei Mengen teilen können, wobei jede Kante jeweils einen Knoten der einen Menge mit einem Knoten der anderen Menge verbindet. Abbildung 4.7 zeigt einen solchen bipartiten Graphen, bei dem die eine Menge Knoten rot und die andere Menge schwarz dargestellt ist. Bipartite Graphen drücken BeziehunAbbildung 4.7: Ein bipartiter Graph
556
4.1 Ungerichtete Graphen
gen aus, wie sie sich auf natürliche Weise in vielen Situationen ergeben. Eine davon werden wir am Ende dieses Abschnitts näher betrachten. Nach Klärung dieser wichtigen Begriffe haben wir das Rüstzeug, um uns den graphenverarbeitenden Algorithmen zuzuwenden. Zuerst betrachten wir eine API und Implementierung für einen Datentyp Graph, um dann klassische Algorithmen zum Durchsuchen von Graphen und Identifizieren von Zusammenhangskomponenten vorzustellen. Den Schluss dieses Abschnitts bilden reale Anwendungen, bei denen Knotennamen nicht unbedingt ganze Zahlen sind und Graphen eine riesige Anzahl von Knoten und Kanten aufweisen können.
4.1.2 Datentyp für ungerichtete Graphen Unsere Ausgangsbasis für die Entwicklung von graphenverarbeitenden Algorithmen ist eine API, die die grundlegenden Operationen für Graphen definiert. Diese Vorgehensweise erlaubt uns, Aufgaben der Graphenverarbeitung zu meistern, die von grundlegenden einfachen Operationen bis zu anspruchsvollen Lösungen schwieriger Probleme reichen.
Tabelle 4.2
API für einen ungerichteten Graphen
public class Graph Graph(int V)
Erzeugt einen Graphen mit V Knoten, aber ohne Kanten.
Graph(In in)
Liest einen Graphen aus dem Eingabestrom in.
int
V()
Anzahl der Knoten
int
E()
Anzahl der Kanten
addEdge(int v, int w)
Fügt die Kante v-w dem Graphen hinzu.
Iterable
adj(int v)
Nachbarknoten von v
String
toString()
String-Repräsentation
void
Diese API enthält zwei Konstruktoren, zwei Methoden, die die Anzahl der Kanten bzw. Knoten zurückliefern, eine Methode, um eine Kante hinzuzufügen, eine Methode toString() und eine Methode adj(), mit der Client-Code über die Knoten iterieren kann, die direkt mit einem gegebenen Knoten verbunden sind (die Reihenfolge der Iteration wird nicht angegeben). Erstaunlicherweise können wir alle Algorithmen, die wir in diesem Abschnitt betrachten, von der grundlegenden Abstraktion ableiten, die sich hinter adj() verbirgt. Der zweite Konstruktor geht von einem Eingabeformat aus, das aus 2E + 2 ganzzahligen Werten besteht: V, dann E und dann E Wertepaare zwischen 0 und V−1, die eine Kante ausdrücken. Als Beispiele verwenden wir die beiden Graphen tinyG.txt und mediumG.txt aus Abbildung 4.8.
557
4
Graphen
In Tabelle 4.3 finden Sie mehrere Beispiele für Graph-Clients. mediumG.txt
tinyG.txt
V
V
13 E 13 0 5 4 3 0 1 9 12 6 4 5 4 0 2 11 12 9 10 0 6 7 8 9 11 5 3
250 E 1273 244 246 239 240 238 245 235 238 233 240 232 248 231 248 229 249 228 241 226 231 ... (1263 zusätzliche Zeilen)
Abbildung 4.8: Eingabeformat für den Graph-Konstruktor (zwei Beispiele)
Tabelle 4.3
Typische Codebeispiele zur Graphenverarbeitung
Aufgabe
Implementierung
Berechnet den Grad von v
public static int degree(Graph G, int v) { int degree = 0; for (int w : G.adj(v)) degree++; return degree; }
Berechnet den höchsten Grad
public static int maxDegree(Graph G) { int max = 0; for (int v = 0; v < G.V(); v++) if (degree(G, v) > max) max = degree(G, v); return max; }
558
Berechnet den durchschnittlichen Grad
public static double avgDegree(Graph G) { return 2 * G.E() / G.V(); }
Zählt reflexive Kanten
public static int numberOfSelfLoops(Graph G) { int count = 0; for (int v = 0; v < G.V(); v++) for (int w : G.adj(v)) if (v == w) count++; return count/2; // jede Kante zwei Mal gezählt }
4.1 Ungerichtete Graphen
Tabelle 4.3
Typische Codebeispiele zur Graphenverarbeitung (Forts.)
Aufgabe
Implementierung
String-Repräsentation der Adjazenzlisten des Graphen (Instanzmethode in Graph)
public String toString() { String s = V + " vertices, " + E + " edges\n"; for (int v = 0; v < V; v++) { s += v + ": "; for (int w : this.adj(v)) s += w + " "; s += "\n"; } return s; }
Repräsentationsalternativen Als Nächstes müssen wir entscheiden, mit welcher Graphenrepräsentation (Datenstruktur) wir diese API implementieren wollen. Wir haben zwei grundsätzliche Anforderungen:
Es muss genügend Speicherplatz vorhanden sein, um die Arten von Graphen aufzunehmen, die uns in Anwendungen üblicherweise begegnen.
Die entwickelten Graph-Instanzmethoden sollen möglichst wenig Laufzeit schlucken – schließlich sind dies die grundlegenden Methoden, die wir zur Entwicklung graphenverarbeitender Clients benötigen. Diese Anforderungen sind etwas vage formuliert, aber dennoch hilfreich bei der Wahl zwischen den drei Datenstrukturen, die einem zur Repräsentation von Graphen direkt einfallen:
Eine Adjazenzmatrix, für die wir ein V×V-Array boolescher Werte einrichten, in dem das Element in Zeile v und Spalte w als true definiert wird, wenn es eine Kante gibt, die das Knotenpaar v-w im Graphen verbindet, andernfalls ist das Element false. Diese Repräsentation scheidet im Grunde direkt aus – Graphen mit Millionen von Knoten sind keine Seltenheit und die Speicherplatzkosten für die benötigten V2 booleschen Werte sind faktisch schon ein Ausschlusskriterium.
Ein Array von Kanten, unter Verwendung einer Klasse Edge mit zwei Instanzvariablen vom Typ int. Diese direkte Repräsentation ist einfach, scheidet aber bei genauerem Hinsehen ebenfalls aus – die Implementierung von adj() würde erfordern, alle Kanten im Graphen zu untersuchen.
Ein Array von Adjazenzlisten, in dem wir ein knotenindiziertes Array von Listen der Nachbarknoten zu jedem Knoten verwalten. Diese Datenstruktur genügt beiden Anforderungen für typische Anwendungen und ist die Wahl, die unseren Beispielen in diesem Kapitel zugrunde liegt.
559
4
Graphen
Eine ausführliche Untersuchung der Anforderungen an die Datenstruktur würde übrigens ergeben, dass abhängig von dem jeweiligen Einsatzgebiet neben den genannten Performance-Zielen auch noch weitere Überlegungen wichtig sein können. Zum Beispiel schließt die Verwendung von parallelen Kanten eine Adjazenzmatrix aus, da es in einer Adjazenzmatrix keine Möglichkeit gibt, diese zu repräsentieren.
Adjazenzlisten-Datenstruktur Die Standardrepräsentation für Graphen, 6 2 1 5 die nicht dicht sind, ist die sogenannte Adjazenzlisten-Datenstruktur, bei der wir 0 Bag-Objekte alle Nachbarknoten eines Knotens in einer mit ihm assoziierten verketteten Liste spei0 chern. Die Listen verwalten wir dann in adj[] einem Array, um für jeden gegebenen Kno- 0 5 4 ten direkt auf dessen Liste zugreifen zu kön- 1 nen. Implementierungsgrundlage für die 2 5 6 3 Listen ist unser abstrakter Datentyp Bag 3 aus Abschnitt 1.3 mit einer Implementie- 4 3 4 0 rung mit verketteter Liste, sodass wir in 5 0 4 konstanter Zeit neue Kanten hinzufügen 6 und in konstanter Zeit pro Nachbarknoten 7 8 über Nachbarknoten iterieren können. Die 8 Repräsentationen 9 Graph-Implementierung in Listing 4.1 baderselben Kante 7 siert auf diesem Ansatz und Abbildung 10 4.9 zeigt, welche Datenstrukturen von die- 11 11 10 12 sem Code für tinyG.txt erzeugt werden. 12 Um eine Kante hinzuzufügen, die v und w 9 verbindet, fügen wir w der Adjazenzliste von v und v der Adjazenzliste von w hinzu. 9 12 Das bedeutet, dass jede Kante zweimal in der Datenstruktur auftaucht. Unsere Graph11 9 Implementierung zeichnet sich durch die Abbildung 4.9: Adjazenzlisten-Repräsentation (ungefolgende Performance aus: richteter Graph)
Der Speicherbedarf ist proportional V + E. Das Hinzufügen einer Kante erfolgt in konstanter Zeit. Das Iterieren über die Nachbarknoten von v benötigt Zeit proportional dem Grad von v (konstante Zeit pro verarbeiteten Nachbarknoten).
560
4.1 Ungerichtete Graphen
Diese Performancedaten sind optimal für die Operationen, die uns im Rahmen der hier betrachteten graphenverarbeitenden Anwendungen interessieren. Parallele und reflexive Kanten sind erlaubt (auf ihr Vorhandensein wird nicht geprüft). Hinweis: Denken Sie immer daran, dass durch die Reihenfolge, in der Kanten zum Graphen hinzugefügt werden, festgelegt wird, in welcher Reihenfolge die Knoten in dem von Graph erstellten Adjazenzlisten-Array aufgeführt werden. Derselbe Graph kann durch viele verschiedene Arrays von Adjazenzlisten repräsentiert werden. Wenn wir den Konstruktor verwenden, der Kanten aus einem Eingabestrom einliest, bestimmen das Eingabeformat und die Reihenfolge, in der die Kanten in der Datei aufgeführt werden, in welcher Reihenfolge die Knoten in dem von Graph erstellten Adjazenzlisten-Array erscheinen. Da unsere Algorithmen adj() verwenden und alle Nachbarknoten unabhängig von ihrer Position in den Listen verarbeiten, haben diese Unterschiede keinen Einfluss auf die Korrektheit der Algorithmen; dennoch ist es wichtig, sich dieser Tatsache beim Debuggen oder Nachverfolgen des Codes bewusst zu sein. Um diese Aktivitäten zu erleichtern, gehen wir davon aus, dass es zu Graph einen Testclient gibt, der einen Graphen aus dem als Befehlszeilenargument übergebenen Eingabestrom einliest und ihn anschließend ausgibt (unter Verwendung der toString()-Implementierung aus Tabelle 4.3), um die Reihenfolge zu veranschaulichen, in der die Knoten in den Adjazenzlisten erscheinen d.h., in der sie von den Algorithmen verarbeitet werden (siehe Übung 4.1.7).
tinyG.txt
V
13 E 13 0 5 4 3 0 1 9 12 6 4 5 4 0 2 11 12 9 10 0 6 7 8 9 11 5 3
erster Nachbarknoten in der Eingabe ist der letzte in der Liste
die zweite Repräsentation jeder Kante ist rot dargestellt
Abbildung 4.10: Ausgabe für die Kantenliste-Eingabe
561
4
Graphen
Listing 4.1: Datentyp Graph public class Graph { private final int V; private int E; private Bag[] adj;
// Anzahl der Knoten // Anzahl der Kanten // Adjazenzlisten
public Graph(int V) { this.V = V; this.E = 0; adj = (Bag[]) new Bag[V]; for (int v = 0; v < V; v++) adj[v] = new Bag(); } public Graph(In in) { this(in.readInt()); int E = in.readInt(); for (int i = 0; i < E; i++) { // Fügt eine Kante hinzu. int v = in.readInt(); int w = in.readInt(); addEdge(v, w); } } public int V() { return V; } public int E() { return E; }
// Erzeugt ein Array von Listen. // Initialisiert alle Listen als // leer.
// Liest V ein und erstellt diesen Graphen. // Liest E ein.
// Liest einen Knoten ein, // liest einen weiteren Knoten ein // und fügt die sie verbindende Kante hinzu.
public void addEdge(int v, int w) { adj[v].add(w); adj[w].add(v); E++; }
// Fügt w zur Liste von v hinzu. // Fügt v zur Liste von w hinzu.
public Iterable adj(int v) { return adj[v]; } }
Diese Graph-Implementierung verwaltet ein knotenindiziertes Array von Listen ganzer Zahlen. Jede Kante erscheint zweimal: Wenn eine Kante v und w verbindet, dann erscheint w in der Liste von v und v in der Liste von w. Der zweite Konstruktor liest einen Graphen aus einem Eingabestrom, in dem Format V gefolgt von E gefolgt von einer Liste von Paaren von int-Werten zwischen 0 und V−1. Den Code von toString() finden Sie in Tabelle 4.3.
562
4.1 Ungerichtete Graphen
Es ist sicherlich sinnvoll, noch andere Operationen näher zu betrachten, die in Anwendungen nützlich sein könnten, z.B. Methoden
zum Hinzufügen eines Knotens zum Löschen eines Knotens Diese Operationen lassen sich beispielsweise realisieren, indem wir die API erweitern und eine Symboltabelle (ST) anstelle eines knotenindizierten Arrays verwenden (nach dieser Änderung können wir auf die Konvention verzichten, Integer-Indizes als Knotennamen zu verwenden). Wir könnten uns auch für Methoden interessieren, die
eine Kante löschen oder prüfen, ob der Graph die Kante v-w enthält. Um diese Methoden zu implementieren (und parallele Kanten auszuschließen), könnten wir für die Adjazenzlisten SET anstelle von Bag verwenden. Wir bezeichnen diese Alternative als Adjazenzmenge-Repräsentation. Aus mehreren Gründen haben wir von diesen Alternativen Abstand genommen:
Unsere Clients müssen weder Knoten hinzufügen noch Knoten und Kanten löschen oder prüfen, ob eine Kante existiert.
Wenn Clients diese Operationen benötigen, werden sie normalerweise nur selten oder für kurze Adjazenzlisten aufgerufen, sodass Sie in diesem Fall mit einer BruteForce-Implementierung, die über eine Adjazenzliste iteriert, genauso gut fahren.
Die SET- und ST-Repräsentationen komplizieren den Implementierungscode der Algorithmen etwas und lenken unnötig von den Algorithmen ab.
In einigen Fällen kann es zu Leistungseinbußen der Größe log V kommen. Es ist nicht schwer, unsere Algorithmen ohne übermäßige Leistungseinbußen um andere Entwurfsmuster zu erweitern (z.B. um parallele oder reflexive Kanten zu verhindern). Tabelle 4.4 fasst die Performancedaten der hier erwähnten Alternativen noch einmal übersichtlich zusammen. Typische Anwendungen verarbeiten riesige dünne Graphen, weshalb wir hier nur die Adjazenzlisten-Repräsentation verwenden.
Tabelle 4.4
Wachstumsordnungen für typische Graph-Implementierungen
Zugrunde liegende Datenstruktur
Speicher
Kante v-w hinzufügen
Prüfen, ob w Nachbar von v ist
Über die Nachbarknoten von v iterieren
Liste von Kanten
E
1
E
E
2
Adjazenzmatrix
V
1
1
V
Adjazenzlisten
E+V
1
grad (v)
grad (v)
Adjazenzmengen
E+V
log V
log V
log V + grad (v)
563
4
Graphen
Entwurfsmuster für die Graphenverarbeitung Da wir eine große Anzahl an graphenverarbeitenden Algorithmen betrachten, besteht unser Entwurfsziel zunächst darin, unsere Implementierungen von der Graphenrepräsentation zu trennen. Dazu entwickeln wir für jede Aufgabe eine spezifische Klasse, von der Clients Objekte erzeugen können, um die Aufgabe auszuführen. Im Allgemeinen leistet der Konstruktor ein wenig Vorarbeit, um Datenstrukturen zu erstellen, mit denen effizient auf Client-Abfragen reagiert werden kann. Ein typisches Clientprogramm erstellt einen Graphen, übergibt diesen Graphen an eine Algorithmusimplementierungsklasse (als Argument an einen Konstruktor) und ruft dann Client-Methoden auf, um verschiedene Eigenschaften des Graphen abzufragen. Als Einstieg wollen wir folgende API betrachten:
Tabelle 4.5
API zur Graphenverarbeitung (Aufwärmübung)
public class Search
boolean int
Search(Graph G, int s)
Findet Knoten, die mit einem Startknoten s verbunden sind.
marked(int v)
Ist v mit s verbunden?
count()
Wie viele Knoten sind mit s verbunden?
Wir verwenden den Begriff Startknoten (source), um den Knoten, der als Argument dem Konstruktor übergeben wird, von den anderen Knoten im Graphen zu unterscheiden. Die Aufgabe des Konstruktors in dieser API besteht darin, die Knoten im Graphen zu finden, die mit dem Startknoten verbunden sind. Über die Instanzmethoden marked() und count() fragt der Client-Code die Eigenschaften des Graphen ab. Der Name der Instanzmethode marked() (markiert) bezieht sich auf einen Ansatz, wie er von allen grundlegenden Algorithmen in diesem Kapitel verwendet wird: Sie verfolgen Pfade von dem Startknoten zu anderen Knoten im Graphen und markieren jeden angesteuerten Knoten. Der Beispielclient TestSearch aus Listing 4.2 übernimmt den Namen eines Eingabestroms und die Nummer eines Startknotens von der Befehlszeile, liest einen Graphen aus dem Eingabestrom (mithilfe des zweiten Graph-Konstruktors), erzeugt ein SearchObjekt für den gegebenen Graphen und Startknoten und gibt mit marked() die Knoten im Graphen aus, die mit dem Startknoten verbunden sind. Außerdem ruft der Client count() auf und gibt an, ob der Graph zusammenhängend ist (der Graph ist nur dann zusammenhängend, wenn bei der Suche alle seine Knoten markiert wurden). Wir haben bereits eine Möglichkeit kennengelernt, die Search-API zu implementieren: die Union-Find-Algorithmen von Kapitel 1. Der Konstruktor kann ein UF-Objekt erzeugen, eine union()-Operation auf jeder Kante des Graphen ausführen und marked(v) durch Aufruf von connected(s, v) implementieren. Die Implementierung von count() setzt eine gewichtete UF-Implementierung voraus, deren API um eine count()-Methode erweitert wurde, die wt[find(v)] zurückliefert (siehe Übung 4.1.8). Diese Implementierung ist einfach und effizient, aber die nachfolgende Implementierung ist noch einfa-
564
4.1 Ungerichtete Graphen
cher und effizienter, denn sie basiert auf der Tiefensuche (depth-first search), einer grundlegenden rekursiven Methode, die die Kanten des Graphen entlangwandert und dabei die Knoten findet, die mit dem Startknoten verbunden sind. Die Tiefensuche ist die Grundlage für etliche der graphenverarbeitenden Algorithmen, die wir in diesem Kapitel vorstellen. public class TestSearch { public static void main(String[] args) { Graph G = new Graph(new In(args[0])); int s = Integer.parseInt(args[1]); Search search = new Search(G, s); for (int v = 0; v < G.V(); v++) if (search.marked(v)) StdOut.print(v + " "); StdOut.println(); if (search.count() != G.V()) StdOut.print("NOT "); StdOut.println("connected"); } } Listing 4.2: Beispiel für einen graphenverarbeitenden Client (Aufwärmübung)
% java TestSearch tinyG.txt 0 0 1 2 3 4 5 6 NOT connected % java TestSearch tinyG.txt 9 9 10 11 12 NOT connected tinyG.txt
V
13 E 13 0 5 4 3 0 1 9 12 6 4 5 4 0 2 11 12 9 10 0 6 7 8 9 11 5 3
Abbildung 4.11: Eingabeformat für den Graph-Konstruktor (kleines Beispiel)
565
4
Graphen
4.1.3 Tiefensuche Aussagen über die Eigenschaften eines Graphen sind oft erst möglich, wenn wir systematisch jeden seiner Knoten und jede seiner Kanten untersucht haben. Einige einfache Grapheneigenschaften – zum Beispiel die Anzahl der Nachbarknoten eines jeden Knotens – lassen sich leicht ermitteln, da man für sie nur die Kanten (in beliebiger Reihenfolge) untersuchen muss. Viele andere Grapheneigenschaften basieren jedoch auf Pfaden; um diese kennenzulernen, wandern wir entlang der Kanten von Knoten zu Knoten. Fast alle hier vorgestellten graphenverarbeitenden Algorithmen verwenden dieses grundlegende abstrakte Modell, wenn auch mit verschiedenen Strategien. Die einfachste Strategie ist ein klassisches Verfahren, das wir im Folgenden beschreiben.
Suche in einem Labyrinth Zum leichteren Verständnis können Sie sich die Suche in einem Graphen anhand eines äquivalenten Problems veranschaulichen, das schon seit ewigen Zeiten bekannt ist: Wie finde ich den Weg aus einem Labyrinth, das aus Gängen verbunden durch Kreuzungen besteht? Bei einigen Labyrinthen reicht eine einfache Regel, die meisten jedoch erfordern eine etwas anspruchsvollere Strategie. Wenn wir statt Graph, Kante und Knoten die Begriffe Labyrinth, Weg und Kreuzung verwenden, macht dies zwar nur semantisch einen Unterschied, aber die Begriffe tragen dazu bei, dass wir das Problem intuitiv besser verstehen. Eine Möglichkeit, Labyrinthe zu erkunden, ohne verloren zu gehen, basiert auf einen Trick, der als Trémaux’ Methode bezeichnet wird und seit der Antike (zumindest seit der Sage von Theseus und dem Minotaurus) bekannt ist. Um alle Wege eines Labyrinths zu erkunden,
gehen Sie einen beliebigen unmarkierten Weg und rollen einen Faden hinter sich ab,
markieren Sie alle Kreuzungen und Wege, wenn Sie sie das erste Mal betreten,
gehen Sie (mithilfe des Fadens) zurück, wenn Sie an eine markierte Kreuzung kommen,
gehen Sie noch weiter zurück, wenn Sie beim Zurückgehen an eine Kreuzung kommen, deren Wege Sie bereits alle besucht haben. Graph
Labyrinth
Abbildung 4.12: Trémaux’ Methode
Kreuzung Weg
Knoten
Kante
Abbildung 4.13: Ähnlichkeiten zwischen dem Modell eines Labyrinths und dem Modell eines Graphen
566
4.1 Ungerichtete Graphen
Der Faden stellt sicher, dass Sie immer aus dem Labyrinth herausfinden, und die Markierungen garantieren, dass Sie keinen Weg oder keine Kreuzung zweimal besuchen. Doch um sicher zu sein, dass Sie das ganze Labyrinth abgeschritten haben, bedarf es einer komplizierteren Beweisführung, die sich besser im Kontext einer Graphensuche erläutern lässt. Die Trémaux’ Methode ist ein intuitiver Ausgangspunkt, aber sie unterscheidet sich ein wenig von der Erkundung eines Graphen, sodass wir uns jetzt der Suche in Graphen zuwenden wollen.
Aufwärmübung Das klassische rekursive Suchverfahren in einem zusammenhängenden Graphen (d.h., alle Knoten und Kanten zu besuchen) entspricht der Methode von Trémaux zur Erkundung eines Labyrinths, ist aber einfacher zu beschreiben. Um einen Graphen zu durchsuchen, rufen Sie eine rekursive Methode auf, die Knoten besucht. Beim Besuch eines Knoten
markieren Sie ihn als besucht und besuchen (rekursiv) alle benachbarten Knoten, die noch nicht markiert sind. Dieses Verfahren wird Tiefensuche (depth-first search, DFS) genannt. Eine Implementierung unserer Search-API, die sich dieser Methode bedient, finden Sie in Listing 4.3. Sie verwaltet ein Array von booleschen Werten, um alle Knoten zu markieren, die mit dem Startknoten verbunden sind. Das rekursive Verfahren markiert den gegebenen Knoten und ruft sich für jeden unmarkierten Knoten seiner Adjazenzliste auf. Wenn der Graph zusammenhängend ist, sollte am Ende jeder Eintrag in der Adjazenzliste markiert sein. public class DepthFirstSearch { private boolean[] marked; private int count; public DepthFirstSearch(Graph G, int s) { marked = new boolean[G.V()]; dfs(G, s); } private void dfs(Graph G, int v) { marked[v] = true; count++; for (int w : G.adj(v)) if (!marked[w]) dfs(G, w); } public boolean marked(int w) { return marked[w]; } public int count() { return count; } } Listing 4.3: Tiefensuche
567
4
Graphen
Satz A: Die Tiefensuche markiert alle Knoten, die mit einem gegebenen Startknoten verbunden sind, in einer Zeit proportional zur Summe ihrer Grade. Beweis: Zuerst beweisen wir, dass der Algorithmus alle Knoten markiert, die mit dem Startknoten s verbunden sind (und keine anderen). Jeder markierte Knoten ist mit s verbunden, da der Algo-
rithmus nur Knoten findet, indem er den Kanten folgt. Nehmen wir dann an, dass irgendein unmarkierter Knoten w mit s verbunden ist. Da s selbst markiert ist, muss jeder Pfad von s zu w mindestens eine Kante haben, die die Menge der markierten Knoten mit der Menge der unmarkierten Knoten verbindet (sagen wir v-x). Allerdings hätte der Algorithmus beim Markieren von v festgestellt, dass es eine Kante zu x gibt, sodass eine solche Kante nicht existieren kann, was einen Widerspruch darstellt. Die Zeitschranke ergibt sich daraus, dass das Markieren sicherstellt, dass jeder Knoten einmal besucht wird (mit einer Zeit proportional seinem Grad, um Markierungen zu prüfen).
Menge der markierten Knoten
Startknoten s
v
Menge der unmarkierten Knoten
eine solche Kante kann es nicht geben
x
w
Einbahnstraßen Im Programm entspricht der Mechanismus aus Methodenaufruf und -rückkehr dem Faden im Labyrinth: Wenn wir alle Kanten verarbeitet haben, die in einem Knoten enden (oder alle Wege erkundet haben, die von einer Kreuzung ausgehen), „kehren wir zurück“ (wie auch return aus einer Methode zurückkehrt). Für eine korrekte Übereinstimmung mit der Erkundung eines Labyrinths nach Trémaux müssen wir uns ein Labyrinth vollständig aus Einbahnstraßen vorstellen (eine in jede Richtung). Auf gleiche Weise, wie wir jeden Weg im Labyrinth zweimal betreten (einmal in jede Richtung), begegnet uns jede Kante im Graphen zweimal (einmal bei jedem ihrer indizierten Knoten). Bei der Trémaux’ Methode erkunden wir einen Weg entweder zum ersten Mal oder kehren über den Weg von einem markierten Knoten zurück. Bei der Tiefensuche in einem ungerichteten Graphen führen wir entweder einen rekursiven Aufruf durch, wenn wir auf eine Kante v-w treffen (sofern w nicht markiert ist), oder überspringen die Kante (wenn w markiert ist). Treffen wir das zweite Mal auf die Kante, und zwar in die
568
4.1 Ungerichtete Graphen
umgekehrte Richtung w-v, dann wird die Kante immer ignoriert, da der Zielknoten v bestimmt bereits besucht wurde (als wir der Kante das erste Mal begegneten). Standardgrafik
tinyCG.txt
V
6 8 0 2 2 1 0 3 3 0
E 5 4 3 2 1 4 5 2
Grafik mit beiden Kanten
Adjazenzlisten
adj[] 0 1 2 3 4 5
2
1
5
0
2
0
1
3
5
4
2
3
2
3
0
4
Abbildung 4.14: Ein zusammenhängender ungerichteter Graph
Ablauf der Tiefensuche Wie üblich werden Sie einen Algorithmus besser verstehen, wenn Sie sich sein Verhalten an einem kleinen Beispiel veranschaulichen. Dies trifft in besonderem Maße für die Tiefensuche zu. Das Erste, was Sie beim Verfolgen der Schritte beachten sollten, ist, dass die Reihenfolge, in der die Kanten untersucht und Knoten besucht werden, von der Repräsentation abhängt und nicht nur von dem Graphen oder dem Algorithmus. Da die Tiefensuche nur Knoten untersucht, die mit dem Startknoten verbunden sind, veranschaulichen wir den Ablauf anhand des kleinen zusammenhängenden Graphen aus Abbildung 4.14. In diesem Beispiel ist Knoten 2 der erste Knoten, der von 0 aus besucht wird, da er zufällig der erste Eintrag in der Adjazenzliste von 0 ist. Das Zweite, was Sie beachten sollten, ist, dass die Tiefensuche, wie oben erwähnt, jede Kante im Graphen zweimal traversiert und beim zweiten Mal immer einen markierten Knoten vorfindet. Eine Erkenntnis aus dieser Beobachtung ist, dass das Verfolgen einer Tiefensuche doppelt so lang dauert, wie man vielleicht annehmen würde! Unser Beispielgraph hat nur acht Kanten, aber wir müssen die Aktion des Algorithmus für die 16 Einträge in den Adjazenzlisten verfolgen.
569
4
Graphen
Ausführliches Ablaufprotokoll der Tiefensuche Abbildung 4.15 zeigt den Inhalt der Datenstrukturen für unser kleines Beispiel mit Startknoten 0, direkt nachdem die einzelnen Knoten markiert wurden. Die Suche beginnt, wenn der Konstruktor die rekursive Methode dfs() aufruft, um den Knoten 0 zu markieren und zu besuchen, und läuft dann folgendermaßen ab:
Da 2 der erste Eintrag in der Adjazenzliste von 0 ist und noch nicht markiert wurde, ruft dfs() sich rekursiv selbst auf, um 2 zu markieren und zu besuchen (genau genommen legt das System 0 und die aktuelle Position in der Adjazenzliste von 0 auf einem Stapel ab).
Damit bewegen wir uns zur Adjazenzliste von 2, in der 0 der erste Eintrag ist. Da dieser bereits markiert ist, überspringt dfs() diesen Eintrag und wendet sich dem nächsten Eintrag zu, in diesem Falle 1. Dieser Eintrag ist noch nicht markiert, sodass dfs() sich rekursiv aufruft, um 1 zu markieren und zu besuchen.
Anders verläuft der Besuch bei 1: Da beide Knoten auf seiner Liste (0 und 2) bereits markiert sind, werden keine rekursiven Aufrufe benötigt und dfs() kehrt von dem rekursiven Aufruf dfs(1) zurück. Die nächste untersuchte Kante verläuft von 2 nach 3 (da in der Adjazenzliste von 2 auf den Knoten 1 der Knoten 3 folgt), d.h., dfs() ruft sich rekursiv selbst auf, um 3 zu markieren und zu besuchen.
In der Adjazenzliste von 3 lautet der erste Knoten 5 und, da dieser unmarkiert ist, ruft sich dfs() rekursiv selbst auf und markiert und besucht 5.
Beide Knoten in der Liste von 5 (3 und 0) sind bereits markiert, sodass keine rekursiven Aufrufe erforderlich sind.
Damit kehren wir wieder zur Adjazenzliste von 3 zurück, in der der nächste Knoten 4 lautet. Dieser ist nicht markiert, sodass dfs() sich rekursiv selbst aufruft, um mit
Knoten 4 den letzten Knoten zu markieren und zu besuchen, der noch nicht markiert war.
Nachdem 4 markiert ist, muss dfs() die Knoten in der Liste von 4 prüfen, dann die verbleibenden Knoten in der Liste von 3, dann in der Liste von 2 und dann in der Liste von 0 – es fallen aber keine rekursiven Aufrufe mehr an, da alle Knoten markiert sind. Dieser grundlegende rekursive Ablauf ist nur ein Einstieg in die Materie, denn die Tiefensuche lässt sich für viele Aufgaben der Graphenverarbeitung einsetzen. So werden wir in diesem Abschnitt zeigen, wie sich mit der Tiefensuche ein Problem lösen lässt, das wir bereits in Kapitel 1 angesprochen haben: Zusammenhang: Wir möchten für einen gegebenen Graphen Fragen beantworten wie Sind zwei gegebene Knoten verbunden? oder Aus wie vielen Zusammenhangskomponenten besteht der Graph?
570
4.1 Ungerichtete Graphen
dfs(0)
dfs(2) prüfe 0
dfs(1) prüfe 0 prüfe 2 1 fertig
dfs(3)
dfs(5) prüfe 3 prüfe 0 5 fertig
dfs(4) prüfe 3 prüfe 2 4 fertig prüfe 2 3 fertig prüfe 4 2 fertig prüfe 1 prüfe 5 0 fertig
Abbildung 4.15: Ablaufprotokoll der Tiefensuche, die die mit 0 verbundenen Knoten findet
Mithilfe unseres Standardentwurfsmusters zur Graphenverarbeitung lässt sich für dieses Problem leicht eine Lösung finden, die wir anschließend mit den Union-FindAlgorithmen aus Abschnitt 1.5 vergleichen.
571
4
Graphen
Die Frage „Sind zwei gegebene Knoten verbunden?“ könnte auch lauten „Gibt es einen Pfad, der zwei gegebene Knoten verbindet?“, weswegen man auch von einem PfadsucheProblem spricht. Mit den Union-Find-Datenstrukturen, die wir in Abschnitt 1.5 kennengelernt haben, lassen sich die Probleme, einen solchen Pfad zu finden, nicht lösen. Die Tiefensuche ist der erste von mehreren hier betrachteten Ansätzen, um auch folgendes Problem zu lösen: Pfade mit einem Startknoten (single-source paths): Wir möchten für einen gegebenen Graphen und einen Startknoten s Fragen beantworten wie Gibt es einen Pfad von s zu einem gegebenen Zielknoten v? Wenn ja, finde einen solchen Pfad.
Die Tiefensuche ist bestechend einfach, da sie auf einem bekannten Konzept basiert und sich sehr leicht implementieren lässt; gleichzeitig verbirgt sich dahinter ein eleganter und mächtiger Algorithmus, mit dem Wissenschaftler inzwischen gern auch schwierige Probleme lösen. Die beiden genannten Beispiele sind die ersten von mehreren, die noch folgen werden.
4.1.4 Pfadsuche Das Problem der Pfade mit nur einem Startknoten gehört zu den wichtigsten der Graphenverarbeitung. In Übereinstimmung mit unserem Standardentwurfsmuster verwenden wir die folgende API:
Tabelle 4.6
API für Implementierungen zur Pfadsuche
public class Paths
boolean Iterable
Paths(Graph G, int s)
Findet Pfade in G ausgehend vom Startknoten s.
hasPathTo(int v)
Gibt es einen Pfad von s zu v?
pathTo(int v)
Pfad von s zu v; null, wenn es keinen Pfad gibt.
Der Konstruktor übernimmt einen Startknoten s als Argument und berechnet die Pfade von s zu jedem damit verbundenen Knoten. Nach der Erzeugung eines Paths-Objekts für einen Startknoten s kann der Client mit der Instanzmethode pathTo() über die Knoten eines Pfades von s zu jedem mit s verbundenen Knoten iterieren. Im Moment akzeptieren wir jeden Pfad; später werden wir Implementierungen entwickeln, die Pfade mit bestimmten Eigenschaften finden. Der Testclient aus Listing 4.4 übernimmt einen Graphen aus dem Eingabestrom und einen Startknoten von der Befehlszeile und gibt einen Pfad von dem Startknoten zu jedem damit verbundenen Knoten aus.
572
4.1 Ungerichtete Graphen
public static void main(String[] args) { Graph G = new Graph(new In(args[0])); int s = Integer.parseInt(args[1]); Paths search = new Paths(G, s); for (int v = 0; v < G.V(); v++) { StdOut.print(s + " to " + v + ": "); if (search.hasPathTo(v)) for (int x : search.pathTo(v)) if (x == s) StdOut.print(x); else StdOut.print("-" + x); StdOut.println(); } } Listing 4.4: Testclient für Implementierungen zur Pfadsuche
Implementierung Algorithmus 4.1 aus Listing 4.5 ist eine DFS-basierte Implementierung von Paths, die unsere Aufwärmübung DepthFirstSearch aus Listing 4.3 um eine Instanzvariable in Form eines Arrays edgeTo[] von int-Werten erweitert, das die Funktion der Fadenrolle in der Trémaux’ Methode hat: Damit finden wir von jedem Knoten, der mit s verbunden ist, einen Pfad zurück zu s. Anstatt also nur dem Pfad vom aktuellen Knoten bis zurück zum Anfang nachzugehen, merken wir uns den Pfad von jedem Knoten zum Anfang. Bei dieser Strategie merken wir uns die Kante v-w, die uns zum ersten Mal mit einem Knoten w verbindet, indem wir edgeTo[w] auf v setzen. Mit anderen Worten, v-w ist die letzte Kante auf dem bekannten Pfad von s zu w. Das Ergebnis der Suche ist ein Baum, der seine Wurzel im Startknoten hat; edgeTo[] ist eine Elternknoten-Referenz-Repräsentation dieses Baums. Ein kleines Beispiel finden Sie in Abbildung 4.16 zu Algorithmus 4.1. Um den Pfad von s zu einem beliebigen Knoten v zu rekonstruieren, verwendet die pathTo()-Methode in Algorithmus 4.1 eine Variable x, um den Baum hochzuwandern, setzt x auf edgeTo[x] (wie bei Union-Find in Abschnitt 1.5) und legt so lange jeden Knoten, der angetroffen wird, auf einem Stapel ab, bis s erreicht wird. Wenn der Client den Stapel als ein Iterable zurückgeliefert bekommt, kann er dem Pfad von s zu v folgen. % 0 0 0 0 0 0
java Paths tinyCG.txt 0 to 0: 0 to 1: 0-2-1 to 2: 0-2 to 3: 0-2-3 to 4: 0-2-3-4 to 5: 0-2-3-5
573
4
Graphen
Listing 4.5: Tiefensuche, um Pfade in einem Graphen zu finden (Algorithmus 4.1) public class DepthFirstPaths { private boolean[] marked; // private int[] edgeTo; // // // private final int s;
Wurde dfs() für diesen Knoten aufgerufen? Letzter Knoten auf dem bekannten Pfad zu diesem Knoten Startknoten
public DepthFirstPaths(Graph G, int s) { marked = new boolean[G.V()]; edgeTo = new int[G.V()]; this.s = s; dfs(G, s); } private void dfs(Graph G, int v) { marked[v] = true; for (int w : G.adj(v)) if (!marked[w]) { edgeTo[w] = v; dfs(G, w); } } public boolean hasPathTo(int v) { return marked[v]; } public Iterable pathTo(int v) { if (!hasPathTo(v)) return null; Stack path = new Stack(); for (int x = v; x != s; x = edgeTo[x]) path.push(x); path.push(s); return path; } }
574
4.1 Ungerichtete Graphen
x
path
Abbildung 4.16: Ablaufprotokoll der pathTo(5)-Berechnung
Dieser Graph-Client bedient sich der Tiefensuche, um in einem Graphen die Pfade zu allen Knoten zu finden, die mit einem gegebenen Startknoten s verbunden sind. Um bekannte Pfade zu jedem Knoten zu speichern, verwaltet dieser Code ein knotenindiziertes Array edgeTo[], sodass edgeTo[w] = v bedeutet, dass v-w die Kante war, über die zum ersten Mal auf w zugegriffen wurde. Das Array edgeTo[] ist eine Elternknoten-Referenz-Repräsentation eines Baums, der seine Wurzel in s hat und alle Knoten enthält, die mit s verbunden sind.
Ausführliches Ablaufprotokoll Abbildung
4.17 zeigt den Inhalt von edgeTo[], direkt nachdem die einzelnen Knoten in unserem Beispiel (mit Startknoten 0) markiert wurden. Die Inhalte von marked[] und adj[] entsprechen denen im Ablaufprotokoll von DepthFirstSearch aus Abbildung 4.15, ebenso ist die ausführliche Beschreibung der rekursiven Aufrufe und der geprüften Kanten gleich, sodass diese Aspekte des Ablaufprotokolls hier weggelassen werden. Die Tiefensuche fügt die Kanten 0-2, 2-1, 2-3, 3-5 und 3-4 in genau dieser Reihenfolge dem Array edgeTo[] hinzu. Diese Kanten bilden einen Baum mit dem Startknoten als Wurzel und liefern die Informationen, die pathTo() benötigt, um dem Client den Pfad von 0 zu 1, 2, 3, 4 oder 5 wie gerade beschrieben zurückzuliefern. Der Konstruktor von DepthFirstPaths unterscheidet sich von dem Konstruktor von DepthFirstSearch nur in ein paar Zuweisungen, sodass Satz A gilt. Diesen können wir wie folgt erweitern:
Satz A (Fortsetzung): Mithilfe der Tiefensuche können wir Clients einen Pfad von einem gegebenen Startknoten zu jedem markierten Knoten zurückliefern, und das in einer Zeit, die proportional seiner Länge ist. Beweis: Durch Induktion über die Zahl der besuchten Knoten; es zeigt sich, dass das Array edgeTo[] in DepthFirstPaths einen Baum repräsentiert, der seine Wurzel im Startknoten hat. Die Methode pathTo() erstellt den Pfad in einer Zeit proportional seiner Länge.
575
4
Graphen
dfs(0)
dfs(2) prüfe 0
dfs(1) prüfe 0 prüfe 2 1 fertig
dfs(3)
dfs(5) prüfe 3 prüfe 0 5 fertig
dfs(4) prüfe 3 prüfe 2 4 fertig prüfe 2 3 fertig prüfe 4 2 fertig prüfe 1 prüfe 5 0 fertig
Abbildung 4.17: Ablaufprotokoll der Tiefensuche, um alle Pfade zu finden, die von 0 ausgehen
576
4.1 Ungerichtete Graphen
4.1.5 Breitensuche Die durch die Tiefensuche ermittelten Pfade hängen nicht nur vom Graphen, sondern auch von der Repräsentation und der Art der Rekursion ab. In der Praxis sind wir aber meist an der Lösung des folgenden Problems interessiert: Kürzeste-Pfade-Problem mit einem Startknoten (single-source shortest paths, SSSP): Wir möchten für einen gegebenen Graphen und einem Startknoten s Fragen beantworten wie Gibt es einen Pfad von s zu einem gegebenen Zielknoten v? Wenn ja, finde den kürzesten Pfad (d.h. den Pfad mit den wenigsten Kanten).
Das klassische Verfahren für diese Aufgabe, die sogenannte Breitensuche (breadth-first search, BFS), bildet die Basis für eine Vielzahl von Algorithmen zur Graphenverarbeitung und wird deshalb in diesem Abschnitt ausführlich behandelt. Die Tiefensuche ist uns bei der Lösung dieses Problems keine große Hilfe, da die Reihenfolge, in der die Tiefensuche uns durch den Graphen führt, in keinem Zusammenhang zu unserem Ziel steht, kürzeste Pfade zu finden. Die Breitensuche hingegen ist speziell auf dieses Ziel ausgerichtet. Um einen kürzesten Pfad von s zu v zu finden, beginnen wir bei s und prüfen alle Knoten, die wir über eine Kante erreichen, ob sie gleich v sind. Dann prüfen wir alle Knoten, die wir über eine Kante erreichen, und so weiter. Die Tiefensuche entspricht demzufolge einer Person, die ein Labyrinth erkundet, während die Breitensuche einer Gruppe von Personen entspricht, die in Abbildung 4.18: alle Richtungen ausschwärmen und jeweils einen eigenen Faden Erkundung des Labyentrollen. Wenn mehr als ein Weg erkundet werden muss, stellen rinths mit Breitensuche wir uns vor, dass sich die Gruppe aufsplittet, um alle Wege möglichst parallel zu erkunden. Treffen sich zwei Gruppen, schließen sie sich wieder zusammen (und verwenden die Fadenrolle der Gruppe, die den Knotenpunkt zuerst erreicht hat). Wenn wir während einer Graphensuche an einen Punkt kommen, an dem sich mehr als eine Kante zum Traversieren anbietet, entscheiden wir uns für eine und heben uns die anderen für später auf. Bei der Tiefensuche verwenden wir zu diesem Zweck einen Stapel (der vom System verwaltet wird, um die rekursive Suchmethode zu unterstützen). Dabei entspricht die Verwendung der für Stapel typischen LIFO-Regel dem Erkunden der nächstgelegenen Wege im Labyrinth. Unter den Wegen, die noch zu erkunden sind, wählen wir den, den wir zuletzt besucht haben. In der Breitensuche wollen wir die Knoten in der Reihenfolge ihrer Entfernung vom Startknoten erkunden. Es zeigt sich, dass sich diese Reihenfolge leicht herstellen lässt: Verwenden Sie einfach eine (FIFO-)Warteschlange anstelle eines (LIFO-)Stapels. Wir wählen von den noch zu erkundenden Wegen wie bereits oben erwähnt den, den wir als Erstes entdeckt haben.
577
4
Graphen
Implementierung Algorithmus 4.2 aus Listing 4.6 implementiert eine solche Breitensuche. Die Basis bildet eine Warteschlange aller Knoten, die markiert wurden, aber deren Adjazenzlisten noch nicht abgearbeitet wurden. Wir stellen den Startknoten in die Warteschlange und führen die folgenden Schritte aus, bis die Warteschlange leer ist:
Wir nehmen den nächsten Knoten v aus der Warteschlange und markieren ihn. Wir stellen alle unmarkierten Nachbarknoten von v in die Warteschlange. Die Methode bfs() in Algorithmus 4.2 ist nicht rekursiv. Anstelle des impliziten Stapels, wie er in der Rekursion üblich ist, verwendet sie eine explizite Warteschlange. Das Ergebnis der Suche ist wie bei der Tiefensuche ein Array edgeTo[] – eine Elternknoten-Referenz-Repräsentation eines Baums mit der Wurzel in s, die die kürzesten Pfade von s zu jedem mit s verbundenen Knoten definiert. Die Pfade können für den Client mit der gleichen pathTo()-Implementierung konstruiert werden wie für die Tiefensuche in Algorithmus 4.1. Abbildung 4.20 zeigt Schritt für Schritt, wie die Breitensuche unseren Beispielgraphen traversiert und dabei vor jedem Schleifendurchlauf der Inhalt der Datenstrukturen ändert. Knoten 0 wird in die Warteschlange gestellt und die Schleife führt dann die Suche wie folgt durch:
Sie entfernt den Knoten 0 aus der Warteschlange und stellt dessen Nachbarknoten 2, 1 und 5 in die Warteschlange, markiert jeden und setzt den edgeTo[]-Eintrag für
jeden auf 0.
Sie entfernt den Knoten 2 aus der Warteschlange, prüft dessen Nachbarknoten 0 und 1, die markiert sind, und stellt dessen Nachbarknoten 3 und 4 in die Warteschlange, markiert jeden und setzt den edgeTo[]-Eintrag für jeden auf 2.
Sie entfernt den Knoten 1 aus der Warteschlange und prüft dessen Nachbarknoten 0 und 2, die bereits markiert sind.
Sie entfernt den Knoten 5 aus der Warteschlange und prüft dessen Nachbarknoten 3 und 0, die bereits markiert sind.
Sie entfernt den Knoten 3 aus der Warteschlange und prüft dessen Nachbarknoten 5, 4 und 2, die bereits markiert sind.
Sie entfernt den Knoten 4 aus der Warteschlange und prüft dessen Nachbarknoten 3 und 2, die markiert sind.
578
4.1 Ungerichtete Graphen
Abbildung 4.19: Ergebnis der Breitensuche, um alle Pfade ausgehend von 0 zu finden Warteschlange 0
2 1 5
1 5 3 4
5 3 4
3 4
4
Abbildung 4.20: Ablaufprotokoll der Breitensuche, um alle Pfade ausgehend von 0 zu finden
579
4
Graphen
Listing 4.6: Breitensuche, um Pfade in einem Graphen zu finden (Algorithmus 4.2) public class BreadthFirstPaths { private boolean[] marked; // Ist ein kürzester Pfad zu diesem Knoten bekannt? private int[] edgeTo; // Letzter Knoten auf bekanntem Pfad zu diesem Knoten private final int s; // Startknoten public BreadthFirstPaths(Graph G, int s) { marked = new boolean[G.V()]; edgeTo = new int[G.V()]; this.s = s; bfs(G, s); } private void bfs(Graph G, int s) { Queue queue = new Queue(); marked[s] = true; // Markiert den Startknoten und queue.enqueue(s); // legt ihn in der Warteschlange ab. while (!queue.isEmpty()) { int v = queue.dequeue(); // Entfernt nächsten Knoten aus der // Warteschlange. for (int w : G.adj(v)) if (!marked[w]) // Speichert für jeden unmarkierten Nachbarknoten { edgeTo[w] = v; // die letzte Kante auf einem kürzesten Pfad, marked[w] = true; // markiert sie, da der Pfad bekannt ist, queue.enqueue(w); // und fügt sie der Warteschlange hinzu. } } } public boolean hasPathTo(int v) { return marked[v]; } public Iterable pathTo(int v) // Das Gleiche wie für die Tiefensuche s.S. 574. }
Dieser Graph-Client bedient sich der Breitensuche, um Pfade in einem Graphen mit der geringsten Anzahl Kanten zu finden; er geht von einem Startknoten s aus, der im Konstruktor übergeben wird. Die Methode bfs() markiert alle Knoten, die mit s verbunden sind, sodass Clients mit hasPathTo() feststellen können, ob ein gegebener Knoten v mit s verbunden ist, und sich mit pathTo() einen Pfad von s zu v zurückliefern lassen können, der die Eigenschaft hat, dass kein anderer Pfad von s zu v weniger Kanten aufweist.
580
4.1 Ungerichtete Graphen
Bei diesem Beispiel ist das Array edgeTo[] nach dem zweiten Schritt fertig. Sobald alle Knoten markiert sind, besteht der Rest der Berechnung – wie bei der Tiefensuche – nur noch aus der Überprüfung von Kanten zu Knoten, die bereits markiert sind.
Satz B: Die Breitensuche berechnet für jeden Knoten v, der von s aus erreicht werden kann, einen kürzesten Pfad (kein Pfad von s zu v hat weniger Kanten). Beweis: Durch Induktion ist leicht zu beweisen, dass die Warteschlange immer null oder mehr Knoten der Distanz k zum Startknoten enthält, gefolgt von null oder mehr Knoten der Distanz k+1 zum Startknoten, für irgendeinen ganzzahligen Wert k, beginnend mit k gleich 0. Diese Eigenschaft impliziert insbesondere, dass Knoten in der Reihenfolge ihrer Distanz zu s in die Warteschlange gestellt werden und diese auch so verlassen. Wurde ein Knoten v in die Warteschlange gestellt, werden Sie keinen kürzeren Pfad zu v finden, bevor dieser Knoten die Warteschlange verlässt, und kein Pfad zu v, der entdeckt wird, nachdem der Knoten die Warteschlange verlassen hat, kann kürzer sein als die Baumpfadlänge von v.
Satz B (Fortsetzung): Die Breitensuche benötigt im schlimmsten Fall Zeit proportional zu V + E. Beweis: Analog Satz A markiert die Breitensuche alle Knoten, die mit s verbunden sind, in einer Zeit proportional der Summe ihrer Grade. Wenn der Graph zusammenhängend ist, ist diese Summe die Summe der Grade aller Knoten oder 2E.
Beachten Sie, dass wir die Search-API nicht nur mit Tiefensuche, sondern auch mit Breitensuche implementieren können, da die Lösung nur davon abhängt, dass bei der Suche alle Knoten und Kanten untersucht werden, die mit dem Startknoten verbunden sind. Wie bereits eingangs erwähnt, sind Tiefensuche und Breitensuche die ersten von mehreren Beispielen in diesem Kapitel für einen allgemeinen Ansatz zur Suche in Graphen. Wir legen den Startknoten in der Datenstruktur ab und führen dann die folgenden Schritte aus, bis die Datenstruktur leer ist:
Wir nehmen den nächsten Knoten v aus der Datenstruktur und markieren ihn. Wir legen alle unmarkierten Nachbarknoten von v in der Datenstruktur ab. Die Algorithmen unterscheiden sich nur in der Regel, nach der wir den nächsten Knoten aus der Datenstruktur entfernen (den ältesten hinzugefügten Knoten bei der Breitensuche und den zuletzt hinzugefügten Knoten bei der Tiefensuche). Dieser Unterschied führt zu komplett unterschiedlichen Sichten auf einen Graphen, auch wenn alle Knoten und Kanten, die mit dem Startknoten verbunden sind, unabhängig von der Regel untersucht werden.
581
4
Graphen
% 0 0 0 0 0 0
java BreadthFirstPaths tinyCG.txt 0 to 0: 0 to 1: 0-1 to 2: 0-2 to 3: 0-2-3 to 4: 0-2-4 to 5: 0-5
Abbildung
20% 4.21 und Abbildung 4.22 20% zeigen jeweils den Verlauf der Tiefensuche und der Breitensuche für unseren Beispielgraphen mediumG.txt. Sie veranschaulichen deutlich, wie unterschiedlich die Pfade sind, die von den beiden Ansätzen entdeckt werden. Die Tiefen- 40% 40% suche bahnt sich windend einen Weg durch den Graphen und speichert die Punkte, an denen Pfade abzweigen, in einem Stapel; die Breitensuche „durchkämmt“ den Graphen und speichert die Grenze der besuchten Orte in einer War60% 60% teschlange. Die Tiefensuche erkundet den Graphen, indem sie nach neuen Knoten Ausschau hält, die weit vom Ausgangspunkt entfernt liegen, wobei sie näher gelegene Knoten nur betrachtet, wenn sie in eine Sackgasse geraten ist; 80% die Breitensuche durchsucht erst voll- 80% ständig den Bereich in der Nähe des Ausgangspunkts, bevor sie sich weiter ausdehnt. Pfade der Tiefensuche sind in der Regel lang und gewunden, während Pfade der Breitensuche kurz und direkt sind. Je nach Anwendung kann die eine 100% 100% oder die andere Eigenschaft gewünscht sein (oder die Eigenschaften der Pfade sind nebensächlich). In Abschnitt 4.4 werden wir weitere Implementierungen der Paths-API betrachten, die Pfade mit anderen spezifizierten Eigenschaften finden. Abbildung 4.21: Tiefen- Abbildung 4.22: Breiten-
suche für Pfade (250 Knoten)
582
suche für kürzeste Pfade (250 Knoten)
4.1 Ungerichtete Graphen
4.1.6 Zusammenhangskomponenten Als Nächstes wollen wir die Tiefensuche dazu nutzen, die Zusammenhangskomponenten (connected component) eines Graphen zu finden. Gemäß Abschnitt 1.5 ist die Aussage „ist verbunden mit“ eine Äquivalenzrelation, die die Knoten in Äquivalenzklassen teilt (die Zusammenhangskomponenten). Für diese sich häufig stellende Aufgabe der Graphenverarbeitung definieren wir die folgende API:
Tabelle 4.7
API für Zusammenhangskomponenten
public class CC CC(Graph G)
Vorverarbeitender Konstruktor
connected(int v, int w)
Sind v und w verbunden?
int
count()
Anzahl der Zusammenhangskomponenten
int
id(int v)
Komponentenbezeichner für v (zwischen 0 und count()-1)
boolean
Die Methode id() kann der Client nutzen, um ein Array nach Komponenten zu indizieren, wie im Testclient in Listing 4.7, der einen Graphen einliest und dann die Anzahl seiner Zusammenhangskomponenten sowie die Knoten in jeder Komponente ausgibt, und zwar eine Komponente pro Zeile. Dazu erstellt die Methode ein Array von Bag-Objekten und verwendet dann den Komponentenbezeichner jedes Knotens als Index in dieses Array, um den Knoten dem korrekten Bag-Objekt hinzuzufügen. Dieser Client ist ein typisches Beispiel für die Standardsituation, dass wir Zusammenhangskomponenten unabhängig voneinander verarbeiten wollen. public static void main(String[] args) { Graph G = new Graph(new In(args[0])); CC cc = new CC(G); int M = cc.count(); StdOut.println(M + " components"); Bag[] components; components = (Bag[]) new Bag[M]; for (int i = 0; i < M; i++) components[i] = new Bag(); for (int v = 0; v < G.V(); v++) components[cc.id(v)].add(v); for (int i = 0; i < M; i++) { for (int v: components[i]) StdOut.print(v + " "); StdOut.println(); } } Listing 4.7: Testclient für die API für Zusammenhangskomponenten
583
4
Graphen
Implementierung Listing 4.8: Tiefensuche, um Zusammenhangskomponenten in einem Graphen zu finden (Algorithmus 4.3) public class CC { private boolean[] marked; private int[] id; private int count; public CC(Graph G) { marked = new boolean[G.V()]; id = new int[G.V()]; for (int s = 0; s < G.V(); s++) if (!marked[s]) { dfs(G, s); count++; } } private void dfs(Graph G, int v) { marked[v] = true; id[v] = count; for (int w : G.adj(v)) if (!marked[w]) dfs(G, w); } public boolean connected(int v, int w) { return id[v] == id[w]; } public int id(int v) { return id[v]; } public int count() { return count; } }
584
4.1 Ungerichtete Graphen
% java Graph tinyG.txt 13 vertices, 13 edges 0: 6 2 1 5 1: 0 2: 0 3: 5 4 4: 5 6 3 5: 3 4 0 6: 0 4 7: 8 8: 7 9: 11 10 12 10: 9 11: 9 12 12: 11 9 % java CC tinyG.txt 3 components 6 5 4 3 2 1 0 8 7 12 11 10 9
Dieser Graph-Client bietet seinen Clients die Möglichkeit, die Zusammenhangskomponenten eines Graphen unabhängig voneinander zu verarbeiten. Die Berechnung basiert auf einem knotenindiziertes Array id[], sodass id[v] auf i gesetzt wird, wenn v sich in der i-ten verarbeiteten Zusammenhangskomponente befindet. Der Konstruktor findet einen unmarkierten Knoten, und um alle damit verbundenen Knoten zu markieren und zu identifizieren, ruft er die rekursive Methode dfs() auf, womit er fortfährt, bis alle Knoten markiert und identifiziert sind. Daraus ergeben sich unmittelbar die Implementierungen der Instanzmethoden connected(), id() und count().
Die Implementierung CC (Algorithmus 4.3 in Listing 4.8) verwendet unser Array marked[], um einen Knoten zu finden, der als Startpunkt für eine Tiefensuche in jeder Komponente dient. Der erste rekursive Aufruf der Tiefensuche erfolgt für den Knoten 0 und markiert alle Knoten, die mit 0 verbunden sind. Anschließend sucht die forSchleife im Konstruktor nach einem unmarkierten Knoten und ruft die rekursive dfs()-Methode auf, um alle Knoten zu markieren, die mit diesem Knoten verbunden sind. Außerdem verwaltet der Algorithmus ein knotenindiziertes Array id[], das jeden Knoten in einer Komponente mit dem gleichen int-Wert assoziiert. Dieses Array vereinfacht die Implementierung von connected(), und zwar auf genau die gleiche Weise wie bei connected() in Abschnitt 1.5 (man muss nur prüfen, ob die Bezeichner gleich sind). In diesem Fall wird der Bezeichner 0 allen Knoten zugewiesen, die sich in der ersten verarbeiteten Komponente befinden, 1 allen Knoten in der zweiten verarbeiteten Komponente und so weiter, d.h., wie in der API spezifiziert, liegen alle Bezeichner zwischen 0 und count()-1. Diese Konvention erlaubt die Verwendung von komponentenindizierten Arrays, wie im Testclient in Listing 4.7.
585
4
Graphen
tinyG.txt
Abbildung 4.23: Ablaufprotokoll der Tiefensuche, um Zusammenhangskomponenten zu finden
586
4.1 Ungerichtete Graphen
Satz C: Die Tiefensuche benötigt Vorverarbeitungszeit und Speicher proportional zu V+E, um Abfragen zum Zusammenhang (connectivity) in einem Graphen in konstanter Zeit zu unterstützen. Beweis: Ergibt sich unmittelbar aus dem Code. Jeder Eintrag in der Adjazenzliste wird genau einmal untersucht, wobei es 2E solcher Einträge gibt (zwei für jede Kante). Instanzmethoden untersuchen eine oder zwei Instanzvariablen oder liefern sie zurück.
Union-Find Inwiefern lässt sich die auf der Tiefensuche basierende Lösung in CC zum Zusammenhang des Graphen mit dem Union-Find-Ansatz in Kapitel 1 vergleichen? In der Theorie ist die Tiefensuche schneller als Union-Find, da sie eine konstante Ausführungszeit garantiert, was bei Union-Find nicht der Fall ist. In der Praxis ist dieser Unterschied jedoch vernachlässigbar, und Union-Find ist schneller, da hier die Repräsentation des Graphen nicht vollständig aufgebaut werden muss. Wichtiger aber ist, dass Union-Find ein Online-Algorithmus ist (d.h., wir können zu jedem Zeitpunkt in fast konstanter Zeit prüfen, ob zwei Knoten verbunden sind, sogar während wir Kanten hinzufügen), während die Tiefensuche-Lösung erst den Graphen vorverarbeiten muss. Deshalb ziehen wir zum Beispiel Union-Find vor, wenn wir nur feststellen müssen, ob der Graph zusammenhängend ist oder wenn wir es mit vielen Abfragen gemischt mit Kanteneinfügungen zu tun haben, halten aber für einen abstrakten Graphendatentyp die Lösung mit der Tiefensuche grundsätzlich geeigneter, da hier die bestehende Infrastruktur effizient genutzt wird. Die Probleme, die wir mit der Tiefensuche gelöst haben, sind fundamentaler Art. Der Ansatz ist einfach und die Rekursion bietet uns die Möglichkeit, über die Berechnung nachzudenken und kompakte Lösungen zu graphenverarbeitenden Problemen zu entwickeln. Zwei weitere Beispiele zur Lösung der folgenden Probleme finden Sie in Tabelle 4.8. Zykluserkennung: Wir möchten die folgende Frage beantworten Ist ein gegebener Graph azyklisch? Zweifärbbarkeit: Wir möchten die folgende Frage beantworten Können den Knoten eines gegebenen Graphen eine von zwei Farben zugewiesen werden, sodass keine Kante Knoten der gleichen Farbe verbindet? Diese Frage lässt sich auch formulieren als Ist der Graph bipartit?
Wie es bei der Tiefensuche üblich ist, verbirgt sich hinter dem einfachen Code eine anspruchsvolle Berechnung, sodass es durchaus der Mühe lohnt, diese Beispiele gründlich zu analysieren, ihr Verhalten anhand kleiner Beispielgraphen zu veranschaulichen und den Code um die Bereitstellung des Zyklus oder einer Färbung zu erweitern, was wir Ihnen zur Übung überlassen wollen.
587
4
Graphen
Tabelle 4.8
Weitere Beispiele der Graphenverarbeitung mit Tiefensuche
Aufgabe
Implementierung
Ist G azyklisch? (vorausgesetzt, es gibt keine reflexiven oder parallele Kanten)
public class Cycle { private boolean[] marked; private boolean hasCycle; public Cycle(Graph G) { marked = new boolean[G.V()]; for (int s = 0; s < G.V(); s++) if (!marked[s]) dfs(G, s, s); } private void dfs(Graph G, int v, int u) { marked[v] = true; for (int w : G.adj(v)) if (!marked[w]) dfs(G, w, v); else if (w != u) hasCycle = true; } public boolean hasCycle() { return hasCycle; } }
Ist G bipartit? (2-färbbar)
public class TwoColor { private boolean[] marked; private boolean[] color; private boolean isTwoColorable = true; public TwoColor(Graph G) { marked = new boolean[G.V()]; color = new boolean[G.V()]; for (int s = 0; s < G.V(); s++) if (!marked[s]) dfs(G, s); } private void dfs(Graph G, int v) { marked[v] = true; for (int w : G.adj(v)) if (!marked[w]) { color[w] = !color[v]; dfs(G, w); } else if (color[w] == color[v]) isTwoColorable = false; } public boolean isBipartite() { return isTwoColorable; } }
588
4.1 Ungerichtete Graphen
4.1.7 Symbolgraphen In typischen Anwendungen verarbeiten Sie Graphen, die in Dateien oder auf Webseiten vorliegen und deren Knoten über Strings und nicht über Integer-Indizes definiert und angesprochen werden. Um solchen Anwendungen gerecht zu werden, definieren wir ein Eingabeformat mit den folgenden Eigenschaften:
Knotennamen bestehen aus Strings. Ein spezifiziertes Trennzeichen trennt Knotennamen (erlaubt es, Leerzeichen in Namen zu verwenden).
Jede Zeile repräsentiert eine Menge von Kanten, die den ersten Knotennamen auf der Zeile mit jedem anderen Knotennamen auf der Zeile verbinden.
Die Anzahl der Knoten V und die Anzahl der Kanten E sind beide implizit definiert. routes.txt In Abbildung 4.24 sehen Sie eine kleine V und E werden nicht explizit JFK MCO Beispieldatei routes.txt, die ein Modell angegeben ORD DEN für ein kleines Transportsystem repräsenORD HOU tiert, in dem die Knoten die amerikaniDFW PHX JFK ATL schen Flughafencodes sind und die sie ORD JFK ORD DFW verbindenden Kanten die Flugrouten. Die DEN ORD PHX Datei besteht lediglich aus einer Liste von ATL HOU LAS DEN PHX Kanten. In Abbildung 4.25 finden Sie ATL PHX LAX DFW LAX PHX ein etwas größeres Beispiel, das der Datei JFK ORD HOU MCO DEN LAS movies.txt aus der Internet Movie DataDFW HOU base (IMDB) entnommen wurde, die Sie ORD ATL bereits aus Abschnitt 3.5 kennen. Wir " " als LAS LAX ATL MCO Trennzeichen erinnern uns, dass diese Datei aus Zeilen HOU MCO besteht, die einen Filmnamen angeben, LAS PHX gefolgt von einer Liste der Schauspieler Abbildung 4.24: Beispiel für einen Symbolgraphen in diesem Film. Im Kontext der Graphen- (Liste von Kanten) verarbeitung definieren wir sozusagen einen Graphen, in dem die Filme und Schauspieler die Knoten sind und jede Zeile die Adjazenzliste der Kanten definiert, die jeden Film mit seinen Schauspielern verbindet. Beachten Sie, dass der Graph ein bipartiter Graph ist, d.h., es gibt keine Kante, die einen Schauspieler mit einem anderen Schauspieler oder einen Film mit einem anderen Film verbindet.
API Die folgende API definiert einen Graph-Client, der es uns erlaubt, unsere Routinen zur Graphenverarbeitung direkt für Graphen zu nutzen, die durch solche Dateien definiert werden:
589
4
Graphen
Tabelle 4.9
API für Graphen mit symbolischen Knotennamen
public class SymbolGraph SymbolGraph(String filename, String delim)
Erstellt einen Graphen, angegeben in filename, und trennt die Knotennamen mit delim.
boolean
contains(String key)
Ist key ein Knoten?
int
index(String key)
Index assoziiert mit key
String
name(int v)
Schlüssel assoziiert mit Index v
Graph
G()
Zugrunde liegender Graph
Patrick Allen
Caligola
Glenn Close
Dial M for Murder
The Stepford Wives
Murder on the Orient Express
Donald Sutherland
Cold Mountain
Vernon Dobtcheff
John Belushi
Animal House
The Woodsman
Filmknoten
The River Wild Paul Herbert
Meryl Streep
Schauspielerknoten Tom Hanks
The Da Vinci Code Serretta Wilson
Titanic
Eternal Sunshine of the Spotless Mind
movies.txt
Joe Versus the Volcano
Apollo 13
Bill Paxton
Wild Things
Kate Winslet
Kathleen Quinlan
Kevin Bacon
Jude
Enigma
Lloyd Bridges
The Eagle Has Landed
Nicole Kidman
An American Haunting
Hamlet
High Noon
To Catch a Thief
John Gielgud Portrait of a Lady
Grace Kelly
V und E werden nicht explizit angegeben
Yves Aubert
Shane Zaza
... Tin Men (1987)/DeBoy, David/Blumenfeld, Alan/... /Geppi, Cindy/Hershey, Barbara... Tirez sur le pianiste (1960)/Heymann, Claude/.../Berger, Nicole (I)... Titanic (1997)/Mazin, Stan/...DiCaprio, Leonardo/.../Winslet, Kate/... "/" als Titus (1999)/Weisskopf, Hermann/Rhys, Matthew/.../McEwan, Geraldine Trennzeichen To Be or Not to Be (1942)/Verebes, Ernö (I)/.../Lombard, Carole (I)... To Be or Not to Be (1983)/.../Brooks, Mel (I)/.../Bancroft, Anne/... To Catch a Thief (1955)/París, Manuel/.../Grant, Cary/.../Kelly, Grace/... To Die For (1995)/Smith, Kurtwood/.../Kidman, Nicole/.../ Tucci, Maria... ...
Film
Schauspieler
Abbildung 4.25: Beispiel für einen Symbolgraphen (Adjazenzlisten)
590
4.1 Ungerichtete Graphen
Diese API enthält einen Konstruktor zum Einlesen und Erstellen des Graphen sowie die Client-Methoden name() und index(), um die Knotennamen, die als Strings über den Eingabestrom entgegengenommen werden, in Integer-Indizes umzuwandeln, wie sie von unseren graphenverarbeitenden Methoden benötigt werden.
Testclient Der Testclient in Listing 4.9 erstellt aus der Datei, die als erstes Befehlszeilenargument angegeben wird, einen Graphen (unter Verwendung des Trennzeichens, das als zweites Befehlszeilenargument angegeben wird) und nimmt Abfragen von der Standardeingabe entgegen. Der Benutzer spezifiziert einen Knotennamen und erhält eine Liste der Nachbarknoten dieses Knotens. Dieser Client liefert sofort einen invertierten Index – eine nützliche Funktionalität, die wir bereits aus Abschnitt 3.5 kennen. Im Fall der Datei routes.txt können Sie einen Flughafencode eingeben und erhalten die direkten Flüge, die von diesem Flughafen ausgehen – eine Information, die so nicht direkt in der Datendatei enthalten ist. Im Fall der Datei movies.txt können Sie den Namen eines Schauspielers eingeben und sehen, in welchen Filmen er mitgewirkt hat, oder Sie können einen Film eingeben, um sich die Liste der Schauspieler in diesem Film ausgeben zu lassen. Die Eingabe eines Filmtitels mit anschließender Ausgabe der Besetzungsliste ist nicht mehr, als die entsprechende Zeile der Eingabedatei auszuspucken, während den Namen eines Schauspielers einzugeben und die Liste der Filme zu erhalten, in denen er mitgewirkt hat, gleich der Invertierung des Index ist. Auch wenn die Datenbank darauf basiert, Filme mit Schauspielern zu verbinden, können dank dem bipartiten Graphenmodell auch Schauspieler mit Filmen verbunden werden. Das bipartite Graphenmodell dient automatisch als invertierter Index und bietet die Grundlage für anspruchsvollere Verarbeitungsaufgaben, wie Sie bald sehen werden. public static void main(String[] args) { String filename = args[0]; String delim = args[1]; SymbolGraph sg = new SymbolGraph(filename, delim); Graph G = sg.G(); while (StdIn.hasNextLine()) { String source = StdIn.readLine(); for (int w : G.adj(sg.index(source))) StdOut.println(" " + sg.name(w)); } } Listing 4.9: Testclient für die API eines Symbolgraphen
591
4
Graphen
% java SymbolGraph routes.txt " " JFK ORD ATL MCO LAX LAS PHX
% java SymbolGraph movies.txt "/" Tin Men (1987) DeBoy, David Blumenfeld, Alan ... Geppi, Cindy Hershey, Barbara ... Bacon, Kevin Mystic River (2003) Friday the 13th (1980) Flatliners (1990) Few Good Men, A (1992) ...
Dieser Ansatz eignet sich selbstverständlich für jede der hier betrachteten graphenverarbeitenden Methoden: Jeder Client kann index() verwenden, wenn er für die spätere Graphenverarbeitung einen Knotennamen in einen Index umwandeln möchte, und name(), wenn er einen Index aus der Graphenverarbeitung in einen Namen umwandeln möchte, falls dies im Kontext der Anwendung nötig werden sollte.
Implementierung Eine vollständige Implementierung von SymbolGraph finden Sie in Code erstellt drei Datenstrukturen:
Listing
4.10. Der
Eine Symboltabelle st mit String-Schlüsseln (Knotennamen) und int-Werten (Indizes).
Ein Array keys[], das die Funktion eines invertierten Index hat und den mit jedem Integer-Index assoziierten Knotennamen liefert.
Ein Graph G, erstellt mit den Indizes, die auf die Knoten verweisen. SymbolGraph durchläuft die Daten zweimal, um diese Datenstrukturen zu erstellen, was vor allem daran liegt, dass für die Erstellung des Graphen die Anzahl der Knoten V benötigt wird. In typischen konkreten Anwendungen ist es etwas umständlich, den Wert von V und E in der Definitionsdatei des Graphen zu speichern (wie in unserem Graph-Konstruktor am Anfang dieses Abschnitts) – bei SymbolGraph können wir Dateien wie routes.txt oder movies.txt verwalten, indem wir Einträge hinzufügen oder löschen, ohne Rücksicht auf die Anzahl der verschiedenen Namen.
592
4.1 Ungerichtete Graphen
Listing 4.10: Datentyp eines Symbolgraphen public class SymbolGraph { private ST st; private String[] keys; private Graph G; public SymbolGraph(String stream, String sp) { st = new ST(); In in = new In(stream); while (in.hasNextLine()) { String[] a = in.readLine().split(sp); for (int i = 0; i < a.length; i++) if (!st.contains(a[i])) st.put(a[i], st.size()); } keys = new String[st.size()]; for (String name : st.keys()) keys[st.get(name)] = name; G = new Graph(st.size()); in = new In(stream); while (in.hasNextLine()) { String[] a = in.readLine().split(sp); int v = st.get(a[0]); for (int i = 1; i < a.length; i++) G.addEdge(v, st.get(a[i])); } } public public public public
// String -> Index // Index -> String // der Graph
// Erster Durchlauf // erstellt den Index // // // //
durch Einlesen von Strings, um jeden eindeutigen String mit einem Index zu assoziieren.
// Invertierter Index, // der String-Schlüssel liefert, // ist ein Array. // Zweiter Durchlauf // erstellt den Graphen, // // // //
indem der auf jeder mit allen verbunden
erste Knoten Zeile anderen Knoten wird.
boolean contains(String s) { return st.contains(s); } int index(String s) { return st.get(s); } String name(int v) { return keys[v]; } Graph G() { return G; }
}
Dieser Graph-Client erlaubt Clients, Graphen zu definieren, die als Knotennamen String-Werte anstelle von Integer-Indizes verwenden. Dazu werden die folgenden Instanzvariablen eingerichtet: st (eine Symboltabelle, die Namen auf Indizes abbildet), keys (ein Array, das Indizes auf Namen abbildet) und G (ein Graph mit ganzzahligen Werten als Knotennamen). Um diese Datenstrukturen zu erstellen, wird die Graphendefinition zweimal durchlaufen (jede Zeile besteht aus einem String und einer Liste von benachbarten Strings, die durch das Trennzeichen sp getrennt sind).
593
4
Graphen
Symboltabelle
invertierter Index
ungerichteter Graph
ST st
String[] keys
Graph G
JFK 0
0 1 2 3 4 5 6 7 8 9
MCO 1 ORD 2 DEN 3 HOU 4 DFW 5 PHX 6 ATL 7
int V
JFK MCO ORD DEN HOU DFW PHX ATL LAX LAS
10
Bag[] adj
2
7
1
4
7
3
7
0
6
9
6
2
1
5
7
4
2
6
9
8
3
2
1
2
4
0
9
6
6
8
0 1 2
5
4
3
3 4 5
LAX 8
2
6
LAS 9
7
Schlüssel Wert
8
ORD
JFK
DEN
5
9
LAS LAX
PHX
ATL
DFW HOU
3
MCO
Abbildung 4.26: Datenstrukturen eines Symbolgraphen
Grade der Trennung Ein klassisches Beispiel für die Anwendung der Graphenverarbeitung ist die Suche nach den Graden der Trennung von Personen in sozialen Netzwerken. Zur Veranschaulichung wollen wir eine solche Suche anhand eines in letzter Zeit sehr beliebten Zeitvertreibs, dem sogenannten Kevin-Bacon-Spiel, diskutieren, das den gerade betrachteten Graphen der Filme und Schauspieler verwendet. Kevin Bacon ist ein sehr produktiver Darsteller, der in vielen Filmen mitgewirkt hat. Wir weisen jedem Schauspieler, der zusammen mit Kevin Bacon in einem Film gespielt hat, eine KevinBacon-Zahl (KBZ) zu: Bacon selbst hat die Zahl 0; jeder, der mit ihm zusammen einen Film gedreht hat, bekommt die Zahl 1; jeder weitere Schauspieler (außer Bacon), der zusammen mit einem Schauspieler der KBZ 1 einen Film gedreht hat, hat die KBZ 2 und so weiter. So hat zum Beispiel Meryl Streep eine Bacon-Zahl von 1, da sie zusammen mit ihm in dem Film Am wilden Fluss (The River Wild) mitgewirkt hat. Die Bacon-Zahl von Nicole Kidman beträgt 2: Sie hat zwar nicht direkt mit Kevin Bacon gearbeitet, aber mit Tom Cruise (im Film Tage des Donners (Days of Thunder)), und Tom Cruise wiederum hat zusammen mit Kevin Bacon den Film Eine Frage der Ehre (A Few Good Men) gedreht. Wenn der Name eines Schauspielers gegeben ist, besteht die einfachste Version des Spiels darin, eine abwechselnde Folge von Filmen und Schauspielern zu finden, die zu Kevin Bacon führt. Ein Filmfan könnte zum Beispiel wissen, dass Tom Hanks den Film Joe gegen den Vulkan (Joe Versus the Volcano) zusammen mit Lloyd Bridges gedreht hat, der zusammen mit Grace Kelly in Zwölf Uhr mittags (High Noon) mitgespielt hat, die mit Patrick Allen in Bei Anruf Mord (Dial M for Murder) mitgespielt hat, der zusammen mit Donald Sutherland in Der Adler ist gelandet (The Eagle has Landed) mitgespielt hat, der mit Kevin Bacon den Film Ich
594
4.1 Ungerichtete Graphen
glaub, mich tritt ein Pferd (Animal House) gedreht hat. Doch dieses Wissen allein reicht nicht, um die Bacon-Zahl von Tom Hanks zu ermitteln (die tatsächlich den Wert 1 hat, da Tom Hanks mit Kevin Bacon Apollo 13 gedreht hat).
Listing 4.11: Grade der Trennung public class DegreesOfSeparation { public static void main(String[] args) { SymbolGraph sg = new SymbolGraph(args[0], args[1]); Graph G = sg.G(); String source = args[2]; if (!sg.contains(source)) { StdOut.println(source + " not in database."); return;
}
int s = sg.index(source); BreadthFirstPaths bfs = new BreadthFirstPaths(G, s); while (!StdIn.isEmpty()) { String sink = StdIn.readLine(); if (sg.contains(sink)) { int t = sg.index(sink); if (bfs.hasPathTo(t)) for (int v : bfs.pathTo(t)) StdOut.println(" " + sg.name(v)); else StdOut.println("Not connected"); } else StdOut.println("Not in database."); } } }
Dieser SymbolGraph- und BreadthFirstPaths-Client sucht die kürzesten Pfade in Graphen. Übergibt man ihm movies.txt spielt er das Kevin-Bacon-Spiel. % java DegreesOfSeparation routes.txt " " JFK LAS JFK ORD PHX LAS DFW JFK ORD DFW
595
4
Graphen
Wie Sie sehen, wird die Bacon-Zahl definiert durch die Anzahl der Filme in der kürzesten aller möglichen Sequenzen, sodass es schwer ist, ohne einen Computer den Gewinner mit Gewissheit zu ermitteln. Wie Sie dem SymbolGraph-Client DegreesOfSeparation aus Listing 4.11 entnehmen können, ist BreadthFirstPaths genau das Programm, das Sie für die Suche nach dem kürzesten Pfad benötigen, der Ihnen die Bacon-Zahl zu jedem Schauspieler in movies.txt liefert. Dieses Programm nimmt einen Startknoten von der Befehlszeile entgegen, übernimmt dann Abfragen von der Standardeingabe und gibt einen kürzesten Pfad vom Startknoten zu dem Abfrageknoten aus. Da der zu movies.txt gehörende Graph bipartit ist, wechseln sich in allen Pfaden Filme und Schauspieler ab, und der ausgegebene Pfad ist ein „Beweis“, dass der Pfad gültig ist (wenn auch kein Beweis, dass er der kürzeste ist – dies müssen Sie Ihren Freunde anhand Satz B erklären). DegreesOfSeparation findet auch kürzeste Pfade in Graphen, die nicht bipartit sind: beispielsweise einen Weg, wie Sie in routes.txt mit den wenigsten Zwischenstopps von einem Flughafen zu einem anderen kommen. Vielleicht führen Sie DegreesOfSeparation nur zum Spaß aus, um einige unterhaltsame Antworten aus dem Filmgeschäft zu erhalten. Sie können zum Beispiel auch nach dem Grad der Trennung zwischen Filmen und nicht nur zwischen Schauspielern suchen. Wichtiger jedoch ist, dass das Konzept der Trennung in vielen anderen Kontexten ausgiebig untersucht wurde. So spielen beispielsweise Mathematiker dieses Spiel mit einem Graphen, der definiert ist durch die Koautorschaft bezogen auf die Publikationen Paul Erdös, einem Mathematiker des 20ten Jahrhunderts, der viel veröffentlicht hat. Genauso gut scheint jeder in New Jersey eine Bruce-Springsteen-Zahl von 2 zu haben, da jeder in diesem Staat jemanden zu kennen scheint, der behauptet, mit Bruce bekannt zu sein. Um das Erdös-Spiel spielen zu können, brauchen Sie lediglich eine Datenbank aller mathematischen Aufsätze, das Springsteen-Spiel zu spielen ist dagegen schon etwas schwieriger. Doch Spaß beiseite: Die Grade der Trennung spielen für Entwurf und Analyse von Rechner- und Kommunikationsnetzen sowie generell für unser Verständnis natürlicher Netzwerke in allen Bereichen der Technik eine sehr wichtige Rolle. % java DegreesOfSeparation movies.txt "/" "Bacon, Kevin" Kidman, Nicole Bacon, Kevin Few Good Men, A (1992) Cruise, Tom Days of Thunder (1990) Kidman, Nicole Grant, Cary Bacon, Kevin Planes, Trains & Automobiles (1987) Martin, Steve (I) Dead Men Don't Wear Plaid (1982) Grant, Cary
596
4.1 Ungerichtete Graphen
% java DegreesOfSeparation movies.txt "/" "Animal House (1978)" Titanic (1997) Animal House (1978) Allen, Karen (I) Raiders of the Lost Ark (1981) Taylor, Rocky (I) Titanic (1997) To Catch a Thief (1955) Animal House (1978) Vernon, John (I) Topaz (1969) Hitchcock, Alfred (I) To Catch a Thief (1955)
4.1.8 Zusammenfassung In diesem Abschnitt haben wir mehrere grundlegende Konzepte vorgestellt, die wir im weiteren Verlauf dieses Kapitels noch ausbauen und weiterentwickeln wollen:
Graphennomenklatur Eine Graphenrepräsentation, mit der wir riesige dünne Graphen verarbeiten können. Ein Entwurfsmuster zur Graphenverarbeitung, bei dem wir Algorithmen implementieren, indem wir Clients entwickeln, die den Graphen im Konstruktor vorverarbeiten, und Datenstrukturen erstellen, die Client-Abfragen über den Graphen effizient unterstützen.
Tiefensuche und Breitensuche Eine Klasse, die uns die Möglichkeit bietet, symbolische Knotennamen zu verwenden. Die nachstehende Tabelle fasst die Implementierungen der bisher betrachteten Graphenalgorithmen noch einmal übersichtlich zusammen. Diese Algorithmen sind ein guter Einstieg in die Graphenverarbeitung, da Varianten dieser Codebeispiele Ihnen wieder begegnen werden, wenn wir kompliziertere Graphen und Anwendungen betrachten und (logischerweise) schwierigere Probleme der Graphenverarbeitung lösen müssen. Die Antworten auf die Fragen zu Verbindungen und Pfaden zwischen den Knoten fallen deutlich schwerer, wenn wir den Graphenkanten eine Richtung und ein Gewicht zuweisen; nichtsdestotrotz können Sie Ihre Probleme mit den gleichen Ansätzen lösen und diese sogar als Ausgangsbasis zur Lösung noch viel komplizierterer Probleme heranziehen.
597
4
Graphen
Tabelle 4.10 Probleme bei der Verarbeitung von (ungerichteten) Graphen in diesem Abschnitt
598
Problem
Lösung
Verweis
Zusammenhang ausgehend von einem Startknoten
DepthFirstSearch
Listing 4.3
Pfade mit einem Startknoten
DepthFirstPaths
Listing 4.5
Kürzeste Pfade mit einem Startknoten
BreadthFirstPaths
Listing 4.6
Zusammenhangskomponenten
CC
Listing 4.8
Zykluserkennung
Cycle
Tabelle 4.8
Zweifärbbarkeit (Bipartitheit)
TwoColor
Tabelle 4.8
Fragen und Antworten
4.1
Fragen und Antworten
Frage: Warum packen wir nicht alle Algorithmen in Graph.java? Antwort: Wir könnten natürlich einfach alle Abfragemethoden (und alle von ihnen benötigten privaten Datenfelder und Methoden) zur Definition des grundlegenden abstrakten Datentyps Graph hinzufügen. Wir würden dann zwar von den Vorzügen unserer gewählten Datenabstraktion profitieren, müssten aber auch einige schwerwiegende Nachteile in Kauf nehmen, da die Welt der Graphenverarbeitung weitaus vielseitiger ist als die grundlegenden Datenstrukturen, die wir in Abschnitt 1.3 besprochen haben. Zu den wichtigsten dieser Nachteile zählen:
– Es gibt viel mehr graphenverarbeitende Operationen zu implementieren, als wir in einer einzigen API akkurat definieren können. – Einfache graphenverarbeitende Aufgaben müssen die gleiche API verwenden wie komplizierte Aufgaben. – Eine Methode kann auf ein Feld zugreifen, das eigentlich für eine andere Methode definiert wurde, was dem Kapselungsprinzip widerspricht, dem wir gerne folgen würden. Diese Situation ist nicht unüblich: APIs dieser Art werden allgemein als breite Schnittstelle bezeichnet (siehe Abschnitt 1.2). In einem Kapitel, das sich den graphenverarbeitenden Algorithmen widmet, wäre eine API dieser Art im wahrsten Sinne des Wortes breit. Frage: Benötigen wir für SymbolGraph wirklich zwei Durchläufe? Antwort: Nein. Sie könnten einen zusätzlichen Faktor lg N in Kauf nehmen und adj() als ST statt als Bag unterstützen. Sie finden eine dementsprechende Implementierung in unserem Buch Einführung in die Programmierung mit Java.
599
4
Graphen
4.1 1.
Geben Sie die maximale Anzahl von Kanten in einem Graphen mit V Knoten und ohne parallele Kanten an. Geben Sie die minimale Anzahl der Kanten in einem Graphen mit V Knoten an, von denen keiner isoliert ist.
2.
Zeichnen Sie analog zu Abbildung 4.9 die AdjaV zenzlisten, die vom Eingabestromkonstruktor von Graph für die Datei tinyGex2.txt erstellt werden (Abbildung 4.27).
tinyGex2.txt
12 16 8 2 1 0 3 10 7 7 11 2 6 5 5 5 8 4
E 4 3 11 6 6 3 11 8 8 0 2 2 10 0 1 1
3.
Erzeugen Sie einen Kopierkonstruktor für Graph, der als Eingabe einen Graphen G übernimmt und eine neue Kopie des Graphen erzeugt und initialisiert. Die Änderungen, die ein Client an G vornimmt, sollten sich nicht auf den neu erzeugten Graphen auswirken.
4.
Ergänzen Sie Graph um eine Methode hasEdge(), die zwei int-Argumente v und w entgegennimmt und true zurückliefert, wenn der Graph eine Kante v-w beinhaltet, beziehungsweise andernfalls false. Abbildung 4.27: Datei tinyGex2.txt
5.
600
Allgemeine Übungen
Ändern Sie Graph so, dass parallele und reflexive mit Graph Kanten nicht gestattet sind.
6.
Betrachten Sie einen Graphen mit vier Knoten und den Kanten 0-1, 1-2, 2-3 und 3-0. Zeichnen Sie ein Array der Adjazenzlisten, die mit dem Aufruf von addEdge() für diese Kanten nicht hätten erstellt werden können, unabhängig von der Ordnung.
7.
Entwickeln Sie einen Testclient für Graph, der einen Graphen vom Eingabestrom einliest, der als Befehlszeilenargument genannt wird, und ihn dann mit toString() ausgibt.
8.
Entwickeln Sie eine Implementierung für die Search-API in Tabelle 4.5, die wie im Text beschrieben UF verwendet.
9.
Erstellen Sie analog zu Abbildung 4.15 ein ausführliches Ablaufprotokoll des Aufrufs dfs(0) für den Graphen, der vom Eingabestromkonstruktor von Graph für die Datei tinyGex2.txt erstellt wurde (siehe Übung 4.1.2). Zeichnen Sie den Baum, der von edgeTo[] repräsentiert wird.
10.
Beweisen Sie, dass jeder zusammenhängende Graph einen Knoten hat, dessen Entfernen (einschließlich aller benachbarten Kanten) nicht den Zusammenhang des Graphen zerstört, und schreiben Sie eine Methode, die einen solchen Knoten mithilfe der Tiefensuche findet. Hinweis: Suchen Sie einen Knoten, dessen Nachbarknoten alle markiert sind.
Allgemeine Übungen
11.
Zeichnen Sie den von edgeTo[] repräsentierten Baum nach dem Aufruf von bfs(G, 0) in Algorithmus 4.2 für den Graphen, der vom Eingabestromkonstruktor von Graph für die Datei tinyGex2.txt erstellt wurde (siehe Übung 4.1.2).
12.
Was verrät uns der Baum der Breitensuche über die Distanz von v zu w, wenn keiner der beiden ein Wurzelknoten ist?
13.
Ergänzen Sie die API und Implementierung von BreadthFirstPaths um eine Methode distTo(), die die Anzahl der Kanten auf dem kürzesten Pfad vom Startknoten zu einem gegebenen Knoten zurückliefert. Eine distTo()-Abfrage sollte nur konstante Zeit benötigen.
14.
Angenommen, Sie verwenden bei einer Breitensuche einen Stapel anstelle einer Warteschlange. Können Sie dann noch die kürzesten Pfade berechnen?
15.
Ändern Sie den Eingabestromkonstruktor für Graph, um auch Adjazenzlisten von der Standardeingabe zuzulassen (ähnlich wie bei SymbolGraph), wie im Beispiel tinyGadj.txt in Abbildung 4.28. Nach Angabe der Knoten- und Kantenanzahl werden zeilenweise die Knoten einschließlich der Liste ihrer Nachbarknoten angegeben.
die gleichen Listen wie für die KantenlisteEingabe, aber mit unterschiedlicher Reihenfolge innerhalb der Listen
tinyGadj.txt
V
13 E 13 0 1 2 5 6 3 4 5 4 5 6 7 8 9 10 11 12 11 12
Reihenfolge in der Liste ist die Umkehrung der Eingabe
zweite Repräsentation von jeder Kante wird rot dargestellt Abbildung 4.28: Ausgabe des Adjazenzlisten-Eingabeformats 16.
Die Exzentrizität (eccentricity) eines Knotens v ist die Länge des kürzesten Pfades von diesem Knoten zu dem am weitesten davon entfernt liegenden Knoten. Der Durchmesser (diameter) eines Graphen ist die maximale Exzentrizität der Knoten. Der Radius (radius) eines Graphen ist die kleinste Exzentrizität der Knoten. Ein Mittelpunkt (center) ist ein Knoten, dessen Exzentrizität der Radius ist. Implementieren Sie die folgende API:
601
4
Graphen
public class GraphProperties
602
GraphProperties(Graph G)
Konstruktor (Ausnahme, wenn G nicht zusammenhängend ist)
int
eccentricity (int v)
Exzentrizität von G
int
diameter()
Durchmesser von G
int
radius()
Radius von G
int
center()
Mittelpunkt von G
17.
Die Taillenweite (girth) eines Graphen ist die Länge seines kürzesten Zyklus. Wenn ein Graph azyklisch ist, dann ist seine Taillenweite unendlich. Fügen Sie eine Methode girth() zu GraphProperties hinzu, die die Taillenweite des Graphen zurückliefert. Hinweis: Führen Sie die Breitensuche von jedem Knoten aus durch. Der kürzeste Zyklus, der s enthält, ist ein kürzester Pfad von s zu einem Knoten v plus die Kante von v zurück zu s.
18.
Erstellen Sie analog Abbildung 4.23 ein ausführliches Ablaufprotokoll von CC, um die Zusammenhangskomponenten in dem Graphen zu finden, der vom Eingabestromkonstruktor von Graph für die Datei tinyGex2.txt erstellt wird (siehe Übung 4.1.2).
19.
Erstellen Sie analog der Abbildungen in diesem Abschnitt ein ausführliches Ablaufprotokoll von Cycle, um einen Zyklus in dem Graphen zu finden, der vom Eingabestromkonstruktor von Graph für die Datei tinyGex2.txt erstellt wurde (siehe Übung 4.1.2). Welche Wachstumsordnung weist die Ausführungszeit des Cycle-Konstruktors im schlimmsten Fall auf?
20.
Erstellen Sie analog der Abbildungen in diesem Abschnitt ein ausführliches Ablaufprotokoll von TwoColor, um eine Zweifärbbarkeit in einem Graphen zu finden, der vom Eingabestromkonstruktor von Graph für die Datei tinyGex2.txt erstellt wird (siehe Übung 4.1.2). Wie sieht die Wachstumsordnung von der Ausführungszeit des TwoColor-Konstruktors im schlimmsten Fall aus?
21.
Führen Sie SymbolGraph mit movies.txt aus, um die Kevin-Bacon-Zahl der diesjährigen Oscar-nominierten Schauspieler zu ermitteln.
22.
Schreiben Sie ein Programm BaconHistogram, das ein Histogramm von KevinBacon-Zahlen ausgibt, um zu erfahren, wie viele Schauspieler aus movies.txt eine Bacon-Zahl von 0, 1, 2, 3 ... haben. Sehen Sie eine Kategorie für die Schauspieler vor, die eine unendliche Zahl haben (d.h., die nicht mit Kevin Bacon verbunden sind).
23.
Berechnen Sie die Anzahl der Zusammenhangskomponenten in movies.txt, die Größe der größten Komponente und die Anzahl der Komponenten, die kleiner sind als 10. Ermitteln Sie Exzentrizität, Durchmesser, Radius, Mittelpunkt und Taillenweite der größten Komponente im Graphen. Enthält sie Kevin Bacon?
Allgemeine Übungen
24.
Ändern Sie den Algorithmus DegreesOfSeparation, sodass er einen int-Wert y als Argument von der Befehlszeile entgegennimmt und Filme unberücksichtigt lässt, die älter sind als y Jahre.
25.
Schreiben Sie einen SymbolGraph-Client wie DegreesOfSeparation, der eine Tiefensuche anstelle einer Breitensuche verwendet, um Pfade zu finden, die zwei Schauspieler verbinden. Erzeugen Sie dabei eine Ausgabe wie die nachfolgende. % java DegreesOfSeparationDFS movies.txt Source: Bacon, Kevin Query: Kidman, Nicole Bacon, Kevin Mystic River (2003) O’Hara, Jenny Matchstick Men (2003) Grant, Beth ... [123 Filme ] (!) Law, Jude Sky Captain... (2004) Jolie, Angelina Playing by Heart (1998) Anderson, Gillian (I) Cock and Bull Story, A (2005) Henderson, Shirley (I) 24 Hour Party People (2002) Eccleston, Christopher Gone in Sixty Seconds (2000) Balahoutis, Alexandra Days of Thunder (1990) Kidman, Nicole
26.
Bestimmen Sie den Speicherbedarf, den Graph für die Repräsentation eines Graphen mit V Knoten und E Kanten benötigt. Legen Sie das Speicherkostenmodell von Abschnitt 1.4 zugrunde.
27.
Zwei Graphen sind isomorph, wenn es eine Möglichkeit gibt, die Knoten des einen Graphen so umzubenennen, dass er identisch zum anderen ist. Zeichnen Sie alle nicht isomorphen Graphen mit zwei, drei, vier und fünf Knoten.
28.
Ändern Sie den Algorithmus Cycle so, dass er auch dann funktioniert, wenn der Graph reflexive und parallele Kanten enthält.
603
4
Graphen
4.1 29.
Knifflige Übungen Eulersche und Hamilton’sche Zyklen: Betrachten Sie die Graphen, die durch die folgenden vier Kantenmengen definiert werden: 0-1 0-2 0-3 1-3 1-4 2-5 2-9 3-6 4-7 4-8 5-8 5-9 6-7 6-9 7-8 0-1 0-2 0-3 1-3 0-3 2-5 5-6 3-6 4-7 4-8 5-8 5-9 6-7 6-9 8-8 0-1 1-2 1-3 0-3 0-4 2-5 2-9 3-6 4-7 4-8 5-8 5-9 6-7 6-9 7-8 4-1 7-9 6-2 7-3 5-0 0-2 0-8 1-6 3-9 6-3 2-8 1-5 9-8 4-5 4-7
In welchen dieser Graphen gibt es eulersche Zyklen (Zyklen, die jede Kante genau einmal besuchen)? In welchen Hamilton’sche Zyklen (Zyklen, die jeden Knoten genau einmal besuchen)?
604
30.
Graphenaufzählung: Wie viele verschiedene ungerichtete Graphen mit V Knoten und E Kanten (ohne parallele Kanten) gibt es?
31.
Erkennung von parallelen Kanten: Entwickeln Sie einen Algorithmus, der in linearer Zeit die parallelen Kanten in einem Graphen zählt.
32.
Ungerade Zyklen: Beweisen Sie, dass ein Graph genau dann zweifärbbar (bipartit) ist, wenn er keinen Zyklus ungerader Länge enthält.
33.
Symbolgraph: Implementieren Sie einen SymbolGraph, der nur einen Durchlauf vorsieht (es muss kein Graph-Client sein). Das könnte für Ihre Implementierung einen zusätzlichen Faktor von log V für Graphenoperationen, für Symboltabellensuchen bedeuten.
34.
Zweifacher Zusammenhang: Ein Graph ist zweifach zusammenhängend (biconnected), wenn jedes Paar von Knoten über zwei disjunkte Pfade verbunden ist. Ein Gelenkpunkt (articulation point) in einem zusammenhängenden Graphen ist ein Knoten, der den Zusammenhang des Graphen aufbrechen würde, wenn er (und seine darin endenden Kanten) entfernt würden. Beweisen Sie, dass jeder Graph ohne Gelenkpunkte zweifach zusammenhängend ist. Hinweis: Gegeben seien die beiden Knoten s und t und ein sie verbindender Pfad; nutzen Sie die Tatsache, dass keiner der Knoten auf dem Pfad ein Gelenkpunkt sein kann, um zwei disjunkte Pfade zu erstellen, die s und t verbinden.
35.
Kantenzusammenhang (edge connectivity): Eine Brücke in einem Graphen ist eine Kante, die beim Entfernen einen zusammenhängenden Graphen in zwei disjunkte Teilgraphen trennt. Ein Graph ohne Brücken wird als 2-fach kantenzusammenhängend bezeichnet. Entwickeln Sie einen auf der Tiefensuche basierenden Datentyp, um zu entscheiden, ob ein gegebener Graph 2-fach kantenzusammenhängend ist.
36.
Euklidische Graphen: Entwerfen und implementieren Sie eine API EuclideanGraph für Graphen, deren Knoten Punkte in der Ebene sind, die Koordinaten umfassen. Nehmen Sie eine Methode show() auf, die den Graphen mit StdDraw zeichnet.
37.
Bildverarbeitung: Implementieren Sie die Floodfill-Operation auf den impliziten Graphen, definiert durch das Verbinden von benachbarten Punkten, die in einem Bild die gleiche Farbe haben.
Experimente
4.1
Experimente
38.
Zufallsgraphen: Schreiben Sie ein Programm ErdosRenyiGraph, das die ganzzahligen Werte V und E von der Befehlszeile entgegennimmt und einen Graphen erstellt, indem es E Zufallspaare ganzzahliger Werte zwischen 0 und V−1 erzeugt. Hinweis: Dieser Generator erzeugt reflexive und parallele Kanten.
39.
Einfache Zufallsgraphen: Schreiben Sie ein Programm RandomSimpleGraph, das die ganzzahligen Werte V und E von der Befehlszeile entgegennimmt und mit gleicher Wahrscheinlichkeit jeden der möglichen einfachen Graphen mit V Knoten und E Kanten erzeugt.
40.
Dünne Zufallsgraphen: Schreiben Sie ein Programm RandomSparseGraph, das dünne Zufallsgraphen für eine sorgfältig ausgewählte Menge von Werten für V und E erzeugt, sodass Sie damit aussagekräftige empirische Tests auf Graphen ausführen können, die auf dem Erdös-Renyi-Modell basieren.
41.
Euklidische Zufallsgraphen: Schreiben Sie einen EuclideanGraph-Client (siehe Übung 4.1.36) namens RandomEuclideanGraph, der Zufallsgraphen erzeugt, indem er V Zufallspunkte in der Ebene erzeugt, dann jeden Punkt mit allen Punkten verbindet, die innerhalb eines Radius d um diesen Punkt liegen. Hinweis: Der Graph ist mit großer Wahrscheinlichkeit zusammenhängend, wenn d größer ist als der Schwellenwert lg V / πV , und mit großer Wahrscheinlichkeit nicht zusammenhängend, wenn d kleiner ist als dieser Wert.
42.
Zufällige Gittergraphen: Schreiben Sie einen EuclideanGraph-Client namens RandomGridGraph, der Zufallsgraphen erzeugt, indem er Knoten, angeordnet in einem V × V Gitter, mit ihren Nachbarknoten verbindet (siehe Übung 1.5.15). Erweitern Sie Ihr Programm, sodass es R zusätzliche Zufallskanten hinzufügt. Verkleinern Sie bei großem R das Gitter, sodass die Gesamtzahl der Kanten ungefähr bei V bleibt. Fügen Sie eine Option hinzu, sodass eine Zusatzkante von Knoten s zu Knoten t verläuft mit einer Wahrscheinlichkeit, die umgekehrt proportional der euklidischen Distanz zwischen s und t ist.
43.
Konkrete Graphen der realen Welt: Suchen Sie im Web nach einem großen gewichteten Graphen – zum Beispiel eine Landkarte mit Entfernungsangaben, Fernsprechverbindungen mit Angabe der Kosten oder das Preissystem einer Fluggesellschaft. Schreiben Sie ein Programm RandomRealGraph, das einen Graphen erstellt, indem es V Knoten und E Kanten nach dem Zufallsprinzip aus dem Teilgraphen wählt, der durch diese Knoten erzeugt wird.
44.
Zufällige Intervallgraphen: Betrachten Sie eine Menge von V Intervallen auf der reellen Zahlengerade (Paare von reellen Zahlen). Solch eine Menge definiert einen Intervallgraphen (interval graph) mit einem Knoten für jedes Intervall, mit Kanten zwischen den Knoten, wenn sich die Intervalle überlappen (irgendwelche Punkte gemeinsam haben). Schreiben Sie ein Programm, das V Zufallsintervalle der Länge d in dem Einheitsintervall erzeugt und dann den entsprechenden Intervallgraphen erstellt. Hinweis: Verwenden Sie einen BST.
605
4
Graphen
45.
Zufällige Transportgraphen: Ein Transportsystem lässt sich unter anderem mit einer Menge von Knotenfolgen definieren, in der jede Folge einen Pfad definiert, der die Knoten verbindet. Zum Beispiel definiert die Folge 0-9-3-2 die Kanten 09, 9-3 und 3-2. Schreiben Sie einen EuclideanGraph-Client namens RandomTransportation, der unter Verwendung symbolischer Namen einen Graphen aus einer Eingabedatei erzeugt, die aus einer Folge von Symbolen pro Zeile besteht. Konstruieren Sie eine Eingabe, die es Ihnen ermöglicht, mit Ihrem Programm einen Graphen zu erzeugen, der dem Pariser Metro-Plan entspricht.
Für alle Graphenmodelle alle Algorithmen zu testen und alle Parameter zu analysieren, ist nicht realistisch. Schreiben Sie für jedes nachfolgend beschriebene Problem einen Client, der das Problem für jeden gegebenen Eingabegraphen löst, und wählen Sie einen der oben genannten Generatoren, um Experimente für das Graphenmodell auszuführen. Wählen Sie Ihre Experimente sorgfältig, vielleicht auf der Basis von Ergebnissen vorheriger Experimente. Beschreiben Sie explizit, welche Ergebnisse Sie erhalten haben und welche Schlüsse sich daraus ziehen lassen.
606
46.
Pfadlängen in der Tiefensuche: Führen Sie Experimente durch, um empirisch die Wahrscheinlichkeit zu bestimmen, dass DepthFirstPaths einen Pfad zwischen zwei zufällig gewählten Knoten findet, und um die durchschnittliche Länge der gefundenen Pfade für verschiedene Graphenmodelle zu berechnen.
47.
Pfadlängen in der Breitensuche: Führen Sie Experimente durch, um empirisch die Wahrscheinlichkeit zu bestimmen, dass BreadthFirstPaths einen Pfad zwischen zwei zufällig gewählten Knoten findet, und um die durchschnittliche Länge der gefundenen Pfade für verschiedene Graphenmodelle zu berechnen.
48.
Zusammenhangskomponenten: Führen Sie Experimente durch, um empirisch die Verteilung der Anzahl der Komponenten in Zufallsgraphen verschiedener Arten zu bestimmten, indem Sie jeweils eine große Anzahl von Graphen erzeugen und ein Histogramm zeichnen.
49.
Zweifärbbarkeit: Die meisten Graphen sind nicht zweifärbbar, was sich mit der Tiefensuche in der Regel relativ leicht feststellen lässt. Führen Sie empirische Tests durch, um die Anzahl der von TwoColor untersuchten Kanten für verschiedene Graphenmodelle zu analysieren.
4.2 Gerichtete Graphen
4.2
Gerichtete Graphen
Gerichtete Graphen, auch Digraphen (directed graphs, digraphs) genannt, zeichnen sich dadurch aus, dass die Kanten sozusagen „Einbahnstraßen“ sind, d.h. eine bestimmte Richtung haben. Die beiden Knoten, die eine Kante definieren, bilden ein geordnetes Paar mit einer gerichteten Beziehung. Viele Anwendungen lassen sich auf natürliche Weise durch gerichtete Graphen ausdrücken (zum Beispiel Graphen, die das Web, Bedingungen (constraints) bei der Ablaufplanung (scheduling) oder Telefonanrufe repräsentieren). Dieses „Einbahnstraßen“-Prinzip entspricht den natürlichen Beziehungen, ist leicht in unseren Implementierungen umzusetzen und scheint auf den ersten Blick problemlos, wenn auch mit einer komplexeren kombinatorischen Struktur verbunden, was tief greifende Auswirkungen auf unsere Algorithmen hat, da sich die Arbeit mit gerichteten Graphen erheblich von der Arbeit mit ungerichteten Graphen unterscheidet. In diesem Abschnitt betrachten wir klassische Algorithmen zur Analyse und Verarbeitung von gerichteten Graphen.
Tabelle 4.11 Typische Anwendungen für Digraphen Anwendung
Knoten
Kante
Nahrungsnetz
Spezies
Räuber-Beute
Internetinhalte
Seite
Hyperlink
Programm
Modul
externer Verweis
Handy
Telefon
Anruf
Wissenschaft
Aufsatz
Zitat
Finanzmarkt
Aktie
Transaktion
Internet
Rechner
Verbindung
4.2.1 Glossar Unsere Definitionen für gerichtete Graphen unterscheiden sich kaum von denen für ungerichtete Graphen (dies gilt auch für einige unserer Algorithmen und Programme). Dennoch wollen wir sie hier noch einmal wiederholen. Die kleinen Unterschiede in der Formulierung, die den Kantenrichtungen geschuldet sind, drücken strukturelle Eigenschaften aus, die Thema dieses Abschnitts sind.
Definition: Gerichteter Graph Ein gerichteter Graph (auch Digraph genannt) besteht aus einer Menge von Knoten und einer Menge von gerichteten Kanten. Jede gerichtete Kante verbindet ein geordnetes Paar von Knoten.
607
4
Graphen
Wir sagen, dass eine gerichtete Kante vom ersten Knoten im Paar auf den zweiten Knoten im Paar zeigt. Der Ausgangsgrad (outdegree) eines Knotens in einem Digraphen gibt an, wie viele Kanten von dem Knoten ausgehen, und der Eingangsgrad (indegree), wie viele Kanten in dem Knoten enden. Meistens verzichten wir im Zusammenhang mit Kanten in Digraphen auf das Attribut „gerichtet“, vor allem wenn die Unterscheidung aus dem Kontext einwandfrei hervorgeht. Der erste Knoten einer gerichteten Kante wird Anfangsknoten (head) und der zweite Endknoten (tail) genannt. Wir zeichnen gerichtete Kanten als Pfeile, die vom Anfangsknoten zum Endknoten zeigen. Eine Kante, die in einem Digraphen von v nach w zeigt, wird in unserer Notation als v->w ausgedrückt. Wie bei ungerichteten Graphen verarbeitet unser Code parallele und reflexive Kanten, auch wenn unsere Beispiele keine enthalten und wir im Text normalerweise nicht darauf eingehen. Lässt man die Anomalien außer Acht, so gibt es vier verschiedene Möglichkeiten der Beziehung zwischen zwei Knoten in einem Digraphen: keine Kante, eine Kante v->w von v nach w, eine Kante w->v von w nach v oder zwei Kanten, v->w und w->v für Verbindungen in beide Richtungen.
Definition: Pfad und Zyklus im gerichteten Graphen Ein gerichteter Pfad (directed path ) in einem Graphen besteht aus einer Folge von Knoten, in der jeder Knoten über eine (gerichtete ) Kante mit seinem Nachfolgerknoten verbunden ist. Ein gerichteter Zyklus (directed cycle ) ist ein gerichteter Pfad mit mindestens einer Kante, dessen erster und letzter Knoten zusammenfallen. Als einfachen Zyklus (simple cycle) bezeichnen wir dabei einen Zyklus, in dem sich keine Kanten oder Knoten wiederholen (bis auf der erforderlichen Zusammenlegung des ersten und letzten Knotens). Die Länge (length ) eines Pfades oder eines Zyklus ergibt sich aus der Anzahl der Kanten.
Wie bei ungerichteten Graphen gehen wir genegerichtete Kante rell davon aus, dass es sich bei gerichteten gerichteter Zyklus der Knoten Pfaden um einfache gerichtete Pfade handelt, Länge 3 gerichteter es sei denn, wir weisen speziell auf bestimmte Pfad der wiederholte Knoten hin (wie in unserer DefiLänge 4 nition eines gerichteten Zyklus) oder auf allKnoten mit gemein gerichtete Pfade. Wir sprechen davon, Eingangsgrad 3 und Ausgangsdass ein Knoten w von Knoten v aus erreichbar grad 2 (reachable) ist, wenn es einen gerichteten Pfad von v zu w gibt. Außerdem gehen wir von der Abbildung 4.29: Aufbau eines Digraphen Konvention aus, dass jeder Knoten von sich selbst aus erreichbar ist. Abgesehen von diesem Fall, ist die Tatsache, dass w in einem Digraphen von v aus erreichbar ist, kein Beleg dafür, dass v auch von w aus erreichbar ist. Diese Unterscheidung liegt auf der Hand, ist aber von großer Bedeutung, wie wir bald sehen werden.
608
4.2 Gerichtete Graphen
Um die Algorithmen in diesem Abschnitt zu w verstehen, müssen Sie den Unterschied zwischen der Erreichbarkeit in Digraphen und dem Zusammenhang in ungerichteten Graphen kennen. Diese Unterscheidung ist komplizierter, als Sie vielleicht denken. Auch wenn Sie auf einem Blick sehen können, dass zwei Knoten in einem kleinen ungerichteten Graphen verbunden sind, ist ein gerichteter Pfad in einem Digraphen nicht so leicht zu erkennen, v wie das Beispiel in Abbildung 4.30 zeigt. Die Verarbeitung von Digraphen lässt sich mit Abbildung 4.30: Ist in diesem Digraphen w von v aus zu erreichen? dem Umherfahren in einer Stadt vergleichen, in der alle Straßen Einbahnstraßen sind und die Fahrtrichtungen nicht notwendigerweise nach einem bestimmten Plan zugewiesen wurden. In einem solchen Fall von einem Ort zum anderen zu gelangen, kann wirklich eine Herausforderung sein. Zum Glück ist die Standarddatenstruktur, die wir zur Repräsentation von Digraphen verwenden, einfacher als ihr Pendant für ungerichtete Graphen!
4.2.2 Datentyp für Digraphen Die API in Tabelle 4.12 und die Klasse Digraph in zu denen für Graph (Listing 4.1)
Listing
4.12 sind fast identisch
Tabelle 4.12 API für einen Digraphen public class Digraph Digraph(int V)
Erzeugt einen Digraphen mit V Knoten ohne Kanten.
Digraph(In in)
Liest einen Digraphen von einem Eingabestrom in ein.
int
V()
Anzahl von Knoten
int
E()
Anzahl von Ecken
addEdge(int v, int w)
Fügt diesem Digraphen die Kante v->w hinzu.
adj(int v)
Knoten, die über Kanten von v ausgehend mit v verbunden sind.
Digraph
reverse()
Umkehrung dieses Digraphen
String
toString()
String-Repräsentation
void Iterable
609
4
Graphen
Repräsentation Wir verwenden die Adjazenzlisten-Repräsentation, in der eine Kante v->w als Listenknoten repräsentiert wird, der w enthält und in der verketteten Liste liegt, die zu v gehört. Diese Repräsentation entspricht im Wesentlichen der für ungerichtete Graphen, ist aber noch einfacher, da jede Kante nur einmal vorkommt (Abbildung 4.31)
Eingabeformat Der Code für den Konstruktor, der einen Digraphen vom Eingabestrom übernimmt, ist identisch zu dem des Konstruktors in Graph – das Eingabeformat ist das gleiche, aber alle Kanten werden als gerichtete Kanten interpretiert. In dem Format der Kantenliste wird ein Paar v w als Kante v->w interpretiert.
Umkehrung eines Digraphen Digraph ergänzt die API um eine Methode reverse(), die eine Kopie des Digraphen
zurückliefert, bei der alle Kanten umgekehrt sind. Diese Methode wird manchmal bei der Digraphenverarbeitung benötigt, da Clients damit die Kanten finden können, die auf jeden Knoten zeigen, während adj() nur die Knoten zurückliefert, die durch Kanten verbunden sind, die von jedem Knoten ausgehen.
Symbolische Namen Ebenfalls einfach ist es, Clients die Möglichkeit einzuräumen, in Digraphenanwendungen symbolische Namen zu verwenden. Um eine Klasse SymbolDigraph wie SymbolGraph in Listing 4.10 zu implementieren, ersetzen Sie einfach überall Graph durch Digraph. Nehmen Sie sich die Zeit und vergleichen Sie sorgfältig nachfolgenden Code und die Abbildung mit den Pendants für ungerichtete Graphen in Listing 4.1 und Abbildung 4.9. Es lohnt sich! Wir wissen, dass in der Adjazenzlisten-Repräsentation eines ungerichteten Graphen der Knoten w in der Liste von v enthalten ist, wenn v in der Liste von w ist. Diese Symmetrie finden Sie in der Adjazenzlisten-Repräsentation eines Digraphen nicht. Dieser Unterschied hat tief greifende Auswirkungen auf die Verarbeitung von Digraphen.
610
4.2 Gerichtete Graphen
Listing 4.12: Datentyp eines gerichteten Graphen (Digraphen) public class Digraph { private final int V; private int E; private Bag[] adj; public Digraph(int V) { this.V = V; this.E = 0; adj = (Bag[]) new Bag[V]; for (int v = 0; v < V; v++) adj[v] = new Bag(); } public int V() { return V; } public int E() { return E; } public void addEdge(int v, int w) { adj[v].add(w); E++; } public Iterable adj(int v) { return adj[v]; } public Digraph reverse() { Digraph R = new Digraph(V); for (int v = 0; v < V; v++) for (int w : adj(v)) R.addEdge(w, v); return R; } }
Der Digraph-Datentyp ist fast identisch zu dem Graph-Datentyp (14.1), sofern man davon absieht, dass addEdge() die Methode add() nur einmal aufruft und die Instanzmethode reverse() hinzugekommen ist, die eine Kopie zurückliefert, bei der alle Kanten umgekehrt sind. Da sich dieser Code leicht von dem entsprechenden Code für Graph ableiten lässt, verzichten wir hier auf die Methode toString() (Tabelle 4.3) und den Eingabestromkonstruktor (Listing 4.1)
611
4
Graphen
tinyDG.txt
V
13 22 4 2 3 6 0 2 11 12 9 9 8 10 11 4 3 7 8 5 0 6 6 7
E 2 3 2 0 1 0 12 9 10 11 9 12 4 3 5 8 7 4 5 4 9 6
adj[]
5
1
0
3
5
2
3
2
0 1 2 3 4
4
5 6
9
4
6
8
7
9
11
10
0
7 8 9 10 11 12
12 4
12
9
Abbildung 4.31: Digraph-Eingabeformat und Adjazenzlisten-Repräsentation
4.2.3 Erreichbarkeit in Digraphen Unser erster graphenverarbeitender Algorithmus für ungerichtete Graphen war DepthFirstSearch in Listing 4.3, der das Zusammenhangsproblem für einen Startknoten löste, indem er Clients erlaubte, die Knoten zu bestimmen, die mit einem gegebenen Startknoten zu verbinden sind. Der gleiche Code, in dem Graph durch Digraph ersetzt wurde, löst das analoge Problem für Digraphen:
612
4.2 Gerichtete Graphen
Erreichbarkeit von einem Startknoten aus (single-source reachability): Wir möchten für einen gegebenen Digraphen und einen Startknoten s Fragen beantworten wie Gibt es einen gerichteten Pfad von s zu einem gegebenen Zielknoten v?
Algorithmus DirectedDFS in Listing 4.13 ist eine kleine Abwandlung von DepthFirstSearch und implementiert die folgende API:
Tabelle 4.13 API für die Erreichbarkeit in Digraphen public class DirectedDFS
boolean
DirectedDFS(Digraph G, int s)
Findet Knoten in G, die von s aus erreichbar sind.
DirectedDFS(Digraph g, Iterable sources)
Findet Knoten in G, die von sources aus erreichbar sind.
marked(int v)
Ist v erreichbar?
Dank eines zweiten Konstruktors, der eine Liste von Knoten übernimmt, können Clients mit dieser API auch das folgende allgemeinere Problem lösen: Erreichbarkeit von mehreren Startknoten aus (multiple-source reachability): Wir möchten für einen gegebenen Digraphen und eine Menge von Startknoten Fragen beantworten wie Gibt es einen gerichteten Pfad von jedem Knoten in der Menge zu einem gegebenen Zielknoten v?
Mit Fragen dieser Art haben Sie es beispielsweise bei dem klassischen zeichenkettenverarbeitenden Problem in Abschnitt 5.4 zu tun. DirectedDFS löst diese Probleme mit unserem Standardparadigma zur Verarbeitung von Graphen und einer Standardprozedur für die rekursive Tiefensuche. Die Methode dfs() wird rekursiv für jeden Startknoten aufgerufen, wodurch jeder angetroffene Knoten markiert wird.
Satz D: Die Tiefensuche markiert alle Knoten in einem Digraphen, die von einer gegebenen Menge von Startknoten zu erreichen sind, in einer Zeit proportional der Summe der Ausgangsgrade der markierten Knoten. Beweis: Analog zu Satz A.
Um die Funktionsweise dieses Algorithmus für unseren Beispiel-Digraphen besser zu verstehen, sollten Sie das Ablaufprotokoll in Abbildung 4.32 studieren. Es ist etwas einfacher als das für ungerichtete Graphen, da die Tiefensuche an sich ein digraphenverarbeitender Algorithmus ist, mit jeweils einer Repräsentation für jede Kante. Eine Analyse dieses Ablaufprotokolls wird Ihnen helfen, Ihre Kenntnisse der Tiefensuche in Digraphen zu festigen.
613
4
Graphen
Listing 4.13: Erreichbarkeit in Digraphen (Algorithmus 4.4) public class DirectedDFS { private boolean[] marked; public DirectedDFS(Digraph G, int s) { marked = new boolean[G.V()]; dfs(G, s); } public DirectedDFS(Digraph G, Iterable sources) { marked = new boolean[G.V()]; for (int s : sources) if (!marked[s]) dfs(G, s); } private void dfs(Digraph G, int v) { marked[v] = true; for (int w : G.adj(v)) if (!marked[w]) dfs(G, w); } public boolean marked(int v) { return marked[v]; } public static void main(String[] args) { Digraph G = new Digraph(new In(args[0])); Bag sources = new Bag(); for (int i = 1; i < args.length; i++) sources.add(Integer.parseInt(args[i])); DirectedDFS reachable = new DirectedDFS(G, sources); for (int v = 0; v < G.V(); v++) if (reachable.marked(v)) StdOut.print(v + " "); StdOut.println(); } }
% java DirectedDFS tinyDG.txt 1 1 % java DirectedDFS tinyDG.txt 2 0 1 2 3 4 5 % java DirectedDFS tinyDG.txt 1 2 6 0 1 2 3 4 5 6 9 10 11 12
Diese Implementierung der Tiefensuche erlaubt es den Clients zu testen, welche Knoten von einem gegebenen Knoten oder einer gegebenen Menge von Knoten aus zu erreichen sind.
614
4.2 Gerichtete Graphen
dfs(0)
dfs(5)
dfs(4)
dfs(3) prüfe 5
dfs(2) prüfe 0 prüfe 3 2 fertig 3 fertig prüfe 2 4 fertig 5 fertig dfs(1) 1 fertig 0 fertig
Abbildung 4.32: Ablaufprotokoll der Tiefensuche in einem Digraphen, die Knoten findet, die von Knoten 0 aus erreichbar sind.
Mark-and-Sweep-Speicherbereinigung Eine wichtige Anwendung zur Prüfung der Erreichbarkeit von mehreren Startknoten aus finden Sie in typischen Speicherverwaltungssystemen, einschließlich vieler Java-Implementierungen. Ein Digraph, der Objekte durch Knoten und Referenzen auf Objekte durch Kanten repräsentiert, eignet sich sehr gut, um die Speichernutzung eines laufenden Java-
615
4
Graphen
Programms zu modellieren. Bei der Ausführung eines Programms gibt es zu jedem Zeitpunkt Objekte, auf die direkt zugegriffen werden kann, und andere Objekte, die nicht mehr zu erreichen sind und somit wieder dem freien Speicher zugeführt werden können. Eine sogenannte Mark-and-Sweep-Strategie, die grundsätzlich ein Bit pro Objekt für die Speicherbereinigung reserviert, markiert (mark) in regelmäßigen Abständen die potenziell zugreifbaren Objekte mithilfe eines Digraph-Algorithmus wie DirectedDFS, der die Frage nach der Erreichbarkeit beantwortet, und gibt dann in einem Rutsch alle unmarkierten Objekte zur Wiederverwendung frei (sweep). direkt zugreifbare Objekte
Objekte, die entfernt werden können
potenziell zugreifbare Objekte Abbildung 4.33: Szenario einer Speicherbereinigung
Pfadsuche in Digraphen DepthFirstPaths (Algorithmus 4.1) und BreadthFirstPaths (Algorithmus 4.2) sind
ebenfalls elementare Algorithmen zur Verarbeitung von Digraphen. Auch hier können wir mithilfe der gleichen APIs und dem gleichen Code (in dem Graph durch Digraph ersetzt wurde) die folgenden Probleme lösen: Gerichtete Pfade mit einem Startknoten (single-source directed paths): Wir möchten für einen gegebenen Digraphen und einen Startknoten s Fragen beantworten wie Gibt es einen gerichteten Pfad von s zu einem Zielknoten v? Wenn ja, finde den Pfad. Kürzeste gerichtete Pfade mit einem Startknoten (single-source shortest directed paths): Wir möchten für einen gegebenen Digraphen und einen Startknoten s Fragen beantworten wie Gibt es einen gerichteten Pfad von s zu einem Zielknoten v? Wenn ja, finde den kürzesten Pfad (d.h. den Pfad mit den wenigsten Kanten).
Auf der Website zum Buch und in den Übungen am Ende dieses Abschnitts finden Sie die Lösungen in DepthFirstDirectedPaths bzw. BreadthFirstDirectedPaths.
616
4.2 Gerichtete Graphen
4.2.4 Zyklen und azyklische Digraphen Gerichtete Zyklen sind vor allem für Anwendungen, die Digraphen verarbeiten, von großem Interesse. Abbildung 4.34 zeigt, wie schwierig es sein kann, ohne Unterstützung eines Computers in einem typischen Digraphen nach gerichteten Zyklen zu suchen. Theoretisch kann ein Digraph immens viele Zyklen enthalten; in der Praxis jedoch sind wir meistens nur an einigen wenigen interessiert oder wollen nur wissen, ob es überhaupt einen gibt. Um Ihnen zu zeigen, wie wichtig gerichtete Zyklen in der Digraphenverarbeitung sind, betrachten wir im Abbildung 4.34: Enthält dieser Digraph Folgenden als fortlaufendes Beispiel eine typische einen gerichteten Zyklus? Anwendung, in der sich Digraphenmodelle von selbst aufdrängen.
Probleme der Ablaufplanung Ein nahezu universell einsetzbares Problemlösungsmodell zur Erledigung mehrerer Arbeitsschritte oder Aufgaben unter bestimmten vorgegebenen Bedingungen besteht darin, dass man festlegt, wie und in welcher Reihenfolge die einzelnen Schritte auszuführen sind. Zu den Bedingungen, die erfüllt sein müssen, können z.B. der Zeitaufwand oder anderweitiger Ressourcenbedarf der Arbeitsschritte gehören. Die wichtigste Bedingung ist dabei die Vorrangbedingung (precedence constraint), die angibt, dass bestimmte Schritte vor anderen ausgeführt sein müssen. Kombiniert mit weiteren Bedingungen führt dies zu Planungsproblemen aller Art mit jeweils unterschiedlichen Schwierigkeitsgraden. Zwar wurden bisher Tausende von verschiedenen Problemen untersucht, doch immer noch suchen Wissenschaftler für viele Probleme nach besseren Algorithmen. Betrachten wir zum Beispiel einen Studenten, der seine Vorlesungen plant und berücksichtigen muss, dass bestimmte Vorlesungen Voraussetzung für den Besuch anderer Vorlesungen sind (Abbildung 4.35). Algorithms
Linear Algebra
Calculus
Theoretical CS Databases
Introduction to CS Artificial Intelligence
Robotics
Advanced Programming Computational Biology
Machine Learning
Neural Networks
Scientific Computing
Abbildung 4.35: Ablaufplanung mit Vorrangbedingungen
617
4
Graphen
Wenn wir ferner davon ausgehen, dass der Student jeweils nur eine Vorlesung belegen kann, haben wir es mit folgendem Problem zu tun: Ablaufplanung mit Vorrangbedingungen: Gegeben sei eine Menge von zu erledigenden Arbeitsschritten mit Vorrangbedingungen, die angeben, dass bestimmte Arbeitsschritte beendet sein müssen, bevor andere angefangen werden können. Wie können wir die Arbeitsschritte organisieren, sodass sie unter Einhaltung der Bedingungen alle erledigt werden?
Für Probleme dieser Art bietet sich ein Digraphenmodell förmlich an, wobei die Arbeitsschritte durch Knoten und die Vorrangbedingungen durch gerichtete Kanten repräsentiert werden. Aus Platzgründen verwenden wir für unser Beispiel das Standardmodell mit den nummerierten Knoten (Abbildung 4.36). In Digraphen ist die Ablaufplanung, die Vorrangbedingungen berücksichtigen muss, mehr oder Abbildung 4.36: Typisches Digraweniger ein Problem des topologischen Sortierens. phenmodell Topologisches Sortieren: Stellen Sie in einem gegebenen Digraphen eine Knotenordnung her, bei der alle gerichteten Kanten von einem in der Ordnung vorangehenden Knoten zu einem in der Ordnung nachfolgenden Knoten zeigen (oder melden Sie, dass dies nicht möglich ist).
Eine topologische Ordnung für unser Beispielmodell zeigt Abbildung 4.37. Alle Kanten zeigen nach unten, das heißt, dieses Digraphenmodell veranschaulicht deutlich eine Lösung unseres Problems der Ablaufplanung mit Vorrangbedingungen: Der Student kann alle Kursanforderungen erfüllen, wenn er die Vorlesungen in genau dieser Reihenfolge belegt. Diese Anwendung ist nur ein Beispiel von vielen – weitere typische Anwendungen finden Sie in Tabelle 4.14.
Kanten zeigen alle nach unten
Voraussetzungen werden alle erfüllt
Calculus Linear Algebra Introduction to CS Advanced Programming Algorithms Theoretical CS Artificial Intelligence Robotics Machine Learning Neural Networks Databases Scientific Computing
Abbildung 4.37: Topologisches Sortieren
618
Computational Biology
4.2 Gerichtete Graphen
Tabelle 4.14 Typische Anwendungen für topologisches Sortieren Anwendung
Knoten
Kante
Ablaufplanung
Arbeitsschritte
Vorrangbedingungen
Vorlesungsplanung
Vorlesung
Voraussetzungen
Vererbung
Java-Klasse
extends
Tabelle
Zelle
Formel
Symbolische Links
Dateiname
Link
Zyklen in Digraphen Wenn Arbeitsschritt x vor Arbeitsschritt y erledigt sein muss, Arbeitsschritt y vor Arbeitsschritt z und Arbeitsschritt z vor Arbeitsschritt x, dann hat jemand einen Fehler gemacht, da diese drei Bedingungen nicht gleichzeitig erfüllt werden können. Allgemein ausgedrückt bedeutet dies, wenn bei einer Ablaufplanung mit Vorrangbedingungen ein gerichteter Zyklus vorliegt, ist das Problem nicht lösbar. Solche Fehler finden wir jedoch erst, wenn wir das folgende Problem lösen können: Erkennung gerichteter Zyklen: Enthält ein gegebener Digraph einen gerichteten Zyklus? Wenn ja, finde die Knoten eines dieser Zyklen, beginnend von einem beliebigen Knoten zurück zu sich selbst.
Da ein Graph eine exponentielle Anzahl von Zyklen haben kann (siehe Übung 4.2.11), fordern wir lediglich, einen Zyklus nachzuweisen. Für die Ablaufplanung von Arbeitsschritten sowie für viele andere Anwendungen ist es allerdings eine Grundvoraussetzung, dass kein einziger gerichteter Zyklus existiert, weswegen Digraphen ohne gerichtete Zyklen von besonderer Bedeutung sind.
Definition: Gerichteter azyklischer Graph Ein gerichteter azyklischer Graph (directed acyclic graph, DAG), auch azyklischer Digraph genannt, ist ein Digraph, der keine gerichteten Zyklen enthält.
Wer also gerichtete Zyklen erkennen kann, weiß die Antwort auf die folgende Frage: Ist ein gegebener Digraph ein azyklischer Digraph? Eine auf der Tiefensuche basierende Lösung zu entwickeln, ist nicht schwer, was darauf zurückzuführen ist, dass der vom System verwaltete Stapel zum Speichern der rekursiven Aufrufe den „aktuell“ betrachteten gerichteten Pfad repräsentiert (wie der Faden, der Sie bei der Erkundung des Labyrinths nach Trémaux zurück zum Eingang führt). Wenn wir eine gerichtete Kante v->w zu einem Knoten w finden, der auf dem Stapel liegt, haben wir es mit einem Zyklus zu tun, da der Stapel der Beweis für einen gerichteten Pfad von w nach v ist und die Kante v->w den Zyklus vervollständigt. Daraus folgt im Umkehrschluss, dass der Graph azyk-
619
4
Graphen
lisch ist, wenn keine solchen Rückwärtskanten vorliegen. DirectedCycle in Listing 4.14 macht sich diese Idee zunutze und implementiert die folgende API:
Tabelle 4.15 API zur Suche nach gerichteten Zyklen in Digraphen public class DirectedCycle
boolean Iterable
DirectedCycle(Digraph G)
Konstruktor, der einen Zyklus findet
hasCycle()
Hat G einen gerichteten Zyklus?
cycle()
Knoten in einem Zyklus (sofern vorhanden)
Abbildung 4.38: Suche nach einem gerichteten Zyklus in einem Digraphen
Bei der Ausführung von dfs(G, v) folgen wir einem gerichteten Pfad vom Startknoten zu v. Diesen Pfad speichert DirectedCycle in einem knotenindizierten Array onStack[], das die Knoten im Aufrufstapel markiert (indem onStack[v] bei Eintritt in dfs(G, v) auf true gesetzt wird, und beim Verlassen auf false). DirectedCycle verwaltet darüber hinaus ein Array edgeTo[], sodass ein entdeckter Zyklus zurückgeliefert werden kann, und zwar auf die gleiche Weise, wie DepthFirstPaths (Listing 4.5) und BreadthFirstPaths (Listing 4.6) Pfade zurückliefern.
Traversierung mit Tiefensuche und topologisches Sortieren Die Ablaufplanung mit Vorrangbedingungen besteht im Prinzip darin, eine topologische Ordnung für die Knoten eines azyklischen Digraphen zu berechnen (siehe folgende API):
Tabelle 4.16 API für topologisches Sortieren public class Topological
620
Topological(Digraph G)
Konstruktor zum topologischen Sortieren
boolean
isDAG()
Ist G ein azyklischer Digraph?
Iterable
order()
Knoten in topologischer Ordnung
4.2 Gerichtete Graphen
Listing 4.14: Suche nach einem gerichteten Zyklus public class DirectedCycle { private boolean[] marked; private int[] edgeTo; private Stack cycle; private boolean[] onStack;
// die Knoten in einem Zyklus (sofern existent) // die Knoten im Stapel der rekursiven Aufrufe
public DirectedCycle(Digraph G) { onStack = new boolean[G.V()]; edgeTo = new int[G.V()]; marked = new boolean[G.V()]; for (int v = 0; v < G.V(); v++) if (!marked[v]) dfs(G, v); } private void dfs(Digraph G, int v) { onStack[v] = true; marked[v] = true; for (int w : G.adj(v)) if (this.hasCycle()) return; else if (!marked[w]) { edgeTo[w] = v; dfs(G, w); } else if (onStack[w]) { cycle = new Stack(); for (int x = v; x != w; x = edgeTo[x]) cycle.push(x); cycle.push(w); cycle.push(v); } onStack[v] = false; } public boolean hasCycle() { return cycle != null; } public Iterable cycle() { return cycle; } }
Diese Klasse ergänzt unsere rekursive Standardmethode dfs() um ein boolesches Array onStack[], das die Knoten speichert, für die der rekursive Aufruf nicht abgeschlossen wurde. Wird eine Kante v->w zu einem Knoten w gefunden, der auf dem Stapel liegt, ist dies der Beweis, dass ein gerichteter Zyklus existiert, der rekonstruiert werden kann, indem man den edgeTo[]-Referenzen folgt.
v w x cycle
Abbildung 4.39: Ablaufprotokoll der Zyklusberechnung
621
4
Graphen
Satz E: Ein Digraph hat genau dann eine topologische Ordnung, wenn er ein azyklischer Digraph ist. Beweis: Wenn es in dem Digraphen einen gerichteten Zyklus gibt, besteht keine topologische Ordnung. Umgekehrt berechnet der Algorithmus, den wir nachfolgend analysieren, für jeden gegebenen azyklischen Digraphen eine topologische Ordnung.
Bemerkenswerterweise sind wir bereits einem Algorithmus begegnet, der sich für topologisches Sortieren eignet: Wir benötigen nur eine zusätzliche Zeile zu unserer rekursiven Tiefensuche! Als Beweis beginnen wir mit der Klasse DepthFirstOrder aus Listing 4.15. Sie basiert darauf, dass die Tiefensuche jeden Knoten genau einmal besucht. Wenn wir den Knoten, den wir der rekursiven dfs()-Methode als Argument übergeben, in einer Datenstruktur speichern und dann über diese Struktur iterieren, sehen wir alle Knoten des Graphen in der Reihenfolge, die von der Art der Datenstruktur vorgegeben wird und abhängig davon, ob die Speicherung vor oder nach den rekursiven Aufrufen erfolgt. Drei Knotenreihenfolgen sind in typischen Anwendungen von Interesse:
Preorder: Der Knoten wird vor den rekursiven Aufrufen in eine Warteschlange gestellt. Postorder: Der Knoten wird nach den rekursiven Aufrufen in eine Warteschlange gestellt. Umgekehrte Postorder: Der Knoten wird nach den rekursiven Aufrufen auf einem Stapel abgelegt. Ein Ablaufprotokoll von DepthFirstOrder für unseren azyklischen Beispieldigraphen finden Sie in Abbildung 4.40. Dieser Algorithmus ist einfach zu implementieren und unterstützt die Methoden pre(), post() und reversePost(), wie sie von anspruchsvolleren Algorithmen der Graphenverarbeitung benötigt werden. So besteht beispielsweise order() in Topological aus einem Aufruf von reversePost().
622
4.2 Gerichtete Graphen
Postorder ist die Reihenfolge, in der Knoten fertig sind
Preorder ist die Reihenfolge der dfs()-Aufrufe pre dfs(0) dfs(5) dfs(4) 4 fertig 5 fertig dfs(1) 1 fertig dfs(6) dfs(9) dfs(11) dfs(12) 12 fertig 11 fertig dfs(10) 10 fertig prüfe 12 9 fertig prüfe 4 6 fertig 0 fertig prüfe 1 dfs(2) prüfe 0 dfs(3) prüfe 5 3 fertig 2 fertig prüfe 3 prüfe 4 prüfe 5 prüfe 6 dfs(7) prüfe 6 7 fertig dfs(8) prüfe 7 8 fertig prüfe 9 prüfe 10 prüfe 11 prüfe 12
post
Warteschlange
reversePost
Warteschlange
Stapel
umgekehrte Postorder
Abbildung 4.40: Berechnung der durch die Tiefensuche definierten Reihenfolgen in einem Digraphen (Preorder, Postorder und umgekehrte Postorder)
623
4
Graphen
Listing 4.15: Durch Tiefensuche definierte Knotenordnung in einem Digraphen public class DepthFirstOrder { private boolean[] marked; private Queue pre; private Queue post; private Stack reversePost;
// // // //
die Knoten in Preorder die Knoten in Postorder die Knoten in umgekehrter Postorder
public DepthFirstOrder(Digraph G) { pre = new Queue(); post = new Queue(); reversePost = new Stack(); marked = new boolean[G.V()]; for (int v = 0; v < G.V(); v++) if (!marked[v]) dfs(G, v); } private void dfs(Digraph G, int v) { pre.enqueue(v); marked[v] = true; for (int w : G.adj(v)) if (!marked[w]) dfs(G, w); post.enqueue(v); reversePost.push(v); } public Iterable pre() { return pre; } public Iterable post() { return post; } public Iterable reversePost() { return reversePost; } }
Diese Klasse erlaubt es Clients, die Knoten in verschiedenen, durch die Tiefensuche definierten Reihenfolgen zu durchlaufen. Diese Fähigkeit ist bei der Entwicklung anspruchsvoller Algorithmen zur Digraphenverarbeitung sehr nützlich, da die rekursive Natur der Suche es uns ermöglicht, Eigenschaften der Berechnung zu beweisen (siehe beispielsweise Satz F).
624
4.2 Gerichtete Graphen
Listing 4.16: Topologisches Sortieren (Algorithmus 4.5) public class Topological { private Iterable order;
// topologische Ordnung
public Topological(Digraph G) { DirectedCycle cyclefinder = new DirectedCycle(G); if (!cyclefinder.hasCycle()) { DepthFirstOrder dfs = new DepthFirstOrder(G); order = dfs.reversePost(); } } public Iterable order() { return order; } public boolean isDAG() { return order != null;
}
public static void main(String[] args) { String filename = args[0]; String separator = args[1]; SymbolDigraph sg = new SymbolDigraph(filename, separator); Topological top = new Topological(sg.G()); for (int v : top.order()) StdOut.println(sg.name(v)); } }
Dieser DepthFirstOrder- und DirectedCycle-Client gibt eine topologische Ordnung für einen azyklischen Digraphen aus. Der Testclient löst das Problem der Ablaufplanung mit Vorrangbedingungen für einen SymbolDigraph. Die Instanzmethode order() liefert entweder null zurück (für den Fall, dass der gegebene Digraph kein azyklischer Digraph ist), oder einen Iterator, der die Knoten in topologischer Ordnung durchläuft. Wir verzichten auf den Code für SymbolDigraph, da dieser – bis auf die Ersetzung von Graph durch Digraph – identisch zu dem Code für SymbolGraph (Listing 4.10) ist.
625
4
Graphen
Satz F: Die umgekehrte Postorder-Reihenfolge in einem azyklischen Digraphen ist eine topologische Sortierung. Beweis: Betrachten Sie eine beliebige Kante v->w. Einer der drei folgenden Fälle muss bei Aufruf von dfs(v) zutreffen (Abbildung 4.41):
dfs(w) wurde bereits aufgerufen und ist zurückgekehrt (w ist markiert). dfs(w) wurde noch nicht aufgerufen (w ist unmarkiert), sodass v->w bewirkt, dass dfs(w) aufgerufen wird (und zurückkehrt), entweder direkt oder indirekt, bevor dfs(v) zurückkehrt.
dfs(w) wurde aufgerufen und ist noch nicht zurückgekehrt, wenn dfs(v) aufgerufen wird. Der Schlüssel zum Beweis ist, dass dieser Fall in einem azyklischen Digraphen nicht vorkommen kann, da die rekursive Aufrufkette einen Pfad von w zu v impliziert und v->w einen gerichteten Zyklus vervollständigen würde. In den zwei möglichen Fällen wird dfs(w) vor dfs(v) ausgeführt, sodass w bei Postorder vor v erscheint und bei umgekehrter Postorder nach v. Damit zeigt jede Kante v->w wie gewünscht von einem in der Ordnung vorangehenden Knoten zu einem in der Ordnung nachfolgenden Knoten.
% more jobs.txt Algorithms/Theoretical CS/Databases/Scientific Computing Introduction to CS/Advanced Programming/Algorithms Advanced Programming/Scientific Computing Scientific Computing/Computational Biology Theoretical CS/Computational Biology/Artificial Intelligence Linear Algebra/Theoretical CS Calculus/Linear Algebra Artificial Intelligence/Neural Networks/Robotics/Machine Learning Machine Learning/Neural Networks % java Topological jobs.txt "/" Calculus Linear Algebra Introduction to CS Advanced Programming Algorithms Theoretical CS Artificial Intelligence Robotics Machine Learning Neural Networks Databases Scientific Computing Computational Biology Topological (Algorithmus 4.5) ist eine Implementierung, die mithilfe der Tiefensuche einen azyklischen Digraphen topologisch sortiert. Das Ablaufprotokoll sehen Sie in Abbildung 4.41.
626
4.2 Gerichtete Graphen
dfs(5) für den
unmarkierten Nachbarn 5 von 0 ist fertig, bevor dfs(0) ausgeführt wird, d.h. 0->5 zeigt nach oben
dfs(6) für den markierten Nachbarn 6 von 7 war fertig, bevor dfs(7) fertig ist, d.h. 7->6 zeigt nach oben
alle Kanten zeigen nach oben; werden zum topologischen Sortieren umgekehrt
umgekehrte Postorder bedeutet die Umkehrung der Reihenfolge, in der Knoten abgearbeitet werden (von unten nach oben gelesen) Abbildung 4.41: Umgekehrte Postorder in einem azyklischen Digraphen entspricht einem topologischen Sortieren
627
4
Graphen
Satz G: Mit der Tiefensuche können wir einen azyklischen Digraphen in einer Zeit proportional V+E topologisch sortieren. Beweis: Ergibt sich unmittelbar aus dem Code. Mit einer ersten Tiefensuche wird sichergestellt, dass der Graph keine gerichteten Zyklen aufweist, und mit einer zweiten wird die umgekehrte Postorder-Reihenfolge hergestellt. Es müssen jeweils alle Kanten und Knoten untersucht werden, was erklärt, warum die Zeit proportional V+E ist.
Trotz seiner Einfachheit stand dieser Algorithmus viele Jahre lang im Schatten eines intuitiveren Algorithmus, der darauf basierte, eine Warteschlange von Startknoten zu verwalten (siehe Übung 4.2.30). In der Praxis gehen topologisches Sortieren und Zykluserkennung Hand in Hand, wobei die Zykluserkennung sozusagen als Debugging-Tool fungiert. In einer Anwendung zur Arbeitsschrittplanung wäre ein gerichteter Zyklus im zugrunde liegenden Digraphen beispielsweise ein Hinweis darauf, dass ein Fehler vorliegt, der korrigiert werden muss – unabhängig davon, wie der Ablaufplan formuliert wurde. Deshalb ist eine solche Anwendung in der Regel ein dreistufiger Prozess:
Spezifizieren der Aufgaben und der Vorrangbedingungen. Sicherstellen, dass eine mögliche Lösung existiert, indem Zyklen in dem zugrunde liegenden Digraphen erkannt und entfernt werden, bis es keine mehr gibt.
Lösen des Problems der Ablaufplanung mithilfe des topologischen Sortierens. Auf gleiche Weise können Sie alle Änderungen an dem Ablaufplan (mit DirectedCycle) auf Zyklen prüfen, um dann (mit Topological) einen neuen Plan zu berechnen.
4.2.5 Starker Zusammenhang in Digraphen Wir haben uns sorgfältig bemüht, zwischen Erreichbarkeit in Digraphen und Zusammenhang in ungerichteten Graphen zu unterscheiden. In einem ungerichteten Graphen sind zwei Knoten v und w verbunden, wenn es einen Pfad gibt, der sie verbindet – wir können über diesen Pfad von v zu w oder von w zu v gelangen. In einem Digraphen dagegen ist ein Knoten w von einem Knoten v aus erreichbar, wenn es einen gerichteten Pfad von v zu w gibt, was jedoch nicht automatisch impliziert, dass es einen Pfad von w zurück zu v geben muss. Um unsere Untersuchung von Digraphen abzuschließen, betrachten wir das natürliche Analogon zum Zusammenhang in ungerichteten Graphen.
Abbildung 4.42: Stark zusammenhängende Digraphen
628
4.2 Gerichtete Graphen
Definition: Starker Zusammenhang Zwei Knoten v und w heißen stark zusammenhängend (strongly connected ), wenn sie untereinander erreichbar sind, d.h. wenn es einen gerichteten Pfad von v zu w und einen gerichteten Pfad von w zu v gibt. Ein Digraph ist stark zusammenhängend, wenn alle seine Knoten stark zusammenhängend sind.
In Abbildung 4.42 finden Sie diverse Beispiele für stark zusammenhängende Graphen. Wie Sie den Beispielen entnehmen können, spielen Zyklen eine wichtige Rolle für den starken Zusammenhang. Wenn wir noch einmal in Erinnerung rufen, dass ein allgemeiner gerichteter Zyklus ein gerichteter Zyklus ist, dessen Knoten sich wiederholen können, sieht man direkt, dass zwei Knoten genau dann stark zusammenhängend sind, wenn es einen allgemeinen gerichteten Zyklus gibt, der beide enthält. (Beweis: Zusammenstellen der Pfade von v zu w und von w zu v.)
Starke Zusammenhangskomponenten Wie der Zusammenhang in ungerichteten Graphen ist der starke Zusammenhang in Digraphen eine Äquivalenzbeziehung auf der Menge der Knoten, die die folgenden Eigenschaften hat:
Reflexiv: Jeder Knoten v ist mit sich selbst stark zusammenhängend. Symmetrisch: Wenn v und w stark zusammenhängend sind, dann sind auch w und v stark zusammenhängend.
Transitiv: Wenn v und w stark zusammenhängend sind und w und x, dann sind auch v und x stark zusammenhängend.
Als Äquivalenzbeziehung teilt der starke Zusammenhang die Knoten in Äquivalenzklassen und diese Äquivalenzklassen sind maximale Teilmengen der Knoten, die stark zusammenhängend sind, wobei jeder Knoten in genau einer Teilmenge ist. Wir bezeichnen diese Teilmengen als starke Zusammenhangskomponenten (strongly connected components) oder kurz als starke Komponenten (strong Abbildung 4.43: Ein Digraph und seine components). Unser Beispieldigraph tinyDG.txt be- starken Komponenten steht aus fünf starken Komponenten (Abbildung 4.43). Ein Digraph mit V Knoten hat zwischen 1 und V starke Komponenten – ein stark zusammenhängender Digraph hat 1 starke Komponente und ein azyklischer Digraph hat V starke Komponenten. Beachten Sie, dass starke Komponenten über Knoten definiert werden und nicht über Kanten. Einige Kanten verbinden zwei Knoten in derselben starken Komponente; andere Kanten verbinden Knoten aus unterschiedlichen starken Komponenten. Letztere liegen nicht auf einem gerichteten Zyklus. Ebenso wie die Identifikation von Zusammenhangskomponenten wichtig ist für die Verarbeitung ungerichteter Graphen, ist die Identifikation von starken Komponenten wichtig für die Verarbeitung von Digraphen.
629
4
Graphen
Anwendungsbeispiele Starker Zusammenhang ist eine nützliche Reiher Abstraktion, die Ihnen hilft, die Struktur eines Digraphen besser zu verstehen, und die Fisch Schlange die zusammenhängende Knotenmengen (starke Spitzmaus Komponenten) hervorhebt. Starke Komponen- Fuchs ten können beispielsweise Fachbuchautoren Frosch Salamander helfen zu entscheiden, welche Themen sich sinnvoll zusammenfassen lassen, oder ProAlgen Ameise grammierern als Entscheidungshilfe bei der Wurm Mücke Organisation der Programmmodule dienen. Schnecke Abbildung 4.44 zeigt ein Beispiel aus dem Bereich der Ökologie. Es handelt sich dabei Gras um einen Digraphen, der das Nahrungsnetz Abbildung 4.44: Kleine Teilmenge des Nahrungsdurch Verbinden lebender Organismen model- netz-Digraphen liert. Dabei repräsentieren die Knoten die Spezies, und die Kanten von einem Knoten zum anderen geben an, dass Organismen, auf die der Pfeil zeigt, sich von den Organismen ernähren, von denen der Pfeil ausgeht. Wissenschaftliche Untersuchungen solcher Digraphen (mit sorgfältig gewählten Mengen von Spezies und genau dokumentierten Beziehungen) spielen für Ökologen eine wichtige Rolle, um grundlegende Fragen zu unseren Ökosystemen zu beantworten. Starke Komponenten in solchen Digraphen können Ökologen helfen, den Energiefluss in einem Nahrungsnetz besser zu verstehen. Ein weiteres Beispiel finden Sie in Abbildung 4.47, in diesem Fall ein Digraphenmodell des Webinhalts, bei dem die Knoten Seiten repräsentieren und die Kanten Hyperlinks von einer Seite zur nächsten. Starke Komponenten in einem solchen Digraphen können Netzwerktechnikern helfen, die riesige Anzahl von Seiten im Web zur Weiterverarbeitung in handlichere Einheiten zu partitionieren. Weitere Eigenschaften dieser Anwendungen und andere Beispiele werden in den Übungen und auf der Website zum Buch angesprochen.
Tabelle 4.17 Typische Anwendungen für starke Komponenten
630
Anwendung
Knoten
Kante
Web
Seite
Hyperlink
Fachbuch
Thema
Verweis
Software
Modul
Aufruf
Nahrungsnetz
Organismus
Räuber-Beute-Beziehung
4.2 Gerichtete Graphen
Hierfür benötigen wir die folgende API – das Gegenstück für Digraphen zur Klasse CC (Listing 4.8):
Tabelle 4.18 API für starke Komponenten public class SCC SCC(Digraph G)
Vorverarbeitungskonstruktor
boolean
stronglyConnected(int v, int w)
Sind v und w stark zusammenhängend?
int
count()
Anzahl der starken Komponenten
int
id(int v)
Komponentenbezeichner für v (zwischen 0 und count()-1)
Es ist nicht schwer, einen quadratischen Algorithmus zu entwickeln, der starke Komponenten berechnet (siehe Übung 4.2.23), aber (wie üblich) ist ein quadratischer Zeitund Speicherbedarf für riesige Digraphen, wie sie in den gerade beschriebenen praktischen Anwendungen vorkommen, nicht akzeptabel.
Kosaraju-Algorithmus Wie wir in CC (Algorithmus 4.3) gesehen haben, lassen sich Zusammenhangskomponenten in ungerichteten Graphen durch eine einfache Tiefensuche berechnen. Doch wie berechnen wir möglichst effizient starke Komponenten in gerichteten Graphen? Die Implementierung KosarajuSCC aus Listing 4.17, die erstaunlicherweise bis auf einige hinzugekommene Codezeilen zu CC identisch ist, löst diese Aufgabe wie folgt:
Für einen gegebenen Digraphen G berechne mithilfe von DepthFirstOrder die umgekehrte Postorder-Reihenfolge seines „umgekehrten“ Digraphen GR.
Führe eine Standard-Tiefensuche in G aus, aber betrachte die unmarkierten Knoten in der gerade berechneten Reihenfolge und nicht in der normalerweise üblichen numerischen Reihenfolge.
Alle Knoten, die bei einem Aufruf der rekursiven dfs()-Methode im Konstruktor erreicht werden, liegen in einer starken Komponente (!) und können wie in CC identifiziert werden.
631
4
Graphen
Listing 4.17: Kosaraju-Algorithmus zur Berechnung starker Komponenten (Algorithmus 4.6) public class KosarajuSCC { private boolean[] marked; private int[] id; private int count;
// die erreichten Knoten // Komponentenbezeichner // Anzahl der starken Komponenten
public KosarajuSCC(Digraph G) { marked = new boolean[G.V()]; id = new int[G.V()]; DepthFirstOrder order = new DepthFirstOrder(G.reverse()); for (int s : order.reversePost()) if (!marked[s]) { dfs(G, s); count++; } } private void dfs(Digraph G, int v) { marked[v] = true; id[v] = count; for (int w : G.adj(v)) if (!marked[w]) dfs(G, w); } public boolean stronglyConnected(int v, int w) { return id[v] == id[w]; } public int id(int v) { return id[v]; } public int count() { return count; } }
% java KosarajuSCC tinyDG.txt 5 components 1 0 5 4 3 2 11 12 9 10 6 8 7
Diese Implementierung unterscheidet sich von CC (Algorithmus 4.3) nur in dem hervorgehobenen Code (und in der Implementierung von main(), die – bis auf die Änderung von Graph in Digraph und von CC in KosarajuSCC – dem Code von Listing 4.7 entspricht). Um starke Komponenten zu finden, wird zuerst eine Tiefensuche im umgekehrten Digraphen ausgeführt, um eine Knotenordnung (umgekehrte Postorder dieser Suche) zu erzeugen. Diese Knotenordnung wird dann für eine zweite Tiefensuche im ursprünglich gegebenen Digraphen verwendet.
632
4.2 Gerichtete Graphen
Der Kosaraju-Algorithmus ist ein extremes Beispiel für ein Verfahren, das leicht zu programmieren, aber schwer zu verstehen ist. Doch lassen Sie sich davon nicht abschrecken. Wenn Sie den Beweis zu Satz H mithilfe von Abbildung 4.45 Schritt für Schritt nachvollziehen, können Sie sich davon überzeugen, dass der Algorithmus korrekt funktioniert:
Satz H: Bei der Tiefensuche in einem Digraphen G – wobei die markierten Knoten in umgekehrter Postorder betrachtet werden, die das Ergebnis einer Tiefensuche im Digraphen mit umgedrehten Kanten, GR, (Kosaraju-Algorithmus) sind – liegen die Knoten, die bei jedem Aufruf der rekursiven Methode im Konstruktor erreicht werden, in einer starken Komponente. Beweis: Zuerst beweisen wir durch Widerspruch, dass jeder Knoten v, der mit s in einer starken
Zusammenhangskomponente liegt, durch den Aufruf von dfs(G, s) im Konstruktor erreicht wird. Angenommen die Knoten v und s sind stark zusammenhängend und v lässt sich nicht mit dfs(G, s) erreichen. Da es einen Pfad von s zu v gibt, muss v zuvor markiert worden sein. Aber da es einen Pfad von v zu s gibt, wäre s bei Aufruf von dfs(G, v) markiert worden und der Konstruktor würde nicht dfs(G, s) aufrufen – ein Widerspruch.
Als Zweites beweisen wir, dass jeder Knoten v, der durch den Aufruf von dfs(G, s) im Konstruktor erreicht wird, mit s in einer starken Zusammenhangskomponente liegt. Sei v ein Knoten, der durch den Aufruf von dfs(G, s) erreicht wird. Dann gibt es in G einen Pfad von s zu v, sodass nur noch bewiesen werden muss, dass es in G einen Pfad von v zu s gibt. Diese Aussage ist äquivalent zu: in GR gibt es einen Pfad von s zu v. Es muss also nur noch bewiesen werden, dass es in GR einen Pfad von s zu v gibt. Der Knackpunkt dieses Beweises ist, dass die umgekehrte Postorder-Konstruktion impliziert, dass dfs(G, v) bei der Tiefensuche in GR vor dfs(G, s) abgeschlossen sein muss; folglich müssen
wir für dfs(G, v) nur zwei Fälle betrachten: der Aufruf der Methode
erfolgte vor dem Aufruf von dfs(G, s) (und war auch schon vor dem Aufruf von dfs(G, s) beendet).
erfolgte nach dem Aufruf von dfs(G, s) (aber war vor der Beendigung von dfs(G, s) beendet). Der erste Fall ist nicht möglich, da es in GR einen Pfad von v zu s gibt; der zweite Fall impliziert, dass es in GR einen Pfad von s zu v gibt, was den Beweis vervollständigt.
DFS in GR (DepthFirstOrder) . . . v muss vor s dfs(v) . fertig sein, sonst . würde dfs(v) . in G vor dfs(s) v fertig aufgerufen . . . dfs(s) . . . s fertig . . . unmöglich, da GR einen Pfad von v zu s hat
DFS in G (KosarajuSCC) . . Prämisse ist, dass . v von s aus erreichbar dfs(s) . ist, sodass G einen . . Pfad von s zu dfs(v) v haben muss . . . v fertig . . . s fertig . . .
. . .
. . .
dfs(s) . . . dfs(v) . . . v fertig . . . s fertig
GR muss einen Pfad von s zu v haben
Abbildung 4.45: Korrektheitsbeweis für den Kosaraju-Algorithmus
633
4
Graphen
Ein Ablaufprotokoll des Kosaraju-Algorithmus für tinyDG.txt finden Sie in Abbildung 4.46. Die beiden Abläufe der Tiefensuche werden jeweils rechts durch ein Diagramm des Digraphen ergänzt, das veranschaulicht, in welcher Reihenfolge die Knoten abgearbeitet werden. Dem linken Diagramm können Sie entnehmen, dass der umgekehrte Digraph „von unten nach oben gelesen“ die umgekehrte Postorder-Reihenfolge liefert, in der die unmarkierten Knoten in der Tiefensuche des ursprünglichen Digraphen geprüft werden. Der Abbildung zufolge ruft die zweite Tiefensuche zuerst dfs(1) auf (und markiert Knoten 1), ruft dann dfs(0) auf (und markiert 5, 4, 3 und 2), und prüft anschließend die Knoten 2, 4, 5 und 3; dann ruft sie dfs(11) auf (und markiert 11, 12, 9 und 10), prüft anschließend 9, 12 und 10, ruft dann dfs(6) auf (und markiert 6), um schlussendlich dfs(7) aufzurufen, was 7 und 8 markiert. Tiefensuche im umgekehrten Digraphen (ReversePost) Tiefensuche im ursprünglichen Digraphen
prüft unmarkierte Knoten in der Reihenfolge
prüft unmarkierte Knoten in der Reihenfolge
0 1 2 3 4 5 6 7 8 9 10 11 12
1 0 2 4 5 3 11 9 12 10 6 7 8
dfs(0) dfs(6) dfs(7) dfs(8) prüfe 7 8 fertig 7 fertig 6 fertig dfs(2) dfs(4) dfs(11) dfs(9) dfs(12) prüfe 11 dfs(10) prüfe 9 10 fertig 12 fertig prüfe 8 prüfe 6 9 fertig 11 fertig prüfe 6 dfs(5) dfs(3) prüfe 4 prüfe 2 3 fertig prüfe 0 5 fertig 4 fertig prüfe 3 2 fertig 0 fertig dfs(1) prüfe 0 1 fertig prüfe 2 prüfe 3 prüfe 4 umgekehrte Postorder prüfe 5 zur Verwendung prüfe 6 in der zweiten prüfe 7 dfs() prüfe 8 (von unten nach prüfe 9 oben gelesen) prüfe 10 prüfe 11 prüfe 12
dfs(1) 1 fertig dfs(0) dfs(5) dfs(4) dfs(3) prüfe 5 dfs(2) prüfe 0 prüfe 3 2 fertig 3 fertig prüfe 2 4 fertig 5 fertig prüfe 1 0 fertig prüfe 2 prüfe 4 prüfe 5 prüfe 3 dfs(11) prüfe 4 dfs(12) dfs(9) prüfe 11 dfs(10) prüfe 12 10 fertig 9 fertig 12 fertig 11 fertig prüfe 9 prüfe 12 prüfe 10 dfs(6) prüfe 9 prüfe 4 prüfe 0 6 fertig dfs(7) prüfe 6 dfs(8) prüfe 7 prüfe 9 8 fertig 7 fertig prüfe 8
Abbildung 4.46: Kosaraju-Algorithmus zum Finden starker Komponenten in Digraphen
634
starke Komponenten
4.2 Gerichtete Graphen
25 34
0 7
2
10
40 19
29
41
33
15
49
8
44 45 28 1
14 48
22 39 18
21
6
42
13 23 47
31
11
32
12
30 26 27
5
37 9
16
43 4
24 38
3
17
36
35
20
46
Abbildung 4.47: Wie viele starke Komponenten gibt es in diesem Digraphen?
Ein größeres Beispiel (wenn auch nur eine sehr kleine Teilmenge eines Digraphenmodells des Webs) finden Sie in Abbildung 4.47. Der Algorithmus von Kosaraju löst das folgende Analogon zum Zusammenhangsproblem für ungerichtete Graphen, das wir zuerst in Kapitel 1 angesprochen und in Abschnitt 4.1 wieder aufgegriffen haben. Starker Zusammenhang: Wir möchten für einen gegebenen Digraphen Fragen beantworten wie Sind zwei gegebene Knoten stark verbunden? und Wie viele starke Komponenten enthält der Digraph?
Die Frage, ob sich dieses Problem in Digraphen genauso effizient lösen lässt wie das entsprechende Zusammenhangsproblem in ungerichteten Graphen, hat die Wissenschaft lange Zeit beschäftigt (erst in den späten 1970ern wurde von R. E. Tarjan eine Antwort gefunden). Das uns heute eine so einfache Lösung zur Verfügung steht, ist schon erstaunlich.
635
4
Graphen
Satz I: Der Kosaraju-Algorithmus benötigt Vorverarbeitungszeit und Speicher proportional V+E, um Abfragen nach starkem Zusammenhang in einem Digraphen zu unterstützen. Beweis: Der Algorithmus berechnet die Umkehrung des Digraphen und führt zwei Tiefensuchen durch. Jeder dieser drei Schritte benötigt Zeit proportional V+E. Die umgekehrte Kopie des Digraphen benötigt Speicher proportional V+E.
Erreichbarkeit – noch einmal Mithilfe von CC für ungerichtete Graphen können wir aus dem Umstand, dass zwei Knoten v und w verbunden sind, ableiten, dass es einen Pfad von v zu w gibt, der gleichzeitig von w zu v führt. Mithilfe von KosarajuSCC können wir aus dem Umstand dass v und w stark zusammenhängend sind, ableiten, dass es einen Pfad von v zu w und einen (anderen) Pfad von w zu v gibt. Doch was ist mit Knotenpaaren, die nicht stark zusammenhängend sind? Dann kann es einen Pfad von v zu w geben oder einen Pfad von w zu v oder keinen, aber nicht beide. Erreichbarkeit zwischen allen Paaren (all-pairs reachability): Wir möchten für einen gegebenen Digraphen Fragen beantworten wie Gibt es einen gerichteten Pfad von einem gegebenen Knoten v zu einem anderen gegebenen Knoten w?
Bei ungerichteten Graphen ist dieses Problem gleich dem Zusammenhangsproblem; was jedoch gerichtete Graphen betrifft, so unterscheidet es sich erheblich von dem Problem des starken Zusammenhangs. Unsere Implementierung CC weist eine lineare Vorverarbeitungszeit auf, um solche Fragen in konstanter Zeit für ungerichtete Graphen zu beantworten. Lässt sich dies auch für Digraphen erreichen? Diese vordergründig harmlose Frage beschäftigt Experten seit Jahrzehnten. Um die damit verbundenen Schwierigkeiten besser zu verstehen, betrachten wir in Abbildung 4.48 das grundlegende Konzept der transitiven Hülle.
Definition: Transitive Hülle Die transitive Hülle (transitive closure) eines Digraphen G ist ein Digraph mit der gleichen Menge von Knoten, aber nur genau dann einer Kante von v zu w, wenn w von v aus in G erreichbar ist.
Per Konvention ist jeder Knoten von sich selbst aus erreichbar, sodass die transitive Hülle V reflexive Kanten umfasst. Unser Beispieldigraph hat nur 13 gerichtete Kanten, während seine transitive Hülle 102 von 169 möglichen gerichteten Kanten hat. Im Allgemeinen hat die transitive Hülle eines Digraphen viel mehr Kanten als der Digraph selber, und es ist für dünne Graphen durchaus üblich, eine dichte transitive Hülle zu haben. So ist zum Beispiel die transitive Hülle eines gerichteten Zyklus mit V Knoten und V gerichteten Kanten ein vollständiger Digraph mit V2 gerichteten Kanten. Da transitive Hüllen in der Regel dicht sind, repräsentieren wir sie normalerweise mit einer booleschen Matrix, in der der Eintrag in Zeile v und Spalte w genau dann true
636
4.2 Gerichtete Graphen
ist, wenn w von v aus erreichbar ist. Anstatt explizit die transitive Hülle zu berechnen, bedienen wir uns der Tiefensuche, um die folgende API zu implementieren:
ursprüngliche Kante (rot) reflexive Kante (grau)
12 ist von 6 aus
erreichbar
Abbildung 4.48: Transitive Hülle
Tabelle 4.19 API für Erreichbarkeit zwischen allen Paaren public class TransitiveClosure
boolean
TransitiveClosure(Digraph G)
Vorverarbeitungskonstruktor
reachable(int v, int w)
Sind v und w stark zusammenhängend?
Der Code in Listing 4.18 ist eine unkomplizierte Implementierung, die auf den Algorithmus 4.4 (DirectedDFS) zurückgreift. Diese Lösung ist optimal für kleine oder dichte Digraphen, aber gänzlich ungeeignet für große Digraphen, wie sie in der Praxis vorkommen, da der Konstruktor einen Speicherbedarf proportional V2 hat und einen Zeitbedarf proportional V(V+E): Jedes der V DirectedDFS-Objekte belegt Speicher proportional V (alle haben marked[]-Arrays der Größe V und untersuchen E Kanten, um die Markierungen zu berechnen). Im Wesentlichen berechnet und speichert TransitiveClosure die transitive Hülle von G, um Abfragen in konstanter Zeit zu beantworten – Zeile v der Matrix der transitiven Hülle speichert das marked[]-Array für den v-ten Eintrag in DirectedDFS[] in TransitiveClosure. Können wir Abfragen in konstanter Zeit mit deutlich geringerer Vorverarbeitungszeit und deutlich geringerem Speicherbedarf (und
637
4
Graphen
zwar deutlich geringer als quadratisch) beantworten? Eine allgemeine Lösung bietet die Wissenschaft bisher nicht, was schwerwiegende Folgen hat: Bevor dieses Problem nicht gelöst ist, besteht keine Hoffnung auf eine praktikable Lösung für das Problem der Erreichbarkeit zwischen allen Paaren eines gigantisch großen Digraphen wie dem Webgraphen. public class TransitiveClosure { private DirectedDFS[] all; TransitiveClosure(Digraph G) { all = new DirectedDFS[G.V()]; for (int v = 0; v < G.V(); v++) all[v] = new DirectedDFS(G, v); } boolean reachable(int v, int w) { return all[v].marked(w); } } Listing 4.18: Erreichbarkeit zwischen allen Paaren
4.2.6 Zusammenfassung Dieser Abschnitt beschäftigte sich mit gerichteten Kanten und gerichteten Graphen (sogenannten Digraphen), mit besonderer Betonung der Verwandtschaft zwischen Digraphenverarbeitung und den korrespondierenden Problemen bei ungerichteten Graphen. Folgende Themen wurden dabei angesprochen:
Digraph-Nomenklatur Die Vorstellung, dass Repräsentation und Verarbeitungsansatz im Wesentlichen denen für ungerichtete Graphen entsprechen, wenn auch einige Probleme bei Digraphen komplizierter sind.
Zyklen, azyklische Digraphen, topologisches Sortieren und Ablaufplanung mit Vorrangbedingungen
Erreichbarkeit, Pfade und starker Zusammenhang in Digraphen Tabelle 4.20 bietet eine Übersicht über die Implementierungen der hier vorgestellten Digraph-Algorithmen (die bis auf einen Algorithmus alle auf der Tiefensuche basieren). Während sich die behandelten Probleme kurz und einfach beschreiben ließen, reichten die Lösungen, die wir untersucht haben, von einfachen Anpassungen der entsprechenden Algorithmen für ungerichtete Graphen bis zu einer äußerst genialen und überraschenden Lösung. Diese Algorithmen sind zum Teil Ausgangsbasis für einige der komplizierteren Algorithmen, die wir in Abschnitt 4.4 betrachten, wenn wir uns mit kantengewichteten Digraphen befassen.
638
4.2 Gerichtete Graphen
Tabelle 4.20 In diesem Abschnitt besprochene Probleme bei der Digraphenverarbeitung Problem
Lösung
Verweis
Erreichbarkeit von einem und von mehreren Startknoten aus
DirectedDFS
Listing 4.13
Gerichtete Pfade mit einem Startknoten
DepthFirstDirectedPaths
Größtenteils identisch zu Listing 4.5 (Lösung auf der Website zum Buch)
Kürzeste gerichtete Pfade mit einem Startknoten
BreadthFirstDirectedPaths
Größtenteils identisch zu Listing 4.6 (Lösung auf der Website zum Buch)
Erkennung gerichteter Zyklen
DirectedCycle
Listing 4.14
DFS-Knotenordnungen
DepthFirstOrder
Listing 4.15
Ablaufplanung mit Vorrangbedingungen
Topological
Listing 4.16
Topologisches Sortieren
Topological
Listing 4.16
Starker Zusammenhang
KosarajuSCC
Listing 4.17
Erreichbarkeit zwischen allen Paaren
TransitiveClosure
Listing 4.18
639
4
Graphen
4.2
Fragen und Antworten
Frage: Ist eine reflexive Kante ein Zyklus? Antwort: Ja, aber ein Knoten benötigt keine reflexive Kante, um von sich selbst aus erreichbar zu sein.
4.2
640
Allgemeine Übungen
1.
Wie hoch ist die maximale Anzahl von Kanten in einem Digraphen mit V Knoten und ohne parallele Kanten? Wie viele Kanten enthält ein Digraph mit V Knoten mindestens, wenn kein Knoten isoliert ist?
2.
Zeichnen Sie analog zu Abbildung 4.9 die AdjaV zenzlisten, die vom Eingabestromkonstruktor von Digraph für die Datei tinyDGex2.txt (Abbildung 4.49) erstellt werden.
tinyDGex2.txt
12 16 8 2 0 0 3 10 7 7 11 2 6 5 5 3 8 4
E 4 3 5 6 6 3 11 8 8 0 2 2 10 10 1 1
3.
Erzeugen Sie einen Kopierkonstruktor für Digraph, der als Eingabe einen Digraphen G übernimmt und eine neue Kopie des Digraphen erzeugt und initialisiert. Die Änderungen, die ein Client an G vornimmt, sollten sich nicht auf den neu erzeugten Digraphen auswirken.
4.
Ergänzen Sie Digraph um eine Methode hasEdge(), die zwei int-Argumente v und w entgegennimmt und true zurückliefert, wenn der Graph eine Kante v->w beinhaltet.
5.
Ändern Sie Digraph so, dass parallele und refle- mit Digraph xive Kanten nicht gestattet sind.
6.
Entwickeln Sie einen Testclient für Digraph.
7.
Der Eingangsgrad eines Knoten in einem Digraphen beschreibt die Anzahl der gerichteten Kanten, die auf diesen Knoten zeigen. Der Ausgangsgrad eines Knotens in einem Digraphen beschreibt die Anzahl der gerichteten Kanten, die von diesem Knoten ausgehen. Von einem Knoten mit dem Ausgangsgrad 0 ist kein Knoten erreichbar. Ein solcher Knoten wird Senke genannt. Ein Knoten mit dem Eingangsgrad 0, der Quelle genannt wird, ist von keinem anderen Knoten aus erreichbar. Ein Digraph, in dem reflexive Kanten zugelassen sind und jeder Knoten einen Ausgangsgrad von 1 hat, wird Abbildung (Map) genannt (eine Funktion von der Menge der ganzen Zahlen von 0 bis V−1 auf sich selbst). Schreiben Sie ein Programm Degrees.java, das die folgende API implementiert:
Abbildung 4.49: Datei tinyDGex2.txt
Allgemeine Übungen
public class Degrees Degrees(Graph G)
Konstruktor
int
indegree (int v)
Eingangsgrad von v
int
outdegree (int v)
Ausgangsgrad von v
Iterable
sources()
Quellen
Iterable
sinks()
Senken
boolean
isMap()
Ist G eine Abbildung?
8.
Zeichnen Sie alle nicht isomorphen azyklischen Digraphen mit zwei, drei, vier und fünf Knoten (siehe Übung 4.1.27).
9.
Schreiben Sie eine Methode, die prüft, ob eine gegebene Permutation der Knoten eines azyklischen Digraphen eine topologische Ordnung dieses azyklischen Digraphen ist.
10.
Gegeben sei ein azyklischer Digraph. Gibt es eine topologische Ordnung, die nicht das Ergebnis eines Algorithmus mit Tiefensuche sein kann, unabhängig davon, in welcher Reihenfolge die Nachbarknoten zu jedem Knoten gewählt werden? Belegen Sie Ihre Antwort.
11.
Beschreiben Sie eine Familie von dünnen Digraphen, in denen die Anzahl von gerichteten Zyklen exponentiell mit der Anzahl der Knoten ansteigt.
12.
Wie viele Kanten gibt es in der transitiven Hülle eines Digraphen, der aus einem einfachen gerichteten Pfad mit V Knoten und V−1 Kanten besteht?
13.
Liefern Sie die transitive Hülle des Digraphen mit zehn Knoten und folgenden Kanten: 3->7 1->4 7->8 0->5 5->2 3->8 2->9 0->6 4->9 2->6 6->4
14.
Beweisen Sie, dass die starken Komponenten in GR die gleichen sind wie in G.
15.
Welches sind die starken Komponenten eines azyklischen Digraphen?
16.
Was passiert, wenn Sie den Algorithmus von Kosaraju auf einem azyklischen Digraphen ausführen?
17.
Wahr oder falsch: Die umgekehrte Postorder-Reihenfolge eines umgekehrten Graphen ist die gleiche wie die Postorder-Reihenfolge des Graphen.
18.
Berechnen Sie den Speicherbedarf eines Digraph mit V Knoten und E Kanten nach dem Speicherkostenmodell von Abschnitt 1.4.
641
4
Graphen
4.2
642
Knifflige Übungen
19.
Topologisches Sortieren und Breitensuche: Erläutern Sie, warum der folgende Algorithmus nicht notwendigerweise eine topologische Ordnung herstellt. Führen Sie eine Breitensuche aus und benennen Sie die Knoten nach zunehmender Entfernung zu ihrem jeweiligen Startknoten.
20.
Gerichteter Eulerzyklus: Ein Eulerzyklus, auch Eulerzug, Eulertour oder Eulerkreis genannt, ist ein gerichteter Zyklus, der jede Kante genau einmal enthält. Schreiben Sie einen Graph-Client Euler, der einen Eulerzyklus findet oder mitteilt, dass ein solcher Zyklus nicht existiert. Hinweis: Beweisen Sie, dass ein Digraph G nur dann einen gerichteten Eulerzyklus hat, wenn G zusammenhängend ist und jeder Knoten einen Eingangsgrad hat, der seinem Ausgangsgrad entspricht.
21.
Letzter gemeinsamer Vorgänger eines azyklischen Digraphen: Gegeben seien ein azyklischer Digraph und die beiden Knoten v und w. Finden Sie den letzten gemeinsamen Vorgänger (lowest common ancestor, LCA) von v und w. Der LCA von v und w ist ein Vorgänger von v und w, der keine Nachfolger hat, die ebenfalls Vorgänger von v und w sind. Die Berechnung des LCA spielt eine wichtige Rolle bei der Mehrfachvererbung in Programmiersprachen, bei der Analyse von genealogischen Daten (Inzuchtgrad in einem Stammbaumgraphen finden) und bei anderen Anwendungen. Hinweis: Definieren Sie die Höhe eines Knotens v in einem azyklischen Digraphen als die Länge des längsten Pfades von einer Wurzel zu v. Unter den Knoten, die Vorgänger von v und w sind, ist der mit der größten Höhe ein letzter gemeinsamer Vorgänger von v und w.
22.
Pfad mit kürzestem Nachfolgerabstand (shortest ancestral path): Gegeben seien ein azyklischer Digraph und die beiden Knoten v und w. Finden Sie den Pfad mit dem kürzesten Nachfolgerabstand zwischen v und w. Ein Pfad mit kürzestem Nachfolgerabstand zwischen v und w führt über einen gemeinsamen Nachfolger x mit einem kürzesten Pfad von v zu x und einen kürzesten Pfad von w zu x. Der Pfad mit kürzestem Nachfolgerabstand ist der Pfad, dessen Gesamtlänge minimiert ist. Aufwärmübung: Finden Sie einen azyklischen Digraphen, bei dem der kürzeste Pfad zu einem gemeinsamen Nachfolger x führt, der kein erster gemeinsamer Nachfolger ist. Hinweis: Führen Sie die Breitensuche zweimal aus: einmal von v aus und einmal von w aus.
23.
Starke Komponente: Beschreiben Sie einen linearen Algorithmus zur Berechnung der stark zusammenhängenden Komponente, die einen gegebenen Knoten v enthält. Beschreiben Sie auf der Basis dieses Algorithmus einen einfachen quadratischen Algorithmus zur Berechnung der starken Komponenten eines Digraphen.
Knifflige Aufgaben
24.
Hamilton-Pfad in azyklischen Digraphen: Gegeben sei ein azyklischer Digraph. Entwerfen Sie einen linearen Algorithmus, um festzustellen, ob es einen gerichteten Pfad gibt, der jeden Knoten genau einmal besucht. Antwort: Berechnen Sie eine topologische Sortierung und prüfen Sie, ob es ein Kante zwischen jedem Paar aufeinanderfolgender Knoten in der topologischen Ordnung gibt.
25.
Eindeutige topologische Anordnung: Entwerfen Sie einen Algorithmus, um festzustellen, ob ein Digraph eine eindeutige topologische Anordnung aufweist. Hinweis: Ein Digraph hat genau dann eine eindeutige topologische Anordnung, wenn es eine gerichtete Kante zwischen jedem Paar aufeinanderfolgender Knoten in der topologischen Ordnung gibt (d.h., der Digraph hat einen Hamilton-Pfad). Wenn der Digraph mehrere topologische Anordnungen zulässt, erhalten Sie eine zweite topologische Ordnung, indem Sie ein Paar aufeinanderfolgende Knoten tauschen.
26.
Erfüllbarkeitsproblem der Aussagenlogik (2-SAT): Gegeben sei eine boolesche Formel in konjunktiver Normalform mit M Klauseln und N Literalen, wobei jede Klausel genau zwei Literale enthält. Finden Sie eine erfüllende Zuweisung (sofern eine existiert). Hinweis: Bilden Sie einen Implikationsgraphen (implication graph) mit 2N Knoten (einen pro Literal plus sein Komplement). Für jede Klausel x + y sehen Sie Kanten von y’ zu x und von x’ zu y vor. Um die Klausel x + y zu erfüllen: (i) wenn y falsch ist, dann ist x wahr, und (ii) wenn x falsch ist, dann ist y wahr. Behauptung: Die Formel ist genau dann erfüllbar, wenn keine Variable x in der gleichen starken Komponente ist wie ihr Komplement x’. Außerdem erhalten Sie eine erfüllende Zuweisung, wenn Sie den Komponentengraphen des azyklischen Digraphen (kontrahiert jede starke Komponente zu einem einzigen Knoten) topologisch sortieren.
27.
Zählung von Digraphen: Zeigen Sie, dass die Anzahl der verschiedenen Digraphen 2 mit V Knoten und ohne parallele Kanten 2V beträgt. (Wie viele Digraphen gibt es, die V Knoten und E Kanten enthalten?) Berechnen Sie dann eine obere Schranke für den Prozentsatz der Digraphen mit 20 Knoten, die jemals von irgendeinem Computer untersucht werden könnten, unter den Annahmen, dass jedes Elektron im Universum jede Nanosekunde einen Digraphen untersucht, das Universum weniger als 1080 Elektronen hat und das Alter des Universums weniger als 1020 Jahre beträgt.
28.
Zählung von azyklischen Digraphen: Geben Sie eine Formel für die Anzahl der azyklischen Digraphen mit V Knoten und E Kanten.
29.
Arithmetische Ausdrücke: Schreiben Sie eine Klasse zur Auswertung von azyklischen Digraphen, die arithmetische Ausdrücke repräsentieren. Verwenden Sie ein knotenindiziertes Array, um die Werte zu jedem Knoten aufzunehmen. Gehen Sie davon aus, dass Werte, die Blättern entsprechen, festgelegt wurden. Beschreiben Sie eine Familie von arithmetischen Ausdrücken mit der Eigenschaft, dass die Größe des Ausdrucksbaums exponentiell größer ist als die des entsprechen-
643
4
Graphen
den azyklischen Digraphen (d.h., die Ausführungszeit Ihres Programms für den azyklischen Digraphen ist also proportional zum Logarithmus der Ausführungszeit für den Baum). 30.
Warteschlangenbasierte topologische Sortierung: Entwickeln Sie eine Implementierung zum topologischen Sortieren mit einem knotenindizierten Array, das den Eingangsgrad der einzelnen Knoten verwaltet. Initialisieren Sie das Array und eine Warteschlange der Quellen, während Sie einmal alle Kanten durchlaufen (siehe Übung 4.2.7). Führen Sie dann die folgenden Operationen durch, bis die Warteschlange der Quellen leer ist: – Entfernen Sie eine Quelle aus der Warteschlange und kennzeichnen Sie sie. – Dekrementieren Sie die Einträge in dem Array der Eingangsgrade, die dem Zielknoten von jeder Kante des entfernten Knotens entsprechen. – Wenn das Dekrementieren eines Eintrags diesen auf 0 setzt, stellen Sie den entsprechenden Knoten in die Warteschlange der Quellen.
31.
4.2
644
Euklidische Digraphen: Überarbeiten Sie Ihre Lösung zu Übung 4.1.36, um eine API EuclideanDigraph für Graphen zu erzeugen, deren Knoten Punkte in der Ebene sind, sodass Sie mit grafischen Repräsentationen arbeiten können.
Experimente
32.
Zufällige Digraphen: Schreiben Sie ein Programm ErdosRenyiDigraph, das zwei ganzzahlige Werte V und E von der Befehlszeile entgegennimmt und einen Digraphen erstellt, indem es E zufällige Paare von ganzen Zahlen zwischen 0 und V−1 erzeugt. Hinweis: Dieser Generator erzeugt reflexive und parallele Kanten.
33.
Zufällige einfache Digraphen: Schreiben Sie ein Programm RandomDigraph, das zwei ganzzahlige Werte V und E von der Befehlszeile entgegennimmt und mit gleicher Wahrscheinlichkeit jeden der möglichen einfachen Digraphen mit V Knoten und E Kanten erstellt.
34.
Zufällige dünne Digraphen: Überarbeiten Sie Ihre Lösung zu Übung 4.1.40, um ein Programm RandomSparseDigraph zu erstellen, das zufällige dünne Digraphen für eine gut gewählte Menge von Werten für V und E erzeugt, mit denen Sie aussagekräftige empirische Tests durchführen können.
35.
Zufällige euklidische Digraphen: Überarbeiten Sie Ihre Lösung zu Übung 4.1.41, um einen EuclideanDigraph-Client namens RandomEuclideanDigraph zu erzeugen, der jeder Kante eine Zufallsrichtung zuweist.
36.
Zufällige Gitterdigraphen. Überarbeiten Sie Ihre Lösung zu Übung 4.1.42, um einen EuclideanDigraph-Client namens RandomGridDigraph zu erzeugen, der jeder Kante eine Zufallsrichtung zuweist.
Experimente
37.
Konkrete Digraphen der realen Welt: Suchen Sie online nach einem großen Digraphen – vielleicht einen Transaktionsgraphen in einem Onlinesystem oder einen Digraphen, der durch Hyperlinks auf Webseiten definiert wird. Schreiben Sie ein Programm RandomRealDigraph, das einen Graphen erstellt, indem es V Knoten und E gerichtete Kanten nach dem Zufallsprinzip aus dem induzierten Teilgraphen dieser Knoten wählt.
38.
Konkrete azyklische Digraphen der realen Welt: Suchen Sie online nach einem großen azyklischen Digraphen – vielleicht einen, der durch Abhängigkeiten von Klassendefinitionen oder durch Verzeichnisverweise in einem großen Dateisystem definiert wird. Schreiben Sie ein Programm RandomRealDAG, das einen Graphen erstellt, indem es V Knoten und E gerichtete Kanten nach dem Zufallsprinzip aus dem induzierten Teilgraphen dieser Knoten wählt.
Für alle Graphenmodelle alle Algorithmen zu testen und alle Parameter zu analysieren, ist nicht realistisch. Schreiben Sie für jedes nachfolgend beschriebene Problem einen Client, der das Problem für jeden gegebenen Eingabegraphen löst, und wählen Sie einen der oben genannten Generatoren, um Experimente für das Graphenmodell auszuführen. Wählen Sie Ihre Experimente sorgfältig, vielleicht auf der Basis von Ergebnissen vorheriger Experimente. Beschreiben Sie explizit, welche Ergebnisse Sie erhalten haben und welche Schlüsse sich daraus ziehen lassen. 39.
Erreichbarkeit: Führen Sie Experimente durch, um empirisch für verschiedene Digraphenmodelle die durchschnittliche Anzahl der Knoten zu ermitteln, die von einem nach dem Zufallsprinzip gewählten Knoten erreichbar sind.
40.
Pfadlängen in der Tiefensuche: Führen Sie Experimente durch, um empirisch für verschiedene zufällige Digraphenmodelle die Wahrscheinlichkeit zu bestimmen, dass DepthFirstDirectedPaths einen Pfad zwischen zwei zufällig gewählten Knoten findet, und um die durchschnittliche Länge der gefundenen Pfade zu berechnen.
41.
Pfadlängen in der Breitensuche: Führen Sie Experimente durch, um empirisch für verschiedene zufällige Digraphenmodelle die Wahrscheinlichkeit zu bestimmen, dass BreadthFirstDirectedPaths einen Pfad zwischen zwei zufällig gewählten Knoten findet, und um die durchschnittliche Länge der gefundenen Pfade zu berechnen.
42.
Starke Komponenten: Führen Sie Experimente durch, um empirisch die Verteilung der Anzahl der starken Komponenten in zufälligen Digraphen verschiedener Arten zu bestimmen, indem Sie sehr viele Digraphen erzeugen und ein Histogramm zeichnen.
645
4
Graphen
4.3
Minimale Spannbäume
tinyEWG.txt Ein kantengewichteter Graph (edge-weighV 8 ted graph) ist ein Graphenmodell, das jeder E 16 Kante des minimalen Kante ein Gewicht (weight) oder auch Kos4 5 0.35 Spannbaums (schwarz) ten zuordnet. Solche Graphen sind natür4 7 0.37 5 7 0.28 liche Modelle für viele Anwendungen. Ein 0 7 0.16 Beispiel sind die Streckennetzkarten von 1 5 0.32 0 4 0.38 Fluggesellschaften. Hier könnten Kanten 2 3 0.17 die Flugstrecken repräsentieren und die 1 7 0.19 Gewichte wären dann beispielsweise die 0 2 0.26 1 2 0.36 Entfernungen zwischen den Flughäfen oder 1 3 0.29 Kante, die nicht zum die Flugpreise. Oder nehmen wir eine elek2 7 0.34 minimalen Spannbaum gehört (grau) 6 2 0.40 trische Schaltung; die Kanten würden die 3 6 0.52 Drähte darstellen und die Gewichte stün6 0 0.58 den für die Länge bzw. Kosten der Drähte 6 4 0.93 oder die Signalübertragungszeit. In solchen Szenarien ist das vorrangige Ziel natürlich, Abbildung 4.50: Ein kantengewichteter Graph und die Kosten zu reduzieren. Dieser Abschnitt sein minimaler Spannbaum beschäftigt sich ausschließlich mit ungerichteten kantengewichteten Graphenmodellen und analysiert Algorithmen für das damit verbundene Problem der minimalen Spannbäume.
Minimaler Spannbaum Gegeben sei ein ungerichteter kantengewichteter Graph. Finde einen minimalen Spannbaum (minimum spanning tree, MST).
Definition: Minimaler Spannbaum Zur Erinnerung sei noch einmal erwähnt, dass der Spannbaum (spanning tree ) eines Graphen ein zusammenhängender Teilgraph ist, der keine Zyklen aufweist und alle Knoten umfasst. Der minimale Spannbaum (minimum spanning tree, MST ) eines kantengewichteten Graphen ist ein Spannbaum, dessen Gesamtgewicht (d.h. die Summe der Gewichte seiner Kanten) nicht größer ist als das Gewicht irgendeines anderen Spannbaums.
In diesem Abschnitt wollen wir uns mit zwei klassischen Algorithmen zur Berechnung minimaler Spannbäume näher befassen: dem Algorithmus von Prim und dem Algorithmus von Kruskal. Diese Algorithmen sind leicht zu verstehen und vor allem relativ leicht zu implementieren. Sie zählen zu den ältesten und bekanntesten Algorithmen in diesem Buch, die außerdem die modernen Datenstrukturen gut nutzen. Da es für minimale Spannbäume in der Praxis viele wichtige Anwendungen gibt, begann man bereits Anfang des 20. Jahrhunderts nach Algorithmen zu ihrer Berechnung zu suchen, zuerst in Zusammenhang mit Stromverteilungsnetzen und später für den Aufbau von Telefonnetzen.
646
4.3 Minimale Spannbäume
Zurzeit sind diese Algorithmen für den Entwurf vieler verschiedener Netze von großer Bedeutung, z.B. für Kommunikationsnetze, elektrische Netze, hydraulische Netze, Computernetze, Straßennetze, Bahnwegenetze, Flugliniennetze und viele andere. Außerdem eignen sie sich hervorragend zur Analyse von biologischen, chemischen und physikalischen Netzen, wie sie in der Natur anzutreffen sind.
Tabelle 4.21 Typische Anwendungen für minimale Spannbäume Anwendung
Knoten
Kante
Schaltkreis
Komponente
Draht
Fluglinie
Flughafen
Flugroute
Stromverteilung
Kraftwerk
Übertragungsleitungen
Bilderkennung
Merkmale
Abstandsbeziehung
Annahmen Bei der Berechnung von minimalen Spannbäumen können diverse anomale Situationen auftreten, die im Allgemeinen jedoch keine Schwierigkeiten bereiten. Zur Vereinheitlichung halten wir uns an die folgenden Konventionen:
Der Graph ist zusammenhängend. Laut Spannbaumbedingung in unserer Definition muss der Graph zusammenhängend sein, damit ein minimaler Spannbaum vorliegt. In Anlehnung an die grundlegenden Eigenschaften von Bäumen aus Abschnitt 4.1 ließe sich das Problem auch ausdrücken als Suche nach einer den Graphen verbindenden Menge von V−1 Kanten mit minimalem Gewicht. Wenn ein Graph nicht zusammenhängend ist, können wir unsere Algorithmen so anpassen, dass sie den minimalen Spannbaum von den einzelnen zusammenhängenden Komponenten berechnen. Diese minimalen Spannbäume bilden dann den sogenannten minimalen Spannwald (minimum spanning forest) (siehe Übung 4.3.22).
Die Kantengewichte sind nicht notwendigerweise Abstände. Geometrische Intuition hilft manchmal, Algorithmen leichter zu verstehen. Deshalb verwenden wir Beispiele, in denen Knoten durch Punkte in der Ebene und Gewichte durch Abstände repräsentiert werden, wie beim Graphen in Abbildung 4.50. Aber Sie sollten sich darüber im Klaren sein, dass Gewichte auch Zeit oder Kosten oder eine gänzlich andere Variable repräsentieren können und nicht proportional zu einem Abstand sein müssen.
Die Kantengewichte können null oder negativ sein. Wenn die Kantengewichte alle positiv sind, reicht es, den minimalen Spannbaum als den Teilgraphen mit dem minimalen Gesamtgewicht zu definieren, der alle Knoten verbindet und damit einen Spannbaum bildet. Die Spannbaumbedingung in der Definition ist umfassend, sodass sie auch für Graphen gilt, die null oder negative Kantengewichte haben können.
Die Kantengewichte sind alle unterschiedlich. Wenn die Kanten gleiche Gewichte haben können, ist der minimale Spannbaum unter Umständen nicht eindeutig (siehe Übung 4.3.2). Die Möglichkeit von mehreren minimalen Spannbäumen kompliziert
647
4
Graphen
den Korrektheitsbeweis für einige unserer Algorithmen, sodass wir diese Möglichkeit hier ausschließen. Diese Annahme ist jedoch nicht restriktiv, da unsere Algorithmen auch bei gleichen Gewichten funktionieren und keiner Änderungen bedürfen. Insgesamt gehen wir also in diesem Abschnitt davon aus, dass unsere Aufgabe darin besteht, den minimalen Spannbaum eines zusammenhängenden kantengewichteten Graphen mit beliebigen (aber verschiedenen) Gewichten zu finden. kein minimaler Spannbaum, wenn der Graph nicht zusammenhängend ist 4 5 4 6 5 6 1 5 2 3 0 3 1 6 0 2
Gewichte können 0 oder negativ sein 0.61 0.62 0.88 0.11 0.35 0.6 0.10 0.22
4 5 1 0 1 0 1 1 2
kann unabhängig die minimalen Spannbäume der Komponenten berechnen
6 0.62 6 0.88 5 0.02 4 -0.99 6 0 2 0.22 2 0.50 3 0.97 6 0.17
minimaler Spannbaum ist vielleicht nicht eindeutig, wenn Gewichte gleiche Werte haben
Gewichte müssen nicht proportional dem Abstand sein 4 5 1 0 1 0 1 1 2
6 6 5 4 6 2 2 3 6
0.62 0.88 0.02 0.64 0.90 0.22 0.50 0.97 0.17
1 1 2 3
2 3 4 4
1.00 0.50 1.00 0.50
1 1 2 3
2 3 4 4
1.00 0.50 1.00 0.50
Abbildung 4.51: Verschiedene Anomalien minimaler Spannbäume
4.3.1 Zugrunde liegende Prinzipien Bevor wir tiefer in die Materie einsteigen, wollen wir uns noch einmal zwei Eigenschaften von Bäumen aus Abschnitt 4.1 in Erinnerung rufen:
das Hinzufügen einer Kante erzeugt einen Zyklus
Das Hinzufügen einer Kante, die zwei Knoten in einem Baum verbindet, erzeugt einen eindeutigen Zyklus.
Das Entfernen einer Kante aus einem Baum zerlegt ihn in zwei getrennte Teilbäume. Auf diesen beiden Eigenschaften basiert unser Beweis einer wichtigen Eigenschaft minimaler Spannbäume, die zu den hier betrachteten MST-Algorithmen führt.
Abbildung 4.52: Grundlegende Eigenschaften eines Baums
648
das Entfernen einer Kante zerlegt den Baum in zwei Teile
4.3 Minimale Spannbäume
Schnitteigenschaft Diese Eigenschaft, die wir als Schnitteigenschaft (cut property) bezeichnen, ist verbunden mit dem Identifizieren von Kanten, die in dem minimalen Spannbaum eines gegebenen kantengewichteten Graphen vorhanden sein müssen. Dabei teilen wir die Knotenmenge in zwei Teilmengen und untersuchen die Kanten, die den Schnitt kreuzen.
Definition: Schnitt und kreuzende Kante Ein Schnitt (cut) teilt die Knotenmenge eines Graphen in zwei nicht leere, disjunkte Teilmengen. Als kreuzende Kante (crossing edge) in einem Schnitt werden Kanten bezeichnet, die einen Knoten der einen Menge mit einem Knoten der anderen Menge verbinden.
Normalerweise spezifizieren wir einen Schnitt durch Angabe einer Knotenmenge, wobei implizit die Annahme gilt, dass der Schnitt die gegebene Knotenmenge und ihr Komplement betrifft, sodass eine kreuzende Kante eine Kante von einem Knoten in der Menge zu einem Knoten nicht in der Menge ist. In unseren Abbildungen stellen wir Knoten auf einer Seite des Schnitts grau und auf der anderen Seite weiß dar.
kreuzende Kanten, die graue Knoten von weißen trennen, werden rot dargestellt f
e
leichte kreuzende Kante muss Teil eines minimalen Spannbaums sein
Abbildung 4.53: Schnitteigenschaft
Satz J (Schnitteigenschaft): Für einen beliebigen gegebenen Schnitt in einem kantengewichteten Graphen liegt die kreuzende Kante mit dem minimalen Gewicht (auch leichte kreuzende Kante genannt) im minimalen Spannbaum des Graphen. Beweis: Sei e die leichte kreuzende Kante und T der minimale Spannbaum. Der Beweis erfolgt indirekt: Angenommen, e ist nicht in T enthalten. Betrachten wir nun den Graphen, der durch Hinzufügen von e zu T gebildet wird. Dieser Graph weist einen Zyklus auf, der e enthält und mindestens eine weitere kreuzende Kante enthalten muss – sagen wir f, die schwerer ist als e (da e minimales Gewicht hat und alle Kantengewichte unterschiedlich sind). Wir erhalten also einen Spannbaum von eindeutig geringerem Gewicht, wenn wir f löschen und e hinzufügen, was im Widerspruch zu der angenommenen Minimalität von T steht.
Gemäß unserer Annahme, dass Kantengewichte unterschiedlich sind, hat jeder zusammenhängende Graph einen eindeutigen minimalen Spannbaum (Übung 4.3.3) und die Schnitteigenschaft besagt, dass die leichte kreuzende Kante für jeden Schnitt Teil des minimalen Spannbaums sein muss. Abbildung 4.54: Ein Schnitt
4.53 zu Satz J veranschaulicht die Schnitteigen- mit zwei Kanten im minimalen schaft. Beachten Sie, dass die leichte Kante prinzipiell nicht Spannbaum
Abbildung
649
4
Graphen
die einzige Kante im minimalen Spannbaum sein muss, die die beiden Mengen verbindet. Bei typischen Schnitten ist es sogar üblich, dass es mehrere Kanten gibt, die einen Knoten in einer Menge mit einem Knoten in einer anderen Menge verbinden.
Greedy-Algorithmus Die Schnitteigenschaft bildet die Grundlage für die Algorithmen, die wir zur Lösung des MST-Problems betrachten. Diese Algorithmen sind Sonderfälle eines allgemeinen Paradigmas, das wir als Greedy-Algorithmus (greedy algorithm) bezeichnen: Bestimmen Sie eine Kante mithilfe der Schnitteigenschaft als Kante eines minimalen Spannbaums und fahren Sie fort, bis Sie alle seine Kanten gefunden haben. Unsere Algorithmen unterscheiden sich in ihren Ansätzen, die Schnitte zu speichern und die leichten kreuzenden Kanten zu identifizieren, sind aber alle Sonderfälle des folgenden Algorithmus:
Satz K (Greedy-Algorithmus zur Bestimmung des minimalen Spannbaums): Die folgende Methode färbt alle Kanten im minimalen Spannbaum eines jeden kantengewichteten Graphen mit V Knoten schwarz. Davon ausgehend, dass alle Kanten grau gefärbt sind, finde einen Schnitt ohne schwarze Kanten, färbe seine leichte Kante schwarz und fahre fort, bis V−1 Kanten schwarz gefärbt sind. Beweis: Der Einfachheit halber nehmen wir in dieser Diskussion an, dass die Kantengewichte alle verschieden sind, obwohl der Satz auch dann gilt, wenn dies nicht der Fall ist (siehe Übung 4.3.5 ). Der Schnitteigenschaft zufolge ist jede Kante, die schwarz gefärbt ist, Teil des minimalen Spannbaums. Wenn weniger als V−1 Kanten schwarz sind, gibt es einen Schnitt ohne schwarze Kanten (unter der besagten Annahme, dass der Graph zusammenhängend ist). Sobald V−1 Kante schwarz sind, spannen die schwarzen Kanten einen Baum auf.
In Abbildung 4.55 sehen Sie ein typisches Ablaufprotokoll für einen Greedy-Algorithmus. Jede Grafik zeigt einen Schnitt und identifiziert die leichte Kante im Schnitt (fette rote Linie), die dann vom Algorithmus zum minimalen Spannbaum hinzugefügt wird.
Abbildung 4.55: Greedy-Algorithmus für minimale Spannbäume
650
im minimalen Spannbaum
leichte Kante im Schnitt
4.3 Minimale Spannbäume
4.3.2 Datentyp eines kantengewichteten Graphen Als Frage bliebe noch zu klären, wie wir einen kantengewichteten Graphen repräsentieren. Die vielleicht einfachste Vorgehensweise bestünde darin, die einfache Graphenrepräsentation aus Abschnitt 4.1 zu erweitern: In der Adjazenzmatrix-Repräsentation kann die Matrix Kantengewichte anstatt boolescher Werte enthalten; in der Darstellung mittels Adjazenzliste können wir einen Listenknoten definieren, der sowohl ein Knotenals auch ein Gewicht-Feld enthält. (Wie immer konzentrieren wir uns auf dünne Graphen und überlassen Ihnen die Adjazenzmatrix-Repräsentation zur Übung.) Dieser klassische Ansatz besticht durch seine Einfachheit; dennoch werden wir einen anderen Weg gehen, der nicht viel komplizierter ist, den Einsatzbereich unserer Programme erweitert und eine etwas allgemeinere API verlangt, mit der wir Edge-Objekte verarbeiten können.
Tabelle 4.22 API für eine gewichtete Kante public class Edge implements Comparable Edge(int v, int w, double weight)
Initialisierungskonstruktor
double
weight()
Gewicht dieser Kante
int
either()
Einer der beiden Knoten dieser Kante
int
other(int v)
Der andere Knoten
int
compareTo(Edge that)
Vergleicht diese Kante mit e.
toString()
String-Repräsentation
String
Die beiden Methoden either() und other() für den Zugriff auf die Knoten der Kante mögen auf den ersten Blick etwas irritieren – aber ihre Notwendigkeit wird sich Ihnen bei der Analyse des Client-Codes erschließen. Eine Implementierung von Edge finden Sie in Listing 4.20. Sie bildet die Basis für die folgende EdgeWeightedGraph-API, die auf natürliche Weise Edge-Objekte referenziert.
Tabelle 4.23 API für einen kantengewichteten Graphen public class EdgeWeightedGraph EdgeWeigtedGraph(int V)
Erzeugt einen leeren Graphen mit V Knoten.
EdgeWeigtedGraph(In in)
Liest Graphen aus dem Eingabestrom.
int
V()
Anzahl der Knoten
int
E()
Anzahl der Kanten
addEdge(Edge e)
Fügt Kante e zu diesem Graphen hinzu.
Iterable
adj(int v)
Kanten inzident zu v
Iterable
edges()
Alle Kanten dieses Graphen
toString()
String-Repräsentation
void
String
651
4
Graphen
Diese API ist der für Graph (Tabelle 4.2) sehr ähnlich. Die beiden wichtigsten Unterschiede sind, dass sie auf Edge basiert und zusätzlich über die Methode edges() aus Listing 4.19 verfügt, die es Clients ermöglicht, über alle Kanten des Graphen zu iterieren (und alle reflexiven Kanten zu ignorieren). Der Rest der Implementierung von EdgeWeightedGraph in Listing 4.21 gleicht größtenteils der Implementierung für ungewichtete ungerichtete Graphen aus Abschnitt 4.1, wobei die Adjazenzlisten nicht wie bei Graph ganze Zahlen verwalten, sondern Edge-Objekte. Abbildung 4.56 veranschaulicht die Repräsentation des kantengewichteten Graphen, den EdgeWeightedGraph aus der Beispieldatei tinyEWG.txt erstellt. Diese Repräsentation zeigt den Inhalt jedes Bag-Objekts als verkettete Liste (in Anlehnung an die Standardimplementierung von Abschnitt 1.3). Um die Abbildung übersichtlich zu halten, stellen wir jedes Edge-Objekt als ein Paar von int-Werten und einem double-Wert dar. Die eigentliche Datenstruktur ist eine verkettete Liste von Referenzen auf Objekte, die diese Werte enthalten. Auch wenn es zwei Referenzen auf jedes Edge-Objekt gibt (jeweils eine in der Liste für jeden Knoten), gibt es nur ein Edge-Objekt für jede Graphenkante. In der Abbildung werden die Kanten in jeder Liste in der umgekehrten Reihenfolge aufgeführt, in der sie verarbeitet werden, da die Standardimplementierung von verketteten Listen große Ähnlichkeit mit Stapeln hat. Wie in Graph stellen wir durch die Verwendung von Bag-Objekten klar, dass unser Client-Code keine Annahmen bezüglich der Ordnung der Objekte in den Listen macht.
public Iterable edges() { Bag b = new Bag(); for (int v = 0; v < V; v++) for (Edge e : adj[v]) if (e.other(v) > v) b.add(e); return b; } Listing 4.19: Erfassen aller Kanten in einem kantengewichteten Graphen
tinyEWG.txt
V
8 16 4 5 4 7 5 7 0 7 1 5 0 4 2 3 1 7 0 2 1 2 1 3 2 7 6 2 3 6 6 0 6 4
E 0.35 0.37 0.28 0.16 0.32 0.38 0.17 0.19 0.26 0.36 0.29 0.34 0.40 0.52 0.58 0.93
adj[]
6 0 .58
0 2 .26
0 4 .38
0 7 .16
1 3 .29
1 2 .36
1 7 .19
1 5 .32
6 2 .40
2 7 .34
1 2 .36
0 2 .26
3 6 .52
1 3 .29
2 3 .17
6 4 .93
0 4 .38
4 7 .37
1 5 .32
5 7 .28
4 5 .35
6 4 .93
6 0 .58
3 6 .52
6 2 .40
2 7 .34
1 7 .19
0 7 .16
5 7 .28
0 1 2 3 4 5 6 7
Abbildung 4.56: Kantengewichtete Graphenrepräsentation
652
BagObjekte
2 3 .17
4 5 .35 Referenzen auf das gleiche Edge-Objekt
5 7 .28
4.3 Minimale Spannbäume
Listing 4.20: Datentyp einer gewichteten Kante public class Edge implements Comparable { private final int v; // ein Knoten private final int w; // der andere Knoten private final double weight; // Kantengewicht public Edge(int v, int w, double weight) { this.v = v; this.w = w; this.weight = weight; } public double weight() { return weight; } public int either() { return v; } public int other(int vertex) { if (vertex == v) return w; else if (vertex == w) return v; else throw new RuntimeException("Inconsistent edge"); } public int compareTo(Edge that) { if (this.weight() < that.weight()) return -1; else if (this.weight() > that.weight()) return +1; else return 0; } public String toString() { return String.format("%d-%d %.2f", v, w, weight);
}
}
Dieser Datentyp definiert die Methoden either() und other(), sodass Clients, wenn v bekannt ist, mit other(v) den anderen Knoten finden können. Wenn keiner der Knoten bekannt ist, verwenden unsere Clients den idiomatischen Code int v = e.either(), w = e.other(v);, um auf die beiden Knoten eines EdgeObjekts e zuzugreifen.
653
4
Graphen
Listing 4.21: Datentyp eines kantengewichteten Graphen public class EdgeWeightedGraph { private final int V; private int E; private Bag[] adj;
// Anzahl der Knoten // Anzahl der Kanten // Adjazenzlisten
public EdgeWeightedGraph(int V) { this.V = V; this.E = 0; adj = (Bag[]) new Bag[V]; for (int v = 0; v < V; v++) adj[v] = new Bag(); } public EdgeWeightedGraph(In in) // Siehe Übung 4.3.9. public int V() { public int E() {
return V; return E;
} }
public void addEdge(Edge e) { int v = e.either(), w = e.other(v); adj[v].add(e); adj[w].add(e); E++; } public Iterable adj(int v) { return adj[v]; } public Iterable edges() // Siehe Listing 4.19. }
Diese Implementierung verwaltet ein knotenindiziertes Array von Listen von Kanten. Wie bei Graph (Listing 4.1) erscheint jede Kante zweimal. Wenn eine Kante v und w verbindet, wird sie sowohl in der Liste von v als auch in der Liste von w aufgeführt. Die Methode edges() stellt alle Kanten in ein Bag-Objekt (Abbildung 4.56). Die toString()-Implementierung überlassen wir Ihnen als Übung.
Kanten nach Gewicht vergleichen Die API spezifiziert, dass die Klasse Edge die Schnittstelle Comparable implementieren und über eine compareTo()-Implementierung verfügen muss. Die natürliche Reihenfolge der Kanten in einem kantengewichteten Graphen wird durch das Gewicht vorgegeben. Folglich ist die Implementierung von compareTo() relativ einfach.
654
4.3 Minimale Spannbäume
Parallele Kanten Wie in unseren Implementierungen für ungerichtete Graphen erlauben wir auch diesmal parallele Kanten. Wir könnten auch eine kompliziertere Implementierung von EdgeWeightedGraph entwickeln, die parallele Kanten nicht gestattet – vielleicht indem wir für jede Menge von parallelen Kanten jeweils nur die leichteste Kante behalten.
Reflexive Kanten Auch reflexive Kanten sind erlaubt (d.h. Kanten, die Knoten mit sich selbst verbinden). Allerdings werden sie von unserer edges()-Implementierung in EdgeWeightedGraph nicht berücksichtigt, auch wenn sie in der Eingabe oder in der Datenstruktur vorhanden sind. Dieses Weglassen hat keine Auswirkungen auf unsere MST-Algorithmen, da ein minimaler Spannbaum keine reflexive Kante aufweist. Wenn Sie es mit einer Anwendung zu tun haben, in der reflexive Kanten eine Rolle spielen, müssen Sie unseren Code eventuell an die Anforderungen der jeweiligen Anwendung anpassen. Dank unserer Entscheidung, explizite Edge-Objekte zu verwenden, ist unser ClientCode übersichtlich und kompakt. Allerdings hat das einen – wenn auch geringen – Preis: Jeder Adjazenzlistenknoten hat eine Referenz auf ein Edge-Objekt mit redundanten Informationen (alle Knoten in der Adjazenzliste von v haben ein v). Hinzu kommen noch zusätzliche Kosten für Objekt-Overhead. Auch wenn wir nur eine Kopie von jedem Edge-Objekt haben, gibt es jeweils zwei Referenzen darauf. Ein alternativer und weit verbreiteter Ansatz besteht darin, wie in Graph zwei Listenknoten für jede Kante zu verwalten, mit je einem Knoten und dem Kantengewicht in jedem Listenknoten. Auch diese Alternative ist mit zusätzlichen Kosten verbunden – zwei Knoten plus zwei Kopien von dem Gewicht für jede Kante.
4.3.3 API und Testclient für minimale Spannbäume Wie Sie es von der Graphenverarbeitung gewohnt sind, definieren wir zuerst eine API. Der Konstruktor übernimmt einen kantengewichteten Graphen als Argument und die Client-Methoden liefern den minimalen Spannbaum und sein Gewicht. Wie aber repräsentieren wir den minimalen Spannbaum selbst? Der minimale Spannbaum eines Graphen G ist ein Teilgraph von G, der außerdem ein Baum ist, sodass wir eine Reihe von Optionen haben. Die wichtigsten sind
eine Liste von Kanten ein kantengewichteter Graph ein knotenindiziertes Array mit Referenzen auf Elternknoten Um den Clients und unseren Implementierungen größtmögliche Flexibilität bei der Wahl zwischen diesen Alternativen für die verschiedenen Anwendungen zu bieten, haben wir uns für folgende API entschieden:
655
4
Graphen
Tabelle 4.24 API für MST-Implementierungen public class MST
Iterable double
MST(EdgeWeightedGraph G)
Konstruktor
edges()
Alle Kanten des minimalen Spannbaums
weight()
Gewicht des minimalen Spannbaums
Testclient Wie bisher erzeugen wir Beispielgraphen und entwickeln einen Testclient zum Testen unserer Implementierungen. Einen Beispielclient finden Sie in Listing 4.22. Er liest Kanten aus dem Eingabestrom ein, erstellt einen kantengewichteten Graphen, berechnet den minimalen Spannbaum dieses Graphen und gibt die Kanten des minimalen Spannbaums aus sowie dessen Gesamtgewicht. public static void main(String[] args) { In in = new In(args[0]); EdgeWeightedGraph G; G = new EdgeWeightedGraph(in); MST mst = new MST(G); for (Edge e : mst.edges()) StdOut.println(e); StdOut.println(mst.weight()); } Listing 4.22: Testclient für den minimalen Spannbaum
Testdaten Sie finden auf der Website zum Buch die Datei tinyEWG.txt, die den kleinen Beispielgraphen zu den ausführlichen Ablaufprotokollen unserer MST-Algorithmen definiert. Von der Website zum Buch können Sie außerdem die Datei mediumEWG.txt herunterladen, die dem gewichteten Graphen mit 250 Knoten in Abbildung 4.57 zugrunde liegt. Er ist ein Beispiel für einen euklidischen Graphen, bei dem die Knoten durch Punkte in der Ebene und die sie verbindenden Kanten durch Linien repräsentiert werden, wobei Letzteren als Gewichte euklidische Distanzen zugeordnet sind. Solche Graphen sind äußerst nützlich, um Einblicke in das Verhalten von Algorithmen zu minimalen Spannbäumen zu gewinnen, und sie modellieren viele der hier bereits erwähnten typischen praktischen Probleme, wie Straßenkarten oder elektrische Schaltungen. Des Weiteren gibt es auf der Website noch ein größeres Beispiel largeEWG.txt mit 1 Million Knoten. Unser Ziel ist es, den minimalen Spannbaum eines solchen Graphen in einer vertretbaren Zeit zu finden.
656
4.3 Minimale Spannbäume
% 8 4 4 5 0 1 0 2 1 0 1 1 2 6 3 6 6
more tinyEWG.txt 16 5 .35 7 .37 7 .28 7 .16 5 .32 4 .38 3 .17 7 .19 2 .26 2 .36 3 .29 7 .34 2 .40 6 .52 0 .58 4 .93
% java MST tinyEWG.txt 0-7 0.16 1-7 0.19 0-2 0.26 2-3 0.17 5-7 0.28 4-5 0.35 6-2 0.40 1.81
% more mediumEWG.txt 250 1273 244 246 0.11712 239 240 0.10616 238 245 0.06142 235 238 0.07048 233 240 0.07634 232 248 0.10223 231 248 0.10699 229 249 0.10098 228 241 0.01473 226 231 0.07638 ... [1263 weitere Kanten ] % java MST mediumEWG.txt 0 225 0.02383 49 225 0.03314 44 49 0.02107 44 204 0.01774 49 97 0.03121 202 204 0.04207 176 202 0.04299 176 191 0.02089 68 176 0.04396 58 68 0.04795 ... [239 weitere Kanten ] 10.46351
657
4
Graphen
Graph
minimaler Spannbaum
Abbildung 4.57: Ein euklidischer Graph mit 250 Knoten (und 1273 Kanten) und sein minimaler Spannbaum
4.3.4 Der Algorithmus von Prim Als Erstes berechnen wir den minimalen Spannnicht kreuzende auswählbare baum nach dem Algorithmus von Prim, das Kante Kante (rot) (grau) heißt, wir lassen einen einzelnen Baum schrittweise um eine neue Kante wachsen. Beginnen Sie den Baum mit einem beliebigen Knoten und fügen Sie dann sukzessive V−1 Kanten hinzu, wobei Sie bei jedem Schritt die Kante kreuzende Kante mit mit dem geringsten Gewicht wählen (und geringstem Gewicht muss schwarz färben), die einen Knoten im Baum im minimalen Baumkante Spannbaum liegen mit einem Knoten verbindet, der noch nicht (dick schwarz) zum Baum gehört (eine Kante, die den von Baumknoten definierten Schnitt kreuzt). Abbildung 4.58: Der Algorithmus von Prim zur Berechnung minimaler Spannbäume
Satz L: Der Algorithmus von Prim berechnet den minimalen Spannbaum zu jedem zusammenhängenden kantengewichteten Graphen. Beweis: Ergibt sich direkt aus Satz K. Der wachsende Baum definiert einen Schnitt, der keine schwarzen Kanten enthält. Der Algorithmus wählt die kreuzende Kante mit dem geringsten Gewicht, d.h., er färbt in Übereinstimmung mit dem Greedy-Algorithmus sukzessive Kanten schwarz.
658
4.3 Minimale Spannbäume
Diese sehr kurze Beschreibung des Prim-Algorithmus lässt eine wichtige Frage offen: Wie finden wir (effizient) die kreuzende Kante mit minimalem Gewicht? Lösungsvorschläge gibt es mehrere – einige davon werden wir besprechen, nachdem wir eine vollständige Lösung erarbeitet haben, die auf einem sehr einfachen Ansatz basiert.
Datenstrukturen Wir implementieren den Prim-Algorithmus mithilfe von einigen einfachen, bekannten Datenstrukturen. So repräsentieren wir die Knoten im Baum, die Kanten im Baum und die kreuzenden Kanten wie folgt:
Knoten im Baum: Hierfür verwenden wir ein knotenindiziertes boolesches Array marked[], wobei marked[v] den Wert true hat, wenn v im Baum liegt.
Kanten im Baum: Hierfür verwenden wir eine von zwei Datenstrukturen: eine Warteschlange mst, um die Kanten des minimalen Spannbaums zu speichern, oder ein knotenindiziertes Array edgeTo[] für Edge-Objekte, wobei edgeTo[v] das Edge-Objekt ist, das v mit dem Baum verbindet.
Kreuzende Kanten: Hierfür verwenden wir eine Vorrangwarteschlange MinPQ, die Kanten nach Gewicht ordnet (siehe Listing 4.20). Diese Datenstrukturen erlauben uns, die grundsätzliche Frage nach der kreuzenden Kante mit dem geringsten Gewicht direkt zu beantworten.
Die Menge der kreuzender Kanten verwalten Jedes Mal, wenn wir dem Baum eine Kante hinzufügen, fügen wir ihm auch einen Knoten hinzu. Zur Verwaltung der kreuzenden Kanten müssen wir alle Kanten, die von diesem Baumknoten zu allen Nicht-Baumknoten führen, in die Vorrangwarteschlange stellen (wobei wir diese Kanten mit marked[] identifizieren). Doch es sind noch weitere Schritte notwendig: Jede Kante, die den gerade hinzugefügten Knoten mit einem Baumknoten verbindet, der sich bereits in der Vorrangwarteschlange befindet, ist damit nicht mehr auswählbar (sie ist jetzt keine kreuzende Kante mehr, da sie zwei Baumknoten verbindet). Eine Eager-Implementierung des Prim-Algorithmus würde solche Kanten sofort aus der Vorrangwarteschlange entfernen. Zuerst jedoch betrachten wir eine einfachere Lazy-Implementierung des Algorithmus, bei der wir diese Kanten in der Vorrangwarteschlange stehen lassen und erst dann auf Auswählbarkeit testen, wenn wir sie entfernen.
659
4
Graphen
* markiert neue Einträge
kreuzende Kanten (nach Gewicht geordnet)
nicht auswählbare Kanten (grau)
Abbildung 4.59: Ablaufprotokoll des Prim-Algorithmus (Lazy-Version)
4.59 zeigt ein Ablaufprotokoll für unseren kleinen Beispielgraphen tinyEWG.txt. Jedes Diagramm besteht aus einem Graphen und der dazugehörigen Vorrangwarteschlange, direkt nachdem ein Knoten besucht wurde (d.h., nachdem der Knoten dem Baum hinzugefügt wurde und die Kanten seiner Adjazenzliste verarbeitet wurAbbildung
660
4.3 Minimale Spannbäume
den). Der Inhalt der Vorrangwarteschlange wird jeweils neben dem Diagramm geordnet angezeigt; neue Kanten sind an einem Sternchen zu erkennen. Der Algorithmus erstellt den minimalen Spannbaum wie folgt:
Er fügt 0 zum minimalen Spannbaum und alle Kanten der dazugehörigen Adjazenzliste zur Vorrangwarteschlange hinzu.
Er fügt 7 und 0-7 zum minimalen Spannbaum und alle Kanten der dazugehörigen Adjazenzliste zur Vorrangwarteschlange hinzu.
Er fügt 1 und 1-7 zum minimalen Spannbaum und alle Kanten der dazugehörigen Adjazenzliste zur Vorrangwarteschlange hinzu.
Er fügt 2 und 0-2 zum minimalen Spannbaum und die Kanten 2-3 und 6-2 zur Vorrangwarteschlange hinzu. Die Kanten 2-7 und 1-2 sind nicht mehr auswählbar.
Er fügt 3 und 2-3 zum minimalen Spannbaum und die Kante 3-6 zur Vorrangwarteschlange hinzu. Die Kante 1-3 ist nicht mehr auswählbar.
Er fügt 5 und 5-7 zum minimalen Spannbaum und die Kante 4-5 zur Vorrangwarteschlange hinzu. Die Kante 1-5 ist nicht mehr auswählbar.
Er entfernt die nicht auswählbaren Kanten 1-3, 1-5 und 2-7 aus der Vorrangwarteschlange.
Er fügt 4 und 4-5 zum minimalen Spannbaum und die Kante 6-4 zur Vorrangwarteschlange hinzu. Die Kanten 4-7 und 0-4 sind nicht mehr auswählbar.
Er entfernt die nicht auswählbaren Kanten 1-2, 4-7 und 0-4 aus der Vorrangwarteschlange.
Er fügt 6 und 6-2 zum minimalen Spannbaum hinzu. Die anderen Kanten, inzident zu 6, sind nicht mehr auswählbar. Nach Hinzufügen von V Knoten (und V−1 Kanten) ist der minimale Spannbaum vollständig. Die verbleibenden Kanten in der Vorrangwarteschlange sind nicht auswählbar, sodass wir sie nicht noch einmal untersuchen müssen.
Implementierung Nach diesen Erläuterungen ist die Implementierung des Prim-Algorithmus ein Kinderspiel, wie die Implementierung LazyPrimMST in Listing 4.23 zeigt. Analog unseren Implementierungen der Tiefen- und der Breitensuche in den vorhergehenden beiden Abschnitten berechnet sie den minimalen Spannbaum im Konstruktor, sodass Clients Eigenschaften des minimalen Spannbaums mit Client-Methoden abfragen können. Wir verwenden eine private-Methode visit(), die dem Baum einen Knoten hinzufügt, indem sie ihn als besucht markiert, und dann alle seine inzidenten Kanten, die auswählbar sind, in die Vorrangwarteschlange stellt, womit sichergestellt wird, dass die Vorrangwarteschlange alle kreuzenden Kanten von den Baumknoten zu den NichtBaumknoten enthält (eventuell auch einige nicht auswählbare Kanten). Die innere Schleife drückt die sehr kurze Beschreibung des Algorithmus in Code aus: Wir wählen eine Kante aus der Vorrangwarteschlange und fügen sie (wenn sie auswählbar ist) zu dem Baum hinzu zusammen mit dem neuen Knoten, zu dem sie führt, wobei wir die
661
4
Graphen
Menge der kreuzenden Kanten durch den Aufruf von visit() mit diesem Knoten als Argument aktualisieren. Die Methode weight() erfordert, dass wir über die Baumkanten iterieren, um die Kantengewichte zu addieren (Lazy-Ansatz), oder dass wir eine laufende Gesamtsumme in einer Instanzvariablen speichern (Eager-Ansatz). Diese Methode überlassen wir Ihnen in Übung 4.3.31 zum Selbststudium.
Listing 4.23: Lazy-Version des MST-Algorithmus von Prim public class LazyPrimMST { private boolean[] marked; private Queue mst; private MinPQ pq;
// MST-Knoten // MST-Kanten // kreuzende (und nicht auswählbare) Kanten
public LazyPrimMST(EdgeWeightedGraph G) { pq = new MinPQ(); marked = new boolean[G.V()]; mst = new Queue(); visit(G, 0); // setzt voraus, dass G zusammenhängend ist (Übung 4.3.22) while (!pq.isEmpty()) { Edge e = pq.delMin(); // Holt Kante mit geringstem Gewicht int v = e.either(), w = e.other(v); // aus Vorrangwarteschlange. if (marked[v] && marked[w]) continue; // Überspringen, falls nicht // auswählbar. mst.enqueue(e); // Kante zum Baum hinzufügen. if (!marked[v]) visit(G, v); // Knoten zum Baum hinzufügen if (!marked[w]) visit(G, w); // (entweder v oder w). } } private void visit(EdgeWeightedGraph G, int v) { // Markiert v und legt alle Kanten von v zu unmarkierten Knoten in Vorrangw. ab. marked[v] = true; for (Edge e : G.adj(v)) if (!marked[e.other(v)]) pq.insert(e); } public Iterable edges() { return mst; } public double weight()
// Siehe Übung 4.3.31.
}
Diese Implementierung des Prim-Algorithmus verwendet eine Vorrangwarteschlange, um die kreuzenden Kanten aufzunehmen, ein knotenindiziertes Array, um Baumknoten zu markieren, und eine Warteschlange, um die Kanten des minimalen Spannbaums zu speichern. Diese Implementierung ist ein Lazy-Ansatz, bei dem wir nicht auswählbare Kanten in der Vorrangwarteschlange lassen.
662
4.3 Minimale Spannbäume
Laufzeit Wie schnell ist der Algorithmus von Prim? Diese Frage ist dank unserer Kenntnisse des Laufzeitverhaltens von Vorrangwarteschlangen nicht schwer zu beantworten.
Satz M: Die Lazy-Version des Prim-Algorithmus benötigt Speicher proportional zu E und Zeit proportional zu E log E (im schlimmsten Fall), um den minimalen Spannbaum eines zusammenhängenden kantengewichteten Graphen mit E Kanten und V Knoten zu berechnen. Beweis: Der Engpass in diesem Algorithmus ist die Anzahl der Vergleiche der Kantengewichte in den Vorrangwarteschlangen-Methoden insert() und delMin(). Die Anzahl der Kanten in der Vorrangwarteschlange ist höchstens E und bestimmt damit die Speicherschranke. Im schlimmsten Fall betragen die Kosten einer Einfügeoperation ∼lg E und die Kosten zum Löschen des Minimums ∼2 lg E (siehe Satz O in Kapitel 2 ). Da höchstens E Kanten eingefügt und gelöscht werden, ergibt sich daraus die Zeitschranke.
Die Oberschranke der Laufzeit ist eher konservativ gewählt, da die Anzahl der Kanten in der Vorrangwarteschlange normalerweise geringer ist als E. Dass es einen solchen einfachen, effizienten und nützlichen Algorithmus für solch eine schwierige Aufgabe gibt, ist schon bemerkenswert. Als Nächstes gehen wir kurz auf einige Verbesserungen ein. Auch hier gilt, dass Sie solche Verbesserungen in performancekritischen Anwendungen lieber Experten überlassen sollten.
4.3.5 Eager-Version des Prim-Algorithmus Um den LazyPrimMST-Algorithmus zu verbessern, verbindet v mit dem Baum könnten wir versuchen, die nicht auswählbaren Kanten aus der Vorrangwarteschlange zu löschen, sodass v die Vorrangwarteschlange ausschließlich die kreuzenden Kanten zwischen den Baumknoten und den w Nicht-Baumknoten enthält. Wir könnten sogar noch mehr Kanten löschen, denn eigentlich gilt unser Interückt w näher an den Baum heran resse ausschließlich den minimalen Kanten von den Nicht-Baumknoten zu einem Baumknoten. Wenn wir Abbildung 4.60: Eager-Version des Primeinen Knoten v zum Baum hinzufügen, besteht die Algorithmus einzige potenzielle Änderung für jeden Nicht-Baumknoten w darin, dass er durch Hinzufügen von v näher an den Baum „heranrückt“. Kurz gesagt, wir müssen nicht alle Kanten von w zu den Baumknoten in der Vorrangwarteschlange speichern, sondern nur die Kante mit dem geringsten Gewicht. Dazu müssen wir prüfen, ob das Hinzufügen von v zum Baum ein Aktualisieren dieses Minimums erforderlich macht (aufgrund einer Kante v-w mit niedrigerem Gewicht), was wir erledigen können, wenn wir die Kanten in der Adjazenzliste von v verarbeiten. Mit anderen Worten, wir verwalten in der Vorrangwarteschlange nur eine Kante für jeden NichtBaumknoten w, und zwar die kürzeste Kante, die ihn mit dem Baum verbindet. Jede längere Kante, die w mit dem Baum verbindet, wird irgendwann nicht auswählbar, sodass keine Notwendigkeit besteht, sie weiterhin in der Vorrangwarteschlange zu halten.
663
4
Graphen
Listing 4.24: Eager-Version des MST-Algorithmus von Prim (Algorithmus 4.7) public class PrimMST { private Edge[] edgeTo; private double[] distTo; private boolean[] marked; private IndexMinPQ pq;
// // // //
kürzeste Kante zu Baumknoten distTo[w] = edgeTo[w].weight() true, wenn v im Baum auswählbare kreuzende Kanten
public PrimMST(EdgeWeightedGraph G) { edgeTo = new Edge[G.V()]; distTo = new double[G.V()]; marked = new boolean[G.V()]; for (int v = 0; v < G.V(); v++) distTo[v] = Double.POSITIVE_INFINITY; pq = new IndexMinPQ(G.V()); distTo[0] = 0.0; pq.insert(0, 0.0); while (!pq.isEmpty()) visit(G, pq.delMin());
// Initialisiert pq mit 0, Gewicht mit 0. // Fügt nächsten Knoten zum Baum.
} private void visit(EdgeWeightedGraph G, int v) { // Fügt v zum Baum hinzu; aktualisiert Datenstrukturen. marked[v] = true; for (Edge e : G.adj(v)) { int w = e.other(v); if (marked[w]) continue; // v-w ist nicht auswählbar. if (e.weight() < distTo[w]) { // Kante e ist neue beste Verbindung vom Baum zu w. edgeTo[w] = e; distTo[w] = e.weight(); if (pq.contains(w)) pq.change(w, distTo[w]); else pq.insert(w, distTo[w]); } } } public Iterable edges() public double weight()
// Siehe Übung 4.3.21. // Siehe Übung 4.3.31.
}
Diese Implementierung des Prim-Algorithmus speichert die auswählbaren kreuzenden Kanten in einer indizierten Vorrangwarteschlange. PrimMST in Listing 4.24 implementiert den Algorithmus von Prim mithilfe unseres
Datentyps für indizierte Vorrangwarteschlangen aus Abschnitt 2.4. Dieser ersetzt die Datenstrukturen marked[] und mst[] in LazyPrimMST durch die beiden knotenindizierten Arrays edgeTo[] und distTo[], die die folgenden Eigenschaften haben:
664
4.3 Minimale Spannbäume
Wenn v nicht zum Baum gehört, aber mindestens eine Kante hat, die ihn mit dem Baum verbindet, dann ist edgeTo[v] die kürzeste Kante, die v mit dem Baum verbindet, und distTo[v] ist das Gewicht dieser Kante.
Alle diese Knoten werden in der indizierten Vorrangwarteschlange als Index v verwaltet, dem das Gewicht von edgeTo[v] zugeordnet ist. Die wichtigste Auswirkung dieser Eigenschaften ist, dass der kleinste Schlüssel in der Vorrangwarteschlange das Gewicht der kreuzenden Kante mit dem geringsten Gewicht ist und dass sein dazugehöriger Knoten v als Nächstes zum Baum hinzugefügt wird. Das Array marked[] wird nicht länger benötigt, da die Bedingung !marked[w] der Bedingung entspricht, dass distTo[w] unendlich ist (und edgeTo[w] null). Zur Verwaltung der Datenstrukturen entnimmt PrimMST der Vorrangwarteschlange einen Knoten v und prüft dann jede Kante v-w in ihrer Adjazenzliste. Wenn w markiert ist, ist die Kante nicht auswählbar; wenn sie nicht in der Vorrangwarteschlange steht oder ihr Gewicht geringer als die aktuelle, bisher bekannte edgeTo[w], dann aktualisiert der Code die Datenstrukturen, um v-w als besten bekannten Weg zu speichern, der v mit dem Baum verbindet. Abbildung
4.61 ist ein Ablaufprotokoll von PrimMST für unseren kleinen Beispielgraphen tinyEWG.txt. Die Inhalte der Arrays edgeTo[] und distTo[] werden dargestellt, nachdem die jeweiligen Knoten dem minimalen Spannbaum hinzugefügt wurden, wobei die Werte farblich gekennzeichnet sind: die Knoten des minimalen Spannbaums (Index schwarz), die Knoten, die nicht zum minimalen Spannbaum gehören (Index grau), die Kanten des minimalen Spannbaums (schwarz) und die Index-WertPaare der Vorrangwarteschlange (rot). In den Diagrammen ist die kürzeste Kante, die jeden Knoten außerhalb des minimalen Spannbaums mit einem Knoten im minimalen Spannbaum verbindet, rot dargestellt. Der Algorithmus fügt die Kanten in der gleichen Reihenfolge dem minimalen Spannbaum hinzu wie die Lazy-Version; der Unterschied liegt in den Operationen der Vorrangwarteschlange. Der Algorithmus erstellt den minimalen Spannbaum wie folgt:
Er fügt 0 zum minimalen Spannbaum und alle Kanten der dazugehörigen Adjazenzliste zur Vorrangwarteschlange hinzu, da jede dieser Kanten die beste (einzig) bekannte Verbindung zwischen einem Baumknoten und einem Nicht-Baumknoten ist.
Er fügt 7 und 0-7 zum minimalen Spannbaum und die Kanten 1-7 und 5-7 zur Vorrangwarteschlange hinzu. Die Kante 2-7 wird nicht von der Vorrangwarteschlange berücksichtigt, da ihr Gewicht nicht geringer ist als die Gewichte der bekannten Verbindungen von dem minimalen Spannbaum zu 2. Die Kante 4-7 ersetzt die Kante 0-4 als kürzeste Verbindung von einem Baumknoten zu 4.
Er fügt 1 und 1-7 zum minimalen Spannbaum und die Kante 1-3 zur Vorrangwarteschlange hinzu.
Er fügt 2 und 0-2 zum minimalen Spannbaum hinzu, ersetzt 0-6 durch 2-6 als kürzeste Kante von einem Baumknoten zu 6 und ersetzt 1-3 durch 2-3 als kürzeste Kante von einem Baumknoten zu 3.
Er fügt 3 und 2-3 zum minimalen Spannbaum hinzu. Er fügt 5 und 5-7 zum minimalen Spannbaum hinzu und ersetzt 4-7 durch 4-5 als kürzeste Kante von einem Baumknoten zu 4.
665
4
Graphen
Er fügt 4 und 4-5 zum minimalen Spannbaum hinzu. Er fügt 6 und 6-2 zum minimalen Spannbaum hinzu. Nachdem V−1 Kanten hinzugefügt wurden, ist der minimale Spannbaum vollständig und die Vorrangwarteschlange ist leer. edgeTo[] distTo[]
Schwarz: im minimalen Spannbaum
Rot: in pq
Grau: nicht im minimalen Spannbaum
Dick rot: am kleinsten in pq, als Nächstes dem minimalen Spannbaum hinzufügen
Abbildung 4.61: Ablaufprotokoll des Prim-Algorithmus (Eager-Version)
666
4.3 Minimale Spannbäume
Ein Argument, das im Großen und Ganzen identisch ist mit dem Beweis zu Satz M, belegt, dass die Eager-Version des Prim-Algorithmus den minimalen Spannbaum eines zusammenhängenden kantengewichteten Graphen in einer Zeit proportional E log V und mit zusätzlichem Speicherbedarf proportional V findet. Bei den riesigen dünnen Graphen, wie sie in der Praxis häufig anzutreffen sind, gibt es keine asymptotischen Unterschiede bei der Zeitschranke (da bei dünnen Graphen lg E ∼ lg V); die Speicherschranke wurde um einen (erfreulichen) konstanten Faktor verbessert. Überlassen Sie die genaueren Analysen und Experimente bei performancekritischen Anwendungen besser Experten, die wissen, welche der viele Faktoren sie berücksichtigen müssen, einschließlich der Implementierungen von MinPQ und IndexMinPQ, der Graphenrepräsentation, Eigenschaften des Graphenmodells für die Anwendung usw. Wie immer ist sorgfältig abzuwägen, ob solche Verbesserungen wirklich notwendig sind, da zunehmende Codekomplexität nur bei Anwendungen gerechtfertigt ist, in denen Leistungssteigerungen um einen konstanten Faktor wichtig sind – in komplexen modernen Systemen kann sie eher kontraproduktiv sein.
20%
40%
60%
80%
Satz N: Die Eager-Version des Prim-Algorithmus benötigt zusätzlichen Speicher proportional V und Zeit proportional E log V (im schlimmsten Fall), um den minimalen Spannbaum eines zusammenhängenden kantengewichteten Graphen mit E Kanten und V Knoten zu berechnen. Beweis: Die Anzahl der Knoten in der Vorrangwarteschlange ist höchstens V und es gibt drei knotenindizierte Arrays, was die Speicherschranke erklärt. Der Algorithmus benötigt V Einfügeoperationen, V Operationen zum Löschen des Minimums und (im schlimmsten Fall) E Operationen zum Ändern der Priorität. Aus diesen Angaben verbunden mit der Tatsache, dass unsere Heapbasierte Implementierung der indizierten Vorrangwarteschlange alle diese Operationen in einer Zeit proportional log V implementiert, ergibt sich die Zeitschranke (siehe Satz Q aus Kapitel 2 ).
MST
Abbildung 4.62: Prim-Algorithmus (250 Knoten)
Das Diagramm in Abbildung 4.62 zeigt den Ablauf des Prim-Algorithmus für unseren euklidischen Graphen mediumEWG.txt mit 250 Knoten. Es ist ein faszinierender dynamischer Prozess (siehe auch Übung 4.3.27). Meistens wächst ein Baum, indem er einen neuen Knoten mit dem gerade hinzugefügten verbindet. Stößt der Baum beim Wachsen auf einen Bereich, in dem es keine nahe gelegenen NichtBaumknoten gibt, wächst er von einem anderen Teil aus weiter.
667
4
Graphen
4.3.6 Der Algorithmus von Kruskal Der zweite Algorithmus zur Berechnung minimaler Spannbäume, auf den wir ausführlich eingehen wollen, verarbeitet die Kanten in der Reihenfolge ihrer Gewichte (von der kleinsten Kante zur größten). Er nimmt für den minimalen Spannbaum (schwarz) nur Kanten, die mit den zuvor hinzugefügten Kanten keinen Zyklus bilden, und ist nach V−1 Kanten fertig. Die schwarzen Kanten entwickeln sich nach und nach von einem aufspannenden Wald zu einem einzelnen Baum – dem minimalen Spannbaum. Diese Methode wird Algorithmus von Kruskal genannt.
nächste Kante des minimalen Spannbaums ist rot
Graphenkanten sortiert nach Gewicht Kante des minimalen Spannbaums (schwarz) 0-7 2-3 1-7 0-2 5-7 1-3 1-5 2-7 4-5 1-2 4-7 0-4 6-2 3-6 6-0 6-4
Satz O: Der Kruskal-Algorithmus berechnet den minimalen Spannbaum von jedem kantengewichteten zusammenhängenden Graphen. Beweis: Ergibt sich unmittelbar aus Satz K. Wenn die nächste zu betrachtende Kante keinen Zyklus mit schwarzen Kanten bildet, kreuzt sie einen Schnitt, der durch die Menge der Knoten definiert ist, die mit einem der Knoten der Kante über Baumkanten verbunden sind (und ihr Komplement). Da die Kante keinen Zyklus erzeugt, ist sie soweit die einzige kreuzende Kante, und da wir die Kanten geordnet betrachten, ist sie eine leichte kreuzende Kante. Auf diese Weise verarbeitet der Algorithmus – entsprechend dem Greedy-Algorithmus – sukzessive immer eine leichte kreuzende Kante.
0.16 0.17 0.19 0.26 0.28 0.29 0.32 0.34 0.35 0.36 0.37 0.38 0.40 0.52 0.58 0.93
obsolete Kante (grau)
graue Knoten sind ein Schnitt, definiert durch die Knoten, die mit einem der Knoten der roten Kante verbunden sind
Der Algorithmus von Prim erstellt den minimalen Spannbaum Kante für Kante, d.h., er sucht in jedem Schritt nach einer neuen Kante, die er einem einzelnen Baum hinzufügt. Der Algorithmus von Kruskal erstellt ebenfalls den minimalen Spannbaum Kante für Kante, doch im Gegensatz zum Prim-Algorithmus findet er eine Kante, die zwei Bäume in einem Wald wachsender Bäume verbindet. Ausgangsbasis ist ein Abbildung 4.63: Ablaufprotokoll des KruskalAlgorithmus degenerierter Wald von V Bäumen, die jeweils aus einem isolierten Knoten bestehen; darauf führen wir die Operation aus, zwei Bäume (und zwar mit der jeweils kürzesten Kante) zu verbinden, bis es nur noch einen Baum gibt: den minimalen Spannbaum.
668
4.3 Minimale Spannbäume
Abbildung 4.63 zeigt ein Beispiel für die schrittweise Vorgehensweise des KruskalAlgorithmus anhand der Datei tinyEWG.txt. Die ersten fünf Kanten mit dem niedrigsten Gewicht im Graphen werden für den minimalen Spannbaum genommen, die dann folgenden Kanten 1-3, 1-5 und 2-7 werden als nicht auswählbar identifiziert, bevor mit 4-5 wieder eine Kante dem minimalem Spannbaum hinzugefügt wird; zum Schluss werden die Kanten 1-2, 4-7 und 0-4 als nicht auswählbar identifiziert und die letzte Kante 6-2 wird Teil des minimalen Spannbaums.
Wie Sie sehen, ist auch der Algorithmus von Kruskal nicht schwer zu implementieren, wenn man über die grundlegenden algorithmischen Werkzeuge verfügt, die wir in diesem Buch vorgestellt haben: Wir verwenden eine Vorrangwarteschlange (Abschnitt 2.4), um die Kanten geordnet nach Gewicht verarbeiten zu können, eine Union-Find-Datenstruktur (Abschnitt 1.5), um die Kanten zu identifizieren, die einen Zyklus erzeugen, und eine Warteschlange (Abschnitt 1.3), die die Kanten des minimalen Spannbaums speichert. Algorithmus 4.8 (Listing 4.25) ist eine Implementierung mit genau diesen Elementen. Beachten Sie, dass die Speicherung der Kanten des minimalen Spannbaums in einer Queue bedeutet, dass ein Client beim Iterieren über die Kanten diese in aufsteigender Reihenfolge ihres Gewichts zurückliefert. Für die Methode weight(), die die Kantengewichte addiert, müssen Sie über die Warteschlange iterieren (oder eine laufende Gesamtsumme in einer Instanzvariablen speichern); dies wollen wir Ihnen zur Übung überlassen (Übung 4.3.31). Die Laufzeit des Kruskal-Algorithmus lässt sich also relativ leicht analysieren, da wir die Laufzeiten der einzelnen grundlegenden Operationen kennen.
Satz O (Fortsetzung): Der Algorithmus von Kruskal benötigt Speicher proportional E und Zeit proportional E log E (im schlimmsten Fall), um den minimalen Spannbaum eines kantengewichteten zusammenhängenden Graphen mit E Kanten und V Knoten zu berechnen. Beweis: Die Implementierung verwendet den Vorrangwarteschlangen-Konstruktor, der die Vorrangwarteschlange mit allen Kanten initialisiert, was mit Kosten von höchstens E Vergleichen verbunden ist (Abschnitt 2.4 ). Nachdem die Vorrangwarteschlange erstellt ist, ist das Argument identisch mit dem des Algorithmus von Prim. Die Vorrangwarteschlange speichert maximal E Kanten, was die Speicherschranke erklärt, und die Kosten pro Operation sind höchstens 2 lg E Vergleiche, was die Zeitschranke erklärt. Darüber hinaus führt der Kruskal-Algorithmus E connected()Operationen und V union()-Operationen aus, was sich jedoch nicht auf die Wachstumsordnung E log E der Gesamtlaufzeit auswirkt (siehe Abschnitt 1.5 ).
Wie beim Prim-Algorithmus sind die Kostengrenzen eher konservativ gewählt, da der Algorithmus terminiert, wenn er die V−1 Kanten des minimalen Spannbaums gefunden hat. Die Wachstumsordnung der eigentlichen Kosten ist E + E0 log E, wobei E0 die Anzahl der Kanten ist, deren Gewicht geringer ist als das der Kante des minimalen Spannbaums mit dem höchsten Gewicht. Trotz dieses Vorteils ist der Kruskal-Algorithmus normalerweise langsamer als der Prim-Algorithmus, da er eine connected()-Operation für jede Kante durchführen muss, und zwar zusätzlich zu den Vorrangwarteschlangen-Operationen, die beide Algorithmen für jede verarbeitete Kante durchführen (siehe Übung 4.3.39).
669
4
Graphen
Listing 4.25: MST-Algorithmus von Kruskal (Algorithmus 4.8) public class KruskalMST { private Queue mst; public KruskalMST(EdgeWeightedGraph G) { mst = new Queue(); MinPQ pq = new MinPQ(); for (Edge e : G.edges()) { pq.insert(e); } UF uf = new UF(G.V()); while (!pq.isEmpty() && mst.size() < G.V()-1) { Edge e = pq.delMin(); // Holt kürzeste Kante aus pq int v = e.either(), w = e.other(v); // und ihre Knoten. if (uf.connected(v, w)) continue; // Ignoriert nicht auswählbare // Kanten. uf.union(v, w); // Mischt Komponenten. mst.enqueue(e); // Fügt Kante zu mst hinzu. } } public Iterable edges() { return mst; } public double weight()
// Siehe Übung 4.3.31.
}
Diese Implementierung des Kruskal-Algorithmus bedient sich einer Warteschlange, um die Kanten des minimalen Spannbaums aufzunehmen, einer Vorrangwarteschlange, um die noch nicht untersuchten Kanten zu speichern, und einer UnionFind-Datenstruktur, um die nicht auswählbaren Kanten zu identifizieren. Die Kanten des minimalen Spannbaums werden dem Client in aufsteigender Reihenfolge ihres Gewichts zurückgeliefert. Die Methode weight() sei Ihnen als Übung überlassen. % java KruskalMST tinyEWG.txt 0-7 0.16 2-3 0.17 1-7 0.19 0-2 0.26 5-7 0.28 4-5 0.35 6-2 0.40 1.81
670
4.3 Minimale Spannbäume
Abbildung 4.64 veranschaulicht den dynamischen Charakter des Algorithmus anhand des größeren Beispiels mediumEWG.txt. Es ist gut zu erkennen, dass die Kanten dem Wald in der Reihenfolge ihrer Länge hinzugefügt werden.
20%
4.3.7 Ausblick Das Problem der minimalen Spannbäume ist eines der am ausführlichsten untersuchten Probleme, die wir in diesem Buch besprechen. Einfache Lösungsansätze zur Laufzeitanalyse von Algorithmen gab es schon, lange bevor moderne Datenstrukturen und moderne Techniken entwickelt wurden – sie stammen aus einer Zeit, als nur Unerschrockene sich daran wagten, den minimalen Spannbaum eines Graphen mit, sagen wir, Tausenden von Kanten zu finden. Die Algorithmen, die wir hier zur Berechnung der minimalen Spannbäume betrachtet haben, unterscheiden sich von den alten im Wesentlichen darin, dass sie grundlegende Aufgaben mit modernen Algorithmen und Datenstrukturen lösen, was uns (in Verbindung mit moderner Computerleistung) befähigt, minimale Spannbäume mit Millionen, ja sogar Milliarden von Kanten zu berechnen.
40%
60%
80%
Historischer Rückblick Eine MST-Implementierung für dichte Graphen (siehe Übung 4.3.29) wurde zuerst von R. Prim im Jahre 1961 vorgestellt und unabhängig davon kurz darauf von E. W. Dijkstra. Sie wird normalerweise als Algorithmus von Prim bezeichnet, obwohl Dijkstras Präsentation allgemeinerer MST Natur war. Allerdings gehen die ersten Grundlagen auf V. Jarnik vom Jahr 1939 zurück, weshalb manche Autoren auch vom Algorithmus von Jarnik sprechen und Prims (bzw. Dijkstras) Rolle darin sehen, eine effizientere Implementierung des Algorithmus für dichte Graphen gefunden zu haben. Als in den 1970ern der abstrakte Datentyp der Vorrangwarteschlange Verbreitung fand, war das Finden minimaler Spannbäume in dünnen Graphen plötzlich ganz einfach; es wurde schnell bekannt, dass minimale Abbildung 4.64: Kruskal-Algorithmus (250 Knoten) Spannbäume von dünnen Graphen in einer Zeit proportional E log E berechnet werden konnten, ohne dass der Erfolg einem bestimmten Wissenschaftler zugeschrieben wurde. 1984 entwickelten M. L. Fredman und R. E Tarjan die Datenstruktur des binären Heaps, der die Wachstumsordnung der Laufzeit vom Prim-Algorithmus so verbesserte, dass die theoretische Schranke bei E + V log V lag.
671
4
Graphen
J. Kruskal präsentierte seinen Algorithmus 1956; auch in diesem Fall wurden die Implementierungen der relevanten abstrakten Datentypen viele Jahre lang vernachlässigt. Erwähnenswert ist auch, dass Kruskal in seinem Aufsatz bereits eine Version des Prim-Algorithmus vorwegnahm und ein Aufsatz von O. Boruvka bereits 1929 (!) beide Ansätze erwähnte. Boruvkas Aufsatz behandelte eine Anwendung zur Stromverteilung und stellte noch ein anderes Verfahren vor, das mit modernen Datenstrukturen leicht zu implementieren ist (Übung 4.3.43 und Übung 4.3.44). Dieses Verfahren wurde 1961 von M. Sollin wiederentdeckt und fand später Verwendung als Grundlage für parallele MST-Algorithmen und für MST-Algorithmen mit effizienter asymptotischer Laufzeit.
Tabelle 4.25 Performancedaten von MST-Algorithmen Algorithmus
Wachstumsordnung für den schlimmsten Fall für V Knoten und E Kanten Speicher
Zeit
Lazy-Prim
E
E log E
Eager-Prim
V
E log V
Kruskal
E
E log E
Fredman-Tarjan
V
E + V log V
Chazelle
V
sehr, sehr nahe an, aber nicht ganz genau E
unmöglich?
V
E?
Ein linearer Algorithmus? Auch wenn die Wissenschaft noch keine theoretischen Ergebnisse geliefert hat, die die Existenz eines MST-Algorithmus mit garantiert linearer Laufzeit für jeden Graphen widerlegen, sind wir doch meilenweit davon entfernt, Algorithmen zu entwickeln, die minimale Spannbäume für dünne Graphen in linearer Zeit berechnen. Seit den 1970ern ist für viele Wissenschaftler die Anwendbarkeit der Union-Find-Abstraktion auf den Kruskal-Algorithmus und die Anwendbarkeit der Vorrangwarteschlange-Abstraktion auf den Prim-Algorithmus der Hauptantrieb, nach besseren Implementierungen für diese Datentypen zu suchen. Viele Wissenschaftler haben bei der Suche nach effizienten MST-Algorithmen für dünne Graphen den Schwerpunkt auf effiziente Vorrangwarteschlangen-Implementierungen gelegt; viele andere haben sich mit Abwandlungen des Boruvka-Algorithmus als Basis für beinahe lineare MST-Algorithmen für dünne Graphen beschäftigt. Forschung in diesem Bereich hat immer noch das Potenzial, uns irgendwann einen praktischen linearen MST-Algorithmus zu liefern, ja hat uns bereits einen randomisierten linearen Algorithmus beschert. Außerdem kommen die Wissenschaftler ihrem Ziel eines Algorithmus mit linearer Laufzeit immer näher: B. Chazelle stellte 1997 einen Algorithmus vor, der in allen praktischen Situationen nicht von einem linearen Algorithmus zu unterscheiden ist (obwohl er nachweislich nicht linear
672
4.3 Minimale Spannbäume
ist), allerdings ist er so kompliziert, dass bisher niemand ihn in der Praxis eingesetzt hat. Doch auch wenn die Algorithmen, die wir diesen Forschungsbemühungen verdanken, in der Regel recht kompliziert sind, könnten vereinfachte Versionen von einigen sich in der Praxis durchaus als nützlich erweisen. In der Zwischenzeit verwenden wir einfach die grundlegenden Algorithmen, die wir hier vorgestellt haben, um in den meisten praktischen Fällen die minimalen Spannbäume in linearer Zeit zu berechnen, wobei wir für einige dünne Graphen unter Umständen einen Zusatzfaktor von log V einplanen müssen. Zusammengefasst lässt sich sagen, dass das Problem der minimalen Spannbäume in der Praxis als „gelöst“ betrachtet werden kann. Bei den meisten Graphen sind die Kosten, den minimalen Spannbaum zu finden, nur geringfügig größer als die Kosten, die Kanten des Graphen zu extrahieren. Diese Regel gilt für die meisten Graphen, außer für riesige Graphen, die extrem dünn sind. Doch auch in diesem Fall lässt sich die Performance gegenüber den bekanntesten Algorithmen nur um einen kleinen konstanten Faktor verbessern, vielleicht bestenfalls um den Faktor 10. Diese Ergebnisse sind für viele Graphenmodelle bestätigt worden, und Programmierer verwenden die Algorithmen von Prim und Kruskal seit Jahrzehnten, um die minimalen Spannbäume in riesigen Graphen zu finden.
673
4
Graphen
4.3
Fragen und Antworten
Frage: Lassen sich die Algorithmen von Prim und Kruskal auch auf gerichtete Graphen anwenden? Antwort: Nein, überhaupt nicht. Das ist ein größeres Problem der Graphenverarbeitung, das als Problem des minimalen gewurzelten Baums bezeichnet wird.
4.3
674
Allgemeine Übungen
1.
Beweisen Sie, dass Sie die Gewichte neu skalieren können, indem Sie zu allen eine positive Konstante addieren oder alle mit einer positiven Konstante multiplizieren, ohne dass dies Auswirkungen auf den minimalen Spannbaum hat.
2.
Zeichnen Sie alle minimalen Spannbäume des nachfolgenden Graphen (alle Kantengewichte seien gleich groß).
3.
Zeigen Sie, dass der minimale Spannbaum eines Graphen eindeutig ist, wenn alle seine Kanten unterschiedliche Gewichte haben.
4.
Prüfen Sie die Behauptung, dass ein kantengewichteter Graph nur dann einen eindeutigen minimalen Spannbaum hat, wenn seine Kantengewichte unterschiedlich sind. Liefern Sie einen Beweis oder ein Gegenbeispiel.
5.
Zeigen Sie, dass der Greedy-Algorithmus auch gilt, wenn die Kantengewichte nicht unterschiedlich sind.
6.
Geben Sie den minimalen Spannbaum des gewichteten Graphen an, den Sie durch Löschen von Knoten 7 in tinyEWG.txt erhalten (Abbildung 4.50).
7.
Wie finden Sie einen maximalen Spannbaum eines kantengewichteten Graphen?
8.
Beweisen Sie die sogenannte Kreiseigenschaft (cycle property): Gegeben sei ein beliebiger Zyklus (auch Kreis genannt) in einem kantengewichteten Graphen (mit unterschiedlichen Kantengewichten). Beweisen Sie, dass die Kante mit dem maximalen Gewicht im Zyklus nicht zum minimalen Spannbaum des Graphen gehört.
9.
Implementieren Sie den Konstruktor für EdgeWeightedGraph, der einen Graphen aus dem Eingabestrom einliest. Nehmen Sie dazu die entsprechenden Änderungen am Konstruktor von Graph vor (Listing 4.1).
10.
Entwickeln Sie eine EdgeWeightedGraph-Implementierung für dichte Graphen, die eine Adjazenzmatrix-Repräsentation (zweidimensionales Array von Gewichten) verwendet. Lassen Sie keine parallelen Kanten zu.
11.
Ermitteln Sie mithilfe des Kostenmodells aus Abschnitt 1.4 den Speicherbedarf von EdgeWeightedGraph, um einen Graphen mit V Knoten und E Kanten zu repräsentieren.
1
2
3
4
5
6
7
8
Allgemeine Übungen
12.
Angenommen, ein Graph hat unterschiedliche Kantengewichte. Muss die kürzeste Kante zum minimalen Spannbaum gehören? Kann seine längste Kante zum minimalen Spannbaum gehören? Muss eine Kante minimalen Gewichts von jedem Zyklus zum minimalen Spannbaum gehören? Begründen Sie Ihre jeweilige Antwort oder geben Sie ein Gegenbeispiel.
13.
Geben Sie ein Gegenbeispiel, warum die folgende Strategie nicht notwendigerweise den minimalen Spannbaum liefert: „Wählen Sie einen beliebigen Knoten als Startknoten für Ihren minimalen Spannbaum und fügen Sie dann V-1 Kanten hinzu, wobei Sie immer als Nächstes die Kante mit dem minimalen Gewicht wählen, die zu dem zuletzt hinzugefügten Knoten inzident ist.“
14.
Gegeben sei ein minimaler Spannbaum für einen kantengewichteten Graphen G. Angenommen eine Kante in G wird gelöscht, ohne den Zusammenhang des Graphen aufzuheben. Beschreiben Sie, wie Sie einen minimalen Spannbaum des neuen Graphen in einer Zeit proportional E finden.
15.
Gegeben seien ein minimaler Spannbaum für einen kantengewichteten Graphen G und eine neue Kante e. Beschreiben Sie, wie Sie einen minimalen Spannbaum des neuen Graphen in einer Zeit proportional V finden.
16.
Gegeben seien ein minimaler Spannbaum für einen kantengewichteten Graphen G und eine neue Kante e. Schreiben Sie ein Programm, das den Gewichtsbereich von e ermittelt, sodass e im minimalen Spannbaum liegt.
17.
Implementieren Sie toString() für EdgeWeightedGraph.
18.
Wie sehen die Ablaufprotokolle für den minimale Spannbaum des in Übung 4.3.6 definierten Graphen aus, und zwar berechnet mit dem Prim-Algorithmus, der Eager-Version des Prim-Algorithmus und dem Kruskal-Algorithmus?
19.
Angenommen, Sie verwenden eine Vorrangwarteschlangen-Implementierung, die eine sortierte Liste verwaltet. Wie sähe die Wachstumsordnung der Worst-CaseLaufzeit für den Prim-Algorithmus und den Kruskal-Algorithmus für Graphen mit V Knoten und E Kanten aus? Wann wäre diese Methode angebracht, falls überhaupt? Verteidigen Sie Ihre Meinung.
20.
Wahr oder falsch: Zu jeder Zeit während der Ausführung des Kruskal-Algorithmus ist jeder Knoten näher an einem Knoten in seinem Teilbaum als an irgendeinem Knoten nicht in seinem Teilbaum. Belegen Sie Ihre Antwort.
21.
Liefern Sie eine Implementierung von edges() für PrimMST (Listing 4.24).
Lösung: public Iterable edges() { Bag mst = new Bag(); for (int v = 1; v < edgeTo.length; v++) mst.add(edgeTo[v]); return mst; }
675
4
Graphen
4.3
676
Knifflige Übungen
22.
Minimaler Spannwald: Entwickeln Sie Versionen des Prim- und des KruskalAlgorithmus, die den minimalen Spannwald eines kantengewichteten Graphen berechnen, der nicht notwendigerweise zusammenhängend ist. Verwenden Sie die API für Zusammenhangskomponenten von Abschnitt 4.1 und finden Sie die minimalen Spannbäume der einzelnen Komponenten.
23.
Algorithmus von Vyssotsky: Entwickeln Sie eine Implementierung zur Berechnung des minimalen Spannbaums, indem Sie wiederholt die Kreiseigenschaft anwenden (Übung 4.3.8). Fügen Sie einem vermeintlichen Baum schrittweise eine Kante hinzu, wobei Sie eine Kante maximalen Gewichts löschen, wenn sich ein Zyklus bildet. Hinweis: Diese Vorgehensweise fand bisher weniger Beachtung als die anderen, da es verhältnismäßig schwierig ist, eine Datenstruktur zu verwalten, die eine effiziente Implementierung der „Löschoperation für die maximale Kante im Zyklus“ unterstützt.
24.
Reverse-Delete-Algorithmus: Entwickeln Sie eine Implementierung, die den minimalen Spannbaum wie folgt berechnet: Beginnen Sie mit einem Graphen, der alle Kanten enthält. Gehen Sie dann wiederholt die Kanten in absteigender Reihenfolge des Gewichts durch. Prüfen Sie für jede Kante, ob ihr Löschen den Zusammenhang dieses Graphen aufbricht; wenn nicht, löschen Sie sie. Beweisen Sie, dass dieser Algorithmus den minimalen Spannbaum berechnet. Wie sieht die Wachstumsordnung von der Anzahl der Kantengewichtsvergleiche aus, die von Ihrer Implementierung durchgeführt werden?
25.
Worst-Case-Generator: Entwickeln Sie einen praxistauglichen Generator für kantengewichtete Graphen mit V Knoten und E Kanten, sodass die Laufzeit der LazyVersion des Prim-Algorithmus nicht linear ist. Tun Sie selbiges für die Eager-Version.
26.
Kritische Kanten: Eine Kante des minimalen Spannbaums, die, wenn gelöscht, das Gewicht des minimalen Spannbaums ansteigen lässt, wird kritische Kante (critical edge) genannt. Zeigen Sie, wie Sie alle kritischen Kanten in einem Graphen in einer Zeit proportional E log E finden. Hinweis: Die Frage geht davon aus, dass Kantengewichte nicht notwendigerweise verschieden sind (ansonsten wären alle Kanten im minimalen Spannbaum kritisch).
27.
Animationen: Schreiben Sie ein Client-Programm, das die Graphen von MST-Algorithmen animiert. Führen Sie Ihr Programm auf mediumEWG.txt aus, um Bilder wie die in Abbildung 4.61 und Abbildung 4.63 zu produzieren.
28.
Speichereffiziente Datenstrukturen: Entwickeln Sie eine Implementierung von der Lazy-Version des Prim-Algorithmus, die Speicher spart, indem sie einfachere Datenstrukturen für EdgeWeightedGraph und MinPQ verwendet als Bag und Edge. Schätzen Sie die Speichereinsparungen als eine Funktion von V und E, unter Verwendung des Kostenmodells aus Abschnitt 1.4 (Übung 4.3.11).
Knifflige Aufgaben
29.
Dichte Graphen: Entwickeln Sie eine Implementierung des Prim-Algorithmus, die einen Eager-Ansatz verfolgt (aber keine Vorrangwarteschlange verwendet) und den minimalen Spannbaum mittels V2 Kantengewichtsvergleiche berechnet.
30.
Euklidische gewichtete Graphen: Überarbeiten Sie Ihre Lösung zu Übung 4.1.36, um eine API EuclideanEdgeWeightedGraph für Graphen zu erstellen, deren Knoten Punkte in der Ebene sind, sodass Sie mit grafischen Repräsentationen arbeiten können.
31.
MST-Gewichte: Entwickeln Sie Implementierungen von weight() für LazyPrimMST, PrimMST und KruskalMST unter Verwendung einer Lazy-Strategie, die über die Kanten des minimalen Spannbaums iteriert, wenn der Client weight() aufruft. Entwickeln Sie dann alternative Implementierungen auf der Basis einer EagerStrategie, die bei der Berechnung des minimalen Spannbaums eine Gesamtsumme in einer Variablen speichert.
32.
Spezifizierte Menge: Gegeben seien ein zusammenhängender kantengewichteter Graph G und eine spezifizierte Kantenmenge S (ohne Zyklen). Beschreiben Sie eine Möglichkeit, einen minimalen Spannbaum in G zu finden, der alle Kanten von S enthält.
33.
Verifizierung: Schreiben Sie einen MST- und EdgeWeightedGraph-Client check(), der die folgenden Schnitt-Optimalitätsbedingungen (cut optimality conditions) verwendet – wie sie sich aus Satz J ergeben –, um zu verifizieren, dass eine vorgeschlagene Kantenmenge tatsächlich ein minimaler Spannbaum ist: Eine Kantenmenge ist ein minimaler Spannbaum, wenn sie ein Spannbaum ist und jede Kante eine leichte Kante in dem Schnitt ist, der durch Entfernen dieser Kante aus dem Baum definiert wird. Welche Wachstumsordnung hat die Laufzeit Ihrer Methode?
677
4
Graphen
4.3
Experimente
34.
Zufällige dünne kantengewichtete Graphen: Schreiben Sie einen Generator für zufällige dünne kantengewichtete Graphen, basierend auf Ihrer Lösung zu Übung 4.1.40. Für die Zuweisung der Kantengewichte definieren Sie einen abstrakten Datentyp für einen zufälligen kantengewichteten Digraphen und schreiben zwei Implementierungen: eine, die gleichmäßig verteilte Gewichte erzeugt, und eine weitere, die Gewichte gemäß einer Gauß’schen Verteilung erzeugt. Schreiben Sie Client-Programme, um zufällige dünne kantengewichtete Graphen für beide Gewichtsverteilungen mit einer gut gewählten Wertemenge für E und V zu erzeugen, mit denen Sie dann empirische Tests auf Graphen ausführen können, die Sie durch verschiedene Verteilungen der Kantengewichte erhalten.
35.
Zufällige euklidische kantengewichtete Graphen: Überarbeiten Sie Ihre Lösung zu Übung 4.1.41, um jeder Kante den Abstand zwischen den Knoten als Gewicht zuzuordnen.
36.
Zufällige kantengewichtete Gittergraphen: Überarbeiten Sie Ihre Lösung zu Übung 4.1.42, um jeder Kante ein zufälliges Gewicht (zwischen 0 und 1) zuzuweisen.
37.
Reale kantengewichtete Graphen: Suchen Sie online nach einem großen gewichteten Graphen – zum Beispiel eine Landkarte mit Entfernungen, Telefonverbindungen mit Kosten oder das Preissystem einer Fluggesellschaft. Schreiben Sie ein Programm RandomRealEdgeWeightedGraph, das einen gewichteten Graphen erstellt, indem es V Knoten und E gewichtete Kanten nach dem Zufallsprinzip aus einem durch diese Knoten induzierten Teilgraphen wählt.
Für alle Graphenmodelle alle Algorithmen zu testen und alle Parameter zu analysieren, ist nicht realistisch. Schreiben Sie für jedes nachfolgend beschriebene Problem einen Client, der das Problem für jeden gegebenen Eingabegraphen löst, und wählen Sie einen der oben genannten Generatoren, um Experimente für das Graphenmodell auszuführen. Wählen Sie Ihre Experimente sorgfältig, vielleicht auf der Basis von Ergebnissen vorheriger Experimente. Beschreiben Sie explizit, welche Ergebnisse Sie erhalten haben und welche Schlüsse sich daraus ziehen lassen.
678
38.
Kosten der Lazy-Version: Führen Sie empirische Untersuchungen durch, um die Performance der Lazy-Version des Prim-Algorithmus mit seiner Eager-Version für verschiedene Arten von Graphen zu vergleichen.
39.
Prim versus Kruskal: Führen Sie empirische Untersuchungen durch, um die Performance der Lazy- und Eager-Versionen des Prim-Algorithmus mit dem KruskalAlgorithmus zu vergleichen.
40.
Reduzierter Overhead: Führen Sie empirische Untersuchungen durch, um festzustellen, wie es sich auswirkt, wenn Sie in EdgeWeightedGraph primitive Typen anstelle von Edge-Werten verwenden (beschrieben in Übung 4.3.28).
Experimente
41.
Längste Kante des minimalen Spannbaums: Führen Sie empirische Untersuchungen durch, um die Länge der längsten Kante im minimalen Spannbaum festzustellen und die Anzahl der Graphenkanten, die nicht länger sind.
42.
Partitionierung: Entwickeln Sie eine Implementierung, die den Kruskal-Algorithmus mit der Quicksort-Partitionierung (anstatt mit einer Vorrangwarteschlange) verwendet, um zu prüfen, ob jede Kante Teil des minimalen Spannbaums ist, sobald alle kleineren Kanten geprüft worden sind.
43.
Algorithmus von Boruvka: Entwickeln Sie eine Implementierung des BoruvkaAlgorithmus: Erstellen Sie einen minimalen Spannbaum, indem Sie wie im Algorithmus von Kruskal Kanten zu einem wachsenden Wald von Bäumen hinzufügen, aber in Etappen. Finden Sie in jeder Etappe die Kante mit dem geringsten Gewicht, die einen Baum mit einem anderen verbindet, und fügen Sie dann alle diese Kanten dem minimalen Spannbaum hinzu. Gehen Sie davon aus, dass die Kantengewichte alle unterschiedlich sind, um Zyklen zu vermeiden. Hinweis: Verwalten Sie ein knotenindiziertes Array, um die Kante zu identifizieren, die jede Komponente mit seinem nächsten Nachbarn verbindet, und verwenden Sie die Union-Find-Datenstruktur.
44.
Verbesserter Boruvka: Entwickeln Sie eine Implementierung des Boruvka-Algorithmus, die zur Repräsentation von MST-Teilbäumen doppelt verkettete Ringlisten verwendet, sodass in jeder Etappe Teilbäume in einer Zeit proportional E zusammengeführt und umbenannt werden können (und der Union-Find-Datentyp deshalb nicht benötigt wird).
45.
Externer minimaler Spannbaum: Beschreiben Sie, wie Sie den minimalen Spannbaum eines Graphen finden, der so riesig ist, dass immer nur V Kanten zurzeit in den Hauptspeicher passen.
46.
Algorithmus von Johnson: Entwickeln Sie eine Vorrangwarteschlangen-Implementierung, die einen d-nären Heap verwendet (Übung 2.4.41). Finden Sie den besten Wert für d für verschiedene gewichtete Graphenmodelle.
679
4
Graphen
4.4
Kürzeste Pfade
Das vielleicht bekannteste Problem der Graphenverarbeitung begegnet Ihnen, wenn Sie mithilfe einer Karten-Anwendung oder eines Navigationssystems Ihre Route von einem Ort zum anderen planen. Diese Wegbeschreibungen lassen sich hervorragend durch ein Graphenmodell formal darstellen: Knoten repräsentieren die Kreuzungen und Kanten die Straßen; die Gewichte der Kanten modellieren die Kosten, in diesem Fall Abstände oder Fahrzeiten. Einbahnstraßen auf der Route bedeuten, dass wir kantengewichtete Digraphen berücksichtigen müssen. Bei diesem Modell ist die Formulierung des Problems einfach:
Finde den Weg mit den geringsten Kosten, um von einem Knoten zu einem anderen zu gelangen. Neben solchen direkten Anwendungen lässt sich mit dem Kürzeste-Pfade-Modell eine Reihe von weiteren Problemen lösen, von denen einige nicht das Geringste mit der Graphenverarbeitung zu tun zu haben scheinen. Als Beispiel werden wir am Ende des Abschnitts das Arbitrageproblem aus dem Bereich der Finanzmathematik betrachten.
Tabelle 4.26 Typische Anwendungen für kürzeste Pfade Anwendung
Knoten
Kante
Straßenkarte
Kreuzung
Straße
Netzwerk
Router
Verbindung
Ablaufplanung
Aufgabe
Vorrangbedingung
Arbitrage
Währung
Wechselkurs
Als allgemeines Modell wählen wir den kan- kantengewichteter Digraph 4->5 0.35 tengewichteten Digraphen (eine Kombina5->4 0.35 tion der Modelle von Abschnitt 4.2 und 4.3). 4->7 0.37 5->7 0.28 In Abschnitt 4.2 wollten wir wissen, ob es 7->5 0.28 generell möglich ist, von einem Startknoten 5->1 0.32 zu einem Zielknoten zu gelangen; in diesem 0->4 0.38 0->2 0.26 Abschnitt berücksichtigen wir die Gewichte kürzester Pfad 7->3 0.39 von 0 nach 6 der Kanten, wie wir es für ungerichtete kan1->3 0.29 0->2 0.26 tengewichtete Graphen in Abschnitt 4.3 ge2->7 0.34 2->7 0.34 6->2 0.40 macht haben. Jeder gerichtete Pfad in einem 7->3 0.39 3->6 0.52 3->6 0.52 kantengewichteten Digraphen hat ein Pfad6->0 0.58 6->4 0.93 gewicht, dessen Wert sich aus der Summe der Gewichte der einzelnen Kanten im Pfad Abbildung 4.65: Ein kantengewichteter Digraph ergibt. Dieses Gewicht erlaubt uns, Prob- und ein kürzester Pfad leme zu formulieren wie Finde den Weg mit
680
4.4 Kürzeste Pfade
den geringsten Kosten, um von einem Startknoten zu einem Zielknoten zu gelangen – ein Thema, das uns in diesem Abschnitt beschäftigen soll. Abbildung 4.65 zeigt ein Beispiel.
Definition: Kürzester Pfad Ein kürzester Pfad (shortest path) von einem Knoten s zu einem Knoten t in einem kantengewichteten Digraphen ist ein gerichteter Pfad von s zu t mit der Eigenschaft, dass kein anderer Pfad ein niedrigeres Gewicht hat.
Wir betrachten also in diesem Abschnitt klassische Algorithmen für das folgende Problem: Kürzeste Pfade mit einem Startknoten (single-source shortest paths): Wir möchten für einen gegebenen kantengewichteten Digraphen und einem Startknoten s Fragen beantworten wie Gibt es einen gerichteten Pfad von s zu einem gegebenen Zielknoten t? Wenn ja, finde den kürzesten Pfad (d.h. den Pfad, dessen Gesamtgewicht minimal ist).
Dabei werden wir im Laufe dieses Abschnitts die folgenden Themen behandeln:
Unsere APIs und Implementierungen für kantengewichtete Digraphen und eine API für kürzeste Pfade mit einem Startknoten;
Der klassische Dijkstra-Algorithmus für das Problem, dass Gewichte nicht negativ sind;
Ein schnellerer Algorithmus für azyklische kantengewichtete Digraphen, der auch funktioniert, wenn Kantengewichte negativ sein sollten;
Der klassische Bellman-Ford-Algorithmus für den allgemeinen Fall, dass Zyklen vorliegen, Kantengewichte negativ sind und wir Algorithmen benötigen, um Zyklen mit negativem Gewicht und kürzeste Pfade in kantengewichteten Digraphen ohne solche Zyklen zu finden. Parallel zu den Algorithmen werden wir zur Veranschaulichung auch Anwendungen betrachten.
681
4
Graphen
4.4.1 Eigenschaften der kürzeste Pfade Die grundlegende Definition des KürzestePfade-Problems ist klar und einprägsam. Doch die prägnante Formulierung verbirgt diverse Punkte, die wir kurz ansprechen sollten, bevor wir Algorithmen und Datenstrukturen zur Lösung des Problems entwickeln.
Arrayrepräsentation von Elternknoten und Kanten
Pfade sind gerichtet: Ein kürzester Pfad muss die Richtung seiner Kanten berücksichtigen.
Die Gewichte sind nicht notwendigerweise Abstände: Geometrische Intuition hilft manchmal, Algorithmen leichter zu verstehen. Deshalb verwenden wir Beispiele, in denen Knoten durch Punkte in der Ebene und Gewichte durch euklidische Abstände repräsentiert werden, wie beim Digraphen in Abbildung 4.65. Aber Sie sollten sich darüber im Klaren sein, dass Gewichte auch Zeit oder Kosten oder eine gänzlich andere Variable repräsentieren können und nicht proportional zu einem Abstand sein müssen. Um dies zu unterstreichen, sprechen wir oft von einem kürzesten Pfad mit minimalem Gewicht oder Kosten.
Startknoten
Nicht alle Knoten müssen erreichbar sein. Wenn t von s aus nicht erreichbar ist, gibt es keinen Pfad und damit auch keinen kürzesten Pfad von s zu t. Der Einfachheit halber ist unser kleines laufendes Beispiel deshalb stark zusammenhängend (jeder Knoten ist von jedem anderen Knoten aus erreichbar).
Negative Gewichte verursachen Komplikationen. Im Moment gehen wir davon aus, dass die Kantengewichte positiv (oder null) sind. Die überraschenden Auswirkungen von negativen Gewichten stehen im Mittelpunkt unserer Untersuchungen am Ende dieses Abschnitts. Abbildung 4.66: Bäume der kürzesten Pfade
682
4.4 Kürzeste Pfade
Kürzeste Pfade sind normalerweise einfach. Unsere Algorithmen ignorieren Kanten mit dem Gewicht null, die Zyklen bilden. Die kürzesten Pfade, die unsere Algorithmen finden, weisen daher keine Zyklen auf.
Kürzeste Pfade sind nicht notwendigerweise eindeutig. Es kann mehrere Pfade des geringsten Gewichts von einem Knoten zu einem anderen geben. Uns reicht es, einen davon zu finden.
Es können parallele und reflexive Kanten vorliegen. Aus einer Menge von parallelen Kanten wird lediglich die Kante mit dem geringsten Gewicht berücksichtigt und kein kürzester Pfad enthält eine reflexive Kante (außer vielleicht eine Kante mit dem Gewicht null, die wir ignorieren). Im Text gehen wir implizit davon aus, dass es keine parallelen Kanten gibt, damit wir die bequeme Notation v->w verwenden können, um eindeutig auf die Kante von v nach w Bezug zu nehmen. Unser Code jedoch verarbeitet diese Kanten ohne Probleme.
Kürzeste-Pfade-Baum Unser Interesse hier gilt dem Problem der kürzesten Pfade mit einem Startknoten, bei dem wir von einem Startknoten s ausgehen. Das Ergebnis der Berechnung ist ein Baum, bezeichnet als Kürzeste-Pfade-Baum (shortest-path tree, SPT), der einen kürzesten Pfad von s zu jedem Knoten liefert, der von s aus erreichbar ist.
Definition: Kürzeste-Pfade-Baum Gegeben seien ein kantengewichteter Digraph und ein bestimmter Knoten s; ein Kürzeste-Pfade-Baum für einen Startknoten s ist ein Teilgraph, der s und alle von s aus erreichbaren Knoten enthält, die einen gerichteten Baum mit der Wurzel s bilden, sodass jeder Baumpfad ein kürzester Pfad im Digraphen ist.
Solch einen Baum gibt es immer. Dabei kann es durchaus zwei Pfade der gleichen Länge geben, die s mit einem Knoten verbinden; wenn das der Fall ist, können wir die letzte Kante bei einem der Pfade löschen, womit wir fortfahren, bis wir nur einen Pfad haben, der den Startknoten mit jedem Knoten verbinden (ein gewurzelter Baum). Durch das Erstellen eines Kürzeste-Pfade-Baums können wir Clients den kürzesten Pfad von s zu jedem Knoten im Graphen liefern. Für den Baum verwenden wir eine Elternknoten-ReferenzRepräsentation – ganz so, wie wir es für Pfade in Graphen getan haben (siehe Abschnitt 4.1).
Kanten zeigen vom Startknoten weg
Startknoten Abbildung 4.67: Ein KürzestePfade-Baum mit 250 Knoten
683
4
Graphen
4.4.2 Datentypen für kantengewichtete Digraphen Unser Datentyp für gerichtete Kanten ist einfacher als für ungerichtete Kanten, da wir den gerichteten Kanten in nur einer Richtung folgen. Statt der Methoden either() und other() von Edge haben wir jetzt die Methoden from() und to():
Tabelle 4.27 API für gewichtete gerichtete Kanten public class DirectedEdge DirectedEdge(int v, int w, double weight) weight()
Gewicht dieser Kante
int
from()
Knoten, von dem diese Kante ausgeht
int
to()
Knoten, auf den diese Kante zeigt
toString()
String-Repräsentation
double
String
Wie bei unserem Übergang von Graph (Abschnitt 4.1) zu EdgeWeightedGraph (Abschnitt 4.3) nehmen wir in diese API eine Methode edges() auf und verwenden DirectedEdge anstelle von ganzen Zahlen:
Tabelle 4.28 API für kantengewichtete Digraphen public class EdgeWeightedDigraph EdgeWeightedDigraph(int V)
Leerer Digraph mit V Knoten
EdgeWeightedDigraph(In in)
Konstruiert aus in.
int
V()
Anzahl der Knoten
int
E()
Anzahl der Kanten
addEdge(DirectedEdge e)
Fügt e zu diesem Digraphen hinzu.
Iterable
adj(int v)
Kanten, die von v ausgehen
Iterable
edges()
Alle Kanten in diesem Digraphen
toString()
String-Repräsentation
void
String
684
4.4 Kürzeste Pfade
Sie finden Implementierungen dieser beiden APIs auf den folgenden zwei Seiten. Sie sind natürliche Erweiterungen der Implementierungen von Abschnitt 4.2 und Abschnitt 4.3. Anstelle der Adjazenzlisten von ganzen Zahlen, wie sie in Digraph verwendet werden, haben wir hier Adjazenzlisten von DirectedEdge-Objekten in EdgeWeightedDigraph. Wie beim Übergang von Graph (Abschnitt 4.1) zu Digraph (Abschnitt 4.2) vereinfacht der Übergang von EdgeWeightedGraph (Abschnitt 4.3) zu EdgeWeightedDigraph in diesem Abschnitt den Code, da jede Kante in der Datenstruktur nur einmal auftaucht.
Listing 4.26: Datentyp für gerichtete gewichtete Kanten public class DirectedEdge { private final int v; private final int w; private final double weight;
// Startknoten der Kante // Zielknoten der Kante // Gewicht der Kante
public DirectedEdge(int v, int w, double weight) { this.v = v; this.w = w; this.weight = weight; } public double weight() { return weight; } public int from() { return v; } public int to() { return w; } public String toString() { return String.format("%d->%d %.2f", v, w, weight);
}
}
Die Implementierung DirectedEdge ist einfacher als die Implementierung Edge, die wir in Abschnitt 4.3 (Listing 4.20) für ungerichtete gewichtete Kanten kennengelernt haben, da sich die beiden Kanten unterscheiden. Unsere Clients verwenden den idiomatischen Code int v = e.to(), w = e.from();, um auf die beiden Knoten eines DirectedEdge-Objekts e zuzugreifen.
685
4
Graphen
Listing 4.27: Datentyp eines kantengewichteten Digraphen public class EdgeWeightedDigraph { private final int V; private int E; private Bag[] adj;
// Anzahl der Knoten // Anzahl der Kanten // Adjazenzlisten
public EdgeWeightedDigraph(int V) { this.V = V; this.E = 0; adj = (Bag[]) new Bag[V]; for (int v = 0; v < V; v++) adj[v] = new Bag(); } public EdgeWeightedDigraph(In in) // Siehe Übung 4.4.2. public int V() { public int E() {
return V; return E;
} }
public void addEdge(DirectedEdge e) { adj[e.from()].add(e); E++; } public Iterable adj(int v) { return adj[v]; } public Iterable edges() { Bag bag = new Bag(); for (int v = 0; v < V; v++) for (DirectedEdge e : adj[v]) bag.add(e); return bag; } }
Diese EdgeWeightedDigraph-Implementierung ist eine Mischung aus EdgeWeightedGraph und Digraph, die ein knotenindiziertes Array von Bag-Objekten von DirectedEdge-Objekten verwaltet. Wie bei Digraph erscheint jede Kante nur ein-
mal: Wenn eine Kante v mit w verbindet, erscheint sie in der Adjazenzliste von v. Reflexive und parallele Kanten sind erlaubt. Die toString()-Implementierung bleibt Ihnen als Übung 4.4.2 überlassen.
686
4.4 Kürzeste Pfade
tinyEWD.txt
V
8 15 4 5 5 4 4 7 5 7 7 5 5 1 0 4 0 2 7 3 1 3 2 7 6 2 3 6 6 0 6 4
0 2 .26
0 4 .38
E 0.35 0.35 0.37 0.28 0.28 0.32 0.38 0.26 0.39 0.29 0.34 0.40 0.52 0.58 0.93
adj[]
1 3 .29
0 1 2 3 4 5 6
2 7 .34
Bag-Objekte
Referenz auf ein
3 6 .52
DirectedEdge-
Objekt
4 7 .37
4 5 .35
5 1 .32
5 7 .28
5 4 .35
6 4 .93
6 0 .58
6 2 .40
7 3 .39
7 5 .28
7
Abbildung 4.68: Repräsentation eines kantengewichteten Digraphen Abbildung
4.68 zeigt die Datenstruktur, die EdgeWeightedDigraph aufbaut, um den Digraphen zu repräsentieren, wobei der Digraph durch die links aufgeführten Kanten definiert ist, die in der Reihenfolge, in der sie erscheinen, hinzugefügt werden. Wie üblich repräsentieren wir die Adjazenzlisten durch Bag-Objekte und stellen sie als verkettete Listen dar – der Standardrepräsentation. Wie bei den ungewichteten Digraphen in Abschnitt 4.2 wird jede Kante nur einmal in der Datenstruktur repräsentiert.
API der kürzesten Pfade Für kürzeste Pfade verwenden wir das gleiche Entwurfsmuster wie für die DepthFirstPaths- und BreadthFirstPaths-APIs in Abschnitt 4.1. Unsere Algorithmen implementieren die folgende API, um Clients die kürzesten Pfade und ihre Längen zurückliefern zu können:
Tabelle 4.29 API für Kürzeste-Pfade-Implementierungen public class SP SP(EdgeWeightedDigraph G, int s)
Konstruktor
double
distTo(int v)
Abstände von s zu v; ∞, wenn es keinen Pfad gibt
boolean
hasPathTo(int v)
Gibt es einen Pfad von s zu v?
Iterable
pathTo(int v)
Pfad von s zu v, null wenn keiner vorhanden
Der Konstruktor erstellt den Baum der kürzesten Pfade und berechnet jeweils die Distanzen dieser Pfade; die Client-Abfragemethoden nutzen diese Datenstrukturen, um dem Client Distanzen und iterierbare Pfade zurückzuliefern.
687
4
Graphen
Testclient Einen Beispielclient finden Sie in Listing 4.28. Er übernimmt einen Eingabestrom und einen Startknotenindex als Befehlszeilenargumente, liest den kantengewichteten Digraphen aus dem Eingabestrom, berechnet den Kürzeste-Pfade-Baum dieses Digraphen für den Startknoten und gibt den kürzesten Pfad vom Startknoten zu jedem der anderen Knoten aus. Wir gehen davon aus, dass alle unsere Kürzeste-Pfade-Implementierungen diesen Testclient umfassen. Unsere Beispiele verwenden die Datei tinyEWD.txt aus Abbildung 4.68. Diese Datei definiert die Kanten und Gewichte für den kleinen Beispieldigraphen, der unseren ausführlichen Ablaufprotokollen der Kürzeste-Pfade-Algorithmen zugrunde liegt. Sie weist das gleiche Dateiformat auf, das wir schon von den Algorithmen für die minimalen Spannbäume kennen: die Anzahl der Knoten V und die Anzahl der Kanten E gefolgt von E Zeilen, die jeweils zwei Knotenindizes und ein Gewicht angeben. Sie finden auf der Website zum Buch mehrere Dateien, die größere kantengewichtete Digraphen definieren, einschließlich der Datei mediumEWD.txt, die einen Graphen mit 250 Knoten definiert (Abbildung 4.67). In der Abbildung dieses Graphen stellt jede Linie die Kanten in beide Richtungen dar, sodass diese Datei zweimal so viele Zeilen hat wie die entsprechende Datei mediumEWG.txt, die wir für minimale Spannbäume untersucht haben. In der Darstellung des Kürzeste-Pfade-Baums stellt jede Linie eine gerichtete Kante dar, die vom Startknoten wegzeigt. public static void main(String[] args) { EdgeWeightedDigraph G; G = new EdgeWeightedDigraph(new In(args[0])); int s = Integer.parseInt(args[1]); SP sp = new SP(G, s); for (int t = 0; t < G.V(); t++) { StdOut.print(s + " to " + t); StdOut.printf(" (%4.2f): ", sp.distTo(t)); if (sp.hasPathTo(t)) for (DirectedEdge e : sp.pathTo(t)) StdOut.print(e + " "); StdOut.println(); } } Listing 4.28: Kürzeste-Pfade-Testclient
% 0 0 0 0 0 0 0 0
688
java to 0 to 1 to 2 to 3 to 4 to 5 to 6 to 7
SP tinyEWD.txt 0 (0.00): (1.05): 0->4 0.38 (0.26): 0->2 0.26 (0.99): 0->2 0.26 (0.38): 0->4 0.38 (0.73): 0->4 0.38 (1.51): 0->2 0.26 (0.60): 0->2 0.26
4->5 0.35
5->1 0.32
2->7 0.34
7->3 0.39
4->5 0.35 2->7 0.34 2->7 0.34
7->3 0.39
3->6 0.52
4.4 Kürzeste Pfade
Datenstrukturen für kürzeste Pfade Die Datenstrukturen, die wir für die Darstellung der kürzesten Pfade benötigen, sind einfach:
Kanten im Kürzeste-Pfade-Baum: Wie bei der Tiefensuche, der Breitensuche und dem Prim-Algorithmus verwenden wir eine Elternknoten-Kanten-Repräsentation in der Form eines knotenindizierten Arrays edgeTo[] von DirectedEdge-Objekten, wobei edgeTo[v] die Kante ist, die v mit seinem Elternknoten im Baum verbindet (die letzte Kante in einem kürzesten Pfad von s zu v).
Abstand zum Startknoten: Wir verwenden ein knotenindiziertes Array distTo[], sodass distTo[v] die Länge des kürzesten bekannten Pfades von s zu v ist. Per Konvention hat edgeTo[s] den Wert null und distTo[s] den Wert 0. Wir folgen ferner der Konvention, dass die Abstände zu Knoten, die nicht vom Startknoten aus erreichbar sind, alle Double.POSITIVE_INFINITY sind. Wie immer entwickeln wir Datentypen, die Abbildung 4.69: Datenstrukturen für kürzeste Pfade diese Datenstrukturen im Konstruktor erstellen und dann über Instanzmethoden davon Gebrauch machen, um Client-Abfragen nach den kürzesten Pfaden und den dazugehörigen Abständen zu unterstützen.
Kantenrelaxation Unsere Kürzeste-Pfade-Implementierungen basieren auf einer einfachen Operation, die als Relaxation bezeichnet wird. Am Anfang kennen wir vom Graphen nur die Kanten und Gewichte, wobei der distTo[]-Eintrag für den Startknoten mit 0 initialisiert wird und alle anderen distTo[]-Einträge mit Double.POSITIVE_INFINITY initialisiert werden. Im Verlauf seiner Ausführung sammelt ein Algorithmus Schritt für Schritt Informationen über die kürzesten Pfade vom Startknoten zu jedem Knoten, auf den er in unseren Datenstrukturen edgeTo[] und distTo[] trifft. Stoßen wir dabei auf Kanten, aktualisieren wir diese Informationen und können daraufhin neue Schlussfolgerungen über die kürzesten Pfade ziehen. Insbesondere bedienen wir uns hierfür der sogenannten Kantenrelaxation, d.h. wir relaxieren eine Kante v->w, indem wir testen, ob der beste bekannte Weg von s zu w darin besteht, von s nach v zu gehen und dann der Kante von v nach w zu folgen, und wenn dies der Fall sein sollte, aktualisieren wir unsere Datenstrukturen, damit sie dies auch widerspiegeln. Der Code in Listing 4.29 implementiert diese Operation.
689
4
Graphen
private void relax(DirectedEdge e) { int v = e.from(), w = e.to(); if (distTo[w] > distTo[v] + e.weight()) { distTo[w] = distTo[v] + e.weight(); edgeTo[w] = e; } } Listing 4.29: Kantenrelaxation
Der beste bekannte Abstand zu w über v ist die Summe von distTo[v] und e.weight(). Wenn dieser Wert nicht kleiner ist als distTo[w], sagen wir, dass die Kante nicht auswählbar ist, und ignorieren sie; wenn er kleiner ist, aktualisieren wir die Datenstrukturen. Abbildung 4.70 veranschaulicht die beiden möglichen Ergebnisse einer Kantenrelaxation. Entweder die Kante ist nicht auswählbar (wie im Beispiel links) und es erfolgen keine Änderungen oder die Kante v->w führt zu einem kürzeren Pfad zu w (wie im Beispiel rechts) und wir aktualisieren edgeTo[w] und distTo[w] (was dazu führen kann, dass einige andere Kanten nicht auswählbar sind und einige neue auswählbare Kanten erzeugt werden). Der Begriff Relaxation basiert auf der Vorstellung, dass der Pfad, der zwei Knoten verbindet, ein stark gedehntes Gummiband ist: Das Relaxieren einer Kante entspricht dem Entspannen des Gummibands entlang eines kürzeren Pfades, sofern vorhanden. Wir sagen, dass eine Kante e erfolgreich relaxiert werden kann, wenn relax() die Werte von distTo[e.to()] und edgeTo[e.to()] ändert. distTo[v]
v->w ist nicht auswählbar v
3.1
v->w ist auswählbar v 3.1
Gewicht von v->w ist 1.3 s
s w 3.3
schwarze Kanten stehen in edgeTo[]
w 7.2
distTo[w]
v
v 3.1 edgeTo[w]
keine Änderungen s
s w
w 4.4
nicht länger im Kürzeste-Pfade-Baum
Abbildung 4.70: Kantenrelaxation (zwei Fälle)
690
4.4 Kürzeste Pfade
Knotenrelaxation Alle unsere Implementierungen relaxieren genau vorher genommen alle Kanten, die von einem gegebenen v Knoten ausgehen, wie die (überladene) Implemens tierung von relax() in Listing 4.30 zeigt. Beachten Sie, dass jede Kante von einem Knoten, dessen distTo[v]-Eintrag eine endliche Zahl ist, zu einem immer noch Knoten, dessen distTo[]-Eintrag unendlich ist, nachher nicht auswählbar auswählbar ist und bei Relaxation zu edgeTo[] hinv zugefügt wird. Insbesondere wird eine der Kans ten, die vom Startknoten ausgehen, als Erste zu edgeTo[] hinzugefügt. Unsere Algorithmen wähjetzt nicht auswählbar len die Knoten sehr sorgfältig, sodass jede Knotenrelaxation einen kürzeren als den besten bisher Abbildung 4.71: Knotenrelaxation bekannten Pfad zu irgendeinem Knoten findet, wobei wir uns schrittweise unserem Ziel nähern, zu jedem Knoten einen kürzesten Pfad zu finden. private void relax(EdgeWeightedDigraph G, int v) { for (DirectedEdge e : G.adj(v)) { int w = e.to(); if (distTo[w] > distTo[v] + e.weight()) { distTo[w] = distTo[v] + e.weight(); edgeTo[w] = e; } } } Listing 4.30: Knotenrelaxation
Client-Abfragemethoden Ähnlich wie in unseren Implementierungen zu den Pfadsuche-APIs in Abschnitt 4.1 (und Übung 4.1.13) unterstützen die Datenstrukturen edgeTo[] und distTo[] direkt die Client-Abfragemethoden pathTo(), hasPathTo() und distTo() (Listing 4.31). Diesen Code finden Sie in allen unseren Kürzeste-Pfade-Implementierungen. Wie wir bereits erwähnt haben, ist distTo[v] nur von Bedeutung, wenn v von s aus erreichbar ist; außerdem halten wir uns an die Konvention, dass distTo() bei Knoten, die nicht von s aus erreichbar sind, unendlich zurückliefert. Um diese Konvention zu implementieren, initialisieren wir alle distTo[]-Einträge mit Double.POSITIVE_INFINITY und distTo[s] mit 0. Unsere Kürzeste-Pfade-Implementierungen setzen dann distTo[v] auf einen endlichen Wert für alle Knoten v, die vom Startknoten aus erreichbar sind.
691
4
Graphen
KürzesteWir können also auf das marked[]-Array verzichPfade-Baum ten, mit dessen Hilfe wir normalerweise erreichbare Knoten in einer Graphensuche markieren, und hasPathTo(v) implementieren, indem wir testen, ob distTo[v] gleich Double.POSITIVE_ INFINITY ist. Bei pathTo() halten wir uns an die pathTo(6) Konvention, dass pathTo(v) null zurückliefert, e path 3->6 wenn v nicht vom Startknoten aus erreichbar ist, 7->3 3->6 und einen Pfad ohne Kanten, wenn v der Start2->7 7->3 3->6 0->2 2->7 7->3 3->6 knoten ist. Bei den erreichbaren Knoten wandern null 0->2 2->7 7->3 3->6 wir den Baum hoch und legen die Kanten, die Abbildung 4.72: Ablaufprotokoll der path wir dabei finden, genauso auf einem Stapel ab, To()-Berechnung wie wir es bei DepthFirstPaths und BreadthFirstPaths gemacht haben. Abbildung 4.72 zeigt die Pfadsuche von 0->2->7->3->6 für unser Beispiel.
public double distTo(int v) { return distTo[v]; } public boolean hasPathTo(int v) { return distTo[v] < Double.POSITIVE_INFINITY;
}
public Iterable pathTo(int v) { if (!hasPathTo(v)) return null; Stack path = new Stack(); for (DirectedEdge e = edgeTo[v]; e != null; e = edgeTo[e.from()]) path.push(e); return path; } Listing 4.31: Client-Abfragemethoden für kürzeste Pfade
4.4.3 Theoretische Grundlagen für Kürzeste-Pfade-Algorithmen Die Kantenrelaxation ist eine leicht zu implementierende grundlegende Operation und bildet eine gute Grundlage für unsere Kürzeste-Pfade-Implementierungen. Darüber hinaus bildet sie aber auch ein theoretisches Fundament, das uns hilft, die Algorithmen besser zu verstehen, und bietet die Möglichkeit, den Beweis für die Korrektheit unserer Algorithmen schon zu Beginn zu erledigen.
Optimalitätsbedingungen Der folgende Satz zeigt eine Äquivalenz zwischen der globalen Bedingung, dass die Abstände Kürzeste-Pfade-Distanzen sind, und der lokalen Bedingung, die wir testen, um eine Kante zu relaxieren.
692
4.4 Kürzeste Pfade
Satz P (Optimalitätsbedingungen für kürzeste Pfade): Sei G ein kantengewichteter Digraph, s ein Startknoten in G und distTo[] ein knotenindiziertes Array der Pfadlängen in G, sodass gilt: Für alle v, die von s aus erreichbar sind, ist der Wert von distTo[v] die Länge irgendeines Pfades von s zu v, wobei distTo[v] für alle nicht von s aus erreichbaren v unendlich ist. Diese Werte sind genau dann die Längen der kürzesten Pfade, wenn sie die Voraussetzung erfüllen, dass distTo[w] distTo[v] + e.weight() für irgendeine Kante e von v zu w, dann würde e einen Pfad von s zu w (über v) liefern, dessen Länge geringer ist als distTo[w], was ein Widerspruch ist. Folglich sind die Optimalitätsbedingungen notwendig. Um zu beweisen, dass die Optimalitätsbedingungen ausreichen, nehmen wir an, dass w von s aus erreichbar ist und dass s = v0->v1->v2...->vk = w ein kürzester Pfad von s zu w mit dem Gewicht OPTsw ist. Für i von 1 bis k bezeichnen wir die Kante von vi-1 zu vi mit ei. Gemäß den Optimalitätsbedingungen haben wir die folgende Folge von Ungleichungen: distTo[w] = distTo[vk] 5 zum Baum und 1 zur Vorrangwarteschlange hinzu. Kante 5->7 ist nicht auswählbar.
Er entfernt 3 aus der Vorrangwarteschlange, fügt 7->3 zum Baum und 6 zur Vorrangwarteschlange hinzu.
Er entfernt 1 aus der Vorrangwarteschlange, fügt 5->1 zum Baum hinzu. Kante 1->3 ist nicht auswählbar.
Er entfernt 6 aus der Vorrangwarteschlange, fügt 3->6 zum Baum hinzu. Die Knoten werden dem Baum der kürzesten Pfade in aufsteigender Reihenfolge ihrer Abstände zum Startknoten hinzugefügt. Dies wird rechts neben den beiden Arrayinhalten durch rote Pfeile gekennzeichnet. Abbildung 4.74: Ablaufprotokoll des Dijkstra-Algorithmus
696
4.4 Kürzeste Pfade
Listing 4.32: Dijkstra-Algorithmus der kürzesten Pfade (Algorithmus 4.9) public class DijkstraSP { private DirectedEdge[] edgeTo; private double[] distTo; private IndexMinPQ pq; public DijkstraSP(EdgeWeightedDigraph G, int s) { edgeTo = new DirectedEdge[G.V()]; distTo = new double[G.V()]; pq = new IndexMinPQ(G.V()); for (int v = 0; v < G.V(); v++) distTo[v] = Double.POSITIVE_INFINITY; distTo[s] = 0.0; pq.insert(s, 0.0); while (!pq.isEmpty()) relax(G, pq.delMin()) } private void relax(EdgeWeightedDigraph G, int v) { for(DirectedEdge e : G.adj(v)) { int w = e.to(); if (distTo[w] > distTo[v] + e.weight()) { distTo[w] = distTo[v] + e.weight(); edgeTo[w] = e; if (pq.contains(w)) pq.change(w, distTo[w]); else pq.insert(w, distTo[w]); } } } public double distTo(int v) // Standardmäßige Client-Abfragemethoden public boolean hasPathTo(int v) // für SPT-Implementierungen public Iterable pathTo(int v) // (Siehe Listing 4.31.) }
Diese Implementierung des Dijkstra-Algorithmus lässt den Kürzeste-Pfade-Baum wachsen, indem bei jedem Schritt eine Kante hinzugefügt wird. Dabei wird immer die Kante gewählt, die von einem Baumknoten zu einem Nicht-Baumknoten verläuft und deren Ziel w dem Startknoten s am nächsten ist.
697
4
Graphen
Die Implementierung des Dijkstra-Algorithmus in DijkstraSP (Algorithmus 4.9) ist die Code gewordene Kurzbeschreibung des Algorithmus, realisiert durch das Hinzufügen einer Anweisung zu relax(), die zwei Fälle unterscheidet: Entweder ist der to()-Knoten einer Kante noch nicht in der Vorrangwarteschlange, sodass er mit insert() dort eingefügt wird, oder er ist bereits in der Vorrangwarteschlange und seine Priorität wird herabgestuft, was wir mit change() erledigen.
Satz R (Fortsetzung): Der Algorithmus von Dijkstra benötigt zusätzlichen Speicher proportional V und Zeit proportional E log V (im schlimmsten Fall), um den Kürzeste-Pfade-Baum zu berechnen, der an einem gegebenen Startknoten eines kantengewichteten Digraphen mit E Kanten und V Knoten gewurzelt ist. Beweis: Analog dem Algorithmus von Prim (siehe Satz N ).
Wie wir bereits erwähnt haben, gibt es Parallelen zwischen dem Dijkstra-Algorithmus zur Berechnung der kürzesten Pfade und dem Prim-Algorithmus zur Berechnung des minimalen Spannbaums aus Abschnitt 4.3. Beide Algorithmen erstellen einen gewurzelten Baum, indem sie einem wachsenden Baum eine Kante nach der anderen hinzufügen: Bei Prim wird als Nächstes der Nicht-Baumknoten hinzugefügt, der dem Baum am nächsten ist, während bei Dijkstra als Nächstes der Nicht-Baumknoten hinzugefügt wird, der dem Startknoten am nächsten ist. Das Array marked[] wird nicht benötigt, da die Bedingung !marked[w] der Bedingung entspricht, dass distTo[w] unendlich ist. Mit anderen Worten, wenn Sie anstelle von gerichteten Graphen ungerichtete Graphen und Kanten verwenden und in Algorithmus 4.9 in relax() die Referenzen auf distTo[v] weglassen, erhalten Sie eine Implementierung von Algorithmus 4.7, die Eager-Version des Prim-Algorithmus(!). Eine Lazy-Version des Dijkstra-Algorithmus in Anlehnung an LazyPrimMST (Listing 4.23) zu entwickeln, ist ebenfalls nicht sonderlich schwierig.
Varianten Unsere Implementierung des Dijkstra-Algorithmus löst nach entsprechenden Änderungen auch ähnliche Probleme. Kürzeste-Pfade-Problem mit einem Startknoten in ungerichteten Graphen (single-source shortest paths, SSSP): Wir möchten für einen gegebenen kantengewichteten ungerichteten Graphen und einem Startknoten s Fragen beantworten wie Gibt es einen Pfad von s zu einem gegebenen Zielknoten v? Wenn ja, finde den kürzesten Pfad (einer, dessen Gesamtgewicht minimal ist).
698
4.4 Kürzeste Pfade
Die Lösung dieses Problems ergibt sich unmittelbar, wenn wir den ungerichteten Graphen als Digraphen betrachten. Das heißt, erstellen Sie aus einem gegebenen ungerichteten Graphen einen kantengewichteten Digraphen mit den gleichen Knoten und mit zwei gerichteten Kanten (eine in jede Richtung) für jede Kante im Graphen. Es gibt eine Eins-zu-eins-Korrespondenz zwischen den Pfaden im Digraphen und den Pfaden im Graphen und die Kosten der Pfade sind die gleichen – die Kürzeste-Pfade-Probleme sind identisch. Kürzeste-Pfade-Problem für ein bestimmtes Knotenpaar (source-sink shortest paths): Gegeben seien ein kantengewichteter Digraph, ein Startknoten s und ein Zielknoten t. Finde den kürzesten Pfad von s nach t.
Lösen Sie dieses Problem mit dem Algorithmus von Dijkstra, aber beenden Sie die Suche, sobald t der Vorrangwarteschlange entnommen wurde. Kürzeste-Pfade-Problem für alle Knotenpaare (all-pairs shortest paths): Wir möchten für einen gegebenen kantengewichteten Digraphen Fragen beantworten wie Gibt es für einen gegebenen Startknoten s und einen Zielknoten t einen Pfad von s zu t? Wenn ja, finde den kürzesten Pfad (einer, dessen Gesamtgewicht minimal ist).
Die erstaunlich kompakte Implementierung aus Listing 4.33 löst das Kürzeste-PfadeProblem für alle Knotenpaare mit einem Speicher- und Zeitaufwand proportional E V log V. Sie erstellt ein Array von DijkstraSP-Objekten, und zwar ein Objekt für jeden Knoten als Startknoten. Um eine Client-Abfrage zu beantworten, greift sie über den Startknoten auf das entsprechende DijkstraSP -Objekt zu und übergibt das Ziel als Argument an die Abfrage. Kürzeste Pfade in euklidischen Graphen: Lösen Sie das Kürzeste-Pfade-Problem für einen Startknoten, für ein bestimmtes Knotenpaar und für alle Knotenpaare in Graphen, in denen Knoten Punkte in der Ebene sind und Kantengewichte proportional den euklidischen Distanzen zwischen den Knoten sind.
Mit einer kleinen Änderung können Sie in diesem Fall den Algorithmus von Dijkstra enorm beschleunigen (siehe Übung 4.4.27). Die Diagramme in Abbildung 4.75 zeigen den Aufbau des Kürzeste-Pfade-Baums, wie er vom Dijkstra-Algorithmus für den euklidischen Graphen (definiert durch unsere Testdatei mediumEWD.txt) für mehrere verschiedene Startknoten berechnet wird. Wir erinnern uns, dass Liniensegmente in diesem Graphen gerichtete Kanten in beide Richtungen repräsentieren. Auch in diesem Fall veranschaulichen die Diagramme einen faszinierenden dynamischen Prozess. Als Nächstes betrachten wir Kürzeste-Pfade-Algorithmen für azyklische kantengewichtete Graphen, die es erlauben, das Problem in linearer Zeit zu lösen (d.h. schneller als der Dijkstra-Algorithmus), und dann für kantengewichtete Digraphen mit negativen Gewichten, bei denen uns der Algorithmus von Dijkstra nicht weiterhilft.
699
4
Graphen
public class DijkstraAllPairsSP { private DijkstraSP[] all; DijkstraAllPairsSP(EdgeWeightedDigraph G) { all = new DijkstraSP[G.V()]; for (int v = 0; v < G.V(); v++) all[v] = new DijkstraSP(G, v); } Iterable path(int s, int t) { return all[s].pathTo(t); } double dist(int s, int t) { return all[s].distTo(t);
}
} Listing 4.33: Kürzeste-Pfade-Problem für alle Knotenpaare 20% Startknoten
40%
60%
80%
SPT
Abbildung 4.75: Dijkstra-Algorithmus (250 Knoten, verschiedene Startknoten)
700
4.4 Kürzeste Pfade
4.4.5 Azyklische kantengewichtete Digraphen Bekanntermaßen haben kantengewichtete Digraphen in vielen natürlichen Anwendungen keine gerichteten Zyklen, d.h., sie sind azyklisch. Deshalb wollen wir uns jetzt einem Kürzeste-Pfade-Algorithmus zuwenden, der für kantengewichtete azyklische Digraphen einfacher und schneller ist als der Dijkstra-Algorithmus. Unter anderem kann er
das Kürzeste-Pfade-Problem mit einem Startknoten in linearer Zeit lösen, negative Kantengewichte verarbeiten, verwandte Probleme lösen, wie das Finden des längsten Pfades. Diese Algorithmen sind unkomplizierte Erweiterungen des Algorithmus zum topologischen Sortieren in azyklischen Digraphen, den Sie bereits aus Abschnitt 4.2 kennen. tinyEWDAG.txt So lässt sich zum Beispiel das KürzesteV 8 Pfade-Problem mit einem Startknoten für E 13 kantengewichtete azyklische Digraphen mit5 4 0.35 hilfe von Knotenrelaxation in Kombination 4 7 0.37 5 7 0.28 mit einer topologischen Sortierung direkt 5 1 0.32 lösen. Wir initialisieren distTo[s] mit 0 und 4 0 0.38 0 2 0.26 alle anderen distTo[]-Werte mit unendlich 3 7 0.39 und relaxieren dann die Knoten einen nach 1 3 0.29 dem anderen in topologischer Reihenfolge. 7 2 0.34 6 2 0.40 Ein Satz, der ähnlich, aber einfacher ist als 3 6 0.52 der Satz zum Algorithmus von Dijkstra in 6 0 0.58 6 4 0.93 Abschnitt 4.4.4, belegt die Wirksamkeit dieser Vorgehensweise. Abbildung 4.76: Ein azyklischer kantengewichteter
Digraph mit einem Kürzeste-Pfade-Baum
Satz S: Durch Relaxieren der Knoten in topologischer Reihenfolge können wir das Kürzeste-PfadeProblem mit einem Startknoten für kantengewichtete azyklische Digraphen in einer Zeit proportional E + V lösen. Beweis: Jede Kante v->w wird genau einmal relaxiert, und zwar wenn v relaxiert wird, woraus sich distTo[w] 3 zum Baum hinzu. Er fügt 3 und 3->6 zum Baum hinzu, aber
rot: zum Baum hinzufügen
nicht die Kante 3->7, die nicht auswählbar ist.
Er fügt 6 und die Kanten 6->2 und 6->0 zum Baum hinzu, aber nicht die Kante 6->4, die nicht auswählbar ist.
Er fügt 4 und 4->0 zum Baum hinzu, aber nicht die Kante 4->7, die nicht auswählbar ist. Kante 6->0 wird nicht auswählbar.
Er fügt 7 und 7->2 zum Baum hinzu. Kante 6->2 wird nicht auswählbar.
Er fügt den Knoten 0 zum Baum hinzu, aber nicht seine inzidente Kante 0->2, die nicht auswählbar ist.
Er fügt 2 zum Baum hinzu. Das Hinzufügen von 2 zum Baum ist nicht dargestellt; der letzte Knoten in einer topologischen Sortierung hat keine Kanten, die von ihm ausgehen.
grau: nicht auswählbar
Die Implementierung in Algorithmus 4.10 (Listing 4.34) ist eine einfache Anwendung, deren Code wir bereits betrachtet haben. Sie geht davon aus, dass es in Topological überladene Methoden zum topologischen Sortieren gibt, die auf die APIs für EdgeWeightedDigraph und DirectedEdge aus diesem AbAbbildung 4.77: Ablaufprotokoll für kürzeste Pfade schnitt (Übung 4.4.12) zurückgehen. Beachten in einem kantengewichteten azyklischen Digraphen Sie, dass unser boolesches Array marked[] in dieser Implementierung nicht benötigt wird: Da wir die Knoten in einem azyklischen Digraphen in topologischer Reihenfolge verarbeiten, treffen wir nie ein zweites Mal
702
4.4 Kürzeste Pfade
auf einen Knoten, den wir bereits relaxiert haben. Algorithmus 4.10 könnte kaum effizienter sein: Nach dem topologischen Sortieren durchsucht der Konstruktor den Graphen und relaxiert jede Kante genau einmal. Diese Vorgehensweise ist das Verfahren der Wahl, um kürzeste Pfade in kantengewichteten azyklischen Graphen zu finden.
Listing 4.34: Kürzeste Pfade in kantengewichteten azyklischen Digraphen (Algorithmus 4.10) public class AcyclicSP { private DirectedEdge[] edgeTo; private double[] distTo; public AcyclicSP(EdgeWeightedDigraph G, int s) { edgeTo = new DirectedEdge[G.V()]; distTo = new double[G.V()]; for (int v = 0; v < G.V(); v++) distTo[v] = Double.POSITIVE_INFINITY; distTo[s] = 0.0; Topological top = new Topological(G); for (int v : top.order()) relax(G, v); } private void relax(EdgeWeightedDigraph G, int v) // Siehe Listing 4.30. public double distTo(int v)
// Standardmäßige Client// Abfragemethoden public boolean hasPathTo(int v) // für SPT-Implementierungen public Iterable pathTo(int v) // (Siehe Listing 4.31.) }
Dieser Kürzeste-Pfade-Algorithmus für kantengewichtete azyklische Digraphen verwendet eine topologische Sortierung (eine Abwandlung des Algorithmus 4.5, die die Klassen EdgeWeightedDigraph und DirectedEdge verwendet), damit er die Knoten in topologischer Reihenfolge relaxieren kann, was ausreicht, um die kürzesten Pfade zu berechnen. % 5 5 5 5 5 5 5 5
java to 0 to 1 to 2 to 3 to 4 to 5 to 6 to 7
AcyclicSP tinyEWDAG.txt (0.73): 5->4 0.35 4->0 (0.32): 5->1 0.32 (0.62): 5->7 0.28 7->2 (0.61): 5->1 0.32 1->3 (0.35): 5->4 0.35 (0.00): (1.13): 5->1 0.32 1->3 (0.28): 5->7 0.28
5 0.38 0.34 0.29 0.29
3->6 0.52
703
4
Graphen
Satz S ist vor allem deshalb wichtig, weil hier an einem konkreten Beispiel gezeigt wird, dass das Nichtvorhandensein von Zyklen ein Problem beträchtlich vereinfacht. Für kürzeste Pfade ist das Verfahren, das auf einer topologischen Sortierung basiert, schneller als der Algorithmus von Dijkstra, und zwar um einen Faktor proportional der Kosten, die für die Vorrangwarteschlangen-Operationen im Dijkstra-Algorithmus anfallen. Außerdem ist der Beweis von Satz S nicht davon abhängig, dass die Kantengewichte nicht negativ sind, sodass wir diese Beschränkung für kantengewichtete azyklische Digraphen wegfallen lassen können. Als Nächstes betrachten wir, welche Auswirkungen es hat, wenn wir negative Kantengewichte zulassen. Dazu ziehen wir das Kürzeste-Pfade-Modell heran, um zwei andere Probleme zu lösen – von denen eines auf den ersten Blick kaum etwas mit der Graphenverarbeitung zu tun zu haben scheint.
Längste Pfade Im Folgenden betrachten wir das Problem, den längsten Pfad in einem kantengewichteten azyklischen Digraphen zu finden, dessen Kantengewichte positiv oder negativ sein können. Längste-Pfade-Problem mit einem Startknoten in kantengewichteten Digraphen (single-source longest paths): Wir möchten für einen gegebenen kantengewichteten azyklischen Digraphen und einem Startknoten s Fragen beantworten wie Gibt es einen gerichteten Pfad von s zu einem gegebenen Zielknoten v? Wenn ja, finde den längsten Pfad (einer, dessen Gesamtgewicht maximal ist).
Der gerade hier betrachtete Algorithmus bietet eine schnelle Lösung zu diesem Problem.
Satz T: Wir können das Längste-Pfade-Problem in kantengewichteten azyklischen Digraphen in einer Zeit proportional E + V lösen. Beweis: Gegeben sei ein Längste-Pfade-Problem; erzeuge eine Kopie des gegebenen kantengewichteten azyklischen Digraphen, die identisch zum Original ist, nur dass alle Kantengewichte negiert werden. Dann ist der kürzeste Pfad in dieser Kopie der längste Pfad im Original. Um die Lösung des Kürzeste-Pfade-Problems in eine Lösung des Längste-Pfade-Problems umzuwandeln, negiere die Gewichte in der Lösung. Die Laufzeit ergibt sich unmittelbar aus Satz S.
Mithilfe dieser Transformation ist die Entwicklung einer Klasse AcyclicLP, die die längsten Pfade in kantengewichtete azyklische Digraphen findet, ganz einfach. Noch einfacher geht es, wenn Sie AcyclicSP kopieren, dann die distTo[]-Initialisierung zu Double.NEGATIVE_INFINITY ändern und die Richtung der Ungleichung in relax() umdrehen. Auf beiden Wegen erhalten wir eine effiziente Lösung des Längste-PfadeProblems für kantengewichtete azyklische Digraphen. Im Vergleich dazu benötigt der beste bekannte Algorithmus im schlimmsten Fall exponentielle Zeit, um den längsten einfachen Pfad in allgemeinen kantengewichteten Digraphen zu finden (dessen Kantengewichte negativ sein können) (siehe Kapitel 6)! Die Möglichkeit, dass Zyklen vorhanden sind, scheint das Problem exponentiell schwieriger zu machen.
704
4.4 Kürzeste Pfade
Abbildung 4.78 zeigt ein Ablaufprotokoll für topologisches Sortieren 5 1 3 6 4 7 0 2 den Prozess, die längsten Pfade in unserem kantengewichteten azyklischen Beispiel-Digraphen tinyEWDAG.txt zu finden. Vergleichen Sie diese Abbildung mit dem Ablaufprotokoll für kürzeste Pfade für den gleichen azyklischen Digraphen in Abbildung 4.77. Bei diesem Beispiel erstellt der Algorithmus den Längste-Pfade-Baum (longestpaths tree, LPT) von Knoten 5 aus wie folgt:
Er führt eine Tiefensuche durch, um die topologische Ordnung 5 1 3 6 4 7 0 2 zu ermitteln.
Er fügt den Knoten 5 und alle davon ausgehenden Kanten zum Baum hinzu.
Er fügt 1 und 1->3 zum Baum hinzu. Er fügt 3 sowie die Kanten 3-> 6 und 3->7 zum Baum hinzu. Kante 5->7 wird nicht auswählbar.
Er fügt 6 sowie die Kanten 6->2, 6->4 und 6->0 zum Baum hinzu.
Er fügt 4 sowie die Kanten 4->0 und 4->7 zum Baum hinzu. Die Kanten 6->0 und 3->7 werden nicht auswählbar.
Er fügt 7 und die Kante 7->2 zum Baum hinzu. Kante 6->2 wird nicht auswählbar.
Er fügt den Knoten 0 zum Baum hinzu, aber nicht die Kante 0->2, die nicht auswählbar ist.
Er fügt 2 zum Baum hinzu (nicht dargestellt).
jetzt nicht auswählbar
Der Längste-Pfade-Algorithmus verarbeitet die Knoten in der gleichen Reihenfolge wie der Kürzeste-Pfade-Algorithmus, liefert aber ein völlig anderes Ergebnis zurück.
Ablaufplanung für parallele Aufgaben Für eine Beispielanwendung kehren wir zu den Problemen der Ablaufplanung zurück, die wir das erste Mal in Abschnitt 4.2.4 angesprochen haben. In diesem Fall haben wir es mit folgendem Problem der Ablaufplanung zu tun (Unter- Abbildung 4.78: Ablaufprotokoll für längste schiede zum Problem aus Abschnitt 4.2.4 wer- Pfade in einem azyklischen Netzwerk den kursiv dargestellt): Ablaufplanung für parallele Aufgaben mit Vorrangbedingung: Gegeben sei eine Menge von Aufgaben (oder Jobs oder Arbeitsschritten) einer vorgegebenen Dauer
705
4
Graphen
mit Vorrangbedingungen, die vorgeben, dass bestimmte Aufgaben erledigt sein müssen, bevor andere begonnen werden können. Die Frage, die sich uns hierbei stellt, lautet „Wie können wir den Ablauf der Aufgaben so planen, dass alle Aufgaben unter Einhaltung der Bedingungen in minimaler Zeit auf identischen Prozessoren (so viele wie erforderlich) erledigt werden?“ Das Modell aus Abschnitt 4.2 ging implizit von nur einen Prozessor aus: Die Aufgaben wurden in topologischer Reihenfolge geplant und die dafür erforderliche Zeit ergab sich aus der Addition der Fertigstellungszeiten der einzelnen Aufgaben. Im vorliegenden Fall jedoch gehen wir von einem Modell aus, bei dem genügend Prozessoren zur Verfügung stehen, um die Aufgaben im Bedarfsfall parallel zu erledigen, das heißt, wir sind nur durch die Vorrangbedingungen beschränkt. Da die Planung auch hier unter Umständen Tausende, ja sogar Millionen von Aufgaben berücksichtigen muss, benötigen wir einen effizienten Algorithmus. Erfreulicherweise gibt es einen linearen Algorithmus: Die sogenannte Methode des kritischen Pfades (critical path method) belegt, dass das Problem äquivalent ist zu dem Längste-Pfade-Problem in einem kantengewichteten azyklischen Digraphen. Diese Methode wurde erfolgreich in zahllosen konkreten Anwendungen eingesetzt. Wir konzentrieren uns auf den frühestmöglichen Zeitpunkt, Aufgabe Dauer muss fertig sein vor an dem wir die einzelnen Aufgaben in Angriff nehmen 0 41.0 1 7 9 können, wobei wir davon ausgehen, dass jeder verfügbare 1 51.0 2 Prozessor die Aufgabe vollständig erledigen kann. Betrach2 50.0 ten wir beispielsweise das Problem aus der Tabelle in 3 36.0 Abbildung 4.79. Die Lösung in Abbildung 4.80 zeigt, 4 38.0 dass 173.0 die Mindestzeit ist, in der sich die Aufgaben 5 45.0 dieses Problems erledigen lassen, und zwar unabhängig 6 21.0 3 8 vom Zeitplan. Der Zeitplan berücksichtigt alle Bedingun7 32.0 3 8 gen und kein anderer Plan benötigt weniger als 173.0, da 8 32.0 2 die Aufgabenfolge 0->9->6->8->2 eingehalten werden 9 29.0 4 6 muss. Diese Folge wird als kritischer Pfad bezeichnet. Jede Abbildung 4.79: Ein Problem der Aufgabenfolge, deren Aufgaben dadurch beschränkt sind, Ablaufplanung dass sie auf die Beendigung der vorangehenden Aufgabe in der Folge warten müssen, stellt eine untere Zeitschranke dar. Wenn wir die Länge einer solchen Folge als die frühestmögliche Fertigstellungszeit (Gesamtdauer der einzelnen Aufgaben) definieren, ist die längste Folge ein sogenannter kritischer Pfad, weil jede später begonnene Aufgabe die bestmögliche Fertigstellungszeit des ganzen Projekts verzögert. 1 7 0
3
9
6
5 0
8 4
41
70
91
Abbildung 4.80: Lösung zur Ablaufplanung von parallelen Aufgaben
706
2
123
173
4.4 Kürzeste Pfade
Listing 4.35: Methode des kritischen Pfades für die Ablaufplanung paralleler Aufgaben mit Vorrangbedingungen public class CPM { public static void main(String[] args) { int N = StdIn.readInt(); StdIn.readLine(); EdgeWeightedDigraph G; G = new EdgeWeightedDigraph(2*N+2); int s = 2*N, t = 2*N+1; for (int i = 0; i < N; i++) { String[] a = StdIn.readLine().split("\\s+"); double duration = Double.parseDouble(a[0]); G.addEdge(new DirectedEdge(i, i+N, duration)); G.addEdge(new DirectedEdge(s, i, 0.0)); G.addEdge(new DirectedEdge(i+N, t, 0.0)); for (int j = 1; j < a.length; j++) { int successor = Integer.parseInt(a[j]); G.addEdge(new DirectedEdge(i+N, successor, 0.0)); } } AcyclicLP lp = new AcyclicLP(G, s); StdOut.println("Start times:"); for (int i = 0; i < N; i++) StdOut.printf("%4d: %5.1f\n", i, lp.distTo(i)); StdOut.printf("Finish time: %5.1f\n", lp.distTo(t)); } }
% more jobsPC.txt 10 41.0 1 7 9 51.0 2 50.0 36.0 38.0 45.0 21.0 3 8 32.0 3 8 32.0 2 29.0 4 6
707
4
Graphen
Diese CPM-Implementierung zur Ablaufplanung verschiedener Aufgaben reduziert das Problem direkt auf das Längste-Pfade-Problem in kantengewichteten azyklischen Digraphen. Sie erstellt aus der Problemspezifikation der Ablaufplanung, die in einem passenden Format vorliegen muss, einen kantengewichteten Digraphen (speziell einen azyklischen kantengewichteten Digraphen), verwendet dann AcyclicLP (siehe Satz T), um den Längste-Pfade-Baum zu finden, und gibt die Längen der längsten Pfade aus (die den Anfangszeiten der einzelnen Aufgaben entsprechen). % java CPM < jobsPC.txt Start times: 0: 0.0 1: 41.0 2: 123.0 3: 91.0 4: 70.0 5: 0.0 6: 70.0 7: 41.0 8: 91.0 9: 41.0 Finish time: 173.0
Beginn der Aufgabe 0
s
41
Ende der Aufgabe
51
1
0
Dauer NullgewichtKante, die zu jedem Aufgabenbeginn führt
7 6 9
29
32
7
21
Vorrangbedingung (Nullgewicht)
1 8
6
3
32
36
2
50
2
8 3
Nullgewicht-Kante, die von jedem Aufgabenende ausgeht
9 4 5
38
45
t 4 5
Abbildung 4.81: Repräsentation eines kantengewichteten azyklischen Digraphen zur Ablaufplanung
Definition: Methode des kritischen Pfades Wenn Sie mit der Methode des kritischen Pfades (critical path method, CPM ) den Ablauf paralleler Aufgaben planen, sollten Sie wie folgt vorgehen: Erzeugen Sie einen kantengewichteten azyklischen Digraphen mit einer Quelle s, einer Senke t und zwei Knoten für jede Aufgabe (einem Startknoten und einem End knoten). Fügen Sie für jede Aufgabe eine Kante zwischen Start- und Endknoten ein, deren Gewicht gleich der Dauer der Aufgabe ist. Jede Vorrangbedingung v->w drücken Sie durch eine Nullgewicht-Kante von dem Endknoten v zu dem Startknoten w aus. Fügen Sie außerdem Nullgewicht-Kanten von der Quelle zu allen Startknoten sowie von allen Endknoten zur Senke hinzu. Setzen Sie dann bei der Planung die Aufgabe zu der Zeit an, die durch die Länge seines längsten Pfades von der Quelle vorgegeben wird.
708
4.4 Kürzeste Pfade
Abbildung 4.81 zeigt, wie diese Beschreibung sich auf unser Beispielproblem abbilden lässt, und Abbildung 4.82 zeigt die Längste-Pfade-Lösung. Wie angegeben, hat der Graph für jede Aufgabe drei Kanten (Nullgewicht-Kanten von der Quelle zum Startknoten und vom Endknoten zur Senke und eine Kante von Start- zu Endknoten) und eine Kante für jede Vorrangbedingung. Die Klasse CPM in Listing 4.35 ist eine einfache Implementierung der Methode des kritischen Pfades. Sie wandelt jedes Problem der Ablaufplanung in ein Längste-Pfade-Problem in einem kantengewichteten azyklischen Digraphen um, löst dieses mit AcyclicLP und gibt dann die Anfangszeiten der Aufgaben und die Gesamtzeit zur Lösung des Problems aus.
0
41
51
1
0
Dauer
7
s
6 9
29
32
1
7
21
8
6
3
32
36
2
50
2
8 3
kritischer Pfad
9 4 5
38
45
t 4 5
Abbildung 4.82: Längeste-Pfade-Lösung zu unserem Beispiel der Ablaufplanung
Satz U: Die Methode des kritischen Pfades löst das Problem der Ablaufplanung für parallele Aufgaben mit Vorrangbedingungen in linearer Zeit. Beweis: Warum funktioniert der CPM-Ansatz? Die Korrektheit des Algorithmus beruht auf zwei Fakten. Erstens, jeder Pfad in dem azyklischen Digraphen ist eine Folge von Start- und Endknoten der Aufgaben, getrennt durch Nullgewicht-Vorrangbedingungen, d.h., die Länge eines jeden Pfades von der Quelle s zu irgendeinem Knoten v im Graphen ist eine untere Schranke für die Start-/ Endzeit repräsentiert durch v, da wir im besten Fall die sukzessive Ausführung dieser Aufgaben auf der gleichen Maschine planen können. Insbesondere ist die Länge des längsten Pfades von der Quelle s zur Senke t eine untere Zeitschranke für die Ausführung aller Aufgaben. Zweitens, alle Start- und Endzeiten, die ein längster Pfad impliziert, sind denkbar. Das heißt, jede Aufgabe, die durch eine Vorrangbedingung als Nachfolger anderer bestimmter Aufgaben definiert wurde, beginnt erst, nachdem ihre Vorgängeraufgaben abgeschlossen wurden, da ihre Startzeit gleich der Länge des längsten Pfades von der Quelle zu ihr ist. Insbesondere ist die Länge des längsten Pfades von s zu t eine obere Schranke der Gesamtausführungszeit aller Aufgaben. Aus Satz T ergibt sich unmittelbar ein lineares Laufzeitverhalten.
709
4
Graphen
Ablaufplanung für parallele Aufgaben mit relativen Deadlines Normalerweise hängen Deadlines von der Startzeit der ersten Aufgabe ab. Angenommen, wir müssen bei unserer Ablaufplanung eine weitere Art von Bedingung berücksichtigen, die angibt, dass eine Aufgabe vor Ablauf einer bestimmten Frist (deadline) relativ zur Startzeit einer anderen Aufgabe beginnen muss. Solche Bedingungen sind in zeitkritischen Herstellungsprozessen und vielen anderen Anwendungen durchaus üblich, können aber die Ablaufplanung erheblich erschweren. Nehmen wir beispielsweise an, dass wir, wie in Abbildung 4.84 gezeigt, eine Bedingung hinzufügen müssen, der zufolge Aufgabe 2 spätestens 12 Zeiteinheiten nach Beginn von Aufgabe 4 starten muss. Diese Deadline beschränkt im Prinzip die Startzeit von Aufgabe 4, denn diese darf jetzt nicht früher als 12 Zeiteinheiten vor dem Start von Aufgabe 2 beginnen. In unserem Beispiel lässt der Plan uns genügend Spielraum, um diese Frist einzuhalten: Wir können die Startzeit von Aufgabe 4 auf 111 verschieben, 12 Zeiteinheiten vor der anberaumten Startzeit von Aufgabe 2. Dabei gilt es zu bedenken, dass bei einer langen Ausführungszeit von Aufgabe 4 diese Änderung durchaus die Gesamtfertigstellungszeit verlängern kann. Und auch wenn wir im Zeitplan die Bedingung berücksichtigen müssten, dass Aufgabe 2 nicht später als 70 Zeiteinheiten nach Aufgabe 7 beginnt, haben wir genug Spielraum, um die Startzeit von Aufgabe 7 auf 53 zu ändern, ohne die Aufgaben 3 und 8 verschieben zu müssen. Wenn wir jedoch eine Deadline ergänzen, dass Aufgabe 4 spätestens 80 Zeiteinheiten nach Aufgabe 0 starten muss, wird der Zeitplan undurchführbar: Die Bedingungen, dass 4 spätestens 80 Zeiteinheiten nach Aufgabe 0 beginnen muss und Aufgabe 2 spätestens 12 Einheiten nach Aufgabe 4, implizieren, dass Aufgabe 2 spätestens nach 93 Zeiteinheiten nach Aufgabe 0 beginnen muss, was im Widerspruch dazu steht, dass Aufgabe 2 erst 123 Zeiteinheiten nach Aufgabe 0 beginnen kann, da die Kette (kritischer Pfad) lautet: 0 (41 Zeiteinheiten) geht 9 (29 Zeiteinheiten) voraus, geht 6 (21 Zeiteinheiten) voraus, geht 8 (32 Zeiteinheiten) voraus, geht 2 voraus. Fügen wir weitere Deadlines hinzu, so multiplizieren sich die Möglichkeiten und ein leichtes Problem wird schnell zu einem schwierigen.
Original Aufgabe
Start
2 um 12.0 nach 4 Aufgabe
Start
2 um 70.0 nach 7 Aufgabe
Start
4 um 80.0 nach 0 undurchführbar!
Aufgabe
Zeit
relativ zu
2 2 4
12.0 70.0 80.0
4 7 0
Abbildung 4.83: Zu berücksichtigende Deadlines bei der Ablaufplanung
710
Abbildung 4.84: Relative Deadlines bei der Ablaufplanung
4.4 Kürzeste Pfade
Satz V: Die Ablaufplanung von parallelen Aufgaben mit relativen Deadlines ist ein Kürzeste-PfadeProblem in kantengewichteten Digraphen (bei dem Zyklen und negative Gewichte erlaubt sind). Beweis: Verwenden Sie die gleiche Konstruktion wie in Satz U und fügen Sie für jede Deadline eine Kante hinzu: Wenn eine Aufgabe v innerhalb von d Zeiteinheiten des Starts von Aufgabe w in Angriff genommen werden muss, fügen Sie eine Kante von v zu w mit einem negativen Gewicht d ein. Wandeln Sie dann Ihr Problem in ein Kürzeste-Pfade-Problem um, indem Sie alle Gewichte im Digraphen negieren. Der Korrektheitsbeweis gilt, vorausgesetzt der Zeitplan ist durchführbar. Wie Sie bald sehen werden, besteht Ihre Aufgabe auch darin festzustellen, ob ein Zeitplan durchführbar ist.
Dieses Beispiel belegt, dass negative Gewichte eine wichtige Rolle in praktischen Anwendungsmodellen spielen können. Es zeigt, dass, wenn es eine effiziente Lösung zu dem Kürzeste-Pfade-Problem mit negativen Gewichten gibt, es auch eine effiziente Lösung zum Problem der Ablaufplanung für parallele Aufgaben mit relativen Deadlines gibt. Keiner der Algorithmen, die wir bisher betrachtet haben, ist dieser Aufgabe gewachsen: Für den Dijkstra-Algorithmus müssen die Gewichte positiv (oder Null) sein, und Algorithmus 4.10 setzt voraus, dass der Digraph azyklisch ist. Deshalb wollen wir uns als Nächstes damit beschäftigen, wie wir negative Gewichte in Digraphen verarbeiten, die nicht notwendigerweise azyklisch sind. -70
Deadline
0
41
7
s
51
1
0
NullgewichtKante, die zu jedem Aufgabenbeginn führt
6 9
29
32
1
7
21
8
6
3
9 4
38
32
36
2
50
2
8 3
Nullgewicht-Kante, die von jedem Aufgabenende ausgeht t
4 -12
-80
5
45
5
Abbildung 4.85: Kantengewichtete Digraphrepräsentation der parallelen Ablaufplanung mit Vorrangbedingungen und relativen Deadlines
4.4.6 Kürzeste Pfade in allgemeinen kantengewichteten Digraphen Das gerade besprochene Beispiel zur Ablaufplanung mit Deadlines zeigt, dass negative Gewichte nicht allein eine mathematische Besonderheit sind, sondern auch die Anwendbarkeit des Kürzeste-Pfade-Problems als Problemlösungsmodell erweitern. Von daher werden wir im Folgenden Algorithmen für kantengewichtete Digraphen betrachten, die sowohl Zyklen als auch negative Gewichte aufweisen. Doch zuvor gehen wir auf einige grundlegende Eigenschaften solcher Digraphen ein, um unsere ersten Erkenntnisse über kürzeste Pfade zu revidieren. Abbildung 4.86 soll Ihnen die Auswirkungen negativer Gewichte auf die kürzesten Pfade eines Digraphen anhand
711
4
Graphen
eines kleinen Beispiels veranschaulichen. Die vielleicht wichtigste Auswirkung ist, dass kürzeste Pfade mit niedrigem Gewicht in der Regel mehr Kanten haben als Pfade mit höherem Gewicht. Bei positiven Gewichten lag unser Schwerpunkt auf der Suche nach Abkürzungen; haben wir es jedoch mit negativen Gewichten zu tun, dann sind wir an Umwegen interessiert, die über Kanten mit negativen Gewichten führen. Dies hat zur Folge, dass wir unsere Intuition, nach „kurzen“ Wegen zu suchen, aufgeben und das Problem aus einem abstrakten Blickwinkel betrachten müssen. tinyEWDn.txt
V E
negative Gewichte sind die gestrichelten Kanten
Baum der kürzesten Pfade von 0 aus
edgeTo[] distTo[] 0 1 2 3 4 5 6 7
5->1 0->2 7->3 6->4 4->5 3->6 2->7
0.93 0.26 0.99 0.26 0.61 1.51 0.60
Abbildung 4.86: Ein kantengewichteter Digraph mit negativen Gewichten
Vorschlag I Der nächstliegende Gedanke ist, das kleinste (d.h. negativste) Kantengewicht zu finden und dann den Absolutwert dieser Zahl zu allen Kantengewichten hinzuaddieren, um die negativen Gewichte im Digraphen zu beseitigen. Dieser naive Ansatz funktioniert leider gar nicht, da kürzeste Pfade im neuen Digraphen kaum etwas mit den kürzesten Pfaden im alten gemeinsam haben. Je mehr Kanten ein Pfad hat, desto mehr ist er von dieser Umwandlung betroffen (siehe Übung 4.4.14).
Vorschlag II Der zweite Gedanke, der sich aufdrängt, wäre zu versuchen, den Dijkstra-Algorithmus irgendwie anzupassen. Die grundlegende Schwierigkeit bei diesem Ansatz ist, dass der Algorithmus darauf basiert, die Pfade in aufsteigender Reihenfolge ihrer Entfernung vom Startknoten zu untersuchen. Der Korrektheitsbeweis des Algorithmus in Satz R geht davon aus, dass das Hinzufügen einer Kante den Pfad verlängert. Doch jede Kante mit negativem Gewicht verkürzt den Pfad, sodass die Annahme ungültig ist (siehe Übung 4.4.14).
712
4.4 Kürzeste Pfade
Negative Zyklen Wenn wir Digraphen mit potenziell negativen Kantengewichten betrachten, ist das V Konzept eines kürzesten Pfades hinfällig, wenn es im Digraphen einen Zyklus negativer Länge gibt. Nehmen wir beispielsweise den Digraphen aus Abbildung 4.87, der identisch ist zu unserem ersten Beispiel, bis auf die Kante 5->4, die hier den Wert -.66 hat. Damit beträgt das Gesamtgewicht des Zyklus 4->7->5->4:
tinyEWDnc.txt
E
.37+.28-.66 = -.01
Wenn wir diesen Zyklus wiederholt durchlaufen, erzeugen wir immer kürzere Pfade! kürzester Pfad von 0 zu 6 Beachten Sie, dass nicht alle Kanten in 0->4->7->5->4->7->5...->1->3->6 einem gerichteten Zyklus ein negatives Gewicht haben müssen; was zählt, ist al- Abbildung 4.87: Ein kantengewichteter Digraph mit einem negativen Zyklus lein die Summe der Kantengewichte.
Definition: Negativer Zyklus Ein negativer Zyklus in einem kantengewichteten Digraphen ist ein gerichteter Zyklus, dessen Gesamtgewicht (Summe der Gewichte seiner Kanten) negativ ist.
Nehmen wir an, dass ein Knoten auf einem Pfad von s zu einem erreichbaren Knoten v auf einem negativen Zyklus liegt. In diesem Fall wäre die Existenz eines kürzesten Pfades von s zu v ein Widerspruch, da wir den Zyklus nutzen könnten, um einen Pfad zu konstruieren, dessen Gewicht niedriger wäre als jeder gegebene Wert. Mit anderen Worten, kürzeste Pfade können ein inkorrekt gestelltes Problem sein, wenn negative Zyklen vorliegen. Beachten Sie, dass die Voraussetzung, dass kürzeste Pfade keine Knoten in negativen Zyklen haben dürfen, die Schlussfolgerung zulässt, dass kürzeste Pfade einfach sind und wir einen Kürzeste-Pfade-Baum für solche Knoten auf die gleiche Weise berechnen können wie für positive Kantengewichte. Abbildung 4.88: Möglichkeiten für kürzeste Pfade
grau: nicht erreichbar von s
weiß: erreichbar von s
s
schwarzer Rand: es existiert ein kürzester Pfad von s aus
negativer Zyklus
roter Rand: es gibt keinen kürzesten Pfad von s aus
713
4
Graphen
Satz W: Es gibt in einem kantengewichteten Digraphen genau dann einen kürzesten Pfad von s zu v, wenn es mindestens einen gerichteten Pfad von s zu v gibt und kein Knoten auf einem gerichteten Pfad von s zu v in einem negativen Zyklus liegt. Beweis: Siehe Diskussion oben und Übung 4.4.29.
Vorschlag III Unabhängig davon, ob negative Zyklen vorliegen, gibt es zu jedem Knoten, der vom Startknoten aus erreichbar ist, einen kürzesten einfachen Pfad. Warum also definieren wir kürzeste Pfade nicht so, dass wir nach solchen Pfaden suchen? Unglücklicherweise benötigt der beste bekannte Algorithmus zur Lösung dieses Problems im schlimmsten Fall exponentielle Zeit (siehe Kapitel 6). Generell stufen wir die Lösung solcher Probleme als „zu schwierig“ ein und untersuchen einfachere Versionen. So sollte eine korrekt gestellte und umsetzbare Version des Kürzeste-Pfade-Problems in kantengewichteten Digraphen folgende Anforderungen an die Algorithmen stellen:
Sie sollten den Knoten, die nicht vom Startknoten aus erreichbar sind, ein KürzestePfad-Gewicht von +∞ zuweisen.
Sie sollten den Knoten, die auf einem vom Startknoten ausgehenden Pfad liegen, der einen Knoten in einem negativen Zyklus enthält, ein Kürzeste-Pfad-Gewicht von −∞ zuweisen.
Und sie sollten das Kürzeste-Pfad-Gewicht (und den Baum) für alle anderen Knoten berechnen. Bisher haben wir das Kürzeste-Pfade-Problem immer mit Bedingungen verknüpft, um Algorithmen entwickeln zu können, die das Problem lösen. Zuerst waren keine negativen Gewichte erlaubt, dann keine gerichteten Zyklen. Jetzt formulieren wir weniger strenge Bedingungen und konzentrieren uns auf die folgenden Probleme in allgemeinen Digraphen: Erkennung negativer Zyklen: Enthält ein gegebener kantengewichteter Digraph einen negativen Zyklus? Wenn ja, finde ihn. Kürzeste Pfade mit einem Startknoten, wenn keine negativen Zyklen erreichbar sind: Wir möchten für einen gegebenen kantengewichteten Digraphen und einen Startknoten s ohne negative Zyklen, die von s aus erreichbar sind, Fragen beantworten wie Gibt es einen gerichteten Pfad von s zu einem gegebenen Zielknoten v? Wenn ja, finde den kürzesten dieser Pfade (einer dessen Gesamtgewicht minimal ist).
Fassen wir noch einmal zusammen: Während das Kürzeste-Pfade-Problem in Digraphen mit gerichteten Zyklen ein inkorrekt gestelltes Problem ist und wir das Problem, einfache kürzeste Pfade in solchen Digraphen zu finden, nicht effizient lösen können, sind wir durchaus in der Lage, negative Zyklen in praktischen Situationen zu identifizieren. Zum Beispiel können wir bei dem Problem der Ablaufplanung mit Deadlines davon ausgehen, dass negative Zyklen relativ selten sind: Bedingungen und Deadlines ergeben sich aus logischen realen Beschränkungen, sodass jeder negative Zyklus wahrscheinlich
714
4.4 Kürzeste Pfade
auf einen Fehler in der Problemstellung zurückzuführen ist. In diesem Falle finden Sie am besten die negativen Zyklen, korrigieren die Fehler und ermitteln dann den Ablaufplan für ein Problem ohne negative Zyklen. In anderen Fällen ist das Finden eines negativen Zyklus das Ziel der Berechnung. Der folgende Ansatz, der von R. Bellman und L. Ford in den späten 1950ern entwickelt wurde, bietet eine einfache und effektive Basis, beide Probleme zu lösen, und eignet sich auch für Digraphen mit positiven Gewichten.
Satz X (Bellman-Ford-Algorithmus): Das folgende Verfahren löst das Kürzeste-Pfade-Problem mit einem gegebenen Startknoten s für jeden kantengewichteten Digraphen mit V Knoten und ohne negative Zyklen, die von s aus erreichbar sind: Initialisiere distTo[s] mit 0 und alle anderen distTo[]-Werte mit unendlich. Relaxiere dann alle Kanten des Digraphen in beliebiger Reihenfolge. Es werden V Durchläufe benötigt. Beweis: Für jeden Knoten t, der von s aus erreichbar ist, betrachten wir einen speziellen kürzesten Pfad von s zu t: v0->v1->...->vk, wobei v0 der Startknoten s ist und vk der Zielknoten t. Da es keine negativen Zyklen gibt, existiert ein solcher Pfad und k kann nicht größer sein als V−1. Durch Induktion auf i zeigen wir, dass nach dem i-ten Durchlauf der Algorithmus einen kürzesten Pfad von s zu vi berechnet. Der Basisfall (i = 0) ist trivial. In der Annahme, dass die Behauptung für i wahr ist, ist v0->v1->...->vi ein kürzester Pfad von s zu vi, und distTo[vi] ist seine Länge. Dann relaxieren wir jeden Knoten im i-ten Durchlauf, einschließlich vi, sodass distTo[vi+1] nicht größer ist als distTo[vi] plus dem Gewicht von vi->vi+1. Nach dem i-ten Durchlauf muss distTo[vi+1] gleich distTo[vi] sein plus dem Gewicht von vi->vi+1. Es kann nicht größer sein, weil wir jeden Knoten im i-ten Durchlauf relaxieren, speziell vi, und es kann nicht kleiner sein, weil dies die Länge von v0->v1->...->vi+1 ist, einem kürzesten Pfad. Demzufolge berechnet der Algorithmus nach dem (i+1)-ten Durchlauf einen kürzesten Pfad von s zu vi+1.
Satz W (Fortsetzung): Der Bellman-Ford-Algorithmus benötigt Zeit proportional EV und zusätzlichen Speicher proportional V. Beweis: Jeder der V Durchgänge relaxiert E Kanten.
Diese Methode ist sehr allgemein, da sie nicht vorgibt, in welcher Reihenfolge die Kanten relaxiert werden. Wir beschränken uns im Folgenden auf eine weniger allgemeine Methode, bei der wir immer alle Kanten relaxieren, die von irgendeinem Knoten ausgehen (in beliebiger Reihenfolge). Der folgende Code veranschaulicht die Einfachheit dieses Ansatzes: for (int pass = 0; pass < G.V(); pass++) for (v = 0; v < G.V(); v++) for (DirectedEdge e : G.adj(v)) relax(e);
Wir wollen diese Version nicht im Detail betrachten, da sie immer VE Kanten relaxiert; mit einer kleinen Änderung wird der Algorithmus wesentlich effizienter für typische Anwendungen.
715
4
Graphen
Bellman-Ford-Algorithmus auf der Basis einer Warteschlange Insbesondere können wir leicht a priori bestimmen, dass etliche Kanten bei keinem gegebenen Durchlauf zu einer erfolgreichen Relaxation führen: Die einzigen Kanten, die zu einer Änderung in distTo[] führen könnten, sind die, die von einem Knoten ausgehen, dessen distTo[]-Wert im vorherigen Durchlauf geändert wurde. Um diese Knoten zu speichern, verwenden wir eine FIFO-Warteschlange. In Abbildung 4.89 sehen Sie die Funktionsweise des Algorithmus für unser Standardbeispiel mit positiven Gewichten. Links der Diagramme stehen die Einträge der Warteschlange für jeden Durchlauf (in rot), gefolgt von den Warteschlangeneinträgen für den nächsten Durchlauf (in schwarz). Wir beginnen mit dem Startknoten in der Warteschlange und berechnen dann den Kürzeste-PfadeBaum wie folgt:
q
Startknoten
Warteschlangenknoten für jede Phase sind rot 0 1 2 3 4 5 6 7
rot: dieser Durchgang
schwarz: nächster Durchgang
Wir relaxieren 1->3 und stellen 3 in die Warteschlange.
Wir relaxieren 3->6 und stellen 6 in die Warteschlange.
Wir relaxieren 6->4, 6->0 und 6->2 und stellen 4, 0 und 2 in die Warteschlange.
Wir relaxieren 4->7 und 4->5 und stellen 7 und 5 in die Warteschlange. Dann relaxieren wir 0->4 und 0->2, die nicht auswählbar sind. Anschließend relaxieren wir 2->7 (und färben 4->7 um).
Wir relaxieren 7->5 (und färben 4->5
edgeTo[] 0 1 2 3 4 5 6 7
umgefärbte Kante
0 1 2 3 4 5 6 7
0 1 2 3 4 5 6 7
0 1 2 3 4 5 6 7
0 1 2 3 4 5 6 7
1->3
edgeTo[]
3->6
edgeTo[] 6->0 6->2 1->3 6->4 3->6
edgeTo[] 6->0 6->2 1->3 6->4 4->5 3->6 2->7 edgeTo[] 6->0 6->2 1->3 6->4 7->5 3->6 2->7 edgeTo[] 6->0
um), stellen aber 5 nicht in die Warte6->2 schlange (da er bereits dort ist). Dann 1->3 6->4 relaxieren wir 7->3, die nicht auswähl7->5 3->6 bar ist. Anschließend relaxieren wir 2->7 5->1, 5->4 und 5->7, die nicht auswählAbbildung 4.89: Ablaufprotokoll des Bellman-Fordbar sind, sodass die Warteschlange leer Algorithmus ist.
716
4.4 Kürzeste Pfade
Implementierung Wenn Sie den Bellman-Ford-Algorithmus wie im vorherigen Abschnitt beschrieben implementieren, kommen Sie mit erstaunlich wenig Code aus (siehe Algorithmus 4.11). Der Algorithmus basiert auf zwei zusätzlichen Datenstrukturen:
Eine Warteschlange q zur Aufnahme der Knoten, die zu relaxieren sind. Ein knotenindiziertes boolesches Array onQ[], das anzeigt, welche Knoten in der Warteschlange sind, um Duplikate zu vermeiden. Wir stellen zuerst den Startknoten s in die Warteschlange, treten dann in eine Schleife ein, in der wir einen Knoten der Warteschlange entnehmen und ihn relaxieren. Um Knoten zur Warteschlange hinzuzufügen, erweitern wir unsere relax()-Implementierung aus Listing 4.29, sodass sie den Knoten in die Warteschlange stellt, auf den irgendeine erfolgreich relaxierte Kante zeigt (Listing 4.36). Die Datenstrukturen stellen sicher,
dass nur eine Kopie von jedem Knoten in der Warteschlange steht und dass jeder Knoten, dessen edgeTo[]- und distTo[]-Werte sich in einem Durchlauf ändern, im nächsten Durchlauf verarbeitet wird. Um die Implementierung zu vervollständigen, müssen wir sicherstellen, dass der Algorithmus nach V Durchläufen terminiert. Dies erreichen wir beispielsweise dadurch, dass wir die Durchläufe explizit speichern. Unsere Implementierung BellmanFordSP (Algorithmus 4.11) verfolgt jedoch einen anderen Ansatz, den wir in Abschnitt Erkennung negativer Zyklen auf Seite 721 ausführlich vorstellen. Die Implementierung dort prüft auf negative Zyklen in der Teilmenge der Digraphenkanten in edgeTo[] und terminiert, wenn sie einen findet. private void relax(EdgeWeightedDigraph G, int v) { for (DirectedEdge e : G.adj(v)) { int w = e.to(); if (distTo[w] > distTo[v] + e.weight()) { distTo[w] = distTo[v] + e.weight(); edgeTo[w] = e; if (!onQ[w]) { queue.enqueue(w); onQ[w] = true; } } if (cost++ % G.V() == 0) findNegativeCycle(); } } Listing 4.36: Relaxation für Bellman-Ford
717
4
Graphen
Listing 4.37: Warteschlangenbasierter Bellman-Ford-Algorithmus (Algorithmus 4.11) public class BellmanFordSP { private double[] distTo; private DirectedEdge[] edgeTo; private boolean[] onQ;
// // // // private Queue queue; // private int cost; // private Iterable cycle; //
Länge des Pfades zu v letzte Kante im Pfad zu v Ist dieser Knoten in der Warteschlange? Knoten, die relaxiert werden Anzahl der Aufrufe von relax() negativer Zyklus in edgeTo[]?
public BellmanFordSP(EdgeWeightedDigraph G, int s) { distTo = new double[G.V()]; edgeTo = new DirectedEdge[G.V()]; onQ = new boolean[G.V()]; queue = new Queue(); for (int v = 0; v < G.V(); v++) distTo[v] = Double.POSITIVE_INFINITY; distTo[s] = 0.0; queue.enqueue(s); onQ[s] = true; while (!queue.isEmpty() && !this.hasNegativeCycle()) { int v = queue.dequeue(); onQ[v] = false; relax(G,v); } } private void relax(EdgeWeightedDigraph G, int v) // Siehe Listing 4.36. public double distTo(int v) // Standardmäßige Client-Abfragemethoden public boolean hasPathTo(int v) // für SPT-Implementierungen public Iterable pathTo(int v) // (Siehe Listing 4.31.) private void findNegativeCycle() public boolean hasNegativeCycle() public Iterable negativeCycle() // Siehe Listing 4.38. }
Diese Implementierung des Bellman-Ford-Algorithmus verwendet eine Version von relax(), die erstens nur die Knoten in eine FIFO-Warteschlange stellt, auf die erfolgreich relaxierte Kanten zeigen (wodurch Duplikate vermieden werden), und zweitens edgeTo[] regelmäßig auf negative Zyklen prüft (siehe Text).
718
4.4 Kürzeste Pfade
Satz Y: Die warteschlangenbasierte Implementierung des Bellman-Ford-Algorithmus löst das Problem der kürzesten Pfade, die von einem gegebenen Startknoten s ausgehen, (oder findet einen negativen Zyklus, der von s aus erreichbar ist) für jeden kantengewichteten Digraphen mit V Knoten in einer Zeit proportional EV und zusätzlichem Speicher proportional V im schlimmsten Fall. Beweis: Wenn es keinen negativen Zyklus gibt, der von s aus erreichbar ist, terminiert der Algorithmus nach den Relaxationen entsprechend dem (V−1)-ten Durchlauf des in Satz X beschriebenen generischen Algorithmus (da alle kürzesten Pfade weniger als V−1 Kanten haben). Gibt es einen negativen Zyklus erreichbar von s, dann leert sich die Warteschlange nie. Nach den Relaxationen entsprechend dem V-ten Durchlauf des in Satz X beschriebenen generischen Algorithmus speichert das Array edgeTo[] einen Pfad mit einem Zyklus (der einen Knoten w mit sich selbst verbindet). Dieser Zyklus muss negativ sein, da der Pfad von s zu dem zweiten Vorkommen von w kürzer sein muss als der Pfad von s zum ersten Vorkommen von w, damit w das zweite Mal in den Pfad aufgenommen wird. Im schlimmsten Fall macht der Algorithmus das gleiche wie der allgemeine Algorithmus und relaxiert alle E Kanten in jedem der V Durchläufe.
Der warteschlangenbasierte Bellman-Ford-Algorithmus ist ein effektives Verfahren, um das Kürzeste-Pfade-Problem effizient zu lösen. Es findet in der Praxis häufig Anwendung, sogar wenn die Kantengewichte positiv sind. Wie beispielsweise das Diagramm in Abbildung 4.90 zeigt, ist unser Beispiel mit 250 Knoten in nur 14 Durchläufen abgeschlossen und erfordert weniger Pfadlängenvergleiche als der Algorithmus von Dijkstra für das gleiche Problem.
Durchläufe 4
Kanten in der Warteschlange sind rot 7
10
13
SPT
Abbildung 4.90: Bellman-Ford (250 Knoten)
719
4
Graphen
Negative Gewichte tinyEWDn.txt
Warteschlange
Startknoten
nicht länger gültig
Abbildung 4.91: Ablaufprotokoll des Bellman-Ford-Algorithmus (negative Gewichte)
Das Beispiel in Abbildung 4.91 ist ein Ablaufprotokoll des Bellman-Ford-Algorithmus in einem Digraphen mit negativen Gewichten. Wir beginnen mit dem Startknoten in q und berechnen dann den Kürzeste-Pfade-Baum wie folgt:
720
4.4 Kürzeste Pfade
Wir relaxieren die Kanten 0->2 und 0->4 und stellen 2 und 4 in die Warteschlange. Wir relaxieren die Kante 2->7 und stellen 7 in die Warteschlange. Dann relaxieren wir die Kante 4->5 und stellen 5 in die Warteschlange. Dann relaxieren wir die Kante 4->7, die nicht auswählbar ist.
Wir relaxieren die Kanten 7->3 und 5->1 und stellen 3 und 1 in die Warteschlange. Die Kante 7->5 ist nicht auswählbar. Dann relaxieren wir die Kanten 5->4 und 5->7, die nicht auswählbar sind.
Wir relaxieren die Kante 3->6 und stellen 6 in die Warteschlange. Dann relaxieren wir 1->3, die nicht auswählbar ist.
Wir relaxieren die Kante 6->4 und stellen 4 in die Warteschlange. Diese Kante mit negativem Gewicht beschert uns einen kürzeren Pfad zu 4, sodass seine Kanten erneut relaxiert werden müssen (sie wurden das erste Mal im zweiten Durchlauf relaxiert). Die Abstände zu 5 und zu 1 sind nicht länger gültig, werden aber in späteren Durchläufen korrigiert.
Wir relaxieren die Kante 4->5 und stellen 5 in die Warteschlange. Dann relaxieren wir 4->7, die immer noch nicht auswählbar ist.
Wir relaxieren die Kante 5->1 und stellen 1 in die Warteschlange. Dann relaxieren wir 5->4 und 5->7, die immer noch nicht auswählbar sind.
Wir relaxieren die Kante 1->3, die immer noch nicht auswählbar ist, womit die Warteschlange leer ist. Der Kürzeste-Pfade-Baum für dieses Beispiel ist ein einziger langer Pfad von 0 zu 1. Die Kanten von 4, 5 und 1 aus wurden bei diesem Beispiel alle zweimal relaxiert. Um dieses Beispiel besser zu verstehen, empfehlen wir Ihnen, den Beweis zu Satz X noch einmal zu lesen.
Erkennung negativer Zyklen Unsere BellmanFordSP-Implementierung prüft auf negative Zyklen, um eine Endlosschleife zu vermeiden. Wir können den Code für diese Prüfung dazu nutzen, um auch Clients die Möglichkeit zu bieten, auf negative Zyklen zu prüfen und diese zu extrahieren. Dazu ergänzen wir die SP-API in Tabelle 4.29 um die folgenden Methoden:
Tabelle 4.30 Erweiterung der Kürzeste-Pfade-API für die Behandlung negativer Zyklen boolean
hasNegativeCycle()
Gibt es einen negativen Zyklus?
Iterable
negativeCycle()
Ein negativer Zyklus (null, wenn keine Zyklen vorhanden sind)
Die Implementierung dieser Methoden ist nicht schwer, wie der Code in Listing 4.38 zeigt. Nach Ausführung des Konstruktors in BellmanFordSP hat der Digraph laut dem Beweis zu Satz Y genau dann einen negativen Zyklus, der vom Startknoten erreichbar ist, wenn die Warteschlange nach dem V-ten Durchlauf aller Kanten nicht leer ist.
721
4
Graphen
Außerdem muss der Teilgraph aus den Kanten in unserem edgeTo[]-Array einen negativen Zyklus enthalten. Um also negativeCycle() zu implementieren, erstellen wir aus den Kanten in edgeTo[] einen kantengewichteten Digraphen und suchen darin nach einem Zyklus. Für diese Suche verwenden wir eine Version von DirectedCycle aus Abschnitt 4.3, die an kantengewichtete Digraphen angepasst wurde (siehe Übung 4.4.12). Wir amortisieren die Kosten dieser Prüfung, indem wir
eine Instanzvariable cycle hinzufügen sowie eine private Methode findNegativeCycle(), die cycle auf einen Iterator für die Kanten eines negativen Zyklus setzt,
wenn ein solcher gefunden wurde (und auf null, wenn kein Zyklus gefunden wurde);
findNegativeCycle() bei jedem V-ten Aufruf von relax() aufrufen. Dieser Ansatz stellt sicher, dass die Schleife im Konstruktor terminiert. Außerdem können Clients hasNegativeCycle() aufrufen, um festzustellen, ob es einen negativen Zyklus gibt, der vom Startknoten aus erreichbar ist (und dann negativeCycle(), um einen solchen Zyklus zurückzuliefern). In Übung 4.4.43 werden Sie sehen, dass die Erweiterung des Codes um die Möglichkeit, jeden negativen Zyklus im Digraphen zu erkennen, ebenfalls sehr einfach ist. private void findNegativeCycle() { int V = edgeTo.length; EdgeWeightedDigraph spt; spt = new EdgeWeightedDigraph(V); for (int v = 0; v < V; v++) if (edgeTo[v] != null) spt.addEdge(edgeTo[v]); EdgeWeightedCycleFinder cf; cf = new EdgeWeightedCycleFinder(spt); cycle = cf.cycle(); } public boolean hasNegativeCycle() { return cycle != null; } public Iterable negativeCycle() { return cycle; } Listing 4.38: Erkennen negativer Zyklen für den Bellman-Ford-Algorithmus
Das Beispiel in Abbildung 4.92 ist ein Ablaufprotokoll des Bellman-Ford-Algorithmus in einem Digraphen mit einem negativen Zyklus. Die ersten beiden Durchläufe entsprechen denen für tinyEWDn.txt. Nach dem Relaxieren von 7->3 und 5->1 und dem Einfügen von 3 und 1 in die Warteschlange im dritten Durchlauf relaxiert der Algorithmus die Kante 5->4 mit negativem Gewicht. Diese Relaxation entdeckt den negativen Zyklus 4->5->4. Er fügt 5->4 im Baum ein und trennt in edgeTo[] den Zyklus vom Startknoten 0. Ab da läuft der Algorithmus durch den Zyklus und senkt dabei die Abstände zu allen berührten Knoten, bis er nach Erkennen des Zyklus beendet wird. Die Warteschlange wird dabei nicht leer. Der Zyklus ist im Array edgeTo[] gespeichert, wo er von findNegativeCycle() gefunden werden kann.
722
4.4 Kürzeste Pfade
tinyEWDnc.txt
Warteschlange
edgeTo[] distTo[]
Startknoten
0 1 2 3 4 5 6 7
0->2
0.26
0->4 4->5
0.38 0.73
2->7
0.60
edgeTo[] distTo[] 0 1 2 3 4 5 6 7
5->1 0->2 7->3 5->4 4->5
1.05 0.26 0.99 0.07 0.73
2->7
0.60
Länge von 0->4->5->4
edgeTo[] distTo[] 0 1 2 3 4 5 6 7
5->1 0->2 7->3 0->4 4->5 3->6 2->7
1.05 0.26 0.99 0.07 0.42 1.51 0.44
edgeTo[] distTo[] 0 1 2 3 4 5 6 7
5->1 0->2 7->3 5->4 4->5 3->6 2->7
0.74 0.26 0.83 -0.59 0.73 1.51 0.60
Länge von 0->4->5->4->5->4
. . .
Abbildung 4.92: Ablaufprotokoll des Bellman-Ford-Algorithmus (negativer Zyklus)
Arbitrage Nehmen Sie einen internationalen Markt für finanzielle Transaktionen, der auf dem Handel mit Gütern basiert. Ein bekanntes Beispiel für einen solchen Markt sind Wechselkurstabellen wie die, die Sie in unserer Beispieldatei rates.txt finden. Die erste Zeile in der Datei gibt die Anzahl V der Währungen an; die nachfolgenden Zeilen der Datei sind jeweils einer Währung gewidmet. Jede Zeile beginnt mit dem Namen der Währung gefolgt von den Umrechnungskursen zu den anderen Währungen. Der Kürze halber enthält dieses Beispiel nur fünf der Hunderte von Währungen, die auf den heutigen Märkten gehandelt werden: US-Dollar (USD), Euros (EUR), Britische Pfund (GBP), Schweizer Franken (CHF) und Kanadische Dollar (CAD). Die t-te Zahl in Zeile s repräsentiert einen Umrechnungskurs: die Anzahl der Währungseinheiten, die benötigt wird, um 1 Einheit von der Währung zu kaufen, die in Zeile s angegeben wird. Gemäß unserer Tabelle erhalten Sie beispielsweise für 1000 US-Dollar genau 741 Euros. Diese Tabelle lässt sich durch einen vollständigen kantengewichteten Digraphen ausdrücken mit einem Knoten für jede Währung und einer Kante für jeden Umrechnungskurs. Eine Kante s->t mit dem Gewicht x entspricht einer Umrechnung von s zu t mit dem Wechselkurs x. Pfade in dem Digraphen spezifizieren mehrstufige Umrechnungen. Wenn Sie zum Beispiel die gerade erläuterte Umrechnung mit einer Kante t->u des Gewichts y kombinieren, erhalten Sie einen Pfad
723
4
Graphen
s->t->u, der einen Weg darstellt, 1 Währungseinheit s in xy Währungseinheiten u umzuwandeln. Wir könnten beispielsweise 1.012,206=741∗1,366 Kanadische Dollar mit unseren Euros kaufen. Beachten Sie, dass wir damit eine bessere Rate erhalten, als wenn wir direkt von US-Dollar in Kanadische Dollar umgewandelt hätten. Sie könnten erwarten, dass xy gleich dem Gewicht von s->u in allen diesen Fällen ist, aber solche Tabellen stellen ein komplexes Finanzsystem dar, in denen eine solche Konsistenz nicht garantiert werden kann. Sie können sich sicher vorstellen, dass das Interesse groß ist, den Pfad von s zu u zu finden, dessen Produkt der Gewichte maximal ist. Noch interessanter ist es, wenn das Produkt der Kantengewichte kleiner ist als das Gewicht der Kante vom letzten Knoten zurück zum ersten. Nehmen wir für unser Beispiel an, dass u->s das Gewicht z hat und xyz>1 ist. Dann zeigt uns der Zyklus s->t->u->s einen Weg, 1 Einheit der Währung s in mehr als 1 Einheit (xyz) der Währung s umzuwandeln. Mit anderen Worten, wir können 100(xyz-1) Prozent Gewinn machen, indem wir die Währung s in t, dann in u und dann wieder in s wechseln. Wenn wir beispielsweise unsere 1.012,206 Kanadische Dollar zurück in US-Dollar wechseln, erhalten wir 1.012.206∗0,995=1.007,14497 USDollar, was einem Gewinn von 7,14497 Dollar entspricht. Das mag vielleicht nicht viel scheinen, aber ein Währungshändler hat Millionen von Dollar zur Verfügung und kann diese Transaktionen jede Minute ausführen, was ihm Gewinne von über 7000 US-Dollar pro Minute oder 420.000 US-Dollar pro Stunde beschert! Diese Situation ist ein Beispiel für eine Arbitragemöglichkeit, die es Händlern erlaubt, unbegrenzte Gewinne einzufahren, gäbe es nicht Kräfte außerhalb des Modells, wie Transaktionsgebühren oder Beschränkungen des Transaktionsvolumens. Doch trotz dieser Kräfte ist Arbitrage in der realen Welt höchst profitabel. Bleibt die Frage, was dieses Problem mit den kürzesten Pfaden zu tun hat? Die Antwort auf diese Frage ist erstaunlich einfach. % more rates.txt 5 USD 1 0.741 EUR 1.349 1 GBP 1.521 1.126 CHF 0.942 0.698 CAD 0.995 0.732
0.657 0.888 1 0.619 0.650
1.061 1.433 1.614 1 1.049
1.005 1.366 1.538 0.953 1
0.741 * 1.366 * .995 = 1.00714497
0. 88 8 1. 12 6
0.657
1.521
1. 06 1
CAD
Abbildung 4.93: Eine Arbitragemöglichkeit
724
0 65 0. 1.049
98 0.6
95 0.9
0.7 32
05 1.0
8 53 1.
0. 94 2 0.953
0.6 19
USD
CHF
GBP 1.6 14
33 1.4
1 74 0.
9 34 1.
1.3 66
EUR
4.4 Kürzeste Pfade
Listing 4.39: Arbitrage im Wechselkurs public class Arbitrage { public static void main(String[] args) { int V = StdIn.readInt(); String[] name = new String[V]; EdgeWeightedDigraph G = new EdgeWeightedDigraph(V); for (int v = 0; v < V; v++) { name[v] = StdIn.readString(); for (int w = 0; w < V; w++) { double rate = StdIn.readDouble(); DirectedEdge e = new DirectedEdge(v, w, -Math.log(rate)); G.addEdge(e); } } BellmanFordSP spt = new BellmanFordSP(G, 0); if (spt.hasNegativeCycle()) { double stake = 1000.0; for (DirectedEdge e : spt.negativeCycle()) { StdOut.printf("%10.5f %s ", stake, name[e.from()]); stake *= Math.exp(-e.weight()); StdOut.printf("= %10.5f %s\n", stake, name[e.to()]); } } else StdOut.println("No arbitrage opportunity"); } }
Dieser BellmanFordSP-Client findet eine Arbitragemöglichkeit in einer Wechselkurstabelle, indem er von der Tabelle eine vollständige Graphenrepräsentation erstellt und dann mithilfe des Bellman-Ford-Algorithmus einen negativen Zyklus in dem Graphen findet. % java Arbitrage < rates.txt 1000.00000 USD = 741.00000 EUR 741.00000 EUR = 1012.20600 CAD 1012.20600 CAD = 1007.14497 USD
725
4
Graphen
Satz Z: Das Arbitrageproblem ist ein Problem der Erkennung negativer Zyklen in kantengewichteten Digraphen. Beweis: Ersetzen Sie jedes Gewicht durch seinen Logarithmus, und zwar negiert. Mit dieser Änderung entspricht die Berechnung der Pfadgewichte durch Multiplikation der Kantengewichte im ursprünglichen Problem der Addition dieser Gewichte im umgewandelten Problem. Somit entspricht jedes Produkt w1∗w2∗…∗wk einer Summe −ln(w1)−ln(w2)−…−ln(wk). Die umgewandelten Kantengewichte können negativ oder positiv sein; ein Pfad von v zu w bietet einen Weg, Währung v in Währung w umzuwechseln und jeder negative Zyklus ist eine Arbitragemöglichkeit.
-.3 119
598 -.3
In unserem Beispiel, bei dem alle Transaktio- -ln(.741) -ln(1.366) -ln(.995) nen möglich sind, ist der Digraph ein vollständiger Graph, d.h., jeder negative Zyklus .2998 - .3119 + .0050 = -.0071 ist von jedem Knoten aus erreichbar. Bei allEUR .1 gemeinen Warenbörsen können durchaus ei18 9 9 8 29 . nige Kanten fehlen, sodass der in Übung 4.4.43 8 9 9 2 -. . beschriebene Konstruktor, der nur ein Argu11 87 .4201 ment übernimmt, benötigt wird. Bisher gibt GBP USD -.4914 -. 05 es noch keinen effizienten Algorithmus, um ersetze jedes 92 05 Gewicht w 43 die beste Arbitragemöglichkeit zu finden (den -. durch ln(w) negativsten Zyklus in einem Graphen) – und der Graph muss noch nicht einmal sehr groß 8 30 sein, damit der Rechenaufwand nicht mehr .0 .4 59 8 zu bewältigen ist. Wichtiger jedoch ist der -.0478 CAD CHF .0481 schnellste Algorithmus, um irgendeine Arbitragemöglichkeit zu finden. Ein Händler, der Abbildung 4.94: Ein negativer Zyklus, der eine diesen Algorithmus einsetzt, kann dann sys- Arbitragemöglichkeit repräsentiert tematisch alle Arbitragemöglichkeiten ausschöpfen, bevor der zweitschnellste Algorithmus überhaupt eine findet. .47 97
95 .35
50 .00
.31 20
-.4 787
080 -.0
Die Umwandlung im Beweis zu Satz Z ist auch bei Fehlen von Arbitrage nützlich, da sie die Währungsumrechnung auf ein Kürzeste-Pfade-Problem reduziert. Da die Logarithmusfunktion monoton ist (und wir die Logarithmen negiert haben), wird das Produkt genau dann maximiert, wenn die Summe minimiert wird. Die Kantengewichte können negativ oder positiv sein und ein kürzester Pfad von v zu w drückt den besten Weg aus, Währung v in Währung w umzuwandeln.
4.4.7 Ausblick Tabelle
4.31 fasst die wichtigsten Daten zum Laufzeitverhalten der Kürzeste-Pfade-Algorithmen zusammen, die wir in diesem Abschnitt betrachtet haben. Die Wahl des Algorithmus hängt primär von den grundlegenden Eigenschaften des vorliegenden Digraphen ab. Hat er negative Gewichte? Hat er Zyklen? Hat er negative Zyklen? Abgesehen von diesen grundlegenden Eigenschaften können die Eigenschaften von kantengewichteten Digra-
726
4.4 Kürzeste Pfade
phen stark voneinander abweichen, sodass die Wahl des Algorithmus unter Umständen einiges Experimentieren erfordert, wenn es mehr als eine Option gibt.
Tabelle 4.31 Performancedaten der Kürzeste-Pfade-Algorithmen Algorithmus
Beschränkung
Pfadlängenvergleich (Wachstumsordnung) Normal
Worst-Case
Zusatzspeicher
Vorteil
Dijkstra (Eager)
Positive Kantengewichte
E log V
E log V
V
Worst-CaseGarantie
Topologisches Sortieren
Kantengewichtete azyklische Digraphen
E+V
E+V
V
Optimal für azyklisch
Bellman-Ford (warteschlangenbasiert)
Keine negativen Zyklen
E+V
VE
V
Breiter Anwendungsbereich
Historischer Rückblick Kürzeste-Pfade-Probleme sind seit mehr als 60 Jahren Gegenstand intensiver Untersuchungen und weit verbreitet. Die Geschichte des Dijkstra-Algorithmus zur Berechnung der kürzesten Pfade und die des Prim-Algorithmus zur Berechnung des minimalen Spannbaums sind sich sehr ähnlich (ja quasi verwandt). Mit Dijkstra-Algorithmus bezeichnen wir normalerweise nicht nur die abstrakte Methode zur Erstellung eines Kürzeste-Pfade-Baums, bei der Knoten in der Reihenfolge ihrer Entfernung zum Startknoten eingefügt werden, sondern auch ihre Implementierung als optimaler Algorithmus für die Adjazenzmatrix-Repräsentation, da E. W. Dijkstra beide in seinem Aufsatz von 1959 vorstellte (und dabei zeigte, dass man mit dem gleichen Ansatz auch den minimalen Spannbaum berechnen könnte). Performancesteigerungen für dünne Graphen sind auf verbesserte Implementierungen der Vorrangwarteschlange zurückzuführen, die erst später und nicht speziell zur Lösung des Kürzeste-Pfade-Problems entwickelt wurden. Diese Technologie fand ihren wichtigsten Niederschlag in der verbesserten Leistung des DijkstraAlgorithmus (zum Beispiel kann mit einer Datenstruktur wie dem Fibonacci-Heap die Worst-Case-Schranke auf E + V log V reduziert werden). Der Bellman-Ford-Algorithmus hat sich in der Praxis als überaus nützlich erwiesen und breite Anwendung gefunden, besonders für allgemeine kantengewichtete Digraphen. Während die Laufzeit des Bellman-Ford-Algorithmus bei typischen Anwendungen in der Regel linear ist, ist seine Laufzeit im schlimmsten Fall VE. Die Entwicklung eines Kürzeste-Pfade-Algorithmus für dünne Graphen, der im schlimmsten Fall linear ist, bleibt vorerst ein offenes Problem. Der Bellman-Ford-Algorithmus wurde in den 1950ern von L. Ford und R. Bellman entwickelt. Trotz der dramatischen Fortschritte in der Performance, die wir bei vielen anderen Graphenproblemen verzeichnen konnten, wurde noch kein Algorithmus gefunden, der für Digraphen mit negativen Kantengewichten (aber ohne negative Zyklen) eine bessere Worst-Case-Performance bietet.
727
4
Graphen
4.4
Fragen und Antworten
Frage: Warum definieren wir für ungerichtete Graphen, gerichtete Graphen, kantengewichtete ungerichtete Graphen und kantengewichtete Digraphen jeweils einen eigenen Datentyp? Antwort: Damit erreichen wir, dass der Client-Code klarer und der Implementierungscode in ungewichteten Graphen einfacher und effizienter wird. Für Anwendungen oder Systeme, in denen alle Arten von Graphen verarbeitet werden müssen, können Sie – beispielsweise auch unter Anleitung eines Software-Engineering-Fachbuchs – einen abstrakten Datentyp entwickeln, von dem Sie folgende abstrakte Datentypen ableiten können: Graph für ungewichtete ungerichtete Graphen (Abschnitt 4.1), Digraph für ungewichtete Digraphen (Abschnitt 4.2), EdgeWeightedGraph für kantengewichtete ungerichtete Graphen (Abschnitt 4.3) oder EdgeWeightedDigraph für kantengewichtete Digraphen in diesem Abschnitt. Frage: Wie finden wir kürzeste Pfade in ungerichteten (kantengewichteten) Graphen? Antwort: Haben die Kanten positive Gewichte, können wir die Aufgabe mit dem Algorithmus von Dijkstra lösen. Wir erstellen einfach einen EdgeWeightedDigraph, der dem gegebenen EdgeWeightedGraph entspricht (indem wir jede ungerichtete Kante durch zwei gerichtete Kanten ersetzen, d.h. für jede Richtung eine) und dann den Dijkstra-Algorithmus ausführen. Auch für den Fall, dass Kantengewichte negativ sind, stehen Ihnen effiziente Algorithmen zur Verfügung; diese sind jedoch komplizierter als der Bellman-Ford-Algorithmus.
728
Allgemeine Übungen
4.4
Allgemeine Übungen
1.
Wahr oder falsch: Das Hinzufügen einer Konstante zu jedem Kantengewicht hat keinen Einfluss auf die Lösung des Kürzeste-Pfade-Problems mit einem Startknoten.
2.
Entwickeln Sie eine Implementierung von toString() für EdgeWeightedDigraph.
3.
Entwickeln Sie eine Implementierung von EdgeWeightedDigraph für dichte Graphen, die eine Adjazenzmatrix-Repräsentation (ein zweidimensionales Array von Gewichten) verwendet (siehe Übung 4.3.9). Ignorieren Sie parallele Kanten.
4.
Zeichnen Sie den Kürzeste-Pfade-Baum für den Startknoten 0 des kantengewichteten Digraphen, den Sie durch Löschen des Knotens 7 aus tinyEWD.txt erhalten (Abbildung 4.68) und geben Sie die Elternknoten-Referenz-Repräsentation für den Kürzeste-Pfade-Baum an. Beantworten Sie die Frage für den gleichen Graphen, bei dem alle Kanten umgedreht sind.
5.
Ändern Sie die Richtung der Kante 0->2 in tinyEWD.txt (Abbildung 4.68). Zeichnen Sie für diesen geänderten kantengewichteten Digraphen zwei verschiedene Kürzeste-Pfade-Bäume, die ihre Wurzel in 2 haben.
6.
Erstellen Sie ein Ablaufprotokoll von dem Prozess, den Kürzeste-Pfade-Baum des Digraphen zu berechnen, der in Übung 4.4.5 mit der Eager-Version des DijkstraAlgorithmus definiert wurde.
7.
Entwickeln Sie eine Version von DijkstraSP, die eine Client-Methode unterstützt, mit der sich ein zweiter kürzester Pfad von s zu t in einem kantengewichteten Digraphen zurückliefern lässt (und null, wenn es nur einen kürzesten Pfad von s zu t gibt).
8.
Der Durchmesser eines Digraphen ist die Länge des längsten kürzesten Pfades, der zwei Knoten verbindet. Schreiben Sie einen DijkstraSP-Client, der den Durchmesser eines gegebenen EdgeWeightedDigraph mit nicht-negativen Gewichten berechnet.
9.
Die nachfolgende Tabelle, die einer alten veröffentlichten Straßenkarte entnommen wurde, gibt die Länge der kürzesten Routen zwischen zwei Städten an. Sie enthält einen Fehler. Korrigieren Sie die Tabelle. Erstellen Sie außerdem eine Tabelle, die zeigt, wie Sie die kürzesten Routen erhalten. Providence
Westerly
New London
Norwich
Providence
-
53
54
48
Westerly
53
-
18
101
New London
54
18
-
12
Norwich
48
101
12
-
729
4
Graphen
10.
Betrachten Sie die Kanten im Digraphen, der in Übung 4.4.4 definiert wurde, als ungerichtete Kanten, wobei im kantengewichteten Digraphen jede Kante zwei entgegengesetzten Kanten gleichen Gewichts entspricht. Beantworten Sie Übung 4.4.6 für diesen kantengewichteten Digraphen.
11.
Verwenden Sie das Speicherkostenmodell von Abschnitt 1.4, um den Speicherbedarf von EdgeWeightedDigraph zur Repräsentation eines Graphen mit V Knoten und E Kanten zu ermitteln.
12.
Passen Sie die Klassen DirectedCycle und Topological aus Abschnitt 4.2 so an, dass sie die APIs von EdgeWeightedDigraph und DirectedEdge aus diesem Abschnitt verwenden, und implementieren Sie so die Klassen EdgeWeightedCycleFinder und EdgeWeightedTopological.
13.
Erstellen Sie wie im Text ein Ablaufprotokoll von dem Prozess zur Berechnung des Kürzeste-Pfade-Baums mithilfe des Dijkstra-Algorithmus für den Digraphen, den Sie erhalten, wenn Sie die Kante 5->7 aus tinyEWD.txt entfernen (Abbildung 4.68).
14.
Zeigen Sie die Pfade, die von den beiden im Abschnitt 4.4.6 beschriebenen Vorschlägen für das Beispiel tinyEWDn.txt entdeckt würden.
15.
Was passiert mit Bellman-Ford, wenn es einen negativen Zyklus auf dem Pfad von s zu v gibt und Sie dann pathTo(v) aufrufen?
16.
Angenommen, wir wandeln ein EdgeWeightedGraph in ein EdgeWeightedDigraph, indem wir für jedes Edge-Objekt in EdgeWeightedGraph zwei DirectedEdge-Objekte in EdgeWeightedDigraph (eines für jede Richtung) erzeugen und dann den BellmanFord-Algorithmus verwenden. Erläutern Sie, warum dieser Ansatz fehlschlagen muss.
17.
Was geschieht, wenn ein Knoten mehr als einmal im selben Durchlauf vom Bellman-Ford-Algorithmus in die Warteschlange gestellt werden darf?
Antwort: Die Laufzeit des Algorithmus kann exponentiell anwachsen. Beschreiben Sie beispielsweise, was im Falle eines vollständigen kantengewichteten Digraphen passiert, dessen Kantengewichte alle -1 sind.
730
18.
Schreiben Sie einen CPM-Client, der alle kritischen Pfade ausgibt.
19.
Finden Sie den Zyklus mit dem geringsten Gewicht (die beste Arbitragemöglichkeit) in dem Beispiel im Text.
20.
Suchen Sie online oder in einer Zeitung nach einer Wechselkurstabelle. Erstellen Sie damit eine Arbitragetabelle. Hinweis: Vermeiden Sie Tabellen, die aus einigen wenigen Werten abgeleitet (berechnet) wurden und deshalb zu ungenaue Umrechnungsinformationen liefern, um interessant zu sein. Zusatzpunkte: Beweisen Sie Ihr Können auf dem Devisenmarkt!
21.
Erstellen Sie wie im Text ein Ablaufprotokoll von dem Prozess zur Berechnung des Kürzeste-Pfade-Baums mithilfe des Bellman-Ford-Algorithmus für den kantengewichteten Digraphen aus Übung 4.4.5.
Knifflige Aufgaben
4.4
Knifflige Aufgaben
22.
Knotengewichte: Gegeben sei ein kantengewichteter Digraph mit nicht-negativen Gewichten an den Knoten; zeigen Sie, dass sich dessen kürzeste Pfade (definiert als Summe der Gewichte der Knoten) berechnen lassen, indem ein kantengewichteter Digraph erstellt wird, dessen Gewichte nur auf den Kanten liegen.
23.
Kürzeste Pfade zwischen Quelle und Senke: Entwickeln Sie eine API und eine Implementierung, die das Problem der kürzesten Pfade von der Quelle zur Senke mithilfe einer Version des Dijkstra-Algorithmus in kantengewichteten Digraphen lösen.
24.
Kürzeste Pfade mit mehreren Startknoten: Entwickeln Sie eine API und eine Implementierung, die das Problem der kürzesten Pfade ausgehend von mehreren Startknoten mithilfe des Dijkstra-Algorithmus in kantengewichteten Digraphen mit positiven Kantengewichten lösen: Gegeben sei eine Menge von Startknoten; finden Sie einen Kürzeste-Pfade-Wald, der die Implementierung einer Methode ermöglicht, die Clients den kürzesten Pfad von irgendeinem dieser Startknoten zu jedem Knoten zurückliefert. Hinweis: Fügen Sie zu jedem Startknoten einen Scheinknoten mit einer Nullgewicht-Kante hinzu oder initialisieren Sie die Vorrangwarteschlange mit allen Startknoten, wobei deren distTo[]-Einträge auf 0 gesetzt sind.
25.
Kürzester Pfad zwischen zwei Teilmengen: Gegeben seien ein Digraph mit positiven Kantengewichten und zwei getrennte Teilmengen S und T von Knoten; finden Sie einen kürzesten Pfad von irgendeinem Knoten in S zu irgendeinem Knoten in T. Die Ausführungszeit Ihres Algorithmus sollte im schlimmsten Fall proportional E log V sein.
26.
Kürzeste Pfade mit einem Startknoten in dichten Graphen: Entwickeln Sie eine Version des Dijkstra-Algorithmus, die von einem gegebenen Knoten aus in einem dichten kantengewichteten Digraphen den Kürzeste-Pfade-Baum in einer Zeit proportional V2 findet. Verwenden Sie eine Adjazenzmatrix-Repräsentation (siehe Übung 4.4.3 und Übung 4.3.29).
27.
Kürzeste Pfade in euklidischen Graphen: Passen Sie unsere APIs an, um den Dijkstra-Algorithmus für den Fall zu beschleunigen, dass Knoten Punkte in der Ebene sind.
28.
Längste Pfade in azyklischen Digraphen: Entwickeln Sie eine Implementierung AcyclicLP, die das Längste-Pfade-Problem in kantengewichteten azyklischen Digraphen (wie in Satz T beschrieben) lösen kann.
29.
Allgemeine Optimalität: Vervollständigen Sie den Beweis zu Satz W, indem Sie zeigen, dass es einen kürzesten Pfad von s zu v gibt, wenn es einen gerichteten Pfad von s zu v gibt und sich kein Knoten auf irgendeinem Pfad von s zu v in einem negativen Zyklus befindet.
30.
Kürzeste Pfade zwischen allen Knotenpaaren in Graphen mit negativen Zyklen: Formulieren Sie eine API wie die, die in Listing 4.33 für das Problem der kürzesten Pfade zwischen allen Knotenpaaren in Graphen ohne negative Zyklen implemen-
731
4
Graphen
tiert wurde. Entwickeln Sie eine Implementierung, die eine Version des BellmanFord-Algorithmus ausführt, um Gewichte pi[v] zu identifizieren, sodass für jede Kante v->w das Kantengewicht plus der Differenz zwischen pi[v] und pi[w] nicht negativ ist. Legen Sie dann diese neuen Gewichte zugrunde, um mithilfe des Dijkstra-Algorithmus alle kürzesten Pfade in dem neugewichteten Graphen zu finden.
732
31.
Kürzeste Pfade zwischen allen Knotenpaaren auf einer Linie: Gegeben sei ein gewichteter Liniengraph (ein ungerichteter zusammenhängender Graph, bei dem alle Knoten den Grad 2 haben, außer den beiden Endpunkten, die den Grad 1 haben); entwickeln Sie einen Algorithmus, der den Graphen in linearer Zeit vorverarbeitet, und liefern Sie die Entfernung des kürzesten Pfades zwischen zwei beliebigen Knoten in konstanter Zeit zurück.
32.
Elternknoten-Prüfheuristik: Überarbeiten Sie Bellman-Ford, um einen Knoten v nur dann zu besuchen, wenn sich sein SPT-Elternknoten edgeTo[v] gerade nicht in der Warteschlange befindet. Diese Heuristik hat sich laut Cherkassky, Goldberg und Radzik in der Praxis bewährt. Beweisen Sie, dass damit die kürzesten Pfade korrekt berechnet werden und die Laufzeit im schlimmsten Fall proportional EV ist.
33.
Kürzester Pfad in einem Gitter: Gegeben sei eine N×N-Matrix von positiven ganzen Zahlen; finden Sie den kürzesten Pfad von Eintrag (0, 0) zu Eintrag (N−1, N−1), wobei die Länge des Pfades die Summe der ganzen Zahlen im Pfad ist. Lösen Sie das Problem auch für den Fall, dass Sie sich nur nach unten und nach rechts bewegen können
34.
Monotoner kürzester Pfad: Gegeben sei ein gewichteter Digraph; finden Sie einen monotonen kürzesten Pfad von s zu jedem anderen Knoten. Ein Pfad ist monoton, wenn das Gewicht von jeder Kante im Pfad entweder streng steigend oder streng fallend ist. Der Pfad sollte einfach sein (keine wiederholten Knoten). Hinweis: Relaxieren Sie die Kanten in aufsteigender Reihenfolge und finden Sie einen besten Pfad beziehungsweise relaxieren Sie die Kanten in absteigender Reihenfolge und finden Sie einen besten Pfad.
35.
Bitonischer kürzester Pfad: Gegeben sei ein Digraph; finden Sie einen bitonischen kürzesten Pfad von s zu jedem anderen Knoten (sofern einer existiert). Ein Pfad ist bitonisch, wenn es einen Zwischenknoten v gibt, sodass die Kanten auf dem Pfad von s zu v streng steigend und die Kanten auf dem Pfad von v zu t streng fallend sind. Der Pfad sollte einfach sein (keine wiederholten Knoten).
36.
Nachbarknoten: Entwickeln Sie einen SP-Client, der alle Knoten innerhalb eines Abstands d eines gegebenen Knotens in einem gegebenen kantengewichteten Digraphen findet. Die Laufzeit Ihrer Methode sollte proportional der Größe des Teilgraphen sein, der durch diese Knoten und die zu ihnen inzidenten Knoten induziert wird, oder proportional V (um die Datenstrukturen zu initialisieren), je nachdem, was gerade größer ist.
37.
Kritische Kanten: Entwickeln Sie einen Algorithmus zum Finden einer Kante, deren Entfernen die Länge eines kürzesten Pfades von einem gegebenen Knoten zu einem anderen gegebenen Knoten in einem gegebenen kantengewichteten Digraphen maximal erhöht.
Knifflige Aufgaben
38.
Fehlererkennbarkeit: Entwickeln Sie einen SP-Client, der eine Fehleranalyse auf den Kanten eines kantengewichteten Digraphen in Bezug auf ein gegebenes Knotenpaar s und t ausführt: Berechnen Sie eine boolesche V×V-Matrix, sodass für alle Knoten v und w der Eintrag in Zeile v und Spalte w wahr ist, wenn v->w eine Kante im kantengewichteten Digraphen ist, deren Gewicht erhöht werden kann, ohne dass die kürzeste Pfadlänge von v zu w erhöht wird, und false andernfalls.
39.
Lazy-Implementierung des Dijkstra-Algorithmus: Entwickeln Sie eine Implementierung der Lazy-Version vom Dijkstra-Algorithmus, wie im Text beschrieben.
40.
Flaschenhals-Baum der kürzesten Pfade: Zeigen Sie, dass ein minimaler Spannbaum eines ungerichteten Graphen äquivalent ist zu einem Flaschenhals-Baum der kürzesten Pfade dieses Graphen: Für jedes Knotenpaar v und w liefert er den sie verbindenden Pfad, dessen längste Kante so kurz wie möglich ist.
41.
Bidirektionale Suche: Entwickeln Sie eine Klasse für das Problem der kürzesten Pfade von Quelle zu Senke, die auf Code wie in Algorithmus 4.9 basiert, aber die Vorrangwarteschlange mit der Quelle und der Senke initialisiert. Das führt zu dem Anwachsen eines Kürzeste-Pfade-Baums von beiden Knoten aus; Ihre Hauptaufgabe besteht darin, zu entscheiden, was genau zu tun ist, wenn die beiden Bäume aufeinandertreffen.
42.
Schlimmster Fall (Dijkstra): Beschreiben Sie eine Familie von Graphen mit V Knoten und E Kanten, für die die Worst-Case-Laufzeit des Dijkstra-Algorithmus erreicht wird.
43.
Erkennen negativer Zyklen: Angenommen wir fügen in Algorithmus 4.11 einen Konstruktor ein, der sich vom gegebenen Konstruktor nur darin unterscheidet, dass er das zweite Argument weglässt und alle distTo[]-Einträge mit 0 initialisiert. Zeigen Sie, dass, wenn ein Client diesen Konstruktor verwendet, ein Client-Aufruf von hasNegativeCycle() genau dann true zurückliefert, wenn der Graph einen negativen Zyklus aufweist (und dass negativeCycle() diesen Zyklus zurückliefert). Antwort: Betrachten wir einen Digraphen, der aus dem ursprünglichen gebildet wird, indem wir einen neuen Startknoten mit einer Nullgewicht-Kante zu allen anderen Knoten hinzufügen. Nach einem Durchlauf sind alle distTo[]-Einträge 0 und die Suche nach einem negativen Zyklus, der von diesem Startknoten aus erreichbar ist, erfolgt genauso wie im ursprünglichen Graphen.
44.
Schlimmster Fall (Bellman-Ford): Beschreiben Sie eine Familie von Graphen, für die Algorithmus 4.11 eine Zeit proportional VE benötigt.
45.
Schneller Bellman-Ford: Entwickeln Sie einen Algorithmus, der für den Spezialfall, dass die Gewichte ganze Zahlen sind, deren Absolutbetrag durch eine Konstante beschränkt ist, die leicht überlineare Laufzeitschranke des Kürzeste-Pfade-Problems mit einem Startknoten in allgemeinen kantengewichteten Digraphen durchbricht.
46.
Animieren: Schreiben Sie ein Client-Programm, das den Dijkstra-Algorithmus dynamisch visualisiert (in Form von grafischen Animationen).
733
4
Graphen
4.4
Experimente
47.
Zufällige dünne kantengewichtete Digraphen: Überarbeiten Sie Ihre Lösung zu Übung 4.3.34, um jeder Kante eine zufällige Richtung zuzuweisen.
48.
Zufällige euklidische kantengewichtete Digraphen: Überarbeiten Sie Ihre Lösung zu Übung 4.3.35, um jeder Kante eine zufällige Richtung zuzuweisen.
49.
Zufällige kantengewichtete Gitter-Digraphen: Überarbeiten Sie Ihre Lösung zu Übung 4.3.36, um jeder Kante eine zufällige Richtung zuzuweisen.
50.
Negative Gewichte I: Überarbeiten Sie Ihre Generatoren von zufälligen kantengewichteten Digraphen, um durch Umskalieren Gewichte zwischen x und y zu erzeugen (die beide zwischen −1 und 1 liegen).
51.
Negative Gewichte II: Überarbeiten Sie Ihre Generatoren von zufälligen kantengewichteten Digraphen, um negative Gewichte zu erzeugen, indem Sie einen bestimmten (vom Client vorgegebenen) Prozentsatz der Kantengewichte negieren.
52.
Negative Gewichte III: Entwickeln Sie Client-Programme, die mithilfe Ihres kantengewichteten Digraphen kantengewichtete Digraphen erzeugen, die einen großen Prozentsatz von negativen Gewichten aufweisen, aber nur ganz wenige negative Zyklen, und zwar für einen größtmöglichen Wertebereich für V und E.
Für alle Modelle von kantengewichteten Digraphen alle Algorithmen zu testen und alle Parameter zu analysieren, ist nicht realistisch. Schreiben Sie für jedes nachfolgend beschriebene Problem einen Client, der das Problem für jeden gegebenen Eingabedigraphen löst, und wählen Sie einen der oben genannten Generatoren, um Experimente für das Graphenmodell auszuführen. Wählen Sie Ihre Experimente sorgfältig, vielleicht auf der Basis von Ergebnissen vorheriger Experimente. Beschreiben Sie explizit, welche Ergebnisse Sie erhalten haben und welche Schlüsse sich daraus ziehen lassen.
734
53.
Vorhersage: Schätzen Sie innerhalb eines Faktors von 10 den größten Graphen mit E = 10V, den Ihr Computer und Programmiersystem verarbeiten könnte, wenn Sie mit dem Dijkstra-Algorithmus alle seine kürzesten Pfade in 10 Sekunden berechnen wollten.
54.
Kosten der Lazy-Version: Führen Sie empirische Studien durch, um die Leistung der Lazy-Version des Dijkstra-Algorithmus für verschiedene kantengewichtete Digraphenmodelle mit der Leistung der Eager-Version zu vergleichen.
55.
Johnson-Algorithmus: Entwickeln Sie eine Vorrangwarteschlangen-Implementierung, die einen d-nären Heap verwendet. Finden Sie den besten Wert für d für mehrere kantengewichtete Digraphenmodelle.
56.
Arbitragemodell: Entwickeln Sie ein Modell zur Erzeugung von zufälligen Arbitrageproblemen. Ziel sei dabei, Tabellen zu erzeugen, die den Tabellen in Übung 4.4.20 weitestgehend entsprechen.
Experimente
57.
Modell der Ablaufplanung von parallelen Aufgaben mit Deadlines: Entwickeln Sie ein Modell zur Erzeugung von zufälligen Fällen des Problems der Ablaufplanung für parallele Aufgaben mit Deadlines. Ziel sei dabei, nicht-triviale Probleme zu erzeugen, wie sie auch in der Praxis auftreten können.
735
Strings
5
5.2 Tries . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 773 5.3 Teilstringsuche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 800 5.4 Reguläre Ausdrücke . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 829 5.5 Datenkomprimierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . 851
ÜBERBLICK
5.1 Stringsortierverfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . 745
5
Strings
»
EINLEITUNG
Achtung (Anmerkung des Übersetzers): Seit Java 7 Update 6 benötigt die Methode substring() der Klasse String zusätzlichen linearen Speicher und Zeit, da eine Kopie des Teilstrings angelegt wird. Die in diesem Kapitel aufgeführten Annahmen, Feststellungen und Beweise gelten nur, wenn substring() konstante Zeit benötigt, so wie in früheren Java Versionen.
Wir kommunizieren, indem wir Folgen von Zeichen, sogenannte „Strings“ austauschen. Entsprechend groß ist die Zahl wichtiger und allgemein bekannter Anwendungen, die auf der Verarbeitung von Strings (Zeichenketten) basieren. In diesem Kapitel stellen wir Ihnen klassische Algorithmen vor, mit denen sich die programmiertechnischen Herausforderungen, wie sie für Anwendungen aus den folgenden Bereichen anfallen, meistern lassen.
Informationsverarbeitung Wenn Sie nach Webseiten suchen, die ein bestimmtes Schlüsselwort enthalten, dann nutzen Sie eine stringverarbeitende Anwendung. Und da heutzutage praktisch alle Informationen als Zeichenfolgen codiert werden, sind folglich die dafür benötigten, auf der Stringverarbeitung basierenden Anwendungen von größter Bedeutung.
Genomik Bioinformatiker arbeiten mit einem genetischen Code, der die DNA auf (sehr lange) Strings reduziert, die aus vier Zeichen gebildet werden (A, C, T und G). In den letzten Jahren wurden riesige Datenbanken aufgebaut, um die Codes allerlei lebender Organismen zu speichern. Damit hat sich die Stringverarbeitung als fester Bestandteil der modernen Forschung in der Bioinformatik etabliert.
Kommunikationssysteme Beim Senden von Textnachrichten oder E-Mails oder dem Herunterladen von E-Books haben wir es mit einer Übertragung von Strings zu tun. Anwendungen, die Strings für die Datenübertragung verarbeiten, gaben den ersten Anstoß für die Entwicklung von stringverarbeitenden Algorithmen.
Programmiersysteme Auch Programme sind Strings. Compiler, Interpreter und verwandte Anwendungen, die Programme in Maschinenbefehle umwandeln, sind enorm wichtige Anwendungen, die sich anspruchsvoller Stringverarbeitungstechniken bedienen. Letzten Endes werden alle Schriftsprachen durch Zeichenfolgen ausgedrückt, und ein weiterer Grund für die Entwicklung von stringverarbeitenden Algorithmen war die Theorie der formalen Sprachen, also das Studium der Beschreibung von Stringmengen. Bereits diese kleine Auswahl bedeutender Anwendungsbereiche lässt erkennen, wie vielfältig und wichtig stringverarbeitende Algorithmen sind. Der Aufbau dieses Kapitels sieht folgendermaßen aus: Nach der Beschreibung der allgemeinen Eigenschaften von Zeichenfolgen greifen wir in den Abschnitten 5.1 und
738
Einleitung
5.2 noch einmal das Thema der Sortier- und Such-APIs von Kapitel 2 und 3 auf. Algorithmen, die die besonderen Eigenschaften von Stringschlüsseln ausnutzen, sind schneller und flexibler als die Algorithmen, die wir zuvor betrachtet haben. In Abschnitt 5.3 beschäftigen wir uns mit den Algorithmen zur Teilstringsuche, einschließlich des berühmten Algorithmus von Knuth, Morris und Pratt. Abschnitt 5.4 ist dann den regulären Ausdrücken, der Grundlage für die musterbasierte Suche, einer Verallgemeinerung der Teilstringsuche und einem unerlässlichen Suchtool namens grep gewidmet. Diese klassischen Algorithmen basieren auf den verwandten Konzepten der formalen Sprachen und endlichen Automaten. Zum Schluss betrachten wir in Abschnitt 5.5 eine extrem wichtige Anwendung: die Datenkomprimierung, in der wir versuchen, die Größe einer Zeichenfolge so weit wie möglich zu reduzieren.
Spielregeln Aus Gründen der Übersichtlichkeit und Effizienz verwenden unsere Implementierungen die Java-Klasse String, wobei wir jedoch absichtlich möglichst wenige Operationen von dieser Klasse nutzen, um die Anpassung unserer Algorithmen an andere stringähnliche Datentypen oder andere Programmiersprachen zu erleichtern. Auch wenn wir bereits in Abschnitt 1.2 ausführlich auf Strings eingegangen sind, wollen wir deren wichtigste Merkmale noch einmal kurz zusammenfassen.
Zeichen Ein String ist eine Folge von Zeichen. Diese Zeichen sind vom Typ char und können einen von 216 möglichen Werten haben. Seit vielen Jahrzehnten haben sich Programmierer auf die Zeichen des 7-Bit ASCII-Codes (Tabelle 5.18) oder des erweiterten 8Bit-ASCII-Codes beschränkt, doch inzwischen ist in vielen der heutigen Anwendungen 16-Bit-Unicode üblich.
Unveränderlichkeit String-Objekte sind unveränderlich, d.h., wir können sie in Zuweisungen und als Argumente und Rückgabewerte aus Methoden verwenden, ohne uns sorgen zu müssen, dass sich ihre Werte ändern.
Indizierung Die Operation, die wir am häufigsten durchführen, ist die Extraktion eines bestimmten Zeichens aus einem String, wofür uns die Java-Klasse String die Methode charAt() an die Hand gibt. Wir erwarten, dass charAt() diese Aufgabe in konstanter Zeit erledigt, wie es bei char[]-Arrays von Strings üblich ist. In Kapitel 1 konnten wir lesen, dass diese Erwartung durchaus realistisch ist.
Länge In Java ist die Operation zur Ermittlung der Stringlängen in der String-Methode length() implementiert. Auch für diese Methode erwarten wir, dass sie ihre Aufgabe in konstanter
739
5
Strings
Zeit erledigt, was ebenfalls realistisch ist, auch wenn man in einigen Programmierumgebungen vorsichtig sein muss.
Teilstrings Die Java-Methode substring() implementiert die Operation zum Extrahieren eines spezifizierten Teilstrings. Wiederum erwarten wir, dass die Implementierung dieser Methode, wie im Falle der Java-Standardimplementierung, konstante Zeit benötigt. Wenn Sie substring() nicht kennen und nicht wissen, warum sie konstant ist, sollten Sie noch mal zu Abschnitt 1.2 zurückkehren und unsere Diskussion der Java-Standardimplementierung für Strings erneut lesen.1 s.length()
s
0
1
2
3
4
5
6
7
8
9 10 11 12
A
T
T
A
C
K
A
T
D
A
W
N
s.charAt(3) s.substring(7, 11)
Abbildung 5.1: Grundlegende String-Operationen mit konstantem Zeitaufwand
Verkettung Die Operation zur Bildung eines neuen Strings, bei der ein String (mittels +-Operator) an einen anderen String angehängt wird, ist bereits in Java integriert. Diese Operation benötigt Zeit proportional der Länge des Ergebnisses. Folglich vermeiden wir es, Strings durch sukzessives Aneinanderhängen von Zeichen zu erstellen, da dies in Java ein quadratischer Prozess ist. (Hierfür stellt Java Ihnen die Klasse StringBuilder zur Verfügung.)
Zeichenarrays Die Java-Klasse String ist eindeutig kein primitiver Datentyp. Die Standardimplementierung mit den gerade beschriebenen Operationen erleichtert uns die Client-Programmierung enorm. Nichtsdestotrotz funktionieren viele der hier vorgestellten Algorithmen auch mit Low-Level-Repräsentationen wie beispielsweise Arrays von charWerten, die viele Clients vorziehen, da sie weniger Speicher und Zeit benötigen. Bei manchen der hier betrachteten Algorithmen sind die Kosten für die Umwandlung von einer Repräsentation in die andere höher als die Kosten, den Algorithmus direkt auszuführen. Wie Sie der Tabelle 5.1 entnehmen können, sind die Unterschiede im Code, der beide Repräsentationen verarbeitet, nur gering (substring() ist komplizierter und wurde deshalb hier weggelassen), sodass es für das Verständnis eines Algorithmus egal ist, für welche Repräsentation Sie sich entscheiden.
1
740
Anmerkung des Übersetzers: Diese Performanceangaben gelten nur für ältere Java-Versionen. Seit Java 7 Update 6 benötigt die Methode substring() der Klasse String zusätzlichen linearen Speicher und Zeit, da eine Kopie des Teilstrings angelegt wird.
Einleitung
Die Effizienz der hier vorgestellten Operationen bildet die Grundlage für die Effizienz etlicher stringverarbeitender Algorithmen. Nicht alle Programmiersprachen bieten String-Implementierungen mit einem solchen Laufzeitverhalten. Zum Beispiel benötigt die weit verbreitete Programmiersprache C für die Teilstring-Operation und die Bestimmung von Stringlängen eine Zeit, die proportional der Anzahl der Zeichen im String ist. Die hier beschriebenen Algorithmen an solche Sprachen anzupassen, ist jederzeit möglich (implementieren Sie einfach einen abstrakten Datentyp wie Javas String), kann Sie aber vor neue Schwierigkeiten und Herausforderungen stellen. Wir arbeiten im Text hauptsächlich mit dem Datentyp String, nutzen freizügig Indizierung und Längenbestimmung und gelegentlich auch Teilstring-Extraktion und Verkettung. Wo wir es für sinnvoll erachten, stellen wir Ihnen auf der Website zum Buch den entsprechenden Code für char-Arrays zur Verfügung. In performancekritischen Anwendungen sollte Ihre Entscheidung, welche der beiden Möglichkeiten Sie für Ihren Client verwenden, davon abhängen, wie hoch die Kosten sind, um auf ein Zeichen zuzugreifen (a[i] ist in typischen Java-Implementierungen meist viel schneller als s.charAt(i)).
Tabelle 5.1
Zwei Möglichkeiten, Strings in Java zu repräsentieren
Operation
Zeichenarrays
Java-String
Deklaration
char[] a
String s
Indizierter Zugriff auf ein Zeichen
a[i]
s.charAt(i)
Länge
a.length
s.length()
Umwandlung
a = s.toCharArray();
s = new String(a);
Alphabete Einige Anwendungen verarbeiten Zeichenfolgen aus einem beschränkten Alphabet. In solchen Anwendungen ist es oft sinnvoll, eine Klasse Alphabet mit der folgenden API zu verwenden.
Tabelle 5.2
Alphabet-API
public class Alphabet Alphabet(String s)
Erzeugt ein neues Alphabet aus den Zeichen in s.
char
toChar(int index)
Wandelt den Index in das entsprechende alphabetische Zeichen um.
int
toIndex(char c)
Wandelt c in einen Index zwischen 0 und R-1 um.
boolean
contains(char c)
Ist c im Alphabet enthalten?
int
R()
Radix (Anzahl der Zeichen im Alphabet)
741
5
Strings
Tabelle 5.2
Alphabet-API (Forts.)
public class Alphabet int
lgR()
Anzahl der Bits, um einen Index zu repräsentieren.
int[]
toIndices(String s)
Wandelt s in ganze Zahlen zur Basis R um.
String
toChars(int[] indices)
Wandelt ganze Zahlen zur Basis R in einen String dieses Alphabet um.
Diese API basiert auf einem Konstruktor, der als Argument einen String mit R Zeichen zur Angabe des Alphabets übernimmt, sowie auf den Methoden toChar() und toIndex(), die (in konstanter Zeit) Stringzeichen in int-Werte zwischen 0 und R−1 umwandeln und umgekehrt. Zusätzlich gibt es eine Methode contains(), die prüft, ob ein gegebenes Zeichen im Alphabet enthalten ist, die Methoden R() und lgR(), um die Anzahl der Zeichen im Alphabet bzw. die Anzahl der Bits zu ihrer Repräsentation festzustellen, sowie die Methoden toIndices() und toChars(), um Strings aus Zeichen des Alphabets in int-Arrays umzuwandeln und umgekehrt. In Tabelle 5.3 finden Sie die in Java vorgegebenen Alphabete, auf die Sie mit Code wie Alphabet.UNICODE16 zugreifen können. Die Implementierung von Alphabet ist recht einfach (siehe Übung 5.1.12). Einen Beispielclient werden wir in Listing 5.1 betrachten.
Tabelle 5.3
742
Standardalphabete
Name
R()
lgR()
Zeichen
BINARY
2
1
01
DNA
4
2
ACTG
OCTAL
8
3
01234567
DECIMAL
10
4
0123456789
HEXADECIMAL
16
4
0123456789ABCDEF
PROTEIN
20
5
ACDEFGHIKLMNPQRSTVWY
LOWERCASE
26
5
abcdefghijklmnopqrstuvwxyz
UPPERCASE
26
5
ABCDEFGHIJKLMNOPQRSTUVWXYZ
BASE64
64
6
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef ghijklmnopqrstuvwxyz0123456789+/
ASCII
128
7
ASCII-Zeichensatz
EXTENDED_ASCII
256
8
erweiterter ASCII-Zeichensatz
UNICODE16
65536
16
Unicode-Zeichensatz
Einleitung
Zeichenindizierte Arrays Einer der wichtigsten Gründe für die Verwendung von Alphabet ist, dass die Effizienz vieler Algorithmen steigt, wenn wir zeichenindizierte Arrays einsetzen, bei denen wir mit jedem Zeichen Informationen assoziieren, die wir mit einem einzigen Arrayzugriff abrufen können. Bei Java-String müssen wir ein Array der Größe 65.536 verwenden; bei Alphabet umfasst das Array nur so viele Einträge, wie Zeichen im Alphabet sind. Einige der Algorithmen, die wir betrachten, können eine riesige Anzahl solcher Arrays erzeugen; in diesen Fällen ist der Speicherbedarf für Arrays der Größe 65.536 unter Umständen nicht mehr vertretbar. Als Beispiel betrachten wir die Klasse Count aus Listing 5.1, die eine Zeichenfolge von der Befehlszeile übernimmt und in Form einer Tabelle ausgibt, wie häufig die Zeichen von der Standardeingabe in der Zeichenfolge vorkommen. Das Array count[], das die Häufigkeiten in Count speichert, ist ein Beispiel für ein zeichenindiziertes Array. Diese Berechnung mag ein wenig trivial erscheinen, bildet aber die Basis für eine Familie von Sortierverfahren, die wir in Abschnitt 5.1 besprechen. public class Count { public static void main(String[] args) { Alphabet alpha = new Alphabet(args[0]); int R = alpha.R(); int[] count = new int[R]; String s = StdIn.readAll(); int N = s.length(); for (int i = 0; i < N; i++) if (alpha.contains(s.charAt(i))) count[alpha.toIndex(s.charAt(i))]++; for (int c = 0; c < R; c++) StdOut.println(alpha.toChar(c) + " " + count[c]); } } Listing 5.1: Typischer Alphabet-Client
% more abra.txt ABRACADABRA! % A B C D R
java Count ABCDR < abra.txt 5 2 1 1 2
743
5
Strings
Zahlen Wie Sie unserer Tabelle der Alphabet-Beispiele entnehmen können, werden Zahlen nicht selten durch Strings repräsentiert. Die Methode toIndices() wandelt jeden String über ein gegebenes Alphabet in eine Zahl zur Basis R um, repräsentiert als int[]-Array, dessen Werte alle zwischen 0 und R−1 liegen. Wenn Sie diese Umwandlung bereits zu Beginn durchführen, erhalten Sie in einigen Fällen überaus kompakten Code, da jede Ziffer als Index in ein zeichenindiziertes Array verwendet werden kann. Wenn wir zum Beispiel wissen, dass die Eingabe nur aus Zeichen aus dem Alphabet besteht, könnten wir die erste innere Schleife von Count durch den folgenden kompakteren Code ersetzen: int[] a = alpha.toIndices(s); for (int i = 0; i < N; i++) count[a[i]]++;
In diesem Zusammenhang bezeichnen wir R als Radix, d.h. die Basis des Zahlensystems. Etliche der Algorithmen, die wir betrachten, werden als „Radix“-Methoden bezeichnet, da sie immer eine Zahl nach der anderen bearbeiten. % more pi.txt 3141592653 5897932384 6264338327 9502884197 ... [100.000 Ziffern von pi] % 0 1 2 3 4 5 6 7 8 9
java Count 0123456789 < pi.txt 9999 10137 9908 10026 9971 10026 10028 10025 9978 9902
Trotz der Vorteile, die die Verwendung eines Datentyps wie Alphabet in stringverarbeitenden Algorithmen mit sich bringt (vor allem für kleine Alphabete), entwickeln wir unsere Implementierungen in diesem Buch aus mehreren Gründen nicht für Strings, die einem allgemeinen Alphabet entstammen:
Die Mehrheit der Clients verwendet einfach String. Die Umwandlung in und aus Indizes erfolgt in der Regel in der inneren Schleife, was die Implementierungen erheblich verlangsamt.
Der Code ist komplizierter und damit auch schwerer zu verstehen. Wir arbeiten also mit String, verwenden die Konstante R = 256 im Code und R als einen Parameter in der Analyse und diskutieren die Performance für allgemeine Alphabete, wo es erforderlich ist. Sie finden vollständige Alphabet-basierte Implementierungen auf der Website zum Buch.
»
744
5.1 Stringsortierverfahren
5.1
Stringsortierverfahren
In vielen Sortieranwendungen bestehen die Schlüssel, die die Reihenfolge definieren, aus Strings. Deshalb wollen wir uns in diesem Abschnitt mit Methoden beschäftigen, die sich die besonderen Eigenschaften von Strings zunutze machen, um Sortierverfahren für Stringschlüssel zu entwickeln, die effizienter sind als die allgemeinen Sortierverfahren, die wir in Kapitel 2 kennengelernt haben. Dazu betrachten wir zwei gänzlich unterschiedliche Ansätze, Zeichenfolgen zu sortieren. Beides sind bewährte Methoden, die den Programmierern seit vielen Jahrzehnten gute Dienste leisten. Der erste Ansatz untersucht die Zeichen in den Schlüsseln von rechts nach links. Diese Methoden werden im Allgemeinen als LSD-Sortierverfahren bezeichnet, da sie mit der niedrigstwertigen Ziffer (least-significant-digit) beginnen. Der Begriff Ziffer anstelle von Zeichen geht zurück auf die Anwendung des gleichen grundlegenden Verfahrens auf verschiedene Zahlendarstellungen. Wenn Sie sich einen String als eine Zahl zur Basis 256 vorstellen, bedeutet das Lesen der Zeichen von rechts nach links, dass die niedrigstwertigen Ziffern zuerst betrachtet werden. Dieser Ansatz ist das Verfahren der Wahl für Sortieranwendungen, bei denen alle Schlüssel die gleiche Länge haben. Der zweite Ansatz untersucht die Zeichen in den Schlüsseln von links nach rechts, d.h. es wird mit der höchstwertigen Ziffer begonnen. Diese Methoden werden im Allgemeinen als MSD-Sortierverfahren (most-significant-digit) bezeichnet. Wir werden in diesem Abschnitt zwei dieser Verfahren betrachten. Die MSD-Sortierung ist vor allem deshalb beliebt, weil beim Sortieren nicht automatisch alle Eingabezeichen untersucht werden müssen. MSD-Sortierverfahren lassen sich mit Quicksort vergleichen, da auch hier das zu sortierende Array in unabhängige Teile partitioniert wird, sodass für das Sortieren rekursiv die gleiche Methode auf die Teilarrays angewendet wird. Der Unterschied ist, dass MSD-Sortierverfahren nur das erste Zeichen des Sortierschlüssels für die Partitionierung verwenden, während Quicksort hierfür Vergleiche durchführt, die unter Umständen voraussetzen, dass der ganze Schlüssel untersucht wird. Die erste Methode, die wir betrachten, erzeugt für jeden Zeichenwert eine Partition und die zweite erzeugt immer drei Partitionen für Sortierschlüssel, deren erstes Zeichen kleiner, gleich oder größer dem ersten Zeichen des Partitionierungsschlüssels ist. Die Anzahl der Zeichen im Alphabet ist ein wichtiger Parameter bei der Analyse von Stringsortierverfahren. Auch wenn unser Schwerpunkt hier auf Strings des erweiterten ASCII-Zeichensatzes (R = 256) liegt, werden wir auch Strings untersuchen, die auf viel kleineren Alphabeten (wie Genomsequenzen) oder viel größeren Alphabeten basieren (wie das 65.536 Zeichen umfassende Unicode-Alphabet, das sich für die Codierung natürlicher Sprachen inzwischen als Standard durchgesetzt hat).
745
5
Strings
5.1.1 Schlüsselindiziertes Zählen Als Aufwärmübung betrachten wir ein einfaches Verfahren, das sich besonders gut eignet, wenn die Schlüssel kleine Integer sind. Dieses Verfahren, das als schlüsselindiziertes Zählen bezeichnet wird, ist nicht nur nützlich, sondern bildet die Grundlage für zwei der drei Sortierverfahren, die wir in diesem Abschnitt ausführlicher erläutern wollen.
Eingabe
sortiertes Ergebnis
Name Gruppe Anderson 2 Brown 3 Davis 3 Garcia 4 Harris 1 Jackson 3 Johnson 4 Jones 3 Martin 1 Martinez 2 Miller 2 Moore 1 Robinson 2 Smith 4 Taylor 3 Thomas 4 Thompson 4 White 2 Williams 3 Wilson 4
(nach Gruppe) Harris 1 Martin 1 Moore 1 Anderson 2 Martinez 2 Miller 2 Robinson 2 White 2 Brown 3 Davis 3 Jackson 3 Jones 3 Taylor 3 Williams 3 Garcia 4 Johnson 4 Smith 4 Thomas 4 Thompson 4 Wilson 4
Betrachten wir beispielsweise folgendes Datenverarbeitungsproblem, vor dem ein Lehrer stehen könnte, der für seine Klasse einen Notenspiegel erstellt, indem er die Schüler bestimmten Gruppen (1, 2, 3 usw.) zuordnet. Gelegentlich ist es erforderlich, die Klasse nach Gruppe aufzulisten. Da die Nummern der Gruppen kleine ganze Zahlen sind, bietet sich das Sortieren durch schlüsselindiziertes Zählen an. Bei der Beschreibung des Verfahrens gehen wir davon aus, dass die Informationen in Schlüssel sind einem Array a[] von Elementen gehalten werden, die kleine ganze Zahlen jeweils aus einem Namen und einer Gruppennummer Abbildung 5.2: Typischer Kandidat bestehen, wobei die Gruppennummern ganze Zahlen für schlüsselindiziertes Zählen zwischen 0 und R-1 sind und der Code a[i].key() die Gruppennummer für den betreffenden Schüler zurückliefert. Das Verfahren besteht aus vier Schritten, die wir nachfolgend beschreiben.
Häufigkeitszählungen Der erste Schritt besteht darin, mithilfe eines int-Arrays count[] zu zählen, wie häufig jeder Schlüsselwert vorkommt. Für jedes Element verwenden wir den Schlüssel, um auf einen Eintrag in count[] zuzugreifen und diesen Eintrag zu erhöhen. Wenn der Schlüsselwert r ist, inkrementieren wir count[r+1]. (Warum +1? Die Begründung folgt im nächsten Schritt.) In Abbildung 5.3 inkrementieren wir zuerst count[3], da Anderson zur Gruppe 2 gehört, dann inkrementieren wir count[4] zweimal, da Brown und Davis zur Gruppe 3 gehören, usw. Beachten Sie, dass count[0] immer 0 ist und count[1] in diesem Beispiel 0 ist (da es keinen Schüler in einer Gruppe 0 gibt).
Abbildung 5.3: Häufigkeitszählungen berechnen
746
for (i = 0; i < N; i++) count[a[i].key() + 1]++; immer 0
Anderson Brown Davis Garcia Harris Jackson Johnson Jones Martin Martinez Miller Moore Robinson Smith Taylor Thomas Thompson White Williams Wilson
2 3 3 4 1 3 4 3 1 2 2 1 2 4 3 4 4 2 3 4
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
count[] 1 2 3 4 0 0 0 0 0 0 1 0 0 0 1 1 0 0 1 2 0 0 1 2 0 1 1 2 0 1 1 3 0 1 1 3 0 1 1 4 0 2 1 4 0 2 2 4 0 2 3 4 0 3 3 4 0 3 4 4 0 3 4 4 0 3 4 5 0 3 4 5 0 3 4 5 0 3 5 5 0 3 5 6 0 3 5 6
Anzahl der 3en
5 0 0 0 0 1 1 1 2 2 2 2 2 2 2 3 3 4 5 5 5 6
5.1 Stringsortierverfahren
Häufigkeiten in Indizes umwandeln Als Nächstes verwenden wir count[], um für jeden for (int r = 0; r < R; r++) count[r+1] += count[r]; Schlüsselwert die Startindexposition in der sortierten immer 0 Reihenfolge der Elemente mit diesem Schlüssel zu r berechnen. Da es in unserem Beispiel drei Elemente mit 0 dem Schlüssel 1 und fünf Elemente mit dem Schlüssel 2 1 gibt, beginnen die Elemente mit Schlüssel 3 im sortierten 2 3 Array an Position 8. Im Allgemeinen erhalten wir die 4 Startindexposition von Elementen eines beliebigen 5 Schlüsselwerts, indem wir die Häufigkeiten der kleineren Werte addieren. Für jeden Schlüsselwert r ist die Anzahl der Schlüssel kleiner als 3 (Startindex von 3en in der Ausgabe) Summe der Anzahl der Schlüsselwerte kleiner als r+1 Abbildung 5.4: Häufigkeiten in Startgleich der Summe der Anzahl der Schlüsselwerte kleiindizes umwandeln ner als r plus count[r], sodass es nicht schwer ist, von links nach rechts vorzugehen, um count[] in eine Indextabelle umzuwandeln, die uns als Basis zum Sortieren der Daten dient.
Daten verteilen for (int i = 0; i < N; i++) aux[count[a[i].key()]++] = a[i];
i 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
a[0]
aux[0]
a[1]
aux[1]
a[2]
aux[2]
a[3]
aux[3]
a[4]
aux[4]
a[5]
aux[5]
a[6]
aux[6]
a[7]
aux[7]
a[8]
aux[8] aux[9]
a[9] a[10]
aux[10]
a[11]
aux[11]
a[12]
aux[12]
a[13]
aux[13]
a[14]
aux[14]
a[15]
aux[15]
a[16]
aux[16]
a[17]
aux[17]
a[18]
aux[18]
a[19]
aux[19]
Abbildung 5.5: Verteilen der Daten (Datensätze mit Schlüssel 3 optisch hervorgehoben)
Mit dem count[]-Array, umgewandelt in eine Indextabelle, nehmen wir nun die eigentliche Sortierung vor, indem wir die Elemente in ein Hilfsarray aux[] verschieben. Wir verschieben jedes Element an die Position in aux[], die durch den count[]-Eintrag ent-
747
5
Strings
sprechend seines Schlüssels vorgegeben wird, und inkrementieren dann diesen Eintrag, um die folgende Invariante für count[] aufrechtzuerhalten: Für jeden Schlüsselwert r ist count[r] der Index der Position in aux[], an der das nächste Element mit dem Schlüsselwert r (sofern vorhanden) abgelegt werden sollte. Wie Abbildung 5.5 zeigt, sortiert dieser Prozess die Daten in einem Durchlauf. Hinweis: In einer unserer Anwendungen ist die Tatsache, dass diese Implementierung stabil ist, entscheidend: Elemente mit gleichen Schlüsseln werden zusammengefasst, behalten aber ihre relative Ordnung zueinander. vorher
. . .
aux[] count[0]
count[1]
count[2]
count[R-1]
während aux[]
0
1
1
count[0]
1
2
count[1]
. . .
2 count[2]
R-1 R-1 R-1 count[R-1]
nachher aux[]
0
...
0
1
1
count[0]
1
...
1
2
2
...
count[1]
2
. . .
count[2]
R-1 R-1 R-1
R-1 count[R-1]
Abbildung 5.6: Schlüsselindiziertes Zählen (Verteilungsphase)
Zurückkopieren Da wir für das Sortieren die Elemente in ein Hilfsarray verschoben haben, besteht der letzte Schritt darin, das sortierte Ergebnis zurück in das ursprüngliche Array zu kopieren.
Satz A: Schlüsselindiziertes Zählen erfordert 11N + 4R + 1 Arrayzugriffe, um N Elemente stabil zu sortieren, deren Schlüssel ganze Zahlen zwischen 0 und R−1 sind. Beweis: Ergibt sich unmittelbar aus dem Code. Die Initialisierung der Arrays benötigt N + R + 1 Arrayzugriffe. Die erste Schleife inkrementiert einen Zähler für jedes der N Elemente (3N Arrayzugriffe), die zweite Schleife führt R Additionen aus (3R Arrayzugriffe), die dritte Schleife führt N Zählerinkrementierungen und N Datenverschiebungen durch (5N Arrayzugriffe) und die vierte Schleife führt N Datenverschiebungen durch (2N Arrayzugriffe). Beide Verschiebeoperationen bewahren die relative Ordnung gleicher Schlüssel.
Schlüsselindiziertes Zählen ist ein extrem effektives und oft vernachlässigtes Sortierverfahren für Anwendungen, in denen die Schlüssel kleine ganze Zahlen sind. Diese Vorgehensweise zu verstehen, ist ein erster Schritt, das Sortieren von Strings zu verstehen. Satz A impliziert, dass schlüsselindiziertes Zählen die untere Schranke von N log N durchbricht, die wir für Sortieren bewiesen haben. Wie ist das möglich? Satz I in Abschnitt 2.2 ist eine untere Schranke bezüglich der Anzahl der benötigten Vergleiche (wenn auf die Daten nur über compareTo() zugegriffen wird) – beim schlüsselindizierten Zählen gibt es keine Vergleiche (auf die Daten wird nur über key() zugegriffen). Wenn R innerhalb eines konstanten Faktors von N ist, haben wir eine lineare Sortierung.
748
5.1 Stringsortierverfahren
int N = a.length; String[] aux = new String[N]; int[] count = new int[R+1]; // Anzahl der Häufigkeiten berechnen. for (int i = 0; i < N; i++) count[a[i].key() + 1]++; // Häufigkeiten in Indizes umwandeln. for (int r = 0; r < R; r++) count[r+1] += count[r]; // Datensätze verteilen. for (int i = 0; i < N; i++) aux[count[a[i].key()]++] = a[i]; // Zurückkopieren. for (int i = 0; i < N; i++) a[i] = aux[i]; Listing 5.2: Schlüsselindiziertes Zählen (a[].key ist ein int in [0, R))
5.1.2 LSD-Sortierverfahren sortiertes Das erste Sortierverfahren, das wir näher untersuchen wollen, ist Eingabe Ergebnis das sogenannte LSD-Sortierverfahren (least-significant-digit). 4PGC938 1ICK750 Betrachten wir den hypothetischen Fall, dass das Straßenbauamt 2IYE230 1ICK750 ein Gerät aufstellt, das die Autokennzeichen aller Fahrzeuge 3CIO720 1OHV845 registriert, die während eines bestimmten Zeitraums eine stark 1ICK750 1OHV845 1OHV845 frequentierte Autobahn befahren, um zu ermitteln, wie viele 1OHV845 4JZY524 2IYE230 verschiedene Fahrzeuge die Autobahn benutzen. Wie Sie viel1ICK750 2RLA629 leicht von Abschnitt 2.1 wissen, lässt sich das Problem leicht 3CIO720 2RLA629 dadurch lösen, dass wir die Zahlen sortieren und dann wie in 1OHV845 3ATW723 3CIO720 DeDup (Listing 3.23) in einem Durchlauf die verschiedenen Werte 1OHV845 2RLA629 3CIO720 zählen. Autokennzeichen in den Vereinigten Staaten sind eine 2RLA629 4JZY524 Mischung aus Buchstaben und Zahlen, sodass man sie am bes- 3ATW723 4PGC938 ten als Strings repräsentiert. Im einfachsten Fall haben die Schlüssel haben Strings alle die gleiche Anzahl von Zeichen (wie die Autokenn- alle die gleiche Länge zeichen von Kalifornien in Abbildung 5.7). Dies kommt in SorAbbildung 5.7: Typischer tieranwendungen sehr häufig vor – Telefonnummern, KontoKandidat für das LSD-Sornummern und IP-Adressen sind typische Beispiele für Strings tierverfahren fester Länge.
Wie Algorithmus 5.1 (LSD) in Listing 5.3 und das dazugehörige Beispiel zeigen, lassen sich solche Strings durch schlüsselindiziertes Zählen sortieren. Wenn alle Strings die Länge W haben, sortieren wir die Strings durch schlüsselindiziertes Zählen W-mal und verwenden dabei jede der Positionen von rechts nach links als Schlüssel. Es ist am Anfang nicht einfach, sich vorzustellen, dass man auf diese Weise ein sortiertes Array erhält – genau genommen funktioniert dieses Verfahren auch nur, wenn die Implemen-
749
5
Strings
tierung des schlüsselsortierten Zählens stabil ist. Vergessen Sie dies nicht und nehmen Sie sich das Beispiel zur Hilfe, wenn Sie den folgenden Korrektheitsbeweis analysieren.
Listing 5.3: LSD-Sortierverfahren (Algorithmus 5.1) public class LSD { public static void sort(String[] a, int W) { // a[] auf den führenden W Zeichen sortieren. int N = a.length; int R = 256; String[] aux = new String[N]; for (int d = W-1; d >= 0; d--) { // Durch schlüsselindiziertes Zählen nach dem d-ten Zeichen sortieren. int[] count = new int[R+1]; // Häufigkeiten berechnen. for (int i = 0; i < N; i++) count[a[i].charAt(d) + 1]++; for (int r = 0; r < R; r++) count[r+1] += count[r];
// Häufigkeiten in Indizes umwandeln.
for (int i = 0; i < N; i++) // Verteilen. aux[count[a[i].charAt(d)]++] = a[i]; for (int i = 0; i < N; i++) a[i] = aux[i]; }
// Zurückkopieren.
} }
Um ein Array a[] von Strings zu sortieren, die jeweils genau W Zeichen haben, führen wir W Sortiergänge mit schlüsselindiziertem Zählen durch: einen für jede Zeichenposition, und zwar von rechts nach links. Eingabe (W = 7)
4PGC938 2IYE230 3CIO720 1ICK750 1OHV845 4JZY524 1ICK750 3CIO720 1OHV845 1OHV845 2RLA629 2RLA629 3ATW723
750
d=6
d=5
d=4
d=3
d=2
d=1
d=0
Ausgabe
5.1 Stringsortierverfahren
Satz B: Das LSD-Sortierverfahren sortiert stabil Strings fester Länge. Beweis: Dies hängt im Wesentlichen davon ab, dass die Implementierung zum schlüsselindiziertem Zählen stabil ist, wie in Satz A bereits erwähnt wurde. Nach dem (stabilen) Sortieren der Schlüssel auf ihren i letzten Zeichen wissen wir, dass zwei beliebige Schlüssel in der richtigen Reihenfolge im Array stehen (wobei wir nur diese Zeichen betrachten), weil entweder das erste ihrer i letzten Zeichen unterschiedlich ist, in welchem Fall wir durch Sortieren dieser Zeichenspalte die Reihenfolge herstellen, oder weil das erste ihrer i letzten Zeichen gleich ist, in welchem Fall sie aufgrund der Stabilität (und durch Induktion für i-1) geordnet sind.
Ein anderer Weg, den Beweis zu erbringen, besteht darin vorauszuschauen: Wenn die für ein Schlüsselpaar noch nicht untersuchten Zeichen identisch sind, beschränkt sich jeder Unterschied zwischen den Schlüsseln auf die bereits untersuchten Schlüssel; die Schlüssel sind also bereits in der richtigen Reihenfolge sortiert und bleiben es aufgrund der Stabilität auch. Sind die noch nicht untersuchten Zeichen jedoch unterschiedlich, dann spielen die bereits untersuchten Zeichen keine Rolle mehr und ein späterer Durchlauf korrigiert die Reihenfolge des Paars auf der Basis der höherwertigen Unterschiede. LSD-Radixsort ist ein Sortierverfahren, wie es von alten Lochkartensortiermaschinen verwendet wird, die Anfang des 20. Jahrhunderts entwickelt wurden und damit Vorläufer der heutigen Rechner zur kommerziellen Datenverarbeitung waren. Solche Sortiermaschinen konnten einen Stapel Lochkarten entsprechend den Lochungen in den zu untersuchenden Spalten auf zehn Fächer verteilen. Wenn ein Kartenstapel eingestanzte Zahlen in einer Menge bestimmter Spalten hatte, konnte ein Operator die Karten sortieren, indem er sie nach der Ziffer ganz rechts durch die Maschine laufen ließ, danach die ausgegebenen Stapel aus der Maschine nahm und in der richtigen Reihenfolge übereinanderlegte, sie dann nach der zweiten Ziffer von rechts durch die Maschine laufen ließ usw., bis er bei der ersten Ziffer angelangt war. Das physikalische Übereinanderlegen der Karten ist ein stabiler Prozess, der vom schlüsselindizierten Zählen nachgebildet wird. Diese Version des LSD-Radixsort war nicht nur in kommerziellen Anwendungen bis Ende der 1970er wichtig, sondern wurde auch von vielen vorsichtigen Programmierern (und Studenten!) verwendet, die ihre Programme auf Lochkarten speichern mussten und fortlaufende Nummern in den letzten Spalten eines Programmstapels stanzten, damit sie den Stapel wieder mechanisch in die richtige Reihenfolge bringen konnten, falls er versehentlich herunterfallen und durcheinandergeraten sollte. Dieses Verfahren eignet sich auch hervorragend, um Spielkarten zu sortieren: Teilen Sie die Karten auf dreizehn Stapel aus (einen für jeden Wert), nehmen Sie dann die Stapel in der richti-
Abbildung 5.8: Ein Kartenspiel mit dem LSD-Sortierverfahren sortieren
751
5
Strings
gen Reihenfolge auf und teilen Sie dann die Karten auf vier Stapel aus (einen für jede Farbe). Dieses (stabile) Kartenausteilverfahren sorgt dafür, dass die Karten in jeder Farbe sortiert sind, sodass Sie, wenn Sie die vier Stapel in der richtigen Farbfolge aufnehmen, ein sortiertes Kartenspiel erhalten. In vielen Anwendungen zum Sortieren von Strings sind die Schlüssel unterschiedlich lang (sogar bei Autokennzeichen). Es ist möglich, das LSD-Sortierverfahren so anzupassen, dass es auch in solchen Anwendungen eingesetzt werden kann; aber diese Aufgabe wollen wir Ihnen als Übung überlassen, da wir als Nächstes zwei Verfahren vorstellen werden, die speziell dafür ausgelegt sind, Schlüssel variabler Länge zu verarbeiten. Aus theoretischer Sicht ist das LSD-Sortierverfahren wichtig, da es für typische Anwendungen eine lineare Laufzeit aufweist. Egal wie groß der Wert von N ist, das Verfahren durchläuft die Daten genau W-mal.
Satz B (Fortsetzung): Das LSD-Sortierverfahren benötigt ∼10WN + 4WR Arrayzugriffe und zusätzlichen Speicher proportional N + R, um N Elemente zu sortieren, deren Stringschlüssel aus W Zeichen bestehen, die einem Alphabet von R Zeichen entnommen wurden. Beweis: Das Verfahren besteht darin, das schlüsselindizierte Zählen W-mal auszuführen, mit der Ausnahme, dass das aux[]-Array nur einmal initialisiert wird. Die Gesamtsumme ergibt sich unmittelbar aus dem Code und aus Satz A.
In typischen Anwendungen ist R wesentlich kleiner als N, sodass Satz B impliziert, dass die Gesamtlaufzeit proportional WN ist. Ein Eingabearray von N Strings mit jeweils W Zeichen besteht insgesamt aus WN Zeichen. Das bedeutet, dass die Laufzeit des LSDSortierverfahrens linear zur Größe der Eingabe ist.
5.1.3 MSD-Sortierverfahren Für die Implementierung eines allgemeineren Sortierverfahrens, bei dem die Strings nicht notwendigerweise alle die gleiche Länge haben, betrachten wir die Zeichen von links nach rechts. Wir wissen, dass Strings mit dem Anfangsbuchstaben a vor Strings stehen sollten, die mit einem b beginnen, und so weiter. Der natürliche Ansatz zur Implementierung dieser Strategie ist ein rekursives Verfahren, das als MSD-Sortierverfahren (most-significant-digit first) bezeichnet wird. Mithilfe schlüsselindizierten Zählens sortieren wir die Strings nach ihrem ersten Zeichen und sortieren dann (rekursiv) die Teilarrays nach jedem Zeichen (ausgenommen dem ersten, das, wie wir wissen, für die Strings eines Teilarrays gleich ist). Wie Quicksort unterteilt das MSD-Sortierverfahren das Array in Teilarrays, die unabhängig sortiert werden können, um die Aufgabe zu beenden. Allerdings wird das Array in je ein Teilarray für jeden möglichen Wert des ersten Zeichens geteilt, anstatt es in zwei oder drei Partitionen aufzuteilen wie bei Quicksort.
752
5.1 Stringsortierverfahren
Konvention für das Ende von Strings Wir müssen beim MSD-Sortierverfahren besonders darauf achten, wann wir das Ende der Strings erreichen. Korrektes Sortieren setzt voraus, dass das Teilarray für Strings, deren Zeichen bereits alle untersucht sind, als erstes Teilarray erscheint; wir wollen dieses Teilarray nicht rekursiv sortieren. Um diese zwei Teile der Berechnung zu erleichtern, verwenden wir eine private Methode toChar() mit zwei Argumenten, die ein indiziertes Stringzeichen in einen Arrayindex umwandelt, der -1 zurückliefert, wenn die angegebene Zeichenposition hinter dem Ende des Strings liegt. Anschließend addieren wir einfach 1 zu jedem Rückgabewert, um einen nicht-negativen int-Wert zu erhalten, mit dem wir count[] indizieren können. Diese Konvention bedeutet, dass wir R+1 verschiedene Zeichenwerte an jeder Stringposition haben: 0 für Stringende, 1 für das erste Zeichen im Alphabet, 2 für das zweite usw. Da schlüsselindiziertes Zählen bereits eine zusätzliche Position erfordert, erzeugen wir das Array der Häufigkeiten mit dem Code int count[] = new int[R+2]; (und setzen alle seine Werte auf 0). Hinweis: In einigen Sprachen – speziell C und C++ – gibt es bereits eine Konvention für die Behandlung von Stringenden, sodass unser Code für diese Sprachen entsprechend angepasst werden muss. sortiert nach dem ersten Zeichenwert, um in Teilarrays zu partitionieren
sortiert Teilarrays rekursiv (ohne das erste Zeichen) 0 0 0 . . . 0 0
0 0 0 . . . 0 0 1 1 1 . . . 1 1 2 2 2 . . . 2 2
1 1 1 . . . 1 1 2 2 2 . . . 2 2 . . .
r r r . . . r r
. . .
Abbildung 5.9: Ein Kartenspiel mit dem MSD-Sortierverfahren sortieren
r r r . . . r r . . .
. . .
Abbildung 5.10: Schema einer MSD-Sortierung
753
5
Strings
Nach diesen Vorbereitungen erfordert die Implementierung des MSD-Sortierverfahrens (Algorithmus 5.2) nur wenig neuen Code. Wir fügen einen Test hinzu, um bei kleinen Teilarrays zu Insertionsort zu wechseln (ein spezielles Insertionsort, das wir später betrachten wollen), und ergänzen den Code für das schlüsselindizierte Zählen um eine Schleife für die rekursiven Aufrufe. Wie Tabelle 5.4 zeigt, liefern uns die Werte im Array count[] (nachdem das Array dazu diente, die Häufigkeiten zu zählen, diese Häufigkeitswerte in Indizes umzuwandeln und die Daten zu verteilen) genau die Informationen, die wir benötigen, um die Teilarrays zu den einzelnen Zeichenwerten (rekursiv) zu sortieren.
Eingabe
sortiertes Ergebnis
she sells seashells by the seashore unterthe shells schiedliche Schlüsselshe längen sells are surely seashells
are by seashells seashells seashore sells sells she she shells surely the the
Abbildung 5.11: Typischer Kandidat für das MSD-Sortierverfahren
Vorgegebenes Alphabet Die Kosten des MSD-Sortierverfahrens hängen stark von der Anzahl der möglichen Zeichen im Alphabet ab. Unsere Sortiermethode lässt sich leicht um Alphabet als Argument erweitern, um die Effizienz von Clients zu verbessern, die Strings über einem relativ kleinen Alphabet verarbeiten. Nehmen Sie die folgenden Änderungen vor:
Speichern Sie das Alphabet in einer Instanzvariablen alpha im Konstruktor. Setzen Sie R im Konstruktor auf alphaR(). Ersetzen Sie s.charAt(d) durch alpha.toIndex(s.charAt(d)) in charAt(). Tabelle 5.4
Interpretation der count[]-Werte beim MSD-Sortierverfahren
Bei Beendigung der Phase für dtes Zeichen
Wert von count[r] ist r=0
r=1
r zwischen 2 und R-1
Häufigkeiten zählen
0 (nicht
Anzahl der Strings der Länge d
Anzahl der Strings, deren d-ter Zeichenwert r-2 ist
Häufigkeitswerte in Indizes umwandeln
Startindex von Teilarray für Strings der Länge d
Verteilen
Startindex von Teilarray für Strings, deren d-ter Zeichenwert r ist
verwendet)
1 + Endindex von Teilarray für Strings der Länge d
754
r=R
Startindex von Teilarray für Strings, deren d-ter Zeichenwert r-1 ist
r=R+1
nicht verwendet
nicht verwendet
1 + Endindex von Teilarray für Strings, deren d-ter Zeichenwert r-1 ist
nicht verwendet
5.1 Stringsortierverfahren
Listing 5.4: MSD-Sortierverfahren (Algorithmus 5.2) public class MSD { private static int R = 256; private static final int M = 15; private static String[] aux;
// Radix // Wechsel für kleine Teilarrays // Hilfsarray für die Verteilung
private static int charAt(String s, int d) { if (d < s.length()) return s.charAt(d); else return -1; } public static void sort(String[] a) { int N = a.length; aux = new String[N]; sort(a, 0, N-1, 0); } private static void sort(String[] a, int lo, int hi, int d) { // Sortiert von a[lo] bis a[hi], wobei mit dem d-ten Zeichen begonnen wird. if (hi 6
2->3
3->2
3->4
5->8
8->9
10->11
NEA-Simulation und Erreichbarkeit Um einen NEA zu simulieren, verwalten wir die Menge der Zustände, zu denen der Automat bei der Untersuchung des aktuellen Eingabezeichens wechseln könnte. Der wichtigste Schritt dabei ist, die Erreichbarkeit von mehreren Startknoten aus zu berechnen, wie wir es bereits in Algorithmus 4.4 (Listing 4.13) gemacht haben. Zur Initialisierung dieser Menge suchen wir die Menge der Zustände, die über ∈-Übergänge vom Zustand 0 aus erreichbar sind. Für jeden dieser Zustände prüfen wir, ob es einen Übereinstimmungsübergang für das erste Eingabezeichen gibt. Diese Prüfung liefert uns die Menge der möglichen Übergänge für den NEA direkt nach dem Abgleich des ersten Eingabezeichens. Zu dieser Menge fügen wir alle Zustände hinzu, die über ∈-Übergänge von einem der Zustände in der Menge erreicht werden könnten. Ist die Menge der möglichen Zustände für den NEA direkt nach dem Abgleich des ersten Eingabezeichens gegeben, so liefert uns die Lösung zu dem Problem der Erreichbarkeit von mehreren Startknoten aus im ∈-Übergang-Digraphen die Menge der Zustände, die zu Übereinstimmungsübergängen für das zweite Zeichen in der Eingabe führen können. Zum Beispiel lautet die anfängliche Menge der Zustände für unseren Beispiel-NEA 0 1 2 3 4 6. Wenn das erste Zeichen ein A ist, könnte der NEA einen Übereinstimmungsübergang zu 3 oder 7 nehmen; dann könnte er ∈-Übergänge von 3 zu 2 oder 3 zu 4 nehmen, sodass die Menge der möglichen Zustände, die zu einem Übereinstimmungsübergang für das zweite Zeichen führen könnten, 2 3 4 7 lautet. Die Wiederholung dieses Prozesses, bis alle Textzeichen erschöpft sind, liefert uns eines von zwei Ergebnissen:
Die Menge der möglichen Zustände umfasst den akzeptierenden Zustand. Die Menge der möglichen Zustände umfasst den akzeptierenden Zustand nicht. Gemäß dem ersten Ergebnis gibt es eine Folge von Übergängen, die den NEA in einen akzeptierenden Zustand überführt, sodass wir Erfolg vermelden können. Gemäß dem zweiten Ergebnis gerät der NEA bei dieser Eingabe immer in eine Sackgasse, sodass wir
840
5.4 Reguläre Ausdrücke
von einem Fehlschlag sprechen. Mit unserem Datentyp SET und der Klasse DirectedDFS, die die Erreichbarkeit von mehreren Startknoten aus in einem Digraphen berechnet, ist die Simulation eines NEA in Listing 5.24 eine einfache Übersetzung der gerade gegebenen Beschreibung in Code. Anhand des Ablaufprotokolls in Abbildung 5.51, das die vollständige Simulation für unser Beispiel veranschaulicht, können Sie prüfen, wie gut Sie den Code verstanden haben. 0 1 2 3 4 6 : Menge der Zustände, die über -Übergänge vom Start aus erreichbar sind (
(
A
*
B
|
A
C
)
D
)
D
)
3 7 : Menge der Zustände, die nach dem Abgleich von A erreichbar sind (
(
A
*
B
|
A
C
)
2 3 4 7 : Menge der Zustände, die über -Übergänge nach dem Abgleich von A erreichbar sind (
(
A
*
B
|
A
C
)
D
)
D
)
3 : Menge der Zustände, die nach dem Abgleich von A A erreichbar sind (
(
A
*
B
|
A
C
)
2 3 4 : Menge der Zustände, die über -Übergänge nach dem Abgleich von A A erreichbar sind (
(
A
*
B
|
A
C
)
D
)
5 : Menge der Zustände, die nach dem Abgleich von A A B erreichbar sind (
(
A
*
B
|
A
C
)
D
)
5 8 9 : Menge der Zustände, die über -Übergänge nach dem Abgleich von A A B erreichbar sind (
(
A
*
B
|
A
C
)
D
)
10 : Menge der Zustände, die nach dem Abgleich von A A B D erreichbar sind (
(
A
*
B
|
A
C
)
D
)
10 11 : Menge der Zustände, die über -Übergänge nach dem Abgleich von A A B D erreichbar sind (
(
A
*
B
|
A
C
)
D
)
akzeptiert! Abbildung 5.51: Simulation des ((A*B|AC)D)-NEA für die Eingabe A A B D
841
5
Strings
Satz Q: Die Feststellung, ob ein Textstring von N Zeichen von einem NEA erkannt wird, der einem regulären Ausdruck mit M Zeichen entspricht, benötigt im schlimmsten Fall eine Zeit, die proportional NM ist. Beweis: Für jedes der N Textzeichen iterieren wir über eine Menge von Zuständen, die nicht größer ist als M, und führen eine Tiefensuche auf dem Digraphen der ∈-Übergänge aus. Die Konstruktion, die wir als Nächstes betrachten, belegt, dass die Anzahl der Kanten in diesem Digraphen nicht größer ist als 2M, sodass die Zeit für jede Tiefensuche im schlimmsten Fall proportional M ist.
Nehmen Sie sich ruhig ein wenig Zeit, um über dieses bemerkenswerte Ergebnis nachzudenken. Die Worst-Case-Kosten, d.h. das Produkt von Textlänge mal Musterlänge, sind gleich den Worst-Case-Kosten für das Finden einer genauen Teilstringübereinstimmung mithilfe des grundlegenden Algorithmus, mit dem wir in Abschnitt 5.3 begonnen haben. public boolean recognizes(String txt) { // Erkennt der NEA txt? Bag pc = new Bag(); DirectedDFS dfs = new DirectedDFS(G, 0); for (int v = 0; v < G.V(); v++) if (dfs.marked(v)) pc.add(v); for (int i = 0; i < txt.length(); i++) { // Berechnet mögliche NEA-Zustände für txt[i+1]. Bag match = new Bag(); for (int v : pc) if (v < M) if (re[v] == txt.charAt(i) || re[v] == '.') match.add(v+1); pc = new Bag(); dfs = new DirectedDFS(G, match); for (int v = 0; v < G.V(); v++) if (dfs.marked(v)) pc.add(v); } for (int v : pc) if (v == M) return true; return false; } Listing 5.24: NEA-Simulation für die musterbasierte Suche
5.4.6 Konstruktion eines NEA für einen regulären Ausdruck Aufgrund der Ähnlichkeit zwischen regulären Ausdrücken und bekannten arithmetischen Ausdrücken dürfte es Sie nicht überraschen, dass die Übersetzung eines regulären Ausdrucks in einen NEA in etwa dem Prozess entspricht, einen arithmetischen Ausdruck mithilfe des Algorithmus von Dijkstra mit zwei Stapeln auszuwerten (siehe Abschnitt 1.3). Der Prozess weist einige Unterschiede auf, weil
842
5.4 Reguläre Ausdrücke
reguläre Ausdrücke keinen expliziten Operator für die Verkettung haben; reguläre Ausdrücke einen unären Operator haben (für die Hüllenoperation *); reguläre Ausdrücke nur einen binären Operator haben (für oder |). Anstatt uns lange mit den Unterschieden und Ähnlichkeiten aufzuhalten, wollen wir eine Implementierung betrachten, die auf reguläre Ausdrücke zugeschnitten ist. So benötigen wir zum Beispiel nur einen Stapel und nicht zwei. Wie wir am Anfang des vorherigen Unterabschnitts im Zusammenhang mit der Repräsentation besprochen haben, müssen wir nur den Digraphen G erstellen, der aus allen ∈-Übergängen besteht. Der reguläre Ausdruck selbst und die formalen Definitionen, die wir am Anfang dieses Abschnitts betrachtet haben, bieten genau die Informationen, die wir benötigen. In Anlehnung an den Dijkstra-Algorithmus verwenden wir einen Stapel, um die Positionen der linken Klammern und Oder-Operatoren zu verwalten.
Verkettung Bezogen auf den NEA ist die Verkettungsoperation am einfachsten zu implementieren. Übereinstimmungsübergänge für Zustände, die Zeichen im Alphabet entsprechen, implementieren explizit eine Verkettung.
Klammern Wir legen den RE-Index von jeder linken Klammer auf dem Stapel ab. Jedes Mal, wenn wir auf eine rechte Klammern treffen, entfernen wir die dazugehörige linke Klammer wie unten beschrieben aus dem Stapel. Wie in dem Algorithmus von Dijkstra können wir dank des Stapels verschachtelte Klammern ganz natürlich behandeln.
Sternhüllenbildung Ein Sternoperator (*) muss entweder (i) vor einem einzelnen Zeichen stehen, wenn wir ∈-Übergänge von und zu diesem Zeichen hinzufügen, oder (ii) nach einer rechten Klammer, wenn wir ∈-Übergänge von und zu der dazugehörigen linken Klammer ganz oben im Stapel hinzufügen.
Oder-Ausdruck Wir verarbeiten einen regulären Ausdruck der Form (A | B), wobei A und B beides reguläre Ausdrücke sind, durch Hinzufügen von zwei ∈-Übergängen: einen Übergang von dem Zustand für die linke Klammer zu dem Zustand für das erste Zeichen von B, und einen zweiten Übergang von dem Zustand für den |-Operator zu dem Zustand für die rechte Klammer. Wir legen den RE-Index für den |-Operator auf dem Stapel ab (sowie den Index für die linke Klammer wie oben beschrieben), sodass die erforderliche Information oben im Stapel liegt, wenn wir sie bei Erreichen der rechten Klammer benötigen. Diese ∈-Übergänge erlauben dem NEA, eine der beiden Alternativen zu wählen. Wir fügen keinen ∈-Übergang von dem Zustand für den |-Operator zu dem Zustand mit dem nächsthöheren Index hinzu, wie wir es für alle anderen Zustände gemacht haben –
843
5
Strings
die einzige Möglichkeit für den NEA, solch einen Zustand zu verlassen, besteht darin, einen Übergang zu dem Zustand für die rechte Klammer zu nehmen. Diese einfachen Regeln reichen, um NEAs für beliebig komplexe reguläre Ausdrücke zu erstellen. Algorithmus 5.9 (Listing 5.25) ist eine Implementierung, deren Konstruktor den Digraphen der ∈-Übergänge entsprechend eines gegebenen regulären Ausdrucks erstellt; ein Ablaufprotokoll der Konstruktion eines solchen NEA für unser Beispiel finden Sie in Abbildung 5.54. Weitere Beispiele sind in Abbildung 5.52 und Abbildung 5.53 dargestellt beziehungsweise werden in den Übungen besprochen. Darüber hinaus empfehlen wir Ihnen, um Ihr Verständnis für diesen Prozess zu vertiefen, eigene Beispiele durchzuarbeiten. Um dieses Buch nicht zu überfrachten, haben wir Ihnen einige Themen (Behandlung von Metazeichen, Deskriptoren von Zeichenmengen, Kurzschreibweisen zur Hüllenbildung und mehrstelligen Oder-Verknüpfungen) zur Übung überlassen (Übungen 5.4.16 bis 5.4.21). Ansonsten erfordert die Konstruktion erstaunlich wenig Code und ist einer der genialsten Algorithmen, die wir gesehen haben. Hülle eines Zeichens i
i+1
A
*
G.addEdge(i, i+1); G.addEdge(i+1, i); Hüllenausdruck i
lp
. . .
(
i+1
)
*
G.addEdge(lp, i+1); G.addEdge(i+1, lp); Oder-Ausdruck or
lp
...
(
i
...
|
)
G.addEdge(lp, or+1); G.addEdge(or, i);
Abbildung 5.52: NEA-Konstruktionsregeln
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(
.
*
A
B
(
(
C
|
D
*
E
)
F
)
*
G
)
Abbildung 5.53: NEA für das Muster (.*AB((C|D*E)F)*G)
844
5.4 Reguläre Ausdrücke
Listing 5.25: Musterbasierte Suche mit regulären Ausdrücken (Algorithmus 5.9) public class NFA { private char[] re; private Digraph G; private int M;
// Übereinstimmungsübergänge // Epsilon-Übergänge // Anzahl der Zustände
public NFA(String regexp) { // Erzeugt den NEA für den gegebenen regulären Ausdruck. Stack ops = new Stack(); re = regexp.toCharArray(); M = re.length; G = new Digraph(M+1); for (int i = 0; i < M; i++) { int lp = i; if (re[i] == '(' || re[i] == '|') ops.push(i); else if (re[i] == ')') { int or = ops.pop(); if (re[or] == '|') { lp = ops.pop(); G.addEdge(lp, or+1); G.addEdge(or, i); } else lp = or; } if (i < M-1 && re[i+1] == '*') // Vorausschau { G.addEdge(lp, i+1); G.addEdge(i+1, lp); } if (re[i] == '(' || re[i] == '*' || re[i] == ')') G.addEdge(i, i+1); } } public boolean recognizes(String txt) // Erkennt der NEA txt? (Siehe Listing 5.24.) }
Dieser Konstruktor erstellt einen NEA für einen gegebenen regulären Ausdruck, indem er einen Digraphen von ∈-Übergängen erzeugt.
845
5
Strings
Stapel für die Indizes der linken Klammern und der Oder-Operationen (ops)
0
(
0
0
(
1 0
0
(
1 0
0
(
1 0
0
(
5 1 0
0
(
5 1 0
0
(
5 1 0
0
(
0
0
(
0
(
i 0
(
1 0
1
0
(
0
(
1
(
1
(
1
(
1
(
1
(
1
(
1
(
1
(
1
(
1
(
2
A
2
A
2
A
2
A
2
A
2
A
2
A
2
A
2
A
2
A
3
*
3
*
3
*
3
*
3
*
3
*
3
*
3
*
3
*
4
B
4
B
4
B
4
B
4
B
4
B
4
B
4
B
5
|
5
|
5
|
5
|
5
|
5
|
5
|
Abbildung 5.54: Erstellt den NEA für ( ( A * B | A C ) D )
846
6
A
6
A
6
A
6
A
6
A
6
A
7
C
7
C
7
C
7
C
7
C
8
)
8
)
8
)
8
)
9
D
9
D
9
D
10
)
10
)
11
5.4 Reguläre Ausdrücke
Satz R: Die Erstellung eines NEA für einen regulären Ausdruck mit M Zeichen benötigt im schlimmsten Fall Zeit und Speicher proportional M. Beweis: Für jedes der M Zeichen in dem regulären Ausdruck fügen wir höchstens drei ∈-Übergänge hinzu und führen vielleicht eine oder zwei Stapeloperationen aus.
Der klassische GREP-Client (Generalized Regular Expression Pattern-matching) für die musterbasierte Suche übernimmt wie der Code von Listing 5.26 einen regulären Ausdruck als Argument und gibt die Zeilen der Standardeingabe aus, die einen Teilstring aufweisen, der in der vom regulären Ausdruck beschriebenen Sprache enthalten ist. Dieser Client war Bestandteil früherer Unix-Implementierungen und für mehrere Programmierergenerationen ein unverzichtbares Werkzeug. public class GREP { public static void main(String[] args) { String regexp = "(.*" + args[0] + ".*)"; NFA nfa = new NFA(regexp); while (StdIn.hasNextLine()) { String txt = StdIn.readLine(); if (nfa.recognizes(txt)) StdOut.println(txt); } } } Listing 5.26: Klassischer GREP-Client für die musterbasierte Suche mit einem NEA
% more tinyL.txt AC AD AAA ABD ADD BCD ABCCBD BABAAA BABBAAA % java GREP "(A*B|AC)D" < tinyL.txt ABD ABCCBD % java GREP StdIn < GREP.java while (StdIn.hasNextLine()) String txt = StdIn.readLine();
847
5
Strings
5.4
Fragen und Antworten
Frage: Was ist der Unterschied zwischen null und ∈. Antwort: Ersteres bezeichnet eine leere Menge, Letzteres einen leeren String. Sie können eine Menge mit ∈ als einziges Element haben, die deshalb nicht null ist.
5.4 1.
Allgemeine Übungen Geben Sie reguläre Ausdrücke an, die alle Strings beschreiben, die Folgendes enthalten: – Genau vier aufeinanderfolgende As – Nicht mehr als vier aufeinanderfolgende As – Mindestens ein Vorkommen von vier aufeinanderfolgenden As
2.
Beschreiben Sie in kurzen Worten jeden der folgenden regulären Ausdrücke: a. .* b. A.*A|A c. .*ABBABBA.* d. .*A.*A.*A.*A.*
3.
Wie viele verschiedene Strings können maximal durch einen regulären Ausdruck mit M Oder-Operatoren und ohne Sternoperatoren beschrieben werden? (Klammern und Verkettung sind erlaubt.)
4.
Skizzieren Sie den NEA für das Muster (((A|B)*|CD*|EFG)*)*.
5.
Zeichnen Sie den Digraphen der ∈-Übergänge für den NEA aus Übung 5.4.4.
6.
Geben Sie die Mengen der Zustände an, die von Ihrem NEA aus Übung 5.4.4 nach jedem Zeichenabgleich und den folgenden ∈-Übergängen für die Eingabe ABBACEFGEFGCAAB erreichbar sind.
7.
Überarbeiten Sie den GREP-Client aus Listing 5.26 zu einem GREPmatch-Client, bei dem vor und nach dem Muster in Klammern jedoch kein .* steht, sodass der Client nur die String-Zeilen ausgibt, die in der vom regulären Ausdruck beschriebenen Sprache enthalten sind. Was erhalten Sie bei Eingabe der folgenden Befehle? a. % java GREPmatch "(A|B)(C|D)" < tinyL.txt b. % java GREPmatch "A(B|C)*D" < tinyL.txt c. % java GREPmatch "(A*B|AC)D" < tinyL.txt
848
Allgemeine Übungen
8.
Schreiben Sie jeweils einen regulären Ausdruck für die folgenden Mengen von binären Strings: a. für eine Menge mit mindestens drei aufeinander folgenden 1en b. für eine Menge mit dem Teilstring 110 c. für eine Menge mit dem Teilstring 1101100 d. für eine Menge ohne den Teilstring 110
9.
Schreiben Sie einen regulären Ausdruck für binäre Strings, mit mindestens zwei 0en, die jedoch nicht aufeinander folgen.
10.
Schreiben Sie jeweils einen regulären Ausdruck für die folgenden Mengen von binären Strings: a. für eine Menge mit mindestens drei Zeichen, deren drittes Zeichen eine 0 ist b. für eine Menge, deren Anzahl von 0en ein Mehrfaches von 3 ist c. für eine Menge, die mit dem gleichen Zeichen beginnt und endet d. für eine Menge, die eine ungerade Länge aufweist e. für eine Menge, die mit einer 0 beginnt und ungerader Länge ist oder mit einer 1 beginnt und gerader Länge ist f. für eine Menge, deren Länge mindestens 1 und höchstens 3 ist
11.
Geben Sie für jeden der folgenden regulären Ausdrücke an, wie viele Bitstrings von genau der Länge 1000 übereinstimmen mit: a. 0(0 | 1)*1 b. 0*101* c. (1 | 01)*
12.
Schreiben Sie einen regulären Ausdruck in Java für a. Telefonnummern wie (609) 555-1234 b. Sozialversicherungsnummern wie 123-45-6789 c. Datumsangaben wie Dezember 31, 1999 d. IP-Adressen der Form a.b.c.d, wobei jeder Buchstabe eine, zwei oder drei Ziffern repräsentiert, wie 196.26.155.241 e. Autokennzeichen, die mit vier Ziffern beginnen und mit zwei Großbuchstaben enden
849
5
Strings
5.4 13.
Knifflige Aufgaben Kompliziertere reguläre Ausdrücke: Konstruieren Sie jeweils einen regulären Ausdruck, der die folgenden Mengen von Strings über das binäre Alphabet beschreibt: a. alle Strings außer 11 oder 111 b. Strings mit einer 1 in jeder ungeraden Bitposition c. Strings mit mindestens zwei 0en und höchstens einer 1 d. Strings ohne zwei aufeinander folgende 1en
14.
Teilbarkeit von Binärzahlen: Konstruieren Sie einen regulären Ausdruck, der alle binären Strings beschreibt, die – interpretiert als Binärzahl – teilbar sind a. durch 2 b. durch 3 c. durch 123
15.
Unverschachtelte reguläre Ausdrücke: Konstruieren Sie einen regulären Ausdruck in Java, der die Menge von Strings beschreibt, die gültige reguläre Ausdrücke über dem binären Alphabet sind, aber keine verschachtelten Klammern enthalten. So ist beispielsweise (0.*1)* in der Sprache enthalten, aber (1(0 or 1)1)* nicht.
16.
Mehrstellige Oder-Verknüpfung: Ergänzen Sie den NEA um ein Mehrfach-Oder. Ihr Code sollte den Automaten aus Abbildung 5.55 für das Muster (.*AB ((C|D|E)F)*G) erzeugen.
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(
.
*
A
B
(
(
C
|
D
|
E
)
F
)
*
G
)
Abbildung 5.55: NEA für das Muster (.*AB((C|D|E)F)*G)
850
17.
Platzhalter: Erweitern Sie den NEA um die Fähigkeit, Platzhalter zu verarbeiten.
18.
Eines oder mehrere: Erweitern Sie den NEA um die Fähigkeit, den Hüllenoperator + zu verarbeiten.
19.
Spezifizierte Menge: Erweitern Sie den NEA um die Fähigkeit, Deskriptoren für spezifizierte Mengen zu verarbeiten.
20.
Bereich: Erweitern Sie den NEA um die Fähigkeit, Bereichsdeskriptoren zu verarbeiten.
21.
Komplement: Erweitern Sie den NEA um die Fähigkeit, Komplementdeskriptoren zu verarbeiten.
22.
Beweis: Entwickeln Sie eine NEA-Version, die einen Beweis ausgibt, dass ein gegebener String in der Sprache enthalten ist, die vom NEA erkannt wird (eine Folge von Zustandsübergängen, die im akzeptierenden Zustand endet).
5.5 Datenkomprimierung
5.5
Datenkomprimierung
Angesichts der täglichen Datenflut spielen Algorithmen zur effizienten Datenrepräsentation eine zunehmend wichtige Rolle in der modernen Webinfrastruktur. Es gibt zwei wesentliche Gründe, Daten zu komprimieren: um Platz zu sparen (wenn wir Informationen speichern) beziehungsweise um Zeit zu sparen (wenn wir Informationen übertragen). Beide Gründe haben seit Generationen nichts von ihrer Aktualität verloren und sind für jeden von Interesse, der ein neues Speichergerät benötigt oder auf einen langen Download wartet. Sicherlich hatten Sie schon einmal mit komprimierten Daten zu tun, zum Beispiel bei der Arbeit mit digitalen Bildern, Sound, Filmen oder allen möglichen anderen Daten. Die Algorithmen, die wir hierzu untersuchen, sparen Speicherplatz, indem sie ausnutzen, dass die meisten Datendateien einen hohen Grad an Redundanz aufweisen: So gibt es beispielsweise in Textdateien bestimmte Zeichenfolgen, die häufiger auftreten als andere; Bitmap-Dateien zur Codierung von Bildern enthalten große homogene Bereiche; und Daten für die digitale Repräsentation von Bildern, Filmen, Sound und anderen analogen Signalen enthalten sich häufig wiederholende Muster. Wir werden hierzu einen elementaren Algorithmus sowie zwei weit verbreitete fortgeschrittene Verfahren betrachten. Die Komprimierungen, die wir mit diesen Verfahren erreichen, variieren in Abhängigkeit von der Art der Eingabe. Für Texte sind Einsparungen von 20 bis 50 Prozent die Regel, wenn auch in manchen Fällen Einsparungen von 50 bis 90 Prozent erzielt werden können. Wie Sie sehen werden, hängt die Effektivität jedes Verfahrens zur Datenkomprimierung von den Eigenschaften der Eingabe ab. Hinweis: Wenn wir in diesem Buch von Performance oder Leistung sprechen, meinen wir normalerweise den Zeitaufwand, und unter Datenkomprimierung verstehen wir in der Regel die Komprimierung, die sich mit diesen Verfahren erreichen lässt; dabei achten wir darauf, die Zeit, die dafür erforderlich ist, nicht aus den Augen zu verlieren. Man könnte vermuten, dass die Techniken der Datenkomprimierung an Bedeutung verloren haben, weil die Preise für Speichermedien dramatisch gesunken sind und dem typischen Anwender heutzutage weitaus mehr Speicherplatz zur Verfügung steht als in der Vergangenheit. Man kann jedoch auch sagen, dass die Techniken der Datenkomprimierung wichtiger sind denn je, denn aufgrund der erhöhten Speicherplatznutzung ist auch das Einsparpotenzial größer. Vor allem das Aufkommen des Internets hat der Datenkomprimierung Auftrieb gegeben, da sie eine preisgünstige Möglichkeit darstellt, die Zeit für die Übertragung großer Datenmengen zu reduzieren. Datenkomprimierung ist ein wichtiges Thema, das Programmierer seit vielen Jahren beschäftigt und auch in Zukunft einer Betrachtung wert sein wird (im Rahmen dieses Buches können wir leider nur eine kurze Einführung in das Thema bieten). Jeder, der sich mit Algorithmendesign beschäftigt, profitiert von der Analyse der Datenkomprimierung, da die Algorithmen klassisch, elegant, interessant und effektiv sind.
851
5
Strings
5.5.1 Spielregeln Alle Datentypen, die wir mit modernen Computersystemen verarbeiten, haben eines gemeinsam: Sie werden am Ende binär repräsentiert. Wir können jeden Datentyp einfach als eine Folge von Bits (oder Bytes) betrachten. Der Kürze halber verwenden wir in diesem Abschnitt den Begriff Bitstrom für eine Folge von Bits und Bytestrom, wenn wir die Bits als eine Folge von Bytes fester Länge betrachten. Ein Bit- oder Bytestrom kann auf Ihrem Computer als Datei gespeichert sein oder als Nachricht im Internet übertragen werden.
Grundlegendes Modell Folglich ist unser grundlegendes Modell für die Datenkomprimierung sehr einfach und besteht im Wesentlichen aus zwei Komponenten, die jeweils in Form einer Blackbox Bitströme lesen beziehungsweise schreiben:
Eine Blackbox zum Komprimieren, die einen Bitstrom B in eine komprimierte Version C(B) überführt.
Eine Blackbox zum Dekomprimieren, die C(B) wieder in B zurückverwandelt. Mit der Notation |B| drücken wir die Anzahl der Bits in einem Bitstrom aus und mit |C(B)| / |B| die Datenkomprimierungsrate – einen Wert, den wir möglichst niedrig halten wollen. Komprimieren Bitstrom B 0110110101...
Dekomprimieren komprimierte Version C(B) 1101011111...
ursprünglicher Bitstrom B 0110110101...
Abbildung 5.56: Grundlegendes Modell der Datenkomprimierung
Dieses Modell beschreibt die verlustlose Komprimierung – das heißt, es gehen keine Informationen verloren, und wir erhalten nach dem Komprimieren und Dekomprimieren einen Bitstrom, der Bit für Bit mit dem ursprünglichen Bitstrom übereinstimmt. Eine verlustlose Komprimierung ist für viele Arten von Dateien, wie numerische Daten oder ausführbaren Code, unabdingbar. Für andere Dateitypen (zum Beispiel Bilder, Videos oder Musik) können Komprimierungsverfahren in Betracht gezogen werden, die Informationen verlieren, d.h., der Decoder erzeugt nur eine Näherung an die Originaldatei. Verlustbehaftete Verfahren müssen also nicht nur hinsichtlich der Komprimierungsrate, sondern auch hinsichtlich eines subjektiven Qualitätsstandards evaluiert werden. Wir werden in diesem Buch auf die Behandlung verlustbehafteter Komprimierung nicht näher eingehen.
852
5.5 Datenkomprimierung
5.5.2 Binärdaten lesen und schreiben Eine genaue Beschreibung, wie Daten auf Ihrem Computer codiert werden, ist im Rahmen dieses Buches nicht möglich, weil die Codierung systemabhängig ist; aber mit ein paar grundlegenden Annahmen und zwei einfachen APIs namens BinaryStdIn und BinaryStdOut können wir unsere Implementierungen von diesen Details trennen. Diese APIs sind den beiden APIs StdIn und StdOut nachempfunden, mit denen Sie bereits gearbeitet haben, dienen jedoch dazu, Bits zu lesen und zu schreiben und nicht wie StdIn und StdOut Zeichenströme, die in Unicode codiert sind. Ein int-Wert auf StdOut ist also eine Folge von Zeichen (seine dezimale Repräsentation), während ein int-Wert auf BinaryStdOut eine Folge von Bits ist (seine binäre Repräsentation).
Binäre Ein- und Ausgabe Die Ein-/Ausgabe der meisten heutigen Systeme, einschließlich Java, basiert auf 8-BitByteströmen, sodass wir auch dafür optieren könnten, Byteströme zu lesen und zu schreiben, um unsere E/A-Formate an die internen Repräsentationen von primitiven Typen anzupassen, wobei ein 8-Bit-char mit 1 Byte, ein 16-Bit-short mit 2 Bytes, ein 32-Bit-int mit 4 Bytes usw. codiert wird. Da Bitströme die primäre Abstraktion für die Datenkomprimierung sind, gehen wir noch einen Schritt weiter und erlauben Clients, neben einzelnen Bits auch Daten primitiver Typen zu lesen und zu schreiben. Ziel ist es, Client-Programme von Typumwandlungen weitgehend zu befreien und gleichzeitig die Betriebssystemkonventionen zur Repräsentation von Daten zu berücksichtigen. Wir verwenden die folgende API, um einen Bitstrom aus der Standardeingabe zu lesen.
Tabelle 5.16 API für statische Methoden, die aus einem Bitstrom der Standardeingabe lesen public class BinaryStdIn readBoolean()
Liest 1 Datenbit und liefert einen boolean-Wert zurück.
char
readChar()
Liest 8 Datenbits und liefert einen char-Wert zurück.
char
readChar(int r)
Liest r Datenbits (zwischen 1 und 16) und liefert einen charWert zurück.
boolean
[ähnliche Methoden für byte (8 Bit), short (16 Bit), int (32 Bit), long und double (64 Bit)] boolean void
isEmpty()
Ist der Bitstrom leer?
close()
Schließt den Bitstrom.
Eine Besonderheit dieser Abstraktion ist, dass die Daten in der Standardeingabe im Gegensatz zu StdIn nicht notwendigerweise an Bytegrenzen ausgerichtet sind. Wenn der Eingabestrom ein einziges Byte ist, könnte ein Client die Eingabe bitweise mit acht Aufrufen von readBoolean() lesen. Die Methode close() ist nicht unbedingt erforder-
853
5
Strings
lich, sorgt aber für eine saubere Beendigung und sollte von den Clients aufgerufen werden, um anzuzeigen, dass keine Bits mehr einzulesen sind. Wie bei StdIn/StdOut gibt es auch hier eine komplementäre API, um Bitströme in die Standardausgabe zu schreiben.
Tabelle 5.17 API für statische Methoden, die einen Bitstrom in die Standardausgabe schreiben public class BinaryStdOut void
write(boolean b)
Schreibt das angegebene Bit.
void
write(char c)
Schreibt das angegebene 8-Bit-char.
void
write(char c, int r)
Schreibt die r niedrigstwertigen Bits (zwischen 1 und 16) des angegebenen char.
[ähnliche Methoden für byte (8 Bit), short (16 Bit), int (32 Bit), long und double (64 Bit)] void
close()
Schließt den Bitstrom.
Im Gegensatz zur Eingabe ist für die Ausgabe die Methode close() wichtig: Clients müssen close() aufrufen, um sicherzustellen, dass alle Bits vorheriger write()-Aufrufe den Bitstrom erreichen und das letzte Byte mit Nullen aufgefüllt wird. Letzterer Schritt dient dazu, die Ausgabe aus Kompatibilitätsgründen mit dem Dateisystem an den Bytegrenzen auszurichten. Analog den APIs In und Out zu StdIn und StdOut verfügen wir auch über BinaryIn und BinaryOut, mit denen wir binärcodierte Dateien direkt referenzieren können.
Beispiel Angenommen, Sie haben einen Datentyp für ein Datum, das durch drei int-Werte repräsentiert wird (Monat, Tag, Jahr). Um diesen Wert mit StdOut im Format 12/31/ 1999 zu schreiben, werden 10 Zeichen bzw. 80 Bits benötigt. Wenn Sie die Werte direkt mit BinaryStdOut schreiben, würden Sie 96 Bits erzeugen (32 Bits für jeden der 3 int-Werte); bei einer günstigeren Repräsentation, die byte-Werte für den Monat und den Tag und einen short-Wert für das Jahr verwendet, kommen Sie mit 32 Bits aus. Mit BinaryStdOut könnten Sie sogar ein 4-Bit-Feld, ein 5-Bit-Feld und ein 12-Bit-Feld schreiben, was insgesamt 21 Bits ergibt (24 Bits, um genau zu sein, da die Dateien eine ganze Zahl von 8-Bit-Bytes sein müssen, sodass close() drei Nullen am Ende anhängt). Wichtiger Hinweis: Diese Biteinsparung ist auch eine Art von Datenkomprimierung.
854
5.5 Datenkomprimierung
ein Zeichenstrom (StdOut)
StdOut.print(month + "/" + day + "/" + year);
1
2
/
3
1
drei int-Werte (BinaryStdOut)
/
1
9
9
9
80 Bits
8-Bit-ASCII-Repräsentation von '9'
BinaryStdOut.write(month); BinaryStdOut.write(day); BinaryStdOut.write(year);
32-Bit-Integer-Repräsentation von 31
12
31
zwei char-Werte und ein short-Wert (BinaryStdOut)
1999
BinaryStdOut.write((char) month); BinaryStdOut.write((char) day); BinaryStdOut.write((short) year);
BinaryStdOut.write(month, 4); BinaryStdOut.write(day, 5); BinaryStdOut.write(year, 12);
0 0 0 0 1 1 0 0 0 0 0 1 1 1 1 1 0 0 0 0011111001111
1100111110111110011110 0 0
12
31
1999
32 Bits
96 Bits
ein 4-Bit-Feld, ein 5-Bit-Feld und ein 12-Bit-Feld (BinaryStdOut)
12
31
1999
21 Bits ( + 3 Bits für die Byteausrichtung beim Schließen)
Abbildung 5.57: Vier Wege, ein Datum in die Standardausgabe zu stellen
Binäre Speicherauszüge Wie können wir den Inhalt eines Bitstroms oder eines Bytestroms beim Debuggen untersuchen? Vor dieser Frage standen bereits die ersten Programmierer, als der einzige Weg, Fehler zu finden, darin bestand, jedes Bit im Speicher zu untersuchen; seit den Anfängen der Computertechnik verbirgt sich hinter dem Begriff Speicherauszug (dump) die lesbare Darstellung eines Bitstroms. Wenn Sie versuchen, eine solche Datei mit einem Editor zu öffnen, oder sie wie eine Textdatei betrachten (oder einfach ein Programm ausführen, das BinaryStdOut verwendet), erhalten Sie üblicherweise Zahlensalat (die genaue Art der Darstellung hängt vom Betriebssystem ab). Dank BinaryStdIn können wir solche Systemabhängigkeiten vermeiden, indem wir eigene Programme zur Umwandlung von Bitströmen schreiben, um diese dann mit unseren Standardwerkzeugen betrachten zu können. Das Programm BinaryDump aus Listing 5.27 ist beispielsweise ein BinaryStdInClient, der die Bits der Standardeingabe als 0 und 1 codiert ausgibt. Dieses Programm eignet sich gut für das Debuggen von kleinen Eingaben. Der analoge Client HexDump gruppiert die Daten in Bytes zu 8-Bits und gibt jedes Byte als zwei hexadezimale Ziffern aus, die jeweils 4 Bits repräsentieren. Der Client PictureDump zeigt die Bits als Bild (Picture), in dem die 0-Bits als weiße Pixel und die 1-Bits als schwarze Pixel repräsentiert werden. Diese bildliche Darstellung eignet sich sehr gut, um Muster in einem Bitstrom zu erkennen. Sie können BinaryDump, HexDump und PictureDump von der Website zum Buch herunterladen. In der Regel verwenden wir Pipelining und Umleitung auf Befehlszeilenebene, wenn wir mit Binärdateien arbeiten: Wir können die Ausgabe eines Codierers an BinaryDump, HexDump oder PictureDump weiterleiten oder in eine Datei umleiten.
855
5
Strings
public class BinaryDump { public static void main(String[] args) { int width = Integer.parseInt(args[0]); int cnt; for (cnt = 0; !BinaryStdIn.isEmpty(); cnt++) { if (width == 0) continue; if (cnt != 0 && cnt % width == 0) StdOut.println(); if (BinaryStdIn.readBoolean()) StdOut.print("1"); else StdOut.print("0"); } StdOut.println(); StdOut.println(cnt + " bits"); } } Listing 5.27: Einen Bitstrom auf der Standard-(Zeichen-)Ausgabe ausgeben Standard-Zeichenstrom % more abra.txt ABRACADABRA! Bitstrom, repräsentiert mit den Zeichen 0 und 1 % java BinaryDump 16 < abra.txt 0100000101000010 0101001001000001 0100001101000001 0100010001000001 0100001001010010 0100000100100001 96 bits
Bitströme, repräsentiert durch Hex-Ziffern % 41 43 42 96
java HexDump 4 < abra.txt 42 52 41 41 44 41 52 41 21 bits
Bitstrom, repräsentiert als Pixel in einem Picture % java PictureDump 16 6 < abra.txt
16x6-Pixel-Fenster, vergrößert 96 bits
Abbildung 5.58: Vier Wege, einen Bitstrom zu betrachten
ASCII-Codierung Wenn Sie einen Bitstrom von ASCII-codierten Zeichen als HexDump ausgeben, ist Tabelle 5.18 sicher eine hilfreiche Referenz. Bei jeder aus zwei Ziffern bestehenden Hexadezimalzahl dient die erste Ziffer als Zeilenindex und die zweite als Spaltenindex, um die Zahl auszudrücken, die sich dahinter verbirgt. So codiert 31 zum Beispiel die Ziffer 1 und 4A den Buchstaben J usw. Die Tabelle beschreibt 7-Bit-ASCII, sodass die erste Zahl immer eine 7 oder kleiner sein muss. Hexadezimalzahlen, die mit einer 0 oder 1 beginnen (sowie die Zahlen 20 und 7F) entsprechen nicht druckbaren Steuerzeichen. Viele dieser Steuerzeichen stammen noch aus der Zeit, als physikalische Geräte wie Schreibmaschinen von ASCII-Eingaben gesteuert wurden. In der Tabelle sind einige dieser Zeichen hervorgehoben, da sie Ihnen in den Speicherauszügen begegnen werden;
856
5.5 Datenkomprimierung
zum Beispiel steht SP für das Leerzeichen (space), NUL für das Nullzeichen, LF für den Zeilenvorschub (line feed) und CR für den Wagenrücklauf (carriage return).
Tabelle 5.18 Umwandlungstabelle hexadezimal zu ASCII 0 0 NUL
1
2
3
4
5
6
7
8
SOH STX ETX EOT ENQ ACK BEL BS
9
A
HT LF
B
C
D
E
F
VT
FF CR SO SI
1 DLE DC1 DC2 DC3 DC4 NAK SYN ETB CAN EM SUB ESC FS GS RS US 2 SP
!
"
#
$
%
&
'
(
)
*
+
,
-
.
/
3 0
1
2
3
4
5
6
7
8
9
:
;
?
4 @
A
B
C
D
E
F
G
H
I
J
K
L
M
N
O
5 P
Q
R
S
T
U
V
W
X
Y
Z
[
\
]
^
_
6 `
a
b
c
d
e
f
g
h
i
j
k
l
m
n
o
7 p
q
r
s
t
u
v
w
x
y
z
{
|
}
~
DEL
Zusammengefasst lässt sich feststellen, dass wir für die Arbeit mit Datenkomprimierung unsere Vorstellung von Standardeingabe und Standardausgabe um die Binärcodierung von Daten erweitern müssen. Die dafür benötigten Methoden finden wir in BinaryStdIn und BinaryStdOut. Damit können wir in den Client-Programmen eindeutig trennen, ob die Daten in Dateien gespeichert bzw. übertragen werden (und somit von Programmen gelesen werden) oder ob sie ausgegeben werden (und wahrscheinlich von Menschen gelesen werden).
U
U
U
5.5.3 Beschränkungen Um die Leistung der Algorithmen zur Datenkomprimierung richtig würdigen zu können, muss man die grundlegenden Beschränkungen kennen, denen diese Algorithmen unterworfen sind. Wissenschaftler haben zu diesem Zweck ein umfassendes theoretisches Fundament entwickelt, das wir kurz am Ende dieses Abschnitts betrachten werden. Doch zuvor wollen wir einige Punkte klären.
. . .
U
Universelle Datenkomprimierung Angesichts der algorithmischen Werkzeuge, die uns zur Verfügung stehen und in vielen Situationen bereits gute Dienste geleistet haben, könnten Sie dem Glauben erliegen, dass unser Ziel die universelle Datenkomprimierung ist, d.h. ein Algorithmus, der jeden Bitstrom verkleinern kann. Doch genau das Gegenteil ist der Fall. Wir müssen uns geringere Ziele stecken, da eine universelle Datenkomprimierung nicht möglich ist.
U
U
Abbildung 5.59: Universelle Datenkomprimierung?
857
5
Strings
Satz S: Kein Algorithmus kann jeden Bitstrom komprimieren. Beweis: Wir betrachten zwei Beweise, die dies untermauern sollen. Der erste ist ein Widerspruchsbeweis: Angenommen, Sie haben einen Algorithmus, der jeden Bitstrom komprimiert. Dann könnten Sie diesen Algorithmus auch dazu verwenden, um die Ausgabe, die Sie erhalten, noch weiter zu komprimieren, bis Sie am Ende einen Bitstrom der Länge 0 erhalten. Die Schlussfolgerung, dass Ihr Algorithmus jeden Bitstrom auf 0 Bit reduziert, ist absurd, und damit auch die Annahme, dass er jeden Bitstrom komprimieren kann. Der zweite Beweis ist ein auf Zahlen basierendes Argument. Angenommen, Sie haben einen Algorithmus, der eine verlustlose Komprimierung für jeden 1000-Bit-Bitstrom verspricht. Das bedeutet, dass sich jeder dieser Ströme auf einen anderen, kürzeren Strom abbilden lassen muss. Da es aber nur 1 + 2 + 4 + … + 2998 + 2999 = 21000 − 1 Bitströme mit weniger als 1000 Bits und 21000 Bitströme mit 1000 Bits gibt, kann Ihr Algorithmus nicht alle komprimieren. Dieses Argument ist noch überzeugender, wenn wir unsere Ziele noch höher stecken. Angenommen, Ihr Ziel ist es, eine Komprimierungsrate von mehr als 50 Prozent zu erreichen. Dann sollten Sie wissen, dass Sie nur bei einem von 2500 der 1000-Bit-Bitströme erfolgreich sein werden!
Anders ausgedrückt, Sie haben nur eine Chance von 1 zu 2500, einen zufälligen Bitstrom von 1000 Bits mit einem beliebigen Datenkomprimierungsalgorithmus um die Hälfte zu komprimieren. Wenn Sie also auf einen neuen Algorithmus mit verlustfreier Komprimierung stoßen, so können Sie davon ausgehen, dass seine Komprimierungsleistung bei einem beliebigen Bitstrom mit Sicherheit nicht besonders groß sein wird. Die Erkenntnis, dass wir Zufallsströme nicht komprimieren können, ist der erste Schritt zu einem besseren Verständnis der Datenkomprimierung. Auf der anderen Seite ist festzustellen, dass wir, obwohl wir praktisch täglich Strings mit Millionen oder Milliarden von Bits verarbeiten, trotzdem niemals auch nur einen Bruchteil aller potenziell möglichen Strings verarbeiten werden, sodass uns die obige Erkenntnis nicht entmutigen sollte. Vielmehr sind die Bitstrings, die wir regelmäßig verarbeiten, extrem strukturiert, was wir uns für die Komprimierung zunutze machen können. % java RandomBits | java PictureDump 2000 500
1000000 Bits
Abbildung 5.60: Eine schwer zu komprimierende Datei: 1 Million (Pseudo-)Zufallsbits
858
5.5 Datenkomprimierung
Unentscheidbarkeit Betrachten Sie beispielsweise den 1 Million Bit großen String in Abbildung 5.60. Hierbei scheint es sich um einen zufälligen String zu handeln, sodass Sie wahrscheinlich keinen verlustfreien Algorithmus zur Komprimierung finden. Dennoch gibt es eine Möglichkeit, diesen String mit nur einigen wenigen tausend Bits zu repräsentieren, da er mit dem Programm in Listing 5.28 erzeugt wurde. (Dieses Programm ist ein Beispiel für einen Generator von Pseudozufallszahlen, wie die Java-Methode Math.random().) Ein Komprimierungsalgorithmus, der komprimiert, indem er das Programm in ASCII schreibt, und dekomprimiert, indem er das Programm liest und dann ausführt, erreicht eine Komprimierungsrate von 0,3 Prozent, was nur schwer zu schlagen ist (und wir können die Rate noch beliebig verkleinern, indem wir mehr Bits schreiben). Eine solche Datei zu komprimieren, bedeutet, das Programm zu finden, von dem sie erzeugt wurde. Dieses Beispiel ist nicht so weit hergeholt, wie es auf den ersten Blick scheint: Wenn Sie ein Video oder ein mit einem Scanner digitalisiertes altes Buch oder einen von zahllosen anderen Dateitypen aus dem Web komprimieren, verrät dies etwas über das Programm, das die Datei erzeugte. Die Tatsache, dass viele Daten, die wir verarbeiten, von einem Programm erzeugt werden, wirft wichtige Fragen der theoretischen Informatik auf und zeigt, vor welchen Herausforderungen die Datenkomprimierung steht. Es kann zum Beispiel bewiesen werden, dass optimale Datenkomprimierung (das kürzeste Programm zu finden, um einen gegebenen String zu erzeugen) ein unentscheidbares Problem ist. Wir müssen uns also nicht nur damit abfinden, dass es keinen Algorithmus gibt, der jeden Bitstrom komprimiert, sondern müssen auch akzeptieren, dass es keine Strategie gibt, den besten Algorithmus zu finden! public class RandomBits { public static void main(String[] args) { int x = 11111; for (int i = 0; i < 1000000; i++) { x = x * 314159 + 218281; BinaryStdOut.write(x > 0); } BinaryStdOut.close(); } } Listing 5.28: Ein „komprimierter“ Millionen-Bitstrom
Diese Beschränkungen bedeuten für die Praxis, dass sich Methoden der verlustlosen Komprimierung die bekannten Strukturen der zu komprimierenden Bitströme zunutze machen müssen. Die vier hier betrachteten Methoden berücksichtigen nacheinander die folgenden strukturellen Eigenschaften:
859
5
Strings
Kleine Alphabete Lange Folgen von gleichen Bits/Zeichen Häufig verwendete Zeichen Lange, wiederverwendete Bit-/Zeichenfolgen Wenn Sie wissen, dass ein gegebener Bitstrom eine oder mehrere dieser Eigenschaften aufweist, können Sie ihn mit einer der nachfolgend vorgestellten Methoden komprimieren. Wenn nicht, lohnt sich wahrscheinlich dennoch ein Versuch, da die zugrunde liegende Struktur Ihrer Daten eventuell nicht direkt ersichtlich ist und diese Methoden vielseitig anwendbar sind. Wie Sie sehen werden, gibt es zu jeder Methode Parameter und Variationen, die eventuell angepasst werden müssen, um die optimale Komprimierung eines bestimmten Bitstroms zu garantieren. Am besten versuchen Sie, selbst etwas über die Struktur Ihrer Daten herauszufinden und dieses Wissen für die Komprimierung dieser Daten auszunutzen – unter Umständen mit einer der vorgestellten Techniken.
5.5.4 Aufwärmübung: Genomik Als Vorbereitung auf kompliziertere Komprimierungsalgorithmen wollen wir ein elementares (aber sehr wichtiges) Komprimierungsproblem betrachten. Alle unsere Implementierungen folgen den gleichen Konventionen, die wir im Kontext des nächsten Beispiels vorstellen.
Genomdaten Als erstes Beispiel der Datenkomprimierung betrachten wir folgende Zeichenfolge: ATAGATGCATAGCGCATAGCTAGATGTGCTAGCAT
Gemäß der Standard-ASCII-Codierung (1 Byte bzw. 8 Bit pro Zeichen) ist dieser String ein Bitstrom der Länge 8 × 35 = 280. Strings wie diese spielen in der modernen Biologie eine sehr wichtige Rolle, da Biologen mit den Buchstaben A, C, T und G die Nukleotide in der DNA lebender Organismen kennzeichnen. Eine Sequenz von Nukleotiden bezeichnen wir als Genom. Wissenschaftler wissen, dass Kenntnisse der Eigenschaften von Genomen Grundvoraussetzung sind, um die Prozesse zu verstehen, die in lebenden Organismen ablaufen, einschließlich Leben, Tod und Krankheiten. Heutzutage sind die Genome vieler lebender Organismen bekannt und Wissenschaftler schreiben Programme, die die Struktur dieser Nukleotidsequenzen analysieren.
2-Bit-Codekomprimierung Eine einfache Eigenschaft von Genomen ist, dass sie nur aus vier verschiedenen Zeichen bestehen, sodass jedes Zeichen mit nur 2 Bits codiert werden kann (siehe die Methode compress() in Listing 5.29). Auch wenn wir wissen, dass der Eingabestrom zeichencodiert ist, lesen wir gemäß des Standardmodells der Datenkomprimierung (Bitstrom zu Bitstrom) die Eingabe mit BinaryStdIn. Die Anzahl der codierten Zeichen
860
5.5 Datenkomprimierung
schreiben wir zusätzlich in die komprimierte Datei, um eine ordentliche Decodierung sicherzustellen, falls das letzte Bit nicht am Ende eines Bytes liegt. Da jedes 8-Bit-Zeichen in einen 2-Bit-Code umgewandelt wird und nur 32 Bits für die Länge hinzugefügt werden, nähert sich dieses Programm mit zunehmender Zeichenzahl einer Komprimierungsrate von 25% an. public static void compress() { Alphabet DNA = new Alphabet("ACTG"); String s = BinaryStdIn.readString(); int N = s.length(); BinaryStdOut.write(N); for (int i = 0; i < N; i++) { // Schreibt Zwei-Bit-Code für Zeichen. int d = DNA.toIndex(s.charAt(i)); BinaryStdOut.write(d, DNA.lgR()); } BinaryStdOut.close(); } Listing 5.29: Methode zur Komprimierung von Genomdaten
2-Bit-Codedekomprimierung Die Methode expand() aus Listing 5.30 dekomprimiert einen Bitstrom, der von der Methode compress() komprimiert wurde. Wie bei der Komprimierung liest und schreibt diese Methode einen Bitstrom gemäß dem grundlegenden Datenkomprimierungsmodell. Der Bitstrom, den wir als Ausgabe erzeugen, entspricht der ursprünglichen Eingabe. public static void expand() { Alphabet DNA = new Alphabet("ACTG"); int w = DNA.lgR(); int N = BinaryStdIn.readInt(); for (int i = 0; i < N; i++) { // Liest 2 Bits; schreibt Zeichen. char c = BinaryStdIn.readChar(w); BinaryStdOut.write(DNA.toChar(c)); } BinaryStdOut.close(); } Listing 5.30: Extrahiermethode für Genomdaten
Dieser Ansatz lässt sich auch auf andere Alphabete fester Größe übertragen. Doch diese Verallgemeinerungen wollen wir Ihnen als eine etwas weniger anspruchsvolle Übung überlassen (Übung 5.5.25).
861
5
Strings
Diese Methoden folgen nicht ganz unserem Standardmodell der Datenkomprimierung, da der komprimierte Bitstrom nicht alle Daten enthält, die zu seiner Entschlüsselung benötigt werden. Beide Methoden haben sich darauf verständigt, dass das Alphabet aus den Buchstaben A, C, T und G besteht. Eine solche Konvention bietet sich in einer Anwendung der Genomik an, bei der der gleiche Code häufig wiederverwendet wird. In anderen Situationen könnte es erforderlich sein, das Alphabet in die codierte Nachricht einzuschließen (siehe Übung 5.5.25). Üblicherweise werden in der Datenkomprimierung diese Kosten beim Vergleichen von Methoden berücksichtigt. In den Anfängen der Genomik war das Entschlüsseln einer Genomsequenz langwierig und mühsam, sodass die Sequenzen relativ kurz waren und Wissenschaftler die Standard-ASCII-Codierung verwendeten, um diese Erbinformationen zu speichern und zu tauschen. Dank der stark verbesserten Vorgehensweise kennen wir inzwischen viele und sogar relativ lange Genome (das menschliche Genom beispielsweise hat einen Informationsgehalt von über 1010 Bits). Die 75% Einsparungen, die wir mit diesen einfachen Methoden erzielen, sind sehr hoch. Gibt es da noch Raum für weitere Komprimierung? Diese Frage ist insofern äußerst interessant, als sie eine wissenschaftliche ist: Die Fähigkeit zu komprimieren setzt das Vorhandensein strukturierter Daten voraus und eines der primären Ziele der modernen Genomik ist es, Strukturen in den Genomdaten zu entdecken. Die hier betrachteten Standardverfahren zur Datenkomprimierung sind für (2-Bit-codierte) Genomdaten genauso ungeeignet wie für Zufallsdaten. Wir packen compress() und expand() als statische Methoden in eine Klasse zusammen mit einer einfachen statischen main()-Methode (Listing 5.31). Um zu testen, inwieweit Sie die Spielregeln und die grundlegenden Tools zur Datenkomprimierung verstanden haben, vergewissern Sie sich, dass Sie die verschiedenen Befehle in Abbildung 5.61 (und ihre Auswirkungen) kennen, mit denen Sie Genome.compress() und Genome.expand() auf unsere Beispieldaten aufrufen. public class Genome { public static void compress() // Siehe Text. public static void expand() // Siehe Text. public static void main(String[] args) { if (args[0].equals("-")) compress(); if (args[0].equals("+")) expand(); } } Listing 5.31: Konvention zum Verpacken von Datenkomprimierungsmethoden
862
5.5 Datenkomprimierung
kleiner Testfall (264 Bits)
% more genomeTiny.txt ATAGATGCATAGCGCATAGCTAGATGTGCTAGC java BinaryDump 64 < genomeTiny.txt 0100000101010100010000010100011101000001010101000100011101000011 0100000101010100010000010100011101000011010001110100001101000001 0101010001000001010001110100001101010100010000010100011101000001 0101010001000111010101000100011101000011010101000100000101000111 01000011 264 bits % java Genome - < genomeTiny.txt kann Bitstrom nicht auf der Standardausgabe sehen ?? % java Genome - < genomeTiny.txt | java BinaryDump 64 0000000000000000000000000010000100100011001011010010001101110100 1000110110001100101110110110001101000000 104 bits % java Genome - < genomeTiny.txt | java HexDump 8 00 00 00 21 23 2d 23 74 8d 8c bb 63 40 104 bits % java Genome - < genomeTiny.txt > genomeTiny.2bit % java Genome + < genomeTiny.2bit ATAGATGCATAGCGCATAGCTAGATGTGCTAGC % java Genome - < genomeTiny.txt | java Genome + ATAGATGCATAGCGCATAGCTAGATGTGCTAGC
KomprimierenDekomprimierenZyklus erzeugt die ursprüngliche Eingabe
ein tatsächlicher Virus (50000 Bits)
% java PictureDump 512 100 < genomeVirus.txt
50000 bits % java Genome - < genomeVirus.txt | java PictureDump 512 25
12536 bits
Abbildung 5.61: Genomsequenzen mit 2-Bit-Codierung komprimieren und dekomprimieren
5.5.5 Lauflängencodierung Die einfachsten Formen von Redundanz in einem Bitstrom sind lange Folgen von sich wiederholenden Bits, die wir Läufe (runs) nennen. Das im Folgenden vorgestellte klassische Verfahren der sogenannten Lauflängencodierung (run-length encoding) macht sich
863
5
Strings
diese Redundanz bei der Komprimierung der Daten zunutze. Betrachten wir beispielsweise den folgenden 40-Bit-String: 0000000000000001111111000000011111111111
Dieser String besteht aus 15 0en, gefolgt von 7 1en, gefolgt von 7 0en, gefolgt von 11 1en, sodass wir den Bitstring mit den Zahlen 15, 7, 7 und 11 codieren können. Alle Bitstrings bestehen aus abwechselnden Läufen von 0en und 1en, und wir codieren einfach die Längen der Läufe. Wenn wir in unserem Beispiel 4 Bits verwenden, um die Zahlen zu codieren, und mit einem Lauf von 0en beginnen, dann erhalten wir den 16-Bit-String: 1111011101111011
(15 = 1111, gefolgt von 7 = 0111, gefolgt von 7 = 0111, gefolgt von 11 = 1011) bei einer Komprimierungsrate von 16/40 = 40 Prozent. Um diese Beschreibung in ein effektives Datenkomprimierungsverfahren umzusetzen, müssen wir die folgenden Fragen klären:
Wie viele Bits benötigen wir, um die Längen der Wiederholungssequenzen zu speichern?
Was machen wir, wenn wir einen Lauf haben, der länger ist als die maximale Länge, die wir codieren können?
Was machen wir mit Läufen, die kürzer sind als die Anzahl der Bits, die benötigt werden, um ihre Länge zu speichern? Unser Interesse gilt vornehmlich langen Bitströmen mit relativ wenigen kurzen Läufen, sodass wir diese Probleme lösen, indem wir folgende Entscheidungen treffen:
Die Längen der Wiederholungssequenzen liegen zwischen 0 und 255 und werden jeweils mit 8 Bit codiert.
Wir machen alle Lauflängen kleiner als 256, indem wir bei Bedarf Läufe der Länge 0 zulassen.
Wir codieren kurze Läufe, auch wenn dies die Ausgabe verlängern sollte. Diese Entscheidungen sind sehr leicht zu implementieren und außerdem sehr effektiv für verschiedene Arten von Bitströmen, wie sie häufig in der Praxis anzutreffen sind. Sie sind nicht effektiv, wenn es viele kurze Läufe gibt – wir sparen bei einem Lauf nur dann Bits ein, wenn die Länge des Laufs größer ist als die Anzahl der Bits, die benötigt werden, um diese Länge als Binärzahl darzustellen.
Bitmaps Als Beispiel für die Effektivität der Lauflängencodierung betrachten wir Bitmaps, die häufig zur Darstellung von Bildern und eingescannten Dokumenten verwendet werden. Der Einfachheit und Kürze halber konzentrieren wir uns auf binäre Bitmaps aus Bitströmen, die aus zeilenweise betrachteten Pixeln bestehen. Den Inhalt einer Bitmap lassen wir uns mit PictureDump ausgeben. Ein Programm zu schreiben, das ein Bild aus einem der vielen verlustfreien Bildformate, die für Bildschirmfotos (sogenannte Screenshots) oder eingescannte Dokumente definiert wurden, in eine Bitmap umwandelt, ist ganz einfach Unser Beispiel, das die Effektivität der Lauflängencodierung demonstrieren soll, stammt aus Screenshots zu diesem Buch und zeigt den Buchstabe „q“ (in verschiedenen Auflösungen).
864
5.5 Datenkomprimierung
Abbildung 5.62 zeigt den binären Auszug eines 32-mal-48 Pixel großen Screenshot mit Angabe der Lauflängen zu jeder Zeile. Da jede Zeile mit einer 0 anfängt und endet, gibt es in jeder Zeile eine ungerade Anzahl von Läufen; und da das Ende einer Zeile gefolgt wird von dem Anfang der nächsten, ist die entsprechende Lauflänge im Bitstrom die Summe der letzten Lauflänge in jeder Zeile plus der ersten Lauflänge in der nächsten (mit weiteren Additionen für Zeilen, die nur aus 0 bestehen).
7 1en
Implementierung Die obige informelle Beschreibung führt direkt zu den Implementierungen von compress() und expand() in Listing 5.32. Wie immer ist die Implementierung von expand() die einfachere von den beiden: Sie liest eine Lauflänge ein, gibt entsprechend viele Kopien des aktuellen Bits aus, bildet das Komplement des aktuellen Bits 17 0en und fährt damit fort, bis die Eingabe erschöpft ist. Die Methode compress() ist Abbildung 5.62: Eine typische Bitmap mit Angabe der Lauflänge für jede Zeile nicht wesentlich schwieriger und besteht aus den folgenden Schritten, solange sich Bits im Eingabestrom befinden.
Lies ein Bit. Wenn dieses Bit sich vom vorherigen Bit unterscheidet, schreibe die aktuelle Länge der aufeinanderfolgenden Bits und setze den Wert für die Länge auf 0 zurück.
Wenn dieses Bit sich nicht vom vorherigen Bit unterscheidet und die Länge das Maximum erreicht hat, schreibe die Länge der aufeinanderfolgenden Bits, schreibe die Lauflänge 0 und setze den Wert für die Länge auf 0 zurück.
Inkrementiere die Länge. Wenn sich der Eingabestrom leert, wird durch Schreiben der Anzahl der Bits (Länge des letzten Laufs) der Prozess beendet.
Zunehmende Auflösung in Bitmaps Der Hauptgrund für die Popularität der Lauflängencodierung für Bitmaps ist, dass sich ihre Effektivität mit zunehmender Auflösung dramatisch verbessert. Es ist leicht zu erkennen, warum dies stimmt. Angenommen, wir verdoppeln die Auflösung für unser Beispiel. Dann gilt Folgendes:
865
5
Strings
Die Anzahl der Bits erhöht sich um den Faktor 4. Die Anzahl der Läufe erhöht sich ungefähr um den Faktor 2. Die Lauflängen erhöhen sich ungefähr um den Faktor 2. Die Anzahl der Bits in der komprimierten Version erhöht sich ungefähr um den Faktor 2.
Damit hat sich die Komprimierungsrate halbiert! public static void expand() { boolean b = false; while (!BinaryStdIn.isEmpty()) { char cnt = BinaryStdIn.readChar(); for (int i = 0; i < cnt; i++) BinaryStdOut.write(b); b = !b; } BinaryStdOut.close(); } public static void compress() { char cnt = 0; boolean b, old = false; while (!BinaryStdIn.isEmpty()) { b = BinaryStdIn.readBoolean(); if (b != old) { BinaryStdOut.write(cnt); cnt = 0; old = !old; } else { if (cnt == 255) { BinaryStdOut.write(cnt); cnt = 0; BinaryStdOut.write(cnt); } } cnt++; } BinaryStdOut.write(cnt); BinaryStdOut.close(); } Listing 5.32: Dekomprimierungs- und Komprimierungsmethoden für die Lauflängencodierung
Ohne die Lauflängencodierung würde sich der Speicherbedarf vervierfachen, wenn die Auflösung verdoppelt wird; mit Lauflängencodierung verdoppelt sich der Speicherbedarf für den komprimierten Bitstrom nur, wenn die Auflösung verdoppelt wird. Das heißt, der
866
5.5 Datenkomprimierung
Speicherbedarf wächst und die Komprimierungsrate fällt linear mit der Auflösung. Zum Beispiel weist unser Buchstabe „q“ (mit seiner niedrigen Auflösung) eine Komprimierungsrate von 74% auf. Wenn wir die Auflösung auf 64 mal 96 erhöhen, fällt diese Rate auf 37%. Diese Änderung wird in den PictureDump-Ausgaben von Abbildung 5.63 grafisch dargestellt. Der Buchstabe in der höheren Auflösung benötigt viermal so viel Speicher wie der Buchstabe geringerer Auflösung (doppelt in beide Richtungen), während die komprimierte Version nur doppelt so viel Speicher belegt (doppelt in nur eine Richtung). Wenn wir die Auflösung noch weiter auf 128 mal 192 erhöhen (was eher den Anforderungen der Druckausgabe entspricht), fällt die Rate auf 18% (siehe Übung 5.5.5). kleiner Testfall (40 Bits)
% java BinaryDump 40 < 4runs.bin 0000000000000001111111000000011111111111 40 bits % java RunLength - < 4runs.bin | java HexDump 0f 07 07 0b 32 bits Komprimierungsrate 32/40 = 80% % java RunLength - < 4runs.bin | java RunLength + | java BinaryDump 40 Komprimieren-Dekomprimieren 0000000000000001111111000000011111111111 erzeugt die ursprüngliche Eingabe 40 bits ASCII-Text (96 Bits)
% java RunLength - < abra.txt | java HexDump 24 01 01 05 01 01 01 04 01 02 01 01 01 02 01 02 01 05 01 01 01 04 02 01 01 05 01 01 01 03 01 03 01 05 01 01 01 04 01 02 01 01 01 02 01 02 01 05 01 02 01 04 01 416 bits Komprimierungsrate 416/96 = 433% – verwenden Sie keine Lauflängencodierung für ASCII! eine Bitmap (1536 Bits)
% java RunLength - < q32x48.bin > q32x48.bin.rle % java HexDump 16 < q32x48.bin.rle 4f 07 16 0f 0f 04 04 09 0d 04 09 06 0c 03 0c 05 0b 04 0c 05 0a 04 0d 05 09 04 0e 05 09 04 0e 05 08 04 0f 05 08 04 0f 05 07 05 0f 05 07 05 0f 05 07 05 0f 05 07 05 0f 05 07 05 0f 05 07 05 0f 05 07 05 0f 05 07 05 0f 05 07 06 0e 05 07 06 0e 05 08 06 0d 05 08 06 0d 05 09 06 0c 05 09 07 0b 05 0a 07 0a 05 0b 08 07 06 0c 14 0e 0b 02 05 11 05 05 05 1b 05 1b 05 1b 05 1b 05 1b 05 1b 05 1b 05 1b 05 1b 05 1b 05 1b 05 1a 07 16 0c 13 0e 41 Komprimierungsrate 1144/1536 = 74% 1144 bits
% java PictureDump 32 48 < q32x48.bin
1536 bits % java PictureDump 32 36 < q32x48.rle.bin
1144 bits
% java PictureDump 64 96 < q64x96.bin
eine höher aufgelöste Bitmap (6144 Bits)
% java BinaryDump 0 < q64x96.bin 6144 bits % java RunLength - < q64x96.bin | java BinaryDump 0 Komprimierungsrate 2296/6144 = 37% 2296 bits
6144 bits % java PictureDump 64 36 < q64x96.rle.bin
2296 bits
Abbildung 5.63: Bitströme mit Lauflängencodierung komprimieren und dekomprimieren
867
5
Strings
Die Lauflängencodierung ist in vielen Situationen sehr effektiv, aber es gibt auch viele Fälle, in denen der Bitstrom, den wir komprimieren wollen (zum Beispiel typischer englischer Text), überhaupt keine langen Läufe hat. Als Nächstes betrachten wir zwei Verfahren, die sich auf eine Vielzahl verschiedener Dateien anwenden lassen. Beide Verfahren sind weit verbreitet und Sie haben beim Herunterladen von Daten aus dem Web sicherlich schon mit einem oder sogar beiden Bekanntschaft gemacht.
5.5.6 Huffman-Komprimierung Im vorliegenden Abschnitt wollen wir ein Komprimierungsverfahren vorstellen, mit dem wir den Speicherbedarf von Textdateien (und vielen anderen Arten von Dateien) enorm reduzieren können. Dieses Ziel erreichen wir nur, wenn wir uns von der herkömmlichen Art der Speicherung von Textdateien verabschieden: Anstelle der üblichen sieben oder acht Bits für jedes Zeichen verwenden wir für Zeichen, die häufiger auftreten, weniger Bits als für die, die selten vorkommen. Als Einführung in das Grundkonzept beginnen wir mit einem kleinen Beispiel. Angenommen, wir wollen den String ABRACADABRA! codieren. Bei einer Codierung in 7-BitASCII erhalten Sie den Bitstring 100000110000101010010100000110000111000001100010010000011000010101001010000010100001.
Um diesen Bitstring zu decodieren, lesen wir einfach nacheinander jeweils 7 Bits und konvertieren diese mithilfe der ASCII-Code-Tabelle 5.18. In diesem Standardcode benötigt das D, das nur einmal auftaucht, die gleiche Anzahl von Bits wie das A, das fünfmal enthalten ist. Die Huffman-Komprimierung basiert auf dem Gedanken, dass wir Bits sparen können, indem wir häufig verwendete Zeichen mit weniger Bits codieren als selten verwendete Zeichen, was die Gesamtzahl der Bits reduziert.
Präfixfreie Codes variabler Länge Ein Code assoziiert Zeichen mit Bitfolgen: also quasi eine Symboltabelle mit Zeichen als Schlüssel und Bitfolgen als Werte. Wir könnten nun zunächst versuchen, die kürzesten Bitfolgen den am häufigsten verwendeten Buchstaben zuzuweisen, zum Beispiel das A mit 0 codieren, das B mit 1, R mit 00, C mit 01, D mit 10 und ! mit 11, sodass ABRACADABRA! als 0 1 00 0 01 0 10 0 1 00 0 11 codiert würde. Diese Repräsentation benötigt nur 17 Bits im Vergleich zu den 77 Bits für 7-Bit-ASCII, aber es ist kein wirklicher Code, da er von den Leerzeichen abhängt, die die Zeichen voneinander abgrenzen. Ohne die Leerzeichen würde die Bitfolge wie folgt lauten: 01000010100100011
und könnte als CRRDDCRCB oder als eine Reihe anderer Zeichenfolgen decodiert werden. Dennoch ist die Zahl der 17 Bits plus der 10 Trennzeichen etwas kompakter als der Standardcode, was vor allem daran liegt, dass keine Bits verwendet werden, um Buchstaben zu codieren, die nicht in der Nachricht sind. Der nächste Schritt besteht darin, es sich zunutze zu machen, dass keine Trennzeichen benötigt werden, wenn
868
5.5 Datenkomprimierung
kein Zeichencode Präfix eines anderen ist. Ein Code mit dieser Eigenschaft wird als präfixfreier Code bezeichnet. Der hier gegebene Code ist nicht präfixfrei, da 0, der Code für A, ein Präfix von 00, dem Code für R, ist. Wenn wir beispielsweise A mit 0, B mit 1111, C mit 110, D mit 100, R mit 1110 und ! mit 101 codieren, gibt es nur eine Möglichkeit, die 30-Bitfolge zu decodieren: 011111110011001000111111100101 ABRACADABRA! Jeder präfixfreie Code ist eindeutig zu decodieren (und bedarf keiner
Trennzeichen), sodass präfixfreie Codes in der Praxis häufig Verwendung finden. Beachten Sie, dass Codes fester Länge wie der 7-Bit-ASCII-Code präfixfrei sind.
Trie-Repräsentation für präfixfreie Codes Präfixfreier Code lässt sich bequem mithilfe eines Trie repräsentieren (siehe Abschnitt 5.2). So definiert jeder Trie mit M null-Referenzen einen präfixfreien Code für M Zeichen: Wir ersetzen die null-Referenzen durch Referenzen auf Blätter (Knoten mit zwei nullReferenzen), die jeweils ein zu codierendes Zeichen enthalten, und definieren den Code für jedes Zeichen mit der Bitfolge, die durch den Pfad von der Wurzel zu dem Zeichen vorgegeben wird. Dabei verwenden wir, wie es bei Tries üblich ist, die 0, wenn wir „nach links gehen“ und die 1, wenn wir „nach rechts gehen“. Abbildung 5.64 zeigt beispielsweise zwei präfixfreie Codes für die Zeichen in ABRACADABRA!. Oben ist das Beispiel für den gerade vorgestellten Code variabler Länge und unten das Beispiel für den Code, der die Bitfolge 11000111101011100110001111101
Trie-Repräsentation
Codewort-Tabelle
Schlüssel ! A B C D R
Wert 101 0 1111 110 100 1110
0
1
A
0
Blätter
0
1
1
D
0
!
1
C
0
1
R
B
komprimierte Bitfolge
30 Bits
011111110011001000111111100101 A B RA CA DA B RA !
Trie-Repräsentation
Codewort-Tabelle
Schlüssel ! A B C D R
Wert 101 11 00 010 100 011
0
0
1
1
0
1
B
A 0
C
1
R
0
D
1
!
komprimierte Bitfolge
11000111101011100110001111101
29 Bits
A B R A C A D A B R A ! erzeugt, die mit 29 Bits 1 Bit kürzer ist. Gibt es einen Trie, der den String noch stärker Abbildung 5.64: Zwei präfixfreie Codes komprimiert? Wie finden wir den Trie, der zum günstigsten präfixfreien Code führt? Es zeigt sich, dass es eine elegante Antwort auf diese Fragen in Form eines Algorithmus gibt, der einen Trie berechnet, der für jeden gegebenen String zu einer Bitfolge minimaler Länge führt. Für einen fairen Vergleich mit anderen Codes müssen wir auch die Bits im Code selbst zählen, da der String ohne ihn nicht decodiert werden kann, und der Code, wie Sie sehen werden, von dem String abhängt. Das allgemeine Verfahren zur Bestimmung des optimalen präfixfreien Codes wurde 1952 von D. Huffman (während seines Studiums!) entdeckt und wird Huffman-Codierung genannt.
869
5
Strings
Überblick Die Verwendung von präfixfreiem Code für die Datenkomprimierung besteht aus fünf wichtigen Schritten. Wir betrachten den zu codierenden Bitstrom als Bytestrom und wenden den präfixfreien Code wie folgt auf die Zeichen an:
Erstelle einen Trie für die Codierung. Schreibe den Trie (codiert als Bitstrom) für die Verwendung beim Extrahieren. Verwende den Trie, um den Bytestrom als Bitstrom zu codieren. Dann erfordert die Dekomprimierung
Lies den Trie (codiert am Anfang des Bitstroms). Decodiere den Bitstrom mithilfe des Trie. Damit Sie den Prozess besser verstehen, betrachten wir diese Schritte in der Reihenfolge ihrer Schwierigkeit.
Trieknoten Wir beginnen mit der Klasse Node in Listing 5.33. Sie entspricht den verschachtelten Klassen, die wir bereits für den Aufbau binärer Bäume und Tries verwendet haben: jedes Node-Objekt hat left- und right-Referenzen auf andere Node-Objekte, die die Struktur des Trie definieren. Jedes Node-Objekt hat außerdem eine Instanzvariable freq für die Komprimierung und eine Instanzvariable ch für die Repräsentation der zu codierenden Zeichen in den Blättern. private static class Node implements Comparable { // Huffman-Trieknoten private char ch; // nicht genutzt für interne Knoten private int freq; // nicht genutzt für Dekomprimierung private final Node left, right; Node(char ch, int freq, Node left, Node right) { this.ch = ch; this.freq = freq; this.left = left; this.right = right; } public boolean isLeaf() { return left == null && right == null; } public int compareTo(Node that) { return this.freq - that.freq; } } Listing 5.33: Trieknoten-Repräsentation
870
5.5 Datenkomprimierung
Dekomprimierung für präfixfreie Codes Trie-Repräsentation Einen Bitstrom zu dekomprimieren, der mit prä- Codewort-Tabelle fixfreiem Code codiert wurde, ist einfach, wenn Schlüssel Wert 0 1 ! 1010 der Trie gegeben ist, der den Code definiert. Die A A 0 0 1 Methode expand() aus Listing 5.34 implemenB 111 tiert diesen Prozess. Nachdem wir den Trie von C 1011 0 1 0 1 der Standardeingabe mit readTrie() eingelesen D 100 D R B 0 1 R 110 haben – eine Methode, auf die wir später noch ! C näher eingehen werden – verwenden wir ihn, um den Rest des Bitstroms wie folgt zu dekompri- Abbildung 5.65: Ein Huffman-Code mieren: Beginnen Sie an der Wurzel und durchlaufen Sie dann den Trie, wie es vom Bitstrom vorgegeben wird (lesen Sie ein Eingabebit und gehen Sie nach links, wenn es eine 0 ist, bzw. nach rechts, wenn es eine 1 ist). Wenn Sie auf ein Blatt treffen, geben Sie das Zeichen an diesem Knoten aus und starten Sie anschließend wieder an der Wurzel. Durch Nachvollziehen dieser Vorgehensweise anhand unseres kleinen Beispiels in Abbildung 5.65 werden Sie dieses Verfahren schnell verstehen und schätzen lernen: Um beispielsweise die Bitfolge 011111001011... zu decodieren, beginnen wir an der Wurzel, gehen nach links, da das erste Bit eine 0 ist, und geben A aus; dann gehen wir zurück zur Wurzel, gehen dreimal nach rechts und geben dann B aus; dann gehen wir zurück zur Wurzel, gehen zweimal nach rechts, dann links und geben R aus und so weiter. Die Einfachheit der Dekomprimierung ist einer der Gründe für die Popularität von präfixfreien Codes im Allgemeinen und der Huffman-Komprimierung im Speziellen.
public static void expand() { Node root = readTrie(); int N = BinaryStdIn.readInt(); for (int i = 0; i < N; i++) { // Dekomprimiert i-tes Codewort. Node x = root; while (!x.isLeaf()) if (BinaryStdIn.readBoolean()) x = x.right; else x = x.left; BinaryStdOut.write(x.ch); } BinaryStdOut.close(); } Listing 5.34: Dekomprimierung von präfixfreiem Code (Decodierung)
871
5
Strings
private static String[] buildCode(Node root) { // Erstellt eine Nachschlagtabelle aus dem Trie. String[] st = new String[R]; buildCode(st, root, ""); return st; } private static void buildCode(String[] st, Node x, String s) { // Erstellt eine Nachschlagtabelle aus dem Trie (rekursiv). if (x.isLeaf()) { st[x.ch] = s; return; } buildCode(st, x.left, s + '0'); buildCode(st, x.right, s + '1'); } Listing 5.35: Aufbau einer Codiertabelle aus einem (präfixfreien) Code-Trie
Komprimierung für präfixfreie Codes Für die Komprimierung verwenden wir den Trie, der den Code zur Erstellung der CodeTabelle definiert (siehe die Methode buildCode() in Listing 5.35). Diese Methode ist kompakt und elegant, aber etwas kompliziert, sodass Sie sie sorgfältig studieren sollten. Sie erzeugt zu jedem Trie eine Tabelle, die jedes Zeichen im Trie mit einer Bitfolge verbindet (repräsentiert als ein String von 0en und 1en). Die Code-Tabelle ist eine Symboltabelle, die einen String mit jedem Zeichen verbindet: Aus Effizienzgründen verwenden wir ein zeichenindiziertes Array st[] anstelle einer allgemeinen Symboltabelle, da die Anzahl der Zeichen nicht groß ist. Um das Array zu erzeugen, durchläuft buildCode() rekursiv den Baum, wobei der Code einen binären String entsprechend dem Pfad von der Wurzel zu jedem Knoten verwaltet (0 für Referenzen nach links und 1 für Referenzen nach rechts) und das Codewort zu jedem Zeichen festlegt, wenn es in einem Blatt gefunden wurde. Sobald die Codetabelle erstellt ist, ist die Komprimierung ganz einfach: Schlagen Sie einfach den Code für jedes Eingabezeichen nach. Mit der Codierung aus Abbildung 5.65 zur Komprimierung von ABRACADABRA! schreiben wir zuerst 0 (das Codewort, das mit A verbunden ist), dann 111 (das Codewort für B), dann 110 (das Codewort für R) usw. Diese Aufgabe übernimmt das Codefragment in Listing 5.36: Es liefert den String zu jedem Eingabezeichen, wandelt ihn in 0/1-Werte in einem char-Array um und gibt die entsprechende Bitfolge aus. for (int i = 0; i < input.length; i++) { String code = st[input[i]]; for (int j = 0; j < code.length(); j++) if (code.charAt(j) == '1') BinaryStdOut.write(true); else BinaryStdOut.write(false); } Listing 5.36: Komprimierung mit Codetabelle
872
5.5 Datenkomprimierung
Triekonstruktion Damit Sie unserer Beschreibung des Verfahrens besser folgen können, finden Sie in Abbildung 5.66 einen Überblick über die Schritte beim Erstellen eines Huffman-Trie für die Eingabe it was the best of times it was the worst of times
Wir speichern die zu codierenden Zeichen in Blättern und verwalten für jeden Knoten die Instanzvariable freq, die die Summe der Häufigkeiten aller Zeichen von dem Teilbaum speichert, der seine Wurzel in diesem Knoten hat. Im ersten Schritt erzeugen wir einen Wald von Bäumen, die jeweils aus einem Knoten (Blatt) bestehen. Dabei erzeugen wir so viele Bäume, wie unterschiedliche Zeichen im Eingabestrom vorhanden sind, und weisen jedem einen freq-Wert zu, der die Häufigkeit dieses Zeichens in der Eingabe angibt. In unserem Beispiel hat die Eingabe 8 t, 5 e, 11 Leerzeichen usw. (Wichtiger Hinweis: Diese Häufigkeitswerte erhalten wir nur, wenn wir den ganzen Eingabestrom lesen – d.h., die Huffman-Codierung ist ein Algorithmus mit zwei Durchgängen, da wir den Eingabestrom für die Komprimierung noch einmal lesen müssen.) Im nächsten Schritt erstellen wir den Codierungs-Trie von unten nach oben entsprechend der Häufigkeiten. Während der Erzeugung des Trie betrachten wir ihn als einen binären Trie, dessen Häufigkeiten in den Knoten gespeichert sind; nach seiner Erzeugung betrachten wir ihn als einen Trie für die Codierung in der oben beschriebenen Weise. Der Ablauf sieht folgendermaßen aus: Wir suchen die beiden Knoten mit den kleinsten Häufigkeiten und erzeugen dann einen neuen Knoten, der diese beiden Knoten als Kindknoten hat (und dessen Häufigkeit gleich der Summe der Häufigkeitswerte seiner Kindknoten ist). Diese Vorgehensweise reduziert die Anzahl der Tries im Wald um eins. Dann wiederholen wir den Prozess: Wir suchen erneut die beiden Knoten mit den kleinsten Häufigkeiten im Wald und erzeugen einen neuen Knoten. Wie die Methode buildTrie() in Listing 5.37 zeigt, lässt sich dies ganz einfach mit einer Vorrangwarteschlange implementieren. (Der Übersichtlichkeit halber sind die Tries in der Abbildung sortiert.) Nach und nach stellen wir auf diese Weise immer größere Tries her und verringern gleichzeitig bei jedem Schritt die Anzahl der Tries im Wald um eins (wir entfernen zwei und fügen einen hinzu). Am Schluss sind alle Knoten zu einem einzigen Trie verbunden. private static Node buildTrie(int[] freq) { // Initialisiert Vorrangwarteschlange mit Singleton-Bäumen. MinPQ pq = new MinPQ(); for (char c = 0; c < R; c++) if (freq[c] > 0) pq.insert(new Node(c, freq[c], null, null)); while (pq.size() > 1) { // Führt zwei kleinste Bäume zusammen. Node x = pq.delMin(); Node y = pq.delMin(); Node parent = new Node('\0', x.freq + y.freq, x, y); pq.insert(parent); } return pq.delMin(); } Listing 5.37: Einen Huffman-Codierungs-Trie erstellen
873
5
Strings
1
1
1
2
2
2
2
3
3
4
5
6
8
11
LF
b
r
f
h
m
a
w
o
i
e
s
t
SP
2
2
2
2
3
3
4
5
6
8
11
f
h
m
a
w
o
i
e
s
t
SP
Fortführung von der linken Spalte unten 8
2
1
r 1
1
LF
b
11
11
8
t 4
4
2
2
f
h
2
6
6
e
7
s
2
m
13
SP
5
a
3
3
w
o
3
4
i 1
2
r 2
f
2
2
2
3
3
h
m
a
w
o
3 1
2
4
5
6
8
11
1
1
i
e
s
t
SP
LF
b
5
6
8
11
5
e
s
t
SP
e
r
2
3
3
a
w
o
2
m
1
1
LF
b
1
4
i
2
2
r
3
3
w
o
f 1
1
LF
b
4
3
r
zwei Tries mit den kleinsten Gewichten
1
1
LF
b
7
8
3
h
w
o
3
4
4
4 2
f
h
2
5
6
8
11
e
s
t
SP
1
1
2
2
f
h
LF
b
m
4
2
r 1
1
LF
b
e 2
2
f
h
2
m
4
f
h
2
3
3
a
w
o
6
6
5
6
8
11
s
t
SP
22
8
8
2
m
2
3
3
a
w
o
3
3
w
o
2 1
1
LF
b
1
1
LF
b
7
2
f
h
8
r 1
1
LF
b
2
f
h
3
w
o
6
16
6
8
11
t
SP
7
8
2
m
8
t
s 3
3
w
o
3
4
4
4
i 1
2
2
2
f
h
r 1
1
LF
b
2
2
m
a
2
m
a
51
11
SP
6
2
3
3
a
w
o
13
16
SP
5
6
6
e
e
29 11
11
11
5
4
2
3
13
e
2
8
4
4
2
a
22
i
2
m
29 11
5
t 3
2
22
4
2
s 1
b
h
i
2
r
6
LF
2
f
SP
4
4
1
1
11
8
3
e 2
4
i
s
e
1
6
8
3
7
6
SP
5
4
i 2
t
r
6
4
r
7
1
5
4
11
11
t 3 1
s
e 2
a
16 7
neuer Elternknoten für diese beiden Tries
4 2
6
5
i 1
2
m
a 13
4
2
2
s 4
4
i 2
r
2
8
t
s 3
6
3
7
Abbildung 5.66: Einen Trie für die Huffman-Codierung erstellen
8
8
t
s 3
3
w
o
3
4
4
4
i 1
2
r
Fortführung in der rechten Spalte oben
874
16
6
1
4
2
13
6
2
i 1
11
11
SP
4
3
1
1
LF
b
2
2
f
h
2
m
2
a
5.5 Datenkomprimierung
Trie-Repräsentation
Codewort-Tabelle Schlüssel Wert 0
LF
1
SP 0
11 0
5
SP 0
1
6
e
0
3
6
1
s
1
w 3 o
0
1
w kommt 3-mal in der Eingabe vor
a b e 0 1 f 8 t h 0 1 0 1 i 4 i m 0 1 0 1 o 2 f 2 h 2 m 2 a r 1 s 1 b t folgt man den Beschriftungen auf dem w Pfad von der Wurzel zum Blatt m, 0
1
1
r 0
1
LF
1
101010 01 11011 101011 000 11000 11001 1011 11010 0011 10100 100 111 0010
so lautet der Code 11010
Abbildung 5.67: Huffman-Code für den Zeichenstrom it was the best of times it was the worst of times LF
Die Blätter in diesem Trie geben Auskunft über die zu codierenden Zeichen und deren Häufigkeiten in der Eingabe; jeder Knoten, der kein Blatt ist, gibt die Summe der Häufigkeiten seiner beiden Kindknoten an. Knoten mit niedrigen Häufigkeiten stehen somit unten im Trie, während Knoten mit hohen Häufigkeiten sich in der Nähe der Triewurzel befinden. Die Häufigkeit in der Wurzel ist gleich der Anzahl der Zeichen in der Eingabe. Da wir es hier mit einem binären Trie zu tun haben, bei dem die Zeichen nur in den Blättern stehen, definiert der Trie einen präfixfreien Code für diese Zeichen. Unter Verwendung der Codewort-Tabelle, die buildCode() für unser Beispiel erzeugt hat (Abbildung 5.67 rechts), erhalten wir folgende Bitfolge: 10111110100101101110001111110010000110101100010011101001111000011111011110100001000110111110100101101110001111110010000100100011101001001110100111100001111101111010000100101010
Sie ist 176 Bits lang und damit um 57% kürzer als die 408 Bits, die wir benötigen, wenn wir die 51 Zeichen in Standard-8-Bit-ASCII codieren würden (die Kosten für den Code nicht mitgerechnet, was wir gleich nachholen werden). Und da die Bitfolge ein Huffman-Code ist, gibt es außerdem keinen anderen präfixfreien Code, der die Eingabe mit weniger Bits codieren kann.
Optimalität Wir haben festgestellt, dass Zeichen mit einer höheren Häufigkeit näher an der Wurzel des Baums stehen als Zeichen mit einer geringeren Häufigkeit und folglich mit weniger Bits codiert werden. Das bedeutet, dass dieser Code ein guter Code ist. Bleibt die Frage, warum er ein optimaler präfixfreier Code ist? Um diese Frage zu beantworten, definieren wir zuerst die gewichtete externe Pfadlänge eines Baums als die Summe der Produkte des Gewichts (zugehöriger Häufigkeitszähler) und der Tiefe über alle Blätter (siehe Abschnitt 1.5.2).
875
5
Strings
Satz T: Für jeden präfixfreien Code ist die Länge der codierten Bitfolge gleich der gewichteten externen Pfadlänge des entsprechenden Trie. Beweis: Die Tiefe von jedem Blatt ist die Anzahl der Bits, die benötigt wird, um das Zeichen im Blatt zu codieren. Folglich ist die gewichtete externe Pfadlänge die Länge der codierten Bitfolge: Sie ist gleich der über alle Buchstaben gebildeten Summe der Produkte der Häufigkeit des Auftretens eines Buchstaben mit der Anzahl der Bits bei jedem Auftreten.
Bei unserem Beispiel gibt es ein Blatt der Distanz 2 (SP mit der Häufigkeit 11), drei Blätter der Distanz 3 (e, s und t mit einer gesamten Häufigkeit von 19), drei Blätter der Distanz 4 (w, o und i mit einer gesamten Häufigkeit von 10), fünf Blätter der Distanz 5 (r, f, h, m und a mit einer gesamten Häufigkeit von 9) und zwei Blätter der Distanz 6 (LF und b mit einer gesamten Häufigkeit von 2), sodass die Gesamtsumme 2⋅11 + 3⋅19 + 4⋅10 + 5⋅9 + 6⋅2 = 176 beträgt, was wie erwartet der Länge der ausgegebenen Bitfolge entspricht.
Satz U: Für eine gegebene Menge von r Symbolen und Häufigkeiten erstellt der Huffman-Algorithmus einen optimalen präfixfreien Code. Beweis: Durch Induktion über r. Nehmen wir an, dass der Huffman-Code für jede Menge mit weniger als r Symbolen optimal ist. Sei TH der Code, der von Huffman für die Menge der Symbole und den dazugehörigen Häufigkeiten (s1, f1), …, (sr , fr ) berechnet wurde, und W(TH) die Länge des Codes (gewichtete externe Pfadlänge des Trie). Angenommen, (si , fi ) und (sj , fj ) sind die ersten beiden gewählten Symbole. Der Algorithmus berechnet dann den Code TH∗ für die Menge von r − 1 Symbolen mit (si , fi ) und (sj, fj ) ersetzt durch (s∗, fi + fj ), wobei s∗ ein neues Symbol in einem Blatt in irgendeiner Tiefe d ist. Dabei gilt
W(TH) = W(TH∗) − d(fi + fj) + (d + 1)(fi + fj) = W(TH∗) + (fi + fj) Betrachten wir nun einen optimalen Trie T für (s1, r1), …, (sr , fr) der Höhe h. Beachten Sie, dass (si , fi) und (sj , fj) sich in der Tiefe h befinden müssen (ansonsten könnten wir einen Trie mit einer kürzeren externen Pfadlänge erstellen, indem wir sie mit Knoten in der Tiefe h tauschen). Nehmen wir weiterhin an, dass (si , fi ) und (sj , fj ) Geschwisterknoten sind, indem wir (sj , fj ) mit dem Geschwisterknoten von (si , fi) tauschen. Betrachten wir nun den Baum T ∗, den wir erhalten, indem wir ihren Elternknoten mit (s∗, fi + fj ) ersetzen. Beachten Sie, dass (gemäß dem gleichen Argument wie oben) W(T) = W(T∗) + (fi + fj ) ist. Gemäß der induktiven Hypothese ist TH∗ optimal: W(TH∗) ≤ W(T ∗). Deshalb gilt
W(TH) = W(TH∗) + (fi + fj) ≤ W(T ∗) + (fi + fj) = W(T) Da T optimal ist, muss die Gleichheit gelten und TH ist optimal.
876
5.5 Datenkomprimierung
Jedes Mal, wenn ein Knoten auszuwählen ist, können mehrere Knoten mit dem gleichen Gewicht vorliegen. Das Huffman-Verfahren gibt nicht an, wie solche Mehrdeutigkeiten aufzulösen sind. Ebenso wie es die Links-/Rechts-Positionen der Kindknoten nicht angibt. Verschiedene Entscheidungen führen zu verschiedenen Codes, aber alle diese präfixfreien Codes werden den Text mit der optimalen Anzahl von Bits verschlüsseln. PreorderTraversierung
1
A
2
3
5
D
R
4
LF
B
C
Blätter A
1
D
23
LF
4
C
R
5
B interne Knoten
Abbildung 5.68: Mit Preorder-Traversierung einen Trie als Bitstrom codieren
Den Trie schreiben und lesen Wie wir bereits erwähnt haben, ist der oben genannte Wert der Einsparungen nicht ganz korrekt, da der komprimierte Bitstrom nicht ohne den Trie codiert werden kann, d.h., wir müssen neben der Bitfolge die Kosten für den Trie bei der Berechnung der Kosten der komprimierten Ausgabe berücksichtigen. Bei langen Eingaben fallen diese Kosten kaum ins Gewicht, dennoch müssen wir für eine vollständige Datenkomprimierung den Trie beim Komprimieren in den Bitstrom schreiben und beim Dekomprimieren wieder auslesen. Wie können wir einen Trie als Bitstrom codieren und dann decodieren? Erstaunlicherweise lassen sich beide Aufgaben mit einfachen rekursiven Prozeduren realisieren, die auf der Preorder-Traversierung des Trie basieren. Die Methode writeTrie() in Listing 5.38 traversiert einen Trie in Preorder: Wenn sie auf einen inneren Knoten stößt, schreibt sie ein einzelnes 0-Bit und, wenn sie auf ein Blatt stößt, schreibt sie ein 1-Bit, gefolgt von dem 8-Bit-ASCII-Code für das Zeichen im Blatt. Die Bitfolgencodierung des Huffman-Trie für unser Beispiel ABRACADABRA! sehen Sie in Abbildung 5.68. Das erste Bit ist eine 0 und steht für die Wurzel. Da wir als Nächstes auf das Blatt mit dem Buchstaben A treffen, ist das nächste Bit eine 1, gefolgt von 0100001, dem 8-Bit-ASCII-Code für A. Die nächsten beiden Bits sind 0en, da wir im Anschluss über zwei interne Knoten iterieren, usw. Die dazugehörige Methode readTrie() in Listing 5.39 rekonstruiert aus dieser Bitfolge den Trie: Sie liest ein einzelnes Bit, um festzustellen, welche Art von Knoten als Nächstes kommt; ist es ein Blatt (d.h., das Bit ist 1), dann liest sie das nächste Zeichen und erzeugt ein Blatt, und ist es ein interner Knoten (d.h., das Bit ist 0), so erzeugt sie einen internen Knoten und erstellt dann (rekursiv) seinen linken und rechten Teilbaum. Stellen Sie sicher, dass Sie diese Methoden verstanden haben: ihre Einfachheit ist trügerisch.
877
5
Strings
private static void writeTrie(Node x) { // Schreibt bitfolgencodierten Trie. if (x.isLeaf()) { BinaryStdOut.write(true); BinaryStdOut.write(x.ch); return; } BinaryStdOut.write(false); writeTrie(x.left); writeTrie(x.right); } Listing 5.38: Gibt einen Trie als Bitfolge aus
private static Node readTrie() { if (BinaryStdIn.readBoolean()) return new Node(BinaryStdIn.readChar(), 0, null, null); return new Node('\0', 0, readTrie(), readTrie()); } Listing 5.39: Rekonstruiert einen Trie aus einer Repräsentation einer Preorder-Bitfolge
Implementierung der Huffman-Komprimierung Zusammen mit den Methoden buildCode(), buildTrie(), readTrie() und writeTrie(), die wir gerade vorgestellt haben (und die Methode expand(), die wir zuerst betrachtet haben) bildet Algorithmus 5.10 eine vollständige Implementierung der Huffman-Komprimierung. Um den Überblick zu erweitern, den wir einige Seiten vorher gegeben haben, betrachten wir den zu codierenden Bitstrom als einen Strom von 8-Bit-charWerten und komprimieren ihn wie folgt:
Lies die Eingabe ein. Tabelliere die Häufigkeit von jedem char-Wert in der Eingabe. Erstelle den Codierungs-Trie gemäß Huffman unter Berücksichtigung dieser Häufigkeiten.
Erstelle die dazugehörige Codewort-Tabelle, um eine Bitfolge mit jedem char-Wert in der Eingabe zu verbinden.
Schreibe den Trie codiert als eine Bitfolge. Schreibe die Anzahl der Zeichen in der Eingabe codiert als Bitfolge. Verwende die Codewort-Tabelle, um für jedes Eingabezeichen das Codewort zu schreiben. Um einen Bitstrom zu decodieren, der auf diese Weise codiert wurde, gehen wir folgendermaßen vor:
Lies den Trie ein (codiert am Anfang des Bitstroms). Lies die Anzahl der zu decodierenden Zeichen. Decodiere den Bitstrom mithilfe des Trie.
878
5.5 Datenkomprimierung
Listing 5.40: Huffman-Komprimierung (Algorithmus 5.10) public class Huffman { private static int R = 256; // ASCII-Alphabet // Siehe Listing 5.33 für die innere Node-Klasse. // Siehe Text für Hilfsmethoden und expand(). public static void compress() { // Liest Eingabe. String s = BinaryStdIn.readString(); char[] input = s.toCharArray(); // Tabelliert Häufigkeitszahlen. int[] freq = new int[R]; for (int i = 0; i < input.length; i++) freq[input[i]]++; // Erstellt den Huffman-Codierungs-Trie. Node root = buildTrie(freq); // Erstellt Codetabelle (rekursiv). String[] st = new String[R]; buildCode(st, root, ""); // Gibt Trie für Decoder aus (rekursiv). writeTrie(root); // Gibt die Anzahl der Zeichen aus. BinaryStdOut.write(input.length); // Codiert die Eingabe mithilfe des Huffman-Codes. for (int i = 0; i < input.length; i++) { String code = st[input[i]]; for (int j = 0; j < code.length(); j++) if (code.charAt(j) == '1') BinaryStdOut.write(true); else BinaryStdOut.write(false); } BinaryStdOut.close(); } }
Diese Implementierung der Huffman-Codierung erstellt einen expliziten Codierungs-Trie und verwendet dazu eine Reihe von Hilfsmethoden, die wir zum Abschluss dieses Abschnitts vorstellen und erläutern werden.
879
5
Strings
Testfall (96 Bits)
% more abra.txt ABRACADABRA! % java Huffman - < abra.txt | java BinaryDump 60 010100000100101000100010000101010100001101010100101010000100 000000000000000000000000000110001111100101101000111110010100 Komprimierungsrate 120/96 = 125%, 120 bits da 59 Bits für den Trie und 32 Bits für die Anzahl Beispiel aus dem Text (408 Bits)
% more tinytinyTale.txt it was the best of times it was the worst of times % java Huffman - < tinytinyTale.txt | java BinaryDump 64 0001011001010101110111101101111100100000001011100110010111001001 0000101010110001010110100100010110011010110100001011011011011000 0110111010000000000000000000000000000110011101111101001011011100 0111111001000011010110001001110100111100001111101111010000100011 0111110100101101110001111110010000100100011101001001110100111100 00111110111101000010010101000000 Komprimierungsrate 352/404= 86%, auch wenn 352 bits 137 Bits für den Trie und 32 Bits für die Anzahl % java Huffman - < tinytinyTale.txt | java Huffman + it was the best of times it was the worst of times erstes Kapitel von "A Tale of Two Cities"
% java PictureDump 512 90 < medTale.txt
45056 bits % java Huffman - < medTale.txt | java PictureDump 512 47
23912 bits
Komprimierungsrate 23912/45056 = 53%
der gesamte Text von "A Tale of Two Cities"
% java BinaryDump 0 < tale.txt 5812552 bits % java Huffman - < tale.txt > tale.txt.huf % java BinaryDump 0 < tale.txt.huf Komprimierungsrate 3043928/5812552 = 52% 3043928 bits Abbildung 5.69: Byteströme mit Huffman-Codierung komprimieren und dekomprimieren
880
5.5 Datenkomprimierung
Einer der Gründe für die Popularität der Huffman-Komprimierung ist, dass sie sich auf verschiedene Dateitypen anwenden lässt, nicht nur auf normale Textdateien. Wir haben bei der Programmierung des Verfahrens darauf geachtet, dass es ordnungsgemäß für jeden 8-Bit-Wert in jedem 8-Bit-Zeichen funktioniert. Mit anderen Worten, wir können es auf jeden beliebigen Bytestrom anwenden. In Abbildung 5.70 werden mehrere Beispiele für Dateitypen gegeben, die wir am Anfang dieses Abschnitts angesprochen haben. Diese Beispiele zeigen, dass die Huffman-Komprimierung sowohl mit der Codierung fester Länge als auch mit der Lauflängencodierung mithalten kann, auch wenn diese Verfahren speziell für bestimmte Dateitypen entwickelt wurden. Die Erklärungen, warum sich die Huffman-Codierung in diesen Bereichen so erfolgreich einsetzen lässt, sind sehr lehrreich. Im Falle der Genomdaten entdeckt die HuffmanKomprimierung im Wesentlichen einen 2-Bit-Code, da die vier Buchstaben mit ungefähr der gleichen Häufigkeit erscheinen, sodass der Huffman-Trie balanciert ist und jedem Zeichen ein 2-Bit-Code zugewiesen wird. Im Fall der Lauflängencodierung sind 00000000 and 11111111 die wahrscheinlich am häufigsten vorkommenden Zeichen, was zur Folge hat, dass sie mit 2 oder 3 Bits codiert werden und damit erheblich zur Komprimierung beitragen. Virus (50000 Bits)
% java Genome
- < genomeVirus.txt | java PictureDump 512 25
12536 bits % java Huffman - < genomeVirus.txt | java PictureDump 512 25
12576 bits
die Huffman-Komprimierung benötigt nur 40 Bits mehr als der Standard-2-Bit-Code
Bitmap (1536 Bits)
% java RunLength - < q32x48.bin | java BinaryDump 0 1144 bits % java Huffman - < q32x48.bin | java BinaryDump 0 die Huffman-Komprimierungn benötigt 29% 816 bits weniger Bits als das angepasste Verfahren Bitmap mit höherer Auflösung (6144 Bits)
% java RunLength - < q64x96.bin | java BinaryDump 0 2296 bits % java Huffman - < q64x96.bin | java BinaryDump 0 die Lücke verkleinert sich auf 11% bei höherer Auflösung 2032 bits Abbildung 5.70: Genomdaten und Bitmaps mit Huffman-Codierung komprimieren und dekomprimieren
881
5
Strings
5.5.7 LZW-Komprimierung Eine interessante Alternative zur Huffman-Komprimierung ist die sogenannte LZWKomprimierung, die in den späten 1970ern und frühen 1980ern von A. Lempel, J. Ziv und T. Welch entwickelt wurde und schon bald zu einem der populärsten Komprimierungsverfahren avancierte, weil sie leicht zu implementieren und auf eine Vielzahl von Dateitypen anwendbar ist. Vom Grundkonzept her komplementiert diese Alternative die Huffman-Codierung. Anstatt mit einer Tabelle von Codewörtern variabler Länge für Muster fester Länge in der Eingabe zu arbeiten, verwenden wir hier eine Tabelle von Codewörtern fester Länge für Muster variabler Länge in der Eingabe. Im Gegensatz zur Huffman-Codierung zeichnet sich dieses Verfahren dadurch aus, dass wir die Tabelle nicht codieren müssen.
LZW-Komprimierung Zur Veranschaulichung werden wir ein Beispiel für die Komprimierung betrachten, bei dem wir die Eingabe als einen Strom von 7-Bit-Zeichen einlesen und als einen Strom von 8-Bit-Bytes ausgeben. (In der Praxis verwenden wir normalerweise größere Werte für diese Parameter – unsere Implementierungen basieren auf 8-Bit-Eingaben und 12Bit-Ausgaben.) Wir bezeichnen Eingabebytes als Zeichen, Folgen von Eingabebytes als Strings und Ausgabebytes als Codewörter, auch wenn diese Begriffe in anderen Zusammenhängen eine etwas andere Bedeutung haben. Der LZW-Algorithmus basiert auf der Verwaltung einer Symboltabelle, die Stringschlüssel mit Codewort-Werten (fester Länge) verbindet. Wir initialisieren die Symboltabelle mit 128 möglichen einbuchstabigen Stringschlüsseln und assoziieren diese mit 8-Bit-Codewörtern, die wir erhalten, indem wir dem 7-Bit-Wert eines jeden Zeichens eine 0 voranstellen. Aus Platzgründen verwenden wir Hexadezimalcode für die Codewort-Werte, d.h., 41 ist das Codewort für ASCII A, 52 für R usw. Wir reservieren das Codewort 80, um das Ende der Datei auszudrücken. Die restlichen Codewort-Werte (81 bis FF) weisen wir verschiedenen Teilstrings der Eingabe zu, denen wir begegnen. Wir beginnen mit 81 und inkrementieren den Wert für jeden neu hinzugefügten Schlüssel. Zur Komprimierung führen wir die folgenden Schritte durch, solange es nicht eingelesene Eingabezeichen gibt:
Suche den längsten String s in der Symboltabelle, der ein Präfix der nicht eingelesenen Eingabe ist.
Schreibe den 8-Bit-Wert (Codewort), der mit s verbunden ist. Lies in der Eingabe ein Zeichen über das s hinaus. Assoziiere in der Symboltabelle den nächsten Codewort-Wert mit s + c (c angehängt an s), wobei c das nächste Zeichen in der Eingabe ist.
882
5.5 Datenkomprimierung
Im letzten Schritt werfen wir einen Blick voraus auf das nächste Zeichen in der Eingabe, um den nächsten Wörterbucheintrag zu erstellen. Aus diesem Grunde heißt das Zeichen c auch Vorausschauzeichen (lookahead character). Im Moment fügen wir einfach keine weiteren Einträge in die Symboltabelle ein, wenn uns die CodewortWerte ausgehen (nachdem wir den Wert FF irgendeinem String zugewiesen haben) – wir werden später noch alternative Strategien diskutieren.
LZW-Komprimierung: Ein Beispiel Abbildung 5.71 veranschaulicht die Operation der LZW-Komprimierung für die Beispieleingabe ABRACADABRABRABRA. Für die ersten sieben Zeichen ist die längste Präfixübereinstimmung nur ein Zeichen, sodass wir das jeweilige Codewort zu diesen Zeichen ausgeben und die Codewörter 81 bis 87 zweibuchstabigen Strings zuweisen. Dann suchen wir Präfixübereinstimmungen mit AB (d.h., wir geben 81 aus und fügen ABR zur Tabelle hinzu), mit RA (d.h., wir geben 83 aus und fügen RAB zur Tabelle hinzu), mit BR (d.h., wir geben 82 aus und fügen BRA zur Tabelle hinzu) und mit ABR (d.h., wir geben 88 aus und fügen ABRA zur Tabelle hinzu), was uns mit dem letzten A zurücklässt (d.h., wir geben das Codewort 41 aus).
Die Eingabe besteht aus 17 ASCII-Zeichen von jeweils 7 Bits, also insgesamt aus 119 Bits; die Ausgabe besteht aus 12 Codewörtern von jeweils 8 Bits, insgesamt 96 Bits – eine Komprimierungsrate von 82 Prozent, sogar bei diesem winzigen Beispiel. Eingabe A Übereinstimmungen A Ausgabe 41
B
R
A
C
A
D
A
B
R
A
C
A
D
A B
B
R R A
A
B B R
R
A A B R
42
52
41
43
41
44
81
83
82
88
B
EingabeTeilstring
LZWCodewort Vorausschauzeichen
R
A
EOF
A 41
80
Codewort-Tabelle Schlüssel Wert 81 AB 82 BR 83 RA 84 AC 85 CA 86 AD 87 DA 88 ABR 89 RAB 8A BRA A B R A 8B
Abbildung 5.71: LZW-Komprimierung für ABRACADABRABRABRA
Repräsentation des LZW-Trie Bei der LZW-Komprimierung fallen zwei Symboltabellen-Operationen an:
Suche nach einer längsten Präfixübereinstimmung der Eingabe mit einem Symboltabellenschlüssel.
Füge einen Eintrag hinzu, der das nächste Codewort mit dem Schlüssel assoziiert, der durch Anhängen des Vorausschauzeichens an diesen Schlüssel gebildet wird.
883
5
Strings
Unsere Trie-Datenstrukturen aus Abschnitt 5.2 sind auf diese Operationen zugeschnitten. Die Trie-Repräsentation für unser BeiA 41 B 42 D 44 R 52 C 43 spiel finden Sie in Abbildung 5.72. Um eine längste Präfixübereinstimmung zu finden, traversieren wir den Trie von der WurB 81 R 82 A 85 A 87 A 83 C 84 D 86 zel aus und gleichen dabei Knotenbezeichnungen mit Eingabezeichen ab. Um ein neues A 8A B 89 R 88 Codewort hinzuzufügen, verbinden wir einen neuen Knoten, der mit dem nächsten Codewort und dem Vorausschauzeichen beschriftet A 8B ist, mit dem Knoten, in dem die Suche endete. Abbildung 5.72: Trie-Repräsentation der LZWIn der Praxis verwenden wir aus Gründen Codetabelle der Speichereffizienz einen ternären Suchtrie (siehe Abschnitt 5.2). Anzumerken ist die jeweils unterschiedliche Nutzung der Tries in den beiden Codierverfahren: Bei der Huffman-Codierung sind die Tries nützlich, da kein Präfix eines Codeworts auch ein Codewort ist; bei der LZW-Codierung sind Tries nützlich, weil jeder Präfix eines Eingabe-Teilstringschlüssels auch ein Schlüssel ist.
LZW-Dekomprimierung Die Eingabe für die LZW-Dekomprimierung in unserem Beispiel ist eine Folge von 8-Bit-Codewörtern und die Ausgabe ein String von 7-Bit-ASCII-Zeichen. Um diese Dekomprimierung zu implementieren, verwalten wir eine Symboltabelle, die Zeichenstrings mit Codewort-Werten assoziiert (die Umkehrung der Tabelle für die Komprimierung). Wir füllen die Tabelleneinträge von 00 bis 7F mit den Zeichen aus dem ASCII-Zeichensatz, setzen den ersten nicht zugewiesenen Codewort-Wert auf 81 (80 ist für das Ende der Datei reserviert), setzen den aktuellen String val auf den ersten aus einem Zeichen bestehenden String und führen die folgenden Schritte durch, bis wir das Codewort 80 (Ende der Datei) lesen:
Schreibe den aktuellen String val. Lies ein Codewort x aus der Eingabe. Setze s auf den Wert, der in der Symboltabelle mit x verbunden ist. Assoziiere den nächsten nicht zugewiesenen Codewort-Wert mit val + c in der Symboltabelle, wobei c das erste Zeichen von s ist.
Setze den aktuellen String val auf s.
884
5.5 Datenkomprimierung
Listing 5.41: LZW-Komprimierung (Algorithmus 5.11) public class LZW { private static final int R = 256; // Anzahl der Eingabezeichen private static final int L = 4096; // Anzahl der Codewörter = 2^12 private static final int W = 12; // Codewortbreite public static void compress() { String input = BinaryStdIn.readString(); TST st = new TST(); for (int i = 0; i < R; i++) st.put("" + (char) i, i); int code = R+1; // R ist Codewort für Dateiende. while (input.length() > 0) { String s = st. longestPrefixOf(input); // Findet max. // Präfixübereinstimmung. BinaryStdOut.write(st.get(s), W); // Gibt Codierung von s aus. int t = s.length(); if (t < input.length() && code < L) // Fügt s zur Symboltabelle // hinzu. st.put(input.substring(0, t + 1), code++); input = input.substring(t); // Sucht über s in der // Eingabe hinaus. } BinaryStdOut.write(R, W); // Schreibt Dateiende. BinaryStdOut.close(); } public static void expand() // Siehe Fortsetzung von Algorithmus 5.11. }
Diese Implementierung der Datenkomprimierung nach Lempel-Ziv-Welch verwendet 8-Bit-Eingabebytes und 12-Bit-Codewörter und eignet sich für beliebig große Dateien. Die Codewörter für das kleine Beispiel entsprechen denen, die wir im Text besprochen haben: Die Codewörter für einzelne Zeichen haben eine führende 0; die anderen beginnen bei 100. % more abraLZW.txt ABRACADABRABRABRA % java LZW - < abraLZW.txt | java HexDump 20 04 10 42 05 20 41 04 30 41 04 41 01 10 31 02 10 80 41 10 00 160 bits
Dieser Prozess ist aufgrund des Vorausschauzeichens komplizierter als die Komprimierung: Wir müssen das nächste Codewort lesen, um das erste Zeichen in dem damit verbundenen String zu erhalten, was den Prozess um einen Schritt aus den Takt bringt. Bei den ersten sieben Codewörtern schlagen wir nur das entsprechende Zei-
885
5
Strings
chen nach und schreiben es, dann schauen wir ein Zeichen voraus und fügen wie zuvor einen Eintrag in die Symboltabelle, der aus zwei Zeichen besteht. Dann lesen wir 81 (und schreiben AB und fügen ABR in die Tabelle), 83 (und schreiben RA und fügen RAB in die Tabelle), 82 (und schreiben BR und fügen BRA in die Tabelle) und 88 (und schreiben ABR und fügen ABRA in die Tabelle), was uns mit 41 zurücklässt. Zum Schluss lesen wir das Dateiendezeichen (EOF, end of file) 80 (und schreiben A). Am Ende des Prozesses erhalten wir die ursprüngliche Eingabe und die gleiche Codetabelle wie bei der Komprimierung, allerdings mit Umkehrung der Schlüssel-WerteRollen. Beachten Sie, dass wir für die Tabelle eine einfache Stringarray-Repräsentation mit Codewort-Index verwenden können. Eingabe 41 Ausgabe A
42 B
52 R
41 A
43 C
41 A
44 D
81 A B
83 R A
82 B R
88 A B R
41 A
80
invertierte Codewort-Tabelle Schlüssel Wert 81
LZWCodewort
EingabeTeilstring
Abbildung 5.73: LZW-Dekomprimierung für 41 42 52 41 43 41 44 81 83 82 88 41 80
Knifflige Situation Es gibt einen subtilen Fehler in dem gerade beschriebenen Prozess, der von Studenten (und erfahrenen Programmierern!) oft erst entdeckt wird, wenn sie eine Implementierung mithilfe der obigen Beschreibung entwickeln. Das Problem, das Abbildung 5.74 verdeutlicht, ist, dass der Vorausschauprozess ein Zeichen zu weit voraus sein kann. In diesem Beispiel wird der Eingabestring ABABABA
zu den fünf Ausgabe-Codewörtern 41 42 81 83 80
komprimiert (siehe oberen Teil der Abbildung). Für die Dekomprimierung lesen wir das Codewort 41, geben A aus, lesen das Codewort 42, um das Vorausschauzeichen zu erhalten, fügen AB als Tabelleneintrag 81 hinzu, geben das mit 42 verbundene B aus, lesen das Codewort 81, um das Vorausschauzeichen zu erhalten, fügen BA als Tabelleneintrag 82 hinzu und geben das mit 81 verbundene AB aus. So weit, so gut. Wenn wir jedoch das Codewort 83 lesen, um das Vorausschauzeichen zu erhalten, kommen wir nicht mehr weiter, da wir dieses Codewort nur deshalb lesen, um den Tabelleneintrag 83 zu vervollständigen! Zum Glück lässt sich diese Bedingung leicht überprüfen (sie tritt genau dann ein, wenn das Codewort das gleiche ist wie der zu vervollständigende Tabelleneintrag) und korrigieren (das Vorausschauzeichen muss das erste Zeichen in diesem Tabelleneintrag sein, da es das nächste auszugebende Zeichen ist). In diesem
886
5.5 Datenkomprimierung
Beispiel muss das Vorausschauzeichen logischerweise ein A sein (das erste Zeichen in ABA). Folglich sollte sowohl der nächste Tabelleneintrag 83 als auch der Ausgabestring ABA lauten. Komprimierung Eingabe A B
A
Übereinstimmungen A Ausgabe 41
B
A B
A B A
42
81
83
B
A
B
A 80
Codewort-Tabelle Schlüssel Wert 81 AB 82 BA A B A 83
81
Dekomprimierung Eingabe 41 Ausgabe 81
A
42 B
81 A B AB BA AB?
83 ?
80 muss A B A lauten (siehe unten)
benötigt Vorausschauzeichen, um Eintrag zu vervollständigen
nächstes Zeichen in der Ausgabe: das Vorausschauzeichen!
Abbildung 5.74: LZW-Erweiterung: Knifflige Situation
Implementierung Mithilfe dieser Erläuterungen ist die Implementierung der LZW-Codierung nicht mehr schwierig, wie Algorithmus 5.11 zeigt (die Implementierung von expand() finden Sie in Listing 5.42). Die Implementierung übernimmt 8-Bit-Bytes als Eingabe (d.h., wir können jede Datei, nicht nur Strings komprimieren) und erzeugt 12-Bit-Codewörter als Ausgabe (d.h., wir erhalten aufgrund eines größeren Wörterbuchs eine höhere Komprimierung). Diese Werte werden im Code in den (final) Instanzvariablen R, L und W angegeben. Wir verwenden einen ternären Suchtrie (siehe Abschnitt 5.2) für die Codetabelle in compress() (wobei wir ausnutzen, dass Trie-Datenstrukturen effiziente Implementierungen von longestPrefixOf() unterstützen) und ein Array von Strings für die invertierte Codetabelle in expand(). Mit diesen Entscheidungen ist der Code für compress() und expand() mehr oder weniger eine zeilenweise Übersetzung der Beschreibungen im Text. Die Methoden sind in ihrer derzeitigen Form sehr effektiv, auch wenn wir sie für bestimmte Dateien noch weiter verbessern könnten, indem wir die Codewort-Tabelle leeren und nach Aufbrauchen aller Codewort-Werte jedes Mal neu füllen. Diese Verbesserungen zusammen mit den Experimenten zum Prüfen auf Effektivität können Sie in den Übungen am Ende dieses Abschnitts selber vornehmen. Wie immer lohnt es sich, die Beispiele zur LZW-Komprimierung in diesem Abschnitt und in Abbildung 5.75 sorgfältig zu studieren. In den Jahrzehnten seit seiner Entdeckung hat sich der Algorithmus als vielseitiges und effektives Verfahren zur Datenkomprimierung bewährt.
887
5
Strings
Listing 5.42: LZW-Dekomprimierung (Fortsetzung von Algorithmus 5.11) public static void expand() { String[] st = new String[L]; int i; // Nächster verfügbarer Codewort-Wert for (i = 0; i < R; i++) // Initialisiert Tabelle für Zeichen. st[i] = "" + (char) i; st[i++] = " "; // (nicht genutztes) Vorausschauzeichen für EOF int codeword = BinaryStdIn.readInt(W); String val = st[codeword]; while (true) { BinaryStdOut.write(val); // Schreibt aktuellen Teilstring. codeword = BinaryStdIn.readInt(W); if (codeword == R) break; String s = st[codeword]; // Liest nächstes Codewort. if (i == codeword) // Wenn Vorausschauzeichen ungültig ist, s = val + val.charAt(0); // erstelle Codewort aus dem letzten. if (i < L) st[i++] = val + s.charAt(0); // Fügt neuen Eintrag in Codetabelle ein. val = s; // Aktualisiert aktuelles Codewort. } BinaryStdOut.close(); }
Die Implementierung der Dekomprimierung für den Lempel-Ziv-Welch-Algorithmus ist etwas komplizierter als die Komprimierung, da wir das Vorausschauzeichen aus dem nächsten Codewort extrahieren müssen und der Fall eintreten kann, dass das Vorausschauzeichen ungültig ist (siehe Text). % java LZW - < abraLZW.txt | java LZW + ABRACADABRABRABRA % more ababLZW.txt ABABABA % java LZW - < ababLZW.txt | java LZW + ABABABA
888
5.5 Datenkomprimierung
Virus (50000 Bits)
% java Genome - < genomeVirus.txt | java PictureDump 512 25
12536 bits % java LZW - < genomeVirus.txt | java PictureDump 512 36
18232 bits
nicht so gut wie 2-Bit-Code, da sich wiederholende Daten selten sind
Bitmap (6144 Bits)
% java RunLength - < q64x96.bin | java BinaryDump 0 2296 bits % java LZW - < q64x96.bin | java BinaryDump 0 nicht so gut wie Lauflängencode, da Datei zu klein 2824 bits kompletter Text von "A Tale of Two Cities" (5812552 Bits)
% java BinaryDump 0 < tale.txt 5812552 bits % java Huffman - < tale.txt | java BinaryDump 0 3043928 bits % java LZW - < tale.txt | java BinaryDump 0 2667952 bits Komprimierungsrate 2667952/5812552 = 46% (bisher am besten) Abbildung 5.75: Verschiedene Dateien mit LZW-12-Bit-Codierung komprimieren und dekomprimieren
889
5
Strings
5.5
Fragen und Antworten
Frage: Warum BinaryStdIn und BinaryStdOut? Antwort: Es ist ein Kompromiss zwischen Effizienz und Bequemlichkeit. StdIn kann 8 Bits gleichzeitig verarbeiten; BinaryStdIn muss jedes Bit einzeln verarbeiten. Die meisten Anwendungen sind bytestromorientiert; Datenkomprimierung ist ein Sonderfall. Frage: Warum close()? Antwort: Da die Standardausgabe eigentlich ein Bytestrom ist, benötigen wir diese Methode, um BinaryStdOut mitzuteilen, wann das letzte Byte zu schreiben ist. Frage: Können wir StdIn und BinaryStdIn mischen? Antwort: Das ist keine gute Idee. Aufgrund der System- und Implementierungsabhängigkeiten gibt es keine Garantie, was passieren wird. Unsere Implementierungen werfen eine Ausnahme. Es gibt allerdings kein Problem, wenn Sie StdOut und BinaryStdOut mischen (was wir auch in unserem Code gemacht haben). Frage: Warum ist die Klasse Node in Huffman static? Antwort: Unsere Algorithmen zur Datenkomprimierung sind als Sammlungen von statischen Methoden organisiert und keine Datentypimplementierungen. Frage: Kann ich zumindest garantieren, dass mein Komprimierungsalgorithmus die Länge eines Bitstroms nicht erhöht? Antwort: Sie können ihn einfach von der Eingabe in die Ausgabe kopieren, aber Sie müssen immer noch angeben, dass Sie keine Standardkomprimierung verwenden. Kommerzielle Implementierungen geben manchmal diese Garantie, aber sie ist ziemlich schwach und weit entfernt von universeller Komprimierung. Typische Komprimierungsalgorithmen schaffen es noch nicht einmal über den zweiten Schritt unseres ersten Beweises von Satz S hinaus: Es gibt nur wenige Algorithmen, die einen Bitstring, der mit diesem Algorithmus erzeugt wurde, noch weiter komprimieren.
890
Allgemeine Übungen
5.5 1.
2.
Allgemeine Übungen Betrachten Sie die vier Codes variabler Länge in der nachfolgenden Tabelle. Welche der Codes sind präfixfrei? Eindeutig decodierbar? Geben Sie für die eindeutig decodierbaren Codes die Codierung von 1000000000000 an. Symbol
Code 1
Code 2
Code 3
Code 4
A
0
0
1
1
B
100
1
01
01
C
10
00
001
001
D
11
11
0001
000
Nennen Sie ein Beispiel für eindeutig decodierbaren Code, der nicht präfixfrei ist.
Antwort: Jeder suffixfreier Code ist eindeutig decodierbar. 3.
Nennen Sie ein Beispiel für einen eindeutig decodierbaren Code, der nicht präfix- oder suffixfrei ist.
Antwort: {0011, 011, 11, 1110} oder {01, 10, 011, 110}. 4.
Sind {01, 1001, 1011, 111, 1110} und {1, 011, 01110, 1110, 10011} eindeutig decodierbar? Wenn nicht, finden Sie einen String mit zwei Codierungen.
5.
Führen Sie RunLength (Implementierung der Lauflängencodierung) auf die Datei q128x192.bin von der Website zum Buch aus. Wie viele Bits umfasst die komprimierte Datei?
6.
Wie viele Bits werden benötigt, um N Kopien des Symbols a (als eine Funktion von N) zu kopieren? N Kopien der Sequenz abc?
7.
Wie sieht der Code für die Strings a, aa, aaa, aaaa, ... (bestehend aus N as) aus, der mit der Lauflängen-, Huffman- und LZW-Codierung erstellt wurde. Was ist die Komprimierungsrate als eine Funktion von N?
8.
Wie sieht der Code für die Strings ab, abab, ababab, abababab, ... (bestehend aus N Wiederholungen von ab) aus, der mit der Lauflängen-, Huffman- und LZW-Codierung erstellt wurde. Was ist die Komprimierungsrate als eine Funktion von N?
9.
Schätzen Sie die Komprimierungsrate, die Sie mit der Lauflängen-, Huffman- und LZW-Codierung für einen zufälligen ASCII-String der Länge N erreichen (alle Zeichen an jeder Position gleich wahrscheinlich, unabhängig voneinander).
10.
Zeigen Sie analog der Abbildung im Text den Konstruktionsprozess für den Huffman-Baum, wenn Sie den String it was the age of foolishness mit Huffman codieren. Wie viele Bits erfordert der komprimierte Bitstrom?
11.
Wie lautet der Huffman-Code für einen String, dessen Zeichen aus einem zweibuchstabigen Alphabet stammen? Geben Sie ein Beispiel an, aus dem die maximale Anzahl von Bits ersichtlich ist, die in einem Huffman-Code für einen String
891
5
Strings
verwendet werden könnte, dessen N Zeichen alle aus einem zweibuchstabigen Alphabet stammen. 12.
Angenommen, alle Symbolwahrscheinlichkeiten sind negative Zweierpotenzen. Beschreiben Sie den Huffman-Code.
13.
Angenommen, alle Symbolhäufigkeiten sind gleich. Beschreiben Sie den Huffman-Code.
14.
Angenommen, die Häufigkeiten des Auftretens aller zu codierenden Zeichen sind unterschiedlich. Ist der Huffman-Baum eindeutig?
15.
Die Huffman-Codierung könnte auf einfache Weise um die 2-Bit-Zeichencodierung erweitert werden (unter Verwendung von 4-Wege-Bäumen). Was wäre der Hauptvorteil bzw. der Hauptnachteil einer solchen Erweiterung?
16.
Wie sieht die LZW-Codierung der folgenden Eingaben aus? a. T O B E O R N O T T O B E b. Y A B B A D A B B A D A B B A D O O c. A A A A A A A A A A A A A A A A A A A A A
17.
Charakterisieren Sie die knifflige Situation, die wir weiter oben für die LZW-Codierung vorgestellt haben.
Lösung: Immer wenn LZW auf cScSc trifft, wobei c ein Symbol und S ein String ist, ist cS bereits in dem Wörterbuch, cSc hingegen nicht. 18.
Sei Fk die k-te Fibonacci-Zahl. Betrachten Sie N Symbole, wobei das k-te Symbol die Häufigkeit Fk hat. Beachten Sie, dass F1 + F2 + … + FN = FN+2 − 1. Beschreiben Sie den Huffman-Code. Hinweis: Das längste Codewort hat die Länge N − 1.
19.
Zeigen Sie, dass es mindestens 2N−1 verschiedene Huffman-Codes gibt, die einer gegebenen Menge von N Symbolen entsprechen.
20.
Geben Sie einen Huffman-Code an, in dem die Häufigkeit der 0en in der Ausgabe viel, viel höher ist als die Häufigkeit von 1en.
Antwort: Wenn das Zeichen A 1 Million Mal und das Zeichen B nur einmal auftritt, ist das Codewort für A 0 und das Codewort für B 1.
892
21.
Beweisen Sie, dass die beiden längsten Codewörter in einem Huffman-Code die gleiche Länge haben.
22.
Beweisen Sie die folgende Tatsache über Huffman-Codes: Wenn die Häufigkeit von Symbol i streng größer ist als die Häufigkeit von Symbol j, dann ist die Länge des Codeworts für Symbol i kleiner gleich der Länge des Codeworts für Symbol j.
23.
Wie würde das Ergebnis lauten, wenn ein Huffman-codierter String in 5-Bit-Zeichen zerlegt und dieser String dann Huffman-codiert wird?
24.
Zeigen Sie analog der Abbildungen im Text den Codierungs-Trie und die Komprimierungs- und Dekomprimierungsprozesse, wenn LZW für den String it was the best of times it was the worst of times verwendet wird.
Knifflige Aufgaben
5.5
Knifflige Aufgaben
25.
Code fester Länge: Implementieren Sie eine Klasse RLE, die ASCII-Byteströme mit einer Codierung fester Länge komprimiert unter Verwendung relativ weniger verschiedener Zeichen, und schließen Sie den Code als Teil des codierten Bitstroms ein. Fügen Sie Code zu compress() hinzu, um einen String alpha mit allen verschiedenen Zeichen in der Nachricht zu erstellen, und verwenden Sie ihn, um ein Alphabet zur Verwendung in compress() zu erstellen. Hängen Sie alpha (8-Bit-Codierung plus seiner Länge) an den komprimierten Bitstrom und fügen Sie dann Code zu expand() hinzu, um das Alphabet vor der Dekomprimierung zu lesen.
26.
Das LZW-Wörterbuch neu erstellen: Überarbeiten Sie LZW, um das Wörterbuch, wenn es voll ist, zu leeren, und neu zu füllen. Dieser Ansatz wird für manche Anwendungen empfohlen, da er sich im Allgemeinen besser an Änderungen in der Eingabe anpasst.
27.
Lange Wiederholungen: Schätzen Sie die Komprimierungsrate, die von der Lauflängen-, Huffman- und LZW-Codierung für einen String der Länge 2N erreicht wird, der durch Aneinanderhängen von zwei Kopien eines zufälligen ASCII-Strings der Länge N gebildet wird (Übung 5.5.9), und zwar unter allen nur denkbaren Annahmen.
893
Im Kontext
6
6
Im Kontext
Computer und computergestützte Geräte sind aus unserem Alltag nicht mehr wegzudenken. Waren sie vor ein paar Jahrzehnten noch praktisch unbekannt, so haben heute bereits Handys eine Leistung, die um Größenordnungen besser ist als die der Supercomputer von vor 30 Jahren, die nur wenigen Privilegierten zur Verfügung standen. Viele der Algorithmen, die ein effektives Funktionieren dieser Geräte ermöglichen, unterscheiden sich dabei erstaunlicherweise kaum von denen, die wir in diesem Buch besprochen haben. Warum? – Survival of the fittest. Skalierbare (lineare und leicht überlineare) Algorithmen haben in diesem Prozess eine wichtige Rolle gespielt und belegen, wie wichtig effizientes Algorithmendesign ist. Wissenschaftler der 1960er und 1970er haben den Grundstein für die Infrastruktur gelegt, deren Nutznießer wir jetzt mit diesen Algorithmen sind. Sie erkannten, dass skalierbare Algorithmen der Schlüssel für die Zukunft sind, und die Entwicklungen der letzten Jahrzehnte haben ihnen Recht gegeben. Jetzt, wo die Infrastruktur steht, fängt man an, sie für alle möglichen Zwecke zu nutzen. Und wie B. Chazelle so treffend formulierte, war das 20. Jahrhundert das Jahrhundert der Gleichung, während das 21. Jahrhundert das Jahrhundert des Algorithmus ist. Unsere Betrachtung der grundlegenden Algorithmen in diesem Buch ist nur als Ausgangspunkt gedacht. In naher Zukunft (sofern nicht schon geschehen) wird Algorithmendesign in der Universität als Hauptfach angeboten. In kommerziellen Anwendungen, wissenschaftlichem Rechnen, Maschinenbau, Operations Research (OR) und zahllosen anderen Bereichen, die zu verschieden sind, um sie hier alle zu erwähnen, können effiziente Algorithmen darüber entscheiden, ob man ein reales Problem erfolgreich löst oder kläglich daran scheitert. Unser Schwerpunkt in diesem Buch lag ausschließlich auf wichtigen und nützlichen Algorithmen. Das trifft auch für dieses Kapitel zu, d.h., wir betrachten hier Beispiele, die die Rolle der untersuchten Algorithmen (und unseren Untersuchungsansatz) in mehreren anspruchsvollen Kontexten veranschaulichen. Um Ihnen einen Eindruck von der Bedeutung der Algorithmen zu geben, stellen wir Ihnen zuerst einige wichtige Anwendungsbereiche kurz vor und werden später einige repräsentative Beispiele ausführlich behandeln, einschließlich einer Einführung in die Theorie der Algorithmen. Allerdings sollte Ihnen klar sein, dass ein solch kurzes Kapitel am Ende eines umfangreichen Buches die Themen nur an der Oberfläche und nicht in der Tiefe behandeln kann. Für jeden Anwendungsbereich, den wir hier erwähnen, gibt es Dutzende andere, die genauso umfassend sind, für jeden Punkt, den wir in einer Anwendung beschreiben, gibt es zig andere, die genauso wichtig sind, und für jedes ausführliche Beispiel gibt es Hunderte, wenn nicht sogar Tausende andere, die genauso bedeutungsvoll sind.
Kommerzielle Anwendungen Die rasante Entwicklung des Internets hat gezeigt, welche zentrale Rolle Algorithmen in kommerziellen Anwendungen spielen. Alle Anwendungen, die Sie regelmäßig nutzen, profitieren in irgendeiner Form von den klassischen Algorithmen, die wir hier untersucht haben:
896
Im Kontext
Infrastruktur (Betriebssysteme, Datenbanken, Kommunikationssysteme) Anwendungen (E-Mail, Textverarbeitung, digitale Fotografie) Verlagswesen (Bücher, Zeitschriften, Webinhalte) Netzwerke (drahtlose Netzwerke, soziale Netzwerke, Internet) Transaktionsverarbeitung (Finanzen, Handel, Websuche) Als besonders erwähnenswertes Beispiel betrachten wir in diesem Kapitel die sogenannten B-Bäume – eine bewährte Datenstruktur, die für die Standardcomputer der 1960er entwickelt wurde, aber immer noch als Basis für moderne Datenbanksysteme dient. In diesem Zusammenhang werden wir auch auf Suffixarrays zur Textindizierung eingehen.
Wissenschaftliches Rechnen Seit von Neumann 1950 Mergesort entwickelt hat, spielen Algorithmen im wissenschaftlichen Rechnen eine wichtige Rolle. Wissenschaftler werden heutzutage von Testdaten förmlich überschwemmt und verwenden deshalb mathematische sowie computergestützte Modelle, um sich die natürliche Welt zu erklären, und zwar für:
mathematische Berechnungen (Polynome, Matrizen, Differenzialgleichungen) Datenverarbeitung (Testergebnisse und Beobachtungen, speziell die Genomik) computergestützte Modelle und Simulation In all diesen Bereichen können komplexe und umfangreiche Berechnungen mit riesigen Datenmengen anfallen. Zur Veranschaulichung betrachten wir in diesem Abschnitt ein klassisches Beispiel der ereignisgesteuerten Simulation. Ziel dabei ist es, ein kompliziertes System der realen Welt in einem Modell zu erfassen und Änderungen in dem Modell über die Zeit zu verfolgen und zu steuern. Es gibt eine riesige Anzahl von Anwendungen mit diesem grundlegenden Ansatz. Außerdem betrachten wir ein grundlegendes Problem der Datenverarbeitung in der computergestützten Genomik.
Maschinenbau Moderner Maschinenbau und Technik sind untrennbar miteinander verbunden. Und da moderne Technik computerbasiert ist, spielen Algorithmen eine wichtige Rolle für
mathematische Berechnungen und Datenverarbeitung computergestütztes Design und Produktion algorithmenbasierter Maschinenbau (Netzwerke, Steuerungssysteme) Bildverarbeitung und andere medizinische Systeme Ingenieure und Wissenschaftler verwenden häufig die gleichen Werkzeuge und Ansätze. So entwickeln beispielsweise Wissenschaftler computergestützte Modelle und Simulationen, um sich die natürliche Welt zu erklären, während Ingenieure mit ihren computergestützten Modellen und Simulationen die Artefakte entwerfen, konstruieren und prüfen, die sie erzeugen.
897
6
Im Kontext
Operations Research Wissenschaftler und Fachleute im Bereich Operations Research entwickeln und nutzen mathematische Modelle zur Problemlösung, einschließlich der
Zeitplanung Entscheidungsfindung Ressourcenzuweisung Das Kürzeste-Pfade-Problem aus Abschnitt 4.4 ist ein klassisches OR-Problem. Wir greifen dieses Problem hier erneut auf und betrachten das Problem der maximalen Flüsse, veranschaulichen die Bedeutung von Reduktion und diskutieren die Auswirkungen für allgemeine Problemlösungsmodelle, speziell das Modell der linearen Programmierung, das für Operations Research von grundlegender Bedeutung ist. Algorithmen sind in einer Vielzahl von Teilbereichen der Informatik von immenser Bedeutung und werden in den folgenden (sowie vielen weiteren) Bereichen angewandt:
algorithmische Geometrie Kryptografie Datenbanken Programmiersprachen und -systeme Künstliche Intelligenz In jedem Bereich kommt es vor allem darauf an, Probleme zu artikulieren und dann effiziente Algorithmen und Datenstrukturen zu ihrer Lösung zu finden. Einige der hier untersuchten Algorithmen sind direkt einsetzbar; wichtiger jedoch ist, dass sich der diesem Buch zugrunde liegende allgemeine Ansatz, Algorithmen zu entwerfen, zu implementieren und zu analysieren, in all diesen Bereichen als erfolgreich erwiesen hat. Dieser Effekt ist nicht nur in der Informatik zu beobachten, sondern auch in vielen anderen Bereichen, von der Spieleentwicklung über Musik, Linguistik, Finanzwissenschaften bis hin zu Neurowissenschaften. Es wurden inzwischen so viele wichtige und nützliche Algorithmen entwickelt, dass die Beziehungen zwischen ihnen ebenfalls von großer Bedeutung sind. Wir beenden deshalb diesen Abschnitt (und damit dieses Buch!) mit einer Einführung in die Theorie der Algorithmen, mit besonderen Schwerpunkt auf die nicht effizient lösbaren Probleme (intractability) und der P=NP?-Frage, die der Schlüssel zum Verstehen der praktischen Probleme ist, die wir lösen wollen.
898
Im Kontext
Ereignisgesteuerte Simulation Unser erstes Beispiel ist eine grundlegende wissenschaftliche Anwendung: Wir simulieren ein System sich bewegender Teilchen, die den Gesetzen der elastischen Kollision gehorchen. Wissenschaftler versuchen, anhand solcher Systeme die Eigenschaften von physikalischen Systemen zu erklären und vorherzusagen. Dieses Paradigma umfasst die Bewegung von Molekülen in einem Gas, die Dynamik von chemischen Reaktionen, atomare Diffusion, Kugelpackungen, die Stabilität von Ringen um Planeten, die Phasenübergänge von bestimmten Elementen, eindimensionale selbstgravitierende Systeme, Frontausbreitung und viele andere Situationen. Die Anwendungen reichen von der Molekulardynamik mit winzigen subatomaren Teilchen bis hin zur Astrophysik, in der riesige Himmelskörper untersucht werden. Um dieses Problem zu lösen, bedarf es ein bisschen Oberstufenphysik, ein bisschen Software-Engineering und ein bisschen Algorithmik. Den physikalischen Teil sparen wir uns für die Übungen am Ende dieses Kapitels auf, damit wir uns hier darauf konzentrieren können, mithilfe eines algorithmischen Tools (der Heap-basierten Vorrangswarteschlangen) eine Anwendung zu entwickeln, um Berechnungen durchzuführen, die ansonsten nicht möglich wären.
Modell der festen Scheibe Wir beginnen mit einem idealisierten Modell für die Bewegung von Atomen oder Molekülen in einem Container, das die folgenden hervorstechenden Eigenschaften hat:
Bewegende Teilchen interagieren via elastischen Kollisionen miteinander und mit den Wänden.
Jedes Teilchen ist eine Scheibe mit bekannter Position, Geschwindigkeit, Masse und Radius.
Es werden keine weiteren Kräfte ausgeübt. Dieses einfache Modell spielt eine zentrale Rolle in der statistischen Mechanik – ein Gebiet, das makroskopisch beobachtbare Größen (wie Temperatur und Druck) mit mikroskopischer Dynamik (wie die Bewegung von einzelnen Atomen und Molekülen) in Beziehung bringt. Maxwell und Boltzmann verwendeten das Modell, um die Geschwindigkeitsverteilung interagierender Moleküle als Funktion der Temperatur herzuleiten. Einstein verwendete das Modell, um die Brown’sche Bewegung von Pollenteilchen in Wasser zu erklären. Die Annahme, dass keine anderen Kräfte wirken, impliziert, dass sich die Teilchen zwischen den Kollisionen mit konstanter Geschwindigkeit geradeaus bewegen. Wir könnten das Modell auch um zusätzliche Kräfte erweitern. Wenn wir zum Beispiel Reibung und Drehung hinzufügen, können wir die Bewegung von bekannten physikalischen Objekten wie Billardkugeln auf einem Pooltisch akkurater modellieren.
899
6
Im Kontext
Zeitgesteuerte Simulation Unser primäres Ziel ist einfach, dieses Modell zugrunde zu legen, d.h., wir wollen imstande sein, die Positionen und Geschwindigkeiten aller Teilchen über die Zeit zu verfolgen. Dazu müssen wir die folgende grundlegende Berechnung ausführen: Seien die Positionen und Geschwindigkeiten für eine spezifische Zeit t gegeben, dann aktualisieren Sie diese Werte nach einer bestimmten Zeit dt, um die Situation für einen zukünftigen Zeitpunkt t+dt auszudrücken. Wenn dabei die Teilchen so weit voneinander und von den Wänden entfernt sind, dass vor t+dt keine Kollision erfolgt, ist die Berechnung einfach: Da Teilchen sich auf einer geraden Bahn bewegen, aktualisieren wir ihre Positionen auf der Basis ihrer jeweiligen Geschwindigkeit. Das Problem dabei ist, die Kollisionen zu berücksichtigen. Der Ansatz der zeitgesteuerten Simulation basiert darauf, einen festen Wert für dt zu verwenden. Bei jeder Aktualisierung müssen wir für alle Teilchenpaare prüfen, ob jeweils zwei Teilchen gleichzeitig dieselbe Position innehaben, und gegebenenfalls zu dem Zeitpunkt der ersten Kollision dieser Art zurückkehren. An diesem Punkt können wir die Geschwindigkeiten der beiden Teilchen korrekt aktualisieren, um die Kollision auszudrücken (unter Verwendung von Berechnungen, die wir später besprechen). Dieser Ansatz ist sehr rechenintensiv, wenn wir eine große Zahl von Teilchen simulieren: Wenn dt in Sekunden gemessen wird (normalerweise Sekundenbruchteile), ist der Zeitaufwand proportional N 2/dt, um ein System mit N Teilchen für 1 Sekunde zu simulieren. Diese Kosten sind nicht vertretbar (noch schlechter als üblich für quadratische Algorithmen) – in den wirklich interessanten Anwendungen ist N sehr groß und dt sehr klein. Die Herausforderung ist, dass, wenn wir dt zu klein wählen, der Rechenaufwand groß ist, und wenn wir dt zu groß wählen, wir eventuell Kollisionen verpassen.
Zeit vorrücken auf t + dt
Zeit vorrücken auf t + 2dt
Zeit zurücksetzen auf den Moment der Kollision
Abbildung Simulation
6.1:
Zeitgesteuerte
dt zu klein: extrem umfangreiche Berechnungen
dt zu groß: Kollisionen werden eventuell verpasst
Abbildung 6.2: Wichtigste Herausforderung der zeitgesteuerten Simulation
Ereignisgesteuerte Simulation Wir verfolgen hier einen anderen Ansatz, der sich nur auf die Zeitpunkte konzentriert, an denen Kollisionen erfolgen. Speziell sind wir immer an der nächsten Kollision interessiert (da die einfache Aktualisierung aller Teilchenpositionen unter Verwendung ihrer Geschwindigkeiten bis zu dieser Zeit gültig ist). Deshalb richten wir eine Vorrangwarteschlange von Ereignissen für potenzielle zukünftige Kollisionen zwi-
900
Im Kontext
schen zwei Teilchen oder zwischen einem Teilchen und einer Wand ein. Die Priorität, die mit jedem Ereignis assoziiert wird, ist der Zeitpunkt der jeweiligen Kollision, sodass wir bei Entfernen des Minimums von der Vorrangwarteschlange die nächste potenzielle Kollision erhalten.
Kollisionsvorhersage Wie identifizieren wir potenzielle Kollisionen? Die Teilchengeschwindigkeiten liefern uns genau die Informationen, die wir dafür benötigen. Angenommen, wir haben zur Zeit t ein Teilchen mit Radius s an Position (rx, ry), das sich mit der Geschwindigkeit (vx, vy) in einem Einheitsquadrat bewegt. Wir gehen weiterhin von einer vertikalen Wand bei x = 1 aus, mit y zwischen 0 und 1. Unser Interesse gilt der horizontalen Komponente der Bewegung, sodass wir uns auf die x-Komponente der Position rx und der xKomponente der Geschwindigkeit vx konzentrieren können. Wenn vx negativ ist, ist das Teilchen nicht auf Kollisionskurs mit der Wand, doch wenn vx positiv ist, gibt es eine potenzielle Kollision mit der Wand. Durch Division der horizontalen Entfernung zur Wand (1 − s − rx) durch den Betrag der horizontalen Komponente der Geschwindigkeit (vx) erfahren wir, dass das Teilchen nach dt = (1 − s − rx)/vx Zeiteinheiten auf die Wand treffen wird, wenn das Teilchen sich an Position (1 − s, ry + vydt) befindet, sofern das Teilchen nicht vorher mit einem anderen Teilchen zusammenstößt oder auf eine horizontale Wand trifft. Entsprechend legen wir einen Eintrag mit der Priorität t + dt in der Vorrangwarteschlange ab (und dazugehörige Daten, die das Ereignis der Teilchen-WandKollision beschreiben). Die Kollisionsvorhersageberechnungen für die anderen Wände sehen ähnlich aus (siehe Übung 6.1). Dies gilt auch für die Berechnung der Kollision von zwei Teilchen, die jedoch etwas komplizierter ist. Beachten Sie, dass die Berechnung oft zu der Vorhersage führt, dass die Kollision nicht eintritt (falls das Teilchen sich von der Wand fortbewegt oder zwei Teilchen sich voneinander fortbewegen) – in diesen Fällen müssen wir nichts in die Vorrangwarteschlange stellen. Zur Behandlung einer anderen typischen Situation, in der die vorhergesagte Kollision zu weit in der Zukunft liegt, um von Interesse zu sein, ergänzen wir einen Parameter limit, der die zu berücksichtigende Zeitdauer angibt, sodass wir getrost alle Ereignisse ignorieren können, die laut Vorhersage später als limit eintreten. Auflösung (zur Zeit t + dt)
s
Geschwindigkeit nach Kollision = ( − vx , vy) Position nach Kollision = ( 1 − s , ry + vydt)
Vorhersage (zur Zeit t)
dt Zeit bis zum Auftreffen auf die Wand = Distanz/Geschwindigkeit (rx , ry ) = (1 − s − rx )/vx vx
Wand bei x=1 vy 1 − s − rx
Abbildung 6.2: Die Kollision von Teilchen und Wand vorhersagen und auflösen
901
6
Im Kontext
Kollisionsauflösung Wenn eine Kollision eintritt, müssen wir sie mithilfe der physikalischen Formeln auflösen, die das Verhalten eines Teilchens nach einer elastischen Kollision mit einer zurückwerfenden Begrenzung oder einem anderen Teilchen beschreiben. In unserem Beispiel, bei dem das Teilchen auf die vertikale Wand trifft, ändert sich im Moment der Kollision die Geschwindigkeit des Teilchens von (vx, vy) in (−vx, vy). Die Kollisionsauflösungsberechnungen für andere Wände sehen ähnlich aus, wie auch die Berechnungen für zwei kollidierende Teilchen, die jedoch etwas komplizierter sind (siehe Übung 6.1). Vorhersage (zur Zeit t)
Teilchen treffen sich, es sei denn, eines passiert den Kreuzungspunkt, bevor das andere dort ankommt
Auflösung (zur Zeit t + dt)
Geschwindigkeit beider Teilchen ändert sich nach der Kollision Abbildung 6.3: Die Kollision von zwei Teilchen vorhersagen und auflösen
Nicht mehr eintretende Ereignisse Teilchen bewegt Viele der von uns vorhergesagten Kollisionen erfolgen nicht, sich auf eine weil vorher eine andere Kollision eintritt. Deswegen richten wir Wand zu für jedes Teilchen eine Instanzvariable ein, die die Anzahl der Kollisionen festhält, an denen das Teilchen beteiligt ist. Wenn wir ein Ereignis zur Verarbeitung aus der Vorrangwarteschlange Teilchen sind auf entfernen, prüfen wir, ob sich die Anzahl der Kollisionen bei einem Kollisionskurs dem bzw. den beteiligten Teilchen seit der Erzeugung des EreigAbbildung 6.4: Vorhersagnisses geändert hat. Dieser Ansatz zur Behandlung nicht mehr bare Ereignisse eintretender Kollisionen ist der sogenannte Lazy-Ansatz: Wenn Teilchen bewegt ein Teilchen an einer Kollision beteiligt ist, lassen wir die davon sich von der betroffenen, jetzt nicht mehr eintretenden Ereignisse in der VorWand weg rangwarteschlange und ignorieren sie, wenn sie von der Warteschlange kommen. Ein alternativer Ansatz ist der sogenannte Eager-Ansatz, der darin besteht, alle Ereignisse, bei denen irgendein kollidierendes Teilchen betroffen ist, aus der Vorrangwarteschlange zu entfernen, bevor alle neuen potenziellen Kollisionen für das Teilchen berechnet werden. Dieser Ansatz erforTeilchen bewegen dert eine etwas anspruchsvollere Vorrangwarteschlange, die die sich voneinander weg Entfernen-Operation (remove) implementiert.
ein Teilchen erreicht den potenziellen Kollisionspunkt vor dem anderen
Abbildung 6.5: Vorhersagbare Nicht-Ereignisse
902
Kollision zu weit in der Zukunft
Im Kontext
Diese Diskussion bildet die Grundlage für eine vollständig zwei Teilchen auf Kollisionskurs ereignisgesteuerte Simulation der Teilchen in Bewegung, die nach den physikalischen Gesetzen elastischer Kollisionen interagieren. Die Software-Architektur legt nahe, die Implementierung in drei Klassen zu kapseln: ein Datentyp Particle, der die Berechnungen zu den Teilchen kapselt, drittes Teilchen stört: keine Kollision ein Datentyp Event für vorhergesagte Ereignisse und ein Client CollisionSystem, der die Simulation durchführt. Das Herzstück der Simulation ist ein MinPQ, das die Ereignisse geordnet nach Zeit enthält. Im Folgenden beschreiben wir die Implementierungen von Particle, Event und CollisionSystem. Abbildung 6.6: Ein nicht eintretendes Ereignis
Teilchen Übung 6.1 erläutert die Implementierung eines Datentyps Particle, der auf einer direkten Anwendung der Newton’schen Gesetze der Bewegung basiert. Ein Simulationsclient muss in der Lage sein, Teilchen zu bewegen, sie zu zeichnen und eine Reihe von kollisionsbezogenen Berechnungen auszuführen, wie sie in der nachfolgenden API aufgelistet werden.
Tabelle 6.1
API für Objekte sich bewegender Teilchen
public class Particle Particle()
Erzeugt ein neues Zufallsteilchen im Einheitsquadrat.
Particle(
Erzeugt ein Teilchen mit
double rx, double ry,
Position
double vx, double vy,
Geschwindigkeit
double s,
Radius
double mass)
und Masse
void
draw()
Zeichnet das Teilchen
void
move(double dt)
Ändert die Position, um die verstrichene Zeit dt auszudrücken.
count()
Anzahl der Kollisionen, die dieses Teilchen betreffen
double
timeToHit(Particle b)
Zeit, bis dieses Teilchen auf Teilchen b trifft
double
timeToHitHorizontalWall()
Zeit, bis dieses Teilchen auf eine horizontale Wand trifft
double
timeToHitVerticalWall()
Zeit, bis dieses Teilchen auf eine vertikale Wand trifft
bounceOff(Particle b)
Ändert Teilchengeschwindigkeiten, um die Kollision auszudrücken.
int
void
903
6
Im Kontext
Tabelle 6.1
API für Objekte sich bewegender Teilchen (Forts.)
public class Particle void
bounceOffHorizontalWall()
Ändert Geschwindigkeit, um das Auftreffen auf die horizontale Wand auszudrücken.
void
bounceOffVerticalWall()
Ändert Geschwindigkeit, um das Auftreffen auf die vertikale Wand auszudrücken.
Die drei timeToHit*()-Methoden liefern alle Double.POSITIVE_INFINITY für den (ziemlich häufigen) Fall zurück, dass sich das Teilchen nicht auf Kollisionskurs befindet. Diese Methoden erlauben uns, alle zukünftigen Kollisionen vorherzusagen, die ein gegebenes Teilchen betreffen, und legen dabei für jede Kollision, die vor einer gegebenen Zeit limit erfolgt, ein Ereignis in einer Vorrangwarteschlange ab. Mit der bounceOff()-Methode verarbeiten wir jedes Zusammenstoßereignis zweier Teilchen, um die Geschwindigkeiten (von beiden Teilchen) zu ändern und damit die Kollision auszudrücken; mit den bounceOff*()-Methoden verarbeiten wir Ereignisse, die Kollisionen zwischen einem Teilchen und einer Wand entsprechen.
Ereignisse Die Beschreibung der Objekte, die in die Vorrangwarteschlange gestellt werden (d.h. die Ereignisse), kapseln wir in einer private-Klasse Event. Die Instanzvariable time speichert die Zeit, zu der das Ereignis stattfinden soll, und die Instanzvariablen a und b nehmen die am Ereignis beteiligten Teilchen auf. Es gibt drei verschiedene Arten von Ereignissen: Ein Teilchen kann auf eine vertikale Wand treffen, auf eine horizontale Wand oder auf ein anderes Teilchen. Für eine glatte dynamische Anzeige der Teilchen in Bewegung fügen wir einen vierten Ereignistyp hinzu, der als Befehl zum Neuzeichnen alle Teilchen an ihren aktuellen Positionen zeichnet. Ein kleiner Kniff in der Implementierung von Event besteht darin, dass wir uns die Tatsache zunutze machen, dass die Teilchenwerte null sein können, um die folgenden vier verschiedenen Arten von Ereignissen zu codieren:
Weder a noch b sind null: Teilchen-Teilchen-Kollision a ist nicht null und b ist null: Kollision zwischen a und einer vertikalen Wand a ist null und b ist nicht null: Kollision zwischen b und einer horizontalen Wand a und b sind null: Neuzeichnen-Ereignis (zeichnet alle Teilchen) Diese Konvention ist zwar nicht beste objektorientierte Programmierung, liegt aber auf der Hand; sie ermöglicht unkomplizierten Client-Code und führt zu der Implementierung in Listing 6.1.
904
Im Kontext
private class Event implements Comparable { private final double time; private final Particle a, b; private final int countA, countB; public Event(double t, Particle a, Particle b) { // Erzeugt ein neues Ereignis zur Zeit t mit den Teilchen a und b. this.time = t; this.a = a; this.b = b; if (a != null) countA = a.count(); else countA = -1; if (b != null) countB = b.count(); else countB = -1; } public int compareTo(Event that) { if (this.time < that.time) return -1; else if (this.time > that.time) return +1; else return 0; } public boolean isValid() { if (a != null && a.count() != countA) return false; if (b != null && b.count() != countB) return false; return true; } } Listing 6.1: Die Klasse Event für die Teilchensimulation
Ein zweiter Kniff in der Implementierung von Event sind die Instanzvariablen countA und countB, die die Anzahl der Kollisionen für jedes Teilchen speichern, und zwar zu dem Zeitpunkt, an dem das Ereignis erzeugt wird. Sind diese Zahlen unverändert, wenn das Ereignis aus der Vorrangwarteschlange entfernt wird, dann können wir dazu übergehen, das Eintreten des Ereignisses zu simulieren. Wenn jedoch eine der Zahlen sich in der Zeit zwischen dem Einstellen des Ereignisses in der Vorrangwarteschlange und seinem Verlassen ändert, wissen wir, dass das Ereignis nicht eintreffen wird, und können es ignorieren. Mithilfe der Methode isValid() kann der Client-Code auf diese Bedingung prüfen.
Simulationscode Nachdem wir die Einzelheiten der Berechnung in den Klassen Particle und Event gekapselt haben, benötigt die Simulation selbst erstaunlich wenig Code, wie Sie in der Implementierung in der Klasse CollisionSystem (Listing 6.3 und Listing 6.4) sehen können. Den Großteil der Berechnungen erledigt die Methode predictCollisions() in Listing 6.2. Diese Methode berechnet alle potenziellen zukünftigen Kollisionen von Teilchen a (entweder mit einem anderen Teilchen oder mit einer Wand) und stellt für jede Kollision ein Ereignis in die Vorrangwarteschlange.
905
6
Im Kontext
private void predictCollisions(Particle a, double limit) { if (a == null) return; for (int i = 0; i < particles.length; i++) { // Stellt Kollision mit particles[i] auf pq. double dt = a.timeToHit(particles[i]); if (t + dt 3->5
Quelle
Senke addiert 1 Flusseinheit entlang 0->2->4->5
Ein physikalisches Modell Wir beginnen mit einem physikalischen Idealmodell, das einige der grundlegenden Konzepte intuitiv erkennen lässt. Stellen Sie sich beispielsweise vor, Sie haben es mit einer Reihe von verbundenen Ölpipelines unterschiedlicher Größe zu tun, die Schieber an den Verbindungsstellen enthalten, welche die Flussrichtung steuern (Abbildung 6.16). Nehmen Sie weiterhin an, dass das leitet 1 Flusseinheit von Netzwerk nur eine Quelle (z.B. ein Ölfeld) und nur eine 1->3->5 Senke (z.B. eine große Raffinerie) hat, mit der alle Pipe- nach 1->4->5 um lines letztendlich verbunden sind. An jedem Knotenpunkt erreicht das fließende Öl ein Gleichgewicht, bei dem die Menge des zufließenden Öls gleich der Menge des abfließenden Öls ist. Wir messen Fluss und Pipelinekapazität in der gleichen Einheit (z.B. Gallonen pro Sekunde). Wenn jeder Schieber die Eigenschaft hat, dass die Gesamtkapazität der eingehenden Pipelines gleich der Gesamtkapazität der ausgehenden Pipelines ist, haben wir kein Problem; wir füllen einfach alle Pipelines ergänzt 1 Flusseinheit entlang 0->2->3->5 bis zum Maximum. Im anderen Fall sind nicht alle Pipelines voll, aber das Öl fließt durch das Netzwerk, wobei der Fluss durch die Schieberstellungen an den Knotenpunkten so gesteuert wird, dass er dort eine lokale Gleichgewichtsbedingung (local equilibrium) erfüllt: Die Menge des Öls, die einem Knotenpunkt zufließt, ist gleich der Menge, die daraus abfließt. Betrachten wir dazu das Beispiel in Abbildung 6.16. Betreiber könnten für den Fluss zuerst die Schieber auf dem Pfad 0->1->3- Abbildung 6.16: Fluss zu einem Netz>5 öffnen, der 2 Flusseinheiten aufnehmen kann, dann werk hinzufügen die Schieber auf dem Pfad 0->2->4->5 öffnen, um eine weitere Flusseinheit in das Netzwerk zu schicken. Da 0->1, 2->4 und 3->5 voll sind, gibt es keinen direkten Weg, um den Fluss von 0 zu 5 zu erhöhen. Wenn wir aber den Schieber
929
6
Im Kontext
an Knotenpunkt 1 so umstellen, dass der Fluss die Pipeline 1->4 füllt, haben wir in Pipeline 3->5 genügend Kapazität, um eine weitere Flusseinheit über die Route 0->2->3->5 in das Netz zu schicken. Sogar für dieses kleine übersichtliche Netzwerk ist das Finden von Schieberstellungen, die den Fluss erhöhen, keine einfache Aufgabe. Bei komplizierten Netzwerken interessiert uns natürlich vor allem folgende Frage: Mit welchen Schieberstellungen können wir die Menge des Ölflusses von der Quelle zur Senke maximieren? Diese Situation lässt sich direkt mit einem kantengewichteten Digraphen modellieren, der nur eine Quelle und eine Senke hat. Die Kanten in dem Netzwerk entsprechen den Ölpipelines, die Knoten stellen die Verbindungsstellen mit den Schiebern dar, die steuern, wie viel Öl in die ausgehende Pipeline geleitet wird, und die Gewichte an den Kanten geben die Leitungskapazität der Pipelines an. Wir gehen davon aus, dass die Kanten gerichtet sind, das Öl in jeder Pipeline kann also nur in eine Richtung fließen. Jede Pipeline hat eine bestimmte Durchflussmenge, die entweder kleiner oder gleich ihrer Kapazität ist, und jeder Knotenpunkt erfüllt die Gleichgewichtsbedingung, dass der Zufluss gleich dem Abfluss ist. Diese Flussnetzwerk-Abstraktion ist ein nützliches Problemlösungsmodell, das sich direkt auf eine Vielzahl von Anwendungen anwenden lässt, und indirekt noch viel mehr Einsatzmöglichkeiten hat. In diesem Buch veranschaulichen wir das Grundkonzept anhand von Ölpipelines als Transportmedium, aber unsere Diskussion lässt sich genauso gut auf die Verteilung von Gütern über andere Transportwege als auch auf viele weitere Situationen übertragen. Wie bei unserer Verwendung von Distanz in den Kürzeste-Pfade-Algorithmen können wir auf jegliche physikalische Intuition verzichten, wenn wir es für angebracht halten, da alle Definitionen, Eigenschaften und Algorithmen, die wir hier betrachten, gänzlich auf einem abstrakten Modell basieren, das nicht notwendigerweise physikalischen Gesetzen gehorcht. Genau genommen gilt unser Interesse dem Netzwerkflussmodell vor allem deshalb, weil es uns ermöglicht, eine Vielzahl von anderen Problemen mittels Reduktion zu lösen, wie der nächste Abschnitt zeigt. bei jedem Knotenpunkt ist Zufluss gleich Abfluss (außer bei der Quelle und der Senke)
Abbildung 6.17: Lokales Gleichgewicht in einem Flussnetzwerk Standardgrafik
tinyFN.txt
V
Grafik mit Kapazitäten
Quelle 6 8 0 0 1 1 2 2 3 4
2.0 3.0 3.0 1.0 1.0 1.0 2.0 3.0
Kapazitäten
Senke
Abbildung 6.18: Analyse des Netzwerkflussproblems
930
Flussrepräsentation 0 0 1 1 2 2 3 4
E 1 2 3 4 3 4 5 5
Grafik mit Fluss
1 2 3 4 3 4 5 5
2.0 3.0 3.0 1.0 1.0 1.0 2.0 3.0
2.0 1.0 2.0 0.0 0.0 1.0 2.0 1.0
Flusswert, der mit jeder Kante assoziiert wird
Im Kontext
Definitionen Aufgrund dieses breiten Anwendungsbereichs wollen wir die bisher formlos eingeführten Begriffe und Konzepte näher definieren.
Definition: Flussnetzwerk Ein Flussnetzwerk (flow network ) ist ein kantengewichteter Digraph mit positiven Kantengewichten (die wir als Kapazitäten bezeichnen). Ein s-t-Flussnetzwerk hat zwei identifizierte Knoten: eine Quelle s und eine Senke t.
Manchmal sprechen wir davon, dass Kanten eine unendliche Kapazität haben. Das bedeutet zum Beispiel, dass wir bei diesen Kanten den Fluss nicht mit der Kapazität gleichsetzen oder dass wir einen Wächterwert verwenden, der garantiert größer ist als jeder Flusswert. Wir bezeichnen den Gesamtfluss in einen Knoten hinein als den Knotenzufluss (inflow, d.h. die Summe der Flüsse entlang seiner eingehenden Kanten), den Gesamtfluss aus einem Knoten heraus als den Knotenabfluss (outflow, d.h. die Summe der Flüsse entlang seiner ausgehenden Kanten) und die Differenz zwischen den beiden (Zufluss minus Abfluss) als den Nettofluss (netflow) des Knotens. Um die Diskussion zu vereinfachen, gehen wir außerdem davon aus, dass es keine Kanten gibt, die aus t heraus- oder in s hineinführen.
Definition: s-t-Fluss Ein s-t-Fluss in einem s-t-Flussnetzwerk ist eine Menge von nicht-negativen Werten, assoziiert mit jeweils einer Kante, die wir als Kantenflüsse (edge flow ) bezeichnen. Wir sagen, ein Fluss ist möglich, wenn die Bedingung erfüllt ist, dass der Fluss keiner Kante größer ist als ihre Kapazität, und wenn gemäß der lokalen Gleichgewichtsbedingung der Nettofluss von jedem Knoten null ist (ausgenommen s und t).
Wir bezeichnen den Zufluss zur Senke als den s-t-Flusswert. Satz E wird zeigen, dass dieser Wert gleich dem Abfluss aus der Quelle ist. Mit diesen Definitionen ist die formelle Erklärung unseres Hauptproblems einfach: Maximaler s-t-Fluss: Finde für ein gegebenes s-t-Flussnetzwerk einen s-t-Fluss, sodass kein anderer Fluss von s nach t einen größeren Wert hat.
Der Kürze halber bezeichnen wir einen solchen Fluss als Max-Fluss und das Problem, einen solchen in einem Netzwerk zu finden, als Max-Fluss-Problem. In einigen Anwendungen reicht es uns unter Umständen, nur den Wert des maximalen Flusses zu kennen, aber im Allgemeinen wollen wir wissen, mit welchem Fluss (d.h. mit welchen Kantenflusswerten) wir diesen Wert erreichen.
931
6
Im Kontext
APIs Die APIs FlowEdge und FlowNetwork in Tabelle 6.4 und Tabelle 6.5 sind einfache Erweiterungen der APIs aus Kapitel 4. Wir werden in Listing 6.11 eine Implementierung von FlowEdge betrachten, die auf einer zusätzlichen Instanzvariablen basiert, in der wir den Fluss zu unserer Edge-Klasse aus Kapitel 4 (Listing 4.20) speichern. Flüsse haben eine Richtung; dennoch leiten wir FlowEdge nicht von DirectedEdge ab, da wir mit der allgemeineren Abstraktion eines Restnetzwerkes (residual network) arbeiten, das nachfolgend beschrieben wird; außerdem muss jede Kante in den Adjazenzlisten beider seiner Knoten stehen, um das Restnetzwerk zu implementieren. Das Restnetzwerk erlaubt uns, Fluss zu addieren und zu subtrahieren sowie zu testen, ob eine Kante von der Kapazität her ausgelastet ist (es kann kein Fluss hinzugefügt werden) oder ob sie leer ist (es kann kein Fluss subtrahiert werden). Diese Abstraktion wird mit den Methoden residualCapacity() und addResidualFlow() implementiert, die wir später besprechen. Die Implementierung von FlowNetwork ist praktisch identisch zu unserer EdgeWeightedGraph-Implementierung in Listing 4.21, sodass wir hier nicht näher darauf eingehen. Um das Dateiformat zu vereinfachen, halten wir uns an die Konvention, 0 für die Quelle und V-1 für die Senke zu verwenden. Diese APIs lassen direkt erkennen, worauf die MaxFluss-Algorithmen abzielen: ein Netzwerk erstellen und dann den flow-Instanzvariablen in den Client-Kanten Werte zuweisen, die den Fluss durch das Netzwerk maximieren. In Listing 6.10 finden Sie die Client-Methoden, die prüfen, ob ein Fluss möglich ist. In der Regel erledigen wir diese Aufgabe am Ende eines Max-Fluss-Algorithmus. private boolean localEq(FlowNetwork G, int v) { // Prüft lokales Gleichgewicht am Knoten v. double EPSILON = 1E-11; double netflow = 0.0; for (FlowEdge e : G.adj(v)) if (v == e.from()) netflow -= e.flow(); else netflow += e.flow(); return Math.abs(netflow) < EPSILON; } private boolean isFeasible(FlowNetwork G) { // Prüft, ob der Fluss entlang jeder Kante nicht negativ // und nicht größer als die Kapazität ist. for (int v = 0; v < G.V(); v++) for (FlowEdge e : G.adj(v)) if (e.flow() < 0 || e.flow() > e.capacity()) return false; // Prüft lokales Gleichgewicht an jedem Knoten. for (int v = 0; v < G.V(); v++) if (v !=s && v != t && !localEq(v)) return false; return true; } Listing 6.10: Prüft, ob ein Fluss in einem Flussnetzwerk möglich ist.
932
Im Kontext
Tabelle 6.4
API für Kanten in einem Flussnetzwerk
public class FlowEdge FlowEdge(int v, int w, double cap) int
from()
Knoten, von dem diese Kante ausgeht.
int
to()
Knoten, auf den diese Kante zeigt.
int
other(int v)
Anderer Endpunkt
double
capacity()
Kapazität dieser Kante
double
flow()
Fluss entlang dieser Kante
double
residualCapacityTo(int v)
Restkapazität in Richtung v
double
addFlowTo(int v, double delta)
Fügt delta-Fluss in Richtung v hinzu.
String
toString()
String-Repräsentation
Tabelle 6.5
Flussnetzwerk-API
public class FlowNetwork FlowNetwork(int V)
Leeres Flussnetzwerk aus V Knoten
FlowNetwork(In in)
Erstellt aus einem Eingabestrom.
int
V()
Anzahl der Knoten
int
E()
Anzahl der Kanten
addEdge(FlowEdge e)
Fügt e zu diesem Flussnetzwerk hinzu.
Iterable
adj(int v)
Kanten, die von v ausgehen
Iterable
edges()
Alle Kanten in diesem Flussnetzwerk
toString()
String-Repräsentation
void
String
tinyFN.txt
V 6 8 0 0 1 1 2 2 3 4
E 1 2 3 4 3 4 5 5
2.0 3.0 3.0 1.0 1.0 1.0 2.0 3.0
Referenzen auf das gleiche FlowEdge-Objekt
0 2 3.0 1.0
0 1 2.0 2.0
1 4 1.0 0.0
1 3 3.0 2.0
0 1 2.0 2.0
2 4 1.0 1.0
2 3 1.0 0.0
0 2 3.0 1.0
3 5 2.0 2.0
2 3 1.0 0.0
1 3 3.0 2.0
4 5 3.0 1.0
2 4 1.0 1.0
1 4 1.0 0.0
4 5 3.0 1.0
3 5 2.0 2.0
adj[] 0 1 2 3 4 5
BagObjekte
Abbildung 6.19: Flussnetzwerkrepräsentation
933
6
Im Kontext
Ford-Fulkerson-Algorithmus L. R. Ford und D. R Fulkerson entwickelten 1962 einen kein Pfad von 0 zu 5 ohne eine volle Kante effektiven Ansatz zur Lösung von Max-Fluss-Problemen. Dieses generische Verfahren zur schrittweisen Erhöhung der Flüsse entlang der Pfade von der Quelle zur Senke bildete die Grundlage für eine ganze Familie von Algorithmen und wird in der klassischen Literatur als Ford-FulkersonAlgorithmus bezeichnet. Anschaulich und weit verbreitet ist aber auch die Bezeichnung Algorithmus der augmentierenden Pfade (augmenting-path algorithm). Betrachten wir beispielsweise einen beliebigen gerichteten Pfad von der Quelle zur Senke in einem s-t-Flussnetzwerk. Sei x das addiert 1 Flusseinheit entlang der Minimum der ungenutzten Kapazitäten der Kanten auf Kanten 0->2->3 dem Pfad. Wir können den Flusswert des Netzwerkes um mindestens x erhöhen, indem wir den Fluss entlang aller Kanten auf dem Pfad um diesen Betrag erhöhen. Mehrmaliges Ausführen dieser Aktion ist ein erster Versuch zur Flussberechnung in einem Netzwerk: Finde einen weiteren Pfad, erhöhe den Fluss entlang dieses Pfades und fahre aus dem damit fort, bis alle Pfade von der Quelle zur Senke mindes- Gleichgewicht tens eine volle Kante aufweisen (und wir deshalb den Fluss subtrahiert 1 Flussso nicht weiter erhöhen können). Dieser Algorithmus wird einheit von 1->3 (die dann den Pfad den maximalen Fluss in einigen Fällen erfolgreich berech- entlang 3->1 nen, in anderen jedoch nicht. Unser einführendes Beispiel traversiert) aus dem in Abbildung 6.16 ist ein solches Beispiel. Um den AlgoGleichgewicht rithmus so zu verbessern, dass er immer einen maximalen Fluss findet, wählen wir einen allgemeineren Weg zur Erhöhung des Flusses entlang eines Pfades von der Quelle zur Senke, und zwar mittels eines ungerichteten Graphen, der dem Netzwerk zugrunde liegt. Die Kanten auf einem solchen Pfad sind entweder Vorwärtskanten (forward 1 Flussedges), die in Richtung des Flusses gerichtet sind (d.h., addiert einheit entlang der wenn wir den Pfad von der Quelle zur Senke traversieren, Kante 1->4->5 traversieren wir die Kante von ihrem Startknoten zu ihrem Zielknoten), oder Rückwärtskanten (backward edges), die gegen den Fluss gerichtet sind (d.h., wenn wir den Pfad von der Quelle zur Senke traversieren, traversieren wir die Kante von ihrem Zielknoten zu ihrem Startknoten). Wir können also für jeden Pfad ohne volle Vorwärtskanten und ohne leere Rückwärtskanten den Betrag der Flussmenge im Netzwerk erhöhen, indem wir den Fluss in den Vorwärts- Abbildung 6.20: Ein augmentierender Pfad
934
Im Kontext
kanten erhöhen und in den Rückwärtskanten reduzieren. Der Betrag, um den der Fluss erhöht werden kann, ist durch das Minimum der ungenutzten Kapazitäten in den Vorwärtskanten und den Flüssen in den Rückwärtskanten beschränkt. Solch ein Pfad heißt auch augmentierender Pfad (augmenting path). Ein Beispiel finden Sie in Abbildung 6.20. Im neuen Fluss wird mindestens eine der Vorwärtskanten auf dem Pfad voll oder mindestens eine der Rückwärtskanten auf dem Pfad leer. Der hier beschriebene Prozess bildet die Basis für den klassischen Ford-Fulkerson-Algorithmus zur Bestimmung des maximalen Flusses. Er lässt sich wie folgt zusammenfassen:
Ford-Fulkerson-Algorithmus zur Bestimmung des maximalen Flusses Wir starten mit einem Fluss, der überall null ist, und erhöhen dann den Fluss entlang eines jeden augmentierenden Pfades von der Quelle zur Senke (ohne volle Vorwärtskanten oder leere Rückwärtskanten) und zwar so lange, bis es keine solche Pfade mehr im Netzwerk gibt.
Dieses Verfahren findet erstaunlicherweise (unter bestimmten technischen Bedingungen über die numerischen Eigenschaften des Flusses) immer einen maximalen Fluss, egal wie die Pfade gewählt wurden. Entsprechend dem Greedy-Algorithmus für minimale Spannbäume aus Abschnitt 4.3 und dem generischen Kürzeste-Pfade-Verfahren aus Abschnitt 4.4 ist dieses Verfahren ein generischer Algorithmus, der vor allem deshalb so nützlich ist, weil er die Korrektheit einer ganzen Familie von spezifischeren Algorithmen belegt. Wir können jedes beliebige Verfahren für die Wahl des Pfades verwenden. Es wurden mehrere Algorithmen entwickelt, die die Folge der augmentierenden Pfade berechnen, und alle führen zu einem maximalen Fluss. Die Algorithmen unterscheiden sich in der Anzahl der augmentierenden Pfade, die sie berechnen, sowie den Kosten, um jeden dieser Pfade zu finden; doch alle Verfahren implementieren den Ford-Fulkerson-Algorithmus und finden einen maximalen Fluss.
Max-Fluss-Min-Schnitt-Theorem Um zu zeigen, dass jeder Fluss, der von einer beliebigen Implementierung des FordFulkerson-Algorithmus berechnet wurde, tatsächlich ein maximaler Fluss ist, beweisen wir einen wichtigen Punkt, der als Max-Fluss-Min-Schnitt-Theorem (maxflowmincut theorem) bekannt ist. Dieses Theorem zu verstehen, ist ein wichtiger Schritt zu einem besseren Verständnis der Netzwerkflussalgorithmen. Wie der Name schon verrät, basiert das Theorem auf einer direkten Beziehung zwischen Flüssen und Schnitten in Netzwerken, sodass wir zuerst einmal Begriffe in Bezug auf Schnitte definieren möchten. Aus Abschnitt 4.3 wissen wir, dass ein Schnitt (cut) in einem Graphen die Knoten in zwei disjunkte Teilmengen trennt und eine kreuzende Kante einen Knoten in der einer Menge mit einem Knoten in der anderen Menge verbindet. Für Flussnetzwerke möchten wir diese Definitionen noch etwas präzisieren.
935
6
Im Kontext
Definition: s-t-Schnitt Ein s-t-Schnitt teilt den Graphen so, dass der Knoten s in der einen Menge und der Knoten t in der anderen Menge ist.
Jede kreuzende Kante, die einem s-t-Schnitt entspricht, ist entweder eine s-t-Kante, die von einem Knoten der Menge, die s enthält, zu einem Knoten der Menge, die t enthält, führt oder eine t-s-Kante, die in die andere Richtung führt. Die Kapazität eines st-Schnitts in einem Flussnetzwerk ist die Summe der Kapazitäten von den s-t-Kanten dieses Schnitts. Der Fluss über einen s-t-Schnitt ist die Differenz zwischen der Summe der Flüsse entlang der s-t-Kanten dieses Schnitts und der Summe der Flüsse entlang der t-s-Kanten dieses Schnitts. Das Entfernen aller s-t-Kanten in einem s-t-Schnitt eines Netzwerks hat zur Folge, dass es keinen Pfad mehr von s zu t gibt; fügen wir jedoch eine dieser Kanten wieder hinzu, dann können wir damit einen solchen Pfad erzeugen. Schnitte sind für viele Anwendungen die Abstraktion der Wahl. Bei unserem Ölflussmodell könnte ein Schnitt zum Beispiel den Fluss des Öls von der Quelle zur Senke komplett unterbrechen. Wenn wir davon ausgehen, dass die Kapazität des Schnitts die Kosten repräsentiert, wäre die kostenoptimale Unterbrechung des Flusses gleich der Lösung des folgenden Problems. Minimaler s-t-Schnitt: Gegeben sei ein s-t-Netzwerk. Finde einen s-t-Schnitt, für dessen Kapazität gilt, dass die Kapazität keines anderen Schnitts geringer ist. Der Kürze halber bezeichnen wir einen solchen Schnitt als Min-Schnitt und die Schwierigkeit, einen solchen Schnitt in einem Netzwerk zu finden, als Min-Schnitt-Problem.
In der Darstellung des Min-Schnitt-Problems werden keine Flüsse erwähnt; vielmehr könnte man den Eindruck gewinnen, dass diese Definitionen uns von unserer Diskussion des Algorithmus der augmentierenden Pfade abbringen. Oberflächlich betrachtet scheint die Berechnung eines Min-Schnitts (eine Menge von Kanten) leichter als die Berechnung eines Max-Flusses (eine Zuordnung von Gewichten zu all den Kanten). Dabei sind die Max-Fluss- und Min-Schnitt-Probleme eng miteinander verwandt. Ein Beweis hierfür ist das Verfahren des augmentierenden Pfades selbst. Dieser Beweis basiert auf der grundlegenden Beziehung zwischen Flüssen und Schnitten, die unmittelbar den Beweis liefert, dass lokales Gleichgewicht in einem s-t-Fluss automatisch globales Gleichgewicht impliziert (erstes Korollar) sowie eine obere Schranke für den Wert eines jeden s-t-Flusses (zweites Korollar).
Satz E: Für jeden s-t-Fluss ist der Fluss über jeden s-t-Schnitt gleich dem Wert des Flusses. Beweis: Sei Cs die Knotenmenge, die s enthält, und Ct die Knotenmenge, die t enthält. Dies ergibt sich unmittelbar durch Induktion über die Größe von Ct. Die Eigenschaft ist laut Definition wahr, wenn Ct gleich t ist und wenn ein Knoten von Cs zu Ct verschoben wird; lokales Gleichgewicht an diesem Knoten impliziert, dass die angegebene Eigenschaft gewahrt bleibt. Jeder s-tSchnitt kann erzeugt werden, indem man die Knoten auf diese Weise verschiebt.
936
Im Kontext
Korollar: Der Abfluss aus s ist gleich dem Zufluss zu t (der Wert des s-t-Flusses). Beweis: Sei Cs gleich {s}.
s
Cs Differenz zwischen Zufluss und Abfluss ist der Fluss über den Schnitt
Ct t
Zufluss zu t ist der Wert des Flusses
Korollar: Der Wert keines s-t-Flusses kann die Kapazität irgendeines s-t-Schnitts übertreffen.
Satz F (Max-Fluss-Min-Schnitt-Theorem): Sei f ein s-t-Fluss. Die folgenden drei Bedingungen sind äquivalent: a
Es gibt einen s-t-Schnitt, dessen Kapazität gleich dem Wert des Flusses f ist.
b
f ist ein Max-Fluss.
c
Es gibt keinen augmentierenden Pfad hinsichtlich f.
Beweis: Bedingung (a) impliziert Bedingung (b) aufgrund des Korollars zu Satz E. Bedingung (b) impliziert Bedingung (c), da die Existenz eines augmentierenden Pfades die Existenz eines Flusses mit einem größeren Flusswert impliziert, was der Maximalität von f widerspricht. Es bleibt zu beweisen, dass Bedingung (c) Bedingung (a) impliziert. Sei Cs die Menge aller Knoten, die von s aus über einen ungerichteten Pfad erreicht werden können, der keine volle Vorwärtskante oder leere Rückwärtskante enthält, und seien Ct die übrigen Knoten. Dann muss t in Ct sein, sodass (Cs , Ct) ein s-t-Schnitt ist, dessen Menge von s-t-Kanten gänzlich aus vollen Vorwärtskanten oder leeren Rückwärtskanten besteht. Der Fluss über diesen Schnitt ist gleich der Kapazität dieses Schnitts (da die Vorwärtskanten voll und die Rückwärtskanten leer sind) sowie gleich dem Wert des Netzwerkflusses (gemäß Satz E ).
937
6
Im Kontext
Korollar (Eigenschaft für ganze Zahlen): Wenn die Kapazitäten ganze Zahlen sind, gibt es einen ganzzahligen Max-Fluss und der Ford-Fulkerson-Algorithmus findet ihn. Beweis: Jeder augmentierende Pfad erhöht den Fluss um eine positive ganze Zahl (das Minimum der ungenutzten Kapazitäten in den Vorwärtskanten und die Flüsse in den Rückwärtskanten, die alle immer positive ganze Zahlen sind).
Es ist möglich, nicht ganzzahlige maximale Flüsse zu entwerfen, auch wenn die Kapazitäten alle ganzzahlig sind; doch wir brauchen solche Flüsse nicht zu berücksichtigen. Theoretisch ist folgende Beobachtung wichtig: Das Zulassen von reellwertigen Kapazitäten und Flüssen, wie wir es gemacht haben und es gängige Praxis ist, kann zu unangenehmen, anomalen Situationen führen. So ist beispielsweise bekannt, dass der Ford-Fulkerson-Algorithmus im Prinzip zu einer unendlichen Folge von augmentierenden Pfaden führen könnte, die nicht einmal gegen den Max-Fluss-Wert konvergiert. Die Version des Algorithmus, die wir betrachten, konvergiert bekanntermaßen immer, sogar wenn wir für die Kapazitäten und Flüsse reelle Werte verwenden. Egal welches Verfahren wir wählen, um einen augmentierenden Pfad zu finden, und egal welche Pfade wir finden, wir erhalten am Ende immer einen Fluss, der keinen augmentierenden Pfad zulässt und deshalb ein maximaler Fluss sein muss.
Restnetzwerk Der generische Ford-Fulkerson-Algorithmus spezifiziert kein bestimmtes Verfahren für die Suche nach einem augmentierenden Pfad. Wie also finden wir einen Pfad ohne volle Vorwärtskanten und leere Rückwärtskanten? Dazu beginnen wir mit der folgenden Definition.
Definition: Restnetzwerk Für ein gegebenes s-t-Flussnetzwerk und einen s-t-Fluss hat das Restnetzwerk (residual network ) für den Fluss die gleichen Knoten wie das Original und für jede Kante in dem Original ein oder zwei Kanten, die wie folgt definiert sind: Für jede Kante e von v zu w in dem Original sei fe ihr Fluss und ce ihre Kapazität. Wenn fe positiv ist, wird eine Kante w->v mit der Kapazität fe in das Restnetzwerk mit aufgenommen; und wenn fe kleiner ist als ce, wird eine Kante v->w mit der Kapazität ce − fe in das Restnetzwerk mit aufgenommen.
938
Im Kontext
Wenn eine Kante e von v zu w leer ist (d.h. fe ist gleich 0), gibt es eine einzelne entsprechende Kante v->w mit der Kapazität ce in dem Restnetzwerk; wenn sie voll ist (d.h., fe ist gleich ce), gibt es eine einzelne entsprechende Kante w->v mit der Kapazität fe in dem Restnetzwerk; und wenn sie weder leer noch voll ist, sind beide, v->w und w->v, mit ihren jeweiligen Kapazitäten in dem Restnetzwerk. Ein Beispiel finden Sie in Abbildung 6.21. Anfangs ist die Repräsentation des Restnetzwerkes ein wenig verwirrend, da die Kanten, die dem Fluss entsprechen, in die entgegengesetzte Richtung zeigen wie der Fluss selbst. Die Vorwärtskanten repräsentieren die verbleibende Kapazität (die Flussmenge, die wir addieren können, wenn wir diese Kante traversieren); die Rückwärtskanten repräsentieren den Fluss (die Flussmenge, die wir entfernen können, wenn wir diese Kante traversieren). Der Code in Listing 6.11 enthält die Methoden der Klasse FlowEdge, die wir benötigen, um die Restnetzwerk-Abstraktion zu implementieren. Dank dieser Implementierungen arbeiten unsere Algorithmen mit dem Restnetzwerk, aber eigentlich untersuchen sie Kapazitäten und ändern den Fluss (über Kantenreferenzen) in den Kanten des Clients. Die Methoden from() und other() erlauben uns, die Kanten in beide Richtungen zu verarbeiten: e.other(v) liefert den Endpunkt von e zurück, der nicht v ist. Die Methoden residualCapacityTo() und addResidualFlowTo() implementieren das Restnetzwerk. Restnetzwerke erlauben uns, mithilfe der Graphensuche einen augmentierenden Pfad zu finden, da jeder Pfad von der Quelle zur Senke im Restnetzwerk direkt einem augmentierenden Pfad im Originalnetzwerk entspricht. Das Erhöhen des Flusses entlang des Pfades impliziert Änderungen am Restnetzwerk: So ist zum Beispiel mindestens eine Kante auf dem Pfad am Ende voll oder leer, d.h., mindestens eine Kante in dem Restnetzwerk ändert seine Richtung oder verschwindet (allerdings zeigt unsere Verwendung eines abstrakten Restnetzwerkes, dass wir nur auf positive Kapazität prüfen und deshalb nicht wirklich Kanten einfügen und löschen müssen). Grafik mit Fluss
Flussrepräsentation 0 0 1 1 2 2 3 4
1 2 3 4 3 4 5 5
2.0 3.0 3.0 1.0 1.0 1.0 2.0 3.0
Kapazität
2.0 1.0 2.0 0.0 0.0 1.0 2.0 1.0
Fluss
Restnetzwerk
1.0
2.0
Rückwärtskante (Fluss)
2.0
1.0
2.0
1.0
1.0
1.0 1.0 2.0
2.0
Vorwärtskante (verbleibende Kapazität) Abbildung 6.21: Analyse des Netzflussproblems (erneut)
939
6
Im Kontext
Listing 6.11: Datentyp FlowEdge (Restnetzwerk) public class FlowEdge { private final int v; private final int w; private final double capacity; private double flow;
// // // //
Kantenquelle Kantenziel Kapazität Fluss
public FlowEdge(int v, int w, double capacity) { this.v = v; this.w = w; this.capacity = capacity; this.flow = 0.0; } public public public public
int from() int to() double capacity() double flow()
{ { { {
return return return return
v; w; capacity; flow;
} } } }
public int other(int vertex) // wie für Edge public double residualCapacityTo(int vertex) { if (vertex == v) return flow; else if (vertex == w) return capacity - flow; else throw new RuntimeException("Inconsistent edge"); } public void addResidualFlowTo(int vertex, double delta) { if (vertex == v) flow -= delta; else if (vertex == w) flow += delta; else throw new RuntimeException("Inconsistent edge"); } public String toString() { return String.format("%d->%d %.2f %.2f", v, w, capacity, flow);
}
}
Diese FlowEdge-Implementierung fügt zu der gewichteten DirectedEdge-Implementierung aus Abschnitt 4.4 eine Instanzvariable flow und zwei Methoden hinzu, um das Restnetzwerk zu implementieren.
940
Im Kontext
Kürzester augmentierender Pfad Die vielleicht einfachste Ford-Fulkerson-Implementierung basiert auf dem kürzesten augmentierenden Pfad (gemessen nach Anzahl der Kanten im Pfad und nicht nach Fluss oder Kapazität). Dieses Verfahren wurde 1972 von J. Edmonds und R. Karp vorgeschlagen. Die Suche nach einem augmentierenden Pfad läuft in diesem Fall in dem Restnetzwerk auf eine Breitensuche (BFS) hinaus, wie wir sie in Abschnitt 4.1 beschrieben haben. Das können Sie feststellen, wenn Sie die Implementierung von hasAugmentingPath() in Listing 6.12 mit unserer Implementierung der Breitensuche in Algorithmus 4.2 (Listing 4.6) vergleichen (der Restgraph ist ein Digraph und dieses Verfahren ist im Wesentlichen ein digraphverarbeitender Algorithmus). Dieses Verfahren bildet die Grundlage für die vollständige Implementierung in Algorithmus 6.3 in Listing 6.13 – eine bemerkenswert gut strukturierte, kurze Implementierung auf der Basis der Tools, die wir entwickelt haben. Der Kürze halber bezeichnen wir dieses Verfahren als den Max-Fluss-Algorithmus mit dem kürzesten augmentierenden Pfad. Ein Ablaufprotokoll für unser Beispiel finden Sie in Abbildung 6.22. private boolean hasAugmentingPath(FlowNetwork G, int s, int t) { marked = new boolean[G.V()]; // Ist der Pfad zu diesem Knoten bekannt? edgeTo = new FlowEdge[G.V()]; // Letzte Kante auf dem Pfad Queue q = new Queue(); marked[s] = true; // Markiert die Quelle q.enqueue(s); // und stellt sie in die Warteschlange. while (!q.isEmpty()) { int v = q.dequeue(); for (FlowEdge e : G.adj(v)) { int w = e.other(v); if (e.residualCapacityTo(w) > 0 && !marked[w]) { // Für jede Kante zu einem unmarkierten Knoten (im Restnetzwerk) edgeTo[w] = e; // Speichert die letzte Kante auf einem Pfad. marked[w] = true; // Markiert w, da ein Pfad bekannt ist, q.enqueue(w); // und fügt w zur Warteschlange hinzu. } } } return marked[t]; } Listing 6.12: Findet mittels Breitensuche einen augmentierenden Pfad im Restnetzwerk
941
6
Im Kontext
Listing 6.13: Ford-Fulkerson-Max-Fluss-Algorithmus mit kürzestem augmentierendem Pfad (Algorithmus 6.3) public class FordFulkerson { private boolean[] marked; private FlowEdge[] edgeTo; private double value;
// Ist s->v-Pfad im Restgraphen? // Letzte Kante auf kürzestem s->v-Pfad // Aktueller Wert von Max-Fluss
public FordFulkerson(FlowNetwork G, int s, int t) { // Finde Max-Fluss in Flussnetzwerk G von s zu t. while (hasAugmentingPath(G, s, t)) { // Solange ein augmentierender Pfad besteht, nutze ihn. // Berechne Engpasskapazität. double bottle = Double.POSITIVE_INFINITY; for (int v = t; v != s; v = edgeTo[v].other(v)) bottle = Math.min(bottle, edgeTo[v].residualCapacityTo(v)); // Augmentiere den Fluss. for (int v = t; v != s; v = edgeTo[v].other(v)) edgeTo[v].addResidualFlowTo(v, bottle); value += bottle; } } public double value() public boolean inCut(int v)
{ {
return value; return marked[v];
} }
public static void main(String[] args) { FlowNetwork G = new FlowNetwork(new In(args[0])); int s = 0, t = G.V() - 1; FordFulkerson maxflow = new FordFulkerson(G, s, t); StdOut.println("Max flow from " + s + " to " + t); for (int v = 0; v < G.V(); v++) for (FlowEdge e : G.adj(v)) if ((v == e.from()) && e.flow() > 0) StdOut.println(" " + e); StdOut.println("Max flow value = " + maxflow.value()); } }
Diese Implementierung des Ford-Fulkerson-Algorithmus findet den kürzesten augmentierenden Pfad im Restnetzwerk, ermittelt die Kapazität der Engpassstelle auf diesem Pfad und augmentiert den Fluss entlang dieses Pfades, und zwar so lange, bis es keinen augmentierenden Pfad mehr von der Quelle zur Senke gibt.
942
Im Kontext
anfänglich leeres Netzwerk
Restnetzwerk 2.0 3.0
3.0
1.0
1.0
1.0
2.0
3.0
addiert 2 Flusseinheiten entlang 0->1->3->5 2.0 3.0
1.0
2.0
1.0
1.0
1.0
2.0
3.0
addiert 1 Flusseinheit entlang 0->2->4->5 1.0
2.0 2.0
1.0
2.0
1.0
1.0
1.0 1.0 2.0
addiert 1 Flusseinheit entlang 0->2->3->1->4->5
2.0
s-t-Schnitt 2.0
2.0 1.0
2.0
1.0
1.0
1.0
1.0 2.0 2.0
1.0
Abbildung 6.22: Ablaufprotokoll des Ford-Fulkerson-Algorithmus des augmentierenden Pfades
943
6
Im Kontext
% java FordFulkerson tinyFN.txt Max flow from 0 to 5 0->2 3.0 2.0 0->1 2.0 2.0 1->4 1.0 1.0 1->3 3.0 1.0 2->3 1.0 1.0 2->4 1.0 1.0 3->5 2.0 2.0 4->5 3.0 2.0 Max flow value = 4.0
Abbildung 6.23: Kürzeste augmentierende Pfade in einem größeren Flussnetzwerk
Performance Ein größeres Beispiel finden Sie in Abbildung 6.23. Wie Sie der Abbildung entnehmen können, bilden die Längen der augmentierenden Pfade eine nicht abnehmende Sequenz. Diese Tatsache ist ein erster Schlüssel zur Analyse der Algorithmus-Performance.
Satz G: Die Anzahl der augmentierenden Pfade, die in der Kürzeste-augmentierende-Pfad-Implementierung des Max-Fluss-Algorithmus nach Ford-Fulkerson für ein Flussnetzwerk mit V Knoten und E Kanten benötigt wird, beträgt höchstens EV/2. Beweisskizze: Jeder augmentierende Pfad hat eine kritische Kante, d.h. eine Kante, die aus dem Restnetzwerk gelöscht wird, da sie entweder einer Vorwärtskante entspricht, die bis zur Kapazitätsgrenze gefüllt wird, oder einer Rückwärtskante, die geleert wird. Jedes Mal, wenn eine Kante eine kritische Kante ist, muss sich die Länge des durch diese Kante laufenden augmentierenden Pfades um 2 erhöhen (siehe Übung 6.39 ). Da ein augmentierender Pfad höchstens die Länge V hat, kann es jede Kante auf höchstens V/2 augmentierenden Pfaden geben und die Gesamtzahl der augmentierenden Pfade ist höchstens EV/2.
944
Im Kontext
Korollar: Die Kürzeste-augmentierende-Pfad-Implementierung des Max-Fluss-Algorithmus nach Ford-Fulkerson benötigt im schlimmsten Fall Zeit proportional VE2/2. Beweis: Die Breitensuche untersucht höchstens E Kanten.
Die obere Schranke von Satz G ist sehr konservativ. So hat zum Beispiel der Graph in Abbildung 6.23 11 Knoten und 20 Kanten, sodass der Algorithmus gemäß dieser Schranke nicht mehr als 110 augmentierende Pfade verwendet. Konkret verwendet er 14.
Weitere Implementierungen Eine andere Ford-Fulkerson-Implementierung, die auf Edmonds und Karp zurückgeht, funktioniert folgendermaßen: Augmentiere entlang des Pfades, der den Fluss um den größten Betrag erhöht. Der Kürze halber bezeichnen wir dieses Verfahren als MaxFluss-Algorithmus mit augmentierendem Pfad maximaler Kapazität. Wir können ihn (und andere Ansätze) implementieren, indem wir eine Vorrangwarteschlange verwenden und unsere Implementierung des Kürzeste-Pfade-Algorithmus von Dijkstra leicht ändern. Dabei wählen wir Kanten aus der Vorrangwarteschlange, um die maximale Flussmenge zu erhalten, die durch eine Vorwärtskante weitergeleitet oder um die eine Rückwärtskante reduziert werden kann. Oder wir suchen nach einem längsten augmentierenden Pfad oder treffen eine Zufallswahl. Eine vollständige Analyse, um das geeignetste Verfahren zu ermitteln, ist eine komplizierte Aufgabe, da deren Laufzeiten von folgenden Punkten abhängen:
Die Anzahl der augmentierenden Pfade, die benötigt werden, um einen maximalen Fluss zu finden.
Die Zeit, die benötigt wird, um jeden augmentierenden Pfad zu finden. Diese Zahlen können, je nach verarbeitetem Netzwerk und Suchstrategie für Graphen, sehr weit auseinanderliegen. Außerdem wurde eine Reihe anderer Ansätze entwickelt, um das Max-Fluss-Problem zu lösen, von denen einige in der Praxis durchaus mit dem Ford-Fulkerson-Algorithmus mithalten können. Die Entwicklung eines mathematischen Modells der Max-Fluss-Algorithmen zur Validierung solcher Hypothesen stellt jedoch eine größere Herausforderung dar. Die Analyse der Max-Fluss-Algorithmen bleibt ein interessantes und aktives Forschungsgebiet. Theoretisch wurden für mehrere MaxFluss-Algorithmen Performance-Schranken für den schlimmsten Fall entwickelt, aber diese Schranken sind im Allgemeinen wesentlich höher als die eigentlichen Kosten, wie sie in den Anwendungen zu beobachten sind, und ebenso ein wenig höher als die triviale (lineare) untere Schranke. Diese Diskrepanz zwischen dem, was bekannt ist, und dem, was möglich ist, ist hier größer als für jedes andere Problem, das wir (bisher) in diesem Buch betrachtet haben. Die Anwendung der Max-Fluss-Algorithmen in der Praxis ist ebenso Kunst wie Wissenschaft. Kunst ist es, die richtige Strategie zu wählen, die für eine gegebene praktische Situation am effektivsten ist, und die Wissenschaft besteht darin, das Wesen des
945
6
Im Kontext
Problems zu erfassen. Gibt es neue Datenstrukturen und Algorithmen, die das MaxFluss-Problem in linearer Zeit lösen, oder können wir beweisen, dass es keine gibt?
Tabelle 6.6
Performancedaten von Max-Fluss-Algorithmen
Algorithmus
Wachstumsordnung für den schlimmsten Fall für V Knoten und E Kanten mit ganzzahligen Kapazitäten (max C)
Ford-Fulkerson Kürzester augmentierender Pfad
VE2
Ford-Fulkerson Maximal augmentierender Pfad
E2 log C
Preflow-Push
EV log(E/V2)
möglich?
V + E?
Reduktion In diesem Buch haben wir in der Regel zuerst spezifische Probleme aufgezeigt, um anschließend Algorithmen und Datenstrukturen zu entwickeln, die diese Probleme lösen. In einigen Fällen (von denen etliche unten aufgeführt werden) bot es sich aus praktischen Gründen an, ein Problem dadurch zu lösen, dass wir es als eine Instanz eines bereits gelösten Problems formulierten. Die Formalisierung dieses Konzepts ist ein guter Ansatzpunkt, um die Beziehungen zwischen den unterschiedlichen Problemen und Algorithmen zu untersuchen, die wir hier besprochen haben.
Definition: Reduktion Wir sprechen davon, dass sich ein Problem A auf ein anderes Problem B reduzieren lässt, wenn wir einen Algorithmus, der B löst, für die Entwicklung eines Algorithmus verwenden können, der A löst.
Dieses Konzept ist Ihnen von der Softwareentwicklung her sicherlich vertraut: Wenn Sie eine Bibliotheksmethode zur Lösung eines Problems heranziehen, reduzieren Sie Ihr Problem auf das Problem, das von der Bibliotheksmethode gelöst wird. In diesem Buch bezeichnen wir Probleme, die wir auf ein gegebenes Problem reduzieren können, informell als Anwendungen.
Reduktion auf Sortieren Das Thema Reduktion wurde das erste Mal in Kapitel 2 erwähnt, um auszudrücken, dass ein effizienter Sortieralgorithmus auch effizient zur Lösung vieler anderer Prob-
946
Im Kontext
leme genutzt werden kann, die auf den ersten Blick nichts mit Sortieren zu tun haben. So haben wir unter anderem die folgenden Probleme betrachtet: Den Median finden: Ermittle für eine gegebene Menge von Zahlen den Medianwert. Verschiedene Werte: Bestimme die Anzahl der verschiedenen Werte in einer Menge von Zahlen. Mit Scheduling die durchschnittliche Bearbeitungszeit minimieren: Gegeben sei eine Menge von auszuführenden Jobs von jeweils spezifizierter Dauer. In welcher Reihenfolge können wir die Jobs auf einem einzigen Prozessor anordnen, um die durchschnittliche Bearbeitungszeit zu minimieren?
Satz H: Die folgenden Probleme lassen sich auf das Sortieren reduzieren:
Den Median finden Die verschiedenen Werte zählen Mit Scheduling die durchschnittliche Bearbeitungszeit minimieren Beweis: Siehe Listing 2.25 und Übung 2.5.12.
Wenn wir eine Reduktion in Erwägung ziehen, müssen wir auf die Kosten achten. Wir können zum Beispiel den Median einer Liste von Werten in linearer Zeit finden; wenn wir jedoch die Reduktion auf das Sortieren anwenden, haben wir es am Ende mit einer leicht überlinearen Zeit zu tun. Dennoch lassen sich solche Extrakosten vertreten, da wir auf bereits vorhandene Sortierimplementierungen zurückgreifen können. Sortieren ist aus drei Gründen von Vorteil:
Es ist per se sehr nützlich. Wir haben effiziente Algorithmen, um das Sortierproblem zu lösen. Viele Probleme lassen sich darauf reduzieren. Im Allgemeinen bezeichnen wir ein Problem mit diesen Eigenschaften als ein Problemlösungsmodell. Ebenso wie gut konzipierte Softwarebibliotheken können gut entworfene Problemlösungsmodelle das Universum der Probleme, die wir effizient lösen können, stark erweitern. Ein Fallstrick bei den Problemlösungsmodellen ist der sogenannte Maslow’sche Hammer, der allgemein A. Maslow in den 1960ern zugeschrieben wird: Wenn mein einziges Werkzeug ein Hammer ist, sehen alle Probleme wie ein Nagel aus. Wenn wir uns auf einige wenige Problemlösungsmodelle konzentrieren, stellen diese praktisch unsere Hämmer dar, mit denen wir jedes auftauchende Problem lösen; so berauben wir uns der Gelegenheit, bessere Algorithmen zur Lösung des Problems zu entdecken, geschweige denn neue Problemlösungsmodelle. Doch so wichtig, leistungsfähig und weit verbreitet die hier betrachteten Modelle sein mögen, sollten wir immer daran denken, auch andere Möglichkeiten in Erwägung zu ziehen.
947
6
Im Kontext
Reduktion auf das Kürzeste-Pfade-Problem In Abschnitt 4.4 haben wir über Reduktion in Zusammenhang mit Kürzeste-Pfade-Algorithmen gesprochen. Unter vielen anderen haben wir die folgenden Probleme betrachtet: Kürzeste-Pfade-Problem mit einem Startknoten in ungerichteten Graphen: Wir möchten für einen gegebenen kantengewichteten ungerichteten Graphen mit nichtnegativen Gewichten und einem Startknoten s Fragen beantworten wie Gibt es einen Pfad von s zu einem gegebenen Zielknoten v? Wenn ja, finde den kürzesten Pfad (einer, dessen Gesamtgewicht minimal ist). Ablaufplanung für parallele Aufgaben mit Vorrangbedingung: Gegeben sei eine Menge von Aufgaben (oder Jobs oder Arbeitsschritten) einer vorgegebenen Dauer mit Vorrangbedingungen, die vorgeben, dass bestimmte Aufgaben erledigt sein müssen, bevor andere begonnen werden können. Die Frage, die sich uns hierbei stellt, lautet „Wie können wir den Ablauf der Aufgaben so planen, dass alle Aufgaben unter Einhaltung der Bedingungen in minimaler Zeit auf identischen Prozessoren (so viele wie erforderlich) erledigt werden?“ Arbitrage: Finde eine Arbitragemöglichkeit in einer gegebenen Währungsumrechnungstabelle.
Auch in diesem Fall scheinen die zwei letztgenannten Beispiele nicht direkt mit den Kürzeste-Pfade-Problemen in Zusammenhang zu stehen; dennoch konnten wir zeigen, dass sie sich mit den Kürzeste-Pfade-Algorithmen effektiv lösen lassen. Diese Beispiele – so wichtig sie auch sein mögen – sollen nur als Anhalt dienen. Eine große Zahl wichtiger Probleme, zu viele, um sie hier alle zu berücksichtigen, lassen sich auf kürzeste Pfade reduzieren, was zeigt, dass wir es hier mit einem effektiven und wichtigen Problemlösungsmodell zu tun haben.
Satz I: Die folgenden Probleme lassen sich auf kürzeste Pfade in gewichteten Digraphen reduzieren:
Kürzeste-Pfade-Problem mit einem Startknoten in ungerichteten Graphen mit nicht-negativen Gewichten
Ablaufplanung für parallele Aufgaben mit Vorrangbedingung Arbitrage [viele andere Probleme] Beweisbeispiele: Listing 4.32, Listing 4.35 und Listing 4.39.
Reduktion auf das Max-Fluss-Problem Max-Fluss-Algorithmen haben ebenfalls einen großen Anwendungsbereich. Wir können diverse Beschränkungen des Flussnetzwerkes entfernen und verwandte Flussprobleme lösen, wir können andere netzwerk- und graphenverarbeitende Probleme lösen, ja wir können sogar Probleme lösen, die überhaupt nichts mit Netzwerken zu tun haben. Nehmen wir beispielsweise die folgenden Probleme:
948
Im Kontext
Stellenvermittlung: Das Stellenvermittlungsbüro einer Universität vermittelt Vorstellungsgespräche von Studenten mit einer Reihe von Unternehmen. Diese Vorstellungsgespräche münden in einer Reihe von Jobangeboten. Angenommen, ein Vorstellungsgespräch gefolgt von einem Jobangebot repräsentiert ein gegenseitiges Interesse, sodass der Student die Stelle in dem Unternehmen antritt, dann ist es in jedermanns Interesse, die Anzahl der Jobzusagen zu maximieren. Ist es möglich, jedem Studenten einen Job zu verschaffen? Wie viele Jobs können maximal vermittelt werden?
Produktvertrieb: Ein Unternehmen, das ein einziges Produkt herstellt, hat Produktionsstätten, in denen das Produkt hergestellt wird, Vertriebszentren, in denen das Produkt bis zum Verkauf gelagert wird, und Läden, in denen das Produkt verkauft wird. Das Unternehmen muss regelmäßig das Produkt von den Produktionsstätten über die Vertriebszentren in die Läden bringen und dafür Vertriebskanäle mit variierenden Kapazitäten nutzen. Ist es möglich, das Produkt von den Auslieferungslagern in die Läden zu bekommen, sodass das Angebot überall die Nachfrage deckt?
Netzwerkzuverlässigkeit: Ein vereinfachtes Modell betrachtet ein Computernetzwerk als ein Geflecht von Hauptleitungen, die die Computer über Schalter miteinander verbinden, sodass es einen geschalteten Pfad über diese Leitungen gibt, der zwei beliebige gegebene Computer miteinander verbindet. Wie hoch ist die Mindestzahl der Hauptleitungen, die gekappt werden können, um zwei Computer voneinander zu trennen? Auch diese Probleme scheinen auf den ersten Blick nichts miteinander beziehungsweise mit Flussnetzwerken im Speziellen zu tun zu haben; dennoch lassen sich alle auf das Problem des maximalen Flusses reduzieren. Satz J: Die folgenden Probleme lassen sich auf das Max-Fluss-Problem reduzieren:
Stellenvermittlung Produktverteilung Netzwerkzuverlässigkeit [viele andere Probleme]
Beweisbeispiele: Wir beweisen das erste Problem (bekannt auch als maximales bipartites MatchingProblem ) und überlassen Ihnen die anderen zur Übung. Reduzieren Sie ein gegebenes Stellenvermittlungsproblem auf eine Instanz des Max-Fluss-Problems, indem Sie von jedem Studentenknoten ausgehend Kanten auf alle Unternehmen richten, für die sich der Student interessiert. Dann fügen Sie einen Knoten für die Quelle hinzu, von dem Kanten zu allen Studenten ausgehen, und einen Knoten für die Senke, in dem die von allen Unternehmen ausgehenden Kanten münden. Weisen Sie jeder Kante die Kapazität 1 zu. Genau genommen ist jede ganzzahlige Lösung für das Max-Fluss-Problem in diesem Netzwerk gleichzeitig eine Lösung für das entsprechende bipartite Matching-Problem (siehe Korollar zu Satz F). Die Zuordnung (matching) entspricht genau den Kanten, die jeweils Knoten der beiden Mengen verbinden und von dem Max-Fluss-Algorithmus bis zur Kapazitätsgrenze gefüllt sind. Erstens führt der Netzwerkfluss immer zu einer gültigen Zuordnung: Da jeder Knoten eine Kante der Kapazität 1 hat, die entweder hineinführt (von der Senke) oder herausführt (zur Quelle), kann höchstens 1 Flusseinheit durch jeden Knoten fließen. Dies wiederum impliziert, dass jeder Knoten höchstens einmal in der Zuordnung auftaucht. Zweitens kann keine Zuordnung mehr Kanten aufweisen, da eine solche Zuordnung direkt zu einem besseren Fluss führen würde als der vom Max-Fluss-Algorithmus erzeugte.
949
6
Im Kontext
bipartites Matching-Problem 1 Alice 7 Adobe Amazon Facebook 2 Bob 8 Adobe Amazon Yahoo 3 Carol 9 Facebook Google IBM 10 4 Dave Adobe Amazon 11 5 Eliza Google IBM Yahoo 12 6 Frank IBM Yahoo
Adobe Alice Bob Dave Amazon Alice Bob Dave Facebook Alice Carol Google Carol Eliza IBM Carol Eliza Frank Yahoo Bob Eliza Frank
Zuordnung (Lösung) Netzwerkfluss
Alice Bob Carol Dave Eliza Frank
maximaler Fluss ss
s
1
2
3
4
5
6
1
2
3
4
5
6
7
8
9
10
11
12
7
8
9
10
11
12
s
Amazon Yahoo Facebook Adobe Google IBM
ts
Abbildung 6.24: Beispiel für die Reduzierung des maximalen bipartiten Matching-Problems auf das Netzwerkflussproblem
Wie Abbildung 6.25 zeigt, könnte ein Augmentierende-Pfade-Algorithmus zur Bestimmung des maximalen Flusses die Pfade s->1->7->t, s->2->8->t, s->3->9->t, s->5->10->t, s->6->11->t und s->4->7->1->8->2->12->t verwenden, um die Zuordnungen 1-8, 2-12, 3-9, 4-7, 5-10 und 6-11 zu berechnen. Es gibt also in unserem Beispiel einen Weg, um allen Studenten einen Job in dem sie interessierenden Unternehmen zu vermitteln. Jeder augmentierende Pfad füllt eine Kante von der Quelle ausgehend und eine Kante zur Senke hinführend. Beachten Sie, dass diese Kanten niemals als Rückwärtskanten genutzt werden, sodass es höchstens V augmentierende Pfade gibt und die Gesamtlaufzeit proportional VE ist. Kürzeste-Pfade und Max-Fluss sind wichtige Problemlösungsmodelle, da sie die gleichen Eigenschaften haben, wie wir sie bereits für das Sortieren feststellen konnten:
Sie sind per se sehr nützlich. Wir verfügen bereits über effiziente Algorithmen zur Lösung dieser Probleme. Viele Probleme lassen sich darauf reduzieren. Diese kurze Diskussion kann Ihnen nur einen kleinen Eindruck vermitteln. Wenn Sie sich intensiver mit dem Bereich Operations Research befassen, werden Sie noch viele weitere Probleme kennenlernen, die sich auf diese und viele andere Problemlösungsmodelle reduzieren lassen.
950
Im Kontext
Lineare Programmierung Einer der Grundpfeiler des Operations Research ist die lineare Programmierung (LP). Damit bezeichnen wir die Vorstellung, dass sich ein gegebenes Problem auf die folgende mathematische Formulierung reduzieren lässt: Lineare Programmierung: Gegeben seien eine Menge von M linearen Ungleichungen und Gleichungen mit N Variablen und eine lineare Zielfunktion dieser N Variablen. Finde eine Zuordnung der Werte zu den Variablen, die die Zielfunktion maximiert, oder gib die Rückmeldung aus, dass es keine solche Zuordnung gibt.
Die lineare Programmierung ist aus folgenden Gründen ein extrem wichtiges Problemlösungsmodell:
Sehr viele wichtige Probleme lassen sich auf die lineare Programmierung reduzieren.
Wir verfügen über effiziente Algorithmen, um Probleme der linearen Programmierung zu lösen. In dieser Aufzählung können wir auf den Punkt „per se nützlich“ verzichten, den wir bei anderen Problemlösungsmodellen angeführt haben, weil sich so viele praktische Probleme auf die lineare Programmierung reduzieren lassen. maximiert f + h in Abhängigkeit von den Bedingungen 0 a2 0 b3 0 c3 0 d1 0 e1 0 f1 0 g2 0 h3 a = c+d b = e+f c+e = g d+f = h
Pfad mit Rückwärtskanten
Abbildung 6.26: Beispiel der linearen Programmierung
Abbildung 6.25: Augmentierende Pfade für bipartites Matching
951
6
Im Kontext
Satz K: Die folgenden Probleme lassen sich auf die lineare Programmierung reduzieren:
Max-Fluss Kürzeste Pfade [und viele, viele weitere Probleme] Beweisbeispiele: Wir beweisen das erste Problem und überlassen Ihnen das zweite zur Übung (Übung 6.50 ). Wir betrachten ein System aus Ungleichungen und Gleichungen, das eine Variable für jede Kante aufweist, zwei Ungleichungen für jede Kante und eine Gleichung für jeden Knoten (außer der Quelle und der Senke). Der Wert der Variablen ist der Fluss entlang der Kanten, die Ungleichungen spezifizieren, dass der Kantenfluss zwischen 0 und der Kapazität der Kante liegen muss. Die Gleichungen spezifizieren, dass der Gesamtfluss auf den Kanten, die in jeden Knoten münden, gleich dem Gesamtfluss entlang der Kanten aus dem Knoten heraus sein muss. Jedes Max-Fluss-Problem kann auf diese Weise in eine Instanz der linearen Programmierung umgewandelt werden und die Lösung lässt sich leicht in eine Lösung des Max-Fluss-Problems umwandeln. Abbildung 6.27 veranschaulicht dies anhand unseres Beispiels.
Max-Fluss-Problem
V
6 8 0 0 1 1 2 2 3 4
Max-Fluss-Lösung Max flow from 0 to 5
E 1 2 3 4 3 4 5 5
2.0 3.0 3.0 1.0 1.0 1.0 2.0 3.0
Kapazitäten
LP-Formulierung
Maximiert x 35 + x 45 in Abhängigkeit von den Bedingungen 0 x 01 2 0 x 02 3 0 x 13 3 0 x 14 1 0 x 23 1 0 x 24 1 0 x 35 2 0 x 45 3 x 01 = x 13 + x 14 x 02 = x 23 + x 24 x 13 + x 23 = x 35 x 14 + x 24 = x 45
LP-Lösung
0->2 3.0 2.0 0->1 2.0 2.0 1->4 1.0 1.0 1->3 3.0 1.0
x 01 = 2 x 02 = 2 x 13 = 1 x 14 = 1 x 23 = 1 x 24 = 1 x 35 = 2 x 45 = 2
2->3 1.0 1.0 2->4 1.0 1.0 3->5 2.0 2.0 4->5 3.0 2.0 Max flow value: 4.0
Abbildung 6.27: Beispiel für die Reduzierung des Netzwerkflussproblems auf ein Problem der linearen Programmierung
Der Punkt „und viele, viele weitere Probleme“ in Satz K bezieht sich auf drei Konzepte. Erstens ist es sehr einfach, ein Modell zu erweitern und Bedingungen hinzuzufügen. Zweitens ist die Reduktion transitiv, sodass sich alle Probleme, die sich auf kürzeste Pfade und maximalen Fluss reduzieren lassen, auch auf die lineare Programmierung
952
Im Kontext
reduzieren lassen. Drittens und von allgemeinerer Art ist, dass alle Arten von Optimierungsproblemen direkt als Probleme der linearen Programmierung formuliert werden können. Genau genommen ist der Begriff lineare Programmierung inzwischen gleichbedeutend mit „Formuliere ein Optimierungsproblem als Problem der linearen Programmierung“. Diese Verwendung ist älter als die Verwendung des Begriffs Programmierung für Computer. Ebenso wichtig wie die Vorstellung, dass sich sehr viele Probleme auf die lineare Programmierung reduzieren lassen, ist die Tatsache, dass wir seit Jahrzehnten effiziente Algorithmen für die lineare Programmierung kennen. Der berühmteste, der sogenannte Simplex-Algorithmus, wurde in den 1940ern von G. Dantzig entwickelt. Das Simplexverfahren ist nicht schwer zu verstehen (siehe das Implementierungsgerüst auf der Website zum Buch). In den 1980ern hat der Ellipsoid-Algorithmus, den L. G. Khachiyan 1979 präsentierte, zu der Entwicklung der Innere-PunkteVerfahren geführt, die sich als effektives Komplement zum Simplex-Algorithmus erwiesen haben, um die riesigen Probleme der linearen Programmierung heutiger Anwendungen zu lösen. Heutzutage sind diese Lösungen robust, testerprobt und effizient und für moderne Großkonzerne unentbehrlich. Sie finden außerdem zunehmend Verwendung in der Wissenschaft und sogar in der Anwendungsprogrammierung. Wenn Sie Ihr Problem als ein Problem der linearen Programmierung modellieren können, ist die Wahrscheinlichkeit hoch, dass es sich lösen lässt. Genau genommen ist die lineare Programmierung der Vorfahre aller Problemlösungsmodelle, da sich so viele Probleme darauf reduzieren lassen. Da stellt sich natürlich sofort die Frage, ob es noch ein mächtigeres Problemlösungsmodell als die lineare Programmierung gibt. Welche Arten von Problemen lassen sich nicht auf die lineare Programmierung reduzieren? Betrachten wir beispielsweise folgendes Problem: Lastenverteilung: Gegeben sei eine Menge von zu erledigenden Jobs einer bestimmten Dauer. Wie können wir die Jobs auf zwei Prozessoren verteilen, sodass alle Aufgaben in minimaler Zeit erledigt werden?
Können wir ein allgemeineres Problemlösungsmodell formulieren und Probleminstanzen innerhalb dieses Modells effizient lösen? Diese Fragen führen uns direkt zu unserem letzten Thema: die nicht effizient lösbaren Probleme (intractability).
Nicht effizient lösbare Probleme Die Algorithmen, die wir bisher in diesem Buch behandelt haben, dienen im Allgemeinen der Lösung praktischer Probleme, was auf einen vertretbaren Ressourcenaufwand schließen lässt. Der praktische Nutzen der meisten dieser Algorithmen ist unumstritten und bei vielen Problemen haben wir das Luxusproblem, unter mehreren effizienten Algorithmen wählen zu können. Leider gibt es in der Praxis aber auch viele Probleme, die sich nicht ohne Weiteres effizient lösen lassen. Und was noch schlimmer ist, für eine große Klasse solcher Probleme können wir nicht einmal sagen, ob überhaupt eine effiziente Lösung existiert. Über diese Tatsache ärgern sich nicht nur viele Programmierer und Algorithmendesigner, die für ein breites Spektrum an konkreten Problemen keinen effizienten Algorithmus finden, sondern auch Theoretiker, die bisher keinen Beweis finden
953
6
Im Kontext
konnten, dass diese Probleme schwierig sind. Intensive Forschung in diesem Bereich hat zu der Entwicklung von Mechanismen geführt, mit deren Hilfe sich neue Probleme in einem besonderen technischen Sinne als „schwer zu lösen“ klassifizieren lassen. Auch wenn wir dieses Thema im Rahmen dieses Buches nur oberflächlich behandeln können, sind die zentralen Konzepte nicht schwer zu verstehen. Diese wollen wir hier kurz vorstellen, da wir der Meinung sind, dass jeder Programmierer, der vor einem neuen Problem steht, wissen sollte, dass es Probleme gibt, für die bisher noch niemand einen garantiert effizienten Algorithmus gefunden hat.
Grundlagen Eine der wunderbarsten und faszinierendsten intellektuellen Erfindungen des 20. Jahrhunderts ist die von A. Turing in den 1930ern entwickelte Turingmaschine, ein einfaches Berechnungsmodell, das allgemein genug ist, um jedes Computerprogramm oder jeden Computer zu modellieren. Eine Turingmaschine ist ein Zustandsautomat, der eine Eingabe einlesen, von einem Zustand zum anderen übergehen und Ausgaben schreiben kann. Turingmaschinen bilden die Grundlage der theoretischen Informatik, basierend auf den beiden folgenden Konzepten:
Universalität: Alle physikalisch realisierbaren Computer können mit einer Turingmaschine simuliert werden. Dieses Konzept wird auch als Church-Turing-These bezeichnet. Hierbei handelt es sich um eine Aussage über die natürliche Welt, die nicht beweisbar ist, aber widerlegt werden könnte. Die Richtigkeit der These wird dadurch belegt, dass Mathematiker und Informatiker eine Vielzahl von Berechnungsmodellen entwickelt haben, die sich aber alle als äquivalent zur Turingmaschine erwiesen haben.
Berechenbarkeit: Es gibt Probleme, die nicht mit einer Turingmaschine (oder, gemäß Universalität, mit irgendeinem anderen Computer) gelöst werden können. Dies ist eine mathematische Wahrheit. Ein berühmtes Beispiel hierfür ist das Halteproblem (kein Programm kann mit Garantie feststellen, ob ein gegebenes Programm anhalten wird). In diesem Zusammenhang sind wir an einem dritten Konzept interessiert, das sich mit der Effizienz der Computer befasst:
Erweiterte Church-Turing-These: Die Wachstumsordnung von der Laufzeit eines Programms, um ein Problem auf irgendeinem Computer zu lösen, liegt innerhalb eines polynomiellen Faktors eines Programms, um das Problem auf einer Turingmaschine (oder irgendeinem anderen Computer) zu lösen. Auch in diesem Fall handelt es sich um eine Aussage über die natürliche Welt, untermauert durch das Konzept, dass alle bekannten Computer mit einer Turingmaschine simuliert werden können, und zwar mit einer Kostensteigerung um höchstens einen polynomiellen Faktor. In den letzten Jahren sind durch Quantencomputing bei einigen Wissenschaftlern Zweifel an der erweiterten Church-Turing-These aufgekommen. Die meisten stimmen übrigens darin überein, dass sie aus praktischer Sicht wahrscheinlich noch eine Weile Bestand hat, aber viele Wissenschaftler arbeiten fleißig daran, diese These zu widerlegen.
954
Im Kontext
Exponentielle Laufzeit Sinn und Zweck der Theorie von den nicht effizient lösbaren Problemen ist es, Probleme, die in Polynomialzeit gelöst werden können, von denen zu trennen, die im schlimmsten Fall (wahrscheinlich) Exponentialzeit benötigen. Dabei stellen Sie sich am besten einen Algorithmus mit exponentieller Ausführungszeit als einen Algorithmus vor, der für irgendeine Eingabe der Größe N (mindestens) Zeit proportional 2N benötigt. Der Wahrheitsgehalt dieses Arguments ändert sich nicht, wenn wir 2 durch eine beliebige Zahl α > 1 ersetzen. Wir gehen im Allgemeinen davon aus, dass nicht garantiert werden kann, dass ein Algorithmus mit Exponentialzeit ein Problem von sagen wir der Größe 100 in einer vernünftigen Zeit löst, da keiner darauf warten kann, dass der Algorithmus 2100 Schritte ausführt, unabhängig davon, wie schnell der Computer ist. Exponentielles Wachstum lässt technologische Änderungen klein und unbedeutend erscheinen: Ein Supercomputer kann eine Billion Mal schneller sein als ein Abakus, aber keiner kommt auch nur annähernd zu einer Lösung für ein Problem, das 2100 Schritte erfordert. Manchmal ist die Linie zwischen einem „einfachen“ und einem „schweren“ Problem sehr fein. So haben wir zum Beispiel in Abschnitt 4.1 einen Algorithmus untersucht, der das folgende Problem lösen kann: Länge des kürzesten Pfades: Wie lang ist der kürzeste Pfad von einem gegebenen Knoten s zu einem gegebenen Knoten t in einem gegebenen Graphen?
Aber wir haben die Algorithmen nicht auf das folgende Problem hin untersucht, das das gleiche zu sein scheint: Länge des längsten Pfades: Wie lang ist der längste einfache Pfad von einem gegebenen Knoten s zu einem gegebenen Knoten t in einem gegebenen Graphen? public class LongestPath { private boolean[] marked; private int max; public LongestPath(Graph G, int s, int t) { marked = new boolean[G.V()]; dfs(G, s, t, 0); } private void dfs(Graph G, int v, int t, int i) { if (v == t && i > max) max = i; if (v == t) return; marked[v] = true; for (int w : G.adj(v)) if (!marked[w]) dfs(G, w, t, i+1); marked[v] = false; } public int maxLength() { return max; } } Listing 6.14: Die Länge des längsten Pfades in einem Graphen ermitteln
955
6
Im Kontext
Der springende Punkt hierbei ist jedoch, dass sich diese Probleme vom Schwierigkeitsgrad her, soweit wir wissen, beinahe an entgegengesetzten Enden des Spektrums befinden. Die Breitensuche liefert eine Lösung für das erste Problem in linearer Zeit, während alle bekannten Algorithmen für das zweite Problem im schlimmsten Fall exponentielle Zeit benötigen. Der Code in Listing 6.14 ist eine Variante der Tiefensuche, mit der sich diese Aufgabe erledigen lässt. Er hat große Ähnlichkeit mit der Tiefensuche, untersucht aber alle einfachen Pfade von s zu t in dem Digraphen, um den längsten Pfad zu finden.
Suchprobleme Der große Unterschied zwischen Problemen, die mit „effizienten“ Algorithmen gelöst werden können, wie wir sie in diesem Buch betrachtet haben, und Problemen, bei denen wir nach einer Lösung unter eine potenziell riesigen Anzahl von Möglichkeiten suchen müssen, ermöglicht es, den Grenzbereich dazwischen mit einem einfachen formalen Modell zu untersuchen. Der erste Schritt besteht darin, die Art des Problems zu beschreiben, mit dem wir es hier zu tun haben.
Definition: Suchproblem Ein Suchproblem ist ein Problem mit Lösungen, die die Eigenschaft haben, dass die Zeit, die benötigt wird, um ihre Korrektheit zu bestätigen, polynomiell zur Größe der Eingabe ist. Wir sagen, dass ein Algorithmus ein Suchproblem löst, wenn er bei einer gegebenen Eingabe entweder eine Lösung produziert oder die Rückmeldung ausgibt, dass eine solche nicht existiert.
Vier spezifische Probleme, die für unsere Diskussion der schweren Lösbarkeit interessant sind, werden nachfolgend vorgestellt. Diese Probleme werden auch als Erfüllbarkeitsprobleme (satisfiability problems) bezeichnet. Um ein Problem als Suchproblem zu etablieren, müssen Sie jetzt nur noch zeigen, dass jede Lösung ausreichend gut beschrieben ist, dann können Sie effizient bestätigen, dass sie korrekt ist. Das Lösen eines Suchproblems ist wie die Suche nach der „Nadel im Heuhaufen“, mit dem einzigen Vorbehalt, dass Sie die Nadel erkennen können, wenn Sie sie sehen. Wenn man Ihnen zum Beispiel eine Zuordnung von Werten zu Variablen gibt für jedes der nachfolgend beschriebenen Erfüllbarkeitsprobleme, können Sie leicht bestätigen, dass jede Gleichung oder Ungleichung erfüllt ist, während die Suche nach einer Zuweisung eine ungleich schwierigere Aufgabe ist. In der Regel verwenden wir die Bezeichnung NP, um Suchprobleme zu beschreiben – den Grund verraten wir Ihnen im übernächsten Abschnitt.
Definition: NP NP beschreibt die Menge aller Suchprobleme.
956
Im Kontext
NP ist lediglich eine genaue Bezeichnung für all die Probleme, die Wissenschaftler, Ingenieure und Anwendungsprogrammierer mit Programmen zu lösen anstreben, die garantiert in einer vertretbaren Zeit ausgeführt werden können.
Tabelle 6.7
Ausgewählte Suchprobleme
Erfüllbarkeit bei linearen Gleichungen : Gegeben sei eine Menge von M linearen Gleichungen mit N Variablen. Finde eine Zuordnung der Werte zu den Variablen, die allen Gleichungen genügt, oder melde zurück, dass keine solche Zuordnung existiert. Erfüllbarkeit bei linearen Ungleichungen (Suchformulierung bei linearer Programmierung) : Gegeben sei eine Menge von M linearen Ungleichungen mit N Variablen. Finde eine Zuordnung der Werte zu den Variablen, die allen Ungleichungen genügt, oder melde zurück, dass keine solche Zuordnung existiert. Erfüllbarkeit bei 0/1 ganzzahligen linearen Ungleichungen (Suchformulierung bei 0/1 ganzzahliger linearer Programmierung) : Gegeben sei eine Menge von M linearen Ungleichungen mit N ganzzahligen Variablen. Finde eine Zuordnung der Werte 0 oder 1 zu den Variablen, die allen Ungleichungen genügt, oder melde zurück, dass keine solche Zuordnung existiert.
Erfüllbarkeitsproblem der Aussagenlogik : Gegeben sei eine Menge von M Gleichungen mit Und- und Oder-Operationen auf N booleschen Variablen. Finde eine Zuordnung der Werte zu den Variablen, die allen Gleichungen genügt, oder melde zurück, dass keine solche Zuordnung existiert.
Andere Arten von Problemen Das Konzept der Suchprobleme ist eine von vielen Möglichkeiten, die Menge der Probleme zu beschreiben, die die Grundlage für die Untersuchung der nicht effizient lösbaren Probleme bilden. Andere Möglichkeiten sind die Entscheidungsprobleme (existiert eine Lösung?) und Optimierungsprobleme (welches ist die beste Lösung?). So ist beispielsweise das Problem des längsten Pfades in Listing 6.14 ein Optimierungsproblem und kein Suchproblem (bei einer gegebenen Lösung haben wir keine Möglichkeit zu verifizieren, ob es sich dabei um die Länge eines längsten Pfades handelt). Eine Suchversion dieses Problems besteht darin, einen einfachen Pfad zu suchen, der alle Knoten verbindet (dieses Problem heißt auch Hamiltonpfadproblem). Bei der Entscheidungsversion des Problems stellt sich die Frage, ob überhaupt ein einfacher Pfad existiert, der alle Knoten verbindet. Arbitrage, Erfüllbarkeit von aussagelogischen Formeln und Hamiltonpfad sind Suchprobleme; die Frage zu klären, ob eine Lösung zu einem dieser Probleme existiert, ist ein Entscheidungsproblem; und kürzeste/längste Pfade, Max-Fluss und lineare Programmierung sind alles Optimierungsprobleme. Auch wenn sie technisch gesehen nicht äquivalent sind, lassen sich Such-, Entscheidungs- und Optimierungsprobleme normalerweise aufeinander reduzieren (siehe Übungen 6.58 und 6.59), und die wesentlichen Schlussfolgerungen, die wir ziehen, lassen sich auf alle drei Arten von Problemen anwenden.
Leichte Suchprobleme Die Definition von NP sagt nichts über die Schwierigkeit aus, die Lösung zu finden, sondern bestätigt nur, dass es eine Lösung ist. Die zweite der beiden Mengen von Problemen, die die Grundlage der Untersuchung von nicht effizient lösbaren Problemen
957
6
Im Kontext
bilden (bekannt als P), befasst sich mit der Schwierigkeit, die Lösung zu finden. In diesem Modell ist die Effizienz eines Algorithmus eine Funktion von der Anzahl der Bits, die zur Codierung der Eingabe benötigt wird.
Definition: P P ist die Menge aller Suchprobleme, die in polynomieller Zeit gelöst werden können.
Die Definition impliziert, dass die polynomielle Zeitschranke eine Schranke für den schlimmsten Fall ist. Für jedes Problem, das P angehört, muss ein Algorithmus existieren, der es garantiert in polynomieller Zeit löst. Beachten Sie, dass das Polynom in keinster Weise angegeben wird. Linear, leicht überlinear, quadratisch und kubisch sind alles polynomielle Zeitschranken, sodass diese Definition sicherlich die Standardalgorithmen abdeckt, die wir bisher untersucht haben. Die Zeit, die ein Algorithmus benötigt, hängt von dem verwendeten Computer ab – diese Behauptung wird aber aufgrund der erweiterten Church-Turing-These hinfällig, die besagt, dass eine polynomielle Lösung auf irgendeinem Rechner die Existenz einer polynomiellen Lösung auf irgendeinem anderen Rechner impliziert. Sortieren gehört zu P, da zum Beispiel Insertionsort in einer Zeit proportional N 2 ausgeführt wird (die Existenz von leicht überlinearen Sortieralgorithmen ist im vorliegenden Zusammenhang unerheblich), ebenso wie kürzeste Pfade, Erfüllbarkeit bei linearen Gleichungen und viele andere Probleme. Über einen effizienten Algorithmus zu verfügen, um ein Problem zu lösen, ist ein Beweis, dass das Problem in P ist. Mit anderen Worten, P ist lediglich eine genaue Bezeichnung für alle Probleme, die Wissenschaftler, Ingenieure und Anwendungsprogrammierer mit Programmen lösen, die garantiert in einer vertretbaren Zeit ausgeführt werden können.
Nichtdeterminismus Das N in NP steht für Nichtdeterminismus. Dahinter verbirgt sich die Idee, dass sich die Leistungsfähigkeit eines Computers theoretisch dadurch erweitern lässt, dass er mit der Leistungsfähigkeit des Nichtdeterminismus ausgestattet wird: Wenn ein Algorithmus die Wahl zwischen mehreren Optionen hat, wird zugesichert, dass er in der Lage ist, die richtige zu „raten“. Zum Zwecke unserer Diskussion stellen wir uns vor, dass ein Algorithmus für einen nichtdeterministischen Automaten die Lösung zu einem Problem „rät“ und dann bestätigt, dass die Lösung gültig ist. In einer Turingmaschine ist Nichtdeterminismus so einfach wie zwei verschiedene Nachfolgerzustände für einen gegebenen Zustand und einer gegebenen Eingabe zu definieren und alle Lösungen als gültige Pfade zu dem gewünschten Ergebnis zu beschreiben. Nichtdeterminismus mag mathematische Fiktion sein, ist aber ein nützliches Konzept. So haben wir beispielsweise in Abschnitt 5.4 Nichtdeterminismus als Tool für Algorithmendesign verwendet – unser Musterabgleich-Algorithmus unter Verwendung regulärer Ausdrücke basiert auf der effizienten Simulation eines nichtdeterministischen Automaten.
958
Im Kontext
Tabelle 6.8
Beispiele für Probleme in NP
Problem
Eingabe
Beschreibung
Algorithmus in polynomieller Zeit
Hamiltonpfadproblem
Graph G
Finde einen einfachen Pfad, der jeden Knoten besucht
?
Faktorisierung
Ganze Zahl x
Finde einen nicht-trivialen Faktor von x
?
97605257271
8784561
Erfüllbarkeit bei 0/1 ganzzahligen linearen Ungleichungen
M 0-1
Weise den Variablen Werte zu, die die Ungleichungen erfüllen
?
x−y≤1 2x − z ≤ 2 x+y≥2 z≥0
x=1 y=1 z=0
Variablen N Ungleichungen
Alle Probleme in P
Tabelle 6.9
Instanz
Lösung
0-2-1-3
Siehe Tabelle unten
Beispiele der Probleme in P
Problem
Eingabe
Beschreibung
Algorithmus in polynomieller Zeit
Instanz
Lösung
Kürzester s-tPfad
Graph G Knoten s, t
Finde den kürzesten Pfad von s nach t
Breitensuche
s
0-3
Sortieren
Array a
Finde eine Permutation, die a aufsteigend sortiert
Mergesort
2.8 8.5 4.1 1.3
3 0 2 1
Erfüllbarkeit bei linearen Gleichungen
M Variablen N Glei-
Weise den Variablen Werte zu, die die Gleichungen erfüllen,
Gauß’sche Eliminierung
x + y = 1.5 2x − y = 0
x = 0.5 y=1
Weise den Variablen Werte zu, die die Ungleichungen erfüllen
Ellipsoid
x − y ≤ 1.5 2x − z ≤ 0 x + y ≥ 3.5 z ≥ 4.0
x = 2.0 y = 1.5 z = 4.0
t
Erfüllbarkeit bei linearen Ungleichungen
chungen
M Variablen N Ungleichungen
959
6
Im Kontext
Die Hauptfrage Nichtdeterminismus ist ein so mächtiges Konzept, dass es fast absurd zu sein scheint, es ernsthaft zu prüfen. Warum sollte man sich die Mühe machen, ein imaginäres Werkzeug zu betrachten, das komplizierte Probleme trivial aussehen lässt? Die Antwort lautet, dass, so leistungsfähig der Nichtdeterminismus zu sein scheint, es noch niemandem gelungen ist zu beweisen, dass er für irgendein spezielles Problem von Nutzen ist! Anders ausgedrückt, es ist noch niemandem gelungen, auch nur ein einziges Problem zu finden, für das bewiesen werden kann, dass es NP angehört, aber nicht P (oder wenigstens zu beweisen, dass ein solches Problem existiert), was die folgende Frage offen lässt: Gilt P = NP oder nicht? Diese Frage wurde zuerst 1950 in einem berühmten Brief von K. Gödel an J. von Neumann aufgeworfen und beschäftigt seitdem Mathematiker wie Informatiker gleichermaßen. Sie können sich dem grundlegenden Problem auch durch andere Fragestellungen nähern:
Gibt es irgendwelche Suchprobleme, die schwer zu lösen sind? Könnten wir einige Suchprobleme effizienter lösen, wenn wir einen nichtdeterministischen Computer erstellen könnten? Die Antworten auf diese Fragen nicht zu kennen, ist sehr unbefriedigend, da viele wichtige praxisrelevante Probleme NP angehören, jedoch nicht bekannt ist, ob sie P angehören (die bekanntesten deterministischen Algorithmen könnten exponentielle Zeit benötigen). Wenn wir beweisen könnten, dass ein Problem nicht P angehört, könnten wir die Suche nach einer effizienten Lösung für dieses Problem aufgeben. Wenn ein solcher Beweis nicht erbracht wurde, besteht die Möglichkeit, dass ein effizienter Algorithmus noch nicht entdeckt wurde. Tatsächlich könnte in Anbetracht des gegenwärtigen Standes unserer Kenntnisse für jedes Problem in NP ein effizienter Algorithmus existieren, was bedeuten würde, dass viele effiziente Algorithmen noch nicht entdeckt wurden. Praktisch glaubt niemand, dass P = NP ist, und es sind beträchtliche Anstrengungen unternommen worden, um das Gegenteil zu beweisen, aber dieses offene Problem der Informatik bleibt auch weiterhin ungelöst.
Reduktion in Polynomialzeit In Abschnitt haben wir gezeigt, dass sich ein Problem A auf ein Problem B reduzieren lässt, und wir jede Instanz von A in drei Schritten lösen können:
Wir transformieren sie in eine Instanz von B. Wir lösen diese Instanz von B. Wir transformieren die Lösung von B zu einer Lösung von A.
960
Im Kontext
Solange die Transformationen durchführbar Erfüllbarkeitsproblem der Aussagenlogik sind (und B effizient lösbar ist), können wir (x'1 oder x2 oder x3) und auch A effizient lösen. In diesem Zusammen(x1 oder x'2 oder x3) und hang gilt für effizient die denkbar schwächste (x'1 oder x'2 oder x'3) und (x'1 oder x'2 oder x3) Definition: Um A zu lösen, lösen wir höchstens eine polynomielle Anzahl von Instanzen der Erfüllbarkeit bei 0/1 von B, unter Verwendung von Transformatio- Formulierung ganzzahligen linearen Ungleichungen nen, die höchstens Polynomialzeit benötigen. c ist genau dann 1, c 1 x 1 1 1 In diesem Fall sprechen wir davon, dass A wenn die c1 x2 erste Klausel c1 x3 polynomiell auf B reduziert werden kann. erfüllbar ist c1 (1 x1) + x2 + x3 Zuvor haben wir mit Reduktion das Konzept der Problemlösungsmodelle eingeführt, die c2 x1 das Spektrum der Probleme, die wir mit effic2 1 x2 zienten Algorithmen lösen können, erheblich c2 x3 c2 x1 + (1 x2) + x3 erweitern. Jetzt verwenden wir die Reduktion anderweitig, und zwar um zu beweisen, dass c3 1 x1 ein Problem schwer zu lösen ist. Wenn bekannt c3 1 x2 ist, dass ein Problem A schwer zu lösen ist, c3 1 x3 und sich A polynomiell auf B reduzieren lässt, c3 (1 x1) + 1 x2 + (1 x3 ) dann muss auch B schwer zu lösen sein. c4 1 x1 Andernfalls würde eine garantiert polynoc4 1 x2 mielle Lösung für B zu einer garantiert polyc4 x3 nomiellen Lösung für A führen. c4 (1 x1) + (1 x2) + x3 s c1 s ist genau s c2 dann 1, wenn s c3 alle c 1 sind s c4 s c1 + c2 + c3 + c4 3 Abbildung 6.28: Beispiel für die Reduzierung der Erfüllbarkeitsprobleme der Aussagenlogik auf die Erfüllbarkeit bei 0/1 ganzzahligen linearen Ungleichungen
Satz L: Das Erfüllbarkeitsproblem der Aussagenlogik lässt sich in polynomieller Zeit auf die Erfüllbarkeit bei 0/1 ganzzahligen linearen Ungleichungen reduzieren. Beweis: Gegeben sei eine Instanz des Erfüllbarkeitsproblems der Aussagenlogik. Definieren Sie eine Menge von Ungleichungen mit einer 0/1-Variablen für jede boolesche Variable und einer 0/1-Variablen für jede Klausel, wie in Abbildung 6.28. Mit dieser Konstruktion können wir eine Lösung zum Problem der Erfüllbarkeit bei 0/1 ganzzahligen linearen Ungleichungen in eine Lösung zu dem Erfüllbarkeitsproblem der Aussagenlogik umwandeln, indem wir jeder booleschen Variablen den Wert true zuweisen, wenn die dazugehörige ganzzahlige Variable 1 ist, und false, wenn sie 0 ist.
961
6
Im Kontext
Korollar: Wenn die Erfüllbarkeit schwer zu lösen ist, dann gilt das auch für die ganzzahlige lineare Programmierung.
Dies ist eine aussagekräftige Aussage über die relative Schwierigkeit, diese beiden Probleme zu lösen, sogar wenn es keine genaue Definition der Bezeichnung „schwer zu lösen“ gibt. In dem Kontext hier meinen wir mit „schwer zu lösen“ eigentlich „nicht in P“. Im Allgemeinen verwenden wir den Begriff nicht effizient lösbar (intractable) für Probleme, die nicht in P sind. Auf der Grundlage der bahnbrechenden Arbeit von R. Karp 1972 haben Wissenschaftler gezeigt, dass Zehntausende von Problemen aus einer Vielzahl von Anwendungsbereichen über Reduktionsbeziehungen, wie sie hier beschrieben wurden, verwandt sind. Darüber hinaus implizieren diese Beziehungen viel mehr als nur Beziehungen zwischen den individuellen Problemen – ein Konzept, dem wir uns im Folgenden zuwenden wollen.
NP-Vollständigkeit Von sehr vielen Problemen ist bekannt, dass sie NP angehören, aber wahrscheinlich nicht P. Das heißt, wir können leicht bestätigen, dass jede gegebene Lösung gültig ist, doch trotz erheblicher Anstrengungen ist es noch niemandem gelungen, einen effizienten Algorithmus zu entwickeln, um eine Lösung zu finden. Bemerkenswerterweise haben alle diese vielen Probleme eine zusätzliche Eigenschaft, die das überzeugende Indiz liefert, dass P ≠ NP.
Definition: NP-vollständig Ein Suchproblem A wird als NP-vollständig bezeichnet, wenn alle Probleme in NP polynomiell auf A reduzierbar sind.
Diese Definition erlaubt uns, unsere Definition von „schwer zu lösen“ zu korrigieren in „nicht effizient lösbar, es sei denn P = NP“. Falls irgendeines der NP-vollständigen Probleme in einer polynomiellen Zeit auf einem deterministischen Automaten gelöst werden kann, so ist das für alle Probleme in NP möglich (d.h. P = NP). Das heißt, das kollektive Unvermögen aller Wissenschaftler, effiziente Algorithmen für alle diese Probleme zu finden, könnte als kollektives Unvermögen angesehen werden, zu beweisen, dass P = NP ist. NP-vollständige Probleme bedeuten, dass wir nicht davon ausgehen, garantiert polynomielle Algorithmen zu finden. Die meisten praktischen Suchprobleme gehören entweder P an oder sind NP-vollständig.
962
Im Kontext
Cook-Levin-Theorem Bei der Reduktion wird die NP-Vollständigkeit eines Problems genutzt, um die NP-Vollständigkeit eines anderen nachzuweisen. In einem Fall jedoch kann die Reduktion nicht verwendet werden: Wie wurde die NP-Vollständigkeit des ersten Problems bewiesen? Dies gelang S. Cook und L. Levin Anfang der 1970er unabhängig voneinander.
Satz M (Cook-Levin-Theorem): Das Erfüllbarkeitsproblem der Aussagenlogik ist NP-vollständig. Extrem kurze Beweisskizze: Ziel ist es zu zeigen, dass alle Probleme in NP in polynomieller Zeit gelöst werden können, wenn es einen polynomiellen Algorithmus für das Erfüllbarkeitsproblem der Aussagenlogik gibt. Da eine nichtdeterministische Turingmaschine jedes Problem in NP lösen kann, besteht der erste Schritt im Beweis darin, jedes Merkmal der Maschine in Form von logischen Formeln zu beschreiben, wie sie bei dem Erfüllbarkeitsproblem der Aussagenlogik auftreten. Diese Konstruktion stellt eine Analogie her zwischen jedem Problem in NP (welches als Programm auf der nichtdeterministischen Turingmaschine ausgedrückt werden kann) und einer Instanz des Erfüllbarkeitsproblems (der Übertragung dieses Programms in eine logische Formel). Damit besteht die Lösung des Erfüllbarkeitsproblems im Wesentlichen in einer Simulation der Maschine, die das gegebene Programm auf den gegebenen Eingabedaten ausführt, und somit eine Lösung für eine Instanz des gegebenen Problems erzeugt wird. Weitere Einzelheiten dieses Beweises würden weit über den Rahmen dieses Buches hinausgehen. Zum Glück benötigen wir nur einen solchen Beweis; es ist viel einfacher, zum Beweis der NP-Vollständigkeit eine Reduktion anzuwenden.
Das Cook-Levin-Theorem mitsamt den darauffolgenden Aber- P = NP tausenden von polynomiellen Reduktionen aus NP-vollständigen Problemen lässt uns mit zwei möglichen Universen P = NP zurück: Entweder ist P = NP und es gibt effizient lösbare Suchprobleme (alle Suchprobleme können in Polynomialzeit gelöst werden), oder P ≠ NP und es gibt keine effizient lösbaren Suchprobleme (einige Suchprobleme können nicht P ≠ NP in Polynomialzeit gelöst werden). NP-vollständige Probleme NP tauchen häufig in wichtigen praktischen Anwendungen auf, P NPC sodass der Anreiz groß ist, gute Algorithmen zu ihrer Lösung zu finden. Die Tatsache, dass für keines dieser Probleme ein guter Algorithmus gefunden werden konnte, spricht stark dafür, dass P ≠ NP ist – eine Annahme, die auch die meisten Abbildung 6.29: Zwei mögliWissenschaftler vertreten. Andererseits könnte die Tatsache, che Universen dass noch keiner beweisen konnte, dass irgendeines dieser Probleme nicht P angehört, auch so ausgelegt werden, dass Argumente für die Gegenseite vorliegen. Doch unabhängig davon, ob P = NP ist oder nicht – Fakt ist, dass der bekannteste Algorithmus für irgendeines der NP-vollständigen Probleme im schlimmsten Fall exponentielle Zeit benötigt.
963
6
Im Kontext
Klassifizierung von Problemen Um zu beweisen, dass ein Suchproblem in P liegt, müssen wir mit einem Algorithmus aufwarten, der das Problem in polynomiell beschränkter Zeit löst, vielleicht indem wir es auf ein Problem reduzieren, von dem bekannt ist, dass es in P liegt. Um zu beweisen, dass ein NP-Problem NP-vollständig ist, müssen wir zeigen, dass irgendein bekanntes NP-vollständiges Problem polynomiell darauf reduzierbar ist, d.h., dass ein polynomieller Algorithmus für das neue Problem verwendet werden könnte, um das NP-vollständige Problem zu lösen und um anschließend alle Probleme in NP zu lösen. Wie bei der ganzzahligen linearen Programmierung in Satz L wurde auf diese Weise für Abertausende von Problemen bewiesen, dass sie NP-vollständig sind. Tabelle 6.10, die auch einige von Karp untersuchte Probleme umfasst, ist repräsentativ, enthält aber nur einen Bruchteil der bekannten NP-vollständigen Probleme. Die Klassifizierung von Problemen in leicht lösbar (in P) oder schwer lösbar (NP-vollständig) kann unterschiedlich schwierig sein:
Einfach: Zum Beispiel beweist der altehrwürdige Gauß’sche Eliminationsalgorithmus, dass das Problem der Erfüllbarkeit bei linearen Gleichungen in P ist.
Knifflig, aber nicht zu schwer: Zum Beispiel benötigen Sie etwas Erfahrung und Übung, um einen Beweis wie in Satz L zu entwickeln, aber er ist einfach zu verstehen.
Extrem schwer: Zum Beispiel war die lineare Programmierung lange Zeit nicht klassifiziert. Der Ellipsoid-Algorithmus von Khachiyan erbrachte den Beweis, dass die lineare Programmierung P angehört.
Offen: So sind zum Beispiel die Isomorphie von Graphen (finde bei zwei gegebenen Graphen einen Weg, die Knoten so umzubenennen, dass der eine identisch ist zum anderen) und die Faktorisierung (finde bei einer gegebenen ganzen Zahl einen nicht trivialen Faktor) noch immer nicht klassifiziert. Wie Sie sehen, haben wir es mit einem interessanten, aktiven Forschungsbereich zu tun, in dem jedes Jahr immer noch Tausende von Forschungsberichten anfallen. Wie die letzten Einträge in Tabelle 6.10 zeigen, sind alle wissenschaftlichen Bereiche betroffen. Berücksichtigen Sie außerdem, dass unsere Definition von NP die Probleme einschließt, die Wissenschaftler, Ingenieure und Anwendungsprogrammierer in einer vernünftigen Zeit lösen wollen – alle diese Probleme bedürfen selbstverständlich einer Klassifizierung!
Tabelle 6.10 Einige berühmte NP-vollständige Probleme Erfüllbarkeitsproblem der Aussagenlogik: Gegeben sei eine Menge von M Gleichungen mit N booleschen Variablen. Finde eine Zuordnung der Werte zu den Variablen, die allen Gleichungen genügt, oder melde zurück, dass keine solche Zuordnung existiert.
Ganzzahlige lineare Programmierung : Gegeben sei eine Menge von M linearen Ungleichungen mit N ganzzahligen Variablen. Finde eine Zuordnung der Werte zu den Variablen, die allen Ungleichungen genügt, oder melde zurück, dass keine solche Zuordnung existiert.
964
Im Kontext
Tabelle 6.10 Einige berühmte NP-vollständige Probleme (Forts.) Lastverteilung : Gegeben seien eine Menge von zu erledigenden Jobs einer vorgegebenen Dauer und eine Zeitschranke T. Wie können wir die Jobs so auf zwei identische Prozessoren verteilen, dass wir sie alle in der Zeit T beenden? Knotenüberdeckung : Gegeben seien ein Graph und eine ganze Zahl C. Finde eine Menge von C Knoten, sodass jede Kante des Graphen inzident ist zu mindestens einem Knoten der Menge. Hamiltonpfad : Gegeben sei ein Graph. Finde einen einfachen Pfad, der jeden Knoten genau einmal besucht, oder melde zurück, dass kein solcher Pfad existiert. Proteinfaltung : Gegeben sei das Energieniveau M. Finde eine gefaltete dreidimensionale Konformation eines Proteins mit einer potenziellen Energie kleiner als M. Ising-Modell : Gegeben seien ein Ising-Modell auf einem dreidimensionalen Gitter und eine Energieschwelle E. Gibt es einen Teilgraphen mit einer freien Energie kleiner als E? Risikoportfolio einer gegebenen Rendite : Gegeben seien ein Investitionsportfolio mit gegebenen Gesamtkosten, einer gegebenen Rendite, Risikowerten, die jeder Investition zugeordnet sind, und einer Schwelle M. Finde einen Weg, die Investitionen zuzuweisen, sodass das Risiko kleiner ist als M.
NP-Vollständigkeit in der Praxis Dieses breite Spektrum an Problemen ist der Grund, warum das Interesse an einer Lösung extrem groß ist. Es ist unmöglich, diesem Themenkomplex in nur einem Absatz gerecht zu werden, aber wir können zumindest einige der verfolgten Ansätze hier kurz vorstellen. Ein Ansatz besteht darin, das Problem zu ändern und einen Näherungsalgorithmus zu finden, der nicht die beste Lösung liefert, aber eine Lösung, die garantiert nahe an der besten Lösung ist. So ist es zum Beispiel einfach, eine Lösung zu dem euklidischen Rundreiseproblem (auch Problem des Handlungsreisenden, traveling salesman problem) zu finden, die innerhalb eines Faktors von 2 des Optimums ist. Unglücklicherweise reicht dieser Ansatz oft nicht aus, um bei der Suche nach verbesserten Annäherungen die NP-Vollständigkeit abzuwenden. Ein anderer Ansatz besteht darin, einen Algorithmus zu entwickeln, der praktisch alle Instanzen effizient löst, die in der Praxis auftreten, auch wenn es Worst-Case-Eingaben gibt, für die sich keine vertretbaren Lösungen finden lassen. Das berühmteste Beispiel für diesen Ansatz sind die ILP-Löser (integer linear programming), die viele Jahrzehnte lang die Arbeitspferde zur Lösung riesiger Optimierungsprobleme in zahllosen industriellen Anwendungen waren. Auch wenn die Gefahr eines exponentiellen Zeitbedarfs besteht, sind die Eingaben, wie sie in der Praxis vorkommen, eindeutig keine Worst-Case-Eingaben. Ein dritter Ansatz beruht auf „effizienten“ exponentiellen Algorithmen, unter Verwendung der sogenannten Backtracking-Technik, um das Prüfen aller möglichen Lösungen zu vermeiden. Und schließlich gibt es eine große Lücke zwischen polynomieller und exponentieller Zeit, die von der Theorie nicht abgedeckt wird. Was ist mit einem Algorithmus, dessen Ausführungszeit proportional N log N oder 2√N ist? Alle Anwendungsbereiche, die wir in diesem Buch untersucht haben, sind von der NPVollständigkeit betroffen: NP-vollständige Probleme tauchen in der elementaren Programmierung ebenso auf wie in der Graphenverarbeitung, Stringverarbeitung, System-
965
6
Im Kontext
programmierung, im Bereich Operations Research, beim Sortieren und Suchen, beim wissenschaftlichen Rechnen und in jedem anderen Bereich, in dem Berechnungen eine Rolle spielen. Der wichtigste praktische Beitrag der Theorie der NP-Vollständigkeit ist, dass sie einen Mechanismus bietet um festzustellen, ob ein neues Problem von irgendeinem dieser vielfältigen Bereiche „leicht“ oder „schwer“ ist. Wenn man einen effizienten Algorithmus zur Lösung eines neuen Problems finden kann, dann gibt es keine Schwierigkeit. Wenn nicht, verrät uns ein Beweis der NP-Vollständigkeit des Problems, dass die Entwicklung eines effizienten Algorithmus eine fantastische Errungenschaft wäre (und legt nahe, vielleicht einen anderen Ansatz zu versuchen). Die vielen effizienten Algorithmen, die wir in diesem Buch untersucht haben, sind Beleg dafür, dass wir seit Euklid sehr viel über effiziente Berechnungsverfahren gelernt haben, wenn auch die Theorie der NP-Vollständigkeit zeigt, dass wir immer noch viel zu lernen haben.
Allgemeine Übungen zu der Kollisionssimulation
966
1.
Vervollständigen Sie die Implementierung predictCollisions() und Particle, wie im Text beschrieben. Es gibt drei Gleichungen, die die elastische Kollision zwischen einem Paar fester Scheiben bestimmen: (a) Erhaltung der linearen Bewegungsenergie, (b) Erhaltung der kinetischen Energie und (c) bei Kollision wirkt die Normalkraft senkrecht zur Fläche des Kollisionspunktes (unter der Annahme, dass keine Reibung oder Drall vorliegt). Weitere Einzelheiten entnehmen Sie bitte der Website zum Buch.
2.
Entwickeln Sie eine Version von CollisionSystem, Particle und Event, die Mehrteilchenkollisionen verarbeitet. Solche Kollisionen sind wichtig, wenn Sie den Anstoß in einem Billardspiel simulieren. (Dies ist eine schwierige Übung!)
3.
Entwickeln Sie eine Version von CollisionSystem, Particle und Event, die drei Dimensionen berücksichtigt.
4.
Prüfen Sie den Ansatz, die Performance von simulate() in CollisionSystem zu verbessern, indem Sie den Bereich in rechteckige Zellen teilen und einen neuen Ereignistyp hinzufügen, sodass Sie nur Kollisionen mit Teilchen in einer von neun benachbarten Zellen in irgendeinem Zeitquantum vorhersagen müssen. Dieser Ansatz reduziert die Anzahl der zu berechnenden Vorhersagen zulasten der Beobachtung der Teilchenbewegung von Zelle zu Zelle.
5.
Führen Sie das Konzept der Entropie in CollisionSystem ein und bestätigen Sie damit die klassischen Ergebnisse.
6.
Brown’sche Bewegung: 1827 beobachtete der Botaniker Robert Brown unter dem Mikroskop das Verhalten von Wildblumenpollen in Wasser. Er stellte fest, dass die Bewegungen der Pollenkörner unregelmäßig waren, und entwickelte daraus eine Theorie, die später als Brown’sche Bewegung bezeichnet wurde. Dieses Phänomen wurde viel diskutiert, aber eine überzeugende mathematische Erklärung lieferte erst Einstein im Jahre 1905: Die Bewegung der Pollenkörner wurde von Millionen von winzigen Molekülen verursacht, die mit den größeren Teilchen
Im Kontext
kollidierten. Simulieren Sie diesen Prozess, um dieses Phänomen zu veranschaulichen. 7.
Temperatur: Ergänzen Sie Particle um eine Methode temperature(), die das Produkt aus ihrer Masse und dem Quadrat des Betrags ihrer Geschwindigkeit geteilt durch dkB zurückliefert, wobei d = 2 die Dimension und kB = 1,3806503 ⋅ 10−23 die Boltzmann-Konstante ist. Die Temperatur des Systems ist der Durchschnittswert hiervon. Ergänzen Sie dann CollisionSystem um eine Methode temperature() und schreiben Sie ein Testprogramm, das die Temperatur in regelmäßigen Abständen einzeichnet, um zu prüfen, ob sie konstant ist.
8.
Maxwell-Boltzmann: Die Verteilung der Teilchengeschwindigkeiten in dem Modell der festen Scheibe gehorcht der Maxwell-Boltzmann-Verteilung (vorausgesetzt, das System ist thermalisiert und die Teilchen sind ausreichend schwer, sodass wir quantenmechanische Effekte ignorieren können), was als Rayleigh-Verteilung in zwei Dimensionen bekannt ist. Die Verteilungskurve hängt von der Temperatur ab. Schreiben Sie ein Testprogramm, das ein Histogramm der Teilchengeschwindigkeiten berechnet und für verschiedene Temperaturen testet.
9.
Beliebige Form: Moleküle bewegen sich schnell fort (schneller als ein rasendes Flugzeug), breiten sich aber nur langsam aus, da sie mit anderen Molekülen zusammenstoßen und so ihre Richtung ändern. Erweitern Sie das Modell um eine begrenzende Form, bestehend aus zwei durch ein Rohr verbundene Kessel, die zwei verschiedene Arten von Teilchen enthalten. Führen Sie eine Simulation aus und messen Sie den Anteil der beiden Teilchenarten in jedem Kessel als Funktion der Zeit.
10.
Zurückspulen: Negieren Sie nach Ausführung einer Simulation alle Geschwindigkeiten und führen Sie dann das System rückwärts aus. Es sollte den ursprünglichen Zustand zurückliefern! Ermitteln Sie die Abrundungsfehler, indem Sie den Unterschied zwischen dem endgültigen und ursprünglichen Zustand des Systems messen.
11.
Druck: Ergänzen Sie Particle um eine Methode pressure(), die den Druck misst, indem sie die Anzahl und Größe der Kollisionen gegen die Wände aufsummiert. Der Druck des Systems ist die Summe dieser Werte. Fügen Sie dann eine Methode pressure() zu CollisionSystem hinzu und schreiben Sie einen Client, der die Gleichung pv = nRT validiert.
12.
Implementierung mit indizierter Vorrangwarteschlange: Entwickeln Sie eine Version von CollisionSystem, die eine indizierte Vorrangwarteschlange verwendet, um zu garantieren, dass die Größe der Vorrangwarteschlange höchstens linear ist zur Anzahl der Teilchen (anstatt quadratisch oder schlechter).
13.
Performance der Vorrangwarteschlange: Überarbeiten Sie die Vorrangwarteschlange und testen Sie Pressure bei verschiedenen Temperaturen, um den rechnerischen Engpass zu ermitteln. Versuchen Sie gegebenenfalls, zu einer anderen Implementierung der Vorrangwarteschlange zu wechseln, um bei hohen Temperaturen eine bessere Leistung zu erzielen.
967
6
Im Kontext
Allgemeine Übungen zu B-Bäumen
968
14.
Angenommen wir können es uns in einem Baum mit drei Ebenen erlauben, a Referenzen im Arbeitsspeicher zu halten, zwischen b und 2b Referenzen in Seiten, die interne Knoten repräsentieren, und zwischen c und 2c Elemente in Seiten, die externe Knoten repräsentieren. Wie viele Elemente können wir maximal in einem solchen Baum als eine Funktion von a, b und c halten?
15.
Entwickeln Sie eine Implementierung von Page, die jeden B-Baumknoten als ein BinarySearchST-Objekt repräsentiert.
16.
Erweitern Sie BTreeSET zu einer BTreeST-Implementierung, die Schlüssel mit Werten assoziiert und unsere vollständige, geordnete Symboltabellen-API unterstützt, die min(), max(), floor(), ceiling(), deleteMin(), deleteMax(), select(), rank() und die Versionen von size() und get() mit zwei Argumenten umfasst.
17.
Schreiben Sie ein Programm, das mithilfe von StdDraw wachsende B-Bäume visualisiert (siehe Text).
18.
Schätzen Sie die durchschnittliche Anzahl von Sondierungen pro Suche in einem B-Baum für S Zufallssuchen in einem typischen Cache-System, bei dem die T zuletzt besuchten Seiten im Speicher gehalten werden (addieren Sie deshalb 0 zur Zahl der Sondierungen). Gehen Sie davon aus, dass S viel größer ist als T.
19.
Websuche: Entwickeln Sie eine Page-Implementierung, die B-Baumknoten als Textdateien in Webseiten repräsentiert, mit dem Ziel, das Web zu indizieren (Erstellung einer Konkordanz für das Web). Verwenden Sie eine Datei von Suchbegriffen. Nehmen Sie die zu indizierenden Webseiten von der Standardeingabe entgegen. Damit das Ganze nicht ausufert, übernehmen Sie einen Befehlszeilenparameter m und setzen Sie eine obere Grenze von 10m internen Knoten (informieren Sie sich bei Ihrem Systemadministrator, bevor Sie die Implementierung für große m ausführen). Verwenden Sie eine m-stellige Zahl als Name für Ihre internen Knoten. Wenn zum Beispiel m gleich 4 ist, könnten Ihre Knotennamen BTreeNode0000, BTreeNode0001, BTreeNode0002 und so weiter lauten. Halten Sie Stringpaare auf den Seiten und fügen Sie der API zum Sortieren und Schreiben eine Operation close() hinzu. Um Ihre Implementierung zu testen, suchen Sie nach Ihrem Namen oder den Namen Ihrer Freunde auf der Website Ihrer Universität.
20.
B*-Bäume: Betrachten wir die B*-Baum-Heuristik bzw. die Heuristik der Aufteilung auf Geschwisterknoten: Wenn es erforderlich ist, einen Knoten aufzuteilen, weil er M Einträge enthält, kombinieren wir den Knoten mit seinem Geschwisterknoten, d.h., wenn der Geschwisterknoten k Einträge aufweist, mit k < M − 1, ordnen wir die Elemente neu zu und verteilen auf den vollen Knoten und seinen Geschwisterknoten jeweils (M+k)/2 Einträge. Ist dies nicht möglich, so erzeugen wir einen neuen Knoten und verteilen auf jeden der drei Knoten 2M/3 Einträge. Außerdem erlauben wir, dass die Wurzel bis auf 4M/3 Elemente anwächst. Wird dieser Wert erreicht, dann teilen wir die Wurzel und erzeugen einen neuen Wurzelknoten mit zwei Einträgen. Geben Sie Grenzen zur Anzahl der Sondierungen
Im Kontext
an, die für eine Suche oder eine Einfügung in einem B*-Baum der Ordnung M mit N Elementen erforderlich ist. Vergleichen Sie Ihre Grenzwerte mit den entsprechenden Grenzwerten für B-Bäume (siehe Satz B). Entwickeln Sie eine Einfüge-Implementierung für B*-Bäume. 21.
Schreiben Sie ein Programm, um die durchschnittliche Anzahl der externen Seiten für einen B-Baum der Ordnung M zu berechnen, der mit N zufälligen Einfügungen in einen anfänglich leeren Baum erstellt wird. Führen Sie Ihr Programm mit vertretbaren Werten für M und N aus.
22.
Wenn Ihr System virtuellen Speicher unterstützt, führen Sie zur Zufallssuche in einer riesigen Symboltabelle eigene Experimente durch, um die Performance von B-Bäumen mit der Performance der binären Suche zu vergleichen.
23.
Führen Sie für Ihre Arbeitsspeicher-Implementierung von Page aus Übung 6.15 Experimente durch, um den Wert von M zu ermitteln, der zu den schnellsten Suchzeiten für eine B-Baum-Implementierung führt, die Zufallssuchoperationen in einer riesigen Symboltabelle unterstützt. Beschränken Sie sich auf Werte von M, die Mehrfaches von 100 sind.
24.
Führen Sie Experimente durch, um die Suchzeiten für interne B-Bäume (mit dem Wert für M aus der vorherigen Übung), Hashing mit linearer Sondierung und RotSchwarz-Bäume für Zufallssuchoperationen in einer riesigen Symboltabelle zu vergleichen.
Allgemeine Übungen zu Suffixarrays 25.
Geben Sie im Stil von Abbildung 6.14 die Suffixe, die sortierten Suffixe, index()und lcp()-Tabellen für die folgenden Strings an: a. abacadaba b. mississippi c. abcdefghij d. aaaaaaaaaa
26.
Wo liegt das Problem bei dem folgenden Codefragment, das alle Suffixe für die Suffixsortierung berechnet: suffix = ""; for (int i = s.length() - 1; i >= 0; i--) { suffix = s.charAt(i) + suffix; suffixes[i] = suffix; }
Antwort: Zeit- und Speicherbedarf sind quadratisch. 27.
In einigen Anwendungen werden von einem Text zyklische Rotationen erzeugt, die jeweils alle Zeichen des Texts umfassen. Für i von 0 bis N−1, besteht die i-te zyklische Rotation eines Texts der Länge N aus den letzten N−i Zeichen gefolgt
969
6
Im Kontext
von den ersten i Zeichen. Ermitteln Sie das Problem bei dem folgenden Codefragment, das alle zyklischen Rotationen berechnet: int N = s.length(); for (int i = 0; i < N; i++) rotation[i] = s.substring(i, N) + s.substring(0, i);
Antwort: Zeit- und Speicherbedarf sind quadratisch. 28.
Entwerfen Sie einen linearen Algorithmus, um die zyklischen Rotationen eines Textstrings zu berechnen.
Antwort: String t = s + s; int N = s.length(); for (int i = 0; i < N; i++) rotation[i] = r.substring(i, i + N);
970
29.
Geben Sie an, wie viel Speicher ein SuffixArray-Objekt mit einem String der Länge N belegt und legen Sie dabei die Annahmen in Abschnitt 1.4 zugrunde.
30.
Längster gemeinsamer Teilstring: Schreiben Sie einen SuffixArray-Client LCS, der zwei Dateinamen als Befehlszeilenargumente übernimmt, die beiden Textdateien liest und in linearer Zeit den längsten Teilstring findet, der in beiden Dateien vorkommt. (1970 war D. Knuth noch der Meinung, dass diese Aufgabe nicht lösbar wäre.) Hinweis: Erzeugen Sie ein Suffixarray für s#t, wobei s und t die beiden Textstrings sind und # ein Zeichen ist, das in keinem von ihnen erscheint.
31.
Burrows-Wheeler-Transformation: Die Burrows-Wheeler-Transformation (BWT) ist eine Transformation, die in Datenkomprimierungsalgorithmen wie bzip2 verwendet wird sowie in den Hochdurchsatzsequenzierungen der Genomik. Schreiben Sie einen SuffixArray-Client, der die BWT in linearer Zeit wie folgt berechnet: Gegeben sei ein String der Länge N, der durch ein besonderes Dateiendezeichen $ terminiert wird, das kleiner ist als alle anderen Zeichen. Betrachten Sie die N-mal-NMatrix, in der jede Zeile eine andere zyklische Rotation des ursprünglichen Textstrings enthält. Sortieren Sie die Zeilen lexikografisch. Die Burrows-WheelerTransformation ist die ganz rechte Spalte in der sortierten Matrix. So lautet beispielsweise die BWT von mississippi$ ipssm$pissii. Die inverse Burrows-Wheeler-Transformation (BWI) invertiert die BWT. Die BWI für ipssm$pissii lautet beispielsweise mississippi$. Schreiben Sie außerdem einen Client, der aus dem gegebenen BWT eines Textstrings in linearer Zeit den BWI berechnet.
32.
Linearisierung zirkulärer Strings: Schreiben Sie einen SuffixArray-Client, der für einen gegebenen String in linearer Zeit die zyklische Rotation findet, die lexikografisch die kleinste ist. Dieses Problem stellt sich in chemischen Datenbanken für zirkuläre Moleküle, in denen jedes Molekül als zirkulärer String dargestellt wird, und eine kanonische Repräsentation (kleinste zyklische Rotation) verwendet wird, um die Suche mit jeder Rotation als Schlüssel zu unterstützen (siehe Übungen 6.27 und 6.28).
Im Kontext
33.
Längster sich k-mal wiederholender Teilstring: Schreiben Sie einen SuffixArrayClient, der für einen gegebenen String und eine ganze Zahl k den längsten Teilstring findet, der k-mal oder häufiger wiederholt wird.
34.
Lange sich wiederholende Teilstrings: Schreiben Sie einen SuffixArray-Client, der für einen gegebenen String und eine ganze Zahl L alle sich wiederholenden Teilstrings der Länge L oder länger findet.
35.
Zählung von k-Gramm-Häufigkeiten: Entwickeln und implementieren Sie einen abstrakten Datentyp zur Vorverarbeitung eines Strings, um die effiziente Beantwortung der folgenden Abfrage zu unterstützen: Wie oft erscheint ein gegebenes kGramm? Jede Abfrage sollte im schlimmsten Fall Zeit proportional k log N benötigen, wobei N die Länge des Strings ist.
Allgemeine Übungen zu Max-Fluss 36.
Wenn Kapazitäten positive ganze Zahlen kleiner M sind, wie groß ist dann der größte mögliche Flusswert für irgendein s-t-Netzwerk mit V Knoten und E Kanten? Geben Sie zwei Antworten in Abhängigkeit davon, ob parallele Kanten erlaubt sind oder nicht.
37.
Geben Sie einen Algorithmus an, um das Max-Fluss-Problem für den Fall zu lösen, dass das Netzwerk einen Baum bildet, wenn die Senke entfernt wird.
38.
Wahr oder falsch? Ist die Aussage wahr, dann liefern Sie einen kurzen Beweis, ist sie falsch, liefern Sie ein Gegenbeispiel: a. In keinem Max-Fluss gibt es einen gerichteten Zyklus, in dem jede Kante einen positiven Fluss hat. b. Es gibt einen Max-Fluss ohne einen gerichteten Zyklus, in dem jede Kante einen positiven Fluss hat. c. Wenn alle Kantenkapazitäten unterschiedlich sind, ist der Max-Fluss eindeutig. d. Wenn alle Kantenkapazitäten um eine additive Konstante erhöht werden, bleibt der Min-Schnitt unverändert. e. Wenn alle Kantenkapazitäten mit einer ganzen Zahl multipliziert werden, bleibt der Min-Schnitt unverändert.
39.
Vervollständigen Sie den Beweis aus Satz G: Zeigen Sie, dass jedes Mal, wenn eine Kante eine kritische Kante ist, sich die Länge des darüberführenden augmentierenden Pfades um 2 erhöht wird.
40.
Suchen Sie online nach einem großen Netzwerk, das Sie als Medium nutzen können, um Flussalgorithmen auf realistischen Daten zu testen. Anbieten würden sich hierzu Transportnetze (Straße, Schiene oder Luft), Kommunikationsnetze (Telefon oder Computerverbindungen) oder Verteilungsnetze. Wenn keine Kapazitäten zur Verfügung stehen, entwickeln Sie ein vernünftiges Modell, um sie hinzuzufügen. Schreiben Sie ein Programm, das die Schnittstelle verwendet, um aus Ihren Daten
971
6
Im Kontext
Flussnetzwerke zu implementieren. Entwickeln Sie gegebenenfalls zusätzliche private Methoden, um die Daten zu bereinigen. 41.
Schreiben Sie einen Zufallsnetzgenerator für dünne Netzwerke mit ganzzahligen Kapazitäten zwischen 0 und 220. Verwenden Sie eine separate Klasse für Kapazitäten und entwickeln Sie zwei Implementierungen: Eine, die gleichverteilte Kapazitäten erzeugt, und eine weitere, die Kapazitäten gemäß der Gauß’schen Verteilung erzeugt. Implementieren Sie Client-Programme, die Zufallsnetzwerke für beide Gewichtsverteilungen erzeugen mit einer wohl gewählten Menge von Werten für V und E, sodass Sie damit empirische Tests auf Graphen ausführen können, die auf der Basis verschiedener Verteilungen der Kantengewichte gezeichnet werden.
42.
Schreiben Sie ein Programm, das V Zufallspunkte in der Ebene erzeugt, dann ein Flussnetzwerk mit Kanten (in beide Richtungen) erstellt, die alle Paare von Punkten innerhalb einer gegebenen Distanz d zueinander verbinden und unter Verwendung eines der in der vorherigen Übung beschriebenen Zufallsmodelle die Kapazität jeder Kante setzen.
43.
Grundlegende Reduktionen: Entwickeln Sie FordFulkerson-Clients, um einen Max-Fluss in jedem der folgenden Arten von Flussnetzwerken zu finden: – Ungerichtet – Keine Beschränkung hinsichtlich der Anzahl der Quellen oder Senken oder der Kanten, die zu der Quelle führen oder die Senke verlassen – Untere Schranken auf den Kapazitäten – Kapazitätsbeschränkungen auf den Knoten
972
44.
Produktverteilung: Angenommen, ein Fluss repräsentiert Produkte, die von Lkws zwischen Städten transportiert werden, wobei der Fluss auf der Kante u-v die Menge repräsentiert, die an einem gegebenen Tag von der Stadt u zur Stadt v transportiert wird. Schreiben Sie einen Client, der die täglichen Aufträge für die Fahrer ausgibt und ihnen mitteilt, wo sie wie viel Güter aufnehmen und wie viel sie wo abliefern sollen. Gehen Sie dabei davon aus, dass das Fahreraufgebot unbeschränkt ist und etwas einen gegebenen Verteilungspunkt erst verlässt, wenn alles zugestellt worden ist.
45.
Jobvermittlung: Entwickeln Sie einen FordFulkerson-Client, der das Problem der Jobvermittlung mithilfe der Reduktion in Satz J löst. Verwenden Sie eine Symboltabelle, um symbolische Namen in ganze Zahlen umzuwandeln, die im Flussnetzwerk Verwendung finden.
46.
Konstruieren Sie eine Familie von bipartiten Matching-Problemen, bei denen die durchschnittliche Länge der augmentierenden Pfade, wie sie die Algorithmen des augmentierenden Pfades zur Lösung des dazugehörigen Max-Fluss-Problems verwenden, proportional E ist.
Im Kontext
47.
s-t-Zusammenhang: Entwickeln Sie einen FordFulkerson-Client, der für einen gegebenen ungerichteten Graphen G und die Knoten s und t die minimale Anzahl von Kanten in G findet, deren Entfernen t von s trennt.
48.
Disjunkte Pfade: Entwickeln Sie einen FordFulkerson-Client, der für einen gegebenen ungerichteten Graphen G und die Knoten s und t die maximale Anzahl von kantendisjunkten Pfaden von s zu t findet.
Allgemeine Übungen zu Reduktionen und scheinbarer Unlösbarkeit 49.
Finden Sie einen nicht-trivialen Faktor von 37703491.
50.
Beweisen Sie, dass sich das Problem der kürzesten Pfade auf lineare Programmierung reduzieren lässt.
51.
Könnte es einen Algorithmus geben, der ein NP-vollständiges Problem in einer durchschnittlichen Zeit von N log N löst, wenn P ≠ NP ist? Erläutern Sie Ihre Antwort.
52.
Angenommen, irgendjemand entdeckt einen Algorithmus, der das Erfüllbarkeitsproblem der Aussagenlogik garantiert in einer Zeit proportional 1,1N löst. Impliziert dies, dass wir andere NP-vollständige Probleme ebenfalls in einer Zeit proportional 1,1N lösen können?
53.
Wie bedeutend wäre ein Programm, das das Problem der ganzzahligen linearen Programmierung in einer Zeit proportional 1,1N lösen könnte?
54.
Geben Sie eine Reduktion in polynomieller Zeit von der Knotenüberdeckung zur Erfüllbarkeit von 0/1 ganzzahligen linearen Ungleichungen.
55.
Beweisen Sie, dass das Problem, einen Hamiltonpfad in einem gerichteten Graphen zu finden, NP-vollständig ist, und zwar unter Verwendung der NP-Vollständigkeit des Hamiltonpfadproblems für ungerichtete Graphen.
56.
Angenommen, von zwei Problemen ist bekannt, dass sie NP-vollständig sind. Impliziert dies, dass es eine Reduktion in polynomieller Zeit von einem Problem zum anderen gibt?
57.
Angenommen, X ist NP-vollständig, X ist polynomiell reduzierbar auf Y und Y polynomiell reduzierbar auf X. Ist Y damit automatisch NP-vollständig?
Antwort: Nein, da Y nicht unbedingt in NP ist. 58.
Angenommen, wir haben einen Algorithmus, um die Entscheidungsversion des Erfüllbarkeitsproblems der Aussagenlogik zu lösen, was darauf hindeutet, dass es eine Belegung von Wahrheitswerten zu den Variablen gibt, die den booleschen Ausdruck erfüllen. Zeigen Sie, wie Sie die Belegung finden.
59.
Angenommen, wir haben einen Algorithmus, um die Entscheidungsversion der Knotenüberdeckung zu lösen, was darauf hindeutet, dass eine Knotenüberdeckung einer gegebenen Größe existiert. Zeigen Sie, wie Sie die Optimierungsversion lösen, die die Knotenüberdeckung minimaler Kardinalität findet.
973
6
Im Kontext
60.
Erläutern Sie, warum die Optimierungsversion der Knotenüberdeckung nicht notwendigerweise ein Suchproblem ist.
Antwort: Es scheint keinen effizienten Weg zu geben, zu prüfen, dass eine vorgegebene Lösung die bestmögliche ist (auch wenn wir bei der Suchversion des Problems die beste Lösung mithilfe der binären Suche finden können). 61.
Angenommen, X und Y sind zwei Suchprobleme und X ist polynomiell reduzierbar auf Y. Welche der folgenden Schlussfolgerungen treffen zu? a. Wenn Y NP-vollständig ist, dann trifft das auch für X zu. b. Wenn X NP-vollständig ist, dann trifft das auch für Y zu. c. Wenn X in P ist, dann ist auch Y in P. d. Wenn Y in P ist, dann ist auch X in P.
62.
Angenommen, P ≠ NP. Welche der folgenden Schlussfolgerungen treffen zu? a. Wenn X NP-vollständig ist, dann kann X nicht in Polynomialzeit gelöst werden. b. Wenn X in NP ist, dann kann X nicht in Polynomialzeit gelöst werden. c. Wenn X in NP, aber nicht NP-vollständig ist, dann kann X in Polynomialzeit gelöst werden. d. Wenn X in P ist, dann ist X nicht NP-vollständig.
974
Register Numerics 2-3-Suchbäume 453 globale Eigenschaften 459 lokale Transformationen 458 2-Bit-Codedekomprimierung 861 2-Bit-Codekomprimierung 860 2-Summen-Problem 209 3-Summen-Problem 210 3-Wege-Quicksort für Strings 762 Performance 766 Randomisierung 765
A Ablaufplanung Methode des kritischen Pfades 708 mit Deadlines 710 parallele Aufgaben 705 Vorrangbedingungen 617 Abrundungsfunktion 395 Abstrakte Datentypen 82 Accumulator 114 Beispiele 92, 110 Client-Code 83 Collections 140 Date 110 Design 116 Ein-/Ausgabe 101 geerbte Methoden 83 Generics 141 grafischer Akkumulator 114 Gültigkeitsbereich 107 implementieren 104 Instanzmethoden 106 Instanzvariablen 105 Konstruktoren 105 Objekte 84 Objekte als Argumente 89 Objekte als Rückgabewerte 89 Objekte erzeugen 85 Objekte verwenden 87 Referenztypen 84 String 99 Zuweisungen 87 Adjazenzlisten 559, 560 Adjazenzmatrix 559
Adjazenzmengen 563 ADT siehe abstrakte Datentypen Algorithmen 3-Wege-Quicksort für Strings 764 Analyse 191 Bottom-Up-Mergesort 303 Boyer-Moore-Teilstringsuche 816 Breitensuche 580 der augmentierenden Pfade 934 Dijkstra 150, 378 euklidischer 21 Fingerprint-Suche 821 Ford-Fulkerson 934 generische 694 Greedy- 650 Heaps 341, 345 Heapsort 352 Huffman 379 Huffman-Komprimierung 879 Insertionsort 275 Johnson 734 KMP-Teilstringsuche 811 Kruskal 379 Las-Vegas 820 LSD-Sortierverfahren 750 LZW-Dekomprimierung 888 LZW-Komprimierung 885 Monte-Carlo 820 MSD-Sortierverfahren 755 Musterabgleich 845 optimale 255 Prim 378 Quick-Find 245 Quicksort 314 Quick-Union 247 Rabin-Karp-Teilstringsuche 821 randomisierte 219 schnellere entwerfen 209 Selectionsort 273 Shellsort 283 Suche in B-Bäumen 916 Suffixarray-Implementierung 926 Tiefensuche 567 Top-Down-Mergesort 298 Verbesserungen 320 Vorrangwarteschlange 345 Algorithmendesign Reduktionen 372
975
Register
Aliasing 38 Allokation sequenzielle 176 verkettete 176 Alphabete 741 API 741 spezifizierte 754 Standard- 742 Zahlen 744 Analysen Algorithmen 191 Beobachtungen 192 mathematische Modelle 198 Messdaten 196 Problemgröße 192 wissenschaftliche Methoden 191 Anweisungen 31 Aufrufe 32 bedingte 33 Block- 33 break 34 continue 34 Deklarationen 32 foreach 142 Rückgabewerte 32 Schleifen 33 Zuweisungen 32 API Accumulator 113 Alphabete 741 B-Baum 915 Date 110 Digraphen 609 entwerfen 117 Flussnetzwerk 933 für abstrakten Datentyp 82 für starke Komponenten 631 für ungerichtete Graphen 557 kantengewichtete Digraphen 684 kantengewichtete Graphen 651 Math 47 Mengen 522 minimale Spannbäume 656 Particle 903 Pfadsuche 572 SET 522 spezifizieren 108 StdDraw 62 String 100 Suffixarrays 922 Symbolgraphen 590
976
Symboltabelle 390 Union-Find 241 Zusammenhangskomponenten 583 Arrays 36 Aliasing 38 ausgefranste 38 Duplikate 372 eindimensionale 36 erzeugen 36 geordnete 405 Größenanpassung 155, 346, 506 initialisieren 36 Iteration 158 Objekte 90 partitionieren 313 sortieren 267 Speicherbedarf 224 traversieren 167 Überlauf 156 unveränderliche Schlüssel 346 verwenden 37 von Objekten 90 Zeichen 740 zeichenindizierte 743 zweidimensionale 38 ASCII-Codierung 856 Assertionen 129 Aufrufe 32 Aufrundungsfunktion 395 Ausdrücke 28, 30 arithmetische 147 boolesche 31 Ausgabe 54 binäre 853 formatierte 56 Standardausgabe 55 Ausgangsgrad 608 Ausgefranste Arrays 38 Ausnahmen 128 Autoboxing 141 Automaten deterministische endliche 806 nichtdeterministische endliche 836 Autounboxing 142 AVL-Suchbäume 484 Azyklische Graphen 555 Azyklische kantengewichtete Digraphen 701 kürzeste Pfade 701 Kürzeste-Pfade-Problem 711 längste Pfade 704
Register
B B*-Bäume 968 Bad-Character-Heuristik 813 Bag-Implementierung 174 Bags 143 Balancierte Suchbäume 453 Eins-zu-eins-Repräsentation 463 Farbrepräsentation 463 Implementierung 470 Löschen 473 Rotationen 464 Bäume Definition 250 der kürzesten Pfade 683 Heap-geordnete 339 B-Bäume 909 API 915 Konventionen 911 Kostenmodell 910 Performance 915 Repräsentation 914 Speicherbedarf 917 Suchen und Einfügen 912 Bedingte Anweisungen 33 Befehlszeilenargumente 53 Bellman-Ford-Algorithmus 715 Ablaufprotokoll 716 Arbitrage-Beispiel 723 Erkennung negativer Zyklen 721 Implementierung 717 negative Gewichte 720 Benachbarte Knoten 554 BFS (breadth-first search) siehe Breitensuche Bibliotheken buchspezifische 48 eigene 50 externe 45 Java 48 Binäre Ein-/Ausgabe 853 Binäre Heaps 339, 340 Definition 340 Repräsentation 340 Binäre Suchbäume 424 Analyse 432 Analyse ordnungsbasierter Operationen 441 auf-/abrunden 435 Auswahl 436 AVL 484
Bereichsabfragen 441 Einfügen 429 Floor/Ceiling 435 löschen 439 Minimum/Maximum 435 ordnungsbasierte Methoden 435 Rang ermitteln 437 Rekursion 430 Repräsentation 425 Suchen 426 Binäre Suche 65, 408 Analyse 411 Entwicklungsclient 66 im Array 65 Kosten 412 Laufzeitverhalten 68 Positivlisten 66 Weiße Listen 66 Bipartite Graphen 556 Blockanweisungen 33 Boltzmann-Konstante 967 Boruvka, O. 672 Bottom-Up Heaps 342 Mergesort 301, 303 Boyer-Moore-Teilstringsuche 812, 813 break-Anweisungen 34 Breitensuche 577 Ablaufprotokoll 578 Algorithmus 580 Kürzeste-Pfade-Problem 577 Laufzeitverhalten 581 BST siehe Binäre Suchbäume Burrows-Wheeler-Transformation 970
C Callback-Mechanismus 364 Chazelle, B. 672 Church-Turing-These 954 Code Refactoring 919 Collections Arrays 155 Generics 153 Iteration 158 Iterator 159 iterierbare 142 Stapel fester Kapazität 151 verkettete Listen 162 Comparator (Schnittstelle) 366
977
Register
continue-Anweisungen 34 Cook-Levin-Theorem 963 CPM-Methode 708
D Daten komprimieren 851 Pipelining 59 umleiten 59 Datenabstraktion 81 Datenkomprimierung 851 2-Bit-Codedekomprimierung 861 2-Bit-Codekomprimierung 860 ASCII-Codierung 856 Beschränkungen 857 Binärdaten 853 binäre Speicherauszüge 855 Genomik 860 Huffman 868 Komprimierungsrate 852 Lauflängencodierung 863 LZW 883 Modell 852 Unentscheidbarkeit 859 universelle 857 verlustlose 852 Datenstrukturen Adjazenzlisten 560 Adjazenzmatrix 559 B-Bäume 910 Tries 775 Datentypdesign 116 Datentypen abstrakte 82 APIs entwerfen 117 Deque 186 Digraphen 609 kantengewichtete Digraphen 684 kantengewichtete Graphen 651 Kapselung 116 Nachbedingungen 129 Nebeneffekte 129 primitive 28, 371 sortieren 371 Steque 186 Stringumwandlung 122 Umwandlung 30 ungerichtete Graphen 557 unveränderliche 126
978
verschiedene sortieren 364 Vorbedingungen 129 Vorrangwarteschlange 334 Wrapper-Klassen 123 Wrappertypen 123 DEA Haltezustand 807 Konstruktion 808 Nichtübereinstimmungsübergang 806 Simulation 806 Übereinstimmungsübergang 806 Deklarationen 32 Gültigkeitsbereich 32 mit Initialisierung 34 Deque 186 Design by Contract 128 Deterministische endliche Automaten siehe DEA DFS (depth-first search) siehe Tiefensuche Dichte Graphen 556 Digraphen Anfangsknoten 608 Aufbau 608 Ausgangsgrad 608 azyklische 617, 619 Datentyp 609 einfacher Zyklus 608 Eingabeformat 610 Eingangsgrad 608 Endknoten 608 Erreichbarkeit 612, 636 euklidische 644 gerichteter Pfad 608 gerichteter Zyklus 608 Gitter- 644 Hamilton-Pfad 643 Kosaraju-Algorithmus 631 letzter gemeinsamer Vorgänger 642 Pfad mit kürzestem Nachfolgerabstand 642 Pfadsuche 616 Postorder-Knotenreihenfolge 622 Preorder-Knotenreihenfolge 622 Repräsentation 610 Speicherbereinigung 615 starke Komponenten 629 starke Zusammenhangskomponenten 629 starker Zusammenhang 628 symbolische Namen 610 topologische Ordnung 622
Register
transitive Hülle 636 umgekehrte Postorder-Knotenreihenfolge 622 Umkehrung 610 Zykluserkennung 619 Dijkstra, Edsgar 148 Dijkstra-Algorithmus 378, 694 Ablaufprotokoll 696 Datenstrukturen 695 Varianten 698 d-näre Heaps 346 Doppelte Sondierung 516 Dünne Graphen 556 Duplikate 372
E Eager deletion 391 Eindimensionale Arrays 36 Eingabe 54 binäre 853 Eingabemodelle 218 Eingangsgrad 608 Entropie-optimales Sortieren 321 Entscheidnungsprobleme 957 Entwicklungsclient 66 Entwurfsmuster 564 Ereignisgesteuerte Simulation 899, 900 Code 905 Ereignisse 904 Performance 909 Erfüllbarkeitsprobleme 956 Erreichbarkeit 612, 636 Euklidische Graphen 604 Euklidischer Algorithmus 21 Eulersche Zyklen 604 Exceptions siehe Ausnahmen Externe Bibliotheken 45
F Fail-Fast-Iterator 180 FBC siehe fragile Basisklasse FIFO-Warteschlangen 144 Fingerprint-Suche 817 Grundkonzept 817 Hashfunktion 818 Monte-Carlo-Korrektur 820 Ford-Fulkerson-Algorithmus 934 foreach-Anweisungen 142
Formatierte Ausgabe 56 for-Schleifen 35 Fragile Basisklasse 133 Fredman, M. L. 671 Funktionsabstraktion 27
G Geerbte Methoden 83 Generics 141 Gerichtete Graphen siehe Digraphen Gewichtetes Quick-Union 251 Ablaufprotokoll 253 Analyse 251 mit Pfadkompression 255 Gittergraphen 605 Graphenverarbeitung Bellman-Ford-Algorithmus 715, 716 Breitensuche 577 Dijkstra-Algorithmus 694 Entwurfsmuster 564 Erreichbarkeit 612 Komponenten 583 Kosaraju-Algorithmus 631 kritischer Pfad 706 Kruskal-Algorithmus 668 kürzeste Pfade 680 längste Pfade 704 minimale Spannbäume 646 Pfadsuche 572 Prim-Algorithmus 658 Schnitte 649 starke Komponenten 629 starke Zusammenhangskomponenten 629 Symbolgraphen 589 Tiefensuche 566 transitive Hülle 636 ungerichtete Graphen 553 Union-Find 587 Greedy-Algorithmus 650 GREP 847 Gültigkeitsbereich 32, 107
H Hamilton'sche Zyklen 604 Hamiltonpfadprobleme 957 Handles 133 Hashattacke 517
979
Register
Hashfunktionen 490 Hashing doppelte Sondierung 516 Kollisionsauflösung 496 Kuckucks- 517 mit linearer Sondierung 501 mit offener Adressierung 501 mit Verkettung 496 Hashtabellen 489 Heap-Ordnung Bottom-Up-Verfahren 342 Top-Down-Verfahren 342 Heaps Algorithmen 341 binäre 339, 340 Definition 339 d-näre 346 Größenanpassung von Arrays 346 größtes Element entfernen 343 Konstruktion 350 neues Element einfügen 343 Operationen 344 Repräsentation 340 unveränderliche Schlüssel 346 Heapsort 350 Ablaufprotokoll 352 absteigendes Sortieren 353 Algorithmus 352 grafisches Ablaufprotokoll 353 Heap-Konstruktion 350 Horner-Schema 818 Huffman-Codierung 868, 869 Dekomprimierung 871 Implementierung 878 Komprimierung 872 Optimalität 875 präfixfreier Code 868 Preorder-Traversierung 877 Triekonstruktion 873 Huffman-Kompression 379
I Implementierungsvererbung 121 Implikationsgraphen 643 Indizierungsclients 531 Infixnotation 30 Inkrement-/Dekrement-Operatoren 34 Inorder-Traversierung 441
980
In-Place-Mergen 294 In-Place-Partitionierung 317 Insertionsort Ablaufprotokoll 275 Algorithmus 275 Performance 274 Instanzmethoden 106 Instanzvariablen 105, 107 Interface siehe Schnittstellen Interpolationssuche 420 Intervallgraphen 605 Invertierter Index 533 Iteration 158, 392 Iteratoren 159 Fail-Fast 180 Stapel 147
J Jarnik-Algorithmus 671 Johnson-Algorithmus 734 Josephus-Problem 187
K Kanten kreuzende 649 kritische 676 parallele 553 reflexive 553 ungerichtete Graphen 553 Kantengewichtete Digraphen API 684 Dijkstra-Algorithmus 694 negative Zyklen 713 Optimalitätsbedingungen 692 Pfadgewicht 680 Kantengewichtete Graphen Datentyp 651 Kreiseigenschaft 674 kritische Kanten 676 Kruskal-Algorithmus 668 minimaler Spannbaum 646 minimaler Spannwald 646 Repräsentation 652 Kantenrelaxation 689 Kapselung 116 Kendall-Tau-Distanz 373 Kleene'sche Satz 837
Register
KMP-Teilstringsuche 804, 806 DEA-Konstruktion 808 DEA-Simulation 806 endliche Automaten 806 Musterzeiger zurücksetzen 805 Knoten benachbarte 554 Exzentrizität 601 Farbe wechseln 468 Farbrepräsentation 463 Quelle 640 Rotationen 464 Senke 640 ungerichtete Graphen 553 Knotenrelaxation 691 Knoten-Verbund 162 Knuth-Morris-Pratt siehe KMP Kollisionen auflösen 902 Behandlung 902 Ereignisse 904 Simulationscode 905 vorhersagen 901 Kollisionsauflösung 496 Komponenten 583 starke 629 Konstruktoren 105 Kosaraju-Algorithmus 631 Kostenmodelle 203 B-Bäume 910 Sortieren 269 Suche 397 Union-Find 243 Kreiseigenschaft 674 Kreuzende Kanten 649 Kruskal-Algorithmus 379, 668 Kuckucks-Hashing 517 Kürzeste-Pfade-Problem 680 azyklische kantengewichtete Digraphen 701 Baum der kürzesten Pfade 683 Bellman-Ford-Algorithmus 715 Client-Abfragemethoden 691 Datenstrukturen 689 Dijkstra-Algorithmus 694 Eigenschaften 682 generischer Algorithmus 694 Kantenrelaxation 689 Knotenrelaxation 691 negative Gewichte 713 Optimalitätsbedingungen 692 theoretische Grundlagen 692
Kürzeste-Pfade-Probleme mit einem Startknoten (SSSP) 577 Kurzschlussoperatoren 71 Kurzschreibweisen 34
L Labyrinth 566 Längste Pfade 704 Lauflängencodierung 863 Bitmaps 864 Implementierung 865 Laufzeitanalysen amortisierte 220 Beobachtungen 192 Fallstricke 216 Kostenmodelle 203 mathematische Modelle 198 Speicherbedarf 221 Tilde-Approximation 199 Wachstumshypothesen 202 Wachstumsordnungen 206 wissenschaftliche Methoden 191 Laufzeitverhalten binäre Suche 68 grafische Darstellung 196 Quicksort 318 Stoppuhr 196 Lazy deletion 391 LIFO-Prinzip 146 Lineare Programmierung 951 Lineare Sondierung 501 Analyse 505 Clusterbildung 504 Listen ungeordnete verkettete 402 verkettete 162 Loitering 157 Lokale Variablen 107 Löschen sofortiges 391 verzögertes 391 LSD-Radixsort 751 LSD-Sortierverfahren 749 LZW-Codierung Vorausschauzeichen 883 LZW-Komprimierung 883 Dekomprimierung 884 Implementierung 887 Probleme 886 Trie-Repräsentation 883
981
Register
M Mark-and-Sweep-Speicherbereinigung 615 Mathematische Modelle Kostenmodell 203 Näherungswerte für die Laufzeit bestimmen 200 Tilde-Approximation 199 Wachstumshypothesen 202 Max-Fluss-Min-Schnitt-Theorem 935 Max-Fluss-Problem 931 Maxwell-Boltzmann-Verteilung 967 Medianstatistik 373 Median-von-Drei-Partitionierung 321 Mehrwege-Mergesort 312 Mehrwege-Mischen 348 Mengen-APIs 522 Merge-Operationen 294 Mergesort 294 Algorithmus 298, 303 Bottom-Up 301, 303 In-Place-Mergen 294 Mehrwege-Mergesort 312 Top-Down 296, 298 verbessern 299 Methoden geerbte 83 Namen überladen 42 Nebeneffekte 42 Pass-by-Value 42 Rekursion 43 statische 39 Trémaux 566 wissenschaftliche 191 Minimale Spannbäume 646 API 656 Dijkstra-Algorithmus 694 Greedy-Algorithmus 650 Kruskal-Algorithmus 668 Prim-Algorithmus 658 Schnitteigenschaft 649 Testclient 656 Minimaler Spannwald 646, 647 Min-Schnitt-Problem 936 Mischsortieren 294 Mischtypoperatoren 31 Modell der festen Scheibe 899 Modulare Programmierung 44 Modultests 45 Monte-Carlo-Algorithmus 820 Moore'sches Gesetz 215
982
MSD-Sortierverfahren 752 Performance 759 Performancedaten 767 Zufallsstring-Modell 759 Multimengen 143 Implementierung 174 Musterabgleich Genomik 835 Kleene'sche Satz 837 NEA-Konstruktion 842 NEA-Simulation 839 nichtdeterministische endliche Automaten 836 reguläre Ausdrücke 829
N Natürliche Ordnung (Sortieren) 270 NEA Erreichbarkeit 840 Konstruktion 842 Repräsentation 840 Simulation 839 Teilstringsuche 836 Negativlisten 524 Netzwerkflüsse 928 API 933 Flussnetzwerk 931 Ford-Fulkerson-Algorithmus 934 Max-Fluss-Problem 931 Performance 944 physikalisches Modell 929 Restnetzwerk 932 s-t-Fluss 931 Nichtdeterminismus 958 Nichtdeterministische endliche Automaten siehe NEA NP-Probleme 956 NP-Vollständigkeit 962 null-Schlüssel 391
O Objekte als Argumente 89 als Rückgabewerte 89 Arrays 90 Eigenschaften 92 erzeugen 85 geometrische 95
Register
Gleichheit 123 Identität 84 instanziieren 85 Speicherbedarf 223 Verhalten 84 verwenden 87 Zustand 84 Operatoren Dekrement/Inkrement 34 Mischtyp- 31 Vergleichs- 31 zusammengesetzte 34 Optimalitätsbedingungen kantengewichtete Digraphen 692 Schnitt 677 Optimierungsprobleme 957 Ordnungen alternative 366 natürliche 270 totale 270 Ordnungsstatistik 373
P Parallele Kanten 553 Parametervariablen 107 Parametrisierte Typen 141 Partitionierung 313 3-Wege 324 In-Place 317 Median-von-Drei 321 Pivotelement 313 Pass-by-Value 42 Pattern Matching siehe Musterabgleich Pfade einfache 554 kritische 706 kürzeste augmentierende 941 Pfadsuche 572 Ablaufprotokoll 575 Digraphen 616 Pipelining 59 Pivotelement 313 Positivlisten 66, 524 Postorder-Knotenreihenfolge 622 Potenzgesetz 198 P-Probleme 958 Präfixfreier Code 868 Preorder-Knotenreihenfolge 622 Preorder-Traversierung 877 Prim-Algorithmus 378, 658
Ablaufprotokoll der Eager-Version 665 Ablaufprotokoll der Lazy-Version 661 Datenstrukturen 659 Eager-Version 663 Laufzeit 663 Lazy-Version 661 Prioritätswarteschlangen siehe Vorrangwarteschlangen Probleme Entscheidungs- 957 Erfüllbarkeits- 956 Hamiltonpfad 957 Klassifizierung 964 NP 956 Optimierungs- 957 P 958 Such- 956 Problemgröße 192 Programmbeispiele Bag-Implementierung 175 Dedup 523 dünnbesetzte Vektoren 537 FIFO-Warteschlange 172 Filter 523 FrequencyCounter 399 Indizierungsclients 531 invertierter Index 533 LIFO-Stapel 161 Positiv-/Negativlisten 524 Stapel mit verketteter Liste 170 Union-Find 244 Wörterbuch-Clients 526 Programmiermodell 25 Programmiersprache streng typisiert 32 Promotion 30
Q Quelle 640 Queue 144 Implementierung 171 Quick-Find 245 Ablaufprotokoll 246 Analyse 246 Quicksort 313 3-Wege-Partitionierung 324 Ablaufprotokoll 314 Algorithmus 314 Entropie-optimales Sortieren 321 In-Place-Partitionierung 317
983
Register
Laufzeitverhalten 318 Median-von-Drei-Partitionierung 321 partitionieren 313 Pivotelement 313 Sortierwechsel zu Insertionsort 321 Verbesserungen 320 Quick-Union 247 Ablaufprotokoll 249 Analyse 249 gewichtetes 251
R Rangfolgen 373 Rangkorrelationskoeffizienten 373 Records 163 Reduktion 372, 946 auf Sortieren 946 Duplikate 372 in Polynomialzeit 960 Kendall-Tau-Distanz 373 Kürzeste-Pfade-Problem 948 lineare Programmierung 951 Max-Fluss-Problem 948 Medianstatistik 373 Ordnungsstatistik 373 Vorrangwarteschlangen 373 Referenztypen 84, 141 Reflexive Kanten 553 Reguläre Ausdrücke 829 Abkürzungen 832 Beschränkungen 836 Escapesequenzen 833 GREP 847 Klammern 830 nichtdeterministische endliche Automaten 836 Oder-Operation 830 Sternhülle 830 Sternoperationen 833 Sternoperator 830 Teilstringsuche 834 Validitätsprüfung 834 Verkettung 830 Zeichenmengendeskriptoren 832 Rekursion 43 binäre Suchbäume 430 Inorder-Traversierung 441 Restnetzwerk 932, 938
984
Rot-Schwarz-Suchbäume 461 Eigenschaften 475 Eins-zu-eins-Repräsentation 463 Farben wechseln 468 Farbrepräsentation 463 Rückgabewerte 32, 89 Rückrufmechanismus 364
S Samplesort 331 Schleifen for 35 innere 201 Rumpf 33 while 33 Schlüssel doppelte 391 Gleichheit 392 Iteration 392 null 391 Shannon-Entropie 325 sortieren 267 Schlüsselindiziertes Zählen 746 Schnitteigenschaft 649 Schnitt-Optimalitätsbedingungen 677 Schnittstellen implements 121 in Java 121 Vererbung 120 Selbstorganisierende Suche 420 Selectionsort 272 Ablaufprotokoll 273 Algorithmus 273 Datenverschiebung 273 Laufzeit 272 Performance 272 Senke 640 Sequenzielle Allokation 176 Shannon-Entropie 325 Shellsort 281 Ablaufprotokoll 283, 284 Abstandsfolge 282 Algorithmus 283 grafisches Ablaufprotokoll 285 Sichtbarkeitsmodifizierer 105 Simulationen Code 905 ereignisgesteuerte 899, 900 zeitgesteuerte 900
Register
Sofortiges Löschen 391 Sollin, M. 672 Sortieranwendungen 375 ereignisgesteuerte Simulation 377 Informationssuche 376 kombinatorische Suche 378 kommerzielle Datenverarbeitung 376 numerische Berechnungen 378 Operations Research 377 Stringverarbeitung 379 Sortieren absteigendes 353 Anwendungen 363 Arrays 267 Bottom-Up-Mergesort 301 Callback-Mechanismus 364 Datentypen 270 Duplikate 372 durch Auswählen 272 elementare Verfahren 267 Entropie-optimales 321 grafisches Ablaufprotokoll 277 Heapsort 350 In-Place-Mergen 294 Komplexität 304 Kostenmodell 269 Mischsortieren 294 natürliche Ordnung 270 primitive Datentypen 371 Rückrufmechanismus 364 Schlüssel 267 Sortieralgorithmen vergleichen 277 Speicherbedarf 270 Spielregeln 267 Top-Down-Mergesort 296 topologisches 618 totale Ordnung 270 verifizieren 269 verschiedene Datentypen 364 Zeiger-Sortieren 365 Sortierverfahren 3-Wege-Quicksort für Strings 762 LSD 749 Mergesort 294 MSD 752 Quicksort 313 Selectionsort 272 Shellsort 281 stabile 368 Spannbäume 555
Speicherauszüge 855 Speicherbedarf 221 Arrays 224 Objekte 223 String-Objekte 226 Teilstrings 226 verkettete Listen 224 Speicherbereinigung 615 Loitering 157 verwaiste Elemente 157 Speicherverwaltung 125 Stabilität 368 Stack 146 Stack-Implementierung 167 Standardalphabete 742 Standardausgabe 55 Standardeingabe 58 Standardgrafik 61 Stapel 146 fester Kapazität 151 Implementierung 167 Starke Komponenten 629 Starke Zusammenhangskomponenten 629 Statische Methoden 39 aufrufen 40 Bibliothek 44 definieren 40 Steque 186 Streng typisierte Programmiersprache 32 Stringindizierung 920 Strings 52 3-Wege-Quicksort 762 Alphabete 741 Anwendungsbereiche 738 automatische Umwandlung 53 Befehlszeilenargumente 53 Datenkomprimierung 851 Huffman-Komprimierung 868 Lauflängencodierung 863 LSD-Sortierverfahren 749 MSD-Sortierverfahren 752 Reguläre Ausdrücke 829 schlüsselindiziertes Zählen 746 Sortierverfahren 745 Speicherbedarf 226 Spielregeln 739 Suffixarrays 920 Teilstrings 226, 740 Teilstringsuche 800 Tries 773
985
Register
Umwandlung 52 Unveränderlichkeit 739 Verkettung 52, 740 Zahlen 744 Zeichen 739 Zeichenarrays 740 zeichenindizierte Arrays 743 Stringumwandlung 122 Strukturen 163 s-t-Schnitt 936 Subclassing 121 Subtyping 120 Suchbäume 2-3 453 balancierte 453 binäre 424 Eins-zu-eins-Repräsentation 463 Farben wechseln 468 Farbrepräsentation 463 Rotationen 464 Rot-Schwarz- 461 Tries 775 Suche binäre 405 erfolglose 777 erfolgreiche 776 Interpolationssuche 420 Kostenmodell 397 selbstorganisierende 420 sequenzielle 402 Suchprobleme 956 Suchtries, ternäre 789 Suffixarrays 918 API 922 Brute-Force-Lösung 919 Implementierung 925 Performance 925 Sortierlösung 920 Stringindizierung 920 Teilstrings 918 Symbolgraphen 589 Symboltabellen Abrundungsfunktion 395 API 390, 393 Aufrundungsfunktion 395 Beispielclient 398 Definition 389 Floor/Ceiling 395 Für und Wider von Implementierungen 415
986
Generics 390 geordnete 393 Gleichheit 396 Kostenmodell 397 Kostenvergleich verschiedener Implementierungen 520 löschen 391 Minimum/Maximum 394 Performance-Client 399 Rang/Auswahl 395 redundante Methoden 396 Systemsortierverfahren (Java) 371
T Tarjan, R. E. 671 Teilgraphen 554 Teilstrings 740 Teilstringsuche 800 Boyer-Moore 812 Brute-Force-Implementierung 801 Fingerprint-Suche 817 geschichtlicher Abriss 800 Knuth-Morris-Pratt 804 Rabin-Karp 817 reguläre Ausdrücke 834 Vorkommensheuristik 813 Ternäre Suchtries 789 Alphabete 793 Eigenschaften 792 hybride 794 Suchen/Einfügen 791 Suchkosten 792 Verzweigungsgrad 1 794 Tiefensuche 566 Ablauf 569 Ablaufprotokoll 570 Algorithmus 567 Labyrinth 566 Pfade mit einem Startknoten 572 Single-Source-Path (SSP) 572 Traversierung 620 Trémaux Methode 566 Union-Find 587 Zusammenhang feststellen 570 Zusammenhangskomponenten 583 Zweifärbbarkeit 587 Zykluserkennung 587 Tilde-Approximation 199 Tilde-Notation 199
Register
Top-Down Heaps 342 Mergesort 296, 298 Topologisches Sortieren 618 Totale Ordnung (Sortieren) 270 Transaktionen-Beispiel 364 Transitive Hülle 636 Trémaux-Methode 566 Tries durchsuchen 776 Eigenschaften 775, 785 Einfügen/Suchen 777, 786 längster Präfix 783 löschen 784 LZW-Komprimierung 883 Platzhalterübereinstimmung 782 Preorder-Traversierung 877 Repräsentation 777, 869, 883 Speicherbedarf 787 ternäre Suchtries 789 Verzweigungsgrad 1 788 Tschebyschow-Ungleichung 328 Tukey Ninther 331 Turingmaschine 954 Typparameter 141 Typumwandlung 30
U Umgekehrte Postorder-Knotenreihenfolge 622 Ungerichtete Graphen 553 Adjazenzlisten 559, 560 Adjazenzmatrix 559 azyklische 555 Baum 555 benachbarte Knoten 554 bipartite 556 Brücken 604 dichte 556 dünne 556 Durchmesser 601 euklidische 604 eulersche Zyklen 604 Gelenkpunkt 604 Gittergraphen 605 Hamilton'sche Zyklen 604 Intervallgraphen 605 isomorphe 603
Kanten 553 Kantenzusammenhang 604 Knoten 553 kreisfreie 555 Mittelpunkt 601 Pfad 554 Radius 601 Spannbaum 555 Taillenweite 602 Teilgraphen 554 zusammenhängende 555 Zusammenhangskomponenten 555 zweifacher Zusammenhang 604 Zyklus 554 Union-Find 238 Tiefensuche 587 Universelle Datenkomprimierung 857 Unveränderlichkeit (final) 126
V Variablen Instanzvariablen 105, 107 lokale 107 Parametervariablen 107 Vektoren, dünnbesetzte 537 Verdopplungsverhältnis 212 Vererbung Implementierung 121 Schnittstellen 120 Vergleiche 31 Sortieralgorithmen 277 Vergleichsoperatoren 31 Verifizierung sortieren 269 Verkettete Allokation 176 Verkettete Listen 162 am Anfang einfügen 164 am Ende einfügen 165 an anderen Position einfügen/ entfernen 166 erstellen 163 Speicherbedarf 224 traversieren 167 vom Anfang entfernen 165 Verkettung 740 Verzögertes Löschen 391 Vorkommensheuristik 813 Vorrangbedingungen 617
987
Register
Vorrangwarteschlangen 333 Algorithmus 345 API 334 Arrayrepräsentation (geordnet) 337 Arrayrepräsentation (ungeordnet) 336 Client 335 Comparator 367 einfache Implementierungen 336 Größenanpassung 346 größtes Element entfernen 343 indizierte 346 Mehrwege-Mischen 348 neues Element einfügen 343 Repräsentation mit verketteten Listen 338 unveränderliche Schlüssel 346
W Wachstumshypothesen 202 Wachstumsordnung exponentielle 207 Klassifikation 206 konstante 206 kubische 207 leicht überlinear 206 lineare 206 logarithmische 206 quadratische 206 unterer Grenzwert 211 Verdopplungsverhältnis 212 Wald von Bäumen 248 Warteschlangen 144 Deque 186 Implementierung 171 Steque 186
988
while-Schleifen 33 Wissenschaftliche Methoden 191, 278 Wörterbuch-Clients 526 Wrapper-Klassen 123 Wrappertypen 123
Z Zahlen 744 Zeichen 739 Zeichenarrays 740 Zeiger-Sortieren 365 Zeitgesteuerte Simulation 900 Zipf'sches Gesetz 422 Zufallsstring-Modell 759 Zugriffsmodifizierer 105 Zusammenhangskomponenten 555 API 583 berechnen 583 Gelenkpunkt 604 starke 629 verwalten 238 Zusicherungen 129 Zuweisungen 32 abstrakte Datentypen 87 implizite 34 Zweidimensionale Arrays 38 Zweifärbbarkeit 587 Zyklen einfache 554 einfache in Digraphen 608 gerichtete 608, 617 negative 713 negative Zyklen erkennen 721 Zykluserkennung 587
it
INFORMAT A IK
informatik
Robert Sedgewick Kevin Wayne
Einführung in die Programmierung mit Java ISBN 978-3-8689-4076-3 49.95 EUR [D], 51.40 EUR [A], 66.00 sFr* 736 Seiten
Einführung in die Programmierung mit Java BESONDERHEITEN Eine Einführung in die Programmierung mit Java vom erfahrensten Autor in der Lehre dieser Programmiersprache: Robert Sedegewick stellt in seinem Lehrbuch den objektorientierten Ansatz vor, mit dem Studenten grundlegende Kontrollstrukturen und Funktionen von Java schnell erarbeiten können. Das Buch umfasst zudem das volle Programmiermodell sowie die Standardbibliotheken für Eingang, Grafik, Ton und Bildverarbeitung. Speziell für Ingenieure und Naturwissenschaftler ist dieser Titel besonders gut geeignet, da die berufliche Praxis stets auf die in diesem Buch behandelten Grundlagen zurückgreift.
KOSTENLOSE ZUSATZMATERIALIEN Für Dozenten: - Alle Abbildungen aus dem Buch zum Download Für Studenten
*unverbindliche Preisempfehlung
- Komplette API-Übersicht
http://www.pearson-studium.de/4076
it
INFORMAT A IK
informatik
Bjarne Stroustrup
Einführung in die Programmierung mit C++ ISBN 978-3-8689-4005-3 69.95 EUR [D], 72.00 EUR [A], 92.00 sFr* 1224 Seiten
Einführung in die Programmierung mit C++ BESONDERHEITEN Bjarne Stroustrup, Erfinder von C++, gibt basierend auf seiner langjährigen Erfahrung in Softwareentwicklung und Lehre mit diesem didaktisch ausgefeilten Werk einen praxisnahen und problemorientierten Einstieg in die Programmierung mit C++. Im Gegensatz zu anderen Einführungen in die Programmierung deckt dieses Buch praxisorientierte Themen wie die Wartbarkeit von Code, das Testen und die professionelle Softwareentwicklung ab. Für jeden, der sich intensiv mit der Programmierung oder mit professioneller Softwareentwicklung auseinandersetzen will, führt kein Weg an diesem Buch vorbei!
KOSTENLOSE ZUSATZMATERIALIEN Für Dozenten: - Alle Abbildungen aus dem Buch Für Studenten: - Lokalisierte Beispielsammlung
*unverbindliche Preisempfehlung
- Weiterführende Links und Informationen
http://www.pearson-studium.de/4005
Copyright Daten, Texte, Design und Grafiken dieses eBooks, sowie die eventuell angebotenen eBook-Zusatzdaten sind urheberrechtlich geschützt. Dieses eBook stellen wir lediglich als persönliche Einzelplatz-Lizenz zur Verfügung! Jede andere Verwendung dieses eBooks oder zugehöriger Materialien und Informationen, einschließlich
der Reproduktion,
der Weitergabe,
des Weitervertriebs,
der Platzierung im Internet, in Intranets, in Extranets,
der Veränderung,
des Weiterverkaufs und
der Veröffentlichung
bedarf der schriftlichen Genehmigung des Verlags. Insbesondere ist die Entfernung oder Änderung des vom Verlag vergebenen Passwortschutzes ausdrücklich untersagt! Bei Fragen zu diesem Thema wenden Sie sich bitte an: [email protected] Zusatzdaten Möglicherweise liegt dem gedruckten Buch eine CD-ROM mit Zusatzdaten bei. Die Zurverfügungstellung dieser Daten auf unseren Websites ist eine freiwillige Leistung des Verlags. Der Rechtsweg ist ausgeschlossen. Hinweis Dieses und viele weitere eBooks können Sie rund um die Uhr und legal auf unserer Website herunterladen: http://ebooks.pearson.de