904. 水果成篮

力扣原题:904. 水果成篮

算法题分析

首先,我们要把这个农场采摘水果的场景故事,翻译成计算机科学的语言。

  1. “你只有两个篮子,并且每个篮子只能装单一类型的水果”:这意味着在任何时刻,你收集的水果最多只能有两种不同的类型。
  2. “你必须从每棵树上恰好摘一个水果…向右移动到下一棵树”:这表明我们处理的是一个 连续的子数组。你不能跳过任何一棵树。
  3. “一旦…水果不符合篮子的水果类型…必须停止”:这是我们窗口扩展的终止条件。当我们遇到第三种水果时,当前的连续采摘就结束了。
  4. “可以从任意一棵树开始采摘…返回…最大数目”:我们的目标是找到满足条件(最多两种水果)的 最长 连续子数组。

综上所述,问题的核心就是:找出数组 fruits 中,只包含最多两种不同元素的最长连续子数组的长度。

这正是“滑动窗口”算法的经典应用场景。我们可以用两个指针(leftright)来维护一个“窗口”,即一个连续子数组 [left, right]

提示解析

  • 1 <= fruits.length <= 10^5N 的规模很大。这意味着一个 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 来存储水果种类和其数量是一种更通用、更高效的选择。

滑动窗口

这是解决此类问题最标准、最通用的滑动窗口解法。

我们使用双指针 leftright 定义一个窗口 [left, right]right 指针负责向右扩展窗口,left 指针在窗口不满足条件时向右收缩窗口。

为了快速判断窗口内的水果种类是否超过了两种,我们使用一个 HashMap 来记录窗口内每种水果出现的次数。

算法流程

  1. 初始化 left = 0, maxLength = 0,以及一个空的 HashMap basket
  2. right 指针从 0 开始遍历整个数组,代表窗口的右边界不断向右移动。
  3. 对于每一个 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)
  4. 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 循环,但 leftright 两个指针都只会从左到右单向移动,最多各自遍历数组一次。因此,每个元素最多被访问两次(一次被right指针加入,一次被left指针移出)。总的时间复杂度是线性的。
  • 空间复杂度O(1)HashMap basket 最多只会存储3种水果的信息(当size刚超过2,准备收缩窗口时)。由于水果种类的上限是一个常数(2,临时是3),所以空间复杂度是常数级别的。

滑动窗口优化

这是一种对解法1的逻辑优化,专注于维护窗口的边界,而不是维护窗口内所有元素的频率。这种思路更精简,但可能稍微抽象一些。

我们不再统计每种水果的完整频率,而是只关心窗口内水果的种类和它们最后一次出现的位置

算法流程

  1. 同样使用双指针 left, right 和一个 HashMap basket。但这次 HashMap 的 Value 存储的是该水果最后一次出现的索引
  2. right 指针向右遍历。
  3. 对于 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即可。巧妙高效,是对问题特性更深入的利用,展示了解决问题的不同思路。面试中能写出此解会是加分项。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值