Pathfinding in PL/SQL

Schon vor längerer Zeit gab es im Datenbank Forum von Tutorials.de eine Diskussion zu rekursivem SQL. Genauer ging es darum, wie Social Network Seiten wie Xing oder StudiVZ die Bekanntschaftsgraphen “Wer kennt wen über wen?” abzubilden. Eins meiner Postings ist dabei sicher ein bisschen über das Ziel hinaus geschossen, aber der entstandene Algorithmus ist es allemal wert hier abzulegen.

Rein aus Interesse habe ich mich mal an den wohl bekanntesten Pathfinding Algorithmus (A*) gemacht und in PL/SQL umgesetzt. Als Grundlage dient mir ein Koordinatensystem mit fixen Orten, dargestellt durch folgende Tabelle und INSERTS.

0 1 2 3 4 5 6 7 8 9101112
0A       B
1                  H
2            C
3    D
4          F           I
5E
6              G
7      K
8                  J     N
9  M
10          L
11              O
12

CREATE TABLE places
(
placeid     NUMBER(10,0)    CONSTRAINT NN_places_id     NOT NULL,
name        VARCHAR2(30)    CONSTRAINT NN_places_name   NOT NULL,
posx        NUMBER(10,0)    CONSTRAINT NN_places_posx   NOT NULL,
posy        NUMBER(10,0)    CONSTRAINT NN_places_posy   NOT NULL
);

ALTER TABLE places
ADD CONSTRAINT PK_places
PRIMARY KEY ( placeid )
USING INDEX;

INSERT INTO places VALUES(  1, 'A',  0,  0 );
INSERT INTO places VALUES(  2, 'B',  4,  0 );
INSERT INTO places VALUES(  3, 'C',  6,  2 );
INSERT INTO places VALUES(  4, 'D',  2,  3 );
INSERT INTO places VALUES(  5, 'E',  0,  5 );
INSERT INTO places VALUES(  6, 'F',  5,  4 );
INSERT INTO places VALUES(  7, 'G',  7,  6 );
INSERT INTO places VALUES(  8, 'H',  9,  1 );
INSERT INTO places VALUES(  9, 'I', 11,  4 );
INSERT INTO places VALUES( 10, 'J',  9,  8 );
INSERT INTO places VALUES( 11, 'K',  3,  7 );
INSERT INTO places VALUES( 12, 'L',  5, 10 );
INSERT INTO places VALUES( 13, 'M',  1,  9 );
INSERT INTO places VALUES( 14, 'N', 12,  8 );
INSERT INTO places VALUES( 15, 'O',  7, 11 );

Die einzelnen Orte sind untereinander mit Strassen verbunden. Als zusätzliche Information werden die Wegekosten abgelegt. In meinem Fall wurden sie einfach über die Länge der Strecke berechnet. Ich gehe also davon aus, dass ich auf allen Strecken gleich schnell voran komme, keine Steigungen o.ä. vorhanden sind etc..

CREATE TABLE roads
(
sourceid    NUMBER(10,0)    CONSTRAINT NN_roads_source  NOT NULL,
targetid    NUMBER(10,0)    CONSTRAINT NN_roads_target  NOT NULL,
cost        NUMBER(5,0)     CONSTRAINT NN_roads_cost    NOT NULL
);

ALTER TABLE roads
ADD CONSTRAINT PK_roads
PRIMARY KEY ( sourceid, targetid )
USING INDEX;

CREATE UNIQUE INDEX idx_roads_inv
ON roads ( targetid, sourceid );

INSERT INTO roads VALUES (  1,  4,  10 );
INSERT INTO roads VALUES (  9, 14,  15 );
INSERT INTO roads VALUES ( 10, 14,  10 );
INSERT INTO roads VALUES ( 14, 15,  25 );
INSERT INTO roads VALUES ( 12, 15,   5 );
INSERT INTO roads VALUES ( 13, 12,  15 );
INSERT INTO roads VALUES ( 13,  5,  15 );
INSERT INTO roads VALUES (  5,  4,   7 );
INSERT INTO roads VALUES (  8,  9,  12 );
INSERT INTO roads VALUES (  8,  3,  10 );
INSERT INTO roads VALUES (  3,  4,  15 );
INSERT INTO roads VALUES (  3,  2,   7 );
INSERT INTO roads VALUES (  1,  2,  15 );
INSERT INTO roads VALUES (  7, 10,   7 );
INSERT INTO roads VALUES (  6,  7,   7 );
INSERT INTO roads VALUES (  7, 11,  15 );
INSERT INTO roads VALUES ( 11,  5,  13 );
INSERT INTO roads VALUES (  8,  7,  23 );

Daraus ergibt sich dann folgende Karte:

Der Algorithmus selbst benötigt 2 temporäre Tabellen zur Berechnung:

CREATE GLOBAL TEMPORARY TABLE freelist (
placeid     NUMBER(10,0),
parentid    NUMBER(10,0),
abscost     NUMBER(10,0),
calccost    NUMBER(10,0),
CONSTRAINT PK_FREELIST
PRIMARY KEY ( placeid )
USING INDEX
);

CREATE GLOBAL TEMPORARY TABLE locklist (
placeid     NUMBER(10,0),
parentid    NUMBER(10,0),
abscost     NUMBER(10,0),
calccost    NUMBER(10,0),
CONSTRAINT PK_LOCKLIST
PRIMARY KEY ( placeid )
USING INDEX
);

Die Funktionsweise des A* Algorithmus möchte ich hier nicht erläutern, das würde den Rahmen sprengen, darum hier einfach der Sourcecode meines Packages:


CREATE OR REPLACE FUNCTION CALCCOSTS( sourceid        IN places.placeid%TYPE,
targetid        IN places.placeid%TYPE )
RETURN             NUMBER
IS
s       places%ROWTYPE;
t       places%ROWTYPE;
calc    NUMBER;
BEGIN
SELECT  *
INTO    s
FROM    places
WHERE   placeid = sourceid;

SELECT  *
INTO    t
FROM    places
WHERE   placeid = targetid;

calc := 5 * ROUND( SQRT( (t.POSX - s.POSX)*(t.POSX - s.POSX) + (t.POSY - s.POSY)*(t.POSY - s.POSY) ) );
DBMS_OUTPUT.put_line( '=> Calculation distance between ' || TO_CHAR( sourceid ) || ' and ' || to_char( targetid ) || ' => ' || to_char( calc ) );
RETURN  calc;
END;
/

SHO ERRORS

CREATE OR REPLACE PACKAGE A_STAR
IS

PROCEDURE SEARCH(   sourceid    IN places.placeid%TYPE,
targetid    IN places.placeid%TYPE );

END;
/

SHOW ERRORS

CREATE OR REPLACE PACKAGE BODY A_STAR
IS

targetnode      places.placeid%TYPE;

------------------------------------------------------------------------------------------------
PROCEDURE INIT_SEARCH
IS
BEGIN
DELETE FROM LOCKLIST;
DELETE FROM FREELIST;
targetnode := NULL;
END;

------------------------------------------------------------------------------------------------
PROCEDURE ADD_TO_LOCKLIST( n_placeid  IN places.placeid%TYPE,
n_parentid IN places.placeid%TYPE,
n_abscost  IN NUMBER,
n_calccost IN NUMBER,
n_rowt     OUT locklist%ROWTYPE )
IS
BEGIN
DBMS_OUTPUT.put_line( '=> Adding node to locklist: ' || TO_CHAR( n_placeid ));
INSERT INTO LOCKLIST VALUES( n_placeid, n_parentid, n_abscost, n_calccost )
RETURNING placeid, parentid, abscost, calccost INTO n_rowt;
DELETE FROM FREELIST WHERE placeid = n_placeid;
END;

------------------------------------------------------------------------------------------------
PROCEDURE STEP_FORWARD( rowt OUT locklist%ROWTYPE )
IS
BEGIN
SELECT  *
INTO    rowt
FROM    FREELIST
WHERE   ABSCOST + CALCCOST = ( SELECT MIN( ABSCOST + CALCCOST ) FROM FREELIST )
AND     ROWNUM  = 1;
END;

------------------------------------------------------------------------------------------------
PROCEDURE MERGE_FREELIST( source    IN locklist%ROWTYPE )
IS
BEGIN
MERGE INTO FREELIST f
USING   (
-- finde alle orte, die mit der source verbunden sind und noch nicht in der
-- lockliste drin stehen
SELECT  sourceid AS PLACEID,
cost
FROM    roads
WHERE   targetid = source.placeid
AND     sourceid NOT IN ( SELECT PLACEID FROM locklist )
UNION
SELECT  targetid AS PLACEID,
cost
FROM    roads
WHERE   sourceid = source.placeid
AND     targetid NOT IN ( SELECT PLACEID FROM locklist )
) r
ON (
f.PLACEID = r.PLACEID
)
WHEN MATCHED THEN
UPDATE SET      f.PARENTID = source.placeid,
f.ABSCOST  = source.abscost + r.cost
WHERE    f.ABSCOST  > source.abscost + r.cost
WHEN NOT MATCHED
THEN INSERT VALUES ( r.PLACEID, source.PLACEID, source.abscost + r.cost, CALCCOSTS( r.placeid, targetnode ));

END;

------------------------------------------------------------------------------------------------
PROCEDURE SEARCH(   sourceid        IN places.placeid%TYPE,
targetid        IN places.placeid%TYPE )
IS
rowt locklist%rowtype;
i   PLS_INTEGER := 1;
BEGIN
INIT_SEARCH;

targetnode := targetid;
ADD_TO_LOCKLIST( sourceid, sourceid, 0, 0, rowt );

WHILE rowt.placeid <> targetid LOOP
MERGE_FREELIST( rowt );
STEP_FORWARD( rowt );
ADD_TO_LOCKLIST( rowt.placeid, rowt.parentid, rowt.abscost, rowt.calccost,  rowt );
i := i+1;
IF i >= 1000 THEN
RETURN;
END IF;
END LOOP;

END;

END;
/

SHOW ERRORS

Der Aufruf erfolgt dann einfach mit

set serveroutput on
exec A_STAR.SEARCH( 9, 1 );

Wobei die beiden Parameter die ID`s des Startknoten und des Zielknoten angeben.

Wer sich detailiert damit auseinadersetzen will, kann mal hier ( http://www.gamasutra.com/features/20010314/pinter_pfv.htm ) etwas tiefer nachlesen. Meine Implementation ist dagegen recht rudimentär.

Schlagworte: , , ,

Kommentieren