动态规划

动态规划

动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。

主要思想

若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题,例如斐波那契数列,如果运用递归的方式来求解会重复计算很多相同的子问题,利用动态规划的思想可以减少计算量。

动态规划法仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量,

一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。

动态规划模板步骤:

确定动态规划状态

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

考虑初始化条件

考虑输出状态

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

Leetcode 300.最长上升子序列

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

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

解题思路

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

  • 是否存在状态转移?

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

想清楚到底是直接用需要求的,比如长度作为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)​

**Tips:**还有一点需要注意,找到一个方便的状态转移会使问题变得非常简单。举个例子,对于Leetcode120.三角形最小路径和问题,大多数人刚开始想到的应该是自顶向下的定义状态转移的思路,也就是从最上面的数开始定义状态转移,但是这题优化的解法则是通过定义由下到上的状态转移方程会大大简化问题,同样的对于Leetcode53.最大子序和也是采用从下往上遍历,保证每个子问题都是已经算好的。这个具体我们在题目中会讲到。

这里额外总结几种Python常用的初始化方法:

  • 对于产生一个全为1,长度为n的数组:
1. dp=[1 for _ in range(n)]
2. dp=[1]*n
  • 对于产生一个全为0,长度为m,宽度为n的二维矩阵:
1. dp=[[0 for _ in range(n)] for _ in range(m)]
2. dp=[[0]*n for _ in range(m)]

第四步:考虑输出状态

主要有以下三种形式,对于具体问题,我们一定要想清楚到底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]+...)

Leetcode 674.最长连续递增序列

题目描述
给定一个未经排序的整数数组,找到最长且连续的的递增序列。
示例 1: 输入: [1,3,5,4,7] 输出: 3 解释: 最长连续递增序列是 [1,3,5], 长度为3。 尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为5和7在原数组里被4隔开

解题思路
这道题是不是一眼看过去和上题非常的像,没错了,这个题目最大的不同就是连续两个字,这样就让这
个问题简单很多了,因为如果要求连续的话,那么就不需要和上题一样遍历两遍数组,只需要比较前后
的值是不是符合递增的关系。

  • 第一步:确定动态规划状态
    对于这个问题,我们的状态dp[i]也是以nums[i]这个数结尾的最长递增子序列的长度
  • 第二步:写出状态转移方程
    这个问题,我们需要分两种情况考虑,第一种情况是如果遍历到的数 nums[i] 后面一个数不是比
    他大或者前一个数不是比他小,也就是所谓的不是连续的递增,那么这个数列最长连续递增序列就
    是他本身,也就是长度为1。
    第二种情况就是如果满足有递增序列,就意味着当前状态只和前一个状态有关, dp[i] 只需要在
    前一个状态基础上加一就能得到当前最长连续递增序列的长度。总结起来,状态的转移方程可以写
    成dp[i]=dp[i-1]+1
  • 第三步:考虑初始化条件
    和上面最长子序列相似,这个题目的初始化状态就是一个一维的全为1的数组。
  • 第四步:考虑输出状态
    与上题相似,这个问题输出条件也是求dp数组中最大的数。
  • 第五步:考虑是否可以优化
    这个题目只需要一次遍历就能求出连续的序列,所以在时间上已经没有可以优化的余地了,空间上
    来看的话也是一维数组,并没有优化余地。
    综上所述,可以很容易得到最后的代码:
def findLengthOfLCIS(self, nums: List[int]) -> int:
        if not nums:return 0  #判断边界条件
        dp=[1]*len(nums)      #初始化dp数组状态
        #注意需要得到前一个数,所以从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)  #确定输出状态

C++ 代码

int findLengthOfLCIS(vector<int>& nums) {
        int res = 0, cnt = 0;
        for(int i = 0; i < nums.size(); i++){
            if(i == 0 || nums[i-1] < nums[i]) res = max(res, ++cnt);
            else cnt = 1;
        }
        return res;
    }

总结: 通过这个题目和例题的比较,我们需要理清子序列和子数组(连续序列)的差别,前者明显比后
者要复杂一点,因为前者是不连续的序列,后者是连续的序列,从复杂度来看也很清楚能看到即使穷举
子序列也比穷举子数组要复杂很多。

Leetcode5. 最长回文子串

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

示例 1:
输入: “babad”
输出: “bab”
注意: “aba” 也是一个有效答案。
对于这个问题,时间和空间都可以进一步优化,对于空间方面的优化:这里采用一种叫中心扩散的方法来进行,而对于时间方面的优化,则是用了Manacher‘s Algorithm(马拉车算法)来进行优化。具体的实现可以参考动态规划、Manacher 算法

这里给出比较容易理解的经典方法的代码:

def longestPalindrome(self, s: str) -> str:
        length=len(s)
        if length<2:  #判断边界条件
            return s
        dp=[[False for _ in range(length)]for _ in range(length)] #定义dp状态矩阵
        #定义初试状态,这步其实可以省略
        # for i in range(length):
        #     dp[i][i]=True
        
        max_len=1
        start=0 #后续记录回文串初试位置
        for j in range(1,length):
            for i in range(j):
                #矩阵中逐个遍历
                if s[i]==s[j]:
                    if j-i<3:
                        dp[i][j]=True
                    else:
                        dp[i][j]=dp[i+1][j-1]
                if dp[i][j]: #记录位置,返回有效答案
                    cur_len=j-i+1
                    if cur_len>max_len:
                        max_len=cur_len
                        start=i
        return s[start:start+max_len]

C++代码

string longestPalindrome(string s) {
    if (s.empty()) return "";
    if (s.size() == 1) return s;
    int min_start = 0, max_len = 1;
    for (int i = 0; i < s.size();) {
      if (s.size() - i <= max_len / 2) break;
      int j = i, k = i;
      while (k < s.size()-1 && s[k+1] == s[k]) ++k; // Skip duplicate characters.
      i = k+1;
      while (k < s.size()-1 && j > 0 && s[k + 1] == s[j - 1]) { ++k; --j; } // Expand.
      int new_len = k - j + 1;
      if (new_len > max_len) { min_start = j; max_len = new_len; }
    }
    return s.substr(min_start, max_len);
}

总结:这个是一个二维dp的经典题目,需要注意的就是定义dp数组的状态是什么,这里不用长度作为dp值而用是否是回文子串这个状态来存储也是一个比较巧妙的方法,使得题目变得容易理解。

Leetcode516. 最长回文子序列

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

示例 1:
输入:
“bbbab”
输出:
4
这里给出基本版的实现代码,如果需要优化后的可以看空间压缩优化解法

def longestPalindromeSubseq(self, s: str) -> int:
        n=len(s)
        dp=[[0]*n for _ in range(n)]  #定义动态规划状态转移矩阵
        for i in range(n):  #   初始化对角线,单个字符子序列就是1
            dp[i][i]=1
        for i in range(n,-1,-1):  #从右下角开始往上遍历
            for j in range(i+1,n):
                if s[i]==s[j]:   #当两个字符相等时,直接子字符串加2
                    dp[i][j]= dp[i+1][j-1]+2  
                else:           #不相等时,取某边最长的字符
                    dp[i][j]=max(dp[i][j-1],dp[i+1][j])
        return dp[0][-1]   #返回右上角位置的状态就是最长

蛮力法:如果字符串的两端相同,那么它们必须包含在最长的回文子序列中。否则,两端都不能包含在最长回文子序列中。

int longestPalindromeSubseq(string s) {
        return longestPalindromeSubseq(0,s.size()-1,s); 
    }
    int longestPalindromeSubseq(int l, int r, string &s) {
        if(l==r) return 1;
        if(l>r) return 0;  //happens after "aa" 
        return s[l]==s[r] ? 2 + longestPalindromeSubseq(l+1,r-1, s) : 
            max(longestPalindromeSubseq(l+1,r, s),longestPalindromeSubseq(l,r-1, s)); 
    }

备忘录法:

int longestPalindromeSubseq(string s) {
        int n = s.size();
        vector<vector<int>> mem(n,vector<int>(n));
        return longestPalindromeSubseq(0,n-1, s,mem); 
    }
    int longestPalindromeSubseq(int l, int r, string &s, vector<vector<int>>& mem) {
        if(l==r) return 1;
        if(l>r) return 0;
        if(mem[l][r]) return mem[l][r];
        return mem[l][r] = s[l]==s[r] ? 2 + longestPalindromeSubseq(l+1,r-1, s,mem) : 
            max(longestPalindromeSubseq(l+1,r, s,mem),longestPalindromeSubseq(l,r-1, s,mem)); 
        
    }

dp:

  int longestPalindromeSubseq(string s) {
        int n = s.size();
        vector<vector<int>> dp(n+1,vector<int>(n));
        for(int i=0;i<n;i++) dp[1][i]=1;
        for(int i=2;i<=n;i++) //length
            for(int j=0;j<n-i+1;j++) {//start index 
                dp[i][j] = s[j]==s[i+j-1]?2+dp[i-2][j+1]:max(dp[i-1][j],dp[i-1][j+1]);
        return dp[n][0]; 
    }

总结:对于二维的数组的动态规划,采用了画状态转移表的方法来得到输出的状态,这种方法更加直观能看出状态转移的具体过程,同时也不容易出错。当然具体选择哪种方法则需要根据具体题目来确定,如果状态转移方程比较复杂的利用这种方法就能简化很多。
模板总结:

for i in range(len(nums)):
            for j in range(n):
            	if s[i]==s[j]:
                    dp[i][j]=dp[i][j]+...
                else:
                	dp[i][j]=最值(...)

Leetcode72. 编辑距离

给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。

题目描述
你可以对一个单词进行如下三种操作:

插入一个字符
删除一个字符
替换一个字符
示例 1:

输入: word1 = “horse”, word2 = “ros”
输出: 3
解释:
horse -> rorse (将 ‘h’ 替换为 ‘r’)
rorse -> rose (删除 ‘r’)
rose -> ros (删除 ‘e’)

def minDistance(self, word1, word2):
        #m,n 表示两个字符串的长度
        m=len(word1) 
        n=len(word2)
        #构建二维数组来存储子问题
        dp=[[0 for _ in range(n+1)] for _ in range(m+1)]
        #考虑边界条件,第一行和第一列的条件
        for i in range(n+1):
            dp[0][i]=i  #对于第一行,每次操作都是前一次操作基础上增加一个单位的操作
        for j in range(m+1):
            dp[j][0]=j #对于第一列也一样,所以应该是1,2,3,4,5...
        for i in range(1,m+1):  #对其他情况进行填充
            for j in range(1,n+1):
                if word1[i-1]==word2[j-1]: #当最后一个字符相等的时候,就不会产生任何操作代价,所以与dp[i-1][j-1]一样
                    dp[i][j]=dp[i-1][j-1]
                else:
                    dp[i][j]=min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])+1 #分别对应删除,添加和替换操作
        return dp[-1][-1] #返回最终状态就是所求最小的编辑距离

c++:

class Solution {
public:
    int minDistance(string word1, string word2) {
        int m = word1.size(), n = word2.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
        for (int i = 1; i <= m; i++) {
            dp[i][0] = i;
        }
        for (int j = 1; j <= n; j++) {
            dp[0][j] = j;
        }
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                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], min(dp[i][j - 1], dp[i - 1][j])) + 1;
                }
            }
        }
        return dp[m][n];
    }
};

Leetcode198. 打家劫舍

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

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

示例 1:

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

def rob(self, nums):
     
        if(not nums):   #特殊情况处理
            return 0
        if len(nums)==1:
            return nums[0]
        n=len(nums)
        dp=[0]*n    #初始化状态转移数组
        dp[0]=nums[0]  #第一个边界值处理
        dp[1]=max(nums[0],nums[1])#第二个边界值处理
        for i in range(2,n):
            dp[i]=max(dp[i-2]+nums[i],dp[i-1]) #状态转移方程
        return dp[-1]

c++:

class Solution {
public:
    int rob(vector<int>& nums) {
        const int n = nums.size();
        if (n == 0) return 0;
        if (n == 1) return nums[0];
        if (n == 2) return max(nums[0], nums[1]);
        vector<int> f(n, 0);
        f[0] = nums[0];
        f[1] = max(nums[0], nums[1]);
        for (int i = 2; i < n; ++i)
            f[i] = max(f[i-2] + nums[i], f[i-1]);
        return f[n-1];
    }
};

Leetcode213. 打家劫舍 II

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

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

示例 1:

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

def rob(self, nums: List[int]) -> int:
        if not nums:
            return 0
        elif len(nums)<=2:
            return max(nums)
        def helper(nums):
            if len(nums)<=2:
                return max(nums)
            dp=[0]*len(nums)
            dp[0]=nums[0]
            dp[1]=max(nums[0],nums[1])
            for i in range(2,len(nums)):
                dp[i]=max(dp[i-1],dp[i-2]+nums[i])
            return dp[-1]
        return max(helper(nums[1:]),helper(nums[:-1]))

这个问题可以简单地分解为两个抢劫犯问题。
假设有n个房子,因为房子0和n - 1现在是邻居,我们不能一起抢劫它们,因此解现在是的最大值
抢劫房屋0到n - 2;
抢劫房屋1到n - 1。
代码如下。一些边界情况(n < 2)需要处理

class Solution {
public:
    int rob(vector<int>& nums) {
        int n = nums.size(); 
        if (n < 2) return n ? nums[0] : 0;
        return max(robber(nums, 0, n - 2), robber(nums, 1, n - 1));
    }
private:
    int robber(vector<int>& nums, int l, int r) {
        int pre = 0, cur = 0;
        for (int i = l; i <= r; i++) {
            int temp = max(pre + nums[i], cur);
            pre = cur;
            cur = temp;
        }
        return cur;
    }
};
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值