动态规划/回溯/贪心

回溯法

  • 回溯的本质是一种**“试探性搜索”策略**
  • 通过“逐步构建解空间,及时剪枝无效路径”来寻找问题的解,或者是枚举所有解
  • 最简单的理解,就是对解空间进行深度优先搜索(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. 通俗的理解,可参照上述题目最短路径和,从表的形式上看,每个数组元素的计算都依赖于上方元素和上一层“靠左”的元素,若从左往右遍历,会先刷新左边的元素(优化成一维),造成后续计算错误;

多重背包

未完待续。。。

<think>好的,现在用户让我详细介绍C语言中的动态规划贪心算法回溯算法。我需要仔细分析他的需求,并给出清晰且有用的回答。 首先,用户之前询问过C语言的十大排序算法,现在转向其他算法,可能是在学习算法基础,需要进一步扩展知识。用户可能是学生或刚入门的程序员,想要系统学习这些常见算法,并希望在C语言中实现。 接下来,我需要考虑如何组织这三个算法的内容。每个算法都需要分开讲解,包括原理、适用场景、实现步骤和示例代码。用户可能希望对比这三种算法,了解它们的区别和应用场景,因此在总结部分加入对比表格会比较有帮助。 动态规划部分,我需要强调其“分治+记忆化”的特点,以及状态转移方程的重要性。示例用斐波那契数列或背包问题比较合适。贪心算法则要说明局部最优不一定全局最优,举活动选择或找零钱的例子。回溯算法需要解释递归和剪枝,八皇后或排列组合是经典例子。 需要注意用户的代码示例要用C语言,所以函数定义和语法要正确。同时,避免使用过于复杂的例子,保持简洁易懂。比如动态规划的背包问题可能稍复杂,但用斐波那契数列更容易理解,不过用户之前可能接触过排序,或许能理解稍微复杂的例子。 另外,用户可能想知道这些算法之间的区别,比如何时用动态规划而不是贪心回溯的优缺点等。在对比总结中,要明确各自的优缺点和应用场景,帮助用户在实际问题中选择合适的算法。 需要检查每个部分的示例代码是否正确,比如动态规划中的背包问题是否有错误,回溯的八皇后是否正确实现了递归和条件判断。同时,确保术语准确,如状态转移方程、剪枝等。 最后,语言要口语化,避免过于学术化,让用户容易理解。结构清晰,分点讲解,每个算法单独一个部分,加上对比和总结,这样用户查阅起来方便。</think>以下是对C语言中动态规划贪心算法回溯算法的详细解析: --- ### 一、动态规划(Dynamic Programming) #### 原理 - **核心思想**:将复杂问题分解为重叠子问题,通过记忆化存储中间结果避免重复计算 - **适用条件**: 1. 最优子结构(全局最优包含局部最优) 2. 重叠子问题(子问题被多次重复计算) 3. 无后效性(当前状态只与之前状态有关) #### 实现步骤 1. 定义状态表示(通常用数组`dp[]`) 2. 建立状态转移方程 3. 初始化边界条件 4. 确定计算顺序(自底向上/自顶向下) #### 经典问题示例 **背包问题(0-1 Knapsack)** ```c int knapsack(int W, int wt[], int val[], int n) { int dp[W+1]; memset(dp, 0, sizeof(dp)); for(int i=1; i<=n; i++) for(int w=W; w>=wt[i-1]; w--) dp[w] = fmax(dp[w], dp[w - wt[i-1]] + val[i-1]); return dp[W]; } ``` --- ### 二、贪心算法(Greedy Algorithm) #### 原理 - **核心思想**:每一步选择当前看似最优的局部解,期望得到全局最优 - **适用条件**: 1. 贪心选择性质(局部最优导致全局最优) 2. 最优子结构 #### 实现特征 - 需要证明贪心策略的正确性 - 通常时间复杂度较低 #### 经典问题示例 **活动选择问题** ```c void activitySelection(int start[], int end[], int n) { qsort(end, n, sizeof(int), compare); // 按结束时间排序 printf("选择活动0 "); int last_selected = 0; for(int i=1; i<n; i++) if(start[i] >= end[last_selected]) { printf("活动%d ", i); last_selected = i; } } ``` --- ### 三、回溯算法(Backtracking) #### 原理 - **核心思想**:通过递归尝试所有可能解,遇到不可行解立即回溯 - **核心操作**: 1. 选择(Make choice) 2. 约束检查(Check constraints) 3. 回溯(Undo choice) #### 算法框架 ```c void backtrack(路径, 选择列表) { if(满足结束条件) { 记录解决方案; return; } for(选择 in 选择列表) { if(不符合约束条件) continue; 做选择; backtrack(新路径, 新选择列表); 撤销选择; } } ``` #### 经典问题示例 **八皇后问题** ```c #define N 8 int board[N][N]; int isSafe(int row, int col) { // 检查列 for(int i=0; i<row; i++) if(board[i][col]) return 0; // 检查左上方对角线 for(int i=row,j=col; i>=0&&j>=0; i--,j--) if(board[i][j]) return 0; // 检查右上方对角线 for(int i=row,j=col; i>=0&&j<N; i--,j++) if(board[i][j]) return 0; return 1; } int solveNQUtil(int row) { if(row >= N) return 1; for(int col=0; col<N; col++) { if(isSafe(row, col)) { board[row][col] = 1; if(solveNQUtil(row+1)) return 1; board[row][col] = 0; // 回溯 } } return 0; } ``` --- ### 四、对比总结 | 算法类型 | 核心特征 | 时间复杂度 | 典型应用场景 | |--------------|------------------------------|------------------|---------------------------| | **动态规划** | 状态转移+记忆化 | $O(n^2)-O(n^k)$ | 背包问题、最长公共子序列 | | **贪心算法** | 局部最优选择 | $O(n log n)$ | 霍夫曼编码、最小生成树 | | **回溯算法** | 递归+剪枝 | $O(n!)$ | 排列组合、N皇后、数独求解 | --- ### 五、选择策略 1. **动态规划**:当问题有重叠子结构和最优子结构时优先考虑 2. **贪心算法**:能证明局部最优即全局最优时使用(需严格证明) 3. **回溯算法**:需要穷举所有可能解时使用,配合剪枝优化效率 --- ### 六、综合应用示例 **找零钱问题对比**: - 贪心解法:每次选最大面额(仅适用于特定面值体系) - 动态规划:`dp[i] = min(dp[i], dp[i - coins[j]] + 1)` - 回溯解法:递归尝试所有组合,记录最小硬币数 需要根据具体场景选择合适算法,例如: - 美元硬币体系(1,5,10,25)适合贪心 - 任意硬币体系需用动态规划 - 需要记录所有组合时使用回溯
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值