Wie löst man mit SQL einen Zauberwürfel?
Wie die Idee der Graph-DB hier für Geschwindigkeit sorgt
Auf einer IT-Konferenz, auf der ich als Sprecher mein Wissen im Bereich T-SQL-Programmierung weitergegeben habe, sprach mich eine Teilnehmerin an: "Diese Graph-DB ist ja faszinierend, aber gibt es dazu auch echte Anwendungsfälle jenseits von Navigationsgeräten und sozialen Netzen wie Facebook oder XING?". Kurz darauf forderte mich ein zweiter Teilnehmer unabhängig von der ersten Frage auf, für das nächste Jahr zu beweisen, dass ich einen beliebig verdrehten Zauberwürfel mit Hilfe von SQL lösen könnte. Er erinnerte mich an meine Behauptung, dass jedes sprachlich sauber formulierbare Abfrageproblem mit der passenden Datenstruktur und ein paar kreativen Ansätzen mit SQL lösbar sei. Die Idee, den "Rubik's Cube", wie der Zauberwürfel im Original heißt, mit den Möglichkeiten einer Graph-DB zu analysieren und einen allgemeinen Lösungsansatz zu implementieren, war geboren…
Die Vereinfachung der Komplexität
Ein handelsüblicher Zauberwürfel ist ein dreidimensionales Puzzle mit 26 farbig markierten Mini-Würfeln, welches um eine zentrale Achse in drei Richtungen in jeder Ebene drehbar ist. Im Auslieferungszustand zeigen die sechs Außenseiten des großen Würfels jeweils eine unterschiedliche Farbe. Sobald man jedoch neugierig anfängt, die einzelnen Ebenen gegeneinander zu verdrehen, nimmt das Chaos seinen Lauf. Die große Herausforderung besteht darin, den Ursprungszustand wieder herzustellen. Dieses Vorhaben ist auch mathematisch recht anspruchsvoll, schließlich gibt es über 43 Trillionen theoretische Zustände [1]. Aus jeder dieser Verdrehungen kann man im Idealfall den Ursprungszustand in spätestens 26 einfachen 90°-Drehungen wieder erreichen.
Um einen Algorithmus in SQL zu entwickeln, ist diese Komplexität zu reduzieren. Tatsächlich sind nur zwei maßgebliche Schritte notwendig, um von dieser enormen Zahl herunterzukommen. Die allgemeine Gültigkeit des Lösungsansatzes wird dabei nicht beeinträchtigt:
- Statt eines Standardwürfels mit der Seitenlänge von drei Kleinwürfeln arbeiten wir hier mit einem sogenannten "Poket Cube". Dieser misst 2x2x2 Würfel. Damit reduziert sich die Zahl der theoretisch erdrehbaren Zustände auf knapp über 3,6 Millionen und man kann das Vorgehen im Kopf nachvollziehen.
- Im Sinne eines vereinfachten Datenmodells überführen wir den dreidimensionalen Poket Cube in ein zweidimensionales Netz und dieses dann in einen eindimensionalen String, indem wir das Netz einfach Würfel für Würfel von oben nach unten und von links nach rechts auslesen:
Hierzu wird ein Netzplan des Würfels erstellt, in dem alle Unterquadrate durchnummeriert ("Ordnungsnummer") sind. Außerdem werden die Farben mit einem Buchstaben codiert, der dem Anfangsbuchstaben der englischen Farbbezeichnung entspricht. Kombiniert man diese beiden Ideen miteinander, kann man einen Poket Cube auch als String von in der Reihenfolge der Ordnungsnummer aneinandergereihten Farbcodierungen darstellen.
Mit SQL für Bewegung sorgen
Eine weitere Vereinfachungsidee stellt die Tatsache dar, dass man jeden Zauberwürfel auf 24 unterschiedliche Arten auf einen Tisch legen kann. Trotzdem ist es natürlich stets derselbe Würfel. Die zu speichernde Datenmenge lässt sich also auf 1/24 reduzieren, wenn man sich für eine Darstellungsform entscheidet. In meiner Lösung wird bspw. immer der bei alphanummerischer Sortierung "kleinste" Wert genommen. Dazu muss man sich folgenden Zusammenhang verdeutlichen:
Eigentlich reicht es, Funktionen für die Drehung des Würfels um 90° nach oben bzw. um 90° nach rechts zu implementieren – notfalls kombiniert oder man ruft diese mehrfach hintereinander auf, um jede der 24 möglichen Positionen zu erreichen. Außerdem muss es natürlich möglich sein, einzelne Ebenen um 90° zu verdrehen. Hierbei bewegen sich dann nur die Miniwürfel der betroffenen Ebene, alle anderen bleiben an ihrem Platz:
Die Idee zur programmatischen Umsetzung ist für beide Fälle identisch. Es wird einer Funktion ein Ausgangswürfel in Form eines serialisierten Strings übergeben. Dieser wird mit Textmanipulationsfunktionen wie LEFT(), RIGHT() oder SUBSTRING() zerlegt und in neuer Reihenfolge als Ausgangswert wieder zusammengesetzt.
-- -----------------------------------------------------------------------------------
-- Diese Funktion nimmt eine Würfel-ID entgegen, dreht dann genau eine definierte --
-- Ebene um 90° in die spezifizierte Richtung, generiert eine passende ID und gibt --
-- die neue Würfel-ID wieder aus. --
-- -----------------------------------------------------------------------------------
-- Funktion für die Drehung der oberen waagerechten Ebene gegen den Uhrzeigersinn --
-- Beispiel: 'WWWWGGRRBBOOGGRRBBOOYYYY' - Würfel vor dem Dreh --
-- 'WWWWOOGGRRBBGGRRBBOOYYYY' - Würfel nach dem Dreh --
-- -----------------------------------------------------------------------------------
CREATE OR ALTER FUNCTION [RubiksCube].[fncDreheObenLinks]
(
@Eingangsposition AS VARCHAR(24)
)
RETURNS CHAR(24)
AS
BEGIN
DECLARE @Rueckgabewert AS VARCHAR(24) = ''
SET @Rueckgabewert = SUBSTRING (@Eingangsposition , 2, 1)
SET @Rueckgabewert = @Rueckgabewert + SUBSTRING (@Eingangsposition , 4, 1)
SET @Rueckgabewert = @Rueckgabewert + SUBSTRING (@Eingangsposition , 1, 1)
SET @Rueckgabewert = @Rueckgabewert + SUBSTRING (@Eingangsposition , 3, 1)
SET @Rueckgabewert = @Rueckgabewert + SUBSTRING (@Eingangsposition , 11, 2)
SET @Rueckgabewert = @Rueckgabewert + SUBSTRING (@Eingangsposition , 5, 6)
SET @Rueckgabewert = @Rueckgabewert + SUBSTRING (@Eingangsposition , 13, 12)
RETURN @Rueckgabewert
END
GO
Insgesamt werden 12 solcher Funktionen für die Drehung der Einzelebenen (oben, unten, links, rechts, vorne und hinten jeweils mit und gegen den Uhrzeigersinn) sowie die oben schon erwähnten zwei Funktionen für das Kippen des Gesamtwürfels um 90° nach oben bzw. nach rechts benötigt.
Die passende Datenstruktur
Als nächstes werden Datenbankobjekte angelegt, die alle theoretisch erreichbaren Verdrehungen speichern. Die Tabellen besitzen einen sehr simplen Aufbau. Dargestellt ist hier exemplarisch das CREATE-Statement für die Tabelle der Speicherung der Zugkombinationen:
-----------------------------------------------------------------------------------
-- Anlage der Tabelle für die Zugkombinationen
-----------------------------------------------------------------------------------
CREATE TABLE [RubiksCube].[ZugKombinationen](
[ID] [BIGINT] IDENTITY (1,1) NOT NULL
, [Ausgangsposition] [CHAR](24) NOT NULL
, [Bewegung] [VARCHAR](25) NOT NULL
, [Zielposition] [CHAR](24) NOT NULL
, [istabgearbeitet] [BIT] NOT NULL,
CONSTRAINT [PK_ZugKombinationen] PRIMARY KEY CLUSTERED
(
[Ausgangsposition] ASC
, [Zielposition] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
Wenn man nun mit einem beliebigen Zauberwürfel startet und auf diese Position einfach alle 12 Umformungen für einzelne Ebenen anwendet, erhält man ein Dutzend Ergebniswürfel. Handelt es sich dabei um eine bisher in der Datenbank nicht erfasste Stellung, nimmt man diese mit dem Wert "FALSE" für die Spalte [istabgearbeitet] auf – ansonsten verwirft man sie. Nun bildet man eine Wiederholungsschleife, die so oft durchlaufen wird, bis man die maximal mögliche Anzahl unterschiedlicher Positionen abgespeichert hat. Dazu holt man sich einen bestehenden Satz mit [istabgearbeitet] = "FALSE", aktualisiert diesen Wert auf "TRUE" und ruft die 12 Umformungen erneut auf. Dieses Mal allerdings nicht mit dem Ausgangswürfel sondern mit dem aktuellen Würfel als Startparameter.
Nach dieser Aktion stehen in der Datenbank genau 3.674.160 unterschiedliche Datensätze, die jeweils eine Würfelposition darstellen.
Umbau vom rationalen Ansatz auf die Graph-DB
Um die unbestreitbaren Vorteile der Graph-Datenbank nutzen zu können, sind nun ein paar Umbaumaßnahmen an diesen Daten notwendig. Wir überführen unser Modell dazu in Graph-Objekte, also eine Knoten- und eine Kantentabelle. Das Beispiel des Zauberwürfels eignet sich sehr gut, um das Vorgehen und die Effektivität des Graph-Ansatzes zu demonstrieren.
Eine Position, also der verdrehte Zustand eines Rubik Cubes, entspricht einem Knoten. Die Bewegung zwischen zwei Knoten bildet dann die Kantentabelle ab. Sie repräsentiert also den Zug, der einen Ausgangswürfel in einen Endwürfel überführt, indem beispielsweise die obere Ebene 90° gegen den Uhrzeigersinn gedreht wird. All diese Informationen finden wir in der vorhin erschaffenen Tabelle [RubiksCube].[Zugkombinationen]. Um diese in Knoten- und Kantentabelle zu überführen, reichen ein paar Zellen Quellcode:
-- Diese Tabelle stellt die Knoten des Graphen dar
CREATE TABLE [RubiksCube].[Position] (
[ID] BIGINT PRIMARY KEY
, [PositionID] VARCHAR(24)
) AS NODE;
GO
-- Diese Tabelle stellt die Kanten, also die Verbindungen zwischen den Knoten, dar
CREATE TABLE [RubiksCube].[dreht_nach] (
[ID] BIGINT PRIMARY KEY
, [Drehung] VARCHAR(25)
, [Kosten] INTEGER
, [Startposition] CHAR(24)
, [Endposition] CHAR(24)
) AS EDGE;
GO
Diese Codezeilen zur Tabellenerstellung unterscheiden sich vom bekannten relationalen Standard nur durch jeweils zwei Wörter: AS NOTE bzw. AS EDGE. Aufgrund dieser Schlüsselwörter weiß der SQL Server, dass es sich um Graph-Tabellen handelt. Diese müssen nun mit Inhalt gefüllt werden:
-- Inhalte für die Knotentabelle [RubiksCube].[Position] erzeugen
INSERT INTO [RubiksCube].[Position]
SELECT * FROM (
SELECT
ROW_NUMBER() OVER(ORDER BY [Ausgangsposition] ASC) - 1 AS [ID] -- (Informatiker zählen ab "0", daher "-1", da ROW_NUMBER bei "1" beginnt
, [Ausgangsposition] AS [PositionID]
FROM
(
SELECT DISTINCT
[Ausgangsposition] AS [Ausgangsposition]
FROM [RubiksCube].[ZugKombinationen]
) AS [DummyInnen]
) AS [DummyAussen]
GO
-- Inhalte für die Kantentabelle [RubiksCube].[dreht_nach] erzeugen
INSERT INTO [RubiksCube].[dreht_nach]
SELECT
(SELECT $node_id FROM [RubiksCube].[Position] WHERE ID = [PO1].[ID]) -- KEIN Alias !!!
, (SELECT $node_id FROM [RubiksCube].[Position] WHERE ID = [PO2].[ID]) -- KEIN Alias !!!
, ROW_NUMBER() OVER(ORDER BY [Ausgangsposition] ASC) - 1 AS [ID]
, [Bewegung] AS [Drehung] -- Identifiziert die Drehung, z.B. "RechtsHoch"
, 1 AS [Kosten] -- Anzahl minimaler Züge bis zur Lösung
, [Ausgangsposition] AS [Startposition] -- Position VOR dem Drehen
, [Zielposition] AS [Endposition] -- Position NACH dem Drehen
FROM [RubiksCube].[ZugKombinationen] AS [ZK]
INNER JOIN [RubiksCube].[Position] AS [PO1]
ON [ZK].[Ausgangsposition] = [PO1].[PositionID]
INNER JOIN [RubiksCube].[Position] AS [PO2]
ON AND [ZK].[Zielposition] = [PO2].[PositionID]
GO
Während die Füllung der Kantentabelle [RubikCube].[Position] genau nach dem aus der relationalen Welt bekannten Muster abläuft, fallen bei der Füllung der Kantentabelle die beiden Subselects auf. Sie ermitteln zur Laufzeit die IDs aus der Knotentabelle, welche beim Insert angegeben werden müssen. Daher ist die Laufzeit dieses Skripts einmalig auch recht hoch – aber der Aufwand lohnt sich 1000fach bei späteren Abfragen.
Wir lösen blitzschnell jeden noch so verdrehten Zauberwürfel
Die eigentliche Lösung ist nun schnell runterprogrammiert und noch schneller ausgeführt. Dank der Abfragesprache "Cypher" kann man sie fast genau niederschreiben, wie man sie gedanklich formuliert:
CREATE OR ALTER PROCEDURE [RubiksCube].[prcGraphLoesung]
@Start CHAR(24)
, @Ziel CHAR(24)
AS
BEGIN
SET NOCOUNT ON;
SELECT
[innen].[Startposition] AS [Startposition]
, [innen].[Drehungen] AS [Drehung]
, [innen].[Zielposition] AS [Zielposition]
FROM
(SELECT
[Start].[PositionID] AS [Startposition]
, STRING_AGG([dn].[Drehung], '->')
WITHIN GROUP (GRAPH PATH) AS [Drehungen]
, LAST_VALUE([Ziel].[PositionID])
WITHIN GROUP (GRAPH PATH) AS [Zielposition]
FROM
[RubiksCube].[Position] AS [Start]
, [RubiksCube].[dreht_nach] FOR PATH AS [dn]
, [RubiksCube].[Position] FOR PATH AS [Ziel]
WHERE MATCH(SHORTEST_PATH([Start](-([dn])->[Ziel])+))
AND [Start].[PositionID] = @Start
) AS [innen]
WHERE [innen].[Zielposition] = @Ziel
END
GO
Übergeben werden der Lösungsprozedur nur die Strings, die den verdrehten und den gewünschten Würfel symbolisieren. Innerhalb der Prozedur sind folgende Punkte bemerkenswert:
- Die STRING_AGG-Funktion dient der Verkettung der einzelnen Lösungsschritte. Diese werden mit Pfeilen separiert hintereinander gehängt.
- Im FROM-Teil gibt es zwei unterschiedliche Aliase auf dieselbe Knotentabelle.
- Es gibt keine JOINs mehr (die Logik einer Graph-DB extrahiert die Verbindungen aus der Kantentabelle, hier haben wir die IDs ja extra hinterlegt).
- In der WHERE-Klausel gibt es ein neues Schlüsselwort: MATCH.
- Wer genau hinsieht, erkennt einen angedeuteten Pfeil, der die Beziehungsrichtung vorgibt. Er läuft in der MATCH-Klausel von [Start] über [dn] nach [Ziel].
- Der Befehl SHORTEST_PATH sucht nun die schnellste Zugkombination vom Ausgangs- zum Zielwürfel. Dabei ist zu beachten, dass der Algorithmus gewollt mit der Erfolgsmeldung abbricht, sobald er sicher ist, dass es keine kürzere Möglichkeit mehr gibt. Sollte es also zwei identisch lange Lösungen geben, wird nur die erste gefunden.
Die Vor- und Nachteile dieser Lösung
Dank der adjazenten Datenspeicherung (fachlich benachbarte Informationen werden auch nah beieinander gespeichert, so dass nicht immer der gesamte Datenbestand durchsucht werden muss) und der automatischen Indizierung von Graph-Tabellen dauert die Suche nach der optimalen Lösung mit diesem Ansatz nur noch Bruchteile von Sekunden. Dies bezahlt man mit der einmaligen Initialisierung, also dem Aufbau und dem Füllen der Graph-Tabellen. Es gilt also abzuwägen, wie oft man ultraschnelle Abfragen benötigt, um einschätzen zu können, ob der Aufwand des Aufbaues einer Graph-DB Sinn macht. Denkt man aber bspw. an eine Online-Preisauskunft für Flugverbindungen, liegt das Ergebnis einer derartigen Überlegung sicher schnell auf der Hand.
Natürlich kann man den Algorithmus noch weiter "pimpen". So ist es beispielsweise ein Leichtes, auch eine 180°-Drehung einer Ebene als einen einzigen Zug einzubauen. Dieser würde dann die benötigte Maximalzahl notwendiger Züge bis zur Lösung auf 20 drücken. Auch ist die unterschiedliche Gewichtung von Zügen denkbar. Implementiert man bspw., dass Drehungen gegen den Uhrzeigersinn teurer sind als Drehungen mit dem Uhrzeigersinn, könnte der Algorithmus zur Lösungsfindung nicht einfach stumpf die Zahl der notwendigen Schritte zählen, sondern die Kosten optimieren. Derartige Nebenbedingungen kennt man bspw. von Navigationssystemen, die intern identisch funktionieren. Hier gibt es unterschiedlich gewichtete Straßen (Autobahnen werden Dorfstraßen vorgezogen) oder bspw. mautpflichtige Streckenabschnitte, die mit kostenfreien Wegen konkurrieren.
Der komplette Quellcode für das Projekt "Rubiks Cube" ist abrufbar [2]. Er enthält zur Gegenüberstellung auch meinen Code einer klassischen, rein relationalen Lösung mit Hilfe einer Rekursion. Auch sie führt zu einem korrekten Ergebnis, ist der Graph-Variante aber sowohl bzgl. der Komplexität der Implementierung als auch vor allem im Laufzeitverhalten gnadenlos unterlegen.
- Wikipedia: Der Würfel als mathematische Gruppe
- Der komplette Quellcode für das Projekt "Rubiks Cube" (ZIP-Datei)