3.动态规划

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 数组始终保持有序,从而减少了更新操作的次数,提高了算法的效率。
posted on 2024-10-22 00:58  才会相思  阅读(31)  评论(0编辑  收藏  举报