Algorithm7---Dynamic

导言

动态规划得算法思想是通过空间换取时间。这篇文章来源于和程序员小吴一起学算法,清晰解释了什么是动态规划以及解决动态规划问题得思考步骤

什么是动态规划

用一句话解释动态规划就是记住你之前做过的事,如果更精确些,其实是记住你之前得到的答案

思考动态规划问题的四个步骤

一般解决动态规划问题,分为四个步骤,分别是

  • 问题拆解,找到问题之间的具体联系
  • 状态定义
  • 递推方程推导
  • 实现

eg:
“1+1+1+1+1+1+1+1” 得出答案是 8,那么如何快速计算 “1+ 1+1+1+1+1+1+1+1”,我们首先可以对这个大的问题进行拆解,这里我说的大问题是 9 个 1 相加,这个问题可以拆解成 1 + “8 个 1 相加的答案”,8 个 1 相加继续拆,可以拆解成 1 + “7 个 1 相加的答案”,… 1 + “0 个 1 相加的答案”,到这里,第一个步骤 已经完成。

状态定义 其实是需要思考在解决一个问题的时候我们做了什么事情,然后得出了什么样的答案。对于这个问题,当前问题的答案就是当前的状态,基于上面的问题拆解,你可以发现两个相邻的问题的联系其实就是 后一个问题的答案 = 前一个问题的答案 + 1,这里,状态的每次变化就是 +1。

定义好了状态,递推方程就变得非常简单,就是 dp[i] = dp[i - 1] + 1,这里的 dp[i] 记录的是当前问题的答案,也就是当前的状态dp[i - 1] 记录的是之前相邻的问题的答案,也就是之前的状态,它们之间通过 +1 来实现状态的变更

最后一步就是实现了,有了状态表示和递推方程,实现这一步上需要重点考虑的其实是初始化,就是用什么样的数据结构,根据问题的要求需要做那些初始值的设定。

func DpExample(n int64) int64 {
	dp := []int64{}
	dp[0] = 0
	for i := int64(1); i <= n; i++ {
		dp[i] = dp[i-1] + 1
	}
	return dp[n]
}

你可以看到,动态规划这四个步骤其实是相互递进的,状态的定义离不开问题的拆解,地推方程的推导离不开状态的定义,最后的实现代码的核心其实就是递推方程,这其中如果有一个步骤卡壳了则会导致问题无法解决,当问题的复杂程度增加的时候,这里面的思维复杂程度会上升。

题目实战

爬楼梯(LeetCode 第 70 号问题)

但凡涉及到动态规划的题目都离不开一道例题:爬楼梯(LeetCode 第 70 号问题)。

1.题目描述

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

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

注意:给定 n 是一个正整数。

示例一:
输入:2
输出:2
解释: 有两种方法可以爬到楼顶。

1. 1+ 12. 2 阶

示例二:
输入:3
输出:3
解释: 有三种方法可以爬到楼顶。

1. 1+ 1+ 12. 1+ 23. 2+ 1
2.题目解析

爬楼梯,可以爬一步也可以爬两步,问有多少种不同的方式到达终点,我们按照上面提到的

四个步骤进行分析:

  • 问题拆解

我们到达第 n 个楼梯可以从第 n - 1 个楼梯和第 n - 2 个楼梯到达,因此第 n 个问题可以拆解成第 n - 1 个问题和第 n - 2 个问题,第 n - 1 个问题和第 n - 2 个问题又可以继续往下拆,直到第 0 个问题,也就是第 0 个楼梯 (起点)

  • 状态定义

问题拆解” 中已经提到了,第 n 个楼梯会和第 n - 1 和第 n - 2 个楼梯有关联,那么具体的联系是什么呢?你可以这样思考,第 n - 1 个问题里面的答案其实是从起点到达第 n - 1 个楼梯的路径总数,n - 2 同理,从第 n - 1 个楼梯可以到达第 n 个楼梯,从第 n - 2 也可以,并且路径没有重复,因此我们可以把第 i 个状态定义为 “从起点到达第 i 个楼梯的路径总数”,状态之间的联系其实是相加的关系。

  • 递推方程

状态定义” 中我们已经定义好了状态,也知道第 i 个状态可以由第 i - 1 个状态和第 i - 2 个状态通过相加得到,因此递推方程就出来了 dp[i] = dp[i - 1] + dp[i - 2]

  • 实现

你其实可以从递推方程看到,我们需要有一个初始值来方便我们计算,起始位置不需要移动 dp[0] = 0,第 1 层楼梯只能从起始位置到达,因此 dp[1] = 1,第 2 层楼梯可以从起始位置和第 1 层楼梯到达,因此 dp[2] = 2,有了这些初始值,后面就可以通过这几个初始值进行递推得到。

3.参考代码

func ClimbStairs(n int64) int64 {
	if (n == 1){
		return 1
	}
	dp := [n]int64{}
	dp[0] = 0; dp[1] = 1; dp[2] = 2
	for i := int64(3); i <= n; i++ {
		dp[i] = dp[i-1] + dp[i-2]
	}
	return dp[n]
}

三角形最小路径和(LeetCode 第120 号问题)

LeetCode 第 120 号问题:三角形最小路径和。

1.题目描述

给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。

例如,给定三角形:

[
     [2],
    [3,4],
   [6,5,7],
  [4,1,8,3]
]

自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。

说明:

如果你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题,那么你的算法会很加分。

2.题目解析

  • 问题拆解:
    这里的总问题是求出最小路径和,路径是这里的分析重点,路径是由一个个元素组成的,和之前爬楼梯拿到题目类似,[i][j] 位置的元素,经过这个元素的路径也会经过 [i-1][j] 或者 [i-1][j-1], 因此经过一个元素的路径和可以通过这个元素上面的一个或者两个元素的路径和得到。
  • 状态定义
    状态的定义一般会和问题需要求解的答案联系在一起,这里其实有两种方式,一种是考虑路径从上到下,另一种是考虑路径从下到上,因为元素的值是不变的, 所以路径的方向不同也不会影响最后求得的路径和,如果是从上到下,你会发现,在考虑下面元素的时候,起始元素的路径只会从[i-1][j] 获得,每行当中的最后一个元素的路径只会从[i-][j-1] 获得,中间二者都可以,这样不太好实现,因此这里考虑从下到上的方式,状态的定义就变成了 “最后一行元素到当前元素的最小路径和”,对于[0][0] 这个元素来说,最后状态表示的就是我们的最终答案。
  • 递推方程
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]
  • 实现
    这里初始化时,我们需要将最后一行的元素填入状态数组中,然后就是按照前面分析的策略,从下到上计算即可
3.参考代码

func MinimumTotal(n [][]int64) int64 {
	size := len(n)

	dp := [][]int64{}

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

	for i := size-2; i >=0; i-- {
		for j:=0; j < i+1; j++{
			dp[i][j] = int64(math.Min(float64(dp[i+1][j]), float64(dp[i+1][j+1]))) + n[i][j]
		}
	}
	return dp[0][0]
}

最大子序和(LeetCode 第 53号问题)

LeetCode 第 53 号问题:最大子序和。

1.题目描述

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6

进阶:
如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的分治法求解。

2.题目解析

求最大子数组和,非常经典的一道题目,这道题目有很多种不同的做法,而且很多算法思想都可以在这道题目上面体现出来,比如动态规划、贪心、分治,还有一些技巧性的东西,比如前缀和数组,这里还是使用动态规划的思想来解题,套路还是之前的四步骤:

  • 问题拆解:
    问题的核心是子数组,子数组可以看作是一段区间,因此可以由起始点和终止点确定一个子数组,两个点中,我们先确定一个点,然后去找另一个点,比如说,如果我们确定一个子数组的截至元素在 i 这个位置,之后时候我们需要思考的问题是 “以 i 结尾的所有子数组中,和最大的是多少?”,然后我们去试着拆解,这里其实只有两种情况:
这个位置的元素自成一个子数组
位置的元素的值 + 以 i-1 结尾的所有子数组中的子数组和最大的值

你可以看到,我们把 i 个问题拆成第 i-1 个问题,之间的联系也变得清晰

  • 状态定义
    通过上面的分析,其实状态已经有了,dp[i] 就是 “以 i 结尾的所有子数组的最大值
  • 递推方程
    拆解问题的时候也提到了,有两种情况,即当前元素自成一个子数组,另外可以考虑前一个状态的答案,于是就有了
dp[i] = Math.max(dp[i - 1] + array[i], array[i])

化简一下就成了:

dp[i] = Math.max(dp[i - 1], 0) + array[i]
  • 实现

题目要求子数组不能为空,因此一开始需要初始化,也就是 dp[0] = array[0],保证最后答案的可靠性,另外我们需要用一个变量记录最后的答案,因为子数组有可能以数组中任意一个元素结尾

3.参考代码

func MaxSubArray(n []int64) int64 {
	if len(n) == 0{
		return 0
	}
	size := len(n)

	dp := n
	//dp[0] = n[0]
	result := dp[0]
	for i := 1; i < size; i++{
		dp[i] = int64(math.Max(float64(dp[i-1]),0)) + n[i]
		result = int64(math.Max(float64(result),float64(dp[i])))
	}

	return result
}

最长子回文

题目描述

回文串(palindromic string)是指这个字符串无论从左读还是从右读,所读的顺序是一样的;简而言之,回文串是左右对称的。

题目解析

最容易想到的是穷举法,穷举所有子串,找出回文串的子串,统计出最长的那一个。

穷举的时间复杂度过高,接下来我们用dp进行优化。对于母串s,我们用dp[i,j]=1表示子串s[i…j]为回文子串,那么就有递推式,dp[i,j] = dp[i+1,j-1] if s[i] = s[j]。

  • 当s[i] = s[j]时,如果 s[i+1…j-1]是回文子串,则s[i…j]也是回文子串;
  • 如果s[i] != s[j] 或 s[i+1…j-1]不是回文子串,则s[i…j]也不是
    对于只包含单个字符、或两个字符重复,其均为回文串:
  • dp[i,i] = 1
  • d[i,i+1] = 1 if s[i] == s[i+1]
代码实现
func LongestPalindrome(s string)string{
	length := len(s)
	longest := string(s[0])
	dp := make([][]bool,length)
	for i:= range dp{
		dp[i] = make([]bool, length)
	}
	for gap := 0;gap<length;gap++{
		for i:=0;i<length-gap;i++{
			j := i+gap
			if s[i] == s[j] && (j-i<=2 || dp[i+1][j-1]){
				dp[i][j] = true
				if j+1-i > len(longest){
					longest = s[i:j+1]
				}
			}
		}
	}
	return longest
}

分治法

//分治法
回文串是左右对称的,如果从中心轴开始遍历,会减少一层循环。
依次以母串的每一个字符为中心轴,得到回文串;然后通过比较得到最长的那一个
func l2rHelper(s string,mid int)string{
	l := mid-1; r := mid+1
	length := len(s)
	for r < length && s[r] == s[mid]{
		r++
	}
	for l >= 0 && r<length && s[l]==s[r]{
		l--
		r++
	}
	return s[l+1:r]
}
func LongestPalindrome(s string)string{
	length := len(s)
	longest := string(s[0])
	for i:=0;i<length-1;i++{
		if len(l2rHelper(s,i)) > len(longest){
			longest = l2rHelper(s,i)
		}
	}
	return longest
}

矩阵类动态规划问题

矩阵类动态规划,也可以叫做坐标类动态规划,一般这类问题都会给你一个矩阵,矩阵里面有着一些信息,然后你需要根据这些信息求解问题。

其实 矩阵可以看作是图的一种,怎么说?你可以把整个矩阵当成一个图,矩阵里面的每个位置上的元素当成是图上的节点,然后每个节点的邻居就是其相邻的上下左右的位置,我们遍历矩阵其实就是遍历图,在遍历的过程中会有一些临时的状态,也就是子问题的答案,我们记录这些答案,从而推得我们最后想要的答案。

一般来说,在思考这类动态规划问题的时候,我们只需要思考当前位置的状态,然后试着去看当前位置和它邻居的递进关系,从而得出我们想要的递推方程,这一类动态规划问题,相对来说比较简单,我们通过几道例题来熟悉一下。

不同路径(LeetCode 第 62 号问题)

1.题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?
在这里插入图片描述
例如,上图是一个7 x 3 的网格。有多少可能的路径?

说明: m 和 n 的值均不超过 100。

示例 1:

输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。

1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右
输入: m = 7, n = 3
输出: 28
2.题目解析

给定一个矩阵,问有多少种不同的方式从起点(0,0) 到终点 (m-1,n-1),并且每次移动只能向右或者向下,我们还是按之前提到的分析动态规划那四个步骤来思考一下:

  • 问题拆解:
    题目中说了,每次移动只能是向右或者是向下,矩阵类动态规划需要关注当前位置和其相邻位置的关系,对于某一个位置来说,经过它的路径只能从它上面过来,或者从它左边过来,因此,如果需要求到达当前位置的不同路径,我们需要知道到达其上方位置的不同路径,以及到达其左方位置的不同路径
  • 状态定义
    矩阵类动态规划的状态定义相对来说比较简单,只需要看当前位置即可,问题拆解中,我们分析了当前位置和其邻居的关系,提到每个位置其实都可以算做是终点,状态表示就是 “从起点到达该位置的不同路径数目
  • 递推方程
    有了状态,也知道了问题之间的联系,其实递推方程也出来了,就是
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
  • 实现
    有了这些,这道题还没完,我们还要考虑状态数组的初始化问题,对于上边界和左边界的点,因为它们只能从一个方向过来,需要单独考虑,比如上边界的点只能从左边这一个方向过来,左边界的点只能从上边这一个方向过来,它们的不同路径个数其实就只有 1,提前处理就好。
3.参考代码

func UniquePaths(m int64,n int64) int64 {
	dp := [7][7]int64{}

	for i:=int64(0); i<m; i++{
		dp[i][0] = 1
	}
	for j:=int64(0); j<n; j++{
		dp[0][j] = 1
	}
	for i:=int64(1); i<m; i++{
		for j:=int64(1); j<n; j++{
			dp[i][j] = dp[i-1][j] + dp[i][j-1]
		}
	}
	fmt.Println(dp)

	return dp[m-1][n-1]
}

不同路径II

1.题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
在这里插入图片描述
网格中的障碍物和空位置分别用 1 和 0 来表示。

说明:m 和 n 的值均不超过 100。

示例 1:

输入:
[
  [0,0,0],
  [0,1,0],
  [0,0,0]
]
输出: 2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:

1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右
2.题目解析

在上面那道题的基础上,矩阵中增加了障碍物,这里只需要针对障碍物进行判断即可,如果当前位置是障碍物的话,状态数组中当前位置记录的答案就是 0,也就是没有任何一条路径可以到达当前位置,除了这一点外,其余的分析方法和解题思路和之前 一样

3.参考代码

func UniquePathsWithObstacles(obstacleGrid *[7][7]int64) int64 {
	if len(obstacleGrid) == 0 || len(obstacleGrid[0]) == 0{
		return 0
	}
	if (obstacleGrid[0][0]) == 1{
		return 0
	}
	m := len(obstacleGrid)
	n := len(obstacleGrid[0])
	dp := [7][7]int64{}
	dp[0][0] = 1

	for i:=0; i<m; i++{
		dp[i][0] = 1
		if obstacleGrid[i][0] == 1{
			dp[i][0] = 0
		}
	}
	for j:=0; j<n; j++{
		dp[0][j] = 1
		if obstacleGrid[0][j] == 1{
			dp[0][j] = 0
		}
	}
	for i:=1; i<m; i++{
		for j:=1; j<n; j++{
			dp[i][j] = dp[i-1][j] + dp[i][j-1]
			if obstacleGrid[i][j] == 1{
				dp[i][j] = 0
			}
		}
	}
	fmt.Println(dp)

	return dp[m-1][n-1]
}

最小路径和(LeetCode 第 64 号问题)

1.题目描述

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

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

示例:

输入:
[
  [1,3,1],
  [1,5,1],
  [4,2,1]
]
输出: 7
解释: 因为路径 13111 的总和最小。
2.题目解析

给定一个矩阵,问从起点(0,0) 到终点 (m-1,n-1) 的最小路径和是多少,并且每次移动只能向右或者向下,按之四个步骤来思考一下:

  • 问题解析:
    拆解问题的方式方法和前两道题目非常类似,这里不同的地方只是记录的答案不同,也就是状态不同,我们还是可以仅仅考虑当前位置,然后可以看到只有上面的位置和左边的位置可以到达当前位置,因此当前问题就可以拆解成两个子问题

  • 状态定义
    因为是要求路径和,因此状态需要记录的是 “从起始点到当前位置的最小路径和

  • 递推方程
    有了状态,以及问题之间的联系,我们知道了,当前的最短路径和可以由其上方和其左方的最短路径和对比得出,递推方程也可以很快写出来:

dp[i][j] = Math.min(dp[i - 1][j] + dp[i][j - 1]) + grid[i][j]
  • 实现
    实现上面需要重点考虑的还是状态数组的初始化,这一步还是和前面两题类似,这里就不过多赘述
3.参考代码
在Go语言中,当多维数组直接作为函数实参进行参数传递的时候,会有很大的限制性,
比如除第一维数组的其他维数需要显式给出等;此时可以使用多维切片来作为参数传递:
type s1 []int
type s2 []s1

func UniquePaths(grid s2) int64 {
	m := len(grid)
	n := len((grid)[0])
	dp := grid
	dp[0][0] = grid[0][0]
	
	for i:=1; i<m; i++{
		dp[i][0] = dp[i-1][0] + grid[i][0]
	}
	for j:=1; j<n; j++{
		dp[0][j] = dp[0][j-1] + grid[0][j]
	}
	for i:=1; i<m; i++{
		for j:=1; j<n; j++{
			dp[i][j] = int64(math.Max(float64(dp[i-1][j]),float64(dp[i][j-1]))) + grid[i][j]
		}
	}
	fmt.Println(dp)

	return dp[m-1][n-1]
}

最大正方形(LeetCode 第 221 号问题)

1.题目描述

在一个由 0 和 1 组成的二维矩阵内,找到只包含 1 的最大正方形,并返回其面积。

示例:

输入: 

1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0

输出: 4
2.题目解析

题目给定一个字符矩阵,字符矩阵中只有两种字符,分别是 ‘0’ 和 ‘1’,题目要在矩阵中找全为 ‘1’ 的,面积最大的正方形。

刚拿道这道题,如果不说任何解法的话,其实并不是特别好想,我们先来看看切题的思路是怎么样的。

首先一个正方形是由四个顶点构成的,如果说我们在矩阵中随机找四个点,然后判断该四个点组成的是不是正方形,如果是正方形,然后看组成正方形的每个位置的元素是不是都是 ‘1’,这种方式也是可行的,但是比较暴力,这么弄下来,时间复杂度是 O((m*n)^4)。

那我们就会思考,组成一个正方形是不是必须要四个点都找到?如果我们找出其中的三个点,甚至说两个点,能不能确定这个正方形呢?

你会发现,这里我们只需要考虑 正方形对角线的两个点 即可,这两个点确定了,另外的两个点也就确定了,因此我们可以把时间复杂度降为 O((m*n)^2)。

但是这里还是会有一些重复计算在里面,我们和之前一样,本质还是在做暴力枚举,只是说枚举的个数变少了,我们能不能记录我们之前得到过的答案,通过牺牲空间换取时间呢,这里正是动态规划所要做的事情!

  • 问题拆解
    我们可以思考,如果我们从左到右,然后从上到下遍历矩阵,假设我们遍历到的当前位置是正方形的右下方的点,那其实我们可以看之前我们遍历过的点有没有可能和当前点组成符合条件的正方形,除了这个点以外,无非是要找另外三个点,这三个点分别在当前点的上方,左方,以及左上方,也就是从这个点往这三个方向去做延伸,具体延伸的距离是和其相邻的三个点中的状态有关
  • 状态定义
    因为我们考虑的是正方形的右下方的顶点,因此状态可以定义成 “当前点为正方形的右下方的顶点时,正方形的最大面积
  • 递推方程
    有了状态,我们再来看看递推方程如何写,前面说到我们可以从当前点向三个方向延伸,我们看相邻的位置的状态,这里我们需要取三个方向的状态的最小值才能确保我们延伸的是全为 ‘1’ 的正方形,也就是
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
  • 实现
    在实现上,我们需要单独考虑两种情况,就是当前位置是 ‘1’,还有就是当前位置是 ‘0’,如果是 ‘0’ 的话,状态就是 0,表示不能组成正方形,如果是 ‘1’ 的话,我们也需要考虑位置,如果是第一行的元素,以及第一列的元素,表明该位置无法同时向三个方向延伸,状态直接给为 1 即可,其他情况就按我们上面得出的递推方程来计算当前状态。
3.参考代码

type s1 []int64
type s2 []s1

func MaximalSquare(matrix s2) int64 {
	if len(matrix) == 0 || len(matrix[0]) == 0{
		return 0
	}
	m := len(matrix); n := len(matrix[0])
	dp := matrix; maxLength := int64(0)

	for i:=0; i<m; i++{
		for j:=0; j<n; j++ {
			if matrix[i][j] == 1 {
				if (i == 0 || j == 0) {
					dp[i][j] = 1
					if matrix[i][j] == 0 {
						dp[i][j] = 0
					}
				} else {
					dp[i][j] = int64(math.Min(float64(dp[i-1][j]),
						math.Min(float64(dp[i][j-1]), float64(dp[i-1][j-1])),
					)) + 1
				}
				maxLength = int64(math.Max(float64(dp[i][j]),float64(maxLength)))
			}
		}
	}
	fmt.Println(dp)

	return maxLength*maxLength
}

总结

  • 通过这几个简单的例子,相信你不难发现,解动态规划题目其实就是拆解问题,定义状态的过程,严格说来,动态规划并不是一个具体的算法,而是凌驾于算法之上的一种 思想

  • 这种思想强调的是从局部最优解通过一定的策略推得全局最优解,从子问题的答案一步步推出整个问题的答案,并且利用空间换取时间。从很多算法之中你都可以看到动态规划的影子,所以,还是那句话 技术都是相通的,找到背后的本质思想是关键。

  • 对于矩阵类的动态规划,相对来说比较简单,这一类动态规划也比较好识别,一般输入的参数就是一个矩阵,解题的时候,我们只需要从当前位置出发考虑状态即可,通常来说当前位置的状态的求解仅仅需要借助其相邻位置的状态,通常我们也不需要考虑非常隐蔽的边界条件,一般需要做的初始化操作都可以从矩阵中,以及题目中的信息得出。

补充

至于为什么最终的解法看起来如此精妙,是因为动态规划遵循一套固定的流程:递归的暴力解法 -> 带备忘录的递归解法 -> 非递归的动态规划解法。这个过程是层层递进的解决问题的过程,你如果没有前面的铺垫,直接看最终的非递归动态规划解法,当然会觉得牛逼而不可及了。

当然,见的多了,思考多了,是可以一步写出非递归的动态规划解法的。任何技巧都需要练习,我们先遵循这个流程走,算法设计也就这些套路,除此之外,真的没啥高深的。

本文会通过两个个比较简单的例子:斐波那契和凑零钱问题,揭开动态规划的神秘面纱,描述上述三个流程。后续还会写几篇文章探讨如何使用动态规划技巧解决比较复杂的经典问题。

斐波那契数列

步骤一、暴力的递归算法
func Fib(n int64) int64 {
	if n == 1 || n == 2{
		return 1
	}
	return Fib(n-1)+Fib(n-2)
}

PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。
在这里插入图片描述
递归算法的时间复杂度怎么计算?子问题个数乘以解决一个子问题需要的时间。

子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。

解决一个子问题的时间,在本算法中,没有循环,只有 f(n – 1) + f(n – 2) 一个加法操作,时间为 O(1)。

所以,这个算法的时间复杂度为 O(2^n),指数级别,爆炸。

观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。

这就是动态规划问题的第一个性质:重叠子问题。下面,我们想办法解决这个问题。

步骤二、带备忘录的递归解法

明确了问题,其实就已经把问题解决了一半。即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。

一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典),思想都是一样的。

var memo [22]int64
func Fib(n int64) int64 {
	if n < 1 {
		return 0
	}
	memo[1] = 1; memo[2] = 1
	return Helper(n)
}
func Helper(n int64) int64{
	if n > 0 && memo[n] == 0{
		memo[n] = Helper(n-1) + Helper(n-2)
	}
	return memo[n]
}

现在,画出递归树,你就知道「备忘录」到底做了什么。

在这里插入图片描述
实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。

子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是 f(1), f(2), f(3) … f(20),数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。

所以,本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击

至此,带备忘录的递归解法的效率已经和动态规划一样了。实际上,这种解法和动态规划的思想已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。

啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 触底,然后逐层返回答案,这就叫「自顶向下」。

啥叫「自底向上」?反过来,我们直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。

步骤三、动态规划

有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」的推算岂不美哉!

var memo [22]int64
func Fib(n int64) int64 {
	if n < 1 {
		return 0
	}
	memo[1] = 1; memo[2] = 1
	for i := int64(3); i < n+1; i++{
		memo[i] = memo[i-1] + memo[i-2]
	}
	return memo[n]
}

在这里插入图片描述
这里,引出「动态转移方程」这个名词,实际上就是描述问题结构的数学形式
在这里插入图片描述

为啥叫「状态转移方程」?为了听起来高端。你把 f(n) 想做一个状态 n,这个状态 n 是由状态 n – 1 和状态 n – 2 相加转移而来,这就叫状态转移,仅此而已。

你会发现,上面的几种解法中的所有操作,例如 return f(n – 1) + f(n – 2),dp[i] = dp[i – 1] + dp[i – 2],以及对备忘录或 DP table 的初始化操作,都是围绕这个方程式的不同表现形式。可见列出「状态转移方程」的重要性,它是解决问题的核心。很容易发现,其实状态转移方程直接代表着暴力解法。

千万不要看不起暴力解,动态规划问题最困难的就是写出状态转移方程,即这个暴力解。优化方法无非是用备忘录或者 DP table,再无奥妙可言。

这个例子的最后,讲一个细节优化。细心的读者会发现,根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 O(1):

func Fib(n int64) int64 {
	if n < 1 {
		return 0
	}
	pre := int64(1); cur := int64(1); sum := int64(0)
	for i := int64(1); i < n-1; i++{
		sum = cur + pre
		pre = cur
		cur = sum
	}
	return sum
}

动态规划的另一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来说不算动态规划,以上旨在演示算法设计螺旋上升的过程。当问题中要求求一个最优解或在代码中看到循环和 max、min 等函数时,十有八九,需要动态规划大显身手。

凑零钱问题

题目:给你 k 种面值的硬币,面值分别为 c1, c2 … ck,再给一个总金额 n,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,则回答 -1 。

比如说,k = 3,面值分别为 1,2,5,总金额 n = 11,那么最少需要 3 枚硬币,即 11 = 5 + 5 + 1 。下面走流程。

一、暴力解法

首先是最困难的一步,写出状态转移方程,这个问题比较好写:
在这里插入图片描述
其实,这个方程就用到了「最优子结构」性质:原问题的解由子问题的最优解构成。即 f(11) 由 f(10), f(9), f(6) 的最优解转移而来。
记住,要符合「最优子结构」,子问题间必须互相独立。啥叫相互独立?你肯定不想看数学证明,我用一个直观的例子来讲解。

var coins = []int64{1,2,5}
func CoinChange(n int64) int64 {
	ans := n
	for _,i := range coins{
		if (n-int64(i)) < 0{continue}
		subProb := CoinChange(n-int64(i))
		ans = int64(math.Min(float64(ans),float64(subProb+1)))
	}
	return ans
}

画出递归树:
在这里插入图片描述
时间复杂度分析:子问题总数 x 每个子问题的时间。子问题总数为递归树节点个数,这个比较难看出来,是 O(n^k),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 O(k)。所以总时间复杂度为 O(k*nk),指数级别。

var coins = []int64{1,2,5}
var memo [13]int64
func CoinChange(amount int64)int64{
	return Helper(amount)
}
func Helper(n int64) int64 {
	if (n == 0) {return 0}
	if (memo[n] != 0) {return memo[n]}
	memo[n] = n
	for _,i := range coins{
		if (n-int64(i)) < 0{continue}
		subProb := Helper(n-int64(i))
		memo[n] = int64(math.Min(float64(memo[n]),float64(subProb+1)))
	}
	return memo[n]
}

不画图了,很显然「备忘录」大大减小了子问题数目,完全消除了子问题的冗余,所以子问题总数不会超过金额数 n,即子问题数目为 O(n)。处理一个子问题的时间不变,仍是 O(k),所以总的时间复杂度是 O(kn)。

三、动态规划
var coins = []int64{1,2,5}
var dp [12]int64

func CoinChange(n int64) int64 {
	for i := int64(0); i<n+1; i++{
		dp[i] = i
		for _,coin := range coins{
			if (i-int64(coin)) < 0{continue}
			dp[i] = int64(math.Min(float64(dp[i]),float64(1+dp[i-int64(coin)])))
		}
	}

	return dp[n]
}

补充总结

计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举,穷举所有可能性。算法设计无非就是先思考如何穷举,然后再追求如何聪明地穷举

列出动态转移方程,就是在解决如何穷举的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。

备忘录、DP table 就是在追求如何聪明地穷举。用空间换时间的思路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花活?

序列类动态规划

通常问题的输入参数会涉及数组或是字符串

输入数组:[1,2,3,4,5,6,7,8,9]
子数组:[2,3,4], [5,6,7], [6,7,8,9], ...
子序列:[1,5,9], [2,3,6], [1,8,9], [7,8,9], ...
  • 子数组的问题和我们前面提到的矩阵类动态规划的分析思路很类似,只需要考虑当前位置,以及当前位置和相邻位置的关系
  • 对于第 i 个位置的状态分析,它不仅仅需要考虑当前位置的状态,还需要考虑前面 i – 1 个位置的状态,思考的方向其实在于 寻找当前状态和之前所有状态的关系

最长上升子序列(LeetCode 第 300 号问题)

题目描述

给定一个无序的整数数组,找到其中最长上升子序列的长度

题目解析

给定一个数组,求最长递增子序列。因为是子序列,**这样对于每个位置的元素其实都存在 两种可能,就是选和不选,**暴力解法,枚举所有的子序列,判断他们是不是递增的,选取最大的递增序列,这样做的话,时间复杂度是 O(2^n),显然不高效。

  • 问题解析
    数组中最长递增子序列,虽然不是连续的区间,但是它依然有起点和终点。如果我们确定终点位置,然后去看前面 i-1 个位置中,哪一个位置可以和当前位置拼接在一起。
  • 状态定义
    如果我们要求解第 i 个问题的解,那么我们必须考虑前 i-1 个问题的解,我们定义dp[i] 表示以位置i 结尾的子序列的最大长度
  • 递推方程
    对于 i 这个位置,我们需要考虑前 i-1 个位置,看看哪些位置可以拼在 i 位置之前, 如果有多个位置可以拼在 i 之前, 那么必须选最长的那个:
dp[i] = Math.max(dp[j],...,dp[k]) + 1, 
其中 inputArray[j] < inputArray[i], inputArray[k] < inputArray[i]
  • 实现
    需要考虑状态数组的初始化,因为对于每个位置,它本身其实就是一个序列,因此所有位置的状态都可以初始化为 1。
参考代码
// 动态规划
// 时间复杂度O(n^2)
// 空间复杂度O(n)
func lengthOfLIS(nums []int) int {
	if nums == nil || len(nums) == 0{return 0}
	var dp = make([]int,len(nums))
	max := 1
	for i:=0;i<len(nums);i++{
		dp[i] = 1
		for j:=0;j<i;j++{
			if nums[j] < nums[i]{
				dp[i] = int(math.Max(float64(dp[i]),float64(dp[j]+1)))
			}
		}
		max = int(math.Max(float64(max),float64(dp[i])))
	}
	return max
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
将 ShardingSphere 和 Dynamic DataSource Spring Boot Starter 整合可以实现在分片数据库环境下动态切换数据源的功能。 以下是整合步骤: 1. 引入 ShardingSphere 和 Dynamic DataSource Spring Boot Starter 的依赖。 ```xml <dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>sharding-jdbc-core</artifactId> <version>${sharding-sphere.version}</version> </dependency> <dependency> <groupId>com.github.yingzhuo</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>${dynamic-datasource.version}</version> </dependency> ``` 2. 配置 ShardingSphere 的数据源。 ```yaml spring: shardingsphere: datasource: names: ds0, ds1 ds0: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/ds0?useSSL=false username: root password: root ds1: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/ds1?useSSL=false username: root password: root sharding: tables: user: actualDataNodes: ds$->{0..1}.user_$->{0..1} keyGenerator: type: SNOWFLAKE column: id databaseStrategy: inline: sharding-column: id algorithm-expression: ds$->{id % 2} tableStrategy: inline: sharding-column: id algorithm-expression: user_$->{id % 2} ``` 3. 配置 Dynamic DataSource Spring Boot Starter。 ```yaml dynamic-datasource: datasource: master: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/ds0?useSSL=false username: root password: root slave: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/ds1?useSSL=false username: root password: root ``` 4. 在代码中使用动态数据源。 ```java @Service public class UserServiceImpl implements UserService { @Autowired private UserDao userDao; @Override @DS("master") public void addUser(User user) { userDao.addUser(user); } @Override @DS("slave") public User getUserById(Long id) { return userDao.getUserById(id); } } ``` 其中,@DS 注解可以指定使用哪个数据源。 通过以上步骤,ShardingSphere 和 Dynamic DataSource Spring Boot Starter 就整合完成了,可以实现在分片数据库环境下动态切换数据源的功能。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值