展開目錄

動態規劃的必要元素

複習一遍目前所學,了解設計動態規劃演算法利用到的題目特性為何。

作者
baluteshih
重要度
常用
先備知識

複習一遍

為了帶出「必要元素」的概念,我們先來幫大家複習一遍我們目前的所學。

  • 第一道動態規劃問題中,我們了解到記錄子問題的重要性,因為同樣的子問題會被呼叫很多次,也就有了用記憶換取時間的優勢。
Tips 1

動態規劃思考方向(複習)

  1. 找到值得記憶的子問題。
  2. 找到使用子問題湊出答案的方法。
  3. 知道最簡單的子問題要怎麼處理。
  • Top down 與 Bottom up 中,我們學到動態規劃的兩種實作方向,Top down 和 Bottom up。另一面,我們還發現了透過整理遞迴式能夠加速計算的速度

(複習)

  • Top down:呼叫 $f(4)$ $\xrightarrow{\text{發現 }f(3)\text{ 和 }f(2)\text{ 都還沒計算過 }}$ 呼叫 $f(3)$ 和 $f(2)$ $\rightarrow$ $\ldots$ $\xrightarrow{\text{等待子問題計算完畢}}$ 算出 $f(4)$
  • Bottom up:已知 $f(0)$ 和 $f(1)$ $\xrightarrow{\text{直接得到值計算總和}}$ 算出 $f(2)$ $\xrightarrow{\text{直接得到值計算總和}}$ 算出 $f(3)$ $\xrightarrow{\text{直接得到值計算總和}}$ 算出 $f(4)$
  • 狀態與轉移中,我們學到「狀態」和「轉移」的用語以及其意義,並了解到一套設計動態規劃演算法的流程。
Definition 1

動態規劃演算法的用語(複習)

在動態規劃演算法中,會有三個重要的元素

  • 狀態:用少少的幾個參數,來描繪出答案被構建出來的「過程」。
  • 轉移:狀態與狀態之間的關係,用來表示「過程」之間是如何轉換的、以及其轉換後造成的變化。
    • 而將轉移產生的變化寫成數學式子後,就變成了「轉移式」。
  • 初始狀態:要進入一段過程就必須有一個開始,你的答案是從何開始算起的?
Tips 2

動態規劃的解題守則(複習)

  1. 設計狀態
  2. 寫出轉移式
  3. 找出初始狀態
  4. 找出所求答案
  5. 計算複雜度
  6. 寫成程式碼
  • 多個維度的 DP 中,我們學到狀態的定義其實非常自由,可以涉及到多個參數,又稱「多維 DP」。

(複習)增加維度與不增加維度的明顯差異,以例題「股票买卖 V」為例:

  • 狀態與轉移的做法中,我們是在把買賣股票「每一組買賣」都當成一種過程的變化,也因此,每一次轉移跨越的天數就會是 $O(N)$ 級別的。
    • $dp[i] = \max(dp[i - 1], \max_{1\leq j<i}\{ a_i - a_j + dp[j-2]\})$
  • 多個維度的 DP 的做法裡,我們是在把「每一天的決定」當成一種過程的變化,而一天的決定通常只取決於前一天或前兩天的結果,所以每一次轉移跨越的天數就會是 $O(1)$。
    • $dp[i][0] = \max(dp[i - 1][0], dp[i - 1][1] + a_i)$
    • $dp[i][1] = \max(dp[i - 1][1], dp[i - 2][0] - a_i)$
  • 背包問題中,我們更是發現狀態的定義還可以再自由一些,參數甚至可以是數值

(複習)令 $dp[i][j]$ 是「前 $i$ 個物品中,湊出總重量為 $j$ 的最大總價值」,豁然開朗,整個轉移式的寫法就很直接了:

  • 不取第 $i$ 個物品:對應到子問題 $dp[i - 1][j]$
  • 取了第 $i$ 個物品:對應到子問題 $dp[i - 1][j - w_i] + v_i$
  • 最後在滾動 DP 中,我們了解到動態規劃演算法的實作還可以有各種特殊情況下的變化
cpp
    for (int i = 1; i <= n; ++i)
        for (int j = wmax; j >= w[i]; --j)
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

複習完畢,接下來就讓我們進入主題吧!

三大要素

動態規劃演算法花樣這麼多,有沒有一個很乾脆的概念整理出個「大方向」呢?

其實很多時候,我們在設計一個動態規劃演算法時,只是在遵守著幾個概念而已,而最常被大家提出來的三個概念,筆者在這裡將他們稱做「三大要素」,並一一介紹之。

重複子問題

這也是我們在最一開始就有提過的,就是因為同一個問題會被重複呼叫很多次,才會有「用記憶換取時間」的意義,就好比以下轉移式:

$$
dp[i] = dp[i - 1] + i
$$

上面這種轉移式,所以我們是把陣列取名成 $dp$ 了,但他卻不太算是一個動態規劃演算法,這是因為每個子問題也就只會被後一個狀態呼叫而已,根本沒有子問題會被重複呼叫。

又好比下列這道例題:

例題

Maximum Subarray Sum

Source:CSES 1643

有一個數列 $x_1,x_2,\dots,x_n$,求所有非空區間之中,最大的總和是多少。

條件限制
  • $n \leq 2 \times 10^5$
  • $-10^9 \leq x_i \leq 10^9$

在網路上,許多人會將這題解釋成一道動態規劃例題,但他們寫出來的轉移式也許會是這樣的:

$$
dp[i] = \max(dp[i - 1] + a_i, a_i)
$$

也就是令 $dp[i]$ 為結尾在 $i$ 的最大區間和,轉移式看起來很自然,但同樣有「每個子問題只被呼叫一次」的問題,因此說他是動態規劃演算法,就有點不太自然。

甚至還可能有人這樣寫:

$$
dp[i] = \max_{0\leq j<i}\{s[i] - s[j]\}
$$

其中 $s[i]$ 是前綴和陣列,為 $x_1, x_2, \ldots, x_i$ 的和。

這連遞迴都沒有,只不過是在「對每個右界獨立找到最好的左界」而已。甚至進一步來說,每個 $dp[i]$ 之間互相是不影響的,導致我們也不清楚該稱呼他們為「$n$ 個不同問題」還是「$n$ 個子問題」。

而拿最直接的例子,在費氏數列的動態規劃作法中,若不將子問題紀錄下來,重複的呼叫就會讓時間複雜度直接退化到指數量級,這時候使用動態規劃演算法將時間複雜度變成多項式時間,其優勢才能被凸顯出來。

最佳子結構

在講解背包問題這道例題時,我們曾嘗試過以下這種狀態定義方法:

  • 定義 $dp[i]$ 是「前 $i$ 個物品的最佳解」

當時我們馬上就給出了一個「這樣定義狀態會錯」的反例,透過這個反例,我們也了解到了子問題狀態的設計不能太過草率。

為了將這種正確和錯誤的子問題定義方式做出區別,人們對正確子問題的性質有了一個稱呼:最佳子結構

什麼概念呢?明確的說:

Definition 2

最佳子結構

一個子問題定義方式擁有最佳子結構,代表你可以透過直接呼叫該子問題的最佳解,來進行後續的轉移。

針對前面講的錯誤背包問題狀態定義,就是一種「沒有最佳子結構」的例子,因為當我們讓每個子問題都紀錄自己的最佳解時,更大的子問題就無法從這些子問題中轉移出自己的最佳解了!

設計狀態時沒有最佳子結構,是一個有時候連高手都會犯的錯誤。讀者在思考問題時,若要從動態規劃的角度下手,不妨可以多思考看看:

  • 當我試圖呼叫子問題來湊出問題的解答時,有沒有可能子問題本身稍微表現得差一些反而會讓整個問題的答案變好?

如果遇到這種狀況,還請務必多想想是否遺漏了什麼「轉移過程中不可或缺的參數」,當遇到這種情況時,最直接的方法就是直接把這個參數放進狀態

  • 在背包問題中使用到的手法就成為了一個例子:因為在子問題中我們刻意選一個總重較少的解才能獲得最佳解,我們不妨就把總重這個參數放進狀態裡!

無後效性

這是一個隱藏性質,到目前為止,讀者可能還不會注意到他的重要性。舉個例題當例子:

例題

xtq的棋盘

xtq 有一個 $1$ 行,$n+1$ 列的棋盤,從左到右編號為 $0$ 到 $n$。初始時刻,在 $m$ 位置有一顆棋子。

xtq 會在接下來的時間裡隨機操作。具體地說,如果某一秒棋子不位於 $n$,那麽他將有 $prb$ 的機率將棋子向左移動一格,$1-prb$ 的機率向右移動一格;否則,他必然將棋子向左移動一格。

現在 xtq 想問你,期望多少秒之後棋子能夠到達 $0$。

條件限制
  • $1\leq m\leq n\leq 10^9$
  • 在本題中,機率是以有理數的形式給予,且輸出的期望值必須對一質數取模。

由於跟本章節關係不大,我們先不管範圍和輸出的方式。

這裡我們先直接試著用動態規劃解決看看,就先定義 $dp[i]$ 是棋子位於 $i$ 時,期望到達 $0$ 的秒數吧!那麼就有

$$
dp[i] = \begin{cases}
0 & i = 0\\
prb \cdot dp[i - 1] + (1 - prb) \cdot dp[i + 1] + 1 & 0 < i < n \\
dp[i - 1] + 1 & i = n
\end{cases}
$$

如果你很開心的想把上面這段轉移式寫成程式的話,就會發現一個很嚴重的問題:

  • 要計算 $dp[1]$,我們就需要 $dp[2]$,但要計算 $dp[2]$,我們也需要 $dp[1]$!

從頭到尾好像都只有 $dp[0]$ 的值是直接被確定的啊?

這就麻煩了,原本在解動態規劃問題時,我們總是很直接的「從小算到大」,所有的子問題在計算時,所謂「更小的子問題」已經早有解答了。但現在,這些子問題沒有所謂的「大小關係」,這是因為他們會互相呼叫,導致不存在「良好的計算的順序」。

從上面的問題可以注意到,就算能輕易的寫出動態規劃的轉移式,也得幫這些狀態找到「計算的順序」,而確實是存在著一些不好找計算順序的問題,也因此會造成實作上的麻煩。

但這次舉的例子,是直接找不到一個合理的計算順序,由於不是本節要討論的對象,僅做為提示,在上面舉出的問題是可以直接將 $dp[i]$ 當成函數對待後,透過解方程式的方式解決的,但這就不是動態規劃演算法了!

也因此,這種「存在一個良好計算順序」的性質,就被稱作為「無後效性」,是動態規劃演算法中不可或缺的重要性質。在後續的章節中,這樣的概念我們能幫助讀者透過圖論的知識點來進行加強,這裡讀者可以先對他有一個認知就好。

使用動態規劃的時機

很可惜,想要找到判斷何時能使用動態規劃的通則,是不太實際的,但似乎又能在網路上聽到不少「這題明顯就 DP 啊」的言論,這些人是如何自信地說出這些話呢?

不用懷疑,這通常都是因為他們「寫的題目多」,知道以該種形式出現的題目多數情況都是用動態規劃解決,才能做出這樣的推論。也就是說,難不成我們就只能靠不斷大量刷題來培養對動態規劃的感覺嗎?

刷題固然重要,但針對動態規劃,還是有一些思考上的技巧的:

  • 你在解題時,會試著把大問題切割成同樣形式的小問題去思考嗎?
    • 面對「背包問題」時,試著去想最後一個物品拿或不拿,就可以發現他們分別對應到較小、形式又差不多的的問題。
  • 對於你要解決的問題,你會習慣想像答案被建構出來的過程嗎?
    • 面對「股票买卖 V」時,不同的股票買賣過程,會得到不一樣的狀態設計方式。

在筆者的想像中,初學動態規劃的讀者讀到這裡很有可能還是對動態規劃的理解感到有些模糊,但透過一系列的教學,筆者希望能給讀者埋下一些思考上的習慣,讓讀者配合這些學到的東西進行題目的練習後,能更正確並迅速的掌握到動態規劃的精髓。最好的情況就是在刷題的過程能有所領悟,這樣靠自己想到的東西就會真正屬於自己。因此,在頻繁的練習過程中,也別忘了時常反思!

小結

到目前為止,希望讀者對動態規劃已經有一套足夠有系統的理解了。當然,光靠這些理論也許不足以了解動態規劃的全貌,有許多的疑點都還存在著:

  • 目前的每一個例題、習題都只輸出「解答的數值」,尤其對於那些「最佳化問題」,在現實生活中,我們需要的可不只是答案而已,還要能達到最佳解的方法本身!這要怎麼解決呢?
  • Top down 的實作方法究竟有什麼用?
  • 我們常在思考動態規劃的轉移式時,會想著「最後一步操作為何」,進而推敲出那些「轉移來源」的狀態是哪些。不過這樣的思考方式似乎有些不直覺,能不能改想成「下一步操作為何」,來知道當前狀態可以轉移到哪些後續狀態呢?
  • 還有沒有更多的狀態設計方法呢?
  • 在幾道例題或習題中,我們曾使用「前綴和」或「前綴最大值」等技巧來加速轉移時間,這種「加速動態規劃演算法」的概念有沒有可能推廣呢?

就讓我們將這些問題暫時留給讀者思考,並在未來的章節一一為大家解惑!

NTUCPC Logo
國立臺灣大學程式解題社NTU Competitive Programming Club
This work is licensed under CC BY-SA 4.0