PDF

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).