數據結構與算法之美(三)

一,紅黑樹

平衡二叉樹的嚴格定義是這樣的:二叉樹中任意一個節點的左右子樹的高度相差不能大於 1。最先被髮明的平衡二叉查找樹是AVL 樹,它嚴格符合我剛講到的平衡二叉查找樹的定義,即任何節點的左右子樹高度相差不超過 1,是一種高度平衡的二叉查找樹。

但是很多平衡二叉查找樹其實並沒有嚴格符合上面的定義(樹中任意一個節點的左右子樹的高度相差不能大於 1),比如我們下面要講的紅黑樹,它從根節點到各個葉子節點的最長路徑,有可能會比最短路徑大一倍。

(1)如何定義一棵“紅黑樹”?

紅黑樹中的節點,一類被標記爲黑色,一類被標記爲紅色。除此之外,一棵紅黑樹還需要滿足這樣幾個要求:

  • 根節點是黑色的;
  • 每個葉子節點都是黑色的空節點,也就是說,葉子節點不存儲數據;
  • 任何相鄰的節點都不能同時爲紅色,也就是說,紅色節點是被黑色節點隔開的;
  • 每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點;

這裏的第二點要求“葉子節點都是黑色的空節點”,稍微有些奇怪,它主要是爲了簡化紅黑樹的代碼實現而設置的

(2)爲什麼說紅黑樹是“近似平衡”的?

二叉查找樹很多操作的性能都跟樹的高度成正比。一棵極其平衡的二叉樹(滿二叉樹或完全二叉樹)的高度大約是 log2n,所以如果要證明紅黑樹是近似平衡的,我們只需要分析,紅黑樹的高度是否比較穩定地趨近 log2n 就好了。

首先,我們來看,如果我們將紅色節點從紅黑樹中去掉,那單純包含黑色節點的紅黑樹的高度是多少呢?

紅色節點刪除之後,有些節點就沒有父節點了,它們會直接拿這些節點的祖父節點(父節點的父節點)作爲父節點。所以,之前的二叉樹就變成了四叉樹。

前面紅黑樹的定義裏有這麼一條:從任意節點到可達的葉子節點的每個路徑包含相同數目的黑色節點。我們從四叉樹中取出某些節點,放到葉節點位置,四叉樹就變成了完全二叉樹。所以,僅包含黑色節點的四叉樹的高度,比包含相同節點個數的完全二叉樹的高度還要小。上一節我們說,完全二叉樹的高度近似 log2n,這裏的四叉“黑樹”的高度要低於完全二叉樹,所以去掉紅色節點的“黑樹”的高度也不會超過 log2n。

我們現在知道只包含黑色節點的“黑樹”的高度,那我們現在把紅色節點加回去,高度會變成多少呢?

從上面我畫的紅黑樹的例子和定義看,在紅黑樹中,紅色節點不能相鄰,也就是說,有一個紅色節點就要至少有一個黑色節點,將它跟其他紅色節點隔開。紅黑樹中包含最多黑色節點的路徑不會超過 log2n,所以加入紅色節點之後,最長路徑不會超過 2log2n,也就是說,紅黑樹的高度近似 2log2n。所以,紅黑樹的高度只比高度平衡的 AVL 樹的高度(log2n)僅僅大了一倍,在性能上,下降得並不多。這樣推導出來的結果不夠精確,實際上紅黑樹的性能更好。

(3)我們剛剛提到了很多平衡二叉查找樹,現在我們就來看下,爲什麼在工程中大家都喜歡用紅黑樹這種平衡二叉查找樹?

AVL 樹是一種高度平衡的二叉樹,所以查找的效率非常高,但是,有利就有弊,AVL 樹爲了維持這種高度的平衡,就要付出更多的代價。每次插入、刪除都要做調整,就比較複雜、耗時。所以,對於有頻繁的插入、刪除操作的數據集合,使用 AVL 樹的代價就有點高了。

紅黑樹只是做到了近似平衡,並不是嚴格的平衡,所以在維護平衡的成本上,要比 AVL 樹要低。所以,紅黑樹的插入、刪除、查找各種操作性能都比較穩定。對於工程應用來說,要面對各種異常情況,爲了支撐這種工業級的應用,我們更傾向於這種性能穩定的平衡二叉查找樹。

我們學習數據結構和算法,要學習它的由來、特性、適用的場景以及它能解決的問題。對於紅黑樹,也不例外。你如果能搞懂這幾個問題,其實就已經足夠了。

  • 紅黑樹是一種平衡二叉查找樹。它是爲了解決普通二叉查找樹在數據更新的過程中,複雜度退化的問題而產生的。
  • 紅黑樹的高度近似 log2n,所以它是近似平衡,插入、刪除、查找操作的時間複雜度都是 O(logn)。
  • 因爲紅黑樹是一種性能非常穩定的二叉查找樹,所以,在工程中,但凡是用到動態插入、刪除、查找數據的場景,都可以用到它。不過,它實現起來比較複雜,如果自己寫代碼實現,難度會有些高,這個時候,我們其實更傾向用跳錶來替代它。

(4)動態數據結構支持動態地數據插入、刪除、查找操作,除了紅黑樹,我們前面還學習過哪些呢?能對比一下各自的優勢、劣勢,以及應用場景嗎?

  • 散列表:插入刪除查找都是O(1), 是最常用的,但其缺點是不能順序遍歷以及擴容縮容的性能損耗。適用於那些不需要順序遍歷,數據更新不那麼頻繁的。
  • 跳錶:插入刪除查找都是O(logn), 並且能順序遍歷。缺點是空間複雜度O(n)。適用於不那麼在意內存空間的,其順序遍歷和區間查找非常方便。
  • 紅黑樹:插入刪除查找都是O(logn), 中序遍歷即是順序遍歷,穩定。缺點是難以實現,去查找不方便。其實跳錶更佳,但紅黑樹已經用於很多地方了。

(5)左旋和右旋

左旋全稱其實是叫圍繞某個節點的左旋,那右旋的全稱估計你已經猜到了,就叫圍繞某個節點的右旋。

二,遞歸樹

1.分析歸併排序複雜度

我們只需要知道這棵樹的高度 h,用高度 h 乘以每一層的時間消耗 n,就可以得到總的時間複雜度 O(n∗h)。從歸併排序的原理和遞歸樹,可以看出來,歸併排序遞歸樹是一棵滿二叉樹。我們前兩節中講到,滿二叉樹的高度大約是 log2​n,所以,歸併排序遞歸實現的時間複雜度就是 O(nlogn)。

2.分析快速排序的時間複雜度

我們假設平均情況下,每次分區之後,兩個分區的大小比例爲 1:k。當 k=9 時,如果用遞推公式的方法來求解時間複雜度的話,遞推公式就寫成 T(n)=T(1/10n​)+T(9/10n​)+n。

所以,遍歷數據的個數總和就介於 nlog10​n 和 nlog910​​n 之間。根據複雜度的大 O 表示法,對數複雜度的底數不管是多少,我們統一寫成 logn,所以,當分區大小比例是 1:9 時,快速排序的時間複雜度仍然是 O(nlogn)。

3.分析斐波那契數列的時間複雜度

f(n) 分解爲 f(n−1) 和 f(n−2),每次數據規模都是 −1 或者 −2,葉子節點的數據規模是 1 或者 2。所以,從根節點走到葉子節點,每條路徑是長短不一的。如果每次都是 −1,那最長路徑大約就是 n;如果每次都是 −2,那最短路徑大約就是 n/2​。

每次分解之後的合併操作只需要一次加法運算,我們把這次加法運算的時間消耗記作 1。所以,從上往下,第一層的總時間消耗是 1,第二層的總時間消耗是 2,第三層的總時間消耗就是 4。依次類推,第 k 層的時間消耗就是 2k−1,那整個算法的總的時間消耗就是每一層時間消耗之和。如果路徑長度都爲 n,那這個總和就是 2n−1。

如果路徑長度都爲 n,那這個總和就是 2n−1。

如果路徑長度都是 n/2​ ,那整個算法的總的時間消耗就是 2(n​/2)−1。

4.分析全排列的時間複雜度


假設數組中存儲的是1,2, 3...n。
        
f(1,2,...n) = {最後一位是1, f(n-1)} + {最後一位是2, f(n-1)} +...+{最後一位是n, f(n-1)}。

 


// 調用方式:
// int[]a = a={1, 2, 3, 4}; printPermutations(a, 4, 4);
// k表示要處理的子數組的數據個數
public void printPermutations(int[] data, int n, int k) {
  if (k == 1) {
    for (int i = 0; i < n; ++i) {
      System.out.print(data[i] + " ");
    }
    System.out.println();
  }

  for (int i = 0; i < k; ++i) {
    int tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;

    printPermutations(data, n, k - 1);

    tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;
  }
}

第 k 層總的交換次數就是 n∗(n−1)∗(n−2)∗…∗(n−k+1)。最後一層的交換次數就是 n∗(n−1)∗(n−2)∗…∗2∗1。每一層的交換次數之和就是總的交換次數。

n + n*(n-1) + n*(n-1)*(n-2) +... + n*(n-1)*(n-2)*...*2*1

這個公式的求和比較複雜,我們看最後一個數,n∗(n−1)∗(n−2)∗…∗2∗1 等於 n!,而前面的 n−1 個數都小於最後一個數,所以,總和肯定小於 n∗n!,也就是說,全排列的遞歸算法的時間複雜度大於 O(n!),小於 O(n∗n!),雖然我們沒法知道非常精確的時間複雜度,但是這樣一個範圍已經讓我們知道,全排列的時間複雜度是非常高的。

三,堆和堆排序

1,如何理解“堆”?

  • 堆必須是一個完全二叉樹
  • 堆中的每個節點的值必須大於等於(或者小於等於)其子樹中每個節點的值

2,往堆中插入一個元素

堆化非常簡單,就是順着節點所在的路徑,向上或者向下,對比,然後交換。下面是從下往上的堆化方法:


public class Heap {
  private int[] a; // 數組,從下標1開始存儲數據
  private int n;  // 堆可以存儲的最大數據個數
  private int count; // 堆中已經存儲的數據個數

  public Heap(int capacity) {
    a = new int[capacity + 1];
    n = capacity;
    count = 0;
  }

  public void insert(int data) {
    if (count >= n) return; // 堆滿了
    ++count;
    a[count] = data;
    int i = count;
    while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
      swap(a, i, i/2); // swap()函數作用:交換下標爲i和i/2的兩個元素
      i = i/2;
    }
  }
 }

3,刪除堆頂元素

我們把最後一個節點放到堆頂,然後利用同樣的父子節點對比方法。對於不滿足父子節點大小關係的,互換兩個節點,並且重複進行這個過程,直到父子節點之間滿足大小關係爲止。這就是從上往下的堆化方法。


public void removeMax() {
  if (count == 0) return -1; // 堆中沒有數據
  a[1] = a[count];
  --count;
  heapify(a, count, 1);
}

private void heapify(int[] a, int n, int i) { // 自上往下堆化
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

一個包含 n 個節點的完全二叉樹,樹的高度不會超過 log2​n。堆化的過程是順着節點所在路徑比較交換的,所以堆化的時間複雜度跟樹的高度成正比,也就是 O(logn)。插入數據和刪除堆頂元素的主要邏輯就是堆化,所以,往堆中插入一個元素和刪除堆頂元素的時間複雜度都是 O(logn)。

4,堆排序實現

(1)建堆

從後往前處理數組,並且每個數據都是從上往下堆化:

private static void buildHeap(int[] a, int n) {
  for (int i = n/2; i >= 1; --i) {
    heapify(a, n, i);
  }
}

private static void heapify(int[] a, int n, int i) {
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

我們對下標從n/2開始到 1 的數據進行堆化,下標是 n/2​+1 到 n 的節點是葉子節點,我們不需要堆化。

因爲葉子節點不需要堆化,所以需要堆化的節點從倒數第二層開始。每個節點堆化的過程中,需要比較和交換的節點個數,跟這個節點的高度 k 成正比。我把每一層的節點個數和對應的高度畫了出來,你可以看看。我們只需要將每個節點的高度求和,得出的就是建堆的時間複雜度。

(2)排序

建堆結束之後,數組中的數據已經是按照大頂堆的特性來組織的。數組中的第一個元素就是堆頂,也就是最大的元素。我們把它跟最後一個元素交換,那最大元素就放到了下標爲 n 的位置。這個過程有點類似上面講的“刪除堆頂元素”的操作,當堆頂元素移除之後,我們把下標爲 n 的元素放到堆頂,然後再通過堆化的方法,將剩下的 n−1 個元素重新構建成堆。

// n表示數據的個數,數組a中的數據從下標1到n的位置。
public static void sort(int[] a, int n) {
  buildHeap(a, n);
  int k = n;
  while (k > 1) {
    swap(a, 1, k);
    --k;
    heapify(a, k, 1);
  }
}

整個堆排序的過程,都只需要極個別臨時存儲空間,所以堆排序是原地排序算法。堆排序包括建堆和排序兩個操作,建堆過程的時間複雜度是 O(n),排序過程的時間複雜度是 O(nlogn),所以,堆排序整體的時間複雜度是 O(nlogn)。堆排序不是穩定的排序算法,因爲在排序的過程,存在將堆的最後一個節點跟堆頂節點互換的操作,所以就有可能改變值相同數據的原始相對順序。在前面的講解以及代碼中,我都假設,堆中的數據是從數組下標爲 1 的位置開始存儲。那如果從 0 開始存儲,實際上處理思路是沒有任何變化的,唯一變化的,可能就是,代碼實現的時候,計算子節點和父節點的下標的公式改變了。如果節點的下標是 i,那左子節點的下標就是 2∗i+1,右子節點的下標就是 2∗i+2,父節點的下標就是 (i-1)/2​。

5,爲什麼快速排序要比堆排序性能好

  • 第一點,堆排序數據訪問的方式沒有快速排序友好

對於快速排序來說,數據是順序訪問的。而對於堆排序來說,數據是跳着訪問的。 比如,堆排序中,最重要的一個操作就是數據的堆化。比如下面這個例子,對堆頂節點進行堆化,會依次訪問數組下標是 1,2,4,8 的元素,而不是像快速排序那樣,局部順序訪問,所以,這樣對 CPU 緩存是不友好的。

  • 第二點,對於同樣的數據,在排序過程中,堆排序算法的數據交換次數要多於快速排序。

堆排序的第一步是建堆,建堆的過程會打亂數據原有的相對先後順序,導致原數據的有序度降低。比如,對於一組已經有序的數據來說,經過建堆之後,數據反而變得更無序了。快速排序數據交換的次數不會比逆序度多。

6,總結

  • 堆是一種完全二叉樹。它最大的特性是:每個節點的值都大於等於(或小於等於)其子樹節點的值。因此,堆被分成了兩類,大頂堆和小頂堆。
  • 堆中比較重要的兩個操作是插入一個數據和刪除堆頂元素。這兩個操作都要用到堆化。插入一個數據的時候,我們把新插入的數據放到數組的最後,然後從下往上堆化;刪除堆頂數據的時候,我們把數組中的最後一個元素放到堆頂,然後從上往下堆化。這兩個操作時間複雜度都是 O(logn)。
  • 除此之外,我們還講了堆的一個經典應用,堆排序。堆排序包含兩個過程,建堆和排序。我們將下標從 n/2 到 1 的節點,依次進行從上到下的堆化操作,然後就可以將數組中的數據組織成堆這種數據結構。接下來,我們迭代地將堆頂的元素放到堆的末尾,並將堆的大小減一,然後再堆化,重複這個過程,直到堆中只剩下一個元素,整個數組中的數據就都有序排列了。

四,堆的應用

1,堆的應用一:優先級隊列

如何實現一個優先級隊列呢?方法有很多,但是用堆來實現是最直接、最高效的。這是因爲,堆和優先級隊列非常相似。一個堆就可以看作一個優先級隊列。很多時候,它們只是概念上的區分而已。往優先級隊列中插入一個元素,就相當於往堆中插入一個元素;從優先級隊列中取出優先級最高的元素,就相當於取出堆頂元素。

(1) 合併有序小文件

假設我們有 100 個小文件,每個文件的大小是 100MB,每個文件中存儲的都是有序的字符串。我們希望將這些 100 個小文件合併成一個有序的大文件。這裏就會用到優先級隊列。

我們將從小文件中取出來的字符串放入到小頂堆中,那堆頂的元素,也就是優先級隊列隊首的元素,就是最小的字符串。我們將這個字符串放入到大文件中,並將其從堆中刪除。然後再從小文件中取出下一個字符串,放入到堆中。循環這個過程,就可以將 100 個小文件中的數據依次放入到大文件中。

(2)高性能定時器

假設我們有一個定時器,定時器中維護了很多定時任務,每個任務都設定了一個要觸發執行的時間點。定時器每過一個很小的單位時間(比如 1 秒),就掃描一遍任務,看是否有任務到達設定的執行時間。如果到達了,就拿出來執行。但是,這樣每過 1 秒就掃描一遍任務列表的做法比較低效,主要原因有兩點:第一,任務的約定執行時間離當前時間可能還有很久,這樣前面很多次掃描其實都是徒勞的;第二,每次都要掃描整個任務列表,如果任務列表很大的話,勢必會比較耗時。

但是,這樣每過 1 秒就掃描一遍任務列表的做法比較低效,主要原因有兩點:第一,任務的約定執行時間離當前時間可能還有很久,這樣前面很多次掃描其實都是徒勞的;第二,每次都要掃描整個任務列表,如果任務列表很大的話,勢必會比較耗時。

針對這些問題,我們就可以用優先級隊列來解決。我們按照任務設定的執行時間,將這些任務存儲在優先級隊列中,隊列首部(也就是小頂堆的堆頂)存儲的是最先執行的任務。這樣,定時器就不需要每隔 1 秒就掃描一遍任務列表了。它拿隊首任務的執行時間點,與當前時間點相減,得到一個時間間隔 T。這個時間間隔 T 就是,從當前時間開始,需要等待多久,纔會有第一個任務需要被執行。這樣,定時器就可以設定在 T 秒之後,再來執行任務。從當前時間點到(T-1)秒這段時間裏,定時器都不需要做任何事情。

2,堆的應用二:利用堆求 Top K

針對靜態數據,如何在一個包含 n 個數據的數組中,查找前 K 大數據呢?我們可以維護一個大小爲 K 的小頂堆,順序遍歷數組,從數組中取出數據與堆頂元素比較。如果比堆頂元素大,我們就把堆頂元素刪除,並且將這個元素插入到堆中;如果比堆頂元素小,則不做處理,繼續遍歷數組。這樣等數組中的數據都遍歷完之後,堆中的數據就是前 K 大數據了。遍歷數組需要 O(n) 的時間複雜度,一次堆化操作需要 O(logK) 的時間複雜度,所以最壞情況下,n 個元素都入堆一次,時間複雜度就是 O(nlogK)。

針對動態數據求得 Top K 就是實時 Top K。實際上,我們可以一直都維護一個 K 大小的小頂堆,當有數據被添加到集合中時,我們就拿它與堆頂的元素對比。如果比堆頂元素大,我們就把堆頂元素刪除,並且將這個元素插入到堆中;如果比堆頂元素小,則不做處理。這樣,無論任何時候需要查詢當前的前 K 大數據,我們都可以立刻返回給他。

3,堆的應用三:利用堆求中位數

對於一組靜態數據,中位數是固定的,我們可以先排序,第 2n​ 個數據就是中位數。每次詢問中位數的時候,我們直接返回這個固定的值就好了。所以,儘管排序的代價比較大,但是邊際成本會很小。但是,如果我們面對的是動態數據集合,中位數在不停地變動,如果再用先排序的方法,每次詢問中位數的時候,都要先進行排序,那效率就不高了。

我們需要維護兩個堆,一個大頂堆,一個小頂堆。大頂堆中存儲前半部分數據,小頂堆中存儲後半部分數據,且小頂堆中的數據都大於大頂堆中的數據。也就是說,如果有 n 個數據,n 是偶數,我們從小到大排序,那前 n/2 個數據存儲在大頂堆中,後 n/2個數據存儲在小頂堆中。這樣,大頂堆中的堆頂元素就是我們要找的中位數。如果 n 是奇數,情況是類似的,大頂堆就存儲 n​/2+1 個數據,小頂堆中就存儲 n/2​ 個數據。

如果新加入的數據小於等於大頂堆的堆頂元素,我們就將這個新數據插入到大頂堆;否則,我們就將這個新數據插入到小頂堆。這個時候就有可能出現,兩個堆中的數據個數不符合前面約定的情況:如果 n 是偶數,兩個堆中的數據個數都是 n/2​;如果 n 是奇數,大頂堆有 n​/2+1 個數據,小頂堆有 n​/2 個數據。這個時候,我們可以從一個堆中不停地將堆頂元素移動到另一個堆,通過這樣的調整,來讓兩個堆中的數據滿足上面的約定。

於是,我們就可以利用兩個堆,一個大頂堆、一個小頂堆,實現在動態數據集合中求中位數的操作。插入數據因爲需要涉及堆化,所以時間複雜度變成了 O(logn),但是求中位數我們只需要返回大頂堆的堆頂元素就可以了,所以時間複雜度就是 O(1)。

4,假設現在我們有一個包含 10 億個搜索關鍵詞的日誌文件,如何快速獲取到 Top 10 最熱門的搜索關鍵詞呢?

(1)數據存儲在一個散列表中

因爲用戶搜索的關鍵詞,有很多可能都是重複的,所以我們首先要統計每個搜索關鍵詞出現的頻率。我們可以通過散列表、平衡二叉查找樹或者其他一些支持快速查找、插入的數據結構,來記錄關鍵詞及其出現的次數。假設我們選用散列表。我們就順序掃描這 10 億個搜索關鍵詞。當掃描到某個關鍵詞時,我們去散列表中查詢。如果存在,我們就將對應的次數加一;如果不存在,我們就將它插入到散列表,並記錄次數爲 1。以此類推,等遍歷完這 10 億個搜索關鍵詞之後,散列表中就存儲了不重複的搜索關鍵詞以及出現的次數。

然後,我們再根據前面講的用堆求 Top K 的方法,建立一個大小爲 10 的小頂堆,遍歷散列表,依次取出每個搜索關鍵詞及對應出現的次數,然後與堆頂的搜索關鍵詞對比。如果出現次數比堆頂搜索關鍵詞的次數多,那就刪除堆頂的關鍵詞,將這個出現次數更多的關鍵詞加入到堆中。以此類推,當遍歷完整個散列表中的搜索關鍵詞之後,堆中的搜索關鍵詞就是出現次數最多的 Top 10 搜索關鍵詞了。

(2)將數據分散存儲到不同的文件中

10 億的關鍵詞還是很多的。我們假設 10 億條搜索關鍵詞中不重複的有 1 億條,如果每個搜索關鍵詞的平均長度是 50 個字節,那存儲 1 億個關鍵詞起碼需要 5GB 的內存空間,而散列表因爲要避免頻繁衝突,不會選擇太大的裝載因子,所以消耗的內存空間就更多了。而我們的機器只有 1GB 的可用內存空間,所以我們無法一次性將所有的搜索關鍵詞加入到內存中。這個時候該怎麼辦呢?我們在哈希算法那一節講過,相同數據經過哈希算法得到的哈希值是一樣的。我們可以根據哈希算法的這個特點,將 10 億條搜索關鍵詞先通過哈希算法分片到 10 個文件中。具體可以這樣做:我們創建 10 個空文件 00,01,02,……,09。我們遍歷這 10 億個關鍵詞,並且通過某個哈希算法對其求哈希值,然後哈希值同 10 取模,得到的結果就是這個搜索關鍵詞應該被分到的文件編號。

對這 10 億個關鍵詞分片之後,每個文件都只有 1 億的關鍵詞,去除掉重複的,可能就只有 1000 萬個,每個關鍵詞平均 50 個字節,所以總的大小就是 500MB。1GB 的內存完全可以放得下。我們針對每個包含 1 億條搜索關鍵詞的文件,利用散列表和堆,分別求出 Top 10,然後把這個 10 個 Top 10 放在一塊,然後取這 100 個關鍵詞中,出現次數最多的 10 個關鍵詞,這就是這 10 億數據中的 Top 10 最頻繁的搜索關鍵詞了。

5,有一個訪問量非常大的新聞網站,我們希望將點擊量排名 Top 10 的新聞摘要,滾動顯示在網站首頁 banner 上,並且每隔 1 小時更新一次。如果你是負責開發這個功能的工程師,你會如何來實現呢?

  • 對每篇新聞摘要計算一個hashcode,並建立摘要與hashcode的關聯關係,使用map存儲,以hashCode爲key,新聞摘要爲值
  • 按每小時一個文件的方式記錄下被點擊的摘要的hashCode
  • 當一個小時結果後,上一個小時的文件被關閉,開始計算上一個小時的點擊top10
  • 將hashcode分片到多個文件中,通過對hashCode取模運算,即可將相同的hashCode分片到相同的文件中
  • 針對每個文件取top10的hashCode,使用Map<hashCode,int>的方式,統計出所有的摘要點擊次數,然後再使用小頂堆(大小爲10)計算top10
  • 再針對所有分片計算一個總的top10,最後合併的邏輯也是使用小頂堆,計算top10
  • 如果僅展示前一個小時的top10,計算結束
  • 如果需要展示全天,需要與上一次的計算按hashCode進行合併,然後在這合併的數據中取top10
  • 在展示時,將計算得到的top10的hashcode,轉化爲新聞摘要顯示即可

6,總結

優先級隊列、求 Top K 問題和求中位數問題:

  1. 優先級隊列是一種特殊的隊列,優先級高的數據先出隊,而不再像普通的隊列那樣,先進先出。實際上,堆就可以看作優先級隊列,只是稱謂不一樣罷了。
  2. 求 Top K 問題又可以分爲針對靜態數據和針對動態數據,只需要利用一個堆,就可以做到非常高效率的查詢 Top K 的數據。
  3. 求中位數實際上還有很多變形,比如求 99 百分位數據、90 百分位數據等,處理的思路都是一樣的,即利用兩個堆,一個大頂堆,一個小頂堆,隨着數據的動態添加,動態調整兩個堆中的數據,最後大頂堆的堆頂元素就是要求的數據。

五,圖

1,圖的表示

微博、微信是兩種“圖”,前者是有向圖,後者是無向圖。

數據結構是爲算法服務的,所以具體選擇哪種存儲方法,與期望支持的操作有關係。針對微博用戶關係,假設我們需要支持下面這樣幾個操作:

  • 判斷用戶 A 是否關注了用戶 B;
  • 判斷用戶 A 是否是用戶 B 的粉絲;
  • 用戶 A 關注用戶 B;
  • 用戶 A 取消關注用戶 B;
  • 根據用戶名稱的首字母排序,分頁獲取用戶的粉絲列表;
  • 根據用戶名稱的首字母排序,分頁獲取用戶的關注列表。

關於如何存儲一個圖,前面我們講到兩種主要的存儲方法,鄰接矩陣和鄰接表。因爲社交網絡是一張稀疏圖,使用鄰接矩陣存儲比較浪費存儲空間。所以,這裏我們採用鄰接表來存儲。不過,用一個鄰接表來存儲這種有向圖是不夠的。我們去查找某個用戶關注了哪些用戶非常容易,但是如果要想知道某個用戶都被哪些用戶關注了,也就是用戶的粉絲列表,是非常困難的。

基於此,我們需要一個逆鄰接表。鄰接表中存儲了用戶的關注關係,逆鄰接表中存儲的是用戶的被關注關係。對應到圖上,鄰接表中,每個頂點的鏈表中,存儲的就是這個頂點指向的頂點,逆鄰接表中,每個頂點的鏈表中,存儲的是指向這個頂點的頂點。如果要查找某個用戶關注了哪些用戶,我們可以在鄰接表中查找;如果要查找某個用戶被哪些用戶關注了,我們從逆鄰接表中查找。

基礎的鄰接表不適合快速判斷兩個用戶之間是否是關注與被關注的關係,所以我們選擇改進版本,將鄰接表中的鏈表改爲支持快速查找的動態數據結構。選擇哪種動態數據結構呢?紅黑樹、跳錶、有序動態數組還是散列表呢?因爲我們需要按照用戶名稱的首字母排序,分頁來獲取用戶的粉絲列表或者關注列表,用跳錶這種結構再合適不過了。這是因爲,跳錶插入、刪除、查找都非常高效,時間複雜度是 O(logn),空間複雜度上稍高,是 O(n)。最重要的一點,跳錶中存儲的數據本來就是有序的了,分頁獲取粉絲列表或關注列表,就非常高效。

如果對於小規模的數據,比如社交網絡中只有幾萬、幾十萬個用戶,我們可以將整個社交關係存儲在內存中,上面的解決思路是沒有問題的。但是如果像微博那樣有上億的用戶,數據規模太大,我們就無法全部存儲在內存中了。這個時候該怎麼辦呢?我們可以通過哈希算法等數據分片方式,將鄰接表存儲在不同的機器上。你可以看下面這幅圖,我們在機器 1 上存儲頂點 1,2,3 的鄰接表,在機器 2 上,存儲頂點 4,5 的鄰接表。逆鄰接表的處理方式也一樣。當要查詢頂點與頂點關係的時候,我們就利用同樣的哈希算法,先定位頂點所在的機器,然後再在相應的機器上查找。

微信好友關係存儲方式。無向圖,也可以使用鄰接表的方式存儲每個人所對應的好友列表。爲了支持快速查找,好友列表可以使用紅黑樹存儲。

2,深度和廣度優先搜索

深度優先搜索算法和廣度優先搜索算法,既可以用在無向圖,也可以用在有向圖上。在本文中,針對無向圖來講解。

public class Graph { // 無向圖
  private int v; // 頂點的個數
  private LinkedList<Integer> adj[]; // 鄰接表

  public Graph(int v) {
    this.v = v;
    adj = new LinkedList[v];
    for (int i=0; i<v; ++i) {
      adj[i] = new LinkedList<>();
    }
  }

  public void addEdge(int s, int t) { // 無向圖一條邊存兩次
    adj[s].add(t);
    adj[t].add(s);
  }
}

(1)廣度優先搜索(BFS)

廣度優先搜索(Breadth-First-Search),我們平常都把簡稱爲 BFS。直觀地講,它其實就是一種“地毯式”層層推進的搜索策略,即先查找離起始頂點最近的,然後是次近的,依次往外搜索。

public void bfs(int s, int t) {
  if (s == t) return;
  boolean[] visited = new boolean[v];
  visited[s]=true;
  Queue<Integer> queue = new LinkedList<>();
  queue.add(s);
  int[] prev = new int[v];
  for (int i = 0; i < v; ++i) {
    prev[i] = -1;
  }
  while (queue.size() != 0) {
    int w = queue.poll();
   for (int i = 0; i < adj[w].size(); ++i) {
      int q = adj[w].get(i);
      if (!visited[q]) {
        prev[q] = w;
        if (q == t) {
          print(prev, s, t);
          return;
        }
        visited[q] = true;
        queue.add(q);
      }
    }
  }
}

private void print(int[] prev, int s, int t) { // 遞歸打印s->t的路徑
  if (prev[t] != -1 && t != s) {
    print(prev, s, prev[t]);
  }
  System.out.print(t + " ");
}

最壞情況下,終止頂點 t 離起始頂點 s 很遠,需要遍歷完整個圖才能找到。這個時候,每個頂點都要進出一遍隊列,每個邊也都會被訪問一次,所以,廣度優先搜索的時間複雜度是 O(V+E),其中,V 表示頂點的個數,E 表示邊的個數。當然,對於一個連通圖來說,也就是說一個圖中的所有頂點都是連通的,E 肯定要大於等於 V-1,所以,廣度優先搜索的時間複雜度也可以簡寫爲 O(E)。廣度優先搜索的空間消耗主要在幾個輔助變量 visited 數組、queue 隊列、prev 數組上。這三個存儲空間的大小都不會超過頂點的個數,所以空間複雜度是 O(V)。

(2)深度優先搜索(DFS)

深度優先搜索(Depth-First-Search),簡稱 DFS。最直觀的例子就是“走迷宮”。假設你站在迷宮的某個岔路口,然後想找到出口。你隨意選擇一個岔路口來走,走着走着發現走不通的時候,你就回退到上一個岔路口,重新選擇一條路繼續走,直到最終找到出口。這種走法就是一種深度優先搜索策略。

public void dfs(int s, int t) {
  found = false;
  boolean[] visited = new boolean[v];
  int[] prev = new int[v];
  for (int i = 0; i < v; ++i) {
    prev[i] = -1;
  }
  recurDfs(s, t, visited, prev);
  print(prev, s, t);
}

private void recurDfs(int w, int t, boolean[] visited, int[] prev) {
  visited[w] = true;
  if (w == t) {
    return;
  }
  for (int i = 0; i < adj[w].size(); ++i) {
    int q = adj[w].get(i);
    if (!visited[q]) {
      prev[q] = w;
      recurDfs(q, t, visited, prev);
    }
  }
}

從我前面畫的圖可以看出,每條邊最多會被訪問兩次,一次是遍歷,一次是回退。所以,圖上的深度優先搜索算法的時間複雜度是 O(E),E 表示邊的個數。

深度優先搜索算法的消耗內存主要是 visited、prev 數組和遞歸調用棧。visited、prev 數組的大小跟頂點的個數 V 成正比,遞歸調用棧的最大深度不會超過頂點的個數,所以總的空間複雜度就是 O(V)。

(3)如何找出社交網絡中某個用戶的三度好友關係?

這個問題就非常適合用圖的廣度優先搜索算法來解決,因爲廣度優先搜索是層層往外推進的。首先,遍歷與起始頂點最近的一層頂點,也就是用戶的一度好友,然後再遍歷與用戶距離的邊數爲 2 的頂點,也就是二度好友關係,以及與用戶距離的邊數爲 3 的頂點,也就是三度好友關係。我們只需要稍加改造一下廣度優先搜索代碼,用一個數組來記錄每個頂點與起始頂點的距離,非常容易就可以找出三度好友關係。

(4)總結

廣度優先搜索,通俗的理解就是,地毯式層層推進,從起始頂點開始,依次往外遍歷。廣度優先搜索需要藉助隊列來實現,遍歷得到的路徑就是,起始頂點到終止頂點的最短路徑。深度優先搜索用的是回溯思想,非常適合用遞歸實現。換種說法,深度優先搜索是藉助棧來實現的。在執行效率方面,深度優先和廣度優先搜索的時間複雜度都是 O(E),空間複雜度是 O(V)。

六,字符串匹配算法

1,BF 算法

BF 算法中的 BF 是 Brute Force 的縮寫,中文叫作暴力匹配算法,也叫樸素匹配算法。

我們在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串。我們把主串的長度記作 n,模式串的長度記作 m。因爲我們是在主串中查找模式串,所以 n>m。作爲最簡單、最暴力的字符串匹配算法,BF 算法的思想可以用一句話來概括,那就是,我們在主串中,檢查起始位置分別是 0、1、2…n-m 且長度爲 m 的 n-m+1 個子串,看有沒有跟模式串匹配的。

在極端情況下,比如主串是“aaaaa…aaaaaa”(省略號表示有很多重複的字符 a),模式串是“aaaaab”。我們每次都比對 m 個字符,要比對 n-m+1 次,所以,這種算法的最壞情況時間複雜度是 O(n*m)。

儘管理論上,BF 算法的時間複雜度很高,是 O(n*m),但在實際的開發中,它卻是一個比較常用的字符串匹配算法。爲什麼這麼說呢?原因有兩點。

  • 第一,實際的軟件開發中,大部分情況下,模式串和主串的長度都不會太長。而且每次模式串與主串中的子串匹配的時候,當中途遇到不能匹配的字符的時候,就可以就停止了,不需要把 m 個字符都比對一下。所以,儘管理論上的最壞情況時間複雜度是 O(n*m),但是,統計意義上,大部分情況下,算法執行效率要比這個高很多。
  • 第二,樸素字符串匹配算法思想簡單,代碼實現也非常簡單。簡單意味着不容易出錯,如果有 bug 也容易暴露和修復。

2,RK 算法

RK 算法的全稱叫 Rabin-Karp 算法,是由它的兩位發明者 Rabin 和 Karp 的名字來命名的。這個算法理解起來也不是很難。我個人覺得,它其實就是剛剛講的 BF 算法的升級版。

RK 算法的思路是這樣的:我們通過哈希算法對主串中的 n-m+1 個子串分別求哈希值,然後逐個與模式串的哈希值比較大小。如果某個子串的哈希值與模式串相等,那就說明對應的子串和模式串匹配了。因爲哈希值是一個數字,數字之間比較是否相等是非常快速的,所以模式串和子串比較的效率就提高了。

這種哈希算法有一個特點,在主串中,相鄰兩個子串的哈希值的計算公式有一定關係。

從這裏例子中,我們很容易就能得出這樣的規律:相鄰兩個子串 s[i-1] 和 s[i](i 表示子串在主串中的起始位置,子串的長度都爲 m),對應的哈希值計算公式有交集,也就是說,我們可以使用 s[i-1] 的哈希值很快的計算出 s[i] 的哈希值。如果用公式表示的話,就是下面這個樣子:

這裏有一個小細節需要注意,那就是 26^(m-1) 這部分的計算,我們可以通過查表的方法來提高效率。我們事先計算好 26^0、26^1、26^2……26^(m-1),並且存儲在一個長度爲 m 的數組中,公式中的“次方”就對應數組的下標。當我們需要計算 26 的 x 次方的時候,就可以從數組的下標爲 x 的位置取值,直接使用,省去了計算的時間。

整個 RK 算法包含兩部分,計算子串哈希值和模式串哈希值與子串哈希值之間的比較。第一部分,我們前面也分析了,可以通過設計特殊的哈希算法,只需要掃描一遍主串就能計算出所有子串的哈希值了,所以這部分的時間複雜度是 O(n)。模式串哈希值與每個子串哈希值之間的比較的時間複雜度是 O(1),總共需要比較 n-m+1 個子串的哈希值,所以,這部分的時間複雜度也是 O(n)。所以,RK 算法整體的時間複雜度就是 O(n)。

總結

  • BF 算法是最簡單、粗暴的字符串匹配算法,它的實現思路是,拿模式串與主串中是所有子串匹配,看是否有能匹配的子串。所以,時間複雜度也比較高,是 O(n*m),n、m 表示主串和模式串的長度。不過,在實際的軟件開發中,因爲這種算法實現簡單,對於處理小規模的字符串匹配很好用。
  • RK 算法是藉助哈希算法對 BF 算法進行改造,即對每個子串分別求哈希值,然後拿子串的哈希值與模式串的哈希值比較,減少了比較的時間。所以,理想情況下,RK 算法的時間複雜度是 O(n),跟 BF 算法相比,效率提高了很多。不過這樣的效率取決於哈希算法的設計方法,如果存在衝突的情況下,時間複雜度可能會退化。極端情況下,哈希算法大量衝突,時間複雜度就退化爲 O(n*m)。

3,BM算法

對於工業級的軟件開發來說,我們希望算法儘可能的高效,並且在極端情況下,性能也不要退化的太嚴重。那麼,對於查找功能是重要功能的軟件來說,比如一些文本編輯器,它們的查找功能都是用哪種算法來實現的呢?有沒有比 BF 算法和 RK 算法更加高效的字符串匹配算法呢?

 BM(Boyer-Moore)算法。它是一種非常高效的字符串匹配算法,有實驗統計,它的性能是著名的KMP 算法的 3 到 4 倍。

BM 算法核心思想是,利用模式串本身的特點,在模式串中某個字符與主串不能匹配的時候,將模式串往後多滑動幾位,以此來減少不必要的字符比較,提高匹配的效率。BM 算法構建的規則有兩類,壞字符規則和好後綴規則。好後綴規則可以獨立於壞字符規則使用。因爲壞字符規則的實現比較耗內存,爲了節省內存,我們可以只用好後綴規則來實現 BM 算法。

(1)壞字符規則

BM 算法的匹配順序比較特別,它是按照模式串下標從大到小的順序,倒着匹配的。

當發生不匹配的時候,我們把壞字符對應的模式串中的字符下標記作 si。如果壞字符在模式串中存在,我們把這個壞字符在模式串中的下標記作 xi。如果不存在,我們把 xi 記作 -1。那模式串往後移動的位數就等於 si-xi。(注意,我這裏說的下標,都是字符在模式串的下標)。如果壞字符在模式串裏多處出現,那我們在計算 xi 的時候,選擇最靠後的那個,因爲這樣不會讓模式串滑動過多,導致本來可能匹配的情況被滑動略過。

利用壞字符規則,BM 算法在最好情況下的時間複雜度非常低,是 O(n/m)。比如,主串是 aaabaaabaaabaaab,模式串是 aaaa。每次比對,模式串都可以直接後移四位,所以,匹配具有類似特點的模式串和主串的時候,BM 算法非常高效。

如果我們拿壞字符,在模式串中順序遍歷查找,這樣就會比較低效,勢必影響這個算法的性能。有沒有更加高效的方式呢?我們之前學的散列表,這裏可以派上用場了。我們可以將模式串中的每個字符及其下標都存到散列表中。這樣就可以快速找到壞字符在模式串的位置下標了。關於這個散列表,我們只實現一種最簡單的情況,假設字符串的字符集不是很大,每個字符長度是 1 字節,我們用大小爲 256 的數組,來記錄每個字符在模式串中出現的位置。數組的下標對應字符的 ASCII 碼值,數組中存儲這個字符在模式串中出現的位置。

如果將上面的過程翻譯成代碼,就是下面這個樣子。其中,變量 b 是模式串,m 是模式串的長度,bc 表示剛剛講的散列表。

private static final int SIZE = 256; // 全局變量或成員變量
private void generateBC(char[] b, int m, int[] bc) {
  for (int i = 0; i < SIZE; ++i) {
    bc[i] = -1; // 初始化bc
  }
  for (int i = 0; i < m; ++i) {
    int ascii = (int)b[i]; // 計算b[i]的ASCII值
    bc[ascii] = i;
  }
}

(2)好後綴規則

我們把已經匹配的 bc 叫作好後綴,記作{u}。我們拿它在模式串中查找,如果找到了另一個跟{u}相匹配的子串{u*},那我們就將模式串滑動到子串{u*}與主串中{u}對齊的位置。

但是當模式串滑動到前綴與主串中{u}的後綴有部分重合的時候,並且重合的部分相等的時候,就有可能會存在完全匹配的情況。針對這種情況,我們不僅要看好後綴在模式串中,是否有另一個匹配的子串,我們還要考察好後綴的後綴子串,是否存在跟模式串的前綴子串匹配的。

4,KMP算法

(1)KMP 算法基本原理

KMP 算法是根據三位作者(D.E.Knuth,J.H.Morris 和 V.R.Pratt)的名字來命名的,算法的全稱是 Knuth Morris Pratt 算法,簡稱爲 KMP 算法。KMP 算法的核心思想:

在模式串與主串匹配的過程中,當遇到不可匹配的字符的時候,我們希望找到一些規律,可以將模式串往後多滑動幾位,跳過那些肯定不會匹配的情況。

 在模式串和主串匹配的過程中,把不能匹配的那個字符仍然叫作壞字符,把已經匹配的那段字符串叫作好前綴。當遇到壞字符的時候,我們就要把模式串往後滑動,在滑動的過程中,只要模式串和好前綴有上下重合,前面幾個字符的比較,就相當於拿好前綴的後綴子串,跟模式串的前綴子串在比較。這個比較的過程能否更高效了呢?可以不用一個字符一個字符地比較了嗎?

我們只需要拿好前綴本身,在它的後綴子串中,查找最長的那個可以跟好前綴的前綴子串匹配的。假設最長的可匹配的那部分前綴子串是{v},長度是 k。我們把模式串一次性往後滑動 j-k 位,相當於,每次遇到壞字符的時候,我們就把 j 更新爲 k,i 不變,然後繼續比較。

如何來求好前綴的最長可匹配前綴和後綴子串呢?這個問題其實不涉及主串,只需要通過模式串本身就能求解。KMP 算法提前構建一個數組,用來存儲模式串中每個前綴(這些前綴都有可能是好前綴)的最長可匹配前綴子串的結尾字符下標。我們把這個數組定義爲 next 數組,很多書中還給這個數組起了一個名字,叫失效函數(failure function)。數組的下標是每個前綴結尾字符下標,數組的值是這個前綴的最長可以匹配前綴子串的結尾字符下標。

有了 next 數組,我們很容易就可以實現 KMP 算法了。我先假設 next 數組已經計算好了,先給出 KMP 算法的框架代碼。

// a, b分別是主串和模式串;n, m分別是主串和模式串的長度。
public static int kmp(char[] a, int n, char[] b, int m) {
  int[] next = getNexts(b, m);
  int j = 0;
  for (int i = 0; i < n; ++i) {
    while (j > 0 && a[i] != b[j]) { // 如果遇到壞字符,從最長匹配前綴子串開始找,j回退,i不變,一直找到a[i]和b[j]
      j = next[j - 1] + 1;
    }
    if (a[i] == b[j]) {
      ++j;
    }
    if (j == m) { // 找到匹配模式串的了
      return i - m + 1;
    }
  }
  return -1;
}

(2)失效函數計算方法

我們按照下標從小到大,依次計算 next 數組的值。當我們要計算 next[i] 的時候,前面的 next[0],next[1],……,next[i-1] 應該已經計算出來了。利用已經計算出來的 next 值,我們是否可以快速推導出 next[i] 的值呢?

如果 next[i-1]=k-1,也就是說,子串 b[0, k-1] 是 b[0, i-1] 的最長可匹配前綴子串。如果子串 b[0, k-1] 的下一個字符 b[k],與 b[0, i-1] 的下一個字符 b[i] 匹配,那子串 b[0, k] 就是 b[0, i] 的最長可匹配前綴子串。所以,next[i] 等於 k。

如果b[0, i-1] 最長可匹配後綴子串對應的模式串的前綴子串的下一個字符並不等於 b[i],那麼我們就可以考察 b[0, i-1] 的次長可匹配後綴子串 b[x, i-1] 對應的可匹配前綴子串 b[0, i-1-x] 的下一個字符 b[i-x] 是否等於 b[i]。如果等於,那 b[x, i] 就是 b[0, i] 的最長可匹配後綴子串。

如何求得 b[0, i-1] 的次長可匹配後綴子串呢?次長可匹配後綴子串肯定被包含在最長可匹配後綴子串中,而最長可匹配後綴子串又對應最長可匹配前綴子串 b[0, y]。於是,查找 b[0, i-1] 的次長可匹配後綴子串,這個問題就變成,查找 b[0, y] 的最長匹配後綴子串的問題了。

// b表示模式串,m表示模式串的長度
private static int[] getNexts(char[] b, int m) {
  int[] next = new int[m];
  next[0] = -1;
  int k = -1;
  for (int i = 1; i < m; ++i) {
    while (k != -1 && b[k + 1] != b[i]) {
      k = next[k];
    }
    if (b[k + 1] == b[i]) {
      ++k;
    }
    next[i] = k;
  }
  return next;
}

這裏最難理解的地方是,k = next[k],因爲前一個的最長串的下一個字符不與最後一個相等,需要找前一個的次長串,問題就變成了求0到next(k)的最長串,如果下個字符與最後一個不等,繼續求次長串,也就是下一個next(k),直到找到,或者完全沒有。

(3)KMP 算法複雜度分析

空間複雜度很容易分析,KMP 算法只需要一個額外的 next 數組,數組的大小跟模式串相同。所以空間複雜度是 O(m),m 表示模式串的長度。

KMP 算法包含兩部分,第一部分是構建 next 數組,第二部分纔是藉助 next 數組匹配。所以,關於時間複雜度,我們要分別從這兩部分來分析。

  • 計算 next 數組的代碼中,我們可以找一些參照變量,i 和 k。i 從 1 開始一直增加到 m,而 k 並不是每次 for 循環都會增加,所以,k 累積增加的值肯定小於 m。而 while 循環裏 k=next[k],實際上是在減小 k 的值,k 累積都沒有增加超過 m,所以 while 循環裏面 k=next[k] 總的執行次數也不可能超過 m。因此,next 數組計算的時間複雜度是 O(m)。
  • i 從 0 循環增長到 n-1,j 的增長量不可能超過 i,所以肯定小於 n。而 while 循環中的那條語句 j=next[j-1]+1,不會讓 j 增長的,那有沒有可能讓 j 不變呢?也沒有可能。因爲 next[j-1] 的值肯定小於 j-1,所以 while 循環中的這條語句實際上也是在讓 j 的值減少。而 j 總共增長的量都不會超過 n,那減少的量也不可能超過 n,所以 while 循環中的這條語句總的執行次數也不會超過 n,所以這部分的時間複雜度是 O(n)。

所以,綜合兩部分的時間複雜度,KMP 算法的時間複雜度就是 O(m+n)。

(4)總結

KMP 算法和上一節講的 BM 算法的本質非常類似,都是根據規律在遇到壞字符的時候,把模式串往後多滑動幾位。BM 算法有兩個規則,壞字符和好後綴。KMP 算法借鑑 BM 算法的思想,可以總結成好前綴規則。這裏面最難懂的就是 next 數組的計算。如果用最笨的方法來計算,確實不難,但是效率會比較低。所以,我講了一種類似動態規劃的方法,按照下標 i 從小到大,依次計算 next[i],並且 next[i] 的計算通過前面已經計算出來的 next[0],next[1],……,next[i-1] 來推導。

七,Trie樹

Trie 樹,也叫“字典樹”。顧名思義,它是一個樹形結構。它是一種專門處理字符串匹配的數據結構,用來解決在一組字符串集合中快速查找某個字符串的問題。Trie 樹的本質,就是利用字符串之間的公共前綴,將重複的前綴合並在一起。

1,如何實現一棵 Trie 樹?

Trie 樹主要有兩個操作,一個是將字符串集合構造成 Trie 樹。這個過程分解開來的話,就是一個將字符串插入到 Trie 樹的過程。另一個是在 Trie 樹中查詢一個字符串。

public class Trie {
  private TrieNode root = new TrieNode('/'); // 存儲無意義字符

  // 往Trie樹中插入一個字符串
  public void insert(char[] text) {
    TrieNode p = root;
    for (int i = 0; i < text.length; ++i) {
      int index = text[i] - 'a';
      if (p.children[index] == null) {
        TrieNode newNode = new TrieNode(text[i]);
        p.children[index] = newNode;
      }
      p = p.children[index];
    }
    p.isEndingChar = true;
  }

  // 在Trie樹中查找一個字符串
  public boolean find(char[] pattern) {
    TrieNode p = root;
    for (int i = 0; i < pattern.length; ++i) {
      int index = pattern[i] - 'a';
      if (p.children[index] == null) {
        return false; // 不存在pattern
      }
      p = p.children[index];
    }
    if (p.isEndingChar == false) return false; // 不能完全匹配,只是前綴
    else return true; // 找到pattern
  }

  public class TrieNode {
    public char data;
    public TrieNode[] children = new TrieNode[26];
    public boolean isEndingChar = false;
    public TrieNode(char data) {
      this.data = data;
    }
  }
}

如果要在一組字符串中,頻繁地查詢某些字符串,用 Trie 樹會非常高效。構建 Trie 樹的過程,需要掃描所有的字符串,時間複雜度是 O(n)(n 表示所有字符串的長度和)。但是一旦構建成功之後,後續的查詢操作會非常高效。

2,Trie 樹很耗內存

Trie 樹的本質是避免重複存儲一組字符串的相同前綴子串,但是現在每個字符(對應一個節點)的存儲遠遠大於 1 個字節。按照我們上面舉的例子,數組長度爲 26,每個元素是 8 字節,那每個節點就會額外需要 26*8=208 個字節。而且這還是隻包含 26 個字符的情況。如果字符串中不僅包含小寫字母,還包含大寫字母、數字、甚至是中文,那需要的存儲空間就更多了。所以,也就是說,在某些情況下,Trie 樹不一定會節省存儲空間。在重複的前綴並不多的情況下,Trie 樹不但不能節省內存,還有可能會浪費更多的內存。當然,我們不可否認,Trie 樹儘管有可能很浪費內存,但是確實非常高效。

實際上,Trie 樹的變體有很多,都可以在一定程度上解決內存消耗的問題。比如,縮點優化,就是對只有一個子節點的節點,而且此節點不是一個串的結束節點,可以將此節點與子節點合併。這樣可以節省空間,但卻增加了編碼難度。

3,Trie 樹與散列表、紅黑樹的比較

在一組字符串中查找字符串,Trie 樹實際上表現得並不好。它對要處理的字符串有及其嚴苛的要求。

  • 第一,字符串中包含的字符集不能太大。我們前面講到,如果字符集太大,那存儲空間可能就會浪費很多。即便可以優化,但也要付出犧牲查詢、插入效率的代價。
  • 第二,要求字符串的前綴重合比較多,不然空間消耗會變大很多。
  • 第三,如果要用 Trie 樹解決問題,那我們就要自己從零開始實現一個 Trie 樹,還要保證沒有 bug,這個在工程上是將簡單問題複雜化,除非必須,一般不建議這樣做。
  • 第四,我們知道,通過指針串起來的數據塊是不連續的,而 Trie 樹中用到了指針,所以,對緩存並不友好,性能上會打個折扣。

綜合這幾點,針對在一組字符串中查找字符串的問題,我們在工程中,更傾向於用散列表或者紅黑樹。因爲這兩種數據結構,我們都不需要自己去實現,直接利用編程語言中提供的現成類庫就行了。

實際上,Trie 樹只是不適合精確匹配查找,這種問題更適合用散列表或者紅黑樹來解決。Trie 樹比較適合的是查找前綴匹配的字符串

4,總結

如何利用 Trie 樹,實現搜索關鍵詞的提示功能?

我們假設關鍵詞庫由用戶的熱門搜索關鍵詞組成。我們將這個詞庫構建成一個 Trie 樹。當用戶輸入其中某個單詞的時候,把這個詞作爲一個前綴子串在 Trie 樹中匹配。爲了講解方便,我們假設詞庫裏只有 hello、her、hi、how、so、see 這 6 個關鍵詞。當用戶輸入了字母 h 的時候,我們就把以 h 爲前綴的 hello、her、hi、how 展示在搜索提示框內。當用戶繼續鍵入字母 e 的時候,我們就把以 he 爲前綴的 hello、her 展示在搜索提示框內。這就是搜索關鍵詞提示的最基本的算法原理。

Trie 樹是一種解決字符串快速匹配問題的數據結構。如果用來構建 Trie 樹的這一組字符串中,前綴重複的情況不是很多,那 Trie 樹這種數據結構總體上來講是比較費內存的,是一種空間換時間的解決問題思路。儘管比較耗費內存,但是對內存不敏感或者內存消耗在接受範圍內的情況下,在 Trie 樹中做字符串匹配還是非常高效的,時間複雜度是 O(k),k 表示要匹配的字符串的長度。

但是,Trie 樹的優勢並不在於,用它來做動態集合數據的查找,因爲,這個工作完全可以用更加合適的散列表或者紅黑樹來替代。Trie 樹最有優勢的是查找前綴匹配的字符串,比如搜索引擎中的關鍵詞提示功能這個場景,就比較適合用它來解決,也是 Trie 樹比較經典的應用場景。

八,AC自動機

1,基於單模式串和 Trie 樹實現的敏感詞過濾

 BF 算法、RK 算法、BM 算法、KMP 算法,還有 Trie 樹。前面四種算法都是單模式串匹配算法,只有 Trie 樹是多模式串匹配算法。單模式串匹配算法,是在一個模式串和一個主串之間進行匹配,也就是說,在一個主串中查找一個模式串。多模式串匹配算法,就是在多個模式串和一個主串之間做匹配,也就是說,在一個主串中查找多個模式串。

儘管,單模式串匹配算法也能完成多模式串的匹配工作。例如開篇的思考題,我們可以針對每個敏感詞,通過單模式串匹配算法(比如 KMP 算法)與用戶輸入的文字內容進行匹配。但是,這樣做的話,每個匹配過程都需要掃描一遍用戶輸入的內容。整個過程下來就要掃描很多遍用戶輸入的內容。如果敏感詞很多,比如幾千個,並且用戶輸入的內容很長,假如有上千個字符,那我們就需要掃描幾千遍這樣的輸入內容。很顯然,這種處理思路比較低效。

與單模式匹配算法相比,多模式匹配算法在這個問題的處理上就很高效了。它只需要掃描一遍主串,就能在主串中一次性查找多個模式串是否存在,從而大大提高匹配效率。我們知道,Trie 樹就是一種多模式串匹配算法。那如何用 Trie 樹實現敏感詞過濾功能呢?我們可以對敏感詞字典進行預處理,構建成 Trie 樹結構。這個預處理的操作只需要做一次,如果敏感詞字典動態更新了,比如刪除、添加了一個敏感詞,那我們只需要動態更新一下 Trie 樹就可以了。

當用戶輸入一個文本內容後,我們把用戶輸入的內容作爲主串,從第一個字符(假設是字符 C)開始,在 Trie 樹中匹配。當匹配到 Trie 樹的葉子節點,或者中途遇到不匹配字符的時候,我們將主串的開始匹配位置後移一位,也就是從字符 C 的下一個字符開始,重新在 Trie 樹中匹配。基於 Trie 樹的這種處理方法,有點類似單模式串匹配的 BF 算法。我們知道,單模式串匹配算法中,KMP 算法對 BF 算法進行改進,引入了 next 數組,讓匹配失敗時,儘可能將模式串往後多滑動幾位。借鑑單模式串的優化改進方法,能否對多模式串 Trie 樹進行改進,進一步提高 Trie 樹的效率呢?這就要用到 AC 自動機算法了。

2,經典的多模式串匹配算法:AC 自動機

AC 自動機算法,全稱是 Aho-Corasick 算法。其實,Trie 樹跟 AC 自動機之間的關係,就像單串匹配中樸素的串匹配算法,跟 KMP 算法之間的關係一樣,只不過前者針對的是多模式串而已。所以,AC 自動機實際上就是在 Trie 樹之上,加了類似 KMP 的 next 數組,只不過此處的 next 數組是構建在樹上罷了。如果代碼表示,就是下面這個樣子:

public class AcNode {
  public char data; 
  public AcNode[] children = new AcNode[26]; // 字符集只包含a~z這26個字符
  public boolean isEndingChar = false; // 結尾字符爲true
  public int length = -1; // 當isEndingChar=true時,記錄模式串長度
  public AcNode fail; // 失敗指針
  public AcNode(char data) {
    this.data = data;
  }
}

所以,AC 自動機的構建,包含兩個操作:

  • 將多個模式串構建成 Trie 樹;
  • 在 Trie 樹上構建失敗指針(相當於 KMP 中的失效函數 next 數組)。

假設我們沿 Trie 樹走到 p 節點,也就是下圖中的紫色節點,那 p 的失敗指針就是從 root 走到紫色節點形成的字符串 abc,跟所有模式串前綴匹配的最長可匹配後綴子串,就是箭頭指的 bc 模式串。這裏的最長可匹配後綴子串,我稍微解釋一下。字符串 abc 的後綴子串有兩個 bc,c,我們拿它們與其他模式串匹配,如果某個後綴子串可以匹配某個模式串的前綴,那我們就把這個後綴子串叫作可匹配後綴子串。我們從可匹配後綴子串中,找出最長的一個,就是剛剛講到的最長可匹配後綴子串。我們將 p 節點的失敗指針指向那個最長匹配後綴子串對應的模式串的前綴的最後一個節點,就是下圖中箭頭指向的節點。

首先 root 的失敗指針爲 NULL,也就是指向自己。當我們已經求得某個節點 p 的失敗指針之後,如何尋找它的子節點的失敗指針呢?我們假設節點 p 的失敗指針指向節點 q,我們看節點 p 的子節點 pc 對應的字符,是否也可以在節點 q 的子節點中找到。如果找到了節點 q 的一個子節點 qc,對應的字符跟節點 pc 對應的字符相同,則將節點 pc 的失敗指針指向節點 qc。

如果節點 q 中沒有子節點的字符等於節點 pc 包含的字符,則令 q=q->fail(fail 表示失敗指針,這裏有沒有很像 KMP 算法裏求 next 的過程?),繼續上面的查找,直到 q 是 root 爲止,如果還沒有找到相同字符的子節點,就讓節點 pc 的失敗指針指向 root。


public void buildFailurePointer() {
  Queue<AcNode> queue = new LinkedList<>();
  root.fail = null;
  queue.add(root);
  while (!queue.isEmpty()) {
    AcNode p = queue.remove();
    for (int i = 0; i < 26; ++i) {
      AcNode pc = p.children[i];
      if (pc == null) continue;
      if (p == root) {
        pc.fail = root;
      } else {
        AcNode q = p.fail;
        while (q != null) {
          AcNode qc = q.children[pc.data - 'a'];
          if (qc != null) {
            pc.fail = qc;
            break;
          }
          q = q.fail;
        }
        if (q == null) {
          pc.fail = root;
        }
      }
      queue.add(pc);
    }
  }
}

通過按層來計算每個節點的子節點的失效指針,剛剛舉的那個例子,最後構建完成之後的 AC 自動機就是下面這個樣子:

AC 自動機到此就構建完成了。我們現在來看下,如何在 AC 自動機上匹配主串?

關於匹配的這部分,文字描述不如代碼看得清楚,所以我把代碼貼了出來,非常簡短,並且添加了詳細的註釋,你可以對照着看下。這段代碼輸出的就是,在主串中每個可以匹配的模式串出現的位置。

public void match(char[] text) { // text是主串
  int n = text.length;
  AcNode p = root;
  for (int i = 0; i < n; ++i) {
    int idx = text[i] - 'a';
    while (p.children[idx] == null && p != root) {
      p = p.fail; // 失敗指針發揮作用的地方
    }
    p = p.children[idx];
    if (p == null) p = root; // 如果沒有匹配的,從root開始重新匹配
    AcNode tmp = p;
    while (tmp != root) { // 打印出可以匹配的模式串
      if (tmp.isEndingChar == true) {
        int pos = i-tmp.length+1;
        System.out.println("匹配起始下標" + pos + "; 長度" + tmp.length);
      }
      tmp = tmp.fail;
    }
  }
}

3,AC自動機的時間複雜度

AC 自動機實現的敏感詞過濾系統,是否比單模式串匹配方法更高效呢?

首先,我們需要將敏感詞構建成 AC 自動機,包括構建 Trie 樹以及構建失敗指針。我們上一節講過,Trie 樹構建的時間複雜度是 O(m*len),其中 len 表示敏感詞的平均長度,m 表示敏感詞的個數。那構建失敗指針的時間複雜度是多少呢?

假設 Trie 樹中總的節點個數是 k,每個節點構建失敗指針的時候,(你可以看下代碼)最耗時的環節是 while 循環中的 q=q->fail,每運行一次這個語句,q 指向節點的深度都會減少 1,而樹的高度最高也不會超過 len,所以每個節點構建失敗指針的時間複雜度是 O(len)。整個失敗指針的構建過程就是 O(k*len)。

我們再來看下,用 AC 自動機做匹配的時間複雜度是多少?

跟剛剛構建失敗指針的分析類似,for 循環依次遍歷主串中的每個字符,for 循環內部最耗時的部分也是 while 循環,而這一部分的時間複雜度也是 O(len),所以總的匹配的時間複雜度就是 O(n*len)。因爲敏感詞並不會很長,而且這個時間複雜度只是一個非常寬泛的上限,實際情況下,可能近似於 O(n),所以 AC 自動機做敏感詞過濾,性能非常高。

你可以會說,從時間複雜度上看,AC 自動機匹配的效率跟 Trie 樹一樣啊。實際上,因爲失效指針可能大部分情況下都指向 root 節點,所以絕大部分情況下,在 AC 自動機上做匹配的效率要遠高於剛剛計算出的比較寬泛的時間複雜度。只有在極端情況下,如圖所示,AC 自動機的性能纔會退化的跟 Trie 樹一樣。

九,貪心算法

1,使用貪心算法的例子

(1)分糖果

我們有 m 個糖果和 n 個孩子。我們現在要把糖果分給這些孩子喫,但是糖果少,孩子多(m<n),所以糖果只能分配給一部分孩子。每個糖果的大小不等,這 m 個糖果的大小分別是 s1,s2,s3,……,sm。除此之外,每個孩子對糖果大小的需求也是不一樣的,只有糖果的大小大於等於孩子的對糖果大小的需求的時候,孩子纔得到滿足。假設這 n 個孩子對糖果大小的需求分別是 g1,g2,g3,……,gn。我的問題是,如何分配糖果,能儘可能滿足最多數量的孩子?

我們現在來看看如何用貪心算法來解決。對於一個孩子來說,如果小的糖果可以滿足,我們就沒必要用更大的糖果,這樣更大的就可以留給其他對糖果大小需求更大的孩子。另一方面,對糖果大小需求小的孩子更容易被滿足,所以,我們可以從需求小的孩子開始分配糖果。因爲滿足一個需求大的孩子跟滿足一個需求小的孩子,對我們期望值的貢獻是一樣的。我們每次從剩下的孩子中,找出對糖果大小需求最小的,然後發給他剩下的糖果中能滿足他的最小的糖果,這樣得到的分配方案,也就是滿足的孩子個數最多的方案。

(2)區間覆蓋

假設我們有 n 個區間,區間的起始端點和結束端點分別是 [l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。我們從這 n 個區間中選出一部分區間,這部分區間滿足兩兩不相交(端點相交的情況不算相交),最多能選出多少個區間呢?

這個問題的解決思路是這樣的:我們假設這 n 個區間中最左端點是 lmin,最右端點是 rmax。這個問題就相當於,我們選擇幾個不相交的區間,從左到右將 [lmin, rmax] 覆蓋上。我們按照起始端點從小到大的順序對這 n 個區間排序。我們每次選擇的時候,左端點跟前面的已經覆蓋的區間不重合的,右端點又儘量小的,這樣可以讓剩下的未覆蓋區間儘可能的大,就可以放置更多的區間。這實際上就是一種貪心的選擇方法。

(3)霍夫曼編碼

假設我有一個包含 1000 個字符的文件,每個字符佔 1 個 byte(1byte=8bits),存儲這 1000 個字符就一共需要 8000bits,那有沒有更加節省空間的存儲方式呢?

假設我們通過統計分析發現,這 1000 個字符中只包含 6 種不同字符,假設它們分別是 a、b、c、d、e、f。而 3 個二進制位(bit)就可以表示 8 個不同的字符,所以,爲了儘量減少存儲空間,每個字符我們用 3 個二進制位來表示。那存儲這 1000 個字符只需要 3000bits 就可以了,比原來的存儲方式節省了很多空間。不過,還有沒有更加節省空間的存儲方式呢?

a(000)、b(001)、c(010)、d(011)、e(100)、f(101)

霍夫曼編碼是一種十分有效的編碼方法,廣泛用於數據壓縮中,其壓縮率通常在 20%~90% 之間。霍夫曼編碼不僅會考察文本中有多少個不同字符,還會考察每個字符出現的頻率,根據頻率的不同,選擇不同長度的編碼。霍夫曼編碼試圖用這種不等長的編碼方法,來進一步增加壓縮的效率。如何給不同頻率的字符選擇不同長度的編碼呢?根據貪心的思想,我們可以把出現頻率比較多的字符,用稍微短一些的編碼;出現頻率比較少的字符,用稍微長一些的編碼。

假設這 6 個字符出現的頻率從高到低依次是 a、b、c、d、e、f。我們把它們編碼下面這個樣子,任何一個字符的編碼都不是另一個的前綴,在解壓縮的時候,我們每次會讀取儘可能長的可解壓的二進制串,所以在解壓縮的時候也不會歧義。經過這種編碼壓縮之後,這 1000 個字符只需要 2100bits 就可以了。

我們把每個字符看作一個節點,並且輔帶着把頻率放到優先級隊列中。我們從隊列中取出頻率最小的兩個節點 A、B,然後新建一個節點 C,把頻率設置爲兩個節點的頻率之和,並把這個新節點 C 作爲節點 A、B 的父節點。最後再把 C 節點放入到優先級隊列中。重複這個過程,直到隊列中沒有數據。

現在,我們給每一條邊加上畫一個權值,指向左子節點的邊我們統統標記爲 0,指向右子節點的邊,我們統統標記爲 1,那從根節點到葉節點的路徑就是葉節點對應字符的霍夫曼編碼。

2,不能用貪心算法的例子

找零問題

即使有面值爲一元的幣值也不行:考慮幣值爲100,99和1的幣種,每種各一百張,找396元。動態規劃可求出四張99元,但貪心算法解出需三張一百和96張一元。

3,思考題

在一個非負整數 a 中,我們希望從中移除 k 個數字,讓剩下的數字值最小,如何選擇移除哪 k 個數字呢?

由最高位開始,比較低一位數字,如高位大,移除,若高位小,則向右移一位繼續比較兩個數字,直到高位大於低位則移除,循環k次,如:
4556847594546移除5位:455647594546-->45547594546-->4547594546-->4447594546-->444594546

十,分治算法

分治算法(divide and conquer)的核心思想其實就是四個字,分而治之 ,也就是將原問題劃分成 n 個規模較小,並且結構與原問題相似的子問題,遞歸地解決這些子問題,然後再合併其結果,就得到原問題的解。這個定義看起來有點類似遞歸的定義。關於分治和遞歸的區別,我們在排序(下)的時候講過,分治算法是一種處理問題的思想,遞歸是一種編程技巧。實際上,分治算法一般都比較適合用遞歸來實現。分治算法的遞歸實現中,每一層遞歸都會涉及這樣三個操作:

  • 分解:將原問題分解成一系列子問題;
  • 解決:遞歸地求解各個子問題,若子問題足夠小,則直接求解;
  • 合併:將子問題的結果合併成原問題。

分治算法能解決的問題,一般需要滿足下面這幾個條件:

  • 原問題與分解成的小問題具有相同的模式;
  • 原問題分解成的子問題可以獨立求解,子問題之間沒有相關性,這一點是分治算法跟動態規劃的明顯區別,等我們講到動態規劃的時候,會詳細對比這兩種算法;
  • 具有分解終止條件,也就是說,當問題足夠小時,可以直接求解;
  • 可以將子問題合併成原問題,而這個合併操作的複雜度不能太高,否則就起不到減小算法總體複雜度的效果了。

1,分治算法應用舉例分析

假設我們有 n 個數據,我們期望數據從小到大排列,那完全有序的數據的有序度就是 n(n-1)/2,逆序度等於 0;相反,倒序排列的數據的有序度就是 0,逆序度是 n(n-1)/2。除了這兩種極端情況外,我們通過計算有序對或者逆序對的個數,來表示數據的有序度或逆序度。

我們用分治算法來試試。我們套用分治的思想來求數組 A 的逆序對個數。我們可以將數組分成前後兩半 A1 和 A2,分別計算 A1 和 A2 的逆序對個數 K1 和 K2,然後再計算 A1 與 A2 之間的逆序對個數 K3。那數組 A 的逆序對個數就等於 K1+K2+K3。

我們前面講過,使用分治算法其中一個要求是,子問題合併的代價不能太大,否則就起不了降低時間複雜度的效果了。那回到這個問題,如何快速計算出兩個子問題 A1 與 A2 之間的逆序對個數呢? 這裏就要藉助歸併排序算法了。

歸併排序中有一個非常關鍵的操作,就是將兩個有序的小數組,合併成一個有序的數組。實際上,在這個合併的過程中,我們就可以計算這兩個小數組的逆序對個數了。每次合併操作,我們都計算逆序對個數,把這些計算出來的逆序對個數求和,就是這個數組的逆序對個數了。


private int num = 0; // 全局變量或者成員變量

public int count(int[] a, int n) {
  num = 0;
  mergeSortCounting(a, 0, n-1);
  return num;
}

private void mergeSortCounting(int[] a, int p, int r) {
  if (p >= r) return;
  int q = (p+r)/2;
  mergeSortCounting(a, p, q);
  mergeSortCounting(a, q+1, r);
  merge(a, p, q, r);
}

private void merge(int[] a, int p, int q, int r) {
  int i = p, j = q+1, k = 0;
  int[] tmp = new int[r-p+1];
  while (i<=q && j<=r) {
    if (a[i] <= a[j]) {
      tmp[k++] = a[i++];
    } else {
      num += (q-i+1); // 統計p-q之間,比a[j]大的元素個數
      tmp[k++] = a[j++];
    }
  }
  while (i <= q) { // 處理剩下的
    tmp[k++] = a[i++];
  }
  while (j <= r) { // 處理剩下的
    tmp[k++] = a[j++];
  }
  for (i = 0; i <= r-p; ++i) { // 從tmp拷貝回a
    a[p+i] = tmp[i];
  }
}

 2,分治思想在海量數據處理中的應用

比如,給 10GB 的訂單文件按照金額排序這樣一個需求,看似是一個簡單的排序問題,但是因爲數據量大,有 10GB,而我們的機器的內存可能只有 2、3GB 這樣子,無法一次性加載到內存,也就無法通過單純地使用快排、歸併等基礎算法來解決了。要解決這種數據量大到內存裝不下的問題,我們就可以利用分治的思想。我們可以將海量的數據集合根據某種方法,劃分爲幾個小的數據集合,每個小的數據集合單獨加載到內存來解決,然後再將小數據集合合併成大數據集合。實際上,利用這種分治的處理思路,不僅僅能克服內存的限制,還能利用多線程或者多機處理,加快處理的速度。

比如剛剛舉的那個例子,給 10GB 的訂單排序,我們就可以先掃描一遍訂單,根據訂單的金額,將 10GB 的文件劃分爲幾個金額區間。比如訂單金額爲 1 到 100 元的放到一個小文件,101 到 200 之間的放到另一個文件,以此類推。這樣每個小文件都可以單獨加載到內存排序,最後將這些有序的小文件合併,就是最終有序的 10GB 訂單數據了。

十一,回溯算法

1,八皇后問題

我們有一個 8x8 的棋盤,希望往裏放 8 個棋子(皇后),每個棋子所在的行、列、對角線都不能有另一個棋子。

我們把這個問題劃分成 8 個階段,依次將 8 個棋子放到第一行、第二行、第三行……第八行。在放置的過程中,我們不停地檢查當前的方法,是否滿足要求。如果滿足,則跳到下一行繼續放置棋子;如果不滿足,那就再換一種方法,繼續嘗試。


int[] result = new int[8];//全局或成員變量,下標表示行,值表示queen存儲在哪一列
public void cal8queens(int row) { // 調用方式:cal8queens(0);
  if (row == 8) { // 8個棋子都放置好了,打印結果
    printQueens(result);
    return; // 8行棋子都放好了,已經沒法再往下遞歸了,所以就return
  }
  for (int column = 0; column < 8; ++column) { // 每一行都有8中放法
    if (isOk(row, column)) { // 有些放法不滿足要求
      result[row] = column; // 第row行的棋子放到了column列
      cal8queens(row+1); // 考察下一行
    }
  }
}

private boolean isOk(int row, int column) {//判斷row行column列放置是否合適
  int leftup = column - 1, rightup = column + 1;
  for (int i = row-1; i >= 0; --i) { // 逐行往上考察每一行
    if (result[i] == column) return false; // 第i行的column列有棋子嗎?
    if (leftup >= 0) { // 考察左上對角線:第i行leftup列有棋子嗎?
      if (result[i] == leftup) return false;
    }
    if (rightup < 8) { // 考察右上對角線:第i行rightup列有棋子嗎?
      if (result[i] == rightup) return false;
    }
    --leftup; ++rightup;
  }
  return true;
}

private void printQueens(int[] result) { // 打印出一個二維矩陣
  for (int row = 0; row < 8; ++row) {
    for (int column = 0; column < 8; ++column) {
      if (result[row] == column) System.out.print("Q ");
      else System.out.print("* ");
    }
    System.out.println();
  }
  System.out.println();
}

2,0-1 揹包

我們有一個揹包,揹包總的承載重量是 Wkg。現在我們有 n 個物品,每個物品的重量不等,並且不可分割。我們現在期望選擇幾件物品,裝載到揹包中。在不超過揹包所能裝載重量的前提下,如何讓揹包中物品的總重量最大?

public int maxW = Integer.MIN_VALUE; //存儲揹包中物品總重量的最大值
// cw表示當前已經裝進去的物品的重量和;i表示考察到哪個物品了;
// w揹包重量;items表示每個物品的重量;n表示物品個數
// 假設揹包可承受重量100,物品個數10,物品重量存儲在數組a中,那可以這樣調用函數:
// f(0, 0, a, 10, 100)
public void f(int i, int cw, int[] items, int n, int w) {
  if (cw == w || i == n) { // cw==w表示裝滿了;i==n表示已經考察完所有的物品
    if (cw > maxW) maxW = cw;
    return;
  }
  f(i+1, cw, items, n, w);
  if (cw + items[i] <= w) {// 已經超過可以揹包承受的重量的時候,就不要再裝了
    f(i+1,cw + items[i], items, n, w);
  }
}

3. 正則表達式

正則表達式中,最重要的就是通配符,通配符結合在一起,可以表達非常豐富的語義。爲了方便講解,我假設正則表達式中只包含“*”和“?”這兩種通配符,並且對這兩個通配符的語義稍微做些改變,其中,“*”匹配任意多個(大於等於 0 個)任意字符,“?”匹配零個或者一個任意字符。基於以上背景假設,我們看下,如何用回溯算法,判斷一個給定的文本,能否跟給定的正則表達式匹配?

我們依次考察正則表達式中的每個字符,當是非通配符時,我們就直接跟文本的字符進行匹配,如果相同,則繼續往下處理;如果不同,則回溯。如果遇到特殊字符的時候,我們就有多種處理方式了,也就是所謂的岔路口,比如“*”有多種匹配方案,可以匹配任意個文本串中的字符,我們就先隨意的選擇一種匹配方案,然後繼續考察剩下的字符。如果中途發現無法繼續匹配下去了,我們就回到這個岔路口,重新選擇一種匹配方案,然後再繼續匹配剩下的字符。

public class Pattern {
  private boolean matched = false;
  private char[] pattern; // 正則表達式
  private int plen; // 正則表達式長度

  public Pattern(char[] pattern, int plen) {
    this.pattern = pattern;
    this.plen = plen;
  }

  public boolean match(char[] text, int tlen) { // 文本串及長度
    matched = false;
    rmatch(0, 0, text, tlen);
    return matched;
  }

  private void rmatch(int ti, int pj, char[] text, int tlen) {
    if (matched) return; // 如果已經匹配了,就不要繼續遞歸了
    if (pj == plen) { // 正則表達式到結尾了
      if (ti == tlen) matched = true; // 文本串也到結尾了
      return;
    }
    if (pattern[pj] == '*') { // *匹配任意個字符
      for (int k = 0; k <= tlen-ti; ++k) {
        rmatch(ti+k, pj+1, text, tlen);
      }
    } else if (pattern[pj] == '?') { // ?匹配0個或者1個字符
      rmatch(ti, pj+1, text, tlen);
      rmatch(ti+1, pj+1, text, tlen);
    } else if (ti < tlen && pattern[pj] == text[ti]) { // 純字符匹配纔行
      rmatch(ti+1, pj+1, text, tlen);
    }
  }
}

 

 

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