1、引言
產品分類,多級的樹狀結構的論壇,郵件列表等許多地方咱們都會遇到這樣的問題:如何存儲多級結構的數據?在PHP的應用中,提供後臺數據存儲的一般是關係型數據庫,它可以保存大量的數據,提供高效的數據檢索和更新服務。然而關係型數據的基本形式是縱橫交錯的表,是一個平面的結構,若是要將多級樹狀結構存儲在關係型數據庫裏就須要進行合理的翻譯工做。接下來我會將本身的所見所聞和一些實用的經驗和你們探討一下:
層級結構的數據保存在平面的數據庫中基本上有兩種經常使用設計方法:
* 毗鄰目錄模式(adjacency list model)
* 預排序遍歷樹算法(modified preorder tree traversal algorithm)
我不是計算機專業的,也沒有學過什麼數據結構的東西,因此這兩個名字都是我本身按照字面的意思翻的,若是說錯了還請多多指教。這兩個東西聽着好像很嚇人,其實很是容易理解。
2、模型
這裏我用一個簡單食品目錄做爲咱們的示例數據。
咱們的數據結構是這樣的,如下是代碼:
- Food
- |
- |---Fruit
- | |
- | |---Red
- | | |
- | | |--Cherry
- | |
- | +---Yellow
- | |
- | +--Banana
- |
- +---Meat
- |--Beef
- +--Pork
複製代碼
爲了照顧那些英文一塌糊塗的PHP愛好者
- Food : 食物
- Fruit : 水果
- Red : 紅色
- Cherry: 櫻桃
- Yellow: 黃色
- Banana: 香蕉
- Meat : 肉類
- Beef : 牛肉
- Pork : 豬肉
複製代碼
3、實現
一、毗鄰目錄模式(adjacency list model)
這種模式咱們常常用到,不少的
教程和書中也介紹過。咱們經過給每一個節點增長一個屬性 parent 來表示這個節點的父節點從而將整個樹狀結構經過平面的表描述出來。根據這個原則,例子中的數據能夠轉化成以下的表:
如下是代碼:
- +-----------------------+
- | parent | name |
- +-----------------------+
- | | Food |
- | Food | Fruit |
- | Fruit | Green |
- | Green | Pear |
- | Fruit | Red |
- | Red | Cherry |
- | Fruit | Yellow |
- | Yellow | Banana |
- | Food | Meat |
- | Meat | Beef |
- | Meat | Pork |
- +-----------------------+
複製代碼
咱們看到 Pear 是Green的一個子節點,Green是Fruit的一個子節點。而根節點'Food'沒有父節點。 爲了簡單地描述這個問題,這個例子中只用了name來表示一個記錄。 在實際的數據庫中,你須要用數字的id來標示每一個節點,數據庫的表結構大概應該像這樣:id, parent_id, name, descrīption。
有了這樣的表咱們就能夠經過數據庫保存整個多級樹狀結構了。
顯示多級樹,若是咱們須要顯示這樣的一個多級結構須要一個遞歸函數。
如下是代碼:
- <?php
- // $parent is the parent of the children we want to see
- // $level is increased when we go deeper into the tree,
- // used to display a nice indented tree
- function display_children($parent, $level) {
- // 得到一個 父節點 $parent 的全部子節點
- $result = mysql_query("
- SELECT name
- FROM tree
- WHERE parent = '" . $parent . "'
- ;"
- );
- // 顯示每一個子節點
- while ($row = mysql_fetch_array($result)) {
- // 縮進顯示節點名稱
- echo str_repeat(' ', $level) . $row['name'] . "\n";
- //再次調用這個函數顯示子節點的子節點
- display_children($row['name'], $level+1);
- }
- }
- ?>
複製代碼
對整個結構的根節點(Food)使用這個函數就能夠打印出整個多級樹結構,因爲Food是根節點它的父節點是空的,因此這樣調用: display_children('',0)。將顯示整個樹的內容:
- Food
- Fruit
- Red
- Cherry
- Yellow
- Banana
- Meat
- Beef
- Pork
複製代碼
若是你只想顯示整個結構中的一部分,好比說水果部分,就能夠這樣調用:display_children('Fruit',0);
幾乎使用一樣的方法咱們能夠知道從根節點到任意節點的路徑。好比 Cherry 的路徑是 "Food >; Fruit >; Red"。 爲了獲得這樣的一個路徑咱們須要從最深的一級"Cherry"開始, 查詢獲得它的父節點"Red"把它添加到路徑中,而後咱們再查詢Red的父節點並把它也添加到路徑中,以此類推直到最高層的"Food",如下是代碼:
- <?php
- // $node 是那個最深的節點
- function get_path($node) {
- // 查詢這個節點的父節點
- $result = mysql_query("
- SELECT parent
- FROM tree
- WHERE name = '" . $node ."'
- ;"
- );
- $row = mysql_fetch_array($result);
- // 用一個數組保存路徑
- $path = array();
- // 若是不是根節點則繼續向上查詢
- // (根節點沒有父節點)
- if ($row['parent'] != '') {
- // the last part of the path to $node, is the name
- // of the parent of $node
- $path[] = $row['parent'];
- // we should add the path to the parent of this node
- // to the path
- $path = array_merge(get_path($row['parent']), $path);
- }
- // return the path
- return $path;
- }
- ?>;
複製代碼
若是對"Cherry"使用這個函數:print_r(get_path('Cherry')),就會獲得這樣的一個數組了:
- Array (
- [0] => Food
- [1] => Fruit
- [2] => Red
- )
複製代碼
接下來如何把它打印成你但願的
格式,就是你的事情了。
缺點:
這種方法很簡單,容易理解,好上手。可是也有一些缺點。主要是由於運行速度很慢,因爲獲得每一個節點都須要進行數據庫查詢,數據量大的時候要進行不少查詢才能完成一個樹。另外因爲要進行遞歸運算,遞歸的每一級都須要佔用一些內存因此在空間利用上效率也比較低。
二、預排序遍歷樹算法
如今讓咱們看一看另一種不使用遞歸計算,更加快速的方法,這就是預排序遍歷樹算法(modified preorder tree traversal algorithm)
這種方法你們可能接觸的比較少,初次使用也不像上面的方法容易理解,可是因爲這種方法不使用遞歸查詢算法,有更高的查詢效率。
咱們首先將多級數據按照下面的方式畫在紙上,在根節點Food的左側寫上 1 而後沿着這個樹繼續向下 在 Fruit 的左側寫上 2 而後繼續前進,沿着整個樹的邊緣給每個節點都標上左側和右側的數字。最後一個數字是標在Food 右側的 18。在下面的這張圖中你能夠看到整個標好了數字的多級結構。(沒有看懂?用你的手指指着數字從1數到18就明白怎麼回事了。還不明白,再數一遍,注意移動你的手指)。
這些數字標明瞭各個節點之間的關係,"Red"的號是3和6,它是 "Food" 1-18 的子孫節點。 一樣,咱們能夠看到 全部左值大於2和右值小於11的節點 都是"Fruit" 2-11 的子孫節點
如下是代碼:
- 1 Food 18
- |
- +------------------------------+
- | |
- 2 Fruit 11 12 Meat 17
- | |
- +-------------+ +------------+
- | | | |
- 3 Red 6 7 Yellow 10 13 Beef 14 15 Pork 16
- | |
- 4 Cherry 5 8 Banana 9
複製代碼
這樣整個樹狀結構能夠經過左右值來存儲到數據庫中。繼續以前,咱們看一看下面整理過的數據表。
如下是代碼:
- +----------+------------+-----+-----+
- | parent | name | lft | rgt |
- +----------+------------+-----+-----+
- | | Food | 1 | 18 |
- | Food | Fruit | 2 | 11 |
- | Fruit | Red | 3 | 6 |
- | Red | Cherry | 4 | 5 |
- | Fruit | Yellow | 7 | 10 |
- | Yellow | Banana | 8 | 9 |
- | Food | Meat | 12 | 17 |
- | Meat | Beef | 13 | 14 |
- | Meat | Pork | 15 | 16 |
- +----------+------------+-----+-----+
複製代碼
注意:因爲"left"和"right"在 SQL中有特殊的意義,因此咱們須要用"lft"和"rgt"來表示左右字段。 另外這種結構中再也不須要"parent"字段來表示樹狀結構。也就是 說下面這樣的表結構就足夠了。
如下是代碼:
- +------------+-----+-----+
- | name | lft | rgt |
- +------------+-----+-----+
- | Food | 1 | 18 |
- | Fruit | 2 | 11 |
- | Red | 3 | 6 |
- | Cherry | 4 | 5 |
- | Yellow | 7 | 10 |
- | Banana | 8 | 9 |
- | Meat | 12 | 17 |
- | Beef | 13 | 14 |
- | Pork | 15 | 16 |
- +------------+-----+-----+
複製代碼
好了咱們如今能夠從數據庫中獲取數據了,例如咱們須要獲得"Fruit"項下的全部全部節點就能夠這樣寫查詢語句:
- SELECT * FROM tree WHERE lft BETWEEN 2 AND 11;
複製代碼
這個查詢獲得瞭如下的結果。
如下是代碼:
- +------------+-----+-----+
- | name | lft | rgt |
- +------------+-----+-----+
- | Fruit | 2 | 11 |
- | Red | 3 | 6 |
- | Cherry | 4 | 5 |
- | Yellow | 7 | 10 |
- | Banana | 8 | 9 |
- +------------+-----+-----+
複製代碼
看到了吧,只要一個查詢就能夠獲得全部這些節點。爲了可以像上面的遞歸函數那樣顯示整個樹狀結構,咱們還須要對這樣的查詢進行排序。用節點的左值進行排序:
- SELECT * FROM tree WHERE lft BETWEEN 2 AND 11 ORDER BY lft ASC;
複製代碼
剩下的問題如何顯示層級的縮進了。
如下是代碼:
- <?php
- function display_tree($root) {
- // 獲得根節點的左右值
- $result = mysql_query("
- SELECT lft, rgt
- FROM tree
- WHERE name = '" . $root . "'
- ;"
- );
- $row = mysql_fetch_array($result);
- // 準備一個空的右值堆棧
- $right = array();
- // 得到根基點的全部子孫節點
- $result = mysql_query("
- SELECT name, lft, rgt
- FROM tree
- WHERE lft BETWEEN '" . $row['lft'] . "' AND '" . $row['rgt'] ."'
- ORDER BY lft ASC
- ;"
- );
- // 顯示每一行
- while ($row = mysql_fetch_array($result)) {
- // only check stack if there is one
- if (count($right) > 0) {
- // 檢查咱們是否應該將節點移出堆棧
- while ($right[count($right) - 1] < $row['rgt']) {
- array_pop($right);
- }
- }
- // 縮進顯示節點的名稱
- echo str_repeat(' ',count($right)) . $row['name'] . "\n";
- // 將這個節點加入到堆棧中
- $right[] = $row['rgt'];
- }
- }
- ?>
複製代碼
若是你運行一下以上的函數就會獲得和遞歸函數同樣的結果。只是咱們的這個新的函數可能會更快一些,由於只有2次數據庫查詢。
要獲知一個節點的路徑就更簡單了,若是咱們想知道Cherry 的路徑就利用它的左右值4和5來作一個查詢。
- SELECT name FROM tree WHERE lft < 4 AND rgt >; 5 ORDER BY lft ASC;
複製代碼
這樣就會獲得如下的結果:
如下是代碼:
- +------------+
- | name |
- +------------+
- | Food |
- | Fruit |
- | Red |
- +------------+
複製代碼
那麼某個節點到底有多少子孫節點呢?很簡單,子孫總數=(右值-左值-1)/2
- descendants = (right – left - 1) / 2
複製代碼
不相信?本身算一算啦。
用這個簡單的公式,咱們能夠很快的算出"Fruit 2-11"節點有4個子孫節點,而"Banana 8-9"節點沒有子孫節點,也就是說它不是一個父節點了。
很神奇吧?雖然我已經屢次用過這個方法,可是每次這樣作的時候仍是感到很神奇。
這的確是個很好的辦法,可是有什麼辦法可以幫咱們創建這樣有左右值的數據表呢?這裏再介紹一個函數給你們,這個函數能夠將name和parent結構的表自動轉換成帶有左右值的數據表。
如下是代碼:
- <?php
- function rebuild_tree($parent, $left) {
- // the right value of this node is the left value + 1
- $right = $left+1;
- // get all children of this node
- $result = mysql_query("
- SELECT name
- FROM tree
- WHERE parent = '" . $parent . "'
- ;"
- );
- while ($row = mysql_fetch_array($result)) {
- // recursive execution of this function for each
- // child of this node
- // $right is the current right value, which is
- // incremented by the rebuild_tree function
- $right = rebuild_tree($row['name'], $right);
- }
- // we've got the left value, and now that we've processed
- // the children of this node we also know the right value
- mysql_query("
- UPDATE tree
- SET
- lft = '" . $left . "',
- rgt= '" . $right . "'
- WHERE name = '" . $parent . "'
- ;"
- );
- // return the right value of this node + 1
- return $right + 1;
- }
- ?>
複製代碼
固然這個函數是一個遞歸函數,咱們須要從根節點開始運行這個函數來重建一個帶有左右值的樹
這個函數看上去有些複雜,可是它的做用和手工對錶進行編號同樣,就是將立體多層結構的轉換成一個帶有左右值的數據表。
那麼對於這樣的結構咱們該如何增長,更新和刪除一個節點呢?
增長一個節點通常有兩種方法:
第一種,保留原有的name 和parent結構,用老方法向數據中添加數據,每增長一條數據之後使用rebuild_tree函數對整個結構從新進行一次編號。
第二種,效率更高的辦法是改變全部位於新節點右側的數值。舉例來講:咱們想增長一種新的水果"Strawberry"(草莓)它將成爲"Red"節點的最後一個子節點。首先咱們須要爲它騰出一些空間。"Red"的右值應當從6改爲8,"Yellow 7-10 "的左右值則應當改爲 9-12。依次類推咱們能夠得知,若是要給新的值騰出空間須要給全部左右值大於5的節點 (5 是"Red"最後一個子節點的右值) 加上2。因此咱們這樣進行數據庫操做:
- UPDATE tree SET rgt = rgt + 2 WHERE rgt > 5;
- UPDATE tree SET lft = lft + 2 WHERE lft > 5;
複製代碼
這樣就爲新插入的值騰出了空間,如今能夠在騰出的空間裏創建一個新的數據節點了, 它的左右值分別是6和7
- INSERT INTO tree SET lft=6, rgt=7, name='Strawberry';
複製代碼
再作一次查詢看看吧!怎麼樣?很快吧。 4、結語 好了,如今你能夠用兩種不一樣的方法設計你的多級數據庫結構了,採用何種方式徹底取決於你我的的判斷,可是對於層次多數量大的結構我更喜歡第二種方法。若是查詢量較小可是須要頻繁添加和更新的數據,則第一種方法更爲簡便。 另外,若是數據庫支持的話 你還能夠將rebuild_tree()和 騰出空間的操做寫成數據庫端的觸發器函數, 在插入和更新的時候自動執行, 這樣能夠獲得更好的運行效率, 並且你添加新節點的SQL語句會變得更加簡單。