滑动窗口下的极限挑战:我在实时数据流中挖掘最大价值分 😎
嘿,各位在代码世界里遨游的伙伴们!我是你们的老朋友,一个坚信"好代码能说话"的开发者。今天,我要和大家分享一次我在项目中遇到的真实挑战,以及我是如何从一道非常有趣的算法题------LeetCode 1695. 删除子数组的最大得分------中找到解题的钥匙,并最终打造出高效解决方案的。这绝对是一次充满"啊哈!"时刻的旅程!
我遇到了什么问题?
我最近在负责一个实时用户行为分析系统 。系统会接收一连串的用户事件流,每个事件(比如点击、浏览、购买)都有一个对应的"价值分"。产品经理提出了一个需求:我们需要识别出用户在短时间内不重复的、高价值的行为序列。
具体来说,就是在一个连续的事件序列中,如果用户的行为都是各不相同 的(比如,先浏览商品、再加入购物车、然后去支付),那么这个序列就是高质量的。我们需要找到这样的"高质量序列",并计算出其总价值分,目标是找到所有可能序列中的最大价值分。
这个需求被我迅速抽象成了这道算法题的核心:
在一个数字数组(事件价值分)中,找到一个元素唯一的连续子数组 ,并使其元素和最大。
例如,对于价值流 [4,2,4,5,6]
,最优的序列是 [2,4,5,6]
,因为其中的元素 2,4,5,6
互不相同,并且它们的和 17
是所有可能组合中最大的。
先看看"通关提示"(题目提示解读)
开工前,瞄一眼提示是我们的好习惯:
1 <= nums.length <= 10^5
:数组长度最长可达十万!这个量级直接给 O(N^2) 的暴力解法判了死刑。任何检查所有子数组(两层循环)的想法都可以直接pass了。我们的目标必须是 O(N) 或 O(N log N) 的高效算法。1 <= nums[i] <= 10^4
:元素值是正整数,且范围不大。这暗示我们可以用一个数组来代替哈希表做频率统计(桶排序的思想),但对于这个问题,哈希表可能更直观。
我是如何用"滑动窗口"丝滑地解决它的
看到"连续子数组"这种关键词,我的"算法雷达"立刻就锁定了滑动窗口这个强大的技术。这就像在一条长长的纸带上,用一个可伸缩的框框来寻找符合条件的片段。
解法一:标准滑动窗口 ------ 稳扎稳打,步步为营
这是最经典、最直观的滑动窗口实现。我们需要维护一个"窗口",并保证这个窗口内的元素始终是唯一的。
我的思路是这样的:
- 定义窗口 :用
left
和right
两个指针代表窗口的左右边界。 - 检查唯一性 :我需要一个能快速告诉我"这个元素在窗口里吗?"的数据结构。
HashSet
简直是为这个场景量身定做的,它的contains
,add
,remove
操作平均时间复杂度都是 O(1)。 - 窗口的"伸"与"缩" :
right
指针不断向右移动,试图"吃"进新元素,这是扩张。- 在吃进新元素
nums[right]
之前,我们检查HashSet
。如果HashSet
里已经有了nums[right]
,说明窗口内出现了重复,我们必须收缩窗口。 - 收缩时,
left
指针向右移动,并从HashSet
和当前的总和currentSum
中移除nums[left]
。这个过程一直持续,直到nums[right]
不再是重复元素为止。
踩坑与顿悟 😉:我最初的担忧是,那个 while
收缩循环会不会让整体复杂度退化成 O(N^2)?后来我恍然大悟:并不会!因为 left
和 right
指针从头到尾都只向右移动,每个元素最多被 right
指针访问一次,被 left
指针访问一次。所以均摊下来,总时间复杂度依然是 O(N),这就是滑动窗口的魅力!
java
/*
* 思路:标准滑动窗口。使用left/right指针定义窗口,HashSet维护窗口内元素的唯一性。
* right指针扩张窗口,当遇到重复元素时,内层while循环收缩left指针,直到窗口恢复唯一。
* 实时维护窗口和,并更新最大得分。
* 时间复杂度:O(N),每个元素最多进出窗口一次。
* 空间复杂度:O(M),M为数组中不同元素的数量。
*/
import java.util.HashSet;
import java.util.Set;
class Solution {
public int maximumUniqueSubarray(int[] nums) {
int left = 0;
int maxScore = 0;
int currentSum = 0;
// 为什么用HashSet?因为它提供了平均O(1)的add, remove, contains操作,
// 是维护窗口唯一性的不二之选,保证了算法的整体性能。
Set<Integer> windowElements = new HashSet<>();
for (int right = 0; right < nums.length; right++) {
// 当新元素已存在于窗口中时,开始收缩
while (windowElements.contains(nums[right])) {
// 将最左侧的元素踢出窗口
windowElements.remove(nums[left]);
currentSum -= nums[left];
left++; // 左边界向右移动
}
// 此刻窗口对新元素是安全的,将其纳入
windowElements.add(nums[right]);
currentSum += nums[right];
// 每次窗口状态合法时,都尝试更新最大得分
maxScore = Math.max(maxScore, currentSum);
}
return maxScore;
}
}
解法二:优化滑动窗口 ------ 从"挪动"到"跳跃"
虽然解法一已经很棒了,但作为一个追求极致的开发者,我在想:那个 while
循环能不能也省掉?left
指针能不能不一步步挪,而是一次性"跳"到正确的位置?
答案是肯定的!为此,我们需要升级我们的工具:
- 从
HashSet
到HashMap
:HashSet
只能告诉我们元素在不在 ,而HashMap<Integer, Integer>
可以告诉我们元素上一次出现在哪个位置(value -> index)
。 - 从
currentSum
到前缀和
:因为left
指针要跳跃,实时维护currentSum
变得复杂。我们可以预先计算一个prefixSum
数组,这样任何子数组[l, r]
的和都能用prefixSum[r+1] - prefixSum[l]
在 O(1) 时间内算出来。
新策略 :当 right
指针遇到 nums[right]
,我们查 HashMap
找到它上一次出现的位置 lastIndex
。如果 lastIndex
在当前窗口内(>= left
),我们就让 left
指针直接跳到 lastIndex + 1
的位置,一个崭新的、无重复的窗口就瞬间形成了!
java
/*
* 思路:优化的滑动窗口 + 前缀和。left指针直接跳跃到新位置,避免了内层循环。
* 使用 HashMap 记录元素值及其最新索引。使用前缀和数组在O(1)内计算子数组和。
* 当right指针遇到重复时,根据HashMap记录的位置,更新left指针,保证其不后退且跳到正确位置。
* 时间复杂度:O(N)。
* 空间复杂度:O(N),主要用于前缀和数组和HashMap。
*/
import java.util.HashMap;
import java.util.Map;
class Solution {
public int maximumUniqueSubarray(int[] nums) {
int n = nums.length;
// 1. 预处理前缀和数组,为O(1)计算子数组和做准备
int[] prefixSum = new int[n + 1];
for (int i = 0; i < n; i++) {
prefixSum[i + 1] = prefixSum[i] + nums[i];
}
// 2. 滑动窗口
// HashMap: value -> last_seen_index
Map<Integer, Integer> lastSeenIndex = new HashMap<>();
int left = 0;
int maxScore = 0;
for (int right = 0; right < n; right++) {
int currentNum = nums[right];
if (lastSeenIndex.containsKey(currentNum)) {
// 这是关键!left指针只能前进不能后退。
// 如果找到的重复元素位置比left还靠前,那它早已不在窗口内,无需理会。
left = Math.max(left, lastSeenIndex.get(currentNum) + 1);
}
// 更新当前元素的最新位置
lastSeenIndex.put(currentNum, right);
// 计算当前有效窗口 [left, right] 的得分,并更新最大值
maxScore = Math.max(maxScore, prefixSum[right + 1] - prefixSum[left]);
}
return maxScore;
}
}
举一反三,滑动窗口的应用远不止此
这个滑动窗口的模式,简直是解决各种"子串/子数组"问题的瑞士军刀:
- 最长无重复字符的子串(LeetCode 3):今天问题的变体,不求和,求最大长度。
- 串联所有单词的子串(LeetCode 30):更复杂的滑动窗口,窗口大小固定,但需要验证窗口内的单词构成是否满足要求。
- 最小覆盖子串(LeetCode 76) :在一个字符串中找到包含另一个字符串所有字符的最小子串。
- 服务端日志分析:在一段时间的日志流中,找到包含"error"、"exception"、"timeout" 这几个关键词的最短时间窗口,以快速定位问题。
最终,我用优化后的滑动窗口方案实现了用户行为分析功能,系统运行得又快又稳。从一个实际的业务需求出发,回归到经典的算法模型,再用代码将其实现,这个过程本身就是一种享受,不是吗?😉
两种解法对比表格
对比维度 | 解法1: 标准滑动窗口 | 解法2: 优化滑动窗口 + 前缀和 |
---|---|---|
核心思想 | HashSet + 内循环收缩窗口 |
HashMap + 前缀和 + 左指针跳跃 |
窗口调整方式 | 左指针逐步挪动 | 左指针一次性跳跃 |
求和方式 | 实时变量 currentSum |
预计算 prefixSum 数组 |
优点 | 代码更直观,空间消耗可能更低(当唯一元素少时)。 | 逻辑更显技巧,避免了内循环,常数时间上可能更优。 |
缺点 | 存在内层循环(虽然均摊复杂度仍是线性的)。 | 代码稍复杂,且prefixSum 数组需要固定的O(N)空间。 |
适用场景 | 通用首选,易于理解和实现。 | 对性能有极致追求,或在面试中展示对滑动窗口模式的深入理解。 |