1. 最大子数组和问题解析
最大子数组和问题(Maximum Subarray Problem)是算法领域的一个经典问题,也是动态规划思想的典型应用场景。这个问题的核心在于:给定一个整数数组,找到一个连续子数组,使得该子数组的和是所有可能子数组中最大的。
1.1 问题理解与示例分析
让我们先看几个典型示例来理解这个问题:
示例1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为6
示例2:
输入:nums = [1]
输出:1
示例3:
输入:nums = [5,4,-1,7,8]
输出:23
从这些示例可以看出,最大子数组可能出现在数组的任何位置,长度也可能各不相同。特别需要注意的是,当数组中所有元素都是负数时,最大子数组就是最大的那个负数本身。
1.2 滑动窗口解法详解
滑动窗口是解决这类连续子数组问题的常见方法。其核心思想是维护一个窗口,通过调整窗口的左右边界来寻找最优解。
1.2.1 滑动窗口实现思路
- 初始化左指针l=0,右指针r从1开始遍历
- 维护当前窗口和max_n和全局最大值ans
- 当窗口和为负时,收缩左边界(因为负数会拖累后续和)
- 每次扩展右边界时更新全局最大值
java复制class Solution {
public int maxSubArray(int[] nums) {
int n = nums.length;
int l = 0, r;
int max_n = nums[0], ans = nums[0];
for (r = 1; r < n; r++) {
while (l < r && max_n < 0) {
max_n -= nums[l];
l++;
}
max_n += nums[r];
ans = Math.max(ans, max_n);
}
return ans;
}
}
1.2.2 滑动窗口的优缺点
优点:
- 直观易懂,符合人类思维
- 时间复杂度O(n),空间复杂度O(1)
缺点:
- 需要处理窗口收缩条件
- 对于全负数数组需要特殊处理
1.3 贪心算法解法
贪心算法提供了一种更简洁的解决方案,其核心思想是:当当前子数组和为负数时,立即放弃它,从下一个元素重新开始计算。
1.3.1 贪心算法实现
javascript复制function maxSubArray(nums) {
let sum = 0, maxSum = nums[0];
for (const num of nums) {
if (sum < 0) sum = 0;
sum += num;
maxSum = Math.max(maxSum, sum);
}
return maxSum;
}
1.3.2 贪心算法的关键点
- 负数前缀重置:当sum<0时,说明前面的子数组对后续求和没有贡献,反而会拖累总和
- 实时更新最大值:每次累加后都要比较更新maxSum
- 初始化处理:maxSum初始化为nums[0],以处理全负数数组的情况
1.4 动态规划最优解
动态规划是解决这个问题的最优方法,时间复杂度O(n),空间复杂度O(1),且代码极其简洁。
1.4.1 DP状态定义
定义dp[i]表示以nums[i]结尾的最大子数组和,则有状态转移方程:
dp[i] = max(nums[i], dp[i-1] + nums[i])
1.4.2 DP实现代码
c复制class Solution {
public:
int maxSubArray(vector<int>& nums) {
int maxSum = nums[0], currSum = nums[0];
for (int i = 1; i < nums.size(); ++i) {
currSum = max(nums[i], currSum + nums[i]);
maxSum = max(maxSum, currSum);
}
return maxSum;
}
};
1.4.3 DP算法解析
- currSum表示以当前元素结尾的最大子数组和
- 对于每个元素,有两种选择:
- 单独作为一个新子数组(nums[i])
- 加入前一个元素结尾的子数组(currSum + nums[i])
- 取两者中较大的作为新的currSum
- 同时维护全局最大值maxSum
1.5 三种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|
| 滑动窗口 | O(n) | O(1) | 中等 | 需要明确窗口收缩条件时 |
| 贪心算法 | O(n) | O(1) | 简单 | 问题具有贪心选择性质时 |
| 动态规划 | O(n) | O(1) | 简单 | 最优子结构明显的问题 |
提示:在实际面试中,推荐优先使用动态规划解法,因为它最简洁且效率高。但在理解问题时,可以先从滑动窗口或贪心算法入手。
2. 合并区间问题详解
合并区间是另一个常见的数组处理问题,要求将所有重叠的区间合并。
2.1 问题描述与示例
给定一个区间集合,合并所有重叠的区间。
示例:
输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间[1,3]和[2,6]重叠,合并为[1,6]
2.2 解题思路
- 首先按照区间的起始位置排序
- 初始化结果列表,放入第一个区间
- 遍历后续区间:
- 如果当前区间与结果列表中最后一个区间重叠,则合并
- 否则直接加入结果列表
2.3 代码实现
java复制class Solution {
public int[][] merge(int[][] intervals) {
if (intervals.length <= 1) return intervals;
// 按起始位置排序
Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
List<int[]> result = new ArrayList<>();
int[] current = intervals[0];
result.add(current);
for (int[] interval : intervals) {
if (interval[0] <= current[1]) { // 重叠
current[1] = Math.max(current[1], interval[1]); // 合并
} else {
current = interval;
result.add(current);
}
}
return result.toArray(new int[result.size()][]);
}
}
2.4 关键点分析
- 排序是前提:必须先按起始位置排序,才能保证后续合并的正确性
- 合并条件:当前区间的start <= 前一个区间的end
- 合并方式:取两个区间end的较大值作为新区间的end
- 边界处理:空数组或单元素数组直接返回
3. 轮转数组问题解析
轮转数组问题要求将数组元素向右轮转k个位置。
3.1 问题示例
示例:
输入:nums = [1,2,3,4,5,6,7], k = 3
输出:[5,6,7,1,2,3,4]
解释:
向右轮转1步:[7,1,2,3,4,5,6]
向右轮转2步:[6,7,1,2,3,4,5]
向右轮转3步:[5,6,7,1,2,3,4]
3.2 切片拼接法
最直观的方法是切片拼接:将后k个元素和前n-k个元素拼接。
javascript复制function rotate(nums, k) {
k = k % nums.length; // 处理k大于数组长度的情况
const rotated = nums.slice(-k).concat(nums.slice(0, -k));
for (let i = 0; i < nums.length; i++) {
nums[i] = rotated[i];
}
}
3.3 三次反转法
更高效的方法是使用三次反转:
- 反转整个数组
- 反转前k个元素
- 反转剩余元素
c复制void reverse(int* nums, int start, int end) {
while (start < end) {
int temp = nums[start];
nums[start] = nums[end];
nums[end] = temp;
start++;
end--;
}
}
void rotate(int* nums, int numsSize, int k) {
k = k % numsSize;
reverse(nums, 0, numsSize - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, numsSize - 1);
}
3.4 方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 切片拼接 | O(n) | O(n) | 简单直观但需要额外空间 |
| 三次反转 | O(n) | O(1) | 原地操作但逻辑稍复杂 |
4. 除自身以外数组的乘积
这个问题要求计算数组中每个元素除自身外所有其他元素的乘积。
4.1 问题示例
示例:
输入:nums = [1,2,3,4]
输出:[24,12,8,6]
4.2 前后缀乘积法
标准解法是使用前缀积和后缀积:
- 计算每个元素的前缀积(左边所有元素的乘积)
- 计算每个元素的后缀积(右边所有元素的乘积)
- 结果就是前缀积 * 后缀积
java复制class Solution {
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
int[] res = new int[n];
// 计算前缀积
res[0] = 1;
for (int i = 1; i < n; i++) {
res[i] = res[i-1] * nums[i-1];
}
// 计算后缀积并同时计算结果
int right = 1;
for (int i = n-1; i >= 0; i--) {
res[i] = res[i] * right;
right *= nums[i];
}
return res;
}
}
4.3 零元素处理
当数组中存在0时:
- 如果有两个及以上0,结果全为0
- 如果只有一个0,则只有该位置的结果不为0
5. 缺失的第一个正数
这个问题要求找出数组中缺失的最小正整数。
5.1 问题示例
示例1:
输入:nums = [1,2,0]
输出:3
示例2:
输入:nums = [3,4,-1,1]
输出:2
5.2 原地哈希法
最优解法是使用原地哈希,将每个正整数x放到数组的x-1位置。
java复制class Solution {
public int firstMissingPositive(int[] nums) {
int n = nums.length;
// 第一遍遍历:将数字放到正确的位置
for (int i = 0; i < n; i++) {
while (nums[i] > 0 && nums[i] <= n && nums[nums[i]-1] != nums[i]) {
swap(nums, i, nums[i]-1);
}
}
// 第二遍遍历:找到第一个位置不正确的数
for (int i = 0; i < n; i++) {
if (nums[i] != i+1) {
return i+1;
}
}
return n+1;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
5.3 算法分析
- 时间复杂度:O(n),每个元素最多被交换一次
- 空间复杂度:O(1),原地操作
- 关键点:利用数组本身作为哈希表,将数字x放到x-1的位置
6. 总结与实战建议
在解决数组类算法问题时,有几个通用技巧:
- 双指针技巧:适用于需要维护窗口或前后指针的问题
- 排序预处理:很多问题在排序后会变得简单
- 贪心思想:局部最优可能导致全局最优
- 动态规划:适用于有重叠子问题和最优子结构的问题
- 原地操作:尽量使用O(1)额外空间
实际编码时要注意:
- 边界条件处理(空数组、单元素数组等)
- 索引越界检查
- 变量初始化
- 循环不变量的维护
对于面试准备,建议:
- 理解每种解法的核心思想
- 能够手动模拟算法执行过程
- 比较不同解法的优缺点
- 针对问题特点选择最合适的解法