Python <演算法思想集結>之抽絲剝繭聊動態規劃
1. 概述
動態規劃
演算法應用非常之廣泛。
對於演算法學習者而言,不跨過動態規劃
這道門,不算真正瞭解演算法。
初接觸動態規劃
者,理解其思想精髓會存在一定的難度,本文將通過一個案例,抽絲剝繭般和大家聊聊動態規劃
。
動態規劃演算法有 3
個重要的概念:
- 重疊子問題。
- 最優子結構。
- 狀態轉移。
只有吃透這 3
個概念,才叫真正理解什麼是動態規劃
。
什麼是重疊子問題?
動態規劃
和分治演算法
有一個相似之處。
將原問題分解成相似的子問題,在求解的過程中通過子問題的解求出原問題的解。
動態規劃與分治演算法的區別:
-
分治演算法的每一個子問題具有完全獨立性,只會被計算一次。
二分查詢
是典型的分治演算法
-
動態規劃經分解得到的子問題往往不是互相獨立的,有些子問題會被重複計算多次,這便是
重疊子問題
。 -
同一個子問題被計算多次,完全是沒有必要的,可以快取已經計算過的子問題,再次需要子問題結果時只需要從快取中獲取便可。這便是動態規劃中的典型操作,優化重疊子問題,通過
空間換時間
的優化手段提高效能。
重疊子問題
並不是動態規劃的專利,重疊子問題是一個很普見的現象。
什麼最優子結構?
最優子結構是動態規劃的必要條件。因為動態規劃只能應用於具有最優子結構
的問題,在解決一個原始問題時,是否能套用動態規劃演算法,分析是否存在最優子結構是關鍵。
那麼!到底什麼是最優子結構?概念其實很簡單,區域性最優解能決定全域性最優解。
如拔河比賽中。如果
A
隊中的每一名成員的力氣都是每一個班上最大的,由他們組成的拔河隊毫無疑問,一定是也是所有拔河隊中實力最強的。如果把求解哪一個團隊的力量最大當成原始問題,則每一個人的力量是否最大就是子問題,則子問題的最優決定了原始問題的最優。
所以,動態規劃多用於求最值
的應用場景。
不是說有 3
個概念嗎!
不急,先把狀態轉移這個概念放一放,稍後再解釋。
2. 流程
下面以一個案例的解決過程描述使用動態規劃
的流程。
問題描述:小兔子的難題。
有一隻小兔子站在一片三角形的胡蘿蔔地的入口,如下圖所示,圖中的數字表示每一個坑中胡蘿蔔的數量,小兔子每次只能跳到左下角
右下角
的坑中,請問小兔子怎麼跳才能得到最多數量的胡蘿蔔?
首先這個問題是求最值問題, 是否能夠使用動態規劃求解,則需要一步一步分析,看是否有滿足使用動態規劃的條件。
2.1 是否存在子問題
先來一個分治思想:思考或觀察是否能把原始問題分解成相似的子問題,把解決問題的希望寄託在子問題上。
那麼,針對上述三角形數列,是否存在子問題?
現在從數字7
出發,兔子有 2
條可行路線。
為了便於理解,首先模糊第 3
行後面的數字或假設第 3
行之後根本不存在。
那麼原始問題就變成:
-
先分別求解
路線 1
和路線 2
上的最大值。路線 1
的最大值為3
,路線 2
上的最大值是8
。 -
然後求解出
路線 1
和路線 2
兩者之間的最大值8
。 把求得的結果和出發點的數字7
相加,7+8=15
就是最後答案。只有
2
行時,兔子能獲得的最多蘿蔔數為15
,肉眼便能看的出來。
前面是假設第 3
行之後都不存在,現在把第 3
行放開,則路線 1
路線2
的最大值就要發生變化,但是,對於原始問題來講,可以不用關心路線 1
和路線 2
是怎麼獲取到最大值,交給子問題自己處理就可以了。
反正,到時從路線 1
和路線 2
的結果中再選擇一個最大值就是。
把第 3
行放開後,路線 1
就要重新更新最大值,如上圖所示,路線 1
也可以分解成子問題,分解後,也只需要關心子問題的返回結果。
- 路線
1
的子問題有2
個,路線 1_1
和路線1_2
。求解2
個子問題的最大值後,再在2
個子問題中選擇最大值8
,最後路線1
的最大值為3+8=11
。 - 路線
2
的子問題有2
個,路線 2_1
和路線2_2
。求解2
個子問題的最大值後,再在2
個子問題中選擇最大值2
,最後路線2
的最大值為8+2=10
。
當第 3
行放開後,更新路線 1
和路線2
的最大值,對於原始問題而言,它只需要再在 2
個子問題中選擇最大值 11
,最終問題的解為7+11=18
。
如果放開第 4 行,將重演上述的過程。和原始問題一樣,都是從一個點出發,求解此點到目標行的最大值。所以說,此問題是存在子問題的。
並且,只要找到子問題的最優解,就能得到最終原始問題的最優解。不僅存在子問題,而且存在最優子結構。
顯然,這很符合遞迴套路:遞進給子問題,回溯子問題的結果。
-
使用
二維數列表
儲存三角形數列中的所有資料。a=[[7],[3,8],[8,1,2],[2,7,4,4],[4,5,2,6,5]]
。 -
原始問題為
f(0,0)
從數列的(0,0)
出發,向左下角和右下角前行,一直找到此路徑上的數字相加為最大。f(0,0)
表示以第1
行的第1
列數字為起始點。 -
分解原始問題
f(0,0)=a(0,0)+max(f(1,0)+f(1,1))
。 -
因為每一個子問題又可以分解,讓表示式更通用
f(i,j)=a(i,j)+max(f(i+1,j)+f(i+1,j+1))
。(i+1,j)
表示(i,j)
的左下角,(i+1,j+1)
表示(i,j)
的右下角,
編碼實現:
# 已經數列
nums = [[7], [3, 8], [8, 1, 2], [2, 7, 4, 4], [4, 5, 2, 6, 5]]
# 遞迴函式
def get_max_lb(i, j):
if i == len(nums) - 1:
# 遞迴出口
return nums[i][j]
# 分解子問題
return nums[i][j] + max(get_max_lb(i + 1, j), get_max_lb(i + 1, j + 1))
# 測試
res = get_max_lb(0, 0)
print(res)
'''
輸出結果
30
'''
不是說要聊聊動態規劃的流程嗎!怎麼跑到遞迴上去了。
其實所有能套用動態規劃
的演算法題,都可以使用遞迴實現,因遞迴平時接觸多,從遞迴切入,可能更容易理解。
2.2 是否存在重疊子問題
先做一個實驗,增加三角形數的行數,也就是延長路徑線。
import random
nums = []
# 遞迴函式
def get_max_lb(i, j):
if i == len(nums) - 1:
return nums[i][j]
return nums[i][j] + max(get_max_lb(i + 1, j), get_max_lb(i + 1, j + 1))
# 構建 100 行的二維列表
for i in range(100):
nums.append([])
for j in range(i + 1):
nums[i].append(random.randint(1, 100))
res = get_max_lb(0, 0)
print(res)
執行程式後,久久沒有得到結果,甚至會超時。原因何在?如下圖:
路線1_2
和路線2_1
的起點都是從同一個地方(藍色標註的位置)出發。顯然,從數字 1
(藍色標註的數字)出發的這條路徑會被計算 2
次。在上圖中被重複計算的子路徑可不止一條。
這便是重疊子問題!子問題被重複計算。
當三角形數列的資料不是很多時,重複計算對整個程式的效能的影響微不足道 。如果資料很多時,大量的重複計算會讓計算機效能低下,並可能導致最後崩潰。
因為使用遞迴的時間複雜度為O(2^n)
。當資料的行數變多時,可想而知,效能有多低下。
怎麼解決重疊子問題?
答案是:使用快取,把曾經計算過的子問題結果快取起來,當再次需要子問題結果時,直接從快取中獲取,就沒有必要再次計算。
這裡使用字典作為快取器,以子問題的起始位置為關鍵字
,以子問題的結果為值
。
import random
def get_max_lb(i, j):
if i == len(nums) - 1:
return nums[i][j]
left_max = None
right_max = None
if (i + 1, j) in dic.keys():
# 檢查快取中是否存在子問題的結果
left_max = dic[i + 1, j]
else:
# 快取中沒有,才遞迴求解
left_max = get_max_lb(i + 1, j)
# 求解後的結果快取起來
dic[(i + 1, j)] = left_max
if (i + 1, j + 1) in dic.keys():
right_max = dic[i + 1, j + 1]
else:
right_max = get_max_lb(i + 1, j + 1)
dic[(i + 1, j + 1)] = right_max
return nums[i][j] + max(left_max, right_max)
# 已經數列
nums = []
# 快取器
dic = {}
for i in range(100):
nums.append([])
for j in range(i + 1):
nums[i].append(random.randint(1, 100))
# 遞迴呼叫
res = get_max_lb(0, 0)
print(res)
因使用隨機數生成資料,每次執行結果不一樣。但是,每次執行後的速度是非常給力的。
當出現重疊子問題時,可以快取曾經計算過的子問題。
好 !現在到了關鍵時刻,屏住呼吸,從分析快取中的資料開始。
使用遞迴解決問題,從結構上可以看出是從上向下
的一種處理機制。所謂從上向下
,也就是由原始問題開始一路去尋找答案。從本題來講,就是從第一行一直找到最後一行,或者說從未知
找到``已知`。
根據遞迴的特點,可知快取資料的操作是在回溯過程中發生的。
當再次需要呼叫某一個子問題時,這時才有可能從快取中獲取到已經計算出來的結果。快取中的資料是每一個子問題的結果,如果知道了某一個子問題,就可以通過子問題計算出父問題。
這時,可能就會有一個想法?
從已知找到未知。
任何一條路徑只有到達最後一行後才能知道最後的結果。可以認為,最後一行是已知資料。先快取最後一行,那麼倒數第 2
行每一個位置到最後一行的路徑的最大值就可以直接求出來。
同理,知道了倒數第 2
行的每一個位置的路徑最大值,就可以求解出倒數第 3
行每一個位置上的最大值。以此類推一直到第 1 行。
天呀!多完美,還用什麼遞迴。
可以認為這種思想便是動態規劃的核心:自下向上。
2.3 狀態轉移
還差最後一步,就能把前面的遞迴轉換成動態規劃實現。
什麼是狀態轉移?
前面分析從最後 1
開始求最大值過程,是不是有點像田徑場上的多人接力賽跑,第 1
名運動力爭跑第 1
,把狀態轉移給第 2
名運動員,第 2
名運動員持續保持第 1
,然後把狀態轉移給第 3
運動員,第 3
名運動員也保持他這一圈的第 1
,一至到最後一名運動員,都保持自己所在那一圈中的第 1
。很顯然最後結果,他們這個團隊一定是第 1
名。
把子問題的值傳遞給另一個子問題,這便是狀態轉移
。當然在轉移過程中,一定會存在一個表示式,用來計算如何轉移。
用來儲存每一個子問題狀態的表稱為 dp
表,其實就是前面遞迴中的快取器。
用來計算如何轉移的表示式,稱為狀態轉移方程式。
有了上述的這張表,就可以使用動態規劃
自下向上的方式解決“兔子的難題”這個問題。
nums = [[7], [3, 8], [8, 1, 2], [2, 7, 4, 4], [4, 5, 2, 6, 5]]
# dp列表
dp = []
idx = 0
# 從最後一行開始
for i in range(len(nums) - 1, -1, -1):
dp.append([])
for j in range(len(nums[i])):
if i == len(nums) - 1:
# 最後一行緩存於狀態轉移表中
dp[idx].append(nums[i][j])
else:
dp[idx].append(nums[i][j] + max(dp[idx - 1][j], dp[idx - 1][j + 1]))
idx += 1
print(dp)
'''
輸出結果:
[[4, 5, 2, 6, 5], [7, 12, 10, 10], [20, 13, 12], [23, 21], [30]]
'''
程式執行後,最終輸出結果和前面手工繪製的dp
表中的資料一模一樣。
其實動態規劃實現是前面遞迴操作的逆過程。時間複雜度是O(n^2)。
並不是所有的遞迴操作都可以使用動態規劃進行逆操作,只有符合動態規劃條件的遞迴操作才可以。
上述解決問題時,使用了一個二維列表
充當dp
表,並儲存所有的中間資訊。
思考一下,真的有必要儲存所有的中間資訊嗎?
在狀態轉移過程中,我們僅關心當前得到的狀態資訊,曾經的狀態資訊其實完全可以不用儲存。
所以,上述程式完全可以使用一個一維列表來儲存狀態資訊。
nums = [[7], [3, 8], [8, 1, 2], [2, 7, 4, 4], [4, 5, 2, 6, 5]]
# dp表
dp = []
# 臨時表
tmp = []
# 從最後一行開始
for i in range(len(nums) - 1, -1, -1):
# 把上一步得到的狀態資料提出來
tmp = dp.copy()
# 清除 dp 表中原來的資料,準備儲存最新的狀態資料
dp.clear()
for j in range(len(nums[i])):
if i == len(nums) - 1:
# 最後一行緩存於狀態轉移表中
dp.append(nums[i][j])
else:
dp.append(nums[i][j] + max(tmp[j], tmp[j + 1]))
print(dp)
'''
輸出結果:
[30]
'''
3.總結
動態規劃問題一般都可以使用遞迴實現,遞迴是一種自上向下的解決方案,而動態規劃是自下向上的解決方案,兩者在解決同一個問題時的思考角度不一樣,但本質是一樣的。
並不是所有的遞迴操作都能轉換成動態規劃,是否能使用動態規劃演算法,則需要原始問題符合最優子結構
和重疊子問題
這 2
個條件。在使用動態規劃過程中,找到狀態轉移表示式是關鍵。