当前位置: 首页 > news >正文

算法---动态规划(Dynamic Programming, DP)

一、动态规划的核心定义与本质

动态规划(DP)是一种通过分解问题为重叠子问题,并存储子问题解以避免重复计算,最终求解原问题最优解的算法思想。它并非具体算法,而是一种“优化思想”,核心解决两类问题:

  1. 重叠子问题:原问题的解依赖多个相同的子问题(如斐波那契数列中,f(5)依赖f(4)f(3),而f(4)又依赖f(3)f(3)被重复计算);
  2. 最优子结构:原问题的最优解可由子问题的最优解推导而来(如“从A到C的最短路径”,若经过B,则“A到B的最短路径”+“B到C的最短路径”必为A到C的最短路径)。

DP与分治法的区别:分治法(如归并排序)也分解问题为子问题,但子问题无重叠,无需存储子问题解;DP的子问题重叠,存储解可大幅降低时间复杂度(通常从指数级降至多项式级)。

二、动态规划的基本步骤

DP的核心是“定义状态”和“推导转移方程”,具体分为4步:

  1. 定义状态:用dp[i]dp[i][j]等表示“以i(或i,j)为边界的子问题的解”(状态定义是DP的灵魂,定义不当会导致转移方程无法推导);
  2. 推导状态转移方程:描述dp[i]dp[i-1]dp[i-2]等子状态的关系(如斐波那契的dp[i] = dp[i-1] + dp[i-2]);
  3. 确定初始条件:初始化最小子问题的解(如斐波那契的dp[0]=0dp[1]=1);
  4. 计算最终结果:通过递推(自底向上)或记忆化搜索(自顶向下)计算原问题的解。

三、DP的两种实现方式(C++示例)

DP有两种经典实现形式,分别对应“自顶向下”和“自底向上”的思路,以下以斐波那契数列为例说明。

1. 记忆化搜索(自顶向下)

  • 思路:从原问题(如f(10))递归分解为子问题(f(9)f(8)),用“备忘录”(数组/哈希表)存储已计算的子问题解,避免重复计算。
  • 优点:代码直观,符合递归思维;仅计算必要的子问题。
  • C++代码
#include <iostream>
#include <vector>
using namespace std;// 备忘录:存储已计算的f(n),初始化为-1表示未计算
vector<int> memo;// 递归函数:计算f(n)
int fib(int n) {// 1.  base case(最小子问题)if (n <= 1) return n;// 2.  若已计算,直接返回备忘录中的值(避免重复计算)if (memo[n] != -1) return memo[n];// 3.  递归计算子问题,并存储结果到备忘录memo[n] = fib(n - 1) + fib(n - 2);return memo[n];
}int main() {int n = 10;memo.resize(n + 1, -1); // 初始化备忘录,大小为n+1(覆盖0~n)cout << "fib(" << n << ") = " << fib(n) << endl; // 输出55return 0;
}

2. 递推(自底向上)

  • 思路:从最小子问题(如f(0)f(1))开始,按顺序计算到原问题(f(n)),用“DP表”(数组)存储子问题解。
  • 优点:无递归栈开销,效率更高;可进一步优化空间(如斐波那契可从O(n)优化到O(1))。
  • C++代码
#include <iostream>
#include <vector>
using namespace std;int fib(int n) {// 1.  处理边界情况if (n <= 1) return n;// 2.  定义DP表:dp[i]表示f(i)vector<int> dp(n + 1);// 3.  初始条件(最小子问题)dp[0] = 0;dp[1] = 1;// 4.  递推计算(自底向上)for (int i = 2; i <= n; ++i) {dp[i] = dp[i - 1] + dp[i - 2]; // 转移方程}return dp[n];
}// 空间优化:仅存储前两个状态(O(1)空间)
int fib_optimized(int n) {if (n <= 1) return n;int prev_prev = 0; // f(i-2)int prev = 1;      // f(i-1)int curr;          // f(i)for (int i = 2; i <= n; ++i) {curr = prev + prev_prev;prev_prev = prev;prev = curr;}return prev;
}int main() {int n = 10;cout << "fib(" << n << ") = " << fib(n) << endl;         // 55cout << "优化后:fib(" << n << ") = " << fib_optimized(n) << endl; // 55return 0;
}

四、常见DP类型及经典问题(C++示例)

DP的应用场景广泛,按问题结构可分为5大类,覆盖90%以上的DP题目。

1. 线性DP(Linear DP)

  • 特征:子问题按“线性顺序”排列(如数组下标、序列长度),状态通常为dp[i](一维)或dp[i][j](二维)。
  • 经典问题:最长上升子序列(LIS)、最长公共子序列(LCS)。
示例:最长上升子序列(LIS)

问题:给定数组nums,求严格递增的最长子序列长度(如nums = [10,9,2,5,3,7,101,18],LIS为[2,3,7,101],长度4)。

实现1:O(n²)递推

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;int lengthOfLIS(vector<int>& nums) {int n = nums.size();if (n == 0) return 0;// 状态定义:dp[i]表示以nums[i]结尾的LIS长度vector<int> dp(n, 1); // 初始化为1(每个元素自身是长度为1的子序列)int max_len = 1;// 递推:遍历每个元素,对比前面所有元素for (int i = 1; i < n; ++i) {for (int j = 0; j < i; ++j) {if (nums[j] < nums[i]) { // 若nums[j] < nums[i],可构成上升子序列dp[i] = max(dp[i], dp[j] + 1); // 转移方程:取最大值}}max_len = max(max_len, dp[i]); // 更新全局最长长度}return max_len;
}int main() {vector<int> nums = {10,9,2,5,3,7,101,18};cout << "LIS长度:" << lengthOfLIS(nums) << endl; // 输出4return 0;
}

实现2:O(nlogn)优化(状态压缩)

  • 思路:用tails数组存储“长度为k的LIS的最小尾元素”,遍历nums时,用二分查找更新tails
int lengthOfLIS_optimized(vector<int>& nums) {vector<int> tails; // tails[k]:长度为k+1的LIS的最小尾元素for (int x : nums) {// 二分查找第一个 >= x 的元素,替换为x(保证tails递增)auto it = lower_bound(tails.begin(), tails.end(), x);if (it == tails.end()) {tails.push_back(x); // x是新的最长LIS的尾元素} else {*it = x; // 替换为更小的尾元素,为后续元素留空间}}return tails.size(); // tails的长度即为LIS长度
}

2. 背包DP(Knapsack DP)

  • 特征:给定“物品”(有重量/价值)和“背包容量”,求满足约束的最优解(如最大价值、最少物品数)。
  • 经典问题:01背包(物品仅选1次)、完全背包(物品可选无限次)、多重背包(物品选有限次)。

核心思路:最小容量拿最大价值
01背包: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]);
完全背包:dp[i][j] = max(dp[i-1][j], dp[i][j-w[i-1]] + v[i-1]);

示例:01背包(最大价值)

问题:有n件物品,每件物品重w[i]、价值v[i],背包容量C,求装入背包的最大价值(每件物品最多选1次)。

a.不放这个物品,最大价值即为放这个物品前的最大价值
b.清空背包,只放这个物品,再看剩余容量能否装下之前的物品,最大价值为这个物品的价值+背包剩余容量的最大价值

实现1:二维DP表(O(n*C)空间)

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;int knapsack01(vector<int>& w, vector<int>& v, int C) {int n = w.size();// 状态定义:dp[i][j]表示前i件物品,容量j时的最大价值vector<vector<int>> dp(n + 1, vector<int>(C + 1, 0));for (int i = 1; i <= n; ++i) { // 遍历物品for (int j = 1; j <= C; ++j) { // 遍历容量if (j < w[i - 1]) { // 容量不足,不选第i件物品(i-1是数组下标)dp[i][j] = dp[i - 1][j];} else { // 选或不选,取最大值dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]);}}}return dp[n][C];
}int main() {vector<int> w = {2,3,4,5}; // 物品重量vector<int> v = {3,4,5,6}; // 物品价值int C = 8; // 背包容量cout << "最大价值:" << knapsack01(w, v, C) << endl; // 输出10(选2+3+3?不,选2+5重量8,价值3+6=9?不对,正确是3+5重量8,价值4+5=9?哦,原数据w=[2,3,4,5],v=[3,4,5,6],容量8:选w=3(v=4)+w=5(v=6)→ 重量8,价值10,对)return 0;
}

实现2:一维DP表(O©空间优化)

  • 关键:容量j逆序遍历(避免覆盖dp[j - w[i]],确保每件物品只选1次)。
int knapsack01_optimized(vector<int>& w, vector<int>& v, int C) {int n = w.size();// 状态定义:dp[j]表示容量j时的最大价值vector<int> dp(C + 1, 0);for (int i = 0; i < n; ++i) { // 遍历物品// 逆序遍历容量:避免重复选同一物品for (int j = C; j >= w[i]; --j) {dp[j] = max(dp[j], dp[j - w[i]] + v[i]);}}return dp[C];
}

3. 区间DP(Interval DP)

  • 特征:子问题按“区间长度”划分(如dp[i][j]表示区间[i,j]的解),需按区间长度从小到大递推。
  • 经典问题:石子合并(最小代价)、最长回文子串。
示例:石子合并(最小代价)

问题n堆石子排成一行,每次合并相邻两堆,代价为两堆石子数之和,求合并为1堆的最小总代价。

C++代码(O(n³))

#include <iostream>
#include <vector>
#include <climits>
using namespace std;int stoneMerge(vector<int>& stones) {int n = stones.size();if (n <= 1) return 0;// 1.  预处理前缀和:sum[i][j]表示区间[i,j]的石子总数vector<vector<int>> sum(n, vector<int>(n, 0));for (int i = 0; i < n; ++i) {sum[i][i] = stones[i];for (int j = i + 1; j < n; ++j) {sum[i][j] = sum[i][j - 1] + stones[j];}}// 2.  状态定义:dp[i][j]表示合并区间[i,j]的最小代价vector<vector<int>> dp(n, vector<int>(n, 0));// 3.  递推:按区间长度len从小到大遍历(len=2到n)for (int len = 2; len <= n; ++len) {for (int i = 0; i + len - 1 < n; ++i) { // 区间起点iint j = i + len - 1; // 区间终点jdp[i][j] = INT_MAX; // 初始化为无穷大// 4.  枚举分割点k(i ≤ k < j),合并[i,k]和[k+1,j]for (int k = i; k < j; ++k) {dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + sum[i][j]);}}}return dp[0][n - 1];
}int main() {vector<int> stones = {3,4,5,6}; // 示例输入cout << "最小合并代价:" << stoneMerge(stones) << endl; // 输出36(3+4=7→7+5=12→12+6=18?不对,正确计算:3+4=7(代价7),7+5=12(代价7+12=19),12+6=18(19+18=37)?哦,原数据正确最小代价是36:4+5=9(代价9),3+9=12(9+12=21),12+6=18(21+18=39)?可能我算错了,代码是正确的,以代码运行结果为准)return 0;
}

4. 树形DP(Tree DP)

  • 特征:问题基于树结构,子问题为“子树的解”,需通过DFS遍历树,从叶子节点向根节点递推。
  • 经典问题:树的最大独立集、树上最长路径(直径)。

5. 状态压缩DP(Bitmask DP)

  • 特征:状态用“二进制位”表示(如mask的第i位为1表示“第i个元素已使用”),适用于n≤20的小规模问题。
  • 经典问题:旅行商问题(TSP,求访问所有城市的最短路径)。

五、DP的优化技巧

  1. 空间优化

    • 滚动数组:如01背包从二维优化到一维,斐波那契从O(n)优化到O(1);
    • 状态复用:仅存储当前计算所需的前几个状态(如线性DP的dp[i]仅依赖dp[i-1])。
  2. 时间优化

    • 二分优化:如LIS的O(nlogn)实现,用二分查找替代线性遍历;
    • 单调队列优化:如“滑动窗口内的最大和”类DP,用队列维护单调递减序列,将O(n²)降至O(n);
    • 斜率优化:适用于线性转移方程(如dp[i] = a[i]*b[j] + c[i] + d[j]),通过维护凸包降低时间复杂度。

六、DP的常见误区

  1. 状态定义模糊:如LIS中若定义dp[i]为“前i个元素的LIS长度”,会导致无法推导转移方程(正确定义是“以nums[i]结尾的LIS长度”);
  2. 转移方程推导错误:如01背包未逆序遍历容量,导致物品被重复选择(变成完全背包);
  3. 初始条件遗漏:如区间DP未初始化dp[i][i] = 0(单个元素无需合并,代价为0);
  4. 忽略子问题重叠:直接暴力递归(如未用备忘录的斐波那契),导致时间复杂度飙升至O(2ⁿ)。

动态规划的核心是“用空间换时间”,通过存储子问题解避免重复计算。DP的关键在于:

  1. 熟练掌握“状态定义→转移方程→初始条件”三步法;
  2. 针对不同问题类型(线性、背包、区间等)总结模板;
  3. 通过优化技巧(空间、时间)提升算法效率;
  4. 多练经典题目(如LeetCode的DP标签题),培养“分解子问题”的思维。

掌握DP后,可解决大量工程中的优化问题(如资源分配、路径规划、序列匹配),是算法面试中的核心考点之一。

http://www.dtcms.com/a/487939.html

相关文章:

  • 如何建立网站会员系统吗电商网站怎么做seo
  • 西安SEO网站推广长治网站制作
  • 山东城建建设职业学院教务网站第一章 网站建设基本概述
  • 新网站多久被收录自己做的网站搜索不到
  • 算法入门:专题二---滑动窗口(长度最小的子数组)类型题目攻克!
  • 知名排版网站wordpress如何安裝
  • 电子商务网站设计毕业设计论文电影网站开发PPT模板
  • index.html网站怎么做wordpress关注公众号发送验证码
  • dede做的网站打不开云服务器一般多少钱
  • HTTP Error 5OO.0- ASPNET Core lIS hosting failure (in-process)
  • 机械做卖产品网站百度识图网页版
  • 公司注册网站系统东营区住房和城乡建设局网站
  • LongCat-Flash:如何使用 SGLang 部署美团 Agentic 模型
  • 怎么让网站绑定域名访问不了开发软件怎么申请版权
  • 光通信网站模板百度app下载安装官方免费版
  • vllm-openai Docker 部署手册
  • 什么是网站主办者宜兴专业做网站公司
  • 微信官网网站移动电子商务平台就是手机网站
  • 建设单位网站需求报告网站页面设计代码
  • 做网站不懂行情 怎么收费想用vs做网站 学什么
  • 网站域名绑定ip微信公众号怎么做预约功能
  • 如何申请GitHub账号?
  • 创意响应式网站建设别的网站做相关链接怎么做
  • 存储引擎:数据库的核心架构与B+树的深度解析
  • 网站建设策划方案t优化网站排名推荐公司
  • 网站建设怎么设计更加吸引人免费建设网站
  • 网站开发中都引用什么文献绍兴网络推广公司
  • Qtday1
  • 基于langgraph agent的SQL DB知识库系统
  • 宿松县住房和城乡建设局网站a5源码网站