樹:基本樹形

長安不亂發表於2020-07-27

樹:基本樹形
樹擴充了那些一維順序結構(連結串列,棧,佇列等),連結串列的插入刪除速度快,但是查詢速度慢,陣列查詢速度快,但是插入刪除速度慢,為了能夠插入、刪除、遍歷、查詢速度相對較快,就使用了樹結構。樹多用與查詢和索引,比如記憶體管理中的堆heap就是一個不儲存邊的完全二叉樹。本文不對樹的定義進行闡述,這些在教科書中都有,主要是對樹的使用發展進行一個簡單的梳理。全部程式碼放在我的github

二叉樹

樹可以分叉,最常見的就是二叉,當然也有N叉樹。這是因為二叉簡單,更容易被理解。一般的搜尋問題,二叉樹就能將之搞定,其中滿二叉樹和完全二叉樹,就不細說,滿二叉樹就是樹的每一層都有最多的節點,節點都是滿的,如圖1,完全二叉樹就是節點從左往右的順序排列的,並且僅僅只有最後一層節點可以不滿,如圖2。二叉樹也有很多的操作,比如中序、前序、後序遍歷等等。

樹:基本樹形
圖1. 滿二叉樹
樹:基本樹形
圖2. 完全二叉樹

但是無特徵的樹在工業上沒啥用,所以就出現了一些具有特殊特徵的二叉樹。首先是節點按順序排序的二叉排序。

二叉排序樹(BST)

二叉排序樹(BST),左子樹上所有節點小於根節點的數,右子樹上所有節點大於根節點的數,並且左右子樹也是如此。如圖3所示。

樹:基本樹形
圖3. BST樹

可以從圖中看出,它的查詢過程和二分法類似,同樣都是分治法,不過二叉樹的查詢過程更像是遞迴操作,小於它就進入左子樹,大於它就進入右子樹。遞迴查詢程式碼如下

    // 查詢BST樹中的結點,遞迴寫法
	private TreeNode searchValue(TreeNode node, int value) {	
		if(node == null || node.node_value == value) {
			return node;
		}	
		if(node.node_value < value) {
			return searchValue(node.right_node,value);
		}else{
			return searchValue(node.left_node,value);
		}
	}

非遞迴寫法,使用迴圈來不斷地進入左右子樹來查詢。

    // 查詢BST樹中的結點,非遞迴寫法
    private TreeNode searchValue(TreeNode node, int value) {
		while(node != null) {
			if(node.node_value == value) {
				return node;
			}		
			if(node.node_value < value) {
				node = node.right_node;
			}else if(node.node_value > value) {
				node = node.left_node;
			}
		}
		return node;
	}

插入的過程也只是需要查詢位置所在就行,然後直接插入。這其中也是需要遞迴的思想,不斷地進入左右子樹。

    // 往BST樹中插入節點 
	private TreeNode insertNode(TreeNode node, int data) {
		if(node == null) {
			// 這邊不是直接賦值的,而是初始化
			node = new TreeNode(data);
		}else if(node.node_value == data){
			System.out.println("已經有相同的節點存在");
		}else if(node.node_value > data) {
			node.left_node = insertNode(node.left_node,data);
		}else if(node.node_value < data) {
			node.right_node = insertNode(node.right_node,data);
		}
		return node;
	}

構造過程也比較簡單,更多的都是使用上面插入,只要將元素依次插入就行。

    // 構造BST樹 
	private TreeNode constructTree(int[] data) {
		TreeNode rootNode = new TreeNode(data[0]);
		int start_index = 1;
		
		while(start_index < data.length) {
			System.out.println(start_index);
		    insertNode(rootNode,data[start_index]);
		    start_index++;
		}	
		return rootNode;
	}

刪除的過程分為三種情況:

  1. 如果被刪除的節點是葉子節點,那麼直接刪除。
  2. 如果這個節點上只有一個左子樹或者右子樹,那麼用它的子樹來代替這個根節點。
  3. 如果這個節點上既有左子樹也有右子樹,那麼令節點的直接後繼節點或者直接前驅節點來代替這個節點的位置,然後刪除這個直接後繼或者直接前驅,這樣就能轉換成其他的情況來處理了。不過做題的時候,我經常直接找和刪除節點差不多大的節點就行。
樹:基本樹形
圖4. 刪除情況1
樹:基本樹形
圖5. 刪除情況2
樹:基本樹形
圖6. 刪除情況3

刪除的程式碼如下,這其中只要考慮三種情況就行了。

    // 刪除BST樹中的節點
    // 1. 如果是葉節點,那麼直接變成null
    // 2. 如果只有左子樹或者右子樹,那麼直接用左子樹或者右子樹代替
    // 3. 如果左右都有,那麼先從右子樹中找出直接後繼,也就是右子樹當中最小的值,
    //              或者先從右子樹中找出直接前驅,也就是左子樹中最大的值。
    private void deleteNode(TreeNode rootNode, int delete_value) {
		TreeNode deleteNode = searchValue(rootNode,delete_value);
		if(deleteNode == null) {
			System.out.println("樹中沒有該節點的存在");
		}else {
			if (deleteNode.left_node == null && deleteNode.right_node == null) {
				deleteNode = null;
			} else if (deleteNode.left_node != null && deleteNode.right_node != null) {
				int min_value = findMin(deleteNode.right_node);
				deleteNode.node_value = min_value;
				deleteNode(deleteNode.right_node, min_value);
			} else {
				deleteNode = deleteNode.left_node == null ? deleteNode.right_node : deleteNode.left_node;
			}
		}
	}

從圖中可以看出它的查詢長度與樹的高度有關,如果樹很高,查詢效率就很低。並且BST還有一個問題,就是相同關鍵字,如果插入的順序不同可能生成不同的二叉排序,比如一邊倒

樹:基本樹形
圖7. 一邊倒

這種極端的一邊倒情況,使得樹結構重新變成了連結串列,查詢效率變得更低了,這明顯不是我們想要的,要有沒有辦法使得插入順序無關呢?那就是需要對樹進行平衡操作。

平衡二叉樹(AVL)

平衡二叉樹(AVL),為了上面一邊倒的情況出現,因此規定插入和刪除二叉樹節點的時候,左右子樹的高度差不超過1,這個高度差就是平衡因子。AVL的查詢過程與BST的查詢相同,只不過AVL因為有了平衡因子,這使得含有n的AVL的最大深度就是\(O(log_2n)\),而查詢長度是不會超過最大深度的,因此AVL的平均查詢長度是\(O(log_2n)\)

AVL所特有的就是平衡旋轉操作,平衡旋轉操作一共分為四種,分別是LL平衡旋轉(右單旋轉)、RR平衡旋轉(左單旋轉)、LR平衡旋轉(先左後右旋轉)、RL平衡旋轉(先右後左旋轉)。上面圖3中的BST是可以通過LR平衡旋轉得到AVL,如圖8

樹:基本樹形
圖8. AVL樹

並且在樹節點的寫法也是和BST不同,多了節點高度,用來檢視是否平衡的。

LL平衡旋轉

當插入位置是左子樹的左節點而導致的不平衡,就需要進行LL平衡旋轉。就是將根節點的左節點作為新的根節點,原先的根節點作為新的右節點。如下圖所示

樹:基本樹形
圖9. LL旋轉

程式碼如下所示

    // LL旋轉
	private AVLTreeNode LLrotate(AVLTreeNode rootNode) {
		AVLTreeNode leftNode = rootNode.left_node;
		rootNode.left_node = leftNode.right_node;
		leftNode.right_node = rootNode;
		
		// 更新節點的高度
		rootNode.height = Math.max(getHeight(rootNode.left_node), getHeight(rootNode.right_node)) + 1;
		leftNode.height = Math.max(getHeight(leftNode.left_node), getHeight(leftNode.right_node)) + 1;
		
		return leftNode;
	}

RR平衡旋轉

當插入位置是右子樹的右節點而導致的不平衡,就需要進行RR平衡旋轉。就是將根節點的右節點作為新的根節點,原先的根節點作為新的左節點。如下圖所示

樹:基本樹形
圖10. RR旋轉

程式碼如下所示

    // RR旋轉 
	private AVLTreeNode RRrotate(AVLTreeNode rootNode) {
		AVLTreeNode rightNode = rootNode.right_node;
		rootNode.right_node = rightNode.left_node;
		rightNode.left_node = rootNode;
		
		// 更新節點的高度
		rootNode.height = Math.max(getHeight(rootNode.left_node), getHeight(rootNode.right_node)) + 1;
		rightNode.height = Math.max(getHeight(rightNode.left_node), getHeight(rightNode.right_node)) + 1;
		
		return rightNode;
	}

LR平衡旋轉

當插入位置是左子樹的右節點而導致了不平衡,就需要進行LR平衡旋轉。這個就是我們將圖3的樹變成AVL的平衡旋轉操作,就是將左子樹的右節點4先左旋轉到2,再將4旋轉到根節點,成為新的根節點,原來的根節點就變成了右子樹。

樹:基本樹形
圖11. LR旋轉

這裡的程式碼就比較簡單了,只要運用上面的操作就行了。

    // LR旋轉
	private AVLTreeNode LRrotate(AVLTreeNode rootNode) {
		// 這裡確實就是先將左子樹進行RR,之後再LL,不知道怎麼起名的
		rootNode.left_node = RRrotate(rootNode.left_node);
		return LLrotate(rootNode);
	}

RL平衡旋轉

當插入位置是右子樹的左節點而導致的不平衡,就需要進行RL平衡旋轉,這與LR類似,只不過方向不同。就是將右子樹的左節點先右旋轉到節點6,之後再左旋轉到根節點,成為新的根節點,原先的根節點2變成了左子樹。

樹:基本樹形
圖12. RL旋轉

程式碼如下

    // RL旋轉
	private AVLTreeNode RLrotate(AVLTreeNode rootNode) {
		rootNode.right_node = LLrotate(rootNode.right_node);
		return RRrotate(rootNode);
	}

對於AVL樹的操作,我只想談及插入和刪除,因為其他的都與BST差不多寫法,並且插入和刪除也是差不多,只不過完成操作之後再檢查是否滿足平衡條件。

插入,和BST不同的就是插入完成之後檢查是否平衡,還有一個要更新節點的高度。

     private AVLTreeNode insertNode(AVLTreeNode rootNode, int value) {
		
		if(rootNode != null) {
			if(rootNode.node_value > value) {
				rootNode.left_node = insertNode(rootNode.left_node, value);
				// 插入的是左子樹,那麼檢查左子樹就好,如果不平衡,需要調整
				if(getHeight(rootNode.left_node) - getHeight(rootNode.right_node) > 1) {
					// 插入的位置是左子樹的左節點,使用LL旋轉
					if(value < rootNode.left_node.node_value) {
						rootNode = LLrotate(rootNode);
					}else {
						// 如果插入的位置是左子樹的右節點,那麼LR旋轉
						rootNode = LRrotate(rootNode);
					}
				}
			}else if(rootNode.node_value < value) {
				rootNode.right_node = insertNode(rootNode.right_node, value);
				// 不平衡的話,需要進行調整
				if(getHeight(rootNode.right_node) - getHeight(rootNode.left_node) > 1) {
					// 插入的位置是右子樹的右節點的話,就需要進行RR
					if(value > rootNode.right_node.node_value) {
						rootNode = RRrotate(rootNode);
					}else {
						// 如果是右子樹的左節點,就需要進行RL
						rootNode = RLrotate(rootNode);
					}
				}
			}else {
				System.out.println("不能插入,已經有了相同節點");
			}
		}else {
			rootNode = new AVLTreeNode(value);			
		}
		
		// 更新節點高度
		rootNode.height = Math.max(getHeight(rootNode.left_node), getHeight(rootNode.right_node)) + 1;
		
		return rootNode;
	}

刪除,不同和BST一樣,先進行查詢,因為時刻要調整,不過不需要更新高度,因為旋轉操作會更新節點的高度。

    // 這個和之前不能一樣了,直接使用search方法找,因為它需要時刻調整
	private AVLTreeNode deleteNode(AVLTreeNode avl_tree, int value) {
		if(avl_tree == null) {
			System.out.println("樹中不包含這個值");
		}else {
			if(avl_tree.node_value > value) {
				avl_tree.left_node = deleteNode(avl_tree.left_node,value);
				// 如果不平衡的話,需要調整
				if(getHeight(avl_tree.right_node) - getHeight(avl_tree.left_node) > 1) {
					if(getHeight(avl_tree.right_node.right_node) > getHeight(avl_tree.right_node.left_node)) {
						avl_tree = RRrotate(avl_tree);
					}else {
						avl_tree = RLrotate(avl_tree);
					}
				}
			}else if(avl_tree.node_value < value) {
				avl_tree.right_node = deleteNode(avl_tree.right_node,value);
				
				if(getHeight(avl_tree.left_node) - getHeight(avl_tree.right_node) > 1) {
					if(getHeight(avl_tree.left_node.left_node) > getHeight(avl_tree.left_node.right_node)) {
						avl_tree = LLrotate(avl_tree);
					}else {
						avl_tree = LRrotate(avl_tree);
					}
				}
			}else {
				// 等於這個值,這個和BST樹的操作是一樣的,就是分三種情況,有沒有左右子樹的
				if(avl_tree.left_node != null && avl_tree.right_node != null) {
					int min_value = findMin(avl_tree.right_node);
					avl_tree.node_value = min_value;
					avl_tree.right_node = deleteNode(avl_tree.right_node, min_value);
				}else {
					// 只要這麼一句話就行
					avl_tree = (avl_tree.left_node != null) ? avl_tree.left_node:avl_tree.right_node; 
				}
			}
		}
		return avl_tree;
	}

AVL雖然使得樹不會出現一邊倒的情況,但是每個節點的左子樹和右子樹高度差至多等於1,這個平衡條件過於嚴格,這會導致每次插入和刪除的時候,都會破壞這個規則,之後就需要旋轉來平衡此樹,這會導致開銷過大。為了能夠對平衡和效率之中進行折中,需要一種不嚴格的平衡樹,也就是紅黑樹。

紅黑樹(RBT)

紅黑樹(RBT),一種不嚴格的平衡二叉查詢樹。AVL樹比RBT更加平衡,但是開銷大。大部分自平衡BST都是紅黑樹實現的,java中的treeSet和treeMap也是紅黑樹。RBT與AVL樹不同,對高度差有著嚴格的要求,它的平衡條件最主要的是來自於RBT的一條要求:從任意一個節點到每個葉節點的所有路徑都包含相同數量的黑色節點,簡稱為黑高,也就是黑色節點代表著高度,所以在插入的時候,插入節點都是紅色節點。將之前那個BST樹變成紅黑樹。

樹:基本樹形
圖13. 紅黑樹

可以從圖中看出根的左子樹和右子樹的高並不能滿足AVL的要求,但是RBT只要求黑高即可。可以從種看出RBT其他幾個要求:

  1. 根節點是黑色。
  2. 節點是黑色或者紅色。
  3. 不能有連續的紅色節點。
  4. 任意節點到葉子節點的路徑都包含相同數量的黑色節點。

問題是這幾個條件就能讓RBT保持平衡嗎?那麼依據是什麼?那要看RBT是從何而來的了,RBT是根據2-3-4樹提出來的,2-3-4樹很像一個小型的B樹,只不過2-3-4樹的每個節點最多隻能有三個資料,B樹可以放一磁碟頁資料,並且它的刪除和插入也和B樹一樣,具體操作會在B樹中分析。上面的BST樹可以變成2-3-4樹。

樹:基本樹形
圖14. 2-3-4樹

那麼如何將2-3-4樹變成所需要的紅黑樹中,就是將其中有倆個資料和三個資料的節點對應轉換為紅黑樹節點,其中的對應關係也很簡單,下圖

樹:基本樹形
樹:基本樹形
圖15. 對應關係

本文只討論左傾紅黑樹,也就是紅色節點在左邊,可能有人問上面的2-3-4樹如果按照下面轉換規則來看的話,只有節點3是紅色的啊,但是之前紅黑樹中節點2也是紅色的,這樣不對啊!這個問題還得紅黑樹的插入操作,紅黑樹的插入可以類比2-3-4樹,插入2-節點,3-節點,4-節點(這裡的節點是指子節點,2-節點就是有倆個子節點,也就是一個資料),注意的是旋轉中節點就是原來節點的顏色。

2-節點直接插進去,不過我們是左傾紅黑樹,要有一個旋轉過程,將紅色節點放在左邊。

樹:基本樹形
圖16. RR旋轉
    // 左旋轉,RR旋轉
	private RBTNode RRrotate(RBTNode node) {
		RBTNode rightNode = node.right_node;
		node.right_node = rightNode.left_node;
		rightNode.left_node = node;
		
		// 變色
		rightNode.color_node = !rightNode.color_node;
		node.color_node = !node.color_node;
		
		return rightNode;
	}

3-節點在2-3-4樹也是直接插進去,但是在紅黑樹中就多了一個步驟,就是判斷是否破壞了性質,也就是倆個連續紅色節點,那麼就需要旋轉變色操作。

樹:基本樹形
圖17. LL旋轉
    // 右旋轉, LL旋轉
	private RBTNode LLrotate(RBTNode node) {
		RBTNode leftNode = node.left_node;
		node.left_node = leftNode.right_node;
		leftNode.right_node = node;
		
		// 變色
		leftNode.color_node = !leftNode.color_node;
		node.color_node = !node.color_node;
		
		return leftNode;
	}

4-節點是需要分裂操作,在紅黑樹中也是如此,所以程式碼實現中,一般都是先將4-節點進行變色將之變成2-節點或者3-節點,那麼再插入之後就不會碰到4-節點分裂操作了。

樹:基本樹形
圖18. 分裂變色
    // 分裂操作,也就是變色
	private RBTNode splitFour(RBTNode node) {
		node.color_node = !node.color_node;
		node.left_node.color_node = !node.left_node.color_node;
		node.right_node.color_node = !node.right_node.color_node;
		return node;
	}

程式碼實現中,可以先保證節點不會是4-節點,那麼增加的時候就不會有分裂這種操作,都是直接插入。在本文的紅黑樹插入操作中,如果有4-節點,那麼先對4-節點進行變色操作,之後再依次查詢插入的位置,插入之後,再進行平衡調整,而插入的平衡調整也很簡單,就是上面的倆種情況,也就是倆種操作,一種是LL旋轉,一種是RR旋轉。

    private RBTNode insertNode(RBTNode node, int value) {
		
		if(node != null) {
			// 判斷需不需要變色,有4-節點就變色
			if(isRed(node.left_node) && isRed(node.right_node)) {
				node = splitFour(node);
			}
			
			if(value < node.node_value) {
				node.left_node = insertNode(node.left_node, value);
			}else if(value > node.node_value) {
				node.right_node = insertNode(node.right_node, value);
			}else {
				System.out.println("樹中已經有相同的節點");
			}
		}else {
			node = new RBTNode(value);
		}
		
		// 只需要判斷倆種情況就行,看是否要左旋轉還是左右旋轉
		if(!isRed(node.left_node) && isRed(node.right_node)) node = RRrotate(node);
		if(isRed(node.left_node) && isRed(node.left_node.left_node)) node = LLrotate(node);
		
		return node;
	}

刪除操作比插入操作難很多,因為考慮的情況很多,刪除操作也可以先從2-3-4樹中進行尋找,刪除節點,也會有幾種情況,就是在2-節點、3-節點、4-節點中刪除,

  1. 3-節點、4-節點中的刪除很簡單,就是直接刪掉,紅黑樹中對應的就是刪除紅色節點,也是直接刪除。
  2. 刪除2-節點,在紅黑樹中,就是刪除黑色節點,需要進行討論情況,2-3-4樹中就是要看兄弟是否可借,兄弟可借,那麼刪除節點之後旋轉就行,如果兄弟不可借,那麼需要進行合併操作。

在程式碼實現中,可以類似於插入的時候,提前分解4-節點,刪除的時候提前改造2-節點,將之變成3-節點或者4-節點,這個根據兄弟是否可借和查詢的值在左子樹還是右子樹分為四種情況:

  1. 兄弟不可借,對應的就是,如果查詢的值在紅黑樹的左子樹中,左節點是2-節點,右節點的子節點不是紅色,或者沒有節點。直接對節點變色,成為一個4-節點。
樹:基本樹形
圖19. 查詢左子樹,兄弟不可借
  1. 兄弟可借,對應的就是,該節點是紅節點,如果查詢的值在紅黑樹的左子樹中,左節點是2-節點,右節點的子節點是紅色。需要先進行變色,之後RL旋轉,之後再進行變色。程式碼實現中可以將1,2倆個操作放在一起。
樹:基本樹形
圖20. 查詢左子樹,兄弟可借
    // 變色
	private RBTNode colorFilp(RBTNode rootNode) {
		rootNode.color_node = !rootNode.color_node;
		rootNode.left_node.color_node = !rootNode.left_node.color_node;
		rootNode.right_node.color_node = !rootNode.right_node.color_node;
		return rootNode;
	}

    private RBTNode moveRedLeft(RBTNode rootNode) {
		rootNode = colorFilp(rootNode);
		// 判斷是否兄弟可借
		if(isRed(rootNode.right_node.left_node)) {
			rootNode = RLrotate(rootNode);
			rootNode = colorFilp(rootNode);
		}
		
		return rootNode;
	}
  1. 兄弟不可借,對應的就是,如果查詢的值在紅黑樹的右子樹中,右節點是2-節點,左節點的子節點不是紅色,或者沒有節點。直接對節點變色,成為一個4-節點。
樹:基本樹形
圖21. 查詢右子樹,兄弟不可借
  1. 兄弟可借,對應的就是,該節點是紅節點,如果查詢的值在紅黑樹的右子樹中,右節點是2-節點,左節點的子節點是紅色。需要先進行變色,之後LL旋轉,之後再進行變色。
樹:基本樹形
圖22. 查詢右子樹,兄弟可借
    // 變色
	private RBTNode colorFilp(RBTNode rootNode) {
		rootNode.color_node = !rootNode.color_node;
		rootNode.left_node.color_node = !rootNode.left_node.color_node;
		rootNode.right_node.color_node = !rootNode.right_node.color_node;
		return rootNode;
	}
	
	private RBTNode moveRedRight(RBTNode rootNode) {
		rootNode = colorFilp(rootNode);
		if(isRed(rootNode.left_node.left_node)) {
			rootNode = LLrotate(rootNode);
			rootNode = colorFilp(rootNode);
		}
		return rootNode;
	}

有了這些操作之後,我們可以對刪除操作進行實現,要特別注意刪除中出現的各種情況,特別是去右子樹的時候,也要將紅色節點轉移到右邊,一定要轉移,不然上面的操作不成立(操作都是基於根節點就是紅節點)。

    // 刪除節點
	private RBTNode deleteNode(RBTNode rootNode, int value) {
		if(searchValue(rootNode, value) == null) {
			System.out.println("樹中不包含此節點");
			return rootNode;
		}
		
		// 因為處理的條件就是頂點是紅節點,因而如果是2-節點,並且先有紅節點,才能變色。
		if(!isRed(rootNode.left_node) && !isRed(rootNode.right_node)) {
			rootNode.color_node = true;
		}
		rootNode = deleteProcess(rootNode,value);
		if(rootNode.color_node) {
			rootNode.color_node = false;
		}
		return rootNode;
	}
    
	private RBTNode deleteProcess(RBTNode rootNode, int value) {
		
		if(rootNode.node_value > value) {
			// 檢查查詢位置在左邊的2-節點
			if(!isRed(rootNode.left_node) && !isRed(rootNode.left_node.left_node)) {
				rootNode = moveRedLeft(rootNode);
			}
			rootNode.left_node = deleteProcess(rootNode.left_node, value);
		}else if(rootNode.node_value < value) {
			// 首先先把3-節點的紅色節點移到右邊去,這是因為如果不移動的話,就會出現都是黑色的情況
			// 4-節點不需要移動
			if(isRed(rootNode.left_node) && !isRed(rootNode.right_node)) {
				rootNode = LLrotate(rootNode);
			}
			
			if(!isRed(rootNode.right_node) && !isRed(rootNode.right_node.left_node)) {
				rootNode = moveRedRight(rootNode);
			}
			rootNode.right_node = deleteProcess(rootNode.right_node, value);
		}else {
			// 因為它的節點有顏色,並不能簡單的代替,還要考慮顏色的變化
			// 1. 節點是葉節點,直接刪除
			// 2. 節點有左節點,直接用左節點代替刪除,程式碼中就是LL旋轉
			// 3. 節點有右節點,選出最小的代替刪除節點
			// 2. 節點有左節點和右節點,從有節點中選出最小的代替刪除節點。
			
			// 先將紅色節點進行右移,然後統一處理右邊即可
			if(isRed(rootNode.left_node) && !isRed(rootNode.right_node)) {
				rootNode = LLrotate(rootNode);
			}
			
			// 情況1和情況2的結合
			if(rootNode.node_value == value && rootNode.right_node == null) {
				return null;
			}
			
			// 依舊需要判斷是不是2-節點,因為不能從2-節點中刪除
			if(!isRed(rootNode.left_node) && !isRed(rootNode.right_node)) {
				rootNode = moveRedRight(rootNode);
			}
			
			// 處理第三和第四種情況
			if(rootNode.node_value == value) {
				int min_value = findMin(rootNode.right_node);
				rootNode.node_value = min_value;
				// 刪除右子樹的最小值
//				rootNode.right_node = deleteNode(rootNode.right_node, min_value);
				// 這裡還是單獨寫一個最好,因為這個時候的根節點不需要變成黑色。
				rootNode.right_node = deleteMin(rootNode.right_node);
			}else {
				// 處理因為上面LL旋轉而導致節點變化的問題
				rootNode.right_node = deleteProcess(rootNode.right_node, value);
			}
		}
		
		// 別忘了這是一個左傾,需要調整
		return fixup(rootNode);
	}

紅黑樹的程式碼實現挺難的,今後得好好複習,很多原始碼中的自平衡樹都是使用的紅黑樹實現的,因為開銷小,它其實就是小B樹的二叉實現,這種二叉實現無法滿足資料庫索引的大容量需求,後面產生了B樹和B+樹。

B樹和B+樹

B樹和B+樹的特性就是加了多分支,本身來說。樹在記憶體中使用多分支沒有意義,只會讀取更多的資料而已。但是如果是在索引中的話,上述的樹(AVL、RBT)不適合索引,因為索引檔案很大,因此不能一次性全部讀到記憶體中,因此每次只能從磁碟中讀取一個磁碟頁的資料到記憶體中。因此每次讀取操作都是IO操作,雖然磁碟為了提高效率,有一種區域性性原理的設計(當一個資料被用到的時候,其附近的資料也馬上會被用到,因此讀取的都是一頁資料,一頁可能是4K、8K),但是AVL和RBT樹都無法充分的使用這個區域性性原理,這時因為邏輯上相近的節點,物理儲存上可能很遠,它並不是順序儲存的。並且他倆的樹深度大,RBT比AVL更深,需要的IO操作更多。下面倆種樹的節點大小都是磁碟頁的大小,它們相較於上面的樹,更加的矮胖。

B樹

首先宣告沒有B減樹,只有B樹。造成這種讀音的原因是當年的翻譯。錯把B-樹翻譯成B減樹了,其實中間的-不是減就是個橫槓。

B樹,又稱為多路平衡查詢樹,從這個名字就可以看出,B樹是從二叉平衡樹演變過來的,把二叉變成了N叉,B樹的一個節點大小進行了變化。對於一棵m階B樹來看,必須要滿足一下特性:

  1. 每個節點最多有m個子樹,就是一個節點中有m-1個關鍵字。
  2. 如果根節點不是終端節點,那麼根節點至少有倆個子樹
  3. 除了葉子節點外,所有的非葉子節點至少有\(\lceil m/2 \rceil\)個子樹,也就是\(\lceil m/2 \rceil-1\)個關鍵字。
  4. 節點中的關鍵字是按照順序的。
樹:基本樹形

因而B樹的定義需要儲存多個關鍵字,外加多個子節點,為了後面方便判斷是否是葉子節點,需要有一個布林值記錄是否為葉子節點。

public class BTreeNode {
	// 樹的階,也就是樹有幾個子節點
	int m = 4;
	List<Integer> keys;
    List<BTreeNode> b_node;
    boolean isLeaf;
    
    public BTreeNode() {
    	keys = new ArrayList<Integer>(m-1);
    	b_node = new ArrayList<BTreeNode>(m);
    	isLeaf = true;
    }
}

而B樹的操作,其實和上面的紅黑樹有異曲同工之妙,因為紅黑樹就是可以看成2-3-4樹,等同於4階樹,因而可以從上面的紅黑樹來看B樹程式碼的實現。先看查詢,B樹的查詢是先找節點,再從節點中找關鍵字,這也是根據區域性性原理設計出的。檢視的時候需要判斷是否為葉子節點來判斷結束與否。

    // 查詢
	private BTreeNode searchValue(BTreeNode rootNode, int value) {
		Iterator iter = rootNode.keys.iterator();
		int index = 0;
		boolean notResult = true;
		while(iter.hasNext() && notResult) {
			int current_key = (int) iter.next();
			if(value == current_key) {
				notResult= false;
				return rootNode;
			}else if(value < current_key && !rootNode.isLeaf) {
				notResult= false;
				return searchValue(rootNode.b_node.get(index),value);
			}
			index++;
		}
		if(notResult && !rootNode.isLeaf) {
			return searchValue(rootNode.b_node.get(index),value);
		}
		return null;
	}

插入是個小難點,插入會導致節點滿了,超過了m-1個,使得節點進行分裂操作,分裂就是將中間節點提出來插入到父結點中,原來的節點刪除值之後作為左節點,新建一個節點作為右節點,再往右節點中新增關鍵字,如果這個節點不是葉子節點,還需要新增左右子樹。

樹:基本樹形
圖23. 分裂提取
    // 分裂操作
	private BTreeNode splitNode(BTreeNode rootNode, BTreeNode childNode, int insert_index) {
		int middle = childNode.m / 2 - 1;
		// 之前的節點做左節點,新定義的節點做右節點
		BTreeNode right_leaf = new BTreeNode();
		right_leaf.isLeaf = childNode.isLeaf;
		rootNode.b_node.add(insert_index + 1, right_leaf);
		// 定義好右節點
		if(!childNode.isLeaf) right_leaf.b_node.add(0, childNode.b_node.remove(childNode.m-1));
		int key_index = childNode.m - 2;
		while(key_index > middle) {
			right_leaf.keys.add(0, childNode.keys.remove(key_index));
			if(!childNode.isLeaf) right_leaf.b_node.add(0, childNode.b_node.remove(key_index));
			key_index--;
		}
		// 父節點和分裂的節點合併
		rootNode.keys.add(insert_index, childNode.keys.remove(middle));
		
		return rootNode;
	}

分裂操作設計到了到父節點與子節點的合併,如果遞迴的話,並不會很好用程式碼實現,因而在寫程式碼的時候在進去相關子樹之前,需要先對子節點進行判斷,是否子節點已滿,如果滿了,先進行分裂操作,找出中間值放入到父節點中,父節點已經檢查過個數了,因為插入中間值不會滿。如果找到了葉子節點,那麼直接插入到葉子節點中,這裡不用擔心葉子節點會滿,並且B樹的值就是插入葉子節點中的。

    // 插入操作 
	private BTreeNode insertNode(BTreeNode rootNode, int value) {
		
		if(rootNode.isLeaf) {
			// 找到的話,直接插入即可,因為葉節點沒有子節點,所以不用考慮子節點的去向		
			rootNode.insertkey(value);
		}else {
			// 如果所走的子節點滿了就需要進行分裂操作
			Iterator it = rootNode.keys.iterator();
			boolean notFind = true;
			int insert_index = 0;
			while(it.hasNext() && notFind) {
				int current_key = (int) it.next();
				if(value < current_key) {
					// 找到插入位置
					notFind = false;
					// 檢查子節點是否滿了
					if(rootNode.b_node.get(insert_index).keys.size() == rootNode.m - 1) {
						rootNode = splitNode(rootNode,rootNode.b_node.get(insert_index),insert_index);
					}
					
					// 重新判斷這個值的大小
					if(value > rootNode.keys.get(insert_index)) {
						BTreeNode current_node = rootNode.b_node.get(insert_index + 1);
						current_node = insertNode(current_node,value);
					}else {
						BTreeNode current_node = rootNode.b_node.get(insert_index);
						current_node = insertNode(current_node,value);
					}
					
				}else if(value == current_key) {
					// 樹中有相同的值
					System.out.println("樹中有相同的值");
				}else {
					insert_index++;
				}
			}
			
			// 插入到最右邊
			if(notFind) {
				// 檢查子節點是否滿了
				if(rootNode.b_node.get(insert_index).keys.size() == rootNode.m - 1) {
					rootNode = splitNode(rootNode,rootNode.b_node.get(insert_index),insert_index);
				}
				
				// 重新判斷這個值的大小
				if(value > rootNode.keys.get(insert_index)) {
					BTreeNode current_node = rootNode.b_node.get(insert_index + 1);
					current_node = insertNode(current_node,value);
				}
				BTreeNode current_node = rootNode.b_node.get(insert_index);
				current_node = insertNode(current_node,value);
			}
				
		}
		
		return rootNode;
	}

刪除的操作就比較複雜了,插入在刪除面前可能就是個弟弟。不過還是和以前一樣,不能讓節點中的關鍵字變成\(\lceil m/2 \rceil-1\),那麼在刪除的過程中,需要判斷子節點的關鍵字是否大於\(\lceil m/2 \rceil-1\),分為種情況:

  1. 子節點的關鍵字個數大於\(\lceil m/2 \rceil-1\),那麼直接進入子樹。
  2. 子節點的關鍵字個數等於\(\lceil m/2 \rceil-1\),那麼看左右兄弟是否可借,並且還需要判斷是否有左右兄弟,因為第一個分支子樹,沒有左兄弟,最後一個沒有右兄弟。如果右兄弟可借,那麼需要右兄弟的最小值代替當前節點的關鍵字,然後當前節點的關鍵字到相關子節點中。還需要看是否是葉子節點,如果不是葉子節點,還需要設定子樹。
樹:基本樹形
圖24. 右兄弟可借
  1. 如果左兄弟可借,那麼就是將上圖倒回去,用左兄弟的最大值代替關鍵字,並將關鍵字到相關子樹中,之後看是否為葉子節點,如果不是,也需要設定子樹。
樹:基本樹形
圖25. 左兄弟可借
  1. 如果左右兄弟都不可借,那麼就是將關鍵字、相關子樹的關鍵字和左(右)節點合併,並且一定要記得加入關鍵字,不然兄弟的子樹很難合併。因為多路分支的原因,還是需要判斷是否有左兄弟還是有右兄弟。
樹:基本樹形
圖26. 左右兄弟不可借
  1. 上面都是要刪除的值不在當前節點中的情況,如果不在當前節點並且當前節點是葉子節點的話,那麼就沒有這個值。

  2. 下面就是刪除的值就在當前節點中,已經找到了要刪除的值了,如果是葉子節點,那麼直接刪除,因為上面的操作使得它的關鍵字個數肯定大於\(\lceil m/2 \rceil-1\),並且是葉子節點也不需要考慮子樹的問題,因而直接刪除。

  3. 如果不是葉子節點,那麼我們不需要判斷左右子樹是否存在,因為一個節點一定存在左右子樹。如果左子樹的關鍵字個數大於\(\lceil m/2 \rceil-1\),找出左子樹的最大值,也就是刪除值的直接前驅來代替這個刪除值,然後刪除左子樹中的這個最大值。記住是直接前驅,不是左子節點的最大值,而是左子樹的最大值。

樹:基本樹形
圖27. 非葉節點,找直接前驅
  1. 如果右子樹的關鍵字個數大於\(\lceil m/2 \rceil-1\),找出右子樹的最小值,也就是刪除值的直接後繼來代替這個刪除值,然後刪除右子樹中的這個最小值。
樹:基本樹形
圖28. 非葉節點,找直接後繼
  1. 如果左右子樹的關鍵字個數都是\(\lceil m/2 \rceil-1\),那麼需要先將左右子樹合併,圖中可以看到先將左右子節點和關鍵字一起合併,這是因為如果左右子節點不是葉子節點,那麼節點3的右子樹和節點9的左子樹進行合併,這邊比較複雜了,不如直接先插入關鍵字8,那麼就不用考慮的那麼多,直接插入子樹了。之後再進行刪除操作即可。
樹:基本樹形
圖29. 非葉節點,合併
  1. 還需要考慮根節點因為合併操作而沒有值,那麼刪除之後還需要檢查根節點,如果出現了這種10情況,還需要將根節點的子樹更新為根節點

如果將這十種情況都考慮進去的話,那麼就可以依次寫出程式碼,程式碼很長,如下

    // 刪除操作的入口
	public BTreeNode deleteNode(BTreeNode rootNode, int value) {
		rootNode = deleteProcess(rootNode,value);
		if(rootNode.keys.size() == 0 && rootNode.b_node.size() == 1) {
			rootNode = rootNode.b_node.get(0);
		}
		return rootNode;
	}

	// 刪除,操作比較複雜,一共有十種情況,
	private BTreeNode deleteProcess(BTreeNode rootNode, int value) {
		// 首先確定值是不是在當前節點中
		int index = rootNode.containKey(value);
		int min_num = (int)rootNode.m / 2 - 1;
		if(index != -1) {
			// 如果在當前節點,並且是葉子節點,那麼直接刪掉
			if(rootNode.isLeaf) {
				rootNode.keys.remove(index);
				return rootNode;
			}else {
				BTreeNode left_node = rootNode.b_node.get(index);
				BTreeNode right_node = rootNode.b_node.get(index + 1);
				// 如果在當前節點,但是不是葉子節點,那麼看左右子節點誰的Key個數大於最小值,就找出一個最值來代替當前節點的key
				// 檢視左節點是否大於最小值
				if(left_node.keys.size() > min_num) {
					// 找出該節點的直接前驅代替這個節點
					int max_value = findMax(left_node);
					rootNode.keys.set(index, max_value);
					left_node = deleteNode(left_node,max_value);
					return rootNode;
				}else if(right_node.keys.size() > min_num) {
					// 檢視右節點是否大於最小值
					// 找出該節點的直接後繼代替這個節點
					int min_value = findMin(right_node);
					rootNode.keys.set(index, min_value);
					right_node = deleteNode(right_node,min_value);
					return rootNode;
				}else {
					
					// 如果左右都是最小數量的節點數,先將要刪除的key插入,為了方便後面插入子樹
					left_node.keys.add(value);
					
					// 再將倆個節點的key合併
					for(int one_key:right_node.keys) {
						left_node.keys.add(one_key);
					}
					if(!right_node.isLeaf) {
						for(BTreeNode one_node:right_node.b_node) {
							left_node.b_node.add(one_node);
						}
					}
					
					// 直接刪除key的值
					rootNode.keys.remove(index);
					// 刪除右節點
					rootNode.b_node.remove(index+1);
                    left_node = deleteNode(left_node, value);     
					return rootNode;
				}
			}
		}else {
			if(rootNode.isLeaf) {
				System.out.println("刪除失敗,數不在該樹中");
				return rootNode;
			}
			
			// 如果不在當前節點中,那麼需要檢查子節點的值的個數是否大於最小數量
			int pos = rootNode.searchInsertPos(value);
			BTreeNode current_path = rootNode.b_node.get(pos);
			if(current_path.keys.size() == min_num) {
				// 子節點是最小數量,那麼需要先進行處理,再往下走
				// 看兄弟是否可借,進行借數
				// 如果有右兄弟,那麼借右兄弟的
				if(pos < rootNode.keys.size() && rootNode.b_node.get(pos + 1).keys.size() > min_num) {
					// 借右兄弟的
					BTreeNode right_node = rootNode.b_node.get(pos + 1);
					current_path.keys.add(rootNode.keys.get(pos));
					if(!right_node.isLeaf) current_path.b_node.add(right_node.b_node.remove(0));
					rootNode.keys.set(pos, right_node.keys.remove(0));
					current_path = deleteNode(current_path,value);
					return rootNode;
				}else if(pos == rootNode.keys.size() && rootNode.b_node.get(pos - 1).keys.size() > min_num){
					// 借左兄弟的
					BTreeNode left_node = rootNode.b_node.get(pos - 1);
					current_path.keys.add(0, rootNode.keys.get(pos - 1));
					if(!left_node.isLeaf) current_path.b_node.add(0, left_node.b_node.remove(left_node.keys.size()));
					rootNode.keys.set(pos - 1, left_node.keys.remove(left_node.keys.size()-1));
					current_path = deleteNode(current_path,value);
					return rootNode;
				}else {
					// 左右兄弟都不可借,合併操作
					// 有右兄弟
					if(pos < rootNode.keys.size()) {
						BTreeNode right_node = rootNode.b_node.get(pos + 1);
						current_path.keys.add(rootNode.keys.get(pos));
						for(int one_key:right_node.keys) {
							current_path.keys.add(one_key);
						}
						if(!right_node.isLeaf) {
							for(BTreeNode one_node:right_node.b_node) {
								current_path.b_node.add(one_node);
							}
						}
						rootNode.keys.remove(pos);
						rootNode.b_node.remove(pos+1);
						current_path = deleteProcess(current_path, value);
						return rootNode;
					}else {
						// 只有左兄弟
						BTreeNode left_node = rootNode.b_node.get(pos - 1);
						current_path.keys.add(0,rootNode.keys.get(pos));
						for(int one_index = left_node.keys.size() - 1;one_index >= 0;one_index--) {
							current_path.keys.add(0,left_node.keys.get(one_index));
						}
						if(!left_node.isLeaf) {
							for(int one_index = left_node.b_node.size() - 1;one_index >= 0;one_index--) {
								current_path.b_node.add(0,left_node.b_node.get(one_index));
							}
						}
						rootNode.keys.remove(pos);
						rootNode.b_node.remove(pos-1);
						current_path = deleteProcess(current_path, value);
						return rootNode;
					}			
				}			
			}else {
				current_path = deleteProcess(current_path, value);
				return rootNode;
			}
		}
	}

B+樹

B樹將資料儲存在節點中,為了能使樹儲存的更多,這樣才能減少IO操作,出現了B+樹,就是非葉子節點上不儲存資料,只儲存索引,這會導致所有的查詢都會到葉子節點上,那麼查詢效能就是穩定的。並且B+樹更加適合範圍查詢,B提高了磁碟IO的效能但是並沒有解決遍歷效率低下的問題,這時因為它所有的資料都存在節點中,B+的資料都在葉子節點中,並且葉子節點連結在一起,那麼只要遍歷葉子節點就可以實現整棵樹的遍歷,比如查詢3到8,B+樹只要找到3,再找到8,然後將3到8的葉子節點都串起來就行了,但是B+就比較麻煩了,得一個一個得查詢。但是B樹也有優勢,就是單個成功查詢比B+樹要好,因為資料就在節點上,每個成功查詢可能不需要查詢到葉子節點中,只要到中間的節點上就行找到結果了,但是B+是必須要到葉子節點才能找到資料。B樹的遍歷是中序遍歷,B+只是順序遍歷。
B+樹,很多資料庫比如mysql就是使用的B+樹,一個m階的B+樹性質如下:

  1. 每個分支最多有m棵子樹。
  2. 子樹個數與關鍵字個數一樣。
  3. 根節點至少倆棵子樹,其他每個節點至少有\(\lceil m-2 \rceil\)
  4. 所有葉子節點包含關鍵字及其指向相應記錄的指標,葉子節點將關鍵字按照大小順序排列,相鄰葉子節點還會連結。
  5. 所有非葉子節點僅僅起到了索引作用,並且每個索引項只含有對應子樹的最大關鍵字和指向該子樹的指標,不含有該關鍵字對應記錄儲存地址。
樹:基本樹形
圖30. B+樹

而B+樹的插入刪除和B樹的插入刪除基本思想上是差不多的,主要的核心程式碼不變,只是一些細節上的改變。

哈夫曼樹(最優二叉樹)

哈夫曼樹就是利用貪心的思想將帶權路徑長度達到了最小,帶權路徑長度就是根節點到任意節點的路徑長度與相應節點上權值的乘積的和為該節點的帶權路徑長度(WPL)。它的構造方法也是一種自底向上的方法。

  1. 先選出倆個權值最小節點的作為新節點的左右子樹,這個新節點的權值就是選出的倆個節點的權值和
  2. 之後從中刪除上面所選的倆個節點,只保留了新建立出來的子樹。
  3. 不斷地重複上面地過程,一直選,然後刪除即可。
樹:基本樹形
圖31. 哈夫曼樹的構造過程

按照上面地構造過程,那麼權重越小地離根節點也就越遠,權重越大地離根節點越近。個人感覺可以理解為那些盲文一樣,在生活中越頻繁使用的,那麼就編碼長度就越小,比如生活中的"的"之類的,這些文字的編碼一定很短,構造出來的WPL一定是最小的。構造的程式碼如下

    // 使用佇列來構造樹
	private HuffmanTreeNode createTree(int[] data) {
		List<HuffmanTreeNode> huffman_queue = new ArrayList<HuffmanTreeNode>();
		HuffmanTreeNode rootNode = null;
		// 先將全部值都變成節點
		for(int one_data:data) {
			HuffmanTreeNode node = new HuffmanTreeNode(one_data);
			huffman_queue.add(node);
		}
		// 物件排序
		huffman_queue.sort(Comparator.naturalOrder());
		
		while(!huffman_queue.isEmpty()) {
			// 選出最小權重的倆個點,組成一個新的節點
			HuffmanTreeNode leftNode = huffman_queue.remove(0);
			HuffmanTreeNode rightNode = huffman_queue.remove(0);
			HuffmanTreeNode parentNode = new HuffmanTreeNode(leftNode.weight + rightNode.weight,
					                                         leftNode,
					                                         rightNode);
			// 只要佇列中還有值,就繼續
			if(!huffman_queue.isEmpty()) {
				huffman_queue.add(parentNode);
				huffman_queue.sort(Comparator.naturalOrder());
			}else {
				// 佇列中沒有值了,那麼就更新路徑長度即可
				rootNode = updateLength(parentNode);
			}
		}
		return rootNode;
	}
	
	private HuffmanTreeNode updateLength(HuffmanTreeNode node) {
		// 更新左子樹
		if(node.left_node != null) {
			node.left_node.length_node = node.length_node + 1;
			node.left_node = updateLength(node.left_node);
		}
		// 更新右子樹
		if(node.right_node != null) {
			node.right_node.length_node = node.length_node + 1;
			node.right_node = updateLength(node.right_node);
		}
		
		return node;
	}

之後的程式碼就是得到這個哈夫曼樹的WPL,這個就很簡單了,只要找出葉子節點,然後將所有葉子節點的權重和長度乘機相加即可。

    // 得到WPL
	private int getWPL(HuffmanTreeNode rootNode) {
		// 只要葉子節點的WPL即可
		if(rootNode.left_node == null && rootNode.right_node == null) {
			return rootNode.length_node*rootNode.weight;
		}else {
			int left_wpl = 0;
			int right_wpl = 0;
			if(rootNode.left_node != null) {
				left_wpl = getWPL(rootNode.left_node);
			}
			
			if(rootNode.right_node != null) {
				right_wpl = getWPL(rootNode.right_node);
			}
			return left_wpl + right_wpl;
		}
		
	}

哈夫曼樹總體的程式碼和其他樹比起來,還是相對比較簡單的。

總結

這章主要就是為了複習一下資料結構中樹的內容,所以想的就是把所有常用的樹都寫一遍,加深一下印象,從普通的二叉排序樹,為了達到自平衡到後來的平衡二叉樹,之後為了減小開銷而發展出的紅黑樹,後來需要大量索引而引出了B和B+樹,最後就是WPL最小的哈夫曼樹,總體上是慢慢加深強度,到了紅黑樹和B樹是最難的,特別是紅黑樹,需要用2-3-4樹作為參照才能理解好紅黑樹的操作,雖然理解,但是程式碼實現上也是有很多技巧的,比如在當前節點的時候處理子節點的情況,這些在程式碼的實現上都需要好好理解。這章的程式碼內容會放在我的github,有興趣的可以看看。

相關文章