Skip to content
Snippets Groups Projects
Commit a7e780a7 authored by Florian Unger's avatar Florian Unger
Browse files

Merge branch 'master' of...

Merge branch 'master' of gitlab.tugraz.at:6048DFF64EAA81BC/datenstrukturen-und-algorithm-ss2020-skript
parents ad166213 34e3a1e4
No related branches found
No related tags found
No related merge requests found
......@@ -209,13 +209,13 @@ die Motivation für die folgende Alternativdefinition:
\subsection{Definition der asymptotischen Schranken über den Limes}
Wir erinnern uns an die Definition des Limes superior bzw Limes inferior:
\begin{definition}[Limes superior und Limes inferior]
Sei $g: \mathbb{N} \rightarrow \mathbb{R}^+$ eine positive Funktion. Wir definieren
Sei $f: \mathbb{N} \rightarrow \mathbb{R}^+$ eine positive Funktion. Wir definieren
\[
\limsup_{n \rightarrow \infty} g(n) := \inf_{n_0 \in \mathbb{N}} \sup_{n \ge n_0} g(n)
\limsup_{n \rightarrow \infty} f(n) := \inf_{n_0 \in \mathbb{N}} \sup_{n \ge n_0} g(n)
\]
und analog
\[
\liminf_{n \rightarrow \infty} g(n) := \sup_{n_0 \in \mathbb{N}} \inf_{n \ge n_0} g(n)
\liminf_{n \rightarrow \infty} f(n) := \sup_{n_0 \in \mathbb{N}} \inf_{n \ge n_0} g(n)
\]
\end{definition}
Für uns der größte Vorteil ist, dass unter den gegebenen Umständen sowohl der Limes superior als auch der Limes inferior
......@@ -238,8 +238,9 @@ Damit können wir nicht nur Kriterien für asymptotisches Wachstum benennen, son
\begin{lemma}
Seien $f, f', g, g', h: \mathbb{N} \rightarrow \mathbb{R}^+$.
\begin{labeling}{Multiplikation\ \ }
\item[Addition] Seien $f \in \mathcal{O}(g)$ und $f' \in \mathcal{O}(g')$. Dann ist $f+f' \in \mathcal{O}(g)
\cup \mathcal{O}(g')$.
\item[Addition] Seien $f \in \mathcal{O}(g)$ und $f' \in \mathcal{O}(g')$. Dann ist $f+f' \in \mathcal{O}(m)$, wobei
$m(n) = \text{max}(g(n), g'(n))$ die Funktion in Abhängigkeit von $g$ und $g'$ ist, welche Elementweise das Maximum
nimmt.
\item[Subtraktion] Seien $f, f' \in \mathcal{O}(g)$. Dann gilt $f-f' \in \mathcal{O}(g)$.
\item[Multiplikation] Seien $f \in \mathcal{O}(g)$ und $f' \in \mathcal{O}(g')$. Dann gilt $ff' \in
\mathcal{O}(gg')$.
......@@ -247,10 +248,8 @@ Damit können wir nicht nur Kriterien für asymptotisches Wachstum benennen, son
\end{labeling}
\label{lemma:asymptotische_rechenregeln}
\end{lemma}
Zur Additionsregel sei angemerkt, dass sie durch die Transitivitätsregel meist weiter vereinfachen lässt. Wachsen
$g$ und $g'$ gleich schnell, also $g \in \Theta(g')$, so ist $\mathcal{O}(g) = \mathcal{O}(g')$. Wächst o.B.d.A. $g'$ stärker
als $g$, also $g \in \mathcal{O}(g')$, so ist $\mathcal{O}(g) \cup \mathcal{O}(g') = \mathcal{O}(g')$. Nur für den
seltenen Fall, das $g \notin \mathcal{O}(g')$ und $g' \notin \mathcal{O}(g)$ bleibt es bei der Meingenvereinigung.
Zur Additionsregel sei angemerkt, dass in vielen praktischen Fällen meist $g \in \mathcal{O}(g')$ oder andersherum gilt.
Dann kann man $m$ einfach durch $g'$ (bzw $g$) ersetzen.
\begin{proof}
Wir betrachten zur Veranschaulichung die Transitivität genauer.
......
......@@ -5,27 +5,18 @@ Was ist eine Datenstruktur? Diesen Begriff mathematisch präzise zu fassen ist s
anderen Vorlesungen und begnügen uns mit einer unpräziseren Definition: Datenstrukturen sind \emph{Daten} mit einer
\emph{Struktur}.
Daten meint dabei sowohl elementare Datentypen wie \texttt{int, float, char}, aber auch komplexere oder abstraktere
Gebilde wie die Repräsentation eines Gegenstandes oder $x \in \mathbb{R}$.
Daten meint dabei sowohl elementare Datentypen wie \texttt{int, float, char}, aber auch komplexere Gebilde wie ein Tupel
$(\text{int}, \text{float})$ oder abstraktere Gebilde wie die Repräsentation eines Gegenstandes oder $x \in \mathbb{R}$.
Die Menge der Daten bezeichnen wir mit $D$.
Struktur kann viel bedeuten. In diesem Kapitel befassen wir uns hauptsächlich mit Strukturen, die eine lineare Ordnung
ermöglichen, die uns also erlauben zu sagen, in welcher ``eindeutigen Reihenfolge'' sich die Daten befinden.
Datenstrukturen, in denen mehr als ein Nachfolger erlaubt ist, behandeln wir in späteren Kapiteln.
\paragraph{Die Menge: Eine Datenstruktur fast ohne Struktur}
Ein kurzes, einführendes Beispiel betrachtet aber eine Datenstruktur mit noch viel weniger Struktur, die sogenannte
Menge (set). Ganz wie die naive Definition einer mathematischen Menge ist das eine ``Zusammenfassung bestimmter,
wohlunterschiedener Objekte unserer Anschauung oder unseres Denkens zu einem Ganzen'' (Cantor).
Vorstellen kann man es sich als Sack, in welchem verschiedene Dinge sind. Die einzige Struktur, die die Menge
auferlegt, ist die Tatsache, das ein Element nicht mehrfach vorkommen darf (das wäre dann ein Multiset). Ansonsten haben
wir keinerlei Reihenfolge oder sonstige Struktur. Das Entfernen eines Elements geschieht in konstanter
Zeit ($\mathcal{O}(1)$), das Suchen und Einfügen muss durch elementweises Vergleichen geschehen ($\mathcal{O}(n)$, wobei $n$ die
Anzahl der Elemente ist). Sortieren ist schlicht nicht möglich.
\paragraph{Datenstrukturen mit einer Reihenfolge}
Im Folgenden betrachen wir die vier bekanntesten Datenstrukturen, die uns zumindest eine Reihenfolge geben. Das
mathematische Äquivalent wäre also nicht mehr die Menge, sondern ein Tupel $(d_0, d_1, d_2, \dots)$ mit $d_i$ als
mathematische Äquivalent wäre also nicht eine Menge, sondern ein Tupel $(d_0, d_1, d_2, \dots)$ mit $d_i$ als
das Nutzdatum an der $i$-ten Stelle. Damit einher geht
die Einführung des Index $i \in \mathbb{N}_0$, wir fangen also (um Verwirrung beim Programmieren vorzubeugen) mit
$0$ an zu zählen. Dieser Index erlaubt uns, den Index $i$ (manchmal auch Schlüssel) und die Daten der Datenstruktur
......@@ -33,8 +24,32 @@ $\mathcal{D}$ zu unterscheiden. Die Daten der Datenstruktur an der Stelle $i$ ne
Wir werden zusätzlich Implementierungen dieser Datenstrukturen angeben. Diese sind in imperativer Perspektive für ein
Maschinenmodell mit Random-Access-Memory (RAM) $\mathcal{M}$ zu verstehen.
Die definierende Eigenschaft dieses Maschinenmodells ist, dass der Zugriff $\mathcal{M}[i]$ auf ein Speicherfeld stets gleich lange braucht,
egal wo es liegt. Der Einfachheit halber nehmen wir zudem an, unendlich viel Speicher zu haben, also $i \in \mathbb{N}$.
Um zu beschreiben, an welcher Stelle in $\mathcal{M}$ wir zugreifen wollen benutzen wir sogenannte pointer $p \in P =
\mathbb{N} \cup \{\text{void}\}$. Ein Zugriff ist dann wie folgt definiert:
\[
\mathcal{M}[p] =
\begin{cases}
\texttt{error} &\text{ if } p = \texttt{void} \\
d_p &\text{ else},
\end{cases}
\]
wobei $d_p$ der Speicherinhalt an der Stelle $p$ ist.
Die definierende Eigenschaft dieses Maschinenmodells ist, dass der Zugriff $\mathcal{M}[p]$ auf ein Speicherfeld stets gleich lange braucht,
egal wo es liegt. Desweiteren nehmen wir an, unendlich viele Speicherfelder zu haben und nicht nur das, auch jedes
einzelne Speicherfeld fasst unser Datum $d$, egal wie groß es sein mag.
\begin{figure}[!htp]
\centering
\tikzsetnextfilename{ram}
\begin{tikzpicture}[box/.style = {draw, minimum size=9mm, inner sep=0pt, outer sep=0pt, anchor=center},]
\matrix (array) [matrix of nodes, nodes={box}, column sep=-\pgflinewidth, inner sep=0pt]
{
$d_0 $ & $d_1$ & \qquad $\dots$ \qquad \vphantom{3} & $d_{p-1}$ & $d_p$ & $d_{p+1}$ & \qquad $\dotsm$ \qquad \vphantom{3} \\
};
\end{tikzpicture}
\caption{Der (unendliche) random access Maschinenspeicher $\mathcal{M}$.}
\end{figure}
\paragraph{Notation}
In der Hoffnung, die folgende Mischung aus mathematischer Notation, Pseudocode und Erklärungen verständlicher zu
......@@ -45,7 +60,7 @@ notationell zu den Speicherstrukturen. Systemfunktionen wie $\texttt{reference\_
$\texttt{len}$ für die Länge von Speicherstrukturen werden in \texttt{truetype} gesetzt, um ihre Maschinennähe darzustellen.
Ebenso bekommen all unsere in Psudeocode notierten Algorithmen diese Notation.
\subsection{Lineares Feld (Array)}
\subsection{Arrays $\mathcal{A}$}
Das Array $\mathcal{A}$ ist eine Datenstruktur von fixer Größe $n \in \mathbb{N}$. Aus logischer Perspektive stehen benachbarte
Elemente direkt nebeneinander. Das Array $\mathcal{A}$ von Größe $n$ ist also nichts weiter als
$\mathcal{M}[s,\dots,s+n-1]$, wobei $s \in \mathbb{N}$ ein gewisser Versatz ist.
......@@ -62,13 +77,21 @@ Wir geben daher später keine Laufzeiten für das Einfügen oder Entfernen von E
\begin{tikzpicture}[box/.style = {draw, minimum size=9mm, inner sep=0pt, outer sep=0pt, anchor=center},]
\matrix (array) [matrix of nodes, nodes={box}, column sep=-\pgflinewidth, inner sep=0pt]
{
$\mathcal{A}[0]$ & $\mathcal{A}[1]$ & $\mathcal{A}[2]$ & \quad$\dotsm$\quad\vphantom{4} & $\mathcal{A}[n-1]$ \\
& & & $\mathcal{A}[0]$ & $\mathcal{A}[1]$ & \quad $\dotsm$
\quad \vphantom{3} & \enspace $\mathcal{A}[n-1]$ \enspace & \\
$d_0 $ & \quad $\dots$ \quad \vphantom{3} & $d_{s-1}$ & $d_s$ & $d_{s+1}$ & \quad $\dotsm$
\quad \vphantom{3} & \quad $d_{s+n-1}$ \quad & \quad $\dots$ \quad \vphantom{3} \\
};
\end{tikzpicture}
\caption{Ein Array $\mathcal{A}$ von Länge $n$}
\caption{Ein Array $\mathcal{A}$ von Länge $n$ mit offset $s$ in $\mathcal{M}$.}
\end{figure}
\subsection{Listen}
Arrays sind die einfachsten Datenstrukturen auf Maschinen mit Random-Access-Memory. Allerdings steht der Vorteil der immer gleichen
Zugriffszeit wird dem Nachteil der Inflexibilität gegenüber: Zur Laufzeit können Elemente weder hinten, noch irgendwie
in der Mitte eingefügt werden. Es ist lediglich ein neubeschreiben der Felder möglich. Desweiteren sind Arrays
\emph{endlich} lang.
\subsection{Listen $\mathcal{L}$}
Nicht immer können wir vorhersagen, wie viele Datensätze wir im Laufe des Programmablaufs entgegennehmen. Daher benötigen wir
Datenstrukturen, in welche wir Daten einfügen, aber auch wieder löschen können. Ein elementarer Prototyp ist die einfach
verkettete Liste:
......@@ -81,22 +104,20 @@ verkettete Liste:
\label{fig:linked_list}
\end{figure}
Eine Liste $\mathcal{L}$ besteht dabei aus Elementen $(d,p_{\text{next}})$, wobei $d$ die Nutzlast und
$p_{\text{next}}$ einen Zeiger (also die Speicheradresse bzw der Index in unserem RAM) auf das nächste Listenelement oder den sogenannten
Nullpointer \texttt{void} darstellt.
Haben wir also einen Zeiger $p$ zu einem Element, so bekommen wir mit der Funktion $\texttt{data}(\mathcal{M}[p])$ die Datennutzlast $d$ und mit
$\texttt{next}(\mathcal{M}[p])$ den Zeiger $p_{\text{next}}$ zum nächsten Element. Ist der Zeiger $p_{\text{next}} =
\texttt{void}$, so handelt es sich also um das letzte Element dieser Liste. Sowohl die Anfrage
$\texttt{next}(\mathcal{M}[\texttt{void}])$ als auch $\texttt{data}(\mathcal{M}[\texttt{void}])$ bringen uns einen
Fehler.
Nun hat unsere Liste ein Ende, sie braucht nur noch einen Anfang. Wir haben dafür ein spezielles Element
$\texttt{head}(\mathcal{L})$, welches wir als nutzlastfreies Element verstehen, welches nur die Referenz auf das erste
(richtige) Element enthält.
Eine neue, leere Liste ist also lediglich dieses Kopfelement $(\texttt{void} ,\texttt{void})$: In diesem Fall haben wir
keine Nutzlast, weil wir das Headelement sind (hier ebenfalls $\texttt{void}$ ausgedrückt) und der Pointer auf das nächste Element ist der Nullpointer $\texttt{void}$.
Aus einer (etwas abstrakteren) Sicht gibt es dann Nodes $D \times P$ und Listen an sich sind eigentlich nur
headnodes, also ein Node, der keine Daten enthält, sondern nur auf das erste Element der Liste zeigt.
Wir wollen dabei folgende Operationen haben:
\begin{itemize}
\item $\texttt{is\_empty}: \mathcal{L} \rightarrow \text{Bool}$,
\item $\texttt{head}: \mathcal{L} \rightarrow \text{Node}$,
\item $\texttt{next}: \text{Node} \rightarrow \text{Node}$,
\item $\texttt{data}: \text{Node} \rightarrow D$,
\item $\texttt{pointer}: \text{Node} \rightarrow P$,
\item $\texttt{insert\_after}: \text{Node} \times D \rightarrow \{\}$,
\item $\texttt{delete\_after}: \text{Node} \rightarrow \{\}$,
\end{itemize}
Ein Node $(d,\texttt{void})$ ist dabei das letzte Element einer Liste, ein Node $(\texttt{void}, p)$ ist der headnode.
Eine neue, leere Liste ist also lediglich dieses Kopfelement $(\texttt{void} ,\texttt{void})$.
Der Bequemlichkeit halber führen wir auch für Listen eine Indexnotation ein: $\mathcal{L}[0]$ referiert auf das
erste Element der Liste, $\mathcal{L}[2]$ auf das dritte, etc. Dabei ist die Abkürzung definiert als
......@@ -107,32 +128,29 @@ erste Element der Liste, $\mathcal{L}[2]$ auf das dritte, etc. Dabei ist die Abk
\end{align*}
Offensichtlich ist hier, anders als beim Array, die Zugriffszeit linear abhängig von~$i$.
\subsubsection{Listenoperationen}
Beginnen wir mit einem einfachen Check, ob unsere Liste $\mathcal{L}$ leer ist oder nicht:\\
\begin{algorithm}[H]
\SetNlSty{texttt}{[}{]}
\caption{\texttt{is\textunderscore empty}$(\mathcal{L})$}
\KwIn{A list $\mathcal{L}$}
\KwOut{A binary value. \texttt{True} in case $\mathcal{L}$ is empty, \texttt{False} otherwise}
\eIf{$\texttt{next}(\texttt{head}(\mathcal{L})) = \texttt{void}$}{
return \texttt{True} \;
}{
return \texttt{False} \;
}
\end{algorithm}
\subsubsection{Implementierung von Listen auf RAM}
Nodes sind Tupel $(d,p)$, headnode ist $(\texttt{void}, p)$. Die meisten Operationen sind trivial:
\begin{itemize}
\item $\texttt{is\_empty}(\mathcal{L}) = \text{True}$ genau dann, wenn
$\texttt{pointer}(\texttt{head}(\mathcal{L})) = \texttt{void}$.
\item $\texttt{head}(\mathcal{L}) = (\texttt{void},p)$,
\item $\texttt{next}((d,p)) = \mathcal{M}[p]$,
\item $\texttt{data}((d,p)) = d$,
\item $\texttt{pointer}((d,p)) = p$,
\end{itemize}
Anders als in einem Array können wir nun an jeder Stelle der Liste etwas einfügen, siehe Abbildung
\ref{fig:linked_list_insert}. Beachtenswert ist hierbei, dass bei Kenntnis der Adresse des vorherigen Listenelements nur
$\mathcal{O}(1)$ Aufwand vonnöten ist. \\
\begin{algorithm}[H]
\SetNlSty{texttt}{[}{]}
\caption{\texttt{insert\textunderscore after}$(p, d)$}
\KwIn{A pointer $p$ to the list element after which to insert the new list element storing $d$}
\caption{\texttt{insert\textunderscore after}$((d,p), d')$}
\KwIn{A node $(d,p)$ to the list element after which to insert the new list element storing $d'$}
\KwOut{Side effects in the memory $\mathcal{M}$}
new\_element = ($d$, \texttt{next}($\mathcal{M}$[p])) \;
\texttt{next}($\mathcal{M}[p]$) $\leftarrow$ \texttt{reference\_of}(new\_element)\;
new\_element $= (d', \texttt{pointer}(\texttt{next}((d,p))))$ \;
$\texttt{next}(\mathcal{M}[p]) \leftarrow \texttt{reference\_of}(new\_element)$\;
\end{algorithm}
Hier gibt $\texttt{reference\_of}$ die Speicherposition des frisch erstellten Nodes zurück.
\begin{figure}[!htb]
\centering
......@@ -145,10 +163,10 @@ Auch für das Löschen ist nur das Umbiegen eines einzelnen Zeigers nötig, sieh
\begin{algorithm}[H]
\SetNlSty{texttt}{[}{]}
\caption{\texttt{delete\textunderscore after(p)}}
\KwIn{A pointer $p$ to the list element whose successor shall be removed from the list}
\caption{\texttt{delete\textunderscore after((d,p))}}
\KwIn{A node $(d,p)$ to the list element whose successor shall be removed from the list}
\KwOut{Side effects in the memory $\mathcal{M}$}
$\texttt{next}(\mathcal{M}[p]) \leftarrow \texttt{next}(\texttt{next}(\mathcal{M}[p]))$ \;
$((d,p)) \leftarrow (d, \texttt{pointer}(\texttt{next}((d,p))))$ \;
\end{algorithm}
\begin{figure}[!htb]
......@@ -161,7 +179,7 @@ Auch für das Löschen ist nur das Umbiegen eines einzelnen Zeigers nötig, sieh
\subsubsection{Varianten von Listen}
Es gibt Listen in fast allen Geschmacksrichtungen. Häufig verwendet werden beispielsweise die sogenannten doppelt
verketteten Listen (Abbildung
\ref{fig:doubly_linked_list}). Deren Elemente $(d,p_{\text{prev}}, p_{\text{next}})$ haben zwei Pointer, sodass in beide
\ref{fig:doubly_linked_list}). Deren Nodes $(d,p_{\text{prev}}, p_{\text{next}})$ haben zwei Pointer, sodass in beide
Richtungen traversiert werden kann.
\begin{figure}[!htb]
......@@ -181,8 +199,13 @@ bei denen anstelle der Referenz auf $\texttt{void}$ wieder auf den Anfang refere
\label{fig:circular_linked_list}
\end{figure}
\subsubsection{Anwendung}
Verkettete Listen sind nur noch in den seltensten Fällen wirklich notwendig, meistens gibt es bessere Datenstrukturen.
Allerdings kann eine doppelt verkettete Liste als die natürlichste Datenstruktur einer Turing-Maschine betrachtet
werden.
\subsection{Stack}
\subsection{Stacks $\mathcal{S}$}
Ein Stack $\mathcal{S}$ ist wie ein Stapel Teller: Hinzufügen oder Entnehmen von weiteren Tellern ist nur an der Spitze möglich.
Viele Anwendungen brauchen genau das, aber auch nicht mehr. Beispielsweise das Umdrehen der Reihenfolge einer Sequenz,
......@@ -202,7 +225,7 @@ $\texttt{pop}$, welches den obersten Teller entfernt. Die LIFO-Bedingung wird da
beschrieben.
\subsubsection{Stackoperationen}
Wir implementieren den Stack $\mathcal{S}$ als oben definierte verkettete Liste. Ein leerer Stack ist daher auch einfach
Wir implementieren den Stack $\mathcal{S}$ mittels einer oben definierten verkettete Liste. Ein leerer Stack ist daher auch einfach
eine leere Liste $\mathcal{L}$.
Der Test auf Inhalt ist somit ident zum entsprechenden $\texttt{is\_empty}$ für Listen.
......@@ -226,19 +249,15 @@ Auslesen, und dem Löschen: \\
Charmant für uns ist, dass alle Operationen auf Stacks jeweils $\mathcal{O}(1)$ Zeit brauchen, meist also
vernachlässigbar sind.
\subsection{Queue}
\subsection{Queues $\mathcal{Q}$}
Anders als der Stapel, der die LIFO-Strategie verfolgt, beschreibt die Queue eine (faire) Warteschlange: Wer als erstes
da war, kommt als erstes dran. Man nennt es auch die FIFO-Strategie (first-in, first-out).
Ansonsten hat auch sie zwei Funktion, $\texttt{put}$, das Hintanstellen, sowie $\texttt{get}$, das Drankommen.
Anwendungsfälle sind vor allem die namensgebenden Warteschlangen. Man könnte also beispielsweise einen sehr primitiven
Betriebsystemscheduler damit beschreiben. Häufiger kommt es aber beispielsweise bei der Arbeitsverteilung eines aus
vielen unabhängigen Einzeloperationen bestehenden Programms auf einzelne Threads vor.
\subsubsection{Queueoperationen}
Wir implementieren eine Queue $\mathcal{Q}$ wieder über eine verkettete Liste $\mathcal{L}$, aber diesmal müssen wir
Wir implementieren eine Queue $\mathcal{Q}$ wieder über eine verkettete Liste $\mathcal{L}$, aber diesmal sollten wir
noch eine Referenz auf das letzte Element mitführen (sonst müssten wir immer mit $\mathcal{O}(n)$ Aufwand zum Ende der
verketteten Liste laufen). Die Queue $\mathcal{Q}$ besteht also also aus $\mathcal{Q} = (\mathcal{L}, p_{\text{last}})$.
verketteten Liste laufen. Das ist prinzipiell möglich, aber langsam.). Die Queue $\mathcal{Q}$ besteht also also aus $\mathcal{Q} = (\mathcal{L}, p_{\text{last}})$.
Wir fügen Elemente am Ende der Liste hinzu und lesen sie vom Anfang der Kette ab.
Der Test auf Leere ist wieder analog wie bei Listen.
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment