用遞歸求解問題時,反復的嵌套會浪費內存。而且更重要的一點是,之前計算的結果無法有效存儲,下一次碰到同一個問題時還需要再計算一次。例如遞歸求解 Fibonacci 數列,假設求第 n 位(從 1 開始)的值,C 代碼如下:
#include <stdio.h>
int fib(int n) {
if (n < 3) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
int main(void) {
int ret = fib(5);
printf("fib ret is: %d\n", ret);
return 0;
}
上面的代碼,每個運算節點都需要拆成兩步運算,時間復雜度位 O(2^n)。
你可以把 n 改為 40 左右試試,這是消耗的時間就是秒級了。總共的求解步驟如下:
- 求第 5 位
- 求第 4 位
- 求第 3 位
- 求第 2 位
- 求第 1 位
- 求第 2 位
- 求第 3 位
- 求第 3 位
- 求第 2 位
- 求第 1 位
- 求第 4 位
如果想把每次求解的結果保存下來,就需要一個長度位 n 的數組,從頭開始把每一個位置的值保存下來,這樣求解后面的值的時候就可以用了。
動態規划(Dynamic Programming)的思想
對於遞歸,只要寫好了退出條件,之后不停的調用自身即可,最終到達退出條件時,逐個退出函數。
動態規划則是從頭開始,用循環達到目的。
動態規划和遞歸的最大的區別,就是在碰到重疊子問題(Overlap Sub-problem)時,是否只需要計算一次。
#include <stdio.h>
int fib(int n) {
int i;
int dp_opt[n];
dp_opt[0] = 1;
dp_opt[1] = 1;
for (i = 2; i < n; i++) {
dp_opt[i] = dp_opt[i - 1] + dp_opt[i - 2];
}
return dp_opt[n - 1];
}
int main(void) {
int ret = fib(5);
printf("fib ret is: %d\n", ret);
return 0;
}
上面代碼的時間復雜的是 O(n)。
示例
求任意 n 個非相鄰數字之和
題目:從集合中,任取任意多個非相鄰的數字並求和,找出最大的和。例如,對於 {1, 9, 2, 5, 4},最大的和是 14。
分析:對於任意第 n 位數字,都有兩種情況,只需要取值最大的那種即可:
- 選中,則該元素的最大和為:當前元素值加上第 n - 2 位的最大和,即 OPT(n) = OPT(n - 2) + arr[n]
- 不選,則該元素的最大和為:第 n - 1 位的最大和,即 OPT(n) = OPT(n - 1)
遞歸解法
遞歸退出條件:
- 當計算到第一個元素時,直接返回這個元素的值
- 當計算到第二個元素時,返回前兩個元素中的最大值
遞歸循環:
- 遞歸計算當前元素的最大和
- 遞歸計算前一個元素的最大和
- 返回這兩個最大和中的大者
int recursive(int arr[], int n, int i) {
if (i == 0)
return arr[0];
if (i == 1)
return arr[0] > arr[1] ? arr[0] : arr[1];
int before = recursive(arr, n, i - 2) + arr[i];
int cur = recursive(arr, n, i - 1);
return cur > before ? cur : before;
}
動態規划
為了確保每個最小子問題都只計算一次,就必須把計算的結果保存起來。另外,跟遞歸的逆序求解方向相反,動態規划從第一個元素開始,依次計算每個元素的最大和:
- 創建跟待求解問題同規模的數組 dp_opt,用來存放每個元素的最大和
- 計算第一個元素的最大和(即這個元素的值),並放入 dp_opt 的第一個位置
- 計算第二個元素的最大和(即前兩個元素的最大值),並放入 dp_opt 的第二個位置
- 從第三個位置開始,循環到最后一個位置,循環內容為:
- 計算當前位置的前一個位置對應的最大和 x
- 計算當前位置元素 arr[n] 加上前前一個位置對應的最大和 y
- dp_opt[n] = max(x, y + arr[n])
int dp_opt(int arr[], int n, int x) {
int i;
int before, cur;
int opt[n];
for (i = 0; i < n; i++) {
opt[i] = 0;
}
opt[0] = arr[0];
opt[1] = arr[0] > arr[1] ? arr[0] : arr[1];
for (i = 2; i < n; i++) {
before = opt[i - 2] + arr[i];
cur = opt[i - 1];
opt[i] = cur > before ? cur : before;
}
return opt[x];
}
綜合示例
#include <stdio.h>
// 遞歸解法
int recursive(int arr[], int n, int i) {
if (i == 0)
return arr[0];
if (i == 1)
return arr[0] > arr[1] ? arr[0] : arr[1];
int before = recursive(arr, n, i - 2) + arr[i];
int cur = recursive(arr, n, i - 1);
return cur > before ? cur : before;
}
// 動態規划
int dp_opt(int arr[], int n, int x) {
int i;
int before, cur;
int opt[n];
for (i = 0; i < n; i++) {
opt[i] = 0;
}
opt[0] = arr[0];
opt[1] = arr[0] > arr[1] ? arr[0] : arr[1];
for (i = 2; i < n; i++) {
before = opt[i - 2] + arr[i];
cur = opt[i - 1];
opt[i] = cur > before ? cur : before;
}
return opt[x];
}
int main() {
int i;
int n = 7;
int arr[] = {1, 2, 4, 1, 7, 8, 3};
for (i = 0; i < 7; i++) {
printf("recursive ret is: %d, dp_opt ret is: %d\n", recursive(arr, n, i), dp_opt(arr, n, i));
}
return 0;
}
已知某個值,判斷在正數集合中是否存在元素的組合,剛好組合中的元素之和等於這個值
例如,對於 {2, 5, 8, 22, 9},給定值位 15,則可以找到組合 {2, 5, 8} 滿足條件。
要判斷多個元素之和是否等於某個值 sum,則對於任意的元素 n,情況如下:
- 選擇,此時需要判斷前 n - 1 個元素之和能否等於 (sum - arr[n])
- 不選,此時需要判斷前 n - 1 個元素之和能否等於 sum
遞歸的思路
遞歸退出條件:
- 找到第一個元素了,則將這個元素的值和 s 比較,並返回 true 或 false
- sum < 0,說明第 n 個元素太大,需要剔除,跳到 recursive(arr, n - 1, sum)
- sum = 0,說明第 n 個元素就是所求組合中的最后一個元素,返回 true
遞歸循環:
- 將 sum 減去當前元素,然后作為和,遞歸計算前一個元素
- 判斷上面的返回值,如果是 true,則直接結束遞歸,返回 true
- 用 sum 遞歸計算前一個元素,並直接返回結果
int recursive(int arr[], int n, int sum) {
if (n == 0)
return arr[0] == sum;
if (sum == 0)
return 1;
if (sum < arr[n])
return recursive(arr, n - 1, sum);
return recursive(arr, n - 1, sum) || recursive(arr, n - 1, sum - arr[n]);
}
動態規划的思路
有了上面的遞歸的思路后,再把遞歸轉為動態規划。
初始化二維數組
上一個例子中,求非相鄰元素最大和時,每個元素的位置上只需要保存當前元素的最大值,所以創建一個一維數組即可。而現在已知元素之和,
例如,對於集合 {3, 5, 9, 1, 2},如果 sum = 6,則需要創建 dp_subset[5][7] 數組:
- 對於第一行,因為第一個元素是 3,所以其只可能等於 3(對應遞歸退出條件
if (i == 0) return arr[0] == sum;
) - 對於第一列,全部為 True(對應遞歸退出條件
if (sum == 0) return true;
)
arr[i] | i \ sum | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|---|
3 | 0 | F | F | F | T | F | F | F |
5 | 0 | T | ||||||
9 | 0 | T | ||||||
1 | 0 | T | ||||||
2 | 0 | T |
開始迭代
在已經初始化的二維數組基礎上,參考遞歸體就可以完成迭代的代碼。另外,還有一個遞歸結束條件也放在迭代里面。
- 創建一個二重循環,從第二行二列開始遍歷數組
- 如果 arr[i] > sum(遞歸結束條件),則 dp_subset[i][s] = dp_subset[i - 1][s](對應
if (arr[i] > sum) recursive(arr, n - 1, sum);
- 否則,判斷 dp_subset[i - 1][s] 和 dp_subset[i - 1][s - arr[i]],只要有一個是 true,就把 dp_subset[i][s] 置為 true
int dp_subset(int arr[], int n,
完整示例
#include <stdio.h>
// 遞歸解法
int recursive(int arr[], int n, int sum) {
if (n == 0)
return arr[0] == sum;
if (sum == 0)
return 1;
if (sum < arr[n])
return recursive(arr, n - 1, sum);
return recursive(arr, n - 1, sum) || recursive(arr, n - 1, sum - arr[n]);
}
// 動態規划
int dp_subset(int arr[], int n, int sum) {
int subset[n][sum + 1];
int i, s;
for (i = 0; i < n; i++) {
for (s = 0; s <= sum; s++)
subset[i][s] = 0;
}
for (i = 0; i < n; i++) {
subset[i][0] = 1;
}
subset[0][0] = 0;
subset[0][arr[0]] = 1;
for (i = 1; i < n; i++) {
for (s = 1; s <= sum; s++) {
if (arr[i] > sum) {
subset[i][s] = subset[i - 1][s];
} else {
subset[i][s] = subset[i - 1][s] || subset[i - 1][s - arr[i]];
}
}
}
return subset[n - 1][sum];
}
int main() {
int n = 7;
int arr[] = {1, 2, 4, 7, 8, 3, 32};
int sum = 3;
printf("recursive ret is: %d, dp_opt ret is: %d\n", recursive(arr, n, sum), dp_subset(arr, n, sum));
return 0;
}