Swift 演算法實戰之路:動態規劃

故胤道長發表於2016-09-01
111721232-e8e8069e07e22728

之前的演算法之路,分析的問題大多比較具體簡單 — 可以直接套用一種方法解決。今天要講的動態規劃,其面對的問題通常是無法一蹴而就,需要把複雜的問題分解成簡單具體的小問題,然後通過求解簡單問題,去推出複雜問題的最終解。

121721232-92891135f072ceb6
Domino Effect

形象的理解就是為了推倒一系列紙牌中的第100張紙牌,那麼我們就要先推倒第1張,再依靠多米諾骨牌效應,去推倒第100張。

例項講解

斐波拉契數列是這樣一個數列:1, 1, 2, 3, 5, 8, … 除了第一個和第二個數字為1以外,其他數字都為之前兩個數字之和。現在要求第100個數字是多少。

這道題目乍一看是一個數學題,那麼要求第100個數字,很簡單,一個個數字算下去就是了。假設F(n)表示第n個斐波拉契數列的數字,那麼我們易得公式F(n) = F(n – 1) + F(n – 2),n >= 2,下面就是體力活。當然這道題轉化成程式碼也不是很難,最粗暴的解法如下:

用動態規劃怎麼寫呢?首先要明白動態規劃有以下幾個專有名詞:

  1. 初始狀態,即此問題的最簡單子問題的解。在斐波拉契數列裡,最簡單的問題是,一開始給定的第一個數和第二個數是幾?自然我們可以得出是1
  2. 狀態轉移方程,即第n個問題的解和之前的 n – m 個問題解的關係。在這道題目裡,我們已經有了狀態轉移方程F(n) = F(n – 1) + F(n – 2)

所以這題要求F(100),那我們只要知道F(99)和F(98)就行了;想知道F(99),我們只要知道F(98)和F(97)就行了;想要知道F(98),我們需要知道F(97)和F(96)。。。,以此類推,我們最後只要知道F(2)和F(1)的值,就可以推出F(100)。而F(2)和F(1)正是我們所謂的初始狀態,即 F(2) = 1,F(1) =1。所以程式碼如下:

131721232-4baa9a0caa516275
斐波拉契數列的動態規劃

這種遞迴的寫法看起來簡潔明瞭,但是上面寫法有一個問題:我們要求F(100),那麼要計算F(99)和F(98);要計算F(99),我們要計算F(98)和F(97)。。。大家已經發現到這一步,我們已經重複計算兩次F(98)了。而之後的計算中還會有大量的重複,這使得這個解法的複雜度非常之高。解決方法就是,用一個陣列,將計算過的值存起來,這樣可以用空間上的犧牲來換取時間上的效率提高,程式碼如下:

動態轉移雖然看上去十分高大上,但是它也存在兩個致命缺點:

  • 棧溢位:每一次遞迴,程式都會將當前的計算壓入棧中。隨著遞迴深度的加深,棧的高度也越來越高,直到超過計算機分配給當前程式的記憶體容量,程式就會崩潰。
  • 資料溢位:因為動態規劃是一種由簡至繁的過程,其中積蓄的資料很有可能超過系統當前資料型別的最大值,導致崩潰。

而這兩個bug,我們上面這道求解斐波拉契數列第100個數的題目就都遇到了。

  • 首先,遞迴的次數很多,我們要從F(100) = F(99) + F(98) ,一直推理到F(3) = F(2) + F(1),這樣很容易造成棧溢位。
  • 其次,F(100)應該是一個很大的數。實際上F(40)就已經突破一億,F(100)一定會造成整型資料溢位。

當然,這兩個bug也有相應的解決方法。對付棧溢位,我們可以把遞迴寫成迴圈的形式(所有的遞迴都可改寫成迴圈);對付資料溢位,我們可以在程式每次計算中,加入資料溢位的檢測,適時終止計算,丟擲異常。

iOS實戰演練

筆者以前在矽谷參加了一個hackthon大賽,當時是要做一個掃描英文單詞出翻譯的app。它大概長這樣:

141721232-e0b25b94ec28a994
掃描單詞出翻譯

當時這個App其他部分執行非常流暢,就是在開啟攝像頭掃描單詞的時候,會出現誤讀的情況。比如手寫的“price”,機器會識別成“pr1ce”,從而無法對其進行正確的翻譯。筆者對這種情況進行了相應的優化處理,方法如下:

  1. 縮小誤差範圍:將所有的單詞構造成字首樹。然後對於掃描的內容,搜尋出相應可能的單詞。具體做法可以參考《Swift 演算法實戰之路:深度和廣度優先搜尋》一文中搜尋單詞的方法。
  2. 計算出最接近的單詞:假如上一步,我們已經有了10個可能的單詞,那麼怎麼確定最接近真實情況的單詞呢?這裡我們要定義兩個單詞的距離 — 從第一個單詞wordA,到第二個單詞wordB,有三種操作:
    • 刪除一個字元
    • 新增一個字元
    • 替換一個字元

綜合上述三種操作,用最少步驟將單詞wordA變到單詞wordB,我們就稱這個值為兩個單詞之間的距離。比如 pr1ce -> price,只需要將 1 替換為 i 即可,所以兩個單詞之間的距離為1。pr1ce -> prize,要將 1 替換為 i ,再將 c 替換為 z ,所以兩個單詞之間的距離為2。相比於prize,price更為接近原來的單詞。

現在問題轉變為實現下面這個方法:

要解決這個複雜的問題,我們不如從一個簡單的例子出發:求“abce”到“abdf”之間的距離。它們兩之間的距離,無非是下面三種情況中的一種。

  • 刪除一個字元:假如已知 wordDistance("abc", "abdf") ,那麼“abce”只需要刪除一個字元到達“abc”,然後就可以得知“abce”到“abdf”之間的距離。
  • 新增一個字元:假如已知 wordDistance("abce", "abd"),那麼我們只要讓“abd”新增一個字元到達“abdf”即可求出最終解。
  • 替換一個字元:假如已知 wordDistance("abc", "abd"),那麼就可以依此推出 wordDistance("abce", "abde") = wordDistance("abc", "abd")。故而只要將末尾的“e”替換成”f”,就可以得出wordDistance("abce", "abdf")

這樣我們就可以發現,求解任意兩個單詞之間的距離,只要知道之前單片語合的距離即可。我們用dp[i][j]表示第一個字串wordA[0…i] 和第2個字串wordB[0…j] 的最短編輯距離,那麼這個動態規劃的兩個重要引數分別是:

  • 初始狀態:dp[0][j] = j,dp[i][0] = i
  • 狀態轉移方程:dp[i][j] = min(dp[i – 1][j – 1], dp[i – 1][j], dp[i][j – 1]) + 1

再舉例解釋一下,”abc”到”xyz”,dp[2][1]就是”ab”到”x”的距離,不難看出是2;dp[1][2]就是”a”到”xy”的距離,是2;dp[1][1]也就是”a”到”x”的距離,很顯然就是1。所以dp[2][2]即”ab”到”xy”的距離是min(dp[2][1], dp[1][2], dp[1][1]) + 1就是2.

有了初始狀態和狀態轉移方程,那麼動態規劃的程式碼就出來了:

用動態規劃計算出單詞之間的距離之後,在做一些相應的優化,就可以準確的識別出掃描的單詞。

全系列總結

動態規劃算是演算法進階中比較重要的一環,它的思想就是把複雜問題化為簡單具體問題,然後分析出初始狀態和狀態轉移方程,從而推出最終解。也許它在實際程式設計或是iOS開發中出現頻率不高,但是這種刪繁就簡的思路,卻可以應用在生活或者工作中的方方面面。

Swift演算法實戰系列前前後後一共9篇:

  • 第1篇是分析Swift的基本語法。談的是如何快速有效書寫Swift的技巧;
  • 第2 – 5篇主要是講各種資料結構。分別講了陣列、字串、字典、連結串列、棧、佇列、二叉樹;
  • 第6 – 9篇分析的是各種基本演算法。搜尋、排序、深度和廣度優先搜尋、遞迴和動態規劃都有涉及。

整個系列的目的就是用Swift說清楚最基本的演算法和資料結構知識,所以語言儘可能通俗易懂。動態規劃再往上講,就比較陽春白雪了,故而至此收筆。感謝大家的閱讀和指教。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

Swift 演算法實戰之路:動態規劃 Swift 演算法實戰之路:動態規劃

相關文章