算法题总结之动态规划

动态规划

斐波那契数列

爬楼梯

70. Climbing Stairs (Easy)

题目描述:有 N 阶楼梯,每次可以上一阶或者两阶,求有多少种上楼梯的方法。

定义一个数组 dp 存储上楼梯的方法数(为了方便讨论,数组下标从 1 开始),dp[i] 表示走到第 i 个楼梯的方法数目。第 i 个楼梯可以从第 i-1 和 i-2 个楼梯再走一步到达,走到第 i 个楼梯的方法数为走到第 i-1 和第 i-2 个楼梯的方法数之和。

img

考虑到 dp[i] 只与 dp[i - 1] 和 dp[i - 2] 有关,因此可以只用两个变量来存储 dp[i - 1] 和 dp[i - 2],使得原来的 O(N) 空间复杂度优化为 O(1) 复杂度。

class Solution {
public:
    int climbStairs(int n) {
        
        if(n<3)
            return n:
        int fibn;
        int fibMinus1=2;
        int fibMinus2=1;
        for(int i=3;i<=n;++i)
        {
            fibn=fibMinus1+fibMinus2;           
            fibMinus2=fibMinus1;//注意赋值顺序
            fibMinus1=fibn;
        }
        return fibn;        
    }
};

强盗抢劫

198. House Robber (Easy)

题目描述:抢劫一排住户,但是不能抢邻近的住户,求最大抢劫量。

这道题的本质相当于在一列数组中取出一个或多个不相邻数,使其和最大。那么我们对于这类求极值的问题首先考虑动态规划Dynamic Programming来解,我们维护一个一位数组dp,其中dp[i]表示到i位置时不相邻数能形成的最大和,那么状态转移方程怎么写呢,我们先拿一个简单的例子来分析一下,比如说nums为{3, 2, 1, 5},那么我们来看我们的dp数组应该是什么样的,首先dp[0]=3没啥疑问,再看dp[1]是多少呢,由于3比2大,所以我们抢第一个房子的3,当前房子的2不抢,所以dp[1]=3,那么再来看dp[2],由于不能抢相邻的,所以我们可以用再前面的一个的dp值加上当前的房间值,和当前房间的前面一个dp值比较,取较大值当做当前dp值,所以我们可以得到状态转移方程dp[i] = max(num[i] + dp[i - 2], dp[i - 1]), 由此看出我们需要初始化dp[0]和dp[1],其中dp[0]即为num[0],dp[1]此时应该为max(num[0], num[1]),代码如下:

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

优化空间复杂度后的代码:

class Solution {
public:
    int rob(vector<int>& nums) {
        int pre1=0;
        int pre2=0;
        int cur=0;
        for(int i=0;i<nums.size();++i)
        {
            cur=max(pre1,pre2+nums[i]);
            pre2=pre1;
            pre1=cur;
        }
        return cur;        
    }
};

强盗在环形街区抢劫

213. House Robber II (Medium)

这道题是之前那道 House Robber 的拓展,现在房子排成了一个圆圈,则如果抢了第一家,就不能抢最后一家,因为首尾相连了,所以第一家和最后一家只能抢其中的一家,或者都不抢,那我们这里变通一下,如果我们把第一家和最后一家分别去掉,各算一遍能抢的最大值,然后比较两个值取其中较大的一个即为所求。那我们只需参考之前的 House Robber 中的解题方法,然后调用两边取较大值,代码如下:

class Solution {
public:
    int rob(vector<int>& nums) {
        if(nums.size()<2)
            return nums.empty()? 0:nums[0];
        return max(core(nums,0,nums.size()-1),core(nums,1,nums.size()));
    }
    int core(vector<int>& nums,int start,int end)
    {
        int pre1=0;
        int pre2=0;
        int cur;
        for(int i=start;i<end;++i)
        {
            cur=max(pre1,pre2+nums[i]);
            pre2=pre1;
            pre1=cur;
        }
        return cur;
    }
};

信件错排

题目描述:有 N 个 信 和 信封,它们被打乱,求错误装信方式的数量。

定义一个数组 dp 存储错误方式数量,dp[i] 表示前 i 个信和信封的错误方式数量。假设第 i 个信装到第 j 个信封里面,而第 j 个信装到第 k 个信封里面。根据 i 和 k 是否相等,有两种情况:

(1)i==k,交换 i 和 k 的信后,它们的信和信封在正确的位置,但是其余 i-2 封信有 dp[i-2] 种错误装信的方式。由于 j 有 i-1 种取值,因此共有 (i-1)*dp[i-2] 种错误装信方式。

(2)i != k,交换 i 和 j 的信后,第 i 个信和信封在正确的位置,其余 i-1 封信有 dp[i-1] 种错误装信方式。由于 j 有 i-1 种取值,因此共有 (i-1)*dp[i-1] 种错误装信方式。综上所述,错误装信数量方式数量为:

img

母牛生产

题目描述:假设农场中成熟的母牛每年都会生 1 头小母牛,并且永远不会死。第一年有 1 只小母牛,从第二年开始,母牛开始生小母牛。每只小母牛 3 年之后成熟又可以生小母牛。给定整数 N,求 N 年后牛的数量。

第 i 年成熟的牛的数量为:img

矩阵路径

矩阵的最小路径和

64. Minimum Path Sum (Medium)

题目描述:求从矩阵的左上角到右下角的最小路径和,每次只能向右和向下移动。

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        int rows=grid.size();
        int cols=grid[0].size();
        int dp[rows][cols];
        dp[0][0]=grid[0][0];
        for(int i=1;i<rows;++i)
            dp[i][0]=grid[i][0]+dp[i-1][0];
        for(int j=1;j<cols;++j)
            dp[0][j]=grid[0][j]+dp[0][j-1];
        for(int i=1;i<rows;++i)
            for(int j=1;j<cols;++j)
                dp[i][j]=grid[i][j]+min(dp[i-1][j],dp[i][j-1]);
        return dp[rows-1][cols-1];
        
    }
};

矩阵的总路径数

62. Unique Paths (Medium)

题目描述:统计从矩阵左上角到右下角的路径总数,每次只能向右或者向下移动。

class Solution {
public:
    int uniquePaths(int m, int n) {
        int dp[m][n];
        for(int i=0;i<m;++i)
            dp[i][0]=1;
        for(int j=0;j<n;++j)
            dp[0][j]=1;
        for(int i=1;i<m;++i)
            for(int j=1;j<n;++j)
                dp[i][j]=dp[i-1][j]+dp[i][j-1];
        return dp[m-1][n-1];
        
    }
};

数组区间

数组区间和

303. Range Sum Query - Immutable (Easy)

求区间 i ~ j 的和,可以转换为 sum[j + 1] - sum[i],其中 sum[i] 为 0 ~ i - 1 的和。

class NumArray {
public:
    NumArray(vector<int> nums) {
        if(!nums.empty())
            dp.push_back(nums[0]);
        for(int i=1;i<nums.size();++i)
            dp.push_back(dp[i-1]+nums[i]);
        
    }
    
    int sumRange(int i, int j) {
        return dp[j]-dp[i-1];
        
    }
private:
    vector<int> dp;
};

/**
 * Your NumArray object will be instantiated and called as such:
 * NumArray obj = new NumArray(nums);
 * int param_1 = obj.sumRange(i,j);
 */

优化后:

class NumArray {
public:
    NumArray(vector<int> &nums) {
        accu.push_back(0);
        for (int num : nums)
            accu.push_back(accu.back() + num);
    }

    int sumRange(int i, int j) {
        return accu[j + 1] - accu[i];
    }
private:
    vector<int> accu;
};

数组中等差递增子区间的个数

413. Arithmetic Slices (Medium)

A = [1, 2, 3, 4]
return: 3, for 3 arithmetic slices in A: [1, 2, 3], [2, 3, 4] and [1, 2, 3, 4] itself.

dp[i] 表示以 A[i] 为结尾的等差递增子区间的个数。

在 A[i] - A[i - 1] == A[i - 1] - A[i - 2] 的条件下,{A[i - 2], A[i - 1], A[i]} 是一个等差递增子区间。如果 {A[i - 3], A[i - 2], A[i - 1]} 是一个等差递增子区间,那么 {A[i - 3], A[i - 2], A[i - 1], A[i]} 也是等差递增子区间,dp[i] = dp[i-1] + 1。

class Solution {
public:
    int numberOfArithmeticSlices(vector<int>& A) {
        int n=A.size();
        vector<int> dp(n,0);
        for(int i=2;i<n;++i)
        {
            if(A[i]-A[i-1]==A[i-1]-A[i-2])
                dp[i]=(dp[i-1]+1);
        }
        int res=0;
        for(auto num:dp)
            res+=num;
        return res;
    }
};

进一步优化空间

class Solution {
public:
    int numberOfArithmeticSlices(vector<int>& A) {
        int n=A.size();
        int cur=0;
        int res=0;
        for(int i=2;i<n;++i)
        {
            if(A[i]-A[i-1]==A[i-1]-A[i-2])
            {
                cur+=1;
                res+=cur;
            }
            else
                cur=0;
        }
        return res;

    }
};

分割整数

分割整数的最大乘积

343. Integer Break (Medim)

题目描述:For example, given n = 2, return 1 (2 = 1 + 1); given n = 10, return 36 (10 = 3 + 3 + 4).

动态规划做法:

class Solution {
public:
    int integerBreak(int n) {
        int res[3]={0,1,2};
        if(n<=3)
            return res[n-1];
        int dp[n+1];
        dp[0]=0;
        dp[1]=1;
        dp[2]=2;
        dp[3]=3;
        for(int i=4;i<=n;++i)
            dp[i]=0;
        for(int i=4;i<=n;++i)
            for(int j=1;j<i;++j)
            {
                dp[i]=max(dp[i],dp[i-j]*dp[j]);
            }
        return dp[n];
        
    }
};

贪心思想:

这道题给了我们一个正整数n,让我们拆分成至少两个正整数之和,使其乘积最大,题目提示中让我们用O(n)来解题,而且告诉我们找7到10之间的规律,那么我们一点一点的来分析:

正整数从1开始,但是1不能拆分成两个正整数之和,所以不能当输出。

那么2只能拆成1+1,所以乘积也为1。

数字3可以拆分成2+1或1+1+1,显然第一种拆分方法乘积大为2。

数字4拆成2+2,乘积最大,为4。

数字5拆成3+2,乘积最大,为6。

数字6拆成3+3,乘积最大,为9。

数字7拆为3+4,乘积最大,为12。

数字8拆为3+3+2,乘积最大,为18。

数字9拆为3+3+3,乘积最大,为27。

数字10拆为3+3+4,乘积最大,为36。

那么通过观察上面的规律,我们可以看出从5开始,数字都需要先拆出所有的3,一直拆到剩下一个数为2或者4,因为剩4就不用再拆了,拆成两个2和不拆没有意义,而且4不能拆出一个3剩一个1,这样会比拆成2+2的乘积小。那么这样我们就可以写代码了,先预处理n为2和3的情况,然后先将结果res初始化为1,然后当n大于4开始循环,我们结果自乘3,n自减3,根据之前的分析,当跳出循环时,n只能是2或者4,再乘以res返回即可:

class Solution {
public:
    int integerBreak(int n) {
        if(n==2||n==3)
            return n-1;
        int res=1;
        while(n>4)
        {
            res*=3;
            n-=3;
        }
        return res*n;
    }
};

按平方数来分割整数

279. Perfect Squares(Medium)

题目描述:For example, given n = 12, return 3 because 12 = 4 + 4 + 4; given n = 13, return 2 because 13 = 4 + 9.

用dp来做,我们建立一个长度为n+1的一维dp数组,将第一个值初始化为0,其余值都初始化为INT_MAX, i从0循环到n,j从1循环到i+j^2 <= n的位置,然后每次更新dp[i+j^2]的值,动态更新dp数组,其中dp[i]表示正整数i能少能由多个完全平方数组成,那么我们求n,就是返回dp[n]即可,也就是dp数组的最后一个数字,参见代码如下:

class Solution {
public:
    int numSquares(int n) {
        vector<int> dp(n+1,INT_MAX);
        dp[0]=0;
        for(int i=0;i<=n;++i)
            for(int j=1;j*j+i<=n;++j)
                dp[i+j*j]=min(dp[i+j*j],dp[i]+1);
        return dp[n];
                
    }
};

分割整数构成字母字符串

91. Decode Ways (Medium)

题目描述:Given encoded message “12”, it could be decoded as “AB” (1 2) or “L” (12)

这道题要求解码方法,跟之前那道 Climbing Stairs 爬梯子问题 非常的相似,但是还有一些其他的限制条件,比如说一位数时不能为0,两位数不能大于26,其十位上的数也不能为0,出去这些限制条件,根爬梯子基本没啥区别,也勉强算特殊的斐波那契数列,当然需要用动态规划Dynamci Programming来解。建立一位dp数组,长度比输入数组长度多1,第一位初始化为1,然后从第二个数开始更新,对应数组的第一个数。对每个数组首先判断其是否为0,若是将改为dp赋0,若不是,赋上一个dp值,此时相当如加上了dp[i - 1], 然后看数组前一位是否存在,如果存在且满足前一位不是0,且和当前为一起组成的两位数不大于26,则当前dp值加上dp[i - 2], 至此可以看出来跟斐波那契数组的递推式一样,代码如下:

class Solution {
public:
    int numDecodings(string s) {
        if(s.empty()||(s.size()==1&&s[0]=='0'))
            return 0;
        vector<int> dp(s.size()+1,0);
        dp[0]=1;
        for(int i=1;i<dp.size();++i)
        {
            dp[i]=(s[i-1]=='0')? 0:dp[i-1];
            if(i>1&&s.substr(i-2,2)>="10"&&s.substr(i-2,2)<="26")
                dp[i]+=dp[i-2];
        }
        return dp.back();
            
    }
};

最长递增子序列

已知一个序列 {S1, S2,…,Sn},取出若干数组成新的序列 {Si1, Si2,…, Sim},其中 i1、i2 … im 保持递增,即新序列中各个数仍然保持原数列中的先后顺序,称新序列为原序列的一个 子序列

如果在子序列中,当下标 ix > iy 时,Six > Siy,称子序列为原序列的一个 递增子序列

定义一个数组 dp 存储最长递增子序列的长度,dp[n] 表示以 Sn 结尾的序列的最长递增子序列长度。对于一个递增子序列 {Si1, Si2,…,Sim},如果 im < n 并且 Sim < Sn,此时 {Si1, Si2,…, Sim, Sn} 为一个递增子序列,递增子序列的长度增加 1。满足上述条件的递增子序列中,长度最长的那个递增子序列就是要找的,在长度最长的递增子序列上加上 Sn 就构成了以 Sn 为结尾的最长递增子序列。因此 dp[n] = max{ dp[i]+1 | Si < Sn && i < n} 。

因为在求 dp[n] 时可能无法找到一个满足条件的递增子序列,此时 {Sn} 就构成了递增子序列,需要对前面的求解方程做修改,令 dp[n] 最小为 1,即:

img

对于一个长度为 N 的序列,最长递增子序列并不一定会以 SN 为结尾,因此 dp[N] 不是序列的最长递增子序列的长度,需要遍历 dp 数组找出最大值才是所要的结果,max{ dp[i] | 1 <= i <= N} 即为所求。

最长递增子序列

300. Longest Increasing Subsequence (Medium)

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        if(nums.empty())
            return 0;
        vector<int> dp(nums.size(),0);
        for(int i=0;i<dp.size();++i)
        {
            int iter_max=1;
            for(int j=0;j<i;++j)
            {
                if(nums[i]>nums[j])
                    iter_max=max(iter_max,dp[j]+1);
            }
            dp[i]=iter_max;
        }
        int final_max=dp[0];
        for(auto num:dp)
        {
            final_max=max(final_max,num);
        }
        return final_max;
    }
};

以上解法的时间复杂度为 O(N2),可以使用二分查找将时间复杂度降低为 O(NlogN)。

二分查找法,思路是先建立一个空的dp数组,然后开始遍历原数组,对于每一个遍历到的数字,我们用二分查找法在dp数组找第一个不小于它的数字,如果这个数字不存在,那么直接在dp数组后面加上遍历到的数字,如果存在,则将这个数字更新为当前遍历到的数字,最后返回dp数字的长度即可,注意的是,跟上面的方法一样,特别注意的是dp数组的值可能不是一个真实的LIS。参见代码如下:

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        vector<int> dp;
        for (auto num:nums) {
            int left = 0, right = dp.size();
            while (left < right) {
                int mid = (right + left) / 2;
                if (dp[mid] < num) left = mid + 1;
                else right = mid;
            }
            if (right == dp.size()) dp.push_back(num);
            else dp[right] = num;
        }
        return dp.size();
    }
};

利用c++ stl中的lower_bound()方法,lower_bound返回数组中第一个不小于指定值的元素,跟上面的算法类似,我们还需要一个一维数组v,然后对于遍历到的nums中每一个元素,找其lower_bound,如果没有lower_bound,说明新元素比一维数组的尾元素还要大,直接添加到数组v中,跟解法二的思路相同了。如果有lower_bound,说明新元素不是最大的,将其lower_bound替换为新元素,这个过程跟算法二的二分查找法的部分实现相同功能,最后也是返回数组v的长度,注意数组v也不一定是真实的LIS,参见代码如下:

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        if(nums.empty())
            return 0;
        vector<int> tails;
        for(auto num:nums)
        {
            auto it = lower_bound(tails.begin(),tails.end(),num);
            if(it == tails.end())
                tails.push_back(num);
            else
                *it=num;
        }
        return tails.size();
    }
};

一组整数对能够构成的最长链

646. Maximum Length of Pair Chain (Medium)

这道题给了我们一些链对,规定了如果后面链对的首元素大于前链对的末元素,那么这两个链对就可以链起来,问我们最大能链多少个。那么我们想,由于规定了链对的首元素一定小于尾元素,我们需要比较的是某个链表的首元素和另一个链表的尾元素之间的关系,如果整个链对数组是无序的,那么就很麻烦,所以我们需要做的是首先对链对数组进行排序,按链对的尾元素进行排序,小的放前面。这样我们就可以利用Greedy算法进行求解了。我们可以用一个栈,先将第一个链对压入栈,然后对于后面遍历到的每一个链对,我们看其首元素是否大于栈顶链对的尾元素,如果大于的话,就将当前链对压入栈,这样最后我们返回栈中元素的个数即可,参见代码如下:

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        vector<int> tails;
        for(auto num:nums)
        {
            int left=0;
            int right=tails.size();
            while(left<right)
            {
                int mid=(right+left)/2;
                if(tails[mid]<num)
                    left=mid+1;
                else
                    right=mid;
            }
            if(right==tails.size())
                tails.push_back(num);
            else
                tails[right]=num;
        }
        return tails.size();
    }
};

最长摆动子序列

376. Wiggle Subsequence (Medium)

这道题直接用greedy在0n复杂度就可以ac,变量p,q分贝记录着上一次下沉/上浮后首次上浮/下沉时的长度。

class Solution {
public:
    int wiggleMaxLength(vector<int>& nums) {
        if(nums.empty())
            return 0;
        int p=1,q=1;
        for(int i=1;i<nums.size();++i)
        {
            if(nums[i]>nums[i-1])
                p=q+1;
            else if(nums[i]<nums[i-1])
                q=p+1;
        }
        return max(p,q);
    }
};

最长公共子序列

对于两个子序列 S1 和 S2,找出它们最长的公共子序列。

定义一个二维数组 dp 用来存储最长公共子序列的长度,其中 dp[i] [j] 表示 S1 的前 i 个字符与 S2 的前 j 个字符最长公共子序列的长度。考虑 S1i 与 S2j 值是否相等,分为两种情况:

  • 当 S1i==S2j 时,那么就能在 S1 的前 i-1 个字符与 S2 的前 j-1 个字符最长公共子序列的基础上再加上 S1i 这个值,最长公共子序列长度加 1,即 dp[i] [j] = dp[i-1] [j-1] + 1。
  • 当 S1i != S2j 时,此时最长公共子序列为 S1 的前 i-1 个字符和 S2 的前 j 个字符最长公共子序列,或者 S1 的前 i 个字符和 S2 的前 j-1 个字符最长公共子序列,取它们的最大者,即 dp[i] [j] = max{ dp[i-1] [j], dp[i] [j-1] }。

综上,最长公共子序列的状态转移方程为:

img

对于长度为 N 的序列 S1 和长度为 M 的序列 S2,dp[N] [M]就是序列 S1 和序列 S2 的最长公共子序列长度。

与最长递增子序列相比,最长公共子序列有以下不同点:

  • 针对的是两个序列,求它们的最长公共子序列。

  • 在最长递增子序列中,dp[i] 表示以 Si 为结尾的最长递增子序列长度,子序列必须包含 Si ;在最长公共子序列中,dp[i] [j] 表示 S1 中前 i 个字符与 S2 中前 j 个字符的最长公共子序列长度,不一定包含 S1i 和 S2j。

  • 在求最终解时,最长公共子序列中 dp[N] [M] 就是最终解,而最长递增子序列中 dp[N] 不是最终解,因为以 SN 为结尾的最长递增子序列不一定是整个序列最长递增子序列,需要遍历一遍 dp 数组找到最大者。

    #include <iostream>
    #include <string>
    #include <vector>
    using namespace std;
    int lcs(string s1, string s2) {
        int len1 = s1.size();
        int len2 = s2.size();
        vector<vector<int>> c(len1+1, vector<int>(len2+1, 0));
        for (int i = 1 ; i <= len1; i++) {
            for(int j = 1; j <=len2 ; j++) {
                if (s1[i-1] == s2[j-1]) c[i][j] = c[i - 1][j - 1] + 1;
                else if (c[i - 1][j] >= c[i][j - 1]) c[i][j] = c[i - 1][j];
                else c[i][j] = c[i][j - 1];
            }
        }
        /* 输出调试
        for (int i = 0 ; i <= len1; i++) {
            for (int j = 0; j <= len2; j++) {
                cout << c[i][j] << " ";
            }
            cout << endl;
        }
        */
        return c[len1][len2];
    }
    
    int main() {
        string s1 = "aefawfawfawfaw";
        string s2 = "aefawfeawfwafwaef";
        cout << lcs(s1, s2); //输出:12
        return 0;
    }
    

0-1 背包

有一个容量为 N 的背包,要用这个背包装下物品的价值最大,这些物品有两个属性:体积 w 和价值 v。

定义一个二维数组 dp 存储最大价值,其中 dp[i][j] 表示前 i 件物品体积不超过 j 的情况下能达到的最大价值。设第 i 件物品体积为 w,价值为 v,根据第 i 件物品是否添加到背包中,可以分两种情况讨论:

  • 第 i 件物品没添加到背包,总体积不超过 j 的前 i 件物品的最大价值就是总体积不超过 j 的前 i-1 件物品的最大价值,dp[i] [j] = dp[i-1] [j]。
  • 第 i 件物品添加到背包中,dp[i] [j] = dp[i-1] [j-w] + v。

第 i 件物品可添加也可以不添加,取决于哪种情况下最大价值更大。因此,0-1 背包的状态转移方程为:

img

public int knapsack(int W, int N, vector<int> weights, vector<int> values)
{
    vector<vector<int>> dp(N+1,vector<int>(W+1,0));
    for(int i=1;i<=N;++i)
    {
        int value=values[i-1],weight=weights[i-1];
        for(int j=1;j<=W;++j)
        {
            if(weight<=j)
                dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight]+value);
            else
                dp[i][j]=dp[i-1][j];
        }
    }
    return dp[N][W];
}
空间优化

在程序实现时可以对 0-1 背包做优化。观察状态转移方程可以知道,前 i 件物品的状态仅与前 i-1 件物品的状态有关,因此可以将 dp 定义为一维数组,其中 dp[j] 既可以表示 dp[i-1] [j] 也可以表示 dp[i] [j]。此时,

img

因为 dp[j-w] 表示 dp[i-1] [j-w],因此不能先求 dp[i] [j-w],以防将 dp[i-1] [j-w] 覆盖。也就是说要先计算 dp[i][j] 再计算 dp[i][j-w],在程序实现时需要按倒序来循环求解。

public int knapsack(int W, int N, vector<int> weights, vector<int> values)
{
    vector<int> dp(W+1,0);
    for(int i=1;i<=N;++i)
    {
        int value=values[i-1],weight=weights[i-1];
        for(int j=W;j>=1;--j)
        {
            if(weight<=j)
                dp[i][j]=max(dp[j],dp[j-weight]+value);
        }
    }
    return dp[M];
}
无法使用贪心算法的解释

0-1 背包问题无法使用贪心算法来求解,也就是说不能按照先添加性价比最高的物品来达到最优,这是因为这种方式可能造成背包空间的浪费,从而无法达到最优。考虑下面的物品和一个容量为 5 的背包,如果先添加物品 0 再添加物品 1,那么只能存放的价值为 16,浪费了大小为 2 的空间。最优的方式是存放物品 1 和物品 2,价值为 22.

id w v v/w
0 1 6 6
1 2 10 5
2 3 12 4
变种
  • 完全背包:物品数量为无限个
  • 多重背包:物品数量有限制
  • 多维费用背包:物品不仅有重量,还有体积,同时考虑这两种限制
  • 其它:物品之间相互约束或者依赖

划分数组为和相等的两部分

416. Partition Equal Subset Sum (Medium)

Input: [1, 5, 11, 5]

Output: true

Explanation: The array can be partitioned as [1, 5, 5] and [11].

这道题给了我们一个数组,问我们这个数组能不能分成两个非空子集合,使得两个子集合的元素之和相同。那么我们想,原数组所有数字和一定是偶数,不然根本无法拆成两个和相同的子集合,那么我们只需要算出原数组的数字之和,然后除以2,就是我们的target,那么问题就转换为能不能找到一个非空子集合,使得其数字之和为target。开始我想的是遍历所有子集合,算和,但是这种方法无法通过OJ的大数据集合。于是乎,动态规划 Dynamic Programming 就是我们的不二之选。我们定义一个一维的dp数组,其中dp[i]表示数字i是否是原数组的任意个子集合之和,那么我们我们最后只需要返回dp[target]就行了。我们初始化dp[0]为true,由于题目中限制了所有数字为正数,那么我们就不用担心会出现和为0或者负数的情况。那么关键问题就是要找出状态转移方程了,我们需要遍历原数组中的数字,对于遍历到的每个数字nums[i],我们需要更新dp数组,要更新[nums[i], target]之间的值,那么对于这个区间中的任意一个数字j,如果dp[j - nums[i]]为true的话,那么dp[j]就一定为true,于是状态转移方程如下:

dp[j] = dp[j] || dp[j - nums[i]] (nums[i] <= j <= target)

有了状态转移方程,那么我们就可以写出代码了,这里需要特别注意的是,第二个for循环一定要从target遍历到nums[i],而不能反过来,想想为什么呢?因为如果我们从nums[i]遍历到target的话,假如nums[i]=1的话,那么[1, target]中所有的dp值都是true,因为dp[0]是true,dp[1]会或上dp[0],为true,dp[2]会或上dp[1],为true,依此类推,完全使我们的dp数组失效了,参见代码如下:

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = accumulate(nums.begin(),nums.end(),0);
        if(sum%2!=0)
            return false;
        sum=sum/2;
        vector<bool> dp(sum+1,false);
        dp[0]=true;
        for(auto num:nums)
        {
            for(int i=sum;i>=num;i--)
            {
                dp[i]=dp[i]||dp[i-num];
            }
        }
        return dp[sum];        
    }
};

改变一组数的正负号使得它们的和为一给定数

494. Target Sum (Medium)

Input: nums is [1, 1, 1, 1, 1], S is 3.
Output: 5
Explanation:

-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3

There are 5 ways to assign symbols to make the sum of nums be target 3.

该问题可以转换为 Subset Sum 问题,从而使用 0-1 背包的方法来求解。

可以将这组数看成两部分,P 和 N,其中 P 使用正号,N 使用负号,有以下推导:

                  sum(P) - sum(N) = target
sum(P) + sum(N) + sum(P) - sum(N) = target + sum(P) + sum(N)
                       2 * sum(P) = target + sum(nums)

因此只要找到一个子集,令它们都取正号,并且和等于 (target + sum(nums))/2,就证明存在解。

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int S) {
        int sum = accumulate(nums.begin(),nums.end(),0);
        if(S>sum||((S+sum)%2!=0))
            return 0;
        sum = (S+sum)/2;
        vector<int> dp(sum+1,0);
        dp[0] = 1;
        for(auto num:nums)
        {
            for(int i=sum;i>=num;--i)
            {
                dp[i] = dp[i]+dp[i-num];
            }
        }
        return dp[sum];
    }
};

DFS:

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int S) {
        int sum=accumulate(nums.begin(),nums.end(),0);
        if(nums.empty()||sum<S)
            return 0;
        return core(nums,S,0);
    }
    int core(vector<int>& nums,int S,int start)
    {
        if(start>=nums.size())
            return (S==0)? 1:0;
        return core(nums,S+nums[start],start+1)+core(nums,S-nums[start],start+1);
    }
};

字符串按单词列表分割

139. Word Break (Medium)

s = "leetcode",
dict = ["leet", "code"].
Return true because "leetcode" can be segmented as "leet code".

dict 中的单词没有使用次数的限制,因此这是一个完全背包问题。

0-1 背包和完全背包在实现上的不同之处是,0-1 背包对物品的迭代是在最外层,而完全背包对物品的迭代是在最里层。

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        vector<bool> dp(s.size()+1,0);
        dp[0]=true;
        for(int i=1;i<=s.size();++i)
        {
            for(auto word:wordDict)
            {
                int n = word.size();
                if(n<=i&&word==s.substr(i-n,n))
                    dp[i] = dp[i]||dp[i-n];
            }
        }
        return dp.back();
    }
};

01 字符构成最多的字符串

474. Ones and Zeroes (Medium)

Input: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
Output: 4

Explanation: There are totally 4 strings can be formed by the using of 5 0s and 3 1s, which are "10","0001","1","0"

这是一个多维费用的 0-1 背包问题,有两个背包大小,0 的数量和 1 的数量。这里用dp[i] [j] [k]表示前i个字符串在0个数不超过j、1个数不超过k时最多能选取的字符串个数。统计第i个字符串中0和1个数分别为cnt0和cnt1,如果取第i个字符串则dp[i] [j] [k] = dp[i-1] [j-cnt0] [k-cnt1] + 1,如果不取第i个字符串则dp[i] [j] [k] = dp[i-1] [j] [k],取两者大的作为dp[i] [j] [k]的值。由于dp[i] [j] [k]只与dp[i-1] [x] [x]相关,所以这里可以重复使用mn个数据将空间复杂度降为O(mn),只需在遍历时从后向前遍历即可。

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        vector<vector<int>> dp(m+1,vector<int>(n+1,0));
        int cnt0,cnt1;
        for(auto str:strs)
        {
            cnt0=0;
            cnt1=0;
            for(auto s:str)
            {
                if(s=='0')
                    ++cnt0;
                else
                    ++cnt1;
            }
            for(int i=m;i>=cnt0;--i)
                for(int j=n;j>=cnt1;--j)
                {
                    dp[i][j]=max(dp[i][j],dp[i-cnt0][j-cnt1]+1);
                }
        }
        return dp[m][n];
    }
};

找零钱的最少硬币数

322. Coin Change (Medium)

Example 1:
coins = [1, 2, 5], amount = 11
return 3 (11 = 5 + 5 + 1)

Example 2:
coins = [2], amount =

题目描述:给一些面额的硬币,要求用这些硬币来组成给定面额的钱数,并且使得硬币数量最少。硬币可以重复使用。

  • 物品:硬币
  • 物品大小:面额
  • 物品价值:数量

因为硬币可以重复使用,因此这是一个完全背包问题。

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int> dp(amount+1,amount+1);
        dp[0] = 0;
        for(int i=1;i<=amount;++i)
            for(auto coin:coins)
            {
                if(coin<=i)
                    dp[i]=min(dp[i],dp[i-coin]+1);
            }
        return (dp.back()>amount)? -1:dp.back();
    }
};

组合总和

377. Combination Sum IV (Medium)(存在问题,尚未解决)

nums = [1, 2, 3]
target = 4

The possible combination ways are:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)

Note that different sequences are counted as different combinations.

Therefore the output is 7.

完全背包

class Solution {
public:
    int combinationSum4(vector<int>& nums, int target) {
        if(nums.empty())
            return 0;
        vector<int> dp(target+1,0);
        dp[0] = 1;
        for(int i=1;i<=target;++i)
        {
            for(auto num:nums)
            {
                if(i>=num)
                    dp[i]=dp[i]+dp[i-num];
            }
        }
        return dp.back();
    }
};

股票交易

需要冷却期的股票交易

309. Best Time to Buy and Sell Stock with Cooldown(Medium)

题目描述:交易之后需要有一天的冷却时间。

20170731204512323

s0[i] = max(s0[i - 1], s2[i - 1])
s1[i] = max(s0[i - 1] - prices[i], s1[i - 1])
s2[i] = s1[i - 1] + prices[i]

其中s0,s1,s2分别表示三种状态下的最大利润值。
值得注意的是这里的s0,s1和s2不是单纯的buy,sell, rest,而应该是

s0 —— sell后rest或者rest后rest
s1 —— rest后的buy或者buy后的rest
s2 —— rest后的sell

同时,可以注意到的是,每次的状态 i 都只与前一次的状态 i - 1有关,也就是说我们可以把空间复杂度从O(n)降到O(1)。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        if(prices.empty())
            return 0;
        int s0 = 0;
        int s1 = -prices[0];
        int s2 = 0;
        int pre0,pre1,pre2;
        for(int i = 1; i<prices.size(); ++i)
        {
            pre0 = s0;
            pre1 = s1;
            pre2 = s2;
            s0 = max(pre0, pre2);
            s1 = max(pre0 - prices[i], pre1);
            s2 = pre1 + prices[i];
        }
        return max(s0,s2);
    }  
};

需要交易费用的股票交易

714. Best Time to Buy and Sell Stock with Transaction Fee (Medium)

hold[i]表示第i天保持手里的股票时最大的利润,sold[i]表示第i天卖掉手里的股票后最大的利润。

sold[i] = max(sold[i - 1], hold[i - 1] + prices[i] - fee);

hold[i] = max(hold[i - 1], sold[i - 1] - prices[i]);

class Solution {
public:
    int maxProfit(vector<int>& prices, int fee) {
        if(prices.empty())
            return 0;
        int sold = 0;
        int hold = -prices[0];
        int prehold, presold;
        for(int i = 1; i<prices.size(); ++i)
        {
            presold = sold;
            prehold = hold;
            sold = max(presold, prehold + prices[i] - fee);
            hold = max(prehold, presold - prices[i]);
        }
        return max(hold,sold);
    }
};

只能进行两次的股票交易

123. Best Time to Buy and Sell Stock III (Hard)

思路参考https://blog.csdn.net/linhuanmars/article/details/23236995

我们定义locali j为在到达第i天时最多可进行j次交易并且最后一次交易在最后一天卖出的最大利润,此为局部最优。然后我们定义globali j为在到达第i天时最多可进行j次交易的最大利润,此为全局最优。它们的递推式为:

local[i] [j] = max(global[i - 1] [j - 1] + max(diff, 0), local[i - 1] [j] + diff)

global[i] [j] = max(local[i] [j], global[i - 1] [j])

其中局部最优值是比较前一天并少交易一次的全局最优加上大于0的差值,和前一天的局部最优加上差值中取较大值,而全局最优比较局部最优和前一天的全局最优。代码如下:

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        if(prices.empty())
            return 0;
        vector<int> global(3,0);
        vector<int> local(3,0);
        for(int i=0; i<prices.size() - 1; ++i)
        {
            int diff = prices[i+1] - prices[i];
            for(int j=2;j>=1;--j)
            {
                local[j]=max(global[j-1]+max(diff,0),local[j]+diff);
                global[j]=max(local[j],global[j]);
            }
        }
        return global[2];
    }
};

只能进行 k 次的股票交易

188. Best Time to Buy and Sell Stock IV (Hard)

class Solution {
public:
    int maxProfit(int k, vector<int>& prices) {
        if(prices.empty())
            return 0;
        if(k>prices.size()/2) //退化为普通股票交易
            return core(prices);
        vector<int> global(k+1,0);
        vector<int> local(k+1,0);
        for(int i=0; i<prices.size() - 1; ++i)
        {
            int diff = prices[i+1] - prices[i];
            for(int j=k;j>=1;--j)
            {
                local[j]=max(global[j-1]+max(diff,0),local[j]+diff);
                global[j]=max(local[j],global[j]);
            }
        }
        return global[k];
    }
    int core(vector<int>& prices)
    {
        int res=0;
        for(int i=1;i<prices.size();++i)
        {
            if(prices[i]>prices[i-1])
                res+=(prices[i]-prices[i-1]);
        }
        return res;
    }
};

字符串编辑

删除两个字符串的字符使它们相等

583. Delete Operation for Two Strings (Medium)

Input: "sea", "eat"
Output: 2
Explanation: You need one step to make "sea" to "ea" and another step to make "eat" to "ea".

其实就是一道lcs问题。

class Solution {
public:
    int minDistance(string word1, string word2) {
        vector<vector<int>> dp(word1.size()+1,vector<int>(word2.size()+1,0));
        for(int i=1;i<=word1.size();++i)
        {
            for(int j=1;j<=word2.size();++j)
            {
                if(word1[i-1]==word2[j-1])
                    dp[i][j]=dp[i-1][j-1]+1;
                else
                    dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
            }
        }
        return word1.size()+word2.size()-dp[word1.size()][word2.size()]*2;
    }
};

编辑距离

72. Edit Distance (Hard)

Example 1:

Input: word1 = "horse", word2 = "ros"
Output: 3
Explanation:
horse -> rorse (replace 'h' with 'r')
rorse -> rose (remove 'r')
rose -> ros (remove 'e')
Example 2:

Input: word1 = "intention", word2 = "execution"
Output: 5
Explanation:
intention -> inention (remove 't')
inention -> enention (replace 'i' with 'e')
enention -> exention (replace 'n' with 'x')
exention -> exection (replace 'n' with 'c')
exection -> execution (insert 'u')

题目描述:修改一个字符串成为另一个字符串,使得修改次数最少。一次修改操作包括:插入一个字符、删除一个字符、替换一个字符。

这道题让求从一个字符串转变到另一个字符串需要的变换步骤,共有三种变换方式,插入一个字符,删除一个字符,和替换一个字符。根据以往的经验,对于字符串相关的题目十有八九都是用动态规划Dynamic Programming来解,这道题也不例外。这道题我们需要维护一个二维的数组dp,其中dp[i][j]表示从word1的前i个字符转换到word2的前j个字符所需要的步骤。那我们可以先给这个二维数组dp的第一行第一列赋值,这个很简单,因为第一行和第一列对应的总有一个字符串是空串,于是转换步骤完全是另一个字符串的长度。跟以往的DP题目类似,难点还是在于找出递推式,我们可以举个例子来看,比如word1是“bbc",word2是”abcd“,那么我们可以得到dp数组如下:

  Ø a b c d
Ø 0 1 2 3 4
b 1 1 1 2 3
b 2 2 1 2 3
c 3 3 2 1 2

我们通过观察可以发现,当word1[i] == word2[j]时,dp[i] [j] = dp[i - 1] [j - 1],其他情况时,dp[i][j]是其左,左上,上的三个值中的最小值加1,那么可以得到递推式为:

dp[i] [j] = / dp[i - 1] [j - 1] if word1[i - 1] == word2[j - 1]

​ \ min(dp[i - 1] [j - 1], min(dp[i - 1] [j], dp[i] [j - 1])) + 1 else

class Solution {
public:
    int minDistance(string word1, string word2) {
        vector<vector<int>> dp(word1.size()+1,vector<int> (word2.size()+1,0));
        for(int i=1;i<=word1.size();++i)
            dp[i][0]=i;
        for(int j=1;j<=word2.size();++j)
            dp[0][j]=j;
        for(int i=1;i<=word1.size();++i)
            for(int j=1;j<=word2.size();++j)
            {
                if(word1[i-1]==word2[j-1])
                    dp[i][j]=dp[i-1][j-1];
                else
                    dp[i][j]=min_3(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])+1;
            }
        return dp[word1.size()][word2.size()];
    }
    int min_3(int& num1,int& num2,int& num3)
    {
        int min=num1>num2? num2:num1;
        return min>num3? num3:min;
    }
};

复制粘贴字符

650. 2 Keys Keyboard (Medium)

题目描述:最开始只有一个字符 A,问需要多少次操作能够得到 n 个字符 A,每次操作可以复制当前所有的字符,或者粘贴。

这道题只给了我们两个按键,如果只能选择两个按键,那么博主一定会要复制和粘贴,此二键在手,天下我有!!!果然,这道题就是给了我们复制和粘贴这两个按键,然后给了我们了一个A,我们的目标时利用这两个键来打印出n个A,注意复制的时候时全部复制,不能选择部分来复制,然后复制和粘贴都算操作步骤,问我们打印出n个A需要多少步操作。对于这种有明显的递推特征的题,我们要有隐约的感觉,一定要尝试递归和DP。递归解法一般接近于暴力搜索,但是有时候是可以优化的,从而能够通过OJ。而一旦递归不行的话,那么一般来说DP这个大杀器都能解的。还有一点,对于这种题,找规律最重要,DP要找出递推公式,而如果无法发现内在的联系,那么递推公式就比较难写出来了。所以,我们需要从简单的例子开始分析,试图找规律:

当n = 1时,已经有一个A了,我们不需要其他操作,返回0

当n = 2时,我们需要复制一次,粘贴一次,返回2

当n = 3时,我们需要复制一次,粘贴两次,返回3

当n = 4时,这就有两种做法,一种是我们需要复制一次,粘贴三次,共4步,另一种是先复制一次,粘贴一次,得到AA,然后再复制一次,粘贴一次,得到AAAA,两种方法都是返回4

当n = 5时,我们需要复制一次,粘贴四次,返回5

当n = 6时,我们需要复制一次,粘贴两次,得到AAA,再复制一次,粘贴一次,得到AAAAAA,共5步,返回5

通过分析上面这6个简单的例子,我想我们已经可以总结出一些规律了,首先对于任意一个n(除了1以外),我们最差的情况就是用n步,不会再多于n步,但是有可能是会小于n步的,比如n=6时,就只用了5步,仔细分析一下,发现时先拼成了AAA,再复制粘贴成了AAAAAA。那么什么情况下可以利用这种方法来减少步骤呢,分析发现,小模块的长度必须要能整除n,这样才能拆分。对于n=6,我们其实还可先拼出AA,然后再复制一次,粘贴两次,得到的还是5。分析到这里,我想解题的思路应该比较清晰了,我们要找出n的所有因子,然后这个因子可以当作模块的个数,我们再算出模块的长度n/i,调用递归,加上模块的个数i来更新结果res即可,参见代码如下:

class Solution {
public:
    int minSteps(int n) {
        if(n==1)
            return 0;
        int res=n;
        for(int i=n/2;i>1;--i) //注意这里i不能等于1,否则陷入死循环。
        {
            if(n%i==0)
                res=min(res,minSteps(n/i)+i);
        }
        return res;
    }
};

猜你喜欢

转载自blog.csdn.net/weixin_38526306/article/details/87193479
今日推荐