W3Cschool
恭喜您成為首批注冊(cè)用戶
獲得88經(jīng)驗(yàn)值獎(jiǎng)勵(lì)
「動(dòng)態(tài)規(guī)劃 dynamic programming」是一個(gè)重要的算法范式,它將一個(gè)問題分解為一系列更小的子問題,并通過存儲(chǔ)子問題的解來避免重復(fù)計(jì)算,從而大幅提升時(shí)間效率。
在本節(jié)中,我們從一個(gè)經(jīng)典例題入手,先給出它的暴力回溯解法,觀察其中包含的重疊子問題,再逐步導(dǎo)出更高效的動(dòng)態(tài)規(guī)劃解法。
爬樓梯
給定一個(gè)共有
如圖 14-1 所示,對(duì)于一個(gè)
圖 14-1 爬到第 3 階的方案數(shù)量
本題的目標(biāo)是求解方案數(shù)量,我們可以考慮通過回溯來窮舉所有可能性。具體來說,將爬樓梯想象為一個(gè)多輪選擇的過程:從地面出發(fā),每輪選擇上
climbing_stairs_backtrack.cpp
/* 回溯 */
void backtrack(vector<int> &choices, int state, int n, vector<int> &res) {
// 當(dāng)爬到第 n 階時(shí),方案數(shù)量加 1
if (state == n)
res[0]++;
// 遍歷所有選擇
for (auto &choice : choices) {
// 剪枝:不允許越過第 n 階
if (state + choice > n)
break;
// 嘗試:做出選擇,更新狀態(tài)
backtrack(choices, state + choice, n, res);
// 回退
}
}
/* 爬樓梯:回溯 */
int climbingStairsBacktrack(int n) {
vector<int> choices = {1, 2}; // 可選擇向上爬 1 或 2 階
int state = 0; // 從第 0 階開始爬
vector<int> res = {0}; // 使用 res[0] 記錄方案數(shù)量
backtrack(choices, state, n, res);
return res[0];
}
回溯算法通常并不顯式地對(duì)問題進(jìn)行拆解,而是將問題看作一系列決策步驟,通過試探和剪枝,搜索所有可能的解。
我們可以嘗試從問題分解的角度分析這道題。設(shè)爬到第
由于每輪只能上
由此便可得出一個(gè)重要推論:爬到第
這意味著在爬樓梯問題中,各個(gè)子問題之間存在遞推關(guān)系,原問題的解可以由子問題的解構(gòu)建得來。圖 14-2 展示了該遞推關(guān)系。
圖 14-2 方案數(shù)量遞推關(guān)系
我們可以根據(jù)遞推公式得到暴力搜索解法。以
觀察以下代碼,它和標(biāo)準(zhǔn)回溯代碼都屬于深度優(yōu)先搜索,但更加簡(jiǎn)潔。
climbing_stairs_dfs.cpp
/* 搜索 */
int dfs(int i) {
// 已知 dp[1] 和 dp[2] ,返回之
if (i == 1 || i == 2)
return i;
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1) + dfs(i - 2);
return count;
}
/* 爬樓梯:搜索 */
int climbingStairsDFS(int n) {
return dfs(n);
}
圖 14-3 展示了暴力搜索形成的遞歸樹。對(duì)于問題
圖 14-3 爬樓梯對(duì)應(yīng)遞歸樹
觀察圖 14-3 ,指數(shù)階的時(shí)間復(fù)雜度是由于“重疊子問題”導(dǎo)致的。例如
以此類推,子問題中包含更小的重疊子問題,子子孫孫無窮盡也。絕大部分計(jì)算資源都浪費(fèi)在這些重疊的問題上。
為了提升算法效率,我們希望所有的重疊子問題都只被計(jì)算一次。為此,我們聲明一個(gè)數(shù)組 mem
來記錄每個(gè)子問題的解,并在搜索過程中將重疊子問題剪枝。
mem[i]
,以便之后使用。mem[i]
中獲取結(jié)果,從而避免重復(fù)計(jì)算該子問題。climbing_stairs_dfs_mem.cpp
/* 記憶化搜索 */
int dfs(int i, vector<int> &mem) {
// 已知 dp[1] 和 dp[2] ,返回之
if (i == 1 || i == 2)
return i;
// 若存在記錄 dp[i] ,則直接返回之
if (mem[i] != -1)
return mem[i];
// dp[i] = dp[i-1] + dp[i-2]
int count = dfs(i - 1, mem) + dfs(i - 2, mem);
// 記錄 dp[i]
mem[i] = count;
return count;
}
/* 爬樓梯:記憶化搜索 */
int climbingStairsDFSMem(int n) {
// mem[i] 記錄爬到第 i 階的方案總數(shù),-1 代表無記錄
vector<int> mem(n + 1, -1);
return dfs(n, mem);
}
觀察圖 14-4 ,經(jīng)過記憶化處理后,所有重疊子問題都只需被計(jì)算一次,時(shí)間復(fù)雜度被優(yōu)化至
圖 14-4 記憶化搜索對(duì)應(yīng)遞歸樹
記憶化搜索是一種“從頂至底”的方法:我們從原問題(根節(jié)點(diǎn))開始,遞歸地將較大子問題分解為較小子問題,直至解已知的最小子問題(葉節(jié)點(diǎn))。之后,通過回溯將子問題的解逐層收集,構(gòu)建出原問題的解。
與之相反,動(dòng)態(tài)規(guī)劃是一種“從底至頂”的方法:從最小子問題的解開始,迭代地構(gòu)建更大子問題的解,直至得到原問題的解。
由于動(dòng)態(tài)規(guī)劃不包含回溯過程,因此只需使用循環(huán)迭代實(shí)現(xiàn),無須使用遞歸。在以下代碼中,我們初始化一個(gè)數(shù)組 dp
來存儲(chǔ)子問題的解,它起到了記憶化搜索中數(shù)組 mem
相同的記錄作用。
圖 14-5 模擬了以上代碼的執(zhí)行過程。
圖 14-5 爬樓梯的動(dòng)態(tài)規(guī)劃過程
與回溯算法一樣,動(dòng)態(tài)規(guī)劃也使用“狀態(tài)”概念來表示問題求解的某個(gè)特定階段,每個(gè)狀態(tài)都對(duì)應(yīng)一個(gè)子問題以及相應(yīng)的局部最優(yōu)解。例如,爬樓梯問題的狀態(tài)定義為當(dāng)前所在樓梯階數(shù)
根據(jù)以上內(nèi)容,我們可以總結(jié)出動(dòng)態(tài)規(guī)劃的常用術(shù)語。
dp
稱為「細(xì)心的你可能發(fā)現(xiàn),由于 dp
來存儲(chǔ)所有子問題的解,而只需兩個(gè)變量滾動(dòng)前進(jìn)即可。
climbing_stairs_dp.cpp
/* 爬樓梯:空間優(yōu)化后的動(dòng)態(tài)規(guī)劃 */
int climbingStairsDPComp(int n) {
if (n == 1 || n == 2)
return n;
int a = 1, b = 2;
for (int i = 3; i <= n; i++) {
int tmp = b;
b = a + b;
a = tmp;
}
return b;
}
觀察以上代碼,由于省去了數(shù)組 dp
占用的空間,因此空間復(fù)雜度從
在動(dòng)態(tài)規(guī)劃問題中,當(dāng)前狀態(tài)往往僅與前面有限個(gè)狀態(tài)有關(guān),這時(shí)我們可以只保留必要的狀態(tài),通過“降維”來節(jié)省內(nèi)存空間。這種空間優(yōu)化技巧被稱為“滾動(dòng)變量”或“滾動(dòng)數(shù)組”。
Copyright©2021 w3cschool編程獅|閩ICP備15016281號(hào)-3|閩公網(wǎng)安備35020302033924號(hào)
違法和不良信息舉報(bào)電話:173-0602-2364|舉報(bào)郵箱:jubao@eeedong.com
掃描二維碼
下載編程獅App
編程獅公眾號(hào)
聯(lián)系方式:
更多建議: