☰
水果成篮题解:滑动窗口与left指针移动详解
2026/10/2 2:51:44 网站建设 项目流程

刷过“水果成篮”这道题的人,八成和我第一次一样:看着题面觉得是一场果园采摘模拟,写起来却发现左一个坑右一个坑。题目说的是,一排果树按顺序长着,每棵树上结一种编号的水果,你手里有两个篮子,每个篮子只能装一种水果,从任意一棵树开始摘,摘的过程中只能一直往右走,一旦遇到第三种水果就必须停下,问最多能摘多少棵树的果子。听起来像模拟题,实际上是典型的滑动窗口,而且是可变长度、以合法性为条件的滑动窗口。

我一开始的思路很简单:维护两个变量,记录当前正在摘的两种水果编号,遇到第三种就清空重来。这个想法在小例子上完全没问题,一提交就露馅。这篇文章不打算只给一个能通过的代码,我会把从“朴素重置”到“正确收缩窗口”的思考过程讲清楚,尤其是 left 指针到底该怎么挪,以及为什么网上很多用哈希表计数的写法在边界上容易翻车。

1. 把水果成篮翻译成人话:题目到底在让我们干什么

1.1 题面还原:两个篮子、连续采摘和水果编号

题目给一个数组 fruits,fruits[i] 表示第 i 棵树上的水果种类编号。两个篮子意味着你最多只能同时接受两种不同编号;第三种编号出现时,你手里的两个篮子一定装不下。从任意位置开始,只能连续往右采摘,一旦前方出现第三种水果就必须停止。目标只有一个:让采摘数量最大化。

换句话说,这题是在找一个连续子数组,子数组里不同元素的个数不超过 2,并且这个子数组的长度要尽量长。比如 [1,2,1,2,3],答案应该是 4,取前四棵 [1,2,1,2];最后一棵是 3,已经是第三种水果,装不下,所以不能算进去。再比如 [1,2,3,2,2],答案是 4,取 [2,3,2,2],从下标 1 开始摘。

这里有个容易被忽视的点:“连续”两个字非常关键。它意味着你不能跳过某棵树去摘后面的果子,也不能从整排树里挑出所有喜欢的水果。这个限制直接把问题定位成子数组问题,而不是子序列问题。很多人一开始没想清楚这一点,跑去排序或者做全局计数,方向就错了。还有一点,两个篮子装的不是“两个水果”,而是“两种编号的同种水果可以装无数个”。理解了这个,再看窗口里的计数逻辑就顺了。

1.2 为什么朴素遍历不靠谱:从“所有起点都试一遍”到N方复杂度

把题面直接翻译成代码,最朴素的做法是:枚举起点 i,从 i 开始向右扩展,维护一个计数器,统计窗口内出现了几种水果,直到种类数超过 2 就停止,记录当前长度。每个起点都这样跑一遍,取最大长度。

这个做法的问题是复杂度太高。如果数组长度是 n,枚举起点是 O(n),每次向右扩展最坏又要 O(n),再加上判断种类数可能还要 O(n),整体轻轻松松到 O(n^2) 甚至 O(n^3)。实际题目数据规模通常到 10^5 这个量级,O(n^2) 基本超时,根本跑不动。

所以我们需要的是:每个元素尽量只被处理常数次,总复杂度 O(n)。滑动窗口正好能做到这一点。窗口可以理解成数组上一个连续区间,右边界不断向前扩展,左边界只有在必要时向前移动。每个元素最多进窗口一次、出窗口一次,总体线性。用一句话概括思路:与其枚举所有可能的起点,不如维护一个“当前合法区间”,让右端点一直往前走,左端点只在条件被破坏时跟进。

顺便说一句,这类题的核心就两个问题:窗口的合法性条件是什么,窗口收缩时数据怎么更新。对水果成篮来说,合法性条件是窗口内不同水果编号数不超过 2;更新策略是 right 每次向右扩一格,然后收缩左边界直到窗口重新合法,最后记录长度。这套骨架理解透了,后面很多类似题目都能套。

2. 滑动窗口的状态设计:哈希表计数与“只记两种水果”的陷阱

2.1 窗口内应该维护什么信息

滑动窗口要能正确判断合法性,就得维护足够的信息。水果成篮常见的做法有三种:第一种是哈希表计数,用 HashMap 记录窗口里每种水果的出现次数,某一种次数归零就把它从表里删掉;第二种是数组计数,如果水果编号范围不大,用数组替代哈希表,省掉哈希开销;第三种是只记录两种水果的最后出现位置,利用“最多两种”这个限制,省掉计数,但实现时容易在细节上翻车。

哈希表计数最直观,也最不容易错。核心逻辑是:右指针遍历数组,把新水果加入计数;只要窗口内种类数大于 2,就移动左指针,把左指针指向的水果计数减一,减到 0 就删除;收缩结束后窗口一定合法,这时更新答案。

这里要特别强调一个细节:收缩条件要用 while 而不是 if。因为左指针移动一次,窗口里可能还残留很多同一种水果,种类数可能仍然大于 2,必须连续移动直到真正回到合法状态。用 if 的话,窗口在异常庞大的用例下会漏收缩,答案直接算错。

2.2 经典的“第三棵树”陷阱:为什么不能简单重置窗口

很多人第一版写法是:发现第三种水果,就把左边界跳到当前位置,从新水果重新开始。这个想法听上去很合理——反正旧的两种水果里肯定有一种要被丢掉,不如干脆都丢掉,从新水果开始重新积累。

但这个直觉是错的。丢掉哪一种,不是由“谁先出现”决定的,而是由“谁先断档”决定的。看一个最经典的反例:[1,2,1,2,3]。如果遇到最后那个 3 时选择重置窗口,从下标 4 开始重新摘,窗口就只剩 [3],答案是 1;而正确答案是 4,也就是前四棵 [1,2,1,2]。这个反例直接否定了“重置整个窗口”的做法。

再看一个反例:[1,2,3,2,2]。正确答案是 4,取 [2,3,2,2],对应下标 1 到 4。如果遇到下标 2 的水果 3 时重置,窗口从下标 2 开始,最多只能得到 [3,2,2],长度 3,照样错。

实际上,正确的做法不是把旧的两种水果全部丢弃,而是只丢弃一种、保留另一种继续延伸。窗口左指针不是随便跳到 right,而是要跳到“被丢弃那种水果最后一次出现位置之后”。理解了这个,才算真正摸到这道题的门道。下面我会展开讲 left 到底该怎么挪。

3. left指针的移动艺术:收缩窗口的时机与边界

3.1 怎么判断该丢哪一种水果:比较“最后出现位置”

当第三种水果出现时,窗口里一共有三种编号,但篮子里只能放两种。这时候必须选择保留两种、丢弃一种。关键问题是:丢弃哪一种?

答案是:丢最后一次出现位置更靠前的那一种。原因其实很朴素:右边界继续向右推进时,窗口会不断纳入新元素。如果一种水果在窗口内最后一次出现的位置比较靠左,说明它和当前右边界之间已经隔了一段没有这种水果的区域。从现在这个时刻往回看,它的“连续性”已经断掉了。如果你还想保留它,左边界就必须越过它最后一次出现的位置,那它实际上已经不在窗口里了;如果不越过,窗口里又会混入第三种水果,违法。所以唯一合理的选择,就是放弃最后出现位置更靠前的水果,把左边界直接移到最后出现位置再加一。

我拿一个具体例子走一遍。[1,2,1,2,3],当遍历到下标 4 的水果 3 时,窗口里的两种旧水果是 1 和 2。水果 1 的最后出现位置是下标 2,水果 2 的最后出现位置是下标 3。比较之后,1 更靠前,所以丢弃 1,左边界跳到下标 3,也就是“最后一个 1 的位置 +1”。新窗口变成 [2,3],合法。此时虽然答案只有 2,但前四棵 [1,2,1,2] 已经在上一轮被记录过了,不会丢解。

所以在“最后出现位置”方案中,left 的移动不是一步一步挪,而是直接跳到指定下标。代码上通常写成 left = Math.min(lastPos[a], lastPos[b]) + 1,同时把被丢弃的那种水果替换成新遇到的水果。

3.2 先收缩再统计:标准模板的步骤顺序不能乱

滑动窗口的代码顺序看起来很简单,但顺序一旦写错,结果就是错的。标准顺序是三步:第一,right 向右走一步,把新水果纳入窗口;第二,窗口可能因为引入新水果而非法,进入收缩阶段,移动 left 直到窗口重新合法;第三,收缩结束后,窗口一定合法,此时用 right - left + 1 更新答案。

为什么要先收缩再更新?因为答案是“合法窗口的最大长度”。如果在收缩前就用右边界减左边界加一,算出来的可能是一个包含三种水果的非法窗口,虽然它更长,但那种情况根本摘不了那么多,所以不能参与比较。有人图省事,发现第三种水果后不移动 left,直接更新答案,同样会错,因为窗口还没回到合法状态。

如果你用“不断缩小的左边界”那种 while 写法,顺序尤其重要:必须先把 left 指向的水果计数减一,再判断这个水果是不是已经清零,清零就删除,最后才 left 加一。不少新手把 left 加一写在前面,while 循环收缩的位置就全错了,因为 left 已经变了,减计数的对象却还是旧下标,整个过程乱套。

3.3 边界情况:单种水果、全相同数组与空数组

数组为空时直接返回 0,这个不用多说。数组里只有一种水果,或者所有水果编号都相同,窗口从头到尾都合法,left 永远不动,答案就是数组长度 n。这两种情况在哈希表计数法下天然成立,不需要特判。

需要注意的反而是 left 移动过程中的边界:当某一种水果的计数被减到 0 时,一定要把它从哈希表里删除,不能留着。否则后续判断 len(count) > 2 时,会把已经不在窗口里的水果也算进去,导致窗口收缩不彻底。这个 bug 我见过不少次,而且小样例不容易暴露,只有在窗口反复出现同一种水果的用例里才会炸出来,属于那种“测试一次通过、提交却超时代码永远跑不对”的隐蔽问题。

4. 从水果成篮看滑动窗口家族:和最大值最小值单调队列的关系

4.1 滑动窗口模板的本质:什么时候能用

水果成篮属于“可变长度 + 合法性条件”的滑动窗口。这类题有一个共性:要求一个连续区间,使得某个条件成立,同时希望区间尽可能长或尽可能短。通用的骨架是:右指针遍历数组,每次加入一个元素;左指针在条件不满足时向前移动,直到条件重新满足;每轮记录最优结果。

这个骨架能成立的前提,是条件具备单调性:窗口扩大时,“条件满足”的难度只会增大;窗口缩小时,条件只会更容易满足。水果成篮里,窗口变大时水果种类数只会增加不会减少,所以一旦超过 2,唯一能把它救回来的方式就是收缩左边界。如果条件不具备这种单调性,滑动窗口会失效,得回头换其他结构。

单调性还有个隐含价值:它保证 left 向右移动的过程中不会漏解。因为当 left 已经移动到某个位置,说明窗口在 left 之前的所有起点,以当前 right 结尾时都已经不合法;而以后 right 继续增大,这些起点只会更加不合法,所以可以放心放弃。想明白了这一点,你就会理解为什么滑动窗口不用回溯。

4.2 同样的名字,不同的结构:最大值/最小值为什么用单调队列

热搜词里有一堆“滑动窗口最小值/最大值”“单调队列-滑动窗口”,这些和水果成篮虽然都叫滑动窗口,但用的数据结构完全不同。固定大小窗口求最值,比如窗口长度固定为 k,每次右移一格,要求快速拿到窗口内的最大值。如果每次扫描窗口求 max,复杂度是 O(nk),数据一大会超时。

单调队列的思路是:维护一个双端队列,队列里的元素在窗口内按值单调递减或递增,队头就是窗口最大值。每次窗口右移时,从队尾弹出那些“不可能再成为最大值”的旧元素,从队头弹出已经离开窗口的元素,每个元素进队出队各一次,总复杂度 O(n)。

水果成篮的哈希表计数解决的是“窗口内有哪些元素、每种有多少个”,这是维持合法性的工具;单调队列解决的是“窗口内元素的最值是哪个”,这是做聚合查询的工具。一个是约束条件,一个是统计极值,两者不要混淆。

4.3 其他领域里的“窗口”:重传协议和滤波器的类比

“滑动窗口”这个词在计算机网络和信号处理里也很常见,比如滑动窗口重传协议、滑动窗口滤波模型、滑动窗口滤波 Verilog 实现。这些地方说的“窗口”,和算法题里的窗口有完全同构的意象:一个容量有限的区间,随着时间推进,旧元素离开、新元素进入,对外输出对当前窗口内数据的某种聚合结果。

差异在于用途。网络协议里的窗口控制的是“允许发送多少个未确认报文”,信号处理里的滑动窗口是对一段信号做滤波或卷积平均,而算法题里的滑动窗口,是为了在一个数组上高效维护某个条件。拿生活化的比喻来说,滑动窗口像你排队时不断向前移动的一段视野:你只关心当前看到的一小段队伍,队伍往前走,窗口内容也更新。水果成篮要求这段视野里最多出现两种人;最大值问题要求这段视野固定长度并记录最高的人。理解了这层意象,再看各种“滑动窗口”,就不会被名词吓到。

5. 实战手记:边界条件、实现取舍与踩坑清单

5.1 HashMap计数法与双变量法的取舍

先给最推荐的实现,HashMap 计数法,直观、通用、不容易错。代码我写成 Python,方便阅读:

from collections import defaultdict def total_fruit(fruits): count = defaultdict(int) left = 0 ans = 0 for right, fruit in enumerate(fruits): count[fruit] += 1 while len(count) > 2: left_fruit = fruits[left] count[left_fruit] -= 1 if count[left_fruit] == 0: del count[left_fruit] left += 1 ans = max(ans, right - left + 1) return ans

这个版本的优点是:它把“窗口内有哪些水果、每种多少个”完整记录下来,收缩条件写起来和题目描述一一对应。推广到“最多 K 种水果”时,只需要把 while len(count) > 2 改成 while len(count) > K,一行搞定。

双变量法也很经典,只维护两种水果以及它们各自最后一次出现的位置,left 一次跳到位,省内存,但代码可读性差、逻辑容易出错。我不建议在需要快速写出正确答案的场景用它,它更适合作为理解 left 指针思想的一个练习。如果非要用双变量法,记得每轮更新答案时,窗口长度是 right - left + 1,而不是两种水果最后位置之差加一,因为还要考虑 left 可能比某个最后位置更大。

5.2 高频误区小结

第一个误区是忘记删除计数归零的水果。哈希表里保留一个计数为 0 的键,表面上不影响当前轮,但下一轮判断 len(count) 时就会多算,最终导致窗口收缩不到位。

第二个误区是收缩顺序写反。正确顺序是“先减计数,再判断是否为零并删除,最后 left 加一”。如果先把 left 加一再去减计数,减的是新下标的计数,窗口收缩完全失效。

第三个误区是把“遇到第三种水果就重置”当成正确答案。前面已经用 [1,2,1,2,3] 和 [1,2,3,2,2] 两个反例说明过,重置会漏掉很多可行区间。

第四个误区是答案更新时机不对。必须等窗口回到合法状态后再更新,而不是刚发现第三种水果时就用当前 right 和旧 left 算长度。非法窗口的长度再大,也不能算数。

第五个误区是只测少量样例就提交。至少要把“只有一种水果”“所有水果都相同”“两种水果交替出现最后来一个第三种”这三类用例跑一遍,再来验证复杂度行为。

5.3 从水果成篮延伸:最多K种水果、最小窗口与统计变体

水果成篮可以轻松泛化成“最多 K 种不同元素的最长连续子数组”,把 while len(count) > 2 改成 while len(count) > K 即可。这个思路在“至多两个不同字符的最长子串”这类题里完全同构,把水果编号换成字符就行,代码都不用大改。

另一个相反的问题是“包含所有 K 种元素的最短窗口”,比如求最短子串包含所有指定字符。这时框架相同,但细节反过来了:right 扩展时窗口从条件不满足走向满足,一旦满足就要收缩 left 试图缩短窗口,更新答案的时机是“刚刚满足条件”而不是“收缩后”。这类问题在字符串处理、日志关键字统计里很常见,想清楚“合法条件”和“更新时机”,理解水果成篮之后基本就能顺藤摸瓜。

另外,如果水果编号范围不大,可以用数组代替哈希表维护计数或最后位置,访问更快、内存更稳定;但编号范围很大时,数组方案直接爆炸,还是老老实实用哈希表。工程上要根据数据范围选,不能一套方案走天下。

最后说点个人感受。我最初刷水果成篮时,总想把滑动窗口背成模板,后来发现模板只是壳,真正有用的是想清楚两个问题:窗口的合法性条件是什么,窗口收缩时数据要怎样更新。对这道题,合法性条件是“种类数不超过 2”,收缩时不是简单重置,而是把最早断档的那种水果去掉。想明白这一点,代码怎么写都顺。如果你刷题时也卡在 left 指针上,建议不要急着背答案,把一个具体反例手写一遍,看看 left 到底应该跳到哪。把这一步做扎实,滑动窗口这类的题你基本就过关了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询