二叉樹、B樹、B*樹、AVL樹... 這麼多樹你真的搞清楚了嗎?

經常在面試或者平時工作中,我們都會聽到類似的樹,類似於二叉樹、B樹、B*樹、AVL樹等等,很多情況下可能對他們都是隻有一知半解。今天我總結了所有常見的樹的原理,深入淺出的分析了其中的優缺點和注意事項,你一定得收藏起來好好研究。

KL9pMr

1 基礎知識

一棵樹由稱作跟的節點r以及0個或多個非空的樹T1,T2, ...Tk組成,這些子樹中每一顆的根都被來至根r的一條有向的邊所連接。

深度: 對任意節點ni,ni的深度爲從根到ni的唯一路徑的長。因此,根的深度爲0.
高: 從ni到一片樹葉的最長路徑的長。因此所有樹葉的高都是0.一顆樹的高等於它的根的高。

1.1 樹的遍歷

樹有很多應用,流行的用法包括Unix在內的很多常用操作系統的目錄結構。

遍歷包括先序遍歷(對節點的處理在它的諸兒子節點處理之前進行的)和後續遍歷(在諸兒子節點處理之後進行)

2 二叉樹

二叉樹是一棵樹,其中每個節點不能有多於兩個的兒子。

遍歷方法:

  • 先序:節點,左,右
  • 中序:左,節點,右
  • 後序:左,右,節點

滿二叉樹: 一顆高度爲h,並且含有2^h-1個節點的二叉樹稱爲滿二叉樹,即樹的每一層都含有最多的節點。
完全二叉樹: 設一個高度爲h,有n個節點的二叉樹,當且僅當其每一個節點都與高度爲h的滿二叉樹中編號爲1~n的節點一一對應時,稱爲完全二叉樹。

struct TreeNode
{
	int val;
	TreeNode* left;   // left child
	TreeNode* right;  // right child
}

3 二叉查找樹

使二叉樹成爲二叉查找樹的性質是,對於樹中的每個節點X,它的左子樹中所有關鍵字值小於X的關鍵字值,而它的右子樹中所有關鍵字值大於X的關鍵字值。

1 查找

注意對是否爲空樹進行判斷,可以使用遞歸,使用的棧空間的量只是O(logN)。

2 查找最小值或最大值

查找最小值時,從根開始並且只要有左兒子就向左進行,終止點是最小的元素。查找最大值時與之相反。

3 插入

爲了將X插入到樹T中,可以像查找那樣沿着樹查找。如果找到X,可以什麼也不做或者做一些更新。否則將X插入到遍歷的路徑上的最後一點上。

4 刪除

分3種情況:

  • 節點是一片葉子節點:可以被立即刪除;
  • 節點有一個兒子:則該節點可以在其父節點調整指針繞過該節點後被刪除;

Screen Shot 2020-03-24 at 10.29.58 PM.png

  • 節點有兩個兒子:用其右子樹中的最小的數據代替該節點的數據並遞歸地刪除那個節點。

Screen Shot 2020-03-24 at 10.30.11 PM.png

void remove(const int& x, BinaryNode* root)
{
	if (root == NULL)
		reutrn;
	else if (x < root->val)
		remove(x, root->left);
	else if (x > root->val)
		remove(x, root->right);
	else if(t->left != NULL && t->right != NULL)
	{
		root->val = findMin(root->right)->val;
		remove(root->val, root->right);
	}
	else
	{
		BinaryNode *oldNode = root;
		root = (root->left != NULL) ? root->left : root->right;
		delete oldNode;
	}
}

Java版本如下:

Screen Shot 2020-03-24 at 10.32.01 PM.png

5 平均情形分析

一棵樹的所有節點的深度的和稱爲內部路徑長,二叉查找樹的平均值爲O(NlogN),因此任意節點期望深度爲O(logN)。

4 AVL樹

一顆AVL樹是其每個節點的左子樹和右子樹的高度最多差1的二叉查找樹。可以證明,一個AVL樹的高度最多隻比logN稍微多一點。AVL樹也成爲平衡二叉樹。

因此,除去可能的插入外,所有樹的操作都可以以時間O(logN)執行。注意,當進行插入操作時,我們需要更新通向根節點路徑上的那些節點的所有平衡信息,而插入操作隱含着困難的原因在於,插入一個節點可能破壞AVL樹的特性。

AVL樹插入時可能出現的4種情況,把必須重新平衡的節點稱爲A,由於任意節點最多有兩個兒子,因此高度不平衡時,A點的兩顆子樹的高度相差2,這種不平衡出現了下面4種情況:

  1. 對A的左兒子的左子樹進行了一次插入;
  2. 對A的左兒子的右子樹進行了一次插入;
    對A的右兒子的左子樹進行了一次插入;
  3. 對A的右兒子的右子樹進行了一次插入;

解決方案:對於1和4兩種情況,插入發生在“外邊”,只需通過對樹的一次單旋轉而完成調整;對於2和3兩種情況,插入發生在“內部”,需通過對樹的雙旋轉而完成調整(也就是先單旋轉轉換爲插入發生在外邊的情況,再使用單旋轉即可);

4.1 單旋轉

  • 情況1的修正(右旋)
    |center|0x180
    |center|0x180
  • 情況4的修正(左旋)
    |center|0x180
    |center|0x180

4.2 雙旋轉

  • 情況2的修正(左右雙旋轉)
    |center|0x180
  • 情況3的修正(右左雙旋轉)
    |center|0x180
    |center|0x200

4.3 AVL樹實現

struct AvlNode
{
	int element;
	AvlNode *left;
	AvlNode *right;
	int height;
	AvlNode(int theElement, AvlNode *lt, AvlNode *rt, int h = 0)
	: element(theElement), left(lt), right(rt), height(h) {}
}

int height (AvlNode *root) const
{
	return root == NULL ? -1 : root->height;
}

/* Insert node */
void insert(const int& x, AvlNode *root)
{
	if (root == NULL)
		root = new AvlNode(x, NULL, NULL);
	else if (x < root->element)
	{
		insert(x, root->left);
		// 需要旋轉
		if (height(root->left) - height(root->right) == 2)
		{
			if (x < root->left->element)
				rotateWithLeftChild(root);
			else
				doubleWithLeftChild(root);
		}
	}
	else if (root->element < x)
	{
		insert(x, root->right);
		if (height(root->right) - height(root->left) == 2)
		{
			if (root->right->element < x)
				rotateWithRightChild(root);
			else
				doubleWithRightChild(root);		}
	}
	else
		;  // duplicate

	root->height = max(height(root->left), height(root->right)) + 1;
}

|center|0x180

/* Rotate binary tree with left child */
void rotateWithLeftChild(AvlNode* k2)
{
	AvlNode* k1 = k2->left;
	k2->left = k1->right;
	k1->right = k2;
	k2->height = max(height(k2->left), height(k2->right)) + 1;
	k1->height = max(height(k1->left), k2->height) + 1;
	k2 = k1;
}

|center|0x180

/* Rotate binary tree with right child */
void rotateWithRightChild(AvlNode* k1)
{
	AvlNode* k2 = k1->right;
	k1->right = k2->left;
	k2->left = k1;
	k1->height = max(height(k1->left), height(k1->right)) + 1;
	k2->height = max(height(k2->right), k1->height) + 1;
	k1 = k2;
}

|center|0x180

/* Double rotate binary tree with left child */
void rotateWithRightChild(AvlNode* k3)
{
	rotateWithRightChild(k3->left);
	rotateWithLeftChild(k3);
}

|center|0x180

/* Double rotate binary tree with right child */
void doubleWithLeftChild(AvlNode* k1)
{
	rotateWithLeftChild(k1->left);
	rotateWithRightChild(k1);
}

5 高級數據結構

大規模數據存儲中,實現索引查詢這樣一個實際背景下,樹節點存儲的元素數量是有限的(如果元素數量非常多的話,查找就退化成節點內部的線性查找了),這樣導致二叉查找樹結構由於樹的深度過大而造成磁盤I/O讀寫過於頻繁,進而導致查詢效率低下,因此我們該想辦法降低樹的深度,從而減少磁盤查找存取的次數。 一個基本的想法就是:採用多叉樹結構(由於樹節點元素數量是有限的,自然該節點的子樹數量也就是有限的)。

磁盤的構造:

磁盤是一個扁平的圓盤(與電唱機的唱片類似)。盤面上有許多稱爲磁道的圓圈,數據就記錄在這些磁道上。磁盤可以是單片的,也可以是由若干盤片組成的盤組,每一盤片上有兩個面。如下圖所示的6片盤組爲例,除去最頂端和最底端的外側面不存儲數據之外,一共有10個面可以用來保存信息。

磁盤的讀/寫原理和效率:

磁盤上數據必須用一個三維地址唯一標示:柱面號、盤面號、塊號(磁道上的盤塊)。

讀/寫磁盤上某一指定數據需要下面3個步驟:

  • 首先移動臂根據柱面號使磁頭移動到所需要的柱面上,這一過程被稱爲定位或查找
  • 如上圖所示的6盤組示意圖中,所有磁頭都定位到了10個盤面的10條磁道上(磁頭都是雙向的)。這時根據盤面號來確定指定盤面上的磁道。
  • 盤面確定以後,盤片開始旋轉,將指定塊號的磁道段移動至磁頭下。

經過上面三個步驟,指定數據的存儲位置就被找到。這時就可以開始讀/寫操作了。

訪問某一具體信息,由3部分時間組成:

  • 查找時間(seek time) Ts: 完成上述步驟(1)所需要的時間。這部分時間代價最高,最大可達到0.1s左右。
  • 等待時間(latency time) Tl: 完成上述步驟(3)所需要的時間。由於盤片繞主軸旋轉速度很快,一般爲7200轉/分(電腦硬盤的性能指標之一, 家用的普通硬盤的轉速一般有5400rpm(筆記本)、7200rpm幾種)。因此一般旋轉一圈大約0.0083s。
  • 傳輸時間(transmission time) Tt: 數據通過系統總線傳送到內存的時間,一般傳輸一個字節(byte)大概0.02us=2*10^(-8)s

磁盤讀取數據是以盤塊(block)爲基本單位的。位於同一盤塊中的所有數據都能被一次性全部讀取出來。而磁盤IO代價主要花費在查找時間Ts上。因此我們應該儘量將相關信息存放在同一盤塊,同一磁道中。或者至少放在同一柱面或相鄰柱面上,以求在讀/寫信息時儘量減少磁頭來回移動的次數,避免過多的查找時間Ts。

總結:在大規模數據存儲方面,大量數據存儲在外存磁盤中,而在外存磁盤中讀取/寫入塊(block)中某數據時,首先需要定位到磁盤中的某塊,如何有效地查找磁盤中的數據,需要一種合理高效的外存數據結構,就是下面所要重點闡述的B-tree結構

5.1 B樹

M階B樹滿足下列條件:

  • 根節點不是葉子節點,則至少有2個子節點
  • 除根節點和葉子節點外,每個節點的子節點個數在M/2(向上取整)和M之間
  • 節點的key值以升序排列,位於N-1和N key的子節點的值位於N-1和N key對應的Value之間
  • 所有葉子節點出現在同一層,葉子結點不包含任何關鍵字信息(可以看做是外部結點或查詢失敗的結點,指向這些結點的指針都爲null) [葉子節點只是沒有孩子和指向孩子的指針,這些節點也存在,也有元素。其實,關鍵是把什麼當做葉子結點,因爲如紅黑樹中,每一個NULL指針即當做葉子結點,只是沒畫出來而已]

下面是B樹的簡單例子:

Screen Shot 2020-03-25 at 10.30.40 PM.png

B樹相關操作:

  • 插入操作

如果插入節點後樹的性質不被改變,可以直接進行插入。如果待插入的節點已滿,由於一片樹葉只能容納兩個或三個關鍵字,解決辦法,先插入到指定的葉子,如果該節點有四個兒子,我們將這個節點分成兩個節點,每個節點兩個兒子即可。而這樣分裂該節點將給它的父節點帶來一個新問題(該父節點就有4個兒子),但是我們可以在通向根的路徑上一直這麼分下去,直到或者到達根節點,或者找到一個節點,這個節點只有兩個兒子。

  • 刪除操作

刪除操作可以看成是插入操作的逆過程;先找到需要被刪除的關鍵字並將其除去而完成刪除操作。如果這個關鍵字是一個節點僅有的兩個關鍵字中的一個,那麼將他出去後就只剩一個關鍵字了。此時通過把這個節點與它的一個兄弟合併起來進行調整。如果這個兄弟已有3個關鍵字,那麼可以取出一個使得兩個節點各有兩個關鍵字,如果這個兄弟只有兩個關鍵字,那麼就將這兩個節點合併成一個具有3個關鍵字的節點。現在這個節點的父親則失去了一個兒子,因此我們還需要向上檢查直到頂部。如果根節點失去了它的第二個兒子,那麼這個根也要刪除,而樹則減少了一層。

下面以4 階 B 樹爲例,來說明B樹的創建過程:

首先必須明確,對於4階B樹,每個節點最多有 4 個子樹、最少有 2 個子樹,由於關鍵字的數目比字數數目少1,所以對應的最多有3個關鍵字,最少有1個關鍵字。

  1. 添加 6,第一個節點
  2. 添加 10,根節點最多能放三個關鍵字,按順序添到根節點中
    添加 4,還能放到根節點中
  3. 添加 14,這時超出了關鍵字最大限制,需要把 14 添加爲子樹,由於字樹的數目比關鍵字

Screen Shot 2020-03-25 at 10.48.27 PM.png

這個拆的過程比較複雜,首先要確定根節點保留幾個關鍵字,由於“非葉子節點的根節點至少有 2 棵子樹”的限制,那就至少需要兩個關鍵字分出去,又因爲“子樹數是關鍵字數+1”,如果根節點有兩個關鍵字,就得有三個子樹,無法滿足,所以只好把除 6 以外的三個關鍵字都拆爲子樹。

  1. 添加 5,放到 4 所在的子樹上
  2. 添加 11,放在 10 和 14 所在的右子樹上
  3. 添加 15,按大小應該放到 10、11 和 14 所在的子樹上,但因爲超過了關鍵字數限制,又得拆分

Screen Shot 2020-03-25 at 10.51.13 PM.png

因爲“根節點必須都在同一層”,因此我們不能給現有的左右子樹添加子樹,只能添加給 6 了;但是如果 6 有三個子樹,就必須得有 2 個關鍵字,提升誰做關鍵字好呢,這得看誰做 6 中間的子樹,因爲右子樹的所有關鍵字都得比父節點的關鍵字大,所以這個提升的關鍵字只能比未來右子樹中的關鍵字都小,那就只有 10 和 11 可以考慮了。提升 10 吧,沒有比它小的做子樹,那就只能提升 11 了:

Screen Shot 2020-03-25 at 10.54.36 PM.png

繼續添加其他元素也是類似的操作。

使用場景

文件系統和數據庫系統中常用的B/B+ 樹,他通過對每個節點存儲個數的擴展,使得對連續的數據能夠進行較快的定位和訪問,能夠有效減少查找時間,提高存儲的空間局部性從而減少IO操作。他廣泛用於文件系統及數據庫中,如:

  • Windows:HPFS 文件系統
  • Mac:HFS,HFS+ 文件系統
  • Linux:ResiserFS,XFS,Ext3FS,JFS 文件系統
  • 數據庫:ORACLE,MYSQL,SQLSERVER 等中

文件查找:
關於內存中的文件名查找,由於是一個有序表結構,可以利用折半查找提高效率。至於IO操作是影響整個B樹查找效率的決定因素。當然,如果我們使用平衡二叉樹的磁盤存儲結構來進行查找,磁盤4次,最多5次,而且文件越多,B樹比平衡二叉樹所用的磁盤IO操作次數將越少,效率也越高。

B樹中的每個結點根據實際情況可以包含大量的關鍵字信息和分支(當然是不能超過磁盤塊的大小,根據磁盤驅動(disk drives)的不同,一般塊的大小在1k~4k左右);這樣樹的深度降低了,這就意味着查找一個元素只要很少結點從外存磁盤中讀入內存,很快訪問到要查找的數據。

樹的高度:

當B樹包含N個關鍵字時,B樹的最大高度爲l-1(因爲計算B樹高度時,葉結點所在層不計算在內),即:l - 1 = log┌m/2┐((N+1)/2 )+1

證明:
若B樹某一非葉子節點包含N個關鍵字,則此非葉子節點含有N+1個孩子結點,而所有的葉子結點都在第I層,我們可以得出:
1. 因爲根至少有兩個孩子,因此第2層至少有兩個結點。
2. 除根和葉子外,其它結點至少有┌m/2┐個孩子,
**** 因此在第3層至少有2┌m/2┐個結點,
4. 在第4層至少有2
(┌m/2┐^2)個結點,
5. 在第 I 層至少有2(┌m/2┐^(l-2) )個結點,於是有: N+1 ≥ 2┌m/2┐I-2;
6. 考慮第L層的結點個數爲N+1,那麼2*(┌m/2┐^(l-2))≤N+1,也就是L層的最少結點數剛好達到N+1個,即: I≤ log┌m/2┐((N+1)/2 )+2;

複雜度分析:
對於一顆節點爲N度爲M的子樹,查找和插入需要log(M-1)N ~ log(M/2)N次比較。這個很好證明,對於度爲M的B樹,每一個節點的子節點個數爲M/2 到 M-1之間,所以樹的高度在log(M-1)N至log(M/2)N之間。

5.2 B+樹

B+樹是B樹的一個升級版,相對於B樹來說B+樹更充分的利用了節點的空間,讓查詢速度更加穩定,其速度完全接近於二分法查找。爲什麼說B+樹查找的效率要比B樹更高、更穩定;我們先看看兩者的區別:

  • B+跟B樹不同,B+樹的非葉子節點不保存關鍵字記錄的指針,只進行數據索引,這樣使得B+樹每個非葉子節點所能保存的關鍵字大大增加;

  • B+樹葉子節點保存了父節點的所有關鍵字記錄的指針,所有數據地址必須要到葉子節點才能獲取到。 所以每次數據查詢的次數都一樣;

  • B+樹葉子節點的關鍵字從小到大有序排列,左邊結尾數據都會保存右邊節點開始數據的指針;

  • 非葉子節點的子節點數=關鍵字數(來源百度百科)(根據各種資料 這裏有兩種算法的實現方式,另一種爲非葉節點的關鍵字數=子節點數-1(來源維基百科),雖然他們數據排列結構不一樣,但其原理還是一樣的Mysql 的B+樹是用第一種方式實現)。

以下來自百度百科:

Screen Shot 2020-03-25 at 11.18.19 PM.png

以下來自維基百科:

jawkefjk.jpg

特點:

1、B+樹的層級更少:相較於B樹B+每個非葉子節點存儲的關鍵字數更多,樹的層級更少所以查詢數據更快;

2、B+樹查詢速度更穩定:B+所有關鍵字數據地址都存在葉子節點上,所以每次查找的次數都相同所以查詢速度要比B樹更穩定;

3、B+樹天然具備排序功能:B+樹所有的葉子節點數據構成了一個有序鏈表,在查詢大小區間的數據時候更方便,數據緊密性很高,緩存的命中率也會比B樹高。

4、B+樹全節點遍歷更快:B+樹遍歷整棵樹只需要遍歷所有的葉子節點即可,而不需要像B樹一樣需要對每一層進行遍歷,這有利於數據庫做全表掃描。

B樹相對於B+樹的優點是,如果經常訪問的數據離根節點很近,而B樹的非葉子節點本身存有關鍵字其數據的地址,所以這種數據檢索的時候會要比B+樹快。

用途: B樹和B+廣泛應用於文件存儲系統以及數據庫系統中。MySQL就普遍使用B+Tree實現其索引結構。索引(Index)是幫助MySQL高效獲取數據的數據結構。索引本身也很大,不可能全部存儲在內存中,因此索引往往以索引文件的形式存儲的磁盤上。這樣的話,索引查找過程中就要產生磁盤I/O消耗,相對於內存存取,I/O存取的消耗要高几個數量級,所以評價一個數據結構作爲索引的優劣最重要的指標就是在查找過程中磁盤I/O操作次數的漸進複雜度。換句話說,索引的結構組織要儘量減少查找過程中磁盤I/O的存取次數。

B+tree比B樹更適合實際應用中操作系統的文件索引:

  • B+-tree的磁盤讀寫代價更低
    B+-tree的內部結點並沒有指向關鍵字具體信息的指針。因此其內部結點相對B樹更小。如果把所有同一內部結點的關鍵字存放在同一盤塊中,那麼盤塊所能容納的關鍵字數量也越多。一次性讀入內存中的需要查找的關鍵字也就越多。相對來說IO讀寫次數也就降低了。
    舉個例子,假設磁盤中的一個盤塊容納16bytes,而一個關鍵字2bytes,一個關鍵字具體信息指針2bytes。一棵9階B-tree(一個結點最多8個關鍵字)的內部結點需要2個盤快。而B+ 樹內部結點只需要1個盤快。當需要把內部結點讀入內存中的時候,B 樹就比B+ 樹多一次盤塊查找時間(在磁盤中就是盤片旋轉的時間)。
  • B+-tree的查詢效率更加穩定
    由於非終結點並不是最終指向文件內容的結點,而只是葉子結點中關鍵字的索引。所以任何關鍵字的查找必須走一條從根結點到葉子結點的路。所有關鍵字查詢的路徑長度相同,導致每一個數據的查詢效率相當。

數據庫索引採用B+樹的主要原因是: B樹在提高了磁盤IO性能的同時並沒有解決元素遍歷的效率低下的問題。正是爲了解決這個問題,B+樹應運而生。B+樹只要遍歷葉子節點就可以實現整棵樹的遍歷。而且在數據庫中基於範圍的查詢是非常頻繁的,而B樹不支持這樣的操作(或者說效率太低)。

5.3 B*樹

B*樹是B+tree的變體,在B+樹的基礎上(所有的葉子結點中包含了全部關鍵字的信息,及指向含有這些關鍵字記錄的指針),

  • B樹中非根和非葉子結點再增加指向兄弟的指針;B樹定義了非葉子結點關鍵字個數至少爲(2/3) * M,即塊的最低使用率爲2/3(代替B+樹的1/2),B*樹分配新結點的概率比B+樹要低,空間使用率更高。
  • B+樹節點滿時就會分裂,而B*樹節點滿時會檢查兄弟節點是否滿(因爲每個節點都有指向兄弟的指針),如果兄弟節點未滿則向兄弟節點轉移關鍵字,如果兄弟節點已滿,則從當前節點和兄弟節點各拿出1/3的數據創建一個新的節點出來;

|center|0x300

1、相同思想和策略

從平衡二叉樹、B樹、B+樹、B*樹總體來看它們的貫徹的思想是相同的,都是採用二分法和數據平衡策略來提升查找數據的速度;

2、不同的方式的磁盤空間利用

不同點是他們一個一個在演變的過程中通過IO從磁盤讀取數據的原理進行一步步的演變,每一次演變都是爲了讓節點的空間更合理的運用起來,從而使樹的層級減少達到快速查找數據的目的;

5.4 紅黑樹 - 算法第4版

紅黑樹的主要像是對2-3查找樹進行編碼,尤其是對2-3查找樹中的3-nodes節點添加額外的信息。紅黑樹中將節點之間的鏈接分爲兩種不同類型,紅色鏈接用來鏈接兩個2-nodes節點來表示一個3-nodes節點,黑色鏈接用來鏈接普通的2-3節點。特別的,使用紅色鏈接的兩個2-nodes來表示一個3-nodes節點,並且向左傾斜,即一個2-node是另一個2-node的左子節點。這種做法的好處是查找的時候不用做任何修改,和普通的二叉查找樹相同。

根據以上描述,紅黑樹定義如下:
紅黑樹是一種具有紅色和黑色鏈接的二叉查找樹,同時滿足:

  • 紅色節點向左傾斜
  • 一個節點不可能有兩個紅色鏈接
  • 該樹是完美黑色平衡的,即任意空鏈接到根節點的路徑上的黑色鏈接的個數都相同。

|center|0x150

下圖可以看到紅黑樹其實是2-3樹的另外一種表現形式:如果我們將紅色的連線水平繪製,那麼它連接的兩個2-node節點就是2-3樹中的一個3-node節點了。

|center|0x400

相關操作:

  • 查找
    紅黑樹是一種特殊的二叉查找樹,他的查找方法也和二叉查找樹一樣,不需要做太多更改。但是由於紅黑樹比一般的二叉查找樹具有更好的平衡,所以查找起來更快。
  • 插入
    |center|0x300
    如上圖所示,標準的二叉查找樹遍歷即可。新插入的節點標記爲紅色,所有情況根據需求進行左旋或者右旋。

性質

  • 在最壞的情況下,紅黑樹的高度不超過2lgN
  • 紅黑樹的平均高度大約爲lgN

下圖是紅黑樹在各種情況下的時間複雜度,可以看出紅黑樹是2-3查找樹的一種實現,它能保證最壞情況下仍然具有對數的時間複雜度。

應用
紅黑樹這種數據結構應用十分廣泛,在多種編程語言中被用作符號表的實現

  • Java中的java.util.TreeMap,java.util.TreeSet
  • C++ STL中的:map,multimap,multiset
  • .NET中的:SortedDictionary,SortedSet 等

至於爲什麼不選擇AVL樹來實現這些數據結構,主要有以下幾點原因:

1. 如果插入一個node引起了樹的不平衡,AVL和RB-Tree都是最多隻需要2次旋轉操作,即兩者都是O(1);但是在刪除node引起樹的不平衡時,最壞情況下,AVL需要維護從被刪node到root這條路徑上所有node的平衡性,因此需要旋轉的量級O(logN),而RB-Tree最多隻需3次旋轉,只需要O(1)的複雜度。
2. AVL的結構相較RB-Tree來說更爲平衡,在插入和刪除node更容易引起Tree的unbalance,因此在大量數據需要插入或者刪除時,AVL需要rebalance的頻率會更高。因此,RB-Tree在需要大量插入和刪除node的場景下,效率更高。自然,由於AVL高度平衡,因此AVL的search效率更高。
**** map的實現只是折衷了兩者在search、insert以及delete下的效率。總體來說,RB-tree的統計性能是高於AVL的。

更詳細參考:淺談算法和數據結構: 九 平衡查找樹之紅黑樹

5.5 紅黑樹 - 標準版

紅黑樹是一種含有紅黑結點並能自平衡的二叉查找樹。它必須滿足下面性質:

  • 性質1:每個節點要麼是黑色,要麼是紅色。
  • 性質2:根節點是黑色。
  • 性質3:每個葉子節點(NIL)是黑色。
  • 性質4:每個紅色結點的兩個子結點一定都是黑色。
  • 性質5:任意一結點到每個葉子結點的路徑都包含數量相同的黑結點。

Screen Shot 2020-03-31 at 11.42.16 PM.png

前面講到紅黑樹能自平衡,主要靠三種操作:左旋、右旋和變色。

  • 左旋:以某個結點作爲支點(旋轉結點),其右子結點變爲旋轉結點的父結點,右子結點的左子結點變爲旋轉結點的右子結點,左子結點保持不變。

Screen Shot 2020-04-01 at 10.02.20 PM.png

  • 右旋:以某個結點作爲支點(旋轉結點),其左子結點變爲旋轉結點的父結點,左子結點的右子結點變爲旋轉結點的左子結點,右子結點保持不變。

Screen Shot 2020-04-01 at 10.02.49 PM.png

  • 變色:結點的顏色由紅變黑或由黑變紅。

下面針對紅黑樹的插入和刪除進行分析:

紅黑樹的插入

Case 1,紅黑樹爲空樹:直接把插入結點作爲根結點就行,但注意,根據紅黑樹性質2:根節點是黑色。

Case 2,插入結點的key已經存在:那麼把插入結點設置爲將要替代結點的顏色,再把結點的值更新就完成插入。

Case 3,插入結點的父結點爲黑結點:由於插入的結點是紅色的,並不會影響紅黑樹的平衡,而且父結點爲黑色,直接插入即可,無需做自平衡。

Case 4,插入結點的父結點爲紅結點 根據紅黑樹性質2,根結點是黑色。如果插入的父結點爲紅結點,那麼該父結點不可能爲根結點,所以插入結點總是存在祖父結點。這點很重要,因爲後續的旋轉操作肯定需要祖父結點的參與。

具體又分爲如下幾種情況:

1) 叔叔結點存在並且爲紅結點,

Screen Shot 2020-04-01 at 10.58.46 PM.png

2)插入結點位於父結點的外側,需要進行單次旋轉。

Screen Shot 2020-04-01 at 11.04.02 PM.png

Screen Shot 2020-04-01 at 11.04.15 PM.png

3)插入結點位於父結點的內側,需要進行兩次旋轉。

Screen Shot 2020-04-01 at 11.12.21 PM.png

Screen Shot 2020-04-01 at 11.12.01 PM.png

紅黑樹的刪除

首先利用右子樹中的最小結點替代被刪除的結點,同時循環刪除替代的結點。刪除結點後,爲了保證紅黑樹的平衡, 可能出現如下幾種需要調節的情況:

  • Case 1:被刪除的結點是紅色,直接刪除;
  • Case 2:如果root結點是DB(Double Black, 刪除結點的顏色與替代結點的顏色均爲Black),可以直接將DB改爲正常結點;
  • Case 3:如果DB結點的兄弟結點是黑色,並且兄弟結點的子結點均爲黑色。

處理:移除DB;將父結點改爲黑色,如果父結點本來就爲黑色,則父結點變爲DB結點;將兄弟結點改爲紅色。操作完成後,如果仍然存在DB,則依據其他Case調整。

Screen Shot 2020-03-31 at 9.05.53 PM.png

在下面的例子中,就出現了這種情況,第一次調整之後, 20變成了DB;因此需要進一步調節,最後5變成紅色,root結點10變成DB,根據Case 2的規則,直接將Root的DB去掉,改爲正常結點即可。

Screen Shot 2020-03-31 at 9.25.03 PM.png

  • Case 4: DB的兄弟結點是紅色。

操作:互換父結點和兄弟結點的顏色;將父結點朝DB的方向旋轉。如此之後,如需要可以依據其他Case調整。

  • Case 5: DB結點的兄弟結點是黑色,兄弟結點的子結點中,遠離DB結點的子結點是黑色,靠近DB結點的子結點是紅色。

操作:互換DB的兄弟結點以及兄弟結點中靠近DB的子結點的顏色;將兄弟結點朝DB的反方向旋轉。如需要參考其他規則調整。

  • Case 6: DB結點的兄弟結點是黑色,兄弟結點的子結點中,遠離DB結點的子結點是紅色。

操作:互換父結點和兄弟結點的顏色;朝DB的方向旋轉父結點;將DB改爲正常結點;將原來兄弟結點中的紅色子結點變爲黑色。

參考:

6 Trie樹

Trie樹,又叫字典樹、前綴樹(Prefix Tree)、單詞查找樹或鍵樹,是一種多叉樹結構,如下圖所示:

上圖是一棵Trie樹,表示了關鍵字集合{“a”, “to”, “tea”, “ted”, “ten”, “i”, “in”, “inn”} 。從上圖可以歸納出Trie樹的基本性質:

  1. 根節點不包含字符,除根節點外的每一個子節點都包含一個字符。
  2. 從根節點到某一個節點,路徑上經過的字符連接起來,爲該節點對應的字符串。
    每個節點的所有子節點包含的字符互不相同。

通常在實現的時候,會在節點結構中設置一個標誌,用來標記該結點處是否構成一個單詞(關鍵字)。可以看出,Trie樹的關鍵字一般都是字符串,而且Trie樹把每個關鍵字保存在一條路徑上,而不是一個結點中。另外,兩個有公共前綴的關鍵字,在Trie樹中前綴部分的路徑相同,所以Trie樹又叫做前綴樹(Prefix Tree)。

優點:

  • 插入和查詢的效率很高,都爲O(m),其中 m是待插入/查詢的字符串的長度。

關於查詢,會有人說 hash 表時間複雜度是O(1)不是更快?但是,哈希搜索的效率通常取決於 hash 函數的好壞,若一個壞的 hash 函數導致很多的衝突,效率並不一定比Trie樹高。Trie樹中不同的關鍵字不會產生衝突。

  • Trie樹只有在允許一個關鍵字關聯多個值的情況下才有類似hash碰撞發生。
  • Trie樹不用求 hash 值,對短字符串有更快的速度。通常,求hash值也是需要遍歷字符串的。
  • Trie樹可以對關鍵字按字典序排序。

缺點:

  • 當 hash 函數很好時,Trie樹的查找效率會低於哈希搜索。
  • 空間消耗比較大。
  • Trie的核心思想是空間換時間,利用字符串的公共前綴來降低查詢時間的開銷以達到提高效率的目的。
const int ALPHABET_SIZE = 26;
struct trieNode
{
	int count;  // 記錄每個節點代表的單詞個數
	trieNode* children[ALPHABET_SIZE];
};

trieNode* createTrieNode()
{
	trieNode* pNode = new trieNode();
	pNode->count = 0;
	for (int i = 0; i < ALPHABET_SIZE; i++)
	{
		pNode->children[i] = NULL;
	}
	return pNode;
}

void trieInsert(trieNode* root, char* key)
{
	trieNode* node = root;
	char* p = key;
	while (*p)
	{
		if (node->children[*p-'a'] == NULL)
		{
			node->children[*p-'a'] = createTrieNode();
		}
		node = node->children[*p-'a'];
		++p;
	}
	node->count += 1;
}

/*
查詢:不存在返回0,存在返回出現的次數
*/
int trieSearch(trieNode* root, char* key)
{
	trieNode* node = root;
	char* p = key;
	while (*p && node != NULL)
	{
		node = node->children[*p-'a'];
		++p;	
	}
	if (node == NULL)
		return 0;
	else
		return node->count;
}

拓展:後綴樹

後綴樹(Suffix tree)是一種數據結構,能快速解決很多關於字符串的問題。
後綴,顧名思義,就是後面尾巴的意思。比如說給定一長度爲n的字符串S=S1S2..Si..Sn,和整數i,1 <= i <= n,子串SiSi+1...Sn便都是字符串S的後綴。
以字符串S=XMADAMYX爲例,它的長度爲8,所以S[1..8], S[2..8], ... , S[8..8]都算S的後綴,我們一般還把空字串也算成後綴。這樣,我們一共有如下後綴。對於後綴S[i..n],我們說這項後綴起始於i。

S[1..8], XMADAMYX, 也就是字符串本身,起始位置爲1
S[2..8], MADAMYX,起始位置爲2
S[.8], ADAMYX,起始位置爲3
S[4..8], DAMYX,起始位置爲4
S[5..8], AMYX,起始位置爲5
S[6..8], MYX,起始位置爲6
S[7..8], YX,起始位置爲7
S[8..8], X,起始位置爲8
空字串,記爲$。

而後綴樹,就是包含一則字符串所有後綴的壓縮Trie。把上面的後綴加入Trie後,我們得到下面的結構:
|400x0|

應用:常用來查找在串S中查詢字串P是否存在

7 Huffman樹

樹的帶權路徑長度:指樹中所有葉子節點到根節點的路徑長度與該葉子節點權值的乘積之和,如果在一棵二叉樹中共有n個葉子節點,用Wi表示第i個葉子節點的權值,Li表示第i個也葉子節點到根節點的路徑長度,則該二叉樹的帶權路徑長度 WPL=W1L1 + W2L2 + ... Wn*Ln。

赫夫曼樹(Huffman Tree),又稱最優二叉樹,是一類帶權路徑長度最短的樹。假設有n個權值{w1,w2,...,wn},如果構造一棵有n個葉子節點的二叉樹,而這n個葉子節點的權值是{w1,w2,...,wn},則所構造出的帶權路徑長度最小的二叉樹就被稱爲赫夫曼樹。

根據節點的個數以及權值的不同,赫夫曼樹的形狀也各不相同,赫夫曼樹具有如下特性:

  • 對於同一組權值,所能得到的赫夫曼樹不一定是唯一的。
  • 赫夫曼樹的左右子樹可以互換,因爲這並不影響樹的帶權路徑長度。
  • 帶權值的節點都是葉子節點,不帶權值的節點都是某棵子二叉樹的根節點。
  • 權值越大的節點越靠近赫夫曼樹的根節點,權值越小的節點越遠離赫夫曼樹的根節點。
  • 赫夫曼樹中只有葉子節點和度爲2的節點,沒有度爲1的節點。
  • 一棵有n個葉子節點的赫夫曼樹共有2n-1個節點。

Huffman樹的構建過程:
1. 將給定的n個權值看做n棵只有根節點(無左右孩子)的二叉樹,組成一個集合HT,每棵樹的權值爲該節點的權值。
2. 從集合HT中選出2棵權值最小的二叉樹,組成一棵新的二叉樹,其權值爲這2棵二叉樹的權值之和
將步驟2中選出的2棵二叉樹從集合HT中刪去,同時將步驟2中新得到的二叉樹加入到集合HT中。
4. 重複步驟2和步驟3,直到集合HT中只含一棵樹,這棵樹便是赫夫曼樹。

舉例如下:

構建出的Huffman樹的可能情況有如下兩種:

Huffman編碼
赫夫曼樹的應用十分廣泛,比如衆所周知的在通信電文中的應用。在等傳送電文時,我們希望電文的總長儘可能短,因此可以對每個字符設計長度不等的編碼,讓電文中出現較多的字符采用盡可能短的編碼。爲了保證在譯碼時不出現歧義,我們可以採取如下圖所示的編碼方式:

對應的Huffman編碼:5:11;4:10;3:00;2:011;1:010;上面構建的第二幅Huffman樹的編碼類似;

8 LSM樹

B+樹最大的性能問題是會產生大量的隨機IO,隨着新數據的插入,葉子節點會慢慢分裂,邏輯上連續的葉子節點在物理上往往不連續,甚至分離的很遠,但做範圍查詢時,會產生大量讀隨機IO。

對於大量的隨機寫也一樣,舉一個插入key跨度很大的例子,如7->1000->3->2000 ... 新插入的數據存儲在磁盤上相隔很遠,會產生大量的隨機寫IO.

爲了克服B+樹的弱點,HBase引入了LSM樹的概念,即Log-Structured Merge-Trees。

爲了更好的說明LSM樹的原理,下面舉個比較極端的例子:

現在假設有1000個節點的隨機key,對於磁盤來說,肯定是把這1000個節點順序寫入磁盤最快,但是這樣一來,讀就悲劇了,因爲key在磁盤中完全無序,每次讀取都要全掃描;

那麼,爲了讓讀性能儘量高,數據在磁盤中必須得有序,這就是B+樹的原理,但是寫就悲劇了,因爲會產生大量的隨機IO,磁盤尋道速度跟不上。

LSM樹本質上就是在讀寫之間取得平衡,和B+樹相比,它犧牲了部分讀性能,用來大幅提高寫性能。

它的原理是把一顆大樹拆分成N棵小樹, 它首先寫入到內存中(內存沒有尋道速度的問題,隨機寫的性能得到大幅提升),在內存中構建一顆有序小樹,隨着小樹越來越大,內存的小樹會flush到磁盤上。當讀時,由於不知道數據在哪棵小樹上,因此必須遍歷所有的小樹,但在每顆小樹內部數據是有序的。

以上就是LSM樹最本質的原理,有了原理,再看具體的技術就很簡單了。

1)首先說說爲什麼要有WAL(Write Ahead Log),很簡單,因爲數據是先寫到內存中,如果斷電,內存中的數據會丟失,因此爲了保護內存中的數據,需要在磁盤上先記錄logfile,當內存中的數據flush到磁盤上時,就可以拋棄相應的Logfile。

2)什麼是memstore, storefile?很簡單,上面說過,LSM樹就是一堆小樹,在內存中的小樹即memstore,每次flush,內存中的memstore變成磁盤上一個新的storefile。

3)爲什麼會有compact?很簡單,隨着小樹越來越多,讀的性能會越來越差,因此需要在適當的時候,對磁盤中的小樹進行merge,多棵小樹變成一顆大樹。

關於LSM Tree,對於最簡單的二層LSM Tree而言,內存中的數據和磁盤中的數據merge操作,如下圖:

LSM Tree弄了很多個小的有序結構,比如每m個數據,在內存裏排序一次,下面100個數據,再排序一次……這樣依次做下去,就可以獲得N/m個有序的小的有序結構。

在查詢的時候,因爲不知道這個數據到底是在哪裏,所以就從最新的一個小的有序結構裏做二分查找,找得到就返回,找不到就繼續找下一個小有序結構,一直到找到爲止。

很容易可以看出,這樣的模式,讀取的時間複雜度是(N/m)*log2N 。讀取效率是會下降的。

LSM Tree優化方式:

a、Bloom filter: 就是個帶隨即概率的bitmap,可以快速的告訴你,某一個小的有序結構裏有沒有指定的那個數據的。於是就可以不用二分查找,而只需簡單的計算幾次就能知道數據是否在某個小集合裏啦。效率得到了提升,但付出的是空間代價。

b、compact:小樹合併爲大樹:因爲小樹他性能有問題,所以要有個進程不斷地將小樹合併到大樹上,這樣大部分的老數據查詢也可以直接使用log2N的方式找到,不需要再進行(N/m)*log2n的查詢了


關注公衆號【碼老思】,第一時間瞭解更多技術乾貨。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章