力扣原题:904. 水果成篮
算法题分析
首先,我们要把这个农场采摘水果的场景故事,翻译成计算机科学的语言。
- “你只有两个篮子,并且每个篮子只能装单一类型的水果”:这意味着在任何时刻,你收集的水果最多只能有两种不同的类型。
- “你必须从每棵树上恰好摘一个水果…向右移动到下一棵树”:这表明我们处理的是一个 连续的子数组。你不能跳过任何一棵树。
- “一旦…水果不符合篮子的水果类型…必须停止”:这是我们窗口扩展的终止条件。当我们遇到第三种水果时,当前的连续采摘就结束了。
- “可以从任意一棵树开始采摘…返回…最大数目”:我们的目标是找到满足条件(最多两种水果)的 最长 连续子数组。
综上所述,问题的核心就是:找出数组 fruits
中,只包含最多两种不同元素的最长连续子数组的长度。
这正是“滑动窗口”算法的经典应用场景。我们可以用两个指针(left
和 right
)来维护一个“窗口”,即一个连续子数组 [left, right]
。
提示解析
1 <= fruits.length <= 10^5
:N
的规模很大。这意味着一个O(N^2)
的暴力解法(即检查所有可能的子数组)将会超时(10^10
次操作级别)。我们的算法必须是O(N log N)
或O(N)
级别。这更加坚定了我们使用滑动窗口的决心,因为它的时间复杂度通常是O(N)
。0 <= fruits[i] < fruits.length
:水果的种类是非负整数,并且其值的上限与数组长度有关。这暗示我们不能想当然地创建一个巨大无比的数组来做频率统计(比如new int[100000]
),因为水果的种类可能是稀疏的(例如[1, 1, 99999, 99999]
)。因此,使用HashMap
来存储水果种类和其数量是一种更通用、更高效的选择。
滑动窗口
这是解决此类问题最标准、最通用的滑动窗口解法。
我们使用双指针 left
和 right
定义一个窗口 [left, right]
。right
指针负责向右扩展窗口,left
指针在窗口不满足条件时向右收缩窗口。
为了快速判断窗口内的水果种类是否超过了两种,我们使用一个 HashMap
来记录窗口内每种水果出现的次数。
算法流程:
- 初始化
left = 0
,maxLength = 0
,以及一个空的HashMap
basket
。 right
指针从0
开始遍历整个数组,代表窗口的右边界不断向右移动。- 对于每一个
fruits[right]
:- 将其放入
basket
中,更新其频率。 - 检查条件:检查
basket
中水果的种类数(即basket.size()
)是否大于2。 - 如果不满足条件 (basket.size() > 2):说明窗口内出现了第三种水果,需要收缩窗口。我们进入一个
while
循环,不断将left
指针向右移动:- 减少
fruits[left]
在basket
中的频率。 - 如果
fruits[left]
的频率变为0,意味着这种水果已经完全移出窗口,将其从basket
中移除。 left
指针加一。- 这个
while
循环一直执行,直到basket.size()
重新变回2为止。
- 减少
- 如果满足条件:当前窗口
[left, right]
是有效的。我们更新最大长度maxLength = max(maxLength, right - left + 1)
。
- 将其放入
right
指针遍历完整个数组后,maxLength
就是最终答案。
/* 思路:标准滑动窗口。使用HashMap记录窗口内水果种类和数量。右指针扩展窗口,当水果种类超过2时,左指针收缩窗口,直到种类数恢复为2。在此过程中不断更新最大长度。时间复杂度:O(N),空间复杂度:O(1)。*/
import java.util.HashMap;
import java.util.Map;
class Solution {
public int totalFruit(int[] fruits) {
if (fruits == null || fruits.length == 0) {
return 0;
}
int n = fruits.length;
// 使用HashMap作为我们的“篮子”。
// Key: 水果种类, Value: 该种类水果在当前窗口内的数量。
// 为什么用HashMap? 因为水果的种类值范围可能很大且稀疏,HashMap能高效处理这种情况,
// 只存储出现过的种类,而不需要预先分配一个可能非常大的数组。
Map<Integer, Integer> basket = new HashMap<>();
int left = 0;
int maxLength = 0;
// right指针负责探索,扩展窗口的右边界
for (int right = 0; right < n; right++) {
int currentFruit = fruits[right];
// 将右指针指向的水果放入篮子,数量+1
// map.getOrDefault(key, defaultValue) 是一个简洁的API,
// 避免了先用containsKey判断再get的繁琐代码。
basket.put(currentFruit, basket.getOrDefault(currentFruit, 0) + 1);
// 当篮子里的水果种类超过2种时,需要从左边扔掉一些水果
while (basket.size() > 2) {
int leftFruit = fruits[left];
basket.put(leftFruit, basket.get(leftFruit) - 1);
// 如果左边这种水果的数量在窗口里减到0了,就从篮子里(map的keyset)拿掉
if (basket.get(leftFruit) == 0) {
basket.remove(leftFruit);
}
// 左指针向右移动,缩小窗口
left++;
}
// 每次窗口状态合法时(种类数<=2),都计算一下当前窗口的长度
// 并与已记录的最大长度比较,取较大者。
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
- 时间复杂度:O(N)。虽然有嵌套的
while
循环,但left
和right
两个指针都只会从左到右单向移动,最多各自遍历数组一次。因此,每个元素最多被访问两次(一次被right
指针加入,一次被left
指针移出)。总的时间复杂度是线性的。 - 空间复杂度:O(1)。
HashMap
basket
最多只会存储3种水果的信息(当size
刚超过2,准备收缩窗口时)。由于水果种类的上限是一个常数(2,临时是3),所以空间复杂度是常数级别的。
滑动窗口优化
这是一种对解法1的逻辑优化,专注于维护窗口的边界,而不是维护窗口内所有元素的频率。这种思路更精简,但可能稍微抽象一些。
我们不再统计每种水果的完整频率,而是只关心窗口内水果的种类和它们最后一次出现的位置。
算法流程:
- 同样使用双指针
left
,right
和一个HashMap
basket
。但这次HashMap
的 Value 存储的是该水果最后一次出现的索引。 right
指针向右遍历。- 对于
fruits[right]
:- 将其放入
basket
并记录其当前索引right
。 - 检查条件:当
basket.size() > 2
时:- 我们需要从
basket
中移除一种水果来使窗口重新有效。应该移除哪种?应该移除那个最久没被看见的水果,即它最后一次出现的索引最小。 - 遍历
basket
的值(即所有索引),找到那个最小的索引minIndex
。 left
指针直接跳到minIndex + 1
,这就是新窗口的左边界。- 从
basket
中移除fruits[minIndex]
这种水果。
- 我们需要从
- 更新结果:
maxLength = max(maxLength, right - left + 1)
。
- 将其放入
/* 思路:优化的滑动窗口。使用HashMap记录窗口内水果种类和其最后一次出现的索引。当水果种类超过2时,找到最后出现位置最靠左的水果,并直接将窗口的左边界left移动到该水果的下一个位置。时间复杂度:O(N),空间复杂度:O(1)。*/
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
class Solution {
public int totalFruit(int[] fruits) {
if (fruits == null || fruits.length == 0) {
return 0;
}
int n = fruits.length;
// Key: 水果种类, Value: 该水果最后一次出现的索引
Map<Integer, Integer> basket = new HashMap<>();
int left = 0;
int maxLength = 0;
for (int right = 0; right < n; right++) {
basket.put(fruits[right], right);
if (basket.size() > 2) {
// 找到最后出现位置最靠左(即索引最小)的水果
int minIndex = n; // 初始化为一个最大可能值
// Collections.min(basket.values()) 也可以做到,但遍历一次更直观
for (int index : basket.values()) {
minIndex = Math.min(minIndex, index);
}
// 将窗口的左边界移动到该水果的下一个位置
left = minIndex + 1;
// 从篮子中移除这种水果
basket.remove(fruits[minIndex]);
}
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
- 时间复杂度:O(N)。
right
指针遍历一次数组。在if (basket.size() > 2)
分支内,我们遍历HashMap
的值。但因为basket
的大小被严格限制在最多3个,所以这个内部遍历是O(1)
的常数时间操作。因此,总时间复杂度是线性的。 - 空间复杂度:O(1)。与解法1相同,
HashMap
的大小是常数级别的。
<解法对比>
对比维度 | 解法1: 标准滑动窗口 (频率统计) | 解法2: 优化滑动窗口 (末位索引) |
---|---|---|
核心思想 | 统计窗口内每种水果的频率,当种类超标时,通过 while 循环逐步收缩左边界。 | 记录窗口内每种水果最后出现的索引,当种类超标时,直接将左边界跳到最旧水果的下一个位置。 |
代码逻辑 | 更直观。while 循环收缩窗口的逻辑非常经典,容易理解。 | 更精简。省去了收缩窗口的 while 循环,但找到 minIndex 的逻辑需要额外思考。 |
时间复杂度 | O(N) | O(N) |
空间复杂度 | O(1) | O(1) |
执行效率 | 理论上可能涉及更多次的 left 指针移动。 | left 指针是“跳跃式”前进的,操作次数可能更少,在某些数据集上可能略快。 |
总结 | 通用性强,是学习和掌握滑动窗口算法的绝佳入门。对于 “最多包含K个不同元素” 的问题,只需把2改成K即可。 | 巧妙高效,是对问题特性更深入的利用,展示了解决问题的不同思路。面试中能写出此解会是加分项。 |