說起動態規劃,我不知道你有沒有這樣的困擾,在掌握了一些基礎演算法和資料結構之後,碰到一些較為複雜的問題還是無從下手,面試時自然也是膽戰心驚。如果我說動態規劃是個玄幻的問題其實也不為過。究其原因,我覺得可以歸因於這樣兩點:
- 你對動態規劃相關問題的套路和思想還沒有完全掌握;
- 你沒有系統地總結過究竟有哪些問題可以用動態規劃解決。
知己知彼,你想把動態規劃作為你的面試武器之一,就得足夠了解它;而應對面試,總結、歸類問題其實是個不錯的選擇,這在我們刷題的時候其實也能感覺得到。那麼,我們就針對以上兩點,系統地談一談究竟什麼樣的問題可以用動態規劃來解。
一、動態規劃是一種思想
動態規劃演算法,這種叫法我想你應該經常聽說。嗯,從道理上講這麼叫我覺得也沒錯,首先動態規劃它不是資料結構,這一點毋庸置疑,並且嚴格意義上來說它就是一種演算法。但更加準確或者更加貼切的提法應該是說動態規劃是一種思想。那演算法和思想又有什麼區別呢?
一般來說,我們都會把演算法和資料結構放一起來講,這是因為它們之間密切相關,而演算法也往往是在特定資料結構的基礎之上對解題方案的一種嚴謹的總結。
比如說,在一個亂序陣列的基礎上進行排序,這裡的資料結構指的是什麼呢?很顯然是陣列,而演算法則是所謂的排序。至於排序演算法,你可以考慮使用簡單的氣泡排序或效率更高的快速排序方法等等來解決問題。
沒錯,你應該也感覺到了,演算法是一種簡單的經驗總結和套路。那什麼是思想呢?相較於演算法,思想更多的是指導你我來解決問題。
比如說,在解決一個複雜問題的時候,我們可以先將問題簡化,先解決簡單的問題,再解決難的問題,那麼這就是一種指導解決問題的思想。另外,我們常說的分治也是一種簡單的思想,當然它在諸如歸併排序或遞迴演算法當中會常常被提及。
而動態規劃就是這樣一個指導我們解決問題的思想:你需要利用已經計算好的結果來推導你的計算,即大規模問題的結果是由小規模問題的結果運算得來的。
總結一下:演算法是一種經驗總結,而思想則是用來指導我們解決問題的。既然動態規劃是一種思想,那它實際上就是一個比較抽象的概念了,也很難和實際的問題關聯起來。所以說,弄清楚什麼樣的問題可以使用動態規劃來解就顯得十分重要了。
二、動態規劃問題的特點
動態規劃作為運籌學上的一種最優化解題方法,在演算法問題上已經得到廣泛應用。接下來我們就來看一下動歸問題所具備的一些特點。
2.1 最優解問題
除非你碰到的問題是簡單到找出一個陣列中最大的值這樣,對這種問題來說,你可以對陣列進行排序,然後取陣列頭或尾部的元素,如果覺得麻煩,你也可以直接遍歷得到最值。不然的話,你就得考慮使用動態規劃來解決這個問題了。這樣的問題一般都會讓你求最大子陣列、求最長遞增子陣列、求最長遞增子序列或求最長公共子串、子序列等等。
如果碰到求最值問題,我們可以使用下面的套路來解決問題:
- 優先考慮使用貪心演算法的可能性;
- 然後是暴力遞迴進行窮舉,針對資料規模不大的情況;
- 如果上面兩種都不適合,那麼再選擇動態規劃。
可以看到,求解動態規劃的核心問題其實就是窮舉。當然了,動態規劃問題也不會這麼簡單了事,我們還需要考慮待解決的問題是否存在重疊子問題、最優子結構等特性。
清楚了動態規劃演算法的特點,接下來我們就來看一下哪些問題適合用動態規劃思想來解題。
1. 乘積最大子陣列
給你一個整數陣列 numbers,找出陣列中乘積最大的連續子陣列(該子陣列中至少包含一個數字),返回該子陣列的乘積。
示例1:
輸入: [2,7,-2,4]
輸出: 14
解釋: 子陣列 [2,7] 有最大乘積 14。
示例2:
輸入: [-5,0,3,-1]
輸出: 3
解釋: 結果不能為 15, 因為 [-5,3,-1] 不是子陣列,是子序列。
首先,很明顯這個題目當中包含一個“最”字,使用動態規劃求解的概率就很大。這個問題的目的就是從陣列中尋找一個最大的連續區間,確保這個區間的乘積最大。由於每個連續區間可以劃分成兩個更小的連續區間,而且大的連續區間的結果是兩個小連續區間的乘積,因此這個問題還是求解滿足條件的最大值,同樣可以進行問題分解,而且屬於求最值問題。同時,這個問題與求最大連續子序列和比較相似,唯一的區別就是你需要在這個問題裡考慮正負號的問題,其它就相同了。
對應實現程式碼:
class Solution {
public:
int maxProduct(vector<int>& nums) {
if(nums.empty()) return 0;
int curMax = nums[0];
int curMin = nums[0];
int maxPro = nums[0];
for(int i=1; i<nums.size(); i++){
int temp = curMax; // 因為curMax在下一行可能會被更新,所以儲存下來
curMax = max(max(curMax*nums[i], nums[i]), curMin*nums[i]);
curMin = min(min(curMin*nums[i], nums[i]), temp*nums[i]);
maxPro = max(curMax, maxPro);
}
return maxPro;
}
};
2. 最長迴文子串
問題:給定一個字串 s,找到 s 中最長的迴文子串。你可以假設 s 的最大長度為 1000。
示例1:
輸入: "babad"
輸出: "bab"
示例2:
輸入: "cbbd"
輸出: "bb"
【迴文串】是一個正讀和反讀都一樣的字串,比如“level”或者“noon”等等就是迴文串。這個問題依然包含一個“最”字,同樣由於求解的最長迴文子串肯定包含一個更短的迴文子串,因此我們依然可以使用動態規劃來求解這個問題。
對應實現程式碼:
class Solution {
public boolean isPalindrome(String s, int b, int e){//判斷s[b...e]是否為迴文字串
int i = b, j = e;
while(i <= j){
if(s.charAt(i) != s.charAt(j)) return false;
++i;
--j;
}
return true;
}
public String longestPalindrome(String s) {
if(s.length() <=1){
return s;
}
int l = 1, j = 0, ll = 1;
for(int i = 1; i < s.length(); ++i){
//下面這個if語句就是用來維持迴圈不變式,即ll恆表示:以第i個字元為尾的最長迴文子串的長度
if(i - 1 - ll >= 0 && s.charAt(i) == s.charAt(i-1-ll)) ll += 2;
else{
while(true){//重新確定以i為邊界,最長的迴文字串長度。確認範圍為從ll+1到1
if(ll == 0||isPalindrome(s,i-ll,i)){
++ll;
break;
}
--ll;
}
}
if(ll > l){//更新最長迴文子串資訊
l = ll;
j = i;
}
}
return s.substring(j-l+1, j+1);//返回從j-l+1到j長度為l的子串
}
}
3. 最長上升子序列
問題:給定一個無序的整數陣列,找到其中最長上升子序列的長度。可能會有多種最長上升子序列的組合,你只需要輸出對應的長度即可。
示例:
輸入: [10,9,2,5,3,7,66,18]
輸出: 4
解釋: 最長的上升子序列是 [2,3,7,66],它的長度是 4。
這個問題依然是一個最優解問題,假設我們要求一個長度為 5 的字串中的上升自序列,我們只需要知道長度為 4 的字串最長上升子序列是多長,就可以根據剩下的數字確定最後的結果。
對應實現程式碼:
class Solution {
public int lengthOfLIS(int[] nums) {
if(nums.length == 0) return 0;
int[] dp = new int[nums.length];
int res = 0;
Arrays.fill(dp, 1);
for(int i = 0; i < nums.length; i++) {
for(int j = 0; j < i; j++) {
if(nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1);
}
res = Math.max(res, dp[i]);
}
return res;
}
}
2.2 求可行性
如果有這樣一個問題,讓你判斷是否存在一條總和為 x 的路徑(如果找到了,就是 True;如果找不到,自然就是 False),或者讓你判斷能否找到一條符合某種條件的路徑,那麼這類問題都可以歸納為求可行性問題,並且可以使用動態規劃來解。
1. 湊零兌換問題
問題:給你 k 種面值的硬幣,面值分別為 c1, c2 … ck,每種硬幣的數量無限,再給一個總金額 amount,問你最少需要幾枚硬幣湊出這個金額,如果不可能湊出,演算法返回 -1 。
示例1:
輸入: c1=1, c2=2, c3=5, c4=7, amount = 15
輸出: 3
解釋: 11 = 7 + 7 + 1。
示例2:
輸入: c1=3, amount =7
輸出: -1
解釋: 3怎麼也湊不到7這個值。
這個問題顯而易見,如果不可能湊出我們需要的金額(即 amount),最後演算法需要返回 -1,否則輸出可能的硬幣數量。這是一個典型的求可行性的動態規劃問題。
對於示例程式碼:
class Solution {
public int coinChange(int[] coins, int amount) {
if(coins.length == 0)
return -1;
//宣告一個amount+1長度的陣列dp,代表各個價值的錢包,第0個錢包可以容納的總價值為0,其它全部初始化為無窮大
//dp[j]代表當錢包的總價值為j時,所需要的最少硬幣的個數
int[] dp = new int[amount+1];
Arrays.fill(dp,1,dp.length,Integer.MAX_VALUE);
for (int coin : coins) {
for (int j = coin; j <= amount; j++) {
if(dp[j-coin] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j], dp[j-coin]+1);
}
}
}
if(dp[amount] != Integer.MAX_VALUE)
return dp[amount];
return -1;
}
}
2. 字串交錯組成問題
問題:給定三個字串 s1, s2, s3, 驗證 s3 是否是由 s1 和 s2 交錯組成的。
示例1:
輸入: s1="aabcc",s2 ="dbbca",s3="aadbbcbcac"
輸出: true
解釋: 可以交錯組成。
示例2:
輸入: s1="aabcc",s2="dbbca",s3="aadbbbaccc"
輸出: false
解釋:無法交錯組成。
這個問題稍微有點複雜,但是我們依然可以通過子問題的視角,首先求解 s1 中某個長度的子字串是否由 s2 和 s3 的子字串交錯組成,直到求解整個 s1 的長度為止,也可以看成一個包含子問題的最值問題。
對應示例程式碼:
class Solution {
public boolean isInterleave(String s1, String s2, String s3) {
int length = s3.length();
// 特殊情況處理
if(s1.isEmpty() && s2.isEmpty() && s3.isEmpty()) return true;
if(s1.isEmpty()) return s2.equals(s3);
if(s2.isEmpty()) return s1.equals(s3);
if(s1.length() + s2.length() != length) return false;
int[][] dp = new int[s2.length()+1][s1.length()+1];
// 邊界賦值
for(int i = 1;i < s1.length()+1;i++){
if(s1.substring(0,i).equals(s3.substring(0,i))){
dp[0][i] = 1;
}
}
for(int i = 1;i < s2.length()+1;i++){
if(s2.substring(0,i).equals(s3.substring(0,i))){
dp[i][0] = 1;
}
}
for(int i = 2;i <= length;i++){
// 遍歷 i 的所有組成(邊界除外)
for(int j = 1;j < i;j++){
// 防止越界
if(s1.length() >= j && i-j <= s2.length()){
if(s1.charAt(j-1) == s3.charAt(i-1) && dp[i-j][j-1] == 1){
dp[i-j][j] = 1;
}
}
// 防止越界
if(s2.length() >= j && i-j <= s1.length()){
if(s2.charAt(j-1) == s3.charAt(i-1) && dp[j-1][i-j] == 1){
dp[j][i-j] = 1;
}
}
}
}
return dp[s2.length()][s1.length()]==1;
}
}
2.3 求總數
除了求最值與可行性之外,求方案總數也是比較常見的一類動態規劃問題。比如說給定一個資料結構和限定條件,讓你計算出一個方案的所有可能的路徑,那麼這種問題就屬於求方案總數的問題。
1. 硬幣組合問題
問題:英國的英鎊硬幣有 1p, 2p, 5p, 10p, 20p, 50p, £1 (100p), 和 £2 (200p)。比如我們可以用以下方式來組成 2 英鎊:1×£1 + 1×50p + 2×20p + 1×5p + 1×2p + 3×1p。問題是一共有多少種方式可以組成 n 英鎊? 注意不能有重複,比如 1 英鎊 +2 個 50P 和 50P+50P+1 英鎊是一樣的。
示例1:
輸入: 2
輸出: 73682
這個問題本質還是求滿足條件的組合,只不過這裡不需要求出具體的值或者說組合,只需要計算出組合的數量即可。
public class Main {
public static void main(String[] args) throws Exception {
Scanner sc = new Scanner(System.in);
while (sc.hasNext()) {
int n = sc.nextInt();
int coin[] = { 1, 5, 10, 20, 50, 100 };
// dp[i][j]表示用前i種硬幣湊成j元的組合數
long[][] dp = new long[7][n + 1];
for (int i = 1; i <= n; i++) {
dp[0][i] = 0; // 用0種硬幣湊成i元的組合數為0
}
for (int i = 0; i <= 6; i++) {
dp[i][0] = 1; // 用i種硬幣湊成0元的組合數為1,所有硬幣均為0個即可
}
for (int i = 1; i <= 6; i++) {
for (int j = 1; j <= n; j++) {
dp[i][j] = 0;
for (int k = 0; k <= j / coin[i - 1]; k++) {
dp[i][j] += dp[i - 1][j - k * coin[i - 1]];
}
}
}
System.out.print(dp[6][n]);
}
sc.close();
}
}
2. 路徑規劃問題
問題:一個機器人位於一個 m x n 網格的左上角。機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角,共有多少路徑?
示例1:
輸入: 2 2
輸出: 2
示例1:
輸入: 3 3
輸出: 6
這個問題還是一個求滿足條件的組合數量的問題,只不過這裡的組合變成了路徑的組合。我們可以先求出長寬更小的網格中的所有路徑,然後再在一個更大的網格內求解更多的組合。這和硬幣組合的問題相比沒有什麼本質區別。
這裡有一個規律或者說現象需要強調,那就是求方案總數的動態規劃問題一般都指的是求“一個”方案的所有具體形式。如果是求“所有”方案的具體形式,那這種肯定不是動態規劃問題,而是使用傳統遞迴來遍歷出所有方案的具體形式。
為什麼這麼說呢?因為你需要把所有情況列舉出來,大多情況下根本就沒有重疊子問題給你優化。即便有,你也只能使用備忘錄對遍歷進行一個簡單加速。但本質上,這類問題不是動態規劃問題。
對應示例程式碼:
package com.qst.Tesst;
import java.util.Scanner;
public class Test12 {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
int x = scanner.nextInt();
int y = scanner.nextInt();
//設定路徑
long[][] path = new long[x + 1][y + 1];
//設定領導數量
int n = scanner.nextInt();
//領導位置
for (int i = 0; i < n; i++) {
int a = scanner.nextInt();
int b = scanner.nextInt();
path[a][b] = -1;
}
for (int i = 0; i <= x; i++) {
path[i][0] = 1;
}
for (int j = 0; j <= y; j++) {
path[0][j] = 1;
}
for (int i = 1; i <= x; i++) {
for (int j = 1; j <= y; j++) {
if (path[i][j] == -1) {
path[i][j] = 0;
} else {
path[i][j] = path[i - 1][j] + path[i][j - 1];
}
}
}
System.out.println(path[x][y]);
}
}
}
三、 如何確認動態規劃問題
從前面我所說來看,如果你碰到了求最值、求可行性或者是求方案總數的問題的話,那麼這個問題就八九不離十了,你基本可以確定它就需要使用動態規劃來解。但是,也有一些個別情況需要注意:
3.1 資料不可排序
假設我們有一個無序數列,希望求出這個數列中最大的兩個數字之和。很多初學者剛剛學完動態規劃會走火入魔到看到最優化問題就想用動態規劃來求解,事實上,這個問題不是簡單做一個排序或者做一個遍歷就可以求解出來的。對於這種問題,我們應該先考慮一下能不能通過排序來簡化問題,如果不能,才極有可能是動態規劃問題。
最小的 k 個數
問題:輸入整數陣列 arr ,找出其中最小的 k 個數。例如,輸入 4、5、1、6、2、7、3、8 這 8 個數字,則最小的 4 個數字是 1、2、3、4。
示例1:
輸入:arr = [3,2,1], k = 2
輸出:[1,2] 或者 [2,1]
示例2:
輸入:arr = [0,1,2,1], k = 1
輸出:[0]
我們發現雖然這個問題也是求“最”值,但其實只要通過排序就能解決,所以我們應該用排序、堆等演算法或者資料結構就可以解決,而不應該用動態規劃。
對應的示例程式碼:
public class Solution {
public ArrayList<Integer> GetLeastNumbers_Solution(int [] input, int k) {
int t;
boolean flag;
ArrayList result = new ArrayList();
if(k>input.length){
return result;
}
for(int i =0;i<input.length;i++){
flag = true;
for(int j = 0; j < input.length-i;j++)
if(j<input.length-i-1){
if(input[j] > input[j+1]) {
t = input[j];
input[j] = input[j+1];
input[j+1] = t;
flag = false;
}
}
if(flag)break;
}
for(int i = 0; i < k;i++){
result.add(input[i]);
}
return result;
}
}
3.2 資料不可交換
還有一類問題,可以歸類到我們總結的幾類問題裡去,但是不存在動態規劃要求的重疊子問題(比如經典的八皇后問題),那麼這類問題就無法通過動態規劃求解。
全排列
問題:給定一個沒有重複數字的序列,返回其所有可能的全排列。
示例:
輸入: [1,2,3]
輸出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
這個問題雖然是求組合,但沒有重疊子問題,更不存在最優化的要求,因此可以使用回溯方法處理。
對應的示例程式碼:
public class Main {
public static void main(String[] args) {
perm(new int[]{1,2,3},new Stack<>());
}
public static void perm(int[] array, Stack<Integer> stack) {
if(array.length <= 0) {
//進入了葉子節點,輸出棧中內容
System.out.println(stack);
} else {
for (int i = 0; i < array.length; i++) {
//tmepArray是一個臨時陣列,用於就是Ri
//eg:1,2,3的全排列,先取出1,那麼這時tempArray中就是2,3
int[] tempArray = new int[array.length-1];
System.arraycopy(array,0,tempArray,0,i);
System.arraycopy(array,i+1,tempArray,i,array.length-i-1);
stack.push(array[i]);
perm(tempArray,stack);
stack.pop();
}
}
}
}
總結一下,哪些問題可以使用動態規劃呢,通常含有下面情況的一般都可以使用動態規劃來解決:
- 求最優解問題(最大值和最小值);
- 求可行性(True 或 False);
- 求方案總數;
- 資料結構不可排序(Unsortable);
- 演算法不可使用交換(Non-swappable)。
如果面試題目出現這些特徵,那麼在 90% 的情況下你都能斷言它就是一個動歸問題。除此之外,還需要考慮這個問題是否包含重疊子問題與最優子結構,在這個基礎之上你就可以 99% 斷言它是否為動歸問題,並且也順勢找到了大致的解題思路。