rokevin
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
  • 动态规划法

  • 算法定义
  • 递归-产生 重叠子问题(overlap sub-problem)
  • 算法思路
    • 算法复杂度
  • 优点和缺点
  • 应用场景
  • Java 代码实现
  • 代码解析
  • 模板
    • 一、动态规划的三大步骤
    • 应用动态规划——将动态规划拆分成三个子目标
    • 「动态规划」中包含三个重要的概念:
  • 题型分类
    • 1. 线性DP
    • 2. 区间DP
    • 3. 背包DP
    • 4. 树形DP
    • 5. 状态压缩DP
    • 6. 数位DP
    • 7. 计数型DP
    • 8. 递推型DP
    • 9. 概率型DP
    • 10. 博弈型DP
    • 11. 记忆化搜索
  • 题型
  • 资料

动态规划法

算法定义

动态规划(Dynamic Programming Paradigm,简称 DP)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法,是一种通过将复杂问题分解为重叠子问题,并存储子问题的解(避免重复计算)来高效求解优化问题的算法设计技术。它适用于具有最优子结构和重叠子问题特性的问题,核心思想是 "以空间换时间"。

20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。

1957年出版了他的名著《Dynamic Programming》,这是该领域的第一本著作。

动态规划的初衷是,通过找到合适的角度,将所求解的目标值在某(几)个维度上展开,使得最终的目标能变成一个函数在某组自变量下的值:比如在经典题目数字三角形中,将“和”在“横纵坐标”上展开,那么最终的目标就是max{f(n - 1, i)},i从0到n-1。

这种展开需要满足的性质是:首先,展开后,函数值是可以由自变量唯一确定的;其次,函数有一种递推表达式;最后,可以通过某种求值顺序(待求的所有函数值的依赖关系形成一个有向无环图),从显然的初值开始依次求,直到目标值。至于是用循环解,还是记忆化搜索解,还是用BFS或者DFS解,都不本质。

这个依赖于上文所说的有向无环图的结构。

dp的本质是记忆化搜索。学习dp的话,先从普通的搜索问题入手,举例来说就是bfs,dfs。

在做bfs和dfs的时候,有的问题的同一情况会被反复使用,将这样的情况存到cache(比如使用hashmap)里,就是递归版本的dp。

递归版本的dp往往看起来和dfs,bfs差不多,很容易理解,比较难理解的是迭代版本的dp。

迭代版本的dp有两种大的方向可以得到,一是直接建模得到递归函数,二是先做递归版本的dp,再把它转化成迭代版本。

需要注意的是dp不论是迭代还是递归,它的时间复杂度和空间复杂度是一样的。

递归版本的更容易写出来,但是虽然时间复杂度相同,会出一个函数压栈弹栈的时间开销,理论上会慢不容易察觉的一点点时间,一般相对于时间复杂度而已都可以忽略不计。

总结来说,要学dp先学dfs,bfs,这两个学透了再加上缓存机制,dp至少递归版本就相对容易很多写出来。

DP是一种记忆化搜索,DP的一个要点就是状态的表示,状态就是在搜索过程中描述

递归-产生 重叠子问题(overlap sub-problem)

fibonacci O(2^n)

递归会产生重叠子问题

动态规划算法去掉了“无用和重复的运算”。

用循环会解决重叠子问题

算法思路

  1. 定义状态:将问题抽象为状态表示,明确每个状态的含义
  2. 确定状态转移方程:描述不同状态之间的递推关系
  3. 初始化边界条件:设置最小子问题的解
  4. 计算顺序:按照一定顺序(通常是自底向上)计算所有状态的解
  5. 提取结果:从最终状态中获取原问题的解

动态规划有两种主要实现方式:

  • 自底向上:从最小子问题开始计算,逐步构建更大问题的解
  • 自顶向下:使用递归并结合记忆化存储(Memoization)避免重复计算

算法复杂度

  • 时间复杂度:
    • 取决于状态数量和每个状态的计算开销,通常为 O (n²)、O (n³) 等多项式时间
    • 相比朴素递归的指数时间,效率有显著提升
  • 空间复杂度:
    • 标准实现为 O (n) 或 O (n²),主要用于存储状态表
    • 优化后可降至 O (1) 或 O (n)(如使用滚动数组)

优点和缺点

  • 优点:
    • 相比递归解法,避免了大量重复计算,效率更高
    • 可以解决许多用贪心算法无法解决的全局最优问题
    • 提供了清晰的状态转移思路,易于理解问题结构
  • 缺点:
    • 状态定义和转移方程设计难度较大,需要经验积累
    • 空间复杂度可能较高,需要针对特定问题进行优化
    • 不适用于不具有重叠子问题和最优子结构的问题

应用场景

  • 背包问题(0-1 背包、完全背包等)
  • 字符串问题(最长公共子序列、编辑距离)
  • 图论问题(最短路径、最长路径)
  • 组合优化问题(最大子数组和、矩阵链乘法)
  • 游戏问题(石子游戏、爬楼梯)
  • 金融问题(最大利润、投资组合)

Java 代码实现

下面以经典的 "斐波那契数列"、"0-1 背包" 和 "最长公共子序列" 为例,展示动态规划的几种实现方式:

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

public class DynamicProgrammingExamples {

    // 1. 斐波那契数列:自顶向下(记忆化递归)
    public static int fibonacciMemoization(int n) {
        if (n <= 0) return 0;
        // 使用HashMap存储已计算的结果
        Map<Integer, Integer> memo = new HashMap<>();
        return fibonacciRecursive(n, memo);
    }
    
    private static int fibonacciRecursive(int n, Map<Integer, Integer> memo) {
        // 基本情况
        if (n == 1 || n == 2) return 1;
        
        // 如果已计算,直接返回
        if (memo.containsKey(n)) {
            return memo.get(n);
        }
        
        // 递归计算并存储结果
        int result = fibonacciRecursive(n - 1, memo) + fibonacciRecursive(n - 2, memo);
        memo.put(n, result);
        return result;
    }
    
    // 2. 斐波那契数列:自底向上(迭代实现)
    public static int fibonacciIterative(int n) {
        if (n <= 0) return 0;
        if (n == 1 || n == 2) return 1;
        
        // 只保存前两个值,优化空间复杂度
        int prevPrev = 1; // f(n-2)
        int prev = 1;     // f(n-1)
        int current = 0;  // f(n)
        
        for (int i = 3; i <= n; i++) {
            current = prev + prevPrev;
            prevPrev = prev;
            prev = current;
        }
        
        return current;
    }
    
    // 3. 0-1背包问题:标准实现
    public static int knapsackStandard(int[] weights, int[] values, int capacity) {
        int n = weights.length;
        if (n == 0 || capacity <= 0) return 0;
        
        // dp[i][j] 表示前i个物品在容量j下的最大价值
        int[][] dp = new int[n + 1][capacity + 1];
        
        // 填充dp表
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= capacity; j++) {
                // 如果当前物品重量超过容量,不放入
                if (weights[i - 1] > j) {
                    dp[i][j] = dp[i - 1][j];
                } else {
                    // 选择放入或不放入的最大值
                    dp[i][j] = Math.max(
                        dp[i - 1][j],  // 不放入当前物品
                        values[i - 1] + dp[i - 1][j - weights[i - 1]]  // 放入当前物品
                    );
                }
            }
        }
        
        return dp[n][capacity];
    }
    
    // 4. 0-1背包问题:空间优化实现(滚动数组)
    public static int knapsackOptimized(int[] weights, int[] values, int capacity) {
        int n = weights.length;
        if (n == 0 || capacity <= 0) return 0;
        
        // 只使用一维数组,空间复杂度从O(n*capacity)降至O(capacity)
        int[] dp = new int[capacity + 1];
        
        // 填充dp数组
        for (int i = 0; i < n; i++) {
            // 逆序遍历,避免覆盖需要使用的上一轮数据
            for (int j = capacity; j >= weights[i]; j--) {
                dp[j] = Math.max(dp[j], values[i] + dp[j - weights[i]]);
            }
        }
        
        return dp[capacity];
    }
    
    // 5. 最长公共子序列(LCS):优化空间实现
    public static int longestCommonSubsequence(String text1, String text2) {
        int m = text1.length();
        int n = text2.length();
        
        // 确保使用较短的字符串作为列,进一步节省空间
        if (m < n) {
            return longestCommonSubsequence(text2, text1);
        }
        
        // 只使用两行数组,空间复杂度从O(m*n)降至O(min(m,n))
        int[] prev = new int[n + 1];
        int[] curr = new int[n + 1];
        
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                    curr[j] = prev[j - 1] + 1;
                } else {
                    curr[j] = Math.max(prev[j], curr[j - 1]);
                }
            }
            // 交换两行
            int[] temp = prev;
            prev = curr;
            curr = temp;
            Arrays.fill(curr, 0);
        }
        
        return prev[n];
    }
    
    // 6. 最长递增子序列(LIS):O(n log n)优化实现
    public static int lengthOfLIS(int[] nums) {
        if (nums == null || nums.length == 0) return 0;
        
        // tails[i]表示长度为i+1的LIS的最小尾元素
        int[] tails = new int[nums.length];
        int length = 0;
        
        for (int num : nums) {
            // 二分查找当前元素在tails中的位置
            int left = 0, right = length;
            while (left < right) {
                int mid = left + (right - left) / 2;
                if (tails[mid] < num) {
                    left = mid + 1;
                } else {
                    right = mid;
                }
            }
            
            // 替换或扩展tails数组
            tails[left] = num;
            if (left == length) {
                length++;
            }
        }
        
        return length;
    }
    
    // 测试
    public static void main(String[] args) {
        // 测试斐波那契数列
        int n = 10;
        System.out.println("斐波那契数列第" + n + "项(记忆化): " + fibonacciMemoization(n));
        System.out.println("斐波那契数列第" + n + "项(迭代): " + fibonacciIterative(n));
        
        // 测试0-1背包问题
        int[] weights = {2, 3, 4, 5};
        int[] values = {3, 4, 5, 6};
        int capacity = 5;
        System.out.println("0-1背包最大价值(标准): " + knapsackStandard(weights, values, capacity));
        System.out.println("0-1背包最大价值(优化): " + knapsackOptimized(weights, values, capacity));
        
        // 测试最长公共子序列
        String text1 = "abcde", text2 = "ace";
        System.out.println("最长公共子序列长度: " + longestCommonSubsequence(text1, text2));
        
        // 测试最长递增子序列
        int[] nums = {10, 9, 2, 5, 3, 7, 101, 18};
        System.out.println("最长递增子序列长度: " + lengthOfLIS(nums));
    }
}

代码解析

上面的代码实现了动态规划的几种典型写法,并展示了关键优化思路:

  1. 自顶向下与自底向上对比:
    • 斐波那契数列的两种实现展示了动态规划的两种基本范式
    • 自顶向下(记忆化)更直观,接近递归思维
    • 自底向上(迭代)通常效率更高,避免了递归开销
  2. 空间优化技术:
    • 滚动数组:0-1 背包问题中,将二维数组优化为一维数组,空间复杂度从 O (n*capacity) 降至 O (capacity)
    • 两行交替:最长公共子序列中,使用两行数组交替更新,空间复杂度从 O (m*n) 降至 O (min (m,n))
    • 变量复用:斐波那契数列中,仅保存前两个值,空间复杂度从 O (n) 降至 O (1)
  3. 算法优化:
    • 最长递增子序列通过引入二分查找,将时间复杂度从 O (n²) 优化至 O (n log n)
    • 最长公共子序列通过交换输入顺序,确保使用更短的维度作为列,进一步节省空间

动态规划的核心优化策略包括:

  • 状态压缩:减少存储的状态数量
  • 滚动数组:利用状态之间的依赖关系,复用存储空间
  • 维度交换:选择更优的维度顺序,减少空间占用
  • 算法融合:结合其他算法(如二分查找)降低时间复杂度

动态规划的关键在于正确定义状态和状态转移方程,这需要对问题有深入理解。一旦状态模型建立,实现和优化就变得有章可循。对于复杂问题,通常需要先从暴力递归入手,再逐步优化为动态规划解法。

模板

  1. 找程序出口

  2. 选的方程

  3. 不选的方程

public static int solutionFibonacci(int n){
		if(n==0){
			return 1;
		}else if(n == 1){
			return 1;
		}else{
			int result[] = new int[n+1];
			result[0] = 1;
			result[1] = 1;
			for(int i=2;i<=n;i++){
				result[i] = result[i-1] + result[i-2];
			}
			return result[n];
		}
}

 1 for(j=1; j<=m; j=j+1) // 第一个阶段
 2    xn[j] = 初始值;
 3 
 4  for(i=n-1; i>=1; i=i-1)// 其他n-1个阶段
 5    for(j=1; j>=f(i); j=j+1)//f(i)与i有关的表达式
 6      xi[j]=j=max(或min){g(xi-1[j1:j2]), ......, g(xi-1[jk:jk+1])};
 8 
 9 t = g(x1[j1:j2]); // 由子问题的最优解求解整个问题的最优解的方案
10 
11 print(x1[j1]);
12 
13 for(i=2; i<=n-1; i=i+1)
15 {  
17      t = t-xi-1[ji];
18 
19      for(j=1; j>=f(i); j=j+1)
21         if(t=xi[ji])
23              break;
25 }

一、动态规划的三大步骤

动态规划,无非就是利用历史记录,来避免我们的重复计算。而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者二维数组来保存。下面我们先来讲下做动态规划题很重要的三个步骤,

第一步骤:定义数组元素的含义,上面说了,我们会用一个数组,来保存历史数组,假设用一维数组 dp[] 吧。这个时候有一个非常非常重要的点,就是规定你这个数组元素的含义,例如你的 dp[i] 是代表什么意思?

第二步骤:找出数组元素之间的关系式,我觉得动态规划,还是有一点类似于我们高中学习时的归纳法的,当我们要计算 dp[n] 时,是可以利用 dp[n-1],dp[n-2].....dp[1],来推出 dp[n] 的,也就是可以利用历史数据来推出新的元素值,所以我们要找出数组元素之间的关系式,例如 dp[n] = dp[n-1] + dp[n-2],这个就是他们的关系式了。而这一步,也是最难的一步,后面我会讲几种类型的题来说。

第三步骤:找出初始值。学过数学归纳法的都知道,虽然我们知道了数组元素之间的关系式,例如 dp[n] = dp[n-1] + dp[n-2],我们可以通过 dp[n-1] 和 dp[n-2] 来计算 dp[n],但是,我们得知道初始值啊,例如一直推下去的话,会由 dp[3] = dp[2] + dp[1]。而 dp[2] 和 dp[1] 是不能再分解的了,所以我们必须要能够直接获得 dp[2] 和 dp[1] 的值,而这,就是所谓的初始值。

由了初始值,并且有了数组元素之间的关系式,那么我们就可以得到 dp[n] 的值了,而 dp[n] 的含义是由你来定义的,你想求什么,就定义它是什么,这样,这道题也就解出来了。

应用动态规划——将动态规划拆分成三个子目标

当要应用动态规划来解决问题时,归根结底就是想办法完成以下三个关键目标。

  1. 建立状态转移方程

    当做已经知道~的值,然后想办法利用它们求得。

  2. 缓存并复用以往结果

    如果没有合适地处理,很有可能就是指数和线性时间复杂度的区别

  3. 按顺序从小往大算

    这里的“小”和“大”对应的是问题的规模,在这里也就是我们要从 , , ... 到 依次顺序计算。

如何理解动态规划? https://www.zhihu.com/question/39948290

「动态规划」中包含三个重要的概念:

【最优子结构】 【边界】 【状态转移公式】

题型分类

  1. 线性DP
  2. 区间DP
  3. 背包DP

所谓背包(Knapsack)问题是指:给定一个由互不相同的数组成的集合A 和一个数N, 找A 的一个子集,使其元素之和为N。 https://zhuanlan.zhihu.com/p/30959069

  1. 树形DP
  2. 状态压缩DP
  3. 数位DP
  4. 计数型DP
  5. 递推型DP
  6. 概率型DP
  7. 博弈型DP
  8. 记忆化搜索

滚动数组

不管是滚动数组还是一维数组,缺点是导致无法通过回溯的方式将所有的最优解求出。

这里的最优解是指 最优解的值对应物品选取情况

1. 线性DP

最经典单串:

300. 最长上升子序列 (LIS)

最经典双串:

1143. 最长公共子序列 (LCS)

经典问题:

120. 三角形最小路径和

53. 最大子序和

152. 乘积最大子数组

887. 鸡蛋掉落 (DP+二分)

354. 俄罗斯套娃信封问题 (隐晦的LIS)

打家劫舍系列: (打家劫舍3 是树形DP)

198. 打家劫舍

213. 打家劫舍 II

股票系列:

121. 买卖股票的最佳时机

122. 买卖股票的最佳时机 II

123. 买卖股票的最佳时机 III

188. 买卖股票的最佳时机 IV

309. 最佳买卖股票时机含冷冻期

714. 买卖股票的最佳时机含手续费

字符串匹配系列

72. 编辑距离

44. 通配符匹配

10. 正则表达式匹配

2. 区间DP

516. 最长回文子序列

730. 统计不同回文子字符串

1039. 多边形三角剖分的最低得分

664. 奇怪的打印机

312. 戳气球

3. 背包DP

416. 分割等和子集 (01背包-要求恰好取到背包容量)

494. 目标和 (01背包-求方案数)

322. 零钱兑换 (完全背包)

518. 零钱兑换 II (完全背包-求方案数)

474. 一和零 (二维费用背包)

4. 树形DP

124. 二叉树中的最大路径和

1245. 树的直径 (邻接表上的树形DP)

543. 二叉树的直径

333. 最大 BST 子树

337. 打家劫舍 III

5. 状态压缩DP

464. 我能赢吗

526. 优美的排列

935. 骑士拨号器

1349. 参加考试的最大学生数

6. 数位DP

233. 数字 1 的个数

902. 最大为 N 的数字组合

1015. 可被 K 整除的最小整数

7. 计数型DP

计数型DP都可以以组合数学的方法写出组合数,然后dp求组合数

62. 不同路径

63. 不同路径 II

96. 不同的二叉搜索树 (卡特兰数)

1259. 不相交的握手 (卢卡斯定理求大组合数模质数)

8. 递推型DP

所有线性递推关系都可以用矩阵快速幂做,可以O(logN),最典型是斐波那契数列

70. 爬楼梯

509. 斐波那契数

935. 骑士拨号器

957. N 天后的牢房

1137. 第 N 个泰波那契数

9. 概率型DP

求概率,求数学期望

808. 分汤

837. 新21点

10. 博弈型DP

策梅洛定理,SG定理,minimax

翻转游戏

293. 翻转游戏

294. 翻转游戏 II

Nim游戏

292. Nim 游戏

石子游戏

877. 石子游戏

1140. 石子游戏 II

井字游戏

348. 判定井字棋胜负

794. 有效的井字游戏

1275. 找出井字棋的获胜者

11. 记忆化搜索

本质是 dfs + 记忆化,用在状态的转移方向不确定的情况

329. 矩阵中的最长递增路径

576. 出界的路径数

题型

斐波那契

汉诺塔

换零钱

N皇后

0/1背包

无限背包

跳跃问题

迷宫

龙与地下城

矩阵的最小路径和

数组中最长连续序列

最长公共子序列问题

最长公共子串问题

最小编辑代价

资料

动态规划看这一篇就够了
动态规划套路详解
告别动态规划,连刷 40 道题,我总结了这些套路,看不懂你打我
力扣 DP问题分类汇总
DP 类型总结2:题型总结
算法|LeetCode(力扣)经典题:动态规划
六大算法之三:动态规划
什么是动态规划(Dynamic Programming)?动态规划的意义是什么?
动态规划及面试,学完这一篇,你就入门了:Dynamic Programming, 动态规划,经典题目
算法-动态规划 Dynamic Programming--从菜鸟到老鸟
如何理解动态规划

最近更新:: 2025/10/27 23:01
Contributors: luokaiwen