Transcription
PDF
http://www.mpi-sb.mpg.de/~sschmitt/info5-ss01 IS UN R S SS 2001 E R SIT S Schmitt, Schömer SA IV A Grundlagen zu Datenstrukturen und Algorithmen A VIE N Lösungsvorschläge für das 6. Übungsblatt Letzte Änderung am 28. Mai 2001 Aufgabe 1 Analog zur Vorlesung entspricht imax dem größten Index, auf den in unserem u_array zugegriffen wird. Da 0 ≤ imax ≤ sn − 1, ist imax per Definition echt kleiner als sn . Auf der anderen Seite muß imax größer oder gleich sn−1 sein, denn hätte imax den Bereich si−1 unseres Arrays nicht überschritten, so hätten wir kein sn gebraucht. Amortisiert konstante Laufzeit pro Zugriff bedeutet, daß wir, wenn wir m Zugriffe betrachten, diese in O(m) (linear) durchführen können. Eine wichtige Vorraussetzung dafür ist, daß sn = O(imax ) gilt. (a) si = 4i . Das entspricht einer vervierfachung des ursprünglichen Speichers. Es gilt also si = 4si−1 Prüfe zunächst, ob nicht zuviel Platz verwendet wird. Wir stellen sn = O(imax ) sicher, da sn = 4sn−1 ≤ 4imax Nun wollen wir prüfen, ob die Kosten für die Feldzugriffe amortisiert konstant sind. Nehmen wir an, daß von m Zugriffsoperationen jede eine Laufzeit Tj besäße. Das ergäbe eine Gesamtlauzeit von m X Tj j=1 Wir unterscheiden 2 Fälle: Operation j bewirke keine keine Feldvergrößerung, was Tj = Θ(1) Kosten verursachen würde. Im komplizierteren Fall müsste das Feld vergrößert werden, also vervierfacht, und es ergäbe sich eine neue Feldgröße si . Ein neues Feld würde somit generiert, und alle Werte müssten dorthin umkopiert werden, was uns Tj = Θ(1 + si ) kosten würde. Die Gesamtlaufzeit beliefe sich dann auf ! n m X X si Tj = Θ m + i=0 j=1 was wir wie folgt abzuschätzen wissen n X i=0 si = n X 4i i=0 1 = · (4n+1 − 1) 3 ≤ 4sn − 1 = Θ(sn ) Die Gesamtlaufzeit ist also m X Tj = Θ(m + sn ) = O(m + imax ) j=1 Sollte imax in O(m) liegen, so ist gewährleistet, daß die Gesamtlaufzeit ebenfalls in O(m) liegt, und somit pro Feldzugriff konstant viel Zeit benötigt wird. (b) si = 2i. Dies entspricht einer steten Vergrößerung um 2 Speicherstellen. Es gilt also si = 2 + si−1 Wir fahren analog zu (a) fort. Wegen sn = 2 + sn−1 ≤ 2 + imax liegt sn in O(imax ), und es wird nicht zu viel Speicher verbraucht. Analog zu oben schätzen wir nun folgende Summe ab: n X si = i=0 n X i=0 2·i n(n + 1) 2 = n(n + 1) = Θ(n2 ) = Θ((sn )2 ) = 2· Das ergibt eine Gesamtlaufzeit von m X Tj = Θ(m + (sn )2 ) = O(m + (imax )2 ) j=1 Damit wir√also eine Laufzeit O(m) bekommen, also amortisiert konstant pro Zugriff, muß imax = O( m) gelten, was wir nicht garantieren können. Somit ist die Garantie einer armotisiert konstanten Laufzeit nicht uneingeschränkt möglich. (c) si = 2i2 . Dies entspricht einer steten Vergößerung des i − 1ten Arrays um 4i − 1, da aus si = 2i2 und si−1 = 2(i − 1)2 si − si−1 = 4i − 1 ⇔ si = si−1 + 4i − 1 folgt. Nun untersuchen wir den Platzverbrauch. Es gilt sn = sn−1 + 4n − 1 ≤ imax + 4n − 1 Somit liegt sn in O(n + imax ). Ob dies zu viel ist, oder nicht hängt nicht zuletzt von imax ab. n X i=0 si = n X 2i2 i=0 1 = 2( n(n − 1)(2n − 1) 6 = Θ(n3 ) 3 = Θ((sn ) 2 ) Die Gesamtlaufzeit m X 3 3 Tj = Θ(m + (sn ) 2 ) = O(m + (imax ) 2 ) j=1 2 ist auch hier nun lediglich für imax = O(m 3 ) amortisiert konstant, was auch nicht uneingeschränkt garantiert werden kann. i (d) si = 22 . Das bedeutet stetes Quadrieren des vorherigen Speicherplatzes, weil i si = 2 2 i−1 = 22·2 i−1 2 = 22 = (si−1 )2 falls i > 0 und s0 = 1. Als nächstes untersuchen wir den Speicherplatzverbrauch. Es gilt sn = (sn−1 )2 ≤ (imax )2 und daher liegt sn = O((imax )2 ) was wir an und für sich nicht wollen. Trotzdem rechnen wir weiter um die amortisierte Laufzeit zu ermitteln: n X si = i=0 n X 22 i i=0 = 2 2n n + √ 2 2n + q √ 2 2n + · · · + 1 Obige Summe liegt in Θ(22 ), da man lediglich den Grenzwert p√ √ n 1 1 1 22 + 2 2n + 2 2n + · · · + 1 + = lim 1 + + · · · + =2<∞ lim n n→∞ n→∞ 22 2 22 22n berechnen muß. Dieser ist echt positiv und kleiner als ∞, somit gilt weiter m X Tj = Θ(m + sn ) = O(m + (imax )2 ) j=1 Es folgt, daß der √ quadratischen Platzverbrauch eine amortisiert konstante Laufzeit lediglich für imax ∈ O( m) verursacht. i.A. können wir also amortisiert konstante Laufzeit nicht garantieren. Aufgabe 2 (a) Hier ging es alleine darum, das “fehlende” & bei der Implementierung der u_array einzufügen, welches nur weggelassen wurde, um den in b) diskutieren Fehler zu verstecken, indem wir ohne selbiges & keine Adressierung und damit keine Schreibvorgänge in das Feld zulassen. (b) Was passiert, wenn A ein u_array ist: A[1]=1; Das Feldelement 1 wird mit 1 besetzt. A[2]=A[1] + 1; Das Feldelement 2 wird mit dem Inhalt von A[1] + 1, also 2. int* p = &A[2]; Wie lassen uns die Adresse von A[2] zurückgeben, die sei z.B. 0815. (*p) = 5; An diese Adresse schreiben wir die 5. Hier wird A[2] tatsächlich auf 5 gesetzt. A[1000] = 6; Jetzt wird das u_array vergrößert und nach der Vorlesungsimplementierung werden die schon vorhandene Elemente an völlig andere Speicherstellen kopiert. Also wird die A[2] mit Inhalt 5 von der Stelle 0815 weggenommen und irgendwoanders hinkopiert. (*p) = 3; Nun schreiben wir die 3 blindlings an die Speicherstelle 0815 die mit dem u_array eventuell garnichts, oder irgendwas anderes zu tun hat. Jedenfalls wird aller Wahrscheinlichkeit nach in A[2] immer noch die 5 von oben stehen. (c) Wir möchten das obige Problem umgehen, indem wir schon vorhandene Elemente an ihrer aktuellen Speicherstelle stehen lassen, und nur neue Elemente hinzufügen. Um unser Konzept der Feldverdoppelung beizubehalten, behelfen wir uns einfach mit einer zusätzlichen Referenzierungsstufe. Wir erinnern uns daran, wie wir mehrdimensionale Felder in eindimensionale eingebettet haben. Ein Zugriff wie feld[2][1] bedeutete nach unserer “Umsortierung” etwas wie “das 1. Element im 2. Unterfeld”: [ [0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3] ] 0 1 2 Die Zahlen stehen fur die Indizes der Felder. Wir möchten jetzt unser Feld so ähnlich organisieren, nur, daß das Feld mit jedem neuen Wachstumsschritt doppelt so groß sein soll, wie vorher. Wir beginnen mit einem einzigen Feld mit einem einzigen Platz für ein einziges Element: Alter Feldindex: 0 Feldstruktur: [[0]] Feldindex des Oberfeldes: 0 Nicht besonders viel Platz. Nach der 1. Vergrößerung soll unser Feld in etwa so aussehen: 0 1 [ [0] , [0] ] 0 1 Wenn wir wieder mehr Platz brauchen, wollen wir wieder Platz für doppelt so viele Elemente, also insgesamt vier. Wir fügen aber wieder nur ein neues Unterfeld ein: 0 1 2 3 [ [0], [0], [ 0, 1 ] ] 0 1 2 Nach den nächsten beiden Vegrößerungen sollte die Vorgehens- und Schreibweise hier klar werden: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [ [0], [0], [ 0, 1 ], [ 0, 1, 2, 3 ], [ 0, 1, 2, 3, 4, 5, 6, 7 ] ] 0 1 2 3 4 In der oberen Zeile steht der Laufindex unseres zu verwaltenden T-Feldes. In der zweiten Zeile sehen wir die Struktur des zweidimensionalen Feldes mit den Indizes der einzelnen Unterfelder. Darunter wiederum die Indizes des Uebergeordneten Feldes, die uns sagen, an welcher Stelle die Unterfelder anfangen. So wuerden wir z.B. die 11 im 4. Unterfeld an der Stelle 3 finden: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [ [0], [0], [ 0, 1 ], [ 0, 1, 2, 3 ], [ 0, 1, 2, 3, 4, 5, 6, 7 ] ] 0 1 2 3 4 Wie wir sehen, verdoppelt sich nicht nur die maximale Gesamtgröße, sondern auch die Größe Pn−1 der Unterfelder relativ zum vorherigen. Das ist nicht verwunderlich, da i=0 2i + 1 = 2n wobei die zusätzliche 1 vom 0. UnterFeld kommt. Um nun wie beim Einbetten der mehrdimensionalen Felder eine eindeutige Abbildung des alten Feldindex auf die “beiden” neuen zu finden, sehen wir uns die Grenzen der Unterfelder einmal genauer an: 0 20 21 22 − 1 22 23 − 1 23 24 − 1 [ [0], [0], [ 0, 1 ], [ 0, 1, 2, 3 ], [ 0, 1, 2, 3, 4, 5, 6, 7 ] ] 0 1 2 3 4 Wir sehen: im Unterfeld i1 befinden sich die Elemente ab 2i1 −1 , wobei wir das 0. Feld als einen Sonderfall betrachten. Suchen wir also ausgehend vom Feldzugriffsindex i (in unserem Beispiel oben die 11), so finden wir den Index i1 des Oberfeldes (der uns den Anfang des richtigen Unterfeldes liefert) durch i1 = blog2 (i)c + 1 Den Zugriffsindex i2 im Unterfeld finden wir nun ganz einfach, indem wir die linke Grenze des richtigen Unterfeldes i1 vom Index i abziehen: i2 = i − 2i1 −1 Nach dieser zugegeben reiflichen Überlegung muß nun am Quellcode gar nicht viel verändert werden, da wir an unserer Vorgehensweise der Verdoppelung bis auf die zweite Referenzierung der Unterfelder nicht viel ändern, wie die folgende Implementierung zeigt. Das einzige, worauf wir achten müssen, ist, das das “Oberfeld” im folgenden “Referenzfeld” linear mitwachsen muß. Da uns aber tatsächlich egal ist, wo die Zeiger auf die Unterfelder im Speicher abgelegt sind, können wir wie bei der alten Implementierung einfach genug Platz für die neuen Zeiger woanders im Speicher schaffen, und die Referenzen umkopieren. Die kritischen Speicherstellen aber, nämlich die, wo die Referenzen hinzeigen bleiden davon unberührt. Natürlich sparen wir so auch eine Menge Zeit, da bei n vorhandenen Elementen nicht n Elemente, sondern nur log2 (n) Referenzen auf Unterfelder umkopiert werden müssen. Das einzige Zeitkritische ist nur noch das Füllen mit Default-Werten, worauf wir aber verzichten können, indem wir einen weiteren Konstruktor ohne Default-Werte hinzufügen, und den neuen Feldern nur Werte zuweisen, wenn dies erwünscht war. #include <assert.h> #include <math.h> #include <stdio.h> // Realisiert unbeschraenkte Felder template <class T> class u_array2 { T** superfeld; // Dies ist das Referenzfeld int groesse; // die Groesse des Feldes int refsize; // Groesse des Referenzfeldes; int max_index; // groesster verwendeter Index + 1 T default_value; // der Defaultwert int defaulted; // moechten wir einen Defaultwert benutzen? // Die Zugriffskomponenten, wie diskutiert. // float log2(float x){return log(x)/log(2);} int first(int i){ if (i==0) return 0; else return (int)floor(log2((float)i))+1; } int second(int i){ if (i==0) return 0; else return i-(int)pow(2,first(i)-1); } // vergroessert das Feld auf ein Feld der Groesse void make_room(int i){ int newrefsize=refsize; do{ /* unser Referenzfeld waechst gemuetlich, waehrend das Feld immer doppelt so gross wird. */ groesse = 2*groesse; newrefsize++; } while(i>=groesse); // jetzt ein neues Referenzfeld erstellen T** neues_superfeld= new T*[newrefsize]; assert(neues_superfeld != 0); // falls etwas schief geht /* und die Referenzen auf die alten Unterfelder umkopieren, aber NUR die Referenzen, die alten Speicherstellen bleiben erhalten. Ab den neuen Werten werden neue Felder mit der richtigen Groesse, naemlich 2^(j-1) erzeugt. */ for(int j=0; j<newrefsize ; j++) { if (j<refsize) neues_superfeld[j]=superfeld[j]; // Referenz auf altes Feld "retten" else { // Neues Unterfeld erstellen, je doppelt so gross wie das vorherige. neues_superfeld[j]=new T[(int)pow(2,j-1)]; /* Das fuellen mit default Werten kostet eine Menge Zeit, ist aber noetig, falls ein Feld referenziert wird, in das vorher nichts hineingeschrieben wurde. */ if (defaulted) for(int k=0;k<(int)pow(2,j-1);k++) neues_superfeld[j][k]=default_value; } } refsize=newrefsize; delete[] superfeld; superfeld=neues_superfeld; } public: /* Konstruktor */ u_array2(T d){ groesse = 1; max_index = 0; refsize=1; default_value=d; defaulted=-1; // erstmal klein anfangen ;o) superfeld=new T*[1]; superfeld[0]=new T[1]; superfeld[0][0]=d; } /* Konstruktor ohne default_value*/ u_array2(){ groesse = 1; max_index = 0; refsize=1; defaulted=0; // erstmal klein anfangen ;o) superfeld=new T*[1]; superfeld[0]=new T[1]; } /* Destruktor */ ~u_array2(){ delete[] superfeld; } /* Gibt die Groesse zurueck */ int size(){return max_index;} /* Zugriff auf das i-te Feld */ T& operator[](int i){ assert(i>=0); // evtl. array vergroessern if(i>=groesse) make_room(i); if(i>=max_index) max_index = i+1; // und mit den Einzelnen Indizes zugreifen return superfeld[first(i)][second(i)]; } }; Hinweis: Diese Implementierung ist ziemlich kompliziert! Es kommt auf die Idee an, nur noch die Zeiger umzukopieren, statt der eigentlichen Elemente. Aufgabe 3 1. Die Schreibweise a ≡ b (mod m) bedeutet, dass a und b bei Division durch m den gleichen Rest haben, oder äquivalent dazu, dass m ein Teiler der Differenz a − b ist. Mathematisch geschrieben: a ≡ b (mod m) ⇔ ∃k ∈ Z : a − b = km Daraus ergibt sich die zu beweisende Implikation: a·b=c⇔a·b−c=0 ⇔ a·b−c=0·m ⇒ ∃k ∈ Z : a · b − c = km ⇔ a ≡ b (mod m) ⇔ a · b ≡ c mod m Die Umkehrung gilt nicht! Gegenbeispiel: 2 · 3 ≡ 1 (mod 5) aber 2 · 3 6= 1 2. Zunächst sollte man sich überlegen, wie man eine zweistellige Zahl a = a1 · B + a0 modulo m rechnen kann. Unter der Annahme, dass m klein gegenüber des größten darstellbaren int ist, kann man sich folgende Beziehung zu Nutze machen: a mod m = (a1 · B + a0 ) mod m = (((a1 mod m) · (B mod m) mod m) + (a0 mod m)) mod m Das heisst man rechnet jede Zahl und jedes Ergebnis einer Rechenoperation direkt modulo m. Da aufgrund der Annahme alles Werte des Typs int sind, kann unter C++ der %Operator verwendet werden. Dies würde dann so aussehen: int amodm(int a1, int a0, int B, int m) { return (((a1 % m)*(B % m)) % m + (a0 % m)) % m; } Wir haben somit eine Funktion, deren Laufzeit nicht von der Größe des integer a abhängt. Also können wir sie durch eine Konstante nach oben abschätzen und erhalten Laufzeit O(1). Um nun das eigentliche Problem zu lösen, benutzen wir den Schuldivisionsalgorithmus. 759 : 5 = 151 25 09 4 Wir haben für jede Stelle einen Übertrag von der vorherigen, den wir mit der Basis multiplizieren und die Ziffer an der aktuellen Stelle dazuaddieren. An der ersten Stelle ist der Übertrag gleich 0 und zum Schluß bleibt der Rest übrig. Dies führt uns zum folgenden Algorithmus: int modulo(integer a, int B, int m) { int c = 0; for (int i = a.size()-1; i >= 0; i--) c = amodm(c, a[i], B, m); return c; } Die Laufzeit der Funktion modulo ist O(n), da die for-Schleife n mal durchlaufen wird und in ihr nur die Funktion amodm mit konstanter Laufzeit aufgerufen wird. Alle anderen Operationen benötigen konstante Zeit und tragen somit nicht zur asymptotischen Gesamtlaufzeit bei. 3. Der Checker soll Teil (a) ausnutzen, d.h. wir errechnen das Produkt von a mod m und b mod m und vergleichen es mit dem Ergebnis der Multiplikation zum Modul m. bool check_mult(integer a, integer b, integer c) { int m = random(); // Waehlt ein zufaelliges m if ((modulo(a, B, m)*modulo(b, B, m)) % m == modulo(c, B, m)) return true; else return false; } Ein Fehler wird wie in Aufgabenteil (a) gezeigt nicht gefunden, wenn m ein Teiler der Differenz a · b − c ist. Aufgabe 4 Spezifikation einer einfach verketteten Liste: 1. Definition: Eine Instanz L des Datentyps List<T> stellt eine Folge von Elementen vom Typ T dar. Jedes Listenelement kennt nur seinen Nachfolger (einfach verkettete Liste). Wir schreiben zur Abkürzung handle für einen Zeiger auf ein Listenelement. Das ist nicht ganz korrekt, da das Listenelement auch vom Typ T abhängt und wir somit handle<T> schreiben müßten. 2. Instanziierung: List<T> L; konstruiert leere Liste vom Typ List<T>. 3. Operationen: bool empty(); handle first(); handle last(); handle prev(handel pos); handle insert(handle pos,T& x); handle erase(handle pos); void clear(); void splice(handle pos, List<T>& L2, handle first, handle last); handle find(T& x); gibt true zurück, wenn die Liste leer ist, sonst false. liefert einen Zeiger auf das erste Element der Liste zurück. liefert einen Zeiger auf das letzte Element der Liste zurück. liefert einen Zeiger auf den Vorgänger des Elementes auf Position pos zurück. VB: pos muss gültiges Element der Liste sein fügt das Element x nach Position pos ein und liefert einen Zeiger auf das Listenelement x. löscht das Element auf Position pos und liefert einen Zeiger auf das auf pos folgende Element zurück. löscht die komplette Liste (ohne den Destruktor aufzurufen) bewegt die Elemente aus dem Bereich [first, last] aus der Liste L2 in die Liste L und fügt sie nach der Position pos ein. Vorbedingungen: pos ist ein gültiger Zeiger in L und first, last sind gültige Zeiger in L2 findet erstes Vorkommen von x in L und liefert einen Zeiger auf dieses Element zurück. 4. Implementierung: Für die Implementierung definieren wir uns zunächst einen Datentyp, der ein Listenelement darstellt. template <class T> class list_node { public: list_node* next; T inf; // der Nachfolger // das Element selbst }; Für einen Zeiger auf ein solches Listenelement schreiben wir kurz handle. template <class T> class list { typedef list_node<T>* handle; handle head; // der Kopf der Liste Idee: Eine Liste besitzt immer einen Listenkopf. Dieser zeigt auf das erste Element der Liste. Das letzte Element der Liste zeigt auf den Listenkopf. Eine leere Liste besteht dann nur aus dem Kopf, der auf sich selbst zeigt. public: // Loescht alle Listenelemente bis auf den Kopf void clear() { handle tmp = head->next; while(tmp!=head) { handle tmpnext = tmp->next; delete tmp; tmp = tmpnext; } head->next = head; } // Konstruktor, erzeugt leere Liste list() { head = new list_node<T>; head->next = head; } // Destruktor ~list() { clear(); delete head; } // Testet, ob die Liste leer ist bool empty() { if(head->next==head) return true; return false; } // gibt das erste Listenelement zurueck handle first() { return head->next; } // gibt das letzte Listenelement zurueck handle last() { return prev(head); } // liefert Zeiger auf Vorgänger des Elementes an Position pos handle prev(handle pos) { handle prev = head; while(prev->next!=pos) { prev=prev->next; assert(prev!=head) } return prev; } // fuegt ein Element in die Liste nach pos ein handle insert(handle pos, T x) { handle tmp = new list_node<T>; tmp->inf = x; tmp->next = pos->next; pos->next = tmp; return tmp; } // loescht das Element pos handle erase(handle pos) { assert(pos!=head); handle next_node = pos->next; handle prev_node = prev(pos); prev_node->next = next_node; delete pos; return next_node; } /* Bewegt die Elemente aus dem Bereich [first, last] aus der Liste L2 in die Liste L und fuegt sie nach pos ein. */ void splice(handle pos, list<T>& L2, handle first, handle last) { // Hier sollten noch irgendwelche Abfragen rein, um die // Vorbedingungen zu ueberpruefen. handle test; //stellt sicher, dass pos in L ( pos darf auch head sein) test = head->next; while(test!=pos) { assert(test!=head) test=test->next; } //stellt sicher, dass [first,last] in L2 test = L2.head->next; while(test!=first) { assert(test!=head) test=test->next; } test = L2.head->next; while(test!=last) { assert(test!=head) test=test->next; } //stellt sicher, dass pos und L2.head nicht in [first,last] liegt assert (pos!=first) assert (L2.head!=first) test=first; while(test!=last) { test=test>next; assert(pos!=test) assert(L2.head!=test) } // Hier passiert nichts if(pos->next==first) return; // die Teilliste aus L2 loeschen // durch Aufruf L2.prev(first) erhalten wir Laufzeit O(n) (L2.prev(first))->next = last->next; // die Teilliste in L einfuegen last->next=pos->next; pos->next=first; } // Sucht das Element x in der Liste handle find(T x) { handle tmp = head->next; while(tmp!=head) { if(tmp->inf == x) return tmp; tmp = tmp->next; } assert(tmp!=head); } }; 5. Laufzeit: Die Operationen clear und find durchlaufen i.A. die ganze Liste und brauchen deshalb O(n) Zeit (bei n Listenelementen). Die Operation prev benötigt ebenfalls O(n), daher haben alle Funktionen die diese benutzen, also erase, last und splice ebenfalls lineare Laufzeit. Alle anderen Operationen brauchen nur konstante Zeit O(1).