之前的演算法之路,分析的問題大多比較具體簡單 — 可以直接套用一種方法解決。今天要講的動態規劃,其面對的問題通常是無法一蹴而就,需要把複雜的問題分解成簡單具體的小問題,然後通過求解簡單問題,去推出複雜問題的最終解。
形象的理解就是為了推倒一系列紙牌中的第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 2 3 4 5 6 7 8 9 10 11 12 |
func Fib() -> Int { var prev = 0 var curr = 1 for _ in 1 ..< 100 { var temp = curr curr = prev + curr prev = temp } return curr } |
用動態規劃怎麼寫呢?首先要明白動態規劃有以下幾個專有名詞:
- 初始狀態,即此問題的最簡單子問題的解。在斐波拉契數列裡,最簡單的問題是,一開始給定的第一個數和第二個數是幾?自然我們可以得出是1
- 狀態轉移方程,即第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。所以程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func Fib(n: Int) -> Int { // 定義初始狀態 guard n > 0 else { return 0 } if n == 1 || n == 2 { return 1 } // 呼叫狀態轉移方程 return Fib(n - 1) + Fib(n - 2) } print(Fib(100)) |
這種遞迴的寫法看起來簡潔明瞭,但是上面寫法有一個問題:我們要求F(100),那麼要計算F(99)和F(98);要計算F(99),我們要計算F(98)和F(97)。。。大家已經發現到這一步,我們已經重複計算兩次F(98)了。而之後的計算中還會有大量的重複,這使得這個解法的複雜度非常之高。解決方法就是,用一個陣列,將計算過的值存起來,這樣可以用空間上的犧牲來換取時間上的效率提高,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var nums = [Int](count: 100, repeatedValue: 0) func Fib(n: Int) -> Int { // 定義初始狀態 guard n > 0 else { return 0 } if n == 1 || n == 2 { return 1 } // 如果已經計算過,直接呼叫,無需重複計算 if nums[n - 1] != 0 { return nums[n - 1] } // 將計算後的值存入陣列 nums[n - 1] = Fib(n - 1) + Fib(n - 2) return nums[n - 1] } |
動態轉移雖然看上去十分高大上,但是它也存在兩個致命缺點:
- 棧溢位:每一次遞迴,程式都會將當前的計算壓入棧中。隨著遞迴深度的加深,棧的高度也越來越高,直到超過計算機分配給當前程式的記憶體容量,程式就會崩潰。
- 資料溢位:因為動態規劃是一種由簡至繁的過程,其中積蓄的資料很有可能超過系統當前資料型別的最大值,導致崩潰。
而這兩個bug,我們上面這道求解斐波拉契數列第100個數的題目就都遇到了。
- 首先,遞迴的次數很多,我們要從F(100) = F(99) + F(98) ,一直推理到F(3) = F(2) + F(1),這樣很容易造成棧溢位。
- 其次,F(100)應該是一個很大的數。實際上F(40)就已經突破一億,F(100)一定會造成整型資料溢位。
當然,這兩個bug也有相應的解決方法。對付棧溢位,我們可以把遞迴寫成迴圈的形式(所有的遞迴都可改寫成迴圈);對付資料溢位,我們可以在程式每次計算中,加入資料溢位的檢測,適時終止計算,丟擲異常。
iOS實戰演練
筆者以前在矽谷參加了一個hackthon大賽,當時是要做一個掃描英文單詞出翻譯的app。它大概長這樣:
當時這個App其他部分執行非常流暢,就是在開啟攝像頭掃描單詞的時候,會出現誤讀的情況。比如手寫的“price”,機器會識別成“pr1ce”,從而無法對其進行正確的翻譯。筆者對這種情況進行了相應的優化處理,方法如下:
- 縮小誤差範圍:將所有的單詞構造成字首樹。然後對於掃描的內容,搜尋出相應可能的單詞。具體做法可以參考《Swift 演算法實戰之路:深度和廣度優先搜尋》一文中搜尋單詞的方法。
- 計算出最接近的單詞:假如上一步,我們已經有了10個可能的單詞,那麼怎麼確定最接近真實情況的單詞呢?這裡我們要定義兩個單詞的距離 — 從第一個單詞wordA,到第二個單詞wordB,有三種操作:
- 刪除一個字元
- 新增一個字元
- 替換一個字元
綜合上述三種操作,用最少步驟將單詞wordA變到單詞wordB,我們就稱這個值為兩個單詞之間的距離。比如 pr1ce -> price,只需要將 1 替換為 i 即可,所以兩個單詞之間的距離為1。pr1ce -> prize,要將 1 替換為 i ,再將 c 替換為 z ,所以兩個單詞之間的距離為2。相比於prize,price更為接近原來的單詞。
現在問題轉變為實現下面這個方法:
1 |
func wordDistance(wordA: String, wordB: String) -> Int { ... } |
要解決這個複雜的問題,我們不如從一個簡單的例子出發:求“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.
有了初始狀態和狀態轉移方程,那麼動態規劃的程式碼就出來了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
func wordDistance(wordA: String, _ wordB: String) -> Int { let aChars = [Character](wordA.characters) let bChars = [Character](wordB.characters) let aLen = aChars.count let bLen = bChars.count var dp = Array(count: aLen + 1, repeatedValue:(Array(count: bLen + 1, repeatedValue: 0))) for i in 0 ... aLen { for j in 0 ... bLen { // 初始情況 if i == 0 { dp[i][j] = j } else if j == 0 { dp[i][j] = i // 特殊情況 } else if aChars[i - 1] == bChars[j - 1] { dp[i][j] = dp[i - 1][j - 1] } else { // 狀態轉移方程 dp[i][j] = min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]) + 1 } } } return dp[aLen][bLen] } |
用動態規劃計算出單詞之間的距離之後,在做一些相應的優化,就可以準確的識別出掃描的單詞。
全系列總結
動態規劃算是演算法進階中比較重要的一環,它的思想就是把複雜問題化為簡單具體問題,然後分析出初始狀態和狀態轉移方程,從而推出最終解。也許它在實際程式設計或是iOS開發中出現頻率不高,但是這種刪繁就簡的思路,卻可以應用在生活或者工作中的方方面面。
Swift演算法實戰系列前前後後一共9篇:
- 第1篇是分析Swift的基本語法。談的是如何快速有效書寫Swift的技巧;
- 第2 – 5篇主要是講各種資料結構。分別講了陣列、字串、字典、連結串列、棧、佇列、二叉樹;
- 第6 – 9篇分析的是各種基本演算法。搜尋、排序、深度和廣度優先搜尋、遞迴和動態規劃都有涉及。
整個系列的目的就是用Swift說清楚最基本的演算法和資料結構知識,所以語言儘可能通俗易懂。動態規劃再往上講,就比較陽春白雪了,故而至此收筆。感謝大家的閱讀和指教。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式