iOS重做輪子,寫一個NSDictionary(二)

前言: 樹結構是一種很常見的數據結構,比如我們的文件目錄,數據庫的索引,以及我們現在將要講述的字典。在第一篇文章中(傳送門:http://www.lxweimin.com/p/577d5e878c4f),我們用hashtable實現了一個字典,這種結構的字典也是 apple NSDictionary 或 CFDictionary 所采用的方案。但是,c++ 的 STL 中 map、 linux 的內核調度的底層實現是一棵紅黑樹。理想情況下,hashtable 的查找插入時間復雜的可達O(1),但是這需要花費額外的內存空間,也就是說需要更多的桶,紅黑樹不需要占用額外的空間,更省空間。

二叉搜索樹 BST (binary search tree)

在實現我們的輪子之前,我們先回顧一下關于二叉樹的相關概念。二叉樹是一個節點最多只有兩個子樹的樹型結構,也就是說節點會有兩個分叉,所以叫二叉樹。二叉搜索樹 BST 是二叉樹的一種,區別于二叉樹是它是有序的。只要按中序遍歷,就能按順序打印數據。二叉搜索樹如果遇到有序輸入,它就會退化成鏈表(好慘),這叫樹的失衡。為了解決這個問題,各種二叉樹的變種,平衡樹就出現了,例如avl樹,紅黑樹等。

我們先看看BST構建,構建一個BST最主要的是插入操作
看偽代碼:
定義簡單的二叉樹結構

typedef struct _Node {
  keyType key;
  _Node *left;
  _Node *right;
} *NodeRef; 
NodeRef insert(NodeRef n, key) {
  if (NULL == n)  {
    //如果節點是空,創建節點
    NodeRef node = new Node
    node.key = key;
    return node;
  }
  if (key == n.key) return n;
  if (key < n.key) { // 往節點的左子樹插入
    n.left = insert(n.left, key);
  } else {  // 往節點的右子樹插入
    n.right = insert(n.right, key);
  }
  return n;
}

刪除節點

刪除節點的操作分三種情況:
第一種情況,節點沒有孩子,這時候直接刪除。
第二種情況,如果只有一個孩子,那么讓節點的孩子頂替節點的位置,刪除節點。
第三種情況,節點有兩個孩子,這時候有兩個選擇,1)尋找節點左孩子的最大葉子,用來頂替被刪除節點。2)尋找節點右孩子的最小節點,用來頂替被刪除節點。

我們用右孩子的最小葉子來頂替被刪除節點。

NodeRef delete(NodeRef n, key) {
  if (!n) return NULL;
  if (key < n.key) {
    n.left = delete(n.left, key);
  } else if (key > n.key) {
    n.right = delete(n.right, key);  
  } else {
    if (n.left && n.right) {
      NodeRef tmp = findMin(n.right);
      n.key = tmp.key;
      n.right = delete(n.right, tmp.key);
    } else {
      n = n.left ?  n.left :  n.right;
    }
  }
  return n;
}

2-3樹

什么是紅黑樹?對于這個問題,筆者在剛開始接觸紅黑樹的時候也很疑惑,代碼怎么體現紅和黑呢?到底紅節點和黑節點有什么作用?為什么它可以保持樹的平衡呢?各種旋轉變色操作讓人看的頭昏眼花。其實,只要我們了解紅黑樹的來源,就能發現紅黑樹也不是那么抽象,其實紅黑樹是來源于2-3樹。讓我們稍微了解一下2-3-樹。2-3樹的定義,可以參考維基百科(https://zh.wikipedia.org/wiki/2-3樹 )。在這里討論2-3樹,那么文章的篇幅未免太長,但是如果不談論,那么這篇文章就不能算完整。猶豫再三,筆者還是先從2-3樹說起。如果讀者了解2-3樹,則可以略過。

2-3樹是多叉樹,一個節點可以有2個分叉,或者3個分叉。他還能臨時有4個分叉。
每種節點如下圖


image.png

我是2節點,我有2只手,指向空葉子null

image.png

我是3節點,我有3只手,指向空葉子null

image.png

我是4節點,我有4只手,指向空葉子null

對于2-3樹來說,4節點是臨時的,很快它就要分裂成2節點或3節點。稍后很快會看到它是怎么分裂的。

理解一棵樹最好的方法就是觀察它是怎么長出來的,或者怎么生成的。
我們試著用A D C E K J B M 依次插入一個空樹中,看看它的生長過程。
(簡便起見,空葉子不畫出來。本來是想用PS畫的,但是太慢了,還不如手寫,不是很好看,大家將就點了??。)

插入 A


image.png

生成一個2節點的樹,同時它也是樹根

插入 D

image.png

如圖,這時候2節點變成3節點了

插入 C

image.png

3節點變成節點,我們看到,在節點內,元素都是按從小到大的順序放置的,這就保持了樹是有序的。上面說到,4節點是臨時的,下一步它就要分裂了,繼續看
image.png

C 元素從4節點提取出來成為新的樹根,也可以叫父節點,它有兩個孩子A 和 D 。為什么要把 C 提取出來作為父節點而不是 A 或 D 呢。因為這是一顆搜索樹,把中間的 C 提取出來樹才能保持有序。看看現在它是不是很像二叉樹啊~

插入 E
image.png

這時候的插入,如同二叉樹,從根節點 C 開始尋找, E 比 C 大,所以從 C 的右子樹繼續尋找,找到 D 節點,這時候樹往下沒有子節點了,那么 E 就放在和 D 同一個節點,并且在 D 的右邊。如圖,D E 組成一個新的3節點。

插入 K


image.png

因為K比C大,所以往右子樹查找,K 比 D E 大,再往下是空葉子了,所以K停留 在 E 的右邊,D E K 它們一起組成了4節點。又因為 這是 2-3樹,4節點是臨時的,所以又要分裂,如下圖:

image.png

對照前一張圖,我們把4節點(包含D E K)分裂,跟前面一樣,提取 E 出來 和父節點(2節點C)合并成為3節點(C E) ,剩下的 D K 各自成為一個2節點 ,父節點的第二只手指向 D ,第三只手指向 K。這就恢復了2-3樹的特點。

插入 J

image.png

繼續上面的方式,J 比 K 小,所以放在 K 的左邊

插入 B


image.png

B 比 C 小 ,往左子樹找,找到 A ,A 是2節點,所以 B 可以和 A 安心的放在一起。

插入 M


image.png

M 要停留在 (J K) 節點, M 比 K大,放 K 右邊。這個時候 J K M 組成了4節點,老規矩他們要分裂。


image.png

K 提取出來 和 它的父節點 合并 組成了 (C E K ) 4節點。
這個時候 ,新的父節點是4節點,不符合2-3樹的性質,他必須分裂。E 被提取出來,成為新的樹根。
C 和 K 成為 E 的子節點。,如下


image.png

可以看到,經過這次分裂,整棵樹長高了一層了。

以上就是2-3樹的生成。了解了2-3樹,我們繼續看紅黑樹

紅黑樹
紅黑樹是被加入了幾個限定條件的二叉樹。這個限定條件的來源其實就是2-3樹。實際情況中,2-3樹編碼實現很麻煩,人們就另辟蹊徑,用二叉樹模擬2-3樹的性質,紅黑樹就粉墨登場,嘿,閃亮登場了。這就是為什么在說紅黑樹前,我們先去了解2-3樹的原因。
先看看紅黑樹都有什么限定條件:(來源百度)
性質1. 節點是紅色或黑色。
性質2. 根節點是黑色。
性質3 每個葉節點(NIL節點,空節點)是黑色的。
性質4 每個紅色節點的兩個子節點都是黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)
性質5. 從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。

解析一下這是什么意思:
性質1:紅黑樹的節點,不是紅色,就是黑色。沒有其它什么綠色白色紫色之類的。嗯,很單純,設計經典紅黑搭配。
性質2:就是說樹根是黑色,或者說樹頭是黑的。很合理不是嗎,嘿嘿,樹頭嘛,能不黑嗎?
性質3:樹葉是黑色的,就是說沒數據的nil節點,也就是空指針,是黑色的。注意,樹葉不是綠色的,啥?!
性質4:也就是說一個節點是紅色,那么它的子節點(離它最近的)必須是黑色。
性質5:(略)

那么,問題來了,這紅黑的節點的區分是有什么意義呢?(其實,撿垃圾只是我的表面,我的真實身份是一名研究生。。。。。。),把節點標記成紅色只是它表面,它的真實意思是:我(紅色節點)和我老爸(父節點)是一伙的,分不開的。紅色節點有如在自身和父節點之間連上了一條紅色的鏈條,我們應當把它們看作一個整體,這就等同于2-3樹的3節點。
如圖:


image.png

紅線連起來的兩個2節點應該看作一個整體,他們是不可分的3節點。這就是紅節點的意義。
說了這么多有什么用。。。寫點代碼吧。
我們試著實現一棵左傾紅黑樹,為什么叫左傾,因為紅鏈都在左邊,就是上圖紅色那根線。左傾比較容易實現......
相比而言,左傾紅黑樹多了兩條性質:
1、紅鏈接均為左鏈接。
2、沒有任何一個結點同時和兩條紅鏈接相連。

左傾是對三節點而言的


image.png

通過上圖可以看到,紅鏈在左邊,4節點是節點的兩個孩子都是紅色的,這是臨時節點,最后要變成左傾的。
再看看紅黑樹和2-3樹的對比


image.png

左邊的樹根(C E)是一個3節點,用紅黑樹表示就是C E 之間有一根
紅黑樹,先給它來點顏色瞧瞧,不要以為是UIColor啊~~~

typedef NS_ENUM(NSUInteger, Color) {
    RED,
    BLACK,
};

呵呵,這就是顏色了,,???,,
接下來我們在定義紅黑樹的節點Node

@interface Node : NSObject
@property(nonatomic, strong) NSString *key;
@property(nonatomic, strong) NSString *value;
@property(nonatomic, assign) Color color;
@property(nonatomic, strong) Node *left;
@property(nonatomic, strong) Node *right; 

+ (instancetype) nodeWidthKey:(id)key value:(id)value;
@end

@implementation Node
+ (instancetype) nodeWidthKey:(NSString *)key value:(NSString *)value {
    Node *n = [Node new];
    n.key = key;
    n.value = value;
    n.color = RED;
    return n;
}
@end

在標準的二叉樹定義下,多了一個color屬性,還包括了一個生成 Node 節點的類方法,這個方法簡單的對成員變量賦值。注意這里有個約定:新創建的節點一定是紅色的。切記這一點。
現在看整棵紅黑樹樹的定義

@interface LLRB : NSObject
@property(nonatomic, strong) Node *root;
- (void) insert:(Node *)n;
- (void) remove:(NSString *)key;
@end

很簡單,只有一個root根節點,一個插入節點的方法insert和一個刪除節點的方法remove。
普遍的,我們先來看看紅黑樹是怎么生成的,再來看如何刪除其中某個節點。
樹生成是通過插入操作實現的。
我們先定義一下工具方法

BOOL isRed(Node *n) {
    if (!n) return NO;
    return n.color == RED;
}

int compareTo(NSString *k1, NSString *k2) {
    return [k1 compare:k2];
}

第一個方法判斷節點是否紅色,第二個是key 的比較方法。
無論我們多么小心翼翼,再插入節點的過程中,總會出現違反紅黑樹性質的情形。下面我們要分析的出現的各種情形以及用什么方法修正。

情形1


image.png

解析: 這圖左邊是一個父節點,插入一個節點在父節點的右邊。新插入的節點總是紅色的,因為我們的是左傾,這里變成右傾,不合法,我們通過左旋操作修復。


image.png

可以看到,F節點的右子樹不再指向Q,而是指向Q的左子樹,并將F 設置為 Q 的左子樹。這就完成了左旋操作,上面是java的代碼,對應 oc 的代碼如下:

Node* rotateLeft(Node *h) {
    Node *x = h.right;
    h.right = x.left;
    x.left = h;
    x.color = x.left.color;
    x.left.color = RED;
    return n;
}

h 對應上面的 F 節點, x 對應上面的 Q 節點。

情形2


image.png

如上圖的,在左鏈下面的右子樹插入了一個節點,又變成了右傾了,那么我們先用左旋操作修復了右傾。這時候又出現了一個情形,同一條路徑有兩個連續紅節點,這是不合法的。我們這時候用右旋操作把上面一條紅鏈移到右邊。


image.png

右旋就是左旋的鏡像,下面直接給出oc代碼了

Node* rotateRight(Node *h) {
    Node *n = h.left;
    h.left = n.right;
    n.right = h;
    n.color = n.right.color;
    n.right.color = RED;
    return n;
}

這時候的樣子是這樣的


image.png

情形3
這時候的請況父節點左右兩邊都是紅鏈了,左傾右傾都有了。這可怎么辦???我們總不能再旋轉了吧。
嗯~~,等等,我們還可以操作顏色對不,只要我們把兩個紅節點涂黑,把父節點涂紅,不就決解問題了嗎!這不會違反紅黑樹的性質(在同一條路徑上黑色節點的數量一樣)。這叫顏色的翻轉


image.png

圖上的代碼是錯的,把參數 h 錯寫成 x 了,也不需要返回值,不過圖不錯,我們拿來用了,?。對應的oc代碼是:

void rbcolorFlip(Node *n) {
    n.color = !n.color;
    n.left.color = !n.left.color;
    n.right.color = !n.right.color;
}

好了,需要考慮的也就上面三種情形,并且我們都解決了,現在我們可以寫紅黑樹的插入了。

Node* insert(Node *h, Node *aNode) {
    if (!h) return aNode;  //1

    int cmp = compareTo(aNode.key, h.key);  //2
    if (cmp == 0) h.value = aNode.value;  //3
    else if (cmp < 0)  //4
        h.left = insert(h.left, aNode);  //5
    else  //6
        h.right = insert(h.right, aNode);  //7
    
    if (isRed(h.right) && !isRed(h.left))  //8
        h = rotateLeft(h);  //9
    if (isRed(h.left) && isRed(h.left.left))  //10
        h = rotateRight(h);  //11
    if (isRed(h.left) && isRed(h.right))  //12
        rbcolorFlip(h);  //13
    return h;  //14
}

代碼很少啊,嗯~,看看都做了什么事。我們逐行討論一下。
第1行,insert 函數傳入的是根節點,如果根節點為空,證明是一顆空的樹,我們直接返回 aNode 作為樹根。
接著比較aNode 的key 和 h 的key ,相等的話最簡單,直接將 h 的 value 替換成 aNode 的 value。如果新插入的key 比 h 的key 小,那么往 h 的左子樹插入,否則往 h 的右子樹插入。
從第8行開始,我們對應著之前分析的插入會導致左傾紅黑樹性質被破壞的情形一個個修復。
1、如果h 右孩子是紅的,左孩子是黑的,對應情形1, 執行左旋操作修復。
2、如果h 的左孩子是紅,h 的左孩子的左孩子也是紅,說明一條路徑上有兩個連續的紅色節點,對應情形2,執行右旋操作修復。
3、上面的分析我們知道,修復情形2會導致情形3,那么我們用顏色翻轉去修復左傾紅黑樹的性質。

以上,我們實現了紅黑樹的插入。繼續之前,我們把上面的的第八行開始的代碼抽出來成為一個fixUp方法,因為刪除也要用到

Node* fixUp(Node *h)
{
    if (isRed(h.right) && !isRed(h.left))
        h = rotateLeft(h);
    if (isRed(h.left) && isRed(h.left.left))
        h = rotateRight(h);
    if (isRed(h.left) && isRed(h.right))
        rbcolorFlip(h);
    return h;
}

紅黑樹的插入操作 oc 代碼這樣調用

- (void)insert:(Node *)node {
    self.root = insert(self.root, node);
    self.root.color = BLACK;
}

呼,能看到這里,列為看官也算給俺面子了(也說明我寫的還不算胡扯,??),要不要歇會。。。

----------不要慫,不要停,繼續干--------------

我們 go on 把紅黑樹的刪除操作也擼完,怎么樣?這主意看起來 不咋地。。。

左傾紅黑樹的刪除(最復雜的)

跟普通二叉搜索樹(下面稱二叉樹)的刪除一樣,因為是左傾的,所以我們盡量刪除右子樹的最小元素。方法如下:
1、找出當前要刪除的節點C
2、找出C的右子樹的最小節點M
3、用M節點的值替換C節點
4、刪除M節點

這里有個問題就是,如果被刪除的M節點是一個3節點,也就是說它紅色的,n那么直接刪除就行了,不會影響樹的平衡,因為每條路徑上的黑節點數沒有變化。如果是一個2節點就麻煩了,2節點意味著它是黑色的,刪除了就造成當前節點路徑上的黑節點少1了。為了讓樹不要失去平衡,最好我們刪除的都是紅節點就再好不過了。為了達到這個目的,我們在往下找到刪除節點的時候能帶一個紅色點下去,那不就很完美了嗎?因此,我們看看怎么做的這一點。

從樹根開始搜索,也是分幾種情況:

1、要刪除的節點比當前節點小
這時候,我們要往left 搜索,我們判斷當前節點的 left 和 left 的 left 都是 2節點的情況。這個時候我們需要把一個紅節點往 left 下面帶。怎么帶呢,這個時候要依賴當前節點的 right 的 left 的顏色。


image.png

如上圖,h為當前節點,h.left 和 h.left.left 都是黑色,如果h.right.left 是黑色,那么簡單的翻轉顏色就行了。這樣heft.left就是紅色了,我們成功把紅色往left帶了。那么h.right也是紅色啦,這沒問題?當然沒問題,在我們完成刪除遞歸返回的時候調用fixUp方法,會修復這個問題的。
如果h.right.left是紅色,我們首先做的是顏色翻轉,這個時候,h.right 是紅色,h.right.left 也是紅色。如果就這樣不管,遞歸返回的時候是沒辦法修復(兩個連續的紅色,fixUp修復不到這個地方)。因此,我們對h.right 右旋,然后顏色翻轉,這樣,紅色往left帶,right同時也滿足紅黑樹的性質。如果還是不太明白,上面的圖仔細看看。
通過上面的分析,我們得到下面的方法:

Node* moveRedLeft(Node* h)
{
    rbcolorFlip(h);
    if (isRed(h.right.left))
    {
        h.right = rotateRight(h.right);
        h = rotateLeft(h);
        rbcolorFlip(h);
    }
    return h;
}

2、要刪除的節點比當前節點大
這時候我們要往右邊搜索了,如果當前節點h 的left 是紅 right 是黑,我們要把紅節點往 right 帶。對h 右旋。如果h.right 和 h.right.left是黑色,我們執行和 1情形的鏡像操作,


image.png

如下:

Node* moveRedRight(Node* h) {
    rbcolorFlip(h);
    if (isRed(h.left.left)) {
        h = rotateRight(h);
        rbcolorFlip(h);
    }
    return h;
}

3、要刪除的節點和當前節點相等。
我們在這里需要往右子樹搜索其最小值。如果當前節點h.left紅,h.right黑,用右旋把紅點往右帶一位。再比較,如果當前節點h和要刪的節點相等,同時h.right 是空,直接刪除當前節點。如果h.right黑和h.right.left黑,moveRedRight 把紅點往右邊帶,再次比較如果相等,尋找h.right的最小節點覆蓋當前節點,然后用deleteMin方法刪除h.right的最小節點,否則對h.right遞歸調用刪除方法。

Node* deleteMin(Node* h)
{
    if (h.left == nil)
        return nil;
    if (!isRed(h.left) && !isRed(h.left.left))
        h = moveRedLeft(h);
    h.left = deleteMin(h.left);
    return fixUp(h);
}

//尋找 最小節點
Node* min(Node* h) {
    if (!h) return nil;
    if (h.left == nil) {
        return h;
    } else {
        return min(h.left);
    }
}

完整的刪除方法:

Node* delete(Node* cn, NSString* k) {
    
    if (cn == nil) return nil;
    
    int cmp = compareTo(k, cn.key);
    
    if (cmp < 0) { // k < cn.k 往左邊 left 搜索
        if (!isRed(cn.left) && !isRed(cn.left.left)) //紅點往left帶
            cn = moveRedLeft(cn);
        cn.left = delete(cn.left, k);
    } else if (cmp > 0) { // k > node.k go right
        if (isRed(cn.left) && !isRed(cn.right)) //紅點往right帶到右邊
            cn = rotateRight(cn);
        if (!isRed(cn.right) && !isRed(cn.right.left)) //紅點往right帶
            cn = moveRedRight(cn);
        cn.right = delete(cn.right, k);
    } else { // 就算找到了,也不能直接刪除,必須先帶了紅點下來才能繼續
        
        if (isRed(cn.left) && !isRed(cn.right)) {
            cn = rotateRight(cn);
        }
        if (compareTo(k, cn.key) == 0 && (cn.right == nil)) //直接刪除
            return nil;
        
        if (!isRed(cn.right) && !isRed(cn.right.left)) // 紅點往right帶
            cn = moveRedRight(cn);
        
        if (compareTo(k, cn.key) == 0) { // 跟二叉樹一樣的辦法,用被刪節點的右子樹的最小節點替代被刪節點
            Node* x = min(cn.right);
            cn.key = x.key;
            cn.value = x.value;
            cn.right = deleteMin(cn.right);
        } else cn.right = delete(cn.right, k);
    }
    return fixUp(cn);
}

- (void) remove:(NSString *)key {
    self.root = delete(self.root, key);
    self.root.color = BLACK;
}

以上就是我們要做的紅黑樹字典的輪子了,寫代碼測試一下

//    A D C E K J B M
    LLRB *t = [LLRB new];
    [t insert: [Node nodeWidthKey: @"A" value: @"1"]];
    [t insert: [Node nodeWidthKey: @"D" value: @"4"]];
    [t insert: [Node nodeWidthKey: @"C" value: @"3"]];
    [t insert: [Node nodeWidthKey: @"E" value: @"5"]];
    [t insert: [Node nodeWidthKey: @"K" value: @"11"]];
    [t insert: [Node nodeWidthKey: @"J" value: @"10"]];
    [t insert: [Node nodeWidthKey: @"B" value: @"2"]];
    [t insert: [Node nodeWidthKey: @"M" value: @"13"]];


    [t remove:@"D"];
    [t remove:@"E"];

剩下的get方法跟二叉樹一樣,就不再贅述了。

參考文章和圖片來源:
http://www.cs.princeton.edu/~rs/talks/LLRB/RedBlack.pdf

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

推薦閱讀更多精彩內容

  • 樹的概述 樹是一種非常常用的數據結構,樹與前面介紹的線性表,棧,隊列等線性結構不同,樹是一種非線性結構 1.樹的定...
    Jack921閱讀 4,489評論 1 31
  • R-B Tree簡介 R-B Tree,全稱是Red-Black Tree,又稱為“紅黑樹”,它一種特殊的二叉查找...
    張晨輝Allen閱讀 9,346評論 5 30
  • 0.目錄 1.算法導論的紅黑樹本質上是2-3-4樹 2.紅黑樹的結構和性質 3.紅黑樹的插入 4.紅黑樹的刪除 5...
    王偵閱讀 2,534評論 1 2
  • 1:不同類型的人待在同一個組,心理學真的神秘,奇怪。 2:大概了解了心理學的內容,知道了以后我們要學什么了。 3:...
    miss瘋丫頭閱讀 290評論 0 2
  • 昔日繁華成過去,孤燈寡影巷深深。 陳墻舊瓦今猶在,難覓當年老少人。 (橋、廟、堡、浜,是老人口中上海崇明昔日四大重...
    亁乾閱讀 440評論 2 0