百日算法修炼 · Day 12

百日算法修炼 · Day 12

主题:前缀树 Trie、pass / end 计数、删除剪枝、DFS 回溯、计数排序、基数排序、排序稳定性

语言:Java | 建议用时:120~150 分钟


阅读方式

本文继续采用"先练习、后答案"的方式:

  • 上半篇是独立练习区:只给题目、示例、思考问题和可折叠提示;
  • 下半篇是参考答案区:包含完整优化代码、过程图解、复杂度分析、原代码改进点与易错点;
  • 建议先独立完成 7 个练习,再通过"查看参考答案"链接逐题核对;
  • 如果某一题卡住,先只展开"提示 1",不要急着直接看完整代码。

今天真正要建立的不是"记住 Trie 和两个排序模板",而是三个底层意识:

  1. 路径本身也可以携带统计信息 :Trie 的 pass / end 让"出现次数"和"前缀数量"都变成 O(L) 查询;
  2. 递归搜索一定要维护现场:DFS 进入分支前修改路径,离开分支后必须恢复路径;
  3. 非比较排序靠的是数据特征:计数排序依赖紧凑值域,基数排序依赖可拆分的数位,而不是"无条件比 O(n log n) 更好"。

今日任务地图

#mermaid-svg-lLyzNoh7xjvJxmjd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lLyzNoh7xjvJxmjd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lLyzNoh7xjvJxmjd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lLyzNoh7xjvJxmjd .error-icon{fill:#552222;}#mermaid-svg-lLyzNoh7xjvJxmjd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lLyzNoh7xjvJxmjd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lLyzNoh7xjvJxmjd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lLyzNoh7xjvJxmjd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lLyzNoh7xjvJxmjd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lLyzNoh7xjvJxmjd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lLyzNoh7xjvJxmjd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lLyzNoh7xjvJxmjd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lLyzNoh7xjvJxmjd .marker.cross{stroke:#333333;}#mermaid-svg-lLyzNoh7xjvJxmjd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lLyzNoh7xjvJxmjd p{margin:0;}#mermaid-svg-lLyzNoh7xjvJxmjd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lLyzNoh7xjvJxmjd .cluster-label text{fill:#333;}#mermaid-svg-lLyzNoh7xjvJxmjd .cluster-label span{color:#333;}#mermaid-svg-lLyzNoh7xjvJxmjd .cluster-label span p{background-color:transparent;}#mermaid-svg-lLyzNoh7xjvJxmjd .label text,#mermaid-svg-lLyzNoh7xjvJxmjd span{fill:#333;color:#333;}#mermaid-svg-lLyzNoh7xjvJxmjd .node rect,#mermaid-svg-lLyzNoh7xjvJxmjd .node circle,#mermaid-svg-lLyzNoh7xjvJxmjd .node ellipse,#mermaid-svg-lLyzNoh7xjvJxmjd .node polygon,#mermaid-svg-lLyzNoh7xjvJxmjd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lLyzNoh7xjvJxmjd .rough-node .label text,#mermaid-svg-lLyzNoh7xjvJxmjd .node .label text,#mermaid-svg-lLyzNoh7xjvJxmjd .image-shape .label,#mermaid-svg-lLyzNoh7xjvJxmjd .icon-shape .label{text-anchor:middle;}#mermaid-svg-lLyzNoh7xjvJxmjd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lLyzNoh7xjvJxmjd .rough-node .label,#mermaid-svg-lLyzNoh7xjvJxmjd .node .label,#mermaid-svg-lLyzNoh7xjvJxmjd .image-shape .label,#mermaid-svg-lLyzNoh7xjvJxmjd .icon-shape .label{text-align:center;}#mermaid-svg-lLyzNoh7xjvJxmjd .node.clickable{cursor:pointer;}#mermaid-svg-lLyzNoh7xjvJxmjd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lLyzNoh7xjvJxmjd .arrowheadPath{fill:#333333;}#mermaid-svg-lLyzNoh7xjvJxmjd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lLyzNoh7xjvJxmjd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lLyzNoh7xjvJxmjd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lLyzNoh7xjvJxmjd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lLyzNoh7xjvJxmjd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lLyzNoh7xjvJxmjd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lLyzNoh7xjvJxmjd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lLyzNoh7xjvJxmjd .cluster text{fill:#333;}#mermaid-svg-lLyzNoh7xjvJxmjd .cluster span{color:#333;}#mermaid-svg-lLyzNoh7xjvJxmjd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lLyzNoh7xjvJxmjd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lLyzNoh7xjvJxmjd rect.text{fill:none;stroke-width:0;}#mermaid-svg-lLyzNoh7xjvJxmjd .icon-shape,#mermaid-svg-lLyzNoh7xjvJxmjd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lLyzNoh7xjvJxmjd .icon-shape p,#mermaid-svg-lLyzNoh7xjvJxmjd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lLyzNoh7xjvJxmjd .icon-shape .label rect,#mermaid-svg-lLyzNoh7xjvJxmjd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lLyzNoh7xjvJxmjd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lLyzNoh7xjvJxmjd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lLyzNoh7xjvJxmjd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Day 12 Start
Trie Node State
Insert and Search
Delete and Prune
Prefix DFS
Count Sort
Radix Sort
Signed Integers
Review

题号 主题 核心能力
1 Trie 节点的 pass / end 路径统计、前缀含义
2 Trie 插入、精确查询、前缀计数 沿字符路径移动
3 Trie 删除与剪枝 计数回退、无效分支释放
4 前缀枚举 DFS、StringBuilder 回溯
5 计数排序 值域映射、负数偏移
6 基数排序单轮 稳定计数排序、前缀和
7 完整基数排序 LSD、多轮稳定性、负数处理

第一部分:独立练习区

先不要向下翻到答案区。

今天每道题都先问自己一句:这个算法真正维护的"状态"是什么?

练习 1:Trie 节点为什么需要 passend

向 Trie 中依次加入:

text 复制代码
"app"
"apple"
"app"
"ape"

可以把共享前缀画成:

text 复制代码
(root)
   |
   a
   |
   p
  / \
 p   e
 |
 l
 |
 e

现在假设每个节点额外记录:

text 复制代码
pass:有多少个加入过的单词经过该节点
end :有多少个加入过的单词恰好在该节点结束

请回答:

  1. 根节点的 pass 是多少?
  2. 路径 "a" -> "p" 对应节点的 pass 是多少?
  3. "app" 最后一个 p 节点的 end 是多少?
  4. "apple" 最后一个 e 节点的 end 是多少?
  5. 如果只知道 "app" 路径存在,能否直接断言 "app" 这个单词被加入过?

思考问题

  1. 为什么 Trie 节点不能只存"有没有孩子"?
  2. pass 与"有多少单词以当前路径为前缀"有什么关系?
  3. end 与"当前完整单词出现多少次"有什么关系?
  4. 重复插入同一个单词时,为什么 end 不能只是 boolean

提示 1:路径存在,不等于单词存在

例如只插入 apple 时,app 这条路径一定存在,但如果 app 节点的 end == 0,就说明完整单词 app 没有被插入。
提示 2:pass 是前缀计数器

走到某个前缀对应节点后,该节点的 pass 就是以这个前缀开头的单词数量,重复插入也会重复计数。

写完后再看:[查看练习 1 参考答案](#查看练习 1 参考答案)


练习 2:实现 Trie 的插入、精确查询与前缀计数

先限定字符范围:

text 复制代码
'a' ~ 'z'

节点结构:

java 复制代码
class Node {
    int pass;
    int end;
    Node[] children = new Node[26];
}

请实现:

java 复制代码
add(word)
count(word)
contains(word)
prefixCount(prefix)

其中:

text 复制代码
count("app")

返回 "app" 被插入过多少次。

text 复制代码
prefixCount("app")

返回有多少个已插入单词以 "app" 开头。

思考问题

  1. add 时根节点的 pass 要不要增加?
  2. 每走到一个字符节点后,哪个计数器要增加?
  3. 只有什么时候才增加 end
  4. 查询 "app" 时,如果路径存在但 end == 0,应该返回什么?
  5. prefixCount 找到前缀末尾节点后,还需要遍历整棵子树吗?

提示 1:add 的核心是"走一路,pass 加一路"

根节点先 pass++;之后每进入一个字符对应节点,就让该节点 pass++;完整单词走完后,最后一个节点再 end++
提示 2:prefixCount 不需要 DFS

如果前缀路径存在,前缀末尾节点的 pass 已经记录了经过该节点的单词总数。

写完后再看:[查看练习 2 参考答案](#查看练习 2 参考答案)


练习 3:Trie 删除时什么时候可以直接剪枝?

当前 Trie 中有:

text 复制代码
app
apple
ape

现在删除:

text 复制代码
apple

删除不是简单地把最后节点的:

text 复制代码
end--

还要让整条路径上的:

text 复制代码
pass--

请继续思考一个更关键的问题:

如果某个字符节点在 pass-- 后变成 0,意味着什么?

思考问题

  1. 删除前为什么必须先确认这个单词确实存在?
  2. 根节点的 pass 是否也要减少?
  3. 某个节点 pass == 0 后,这个节点以下还有可能被别的有效单词使用吗?
  4. 一旦把父节点到这个子节点的引用断开,还需要继续向下修改后续节点吗?
  5. 删除重复单词时,什么时候才能真正剪掉路径?

提示 1:先 count,再 delete

如果完整单词不存在,不能修改任何 pass / end,否则 Trie 的统计信息会被破坏。
提示 2:pass 归零就是剪枝信号

节点 pass == 0 表示再也没有有效单词经过这个节点,因此父节点可以直接断开这条边,整棵后续分支都可以被回收。

写完后再看:[查看练习 3 参考答案](#查看练习 3 参考答案)


练习 4:列出所有具有指定前缀的单词

Trie 中加入:

text 复制代码
app
apple
apply
ape
bat

现在调用:

text 复制代码
wordsWithPrefix("app")

希望得到:

text 复制代码
app
apple
apply

假设你已经沿 "app" 找到了对应节点。

接下来需要 DFS 枚举它的所有后代路径。

思考问题

  1. DFS 进入一个孩子前,要对当前字符串做什么?
  2. DFS 返回父节点后,要恢复什么状态?
  3. 如果不恢复 StringBuilder,访问第二个兄弟分支时会发生什么?
  4. 为什么 StringBuilder 比每层都创建新字符串更适合这个场景?
  5. 如果一个单词被插入了 3 次,结果列表中应该出现几次?

提示 1:记住进入前的长度

进入孩子前先保存 int len = path.length(),追加字符并递归,回来后执行 path.setLength(len)
提示 2:这是标准回溯

"做选择 → 递归 → 撤销选择"不仅用于排列组合,任何共享可变路径对象的 DFS 都要注意恢复现场。

写完后再看:[查看练习 4 参考答案](#查看练习 4 参考答案)


练习 5:计数排序为什么不需要比较元素?

给定:

text 复制代码
arr = [5, -2, 3, 5, 0, -2]

最小值:

text 复制代码
min = -2

最大值:

text 复制代码
max = 5

如果创建一个计数数组:

text 复制代码
count[0 ... max - min]

规定:

text 复制代码
原值 value
映射到下标 value - min

那么:

text 复制代码
-2 -> 0
-1 -> 1
 0 -> 2
 ...
 5 -> 7

请实现计数排序。

思考问题

  1. 为什么需要先找 min / max
  2. 有负数时,为什么不能直接写 count[arr[i]]++
  3. count[i] 对应的原始值是什么?
  4. 计数排序的复杂度为什么通常写成 O(n + range)?
  5. 如果只有 10 个数,但值域从 Integer.MIN_VALUEInteger.MAX_VALUE,还适合计数排序吗?

提示 1:用 min 做偏移

桶下标是 value - min,恢复原值时则是 bucketIndex + min
提示 2:值域大小必须先用 long 算

max - min + 1 如果全部使用 int 运算,极端情况下会先发生整数溢出。应写成 (long) max - min + 1

写完后再看:[查看练习 5 参考答案](#查看练习 5 参考答案)


练习 6:基数排序的一轮为什么必须稳定?

先只考虑非负整数:

text 复制代码
[13, 21, 12, 11]

第一轮按个位排序:

text 复制代码
21, 11, 12, 13

注意:

text 复制代码
21 和 11 的个位都等于 1

如果个位排序是稳定的,它们会保留原有相对顺序。

下一轮再按十位排序,就能得到:

text 复制代码
11, 12, 13, 21

请思考:

为什么"低位先排 + 每一轮稳定",最终能够得到整体有序?

思考问题

  1. LSD(Least Significant Digit)为什么从最低位开始?
  2. 当前轮处理高一位时,之前已经排好的低位信息怎样才能保留下来?
  3. 计数数组从"出现次数"变成"前缀和"后,表示什么?
  4. 为什么回填辅助数组时通常从右向左扫描原数组?
  5. 如果从左向右回填,会不会破坏稳定性?

提示 1:前缀和表示右边界

假设 count[d] 累加后等于 7,表示 digit 不大于 d 的元素一共有 7 个,因此 digit=d 的最后一个元素应该放在下标 6。
提示 2:右向左才能保持同 digit 的相对顺序

使用 help[--count[digit]] = arr[i] 时,从右向左扫描可以让后出现的同桶元素先占据更靠右的位置,从而保留原始顺序。

写完后再看:[查看练习 6 参考答案](#查看练习 6 参考答案)


练习 7:让十进制基数排序正确处理负数

普通十进制 LSD 常见写法只使用:

text 复制代码
0 ~ 9

十个桶。

但 Java int 可能出现:

text 复制代码
-13
-2
0
7
21

如果直接计算:

java 复制代码
digit = (num / divisor) % 10;

负数会得到:

text 复制代码
-9 ... -1

这显然不能直接作为 count[10] 的下标。

请尝试设计一种仍然保持"十进制逐位稳定排序"的方案。

思考问题

  1. 一位十进制数的有符号 digit 可能落在哪个范围?
  2. 能否把 -9...9 平移成合法数组下标?
  3. 为什么需要 19 个桶?
  4. Integer.MIN_VALUE 取绝对值时有什么危险?
  5. 怎样安全计算数组中的最大绝对值,从而知道需要处理多少位?

提示 1:桶编号整体加 9

digit 范围是 -9...9,使用 bucket = digit + 9 后就映射到 0...18
提示 2:先转 long 再取绝对值

Math.abs(Integer.MIN_VALUE) 仍会溢出 int。应先转成 long:Math.abs((long) value)

写完后再看:[查看练习 7 参考答案](#查看练习 7 参考答案)


第二部分:参考答案与图解

到这里再开始核对。

每道答案都先回顾题意,再解释"为什么这样设计",最后才给代码。

答案 1:理解 pass / end

[返回练习 1](#返回练习 1)

题目回顾

插入:

text 复制代码
app
apple
app
ape

一共执行了 4 次有效插入。

因此:

text 复制代码
root.pass = 4

路径 "ap" 被 4 个单词经过:

text 复制代码
app
apple
app
ape

所以 "ap" 节点:

text 复制代码
pass = 4

"app" 被完整插入两次:

text 复制代码
end = 2

用一张图看懂

text 复制代码
root
pass=4
  |
  a
pass=4
  |
  p
pass=4
 / \
p   e
|   |
|   end=1      <- ape
|
pass=3
end=2           <- app 出现两次
 |
 l
pass=1
 |
 e
pass=1
end=1           <- apple

passend 的语义一定要分开

text 复制代码
pass > 0

只说明:

至少有单词经过这里。

而:

text 复制代码
end > 0

才说明:

至少有一个完整单词恰好在这里结束。

例如只插入:

text 复制代码
apple

虽然路径:

text 复制代码
a -> p -> p

一定存在,但 "app" 节点:

text 复制代码
end = 0

所以:

text 复制代码
contains("app") == false

为什么不用 boolean?

因为 Trie 可以表达多重集合。

连续插入:

text 复制代码
app
app
app

应该得到:

text 复制代码
count("app") == 3

如果只用:

java 复制代码
boolean end;

重复次数就丢失了。

一句话记忆

text 复制代码
pass:经过了多少次
end :停在这里多少次

答案 2:插入、精确查询与前缀计数

[返回练习 2](#返回练习 2)

add 的不变量

每加入一个单词:

text 复制代码
root.pass++

然后每经过一个字符节点:

text 复制代码
node.pass++

走完整个单词:

text 复制代码
last.end++

#mermaid-svg-mfbBRuXgbJcGPCoN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mfbBRuXgbJcGPCoN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mfbBRuXgbJcGPCoN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mfbBRuXgbJcGPCoN .error-icon{fill:#552222;}#mermaid-svg-mfbBRuXgbJcGPCoN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mfbBRuXgbJcGPCoN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mfbBRuXgbJcGPCoN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mfbBRuXgbJcGPCoN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mfbBRuXgbJcGPCoN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mfbBRuXgbJcGPCoN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mfbBRuXgbJcGPCoN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mfbBRuXgbJcGPCoN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mfbBRuXgbJcGPCoN .marker.cross{stroke:#333333;}#mermaid-svg-mfbBRuXgbJcGPCoN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mfbBRuXgbJcGPCoN p{margin:0;}#mermaid-svg-mfbBRuXgbJcGPCoN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-mfbBRuXgbJcGPCoN .cluster-label text{fill:#333;}#mermaid-svg-mfbBRuXgbJcGPCoN .cluster-label span{color:#333;}#mermaid-svg-mfbBRuXgbJcGPCoN .cluster-label span p{background-color:transparent;}#mermaid-svg-mfbBRuXgbJcGPCoN .label text,#mermaid-svg-mfbBRuXgbJcGPCoN span{fill:#333;color:#333;}#mermaid-svg-mfbBRuXgbJcGPCoN .node rect,#mermaid-svg-mfbBRuXgbJcGPCoN .node circle,#mermaid-svg-mfbBRuXgbJcGPCoN .node ellipse,#mermaid-svg-mfbBRuXgbJcGPCoN .node polygon,#mermaid-svg-mfbBRuXgbJcGPCoN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mfbBRuXgbJcGPCoN .rough-node .label text,#mermaid-svg-mfbBRuXgbJcGPCoN .node .label text,#mermaid-svg-mfbBRuXgbJcGPCoN .image-shape .label,#mermaid-svg-mfbBRuXgbJcGPCoN .icon-shape .label{text-anchor:middle;}#mermaid-svg-mfbBRuXgbJcGPCoN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-mfbBRuXgbJcGPCoN .rough-node .label,#mermaid-svg-mfbBRuXgbJcGPCoN .node .label,#mermaid-svg-mfbBRuXgbJcGPCoN .image-shape .label,#mermaid-svg-mfbBRuXgbJcGPCoN .icon-shape .label{text-align:center;}#mermaid-svg-mfbBRuXgbJcGPCoN .node.clickable{cursor:pointer;}#mermaid-svg-mfbBRuXgbJcGPCoN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-mfbBRuXgbJcGPCoN .arrowheadPath{fill:#333333;}#mermaid-svg-mfbBRuXgbJcGPCoN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-mfbBRuXgbJcGPCoN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-mfbBRuXgbJcGPCoN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mfbBRuXgbJcGPCoN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-mfbBRuXgbJcGPCoN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mfbBRuXgbJcGPCoN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-mfbBRuXgbJcGPCoN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mfbBRuXgbJcGPCoN .cluster text{fill:#333;}#mermaid-svg-mfbBRuXgbJcGPCoN .cluster span{color:#333;}#mermaid-svg-mfbBRuXgbJcGPCoN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-mfbBRuXgbJcGPCoN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mfbBRuXgbJcGPCoN rect.text{fill:none;stroke-width:0;}#mermaid-svg-mfbBRuXgbJcGPCoN .icon-shape,#mermaid-svg-mfbBRuXgbJcGPCoN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mfbBRuXgbJcGPCoN .icon-shape p,#mermaid-svg-mfbBRuXgbJcGPCoN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-mfbBRuXgbJcGPCoN .icon-shape .label rect,#mermaid-svg-mfbBRuXgbJcGPCoN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mfbBRuXgbJcGPCoN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mfbBRuXgbJcGPCoN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mfbBRuXgbJcGPCoN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} No
Yes
Yes
No
Add Word
Root pass Plus One
Read Next Character
Child Exists?
Create Child
Move to Child
Child pass Plus One
More Characters?
End Plus One

为什么 prefixCount 是 O(L)?

假设查询:

text 复制代码
prefixCount("app")

只需要沿:

text 复制代码
a -> p -> p

走到前缀末尾。

节点自己的:

text 复制代码
pass

已经告诉我们有多少个单词经过这里。

因此不需要 DFS 整棵子树。

核心代码

java 复制代码
public void add(String word) {
    if (word == null || word.isEmpty()) {
        return;
    }

    ensureLowercase(word);

    Node cur = root;
    cur.pass++;

    for (char c : word.toCharArray()) {
        int path = c - 'a';

        if (cur.children[path] == null) {
            cur.children[path] = new Node();
        }

        cur = cur.children[path];
        cur.pass++;
    }

    cur.end++;
}

public int count(String word) {
    Node node = find(word);
    return node == null ? 0 : node.end;
}

public boolean contains(String word) {
    return count(word) > 0;
}

public int prefixCount(String prefix) {
    Node node = find(prefix);
    return node == null ? 0 : node.pass;
}

复杂度

设字符串长度为 L

  • addO(L)
  • count / containsO(L)
  • prefixCountO(L)
  • 额外查询空间:O(1)
  • Trie 总空间与实际创建的字符节点数量有关。

答案 3:删除与剪枝

[返回练习 3](#返回练习 3)

删除前必须先确认完整单词存在

如果 Trie 中只有:

text 复制代码
apple

调用:

text 复制代码
delete("app")

虽然 "app" 路径存在,但:

text 复制代码
end == 0

不能删除。

所以第一步必须是:

java 复制代码
if (count(word) == 0) {
    return false;
}

删除过程

假设:

text 复制代码
app
apple
ape

删除 "apple"

先:

text 复制代码
root.pass--

然后沿:

text 复制代码
a -> p -> p -> l -> e

每个节点:

text 复制代码
pass--

当走到某个节点后:

text 复制代码
pass == 0

说明:

text 复制代码
没有任何有效单词再经过这个节点

父节点可以直接:

java 复制代码
parent.children[path] = null;

并立即结束。
#mermaid-svg-t81fZSi3OBQA2mQO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-t81fZSi3OBQA2mQO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-t81fZSi3OBQA2mQO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-t81fZSi3OBQA2mQO .error-icon{fill:#552222;}#mermaid-svg-t81fZSi3OBQA2mQO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-t81fZSi3OBQA2mQO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-t81fZSi3OBQA2mQO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-t81fZSi3OBQA2mQO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-t81fZSi3OBQA2mQO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-t81fZSi3OBQA2mQO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-t81fZSi3OBQA2mQO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-t81fZSi3OBQA2mQO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-t81fZSi3OBQA2mQO .marker.cross{stroke:#333333;}#mermaid-svg-t81fZSi3OBQA2mQO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-t81fZSi3OBQA2mQO p{margin:0;}#mermaid-svg-t81fZSi3OBQA2mQO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-t81fZSi3OBQA2mQO .cluster-label text{fill:#333;}#mermaid-svg-t81fZSi3OBQA2mQO .cluster-label span{color:#333;}#mermaid-svg-t81fZSi3OBQA2mQO .cluster-label span p{background-color:transparent;}#mermaid-svg-t81fZSi3OBQA2mQO .label text,#mermaid-svg-t81fZSi3OBQA2mQO span{fill:#333;color:#333;}#mermaid-svg-t81fZSi3OBQA2mQO .node rect,#mermaid-svg-t81fZSi3OBQA2mQO .node circle,#mermaid-svg-t81fZSi3OBQA2mQO .node ellipse,#mermaid-svg-t81fZSi3OBQA2mQO .node polygon,#mermaid-svg-t81fZSi3OBQA2mQO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-t81fZSi3OBQA2mQO .rough-node .label text,#mermaid-svg-t81fZSi3OBQA2mQO .node .label text,#mermaid-svg-t81fZSi3OBQA2mQO .image-shape .label,#mermaid-svg-t81fZSi3OBQA2mQO .icon-shape .label{text-anchor:middle;}#mermaid-svg-t81fZSi3OBQA2mQO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-t81fZSi3OBQA2mQO .rough-node .label,#mermaid-svg-t81fZSi3OBQA2mQO .node .label,#mermaid-svg-t81fZSi3OBQA2mQO .image-shape .label,#mermaid-svg-t81fZSi3OBQA2mQO .icon-shape .label{text-align:center;}#mermaid-svg-t81fZSi3OBQA2mQO .node.clickable{cursor:pointer;}#mermaid-svg-t81fZSi3OBQA2mQO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-t81fZSi3OBQA2mQO .arrowheadPath{fill:#333333;}#mermaid-svg-t81fZSi3OBQA2mQO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-t81fZSi3OBQA2mQO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-t81fZSi3OBQA2mQO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t81fZSi3OBQA2mQO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-t81fZSi3OBQA2mQO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t81fZSi3OBQA2mQO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-t81fZSi3OBQA2mQO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-t81fZSi3OBQA2mQO .cluster text{fill:#333;}#mermaid-svg-t81fZSi3OBQA2mQO .cluster span{color:#333;}#mermaid-svg-t81fZSi3OBQA2mQO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-t81fZSi3OBQA2mQO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-t81fZSi3OBQA2mQO rect.text{fill:none;stroke-width:0;}#mermaid-svg-t81fZSi3OBQA2mQO .icon-shape,#mermaid-svg-t81fZSi3OBQA2mQO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t81fZSi3OBQA2mQO .icon-shape p,#mermaid-svg-t81fZSi3OBQA2mQO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-t81fZSi3OBQA2mQO .icon-shape .label rect,#mermaid-svg-t81fZSi3OBQA2mQO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t81fZSi3OBQA2mQO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-t81fZSi3OBQA2mQO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-t81fZSi3OBQA2mQO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} No
Yes
Yes
No
Yes
No
Delete Word
Word Exists?
Return False
Root pass Minus One
Move to Next Child
Child pass Minus One
pass Equals Zero?
Detach Branch and Return
More Characters?
End Minus One

核心代码

java 复制代码
public boolean delete(String word) {
    if (word == null || word.isEmpty() || count(word) == 0) {
        return false;
    }

    Node cur = root;
    cur.pass--;

    for (char c : word.toCharArray()) {
        int path = c - 'a';
        Node child = cur.children[path];

        child.pass--;

        // 没有任何有效单词再经过 child。
        // 直接断开整条分支即可,后续节点已经不可达。
        if (child.pass == 0) {
            cur.children[path] = null;
            return true;
        }

        cur = child;
    }

    // 整条路径仍被其他单词共享,只减少当前单词的结束次数。
    cur.end--;
    return true;
}

为什么剪枝后可以直接 return?

假设这条后续分支只属于当前要删除的单词。

一旦父节点断开:

text 复制代码
parent -> child

这条引用,整个子树已经从 Trie 主结构中不可达。

内部节点保存的旧 pass / end 值不再影响任何查询,Java GC 也可以回收这些对象。

因此没必要继续沿已经被删除的分支做无意义修改。

重复单词为什么不会误剪?

假设:

text 复制代码
app
app

"app" 路径上的:

text 复制代码
pass = 2
end = 2

删除一次后:

text 复制代码
pass = 1
end = 1

没有任何节点归零,所以路径仍然保留。

只有最后一次删除后才可能剪枝。


答案 4:前缀枚举与 DFS 回溯

[返回练习 4](#返回练习 4)

题目回顾

调用:

text 复制代码
wordsWithPrefix("app")

首先沿前缀找到节点:

text 复制代码
a -> p -> p

之后 DFS 枚举下面所有路径。

最容易漏掉的一步:恢复 StringBuilder

假设 "app" 后有两个分支:

text 复制代码
apple
apply

DFS 先走:

text 复制代码
app -> l -> e

如果返回后 StringBuilder 仍然是:

text 复制代码
apple

然后直接追加第二个分支字符,就会形成错误字符串。

正确模板一定是:

java 复制代码
int oldLength = path.length();

path.append(c);
dfs(...);

path.setLength(oldLength);

这就是回溯的"撤销选择"。
#mermaid-svg-eiAC56NxrlCOPEnV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eiAC56NxrlCOPEnV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eiAC56NxrlCOPEnV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eiAC56NxrlCOPEnV .error-icon{fill:#552222;}#mermaid-svg-eiAC56NxrlCOPEnV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eiAC56NxrlCOPEnV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eiAC56NxrlCOPEnV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eiAC56NxrlCOPEnV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eiAC56NxrlCOPEnV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eiAC56NxrlCOPEnV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eiAC56NxrlCOPEnV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eiAC56NxrlCOPEnV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eiAC56NxrlCOPEnV .marker.cross{stroke:#333333;}#mermaid-svg-eiAC56NxrlCOPEnV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eiAC56NxrlCOPEnV p{margin:0;}#mermaid-svg-eiAC56NxrlCOPEnV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eiAC56NxrlCOPEnV .cluster-label text{fill:#333;}#mermaid-svg-eiAC56NxrlCOPEnV .cluster-label span{color:#333;}#mermaid-svg-eiAC56NxrlCOPEnV .cluster-label span p{background-color:transparent;}#mermaid-svg-eiAC56NxrlCOPEnV .label text,#mermaid-svg-eiAC56NxrlCOPEnV span{fill:#333;color:#333;}#mermaid-svg-eiAC56NxrlCOPEnV .node rect,#mermaid-svg-eiAC56NxrlCOPEnV .node circle,#mermaid-svg-eiAC56NxrlCOPEnV .node ellipse,#mermaid-svg-eiAC56NxrlCOPEnV .node polygon,#mermaid-svg-eiAC56NxrlCOPEnV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eiAC56NxrlCOPEnV .rough-node .label text,#mermaid-svg-eiAC56NxrlCOPEnV .node .label text,#mermaid-svg-eiAC56NxrlCOPEnV .image-shape .label,#mermaid-svg-eiAC56NxrlCOPEnV .icon-shape .label{text-anchor:middle;}#mermaid-svg-eiAC56NxrlCOPEnV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eiAC56NxrlCOPEnV .rough-node .label,#mermaid-svg-eiAC56NxrlCOPEnV .node .label,#mermaid-svg-eiAC56NxrlCOPEnV .image-shape .label,#mermaid-svg-eiAC56NxrlCOPEnV .icon-shape .label{text-align:center;}#mermaid-svg-eiAC56NxrlCOPEnV .node.clickable{cursor:pointer;}#mermaid-svg-eiAC56NxrlCOPEnV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eiAC56NxrlCOPEnV .arrowheadPath{fill:#333333;}#mermaid-svg-eiAC56NxrlCOPEnV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eiAC56NxrlCOPEnV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eiAC56NxrlCOPEnV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eiAC56NxrlCOPEnV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eiAC56NxrlCOPEnV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eiAC56NxrlCOPEnV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eiAC56NxrlCOPEnV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eiAC56NxrlCOPEnV .cluster text{fill:#333;}#mermaid-svg-eiAC56NxrlCOPEnV .cluster span{color:#333;}#mermaid-svg-eiAC56NxrlCOPEnV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eiAC56NxrlCOPEnV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eiAC56NxrlCOPEnV rect.text{fill:none;stroke-width:0;}#mermaid-svg-eiAC56NxrlCOPEnV .icon-shape,#mermaid-svg-eiAC56NxrlCOPEnV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eiAC56NxrlCOPEnV .icon-shape p,#mermaid-svg-eiAC56NxrlCOPEnV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eiAC56NxrlCOPEnV .icon-shape .label rect,#mermaid-svg-eiAC56NxrlCOPEnV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eiAC56NxrlCOPEnV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eiAC56NxrlCOPEnV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eiAC56NxrlCOPEnV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Yes
No
DFS Current Node
Record Path Length
Append Child Character
Visit Child
Recursive DFS
Restore Path Length
Next Child?
Return

正确 DFS

java 复制代码
private void collect(Node node, StringBuilder path, List<String> ans) {
    // 当前路径本身可能就是一个完整单词。
    for (int i = 0; i < node.end; i++) {
        ans.add(path.toString());
    }

    for (int i = 0; i < 26; i++) {
        Node child = node.children[i];

        if (child == null) {
            continue;
        }

        int oldLength = path.length();

        path.append((char) ('a' + i));
        collect(child, path, ans);

        // 回溯:离开当前孩子后恢复父节点路径。
        path.setLength(oldLength);
    }
}

Node[26]Map 怎么选?

固定字符集

如果明确只有:

text 复制代码
'a' ~ 'z'

数组非常直接:

java 复制代码
Node[] children = new Node[26];

优点:

  • 下标访问 O(1);
  • 常数小;
  • 0...25 DFS 天然得到字典序。

缺点:

  • 每个节点固定预留 26 个引用;
  • 不能直接支持任意字符。
任意字符

可以使用:

java 复制代码
Map<Character, Node>

如果还希望前缀枚举结果天然有序,可以用:

java 复制代码
TreeMap<Character, Node>

而不是依赖 HashMap 的遍历顺序。


完整版:Code01_TrieTree.java

下面保留你原来"数组版 Trie + Map 版 Trie + 暴力对数器"的训练结构,但修复 DFS 回溯和对拍副作用,并补上 count / prefixCount

java 复制代码
package class04;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.TreeMap;

/**
 * Day 12:前缀树 Trie。
 *
 * 包含:
 * 1. 固定字符集 'a' ~ 'z' 的数组版 Trie;
 * 2. 支持更一般字符的 TreeMap 版 Trie;
 * 3. HashMap 暴力参考实现,用于随机对拍。
 */
public class Code01_TrieTree {

    private static final Random RANDOM = new Random();

    public static void main(String[] args) {
        final int testTimes = 10_000;
        final int operationsPerTest = 200;

        for (int test = 0; test < testTimes; test++) {
            TrieArray trie1 = new TrieArray();
            TrieMap trie2 = new TrieMap();
            ReferenceCounter reference = new ReferenceCounter();

            for (int op = 0; op < operationsPerTest; op++) {
                String word = randomLowercaseWord(1, 10);
                int type = RANDOM.nextInt(5);

                if (type == 0) {
                    trie1.add(word);
                    trie2.add(word);
                    reference.add(word);

                } else if (type == 1) {
                    boolean a = trie1.contains(word);
                    boolean b = trie2.contains(word);
                    boolean c = reference.contains(word);

                    assertEquals(a, b, c, "contains", word);

                } else if (type == 2) {
                    // 每个实现只调用一次 delete。
                    // 不要在一个布尔表达式中重复调用会修改状态的方法。
                    boolean a = trie1.delete(word);
                    boolean b = trie2.delete(word);
                    boolean c = reference.delete(word);

                    assertEquals(a, b, c, "delete", word);

                } else if (type == 3) {
                    String prefix = randomLowercaseWord(1, 4);

                    int a = trie1.prefixCount(prefix);
                    int b = trie2.prefixCount(prefix);
                    int c = reference.prefixCount(prefix);

                    if (a != b || a != c) {
                        throw new AssertionError(
                                "prefixCount failed, prefix=" + prefix
                                        + ", trie1=" + a
                                        + ", trie2=" + b
                                        + ", reference=" + c);
                    }

                } else {
                    String prefix = randomLowercaseWord(1, 4);

                    List<String> a = trie1.wordsWithPrefix(prefix);
                    List<String> b = trie2.wordsWithPrefix(prefix);
                    List<String> c = reference.wordsWithPrefix(prefix);

                    if (!a.equals(b) || !a.equals(c)) {
                        throw new AssertionError(
                                "prefix words failed, prefix=" + prefix
                                        + "\ntrie1=" + a
                                        + "\ntrie2=" + b
                                        + "\nreference=" + c);
                    }
                }
            }
        }

        System.out.println("Accept");
    }

    /**
     * 固定字符集版本:
     * 仅支持 'a' ~ 'z'。
     */
    public static class TrieArray {

        private static class Node {
            int pass;
            int end;
            final Node[] children = new Node[26];
        }

        private final Node root = new Node();

        /**
         * 插入一个单词。
         *
         * 时间复杂度:O(L)
         */
        public void add(String word) {
            if (word == null || word.isEmpty()) {
                return;
            }

            ensureLowercase(word);

            Node cur = root;
            cur.pass++;

            for (char c : word.toCharArray()) {
                int path = c - 'a';

                if (cur.children[path] == null) {
                    cur.children[path] = new Node();
                }

                cur = cur.children[path];
                cur.pass++;
            }

            cur.end++;
        }

        /**
         * 返回完整单词被加入过多少次。
         *
         * 时间复杂度:O(L)
         */
        public int count(String word) {
            Node node = find(word);
            return node == null ? 0 : node.end;
        }

        /**
         * 是否至少存在一次完整单词。
         */
        public boolean contains(String word) {
            return count(word) > 0;
        }

        /**
         * 返回以 prefix 开头的单词数量。
         * 重复插入会重复计数。
         *
         * 时间复杂度:O(P)
         */
        public int prefixCount(String prefix) {
            Node node = find(prefix);
            return node == null ? 0 : node.pass;
        }

        /**
         * 删除一次完整单词。
         *
         * 时间复杂度:O(L)
         */
        public boolean delete(String word) {
            if (word == null || word.isEmpty() || count(word) == 0) {
                return false;
            }

            Node cur = root;
            cur.pass--;

            for (char c : word.toCharArray()) {
                int path = c - 'a';
                Node child = cur.children[path];

                child.pass--;

                // 该分支已经没有任何有效单词经过。
                if (child.pass == 0) {
                    cur.children[path] = null;
                    return true;
                }

                cur = child;
            }

            cur.end--;
            return true;
        }

        /**
         * 返回所有以 prefix 开头的单词。
         * 结果按字典序排列,重复单词会重复出现。
         */
        public List<String> wordsWithPrefix(String prefix) {
            List<String> ans = new ArrayList<>();

            Node node = find(prefix);
            if (node == null) {
                return ans;
            }

            collect(node, new StringBuilder(prefix), ans);
            return ans;
        }

        /**
         * 找到字符串路径对应的最后一个节点。
         */
        private Node find(String text) {
            if (text == null || text.isEmpty()) {
                return null;
            }

            ensureLowercase(text);

            Node cur = root;

            for (char c : text.toCharArray()) {
                int path = c - 'a';

                if (cur.children[path] == null) {
                    return null;
                }

                cur = cur.children[path];
            }

            return cur;
        }

        /**
         * DFS 枚举所有后缀。
         *
         * 关键:
         * 进入孩子前记录长度;
         * 离开孩子后恢复长度。
         */
        private void collect(
                Node node,
                StringBuilder path,
                List<String> ans) {

            for (int i = 0; i < node.end; i++) {
                ans.add(path.toString());
            }

            for (int i = 0; i < 26; i++) {
                Node child = node.children[i];

                if (child == null) {
                    continue;
                }

                int oldLength = path.length();

                path.append((char) ('a' + i));
                collect(child, path, ans);

                // 回溯,避免污染下一个兄弟分支。
                path.setLength(oldLength);
            }
        }

        private void ensureLowercase(String text) {
            for (int i = 0; i < text.length(); i++) {
                char c = text.charAt(i);

                if (c < 'a' || c > 'z') {
                    throw new IllegalArgumentException(
                            "TrieArray only supports 'a' to 'z': " + text);
                }
            }
        }
    }

    /**
     * TreeMap 版本:
     * 不再把字符限制为 'a' ~ 'z'。
     *
     * 使用 TreeMap 而不是 HashMap,是为了让 DFS 输出顺序稳定。
     */
    public static class TrieMap {

        private static class Node {
            int pass;
            int end;
            final TreeMap<Character, Node> children = new TreeMap<>();
        }

        private final Node root = new Node();

        public void add(String word) {
            if (word == null || word.isEmpty()) {
                return;
            }

            Node cur = root;
            cur.pass++;

            for (char c : word.toCharArray()) {
                cur.children.putIfAbsent(c, new Node());
                cur = cur.children.get(c);
                cur.pass++;
            }

            cur.end++;
        }

        public int count(String word) {
            Node node = find(word);
            return node == null ? 0 : node.end;
        }

        public boolean contains(String word) {
            return count(word) > 0;
        }

        public int prefixCount(String prefix) {
            Node node = find(prefix);
            return node == null ? 0 : node.pass;
        }

        public boolean delete(String word) {
            if (word == null || word.isEmpty() || count(word) == 0) {
                return false;
            }

            Node cur = root;
            cur.pass--;

            for (char c : word.toCharArray()) {
                Node child = cur.children.get(c);
                child.pass--;

                if (child.pass == 0) {
                    cur.children.remove(c);
                    return true;
                }

                cur = child;
            }

            cur.end--;
            return true;
        }

        public List<String> wordsWithPrefix(String prefix) {
            List<String> ans = new ArrayList<>();

            Node node = find(prefix);
            if (node == null) {
                return ans;
            }

            collect(node, new StringBuilder(prefix), ans);
            return ans;
        }

        private Node find(String text) {
            if (text == null || text.isEmpty()) {
                return null;
            }

            Node cur = root;

            for (char c : text.toCharArray()) {
                cur = cur.children.get(c);

                if (cur == null) {
                    return null;
                }
            }

            return cur;
        }

        private void collect(
                Node node,
                StringBuilder path,
                List<String> ans) {

            for (int i = 0; i < node.end; i++) {
                ans.add(path.toString());
            }

            for (Map.Entry<Character, Node> entry
                    : node.children.entrySet()) {

                int oldLength = path.length();

                path.append(entry.getKey());
                collect(entry.getValue(), path, ans);

                path.setLength(oldLength);
            }
        }
    }

    /**
     * 暴力参考实现。
     *
     * 不追求快,只追求逻辑简单、便于证明正确。
     */
    public static class ReferenceCounter {

        private final HashMap<String, Integer> map = new HashMap<>();

        public void add(String word) {
            if (word == null || word.isEmpty()) {
                return;
            }

            map.put(word, map.getOrDefault(word, 0) + 1);
        }

        public int count(String word) {
            if (word == null || word.isEmpty()) {
                return 0;
            }

            return map.getOrDefault(word, 0);
        }

        public boolean contains(String word) {
            return count(word) > 0;
        }

        public boolean delete(String word) {
            int times = count(word);

            if (times == 0) {
                return false;
            }

            if (times == 1) {
                map.remove(word);
            } else {
                map.put(word, times - 1);
            }

            return true;
        }

        public int prefixCount(String prefix) {
            if (prefix == null || prefix.isEmpty()) {
                return 0;
            }

            int ans = 0;

            for (Map.Entry<String, Integer> entry : map.entrySet()) {
                if (entry.getKey().startsWith(prefix)) {
                    ans += entry.getValue();
                }
            }

            return ans;
        }

        public List<String> wordsWithPrefix(String prefix) {
            List<String> ans = new ArrayList<>();

            if (prefix == null || prefix.isEmpty()) {
                return ans;
            }

            for (Map.Entry<String, Integer> entry : map.entrySet()) {
                if (!entry.getKey().startsWith(prefix)) {
                    continue;
                }

                for (int i = 0; i < entry.getValue(); i++) {
                    ans.add(entry.getKey());
                }
            }

            Collections.sort(ans);
            return ans;
        }
    }

    private static void assertEquals(
            boolean a,
            boolean b,
            boolean c,
            String operation,
            String word) {

        // 三个结果必须全部相同。
        if (a != b || a != c) {
            throw new AssertionError(
                    operation + " failed, word=" + word
                            + ", trie1=" + a
                            + ", trie2=" + b
                            + ", reference=" + c);
        }
    }

    private static String randomLowercaseWord(
            int minLength,
            int maxLength) {

        int length = minLength
                + RANDOM.nextInt(maxLength - minLength + 1);

        StringBuilder sb = new StringBuilder(length);

        for (int i = 0; i < length; i++) {
            sb.append((char) ('a' + RANDOM.nextInt(26)));
        }

        return sb.toString();
    }
}

答案 5:计数排序

[返回练习 5](#返回练习 5)

题目回顾

数组:

text 复制代码
[5, -2, 3, 5, 0, -2]

范围:

text 复制代码
min = -2
max = 5
range = 8

计数数组下标与原值:

text 复制代码
bucket: 0  1  2  3  4  5  6  7
value : -2 -1  0  1  2  3  4  5
count :  2  0  1  0  0  1  0  2

最后按桶下标从小到大恢复:

text 复制代码
-2, -2, 0, 3, 5, 5

#mermaid-svg-2QvzTsIW25ZOTIko{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2QvzTsIW25ZOTIko .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2QvzTsIW25ZOTIko .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2QvzTsIW25ZOTIko .error-icon{fill:#552222;}#mermaid-svg-2QvzTsIW25ZOTIko .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2QvzTsIW25ZOTIko .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2QvzTsIW25ZOTIko .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2QvzTsIW25ZOTIko .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2QvzTsIW25ZOTIko .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2QvzTsIW25ZOTIko .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2QvzTsIW25ZOTIko .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2QvzTsIW25ZOTIko .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2QvzTsIW25ZOTIko .marker.cross{stroke:#333333;}#mermaid-svg-2QvzTsIW25ZOTIko svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2QvzTsIW25ZOTIko p{margin:0;}#mermaid-svg-2QvzTsIW25ZOTIko .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2QvzTsIW25ZOTIko .cluster-label text{fill:#333;}#mermaid-svg-2QvzTsIW25ZOTIko .cluster-label span{color:#333;}#mermaid-svg-2QvzTsIW25ZOTIko .cluster-label span p{background-color:transparent;}#mermaid-svg-2QvzTsIW25ZOTIko .label text,#mermaid-svg-2QvzTsIW25ZOTIko span{fill:#333;color:#333;}#mermaid-svg-2QvzTsIW25ZOTIko .node rect,#mermaid-svg-2QvzTsIW25ZOTIko .node circle,#mermaid-svg-2QvzTsIW25ZOTIko .node ellipse,#mermaid-svg-2QvzTsIW25ZOTIko .node polygon,#mermaid-svg-2QvzTsIW25ZOTIko .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2QvzTsIW25ZOTIko .rough-node .label text,#mermaid-svg-2QvzTsIW25ZOTIko .node .label text,#mermaid-svg-2QvzTsIW25ZOTIko .image-shape .label,#mermaid-svg-2QvzTsIW25ZOTIko .icon-shape .label{text-anchor:middle;}#mermaid-svg-2QvzTsIW25ZOTIko .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2QvzTsIW25ZOTIko .rough-node .label,#mermaid-svg-2QvzTsIW25ZOTIko .node .label,#mermaid-svg-2QvzTsIW25ZOTIko .image-shape .label,#mermaid-svg-2QvzTsIW25ZOTIko .icon-shape .label{text-align:center;}#mermaid-svg-2QvzTsIW25ZOTIko .node.clickable{cursor:pointer;}#mermaid-svg-2QvzTsIW25ZOTIko .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2QvzTsIW25ZOTIko .arrowheadPath{fill:#333333;}#mermaid-svg-2QvzTsIW25ZOTIko .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2QvzTsIW25ZOTIko .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2QvzTsIW25ZOTIko .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2QvzTsIW25ZOTIko .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2QvzTsIW25ZOTIko .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2QvzTsIW25ZOTIko .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2QvzTsIW25ZOTIko .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2QvzTsIW25ZOTIko .cluster text{fill:#333;}#mermaid-svg-2QvzTsIW25ZOTIko .cluster span{color:#333;}#mermaid-svg-2QvzTsIW25ZOTIko div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2QvzTsIW25ZOTIko .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2QvzTsIW25ZOTIko rect.text{fill:none;stroke-width:0;}#mermaid-svg-2QvzTsIW25ZOTIko .icon-shape,#mermaid-svg-2QvzTsIW25ZOTIko .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2QvzTsIW25ZOTIko .icon-shape p,#mermaid-svg-2QvzTsIW25ZOTIko .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2QvzTsIW25ZOTIko .icon-shape .label rect,#mermaid-svg-2QvzTsIW25ZOTIko .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2QvzTsIW25ZOTIko .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2QvzTsIW25ZOTIko .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2QvzTsIW25ZOTIko :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Input Array
Find Min and Max
Create Count Array
Count Frequencies
Scan Buckets Left to Right
Write Values Back

为什么计数排序不用比较?

它不是问:

text 复制代码
A 和 B 谁更小?

而是直接统计:

text 复制代码
每个值出现了多少次?

只要值域的位置本身就是有序的:

text 复制代码
min
min + 1
min + 2
...
max

按桶顺序恢复就自然有序。

负数怎样支持?

桶下标不能为负。

所以统一做偏移:

text 复制代码
bucketIndex = value - min

恢复:

text 复制代码
value = bucketIndex + min

一个很容易忽略的溢出点

不要直接:

java 复制代码
int range = max - min + 1;

如果:

text 复制代码
min = Integer.MIN_VALUE
max = Integer.MAX_VALUE

int 会在计算阶段溢出。

应该先提升为 long:

java 复制代码
long range = (long) max - min + 1;

完整代码:Code02_CountSort.java

java 复制代码
package class04;

import java.util.Arrays;
import java.util.Random;

/**
 * Day 12:计数排序。
 *
 * 适用条件:
 * 数据是整数,并且数值范围相对紧凑。
 */
public class Code02_CountSort {

    private static final Random RANDOM = new Random();

    public static void main(String[] args) {
        final int testTimes = 10_000;
        final int maxSize = 100;
        final int minValue = -500;
        final int maxValue = 500;

        for (int test = 0; test < testTimes; test++) {
            int[] arr = randomArray(
                    maxSize,
                    minValue,
                    maxValue);

            int[] expected = Arrays.copyOf(arr, arr.length);
            int[] origin = Arrays.copyOf(arr, arr.length);

            countSort(arr);
            Arrays.sort(expected);

            if (!Arrays.equals(arr, expected)) {
                System.out.println(
                        "origin   = " + Arrays.toString(origin));
                System.out.println(
                        "result   = " + Arrays.toString(arr));
                System.out.println(
                        "expected = " + Arrays.toString(expected));

                throw new AssertionError("count sort failed");
            }
        }

        System.out.println("Accept");
    }

    /**
     * 计数排序。
     *
     * 设:
     * n     = 数组长度
     * range = max - min + 1
     *
     * 时间复杂度:O(n + range)
     * 额外空间复杂度:O(range)
     */
    public static void countSort(int[] arr) {
        if (arr == null || arr.length < 2) {
            return;
        }

        int min = arr[0];
        int max = arr[0];

        // 先确定真实值域。
        for (int i = 1; i < arr.length; i++) {
            min = Math.min(min, arr[i]);
            max = Math.max(max, arr[i]);
        }

        // 必须先转 long,避免 max - min 在 int 中溢出。
        long range = (long) max - min + 1;

        // Java 数组下标使用 int。
        // 如果值域连 int 长度都无法表示,计数排序显然不适合。
        if (range > Integer.MAX_VALUE) {
            throw new IllegalArgumentException(
                    "value range is too large for counting sort: "
                            + range);
        }

        int[] count = new int[(int) range];

        // value - min 把真实数值映射到从 0 开始的桶下标。
        for (int value : arr) {
            int bucket = (int) ((long) value - min);
            count[bucket]++;
        }

        int write = 0;

        // 按桶下标从小到大恢复原数组。
        for (int bucket = 0; bucket < count.length; bucket++) {
            int value = (int) ((long) min + bucket);

            for (int times = count[bucket]; times > 0; times--) {
                arr[write++] = value;
            }
        }
    }

    private static int[] randomArray(
            int maxSize,
            int minValue,
            int maxValue) {

        int size = RANDOM.nextInt(maxSize + 1);
        int[] arr = new int[size];

        int bound = maxValue - minValue + 1;

        for (int i = 0; i < size; i++) {
            arr[i] = minValue + RANDOM.nextInt(bound);
        }

        return arr;
    }
}

计数排序的适用边界

假设:

text 复制代码
n = 100
range = 1_000_000_000

虽然代码逻辑上仍然能描述这个范围,但为了 100 个数据创建十亿级桶数组完全不划算。

所以计数排序真正适合:

text 复制代码
数据量较大
+
值域相对紧凑

复杂度

设:

text 复制代码
range = max - min + 1

那么:

  • 扫描 min / max:O(n)
  • 统计:O(n)
  • 扫桶恢复:O(range + n)
  • 总时间复杂度:O(n + range)
  • 额外空间复杂度:O(range)

关于稳定性

对于单纯的 int

text 复制代码
相等的 5

彼此没有可观察身份,因此稳定性没有实际区别。

如果排序对象是:

text 复制代码
(score=90, id=A)
(score=90, id=B)

并要求 A、B 保持原相对顺序,就需要:

text 复制代码
前缀和
+
辅助数组
+
稳定回填

这正好是下一题基数排序每一轮会使用的技术。


答案 6:基数排序中的稳定计数排序

[返回练习 6](#返回练习 6)

为什么 LSD 要求每一轮稳定?

还是看:

text 复制代码
[13, 21, 12, 11]

先按个位稳定排序:

text 复制代码
21, 11, 12, 13

个位为 1 的:

text 复制代码
21
11

仍保持原来的相对顺序。

然后按十位排序。

十位为 1 的:

text 复制代码
11
12
13

它们之间的低位顺序仍然保留,因此这一组内部已经正确。

一轮稳定计数排序分四步

假设当前处理个位。

第 1 步:统计 digit
text 复制代码
digit 0 ... 9

各出现多少次。

第 2 步:计算前缀和

原始:

text 复制代码
count[d] = digit == d 的数量

累加后:

text 复制代码
count[d] = digit <= d 的数量
第 3 步:从右向左回填

如果:

text 复制代码
count[d] = 7

那么 digit=d 当前最后一个元素的目标位置是:

text 复制代码
index = 6

代码:

java 复制代码
help[--count[d]] = value;

为什么从右向左?

为了让后出现的同 digit 元素先占据更右位置,从而保持原始相对顺序。

第 4 步:复制回原数组
java 复制代码
System.arraycopy(help, 0, arr, 0, arr.length);

#mermaid-svg-kx9vxQtlXJHxjyqc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kx9vxQtlXJHxjyqc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kx9vxQtlXJHxjyqc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kx9vxQtlXJHxjyqc .error-icon{fill:#552222;}#mermaid-svg-kx9vxQtlXJHxjyqc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kx9vxQtlXJHxjyqc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kx9vxQtlXJHxjyqc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kx9vxQtlXJHxjyqc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kx9vxQtlXJHxjyqc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kx9vxQtlXJHxjyqc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kx9vxQtlXJHxjyqc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kx9vxQtlXJHxjyqc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kx9vxQtlXJHxjyqc .marker.cross{stroke:#333333;}#mermaid-svg-kx9vxQtlXJHxjyqc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kx9vxQtlXJHxjyqc p{margin:0;}#mermaid-svg-kx9vxQtlXJHxjyqc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kx9vxQtlXJHxjyqc .cluster-label text{fill:#333;}#mermaid-svg-kx9vxQtlXJHxjyqc .cluster-label span{color:#333;}#mermaid-svg-kx9vxQtlXJHxjyqc .cluster-label span p{background-color:transparent;}#mermaid-svg-kx9vxQtlXJHxjyqc .label text,#mermaid-svg-kx9vxQtlXJHxjyqc span{fill:#333;color:#333;}#mermaid-svg-kx9vxQtlXJHxjyqc .node rect,#mermaid-svg-kx9vxQtlXJHxjyqc .node circle,#mermaid-svg-kx9vxQtlXJHxjyqc .node ellipse,#mermaid-svg-kx9vxQtlXJHxjyqc .node polygon,#mermaid-svg-kx9vxQtlXJHxjyqc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kx9vxQtlXJHxjyqc .rough-node .label text,#mermaid-svg-kx9vxQtlXJHxjyqc .node .label text,#mermaid-svg-kx9vxQtlXJHxjyqc .image-shape .label,#mermaid-svg-kx9vxQtlXJHxjyqc .icon-shape .label{text-anchor:middle;}#mermaid-svg-kx9vxQtlXJHxjyqc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kx9vxQtlXJHxjyqc .rough-node .label,#mermaid-svg-kx9vxQtlXJHxjyqc .node .label,#mermaid-svg-kx9vxQtlXJHxjyqc .image-shape .label,#mermaid-svg-kx9vxQtlXJHxjyqc .icon-shape .label{text-align:center;}#mermaid-svg-kx9vxQtlXJHxjyqc .node.clickable{cursor:pointer;}#mermaid-svg-kx9vxQtlXJHxjyqc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kx9vxQtlXJHxjyqc .arrowheadPath{fill:#333333;}#mermaid-svg-kx9vxQtlXJHxjyqc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kx9vxQtlXJHxjyqc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kx9vxQtlXJHxjyqc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kx9vxQtlXJHxjyqc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kx9vxQtlXJHxjyqc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kx9vxQtlXJHxjyqc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kx9vxQtlXJHxjyqc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kx9vxQtlXJHxjyqc .cluster text{fill:#333;}#mermaid-svg-kx9vxQtlXJHxjyqc .cluster span{color:#333;}#mermaid-svg-kx9vxQtlXJHxjyqc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kx9vxQtlXJHxjyqc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kx9vxQtlXJHxjyqc rect.text{fill:none;stroke-width:0;}#mermaid-svg-kx9vxQtlXJHxjyqc .icon-shape,#mermaid-svg-kx9vxQtlXJHxjyqc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kx9vxQtlXJHxjyqc .icon-shape p,#mermaid-svg-kx9vxQtlXJHxjyqc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kx9vxQtlXJHxjyqc .icon-shape .label rect,#mermaid-svg-kx9vxQtlXJHxjyqc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kx9vxQtlXJHxjyqc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kx9vxQtlXJHxjyqc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kx9vxQtlXJHxjyqc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Count Current Digits
Build Prefix Sums
Scan Input Right to Left
Place into Help Array
Copy Help Back
Next Digit

稳定性的本质

每一轮结束后:

text 复制代码
当前位已经有序
+
之前更低位的相对顺序仍被保留

因此处理完最高有效位后:

text 复制代码
所有位共同决定的整数顺序

就正确了。


答案 7:完整十进制基数排序与负数支持

[返回练习 7](#返回练习 7)

19 个桶

十进制有符号 digit:

text 复制代码
-9, -8, ..., -1, 0, 1, ..., 8, 9

共:

text 复制代码
19

个可能值。

统一平移:

text 复制代码
bucket = digit + 9

得到:

text 复制代码
digit  -9 ... 0 ... 9
bucket  0 ... 9 ... 18

为什么这种做法仍然成立?

LSD 的关键不是"必须恰好有 10 个桶",而是:

text 复制代码
当前 digit 能按正确大小顺序稳定排序

有符号 digit 的自然顺序就是:

text 复制代码
-9 < -8 < ... < -1 < 0 < 1 < ... < 9

所以 19 桶一样可以逐位稳定排序。

Integer.MIN_VALUE 的坑

不要:

java 复制代码
Math.abs(value)

如果:

text 复制代码
value = Integer.MIN_VALUE

它的绝对值:

text 复制代码
2147483648

超出 int 正数上限。

正确写法:

java 复制代码
Math.abs((long) value)

完整版:Code03_RadixSort.java

java 复制代码
package class04;

import java.util.Arrays;
import java.util.Random;

/**
 * Day 12:十进制 LSD 基数排序。
 *
 * 这一版支持整个 Java int 范围,包括负数和 Integer.MIN_VALUE。
 */
public class Code03_RadixSort {

    private static final Random RANDOM = new Random();

    // 十进制有符号 digit 范围是 -9...9。
    private static final int BUCKETS = 19;
    private static final int OFFSET = 9;

    public static void main(String[] args) {
        final int testTimes = 20_000;
        final int maxSize = 100;

        for (int test = 0; test < testTimes; test++) {
            int[] arr = randomArray(maxSize);
            int[] expected = Arrays.copyOf(arr, arr.length);
            int[] origin = Arrays.copyOf(arr, arr.length);

            radixSort(arr);
            Arrays.sort(expected);

            if (!Arrays.equals(arr, expected)) {
                System.out.println(
                        "origin   = " + Arrays.toString(origin));
                System.out.println(
                        "result   = " + Arrays.toString(arr));
                System.out.println(
                        "expected = " + Arrays.toString(expected));

                throw new AssertionError("radix sort failed");
            }
        }

        System.out.println("Accept");
    }

    /**
     * 十进制 LSD 基数排序。
     *
     * 设 d 为最大绝对值的十进制位数。
     *
     * 时间复杂度:O(d * n)
     * 额外空间复杂度:O(n + 19)
     *
     * 对 Java int 来说 d 最多为 10。
     */
    public static void radixSort(int[] arr) {
        if (arr == null || arr.length < 2) {
            return;
        }

        long maxAbs = 0;

        // 先转 long 再 abs,安全覆盖 Integer.MIN_VALUE。
        for (int value : arr) {
            maxAbs = Math.max(
                    maxAbs,
                    Math.abs((long) value));
        }

        // 全部为 0。
        if (maxAbs == 0) {
            return;
        }

        int[] help = new int[arr.length];

        // divisor = 1, 10, 100, ...
        for (long divisor = 1;
             maxAbs / divisor > 0;
             divisor *= 10) {

            stableSortByDigit(arr, help, divisor);
        }
    }

    /**
     * 按当前十进制位做一次稳定计数排序。
     */
    private static void stableSortByDigit(
            int[] arr,
            int[] help,
            long divisor) {

        int[] count = new int[BUCKETS];

        // 1. 统计当前 digit 的出现次数。
        for (int value : arr) {
            int digit = digitOf(value, divisor);
            count[digit + OFFSET]++;
        }

        // 2. 前缀和:
        // count[i] 表示 bucket <= i 的元素总数。
        for (int i = 1; i < count.length; i++) {
            count[i] += count[i - 1];
        }

        // 3. 从右向左稳定回填。
        for (int i = arr.length - 1; i >= 0; i--) {
            int value = arr[i];
            int digit = digitOf(value, divisor);
            int bucket = digit + OFFSET;

            help[--count[bucket]] = value;
        }

        // 4. 当前位排序完成,复制回原数组。
        System.arraycopy(
                help,
                0,
                arr,
                0,
                arr.length);
    }

    /**
     * 获取指定十进制位。
     *
     * 例如:
     * digitOf( 123, 1)   =  3
     * digitOf( 123, 10)  =  2
     * digitOf(-123, 10)  = -2
     */
    private static int digitOf(
            int value,
            long divisor) {

        return (int) ((value / divisor) % 10);
    }

    private static int[] randomArray(int maxSize) {
        int size = RANDOM.nextInt(maxSize + 1);
        int[] arr = new int[size];

        for (int i = 0; i < size; i++) {
            // nextInt() 可以覆盖整个 int 范围。
            arr[i] = RANDOM.nextInt();
        }

        return arr;
    }
}

复杂度

设:

text 复制代码
d = 最大绝对值的十进制位数

每一轮:

text 复制代码
统计 O(n)
前缀和 O(19)
稳定回填 O(n)
复制 O(n)

总时间:

text 复制代码
O(d * n)

对 32 位 Java int

text 复制代码
d <= 10

所以在固定整数位宽模型下,经常可以近似理解为线性时间。

额外空间:

text 复制代码
help: O(n)
count: O(19)

总计:

text 复制代码
O(n)

第三部分:把 Trie 的四个操作串起来理解

1. 所有操作本质上都在"走路径"

#mermaid-svg-2VffbelvNH6IxVUL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2VffbelvNH6IxVUL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2VffbelvNH6IxVUL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2VffbelvNH6IxVUL .error-icon{fill:#552222;}#mermaid-svg-2VffbelvNH6IxVUL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2VffbelvNH6IxVUL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2VffbelvNH6IxVUL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2VffbelvNH6IxVUL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2VffbelvNH6IxVUL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2VffbelvNH6IxVUL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2VffbelvNH6IxVUL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2VffbelvNH6IxVUL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2VffbelvNH6IxVUL .marker.cross{stroke:#333333;}#mermaid-svg-2VffbelvNH6IxVUL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2VffbelvNH6IxVUL p{margin:0;}#mermaid-svg-2VffbelvNH6IxVUL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2VffbelvNH6IxVUL .cluster-label text{fill:#333;}#mermaid-svg-2VffbelvNH6IxVUL .cluster-label span{color:#333;}#mermaid-svg-2VffbelvNH6IxVUL .cluster-label span p{background-color:transparent;}#mermaid-svg-2VffbelvNH6IxVUL .label text,#mermaid-svg-2VffbelvNH6IxVUL span{fill:#333;color:#333;}#mermaid-svg-2VffbelvNH6IxVUL .node rect,#mermaid-svg-2VffbelvNH6IxVUL .node circle,#mermaid-svg-2VffbelvNH6IxVUL .node ellipse,#mermaid-svg-2VffbelvNH6IxVUL .node polygon,#mermaid-svg-2VffbelvNH6IxVUL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2VffbelvNH6IxVUL .rough-node .label text,#mermaid-svg-2VffbelvNH6IxVUL .node .label text,#mermaid-svg-2VffbelvNH6IxVUL .image-shape .label,#mermaid-svg-2VffbelvNH6IxVUL .icon-shape .label{text-anchor:middle;}#mermaid-svg-2VffbelvNH6IxVUL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2VffbelvNH6IxVUL .rough-node .label,#mermaid-svg-2VffbelvNH6IxVUL .node .label,#mermaid-svg-2VffbelvNH6IxVUL .image-shape .label,#mermaid-svg-2VffbelvNH6IxVUL .icon-shape .label{text-align:center;}#mermaid-svg-2VffbelvNH6IxVUL .node.clickable{cursor:pointer;}#mermaid-svg-2VffbelvNH6IxVUL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2VffbelvNH6IxVUL .arrowheadPath{fill:#333333;}#mermaid-svg-2VffbelvNH6IxVUL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2VffbelvNH6IxVUL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2VffbelvNH6IxVUL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2VffbelvNH6IxVUL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2VffbelvNH6IxVUL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2VffbelvNH6IxVUL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2VffbelvNH6IxVUL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2VffbelvNH6IxVUL .cluster text{fill:#333;}#mermaid-svg-2VffbelvNH6IxVUL .cluster span{color:#333;}#mermaid-svg-2VffbelvNH6IxVUL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2VffbelvNH6IxVUL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2VffbelvNH6IxVUL rect.text{fill:none;stroke-width:0;}#mermaid-svg-2VffbelvNH6IxVUL .icon-shape,#mermaid-svg-2VffbelvNH6IxVUL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2VffbelvNH6IxVUL .icon-shape p,#mermaid-svg-2VffbelvNH6IxVUL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2VffbelvNH6IxVUL .icon-shape .label rect,#mermaid-svg-2VffbelvNH6IxVUL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2VffbelvNH6IxVUL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2VffbelvNH6IxVUL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2VffbelvNH6IxVUL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Character Path
Add
Exact Search
Prefix Count
Delete
Prefix Enumeration

插入

text 复制代码
走一路
pass 加一路
最后 end++

精确查询

text 复制代码
路径不存在 -> 0
路径存在但 end == 0 -> 0
路径存在且 end > 0 -> 单词存在

前缀计数

text 复制代码
找到前缀末尾节点
直接返回 pass

删除

text 复制代码
先确认 end > 0
pass 减一路
出现 pass == 0 就剪枝
最后 end--

前缀枚举

text 复制代码
先找到前缀末尾节点
再 DFS 后续路径
每次递归都要回溯 StringBuilder

2. pass / end 是 Trie 的两个不变量

对任意有效节点:

text 复制代码
end <= pass

因为:

text 复制代码
在这里结束的单词
一定也经过这里

根节点:

text 复制代码
root.pass

等于当前 Trie 中所有单词的总次数。

某个前缀节点:

text 复制代码
node.pass

等于:

text 复制代码
以该前缀开头的单词总次数

某个完整单词末尾:

text 复制代码
node.end

等于:

text 复制代码
该完整单词出现次数

如果随机测试中出现:

text 复制代码
end > pass
pass < 0

几乎可以直接判断:

text 复制代码
插入 / 删除的计数逻辑出错了

第四部分:计数排序与基数排序的共同点

1. 它们都不是比较排序

比较排序关心:

text 复制代码
a < b ?

而今天两个排序关心:

计数排序

text 复制代码
value 属于哪个值桶?

基数排序

text 复制代码
当前 digit 属于哪个桶?

#mermaid-svg-ewCJAcvz2yoUq7cn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ewCJAcvz2yoUq7cn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ewCJAcvz2yoUq7cn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ewCJAcvz2yoUq7cn .error-icon{fill:#552222;}#mermaid-svg-ewCJAcvz2yoUq7cn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ewCJAcvz2yoUq7cn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ewCJAcvz2yoUq7cn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ewCJAcvz2yoUq7cn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ewCJAcvz2yoUq7cn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ewCJAcvz2yoUq7cn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ewCJAcvz2yoUq7cn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ewCJAcvz2yoUq7cn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ewCJAcvz2yoUq7cn .marker.cross{stroke:#333333;}#mermaid-svg-ewCJAcvz2yoUq7cn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ewCJAcvz2yoUq7cn p{margin:0;}#mermaid-svg-ewCJAcvz2yoUq7cn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ewCJAcvz2yoUq7cn .cluster-label text{fill:#333;}#mermaid-svg-ewCJAcvz2yoUq7cn .cluster-label span{color:#333;}#mermaid-svg-ewCJAcvz2yoUq7cn .cluster-label span p{background-color:transparent;}#mermaid-svg-ewCJAcvz2yoUq7cn .label text,#mermaid-svg-ewCJAcvz2yoUq7cn span{fill:#333;color:#333;}#mermaid-svg-ewCJAcvz2yoUq7cn .node rect,#mermaid-svg-ewCJAcvz2yoUq7cn .node circle,#mermaid-svg-ewCJAcvz2yoUq7cn .node ellipse,#mermaid-svg-ewCJAcvz2yoUq7cn .node polygon,#mermaid-svg-ewCJAcvz2yoUq7cn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ewCJAcvz2yoUq7cn .rough-node .label text,#mermaid-svg-ewCJAcvz2yoUq7cn .node .label text,#mermaid-svg-ewCJAcvz2yoUq7cn .image-shape .label,#mermaid-svg-ewCJAcvz2yoUq7cn .icon-shape .label{text-anchor:middle;}#mermaid-svg-ewCJAcvz2yoUq7cn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ewCJAcvz2yoUq7cn .rough-node .label,#mermaid-svg-ewCJAcvz2yoUq7cn .node .label,#mermaid-svg-ewCJAcvz2yoUq7cn .image-shape .label,#mermaid-svg-ewCJAcvz2yoUq7cn .icon-shape .label{text-align:center;}#mermaid-svg-ewCJAcvz2yoUq7cn .node.clickable{cursor:pointer;}#mermaid-svg-ewCJAcvz2yoUq7cn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ewCJAcvz2yoUq7cn .arrowheadPath{fill:#333333;}#mermaid-svg-ewCJAcvz2yoUq7cn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ewCJAcvz2yoUq7cn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ewCJAcvz2yoUq7cn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ewCJAcvz2yoUq7cn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ewCJAcvz2yoUq7cn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ewCJAcvz2yoUq7cn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ewCJAcvz2yoUq7cn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ewCJAcvz2yoUq7cn .cluster text{fill:#333;}#mermaid-svg-ewCJAcvz2yoUq7cn .cluster span{color:#333;}#mermaid-svg-ewCJAcvz2yoUq7cn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ewCJAcvz2yoUq7cn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ewCJAcvz2yoUq7cn rect.text{fill:none;stroke-width:0;}#mermaid-svg-ewCJAcvz2yoUq7cn .icon-shape,#mermaid-svg-ewCJAcvz2yoUq7cn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ewCJAcvz2yoUq7cn .icon-shape p,#mermaid-svg-ewCJAcvz2yoUq7cn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ewCJAcvz2yoUq7cn .icon-shape .label rect,#mermaid-svg-ewCJAcvz2yoUq7cn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ewCJAcvz2yoUq7cn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ewCJAcvz2yoUq7cn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ewCJAcvz2yoUq7cn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Non Comparison Sort
Map Data to Buckets
Count or Group
Read Buckets in Order
Obtain Ordered Result


2. 两者的"桶"含义不同

算法 一个桶表示什么 桶数量
计数排序 一个完整数值 max - min + 1
十进制基数排序 当前一位 digit 固定 19 个(本文有符号版本)

计数排序一次就把完整值区分开。

基数排序每一轮只知道:

text 复制代码
个位
十位
百位
...

所以必须进行多轮。


3. 为什么计数排序怕大值域,基数排序没那么怕?

数组:

text 复制代码
[1, 1_000_000_000]

计数排序如果按完整值建桶:

text 复制代码
需要约十亿个位置

非常浪费。

十进制基数排序每一轮只需要固定桶数:

text 复制代码
19

只是多处理若干数位。

这就是两种"用空间换比较"的方式之间最大的差异之一。


第五部分:排序稳定性再理解一次

什么叫稳定?

假设排序对象:

text 复制代码
(90, Alice)
(80, Bob)
(90, Carol)

只按分数排序。

稳定排序后,两个 90 分仍保持:

text 复制代码
Alice 在 Carol 前面

不稳定排序则不保证。

为什么基数排序特别依赖稳定性?

低位先排后:

text 复制代码
低位顺序已经包含信息

高位排序时如果把相同高位的元素随意打乱:

text 复制代码
之前低位排好的信息就丢了

所以:

LSD 基数排序的正确性建立在"每一轮按 digit 稳定排序"之上。


第六部分:建议测试用例

今天的 bug 很多都不是"主干算法不会写",而是藏在重复值、空输入、兄弟分支、负数和极值里。

Trie 插入与查询

text 复制代码
1. null
2. 空字符串
3. 单字符 "a"
4. "app" 与 "apple"
5. 只插入 "apple",查询 "app"
6. 重复插入 "app" 三次
7. 查询完全不存在的路径
8. 多个单词共享长前缀

Trie 删除

text 复制代码
1. 删除不存在单词
2. 删除路径存在但 end == 0 的前缀
3. 删除唯一单词,整条分支应剪掉
4. 删除共享前缀中的长单词
5. 删除共享前缀中的短单词
6. 重复单词只删除一次
7. 连续删到最后一次才真正剪枝
8. 删除后重新 add

Trie 前缀枚举

text 复制代码
1. 前缀不存在
2. 前缀本身就是完整单词
3. 前缀下面只有一条链
4. 前缀下面有多个兄弟分支
5. 深层分叉,重点检查 StringBuilder 回溯
6. 重复单词是否重复输出
7. Array 版与 Map 版结果顺序是否一致

计数排序

text 复制代码
1. []
2. [1]
3. [2, 1]
4. 全部相同
5. 全部正数
6. 全部负数
7. 正负混合
8. min != 0
9. 大量重复值
10. 与 Arrays.sort 随机对拍

基数排序

text 复制代码
1. []
2. [0]
3. 全部为 0
4. 单位数
5. 不同位数混合
6. 全部正数
7. 全部负数
8. 正负混合
9. Integer.MIN_VALUE
10. Integer.MAX_VALUE
11. 重复值
12. 与 Arrays.sort 随机对拍整个 int 范围

第七部分:今天的对数器为什么特别重要?

1. 不要让"测试代码"修改两次被测状态

这是数据结构随机测试里非常典型的问题。

错误思路:

text 复制代码
在一个 if 条件中重复调用 delete

第一次调用后:

text 复制代码
Trie 已经变化

第二次调用测试的已经不是同一个现场。

正确做法:

java 复制代码
boolean ans1 = trie1.delete(word);
boolean ans2 = trie2.delete(word);
boolean ans3 = reference.delete(word);

if (ans1 != ans2 || ans1 != ans3) {
    // fail
}

原则:

有副作用的方法,测试时先调用一次并保存结果。


2. 三个版本一致,失败条件应该用 OR

想保证:

text 复制代码
a == b
并且
a == c

失败条件就是:

text 复制代码
a != b
或者
a != c

代码:

java 复制代码
if (a != b || a != c) {
    throw new AssertionError();
}

如果写成 AND:

java 复制代码
a != b && a != c

就可能漏掉:

text 复制代码
a == b
但 a != c

这种错误。


3. 列表对拍先统一"顺序语义"

如果题目只要求:

text 复制代码
包含相同元素

但不要求顺序,那么比较前可以:

java 复制代码
Collections.sort(...)

如果你的 API 明确承诺:

text 复制代码
字典序输出

那就让所有实现都真正产生同样的字典序。

本文选择:

text 复制代码
TrieArray:0...25 DFS
TrieMap  :TreeMap DFS
Reference:结果 Collections.sort

让顺序语义完全一致。


第八部分:复杂度总表

操作 / 算法 时间复杂度 额外空间 关键条件
Trie add O(L) 新节点 O(L) 最坏 字符串长度 L
Trie count O(L) O(1) 路径查询
Trie prefixCount O(1) 前缀长度 P
Trie delete O(L) O(1) 先确认完整单词存在
Trie 前缀枚举 O(P + 输出总字符数) 递归栈 必须回溯
计数排序 O(n + range) O(range) 值域紧凑
基数排序 O(dn) O(n) 每轮稳定

其中:

text 复制代码
L     = 单词长度
P     = 前缀长度
range = max - min + 1
d     = 最大绝对值的十进制位数

今日自测

1. Trie 中 passend 分别表示什么?

查看答案

pass 表示有多少次有效插入经过当前节点;end 表示有多少次有效插入恰好在当前节点结束。前缀计数读取 pass,完整单词出现次数读取 end。

2. 为什么路径存在不能证明完整单词存在?

查看答案

某个字符串可能只是更长单词的前缀。例如只插入 apple 后,app 的路径存在,但 app 节点的 end 仍为 0,因此 app 并没有作为完整单词被插入。

3. 删除 Trie 单词前为什么必须先确认 end > 0

查看答案

如果完整单词不存在却直接减少 pass,会破坏共享路径上的统计信息,甚至造成负数或错误剪枝。路径存在只说明它可能是其他单词的前缀。

4. Trie 删除时 pass 变成 0 为什么可以直接剪枝?

查看答案

pass 为 0 表示已经没有任何有效单词经过该节点,因此整棵后续子树都不会再被查询命中。父节点断开引用后即可直接结束删除。

5. 前缀 DFS 为什么一定要回溯 StringBuilder

查看答案

因为多个递归分支共享同一个可变 StringBuilder。一个孩子递归结束后如果不恢复原长度,下一个兄弟分支会在前一个分支的字符串后继续追加,造成路径串联错误。

6. 计数排序为什么要使用 value - min 作为桶下标?

查看答案

这样可以把真实值域 min, max 平移到从 0 开始的连续数组下标 0, max-min,因此能够自然支持负数。

7. 为什么 max - min + 1 最好先用 long 计算?

查看答案

当 min 和 max 接近 int 两端极值时,它们的差可能超过 int 正数上限。如果先按 int 做减法会在得到 long 之前就已经溢出。

8. LSD 基数排序为什么要求每一轮稳定?

查看答案

低位排序产生的相对顺序是后续高位排序需要保留的信息。如果高位排序不稳定,相同高位的元素会被重新打乱,之前排好的低位顺序就会丢失。

9. 为什么稳定计数回填通常从右向左扫描?

查看答案

前缀和给出的是每个桶当前的右边界。右向左扫描时,后出现的同桶元素先占据更右的位置,先出现的元素随后落到更左位置,因此保持原相对顺序。

10. 为什么原来的十进制 10 桶写法不能直接处理负数?

查看答案

Java 对负整数取某一十进制位时可能得到 -9 到 -1,无法直接作为长度为 10 的数组下标。本文把有符号 digit 的 -9...9 平移到 0...18,因此使用 19 个桶。

11. 为什么不能直接写 Math.abs(Integer.MIN_VALUE)

查看答案

Integer.MIN_VALUE 的绝对值是 2147483648,超过 int 最大正数 2147483647,所以在 int 中仍然溢出。应先转 long,再执行 Math.abs。

12. 为什么数据结构对拍时不能重复调用 delete() 来比较结果?

查看答案

delete 有副作用。第一次调用已经修改了 Trie,第二次调用面对的是另一个状态。应该每个实现只调用一次,把返回值保存后再比较。


加餐挑战

  1. TrieArray 增加 totalWords(),要求 O(1) 返回当前单词总次数;
  2. 增加 distinctWords(),思考需要在什么时机修改"不同单词数量";
  3. 支持空字符串作为合法单词,重新设计 root 的 end 语义;
  4. 给 Trie 实现 longestPrefixOf(text):找出 Trie 中是 text 前缀的最长完整单词;
  5. 实现自动补全:返回某前缀下字典序最小的前 K 个单词;
  6. TrieMapchar 扩展思路继续升级,思考 Unicode code point 与 Java char 的区别;
  7. 写"稳定版计数排序",把 (key, id) 对象按 key 排序并保持 id 相对顺序;
  8. 推导计数排序在什么 range / n 比例下开始明显浪费空间;
  9. 把基数从十进制改成 256,每次处理 1 个 byte;
  10. 尝试用 4 轮 byte radix sort 排完整 32 位 int,并思考最高位的符号处理;
  11. 为 Trie 随机对拍加入固定随机种子,使失败现场可稳定复现;
  12. 给每次随机操作打印操作日志,失败时回放整段操作序列。

挑战 2 提示

当插入前 count(word) == 0 时,不同单词数量加 1;删除前 count(word) == 1 时,本次删除后该完整单词将彻底消失,不同单词数量减 1。
挑战 7 提示

先对 count 做前缀和,再从原对象数组右向左扫描,用 help[--count[bucket]] = object 稳定回填,最后复制回原数组。


Day 12 复盘清单

  • 我能准确解释 Trie 节点的 pass / end
  • 我知道"路径存在"和"完整单词存在"不是一回事
  • 我能不看答案写出 Trie 的 add
  • 我能用 end O(L) 得到完整单词出现次数
  • 我能用 pass O§ 得到前缀数量
  • 我知道 Trie 删除前必须先确认完整单词存在
  • 我知道 pass == 0 为什么可以直接剪枝
  • 我能写出前缀枚举 DFS
  • 我会在 DFS 后恢复 StringBuilder 长度
  • 我知道固定字符集适合数组孩子,通用字符集适合 Map
  • 我知道 HashMap 的遍历顺序不能作为字典序承诺
  • 我能手写支持负数偏移的计数排序
  • 我会用 long 计算 max - min + 1
  • 我知道计数排序真正受制于值域大小
  • 我能解释 LSD 基数排序为什么从低位开始
  • 我能解释为什么每轮 digit 排序必须稳定
  • 我知道前缀和在稳定计数排序中的含义
  • 我知道为什么稳定回填要从右向左
  • 我能处理基数排序中的负数 digit
  • 我知道 Integer.MIN_VALUE 的绝对值陷阱
  • 我不会在对数器中重复调用有副作用的方法
  • 我会用 a != b || a != c 检查三方结果一致性
  • 我会让列表对拍先统一顺序语义
  • 我能说清计数排序与基数排序各自依赖的数据条件

一句话总结: Day 12 的核心是"把结构信息提前存下来、把排序条件主动利用起来":Trie 用 pass / end 把字符串集合的共享前缀变成可查询状态;计数排序把完整值映射到桶;基数排序把整数拆成一位一位的稳定桶排序。真正决定代码是否可靠的,则是那些容易被忽略的不变量------删除不能破坏计数、DFS 必须恢复现场、稳定排序必须保留旧顺序、测试代码本身也不能偷偷改变被测状态。

相关推荐
额额额对了1 小时前
数据结构:二叉树
c语言·数据结构·算法
iNeuOS工业互联网2 小时前
iNeuOS_Vision_视觉分析,增加SAM3模型对实例分割和目标检测AI自动标注图片样本功能
人工智能·算法·目标检测
qeen872 小时前
【数据结构】红黑树的算法原理解析与实现
开发语言·数据结构·c++·算法·红黑树
码匠许师傅2 小时前
【C++ 面试真题】20. 聊聊 C++ 的标准库常用算法
c++·算法·面试
m0_547486663 小时前
《数据结构与算法(Python语言版)》全套PPT课件2026
数据结构·算法
个 人 练 习 生3 小时前
数据结构入门:算法复杂度
开发语言·数据结构·经验分享·学习·程序人生·算法
Ricardo-Yang3 小时前
无人机单目深度估计测试:ZipDepth 与 AerialMetric
人工智能·算法·机器学习·计算机视觉·无人机
Nil2083 小时前
golang解决单词转换
算法
imaol14 小时前
文件编程--标准IO
linux·运维·算法