qinghuan
← 计算机基础 / 全部笔记

单元 60 / 计算机基础

P1 · 重点提升L2 · 进阶

哈希表、树与堆:定位、范围和优先级的不同答案

按编号查、按范围查、反复取最优项是三种不同需求,分别理解哈希表、搜索树和堆的作用。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能为精确定位、范围和最早到期任务分别选结构,并说明复杂度前提。

学习理由、准备要求与知识点

为什么学它

不同结构对精确查找、范围查询和优先级访问提供不同能力与成本。

先读这些内容

知识点清单

  • 哈希映射和冲突
  • 散列分布与负载
  • 稳定键
  • 二叉搜索树退化
  • 平衡树高度
  • 堆的局部顺序
  • PriorityQueue 成本
  • 比较器等价与业务键

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

按编号找商品、找一段价格范围、取出当前最紧急的任务,需要的数据结构可能不同。哈希表按哈希值和相等规则找键,适合检查存在或按键取值;搜索树按大小关系组织数据,适合排序和范围查询;堆只维护父子之间的优先级,适合反复取出最小或最大项。能存同样的数据,不代表适合做同样的操作。

哈希表通常查得快,但需要哈希值分布和容量安排得合理。不同键可能得到相同哈希值,这叫冲突;表还会比较真实键,所以冲突不等于数据被丢掉。键加入后若改了参与哈希的字段,就可能查不回原记录;数据增加也可能引发扩容。Java17 HashMap 对部分冲突有额外处理,仍不能忽略键的相等规则和大量碰撞的成本。

普通二叉搜索树在极端插入顺序下可能退化成长链,平衡树通过维护高度约束控制路径长度。TreeMap 基于有序比较,比较结果为零会被当作同一排序键。PriorityQueue 则通常让最小元素在顶部,遍历顺序不保证全局有序;需要排序输出时应逐次移除或显式排序副本。

文中这几个词,先认一认

负载因子
控制哈希表允许装多满的参数之一,达到相应条件时可能需要扩容。
平衡树
通过调整结构控制高度的搜索树,避免查一个值时退化成从头走到尾。
堆序
例如最大堆要求父节点的值不小于它的子节点,就像每位组长的优先级都不低于直接组员。只满足这种父子关系,不代表按普通遍历次序读一遍所有节点,就能得到完整排序。
Top K
只求按某种规则最好的 K 项,例如最贵的 10 件商品,不必把所有数据完整排序。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

完全二叉堆可以按从上到下、每层从左到右的顺序放进数组。下标从零开始时,非根节点 i 的父下标是 (i-1)/2 向下取整;根节点 i=0 没有父节点。左右子节点的候选下标是 2i+1、2i+2,只有小于元素数量时才存在。新元素放到末尾后,沿父节点向上比较和调整,叫上浮;取走堆顶后,把末项放到根,再向下调整。每次主要检查一条上下路径,而堆的层数只随元素数量按对数增长。

只求最大的 k 项时,维护大小最多 k 的最小堆更合适。堆顶是当前候选中最弱的一项,新值若不超过它就不可能进入当前前 k;更大则替换堆顶。这个方向容易记反:要保留最大若干项,内部用最小堆暴露应被淘汰的最小候选。

堆没有保存候选内部完整顺序,所以扫描结束后还需把最多 k 项排序为最终输出。若只需要第 k 大值且候选足够,堆顶就能直接回答;若需要全部结果按降序展示,则这一步成本不能省略。图把候选维护与最终展示分开,防止把 PriorityQueue.iterator 当成排序遍历。

正在加载流程图…

最小堆用于暴露最大 K 候选中的淘汰对象,最终排序是独立步骤。
查看流程图源码(Mermaid)
flowchart TD
 A["扫描新数值"] --> B{"候选堆未满"}
 B -->|"是"| C["加入最小堆"]
 B -->|"否"| D{"新值大于堆顶"}
 D -->|"是"| E["移除最弱候选并加入新值"]
 D -->|"否"| F["忽略新值"]
 C --> A
 E --> A
 F --> A
 G["扫描完成"] --> H["取候选并显式降序排序"]

按顺序看,每一步做了什么

  1. 明确需要无序查询、有序范围还是最优项。
  2. 核对相等或比较器的键语义。
  3. 为堆写出父子不变量。
  4. Top K 明确重复与平价策略。
  5. 把候选维护和输出排序成本分别计算。

03对照实现代码

新堆元素如何沿父链上浮 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/PriorityQueue.java · java.util.PriorityQueue#siftUpComparable(int,Object,Object[])

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
public final class SourceModel {
    public static void siftUp(int[] heap, int index) {
        int value = heap[index];
        int position = index;
        while (position > 0) {
            int parent = (position - 1) / 2;
            if (heap[parent] <= value) break;
            heap[position] = heap[parent]; // 较大父值向下,为新值腾出位置。
            position = parent;
        }
        heap[position] = value;
    }
}

真实 PriorityQueue.siftUpComparable 把元素按自然顺序与父节点比较,父节点优先级不高于当前元素时停止,否则把父项向下移动并继续。模型专门处理 int 数组,省略泛型、比较器路径、容量增长和公开参数校验;调用前要求只有新加入位置到根的堆序可能被破坏。

先保存新 value,再移动父项,避免在搬运过程中丢失新值。每次 position 变为 parent,路径严格向根缩短,最多经过堆高层数。其它子树没有改变,因此只需要修复这条路径,不必每次插入都重新排序整个容器。

自然顺序路径会把元素转换成可比较类型,另一个路径则使用调用者提供的 Comparator。比较规则必须稳定,不能在入堆后修改影响优先级的字段并期待堆自动调整。对可变任务优先级,通常需要明确移除重插或使用带版本的惰性失效策略。

上浮只建立父子关系,无法推出数组从左到右有序。例如根最小成立,两个不同子树之间仍可能交错。源码结构直接解释了为什么 PriorityQueue 的迭代不能当作完整排序输出,以及为什么 peek 只保证当前顶项而非任意下标的有序位置。

代码先调用谁,再调用谁

  1. offer 追加元素并确保容量
  2. siftUp 选择自然顺序或比较器路径
  3. 沿父链移动较大父项
  4. 把新元素放到恢复堆序的位置

04动手写一个例子

用有界最小堆保留最大的 K 个整数

输入 int 数组与非负 k,返回最大的至多 k 项,保留重复次数并按降序输出。k 大于长度时返回全部排序结果,k 为零或输入空时返回空;不修改输入。

输入 1
[3,1,5,5,2],k=3
输出 1
[5,5,3]
输入 2
[-3,-1,-2],k=2
输出 2
[-1,-2]
输入 3
任意数组,k=0
输出 3
[]

把实际候选上限设为 k 与输入长度的较小值,零上限直接返回空。堆未满时加入元素,堆满后只接受比堆顶更大的值。相等值若堆已满可以忽略,因为它不会改变输出多重集合的最佳数值;此前加入的重复项仍被保留。

处理一个前缀后,堆始终包含该前缀最大的 limit 项,或前缀本身不足 limit 的全部项。新值若不大于当前最小候选,就没有理由替换任何候选;若更大,替换最小项正好恢复最佳集合。这个交换论证比只看样本更能说明选择正确。

扫描完成后复制堆中元素并降序排序,明确最终输出顺序。不要直接迭代堆打印并期待降序。时间由 n 次候选判断、可能的对数级调整和最后 k 项排序组成;当 k 接近 n 时,完整排序可能更简单,具体选择取决于后续是否还需要持续输入。

为什么这样写是对的处理任意输入前缀后,堆保存该前缀中最大的 min(limit,前缀长度) 个值,堆顶是候选中最小者。

时间 O(n log(k+1)+k log(k+1)) 上界;实际 k 取 min(k,n)。额外空间 O(k) 候选与结果。

每个元素最多导致一次插入或替换,堆高度对数;最后对 k 项排序。使用 k+1 书写覆盖 k 为零或一的边界,零上限可直接结束。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 38 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;
public class Main {
    static List<Integer> largest(int[] values, int k) {
        if (values == null || k < 0) throw new IllegalArgumentException("input");
        int limit = Math.min(k,values.length);
        if (limit == 0) return List.of();
        PriorityQueue<Integer> candidates = new PriorityQueue<>();
        for (int value : values) {
            if (candidates.size() < limit) candidates.offer(value);
            else if (value > candidates.peek()) {
                // 堆顶是当前候选中最弱者,只在更优值到来时淘汰。
                candidates.poll(); candidates.offer(value);
            }
        }
        List<Integer> output = new ArrayList<>(candidates);
        output.sort(Comparator.reverseOrder()); // 堆迭代本身不是排序输出。
        return List.copyOf(output);
    }
    static void check(List<Integer> wanted, int k, int... input) {
        if (!wanted.equals(largest(input,k))) throw new AssertionError(wanted);
    }
    public static void main(String[] args) {
        // 重复、负数、零上限、空、超长 k 和整数极值。
        check(List.of(5,5,3),3,3,1,5,5,2);
        check(List.of(-1,-2),2,-3,-1,-2);
        check(List.of(),0,1,2);
        check(List.of(),3);
        check(List.of(3,2,1),9,1,3,2);
        check(List.of(Integer.MAX_VALUE),1,Integer.MIN_VALUE,Integer.MAX_VALUE);
        boolean rejected = false;
        try { largest(new int[0],-1); } catch (IllegalArgumentException e) { rejected = true; }
        if (!rejected) throw new AssertionError("k");
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 重复值按出现次数保留。
  • 负数不使用零作为默认最佳值。
  • k 超出长度返回全部项。
  • 不修改原数组,结果结构不可修改。
进阶选读 · 可选深入:多路有序流归并

场景若干来源各自有序,需要持续输出全局最小项。

做法堆中只放每个来源的当前头项,取出后从同一来源补下一项,并用来源序号定义平价顺序。

复杂度处理 N 项、k 个来源通常 O(N log k),额外 O(k) 候选。

好处与代价来源阻塞和失败需要独立策略,堆只决定已有候选顺序,不解决网络等待与完整性。

05项目里怎么用

只按 ID 找数据,通常不必维护排序树;要查范围、前一项或后一项,则需要有序结构。哈希键在使用期间应保持稳定,树的比较规则也要符合业务对“相同”的定义。换容器可能同时改变遍历顺序和去重结果,不能只核对它们都有同名方法。

优先队列适合调度最早到期或最高优先级候选,但普通 PriorityQueue 不是线程安全队列,也不自动支持优先级动态修改。把任务对象的优先级字段改掉不会重新建立堆序;可以移除重插,或在出队时识别过期版本,具体成本与一致性应明确。

Top K 在日志、榜单和推荐候选中常见,必须定义重复、平价和结果时效。按分数相同就任意丢弃可能不符合用户排名要求,稳定 ID 或时间可以补全排序。输入规模、k 大小和是否持续流入决定堆、排序或选择算法的适用性,不能宣称一种方案永远最快。

容易踩的坑

  • 遍历 PriorityQueue 时期待全局有序。
  • 把普通二叉搜索树自动当作平衡树。
  • 修改堆内对象优先级却不重新调整。
  • Top K 忘记重复和同分的业务规则。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
哈希表常见键查询期望高效无天然范围顺序且依赖哈希质量映射、去重和成员判断
平衡搜索树有序与范围操作清楚每次操作有比较和节点成本范围查询、前驱后继
二叉堆反复取最优项且维护成本低不支持任意位置有序遍历调度、Top K 和多路归并

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01堆序为什么不等于排序?

展开参考答案与推导

它只约束父节点与孩子,两个子树之间没有完整先后关系。根可以保证最优,但数组或迭代顺序不保证有序,需要不断移除或另行排序。

02保留最大 K 为什么用最小堆?

展开参考答案与推导

候选中最小项是最容易被淘汰的边界,放在堆顶可快速判断新值是否值得进入。最大堆暴露的是最强候选,反而不便决定该淘汰谁。

03普通搜索树为什么可能退化?

展开参考答案与推导

若连续按某种顺序插入而不维护平衡,节点可能都落在同一侧,高度接近元素数。平衡树通过旋转或其它结构约束限制高度,不能把普通树的查找无条件写成对数。

换个情况,还会处理吗

01任务优先级改了但出队顺序没变,为什么?

展开参考答案与推导

堆不会自动追踪对象字段变化。需要在受控条件下移除重插或采用版本化候选策略,同时处理并发与重复条目;仅改变字段并未恢复父子堆序。

02同分用户排名每次变化,先补什么规则?

展开参考答案与推导

明确稳定平价键,例如 ID 或创建时间,并在所有比较、Top K 和输出阶段一致使用。只比较分数没有唯一顺序,不能把未定义行为留给容器遍历或线程调度决定。

03k 与 n 一样大,堆是否仍最好?

展开参考答案与推导

不一定。此时维护堆后还要排序所有候选,完整排序可能更简单且常数更合适。结合数据是否流式、内存和实际规模比较,不能只根据 Top K 名字固定选择。

写代码试一试

01求第 K 大而不返回全部

输入 / 条件
[3,1,5,5,2],k=3
输出 / 预期
3

提示 候选满时堆顶是边界。

展开参考答案与推导

要求一到数组长度的合法 k,扫描后返回最小堆顶即可,不需要最终排序。空输入或 k 超界应拒绝或返回明确缺失,不能沿用“至多 k 项”接口的全部边界。

02验证全相等输入

输入 / 条件
[7,7,7,7],k=2
输出 / 预期
[7,7]

提示 重复次数应保留。

展开参考答案与推导

加入结果两项七的测试。若使用 Set 维护候选会丢失重复项,说明抽象语义不匹配;算法筛选的是元素多重集合而不是不同值集合。

03返回带ID的候选

输入 / 条件
同分A与B,只留一项
输出 / 预期
按明确ID规则选择

提示 比较器必须形成完整顺序。

展开参考答案与推导

把值改成记录,比较分数后再比较 ID,并让堆顶代表完整规则下最弱项。最终输出用相反或对应的展示顺序,避免候选淘汰规则与展示规则不一致。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能为精确定位、范围和最早到期任务分别选结构,并说明复杂度前提。

本篇重点加练

重点训练稳定键与比较器等价,让不同索引对同一业务对象采用一致身份政策。

  • 稳定键
  • 比较器等价与业务键

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为学生编号查询设计哈希映射,比较均匀键、集中碰撞键和可变键三组输入。说明每组需要验证的正确性与成本,不用“平均 O(1)”覆盖全部情况。

    提交内容
    键合同与三组实验计划。
    怎样算完成
    • 哈希分布前提明确
    • 键稳定性单独检查

    练到的知识点哈希映射和冲突 · 散列分布与负载 · 稳定键

  2. 作业 02代码推演

    把 [1,2,3,4,5] 和 [3,1,5,2,4] 分别插入普通二叉搜索树,画出形状并计算查找路径;说明平衡约束需要控制哪项结构指标。

    提交内容
    两棵树与路径长度表。
    怎样算完成
    • 有序性质和高度分开
    • 操作成本与结构对应

    练到的知识点二叉搜索树退化 · 平衡树高度

综合应用

  1. 作业 03排错实验

    向 PriorityQueue 插入 [7,2,9,2,5],分别记录迭代次序、peek 结果和连续 poll 的结果;对每种操作说明接口实际承诺。

    提交内容
    Java 实验、自写重复值与空队列测试、运行证据。
    怎样算完成
    • 遍历与逐次出队语义分开
    • 成本区分单次与全部操作

    练到的知识点堆的局部顺序 · PriorityQueue 成本

  2. 作业 04设计评审

    排行榜以分数比较学生,存在两个同分不同 ID。分别为 TreeSet、HashMap 和 PriorityQueue 定义如何识别学生、处理同分及更新记录。

    提交内容
    三种结构的键/比较器设计及验收样本。
    怎样算完成
    • 比较器零值语义明确
    • 结构选择服务具体操作

    练到的知识点比较器等价与业务键

  3. 作业 05编程实践

    实现最多保留 3 个最高分候选的排行榜,候选含不可变 ID 与分数,平分按先约定的 ID 顺序;输入含重复 ID,需先决定更新政策。

    提交内容
    Java 实现、自写平分/重复/少量输入测试及运行证据。
    怎样算完成
    • 业务更新与堆维护一致
    • 结果顺序有合同
    • 时间空间按输入和容量说明

    练到的知识点稳定键 · 堆的局部顺序 · PriorityQueue 成本 · 比较器等价与业务键

  4. 作业 06编程实践

    为收藏文章实现按编号查询与按标题排序两个视图,标题允许相同且可更新;规定文章身份后写测试核对两视图是否指向同一业务集合。

    提交内容
    Java 实现、自写同名/标题更新/删除测试与运行证据。
    怎样算完成
    • 业务键保持声明的稳定性
    • 视图一致性有独立核验

    练到的知识点稳定键 · 比较器等价与业务键

  5. 作业 07设计评审

    用户编号未来可能合并,旧编号仍需查询历史数据。设计稳定键与别名关系,说明 Map 键是否应直接随显示编号变化。

    提交内容
    身份模型和编号合并验收。
    怎样算完成
    • 历史引用的含义明确
    • 变更操作有一致性边界

    练到的知识点稳定键

  6. 作业 08代码推演

    比较器仅使用名字时与同时使用名字和编号时,分别插入同名不同编号对象;预先声明集合的业务去重合同,再观察实际行为。

    提交内容
    比较器、样本和契约对照记录。
    怎样算完成
    • 排序等价与业务唯一性逐项比较
    • 没有用正常排序样本替代碰撞样本

    练到的知识点比较器等价与业务键

进阶挑战 · 掌握前置后选做

  1. 作业 09面试场景

    完成性能分析后:外部输入可制造大量重复或碰撞,系统需支持精确查询、范围查询与实时优先级。提出组合数据结构的评审方案,说明维护一致性的代价。

    提交内容
    操作需求矩阵、资源预算与失效场景。
    怎样算完成
    • 每个结构承担明确操作
    • 多索引更新原子边界清楚

    练到的知识点散列分布与负载 · 二叉搜索树退化 · 堆的局部顺序 · PriorityQueue 成本

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    把内存有序集合替换成数据库唯一索引加排序查询。审查大小写、空值和平价规则是否仍表达相同身份,尚未学数据库可用表格模拟。

    提交内容
    两种模型对应与冲突样本。
    怎样算完成
    • 跨表示的比较政策明确
    • 相同标签不被视为自动相同语义

    练到的知识点稳定键 · 比较器等价与业务键

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗