【回溯算法 (一)】回溯算法和動態規划的轉化


轉載自 回溯算法和動態規划,到底誰是誰爹?文末送書

有的問題如果實在想不出狀態轉移方程,嘗試用回溯算法暴力解決也是一個聰明的策略,總比寫不出來解法強。

那么,回溯算法和動態規划到底是啥關系?它倆都涉及遞歸,算法模板看起來還挺像的,都涉及做「選擇」,真的酷似父與子。

Image

那么,它倆具體有啥區別呢?回溯算法和動態規划之間,是否可能互相轉化呢?

今天就用力扣第 494 題「目標和」來詳細對比一下回溯算法和動態規划,真可謂群魔亂舞:

Image

注意,給出的例子 nums 全是 1,但實際上可以是任意正整數哦。

一、回溯思路

其實我第一眼看到這個題目,花了兩分鍾就寫出了一個回溯解法。

任何算法的核心都是窮舉,回溯算法就是一個暴力窮舉算法,前文 回溯算法解題框架 就寫了回溯算法框架:

def backtrack(路徑, 選擇列表):
    if 滿足結束條件:
        result.add(路徑)
        return

    for 選擇 in 選擇列表:
        做選擇
        backtrack(路徑, 選擇列表)
        撤銷選擇

關鍵就是搞清楚什么是「選擇」,而對於這道題,「選擇」不是明擺着的嗎?

對於每個數字 nums[i],我們可以選擇給一個正號 + 或者一個負號 -,然后利用回溯模板窮舉出來所有可能的結果,數一數到底有幾種組合能夠湊出 target 不就行了嘛?

偽碼思路如下:

def backtrack(nums, i):
    if i == len(nums):
        if 達到 target:
            result += 1
        return

    for op in { +1, -1 }:
        選擇 op * nums[i]
        # 窮舉 nums[i + 1] 的選擇
        backtrack(nums, i + 1)
        撤銷選擇

如果看過我們之前的幾篇回溯算法文章,這個代碼可以說是比較簡單的了:

int result = 0;

/* 主函數 */
int findTargetSumWays(int[] nums, int target) {
    if (nums.length == 0) return 0;
    backtrack(nums, 0, target);
    return result;
}

/* 回溯算法模板 */
void backtrack(int[] nums, int i, int rest) {
    // base case
    if (i == nums.length) {
        if (rest == 0) {
            // 說明恰好湊出 target
            result++;
        }
        return;
    }
    // 給 nums[i] 選擇 - 號
    rest += nums[i];
    // 窮舉 nums[i + 1]
    backtrack(nums, i + 1, rest);
    // 撤銷選擇
    rest -= nums[i]; 

    // 給 nums[i] 選擇 + 號
    rest -= nums[i];
    // 窮舉 nums[i + 1]
    backtrack(nums, i + 1, rest);
    // 撤銷選擇
    rest += nums[i];
}

有的讀者可能問,選擇 - 的時候,為什么是 rest += nums[i],選擇 + 的時候,為什么是 rest -= nums[i] 呢,是不是寫反了?

不是的,「如何湊出 target」和「如何把 target 減到 0」其實是一樣的。我們這里選擇后者,因為前者必須給 backtrack 函數多加一個參數,我覺得不美觀:

void backtrack(int[] nums, int i, int sum, int target) {
    // base case
    if (i == nums.length) {
        if (sum == target) {
            result++;
        }
        return;
    }
    // ...
}

因此,如果我們給 nums[i] 選擇 + 號,就要讓 rest - nums[i],反之亦然。

以上回溯算法可以解決這個問題,時間復雜度為 O(2^N)Nnums 的大小。這個復雜度怎么算的?回憶前文 學習數據結構和算法的框架思維,發現這個回溯算法就是個二叉樹的遍歷問題:

void backtrack(int[] nums, int i, int rest) {
    if (i == nums.length) {
        return;
    }
    backtrack(nums, i + 1, rest - nums[i]);
    backtrack(nums, i + 1, rest + nums[i]);
}

樹的高度就是 nums 的長度嘛,所以說時間復雜度就是這棵二叉樹的節點數,為 O(2^N),其實是非常低效的。

那么,這個問題如何用動態規划思想進行優化呢?

二、消除重疊子問題

動態規划之所以比暴力算法快,是因為動態規划技巧消除了重疊子問題。

如何發現重疊子問題?看是否可能出現重復的「狀態」。對於遞歸函數來說,函數參數中會變的參數就是「狀態」,對於 backtrack 函數來說,會變的參數為 irest

前文 動態規划之編輯距離 說了一種一眼看出重疊子問題的方法,先抽象出遞歸框架:

void backtrack(int i, int rest) {
    backtrack(i + 1, rest - nums[i]);
    backtrack(i + 1, rest + nums[i]);
}

舉個簡單的例子,如果 nums[i] = 0,會發生什么?

void backtrack(int i, int rest) {
    backtrack(i + 1, rest);
    backtrack(i + 1, rest);
}

你看,這樣就出現了兩個「狀態」完全相同的遞歸函數,無疑這樣的遞歸計算就是重復的。這就是重疊子問題,而且只要我們能夠找到一個重疊子問題,那一定還存在很多的重疊子問題

因此,狀態 (i, rest) 是可以用備忘錄技巧進行優化的:

int findTargetSumWays(int[] nums, int target) {
    if (nums.length == 0) return 0;
    return dp(nums, 0, target);
}

// 備忘錄
HashMap<String, Integer> memo = new HashMap<>();
int dp(int[] nums, int i, int rest) {
    // base case
    if (i == nums.length) {
        if (rest == 0) return 1;
        return 0;
    }
    // 把它倆轉成字符串才能作為哈希表的鍵
    String key = i + "," + rest;
    // 避免重復計算
    if (memo.containsKey(key)) {
        return memo.get(key);
    }
    // 還是窮舉
    int result = dp(nums, i + 1, rest - nums[i]) + dp(nums, i + 1, rest + nums[i]);
    // 記入備忘錄
    memo.put(key, result);
    return result;
}

以前我們都是用 Python 的元組配合哈希表 dict 來做備忘錄的,其他語言沒有元組,可以用把「狀態」轉化為字符串作為哈希表的鍵,這是一個常用的小技巧。

這個解法通過備忘錄消除了很多重疊子問題,效率有一定的提升,但是這就結束了嗎?

三、動態規划

事情沒有這么簡單,先來算一算,消除重疊子問題之后,算法的時間復雜度是多少?其實最壞情況下依然是 O(2^N)

為什么呢?因為我們只不過恰好發現了重疊子問題,順手用備忘錄技巧給優化了,但是底層思路沒有變,依然是暴力窮舉的回溯算法,依然在遍歷一棵二叉樹。這只能叫對回溯算法進行了「剪枝」,提升了算法在某些情況下的效率,但算不上質的飛躍。

其實,這個問題可以轉化為一個子集划分問題,而子集划分問題又是一個典型的背包問題。動態規划總是這么玄學,讓人摸不着頭腦……

首先,如果我們把 nums 划分成兩個子集 AB,分別代表分配 + 的數和分配 - 的數,那么他們和 target 存在如下關系:

sum(A) - sum(B) = target
sum(A) = target + sum(B)
sum(A) + sum(A) = target + sum(B) + sum(A)
2 * sum(A) = target + sum(nums)

綜上,可以推出 sum(A) = (target + sum(nums)) / 2,也就是把原問題轉化成:nums 中存在幾個子集 A,使得 A 中元素的和為 (target + sum(nums)) / 2

類似的子集划分問題我們前文 經典背包問題:子集划分 講過,現在實現這么一個函數:

/* 計算 nums 中有幾個子集的和為 sum */
int subsets(int[] nums, int sum) {}

然后,可以這樣調用這個函數:

int findTargetSumWays(int[] nums, int target) {
    int sum = 0;
    for (int n : nums) sum += n;
    // 這兩種情況,不可能存在合法的子集划分
    if (sum < target || (sum + target) % 2 == 1) {
        return 0;
    }
    return subsets(nums, (sum + target) / 2);
}

好的,變成背包問題的標准形式:

有一個背包,容量為 sum,現在給你 N 個物品,第 i 個物品的重量為 nums[i - 1](注意 1 <= i <= N),每個物品只有一個,請問你有幾種不同的方法能夠恰好裝滿這個背包

現在,這就是一個正宗的動態規划問題了,下面按照我們一直強調的動態規划套路走流程:

第一步要明確兩點,「狀態」和「選擇」

對於背包問題,這個都是一樣的,狀態就是「背包的容量」和「可選擇的物品」,選擇就是「裝進背包」或者「不裝進背包」。

第二步要明確 dp 數組的定義

按照背包問題的套路,可以給出如下定義:

dp[i][j] = x 表示,若只在前 i 個物品中選擇,若當前背包的容量為 j,則最多有 x 種方法可以恰好裝滿背包。

翻譯成我們探討的子集問題就是,若只在 nums 的前 i 個元素中選擇,若目標和為 j,則最多有 x 種方法划分子集。

根據這個定義,顯然 dp[0][..] = 0,因為沒有物品的話,根本沒辦法裝背包;dp[..][0] = 1,因為如果背包的最大載重為 0,「什么都不裝」就是唯一的一種裝法。

我們所求的答案就是 dp[N][sum],即使用所有 N 個物品,有幾種方法可以裝滿容量為 sum 的背包。

第三步,根據「選擇」,思考狀態轉移的邏輯

回想剛才的 dp 數組含義,可以根據「選擇」對 dp[i][j] 得到以下狀態轉移:

如果不把 nums[i] 算入子集,或者說你不把這第 i 個物品裝入背包,那么恰好裝滿背包的方法數就取決於上一個狀態 dp[i-1][j],繼承之前的結果。

如果把 nums[i] 算入子集,或者說你把這第 i 個物品裝入了背包,那么只要看前 i - 1 個物品有幾種方法可以裝滿 j - nums[i-1] 的重量就行了,所以取決於狀態 dp[i-1][j-nums[i-1]]

PS:注意我們說的 i 是從 1 開始算的,而數組 nums 的索引時從 0 開始算的,所以 nums[i-1] 代表的是第 i 個物品的重量,j - nums[i-1] 就是背包裝入物品 i 之后還剩下的容量。

由於 dp[i][j] 為裝滿背包的總方法數,所以應該以上兩種選擇的結果求和,得到狀態轉移方程

dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]];

然后,根據狀態轉移方程寫出動態規划算法:

/* 計算 nums 中有幾個子集的和為 sum */
int subsets(int[] nums, int sum) {
    int n = nums.length;
    int[][] dp = new int[n + 1][sum + 1];
    // base case
    for (int i = 0; i <= n; i++) {
        dp[i][0] = 1;
    }

    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= sum; j++) {
            if (j >= nums[i-1]) {
                // 兩種選擇的結果之和
                dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]];
            } else {
                // 背包的空間不足,只能選擇不裝物品 i
                dp[i][j] = dp[i-1][j];
            }
        }
    }
    return dp[n][sum];
}

然后,發現這個 dp[i][j] 只和前一行 dp[i-1][..] 有關,那么肯定可以優化成一維 dp

/* 計算 nums 中有幾個子集的和為 sum */
int subsets(int[] nums, int sum) {
    int n = nums.length;
    int[] dp = new int[sum + 1];
    // base case
    dp[0] = 1;

    for (int i = 1; i <= n; i++) {
        // j 要從后往前遍歷
        for (int j = sum; j >= 0; j--) {
            // 狀態轉移方程
            if (j >= nums[i-1]) {
                dp[j] = dp[j] + dp[j-nums[i-1]];
            } else {
                dp[j] = dp[j];
            }
        }
    }
    return dp[sum];
}

對照二維 dp,只要把 dp 數組的第一個維度全都去掉就行了,唯一的區別就是這里的 j 要從后往前遍歷,原因如下

因為二維壓縮到一維的根本原理是,dp[j]dp[j-nums[i-1]] 還沒被新結果覆蓋的時候,相當於二維 dp 中的 dp[i-1][j]dp[i-1][j-nums[i-1]]

那么,我們就要做到:在計算新的 dp[j] 的時候,dp[j]dp[j-nums[i-1]] 還是上一輪外層 for 循環的結果

如果你從前往后遍歷一維 dp 數組,dp[j] 顯然是沒問題的,但是 dp[j-nums[i-1]] 已經不是上一輪外層 for 循環的結果了,這里就會使用錯誤的狀態,當然得不到正確的答案。

現在,這道題算是徹底解決了。

總結一下,回溯算法雖好,但是復雜度高,即便消除一些冗余計算,也只是「剪枝」,沒有本質的改進。而動態規划就比較玄學了,經過各種改造,從一個加減法問題變成子集問題,又變成背包問題,經過各種套路寫出解法,又搞出狀態壓縮,還得反向遍歷。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM