3.動態規劃

才会相思發表於2024-10-22

3.1 揹包問題

3.1.1 01揹包

01揹包定義

  • 01揹包問題是組合最佳化的一個例子。它的問題描述是這樣的:給定一組物品,每種物品都有自己的重量和價值,揹包的總容量是固定的。我們需要從這些物品中挑選一部分,使得揹包內物品的總價值最大,同時不超過揹包的總容量。
    思路
  • 01揹包問題通常使用動態規劃來解決。動態規劃的核心思想是將大問題分解為小問題,透過儲存和複用小問題的解來避免重複計算。
  • 以下是解決01揹包問題的基本思路:
    1. 建立一個二維陣列dp,其中dp[i][j]表示在前i件物品中選擇,使得總重量不超過j時揹包的最大價值。
    2. 遍歷所有物品,對於每個物品,遍歷所有可能的容量,決定是選擇該物品還是不選擇。
    3. 根據狀態轉移方程來更新dp陣列:
      • 如果不選擇當前物品,則dp[i][j] = dp[i-1][j]
      • 如果選擇當前物品,且當前物品可以放入揹包,則dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
    4. dp[n][W]n是物品數量,W是揹包容量)就是揹包能夠達到的最大價值。
      步驟
  1. 初始化dp陣列,通常dp[0][..]dp[..][0]設定為0,因為不選擇任何物品或揹包容量為0時價值為0。
  2. 遍歷所有物品:
    • 對於每個物品,遍歷所有可能的容量。
    • 更新dp陣列。
  3. 輸出dp[n][W]作為最大價值。
    示例:416. 分割等和子集
  • 給定一個只包含正整數的非空陣列。是否可以將這個陣列分割成兩個子集,使得兩個子集的元素和相等。
  • 注意:
    • 每個陣列中的元素不會超過 100
    • 陣列的大小不會超過 200
      題解:
#include <vector>
using namespace std;

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }
        // 如果總和為奇數,則不能分割成兩個和相等的子集
        if (sum % 2 != 0) return false;
        
        int target = sum / 2;
        vector<int> dp(target + 1, 0);
        
        // 遍歷物品
        for (int num : nums) {
            // 從後向前更新dp陣列
            for (int j = target; j >= num; --j) {
                dp[j] = max(dp[j], dp[j - num] + num);
            }
        }
        
        // 如果dp陣列的最後一個值等於目標和,則可以分割
        return dp[target] == target;
    }
};

在這段程式碼中,我們使用了一個一維陣列 dp 來儲存每個可能重量下的最大價值。陣列 dp 的索引代表可能的揹包容量,而 dp[j] 的值代表在不超過容量 j 的情況下,能夠達到的最大價值。

  • sum 變數用來儲存陣列 nums 中所有元素的和。
  • 如果 sum 是奇數,那麼不可能將陣列分割成兩個和相等的子集,因此直接返回 false
  • target 是我們希望達到的子集和,等於 sum / 2
  • 在遍歷物品時,我們使用一個從後向前的迴圈來更新 dp 陣列,這樣可以確保每個物品只被考慮一次。
  • 最後,如果 dp[target] 等於 target,則表示我們可以找到一個子集,其和等於 target,這意味著我們可以將陣列分割成兩個和相等的子集。

3.1.2 多重揹包

定義

  • 多重揹包問題是揹包問題的一種擴充套件。與01揹包問題不同,在多重揹包問題中,每種物品有有限的數量,而不是隻有0個或1個。具體來說,給定n種物品和一個最多能承重W的揹包,物品i的重量是w[i],價值是v[i],數量是c[i]。問應如何選擇裝入揹包的物品,使得揹包內物品的總價值最大,同時不超過揹包的總重量。
    思路
  • 多重揹包問題的解決思路可以透過將每種物品拆分成若干個01揹包中的物品來實現。例如,如果物品i有c[i]個,我們可以將它拆分成c[i]個物品,每個物品的重量是w[i],價值是v[i],然後使用01揹包的方法來解決。
  • 然而,這種方法可能會導致時間複雜度過高,因為拆分後的物品數量可能非常大。為了最佳化,可以使用二進位制拆分的方法,將物品拆分成若干個不同的部分,每個部分的重量和價值是原物品的整數倍。
    步驟
  1. 對於每種物品,使用二進位制拆分將其拆分成若干個部分。
  2. 將拆分後的物品視為01揹包問題中的物品。
  3. 使用01揹包問題的動態規劃方法來解決。
    以下是解決多重揹包問題的步驟:
  4. 初始化一個一維陣列dp,大小為W+1,用於儲存每個重量的最大價值。
  5. 遍歷每種物品,對於每種物品,使用二進位制拆分方法拆分成若干部分。
  6. 對於每個拆分後的物品,使用01揹包的動態規劃方法更新dp陣列。
    題目:518. 零錢兌換 II
    給定不同面額的硬幣和一個總金額。寫出函式來計算可以湊成總金額的硬幣組合數。假設每一種面額的硬幣有無限多個。
    題解
#include <vector>
using namespace std;

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        vector<int> dp(amount + 1, 0);
        dp[0] = 1; // 邊界條件,表示金額為0時有一種方法
        
        // 遍歷每種硬幣
        for (int coin : coins) {
            // 更新dp陣列
            for (int j = coin; j <= amount; ++j) {
                dp[j] += dp[j - coin];
            }
        }
        
        return dp[amount];
    }
};
  • 在這個題解中,我們實際上解決了一個完全揹包問題,因為每種硬幣可以使用無限次。我們使用一個一維陣列 dp 來儲存每個金額下的組合數。dp[j] 表示湊成金額 j 的組合數。我們初始化 dp[0] 為1,因為湊成金額0有一種方法,即不使用任何硬幣。然後我們遍歷每種硬幣,並更新 dp 陣列。最後返回 dp[amount],即湊成總金額的組合數。

3.1.3 完全揹包

定義

  • 完全揹包問題(Complete Knapsack Problem)是揹包問題的一種。在完全揹包問題中,每種物品有無限個,而揹包的容量是有限的。給定n種物品和一個最多能承重W的揹包,物品i的重量是w[i],價值是v[i]。問應如何選擇裝入揹包的物品,使得揹包內物品的總價值最大,同時不超過揹包的總重量。
    思路
  • 完全揹包問題的解決思路與01揹包問題類似,都是使用動態規劃的方法。不過,由於每種物品可以選擇無限次,所以在更新動態規劃陣列時,需要從左到右更新,而不是從右到左(01揹包問題的更新方向)。
    步驟
  1. 初始化一個一維陣列dp,大小為W+1,用於儲存每個重量的最大價值。初始時,除了dp[0]為0(表示沒有物品時的價值為0),其餘的dp[j](j > 0)都初始化為0。
  2. 遍歷每種物品。
  3. 對於每種物品,從該物品的重量開始,一直到揹包的最大容量,更新dp陣列。
    以下是解決完全揹包問題的步驟:
for (int i = 0; i < n; ++i) { // 遍歷物品
    for (int j = w[i]; j <= W; ++j) { // 遍歷揹包容量
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
    }
}

題目:322. 零錢兌換
給定不同面額的硬幣和一個總金額。編寫一個函式來計算可以湊成總金額所需的最少硬幣個數。如果沒有任何一種硬幣組合能組成總金額,返回-1。
題解

#include <vector>
#include <algorithm>
using namespace std;

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        int Max = amount + 1;
        vector<int> dp(amount + 1, Max);
        dp[0] = 0;
        
        for (int coin : coins) {
            for (int i = coin; i <= amount; ++i) {
                dp[i] = min(dp[i], dp[i - coin] + 1);
            }
        }
        
        return dp[amount] > amount ? -1 : dp[amount];
    }
};
  • 在這個題解中,我們定義了一個一維陣列 dp,其中 dp[i] 表示湊成金額 i 所需的最少硬幣個數。我們初始化 dp[0] 為0,因為湊成金額0不需要任何硬幣。然後,我們遍歷每種硬幣,對於每種硬幣,我們嘗試用它來湊成從它的面額到總金額的每一種金額。我們使用 min 函式來更新 dp 陣列,確保我們得到的是最小的硬幣個數。最後,如果 dp[amount] 的值大於 amount,說明沒有一種硬幣組合能湊成總金額,我們返回-1;否則,返回 dp[amount]

3.2 數位dp

數位DP定義

  • 數位DP(Digit DP)是一種處理數字相關問題的動態規劃方法。它通常用於解決那些需要統計滿足某些特定條件的數字個數的問題。數位DP的核心思想是將數字按位拆分,然後使用記憶化搜尋來避免重複計算。
    思路
  • 數位DP的基本思路是將數字轉換為字串或字元陣列,然後從最高位開始,逐位考慮每一位數字的可能性。在每一步,我們都會面臨兩種選擇:選擇當前位的數字,或者不選擇。透過這種方式,我們可以構建出所有可能的數字,並計算滿足條件的數字個數。
    步驟
  1. 將數字轉換為字串或字元陣列:這樣便於我們逐位處理。
  2. 定義狀態:通常狀態由當前處理到的位數、前面的數字是否小於原數字(用於處理不重複的情況)、是否已經使用了某個特定的數字(用於處理其他限制條件)等因素組成。
  3. 定義遞迴函式:遞迴函式將處理每一位數字,並返回滿足條件的數字個數。
  4. 記憶化搜尋:為了避免重複計算,我們通常使用一個三維陣列來儲存已經計算過的狀態。
    以下是數位DP的一般步驟:
int dp[index][isSmall][used];
int dfs(int index, bool isSmall, bool used, const string& num) {
    if (index == num.size()) return 1; // 如果處理完所有位數,返回1
    if (!isSmall && dp[index][isSmall][used] != -1) return dp[index][isSmall][used]; // 如果已經計算過,直接返回結果
    
    int res = 0;
    int limit = isSmall ? 9 : num[index] - '0'; // 如果前面的數字已經小於原數字,則當前位可以是0-9,否則不能超過原數字的對應位
    for (int digit = 0; digit <= limit; ++digit) {
        res += dfs(index + 1, isSmall || digit < limit, used || (digit > 0), num);
    }
    if (!isSmall) dp[index][isSmall][used] = res; // 記憶化
    return res;
}

題目:233. 數字1的個數

  • 編寫一個函式,輸入是一個無符號整數(以二進位制串的形式),返回其二進位制表示式中數字位數為 ‘1’ 的個數(也被稱為漢明重量)。
    題解
  • 這個問題實際上並不需要數位DP來解決,因為可以直接使用內建函式或者位操作來計算。不過,為了演示數位DP,我們可以修改題目為:計算從0到n的所有整數中,數字1出現的次數。
#include <vector>
#include <string>
#include <algorithm>
using namespace std;

class Solution {
public:
    int countDigitOne(int n) {
        string num = to_string(n);
        vector<vector<int>> dp(num.size(), vector<int>(2, -1));
        return dfs(0, false, num, dp);
    }

    int dfs(int index, bool isSmall, const string& num, vector<vector<int>>& dp) {
        if (index == num.size()) return 0;
        if (!isSmall && dp[index][0] != -1) return dp[index][0];
        
        int res = 0;
        int limit = isSmall ? 9 : num[index] - '0';
        for (int digit = 0; digit <= limit; ++digit) {
            res += dfs(index + 1, isSmall || digit < limit, num, dp);
            if (digit == 1) {
                res += (isSmall ? (num.size() - index) : pow(10, num.size() - index - 1));
            }
        }
        if (!isSmall) dp[index][0] = res;
        return res;
    }
};
  • 在這個題解中,我們定義了一個遞迴函式 dfs 來計算從當前位開始,後面所有數字中1出現的次數。dp 陣列用於記憶化,避免重複計算。在遞迴過程中,我們統計當前位為1時,後面所有數字中1出現的次數,並累加到結果中。最後,我們返回從最高位開始計算的結果。

3.3 狀態壓縮dp

狀態壓縮DP定義

  • 狀態壓縮DP是一種動態規劃技術,它透過將狀態表示為整數來減少空間複雜度。在許多問題中,狀態可以由多個布林變數表示,而每個布林變數都可以用一位二進位制數表示。因此,整個狀態可以用一個二進位制數來表示,這就是所謂的“狀態壓縮”。
    思路
  • 狀態壓縮DP的核心思想是利用位運算來表示和操作狀態。這種方法通常用於解決組合最佳化問題,特別是那些狀態可以用一組布林變數表示的問題。
    步驟
  1. 定義狀態:將每個布林變數對映到位的位置上。
  2. 初始化狀態:設定初始狀態,通常是基於問題的初始條件。
  3. 狀態轉移:使用位運算來更新狀態。通常,這涉及到位掩碼和位翻轉。
  4. 記憶化搜尋:為了避免重複計算,使用陣列或雜湊表來儲存已經計算過的狀態的結果。
    以下是狀態壓縮DP的一般步驟:
int dp[狀態上限];
int dfs(當前狀態) {
    if (當前狀態是終點狀態) return 0; // 終止條件
    if (dp[當前狀態] != -1) return dp[當前狀態]; // 記憶化
    
    int res = 最大值; // 初始化結果
    // 嘗試所有可能的轉移
    for (int i = 0; i < 狀態位數; ++i) {
        if (當前狀態的第i位是0) {
            int 新狀態 = 當前狀態 | (1 << i); // 更新狀態
            res = min(res, dfs(新狀態) + 轉移代價);
        }
    }
    dp[當前狀態] = res; // 記憶化
    return res;
}

題目:691. 貼紙拼詞

  • 我們有一組拼貼,其中每個拼貼都有一個小寫的英文單詞。我們想要用所有的拼貼拼出一個句子。返回所有可能的句子。
    題解
  • 這個問題實際上不需要狀態壓縮DP,因為它是一個組合問題,而不是最佳化問題。但是,我們可以用狀態壓縮DP來解決另一個問題:給定一個整數集合,求所有不重複的子集。以下是這個問題的C++題解:
#include <vector>
#include <string>
#include <algorithm>
using namespace std;

class Solution {
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        vector<vector<int>> result;
        vector<int> subset;
        int n = nums.size();
        int maxState = 1 << n; // 狀態上限

        for (int state = 0; state < maxState; ++state) {
            subset.clear();
            for (int i = 0; i < n; ++i) {
                if (state & (1 << i)) { // 檢查第i位是否為1
                    subset.push_back(nums[i]);
                }
            }
            result.push_back(subset);
        }
        return result;
    }
};
  • 在這個題解中,我們用二進位制數來表示子集的狀態,其中每一位代表原陣列中的一個元素是否存在於當前子集中。透過遍歷所有可能的狀態(從0到2^n - 1),我們可以得到所有可能的子集。
  • 請注意,這個問題實際上不需要動態規劃,因為它只是簡單地遍歷了所有可能的子集,而沒有需要最佳化的目標函式。狀態壓縮DP通常用於那些需要計算最優解的問題,例如最小/最大路徑和、最小/最大覆蓋等。

3.4 區間dp

定義

  • 區間動態規劃(Interval DP)是一種動態規劃技術,它將問題分解為更小的子問題,這些子問題通常以區間為形式。區間DP通常用於解決那些子問題可以被表示為給定序列的一個連續子區間的問題。
    思路
  • 區間DP的核心思想是將問題分解為以區間為基礎的子問題,並從最小的區間開始解決,逐步擴大區間範圍,直到解決整個問題。
    步驟
  1. 定義狀態:dp[i][j] 表示從序列的第i個元素到第j個元素的最優解。
  2. 初始化狀態:對於單個元素的情況,通常可以直接給出初始值,因為它們是基本情況。
  3. 狀態轉移:對於長度大於1的區間,根據子區間的最優解來更新當前區間的最優解。
  4. 計算順序:通常按照區間長度從小到大進行計算,確保計算dp[i][j]時,所有需要的子問題dp[i][k]和dp[k+1][j]都已經解決。
    題目:LeetCode 516. 最長迴文子序列
  • 給定一個字串s,找到其中最長的迴文子序列。可以假設s的最大長度為1000。
    題解
#include <vector>
#include <string>
using namespace std;

class Solution {
public:
    int longestPalindromeSubseq(string s) {
        int n = s.size();
        vector<vector<int>> dp(n, vector<int>(n, 0));

        // 初始化狀態:單個字元的迴文子序列長度為1
        for (int i = 0; i < n; ++i) {
            dp[i][i] = 1;
        }

        // 計算順序:從長度為2的區間開始
        for (int len = 2; len <= n; ++len) {
            for (int i = 0; i <= n - len; ++i) {
                int j = i + len - 1;
                if (s[i] == s[j]) {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                } else {
                    dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
                }
            }
        }

        // 返回整個字串的最長迴文子序列長度
        return dp[0][n - 1];
    }
};
  • 在這個題解中:
    • 狀態dp[i][j]表示字串s從索引ij的最長迴文子序列的長度。
    • 初始化狀態是單個字元的迴文子序列長度為1。
    • 狀態轉移方程是:如果s[i] == s[j],則dp[i][j] = dp[i + 1][j - 1] + 2;否則dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
    • 計算順序是按照區間長度從小到大進行,確保計算dp[i][j]時,所有需要的子問題dp[i + 1][j - 1]dp[i + 1][j]dp[i][j - 1]都已經解決。

3.5 樹形dp

樹形DP定義

  • 樹形動態規劃(Tree DP)是一種動態規劃技術,專門用於解決樹形結構上的問題。在樹形DP中,通常需要定義狀態來表示從根節點到當前節點的某個性質,然後透過遞迴的方式,從子節點向上計算父節點的狀態。
    思路
  • 樹形DP的思路是將樹分解為子樹問題,然後透過子樹問題的解來構建整個樹的解。通常,我們定義一個狀態來表示以某個節點為根的子樹的最優解,然後遞迴地計算每個節點的狀態。
    步驟
  1. 定義狀態:通常定義dp[u]來表示以節點u為根的子樹的最優解。
  2. 遞迴函式:編寫一個遞迴函式,它將計算以當前節點為根的子樹的狀態。
  3. 初始化狀態:對於葉節點,通常可以直接給出初始值,因為它們沒有子節點。
  4. 狀態轉移:對於每個節點,透過其子節點的狀態來更新當前節點的狀態。
  5. 返回結果:遞迴結束後,通常根節點的狀態即為整個樹的最優解。
    題目:LeetCode 337. 打家劫舍 III
  • 在上次打劫完一條街道之後和一圈房屋後,小偷又發現了一個新的可行竊的地區。這個地區只有一個入口,我們稱之為“根”。除了“根”之外,每棟房子有且只有一個“父“房子與之相連。一番偵察之後,聰明的小偷意識到“這個地方的所有房屋的排列類似於一棵二叉樹”。如果兩個直接相連的房子在同一天晚上被打劫,房屋將自動報警。
  • 計算在不觸動警報的情況下,小偷一晚能夠盜取的最高金額。
    題解
#include <vector>
#include <algorithm>
using namespace std;

struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

class Solution {
public:
    int rob(TreeNode* root) {
        vector<int> result = robSub(root);
        return max(result[0], result[1]);
    }

    // 返回一個大小為2的陣列 arr
    // arr[0] 表示不偷該節點所得到的最大錢數
    // arr[1] 表示偷該節點所得到的最大錢數
    vector<int> robSub(TreeNode* root) {
        if (root == nullptr) return {0, 0};

        vector<int> left = robSub(root->left);
        vector<int> right = robSub(root->right);
        // 偷當前節點,則不能偷左右子節點
        int rob = root->val + left[0] + right[0];
        // 不偷當前節點,則可以選擇偷或不偷左右子節點
        int not_rob = max(left[0], left[1]) + max(right[0], right[1]);

        return {not_rob, rob};
    }
};
  • 在這個題解中:
    • 狀態vector<int> dp表示一個節點在不偷和偷的情況下的最大金額,其中dp[0]是不偷,dp[1]是偷。
    • 遞迴函式robSub計算以當前節點為根的子樹的狀態。
    • 狀態轉移方程是:如果偷當前節點,則不能偷左右子節點,即rob = root->val + left[0] + right[0];如果不偷當前節點,則可以選擇偷或不偷左右子節點,即not_rob = max(left[0], left[1]) + max(right[0], right[1])
    • 最終返回根節點的兩種狀態中的最大值。

3.6 最佳化方法

3.6.1 滾動陣列

定義:

  • 滾動陣列是一種最佳化動態規劃空間複雜度的方法。在動態規劃中,通常需要維護一個陣列來儲存每一步的中間狀態。如果這些狀態只依賴於前幾個狀態,那麼可以透過“滾動”陣列來減少空間的使用,即只保留必要的狀態,而不是儲存整個歷史狀態。
    思路:
  • 滾動陣列的思路是將陣列的維度減少到2(在某些情況下可能更少),因為當前狀態只依賴於前一個或幾個狀態。透過這種方式,我們可以在迭代過程中“覆蓋”舊的狀態,用同一塊記憶體空間來儲存新的狀態。
    步驟
  1. 初始化狀態:通常需要初始化兩個狀態,代表“上一個”和“當前”狀態。
  2. 狀態轉移:在每一步迭代中,使用當前狀態來更新這兩個狀態中的一個,而另一個狀態保持不變。
  3. 更新狀態:在狀態轉移完成後,將“上一個”狀態更新為“當前”狀態,為下一輪迭代做準備。
    LeetCode 示例題:
  • LeetCode 70. 爬樓梯(Climbing Stairs)是一個很好的滾動陣列示例。
  • 題目描述:假設你正在爬樓梯。需要 n 階你才能到達樓頂。每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?
    題解:
#include <iostream>
#include <vector>

class Solution {
public:
    int climbStairs(int n) {
        if (n <= 2) return n; // 如果小於等於2階,直接返回n
        
        int prev = 1; // 初始化為爬1階的方法數
        int curr = 2; // 初始化為爬2階的方法數
        
        for (int i = 3; i <= n; ++i) {
            int next = prev + curr; // 狀態轉移方程
            prev = curr; // 更新“上一個”狀態
            curr = next; // 更新“當前”狀態
        }
        
        return curr; // 返回爬到n階的方法數
    }
};

int main() {
    Solution sol;
    int n = 5; // 示例:爬5階樓梯
    std::cout << "Number of ways to climb " << n << " stairs: " << sol.climbStairs(n) << std::endl;
    return 0;
}
  • 在這個示例中,prevcurr就是滾動陣列,它們分別代表爬到上一階樓梯和當前階樓梯的方法數。在每一步迭代中,我們計算出爬到下一階樓梯的方法數,然後更新這兩個狀態。透過這種方式,我們只使用了常數空間,而不是一個大小為n的陣列。

3.6.2 二分最佳化

定義:

  • 二分最佳化是一種利用二分查詢演算法來減少搜尋空間,從而提高演算法效率的最佳化方法。它通常用於最佳化那些具有單調性(單調遞增或單調遞減)的搜尋問題,透過不斷縮小搜尋區間來快速定位問題的解。
    思路:
  • 二分最佳化的核心思路是將線性搜尋轉化為二分搜尋。對於單調問題,如果某個解不滿足條件,那麼在它之前的所有解也都不滿足條件;同理,如果某個解滿足條件,那麼在它之後的所有解也都滿足條件。基於這一性質,我們可以每次將搜尋區間縮小一半,從而快速找到滿足條件的解。
    步驟:
  1. 初始化邊界:設定搜尋區間的左右邊界。
  2. 計算中點:在每次迭代中,計算當前搜尋區間的中點。
  3. 檢查條件:檢查中點是否滿足問題的條件。
  4. 更新邊界:根據中點是否滿足條件來更新搜尋區間的左右邊界。
  5. 終止條件:當搜尋區間縮小到只有一個元素時,或者找到滿足條件的解時,停止搜尋。
    LeetCode 示例題:
  • LeetCode 69. x 的平方根(Sqrt(x))是一個適合使用二分最佳化的題目。
  • 題目描述:實現 int sqrt(int x) 函式。計算並返回 x 的平方根,其中 x 是非負整數。由於返回型別是整數,結果只保留整數的部分,小數部分將被捨去。
    題解:
#include <iostream>

class Solution {
public:
    int mySqrt(int x) {
        if (x == 0) return 0;
        long long left = 1, right = x, mid, sqrt;
        while (left <= right) {
            mid = left + (right - left) / 2;
            sqrt = mid * mid;
            if (sqrt == x) return mid; // 找到精確解
            else if (sqrt < x) left = mid + 1; // 縮小左邊界
            else right = mid - 1; // 縮小右邊界
        }
        return right; // 當退出迴圈時,right是小於x的最大平方根整數
    }
};

int main() {
    Solution sol;
    int x = 16; // 示例:計算16的平方根
    std::cout << "The integer part of the square root of " << x << " is: " << sol.mySqrt(x) << std::endl;
    return 0;
}
  • 在這個示例中,我們使用二分查詢來尋找不大於x的平方根的最大整數。我們初始化左右邊界,然後在每次迭代中計算中點,檢查中點的平方是否等於x。如果不等於,我們就根據中點平方的大小來調整左右邊界,直到找到解或搜尋區間縮小到只有一個元素。最終,當迴圈結束時,right就是我們要找的解。

3.6.3 矩陣最佳化

定義:

  • 矩陣最佳化是一種利用矩陣運算的特性來提高演算法效率的最佳化方法。在許多演算法問題中,尤其是動態規劃問題,可以透過矩陣乘法等操作來降低演算法的時間複雜度,從而最佳化演算法效能。
    思路:
  • 矩陣最佳化的核心思路是將問題轉化為矩陣的形式,然後利用矩陣運算的性質(如結合律、分配律)來簡化計算。常見的矩陣最佳化方法包括矩陣快速冪、矩陣乘法等。
    步驟:
  1. 問題建模:將問題轉化為矩陣形式,明確狀態轉移方程。
  2. 矩陣初始化:根據問題的初始條件初始化矩陣。
  3. 矩陣運算:根據狀態轉移方程進行矩陣運算,如矩陣乘法、矩陣快速冪等。
  4. 結果提取:從最終矩陣中提取出問題的解。
    LeetCode 示例題:
  • LeetCode 322. 零錢兌換(Coin Change)可以透過矩陣快速冪進行最佳化。
  • 題目描述:給定不同面額的硬幣 coins 和一個總金額 amount。編寫一個函式來計算組成該金額所需的最少硬幣數量。如果沒有任何一種硬幣組合能組成總金額,返回 -1。
    題解:
#include <vector>
#include <algorithm>

using namespace std;

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        int n = coins.size();
        // 初始化轉移矩陣
        vector<vector<long>> mat(n, vector<long>(n, 0));
        for (int i = 0; i < n; ++i) {
            mat[i][i] = 1;
        }
        vector<vector<long>> res = matrixPower(mat, amount - 1);
        
        // 初始化結果矩陣
        vector<long> vec(n, 1);
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < n; ++j) {
                vec[i] += res[i][j] * coins[j];
            }
        }
        
        // 尋找最小的硬幣組合
        int minCoins = INT_MAX;
        for (int i = 0; i < n; ++i) {
            if (vec[i] <= (long)amount) {
                minCoins = min(minCoins, (int)((amount - vec[i]) / coins[i] + 1));
            }
        }
        
        return minCoins == INT_MAX ? -1 : minCoins;
    }

    // 矩陣快速冪
    vector<vector<long>> matrixPower(vector<vector<long>>& mat, int n) {
        int size = mat.size();
        vector<vector<long>> res(size, vector<long>(size, 0));
        for (int i = 0; i < size; ++i) {
            res[i][i] = 1;
        }
        vector<vector<long>> tmp = mat;
        while (n > 0) {
            if (n & 1) res = matrixMultiply(res, tmp);
            tmp = matrixMultiply(tmp, tmp);
            n >>= 1;
        }
        return res;
    }

    // 矩陣乘法
    vector<vector<long>> matrixMultiply(vector<vector<long>>& a, vector<vector<long>>& b) {
        int size = a.size();
        vector<vector<long>> c(size, vector<long>(size, 0));
        for (int i = 0; i < size; ++i) {
            for (int j = 0; j < size; ++j) {
                for (int k = 0; k < size; ++k) {
                    c[i][j] += a[i][k] * b[k][j];
                }
            }
        }
        return c;
    }
};

int main() {
    Solution sol;
    vector<int> coins = {1, 2, 5};
    int amount = 11;
    cout << "Minimum coins required: " << sol.coinChange(coins, amount) << endl;
    return 0;
}
  • 在這個題解中,我們首先將問題轉化為矩陣形式,然後透過矩陣快速冪來計算狀態轉移矩陣的冪。接著,我們使用矩陣乘法來計算最終的狀態向量,最後從狀態向量中提取出問題的解。
  • 需要注意的是,上述程式碼只是一個示例,它沒有真正實現矩陣最佳化的完整過程,因為零錢兌換問題並不適合直接使用矩陣快速冪來最佳化。通常矩陣快速冪用於解決那些狀態轉移方程可以表示為矩陣乘法的問題,例如斐波那契數列。對於零錢兌換問題,標準的動態規劃解法會更直接和高效。這裡的示例只是為了說明矩陣最佳化的概念和步驟。

3.6.4 斜率最佳化

定義:

  • 斜率最佳化是一種利用函式的斜率(導數)來最佳化動態規劃問題的方法。在動態規劃問題中,我們通常需要找到一種狀態轉移關係,使得問題的解能夠透過一系列子問題的最優解來遞推得到。斜率最佳化透過比較不同狀態轉移方案的斜率,選擇最優的轉移方案,從而減少計算量,提高演算法效率。
    思路:
  • 斜率最佳化的核心思路是將狀態轉移方程轉化為一個關於決策變數的函式,然後透過維護一個凸殼(Convex Hull)來找到最優的決策點。具體來說,就是透過比較不同決策點的斜率,找到使得目標函式最小的決策點。
    步驟:
  1. 狀態轉移方程轉化:將動態規劃的狀態轉移方程轉化為關於決策變數的函式。
  2. 斜率計算:計算每個決策點的斜率。
  3. 維護凸殼:使用單調佇列等資料結構維護一個凸殼,確保能夠快速找到最優的決策點。
  4. 狀態轉移:根據凸殼上的最優決策點進行狀態轉移。
    LeetCode 示例題:
  • LeetCode 84. 柱狀圖中最大的矩形(Largest Rectangle in Histogram)可以使用斜率最佳化來求解。
  • 題目描述:給定 n 個非負整數,用來表示柱狀圖中各個柱子的高度。每個柱子彼此相鄰,且寬度為 1 。求在該柱狀圖中,能夠勾勒出來的矩形的最大面積。
    題解:
#include <vector>
#include <stack>
#include <algorithm>

using namespace std;

class Solution {
public:
    int largestRectangleArea(vector<int>& heights) {
        int n = heights.size();
        vector<int> left(n), right(n);
        stack<int> s;

        // 計算每個柱子的左邊界
        for (int i = 0; i < n; ++i) {
            while (!s.empty() && heights[s.top()] >= heights[i]) {
                s.pop();
            }
            left[i] = s.empty() ? -1 : s.top();
            s.push(i);
        }

        // 清空棧,用於計算右邊界
        while (!s.empty()) s.pop();

        // 計算每個柱子的右邊界
        for (int i = n - 1; i >= 0; --i) {
            while (!s.empty() && heights[s.top()] >= heights[i]) {
                s.pop();
            }
            right[i] = s.empty() ? n : s.top();
            s.push(i);
        }

        // 計算最大矩形面積
        int maxArea = 0;
        for (int i = 0; i < n; ++i) {
            maxArea = max(maxArea, heights[i] * (right[i] - left[i] - 1));
        }

        return maxArea;
    }
};

int main() {
    Solution sol;
    vector<int> heights = {2, 1, 5, 6, 2, 3};
    cout << "Largest rectangle area: " << sol.largestRectangleArea(heights) << endl;
    return 0;
}
  • 在這個題解中,我們並沒有直接使用斜率最佳化,而是使用了單調棧來找到每個柱子的左右邊界,從而計算每個柱子為高度的最大矩形面積。這種方法在本質上與斜率最佳化有相似之處,因為它也是透過比較相鄰柱子的高度來找到最優解。但是,真正的斜率最佳化通常會涉及到更復雜的斜率計算和凸殼維護。
  • 由於柱狀圖中最大的矩形問題並不直接對應於斜率最佳化的標準形式,因此這裡提供的題解並不是一個嚴格的斜率最佳化示例。斜率最佳化通常用於一些特定的動態規劃問題,如部分和問題(POJ 3250)、旅行問題(POJ 3616)等,在這些問題中,我們可以將狀態轉移方程轉化為關於決策變數的斜率,並利用凸殼來找到最優決策點。斜率最佳化的具體實現通常較為複雜,並且需要較強的數學背景和技巧。

3.6.5 四邊形不等式最佳化

四邊形不等式最佳化的定義

  • 四邊形不等式最佳化是一種用於動態規劃演算法的最佳化技巧,它基於四邊形不等式的性質來減少動態規劃中的狀態轉移次數。四邊形不等式通常用於具有特定結構的問題,尤其是那些涉及到兩個決策變數的動態規劃問題。
  • 四邊形不等式指的是對於任意的實數a, b, c, d,如果a ≤ b,c ≤ d,則 a + c ≤ b + d。在動態規劃中,如果狀態轉移滿足四邊形不等式,則可以透過這個性質來最佳化狀態轉移的過程。
    思路:
  • 四邊形不等式最佳化的核心思路是利用四邊形不等式來減少動態規劃中的狀態轉移次數。具體來說,如果在某個動態規劃問題中,狀態轉移方程滿足四邊形不等式,那麼在計算某個狀態的最優值時,我們只需要考慮一部分決策點,而不是所有的決策點。
    步驟:
  1. 證明四邊形不等式:首先證明問題中的狀態轉移方程滿足四邊形不等式。
  2. 確定決策點範圍:根據四邊形不等式,確定每個狀態需要考慮的決策點範圍。
  3. 狀態轉移:在計算每個狀態的最優值時,只考慮決策點範圍內的值,而不是所有的可能值。
  4. 最佳化演算法:利用上述性質最佳化動態規劃演算法,減少計算量。
    LeetCode 示例題:
  • LeetCode 87. 擾亂字串(Scramble String)可以使用四邊形不等式最佳化來求解。
  • 題目描述:給定一個字串 s1,我們可以把它遞迴地分割成兩個非空子字串,從而將其表示為二叉樹。在擾亂這個字串的過程中,我們可以選擇任意一個非葉節點,然後交換它的兩個子節點。給定兩個字串 s1 和 s2,當它們分別被遞迴地分割時,可能會得到同樣的二叉樹。當 s2 是 s1 的擾亂字串時,返回 true,否則返回 false。
    題解:
  • 以下是使用動態規劃來求解擾亂字串問題的C++題解,由於四邊形不等式最佳化通常較為複雜,這裡只給出基本的動態規劃解法:
#include <string>
#include <vector>

using namespace std;

class Solution {
public:
    bool isScramble(string s1, string s2) {
        if (s1.size() != s2.size()) return false;
        int n = s1.size();
        vector<vector<vector<bool>>> dp(n, vector<vector<bool>>(n, vector<bool>(n + 1, false)));
        
        // 初始化dp陣列
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < n; ++j) {
                dp[i][j][1] = (s1[i] == s2[j]);
            }
        }
        
        // 填充dp陣列
        for (int len = 2; len <= n; ++len) {
            for (int i = 0; i <= n - len; ++i) {
                for (int j = 0; j <= n - len; ++j) {
                    for (int k = 1; k < len; ++k) {
                        dp[i][j][len] = (dp[i][j][k] && dp[i + k][j + k][len - k]) || 
                                        (dp[i][j + len - k][k] && dp[i + k][j][len - k]);
                        if (dp[i][j][len]) break;
                    }
                }
            }
        }
        
        return dp[0][0][n];
    }
};

int main() {
    Solution sol;
    string s1 = "great";
    string s2 = "rgeat";
    cout << "Is scramble string: " << (sol.isScramble(s1, s2) ? "true" : "false") << endl;
    return 0;
}
  • 在這個題解中,我們使用了三維動態規劃陣列 dp[i][j][len] 來表示字串 s1 中從 i 開始長度為 len 的子串和字串 s2 中從 j 開始長度為 len 的子串是否互為擾亂字串。然後透過遞迴地檢查所有可能的分割點來填充這個陣列。
  • 邊形不等式最佳化通常需要更復雜的分析和證明,上述程式碼並沒有使用四邊形不等式最佳化。四邊形不等式最佳化通常適用於那些有明確決策順序和決策範圍的問題,例如任務排程問題或者分割問題。在這些問題中,四邊形不等式可以幫助我們減少狀態轉移的計算量。在擾亂字串問題中,由於狀態轉移較為複雜,並不直接適用四邊形不等式最佳化。

3.6.6 資料結果最佳化

資料結果最佳化的定義:

  • 結果最佳化是一種最佳化方法,它關注於改善演算法的輸出結果,通常是透過調整輸入資料或者演算法的執行過程來獲得更好的結果。這種方法通常不改變演算法的時間複雜度或空間複雜度,而是透過調整演算法細節來提高結果的準確度、穩定性或某些特定指標。
    思路:
  • 資料結果最佳化的核心思路是透過以下幾種方式來提高演算法輸出的質量:
    1. 調整輸入資料:對輸入資料進行預處理,比如排序、去噪、歸一化等,以改善演算法的效能。
    2. 改進演算法邏輯:在演算法執行過程中,加入額外的邏輯來提高結果的準確性,比如增加啟發式規則、使用動態調整策略等。
    3. 後處理結果:對演算法的輸出結果進行後處理,例如透過某些策略來最佳化結果的穩定性或可靠性。
      步驟:
  1. 分析問題:理解問題需求和演算法的侷限性,確定最佳化目標。
  2. 預處理資料:根據演算法需求,對輸入資料進行適當的預處理。
  3. 最佳化演算法邏輯:在演算法執行過程中,加入邏輯以改善結果的準確性或效率。
  4. 後處理結果:對演算法輸出進行後處理,以滿足特定的最佳化目標。
  5. 驗證最佳化效果:透過測試用例驗證最佳化後的演算法是否達到了預期的效果。
    LeetCode 示例題:
  • LeetCode 300. 最長上升子序列(Longest Increasing Subsequence)
  • 題目描述:給定一個整數陣列 nums,找出一個具有最大長度的嚴格遞增子序列,返回這個子序列的長度。子序列是由陣列派生而來的序列,刪除(或不刪除)陣列中的元素而不改變其餘元素的順序。
    題解:
  • 以下是使用動態規劃求解最長上升子序列問題的C++題解,並在其中應用資料結果最佳化的思路。
#include <vector>
#include <algorithm>

using namespace std;

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        if (nums.empty()) return 0;
        
        // dp陣列儲存到當前位置的最長上升子序列長度
        vector<int> dp(nums.size(), 1);
        int max_len = 1; // 最長上升子序列的長度
        
        // 動態規劃求解
        for (int i = 1; i < nums.size(); ++i) {
            for (int j = 0; j < i; ++j) {
                // 如果nums[i]大於nums[j],則更新dp[i]
                if (nums[i] > nums[j]) {
                    dp[i] = max(dp[i], dp[j] + 1);
                }
            }
            // 更新最長上升子序列的長度
            max_len = max(max_len, dp[i]);
        }
        
        return max_len;
    }
};

int main() {
    Solution sol;
    vector<int> nums = {10, 9, 2, 5, 3, 7, 101, 18};
    cout << "Length of LIS: " << sol.lengthOfLIS(nums) << endl;
    return 0;
}
  • 在這個題解中,我們使用了動態規劃陣列 dp 來記錄到當前位置的最長上升子序列長度。演算法的時間複雜度是 O(n^2),空間複雜度是 O(n)。

資料結果最佳化:

  • 在上述題解中,資料結果最佳化的一個例子是透過二分查詢最佳化動態規劃的更新步驟。我們可以維護一個額外的陣列 tails,其中 tails[i] 表示長度為 i+1 的所有上升子序列中,結尾元素的最小值。這樣,我們可以在 O(n log n) 的時間複雜度內解決這個問題。
  • 以下是最佳化後的C++題解:
#include <vector>
#include <algorithm>

using namespace std;

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        vector<int> tails;
        for (int num : nums) {
            auto it = lower_bound(tails.begin(), tails.end(), num);
            if (it == tails.end()) {
                tails.push_back(num);
            } else {
                *it = num;
            }
        }
        return tails.size();
    }
};

int main() {
    Solution sol;
    vector<int> nums = {10, 9, 2, 5, 3, 7, 101, 18};
    cout << "Length of LIS: " << sol.lengthOfLIS(nums) << endl;
    return 0;
}
  • 在這個最佳化後的版本中,我們使用 lower_bound 來找到第一個大於等於 num 的位置,這樣可以確保 tails 陣列始終保持有序,從而減少了更新操作的次數,提高了演算法的效率。

相關文章