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

本文始發於我的公衆號:TechFlow,原創不易,求個關注node


今天是機器學習的第16篇文章,咱們來繼續上週KD-Tree的話題。web

若是有沒有看過上篇文章或者是最新關注的小夥伴,能夠點擊一下下方的傳送門:算法

【硬核】機器學習與數據結構的完美結合——KD-Treeubuntu

旋轉不可行分析

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

最樸素的辦法就是從新建樹,可是顯然咱們每次數據發生變更都把整棵樹重建顯然是不科學的,由於絕大多數數據是沒有變化的,而且咱們從新建樹的成本很高,若是變更稍微頻繁一些會致使大量的開銷,這明顯是不合理的。數據結構

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

咱們來看下面這張圖:機器學習

這是平衡樹當中經典的左旋操做,它旋轉先後都知足平衡樹的性質,即左子樹上全部元素小於根節點,小於右子樹上全部元素。經過旋轉操做,咱們能夠變動樹結構,可是不影響二叉搜索樹的性質。編輯器

問題是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

那麼,除了平衡樹旋轉的方法以外,還有其餘方法能夠保持樹平衡嗎?

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

替罪羊樹

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

爲何叫替罪羊樹呢,替罪羊是聖經裏的一個宗教術語,本來指的是將山羊獻祭做爲贖罪的儀式,後來才衍生出了代人受過,背鍋俠的意思。替罪羊樹的意思是一個節點的變化可能會致使某一個子樹或者是整棵樹被摧毀並重建,至關於整棵子樹充當了某一個節點的」替罪羊「。

替罪羊樹的裏很是簡單粗暴,不強制保證全部子樹徹底平衡,容許必定程度的不平衡存在。當咱們插入或者刪除使得某一棵子樹的節點超過平衡底線的時候,咱們將整棵樹拍平後重建。

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

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

拍平以後的結果是:

拍平以後重建該子樹,獲得:

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

整個原理應該很是簡單,底層的細節也只有一個,就是咱們怎麼衡量何時應該執行拍平重建的操做呢

這一點在替罪羊樹當中也很是簡單粗暴,咱們維護每一棵子樹中的節點數,而後經過一個參數alpha來控制。當它的某一棵子樹的節點數的佔比超過alpha的時候,咱們就認爲不平衡性超過了限度,須要進行拍平和重建操做了。

通常alpha的取值在0.6-0.8之間。

刪除

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

可是節點被打上標記而不是真的刪除雖然實現起來簡單,可是也有隱患,畢竟一個節點被刪除了,咱們把它留在樹上一段時間還能夠接受,一直留着顯然就有問題了。不只會佔用空間,也會給計算增長負擔

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

所謂的惰性操做通常是經過標記代替本來複雜的運算,等待之後須要的時候執行。這個所謂須要的時候能夠是之後查詢到的時候,也能夠是積累到必定閾值的時候。總之經過這樣的設計,咱們能夠簡化刪除操做,由於加上標記不會影響樹結構,因此也不用擔憂不平衡的問題。

新增和修改

對於KD-Tree的常規實現來講,修改和新增是一回事,由於咱們會經過刪除新增來代替修改。這麼作的緣由也很簡單,由於修改某一個節點的數據可能會影響整個樹結構,尤爲是KD-Tree中的數據是多維的,因此咱們是不能隨意修改一個節點的

實際上不僅是KD-Tree如此,不少平衡樹都不支持修改,好比咱們以前介紹過的LSMT就不支持。固然不支持的緣由多種多樣,本質上來講都是由於性價比過低。

咱們再來看新增操做,二叉搜索樹的純新增操做實際上是很簡單的,咱們只須要遍歷樹找到能夠插入的位置便可。KD-Tree當中的新增也是如此,雖然KD-Tree當中是多個維度,可是查找節點的邏輯和以前相差並不大。咱們就順着樹結構遍歷,找到須要插入的葉子節點便可。因爲咱們使用替罪羊樹的原理來維護樹的平衡,因此咱們在插入的是時候也須要維護子樹當中節點的數量,以及會不會出發拍平操做。

若是存在子樹違反了平衡條件,咱們須要找到最上層的知足拍平條件的子樹來進行拍平,不然的話底層的子樹平衡了,可是上層的子樹可能仍然須要拍平。注意這兩個細節便可,其餘的原理和普通的二叉樹插入節點一致。

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

def _insert_data(self, node, data):
    # 子樹節點的數量+1
    node.size += 1
    axis = node.axis
    new_axis = (axis + 1) % self.K
    flat = False
    # 當前節點的判斷條件
    # 小於等於則進入左子樹,不然進入右子樹
    if data[axis] <= node.boundray:
        # 若是子節點爲空,說明已經到葉子節點,建立新節點
        if node.lchild is None:
            new_node = KDTree.Node(
                data[new_axis], data, new_axis, node.depth + 11NoneNone)
            new_node.father = node
            node.lchild = new_node
        else:
            # 遞歸
            self._insert_data(node.lchild, data)
            # 回溯的時候判斷是否引起樹不平衡
            if node.lchild.size >= self.alpha * node.size:
                self.rebuildNode = node
    else:
        # 邏輯同上,找到葉子節點,回溯的時候判斷是否不平衡
        if node.rchild is None:
            new_node = KDTree.Node(
                data[new_axis], data, new_axis, node.depth + 11NoneNone)
            new_node.father = node
            node.rchild = new_node
        else:
            self._insert_data(node.rchild, data)
            if node.rchild.size >= self.alpha * node.size:
                self.rebuildNode = node

咱們再來看下拍平的邏輯,拍平其實就是拿到子樹當中全部的節點。若是是二叉搜索樹,咱們能夠經過中序遍歷保證元素的有序性,可是在KD-Tree當中,元素的維度太多,再加上存在被刪除的節點,因此有序性沒法保證,因此咱們能夠忽略這點,拿到全部數據便可。

def flat_data(self, node, data):
    if node is None:
        return
    # 跳過刪除元素
    if not node.deleted:
        data.append(node.value)
    self.flat_data(node.lchild, data)
    self.flat_data(node.rchild, data)

拿到全部數據以後也簡單,咱們只須要調用以前的建樹函數,得到一棵新子樹,而後將新子樹插回到原樹上對應的位置。

def rebuild(self):
    data = []
    # 拍平以rebuildNode節點爲根的子樹
    node = self.rebuildNode
    if node is None:
        return
    # 拿到全部數據
    self.flat_data(node, data)
    # 塞回到父節點當中去代替舊子樹
    father = node.father
    if father is None:
        # 若是父節點爲空說明是整棵樹重建了
        self.root = self._build_model(data, node.depth)
        self.set_father(self.root, None)
    else:
        # 判斷是左孩子仍是右孩子
        position = 'left' if node == father.lchild else 'right'
        node = self._build_model(data, node.depth)
        if position == 'left':
            father.lchild = node
        else:
            father.rchild = node
        self.set_father(node, father)

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

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

總結

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

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

最後,我把完整的代碼放在ubuntu.paste當中,在公衆號裏回覆'kd-tree2',我把完整代碼發給你,和你一塊兒學習。

若是你也這麼以爲,請順手點個關注或者轉發吧,大家的舉手之勞對我來講很重要。

相關文章
相關標籤/搜索