算法以及數據結構(上)

一、算法

1、算法定義

      所謂算法,就是在特定計算模型下,在信息處理過程中爲了解決某一類問題而設計的一個指令序列。一個算法還必須具備以下要素:

      (1)輸入:待處理的信息,即對問題的描述。

      (2)輸出:經過處理後得到的信息,即問題的答案。

      (3)確定性:任一算法都可以描述爲由若干種基本操作組成的序列,即處理該問題的基本操作步驟。

      (4)可行性:在相應的計算模型中,每一個基本操作都可以實現,且能夠在常數時間內完成。

      (5)有窮性:對於任何輸入,按照算法,經過有窮次基本操作都可以得到正確的輸出。

2、算法性能

     衡量算法性能的兩個方面:

      (1)時間複雜度及其度量,包括問題規模、運行時間及時間複雜度(冒泡排序舉例,問題規模指數組大小,運行時間即爲完成排序所需時間)、漸變複雜度、機器差異與基本操作

       時間複雜度:某一算法爲了處理估摸爲n的問題所需的時間記作T(n),那麼隨着問題規模n的增長,運行時間T(n)的增長方式稱爲算法的時間複雜度(漸進複雜度就是當n趨於無窮大時,T(n)取得的極限值),其分爲:

      ①常數的時間複雜度:在一個子集中取非極端元素,不管子集有多大,都可以去集合的前三個元素,比較去中間元素,進行的算法操作完全一樣,我們稱之爲常數的時間複雜度σ(1)

      ②對數的時間複雜度:對一個十進制轉換成n進制,每次十進制都至少縮小爲之前的1/n,所以經過以n爲底,十進制數爲真數的對數函數的值加1次循環,算法完成,我們稱這類算法具有對數的時間複雜度σ(㏒n)

      ③線性的時間複雜度:對於求和,多少個元素就就行多少個算法步驟,我們稱這類算法具有線性的時間複雜度σ(n)

      以上三種複雜度上升,算法的效率不斷下降,但就實際應用而言這類算法的效率還在允許的範圍內,但以下多項式時間複雜度和指數的時間複雜度,我們認爲無法應用於實際問題之中,它們不是有效的算法,甚至不能稱作算法

      ④平方時間的複雜度:冒泡排序即爲平方時間複雜度,對於其它一些算法,n的次數可能更高,但只要其次數爲常數,我們統稱爲多項式時間複雜度σ(冪函數)

      ⑤指數的時間複雜度:對於求2的n次冪的值,稱爲具有指數的時間複雜度(σ(指數函數))

      (2)空間複雜度,即算法所需使用的存儲空間量。對於同樣的輸入規模,在時間複雜度相同的前提下,算法所佔用的空間越少越好。

3、遞歸算法

      遞歸是高級程序設計語言的一個重要特徵,它允許程序中的函數或過程自我調用。同時最後一次遞歸調用被稱作遞歸的基底,簡稱遞歸基,所以所有遞歸算法必須要有遞歸基。
      (1)線性遞歸:線性遞歸是最簡單的遞歸形式,這類方法的每個實例只能遞歸地調用自己至多一次。
      尾遞歸是線性遞歸的一種形式,在線性遞歸算法中,若遞歸調用恰好出現在算法的最後一次操作,我們稱之爲尾遞歸(該方法最後一次執行是以遞歸調用結束)。通常尾遞歸會改爲迭代形式,因爲利用遞歸可以編寫出簡潔而優美的算法,但代價是計算機需要使用更多的空間,花費額外的時間以跟蹤遞歸的調用過程。
      (2)遞歸算法的複雜度
      ①遞歸跟蹤法:所謂遞歸跟蹤法,就是將遞歸方法的執行過程表示爲圖形的形式,如下圖:
        ②遞推方程法

       (3)二分遞歸:有的時候,算法需要將一個大問題分解爲兩個子問題,然後分別通過遞歸調用來求解,這種情況稱作二分遞歸。

       (4)多分支遞歸:有的時候,一個問題可能需要分解爲不止兩個子問題,此時就需要採用多分支遞歸,這類遞歸的典型運用,就是在求解組合遊戲問題時,枚舉各種可能的排列。

二、數據結構之線性結構

       在各式各樣的數據結構中,棧與隊列是最簡單,最基本的,但也絕對是最重要的,這兩種基本數據結構的應用非常廣泛,也是複雜數據結構的基礎。

      1、棧

       棧是存放對象的特殊容器,在插入與刪除對象時,這種結構遵循後進先出的原則,也就是說,對象可以任意插入棧中,但每次取出的都是此前插入的最後一個對象,插入和取出的操作分別稱作入棧和退棧。
       棧ADT(抽象數據類型的縮寫)作爲一種抽象數據類型,必須支持以下方法:push(入棧)、pop(返回棧頂對象並移除)、getSize(棧大小)、isEmpty(棧是否爲空)、top(返回棧頂對象但不移除),java中專門爲棧結構建了一個類:java.util.Stack,任何Java對象都可以作爲該內建類的棧元素。
       任一運行中的Java線程都會配備一個私有的棧,稱作Java方法棧,用於記錄各個方法在被調用過程中的局部變量等重要信息,這些被調用的方法實例的描述符,稱作幀,比如方法N調用了方法M,則在M的這個實例所對應的幀中,記錄了該實例的調用參數以及其中的局部變量,還有關於N的信息,以及在M結束時應該返回給N的東西,JVM還設置了一個稱作程序計數器的變量PC,負責記錄程序在JVM中運行到的當前位置,當方法N調用方法M時,程序計數器當前的數值就會被存放在N的實例所對應的幀中。

      2、隊列

       隊列也是對象的一種容器,在插入和刪除對象遵循先進先出的原則,也就是說,每次刪除的只能是最先插入的對象,因此,我們可以想象成將對象追加在隊列的後端,而從其前段摘除對象。
       隊列ADT也是抽象數據類型,作爲一個容器,它需要支持以下方法:enqueue(將對象加入隊列末)、dequeue(若隊列非空,返回隊列首對象並從隊列移除)、getSize(隊列大小)、isEmpty(隊列是否爲空)、front(若隊列非空,返回首對象但不移除),Java中的隊列通用接口爲Queue。

      3、鏈表

       所謂鏈表,就是按線性次序排列的一組數據節點,每個節點都是一個對象,它通過一個引用element指向對應的數據元素,同時還通過一個引用next指向下一節點。
       (1)單鏈表鏈表的第一個節點和最後一個節點,分別稱爲鏈表的首節點和末節點,末節點的特徵是其next引用爲空,如此定義的鏈表,稱作單鏈表。
       ①首節點的插入和刪除:我們只需要通過首節點的引用找到首對象,進行操作(插入:創建新的節點,設置節點next爲當前首節點,將隊列的head引用指向新節點,刪除:通過head引用找到首節點,複製next指向對象,設置next爲空,head引用指向複製的引用,時間複雜度都是常數的時間複雜度
       ②末節點的插入和刪除:對於插入,我們通過末節點的引用找到末對象,創建新的對象,設置末節點對象的next爲新對象,將末節點引用指向新對象,是常數的時間複雜度
對於刪除,我們通過末節點的引用找到莫對象,需要從鏈表的首節點逐個遍歷找到next指向末節點對象的對象,設置其next爲空,刪除原末節點對象,將末節點引用指向新末節點對象,是線性的時間複雜度因爲棧的操作僅限於棧頂元素,所以基於鏈表結構實現棧結構要把首節點作爲棧頂。

      4、位置

       抽象出位置這一概念,使我們即能夠保持鏈表結構的高效性,而又不致違背面向對象的設計原則。
       所謂位置ADT,就是支持以下方法的數據類型:getElem(返回存放於該位置的元素)、setElem(將元素存放於當前位置並返回此處原先存放的元素)。在列表中,各個位置都是相對而言的,不管該位置內的元素如何別替換或者互換,位置都不會改變。

      5、雙端隊列

       雙端隊列簡稱Deque[dek],顧名思義,就是前段和後端都支持插入和刪除操作的隊列。
       相比於棧和隊列,雙端隊列的抽象數據類型要複雜很多,基本方法如下:
       ①insertFirst(將對象作爲首元素插入)   ②insertLast(將對象作爲末元素插入)  ③removeFirst(若隊列非空,刪除首元素並返回)  ④removeLast(若隊列非空,刪除末元素並返回)  ⑤first(若隊列非空,返回首元素)  ⑥last(若隊列非空,返回末元素)  ⑦getSize(隊列大小)  ⑧isEmpty(隊列是否爲空)

 三、序列

       所謂序列(ADT),就是依次排列的多個對象,就是一組對象之間的後續和前驅關係,在實際問題中,序列可以用來實現很多種數據結構,因此被認爲是數據結構設計的基礎。

      1、向量ADT

       假定集合 S 由 n 個元素組成,它們按照線性次序存放,於是我們就可以直接訪問其中的第一個元素、第二個元素、第三個元素......。也就是說,通過[0, n-1]之間的每一個整數,都可以直接訪問到唯一的元素 e,而這個整數就等於 S 中位於 e 之前的元素個數⎯⎯在此,我們稱之爲該元素的秩(Rank)。不難看出,若元素 e 的秩爲 r,則只要 e 的直接前驅(或直接後繼)存在,其秩就是 r-1(或 r+1)。這一定義與 Java、C++之類的程序語言中關於數組元素的編號規則是一致的,支持通過秩直接訪問其中元素的序列,稱作向量(Vector)或數組列表(Array List)。 
      利用數組實現向量或者數組列表,一個很大的缺陷是數組容量固定,在向量規模小時預留過多空間浪費,在向量規模大時可能過超過數組容量,解決方法是採用可擴充數組策略,當規模大於數組容量時創建新的二倍於之前數組大小的數組,將之前數組元素全部插入新的數組,用新的數組代替之前的數組。與簡單的數組實現相比,基於可擴充數組的實現可以更高效的利用存儲空間,因爲在擴充數組時需要將原數組的內容複製到新數組,所以需要線性的時間複雜度的時間,但擴容不是每次都執行,所以我們引入分攤複雜度的概念,所謂分攤運行時間就是指在連續執行的足夠多次操作中,每次操作所需的平均運行時間,分攤時間與平均時間有本質的區別,一個算法的平均運行時間,指的是對於出現概率符合某種分佈的所有輸入,算法所需運行時間的平均值,因此也稱作期望運行時間(Expected running time)。而這裏的分攤運行時間,指的是在反覆使用某一算法及其數據結構的過程中,連續的足夠多次運行所需時間的平均值。通過分攤運行時間可以得出結論,基於可擴充數組實現的向量,每次數組擴容的分攤運行時間是常數的時間複雜度。Java中的java.util.ArrayList和java.util.Vector類就是基於可擴充數組實現的向量結構。

      2、列表ADT

       向量ADT是基於秩的實現(基於數組實現的序列),而列表ADT是基於節點的實現(基於鏈表節點實現的序列)。
       對於一個鏈表,我們如果使用秩的概念,對鏈表的訪問會很慢,因爲爲了確定鏈表結構中特定元素的秩,我們需要順着元素間的next或者prev引用逐一掃描各個元素,這需要線性的時間,因此我們在對基於鏈表實現的序列ADT進行操作時,我們直接以鏈表節點作爲參數,找到所需節點並對其實施操作,這樣可以在常數時間內完成。

      3、序列ADT

       序列ADT是向量ADT和列表ADT的繼承

      4、迭代器ADT

四、數據結構之樹結構

        上述的數據結構,根據其實現方式,可以劃分爲兩種類型:基於數組的實現與基於鏈表的實現,基於數組實現的結構允許我們通過秩在常數時間內找到目標對象進行改查操作,但一旦進行增刪操作,就需要耗費線性的時間,而基於鏈表實現的結構允許我們借用引用或者位置對象,在常數時間內增刪對象,但進行改查操作則需要耗費線性時間。當這兩種線性就夠都存在明顯缺陷後,我們引入了樹結構,樹結構中的元素不存在天然的直接後繼和直接前驅關係,因此屬於非線性結構,同時,如果附加上某種約束(比如遍歷),也可以在樹結構中的元素之間確定某種線性關係,因此也可稱之爲半線性結構,樹結構是一種分層結構

       1、樹ADT

        作爲一種抽象數據類型,樹可以用來對一組元素進行層次化的組織,樹中的元素也稱作節點,同時每個節點都被賦予了特殊的指標—深度,基座depth(b).樹結構的特點如下:
        (1)每個節點的深度都是一個非負整數
        (2)深度爲0的節點有且僅有一個,稱作樹根(Root)
        (3)對於深度爲k(k>=1)的每個節點,都有且僅有一個深度爲k-1的節點與之對應,稱爲父親或者父節點
        (4)若節點v是節點u的父親,則u稱作v的孩子,並在二者之間建立一條數邊。儘管每個節點至多有隻有一個父親,但卻可能有多個孩子,同一節點的孩子互稱兄弟。
        (5)樹中所有節點的最大深度,稱作樹的深度或高度。樹中節點的數目,總是等於邊數加一。
        (6)任一節點的孩子數目,稱作它的"度"。請注意:節點的父親不計入度數。
        (7)至少擁有一個孩子的節點稱作內部節點,沒有任何孩子的節點稱作外部節點或者葉子,換言之,當且僅當一個節點的度數爲零,則爲外部節點。
        (8)由樹中k+1節點通過樹邊首尾銜接而構成的序列{(v0, v1), (v1, v2), ..., (vk-1, vk) | k ≥ 0}稱作樹中長度爲k的一條路徑(Path)。
 
        (9)樹中任何兩個節點之間都存在唯一的一條路徑。推論:從樹根通往任意節點的路徑長度,恰好等於該節點的深度
        (10)每個節點都是自己的祖先,也是自己的後代,若v是u的父節點,則v也是u的祖先,若u的父節點的v的後代,則u也是v的後代。
        (11)除節點本身以外的祖先(後代),稱作真祖先(後代)。
        (12)任一節點的深度,等於其真祖先的數目,任一節點的祖先,在每一個深度上最多有一個
        (13)樹中每一個節點的所有後代也構成一棵樹,稱作當前樹的以該節點爲根的子樹。注意:空節點(null)本身也構成一棵樹,稱作空樹,空樹雖然不含任何節點,但卻是任何數的子樹。
        (14)以某節點爲根的子樹的深度(高度),稱爲該節點的高度。根節點的高度就是整棵樹的深度。
        (15)在樹結構中,若兩個節點都是同一個節點的後代,則稱該節點是兩個節點的共同祖先。根節點是所有節點的共同祖先,每一對節點至少存在一個共同祖先,同時一對節點可以有多個共同祖先
        (16)在一對節點的所用共同祖先中,深度最大者稱爲它們的最低共同祖先,每一對節點的最低共同祖先必存在且唯一
        (17)在樹結構中,若每個節點的所有孩子之間可以定義某一線性次序,則稱之爲有序樹,即我們可以明確每個節點的每個孩子的位置。
        (18)每個內部節點均爲m(m未知)度的有序樹,稱作m叉樹
        (19)每個內部節點均不超過2度的有序樹,稱作二叉樹。二叉樹是最簡單的非平凡m叉樹,在二叉樹中,每個節點的孩子可以用左右區分,分別稱作左孩子和右孩子,如果左右孩子同時存在,則左孩子的次序優先於右孩子。
        (20)不含1度節點的二叉樹,稱作真二叉樹,否則稱作非真二叉樹。即樹中每個節點都是二度,稱作真二叉樹。
        (21)在二叉樹中,深度爲k的節點不超過2k個,高度爲h的二叉樹最多包含2h+1-1 個節點,由n個節點構成的二叉樹,高度至少爲⎣log2n⎦。
        (22)在二叉樹中,葉子總是比二度節點多一個。
        (23)若真二叉樹中所有葉子的深度完全相同,則稱之爲滿二叉樹。高度爲h的二叉樹是滿的,當且僅當它擁有2h匹葉子、2h+1-1 個二度節點。
        (24)在一個滿二叉樹中,從最右側起將相鄰的若干匹葉子節點摘除掉,則得到的二叉樹稱作完全二叉樹。由n個節點構成的完全二叉樹,高度h=⎣log2n⎦。 
        (25)在由固定數目的節點所組成的所有二叉樹中,完全二叉樹的高度最低。

       2、樹ADT的實現

        樹ADT中,每個節點的所有後代均構成一顆子樹,故從數據類型的角度來看,樹、子樹以及樹節點都是等同的,我們採用"父親-長子-弟弟"的模型來定義樹,樹中每個節點都記錄自己的父親、長子以及最大弟弟。
        樹結構的基本算法:
        (1)獲取(子)樹的規模:一棵樹的規模,等於根節點下所有子樹規模之和再加一,也等於根節點的後代總數(節點的後代包括節點本身)。
        (2)計算節點高度:若u是v的孩子,則height(v)>=height(u)+1,可通過節點長子,並沿着最大弟弟順次找出其餘的孩子,遞歸計算出各子樹的高度,最後找出最大高度再計入根節點本身,就得到了根節點的高度。
        (3)計算節點的深度:若u是v的孩子,則depth(u)=depth(v)+1。

       3、樹遍歷算法

        所謂樹的遍歷,就是按照某種次序訪問樹中的節點,且每個節點恰好訪問一次,也就是說,按照被訪問的次序,可以得到由樹中所有節點排成的一次序列。
        (1)前序遍歷:對任一(子)樹的前序遍歷,將首先訪問其跟節點,然後再遞歸地對其下的各棵子樹進行前序遍歷,對於同一根節點下的各棵子樹,遍歷的次序通常是任意的,但若換成有序樹,則可以按照兄弟間響應的次序對它們實施遍歷,由前序遍歷生成的節點序列,稱作前序遍歷序列

        (2)後序遍歷:對任一(子)樹的後序遍歷將首先遞歸地對根節點下的各棵子樹進行後續遍歷,最後才訪問根節點,由後序遍歷生成的節點序列,稱作後序遍歷序列。
        (3)中序遍歷:中序遍歷是對於二叉樹定義的遍歷方法,在訪問每個節點之前,首先遍歷其左子樹,待該節點被訪問過後,才遍歷其右子樹,由中序遍歷確定的節點序列,稱作中序遍歷序列。

        中序遍歷中,直接前驅、直接後繼的定位算法:在二叉樹中,除中序遍歷序列中的首節點外,任一節點v的直接前驅u不外乎三種情況:
               ①v沒有左孩子,同時v是右孩子,此時u是v的父親節點。
               ②v沒有左孩子,同時v是左孩子,此時,從v出發沿parent引用逆行向上,直到第一個是右孩子的節點w,則u就是w的父親節點。
               ③v有左孩子,此時,從v的左孩子出發,沿右孩子引用不斷下行,最後一個(沒有右孩子的)節點就是u。
        任一節點v的直接後繼u的情況如下:
               ①v沒有右孩子,同時v是左孩子,此時u是v的父親節點。
               ②v沒有右孩子,同時v是右孩子,此時,從v出發沿parent引用逆行向上,知道第一個是左孩子的節點w,則u就是w的父親節點。
               ③v有右孩子,此時從v的右孩子出發,沿左孩子引用不斷下行,最後一個(沒有左孩子的)節點就是u。
        (4)層次遍歷:除了上述兩種最常見的遍歷算法,層次遍歷也是一種,在這種遍歷算法中,各節點被訪問的次序取決於它們各自的深度,其策略可以總結爲"深度小的節點優先訪問",對於同一深度的節點,訪問的次序可以是隨機的,通常取決於它們的存儲次序,即首先訪問長子,則訪問最大弟弟,一次進行。
        
public class IteratorTree implements Iterator {
private List list;//列表
private Position nextPosition;//當前(下一個)元素的位置
//默認構造方法
public IteratorTree() { list = null; }
//前序遍歷
public void elementsPreorderIterator(TreeLinkedList T) {
if (null == T) return;//遞歸基 list.insertLast(T);//首先輸出當前節點
TreeLinkedList subtree = T.getFirstChild();//從當前節點的長子開始 while (null != subtree) {//依次對當前節點的各個孩子
this.elementsPreorderIterator(subtree);//做前序遍歷 subtree = subtree.getNextSibling();
} }
//後序遍歷
public void elementsPostorderIterator(TreeLinkedList T) {
if (null == T) return;//遞歸基
TreeLinkedList subtree = T.getFirstChild();//從當前節點的長子開始 while (null != subtree) {//依次對當前節點的各個孩子
this.elementsPostorderIterator(subtree);//做後序遍歷 subtree = subtree.getNextSibling();
}
list.insertLast(T);//當所有後代都訪問過後,最後才訪問當前節點 }
//層次遍歷
public void levelTraversalIterator(TreeLinkedList T) { if (null == T) return;
Queue_List Q = new Queue_List();//空隊
Q.enqueue(T);//根節點入隊
while (!Q.isEmpty()) {//在隊列重新變空之前
TreeLinkedList tree = (TreeLinkedList) (Q.dequeue());//取出隊列首節點 list.insertLast(tree);//將新出隊的節點接入迭代器中
TreeLinkedList subtree = tree.getFirstChild();//從tree的第一個孩子起 while (null != subtree) {//依次找出所有孩子,並
Q.enqueue(subtree);//將其加至隊列中 subtree = subtree.getNextSibling();
}
}
}
//檢查迭代器中是否還有剩餘的元素
public boolean hasNext() { return (null != nextPosition); }
//返回迭代器中的下一元素
public Object getNext() throws ExceptionNoSuchElement {
if (!hasNext()) throw new ExceptionNoSuchElement("No next position"); Position currentPosition = nextPosition;
if (currentPosition == list.last())//若已到達尾元素,則
nextPosition = null;//不再有下一元素 else//否則
nextPosition = list.getNext(currentPosition);//轉向下一元素 return currentPosition.getElem();
}}

      4、完全二叉樹的Java實現

       不難發現,只要給定規模n,完全二叉樹的就夠就已完全確定,因此我們可以從0開始到n-1按照層次遍歷的次序對各個節點進行編號,這種基於向量的實現,是線性的時間複雜度,若將節點v的這種編號記做i(v),則根節點的編號i(root)=0,i(lchild(root))=1,i(rchild(root))=2...則可通過如下定理確定父子關係:
       (1)若節點v有左孩子,則i(lchild(v))=2xi(v)+1。
       (2)若節點v有右孩子,則i(rchild(v))=2xi(v)+2。
       (3)若節點v有父節點,則i(parent(v))=(i(v)-1)/2(v爲左節點)=i(v)/2-1(v爲右節點)。

五、數據結構之優先隊列結構

       存放數據只是數據結構的基本功能之一,數據結構的另一方面的典型用途就是按照次序將數據組織起來,優先隊列結構在很多應用領域都有很大的用途,比如各種事件隊列的模擬,操作系統中多任務的調度及中斷機制、採用詞頻調整策略的輸入法等,另外優先隊列也是很多高級算法的基礎,比如Huffman編碼、堆排序算法等,在採用空間掃描策略的算法中,優先隊列是組織事件隊列的最佳形式。優先隊列之所以具有廣泛的用途,是得益於其高效率以及實現的簡捷性。

      1、相關名詞

       (1)關鍵碼:優先隊列結構中各對象之間的次序是由它們共同的某個特徵、屬性或指標決定的,我們稱之爲關鍵碼(key),關鍵碼本身也是一個對象,作爲優先隊列結構的一個基本要求,在關鍵碼之間必須能夠定義某種全序關係,具體來說,任何兩個關鍵碼都必須能夠比較大小。
       (2)條目:所謂一個條目,就是由一個對象及其關鍵碼合成的一個對象,它反映和記錄了二者之間的關聯關係,通過將條目對象作爲優先隊列的元素,即可記錄和維護原先對象及其關鍵碼之間的關聯關係。
       (3)比較器:我們可以通過實現某個接口實現一個關鍵碼類,並將所有通常的比較方法封裝起來,以支持關鍵碼之間的比較,採用這一策略,我們只需編寫一個隊列類即可處理各種類型的關鍵碼,然而按照這一策略,關鍵碼的比較方式完全取決於關鍵碼本身的類型,而在很多情況下這並不能最終決定關鍵碼的具體比較方式,因此我們可以基於這個藉口實現一個獨立於關鍵碼之外的比較器類,由它來確定具體的比較規則,在創建每個優先隊列時,只要指定這樣一個比較器對象,即可按照該比較器確定的規則,在此後進行關鍵碼的比較,這一策略的另一個優點在於,一旦不想繼續使用原先的比較器對象,可以隨時用另一個比較器對象將其替換掉,而不用重寫優先隊列本身。

      2、優先隊列ADT實現

       我們可以基於向量實現,若用無序的向量實現優先隊列,我們是將未通過比較器排序的條目直接存放,需要耗費常數的時間複雜度,但在我們獲取最大優先條目時,我們需要逐一檢查每個向量對象中的條目並做比較,這需要線性的時間複雜度,而如果採用有序的向量,即先通過比較器確定條目順序進行向量存儲,那麼我們在獲取最大優先條目時需要常數的時間複雜度,但在存儲時則需要線性的時間複雜度。
       同樣的,我們也可以基於列表實現優先隊列,基於無序列表和有序列表,時間複雜度同基於向量實現相同。

六、數據結構之堆(Heap)結構

       堆結構是實現優先隊列最高效的方式,堆的高效,在於它放棄了列表結構而轉向樹形結構,基於向量和列表實現的優先隊列,之所以效率不高,原因在於時刻保持了整個集合的全序關係,所有的元素都是按照全序排列的,但我們只需要知道最小條目(優先級最大)即可,堆結構就是利用了優先序列這一特點,在任何時候只保存了整個集合的偏序關係,從而這一結構在時間複雜度方面有了實質性的改進。

      1、堆結構定義

       堆結構就是滿足一下兩條性質的二叉樹
       (1)結構性:Heap中各元素的聯結關係應符合二叉樹的結構要求(其根節點稱作堆頂)
       (2)堆序性:就其關鍵碼而言,除堆頂外的任何條目都不小於其父親。
       (3)完全性:二叉樹的搜索效率在很大程度上取決於樹的高度,爲了降低堆的高度以提高操作的效率,所以要求堆必須是一顆完全二叉樹。
       由此可見堆結構中,最小條目必處於堆頂。實際上,上述定義可以推廣至m叉樹形結構,相應的堆結構稱作m叉堆,而如上定義的堆也稱作二叉堆

      2、堆結構算法

       實現堆結構的完全二叉樹基於向量實現,以此進行堆結構的算法計算:
       (1)插入與上濾
       通過基於向量實現的完全二叉樹的addLast方法,直接將條目作爲末尾節點插入Heap中,除非Heap原先是空的,否則新插入的條目必定有一個父親,就該條目與其父親節點所保存的元素的關鍵碼進行比較,如果該條目的關鍵碼大,則沒有破壞Heap的堆序性,如果小,則只需要更換節點內保存的元素,同時要繼續與新的父節點進行比較,如有必要,重複交換操作,最終堆序性必將恢復。新插入的節點通過與父親交換不斷向上移動的這一過程,稱作上濾,若利用向量實現完全二叉樹,則二叉樹的插入操作可以在對數的時間複雜度內完成。
       (2)刪除與下濾
       當我們將堆頂的最小元素取出,堆結構將不再完整,不再滿足結構性,爲了恢復結構性,最簡單的辦法是將最末尾的節點移至堆頂位置,然而,除非此時堆中只剩下單個節點,否則移至堆頂的節點必然擁有後代,一般而言它與其後代將不滿足堆序性,我們可以挑選兩個孩子中的更小者將其交換,交換後若仍違背堆序性,則重複上述操作直到堆序性恢復。節點的高度逐層下降,我們稱之爲下濾,二叉堆的刪除操作也可以在對數的時間複雜度內完成。
       (3)建堆
       ①蠻力算法:從空堆開始依次插入,或者將所有的條目按照關鍵碼進行全序排序然後加入二叉樹,這兩種方式都能成功建堆,但缺點是需要花費很多的時間。
       ②Robert Floyd算法:我們將蠻力建堆策略的處理方向與次序顛倒過來,首先將所有節點存儲爲一顆完全二叉樹,從而滿足結構性和完整性,爲了恢復堆序性,可以從下而上對各個節點實施下濾操作。    

      3、Huffman樹

       二叉編碼樹:我們將"父親-左孩子"關係對應二進制位"0","父親-右孩子"對應二進制位"1",令每個字符對應一匹葉子,則從根節點通往每匹葉子的路徑,就對應於相應字符的二進制編碼,這樣一棵樹也稱作二叉編碼樹。解碼時,我們根據接收到的信息流,從根節點依次掃描直到對應有字符串的葉子節點,讀出對應字符,然後繼續從根節點出發進行掃描,全部讀取完成獲得原始字符串。

七、數據結構之映射(Map)和詞典(Dictionary)結構

       與優先隊列結構一樣,映射和詞典中存放的元素也是一組由關鍵碼和數據組成的條目,映射要求不同條目的關鍵碼互異,而詞典則允許多個條目擁有相同的關鍵碼。

      1、映射

       映射(Map)也是一種存放一組條目的容器,與優先隊列一樣,映射中的條目也是含有key和value(關鍵碼和數據對象),需要注意的是,映射中的關鍵碼不允許重複,因爲映射結構必須能夠比較任意一對關鍵碼是否相等,Java中的Map內建了判等方法equals,這種辦法通用性不好,因爲一旦默認的equals方法對map中存放的對象不適用,我們就需要去修改關鍵碼類本身,這樣有悖於面向對象的封裝原則,同時這種方法的靈活性也欠佳,即使是同一類對象,在不同場合的判等標準也可能不同
       (1)散列表
       如果基於列表實現映射結構,那麼不論是增刪改查,都需要掃描整個列表,是線性的時間複雜度,效率不高,如果我們將條目的關鍵碼視作其在映射中的存放位置,則可以散列表的形式來實現映射結構,散列表由兩個要素構成:桶數組與散列函數。
       ①桶數組:其實就是一個容量爲N的普通數組,在這裏,我們將其中的每一個單元都想象爲一個"桶",每個桶單元裏都可以存放一個條目。
       ②散列函數:桶中的條目,如果關鍵碼是整數,可以直接放到以關鍵碼爲角標的桶數組中的位置,但是,首先我們無法確定數組的最佳容量,會造成空間的巨大浪費,其次,我們不可能保證所有的關鍵碼都是整數,所以,我們需要一個函數,將任意關鍵碼轉換爲介於0與N-1之間的整數,這個函數就是所謂的散列函數,同時這個轉換後的整數也被稱作其對應條目的散列地址。
       對於不同的關鍵碼,必須要有不同的散列地址,如果不同關鍵碼的散列地址相同,我們就說散列發生了衝突。一個好的散列函數,必須要兼顧以下兩個基本條件:必須儘可能的單射(即不同關鍵碼轉換成不同的散列地址),另外,散列地址的計算必須在常數的時間複雜度內完成。
       Java關於散列函數的計算,有其特有的習慣,首先將一般性的關鍵碼轉換成一個稱作散列碼的整數,然後在通過所謂的"壓縮函數"將該整數映射至相應的整數區間。Java可以幫助我們將任意類型的關鍵碼key轉換爲一個整數,稱作key的散列碼(Hash code),Java通用類Object提供了默認的散列碼轉換方法hashCode(),利用它可以將任意對象實例映射爲"代表“該對象的某個整數,具體來說,hashCode()方法的返回值是一個32bit位int型整數,實際上,這個方法返回的不過就是對象在內存中的存儲地址,但該方法也存在着嚴重的缺陷,比如對於字符串類型的關鍵碼,對於基本數據類型,內存地址相同,作爲對象,兩個完全相同的字符串對象,內存地址不同,所以本應轉換成同一散列碼卻轉換成了不同的散列碼,而實際String類也對hashCode方法進行了改寫。而壓縮函數,就是將32位的int型整數壓縮至我們希望的區間內,通常採用的壓縮方法有:模餘法和MAD法,模餘法是最簡單的壓縮辦法,就是取一個素數,然後取模(之所以選取素數,是爲了最大程度地將散列碼均勻的映射至指定區間內),MAD法是一種將乘法、加法、除法結合起來的方法,通過對散列碼i進行|a*i+b| mod N的處理,其中N仍爲素數,a>0,b>0,a mod N≠0,它們都是在確定壓縮函數時隨機選取的常數。
       (2)散列表的基本思想,是採用一個桶數組,藉助一個散列函數得到桶單元編號,從而快速地完成訪問和修改,然而遺憾的是很難保證不同關鍵碼所對應的桶編號不致衝突。
解決衝突的方法有:
       ①分離鏈:解決衝突最直接了當的方法,將所有相互衝突的條目組成一個映射結構,存放在他們共同對應的桶單元中,也就是說該桶單元對應映射結構。
       ②衝突池:在散列表之外另設一個映射結構,一旦發生衝突,就將衝突的條目放入該映射結構中,從效果上看,這相當於將所有衝突的條目存入一緩衝池,該方法也因此得名。
       ③開放定址:分離鏈策略可以非常便捷地實現映射結構的各種操作算法,但是就數據結構本身而論,這一策略需要藉助列表作爲附加結構,將相互衝突的條目分組存放,這不僅會增加代碼出錯的可能,而且也需要佔用更多的空間,開放定址是一種不借助附加結構解決散列衝突的策略,這一策略可以導出一系列的變型,比如線性探測法、平方探測法以及雙散列法等。
       ④線性探測:採用開放定址策略,最簡單的一種形式就是線性探測法,即當發現桶單元被佔用,轉而嘗試該桶單元后面的一個桶單元,若仍被佔用,繼續嘗試,直到發現一個可以利用的桶單元。若條目在長度爲N的散列表A中被存放於位置i,則i+1 mod N,i+2 mod N ...位置的桶單元稱作該條目的查找前驅桶單元(即i位置存在桶單元,i+1、i+2...直到該條目存放位置之前的單元)。使用線性探測策略,需要滿足查找前驅桶單元均非空的條件,因爲當我們刪除一個桶單元時,可能會使該桶單元之後的某個桶單元的查找前驅桶單元爲空從而查找失敗,解決辦法一是將刪除位置後的桶單元依次前移,這種方法增加了remove操作的時間複雜度,另一種方法是將刪除位置的空桶做特殊的標記,查找時遇到該標記繼續後繼查找,插入時記錄最靠前的帶標記的空桶,如果之後沒有可插入位置,則將新條目放入其中。
       線性探測法可以節約空間,但是各操作要複雜的多,最大的缺陷是基於這一策略的散列表往往會存在大量的條目堆積,因爲不能使用附加空間,每次解決衝突就會佔用空桶,會使發生衝突的可能性隨之增加,克服這一缺點的有效方法是平方探測法
       ⑤平方探測平方探測法是對線性探測法的改進,它是通過對i,i+1,i+2²,i+3²...位置進行探測,知道防線空桶,這個策略很好的解決了條目堆積的問題,隨着衝突次數的增加,其探測的步長將以線性的速度增長,而不是線性探測法的固定步長1,因爲一旦發生衝突,這一方法可以使待摻入條目快速跳離條目聚集的區域。
       平方探測法的缺陷是回出現二階聚集現象,條目雖然不會連續聚集成片,但是會在多個位置多次反彈,同時如果散列表容量不是素數,則可能出現循環反彈,以至於無法插入的情況,即使N爲素數,也可能出現即使有空桶,也找不到插入位置。
       ⑥雙散列:雙散列也是克服條目堆積現象的一種有效方法。
       綜上,分離鏈策略算法簡單,但耗費更多的空間,開放定址策略正好相反,可以儘可能的節省空間,但是算法需做複雜的調整,分離鏈的時間效率要遠遠高於其他方法,因此,除非在存儲你空間非常緊張的場合,我們都建議採用分離鏈策略解決衝突。

      2、詞典

       詞典結構也是用來存放條目對象的一種容器,它對其中條目的類型沒有限制,詞典和映射之間一個非常重要的差別是詞典不再要求其中各條目的關鍵碼互異,我們往往將詞典中的條目直接稱作詞條。詞典分爲兩大類:有序詞典和無序詞典,前一種詞典所存放的條目之間定義了某種全序關係,因此也相應的支持first()、last()、prev()和succ()之類的方法,而後一種詞典存放的條目無所謂次序,我們只能利用某一判等器比較一對條目的關鍵碼是否相等。

八、查找樹

      1、二分查找樹

       所謂一顆二分查找樹,要麼是一顆空樹,要麼是以某個條目爲根節點的二叉樹,而且其左、右子樹都是二分查找樹,同時,在其左子樹中,所有節點的關鍵碼均不大於根節點的關鍵碼,在其右子樹中,所有節點的關鍵碼均不小於根節點的關鍵碼,二叉樹爲二分查找樹,當且僅當其中序遍歷序列是單調非降的(可能存在相同的關鍵碼)。二分查找樹查找算法的構思是:從根節點開始,以遞歸的形式不斷縮小查找範圍,知道發現目標條目(查找成功)或查找範圍縮小至空樹(查找失敗)。這裏定義的二分查找樹允許多個幾點擁有相等的關鍵碼。在二分查找中,獲取到的節點,是這些相同關鍵碼的節點中深度最小者(深度最小者必然唯一)。
       插入算法:通過要插入的節點的關鍵碼確定插入的位置和方向,在二分查找樹中插入一個節點需要線性的時間複雜度。
       ②刪除算法首先衝入查找算法判斷樹中是否有該節點,如果有確定位置,如果該節點有左子樹,則在左子樹中找到其直接前驅,將其交換位置,然後刪除該節點,如果該節點沒有左子樹,那麼直接刪除該節點,並用其右節點取代該幾點的位置

      2、平衡二分查找樹

       二分查找樹在最後的情況下,會退化爲鏈表,即對一個有序條目組組成的二叉查找樹即爲鏈表,此時查找效率會聚降,在節點數目固定的前提下,二分查找樹的高度越低越好,因此,儘可能使二叉查找樹平衡才能達到更高的效率。

      3、等價二分查找樹

       中序遍歷序列相同的任意兩顆二叉樹,稱作相互等價的,即由n個節點組成的任意一顆二分查找樹,都與某一顆高度不超過log2n 的二分查找樹等價,所以,每一顆二分查找樹都與某一顆平衡二分查找樹相互等價,由此,爲了提高效率,我們可以通過等價二分查找樹的定義,將普通的二分查找樹向平衡二分查找樹轉換,我們需要若干種重平衡策略。
       ①zig旋轉:假設節點v是節點p的左孩子,x和y分別是v的左、右子樹,z爲p的右子樹,所謂圍繞節點p的zig旋轉操作,就是重新調整這些節點的位置,將p作爲v的右孩子,將x作爲v的左孩子,將y和z分別作爲p的左、右子樹。
     
        ②zag旋轉:假定節點v是節點p的右孩子,z和y分別是v的右、左子樹,x爲p的左子樹,所謂圍繞節點p的zag旋轉操作,就是將p作爲v的左孩子,將z作爲v的右子樹,將y和x分別作爲p的右、左子樹。
       zig和zag旋轉操作都可以在常數時間內完成。

      4、AVL樹

       在二分查找樹中,任一節點v的平衡因子都定義爲其左右子樹的高度差,空樹的高度差定義爲-1,根據平衡因子,我們可以定義一種特殊的二分查找樹,在二分查找樹中,若所有節點的平衡因子的絕對值均不超過1,則稱之爲一顆AVL樹。AVL樹的該特性在任意局部都滿足,所以AVL樹的任一子樹也必是AVL樹,同時,完全二叉樹節點的平衡因爲非0即1,固完全二叉樹必是AVL樹,但反之不然。
       與一般的二分查找樹一樣,AVL樹也不是靜態的,也應支持插入、刪除等動態修改操作,然而,在經過這類操作之後,某些節點的高度可能發生變化,以至於不再滿足AVL樹的條件,這種情況下我們需要進行處理使其重新恢復平衡。一般地,若在插入新節點之後使AVL樹失去平衡,則可以將失衡的節點(以該節點爲根節點的子樹失衡)組成集合,該集合中的每個節點都是新節點的祖先,我們爲了將其重新恢復平衡,可採取以下方法:
       ①單旋:只進行一次旋轉操作就可以恢復平衡。
       ②雙旋:進行兩次單旋恢復平衡。
刪除節點時同樣也會使AVL樹失去平衡,將失衡的節點(以該節點爲根節點的子樹失衡)組成集合,同樣的該集合中的每個節點都是刪除節點的祖先,然後通過單旋或者雙旋恢復平衡,最深失衡節點的深度必然降低。

      5、伸展樹

       AVL樹是平衡二分查找樹的一種完美實現方式,但實際上,平衡二分查找樹的實現方式還遠不止於此,伸展樹就是另外一種形式,相對於AVL樹,伸展樹更爲簡單,首先,與AVL樹不同,伸展樹無需對節點實施顯式的平衡化操作,而是代之以一種直觀而方便的操作——將最近被訪問的節點推至樹根,這一操作稱作伸展,因此,在伸展樹中,各節點不需要記錄高度、深度和平衡因子之類的信息,故節點本身也相對簡單。引入伸展樹的最初動機,在於利用平衡二分查找樹的數據局部性的一種極端情況:剛被訪問過的節點極有可能就是下一將被訪問的節點,因此每次訪問過一個節點,都通過某種方式將其移至樹根處。
       ①簡易伸展樹:通過對最近訪問節點的父節點進行zig或者zag旋轉操作。
       ②雙層伸展:兩層兩層的伸展,也就是說每次從當前節點v出發上溯兩層

      6、B-樹

       所謂m階B-樹,即滿足一下條件的m路平衡查找樹:其中的每一內部節點,都存在n個關鍵碼{K1< K2< ... < Kn}和n+1 個引用{A0, A1, A2, ..., An},n+1 ≤ m,對於每一非根內部節點,都有n+1 ≥ ⎡m/2⎤,對於根節點,除非它同時也是葉子,否則必有n+1 ≥ 2 ,每個引用 Ai分別指向一棵子樹 Ti,而且若 i ≥ 1,則 Ti中的每一關鍵碼 key 都滿足 key > Ki;若i ≤ n-1,則 Ti中的每一關鍵碼 key 都滿足 key < Ki+1。與一般的查找樹不同,爲了簡化敘述,這裏我們假定 B-樹中的所有關鍵碼互異,另外,所有葉子節點的深度相等,即它們都處於同一層。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章