前言
子数组是算法中最常见的一类问题,我们最重要的是要掌握问题底层的核心逻辑,而不是无效刷题,不断堆题量。
根据以前几年比赛经验,总结了下,子数组问题中的几种优化方式,并分享了下思考问题的方式。希望能给予仍在算法中不断挣扎的朋友,一点帮助。
深入理解问题:子数组问题的底层性质
给定一个含有 n 个正整数的数组和一个正整数 target
找出该数组中满足其总和大于等于 target 的长度最小的 子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度 。 如果不存在符合条件的子数组,返回 0 。
题意解析
在解决子数组问题时,首先需要深入理解问题的底层性质。题目要求我们找到一个长度最小的子数组,使其总和大于等于给定的目标值。通过拆解题目,我们可以提取出几个关键概念:
- 子数组总和:需要计算连续子数组的和。
- 长度最小:大于等于目标的长度最小
- 连续性:子数组必须是连续的,不能跳跃。
- 单调性:随着子数组长度的增加,其总和也会增加,这为我们提供了优化的方向。 尤其是题目中的单调性,但凡出现,都可以往二分、滑动窗口去思考.
从暴力开始:枚举与前缀和的初步优化
在解决任何问题时,最直观的方法往往是从暴力解法入手。对于子数组问题,我们可以通过枚举所有可能的子数组,计算它们的和,并找到满足条件的最小长度。 核心思路:
- 枚举所有可能的子数组起点和长度。
- 计算每个子数组的和,判断是否满足条件。
- 记录满足条件的最小长度。
优化:前缀和
题目满足了连续性,而计算子区间连续和,根据算法积累,可以明确知道用前缀和去优化。(看到连续性、区间和就要想到前缀和)
前缀和数组可以在 O(1) 时间内计算任意子数组的和,从而将暴力解法的时间复杂度从 O(n^3) 优化到 O(n^2)。
代码示例
n := len(nums)
sum := make([]int, n)
// 这里sum从1开始,方便计算前缀和,是前缀和的小技巧
for i:=1; i<= n;i ++{
sum[i] += sum[i-1] + nums[i-1] // nums从0 到 n-1
}
ans := 0
for length := 2 ;length <= n;i++{
// 考虑枚举的对象是sum 所以从1开始
for left := 1;i <= n - length + 1;left ++{
// 这里确定 < 多少, 有个技巧: 举个例尝试
// 比如 n是4 length是2 那我们最多枚举left到3 所以 i <= n - length + 1
tmpAns := sum[right] -sum[left-1]// 前缀和公式 right - (left-1)
ans = max(ans, tmpAns)
}
}
利用单调性:二分查找的巧妙应用O(logn)
我们再回过头去思考题干,有什么可利用的性质。
通过观察子数组长度与总和之间的关系,我们可以发现它们具有单调性:随着子数组长度的增加,其总和也会增加。
可以假设:一个x长度的子数组总和为y。
那么容易想得到,当x+1,y一定是增加的;当x-1,y也一定是减少的;由此可以递推到x=1,x=2,...,x=n,
x和y之间具有单调性,因此可以考虑二分x,判断y是否>=目标,从而得到长度最小.
二分查找的核心思路:
- 二分查找的目标是子数组的长度。
- 对于每个可能的长度,检查是否存在一个子数组,其总和大于等于目标值。
- 如果存在,尝试更小的长度;如果不存在,尝试更大的长度。
二分code
func binarySearch(nums []int, target int, sum []int) int{
lo := 1
hi := len(nums)
ans := 0
n := len(nums)
for lo <= hi {
mid := (lo + hi) / 2
res := 0
for i:=1; i <= n - mid + 1; i++{
right := i+mid - 1
res = max(res, sum[right] -sum[i-1])
}
if res > target{
ans = mid
hi = mid - 1
}else if res < target{
lo = mid + 1
}else {
ans = mid
hi = mid - 1
}
}
return ans
}
func minSubArrayLen(target int, nums []int) int {
// 满足连续性
// 先想暴力
// 枚举长度. 求区间和最大值.
// 想到前缀和优化. 这样枚举长度是O(长度n)*O(起始位置(n))
// 这个算法能优化吗? 从长度最大的区间和开始枚举呢?长度越小的区间,最大和一定越小吧。
// 那说明满足单调性,那就可以二分数组长度
// 显然能想到, 子数组长度越短. 子数组最大和一定更小, 满足单调性,我们就二分长度即可.
n := len(nums)
sum := make([]int, n + 1)
for i := 1; i <= n; i++{
sum[i] += nums[i - 1]
if i != 0 {
sum[i] += sum[i - 1]
}
}
return binarySearch(nums, target, sum)
}
滑动窗口:单调性与连续性的完美结合O(n)
滑动窗口是一种基于单调性和连续性的高效算法。通过维护一个窗口,我们可以在 O(n) 的时间内找到满足条件的最小长度子数组。
单调性:即为长度递增,结果一定递增
连续性:子数组连续,并且是区间
连续的子数组和最大,结果一定是一个区间窗口。再进一步想到,如果结果同时满足单调性和连续性,我们就可以用滑动窗口:当满足结果时,可以缩短左窗口变小;不满足时,扩充右窗口一定能变大。
滑动窗口的核心思路:
- 使用两个指针,分别表示窗口的左右边界。
- 移动右指针,扩展窗口,直到窗口内的总和大于等于目标值。
- 移动左指针,收缩窗口,尝试找到更小的满足条件的子数组。
code
// 满足连续性+单调性的另一个神技,滑动窗口,或者说滑动区间
// 滑动窗口的特性. 必须满足连续性+单调性
func minSubArrayLen(target int, nums []int) int {
q := []int{}
hh := 0
qsum := 0
ans := math.MaxInt64
n := len(nums)
for i:=0;i<n;i++{
val := nums[i]
q = append(q, i)
qsum += val
for hh <= i && qsum >= target{
ans = min(ans, i - hh + 1)
qsum -= nums[q[hh]]
hh++
}
}
if ans == math.MaxInt64{
return 0
}
return ans
}
总结
思考一个问题,最重要的还是要理解问题本身的性质,从特殊归纳到一般,去除杂质,抽取出单调性、连续性等,再选择合适的算法去优化
总结要点:
- 暴力解法:从最直观的方法入手,理解问题的基本要求。
- 前缀和优化:通过预处理数据,减少重复计算,提升效率。
- 二分查找:利用单调性,将时间复杂度从 O(n^2) 降低到 O(n log n)。
- 滑动窗口:结合单调性和连续性,将时间复杂度进一步降低到 O(n)。
无论最终用什么算法,都是可以学习掌握的。比如比如区间和,想到前缀和;单调性,想到二分;单调性+连续性,想到滑动窗口。
但最重要的是理解问题的本质,合理归纳,并从中提取出通用的规律。正如“术与道”的关系,算法是“术”,而理解问题的本质是“道”。只有掌握了“道”,才能灵活运用各种“术”,解决复杂的问题。