滑动窗口+差分计数:LeetCode 3859 恰好k个不同整数子数组解法
刷 LeetCode 的朋友应该都有过这种体验一道题看起来平平无奇一读题觉得“不就是统计一下子数组嘛”可真要动手写一个不超时的解法又得卡半天。3859 这道“统计包含 k 个不同整数的子数组”就是典型代表。它和经典题 992 基本同源核心考的是滑动窗口和计数思想但我第一次做的时候硬是被“恰好 k 个不同整数”这个条件绕进去了写了个两层循环一提交直接超时。今天就把我踩过的坑和最终打磨出来的解法完整复盘一遍。这道题适合三类人看准备周赛冲分的、面试前端后端被问滑动窗口的以及觉得“双指针 哈希表”这种组合总是差点意思的刷题新手。我会从题目拆解讲起把差分思路、窗口维护细节、边界条件一次说透最后也会聊聊这类题的通用套路。1. 先把题目翻译成人话它到底在数什么1.1 一个例子手算一遍理解“不同整数”题目描述看着很简洁给定一个整数数组 nums 和一个整数 k返回 nums 中“恰好包含 k 个不同整数”的子数组个数。很多人卡住的第一步就是把“不同整数”和“不同位置”搞混。我说的“不同整数”指的是值不一样比如子数组 [1, 2, 1] 里1 出现了两次但它只算一种不同整数所以这个子数组里不同整数个数是 2不是 3。拿一个具体的例子手动跑一遍。假设 nums [1, 2, 1, 2, 3]k 2答案是多少先把所有连续子数组列出来就会发现符合条件的子数组有很多比如 [1, 2] 有两个不同整数[1, 2, 1] 也是两个[2, 1, 2] 也是两个但 [1, 2, 1, 2, 3] 就有三个不同整数不符合。手动数一遍会得到答案 7。这个手算过程很关键它让你意识到子数组是“连续”的不是任选几个位置组合。很多人一开始容易把子数组和子序列搞混子序列可以跳着选元素子数组不行必须是一段紧挨着的区间。滑动窗口能解决这类题前提条件就是这个“连续性”。1.2 暴力枚举的复杂度陷阱理解了题意第一反应通常是暴力枚举所有左端点 i再枚举所有右端点 j用一个集合统计 [i, j] 里不同数字的个数每次往里加元素如果个数等于 k 就计数加一。这个解法逻辑没错但复杂度是 O(n²) 的遇到 n 到 10⁵ 的测试数据直接超时。有人会觉得那我在暴力的时候做点优化比如右指针移动时维护一个频次数组不就能减少一点重复计算吗确实可以减少一部分开销但整体枚举所有区间这个思路没变复杂度仍然是 O(n²)。LeetCode 的题目基本都会把数据范围卡到暴力过不了这道题 n 的上限是 10⁵O(n²) 妥妥超时。这也是刷题的一个基本感觉只要看到“子数组”“连续区间”“恰好/最多”这几个词十有八九是滑动窗口的菜。你不需要在暴力思路上纠结太久直接考虑双指针维护窗口才是正路。1.3 “恰好 k 个”很难但“最多 k 个”很好办滑动窗口最擅长的场景是维护一个“满足某个单调条件”的窗口。比如“最多包含 k 个不同整数”这个条件就很好办窗口内不同整数多了就把左指针往右移直到满足条件为止。但你发现题目要求的是“恰好包含 k 个”这个条件不是单调的——窗口向右扩展时不同整数个数可能从 k 跳到 k1也可能保持 k左指针收缩时也是不规则变化。这时候就需要一个经典思路把“恰好”拆成两个“最多”的差值。也就是说我们定义函数 atMostK(k) 为“包含的不同整数不超过 k 个”的子数组个数那么答案就是 atMostK(k) - atMostK(k-1)。这个等式的逻辑很直白所有不超过 k 个的子数组去掉那些不超过 k-1 个的剩下的就正好是 k 个。当时我想通这一点的时候真的有种“山重水复疑无路柳暗花明又一村”的感觉。后面所有代码的核心就是把这个 atMostK 函数写得又对又快。2. 关键突破口用“最多”差分出“恰好”2.1 atMostK 函数的设计思想atMostK 函数要解决的问题是给定一个窗口内不同整数的上限 k统计所有满足条件的连续子数组个数。这个函数用双指针可以做到 O(n) 时间内完成。具体思路是这样的右指针 right 从 0 开始遍历整个数组每遍历到一个新元素就把它的频次加一。然后检查窗口内不同整数的个数有没有超过 k如果超过了就移动左指针 left同时把左指针指向的元素的频次减一。如果某个元素的频次变成 0就把它从窗口的统计结构中移除。当窗口内不同整数个数重新小于等于 k 时说明以 right 为右端点的所有合法子数组都已经统计到了ans 累加 right - left 1 就行。这个累加“right - left 1”是我最开始不太理解的地方后来才明白它的含义。以 right 为右端点、左端点在 [left, right] 范围内的所有子数组不同整数个数都不会超过 k因为 left 是已经收缩到合法位置的最左边界。比如当前窗口是 nums[2:5]那以 nums[5] 结尾的合法子数组就有 nums[5]、nums[4:5]、nums[3:5]、nums[2:5] 四个正好是 right - left 1。2.2 差分公式的正确性验证数学上这个差分公式很漂亮但实际用的时候要注意一个细节atMostK(0) 是什么如果 k0那么 atMostK(k-1) 就是 atMostK(-1)也就是“不同整数不超过 -1 个”的子数组数量合理结果应该是 0。所以代码里要单独处理 k0 的情况或者让 atMostK 在 k 小于 0 时直接返回 0。再验证一个具体的例子还是 nums [1, 2, 1, 2, 3]k 2。手动数一下“不超过 2 个不同整数”的子数组一共是 12 个再数“不超过 1 个不同整数”的子数组只有 [1]、[2]、[1]、[2]、[3] 这 5 个。两者相减12 - 5 7和第一部分的答案完全一致。这种差分技巧在算法题里其实很普适。比如“恰好等于 k”的问题往往可以转化为“最多为 k”的单调条件来做差。一旦你识别出这个模式很多看起来棘手的计数问题都有了解法入口。2.3 为什么滑动窗口统计子数组数是 right - left 1我刚学滑动窗口的时候一直不理解为什么计数要加 right - left 1而不是直接加 1。后来用手指头数了几遍才彻底想明白。关键在于每一次右指针扩展到一个新位置窗口整体可能是收缩过的但最终是一个合法区间。这个合法区间的所有“后缀子数组”也就是以 right 为结尾的、起点在 left 到 right 之间的所有连续子数组都是符合条件的。举个例子假设某时刻 left 2right 5窗口内是 nums[2], nums[3], nums[4], nums[5]那么以 nums[5] 结尾的子数组有[5]、[4,5]、[3,4,5]、[2,3,4,5]一共四个。这四个的起点分别是从 left 到 right 的 2、3、4、5所以数量就是 right - left 1 4。这里最妙的是即便我们移动过 left窗口内的每个后缀子数组都是新产生的、之前没统计过的所以不会重复计数。这也解释了一个常见误区不要试图维护一个窗口然后在窗口满足条件时“遍历所有子数组”那是 O(n²) 的正确做法是每个 right 只做 O(1) 的累加把整个区间内的子数组批量计数。3. 完整代码实现与逐行拆解3.1 Python 标准解法class Solution: def subarraysWithKDistinct(self, nums: List[int], k: int) - int: return self.atMostK(nums, k) - self.atMostK(nums, k - 1) def atMostK(self, nums: List[int], k: int) - int: if k 0: return 0 freq {} left 0 ans 0 for right, num in enumerate(nums): freq[num] freq.get(num, 0) 1 while len(freq) k: freq[nums[left]] - 1 if freq[nums[left]] 0: del freq[nums[left]] left 1 ans right - left 1 return ans这个解法是标准的双指针 哈希表。我建议你先自己照着思路写一遍再看下面的逐行讲解这样印象更深。3.2 每一行代码在干什么先说主函数。subarraysWithKDistinct 直接返回 atMostK(k) 与 atMostK(k-1) 的差值。因为两次调用互不干扰时间复杂度是 O(n)常数项是 2。有些人觉得可以只用一次遍历搞定“恰好 k 个”但我个人经验是差分解法更好写、更好调面试时也不容易出逻辑漏洞。再讲 atMostK 内部。freq 用字典记录窗口内每个数字出现的次数它的长度就是当前窗口内“不同整数”的个数。left 是左指针right 是右指针ans 是当前条件下累计的子数组数量。每次扩展 right先把 nums[right] 的计数加一然后进入 while 循环检查 len(freq) 是否大于 k如果大于说明窗口不合法需要把左指针右移直到不同整数数量小于等于 k 为止。这里最需要注意的是删除键的时机。freq[nums[left]] - 1 之后如果频次变成了 0一定要 del freq[nums[left]]否则 len(freq) 会多算这个已经不在窗口里的数字。我当时就是漏了这行导致窗口内不同整数数量一直虚高答案偏小排查了好半天才发现。3.3 性能优化用数组替代字典如果 nums[i] 的取值范围有限比如题目明确说 0 nums[i] n可以用数组代替字典把常数时间进一步压低。写法如下def atMostK(self, nums: List[int], k: int) - int: if k 0: return 0 freq [0] * (len(nums) 1) left 0 ans 0 diff 0 for right, num in enumerate(nums): if freq[num] 0: diff 1 freq[num] 1 while diff k: freq[nums[left]] - 1 if freq[nums[left]] 0: diff - 1 left 1 ans right - left 1 return ans这种方法用一个变量 diff 显式维护当前窗口中不同整数的个数数组下标就是数字本身访问速度比字典快。LeetCode 上两种写法都能通过但数组版本通常能快个几十毫秒。如果你不确定数据范围字典版本更稳妥如果知道范围数组版本更推荐。3.4 时间与空间复杂度时间复杂度方面每个元素最多被左指针和右指针各访问一次整体是 O(n)。两个 atMostK 调用合起来常数是 2所以整体仍然是 O(n)。空间复杂度上字典版本是 O(n)因为极端情况下窗口内可以包含 n 个不同整数数组版本是 O(n)分配了 n1 长度的数组。从刷题角度看这个复杂度已经是线性最优了面试官对这个答案通常会很满意。千万注意别把 freq 字典实现成每次进入 while 都调用一次 Counter(nums[left:right])那是 O(n²) 级别的操作完全违背了滑动窗口“增量维护”的核心思想。我第一次写的版本就踩了这个坑。4. 实战中的坑边界条件与调试技巧4.1 k0 和空数组k0 的时候直接调用 atMostK(k-1) 会触发 k -1如果没有保护分支就会返回一个错误结果。我的习惯是在 atMostK 函数开头加一个 if k 0: return 0这样即使 k0 也不会出问题。空数组的情况也要考虑。nums 为空时循环体根本不执行left 0, ans 0返回 0结果是正确的。数组里只有一个元素时无论 k 是 0 还是 1规则也都能正确处理。不过如果你在代码里用了 nums[0] 之类的写法记得先判空。4.2 窗口收缩的细节顺序窗口收缩时很多人搞不清该先更新频次还是先移动 left。正确的顺序是先对 nums[left] 的计数做减一检查是否减到了 0如果是就删除这个键或减少 diff最后 left 1。反过来先移动 left 再更新 freq会让 left 指向错误的位置统计结果全乱。还有一点while 循环里的判断条件是 len(freq) k条件是“大于”不是“大于等于”。因为我们是允许窗口里正好有 k 个不同整数的一旦用了 合法窗口会被过度收缩答案会少算很多子数组。4.3 计数更新的时机与方向右指针每扩展一步先增加新元素的计数再做收缩判断。这个顺序不能反。如果先收缩再添加新元素是否导致 diff 超限就没法正确判断了。我见过有人把循环顺序写反结果右指针移动一次窗口就空一次最后答案变成 0。调试的时候我习惯在关键位置打印 left、right、freq、diff然后拿小样例 [1,2,1,2,3] 手动追踪一遍。建议你把代码里的循环过程推演一遍尤其是 ans right - left 1 那行每个 right 对应累计了多少子数组这样理解会非常透彻。这里整理一个常见的坑位速查表问题错误写法正确写法删除键时机频次归0后不删除立即删除或维护 diff 变量while 边界条件用 len(freq) k用 len(freq) k计数顺序先移动 left 再更新频次先更新频次再移动 leftk 为负直接用数组下标函数开头返回 0结果累加ans 1ans right - left 15. 同类题型的识别与扩展思路5.1 滑动窗口题目的“三件套”判断刷到一定量之后你会发现滑动窗口题基本都有共同点求连续子数组/子串满足某个约束条件的数量或长度而且数据范围大到暴力遍历不可行。判断要不要用滑动窗口主要看三个特征元素有序连续、条件关于区间是单调的、需要统计最值或计数。当然“恰好 k 个”这个条件本身不单调所以需要差分技巧把它变成两个“最多 k 个”的差。遇到“至少”“恰好”“最多”这种表述时第一步不是急着写代码而是先想想它能不能转换成单调条件。5.2 常见变体至少k个、最长子数组、字符串版本这类题有很多变形。比如要求“至少 k 个不同整数”的子数组个数思路是总数减去最多包含 k-1 个不同整数的子数组个数最终还是落到 atMostK 函数上。再比如 LeetCode 340 是“最多 k 个不同字符的最长子串”直接用滑动窗口维护最长长度就行不需要差分。而“无重复字符的最长子串”其实就是 k 固定为窗口内所有字符都不重复的特殊情况这类题的滑动窗口逻辑都一脉相承。还有一个经典变体是“和为 k 的子数组”那通常要用前缀和不是滑动窗口注意别把所有“恰好 k”都套到窗口上。判断依据很简单条件是关于“不同整数个数”这种随窗口单调变化的东西可以用滑动窗口如果是“和等于某个值”那窗口收缩不满足单调性得换前缀和的思路。5.3 面试笔试中的实战建议面试官如果出这道题通常期望你能讲清楚两件事为什么直接用双指针处理“恰好 k 个”有问题以及怎么通过“最多 k 个”的差值得出答案。光写出代码不够最好能画一个数组展示 left 和 right 的移动过程把 ans 累加的逻辑说清楚。还有个小技巧如果面试官追问能不能只遍历一次你可以说理论上可以在一个滑动窗口内维护两个左指针来做但差分写法更简洁、更不易错实际生产代码也更可维护。这属于“知道有更优方案但选择稳妥方案”的回答方式通常不会扣分。笔试里时间紧张差分写法绝对够用。另外LeetCode 热门题里还有一道“073 爱吃香蕉的狒狒”看着和滑动窗口无关但它的核心是二分答案加可行性判断本质上也是把“直接求解”转换成“通过某种区间判定递归逼近”。刷题多了你会发现很多题表面不同底层的“条件转换”思维是通用的。如果能把这些题放到一起总结你的算法水平会有明显提升。我个人在实际做题中的体会是这道题最大的价值不是记住解法而是学会一个通用的思维工具——当“恰好”不好求时尝试用“最多”的差分。这个套路在很多计数类题目里都能复用比如统计满足某个条件的子数组、子序列数量大部分都能通过“最多”来转化。刚开始用差分的时候可能会觉得绕多写几道类似的题之后你会发现它已经变成了肌肉记忆。最后再分享一个小技巧刷类似题时我习惯在草稿纸上写下“freq 的 len 代表什么”“diff 代表什么”“ans 每次累加的是什么含义”三个问题每次调试想不清楚就回去看这三个答案。再加上这道题的边界条件都不难只要把窗口收缩的顺序和 del 键的时机把握住基本就能一次 AC 了。