滑动窗口下的极限挑战:我在实时数据流中挖掘最大价值分(1695. 删除子数组的最大得分)

滑动窗口下的极限挑战:我在实时数据流中挖掘最大价值分 😎

嘿,各位在代码世界里遨游的伙伴们!我是你们的老朋友,一个坚信"好代码能说话"的开发者。今天,我要和大家分享一次我在项目中遇到的真实挑战,以及我是如何从一道非常有趣的算法题------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:元素值是正整数,且范围不大。这暗示我们可以用一个数组来代替哈希表做频率统计(桶排序的思想),但对于这个问题,哈希表可能更直观。

我是如何用"滑动窗口"丝滑地解决它的

看到"连续子数组"这种关键词,我的"算法雷达"立刻就锁定了滑动窗口这个强大的技术。这就像在一条长长的纸带上,用一个可伸缩的框框来寻找符合条件的片段。

解法一:标准滑动窗口 ------ 稳扎稳打,步步为营

这是最经典、最直观的滑动窗口实现。我们需要维护一个"窗口",并保证这个窗口内的元素始终是唯一的。

我的思路是这样的:

  • 定义窗口 :用 leftright 两个指针代表窗口的左右边界。
  • 检查唯一性 :我需要一个能快速告诉我"这个元素在窗口里吗?"的数据结构。HashSet 简直是为这个场景量身定做的,它的 contains, add, remove 操作平均时间复杂度都是 O(1)。
  • 窗口的"伸"与"缩"
    1. right 指针不断向右移动,试图"吃"进新元素,这是扩张
    2. 在吃进新元素 nums[right] 之前,我们检查 HashSet。如果 HashSet里已经有了 nums[right],说明窗口内出现了重复,我们必须收缩窗口。
    3. 收缩时,left 指针向右移动,并从 HashSet 和当前的总和 currentSum 中移除 nums[left]。这个过程一直持续,直到 nums[right] 不再是重复元素为止。

踩坑与顿悟 😉:我最初的担忧是,那个 while 收缩循环会不会让整体复杂度退化成 O(N^2)?后来我恍然大悟:并不会!因为 leftright 指针从头到尾都只向右移动,每个元素最多被 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 指针能不能不一步步挪,而是一次性"跳"到正确的位置?

答案是肯定的!为此,我们需要升级我们的工具:

  • HashSetHashMapHashSet 只能告诉我们元素在不在 ,而 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;
    }
}

举一反三,滑动窗口的应用远不止此

这个滑动窗口的模式,简直是解决各种"子串/子数组"问题的瑞士军刀:

  1. 最长无重复字符的子串(LeetCode 3:今天问题的变体,不求和,求最大长度。
  2. 串联所有单词的子串(LeetCode 30:更复杂的滑动窗口,窗口大小固定,但需要验证窗口内的单词构成是否满足要求。
  3. 最小覆盖子串(LeetCode 76 :在一个字符串中找到包含另一个字符串所有字符的最小子串。
  4. 服务端日志分析:在一段时间的日志流中,找到包含"error"、"exception"、"timeout" 这几个关键词的最短时间窗口,以快速定位问题。

最终,我用优化后的滑动窗口方案实现了用户行为分析功能,系统运行得又快又稳。从一个实际的业务需求出发,回归到经典的算法模型,再用代码将其实现,这个过程本身就是一种享受,不是吗?😉


两种解法对比表格

对比维度 解法1: 标准滑动窗口 解法2: 优化滑动窗口 + 前缀和
核心思想 HashSet + 内循环收缩窗口 HashMap + 前缀和 + 左指针跳跃
窗口调整方式 左指针逐步挪动 左指针一次性跳跃
求和方式 实时变量 currentSum 预计算 prefixSum 数组
优点 代码更直观,空间消耗可能更低(当唯一元素少时)。 逻辑更显技巧,避免了内循环,常数时间上可能更优。
缺点 存在内层循环(虽然均摊复杂度仍是线性的)。 代码稍复杂,且prefixSum数组需要固定的O(N)空间。
适用场景 通用首选,易于理解和实现。 对性能有极致追求,或在面试中展示对滑动窗口模式的深入理解。
相关推荐
Asmalin2 小时前
【代码随想录day 29】 力扣 135.分发糖果
算法·leetcode·职场和发展
微笑尅乐2 小时前
多解法详解与边界处理——力扣7.整数反转
算法·leetcode·职场和发展
夏鹏今天学习了吗2 小时前
【LeetCode热题100(31/100)】K 个一组翻转链表
算法·leetcode·链表
薰衣草23332 小时前
力扣——位运算
python·算法·leetcode
未知陨落2 小时前
LeetCode:83.打家劫舍
算法·leetcode
Pluchon2 小时前
硅基计划4.0 算法 字符串
java·数据结构·学习·算法
三年呀2 小时前
共识算法的深度探索:从原理到实践的全面指南
算法·区块链·共识算法·分布式系统·区块链技术·高性能优化
alex1003 小时前
BeaverTails数据集:大模型安全对齐的关键资源与实战应用
人工智能·算法·安全
麦格芬2303 小时前
LeetCode 416 分割等和子集
数据结构·算法
2401_841495644 小时前
【自然语言处理】Universal Transformer(UT)模型
人工智能·python·深度学习·算法·自然语言处理·transformer·ut