數據結構與算法分析-樹

對於大量的輸入數據,鏈表的線性訪問時間太慢,不宜使用。

預備知識

*樹(tree)可以用幾種方式定義。定義樹的一種自然的方式是遞歸的方法。一棵樹是一些節點的集合。這個集合可以是空集;若非空,則一棵樹由稱做根(root)的節點rr以及0個或多個非空的(子)樹T1,T2,,TkT_1,T_2,\cdots,T_k組成,這些子樹中每一棵樹的根都被來自根rr的一條有向的邊(edge)*所連接。

每一課子樹的根叫做根rr兒子(child),而rr是每一棵子樹的根的父親(parent)。如下圖。

從遞歸中我們發現,一棵樹是NN個節點和N1N-1條邊的集合,其中的一個節點叫做根。存在N1N-1條邊的結論是由下面的事實得出的,每條邊都將某個節點連接到它的父親,而除去根節點外每一個節點都有一個父親。

如上圖所示,節點A是根。節點F有一個父親並且兒子K、L和M。每一個節點可以有任意多個兒子,也可能是零個兒子。沒有兒子的節點稱爲樹葉(leaf);上圖中的樹葉是B、C、H、I、P、Q、K、L、M、和N。具有相同父親的節點稱爲兄弟(sibling);用類似的方法可以定義祖父(grandfather)孫子(grandchild)

從節點n1n_1nkn_k的*路徑(path)定義爲節點n1,n2,,nkn_1,n_2,\cdots,n_k的一個序列,使得對於1i<k1\le i <k,節點nin_ini+1n_{i+1}的父親。這個路徑的長(length)*爲該路徑上的邊的條數,即k1k-1。從每一個節點到它自己有一條長爲0的路徑。注意,在一棵樹中從根到每個節點恰好存在一條路徑。

對任意節點nin_inin_i的***深度(depth)***爲從根到nin_i的唯一路徑的長。因此,根的深度爲0。nin_i的***高(height)***是從nin_i到一片樹葉的最長路徑的長。因此,所有樹葉的高都是0。一棵樹的高等於它的根的高。對於上圖,E的深度爲1而高爲2;F的深度爲1高也是1;該樹的高爲3。一棵樹的深度等於它的最深的樹葉的深度;該深度總是等於這棵樹的高。

如果存在從n1n_1n2n_2的一條路徑,那麼n1n_1n2n_2的一位祖先(ancestor)n2n_2n1n_1的一個後裔(descendant)。如果n1n2n_1\neq n_2,那麼n1n_1n2n_2的一位真祖先(proper ancestor)n2n_2n1n_1的一個真後裔(proper descendant)

樹的實現

實現樹的一種方法可以是在每一個節點除數據外還要有一些指針,使得該節點的每一個兒子都有一個指針指向它。然而,由於每個節點的兒子數可以變化很大並且事先並不知道,因此在數據結構中建立到各兒子節點直接的鏈接是不可行的。實際上解法很簡單:將每個節點的所有兒子都放在樹節點的鏈表中。如下聲明。

typedef struct TreeNode *PtrToNode;

struct TreeNode
{
    ElementType Element;
    PtrToNode FirstChild;
    PtrToNode NextSibling;
}

如下圖顯示一棵樹可以用這種實現方法表示出來。

第一兒子/下一兄弟表示法

圖中向下的箭頭是指向*FirstChild(第一兒子)的指針。從左到右的箭頭是指向NextSibling(下一兄弟)*的指針。

如上圖中,節點E有一個指針指向兄弟(F),另一指針指向兒子(I),而有的節點這兩種指針都沒有。

樹的遍歷及應用

樹有很多應用。流行的用法之一是包括UNIX、VAX/VMS和DOS在內的許多常用操作系統中的目錄結構。如下圖是UNIX文件系統中一個典型的目錄。

UNIX目錄

這個目錄的根是/usr。(名字後面的星號指出"/usr"本身就是一個目錄。)“/usr”有三個兒子:mark、alex和bill,它們自己也都是目錄。文件名“/usr/mark/book/ch1.r”先後三次通過最左邊的兒子節點而得到。在第一個“/“後的每個”/”都表示一條邊;結果爲一全部路徑名。在UNIX文件系統中的目錄就是含有它的所有兒子的一個文件,因此,這些目錄幾乎是完全按照上述的類型聲明構造的。

在UNIX文件系統中每個目錄還有一項指向該目錄本身以及另一項指向該目錄的父目錄。因此,嚴格來說,UNIX文件系統不是樹,而是類樹(treelike)。

事實上,如果將打印一個文件的標準命令應用到一個目錄上,那麼在該目錄中的這些文件名能夠在輸出中被看到。

設我們想要列出目錄中所有文件的名字。我們的輸出格式將是:深度爲did_i的文件的名字將被did_i次跳格(tab)縮進後打印出來。算法代碼如下。

void ListDir(DirectorOrFile D, int Depth)
{
    if(D is a legitimate entry)
    {
        PrintName(D, Depth);
        if(D is a dorectory)
            for each child, C of D
                ListDir(C, Depth + 1);
    }
}
void ListDirectory(DirectorOrFile D)
{
    ListDir(D, 0);
}

目錄(先序)列表

這種遍歷的策略叫做先序遍歷(preorder traversal)。在先序遍歷中,對節點的處理工作是在它的諸兒子節點被處理之前(pre)進行的。

另一種遍歷樹的方法是後序遍歷(postorder traversal)。在後序遍歷中,在一個節點處的工作是在它的諸兒子節點被計算後(post)進行的。例如下圖。

後序遍歷得到的目錄

上圖表示的是與前面相同的目錄結構,其中圓括號內的數代表每個文件佔用的磁盤區塊(disk block)的個數。

由於目錄本身也是文件,因此它們也有大小。設我們想要計算被該樹所有文件佔用的磁盤塊的總數。最自然的做法是找出含與子目錄“/usr/mark(30)"、”/usr/alex(9)“和"/usr/bill(32)"的塊的個數。於是磁盤塊的總數就是子目錄中的塊的總數(71)加上”/usr"使用的一個塊,共72個塊。下圖代碼實現這種遍歷的策略。

void SizeDirectory(DirectoryOrFile D)
{
    int TotalSize;
    
    TotalSize = 0;
    if(D is a legitimate entry)
    {
        TotalSize = FileSize(D);
        if(D is a directory)
            for each child, C, of D
                TotalSize += SizeDirectory(C);
    }
    return TotalSize;
}

如果D不是一個目錄,那麼SizeDirectory只返回D所佔用的塊數。否則,被D佔用的塊數將被加到在其所有子節點(遞歸地)發線的塊數中去。下圖顯示每個目錄或文件的大小是如何由該算法產生的。

軌跡

二叉樹

*二叉樹(binary tree)*是一棵樹,其中每個節點都不能有多於兩個的兒子。

如下圖顯示一顆由一個根和兩顆子樹組成的二叉樹,TLT_LTRT_R均可能爲空。

一般二叉樹

二叉樹的一個性質是平均二叉樹的深度要比NN小得多,這個性質有時很重要。分析表明,這個平均深度爲O(N)O(\sqrt N),而對於特殊類型的二叉樹,即二叉查找樹(binary search tree),其深度的平均值是O(logN)O(\log N)。不幸的是,正如下圖所示,這個深度可以大到N1N-1的。

最壞情況的二叉樹

實現

因爲一顆二叉樹最多有兩個兒子,所以我們可以用指針直接指向它們。如下代碼所示。

typedef struct TreeNode *PtrToNode;
typedef struct PtrToNode Tree;

struct TreeNode
{
    ElementType Element;
    Tree Left;
    Tree Right;
};

二叉樹有許多與搜索無關的重要應用。二叉樹的主要用處之一是在編譯器的設計領域。

表達式樹

如下圖是一個*表達式樹(expression tree)*的例子。

表達式樹

表達式樹的樹葉是操作數(operand),而其他的節點爲操作符(operator)。由於這裏的所有操作都是二元的,因此這顆特定的樹正好是二叉樹,雖然這是最簡單的情況,但是節點還是有可能含有多於兩個的兒子的。一個節點也有可能只有一個兒子,如具有一目減算符(unary minus operator)的情形。我們可以將通過遞歸計算左子樹和右子樹所得到的值應用在根處的算符操作中而算出表達式樹T的值。

我們可以通過遞歸產生一個帶括號的左表達式,然後打印處在根處的運算符,最好再遞歸地產生一個帶括號的右表達式而得到一個(對兩個括號整體進行運算的)中綴表達式(infix expression)。這種一般的方法(左、節、右)稱爲中序遍歷(inorder traversal)

另一個遍歷策略是遞歸打印出左子樹、右子樹,然後打印運算符。如果我們應用這種策略於上面的樹,則輸出將是“a b c  + d e  f + g  +a\space b\space c\space *\space +\space d\space e\space *\space f\space +\space g\space *\space +”,容易看出,這就是後綴表達式。這種遍歷策略一般稱爲後序遍歷(postorder traversal)

第三種遍歷策略是先打印出運算符,然後遞歸地打印出右子樹和左子樹。其結果“+ + a  b c  +  d e f g+\space +\space a\space *\space b\space c\space *\space +\space *\space d\space e\space f\space g”是不太常用的前綴(prefix)記法,這種遍歷策略爲先序遍歷(preorder traversal)

構造一棵表達式樹

現在給出一種算法來把後綴表達式轉變成表達式樹。一次一個符號地讀入表達式。如果符號是操作數,我們就建立一個單節點樹並將一個指向它的指針推入棧中。如果符號是操作符,那麼我們就從棧中彈出指向兩棵樹T1T_1T2T_2的那兩個指針(T1T_1的先彈出)並形成一顆新的樹,該樹的根就是操作符,它的左、右兒子分別指向T2T_2T1T_1。然後將指向這顆樹的指針壓入棧中。

來看一個例子。設輸入爲:

a b + c d e +  a\space b\space +\space c\space d\space e\space +\space *\space *

前兩個符號是操作數,因此我們兩顆單節點樹並將指向它們的指針壓入棧中。

1

接着,“+”被讀入,因此指向這兩棵樹的指針被彈出,一顆新的樹形成,而指向該樹的指針被壓入棧中。

2

然後,c、d和e被讀入,在每個單節點樹創建後,指向對應的樹的指針被壓入棧中。

3

接下來讀入“+”號,因此兩棵樹合併。

4

繼續進行,讀入“*”號,因此,我們彈出兩個樹指針並形成一個新的樹,“*"號是它的根。

5

最好,讀入最後一個符號,兩棵樹合併,而指向最後的樹的指針留在棧中。

6

查找樹ADT——二叉查找樹

使二叉樹稱爲二叉查找樹的性質是,對於樹中的每個節點X,它的左子樹中所有關鍵字值小於X的關鍵字值,而它的右子樹中所有關鍵字值大於X的關鍵字值。注意,這意味着,該樹所有的元素可以用某種統一的方式排序。

現在給出通常對二叉查找樹進行的操作的簡要描述。注意,由於樹的遞歸定義,通常遞歸地編寫這些操作的例程。因爲二叉查找樹的平均深度是O(logN)O(\log N),所以我們一般不必擔心棧空間被用盡。下面給出定義和聲明。

#ifndef _Tree_h
#define _Tree_h

struct TreeNode;
typedef struct TreeNode *Position;
typedef struct TreeNode *SearchTree;
typedef int ElmentType;

SearchTree MakeEmpty(SearchTree T);
Position Find(ElementType X, SearchTree T);
Position FindMin(SearchTree T);
Position FindMax(SearchTree T);
SearchTree Insert(ElementType X, SearchTree T);
SearchTree Delete(ElementType X, SearchTree T);
ElementType Retrieve(Position P);

#endif /* _Tree_h */

/* Place in the implementation file */
struct TreeNode
{
    ElementType Element;
    SearchTree Left;
    SearchTree Right;
};

MakeEmpty

這個操作主要用於初始化。有些程序設計人員更願意將第一個元素初始化爲單節點樹,但是,我們的實現方法更緊密地遵循樹的遞歸定義。如下代碼所示。

SearchTree MakeEmpty(SearchTree T)
{
    if (T != NULL)
    {
        MakeEmpty(T->Left);
        MakeEmpty(T->Right);
        free(T);
    }
    return NULL;
}

Find

這個操作一般需要返回指向樹T中具有關鍵字X的節點的指針,如果這樣的節點不存在則返回NULL。樹的結構使得這種操作很簡單。如果T是NULL,那麼我們可以就返回NULL。否則,如果存儲在T中的關鍵字是X,那麼我們可以返回T。否則,我們對樹T的左子樹或右子樹進行一次遞歸調用,這依賴於X與存儲在T中的關鍵字的關係。如下代碼所示。

Position Find(ElementType X, SearchTree T)
{
    if (T == NULL)
        return NULL;
    if (X < T->Element)
        return Find(X, T->Right);
    else if (X > T->Element)
        return Find(X, T->Left);
    else
        return T;
}

還要注意,這裏的兩個遞歸調用事實上都是尾遞歸併且可以用一次複製和一個goto語句很容易地代替。尾遞歸的使用在這裏是合理的,因爲算法表達式的簡明性是以速度的降低爲代價的,而這裏所使用的棧空間的量也只不過是O(logN)O(\log N)而已。

FindMin和FindMax

爲執行FindMin,從根開始並且只要有左兒子就向左進行。終止點就是最小的元素。FindMax例程除分支朝右兒子外其餘過程相同。如下代碼所示。

Position FindMin(SearchTree T)
{
    if (T == NULL)
        return NULL;
    else if (T->Left == NULL)
        return T;
    else
        return FindMin(T->Left);
}

我們使用遞歸編寫FindMin,使用非遞歸編寫FindMax。

Position FindMax(SearchTree T)
{
    if (T != NULL)
        while (T->Right != NULL)
            T = T->Right;
    return T;
}

Insert

爲了將X插入到樹T中,你可以像用Find那樣沿着樹查找。如果查找到X,則什麼也不用做(或做一些“更新”)。否則,將X插入到遍歷的路徑上的最後一點上。如下圖顯示插入的實際情況。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-0mlk3tBP-1579703325968)(C:\Users\FxPC\AppData\Roaming\Typora\typora-user-images\image-20200122152927129.png)]

下面給出Insert例程的代碼。

SearchTree Insert(ElementType X, SearchTree T)
{
    if (T == NULL)
    {
        /* Create and return a one-node tree */
        T = (Position)malooc(sizeof(struct TreeNode));
        if (T == NULL)
            printf("Out of space!!!");
        else
        {
            T->Element = X;
            T->Left = T->Right = NULL;
        }
    }
    else if (X < T->Element)
        T->Left = Insert(X, T->Left);
    else if (X > T->Element)
        T->Right = Insert(X, T->Right);
    /* Else X is in the tree already; we'll do nothing */
    return T; /* Do not forget this line!!! */
}

由於T指向該樹的根,而根又在第一次插入時變化,因此Insert被寫成一個返回指向新樹根的指針的函數。

Delete

正如許多數據結構一樣,最困難的操作是刪除。一旦發線要被刪除的節點,我們就需要考慮幾種可能的情況。

如果節點是一片樹葉,那麼它可以被立即刪除。如果節點有一個兒子,則該節點可以在其父節點調整指針繞過該節點後被刪除,如下圖。

具有一個兒子的節點的刪除

注意,所刪除的節點現在已不在引用,而該節點只有在指向它的指針已被省去的情況下才能夠被去掉。

複雜的情況是處理具有兩個兒子的節點。一般的刪除策略是用其右子樹的最小的數據(很容易找到)代替該節點的數據並遞歸地刪除那個節點(現在它是空的)。因爲右子樹中的最小的節點不可能有左兒子,所以第二次Delete要容易。如下圖所示。

刪除具有兩個兒子

要被刪除的節點是根節點的左兒子,其關鍵字是2。它被右子樹中的最小數據(3)所代替,然後關鍵字是3的原節點如前例那樣被刪除。代碼如下。

SearchTree Delete(ElementType X, SearchTree T)
{
    Position TmpCell;

    if (T == NULL)
        printf("Element not found");
    else if (X < T->Element) /* Go left */
        T->Left = Delete(X, T->Left);
    else if (X > T->Element) /* Go right */
        T->Right = Delete(X, T->Right);
    else                         /* Found element to be deleted */
        if (T->Left && T->Right) /* Two children */
    {
        /* Replace with smallest in right subtree */
        TmpCell = FindMin(T->Right);
        T->Element = TmpCell->Element;
        T->Right = Delete(T->Element, T->Right);
    }
    else /* One or zero children */
    {
        TmpCell = T;
        if (T->Left == NULL) /* Also handles 0 children */
            T = T->Right;
        else if (T->Right == NULL)
            T = T->Left;
        free(TmpCell);
    }
    return T;
}

上面代碼效率並不高,因爲它沿該樹進行兩趟搜索以查找和刪除右子樹中最小的節點。寫一個特殊的DeleteMin函數可以容易地改變效率不高的缺點。

如果刪除的次數不多,則通常使用的策略是懶惰刪除(lazy deletion):當一個元素要被刪除時,它仍留在樹中,而是隻做了個被刪除的記號。這種做法特別是在有重複關鍵字時很流行,因爲此時記錄出現頻數的域可以減1。如果樹中的實際節點數和“被刪除”的節點數相同,那麼樹的深度預計只上升一個小的常數,因此,存在一個與懶惰刪除相關的非常小的實際損耗。再有,如果被刪除的關鍵字是重新插入的,那麼分配一個新單元的開銷就避免了。

平均情形分析

直觀上,除MakeEmpty外,所以的操作都花費O(logN)O(\log N)時間,因爲我們用常數時間在樹種降低了一層,這樣一來,對樹的操作大致減少了一半左右。因此,除MakeEmpty外,所有的操作都是O(d)O(d),其中dd是包含所訪問的關鍵字的節點的深度。

我們在此要證明,所有的樹出現的機會均等,則樹的所有結點的平均深度爲O(logN)O(\log N)

一棵樹的所有節點的深度的和稱爲內部路徑長(internal path length)。我們現在要計算二叉查找樹平均內部路徑長,其中的平均是對向二叉查找樹中所有可能的插入序列進行的。

D(N)D(N)是具有NN個節點的某棵樹T的內部路徑長,D(1)=0D(1)=0。一顆NN節點樹是由一顆ii節點左子樹和一顆(Ni1N-i-1)節點右子樹以及深度爲0的一個根節點組成,其中0iN0\le i\leq ND(i)D(i)爲根的左子樹的內部路徑長。但是在原樹中,所有這些節點都要加深一度。因此我們得到遞歸關係:

D(N)=D(i)+D(Ni1)+N1D(N)=D(i)+D(N-i-1)+N-1

如果所有子樹的大小都等可能地出現,這對於二叉查找樹是成立的(因爲子樹的大小隻依賴於第一個插入到樹中的元素的相對的秩),但對於二叉樹則不成立,那麼D(i)D(i)D(Ni1)D(N-i-1)的平均值都是(1Nj=0N1D(j)\frac1N\sum^{N-1}_{j=0}D(j))。於是

D(N)=2N[j=0N1D(j)]+N1D(N)=\frac2N[\sum^{N-1}_{j=0}D(j)]+N-1

得到的平均值爲D(N)=O(NlogN)D(N)=O(N\log N)。因此任意節點的期望深度爲O(logN)O(\log N)

但是,上來就斷言這個結果意味着上一節討論的所有操作的平均運行時間是O(logN)O(\log N)是並不完全正確的。原因在於刪除操作,我們並不清楚是否所有的二叉查找樹都是等可能出現的。特別的,上面描述的刪除算法有助於使得左子樹比右子樹深度深,因爲我們總是用右子樹的一個節點來代替刪除的節點。這種策略的準確效果仍然是未知的,但它似乎是理論上的謎團。已經證明,如果我們交替插入和刪除Θ(N2)\Theta(N^2)次,那麼樹的期望深度將是Θ(N)\Theta(\sqrt N)。在25萬次隨機Insert/Delete後,如下圖所示中右沉的樹看起來明顯地不平衡(平均深度=12.51)。

隨機生成的二叉查找樹

交替插入刪除後的二叉查找樹

在刪除操作中,我們可以通過隨機選取右子樹的最小元素或左子樹的最大元素來代替被刪除的元素以消除這種不平衡問題。這種做法明顯地消除了上述偏向並使樹保持平衡,但是,沒有人實際上證明過這一點。

如果向一棵樹預先排序的樹輸入數據,那麼一連串Insert操作將花費二次時間,而鏈表實現的代價會非常巨大,因爲此時的樹將只由那些沒有左兒子的節點組成。一種解決辦法就是要有一個稱爲平衡(balance)的附加的結構條件:任何節點的深度均不得過深。

有許多一般的算法實現平衡樹。但是,大部分算法都要比標準的二叉查找樹複雜得多,而且更新要平均花費更長的時間。不過,它們確實防止了處理起來非常麻煩的一些簡單情形。下面將介紹最古老的一種平衡查找樹,即AVL樹。

另外,較新的方法是放棄平衡條件,允許樹有任意的深度,但是在每次操作之後要使用一個調整規則進行調整,使得後面的操作效率更高。這種類型的數據結構一般屬於自調整(self-adjusting)類結構。

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