动态规划的问题中,已经写出了记忆化搜索的版本,还要写出严格位置依赖的版本,意义在于不仅可以进行空间压缩优化;关键还在于,很多时候通过进一步观察,可以优化枚举,让时间复杂度更好。优化枚举的技巧很多,本文讲解根据观察优化。
动态规划方法的复杂度大致可以理解为:O(状态数量 * 每个状态的枚举代价)。当每个状态的枚举代价为O(1),那么写出记忆化搜索的版本,就是时间复杂度最好的实现了。但是当每个状态的枚举代价比较高的时候,记忆化搜索的版本可能不是最优解,可能存在进一步的优化。之所以从记忆化搜索改出了严格位置依赖的版本,是为了建立空间感,让观察并优化枚举的分析变容易。
通过观察优化枚举的技巧包括:
观察并优化转移方程、观察并设计高效的查询结构
下面通过题目加深理解。注意:因为题目四属于著名的买卖股票系列问题中的一个,所以索性把这个系列全讲了,买卖股票系列请重点关注题目四。
题目一
测试链接:121. 买卖股票的最佳时机 - 力扣(LeetCode)
分析:这道题实际上是对于卖股票的一天,只要找到之前买股票时之间的差值最大就行。代码如下。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int min_price = prices[0];
int profit = 0;
int length = prices.size();
for(int i = 1;i <length;++i){
min_price = min(min_price, prices[i]);
profit = max(profit, prices[i] - min_price);
}
return profit;
}
};
题目二
测试链接:122. 买卖股票的最佳时机 II - 力扣(LeetCode)
分析:对于无限购买的情况,实际是把握住每一次价格的上升,也就是收益。代码如下。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int length = prices.size();
int profit = 0;
for(int i = 1;i <length;++i){
profit += max(prices[i] - prices[i-1], 0);
}
return profit;
}
};
题目三
测试链接:123. 买卖股票的最佳时机 III - 力扣(LeetCode)
分析:对于一定在第i天第二次卖掉股票的情况下,找到i之前的某一天j,只需要知道0到j时间第一笔交易的最大利润以及在第j天买入在第i天卖掉的第二笔利润,就可以知道对于在第i天第二次卖掉股票的其中一种情况。很容易知道,对于第i天第二次卖掉股票的求解过程中,有枚举过程。通过观察,可以得到对于第i天第二次卖掉股票的可能性展开方程中抽象出具有相同结构的地方形成一个数组,这样就可以通过查询数组,从而优化掉枚举过程。代码如下。
class Solution {
public:
int dp1[100001];
int best[100001];
int maxProfit(vector<int>& prices) {
int length = prices.size();
int min_price = prices[0];
int ans = 0;
dp1[0] = 0;
best[0] = -prices[0];
for(int i = 1;i < length;++i){
min_price = min(min_price, prices[i]);
dp1[i] = max(dp1[i-1], prices[i] - min_price);
best[i] = max(best[i-1], dp1[i] - prices[i]);
ans = max(ans, best[i] + prices[i]);
}
return ans;
}
};
其中,dp1数组存储从0到i之间完成一笔交易的最大利润;best数组存储抽象出来的结构。
题目四
测试链接:188. 买卖股票的最佳时机 IV - 力扣(LeetCode)
分析:可以知道,如果有n天那么最多有n/2个上升,也就是说,如果k大于等于n/2,则此题就变化成可以交易无限次求最大利润。如果k小于n/2,对于dp[i][j]代表在0到i范围上完成j笔交易的最大利润。对于dp[i][j]代表在0到j范围上完成i笔交易的最大利润可能性的展开分为是否在第j天卖出。可以观察到,如果要在第j天卖出,存在一个枚举行为,和上一题类似,通过抽象出一个best变量,从而优化掉这个枚举行为。代码如下。
class Solution {
public:
int dp[101][1000] = {0};
int infinite(vector<int>& prices, int length){
int ans = 0;
for(int i = 1;i < length;++i){
ans += max(prices[i] - prices[i-1], 0);
}
return ans;
}
int maxProfit(int k, vector<int>& prices) {
int length = prices.size();
if(k >= (length/2)){
return infinite(prices, length);
}
int best;
for(int i = 1;i <= k;++i){
best = dp[i-1][0] - prices[0];
for(int j = 1;j < length;++j){
best = max(best, dp[i-1][j] - prices[j]);
dp[i][j] = max(dp[i][j-1], best + prices[j]);
}
}
return dp[k][length-1];
}
};
题目五
测试链接:714. 买卖股票的最佳时机含手续费 - 力扣(LeetCode)
分析:维护一个prepare变量和一个down变量分别表示遍历到i时取得的最大利润在买入一次股票和扣掉一次手续费时的最大值,down代表来到i时所取得的最大利润。对于down的更新,当来到第i天时,如果不在第i天卖出,则此时的down不变;如果在第i天卖出,则用prepare加上第i天的价格和之前的down进行比较,取最大值。对于prepare的更新,如果不在第i天买入,则prepare不变;如果在第i天买入,则是来到第i天的最大利润减去第i天的价格和一次手续费,取最大值。代码如下。
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
int length = prices.size();
int prepare = -prices[0] - fee;
int done = 0;
for(int i = 1;i < length;++i){
done = max(done, prepare + prices[i]);
prepare = max(prepare, done - prices[i] - fee);
}
return done;
}
};
题目六
测试链接:309. 买卖股票的最佳时机含冷冻期 - 力扣(LeetCode)
分析:因为存在如果要在某一天买入,则需要前两天的最大利润的情况,所以相比于上一题,此题需要维护一个prepare变量、一个来到第i天的最大利润down、来到第i-1天的最大利润pdown和来到第i-2天的最大利润ppdown。更新思路和上一题基本相同,代码如下。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int length = prices.size();
if(length < 2){
return 0;
}
int prepare = max(-prices[0], -prices[1]);
int pdone = 0, ppdone = 0, done = max(0, prices[1] - prices[0]);
for(int i = 2;i < length;++i){
ppdone = pdone;
pdone = done;
done = max(done, prepare + prices[i]);
prepare = max(prepare, ppdone - prices[i]);
}
return done;
}
};
题目七
测试链接:903. DI 序列的有效排列 - 力扣(LeetCode)
分析:这道题很容易想到的一个思路是,当来到了字符串的i位置,然后有一个整数是否被取的一个状态,上一个位置取的数是多少,进行可能性展开,大致思路类似于状压dp中的题。但是因为n的规模,这个思路尝试不可取。观察到这道题,对于整数的具体值并不严格要求,只严格要求大小关系,所以对于递归的参数可以用比前一个数小的数有多少个代替哪些整数被用过,这样来进行可能性展开,记忆化搜索就可行了。代码如下。
class Solution {
public:
int MOD = 1000000007;
int dp[201][201];
int f(string& s, int i, int n, int less){
if(dp[i][less] != -1){
return dp[i][less];
}
int ans = 0;
char ch = s[i-1];
if(i == n){
if(ch == 'D' && less == 1){
ans = 1;
}else if(ch == 'I' && less == 0){
ans = 1;
}else{
ans = 0;
}
}else{
if(ch == 'D'){
for(int j = 0;j < less;++j){
ans = (int)(((long long)ans + f(s, i+1, n, j)) % MOD);
}
}else{
for(int j = 0;j < (n+1-i-less);++j){
ans = (int)(((long long)ans + f(s, i+1, n, n-i-j)) % MOD);
}
}
}
dp[i][less] = ans;
return ans;
}
int numPermsDISequence(string s) {
int n = s.size();
int ans = 0;
for(int i = 0;i <= n;++i){
for(int j = 0;j <= n;++j){
dp[i][j] = -1;
}
}
for(int i = 0;i <= n;++i){
ans = (int)(((long long)ans + f(s, 1, n, i)) % MOD);
}
return ans;
}
};
其中,f方法返回在来到i位置,比前一位置小的数有less个的情况下有效排列的个数。
题目八
测试链接:1235. 规划兼职工作 - 力扣(LeetCode)
分析:首先,对每个工作的结束时间从小到大排序,dp[i]代表从排好序的数组中前i个工作范围取获得的最大报酬,可能性的展开即对第i个工作做或者不做。如果要做第i个工作,则需要找到结束时间小于等于第i个工作开始时间的工作,然后得到它的dp值,遍历取最大值。这里是一个枚举过程,但是观察到dp数组是一个不递减的序列即一个有序序列,则可以利用二分查找求得离i最近的结束时间小于等于i的开始时间的dp值,从而优化掉这个枚举过程。代码如下。
class Solution {
public:
struct job
{
int start;
int end;
int profit;
};
int dp[50001];
int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit) {
int length = startTime.size();
vector<job> Job;
Job.reserve(length);
for(int i = 0;i < length;++i){
job temp;
temp.start = startTime[i];
temp.end = endTime[i];
temp.profit = profit[i];
Job.push_back(temp);
}
sort(Job.begin(), Job.end(), [](job j1, job j2){
return j1.end < j2.end;
});
int l, r, m, ans;
dp[0] = 0;
for(int i = 1;i <= length;++i){
if(Job[0].end > Job[i-1].start){
ans = 0;
}else{
l = 0;
r = i-2;
while (l <= r)
{
m = l + (r - l) / 2;
if(Job[m].end <= Job[i-1].start){
ans = m + 1;
l = m + 1;
}else{
r = m - 1;
}
}
}
dp[i] = max(dp[i-1], Job[i-1].profit + dp[ans]);
}
return dp[length];
}
};
题目九
测试链接:https://leetcode.cn/problems/k-inverse-pairs-array/
分析:dp[i][j]的含义代表在1到i范围上有j个逆序对的个数。可能性的展开即如果i大于j,则第i个数插入1到i-1的排列中,可以产生0到i-1个逆序对,这将这些可能性相加;如果i小于等于j,则第i个数插入到1到i-1的排列中,可以产生0到i-1个逆序对,将这些可能性相加。可以发现,这是一个枚举过程,并且i大于j的时候相加是从下标0开始到j,i小于等于j的时候相加是从j-i+1到j,所以可以利用一个窗口来维护这些值,进而优化掉枚举过程。第一个版本是严格位置依赖未优化枚举过程,第二个版本是优化掉枚举过程。代码如下。
class Solution {
public:
int MOD = 1000000007;
int dp[1001][1001] = {0};
int kInversePairs(int n, int k) {
dp[1][0] = 1;
for(int i = 2;i <= n;++i){
for(int j = 0;j <= k;++j){
if(i > j){
for(int l = 0;l <= j;++l){
dp[i][j] = (int)(((long long)dp[i][j] + dp[i-1][l]) % MOD);
}
}else{
for(int l = j - i + 1;l <= j;++l){
dp[i][j] = (int)(((long long)dp[i][j] + dp[i-1][l]) % MOD);
}
}
}
}
return dp[n][k];
}
};
class Solution {
public:
int MOD = 1000000007;
int dp[1001][1001] = {0};
int kInversePairs(int n, int k) {
int window;
dp[1][0] = 1;
for(int i = 2;i <= n;++i){
window = 0;
for(int j = 0;j <= k;++j){
if(i > j){
window = (int)(((long long)window + dp[i-1][j]) % MOD);
dp[i][j] = window;
}else{
window = (int)(((long long)window + dp[i-1][j] - dp[i-1][j-i] + MOD) % MOD);
dp[i][j] = window;
}
}
}
return dp[n][k];
}
};
题目十
测试链接:514. 自由之路 - 力扣(LeetCode)
分析:这道题的递归即从转盘指向i位置,关键词来到j位置时完成的最少步数。可能性的展开很好想到,就是对于关键词j位置的字符在转盘上进行遍历枚举,然后调递归。这是一个枚举过程,但是观察可知,只需要对顺时针最近的关键词字符调递归和逆时针最近的关键词字符调递归这两个可能性进行展开即可,这样就优化掉了枚举过程。代码如下。
class Solution {
public:
int dp[100][100];
int f(string& ring, int length_r, int i, string& key, int length_k, int j){
if(j == length_k){
return 0;
}
if(dp[i][j] != -1){
return dp[i][j];
}
int ans;
if(ring[i] == key[j]){
ans = 1 + f(ring, length_r, i, key, length_k, j+1);
}else{
for(int k = (i+1+length_r)%length_r;;k = (k+1+length_r)%length_r){
if(ring[k] == key[j]){
ans = 1 + ((k-i+length_r)%length_r) + f(ring, length_r, k, key, length_k, j+1);
break;
}
}
for(int k = (i-1+length_r)%length_r;;k = (k-1+length_r)%length_r){
if(ring[k] == key[j]){
ans = min(ans, 1 + ((i-k+length_r)%length_r) + f(ring, length_r, k, key, length_k, j+1));
break;
}
}
}
dp[i][j] = ans;
return ans;
}
int findRotateSteps(string ring, string key) {
int length_r = ring.size();
int length_k = key.size();
for(int i = 0;i < length_r;++i){
for(int j = 0;j < length_k;++j){
dp[i][j] = -1;
}
}
return f(ring, length_r, 0, key, length_k, 0);
}
};
题目十一
测试链接:未排序数组中累加和小于或等于给定值的最长子数组长度_牛客题霸_牛客网
分析:第一个nlog n复杂度的解法是,对于前缀和数组,如果要求以i位置结尾的子数组的值满足条件,则需要在i之前的前缀和进行枚举,符合条件的子数组进行长度的更新,这样复杂度是n²且有枚举过程。观察到,对于枚举过程中是要找到距离i最远的满足大于等于前缀和减k的下标,即如果对一个位置j之后的位置的前缀和小于j位置的前缀和,那么j之后的这个位置就没有意义,所以可以将这个前缀和数组变化成一个不递减数组,进而利用二分查找优化掉枚举过程。代码如下。
#include <iostream>
#include <cmath>
using namespace std;
int N, k;
int b_prefix[100001];
int prefix[100001];
int main(void){
int temp;
scanf("%d%d", &N, &k);
b_prefix[0] = 0;
prefix[0] = 0;
for(int i = 1;i <= N;++i){
scanf("%d", &temp);
prefix[i] = prefix[i-1] + temp;
b_prefix[i] = max(prefix[i], b_prefix[i-1]);
}
int ans = 0;
int l, r, m, s;
for(int i = 1;i <= N;++i){
l = 0;
r = i;
s = prefix[i] - k;
temp = i+1;
while (l <= r)
{
m = l + (r - l) / 2;
if(b_prefix[m] >= s){
temp = m;
r = m - 1;
}else{
l = m + 1;
}
}
ans = max(ans, i - temp);
}
printf("%d", ans);
return 0;
}
第二种解法是n复杂度的解法,利用构建查询结构。构建nums数组存储数组值,numsSum数组存储从i下标往右扩的最小值,numsSumEnd数组存储从i往右扩到最小值时的下标。这样对于一个下标可以往右探查到满足条件的最远下标,更新长度后,减去此下标的值,再从刚才的最远下标看是否还能往右边扩,这样区间的左右俩下标都不回退,是n的复杂度,并且可以用一个窗口值去维护区间的和。代码如下。
#include <iostream>
#include <cmath>
using namespace std;
int N, k;
int nums[100001];
int numsSum[100001];
int numsSumEnd[100001];
int main(void){
scanf("%d%d", &N, &k);
for(int i = 0;i < N;++i){
scanf("%d", &nums[i]);
}
numsSum[N-1] = nums[N-1];
numsSumEnd[N-1] = N-1;
for(int i = N-2;i >= 0;--i){
if(numsSum[i+1] < 0){
numsSum[i] = nums[i] + numsSum[i+1];
numsSumEnd[i] = numsSumEnd[i+1];
}else{
numsSum[i] = nums[i];
numsSumEnd[i] = i;
}
}
int window;
int p1, p2;
int ans;
for(p1 = 0;p1 < N;++p1){
if(numsSum[p1] <= k){
window = numsSum[p1];
p2 = numsSumEnd[p1];
break;
}
}
while (p2 + 1 < N)
{
if(numsSum[p2+1] + window <= k){
window += numsSum[p2+1];
p2 = numsSumEnd[p2+1];
}else{
break;
}
}
ans = p2 - p1 + 1;
for(;p1 < N && p2 < N;){
if(p2 + 1 == N){
break;
}
window -= nums[p1++];
while (p2 + 1 < N)
{
if(numsSum[p2+1] + window <= k){
window += numsSum[p2+1];
p2 = numsSumEnd[p2+1];
}else{
break;
}
}
ans = max(ans, p2 - p1 + 1);
}
printf("%d", ans);
return 0;
}