详解动态规划
详解动态规划
Leetcode 动态规划直达:Leetcode动态规划题集
DP 解题套路
DP思想简介
中心思想:
通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。 这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。通俗来说就是:大事化小,小事化无。
举例:
比如要把100元钱给花掉,你可以把它分为两个50元分别让两个人花掉。两个人如果花不掉50元(纯属理想状态),那可以把50元再次划分为25元,一直到每个人可以完全花费完。而动态规划大致就是这个意思。
适用问题:
动态规划常常适用于有重叠子问题和最优子结构性质的问题
DP 解题套路初探
题目——求斐波拉契数列Fibonacci。这道题相信大家应该是很熟悉了,题目是:指的是这样一个数列:1、1、2、3、5、8、13、21、34、……在数学上,斐波纳契数列以如下被以递推的方法定义:F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*)要你求第n项的数是多少?
常规思路----递归
def fibonacci(n):
"""
:param n: int-->指代给定的参数大小
:return: int-->指代第n项对应的值
"""
if n == 1 or n == 2:
return 1
else:
return fibonacci(n-1)+fibonacci(n-2)
if __name__ == "__main__":
n = 6
final_array = fibonacci(n)
print(final_array)
上图展示了递归过程,不难发现操作过程中存在不少的重复操作。
什么方法可以去重呢?可不可以把已经计算过的值给保存起来呢?下次要用到这个值就不用再次计算了,而是直接取出这个值即可。
DP算法的两个常规解题方法——自顶向下的备忘录法以及自下而上检索法
自顶向下的备忘录法
顾名思义,这个方法就是从结果倒推上一级,一直推到出口,也就是把我们给的初始值。
比如:让你求fib(6)的值。此处就是:fib(6)=fib(5)+fib(4);fib(5)=fib(4)+fib(3)…此处还会定义一个memo数组来记录每一个fib()函数的值,
def fibonacci(n, meno):
"""
:param n: int-->指代给定的参数大小
:param meno: [int]-->设定的备忘录,存储相应的计算结果,避免重复计算
:return:
"""
if meno[n] != -1:
return meno[n]
if n <= 2:
meno[n] = 1
else:
meno[n] = fibonacci(n-1, meno)+fibonacci(n-2, meno)
return meno[n]
if __name__ == "__main__":
n = 6
meno = [-1] * (n+1)
final_array = fibonacci(n, meno)
print(final_array)
自下而上检索法
这个方法就是根据已知条件一步一步的往结果的方向推,它没有用递归的方法,而是主要用for循环层层检索
def fibonacci(n):
"""
:param n: int-->指代给定的参数大小
:return: int-->指代第n项对应的值
"""
if n == 1:
return 1
memo = [0]*(n+1)
memo[1] = 1
for index in range(1, n):
memo[index+1] = memo[index] + memo[index-1]
return memo[n]
if __name__ == "__main__":
n = 6
final_array = fibonacci(n)
print(final_array)
此处的标记列表memo列表长度就是为n+1的,memo[index]的值就是记录第index项值
DP解题过程小结
重要概念
动态规划中包含三个重要的概念,最优子结构、边界、状态转移公式
fib(6)=fib(5)+fib(4),其中fib(5)和fib(4)就是fib(6)的最优子结构
fib(1)=fib(2)=1给定的初始值就是边界
状态转移公式 :本例的状态转移方程很简单,就是fib(n)=fib(n-1)+fib(n-2),很直接都没绕什么弯,有些题目的状态转移方程那就是很复杂了,需要考虑多种限制条件,比如前后几个数的大小关系,这时候更是考验我们总结规律的功力了。还有就是如果标记列表的值那是与你的状态转移方程息息相关的,如果状态转移方程没有找出来,那标记列表的值你也别想找出来了
解题流程:(按照自下而上检索法)
第一步:首先排除给定变量n的特殊值。比如这题n的值不能小于0,当n==1时可以直接返回初始值1,剔除特殊值是为了方便后续计算的正常进行。
第二步:定义标记列表或者标记矩阵。维度视题目而定,这个我在后面的章节会好好介绍。长度也是视题目而定,我在前面也提到过了,一般情况下长度是设为n+1,即递推式一般为两项相加的。最后再多嘴一句:标记列表或标记矩阵真的真的真的是很重要,我们所需的结果都是直接或间接从这个好东西里面得出来的,大家看上一题应该也差不多知道了。
第三步:for循环遍历给定的n项数值,利用找出的状态转移公式得到相应的标记列表或标记矩阵的值。
我也提到过:状态转移方式可以说是整个DP算法的核心了,一个DP算法题目要出难,就是在这个点上疯狂的恶心你,会让你考虑各种情况,所以得需大家保持耐心的找好状态转移方式了。刚开始不好弄,但多见几种题目,就慢慢有感觉了
DP解题套路深挖
动态规划,无非就是利用历史记录,来避免我们的重复计算。而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者二维数组来保存。下面我们先来讲下做动态规划题很重要的三个步骤,
三个重要步骤
第一步:定义数组元素的含义
上面说了,我们会用一个数组,来保存历史数组,假设用一维数组 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
青蛙跳台阶
(1)定义数组元素的含义
首先我们来定义 dp[i] 的含义,我们的问题是要求青蛙跳上 n 级的台阶总共由多少种跳法,那我们就定义 dp[i] 的含义为:跳上一个 i 级的台阶总共有 dp[i] 种跳法。这样,如果我们能够算出 dp[n],不就是我们要求的答案吗?所以第一步定义完成。
(2)找出数组元素间的关系式
我们的目的是要求 dp[n],动态规划的题,如你们经常听说的那样,就是把一个规模比较大的问题分成几个规模比较小的问题,然后由小的问题推导出大的问题。也就是说,dp[n] 的规模为 n,比它规模小的是 n-1, n-2, n-3… 也就是说,dp[n] 一定会和 dp[n-1], dp[n-2]…存在某种关系的。我们要找出他们的关系。
这个怎么找,是最核心最难的一个,我们必须回到问题本身来了,来寻找他们的关系式,dp[n] 究竟会等于什么呢?
对于这道题,由于情况可以选择跳一级,也可以选择跳两级,所以青蛙到达第 n 级的台阶有两种方式
一种是从第 n-1 级跳上来
一种是从第 n-2 级跳上来
由于我们是要算所有可能的跳法的,所以有 dp[n] = dp[n-1] + dp[n-2]。
(3)找出初始条件
当 n = 1 时,dp[1] = dp[0] + dp[-1],而我们是数组是不允许下标为负数的,所以对于 dp[1],我们必须要直接给出它的数值,相当于初始值,显然,dp[1] = 1。一样,dp[0] = 0.(因为 0 个台阶,那肯定是 0 种跳法了)。于是得出初始值:
dp[0] = 0.
dp[1] = 1.
即 n <= 1 时,dp[n] = n.
实现
def How_the_frog_jumps(n):
#异常输入处理
if n <= 1:
return n
#建立dp数字
dp = [-1]*(n+1)
print(dp)
#初始值
dp[0] = 1
dp[1] = 1
#通过关系式计算dp[n]
for i in range(2,n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
if __name__ == "__main__":
print(How_the_frog_jumps(20))
爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例 2:
输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
(1)定义数组元素的含义
定义dp数组中的元素dp[i]为:爬上第层楼的方案数
(2)找出数组元素间的关系式
dp[i] = dp[i-1] + dp[i-2]
(3)找出初始条件
常识即可知道
dp[0] = 1
dp[1] = 1
二维DP
不同路径
leetcode 的 62 号题:https://leetcode-cn.com/problems/unique-paths/
(1)定义数组元素的含义
由于我们的目的是从左上角到右下角一共有多少种路径,那我们就定义 dp[i] [j]的含义为:当机器人从左上角走到(i, j) 这个位置时,一共有 dp[i] [j] 种路径。那么,dp[m-1] [n-1] 就是我们要的答案了
(2)找出数组元素间的关系式
想象以下,机器人要怎么样才能到达 (i, j) 这个位置?由于机器人可以向下走或者向右走,所以有两种方式到达
一种是从 (i-1, j) 这个位置走一步到达
一种是从(i, j - 1) 这个位置走一步到达
因为是计算所有可能的步骤,所以是把所有可能走的路径都加起来,所以关系式是 dp[i] [j] = dp[i-1] [j] + dp[i] [j-1]。
(3)找出初始条件
显然,当 dp[i] [j] 中,如果 i 或者 j 有一个为 0,那么还能使用关系式吗?答是不能的,因为这个时候把 i - 1 或者 j - 1,就变成负数了,数组就会出问题了,所以我们的初始值是计算出所有的 dp[0] [0….n-1] 和所有的 dp[0….m-1] [0]。这个还是非常容易计算的,相当于计算机图中的最上面一行和左边一列。因此初始值如下:
dp[0] [0….n-1] = 1; // 相当于最上面一行,机器人只能一直往左走
dp[0…m-1] [0] = 1; // 相当于最左面一列,机器人只能一直往下走
实现
def uniquePaths(m,n):
#处理异常输入
if m <= 0 or n <= 0:
return 0
dp = [[-1]*n]*m
#print(dp)
#初始化
for i in range(m):
dp[i][0] = 1
for j in range(n):
dp[0][j] = 1
for i in range(m):
for j in range(n):
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[m-1][n-1]
if __name__ == "__main__":
print(uniquePaths(3,4))
最小路径和
leetcode 的第64题:https://leetcode-cn.com/problems/minimum-path-sum/
(1)定义数组元素的含义
由于我们的目的是从左上角到右下角,最小路径和是多少,那我们就定义 dp[i] [j]的含义为:当机器人从左上角走到(i, j) 这个位置时,最下的路径和是 dp[i] [j]。那么,dp[m-1] [n-1] 就是我们要的答案了。
(2)找出数组元素间的关系式
想象以下,机器人要怎么样才能到达 (i, j) 这个位置?由于机器人可以向下走或者向右走,所以有两种方式到达
一种是从 (i-1, j) 这个位置走一步到达
一种是从(i, j - 1) 这个位置走一步到达
不过这次不是计算所有可能路径,而是计算哪一个路径和是最小的,那么我们要从这两种方式中,选择一种,使得dp[i] [j] 的值是最小的,显然有
dp[i] [j] = min(dp[i-1][j],dp[i][j-1]) + arr[i][j];// arr[i][j] 表示网格种的值
(3)找出初始条件
显然,当 dp[i] [j] 中,如果 i 或者 j 有一个为 0,那么还能使用关系式吗?答是不能的,因为这个时候把 i - 1 或者 j - 1,就变成负数了,数组就会出问题了,所以我们的初始值是计算出所有的 dp[0] [0….n-1] 和所有的 dp[0….m-1] [0]。这个还是非常容易计算的,相当于计算机图中的最上面一行和左边一列。因此初始值如下:
dp[0] [j] = arr[0] [j] + dp[0] [j-1]; // 相当于最上面一行,机器人只能一直往左走
dp[i] [0] = arr[i] [0] + dp[i] [0]; // 相当于最左面一列,机器人只能一直往下走
实现
def miniNumSum(arr):
m = len(arr)
n = len(arr[0])
if m <= 0 or n <= 0:
return 0
dp = [[-1]*n]*m
print(dp)
#初始化
dp[0][0] = arr[0][0]
#初始化最左边的列
for i in range(m):
dp[i][0] = dp[i-1][0] + arr[i][0]
#初始化最上边的行
for j in range(n):
dp[0][j] = dp[0][j-1] + arr[0][j]
#推导出dp[m-1][n-1]
for i in range(m):
for j in range(n):
dp[i][j] = min(dp[i-1][j],dp[i][j-1]) + arr[i][j]
return dp[m-1][n-1]
if __name__ == "__main__":
arr=[
[1,3,1],
[1,5,1],
[4,2,1]
]
print(miniNumSum(arr))
编辑距离
leetcode 的第 72 号题
(1)定义数组元素的含义
由于我们的目的求将 word1 转换成 word2 所使用的最少操作数 。那我们就定义 dp[i] [j]的含义为:当字符串 word1 的长度为 i,字符串 word2 的长度为 j 时,将 word1 转化为 word2 所使用的最少操作次数为 dp[i] [j]。
(2)找出数组元素间的关系式
接下来我们就要找 dp[i] [j] 元素之间的关系了,比起其他题,这道题相对比较难找一点,但是,不管多难找,大部分情况下,dp[i] [j] 和 dp[i-1] [j]、dp[i] [j-1]、dp[i-1] [j-1] 肯定存在某种关系。因为我们的目标就是,**从规模小的,通过一些操作,推导出规模大的。对于这道题,我们可以对 word1 进行三种操作
插入一个字符
删除一个字符
替换一个字符
由于我们是要让操作的次数最小,所以我们要寻找最佳操作。那么有如下关系式:
一、如果我们 word1[i] 与 word2 [j] 相等,这个时候不需要进行任何操作,显然有 dp[i] [j] = dp[i-1] [j-1]。(别忘了 dp[i] [j] 的含义哈)。
二、如果我们 word1[i] 与 word2 [j] 不相等,这个时候我们就必须进行调整,而调整的操作有 3 种,我们要选择一种。三种操作对应的关系试如下(注意字符串与字符的区别):
(1)、如果把字符 word1[i] 替换成与 word2[j] 相等,则有 dp[i] [j] = dp[i-1] [j-1] + 1;
(2)、如果在字符串 word1末尾插入一个与 word2[j] 相等的字符,则有 dp[i] [j] = dp[i] [j-1] + 1;
(3)、如果把字符 word1[i] 删除,则有 dp[i] [j] = dp[i-1] [j] + 1;
那么我们应该选择一种操作,使得 dp[i] [j] 的值最小,显然有
dp[i] [j] = min(dp[i-1] [j-1],dp[i] [j-1],dp[[i-1] [j]]) + 1;
于是,我们的关系式就推出来了
(3)找出初始条件
显然,当 dp[i] [j] 中,如果 i 或者 j 有一个为 0,那么还能使用关系式吗?答是不能的,因为这个时候把 i - 1 或者 j - 1,就变成负数了,数组就会出问题了,所以我们的初始值是计算出所有的 dp[0] [0….n] 和所有的 dp[0….m] [0]。这个还是非常容易计算的,因为当有一个字符串的长度为 0 时,转化为另外一个字符串,那就只能一直进行插入或者删除操作了
实现
def minDistance(word1,word2):
n1 = len(word1)
n2 = len(word2)
dp = [[-1]*(n2+1)]*(n1+1)
#print(dp)
#dp[0][0...n2]的初始值
for j in range(1,n2+1):
dp[0][j] = dp[0][j-1] + 1
#dp[0...n1][0] 的初始值
for i in range(1,n1+1):
dp[i][0] = dp[i-1][0] + 1
#通过公式推出 dp[n1][n2]
for i in range(1,n1+1):
for j in range(1,n2+1):
#如果 word1[i] 与 word2[j] 相等。第 i 个字符对应下标是 i-1
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = min(dp[i-1][j-1],dp[i-1][j],dp[i][j-1])+1
return dp[n1][n2]
if __name__ == "__main__":
print(minDistance("hello","tensorflow"))
最大子序和
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
(1)定义数组元素的含义
定义向量dp,其中dp[i]表示数组从上一个大于0的和到第i个位置为止的最大子序列和
(2)找出数组元素间的关系式
dp[i+1] = max(dp[i]+nums[i], nums[i])
(3)找出初始条件
dp = [None for _ in range(len(nums))] # 定义dp矩阵
dp[0] = nums[0] # 初始化第一个数
实现
def maxSubArray(nums):
"""
:type nums: List[int]
:rtype: int
"""
dp = [None for _ in range(len(nums))]
dp[0] = nums[0]
for i in range(1,len(nums)):
cur_num = nums[i]
cur_sum = nums[i] + dp[i-1]
dp[i] = max(cur_num,cur_sum)
return max(dp)
最长回文子串
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。
示例 2:
输入: "cbbd"
输出: "bb"
其中f(i,j)表示当s[i:j]子串是否是回文串。
当 j-i<=1时,如果s[i] == s[j]则表示 s[i:j]为回文串,即f(i,j) = true,否则f(i,j) = false。
当 j-i > 1时,则判断 s[i]、s[j]是否相等以及f(i+1, j-1)是否为true,即s[i+1:j-1]是否为回文串,如果为真,则f(i,j) = true
所以就需要一个n*n的二维矩阵用于存储f(i,j)的值,其中 j in range(0, k),i in range(0, j+1),之所以是j+1是因为i可以等于j。
def longestPalindrome(s):
"""
:type s: str
:rtype: str
"""
k = len(s) #计算字符串的长度
#初始化 n*n 的列表
matrix = [[0 for _ in range(k)] for _ in range(k)]
#print(matrix)
longesSubStr = "" #存储最长回文子串
longesLen = 0 #最长回文子串的长度
for j in range(0,k):
for i in range(0,j+1):
if j - i <= 1:
if s[i] == s[j]:
matrix[i][j] = 1
if longesLen < j-i+1: #将s[i:j]的长度当前的回文子串的最长长度相比
longesSubStr = s[i:j+1]# 取当前的最长回文子串
longesLen = j-i+1#当前最长回文子串的长度
else:
if s[i] == s[j] and matrix[i+1][j-1]:
matrix[i][j] = 1
if longesLen < j - i + 1:
longesSubStr = s[i:j+1]
longesLen = j-i+1
return longesSubStr
if __name__ == "__main__":
print(longestPalindrome("babad"))