摘要:平時練習演算法題學習演算法知識時,經常會發現題解裡寫著“動態規劃”,裡面一上來就是一個複雜的dp公式,對於新人來說除了說聲“妙啊”,剩下就是疑惑,他是怎麼想到這個公式的?我能想到嗎?這玩意工作中有用嗎?
本文分享自華為雲社群《動態規劃究竟是怎麼想到的?【奔跑吧!JAVA】》,原文作者:breakDraw。
平時練習演算法題學習演算法知識時,經常會發現題解裡寫著“動態規劃”,裡面一上來就是一個複雜的dp公式,對於新人來說除了說聲
剩下就是疑惑,他是怎麼想到這個公式的?我能想到嗎?這玩意工作中有用嗎?
加上“動態規劃”這高階的名字,然後就勸退了不少試圖去理解他的人。
動態規劃聽起來太嚇人,可以換個說法
我在內心更喜歡叫他“狀態快取”
如果是服務開發,相信很熟悉這個詞語, 利用快取來加快一些重複的請求的響應速度。
而這個快取的特點是 和其他快取有所關聯。
比如我們的服務要計算7天內的某金錢總和,計算後要快取一下。
後來又收到一個請求,要計算8天內的金錢總和
那我們只需要取之前算過的7天內的金錢綜合,加上第8天的金錢就行了。
1+4的思考套路
自己針對動態規劃總結了一個自己的思考套路,我叫他1組例子4個問題,就叫1+4好了,通過這5個過程,可以站在普通人的角度(就是非acm大佬那種的角度),去理解動態規劃是如何被思考出來的
- 在超時的思路上寫出一組計算過程的例子
- 在超時例子的基礎上,有哪些重複、浪費的地方?
- 如何定義dp陣列
- 狀態的變化方向是什麼,是怎麼變化的
- 邊界狀態是什麼
簡單例子
以一道簡單題為例:
爬樓梯:
https://leetcode-cn.com/problems/climbing-stairs/
這時候就要靜下心,觀察這個解法的例子中是否有重複經歷的場景,而這個重複經歷的場景就叫狀態。
我處理動態規劃的題目時, 都會問自己3個問題,一般就能順利地解決。
①在超時的思路上寫出一組計算過程的例子
如果我們考慮最簡單的解法, 就是從起點開始,每次選擇走1步或者走2步,看下能否走到終點,能走到則方法數+1。
但這種方法註定超時(O(n^2))
但我還是照著這個過程模擬了一下,隨便列了幾個
1 ->2-> 3-> 4-> 5
1 ->2 ->3-> 5
1->3->4->5
1->3->5
②在超時例子的基礎上,有哪些重複、浪費的地方?
在上面,我發現了重複的地方
也就是說
從3到5總共就2種路線,已經在1->2之後計算過了,我後面從1走到3再往後走時,沒必要再去算了。
換言之,當我走到3的時候,其實早就可以知道後面還剩下多少種走法。
發現重複的地方後,就可以開始建立dp公式了。
③如何定義dp陣列?
定義dp陣列,也就是定義上面提到的重複的地方。重新看下之前的那句話
當我走到3的時候,其實早就可以知道後面還剩下多少種走法。
所以dp[3]代表的就是從3往後,有多少種可走的方法。
④狀態的變化方向是什麼,是怎麼變化的
- 首先思考狀態的變化方向
重新看這句話:
當我走到3的時候,其實早就可以知道後面還剩下多少種走法
說明結果取決於往 後面 的狀態
因此我們要先計算後面的狀態, 即從後往前算
- 接著思考這個後面的狀態和當前的狀態有什麼聯絡,是怎麼變化的
這個一般都包含在題目條件中
根據題意,要麼走2步,要麼走1步,因此每當我走到一層時,下一次就2種狀態可以變化。
那麼對於第3層而言,他後續有2種走法,走1步或者走2步
那麼他的情況就是dp[3] = dp[3+1] + dp{3+2}
如果層數設為i,那麼這個變化情況就是
dp[i] = dp[i+1] + dp[i+2]
⑤邊界狀態是什麼?
邊界狀態就是不需要依賴後面的狀態了,直接可以得到結果的狀態。
在這裡肯定就是最後一層dp[n], 最後一層預設是一種走法。 dp[n]=1
實現
根據上面的過程,自己便定義了這個狀態和變化
- 定義:dp[i] : 代表從第i層往後,有多少種走法
- 方向和變化:dp[i] = dp[i+1] + dp[i+2];
- 邊界: dp[n] = 1
根據這個寫程式碼就很容易了
程式碼:
public int climbStairs(int n) { int[] dp = new int[n + 1]; dp[n] = 1; dp[n-1] = 1; for(int i = n-2; i >=0;i--) { dp[i] = dp[i+1] + dp[i+2]; } return dp[0]; }
進階版,二維的動態規劃
https://leetcode-cn.com/problems/number-of-ways-to-stay-in-the-same-place-after-some-steps/
①在超時的思路上寫出一組計算過程的例子
超時的思路肯定是像搜尋一樣模擬所有的行走過程。
先假設1個steps=5, arrlen=3的情況
隨便先列幾個。模擬一下不斷走的位置。數字指的是當前位置。
0->1->2->1->0->0
0->1->2->1->1->0
0->1->1->1->1->0
0->1->1->1->0->0
0->0->1->1->1->0
……
②在超時例子的基礎上,有哪些重複、浪費的地方?
0->1->2->1->0->0
0->1->2->1->1->0
0->1->1->1->1->0
0->1->1->1->0->0
0->0->1->1->1->0
0->0->1->1->0->0
我發現這部分標粗的部分重複了,
換句話說
當我還剩2步且當前位置為1的時候,後面還有多少種走法,其實早就知道了。
③如何定義dp陣列?
重新看這句話:
當我還剩2步且當前位置為1的時候,後面還有多少種走法,其實早就知道了。
涉及了2個關鍵因素: 剩餘步數和當前值,所以得用二維陣列
因此
dp[realstep][index]
就代表了 剩餘步數為step且位置為index時, 後續還剩多少種走法。
④狀態的變化方向是什麼,是怎麼變化的
- 先思考變化方向
“當我還剩2步且當前位置為1的時候,後面 還有多少種走法,其實早就知道了。”
這個後面是指啥, 後面會怎麼變?
後面肯定是步數越來越少的情況, 並且位置會根據規律變化。 所以變化方向是步數變少,位置則按照規定去變。
那麼這個固定越來越少的這個“剩餘步數”,就是核心的變化方向。
我們計算時,可以先計算小的剩餘步數的狀態, 再去算大的剩餘步數。
- 如何變化
根據題意和方向,剩餘步數肯定-1, 然後位置有3種選擇(減1,不變,加1), 那麼方法就是3種選擇的相加。
dp[step][index] = dp[step-1][index-1] + dp[step-1][index] + dp[step-1][index+1]
⑤邊界狀態是什麼?
剩餘步數為0時,只有當前位置為0才是我們最終想要的方案,把值設為1並提供給後面用,其他位置且步數為0時都認為是0。
dp[0][0] = 1;
dp[0][index] = 0;(index>0)
實現
那麼最終出來了
- 定義:dp{realstep][index]: 剩餘步數為step且位置為index時, 後續還剩多少種走法。
- 方向和變化:dp[step][index] = dp[step-1][index-1] + dp[step-1][index] + dp[step-1][index+1]
- 邊界: dp[0][0] = 1;
記憶體溢位處理
不過這題因為是困難題,所以給上面這個公式設立了一個小難度:
陣列長度非常大,導致如果index的範圍我們選擇為0~arrLen-1, 那麼最大情況dp[500][10^6]註定超時記憶體範圍。
這時候就要去思考index設那麼大是不是沒必要
一般我們可以自己列這種情況的小例子,例如
step=2, arr=10
然後看下index有沒有必要設成0~9,隨便走幾步
0->1->0
0->1->0
0->0->0
嗯?我發現就3種情況,arr後面那麼長不用啦?
於是發現規律:
剩餘的步數,必須支撐他返回原點!
也就是說,其實index的最大範圍最多就是step/2, 不能再多了,再多肯定回不去了。
於是問題解決。
其他類似題目練習
https://leetcode-cn.com/problems/minimum-cost-for-tickets/