feat: Traditional Chinese version (#1163)
* First commit * Update mkdocs.yml * Translate all the docs to traditional Chinese * Translate the code files. * Translate the docker file * Fix mkdocs.yml * Translate all the figures from SC to TC * 二叉搜尋樹 -> 二元搜尋樹 * Update terminology. * Update terminology * 构造函数/构造方法 -> 建構子 异或 -> 互斥或 * 擴充套件 -> 擴展 * constant - 常量 - 常數 * 類 -> 類別 * AVL -> AVL 樹 * 數組 -> 陣列 * 係統 -> 系統 斐波那契數列 -> 費波那契數列 運算元量 -> 運算量 引數 -> 參數 * 聯絡 -> 關聯 * 麵試 -> 面試 * 面向物件 -> 物件導向 歸併排序 -> 合併排序 范式 -> 範式 * Fix 算法 -> 演算法 * 錶示 -> 表示 反碼 -> 一補數 補碼 -> 二補數 列列尾部 -> 佇列尾部 區域性性 -> 區域性 一摞 -> 一疊 * Synchronize with main branch * 賬號 -> 帳號 推匯 -> 推導 * Sync with main branch * First commit * Update mkdocs.yml * Translate all the docs to traditional Chinese * Translate the code files. * Translate the docker file * Fix mkdocs.yml * Translate all the figures from SC to TC * 二叉搜尋樹 -> 二元搜尋樹 * Update terminology * 构造函数/构造方法 -> 建構子 异或 -> 互斥或 * 擴充套件 -> 擴展 * constant - 常量 - 常數 * 類 -> 類別 * AVL -> AVL 樹 * 數組 -> 陣列 * 係統 -> 系統 斐波那契數列 -> 費波那契數列 運算元量 -> 運算量 引數 -> 參數 * 聯絡 -> 關聯 * 麵試 -> 面試 * 面向物件 -> 物件導向 歸併排序 -> 合併排序 范式 -> 範式 * Fix 算法 -> 演算法 * 錶示 -> 表示 反碼 -> 一補數 補碼 -> 二補數 列列尾部 -> 佇列尾部 區域性性 -> 區域性 一摞 -> 一疊 * Synchronize with main branch * 賬號 -> 帳號 推匯 -> 推導 * Sync with main branch * Update terminology.md * 操作数量(num. of operations)-> 操作數量 * 字首和->前綴和 * Update figures * 歸 -> 迴 記憶體洩漏 -> 記憶體流失 * Fix the bug of the file filter * 支援 -> 支持 Add zh-Hant/README.md * Add the zh-Hant chapter covers. Bug fixes. * 外掛 -> 擴充功能 * Add the landing page for zh-Hant version * Unify the font of the chapter covers for the zh, en, and zh-Hant version * Move zh-Hant/ to zh-hant/ * Translate terminology.md to traditional Chinese
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,101 @@
|
||||
# 動態規劃問題特性
|
||||
|
||||
在上一節中,我們學習了動態規劃是如何透過子問題分解來求解原問題的。實際上,子問題分解是一種通用的演算法思路,在分治、動態規劃、回溯中的側重點不同。
|
||||
|
||||
- 分治演算法遞迴地將原問題劃分為多個相互獨立的子問題,直至最小子問題,並在回溯中合併子問題的解,最終得到原問題的解。
|
||||
- 動態規劃也對問題進行遞迴分解,但與分治演算法的主要區別是,動態規劃中的子問題是相互依賴的,在分解過程中會出現許多重疊子問題。
|
||||
- 回溯演算法在嘗試和回退中窮舉所有可能的解,並透過剪枝避免不必要的搜尋分支。原問題的解由一系列決策步驟構成,我們可以將每個決策步驟之前的子序列看作一個子問題。
|
||||
|
||||
實際上,動態規劃常用來求解最最佳化問題,它們不僅包含重疊子問題,還具有另外兩大特性:最優子結構、無後效性。
|
||||
|
||||
## 最優子結構
|
||||
|
||||
我們對爬樓梯問題稍作改動,使之更加適合展示最優子結構概念。
|
||||
|
||||
!!! question "爬樓梯最小代價"
|
||||
|
||||
給定一個樓梯,你每步可以上 $1$ 階或者 $2$ 階,每一階樓梯上都貼有一個非負整數,表示你在該臺階所需要付出的代價。給定一個非負整數陣列 $cost$ ,其中 $cost[i]$ 表示在第 $i$ 個臺階需要付出的代價,$cost[0]$ 為地面(起始點)。請計算最少需要付出多少代價才能到達頂部?
|
||||
|
||||
如下圖所示,若第 $1$、$2$、$3$ 階的代價分別為 $1$、$10$、$1$ ,則從地面爬到第 $3$ 階的最小代價為 $2$ 。
|
||||
|
||||

|
||||
|
||||
設 $dp[i]$ 為爬到第 $i$ 階累計付出的代價,由於第 $i$ 階只可能從 $i - 1$ 階或 $i - 2$ 階走來,因此 $dp[i]$ 只可能等於 $dp[i - 1] + cost[i]$ 或 $dp[i - 2] + cost[i]$ 。為了儘可能減少代價,我們應該選擇兩者中較小的那一個:
|
||||
|
||||
$$
|
||||
dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
|
||||
$$
|
||||
|
||||
這便可以引出最優子結構的含義:**原問題的最優解是從子問題的最優解構建得來的**。
|
||||
|
||||
本題顯然具有最優子結構:我們從兩個子問題最優解 $dp[i-1]$ 和 $dp[i-2]$ 中挑選出較優的那一個,並用它構建出原問題 $dp[i]$ 的最優解。
|
||||
|
||||
那麼,上一節的爬樓梯題目有沒有最優子結構呢?它的目標是求解方案數量,看似是一個計數問題,但如果換一種問法:“求解最大方案數量”。我們意外地發現,**雖然題目修改前後是等價的,但最優子結構浮現出來了**:第 $n$ 階最大方案數量等於第 $n-1$ 階和第 $n-2$ 階最大方案數量之和。所以說,最優子結構的解釋方式比較靈活,在不同問題中會有不同的含義。
|
||||
|
||||
根據狀態轉移方程,以及初始狀態 $dp[1] = cost[1]$ 和 $dp[2] = cost[2]$ ,我們就可以得到動態規劃程式碼:
|
||||
|
||||
```src
|
||||
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp}
|
||||
```
|
||||
|
||||
下圖展示了以上程式碼的動態規劃過程。
|
||||
|
||||

|
||||
|
||||
本題也可以進行空間最佳化,將一維壓縮至零維,使得空間複雜度從 $O(n)$ 降至 $O(1)$ :
|
||||
|
||||
```src
|
||||
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp_comp}
|
||||
```
|
||||
|
||||
## 無後效性
|
||||
|
||||
無後效性是動態規劃能夠有效解決問題的重要特性之一,其定義為:**給定一個確定的狀態,它的未來發展只與當前狀態有關,而與過去經歷的所有狀態無關**。
|
||||
|
||||
以爬樓梯問題為例,給定狀態 $i$ ,它會發展出狀態 $i+1$ 和狀態 $i+2$ ,分別對應跳 $1$ 步和跳 $2$ 步。在做出這兩種選擇時,我們無須考慮狀態 $i$ 之前的狀態,它們對狀態 $i$ 的未來沒有影響。
|
||||
|
||||
然而,如果我們給爬樓梯問題新增一個約束,情況就不一樣了。
|
||||
|
||||
!!! question "帶約束爬樓梯"
|
||||
|
||||
給定一個共有 $n$ 階的樓梯,你每步可以上 $1$ 階或者 $2$ 階,**但不能連續兩輪跳 $1$ 階**,請問有多少種方案可以爬到樓頂?
|
||||
|
||||
如下圖所示,爬上第 $3$ 階僅剩 $2$ 種可行方案,其中連續三次跳 $1$ 階的方案不滿足約束條件,因此被捨棄。
|
||||
|
||||

|
||||
|
||||
在該問題中,如果上一輪是跳 $1$ 階上來的,那麼下一輪就必須跳 $2$ 階。這意味著,**下一步選擇不能由當前狀態(當前所在樓梯階數)獨立決定,還和前一個狀態(上一輪所在樓梯階數)有關**。
|
||||
|
||||
不難發現,此問題已不滿足無後效性,狀態轉移方程 $dp[i] = dp[i-1] + dp[i-2]$ 也失效了,因為 $dp[i-1]$ 代表本輪跳 $1$ 階,但其中包含了許多“上一輪是跳 $1$ 階上來的”方案,而為了滿足約束,我們就不能將 $dp[i-1]$ 直接計入 $dp[i]$ 中。
|
||||
|
||||
為此,我們需要擴展狀態定義:**狀態 $[i, j]$ 表示處在第 $i$ 階並且上一輪跳了 $j$ 階**,其中 $j \in \{1, 2\}$ 。此狀態定義有效地區分了上一輪跳了 $1$ 階還是 $2$ 階,我們可以據此判斷當前狀態是從何而來的。
|
||||
|
||||
- 當上一輪跳了 $1$ 階時,上上一輪只能選擇跳 $2$ 階,即 $dp[i, 1]$ 只能從 $dp[i-1, 2]$ 轉移過來。
|
||||
- 當上一輪跳了 $2$ 階時,上上一輪可選擇跳 $1$ 階或跳 $2$ 階,即 $dp[i, 2]$ 可以從 $dp[i-2, 1]$ 或 $dp[i-2, 2]$ 轉移過來。
|
||||
|
||||
如下圖所示,在該定義下,$dp[i, j]$ 表示狀態 $[i, j]$ 對應的方案數。此時狀態轉移方程為:
|
||||
|
||||
$$
|
||||
\begin{cases}
|
||||
dp[i, 1] = dp[i-1, 2] \\
|
||||
dp[i, 2] = dp[i-2, 1] + dp[i-2, 2]
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||

|
||||
|
||||
最終,返回 $dp[n, 1] + dp[n, 2]$ 即可,兩者之和代表爬到第 $n$ 階的方案總數:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_constraint_dp}-[class]{}-[func]{climbing_stairs_constraint_dp}
|
||||
```
|
||||
|
||||
在上面的案例中,由於僅需多考慮前面一個狀態,因此我們仍然可以透過擴展狀態定義,使得問題重新滿足無後效性。然而,某些問題具有非常嚴重的“有後效性”。
|
||||
|
||||
!!! question "爬樓梯與障礙生成"
|
||||
|
||||
給定一個共有 $n$ 階的樓梯,你每步可以上 $1$ 階或者 $2$ 階。**規定當爬到第 $i$ 階時,系統自動會在第 $2i$ 階上放上障礙物,之後所有輪都不允許跳到第 $2i$ 階上**。例如,前兩輪分別跳到了第 $2$、$3$ 階上,則之後就不能跳到第 $4$、$6$ 階上。請問有多少種方案可以爬到樓頂?
|
||||
|
||||
在這個問題中,下次跳躍依賴過去所有的狀態,因為每一次跳躍都會在更高的階梯上設定障礙,並影響未來的跳躍。對於這類問題,動態規劃往往難以解決。
|
||||
|
||||
實際上,許多複雜的組合最佳化問題(例如旅行商問題)不滿足無後效性。對於這類問題,我們通常會選擇使用其他方法,例如啟發式搜尋、遺傳演算法、強化學習等,從而在有限時間內得到可用的區域性最優解。
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,183 @@
|
||||
# 動態規劃解題思路
|
||||
|
||||
上兩節介紹了動態規劃問題的主要特徵,接下來我們一起探究兩個更加實用的問題。
|
||||
|
||||
1. 如何判斷一個問題是不是動態規劃問題?
|
||||
2. 求解動態規劃問題該從何處入手,完整步驟是什麼?
|
||||
|
||||
## 問題判斷
|
||||
|
||||
總的來說,如果一個問題包含重疊子問題、最優子結構,並滿足無後效性,那麼它通常適合用動態規劃求解。然而,我們很難從問題描述中直接提取出這些特性。因此我們通常會放寬條件,**先觀察問題是否適合使用回溯(窮舉)解決**。
|
||||
|
||||
**適合用回溯解決的問題通常滿足“決策樹模型”**,這種問題可以使用樹形結構來描述,其中每一個節點代表一個決策,每一條路徑代表一個決策序列。
|
||||
|
||||
換句話說,如果問題包含明確的決策概念,並且解是透過一系列決策產生的,那麼它就滿足決策樹模型,通常可以使用回溯來解決。
|
||||
|
||||
在此基礎上,動態規劃問題還有一些判斷的“加分項”。
|
||||
|
||||
- 問題包含最大(小)或最多(少)等最最佳化描述。
|
||||
- 問題的狀態能夠使用一個串列、多維矩陣或樹來表示,並且一個狀態與其周圍的狀態存在遞推關係。
|
||||
|
||||
相應地,也存在一些“減分項”。
|
||||
|
||||
- 問題的目標是找出所有可能的解決方案,而不是找出最優解。
|
||||
- 問題描述中有明顯的排列組合的特徵,需要返回具體的多個方案。
|
||||
|
||||
如果一個問題滿足決策樹模型,並具有較為明顯的“加分項”,我們就可以假設它是一個動態規劃問題,並在求解過程中驗證它。
|
||||
|
||||
## 問題求解步驟
|
||||
|
||||
動態規劃的解題流程會因問題的性質和難度而有所不同,但通常遵循以下步驟:描述決策,定義狀態,建立 $dp$ 表,推導狀態轉移方程,確定邊界條件等。
|
||||
|
||||
為了更形象地展示解題步驟,我們使用一個經典問題“最小路徑和”來舉例。
|
||||
|
||||
!!! question
|
||||
|
||||
給定一個 $n \times m$ 的二維網格 `grid` ,網格中的每個單元格包含一個非負整數,表示該單元格的代價。機器人以左上角單元格為起始點,每次只能向下或者向右移動一步,直至到達右下角單元格。請返回從左上角到右下角的最小路徑和。
|
||||
|
||||
下圖展示了一個例子,給定網格的最小路徑和為 $13$ 。
|
||||
|
||||

|
||||
|
||||
**第一步:思考每輪的決策,定義狀態,從而得到 $dp$ 表**
|
||||
|
||||
本題的每一輪的決策就是從當前格子向下或向右走一步。設當前格子的行列索引為 $[i, j]$ ,則向下或向右走一步後,索引變為 $[i+1, j]$ 或 $[i, j+1]$ 。因此,狀態應包含行索引和列索引兩個變數,記為 $[i, j]$ 。
|
||||
|
||||
狀態 $[i, j]$ 對應的子問題為:從起始點 $[0, 0]$ 走到 $[i, j]$ 的最小路徑和,解記為 $dp[i, j]$ 。
|
||||
|
||||
至此,我們就得到了下圖所示的二維 $dp$ 矩陣,其尺寸與輸入網格 $grid$ 相同。
|
||||
|
||||

|
||||
|
||||
!!! note
|
||||
|
||||
動態規劃和回溯過程可以描述為一個決策序列,而狀態由所有決策變數構成。它應當包含描述解題進度的所有變數,其包含了足夠的資訊,能夠用來推導出下一個狀態。
|
||||
|
||||
每個狀態都對應一個子問題,我們會定義一個 $dp$ 表來儲存所有子問題的解,狀態的每個獨立變數都是 $dp$ 表的一個維度。從本質上看,$dp$ 表是狀態和子問題的解之間的對映。
|
||||
|
||||
**第二步:找出最優子結構,進而推導出狀態轉移方程**
|
||||
|
||||
對於狀態 $[i, j]$ ,它只能從上邊格子 $[i-1, j]$ 和左邊格子 $[i, j-1]$ 轉移而來。因此最優子結構為:到達 $[i, j]$ 的最小路徑和由 $[i, j-1]$ 的最小路徑和與 $[i-1, j]$ 的最小路徑和中較小的那一個決定。
|
||||
|
||||
根據以上分析,可推出下圖所示的狀態轉移方程:
|
||||
|
||||
$$
|
||||
dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j]
|
||||
$$
|
||||
|
||||

|
||||
|
||||
!!! note
|
||||
|
||||
根據定義好的 $dp$ 表,思考原問題和子問題的關係,找出透過子問題的最優解來構造原問題的最優解的方法,即最優子結構。
|
||||
|
||||
一旦我們找到了最優子結構,就可以使用它來構建出狀態轉移方程。
|
||||
|
||||
**第三步:確定邊界條件和狀態轉移順序**
|
||||
|
||||
在本題中,處在首行的狀態只能從其左邊的狀態得來,處在首列的狀態只能從其上邊的狀態得來,因此首行 $i = 0$ 和首列 $j = 0$ 是邊界條件。
|
||||
|
||||
如下圖所示,由於每個格子是由其左方格子和上方格子轉移而來,因此我們使用迴圈來走訪矩陣,外迴圈走訪各行,內迴圈走訪各列。
|
||||
|
||||

|
||||
|
||||
!!! note
|
||||
|
||||
邊界條件在動態規劃中用於初始化 $dp$ 表,在搜尋中用於剪枝。
|
||||
|
||||
狀態轉移順序的核心是要保證在計算當前問題的解時,所有它依賴的更小子問題的解都已經被正確地計算出來。
|
||||
|
||||
根據以上分析,我們已經可以直接寫出動態規劃程式碼。然而子問題分解是一種從頂至底的思想,因此按照“暴力搜尋 $\rightarrow$ 記憶化搜尋 $\rightarrow$ 動態規劃”的順序實現更加符合思維習慣。
|
||||
|
||||
### 方法一:暴力搜尋
|
||||
|
||||
從狀態 $[i, j]$ 開始搜尋,不斷分解為更小的狀態 $[i-1, j]$ 和 $[i, j-1]$ ,遞迴函式包括以下要素。
|
||||
|
||||
- **遞迴參數**:狀態 $[i, j]$ 。
|
||||
- **返回值**:從 $[0, 0]$ 到 $[i, j]$ 的最小路徑和 $dp[i, j]$ 。
|
||||
- **終止條件**:當 $i = 0$ 且 $j = 0$ 時,返回代價 $grid[0, 0]$ 。
|
||||
- **剪枝**:當 $i < 0$ 時或 $j < 0$ 時索引越界,此時返回代價 $+\infty$ ,代表不可行。
|
||||
|
||||
實現程式碼如下:
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs}
|
||||
```
|
||||
|
||||
下圖給出了以 $dp[2, 1]$ 為根節點的遞迴樹,其中包含一些重疊子問題,其數量會隨著網格 `grid` 的尺寸變大而急劇增多。
|
||||
|
||||
從本質上看,造成重疊子問題的原因為:**存在多條路徑可以從左上角到達某一單元格**。
|
||||
|
||||

|
||||
|
||||
每個狀態都有向下和向右兩種選擇,從左上角走到右下角總共需要 $m + n - 2$ 步,所以最差時間複雜度為 $O(2^{m + n})$ 。請注意,這種計算方式未考慮臨近網格邊界的情況,當到達網路邊界時只剩下一種選擇,因此實際的路徑數量會少一些。
|
||||
|
||||
### 方法二:記憶化搜尋
|
||||
|
||||
我們引入一個和網格 `grid` 相同尺寸的記憶串列 `mem` ,用於記錄各個子問題的解,並將重疊子問題進行剪枝:
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs_mem}
|
||||
```
|
||||
|
||||
如下圖所示,在引入記憶化後,所有子問題的解只需計算一次,因此時間複雜度取決於狀態總數,即網格尺寸 $O(nm)$ 。
|
||||
|
||||

|
||||
|
||||
### 方法三:動態規劃
|
||||
|
||||
基於迭代實現動態規劃解法,程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp}
|
||||
```
|
||||
|
||||
下圖展示了最小路徑和的狀態轉移過程,其走訪了整個網格,**因此時間複雜度為 $O(nm)$** 。
|
||||
|
||||
陣列 `dp` 大小為 $n \times m$ ,**因此空間複雜度為 $O(nm)$** 。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
=== "<9>"
|
||||

|
||||
|
||||
=== "<10>"
|
||||

|
||||
|
||||
=== "<11>"
|
||||

|
||||
|
||||
=== "<12>"
|
||||

|
||||
|
||||
### 空間最佳化
|
||||
|
||||
由於每個格子只與其左邊和上邊的格子有關,因此我們可以只用一個單行陣列來實現 $dp$ 表。
|
||||
|
||||
請注意,因為陣列 `dp` 只能表示一行的狀態,所以我們無法提前初始化首列狀態,而是在走訪每行時更新它:
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp_comp}
|
||||
```
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,129 @@
|
||||
# 編輯距離問題
|
||||
|
||||
編輯距離,也稱 Levenshtein 距離,指兩個字串之間互相轉換的最少修改次數,通常用於在資訊檢索和自然語言處理中度量兩個序列的相似度。
|
||||
|
||||
!!! question
|
||||
|
||||
輸入兩個字串 $s$ 和 $t$ ,返回將 $s$ 轉換為 $t$ 所需的最少編輯步數。
|
||||
|
||||
你可以在一個字串中進行三種編輯操作:插入一個字元、刪除一個字元、將字元替換為任意一個字元。
|
||||
|
||||
如下圖所示,將 `kitten` 轉換為 `sitting` 需要編輯 3 步,包括 2 次替換操作與 1 次新增操作;將 `hello` 轉換為 `algo` 需要 3 步,包括 2 次替換操作和 1 次刪除操作。
|
||||
|
||||

|
||||
|
||||
**編輯距離問題可以很自然地用決策樹模型來解釋**。字串對應樹節點,一輪決策(一次編輯操作)對應樹的一條邊。
|
||||
|
||||
如下圖所示,在不限制操作的情況下,每個節點都可以派生出許多條邊,每條邊對應一種操作,這意味著從 `hello` 轉換到 `algo` 有許多種可能的路徑。
|
||||
|
||||
從決策樹的角度看,本題的目標是求解節點 `hello` 和節點 `algo` 之間的最短路徑。
|
||||
|
||||

|
||||
|
||||
### 動態規劃思路
|
||||
|
||||
**第一步:思考每輪的決策,定義狀態,從而得到 $dp$ 表**
|
||||
|
||||
每一輪的決策是對字串 $s$ 進行一次編輯操作。
|
||||
|
||||
我們希望在編輯操作的過程中,問題的規模逐漸縮小,這樣才能構建子問題。設字串 $s$ 和 $t$ 的長度分別為 $n$ 和 $m$ ,我們先考慮兩字串尾部的字元 $s[n-1]$ 和 $t[m-1]$ 。
|
||||
|
||||
- 若 $s[n-1]$ 和 $t[m-1]$ 相同,我們可以跳過它們,直接考慮 $s[n-2]$ 和 $t[m-2]$ 。
|
||||
- 若 $s[n-1]$ 和 $t[m-1]$ 不同,我們需要對 $s$ 進行一次編輯(插入、刪除、替換),使得兩字串尾部的字元相同,從而可以跳過它們,考慮規模更小的問題。
|
||||
|
||||
也就是說,我們在字串 $s$ 中進行的每一輪決策(編輯操作),都會使得 $s$ 和 $t$ 中剩餘的待匹配字元發生變化。因此,狀態為當前在 $s$ 和 $t$ 中考慮的第 $i$ 和第 $j$ 個字元,記為 $[i, j]$ 。
|
||||
|
||||
狀態 $[i, j]$ 對應的子問題:**將 $s$ 的前 $i$ 個字元更改為 $t$ 的前 $j$ 個字元所需的最少編輯步數**。
|
||||
|
||||
至此,得到一個尺寸為 $(i+1) \times (j+1)$ 的二維 $dp$ 表。
|
||||
|
||||
**第二步:找出最優子結構,進而推導出狀態轉移方程**
|
||||
|
||||
考慮子問題 $dp[i, j]$ ,其對應的兩個字串的尾部字元為 $s[i-1]$ 和 $t[j-1]$ ,可根據不同編輯操作分為下圖所示的三種情況。
|
||||
|
||||
1. 在 $s[i-1]$ 之後新增 $t[j-1]$ ,則剩餘子問題 $dp[i, j-1]$ 。
|
||||
2. 刪除 $s[i-1]$ ,則剩餘子問題 $dp[i-1, j]$ 。
|
||||
3. 將 $s[i-1]$ 替換為 $t[j-1]$ ,則剩餘子問題 $dp[i-1, j-1]$ 。
|
||||
|
||||

|
||||
|
||||
根據以上分析,可得最優子結構:$dp[i, j]$ 的最少編輯步數等於 $dp[i, j-1]$、$dp[i-1, j]$、$dp[i-1, j-1]$ 三者中的最少編輯步數,再加上本次的編輯步數 $1$ 。對應的狀態轉移方程為:
|
||||
|
||||
$$
|
||||
dp[i, j] = \min(dp[i, j-1], dp[i-1, j], dp[i-1, j-1]) + 1
|
||||
$$
|
||||
|
||||
請注意,**當 $s[i-1]$ 和 $t[j-1]$ 相同時,無須編輯當前字元**,這種情況下的狀態轉移方程為:
|
||||
|
||||
$$
|
||||
dp[i, j] = dp[i-1, j-1]
|
||||
$$
|
||||
|
||||
**第三步:確定邊界條件和狀態轉移順序**
|
||||
|
||||
當兩字串都為空時,編輯步數為 $0$ ,即 $dp[0, 0] = 0$ 。當 $s$ 為空但 $t$ 不為空時,最少編輯步數等於 $t$ 的長度,即首行 $dp[0, j] = j$ 。當 $s$ 不為空但 $t$ 為空時,最少編輯步數等於 $s$ 的長度,即首列 $dp[i, 0] = i$ 。
|
||||
|
||||
觀察狀態轉移方程,解 $dp[i, j]$ 依賴左方、上方、左上方的解,因此透過兩層迴圈正序走訪整個 $dp$ 表即可。
|
||||
|
||||
### 程式碼實現
|
||||
|
||||
```src
|
||||
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp}
|
||||
```
|
||||
|
||||
如下圖所示,編輯距離問題的狀態轉移過程與背包問題非常類似,都可以看作填寫一個二維網格的過程。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
=== "<9>"
|
||||

|
||||
|
||||
=== "<10>"
|
||||

|
||||
|
||||
=== "<11>"
|
||||

|
||||
|
||||
=== "<12>"
|
||||

|
||||
|
||||
=== "<13>"
|
||||

|
||||
|
||||
=== "<14>"
|
||||

|
||||
|
||||
=== "<15>"
|
||||

|
||||
|
||||
### 空間最佳化
|
||||
|
||||
由於 $dp[i,j]$ 是由上方 $dp[i-1, j]$、左方 $dp[i, j-1]$、左上方 $dp[i-1, j-1]$ 轉移而來的,而正序走訪會丟失左上方 $dp[i-1, j-1]$ ,倒序走訪無法提前構建 $dp[i, j-1]$ ,因此兩種走訪順序都不可取。
|
||||
|
||||
為此,我們可以使用一個變數 `leftup` 來暫存左上方的解 $dp[i-1, j-1]$ ,從而只需考慮左方和上方的解。此時的情況與完全背包問題相同,可使用正序走訪。程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp_comp}
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
# 動態規劃
|
||||
|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
小溪匯入河流,江河匯入大海。
|
||||
|
||||
動態規劃將小問題的解彙集成大問題的答案,一步步引領我們走向解決問題的彼岸。
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,110 @@
|
||||
# 初探動態規劃
|
||||
|
||||
<u>動態規劃(dynamic programming)</u>是一個重要的演算法範式,它將一個問題分解為一系列更小的子問題,並透過儲存子問題的解來避免重複計算,從而大幅提升時間效率。
|
||||
|
||||
在本節中,我們從一個經典例題入手,先給出它的暴力回溯解法,觀察其中包含的重疊子問題,再逐步導出更高效的動態規劃解法。
|
||||
|
||||
!!! question "爬樓梯"
|
||||
|
||||
給定一個共有 $n$ 階的樓梯,你每步可以上 $1$ 階或者 $2$ 階,請問有多少種方案可以爬到樓頂?
|
||||
|
||||
如下圖所示,對於一個 $3$ 階樓梯,共有 $3$ 種方案可以爬到樓頂。
|
||||
|
||||

|
||||
|
||||
本題的目標是求解方案數量,**我們可以考慮透過回溯來窮舉所有可能性**。具體來說,將爬樓梯想象為一個多輪選擇的過程:從地面出發,每輪選擇上 $1$ 階或 $2$ 階,每當到達樓梯頂部時就將方案數量加 $1$ ,當越過樓梯頂部時就將其剪枝。程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_backtrack}-[class]{}-[func]{climbing_stairs_backtrack}
|
||||
```
|
||||
|
||||
## 方法一:暴力搜尋
|
||||
|
||||
回溯演算法通常並不顯式地對問題進行拆解,而是將求解問題看作一系列決策步驟,透過試探和剪枝,搜尋所有可能的解。
|
||||
|
||||
我們可以嘗試從問題分解的角度分析這道題。設爬到第 $i$ 階共有 $dp[i]$ 種方案,那麼 $dp[i]$ 就是原問題,其子問題包括:
|
||||
|
||||
$$
|
||||
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
|
||||
$$
|
||||
|
||||
由於每輪只能上 $1$ 階或 $2$ 階,因此當我們站在第 $i$ 階樓梯上時,上一輪只可能站在第 $i - 1$ 階或第 $i - 2$ 階上。換句話說,我們只能從第 $i -1$ 階或第 $i - 2$ 階邁向第 $i$ 階。
|
||||
|
||||
由此便可得出一個重要推論:**爬到第 $i - 1$ 階的方案數加上爬到第 $i - 2$ 階的方案數就等於爬到第 $i$ 階的方案數**。公式如下:
|
||||
|
||||
$$
|
||||
dp[i] = dp[i-1] + dp[i-2]
|
||||
$$
|
||||
|
||||
這意味著在爬樓梯問題中,各個子問題之間存在遞推關係,**原問題的解可以由子問題的解構建得來**。下圖展示了該遞推關係。
|
||||
|
||||

|
||||
|
||||
我們可以根據遞推公式得到暴力搜尋解法。以 $dp[n]$ 為起始點,**遞迴地將一個較大問題拆解為兩個較小問題的和**,直至到達最小子問題 $dp[1]$ 和 $dp[2]$ 時返回。其中,最小子問題的解是已知的,即 $dp[1] = 1$、$dp[2] = 2$ ,表示爬到第 $1$、$2$ 階分別有 $1$、$2$ 種方案。
|
||||
|
||||
觀察以下程式碼,它和標準回溯程式碼都屬於深度優先搜尋,但更加簡潔:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dfs}-[class]{}-[func]{climbing_stairs_dfs}
|
||||
```
|
||||
|
||||
下圖展示了暴力搜尋形成的遞迴樹。對於問題 $dp[n]$ ,其遞迴樹的深度為 $n$ ,時間複雜度為 $O(2^n)$ 。指數階屬於爆炸式增長,如果我們輸入一個比較大的 $n$ ,則會陷入漫長的等待之中。
|
||||
|
||||

|
||||
|
||||
觀察上圖,**指數階的時間複雜度是“重疊子問題”導致的**。例如 $dp[9]$ 被分解為 $dp[8]$ 和 $dp[7]$ ,$dp[8]$ 被分解為 $dp[7]$ 和 $dp[6]$ ,兩者都包含子問題 $dp[7]$ 。
|
||||
|
||||
以此類推,子問題中包含更小的重疊子問題,子子孫孫無窮盡也。絕大部分計算資源都浪費在這些重疊的子問題上。
|
||||
|
||||
## 方法二:記憶化搜尋
|
||||
|
||||
為了提升演算法效率,**我們希望所有的重疊子問題都只被計算一次**。為此,我們宣告一個陣列 `mem` 來記錄每個子問題的解,並在搜尋過程中將重疊子問題剪枝。
|
||||
|
||||
1. 當首次計算 $dp[i]$ 時,我們將其記錄至 `mem[i]` ,以便之後使用。
|
||||
2. 當再次需要計算 $dp[i]$ 時,我們便可直接從 `mem[i]` 中獲取結果,從而避免重複計算該子問題。
|
||||
|
||||
程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}
|
||||
```
|
||||
|
||||
觀察下圖,**經過記憶化處理後,所有重疊子問題都只需計算一次,時間複雜度最佳化至 $O(n)$** ,這是一個巨大的飛躍。
|
||||
|
||||

|
||||
|
||||
## 方法三:動態規劃
|
||||
|
||||
**記憶化搜尋是一種“從頂至底”的方法**:我們從原問題(根節點)開始,遞迴地將較大子問題分解為較小子問題,直至解已知的最小子問題(葉節點)。之後,透過回溯逐層收集子問題的解,構建出原問題的解。
|
||||
|
||||
與之相反,**動態規劃是一種“從底至頂”的方法**:從最小子問題的解開始,迭代地構建更大子問題的解,直至得到原問題的解。
|
||||
|
||||
由於動態規劃不包含回溯過程,因此只需使用迴圈迭代實現,無須使用遞迴。在以下程式碼中,我們初始化一個陣列 `dp` 來儲存子問題的解,它起到了與記憶化搜尋中陣列 `mem` 相同的記錄作用:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp}
|
||||
```
|
||||
|
||||
下圖模擬了以上程式碼的執行過程。
|
||||
|
||||

|
||||
|
||||
與回溯演算法一樣,動態規劃也使用“狀態”概念來表示問題求解的特定階段,每個狀態都對應一個子問題以及相應的區域性最優解。例如,爬樓梯問題的狀態定義為當前所在樓梯階數 $i$ 。
|
||||
|
||||
根據以上內容,我們可以總結出動態規劃的常用術語。
|
||||
|
||||
- 將陣列 `dp` 稱為 <u>dp 表</u>,$dp[i]$ 表示狀態 $i$ 對應子問題的解。
|
||||
- 將最小子問題對應的狀態(第 $1$ 階和第 $2$ 階樓梯)稱為<u>初始狀態</u>。
|
||||
- 將遞推公式 $dp[i] = dp[i-1] + dp[i-2]$ 稱為<u>狀態轉移方程</u>。
|
||||
|
||||
## 空間最佳化
|
||||
|
||||
細心的讀者可能發現了,**由於 $dp[i]$ 只與 $dp[i-1]$ 和 $dp[i-2]$ 有關,因此我們無須使用一個陣列 `dp` 來儲存所有子問題的解**,而只需兩個變數滾動前進即可。程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp_comp}
|
||||
```
|
||||
|
||||
觀察以上程式碼,由於省去了陣列 `dp` 佔用的空間,因此空間複雜度從 $O(n)$ 降至 $O(1)$ 。
|
||||
|
||||
在動態規劃問題中,當前狀態往往僅與前面有限個狀態有關,這時我們可以只保留必要的狀態,透過“降維”來節省記憶體空間。**這種空間最佳化技巧被稱為“滾動變數”或“滾動陣列”**。
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,168 @@
|
||||
# 0-1 背包問題
|
||||
|
||||
背包問題是一個非常好的動態規劃入門題目,是動態規劃中最常見的問題形式。其具有很多變種,例如 0-1 背包問題、完全背包問題、多重背包問題等。
|
||||
|
||||
在本節中,我們先來求解最常見的 0-1 背包問題。
|
||||
|
||||
!!! question
|
||||
|
||||
給定 $n$ 個物品,第 $i$ 個物品的重量為 $wgt[i-1]$、價值為 $val[i-1]$ ,和一個容量為 $cap$ 的背包。每個物品只能選擇一次,問在限定背包容量下能放入物品的最大價值。
|
||||
|
||||
觀察下圖,由於物品編號 $i$ 從 $1$ 開始計數,陣列索引從 $0$ 開始計數,因此物品 $i$ 對應重量 $wgt[i-1]$ 和價值 $val[i-1]$ 。
|
||||
|
||||

|
||||
|
||||
我們可以將 0-1 背包問題看作一個由 $n$ 輪決策組成的過程,對於每個物體都有不放入和放入兩種決策,因此該問題滿足決策樹模型。
|
||||
|
||||
該問題的目標是求解“在限定背包容量下能放入物品的最大價值”,因此較大機率是一個動態規劃問題。
|
||||
|
||||
**第一步:思考每輪的決策,定義狀態,從而得到 $dp$ 表**
|
||||
|
||||
對於每個物品來說,不放入背包,背包容量不變;放入背包,背包容量減小。由此可得狀態定義:當前物品編號 $i$ 和剩餘背包容量 $c$ ,記為 $[i, c]$ 。
|
||||
|
||||
狀態 $[i, c]$ 對應的子問題為:**前 $i$ 個物品在剩餘容量為 $c$ 的背包中的最大價值**,記為 $dp[i, c]$ 。
|
||||
|
||||
待求解的是 $dp[n, cap]$ ,因此需要一個尺寸為 $(n+1) \times (cap+1)$ 的二維 $dp$ 表。
|
||||
|
||||
**第二步:找出最優子結構,進而推導出狀態轉移方程**
|
||||
|
||||
當我們做出物品 $i$ 的決策後,剩餘的是前 $i-1$ 個物品的決策,可分為以下兩種情況。
|
||||
|
||||
- **不放入物品 $i$** :背包容量不變,狀態變化為 $[i-1, c]$ 。
|
||||
- **放入物品 $i$** :背包容量減少 $wgt[i-1]$ ,價值增加 $val[i-1]$ ,狀態變化為 $[i-1, c-wgt[i-1]]$ 。
|
||||
|
||||
上述分析向我們揭示了本題的最優子結構:**最大價值 $dp[i, c]$ 等於不放入物品 $i$ 和放入物品 $i$ 兩種方案中價值更大的那一個**。由此可推導出狀態轉移方程:
|
||||
|
||||
$$
|
||||
dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
|
||||
$$
|
||||
|
||||
需要注意的是,若當前物品重量 $wgt[i - 1]$ 超出剩餘背包容量 $c$ ,則只能選擇不放入背包。
|
||||
|
||||
**第三步:確定邊界條件和狀態轉移順序**
|
||||
|
||||
當無物品或無剩餘背包容量時最大價值為 $0$ ,即首列 $dp[i, 0]$ 和首行 $dp[0, c]$ 都等於 $0$ 。
|
||||
|
||||
當前狀態 $[i, c]$ 從上方的狀態 $[i-1, c]$ 和左上方的狀態 $[i-1, c-wgt[i-1]]$ 轉移而來,因此透過兩層迴圈正序走訪整個 $dp$ 表即可。
|
||||
|
||||
根據以上分析,我們接下來按順序實現暴力搜尋、記憶化搜尋、動態規劃解法。
|
||||
|
||||
### 方法一:暴力搜尋
|
||||
|
||||
搜尋程式碼包含以下要素。
|
||||
|
||||
- **遞迴參數**:狀態 $[i, c]$ 。
|
||||
- **返回值**:子問題的解 $dp[i, c]$ 。
|
||||
- **終止條件**:當物品編號越界 $i = 0$ 或背包剩餘容量為 $0$ 時,終止遞迴並返回價值 $0$ 。
|
||||
- **剪枝**:若當前物品重量超出背包剩餘容量,則只能選擇不放入背包。
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dfs}
|
||||
```
|
||||
|
||||
如下圖所示,由於每個物品都會產生不選和選兩條搜尋分支,因此時間複雜度為 $O(2^n)$ 。
|
||||
|
||||
觀察遞迴樹,容易發現其中存在重疊子問題,例如 $dp[1, 10]$ 等。而當物品較多、背包容量較大,尤其是相同重量的物品較多時,重疊子問題的數量將會大幅增多。
|
||||
|
||||

|
||||
|
||||
### 方法二:記憶化搜尋
|
||||
|
||||
為了保證重疊子問題只被計算一次,我們藉助記憶串列 `mem` 來記錄子問題的解,其中 `mem[i][c]` 對應 $dp[i, c]$ 。
|
||||
|
||||
引入記憶化之後,**時間複雜度取決於子問題數量**,也就是 $O(n \times cap)$ 。實現程式碼如下:
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dfs_mem}
|
||||
```
|
||||
|
||||
下圖展示了在記憶化搜尋中被剪掉的搜尋分支。
|
||||
|
||||

|
||||
|
||||
### 方法三:動態規劃
|
||||
|
||||
動態規劃實質上就是在狀態轉移中填充 $dp$ 表的過程,程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dp}
|
||||
```
|
||||
|
||||
如下圖所示,時間複雜度和空間複雜度都由陣列 `dp` 大小決定,即 $O(n \times cap)$ 。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
=== "<9>"
|
||||

|
||||
|
||||
=== "<10>"
|
||||

|
||||
|
||||
=== "<11>"
|
||||

|
||||
|
||||
=== "<12>"
|
||||

|
||||
|
||||
=== "<13>"
|
||||

|
||||
|
||||
=== "<14>"
|
||||

|
||||
|
||||
### 空間最佳化
|
||||
|
||||
由於每個狀態都只與其上一行的狀態有關,因此我們可以使用兩個陣列滾動前進,將空間複雜度從 $O(n^2)$ 降至 $O(n)$ 。
|
||||
|
||||
進一步思考,我們能否僅用一個陣列實現空間最佳化呢?觀察可知,每個狀態都是由正上方或左上方的格子轉移過來的。假設只有一個陣列,當開始走訪第 $i$ 行時,該陣列儲存的仍然是第 $i-1$ 行的狀態。
|
||||
|
||||
- 如果採取正序走訪,那麼走訪到 $dp[i, j]$ 時,左上方 $dp[i-1, 1]$ ~ $dp[i-1, j-1]$ 值可能已經被覆蓋,此時就無法得到正確的狀態轉移結果。
|
||||
- 如果採取倒序走訪,則不會發生覆蓋問題,狀態轉移可以正確進行。
|
||||
|
||||
下圖展示了在單個陣列下從第 $i = 1$ 行轉換至第 $i = 2$ 行的過程。請思考正序走訪和倒序走訪的區別。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
在程式碼實現中,我們僅需將陣列 `dp` 的第一維 $i$ 直接刪除,並且把內迴圈更改為倒序走訪即可:
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dp_comp}
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# 小結
|
||||
|
||||
- 動態規劃對問題進行分解,並透過儲存子問題的解來規避重複計算,提高計算效率。
|
||||
- 不考慮時間的前提下,所有動態規劃問題都可以用回溯(暴力搜尋)進行求解,但遞迴樹中存在大量的重疊子問題,效率極低。透過引入記憶化串列,可以儲存所有計算過的子問題的解,從而保證重疊子問題只被計算一次。
|
||||
- 記憶化搜尋是一種從頂至底的遞迴式解法,而與之對應的動態規劃是一種從底至頂的遞推式解法,其如同“填寫表格”一樣。由於當前狀態僅依賴某些區域性狀態,因此我們可以消除 $dp$ 表的一個維度,從而降低空間複雜度。
|
||||
- 子問題分解是一種通用的演算法思路,在分治、動態規劃、回溯中具有不同的性質。
|
||||
- 動態規劃問題有三大特性:重疊子問題、最優子結構、無後效性。
|
||||
- 如果原問題的最優解可以從子問題的最優解構建得來,則它就具有最優子結構。
|
||||
- 無後效性指對於一個狀態,其未來發展只與該狀態有關,而與過去經歷的所有狀態無關。許多組合最佳化問題不具有無後效性,無法使用動態規劃快速求解。
|
||||
|
||||
**背包問題**
|
||||
|
||||
- 背包問題是最典型的動態規劃問題之一,具有 0-1 背包、完全背包、多重背包等變種。
|
||||
- 0-1 背包的狀態定義為前 $i$ 個物品在剩餘容量為 $c$ 的背包中的最大價值。根據不放入背包和放入背包兩種決策,可得到最優子結構,並構建出狀態轉移方程。在空間最佳化中,由於每個狀態依賴正上方和左上方的狀態,因此需要倒序走訪串列,避免左上方狀態被覆蓋。
|
||||
- 完全背包問題的每種物品的選取數量無限制,因此選擇放入物品的狀態轉移與 0-1 背包問題不同。由於狀態依賴正上方和正左方的狀態,因此在空間最佳化中應當正序走訪。
|
||||
- 零錢兌換問題是完全背包問題的一個變種。它從求“最大”價值變為求“最小”硬幣數量,因此狀態轉移方程中的 $\max()$ 應改為 $\min()$ 。從追求“不超過”背包容量到追求“恰好”湊出目標金額,因此使用 $amt + 1$ 來表示“無法湊出目標金額”的無效解。
|
||||
- 零錢兌換問題 II 從求“最少硬幣數量”改為求“硬幣組合數量”,狀態轉移方程相應地從 $\min()$ 改為求和運算子。
|
||||
|
||||
**編輯距離問題**
|
||||
|
||||
- 編輯距離(Levenshtein 距離)用於衡量兩個字串之間的相似度,其定義為從一個字串到另一個字串的最少編輯步數,編輯操作包括新增、刪除、替換。
|
||||
- 編輯距離問題的狀態定義為將 $s$ 的前 $i$ 個字元更改為 $t$ 的前 $j$ 個字元所需的最少編輯步數。當 $s[i] \ne t[j]$ 時,具有三種決策:新增、刪除、替換,它們都有相應的剩餘子問題。據此便可以找出最優子結構與構建狀態轉移方程。而當 $s[i] = t[j]$ 時,無須編輯當前字元。
|
||||
- 在編輯距離中,狀態依賴其正上方、正左方、左上方的狀態,因此空間最佳化後正序或倒序走訪都無法正確地進行狀態轉移。為此,我們利用一個變數暫存左上方狀態,從而轉化到與完全背包問題等價的情況,可以在空間最佳化後進行正序走訪。
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,207 @@
|
||||
# 完全背包問題
|
||||
|
||||
在本節中,我們先求解另一個常見的背包問題:完全背包,再瞭解它的一種特例:零錢兌換。
|
||||
|
||||
## 完全背包問題
|
||||
|
||||
!!! question
|
||||
|
||||
給定 $n$ 個物品,第 $i$ 個物品的重量為 $wgt[i-1]$、價值為 $val[i-1]$ ,和一個容量為 $cap$ 的背包。**每個物品可以重複選取**,問在限定背包容量下能放入物品的最大價值。示例如下圖所示。
|
||||
|
||||

|
||||
|
||||
### 動態規劃思路
|
||||
|
||||
完全背包問題和 0-1 背包問題非常相似,**區別僅在於不限制物品的選擇次數**。
|
||||
|
||||
- 在 0-1 背包問題中,每種物品只有一個,因此將物品 $i$ 放入背包後,只能從前 $i-1$ 個物品中選擇。
|
||||
- 在完全背包問題中,每種物品的數量是無限的,因此將物品 $i$ 放入背包後,**仍可以從前 $i$ 個物品中選擇**。
|
||||
|
||||
在完全背包問題的規定下,狀態 $[i, c]$ 的變化分為兩種情況。
|
||||
|
||||
- **不放入物品 $i$** :與 0-1 背包問題相同,轉移至 $[i-1, c]$ 。
|
||||
- **放入物品 $i$** :與 0-1 背包問題不同,轉移至 $[i, c-wgt[i-1]]$ 。
|
||||
|
||||
從而狀態轉移方程變為:
|
||||
|
||||
$$
|
||||
dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1])
|
||||
$$
|
||||
|
||||
### 程式碼實現
|
||||
|
||||
對比兩道題目的程式碼,狀態轉移中有一處從 $i-1$ 變為 $i$ ,其餘完全一致:
|
||||
|
||||
```src
|
||||
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp}
|
||||
```
|
||||
|
||||
### 空間最佳化
|
||||
|
||||
由於當前狀態是從左邊和上邊的狀態轉移而來的,**因此空間最佳化後應該對 $dp$ 表中的每一行進行正序走訪**。
|
||||
|
||||
這個走訪順序與 0-1 背包正好相反。請藉助下圖來理解兩者的區別。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
程式碼實現比較簡單,僅需將陣列 `dp` 的第一維刪除:
|
||||
|
||||
```src
|
||||
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp_comp}
|
||||
```
|
||||
|
||||
## 零錢兌換問題
|
||||
|
||||
背包問題是一大類動態規劃問題的代表,其擁有很多變種,例如零錢兌換問題。
|
||||
|
||||
!!! question
|
||||
|
||||
給定 $n$ 種硬幣,第 $i$ 種硬幣的面值為 $coins[i - 1]$ ,目標金額為 $amt$ ,**每種硬幣可以重複選取**,問能夠湊出目標金額的最少硬幣數量。如果無法湊出目標金額,則返回 $-1$ 。示例如下圖所示。
|
||||
|
||||

|
||||
|
||||
### 動態規劃思路
|
||||
|
||||
**零錢兌換可以看作完全背包問題的一種特殊情況**,兩者具有以下關聯與不同點。
|
||||
|
||||
- 兩道題可以相互轉換,“物品”對應“硬幣”、“物品重量”對應“硬幣面值”、“背包容量”對應“目標金額”。
|
||||
- 最佳化目標相反,完全背包問題是要最大化物品價值,零錢兌換問題是要最小化硬幣數量。
|
||||
- 完全背包問題是求“不超過”背包容量下的解,零錢兌換是求“恰好”湊到目標金額的解。
|
||||
|
||||
**第一步:思考每輪的決策,定義狀態,從而得到 $dp$ 表**
|
||||
|
||||
狀態 $[i, a]$ 對應的子問題為:**前 $i$ 種硬幣能夠湊出金額 $a$ 的最少硬幣數量**,記為 $dp[i, a]$ 。
|
||||
|
||||
二維 $dp$ 表的尺寸為 $(n+1) \times (amt+1)$ 。
|
||||
|
||||
**第二步:找出最優子結構,進而推導出狀態轉移方程**
|
||||
|
||||
本題與完全背包問題的狀態轉移方程存在以下兩點差異。
|
||||
|
||||
- 本題要求最小值,因此需將運算子 $\max()$ 更改為 $\min()$ 。
|
||||
- 最佳化主體是硬幣數量而非商品價值,因此在選中硬幣時執行 $+1$ 即可。
|
||||
|
||||
$$
|
||||
dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
|
||||
$$
|
||||
|
||||
**第三步:確定邊界條件和狀態轉移順序**
|
||||
|
||||
當目標金額為 $0$ 時,湊出它的最少硬幣數量為 $0$ ,即首列所有 $dp[i, 0]$ 都等於 $0$ 。
|
||||
|
||||
當無硬幣時,**無法湊出任意 $> 0$ 的目標金額**,即是無效解。為使狀態轉移方程中的 $\min()$ 函式能夠識別並過濾無效解,我們考慮使用 $+ \infty$ 來表示它們,即令首行所有 $dp[0, a]$ 都等於 $+ \infty$ 。
|
||||
|
||||
### 程式碼實現
|
||||
|
||||
大多數程式語言並未提供 $+ \infty$ 變數,只能使用整型 `int` 的最大值來代替。而這又會導致大數越界:狀態轉移方程中的 $+ 1$ 操作可能發生溢位。
|
||||
|
||||
為此,我們採用數字 $amt + 1$ 來表示無效解,因為湊出 $amt$ 的硬幣數量最多為 $amt$ 。最後返回前,判斷 $dp[n, amt]$ 是否等於 $amt + 1$ ,若是則返回 $-1$ ,代表無法湊出目標金額。程式碼如下所示:
|
||||
|
||||
```src
|
||||
[file]{coin_change}-[class]{}-[func]{coin_change_dp}
|
||||
```
|
||||
|
||||
下圖展示了零錢兌換的動態規劃過程,和完全背包問題非常相似。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||
|
||||
=== "<6>"
|
||||

|
||||
|
||||
=== "<7>"
|
||||

|
||||
|
||||
=== "<8>"
|
||||

|
||||
|
||||
=== "<9>"
|
||||

|
||||
|
||||
=== "<10>"
|
||||

|
||||
|
||||
=== "<11>"
|
||||

|
||||
|
||||
=== "<12>"
|
||||

|
||||
|
||||
=== "<13>"
|
||||

|
||||
|
||||
=== "<14>"
|
||||

|
||||
|
||||
=== "<15>"
|
||||

|
||||
|
||||
### 空間最佳化
|
||||
|
||||
零錢兌換的空間最佳化的處理方式和完全背包問題一致:
|
||||
|
||||
```src
|
||||
[file]{coin_change}-[class]{}-[func]{coin_change_dp_comp}
|
||||
```
|
||||
|
||||
## 零錢兌換問題 II
|
||||
|
||||
!!! question
|
||||
|
||||
給定 $n$ 種硬幣,第 $i$ 種硬幣的面值為 $coins[i - 1]$ ,目標金額為 $amt$ ,每種硬幣可以重複選取,**問湊出目標金額的硬幣組合數量**。示例如下圖所示。
|
||||
|
||||

|
||||
|
||||
### 動態規劃思路
|
||||
|
||||
相比於上一題,本題目標是求組合數量,因此子問題變為:**前 $i$ 種硬幣能夠湊出金額 $a$ 的組合數量**。而 $dp$ 表仍然是尺寸為 $(n+1) \times (amt + 1)$ 的二維矩陣。
|
||||
|
||||
當前狀態的組合數量等於不選當前硬幣與選當前硬幣這兩種決策的組合數量之和。狀態轉移方程為:
|
||||
|
||||
$$
|
||||
dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]]
|
||||
$$
|
||||
|
||||
當目標金額為 $0$ 時,無須選擇任何硬幣即可湊出目標金額,因此應將首列所有 $dp[i, 0]$ 都初始化為 $1$ 。當無硬幣時,無法湊出任何 $>0$ 的目標金額,因此首行所有 $dp[0, a]$ 都等於 $0$ 。
|
||||
|
||||
### 程式碼實現
|
||||
|
||||
```src
|
||||
[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp}
|
||||
```
|
||||
|
||||
### 空間最佳化
|
||||
|
||||
空間最佳化處理方式相同,刪除硬幣維度即可:
|
||||
|
||||
```src
|
||||
[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp_comp}
|
||||
```
|
||||