动态规划/回溯/贪心

回溯法

  • 回溯的本质是一种**“试探性搜索”策略**
  • 通过“逐步构建解空间,及时剪枝无效路径”来寻找问题的解,或者是枚举所有解
  • 最简单的理解,就是对解空间进行深度优先搜索(DFS),每个解都可以构建成一颗搜索树
  • 回溯最直接的写法,就是递归

贪心

  • 贪心的本质是局部最优解导向全局最优
  • 需要满足最优子结构,也就是必须先证明局部最优,一定可以导向全局最优
  • 若不满足最优子结构,说明本场景不适用贪心算法

动态规划

  • 动态规划的本质是“重叠子问题”和“最优子结构”
  • 通过定义状态(存储子问题解)和状态转移方程(复用子问题解)”,以空间换时间
  • 动态规划的题目一般都可以用回溯法,只不过数据量大可能会超时,动态规划 ≈ 回溯 + 剪枝

实战举例

leetcode 494.目标和

给你一个非负整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 +- ,然后串联起所有整数,可以构造一个 表达式 :

例如,nums = [2, 1] ,可以在 2 之前添加 + ,在 1 之前添加 - ,然后串联起来得到表达式 +2-1
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

思路拆解

目标和:nums数组里面每个数添加符号,组成目标值target,可以拆解成“+”的集合A,和“-”的集合B
目标和的答案是:sum(A) - sum(B) = target,而total = sum(A) + sum(B),可以推导出sum(A) = (total - target) / 2
题目等价于“找到所有子集A”,抽象层面就是“二元选择”和“累积效应”,满足动态规划,将需要重复计算的结果累计保存
之所以可以这么转化,是因为两个问题的解空间是完全同构的

回溯法

先用回溯法解本题,回溯一般分两种思路

  • 输入的视角:“选or不选”
  • 答案的视角:每个节点枚举所有可能答案,递归下一个节点

本题采用第一种解法

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int n = nums.size();
        // 计算 sum(A)
        int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
        // sum(A) 必须是偶数,因为除以2,得出来的结果必须是整数,才能符合题目要求
        if (s < 0 || s & 1 != 0) {
            return 0;
        }

        int ans = 0;
        function<void(int, int)> dfs = [&](int i, int c) { // i: f(i); c: sum(A) - nums[i]
            // 退出条件
            if (i < 0) {
            	// 遍历完成,并且c(容量)满足目标值
                if (c == 0) {
                    ans++;
                }
                return;
            }

            // 不选,直接递归下一个节点
            dfs(i - 1, c);
            // 选,剩余容量减去待选取容量
            if (c >= nums[i]) {
                dfs(i - 1, c - nums[i]);
            }
        };

        dfs(n - 1, s / 2);
        return ans;
    }
};

可证明,动态规划的题目,实际上也可以回溯解,只不过回溯会枚举所有的可能解,而本题实际上只需要计算满足解的数量,并不需要体现每一种解法,而且一般纯回溯容易导致超时

时间复杂度:O(2ⁿ):nums.length == n,每个元素选或者不选两种可能
空间复杂度:O(n),栈的递归深度

动态规划

记忆化搜索

记忆化搜索(Memoization),本质是自顶向下(Top-Down)的动态规划实现方式
记忆化搜索,采用 递归 + 记忆表(memo) 实现,从原问题递归到子问题

因此可以考虑使用动态规划的解法,上面提到动态规划 ≈ 回溯 + 剪枝

由以上的递归回溯解法,可以推导出一个状态转移方程:f(i, c) = f(i - 1, c) + f(i - 1, c - nums[i])
那么实际上在回溯每个解法的时候,退出条件都是递归到i < 0,也就是遍历完nums[i],才能知道是否符合条件
因此每个f(i)都可能存在被多次重复计算,可以剪枝

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int n = nums.size();
        // 计算 sum(A)
        int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
        // sum(A) 必须是偶数,因为除以2,的出来的结果必须是整数,才能符合题目要求
        if (s < 0 || s & 1 != 0) {
            return 0;
        }

        int m = s / 2; // 背包容量
        vector memo(n, vector<int>(m + 1, -1)); // 初始化-1,表示没访问过,[0, m]
        function<int(int, int)> dfs = [&](int i, int c) -> int { // i: f(i); c: sum(A) - nums[i]
            // 退出条件
            if (i < 0) {
                return c == 0;
            }

            // 剪枝,不重复计算
            if (memo[i][c] != -1) { // c最大就是容量m,不会越界
                return memo[i][c];
            }

            // 剩余容量不足,只能不选
            if (c < nums[i]) {
                return memo[i][c] = dfs(i - 1, c);
            }

            return memo[i][c] = dfs(i - 1, c) + dfs(i - 1, c - nums[i]);
        };

        return dfs(n - 1, m);
    }
};

此题转化成动态规划,实现上是一种记忆化搜索,记忆化搜索 = 递归搜索 + 保存计算结果(备忘录),本题本质上也是0-1背包问题,从i == n - 1开始递归计算,依次计算从[i, n - 1]组成容量c(总和c)的方案数,直到边界条件i == 0

时间复杂度:O(nm),n == nums.length,每个nums[i],都有m种背包容量的遍历,状态个数 = m * n
空间复杂度:O(nm),每个状态计算结果都保存

迭代(递推)

递推是动态规划自底向上(Bottom-up)的实现方式
采用 迭代 + 动态规划表(dp数组) 实现,从子问题逐步计算到原问题

因此,本题也有另外一种实现方式

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int n = nums.size();
        // 计算 sum(A)
        int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
        // sum(A) 必须是偶数,因为除以2,的出来的结果必须是整数,才能符合题目要求
        if (s < 0 || s & 1 != 0) {
            return 0;
        }

        int m = s / 2; // 背包容量
        vector dp(n + 1, vector<int>(m + 1));
        dp[0][0] = 1; // sum(A)的子集,表示总和0,也就是空集,算一种解决方案,初始化1

        // dp[i][c]表示前i个元素,容量为c的方案数量
        for (int i = 0; i < n; i++) {
            for (int c = 0; c <= m; c++) {
                // 只能不选
                if (c < nums[i]) {
                    dp[i + 1][c] = dp[i][c];
                } else {
                    dp[i + 1][c] = dp[i][c] + dp[i][c - nums[i]]; // i 表示dp的第i + 1元素,因为手动加入了dp[0],总长度是n + 1
                }
            }
        }

        return dp[n][m];
    }
};

递推,一般采用循环的方式,本题中与记忆化搜索不同的写法是,从i == 0开始遍历,依次计算前i个元素组成容量c(总和c)的方案数,直到边界条件i == n

时间复杂度:O(nm)
空间复杂度:O(nm)

两种写法只是推演方向不同,一种自顶向下(递归),一种自底向上(递推),时间复杂度是一致的,简单讲下两种思路的区别

维度记忆化搜索(递归)迭代(递推)
计算顺序自顶向下(Top-Down) :原问题->子问题自底向上(Bottom-Up):子问题->原问题
实现方式递归 + 记忆表/备忘录(哈希表 / memo数组)迭代/循环 + dp数组
空间开销额外包含递归栈空间(数据量大可能导致溢出)仅需dp表
适用场景子问题稀疏型,无需全量计算,“懒加载”子问题密集型,需要遍历所有解

针对适用场景,举个例子,计算组合数C(n, k)

  • 状态转移方程:C(n, k) = C(n - 1, k - 1) + C(n - 1, k)
  • 当n = 1000, k = 2时,属于子问题稀疏型,不需要计算全量解,最终只涉及k ≤ 2的解,总数量约1000 * 3
  • 当k无限接近n时,就属于子问题密集型

因此,当问题数据规模大,优先选择无递归栈调用的递推方式解决,但是以上两种解法,除了时间复杂度一样,空间复杂度也没区别
实际上,我们可以针对第二种解法,优化空间复杂度

思路拆解

状态转移方程:dp[i + 1][c] = dp[i][c] + dp[i][c - nums[i]]
可见,只需要依赖两个一维的dp数组,而不需要申请n个

所以,可以针对以上递推的代码,实现滚动数组的修改,降低空间复杂度

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int n = nums.size();
        // 计算 sum(A)
        int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
        // sum(A) 必须是偶数,因为除以2,的出来的结果必须是整数,才能符合题目要求
        if (s < 0 || s & 1 != 0) {
            return 0;
        }

        int m = s / 2; // 背包容量
        vector dp(2, vector<int>(m + 1));
        dp[0][0] = 1; // sum(A)的子集,表示总和0,也就是空集,算一种解决方案,初始化1

        // dp[i][c]表示前i个元素,容量为c的方案数量
        for (int i = 0; i < n; i++) {
            for (int c = 0; c <= m; c++) {
                // 只能不选
                if (c < nums[i]) {
                    dp[(i + 1) % 2][c] = dp[i % 2][c];
                } else {
                    dp[(i + 1) % 2][c] = dp[i % 2][c] + dp[i % 2][c - nums[i]]; // i 表示dp的第i + 1元素,因为手动加入了dp[0],总长度是n + 1
                }
            }
        }

        return dp[n % 2][m];
    }
};

实现了,只需要两个滚动数组,依次替换dp保存中间计算结果

时间复杂度:O(nm)
空间复杂度: O(m)

但该题可以继续优化空间,只用一个一维数组搞定,只不过需要注意,必须倒序遍历

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int n = nums.size();
        // 计算 sum(A)
        int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
        // sum(A) 必须是偶数,因为除以2,的出来的结果必须是整数,才能符合题目要求
        if (s < 0 || s & 1 != 0) {
            return 0;
        }

        int m = s / 2; // 背包容量
        vector<int> dp(m + 1);
        dp[0] = 1; // sum(A)的子集,表示总和0,也就是空集,算一种解决方案,初始化1

        // dp[c]表示前i个元素,容量为c的方案数量(i被隐藏)
        for (int x : nums) {
            // 需要倒序遍历,否则计算dp[c]时,dp[c - x]可能已经被覆盖
            for (int c = m; c >= x; c--) {
                dp[c] += dp[c - x];
            }
        }

        return dp[m];
    }
};

背包问题

背包问题作为动态规划的典型,不可不提

0 - 1背包

题目:有n块物品,每块有不同的价值v和重量w,现有一个容量上限为m的背包,如何使得装入的物品价值最大?

状态转移方程:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])

怎么理解状态转移方程呢?

  1. dp[i][j]表示将前i块物品装入容量为j的背包中的最大价值;
  2. 如上述所言,动态规划实际上就是减少子问题的计算量,将中间量保存下来,因此需要计算从0开始到n每个物品的动作,即装入或者不装入两种选择(0 <= i <= n);
  3. 另外,还需要记录从1开始,到背包容量m的每个容量的最大价值,满足前置条件最优化,需要另一层循环(1 <= j <= m);
  4. 综上所述,0 - 1背包的时间复杂度就是O(n * m)
  5. 0 - 1背包依然可以采取滚动数组的处理方式,对空间进行降维处理,即空间复杂度为O(m)

代码如下:

int maxValue(vector<int>& weights, vector<int>& values, int m)
{
    if (weights.empty() || values.empty()) {
        return 0;
    }
    
    vector<int> dp(m + 1, 0);
    int n = weights.size();
    for (int i = 0; i < n; ++i) {
        for (int j = m; j >= weights[i]; --j) { // 注意j的遍历顺序
            dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    
    return dp[m];
}


为什么j的遍历顺序要从后往前推呢?

  1. 首先这是空间优化的结果,因为每个物品的装入状态计算,依赖于上一个物品的计算结果,从二维数组形式上看,每个dp[i][j],都依赖于上一层(i - 1)的计算结果;
  2. 比如dp[i - 1][j - w[i]],而优化后的数组仅有一维,若从左往右计算,会将dp[i - 1][j - w[i]]的值覆盖,造成计算结果错误;
  3. 通俗的理解,可参照上述题目最短路径和,从表的形式上看,每个数组元素的计算都依赖于上方元素和上一层“靠左”的元素,若从左往右遍历,会先刷新左边的元素(优化成一维),造成后续计算错误;

多重背包

未完待续。。。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值