數據庫無限級分類

程序設計中常使用樹型結構來表征某些數據的關聯關系,如上下級、欄目結構、商品分類、菜單、回復等。

分類的層級關系可以表述為一父多子的繼承關系,對應數據結構中的樹。因此,分類問題可以轉換為如何在數據庫中存儲一棵樹。

常見樹狀結構

通常樹形結構需借助數據庫完成持久化,在關系型數據庫中由于是以二維表的形式記錄數據信息,因此不能直接將樹形結構存入,必須設計合適的Schema及對應的增刪改查算法以實現在關系型數據庫中存儲和操作。

理想的樹形結構應該具備

  • 數據存儲冗余度小且直觀性強
  • 檢索遍歷過程簡單高效
  • 節點增刪改查操作簡單高效

樹形結構在關系型數據庫中常用的存儲方式

  • 雙親表

雙親表主要通過記錄節點唯一id以及父節點pid來維護樹的結構關系

`gid` int(11) unsigned DEFAULT '0' COMMENT '唯一編號 ',
`pid` int(11) unsigned DEFAULT '0' COMMENT '上級編號',
雙親表

雙親表有什么優缺點呢?

  • 優點在于可以方便的對樹的節點進行增刪改查等操作,而且涉及變動的記錄較少。
  • 缺點是對于無差別子孫集合的獲取需要遞歸,獲取節點從根節點開始的路徑也需要遞歸追溯,因此時間開銷較大。

什么是遞歸函數呢?

遞歸函數是函數自身調用自身,但必須在調用自身前有條件判斷,否則將無限調用下去。

遞歸

遞歸算法解決問題的特點

  1. 遞歸就是函數或方法里調用自身
  2. 在使用遞增策略時,必須有一個明確的遞歸結束條件,稱為遞歸出口。
  3. 遞歸算法解題顯得簡潔,但運行效率較低,一般不提倡使用遞歸算法設計程序。
  4. 在遞歸調用過程中系統為每一層的返回點、局部變量等都會開辟了棧來存儲
  5. 遞歸層級過深容易造成棧溢出
遞歸迭代流程

PHP實現遞歸函數有三種基本的方式:全局變量、引用、靜態變量

  1. 使用引用做參數&

什么是引用呢?引用是指兩個不同名字的變量指向同一塊存儲地址,本來每個變量都是各自的存儲地址,賦值刪除操作時各行其道,使用&取地址符后可以使兩個變量共享同一塊地址。

函數之間本來時各行其道,即使是同名函數。遞歸函數將引用作為參數形成一個橋梁使兩個函數之間進行數據共享,雖然兩個函數貌似操作的是不同地址,但實際上操作的是同一塊內存地址。

例如:將數據格式化為樹形結構

function tree($list, $pid="pid", $pk="id", $child="child"){
  foreach($list as $k=>$v){
    $list[$v[$pid]][$child][$v[$pk]] = &$list[$v[$pk]];
  }
  return isset($list[0][$child]) ? $list[0][$child] : [];
}
function tree($list, $pid="pid", $pk="id", $child="child"){
  $ret = [];
  foreach($list as $k=>$v){
    if(isset($list[$v[$pid]])){
      $list[$v[$pid]][$child][] = &$list[$v[$pk]];
    }else{
      $ret[] = &$list[$v[$pk]];
    }
  }
  return $ret;
}
  1. 利用全局變量global

利用全局變量完成遞歸,全局變量global在函數內申明變量不過是外部變量的同名引用,變量的作用范圍仍然在本函數范圍內。改變變量的值,外部同名變量的值自然也會改變。但一旦使用了取地址符&,同名變量不再是同名引用。

  1. 利用靜態變量static

利用靜態變量static使用到遞歸函數時,static的作用僅在第一次調用函數時對變量進行初始化,并保留變量值。因此,將static應用到遞歸函數作用可想而知。在需要作為遞歸函數間作為“橋梁”的變量利用static進行初始化,每次遞歸都會保留“橋梁變量”的值。

例如:根據子類ID獲取所有父類

/*根據子類ID獲取所有父類*/
function getParents($list, $id=0, $level=0, $clear=true){
  static $ret = [];//聲明靜態數組用于存儲最終結果
  //首次進入清除上次調用函數留下的靜態變量的值,進入深一層循環時則不要清除。
  if($clear==true) $ret = [];
  //循環遍歷
  foreach($list as $k=>$v){
    if($id == $v['id']){
      $v["level"] = $level;
      $ret[] = $v;
      getParents($list, $v["pid"], $level-1, false);
    }
  }
  return $ret;
}

例如:根據父類ID獲取所有子類

function getChildren($list, $pid=0, $level=0, $clear=true){
  static $ret = [];//聲明靜態數組存儲結果
  //對剛進入函數要清除上次調用此函數后留下的靜態變量的值,進入深一層循環時則無需清除。
  if($clear==true) $ret = [];
  foreach($list as $k=>$v){
    if($pid == $v["pid"]){
      $v["level"] = $level;
      $ret[] = $v;
      getChildren($list, $v["id"], $level+1, $clear=false);
    }
  }
  return $ret;
}

遞歸函數重點是如何處理函數調用自身是如何保證所需要的結果得以在函數間合理傳遞,當然也無需函數之間傳值得遞歸函數。

例如:遞歸獲取某節點下的所有子孫節點,返回一個多維數組。

function tree($list, $pid=0, $pk="id", $label="child"){
  $children = [];
  //循環所有數據查找指定ID的下級
  foreach($list as $k=>$v){
    if($pid == $v[$pk]){//找到下級
      $children[$v[$pk]] = $v;//保存后繼續查找下級的下級
      unset($list[$k]);//去掉自己,因為自己不可能是自己的下級
      // 遞歸查找將找到的下級放入children數組的child字段中
      $children[$v[$pk]][$label] = tree($list, $v[$pk], $pk, $label);
    }
  }
  return $children;
}

例如:使用遞歸獲取多維數組的樹形結構

/*由父類獲取全部子類并得到多維數組的樹形結構*/
function tree($list, $pid=0, $pk="id", $label="child"){
  $arr = [];
  foreach($list as $k=>$v){
    if($pid == $v[$pk]){
      $v[$label] = tree($list, $v[$pk], $pk, $label);
      $arr[] = $v;
    }
  }
  return $arr;
}

例如:使用靜態變量根據父類獲得全部子類得到一個二維數組

function tree($list, $pid=0, $level=0, $pk="id"){
  static $arr = [];//定義靜態數組
  //第一次遍歷時找到pid=0的節點
  foreach($list as $k=>$v){
    //pid為0的節點對應的是第一級也就是頂級節點
    if($v[$pk] == $pid){
      $v["level"] = $level;
      $arr[] = $v;//將數組放入靜態變量
      unset($list[$k]);//將節點從數組中移除以減少后續遞歸消耗
      tree($list, $v[$pk], $level+1, $pk);//遞歸查找父節點為本節點ID的節點,層級自增。
    }
  }
  return $arr;
}

例如:使用引用傳值的方式獲取多維樹形結構

引用&是一個非常巧妙的方式,不用像遞歸那樣循環多次,思路是將數據以主鍵為索引重新排列,排序后找到根節點pid=0,并將其放入一個全新的數組。注意,這里存放的并非簡單的賦值,而是引用之前的地址。

function tree($list, $pk="id", $pid="pid", $child="child", $root=0){
  $pick = [];
  //循環重新排列
  foreach($list as $item){
    $pick[$item[$pk]] = $item;
  }
  $tree = [];
  foreach($pick as $k=>$v){
    //判斷是否為根節點,若是則將根節點數組的引用賦給新數組
    if($v[$pid] == $root){
      $tree[] = &$pick[$k];//根節點直接把地址放入新數組
    }else{
      // 子類數組賦值給父類數組中鍵為child的數組
      $pick[$v[$pid]][$child][] = &$pick[$k];//不是過根節點的則將自己的地址存放到父級的child節點中
    }
  }
  return $tree;
}
  • 層次表

層次表通過記錄節點id以及從根節點起到目標節點的路徑path來存儲樹形結構的關系,其中路徑path由節點編號id的序列組成。因為涉及到路徑的編碼規則,所以在實現時有多種不同形式。

比如,在節點較少編號較短的情況下節點路徑可以考慮直接使用無層次差別的節點編碼。在節點較多時可以考慮以層次level為基準對節點進行編碼,節點的唯一編碼由【層級level】+【層次節點path】組成。即使路徑使用無層次差別的同一節點編號,也可以使用“層次級別”來標識節點深度,以便更快的查詢特定深度級別的節點。

層次表的優點在于無需遞歸就可以方便地實現常用樹形結構的查詢,缺點是首先對于更改樹形層次結構時,尤其時更改位于較高層次節點時會引起大量記錄的修改,這個時間開銷十分巨大。其次,路徑的表達也有一些棘手的問題,路徑字段的長度設置會限制了樹形結構的層次深度。節點的編碼方式也可能影響到每個層次上節點的最大數量。

例如:查詢指定節點的所有下級

SELECT * FROM nodes WHERE path LIKE "1,%"

例如:查詢指定節點的直屬下級

SELECT * FROM nodes WHERE path LIKE "1%"
  • 先根遍歷樹表

先根遍歷樹表的主要思想是通過記錄先根遍歷中的第一次訪問節點時的次序號(左值,lft)與回溯時第二次訪問的次序號(右值,rgt)來維護樹形結構的層次關系。

由先根遍歷的概念可知,子節點的左值必須大于父節點的左值,子節點的右值必然小于父節點的右值。結合排序操作可以很容易的在不適用遞歸的情況下對樹形數據進行查詢操作。

  • 擴展的線索二叉樹表

擴展的線索二叉樹表方式是在雙親表的基礎上進行改變,增加了按深度搜索順序的節點訪問序號sn。這種方式可以看作是雙親表與先根遍歷樹方式的折中方案。

常見實現方式:鄰接表(The Adjacency List Model,鄰接列表模型)、預排序遍歷樹(MPTT)

  • 繼承關系驅動的Schema設計
  • 基于左右值編碼的Schema設計

繼承關系驅動的Schema設計

對樹形結構最直觀的分析莫過于節點之間的繼承關系,通過顯式地描述某個節點的父節點,從而能夠建立二維的關系表,這種方案的屬性結果表通常設計為{node_id, parent_id}

繼承關系驅動的Schema設計
  • 設計優點

設計和實現自然而然,非常直觀和方便。

  • 設計缺點

由于直接記錄了節點之間的繼承關系,因此對樹形結構的任何增刪改查操作都將是低效的,這主要歸根于頻繁的遞歸操作,遞歸過程不斷地訪問數據庫,每次數據庫IO都是會有時間開銷的。

遞歸中的SQL查詢會導致負載變大,特別是需要處理比較大型的樹狀結構時,查詢語句會隨著層級的增加而增加。

例如:獲取某子類其它所有父級的名稱

SELECT 
  t1.name AS lv1name,
  t2.name AS lv2name,
  t3.name AS lv3name
FROM nodes AS t1
LEFT JOIN nodes AS t2 ON t2.pid=t1.id
LEFT JOIN nodes AS t3 ON t3.pid=t2.id
WHERE 1=1
AND t3.id = 100

例如:根據指定節點獲取所有下級節點

delimiter /
DROP FUNCTION IF EXISTS `nodes`.`getChildren` /
CREATE FUNCTION `getChildren`(root_id INT)
RETURNS VARCHAR(255)
BEGIN
  DECLARE ret VARCHAR(255);
  DECLARE ids VARCHAR(255);
  SET ret  = '#';
  SET ids  = CAST(root_id AS CHAR);
  WHILE ids IS NOT NULL DO
    SET ids = CONCAT(ret, ',', ids);
    SELECT GROUP_CONCAT(id) INTO ids FROM nodes WHERE 1=1 AND FIND_IN_SET(pid, ids)>0;
  END WHILE;
  RETURN ret;
END
SELECT * FROM nodes WHERE 1=1 AND FIND_IN_SET(id, getChildren(1))

適用場景

在樹形結構規模較小的情況下,可借助于緩存接置來優化,將樹形結構存入內存進行處理,避免直接對數據庫IO操作的性能開銷。

最佳實踐

節點結構
權限節點

鄰接表主要依賴于pid字段,用于指向上級節點,將相鄰上下級節點連接,id為自動遞增。

CREATE TABLE `nodes` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '名稱',
  `pid` int(11) unsigned DEFAULT '0' COMMENT '父級節點',
  `path` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '0' COMMENT '節點路徑',
  `level` tinyint(3) unsigned DEFAULT '1' COMMENT '節點層級',
  `sort` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '排序值',
  `status` tinyint(1) unsigned DEFAULT '1' COMMENT '狀態 0禁用 1啟用',
  `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '備注',
  `created_at` timestamp NULL DEFAULT NULL COMMENT '創建時間',
  `updated_at` timestamp NULL DEFAULT NULL COMMENT '更新時間'
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='節點';
  • 優化設計
  1. 根據父節點遞歸pid

當查詢指定深度節點時,需要通過遞歸來逐層展開,才能獲取到所有該層的節點,然后在其中進行查詢,既浪費時間又浪費空間。

  1. 保存節點路徑path和深度level
SELECT * FROM `nodes` WHERE `path` LIKE "1,3%"

這樣做的目的是通過增加冗余信息來提高檢索速度,同時冗余信息非常容易維護,所以不會因為操作不慎而導致信息不一致。

設想一下你要對樹增加/移動/刪除一個節點,原本一條SQL語句就能完成的事情現在還是一條SQL語句就能完成,就算不依賴事務也絕對不會導致信息不一致。

  • 進一步優化

在樹形結構中,為方便排序且提高查詢效率,設計了path字段,用于記錄樹形的鏈條節點。為了進一步提高查詢效率,可將每個節點的主鍵id回寫入路徑path中,這樣做避免使用CONCAT(path,',',id) AS abspath的方式,但同時也存在相應弊端。

這樣做的好處是直接通過ORDER BY path ASC即可對樹形結構排序,但是當出現需要自定義排序的時候,問題出現了。若采用普通設置一個sort且以自然數的形式,排序時采用ORDER BY path ASC, sort ASC的做法,其結果又是無法得到想要不同層級的排序。

排序字段sort中需要反映中層級結構,且能進行層級內與層級間的排序。為此使用sort編碼的方式,每個排序值以4位數字進行編碼,縱向層級依次向后拓展。很明顯,這種規則存在這硬傷,適用的范圍可想而知。使用時限制仍舊很多,相對來說解決了一定的問題,但同時也帶來風險。

  1. 編碼法sort

每級分類遞增4位數字,既定每級分類數目限定在10000個。

一級:0001,0002,0003...
二級:00010001,00010002,00010003...
三級:000100010001,000100010002,000100010003....
...

可在數據表設置添加兩個字段rank排序值與code編碼值,rank排序值為用戶自定義設置的數值,code編碼值則使用父級的編碼值拼接上自己的4位數字編碼值。

$code .= str_pad($rank, 4, "0", STR_PAD_LEFT);
編碼法
SELECT `gid`,`pid`,`level`,`path`,`rank`,`code` FROM web_group ORDER BY code ASC
編碼法

基于左右值編碼的Schema設計

在基于數據庫的應用中查詢的需求總是要大于刪除和修改的,也就是說讀操作往往會大于寫操作。為了避免對樹形結構查詢時的遞歸操作,需要設計一種全新的無遞歸查詢、無限分類的方案。

基于左右值編碼的Schema設計的算法采用了預排序遍歷樹算法MPTT(Modified Preorder Tree Traversal),此算法在第一種方式的基礎上,給每個節點增加左右數字(lftrgt),用于標識節點的遍歷順序。

基于樹形結構的前序遍歷的方案的屬性結果表通常設計為{node_id, lft, rgt},由于leftright在SQL中具有特殊含義,所以使用lftrgt來標識列。

基于左右值編碼的Schema設計

基于左右值編碼的Schema設計中并沒有保存保存父子節點的繼承關系,根據箭頭的移動順序就是對樹進行前序遍歷的順序,整棵樹的結構通過左值和右值存儲了下來。

MPTT

預排序遍歷樹算法的數據結構中重點關注的是左右值的維護及查詢的便捷性。

  • lft 表示節點的左值
  • rgt 表示節點的右值
  • level 表示節點所在層級

添加節點

添加節點時如果不考慮指點節點的順序而采用從左到右的自然順序時,只需要從左向右依次插入即可。

左向右依次插入頂級節點

從左至右依次插入頂級節點時,待插入節點的左右值與頂級節點中最大右值相關。

  • 待插入頂級節點左值 = 頂級節點最大右值 + 1
  • 待插入頂級節點右值 = 頂級節點最大右值 + 2

從左至右依次插入子節點時,待插入子節點的左右值與父級節點的右值相關。

自左向右依次插入子節點時
  • 待插入子節點左值 = 父級節點右值
  • 待插入子節點右值 = 父級節點右值 + 1

添加節點時如果需要指定節點的順序,此時每個節點都需要設置一個所在層級的排序值,相當于索引值。

此時添加節點前首先需要考慮的時添加節點的位置,也就是需要在當前層級中找到與待插入節點排序值最接近的那個參照節點。在參照節點的左側或右側進行插入,與之同時需要考慮最左側與最右側兩種情況。

整體而言,思路是先找到參照節點,然后為目標節點騰出位置,也就是將參考節點之后的所有節點進行左右值更新為其騰出位置,最后才是插入目標節點。

這里重點就指定排序值的方式插入為例加以說明

節點定位

如何新增節點呢?首先需要有一個參考點,也就是你準備在樹中哪個位置插入節點,是參考節點左側、右側還是下面呢,當然這里并不會考慮上面,你懂的!簡單來說,就是如何定位參考節點的位置,結合上面的做法,可以在表中新增排序值rank字段,表示每個節點在所在層級level中的位置。注意是當前所在層級,而非樹結構整體中的位置。如果要標識當前節點在樹結構中的位置,可以采用編碼法搞定。

排序值

這里的排序值采用升序的方式,也就是小的在上面大的在下面,相當于排行榜。

使用當前層級的排序值rank重點是為了定位節點,根據排序值的大小,可以找到目標位置。接下來應該怎么做呢?思路是這個樣子的:當添加某層級節點并設置排序值后,根據排序值查找當前層級(也就是具有相同父節點的子節點)中最接近的節點,用SQL語句表達一下。

例如:獲取父級為10的子節點中,排序值與8000最接近的子節點。

SELECT * FROM nodes WHERE 1=1 AND pid=10 ORDER BY ABS(`rank`-8000) ASC LIMIT 0,1

獲取到了有什么用呢,劃重點再強調下定位,找到目標節點后,比較新節點與目標節點的排序值,根據升序排列的規則,如果新節點的排序值小于目標節點的排序值,表示新節點位于目標節點的左側或者說是前面,換種說法也就是新節點是目標節點的同輩兄弟節點之前的元素。只是在樹結構中看到的是左側,而在排序數值上看到的是上面,這些就不糾結了。反之亦然...

如果沒有最接近的子節點,那么不用說,它就是第一個節點,這個最好辦。

范例

最左側

如果同輩中沒有節點,此時會插入到子節點的最左端,此時參考點是父節點。

  • 參考節點:父節點
  • 騰出空間:所有比父節點左值大的節點的左右值均需要增加2
  • 目標節點:
    • 待插入節點的左值 = 父節點的左值 + 1
    • 待插入節點的右值 = 父節點的左值 + 2
最左端
最左側

相對左側

  • 參考節點:待插入節點排序值為2221,獲取參考點的排序值為2222,目標節點位于參考節點相對左側。
  • 騰出空間:大于等于參考字節左值的節點的左值增加2, 大于等于參考字節右值的節點的右值增加2。
  • 目標節點:目標節點左值等于參考節點左值,目標節點 右值等于參考節點右值
相對左側

相對右側

  • 參考節點:待插入節點排序值為1112,獲取參考點的排序值為1111,目標節點位于參考節點相對右側。
  • 騰出空間:大于參考節點右值的節點的左右值都增加2
  • 目標節點:目標節點左值等于參考節點右值增加1,目標節點 右值等于參考節點右值增加2.
相對右側
相對右側

插入節點的整體思路是:變更所有受影響的節點并給新節點騰出空位置

image.png
  1. 左側插入節點

有了參考點之后,首先需求獲取目標節點,然后變更所有受影響的節點。那么插入節點時哪些節點會受到影響呢?根據前置排序遍歷算法MTPP節點遍歷的路徑來看,比目標節點左值大的節點都會受到影響,受到什么樣的影響呢?這里要分兩種情況來看,第一種時目標節點前面沒有節點也就是說目標節點實際上就是頭節點,第二種情況是目標節點前面還有同輩兄弟節點。

  • 目標節點是頭節點

目標節點是頭節點,很好做,也就是插入的節點就是目標節點,目標節點后移一位。具體來說,首先目標節點及其后續節點的左值加1右值加2,新增節點左右值等于目標節點左右值。

所有左節點比目標節點大的都增加2, 所有右節點比目標節點大的都增加2,計算新節點的左右值并插入。

LOCK TABLE nodes WRITE;

SELECT @left := lft FROM nodes WHERE 1=1 AND id = 12;
UPDATE nodes SET lft = lft + 2 WHERE 1=1 AND lft > @left;
UPDATE nodes SET rgt = rgt + 2 WHERE 1=1 AND rgt > @left;
INSERT INTO nodes(name, lft, rgt) VALUES("charqui", @left+1, @left+2);

UNLOCK TABLES;
  1. 右側添加節點
右側添加節點
LOCK TABLE nodes WRITE;

SELECT @right := rgt FROM nodes WHERE 1=1 AND 'name' = 'Cherry';
UPDATE nodes SET lft = lft + 2 WHERE 1=1 AND lft > @right;
UPDATE nodes SET rgt = rgt + 2 WHERE 1=1 AND rgt > @right;
INSERT INTO nodes(name, lft, rgt) VALUES("Apple", @right+1, @right+2);

UNLOCK TABLES;

例如:每次插入節點后查看驗證

SELECT 
  CONCAT( REPEAT(" ", (COUNT(parent.name)-1)), node.name ) AS name 
FROM nodes AS node, nodes AS parent 
WHERE 1=1 
AND node.lft BETWEEN parent.lft  AND parent.rgt 
GROUP BY node.name 
ORDER BY node.lft

查詢節點

采用左右值編碼的設計方案,在進行類別樹的遍歷時只需進行兩次遍歷,消除了遞歸,加之查詢條件都是以數字進行比較,效率極高。類別樹的記錄數量越多執行效率越高。

計算某個節點的子孫節點總數,不包含自身節點。

子孫總數 = (右值 - 左值 - 1)/ 2
SELECT (rgt - lft - 1)/2 AS leaves FROM node WHERE name="database";

計算某個節點的子孫節點總數,包含自身節點。

子孫總數 = (右值 - 左值 + 1)/ 2
SELECT (rgt - lft + 1)/2 AS leaves FROM node WHERE name="database";

獲取節點在樹中所處的層數

SELECT COUNT(1) AS level FROM node WHERE 1=1 lft<=2 AND rgt>=11

獲取當前節點所在路徑

SELECT * FROM node WHERE 1=1 AND lft<=2 AND rgt>=11 ORDER BY lft ASC;

判斷是否為葉子節點,即子孫節點個數為0。

是否為葉子節點 = (右值 - 左值 - 1) / 2 < 1
SELECT * FROM node WHERE 1=1 AND (rgt - lft - 1) / 2 < 1;

判斷是否有子節點

是否有子節點 = (右值 - 左值)> 1

獲得某個節點下的所有子孫節點

要使用左右值表示的樹首先必須標識要檢索的節點

例如:獲取Database的子樹,則必須僅選擇左值在2到11之間的節點

SELECT * FROM nodes WHERE lft BETWEEN 2 AND 11 ORDER BY lft ASC

如果查詢一棵樹后要像遞歸一樣顯示這顆樹,則必須向查詢中添加排序子句。如果要從表中添加和刪除表,則表可能不會處于正確的順序。因此,需要按照左值排序。

SELECT * FROM nodes WHERE 1=1 AND lft BETWEEN 2 AND 11 ORDER BY lft ASC;

為了顯示樹狀結構,子級的縮進應該比父級多一點,應該怎么做呢?

例如:獲取節點的所有子節點數量與所屬層級

SELECT 
  a.name,
  a.lft,
  a.rgt,
  (a.rgt - a.lft - 1)/2+'' AS children,
  (SELECT COUNT(1) FROM nodes AS b WHERE 1=1 AND b.lft<a.lft AND b.rgt>a.rgt) AS level
FROM nodes AS a
ORDER BY a.lft

獲取祖先節點個數,同時也是自身層級數。

SELECT COUNT(1) AS cnt FROM node WHERE 1=1 AND lft<4 AND rgt>5

查詢所有無分支的節點:右值 = 左值 + 1

SELECT * FROM nodes WHERE 1=1 AND rgt = lft + 1;

刪除節點

刪除葉子節點

這里的刪除節點指的是葉子節點,也就是沒有下級節點的節點。

操作思路

  1. 獲取目標節點的左右值
SELECT @left := lft, @right := rgt FROM nodes WHERE 1=1 AND name = "xxx";
  1. 獲取目標節點的間距值并加1

間距值 = 目標節點右值 - 目標節點左值 + 1

@width := rgt - lft + 1
  1. 刪除目標節點
DELETE FROM nodes WHERE 1=1 AND lft BETWEEN @lft AND @rgt;
  1. 更新目標節點右側后續節點的右值

將右值大于目標節點右值的所有節點的右值減去間距值

UPDATE nodes SET rgt = rgt - @width WHERE 1=1 AND rgt > @right;
  1. 更新目標節點右側后續節點的左值

將左值大于目標節點右值的所有節點的左值減去間距值

UPDATE nodes SET lft = lft - @width WHERE 1=1 AND lft > @rgt;

完整代碼

LOCK TABLE nodes WRITE;

SELECT @left := lft, @right := rgt, @width := rgt - lft + 1 FROM nodes WHERE 1=1 AND name = "Beef";
DELETE FROM nodes WHERE 1=1 AND lft BETWEEN @lft AND @rgt;
UPDATE nodes SET rgt = rgt - @width WHERE 1=1 AND rgt > @rgt;
UPDATE nodes SET lft = lft - @width WHERE 1=1 AND lft > @rgt;

UNLOCK TABLES;
刪除葉子節點

刪除父節點

  1. 獲取目標節點左右值
SELECT @lft:=lft, @rgt:=rgt FROM nodes WHERE 1=1 AND name="xxx";
  1. 計算目標節點的左右間距值并加1
@width:= rgt - left + 1
  1. 刪除目標節點
DELETE FROM nodes WHERE lft = @lft;
  1. 更新目標節點的子孫節點的左右值,子孫節點的左右值均減去1。
UPDATE nodes SET lft = lft -1, rgt = rgt - 1 WHERE 1=1 AND lft BETWEEN @lft AND @rgt;
  1. 更新目標節點右側后續節點的右值,后續節點右值減去2。
UPDATE nodes SET rgt = rgt - 2 WHERE 1=1 AND rgt > @rgt;
  1. 更新目標節點右側后續節點的左值,后續節點左值減去2。
UPDATE nodes SET lft = lft - 2 WHERE 1=1 AND lft > @rgt;

完整代碼

LOCK TABLE nodes WRITE;

SELECT @lft:=lft, @rgt:=rgt,@width:=rgt - left + 1 FROM nodes WHERE 1=1 AND name="xxx";
DELETE FROM nodes WHERE lft = @lft;
UPDATE nodes SET lft = lft -1, rgt = rgt - 1 WHERE 1=1 AND lft BETWEEN @lft AND @rgt;
UPDATE nodes SET rgt = rgt - 2 WHERE 1=1 AND rgt > @rgt;
UPDATE nodes SET lft = lft - 2 WHERE 1=1 AND lft > @rgt;

UNLOCK TABLES; 
刪除父節點

移除父節點及其子孫節點

移除父節點表示刪除父節點以及其分支子孫節點

  1. 獲取目標節點的左右值
SELECT @lft:=lft, @rgt:=rgt FROM nodes WHERE 1=1 AND name="xxx";
  1. 計算節點間距
@width = rgt - lft + 1;
  1. 刪除父節點及其子孫節點
DELETE FROM node WHERE 1=1 AND lft BETWEEN @lft AND @rgt;
  1. 更新目標節點右側后續節點的右值
UPDATE nodes SET rgt = rgt - @width WHERE 1=1 AND rgt > @rgt;
  1. 更新目標節點右側后續節點的左值
UPDATE nodes SET lft = lft - @width WHERE 1=1 AND lft > @rgt;

完整代碼

LOCK TABLE nodes WRITE;

SELECT @lft := lft, @rgt := rgt, @width = rgt - lft + 1 FROM nodes WHERE 1=1 AND name="xxx";
DELETE FROM nodes WHERE 1=1 AND lft BETWEEN @lft AND @rgt;
UPDATE nodes SET rgt = rgt - @width WHERE 1=1 AND rgt > @rgt;
UPDATE nodes SET lft = lft - @width WHERE 1=1 AND lft > @rgt;

UNLOCK TABLES;

移動節點

移動節點中的目標節點可分為兩種情況,一種是目標節點為葉子節點即沒有子節點的節點,第二種情況是父節點即帶子節點的節點。從方位上來說,移動節點可以根據層級分為上級移動到下級、下級移動到上級。

移動節點可分為兩個步驟來完成,首先是剔除原節點,其次是添加節點。移動節點的難題在于目標節點與目標節點之外的數據如何分割的問題,一種思路是采用臨時表,使用變量替換的方式,將目標節點剔除后保存到臨時表中,然后更新原來的樹,接著將目標節點插入樹并更新節點。

優化方案

設計優點

在消除遞歸操作的前提下實現了無限極分類,由于查詢條件是基于整型數字的比較,因此效率很高。

設計缺點

節點的添加、刪除、修改代價較大,將會設計到表中多方面數據的改動。

預排序遍歷樹算法最大優勢是提升了讀的性能,但犧牲了寫的性能,而且寫的時候必須鎖表,因為新增節點時要更新大量節點的左右值。

優化方案

非連續性與排序遍歷算法是預排序遍歷算法的改進版,其目的是讓新增節點時不用更新其它節點的左右值,這樣就不會犧牲寫的性能,那么如何實現呢?

由于預排序遍歷樹算法只是對各個節點進行了順序且連續的整數型預排序,而非連續型預排序遍歷樹算法是對各個節點進行順序的非連續的實數型預排序。

在初始化根節點時,為其設置一個比較大的實數范圍,如lft=1000000 rgt=9000000,然后新增子節點時可以劃分實數范圍的一個數據段。

只需要將左右值的字段類型定義為雙精度double。當新增節點時,只需要將新節點放到空位上,并設置左右值為小數,其它節點都不用更新左右值。

未完待續...

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容