Im letzten Teilkapitel ist Ihnen bestimmt aufgefallen, dass es auffallend lästig ist,
selbst für einfache Sprachen wie
oder
Turingmaschinen zu programmieren. Ein Grund dafür ist, dass die Maschine nur an einer Position
des Bandes
lesen und schreiben kann und man deswegen ständig zwischen verschiedenen Stellen hin- und
herfahren muss.
Es bietet sich daher an, an etwas allgemeineres Modell einer Rechenmaschine zu definieren,
das dann auch leichter zu programmieren ist. Dies ist die Mehrband-Turingmaschine.
Eine Mehrband-Turingmaschine ist wie eine Turingmaschine, nur dass sie statt einem
viele Bänder und somit auch viele Schreib-Lese-Köpfe hat. Die
Zustandsübergangsfunktion hat somit auch die Signatur
Beispiel 8.3.1
Entwerfen wir nun eine Turingmaschine für die Palindromsprache
In Beispiel 4.2.2
haben wir dafür eine Einband-Turingmaschine geschrieben. Deren Nachteil war, dass sie
ständig zwischen dem linken und rechten Rand hin-und-herlaufen musste.
Bauen wir nun eine Mehrband-Turingmaschine. Diese arbeitet in drei einfachen und kurzen
Phasen:
copy: kopiert das auf das zweite Band
rewind: bewegt den Kopf des ersten Bandes zurück zum Anfang
compare: schaut, ob erstes und zweites Band den gleichen Inhalt haben.
Übungsaufgabe 8.3.1
Schreiben Sie eine Mehrband-Turingmaschine, die Binärzahlen addiert. Wenn
also beispielsweise 1010+110 auf dem ersten Band (Eingabeband) steht,
dann soll nach Abschluss der Berechnung das Ergebnis auf dem Ausgabeband stehen,
also 10000.
Tip. Verwenden Sie drei Bänder. Sei der Bandinhalt . In einer ersten
Phase kopieren Sie auf das zweite Band. In der nächsten Phase gehen Sie ans Ende von
.
Dann addieren Sie nach den Regeln der Binäraddition. Ob "1 gemerkt" gilt oder nicht, können
Sie in Ihrem internen Zustand speichern. Eine Regel wäre also zum Beispiel:
Ein lästiges Detail ist, dass kürzer sein könnte als und Sie daher
in das + reinlaufen könnten; wenn kürzer ist als , dann könnten
Sie auf dem zweiten Band in ein reinlaufen. Wie ist dieser Fall zu behandeln?
Berechnete Sprache, berechnete Funktion.
Die Begriffe des Akzpetierens und Ablehnens definieren wir genau wie für die
Einband-Turingmaschinen. Eine formale Definition der Konfiguration ersparen wir uns jedoch.
Wenn unsere Mehrband-Turingmaschine nicht nur akzeptieren / ablehnen, sondern etwas
berechnen soll, also eine Funktion
dann bauen wir sie per Konvention so, dass sie ein designiertes Ausgabeband hat, auf dem
nach Abschluss der Berechnung das Ausgabewort steht.
Einband-Maschinen können Mehrband-Maschinen simulieren
Es stellt sich heraus, dass mehrere Bänder zwar ein praktisches Feature sind, aber nicht
wirklich mehr Ausdruckskraft verlangen; was eine Mehrband-Turingmaschine schafft,
schafft eine Einband-Turingmaschine auch.
Theorem 8.3.2(Einband-Turingmaschine simuliert Mehrband-Turingmaschine).
Sei eine Turingmaschine mit Bändern, wovon eines ein designiertes
Ausgabeband ist. Dann gibt es
eine Einband-Turingmaschine mit folgenden Eigenschaften:
akzeptiert/lehnt ab/terminiert nicht genau dann, wenn
akzeptiert/ablehnt/nicht terminiert.
Wenn akzeptiert und der Bandinhalt des Ausgabebandes
ist, dann akzeptiert auch, und der Bandinhalt (des einzigen Bandes, es gibt
ja nur eins) ist .
In anderen Worten: simuliert .
Beweis.
Der erste Trick ist, dass wir die Bänder von "zusammenkleben" in ein
neues Band, in welcher jede Zelle Symbole enthalten kann:
Das Problem sind nun die Köpfe. Wenn wir diese Idee naiv umsetzen würden, hätte unsere
Maschine zwar ein Band, dafür drei Köpfe auf diesem:
Wir lösen dies, indem wir die Kopfpositionen in das Band selbst reinschreiben:
Das neue Bandalphabet ist also nicht sondern
. Wo steht nun aber denn der
Kopf von ? Jetzt kommt der schwierige Teil: um einen Schritt von zu
simulieren,
muss von ganz links nach ganz rechts laufen und alle Informationen über die -Köpfe sammeln. Dann von rechts nach links gehen und die ausführen.
Wir müssen also die Zustandsmenge deutlich erweitern; so muss sie speichern können,
ob wir ein Symbol bereits gelesen haben; ob wir ein Symbol bereits geschrieben haben und
ob wir den Kopf bereits entsprechend verschoben haben. Für eine Rechtsverschiebung
müssen wir uns zusätzlich noch merken, dass wir sie gerade durchführen und für welches
Band. All dies ist viel, aber immer noch endlich. Wir können alles in einer
endlichen Zustandsmenge speichern.
Wenn der -Zustand (der natürlich auch im -Zustand gespeichert ist),
accept
erreicht hat, dann macht noch eine Aufräumphase, in welcher sie alle Symbole,
die nicht zum Ausgabeband gehören, durch ersetzt. Dann wechselt sie in
ihren eigenen akzeptierenden Zustand accept'.
Einfügen versus Überschreiben
Folgende Aufgabe ist auf einer Einband-Turingmaschine sehr leicht: gegeben ein Eingabewort , ersetze
jedes durch ein . In der Syntax von turingmachinesimulator.com:
Ungleich schwieriger ist die Aufgabe, jedes durch ein zu ersetzen, weil wir hier
etwas
einfügen wollen. Auf einer Einband-Turingmaschine müssen wir für jedes alles, was
rechts davon kommt,
um eine Zelle nach rechts verschieben.
Meinen Quelltext finden sie in replace-b-by-bc.txt.
Können wir unserer Turingmaschine die Funktionalität geben, eine Zelle einzufügen und
alles von Kopf bis zum linken Ende
um eins nach links zu verschieben bzw. das analoge, aber nach rechts?
Wir sind freie Menschen, wir können definieren, was wir wollen, müssen uns aber zwei Fragen
stellen:
Ist das immer noch ein plausibles Modell einer Rechenmaschine? Ist also unser neue
Funktionalität physikalisch realisierbar?
Verleiht es wirklich neue Funktionalität, oder ist es nur Syntaxzucker?
In diesem Fall ahnen Sie es wohl bereits: es ist nur Syntaxzucker. Die Funktionalität des
Einfügens/Verschiebens können wir
leicht mit zwei Bändern simulieren. Wir halten uns einfach an die Konvention, dass auf Band 1
der Kopf immer auf dem linkesten Zeichen steht
und auf Band 2 der Kopf jenseits des rechtesten.
wird dann
q, x, _
r, _, x, >, >
Eine Linksbewegung ist etwas schwieriger zu implementieren. Aus
wird
q, x, _
r', x, _, -, -
r', _, c
r, c, _ // für jedes Zeichen c
Ein neues Zeichen links vom Kopf einfügen ist nun einfach: aus
wird
q, x, _
r'', _, z, >, >
r'', c, _
r, c, y, -, > // für jedes Zeichen c
Sie können sich meine Implementierung in insert-z-before-y.txt ansehen.
Geben Sie beispielsweise xxyxyyxx als Eingabewort ein.
Wir können von nun an also so tun, als hätten unsere Turingmaschinen die Möglichkeit, zusätzliche
Zellen
einzufügen. In einer konkreten Implementierung müssten wir dafür allerdings jedes Band durch
zwei
Bänder ersetzen. Alternativ können Sie sich eine Turingmaschine vorstellen, die statt
Bändern einfach
Stapel hat.
Die Dictionary-Maschine
Ein fundamentale Datenstruktur beim Programmieren sind Dictionaries, die
Key-Value-Paare speichern:
Im Zweifelsfall sind diese als Hashmaps oder Rot-Schwarz-Bäume oder B-Bäume implementiert.
Hier interessiert uns nicht so sehr die Laufzeit, sondern einfach die Funktionalität.
Können wir für Dictionaries eine Turingmaschine implementieren?
Übungsaufgabe 8.3.2
Schreiben Sie auf turingmachinesimulator.com
eine Mehrband-Turingmaschine, die Inputs der Form
entgegennimmt, für ,
also und akzeptiert, wenn
es ein gibt mit und in diesem Falle auf das Ausgabeband schreibt.
Tip: Kopieren Sie erst einmal den gesuchten Schlüssel auf
das zweite Band. Dann können Sie bequem den Schlüssel auf dem
ersten Band mit dem auf dem zweiten Band vergleichen.
Wenn Sie es sich einfach machen wollen, nehmen Sie einfach mal an, dass
alle Schlüssel gleiche Länge haben. Das erspart Ihnen gefühlt 20 Zeilen
im Programmcode der Turingmaschine.
Nichtdeterministische Turingmaschinen
Bereits im Kapitel über reguläre Sprachen haben wir gesehen, dass
Nichtdeterminismus hilfreich ist, wenn wir Dinge beschreiben wollen,
auch wenn es kein realistisches Modell für Rechenmaschinen darstellt.
Die Sprache aller Wörter über , die das Teilwort
enthalten, kann man beispielsweise leicht
mit dem folgenden Automaten beschreiben:
Es ist klar, was dieser Automat erlaubt. Einen deterministischen Automaten für die
gleiche Sprache zu entwerfen (ohne systematisch über den nichtdeterministischen zu gehen)
wird schnell chaotisch, und Sie werden sich in den vielen Fallunterscheidungen verlieren.
Andererseits haben wir für endliche Automaten gezeigt, dass die deterministischen und
nichtdeterministischen Varianten
tatsächlich gleichmächtig sind (Potenzmengenkonstruktion). Für die Kellerautomaten,
die für kontextfreie Sprachen relevant sind, galt das nicht (einen Beweis
haben wir allerdings in der Vorlesung nicht durchgenommen). Wie sieht es nun für
Turingmaschinen aus?
Um nichtdeterministische Turingmaschinen zu definieren, müssen wir die
Zustandsübergangsfunktion
zu einer Zustandsübergangsrelation machen. Statt
also nun
Und statt schreiben wir nun . Wir beschränken uns zunächst auf Einband-Turingmaschinen.
Für Konfigurationen haben wir keine
erweiterte Zustandsübergangsfunktion , wo die Folgekonfiguration ist,
sondern eine erweiterte Zustandsübergangsrelation:
Wobei nun dank Nichtdeterminismus mehrere Folgekonfigurationen geben kann (oder eben mal
auch gar keine). Wir schreiben
wenn wir von in einer Folge von Schritten nach kommen können, also
Wir sagen auch: Die Konfiguration ist von aus erreichbar.
Für ein Eingabewort sie die Startkonfiguration.
Eine nichtdeterministische Turingmaschine akzeptiert, wenn es eine
akzeptierende Endkonfiguration gibt mit
wenn es also (mindestens) eine akzeptierende Konfiguration gibt, die von aus erreichbar
ist.
Dabei kann es mehrere erreichbare akzeptierende Konfigurationen geben,
Es kann sogar eine ablehnende Konfiguration
geben. Spielt keine Rolle: solange es
einen Weg gibt, sagen wir,
dass das Eingabewort akzeptiert.
Definition 8.3.3(Akzeptieren und Entscheiden bei nichtdeterministischen Turingmaschinen).
Eine nichtdeterministische Turingmaschine akzeptiert die Sprache wenn
Für jedes gibt es also keine akzeptierende Konfiguration mit
.
Die Turingmaschine entscheidet die Sprache , wenn sie sie akzeptiert
und es keine unendlich langen Ketten
gibt.
Oft wird händeringend versucht, zu erklären, was denn eine nichtdeterministische
Turingmaschine
tut. Da lesen Sie dann beispielsweise, dass die alle Möglichkeiten gleichzeitig
ausprobiert
oder den richtigen Pfad von einem Engel gesagt bekommt oder errät.
Ich stelle mir lieber vor, dass eine nichtdeterministische Turingmaschine gar nichts "tut"
sondern
Spielregeln definiert, wie man "ziehen" kann. Man gewinnt, wenn man in einer
akzeptierenden
Konfiguration landet.
Beispiel 8.3.4
Beim Teilsummenproblem haben wir eine Liste von Waren (alles Unikate) mit Preisen
und ein Guthaben gegeben und wollen wissen,
ob wir unser Guthaben exakt ausgeben können. Ob es also eine Teilmenge
von Waren gibt, die genau kostet:
Um das als formale Sprachen bzw. Entscheidungsproblem zu formalisieren, müssen wir uns
eine Codierung überlegen. Preise sind ganze Zahlen (in Cent, wenn Sie so wollen),
in Dezimalschreibweise dargstellt. Waren sind mit einem separiert. Nach den Waren
kommt ein und dann das Guthaben. Wenn also beispielsweise die Waren
die Preise 65, 8, 22, 19, 7, 58, 30, 1, 13, 38 haben und unser Guthaben 194 ist, dann
würden wir das als Wort
über dem Alphabet codieren. Das Wort ist
in unserer Sprache enthalten, wenn es nun eben eine Teilmenge gibt, die sich
genau zu 194 aufsummiert.
Entwerfen wir nun eine nichtdeterministische Turingmaschine für diese Sprache.
geht von links nach rechts alle Waren durch. Jedes Mal, wenn ein
Preis beginnt, haben wir die Möglichkeit, diesen Preis auf das zweite Band zu kopieren (die
Ware zu kaufen) oder eben nicht:
also
Dies erlaubt uns zum Beispiel, bei Eingabe
eine
Konfiguration zu erreichen, wo auf dem ersten Band der Kopf auf dem : steht und
auf dem zweiten Band
aber eben auch jede beliebige andere Summe. Im Zustand aufgerufen, muss nun
die
Turingmaschine
alle diese Zahlen auf dem zweiten Band addieren (lästig, geht aber irgendwie) und dann in
einer
dritten Phase mit der Zahl rechts vom : vergleichen. Stimmen sie überein, akzeptiert die
Turingmaschine,
stimmt sie nicht über ein, lehnt sie ab. Da
, haben wir eine ablehnende Konfiguration erreicht.
Es sind aber viele Endkonfigurationen erreichbar. Wir können zum Beispiel in Phase 1
auch
auf das untere Band kopieren. Da dies tatsächlich 194 ergibt, akzeptiert die Turingmaschine.
Wir sehen also:
weil es eben eine vom Start aus erreichbare akzeptierende Konfiguration gibt.
Sind nun nichtdeterministische Turingmaschinen inhärent mächtiger? Können Sie
das Teilsummenproblem auch mit einer deterministischen lösen? Klar! Hier
ist mein Code in Elm, einer funktionalen Programmiersprache: er probiert alle Möglichkeiten
durch.
module SubsetSum exposing (..)subsetSum : List Int -> Int -> BoolsubsetSum prices amount = case ( prices, amount ) of ( [], 0 ) -> True ( x :: rest, _ ) -> subsetSum rest amount || subsetSum rest (amount - x) ( [], _ ) -> False
Auf einer deterministischen Turingmaschine wäre das deutlich anstrengender, aber irgendwie auch
möglich. Können wir jede nichtdeterministische Turingmaschine deterministisch simulieren,
indem wir "alles ausprobieren"? Ja, in der Tat!
Theorem 8.3.5(Nichtdeterministische Turingmaschinen deterministisch simulieren)
Sei eine nichtdeterministische Turingmaschine. Dann gibt
es eine deterministische Maschine mit , d.h.
akzeptiert genau dann, wenn es akzeptiert.
Zusätzlich gilt: wenn die Sprache nicht nur akzpetiert, sondern
entscheidet, dann entscheidet auch die Sprache (terminiert also
auf jedem Eingabewort).
Beweis.
Als erstes führen wir eine kosmetische Änderung unserer nichtdeterministischen
Maschine durch: wir wollen, dass es für jedes genau zwei Möglichkeiten gibt, also
außer wenn ; dann gibt es gar keine Möglichkeit.
Dies ist einfach: sollte es mehr als zwei Möglichkeiten geben, so führen wir
Zwischenzustände ein:
Auf der Menge der Konfigurationen schaut das dann noch intuitiver aus:
Sollte ein Paar weniger als zwei Folgemöglichkeiten geben, so erfinden
wir einfach neue, die jedoch direkt nach reject führen. Es sollte
klar sein, dass diese Änderungen rein kosmetisch sind und nichts an der Funktionsweise
von ändern.
Nun bauen wir um und geben ihr ein zweites Band. Auf diesem Band soll ein Wort in
stehen. Wir machen deterministisch mit der folgenden Regel:
wenn Du im Zustand bist und auf dem ersten Band ein hast und
auf dem zweiten Band eine liest, nimm den oberen Pfeil, der von ausgeht;
wenn Du eine liest, nimm den unteren Pfeil.
Falls wir auf dem zweiten Band einem anderen Zeichen begegnen ( oder sonst etwas,
das weder 0 noch 1 ist), dann lehnen wir sofort ab.
Wir haben nun eine deterministische Turingmaschine , die jedoch nicht das gleiche tut
wie . Aber: wenn , dann gibt es ein Wort
, das wir auf das zweite Band schreiben könnten, so dass
das Eingabewort akzeptiert. Im Gegenzug: wenn , dann
können wir auf das zweite Band schreiben, was wir wollen, wird immer ablehnen.
Nun bauen wir schlussendlich eine Maschine , die in einer Endlosschleife
alle möglichen aufzählt, auf das zweite Band schreibt, und
neustartet.
Geht das? Wir können zum Beispiel hochzählen, binär schreiben und die
führende
1 löschen. Überzeugen Sie sich, dass in dieser Reihe wirklich alle
vorkommen.