Struktura drzewa w MySQL Michał Tyszczenko
W informatyce drzewa są strukturami danych reprezentującymi drzewa matematyczne. W naturalny sposób reprezentują hierarchię danych toteż głównie do tego celu są stosowane. Drzewa ułatwiają i przyspieszają wyszukiwanie, a także pozwalają w łatwy sposób operować na posortowanych danych. Znaczenie tych struktur jest bardzo duże i ze względu na swoje własności drzewa są stosowane praktycznie w każdej dziedzinie informatyki. Przechowywanie drzew w bazie danych okazuje się nie być takie proste, ponieważ ani System Zarządzania Relacyjnymi Bazami Danych MySQL ani sam język SQL nie posiadają żadnych wbudowanych mechanizmów do przechowywania oraz zarządzania takimi strukturami. Powstało jednak wiele różnych implementacji takich drzew, z których żadna nie jest idealna. O wyborze metody może decydować łatwość oraz szybkość wykonania podstawowych operacji tj: wyliczenie wszystkich elementów drzewa, wyszukanie konkretnego elementu, dodanie nowego elementu w określonym miejscu drzewa, usunięcie elementu. 1 Chciałbym przedstawić oraz porównać dwie metody zarządzania strukturami drzewa w MySQL. W obu metodach wykorzystam drzewo kategorii od A do L ukazane na rysunku poniżej. 1 http://pl.wikipedia.org/wiki/drzewo_(informatyka) 2008-03-31 godz. 21:00
Metoda 1 1. Struktura tabeli Informacje o drzewie przechowywane będą w tabeli o nazwie Kategorie. Pole id nazwa id_rodzica Tabela Kategorie Typ INTEGER(8) VARCHAR(100) INTEGER(8) W języku SQL tabelę tworzymy za pomocą zapytania: CREATE TABLE `Kategorie` ( `id` int(8) NOT NULL AUTO_INCREMENT, `nazwa` varchar( 100 ) NOT NULL DEFAULT '', `id_rodzica` int(8) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ); 2. Dodanie rekordów do tabeli Wykonujemy zapytanie: INSERT INTO `Kategorie` VALUES ('1', 'A', '0'), ('2', 'B', '1'), ('3', 'C', '1'), ('4', 'D', '1'), ('5', 'E', '2'), ('6', 'F', '3'), ('7', 'G', '3'), ('8', 'H', '4'), ('9', 'I', '4'), ('10', 'J', '8'), ('11', 'K', '8'), ('12', 'L', '8');
Po wykonaniu zapytania i wyświetleniu wszystkich rekordów otrzymujemy: +----------------+ id nazwa id_rodzica +----------------+ 1 A 0 2 B 1 3 C 1 4 D 1 5 E 2 6 F 3 7 G 3 8 H 4 9 I 4 10 J 8 11 K 8 12 L 8 +----------------+ Na rysunku widzimy odpowiednio ponumerowane drzewo. 3. Wyświetlenie całego drzewa Wyświetlenie całego drzewa jest dość skomplikowane, ponieważ musimy wielokrotnie złączyć tabelę tyle razy, ile poziomów posiada nasze drzewo. Tak więc nie jest to zapytanie uniwersalne, za każdym razem kiedy zmieni się ilość poziomów drzewa, zapytanie to będzie trzeba dostosowywać.
SELECT t1.nazwa AS poziom1, t2.nazwa as poziom2, t3.nazwa as poziom3, t4.nazwa as poziom4 FROM Kategorie AS t1 LEFT JOIN Kategorie AS t2 ON (t2.id_rodzica = t1.id) LEFT JOIN Kategorie AS t3 ON (t3.id_rodzica = t2.id) LEFT JOIN Kategorie AS t4 ON (t4.id_rodzica = t3.id) WHERE t1.nazwa = 'A'; Po wykonaniu zapytania otrzymujemy: +---------+---------+---------+---------+ poziom1 poziom2 poziom3 poziom4 +---------+---------+---------+---------+ A B E NULL A C F NULL A C G NULL A D H J A D H K A D H L A D I NULL +---------+---------+---------+---------+ 4. Znalezienie wszystkich liści drzewa Aby znaleźć wszystkie liście wykonujemy zapytanie SQL, które szuka wszystkich elementów drzewa nie posiadających dzieci. Zapytanie to jest uniwersalne dla każdego drzewa, niezależnie do jego rozmiarów, poziomów czy zagłębień. Oczywiście dla rozległych drzew będzie wykonywało się odpowiednio wolniej. SELECT t1.nazwa FROM Kategorie AS t1 LEFT JOIN Kategorie as t2 ON (t1.id = t2.id_rodzica) WHERE t2.id IS NULL; Wynik zapytania przedstawiony w tabeli i na rysunku. nazwa E F G I J K L
5. Znalezienie drogi od określonego miejsc do korzenia Aby znaleźć drogę od określonego miejsc drzewa do korzenia należy wykonać zapytanie zbudowane w ten sam sposób jak zapytanie wyświetlające całe drzewo. Jako warunek podajemy element od którego i do którego szukamy, czyli zakres przeszukiwania. Również ten SQL nie jest uniwersalny i wymaga wielokrotnych złączeń tabeli w zależności od ilości poziomów drzewa. SELECT t1.nazwa AS p_1, t2.nazwa as p_2, t3.nazwa as p_3, t4.nazwa as p_4 FROM Kategorie AS t1 LEFT JOIN Kategorie AS t2 ON (t2.id_rodzica = t1.id) LEFT JOIN Kategorie AS t3 ON (t3.id_rodzica = t2.id) LEFT JOIN Kategorie AS t4 ON (t4.id_rodzica = t3.id) WHERE t1.nazwa = 'A' AND t4.nazwa = 'K'; Wynik zapytania ukazany w tabeli oraz na rysunku: +-----+------+------+------+ p_1 p_2 p_3 p_4 +-----+------+------+------+ A D H K +-----+------+------+------+ 6. Dodanie nowej kategorii W metodzie tej, dodawanie nowej pozycji do drzewa jest bardzo proste i szybkie. Aby tego dokonać należy dodać nowy rekord do tabeli Kategorie, gdzie jako id_rodzica podajemy id elementu nadrzędnego. Np. aby dodać elementy M i N wykonujemy zapytania: INSERT INTO Kategorie (nazwa, id_rodzica) VALUES ('M',6); INSERT INTO Kategorie (nazwa, id_rodzica) VALUES ('N',11);
Drzewo wraz z nowymi elementami: 7. Przenoszenie kategorii Przenoszenie, tak jak dodawanie, jest również szybkie i proste. Dodatkową zaletą jest możliwość przeniesienia całej gałęzi przenosząc tylko jeden element. Wynika to z tego, iż każdy element zna tylko swojego rodzica, a więc przenosząc dowolny element drzewo nie traci spójności. Np. przenosząc element H wraz z nim przeniosą się wszystkie elementy poniżej niego.
Do tego celu użyjemy zapytania, które odczyta do zmiennej id elementu G, do którego przenosimy element H oraz zapytania, które zmieni id_rodzica w elemencie H na id elementy G. SELECT @id := id FROM Kategorie WHERE nazwa = 'G'; UPDATE Kategorie SET id_rodzica = @id WHERE nazwa = 'H'; W ten sposób w drzewie zmieniła się ilość poziomów, dlatego aby ponownie wyświetlić całe drzewo, należy do zwiększyć ilość złączeń tabel. SELECT t1.nazwa AS poziom1, t2.nazwa as poziom2, t3.nazwa as poziom3, t4.nazwa as poziom4, t5.nazwa as poziom5, t6.nazwa as poziom6 FROM Kategorie2 AS t1 LEFT JOIN Kategorie AS t2 ON (t2.id_rodzica = t1.id) LEFT JOIN Kategorie AS t3 ON (t3.id_rodzica = t2.id) LEFT JOIN Kategorie AS t4 ON (t4.id_rodzica = t3.id) LEFT JOIN Kategorie AS t5 ON (t5.id_rodzica = t4.id) LEFT JOIN Kategorie AS t6 ON (t6.id_rodzica = t5.id) WHERE t1.nazwa = 'A'; Wynik zapytania: +---------+---------+---------+---------+---------+---------+ poziom1 poziom2 poziom3 poziom4 poziom5 poziom6 +---------+---------+---------+---------+---------+---------+ A B E NULL NULL NULL A C F M NULL NULL A C G H J NULL A C G H K N A C G H L NULL A D I NULL NULL NULL +---------+---------+---------+---------+---------+---------+
Metoda 2. 1. Struktura tabeli W metodzie tej dane przechowywane będą w tabeli, która składać się z 4 pól. Zamiast id_rodzica każdy element będzie posiadał pola lewa oraz prawa. Pole id nazwa lewa prawa Tabela Kategorie Typ INTEGER(8) VARCHAR(100) INTEGER(8) INTEGER(8) Tabelą tworzymy za pomocą zapytania: CREATE TABLE `Kategorie` ( `id` int(8) NOT NULL AUTO_INCREMENT, `nazwa` varchar( 100 ) NOT NULL DEFAULT '', `lewa` int(8) NOT NULL DEFAULT '0', `prawa` int(8) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `lewa` ( lewa`), KEY `prawa` (`prawa`) ); 2. Dodanie rekordów Przed dodaniem rekordów do tabeli numerujemy drzewo, w sposób widoczny na rysunku:
Jak numerujemy? Każda kategoria posiada dwa pola: lewa i prawa Numerowanie zaczynamy od korzenia kategorii, gdzie: lewa = 1 Jeżeli kategoria nie posiada żadnych podkategorii to: prawa = lewa + 1 Jeżeli kategoria posiada podkategorie to: prawa = lewa + (2 * liczba_podkategorii + 1) Stąd: liczba_podkategorii = (prawa lewa 1)/2 Czyli: Dla pierwszego dziecka: lewa = lewa_rodzica + 1 Dla rodzica: prawa = prawa_ostatniego_dziecka + 1 Wykonujemy zapytanie: INSERT INTO `Kategorie` VALUES ('1', 'A', '1', '24'), ('2', 'B', '2', '5'), ('3', 'C', '6', '11'), ('4', 'D', '12', '23'), ('5', 'E', '3', '4'), ('6', 'F', '7', '8'), ('7', 'G', '9', '10'), ('8', 'H', '13', '20'), ('9', 'I', '21', '22'), ('10', 'J', '14', '15'), ('11', 'K', '16', '17'), ('12', 'L', '18', '19');
Po wykonaniu zapytania tabela wygląda w następujący sposób: +---------- id nazwa lewa prawa +---------- 1 A 1 24 2 B 2 5 3 C 6 11 4 D 12 23 5 E 3 4 6 F 7 8 7 G 9 10 8 H 13 20 9 I 21 22 10 J 14 15 11 K 16 17 12 L 18 19 +---------- 3. Wyświetlenie całego drzewa przy pomocy jednego zapytania W metodzie tej zapytanie pobierające informacje o całym drzewie jest uniwersale i nie zależy od ilość poziomów czy elementów. Zaczyna szukać od korzenia, a lewa każdego dziecka musi zawierać się pomiędzy lewą a prawą rodzica. Wykonując zapytanie: SELECT dziecko.nazwa FROM Kategorie AS dziecko, Kategorie AS rodzic WHERE (dziecko.lewa BETWEEN rodzic.lewa AND rodzic.prawa) AND rodzic.nazwa = 'A' ORDER BY dziecko.lewa; Otrzymujemy: nazwa A B E C F G D H J K L I
4. Znalezienie wszystkich liści drzewa W metodzie tej, każdy liść (element nie posiadający dzieci) posiada taką cechę, że jego prawa strona wynosi lewa+1, dlatego podając taki warunek w zapytaniu SQL znajdziemy w prosty sposób wszystkie liście, niezależnie od wielkości drzewa. Zapytanie: SELECT nazwa FROM Kategorie WHERE prawa = lewa + 1; Wynik zapytania przedstawiony w tabeli i na rysunku: nazwa E F G I J K L 5. Znalezienie drogi od określonego miejsca do korzenia Aby znaleźć drogę od danego elementu do korzenia, szukamy od najniższego elementu sprawdzając warunek aby lewa dziecka mieściła się pomiędzy lewą i prawą rodzica. Jest to zapytanie szybkie i uniwersalne w przeciwieństwie do zapytanie z metody pierwszej. Np. aby wyszukać drogę od elementu K do korzenia wykonujemy zapytanie: SELECT rodzic.nazwa FROM Kategorie AS dziecko, Kategorie AS rodzic WHERE (dziecko.lewa BETWEEN rodzic.lewa AND rodzic.prawa) AND dziecko.nazwa = 'K' ORDER BY rodzic.lewa;
Wynik zapytania ukazany w tabeli oraz na rysunku: nazwa A D H K 6. Wyświetlenie całego drzewa wraz z zagłębieniem Metoda ta pozwala także na zliczenie zagłębienia dla każdego elementu. Cechy tej nie posiadała poprzednia metoda. Aby pobrać wszystkie elementy wraz z zagłębieniem, wykonujemy zapytanie: SELECT dziecko.nazwa, (COUNT(rodzic.nazwa) - 1) AS zaglebienie FROM Kategorie AS dziecko, Kategorie AS rodzic WHERE (dziecko.lewa BETWEEN rodzic.lewa AND rodzic.prawa) GROUP BY dziecko.nazwa ORDER BY dziecko.lewa;
Wynik zapytania ukazany w tabeli oraz na rysunku: -------------+ nazwa zaglebienie -------------+ A 0 B 1 E 2 C 1 F 2 G 2 D 1 H 2 J 3 K 3 L 3 I 2 -------------+ 7. Wyświetlenie sformatowanego drzewa Używają wbudowanych funkcji MySQL tj. CONCAT łączącą dwa łańcuchy znaków oraz REPEAT łączącą łańcuch znaków określoną ilość razy możemy pobrać całe drzewo z tabeli i wyświetlić je w sformatowany sposób. Wykonujemy zapytanie: SELECT CONCAT(REPEAT(' ',COUNT(rodzic.nazwa)-1), dziecko.nazwa) AS nazwa FROM Kategorie AS dziecko, Kategorie AS rodzic WHERE dziecko.lewa BETWEEN rodzic.lewa AND rodzic.prawa GROUP BY dziecko.nazwa ORDER BY dziecko.lewa;
Otrzymujemy: nazwa A B E C F G D H J K L I 8. Dodanie nowej kategorii poziom niżej Dodanie nowego elementu jest bardziej skomplikowane niż w pierwszej metodzie. Aby dodać element poziom niżej musimy prenumerować całe drzewo dla lewej i prawej strony większej od lewej elementu poniżej którego dodajemy. Na czas zmiany najlepiej jest zablokować tabelę do zapisu aby nikt poza mani nie miał do niej dostępu. Lewa nowego elementu to lewa rodzica + 1 a prawa to lewa rodzica + 2. Np. aby dodać nowy element o nazwie M poniżej elementu F należy wykonać zestaw zapytań: LOCK TABLE Kategorie WRITE; SELECT @lewa := lewa FROM Kategorie WHERE nazwa = 'F'; UPDATE Kategorie SET prawa = prawa + 2 WHERE prawa > @lewa; UPDATE Kategorie SET lewa = lewa + 2 WHERE lewa > @lewa; INSERT INTO Kategorie (nazwa, lewa, prawa) VALUES ('M', @lewa + 1, @lewa + 2); UNLOCK TABLES;
Rysunek wskazuje, gdzie znajduj się nowy element. 9. Dodanie nowej kategorii na tym samym poziomie Dodawanie na tym samym poziomie różni się przenumerowaniem całego drzewa dla elementów gdzie prawa i lewa są większe od prawej ostatniego brata. Lewa i prawa nowego elementu wynoszą odpowiednio: prawa brata + 1 i praw brata + 2. Dodanie elementu N obok L : LOCK TABLE Kategorie WRITE; SELECT @prawa := prawa FROM Kategorie WHERE nazwa = 'L'; UPDATE Kategorie SET prawa = prawa + 2 WHERE prawa > @prawa; UPDATE Kategorie SET lewa = lewa + 2 WHERE lewa > @prawa; INSERT INTO Kategorie (nazwa, lewa, prawa) VALUES ('N', @prawa + 1, @prawa + 2); UNLOCK TABLES;
Zmiany ukazane zostały na rysunku: Aby wyświetlić zmodyfikowane drzewo używany zapytania omawianego wcześniej, ponieważ jest ono uniwersalne i nie trzeba go modyfikować. SELECT CONCAT(REPEAT(' ',COUNT(rodzic.nazwa)-1), dziecko.nazwa) AS nazwa FROM Kategorie AS dziecko, Kategorie AS rodzic WHERE dziecko.lewa BETWEEN rodzic.lewa AND rodzic.prawa GROUP BY dziecko.nazwa ORDER BY dziecko.lewa; Wynik zapytania ukazuje tabela oraz rysunek:
Wnioski Bibliografia: 1. http://www.eioba.pl/a130/drzewa_w_php_i_mysql 2. http://blog.mwojcik.pl/2008/02/17/drzewa-kategorii-w-sql-i-php-metoda-ip/ 3. http://dev.mysql.com/tech-resources/articles/hierarchical-data.html 4. http://www.depesz.com/various/various-sqltrees.php