☰
水果成篮最优解:滑动窗口与双指针处理最长连续子数组
2026/10/21 7:35:22 网站建设 项目流程

水果成篮这道题,是我心目中滑动窗口专题里最值得反复琢磨的一道中等题。它的题面讲故事讲得很生活化:一排果树上挂满了不同品种的水果,你手里只有两个篮子,从任意一棵树开始往右摘,遇到两个篮子都装不下的新品种就得停手,问最多能连续摘多少颗树。翻译成算法语言其实就是一句话:在一个数组里,找一个最长的连续子数组,要求子数组中不同数字的个数不超过2。这个转化看着简单,但我见过不少人第一次做的时候绕了很久,因为题面的“篮子”“果树”这些比喻会把真实约束藏起来。这篇内容适合正在刷滑动窗口、双指针专题的读者,也适合准备面试时想把一类题讲明白的人。我会把暴力思路、滑动窗口原理、完整代码、边界坑点和同类变体一次讲透,尽量让没有接触过滑窗的人也能跟着我的思路走完。

1. 你先要读懂题目在说什么

1.1 果园场景背后的真实约束

题目描述一般是:你面前有一排果树,每棵树结一种水果,水果种类用一个整数表示,比如1代表苹果,2代表香蕉,等等。你想采摘水果,手里有两个篮子,每个篮子只能装同一种水果。你可以任选一棵树作为起点,然后只能沿着这一排向右连续采摘,不能回头,也不能跳着摘。每遇到一棵树,如果这棵树的水果是两种篮子里的某一种,就摘下来放进对应篮子;如果遇到第三种水果,因为两个篮子都已经占用了,采摘就必须停止。问从哪棵树开始,最多能连续摘到多少棵树。

我一向建议先不要把“两个篮子”当成具体的物品,题目真正的约束是:采摘过程中遇到的、被你装进篮子的所有水果,最多只能有两种不同的类型。同一个篮子里可以装很多个苹果,比如连续摘到五个苹果都只算一种类型。所以“篮子”只是比喻,本质是在限制区间内不同类型元素的个数。理解到这里,题目就从“摘水果”变成了一个纯粹的数组问题。

1.2 把它翻译成算法语言

把果树数组记作fruits,水果类型就是数组元素值。采摘一段连续区间,等价于选择一个连续子数组fruits[l..r]。两个篮子意味着这个子数组内不同数值的种类数最多为2。最终答案就是所有满足“不同数值种类数不超过2”的连续子数组中,长度最大的那个长度。

所以题目是典型的“最长连续子数组 + 种类数上限”模型。一旦你完成了这层翻译,后面所有解法都是围绕这个模型展开的。很多资料会用“最多包含两个不同数字的最长连续子数组”来描述这一题,实际上就是在说同一件事。这个模型以后也会反复出现,比如“最多 k 个不同字符的最长子串”,只是把固定的2换成了参数k。

1.3 为什么很多人第一眼会懵

我刚接触这道题时也犯过一个错:以为篮子装满了就不能摘同一类型的水果了,于是把题目理解成“窗口内每种水果最多出现一次”。但题目说的是篮子能装一种水果,而不是只能装一个水果,所以连续出现的同类型水果是可以无限装的。这个误解会让解题方向直接跑偏到“无重复字符的最长子串”那类问题上。理解题意时,一定要抓住“类型数”而不是“个数”,这是整道题的命门。面试时如果题目描述里没说清楚,可以主动追问一句:同一种水果出现多次,是算一种类型还是会把篮子占满?明确之后再动手,能省很多返工时间。

2. 从暴力解法出发,先把正确性立住

2.1 枚举所有起点和终点的笨办法

最直接的想法是枚举所有的起点i和终点j,然后检查从i到j这个连续区间里是否只包含最多两个不同的数字。随便写一版朴素代码就是:

def total_fruit_bruteforce(fruits): n = len(fruits) ans = 0 for i in range(n): for j in range(i, n): if len(set(fruits[i:j + 1])) <= 2: ans = max(ans, j - i + 1) return ans

这里每次检查都用set去重,思路完全正确,能过小数据,但到了正式数据规模基本必挂。优化版可以把set的构建改成增量维护,也就是固定i,让j一路往右扩展,每扩展一个元素就更新当前窗口内不同数字的情况,一旦超过2就停止,这样能把复杂度压到O(n^2)。但仍然不够快。

2.2 复杂度分析:看着简单,实际顶不住

朴素暴力版外层起点O(n)个,内层终点O(n)个,最内层构造set又要遍历子数组长度O(n),整体是O(n^3)量级。优化成增量检查后是O(n^2)。当数组长度到达常见的10^5级别时,O(n^2)意味着10^10次操作,跑一次要几十秒甚至更久,在在线评测环境里铁定超时。暴力的价值不在通过,而在于它给出了一个绝对正确的基准答案;后面优化完可以用小规模随机数据对拍,验证滑动窗口没有写错。我每次刷这类题都会留一版暴力方法,不是为了提交,而是为了保证验证环节有参照物。

2.3 给暴力法优化的关键观察

仔细观察暴力法会发现一个非常重要的单调性规律:如果区间[i, j]是合法的,即区间内不同水果不超过2种,那么它的任意子区间也是合法的,因为子区间只会减少元素,不会增加种类;反过来,如果区间[i, j]不合法,那么以j为终点、起点在i左边的任何区间也一定不合法。既然合法区间有这种“左边位置越靠左越难合法”的趋势,我们就没有必要为每个终点重新从0枚举起点,而可以让起点指针只往右移动,不回头。这一个观察,正是滑动窗口能成立的根基。

3. 滑动窗口为什么是正解

3.1 窗口里维护着什么

滑动窗口做法维护一个左指针left和一个右指针right,窗口就是fruits[left..right]。我们始终保持窗口内的不同水果种类数不超过2。右指针不断向右扩展,把新的水果纳入窗口;每纳入一个水果,如果窗口内种类数变成了3,就让左指针向右收缩,去掉最左边的一些水果,直到窗口重新合法。

需要注意,左指针每次并不是只移动一步就够了。因为新加入的可能是第三种水果,而窗口最左边可能连着好几棵都属于同一种水果,必须把这一整段全部移出去,种类数才可能降到2。所以收缩是一个持续过程,用循环而不是单次判断。这也是整个实现里最容易出错的地方。

3.2 不遗漏最优解的论证

很多人会担心:右指针一路右移,左指针一路右移,会不会把最优解跳过?不会。假设全局最优解是[L, R]这一段,它包含1到2种水果。当算法右指针走到R的时候,窗口右端点正好落在最优解的终点上。此时算法会尽量把left保持在能保证窗口合法的最左侧,因此它在枚举right = R时得到的窗口长度,一定不小于最优解的长度。而且每个left和right都只会向一个方向移动,所以整体复杂度是线性的。简单说:只要对每个右端点都找到了“最左合法左端点”,那么最优解一定会在某个右端点被覆盖到。

3.3 为什么哈希表计数比“记录最后位置”更稳

关于“窗口里目前有哪几种水果”这个信息,常见的维护方式有两种。一种是记录每种水果最后出现的位置,收缩时根据两个类型最后出现位置的最小值决定新的left;另一种是用哈希表记录窗口内每种水果出现的次数,出现次数归零就删除键,用哈希表大小代表不同水果数。我个人强烈推荐第二种。最后位置法的确能省一点空间,但它对“新加入的水果种类与保留种类如何取舍”非常敏感,容易在连续多个同类型元素时算错边界;计数法就是朴素地维护窗口内容,逻辑直白,几乎没有什么需要记忆的公式。面试讲解时,计数法也更不容易让听者产生困惑。

4. 双指针解法完整实现与逐行拆解

4.1 Python版本和运行流程

from collections import defaultdict def total_fruit(fruits): left = 0 ans = 0 count = defaultdict(int) 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

逐行说明一下:

  • count里存的是“每种水果在窗口内出现了多少次”,键的数量就等于不同水果种类数。
  • 每来一个新水果,先无条件加进窗口,把计数加1。
  • while len(count) > 2是唯一需要收缩的触发条件;循环里先把left指向的水果计数减1,如果计数降为0就删除这个键,然后left右移。这里减的是fruits[left],和当前新加入的fruit没关系,因为我们要去掉的是窗口最左端的水果。
  • 收缩结束后,窗口必定合法,用right - left + 1求当前窗口长度并更新答案。

用[1,2,3]走一遍:right=0加入1,窗口[1],ans=1;right=1加入2,窗口[1,2],ans=2;right=2加入3,count里变成{1:1,2:1,3:1},len=3进入 while,先去掉1,left=1,再去掉2,left=2,count只剩{3:1},窗口变成[3],ans=max(2,1)=2。最终答案2,对应[1,2]或[2,3],完全正确。注意算法在right=2时窗口变成了[3],并没有覆盖[2,3],但[2,3]的长度已经在right=1时记录过了,所以不会丢答案。这个现象很多人初次看到会疑惑,解释通了就彻底理解滑动窗口这一类题。

4.2 Java版本参考

class Solution { public int totalFruit(int[] fruits) { int n = fruits.length; int left = 0; int ans = 0; Map<Integer, Integer> count = new HashMap<>(); for (int right = 0; right < n; right++) { count.put(fruits[right], count.getOrDefault(fruits[right], 0) + 1); while (count.size() > 2) { int f = fruits[left]; count.put(f, count.get(f) - 1); if (count.get(f) == 0) { count.remove(f); } left++; } ans = Math.max(ans, right - left + 1); } return ans; } }

逻辑和 Python 版完全对照,Java 里记得import java.util.*;即可。唯一要注意的是 Java 的HashMap计数减到0后如果不remove,count.size()会把这个已经消失的类型还当一种水果算,结果就会出错。这个细节在后面的坑点部分还会专门展开。相比 Python 的defaultdict,Java 在删除键这件事上更强制,但也因此逼着写代码的人把窗口状态想得更清楚。

4.3 复杂度与空间占用

时间复杂度:每个元素最多被right纳入窗口一次,被left移出窗口一次,所以总操作次数是O(n),完全线性。空间复杂度:虽然哈希表可以容纳很多键,但我们的约束强制窗口里最多只有3个不同键出现(窗口合法时2个,进入 while 的瞬间最多3个),所以实际占用是常数级,空间复杂度O(1)。有些资料写空间O(k),k 为水果类型总数,那是泛指整个数组的取值范围;针对这道题本身,常数级描述更准确。面试时直接说时间O(n)、空间O(1),再补充一句“因为窗口内键的数量上界是3”,基本就够了。

5. 手把手走一遍用例:窗口是怎么滑的

5.1 示例全过程跟踪

选一个更有代表性的例子:fruits = [1,2,1,2,3],手工模拟每一轮,重点观察收缩过程。

初始left=0,ans=0。

  • right=0,fruit=1,count={1:1},种类1,窗口[1],ans=1。
  • right=1,fruit=2,count={1:1,2:1},种类2,窗口[1,2],ans=2。
  • right=2,fruit=1,count={1:2,2:1},种类2,窗口[1,2,1],ans=3。
  • right=3,fruit=2,count={1:2,2:2},种类2,窗口[1,2,1,2],ans=4。
  • right=4,fruit=3,加入后count={1:2,2:2,3:1},种类3,触发 while 收缩:left=0去掉1,count={1:1,2:2,3:1},left=1,仍然3种;left=1去掉2,count={1:1,2:1,3:1},left=2,仍然3种;left=2去掉1,count[1]变为0删除键,count={2:1,3:1},left=3,种类2,停止收缩。 窗口变成[2,3],长度为2,ans保持4。最终输出4。

从第5步能看到:收缩过程中,窗口内1、2两种旧类型被逐一清空,每种水果的计数下降路径是不一样的。1在窗口里有2个,第一次去掉一个还剩1个,第二次去掉一个才归零;2也出现过2次,但因为2同时还有一个在right=3的位置,所以去掉left=1的2之后计数从2变1,并不会误删键。这正是计数法最稳的地方,它天然知道每个类型还剩几个。

5.2 边界用例速查表

整理几个典型的边界输入,方便写完代码后快速自测:

输入期望输出原因
[]0空数组没有水果
[5]1只有一棵树,摘到1个
[1,2]2两种类型正好放进两个篮子
[1,2,3]2第三种类型出现就停,最长为2
[3,3,3,3]4所有树同一种水果,整排都能摘
[1,2,1,2,3]4最长窗口是[1,2,1,2]或[2,1,2,3]
[1,0,1,4,4,4,4]5最后一整段4和前面一个1组合出长度5

这些用例的价值在于覆盖了“无收缩”“一次收缩”“多次收缩”“窗口覆盖全数组”四种情况。跑一遍相当于给代码做了个小型回归测试。

6. 高频踩坑与排查实录

6.1 用 if 代替 while,只收缩一步

我在一开始自己写版本的时候,以为加入第三种类型后left右移一步就够了,于是用了if而不是while。结果在输入[1,2,1,2,3]这类场景中,left只移动一次后窗口变成[2,1,2,3],仍然包含1、2、3三种类型,count.size()还是3,后续答案就会算错。排查方法很简单:在 while 循环里打印left和count,观察是否一次收缩到位。遇到这种场景,凡是“新类型加入后,窗口左边连续排着同一类型的多棵果树”,都必须把整段都移出去。用if判断本质上是把“窗口可能一次减少多种类”这件事漏掉了。

6.2 计数为0不删键,哈希表“假膨胀”

这是这一类计数滑窗最常见的错误。如果count[某种水果]减到0后不删除键,那么这个键仍然占着count的一席之地,len(count)不会真正反映“窗口内还能看到的类型数”。比如窗口收缩后[2,3]明明只有2种,但count里还残留着{1:0,2:1,3:1},len=3,程序会以为窗口依然不合法,继续收缩left,导致窗口被无谓地压缩甚至变成空窗口。在 Python 里用defaultdict时尤其要记得:减到0要del;在 Java 里则要remove。排查技巧是在每次循环后打印字典,观察键的数量是否和窗口里实际出现的类型数一致。

6.3 答案更新时机放错位置

正确顺序是:加入新元素 -> 收缩到合法 -> 更新答案。如果先更新答案再收缩,会把临时出现的非法窗口长度算进去。例如[1,2,3]在right=2时,如果先ans = max(ans, 3)再收缩,答案就会错误变成3;正确结果是2。这个错误很小,但在长用例中会悄悄让答案偏大,很难一眼发现。我建议把ans更新放在循环的最后一行,养成固定顺序。写完代码后可以故意调换一次顺序,用边界用例验证自己的理解是否到位。

6.4 面试讲解时的表达顺序

如果是在面试场合遇到这题,我建议按“题意转化 -> 暴力思路 -> 单调性观察 -> 双指针维护 -> 复杂度分析”的顺序讲。题意转化亮出“最长连续子数组 + 不同元素不超过2”这个核心模型;暴力思路证明自己有基础;单调性观察是滑窗思想的动机;代码写完后主动说时间O(n)、空间O(1)。不要一上来就背滑窗模板,面试官更希望看到你“为什么想到滑窗”的推理过程。问清楚“篮子能否容纳同类型多个水果”这个边界,也能避免一开始就跑错方向。

7. 同类题的举一反三

7.1 从2个篮子到k个篮子

如果把两个篮子改成k个篮子,即“最多包含k种不同数字的最长连续子数组”,代码只需要把while len(count) > 2改成while len(count) > k。这一类问题在题库里有大量变体,比如“最多 k 个不同字符的最长子串”。判断条件从具体的2变成参数后,滑窗模板的通用性就体现出来了。准备这部分时,可以用一个通用函数接受fruits数组和k两个参数,顺手跑几个测试用例,对模板的理解会更深。核心框架不变,变化只在“种类数上限”这一个条件上。

7.2 从“最多”到“恰好”

如果题目要求“恰好包含k种不同数字的子数组个数”,处理方式会变:一种套路是定义f(k)表示“最多包含k种不同数字的子数组个数”,那么“恰好k种”的个数就是f(k) - f(k-1)。这个容斥思路在滑动窗口计数类问题里非常经典,水果成篮恰好是f(2)的答案值。明白这层联系之后,再看到“最多两种水果能摘多少”就不只是背一道题了,而是掌握了“最多型滑窗”的一整套换算逻辑。以后遇到“恰好”类问题,第一反应就应该是“最多”函数做差。

7.3 和“无重复字符的最长子串”对比

很多人会把水果成篮和无重复字符最长子串混在一起。两者都是滑动窗口,但约束类型不同:无重复字符是窗口内每个元素最多出现一次,因为重复字符本身就犯规;而水果成篮是窗口内每个数字可以出现任意多次,只是数字种类不能超过2。前者的窗口可以使用set记录是否出现过,因为重复即收缩;后者必须用计数表,因为出现多次仍然合法。把这两题放在一起做对比练习,是掌握滑动窗口数据结构选型最好的素材。

踩过几次坑之后,我最大的体会是:这类题真正难的不是代码,而是“什么时候用 set、什么时候用哈希表计数”的判断,以及对左指针收缩必要性的直觉。我建议在纸上画一棵一棵树的窗口示意图,先标right再标left,看窗口里的类型怎么变化,比直接对着代码调试更快建立起手感。如果后续想继续扩展,还可以把这一题的计数窗口模板改成“最长连续子数组中众数过半”之类的变体,思路依然是滑动窗口加额外状态维护。希望这篇拆解能帮你把水果成篮一次吃透,下次再碰到类似题目,直接搬模板就能稳拿。

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

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

立即咨询