外观
53. 最大子数组和
约 3211 字大约 11 分钟
数组动态规划分治法
2025-05-18
引言
最大子数组和问题是算法面试中的经典题目,也是动态规划的入门级问题。在实际开发中,我们常常需要在数据序列中找出具有某种特性的连续片段,比如股票交易中寻找最大收益区间、信号处理中识别有效信号段等。这类问题的核心思想与最大子数组和问题高度相似,因此掌握这一算法对于工程实践有着重要意义。
审题
在面试中,我们首先需要向面试官确认问题的细节:
"这道题要求我找出一个整数数组中具有最大和的连续子数组,并返回其最大和,对吗?"
"子数组是指原数组中的一个连续部分,至少包含一个元素,对吧?"
"输入是一个整数数组,可能包含正数、负数和零,数组长度范围是多少?元素的取值范围是什么?"
"对于边界情况,如果数组为空应该返回什么?如果数组中全是负数,应该返回什么?"
根据题目描述,我们可以明确:
- 输入:一个整数数组
nums
,长度在 1 到 10^5 之间,元素值在 -10^4 到 10^4 之间 - 输出:具有最大和的连续子数组的和
- 子数组必须是连续的,且至少包含一个元素
- 如果数组全为负数,应返回最大的那个负数(即绝对值最小的负数)
解析
思路概述
解决最大子数组和问题有多种方法,包括暴力法、分治法和动态规划法。其中,动态规划法(特别是Kadane算法)是最为高效的解法,时间复杂度为O(n),空间复杂度为O(1)。
Kadane算法的核心思想是:对于数组中的每个元素,我们有两种选择:
- 将其作为新子数组的起点
- 将其加入到前面已形成的子数组中
我们选择这两种情况中能够产生更大和的那一种,并不断更新全局最大和。
算法步骤
- 初始化两个变量:
currentSum
表示以当前元素结尾的最大子数组和,maxSum
表示全局最大子数组和,初始值都为数组第一个元素 - 从数组的第二个元素开始遍历
- 对于每个元素,更新
currentSum = max(nums[i], currentSum + nums[i])
- 更新全局最大和
maxSum = max(maxSum, currentSum)
- 遍历结束后,返回
maxSum
下图展示了Kadane算法处理数组 [-2,1,-3,4,-1,2,1,-5,4]
的过程:
从图中可以看出,当我们遍历到数组中的正数元素4时,由于前面子数组的和为负,选择重新开始一个子数组更优。而当遍历到-1时,虽然它是负数,但加入到当前子数组后的和仍然是正的,所以选择将其加入。这种动态决策的过程正是Kadane算法的精髓。
源码
核心代码模式
Java
class Solution {
public int maxSubArray(int[] nums) {
// 特殊情况处理
if (nums == null || nums.length == 0) {
return 0;
}
// 初始化当前子数组和与最大子数组和
int currentSum = nums[0];
int maxSum = nums[0];
// 从第二个元素开始遍历数组
for (int i = 1; i < nums.length; i++) {
// 状态转移:当前位置的最大子数组和 = max(当前元素, 当前元素+前一位置的最大子数组和)
currentSum = Math.max(nums[i], currentSum + nums[i]);
// 更新全局最大子数组和
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
}
}
Python
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
# 特殊情况处理
if not nums:
return 0
# 初始化当前子数组和与最大子数组和
current_sum = nums[0]
max_sum = nums[0]
# 从第二个元素开始遍历数组
for i in range(1, len(nums)):
# 状态转移:当前位置的最大子数组和 = max(当前元素, 当前元素+前一位置的最大子数组和)
current_sum = max(nums[i], current_sum + nums[i])
# 更新全局最大子数组和
max_sum = max(max_sum, current_sum)
return max_sum
JavaScript
/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function(nums) {
// 特殊情况处理
if (!nums || nums.length === 0) {
return 0;
}
// 初始化当前子数组和与最大子数组和
let currentSum = nums[0];
let maxSum = nums[0];
// 从第二个元素开始遍历数组
for (let i = 1; i < nums.length; i++) {
// 状态转移:当前位置的最大子数组和 = max(当前元素, 当前元素+前一位置的最大子数组和)
currentSum = Math.max(nums[i], currentSum + nums[i]);
// 更新全局最大子数组和
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
};
Go
func maxSubArray(nums []int) int {
// 特殊情况处理
if len(nums) == 0 {
return 0
}
// 初始化当前子数组和与最大子数组和
currentSum := nums[0]
maxSum := nums[0]
// 从第二个元素开始遍历数组
for i := 1; i < len(nums); i++ {
// 状态转移:当前位置的最大子数组和 = max(当前元素, 当前元素+前一位置的最大子数组和)
if nums[i] > currentSum+nums[i] {
currentSum = nums[i]
} else {
currentSum = currentSum + nums[i]
}
// 更新全局最大子数组和
if currentSum > maxSum {
maxSum = currentSum
}
}
return maxSum
}
ACM模式
Java
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
// 读取数组长度
int n = scanner.nextInt();
int[] nums = new int[n];
// 读取数组元素
for (int i = 0; i < n; i++) {
nums[i] = scanner.nextInt();
}
// 调用解决方案
int result = maxSubArray(nums);
System.out.println(result);
scanner.close();
}
public static int maxSubArray(int[] nums) {
// 特殊情况处理
if (nums == null || nums.length == 0) {
return 0;
}
// 初始化当前子数组和与最大子数组和
int currentSum = nums[0];
int maxSum = nums[0];
// 从第二个元素开始遍历数组
for (int i = 1; i < nums.length; i++) {
// 状态转移:当前位置的最大子数组和 = max(当前元素, 当前元素+前一位置的最大子数组和)
currentSum = Math.max(nums[i], currentSum + nums[i]);
// 更新全局最大子数组和
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
}
}
Python
def max_sub_array(nums):
# 特殊情况处理
if not nums:
return 0
# 初始化当前子数组和与最大子数组和
current_sum = nums[0]
max_sum = nums[0]
# 从第二个元素开始遍历数组
for i in range(1, len(nums)):
# 状态转移:当前位置的最大子数组和 = max(当前元素, 当前元素+前一位置的最大子数组和)
current_sum = max(nums[i], current_sum + nums[i])
# 更新全局最大子数组和
max_sum = max(max_sum, current_sum)
return max_sum
if __name__ == "__main__":
# 读取数组长度
n = int(input().strip())
# 读取数组元素
nums = list(map(int, input().strip().split()))
# 调用解决方案
result = max_sub_array(nums)
print(result)
JavaScript
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
let lines = [];
rl.on('line', (line) => {
lines.push(line);
// 当读取到两行输入时,处理输入并输出结果
if (lines.length === 2) {
const n = parseInt(lines[0]);
const nums = lines[1].split(' ').map(Number);
// 调用解决方案
const result = maxSubArray(nums);
console.log(result);
rl.close();
}
});
/**
* 最大子数组和函数
* @param {number[]} nums
* @return {number}
*/
function maxSubArray(nums) {
// 特殊情况处理
if (!nums || nums.length === 0) {
return 0;
}
// 初始化当前子数组和与最大子数组和
let currentSum = nums[0];
let maxSum = nums[0];
// 从第二个元素开始遍历数组
for (let i = 1; i < nums.length; i++) {
// 状态转移:当前位置的最大子数组和 = max(当前元素, 当前元素+前一位置的最大子数组和)
currentSum = Math.max(nums[i], currentSum + nums[i]);
// 更新全局最大子数组和
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
}
Go
package main
import (
"fmt"
)
func main() {
// 读取数组长度
var n int
fmt.Scan(&n)
// 读取数组元素
nums := make([]int, n)
for i := 0; i < n; i++ {
fmt.Scan(&nums[i])
}
// 调用解决方案
result := maxSubArray(nums)
fmt.Println(result)
}
// 最大子数组和函数
func maxSubArray(nums []int) int {
// 特殊情况处理
if len(nums) == 0 {
return 0
}
// 初始化当前子数组和与最大子数组和
currentSum := nums[0]
maxSum := nums[0]
// 从第二个元素开始遍历数组
for i := 1; i < len(nums); i++ {
// 状态转移:当前位置的最大子数组和 = max(当前元素, 当前元素+前一位置的最大子数组和)
if nums[i] > currentSum+nums[i] {
currentSum = nums[i]
} else {
currentSum = currentSum + nums[i]
}
// 更新全局最大子数组和
if currentSum > maxSum {
maxSum = currentSum
}
}
return maxSum
}
吊打面试官
在面试中,面试官可能会针对最大子数组和问题提出一些深入的问题,下面我们来看看如何应对这些问题:
1. 该算法的时间复杂度和空间复杂度是多少?如何计算的?
回答:
我们使用的Kadane算法的时间复杂度是O(n),空间复杂度是O(1)。
时间复杂度分析:算法只需要遍历数组一次,对于长度为n的数组,执行n次基本操作(比较和加法),因此时间复杂度为O(n)。
空间复杂度分析:算法只使用了两个变量(currentSum和maxSum)来存储中间状态,不管输入数组的大小如何,额外空间使用量都是常数级别的,因此空间复杂度为O(1)。
相比之下,暴力解法需要考虑所有可能的子数组,时间复杂度为O(n²),而分治法的时间复杂度为O(n log n)。Kadane算法在保持低空间复杂度的同时,实现了最优的时间复杂度。
2. 该算法的核心思想是什么?为什么能够正确解决问题?
回答:
Kadane算法的核心思想是动态规划,它利用了问题的最优子结构特性。算法的关键在于维护两个变量:
currentSum
:表示以当前元素结尾的连续子数组的最大和maxSum
:表示全局最大子数组和
对于每个位置i,我们有两种选择:
- 将当前元素作为新子数组的起点(取nums[i])
- 将当前元素加入到前面的子数组中(取currentSum + nums[i])
我们选择这两种情况中的较大值作为新的currentSum,并更新maxSum。
算法能够正确解决问题的原因在于:对于任意位置i,我们已经计算出了以i-1结尾的最大子数组和,基于此我们可以推导出以i结尾的最大子数组和。这种"记住过去,决定现在"的思路正是动态规划的精髓。
3. 如果输入数组全为负数,算法会如何表现?如何处理边界情况?
回答:
如果输入数组全为负数,Kadane算法仍然能够正确工作,它会返回数组中的最大元素(即绝对值最小的负数)。
这是因为当所有元素都为负时,任何包含多个元素的子数组的和都会小于单个元素。在这种情况下,状态转移方程currentSum = max(nums[i], currentSum + nums[i])
会在每一步都选择单个元素而不是累加,因为累加会使和变得更小。
对于其他边界情况:
- 空数组:我们在代码中进行了特殊处理,返回0
- 只有一个元素的数组:算法正常工作,返回该元素
- 全正数组:算法会返回整个数组的和,因为不需要舍弃任何元素
这种对边界情况的良好处理是Kadane算法的一个优势,使其在各种输入下都能稳定工作。
结语
最大子数组和问题是算法面试中的经典题目,也是动态规划思想的绝佳练习场。通过本文的学习,我们不仅掌握了解决这个问题的多种方法,更重要的是理解了背后的思维模式。
在实际开发中,我们常常需要在数据序列中寻找特定的模式或最优子结构,比如股票价格波动分析、信号处理中的峰值检测等。Kadane算法的思想可以迁移到这些场景中,帮助我们高效地解决实际问题。
动态规划的核心在于"记住过去,决定现在"。通过存储和复用中间计算结果,我们避免了重复计算,大大提高了算法效率。而Kadane算法更进一步,将空间复杂度优化到O(1),展示了算法设计的精妙之处。
希望通过这道题目的学习,你能够加深对动态规划的理解,并在面试和实际工作中灵活运用这一强大的算法思想。记住,算法不仅是解题的工具,更是一种思维方式,掌握它将使你在编程的道路上走得更远。