承接上文,探討kd二叉查詢樹的平衡、刪除改進以及運用。
無論是普通的二叉查詢樹還是kd二叉查詢樹,頻繁的新增以及刪除操作都可能破壞整棵樹的平衡,怎麼辦呢?對於普通的二叉查詢樹,可以通過DSW演算法或者AVL演算法進行平衡,相關內容可以看這裡,資料結構與演算法-二叉查詢樹(DSW)和資料結構與演算法-二叉查詢樹(AVL)。以上演算法的核心都是旋轉,通過旋轉來調整左右子樹的高度來平衡樹,但是旋轉對於kd二叉樹是不合適的,因為kd二叉樹不同層次之間比較的維度是不同的。比如說存在節點P,比較的維度是x吧,那麼節點P的左子樹中任意節點的x維度的值小於P節點x維度值,右子樹中任意節點的x維度的值大於P節點x維度值。如果將P節點調高或者降低層次,那麼P節點和左右子樹比較的維度就會改變,假如是y吧。不能保證節點P的左子樹中任意節點的y維度的值小於P節點y維度值,右子樹中任意節點的y維度的值大於P節點y維度值。所以,旋轉不能用於kd二叉樹。
那麼,該怎麼平衡一顆kd二叉查詢樹呢?
答案是刪除原有的樹,重新建立一顆平衡的kd二叉查詢樹。這種方法非常暴力,如果用於生產環境,必須選擇伺服器壓力較小的一個時間點來重新平衡樹。並且,要控制重新建立kd樹的頻率,這個頻率需要在生產環境中,根據具體的資料量來調整。
接下來探討改進刪除演算法。刪除演算法的效率不高,原因在於需要不停的遍歷被刪除節點下的某顆子樹,不僅耗費時間而且浪費計算資源。怎麼改進呢?
有一種叫做替罪羊的演算法可以用到這裡。就是說,每次刪除節點的時候,不是真正的刪除,而是做個標記表明這個節點已刪除,這樣就不會影響kd樹的平衡。但是被標記的節點太多也不好,怎麼處理呢?可以在每次刪除的時候進行檢查,統計被標記節點在整棵樹中的比例,如果比例大於閾值,就重新平衡樹。刪除掉被標記的節點,使用剩餘節點建立新的平衡的kd二叉樹。由於重新建立kd樹是由某個節點刪除引起的,但是顯然責任不是它一個節點的,它就成了替罪羊,因此,演算法就以此命名了。替罪羊演算法效率還是可以的,犧牲較小的查詢效率來獲取整棵樹的平衡是可取的,生產環境可以考慮使用。
下面來探討kd二叉查詢樹的運用。
- 查詢符合特定範圍的節點
假設我們有一系列點處於k維空間中,現在有個需求,需要知道符合x1<x<xn,y1<y<yn,...,k1<k<kn的節點有哪些。如果沒有kd二叉樹,只能通過遍歷篩選出符合要求的節點,顯然,這種方式效率不高。kd二叉樹可以幫助我們跳過一些節點來提高查詢效率。首先用自然語言描述演算法邏輯:
假設存在節點P,節點P所處層次需要比較維度x,首先判斷x(P)處於x1<x<xn的哪個範圍。如果x1<x(P)<xn,顯然,需要遍歷P節點的所有子樹,如果x(P)<x1,那麼只需要遍歷P節點的右子樹,如果x(P)>xn,那麼只需要遍歷P節點的左子樹即可。
虛擬碼如下:
searchRange(range[][]){
if root != 0
search(root, 0, range);
}
search(p, i, range[][]){
found = true;
for j = 0 到 k - 1
if !(range[j][0] <= p-el.keys[j] <= range[j][1])
found = false;
bread;
if found
輸出 p->el;
if p->left != 0 && range[i][1] <= p->el.keys[i]
search(p->left, (i + 1) mod k, range);
if p->right != 0 && range[i][0] >= p->el.keys[i]
search(p->right, (i + 1) mod k, range);
}複製程式碼
使用kd二叉樹查詢特定範圍的節點相比於直接遍歷要快的多。
- 實現kNN演算法
kNN的全稱是k-Nearest-Neighbor,k個最近鄰點。也就是說,假設現在有n個m維空間的點,給定點P,需要找到與定點P最靠近的k個點,這就是kNN演算法。這裡距離的求法用的是歐式距離:
最直觀的演算法邏輯是遍歷n個節點,計算出每個節點與定點P的距離,然後排序,獲取距離最小的k個鄰點。演算法的時間複雜度是0(n),還可以接受,但是太佔用計算資源了,每兩個節點距離的計算需要n次減法,n次平方,不僅僅佔用大量時間,還會佔用大量計算資源,得不償失。
當前有很多kNN解決方案,我們來探討基於kd二叉樹的kNN方案。
在探討之前,我們需要理解kd二叉樹的幾何意義,就從一維kd二叉樹開始。
當k為1時,kd二叉樹就退化為普通的二叉查詢樹,我們可以將二叉樹上的節點想象成橫座標上的定點。就像這樣:
橫座標被分解成8個部分,如果給出定點P,那麼該定點最終會落入這8個部分之一。
當k為2時,kd二叉樹中的節點可以想象成座標系上的點,就像這樣:
其中每個點都代表著一部分割槽域,首先A節點代表整個矩形,G節點代表被A分開的左邊的矩形,B節點代表被A分開的右邊的矩形,以此類推,每個節點代表著某塊區域的同時將該區域分割成兩部分,分別被左右子樹佔據。
也就是說,如果給出一個定點P,那麼P必然落入以上8個區域之一。
在kd二叉樹上實現kNN演算法之所以效率較高,是因為kd二叉樹可以將k維空間分割成m個區域,給出的查詢定點必然落入某個區域之中,該區域必然被某個節點P的左子樹或者右子樹佔據,假設定點落入了左子樹中,我們可以計算出在該區域中距離定點最近的節點,假設距離為l1。下面是核心邏輯,我們已經獲取了節點P左子樹中距離定點最近的節點,那麼節點P的右子樹有必要遍歷嗎?假設節點P比較的是x維度的值,我們可以計算出定點和x=x(P)超平面之間的距離l2。如果l1<l2,那麼就沒必要遍歷右子樹了,因為定點距離右子樹中節點的距離只會比l2更大,也就是說l1肯定是最小的距離。如果l1>l2,那麼就有必要遍歷右子樹,因為右子樹中可能存在距離定點更近的節點。到目前為止,我們已經獲取了節點P所代表的的子樹中距離定點最近的節點,下一步怎麼走?我們知道,節點P必然是某個節點R的左子樹或者右子樹,假設是左子樹吧,那麼問題來了,節點R的右子樹需要遍歷嗎?這就回到了上面的問題,你會發現,通過不斷回溯父節點,我們可以不斷的跳過某些子樹,最終整棵樹裡的節點全部遍歷完畢。因為kd樹在查詢距離定點最近節點時,可以跳過很多子樹,因此效率會大大提高。
下面用自然語言描述kNN演算法:
- 給出定點P,首先獲取定點P落入了哪塊區域,假設是區域Q吧,計算出定點P和區域Q中節點最小的距離l1
- 回溯到父節點R,計算定點P和節點R之間的距離l2,取l1和l2之間的最小值為新的l1
- 假設節點R比較的是x維度的值,計算定點P和x=x(p)超平面之間的距離l3
- 如果l1<l3,跳過R節點的另一顆子樹,繼續回溯到R節點的父節點,如果R節點為Root,遍歷結束,否則從第二步開始
- 如果l1>l3,遍歷R節點的另一顆子樹,獲取定點和該子樹中節點最小的距離l4
- 取l1和l4之間的最小值為新的l1,當前,R節點所在子樹已經遍歷完畢,那就繼續回溯到R節點的父節點,如果R節點為Root,遍歷結束,否則從第二步開始
演算法中,也許有人會疑惑l4的大小如何獲取,也就是說,不知道如何遍歷R節點的另一顆子樹。答案是使用遞迴,因為R節點的子樹也是一顆kd二叉樹,我們對該子樹使用遞迴即可獲取l4的大小。
到目前為止,我們已經探討了kd二叉樹的平衡,刪除演算法改進,輸出符合特定範圍的節點以及最後的kNN演算法。kd二叉樹還有很多細節需要去理解,這些需要讀者在實踐中獲取了。