1. 程式人生 > 其它 >資料結構與演算法_23 _ 二叉樹基礎(上):什麼樣的二叉樹適合用陣列來儲存

資料結構與演算法_23 _ 二叉樹基礎(上):什麼樣的二叉樹適合用陣列來儲存

前面我們講的都是線性表結構,棧、佇列等等。今天我們講一種非線性表結構,樹。樹這種資料結構比線性表的資料結構要複雜得多,內容也比較多,所以我會分四節來講解。

我反覆強調過,帶著問題學習,是最有效的學習方式之一,所以在正式的內容開始之前,我還是給你出一道思考題:二叉樹有哪幾種儲存方式?什麼樣的二叉樹適合用陣列來儲存?

帶著這些問題,我們就來學習今天的內容,樹!

樹(Tree)

我們首先來看,什麼是“樹”?再完備的定義,都沒有圖直觀。所以我在圖中畫了幾棵“樹”。你來看看,這些“樹”都有什麼特徵?

你有沒有發現,“樹”這種資料結構真的很像我們現實生活中的“樹”,這裡面每個元素我們叫做“節點”;用來連線相鄰節點之間的關係,我們叫做“父子關係”。

比如下面這幅圖,A節點就是B節點的父節點,B節點是A節點的子節點。B、C、D這三個節點的父節點是同一個節點,所以它們之間互稱為兄弟節點。我們把沒有父節點的節點叫做根節點,也就是圖中的節點E。我們把沒有子節點的節點叫做葉子節點或者葉節點,比如圖中的G、H、I、J、K、L都是葉子節點。

除此之外,關於“樹”,還有三個比較相似的概念:高度(Height)、深度(Depth)、(Level)。它們的定義是這樣的:

這三個概念的定義比較容易混淆,描述起來也比較空洞。我舉個例子說明一下,你一看應該就能明白。

記這幾個概念,我還有一個小竅門,就是類比“高度”“深度”“層”這幾個名詞在生活中的含義。

在我們的生活中,“高度”這個概念,其實就是從下往上度量,比如我們要度量第10層樓的高度、第13層樓的高度,起點都是地面。所以,樹這種資料結構的高度也是一樣,從最底層開始計數,並且計數的起點是0。

“深度”這個概念在生活中是從上往下度量的,比如水中魚的深度,是從水平面開始度量的。所以,樹這種資料結構的深度也是類似的,從根結點開始度量,並且計數起點也是0。

“層數”跟深度的計算類似,不過,計數起點是1,也就是說根節點位於第1層。

二叉樹(Binary Tree)

樹結構多種多樣,不過我們最常用還是二叉樹。

二叉樹,顧名思義,每個節點最多有兩個“叉”,也就是兩個子節點,分別是左子節點右子。不過,二叉樹並不要求每個節點都有兩個子節點,有的節點只有左子節點,有的節點只有右子節點。我畫的這幾個都是二叉樹。以此類推,你可以想象一下四叉樹、八叉樹長什麼樣子。

這個圖裡面,有兩個比較特殊的二叉樹,分別是編號2和編號3這兩個。

其中,編號2的二叉樹中,葉子節點全都在最底層,除了葉子節點之外,每個節點都有左右兩個子節點,這種二叉樹就叫做滿二叉樹

編號3的二叉樹中,葉子節點都在最底下兩層,最後一層的葉子節點都靠左排列,並且除了最後一層,其他層的節點個數都要達到最大,這種二叉樹叫做完全二叉樹

滿二叉樹很好理解,也很好識別,但是完全二叉樹,有的人可能就分不清了。我畫了幾個完全二叉樹和非完全二叉樹的例子,你可以對比著看看。

你可能會說,滿二叉樹的特徵非常明顯,我們把它單獨拎出來講,這個可以理解。但是完全二叉樹的特徵不怎麼明顯啊,單從長相上來看,完全二叉樹並沒有特別特殊的地方啊,更像是“芸芸眾樹”中的一種。

那我們為什麼還要特意把它拎出來講呢?為什麼偏偏把最後一層的葉子節點靠左排列的叫完全二叉樹?如果靠右排列就不能叫完全二叉樹了嗎?這個定義的由來或者說目的在哪裡?

要理解完全二叉樹定義的由來,我們需要先了解,如何表示(或者儲存)一棵二叉樹?

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

我們先來看比較簡單、直觀的鏈式儲存法。從圖中你應該可以很清楚地看到,每個節點有三個欄位,其中一個儲存資料,另外兩個是指向左右子節點的指標。我們只要拎住根節點,就可以通過左右子節點的指標,把整棵樹都串起來。這種儲存方式我們比較常用。大部分二叉樹程式碼都是通過這種結構來實現的。

我們再來看,基於陣列的順序儲存法。我們把根節點儲存在下標i = 1的位置,那左子節點儲存在下標2 * i = 2的位置,右子節點儲存在2 * i + 1 = 3的位置。以此類推,B節點的左子節點儲存在2 * i = 2 * 2 = 4的位置,右子節點儲存在2 * i + 1 = 2 * 2 + 1 = 5的位置。

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

不過,我剛剛舉的例子是一棵完全二叉樹,所以僅僅“浪費”了一個下標為0的儲存位置。如果是非完全二叉樹,其實會浪費比較多的陣列儲存空間。你可以看我舉的下面這個例子。

所以,如果某棵二叉樹是一棵完全二叉樹,那用陣列儲存無疑是最節省記憶體的一種方式。因為陣列的儲存方式並不需要像鏈式儲存法那樣,要儲存額外的左右子節點的指標。這也是為什麼完全二叉樹會單獨拎出來的原因,也是為什麼完全二叉樹要求最後一層的子節點都靠左的原因。

當我們講到堆和堆排序的時候,你會發現,堆其實就是一種完全二叉樹,最常用的儲存方式就是陣列。

二叉樹的遍歷

前面我講了二叉樹的基本定義和儲存方法,現在我們來看二叉樹中非常重要的操作,二叉樹的遍歷。這也是非常常見的面試題。

如何將所有節點都遍歷打印出來呢?經典的方法有三種,前序遍歷中序遍歷後序遍歷。其中,前、中、後序,表示的是節點與它的左右子樹節點遍歷列印的先後順序。

  • 前序遍歷是指,對於樹中的任意節點來說,先列印這個節點,然後再列印它的左子樹,最後列印它的右子樹。

  • 中序遍歷是指,對於樹中的任意節點來說,先列印它的左子樹,然後再列印它本身,最後列印它的右子樹。

  • 後序遍歷是指,對於樹中的任意節點來說,先列印它的左子樹,然後再列印它的右子樹,最後列印這個節點本身。

實際上,二叉樹的前、中、後序遍歷就是一個遞迴的過程。比如,前序遍歷,其實就是先列印根節點,然後再遞迴地列印左子樹,最後遞迴地列印右子樹。

寫遞迴程式碼的關鍵,就是看能不能寫出遞推公式,而寫遞推公式的關鍵就是,如果要解決問題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

有了遞推公式,程式碼寫起來就簡單多了。這三種遍歷方式的程式碼,我都寫出來了,你可以看看。

void preOrder(Node* root) {
if (root == null) return;
print root // 此處為虛擬碼,表示列印root節點
preOrder(root->left);
preOrder(root->right);
}

void inOrder(Node* root) {
if (root == null) return;
inOrder(root->left);
print root // 此處為虛擬碼,表示列印root節點
inOrder(root->right);
}

void postOrder(Node* root) {
if (root == null) return;
postOrder(root->left);
postOrder(root->right);
print root // 此處為虛擬碼,表示列印root節點
}

二叉樹的前、中、後序遍歷的遞迴實現是不是很簡單?你知道二叉樹遍歷的時間複雜度是多少嗎?我們一起來看看。

從我前面畫的前、中、後序遍歷的順序圖,可以看出來,每個節點最多會被訪問兩次,所以遍歷操作的時間複雜度,跟節點的個數n成正比,也就是說二叉樹遍歷的時間複雜度是O(n)。

解答開篇&內容小結

今天,我講了一種非線性表資料結構,樹。關於樹,有幾個比較常用的概念你需要掌握,那就是:根節點、葉子節點、父節點、子節點、兄弟節點,還有節點的高度、深度、層數,以及樹的高度。

我們平時最常用的樹就是二叉樹。二叉樹的每個節點最多有兩個子節點,分別是左子節點和右子節點。二叉樹中,有兩種比較特殊的樹,分別是滿二叉樹和完全二叉樹。滿二叉樹又是完全二叉樹的一種特殊情況。

二叉樹既可以用鏈式儲存,也可以用陣列順序儲存。陣列順序儲存的方式比較適合完全二叉樹,其他型別的二叉樹用陣列儲存會比較浪費儲存空間。除此之外,二叉樹裡非常重要的操作就是前、中、後序遍歷操作,遍歷的時間複雜度是O(n),你需要理解並能用遞迴程式碼來實現。

課後思考

  1. 給定一組資料,比如1,3,5,6,9,10。你來算算,可以構建出多少種不同的二叉樹?

  2. 我們講了三種二叉樹的遍歷方式,前、中、後序。實際上,還有另外一種遍歷方式,也就是按層遍歷,你知道如何實現嗎?

歡迎留言和我分享,我會第一時間給你反饋。