复杂度 · 数据量估算 · 二分查找 · Top K · 小顶堆 · 滑动窗口 · TimSort · 双轴快排 · 稳定性 · 比较器约定 · 红黑树 · 旋转 · 变色 · 题型地图

数据结构与算法:先估量级,再按“怎么查”提前摆好数据

业务里的算法题,几乎都是同一个套路:先用数据量估一下 O(n²) 会不会爆,再把数据提前组织成“查起来便宜”的样子。按 key 找就放进哈希表,要最大的几个就维护一个小堆,要按范围查就先排好序再二分,要“最近一分钟”就用一个两头进出的队列当窗口。Java 的集合和 Arrays 已经把这些结构写好了,日常要做的是认出“这是哪一类问题”,挑对工具,再别踩比较器和边界的坑。红黑树是这些工具里最复杂的一个,本页手推一遍它的插入。

全景:一个订单对象的一生 · 第 4、9 站地图:J01 · 数据结构与算法(最后一个知识点)前面几页:集合怎么选 · HashMap · HashMap 的亲戚们实验:JDK 17.0.20 实测

①要解决的问题:交易靶场里冒出来的四道题

在全景第 9 站,这一步要解决的是:几十万个待付款订单里,每次只要“最早到期的那一个”。DelayQueue 底下的二叉堆就是为这类问题生的。同一个靶场里,类似的题还有好几道:运营要每天的热销 Top 10;客服要按时间段查订单;风控要同一个用户一分钟内最多下 5 单;财务的对账单要按金额排序,同金额的还得保持下单顺序。第 4 站的 HashMap 是第五道:按 key 一步找到。

靶场里的需求其实是哪类题用什么在哪讲
第 4 站:按商品号找商品按 key 查找哈希表 HashMapHashMap 页
第 9 站:最早到期的先取消反复取最小二叉堆 PriorityQueue集合怎么选④-5
热销 Top 10Top K大小为 K 的小顶堆本页④-3
查 12:05~12:15 的订单有序数据的范围查找排序 + 二分,或 TreeMap(红黑树)本页④-2、⑤
一分钟内最多 5 单滑动窗口两头进出的队列 ArrayDeque本页④-4
对账单按金额排,同额保持时间序稳定排序List.sort(TimSort)本页④-5
需求J01 地图上这一项的深度是“会用”:日常能认出题型、挑对 JDK 现成的工具;面试能写出二分、Top K、滑动窗口这几道高频题,讲得清红黑树为什么平衡。动态规划、图论这些只给出路和刷题清单(⑥)。

②朴素做法:每次都把数据从头看一遍

实测 JDK 17,100 万单。耗时取 4 次中最快的一次。条形按对数刻度。

Top 10:Stream 全排序468.5 ms
Top 10:int[] 全排序77.6 ms
Top 10:10 个元素的小顶堆3.3 ms
时间段查询:从头扫比较 100 万次
时间段查询:两次二分比较 40 次

不懂精华时的典型误用:觉得“数据量才 1 万,随便写”,在循环里套循环(O(n²),1 万的平方是 1 亿次),一个接口 2 秒;数据长到 10 万,就是 200 秒(④-1 估算)。

③几种思路:把数据摆成什么样

哈希表HashMap / HashSet按 key 找 O(1)无序不能范围查第 4 站 二叉堆PriorityQueue看最小 O(1)进出 O(log n)只有堆顶有序第 9 站、Top K 有序数组排好序的 long[] / List二分 O(log n)插入 O(n)最省内存数据不怎么变时 平衡树TreeMap / TreeSet查、插、删 O(log n)一直有序能范围查数据边变边查时 双端队列ArrayDeque两头进出 O(1)按到达顺序做滑动窗口限流
选择规则:先问“最频繁的操作是什么”,再挑让这个操作最便宜的结构。数据几乎不变、只查:有序数组加二分;边改边查还要范围:平衡树;只要最值:堆;只按 key:哈希表。

本页的重点:④讲五个关键洞察(量级估算、二分、Top K、滑动窗口、Java 的排序),⑤手推红黑树插入并用反射核对 TreeMap,⑥讲边界坑和面试题型地图。

④关键洞察

先知道这些
  • 大 O:见集合怎么选“先知道这些”。log₂(100 万) ≈ 20。
  • 堆和平衡树长什么样:堆见集合怎么选④-5;二叉搜索树就是“左边都比我小,右边都比我大”。

④-1先估量级:一台机器一秒大约做 1 亿~10 亿次简单操作

数据量 nO(log n)O(n)O(n log n)O(n²)
1 千101 千1 万100 万
1 万141 万14 万1 亿
100 万20100 万2000 万1 万亿
1 亿271 亿27 亿不可能

按“一秒 1 亿次”估:接口里 O(n²) 碰上 1 万条就是 1 秒;100 万条的 O(n log n) 排序是几十毫秒(实测 int[] 排序 77.6 ms)。这个表是用来做决定的:写代码前先问数据量会长到多大。

④-2二分:有序 + 每次砍掉一半,不变式是关键

④-3Top K:只盯住“当前第 K 大”的门槛

④-4滑动窗口:只保留窗口里的数据,从一头进、另一头出

④-5Java 的排序:对象用 TimSort(稳定),基本类型用双轴快排

TimSortArrays.sort(Object[])、List.sort、Collections.sort

  • 稳定:相等的元素保持原来的先后。实测 先按时间排、再按金额排,同金额的订单保持时间顺序
  • 利用已有顺序:找出数据里本来就有序的片段(run),再合并;对“几乎有序”的数据接近 O(n)
  • 最坏 O(n log n),要额外 O(n) 的空间
  • 依赖比较器遵守约定,否则可能抛 “Comparison method violates its general contract!”(实测,⑥ 坑 1)

双轴快排Arrays.sort(int[]、long[]、double[]…)

  • 原地排序,不要额外空间,常数小,实际最快
  • 两个基准把数组切成三段;小段改用插入排序(JDK 17 源码里还混合了堆排序兜底,防止退化)
  • 不稳定:但对基本类型无所谓,两个 5 分不出谁是谁
为什么分两种:对象排序常常是“多轮排序”(先按时间、再按金额),只有稳定才能保住前一轮的顺序,而对象本身有身份,顺序乱了看得出来;基本类型没有身份,稳定没意义,就选空间和常数都最省的快排。这一条决定了你在 Java 里不必自己实现排序,只需要写对比较器。

⑤手推一遍:红黑树的插入,以及靶场里的四道题

TreeMap 能在边改边查的情况下一直保持 O(log n),靠的是红黑树:插入后如果破坏了规则,就靠变色和旋转修回来,而且最多旋转两次。下面依次插入 10、20、30、15、25、5、1,每一步都和 JDK 17 TreeMap 用反射读出来的结构一致。

红黑树的规则(不变式)
  • 每个节点非红即黑,根是黑的,空节点算黑的。
  • 红节点的孩子必须是黑的(不能连着两个红)。
  • 从任一节点往下到每个空节点,经过的黑节点数一样多。
  • 后两条合起来保证:最长路径(红黑相间)不超过最短路径(全黑)的 2 倍,所以树高 ≤ 2·log₂(n+1),查找最坏也是 O(log n)。

实测 每次插入后,用反射读 TreeMap 的 root 和每个 Entry 的 left / right / color,形状写成“(根 左 右)”,R 红 B 黑:放完 7 个后是 (20B (10R (5B 1R ·) 15B) (30B 25R ·))。演示每插完一个数,下面也会打出同样格式的形状,前 7 个和实测逐一对得上。修复逻辑照 JDK 17 TreeMap.fixAfterInsertion 写,在 node 里跑过。

1Demo.java 节选,JDK 17
// Top 10:大小为 10 的小顶堆
PriorityQueue<Integer> heap = new PriorityQueue<>(11);
for (int a : amount) {
  if (heap.size() < 10) heap.offer(a);
  else if (a > heap.peek()) { heap.poll(); heap.offer(a); }
}

// 第一个 >= key 的下标
static int lowerBound(long[] a, long key) {
  int lo = 0, hi = a.length;              // [lo, hi)
  while (lo < hi) {
    int mid = (lo + hi) >>> 1;            // 不会溢出
    if (a[mid] < key) lo = mid + 1; else hi = mid;
  }
  return lo;
}

// 滑动窗口限流
Deque<Long> q = recent.computeIfAbsent(user, k -> new ArrayDeque<>());
while (!q.isEmpty() && q.peekFirst() <= now - 60_000) q.pollFirst();
if (q.size() >= 5) return false;
q.addLast(now); return true;

// 对账:两轮稳定排序
orders.sort(comparingLong(Order::ts));
orders.sort(comparingInt(Order::amountCents));
2运行 JDK 17.0.20,2 核 ARM
== 1. 100 万单里找最大的 10 单 ==
  全部排序再取前 10            77.6 ms
  大小为 10 的小顶堆            3.3 ms
  Stream sorted().limit(10)   468.5 ms
== 2. 按时间查 [t1, t2) ==
  二分:比较 40 次,命中 1621 单
  从头扫:比较 1000000 次,命中 1621 单
== 3. 60 秒内最多 5 单 ==
  0s 放行  5s 放行  10s 放行  20s 放行  30s 放行
  40s 拒绝  55s 拒绝  61s 放行  66s 放行  125s 放行
== 4. 对账排序 ==
  [0元#9527, 0元#9530, 0元#9533, 100元#9528,
   100元#9531, 100元#9534, 200元#9529, 200元#9532]
  比较器相等时返回 -1:第 3 次随机数据报
  Comparison method violates its general contract!
== 5. TreeMap 里的红黑树 ==
  放 10 后:10B
  放 20 后:(10B · 20R)
  放 30 后:(20B 10R 30R)
  放 15 后:(20B (10B · 15R) 30B)
  放 25 后:(20B (10B · 15R) (30B 25R ·))
  放  5 后:(20B (10B 5R 15R) (30B 25R ·))
  放  1 后:(20B (10R (5B 1R ·) 15B) (30B 25R ·))

第 5 段要加 --add-opens java.base/java.util=ALL-UNNAMED 才能反射读 TreeMap 内部。完整代码:study-labs/java/javase/algorithms-everyday →

到这里已经能交差了:靶场的热销榜、时间段查询、下单限流、对账排序都有了能直接抄的写法,你也亲手推过一遍红黑树。
已知还没处理的细节:比较器写错(⑥-1)、二分边界(⑥-1)、递归太深(⑥-1)、面试题型和刷题路线(⑥-3)。

⑥边界和变体

第一圈

注意事项:不知道就会出事故

坑 1比较器违反约定:TimSort 直接抛异常← ④-5 TimSort 依赖约定
现象
线上偶发 IllegalArgumentException: Comparison method violates its general contract!,数据少时从不出现。⑤ 实测第 3 次随机数据
根源
比较器必须自反、对称、传递,相等时返回 0。写成 a > b ? 1 : -1,两个相等的数 compare(a,b) 和 compare(b,a) 都是 −1,TimSort 合并时发现前后矛盾就抛异常(JDK 7 起)。
做法
用 Comparator.comparing…、Integer.compare;多字段用 thenComparing;有 null 用 nullsFirst。
坑 2比较器里用减法:溢出后排反← ④-5 比较器只看符号
现象
(a, b) -> a.amount - b.amount,金额差超过 21 亿分(约 2147 万元)时溢出成负数,大额订单排到了前面。
根源
int 相减可能越界,符号反过来。
做法
Integer.compare(a, b),或 Comparator.comparingInt。
坑 3二分的三个边界← ④-2 不变式
现象
死循环、漏掉最后一个元素、数组很大时下标变成负数抛 ArrayIndexOutOfBounds。
根源
开闭区间没想清楚,lo = mid 在只剩两个元素时不再前进;(lo + hi) / 2 在 lo + hi 超过 21 亿时溢出(JDK 自己的 binarySearch 在 2006 年修过这个 bug)。
做法
固定一种写法(⑤ 的半开区间 lower bound),mid = (lo + hi) >>> 1;能用 Arrays.binarySearch、TreeMap 的 ceilingKey / subMap 就别手写。
坑 4对没排序的数据二分← ④-2 前提是有序
现象
Collections.binarySearch 返回一个负数,可元素明明在列表里。
根源
二分的前提是有序,而且排序用的比较规则要和查找时一致。不报错,只是结果不可信。
做法
数据会变就用 TreeMap / TreeSet;一定要二分,先排序,用同一个 Comparator 查。
坑 5Stream 的 sorted().limit(k) 不是 Top K← ④-3 只盯门槛
现象
代码看起来最优雅,100 万单却要 468.5 ms,比堆慢 140 倍。⑤ 实测
根源
sorted 是有状态操作,要先收齐全部元素整体排序(见 Stream 那页④-3),再加上 100 万次装箱。
做法
数据大时用小顶堆;数据在数据库里就交给 ORDER BY … LIMIT 10 加索引(J06)。
坑 6递归太深:StackOverflowError线程栈有限
现象
递归遍历商品类目树、递归 DFS,某个数据层级特别深(或成了环)时栈溢出。
根源
每层递归占一个栈帧,线程栈默认大小有限(常见 512 KB~1 MB,取决于平台和 -Xss),几千到几万层就满了。
做法
改成显式栈或队列的迭代写法;树形数据加 visited 防环;深度不可控时别用递归。
第二圈

使用规范

先估量级,再写代码

写之前问一句:这个集合会长到多大?循环里有没有隐藏的 O(n)(contains、indexOf、remove(0))?

数据量一涨接口就超时。

比较器只用 JDK 的组合方法

comparing、thenComparing、reversed、nullsLast,不手写 if-else 返回 1 / −1。

坑 1、坑 2。

能用 JDK 现成的就别手写

排序、二分、堆、平衡树、双端队列都现成,手写只在面试和 JDK 没有的结构时。

边界 bug。

数据在库里,就让库去算

Top K、范围查询、分组计数,交给 SQL 加索引,比捞到 Java 里算省得多。

把 100 万行搬进内存。

第三圈

深处:面试和下一步

红黑树为什么最多转两次

插入后的修复只有三种情形:叔叔是红的,就把父亲和叔叔染黑、祖父染红,问题上移两层(只变色,不旋转,可能一路到根);叔叔是黑的、且自己和父亲在同一侧,绕祖父转一次再变色,结束;在不同侧,先绕父亲转一次变成同侧,再按同侧处理,结束。所以旋转最多两次,变色最多 O(log n) 次。

为什么 HashMap 的树化、TreeMap、Linux 内核的调度器都选红黑树而不是 AVL 树:AVL 更严格平衡、查得略快,但插入删除要转更多次;红黑树写多读多都均衡。数据库索引则选 B+ 树:一个节点放几百个 key,树只有三四层,适合磁盘按页读(J06)。

面试题型地图(只列出路)

哈希两数之和、字母异位词分组、最长连续序列
双指针三数之和、盛最多水的容器、合并两个有序数组
滑动窗口无重复字符的最长子串、滑动窗口最大值(单调队列)
二分搜索插入位置、旋转数组找最小值、在排序数组中找首尾位置
堆前 K 个高频元素、合并 K 个有序链表、数据流的中位数
链表反转链表、环形链表、LRU 缓存(见 HashMap 的亲戚们)
树、BFS / DFS二叉树层序遍历、最大深度、岛屿数量、课程表(拓扑排序)
递归回溯全排列、子集、组合总和
动态规划爬楼梯、打家劫舍、最长递增子序列、零钱兑换、编辑距离

每类先做第一题吃透模板,再做后面的。都是 LeetCode 上的经典题名,搜名字就能找到。

堆的另一个用法:多路归并

“合并 K 个有序链表”“把 8 张分表的订单按时间合成一个流”(J11 分库分表后的跨表查询):把每一路的第一个元素放进小顶堆,每次弹出最小的,再把它那一路的下一个补进来。K 路总共 n 个元素,O(n log K)。数据库的外部排序、Kafka 消费多个分区按时间合并,都是这个形状。

版本差异

JDK 7对象排序换成 TimSort(违反约定开始抛异常,-Djava.util.Arrays.useLegacyMergeSort=true 可退回旧实现);基本类型换成双轴快排
JDK 8HashMap 链表过长转红黑树;Arrays.parallelSort
JDK 14双轴快排重写,混入堆排序兜底和并行化

按文档和 JDK 17 源码(TimSort 的 MIN_MERGE = 32、DualPivotQuicksort)写,没有跑 7 和 14 对比。

面试官会怎么问
从 100 万个数里找最大的 10 个,怎么做?复杂度?

大小为 10 的小顶堆,堆顶是门槛,比门槛大才换进来。O(n log K)(④-3)。数据放不进内存就分块各求 Top K 再合并。

Java 的 Arrays.sort 用什么算法?为什么对象和基本类型不一样?

对象用 TimSort(稳定,利用已有有序片段),基本类型用双轴快排(原地、快,稳定没意义)(④-5)。

红黑树有哪些性质?为什么能保证 O(log n)?

根黑、红节点孩子必黑、每条到空节点的路径黑节点数相同;所以最长路径不超过最短的 2 倍,树高 ≤ 2log(n+1)。插入修复最多两次旋转(⑤、⑥-3)。

怎么实现“1 分钟内最多 5 次”的限流?

单机:每个用户一个时间戳队列,滑动窗口,O(1) 均摊(④-4)。多实例:Redis 的 ZSet 存时间戳,或者令牌桶(J12)。

下一圈

↺回到⑤:改造你的案例

1. 热销 Top 10 带商品名

把⑤的 int 换成 record Sale(long skuId, long qty),用 PriorityQueue<Sale>(comparingLong(Sale::qty)) 求 Top 10,最后按销量从高到低输出。

用到:④-3、坑 2

2. 用 TreeMap 换掉二分

把时间戳放进 TreeMap<Long, Order>,用 subMap(t1, true, t2, false).size() 求时间段订单数,对比和二分的结果、耗时;再往里插新订单,看谁更方便。

用到:③、⑤ 红黑树

3. 修好比较器

把⑤第 4 段的 a > b ? 1 : -1 改成 Integer::compare,跑 500 次确认不再报错。

用到:坑 1

自测

1. 用一句话说出这页的精华。

先按数据量估复杂度,再按最频繁的操作把数据提前组织好:key 用哈希,最值用堆,范围用有序数组或平衡树,窗口用双端队列;JDK 都有现成的,自己写对比较器和边界。

2. 朴素做法最疼在哪?

每次都把全部数据看一遍或排一遍,做了大量不需要的工作,也没利用数据已有的顺序(②)。

3. “Comparison method violates its general contract”的根源是哪条原理?

TimSort 依赖比较器自反、对称、传递,合并时发现矛盾就抛异常(④-5,坑 1)。

4. 预测:在⑤的红黑树里接着插入 12,会发生什么?

12 比 20 小、比 10 大、比 15 小,落在 15 的左边,是红的;父亲 15 是黑的,规则没被破坏,不用修。在演示里输入 12 点“插入”可以验证。

5. 预测:Top K 的 K 从 10 变成 50 万,小顶堆还比全排序快吗?

不一定。复杂度变成 O(n log K),log 50 万 ≈ 19,和全排序的 log n ≈ 20 差不多,堆的常数还更大(对象装箱、比较器),这时直接排序或用快速选择(O(n) 平均)更合适(④-3)。

6. 放回全景:第 9 站前后各发生了什么?这一页讲的东西用错了,整条链路会怎样?

前面是第 8 站把订单放进 userOrders,后面是第 10 站序列化返回。第 9 站如果不用堆、改成每次把全部待取消订单排序,后台取消线程每秒排一次几十万条,CPU 被占满,第 1 站的 Tomcat 线程跟着变慢;如果 CancelTask 的 compareTo 写成相等返回 −1,PriorityQueue 不会报错,但换成 TreeSet 或排序时就可能抛异常或丢任务。第 4 站的 HashMap 是这页“按 key 查”那一格,选错成 List,查商品就从 O(1) 变成 O(n)。