樹形結構存儲方案對比分析


  在程序開發中,我們常遇到用樹型結構來表示某些數據間的關系,如企業的組織架構、商品的分類、操作欄目等,目前的關系型數據庫都是以二維表的形式記錄存儲數據,而樹型結構的數據如需存入二維表就必須進行Schema設計。最近對此方面比較感興趣,專門做下梳理,如下為常見的樹型結構的數據:

一、鄰接表

  其中最簡單的方法是:Adjacency List(鄰接列表模式)。簡單的說是根據節點之間的繼承關系,顯現的描述某一節點的父節點,從而建立二位的關系表。表結構通常設計為{Node_id,Parent_id},如下圖:

  這種方案的優點很明顯:結構簡單易懂,由於互相之間的關系只由一個parent_id維護,所以增刪改都是非常容易,只需要改動和他直接相關的記錄就可以。

  缺點當然也是非常的突出:由於直接地記錄了節點之間的繼承關系,因此對Tree的任何CRUD操作都將是低效的,這主要歸根於頻繁的“遞歸”操作,遞歸過程不斷地訪問數據庫,每次數據庫IO都會有時間開銷。舉個例子,如果想要返回所有水果,也就是水果的所有子孫節點,看似很簡單的操作,就需要用到一堆遞歸。

  當然,這種方案並非沒有用武之地,在樹的層級比較少的時候就非常實用。這種方法的優點是存儲的信息少,查直接上司和直接下屬的時候很方便,缺點是多級查詢的時候很費勁。所以當只需要用到直接上下級關系的時候,用這種方法還是不錯的,可以節省很多空間。

二、物化路徑

  物化路徑其實更加容易理解,其實就是在創建節點時,將節點的完整路徑進行記錄。以下圖為例:

  此種方案借助了unix文件目錄的思想,主要時以空間換時間。

// 查詢某一節點下的所有子節點:(以Fruit為例)
SET @path = (SELECT path FROM pathTree WHERE node_name = 'Fruit'); SELECT * FROM pathTree WHERE path like CONCAT(@path,'/%'); // 如何查詢直屬子節點?需要采用MySQL的正則表達式查詢:
SET @path = (SELECT path FROM pathTree WHERE node_name = 'Fruit'); SELECT * FROM pathTree WHERE path REGEXP CONCAT(@path,'/','[0-9]$'); // 查詢任意節點的所有上級:(以Yellow為例):
SET @path = (SELECT path FROM pathTree WHERE node_name = 'Yellow'); SELECT * FROM pathTree WHERE @path LIKE CONCAT(path, '%') AND path <> @path; // 插入新增數據:
SET @parent_path = ( SELECT path FROM pathTree WHERE node_name = 'Fruit'); INSERT INTO pathtree (path,node_name) VALUES (CONCAT(@parent_path,'/',LAST_INSERT_ID()+1),'White')

  此方案的缺點是樹的層級太深有可能會超過PATH字段的長度,所以其能支持的最大深度並非無限的。

  如果層級數量是確定的,可以再將所有的列都展開,如下圖,比較適用於於類似行政區划、生物分類法(界、門、綱、目、科、屬、種)這些層級確定的內容。

三、左右值編碼

  在基於數據庫的一般應用中,查詢的需求總要大於刪除和修改。為了避免對於樹形結構查詢時的“遞歸”過程,基於Tree的前序遍歷設計一種全新的無遞歸查詢、無限分組的左右值編碼方案,來保存該樹的數據。

  第一次看見這種表結構,相信大部分人都不清楚左值(Lft)和右值(Rgt)是如何計算出來的,而且這種表設計似乎並沒有保存父子節點的繼承關系。但當你用手指指着表中的數字從1數到18,你應該會發現點什么吧。對,你手指移動的順序就是對這棵樹進行前序遍歷的順序,如下圖所示。當我們從根節點Food左側開始,標記為1,並沿前序遍歷的方向,依次在遍歷的路徑上標注數字,最后我們回到了根節點Food,並在右邊寫上了18。

  依據此設計,我們可以推斷出所有左值大於2,並且右值小於11的節點都是Fruit的后續節點,整棵樹的結構通過左值和右值存儲了下來。然而,這還不夠,我們的目的是能夠對樹進行CRUD操作,即需要構造出與之配套的相關算法。按照深度優先,由左到右的原則遍歷整個樹,從1開始給每個節點標注上left值和right值,並將這兩個值存入對應的name之中。

  本方案的優點是查詢非常的方便,缺點就是每次插入刪除數據涉及到的更新內容太多,如果樹非常大,插入一條數據可能花很長的時間。所以不推薦,了解下就行,也不打算深入研究,想研究的可以查其他資料。

四、閉包表

  將Closure Table翻譯成閉包表不知道是否合適,閉包表的思路和物化路徑差不多,都是空間換時間,Closure Table,一種更為徹底的全路徑結構,分別記錄路徑上相關結點的全展開形式。能明晰任意兩結點關系而無須多余查詢,級聯刪除和結點移動也很方便。但是它的存儲開銷會大一些,除了表示結點的Meta信息,還需要一張專用的關系表。

// 主表
CREATE TABLE nodeInfo ( node_id INT NOT NULL AUTO_INCREMENT, node_name VARCHAR (255), PRIMARY KEY (`node_id`) ) DEFAULT CHARSET = utf8; // 關系表
CREATE TABLE nodeRelationship ( ancestor INT NOT NULL, descendant INT NOT NULL, distance INT NOT NULL, PRIMARY KEY (ancestor, descendant) ) DEFAULT CHARSET = utf8;

  其中

  • Ancestor代表祖先節點
  • Descendant代表后代節點
  • Distance 祖先距離后代的距離

  添加數據(創建存儲過程)

CREATE DEFINER = `root`@`localhost` PROCEDURE `AddNode`(`_parent_name` varchar(255),`_node_name` varchar(255)) BEGIN DECLARE _ancestor INT; DECLARE _descendant INT; DECLARE _parent INT; IF NOT EXISTS(SELECT node_id From nodeinfo WHERE node_name = _node_name) THEN INSERT INTO nodeinfo (node_name) VALUES(_node_name); SET _descendant = (SELECT node_id FROM nodeinfo WHERE node_name = _node_name); INSERT INTO noderelationship (ancestor,descendant,distance) VALUES(_descendant,_descendant,0); IF EXISTS (SELECT node_id FROM nodeinfo WHERE node_name = _parent_name) THEN SET _parent = (SELECT node_id FROM nodeinfo WHERE node_name = _parent_name); INSERT INTO noderelationship (ancestor,descendant,distance) SELECT ancestor,_descendant,distance+1 from noderelationship where descendant = _parent; END IF; END IF; END;

  完成后2張表的數據大致是這樣的:(注意:每個節點都有一條到其本身的記錄。)

// 查詢Fruit下所有的子節點
SELECT n3.node_name FROM nodeinfo n1 INNER JOIN noderelationship n2 ON n1.node_id = n2.ancestor INNER JOIN nodeinfo n3 ON n2.descendant = n3.node_id WHERE n1.node_name = 'Fruit' AND n2.distance != 0

// 查詢Fruit下直屬子節點:
SELECT n3.node_name FROM nodeinfo n1 INNER JOIN noderelationship n2 ON n1.node_id = n2.ancestor INNER JOIN nodeinfo n3 ON n2.descendant = n3.node_id WHERE n1.node_name = 'Fruit' AND n2.distance = 1

// 查詢Fruit所處的層級:
SELECT n2.*, n3.node_name FROM nodeinfo n1 INNER JOIN noderelationship n2 ON n1.node_id = n2.descendant INNER JOIN nodeinfo n3 ON n2.ancestor = n3.node_id WHERE n1.node_name = 'Fruit' ORDER BY n2.distance DESC

  然后是新增節點:即為給某一個節點新增子節點,假設該節點還是B,現在給B節點新增一個子節點E

  首先,把自己新增進去,SQL:insert into releation values('E','E',0);,然后找到E的祖先,那么現在E的祖先是B的祖先加上B自己,然后告訴這些祖先們,他們新增了一個后代。通過找祖先的SQL,我們找到了B的祖先,A,那么E的祖先就是B和A:insert into releation values('A','E',2);insert into releation values('B','E',1);那么我們可以看出,新增子節點,除了新增自己以外,還需要通知祖先,並讓祖先保存自己,下面提供一個偽碼,實現該功能
public insert(Node a,Node b){ //1.將自己記錄下來
    conn.excuteSql(insert into releation values(a,a,0)); //2.查找a的祖先和自己,並告知他們,他們新增的子孫b
    List<Releation> ancestors = conn.excuteSql(select r.ancestor from releation r where r.descendant=a order by distacne); for(Releation r : ancestors){ conn.excuteSql(insert into releation values(r.ancestor,b,r.distacne+1)) } }

  缺點很顯而易見,他的存儲和更新開銷太大。

 

  以上所述存儲方案,並沒有絕對的優劣之分,適用場合不同而已。可以根據情況自己選用。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM