【算法與數據結構】動態規劃

用遞歸求解問題時,反覆的嵌套會浪費內存。並且更重要的一點是,以前計算的結果沒法有效存儲,下一次碰到同一個問題時還須要再計算一次。例如遞歸求解 Fibonacci 數列,假設求第 n 位(從 1 開始)的值,C 代碼以下:web

#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 左右試試,這是消耗的時間就是秒級了。總共的求解步驟以下:svg

  • 求第 5 位
    • 求第 4 位
      • 求第 3 位
        • 求第 2 位
        • 求第 1 位
      • 求第 2 位
    • 求第 3 位
      • 求第 2 位
      • 求第 1 位

若是想把每次求解的結果保存下來,就須要一個長度位 n 的數組,從頭開始把每個位置的值保存下來,這樣求解後面的值的時候就能夠用了。函數

動態規劃(Dynamic Programming)的思想

對於遞歸,只要寫好了退出條件,以後不停的調用自身便可,最終到達退出條件時,逐個退出函數。spa

動態規劃則是從頭開始,用循環達到目的。code

動態規劃和遞歸的最大的區別,就是在碰到重疊子問題(Overlap Sub-problem)時,是否只須要計算一次。xml

#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。token

分析:對於任意第 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;
}
相關文章
相關標籤/搜索