Java數據結構和算法

目錄

數據結構的特徵

數據結構 優勢 缺點
數組 插入快,若是知道下標,能夠很是快地存取 查找慢,刪除慢,大小固定
有序數組 比無序數組查找快 刪除和插入慢,大小固定
提供後進先出方式的存取存取 其餘項很慢
隊列 提供先進先出方式的存取 存取其餘項很慢
鏈表 插入快,刪除快 查找慢
二叉樹 查找、插入、刪除都快(若是樹老是平衡) 刪除算法複雜
紅黑樹 查找、插入、刪除都快。樹老是平衡的 算法複雜
2-3-4樹 查找、插入、刪除都快。樹老是平衡的,相似的樹對磁盤存儲有用 算法複雜
哈希表 若是關鍵字已知則存取極快,插入快 刪除慢,若是不知道關鍵字則存取很慢,對存儲空間使用不充分
插入,刪除快,對最大數據項的存取很快 堆對其餘項存取很慢
對現實世界模擬 有些算法慢而且複雜

1.數組

1.1 無序數組

數組是最普遍使用的數據存儲結構,被植入到大部分編程語言當中,Java中經常使用數組操做有:前端

1.1.1 插入

在數組中因爲知道數組中已有數據的長度,數組元素的插入位置可以直接肯定,新的數據項就可以輕鬆地插入到數組中。node

在不容許出現相同值的狀況下,就會涉及到查找的問題,關於查找在之後的章節中介紹。程序員

1.1.2  查找

對於無序數組來講,查找(假設不容許出現重複值)算法必須平均搜索一半的數據項來查找特定的數據項,找數組頭部的數據很快,找數組尾部的數據很慢。設數據項爲N,則一個數據項的平均查找長度爲N/2,最壞狀況下,須要查找到N次才能找到。算法

1.1.3  刪除

只有在找到某個數據項以後才能刪除它,刪除算法暗含一個假設,即數組的數據之間不會有空值,在刪除某條中間數據後,下標比它大的數據項會自動填充上,保證數據的長度等於數組中的最後一個元素減一(若是數組數據之間有空值會致使其餘算法更加複雜)。所以,刪除算法須要查找平均N/2個數據項並平均移動剩餘的N/2個數據項來填充刪除帶來的數據間空值,總共是N步。數據庫

容許重複和不容許重複的比較編程

不容許重複 容許重複
查找 N/2次比較 N次比較
插入 無比較,一次移動 無比較,一次移動
刪除 N/2次比較,N/2次移動 N次比較,多於N/2次移動

1.2 有序數組

數組中的數據項按照關鍵字升序排列,即最小的數據項下標爲0,每個單元都比前一個單元的值要大,這種類型的數組就稱爲有序數組。數組

有序數組中預先設定了不容許重複,這種數據結構的選擇提升了查找的速度,可是下降了插入的速度。數據結構

對於無序的數組查找,只可以採用線性查找的方式,這就致使在無序數組中查找執行須要平均N/2次比較。架構

當使用二分查找時就可以體現出有序數組的好處。這種查找比線性查找要好的多,尤爲是對於大數組來講,二分查找就是將每次查找的範圍進行縮小,每次縮小一半,比較查找值與當前範圍中軸值的大小,直至最終找到所選值。框架

在集合工具類Collections. binarySearch()方法中實現了二分查找。

有序數組帶來的最大好處就是查找的速度比無序數組快多了,很差的方面是插入操做中因爲全部靠後的數據都須要移動以騰開空間,因此速度較慢,有序數組在查找頻繁的狀況下十分有用,但如果插入與刪除較爲頻繁時,則沒法高效工做。

爲何不用數組表示一切?

僅僅使用數組彷佛就能夠完成全部的工做,爲何不用它來進行全部的數據存儲呢?咱們已經見到了使用數組的全部缺點,在一個無序數組中插入數據很快(O(1)),可是查找卻須要很長的時間;在一個有序數組中查找數據很快,用O(logN)時間,但插入卻須要O(N)的時間;對於這兩種數組而言,因爲平均半數的數據項爲了填補「空洞」必須移動,因此刪除操做平均須要O(N)時間。

數據的另一個問題是,它們被new建立出來後,大小尺寸就被固定住了。但一般設計程序時並不會考慮之後會有多少數據項將會被放入至數組中。Java中有Collection集合相關類型,使用起來像數組,並且能夠擴展,這些附加功能是以效率爲代價的(在插入時檢查數組長度,不知足的話新建數組並移動原有數據)。

2.簡單排序

一旦創建一個重要的數據庫後,就可能根據某些需求對數據進行不一樣方式的排序,對數據的排序很是重要,且多是數據檢索的初始步驟,正如剛在有序數組中所講到的,二分查找要比線性查找快不少,可是它僅適用於有序的數組。

在數據排序方面,人與計算機相比有如下的優點:咱們能夠看到全部的數據,而且能夠一下看到最大的數據,而計算機程序卻不能像人同樣可以通覽全部的數據,它只能根據計算機的比較操做原理,在同一時間內對兩條數據進行比較。

算法的這種「管視」將是一個重複出現的問題,簡單排序的算法中都包括大概兩個步驟,這兩步循環執行,直到所有數據有序爲止:

  1. 比較兩個數據項;
  2. 交換兩個數據項,或複製其中一項。

2.1 冒泡排序

冒泡排序算法運行起來很是慢,但在概念上來講它又是最簡單的,所以冒泡排序算法在剛開始研究排序技術時是一個很是好的算法。

如下是冒泡排序要遵循的規則:

  1. 比較兩個數據;
  2. 若是前面的數據比後面的數據大,則數據之間進行交換操做;
  3. 向後移動一個位置,比較下面兩個數據;
  4. 當碰到第一個排好順序的隊員後,返回隊列最前端從新開始下一趟排序。

通常來講,數組中有N個數據項,第一趟排序中有N-1次比較,第二趟有N-2次,以此類推。這樣算法做了$N^2/2$次比較。 交換和比較操做次數都和$N2$成正比,冒泡排序運行須要$O(N2)$時間級別。不管什麼時候,只要看到一個循環嵌套在另外一個循環裏面,就能夠懷疑這個算法的運行時間爲$O(N^2)$這個時間級。

2.2 選擇排序

選擇排序改進了冒泡排序,將必要的交換次數從$O(N2)$改成$O(N)$,不幸的是比較次數仍然爲$O(N2)$。

然而,選擇排序仍然爲大記錄量的排序提出了一個很是重要的改進,由於這些大量的記錄須要在內存中移動,這就使得交換的時間和比較的時間比起來,交換的時間更爲重要(注:通常來講,Java中不是這種狀況,Java只是改變了引用位置,而實際對象的位置並無改變)。

選擇排序和冒泡排序執行了相同的比較次數:$N*(N-1)/2$。N值很大時,比較的次數是主要的,因此結論是選擇排序和冒泡排序同樣運行了$O(N^2)$時間。

可是,選擇排序無疑更快,由於它進行的交換次數要少得多;但N值較小時,特別是若是交換的時間比比較的時間級別大不少的時候,選擇排序是至關快的。

2.3 插入排序

大多數狀況下,插入排序是簡單排序算法之中最好的一種。雖然插入排序仍然須要$O(N^2)$
的時間,可是在通常狀況下,它比冒泡排序快一倍,比選擇排序還要快一點。儘管它比冒泡排序和選擇排序更麻煩一些,但也並非很複雜。它常常被用在複雜排序中的最後階段,例如快速排序。

在每趟排序完成後,全部數據項都是局部有序的,複製的次數大體等於比較的次數。然而,一次複製和一次交換的效率不一樣,因此相對於隨機數據,這個算法比冒泡排序快一倍,比選擇排序略快。在任意狀況下,對於隨機的數據進行插入排序也須要$O(N^2)$的時間級。 對於已經有序或者基本有序的數據來講,插入排序要好得多,算法只須要O(N)的時間,而對於逆序排列的數據,每次比較和移動都要執行,因此插入排序不比冒泡排序快。

3.棧和隊列

3.1 棧

棧只容許訪問一個數據項:即最後插入的數據項。移除這個數據項以後才能訪問倒數第二個插入的數據項,以此類推,是那些應用了至關複雜的數據結構算法的便利工具。

大部分微處理器運用了基於棧的體系結構,但調用一個方法時,把它的返回地址和參數壓入棧,但方法返回時,那些數據出棧,棧操做就嵌入在微處理器中。

棧是一個概念上的輔助工具,提供限定性的訪問方法push()和pop(),使程序易讀並且不易出錯。在棧中,數據項入棧和出棧的時間複雜度均爲O(1),棧操做所消耗的時間不依賴棧中數據項的個數,所以操做時間很短,棧不須要比較和移動操做。

3.2 隊列

隊列是一種相似「棧」的數據結構,只是在隊列中第一個插入的數據項會被最早移除(先進先出,FIFO),而在棧中,最後插入的數據項最早移除(LIFO)。

隊列和棧同樣也被用做程序員的工具,隊列的兩個基本操做是插入一個數據項,即把一個數據項放入隊尾;另外一個是移除一個數據項,即移除隊頭的數據項。

爲了不隊列不滿卻不能插入新數據的問題,可讓隊頭隊尾指針繞回到數組開始的位置,這也就是循環隊列(有時也稱爲「緩衝環」)。 和棧同樣,隊列中插入數據項和移除數據項的時間複雜度均爲O(1)。

3.3 優先級隊列

優先級隊列是比棧和隊列更專用的數據結構,在不少的狀況下都頗有用,優先級隊列中有一個隊列頭和隊列尾,而且也是從隊列頭移除數據。不過在優先級隊列中,數據項按照關鍵字的值有序,這樣關鍵字最小的項老是在隊列頭,數據項插入的時候會按照順序插入到合適的位置以確保隊列的順序。

優先級隊列也經常被用做程序員的工具,好比在圖的最小生成樹算法中就應用了優先級隊列。優先級隊列在某些計算機系統中應用比較普遍,例如搶佔式多任務操做系統中時間片的分配。 優先級隊列中插入操做須要O(N)的時間,而刪除操做須要O(1)的時間。

4. 鏈表

咱們以前的數據結構和算法,都是以數組爲基礎的,數組存在一種缺陷。在無序數組中,搜索是低效的;而在有序數組中,插入效率又很低;無論在哪一種數組中刪除效率都很低;何況一個數組建立後,它的大小是不可改變的。

鏈表是繼數組以後第二種使用得最普遍的通用存儲結構,鏈表的機制靈活,用途普遍,適用於多種通用的數據庫;也能夠取代數組,做爲其餘存儲結構的基礎,例如棧和隊列。

在鏈表中,每一個數據項都是被包含在「連接點」中,一個連接點是某個類的對象,一個鏈表中有許多相似的連接點,因此有必要用一個不一樣於鏈表的類來表達連接點,每一個連接點都包含一個對下一個連接點的引用。

在數組中,每一項佔用一個特定的位置。這個位置能夠用一個下標號直接訪問。在鏈表中,尋找一個特定元素的惟一方法就是沿着這個元素的鏈一直向下尋找。

鏈表中在表頭插入和刪除數據很快,僅須要改變一兩個引用值,因此花費O(1)的時間;平均起來,查找、刪除和在指定連接點後面插入都須要搜索鏈表的一半鏈節點,須要O(N)次比較。在數組中執行這些操做也須要O(N)次比較,可是鏈表仍然要快一些,由於但插入和刪除連接點時,鏈表不須要移動任何東西,增長的效率是很顯著的,特別是當複製的時間遠遠大於比較時間的時候。

鏈表比數組優越的另一個重要方面是鏈表須要用多少內存就能夠用多少內存,而且能夠擴展到全部可用內存。數組的大小在它建立的時候就固定了;因此常常因爲數組太大致使效率低下,或者數組過小致使空間溢出。Collection集合的ArrayList是一種可擴展的數組,它能夠經過可變長度解決這個問題,可是它常常只容許以固定大小的增量擴展(例如快要溢出的時候,就增長一倍的容量),這個解決方案在內存使用效率上仍是要比鏈表的低。

5. 遞歸

遞歸是一種方法(函數)調用本身的編程技術。這聽起來有點奇怪,或者甚至像是一個災難性的錯誤。可是,遞歸在編程中倒是最有趣,又有驚人高效的技術之一,不只能夠解決特定的問題,並且它能爲解決不少問題提供了一個獨特的概念上的框架。

遞歸的一些典型實例,計算三角數字(第N項是由第N-1項加N獲得):

public static int triangle(int n){
    return n == 1 ? 1 : n + triangle(n - 1);
}

全部的這些方法均可以看做是把責任推給別人,什麼地方是這個傳遞的終結呢?在這個地方必須再也不須要獲得其餘人的幫助就可以解決問題,若是這種狀況沒有發生,那麼就會有一個無限的一我的要求另一我的的鏈,它將永遠不會結束。致使遞歸的方法返回而沒有再一次進行遞歸調用,此時咱們稱爲基值狀況,每個遞歸方法都會有一個基值(終止)條件,以防止無限地遞歸下去,以及由此引起的程序崩潰。

遞歸方法的特徵:

  • 調用自身;
  • 當它調用自身的時候,這樣作是爲了解決更小的問題;
  • 存在某個足夠簡單的問題層次,在這一層次算法不須要調用本身就能直接解答,且返回結果;

在遞歸算法中每次調用自身的過程當中,參數變小(或者是被多個參數描述的範圍變小),這反映了問題變小或變簡單的事實。但參數或者範圍達到必定的最小值的時候,將會觸發一個條件,此時方法不須要調用自身就能夠返回。

調用一個方法會有必定的額外開銷,控制必須從這個調用的位置轉移到這個方法的開始處。除此以外,傳給這個方法的參數以及這個方法返回的地址都要被壓入到一個內部的棧中,爲的是這個方法能夠返回參數值和知道返回到哪裏。

另一個低效性反映在系統內存空間存儲全部的中間參數以及返回值,若是有大量的數據須要存儲,這就會引發棧溢出的問題。採用遞歸是由於它在概念上簡化了問題,而不是本質上更有效率。

5.1 分治算法

遞歸的二分查找法是分治算法的一個例子。把一個大問題分紅兩個相對來講更小的問題,而且分別解決每個小問題,這個過程一直持續下去直到達到易於求解的基值狀況,就不用繼續再分了。分治算法經常是一個方法,在這個方法中含有兩個對自身的遞歸調用,分別對應於問題的兩個部分。在二分查找中,就有兩個這樣的調用,可是隻有一個真正執行了。

5.2 歸併排序

歸併排序就是用遞歸來實現的,比簡單排序中的三種排序方法要有效地多,至少在速度上是這樣的。冒泡排序,插入排序和選擇排序要用$O(N2)$的時間,而歸併排序只須要$O(Nlog2N)$的時間,歸併排序也至關容易實現(至少比起下面章節中介紹的快速排序和希爾排序),一個缺點是它須要在存儲器中有另外一個大小等於被排序的數據項數目的數組。若是初始數組幾乎佔滿整個存儲器的空間,那麼歸併排序將不能工做。可是,若是有足夠的空間,歸併排序將是一個很好的選擇。

歸併排序的核心是歸併兩個已經有序的數組,歸併兩個有序的數組A和B,就生成了第三個數組C,數組C中包含了A和B的全部數據項,而且他們有序地排列在數組C中,排序算法的實現以下:

private void merge(T[] workSpace, T[] toSortArray, int lowPtr, int highPtr, int upperBound) {
    int index = 0;
    int lowerBound = lowPtr;
    int mid = highPtr - 1;
    int number = upperBound - lowerBound + 1;
    while (lowPtr <= mid && highPtr <= upperBound) {
        if (toSortArray[lowPtr].compareTo(toSortArray[highPtr]) < 0) {
            workSpace[index++] = toSortArray[lowPtr++];
        } else {
            workSpace[index++] = toSortArray[highPtr++];
        }
    }
    while (lowPtr <= mid) {
        workSpace[index++] = toSortArray[lowPtr++];
    }
    while (highPtr <= upperBound) {
        workSpace[index++] = toSortArray[highPtr++];
    }
    for (index = 0; index < number; index++) {
        toSortArray[index + lowerBound] = workSpace[index];
    }
}

歸併排序的思想是把一個數組分紅兩半,排序每一半,而後將其執行合併算法。如何對每一部分排序呢?這就須要遞歸了,將每一半繼續分割成1/4,每一個1/4進行排序,而後合併成一個有序的一半,依次類推,反覆分割數組,直到獲得的數組只有一個數據項,這就是其基值條件:設定只有一個數據項的數組是有序的。

歸併排序中全部的這些子數組都存放在存儲器的什麼地方?本算法中建立了一個和初始數組同樣大小的工做空間數組,子數組就存放在這個工做空間數組的這個部分中。 歸併排序的運行時間是$O(Nlog2^N)$,算法執行的過程當中看這個算法執行復制的次數和比較的次數(假設複製和比較是比較費時的操做,遞歸調用和返回不增長額外的開銷,事實上也如此)。

一個算法做爲一個遞歸的方法一般從概念上來說很容易理解,可是,在實際的應用中證實遞歸算法的效率不是過高,這種狀況下,把遞歸的算法轉換成非遞歸的算法是很是有用的,這種轉換常常會用到棧,任何一個遞歸程序都有可能做出這種轉換(使用棧實現)。

6. 高級排序

前面講了幾個簡單排序:冒泡排序,選擇排序和插入排序,都是一些比較容易實現的,但速度比較慢的算法。歸併排序運行速度比簡單排序快,可是它須要的空間是原始數組空間的兩倍,一般這是一個嚴重的缺點。

6.1 希爾排序

希爾排序基於插入排序,可是增長了一個新的特性,大大地提升了插入排序的執行效率。

依靠這個特別的實現機制,希爾排序對於多達幾千個數據項的,中等大小規模的數組排序表現良好。希爾排序不像快速排序和其餘時間複雜度爲$O(Nlog2N)$的排序算法那樣快,所以對於很是大的文件排序,它不是最優選擇。可是,希爾排序比插入排序和選擇排序這種時間複雜度爲$O(N2)$的排序算法仍是要快得多,而且它特別容易實現:希爾排序的算法既簡單又很短。

它在最壞狀況下的執行效率和在平均狀況下的執行效率相比而言沒有差不少,一些專家提倡差很少任何排序工做在開始時均可以使用希爾排序算法,若在實際狀況下證實它不夠快,再改換成諸如快速排序這樣更高級的排序算法。

插入排序,複製的操做太多。在插入排序執行一半的時候,標記符左邊這部分數據項都是排過序的,而右邊都數據項則沒有排過序,這個算法取出標記符所指的數據項,將其存儲在一個臨時變量中。

假設一個很小的數據項在靠右的位置上,這裏原本是值比較大的數據項所在位置,將這個小數據移動到在左邊的正確位置上,全部的中間項都必需要向右移動一位。這個步驟對每一個數據項都執行了將近N次的複製。雖然不是全部的數據項都必須移動N個位置,可是數據項平均起來移動了N/2個位置,總共是$O(N2/2)$次複製,所以插入排序的執行效率爲$O(N2)$。

希爾排序經過加大插入排序中元素之間的間隔,並在這些間隔的元素中進行插入排序,從而使得數據項能夠大跨度地移動。但這些數據項通過一趟排序以後,希爾排序算法減小數據項的間隔進行排序,依次進行下去。

public void sort(T[] toSortArray) {
    int nElements = toSortArray.length;
    int inner, outer;
    T temp;

    int interval = 1;
    while (interval <= nElements / 3) {
        interval = interval * 3 + 1;
    }

    while (interval > 0) {
        for (outer = interval; outer < nElements; outer++) {
            temp = toSortArray[outer];
            inner = outer;
            while (inner > interval - 1 && toSortArray[inner - interval].compareTo(temp) >= 0) {
                toSortArray[inner] = toSortArray[inner - interval];
                inner = inner - interval;
            }
            toSortArray[inner] = temp;
         }
         interval = (interval - 1) / 3;
     }
}

希爾排序比插入排序要快不少,這是由於當間隔值很大的時候,數據項每一項須要移動元素的個數較少,但數據項移動的距離很長,這是很是有效率的。但間隔值變小時,每一趟須要移動的元素個數變多,可是此時它們已經接近於它們排序後最終所在的位置,這對於插入排序更有效率。

選擇間隔序列能夠說是一種魔法,例子中使用的是 $h=h*3+1$ 生成間隔序列,固然使用其餘間隔序列也會取得不一樣程度的成功。只有一個絕對的條件,就是逐漸減小的間隔最後必定要等於1,所以最後一趟的排序是一次普通的插入排序。

6.2 快速排序

在介紹快速排序以前,先簡單講一下劃分。劃分是快速排序的基本機制,其自己也是一個比較有用的操做。劃分數據就是把數據分爲兩組,使全部的關鍵字大於特定值的在一組,而小於特定值的在另外一組。劃分前須要肯定樞紐,這個值用來判斷數據項屬於哪一組,關鍵字的值小於樞紐的數據項放在數組的左邊部分,關鍵字的值大於樞紐的數據項放在數組的右邊部分。

在完成劃分以後,數據還不能稱爲有序,這只是將數據簡單地分紅了兩組。可是數據仍是比沒有劃分以前要更接近有序了。注意,劃分是不穩定的,這也就是說,每一組的數據項並非按照它原來的順序排列的,事實上,劃分每每會顛倒組中一些數據的順序。

劃分算法由兩個指針開始工做,兩個指針分別指向數組的兩頭,左邊的指針向右移動,右邊的指針向左移動。當左邊的指針遇到比樞紐值小的數據項時,它繼續右移,由於這個數據項的位置已經處在數組的正確一邊了;可是,但遇到比樞紐值大的數據項時,它就停下來。相似地,但右邊的指針遇到大於樞紐的值的數據項時,它繼續左移,可是當發現比樞紐小的數據項時,它也停下來。兩個內層的while循環,第一個應用於左邊指針,第二個應用於右邊指針,控制這個掃描過程,由於指針退出了while循環,因此它中止移動。交換以後,繼續移動指針。

劃分算法的運行時間爲$O(N)$,每一次劃分都有N+1或N+2次比較,每一個數據項都由這個或那個指針參與比較,這產生了N次比較。交換的次數取決於數據是如何排列的,若是數據是逆序排列,而且樞紐把數據項分紅兩半,每一對值都須要交換,也就是N/2次交換。

快速排序是最流行的排序算法,在大多數狀況下,快速排序都是最快的,執行時間爲$O(Nlog2^N)$級(這隻序或者說隨機存是對內部存儲器內的排序而言,對於在磁盤文件中的數據進行排序,其餘的排序算法也許更好)。

快速排序算法本質上是經過把一個數組劃分紅兩個子數組,而後遞歸地調用自身爲每個子數組進行快速排序來實現的。可是,對這個基本的設計還須要進行一些加工,算法必須選擇樞紐以及對小的劃分區域進行排序,有三個基本的步驟:

  1. 把數組或者子數組劃分紅左邊(較小關鍵字)的一組和右邊(較大關鍵字)的一組;
  2. 調用自身對左邊的一組排序;
  3. 調用自身對右邊的一組排序;

通過一次劃分以後,全部在左邊子數組的數據項都小於在右邊子數組的數據項,只要對左邊子數組和右邊子數組分別進行排序,整個數組就是有序的了。如何對子數組進行排序?只要遞歸調用排序算法就能夠了。

如何選擇樞紐?應該選擇具體的一個數據項的關鍵字的值做爲樞紐,能夠選任意一個數據項,但劃分完成以後,若是樞紐被插入到左右子數組之間的分界處,那麼樞紐就落到了排序以後的最終位置上了。 理想狀態下,應該選擇被排序的數據項的中值數據做爲樞紐,也就是說,應該有一半的數據項大於樞紐,一邊的數據項小於樞紐,這會使得數組被劃分紅爲兩個大小相等的子數組。對快速排序來講擁有兩個大小相等的子數組是最優的狀況,若是快速排序算法必需要對劃分的一大一小兩個子數組排序,那麼將會下降算法的效率,這是由於較大的子數組會必需要被劃分更屢次。

N個數據項數組的最壞狀況是一個子數組只有一個數據項,而另外一個子數組有N-1個數據項。在逆序排列的數據項中實際上發生的就是這種狀況,在全部的子數組中,樞紐都是最小的數據項,此時算法的效率下降到了$O(N^2)$。

快速排序以$O(N^2)$運行的時候,除了慢還有一個潛在的問題,但劃分的次數增長的時候,遞歸方法的調用次數增長了,每個方法調用都要增長所需遞歸工做棧的大小。若是調用次數太多,遞歸工做棧可能會溢出,從而使得系統癱瘓。

人們已經設計出了不少選擇樞紐的方法,方法應該簡單並且可以避免出現選擇最大或者最小值做爲樞紐的狀況。選擇任意一個數據項做爲樞紐的方法的確很是簡單,可是這並不老是一個好的選擇,能夠檢測全部的數據項,而且實際計算哪個數據項是中值數據項,折中的辦法是找到數組第一個,最後一個和中間位置數據項的居中數據項值,而且設置此數據項爲樞紐,這稱爲「三數據項取中」(須要解決處理小劃分等小於3個數據項的狀況)。 快速排序的時間複雜度爲$O(Nlog2^N)$,對於分治算法來講都是這樣的,在分治算法中用遞歸的方法把一列數據項分紅兩組,而後調用自身分別處理每一組數據項。

7. 二叉樹

7.1 爲何使用二叉樹?

爲何要使用樹呢?由於它結合了另外兩種數據結構的優勢:一種是有序數組;另外一種是鏈表。在樹中查找數據項的速度和有序查找同樣快,而且插入數據項和刪除數據項的速度也和鏈表同樣。

在有序數組中插入數據項太慢,用二分查找法能夠在有序數組中快速地查找特定的值,它的過程是先查看數組最中間的數據項,若是那個數據項值比要找的大,就縮小查找範圍,在數組的後半段找;若是小就在前半段找。反覆這個過程,查找數聽說須要的時間是$O(Nlog2^N)$,同時也能夠按順序遍歷有序數組,訪問每一個數據項。然而,想在有序數組插入一個數據項,就必須先查找新數據項要插入的位置,而後把全部比數據項大的數據向後移動一位(N/2次移動),刪除數據項也須要屢次移動,因此也很慢。

鏈表中插入和刪除操做都很快,它們只須要改變一些引用值就好了,這些操做的複雜度爲O(1),可是遺憾的是,在鏈表中查找數據項可不那麼容易,查找必須從頭開始,依次訪問鏈表中的每一個數據項,直到找到該數據項爲止。所以,平均須要訪問N/2個數據項,把每一個數據項和要查找的數據項比較,這個過程很慢,費時O(N)。

不難想到能夠經過有序的鏈表來加快查找的速度,鏈表的數據項是有序的,可是這樣作沒有任何意義。即便有序的鏈表仍是必須從頭開始一次訪問數據項,由於鏈表不能直接訪問某個數據項,必須經過數據項間的鏈式引用才能夠。

要是能有一種數據結構,既能像鏈表那樣快速地插入和刪除,又能像有序數組那樣快速查找,樹實現了這些特定,稱爲最有意思的數據結構之一。

7.2 樹-簡介

樹由變鏈接的節點而構成,節點間的直線表示關聯節點間的路徑。 在樹的頂層老是有一個節點,它經過邊鏈接到第二層的多個節點,而後第二層節點連向第三層更多的節點,依此類推。因此樹的頂部小,底部大。

樹有不少種,本節中討論的是一種特殊的樹 —— 二叉樹。二叉樹的每一個節點最多有兩個子節點。更普通的樹中,節點的子節點能夠多於兩個,這種樹稱爲多路樹。二叉樹每一個節點的兩個子節點被稱爲左子節點和右子節點,分別對於樹圖形它們的位置。

7.3 二叉搜索樹

二叉搜索樹特徵的定義能夠這麼說:一個節點的左子節點的關鍵字值小於這個節點,右子節點的值大於或等於這個父節點。

注意有些樹是非平衡樹:這就是說,它們大部分的節點在根的一邊或者另外一邊,個別的子樹也極可能是非平衡的。樹的不平衡性是由數據項插入的順序形成的,若是關鍵字值是隨機插入的,樹或多或少更平衡一點。可是,若是插入序列是升序或者降序,則全部的值都是右子節點(升序時)或左子節點(降序時),這樣樹就是不平衡了。

若是樹中關鍵字值的輸入順序是隨機的,這樣創建的較大的樹,它的不平衡性問題可能不會很嚴重,由於很長一串隨機數字有序的機率是很小的。

像其餘數據結構同樣,有不少方法能夠在計算機內存中表示一棵樹,最經常使用的方法是把節點存在無關聯的存儲器中,經過每一個節點中指向本身子節點的引用來表示鏈接(固然,還能夠在內存中用數組表示樹,用存儲在數組中相對的位置來表示節點在樹中的位置)。

public class JTreeNode<T extends Comparable> {
    private T data;
    private JTreeNode<T> leftNode;
    private JTreeNode<T> rightNode

7.4 查找節點

根據關鍵字查找節點是樹的主要操做中最簡單的:查找節點的時間取決於這個節點所在的層數,假設有31個節點,不超過5層——所以最多隻須要5次比較,就能夠找到任何節點,它的時間複雜度是O(logN),更精確地說,應該是以2爲底的對數。

public JTreeNode<T> find(T key) {
    JTreeNode<T> current = root;
    while (!current.getData().equals(key)) {
        current = current.getData().compareTo(key) > 0 ? current.getLeftNode() : current.getRightNode();
        if (current == null) {
            return null;
        }
    }
    return current;
}

7.5 插入節點

要插入一個節點,必須先找到插入的地方。這很像是要找一個不存在的節點的過程。從樹的根節點開始查找一個相應的節點,它將是新節點的父節點。當父節點找到了,新的節點就能夠鏈接到它的左子節點和右子節點處,這取決於新節點的值比父節點的值大仍是小。

public void insert(T data) {
    JTreeNode<T> node = new JTreeNode<T>();
    node.setData(data);
    if (root == null) {
        root = node;
    } else {
        JTreeNode<T> current = root;
        JTreeNode<T> parent = null;
        while (true) {
            parent = current;
            if (data.compareTo(current.getData()) < 0) {
                current = current.getLeftNode();
                if (current == null) {
                    parent.setLeftNode(node);
                    return;
                }
            } else {
                current = current.getRightNode();
                if (current == null) {
                    parent.setRightNode(node);
                    return;
                }
            }
        }
     }
}

7.6 遍歷樹

遍歷樹就是按照某一種特定順序訪問樹的每個節點,這個過程不如查找、插入和刪除節點經常使用,其中一個緣由是由於遍歷的速度不夠快。不過遍歷樹在某些狀況下是有用的,並且在理論上頗有意義,有三種簡單的方法能夠用來遍歷樹,它們是:前序,中序和後序。二叉搜索樹最經常使用的遍歷方法是中序遍歷。

中序遍歷二叉搜索樹會使全部的節點按關鍵字升序被訪問到,若是但願在二叉樹中建立有序的數據序列,這是一種方法,遍歷樹的最簡單方法是用遞歸的方法,這個方法只須要作三件事:

  1. 調用自身來遍歷節點的左子樹;
  2. 訪問節點;
  3. 調用自身來遍歷節點的右子樹;

中序遍歷的方法:

public void inOrderTraverse(JTreeNode<T> currentNode) {
    if (currentNode != null) {
        inOrderTraverse(currentNode.getLeftNode());
        //here to add traverse code
        System.out.println(currentNode.getData());
        inOrderTraverse(currentNode.getRightNode());
    }
}

7.7 查找最大值和最小值

在二叉搜索樹中獲得最大值和最小值是垂手可得的事情。要找最小值時,先走到根的左子節點處,而後接着走到那個節點的左子節點,如此類推,直到找到一個沒有左子節點的節點,這個節點就是最小值的節點。

public JTreeNode<T> findMinimum() {
    JTreeNode<T> current, last = null;
    current = root;
    while (current != null) {
        last = current;
        current = current.getLeftNode();
    }
    return last;
}

同理,查找最大值就是查找最後節點的右子節點,直到找到一個沒有右子節點的節點。

7.8 刪除節點

刪除節點是二叉搜索樹中經常使用的通常操做中最複雜的,可是刪除節點在不少樹的應用中又很是重要。

刪除節點要從查找要刪除的節點開始入手,方法與前面介紹的查找節點和插入節點相同,查找節點代碼以下:

public boolean delete(T key) {
    JTreeNode<T> current = root;
    JTreeNode<T> parent = root;
    boolean isLeftChild = true;
    while (!current.getData().equals(key)) {
        parent = current;
        if (key.compareTo(current.getData()) < 0) {
            isLeftChild = true;
            current = current.getLeftNode();
        } else {
            isLeftChild = false;
            current = current.getRightNode();
        }
        if (current == null) {
            return false;
        }
    }

找到節點後,有三種狀況須要考慮:

  1. 該節點是葉子節點;

要刪除葉節點,只須要改變該節點的父節點的對應字段值,由指向該節點改成null就能夠了,要刪除的節點依然存在,但它已經不是樹的一部分了;若是要刪除的節點是根,直接設置根爲空值。 由於Java語言有垃圾回收機制,因此不須要非得把節點自己給刪掉。一旦Java認識到程序再也不與這個節點有關聯,就會自動把它清理出存儲器。

if (current.getLeftNode() == null && current.getRightNode() == null) {
    if (current == root) {
        root = null;
    } else if (isLeftChild) {
        parent.setLeftNode(null);
    } else {
        parent.setRightNode(null);
    }
  1. 該節點有一個子節點;

這個節點只有兩個鏈接:鏈接父節點的和連向它唯一的子節點的。須要從這個序列中剪斷這個節點,把它的子節點鏈接到它的父節點上。這個過程要求改變父節點適當的引用,指向要刪除節點的子節點;若是被刪除的節點是根,它沒有父節點,只是被合適的子樹所代替。

} else if (current.getRightNode() == null) {
    if (current == root) {
        root = current.getLeftNode();
    } else if (isLeftChild) {
        parent.setLeftNode(current.getLeftNode());
    } else {
        parent.setRightNode(current.getLeftNode());
    }
} else if (current.getLeftNode() == null) {
    if (current == root) {
        root = current.getRightNode();
    } else if (isLeftChild) {
        parent.setLeftNode(current.getRightNode());
    } else {
        parent.setRightNode(current.getRightNode());
    }

注意應用引用使得移動整棵子樹很是容易。這隻要斷開連向子樹的舊的引用,創建新的引用鏈接到別處便可。

  1. 該節點有兩個子節點

若是要刪除的節點有兩個子節點,就不能只是用它的一個子節點代替它,就須要用另外一種方法,對於每個節點來講,比該節點的關鍵字值次高的節點是它的中序後繼,能夠簡稱爲該節點的後繼。這就是竅門:刪除有兩個子節點的節點,用它的中序後繼來代替該節點。

怎麼找到該節點的後繼呢?首先,程序找到初始節點的右子節點,它的關鍵字必定比初始節點大,而後轉到初始節點的右子節點的左子節點那裏(若是有的話),而後到這個左子節點的左子節點,以此類推,順着左子節點的路徑一直向下找,這個路徑上的最後一個左子節點就是初始節點的後繼,算法以下:

private JTreeNode<T> getSuccessor(JTreeNode<T> delNode) {
    JTreeNode<T> successorParent = delNode;
    JTreeNode<T> successor = delNode;
    JTreeNode<T> current = delNode.getRightNode();
    while (current != null) {
        successorParent = successor;
        successor = current;
        current = current.getLeftNode();
    }
    if (successor != delNode.getRightNode()) {           successorParent.setLeftNode(successor.getRightNode());
            successor.setRightNode(delNode.getRightNode());
    }
    return successor;
}

若是後繼是當前current的右子節點,狀況相對簡單,只須要把後繼爲根的子樹移到刪除節點的位置; 若是後繼節點有子節點怎麼辦?首先,後繼節點是確定不會有左子節點的,這是由查找後繼節點的算法致使的,後繼只能有右子節點。當後繼節點是要刪除節點右子節點的左後代,執行刪除要通過以下的步驟:

  1. 把後繼父節點的左子節點字段設置爲後繼的右子節點;
  2. 把後繼的右子節點設置爲要刪除節點的右子節點;
  3. 把當前節點從它父節點的右子節點刪除,把這個值設置爲後繼;
  4. 把當前左子節點從當前節點移除,後繼節點的左子節點設置爲當前節點的左子節點。
JTreeNode<T> successor = getSuccessor(current);
    if(current == root){
        root = successor;
    } else if(isLeftChild){
        current.setLeftNode(successor);
    } else {
        current.setRightNode(successor);
    }
    successor.setLeftNode(current.getLeftNode());

看到這裏,就會發現刪除是至關棘手的操做,實際上,由於它很是複雜,一些程序員都嘗試躲開它。他們在樹節點的node類中增長了一個Boolean的字段,用來表示是否已經被刪除,要刪除一個節點時,就將該字段設置爲true,其餘操做如查找以前,就必須先判斷該字段是否已經被設置爲true。這種方法也許有些逃避責任,但若是樹中沒有那麼多刪除操做時也不失爲一個好方法。

7.9 二叉樹的效率

樹的大部分操做都須要從上到下一層一層地查找某個節點,一棵滿樹中,大約一半的節點在最底層。所以,查找、插入和刪除節點的操做大約有一半都須要找到最底層的節點(以此類推,大約四分之一節點的這些操做要到倒數第二層)。所以,常見的樹的操做時間複雜度大概是N以2爲底的對數,表示爲$O(log2^N)$。

把樹和其餘數據結構做比較,相比於無序數組或鏈表中,查找數據會變得很快;有序數組能夠很快地找到數據項,但插入數據平均須要移動N/2,相比起來,樹在插入數據時複雜度較低。所以,樹對全部經常使用的數據存儲操做都有很高的效率。

惟一不足的就是遍歷不如其餘操做快,可是遍歷在大型數據庫中不是經常使用的操做。

7.10 哈夫曼(Huffman)編碼

二叉樹並不全是搜索樹,本節中介紹一種算法,它使用二叉樹以使人驚訝的方式來壓縮數據,數據壓縮在不少領域中都是很是重要的。

首先來看簡單一些的解碼是怎樣完成的,消息中出現的字符在樹中的葉子節點,它們在消息中出現的機率越高,在樹中的位置也越高,每一個圓圈外面的數字就是頻率,非葉節點外面的數字是它子節點頻率的和。 如何使用該樹進行解碼?每一個字符都是從根開始,若是遇到0,就向左走到下個節點,若是遇到1,就向右,這樣就找到了相應的節點A。

下面是創建哈夫曼樹的方法:

  1. 爲消息中的每一個字符建立一個Node對象,每一個節點有兩個數據項:字符和字符在消息中出現的頻率;
  2. 爲這些節點建立tree對象,這些節點就是樹的根;
  3. 把這些樹都插入到一個優先級隊列中,它們按照頻率排序,頻率最小的節點擁有最高的優先級。所以,刪除一棵樹的時候,它就是隊中最少用到的字符。

如今作下面的事情:

  1. 從優先級隊列中刪除兩棵樹,並把它們做爲一個新節點的子節點。新節點的頻率是子節點頻率的和;它們的字符字段能夠是空的;
  2. 把這個新的三節點樹插回到優先級隊列中;
  3. 反覆重複第一步和第二步,樹會越變越大,隊列中的數據項會愈來愈少,但隊中只有一棵樹時,它就是創建後的哈夫曼樹了。

8. 紅-黑樹

普通的二叉樹做爲數據存儲工具備着重要的優點:能夠快速地找到給定關鍵字的數據項,而且能夠快速地插入和刪除數據項。其餘的數據存儲結構,例如數組、有序數組和鏈表,執行這些操做卻很慢。所以二叉樹彷佛是很理想的數據存儲結構。

遺憾的是,二叉搜索樹有一個很麻煩的問題,若是樹中插入的是隨機數據,執行效果很好;可是若是插入的數據是有序的,速度會變得很是慢,此時二叉樹就是非平衡的了,對於非平衡樹,它的快速查找指定數據項的能力就喪失了。

但樹沒有分支時,它其實就是一個鏈表。數據的排列是一維的,而不是二維的,這種狀況下,查找的速度降低到O(N),對於隨機數據的實際數量來講,一棵樹特別不平衡的狀況是不大可能的,可是可能會有一小部分有序數據使樹部分非平衡。搜索部分非平衡樹的時間介於O(N)和O(logN)之間,這取決於樹的不平衡程度。

紅-黑樹的平衡是在插入的過程當中取得的,對於一個要插入的數據項,插入例程要檢查會不會破壞樹必定的特徵。若是破壞了就要立刻糾正,根據須要修改樹的結構,經過維持樹的特徵,保證樹的平衡。

8.1 紅-黑樹的特徵

節點都有顏色每個節點或者是黑色或者是紅色。在插入和刪除的過程當中,要遵照保持這些顏色的不一樣排列的規則

  1. 每個節點不是紅色就是黑色;
  2. 根老是黑色的;
  3. 若是節點是紅色,則它的子節點必須是黑色的;
  4. 從根到葉節點或空子節點的每條路徑,必須包含相同數目的黑色節點。

8.2 紅-黑樹的效率

和通常的二叉搜索樹相似,紅黑樹的查找,插入和刪除的時間複雜度爲$O(log2^N)$,額外的開銷僅僅是每一個節點的存儲空間都稍微增長了一點,來存儲紅-黑的顏色。紅-黑樹的優勢是對有序數據的操做不會慢到O(N)的時間複雜度。

9. 2-3-4樹和外部存儲

二叉樹中,每一個節點有一個數據項,最多有兩個子節點。若是容許每一個節點能夠有更多的數據項和更多的子節點,就是多叉樹。2-3-4樹很是有趣,它像紅-黑樹同樣是平衡樹,它的效率比紅-黑樹要差一些,但編程容易實現,學習2-3-4樹能夠更容易地理解B-樹。

B-樹是另外一種多叉樹,專門用在外部存儲(一般是磁盤驅動器)中來組織數據。

9.1 2-3-4樹

2-3-4樹名字中的二、3和4的含義是指一個節點可能含有的子節點的個數。對非葉子節點來講有三種可能的狀況:

  • 有一個數據項的節點老是有兩個子節點;
  • 有兩個數據項的節點老是有三個子節點;
  • 有三個數據項的節點老是有四個子節點;

簡而言之,非葉節點的子節點樹老是比它含有的數據項數多1,這個重要的關係決定了2-3-4樹的結構,比較來講,葉節點沒有子節點,然而它可能含有二、三、4個數據項,空節點是不會存在的。

在2-3-4樹中,不容許只有一個連接,有一個數據項的節點必須老是保持有兩個連接,除非它是葉節點,在那種狀況下沒有連接。

樹結構中很重要的一點就是它的鏈與本身數據項的關鍵字值之間的關係。二叉樹中,全部關鍵字值比某個節點值小的節點都在這個節點左子節點爲根的子樹上,全部關鍵字值比某個節點值大的節點都在這個節點右子節點爲根的子樹上。2-3-4樹中規則是同樣的,還加上了一下幾點:

  • 根是child0的子樹的全部子節點的關鍵字值小於key0;
  • 根是child1的子樹的全部子節點的關鍵字值大於key0小於key1;
  • 根是child2的子樹的全部子節點的關鍵字值大於key1小於key2;
  • 根是child3的子樹的全部子節點的關鍵字值大於key2;

注意樹是平衡的,即便插入一列升序(或降序)排列的數據2-3-4樹都能保持平衡。

9.1.1  2-3-4樹的搜索

查找特定關鍵字值的數據項和在二叉樹中的搜索例程相相似。從根開始,除非查找的關鍵字值是根,不然選擇關鍵字值所在的合適範圍,轉向那個方向直到找到爲止。

9.1.2  插入

新的數據項老是插在葉節點裏,在樹的最底層。若是插入到有子節點的節點裏,子節點的編號就要發生變化以此來保持樹的結構,這保證了節點的子節點比數據項多1。在2-3-4樹中插入節點有時候比較簡單,有時候至關複雜,不管哪一種狀況都是從查找適當的葉子節點開始的。

若是查找時沒有碰到滿節點時,插入很簡單,找到合適的葉子節點後,只要把新數據插入進去即可以了;若是向下尋找要插入位置的路途中,節點已經滿了,插入就變得複雜了。發生這種狀況時,節點必須分裂,正是這種分裂過程保證了樹的平衡(分裂過程比較複雜,暫不贅述,有興趣的同窗能夠研究一下)。

9.1.3  效率

分析2-3-4樹的效率比紅-黑樹要困難,可是仍是能夠從二者的等價性開始分析。查找過程當中紅-黑樹每層都要訪問一個節點,多是查找已經存在的節點,也多是插入一個新的節點,2-3-4樹與紅-黑樹在這個方面是比較相似的,可是2-3-4樹有個優點就是層數比紅-黑樹要短,在全部節點都滿的狀況下,2-3-4樹的高度就大體在log(N+1)到log(N+1)/2之間,減小2-3-4樹的高度能夠使它的查找時間比紅-黑樹的短一些。

9.2 外部存儲

2-3-4樹是多叉樹的例子,多叉樹是指節點多於兩個而且數據項多於一個。另外一種多叉樹,B-樹,在外部存儲器上的數據存儲時有着很大的做用(外部存儲指的是磁盤系統)。

到目前爲止所講過的數據結構都是假設數據存儲在主存中(RAM,隨機訪問存儲器)的,可是,大多數狀況下要處理的數據項太大,不能都存儲在主存中,這種狀況須要另一種存儲方式:磁盤文件存儲器。磁盤存儲還有另一個好處,持久性,但計算機斷電時,主存中的數據會丟失,磁盤文件存儲器斷點後還能夠保存數據;缺點就是要比主存慢得多。在磁盤存儲器中保存着大量的數據,怎樣組織他們來實現快速查找、插入和刪除呢?

計算機的主存按電子的方式工做,幾微秒就能夠訪問一個字節。在磁盤存儲器上存取要複雜得多,在旋轉的磁盤上數據按照圓形的磁道排列,要訪問磁盤驅動器上的某段數據,讀寫頭要移動到正確的磁道,經過步進的電動機或相似的設備完成,這樣的機械運動須要幾毫秒。一旦找到正確的磁道,讀寫頭必需要等待數據旋轉到正確的位置,平均來講這還須要旋轉半圈,讀寫頭就位後,就能夠進行實際的讀/寫操做了,這可能還須要幾毫秒。所以,一般磁盤存取的速度大約是10毫秒,這比訪問主存大概慢了10000倍(每一年都會發展新技術來減小磁盤存取的時間,可是主存訪問時間提高得遠遠超過了磁盤存取)。

磁盤驅動器每次最少讀或者寫一個數據塊的數據,塊的大小根據操做系統,磁盤驅動器的容量和其餘因素組成,老是2的倍數。在讀寫操做時若是按照塊的倍數來操做時效率最高的,經過組織軟件使它每次操做一塊數據,能夠優化性能。

9.2.1  順序有序排列

假設磁盤數據是順序有序排列的,若是要查找某個記錄,能夠用二分查找方法,須要從讀取一塊記錄中間位置的記錄開始,如今處理的數據存儲在磁盤上,由於每次磁盤存取都很耗時,因此更重要的是要注意訪問多少次磁盤,而不是有多少獨立的記錄。

磁盤存取要比內存讀取慢不少,但另外一方面,一次訪問一塊,塊數比記錄數要少得多,假如31250塊記錄,取2的對數等於15次,理論上要讀取磁盤數據15次。實際上這個數據仍是要小一點,由於每塊記錄可能存儲多條數據(假設是16條),一次能夠讀取16條記錄,二分查找的開始階段,內存中有多少條記錄不會有很大的幫助,由於下一次存取會在較遠的位置,但離下一條記錄很近的時候,內存記錄就很是有用了,由於它可能會直接存在這16條記錄當中。 不幸的是,要在順序有序排列的文件插入(或刪除)一個數據項時的狀況要遭得多,由於數據是有序的,這兩個操做要平均移動一半的記錄,所以要移動大概一半的塊,這顯然太不理想了。

順序有序排列的另一個問題是,若是它只有一個關鍵字,速度還比較快;好比文件是按照姓排序的,假設須要用地址簿中的電話號碼方式查找,就不能用二分查找,由於數據時按照姓排序的,這就得查找整個文件,用順序訪問的方式一塊一塊地找,很是糟糕,因此須要尋找一種更有效的方式來保存磁盤中的數據。

9.2.2  B-樹

怎樣保存文件中的記錄纔可以快速地查找、插入和刪除記錄呢?樹是組織內存數據的一個好方法,能夠應用到外部存儲的文件中來,但對於外部存儲來講,須要用和內存數據不同的樹,這種樹是多叉樹,有點兒像2-3-4樹,但每一個節點有多個數據項,稱它爲B-樹。

爲何每一個節點有那麼多的數據項呢?一次讀或寫一塊的數據時的效率最高,在樹中,包含數據的實體是節點。把一整塊數據做爲樹的節點是比較合適的,這樣讀取一個節點能夠在最短的時間裏訪問最大數據量的數據。

樹中還須要保存節點間的連接(連接到其餘塊兒的,節點對應塊),內存中的樹裏這些連接是引用,指向內存中其餘部分的節點。在磁盤文件中的存儲的樹,連接是文件中的塊兒的編號,可用int型的字段保存塊號碼,int能夠保存20億以上的塊號碼,基本上對大多數的文件都夠用了。

在每一個節點中數據是按照關鍵字順序有序排列的,像2-3-4樹同樣。實際上,B-樹的結構很像2-3-4樹,只是每一個節點有更多的數據項和更多的指向子節點的連接。B-樹的階數由節點擁有最多的節點數決定。

在記錄中按照關鍵字查找和在內存的2-3-4樹很是相似。首先,含有根的塊兒讀入到內存中,而後搜索算法開始在這15個節點中查找(或者塊兒不滿的話,有多少塊就檢查多少塊兒),從0開始,但記錄的關鍵字比較大時,須要找在這條記錄和前一條記錄之間的那個子節點。 儘量讓B-樹節點盡是很是重要的,這樣每次存取磁盤時,讀取整個節點,就能夠得到最大數量的數據。

由於每一個節點有那麼多的記錄,每層有那麼多的節點,所以在B-樹上的操做很是快,這裏假設全部的數據都保存在磁盤上。在電話本的例子裏有500000條記錄,B-樹中全部的節點至少是半滿的,全部每一個節點至少有8個記錄和9個子節點的連接。樹的高度所以比N以9爲底的對數,N是500000,結果爲5972,這樣樹的高度大概爲6層,使用B-樹只須要6次訪問磁盤就能夠在有500000條記錄的文件中找到任何記錄了,每次訪問10毫秒,須要花費60毫秒的時間,這比順序有序排列的文件中二分查找要快得多。

雖然B-樹中的查找比在順序有序排列的磁盤文件查找塊,可是插入和刪除操做才顯示出B-樹的最大優越性。

另外一種加快文件訪問速度的方法是用順序有序排列存儲記錄但用文件索引鏈接數據。文件索引是由關鍵字-塊對組成的列表,按關鍵字排序。索引中的記錄根據某個條件順序排列,磁盤上原來那些記錄中能夠按任何順序有序排列,這就是說新記錄能夠簡單地添加到文件末尾,這樣記錄能夠按照時間排列。

索引比文件中實際記錄小得多,它甚至能夠徹底放在內存裏。在本章中介紹的實例中,有500000條記錄,每條的索引中的記錄是32字節,這樣索引大小是32×500000字節,即1600000字節(1.6M),放在內存中沒有任何問題,索引能夠保存在磁盤中,數據庫程序啓動後讀取到內存中,這樣對索引的操做就能夠直接在內存中完成了,天天結束時索引能夠寫回磁盤永久保存。應用將索引放在內存中的方法,使得操做電話本的文件比直接在順序有序排列記錄的文件中執行操做更快。

在索引文件中插入新數據項,要作兩步,首先把這個數據項整個記錄插入到主文件中去,而後把關鍵字和包括新數據項存儲的塊號碼的記錄插入到索引中。

若是索引是順序有序排列的,要插入新數據項,平均須要移動一半的索引記錄。固然能夠使用更復雜的方法在內存中保存索引,例如保存成二叉樹,2-3-4樹,或紅-黑樹,這些方法都大大地減小了插入和刪除的時間。這種狀況下把索引存在內存中的方法都比文件順序有序排列的方法快得多,有時比B-樹都要快。

索引方法的一個優勢是多級索引,同一個文件能夠建立不一樣關鍵字的索引。在一個索引中關鍵字能夠是姓,另外一個索引中的關鍵字能夠是地址,索引和文件比起來很小,因此它不會大量地增長數據存儲量。

若是索引太大不能放在內存中,就須要按照塊分開存儲在磁盤上,對大文件來講把索引保存成B-樹是很合適的,主文件中記錄能夠存成任何合適的順序。

對於外部文件來講,歸併排序是外部數據排序的首選方法,這是由於這種方法比起其餘大部分排序方法來講,磁盤訪問更多地涉及臨近的記錄而不是文件的隨機部分。

第一步,讀取一塊,它的記錄在內部排序,而後把排完序的塊寫回到磁盤中,下一起也一樣排序並寫回到磁盤中,直到全部的塊內部都有序爲止;

第二步,讀取兩個有序的塊,合併成一個兩塊的有序的序列,再把它們寫回到磁盤中,下次把每兩塊序列合成四塊兒的序列。這個過程繼續下去,直到全部成對的塊都合併了爲止。每次,有序的長度增加一倍,直到整個文件有序。

10. 哈希表

哈希表是一種數據結構,能夠提供快速的插入和查找操做。第一次接觸哈希表時,它的優勢多得讓人難以置信,不論哈希表有多少數據,插入和刪除只須要常量級的時間,即O(1)的時間。哈希表運算得很是快,在計算機程序中,若是須要在一秒鐘查找上千條記錄,一般使用哈希表。哈希表的速度明顯比較比樹快,樹的操做一般須要O(N)的時間級。哈希表不只速度快,編程實現也容易。

哈希表也有一些缺點,它是基於數組的,數組建立後難於擴展。某些哈希表被基本填滿時,性能降低地很是嚴重;並且,也沒有一種簡便的方法能夠以任何一種順序(好比從小到大)遍歷表中的數據項,若是須要這種能力,只能選擇其餘數據結構。

然而,若是不須要有序遍歷數據,並且能夠提早預測數據量的大小,那麼哈希表在速度和易用性方面是無與倫比的。

假設使用數組做爲數據存儲結構,若是知道數組下標,要訪問特定的數組數據數據項很是方便也很是快。增長一個新項很快,只須要把它插在最後一個數據項的後面,使用基於數組的數據庫,使得存儲數據塊且很是簡單,很吸引人,可是關鍵字必須組織得很是好,可以直接查找到數組下標以便查找到該數據項。

10.1 哈希化

經典使用的例子是字典,若是想要把一本英文字典的每一個單詞,從a到zyzzyva,都寫入到計算機內存,以便快速讀寫,那麼哈希表是一個不錯的選擇。

如何把單詞轉換成數組下標?把單詞每一個字符的代碼求和(a=0,b=1,z=26),若是是cats,轉換的下標爲43,若是用這樣的方法,a轉換成0,字典中最後一個單詞是zzzzzzzzzz(10個z),全部字符編碼的和是26×10=260,所以,單詞編碼的範圍是0到260,不幸的是,詞典中有50000個單詞,沒有足夠的下標來索引那麼多的單詞,每一個數組數據項大概要存儲192個單詞;若是用冪的連乘,27個字符,最終結果是

27^n+27^{n-1}+27^{n-2}+...+27

,最終結果可能會7000000000,這個過程確實能夠創造出獨一無二的整數,可是結果很是巨大。內存中的數組根本不會有這麼多的單元。第一種方法下標過小,第二種方法下標又太大。

如今須要一種壓縮方法,把數位冪的連乘系統中獲得的巨大的整數範圍壓縮到可接受的數組範圍內。

可是若是把全部的字典數據都放在數組中,若是隻有50000個單詞,可能會假設這個數組大概就有這麼多空間。但實際上,須要多一倍的空間容納這些單詞。因此最終須要容量爲100000的數組,把0到7000000000的範圍,壓縮到0到100000,有一種簡單的方法是取餘,smallNumber = largeNumber % smallRange,用相似的方法把表示單詞的惟一的數字壓縮成數組下標,這是一種哈希函數。

可是,把巨大的數字空間壓縮成較小的數字空間,必然要付出代價,即不能保證每一個單詞都映射到數組的空白單元。假設在數組中要插入一個新的數據項,經過哈希函數獲得其下標後,發現那個單元已經有一個數據項,由於這兩個數據項哈希化獲得的下標徹底相同,這種狀況就是衝突。

衝突的可能性會致使哈希化方法沒法實施,實際上,能夠經過其餘方式解決這個問題。但衝突發生時,一個方法是經過系統的另外一個方法找到數組的一個空位,並把這個數據項填入,而再也不使用哈希函數獲得的數組下標,這個方法叫作開發地址法;第二種方法是建立一個存放一個單詞鏈表的數組,數組內不直接存儲單詞。這樣,但發生衝突時,新的數據項直接接到這個數組下標所指的鏈表中,這種方法叫作鏈地址法。

10.2 開放地址法

在開放地址法中,若數據不能直接放在由哈希函數計算出來的數組下標所指的單元時,就須要尋找數組的其餘位置。

10.2.1 線性探測

線性探測中,線性地查找空白單元,若是5421是要插入數據的位置,它已經被佔用了,那麼就使用5422,而後5433,以此類推,數組下標一直遞增,直到找到空位,這就叫線性探測,由於它沿着數組的下標一步一步地順序查找空白單元。

當哈希表變得太滿時,一個選擇是擴展數組。在Java中,數組有固定的大小,並且不能擴展。編程時只能另外建立一個新的更大的數組,而後把舊數據的全部內容插入到新的數組中去。

哈希函數根據數組大小計算給點數據項的位置,因此這些數據項不能再放到新數組和老數組相同的位置上,所以不能簡單地從一個數組向另外一個數組拷貝數據。須要按照順序遍歷數組,而後執行insert向新數組中插入數據項,這叫作從新哈希化,這是一個很是耗時的過程,可是若是數組要進行擴展,這個過程是必要的。

擴展後的數組是原來的兩倍,事實上,由於數組的容量最好應該是一個質數,新數組的長度應該是兩倍多一點,計算新數組的容量是從新哈希化的一部分。

10.2.2 二次探測

在開放地址法中的線性探測會發生彙集(連續使用的數組單元),一旦彙集造成,就會變得愈來愈大。那些哈希化的落在彙集範圍內的數據項,都要一步一步地移動,而且插在彙集的最後,所以使得彙集變得更大,彙集越大,增加地越快。

已填入哈希表的數據項和表長的比率叫作裝填因子,當裝填因子不太大時,彙集分佈地比較連貫,哈希表的某個部分可能包含大量的彙集,而另外一個部分還很稀疏,彙集下降了哈希表的性能。

二次探測是防止彙集產生的一種嘗試,思想是探測相隔較遠的單元,而不是和原始位置相鄰的單元。在線性探測中,若是哈希函數計算的原始下標是x,線性探測是x+1,x+2,x+3,以此類推。而在二次探測中,探測的過程是x+1,x+4,x+9,x+16,以此類推。

但二次探測的搜索變長時,好像它變得愈來愈絕望。第一次,查找相鄰的單元。若是這個單元被佔用,它認爲這裏可能有一個小的彙集,因此,它嘗試距離爲4的單元,若是這裏也被佔用,認爲這裏有個更大的彙集,而後嘗試距離爲9的單元,若是這也被佔用,它感到一絲恐慌,跳到距離爲16的單元,但哈希表幾乎填滿時,它會歇斯底里地跨越整個數組空間。

二次探測消除了在線性探測中產生的彙集問題,這種問題叫作原始彙集。而後,二次探測產生了另一種,更細的彙集問題。之因此會發生,是由於全部映射到同一個位置的關鍵字在尋找空位時,探測的單元都是同樣的。

10.2.3 再哈希法

爲了消除原始彙集和二次彙集,能夠使用另一種方法:再哈希法。二次彙集產生的緣由是,二次探測的算法產生的探測序列步長老是固定的:1,4,9,16,依此類推。 如今須要的一種方法是產生一種依賴關鍵字的探測序列,而不是每一個關鍵字都同樣,那麼不一樣的關鍵字即便映射到相同的數組下標,也能夠使用不一樣的探測序列。方法是把關鍵字用不一樣的哈希函數再作一次哈希化,用這個結果做爲步長。經驗說明,第二個哈希函數必須具有如下特色:

  • 和第一個哈希函數不一樣;
  • 不能輸出0(不然將沒有步長:每次探測都是原地踏步,算法陷入死循環)。

使用開放地址策略時,探測序列一般用再哈希法生成。

10.3 鏈地址法

開放地址法中,經過在哈希表中再尋找一個空位解決衝突問題。另外一個方法是在哈希表每一個單元設置鏈表。某個數據項的關鍵字值仍是像一般同樣映射到哈希表的單元,而數據項自己插入到這個單元的鏈表中。其餘一樣映射到這個位置的數據項只須要加到鏈表中,不須要在元素的數組中尋找空位。

鏈地址法在概念上比開放地址法中的幾種探測策略都要有效且簡單,然而代碼會比其它的長,由於必須包含鏈表機制。

鏈地址法中的裝填因子與開放地址法的不一樣,在鏈地址法中,須要在有N個單元的數組中裝入N個或更多的數據項;所以裝填因子通常爲1,或比1大,這沒有問題,由於某些位置包含的鏈表中包含兩個或兩個以上的數據項。 固然,若是鏈表中有許多項,存取的時間就會變長,找到初始的單元須要O(1)的時間級,而搜索鏈表的時間與M成正比,M爲鏈表包含的平均項數,即O(M)的時間級,所以不但願鏈表太滿。

10.4 哈希函數

好的哈希函數很簡單,因此可以快速計算,哈希表的主要優勢是它的速度。若是哈希函數運行緩慢,速度就會下降。哈希函數中有不少乘法和除法是不可取的(Java或C++語言中有位操做,例如除以2的倍數,使得每位都向右移動,這種操做頗有用)。

所謂完美的哈希函數把每一個關鍵字都映射到表中不一樣的位置,只有在關鍵字組織得異乎尋常的好,且它的範圍足夠小,能夠直接用於數組下標的時候,這種狀況纔可能出現。哈希函數須要把較大的關鍵字值範圍壓縮成較小的數組下標的範圍。

壓縮關鍵字字段,要把每一個位都計算在內。並且,校驗和應該捨棄,由於它沒有提供任何有用的信息,在壓縮中是多餘的,各類調整位的技術均可以用來壓縮關鍵字的不一樣字段。關鍵字的每一個字段都應該在哈希函數中有所反映,關鍵字提供的數據越多,哈希化後越可能覆蓋整個下標範圍。

關於哈希函數的竅門是找到既簡單又快的哈希函數,並且去掉關鍵字中的無用數據,並儘可能使用全部的數據。一般,哈希函數包含對數組容量的取模操做,若是關鍵字不是隨機分佈的,不論使用什麼哈希化系統都應該要求數組容量爲質數。

10.5 哈希化的效率

在哈希表中執行插入和查詢操做能夠達到O(1)的時間級,若是沒有發生衝突,只須要使用一次哈希函數和數組的引用,就能夠插入一個新數據項或找到一個已存在的數據項。這是最小的存取時間級。

若是發生衝突,存取時間就依賴後來的探測深度,所以一次單獨的查找或插入時間與探測的長度成正比,這裏還要加到哈希函數的執行時間。

平均探測長度(以及平均存取時間)取決於裝填因子(表中項數和表長的比率)。隨着裝填因子變大,探測長度也愈來愈長。

11. 堆

前面介紹了優先級隊列,它是對最小(最大)關鍵字的數據項提供便利訪問的數據結構。優先級隊列能夠用於計算機的任務調度,在計算機中某些程序和活動須要比其餘的程序和活動先執行,所以要給它們分配更高的優先級。

優先級隊列是一種抽象數據類型,它提供了刪除最大(最小)關鍵字值的數據項的方法,插入數據項的方法,和其餘操做,優先級隊列能夠用不一樣的內部結構實現。優先級隊列能夠用有序數組來實現,這種作法的問題是,儘管刪除最大數據項的時間複雜度爲O(1),可是插入仍是須要較長的O(N)的方法,這是由於必須移動數組中平均一半的數據項以插入新的數據項,並在插入後數組依然有序。 本節中介紹優先級隊列中的另外一種結構:堆。堆是一種樹,由它實現的優先級隊列的插入和刪除時間複雜度都是O(logN),儘管這樣刪除的時間變慢了一些,可是插入的時間快多了。但速度很是重要,且不少插入操做時,能夠選擇堆來實現優先級隊列。

堆是有以下特色的二叉樹:

  • 它是徹底二叉樹,這也就是說,除了樹的最後一層節點不須要是滿的,其餘每一層從左到右都徹底是滿的;
  • 它經常是用一個數組來實現的;
  • 堆中的每個節點都知足堆的條件,也就是說每個節點的關鍵字都大於(或等於)這個節點的子節點的關鍵字。

堆是徹底二叉樹的事實說明了堆的數組中沒有「洞」,從下標0到N-1,每一個數據單元都有數據項。本節中假設最大的關鍵字在根節點上,基於這種堆的優先級隊列是降序的優先級隊列。

堆和二叉搜索樹相比是弱序的,在二叉搜索樹中全部節點的左子孫的關鍵字都小於右子孫的關鍵字。這說明一個二叉搜索樹中經過一個簡單的算法就能夠按序遍歷節點。在堆中,按序遍歷節點是困難的,由於堆中的組織規則比二叉搜索樹的組織規則弱。對於堆來講,只要求沿着從根到葉子的每條路徑,節點都是降序排列的。

因爲堆是弱序的,因此一些操做是困難的或者是不可能的。除了不支持遍歷之外,也不能在堆上便利地查找指定關鍵字,由於在查找的過程當中,沒有足夠的信息來決定選擇經過節點的兩個節點中哪個走向下一層,它也不能在少至$O(log2^N)$的時間內刪除一個指定關鍵字的節點,由於沒有辦法可以找到這個節點。

所以,堆的這種組織彷佛很是接近無序,不過對於快速移除最大節點的操做以及快速插入節點的操做,這種順序已經足夠了,這些操做是使用堆做爲優先級隊列時所需的全部操做。

11.1 移除節點

移除是指刪除關鍵字最大的節點,這個節點是根節點,因此移除是很是容易的,根在堆數組中的索引老是0。

可是問題是,一旦移除了根節點,樹就再也不是徹底的;數組裏就有了一個空的數據單元。這個「洞」必需要填上,能夠把數組中全部數據項都向前移動一個單元,可是還有快得多的方法,下面就是移除最大節點的步驟:

  1. 移走根;
  2. 把最後一個節點移動到根的位置;
  3. 一直向下篩選這個節點,直到它在一個大於它的節點之下,小於它的節點之上爲止。

向上或者向下篩選一個節點是指沿着一條路徑一步步移動此節點,和它前面的節點交換位置,每一步都檢查它是否處在了合適的位置。

11.2 插入節點

插入節點是很是容易的,插入使用向上篩選,而不是向下篩選。節點初始時插入到數組中最後一個空着的單元中,數組容量大小增一。但這樣會破壞堆的條件,若是新插入的節點大於它新獲得的父節點時,就會發生這種狀況。由於父節點在堆的底端,它可能很小,因此新節點就顯得比較大。所以,須要向上篩選新節點,直到它在一個大於它的節點之下,在一個小於它的節點之上。

向上篩選的算法比向下篩選的算法相比來講簡單,由於它不用比較兩個子節點的關鍵字大小。節點只有一個父節點,目標節點只要和它的父節點換位便可。若是先移除一個節點再插入相同的一個節點,結果並不必定是恢復成原來的堆。一組給定的節點能夠組成不少合法的堆,這取決於節點插入的順序。

11.3 堆操做的效率

對有足夠多數據項的堆來講,向上篩選和向下篩選算法算是堆操做中最耗時的部分。這兩個算法都是沿着一條路徑重複地向上或向下移動節點,所需的複製操做和堆的高度有關係。

11.4 基於樹的堆

也能夠基於真正的樹來實現堆,這棵樹能夠是二叉樹,但不會是二叉搜索樹,它的有序規則不是那麼強。它也是一棵徹底樹,沒有空缺的節點,稱這樣的樹爲樹堆。

關於樹堆的一個問題是找到最後的一個節點,移除最大數據項的時候是須要找到這個節點,由於這個節點將要插入到已移除的根的位置(而後再向下篩選)。同時也須要找到的一個空節點,由於它是插入新節點的位置。因爲不知道它們的值,何況它不是一棵搜索樹,不能直接查找到這兩個節點,這就須要使用節點的標號的算法了,關鍵是取模(%)操做。 樹堆操做的時間複雜度爲$O(log2^N)$,由於基於數組的堆操做的大部分時間都消耗在向上篩選和向下篩選的操做上了,操做的時間和樹的高度成正比。

11.5 堆排序

堆數據結構的效率使它引出一種出奇簡單,但卻頗有效率的排序算法,稱爲堆排序。

堆排序的基本思想是使用普通的insert()例程在堆中插入所有無序的數據項,而後重複用remove()例程,就能夠按順序移除全部數據項。由於插入和刪除方法操做的時間複雜度都是$O(log2N)$,而且每一個方法都必需要執行N次,因此整個排序操做須要$O(Nlog2N)$時間,這和快速排序同樣,可是它不如快速排序快,部分緣由是向下篩選中的循環的操做比快速排序裏循環的操做要多。

儘管它要比快速排序略慢,但它比快速排序優越的一點是它對初始數據的分佈不敏感。在關鍵字值按某種排列順序的狀況下,快速排序運行的時間複雜度能夠下降到$O(N2)$級,然而堆排序對任意排列的數據,其排序的時間複雜度都是$O(Nlog2N)$。

12. 數據結構的應用場合

12.1 通用的數據結構

12.1.1 數組,鏈表,樹,哈希表

對於一個給定的問題,這些通用的數據結構中哪個是合適的呢?下圖給出一個大體的解法:

12.1.2 數組

當存儲和操做數據時,在大多數狀況下數組是首先應該考慮的結構。數組在下列狀況下頗有用:

  • 數據量較小。
  • 數據量的大小事先可預測。

若是插入速度很重要的話,能夠使用無序數組。若是查找數組很重要的話,使用有序數組並用二分查找。數組元素的刪除老是很慢,這是因爲爲了填充空出來的單元,平均半數以上要被移動。在有序數組中的遍歷是很快的,而無序數組中不支持這種功能。

使用集合(Collection)是一種當數據太滿時能夠本身擴充空間的數組,集合能夠應用於數據量不可預知的狀況下,然而在向量擴充時,要將舊的數據拷貝到一個新的空間中,這一過程會形成程序明顯的週期性暫停。

12.1.3 鏈表

若是須要存儲的數據量不能預知或者須要頻繁地插入刪除數據元素時,考慮使用鏈表。但有新的元素加入時,鏈表就開闢新的所須要的空間,因此它甚至能夠佔滿幾乎全部的內存,在刪除過程當中沒有必要像數組那樣填補「空洞」。 在一個無序的鏈表中,插入是至關快的,查找或刪除卻很慢(儘管比數組的刪除快一些),所以與數組同樣,鏈表最好也應用於數據量相對較小的狀況。對於編程而言,鏈表比數組複雜,但它比樹或哈希表簡單。

12.1.4 二叉搜索樹

當確認數組和鏈表過慢時,二叉樹是最早應該考慮的結構。樹能夠提供快速的$O(log2^N)$級的插入、查找和刪除。遍歷的時間複雜度是O(N)級的,這是任何數據結構遍歷的最大值。對於遍歷必定範圍內的數據能夠很快地訪問出數據的最大值和最小值。

對於程序來講,不平衡的二叉樹要比平衡的二叉樹簡單地多,但不幸的是,有序數據能將它的性能下降到O(N)級,不比一個鏈表好多少。然而若是能夠保證數據是隨機進入的,就不須要使用平衡二叉樹。

12.1.5 平衡樹

在衆多平衡樹中,咱們討論了紅-黑樹和2-3-4樹,它們都是平衡樹而且不管輸入數據是否有序,它們都能保證性能爲$O(log2^N)$。然而對於編程來講,這些平衡樹都是頗有挑戰性的,其中最難的是紅-黑樹。它們也由於用了附加存儲而產生額外消耗,對系統或多或少有些影響。

若是利用樹的商用類能夠下降編程的複雜性,有些狀況下,選擇哈希表比平衡樹要好,即使當數據有序時,哈希表的性能也不下降。

12.1.6 哈希表

哈希表在數據存儲結構中速度最快。哈希表一般用於拼寫檢查器和做爲計算機編譯器中的符號表,在這些應用中,程序必須在很短的時間內檢查上千的詞或符號。

哈希表對數據插入的順序並不敏感,所以能夠取代平衡樹,但哈希表的編程卻比平衡樹簡單多了。哈希表須要額外的存儲空間,尤爲是對於開放地址法。由於哈希表用數組做爲存儲結構,因此必須預先精確地知道待存儲的數據量。

用鏈地址法處理衝突的哈希表是最健壯的解決方法.若能精確地知道數據量,在這種狀況下用開放地址法編程最簡單,由於不須要用到鏈表類。

哈希表並不能提供任何形式的有序遍歷,或對最大最小值元素進行存取。若是這些功能重要的話,使用二叉搜索樹更加合適。

12.2 專用的數據結構

12.2.1 棧

棧用在只對最後被插入數據項訪問的時候,它是一個後進先出(LIFO)的結構。 棧每每經過數組或者鏈表實現,經過數組實現頗有效率,由於最後被插入的數據老是在數組的最後,這個位置的數據很容易被刪除。棧的溢出有可能出現,但當數組的大小被合理地規劃好以後,溢出並不常見,由於棧不多會擁有大量的數據。

12.2.2 隊列

隊列用在只對最早被插入數據項訪問的時候,它是一個先進先出(FIFO)的結構。 同棧相比,隊列一樣能夠用數組和鏈表來實現。這兩種方法都很是效率。數組須要附加的程序來處理隊列在尾部迴繞的狀況。鏈表必須是雙端的,這樣才能從一端插入到另外一端刪除。用數組仍是鏈表來實現隊列的選擇是經過數據量是否能夠被很好地預測來決定的。若是知道有多少數據量的話,就是用數組;不然就使用鏈表。

12.2.3 優先級隊列

優先級隊列用在只對訪問最高優先級數據項的時候有用,優先級隊列能夠用有序數組或堆來實現,向有序數組中插入數據是很慢的,可是刪除很快。使用堆來實現優先級隊列,插入和刪除數據的時間複雜度均爲$O(log2^N)$。

當插入速度不重要時,能夠使用數組或雙端鏈表。當數據量能夠被預測時,使用數組;當數據量未知時,使用鏈表。若是速度很重要時,使用堆更好一些。

12.3 排序

當選擇數據結構時,能夠先嚐試一種較慢但簡單的排序,如插入排序。

插入排序對幾乎已經已排好順序的文件頗有效,若是沒有太多的元素處於亂序的位置上,操做的時間複雜度大約在O(N)級。

若是插入排序顯得很慢,下一步能夠嘗試希爾排序。它很容易實現,而且使用起來不會由於條件不一樣而性能變化巨大。

只有當希爾排序變得很慢時,才應該選擇那些更復雜但更快速的排序方法:歸併排序、堆排序或快速排序。歸併排序須要輔助存儲空間,堆排序須要有一個堆的數據結構,前二者都比快速排序在某些程度上慢,因此當須要最短的排序時間時常用快速排序。

然而,快速排序在處理非隨機順序的數據時性能不太可靠,由於它的速度可能會蛻化成$O(N^2)$級。對於那些有多是非隨機性的數據來講,堆排更加可靠。

12.4 外部存儲

前面的討論都是假設數據被存放在了內存中,然而數據量大到內存中容不下時,只能被存到外部存儲空間,它們被常常稱爲磁盤文件。

12.4.1 順序存儲

經過指定關鍵字進行搜索的最簡單的方法是隨機存儲記錄而後順序讀取。新的記錄能夠簡單地插入在文件的最後,已刪除的記錄能夠標記爲已刪除,或將記錄順次移動來填補空缺。

就平均而言,查找和刪除會涉及讀取半數的塊,因此順序存儲並非很快,時間複雜度爲O(N),可是對於小量數據來講它仍然是使人滿意的。

12.4.2 索引文件

當使用索引文件時,速度會明顯地提高。在這種方法中關鍵字的索引和相應塊的號數被存放在內存中,當經過一個特殊的關鍵字訪問一條記錄時,程序會向索引詢問。索引提供這個關鍵字的塊號數,而後只須要讀取這一個塊,僅耗費O(1)級的時間。

能夠使用不一樣種類的關鍵字來作多種索引,只要索引數量能在內存的存儲範圍以內,這種方法表現得很好;一般索引文件存儲在磁盤中,只有在須要時才複製到內存中。

索引文件的缺點是必須先建立索引,這有可能對磁盤上的文件進行順序讀取,因此建立索引是很慢的。一樣當記錄被加入到文件中時索引還須要更新。

12.4.3 B-樹

B-樹是多叉樹,一般用於外部存儲,樹中的節點對應於磁盤中的塊。同其餘樹同樣,經過算法來遍歷樹,在每一層上讀取一個塊。B-樹能夠在$O(log2^N)$級的時間內進行查找,插入和刪除。這是至關快的,而且對於大文件也很是有效,可是它的編程很繁瑣。

若是能夠佔用一個文件一般大小兩倍以上的外部存儲空間的話,外部哈希會是一個很好的選擇。它同索引文件同樣有着相同的存儲時間O(1),但它能夠對更大的文件進行操做。

12.4.4 虛擬內存

有時候能夠經過操做系統的虛擬內存能力來解決磁盤存取的問題,而不須要經過編程。

若是讀取一個大小超過主存的文件,虛擬內存系統會讀取合適主存大小的部分並將其它存儲在磁盤上。當訪問文件的不一樣部分時,它們會自動從磁盤讀取並防止到內存中。

能夠對整個文件使用內部存儲的算法,使它們好像同時在內存中同樣,固然,這樣的操做比整個文件在內存中的速度要慢得多,可是經過外部存儲算法一塊塊地處理文件的話,速度也是同樣的慢。不要在意文件的大小適合放在內存中,在虛擬內存的幫助下驗證算法工做得好壞是有益的,尤爲是對那些比可用內存大不了多少的文件來講,這是一個簡單的解決方案。

附[直觀學習排序算法]

視覺直觀感覺若干經常使用排序算法

1 快速排序

介紹:快速排序是由東尼·霍爾所發展的一種排序算法。在平均情況下,排序 n 個項目要$O(Nlog2N)$次比較。在最壞情況下則須要$O(N2)$次比較,但這種情況並不常見。事實上,快速排序一般明顯比其餘$O(Nlog2^N)$算法更快,由於它的內部循環(inner loop)能夠在大部分的架構上頗有效率地被實現出來,且在大部分真實世界的數據,能夠決定設計的選擇,減小所需時間的二次方項之可能性。

步驟:

  • 從數列中挑出一個元素,稱爲"基準"(pivot),
  • 從新排序數列,全部元素比基準值小的擺放在基準前面,全部元素比基準值大的擺在基準的後面(相同的數能夠到任一邊)。在這個分區退出以後,該基準就處於數列的中間位置。這個稱爲分區(partition)操做。
  • 遞歸地(recursive)把小於基準值元素的子數列和大於基準值元素的子數列排序。

排序效果:

2.歸併排序

介紹:歸併排序(Merge sort,臺灣譯做:合併排序)是創建在歸併操做上的一種有效的排序算法。該算法是採用分治法(Divide and Conquer)的一個很是典型的應用,步驟:

  • 申請空間,使其大小爲兩個已經排序序列之和,該空間用來存放合併後的序列
  • 設定兩個指針,最初位置分別爲兩個已經排序序列的起始位置
  • 比較兩個指針所指向的元素,選擇相對小的元素放入到合併空間,並移動指針到下一位置
  • 重複步驟3直到某一指針達到序列尾
  • 將另外一序列剩下的全部元素直接複製到合併序列尾

排序效果:

3 堆排序

介紹:堆積排序(Heapsort)是指利用堆這種數據結構所設計的一種排序算法。堆是一個近似徹底二叉樹的結構,並同時知足堆性質:即子結點的鍵值或索引老是小於(或者大於)它的父節點。

步驟:(比較複雜,本身上網查吧)排序效果:

4 選擇排序

介紹:選擇排序(Selection sort)是一種簡單直觀的排序算法。它的工做原理以下:

  • 首先在未排序序列中找到最小元素,存放到排序序列的起始位置,
  • 而後,再從剩餘未排序元素中繼續尋找最小元素,而後放到排序序列末尾。
  • 以此類推,直到全部元素均排序完畢。

排序效果:

5 冒泡排序

介紹:冒泡排序(Bubble Sort,臺灣譯爲:泡沫排序或氣泡排序)是一種簡單的排序算法。它重複地走訪過要排序的數列,一次比較兩個元素,若是他們的順序錯誤就把他們交換過來。走訪數列的工做是重複地進行直到沒有再須要交換,也就是說該數列已經排序完成。這個算法的名字由來是由於越小的元素會經由交換慢慢「浮」到數列的頂端。步驟:

  1. 比較相鄰的元素。若是第一個比第二個大,就交換他們兩個。
  2. 對每一對相鄰元素做一樣的工做,從開始第一對到結尾的最後一對。在這一點,最後的元素應該會是最大的數。
  3. 針對全部的元素重複以上的步驟,除了最後一個。
  4. 持續每次對愈來愈少的元素重複上面的步驟,直到沒有任何一對數字須要比較。

排序效果:

6 插入排序

介紹:插入排序(Insertion Sort)的算法描述是一種簡單直觀的排序算法。它的工做原理是經過構建有序序列,對於未排序數據,在已排序序列中從後向前掃描,找到相應位置並插入。插入排序在實現上,一般採用in-place排序(即只需用到O(1)的額外空間的排序),於是在從後向前掃描過程當中,須要反覆把已排序元素逐步向後挪位,爲最新元素提供插入空間。

步驟:

  • 從第一個元素開始,該元素能夠認爲已經被排序
  • 取出下一個元素,在已經排序的元素序列中從後向前掃描
  • 若是該元素(已排序)大於新元素,將該元素移到下一位置
  • 重複步驟3,直到找到已排序的元素小於或者等於新元素的位置
  • 將新元素插入到該位置中
  • 重複步驟2

7 希爾排序

介紹:希爾排序,也稱遞減增量排序算法,是插入排序的一種高速而穩定的改進版本。  

希爾排序是基於插入排序的如下兩點性質而提出改進方法的:插入排序在對幾乎已經排好序的數據操做時, 效率高, 便可以達到線性排序的效率;但插入排序通常來講是低效的, 由於插入排序每次只能將數據移動一位。

排序效果:

相關文章
相關標籤/搜索