Musterlösung

Werbung
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
Herunterladen