重學資料結構和演算法(三)之遞迴、二分、字串匹配

夢和遠方發表於2021-04-23

最近學習了極客時間的《資料結構與演算法之美》很有收穫,記錄總結一下。
歡迎學習老師的專欄:資料結構與演算法之美
程式碼地址:https://github.com/peiniwan/Arithmetic

遞迴

週末你帶著女朋友去電影院看電影,女朋友問你,我們們現在坐在第幾排啊?電影院裡面太黑了,看不清,沒法數,現在你怎麼辦?別忘了你是程式設計師,這個可難不倒你,遞迴就開始排上用場了。
於是你就問前面一排的人他是第幾排,你想只要在他的數字上加一,就知道自己在哪一排了。但是,前面的人也看不清啊,所以他也問他前面的人。就這樣一排一排往前問,直到問到第一排的人,說我在第一排,然後再這樣一排一排再把數字傳回來。直到你前面的人告訴你他在哪一排,於是你就知道答案了。
我們用遞推公式將它表示出來就是這樣的:

f(n)=f(n-1)+1 其中,f(1)=1

f(n) 表示你想知道自己在哪一排,f(n-1) 表示前面一排所在的排數,f(1)=1 表示第一排的人知道自己在第一排。有了這個遞推公式,我們就可以很輕鬆地將它改為遞迴程式碼,如下:

int f(int n) {
  if (n == 1) return 1;
  return f(n-1) + 1;
}

遞迴需要滿足的三個條件

  1. 一個問題的解可以分解為幾個子問題的解
  2. 這個問題與分解之後的子問題,除了資料規模不同,求解思路完全一樣
  3. 存在遞迴終止條件
    第一排的人不需要再繼續詢問任何人,就知道自己在哪一排,也就是 f(1)=1,這就是遞迴的終止條件。

如何編寫遞迴程式碼?

寫遞迴程式碼最關鍵的是寫出遞推公式,找到終止條件
爬樓梯

int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}

寫遞迴程式碼的關鍵就是找到如何將大問題分解為小問題的規律,並且基於此寫出遞推公式,然後再推敲終止條件,最後將遞推公式和終止條件翻譯成程式碼。
人腦幾乎沒辦法把整個“遞”和“歸”的過程一步一步都想清楚。計算機擅長做重複的事情,所以遞迴正和它的胃口。
對於遞迴程式碼,這種試圖想清楚整個遞和歸過程的做法,實際上是進入了一個思維誤區。很多時候,我們理解起來比較吃力,主要原因就是自己給自己製造了這種理解障礙。那正確的思維方式應該是怎樣的呢?

如果一個問題 A 可以分解為若干子問題 B、C、D,你可以假設子問題 B、C、D 已經解決,在此基礎上思考如何解決問題 A。而且,你只需要思考問題 A 與子問題 B、C、D 兩層之間的關係即可,不需要一層一層往下思考子問題與子子問題,子子問題與子子子問題之間的關係。遮蔽掉遞迴細節,這樣子理解起來就簡單多了。
因此,編寫遞迴程式碼的關鍵是,只要遇到遞迴,我們就把它抽象成一個遞推公式,不用想一層層的呼叫關係,不要試圖用人腦去分解遞迴的每個步驟。
不要陷入思維誤區。

遞迴程式碼要警惕堆疊溢位

函式呼叫會使用棧來儲存臨時變數。每呼叫一個函式,都會將臨時變數封裝為棧幀壓入記憶體棧,等函式執行完成返回時,才出棧。系統棧或者虛擬機器棧空間一般都不大。如果遞迴求解的資料規模很大,呼叫層次很深,一直壓入棧,就會有堆疊溢位的風險

那麼,如何避免出現堆疊溢位呢?

// 全域性變數,表示遞迴的深度。
int depth = 0;

int f(int n) {
  ++depth;
  if (depth > 1000) throw exception;
  
  if (n == 1) return 1;
  return f(n-1) + 1;
}

但這種做法並不能完全解決問題,因為最大允許的遞迴深度跟當前執行緒剩餘的棧空間大小有關,事先無法計算。如果實時計算,程式碼過於複雜,就會影響程式碼的可讀性。所以,如果最大深度比較小,比如 10、50,就可以用這種方法,否則這種方法並不是很實用。

遞迴程式碼要警惕重複計算


為了避免重複計算,我們可以通過一個資料結構(比如雜湊表)來儲存已經求解過的 f(k)。當遞迴呼叫到 f(k) 時,先看下是否已經求解過了。如果是,則直接從雜湊表中取值返回,不需要重複計算,這樣就能避免剛講的問題了。

public int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  
  // hasSolvedList可以理解成一個Map,key是n,value是f(n)
  if (hasSolvedList.containsKey(n)) {
    return hasSolvedList.get(n);
  }
  
  int ret = f(n-1) + f(n-2);
  hasSolvedList.put(n, ret);
  return ret;
}

電影院遞迴程式碼,空間複雜度並不是 O(1),而是 O(n)。

怎麼將遞迴程式碼改寫為非遞迴程式碼?

遞迴有利有弊,利是遞迴程式碼的表達力很強,寫起來非常簡潔;而弊就是空間複雜度高、有堆疊溢位的風險、存在重複計算、過多的函式呼叫會耗時較多等問題

電影院修改

int f(int n) {
  int ret = 1;
  for (int i = 2; i <= n; ++i) {
    ret = ret + 1;
  }
  return ret;
}

但是這種思路實際上是將遞迴改為了“手動”遞迴,本質並沒有變,而且也並沒有解決前面講到的某些問題,徒增了實現的複雜度。

如何找到“最終推薦人”?

推薦註冊返佣金的這個功能我想你應該不陌生吧?現在很多 App 都有這個功能。這個功能中,使用者 A 推薦使用者 B 來註冊,使用者 B 又推薦了使用者 C 來註冊。我們可以說,使用者 C 的“最終推薦人”為使用者 A,使用者 B 的“最終推薦人”也為使用者 A,而使用者 A 沒有“最終推薦人”。

long findRootReferrerId(long actorId) {
  Long referrerId = select referrer_id from [table] where actor_id = actorId;
  if (referrerId == null) return actorId;
  return findRootReferrerId(referrerId);
}

不過在實際專案中,上面的程式碼並不能工作,為什麼呢?這裡面有兩個問題。
第一,如果遞迴很深,可能會有堆疊溢位的問題。
第二,如果資料庫裡存在髒資料,我們還需要處理由此產生的無限遞迴問題。比如 demo 環境下資料庫中,測試工程師為了方便測試,會人為地插入一些資料,就會出現髒資料。如果 A 的推薦人是 B,B 的推薦人是 C,C 的推薦人是 A,這樣就會發生死迴圈。

第一個問題,我前面已經解答過了,可以用限制遞迴深度來解決。第二個問題,也可以用限制遞迴深度來解決。不過,還有一個更高階的處理方法,就是自動檢測 A-B-C-A 這種“環”的存在。如何來檢測環的存在呢?

除錯遞迴
我們平時除錯程式碼喜歡使用 IDE 的單步跟蹤功能,像規模比較大、遞迴層次很深的遞迴程式碼,幾乎無法使用這種除錯方式。
除錯遞迴:

  1. 列印日誌發現,遞迴值。
  2. 結合條件斷點進行除錯。
public class Recursion {
    /**
     * 求和
     */
    public static int summation(int num) {
        if (num == 1) {
            return 1;
        }
        return num + summation(num - 1);
    }


    /**
     * 求二進位制
     */
    public static int binary(int num) {
        StringBuilder sb = new StringBuilder();
        if (num > 0) {
            summation(num / 2);
            int i = num % 2;
            sb.append(i);
        }
        System.out.println(sb.toString());
        return -1;
    }

    /**
     * 求n的階乘
     */
    public int f(int n) {
        if (n == 1) {
            return n;
        } else {
            return n * f(n - 1);
        }
    }
}

二分查詢

有點像分治,底層必須依賴陣列,並且還要求資料是有序的。二分查詢更適合處理靜態資料,也就是沒有頻繁的資料插入、刪除操作。

這是一個等比數列。其中 n/2k=1 時,k 的值就是總共縮小的次數。而每一次縮小操作只涉及兩個資料的大小比較,所以,經過了 k 次區間縮小操作,時間複雜度就是 O(k)。通過 n/2k=1,我們可以求得 k=log2n,所以時間複雜度就是 O(logn)。

    public int binarySearch(int[] arr, int k) {
        if (arr.length == 0) {
            return -1;
        }
        if (arr[0] == k) {
            return 0;
        }
        int a = 0;
        int b = arr.length - 1;
        while (a <= b) {
            int m = a + (b - a) / 2;
            if (k < arr[m]) {
                b = m-1;
            } else if (k > arr[m]) {
                a = m + 1;
            } else {
                return m;
            }
        }
        return -1;
    }

容易出錯的 3 個地方

  1. 迴圈退出條件
    注意是 low<=high,而不是 low<high。
  2. mid 的取值
    我們可以將這裡的除以 2 操作轉化成位運算 low+((high-low)>>1)。因為相比除法運算來說,計算機處理位運算要快得多。
  3. low 和 high 的更新
    low=mid+1,high=mid-1。注意這裡的 +1 和 -1,如果直接寫成 low=mid 或者 high=mid,就可能會發生死迴圈。比如,當 high=3,low=3 時,如果 a[3] 不等於 value,就會導致一直迴圈不退出。

二分查詢除了用迴圈來實現,還可以用遞迴來實現


// 二分查詢的遞迴實現
public int bsearch(int[] a, int n, int val) {
  return bsearchInternally(a, 0, n - 1, val);
}

private int bsearchInternally(int[] a, int low, int high, int value) {
  if (low > high) return -1;

  int mid =  low + ((high - low) >> 1);
  if (a[mid] == value) {
    return mid;
  } else if (a[mid] < value) {
    return bsearchInternally(a, mid+1, high, value);
  } else {
    return bsearchInternally(a, low, mid-1, value);
  }
}

二分查詢應用場景的侷限性

首先,二分查詢依賴的是順序表結構,簡單點說就是陣列
陣列按照下標隨機訪問資料的時間複雜度是 O(1),而連結串列隨機訪問的時間複雜度是 O(n)。所以,如果資料使用連結串列儲存,二分查詢的時間複雜就會變得很高。
其次,二分查詢針對的是有序資料。
資料必須是有序的。如果資料沒有序,我們需要先排序
如果我們的資料集合有頻繁的插入和刪除操作,要想用二分查詢,要麼每次插入、刪除操作之後保證資料仍然有序,要麼在每次二分查詢之前都先進行排序。針對這種動態資料集合,無論哪種方法,維護有序的成本都是很高的。
所以,二分查詢只能用在插入、刪除操作不頻繁,一次排序多次查詢的場景中。針對動態變化的資料集合,二分查詢將不再適用。那針對動態資料集合,如何在其中快速查詢某個資料呢?別急,等到二叉樹那一節我會詳細講。
再次,資料量太小不適合二分查詢。
如果要處理的資料量很小,完全沒有必要用二分查詢,順序遍歷就足夠了。比如我們在一個大小為 10 的陣列中查詢一個元素,不管用二分查詢還是順序遍歷,查詢速度都差不多。只有資料量比較大的時候,二分查詢的優勢才會比較明顯。
最後,資料量太大也不適合二分查詢。
二分查詢的底層需要依賴陣列這種資料結構,而陣列為了支援隨機訪問的特性,要求記憶體空間連續,對記憶體的要求比較苛刻。比如,我們有 1GB 大小的資料,如果希望用陣列來儲存,那就需要 1GB 的連續記憶體空間。

如何在 1000 萬個整數中快速查詢某個整數?
這個問題並不難。我們的記憶體限制是 100MB,每個資料大小是 8 位元組,最簡單的辦法就是將資料儲存在陣列中,記憶體佔用差不多是 80MB,符合記憶體的限制。藉助今天講的內容,我們可以先對這 1000 萬資料從小到大排序,然後再利用二分查詢演算法,就可以快速地查詢想要的資料了。

雖然大部分情況下,用二分查詢可以解決的問題,用雜湊表、二叉樹都可以解決。但是,我們後面會講,不管是雜湊表還是二叉樹,都會需要比較多的額外的記憶體空間。如果用雜湊表或者二叉樹來儲存這 1000 萬的資料,用 100MB 的記憶體肯定是存不下的。而二分查詢底層依賴的是陣列,除了資料本身之外,不需要額外儲存其他資訊,是最省記憶體空間的儲存方式,所以剛好能在限定的記憶體大小下解決這個問題。

二分查詢變形

十個二分九個錯
上一節講的只是二分查詢中最簡單的一種情況,在不存在重複元素的有序陣列中,查詢值等於給定值的元素。最簡單的二分查詢寫起來確實不難,但是,二分查詢的變形問題就沒那麼好寫了。

變體一:查詢第一個值等於給定值的元素
如下面這樣一個有序陣列,其中,a[5],a[6],a[7] 的值都等於 8,是重複的資料。我們希望查詢第一個等於 8 的資料,也就是下標是 5 的元素。

如果我們用上一節課講的二分查詢的程式碼實現,首先拿 8 與區間的中間值 a[4] 比較,8 比 6 大,於是在下標 5 到 9 之間繼續查詢。下標 5 和 9 的中間位置是下標 7,a[7] 正好等於 8,所以程式碼就返回了。
儘管 a[7] 也等於 8,但它並不是我們想要找的第一個等於 8 的元素,因為第一個值等於 8 的元素是陣列下標為 5 的元素。

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == 0) || (a[mid - 1] != value)) return mid;
      else high = mid - 1;
    }
  }
  return -1;
}

變體二:查詢最後一個值等於給定值的元素
前面的問題是查詢第一個值等於給定值的元素,我現在把問題稍微改一下,查詢最後一個值等於給定值的元素,又該如何做呢?


public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

變體三:查詢第一個大於等於給定值的元素
在有序陣列中,查詢第一個大於等於給定值的元素。比如,陣列中儲存的這樣一個序列:3,4,6,7,10。如果查詢第一個大於等於 5 的元素,那就是 6。

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] >= value) {
      if ((mid == 0) || (a[mid - 1] < value)) return mid;
      else high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return -1;
}

變體四:查詢最後一個小於等於給定值的元素
我們來看最後一種二分查詢的變形問題,查詢最後一個小於等於給定值的元素。比如,陣列中儲存了這樣一組資料:3,5,6,8,9,10。最後一個小於等於 7 的元素就是 6。


public int bsearch7(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

字串匹配

我們用的最多的就是程式語言提供的字串查詢函式,比如 Java 中的 indexOf(),Python 中的 find() 函式等,它們底層就是依賴接下來要講的字串匹配演算法。
BF 演算法和 RK 演算法、BM 演算法和 KMP 演算法。

BF 演算法

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

我們在字串 A 中查詢字串 B,那字串 A 就是主串,字串 B 就是模式串。我們把主串的長度記作 n,模式串的長度記作 m。因為我們是在主串中查詢模式串,所以 n>m。

BF 演算法的思想可以用一句話來概括,那就是,我們在主串中,檢查起始位置分別是 0、1、2…n-m 且長度為 m 的 n-m+1 個子串,看有沒有跟模式串匹配的(看圖)。

我們每次都比對 m 個字元,要比對 n-m+1 次,所以,這種演算法的最壞情況時間複雜度是 O(n* m)。

 /**
     * BF演算法
     * 檢查起始位置分別是 0、1、2…n-m 且長度為 m 的 n-m+1 個子串,看有沒有跟模式串匹配的
     */
    public static int bfFind(String S, String T, int pos) {
        char[] arr1 = S.toCharArray();
        char[] arr2 = T.toCharArray();
        int i = pos;
        int j = 0;
        while (i < arr1.length && j < arr2.length) {
            if (arr1[i] == arr2[j]) {
                i++;
                j++;
            } else {
                i = i - j + 1;
                j = 0;
            }
        }
        if (j == arr2.length) return i - j;
        else return -1;
    }

儘管理論上,BF 演算法的時間複雜度很高,是 O(n* m),但在實際的開發中,它卻是一個比較常用的字串匹配演算法。
第一,實際的軟體開發中,大部分情況下,模式串和主串的長度都不會太長。
第二,樸素字串匹配演算法思想簡單,程式碼實現也非常簡單。

RK 演算法

BF 演算法的升級版。
BF每次檢查主串與子串是否匹配,需要依次比對每個字元,所以 BF 演算法的時間複雜度就比較高,是 O(n* m)。我們對樸素的字串匹配演算法稍加改造,引入雜湊演算法,時間複雜度立刻就會降低。

RK 演算法的思路是這樣的:我們通過雜湊演算法對主串中的 n-m+1 個子串分別求雜湊值,然後逐個與模式串的雜湊值比較大小。如果某個子串的雜湊值與模式串相等,那就說明對應的子串和模式串匹配了(這裡先不考慮雜湊衝突的問題,後面我們會講到)。因為雜湊值是一個數字,數字之間比較是否相等是非常快速的,所以模式串和子串比較的效率就提高了。

比如要處理的字串只包含 a~z 這 26 個小寫字母,那我們就用二十六進位制來表示一個字串。我們把 a~z 這 26 個字元對映到 0~25 這 26 個數字,a 就表示 0,b 就表示 1,以此類推,z 表示 25。
在十進位制的表示法中,一個數字的值是通過下面的方式計算出來的。對應到二十六進位制,一個包含 a 到 z 這 26 個字元的字串,計算雜湊的時候,我們只需要把進位從 10 改成 26 就可以。

這種雜湊演算法有一個特點,在主串中,相鄰兩個子串的雜湊值的計算公式有一定關係。我這有個個例子,你先找一下規律,再來看我後面的講解。

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

相關文章