在算法面试和实际开发中,我们经常遇到需要处理数组或数据流的问题。今天,我们将深入探讨一个经典且极具启发性的问题——通常被称为“水果成篮”。这个问题不仅考察我们对基本数据结构的掌握程度,更是练习滑动窗口技巧的绝佳场景。
通过这篇文章,你将学会如何从最直观的暴力解法出发,逐步识别性能瓶颈,并最终利用滑动窗口算法将时间复杂度从 $O(N^2)$ 降至 $O(N)$。我们将使用 C++、Java、Python 等多种语言实现解决方案,并分享在编码过程中容易踩到的“坑”和优化技巧。
1. 问题背景:我们要解决什么?
想象一下,我们正在果园里,面前有一排排列整齐的树,每一棵树都结了某种特定类型的水果(我们用整数或字符来表示类型,如 A[i])。现在的规则如下:
- 两个篮子:我们只有两个篮子,每个篮子只能装一种类型的水果。但是,每个篮子装的数量没有限制。
- 采摘规则:我们可以从任意一棵树开始,一旦开始,就必须向右移动,一棵接一棵地采摘。不能跳过任何一棵树,也不能回头。
- 目标:在这些规则的限制下,我们要计算最多能采摘到多少个水果。
实际上,这个问题翻译成算法语言就是:给定一个数组,找到一个最长的连续子数组,其中包含的不同元素的数量不超过 2 个。
为了让你更直观地理解,让我们看几个例子:
#### 示例 1
> 输入: A[] = {1, 2, 3, 2, 2}
> 输出: 4
> 解释:
> – 如果我们从索引 0 (水果 1) 开始,遇到 3 时,篮子里已经有 1 和 2 了,无法容纳 3,只能停止。长度为 2。
> – 如果我们从索引 1 (水果 2) 开始,我们可以收集 {2, 3},遇到下一个 2 时没问题(篮子1是2,篮子2是3),接着又是 2。直到数组结束。序列是 {2, 3, 2, 2}。长度为 4。
#### 示例 2
> 输入: A[] = {1, 2, 1}
> 输出: 3
> 解释:
> – 虽然有 1 和 2 两种类型,但只要不超过两种,我们就可以一直采下去。序列 {1, 2, 1} 的长度为 3。
2. 朴素方法:暴力破解
当我们拿到这个问题时,最直观的想法往往是:既然不知道从哪里开始最好,那就把每一个可能的起点都试一遍。
这就是我们要介绍的第一种方法。
#### 算法思路
- 遍历所有起点:我们需要针对数组中的每一个元素
A[start],把它当作采摘的起点。 - 向右扩张:从当前起点开始,向右遍历,记录采摘到的水果。
- 记录类型:我们需要一种数据结构(比如哈希表或集合)来记录当前篮子里已经有了哪些类型的水果。
- 判断停止条件:每遇到一棵新树,检查它的类型是否已在篮子中。如果不在,且篮子中已有两种类型,则必须停止。否则,将其加入篮子并继续。
- 更新最大值:记录下这次采摘的长度,并与历史最大值比较,保留较大的那个。
#### 代码实现
让我们通过代码来实现这个逻辑。这里我们使用 C++ 作为示例,你会发现代码逻辑非常直接。
#include
using namespace std;
// 这个函数计算最长的连续子数组长度,且该子数组中最多包含2种不同的数字
int maxFruits(vector& A) {
int n = A.size();
int maxFruitsCollected = 0; // 记录全局的最大采摘数量
// 步骤1:尝试从每一棵树作为起点
for (int start = 0; start < n; start++) {
unordered_map basket; // 用哈希表记录当前篮子里的水果类型及其计数
int count = 0; // 当前起点的采摘计数
// 步骤2:从起点向右遍历
for (int i = start; i 2) break;
// 如果没有超过,增加采摘计数
count++;
}
// 步骤4:更新全局最大值
maxFruitsCollected = max(maxFruitsCollected, count);
}
return maxFruitsCollected;
}
// 主函数用于测试
int main() {
vector A = {1, 2, 3, 2, 2};
cout << "Maximum fruits: " << maxFruits(A) << endl; // 输出应为 4
return 0;
}
如果你是 Java 开发者,逻辑是完全一样的,只是 API 不同:
import java.util.*;
public class FruitBasket {
static int maxFruits(int[] A) {
int n = A.length;
int maxFruitsCollected = 0;
for (int start = 0; start < n; start++) {
Map basket = new HashMap();
int count = 0;
for (int i = start; i 2) break;
count++;
}
maxFruitsCollected = Math.max(maxFruitsCollected, count);
}
return maxFruitsCollected;
}
public static void main(String[] args) {
int[] A = {1, 2, 3, 2, 2};
System.out.println("Maximum fruits: " + maxFruits(A));
}
}
Python 的版本则更加简洁:
def max_fruits(A):
n = len(A)
max_collected = 0
# 遍历每一个可能的起始索引
for start in range(n):
basket = {} # 使用字典来模拟哈希表
count = 0
# 从起点向右探索
for i in range(start, n):
fruit_type = A[i]
basket[fruit_type] = basket.get(fruit_type, 0) + 1
# 一旦种类超过2,立即终止内层循环
if len(basket) > 2:
break
count += 1
max_collected = max(max_collected, count)
return max_collected
# 测试代码
if __name__ == "__main__":
A = [1, 2, 3, 2, 2]
print(f"Maximum fruits: {max_fruits(A)}")
#### 复杂度分析
虽然这个方法很容易理解,但在处理大规模数据时,它的表现并不理想。
- 时间复杂度:$O(N^2)$。我们需要遍历每一个起点 ($N$),对于每个起点,最坏情况下需要遍历到数组末尾 ($N$)。随着数据量增加,耗时会呈平方级增长。
- 空间复杂度:$O(1)$。因为我们的篮子(哈希表)最多只会存储 3 个元素(在发现第 3 种时就会 break),所以这是常数级别的空间占用。
你能想到瓶颈在哪里吗? 没错,我们在重复计算!当我们从第 1 棵树移动到第 2 棵树作为起点时,我们重新遍历了后面所有的树,完全忽略了之前已经计算过的信息。接下来,我们将用一种更聪明的方法来解决这个问题。
3. 更优的方法:滑动窗口
如果我们能像扫描仪一样,从左向右扫描一遍数组就能得到答案,那该多好?这就是滑动窗口 的核心思想。
#### 什么是滑动窗口?
想象一个变长的窗口,它的左边界和右边界都指向数组。
- 右边界:不断向右移动,尝试将新元素纳入窗口(采摘水果)。如果满足条件(类型不超过 2),窗口变大。
- 左边界:当加入新元素导致窗口不满足条件时(出现了第 3 种水果),我们需要移动左边界,将窗口最左边的元素踢出(扔掉水果),直到窗口重新满足条件为止。
在这个过程中,我们始终维护着一个“合法”的窗口,并记录窗口曾经达到的最大长度。
#### 优化思路详解
- 初始化:INLINECODE3f068ad4,创建一个空的哈希表 INLINECODEc42fd415。
- 扩张:遍历数组,INLINECODE45495a7a 指针每次向右移动一步。将 INLINECODE0ae3893d 加入
basket,计数加 1。 - 收缩:检查 INLINECODE68ce69bd 的大小。如果 INLINECODEcb08f7e1,说明我们有了 3 种水果。这是不允许的。这时,我们需要移动 INLINECODEfc34e24b 指针。将 INLINECODE2bb27568 从 INLINECODEe0b9b2e6 中移除(或者计数减 1,如果计数为 0 则从表中删除该键),然后 INLINECODEd075259c 向右移动一步。重复此过程,直到
basket.size() <= 2。 - 更新结果:每次窗口处于合法状态时(也就是 INLINECODEa1b946af 时),计算当前窗口大小 (INLINECODE0a3469fe) 并更新最大值。
#### 代码实现:进阶版
让我们用 C++ 来实现这个高效的算法。
#include
using namespace std;
// 优化后的滑动窗口解法
int maxFruitsOptimized(vector& A) {
int n = A.size();
int left = 0, maxLen = 0;
unordered_map basket; // 记录窗口内每种水果的数量
for (int right = 0; right 2) {
// 移除左边界水果的数量
basket[A[left]]--;
// 如果该水果数量减为0,从篮子里彻底移除该种类
if (basket[A[left]] == 0) {
basket.erase(A[left]);
}
// 左指针右移
left++;
}
// 3. 此时窗口 [left, right] 是合法的,更新最大长度
maxLen = max(maxLen, right - left + 1);
}
return maxLen;
}
C# 版本实现:
using System;
using System.Collections.Generic;
class GFG {
static int maxFruits(int[] A) {
int n = A.Length;
int left = 0, maxLen = 0;
Dictionary basket = new Dictionary();
for (int right = 0; right 2) {
basket[A[left]]--; // 减少左边界水果计数
if (basket[A[left]] == 0) {
basket.Remove(A[left]); // 计数为0则移除
}
left++; // 左边界右移
}
// 计算当前窗口大小
maxLen = Math.Max(maxLen, right - left + 1);
}
return maxLen;
}
static void Main() {
int[] A = {1, 2, 3, 2, 2};
Console.WriteLine("Maximum fruits (Optimized): " + maxFruits(A));
}
}
#### 复杂度分析
- 时间复杂度:$O(N)$。虽然代码里看起来有 INLINECODE8f4a81a0 循环,但请注意,INLINECODE8783878b 指针在整个过程中只会从 0 移动到 INLINECODEe27fd1e2。每个元素最多被 INLINECODEf0c52b93 指针遍历一次(进入窗口),被
left指针遍历一次(离开窗口)。所以总操作次数是 $2N$,是线性的。 - 空间复杂度:$O(1)$。哈希表的大小最大为 3(当有第 3 种元素进入时会立即收缩),因此也是常数空间。
4. 关键步骤解析与常见陷阱
在编写滑动窗口代码时,有几个细节非常容易出错,让我们来详细拆解一下。
#### 陷阱一:从哈希表中删除元素的时机
在朴素方法中,我们只是简单地把 INLINECODE861ea666 放入集合中,如果不满足条件直接 INLINECODEad9ff8e9。但在滑动窗口中,我们必须维护计数(Counter)。
错误做法:当 INLINECODE159395e1 时,直接删除 INLINECODEe1b74a52 的键。
为什么错? 因为 INLINECODEc23050c0 这种水果可能在窗口里出现了多次(比如 INLINECODE10ed5f68)。当你遇到第 3 种水果时,你只需要把最左边的一个 INLINECODEd9c02604 扔掉,此时窗口里还有 INLINECODE7ba68c90。如果你直接把“2”这个键删了,哈希表可能会丢失“2”还在窗口里的信息,导致 basket.size() 计算错误。
正确做法:如我们在代码中所做,INLINECODE737024d0,然后判断是否为 0。只有当数量真的减到 0 时,才使用 INLINECODEade03170 或 Remove 将其从哈希表中移除。
#### 陷阱二:忘记更新最大长度
这是一个逻辑错误。在收缩窗口 INLINECODEb963030e 循环结束后,窗口虽然变短了,但它变为了一个合法的窗口。这个合法窗口的长度(可能是 INLINECODE96a1a144)依然需要与 INLINECODE0000e843 进行比较。千万不要只在没有进入 INLINECODE52edc01c 循环时才更新结果。
5. 实际应用与拓展
这个问题其实是 LeetCode 上“Longest Substring with At Most Two Distinct Characters”的变体。在现实生活中,类似的场景随处可见:
- 流量分析:假设你在监控网络流量,允许最多两种协议的数据包通过缓冲区,缓冲区最大能容纳多少连续的数据包?
- 电商推荐系统:你希望在用户的浏览历史中找到一个最长的区间,其中包含的商品类别不超过 2 种,以便分析用户的混合兴趣。
更进一步思考:
如果题目变一下,我们不仅能装 2 种,而是能装 k 种水果,怎么办?
其实代码逻辑完全不需要大改,只需要把 INLINECODE47f049f4 改成 INLINECODE6406b8f4 即可。滑动窗口的优雅之处就在于此——它非常容易泛化。
6. 总结
在这篇文章中,我们从最朴素的暴力解法开始,一步步挖掘其性能瓶颈,最终利用滑动窗口技术将算法效率提升到了线性级别 $O(N)$。
核心要点回顾:
- 暴力法:逻辑简单,但效率低($O(N^2)$),适合作为理解问题的第一步。
- 滑动窗口:利用双指针维护一个动态变化的窗口,是解决此类“最长子串/子数组”问题的标准解法。
- 哈希表细节:在收缩窗口时,要注意处理元素计数为 0 的情况,确保数据结构准确反映窗口内的元素状态。
希望这篇文章不仅帮助你解决了“水果成篮”这个问题,更能让你在面对类似的数组处理问题时,能够敏锐地想到滑动窗口这个强大的工具。试着动手写一遍代码,你会对其中指针移动的逻辑有更深的体会。