小浩:宜信科技中心攻城狮一枚,热爱算法,热爱学习,不拘泥于枯燥编程代码,更喜欢用轻松方式把问题简单阐述,但愿喜欢的小伙伴能够多多关注!
讲解动态规划的资料不少,官方的定义是指把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解。概念中的各阶段之间的关系,其实指的就是状态转移方程。不少人以为DP难(下文统称动态规划为DP),根本缘由是由于DP区别于一些固定形式的算法(好比DFS、二分法、KMP),没有实际的步骤规定第一步第二步来作什么,因此准确的说,DP实际上是一种解决问题的思想。算法
这种思想的本质是:一个规模比较大的问题(能够用两三个参数表示的问题),能够经过若干规模较小的问题的结果来获得的(一般会寻求到一些特殊的计算逻辑,如求最值等)编程
因此咱们通常看到的状态转移方程,基本都是这样:数组
opt :指代特殊的计算逻辑,一般为max or min。i,j,k 都是在定义DP方程中用到的参数。函数
dp[i] = opt(dp[i-1])+1学习
dpi = w(i,j,k) + opt(dpi-1)优化
dpi = opt(dpi-1 + xi, dpi + yj, ...)spa
每个状态转移方程,多少都有一些细微的差异。这个其实很容易理解,世间的关系多了去了,不可能抽象出彻底能够套用的公式。因此我我的其实不建议去死记硬背各类类型的状态转移方程。可是DP的题型真的就彻底没法掌握,没法归类进行分析吗?我认为不是的。在本系列中,我将由简入深为你们讲解动态规划这个主题。3d
咱们先看上一道最简单的DP题目,熟悉DP的概念:code
题目:假设你正在爬楼梯。须要 n 阶你才能到达楼顶。每次你能够爬 1 或 2 个台阶。你有多少种不一样的方法能够爬到楼顶呢?注意:给定 n 是一个正整数。blog
示例 1:
输入:2输出:2解释:有两种方法能够爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
示例 2:
输入:3输出:3解释:有三种方法能够爬到楼顶。
- 1 阶 + 1 阶 + 1 阶
- 1 阶 + 2 阶
- 2 阶 + 1 阶
经过分析咱们能够明确,该题能够被分解为一些包含最优子结构的子问题,即它的最优解能够从其子问题的最优解来有效地构建。知足“将大问题分解为若干个规模较小的问题”的条件。因此咱们令 dp[n] 表示能到达第 n 阶的方法总数,能够获得以下状态转移方程:
dp[n]=dp[n-1]+dp[n-2]
根据分析,获得代码以下:
func climbStairs(n int) int { if n == 1 { return 1 } dp := make([]int, n+1) dp[1] = 1 dp[2] = 2 for i := 3; i <= n; i++ { dp[i] = dp[i-1] + dp[i-2] } return dp[n] }
题目:给定一个整数数组 nums ,找到一个具备最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例 :输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
拿到题目请不要看下方题解,先自行思考2-3分钟....
首先咱们分析题目,一个连续子数组必定要以一个数做为结尾,那么咱们能够将状态定义成以下:
dp[i]:表示以 nums[i] 结尾的连续子数组的最大和。
那么为何这么定义呢?由于这样定义实际上是最容易想到的!在上一节中咱们提到,状态转移方程实际上是经过1-3个参数的方程来描述小规模问题和大规模问题间的关系。
固然,若是你没有想到,其实也很是正常!由于 "该问题最先于 1977 年提出,可是直到 1984 年才被发现了线性时间的最优解法。"
根据状态的定义,咱们继续进行分析:
若是要获得dp[i],那么nums[i]必定会被选取。而且 dp[i] 所表示的连续子序列与 dp[i-1] 所表示的连续子序列极可能就差一个 nums[i] 。即
dp[i] = dp[i-1]+nums[i] , if (dp[i-1] >= 0)
可是这里咱们遇到一个问题,颇有可能dp[i-1]自己是一个负数。那这种状况的话,若是dp[i]经过dp[i-1]+nums[i]来推导,那么结果其实反而变小了,由于咱们dp[i]要求的是最大和。因此在这种状况下,若是dp[i-1]<0,那么dp[i]其实就是nums[i]的值。即
dp[i] = nums[i] , if (dp[i-1] < 0)
综上分析,咱们能够获得:
dp[i]=max(nums[i], dp[i−1]+nums[i])
获得了状态转移方程,可是咱们还须要经过一个已有的状态的进行推导,咱们能够想到 dp[0] 必定是以 nums[0] 进行结尾,因此
dp[0] = nums[0]
在不少题目中,由于dp[i]自己就定义成了题目中的问题,因此dp[i]最终就是要的答案。可是这里状态中的定义,并非题目中要的问题,不能直接返回最后的一个状态 (这一步常常有初学者会摔跟头)。因此最终的答案,其实咱们是寻找:
max(dp[0], dp[1], ..., d[i-1], dp[i])
分析完毕,咱们绘制成图:
假定 nums 为 [-2,1,-3,4,-1,2,1,-5,4]
根据分析,获得代码以下:
func maxSubArray(nums []int) int { if len(nums) < 1 { return 0 } dp := make([]int, len(nums)) //设置初始化值 dp[0] = nums[0] for i := 1; i < len(nums); i++ { //处理 dp[i-1] < 0 的状况 if dp[i-1] < 0 { dp[i] = nums[i] } else { dp[i] = dp[i-1] + nums[i] } } result := -1 << 31 for _, k := range dp { result = max(result, k) } return result } func max(a, b int) int { if a > b { return a } return b }
咱们能够进一步精简代码为:
func maxSubArray(nums []int) int { if len(nums) < 1 { return 0 } dp := make([]int, len(nums)) result := nums[0] dp[0] = nums[0] for i := 1; i < len(nums); i++ { dp[i] = max(dp[i-1]+nums[i], nums[i]) result = max(dp[i], result) } return result } func max(a, b int) int { if a > b { return a } return b }
复杂度分析:时间复杂度:O(N)。空间复杂度:O(N)。
题目:给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
说明:可能会有多种最长上升子序列的组合,你只须要输出对应的长度便可。
本题有必定难度!
若是没有思路请回顾上一篇的学习内容!
不建议直接看题解!
首先咱们分析题目,要找的是最长上升子序列(Longest Increasing Subsequence,LIS)。由于题目中没有要求连续,因此LIS多是连续的,也多是非连续的。同时,LIS符合能够从其子问题的最优解来进行构建的条件。因此咱们能够尝试用动态规划来进行求解。首先咱们定义状态:
dp[i] :表示以nums[i]结尾的最长上升子序列的长度
咱们假定nums为[1,9,5,9,3]
咱们分两种状况进行讨论:
咱们先初步得出上面的结论,可是咱们发现了一些问题。由于dp[i]前面比他小的元素,不必定只有一个!
可能除了nums[j],还包括nums[k],nums[p] 等等等等。因此dp[i]除了可能等于dp[j]+1,还有可能等于dp[k]+1,dp[p]+1 等等等等。因此咱们求dp[i],须要找到dp[j]+1,dp[k]+1,dp[p]+1 等等等等中的最大值。(我在3个等等等等上都进行了加粗,主要是由于初学者很是容易在这里摔跟斗!这里强调的目的是但愿能记住这道题型!)
即:
dp[i] = max(dp[j]+1,dp[k]+1,dp[p]+1,.....)只要知足:
nums[i] > nums[j]
nums[i] > nums[k]
nums[i] > nums[p]
....
最后,咱们只须要找到dp数组中的最大值,就是咱们要找的答案。
分析完毕,咱们绘制成图:
根据分析,获得代码以下:
func lengthOfLIS(nums []int) int { if len(nums) < 1 { return 0 } dp := make([]int, len(nums)) result := 1 for i := 0; i < len(nums); i++ { dp[i] = 1 for j := 0; j < i; j++ { //这行代码就是上文中那个 等等等等 if nums[j] < nums[i] { dp[i] = max(dp[j]+1, dp[i]) } } result = max(result, dp[i]) } return result } func max(a, b int) int { if a > b { return a } return b }
前面章节咱们经过题目“最长上升子序列”以及"最大子序和",学习了DP(动态规划)在线性关系中的分析方法。这种分析方法,也在运筹学中被称为“线性动态规划”,具体指的是 “目标函数为特定变量的线性函数,约束是这些变量的线性不等式或等式,目的是求目标函数的最大值或最小值”。这点你们做为了解便可,不须要死记,更不要生搬硬套!
在本节中,咱们将继续分析一道略微区别于以前的题型,但愿能够由此题与以前的题目进行对比论证,进而顺利求解!
题目:给定一个三角形,找出自顶向下的最小路径和。
示例:每一步只能移动到下一行中相邻的结点上。
例如,给定三角形:
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
首先咱们分析题目,要找的是三角形最小路径和,这是个啥意思呢?假设咱们有一个三角形:[[2], [3,4], [6,5,7], [4,1,8,3]]
那从上到下的最小路径和就是2-3-5-1,等于11。
因为咱们是使用数组来定义一个三角形,因此便于咱们分析,咱们将三角形稍微进行改动:
这样至关于咱们将整个三角形进行了拉伸。这时候,咱们根据题目中给出的条件:每一步只能移动到下一行中相邻的结点上。其实也就等同于,每一步咱们只能往下移动一格或者右下移动一格。将其转化成代码,假如2所在的元素位置为[0,0],那咱们往下移动就只能移动到[1,0]或者[1,1]的位置上。假如5所在的位置为[2,1],一样也只能移动到[3,1]和[3,2]的位置上。以下图所示:
题目明确了以后,如今咱们开始进行分析。题目很明显是一个找最优解的问题,而且能够从子问题的最优解进行构建。因此咱们经过动态规划进行求解。首先,咱们定义状态:
dpi : 表示包含第i行j列元素的最小路径和
咱们很容易想到能够自顶向下进行分析。而且,不管最后的路径是哪一条,它必定要通过最顶上的元素,即[0,0]。因此咱们须要对dp0进行初始化。
dp0 = 0位置所在的元素值
继续分析,若是咱们要求dpi,那么其必定会从本身头顶上的两个元素移动而来。
如5这个位置的最小路径和,要么是从2-3-5而来,要么是从2-4-5而来。而后取两条路径和中较小的一个便可。进而咱们获得状态转移方程:
dpi = min(dpi-1,dpi-1) + trianglei
可是,咱们这里会遇到一个问题!除了最顶上的元素以外,
最左边的元素只能从本身头顶而来。(2-3-6-4)
最右边的元素只能从本身左上角而来。(2-4-7-3)
而后,咱们观察发现,位于第2行的元素,都是特殊元素(由于都只能从[0,0]的元素走过来)
咱们能够直接将其特殊处理,获得:
dp1 = triangle1 + triangle0dp1 = triangle1 + triangle0
最后,咱们只要找到最后一行元素中,路径和最小的一个,就是咱们的答案。即:
l:dp数组长度result = min(dp[l-1,0],dp[l-1,1],dp[l-1,2]....)
综上咱们就分析完了,咱们总共进行了4步:
分析完毕,代码自成:
func minimumTotal(triangle [][]int) int { if len(triangle) < 1 { return 0 } if len(triangle) == 1 { return triangle[0][0] } dp := make([][]int, len(triangle)) for i, arr := range triangle { dp[i] = make([]int, len(arr)) } result := 1<<31 - 1 dp[0][0] = triangle[0][0] dp[1][1] = triangle[1][1] + triangle[0][0] dp[1][0] = triangle[1][0] + triangle[0][0] for i := 2; i < len(triangle); i++ { for j := 0; j < len(triangle[i]); j++ { if j == 0 { dp[i][j] = dp[i-1][j] + triangle[i][j] } else if j == (len(triangle[i]) - 1) { dp[i][j] = dp[i-1][j-1] + triangle[i][j] } else { dp[i][j] = min(dp[i-1][j-1], dp[i-1][j]) + triangle[i][j] } } } for _,k := range dp[len(dp)-1] { result = min(result, k) } return result } func min(a, b int) int { if a > b { return b } return a }
运行上面的代码,咱们发现使用的内存过大。咱们有没有什么办法能够压缩内存呢?经过观察咱们发现,在咱们自顶向下的过程当中,其实咱们只须要使用到上一层中已经累积计算完毕的数据,而且不会再次访问以前的元素数据。绘制成图以下:
优化后的代码以下:
func minimumTotal(triangle [][]int) int { l := len(triangle) if l < 1 { return 0 } if l == 1 { return triangle[0][0] } result := 1<<31 - 1 triangle[0][0] = triangle[0][0] triangle[1][1] = triangle[1][1] + triangle[0][0] triangle[1][0] = triangle[1][0] + triangle[0][0] for i := 2; i < l; i++ { for j := 0; j < len(triangle[i]); j++ { if j == 0 { triangle[i][j] = triangle[i-1][j] + triangle[i][j] } else if j == (len(triangle[i]) - 1) { triangle[i][j] = triangle[i-1][j-1] + triangle[i][j] } else { triangle[i][j] = min(triangle[i-1][j-1], triangle[i-1][j]) + triangle[i][j] } } } for _,k := range triangle[l-1] { result = min(result, k) } return result } func min(a, b int) int { if a > b { return b } return a }
在上节中,咱们经过分析,顺利完成了“三角形最小路径和”的动态规划题解。在本节中,咱们继续看一道类似题型,以求能彻底掌握这种“路径和”的问题。话很少说,先看题目:
题目:给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。
示例:输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 由于路径 1→3→1→1→1 的总和最小。
首先咱们分析题目,要找的是 最小路径和,这是个啥意思呢?假设咱们有一个 m*n 的矩形 :[[1,3,1],[1,5,1],[4,2,1]]
那从左上角到右下角的最小路径和,咱们能够很容易看出就是1-3-1-1-1,这一条路径,结果等于7。
题目明确了,咱们继续进行分析。该题与上一道求三角形最小路径和同样,题目明显符合能够从子问题的最优解进行构建,因此咱们考虑使用动态规划进行求解。首先,咱们定义状态:
dpi : 表示包含第i行j列元素的最小路径和
一样,由于任何一条到达右下角的路径,都会通过[0,0]这个元素。因此咱们须要对dp0进行初始化。
dp0 = 0位置所在的元素值
继续分析,根据题目给的条件,若是咱们要求dpi,那么它必定是从本身的上方或者左边移动而来。以下图所示:
进而咱们获得状态转移方程:
dpi = min(dpi-1,dpi) + gridi
一样咱们须要考虑两种特殊状况:
最后,由于咱们的目标是从左上角走到右下角,整个网格的最小路径和其实就是包含右下角元素的最小路径和。即:
设:dp的长度为l最终结果就是:dpl-1)-1]
综上咱们就分析完了,咱们总共进行了4步:
分析完毕,代码自成:
func minPathSum(grid [][]int) int { l := len(grid) if l < 1 { return 0 } dp := make([][]int, l) for i, arr := range grid { dp[i] = make([]int, len(arr)) } dp[0][0] = grid[0][0] for i := 0; i < l; i++ { for j := 0; j < len(grid[i]); j++ { if i == 0 && j != 0 { dp[i][j] = dp[i][j-1] + grid[i][j] } else if j == 0 && i != 0 { dp[i][j] = dp[i-1][j] + grid[i][j] } else if i != 0 && j != 0 { dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j] } } } return dp[l-1][len(dp[l-1])-1] } func min(a, b int) int { if a > b { return b } return a }
一样,运行上面的代码,咱们发现使用的内存过大。有没有什么办法能够压缩内存呢?经过观察咱们发现,在咱们自左上角到右下角计算各个节点的最小路径和的过程当中,咱们只须要使用到以前已经累积计算完毕的数据,而且不会再次访问以前的元素数据。绘制成图以下:(你们看这个过程像不像扫雷,其实若是你们研究扫雷外挂的话,就会发如今扫雷的核心算法中,就有一处颇为相似这种分析方法,这里就不深究了)
优化后的代码以下:
func minPathSum(grid [][]int) int { l := len(grid) if l < 1 { return 0 } for i := 0; i < l; i++ { for j := 0; j < len(grid[i]); j++ { if i == 0 && j != 0 { grid[i][j] = grid[i][j-1] + grid[i][j] } else if j == 0 && i != 0 { grid[i][j] = grid[i-1][j] + grid[i][j] } else if i != 0 && j != 0 { grid[i][j] = min(grid[i-1][j], grid[i][j-1]) + grid[i][j] } } } return grid[l-1][len(grid[l-1])-1] } func min(a, b int) int { if a > b { return b } return a }
本系列全部教程中都不会用到复杂的语言特性,你们不须要担忧没有学过go。算法思想最重要,使用go纯属做者爱好。原文首发于公众号-浩仔讲算法