數據結構與算法之樹形結構


樹形結構是一種比線性結構更復雜的結構,與線性結構一樣,是一種在邏輯上是有序的結構。樹形結構(如果非空)具有一個頂點,稱為起始結點,起始結點下又連接着其他結點,一直往下延伸。樹形結構邏輯上有序的意思就是從起始結點往下延伸的順序。
以下用一張圖來描述下樹的一些基本屬性:

了解了樹的一些基本屬性后,我們來看看樹的特例之一:二叉樹

二叉樹

為什么說二叉樹是樹的特例呢?因為二叉樹是一個最簡單的樹形結構,它的每個結點的后繼結點至多有兩個,所以后繼結點可能為0,1,2。而且明確定義了后繼結點中誰是左結點,誰是右結點。為了便於理解,我通過一張圖來描述一下常見的二叉樹的圖形結構:

二叉樹的重要性質

性質一:如果一個樹中定義根結點的層數為0,以i代表其它結點的層數,那么第i層最多為2^i個結點
說明:第一層是根結點,為1個結點=2º=1,如果為滿二叉樹,那么第二層就最多為2個結點=2¹=2,依次類推
性質二:如果一個樹的高度為h,定義根結點的高度為0,那么一個高度為h的樹中最多有2(h+1)-1個結點,最少有2h-1個結點
性質三:對於任何非空二叉樹T,如果其葉子結點的個數為N0,其度為2的結點的個數為N2,則N0=N2+1
說明:一個層數為1的二叉樹,定義根結點的層數為0,那么它的度為2的結點為0或者1,它的葉子結點為1或者2,一個層數為2的二叉樹,它的度為2的結點為0,1,2或3,它的葉子結點為1,2,3,4。依次類推。在此有兩種情況可以幫助理解:
1、當往樹中添加一個結點時,如果此結點不能構成它的雙親結點度為2,那么此種情況並不影響樹中葉子結點的個數,比如看下圖:

此種情況時,並不會改變樹中葉子結點個數,比如左圖中有葉子結點C,D。在C中添加了E的左子結點后,樹中有葉子結點D,E。還是兩個葉子結點,此種情況並不影響N0=N2+1的結果。
2、當往樹中添加一個結點時,如果此結點加入后,它的雙親結點的度從1變成2,此時,度為2的結點個數+1,葉子結點也加1,也不影響N0=N2+1的結果。
綜合上述兩種情況,N0=N2+1的結果只需要從單點樹(就是只有一個根結點的樹)中來驗證即可,因為單點樹只要滿足了此條性質,無論往單點數加什么結點,都不會影響這條性質的結果。在單點樹中N0=1,N2=0,所以N0=N2+1
性質四:滿二叉樹的葉子結點比分支結點多一個
說明:滿二叉樹中的結點要么是葉子結點,要么就是度為2的分支結點,推論跟性質三類似,可以作為參考
性質五:n個結點的完全二叉樹的高度h為不大於㏒₂ⁿ的最大整數。
說明:根據性質二與完全二叉樹的性質可得出,2h-1<T<2(h+1)-1 ——> 2^h <T<2^(h+1) ——> h<㏒₂ⁿ<h+1 ——> h為不大於㏒₂ⁿ的最大整數
性質六:如果n個結點的完全二叉樹的結點按層次並按從左到右的順序從0開始編號,對任一結點i(0<=i<=n-1)都有:

1、序號為0的結點是根結點
2、對於i>0,其父結點的編號是(i-1)/2
3、若2i+1<n,其左子結點的序號為2i+1,否則無左子結點
4、若2i+2<n,其右子結點的序號為2i+2,否則無右子結點
說明:
(1)對於i>0,其父結點的編號是(i-1)/2:
對於一個結點查找其父結點,我們可以從父結點出發,去找子結點的規律,參考下圖:

可能看出,父結點下標k=(i-1)除以2,或者等於(i-2)除以2,如果不考慮整除的話,用/取整除來代替,於是父結點下標k=(i-1)/2
(2)若2i+1<n,其左子結點的序號為2i+1,否則無左子結點
解釋下2*i+1<n,為什么有這個限制條件,有了這個,就可以避免掉單點樹,因為單點樹也是一個完全二叉樹,但是單點樹沒有左子結點與右子結點,於是3,4兩條性質不適用。而其左子結點的序號為2*i+1,否則無左子結點這句,通過上面的父結點去理解即可。
(3)同(2)

定義一個二叉樹

點擊查看代碼
class BinNode:
    """
    二叉樹結點類
    """
    def __init__(self, _data, left=None, right=None):
        # 初始化結點
        self.data = _data
        self.left = left
        self.right = right

class BinTree:
    """
    二叉樹類
    """
    def __init__(self):
        self._root = None

    def is_empty(self):
        return self._root is None

    def root(self):
        return self._root

    def leftchild(self):
        return self._root.left

    def rightchild(self):
        return self._root.right

    def set_root(self, rootnode):
        self._root = rootnode

    def set_left(self, leftnode):
        self._root.left = leftnode

    def set_right(self, rightchild):
        self._root.right = rightchild

    def preorder_elements(self, t):
        """
        先序根遍歷(遞歸方式)
        :return:
        """
        root = t
        if root is None:
            return
        print(root.data)
        self.preorder_elements(root.left)
        self.preorder_elements(root.right)

    def preorder_elem_nonrec(self):
        """
        先序根遍歷(非遞歸方式)
        :return:
        """
        root = self._root
        s = SStack()
        t = root
        while t is not None and not s.is_empty():
            while t is not None:
                print(t.data)
                t = t.left
                if t.right is not None:
                    s.push(t.right)
            t = s.pop()

    def postorder_elements(self, t):
        """
        后序根遍歷(遞歸方式)
        :return:
        """
        root = t
        if root is None:
            return
        self.postorder_elements(root.left)
        self.postorder_elements(root.right)
        print(root.data)

    def postorder_elem_nonrec(self):
        """
        后序根遍歷,非遞歸方式
        :return:
        """
        root = self._root
        s = SStack()
        t = root
        # 從根結點開始循環
        while t is not None or not s.is_empty():
            while t is not None:
                # 將當前結點加入棧中,並一直往樹的左邊找
                s.push(t)
                t = t.left if t.left is not None else t.right
            # 上面循環后,s棧中存的就是根結點的所有待出棧的左邊結點
            node = s.pop()
            print(node.data)
            # 判斷是否找到了當前棧頂結點的左子結點, 這時候依據后序根遍歷,應該去找棧頂結點的右邊結點
            if not s.is_empty() and node == s.top().left:
                t = s.top().right
            else:
                t = None

    def middle_elem(self, t):
        """
        中序根遍歷(遞歸方式)
        :return:
        """
        root = t
        if root is None:
            return
        self.middle_elem(root.left)
        print(root.data)
        self.middle_elem(root.right)

    def middle_elem_nonrec(self):
        """
        中序根遍歷(非遞歸方式)
        :return:
        """
        root = self._root
        s = SStack()
        t = root
        while t is not None or not s.is_empty():
            while t is not None:
                s.push(t)
                t = t.left
            node = s.pop()
            print(node.data)
            if node.right is not None:
                t = node.right
            else:
                t = None

    def breadth_first(self):
        """
        寬度優先遍歷,從左到右依次遍歷結點
        :return:
        """
        root = self._root
        # 需要用到隊列,以下代碼省略

二叉樹的遍歷

還是以兩種搜索方式為基礎,深度優先遍歷與廣度優先遍歷。深度優先遍歷:順着一條路徑盡可能的向前搜索,必要時回溯。廣度優先遍歷:在所有路徑上並頭前進。
以深度優先遍歷的方式:

按深度優先方式遍歷一棵二叉樹,需要做三件事,遍歷左子樹、遍歷右子樹和訪問根結點。這三件事的不同順序會產生三種遍歷的順序:
1、先序根遍歷:遍歷根結點 -> 遍歷左子樹 -> 遍歷右子樹
2、中序根遍歷:遍歷左子樹 -> 遍歷根結點 -> 遍歷右子樹
3、后序根遍歷:遍歷左子樹 -> 遍歷右子樹 -> 遍歷根結點
方便記憶的方式:從遍歷名稱解讀,先序根就是先訪問根結點,再訪問左子樹,最后訪問右子樹,中序根就是中間步驟訪問根結點,這三種方式中,左子樹一定是優先於右子樹被訪問的。還有就是在得到一個二叉樹序列時,我們想要算出它的先序、中序與后序遍歷序列的時候,容易搞混,在進行分析的時候,我們一定要將此規則應用到每一個子樹上,包括葉子結點也是一個子樹,這才不會混淆,以下是舉例說明:

當進行中序根遍歷或后序根遍歷時也是一樣,在一步步往下遍歷時,遇到的子樹都要去應用上遍歷規則,才會才能找到第一個彈出的點,當往下遍歷時子樹為空時,這時就需要回溯,回溯到最近的一次訪問子樹,從它開始繼續往下遍歷。

以廣度優先的方式:

按廣度優先遍歷,則是將子樹按從上到下,從左到右的順序一直遍歷結束。

兩種遍歷方法的算法:

深度優先往下遍歷時,當遇到分支時,最先遇到的分支將是最后遍歷的選擇,這符合棧的后進先出,先進后出的規則,所以深度優先遍歷將會使用到棧這個數據結構。廣度優先遍歷時,當遇到分支時,將會齊頭並進,是符合隊列的先進先出,后進后出的規則,廣度優先遍歷一般會使用到隊列。上面二叉樹的定義代碼中,就定義了多個遍歷的方法,分別采用了深度遍歷(包括三種遍歷順序)和廣度遍歷。可用於參考。

二叉樹的應用

應用一:優先隊列

優先隊列的概念:優先隊列就是一種考慮隊列中元素優先級的隊列,本質上還是一種隊列數據結構,但是彈出的時候需要將隊列中優先級最高的元素彈出,如果出現相同優先級的元素,保證彈出它們之間的一個。
實現優先隊列的方式:

1、使用順序表來實現優先隊列

順序表是基礎的數據結構,對於順序表的理解可以參加數組這個結構。用順序表來實現優先隊列,通過優先級按下標從表頭到表尾進行排序元素,表尾的元素優先級最高,每次取出優先級最高的元素的時候,時間復雜度為O(1)。當插入一個新元素時,需要比較並將元素插入到合適的位置,構建新的優先級順序序列,此時所造成的元素移動的時間復雜度開銷為O(n)

2、使用鏈接表來實現優先隊列

用鏈接表實現優先隊列,將優先級高的元素放置鏈接表的表頭,因為鏈接表頭部刪除的復雜度為O(1)。此種實現方式也會造成有線性時間的復雜度,在對鏈接表進行插入元素所遍歷元素造成的開銷。
順序表與鏈接表相關概念可以參考:數據結構與算法之線性結構
以上兩種方式都會存在有線性時間O(n)的復雜度,這個不是最好的方案,可以通過樹形結構來優化方案。

3、用堆來實現優先隊列

堆:從結構上看,堆就是結點里存儲數據的完全二叉樹。但堆中的元素滿足一種順序。如果完全二叉樹的樹根為最小元素,樹中每個結點的數據均小於或等於其子結點的數據,這種構造出來的完全二叉樹或堆叫做小頂堆,如果最大元素在樹根,稱為大頂堆。有了堆結構之后對於構造完全二叉樹有什么好處呢?
對於堆頂元素,我們可以直接拿得到,時間復雜度是O(1),需要思考的是插入元素與拿出堆頂元素后重新構建堆序的時間復雜度,這兩個操作都可以在O(logn)的時間內完成。下面來解釋:
插入元素:向堆中插入一個元素,需要與堆中已有的數據進行比較,可以將元素與堆中的最后一個元素進行比較,如果此元素小於它的父結點,那么將此元素上移,不停的上移直到根結點比較結束。此時就完成了堆序的構建。因為完全二叉樹的性質,進行比較與替換的次數只需要logn次就行了,參考上述的性質五
彈出元素:需要將剩下的元素構建成新的堆序。將末尾元素放置堆頂,然后將剩下的元素與堆頂元素進行比較,直至構成新的堆序。操作的時間復雜度也是O(logn)
總結:對於優先隊列,通過線性結構來定義,避免不了某些操作會出現線性時間的復雜度,通過樹形結構來定義,可以將時間復雜度縮短至logn。
基於堆實現的優先隊列的Python代碼

點擊查看代碼
class priorityQueueError(ValueError):
pass


class priorityQueue:
"""
用堆實現的優先隊列類
"""

def __init__(self, elist=[]):
self._elems = list(elist)
if elist:
self.buildheap()

def is_empty(self):
return not self._elems

def peek(self):
if self.is_empty():
raise priorityQueueError("in peek")
return self._elems[0]

def enqueue(self, e):
"""
入隊操作
:return:
:param e 添加的元素
"""
# 加入一個假元素
self._elems.append(None)
self.siftup(e, len(self._elems) - 1)

def siftup(self, e, last):
"""
往優先隊列里插入數據,調整堆序
:param e: 元素
:param last: 結束
:return: None
"""
elems, i, j = self._elems, last, (last - 1) // 2
while i > 0 and e < elems[j]:
elems[i] = elems[j]
i = j
j = (j - 1) // 2
elems[i] = e

def dequeue(self):
"""
出隊
:return:
"""
if self._elems is None:
raise priorityQueueError("in dequeue")
# 彈出首端元素
elems = self._elems
e0 = elems[0]
prio_elem = elems.pop()
# 重新構建堆序
if len(elems) == 0:
raise priorityQueueError("no more elements")
self.siftdown(prio_elem, 0, len(elems))
return e0

def siftdown2(self):
"""
彈出首端元素后,構建堆序
思路:每次都補前面的一個坑。直到到達堆的尾部
缺陷:到了葉子結點的時候,有三種情況需要判斷,邏輯復雜
情況1:如果只有一個結點的話,直接將此結點放入坑中即可。
情況2:如果有兩個結點, 需要選一個較小的結點去填坑
情況3:如果選擇的結點為右結點,結束即可,如果選擇的結點為左結點,則需要將右結點左移一位,用於補位
:return:
"""
elems = self._elems
i, j = 2 * parent_index + 1, 2 * parent_index + 2
parent_index = 0
while 2 * parent_index + 1 < len(elems) - 1:
if elems[i] <= elems[j]:
elems[parent_index] = elems[i]
parent_index = i
else:
elems[parent_index] = elems[j]
parent_index = j
i, j = 2 * parent_index + 1, 2 * parent_index + 2
temp = 2 * parent_index + 1
elem = elems.pop()
if 2 * parent_index + 1 < len(elems) - 1:
elem2 = elems.pop()
if elem2 < elem:
elems[parent_index] = elem2
elems[2 * parent_index + 1] = elem
else:
elems[parent_index] = elem
elems[2 * parent_index + 1] = elem

def buildheap(self):
"""
初始化構建堆
:return:
"""
end = len(self._elems)
for i in range(end // 2, -1, -1):
self.siftdown(self._elems[i], i, end)

def siftdown(self, e, begin, end):
"""
構建堆,在begin起始點添加一個堆頂,將以下的子堆關聯起來共同構建一個更大的子堆
:param e: 需要添加到堆頂的元素
:param begin: 堆頂元素的下標
:param end: 堆的最大下標
:return:
"""
elems = self._elems
i, j = begin, begin * 2 + 1
while j < end:
if j + 1 < end and elems[j + 1] < elems[j]:
# 存在右邊結點,並右邊結點小於當前結點
j += 1
if e < elems[j]:
# 構建結束,已經找到e需要插入的點了
break
# 如果當前e不是最小的,將j位置的元素放到e位置中,e的位置繼續往下找
elems[i] = elems[j]
i, j = j, 2 * j + 1
elems[i] = e
堆的應用:堆排序

如果一個順序表中,存儲的是個小頂堆,那么按照優先隊列的操作方式依次彈出堆頂元素,得到的將是一個遞增序列。這種方法可以幫助於排序,

應用二:哈夫曼樹(最優二叉樹)

哈夫曼樹是哈夫曼編碼所使用構建的一種二叉樹結構,為了了解哈夫曼樹的特性,先來了解下哈夫曼編碼的由來。
哈夫曼編碼:是可變字長編碼(VLC)的一種,Huffman於1952年提出一種編碼方法,該方法完全依據字符出現概率來構造異字頭的平均長度最短的碼字(百度百科摘選)。這是哈夫曼編碼的定義,以下是定義中一些概念的理解。
1、可變字長編碼(VLC)是什么意思?
可變字長編碼的含義就是每個字符編碼的長度可以動態改變,可變字長對應的就是不變字長編碼,或稱為定長編碼,比如一串文本字符,ABCD,用定長編碼來編碼為:00,01,10,11,用的是8位來代表這四個字符。如果變長編碼來編碼ABCD的話,使用0,1,10,11來進行編碼,每個字符所編碼的長度不確定,得到的編碼為(011011)
2、異字頭是什么意思?
上述用定長編碼得到的編碼串為00,01,10,11,所保證ABCD編碼為(00,01,10,11)與(00,01,10,11)解碼為ABCD都是唯一的。使用變長的(011011),當前編碼解碼后並不能唯一得到ABCD的解碼,可能得到的是ADAD,ABBABB等原碼。所以要想使用定長編碼,就需要有一種方式來保證一致性。怎么保持一致性的關鍵在於任何一個字符的編碼都不能是另一個字符編碼的前綴,而異字頭的含義就是指前綴不同。所以哈夫曼編碼是一種一致性編碼或稱為前綴編碼。
3、哈夫曼編碼用什么來確定編碼長度?
用的是字符出現的概率。字符出現的越多,所編碼的長度就越小。
4、哈夫曼編碼怎么保證“異字頭”?
使用的是哈夫曼樹來構造,一開始並不是叫哈夫曼樹,只是哈夫曼編碼使用了一種最優二叉樹的方式來保證任何一個字符的編碼都不能是另一個字符編碼的前綴,而且還能通過帶權的方式(出現的頻率就是權值)來獲取每個字符的編碼,將左子樹的邊定義為0,右子樹的邊定義為1。此種帶權最優二叉樹就被稱為哈夫曼樹。
哈夫曼樹介紹
1、圖解

2、定義
給定n個權值作為n個葉子結點,構造一顆二叉樹,若該樹的帶權路徑長度達到最小,則這樣的二叉樹稱為最優二叉樹。
說明:一個樹帶權路徑長度怎么算?就是從根結點到一個葉子結點所經過的邊的最短條數(或者是每個葉子結點的層數,根結點層數為0)* 葉子結點所帶的權值。
3、構建算法

點擊查看代碼
# 哈夫曼樹
from trees.tree_do import PriorityQueue
from trees.binary_tree_node import BinNode

class HTNode(BinNode):
    """
    哈夫曼樹擴展的結點類
    """
    def __lt__(self, other):
        """
        比較結點之間的權值大小
        :param other:
        :return:
        """
        return self.data < other.data


class HuffmanPrioQ(PriorityQueue):
    """
    哈夫曼樹所使用的優先隊列
    """
    def number(self):
        """
        返回當前隊列中所剩元素的個數
        :return:
        """
        return len(self._elems)


class HuffmanTree:
    """
    哈夫曼樹
    """
    def __init__(self):
        pass

    def huffmanTree(self, weights):
        trees = HuffmanPrioQ()
        for w in weights:
            trees.enqueue(HTNode(w))
        while trees.number() > 1:
            t1 = trees.dequeue()
            t2 = trees.dequeue()
            x = t1.data + t2.data
            trees.enqueue(HTNode(x, t1, t2))
        return trees.dequeue()

4、哈夫曼編碼的應用
哈夫曼編碼可以支持對數據的解壓縮。而且是無損的。
5、其它
哈夫曼編碼又分為自適應的與非自適應的。適應性哈夫曼編碼(Adaptive Huffman coding),又稱動態哈夫曼編碼(Dynamic Huffman coding),是基於哈夫曼編碼的適自適應編碼技術。它允許在符號正在傳輸時構建代碼,允許一次編碼並適應數據中變化的條件,即隨着數據流的到達,動態地收集和更新符號的概率(頻率)。一遍掃描的好處是使得源程序可以實時編碼,但由於單個丟失會損壞整個代碼,因此它對傳輸錯誤更加敏感(摘自百度百科)。非適應性的又稱為靜態哈夫曼編碼,靜態哈夫曼編碼需要先掃碼編碼原文,得到文本頻率,然后再次掃碼編碼原文進行編碼,所以靜態哈夫曼編碼在內存使用上,效率上都劣於動態哈夫曼編碼。

總結
1、樹形結構相較於線性結構,是具有層次關系。在我們生活中,比如家里的祖輩關系、公司的組織結構關系等等都是屬於樹形結構。
2、二叉樹是樹形結構中的一種,二叉樹顧名思義,就是兩個分叉的樹,所以二叉樹的每一個結點至多有兩個子結點。
3、二叉樹的遍歷有深度優先與廣度優先兩種,深度優先又分為先序根、中序根、后序根三種
4、在二叉樹中又存在一種特殊的二叉樹,為完全二叉樹。在結構上很像完全二叉樹的可以更優的實現優先隊列的結構為:堆
5、二叉樹的另一項重要應用就是哈夫曼樹,哈夫曼樹是哈夫曼編碼過程中所構建的一種最優二叉樹,它廣泛應用於解壓縮領域,是一種一致性無損壓縮算法。
6、二叉樹只是樹形結構的一種,且是最簡單的一種,還有多種應用廣泛的樹形結構並沒有介紹,比如:
基於二叉樹的擴展:二叉搜索(排序)樹等
平衡樹類:AVL、紅黑樹、B樹、B+樹等
圖論相關:最小生成樹等

相關推薦閱讀
數據結構與算法之二叉樹擴展
數據結構與算法之隊列與棧
深度優先與廣度優先算法
圖轉換成樹,最小生成樹


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM