Worin wir uns auf die Studienreise in ein anderes Gebiet der Informatik begeben.
Schaltkreisentwerfer wollen möglichst viele nette Dinge auf möglichst wenig Platz unterbringen. Da sind sie schon ganz schon erfinderisch in der Wahl ihrer Methoden. Besonders die Implementation boolescher Schaltungen liegt diesen Leuten am Herzen, weil die meisten Dinge sich um 0 und 1 und deren Verknüpfungen dreht. Im ersten Semester gab es die Primimplikandenmethode zum Verkleinern boolescher Schaltungen. Nun gibt es die optimalen binären Entscheidungsgraphen.
Gegeben sei eine n-stellige boolesche Funktion
.Basis der Überlegungen ist eine brute-force-Implementation der Funktion durch
eine Kaskade von Anfragen nach den Werten der Argumente. Es entsteht ein
Entscheidungsbaum, dessen innere Knoten
Fragen nach einzelnen Argumentwerten und dessen Blätter zugehörige
Ergebnisse sind.
Die Abbildung zeigt einen binären Entscheidungsbaum für die gute alte Implikation.
Alles klar, oder? Der Faulheit zuliebe beschließen wir, daß ab sofort die 0-Ausgänge von Frageknoten mit gestrichelten und die 1-Ausgänge mit durchgezogenen Linien gemalt werden. Dann brauchen wir die Beschriftung mit 1 und 0 nicht extra hinzuschreiben. Außerdem ist klar, daß man jede beliebige n-stellige boolesche Funktion auf diese Weise durch einen Baum der Tiefe n repräsentieren kann. Also einen mit um die 2n Knoten.
Der Baum ist, wenn man einmal die Ordnung der Argumente festgelegt hat, bis auf Graphisomorphie eindeutig bestimmt. Also eindeutig. Alles, was jetzt passiert, beruht auf einer festgelegten Reihenfolge der Argumentabfragen. Man kann nämlich jede Menge Speicher sparen, wenn man den Baum nach den folgenden beiden Regeln verkleinert.
Regel 1: Wenn Du zwei gleiche Teilbäume findest, streiche den einen und leite seine Eingänge zum anderen um.
Regel 2: Wenn beide Ausgänge einer Frage zum selben Knoten führen, lasse die Frage weg.
Man sieht, daß man anhand des kleineren Entscheidungsgraphen (es ist längst kein Baum mehr) immer noch jeden beliebigen Funktionswert durch eine beim Wurzelknoten beginnende Kaskade von Fragen herausbekommen kann.
Definition 13692 (Entscheidungsgraph)
Ein azyklischer beschrifteter Graph heißt n-stelliger binärer Entscheidungsgraph, falls
Die Knoten xi repräsentieren natürlich Fragen nach dem i-ten Argument der Funktion.
Entscheidungsbäume sind Entscheidungsgraphen. Die beiden Regeln überführen Entscheidungsgraphen wieder in Entscheidungsgraphen. Ein Entscheidungsgraph heißt irreduzibel, falls keine der Regeln 1 und 2 auf diesen Graphen anwendbar sind.
Die erste nette Eigenschaft von binären Entscheidungsgraphen ist:
Satz 13696
Bei festgelegter Reihenfolge zwischen den xi gibt es zu jeder booleschen Funktion genau einen irreduziblen Entscheidungsgraphen.
Diesen Graphen können wir mit fug und recht als den optimalen Entscheidungsgraphen bzw. die Normalform der Funktion bezeichnen.
Alles in diesem Kapitel dreht sich einzig und allein um optimale Entscheidungsgraphen. Deren englischer Name ist übrigens (optimal) binary decision diagram, also (O)BDD. Manche Leute spielen damit, die fixe Argumentordnung aufzugeben. Damit machen sie die Sache aber viel komplizierter.
Die Größe eines optimalen Entscheidungsgraphen hängt sehr stark von
der gewählten Reihenfolge der Abfrage der Argumente ab. Betrachten wir
zum Beispiel die 2n-stellige Funktion
.Eine Ordnung der Argumente könnte z.B.
sein. Dann sieht der optimale Entscheidungsgraph wie in dieser Abbildung aus.
Dieser Graph hat 2n+2 Knoten, was durchaus linear ist.
Wählen wir statt dessen die Ordnung
,so sieht der Graph für n = 3 so aus:
Man sieht, daß bis zur Hälfte des Baumes gar keine Reduktion passiert. Das muß auch so sein, denn der arme Graph muß sich bis zu diesem Zeitpunkt genau merken, welche der ai erfüllt sind. Nur dadurch bekommt es heraus, welche bi es auf Erfülltheit testen muß. Der Graph bleibt exponentiell groß.
Es gibt natürlich auch Sahnebeispiele, z.B. die symmetrischen Funktionen.
Eine Funktion ist symmetrisch, falls für alle
und alle
i,j gilt:
.Das heißt, daß der Wahrheitswert nur von der Anzahl der Stellen abhängt,
die den Wert eins bekommen.
Wir können zu einer beliebigen Argumentordnung einen (noch nicht optimalen)
Graphen konstruieren, der pro Argument max. n Knoten enthält.
Zu einem Argument xi führen wir die Knoten xi(0) bis
xi(n) ein. Als Wurzel nehmen wir x1(0).
Von einem Knoten xi(j) ziehen wir den 0-Ausgang zu
xi+1(j) und den 1-Ausgang zu xi+1(j+1).
Damit steht jeder Knoten für eine bisherige Zwischensumme. Da das Gesamtergebnis
nur von der Anzahl der Einsen abhängt, kann man an den letzten Knoten
geeignet Bögen zu den termninalen Knoten
und
ziehen.
Wir schmeißen nachträglich alle nicht von der Wurzel erreichbaren Knoten weg.
Wenn es also einen Entscheidungsgraph dieser Form, also quadratischer
Größe gibt, so hat der optimale Graph höchstens diese Größe.
Im Extremfall kann der Graph natürlich konstante Größe haben. So sind die
optimalen Graphen für die immer wahre bzw. die immer falsche Funktion
einfach
und
.
Leider gibt es auch äußerst unsympathische Funktionen. Multipliziert man z.B. zwei n-stelligen Binärzahlen, so entsteht eine maximal 2n-stellige Binärzahl. Die Bestimmung eines jeden dieser 2n Bit kann man einer 2n-stelligen booleschen Funktion überlassen, die beide Argumentzahlen als Input und ihr Ergebnisbit als Output hat. Die beiden Funktionen für die beiden mittleren Bits des Ergbnisses sind die grausamsten. Die haben unabhängig von der Variablenordnung immer einen exponentiell großen optimalen binären Entscheidungsgraphen. Schade drum. Dabei ist Multiplikation soooo wichtig. Wir lernen also, daß die schlußendliche Größe eines optimalen Graphen stark von der repräsentierten Funktion und der gewählten Argumentordnung abhängt. Zu einer zufällig gewürfelten Funktion ist die Größe des Graphen kaum vorhersagbar. Macht aber nix. Wir sind Optimisten und sagen: Immerhin gibt es genug Fälle, wo der optimale Graph deutlich kleiner als der Entscheidungsbaum wird. Und das allein ist schon ziemlich gut.
Das eigentliche Geheimnis der Entscheidungsgraphen ist, daß man optimale Graphen nicht unbedingt aus einem Baum gewinnen muß. Vielmehr kann man neue optimale Graphen aus vorhandenen, schon optimalen Graphen konstruieren.
Wir studieren Konstruktionen, die zu zwei optimalen Graphen für f und
g optimale Graphen für h1 und h2 konstruieren, wobei
ist
(
ist irgendeine zweistellige boolesche Funktion) und
(
).
Die erste Konstruktion nennen wir APPLY (apply binary boolean function),
die zweite RESTRICT (restrict xi to value b).
Wir beginnen mit einem Spezialfall von RESTRICT, setzen dann mit APPLY fort und wenden uns zuletzt RESTRICT in voller Allgemeinheit zu.
Zuerst beobochten wir, daß Knoten für Argumente, von denen der Wert der
Funktion nicht wirklich abhängt, im Graph auf keinen Fall vorkommen.
Wenn wir also die Restriktion von f auf den Wert b für xi betrachten
(wir schreiben
), dann ist der optimale Graph für
diese Funktion derselbe, egal ob wir definieren
![]()
![]()
Merksatz 13742
Man kann Argumente, von denen der Funktionswert nicht abhängt, beliebig einfügen oder entfernen, ohne daß sich der optimale Entscheidungsgraph ändert.
Wir konstruieren nun als erste Aufgabe einen optimalen Graph für die
Funktion
für den Fall, daß xi in der
Argumentordnung kleiner oder gleich des Arguments im Wurzelknoten ist.
Das heißt also, daß der Funktionswert von f von keinem Argument kleiner
als dem i-ten (in unserer Ordnung) abhängt. Wir unterscheiden nun
zwei Fälle.
Erster Fall: xi ist nicht die Variable des Wurzelknotens
des optimalen Graphen für f.
Dann ist der optimale Graph für
genau derselbe wie
für f, denn die Restriktion eines Variablenwertes auf einen Wert, von dem
der Funktionswert gar nicht abhängt, ändert die Funktion und damit den
optimalen Graphen nicht.
Zweiter Fall: xi ist das Argument des Wurzelknotens.
Dann wird in der restriktierten Funktion die Frage nach dem Argument immer
mit dem Ausgang b beantwortet. Der optimale Graph für
ist also einfach derjenige Teilgraph, der beim Nachfolger des Wurzelknotens
über die mit b beschriftete Kante beginnt. Es gibt ja, wenn ein Argument
im Wurzelknoten behandelt wird, nur diesen einen Knoten für dieses Argument.
Man sieht leicht, daß der entstehende Graph optimal ist.
Beispiel 13761
Wir betrachten die Funktion
.Der Graph für f und die Graphen für
,
,
,
sind im nächsten Bild aufgemalt.
Beachte, daß die Korrektheit der Konstruktion wie auch die Optimalität des Ergebnisses von der Optimalität des Graphen für f abhängen!
Das ist auch bei der zweiten Operation so, schon deshalb, weil wir die
erste Konstruktion dort verwenden. Wir geben uns zwei n-stellige Funktionen
f und g sowie eine zweistellige Operation
vor. Unser Ziel ist
es, aus optimalen Graphen für f und g einen optimalen Graphen für
(mit
) zu konstruieren.
Grundlage der Konstruktion ist die sogenannte SHANNON-Expansion. Diese heißt so, obwohl schon BOOLE sie kannte.
Vielleicht, weil nach BOOLE schon so viele Sachen heißen...
Die Shannon-Expansion ist eine logische Äquivalenz, die da für beliebige Funktionen f und alle i zwischen 1 und n lautet:
![]()
Die Bedeutung dieser Formel liegt in deren Nähe zum Prinzip des Entscheidungsbaumes. Sie besagt: Schaue nach, ob xi gilt oder nicht. Wenn ja, gehe zu einem
Teilbaum, der
repräsentiert, wenn nicht, gehe zu
einem Teilbaum, der
repräsentiert. Verfolgt man die
Expansion, beim ersten Argument beginnend, rekursiv weiter und ersetzt
konstante Funktionen durch
bzw.
, so entsteht haargenau
der Entscheidungsbaum. Diese nette Eigenschaft der Expansion wollen wir uns
zunutze machen. Denn es gilt weiter:
Diese Formel liefert die Grundlage für unseren Algorithmus APPLY.
Wir bilden
und
mit Hilfe der
einfachen Version von RESTRICT (wir nehmen einfach immer das am weitesten vorn
stehende Argument) und rufen rekursiv
unseren Algorithmus APPLY. Bei jedem Ruf von APPLY sinkt die Zahl der
Argumente, von denen der Funktionswert potentiell abhängen kann. Zwei konstante
Funktionen werden verknüpft, indem
angewendet wird und dadurch eine
neue konstante Funktion entsteht. Die beiden rekursiven Rufe von APPLY
liefern Graphen für
und
. Wir setzen vor
diese beiden Graphen einfach eine neue Wurzel für xi mit den beiden
Resultaten als Nachfolger und fertig ist der neue Graph. Das ist soweit die
Grundidee. Sie liefert einen korrekten Graphen. Nur leider keinen optimalen und
den auch noch zu langsam.
Beispiel 13791
Betrachte die Funktionen f und g, die durch folgende optimale
Graphen gegeben sind. Ich habe jedem Knoten einen Namen gegeben. Im
wirklichen Leben kann man diese Namen durch Pointer realisieren.
Wir wollen
berechnen.
Als Argumente für den rekursiven Ruf von APPLY geben wir ja immer das Ergebnis eines einfachen RESTRICT mit, also entweder den Eingabegraph oder einen Nachfolgergraph. Wir können der Einfachheit halber die Argumentgraphen völlig unverändert lassen und nur den Pointer auf die jeweils aktuelle Wurzel mitgeben. Dann kann man die rekursive Aufrufstruktur (wo der Ruf für die Restriktion auf 0 wieder gestrichelt und die auf 1 wieder voll gezeichnet wird) durch folgenden Rekursionsbaum (mit Angabe der Argumentpointer) darstellen.
Diese Struktur berücksichtigt schon eine erste Optimierung. Die ODER-Funktion
hat nämlich die 1 als dominanten Wert (also braucht man das andere Argument
nicht). Kommt man mit ODER also in einen APPLY-Ruf, wo ein Argument die
konstante 1 ist (A5 bzw B4), so kann man ungesehen den terminalen Knoten
als Resultat zurückgeben.
Trotz dieser Optimierung wird bei der naiven Implementation mit Zeit und Platz nur so umhergeschmissen. Die Aufrufe mit den Argumenten A3,B2 (und dem Schwanz dahinter) sowie A5,B2 werden doppelt bearbeitet, obwohl doch bei zwei Aufrufen mit ein und denselben Argumenten normalerweise dasselbe Resultat kommen sollte. Erschwerend kommt hinzu, daß im Resultatgraph zweimal derselbe Teilgraph (als Resultat der jeweiligen Aufrufe) erscheint. Das muß verhindert werden. Zu diesem Zweck führen wir eine Tabelle ein, die eine Spalte für jedes Pointerpaar der Argumente hat und eine Spalte für das jeweilige Ergebnis. Am Ende eines Unterrufes von APPLY wird das Argumentpaar mit dem Pointer auf das Ergebnis in die Tabelle eingetragen. Bei jedem APPLY-Ruf wird, bevor so richtig losgerechnet wird, erst in der Tabelle nachgeschaut, ob APPLY für dieses Argumentpaar schon mal gerufen war, und gegebenenfalls das Ergebnis aus der Tabelle entnommen, anstatt es noch mal auszurechnen. Dadurch werden bereits eine Reihe gleicher Teilgraphen verschmolzen. Die Tabelle kann man als Hash-Tabelle mit dem Argumentpaar als Index organisieren, so daß bei hervorragender Streuung konstante Zugriffszeiten resultieren.
Wir schauen uns die neue Aufrufstruktur an, wobei wir den Rückgriff auf die Tabelle durch Umlenken der Aufrufkante dokumentieren. Den entstehenden Graph und die vollständig (in der richtigen Reihenfolge) gefüllte Tabelle malen wir dahinter.
9 Rufe anstatt 13. Wenn das nix ist!? Ach so, der Resultatgraph und die Tabelle. Die Pointer für den Resultatgraphen sind in der Reihenfolge ihrer Entstehung numeriert.
| Argumente | Ergebnis |
| A4,B3 | C1 |
| A5,B4 | C2 |
| A3,B2 | C3 |
| A5,B2 | C4 |
| A6,B2 | C5 |
| A2,B2 | C6 |
| A3,B4 | C7 |
| A6,B5 | C8 |
| A1,B1 | C9 |
Leider immer noch nicht optimal. Nicht nur, daß wir viel zu viele Kopien der
terminalen Knoten haben, nach deren Verschmelzung entstehen auch noch
zusätzliche Unoptimalitäten (gibt es dieses Wort?). In unserem Beispiel
wird der rechte Test auf x3 redundant. Eine zweite Hash-Tabelle muß her,
um dieses Problem zu beseitigen. Dort tragen zu jedem erzeugten Knoten
des neuen Baumes eine Zeile ein. Wir tragen als Index die Beschriftung des
Knotens ein (
) und für nichtterminale
Knoten die beiden Nachfolgeknotenpointer. Als andere Spalte tragen wir den Pointer auf den neu erzeugten Knoten ein.
Diese Tabelle würde, wenn wir sie
in unserem Beispiel einfach erzeugen, aber nicht auswerten, so aussehen.
| Knotenstruktur | Pointer |
| C1 | |
| C2 | |
| x4, C1,C2 | C3 |
| C4 | |
| x3,C3,C4 | C5 |
| x2,C3,C5 | C6 |
| C7 | |
| x3,C4,C7 | C8 |
| x1,C6,C8 | C9 |
Man sieht, daß die zu verschmelzende Knoten gleichen Index haben. Bevor also ein Aufruf von APPLY einen neuen Knoten erzeugt, schaut es in der zweiten Tabelle nach, ob es einen Knoten mit demselben Test und denselben Nachfolgegraphen bereitsgibt. Wenn ja, erzeugt es keinen neuen Knoten, sondern übergibt den in der Tabelle gespeicherten. Damit wird verhindert, daß der neue Graph gleiche Teilgraphen mehrfach enthält. Außerdem schaut APPLY nach, ob beide Teilgraphen vielleicht denselben Index in der zweiten Tabelle haben. In diesem Fall wird kein neuer Knoten erzeugt, sondern gibt einfach den (für beide Seiten gleichen) Pointer zurück. So verhindert man, daß im neuen Graphen redundante Tests entstehen. Mit der zweiten Tabelle erreicht man tatsächlich, daß das Resultat ein optimaler Entscheidungsgraph wird.
Um mein Skript schön dick zu machen, gebe ich noch einmal den kompletten Trace der optimalen Berechnung und die beiden dann entstehenden Tabellen an. Den Ergebnisgraph auch.
Der entstehende Graph steht in Abbildung 6.11.
Die Tabellen haben folgende Gestalt:
| 3||c||Tabelle 1 | 3|c||Tabelle 2 | |||||
| Index | Pointer | Eintrag in | Index | Pointer | Eintrag in | |
| A4,B3 | C1 | 2.2.2.5. | C1 | 2.2.2.4. | ||
| A5,B4 | C2 | 2.2.3.5. | C2 | 2.2.3.4. | ||
| A3,B2 | C3 | 2.2.7. | x4,C1,C2 | C3 | 2.2.6. | |
| A5,B2 | C2 | 2.3.3.4. | x3,C3,C2 | C4 | 2.3.7. | |
| A6,B2 | C4 | 2.3.6. | x2,C3,C4 | C5 | 2.6. | |
| A2,B2 | C5 | 2.7. | x1,C5,C2 | C6 | 7. | |
| A3,B4 | C2 | 3.3.4. | ||||
| A6,B5 | C2 | 3.5. | ||||
| A1,B1 | C6 | 8. |
Innerhalb eines Rekursionslevels hält sich APPLY nur konstante Zeit auf,
so man denn die Suche in den Tabellen mit konstanter Zeit ansetzt (das
setzt gut streuende und wohldimensionierte Hash-Techniken voraus.
Das einfache RESTRICT kostet im Prinzip gar nix. Dank Tabelle 1 wird
jedes Argumentpaar maximal einmal wirklich bearbeitet. Demnach belaufen
sich die Gesamtkosten (sowohl Zeit als auch Speicher)
eines APPLY auf
, wobei |f|
die Größe des optimalen Graphen von f sein soll. Das ist ziemlich gut.
Bei anderen Repräsentationsformen boolescher Funktionen kann das
Ergebnis deutlich stärker ausarten.
Wenden wir uns nun dem allgemeinen RESTRICT zu. Wir wollen also
einen optimalen Graph für
berechnen. Dabei soll
xi aber irgendeine Variable sein und nicht eine an so angenehmer
Position stehende wie vorhin.
Die grundlegende Technik ist natürlich, die zu xi gehörenden
Knoten aufzusuchen und deren eingehende Kanten auf den Nachfolger des
xi-Knotens in Richtung b umzuleiten. Dadurch können allerdings
Unoptimalitäten entstehen. Erfahren, wie wir sind, können wir diese
Unoptimalitäten ganz souverän mit Hilfe einer Tabelle wie die
APPLY-Tabelle 2 ausmerzen. Für die Suche nach den xi-Knoten nehmen wir
aus alter Gewohnheit den Tiefensuchalgorithmus.
Ein voll durchexerziertes Beispiel soll genügen, uns von der Implementierbarkeit von RESTRICT zu überzeugen.
Für die durch Abbildung 6.12 gegebene Funktion f
wollen wir
berechnen.
Die Tabelle hat also in unserem Beispiel nur einen Eintrag, das Ergebnis
besteht aus einem einzigen Test auf x3 mit Nullnachfolger
und
Einsnachfolger
. Der Zeitaufwand ist O(|f|). Der Speicherplatz ist
höchstens so groß wie vorher.
Eine besonders einfache Operation ist die Negation. Wir müssen einfach die beiden terminalen Knoten austauschen.
Der humane Verbrauch an Laufzeit für APPLY und RESTRICT ist äußerst angenehm, zumal sich weitere Operationen auf diese beiden zurückführen lassen:
Diese Operationen mögen uns genügen, so daß wir nun von unserer Reise zum Schaltkreisentwurf zurückkehren können.