写在前面
早就想对这方面的内容写个总结了,因为在LeetCode上刷题时一直碰到了关于动态规划思想的题目,基本上都是被虐做不出来,此时心里一直在后悔当初大学上算法课时为什么不好好听讲,现在就是来还债了。前段时间因为有考试又要做项目(其实还是自己懒),所以一直耽搁了,这几天沉下心来好好学习了这方面的内容并做了一些题目,终于是有所收获,就想着赶紧把它给记录下来,然后分享给大家,希望对大家有些感触。
正文
在网上找关于DP(动态规划,全文皆省略为DP)的帖子时,发现写的模式基本上都是:首先硬搬出百度好DP的定义以及解题过程+几道例题佐证(介绍很少)。全文属于自己总结的东西比较少,不是说借鉴不好,而是没有了自己的思想,这样只会是辜负了读者的期望。而且全文看完,会做的早就会做了,不会做的还是不会做。虽然算法题是得多做才会熟练,但如果一开始就让你把解题的过程了然于胸,那你趟过的坑绝对会少很多。本人一直秉持着仁济天下的态度(咳咳,请不要打断我强行装逼),就是想把关于解DP算法的题目的一些常规套路分享给大家,绝对能让大家是恍然大悟,碰到此类题目大多数都能横着走了(解不了也不要来找我哈,我不会负责的,哈哈哈哈哈哈哈哈)
DP思想简介
常规的思想以及解题套路还是先按百度的来给大家强行安利一波(这可不是打脸哈)。动态规划是:通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。它的主要思想就是:若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。 这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。通俗来说就是:大事化小,小事化无。
我可以先举一个简单的例子,比如要把100元钱给花掉,你可以把它分为两个50元分别让两个人花掉。两个人如果花不掉50元(纯属理想状态),那可以把50元再次划分为25元,一直到每个人可以完全花费完。而动态规划大致就是这个意思,又比如:一个大西瓜你肯定啃不了,但如果你把它且分为十几瓣,你就好吃了。不知道大家脑海里有没有一个大致的印象,我不清楚是不是读者一看就知了,反正我是熟的不能再熟了,哈哈哈哈哈哈哈哈。
DP解题套路初探
前面铺垫了这么多,是时候要开始写真正精彩的干货了,不然装逼的我要被大家给揍了。很多帖子这时候就是直接把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(4)就重复了2次,fib(3)重复了3次。。。随着n的值增大,重复的值将会呈指数次形式增长。这给我们造成了非常大的内存浪费,那有什么方法可以去重呢?可不可以把已经计算过的值给保存起来呢?下次要用到这个值就不用再次计算了,而是直接取出这个值即可。答案肯定是可以的。不然我给你讲这么多干嘛啊(哈哈哈哈哈哈),这里也引出了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)
此处的标记列表为一维的,长度为(n+1)。我特地提出来就是希望大家要好好记住这个关键节点,因为每道DP题目设置的标记列表的维度是不一样的,这个要视题目而定。至于怎么设置维度这儿先卖个关子了。还有列表的长度也是一门学问的,可能有很多读者好奇:为什么这个标记列表的长度不是和给的n值一样呢?其实这儿是有一点套路的,这个方法中体现的还不明显,到了下一个方法就很明显了。主要是为了临界值计算的方便。
自下而上检索法
这个方法就是根据已知条件一步一步的往结果的方向推,它没有用递归的方法,而是主要用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项值
这张图就比较清楚的解释了标记列表memo列表长度设为n+1的原因。我之前其实也是挺反感把这个长度设为n+1的,题目做了一些之后才发现这个的妙用。还有一点需要强调:并不是所有的标记列表党的长度都设为n+1的,这个要视叠加项而定。这一题是fib(n)=fib(n-1)+fib(n-2)是前两项叠加得出后一项的,如果fib(n)=fib(n-1)+fib(n-2)+fib(n-3),那么标记列表memo列表长度应设为n+2
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算法题目要出难,就是在这个点上疯狂的恶心你,会让你考虑各种情况,所以得需大家保持耐心的找好状态转移方式了。刚开始不好弄,但多见几种题目,就慢慢有感觉了
可能我说了这么多,各位读者可能还是一脸雾水,觉得我写的比较抽象。而且我相信各位读者肯定会疯狂diss我,就给一道简单的不能再简单的题目,就算你讲出花来了,我还是不会做其他的题目。这小编就是辣鸡,只知道捡软柿子捏,哈哈哈哈哈哈哈,为了堵住大家的嘴,我再LeetCode上面选取了几道题目,用来堵住大家的嘴。
DP解题套路深挖
- LeetCode上第91题-解码方法
题目如下:
一条包含字母 A-Z
的消息通过以下方式进行了编码:
'A' -> 1
'B' -> 2
...
'Z' -> 26
给定一个只包含数字的非空字符串,请计算解码方法的总数。
示例 1:
输入: "12"
输出: 2
解释: 它可以解码为 "AB"(1 2)或者 "L"(12)。
示例 2:
输入: "226"
输出: 3
解释: 它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 。
思路:
这一题其实和爬楼梯题目很类似,爬楼梯题目是:每次可以跳一级或二级台阶。本题是本次可以解码一个或连续两个字符,但它比爬楼梯题目难的是当前一个或两个字符的大小可能不在规定范围内[1, 26],此时就要对这些情况进行讨论了,但总体的解题步骤还是按照我上面总结的来。
这里面的特殊情况就有字符串里含0情况,可能只有1个0,也可能有两个0,三个0.。。。对于这些情况,我们就得仔细考虑清楚了。
还有一种特殊情况是:连续两个字符的值大于26,这种情况我会在下面给出的转移方程中给出。即第n个字符对应的转码数为code(n),则方程表达式为:
这是我给的分类情况讨论,网上肯定是有很多大佬给的版本比我的要简便,大家只要记住把分类情况理清楚就行。还有就是肯定会有人说:我上面说的连续出现多个0的情况,你好像没有写出来,别急,我已经预处理了,在代码中可以很清楚的看到。
代码如下:
class Solution:
# 本题采用动态规划的方法,与爬楼梯题目类似,转移方程几乎相同
def numDecodings(self, s):
"""
:type s: str
:rtype: int
"""
if len(s) == 0:
return 0
# 定义标记列表,用来记录字符串s中每个字符所对应的组合情况
flag_list = [0]*(len(s)+2)
flag_list[0] = 0
flag_list[1] = 1
# 如果字符串s的首字符为0,或者字符串s中连续出现了'00'子字符串,则直接返回0
if s[0] == '0' or '00' in s:
return 0
for index in range(2, len(s)+2):
# 主要是考虑到字符串s首字符的计算
start = max(index-3, 0)
end = index - 1
# 如果当前字符为'0'而前面的字符为大于2的数,则无法转换,返回0;这一步很关键,也是本题的陷阱所在
if s[end-1] == '0' and s[end-2] > '2':
return 0
# 如果当前字符位于[1,9]区间内,而前一个字符与其组成的二字符串的大小位于[1,26]区间内,则其转换的数量为以下公式
if '1' <= s[start:end] <= '26' and '1' <= s[end-1] <= '9':
flag_list[index] = flag_list[index-2] + flag_list[index-1]
elif '1' <= s[start:end] <= '26' and int(s[end-1]) not in range(1, 10):
flag_list[index] = flag_list[index - 2]
elif int(s[start:end]) not in range(1, 27) and '1' <= s[end-1] <= '9':
flag_list[index] = flag_list[index - 1]
else:
flag_list[index] = flag_list[index - 1]
return flag_list[len(s)+1]
if __name__ == "__main__":
s = "230"
combination_nums = Solution().numDecodings(s)
print(combination_nums)
大家可以看到:我们这儿设置的标记列表是一维形式的,做了两道题之后,不知道大家有没有点感触。就是凡是给定的变量是类似于一维形式的表达,我们设置的标记列表都是一维形式,斐波纳契数列题目给的是一个n值,这个本身就可以理解为List[1,,,n]的列表,这一题给的是一个字符串,也是类似于一维形式。那标记列表与给定的变量之间有什么共通的地方呢?有,就是之前也提到过的,标记列表里的值表示的是给定变量所代表的一维列表中每个位置上数值的相关信息,就是一一对应的关系,所以标记列表的维度是与给定变量所代表的维度是基本一致的。注意注意:这里说的是基本一致,有些题目维度可能还会有所不同的,我也碰到过,但还是比较少,所以大家刚开始还是可以按照我说的这个方法来,屡试不爽。
而且大家看到:我写的过程基本上都是我之前总结的解题步骤来的,那三步在我的代码里都可以体现出来。核心的就是那个状态转移方程了,这个只要一找出来,题目是分分钟解决。大家可以放心使用我的程式三步法。
2. LeetCode第63题-不同路径II
题目如下:
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1
和 0
来表示。
说明:m 和 n 的值均不超过 100。
说明:m 和 n 的值均不超过 100。
示例 1:
输入:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
输出: 2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右
思路:
这一题给定的变量为二维矩阵,所以我们的标记矩阵也是二维的。即当前(i, j)位置上点对应的路径数为path[i][j],当前(i, j)位置上的点的值记为map[i][j],如果map[i][j]==1, 则path[i][j]=0;如果map[i][j]==0, 则path[i][j]=path[i-1][j]+path[i][j-1]这个是理想情况,具体的分类情况见下图:
这题的分类情况不是很多,还是挺容易理解的,直接贴代码吧。
代码如下:
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid):
"""
:type obstacleGrid: List[List[int]]
:rtype: int
"""
# 分别获取obstacleGrid矩阵的宽度和高度
m, n = len(obstacleGrid), len(obstacleGrid[0])
# 获取obstacleGrid矩阵第一列的元素
first_column = [col[0] for col in obstacleGrid]
# 该数组用来保存从起始点到某一个点的路线数量
paths_map = []
# 对paths_map初始化
for index in range(m):
paths_map.append([0]*n)
for row in range(m):
for col in range(n):
# 如果当前没有障碍物,则说明该位置可以通过,反之则不能通过,路线数量肯定是0
if obstacleGrid[row][col] == 0:
# 如果当前位置是在第一行或第一列上,发现该位置之前没有1元素(即障碍物),则赋值为1,反之则为0
if (row == 0 and 1 not in obstacleGrid[0][:col+1]) or (col == 0 and 1 not in first_column[:row+1]):
paths_map[row][col] = 1
else:
paths_map[row][col] = paths_map[row - 1][col] + paths_map[row][col - 1]
return int(paths_map[m - 1][n - 1])
if __name__ == "__main__":
obstacleGrid = [[0, 0], [1, 1], [0, 0]]
paths = Solution().uniquePathsWithObstacles(obstacleGrid)
print(paths)
3. LeetCode第32题-最长有效括号
这一题算是进阶题了,我不打算讲了,留给大家巩固用吧,方法都是一样的,可不是我偷懒嚯(你要这么想我也没有办法,哈哈哈哈哈哈)
题目:
给定一个只包含 '('
和 ')'
的字符串,找出最长的包含有效括号的子串的长度。
示例 1:
输入: "(()"
输出: 2
解释: 最长有效括号子串为 "()"
示例 2:
输入: ")()())"
输出: 4
解释: 最长有效括号子串为 "()()"
代码如下:
class Solution(object):
# 本题采用动态规划的方法
def longestValidParentheses(self, s):
"""
:type s: str
:rtype: int
"""
if len(s) <= 1:
return 0
s_length = len(s)
# 定义一标记列表,用来记录括号串s中每个括号对应的有效括号的信息
flag_list = [0]*(s_length+1)
# 定义括号串s中有效括号的最大长度
max_length = 0
# 从字符串s的第一个字符开始遍历整个字符串
for index in range(1, len(s)):
if s[index] == ')':
LastValid_index = index - flag_list[index] - 1
if s[index-1] == '(':
flag_list[index + 1] = flag_list[index-1] + 2
elif LastValid_index >= 0 and s[LastValid_index] == '(':
flag_list[index+1] = flag_list[index]+2+flag_list[LastValid_index]
else:
flag_list[index + 1] = 0
else:
flag_list[index + 1] = 0
max_length = max(max_length, flag_list[index+1])
return max_length
if __name__ == "__main__":
s = "()(())"
max_str_length = Solution().longestValidParentheses(s)
print(max_str_length)
总结
讲到这儿算是告一段落了,总算是把自己的一桩心事给了结了。当然DP算法的题目还有一些类型我没有讲到,感觉也很难概括到,这个确确实实是要大家自己去接触了,但这并不说我给的方法就不能适用了,我觉得万变不离其宗,信小编得永生,哈哈哈哈哈哈。开个玩笑,但我确实要声明一点:我写的东西都是自己总结的,本人学识有限,讲的知识点难免会有疏漏或错误之处,如若各位读者发现,还望不吝赐教!