#004. 数据结构 III:堆、优先队列与 Trie

数据结构与算法知识地图:线性结构、树与哈希、图算法、排序查找范式四大板块与面试口径

#学习目标

很多问题的正确形态是「我只要当前最重要的那个,别的不用全排好」——合并 k 个有序流要反复取最小,调度要反复取最紧急,流式数据要维护前 k 大。排序是杀鸡用牛刀:全排 \(O(n \log n)\),而「反复取最值」用堆只要 \(O(\log n)\) 一次。堆(heap)就是为「只维护一个最值、其余部分保持半有序」而生的结构。

本章三块内容是一条线:先讲二叉堆本体——为什么一棵完全二叉树可以原样躺进数组、sift-up/sift-down 如何以 \(O(\log n)\) 恢复堆序、以及「自底向上建堆只要 \(O(n)\)」这个反直觉结论的完整证明;再讲它的两个直接应用——优先队列与 Top-k、原地堆排;最后讲另一种「按结构换查询」的树:Trie(前缀树),它把「共享前缀的字符串集合」变成一条条可 \(O(L)\) 行走的路径。

堆:半有序

只承诺「父不劣于子」,兄弟之间无序。换来的是:数组零指针存储、取最值 \(\Theta(1)\)、插入删除 \(O(\log n)\)。

Top-k:维护门槛

求前 k 大只维护一个大小为 k 的小顶堆,堆顶是「进入前 k 的门槛」,新元素赢过门槛才入局,总代价 \(O(n \log k)\)。

Trie:前缀共享

每个节点是字符串的一个前缀状态,插入与查询都沿字符走路径,\(O(L)\) 与集合大小无关,代价是空间放大。

#二叉堆:数组上的完全二叉树

二叉堆是一棵完全二叉树(除最后一层外全满,最后一层从左到右连续排列),且满足堆序性质:每个节点的键不劣于其父节点的键(小顶堆 min-heap 就是父 \(\le\) 子,堆顶全局最小;大顶堆 max-heap 反之)。完全性带来第一个礼物:零指针存储——按层序把节点排进数组,父子下标可以用算术互相推出:

\[\mathrm{parent}(i) = \left\lfloor \frac{i-1}{2} \right\rfloor, \qquad \mathrm{left}(i) = 2i+1, \qquad \mathrm{right}(i) = 2i+2.\]

怎么读:下标 0 是堆顶;节点 \(i\) 的父亲是 \((i-1)/2\) 向下取整,孩子是 \(2i+1\) 与 \(2i+2\)。反过来孩子推父亲唯一、父亲推孩子唯一,树的结构被数组的下标算术完整编码——这正是002 章「数组算得出地址」思想的延伸。第二个礼物来自完全性加堆序:树高恰为 \(\lfloor \log_2 n \rfloor\),任何「沿父子链移动」的操作都是 \(O(\log n)\)。

堆的两个基本操作都是「局部破坏、局部修复」:

sift-up(上浮):插入时把新元素放到数组末尾(保持完全性),若它小于父就与父交换,直至不小于父或到顶。路径长度 \(\le h\),代价 \(O(\log n)\)。

sift-down(下沉):取走堆顶后把末元素补到堆顶(保持完全性),让它与较小的那个孩子交换(小顶堆),直至两个孩子都不小于它或到底。代价 \(O(\log n)\)。

堆顶查询:下标 0 直接返回,\(\Theta(1)\)。注意堆只承诺这一个最值,其余部分无序

为什么下沉要挑「较小的孩子」

小顶堆要把补位元素换到「两个孩子中较小的那个」的位置上,因为新堆顶必须同时不大于两个孩子——如果换到较大的孩子位置,较小的孩子升上来当父亲反而可能大于另一个孩子,堆序在兄弟支路上被破坏。这个「挑较小者」是手写堆里最容易写错的分支。

#建堆为什么是 O(n),堆排为什么能原地

把 \(n\) 个元素变成堆有两种做法。逐个 push(自顶向下):每次 \(O(\log n)\),总 \(O(n \log n)\)。而自底向上建堆(heapify):从最后一个非叶子节点(下标 \(\lfloor n/2 \rfloor - 1\))倒着扫到 0,对每个节点做一次 sift-down。叶子天然是合法的小堆,倒序保证「被下沉的节点的两棵子树已经是堆」,于是每次 sift-down 都合法。它的总代价是线性:

\[\sum_{h=0}^{\lfloor \log_2 n \rfloor} \frac{n}{2^{h+1}} \cdot O(h) \;=\; O\!\left(n \sum_{h \ge 1} \frac{h}{2^{h+1}}\right) \;=\; O(n).\]

怎么读:高度为 \(h\) 的节点至多 \(n / 2^{h+1}\) 个(越高的层人越多、但高度越小),每个节点的 sift-down 代价正比于它的高度 \(h\)。求和号里把 \(n\) 提出来,剩下级数 \(\sum_h h/2^{h+1}\) 是收敛的常数(等于 1)。直觉版本:一半的节点是叶子、代价 0;四分之三的节点高度至多 1……绝大多数节点在树底,根本没得沉;少数在树顶的节点虽然贵,但数量按指数稀疏。这与「逐个 push」的本质区别是:push 让每个元素都可能从底浮到顶,而 heapify 让底部的海量元素一步不动。

堆排序(heapsort)把堆的两个机制串起来,得到最坏 \(O(n \log n)\) 且原地 \(O(1)\) 额外空间的排序(源题提纲对堆排的明确要求就是 in-place 换 \(O(1)\) 空间):先 heapify 建大顶堆;每轮把堆顶(当前最大值)与堆区末尾交换、堆区长度减一、对新堆顶 sift-down。每轮 \(O(\log n)\),共 \(n-1\) 轮,加上建堆 \(O(n)\),总 \(O(n \log n)\),全程只在原数组内交换。代价是不稳定(相等元素的相对次序会被跨越交换打乱)与缓存不友好(父子下标跳跃访问)。堆排与快排、归并的完整对比放在007 章

#优先队列与 Top-k 问题

优先队列(priority queue)是堆的抽象接口:元素带优先级,出队永远是优先级最高(或最低,按定义)的那个。C++ 里 std::priority_queue<int> 默认是大顶堆(模板参数是 less),要小顶堆须写 std::priority_queue<int, std::vector<int>, std::greater<int>>——「要小顶堆反而写 greater」是使用层的第一个坑,因为它比较的对象语义是「排序比较器」而不是「堆序」。

Top-k 是量化面试最爱的一类题:从 \(n\) 个元素里找最大的 \(k\) 个(或最高频的 \(k\) 个)。三条路线一张表看清:

方案时间额外空间适用
全排序后取前 k\(O(n \log n)\)\(O(1)\) 至 \(O(n)\)k 接近 n 或本来就要全序
大小为 k 的小顶堆\(O(n \log k)\)\(O(k)\)流式数据、k 远小于 n
快速选择(QuickSelect)平均 \(O(n)\)\(O(1)\)一次性查询、允许平均口径

堆解法的方向是最容易搞反的地方,用「门槛」来记:求最大的 k 个,维护的是小顶堆——堆里装着「目前的冠军组」,堆顶是冠军组里最弱的那个,也就是新元素的门槛;新元素若大于门槛,踢掉门槛入堆,否则直接淘汰。\(n\) 个元素每个至多触发一次 \(O(\log k)\) 的堆操作,总 \(O(n \log k)\),且内存只装得下 k 个元素时它照常工作(流式)。QuickSelect 的完整实现(含中位数的中位数保底)在008 章

#Trie:前缀树

Trie(发音 try,又称前缀树 prefix tree)把「共享前缀」显式编码进结构:从根到某节点的路径拼出一个前缀,每个节点持有至多 \(\Sigma\) 个孩子指针(\(\Sigma\) 为字母表大小,小写英文即 26)和一个「是否为完整单词结尾」的标记。三个核心操作都是「沿路径走」:

insert(word):逐字符沿孩子指针下行,缺节点就新建,走完把终点标记 isEnd。

search(word):沿路径下行,路径中断返回不存在;走通且终点有 isEnd 才算「是完整单词」。

startsWith(prefix):同 search,但只要求路径存在,不看 isEnd。

复杂度:三个操作都是 \(O(L)\),\(L\) 为字符串长度,与树里存了多少词无关——这是 Trie 相比哈希(还要算整串哈希)与 BST(\(O(L \log n)\) 次字符比较)的卖点。空间上界 \(O(N \Sigma)\),\(N\) 为全部字符数。

search 与 startsWith 的区别是考点

集合里存了 {"quant"}:search("quan") 是 false(不是完整词),startsWith("quan") 是 true(路径存在)。手写 Trie 时把「词尾标记」漏掉、把两者混为一谈,是最常见的实现错误。空间上,26 叉数组节点每层每字符固定开销 26 个指针,词表稀疏时浪费明显,可换 map 存孩子(省空间、稍慢)。

#例题详解

例题 1:手写小顶堆,支持 push、pop、top,并给出每个操作的复杂度。

建模。底层用 std::vector<int> 按层序存完全二叉树(下标映射见知识点)。不变量:任何时刻数组都满足小顶堆序(父 \(\le\) 子)。push 破坏后用 sift-up 修复,pop 用末元素补位后用 sift-down 修复。

代码。

#include <cstddef>
#include <vector>
#include <utility>
#include <stdexcept>

class MinHeap {
public:
    void push(int v) {
        heap_.push_back(v);                 // 放到末尾,保持完全性
        siftUp((int)heap_.size() - 1);      // 与父交换直至堆序恢复
    }

    int top() const {
        if (heap_.empty()) throw std::out_of_range("empty heap");
        return heap_[0];                    // 堆顶即全局最小
    }

    void pop() {
        if (heap_.empty()) throw std::out_of_range("empty heap");
        heap_[0] = heap_.back();            // 末元素补位
        heap_.pop_back();
        if (!heap_.empty()) siftDown(0);    // 下沉恢复堆序
    }

    bool empty() const { return heap_.empty(); }
    std::size_t size() const { return heap_.size(); }

private:
    static int parent(int i) { return (i - 1) / 2; }

    void siftUp(int i) {
        while (i > 0 && heap_[parent(i)] > heap_[i]) {
            std::swap(heap_[i], heap_[parent(i)]); // 与父交换
            i = parent(i);
        }
    }

    void siftDown(int i) {
        int n = (int)heap_.size();
        while (true) {
            int best = i, l = 2 * i + 1, r = 2 * i + 2;
            if (l < n && heap_[l] < heap_[best]) best = l;  // 取较小孩子
            if (r < n && heap_[r] < heap_[best]) best = r;
            if (best == i) break;                     // 已比两个孩子都小
            std::swap(heap_[i], heap_[best]);
            i = best;
        }
    }

    std::vector<int> heap_;
};

数值演示。依次 push 5、3、8、1:插入 1 后位于下标 3,其父(下标 1,值 3)大于 1,交换;再与堆顶 5 交换,数组变为 [1,3,8,5]。pop():末元素 5 补位为 [5,3,8],5 与较小孩子 3 交换得 [3,5,8],堆顶返回值 1。

复杂度。push 与 pop 均 \(O(\log n)\)(路径长度 \(\le \lfloor \log_2 n \rfloor\)),top 与 empty 为 \(\Theta(1)\)。空堆 pop 直接抛异常,fail-fast。

边界。空堆/单元素堆(siftDown 中孩子下标越界检查 \(l \lt n\)、\(r \lt n\) 必须有);相等元素(交换条件用严格大于,允许相等共存);大顶堆只需把两个比较符号反过来。变式追问「合并两个堆」:二叉堆不支持高效合并,需要斜堆/二项堆等可合并堆,能点名即可。

面试怎么讲。先写下标映射三公式,再讲「插入上浮、删除下沉」的对称性,数值演示一遍 push-pop 全过程;被问「堆为什么不是有序数组」时,答「层序数组无序、只有堆顶最值保证,排序要靠反复取顶」。

例题 2:堆排序。给定整数数组,用大顶堆将其升序排序,额外空间 \(O(1)\)。

建模。两阶段:先在原数组上自底向上建大顶堆(倒数第一个非叶开始 sift-down);再每轮「堆顶与堆区末尾交换、堆区减一、新顶下沉」。不变量:数组尾部 \(a[\text{end}..n-1]\) 是已排好的最大值降序前缀,\(a[0..\text{end}-1]\) 仍是合法大顶堆。

代码。

#include <vector>
#include <utility>

void siftDownMax(std::vector<int>& a, int i, int n) { // 大顶堆下沉
    while (true) {
        int best = i, l = 2 * i + 1, r = 2 * i + 2;
        if (l < n && a[l] > a[best]) best = l;      // 取较大孩子
        if (r < n && a[r] > a[best]) best = r;
        if (best == i) break;
        std::swap(a[i], a[best]);
        i = best;
    }
}

void heapSort(std::vector<int>& a) {
    int n = (int)a.size();
    for (int i = n / 2 - 1; i >= 0; --i)   // 阶段一:建大顶堆,O(n)
        siftDownMax(a, i, n);
    for (int end = n - 1; end > 0; --end) { // 阶段二:反复取堆顶,O(n log n)
        std::swap(a[0], a[end]);            // 当前最大值归位
        siftDownMax(a, 0, end);             // 堆区缩小为 end
    }
}

数值演示。a = [4,10,3,5,1]:建堆阶段对下标 1、0 依次下沉,得到大顶堆 [10,5,3,4,1];第一轮交换 a[0]、a[4] 得 [1,5,3,4,10],堆区 [1,5,3,4] 下沉修复为 [5,4,3,1];如此再三轮,最终 [1,3,4,5,10]。

复杂度。建堆 \(O(n)\)(级数证明见知识点),\(n-1\) 轮取顶各 \(O(\log n)\),总 \(O(n \log n)\) 且最坏也是 \(O(n \log n)\)(与快排的平均/最坏分裂对照)。额外空间 \(O(1)\):只有交换的临时量与下标。不稳定:相等元素会被远距离交换分开。

边界。空数组与单元素(两个循环都不执行,直接返回);全相等元素(比较全不触发交换,仍线性对数但无实际移动);n 为 1 时建堆起点 \(n/2 - 1 = -1\),循环条件自然跳过。追问「降序怎么办」:建小顶堆即可,机制对称。

面试怎么讲。把「建堆 O(n)」与「取顶 n log n」分开报复杂度,并给出级数求和的一句话证明;主动对比:堆排最坏稳、快排平均快、归并稳但要 \(O(n)\) 空间——三者取舍的完整论述在007 章

例题 3:前 K 个高频元素。给定整数数组与 k,返回出现频率最高的 k 个数。

建模。两步:哈希表统计频次 \(O(n)\);再在「(频次, 元素) 对」上求频次前 k 大。用大小为 k 的小顶堆维护冠军组,堆顶是组内频次最低者(门槛);扫描每个不同元素,赢过门槛就替换。

代码。

#include <vector>
#include <queue>
#include <utility>
#include <unordered_map>

std::vector<int> topKFrequent(const std::vector<int>& nums, int k) {
    std::unordered_map<int,int> freq;
    for (int x : nums) ++freq[x];               // 第一步:计数 O(n)

    using P = std::pair<int,int>;               // (频次, 元素)
    auto byFreq = [](const P& a, const P& b) { return a.first > b.first; };
    std::priority_queue<P, std::vector<P>, decltype(byFreq)> pq(byFreq);
                                               // 比较器 greater 语义 = 小顶堆
    for (const auto& kv : freq) {
        pq.push(kv);
        if ((int)pq.size() > k) pq.pop();       // 踢掉门槛
    }

    std::vector<int> ans;
    while (!pq.empty()) { ans.push_back(pq.top().second); pq.pop(); }
    return ans;
}

数值演示。nums = [1,1,1,2,2,3],k = 2:频次表 {1:3, 2:2, 3:1}。堆容量 2:入 (3,1)、(2,2);入 (1,3) 时堆超容,踢掉 (1,3) 自己——即 3 被门槛淘汰。最终输出 [2,1](或 [1,2],题目通常不要求堆内顺序)。

复杂度。计数 \(O(n)\);不同元素数 \(m \le n\),堆操作至多 \(m\) 次每次 \(O(\log k)\),总 \(O(n + m \log k)\subseteq O(n \log k)\)。空间:哈希 \(O(m)\) + 堆 \(O(k)\)。

边界。k 等于不同元素个数(全保留,不踢任何人);k = 1(退化成求众数,可以直接一次扫描);频次并列时题目一般允许任意 k 个(要向面试官确认口径)。若数组是流式到达的,本解法无需任何修改即可在线维护——这是它对「全排序」的决定性优势。

面试怎么讲。先报三方案复杂度表(全排序 / 堆 / QuickSelect),说明为什么选堆(流式、空间 \(O(k)\));「求前 k 大配小顶堆」这句方向口诀必须说出口,方向反了这题就写反了。

例题 4:实现前缀树。支持 insert(word)、search(word)、startsWith(prefix),字符集为小写字母。

建模。节点 = 26 个孩子指针 + 词尾标记。insert 沿路径建节点并在终点标 isEnd;search 要求路径存在终点有标记;startsWith 只要求路径存在。不变量:任意时刻树中每条「根到节点」的路径都对应集合中某词的某个前缀。

代码。

#include <array>
#include <string>

class Trie {
public:
    Trie() : root_(new Node()) {}
    ~Trie() { destroy(root_); }

    void insert(const std::string& word) {
        Node* cur = root_;
        for (char ch : word) {
            int c = ch - 'a';
            if (cur->children[c] == nullptr)
                cur->children[c] = new Node();   // 按需建节点
            cur = cur->children[c];
        }
        cur->isEnd = true;                       // 词尾标记
    }

    bool search(const std::string& word) const {
        const Node* node = walk(word);
        return node != nullptr && node->isEnd;    // 路径通且到词尾
    }

    bool startsWith(const std::string& prefix) const {
        return walk(prefix) != nullptr;           // 只要路径存在
    }

private:
    struct Node {
        std::array<Node*, 26> children{};        // 值初始化为 nullptr
        bool isEnd = false;
    };

    const Node* walk(const std::string& s) const { // 公共路径行走
        const Node* cur = root_;
        for (char ch : s) {
            cur = cur->children[ch - 'a'];
            if (cur == nullptr) return nullptr;   // 路径断开
        }
        return cur;
    }
    static void destroy(Node* n) {
        if (n == nullptr) return;
        for (Node* c : n->children) destroy(c);
        delete n;
    }

    Node* root_;
};

数值演示。依次 insert("quant")、insert("quantile"):前 5 个字符的路径完全共享,"quant" 终点标 isEnd 后,"quantile" 继续向下延伸 3 个节点。此时 search("quant") = true,search("quan") = false,startsWith("quan") = true,startsWith("quantx") = false。

复杂度。三操作均为 \(O(L)\),与词表大小无关;空间最坏 \(O(26 N)\)(\(N\) 为全部字符数,前缀几乎不共享时),前缀共享越多越省。析构递归深度为词长,正常使用无栈风险。

边界。空串(insert("") 只标根的 isEnd,此时 root 既是前缀状态又是词尾);非小写字符(需先约定字符集或换 map<char,Node*> 孩子);内存口径(26 叉数组版 vs map 版的空间/时间取舍,面试要能主动对比)。追问应用:自动补全、拼写检查、按前缀计数、以及「用 Trie 求最大异或对」的思想延伸(路径即决策)。

面试怎么讲。先画 {quant, quantile} 的共享路径图说明「前缀复用」,再指出 search 与 startsWith 的唯一区别是 isEnd 检查,最后给出「\(O(L)\) 与集合大小无关」的卖点与空间代价——答案就完整了。

#误区与边界

「堆是有序的」是错觉

堆只保证堆顶是全局最值(以及父不劣于子的局部序),层序数组本身无序,第二个元素也不是次小值。「第 k 小」不能直接读 a[k-1],要么弹 k 次(\(O(k \log n)\),008 章展开),要么换 QuickSelect。

priority_queue 的三个坑

默认是大顶堆(less 比较),要小顶堆写 greater;没有 decrease-key 接口(Dijkstra 的懒惰更新法就是绕开它,见006 章);遍历底层容器是未定义行为,调试时别这么干。自定义比较器用 lambda 时要 decltype 并把实例传给构造函数。

建堆复杂度的口径

「自底向上建堆 \(O(n)\)」与「逐个插入建堆 \(O(n \log n)\)」都正确但指的是不同过程,答「建堆 \(O(n \log n)\)」而不加限定会被判错。同理堆排要拆开报:建堆 \(O(n)\) + 取顶 \(n-1\) 轮 \(O(n \log n)\)。

高频追问清单:动态数据流的中位数怎么维护(大顶堆装较小一半、小顶堆装较大一半,两堆规模差至多 1,插入后互相倒腾,\(O(\log n)\)——双堆思想的设计题在036 章);合并 k 个有序链表(小顶堆装 k 个表头,每轮取最小接出并补它的后继,\(O(N \log k)\),\(N\) 为总节点数);为什么 Dijkstra 用堆(每次取「当前距离最小未确定点」的堆版实现);Trie 与哈希集合的比较(前缀查询只有 Trie 能做、单点查询哈希更省内存)。

#检查清单

  • 我能写出下标映射 parent/left/right 三公式,并解释完全二叉树为何能零指针躺进数组。
  • 我能手写 sift-up 与 sift-down,说清下沉为什么必须与「较小的孩子」交换。
  • 我能用级数 \(\sum_h h \cdot n/2^{h+1} = O(n)\) 证明自底向上建堆是线性的,并指出它与逐个 push 的区别。
  • 我能在原数组上手写堆排(建堆 + 反取堆顶),报出最坏 \(O(n \log n)\)、空间 \(O(1)\)、不稳定。
  • 我能说清「求前 k 大配大小为 k 的小顶堆」的门槛逻辑与 \(O(n \log k)\) 复杂度,并对比全排序与 QuickSelect。
  • 我知道 std::priority_queue 默认大顶堆、小顶堆要 greater,且没有 decrease-key。
  • 我能手写 Trie 的 insert/search/startsWith,指出 search 与 startsWith 的 isEnd 区别。
  • 我能描述双堆求动态中位数的机制(大顶堆 + 小顶堆平衡),知道它在设计题里的位置。