力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 24进阶速览:单调队列、设计题与抽样

这是进阶卷的最后一站,我们快速过几类「面试常客但自成体系」的考点。它们不像前面那样能塞进某个大主题,但几乎每场面试都可能冒出来: 单调队列(滑动窗口最大值的 O(n) 解法,是 专题 03 滑窗的近亲)、设计题(LRU / LFU 缓存,考你数据结构组合能力)、蓄水池抽样(长度未知的流式数据等概率抽样)。 末尾再给你一个「树状数组与线段树」的概念速览——这是竞赛向的提速武器,面试只要知道它解决什么即可。本章以「速览 + 可直接提交的模板」为主,帮你把工具箱补齐。

学习目标
掌握单调队列、LRU、蓄水池抽样
前置要求
专题 03 滑窗、专题 06 单调栈、链表基础
本章产出
239 / 146 / 382 完整代码 + 概念卡
建议学时
8–10 小时

24.1单调队列:滑动窗口最大值(困难,力扣 239)

困难 核心:Deque 存下标 · 候选集淘汰

题意:给你一个数组 nums 和窗口大小 k,窗口从最左滑到最右,求每个窗口里的最大值,返回数组。例:nums=[1,3,-1,-3,5,3,6,7], k=3 → 各窗口最大为 [3,3,5,5,6,7]。

思路推导

第一步,暴力与 naive 滑窗的代价。每滑一次重新找最大是 O(k),总 O(nk)。能不能每个元素只处理一次?关键是维护一个「当前窗口候选最大值集合」。

第二步,核心直觉——「候选集淘汰」。用双端队列 Deque 存下标(必须存下标,才能判断它是否滑出窗口),且保持队列里下标对应的数值从队头到队尾严格递减(队头永远是当前窗口最大)。

第三步,入队时从队尾淘汰。当新元素 nums[i] 要进窗口,它比队尾元素大时,队尾那些元素「这辈子都不可能再成为最大值了」——因为 nums[i] 更晚进、也更晚出,只要它还在窗口,队尾那些更小的就永远被压着。所以从队尾不断弹出比 nums[i] 小的,再把 i 压入。这样队头始终是最大值。

第四步,出队头判过期。每步检查队头下标是否 ≤ i - k(已滑出左边界),是则弹出。窗口形成后(i >= k-1),队头就是当前窗口最大值,记录。

第五步,与单调栈对比(承接 专题 06)。单调栈是「只从栈顶进、只从栈顶出」(处理「下一个更大元素」);单调队列是「从队尾进、队头队尾都能出」(队头出是因为过期、队尾出是因为被淘汰)。一个只进不出端不同,用途也不同。

参考代码

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        int[] ans = new int[n - k + 1];
        Deque<Integer> dq = new ArrayDeque<>();   // 存下标,队头=当前最大
        for (int i = 0; i < n; i++) {
            // ① 队尾淘汰:弹出所有比 nums[i] 小的(它们永无出头之日)
            while (!dq.isEmpty() && nums[dq.peekLast()] <= nums[i]) {
                dq.pollLast();
            }
            dq.offerLast(i);                       // ② 下标入队尾
            // ③ 队头过期就弹出(滑出窗口左边界)
            if (dq.peekFirst() <= i - k) {
                dq.pollFirst();
            }
            // ④ 窗口形成后,队头即当前窗口最大值
            if (i >= k - 1) {
                ans[i - k + 1] = nums[dq.peekFirst()];
            }
        }
        return ans;
    }
}

复杂度

O(n) 时间(每个下标最多入队一次、出队一次),O(k) 空间(队列长度不超过窗口)。把 O(nk) 压到 O(n),是单调队列的招牌应用。

存下标不存值:队列里必须存下标而非数值,否则无法判断「队头元素是否已经滑出窗口」。这是单调队列题的第一铁律(见 24.6 节坑一)。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 队列的最大值(剑指 Offer 59-II,简单)——单调队列的简化版;差异在动态 push/pop 而非固定窗口(提示:维护一个递减双端队列,push 时从队尾淘汰小的,pop 时若弹出的是队头则同步出队)。
  • 和至少为 K 的最短子数组(力扣 862,困难)——前缀和 + 单调队列;差异在找最小长度使区间和 ≥K(提示:维护前缀和的递增队列,对每个 j 找最小的 i 使 pre[j]-pre[i]≥K,即队头尽可能小)。
  • 绝对差不超过限制的最长连续子数组(力扣 1438,中等)——两个单调队列;差异在同时维护窗口最大和最小(提示:一个递减队列存最大、一个递增队列存最小,二者差 >limit 就缩左边界)。

24.2LRU 缓存:哈希表 + 双向链表(中等,力扣 146)

中等 核心:HashMap + 自制双向链表

题意:设计一个数据结构,支持:get(key) 取值(不存在返回 -1)、put(key, value) 写入;容量满时淘汰最久没用过的(LRU = Least Recently Used)。两个操作都要 O(1)。例:容量 2,依次 put(1,1), put(2,2), get(1), put(3,3) → 此时淘汰 key2(最久未用),再 get(2) 返回 -1。

思路推导

第一步,为什么单向链表不够。要 O(1) 删除「任意节点」(比如某个 key 被访问后要从原位置摘下、移到头部),需要知道它的前驱。单向链表找前驱要遍历,破坏 O(1)。所以必须用双向链表:每个节点有 prev/next,删除/插入都 O(1)。

第二步,组合两个结构。HashMap<key, Node> 负责按 key 秒查节点(O(1) 定位);双向链表负责维护使用顺序——最近用的放头部,最久未用的在尾部。淘汰时删尾节点,并从 map 移除。

第三步,哨兵节点简化边界。链表头尾各放一个「 dummy 哨兵」(head、tail),这样真正的元素永远在中间,addToHead / removeNode 不用特判空指针,代码干净很多。

第四步,get / put 的套路。 get:map 查到 → 把节点移到头部(删原位置 + 加到头)并返回值;查不到返回 -1。 put:若 key 已存在 → 更新值并移到头部(注意不是新增!见坑);若不存在 → 新建节点加到头部,map 放入;若超容量 → 删尾部节点并从 map 移除。

参考代码(完整可提交)

class LRUCache {
    // 双向链表节点
    class Node {
        int key, val;
        Node prev, next;
        Node(int k, int v) { key = k; val = v; }
    }

    private int capacity, size = 0;
    private Map<Integer, Node> map = new HashMap<>();
    private Node head, tail;               // 哨兵,head 最近、tail 最久

    public LRUCache(int capacity) {
        this.capacity = capacity;
        head = new Node(0, 0); tail = new Node(0, 0);
        head.next = tail; tail.prev = head; // 串起两个哨兵
    }

    // 把节点 x 加到 head 之后(最新位置)
    private void addToHead(Node x) {
        x.prev = head; x.next = head.next;
        head.next.prev = x; head.next = x;
    }
    // 从链表中摘除 x
    private void remove(Node x) {
        x.prev.next = x.next; x.next.prev = x.prev;
    }
    // 把已有节点移到头部(先删后加)
    private void moveToHead(Node x) {
        remove(x); addToHead(x);
    }

    public int get(int key) {
        Node x = map.get(key);
        if (x == null) return -1;
        moveToHead(x);                     // 访问过 → 变最新
        return x.val;
    }

    public void put(int key, int value) {
        Node x = map.get(key);
        if (x != null) {                   // 已存在:更新并移到头部(非新增!)
            x.val = value;
            moveToHead(x);
        } else {
            Node node = new Node(key, value);
            map.put(key, node);
            addToHead(node);
            size++;
            if (size > capacity) {          // 超容量:淘汰最久(尾前节点)
                Node old = tail.prev;
                remove(old);
                map.remove(old.key);
                size--;
            }
        }
    }
}

复杂度

get / put 均 O(1)(哈希定位 + 链表常数操作),空间 O(capacity)。这是面试设计题的标杆模板。

边界自查:① 容量为 1 时,put 新 key 会先淘汰再插入,始终只剩一个。② get 不存在返回 -1 且不改变顺序。③ put 已存在的 key 只是更新 + 移到头,size 不增、不触发淘汰(常见错:重复计数导致误淘汰)。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 常数时间插入、删除和获取随机元素(力扣 380,中等)——哈希 + 数组组合;差异在还要 O(1) 随机返回(提示:用 ArrayList 存值、HashMap 记值→下标,删除时与末尾交换再删,保证随机均匀)。
  • 全 O(1) 的数据流(力扣 432,困难)——LRU 的升级;差异在支持插入/删除任意值、查最大频率(提示:双向链表存 (值, 频次) 节点,配合 map 定位,逻辑同 LRU 但操作更杂)。
  • LFU 缓存(力扣 460,困难,选学)——见 24.3 节,在 LRU 基础上再按「使用频率」分层。

24.3LFU 缓存思路(困难,力扣 460,选学)

困难 核心:频率桶 + 每桶内 LRU 选学

它解决什么:LRU 淘汰「最久没用」的;LFU(Least Frequently Used)淘汰「使用次数最少」的——若多个都用得少,再比谁更久没用。适用于「热点数据应常驻」的场景。

设计骨架(不给完整实现)

关键难点是既要按频率分层、又要同频内按时间排序,且所有操作 O(1)。经典骨架:

选学定位:LFU 的完整实现细节多、易错(频率桶的增删、minFreq 的更新、同频 LRU),面试中直接让写 LFU 的题较少,且常允许先讲思路。能说出「频率桶 + 桶内双向链表 + 维护 minFreq」三件套即可得分,不必背完整代码。

24.4链表随机节点:蓄水池抽样(中等,力扣 382)

中等 核心:第 i 个以 1/i 概率替换

题意:给一个单向链表(长度未知、可能极大,不允许先遍历计数再随机),要求实现 getRandom():等概率返回链表中的任一节点值。多次调用,每个节点被返回的概率相同。

思路推导

第一步,为什么不能直接随机下标。单向链表不能 O(1) 按 index 取,且题目常隐含「数据流式到来、总长度未知或太大」。必须先遍历一遍才能知道长度,但那样 getRandom 每次都要重遍历,太慢;若数据无限流更不可能。

第二步,蓄水池抽样思想——边走边定。只遍历一次,一边走一边维护「当前候选」。规则:

第三步,为什么这样能保证等概率(一句话直觉)。每个节点「最终存活」的概率 = 「它被选入的概率」×「之后所有节点都没把它换掉的概率」= (1/i) × (i/(i+1)) × ((i+1)/(i+2)) × … × ((n-1)/n) = 1/n。望远镜式约分后,每个节点恰好 1/n。妙在不需要事先知道 n。

第四步,循环从 i=1 还是 i=0。若用「已遍历计数 cnt」,第一个节点 cnt=1 概率 1/1 直接选;之后 cnt 递增,第 cnt 个以 1/cnt 替换。注意 cnt 从 1 开始(不是 0),否则会出现除以 0 或概率错(见 24.6 节坑三)。

参考代码

class Solution {
    ListNode head;
    Random rand = new Random();

    public Solution(ListNode head) { this.head = head; }

    public int getRandom() {
        int ans = 0;                  // 当前候选值
        int cnt = 0;                  // 已遍历节点数(从 1 开始)
        ListNode p = head;
        while (p != null) {
            cnt++;                    // 第 cnt 个节点
            // 以 1/cnt 的概率把候选替换为当前节点
            if (rand.nextInt(cnt) == 0) {   // 等价于 Math.random() < 1.0/cnt
                ans = p.val;
            }
            p = p.next;
        }
        return ans;
    }
}

复杂度

O(n) 时间(每次 getRandom 遍历一遍链表),O(1) 额外空间。代价是每次调用都走一遍,但换来了「不依赖总长、等概率」的能力,是流式抽样的标配。

适用场景一句话:当你面对「长度未知 / 太大不能全存 / 流式到来」的数据,又想等概率抽一个(或 k 个),就用蓄水池抽样——它把「先数总数再随机」变成了「一次遍历边走边定」。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 随机数索引(力扣 398,中等)——蓄水池的「条件版」;差异在只从等于 target 的节点里等概率抽(提示:遍历时只对值 == target 的节点做计数 cnt,第 cnt 个以 1/cnt 替换,其余节点不参与)。
  • 按权重随机选择(力扣 528,中等)——前缀和 + 二分;差异在每个元素有不同权重(提示:算权重前缀和,rand 出一个数后二分定位落在哪个区间,等价「带权抽签」)。
  • 随机点(力扣 497,中等)——二维蓄水池/前缀和;差异在矩形网格里随机选一个 1 的格子(提示:把格子拍平成一维,用前缀和记录 1 的累计数,rand 后二分定位)。

24.5树状数组与线段树是什么(概念)

核心:单点改 + 区间和 竞赛向

它们解决什么:一类「动态」问题——数组会频繁单点修改,又频繁问「某段区间的和 / 最值」。朴素做法:修改 O(1) 但区间求和 O(n),或前缀和 O(1) 查询但修改 O(n)。树状数组(Fenwick Tree)和线段树(Segment Tree)把两者都压到 O(log n)。

两个思想的直觉(不写实现)

① 树状数组 = 「前缀和的分块倍增」。它不存完整前缀和,而是把下标按「二进制最低位 1 的位置」分块,每个节点管一段长度为 2ᵏ 的区间。修改时只更新「覆盖到它的几个块」,查询前缀和时把「几个不重叠的块」拼起来,都是沿着 lowbit 跳转,O(log n)。优点:代码极短、常数小;缺点:主要擅长「前缀和 / 单点改」,区间最值不自然。

② 线段树 = 「区间的二分树」。把区间 [1, n] 不断对半拆成左右子区间,建成一棵二叉树,每个节点存对应区间的聚合值(和 / 最大 / 最小皆可)。修改和查询都沿树下钻到 O(log n) 个节点。优点:通用,区间最值、区间修改都能做;缺点:代码比树状数组长。

怎么选、面试到什么程度

场景推荐理由
单点改 + 前缀/区间和树状数组代码短、常数小
区间改 / 区间最值 / 更复杂聚合线段树通用性强
面试知道概念即可多数面试不要求默写建树,能说清「解决单点改+区间查询、O(log n)」即可
面试够用版本:能说出——「当数组既要频繁单点修改、又要频繁区间求和/最值时,朴素法顾此失彼;树状数组和线段树都能把修改和查询都做到 O(log n)。树状数组靠 lowbit 分块、代码短;线段树是区间二分树、更通用。」不要求写实现,知道它是什么、解决什么,就是合格的面试回答。

24.6本专题常见坑

  • 坑一:单调队列存值忘存下标。只存数值无法判断队头是否已滑出窗口,导致返回过期的旧最大值。务必存下标,用 dq.peekFirst() ≤ i - k 判过期(见 24.1 节)。
  • 坑二:LRU 的 put 已存在 key 时忘更新并移到头部。常见两种错:① 当成新增,size 重复 +1 触发误淘汰;② 只更新值却没 moveToHead,破坏「最近使用」语义。正确做法是「更新 val + 移到头部」,size 不变。
  • 坑三:蓄水池抽样循环从 i=0 还是 i=1。计数 cnt 必须从 1 开始(第一个节点概率 1/1 直接选),若从 0 开始会出现 rand.nextInt(0) 报错或概率分母错。记住「第 cnt 个以 1/cnt 替换,cnt 从 1 起」。
  • 坑四:LFU 忘维护 minFreq。淘汰时要知道当前最小频率在哪,若只维护频率桶却不跟踪 minFreq,淘汰时得全桶扫描退化为 O(n)。minFreq 在节点频率升档、旧桶变空时需同步更新。
  • 坑五:单调队列的「等于」是否弹出。求最大值时,nums[dq.peekLast()] <= nums[i] 的等于号通常要弹(相同值留新的更晚过期更优);但若题目要求「严格递减去重」则改 <。看清题目对相等元素的处理。

24.7自测题

Q1.【动手题】用单调队列求滑动窗口最大值,当 nums=[1,3,-1,-3,5,3,6,7], k=3 时,写出队列在 i=0,1,2,3 各步后的内容(存的是下标),并指出每次记录的窗口最大值。

i=0: 队[0],窗未成;i=1: 弹0(3≤3)入1,队[1],窗未成;i=2: 入2(-1≤3留),队[1,2],i≥2记队头nums[1]=3;i=3: 弹2(-3≤-3)入3,队[1,3],记nums[1]=3。后续 i=4 队头1过期弹出入4记5;i=5 入5(3≤5弹3)队[4,5]记5;i=6 弹5入6记6;i=7 队[6,7]记7。结果 [3,3,5,5,6,7]。

Q2. 为什么 LRU 缓存必须用双向链表而不是单向链表?

因为 O(1) 删除「任意被访问的节点」需要知道它的前驱。单向链表找前驱要 O(n) 遍历,破坏 O(1) 要求;双向链表每个节点有 prev/next,删除和头尾插入都是常数时间,配合 HashMap 定位才能实现 get/put 全 O(1)。

Q3. 蓄水池抽样为什么不需要事先知道数据总长度 n?给出每个节点最终被选中的概率推导。

因为它边遍历边以 1/i 概率替换候选,不用先数总数。节点 i 最终存活概率 = 被选入(1/i) × 之后都没被换掉 = (1/i)×(i/(i+1))×…×((n-1)/n) = 1/n,望远镜约分后每个节点恰为 1/n,与先知道 n 再随机等价。
学完这章,你应该能回答:① 单调队列如何用「队尾淘汰 + 队头判过期」在 O(n) 内求窗口最大值,并与单调栈区分?② 手写 LRU(HashMap + 双向链表 + 哨兵)并保证 get/put 全 O(1)?③ 用蓄水池抽样在长度未知时等概率抽一个?④(选学)说出 LFU 与树状数组/线段树分别解决什么、到什么程度够面试。