目录
题目背景
1.取自leetcode第918道。
2.该题为2020年10月11日美团笔试题。
题目描述
如何分析?
首先,根据题目含义这道题的存放空间实际上是一个“循环链表”,即数组模拟的循环链表。
其次,这道题提出的“子数组”是在“循环链表”中连续即可。
再者,我们提出题目的大概含义:最大子数组和。提取出这些关键字,那么我们可以很快反应过来,也许可以使用--动态规划。
但是,这道题一般的动态规划难以解决,因为使用数组模拟循环链表,必然导致存在跨区域的情况。
如果你们能提出这些关键字怎么办?最好的办法是,抛开效率,实现功能,逐步优化,提高功效。
难点
根据以上的分析,难点在于:
1.跨区域的情况如何处理?
2.如果没有经验如何设计,实现代码。
对于难点1,我们采用两种策略。
策略1:区间拼接/向量加法
策略2:数学结论
设计优化:
角度1:
(有经验可以跳过)
从抛开效率的角度讲,我们只需要去枚举所有区间就可以了。所以有以下代码:
class Solution {
const int INF = 0x3f3f3f3f;
public:
int maxSubarraySumCircular(std::vector<int>& nums) {
int n = nums.size();
int ans = -INF;
for (int i = 0; i < n; ++i) {//以nums[i]为首
int sum = nums[i], tmp_ans = nums[i];
//逐个求和,获取区间和,寻找最大值
for (int j = (i + 1) % n; j != i; j = (j + 1) % n) {
sum += nums[j];
if (sum > tmp_ans) tmp_ans = sum;//tmp_ans记录临时最大值
}
if (tmp_ans > ans) ans = tmp_ans;
}
return ans;
}
};
我们很容易看出来,此段代码的时间复杂度为。但是我们的数据规模在
,所以面对这种复杂度我们很难通过。我们必须面临优化。
可是,从这种角度看,我们似乎已经无路可走了。不过,这段代码是我们优化过的。真正的枚举到底指什么?
真正的枚举是指枚举出每一个区间,对区间进行求和,再寻找最大值。我们知道枚举所有区间需要的复杂度,求和需要
的复杂度。所以,真正的枚举复杂度是
。
那么这段代码,看似枚举到底优化在哪里?
这点优化就是角度2。
角度2:动态规划
在角度1中,我们初次看到了角度2的影子。动态规划最最重要的就是利用每次状态转移关系的弱相关,从而通过简单的记录状态快速求解问题。
再次看到这道题目,我们发现如果在求和操作中,每一次的状态转移关系是简单的。
ps:每一次的状态转移关系是,固定区间左端点(一个端点)后,状态改变是简单的。
所以我们可以通过记录信息状态来求解这个问题。
而角度1的优化代码在求和时,就运用到了这个技巧。
所以我们可以设计状态数组dp[n] 表示从["左端点", i]区间中最大的序列和。
那么我们有状态转移方程:
dp[i] = max{nums[i], dp[(i - 1 + n) % n] + nums[i]}
其中呢,dp就数据就是tmp_ans。
所以我们可以将角度一的代码改写为如下两个版本:
动态规划版本1
class Solution {
const int INF = 0x3f3f3f3f;
public:
int maxSubarraySumCircular(std::vector<int>& nums) {
int n = nums.size();
int ans = -INF;
for (int i = 0; i < n; ++i) {//以nums[i]为首
vector<int> dp = vector<int>(n);
dp[i] = nums[i]
//逐个求和,获取区间和,寻找最大值
for (int j = (i + 1) % n; j != i; j = (j + 1) % n) {
dp[j] = std::max(dp[(j - 1 + n) % n] + nums[j], num[j]);
if (dp[j] > ans) ans = dp[j];
}
}
return ans;
}
};
动态规划版本2
class Solution {
const int INF = 0x3f3f3f3f;
public:
int maxSubarraySumCircular(std::vector<int>& nums) {
int n = nums.size();
int ans = -INF;
for (int i = 0; i < n; ++i) {//以nums[i]为首
int sum = nums[i];
//逐个求和,获取区间和,寻找最大值
for (int j = (i + 1) % n; j != i; j = (j + 1) % n) {
sum = std::max(sum + nums[j], nums[j]);
if (sum > ans) ans = sum;
}
}
return ans;
}
};
因为跨界情况的存在,使得我们需要对首元素需要枚举。以上三分代码都没有对这个点进行优化!
我们来说说,为什么需要对首元素枚举。
这个道理很简单。数组始终是数组,虽然我们可以使用取余运算使其具有“循环性”。但是,在链表区间上,我们想要枚举清楚,就必要将循环链表断成n条单向链表(数组);
那么说完产生原因,我们来看看,如何处理跨界问题。
我们记nums[i: j]为从编号为i的节点开始到编号j的节点进行求和。
此时我们求解出了nums[0 : i]和nums[j: n]。那么我们要处理的跨界情况值就是nums[0: i] + nums[j: n]。而我们的跨界最大值为
max{nums[j: n] + nums[0: i], 其中 (j - 1 + n) % n };
这是我们枚举的情况,但是你还记得第一版和第二版的动态规划吗?我们可以记录max{num[0: i]};
所以有了以下代码:
class Solution {
public:
int maxSubarraySumCircular(vector<int>& nums) {
int n = nums.size();
vector<int> leftMax(n);//记录[0: n]最大子区间和
// 对坐标为 0 处的元素单独处理,避免考虑子数组为空的情况
leftMax[0] = nums[0];
int leftSum = nums[0];
int pre = nums[0];//第一版的空间优化,等于第二版sum
int res = nums[0];//最后的返回答案
for (int i = 1; i < n; i++) {
pre = max(pre + nums[i], nums[i]);
res = max(res, pre);
leftSum += nums[i];
leftMax[i] = max(leftMax[i - 1], leftSum);
}
// 从右到左枚举后缀,固定后缀,选择最大前缀
int rightSum = 0;
for (int i = n - 1; i > 0; i--) {
rightSum += nums[i];
res = max(res, rightSum + leftMax[i - 1]);//处理跨区间情况
}
return res;
}
};
角度三:数学角度
前面两个角度,我们通过“动态规划”的方式去求解问题。或者说,这是区间拼接/向量加法;
我们还有一种数学角度,最去区间和问题。有一个很常用简单的数学结论:区间固定,无论对区间内部做任何调换,都不改变区间总值。
还记得我们之前说的,产生枚举首元素的原因吗?因为我们要包含所有可能,所以我们去枚举首元素。而枚举首元素我们也解释过,他是一种切断链表的操作。反应到数组中,是调整了数组内部的编号顺序。
例如:原来我们有数组nums = [1,2,3];
那么我们在枚举以nums[1]作为首元素,其实就是将数组变成了nums = [2,3,1];
但是,根据数学结论,区间内部的调动不会改变总值。也就是说,无论谁是首元素,总值不变。
我们记总值为sum,它与我们所要求取最子区间和的关系为:
sum = max_sum + min_sum;
稍作变化:
max_sum = sum - min_sum;
那么我们选取nums[0]作为首元素,正如上图所示,
当min_subarray(min_sum) 是连续的,那么最大值就是跨界的情况。
当最大值是在不跨界中产生时,我们就可以使用动态规划解决。
而对我们来说,跨界正是我们头疼的问题。所以我们只需要在nums[0]为首元素的时候,记录不跨界最大值,和不跨界最小值就可以了以及总值。
class Solution {
public:
int maxSubarraySumCircular(vector<int>& nums) {
int n = nums.size();
int preMax = nums[0], maxRes = nums[0];
int preMin = nums[0], minRes = nums[0];
int sum = nums[0];
//以nums[0]为首元素,记录不跨界情况的最大值和最小值
for (int i = 1; i < n; i++) {
preMax = max(preMax + nums[i], nums[i]);
maxRes = max(maxRes, preMax);//记录最大值
preMin = min(preMin + nums[i], nums[i]);
minRes = min(minRes, preMin);//记录最小值
sum += nums[i];//计算总值
}
if (maxRes < 0) {
return maxRes;
} else {
//最大值在不跨界(maxRes)和跨界(sum - minRes)中产生。
return max(maxRes, sum - minRes);
}
}
};