动态规划(C++):从入门到实战的完整指南

动态规划是算法学习中最容易「听懂却写不出」的一类内容:概念并不复杂,但真正面对题目时,状态怎么定义、转移怎么写、循环往哪个方向走,往往让人卡住。本文希望用一条清晰的路径,把动态规划从思想到代码串起来。

**面向读者:**已经掌握 C++ 基础语法(数组、vector、函数、递归),了解时间复杂度和空间复杂度的基本概念,但还没有系统学习过动态规划的读者;也适合作为竞赛入门或面试复习的速查资料。

**覆盖范围:**文章先讲清动态规划的核心要素与两种实现方式,再依次拆解斐波那契数列、0-1 背包、最长上升子序列(LIS)、最长公共子序列(LCS)、区间 DP、树形 DP、状态压缩 DP 等经典模型,每个模型都配有可直接编译运行的 C++ 代码;最后补充常用优化技巧与常见错误排查方法。

**阅读建议:**建议按顺序阅读,前三节务必吃透,因为后面的所有模型都是「状态定义 + 转移方程 + 遍历顺序」的重复演练。阅读代码时不要只看,最好自己敲一遍并修改测试数据观察输出;遇到某个模型卡住时,可以先跳过,等积累了几个模型后再回头理解,往往会有新的体会。

目录

一、什么是动态规划

动态规划(Dynamic Programming,简称 DP)是一种通过把原问题拆解为相互重叠的子问题,并保存子问题结果来避免重复计算的算法思想。它通常适用于满足以下两个条件的问题:

  • 最优子结构:原问题的最优解可以由子问题的最优解推导出来。
  • 重叠子问题:不同分支在求解过程中会反复遇到相同的子问题。

与分治算法相比,动态规划的关键区别在于子问题之间存在重叠,因此需要「记忆化」;与贪心算法相比,动态规划会考虑所有可能的决策,而不是只做局部最优选择。

二、动态规划的核心要素

1. 状态定义

状态是动态规划的灵魂。状态定义要能完整描述当前局面,并且便于转移。例如在背包问题中,dp[i][j] 表示「前 i 个物品、容量为 j 时的最大价值」。

2. 状态转移方程

转移方程描述状态之间如何推导。写转移方程时,先想清楚「当前决策有哪几种选择」,再对每种选择取最优值。

3. 初始化与边界

边界条件是递推的起点,例如 dp[0] = 0。初始化错误是动态规划最常见的 bug 来源之一。

4. 遍历顺序

遍历顺序必须保证「计算当前状态时,它依赖的状态已经计算完毕」。一维 DP 通常从前向后,二维 DP 要结合依赖方向确定内外层循环。

三、动态规划的两种实现方式

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

用递归描述问题,并用数组或哈希表记录已经计算过的状态。写法直观,适合状态转移关系复杂、难以确定遍历顺序的场景。

2. 递推(自底向上)

按照依赖顺序从小到大填表,通常常数更小、没有递归栈开销,是竞赛和工程中的主流写法。

四、经典入门:斐波那契数列

朴素递归会重复计算大量子问题,时间复杂度为指数级。加入记忆化后降为 O(n)。

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

// 记忆化搜索版本
long long fib(int n, vector<long long>& memo) {
    if (n <= 1) return n;
    if (memo[n] != -1) return memo[n];
    return memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
}

// 递推版本,空间可优化到 O(1)
long long fibIter(int n) {
    if (n <= 1) return n;
    long long a = 0, b = 1;
    for (int i = 2; i <= n; ++i) {
        long long c = a + b;
        a = b;
        b = c;
    }
    return b;
}

int main() {
    int n = 40;
    vector<long long> memo(n + 1, -1);
    cout << fib(n, memo) << endl;
    cout << fibIter(n) << endl;
    return 0;
}

五、经典模型一:0-1 背包

有 n 个物品,第 i 个物品重量为 w[i]、价值为 v[i],背包容量为 W。每个物品最多选一次,求能获得的最大价值。

状态定义:dp[j] 表示容量为 j 时的最大价值。由于每个物品只能用一次,一维写法必须倒序遍历容量。

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int knapsack01(int W, const vector<int>& w, const vector<int>& v) {
    int n = w.size();
    vector<int> dp(W + 1, 0);
    for (int i = 0; i < n; ++i) {
        for (int j = W; j >= w[i]; --j) {
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    return dp[W];
}

int main() {
    vector<int> w = {2, 3, 4, 5};
    vector<int> v = {3, 4, 5, 8};
    cout << knapsack01(8, w, v) << endl; // 输出 12
    return 0;
}

易错点:0-1 背包一维写法必须倒序;完全背包(物品可无限次选)则要正序遍历容量。

六、经典模型二:最长上升子序列(LIS)

给定数组 a,求最长严格上升子序列的长度。基础做法是 O(n²) 的 DP,进阶做法用贪心加二分优化到 O(n log n)。

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// O(n^2) 动态规划
int lisDp(const vector<int>& a) {
    int n = a.size();
    vector<int> dp(n, 1);
    int ans = 0;
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < i; ++j) {
            if (a[j] < a[i]) dp[i] = max(dp[i], dp[j] + 1);
        }
        ans = max(ans, dp[i]);
    }
    return ans;
}

// O(n log n) 贪心 + 二分
int lisFast(const vector<int>& a) {
    vector<int> tails;
    for (int x : a) {
        auto it = lower_bound(tails.begin(), tails.end(), x);
        if (it == tails.end()) tails.push_back(x);
        else *it = x;
    }
    return tails.size();
}

int main() {
    vector<int> a = {10, 9, 2, 5, 3, 7, 101, 18};
    cout << lisDp(a) << endl;   // 4
    cout << lisFast(a) << endl; // 4
    return 0;
}

七、经典模型三:最长公共子序列(LCS)

给定两个字符串 s 和 t,求它们的最长公共子序列长度。状态 dp[i][j] 表示 s 前 i 个字符与 t 前 j 个字符的 LCS 长度。

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;

int lcs(const string& s, const string& t) {
    int n = s.size(), m = t.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
            else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
        }
    }
    return dp[n][m];
}

int main() {
    cout << lcs("abcde", "ace") << endl; // 3
    return 0;
}

八、经典模型四:区间 DP

区间 DP 以区间长度作为阶段,从小到大枚举区间,典型题目如「石子合并」「最长回文子序列」。核心是枚举分割点 k。

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 石子合并:求合并成一堆的最小代价
int stoneMerge(const vector<int>& a) {
    int n = a.size();
    vector<int> pre(n + 1, 0);
    for (int i = 0; i < n; ++i) pre[i + 1] = pre[i] + a[i];

    const int INF = 1e9;
    vector<vector<int>> dp(n, vector<int>(n, 0));
    for (int len = 2; len <= n; ++len) {
        for (int i = 0; i + len - 1 < n; ++i) {
            int j = i + len - 1;
            dp[i][j] = INF;
            for (int k = i; k < j; ++k) {
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + pre[j + 1] - pre[i]);
            }
        }
    }
    return dp[0][n - 1];
}

int main() {
    vector<int> a = {4, 1, 1, 4};
    cout << stoneMerge(a) << endl; // 18
    return 0;
}

九、经典模型五:树形 DP

树形 DP 在树上做递推,通常用 DFS 后序遍历,把子树结果汇总到父节点。典型题目是「没有上司的舞会」:每个节点有开心值,父子不能同时选,求最大开心值。

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

vector<vector<int>> g;
vector<int> happy;
vector<vector<int>> dp; // dp[u][0] 不选 u,dp[u][1] 选 u

void dfs(int u, int parent) {
    dp[u][0] = 0;
    dp[u][1] = happy[u];
    for (int v : g[u]) {
        if (v == parent) continue;
        dfs(v, u);
        dp[u][0] += max(dp[v][0], dp[v][1]);
        dp[u][1] += dp[v][0];
    }
}

int main() {
    int n = 5;
    g.assign(n, {});
    happy = {0, 10, 20, 30, 40};
    dp.assign(n, vector<int>(2, 0));

    // 构造一棵树:0 为根
    g[0] = {1, 2};
    g[1] = {0, 3, 4};
    g[2] = {0};
    g[3] = {1};
    g[4] = {1};

    dfs(0, -1);
    cout << max(dp[0][0], dp[0][1]) << endl;
    return 0;
}

十、状态压缩 DP 简介

当状态维度较多但每一维取值很少时,可以把状态压缩成一个整数(通常用二进制位表示集合),这就是状态压缩 DP。典型题目如「旅行商问题(TSP)」「棋盘覆盖」。

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// TSP:从 0 出发遍历所有点再回到 0 的最短路径
int tsp(const vector<vector<int>>& dist) {
    int n = dist.size();
    const int INF = 1e9;
    vector<vector<int>> dp(1 << n, vector<int>(n, INF));
    dp[1][0] = 0; // 只访问了 0 号点,当前在 0

    for (int mask = 1; mask < (1 << n); ++mask) {
        for (int u = 0; u < n; ++u) {
            if (dp[mask][u] == INF) continue;
            for (int v = 0; v < n; ++v) {
                if (mask & (1 << v)) continue;
                int next = mask | (1 << v);
                dp[next][v] = min(dp[next][v], dp[mask][u] + dist[u][v]);
            }
        }
    }

    int ans = INF;
    for (int u = 1; u < n; ++u) {
        ans = min(ans, dp[(1 << n) - 1][u] + dist[u][0]);
    }
    return ans;
}

int main() {
    vector<vector<int>> dist = {
        {0, 2, 9, 10},
        {1, 0, 6, 4},
        {15, 7, 0, 8},
        {6, 3, 12, 0}
    };
    cout << tsp(dist) << endl;
    return 0;
}

十一、动态规划的优化技巧

  • 滚动数组:当第 i 层只依赖第 i-1 层时,可以把二维数组压缩成一维,降低空间复杂度。
  • 前缀和 / 差分:把区间求和类转移从 O(n) 降到 O(1)。
  • 单调队列优化 :适用于转移形如 dp[i] = min(dp[j] + cost(j, i)) 且决策点单调的场景。
  • 斜率优化:把转移方程整理成直线形式,用凸包维护最优决策点。
  • 矩阵快速幂:线性递推且转移固定时,可把 O(n) 降到 O(log n)。

十二、常见错误与调试建议

  1. 状态定义不完整:状态缺少必要信息,导致转移时无法区分不同局面。
  2. 初始化遗漏:求最小值时忘记把 dp 初始化为 INF,求最大值时忘记初始化为极小值。
  3. 遍历顺序错误:0-1 背包正序遍历容量会变成完全背包,这是最经典的错误。
  4. 边界越界 :注意 i - 1、j - w[i] 等下标是否合法。
  5. 数据溢出 :答案较大时使用 long long,并注意 INF 相加溢出。

调试建议:先写暴力搜索,再写记忆化搜索,最后改写成递推,并用手写小数据对拍验证。

十三、总结

动态规划的学习路径可以概括为:先掌握状态定义与转移方程的基本套路,再通过背包、LIS、LCS、区间 DP、树形 DP、状态压缩等经典模型积累经验,最后学习滚动数组、单调队列、斜率优化等进阶技巧。建议每道题都先写出状态含义和转移方程,再动手编码,这样能显著减少调试时间。

相关推荐
Godspeed Zhao1 小时前
具身智能系统概论——边缘计算和云端协调
人工智能·边缘计算·1024程序员节
JavaPub-rodert1 小时前
不用 React、Vue,也不用后端:王仕宇是怎么把 JavaPub Tools 做成 Web + Electron 双端产品的
1024程序员节
桐盛科技2 小时前
基于边缘计算网关的 Modbus 与 DL/T 645 协议数据清洗与断网续传实现
边缘计算·1024程序员节
u1301302 小时前
GitHub 热榜项目:日榜(2026-10-24)
人工智能·github·1024程序员节
sbjdhjd2 小时前
从黑板事实到长链路剪枝:AI 渗透测试 Agent 的实验、失效与改造
人工智能·网络安全·数据挖掘·php·agent·剪枝·1024程序员节
Xxtaoaooo8 小时前
双11避坑:我用 WorkBuddy + TextIn xParse 做了个「避坑对比台」,商品详情图丢进去,参数矛盾自动现形
人工智能·1024程序员节·textin·workbuddy·ai数据层基础设施
AI视觉网奇13 小时前
3d 网格布尔几何 Manifold
1024程序员节·3d算法