K a r l s ru h e r I n s t i t u t f ü r T e c h n o l o g i e Institut für Theoretische Informatik Prof. Dr. Dennis Hofheinz Lukas Barth, Lisa Kohl 3. Übungsblatt zu Algorithmen I im SoSe 2016 https://crypto.iti.kit.edu/index.php?id=algo-sose16 {lisa.kohl,lukas.barth}@kit.edu Musterlösungen Aufgabe 1 (Amortisierte Analyse, 2 + 2 Punkte) Gegeben ist ein binärer Zähler, bei dem pro Speicherstelle 1 Bit gespeichert ist. Der Zähler ist mit 0 initialisiert ist. Mit der Operation inc wird der Zähler um eins erhöht und mit der Operation res auf 0 zurückgesetzt. Nehmen Sie im Folgenden die Kosten für das Kippen eines Bits mit 1 an. a) Zeigen Sie, dass die Kosten von n Operationen in O(n) liegen, indem Sie den Operationen je nach Zustand des Zählers geeignete amortisierte Kosten zuweisen. b) Nun kommt eine weitere Operation dec hinzu, bei der der Zähler um eins erniedrigt wird. Was sind nun die Kosten von n Operationen im Worst-Case (im O-Kalkül)? Musterlösung: a) Wir führen eine amortisierte Analyse mit Hilfe der Bankkontomethode durch. Wir berechnen amortisierte Kosten von 2 Token für das Setzen eines Bits zu 1. Wenn ein Bit zu 1 gesetzt wird, verwenden wir 1 Token davon um für das Setzen des Bits zu zahlen und behalten das andere Token als Kredit. Für jedes auf 1 gesetzte Bit ist daher immer ein Token auf dem Konto übrig, und wir müssen nichts einbezahlen um ein Bit auf 0 zu setzen. Bei der Operation inc muss ein Bit auf 1 gesetzt werden (nämlich das niedrigste auf 0 gesetzte Bit), und alle niedrigeren Bit auf 0. Daher sind die amortisierten Kosten von inc 2 Token. Da bei res nur Bits von 1 auf 0 gesetzt werden müssen und für jedes auf 1 gesetzte Bit ein Token auf dem Konto ist, verursacht die res Methode keine zusätzlichen amortisierten Kosten. Da weiter die Zahl der auf 1 gesetzten Bits nie negativ wird, ist auch der Kontostand nie negativ. Insgesamt ergibt sich, dass die amortisierten Kosten für n Operationen in O(n) liegen. b) Davon ausgehend, dass der Zähler mit 0 initialisiert ist, liegen die Kosten für n Operationen mit der zusätzlichen dec Operation im Worst-Case in Θ(n log n). Diese Kosten erreicht man, indem man zunächst 2dlog(n/2)e − 1 inc Operationen ausführt und so einen Zähler mit dlog(n/2)e zu 1 gesetzten Bits erhält. Mit den übrigen ≈ n/2 Operationen führt man abwechselnd inc und dec Operationen aus, die jedesmal das Kippen von dlog(n/2)e + 1 Bits bewirken. Aufgabe 2 (Einfach verkettete Liste, 1 + 2 + 3 Punkte) Sie verwenden die einfach verkettete Listen-Datenstruktur mit Dummy-Header aus der Vorlesung in einem Programm. Die Datenstruktur mit n Listen-Elementen ist von Ihrem Kollegen programmiert worden. Der Kollege versichert Ihnen, dass jedes Listen-Element auf ein weiteres Listen-Element zeigt. ... 1 Sie möchten nun noch die Konsistenz der Liste überprüfen, indem sie ausschließen, dass eins der Listen-Elemente auf ein vorheriges Listen-Element zeigt. a) Entwerfen Sie einen Algorithmus, der in O(n) Zeit prüft, ob eine Liste konsistent ist. Sie dürfen dabei annehmen, dass die Listenlänge bekannt ist und in jedem Listen-Element ein zusätzliches mit 0 initialisiertes Bit Speicher verfügbar ist, dass Sie verändern dürfen. b) Entwerfen Sie wieder einen Algorithmus, der in O(n) Zeit und mit O(1) zusätzlichen Platz prüft, ob eine Liste konsistent ist. Diesmal darf Ihr Algorithmus dabei allerdings die Liste nicht verändern und die Listenlänge ist unbekannt. c) Erweitern Sie Ihren Algorithmus aus a) oder b), indem Sie zusätzlich feststellen an welchem Element die Invariante verletzt wird. Wieder darf dieser die Liste nicht verändern und nur mit O(1) zusätzlichem Platz arbeiten. Die Listenlänge dürfen Sie hier aber als bekannt voraussetzen. Ein Algorithmus in O n2 Laufzeit erhält 1 Punkt, einer in o n2 zusätzlich 2 Punkte. Begründen Sie jeweils die Laufzeit und den Platzbedarf Ihrer Algorithmen. Musterlösung: Hinweis: Im Folgenden werden wir häufig von einem Kreis in der Liste sprechen. Strenggenommen stellt eine ordnungsgemäß verkettete Liste natürlich einen großen Kreis dar. Diesen meinen wir im Folgenden nicht - mit Kreis bezeichnen wir Kreise auf den Elementen der verketteten Liste, die gerade nicht die gesamte Liste beinhalten. a) Die Invariante der einfach verketten Liste ist dann verletzt, wenn es einen Knoten mit zwei eingehenden Zeigern gibt. Hierdurch kann man die einfach verkette Liste in einen Anfangsteil und einen oder mehrere Kreise zerlegen. Von Knoten im Kreis aus kann der Anfangsteil nicht mehr erreicht werden. Sentinel ... Ein einfacher erster Algorithmus könnte z.B. die Liste ablaufen und dabei jedes gesehene Element markieren (damit ändert er natürlich die Liste bzw. benötigt O(n) zusätzlichen Speicherplatz!) Kommt man bei einem bereits markierten Element wieder an, bevor man den Dummy-Header gesehen hat, so gibt es einen die Invariante verletzenden Kreis. Wenn wir davon ausgehen, dass die Listenlänge bekannt ist, so kann man auch vom Dummy-Header aus loslaufen und überprüfen, ob man nach genau der richtigen Anzahl an Schritten zum ersten Mal wieder den Dummy-Header erreicht hat. b) Die Grundidee einer Lösung mit O(1) Platzverbrauch und ohne Kenntnis der Listenlänge ist, unterschiedlich schnelle Iterationen der Liste zu verwenden und periodisch zu prüfen, ob die Iterationen das gleiche Element enthalten. Die einfachste Möglichkeit ist, zwei Zeiger zu verwenden. Der langsamere Zeiger macht pro Schleifendurchlauf ein Schritt, der schnellere macht zwei Schritte. Nach jedem Schritt wird geprüft ob der schnellere Zeiger oder sein Nachfolger gleich dem langsameren ist, der schnellere Zeiger den langsameren also „überrundet“ hat. Erreicht der schnellere Zeiger den Dummy-Header bevor das passiert, dann ist die Liste korrekt. Enthält die Liste eine Schleife ohne den Dummy-Header, so holt der schnellere Zeiger den langsameren in dieser Schleife ein, sie treffen sich also. Daran erkennt man, dass die Listeninvariante verletzt ist. Function hasLoop(s : Handle) : boolean a := s.next, b := a.next while a 6= s & b 6= s & b.next 6= s do 2 – – a ein Schritt, b zwei Schritte if a = b or a = b.next then return true a = a.next b = b.next.next return false Der langsamere Zeiger durchläuft die Schleife niemals vollständig, sondern höchstens die Hälfte der Schleifenlänge. Dies tritt dann auf, wenn der schnellere Zeiger gerade ein Knoten nach dem langsameren Zeiger ist, wenn dieser die Schleife betritt. Ist die Länge des Anfangsteils x und die Schleifenlänge c, so finden insgesamt höchstens 2x+2d 2c e ≤ 2n + 2 Vergleiche in Zeile “a = b or a = b.next” statt. Die ganze Schleife benötigt also O(n) Zeit. c) Es muss nun ein Algorithmus gefunden werden, der den Knoten t mit Eingangsgrad 2 bestimmt, an dem sich also der Kreis schließt. Wir bezeichnen den Treffpunkt der beiden Zeiger aus der vorherigen Teilaufgabe mit p, genauer den Zustand des langsameren Zeigers bei Terminierung der Schleife. Wenn man von p immer einen Schritt weiterläuft, erreicht man nach O(n) Schritten wieder p, so kann man den Kreis durchlaufen und dabei prüfen, ob ein gegebener Zeiger Teil des (illegalen) Kreises ist: Function isInCircle(q : Handle) : boolean a := p.next while a 6= p do if a = q return true a := a.next return p = q Für eine O n2 -Lösung kann man zum Beispiel vom Start aus immer einen Knoten weiterlaufen, und nach jedem Schritt testen, ob man schon im Kreis ist: Function findBadElementSlowly() : Handle a := s while not isInCircle(a) do a := a.next return a Der Unterfunktionsaufruf wird O(n)-mal aufgerufen, daher ergibt sich eine Gesamtlaufzeit von O n2 . (1 Punkt) Für eine o n2 -Lösung kann man ähnlich vorgehen, aber den Knoten t mit Intervallhalbierung, also binärer Suche bestimmen. Dazu merken wir uns noch die Distanz m, den der langsamere Zeiger aus der vorherigen Teilaufgabe bis zum Treffpunkt zurückgelegt hat. Die Schwierigkeit in der Implementierung besteht hauptsächlich darin, dass wir keinen random access auf die Elemente der Liste haben. Function findBadElementFaster() : Handle l := a := s while m > 0 do m := bm/2c for i := 0 to m do a := a.next if isInCircle(a) do a := l else l := a return l.next 3 m m Die Gesamtstrecke, die durch den Zeiger a zurückgelegt wird, ist m 2 + 4 + 8 + ··· + 1 ≤ m = O(n). Der Unterfunktionsaufruf wird O(log m) mal aufgerufen, insgesamt erhalten wir daher eine Laufzeit von O(n log n) = o n2 . (Es gilt o n2 ⊂ O n2 , daher gibt eine solche Lösung 3 Punkte.) Es ist tatsächlich auch möglich, die Frage nach dem die Invariante verletzenden Element in O(n) zu beantworten (das war aber gar nicht gefordert!): Die O(n)-Lösung ist wesentlich einfacher zu implementieren, jedoch ist es nicht so einfach einzusehen, dass sie stimmt. Die Idee ist es, von s und von p in gleicher Geschwindigkeit loszulaufen bis die Zeiger sich treffen. Der Treffpunkt ist das gesuchte Element. Wir zeigen diese Eigenschaft für den Fall, dass die beiden Zeiger aus dem vorherigen Aufgabenteil sich genau getroffen haben, dass also die Schleife durch den zweite Aussage der Konjunktion abgebrochen wurde. In diesem Fall gilt p = s + m = s + 2m, wobei wir hier den Operator „+“ als Zeigerverschiebung interpretieren. Daraus folgt p + m = p. Wenn also ein Zeiger von p und einer von s mit Geschwindigkeit 1 loslaufen, treffen sich beide nach m Schritten in p. Da aber s nicht im Kreis liegt, muss es ein erstes Element geben, an dem sie sich treffen. Dieses Element hat dann Eingangsgrad zwei und ist somit unser gesuchtes t. Für die Implementierung müssen wir jetzt noch den Fall beachten, an dem die Zeiger sich nicht genau getroffen haben. Dann ist p = s + m = s + 2m + 1. Dies berücksichtigen wir, indem man nicht nur die beiden aktuellen Zeiger vergleicht, sondern auch noch mit dem Nachfolger des von p startenden. Function findBadElementSuperFast() : Handle a := s b := p while a 6= b & a 6= b.next do a := a.next b := b.next return a Aufgabe 3 (XOR Listen, 1 + 2 + 1 + 1 Punkte) In der Vorlesung wurden doppelte verkettete Listen L mit zwei Zeigern pro Listenelement, x.prev und x.next, implementiert. Nehmen Sie an, dass alle Zeigerwerte als k-bit Integer-Zahlen interpretiert werden können, auf denen Bit-Operationen durchgeführt werden können. Nehmen Sie weiter für die Aufgabenstellung an, dass jedes Listenelement ein Feld key hat, dass die eindeutige Identifizierung des Elements ermöglicht (dieses darf nicht verändert werden; es wird nur für die search-Funktion benötigt). ( 0 falls i = j, Hinweis: ⊕ ist die bitweise XOR-Operation, d.h. für i, j ∈ {0, 1} gilt i ⊕ j = . 1 sonst a) Gegeben sind zwei k-Bit-Integer I und J. Zeigen Sie mit Hilfe der Definition (I ⊕ J) ⊕ J = I. b) Erklären Sie, wie eine doppelt verkettete Liste mit nur einem Zeiger x.np pro Listenelement implementiert werden kann. Hinweis: mit der Datenstruktur müssen nur die Funktionen aus Teilaufgabe c) und d) effizient implementiert werden können. 4 c) Implementieren Sie die Funktion search, die ein Listenelement mit einem gegebenen Key findet, in O(|L|) und die Funktion insertFront in O(1). d) Beschreiben Sie, wie die Reihenfolge der Listenelemente in O(1) umgekehrt werden kann. Musterlösung: a) Es sei I repräsentiert durch die Bits [i0 , . . . , ik−1 ] und J durch [j0 , . . . , jk−1 ]. Da ⊕ jedes Bit einzeln manipuliert reicht es die Behauptung für ein beliebiges Bit ` zu zeigen. Dazu schauen wir uns die Wertetabelle an: i` 0 0 1 1 j` 0 1 0 1 i` ⊕ j` 0 1 1 0 (i` ⊕ j` ) ⊕ j` 0 0 1 1 b) Wie schon in der Aufgabenstellung erklärt, speichert man in jedem Listenelement nur einen Zeiger x.np. Diesen wählen wir zu x.np = prev[x] ⊕ next[x]. Weiter speichert man global einen Zeiger auf den Kopf headL und das Ende der Liste tailL . Beachte: das erste Element hat keinen Vorgänger (prev[headL ] = 0). Damit ist headL .np = next[headL ]. Ebenfalls hat das letzte Element keinen Nachfolger, also tailL .np = prev[tailL ]. c) Wir geben die zwei Funktionen als Pseudocode an: 1: Function Search(L, search_key) 2: prev := 0 3: cur_element := headL 4: while cur_element 6= 0 and cur_element.key 6= search_key do 5: next := cur_element.np ⊕ prev 6: prev := cur_element 7: cur_element := next 8: return cur_element 1: Function insertFront(L, element) 2: element.np := headL 3: if headL != 0 then 4: headL .np := headL .np ⊕ element 5: else tailL := element 6: headL := element d) Das geht durch Vertauschen von headL und tailL . Aufgabe 4 (Dr. Meta vs. Unbounded Arrays, 3 Punkte) Der ebenso gerissene wie gefährliche Superschurke Dr. Meta hat wie jedes Jahr zur WeltherrschaftsKonferenz geladen. Einer seiner mittelmäßig intelligenten Handlanger ist mit der Organisation des Tagungshotels betraut. Eine seiner Aufgaben ist es, während der Konferenz sicherzustellen, dass immer ein für alle versammelten Bösewichte ausreichend großer Raum im Hotel zur Verfügung gestellt wird. Diese Aufgabe wird dadurch erschwert, dass Schurken sich nicht an Regeln halten, und nach Lust und Laune die Konferenz betreten oder verlassen. Das Hotel hat Räume, die beliebige Zweierpotenzen von Personen fassen können (also 2, 4, 8, 16, . . . Personen). Die Strategie des Handlangers sieht wie folgt aus: Wenn der aktuelle Raum zu klein wird, so bucht er den nächstgrößeren - also einen Raum, der doppelt so viele Personen fasst wie der aktuelle. Daraufhin 5 muss das versammelte Verbrecherkollektiv den aktuellen Raum verlassen und in den neuen Raum umziehen. Wenn der aktuelle Raum nur noch zur Hälfte gefüllt ist, so bucht der Handlanger auf den nächst kleineren Raum um, um die bei Dr. Meta grundsätzlich knappen Geldmittel zu sparen. Wieder müssen alle Teilnehmer umziehen. Gehen Sie nun davon aus, dass eine Teilgruppe der eingeladenen Gauner so durchtrieben ist, dass sie die Weltherrschafts-Konferenz selbst torpedieren wollen. Sie hecken daher eine Abfolge von „Konferenz betreten“ und „Konferenz verlassen“ aus, sodass ihre Kollegen möglichst oft umziehen müssen. Zeigen Sie: mit einer Abfolge von n „betreten“/„verlassen“-Aktionen ist es möglich, Θ n2 Personen-Umzüge zu erzwingen. Irgendwann merkt selbst der Handlanger, dass er seine Strategie verändern muss. Was kann er besser machen? Hinweis: Wenn k Personen den Raum wechseln müssen, so sind dies k Personen-Umzüge. Genauso gilt: Wenn eine Person l-fach den Raum wechseln muss, so sind dies l Personen-Umzüge. Musterlösung: Die Tagungsräume werden wir im Folgenden als Arrays auffassen und die Tagungsteilnehmer als Elemente, die in diesen Arrays gespeichert sein können. Ein Personen-Umzug entspricht damit genau dem Umkopieren eines einzelnen Elements. Damit sind wir bei einer Aufgabe zu unbounded Arrays: Man führt zunächst n2 pushBack Operationen aus. Das Array enthält dann n2 Elemente. Dann führt man n4 Mal die Operationsfolge pushBack popBack aus. Bei jedem pushBack wird ein Array der Größe n alloziiert, und n2 Elemente umkopiert. Danach beim popBack wird wieder ein kleineres Array der Größe n2 alloziiert und alle verbliebenen n2 Elemente dorthin kopiert. Für ein Operationspaar pushBack popBack müssen also n Kopieroperationen durchgeführt werden. Es werden nur die Kopieroperationen betrachtet, da der Aufwand für alle anderen Operationen insgesamt n 2 in O(n) liegt. Insgesamt werden für n Operationen damit mindestens 4 · n = Θ n Kopieroperationen durchgeführt. Eine bessere Strategie wäre wie in der Vorlesung vorgestellt, das Array bzw. den Raum nicht immer sofort zu verkleinern, sondern erst dann, wenn dieser um weniger als ein Viertel gefüllt ist. 6