diff --git a/102_Asymptotische_Schranken.tex b/102_Asymptotische_Schranken.tex index bc81f69aee986fa68def8d27094aaf48af271936..162d81f2984a8630492c7c61e5873297a55a405e 100644 --- a/102_Asymptotische_Schranken.tex +++ b/102_Asymptotische_Schranken.tex @@ -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. diff --git a/103_Elementare_Datenstrukturen.tex b/103_Elementare_Datenstrukturen.tex index b34a6cc8ecc748292588cee17ab588a0f9dc34f2..489f87ded8ac15382e1dcd33d85800374ad32cbd 100644 --- a/103_Elementare_Datenstrukturen.tex +++ b/103_Elementare_Datenstrukturen.tex @@ -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} - \KwOut{Side effects in the memory $\mathcal{M}$} - $\texttt{next}(\mathcal{M}[p]) \leftarrow \texttt{next}(\texttt{next}(\mathcal{M}[p]))$ \; + \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}$} + $((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.