1. 程式人生 > >【資料結構與演算法】之樹的基本概念及常用操作的Java實現(二叉樹為例) --- 第十二篇

【資料結構與演算法】之樹的基本概念及常用操作的Java實現(二叉樹為例) --- 第十二篇

樹是一種非線性資料結構,這種資料結構要比線性資料結構複雜的多,因此分為三篇部落格進行講解:

第一篇:樹的基本概念及常用操作的Java實現(二叉樹為例)

第二篇:二叉查詢樹

第三篇:紅黑樹


本文目錄:

1、基本概念

1.1  什麼是樹

1.2  樹的相關術語

2、二叉樹(Binary  Tree)

2.1  二叉樹的基本概念

2.2  二叉樹的儲存方式

2.3  二叉樹的遍歷   ----  面試常問

2.4  二叉樹的其他操作

         推薦及參考


第一篇:樹的基本概念及常用操作

1、基本概念

1.1  什麼是樹

我們前面學習的陣列、連結串列等都是線性結構,它們的特點是:一個節點至多隻有一個頭結點,至多隻有一個尾結點,彼此連線起來形成一條線性的結構,如下圖所示。

而樹結構,是非線性的典型例子,不再是一對一,變成了一對多(圖可以是多對多),如下圖所示:

根據上面樹的圖片,我們可以大致總結下樹的特點:

1、圖中的樹就像我們實際生活中的樹倒立過來一樣,最頂部的節點就是樹根,即根節點(root節點)

2、每棵樹至多隻有一個根節點,如果樹不為空,也至少有一個根節點;

3、根節點下面可以有多個子節點,但是每個子節點只能有一個父節點,同時每個子節點下面又可以有多個它的子節點;

4、具有同一個節點的節點稱為兄弟節點;

5、沒有子節點的節點成為葉子節點(leaf);

1.2  樹的相關術語

關於樹,有幾個比較重要的術語需要掌握:高度(Height)深度(Depth)層(Level)。它們的定義如下:

1、節點的高度 = 節點到葉子節點的最長路徑(邊數);

2、節點的深度 = 根節點到這個節點所經歷的邊的個數;

3、節點的層數 = 節點的深度 + 1;

4、樹的高度 = 根節點的高度。

這幾個概念比較容易混淆,可以利用下面這張圖片幫助記憶。

可以從生活中這個詞的含義進行理解:

“高度”

:這個概念就是從下往上的度量,比如我們說一棟80層樓的高度,都是從地面開始的。只不過樹的高度是把0作為起點而已;

“深度”:生活中我們往往說水深多少米,很明顯是從上往下度量的,所以樹也是從根節點開始度量的,並且計數起點也是0.可以看出來高度和深度正好是相反的兩個方向開始對樹進行度量的;

“層數”:和深度的計算比較類似,只不過計數起點從1開始,即根節點位於第一層。

除此之外樹還有很多其他概念,比如:

節點的度:一個節點直接含有的子樹個數(即有幾個分支),叫做節點的度。

樹的度:即一棵樹中最大節點的度,哪個節點上的子節點最多,它的度就是樹的度。


2、二叉樹(Binary  Tree)

2.1  二叉樹的基本概念

樹的結構多種多樣,我們最常用的是二叉樹,所以這裡就用二叉樹對樹這種資料結構進行講解了。

二叉樹:是有限個節點的集合,這個集合可以是空集,也可以是一個根節點和至多兩個子二叉樹組成的集合,其中一顆樹叫做根的左子樹,另一棵叫做根的右子樹。

從二叉樹的定義可以看出來它是一個遞迴的定義,必須注意到左、右子樹的概念,比如下圖中的兩棵不同的二叉樹。

 

 下面對二叉樹中比較特殊的兩種樹結構進行說明:滿二叉樹完全二叉樹

上圖所示的3個樹型結構都是二叉樹,但是2號和3號比較特殊。

其中2號的二叉樹中,葉子節點全都在最底層,除了葉子節點外,每個節點都有左右兩個子節點,這種二叉樹就叫做滿二叉樹。其準確定位為:

滿二叉樹定義:如果一棵樹的高度為k,且擁有(2^k) - 1個節點,則稱之為滿二叉樹。即每個節點要麼有兩個子節點,要麼沒有子節點。

其中3號的二叉樹中,葉子節點都在最底層,並且最後一層的葉子節點都靠左排列,並且除了最後一層,其他層的節點個數都要達到最大,這種二叉樹叫做完全二叉樹。要成為一棵完全二叉樹,需要滿足以下要求:

1、所有葉子節點都出現在k或者k-1層,而且從1到k-1層必須達到最大節點數;

2、第k層可以不是滿的,但是第k層的所有節點必須集中在最左邊。

即:葉子節點都必須在最後一層或者倒數第二層,而且必須在左邊。任何一個節點不可能有右子樹而沒有左子樹

2.2  二叉樹的儲存方式

要想儲存一棵二叉樹,我們有兩種辦法:一種是基於指標或者引用的二叉鏈式儲存法,另一種是基於陣列的順序儲存法

2.2.1  鏈式儲存法

如下圖所示,每個節點有三個欄位,其中一個儲存資料,另外兩個指向左右節點的指標。我們只要只要拎著根節點,就可以通過左右節點的指標,把整棵樹都串起來。大部分的二叉樹程式碼都是通過鏈式儲存法這種方法實現的。

鏈式儲存法實現二叉樹的Java程式碼:

package com.zju.tree;
/** 
    * @author    作者 pcwl
    * @date      建立時間:2018年11月16日 下午12:25:26 
    * @version   1.0 
    * @comment   連結串列法實現二叉樹
*/
public class BinaryTree_LinkedList {

		private int data;   // 二叉樹的資料
		private BinaryTree_LinkedList leftChildNode;     // 二叉樹的左子節點
		private BinaryTree_LinkedList rightChildNode;    // 二叉樹的右子節點
		
		public BinaryTree_LinkedList(int data, BinaryTree_LinkedList leftChildNode, BinaryTree_LinkedList rightChildNode){
			data = this.data;
			leftChildNode = this.leftChildNode;
			rightChildNode = this.rightChildNode;
		}
	
		public int getData(){
			return data;
		}
		
		public void setData(int data){
			this.data = data;
		}
		
		public BinaryTree_LinkedList getLeftChildNode(){
			return this.leftChildNode;
		}
		
		public void setLeftChildNode(BinaryTree_LinkedList leftChildNode){
			this.leftChildNode = leftChildNode;
		}
		
		public BinaryTree_LinkedList getRightChildNode(){
			return this.rightChildNode;
		}
		
		public void setRightChildNode(BinaryTree_LinkedList rightChildNode){
			this.rightChildNode = rightChildNode;
		}
}

2.2.2  陣列的順序儲存法

如下圖所示,我們把根節點儲存在下標i=1的位置,那麼左子節點儲存在下標2*i = 2的位置上,右子節點儲存在(2*i)+1=3的位置上。依次類推,B節點的左子節點儲存在2*i=2*2=4的位置上,右子節點儲存在(2*i)+1=5的位置上。

如果節點X儲存在陣列下標為i的位置,下標為2*i的位置儲存的就是左子節點,下標為(2*i)+1的位置儲存的就是右子節點。反過來,下標為i/2的位置儲存的就是它的父節點。通過這種方式,我們只要知道根節點的儲存位置(一般情況下,為了方便計運算元節點,根節點會儲存在下標為1的位置上),這樣就可以通過下標計算,把整棵樹串起來。

但是,可以發現上面的例子是一棵完全二叉樹,所以僅僅“浪費”了一個下標為0的儲存位置。如果是非完全二叉樹,其實會浪費比較多的陣列儲存空間。比如下面這個例子:

這個例子中的二叉樹是一棵非完全二叉樹,所以就浪費了陣列中很多的儲存空間。

因此,如果要儲存的二叉樹是一棵完全二叉樹,那用陣列儲存無疑是最節省記憶體的一種方式。因為陣列的儲存方式並不需要像鏈式儲存那樣需要有額外的左右子節點的指標開銷。其實推排序用的就是一棵完全二叉查詢樹,最常用的儲存方式就是陣列。

陣列的順序儲存法實現二叉樹的Java程式碼:

/** 
    * @author    作者 pcwl
    * @date      建立時間:2018年11月16日 下午1:57:03 
    * @version   1.0 
    * @comment   二叉樹的陣列實現
*/
public class BinaryTree_Array {

	// 二叉樹的陣列實現,儲存的不是左右子樹的引用,而是陣列的下標,通過下標就能找到它們之間的對應關係了
	private int parentNode;
	private int leftChildNode;
	private int rightChildNode;
	
	public int getParentNode() {
		return parentNode;
	}
	
	public void setParentNode(int parentNode) {
		this.parentNode = parentNode;
	}
	
	public int getLeftChildNode() {
		return leftChildNode;
	}
	
	public void setLeftChildNode(int leftChildNode) {
		this.leftChildNode = leftChildNode;
	}
	
	public int getRightChildNode() {
		return rightChildNode;
	}
	
	public void setRightChildNode(int rightChildNode) {
		this.rightChildNode = rightChildNode;
	}
}

2.3  二叉樹的遍歷   ----  面試常問

對於二叉樹常用的操作,面試中問的比較多的就是二叉樹的遍歷,因此這裡單獨把它拿出來講,後面再講其他二叉樹的操作。

二叉樹的遍歷常用的方法有三種:前序遍歷中序遍歷後序遍歷。【實際上是按照當前節點的列印順序來命名的】

1、前序遍歷:對於樹中的任意節點,先列印這個節點,然後再列印它的左子樹,最後列印它的右子樹;

2、中序遍歷:對於樹中的任意節點,先列印它的左子樹,然後再列印它本身,最後列印它的右子樹;

3、後序遍歷:對於樹中的任意節點,先列印它的左子樹,然後列印它的右子樹,最後列印這個節點本身。

從上圖中可以看出來,二叉樹的前、中、後序遍歷都是一個遞迴的過程。比如:前序遍歷,其實就是先列印根節點,然後再遞迴地列印左子樹,最後遞迴地列印右子樹。

寫遞迴程式碼的關鍵,就是看能不能寫出遞推公式,而遞推公式的關鍵就是:如果要解決問題A,就假設子問題B和C已經解決,然後再來看看如何利用B和C來解決A。前、中、後序遍歷的遞推公式如下所示:

前序遍歷的遞推公式:
preOrder(r) = print r -> preOrder(r -> left) -> preOrder(r -> right)

中序遍歷的遞推公式:
inOrder(r) = inOrder(r -> left) -> print r -> inOrder(r -> right)

後序遍歷的遞推公式:
postOrder(r) = postOrder(r -> left) -> postOrder(r -> right) -> print r

下面看它們的具體Java程式碼實現:

2.3.1  前序遍歷

1、先訪問根節點;

2、再遍歷左子樹;

3、再遍歷右子樹;

4、退出。

// 遍歷二叉樹:前序遍歷
public void iteratorFirstOrder(BinaryTree_LinkedList node){
	if(node == null){
		return;
	}
			
	System.out.println(node);                       // 列印root節點
	iteratorFirstOrder(node.getLeftChildNode());    // 遍歷左子樹
	iteratorFirstOrder(node.getRightChildNode());   // 遍歷右子樹
}

2.3.2  中序遍歷

1、先遍歷左子樹;

2、再訪問根節點;

3、再遍歷右子樹;

4、退出。

// 遍歷二叉樹:中序遍歷
public void iteratorMediumOrder(BinaryTree_LinkedList node){
	if(node == null){
		return;
	}
			
	iteratorMediumOrder(node.getLeftChildNode());    // 遍歷左子樹
	System.out.println(node);                        // 訪問根節點
	iteratorMediumOrder(node.getRightChildNode());   // 遍歷右子樹
}

2.3.3  後序遍歷  

1、先遍歷左子樹;

2、再遍歷右子樹;

3、訪問根節點;

4、退出。

// 遍歷二叉樹:後序遍歷
public void iteratorLastOrder(BinaryTree_LinkedList node){
	if(node == null){
		return;
}
			
	iteratorLastOrder(node.getLeftChildNode());      // 遍歷左子樹
	iteratorLastOrder(node.getRightChildNode());     // 遍歷右子樹
	System.out.println(node);                        // 訪問根節點
}

2.4  二叉樹的其他操作

2.4.1  二叉樹的建立  

          上面已經講過連結串列陣列實現二叉樹了。下面對二叉樹的其他操作用基於連結串列實現的二叉樹進行講解。

public class BinaryTree_LinkedList {

        private BinaryTree_LinkedList root;              // 根節點 
		private int data;                                // 二叉樹的資料
		private BinaryTree_LinkedList leftChildNode;     // 二叉樹的左子節點
		private BinaryTree_LinkedList rightChildNode;    // 二叉樹的右子節點
		
		public BinaryTree_LinkedList(int data, BinaryTree_LinkedList leftChildNode, BinaryTree_LinkedList rightChildNode){
			data = this.data;
			leftChildNode = this.leftChildNode;
			rightChildNode = this.rightChildNode;
		}
	
		public int getData(){
			return data;
		}
		
		public void setData(int data){
			this.data = data;
		}
		
	        public BinaryTree_LinkedList getLeftChildNode(){
			return this.leftChildNode;
		}
		
		public void setLeftChildNode(BinaryTree_LinkedList leftChildNode){
		    this.leftChildNode = leftChildNode;
		}
		
		public BinaryTree_LinkedList getRightChildNode(){
			return this.rightChildNode;
		}
		
		public void setRightChildNode(BinaryTree_LinkedList rightChildNode){
			this.rightChildNode = rightChildNode;
		}

                public BinaryTree_LinkedList getRoot() {
		    return root;
	    }

	        public void setRoot(BinaryTree_LinkedList root) {
		    this.root = root;
	    }
}

2.4.2  二叉樹的新增元素

需要考慮:新增到左子樹還是右子樹;

// 新增元素:左子樹
public void insertAsLeftChildNode(BinaryTree_LinkedList node) {
	if (root == null) {
		root = node;   // 如果當前根節點為空,就將此節點當作根節點
	}

	root.setLeftChildNode(node);
}

// 新增元素:左子樹
public void insertAsRighttChildNode(BinaryTree_LinkedList node) {
	if (root == null) {
		root = node;   // 如果當前根節點為空,就將此節點當作根節點
	}
		
	root.setRightChildNode(node);
}

2.4.3  二叉樹刪除元素

刪除哪個元素,就將它設定為null。

// 刪除元素
public void deleteNode(BinaryTree_LinkedList node){
	node = null;
}

2.4.4  清空二叉樹

只需要將刪除根節點即可,即將根節點設定為null。

// 清空二叉樹
public void clearBinaryTree(){
	root = null;
}

2.4.5  獲得二叉樹的高度

即獲得根節點的度,需要遞迴左右子樹,找出根節點最大的度,即為二叉樹的高度。

// 獲取樹的高度,即為獲取根節點的度
public int getTreeHeight(){
	return getNodeHeight(root);
}
	
// 獲取指定節點的度
public int getNodeHeight(BinaryTree_LinkedList node){
	if(node == null){
		return 0;
	}
		
	int leftChildHeight = getNodeHeight(node.getLeftChildNode());    // 獲取當前節點左子樹的度
	int rightChildHeight = getNodeHeight(node.getRightChildNode());  // 獲取當前節點右子樹的度
		
	int max = Math.max(leftChildHeight, rightChildHeight);
	return max + 1;   // 加上當前節點自己
}

2.4.6  獲取二叉樹的節點總數

遍歷所有子樹,再相加。

// 獲取所有節點總數
public int getSize(){
	return getChildSize(root);
}
	
// 獲取指定節點的位元組點個數
public int getChildSize(BinaryTree_LinkedList node){
	if(node == null){
		return 0;
}
		
	int leftChildSize = getChildSize(node.getLeftChildNode());
	int rightChildSize = getChildSize(node.getRightChildNode());
		
	return leftChildSize + rightChildSize + 1;     // 加上指定節點自己
}

2.6.7  獲得某個節點的父節點

我們在構造樹時,用的是左右子樹表示節點,沒有含有父節點的引用。因此,如果要想獲取指定節點的父節點,那麼需要從頂向下遍歷各個子樹,若該子樹的根節點的孩子就是目標節點,則返回該節點,否則遍歷它的左右子樹。

說明:以下程式碼實現未考慮資料重複的情況。

// 獲取指定節點的父節點
public BinaryTree_LinkedList getParentNode(BinaryTree_LinkedList node){
	// 如果樹為空或者這個節點就是根節點,則它沒有父節點
	if(root == null || root == node){
		return null;
	}else{
		return getParent(root, node);
	}
}
	
// 遞迴對比,節點的孩子節點與指定節點是否一致
public BinaryTree_LinkedList getParent(BinaryTree_LinkedList subTreeNode, BinaryTree_LinkedList node){
	// 如果子樹為空,則沒有父節點
	if(subTreeNode == null){
		return null;
	}
		
	// 該節點的左右子節點與目標節點一致
	if(subTreeNode.getLeftChildNode() == node || subTreeNode.getRightChildNode() == node){
		return subTreeNode;
	}
		
	// 需要遍歷subTreeNode的左右子樹
	BinaryTree_LinkedList parent;
	// 從左子樹查詢
	if((parent = getParent(subTreeNode.getLeftChildNode(), node)) != null){
		return parent;
	}else{
		// 從右子樹查詢
		return getParent(subTreeNode.getRightChildNode(), node);
	}
}

說明:翻轉二叉樹、對稱二叉樹的內容來自於:https://mp.weixin.qq.com/s/ONKJyusGCIE2ctwT9uLv9g

2.6.8  翻轉二叉樹

對於一棵二叉樹,翻轉它的左右子樹,如下圖所示:

圖 1:反轉後的結果圖

下面我們來分析下,二叉樹的翻轉過程:

1、首先我們要對根節點進行判空處理,在根節點不為空的情況下存在左右子樹(即使左右子樹為空),然後交換左右子樹;

2、把根節點的左子樹當成左子樹的根節點,對當前根節點進行判空處理,不為空時交換左右子樹;

第三次翻轉時,11節點沒有子節點,所以不用進行交換了。

3、把根節點的右子樹當成右子樹的根節點,對當前根節點進行判空處理,不為空時交換左右子樹。

4、重複步驟2和3,最後二叉樹變為原來的映象結構,如圖1所示。

// 二叉樹的翻轉
public BinaryTree_LinkedList reverseTree(BinaryTree_LinkedList root){
		
	// 1、首先我們要對根節點進行判空處理,在根節點不為空的情況下存在左右子樹(即使左右子樹為空),然後交換左右子樹;
	if(root == null){
		return null;
	}
		
	// 2、把根節點的左子樹當成左子樹的根節點,對當前根節點進行判空處理,不為空時交換左右子樹;
	if(root.leftChildNode != null){
		reverseTree(root.leftChildNode);
	}
		
	// 3、把根節點的右子樹當成右子樹的根節點,對當前根節點進行判空處理,不為空時交換左右子樹。
	if(root.rightChildNode != null){
		reverseTree(root.rightChildNode);
	}
		
        BinaryTree_LinkedList temp = root.leftChildNode;
	root.leftChildNode = root.rightChildNode;
	root.rightChildNode = root.leftChildNode;
	return root;
	}

2.6.9  判斷兩個二叉樹是否完全對稱

一棵左右完全對稱的二叉樹如下圖所示:

分析過程如下:

1、先比較根節點的左右子節點;

2、將左子樹根節點的左子節點右子樹根節點的右子節點進行比較;  ---- 左的左和右的右進行比較

3、將左子樹根節點的右子節點右子樹根節點的左子節點進行比較;   ---- 左的右和右的左進行比較

4、重複以上過程......

// 判斷兩個二叉樹是否對稱
public boolean isSymmetrical(BinaryTree_LinkedList root1, BinaryTree_LinkedList root2){
	if(root1 == null && root2 == null){
		return true;
	}
		
	if(root1 == null || root2 == null){
		return false;
	}
		
	if(root1.data != root2.data){
		return false;
	}
		
	// 遞迴
	return (isSymmetrical(root1.leftChildNode, root2.rightChildNode) && isSymmetrical(root1.rightChildNode, root2.leftChildNode));
}

2.6.10  二叉樹的寬度

二叉樹的深度上面已經介紹過了,下面介紹怎麼求二叉樹的寬度:

二叉樹的寬度:具有最多節點數的層中包含的節點數。比如:下圖所示的二叉樹的寬度就是4,即第3層的節點數。

求解思路:使用佇列,層次遍歷二叉樹。在上一層遍歷完成後,下一層的所有節點放到佇列中,此時佇列的元素個數就是下一層的寬度。依次類推,依次遍歷每一層即可求出二叉樹的最大寬度。

// 求解二叉樹的最大寬度
public int getWidth(BinaryTree_LinkedList root){
	if(root == null){
		return 0;
	}
		
	Queue<BinaryTree_LinkedList> queue = new ArrayDeque<BinaryTree_LinkedList>();
		
	int maxWidth = 1;  // 最大寬度
		
	queue.add(root);   // 入隊
		
	while(true){
		int len = queue.size();    // 當前層節點的個數
		if(len == 0)  break;
		// 如果當前層還有節點
		while(len > 0){    
			BinaryTree_LinkedList node = queue.poll();
			len--;
			if(node.leftChildNode != null)  queue.add(leftChildNode);    // 下一層節點入隊
			if(node.rightChildNode != null) queue.add(rightChildNode);   // 下一層節點入隊
		}
			
		maxWidth = Math.max(maxWidth, queue.size());    // 取當前層的寬度和下一層寬度較大的
	}
		
	return maxWidth;
}

2.6.11  重建二叉樹   ----  面試題

參考:https://blog.csdn.net/snow_7/article/details/51822366

參考:https://blog.csdn.net/jsqfengbao/article/details/47088947

輸入某二叉樹的前序和中序遍歷的結果,請重建出該二叉樹。假設輸入的前序遍歷和中序遍歷都不含重複的數字。

根據前序遍歷陣列,可以得到樹的根節點,根據得到的根節點,去中序陣列中找到相應的根節點,因為中序的遍歷順序是左-->根節點-->右,可以得到根節點的左子樹和右子樹的中序遍歷陣列,這樣樹的左子樹中的節點個數和右子樹中的節點個數就確定了。在前序陣列中,左子樹的前序和右子樹的前序也可以確定了。

引數說明:前序遍歷陣列,子樹在前序陣列中的開始位置,結束位置;

                  中序遍歷陣列,子樹在中序陣列中的開始位置,結束位置。

可以根據根節點的前序和中序陣列得根節點子樹的前序和中序陣列,所以需要使用遞迴。

public class BuildTree {

	public TreeNode reConstructBinaryTree(int[] pre, int[] in) {
		// 如果先序或者中序陣列有一個為空的話,就無法建樹,返回為空
		if (pre == null || in == null || pre.length != in.length)
			return null;
		else {
			return reBulidTree(pre, 0, pre.length - 1, in, 0, in.length - 1);
		}
	}

	private TreeNode reBulidTree(int[] pre, int startPre, int endPre, int[] in, int startIn, int endIn) {
		if (startPre > endPre || startIn > endIn)// 先對傳的引數進行檢查判斷
			return null;
		int root = pre[startPre];   // 陣列的開始位置的元素是根元素
		int locateRoot = locate(root, in, startIn, endIn);    // 得到根節點在中序陣列中的位置,左子樹的中序和右子樹的中序以根節點位置為界
		if (locateRoot == -1)   return null;  // 在中序陣列中沒有找到跟節點,則返回空
			
		TreeNode treeRoot = new TreeNode(root);// 建立樹根節點
		treeRoot.left = reBulidTree(pre, startPre + 1, startPre + locateRoot - startIn, in, startIn, locateRoot - 1);// 遞迴構建左子樹
		treeRoot.right = reBulidTree(pre, startPre + locateRoot - startIn + 1, endPre, in, locateRoot + 1, endIn);// 遞迴構建右子樹
		return treeRoot;
	}

	// 找到根節點在中序陣列中的位置,根節點之前的是左子樹的中序陣列,根節點之後的是右子樹的中序陣列
	private int locate(int root, int[] in, int startIn, int endIn) {
		for (int i = startIn; i < endIn; i++) {
			if (root == in[i])
				return i;
		}
		return -1;
	}
}

推薦及參考:

1、《資料結構與演算法之美》之樹篇:https://time.geekbang.org/column/article/67856

2、重溫資料結構:https://blog.csdn.net/u011240877/article/details/53193877

3、二叉樹的基本操作:https://mp.weixin.qq.com/s/ONKJyusGCIE2ctwT9uLv9g

4、紅黑樹深入剖析:https://zhuanlan.zhihu.com/p/24367771