← 返回 Java 后端知识路线
阶段 02集合与并发

Map、泛型与算法:从键值查找走到可解释的排序

用文章统计和排行榜示例,串起 Map 的索引、泛型边界、Comparator 和常见算法复杂度。

第 10 / 35 篇
Map泛型Comparator算法

先看这一课值不值得学

学完后,你手里多了哪些代码积木

用键快速定位值,并把排序规则写成可替换策略。

本课正式新增

语法 / API / 命令你必须会到什么程度
Map<K,V>按键保存和查找值
getOrDefault / computeIfAbsent安全读取或初始化映射项
Comparator<T>声明独立排序规则
? extends / ? super描述泛型读取和写入边界

本课只借用,先别硬背

  • 复杂度 O(1)/O(n log n) 用于比较选择,不要求推导数学证明

学完必须能独立写

  • 统计词频并生成排行榜
  • 为同一批对象切换多种排序方式
本课目录
  1. 1. 现实问题:为什么循环查找会让页面越来越慢
  2. 2. 最小可运行示例:建立索引,再排序展示
  3. 3. 调用链与对象变化
  4. 4. 为什么这样设计
  5. 5. 项目落点:批量查询和分页排序
  6. 6. 易错排查
  7. 7. 一页复习

1. 现实问题:为什么循环查找会让页面越来越慢

一次查询得到文章列表,另一个查询得到作者列表。若每篇文章都在作者 List 中从头找 id,1000 篇文章就可能做 100 万次比较。更清楚的做法是先把作者按 id 建成 Map,再按键定位。Map 把“查找关系”显式化,泛型则保证键和值的类型一致。

排行榜还需要按分数降序、分数相同按发布时间升序。排序规则如果散落在多个匿名比较器里,稍微改变业务就会出现顺序不一致,所以要把比较策略命名并测试。

2. 最小可运行示例:建立索引,再排序展示

import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class RankingDemo {
    record Author(long id, String name) {}
    record Article(long id, long authorId, int likes, String title) {}

    public static void main(String[] args) {
        Map<Long, Author> authors = new HashMap<>();
        authors.put(1L, new Author(1L, "林"));
        authors.put(2L, new Author(2L, "舟"));
        List<Article> articles = List.of(
                new Article(10, 2, 8, "Map 索引"),
                new Article(11, 1, 8, "集合算法"),
                new Article(12, 1, 12, "SQL 边界"));

        List<Article> sorted = articles.stream()
                .sorted(Comparator.comparingInt(Article::likes).reversed()
                        .thenComparingLong(Article::id))
                .toList();
        for (Article article : sorted) {
            System.out.println(authors.get(article.authorId()).name() + ":" + article.title());
        }
    }
}

Map<Long, Author> 让 id 关系在编译期明确;get 可能返回 null,真实代码要决定作者缺失是错误还是匿名作者。sorted 返回新流结果,原 articles 不变。toList 返回不可变结果,若后面需要增删就显式复制。

3. 调用链与对象变化

构造 HashMap 后,put 将 Long key 的哈希映射到内部桶,值保存 Author 引用。循环通过 authorId 调用 get,平均不需要扫描所有作者。Stream 从 articles 创建惰性管道,sorted 在终端操作 toList 触发时收集元素、执行比较并生成新 List。

Comparator 先比较 likes 的反向结果,若相等再比较 id;比较器组合本身是一个对象,排序算法反复调用它。泛型方法可以让同一算法服务多种类型,但类型边界要表达真实能力,例如 T extends Comparable<? super T>,不要为了“通用”塞进 Object 后再强转。

4. 为什么这样设计

Map 索引把时间换空间:建立索引是 O(n),后续平均查找接近 O(1),总体从 O(n*m) 降到 O(n+m)。数据量很小或一次性查询时,直接循环更简单,优化前要用真实样本和指标证明问题。算法复杂度是决策依据,不是炫技标签。

排序必须定义稳定的全序,避免相等元素在不同运行路径中顺序漂移。Comparator 关注比较,不应在比较过程中访问数据库或修改对象,否则排序会变慢且难以复现。泛型的价值是把“不允许的组合”提前变成编译错误,让 API 读起来像一份数据契约。

5. 项目落点:批量查询和分页排序

Service 获取文章后,先提取作者 id 做批量查询,再用 Map<Long, Author> 组装 DTO,避免 N+1 查询。排序规则应位于查询层能承担的地方:数据库能稳定排序就用 SQL,内存排序要注明数据范围和分页语义,不能在全量分页之后才随意重排。

练习:实现 indexById(List<T>, Function<T, Long>),遇到重复 id 选择抛异常还是保留最后一个;再写 partition 把列表按固定大小拆分,用于批量数据库查询。为空列表、重复键和越界 batch size 设计测试。

6. 易错排查

  • Map.get 空指针:键不存在是正常可能,使用 getOrDefaultcomputeIfAbsent 前先确认语义。
  • 排序反转错误:只反转第一个字段还是整条比较链要明确,用样例验证同分情况。
  • Stream 已经消费:流只能终端操作一次,需要重新创建或保存结果集合。
  • 使用 raw Map:丢失键值类型,后续强转才爆错;从方法签名就写清泛型。

7. 一页复习

批量关系先建索引,排序规则先写成可命名的 Comparator;复杂度决定是否值得引入额外 Map。泛型表达键、值、元素和返回值的约束,Stream 只在能让数据流更清楚时使用。任何 Map 缺失键、重复键、排序相等和分页边界都要有测试。

把批量组装过程写成输入输出:输入是 3 篇 Article 和 2 个 Author,先构建 Map<Long, Author>,再按每篇的 authorId 取引用,最后得到包含作者名的 ArticleView。作者缺失时是跳过、匿名还是报错,必须在 Service 契约中固定;不能让 authors.get(id).name() 的空指针替你做业务决定。

比较器的每一层也应有稳定依据:第一键 likes 越大越靠前,第二键 id 越小越靠前,不能在比较器里读取当前时间或访问数据库。若第一页按 likes 排序、第二页查询后又在内存按 title 排序,用户会看到跨页重复和遗漏。分页排序应尽可能下推 SQL,内存排序要限制在一个明确的完整数据集内。

项目文件可以把 AuthorIndex 放在 application assembler,把通用 indexById 放在 collection utility,把排名规则放在 ArticleRanking,而不是塞进 Controller。泛型方法的类型变量应由编译器推断,返回 Map<K,V>;若重复键必须失败,方法应抛带 key 的异常,而不是静默覆盖导致数据丢失。

排错时先打印输入数量、索引键数量和缺失键,再查排序前后的 id 序列。Stream 没有执行通常是少了终端操作,排序结果为空可能是 filter 条件先过滤了全部数据,性能变慢则需要比较扫描次数而不是只看代码长度。练习是实现分批加载作者的函数,限制每批 100 个 id,并测试重复键、缺失作者、同分文章和空输入。

Map 的状态变化可以具体追踪:输入作者行先让 index 为空,put(1,林) 后键数为 1,put(2,舟) 后为 2;组装文章 10 时通过 authorId=2 得到舟的引用,文章 13 使用缺失 id 时产生一个业务分支。排序之前文章序列是查询顺序,排序之后按 likes/id 形成稳定序列,原 List 是否改变取决于你调用的是 stream 还是直接 sort。

泛型边界落到项目 API 后,findById 返回 Optional<T>indexById 接收 Function<T,K>,分页函数接收 List<T> 并返回 List<List<T>>。每个类型参数都描述一种关系,不能用 Object 省事。Comparator 如果需要字段为空的规则,要先定义 null 在前还是在后,再组合比较器并写测试。

排查效率问题要采样输入大小、数据库查询次数、Map 键数量和排序耗时;Map 变快但内存暴涨,说明用空间换时间的代价没有纳入;排序正确但分页乱,说明排序发生在错误的层;重复键静默覆盖,说明数据质量错误被吞掉。练习是把一个 N+1 组装改成批量索引,并用日志证明查询次数从 N+1 变成 2。

批量索引练习的最终证据是查询次数和排列后的 id 序列,而不只是页面看起来正确。把复杂度、内存代价和重复键策略写进方法说明,后续才知道何时该换算法或回到 SQL。

验证清单:准备文章 id 为 10、11、12、13 的输入和作者 1、2 的输入,先记录原始查询顺序,再记录索引键、缺失键、组装结果和最终排序序列;作者缺失时必须看到明确分支。给同一个 id 两行作者数据,确认方法按契约失败而不是静默覆盖;给空列表和超过一批大小的列表,确认分页边界与查询次数。复盘 stream() 链时把 filter、map、sorted、toList 的每个输入输出写出来,并检查终端操作是否真的触发了数据库或日志副作用。

练习复盘:把作者批量加载的输入固定为文章 id 列表和作者行列表,输出固定为查询次数、缺失作者列表、组装后的 articleId 序列和排序后的 view 序列。分别测试重复键、空输入、作者缺失和同分排序,确认每种结果都有业务策略;如果只检查页面顺序而没有检查 Map 键数与 SQL 次数,算法优化的证据仍不完整。

进阶附录:并行流的边界

parallelStream 不会自动让数据库查询或 IO 变快,还会共享公共 ForkJoinPool,可能影响其他任务。只有计算独立、数据规模足够、线程开销有收益且结果顺序已定义时,才考虑并行;先用基准测试而不是凭直觉切换。

本课按「Java 21 Map、泛型方法与集合算法」的学习范围组织,正文与示例均为本站原创整理。