“動態規劃”這詞太嚇人,其實可以叫“狀態快取”

華為雲開發者社群發表於2021-06-26
摘要:平時練習演算法題學習演算法知識時,經常會發現題解裡寫著“動態規劃”,裡面一上來就是一個複雜的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/

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章