數據結構梳理(5) - 二叉樹

前言

接上次的隊列,我們今天來梳理一下既複雜又簡單的數據結構,就是樹,大致還是按照之前的流程,先手動實現一遍,然後再學一些樹在時實際開發中的不同類型,例如二叉排序樹、堆等,主要是針對二叉樹來說。

目錄

1、二叉樹的結構、基本操作、常見類型
2、基於數組實現二叉樹
3、基於鏈表實現二叉樹
4、二叉排序樹
5、平衡二叉樹
6、堆

正文

1、二叉樹的結構、基本操作、常見類型

1.1 二叉樹的結構

先來看看二叉樹的概念,有個基本的認識,二叉樹,是一種樹狀的數據結構,對其中每個節點來說,包含這樣幾個數據,一個是該節點代表的值,一個是該節點的左孩子節點,一個是該節點的右孩子節點,從整體結構來看,每個節點只能有兩個孩子節點,沒有孩子節點的節點稱爲葉子節點。

上面說了這麼多,感覺還是一張清晰的圖來的實在,如下
在這裏插入圖片描述
怎麼樣,是不是一圖勝千言,嘻嘻,下面我們再來學習一下二叉樹的基本操作。

1.2 二叉樹的基本操作

對二叉樹來說,它的基本操作除了增加節點,刪除節點,更新節點的值之外,最重要的就是各種"花式遍歷"了,包括,前序遍歷,中序遍歷,後序遍歷,層序遍歷。

如果只是一顆普通的二叉樹,那麼對於增刪改這三個操作來說,就很簡單了,增加只需要在樹的最後一個位置增加節點即可,刪除只需要遍歷整個樹,然後刪除該節點,並將其左右子樹中的任意一個替換到刪除節點的位置,若無葉子節點,就直接刪除即可,修改節點也是一樣,遍歷找到該節點,然後修改其值即可。

然後就是遍歷操作,上面說了,遍歷一共有四種常見的遍歷方式,具體的遍歷方法如下:

  • 前序遍歷:先遍歷根節點,再遍歷根節點的左子節點,再遍歷根節點的右子節點。在遍歷子節點的時候,同樣遞歸繼續遍歷。簡稱“根左右”
  • 中序遍歷:和前序遍歷類似,它的遍歷順序是“左根右”
  • 後序遍歷:和前序遍歷類似,它的遍歷順序是“左右根”
  • 層序遍歷:和上面三種不同, 這種遍歷順序是從樹的根節點開始,逐層網下遍歷,對每一層來說,一般是從左邊開始往右邊遍歷。

這裏我最初接觸到的時候,也是覺得難以一下子理解,後來自己拿一顆樹來比劃一下之後,就很容易明白了這四種遍歷方式及其區別,所以,下面我拿一顆樹來舉例子,看這四種遍歷方式最後會得到什麼結果。
在這裏插入圖片描述

首先是分析前序遍歷,爲什麼是前序遍歷呢,因爲這個遍歷比較符合大多數人的正常遍歷思維,所以就拿這個來舉例詳解,前序遍歷的順序是根左右,我們只要時刻遵循這三個字的順序即可,然後我們來嘗試訪問。

首先從根節點開始,首先訪問5(根),然後是4(5的左),由於4有子節點,所以不能接着訪問5的右節點,要先把左子樹訪問完,所以此時4應作爲根節點,繼續從根節點開始訪問,4(根)的下一個節點是2(4的左),由於2沒有子節點,所以下一個是8(4的右),由於8有子節點,所以8作爲根,繼續訪問6(8的左),由於8沒有右子節點,所以8這棵樹訪問完畢,從而4的右子樹也訪問完畢,從而5的左子樹訪問完畢,接着訪問3(5的右),由於3有子節點,所以3作爲根節點,接着訪問,由於沒有左節點,所以訪問3的右節點7(3的右)。

綜上,前序遍歷的結果就是5428637,然後同樣的分析方法,我們可以得到中序遍歷的結果是2468537,後序遍歷的結果是2684735。

最後只剩下個層序遍歷了,我們再來看看這個的遍歷結果是什麼,首先是根節點開始,訪問5(第一層),現在第一層沒有其它元素了,開始訪問4(第二層從左至右第一個元素),然後是3(第二層從左至右第二個元素),然後第二層沒了,接下來是第三層,同樣的先訪問2(第三層最左邊的元素),然後是8(第三層從左至右第二個元素),然後是7(第三層從左至右第三個元素),第三層也訪問完了,接下來是第四層,由於只有一個元素,所以訪問6。至此,訪問完畢,所以層序遍歷的最終結果是5432876。

怎麼樣,是不是有了實例之後,對這幾種遍歷一下子就明白了許多,至於這幾種遍歷方式要怎麼去實現,馬上就會說到啦!

1.3 二叉樹的常見類型

二叉樹其實也和隊列一樣,在實際使用的時候,會加上許多額外的特性來方便使用,所以這就會演化出各種各樣類型的二叉樹,如果要全部羅列他們的話,估計有數十種,而且也沒有去全部掌握他們的必要,我們同樣的會挑幾個非常典型的來學習,達到舉一反三的效果。

我這裏把我認爲比較典型的樹羅列出來,在後面再挑幾個詳細介紹,主要有:完全二叉樹,滿二叉樹,二叉排序樹,平衡二叉樹,堆,紅黑樹,B樹,B+樹,B-樹。

這裏額外說明一下,B/B+/B-樹,這三個其實並不能算作二叉樹,它們應該叫多叉樹,不過由於使用的比較多,更多的是和後臺相關,所以我就羅列了出來,不做過多的介紹。然後後面我會詳細介紹二叉排序樹,平衡二叉樹,以及堆這三者,所以它們就先放着,然後剩下完全二叉樹、滿二叉樹和紅黑樹,這裏來看看它們的概念。

首先是完全二叉樹,它的概念如下:滿足二叉樹的性質的條件下,最後一層的葉子節點都是靠左的。然後是滿二叉樹:滿足二叉樹的性質的條件下,每個節點都有兩個子節點,其實還有一種樹,叫完美二叉樹,完美二叉樹其實就是每層都是“飽滿的”,下面三張圖就可以清晰的區分這三者。

完全二叉樹如下:
在這裏插入圖片描述
滿二叉樹如下:
在這裏插入圖片描述
完美二叉樹如下:
在這裏插入圖片描述

然後是紅黑樹,紅黑樹是一種非常特別的樹,它除了具有二叉樹的性質之外,額外的性質如下:
性質1. 節點是紅色或黑色。
性質2. 根節點是黑色。
性質3. 每個葉節點(NIL節點,空節點)是黑色的。
性質4. 每個紅色節點的兩個子節點都是黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)
性質5. 從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。

一顆符合條件的紅黑樹如下:
在這裏插入圖片描述

2、基於數組實現二叉樹

上面對二叉樹有了一個基本的瞭解,下面我們來手動實現一個簡單的二叉樹,和之前一樣,我們先實現基於數組的。

Ok,我們接下來在實現的時候,可能遇到的最大的一個問題就是如何用數組這個線性的數據結構來存儲一個樹狀的數據結構,至於其它的問題,例如初始化以及基本操作的實現,基本上只要把這個問題解決了,都不是什麼問題了,所以這是一個最核心的問題。這時候,我們就需要去發散我們的思維了,我們嘗試找一顆樹來看,從根節點開始,從上往下,從左往右,依次按照下標0、1、2、、、標號,會發現一個規律,左孩子節點的下標是父節點下標乘以2再加1,右孩子節點的下標就是父節點下標乘以2再加2,分析到了這裏,其實大部分問題都已經解決了,還有一個就是如何存儲空節點,這個可以使用null來存儲,Ok,核心問題解決了,現在我們來寫代碼。

第一個問題就是成員變量和初始化,我們可以預設一個數組來用來初始化,然後就是一個聲明一個數組長度的變量,如下

private int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 };
private int size = arr.length;

當然,這個工作也可以放在構造方法中,然後就是插入和刪除節點的方法了,由於這裏只是一顆普通的二叉樹,所以插入刪除節點的邏輯就很簡單,只需要在數組尾端加上相應的數據即可。如下是插入節點的代碼。

public void insert(int value){
	int[] temp=new int[size];
	for(int i=0;i<size;i++){
		temp[i]=arr[i];
	}
	arr=new int[size+1];
	for(int i=0;i<size;i++){
		arr[i]=temp[i];
	}
	arr[size]=value;
	size=arr.length;
}

然後我們爲了使用的方便,可以爲其加上一些常用的方法,例如獲取左孩子節點、右孩子節點、獲取樹的深度等等,由於我們明白了父節點和子節點的下標關係,所以這些方法的實現都很簡單,如下

//獲取樹的深度
public int getDeep() {
	int deep;
	deep = (int) Math.ceil(Math.log10((double) size + 1) / Math.log10(2.0));
	return deep;
}
//獲取左孩子節點的值
public int getLeftChild(int index) {
	if (index * 2 + 1 >= size) {
		throw new RuntimeException("越界啦");
	}
	return arr[index * 2 + 1];
}
//獲取右孩子節點的值
public int getRightChild(int index) {
	if (index * 2 + 1 >= size) {
		throw new RuntimeException("越界啦");
	}
	return arr[index * 2 + 2];
}
//獲取左孩子節點的下標
public int getLeftChildIndex(int index) {
	if (index * 2 + 1 >= size) {
		return -1;
	}
	return index * 2 + 1;
}
//獲取右孩子節點的下標
public int getRightChildIndex(int index) {
	if (index * 2 + 2 >= size) {
		return -1;
	}
	return index * 2 + 2;
}
//獲取父節點的值
public int getParent(int index) {
	if (index < 2 || index >= size) {
		throw new RuntimeException("越界啦");
	}
	return arr[index / 2 - 1];
}

有了基本操作之後,接下來就是我們最重要的一部分了,那就是遍歷,上面關於二叉樹的幾種遍歷方式已經做了比較詳細的解釋,下面我們來實現這些遍歷,對於前三種遍歷,前序遍歷、中序遍歷、後序遍歷,我們很自然的想到的方式是遞歸,ok,我們不難寫出如下的遞歸代碼

// 先序遍歷
public void preOrderTraverse(int index) {
	
	if (arr == null) {
		throw new RuntimeException("空指針異常");
	}
	System.out.print(arr[index] + " ");
	if (getLeftChildIndex(index) != -1) {
		preOrderTraverse(getLeftChildIndex(index));
	}
	if (getRightChildIndex(index) != -1) {
		preOrderTraverse(getRightChildIndex(index));
	}

}

// 中序遍歷
public void inOrderTraverse(int index) {
	if (arr == null) {
		throw new RuntimeException("空指針異常");
	}
	if (getLeftChildIndex(index) != -1) {
		inOrderTraverse(getLeftChildIndex(index));
	}
	System.out.print(arr[index] + " ");
	if (getRightChildIndex(index) != -1) {
		inOrderTraverse(getRightChildIndex(index));
	}

}

// 後序遍歷
public void postOrderTraverse(int index) {
	if (arr == null) {
		throw new RuntimeException("空指針異常");
	}
	if (getLeftChildIndex(index) != -1) {
		postOrderTraverse(getLeftChildIndex(index));
	}
	if (getRightChildIndex(index) != -1) {
		postOrderTraverse(getRightChildIndex(index));
	}
	System.out.print(arr[index] + " ");
}

最後,我們可以寫一些測試用例,來判斷代碼的正確性,由於使用數組存儲的樹沒有太多複雜的地方,所以實現的也很簡略,下面重點看看基於鏈表的是怎麼實現的 。

3、基於鏈表實現二叉樹

我們再來基於鏈表實現,由於是基於鏈表,所以我們上面遇到的問題,如何存儲樹狀的數據元素這個核心問題,其實就已經解決了,因爲可以使用對象指針,ok,現在我們來思考節點的初始化和成員變量的問題,由於每個節點主要有三個值,左孩子、右孩子和數據值,所以節點的初始化代碼如下

// 二叉鏈表實現法,即節點只有數據域+左節點指針+右節點 指針
public class Node {

	private int data;
	private Node lChild;
	private Node rChild;

	public Node() {
		this(0);
	}

	public Node(int data) {
		this.data = data;
		lChild = null;
		rChild = null;
	}

	public int getData() {
		return data;
	}

	public void setData(int data) {
		this.data = data;
	}

	public Node getlChild() {
		return lChild;
	}

	public void setlChild(Node lChild) {
		this.lChild = lChild;
	}

	public Node getrChild() {
		return rChild;
	}

	public void setrChild(Node rChild) {
		this.rChild = rChild;
	}

}

Node是一個內部類,然後我們再來看成員變量,因爲是基於鏈表,所以我們可以不設置容量限制,只需要設置一個根節點的成員變量即可,如下

private Node root = null;

public LinkedBiTree() {

}

然後就是樹的初始構建,這個可以通過手動添加節點來實現,也可自行寫一個構建樹的方法來實現,我這裏爲了方便就寫了一個構建樹的方法,如下

public void createBiTree() {
	root = new Node(1);
	Node NodeB = new Node(2);
	Node NodeC = new Node(8);
	Node NodeD = new Node(9);
	Node NodeE = new Node(4);
	Node NodeF = new Node(7);

	root.lChild = NodeB;
	root.rChild = NodeC;
	NodeB.lChild = NodeD;
	NodeB.rChild = NodeE;
	NodeC.rChild = NodeF;
}

有了數據元素之後,我們再同樣的爲其添加上如下的普通操作方法,方便使用

public boolean isEmpty() {
	return root == null;
}

// 求某個節點的子節點的個數,包括自身
public int getChildNodeNum(Node node) {
	if (node == null) {
		return 0;
	} else {
		int leftSize = getChildNodeNum(node.lChild);
		int rightSize = getChildNodeNum(node.rChild);
		return leftSize + rightSize + 1;
	}
}

// 求指定節點的高度
public int getNodeheight(Node node) {
	if (node == null) {
		return 0; // 遞歸結束:空樹高度爲0
	} else {
		int leftHeight = getNodeheight(node.lChild) + 1;
		int rightHeight = getNodeheight(node.rChild) + 1;
		return leftHeight > rightHeight ? leftHeight : rightHeight;
	}
}

public int getDeep() {
	return getNodeheight(root);
}

// 後序遞歸釋放節點
public void destroy(Node node) {
	if (node == null) {
		return;
	} else {
		destroy(node.lChild);
		destroy(node.rChild);
		node = null;
	}
}

接下來就是核心問題,遍歷,這裏我們的常規思路是直接使用遞歸來實現,相關代碼如下

// 遞歸前序遍歷 根-左-右
public void preOrderTraverse(Node node) {
	if (node == null) {
		return;
	}
	System.out.print(node.data);
	if (node.lChild != null) {
		preOrderTraverse(node.lChild);
	}
	if (node.rChild != null) {
		preOrderTraverse(node.rChild);
	}
}

// 遞歸中序遍歷 左-根-右
public void inOrderTraverse(Node node) {
	if (node == null) {
		return;
	}

	if (node.lChild != null) {
		inOrderTraverse(node.lChild);
	}
	System.out.print(node.data);
	if (node.rChild != null) {
		inOrderTraverse(node.rChild);
	}
}

// 遞歸後序遍歷 左-右-根
public void postOrderTraverse(Node node) {
	if (node == null) {
		return;
	}

	if (node.lChild != null) {
		postOrderTraverse(node.lChild);
	}
	if (node.rChild != null) {
		postOrderTraverse(node.rChild);
	}
	System.out.print(node.data);
}

但是有時候我們會發現遞歸的效率是非常慢的,所以爲了提高遍歷的效率,我們可以使用非遞歸來實現,於是我們自然想到了常用的數據結構,那就是棧,我們可以使用棧來模擬遞歸的效果、

拿前序遍歷來舉例,具體的思路爲:首先將根節點入棧,然後一直往左訪問,也就是找到“最左邊的”節點,然後開始逐個出棧並訪問右孩子節點,每次出棧都繼續找當前右孩子節點的“最左節點”訪問,依次循環下去,直到棧爲空即訪問完畢,相關代碼如下

// 非遞歸前序遍歷 根-左-右
public void preOrderTraverseN() {
	if (root == null) {
		return;
	}

	Stack<Node> stack = new Stack<Node>();
	Node node = root;

	while (node != null || !stack.isEmpty()) {
		while (node != null) {
			System.out.print(node.data);
			stack.push(node);
			node = node.lChild;
		}
		node = stack.pop();
		node = node.rChild;
	}
	System.out.println();
}

同樣的思路,我們可以寫出中序遍歷的非遞歸代碼,如下

// 非遞歸中序遍歷 左-根-右
public void inOrderTraverseN() {
	if (root == null) {
		return;
	}

	Stack<Node> stack = new Stack<Node>();
	Node node = root;

	while (node != null || !stack.isEmpty()) {
		while (node != null) {
			stack.push(node);
			node = node.lChild;
		}
		if (!stack.isEmpty()) {
			node = stack.pop();
			System.out.print(node.data);
			node = node.rChild;
		}
	}
	System.out.println();
}

最後是非遞歸的後序遍歷,這個是最爲複雜的,我們可以按照上面的思路,發現在嘗試寫後序遍歷的時候,由於必須先訪問根,才能訪問左孩子和右孩子,所以對於後序遍歷來說,無法像前序和中序那樣簡單的入棧和出棧即可完成,爲此我們必須在中間記錄一些標誌位,用來輔助我們遍歷,相關的後序遍歷代碼如下

// 非遞歸後序遍歷 左-右-根
public void postOrderTraverseN() {
	if (root == null) {
		return;
	}

	Stack<Node> stack = new Stack<Node>();
	Node node = root;
	// 上一次被打印的節點,作爲標誌使用
	Node prevNode = null;
	while (node != null || !stack.isEmpty()) {
		while (node != null) {
			stack.push(node);
			node = node.lChild;
		}

		// 取到棧頂節點
		Node leftNode = stack.peek();

		// 如果最左節點的右節點爲空或者已經打印過,則打印本節點
		if (leftNode.rChild == null || leftNode.rChild == prevNode) {
			System.out.print(leftNode.data);
			stack.pop();
			prevNode = leftNode;
		} else {
			node = leftNode.rChild;
		}
	}
	System.out.println();
}

搞定了三種訪問順序的遞歸和非遞歸的實現之後,最後還剩下一個層序遍歷,我們要實現按照層次遍歷的效果,可能一開始沒什麼思路,但是當我告訴你可以藉助隊列這個數據結構的時候,是不是一下子就明白了,現在我們來嘗試藉助隊列實現,理一下思路:首先將根節點入隊,然後根節點出隊,在出隊的時候,判斷其是否有左孩子節點,若有,則入隊,再判斷是否有右孩子節點,若有,同樣的入隊,然後接着出隊,對每個出隊的元素,都作同樣的處理,這樣一遍下來,你就完成了按照層次遍歷二叉樹的效果,最終代碼如下

// 層序遍歷
public void levelOrderTraverse() {
	if (root == null) {
		return;
	}
	Queue<Node> queue = new ArrayDeque<>();
	queue.add(root);
	Node current;
	while (!queue.isEmpty()) {
		current = queue.peek();
		System.out.print(current.data);
		if (current.lChild != null) {
			queue.offer(current.lChild);
		}
		if (current.rChild != null) {
			queue.offer(current.rChild);
		}
		queue.poll();
	}
	System.out.println();
}

ok,到這裏爲止,一個功能相對完善的基於鏈表的二叉樹數據結構就實現完成,我們爲其寫上一些測試代碼測試無誤之後,也可再在這個基礎之上添枝加葉,讓其功能更加的豐富,因爲目前實現的只是一顆非常普通的二叉樹,沒有任何其它額外的特性,但是隻要我們把最基礎的掌握了,其它額外的“枝葉”掌握起來也就會輕鬆許多。

下面我們再來學一些這些二叉樹的“枝葉”!

4、二叉排序樹

首先就是二叉樹中最常見的一種,那就是二叉排序樹,我們首先來看一下它的“枝葉”,也就是它的額外特性是什麼,它的定義如下

二叉排序樹或者是一棵空樹;或者是具有下列性質的二叉樹: (1)若左子樹不空,則左子樹上所有結點的值均小於它的根結點的值; (2)若右子樹不空,則右子樹上所有結點的值均大於它的根結點的值; (3)左、右子樹也分別爲二叉排序樹;

換個簡單的說法就是左孩子節點的值是小於根節點的,右孩子節點的值是大於根節點的。ok,再加上這個特性之後,帶來的影響是什麼呢?答案就是遍歷的效率,我們不難想到二分查找的思想,這裏也類似,在二叉排序樹中查找一個元素時,首先和根元素相比較,然後如果大於根節點的值,則在較小的一邊,也就是左子樹繼續遞歸查找,若大於根節點的值,則在右子樹中查找。

現在我們再來思考如何實現二叉排序樹,也就是如何將“枝葉”特性給二叉樹添加上去,我們不難想到的是在添加節點的方法中做處理,讓其保持左子樹節點值小,右子樹節點值大。那麼如何實現呢?我們可以採用遞歸,當插入節點的值大於根節點時,則在根節點的右子樹中繼續遞歸比較,當小於時,則在左子樹中繼續遞歸,如果發現根節點爲空,則將帶插入節點放在這裏即可,遞歸結束。相關代碼如下

//遞歸插入節點
//待插入節點:node   根節點:root
public Node insertOrder(Node node, Node root) {
	if (root == null) {
		root = node;
		return root;
	}
	if (node.data < root.data) {
		root.lChild = insertOrder(node, root.lChild);
	} else if (node.data > root.data) {
		root.rChild = insertOrder(node, root.rChild);
	} else {
		// 節點已經存在,做相應處理
	}

	return root;
}

現在實現了插入,我們是採用的遞歸實現,那麼我們是否可以採用非遞歸來實現插入呢?答案當然是可以的,只需要找到帶插入節點的值在樹中相應的未知即可,插入節點的非遞歸代碼如下

// 非遞歸插入一個節點
public void insert(Node node) {
	if (root == null) {
		root = node;
		return;
	}
	Node current = root;
	// 記錄上一個節點
	Node parent = null;
	while (true) {
		parent = current;
		if (node.data < current.data) {
			current = current.lChild;
			if (current == null) {
				parent.lChild = node;
				break;
			}
		} else if (node.data > current.data) {
			current = current.rChild;
			if (current == null) {
				parent.rChild = node;
				break;
			}
		} else {
			System.out.println("節點重複");
			return;
		}
	}
}

在實現完了插入節點之後,那麼我們自然而然還需要給刪除節點的方法做處理,否則我們這棵樹在刪除節點的時候,就無法保證二叉排序樹的特性了,現在我們再來思考如何刪除一個節點讓其仍然維持特性呢?這裏情況就比較複雜了,我們分爲如下四種情況:

  • 待刪除節點是葉子節點
  • 待刪除節點只有左子樹
  • 待刪除節點只有右子樹
  • 待刪除節點左右子樹都有

對第一種情況,也就是爲葉子節點的情況來說,非常簡單,我們只需要刪除它即可;現在來看第二種和第三種情況,如果待刪除節點只有左子樹或右子樹,這種情況也很簡單,我們只要將它的孩子節點“接在”它的父節點下面即可,也就是直接讓孩子節點替換到自己的位置;最後一種情況就稍微複雜點了,如果待刪除節點左右子樹都有,那麼爲了維持特性,我們有兩種方案,一種是選擇左子樹中最大的節點替換當前節點,另一種是選擇右子樹中最小的節點替換當前節點。

ok,我們有了上面的思路,現在來實現這個刪除方法,如下

// 刪除一個節點
public void remove(Node node) {
	if (!contains(root, node)) {
		System.out.println("刪除的節點不存在");
		return;
	}
	// 待刪除節點的父節點
	Node parent = root;
	// 待刪除節點
	Node current = root;
	// 待刪除節點是父節點的左兒子還是右兒子
	boolean isRightChild = false;
	while (node.data != current.data) {
		parent = current;
		if (node.data < current.data) {
			current = current.lChild;
			isRightChild = false;
		} else if (node.data > current.data) {
			current = current.rChild;
			isRightChild = true;
		}
	}
	// 分四種情況討論
	// 葉子節點,左右子樹都爲空
	if (current.lChild == null && current.rChild == null) {
		if (current == root) {
			root = null;
			return;
		}
		if (isRightChild) {
			parent.rChild = null;
		} else {
			parent.lChild = null;
		}
		return;
	}
	// 只有右子樹
	if (current.lChild == null && current.rChild != null) {
		if (current == root) {
			root = root.rChild;
			return;
		}
		if (isRightChild) {
			parent.rChild = current.rChild;
		} else {
			parent.lChild = current.rChild;
		}
		return;
	}
	// 只有左子樹
	if (current.lChild != null && current.rChild == null) {
		if (current == root) {
			root = root.lChild;
			return;
		}
		if (isRightChild) {
			parent.rChild = current.lChild;
		} else {
			parent.lChild = current.lChild;
		}
		return;
	}
	// 左右子樹都有
	if (current.lChild != null && current.rChild != null) {
		parent = current;
		// 轉向右子樹,然後向左走到盡頭 PS:也可以轉向左子樹,然後向右走到盡頭,二者一樣都維護了結構的完整性
		Node s = current.rChild;
		while (s.lChild != null) {
			parent = s;
			s = s.lChild;
		}
		node.data = s.data;// 將node的值設爲右子樹最左側s的值,也就是右子樹最大的值
		if (parent == node) {
			parent.rChild = s.rChild;
		} else {
			parent.lChild = s.rChild;
		}
		return;
	}
}

在實現完之後,我們發現雖然我們邏輯清晰,但是我們的代碼卻非常的冗長,原因就是我們分了很多種不同的情況,我們如果想再精簡一點的話,可以將第二種情況和第三種情況合併,來減少代碼冗餘,當然這並不是最好的辦法,我們換個思路,不難想到使用遞歸的方法來解決刪除節點的問題,現在我們來嘗試一下遞歸刪除,理一下思路:首先通過遞歸比較,找到待刪除元素在樹中的位置,然後找到左子樹最大的節點或右子樹中最小的節點替換待刪除節點即可。

爲了便於理解,這裏可以將找左子樹中最大值的節點和找右子樹中最小的節點單獨寫一個方法,最終代碼如下

// 遞歸刪除結點
// node->待刪除結點
// root->根結點
private Node remove(Node node, Node root) {
	// 根結點爲空,直接返回
	if (root == null) {
		return null;
	}
	// 新的根爲右兒子
	if (node.data > root.data) {
		root.rChild = remove(node, root.rChild);
	} else if (node.data < root.data) {
		root.lChild = remove(node, root.lChild);
	} else {
		if (root.lChild != null && root.rChild != null) {
			// 找到右子樹中最小結點的值賦值給待刪除結點,然後刪除右子樹的最小結點
			Node replaceNode = findMin(root.rChild);
			root.data = replaceNode.data;
			root.rChild = remove(replaceNode, root.rChild);
		} else {
			root = (root.lChild != null) ? root.lChild : root.rChild;
		}
	}
	return root;
}

// 尋找指定子樹中的最小元素
public Node findMin(Node root) {
	if (root == null) {
		return null;
	}
	Node current = root;
	while (current.lChild != null) {
		current = current.lChild;
	}
	return current;
}

現在插入刪除節點的方法都完成了,我們也就完成了一顆二叉排序樹的“枝葉”,最後記得寫測試用例測試正確性。

5、平衡二叉樹

下面再來看看另一個二叉樹,平衡二叉樹,先來看看它的定義

平衡二叉樹,是一顆特殊的二叉搜索樹,它的性質如下:它是一 棵空樹或它的左右兩個子樹的高度差的絕對值不超過1,並且左右兩個子樹都是一棵平衡二叉樹。

我們先來思考爲什麼要有平衡二叉樹存在,上面我們講到了二叉排序樹,其最重要的作用就體現在查找這裏,其可實現二分的效果,也就是時間複雜度可達到O(log(N))級別,但是可否想過,假設原序列有序,按照順序依次往二叉排序樹中插入元素,那麼最終一顆二叉排序樹將會退化爲一個鏈表,導致查找的效率降低爲O(N),所以我們需要有一種辦法來規避這種情況,而這個解決辦法就是平衡二叉樹,因爲二叉排序樹進化成了平衡二叉樹,所以其永遠維持在“平衡”的狀態,也就是左右子樹高度差不超過1,時間複雜度也將一直維持在O(log(N))級別。

對平衡二叉樹有一個基本的瞭解了之後,同樣的,我們再來看看怎麼實現它,由於平衡二叉樹是在二叉排序樹上又新添加的“枝葉”,所以我們還是圍繞核心的兩個方法,插入節點和刪除節點。在二叉排序樹的節點插入基礎上,當我們發現插入某個節點使其性質不滿足平衡二叉樹了,那麼就需要對其進行調整,由於這裏比較複雜,涉及到二叉樹的旋轉問題,限於篇幅,所以具體的調整方法就不贅述了,主要分爲四種,左旋、右旋、左右旋和右左旋,相關的插入節點代碼和刪除節點代碼如下

// node 爲插入的樹的根結點
// insertNode 爲插入的節點
private Node insert(Node node, Node insertNode) {
	if (node == null) {
		node = insertNode;
	} else {
		if (insertNode.data < node.data) {// 將data插入到node的左子樹
			node.lChild = insert(node.lChild, insertNode);
			// 如果插入後失衡
			if (getHeight(node.lChild) - getHeight(node.rChild) == 2) {
				if (insertNode.data < node.lChild.data) {// 如果插入的是在左子樹的左子樹上,即要進行LL翻轉
					node = leftLeftRotation(node);
				} else {// 否則執行LR翻轉
					node = leftRightRotation(node);
				}
			}
		} else if (insertNode.data > node.data) {
			// 將data插入到node的右子樹
			node.rChild = insert(node.rChild, insertNode);
			// 如果插入後失衡
			if (getHeight(node.rChild) - getHeight(node.lChild) == 2) {
				if (insertNode.data > node.rChild.data) {
					node = rightRightRotation(node);
				} else {
					node = rightLeftRotation(node);
				}
			}
		} else {
			System.out.println("節點重複啦");
		}
		node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
	}
	return node;
}

// 刪除節點
private Node remove(Node node, Node removeNode) {
	if (node == null) {
		return null;
	}
	// 待刪除節點在node的左子樹中
	if (removeNode.data < node.data) {
		node.lChild = remove(node.lChild, removeNode);
		// 刪除節點後,若失去平衡
		if (getHeight(node.rChild) - getHeight(node.lChild) == 2) {
			Node rNode = node.rChild;// 獲取右節點
			// 如果是左高右低
			if (getHeight(rNode.lChild) > getHeight(rNode.rChild)) {
				node = rightLeftRotation(node);
			} else {
				node = rightRightRotation(node);
			}
		}
	} else if (removeNode.data > node.data) {// 待刪除節點在node的右子樹中
		node.rChild = remove(node.rChild, removeNode);
		// 刪除節點後,若失去平衡
		if (getHeight(node.lChild) - getHeight(node.rChild) == 2) {
			Node lNode = node.lChild;// 獲取左節點
			// 如果是右高左低
			if (getHeight(lNode.rChild) > getHeight(lNode.lChild)) {
				node = leftRightRotation(node);
			} else {
				node = leftLeftRotation(node);
			}
		}
	} else {// 待刪除節點就是node
			// 如果Node的左右子節點都非空
		if (node.lChild != null && node.rChild != null) {
			// 如果左高右低
			if (getHeight(node.lChild) > getHeight(node.rChild)) {
				// 用左子樹中的最大值的節點代替node
				Node maxNode = maxNode(node.lChild);
				node.data = maxNode.data;
				// 在左子樹中刪除最大的節點
				node.lChild = remove(node.lChild, maxNode);
			} else {// 二者等高或者右高左低
				// 用右子樹中的最小值的節點代替node
				Node minNode = minNode(node.rChild);
				node.data = minNode.data;
				// 在右子樹中刪除最小的節點
				node.rChild = remove(node.rChild, minNode);
			}
			
		} else {
			// 只要左或者右有一個爲空或者兩個都爲空,直接將不爲空的指向node
			// 兩個都爲空的話,想當於最後node也指向了空,邏輯仍然正確
			node = node.lChild == null ? node.rChild : node.lChild;// 賦予新的值
		}
	}
	if(node!=null) {
		node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
	}
	return node;
}

對應的四種二叉樹的旋轉代碼如下

// 左左翻轉 LL
// 返回值爲翻轉後的根結點
private Node leftLeftRotation(Node node) {
	// 首先獲取待翻轉節點的左子節點
	Node lNode = node.lChild;

	node.lChild = lNode.rChild;
	lNode.rChild = node;

	node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
	lNode.height = max(getHeight(lNode.lChild), node.height) + 1;

	if (node == root) {
		root = lNode;// 更新根結點
	}
	return lNode;
}

// 右右翻轉 RR
// 返回值爲翻轉後的節點
private Node rightRightRotation(Node node) {
	// 首先獲取待翻轉節點的右子節點
	Node rNode = node.rChild;

	node.rChild = rNode.lChild;
	rNode.lChild = node;

	node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
	rNode.height = max(getHeight(rNode.lChild), node.height) + 1;

	if (node == root) {
		root = rNode;// 更新根結點
	}
	return rNode;
}

// 左右翻轉 LR 先對左子節點進行RR翻轉,再對自身進行LL翻轉
// 返回值爲翻轉後的節點
private Node leftRightRotation(Node node) {
	node.lChild = rightRightRotation(node.lChild);

	return leftLeftRotation(node);
}

// 右左翻轉 RL 先對右子節點進行LL翻轉,再對自身進行RR翻轉
// 返回值爲翻轉後的節點
private Node rightLeftRotation(Node node) {
	node.rChild = leftLeftRotation(node.rChild);

	return rightRightRotation(node);
}

由於這裏涉及到的二叉樹的旋轉並沒有詳細說明,所以這一塊需要額外去學習,一般只要弄懂了四種旋轉中的其中一種,然後剩下的三種旋轉也就比較好理解了。

6、堆

最後我們來看看另外一個經常在開發中遇到的數據結構,那就是堆,其實準確的說,也不能把堆叫做“另外一個數據結構”,因爲它其實就是一顆普通的二叉樹,只不過和二叉排序樹一樣,它是在節點數據元素值的大小上做了一些限制,堆一般分爲兩種,大根堆和小根堆,這兩者類似,然後一般默認情況下,我們說的堆是指二叉堆,也就是孩子節點數目小於等於兩個,當然言外之意就是說還有多叉堆,這個就是孩子節點數目的問題,不作過多討論。現在我們就來看看大根堆的定義。如下

根結點(亦稱爲堆頂)的關鍵字是堆裏所有結點關鍵字中最大者,稱爲大根堆,又稱最大堆(大頂堆)。堆的子樹也是堆。

或者另一種我更喜歡的定義:
最大堆 根節點大於左右節點的完全二叉樹;
最小堆 根節點小於左右節點的完全二叉樹

由此,我們可以很自然的想到小根堆的定義,就不贅述了,然後畫個圖再來加深下對這兩個堆的映像

在這裏插入圖片描述
在這裏插入圖片描述
怎麼樣,是不是很簡單,現在我們再來看看堆在實際中的簡單應用,也就是爲什麼要有堆這個東西存在,在實際中,聽到的最多的可能就是堆排序了,但是你會發現一般的排序場景,我們都是直接寫個快速排序或簡單的插入排序等解決問題,那什麼場景下才會利用堆排序呢,答案就是海量數據排序,或者海量數據排序的變種,例如海量數據TopK問題

TopK問題,就是求一組數據中最大或最小的某幾個值,例如,有十億條數據,找出現次數最多的100個。

上面說到的一些情況,我們會發現都離不開一個關鍵字,那就是海量數據,這也正是適合堆的應用場景,爲什麼堆會適合這個應用場景呢?原因就是在堆這個數據結構中,堆頂元素總是整個數據元素中最大或者最小的元素,這就會帶來很多方便性,例如上面這個問題,求十億條數據中,出現次數最多的100個,首先通過HashMap或者其它手段統計每個元素對應的次數,得到一個次數序列,然後維護一個大小爲100的小根堆,然後遍歷這個次數序列,每次和堆頂元素值比較,一旦大於堆頂元素值,就往小根堆中插入這個值,最終小根堆中的100個次數值對應的數據就是出現次數最多的100個,因爲堆頂元素是最小的,所以堆中剩餘元素都是大於這個值的,而且堆頂的次數值正好是出現次數第100的數據對應的次數值。

接下來,我們可以自己實現一個堆,其核心的方法就是維護堆的性質,也就是當插入數據和刪除數據之後,如果堆的性質被破壞了,就要及時調整過來,也就是這個調整方法的實現。

其實我們只要掌握瞭如何來調整,也就成功了一半,我們先理清一下思路:在一個小根堆中,當插入刪除一個數據元素的時候,如果孩子節點的值小於根節點,那麼我們就需要調換根節點和孩子節點的值,來讓最小的值始終在根節點,但是要知道,在堆這個樹狀的數據結構中,一旦交換了節點值,就可能產生連帶影響,也就是說會影響祖父節點的值情況,這樣可能導致祖父節點也需要調整,所以這裏就需要遞歸或者循環,下面我們來看看這個調整方法

// 構建最小堆
public void buildMinHeap() {
	// 葉子節點沒有子節點不用重構堆,所以從長度的一半開始調整
	for (int i = length / 2; i >= 0; i--) {
		minHeapFixed(heap, i, length);
	}
}

// 從i節點開始調整,n爲節點總數 從0開始計算 i節點的子節點爲 2*i+1, 2*i+2
// 刪除節點時,把根元素和最後的一個元素交換,並根據新交換的新根元素調整整個堆(從上到下沉降調整),所以此時i爲0
public void minHeapFixed(int arr[], int i, int n) {
	// 獲取要調整的節點的值
	int child = 2 * i + 1;//先獲取左孩子
	while (child <= n) {
		// 保證有右孩子並且右孩子比左孩子小
		if (child + 1 <= n && arr[child + 1] < arr[child]) {
			child++;
		}
		// 根比左右孩子都小則結束循環
		if (arr[child] >= arr[i]) {
			break;
		}
		// 根比左和右大,用左、右的最小值覆蓋根值,並定義新的根和孩子向下沉降遞歸
		int temp = arr[i];
		arr[i] = arr[child];
		arr[child] = temp;
		child = 2 * i + 1;

		i = child;
		child = child * 2 + 1;
	}
}

我們首先看到buildMinHeap這個方法,注意到我們是從length/2處開始調整的,爲什麼呢,這個其實非常好理解,因爲對於葉子節點來說,它沒有孩子節點,這種情況下是肯定滿足堆的性質的,所以對於這些節點,我們可以跳過,不用從它開始調整,而選擇第一個有孩子節點的節點開始調整,而第一個有孩子節點的節點,根據樹的排序規律,正好是下標位於長度的一半的位置,文字不如圖好理解,看下圖
在這裏插入圖片描述
對上圖這個長度爲11的堆來說,我們只需要從長度的一半,也就是11/2=5,從下標爲5的元素,也就是元素值爲8開始調整即可。

當然堆還有很多其它的應用,例如jvm裏對象內存的分配等等,由於目前能力尚淺,就不深入分析了,以後如果關於堆有新發現了,再來這裏補充上也不遲,hhhhhh

結語

呼,關於樹這一節,算是暫告一段落了,本來準備寫的內容沒有這麼多,結果發現樹這裏東西還是太多了,其中每一個不同類型的樹單獨拿出來學習都可以有很多東西,爲了方便,我還是將它們儘可能的整合到了一起,也就是這篇文章,日後,應該會再來這裏補充,樹這一篇,就到這吧!!

下一篇,圖!!!ready---->>>>>

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