今天在LeetCode上研究了两道关于普通数组操作的题目,整个过程就是从直觉出发,经过不断试错,最终找到最优解。
一、最大子数组和

当我看到最大子数组和这个问题时,脑海中浮现的是一个看似合理的简单想法:遍历数组,不断累加元素,只要当前和是正数就继续加,一旦和变成负数就从下一个元素重新开始。但测试后很快就发现了问题。
考虑数组 [4, -1, 2, 1],按照我的算法:从4开始,和是4;加-1得到3,比4小,所以从-1重新开始,和是-1;加2得到1;加1得到2。最终得到的最大和是4,但实际上正确答案应该是6(整个子数组 [4, -1, 2, 1])。问题出在我过早地放弃了可能的最优解,没有意识到即使当前和暂时变小,后续的正数仍然可能让整体和变得更大。
问题的关键在于:如果和是小于0的才可以直接舍弃,有了这个思路,我写下了第一个版本的代码:
cpp
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if (nums.empty()) return 0;
int maxSum = nums[0]; // 全局最大和
int currentSum = nums[0]; // 当前和
for (int i = 1; i < nums.size(); i++) {
// 如果当前和是正数,继续累加
if (currentSum > 0) {
currentSum += nums[i];
} else {
// 如果当前和是负数,从当前元素重新开始
currentSum = nums[i];
}
// 更新全局最大值
if (currentSum > maxSum) {
maxSum = currentSum;
}
}
return maxSum;
}
};
这个版本虽然能够正确工作,但还可以进一步优化。对于每个位置,计算以该位置结尾的最大子数组和。那么,以位置i结尾的最大子数组和要么是只包含当前元素,要么是包含前面元素的最大和再加上当前元素。用公式表达就是:sum[i] = max(nums[i], sum[i-1] + nums[i])。
这个公式的直观解释很巧妙:如果前面的累计和是正数,那么加上当前元素只会让和更大;如果前面的累计和是负数,那么加上当前元素只会让和更小,所以不如直接从当前元素重新开始。这就是为什么代码中那个关键的判断条件 sum = max(nums[i], sum + nums[i]) ------要么从当前元素重新开始,要么继续累加。于是有了第二个更简洁的版本:
cpp
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if (nums.empty()) return 0;
int maxSum = nums[0];
int currentSum = nums[0];
for (int i = 1; i < nums.size(); i++) {
// 关键优化:用max函数代替if-else判断
currentSum = max(nums[i], currentSum + nums[i]);
maxSum = max(maxSum, currentSum);
}
return maxSum;
}
};
注意边界条件:初始化时,最大和与当前和都应该是数组的第一个元素,而不是0,因为子数组至少要包含一个元素,而且数组可能全为负数。
但此题分治法的思路更巧妙:将数组分成两半,那么最大子数组要么完全在左半部分,要么完全在右半部分,要么跨越中点。左右部分可以递归求解,跨越中点的部分则需要特殊处理------从中点向左右两边扩展,分别找到向左和向右的最大和,然后相加。
cpp
class Solution {
public:
int maxSubArray(vector<int>& nums) {
return divide(nums, 0, nums.size() - 1);
}
private:
int divide(vector<int>& nums, int left, int right) {
if (left == right) return nums[left];
int mid = left + (right - left) / 2;
// 分别求左半部分、右半部分和跨越中点的最大和
int leftMax = divide(nums, left, mid);
int rightMax = divide(nums, mid + 1, right);
int crossMax = crossSum(nums, left, mid, right);
return max(max(leftMax, rightMax), crossMax);
}
int crossSum(vector<int>& nums, int left, int mid, int right) {
// 从中点向左扩展的最大和
int leftSum = INT_MIN;
int sum = 0;
for (int i = mid; i >= left; i--) {
sum += nums[i];
leftSum = max(leftSum, sum);
}
// 从中点向右扩展的最大和
int rightSum = INT_MIN;
sum = 0;
for (int i = mid + 1; i <= right; i++) {
sum += nums[i];
rightSum = max(rightSum, sum);
}
return leftSum + rightSum;
}
};
分治法的时间复杂度也是O(n),但空间复杂度由于递归调用是O(log n)。虽然不如动态规划简洁,但分治法展示了另一种思考角度。
二、合并区间

面对合并区间这个问题,我的第一反应是直接比较所有区间对,检查它们是否重叠。这个算法虽然能够正确工作,但效率很低,时间复杂度是O(n²)。
在仔细思考后,我意识到问题的关键在于:如果区间按照起始位置排序,那么重叠的区间就会相邻,合并起来就简单多了。
cpp
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
if (intervals.empty()) return {};
// 先按区间起始位置排序
sort(intervals.begin(), intervals.end());
vector<vector<int>> result;
result.push_back(intervals[0]);
for (int i = 1; i < intervals.size(); i++) {
// 获取结果中最后一个区间
vector<int>& last = result.back();
// 如果当前区间与最后一个区间重叠
if (intervals[i][0] <= last[1]) {
// 合并区间,结束位置取较大值
// 这里需要注意:即使当前区间完全包含在最后一个区间内,
// 也需要更新结束位置,因为可能存在 [1,4] 和 [2,3] 的情况
last[1] = max(last[1], intervals[i][1]);
} else {
// 不重叠,直接加入
result.push_back(intervals[i]);
}
}
return result;
}
};
这个算法的核心思想变得非常简单:排序后,我们只需要关心当前区间是否与结果中的最后一个区间重叠。如果重叠就合并,不重叠就加入。时间复杂度主要消耗在排序上。
我还试了试用栈来解决这个问题,思路是类似的:将排序后的区间依次压入栈中,如果新区间与栈顶区间重叠就合并,否则直接压入。虽然这种方法的代码稍显复杂,但本质上和上面的算法是一样的。
cpp
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
if (intervals.empty()) return {};
// 排序是关键
sort(intervals.begin(), intervals.end());
stack<vector<int>> st;
st.push(intervals[0]);
for (int i = 1; i < intervals.size(); i++) {
vector<int> top = st.top();
// 检查重叠
if (intervals[i][0] <= top[1]) {
// 合并区间
st.pop();
st.push({top[0], max(top[1], intervals[i][1])});
} else {
// 不重叠,直接压入
st.push(intervals[i]);
}
}
// 将栈中元素转换为结果
vector<vector<int>> result;
while (!st.empty()) {
result.push_back(st.top());
st.pop();
}
// 栈是后进先出,需要反转
reverse(result.begin(), result.end());
return result;
}
};
三、总结
对于最大子数组和问题,对于每个位置,我们需要考虑的是以该位置结尾的最大子数组和,而不是全局的最大和。这个微妙的区别正是动态规划思想的精髓。
对于合并区间问题,排序在解决区间相关问题中起了重要作用。原本需要比较所有区间对的复杂问题,经过排序后变成了只需比较相邻区间的简单问题。这让我想起了一个更广泛的算法设计原则:当问题涉及顺序或范围时,排序往往是第一个要考虑的优化手段。
这两道题看似简单,但其中蕴含的思考过程却很有价值。从直觉到优化,从暴力到高效。每次克服一个难点,每次找到一个更优的解法,都会对算法设计有更深的理解和更强的信心。