Leetcode学习--动态规划(8.20-8.22)

动态规划

一、简介

1、主要思想

动态规划的思想比较适用于有重叠子问题和最优子结构性质的问题。主要的思想就是:若要解一个给定问题,我们需要解其不同部分即子问题,再根据子问题的解以得出原问题的解。动态规划法仅仅解决每个子问题一次,一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。

2、动态规划模板步骤:

*确定动态规划状态

*写出状态转移方程(画出状态转移表)

*考虑初始化条件

*考虑输出状态

*考虑对时间,空间复杂度的优化(Bonus)

3、例题

1)、题目

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

示例:

输入: [10,9,2,5,3,7,101,18]
输出: 4 
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4

2)、解题思路

第一步:确定动态规划状态

是否存在状态转移?
什么样的状态比较好转移,找到对求解问题最方便的状态转移?

想清楚到底是直接用需要求的,比如长度作为dp保存的变量还是用某个判断问题的状态比如是否是回文子串来作为方便求解的状态。

该题目可以直接用一个一维数组dp来存储转移状态,dp[i]可以定义为以nums[i]这个数结尾的最长递增子序列的长度。举个实际例子,比如在nums[10,9,2,5,3,7,101,18]中,dp[0]表示数字10的最长递增子序列长度,那就是本身,所以为1,对于dp[5]对应的数字7来说的最长递增子序列是[2,5,7](或者[2,3,7])所以dp[5]=3。

第二步:写出一个好的状态转移方程

使用数学归纳法思维,写出准确的状态方程

比如还是用刚刚那个nums数组,我们思考一下是如何得到dp[5]=3的:既然是递增的子序列,我们只要找到nums[5] (也就是7)前面那些结尾比7小的子序列,然后把7接到最后,就可以形成一个新的递增的子序列,也就是这个新的子序列也就是在找到的前面那些数后面加上7,相当长度加1。当然可能会找到很多不同的子序列,比如刚刚在上面列举的,但是只需要找到长度最长的作为dp[5]的值就行。总结来说就是比较当前dp[i]的长度和dp[i]对应产生新的子序列长度,我们用j来表示所有比i小的组数中的索引,可以用如下代码公式表示。

for i in range(len(nums)):
    for j in range(i):
    	if nums[i]>nums[j]:
    		dp[i]=max(dp[i],dp[j]+1)

Tips: 在实际问题中,如果不能很快得出这个递推公式,可以先尝试一步一步把前面几步写出来,如果还是不行很可能就是 dp 数组的定义不够恰当,需要回到第一步重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组。

第三步:考虑初始条件

​ 这是决定整个程序能否跑通的重要步骤,当我们确定好状态转移方程,我们就需要考虑一下边界值,边界值考虑主要又分为三个地方:

dp数组整体的初始值

dp数组(二维)i=0和j=0的地方

dp存放状态的长度,是整个数组的长度还是数组长度加一,这点需要特别注意。

对于本问题,子序列最少也是自己,所以长度为1,这样我们就可以方便的把所有的dp初始化为1,再考虑长度问题,由于dp[i]代表的是nums[i]​的最长子序列长度,所以并不需要加一。 所以用代码表示就是​*dp=[1]len(nums)​

第四步:考虑输出状态

主要有以下三种形式,对于具体问题,我们一定要想清楚到底dp数组里存储的是哪些值,最后我们需要的是数组中的哪些值:

返回dp数组中最后一个值作为输出,一般对应二维dp问题。

返回dp数组中最大的那个数字,一般对应记录最大值问题。

返回保存的最大值,一般是Maxval=max(Maxval,dp[i])这样的形式。

Tips:这个公式必须是在满足递增的条件下,也就是nums[i]>nums[j]​的时候才能成立,并不是nums[i]​前面所有数字都满足这个条件的,理解好这个条件就很容易懂接下来在输出时候应该是​max(dp)​而不是​dp[-1]​,原因就是dp数组由于计算递增的子序列长度,所以dp数组里中间可能有值会是比最后遍历的数值大的情况,每次遍历nums[j]所对应的位置都是比nums[i]小的那个数。举个例子,比如nums=[1,3,6,7,9,4,10,5,6],而最后dp=[1,2,3,4,5,3,6,4,5]。 总结一下,最后的结果应该返回dp数组中值最大的数。

最后加上考虑数组是否为空的判断条件,下面是该问题完整的代码:

def lengthOfLIS(self, nums: List[int]) -> int:
        if not nums:return 0  #判断边界条件
        dp=[1]*len(nums)      #初始化dp数组状态
        for i in range(len(nums)):
            for j in range(i):
                if nums[i]>nums[j]:   #根据题目所求得到状态转移方程
                    dp[i]=max(dp[i],dp[j]+1)
        return max(dp)  #确定输出状态
第五步:考虑对时间,空间复杂度的优化(Bonus)

切入点: 我们看到,之前方法遍历dp列表需要 O ( N ) O(N) O(N),计算每个dp[i]需要 O ( N ) O(N) O(N)的时间,所以总复杂度是 O ( N 2 ) O(N^2) O(N2)

前面遍历dp列表的时间复杂度肯定无法降低了,但是我们看后面在每轮遍历[0,i]的dp[i]​元素的时间复杂度可以考虑设计状态定义,使得整个dp为一个排序列表,这样我们自然想到了可以利用二分法来把时间复杂度降到了 O ( N l o g N ) O(NlogN) O(NlogN)。这里由于篇幅原因,如果大家感兴趣的话详细的解题步骤可以看好心人写的二分方法+动态规划详解
模板总结:

        for i in range(len(nums)):
            for j in range(i):
                    dp[i]=最值(dp[i],dp[j]+...)

二、刷题练习

1、最长回文子串

1)、题目

给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

示例 1:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。

示例 2:
输入: "cbbd"
输出: "bb"

2)、思路

1.什么是回文串?

对于一个子串而言,如果它是回文串,并且长度大于 2,那么将它首尾的两个字母去除之后,它的子串仍然是个回文串。首先,单个字符一定是回文子串,比如a。其次若回文串的长度为2或者为3,那么只要他左右两个边界处字符相等,那么他也是回文串(无需验证去掉边界后的子串)。对于字符串 ababa,如果我们已经知道 bab 是回文串,那么ababa 一定是回文串,这是因为它的首尾两个字母都是 a。

2.动态规划

我们可以用动态规划的方法解决本题。我们用 P(i,j) 表示字符串s的第i到j个字母组成的串,下文表示成 s[i:j]是否为回文串
在这里插入图片描述
其他情况可能是:

1.s[i,j] 本身不是一个回文串;

2.i > j,此时 s[i, j] 本身不合法。

因此动态规划的状态转移方程就是:
在这里插入图片描述
也就是说,只有 s[i+1:j-1] 是回文串,并且 s的第 i 和 j 个字母相同时,s[i:j]才会是回文串。

3)、代码

class Solution:
    def longestPalindrome(self, s: str) -> str:
        n = len(s)
        dp = [[False] * n for _ in range(n)]
        ans = ""
        # 枚举子串的长度 l+1
        for l in range(n):
            # 枚举子串的起始位置 i,这样可以通过 j=i+l 得到子串的结束位置
            for i in range(n):
                #dp[i][j]表示字串s[i...j]是否为回文子串
                j = i + l
                if j >= len(s):
                    break
                if l == 0:
                    #对角线上的字符串一定是回文,因此直接赋值为ture
                    dp[i][j] = True
                elif l == 1:
                    #单个字符一定是回文,dp[i][j]表示字串s[ij]是回文(s[i] == s[j])
                    dp[i][j] = (s[i] == s[j])
                else:
                    #状态转移方程:dp[i][j]在去掉了两边的边界值之后依然是回文
                    dp[i][j] = (dp[i + 1][j - 1] and s[i] == s[j])
                #边界值
                if dp[i][j] and l + 1 > len(ans):
                    ans = s[i:j+1]
        return ans

4)、来源

Leetcode(力扣):https://leetcode-cn.com/problems/longest-palindromic-substring/

2、编辑距离

1)、题目

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:

插入一个字符
删除一个字符
替换一个字符
示例 1:
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse ('h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

示例 2:
输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention ('i' 替换为 'e')
enention -> exention ('n' 替换为 'x')
exention -> exection ('n' 替换为 'c')
exection -> execution (插入 'u')

2)、思路

由于本题有两个字符串,所以可以用二维数组来保存转移状态,定义dp[i][j]为字符串word1长度为i和字符串word2长度为j时,word1转化成word2所执行的最少操作次数的值。
思路具体为:
1.当 word1[i] == word2[j]时,dp[i][j] = dp[i-1][j-1];
2.当 word1[i] != word2[j]时,dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1

其中,dp[i-1][j-1] 表示替换操作,dp[i-1][j] 表示删除操作,dp[i][j-1] 表示插入操作。

3)、代码

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        n1 = len(word1)
        n2 = len(word2)
        dp = [[0] * (n2 + 1) for _ in range(n1 + 1)]
        # 第一行
        for j in range(1, n2 + 1):
            dp[0][j] = dp[0][j-1] + 1
        # 第一列
        for i in range(1, n1 + 1):
            dp[i][0] = dp[i-1][0] + 1
        for i in range(1, n1 + 1):
            for j in range(1, n2 + 1):
                if word1[i-1] == word2[j-1]:
                    dp[i][j] = dp[i-1][j-1]
                else:
                    dp[i][j] = min(dp[i][j-1], dp[i-1][j], dp[i-1][j-1] ) + 1
        return dp[-1][-1]

4)、来源

Leetcode(力扣):https://leetcode-cn.com/problems/edit-distance/

3、打家劫舍

1)、题目

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4 。
     
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12

2)、思路

本题可以采用滑动窗口的方式思考。如果房屋数量大于两间,应该如何计算能够偷窃到的最高总金额呢?对于第 k(k>2)间房屋,有两个选项:
1.偷窃第 k 间房屋,那么就不能偷窃第 k-1 间房屋,偷窃总金额为前 k-2 间房屋的最高总金额与第 k 间房屋的金额之和。
2.不偷窃第 k 间房屋,偷窃总金额为前 k-1 间房屋的最高总金额。
在两个选项中选择偷窃总金额较大的选项,该选项对应的偷窃总金额即为前 k 间房屋能偷窃到的最高总金额。
用 dp[i] 表示前 i 间房屋能偷窃到的最高总金额,那么就有如下的状态转移方程:
在这里插入图片描述
那么对于最简单的情况:只有一间房子,那就只能偷这间,如果有两间房子,那就偷数额最大的一间
在这里插入图片描述

3)、代码

class Solution:
    def rob(self, nums: list[int]) -> int:
        if not nums:
            return 0

        size = len(nums)
        if size == 1:
            return nums[0]

        dp = [0] * size
        dp[0] = nums[0]
        dp[1] = max(nums[0], nums[1])
        for i in range(2, size):
            dp[i] = max(dp[i - 2] + nums[i], dp[i - 1])

        return dp[size - 1]

4)、来源

Leetcode(力扣):https://leetcode-cn.com/problems/house-robber/

4、打家劫舍Ⅱ

1)、题目

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例:

示例 1:
输入: [2,3,2]
输出: 3
解释: 你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2, 因为他们是相邻的。

示例 2:
输入: [1,2,3,1]
输出: 4
解释: 你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

2)、思路

本题与上题的区别就是房子是环形排列的。

  • 状态定义
    设动态规划列表 dp ,dp[i]代表前 i 个房子在满足条件下的能偷窃到的最高金额。
  • 转移方程
    1.设: 有 n 个房子,前 n 间能偷窃到的最高金额是 dp[n] ,前 n−1 间能偷窃到的最高金额是 dp[n−1] ,此时向这些房子后加一间房,此房间价值为 num ;
    2.加一间房间后: 由于不能抢相邻的房子,意味着抢第 n+1 间就不能抢第 n 间;那么前 n+1 间房能偷取到的最高金额 dp[n+1] 一定是以下两种情况的 较大值 :
    不抢第 n+1 个房间,因此等于前 n 个房子的最高金额,即 dp[n+1] = dp[n];
    抢第 n+1 个房间,此时不能抢第 n 个房间;因此等于前 n-1个房子的最高金额加上当前房间价值,即 dp[n+1] = dp[n-1] + num
    3. 难道在前 n 间的最高金额 dp[n]情况下,第 n 间一定被偷了吗?假设没有被偷,那 n+1 间的最大值应该也可能是 dp[n+1] = dp[n] + num吧?其实这种假设的情况可以被省略,这是因为:
    假设第 n 间没有被偷,那么此时 dp[n] = dp[n-1],此时 dp[n+1] = dp[n] + num = dp[n-1] + num ,即可以将 两种情况合并为一种情况 考虑;
    假设第 n 间被偷,那么此时 dp[n+1] = dp[n] + num 不可取 ,因为偷了第 n 间就不能偷第 n+1间。

4.因此最终的转移方程为:
在这里插入图片描述

3)、代码

class Solution:
    def rob(self, nums: [int]) -> int:
        def my_rob(nums):
            cur, pre = 0, 0
            for num in nums:
                cur, pre = max(pre + num, cur), cur
            return cur
        return max(my_rob(nums[:-1]),my_rob(nums[1:])) if len(nums) != 1 else nums[0]

4)、来源

Leetcode(力扣):https://leetcode-cn.com/problems/house-robber-ii/

5、最长回文子序列

1)、题目

给定一个字符串s,找到其中最长的回文子序列。可以假设s的最大长度为1000。
示例

示例 1:
输入:
"bbbab"

输出:
4

一个可能的最长回文子序列为 "bbbb"。

示例 2:
输入:
"cbbd"

输出:
2

一个可能的最长回文子序列为 "bb"

2)、思路

本题与第一道最长回文子串非常相似。可以建一个二维数组dp[i][j],按照第一题的方式来解题。

3)、代码

class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        n = len(s)
        dp = [[0] * n for _ in range(n)]
        for i in range(n):
            dp[i][i] = 1
            for j in range(i - 1, -1, -1):
                if s[j] == s[i]:
                    dp[j][i] = 2 + dp[j + 1][i - 1]
                else:
                    dp[j][i] = max(dp[j + 1][i], dp[j][i - 1])
        return dp[0][n - 1]

4)、来源

Leetcode(力扣):https://leetcode-cn.com/problems/longest-palindromic-subsequence/

6、最长连续递增序列

1)、题目

给定一个未经排序的整数数组,找到最长且连续的递增序列,并返回该序列的长度。
示例:

示例 1:
输入: [1,3,5,4,7]
输出: 3
解释: 最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为57在原数组里被4隔开。 

示例 2:
输入: [2,2,2,2,2]
输出: 1
解释: 最长连续递增序列是 [2], 长度为1

2)、思路

第一步:可以设一个数组dp[i]代表最长连续递增序列,对于这个问题,我们的状态dp[i]也是以nums[i]这个数结尾的最长递增子序列的长度。
第二步:写出状态转移方程:

  • 第一种情况是如果遍历到的数nums[i]后面一个数不是比他大或者前一个数不是比他小,也就是所谓的不是连续的递增,那么这个数列最长连续递增序列就是他本身,也就是长度为1。
  • 第二种情况就是如果满足有递增序列,就意味着当前状态只和前一个状态有关,dp[i]只需要在前一个状态基础上加一就能得到当前最长连续递增序列的长度。
  • 总结起来,状态的转移方程可以写成 dp[i]=dp[i-1]+1

3)、代码

def findLengthOfLCIS(self, nums: list[int]) -> int:
    # 判断边界条件
    if not nums: return 0
    # 初始化dp数组状态
    dp = [1] * len(nums)
    # 注意需要得到前一个数,所以从1开始遍历,否则会超出范围
    for i in range(1, len(nums)):
        # 根据题目所求得到状态转移方程
        if nums[i] > nums[i - 1]:
            dp[i] = dp[i - 1] + 1
        else:
            dp[i] = 1
    #返回最大值
    return max(dp)

4)、来源

Leetcode(力扣):https://leetcode-cn.com/problems/longest-continuous-increasing-subsequence/

你可能感兴趣的:(算法,leetcode,python)