头图

动态规划

引子 - 爬楼梯

在正式聊动态规划之前,我们先来看一个经典问题

1.爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

爬楼梯

2.问题分析

假设台阶总数为10,我们先不考虑从第 0~ 8 阶的过程,也不考虑0~9的过程,想要到达第10阶,最后一步必然有两个选择。

 1. 从第9个台阶爬1阶
 2. 从第8个台阶爬2阶

接下来引申出一个新问题,如果我们已知0~9阶台阶的走法有x种,0-8阶的走法有y种,那么到达第10阶有多少种走法呢?答案就是 x+y

那到达第9个台阶有多少种方法呢?我们可以按照上面的思路,继续分析

3.问题泛化

假设我们当前处于第 i 个台阶上,根据已知条件,每次只能走1或2步,所以有两种方法到达第 i 个台阶

 1. 从第i-1个台阶爬1阶
 2. 从第i-2个台阶爬2阶

假设我们知道到达第i-1个台阶有f(i-1)种方法,到达第i-2个台阶有 f(i-2)种方法

若到达第i-1个台阶有f(i-1)种方法,到达第i-2个台阶有 f(i-2)种方法,

则到达第i阶有f(i)种走法,且等于到达第i-1阶的方法数f(i-1)加上到达第i-2阶的方法数f(i-2)

​ 故可得 f(i) = f(i-1)+f(i-2)

上述的过程就是将一个大问题拆解成一个一个的小问题,然后逐层分析,得到最终结果

其实爬楼梯问题就是一个求解斐波那契数列的问题

3.斐波那契数列的一般解决方法

​ 我们解决此问题是通过递归方式,从第n层一层层递减求出最终结果

func fib(n int) int {
    if n < 0 {
        return 0
    } else if n <= 1 {
        return 1
    } 
    
    return fib(n-1) + fib(n-2)
}

这里以5阶为例

<img src="https://test-kefu-zs.oss-cn-shenzhen.aliyuncs.com/zjalpha/mobilecheckqualitytool/39fc0af2-2a42-dc6e-64bf-ab936fad7434.png" style="zoom:50%;" />

从上图可以看出递归解法存在以下缺陷:

  1. 标有颜色部分存在重复计算,n值越大,重复计算越多
  2. 递归层数随着n的值变大而加深,会触发系统最大函数嵌套层
  3. 算法的时间复杂度为O(2^n)

那是否有其他方法来解决这个问题呢?是否可以用最开始提到的动态规划来解决呢?接下来让我们一起来了解动态规划,看看是否能解决爬楼梯的问题,以及探讨它与递归的关系

正文 - 动态规划

1.概念
动态规划(Dynamic Programming 简称DP):是运筹学的一个分支,是解决多阶段决策过程最优化的一种数学方法。把多阶段问题变换为一系列相互联系的单阶段问题,然后加以解决。
2.DP可以解决的问题

适合动态规划的问题必须满足以下条件:

最优子结构:一个最优化策略的子策略总是最优的。

无后效性: 问题的历史状态不影响未来的发展;只能通过当前的状态影响未来;当前的状态是对以往历史的总结

3.用DP解决问题的思路
  1. 状态定义:描述第 i 时刻的状态信息

    1. 找出状态转移方程:找出状态的递推关系式
4.怎么用DP解决爬楼梯(斐波那契数列问题)

1.状态定义:fib[n]到达第n个台阶的总走法

2.状态转移方程(递推式):到达第 n 阶有两种方式:从 n-1 阶到达,或从 n-2 阶到达,故

fib[n] = fib[n-1] + fib[n-2]

代码如下

func fib(n int) int {
    if n < 0 {
        return 0
    }
        // 为了方便大家理解,这里采用了切片格式,这里其实可以使用两个变量代替dp[]
    var dp = make([]int, n+1)
    dp[0] = 1
    dp[1] = 1

    for i:=2; i<=n;i++ {
        dp[i] = dp[i-1]+dp[i-2]
    }

    return dp[n]
}

算法时间复杂度:O(n)

空间复杂度:O(n),可以优化为 O(1)

看到这里,大家是否发现递归与动态规划的关系

递归和动态规划都是将大问题拆分成多个小问题,动态规划=递归+记忆化(存储子问题的解)

区别:动态规划是自底向上求解,而递归是自上向下求解

接下来,我再举个栗子


栗子

最小路径和问题

描述:给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

分析

1.由于题目是求最小路径和,所以我们定义状态 dp[i][j]为在第ij列时的最小路径和

2.由于路径的方向只能向下或向右,所以 dp[i][j] 可能的结果就是从当前位置的 上方dp[i-1][j] 或 左方dp[i][j-1]过来,所以当前位置的最小路径为 上方或左方的路径中的最小值加上当前位置的值,故可得状态转移方程

dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]

根据上述分析,得到如下代码

func minPathSum(grid [][]int) int {
    m := len(grid)
    n := len(grid[0])
    var minPath = make([][]int, m+1)

    for i:=0; i<m; i++ {
        minPath[i] = make([]int, n+1)
    }

    var min int
    for i:=0; i<m; i++ {
        for j:=0; j<n; j++ {
              // 若i=0且j=0,则表示当前位置为起始位置,不存在左方、上方节点
            if i == 0 && j == 0 {
                min = 0
            } else {
                if i == 0 { // i=0表示第0行,不存在上方节点,则最小值从当前位置的左方得到
                   min = minPath[i][j-1] 
                } else if j == 0 { // j=0表示表示第0列,不存在左方节点,则最小值从当前位置的上方得到
                    min = minPath[i-1][j]
                } else if minPath[i-1][j] > minPath[i][j-1] { // 当左方、上方节点值存在时,取最小值
                    min = minPath[i][j-1]
                } else {
                    min = minPath[i-1][j]
                }
            }

            minPath[i][j] = min + grid[i][j]
        }
    }

    return minPath[m-1][n-1]
}
结语

​ 动态规划在生活中的应用极其广泛,包括工程技术、经济、工业生产、军事以及自动化控制等领域,并在背包问题、生产经营问题、资金管理问题、资源分配问题、最短路径问题和复杂系统可靠性问题等中取得了显著的效果。

​ 本篇文章,旨在帮助大家对动态规划有个基本的了解,拓宽大家处理问题的思路,当然对动态规划感兴趣的小伙伴还可以继续找找相关题目,提升对它的理解。


Summer
1 声望0 粉丝

Coding and Recording