【硬核】使用替罪羊樹實現KD-Tree的增刪改查

今天是機器學習的第16篇

文章,我們來繼續上週KD-Tree的話題。


如果有沒有看過上篇文章或者是最新關注的小夥伴,可以點擊一下下方的傳送門:

機器學習——詳解KD-Tree來龍去脈


旋轉不可行分析


上週我們實現了KD-Tree建樹和查詢的核心功能,然後我們留了一個問題,如果我們KD-Tree的數據集發生變化,應該怎麼辦呢?


最樸素的辦法就是重新建樹,但是顯然我們每次數據發生變動都把整棵樹重建顯然是不科學的,因為絕大多數數據是沒有變化的,並且我們重新建樹的成本很高,如果變動稍微頻繁一些會導致大量的開銷,這明顯是不合理的。


另一個思路是借鑑平衡樹,比如AVL或者是紅黑樹等樹結構。在這些樹結構當中,當我們新增或者是刪除節點導致樹發生不平衡的情況時,平衡樹會進行旋轉操作在不改變二叉搜索樹性質的前提下維護樹的平衡。看起來這是一個比較好的方法,但是遺憾的是,這並不太可行。因為KD-Tree和二叉搜索樹不同,KD-Tree中的節點存儲的元素都是高維的。每一棵子樹的衡量的維度都不同,這會使得旋轉操作變得非常麻煩,甚至是不可行的。


我們來看下面這張圖:

【硬核】使用替罪羊樹實現KD-Tree的增刪改查

這是平衡樹當中經典的左旋操作,它旋轉前後都滿足平衡樹的性質,即左子樹上所有元素小於根節點,小於右子樹上所有元素。通過旋轉操作,我們可以變更樹結構,但是不影響二叉搜索樹的性質。


問題是KD-Tree當中我們在不同深度判斷元素大小的維度不同,我們旋轉之後節點的樹深會發生變化,會導致判斷標準發生變化。這樣會導致旋轉之後不再滿足KD-Tree的性質。


我們用剛才的圖舉個例子:

【硬核】使用替罪羊樹實現KD-Tree的增刪改查

我們給每個節點標上了數據,在樹深為0的節點當中,劃分維度是0,樹深為1的節點劃分維度是1。當我們旋轉之後,很明顯可以發現KD-Tree的性質被打破了。


比如D節點的第0維是2,B節點是1,但是D卻放在了B的左子樹。再比如A節點的第1維是3,E節點的第1維是7,但是E同樣放在了A的左子樹。


這還只是二維的KD-Tree,如果維度更高,會導致情況更加複雜。


通過這個例子,我們證明了平衡樹旋轉的方式不適合KD-Tree。那麼,除了平衡樹旋轉的方法之外,還有其他方法可以保持樹平衡嗎?


別說,還真有,這也是本篇文章的正主——替罪羊樹。


替罪羊樹


替罪羊樹其實也是平衡二叉樹,但是它和普通的平衡二叉樹不同,它維護平衡的方式不是旋轉,而是重建


為什麼叫替罪羊樹呢,替罪羊是聖經裡的一個宗教術語,原本指的是將山羊獻祭作為贖罪的儀式,後來才衍生出了代人受過,背鍋俠的意思。替罪羊樹的意思是一個節點的變化可能會導致某一個子樹或者是整棵樹被摧毀並重建,相當於整棵子樹充當了某一個節點的”替罪羊“。


替罪羊樹的裡非常簡單粗暴,不強制保證所有子樹完全平衡,允許一定程度的不平衡存在。當我們插入或者刪除使得某一棵子樹的節點超過平衡底線的時候,我們將整棵樹拍平後重建。


比如下圖紅框當中表示一棵不平衡的子樹:

【硬核】使用替罪羊樹實現KD-Tree的增刪改查

很明顯,它不平衡地十分嚴重,超過了我們的底線。於是我們將整棵子樹拍平,拍平的意思是將子樹當中所有的元素全部取出,然後重建該樹。


拍平之後的結果是:

【硬核】使用替罪羊樹實現KD-Tree的增刪改查

拍平之後重建該子樹,得到:

【硬核】使用替罪羊樹實現KD-Tree的增刪改查

我們把重建的這棵子樹插回到原樹上,代替之前不平衡的部分,這樣就保證了樹的平衡。


整個原理應該非常簡單,底層的細節也只有一個,就是我們怎麼衡量什麼時候應該執行拍平重建的操作呢


這一點在替罪羊樹當中也非常簡單粗暴,我們維護每一棵子樹中的節點數,然後通過一個參數alpha來控制。當它的某一棵子樹的節點數的佔比超過alpha的時候,我們就認為不平衡性超過了限度,需要進行拍平和重建操作了。


一般alpha的取值在0.6-0.8之間。


刪除


在替罪羊樹當中刪除節點有很多種方法,但是大都大同小異,核心的思想是我們刪除節點並不是真的刪除,而是給節點打上標記,標記這個節點在查詢的時候不會被考慮進去。


但是節點被打上標記而不是真的刪除雖然實現起來簡單,但是也有隱患,畢竟一個節點被刪除了,我們把它留在樹上一段時間還可以接受,一直留著顯然就有問題了。

不僅會佔用空間,也會給計算增加負擔


針對這種情況,也有幾種不同的解決策略。一種策略是不用理會,等待某一次插入的時候發現樹不平衡,進行拍平重構的時候將已經刪除的節點移除。另一種策略是我們也刪除設置一個參數,當某棵子樹上被刪除的元素的比例超過這個閾值的時候,我們也同樣進行子樹的拍平重建。但是不論選擇哪一種,本質上來說都是惰性操作


所謂的惰性操作一般是通過標記代替原本複雜的運算,等待以後需要的時候執行。這個所謂需要的時候可以是以後查詢到的時候,也可以是積累到一定閾值的時候。總之通過這樣的設計,我們可以簡化刪除操作,因為加上標記不會影響樹結構,所以也不用擔心不平衡的問題。


新增和修改


對於KD-Tree的常規實現來說,修改和新增是一回事,因為我們會通過刪除新增來代替修改。這麼做的原因也很簡單,因為修改某一個節點的數據可能會影響整個樹結構,尤其是KD-Tree中的數據是多維的,所以我們是不能隨意修改一個節點的


實際上不只是KD-Tree如此,很多平衡樹都不支持修改,比如我們之前介紹過的LSMT就不支持。當然不支持的原因多種多樣,本質上來說都是因為性價比太低。


我們再來看新增操作,二叉搜索樹的純新增操作其實是很簡單的,我們只需要遍歷樹找到可以插入的位置即可。KD-Tree當中的新增也是如此,雖然KD-Tree當中是多個維度,但是查找節點的邏輯和之前相差並不大。我們就順著樹結構遍歷,找到需要插入的葉子節點即可。由於我們使用替罪羊樹的原理來維護樹的平衡,所以我們在插入的是時候也需要維護子樹當中節點的數量,以及會不會出發拍平操作。


如果存在子樹違反了平衡條件,我們需要找到最上層的滿足拍平條件的子樹來進行拍平,否則的話底層的子樹平衡了,但是上層的子樹可能仍然需要拍平。注意這兩個細節即可,其他的原理和普通的二叉樹插入節點一致。


我們來看下代碼,尋找更多細節:


【硬核】使用替罪羊樹實現KD-Tree的增刪改查


我們再來看下拍平的邏輯,拍平其實就是拿到子樹當中所有的節點。如果是二叉搜索樹,我們可以通過中序遍歷保證元素的有序性,但是在KD-Tree當中,元素的維度太多,再加上存在被刪除的節點,所以有序性無法保證,所以我們可以忽略這點,拿到所有數據即可。


【硬核】使用替罪羊樹實現KD-Tree的增刪改查


拿到所有數據之後也簡單,我們只需要調用之前的建樹函數,獲得一棵新子樹,然後將新子樹插回到原樹上對應的位置。


【硬核】使用替罪羊樹實現KD-Tree的增刪改查


這樣一來,我們帶增刪改查功能的KD-Tree就實現好了。到這裡,我們還有一個問題沒有解決,就是

複雜度的問題。


這樣做看起來可行,真的複雜度會降低嗎?很遺憾,這個問題涉及到非常複雜的數學證明,我暫時還沒有找到靠譜的證明過程,但是可以肯定的是,雖然我們每一次重建樹都需要nlogn次計算,但是並不是每一次插入和刪除都會引發重建。如果假設發生大量操作的話,那麼我們拍平重建的計算會分攤到每一次查詢上,分攤之後可以得到 logN 級別的插入和刪除。實際上分攤的思路非常常見,像是紅黑樹也是利用了分攤操作。


總結


到這裡關於替罪羊樹在KD-Tree的應用就結束了,雖然這是一個全新的數據結構,並且和比較困難的平衡樹有關,但其實核心的思路並不困難,非但不困難,而且有些過於簡單了,但是效果卻又如此神奇,能解決一個如此棘手的問題,不得不說算法的魅力實在是無窮。


另外,網絡上絕大多數關於KD-Tree的博客都只有建樹和查詢的部分,雖然實際場景當中,這也基本上足夠了。但是我個人覺得,學習的過程應該是飽和式的,不能僅僅停留在夠用上。畢竟我們努力保持學習的目的,並不只是為了讓這些知識派上用場,更是為了可以擁有更強的能力,成為一個更優秀的人。


如果你也這麼覺得,請順手點個關注或者轉發吧,你們的舉手之勞對我來說很重要。


分享到:


相關文章: