题目描述

✅ 1438. 绝对差不超过限制的最长连续子数组

image-20260928234402565

image-20260928234402567

题意分析

在所有连续子数组中,找到任意两个元素的绝对差都不超过 limit 的最长长度。区间中最大的绝对差一定来自最大值与最小值,因此合法条件等价于 max - min <= limit,不需要逐对比较。

向右加入元素时,区间极差不会减小;从左侧移出元素时,极差不会增大。这种单调性允许用滑动窗口维护合法区间,难点是快速得到窗口的最大值与最小值。

解法:双单调队列滑动窗口

核心思路

[!blue]

维护窗口 [left, right],再用两条存放下标的双端队列记录极值候选。两条队列的下标都从头到尾递增,最大值队列对应的数值递减,最小值队列对应的数值递增,因此两个队首就是当前窗口的最大值与最小值。

右端加入新元素时,最大值队列从尾部删除所有不大于新值的候选。旧候选位置更早、值又不更大:以后只要窗口还包含它,就一定也包含新元素,而新元素更有资格成为最大值、离开窗口也更晚,所以旧候选可以永久淘汰。最小值队列对称地删除不小于新值的队尾。

加入右端后,若两个队首的差大于 limit,就持续右移 left。移出的下标只有恰好位于队首时才需要弹出。若它不在某条队列的队首,说明它已经从这条队列中被淘汰,无需额外处理,不能把仍在窗口中的队首误删。

第一次恢复合法时停止收缩,此时得到以 right 结尾的最长合法窗口。更靠左的起点已经证明会超限;之后继续扩大右端也不能让这些起点重新合法,所以左指针只需向右移动。依次取各个右端的最长长度,就得到全局答案。

题目保证 limit >= 0,单个元素的极差为 0,因此收缩最迟会在单元素窗口停止,不会把窗口缩空。

解题步骤

  1. 初始化两条空队列、左边界 left = 0 和答案 answer = 0。
  2. 每次扩展 right,弹出最大值队列中不大于新值的队尾,以及最小值队列中不小于新值的队尾,再把 right 加入两条队列。
  3. 比较两个队首对应的值。若极差超限,检查 left 是否位于任一队首,是则弹出,再让 left 加一,重复直到窗口合法。
  4. 用 right - left + 1 更新答案,再处理下一个右端。
  5. Go 用切片和 maxHead、minHead 模拟队列。有效元素从头下标开始,队尾弹出条件也必须限制在这个有效范围内。

代码实现

class Solution {
    public int longestSubarray(int[] nums, int limit) {
        Deque<Integer> maxQueue = new ArrayDeque<>();
        Deque<Integer> minQueue = new ArrayDeque<>();
        int left = 0;
        int answer = 0;

        for (int right = 0; right < nums.length; right++) {
            // 更新且不更小的新值支配旧候选,可从尾部永久淘汰
            while (!maxQueue.isEmpty() && nums[maxQueue.peekLast()] <= nums[right]) {
                maxQueue.pollLast();
            }

            while (!minQueue.isEmpty() && nums[minQueue.peekLast()] >= nums[right]) {
                minQueue.pollLast();
            }

            maxQueue.offerLast(right);
            minQueue.offerLast(right);

            // 持续收缩到极差合法,只移除实际过期的队首
            while (nums[maxQueue.peekFirst()] - nums[minQueue.peekFirst()] > limit) {
                // 移出的是当前左端,检查并清理对应队首后再前移
                if (maxQueue.peekFirst() == left) {
                    maxQueue.pollFirst();
                }

                if (minQueue.peekFirst() == left) {
                    minQueue.pollFirst();
                }

                left++;
            }

            answer = Math.max(answer, right - left + 1);
        }

        return answer;
    }
}
func longestSubarray(nums []int, limit int) int {
    maxQueue := make([]int, 0, len(nums))
    minQueue := make([]int, 0, len(nums))
    maxHead, minHead := 0, 0
    left, answer := 0, 0

    for right, num := range nums {
        // 更新且不更小的新值支配旧候选,可从尾部永久淘汰
        for len(maxQueue) > maxHead &&
            nums[maxQueue[len(maxQueue)-1]] <= num {
            maxQueue = maxQueue[:len(maxQueue)-1]
        }
        for len(minQueue) > minHead &&
            nums[minQueue[len(minQueue)-1]] >= num {
            minQueue = minQueue[:len(minQueue)-1]
        }
        maxQueue = append(maxQueue, right)
        minQueue = append(minQueue, right)

        // 持续收缩到极差合法,只移除实际过期的队首
        for nums[maxQueue[maxHead]]-nums[minQueue[minHead]] > limit {
            // 移出的是当前左端,检查并清理对应队首后再前移
            if maxQueue[maxHead] == left {
                maxHead++
            }
            if minQueue[minHead] == left {
                minHead++
            }
            left++
        }
        if right-left+1 > answer {
            answer = right - left + 1
        }
    }
    return answer
}

复杂度分析

  • 时间复杂度:$O(n)$,每个下标在每条队列中进出至多一次。
  • 空间复杂度:$O(n)$,两条候选队列。

关键点总结

[!green]

  • 极差代替任意两元素的比较,双单调队列让两个极值始终位于队首。
  • 从队尾删除的是被新元素替代的候选,从队首删除的是已经离开窗口的下标,两种操作理由不同。
  • 每个下标在每条队列中只会加入一次、删除至多一次,所以多重循环仍是线性总时间。
  • 左端只收缩到首次合法,才能保留当前右端下的最大长度。

易错点总结

[!yellow]

  • 最大值队列按值递减,最小值队列按值递增,不要把两个方向写反。
  • 队列保存下标,才能准确判断哪个候选离开了窗口;仅凭数值无法区分重复元素的位置。
  • 左边界移动时,只有队首下标等于旧 left 才弹出,不能每次无条件弹队首。
  • 一次移动未必能消除超限,需要使用循环;恢复合法之前不能更新答案。
  • Go 的队尾操作不能越过 maxHead 或 minHead,头下标之前的区域已经不属于有效队列。

相似题目

题目 难度 关联与区别
239. 滑动窗口最大值 困难 一个单调队列维护最大值,再增加一个维护最小值,窗口合法性由两者差决定。
862. 和至少为 K 的最短子数组 困难 用单调队列删除过期且不可能更优的候选;本题同时维护窗口最大值和最小值,该题按前缀和维护可能的最短区间起点。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2023/60256979
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!