what?动态规划?

动态规划入门:从原理到实战,吃透基础算法

动态规划(Dynamic Programming,简称 DP)是算法领域的核心思想之一,也是面试、竞赛中的高频考点。它并非单一算法,而是一种 "化繁为简" 的解题思路 ------ 通过拆解复杂问题为可解决的子问题,存储子问题的解以避免重复计算,最终推导出原问题的最优解。本文将从核心原理、通用解题步骤、经典案例到实战技巧,全方位拆解动态规划的基础逻辑,所有示例均采用 C++ 实现,帮你真正吃透 DP 底层逻辑。

一、动态规划的核心:解决 "重复计算" 的痛点

在理解 DP 之前,我们先看一个经典问题:求斐波那契数列的第 n 项。斐波那契数列定义为:f(0)=0,f(1)=1,f(n)=f(n-1)+f(n-2)(n≥2)

暴力递归的问题

如果用纯递归实现:

cpp

运行

复制代码
int fib(int n) {
    if (n <= 1) return n;
    return fib(n-1) + fib(n-2);
}

看似简洁,但存在致命缺陷:重叠子问题 。计算 fib(5) 时,需要计算 fib(4)fib(3);计算 fib(4) 又需要 fib(3)fib(2)------fib(3) 被重复计算了两次。随着 n 增大,重复计算的子问题呈指数级增长,时间复杂度达到 O (2ⁿ),n=40 时就会出现明显卡顿。

动态规划的优化思路

DP 的核心就是解决 "重复计算":

  1. 最优子结构 :原问题的最优解包含子问题的最优解(比如 fib(n) 的解依赖 fib(n-1)fib(n-2) 的解);
  2. 记忆化存储:用数组 / 哈希表记录已计算的子问题解,后续直接调用,无需重复计算。

这两点让 DP 能将时间复杂度从暴力递归的 O (2ⁿ) 优化到 O (n),效率提升数个量级。

二、动态规划解题四步法(通用模板)

无论多难的 DP 问题,都能拆解为以下四步,这是新手入门的 "万能框架":

第一步:定义状态(DP 的 "记忆载体")

状态是 DP 的核心,需要明确:dp[i]dp[i][j] 代表什么具体含义?

  • 一维 DP:dp[i] 可表示 "前 i 个元素的最优解""到达第 i 个位置的方案数";
  • 二维 DP:dp[i][j] 可表示 "从 (0,0) 到 (i,j) 的最短路径和""用前 i 个物品装满容量 j 的背包的最大价值"。

关键原则:状态定义必须贴合 "子问题",让后续的递推关系能自然成立。

第二步:推导递推公式(DP 的 "灵魂")

递推公式描述 "大问题如何由小问题推导",是 DP 解题的核心逻辑。比如爬楼梯问题(一次能爬 1 或 2 级台阶,求到第 n 级的方案数):

  • 状态定义:dp[i] = 到达第 i 级台阶的方案数;
  • 递推逻辑:到达第 i 级只能从 i-1 级(爬 1 级)或 i-2 级(爬 2 级)过来,因此 dp[i] = dp[i-1] + dp[i-2]

第三步:初始化状态(边界条件)

递推公式需要 "起点",必须初始化最小子问题的解。比如爬楼梯:

  • dp[1] = 1(1 级台阶只有 1 种走法);
  • dp[2] = 2(2 级台阶有 "1+1" 或 "2" 两种走法)。

第四步:确定遍历顺序(避免依赖未计算的状态)

遍历顺序需保证:计算 dp[i] 时,其依赖的 dp[i-1]/dp[i-2] 已经被计算完成。比如爬楼梯需 "从前往后" 遍历(i 从 3 到 n),而 0-1 背包则需要 "从后往前" 遍历(避免物品重复选取)。

三、经典案例拆解:从基础到进阶

案例 1:斐波那契数列(一维 DP 入门)

解题步骤
  1. 状态定义:dp[i] 表示第 i 项斐波那契数;
  2. 递推公式:dp[i] = dp[i-1] + dp[i-2]
  3. 初始化:dp[0] = 0dp[1] = 1
  4. 遍历顺序:从 2 到 n 正序遍历。
C++ 实现(基础版 + 空间优化版)

cpp

运行

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

// 基础版:O(n) 时间,O(n) 空间
int fib(int n) {
    if (n <= 1) return n;
    // 定义状态数组
    vector<int> dp(n + 1);
    // 初始化
    dp[0] = 0;
    dp[1] = 1;
    // 递推计算
    for (int i = 2; i <= n; ++i) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n];
}

// 优化版:O(n) 时间,O(1) 空间(滚动变量)
int fib_optimized(int n) {
    if (n <= 1) return n;
    int a = 0, b = 1; // a=dp[i-2], b=dp[i-1]
    for (int i = 2; i <= n; ++i) {
        int temp = b;
        b = a + b;
        a = temp;
    }
    return b;
}

int main() {
    int n = 10;
    cout << "斐波那契数列第 " << n << " 项(基础版):" << fib(n) << endl;
    cout << "斐波那契数列第 " << n << " 项(优化版):" << fib_optimized(n) << endl;
    return 0;
}

案例 2:爬楼梯(一维 DP 经典应用)

问题描述:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。问有多少种不同的方法可以爬到楼顶?

解题步骤
  1. 状态定义:dp[i] 表示到达第 i 级台阶的方法数;
  2. 递推公式:dp[i] = dp[i-1] + dp[i-2](最后一步爬 1 级或 2 级);
  3. 初始化:dp[1] = 1dp[2] = 2
  4. 遍历顺序:从 3 到 n 正序遍历。
C++ 实现

cpp

运行

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

int climbStairs(int n) {
    if (n <= 2) return n;
    vector<int> dp(n + 1);
    dp[1] = 1;
    dp[2] = 2;
    for (int i = 3; i <= n; ++i) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n];
}

// 空间优化版
int climbStairs_optimized(int n) {
    if (n <= 2) return n;
    int a = 1, b = 2;
    for (int i = 3; i <= n; ++i) {
        int temp = b;
        b = a + b;
        a = temp;
    }
    return b;
}

int main() {
    int n = 5;
    cout << "爬 " << n << " 级台阶的方法数(基础版):" << climbStairs(n) << endl;
    cout << "爬 " << n << " 级台阶的方法数(优化版):" << climbStairs_optimized(n) << endl;
    return 0;
}

案例 3:最长递增子序列(LIS,一维 DP 进阶)

问题描述:给你一个整数数组 nums,找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。

解题步骤
  1. 状态定义:dp[i] 表示以 nums [i] 结尾的最长递增子序列的长度;
  2. 递推公式:遍历 j 从 0 到 i-1,若 nums[j] < nums[i],则 dp[i] = max(dp[i], dp[j] + 1)
  3. 初始化:dp[i] = 1(每个元素自身是长度为 1 的子序列);
  4. 遍历顺序:外层 i 从 1 到 n-1,内层 j 从 0 到 i-1。
C++ 实现

cpp

运行

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

int lengthOfLIS(vector<int>& nums) {
    int n = nums.size();
    if (n == 0) return 0;
    // 定义状态数组
    vector<int> dp(n, 1);
    int max_len = 1; // 记录最长长度
    // 递推计算
    for (int i = 1; i < n; ++i) {
        for (int j = 0; j < i; ++j) {
            if (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 << "最长递增子序列长度:" << lengthOfLIS(nums) << endl;
    return 0;
}

案例 4:0-1 背包问题(二维 DP 核心)

问题描述:有 N 件物品和一个容量为 V 的背包。每件物品只能用一次。第 i 件物品的体积是 v [i],价值是 w [i]。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。

解题步骤
  1. 状态定义:dp[i][j] 表示前 i 件物品,背包容量为 j 时的最大价值;
  2. 递推公式:
    • 不选第 i 件物品:dp[i][j] = dp[i-1][j]
    • 选第 i 件物品(需 j ≥ v [i]):dp[i][j] = max(dp[i][j], dp[i-1][j - v[i]] + w[i])
  3. 初始化:dp[0][j] = 0(0 件物品时价值为 0),dp[i][0] = 0(容量为 0 时价值为 0);
  4. 遍历顺序:外层 i 从 1 到 N,内层 j 从 1 到 V。
C++ 实现(基础版 + 空间优化版)

cpp

运行

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

// 基础版:二维数组,O(N*V) 时间,O(N*V) 空间
int knapsack_01(vector<int>& v, vector<int>& w, int V) {
    int N = v.size();
    // 定义状态数组:dp[i][j] 前i件物品,容量j的最大价值
    vector<vector<int>> dp(N + 1, vector<int>(V + 1, 0));
    // 递推计算
    for (int i = 1; i <= N; ++i) {
        for (int j = 1; j <= V; ++j) {
            // 不选第i件物品
            dp[i][j] = dp[i-1][j];
            // 选第i件物品(需容量足够)
            if (j >= v[i-1]) { // v[i-1] 是第i件物品的体积(数组从0开始)
                dp[i][j] = max(dp[i][j], dp[i-1][j - v[i-1]] + w[i-1]);
            }
        }
    }
    return dp[N][V];
}

// 优化版:一维数组,O(N*V) 时间,O(V) 空间(逆序遍历)
int knapsack_01_optimized(vector<int>& v, vector<int>& w, int V) {
    int N = v.size();
    vector<int> dp(V + 1, 0);
    for (int i = 0; i < N; ++i) {
        // 逆序遍历:避免重复选取同一物品
        for (int j = V; j >= v[i]; --j) {
            dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
        }
    }
    return dp[V];
}

int main() {
    // 物品体积
    vector<int> v = {2, 3, 4, 5};
    // 物品价值
    vector<int> w = {3, 4, 5, 6};
    // 背包容量
    int V = 8;
    cout << "0-1背包最大价值(二维版):" << knapsack_01(v, w, V) << endl;
    cout << "0-1背包最大价值(一维版):" << knapsack_01_optimized(v, w, V) << endl;
    return 0;
}

四、动态规划实战技巧与避坑指南

1. 空间优化技巧

  • 一维 DP:用 "滚动变量" 替代数组(如斐波那契、爬楼梯的 O (1) 空间优化);
  • 二维 DP:压缩为一维数组(如 0-1 背包的逆序遍历),核心是 "覆盖不需要的历史状态";
  • 特殊场景:状态仅依赖相邻行 / 列时,可用 "滚动数组"(如二维 DP 压缩为两行)。

2. 新手常见误区

  • 状态定义模糊 :比如 LIS 问题中,若错误定义 dp[i] 为 "前 i 个元素的最长递增子序列长度",会导致递推公式无法推导。正确做法是让 dp[i] 绑定 "以 nums [i] 结尾" 的条件;
  • 递推公式遗漏边界 :比如 0-1 背包未判断 j >= v[i],会导致数组越界;
  • 遍历顺序错误:0-1 背包一维优化时,若正序遍历 j,会导致同一物品被多次选取;
  • 过度追求优化:新手优先保证代码正确性,再优化空间 / 时间,比如先写二维背包,再尝试一维压缩。

3. 题型分类与学习路径

动态规划的题型可按 "状态维度" 分类,建议学习顺序:

  1. 线性 DP(斐波那契、爬楼梯、LIS)→
  2. 背包 DP(0-1 背包、完全背包)→
  3. 区间 DP(最长回文子串、石子合并)→
  4. 状态压缩 DP(二进制状态表示)。

五、总结

动态规划的核心不是 "背模板",而是理解 "如何拆解问题、如何定义状态、如何推导递推关系"。新手入门时,建议先从简单的一维 DP 入手,手动推导状态转移过程(比如画表格记录 dp[i] 的值),再逐步挑战二维 DP 和进阶题型。

记住:所有 DP 问题的本质都是 "用空间换时间",通过记忆子问题的解,避免重复计算。只要掌握了 "状态定义→递推公式→初始化→遍历顺序" 这四步,再配合大量练习,就能攻克绝大多数 DP 基础问题。

相关推荐
老毛肚2 天前
Java两种代理模式详解
java·开发语言·代理模式
Wnq100724 天前
鸿蒙 OS 与 CORBA+DDS+QOS+SOA 在工业控制领域的核心技术对比研究
物联网·性能优化·wpf·代理模式·信号处理·harmonyos·嵌入式实时数据库
ZouZou老师5 天前
C++设计模式之代理模式:以家具生产示例
c++·设计模式·代理模式
菜鸡的升级之路5 天前
ngrok
前端·代理模式
roman_日积跬步-终至千里5 天前
【计算机算法与设计(5)】贪心法、分治法、动态规划的原理和问题求解
算法·动态规划·代理模式
阿波罗尼亚7 天前
Head First设计模式(十一) 设计原则 代理模式
设计模式·代理模式
繁华似锦respect8 天前
C++ 智能指针底层实现深度解析
linux·开发语言·c++·设计模式·代理模式
开心香辣派小星11 天前
23种设计模式-11代理模式
设计模式·代理模式
babywew111 天前
[2021一区Top刊复现+改进] Highlights:能源枢纽;多目标优化;碳排放管理;折中规划
代理模式