百日算法修炼 · Day 12
主题:前缀树 Trie、pass / end 计数、删除剪枝、DFS 回溯、计数排序、基数排序、排序稳定性
语言:Java | 建议用时:120~150 分钟
阅读方式
本文继续采用"先练习、后答案"的方式:
- 上半篇是独立练习区:只给题目、示例、思考问题和可折叠提示;
- 下半篇是参考答案区:包含完整优化代码、过程图解、复杂度分析、原代码改进点与易错点;
- 建议先独立完成 7 个练习,再通过"查看参考答案"链接逐题核对;
- 如果某一题卡住,先只展开"提示 1",不要急着直接看完整代码。
今天真正要建立的不是"记住 Trie 和两个排序模板",而是三个底层意识:
- 路径本身也可以携带统计信息 :Trie 的
pass / end让"出现次数"和"前缀数量"都变成 O(L) 查询;- 递归搜索一定要维护现场:DFS 进入分支前修改路径,离开分支后必须恢复路径;
- 非比较排序靠的是数据特征:计数排序依赖紧凑值域,基数排序依赖可拆分的数位,而不是"无条件比 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 节点为什么需要 pass 和 end?
向 Trie 中依次加入:
text
"app"
"apple"
"app"
"ape"
可以把共享前缀画成:
text
(root)
|
a
|
p
/ \
p e
|
l
|
e
现在假设每个节点额外记录:
text
pass:有多少个加入过的单词经过该节点
end :有多少个加入过的单词恰好在该节点结束
请回答:
- 根节点的
pass是多少? - 路径
"a" -> "p"对应节点的pass是多少? "app"最后一个p节点的end是多少?"apple"最后一个e节点的end是多少?- 如果只知道
"app"路径存在,能否直接断言"app"这个单词被加入过?
思考问题
- 为什么 Trie 节点不能只存"有没有孩子"?
pass与"有多少单词以当前路径为前缀"有什么关系?end与"当前完整单词出现多少次"有什么关系?- 重复插入同一个单词时,为什么
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" 开头。
思考问题
add时根节点的pass要不要增加?- 每走到一个字符节点后,哪个计数器要增加?
- 只有什么时候才增加
end? - 查询
"app"时,如果路径存在但end == 0,应该返回什么? 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,意味着什么?
思考问题
- 删除前为什么必须先确认这个单词确实存在?
- 根节点的
pass是否也要减少? - 某个节点
pass == 0后,这个节点以下还有可能被别的有效单词使用吗? - 一旦把父节点到这个子节点的引用断开,还需要继续向下修改后续节点吗?
- 删除重复单词时,什么时候才能真正剪掉路径?
提示 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 枚举它的所有后代路径。
思考问题
- DFS 进入一个孩子前,要对当前字符串做什么?
- DFS 返回父节点后,要恢复什么状态?
- 如果不恢复
StringBuilder,访问第二个兄弟分支时会发生什么? - 为什么
StringBuilder比每层都创建新字符串更适合这个场景? - 如果一个单词被插入了 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
请实现计数排序。
思考问题
- 为什么需要先找
min / max? - 有负数时,为什么不能直接写
count[arr[i]]++? count[i]对应的原始值是什么?- 计数排序的复杂度为什么通常写成 O(n + range)?
- 如果只有 10 个数,但值域从
Integer.MIN_VALUE到Integer.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
请思考:
为什么"低位先排 + 每一轮稳定",最终能够得到整体有序?
思考问题
- LSD(Least Significant Digit)为什么从最低位开始?
- 当前轮处理高一位时,之前已经排好的低位信息怎样才能保留下来?
- 计数数组从"出现次数"变成"前缀和"后,表示什么?
- 为什么回填辅助数组时通常从右向左扫描原数组?
- 如果从左向右回填,会不会破坏稳定性?
提示 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] 的下标。
请尝试设计一种仍然保持"十进制逐位稳定排序"的方案。
思考问题
- 一位十进制数的有符号 digit 可能落在哪个范围?
- 能否把
-9...9平移成合法数组下标? - 为什么需要 19 个桶?
Integer.MIN_VALUE取绝对值时有什么危险?- 怎样安全计算数组中的最大绝对值,从而知道需要处理多少位?
提示 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
pass 和 end 的语义一定要分开
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:
add:O(L);count / contains:O(L);prefixCount:O(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...25DFS 天然得到字典序。
缺点:
- 每个节点固定预留 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§ | 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 中 pass 和 end 分别表示什么?
查看答案
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,第二次调用面对的是另一个状态。应该每个实现只调用一次,把返回值保存后再比较。
加餐挑战
- 给
TrieArray增加totalWords(),要求 O(1) 返回当前单词总次数; - 增加
distinctWords(),思考需要在什么时机修改"不同单词数量"; - 支持空字符串作为合法单词,重新设计 root 的
end语义; - 给 Trie 实现
longestPrefixOf(text):找出 Trie 中是text前缀的最长完整单词; - 实现自动补全:返回某前缀下字典序最小的前 K 个单词;
- 把
TrieMap的char扩展思路继续升级,思考 Unicode code point 与 Javachar的区别; - 写"稳定版计数排序",把
(key, id)对象按 key 排序并保持 id 相对顺序; - 推导计数排序在什么
range / n比例下开始明显浪费空间; - 把基数从十进制改成 256,每次处理 1 个 byte;
- 尝试用 4 轮 byte radix sort 排完整 32 位 int,并思考最高位的符号处理;
- 为 Trie 随机对拍加入固定随机种子,使失败现场可稳定复现;
- 给每次随机操作打印操作日志,失败时回放整段操作序列。
挑战 2 提示
当插入前 count(word) == 0 时,不同单词数量加 1;删除前 count(word) == 1 时,本次删除后该完整单词将彻底消失,不同单词数量减 1。
挑战 7 提示
先对 count 做前缀和,再从原对象数组右向左扫描,用 help[--count[bucket]] = object 稳定回填,最后复制回原数组。
Day 12 复盘清单
- 我能准确解释 Trie 节点的
pass / end - 我知道"路径存在"和"完整单词存在"不是一回事
- 我能不看答案写出 Trie 的
add - 我能用
endO(L) 得到完整单词出现次数 - 我能用
passO§ 得到前缀数量 - 我知道 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 必须恢复现场、稳定排序必须保留旧顺序、测试代码本身也不能偷偷改变被测状态。