From 032aa17400601c8e79c343f26e6f6099bcd0aa6a Mon Sep 17 00:00:00 2001 From: WEIREN FENG Date: Thu, 8 Oct 2026 17:03:17 -0700 Subject: [PATCH] Remove the stray spaces line breaks put into the Chinese copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSX trims each line of a text block and joins the lines with one space. The Chinese copy is wrapped across lines all over the course, so it rendered with spaces between Chinese characters and after full-width punctuation: 1434 of them on the fifteen Chinese pages ("算。 建议", "一章一个结构, 看见它"). - 1910 line breaks inside JSX text are joined where the site's convention has no space: between Han characters, after full-width punctuation or a Chinese ellipsis, before full-width punctuation, before "(" or a curly quote after Han, after half-width , : ; ? ! before Han, and after the ")" of a Chinese parenthetical. Breaks next to Latin letters, digits, expressions such as O(1), "——" and math operators keep their space, as the convention asks ("第 3 章", "O(1) 随机访问"). - 37 spaces written after full-width punctuation are removed: a leading space inside a bold label after a full stop ("。 暴力:"), one {" "}, and spaces after "……". Three spaces around bold terms in the graph chapter are removed to match the rest of the course. - Only whitespace changes, apart from the one {" "}: every edited file is identical to the original with whitespace stripped. The fifteen Chinese pages now show 7 such spaces, all intended (around formulas such as "地址 = 首地址", mapping arrows, a step label and the atlas title). test/unit/zh-copy-spacing.test.ts scans every TSX file with the same rules and fails on a new stray break or space; it fails on the previous tree. CLAUDE.md states the rule under the language convention. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 + app/advanced/page.tsx | 372 +++++++++--------------------- app/advanced/viz.tsx | 38 +-- app/array/page.tsx | 232 ++++++------------- app/array/viz.tsx | 6 +- app/atlas/page.tsx | 31 +-- app/binary-tree/page.tsx | 204 +++++----------- app/binary-tree/viz.tsx | 103 +++------ app/bst/page.tsx | 239 ++++++------------- app/bst/viz.tsx | 10 +- app/graph/page.tsx | 239 ++++++------------- app/graph/viz.tsx | 82 ++----- app/hash/page.tsx | 221 +++++------------- app/hash/viz.tsx | 31 +-- app/heap/page.tsx | 326 +++++++------------------- app/heap/viz.tsx | 26 +-- app/home-viz.tsx | 3 +- app/linked-list/page.tsx | 230 +++++------------- app/linked-list/viz.tsx | 67 ++---- app/page.tsx | 63 ++--- app/queue/page.tsx | 175 +++++--------- app/queue/viz.tsx | 28 +-- app/stack/page.tsx | 208 +++++------------ app/stack/viz.tsx | 21 +- app/string/page.tsx | 242 ++++++------------- app/string/viz.tsx | 15 +- app/trie/page.tsx | 239 ++++++------------- app/trie/viz.tsx | 13 +- app/union-find/page.tsx | 285 ++++++----------------- app/union-find/viz.tsx | 7 +- lib/advanced-problems.tsx | 34 +-- lib/advanced-quiz.tsx | 3 +- lib/array-problems.tsx | 50 ++-- lib/binary-tree-problems.tsx | 69 ++---- lib/binary-tree-quiz.tsx | 3 +- lib/bst-problems.tsx | 36 +-- lib/bst-quiz.tsx | 3 +- lib/graph-problems.tsx | 52 ++--- lib/graph-quiz.tsx | 3 +- lib/hash-problems.tsx | 54 ++--- lib/heap-problems.tsx | 55 +---- lib/linked-list-problems.tsx | 52 ++--- lib/queue-problems.tsx | 26 +-- lib/stack-problems.tsx | 47 +--- lib/string-problems.tsx | 32 +-- lib/trie-problems.tsx | 37 +-- lib/trie-quiz.tsx | 3 +- lib/union-find-problems.tsx | 49 +--- test/unit/zh-copy-spacing.test.ts | 127 ++++++++++ 49 files changed, 1360 insertions(+), 3137 deletions(-) create mode 100644 test/unit/zh-copy-spacing.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 903dc25..e61a882 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,12 @@ Read this file end to end before touching anything. with a 中文 toggle, so user-facing copy is authored as `{ en, zh }` pairs (type `Loc` from `lib/i18n.tsx`) — never English-only, never Chinese-only. - Chinese inside a `zh:` value is content, not a comment. Never "clean it up". +- Keep a Chinese sentence in JSX on one line, or break it only next to a tag or + a `{…}` expression. JSX joins the lines of a text block with a space, and + Chinese shows it as a stray gap ("算。 建议"). Spaces belong only between + Chinese and Latin letters, digits or expressions ("第 3 章", "O(1) 随机访问"), + around "——" and around math operators — never after full-width punctuation. + `test/unit/zh-copy-spacing.test.ts` fails on any other. ## What this is diff --git a/app/advanced/page.tsx b/app/advanced/page.tsx index c62cfcf..5e61bee 100644 --- a/app/advanced/page.tsx +++ b/app/advanced/page.tsx @@ -97,8 +97,7 @@ const F146: ArrayFrame[] = [ ), zh: ( <> - put(2,2):2:2 头插,1:1 被挤到尾部 —— 它现在是最久未使用的那个, - 也是下一个被淘汰的候选。 + put(2,2):2:2 头插,1:1 被挤到尾部 —— 它现在是最久未使用的那个,也是下一个被淘汰的候选。 ), }, @@ -143,8 +142,7 @@ const F146: ArrayFrame[] = [ ), zh: ( <> - put(3,3):容量已满,尾部的 2:2 被淘汰 —— 链表 O(1) 摘除, - 再用节点里存的 key 在哈希表里 O(1) 删掉条目,然后 3:3 头插。 + put(3,3):容量已满,尾部的 2:2 被淘汰 —— 链表 O(1) 摘除,再用节点里存的 key 在哈希表里 O(1) 删掉条目,然后 3:3 头插。 ), }, @@ -333,8 +331,7 @@ const F307: ArrayFrame[] = [ ), zh: ( <> - tree[6] 管 [5,6]:s = 2+14 = 16;6−lowbit(6) = 6−2 = 4。 - 注意各段首尾相接、互不重叠 —— 所以直接相加就是答案。 + tree[6] 管 [5,6]:s = 2+14 = 16;6−lowbit(6) = 6−2 = 4。注意各段首尾相接、互不重叠 —— 所以直接相加就是答案。 ), }, @@ -353,8 +350,7 @@ const F307: ArrayFrame[] = [ ), zh: ( <> - tree[4] 管 [1,4](已经含刚才的 +2):s = 16+11 = 27;4−4 = 0,停。 - 三段拼出前缀和 27 = 3+1+6+1+5+9+2 ✓ —— 拆的正是 7 = 4+2+1 的二进制。 + tree[4] 管 [1,4](已经含刚才的 +2):s = 16+11 = 27;4−4 = 0,停。三段拼出前缀和 27 = 3+1+6+1+5+9+2 ✓ —— 拆的正是 7 = 4+2+1 的二进制。 ), }, @@ -419,8 +415,7 @@ const F380: ArrayFrame[] = [ ), zh: ( <> - remove(8):哈希表查到 8 住在下标 1。数组中间删除通常要 O(n) 搬移后面的元素, - 但集合不在乎顺序 —— 直接把末尾的 3 抄过来盖掉 8。 + remove(8):哈希表查到 8 住在下标 1。数组中间删除通常要 O(n) 搬移后面的元素,但集合不在乎顺序 —— 直接把末尾的 3 抄过来盖掉 8。 ), }, @@ -480,8 +475,7 @@ const F380: ArrayFrame[] = [ zh: ( <> getRandom():在 [0, size) 里随机取一个下标,数组 O(1) 随机访问 —— - 每个元素被选中的概率恰好是 1/size。这就是必须有数组的原因: - 哈希表内部有空桶,做不到 O(1) 的等概率抽样。 + 每个元素被选中的概率恰好是 1/size。这就是必须有数组的原因:哈希表内部有空桶,做不到 O(1) 的等概率抽样。 ), }, @@ -533,8 +527,7 @@ export default function AdvancedChapter() { zh: ( <> 前 12 章学的是单个结构;这一章把它们拼成 - 能用的机器:LRU 缓存、线段树、跳表、布隆过滤器。 - 真实系统里跑着的很少是单个结构,而是两个结构互相补上对方的短板。 + 能用的机器:LRU 缓存、线段树、跳表、布隆过滤器。真实系统里跑着的很少是单个结构,而是两个结构互相补上对方的短板。 ), }} @@ -584,17 +577,14 @@ export default function AdvancedChapter() { zh={ <>

- 回头看这门课:数组(第 1 章)给你 O(1) 随机访问,链表(第 3 章) - 在拿到节点后给你 O(1) 摘除和插入,哈希表(第 6 章)给你 O(1) - 按 key 定位,堆(第 9 章)给你 O(log n) 取最值。 - 每个结构都只有一两招快,别的操作就慢。 + 回头看这门课:数组(第 1 章)给你 O(1) 随机访问,链表(第 3 章)在拿到节点后给你 O(1) 摘除和插入,哈希表(第 6 章)给你 O(1) + 按 key 定位,堆(第 9 章)给你 O(log n) 取最值。每个结构都只有一两招快,别的操作就慢。

真实需求却很少只要一招:「缓存要能 O(1) 读并且 O(1) 淘汰最久未用的」、「数组要能改并且能快速查区间和」—— 任何单一结构都会在其中一个操作上卡住。常见的解法不是发明新结构,而是 - 把两个结构拼在一起,让每个只干自己快的那件事。 - 本章的六台机器全部是这么拼出来的。 + 把两个结构拼在一起,让每个只干自己快的那件事。本章的六台机器全部是这么拼出来的。

} @@ -722,8 +712,7 @@ export default function AdvancedChapter() { } zh={ <> - 每次操作必须在同一步里把所有成员都改到位:删链表节点就必须删哈希条目。 - 丢掉这个同步,是组合结构最常见的 bug 来源。 + 每次操作必须在同一步里把所有成员都改到位:删链表节点就必须删哈希条目。丢掉这个同步,是组合结构最常见的 bug 来源。 } /> @@ -754,8 +743,7 @@ export default function AdvancedChapter() {

拿到「设计一个 XX」:第一步列出全部操作 (get/put/delete/random…);第二步给每个操作定 - 复杂度预算(题目要求 O(1) 还是 O(log n)?); - 第三步看单一结构在哪一步超预算, + 复杂度预算(题目要求 O(1) 还是 O(log n)?);第三步看单一结构在哪一步超预算, 再补一个结构专门负责那个操作。本章每一节都是这套方法的一次完整演练。

} @@ -817,23 +805,17 @@ export default function AdvancedChapter() { zh={ <>

- 先从需求说起。缓存(cache)是一块「快但小」的存储: - 内存比磁盘快得多,却装不下全部数据,所以只放最可能被再次用到的那部分。 - 小就带来一个绕不开的问题 ——{" "} + 先从需求说起。缓存(cache)是一块「快但小」的存储:内存比磁盘快得多,却装不下全部数据,所以只放最可能被再次用到的那部分。小就带来一个绕不开的问题 ——{" "} 满了以后,腾谁的位置?

经典答案:淘汰最久没被用过的那条数据,即{" "} - LRU(least recently used,最近最少使用)。 - 它之所以有效,是因为程序访问数据有 - 时间局部性(temporal locality): - 刚读过的数据很可能马上又要读,很久没碰的大概率短期内也用不上。 + LRU(least recently used,最近最少使用)。它之所以有效,是因为程序访问数据有 + 时间局部性(temporal locality):刚读过的数据很可能马上又要读,很久没碰的大概率短期内也用不上。

于是需求清单出炉,这正是 LC 146 的原题: - get(key) 读缓存、put(key, value) 写缓存, - 容量满时自动淘汰最久未用的那条 —— 并且两个操作都必须 O(1)。 - 缓存本身是为「快」而生的,如果缓存操作自己是 O(n),它就失去了意义。 + get(key) 读缓存、put(key, value) 写缓存,容量满时自动淘汰最久未用的那条 —— 并且两个操作都必须 O(1)。缓存本身是为「快」而生的,如果缓存操作自己是 O(n),它就失去了意义。

} @@ -904,8 +886,7 @@ export default function AdvancedChapter() { <> 只用数组或链表(按访问时间排) 把数据按最近使用排成一排:谁被访问就搬到最前面,末尾就是该淘汰的 - —— 顺序有了。但 get(key) 要先找到这条数据: - 没有下标可算、没有哈希可查,只能从头扫,O(n),出局。 + —— 顺序有了。但 get(key) 要先找到这条数据:没有下标可算、没有哈希可查,只能从头扫,O(n),出局。 } /> @@ -933,8 +914,7 @@ export default function AdvancedChapter() { zh={ <> 数组 + 哈希表(哈希存下标) - 哈希 O(1) 定位到数组下标,顺序也有 —— 可「把刚访问的元素搬到最前」 - 是数组的中间删除 + 头部插入:第 1 章数过,后面所有元素都要搬移, + 哈希 O(1) 定位到数组下标,顺序也有 —— 可「把刚访问的元素搬到最前」是数组的中间删除 + 头部插入:第 1 章数过,后面所有元素都要搬移, O(n),还会让哈希表里一大片下标失效。出局。 } @@ -965,9 +945,7 @@ export default function AdvancedChapter() { zh={ <> 单链表 + 哈希表(哈希存节点引用) - 最接近了。哈希表存「key → 链表节点」,一步定位到节点, - 拿到节点后链表插删又是 O(1)……但把节点从链表中间摘下来, - 要把前驱的 next 指到自己的 next 上 ——{" "} + 最接近了。哈希表存「key → 链表节点」,一步定位到节点,拿到节点后链表插删又是 O(1)……但把节点从链表中间摘下来,要把前驱的 next 指到自己的 next 上 ——{" "} 单链表的节点拿不到自己的前驱,找前驱只能从头扫,O(n)。就差一根指针。 } @@ -1024,10 +1002,7 @@ export default function AdvancedChapter() { } zh={

- 这是错的。LRU 里从来没有人遍历链表去查找节点: - 定位永远走哈希表(key → 节点引用,一步到位), - 链表只在「已经拿到节点」之后做摘除和头插 —— 全是改几根指针的 O(1) 操作。 - 两个结构各做各自快的那件事。 + 这是错的。LRU 里从来没有人遍历链表去查找节点:定位永远走哈希表(key → 节点引用,一步到位),链表只在「已经拿到节点」之后做摘除和头插 —— 全是改几根指针的 O(1) 操作。两个结构各做各自快的那件事。

} /> @@ -1536,10 +1511,7 @@ class LRUCache { zh={

JDK 里的 LinkedHashMap,名字就说明了一切:Linked - (双向链表)+ HashMap(哈希表)。它的每个 Entry 除了挂在哈希桶里, - 还带 before/after 指针串成一条双向链表 —— 就是我们刚手写的那台机器。 - 构造参数 accessOrder = true 让链表按访问序而不是插入序排列, - 再覆写一个钩子方法就成了。 + (双向链表)+ HashMap(哈希表)。它的每个 Entry 除了挂在哈希桶里,还带 before/after 指针串成一条双向链表 —— 就是我们刚手写的那台机器。构造参数 accessOrder = true 让链表按访问序而不是插入序排列,再覆写一个钩子方法就成了。

} /> @@ -1630,14 +1602,10 @@ class LRUCache { } zh={

- Redis:配置 maxmemory-policy allkeys-lru 后, - 内存吃紧时按 LRU 淘汰。它用的是近似 LRU: - 给几亿个 key 维护一条全局链表,光指针就太费内存, - 所以 Redis 每次随机采样几个 key,淘汰其中最久未用的 —— + Redis:配置 maxmemory-policy allkeys-lru 后,内存吃紧时按 LRU 淘汰。它用的是近似 LRU:给几亿个 key 维护一条全局链表,光指针就太费内存,所以 Redis 每次随机采样几个 key,淘汰其中最久未用的 —— 用一点点精度换大量内存。 - 浏览器:HTTP 缓存、图片解码缓存的容量控制都是 LRU 的变体。 - 操作系统:物理内存不够时要选一个页面换出到磁盘, - 主流的页面置换算法(如 Clock)同样是 LRU 的近似 —— + 浏览器:HTTP 缓存、图片解码缓存的容量控制都是 LRU 的变体。 + 操作系统:物理内存不够时要选一个页面换出到磁盘,主流的页面置换算法(如 Clock)同样是 LRU 的近似 —— 精确 LRU 要求每次内存访问都更新链表,代价太高。

} @@ -1685,17 +1653,10 @@ class LRUCache { zh={ <>

- LRU 看「多久没用」,LFU 看「用了多少次」: - 淘汰访问次数最少的;次数打平时,再淘汰其中最久未用的那个。 - 它适合热点长期稳定的场景 —— 一条被反复访问的数据即使最近 10 分钟没人碰, - 也不该被一批一次性访问挤出缓存。 + LRU 看「多久没用」,LFU 看「用了多少次」:淘汰访问次数最少的;次数打平时,再淘汰其中最久未用的那个。它适合热点长期稳定的场景 —— 一条被反复访问的数据即使最近 10 分钟没人碰,也不该被一批一次性访问挤出缓存。

- 为什么 LFU 比 LRU 难一档?LRU 里「最旧」永远躺在链表尾部,天然就绪; - 而 LFU 的频次每访问一次就变:key 的频次 +1, - 它在「按频次排序」里的位置就要挪。用堆(第 9 章)维护最小频次? - 每次调整是 O(log n),而且相同频次还要再比时间,做不到全 O(1)。 - 正解是按频次分桶(frequency buckets)。 + 为什么 LFU 比 LRU 难一档?LRU 里「最旧」永远躺在链表尾部,天然就绪;而 LFU 的频次每访问一次就变:key 的频次 +1,它在「按频次排序」里的位置就要挪。用堆(第 9 章)维护最小频次?每次调整是 O(log n),而且相同频次还要再比时间,做不到全 O(1)。正解是按频次分桶(frequency buckets)。

} @@ -1727,12 +1688,8 @@ class LRUCache {

三份状态:① key → (value, freq) 哈希表;②{" "} freq → 桶 哈希表,每个桶按时间序存该频次的所有 key - (桶内天然是个小 LRU);③ 一个 minFreq 变量记录当前最小频次。 - 访问某 key = 把它从 freq 桶搬进 freq+1 桶(两次 O(1) 链表操作); - 淘汰 = 移除 minFreq 桶里最老的 key,也就是「频次最低者中最久未用的那个」。 - minFreq 同样不需要搜索:只有两种时刻它会变 —— - 旧桶被搬空时 +1(刚搬走的 key 正是 minFreq 桶里最后一个), - 插入新 key 时归 1(新 key 频次必为 1,不可能更小)。 + (桶内天然是个小 LRU);③ 一个 minFreq 变量记录当前最小频次。访问某 key = 把它从 freq 桶搬进 freq+1 桶(两次 O(1) 链表操作);淘汰 = 移除 minFreq 桶里最老的 key,也就是「频次最低者中最久未用的那个」。minFreq 同样不需要搜索:只有两种时刻它会变 —— + 旧桶被搬空时 +1(刚搬走的 key 正是 minFreq 桶里最后一个),插入新 key 时归 1(新 key 频次必为 1,不可能更小)。

} /> @@ -1846,8 +1803,7 @@ class LRUCache { zh: ( <> LinkedHashSet = 哈希表 + 双向链表(又是这对组合), - iterator().next() 拿到的就是桶里最老的 key。 - 高亮的两处是全代码中唯二会改动 minFreq 的地方。 + iterator().next() 拿到的就是桶里最老的 key。高亮的两处是全代码中唯二会改动 minFreq 的地方。 ), }, @@ -1941,8 +1897,7 @@ class LFUCache: ), zh: ( <> - popitem(last=False) 从 OrderedDict 头部弹出, - 正是桶里最老的 key。整份代码没有一处循环 —— 这就是全 O(1) 的直接证据。 + popitem(last=False) 从 OrderedDict 头部弹出,正是桶里最老的 key。整份代码没有一处循环 —— 这就是全 O(1) 的直接证据。 ), }, @@ -2048,8 +2003,7 @@ class LFUCache: ), zh: ( <> - JS 的 Set / Map 都记住插入顺序, - 所以 Set 直接就能当「按时间排序的桶」用, + JS 的 Set / Map 都记住插入顺序,所以 Set 直接就能当「按时间排序的桶」用, values().next().value 拿到的就是最老的成员。 ), @@ -2081,12 +2035,7 @@ class LFUCache: } zh={

- LFU 的软肋:旧热点退役慢 —— 一条昨天被读了几万次、 - 今天再没人碰的数据,频次依然很高,新数据要很久才能把它挤出去 - (工程上用「频次随时间衰减」来修,Redis 的 LFU 策略就带衰减因子)。 - LRU 的软肋:怕一次全量扫描 —— 一次全表遍历把每行都读一遍, - 会把真正的热数据全部冲出缓存。所以 Redis 两种策略都提供, - 按业务的访问模式选。 + LFU 的软肋:旧热点退役慢 —— 一条昨天被读了几万次、今天再没人碰的数据,频次依然很高,新数据要很久才能把它挤出去(工程上用「频次随时间衰减」来修,Redis 的 LFU 策略就带衰减因子)。LRU 的软肋:怕一次全量扫描 —— 一次全表遍历把每行都读一遍,会把真正的热数据全部冲出缓存。所以 Redis 两种策略都提供,按业务的访问模式选。

} /> @@ -2125,10 +2074,7 @@ class LFUCache: zh={

换一类需求。给一个数组,反复问「下标 l 到 r 的 - 区间和是多少」——前缀和就能解决: - 先算 pre[i] = a[0] + … + a[i−1],区间和 = pre[r+1] − pre[l], - 预处理一遍,每次查询 O(1)。 - 但加一个条件:数组元素还会被修改。改一个{" "} + 区间和是多少」——前缀和就能解决:先算 pre[i] = a[0] + … + a[i−1],区间和 = pre[r+1] − pre[l],预处理一遍,每次查询 O(1)。但加一个条件:数组元素还会被修改。改一个{" "} a[i],它后面的所有前缀和全部作废,重建要 O(n) —— 改 10 万次就是 10 万次重建,前缀和不再可用。

@@ -2182,11 +2128,8 @@ class LFUCache: } zh={

- 两个极端之间,需要一个「改和查都不太差」的方案。做法是把整个区间对半劈开, - 一直劈到每段只剩一个元素,于是得到一棵二叉树(第 7 章), - 每个节点存自己那段区间的和。 - 这样「和的信息」被分摊到 O(n) 个节点上,任何一次修改或查询, - 都只需要动其中 O(log n) 个。 + 两个极端之间,需要一个「改和查都不太差」的方案。做法是把整个区间对半劈开,一直劈到每段只剩一个元素,于是得到一棵二叉树(第 7 章), + 每个节点存自己那段区间的和。这样「和的信息」被分摊到 O(n) 个节点上,任何一次修改或查询,都只需要动其中 O(log n) 个。

} /> @@ -2216,9 +2159,7 @@ class LFUCache: } zh={ <> - 要改 a[i]:从根一路二分下潜找到那片叶子并改掉, - 回溯时把路径上每个祖先的和重算一遍(= 左孩子 + 右孩子)。 - 被动到的节点数 = 树高 = O(log n);其余节点存的和依然正确 —— + 要改 a[i]:从根一路二分下潜找到那片叶子并改掉,回溯时把路径上每个祖先的和重算一遍(= 左孩子 + 右孩子)。被动到的节点数 = 树高 = O(log n);其余节点存的和依然正确 —— 这正是它比前缀和强的地方。 } @@ -2245,8 +2186,7 @@ class LFUCache: <> 问 [l,r] 的和,每个节点只有三种处境:① 完全不相交 → 返回 0; ② 整段落在 [l,r] 内 → 直接返回存好的和,不再下探; - ③ 部分相交 → 劈开问两个孩子。每层最多有 2 个节点落到情况 ③, - 所以总访问量是 O(log n)。 + ③ 部分相交 → 劈开问两个孩子。每层最多有 2 个节点落到情况 ③,所以总访问量是 O(log n)。 } /> @@ -2265,8 +2205,7 @@ class LFUCache: } zh={

- 亲手试一遍:改值模式点一个叶子,看一条「从叶到根」的更新路径; - 查询模式圈定一个区间,看哪些节点被整段命中 —— + 亲手试一遍:改值模式点一个叶子,看一条「从叶到根」的更新路径;查询模式圈定一个区间,看哪些节点被整段命中 —— 绿色节点直接交出存好的和,一次都不用下探到叶子。

} @@ -2296,11 +2235,8 @@ class LFUCache: zh={

树直接存在一个数组里:tree[1] 是根,节点 i 的孩子是 2i 和 2i+1 —— - 和第 9 章的堆同款存法,不用真的创建节点对象。数组开 4n。 - 为什么是 4n 而不是 2n:如果 n 是 2 的幂,这棵树是满的, - n 个叶子 + n−1 个内部节点,用到的最大下标不超过 2n; - 但 n 不是 2 的幂时,最底层不满,递归可能把叶子放到再深一层的位置上。 - 最坏情况下用到的最大下标小于 4n,所以开 4n 永远安全,也不用分情况讨论。 + 和第 9 章的堆同款存法,不用真的创建节点对象。数组开 4n。为什么是 4n 而不是 2n:如果 n 是 2 的幂,这棵树是满的, + n 个叶子 + n−1 个内部节点,用到的最大下标不超过 2n;但 n 不是 2 的幂时,最底层不满,递归可能把叶子放到再深一层的位置上。最坏情况下用到的最大下标小于 4n,所以开 4n 永远安全,也不用分情况讨论。

} /> @@ -2419,8 +2355,7 @@ class LFUCache: ), zh: ( <> - 这三行就是查询的全部:不相交、全包住、部分相交。 - 任何线段树题都是这个骨架。 + 这三行就是查询的全部:不相交、全包住、部分相交。任何线段树题都是这个骨架。 ), }, @@ -2665,13 +2600,8 @@ class LFUCache: zh={

把代码里的 + 换成 Math.max,同一棵树就能回答 - 区间最值查询 —— 任何满足结合律的操作(和 / 最值 / GCD / 矩阵乘) - 都能挂上去,这来自树结构本身的通用性。第二个延伸是 - 懒标记(lazy propagation):「把 [l,r] 里每个元素都 +5」 - 这类批量修改,先把标记贴在覆盖该区间的大节点上, - 等后面的操作真的要用到孩子时才下推。没有懒标记时, - 区间修改等于对每个元素做一次单点修改,是 O(n log n); - 有了懒标记,区间修改降到 O(log n)。竞赛常用,面试少见。 + 区间最值查询 —— 任何满足结合律的操作(和 / 最值 / GCD / 矩阵乘)都能挂上去,这来自树结构本身的通用性。第二个延伸是 + 懒标记(lazy propagation):「把 [l,r] 里每个元素都 +5」这类批量修改,先把标记贴在覆盖该区间的大节点上,等后面的操作真的要用到孩子时才下推。没有懒标记时,区间修改等于对每个元素做一次单点修改,是 O(n log n);有了懒标记,区间修改降到 O(log n)。竞赛常用,面试少见。

} /> @@ -2730,18 +2660,11 @@ class LFUCache:

线段树好用,但要几十行。如果需求只是 单点修改 + 前缀和查询,有一个小得多的结构: - 树状数组(Binary Indexed Tree,又叫 Fenwick Tree)。 - 它只保留一个数组 tree[1..n],规则一句话: + 树状数组(Binary Indexed Tree,又叫 Fenwick Tree)。它只保留一个数组 tree[1..n],规则一句话: tree[i] 存的是「以 i 结尾、长度为 lowbit(i)」那一段的和。

- lowbit(i) = i 的二进制里最低位那个 1 所代表的值。 - 求它只要一个位运算:lowbit(x) = x & (-x)。 - 为什么成立:补码里 -x = ~x + 1。 - 取反把最低位的 1 变成 0、其后各位全变 1; - 再 +1 时进位穿过这些位,恰好停在原来那个 1 的位置。 - 于是在这一位之上,x 与 -x 各位互反,按位与为 0;这一位之下两者都是 0;只有这一位两者都是 1,按位与就只剩这一位。 - 拿 6 验算:6 = 0110,-6 = 1010,0110 & 1010 = 0010 = 2。 + lowbit(i) = i 的二进制里最低位那个 1 所代表的值。求它只要一个位运算:lowbit(x) = x & (-x)。为什么成立:补码里 -x = ~x + 1。取反把最低位的 1 变成 0、其后各位全变 1;再 +1 时进位穿过这些位,恰好停在原来那个 1 的位置。于是在这一位之上,x 与 -x 各位互反,按位与为 0;这一位之下两者都是 0;只有这一位两者都是 1,按位与就只剩这一位。拿 6 验算:6 = 0110,-6 = 1010,0110 & 1010 = 0010 = 2。

} @@ -2841,11 +2764,9 @@ class LFUCache: zh={

看这张表里的规律: - i 的二进制末尾 0 越多,它管的那一段越长。 - 求前缀和 a[1..7] 时,7 = 0111 = 4+2+1,和恰好拆成 tree[7](1 个元素)+ + i 的二进制末尾 0 越多,它管的那一段越长。求前缀和 a[1..7] 时,7 = 0111 = 4+2+1,和恰好拆成 tree[7](1 个元素)+ tree[6](2 个)+ tree[4](4 个),三段首尾相接、互不重叠。 - i -= lowbit(i) 每次剥掉一个最低位的 1,正是在做这个二进制拆分, - 最多做 log n 次。修改则相反:i += lowbit(i){" "} + i -= lowbit(i) 每次剥掉一个最低位的 1,正是在做这个二进制拆分,最多做 log n 次。修改则相反:i += lowbit(i){" "} 跳到所有包含位置 i 的更大段,同样最多 log n 次。精讲 B 会逐帧走一遍这两条路径。

} @@ -2987,8 +2908,7 @@ class LFUCache: ), zh: ( <> - Python 的负数同样是补码语义,x & -x 照常工作; - 而且 Python 整数是无限精度的,不用担心溢出。 + Python 的负数同样是补码语义,x & -x 照常工作;而且 Python 整数是无限精度的,不用担心溢出。 ), }, @@ -3056,8 +2976,7 @@ class LFUCache: ), zh: ( <> - JS 的位运算按 32 位有符号整数进行,所以只要 n 不超过 2^31, - 下标运算就是正确的。注意这个限制针对的是下标,而不是数组里存的和。 + JS 的位运算按 32 位有符号整数进行,所以只要 n 不超过 2^31,下标运算就是正确的。注意这个限制针对的是下标,而不是数组里存的和。 ), }, @@ -3148,15 +3067,12 @@ class LFUCache: <>

第 3 章留下的限制:有序链表查找是 O(n) —— - 数据明明有序,却不能二分,因为二分需要 O(1) 随机访问,而链表没有 - (第 1 章)。只能一格一格 next,有序性被浪费掉了。 + 数据明明有序,却不能二分,因为二分需要 O(1) 随机访问,而链表没有(第 1 章)。只能一格一格 next,有序性被浪费掉了。

跳表的做法是在链表上面加几层「快线」: - 最底层 L0 是完整的有序链表,往上每一层只保留下一层大约一半的节点。 - 层数越高节点越少,一步跨得越远。查找从最顶层出发,规则只有一条: - 右邻还比目标小就向右走,否则下楼一层。 - 走到 L0 时,已经停在目标旁边了。 + 最底层 L0 是完整的有序链表,往上每一层只保留下一层大约一半的节点。层数越高节点越少,一步跨得越远。查找从最顶层出发,规则只有一条: + 右邻还比目标小就向右走,否则下楼一层。走到 L0 时,已经停在目标旁边了。

} @@ -3181,12 +3097,8 @@ class LFUCache: } zh={

- 每层节点数大约是下层的一半,所以层数约 log₂n; - 在每一层通常只走一两步就下楼 —— 如果能在某一层走很多步, - 说明上一层本可以带你走得更远。因此查找的期望代价是 - O(log n)。n = 100 万时,普通有序链表平均要比较约 50 万次, - 跳表大约几十次 —— 和二分、平衡树同一量级, - 同时保留了链表「插删只改几根指针」的优点。 + 每层节点数大约是下层的一半,所以层数约 log₂n;在每一层通常只走一两步就下楼 —— 如果能在某一层走很多步,说明上一层本可以带你走得更远。因此查找的期望代价是 + O(log n)。n = 100 万时,普通有序链表平均要比较约 50 万次,跳表大约几十次 —— 和二分、平衡树同一量级,同时保留了链表「插删只改几根指针」的优点。

} /> @@ -3218,12 +3130,7 @@ class LFUCache: } zh={ <> - 「上层严格隔一抽一」看起来最理想,但一次插入就会破坏它: - 新节点挤进来,后面所有节点的位置全变,索引要 O(n) 重建。 - 跳表改成让每个新节点抛硬币决定高度: - 正面就再长一层,反面就停,所以每多长一层的概率是 1/2。 - 没人维护那个精确模式,但在期望意义上每层依然是下层的一半: - 单个节点的期望层数是 1 + 1/2 + 1/4 + … = 2, + 「上层严格隔一抽一」看起来最理想,但一次插入就会破坏它:新节点挤进来,后面所有节点的位置全变,索引要 O(n) 重建。跳表改成让每个新节点抛硬币决定高度:正面就再长一层,反面就停,所以每多长一层的概率是 1/2。没人维护那个精确模式,但在期望意义上每层依然是下层的一半:单个节点的期望层数是 1 + 1/2 + 1/4 + … = 2, n 个节点整体的期望高度约 log₂n。 } @@ -3254,10 +3161,7 @@ class LFUCache: } zh={ <> - 这一点很重要:O(log n) 的期望是对抛硬币的随机性取的, - 不是对输入数据取的。跳表没有「最坏输入」,因为结构完全不依赖数据。 - 运气极差的一串硬币确实会让它变慢,但那个概率小到可以忽略。 - 这和快速排序随机选轴、哈希函数打散 key(第 6 章)是同一种权衡: + 这一点很重要:O(log n) 的期望是对抛硬币的随机性取的,不是对输入数据取的。跳表没有「最坏输入」,因为结构完全不依赖数据。运气极差的一串硬币确实会让它变慢,但那个概率小到可以忽略。这和快速排序随机选轴、哈希函数打散 key(第 6 章)是同一种权衡: 用随机性换掉昂贵的确定性维护。 } @@ -3294,12 +3198,8 @@ class LFUCache: zh={

两者的查 / 插 / 删都是 O(log n)。Redis 作者 Salvatore Sanfilippo - 给过三个理由:① 实现简单一个量级 —— 红黑树插入、删除合起来有十几种旋转和变色 - 的情况,跳表插入就是「普通链表插入 × 层数」;② 范围操作天然顺滑 —— - ZRANGE 取的是排名区间,跳表定位到起点后沿 L0 一路往前走即可, - 而树要反复做中序遍历;③ 更容易改造(Redis 在跳表节点上加了 span 字段做排名)。 - LevelDB / RocksDB 的内存写缓冲(MemTable)也用跳表, - 还因为它对无锁并发友好:改几根指针比旋转整棵树更容易做成原子操作。 + 给过三个理由:① 实现简单一个量级 —— 红黑树插入、删除合起来有十几种旋转和变色的情况,跳表插入就是「普通链表插入 × 层数」;② 范围操作天然顺滑 —— + ZRANGE 取的是排名区间,跳表定位到起点后沿 L0 一路往前走即可,而树要反复做中序遍历;③ 更容易改造(Redis 在跳表节点上加了 span 字段做排名)。LevelDB / RocksDB 的内存写缓冲(MemTable)也用跳表,还因为它对无锁并发友好:改几根指针比旋转整棵树更容易做成原子操作。

} /> @@ -3320,11 +3220,8 @@ class LFUCache: } zh={

- 实现思路(LC 1206 原题):每个节点带一个 next[] 指针数组, - 第 i 格 = 它在第 i 层的后继。三个操作共用同一段导航逻辑 —— - 从顶层开始,每层在「右邻还比目标小」时向右走, - 并把每层最后停留的节点记进 update[]; - 插入和删除就是在这些记录点之后做普通的链表指针操作。 + 实现思路(LC 1206 原题):每个节点带一个 next[] 指针数组,第 i 格 = 它在第 i 层的后继。三个操作共用同一段导航逻辑 —— + 从顶层开始,每层在「右邻还比目标小」时向右走,并把每层最后停留的节点记进 update[];插入和删除就是在这些记录点之后做普通的链表指针操作。

} /> @@ -3475,8 +3372,7 @@ class LFUCache: ), zh: ( <> - search / add / erase 的前半段是同一段代码:从顶层往下, - 每层尽量向右走。看懂一个就看懂了全部三个。 + search / add / erase 的前半段是同一段代码:从顶层往下,每层尽量向右走。看懂一个就看懂了全部三个。 ), }, @@ -3614,8 +3510,7 @@ class Skiplist: zh: ( <> update = [self.head] * MAX_LEVEL 存的是同一个 head - 的多个引用 —— 这里没问题:这些格子本来就都应指向 head, - 共享同一个对象正是想要的效果(与需要独立对象的 [[]] * n 不同)。 + 的多个引用 —— 这里没问题:这些格子本来就都应指向 head,共享同一个对象正是想要的效果(与需要独立对象的 [[]] * n 不同)。 ), }, @@ -3787,9 +3682,7 @@ class Skiplist {

跳表出自 William Pugh 1990 年的论文,标题就是结论: Skip Lists: A Probabilistic Alternative to Balanced Trees - (跳表:平衡树的概率替代品)。论文的论点是:平衡树很难写对, - 而一个更简单的随机化结构能给出同样的期望性能。 - 三十多年后 Redis、LevelDB 都在用跳表,算是对这个论点的支持。 + (跳表:平衡树的概率替代品)。论文的论点是:平衡树很难写对,而一个更简单的随机化结构能给出同样的期望性能。三十多年后 Redis、LevelDB 都在用跳表,算是对这个论点的支持。

} /> @@ -3840,16 +3733,13 @@ class Skiplist {

最后一台机器回答一个非常朴素的问题: 这个东西我见过吗? - 爬虫要判断某个 URL 是否抓过 —— 100 亿个 URL,每个平均 60 字节, - 用哈希集合(第 6 章)存需要约 600 GB,还没算每条记录的额外开销。存不下。 + 爬虫要判断某个 URL 是否抓过 —— 100 亿个 URL,每个平均 60 字节,用哈希集合(第 6 章)存需要约 600 GB,还没算每条记录的额外开销。存不下。

布隆过滤器(Bloom filter)的做法是 - 根本不存数据本身,只记录哈希位置: - 一个 m 位的位数组(bit array)加 k 个不同的哈希函数。 + 根本不存数据本身,只记录哈希位置:一个 m 位的位数组(bit array)加 k 个不同的哈希函数。 - 注意它做不到什么:布隆过滤器取不回存进去的值, - 也无法确定地告诉你某个元素在里面。它不是「更快的集合」。 + 注意它做不到什么:布隆过滤器取不回存进去的值,也无法确定地告诉你某个元素在里面。它不是「更快的集合」。

@@ -3873,8 +3763,7 @@ class Skiplist { } zh={ <> - k 个哈希函数算出 k 个位置,把这些位全部置 1。 - x 本身不存。一个元素最多只占 k 个比特,而且这些位是所有元素共享的。 + k 个哈希函数算出 k 个位置,把这些位全部置 1。x 本身不存。一个元素最多只占 k 个比特,而且这些位是所有元素共享的。 } /> @@ -3927,9 +3816,7 @@ class Skiplist {

注意这个不对称:回答只有「一定不在」和 「可能在」两种。原因是 - 位只会从 0 变 1,永远不会被清零: - 别的元素可能顺手点亮你的位,这就产生假阳性; - 但没有任何操作会把已点亮的位熄灭,所以 + 位只会从 0 变 1,永远不会被清零:别的元素可能顺手点亮你的位,这就产生假阳性;但没有任何操作会把已点亮的位熄灭,所以 永远不会有假阴性。亲手制造一次假阳性:

} @@ -3961,15 +3848,10 @@ class Skiplist { } zh={

- 误判率不是固定的,它取决于三个量:位数组大小 m、哈希函数个数 k, - 以及实际插入的元素个数 n。两条定性规律: - 相对元素数而言位数组越大(m/n 越大),误判率越低, - 因为位被填满得更慢; + 误判率不是固定的,它取决于三个量:位数组大小 m、哈希函数个数 k,以及实际插入的元素个数 n。两条定性规律: + 相对元素数而言位数组越大(m/n 越大),误判率越低,因为位被填满得更慢; 哈希函数个数 k 存在一个最优值 —— - 太少,每个元素留下的「指纹」太小,容易撞; - 太多,每个元素占的位太多,数组很快全亮,误判率反而升高。 - 工程上按目标误判率反推 m 和 k:比如每元素 10 个比特、k = 7, - 误判率约 1%。而哈希集合每个元素要几百比特,所以内存能省一到两个数量级。 + 太少,每个元素留下的「指纹」太小,容易撞;太多,每个元素占的位太多,数组很快全亮,误判率反而升高。工程上按目标误判率反推 m 和 k:比如每元素 10 个比特、k = 7,误判率约 1%。而哈希集合每个元素要几百比特,所以内存能省一到两个数量级。

} /> @@ -4006,12 +3888,9 @@ class Skiplist {

爬虫去重:100 亿 URL × 10 bit ≈ 12 GB,单机就装得下; 1% 的误判只意味着漏抓极少数新页面。 - 缓存穿透防护:大量请求查询数据库里根本不存在的 key, - 每次都穿过缓存直接打到数据库 —— - 把全量 key 装进布隆过滤器挡在缓存前面, - 被判定为「一定不在」的请求直接拒绝。 - 垃圾邮件 / 恶意网址名单:黑名单太大放不进内存, - 先用布隆过滤器判断值不值得做一次昂贵的精确校验。共同模式是: + 缓存穿透防护:大量请求查询数据库里根本不存在的 key,每次都穿过缓存直接打到数据库 —— + 把全量 key 装进布隆过滤器挡在缓存前面,被判定为「一定不在」的请求直接拒绝。 + 垃圾邮件 / 恶意网址名单:黑名单太大放不进内存,先用布隆过滤器判断值不值得做一次昂贵的精确校验。共同模式是: 插入过的绝不会被误拒,少量被误放行的再交给慢的精确查询复核。

} @@ -4042,12 +3921,8 @@ class Skiplist { zh={

① 标准布隆过滤器不支持删除。 - 一个位可能被多个元素共享,把它清零等于把别的元素也从过滤器里抹掉, - 会制造假阴性,破坏它唯一的那条保证。需要删除就用计数布隆过滤器 - (每个位置换成一个小计数器)或布谷鸟过滤器。 - ② 误判率会随插入量上涨。 - 按 1000 万条设计容量却塞进 1 亿条,位数组几乎全亮, - 任何查询都会得到「可能在」,过滤器就没用了 —— + 一个位可能被多个元素共享,把它清零等于把别的元素也从过滤器里抹掉,会制造假阴性,破坏它唯一的那条保证。需要删除就用计数布隆过滤器(每个位置换成一个小计数器)或布谷鸟过滤器。② 误判率会随插入量上涨。 + 按 1000 万条设计容量却塞进 1 亿条,位数组几乎全亮,任何查询都会得到「可能在」,过滤器就没用了 —— 事先估算元素数量是使用它的前提。

} @@ -4070,10 +3945,7 @@ class Skiplist { } zh={

- Burton Bloom 在 1970 年提出这个结构,当时内存以 KB 计, - 「牺牲一点点准确性换大量内存」是刚需。此后内存便宜了很多, - 但这个结构反而用得更多 —— 因为数据规模涨得比内存更快。 - Redis、HBase、Cassandra、Chrome 里都有它。 + Burton Bloom 在 1970 年提出这个结构,当时内存以 KB 计,「牺牲一点点准确性换大量内存」是刚需。此后内存便宜了很多,但这个结构反而用得更多 —— 因为数据规模涨得比内存更快。Redis、HBase、Cassandra、Chrome 里都有它。

} /> @@ -4129,10 +4001,8 @@ class Skiplist { zh={

题意:实现容量固定的 LRU 缓存,get / put 都是 O(1),满了淘汰最久未用的。 - 为什么这样拼:§02 已经完整推导过 —— - 哈希表 O(1) 回答「在哪」,双向链表回答「多旧」并支持 O(1) 摘除和插入, - 各自 O(n) 的那件事由对方来做;完整手写实现也在 §02。 - 这里先用官方样例把执行过程逐帧走一遍,再补一个面试省时间的快写版。 + 为什么这样拼:§02 已经完整推导过 —— + 哈希表 O(1) 回答「在哪」,双向链表回答「多旧」并支持 O(1) 摘除和插入,各自 O(n) 的那件事由对方来做;完整手写实现也在 §02。这里先用官方样例把执行过程逐帧走一遍,再补一个面试省时间的快写版。

} /> @@ -4205,8 +4075,7 @@ class LRUCache extends LinkedHashMap { ), zh: ( <> - 面试时先问「能不能用标准库」。要求手写就写 §02 的完整版; - 允许用库就写这个,并主动讲出它内部的两个结构。 + 面试时先问「能不能用标准库」。要求手写就写 §02 的完整版;允许用库就写这个,并主动讲出它内部的两个结构。 ), }, @@ -4364,8 +4233,7 @@ class LRUCache: get / put 时间 O(1),空间 O(capacity)。高频追问: ①「为什么必须双向链表?」摘除需要前驱,见 §02 的方案 4; ②「节点里为什么要存 key?」淘汰尾节点时要用它删掉对应的哈希条目; - ③「多线程安全怎么做?」整体加一把锁最简单; - 更好的答案是分片、每片一把锁,或者像 Redis 那样用单线程处理命令来回避; + ③「多线程安全怎么做?」整体加一把锁最简单;更好的答案是分片、每片一把锁,或者像 Redis 那样用单线程处理命令来回避; ④「容量非常大怎么办?」改用采样的近似 LRU,省掉链表 —— Redis 的做法。

} @@ -4404,11 +4272,9 @@ class LRUCache: zh={

题意:update(i, val) 单点改值,sumRange(l, r) 查区间和,两者交替出现。 - 为什么这样拼:前缀和查 O(1) 但改 O(n),裸数组改 O(1) 但查 O(n) —— - 两个操作都频繁时,任一极端都无法满足要求,需要一个两边都 O(log n) 的结构, - 这正是 §04 和 §05 的主场。 - 逐帧:线段树的路径动画已在 §04 实验室里演示过, - 这里走一遍树状数组版,看 lowbit 怎么在 tree 数组上跳。 + 为什么这样拼:前缀和查 O(1) 但改 O(n),裸数组改 O(1) 但查 O(n) —— + 两个操作都频繁时,任一极端都无法满足要求,需要一个两边都 O(log n) 的结构,这正是 §04 和 §05 的主场。 + 逐帧:线段树的路径动画已在 §04 实验室里演示过,这里走一遍树状数组版,看 lowbit 怎么在 tree 数组上跳。

} /> @@ -4495,8 +4361,7 @@ class LRUCache: ), zh: ( <> - 易错点:update 给的是「新值」而不是「增量」, - 直接把 val 加进去是错的 —— 先算{" "} + 易错点:update 给的是「新值」而不是「增量」,直接把 val 加进去是错的 —— 先算{" "} delta = val − a[i],再把 delta 加上去。 ), @@ -4566,8 +4431,7 @@ class LRUCache: ), zh: ( <> - 建树还有一种 O(n) 的写法(先算前缀和再相减), - 但做题时用逐个 add 的 O(n log n) 已经够了。 + 建树还有一种 O(n) 的写法(先算前缀和再相减),但做题时用逐个 add 的 O(n log n) 已经够了。 ), }, @@ -4663,12 +4527,9 @@ class LRUCache: } zh={

- 两个版本的 update / query 都是 O(log n); - 树状数组占 n+1 空间,线段树占 4n。追问: - ①「什么时候必须用线段树?」区间最值、区间修改, - 或任何「结果无法用两个前缀相减还原」的合并; - ②「二维怎么办?」LC 304 不可变,用二维前缀和; - 可变就用二维树状数组,两重 lowbit 循环; + 两个版本的 update / query 都是 O(log n);树状数组占 n+1 空间,线段树占 4n。追问: + ①「什么时候必须用线段树?」区间最值、区间修改,或任何「结果无法用两个前缀相减还原」的合并; + ②「二维怎么办?」LC 304 不可变,用二维前缀和;可变就用二维树状数组,两重 lowbit 循环; ③「为什么 update 要先算 delta?」因为树的原语是「加」而不是「赋值」—— 每个格子存的是一段的和,不能直接用某个元素的新值覆盖。

@@ -4720,15 +4581,10 @@ class LRUCache: zh={

题意:设计一个集合,insert / remove / getRandom(等概率随机返回一个成员) - 全部 O(1)。哈希那一章可能见过它,这次用 §01 的组合方法重新推一遍: - 列出操作 → 找出单一结构在哪超预算 → 补一个结构专门做那件事。 - 排除:只用哈希集合,insert / remove 是 O(1),但 getRandom 不行 —— - 桶数组里布满空桶,随机探桶可能连续踩空,做不到 O(1) 的等概率抽样。 - 只用数组,getRandom 完美(随机下标),但 remove 要先找到值(O(n)), - 再把洞补上(又 O(n))。 - 组合:数组存值,负责随机;哈希表存 值 → 下标,负责定位。 - 还剩一个问题:数组中间删除要搬移元素。 - 解法正是第 6 章 LC 380 题解里的办法 —— 先和末尾元素交换,再删末尾。 + 全部 O(1)。哈希那一章可能见过它,这次用 §01 的组合方法重新推一遍:列出操作 → 找出单一结构在哪超预算 → 补一个结构专门做那件事。 + 排除:只用哈希集合,insert / remove 是 O(1),但 getRandom 不行 —— + 桶数组里布满空桶,随机探桶可能连续踩空,做不到 O(1) 的等概率抽样。只用数组,getRandom 完美(随机下标),但 remove 要先找到值(O(n)),再把洞补上(又 O(n))。 + 组合:数组存值,负责随机;哈希表存 值 → 下标,负责定位。还剩一个问题:数组中间删除要搬移元素。解法正是第 6 章 LC 380 题解里的办法 —— 先和末尾元素交换,再删末尾。

} /> @@ -4814,8 +4670,7 @@ class LRUCache: <> 易错点:idx.put(last, i) 必须写在{" "} idx.remove(val) 之前。如果被删的恰好是末尾元素, - last 就等于 val,顺序反了会把刚删掉的条目又加回来。 - 这里的顺序在两种情况下都正确。 + last 就等于 val,顺序反了会把刚删掉的条目又加回来。这里的顺序在两种情况下都正确。 ), }, @@ -4967,8 +4822,7 @@ class RandomizedSet: ), zh: ( <> - 核心就是三行:把末尾元素补到洞里、更新哈希表、尾删。 - 很多需要「从无序集合中 O(1) 删除」的题目都用这个办法。 + 核心就是三行:把末尾元素补到洞里、更新哈希表、尾删。很多需要「从无序集合中 O(1) 删除」的题目都用这个办法。 ), }, @@ -5002,13 +4856,7 @@ class RandomizedSet: zh={

三个操作全是 O(1)(insert 是均摊),空间 O(n)。追问: - ①「允许重复元素呢?」那是 LC 381:哈希表的 value 改成「下标集合」, - 交换时要小心两个值相等的情况;难度明显更高,值得一做。 - ②「为什么哈希表单独做不了 getRandom?」 - 桶数组里有空槽,等概率抽样要么扫描 O(容量) 个桶, - 要么用拒绝采样、尝试次数没有上界。 - ③「怎么证明 getRandom 是等概率的?」数组没有空洞, - 每个下标被选中的概率恰好是 1/size。注意这道题和 LRU 的共同点: + ①「允许重复元素呢?」那是 LC 381:哈希表的 value 改成「下标集合」,交换时要小心两个值相等的情况;难度明显更高,值得一做。②「为什么哈希表单独做不了 getRandom?」桶数组里有空槽,等概率抽样要么扫描 O(容量) 个桶,要么用拒绝采样、尝试次数没有上界。③「怎么证明 getRandom 是等概率的?」数组没有空洞,每个下标被选中的概率恰好是 1/size。注意这道题和 LRU 的共同点: 组合里的每个成员各提供一个 O(1) 操作,合起来覆盖题目要求的全部操作。

} @@ -5072,8 +4920,7 @@ class RandomizedSet: zh: ( <> 组合设计的方法:先列出全部操作和每个操作的复杂度预算,再挑能互相补位的基础结构 - —— 每个成员只做自己 O(1)/O(log n) 的操作, - 两者通过「互存引用 + 同一步更新所有成员」连接起来。 + —— 每个成员只做自己 O(1)/O(log n) 的操作,两者通过「互存引用 + 同一步更新所有成员」连接起来。 ), }, @@ -5093,9 +4940,7 @@ class RandomizedSet: zh: ( <> LRU = 哈希表(回答「在哪」)+ 双向链表(回答「多旧」),get/put 全 O(1)。 - 必须双向:摘除节点要改前驱的 next,而单链表的节点拿不到前驱; - 节点里要存 key,淘汰时才能删掉对应的哈希条目。 - Java 的 LinkedHashMap 配 accessOrder = true 就是同一台机器。 + 必须双向:摘除节点要改前驱的 next,而单链表的节点拿不到前驱;节点里要存 key,淘汰时才能删掉对应的哈希条目。Java 的 LinkedHashMap 配 accessOrder = true 就是同一台机器。 ), }, @@ -5110,9 +4955,7 @@ class RandomizedSet: ), zh: ( <> - LFU 比 LRU 难,因为频次每访问一次就变。做到 O(1) 需要三样东西: - 频次哈希表、每个频次一条按时间排序的链表、以及一个 minFreq 变量。 - 淘汰规则是:频次最低者中最久未用的那个。 + LFU 比 LRU 难,因为频次每访问一次就变。做到 O(1) 需要三样东西:频次哈希表、每个频次一条按时间排序的链表、以及一个 minFreq 变量。淘汰规则是:频次最低者中最久未用的那个。 ), }, @@ -5137,13 +4980,11 @@ class RandomizedSet: <> 区间统计三种情况: - 只查不改 → 前缀和,查询 O(1);又改又查 → 线段树或树状数组, - 两者都是 O(log n) + 只查不改 → 前缀和,查询 O(1);又改又查 → 线段树或树状数组,两者都是 O(log n) 。线段树能挂任何满足结合律的合并,配懒标记后区间修改是 O(log n) 而不是 O(n log n)。树状数组约 25 行,靠{" "} - lowbit = x & (−x) 取出最低位的 1, - 但只适用于「答案能由两个前缀相减还原」的运算,而且必须 1-based。 + lowbit = x & (−x) 取出最低位的 1,但只适用于「答案能由两个前缀相减还原」的运算,而且必须 1-based。 ), }, @@ -5161,9 +5002,7 @@ class RandomizedSet: ), zh: ( <> - 跳表 = 有序链表 + 抛硬币长出来的多层索引:期望 O(log n), - 而且期望是对抛硬币取的、与输入无关,所以没有「最坏输入」。 - 用随机化替掉平衡树的旋转维护 —— + 跳表 = 有序链表 + 抛硬币长出来的多层索引:期望 O(log n),而且期望是对抛硬币取的、与输入无关,所以没有「最坏输入」。用随机化替掉平衡树的旋转维护 —— Redis zset(跳表 + 哈希表)和 LevelDB MemTable 的骨架。 ), @@ -5185,9 +5024,7 @@ class RandomizedSet: zh: ( <> 布隆过滤器 = 位数组 + k 个哈希函数: - 说「不在」是确定的,说「在」只是可能,因为位只会从 0 变 1。 - 普通布隆过滤器不能删除;误判率取决于位数组大小、哈希函数个数、 - 以及实际插入的元素个数。它是一道前置过滤门,不是更快的集合。 + 说「不在」是确定的,说「在」只是可能,因为位只会从 0 变 1。普通布隆过滤器不能删除;误判率取决于位数组大小、哈希函数个数、以及实际插入的元素个数。它是一道前置过滤门,不是更快的集合。 ), }, @@ -5210,8 +5047,7 @@ class RandomizedSet: 这六台机器没有用到任何新的基础结构 —— 数组、链表、哈希表、树、位运算全部来自前 12 章。 - 面试设计题的做法:先复述操作需求和复杂度目标,再报出结构组合, - 最后主动指出两个结构之间必须同步的地方 + 面试设计题的做法:先复述操作需求和复杂度目标,再报出结构组合,最后主动指出两个结构之间必须同步的地方 。 diff --git a/app/advanced/viz.tsx b/app/advanced/viz.tsx index 0f5c73b..ebb5161 100644 --- a/app/advanced/viz.tsx +++ b/app/advanced/viz.tsx @@ -184,8 +184,7 @@ export function LRUAnatomy() { } zh={ <> - 哈希表存的不是值,而是链表节点的引用:查找交给哈希(不用遍历链表), - 顺序交给链表(不用扫描哈希)—— 两个结构各自只负责自己那部分 O(1) 的工作。 + 哈希表存的不是值,而是链表节点的引用:查找交给哈希(不用遍历链表),顺序交给链表(不用扫描哈希)—— 两个结构各自只负责自己那部分 O(1) 的工作。 } /> @@ -251,8 +250,7 @@ export function LRULab() { } zh={ <> - put({k},{v}):哈希表里已有 {k} → O(1) 拿到节点引用,原地改值, - 再把节点搬到头部(它刚被用过,变成最新)。 + put({k},{v}):哈希表里已有 {k} → O(1) 拿到节点引用,原地改值,再把节点搬到头部(它刚被用过,变成最新)。 } />, @@ -301,8 +299,7 @@ export function LRULab() { } zh={ <> - 新节点 {k}:{v} 挂到链表头部(最新),哈希表写入 {k} → 节点引用。 - 整个 put 没有出现任何一次 O(n) 扫描。 + 新节点 {k}:{v} 挂到链表头部(最新),哈希表写入 {k} → 节点引用。整个 put 没有出现任何一次 O(n) 扫描。 } />, @@ -330,8 +327,7 @@ export function LRULab() { } zh={ <> - get({k}) = -1:哈希表 O(1) 查无此 key —— 链表碰都不用碰。 - (它可能从未 put 过,也可能已经被淘汰了。) + get({k}) = -1:哈希表 O(1) 查无此 key —— 链表碰都不用碰。(它可能从未 put 过,也可能已经被淘汰了。) } />, @@ -581,8 +577,7 @@ export function LFUBuckets() { } zh={ <> - 每个桶是一条按时间排序的链表(桶内就是一个小 LRU);再配两张哈希表 - (key → 值和频次、freq → 桶)和一个 minFreq 变量 —— 访问 = 从 freq 桶搬进 + 每个桶是一条按时间排序的链表(桶内就是一个小 LRU);再配两张哈希表(key → 值和频次、freq → 桶)和一个 minFreq 变量 —— 访问 = 从 freq 桶搬进 freq+1 桶,淘汰 = 移除 minFreq 桶里最老的。每一步都是 O(1)。 } @@ -709,10 +704,7 @@ export function SegAnatomy() { } zh={ <> - 根节点 = 整个数组的和(24);每往下一层,区间对半分;叶子 = 单个元素。 - [0,2] 这种奇数长度的区间就让左边多分一个:mid = (0+2)/2 = 1 → 左孩子 [0,1]、 - 右孩子 [2,2]。这棵树共 ⌈log₂6⌉ + 1 = 4 层,按边数算树高是 3, - 所有操作都只沿着一条自顶向下的路径走。 + 根节点 = 整个数组的和(24);每往下一层,区间对半分;叶子 = 单个元素。[0,2] 这种奇数长度的区间就让左边多分一个:mid = (0+2)/2 = 1 → 左孩子 [0,1]、右孩子 [2,2]。这棵树共 ⌈log₂6⌉ + 1 = 4 层,按边数算树高是 3,所有操作都只沿着一条自顶向下的路径走。 } /> @@ -932,8 +924,7 @@ export function SegLab() { } zh={ <> - sum({l},{r}) = {sum}:{pack.length} 个「整段被包住」的节点(绿色)直接给出答案, - 黄色节点只是路过时被劈开 —— 访问节点数 O(log n),而不是把 {r - l + 1} 个叶子逐个相加。 + sum({l},{r}) = {sum}:{pack.length} 个「整段被包住」的节点(绿色)直接给出答案,黄色节点只是路过时被劈开 —— 访问节点数 O(log n),而不是把 {r - l + 1} 个叶子逐个相加。 } />, @@ -1293,8 +1284,7 @@ const SKIP_FRAMES: SkipFrame[] = [ } zh={ <> - L2 上 19 的右邻是 ∞ > 23 —— 会走过头,于是原地下楼到 L1。 - 下一层节点更密,能走得更细。 + L2 上 19 的右邻是 ∞ > 23 —— 会走过头,于是原地下楼到 L1。下一层节点更密,能走得更细。 } /> @@ -1348,8 +1338,7 @@ const SKIP_FRAMES: SkipFrame[] = [ } zh={ <> - L0 上 19 的右邻是 23 = 23,找到了。全程只做了 4 次比较; - 普通有序链表要从 3 开始一格格比。n 越大差距越大: + L0 上 19 的右邻是 23 = 23,找到了。全程只做了 4 次比较;普通有序链表要从 3 开始一格格比。n 越大差距越大: O(log n) vs O(n)。 } @@ -1670,8 +1659,7 @@ export function BloomLab() { } zh={ <> - 位 {zero} 还是 0 → "{word}" 一定不在。这是确定的: - 如果它插入过,这一位必然已被置 1,而位从来不会被清零。 + 位 {zero} 还是 0 → "{word}" 一定不在。这是确定的:如果它插入过,这一位必然已被置 1,而位从来不会被清零。 } />, @@ -1688,8 +1676,7 @@ export function BloomLab() { } zh={ <> - 3 个位全是 1 → 报告「可能在」。这次它确实插入过(真阳性), - 但布隆过滤器自己分辨不出来,它只看位。 + 3 个位全是 1 → 报告「可能在」。这次它确实插入过(真阳性),但布隆过滤器自己分辨不出来,它只看位。 } />, @@ -1738,8 +1725,7 @@ export function BloomLab() { } zh={ <> - 在候选词表里暂时找不到假阳性 —— 位数组还太「空」。再插入一两个词 - (位越满,误判率越高),然后再点我。 + 在候选词表里暂时找不到假阳性 —— 位数组还太「空」。再插入一两个词(位越满,误判率越高),然后再点我。 } />, diff --git a/app/array/page.tsx b/app/array/page.tsx index 5f83551..bb04c27 100644 --- a/app/array/page.tsx +++ b/app/array/page.tsx @@ -47,8 +47,7 @@ const F283: ArrayFrame[] = [ } zh={ <> - 初始:slow 指向「下一个非零元素该放的位置」,fast 负责逐个读。 - 目标是把非零元素前移,零自然落到末尾。 + 初始:slow 指向「下一个非零元素该放的位置」,fast 负责逐个读。目标是把非零元素前移,零自然落到末尾。 } /> @@ -115,8 +114,7 @@ const F283: ArrayFrame[] = [ } zh={ <> - 交换完成,slow 前移到 1。fast=2 又遇到 0,跳过。 - 前缀 [1] 现在只含非零元素。 + 交换完成,slow 前移到 1。fast=2 又遇到 0,跳过。前缀 [1] 现在只含非零元素。 } /> @@ -215,8 +213,7 @@ const F11: ArrayFrame[] = [ } zh={ <> - L=0(高 1)、R=8(高 7):面积 = min(1,7) × 宽 8 = 8。 - 较矮的一边在左侧,所以 L 右移。 + L=0(高 1)、R=8(高 7):面积 = min(1,7) × 宽 8 = 8。较矮的一边在左侧,所以 L 右移。 } /> @@ -239,8 +236,7 @@ const F11: ArrayFrame[] = [ } zh={ <> - L=1(高 8)、R=8(高 7):面积 = min(8,7) × 7 = 49,当前最优。 - 这次较矮的一边在右侧,所以 R 左移。 + L=1(高 8)、R=8(高 7):面积 = min(8,7) × 7 = 49,当前最优。这次较矮的一边在右侧,所以 R 左移。 } /> @@ -332,10 +328,8 @@ const F11: ArrayFrame[] = [ } zh={ <> - L、R 已经相邻:比较完这最后一组,两者相遇,循环结束,答案是 49。 - 为什么可以跳过这么多组合?因为任何 - 仍然保留那条较矮线的组合,宽度更小、高度不会更高,面积不可能超过刚算过的值。 - 丢掉较矮的一端不会丢掉答案,这就是 O(n²) 降到 O(n) 的依据。 + L、R 已经相邻:比较完这最后一组,两者相遇,循环结束,答案是 49。为什么可以跳过这么多组合?因为任何 + 仍然保留那条较矮线的组合,宽度更小、高度不会更高,面积不可能超过刚算过的值。丢掉较矮的一端不会丢掉答案,这就是 O(n²) 降到 O(n) 的依据。 } /> @@ -464,8 +458,7 @@ const F209: ArrayFrame[] = [ } zh={ <> - 移出 3:sum = 7,仍然 ≥ 7,长度 3 成为新的最优解。 - 再收缩一次 sum = 6,停止。 + 移出 3:sum = 7,仍然 ≥ 7,长度 3 成为新的最优解。再收缩一次 sum = 6,停止。 } /> @@ -490,9 +483,7 @@ const F209: ArrayFrame[] = [ } zh={ <> - r=5 使 sum 变为 9。收缩一次移出 2,窗口变为 [4, 3],和为 7,长度 2 —— 最终答案; - 再收缩一次移出 4,和降到 7 以下。 - l 和 r 各自最多走 n 步,总步数不超过 2n,所以是 O(n) 而不是 O(n²)。 + r=5 使 sum 变为 9。收缩一次移出 2,窗口变为 [4, 3],和为 7,长度 2 —— 最终答案;再收缩一次移出 4,和降到 7 以下。l 和 r 各自最多走 n 步,总步数不超过 2n,所以是 O(n) 而不是 O(n²)。 } /> @@ -556,8 +547,7 @@ export default function ArrayChapter() { ), zh: ( <> - 内存里一排连续的格子,门牌号就是下标。它用「中间插删要搬动后面所有元素」的代价, - 换来了整个数据结构世界里最快的一件事:O(1) 随机访问。 + 内存里一排连续的格子,门牌号就是下标。它用「中间插删要搬动后面所有元素」的代价,换来了整个数据结构世界里最快的一件事:O(1) 随机访问。 ), }} @@ -598,8 +588,7 @@ export default function ArrayChapter() { zh={ <>

- 想象健身房门口那排储物柜:柜子一个挨一个、大小一致、 - 从 0 开始编号。你拿着 5 号钥匙,不需要从 0 号一路看过去 —— + 想象健身房门口那排储物柜:柜子一个挨一个、大小一致、从 0 开始编号。你拿着 5 号钥匙,不需要从 0 号一路看过去 —— 直接走到 5 号柜前开门。这就是数组被设计出来要做好的那件事: 给我下标,我立刻给你元素。

@@ -627,8 +616,7 @@ export default function ArrayChapter() { } zh={

- 所有元素在内存里一个挨一个,中间没有空隙。好处:任意位置都能用公式算出来; - 代价:中间插入或删除必须搬动其他元素。 + 所有元素在内存里一个挨一个,中间没有空隙。好处:任意位置都能用公式算出来;代价:中间插入或删除必须搬动其他元素。

} /> @@ -670,8 +658,7 @@ export default function ArrayChapter() { } zh={

- 建好后长度不可变 —— 紧挨着它的那块内存不一定是空的。想「变长」, - 只能整体搬到一块更大的内存里,这就是 §04 的动态数组。 + 建好后长度不可变 —— 紧挨着它的那块内存不一定是空的。想「变长」,只能整体搬到一块更大的内存里,这就是 §04 的动态数组。

} /> @@ -694,9 +681,7 @@ export default function ArrayChapter() { } zh={

- 你的照片是像素数组,这个网页上的文字是字符数组,数据库的一页记录也是一条接一条存放的。 - 数组是最贴近硬件的结构,也是后面哈希表、堆、动态数组的地基。 - 把这一章学扎实,后面几章会轻松很多。 + 你的照片是像素数组,这个网页上的文字是字符数组,数据库的一页记录也是一条接一条存放的。数组是最贴近硬件的结构,也是后面哈希表、堆、动态数组的地基。把这一章学扎实,后面几章会轻松很多。

} /> @@ -731,8 +716,7 @@ export default function ArrayChapter() { zh={

内存是一条编了门牌号的长街。数组向系统申请的是 - 一段连续的门牌号。假设一个 int 数组从地址 1000 开始、 - 每个 int 占 4 字节,那么第 i 个元素住在哪,不用找 —— 用算的: + 一段连续的门牌号。假设一个 int 数组从地址 1000 开始、每个 int 占 4 字节,那么第 i 个元素住在哪,不用找 —— 用算的:

} /> @@ -818,10 +802,8 @@ export default function ArrayChapter() { zh={

CPU 不是一个字节一个字节地读内存,而是一次搬一整块 —— - 这一块叫缓存行,通常 64 字节。数组的元素紧挨着,元素是 4 字节 int 时, - 读 arr[0] 会顺便把 arr[1..15]{" "} - 一起带进缓存,顺序遍历几乎全程命中。注意这是缓存效应,不是复杂度差异: - 遍历数组和遍历链表都是 O(n),但实际运行时数组常常快上几倍。 + 这一块叫缓存行,通常 64 字节。数组的元素紧挨着,元素是 4 字节 int 时,读 arr[0] 会顺便把 arr[1..15]{" "} + 一起带进缓存,顺序遍历几乎全程命中。注意这是缓存效应,不是复杂度差异:遍历数组和遍历链表都是 O(n),但实际运行时数组常常快上几倍。

} /> @@ -963,8 +945,7 @@ export default function ArrayChapter() { } zh={

- 表格里最重要的不是结论,是那列「为什么」。亲手做几次插入删除, - 数一数搬动了几次,你就不会再记错: + 表格里最重要的不是结论,是那列「为什么」。亲手做几次插入删除,数一数搬动了几次,你就不会再记错:

} /> @@ -1019,9 +1000,7 @@ export default function ArrayChapter() {

表里那行 O(log n) 的有序查找值得单独写一遍代码。 二分查找(binary search) - 的做法是反复把剩余区间砍掉一半:拿中间元素和目标比一次,就能丢掉一半。 - 想象猜数字游戏:1~100 之间,对方只回答「大了 / 小了」,每次都猜中间值, - 最多 7 次必中(2⁷ = 128 > 100)。一百万个元素只要 20 次,十亿个只要 30 次 + 的做法是反复把剩余区间砍掉一半:拿中间元素和目标比一次,就能丢掉一半。想象猜数字游戏:1~100 之间,对方只回答「大了 / 小了」,每次都猜中间值,最多 7 次必中(2⁷ = 128 > 100)。一百万个元素只要 20 次,十亿个只要 30 次 —— 这就是对数的意义。

} @@ -1068,8 +1047,7 @@ public int search(int[] nums, int target) { <> 易错点:Java 的 int 会溢出 —— 两个数都接近 2³¹ − 1 时,(left + right) / 2{" "} - 会变成负数。标准写法是 left + (right - left) / 2, - 两数之差一定放得下。 + 会变成负数。标准写法是 left + (right - left) / 2,两数之差一定放得下。 ), }, @@ -1163,9 +1141,7 @@ var search = function (nums, target) { zh: ( <> 细节:很多示例写 (left + right) >> 1。 - >> 会先把操作数转成 32 位有符号整数, - 只有在和小于 2³¹ 时才安全。JavaScript 的数字是双精度浮点, - 能精确表示 2⁵³ 以内的整数,远超任何数组长度,所以{" "} + >> 会先把操作数转成 32 位有符号整数,只有在和小于 2³¹ 时才安全。JavaScript 的数字是双精度浮点,能精确表示 2⁵³ 以内的整数,远超任何数组长度,所以{" "} Math.floor((left + right) / 2) 始终正确。 ), @@ -1202,17 +1178,13 @@ var search = function (nums, target) { } zh={

- 上面的代码用的是闭区间 [left, right], - 两个端点都还是候选。循环不变量是: - 如果 target 在数组里,它的下标一定落在 [left, right] 内。 - 每个分支都在维持这条:nums[mid] < target{" "} + 上面的代码用的是闭区间 [left, right],两个端点都还是候选。循环不变量是: + 如果 target 在数组里,它的下标一定落在 [left, right] 内。每个分支都在维持这条:nums[mid] < target{" "} 排除了 mid 及其左边,nums[mid] > target{" "} 排除了 mid 及其右边。由不变量可以推出三个细节:① 循环条件是{" "} left <= right,因为只剩一个元素的区间也必须检查; - ② 收缩必须写 mid + 1 或 mid - 1, - 把 mid 留在区间里可能死循环;③ 二分同时需要有序和 - O(1) 随机访问,所以链表上没法二分。 - 对应练习:LC 704(模板题)、LC 35(找不到时 left 停在哪)。 + ② 收缩必须写 mid + 1 或 mid - 1,把 mid 留在区间里可能死循环;③ 二分同时需要有序和 + O(1) 随机访问,所以链表上没法二分。对应练习:LC 704(模板题)、LC 35(找不到时 left 停在哪)。

} /> @@ -1253,10 +1225,8 @@ var search = function (nums, target) { zh={

普通数组定长,但我们天天在用「能一直 push 的数组」。它的做法很朴素: - 内部维护一个定长数组 + 一个记录已用多少的 size。 - 有空位就直接写进下一格(O(1));满了就申请一个 - 更大的数组(通常是原来的 1.5 到 2 倍), - 把旧元素全部拷贝过去,再继续。亲手触发一次扩容: + 内部维护一个定长数组 + 一个记录已用多少的 size。有空位就直接写进下一格(O(1));满了就申请一个 + 更大的数组(通常是原来的 1.5 到 2 倍),把旧元素全部拷贝过去,再继续。亲手触发一次扩容:

} /> @@ -1283,15 +1253,10 @@ var search = function (nums, target) { } zh={

- 一次扩容确实是实打实的 O(n),凭什么说尾部追加是 O(1)? - 别看单次,看总账:容量从 1 按倍增涨到 n,所有扩容的总拷贝量是 + 一次扩容确实是实打实的 O(n),凭什么说尾部追加是 O(1)?别看单次,看总账:容量从 1 按倍增涨到 n,所有扩容的总拷贝量是 1 + 2 + 4 + … + n/2,小于 n。也就是说, - n 次追加总共最多做约 2n 单位的工作, - 平均每次 2 个单位,是常数。这种记账方式叫 - 均摊分析(amortized analysis), - 上面实验室里的「均摊操作数」一直在替你验证它。这里的说法要准确: - 单次追加在最坏情况下不是 O(1),扩容那一次就是 O(n); - 它是均摊 O(1) —— 昂贵的那几次足够稀少,任意 n + n 次追加总共最多做约 2n 单位的工作,平均每次 2 个单位,是常数。这种记账方式叫 + 均摊分析(amortized analysis),上面实验室里的「均摊操作数」一直在替你验证它。这里的说法要准确:单次追加在最坏情况下不是 O(1),扩容那一次就是 O(n);它是均摊 O(1) —— 昂贵的那几次足够稀少,任意 n 次追加的总代价仍是 O(n)。只要扩容倍数大于 1 就成立,不必非得翻倍。

} @@ -1412,8 +1377,7 @@ var search = function (nums, target) {

常听到「动态数组都是翻倍扩容」,这是简化说法。Java 的{" "} ArrayList 大约按 1.5 倍增长;CPython 是按新长度的约 - 1/8 再加一个小常数来超额分配,倍数小得多,而且随规模变化。 - 它们真正的共同点是:增长量与当前规模成比例 —— + 1/8 再加一个小常数来超额分配,倍数小得多,而且随规模变化。它们真正的共同点是:增长量与当前规模成比例 —— 均摊 O(1) 的论证只需要这一条性质。

} @@ -1457,8 +1421,7 @@ var search = function (nums, target) {

读到这里,动态数组的三块零件你都认识了:一个定长数组、 一个记录已用多少的 size、 - 一段满了就扩容的逻辑。现在把它们拼起来。 - 下面的实现虽小但完整:支持读写、尾部追加、任意位置插入删除。 + 一段满了就扩容的逻辑。现在把它们拼起来。下面的实现虽小但完整:支持读写、尾部追加、任意位置插入删除。

建议做法:先盖住代码,自己写一遍 push 和{" "} @@ -1575,8 +1538,7 @@ public class DynArray { zh: ( <> 真实 ArrayList 的差异:按约 1.5 倍扩容、用{" "} - System.arraycopy(比 Java 层的 for 循环快)、 - 存的是 Object[]。骨架完全一样。 + System.arraycopy(比 Java 层的 for 循环快)、存的是 Object[]。骨架完全一样。 ), }, @@ -1686,8 +1648,7 @@ class DynArray: ), zh: ( <> - 真实 CPython list 的差异:每次超额分配约 1/8 再加一个小常数, - 底层是一个存对象指针的 C 数组。逻辑完全一致。 + 真实 CPython list 的差异:每次超额分配约 1/8 再加一个小常数,底层是一个存对象指针的 C 数组。逻辑完全一致。 ), }, @@ -1794,9 +1755,7 @@ class DynArray { ), zh: ( <> - JavaScript 的 Array 本身就会自动增长, - 这里刻意用「定长思维」来写,是为了看清引擎替你做了什么。 - V8 的底层存储大约按 1.5 倍 + 16 增长。 + JavaScript 的 Array 本身就会自动增长,这里刻意用「定长思维」来写,是为了看清引擎替你做了什么。V8 的底层存储大约按 1.5 倍 + 16 增长。 ), }, @@ -1822,8 +1781,7 @@ class DynArray {

合上代码回答三个问题:① insert 为什么从后往前搬? ② removeAt 为什么从前往后补? - ③ 如果扩容改成「每次只 +1 个格子」,连续 n 次追加的总代价是多少? - 第三问的答案是 1 + 2 + … + n = O(n²) —— 这正是容量必须按 + ③ 如果扩容改成「每次只 +1 个格子」,连续 n 次追加的总代价是多少?第三问的答案是 1 + 2 + … + n = O(n²) —— 这正是容量必须按 倍数增长、而不是按固定量增长的原因。

} @@ -1862,10 +1820,8 @@ class DynArray { zh={ <>

- 矩阵就是数组的数组:matrix[i][j] 表示第 i 行第 j 列。 - 内存里没有行列,矩阵必须铺成一条线。常见的铺法是 - 一行接一行,叫行优先(row-major), - 定位公式因此只是升了一个维度: + 矩阵就是数组的数组:matrix[i][j] 表示第 i 行第 j 列。内存里没有行列,矩阵必须铺成一条线。常见的铺法是 + 一行接一行,叫行优先(row-major),定位公式因此只是升了一个维度: 一维下标 = 行号 × 列数 + 列号。点下面的格子验证:

@@ -1898,13 +1854,8 @@ class DynArray { } zh={

- 在 C 语言和 NumPy 里,二维数组是一整块连续内存, - 上面那条公式就是编译器实际用的寻址方式。而 Java 的{" "} - int[][] 是一个存放各行引用的数组: - 每一行内部连续,但两行之间不一定相邻,各行长度甚至可以不同。 - Python 的嵌套 list、JavaScript 的嵌套 Array 也是同样的结构。 - 当你自己把矩阵压平成一维数组时(很多题目就是这么做的), - 这条公式描述的正是那种布局。 + 在 C 语言和 NumPy 里,二维数组是一整块连续内存,上面那条公式就是编译器实际用的寻址方式。而 Java 的{" "} + int[][] 是一个存放各行引用的数组:每一行内部连续,但两行之间不一定相邻,各行长度甚至可以不同。Python 的嵌套 list、JavaScript 的嵌套 Array 也是同样的结构。当你自己把矩阵压平成一维数组时(很多题目就是这么做的),这条公式描述的正是那种布局。

} /> @@ -1926,10 +1877,7 @@ class DynArray { } zh={

- 先行后列(for i → for j)读到的是内存里相邻的元素, - 绝大多数读取都命中缓存;先列后行则每一步跳过一整行, - 大矩阵下能慢好几倍。两者复杂度同为 O(mn),差别是缓存效应, - 不是复杂度差异(回到 §02 的缓存行)。 + 先行后列(for i → for j)读到的是内存里相邻的元素,绝大多数读取都命中缓存;先列后行则每一步跳过一整行,大矩阵下能慢好几倍。两者复杂度同为 O(mn),差别是缓存效应,不是复杂度差异(回到 §02 的缓存行)。

} /> @@ -1962,8 +1910,7 @@ class DynArray { dirs = [[-1,0],[1,0],[0,-1],[0,1]]{" "} 一个循环就能完成,不用写四段 if;② 先做边界检查( 0 ≤ i < m 且 0 ≤ j < n)再读格子; - ③ 想要 O(1) 额外空间,就用矩阵自身的第一行和第一列当标记(LC 73)。 - 矩阵同时也是「网格图」,图那一章会把它当图来遍历。 + ③ 想要 O(1) 额外空间,就用矩阵自身的第一行和第一列当标记(LC 73)。矩阵同时也是「网格图」,图那一章会把它当图来遍历。

} /> @@ -2002,13 +1949,10 @@ class DynArray { } zh={

- 三种语言里抽象是一致的:元素按下标排列、 - 按下标访问 O(1)、中间插删 O(n)。但 - 内存布局并不保证相同。Java 给你两个类型: - 定长的 int[] 和动态的 ArrayList; + 三种语言里抽象是一致的:元素按下标排列、按下标访问 O(1)、中间插删 O(n)。但 + 内存布局并不保证相同。Java 给你两个类型:定长的 int[] 和动态的 ArrayList; Python 只有动态的 list,里面存的是对象指针; - JavaScript 的 Array 本质是一个键为「类下标字符串」的对象, - 只要保持稠密,V8 就用连续的 packed 存储,一旦不稠密就换成字典表示。 + JavaScript 的 Array 本质是一个键为「类下标字符串」的对象,只要保持稠密,V8 就用连续的 packed 存储,一旦不稠密就换成字典表示。

} /> @@ -2100,9 +2044,7 @@ for v in arr: # 遍历 ), zh: ( <> - 易错点:list 存的是对象指针而不是裸数值,切片会复制。 - 真要定长数值数组请用 array 模块或 NumPy。 - 初始化二维数组不要写 [[0]*m]*n —— + 易错点:list 存的是对象指针而不是裸数值,切片会复制。真要定长数值数组请用 array 模块或 NumPy。初始化二维数组不要写 [[0]*m]*n —— 这样 n 行会共享同一个内层 list。 ), @@ -2147,10 +2089,8 @@ for (const v of arr) console.log(v); // 遍历`, zh: ( <> 易错点:让数组变稀疏(在短数组上写{" "} - arr[1000] = 1)会让引擎切换成字典表示,访问明显变慢; - 另外 sort() 默认把元素当字符串比较 ——{" "} - [10,9,1].sort() 得到 [1,10,9], - 记得传比较函数。 + arr[1000] = 1)会让引擎切换成字典表示,访问明显变慢;另外 sort() 默认把元素当字符串比较 ——{" "} + [10,9,1].sort() 得到 [1,10,9],记得传比较函数。 ), }, @@ -2280,10 +2220,8 @@ for (const v of arr) console.log(v); // 遍历`, } zh={ <> - * Java 的 subList 返回的是视图(view)而不是拷贝: - 创建视图是 O(1),复制成新列表(如{" "} - new ArrayList<>(list.subList(a, b)))才是 O(k)。 - 改视图会改到原 list,这是三种语言的切片语义里最特殊的一个。 + * Java 的 subList 返回的是视图(view)而不是拷贝:创建视图是 O(1),复制成新列表(如{" "} + new ArrayList<>(list.subList(a, b)))才是 O(k)。改视图会改到原 list,这是三种语言的切片语义里最特殊的一个。 } /> @@ -2324,9 +2262,7 @@ for (const v of arr) console.log(v); // 遍历`,

数组题的暴力解通常是两层循环 O(n²):枚举所有下标对。而 双指针家族的做法,是让两个下标 - 各自只朝一个方向走, - 每一步都用问题本身的性质排除掉一批候选,把 O(n²) 压成 O(n)。 - 常见的有三种形式: + 各自只朝一个方向走,每一步都用问题本身的性质排除掉一批候选,把 O(n²) 压成 O(n)。常见的有三种形式:

} /> @@ -2349,8 +2285,7 @@ for (const v of arr) console.log(v); // 遍历`, } zh={

- fast 负责读,slow 负责写,slow 左侧始终是已整理好的区域。 - 原地删除和压缩类题目的标准写法,见 LC 283、26、27。 + fast 负责读,slow 负责写,slow 左侧始终是已整理好的区域。原地删除和压缩类题目的标准写法,见 LC 283、26、27。

} /> @@ -2372,8 +2307,7 @@ for (const v of arr) console.log(v); // 遍历`, } zh={

- 从两端向中间靠拢。利用有序性,或者「较矮的一端限制了结果」这一点, - 每一步都能安全丢掉一端,见 LC 11、167、15、42。 + 从两端向中间靠拢。利用有序性,或者「较矮的一端限制了结果」这一点,每一步都能安全丢掉一端,见 LC 11、167、15、42。

} /> @@ -2395,8 +2329,7 @@ for (const v of arr) console.log(v); // 遍历`, } zh={

- 连续子数组问题专用:右端加入、左端移出,窗口里维护一个可以逐步更新的量, - 见 LC 209、3、76。 + 连续子数组问题专用:右端加入、左端移出,窗口里维护一个可以逐步更新的量,见 LC 209、3、76。

} /> @@ -2430,9 +2363,9 @@ for (const v of arr) console.log(v); // 遍历`, zh={

题意:把所有 0 移到数组末尾,非零元素保持相对顺序,必须原地操作。 - 暴力:新建一个数组,先抄非零元素再补零 —— + 暴力:新建一个数组,先抄非零元素再补零 —— 时间 O(n) 但用了 O(n) 额外空间,不符合题目要求。 - 正解:换个目标:不搬 0,搬非零元素。 + 正解:换个目标:不搬 0,搬非零元素。

} /> @@ -2544,10 +2477,7 @@ for (const v of arr) console.log(v); // 遍历`, } zh={

- 时间 O(n)(fast 只走一遍),空间 O(1)。 - 面试官常追问:「如果要移动的不是 0 而是某个给定值?」(那就是 LC 27) - 「能否减少写操作?」(只在 fast ≠ slow 时才交换;或者先把非零元素依次写到前面,最后统一补 0)。 - 他们真正想听的是循环不变量:slow 左边恒为非零元素,且保持原有相对顺序。 + 时间 O(n)(fast 只走一遍),空间 O(1)。面试官常追问:「如果要移动的不是 0 而是某个给定值?」(那就是 LC 27)「能否减少写操作?」(只在 fast ≠ slow 时才交换;或者先把非零元素依次写到前面,最后统一补 0)。他们真正想听的是循环不变量:slow 左边恒为非零元素,且保持原有相对顺序。

} /> @@ -2584,11 +2514,9 @@ for (const v of arr) console.log(v); // 遍历`, } zh={

- 题意:给 n 条竖线,选两条,与 x 轴围成一个容器,求最大盛水面积 - (面积 = 两线间距 × 较矮的那条)。暴力:枚举所有线对,O(n²)。 - 正解:从最宽的一对开始向中间靠拢, - 每一步都移动较矮的那一端: - 宽度反正只会变小,只有换掉较矮的线,面积才有机会变大。 + 题意:给 n 条竖线,选两条,与 x 轴围成一个容器,求最大盛水面积(面积 = 两线间距 × 较矮的那条)。暴力:枚举所有线对,O(n²)。 + 正解:从最宽的一对开始向中间靠拢, + 每一步都移动较矮的那一端:宽度反正只会变小,只有换掉较矮的线,面积才有机会变大。

} /> @@ -2708,11 +2636,7 @@ for (const v of arr) console.log(v); // 遍历`, } zh={

- 设左边那条线较矮。考虑所有仍然保留这条左线的组合: - 另一条线只能落在当前两端之间,宽度更小,而高度仍被这条矮线限制。 - 这些组合的面积都不会超过刚刚算出的面积,可以整批丢弃。 - 所以每一步都在无损地排除一批候选,总共 n 步就能扫完:O(n²) 降为 O(n)。 - 这段论证就是面试时期待你说出来的答案。 + 设左边那条线较矮。考虑所有仍然保留这条左线的组合:另一条线只能落在当前两端之间,宽度更小,而高度仍被这条矮线限制。这些组合的面积都不会超过刚刚算出的面积,可以整批丢弃。所以每一步都在无损地排除一批候选,总共 n 步就能扫完:O(n²) 降为 O(n)。这段论证就是面试时期待你说出来的答案。

} /> @@ -2753,11 +2677,9 @@ for (const v of arr) console.log(v); // 遍历`,

题意:在正整数数组中,找出和 ≥ target 的 最短连续子数组的长度。 - 暴力:枚举所有起点和终点,O(n²)。 - 正解:滑动窗口。因为元素全为正,窗口和在右端右移时 - 只增不减、在左端右移时只减不增。 - 这正是保证正确性的不变量:一旦和降到 target 以下, - 继续右移左端只会更小,所以左端永远不需要回头。 + 暴力:枚举所有起点和终点,O(n²)。 + 正解:滑动窗口。因为元素全为正,窗口和在右端右移时 + 只增不减、在左端右移时只减不增。这正是保证正确性的不变量:一旦和降到 target 以下,继续右移左端只会更小,所以左端永远不需要回头。

} /> @@ -2880,12 +2802,8 @@ for (const v of arr) console.log(v); // 遍历`, zh={

虽然 for 里套了 while,但 l 和 r{" "} - 都只前进不后退,各自最多 n 步,总步数不超过 2n,时间是 O(n)。 - 任何窗口题都可以用三个问题来描述:① 窗口里维护什么量(这里是 sum)? - ② 什么时候扩(这里每一步都扩)?③ 什么时候缩(这里是 sum ≥ target)? - 想清这三问,LC 3、76、438 都是同一套写法。一个前提要记住: - 这个窗口成立是因为元素全为正;一旦允许负数,窗口和不再单调, - 就要改用前缀和的思路。 + 都只前进不后退,各自最多 n 步,总步数不超过 2n,时间是 O(n)。任何窗口题都可以用三个问题来描述:① 窗口里维护什么量(这里是 sum)? + ② 什么时候扩(这里每一步都扩)?③ 什么时候缩(这里是 sum ≥ target)?想清这三问,LC 3、76、438 都是同一套写法。一个前提要记住:这个窗口成立是因为元素全为正;一旦允许负数,窗口和不再单调,就要改用前缀和的思路。

} /> @@ -2961,8 +2879,7 @@ for (const v of arr) console.log(v); // 遍历`, ), zh: ( <> - 要不要搬动别的元素?这一个问题就决定了数组每种操作的成本: - 尾部 O(1),头部和中间 O(n) —— 代价来自搬动,不是来自查找。 + 要不要搬动别的元素?这一个问题就决定了数组每种操作的成本:尾部 O(1),头部和中间 O(n) —— 代价来自搬动,不是来自查找。 ), }, @@ -3000,10 +2917,7 @@ for (const v of arr) console.log(v); // 遍历`, ), zh: ( <> - 二分查找同时需要有序和 O(1) 随机访问。 - 闭区间写法的不变量是「答案若存在,必在 [left, right] 内」, - 由此得出 left <= right、mid ± 1, - 以及 Java 里防溢出的中点写法{" "} + 二分查找同时需要有序和 O(1) 随机访问。闭区间写法的不变量是「答案若存在,必在 [left, right] 内」,由此得出 left <= right、mid ± 1,以及 Java 里防溢出的中点写法{" "} left + (right - left) / 2。 ), @@ -3022,10 +2936,8 @@ for (const v of arr) console.log(v); // 遍历`, zh: ( <> 二维只是读法:行优先下{" "} - 一维下标 = 行号 × 列数 + 列号。 - C 和 NumPy 里确实是一整块连续内存,Java 的{" "} - int[][] 则是一个存放各行引用的数组。 - 按行遍历更快是缓存行带来的,不是复杂度的差别。 + 一维下标 = 行号 × 列数 + 列号。C 和 NumPy 里确实是一整块连续内存,Java 的{" "} + int[][] 则是一个存放各行引用的数组。按行遍历更快是缓存行带来的,不是复杂度的差别。 ), }, @@ -3045,10 +2957,8 @@ for (const v of arr) console.log(v); // 遍历`, ), zh: ( <> - 双指针三种形式:同向用于原地压缩,对撞用于有序数组或「较矮端限制结果」, - 滑动窗口用于连续子数组。三者背后是同一个思想: - 用单调性排除候选,把 O(n²) 压成 O(n), - 而且每种都要能说清它的不变量。 + 双指针三种形式:同向用于原地压缩,对撞用于有序数组或「较矮端限制结果」,滑动窗口用于连续子数组。三者背后是同一个思想: + 用单调性排除候选,把 O(n²) 压成 O(n),而且每种都要能说清它的不变量。 ), }, diff --git a/app/array/viz.tsx b/app/array/viz.tsx index 56b0120..822da58 100644 --- a/app/array/viz.tsx +++ b/app/array/viz.tsx @@ -69,8 +69,7 @@ export function IndexLab() { zh={ <> arr[{sel}] 的地址 = {BASE}(首地址) + {sel} × {SIZE} - (元素大小) = {BASE + sel * SIZE} —— 一次乘加,不用查找, - 这就是 O(1)。哪怕数组有一亿个元素,公式还是这一条。 + (元素大小) = {BASE + sel * SIZE} —— 一次乘加,不用查找,这就是 O(1)。哪怕数组有一亿个元素,公式还是这一条。 } /> @@ -254,8 +253,7 @@ export function ShiftLab() { } zh={ <> - 在下标 {pos} 插入 {v} 完成,一共搬动了 {done} 个元素。 - 数组越长、位置越靠前,搬动的越多,这就是 O(n)。 + 在下标 {pos} 插入 {v} 完成,一共搬动了 {done} 个元素。数组越长、位置越靠前,搬动的越多,这就是 O(n)。 } />, diff --git a/app/atlas/page.tsx b/app/atlas/page.tsx index de2ba2c..665090f 100644 --- a/app/atlas/page.tsx +++ b/app/atlas/page.tsx @@ -840,9 +840,7 @@ export default function AtlasChapter() { ), zh: ( <> - 十三种结构都认识了,最后一课只教一件事:看到问题的那一刻, - 你脑子里应该亮起哪盏灯。选型没有玄学 —— 把操作需求列出来, - 对着价格表挑就是了。 + 十三种结构都认识了,最后一课只教一件事:看到问题的那一刻,你脑子里应该亮起哪盏灯。选型没有玄学 —— 把操作需求列出来,对着价格表挑就是了。 ), }} @@ -882,8 +880,7 @@ export default function AtlasChapter() { zh={ <> 它不是标准答案,是提问顺序:先问「核心动作是什么」,再问「要不要有序 / - 前缀 / 动态修改」。真实题目常常要组合(LRU = 哈希+链表; - 前 K 个高频 = 哈希+堆)—— 先各自选型,再拼积木。 + 前缀 / 动态修改」。真实题目常常要组合(LRU = 哈希+链表;前 K 个高频 = 哈希+堆)—— 先各自选型,再拼积木。 } /> @@ -1123,8 +1120,7 @@ export default function AtlasChapter() { zh={ <> 沿着本课的伏笔继续:二分的边界变体(旋转数组/答案二分)→ - 回溯(递归树,你在二叉树章练过的思维)→ 贪心 → 动态规划 - (Kadane 算法你已经在 LC 53 的题解里见过,它就是 DP)。每个专题都建立在你手里的结构上。 + 回溯(递归树,你在二叉树章练过的思维)→ 贪心 → 动态规划(Kadane 算法你已经在 LC 53 的题解里见过,它就是 DP)。每个专题都建立在你手里的结构上。 } /> @@ -1149,8 +1145,7 @@ export default function AtlasChapter() { zh={ <> 把结构放大一万倍:哈希 → 一致性哈希与分片;跳表 → Redis zset; - B+ 树 → 数据库索引;布隆过滤器 → 缓存穿透防护;队列 → Kafka。 - 这门课的每个 Callout「工程现场」都是入口。 + B+ 树 → 数据库索引;布隆过滤器 → 缓存穿透防护;队列 → Kafka。这门课的每个 Callout「工程现场」都是入口。 } /> @@ -1175,8 +1170,7 @@ export default function AtlasChapter() { } zh={ <> - 回到 §04 的总表:先扫 Easy 建手感,再限时刷 Medium。 - 每道题做完问三句:复杂度多少?为什么能优化?换个数据结构会怎样? + 回到 §04 的总表:先扫 Easy 建手感,再限时刷 Medium。每道题做完问三句:复杂度多少?为什么能优化?换个数据结构会怎样? —— 面试考的就是这三句。 } @@ -1252,8 +1246,7 @@ export default function AtlasChapter() { ), zh: ( <> - 选型三步:列出操作需求 → 标出频率最高的操作 → 对着复杂度表挑 - 让它最便宜的结构。 + 选型三步:列出操作需求 → 标出频率最高的操作 → 对着复杂度表挑让它最便宜的结构。 ), }, @@ -1268,8 +1261,7 @@ export default function AtlasChapter() { ), zh: ( <> - 两条物理直觉贯穿一切:连续内存靠「首地址+偏移」公式(数组/哈希桶/堆), - 离散内存靠引用纸条(链表/树/图)。 + 两条物理直觉贯穿一切:连续内存靠「首地址+偏移」公式(数组/哈希桶/堆),离散内存靠引用纸条(链表/树/图)。 ), }, @@ -1284,8 +1276,7 @@ export default function AtlasChapter() { ), zh: ( <> - 复杂题拼积木:LRU = 哈希+双向链表;前 K 高频 = 哈希+堆; - 单词搜索 = Trie+回溯 —— 先各自选型,再组合。 + 复杂题拼积木:LRU = 哈希+双向链表;前 K 高频 = 哈希+堆;单词搜索 = Trie+回溯 —— 先各自选型,再组合。 ), }, @@ -1300,8 +1291,7 @@ export default function AtlasChapter() { ), zh: ( <> - 没有万能结构:每个 O(1) 都在别处付了账。能讲清楚「我为什么不用 X」, - 比会用 X 更能打动面试官。 + 没有万能结构:每个 O(1) 都在别处付了账。能讲清楚「我为什么不用 X」,比会用 X 更能打动面试官。 ), }, @@ -1315,8 +1305,7 @@ export default function AtlasChapter() { ), zh: ( <> - 这门课的终点是你的起点:把题单总表完整做三遍,让每一章都亮起绿灯, - 然后把学到的内容用到实际问题中去。 + 这门课的终点是你的起点:把题单总表完整做三遍,让每一章都亮起绿灯,然后把学到的内容用到实际问题中去。 ), }, diff --git a/app/binary-tree/page.tsx b/app/binary-tree/page.tsx index f1fa169..88bce31 100644 --- a/app/binary-tree/page.tsx +++ b/app/binary-tree/page.tsx @@ -95,10 +95,8 @@ export default function BinaryTreeChapter() { ), zh: ( <> - 链表节点只握着一个指向下一站的引用;让它握两个, - 链就长成了树。树是层级数据的天然形状,也是递归 - 第一次真正派上用场的地方 —— 记住「树 = 根 + 左子树 + 右子树」这一句, - 后面五章都是它的变奏。 + 链表节点只握着一个指向下一站的引用;让它握两个,链就长成了树。树是层级数据的天然形状,也是递归 + 第一次真正派上用场的地方 —— 记住「树 = 根 + 左子树 + 右子树」这一句,后面五章都是它的变奏。 ), }} @@ -155,21 +153,16 @@ export default function BinaryTreeChapter() { <>

到目前为止,我们的数据都排成一条线 - :数组、链表、栈、队列,每个元素最多有一个「下一个」。 - 但看看你身边的信息:文件夹里套着子文件夹、一位总监下面带着几个组、 - 家谱里一位祖先开枝散叶、这个网页本身(HTML 的 DOM)也是标签套标签 —— + :数组、链表、栈、队列,每个元素最多有一个「下一个」。但看看你身边的信息:文件夹里套着子文件夹、一位总监下面带着几个组、家谱里一位祖先开枝散叶、这个网页本身(HTML 的 DOM)也是标签套标签 —— 层级(hierarchy)无处不在,而一条线画不出层级。

怎么升级?回想链表:每个节点有一个 next{" "} 引用,指向下一站。现在允许它指向不止一个「下一站」 - —— 数据结构立刻从「一条链」长成「一棵倒挂的树」:最上面是根, - 往下逐层展开,末端是叶。如果每个节点最多指向两个,叫{" "} + —— 数据结构立刻从「一条链」长成「一棵倒挂的树」:最上面是根,往下逐层展开,末端是叶。如果每个节点最多指向两个,叫{" "} left 和 right,就是本章的主角 - 二叉树(binary tree)。为什么偏偏是两个? - 两个分支已经能表达任何「二选一」的决策 —— - 下一章二叉搜索树的地基就是它;而且任意多叉树都能改写成二叉形态: - 让 left 表示「第一个孩子」、right{" "} + 二叉树(binary tree)。为什么偏偏是两个?两个分支已经能表达任何「二选一」的决策 —— + 下一章二叉搜索树的地基就是它;而且任意多叉树都能改写成二叉形态:让 left 表示「第一个孩子」、right{" "} 表示「下一个兄弟」即可。所以二叉树是整个树家族的标准构件。

@@ -196,8 +189,7 @@ export default function BinaryTreeChapter() { zh={ <> 每个节点至多一个左孩子、一个右孩子,而且两个位置不能互换 - :只有左孩子的节点,和只有右孩子的节点,是两棵不同的树。 - 孩子可以缺,但不能多出第三个。 + :只有左孩子的节点,和只有右孩子的节点,是两棵不同的树。孩子可以缺,但不能多出第三个。 } /> @@ -221,8 +213,7 @@ export default function BinaryTreeChapter() { zh={ <> 除根以外,每个节点恰好有一个父节点 - ;顺着孩子引用一直走,永远不会绕回某个祖先。 - 去掉这条规则,结构就退化成图 —— 那是第 12 章的内容。 + ;顺着孩子引用一直走,永远不会绕回某个祖先。去掉这条规则,结构就退化成图 —— 那是第 12 章的内容。 } /> @@ -244,8 +235,7 @@ export default function BinaryTreeChapter() { } zh={ <> - 从任何一个节点往下看,看到的又是一棵合法的二叉树(它的子树)。 - 这条自相似性质正是递归能用在树上的原因,本章会反复用到它。 + 从任何一个节点往下看,看到的又是一棵合法的二叉树(它的子树)。这条自相似性质正是递归能用在树上的原因,本章会反复用到它。 } /> @@ -290,9 +280,7 @@ export default function BinaryTreeChapter() { (这也是主流约定):节点的深度 depth = 从根走到它经过的边数,所以根的深度是 0;节点的高度 height{" "} = 从它往下走到叶子的最长路径的边数,所以叶子的高度是 0; - 整棵树的高度 = 根的高度。于是只有一个根的树,树高为 0; - 空树按惯例记作 −1。第 8、9 章沿用同一套定义, - 两章的公式可以直接对照。 + 整棵树的高度 = 根的高度。于是只有一个根的树,树高为 0;空树按惯例记作 −1。第 8、9 章沿用同一套定义,两章的公式可以直接对照。

} /> @@ -343,11 +331,7 @@ export default function BinaryTreeChapter() {

把节点按层序从 0 开始编号,于是编号 i 的两个孩子恰好落在{" "} 2i+1 和 2i+2,父亲落在{" "} - (i-1)/2 向下取整 —— 算术完全替代了引用。 - 但这只有在「用到的编号是一段没有空洞的连续区间」时才划算, - 而这正是完全二叉树的定义:n 个节点用 n 格数组。 - 别的形状会留下空洞:一条始终向右倾斜的 n 节点链, - 最深的节点编号是 2n−2,要存下 n 个值就得开 2n−1 + (i-1)/2 向下取整 —— 算术完全替代了引用。但这只有在「用到的编号是一段没有空洞的连续区间」时才划算,而这正是完全二叉树的定义:n 个节点用 n 格数组。别的形状会留下空洞:一条始终向右倾斜的 n 节点链,最深的节点编号是 2n−2,要存下 n 个值就得开 2n−1 格。所以数组存树只对完全二叉树成立,第 9 章的堆也才要刻意维持「完全」。

} @@ -377,9 +361,7 @@ export default function BinaryTreeChapter() {

浏览器渲染页面 = 遍历 DOM 树;ls -R 列目录 = 遍历文件树;编译器先把源码变成语法树(AST)再翻译;JSON - 解析出来就是一棵树;数据库索引通常是 B+ 树 —— 它不是二叉的, - 但思路一样:每个节点孩子数有上限的层级结构。 - 只要数据是嵌套的,树就是它的形状 —— + 解析出来就是一棵树;数据库索引通常是 B+ 树 —— 它不是二叉的,但思路一样:每个节点孩子数有上限的层级结构。只要数据是嵌套的,树就是它的形状 —— 这也是树的遍历成为面试保留节目的原因。

} @@ -493,10 +475,8 @@ export default function BinaryTreeChapter() { 一棵二叉树,要么是空的,要么 = 一个根节点 + 一棵左子树 + 一棵右子树(而这两棵子树,又各自是一棵二叉树)。 - {" "} - 注意「空也是树」—— 它不是勉强容忍的特例,而是递归的地基: - 任何叶子的左右孩子都是空树,所以「空树怎么办」永远是递归的第一行代码。 - 这句话读三遍,本章剩下的一切都是它的翻译。 + + 注意「空也是树」—— 它不是勉强容忍的特例,而是递归的地基:任何叶子的左右孩子都是空树,所以「空树怎么办」永远是递归的第一行代码。这句话读三遍,本章剩下的一切都是它的翻译。

} /> @@ -515,11 +495,8 @@ export default function BinaryTreeChapter() { } zh={

- 一个有用的计数:n 个节点的二叉树一共有 2n 条「孩子插槽」, - 其中只用了 n−1 条(除根以外每个节点恰好当一次别人的孩子), - 剩下 n+1 条插槽是 null。null 比节点还多 —— - 这就是终止条件会被走到那么多次的原因,也说明它不是「写得整齐」的问题, - 而是不写就必错。 + 一个有用的计数:n 个节点的二叉树一共有 2n 条「孩子插槽」,其中只用了 n−1 条(除根以外每个节点恰好当一次别人的孩子),剩下 n+1 条插槽是 null。null 比节点还多 —— + 这就是终止条件会被走到那么多次的原因,也说明它不是「写得整齐」的问题,而是不写就必错。

} /> @@ -643,10 +620,7 @@ console.log(factorial(3)); // 6`, } zh={

- 「函数还没执行完,又调用了自己 —— 电脑不会晕吗?」不会, - 因为有调用栈(第 4 章的调用栈演示又出现了): - 每次调用压入一个新栈帧,各帧独立保存自己的参数和执行进度。 - 眼见为实,逐帧看 factorial(3) 的一生: + 「函数还没执行完,又调用了自己 —— 电脑不会晕吗?」不会,因为有调用栈(第 4 章的调用栈演示又出现了):每次调用压入一个新栈帧,各帧独立保存自己的参数和执行进度。眼见为实,逐帧看 factorial(3) 的一生:

} /> @@ -685,8 +659,7 @@ console.log(factorial(3)); // 6`, <> 用一句话说清函数到底返回什么,而且这句话对每个 节点都成立,不只对根成立。以数节点为例: - count(node) 返回「以 node 为根的子树里有多少个节点」。 - 这句话不先写下来,后面两个问题根本没法回答。 + count(node) 返回「以 node 为根的子树里有多少个节点」。这句话不先写下来,后面两个问题根本没法回答。 } /> @@ -713,8 +686,7 @@ console.log(factorial(3)); // 6`, zh={ <> 最小的、不用递归就能直接回答的输入。阶乘是 n ≤ - 1;树上几乎永远是空树,即 node == null。 - 它写在第一行,而且它返回的值必须满足问题一的承诺:count(null) = + 1;树上几乎永远是空树,即 node == null。它写在第一行,而且它返回的值必须满足问题一的承诺:count(null) = 0。 } @@ -746,8 +718,7 @@ console.log(factorial(3)); // 6`, zh={ <> 假设递归调用已经返回了正确答案,只管怎么用它们拼出当前答案: - count(node) = 1 + count(左) + count(右)。 - 这个假设是允许的,因为它就是数学归纳法的归纳假设,不是碰运气 —— + count(node) = 1 + count(左) + count(右)。这个假设是允许的,因为它就是数学归纳法的归纳假设,不是碰运气 —— 见本节末尾那条说明。三个问题里,只有这一步需要你真正动脑设计。 } @@ -773,11 +744,9 @@ console.log(factorial(3)); // 6`, zh={

现在回到树。§02 的钥匙说「树 = 根 + 左子树 + 右子树」,那么 - 数节点这个问题天然就是递归形状。 - 照着三个问题回答一遍:承诺是 + 数节点这个问题天然就是递归形状。照着三个问题回答一遍:承诺是 count(node) 返回「以 node 为根的子树里有多少个节点」 - ;终止条件是 count(null) = 0;合并是 1 + count(左) + count(右)。 - 看它在一棵 7 节点的树上怎么跑 —— + ;终止条件是 count(null) = 0;合并是 1 + count(左) + count(右)。看它在一棵 7 节点的树上怎么跑 —— 注意右侧调用栈的涨落,以及每个节点算完时亮出的返回值:

} @@ -807,8 +776,7 @@ console.log(factorial(3)); // 6`, zh={

没有 if (node == null) return 0;,代码就会试图读{" "} - null.left,抛空指针异常;换一种写法则会无限自我调用, - 栈帧堆到几万层 —— Java 和 JavaScript 抛栈溢出,Python 抛{" "} + null.left,抛空指针异常;换一种写法则会无限自我调用,栈帧堆到几万层 —— Java 和 JavaScript 抛栈溢出,Python 抛{" "} RecursionError。养成肌肉记忆: 第一行永远先问「空树怎么办」。§05 会给出各语言的递归深度红线。

@@ -840,12 +808,8 @@ console.log(factorial(3)); // 6`, } zh={

- 数学归纳法:先证 n = 1 成立(终止条件), - 再证「若 n−1 成立则 n 成立」(合并这一步), - 就证明了对一切 n 成立。递归是同一个论证的可执行版本。 - 它成立要满足两个条件:终止条件本身正确,且每次调用规模 - 严格变小,从而终止条件一定会被走到。 - 新手在脑子里追着展开三层就晕了;正确的做法是只检查一层: + 数学归纳法:先证 n = 1 成立(终止条件),再证「若 n−1 成立则 n 成立」(合并这一步),就证明了对一切 n 成立。递归是同一个论证的可执行版本。它成立要满足两个条件:终止条件本身正确,且每次调用规模 + 严格变小,从而终止条件一定会被走到。新手在脑子里追着展开三层就晕了;正确的做法是只检查一层: 假定孩子给的答案是对的,我这一层拼出来的答案对不对?

} @@ -898,16 +862,10 @@ console.log(factorial(3)); // 6`,

树是二维的,而打印、比较、序列化都需要一维序列 —— 按某种固定顺序走遍每个节点就叫 - 遍历(traversal)。深度优先(DFS)的路线只有一条: - 顺着一条分支扎到底,再回头走下一条。三种 DFS 遍历的差别只有一个: - 节点自己相对于两棵子树在什么时候被访问。 - 先访问自己 = 前序(根 → 左 → 右); - 在两棵子树之间访问 = 中序(左 → 根 → 右); - 最后访问 = 后序(左 → 右 → + 遍历(traversal)。深度优先(DFS)的路线只有一条:顺着一条分支扎到底,再回头走下一条。三种 DFS 遍历的差别只有一个: + 节点自己相对于两棵子树在什么时候被访问。先访问自己 = 前序(根 → 左 → 右);在两棵子树之间访问 = 中序(左 → 根 → 右);最后访问 = 后序(左 → 右 → 根)。左永远在右前面,这只是约定。第四种是广度优先(BFS)的 - 层序:先整个第一层,再整个第二层,每层从左到右。 - 它不「扎到底」,靠队列(第 5 章)驱动。 - 亲手把四种走法各放一遍: + 层序:先整个第一层,再整个第二层,每层从左到右。它不「扎到底」,靠队列(第 5 章)驱动。亲手把四种走法各放一遍:

} /> @@ -1077,8 +1035,7 @@ console.log(factorial(3)); // 6`, zh={

锚点一:DFS 三种遍历的名字说的都是根什么时候被访问 —— - pre(前)/ in(中)/ post(后);左永远在右前面,三种都一样。 - 锚点二:DFS 的额外空间花在栈上 + pre(前)/ in(中)/ post(后);左永远在右前面,三种都一样。锚点二:DFS 的额外空间花在栈上 (递归栈或自己维护的栈),所以是 O(h),h 是树的高度。 要写 O(h),不要写 O(log n) —— 只有树平衡时 h 才约等于 log n;退化成一条链时 h = n−1。BFS 的额外空间花在队列上 @@ -1122,12 +1079,9 @@ console.log(factorial(3)); // 6`, } zh={

- 对比三个递归版本,你会发现只有一行的位置不同: - 输出节点那行,分别落在两次递归调用之前、之间、之后 —— - 因为路线本来就一样。迭代版前序则把隐藏的栈搬到明面上: - 用自己的栈保存「待处理的节点」,并且先压右孩子 - —— 栈返回的是最后压入的那个,所以后压左孩子,左孩子才会先被弹出。 - 最下面的层序函数在内层循环之前记下了 size,§07 + 对比三个递归版本,你会发现只有一行的位置不同:输出节点那行,分别落在两次递归调用之前、之间、之后 —— + 因为路线本来就一样。迭代版前序则把隐藏的栈搬到明面上:用自己的栈保存「待处理的节点」,并且先压右孩子 + —— 栈返回的是最后压入的那个,所以后压左孩子,左孩子才会先被弹出。最下面的层序函数在内层循环之前记下了 size,§07 会讲清这一行为什么就是层与层的分界线。

} @@ -1282,8 +1236,7 @@ class Traversals { ), zh: ( <> - 栈深:JVM 默认线程栈在常见的 64 位平台上约为 1 MB(具体随平台而异), - 退化成一条链的百万节点树会抛 StackOverflowError —— + 栈深:JVM 默认线程栈在常见的 64 位平台上约为 1 MB(具体随平台而异),退化成一条链的百万节点树会抛 StackOverflowError —— 深树要么用迭代版,要么用 -Xss 调大栈。 ), @@ -1602,14 +1555,10 @@ function levelOrder(root) { } zh={

- 完整的回答:递归版读起来就是树的定义本身, - 代价是栈深有限(Python 默认约 1000 层,JVM 和 JS + 完整的回答:递归版读起来就是树的定义本身,代价是栈深有限(Python 默认约 1000 层,JVM 和 JS 引擎也各有几千到几万层的红线);极深或链状的树需要迭代版 —— - 它自己管栈,深度只受堆内存限制。两者时间复杂度都是 O(n)。 - 能顺手写出迭代前序,会让回答更完整。如果面试官进一步要求 - 额外空间 O(1),答案是 Morris 遍历: - 它借用节点闲置的右指针记住「回来时该去哪」,完全不需要栈。 - 代价是它运行期间会修改树,并在结束前把改过的指针全部复原。 + 它自己管栈,深度只受堆内存限制。两者时间复杂度都是 O(n)。能顺手写出迭代前序,会让回答更完整。如果面试官进一步要求 + 额外空间 O(1),答案是 Morris 遍历:它借用节点闲置的右指针记住「回来时该去哪」,完全不需要栈。代价是它运行期间会修改树,并在结束前把改过的指针全部复原。

} /> @@ -1648,9 +1597,7 @@ function levelOrder(root) {

数组、字典每个语言都自带,树却没有 —— 因为树的形状由具体问题决定,标准库给不出通用款(Java 的{" "} - TreeMap 内部虽是红黑树,但不暴露节点)。好消息: - 做题时 TreeNode 由题目提供,你只需要会读; - 本地调试时,下面的 buildTree 能把 LeetCode + TreeMap 内部虽是红黑树,但不暴露节点)。好消息:做题时 TreeNode 由题目提供,你只需要会读;本地调试时,下面的 buildTree 能把 LeetCode 的层序数组(例如 [3,9,20,null,null,15,7])变成一棵真树:

} @@ -1829,8 +1776,7 @@ print(root.right.left.val) # 15`, zh: ( <> 细节:判断空位要用 is None 而不是{" "} - not vals[i] —— 后者会把合法的节点值 0 也当成空位, - 这是做题时实际很常见的错误。 + not vals[i] —— 后者会把合法的节点值 0 也当成空位,这是做题时实际很常见的错误。 ), }, @@ -1916,8 +1862,7 @@ console.log(root.right.left.val); // 15`, zh: ( <> 细节:LeetCode 的 JS 模板用的是构造函数写法{" "} - function TreeNode(val, left, right), - 上面的 class 写法与它完全兼容 —— 两种都要认识。 + function TreeNode(val, left, right),上面的 class 写法与它完全兼容 —— 两种都要认识。 ), }, @@ -2125,9 +2070,7 @@ console.log(root.right.left.val); // 15`, 一棵树的答案 = 当前节点贡献什么 + 左子树的答案 + 右子树的答案 - 。所以拿到题先问同一个问题:「如果左右子树的答案已经在我手上, - 我该怎么拼出整棵树的答案?」拼法说得出来,再加上终止条件, - 代码就写完了。题与题之间真正不同的是 + 。所以拿到题先问同一个问题:「如果左右子树的答案已经在我手上,我该怎么拼出整棵树的答案?」拼法说得出来,再加上终止条件,代码就写完了。题与题之间真正不同的是 信息往哪个方向流,这决定了两种做法:

} @@ -2158,8 +2101,7 @@ console.log(root.right.left.val); // 15`, } zh={ <> - 信息从根流向叶:把「一路走来的状态」(当前深度、还差多少和、 - 走过的路径)装进参数带下去,在前序位置 + 信息从根流向叶:把「一路走来的状态」(当前深度、还差多少和、走过的路径)装进参数带下去,在前序位置 (进入节点时)干活,到叶子时结算。参见 LC 112、257、129。 } @@ -2234,9 +2176,7 @@ console.log(root.right.left.val); // 15`,

关于自底向上有一个提醒:函数的返回值 不一定就是你要的答案。最经典的是直径(题单 LC - 543):函数返回的是高度,因为那是父节点需要的东西; - 而直径存在递归之外的一个变量里,每个节点算完时去挑战它一次。 - 只要题目问的东西可以「在某个节点拐弯」,就要预料到这两者不同 —— + 543):函数返回的是高度,因为那是父节点需要的东西;而直径存在递归之外的一个变量里,每个节点算完时去挑战它一次。只要题目问的东西可以「在某个节点拐弯」,就要预料到这两者不同 —— 动手写之前先把两句话都说清楚。

@@ -2289,17 +2229,13 @@ console.log(root.right.left.val); // 15`, <>

题意:求这棵树的最大深度。先看清约定:LeetCode - 数的是从根到最远叶子这条路径上的节点数, - 所以单个节点的最大深度是 1,空树是 0 —— 这比 §01 - 定义的「数边」深度大 1(那里根的深度是 0)。 - 两种约定都很常见,说的时候务必讲清用的是哪一种。 + 数的是从根到最远叶子这条路径上的节点数,所以单个节点的最大深度是 1,空树是 0 —— 这比 §01 + 定义的「数边」深度大 1(那里根的深度是 0)。两种约定都很常见,说的时候务必讲清用的是哪一种。

函数的承诺:maxDepth(node) 返回「从 node 往下到叶子的最长路径上有几个节点」。终止条件: - maxDepth(null) = 0。合并这一步:1 + max(左边的答案, - 右边的答案)—— 节点自己占一层,而最长路径只可能由更深的那一侧决定。 - 三个问题都答完了,函数也就写完了。这是本章第一道自底向上的题: + maxDepth(null) = 0。合并这一步:1 + max(左边的答案,右边的答案)—— 节点自己占一层,而最长路径只可能由更深的那一侧决定。三个问题都答完了,函数也就写完了。这是本章第一道自底向上的题:

} @@ -2389,10 +2325,7 @@ console.log(root.right.left.val); // 15`, zh={

每个节点访问一次,时间 O(n);递归栈最深等于树高,额外空间{" "} - O(h) —— 平衡时约 log n,退化成链时是 n。追问一: - 「用 BFS 怎么求?」层序遍历数层数,有几层深度就是几。追问二: - 「最小深度呢?」小心叶子陷阱(题单 LC 111): - 只有一个孩子的节点不是叶子,空的那一侧不能把它的 0 算进来。 + O(h) —— 平衡时约 log n,退化成链时是 n。追问一:「用 BFS 怎么求?」层序遍历数层数,有几层深度就是几。追问二:「最小深度呢?」小心叶子陷阱(题单 LC 111):只有一个孩子的节点不是叶子,空的那一侧不能把它的 0 算进来。

} /> @@ -2432,10 +2365,7 @@ console.log(root.right.left.val); // 15`, 题意:把整棵树左右镜像。函数的承诺:invertTree(node) 返回「同一棵子树,但已经镜像好了」。终止条件: 空树返回 null。合并这一步: - 交换当前节点的两个孩子引用,再让递归去镜像两棵子树。 - 交换写入的只是两根引用(§02), - 一次赋值就带动整棵子树;子树内部的顺序交给递归。 - 交换写在两次递归调用之前还是之后,结果都一样: + 交换当前节点的两个孩子引用,再让递归去镜像两棵子树。交换写入的只是两根引用(§02),一次赋值就带动整棵子树;子树内部的顺序交给递归。交换写在两次递归调用之前还是之后,结果都一样:

} /> @@ -2532,9 +2462,7 @@ console.log(root.right.left.val); // 15`, zh={

Mac 开发者人手一个的包管理器 Homebrew,其作者 Max Howell - 当年面试谷歌被拒后发推谈到这件事:谷歌说, - 虽然他们绝大多数工程师都在用他写的软件, - 但他在白板上翻转不出一棵二叉树。这条推特让 LC 226 + 当年面试谷歌被拒后发推谈到这件事:谷歌说,虽然他们绝大多数工程师都在用他写的软件,但他在白板上翻转不出一棵二叉树。这条推特让 LC 226 成了全站最有名的 Easy 题。现在你会写了 —— 而且还能说出它是 O(n) 时间、O(h) 空间,交换的是引用而不是值。

@@ -2585,8 +2513,7 @@ console.log(root.right.left.val); // 15`, 返回「子树 L 与子树 R 互为镜像」。终止条件: 两个都空 → true;只有一个空 → false。合并这一步: 值必须相等,并且L 的左与 R 的右互为镜像(外侧)、 - L 的右与 R 的左互为镜像(内侧)。 - 两个指针在树的两半上镜像同步走: + L 的右与 R 的左互为镜像(内侧)。两个指针在树的两半上镜像同步走:

} /> @@ -2706,9 +2633,7 @@ console.log(root.right.left.val); // 15`,

时间 O(n),栈空间 O(h)。这题真正可迁移的技巧是: 递归函数的参数不必只有一个节点。双树同步递归正是 LC 100 - (相同的树)、本题、LC 572(另一棵树的子树)共同的骨架。追问: - 「迭代怎么写?」把该成对比较的 (L, R) 成对放进容器, - 每轮取出一对 —— 用栈还是队列都行,关键是两个节点必须成对。 + (相同的树)、本题、LC 572(另一棵树的子树)共同的骨架。追问:「迭代怎么写?」把该成对比较的 (L, R) 成对放进容器,每轮取出一对 —— 用栈还是队列都行,关键是两个节点必须成对。

} /> @@ -2755,10 +2680,8 @@ console.log(root.right.left.val); // 15`, [[3],[9,20],[15,7]])。已知: 队列能按层的顺序把节点吐出来,但吐出来是一维的 —— 层与层的边界在哪?回答这个问题的一步: - 每一层开始的那一刻,队列里装着的恰好是这一层的全部节点,不多不少。 - 所以先记下 size = 当前队列长度,本轮只出队这么多个 - ;这一轮期间入队的孩子排在它们后面,自然属于下一层。 - 没有这一步,循环就分不清层的边界 —— 因为你读队列的同时它还在变长: + 每一层开始的那一刻,队列里装着的恰好是这一层的全部节点,不多不少。所以先记下 size = 当前队列长度,本轮只出队这么多个 + ;这一轮期间入队的孩子排在它们后面,自然属于下一层。没有这一步,循环就分不清层的边界 —— 因为你读队列的同时它还在变长:

} /> @@ -2919,9 +2842,7 @@ class Solution: } zh={

- 时间 O(n);队列里最多装一层,空间 O(w)。「先记 size」这一步撑起了: - 锯齿形层序(LC 103,隔层反转)、右视图(LC 199,取每层最后一个)、 - 每层最大值(LC 515)、最小深度(LC 111,出队遇到叶子立刻返回)—— + 时间 O(n);队列里最多装一层,空间 O(w)。「先记 size」这一步撑起了:锯齿形层序(LC 103,隔层反转)、右视图(LC 199,取每层最后一个)、每层最大值(LC 515)、最小深度(LC 111,出队遇到叶子立刻返回)—— 每一道都是这个模板改两行。树上的 BFS,学到这一题就完整了。

} @@ -2985,8 +2906,7 @@ class Solution: zh: ( <> 整章的钥匙: - 一棵树要么是空的,要么 = 根 + 左子树 + 右子树。 - 结构是递归定义的,处理它的代码自然也是递归的。 + 一棵树要么是空的,要么 = 根 + 左子树 + 右子树。结构是递归定义的,处理它的代码自然也是递归的。 ), }, @@ -3006,9 +2926,7 @@ class Solution: <> 递归的三个问题:它承诺返回什么、终止条件是什么 (树上就是空树,写在第一行)、 - 怎么用孩子的答案拼出我的答案。 - 每次调用的规模必须严格变小,否则永远走不到终止条件,栈就会溢出 - (Python 默认只允许约 1000 层)。 + 怎么用孩子的答案拼出我的答案。每次调用的规模必须严格变小,否则永远走不到终止条件,栈就会溢出(Python 默认只允许约 1000 层)。 ), }, @@ -3026,8 +2944,7 @@ class Solution: <> 前 / 中 / 后序是同一条 DFS 路线 ,名字只说明节点自己什么时候被访问;层序是 BFS。四者时间都是 - O(n)。DFS 吃 O(h) 栈空间 —— 只有平衡时 h 才是 log n, - 退化成链时是 n;BFS 吃 O(w) 队列空间。 + O(n)。DFS 吃 O(h) 栈空间 —— 只有平衡时 h 才是 log n,退化成链时是 n;BFS 吃 O(w) 队列空间。 ), }, @@ -3045,8 +2962,7 @@ class Solution: zh: ( <> 两种做法:自顶向下用参数把祖先信息带下去(路径和、深度); - 自底向上用返回值把子树信息带上来(高度、直径)。 - 看答案依赖祖先还是子孙来选。另外记住返回值不一定是答案 —— + 自底向上用返回值把子树信息带上来(高度、直径)。看答案依赖祖先还是子孙来选。另外记住返回值不一定是答案 —— LC 543 返回的是高度,直径另用一个变量收集。 ), @@ -3063,8 +2979,7 @@ class Solution: zh: ( <> 让 BFS 能按层工作的那一步: - 在内层循环之前先记下队列长度,本轮只出队这么多个。 - 右视图、锯齿层序、最小深度全是它的变奏。 + 在内层循环之前先记下队列长度,本轮只出队这么多个。右视图、锯齿层序、最小深度全是它的变奏。 ), }, @@ -3086,8 +3001,7 @@ class Solution: 三个形状名词的中英对应不能想当然:真二叉树 full —— 每个节点要么 0 个孩子要么 2 个;满二叉树 perfect —— 每层都填满,所有叶子同一深度;完全二叉树 complete —— - 除最后一层外都填满,最后一层从左到右连续。 - 只有完全二叉树能不浪费地铺进数组(下标 i 的孩子在 2i+1、2i+2) + 除最后一层外都填满,最后一层从左到右连续。只有完全二叉树能不浪费地铺进数组(下标 i 的孩子在 2i+1、2i+2) —— 这就是第 9 章堆的地基。 ), diff --git a/app/binary-tree/viz.tsx b/app/binary-tree/viz.tsx index 36037cc..1307b31 100644 --- a/app/binary-tree/viz.tsx +++ b/app/binary-tree/viz.tsx @@ -178,8 +178,7 @@ export function TermTree() { <> 节点的深度 depth:从根走到它要经过几条边。根的深度是 0,所以 D 的深度是 2。节点的高度 height:从它走到自己最深的 - 叶子要经过几条边。叶子的高度是 0,所以 B 的高度是 1。 - 整棵树的高度 = 根的高度 = 这里的 2。 + 叶子要经过几条边。叶子的高度是 0,所以 B 的高度是 1。整棵树的高度 = 根的高度 = 这里的 2。 } /> @@ -261,9 +260,7 @@ export function ShapeGallery() { } zh={ <> - 每个节点要么有两个孩子,要么一个都没有。节点 2 是叶子, - 节点 3 两个孩子齐全,所以这棵树是真二叉树。它允许某一层中间留空, - 因此不是完全二叉树:下标 [3]、[4] 空着,[5]、[6] 却用上了。 + 每个节点要么有两个孩子,要么一个都没有。节点 2 是叶子,节点 3 两个孩子齐全,所以这棵树是真二叉树。它允许某一层中间留空,因此不是完全二叉树:下标 [3]、[4] 空着,[5]、[6] 却用上了。 } /> @@ -290,8 +287,7 @@ export function ShapeGallery() { zh={ <> 每一层都填满,所有叶子处在同一深度。高度为 h 的满二叉树共有 - 2h+1−1 个节点;这里 h = 2,正好 7 个。注意英文对应: - 满二叉树是 perfect,不是 full。 + 2h+1−1 个节点;这里 h = 2,正好 7 个。注意英文对应:满二叉树是 perfect,不是 full。 } /> @@ -318,9 +314,7 @@ export function ShapeGallery() { } zh={ <> - 除最后一层外每层都填满,最后一层从左到右连续排列。 - 于是用到的下标是一段没有空洞的连续区间 [0]…[5], - 整棵树铺进数组不浪费任何一格。第 9 章的堆,就靠这个性质活着。 + 除最后一层外每层都填满,最后一层从左到右连续排列。于是用到的下标是一段没有空洞的连续区间 [0]…[5],整棵树铺进数组不浪费任何一格。第 9 章的堆,就靠这个性质活着。 } /> @@ -385,8 +379,7 @@ const FAC_FRAMES: FacFrame[] = [ } zh={ <> - factorial(2) 同样卡在 factorial(1) 上,于是再压一帧。 - 每个栈帧独立记着自己的 n 和「算到哪一行了」,互不干扰。 + factorial(2) 同样卡在 factorial(1) 上,于是再压一帧。每个栈帧独立记着自己的 n 和「算到哪一行了」,互不干扰。 } /> @@ -413,8 +406,7 @@ const FAC_FRAMES: FacFrame[] = [ } zh={ <> - factorial(1) 命中终止条件:不再往下调,直接答 1, - 下潜到此为止。栈高 3 层,这就是递归的深度。 + factorial(1) 命中终止条件:不再往下调,直接答 1,下潜到此为止。栈高 3 层,这就是递归的深度。 } /> @@ -441,8 +433,7 @@ const FAC_FRAMES: FacFrame[] = [ } zh={ <> - f(1) 弹栈,把 1 交回 f(2)。f(2) 从冻结的那一行继续:2 × 1 = 2, - 算完了,轮到它弹栈。 + f(1) 弹栈,把 1 交回 f(2)。f(2) 从冻结的那一行继续:2 × 1 = 2,算完了,轮到它弹栈。 } /> @@ -569,9 +560,7 @@ const R_FRAMES: RFrame[] = [ zh={ <> 目标:数出这棵树有几个节点。函数的承诺: - count(node) 返回「以 node 为根的子树里有多少个节点」。 - 于是 count(node) = 1 + count(左) + count(右),count(null) = 0。 - 先调用 count(A)。 + count(node) 返回「以 node 为根的子树里有多少个节点」。于是 count(node) = 1 + count(左) + count(右),count(null) = 0。先调用 count(A)。 } /> @@ -592,8 +581,7 @@ const R_FRAMES: RFrame[] = [ } zh={ <> - count(A) 要算「1 + count(B) + count(C)」,先问 count(B)。 - A 的帧冻结在栈底,count(B) 压了进来。 + count(A) 要算「1 + count(B) + count(C)」,先问 count(B)。A 的帧冻结在栈底,count(B) 压了进来。 } /> @@ -613,8 +601,7 @@ const R_FRAMES: RFrame[] = [ } zh={ <> - count(B) 同样先要左边:压入 count(D)。栈越来越高, - 每一帧都记着自己停在哪里。 + count(B) 同样先要左边:压入 count(D)。栈越来越高,每一帧都记着自己停在哪里。 } /> @@ -635,8 +622,7 @@ const R_FRAMES: RFrame[] = [ } zh={ <> - D 的左右孩子都是 null,而 count(null) = 0 是终止条件。 - 于是 count(D) = 1 + 0 + 0 = 1,弹栈,把 1 交给 B。 + D 的左右孩子都是 null,而 count(null) = 0 是终止条件。于是 count(D) = 1 + 0 + 0 = 1,弹栈,把 1 交给 B。 } /> @@ -811,9 +797,7 @@ const R_FRAMES: RFrame[] = [ } zh={ <> - count(A) = 1 + 3 + 3 = 7。栈清空,答案是 7。复盘: - 每个节点恰好被访问一次,时间 O(n);栈最高时不超过树的高度, - 额外空间 O(h)。 + count(A) = 1 + 3 + 3 = 7。栈清空,答案是 7。复盘:每个节点恰好被访问一次,时间 O(n);栈最高时不超过树的高度,额外空间 O(h)。 } /> @@ -927,9 +911,7 @@ function buildDfsFrames(order: Exclude): TravFrame[] { } zh={ <> - {label.zh}:三种深度优先遍历走的路线完全一样, - 差别只在节点自己相对于两棵子树在什么时候被访问。 - 点「下一步」开走。 + {label.zh}:三种深度优先遍历走的路线完全一样,差别只在节点自己相对于两棵子树在什么时候被访问。点「下一步」开走。 } /> @@ -971,8 +953,7 @@ function buildDfsFrames(order: Exclude): TravFrame[] { } zh={ <> - 叶子 {node.n}:一进门就被访问。它的左右孩子都是空树, - 所以这次调用马上返回。 + 叶子 {node.n}:一进门就被访问。它的左右孩子都是空树,所以这次调用马上返回。 } /> @@ -986,8 +967,7 @@ function buildDfsFrames(order: Exclude): TravFrame[] { } zh={ <> - 进入 {node.n}:前序一进门就访问节点, - 然后才去左、右子树。 + 进入 {node.n}:前序一进门就访问节点,然后才去左、右子树。 } /> @@ -1012,8 +992,7 @@ function buildDfsFrames(order: Exclude): TravFrame[] { } zh={ <> - 叶子 {node.n}:左子树是空树(也算「走完了」), - 所以此刻访问它自己,右子树同样是空。 + 叶子 {node.n}:左子树是空树(也算「走完了」),所以此刻访问它自己,右子树同样是空。 } /> @@ -1027,8 +1006,7 @@ function buildDfsFrames(order: Exclude): TravFrame[] { } zh={ <> - {node.n} 的左子树全部走完 —— 中序此刻访问节点, - 位置在两棵子树之间,然后去右子树。 + {node.n} 的左子树全部走完 —— 中序此刻访问节点,位置在两棵子树之间,然后去右子树。 } /> @@ -1095,8 +1073,7 @@ function buildDfsFrames(order: Exclude): TravFrame[] { } zh={ <> - 完成。{label.zh}的序列是 [{out.join(", ")}]。 - 调用栈最深只到过 3 层 —— 恰好是树高加一。 + 完成。{label.zh}的序列是 [{out.join(", ")}]。调用栈最深只到过 3 层 —— 恰好是树高加一。 } /> @@ -1128,8 +1105,7 @@ function buildBfsFrames(): TravFrame[] { zh={ <> 层序(BFS):根 1 入队。规则只有一条: - 从队首取出一个、访问它、把它的孩子放到队尾。 - 先进先出保证一层处理完才轮到下一层。 + 从队首取出一个、访问它、把它的孩子放到队尾。先进先出保证一层处理完才轮到下一层。 } /> @@ -1203,8 +1179,7 @@ function buildBfsFrames(): TravFrame[] { } zh={ <> - 队列空,遍历结束:[{out.join(", ")}] —— 正好一层一层、从左到右。 - 队列最长时装下了一整层(4 个),所以额外空间是 O(w), + 队列空,遍历结束:[{out.join(", ")}] —— 正好一层一层、从左到右。队列最长时装下了一整层(4 个),所以额外空间是 O(w), w 是最宽那一层的宽度。 } @@ -1359,8 +1334,7 @@ const D_FRAMES: DFrame[] = [ zh={ <> 函数的承诺:maxDepth(node) 返回「从 node 往下到叶子的最长路径上有几个节点」 - ,并且 maxDepth(null) = 0。于是 maxDepth(node) = 1 + max(左边的答案, - 右边的答案)。这棵树是 [3, 9, 20, null, null, 15, 7]。 + ,并且 maxDepth(null) = 0。于是 maxDepth(node) = 1 + max(左边的答案,右边的答案)。这棵树是 [3, 9, 20, null, null, 15, 7]。 } /> @@ -1384,8 +1358,7 @@ const D_FRAMES: DFrame[] = [ } zh={ <> - 9 是叶子:左右都是空树,各报 0,于是 1 + max(0, 0) = 1, - 把 1 报给父节点。 + 9 是叶子:左右都是空树,各报 0,于是 1 + max(0, 0) = 1,把 1 报给父节点。 } /> @@ -1479,8 +1452,7 @@ const D_FRAMES: DFrame[] = [ } zh={ <> - 根也收齐了:1 + max(1, 2) = 3。答案是从叶子一层层「长」上来的, - 不是从根「数」下去的 —— 这就是自底向上。时间 O(n),栈空间 O(h)。 + 根也收齐了:1 + max(1, 2) = 3。答案是从叶子一层层「长」上来的,不是从根「数」下去的 —— 这就是自底向上。时间 O(n),栈空间 O(h)。 } /> @@ -1560,9 +1532,7 @@ const I_FRAMES: IFrame[] = [ zh={ <> 目标:把每个节点的左右孩子互换,整棵树镜像。函数的承诺: - invertTree(node) 返回「同一棵子树,但已镜像」。 - 做法:交换根的两个孩子,再让递归去镜像两棵子树。 - 交换写在两次递归调用之前还是之后,结果都一样。 + invertTree(node) 返回「同一棵子树,但已镜像」。做法:交换根的两个孩子,再让递归去镜像两棵子树。交换写在两次递归调用之前还是之后,结果都一样。 } /> @@ -1632,9 +1602,7 @@ const I_FRAMES: IFrame[] = [ } zh={ <> - 叶子没有孩子,invertTree(null) 直接返回 —— 完成。 - 新树的层序是 [4, 7, 2, 9, 6, 3, 1],处处是原树的镜像。 - 每个节点只交换一次:时间 O(n),栈空间 O(h)。 + 叶子没有孩子,invertTree(null) 直接返回 —— 完成。新树的层序是 [4, 7, 2, 9, 6, 3, 1],处处是原树的镜像。每个节点只交换一次:时间 O(n),栈空间 O(h)。 } /> @@ -1698,9 +1666,7 @@ const M_FRAMES: MFrame[] = [ } zh={ <> - 对称的意思是左子树和右子树互为镜像。 - 这是「两棵子树之间」的关系,单参数的 f(node) 表达不了, - 所以把递归升级成双参数 check(L, R),让两边沿镜像路线同步走。 + 对称的意思是左子树和右子树互为镜像。这是「两棵子树之间」的关系,单参数的 f(node) 表达不了,所以把递归升级成双参数 check(L, R),让两边沿镜像路线同步走。 } /> @@ -1783,9 +1749,7 @@ const M_FRAMES: MFrame[] = [ } zh={ <> - check(2, 2) 收到外侧、内侧两个 true,于是返回 true:这棵树对称。 - 任何一步出现「值不等」或「一边空一边不空」,false 都会一路传回顶层。 - 时间 O(n),栈空间 O(h)。 + check(2, 2) 收到外侧、内侧两个 true,于是返回 true:这棵树对称。任何一步出现「值不等」或「一边空一边不空」,false 都会一路传回顶层。时间 O(n),栈空间 O(h)。 } /> @@ -1884,8 +1848,7 @@ const L_FRAMES: LFrame[] = [ } zh={ <> - 第 1 层:size = 1。出队 3 并收进本层;孩子 9、20 入队。 - 本层 1 个处理完 → 第 1 层 = [3] 结束。 + 第 1 层:size = 1。出队 3 并收进本层;孩子 9、20 入队。本层 1 个处理完 → 第 1 层 = [3] 结束。 } /> @@ -1907,8 +1870,7 @@ const L_FRAMES: LFrame[] = [ } zh={ <> - 第 2 层:size = 2,先记下来。从现在起入队的孩子都不算这一层。 - 出队 9:它是叶子,没有孩子入队。 + 第 2 层:size = 2,先记下来。从现在起入队的孩子都不算这一层。出队 9:它是叶子,没有孩子入队。 } /> @@ -1930,8 +1892,7 @@ const L_FRAMES: LFrame[] = [ } zh={ <> - 出队 20,孩子 15、7 入队。本层 2 个处理完 → 第 2 层 = [9, 20]。 - 注意 15、7 虽然已经在队列里,但 size 早就定死为 2,它们只能等下一轮。 + 出队 20,孩子 15、7 入队。本层 2 个处理完 → 第 2 层 = [9, 20]。注意 15、7 虽然已经在队列里,但 size 早就定死为 2,它们只能等下一轮。 } /> @@ -1978,9 +1939,7 @@ const L_FRAMES: LFrame[] = [ } zh={ <> - 结果 [[3], [9, 20], [15, 7]]。没有「先记 size」这一步, - 队列只会给你一维序列,层与层的边界就丢了。 - 所有「按层处理」的题目都要用到这一步。 + 结果 [[3], [9, 20], [15, 7]]。没有「先记 size」这一步,队列只会给你一维序列,层与层的边界就丢了。所有「按层处理」的题目都要用到这一步。 } /> diff --git a/app/bst/page.tsx b/app/bst/page.tsx index 1628169..99e3000 100644 --- a/app/bst/page.tsx +++ b/app/bst/page.tsx @@ -88,8 +88,7 @@ const F98: TreeFrame[] = [ ), zh: ( <> - 正解:给每个节点发一张「合法区间」通行证。根没有任何祖先约束, - 区间是 (-∞, +∞),10 当然合法。 + 正解:给每个节点发一张「合法区间」通行证。根没有任何祖先约束,区间是 (-∞, +∞),10 当然合法。 ), }, @@ -107,8 +106,7 @@ const F98: TreeFrame[] = [ ), zh: ( <> - 往左走:左子树全体必须小于 10,所以上界收紧为 10。 - 5 ∈ (-∞, 10) ✓ + 往左走:左子树全体必须小于 10,所以上界收紧为 10。5 ∈ (-∞, 10) ✓ ), }, @@ -126,8 +124,7 @@ const F98: TreeFrame[] = [ ), zh: ( <> - 往右走:右子树全体必须大于 10,所以下界收紧为 10。 - 15 ∈ (10, +∞) ✓ + 往右走:右子树全体必须大于 10,所以下界收紧为 10。15 ∈ (10, +∞) ✓ ), }, @@ -146,8 +143,7 @@ const F98: TreeFrame[] = [ ), zh: ( <> - 15 的左孩子:区间收成 (10, 15) —— 既要小于 15(父节点), - 又要大于 10(祖父节点)。6 越过下界 10,验证失败。 + 15 的左孩子:区间收成 (10, 15) —— 既要小于 15(父节点),又要大于 10(祖父节点)。6 越过下界 10,验证失败。 ), }, @@ -169,8 +165,7 @@ const F98: TreeFrame[] = [ zh: ( <> 复盘:6 只和父节点 15 比是合格的,但它住在 10 的右子树里,必须大于 10。 - 「只查父子」会漏掉祖先的约束 —— 上下界法把祖先的要求一路传下来, - 谁也逃不掉。 + 「只查父子」会漏掉祖先的约束 —— 上下界法把祖先的要求一路传下来,谁也逃不掉。 ), }, @@ -390,8 +385,7 @@ const F108: ArrayFrame[] = [ ), zh: ( <> - 最后 [9] → 5 的右孩子。完成:5 个节点、树高 2 = ⌊log₂5⌋(按边数); - 每个元素恰好被用一次 → O(n) 建树。 + 最后 [9] → 5 的右孩子。完成:5 个节点、树高 2 = ⌊log₂5⌋(按边数);每个元素恰好被用一次 → O(n) 建树。 ), }, @@ -445,9 +439,7 @@ export default function BSTChapter() { ), zh: ( <> - 在二叉树上立下一条规矩:左 < 根 < 右, - 而且要对整棵子树成立,不只是对两个孩子。 - 从此每次比较都能整棵扔掉一边的子树 —— + 在二叉树上立下一条规矩:左 < 根 < 右,而且要对整棵子树成立,不只是对两个孩子。从此每次比较都能整棵扔掉一边的子树 —— 它把二分查找,长在了一棵随时可以插入删除的树上。 ), @@ -486,13 +478,8 @@ export default function BSTChapter() { } zh={

- 先回顾两种已学结构各自的长处。有序数组查找极快: - 二分每次排除一半,O(log n);但插入一个新值得先腾位置 —— - 右边所有元素集体后移,O(n)。链表正相反: - 只要已经站在位置上,接一个节点只改两根指针,O(1); - 可想找某个值,只能从头一路问过去,O(n)。 - 链表连二分都做不了 —— 它没有 O(1) 随机访问, - 「跳到中间」这一步本身就要 O(n)。 + 先回顾两种已学结构各自的长处。有序数组查找极快:二分每次排除一半,O(log n);但插入一个新值得先腾位置 —— + 右边所有元素集体后移,O(n)。链表正相反:只要已经站在位置上,接一个节点只改两根指针,O(1);可想找某个值,只能从头一路问过去,O(n)。链表连二分都做不了 —— 它没有 O(1) 随机访问,「跳到中间」这一步本身就要 O(n)。

} /> @@ -599,12 +586,7 @@ export default function BSTChapter() { } zh={

- BST 的主意:把「二分」的判断过程固化成树的形状。 - 想象一座图书馆:进门的导览牌写着「A–M 往左,N–Z 往右」;走进左区, - 又有一块牌子「A–F 往左,G–M 往右」…… 每读一块牌子, - 要找的范围就少掉一大块。BST 的每个节点就是一块这样的导览牌; - 而且因为节点之间用的是指针(第 3 章链表教的), - 插一块新牌子不需要移动任何已有的牌子。 + BST 的主意:把「二分」的判断过程固化成树的形状。想象一座图书馆:进门的导览牌写着「A–M 往左,N–Z 往右」;走进左区,又有一块牌子「A–F 往左,G–M 往右」……每读一块牌子,要找的范围就少掉一大块。BST 的每个节点就是一块这样的导览牌;而且因为节点之间用的是指针(第 3 章链表教的),插一块新牌子不需要移动任何已有的牌子。

} /> @@ -657,10 +639,8 @@ export default function BSTChapter() { } zh={

- 每到一个节点只问一次「大还是小」,另一边的子树整棵被扔掉。 - 比较次数 = 走过的路径上的节点数 ≤ h + 1 - (h 条边的路径串着 h + 1 个节点)。 - 树平衡时,每一步扔掉的大约就是剩余节点的一半。 + 每到一个节点只问一次「大还是小」,另一边的子树整棵被扔掉。比较次数 = 走过的路径上的节点数 ≤ h + 1 + (h 条边的路径串着 h + 1 个节点)。树平衡时,每一步扔掉的大约就是剩余节点的一半。

} /> @@ -681,8 +661,7 @@ export default function BSTChapter() { } zh={

- 插入:沿查找路线走到空位,挂上去,改一根指针。 - 删除:最多牵动一条路径。没有任何「集体后移」—— + 插入:沿查找路线走到空位,挂上去,改一根指针。删除:最多牵动一条路径。没有任何「集体后移」—— 这是从链表那里继承来的性质。

} @@ -711,10 +690,7 @@ export default function BSTChapter() { } zh={

- 印刷版字典有序但改不动:要加一个词,只能重排再版。 - 哈希表正相反:存取极快,但遍历得到的顺序没有意义。 - BST 是一部活页字典:随时插新页,而且任何时刻按顺序翻阅 - (中序遍历)都是排好序的。需求里同时出现 + 印刷版字典有序但改不动:要加一个词,只能重排再版。哈希表正相反:存取极快,但遍历得到的顺序没有意义。BST 是一部活页字典:随时插新页,而且任何时刻按顺序翻阅(中序遍历)都是排好序的。需求里同时出现 数据会变和要有顺序时,答案基本就是树结构。

} @@ -755,8 +731,7 @@ export default function BSTChapter() { 上一章学了四种遍历。对 BST 来说,其中一种格外重要: 中序遍历(inorder,左 → 根 → 右)输出的序列必然升序 。为什么?把树画出来就明白了 —— - 规矩保证左子树全体在根的左边、右子树全体在根的右边, - 所以每个节点的水平位置天然就是它的排序位次: + 规矩保证左子树全体在根的左边、右子树全体在根的右边,所以每个节点的水平位置天然就是它的排序位次:

} /> @@ -790,18 +765,11 @@ export default function BSTChapter() { zh={ <>

- 这条性质怎么用?正反两个方向都用得上。 - 想按顺序拿到 BST 里的数据,中序遍历即可, - 不需要额外排序;想验证一棵树是不是 BST, - 看它的中序序列是否严格递增;想找「第 k 小」,中序数到第 k 个。 - 本章一半的题目都建立在这一条性质上。 + 这条性质怎么用?正反两个方向都用得上。想按顺序拿到 BST 里的数据,中序遍历即可, + 不需要额外排序;想验证一棵树是不是 BST,看它的中序序列是否严格递增;想找「第 k 小」,中序数到第 k 个。本章一半的题目都建立在这一条性质上。

- 再看查找。在下面的实验室里输入一个数字点「查找」, - 你会看到它从根出发,每一步比较后只往一边走。 - 也试试插入:新值总是落在「查找失败的那个空位」上。最后, - 务必点一次「顺序插入 1→5」: - 亲眼看看规矩没变、速度却没了的样子。 + 再看查找。在下面的实验室里输入一个数字点「查找」,你会看到它从根出发,每一步比较后只往一边走。也试试插入:新值总是落在「查找失败的那个空位」上。最后,务必点一次「顺序插入 1→5」:亲眼看看规矩没变、速度却没了的样子。

} @@ -829,9 +797,7 @@ export default function BSTChapter() { } zh={

- 顺序插入时每个新值都往同一边拐,树斜成一条链: - 高度 h 从大约 log n 恶化到 n − 1,查找从「整棵扔掉子树」 - 退化成「逐个检查」—— 和链表一模一样。 + 顺序插入时每个新值都往同一边拐,树斜成一条链:高度 h 从大约 log n 恶化到 n − 1,查找从「整棵扔掉子树」退化成「逐个检查」—— 和链表一模一样。 规矩(左小右大)只保证正确性,不保证形状。 管形状的是 §05 的平衡树。

@@ -986,17 +952,13 @@ export default function BSTChapter() { zh={ <>

- 为什么全都写 O(h) 而不是 O(log n)? - 因为 h(树高)是个变量。和第 7 章一样, - 树高按边数计:只有根时 h = 0。完全平衡时 h = ⌊log₂n⌋ - —— 每层能装的节点数是上一层的两倍;顺序插入退化成链时 h = n − 1。 - 不加条件地说 O(log n) 是错的。完整答案是: + 为什么全都写 O(h) 而不是 O(log n)?因为 h(树高)是个变量。和第 7 章一样,树高按边数计:只有根时 h = 0。完全平衡时 h = ⌊log₂n⌋ + —— 每层能装的节点数是上一层的两倍;顺序插入退化成链时 h = n − 1。不加条件地说 O(log n) 是错的。完整答案是: O(h),平衡时 log n、最坏 n,工程用红黑树把 h 钉在 log n。

查找和插入你已经在实验室亲手做过了。真正的难点是 - 删除:挖掉一个节点会在树上留下一个洞, - 怎么补洞才能不破坏「左小右大」?按被删节点的孩子数量分三种情况: + 删除:挖掉一个节点会在树上留下一个洞,怎么补洞才能不破坏「左小右大」?按被删节点的孩子数量分三种情况:

} @@ -1100,8 +1062,7 @@ export default function BSTChapter() { zh={ <> 70 只有一个孩子 80:让父节点 50 直接接管 80 - (就像链表里跳过一个节点)。 - 80 那一支本来就全部大于 50,接上来规矩依旧成立。 + (就像链表里跳过一个节点)。80 那一支本来就全部大于 50,接上来规矩依旧成立。 } /> @@ -1165,8 +1126,7 @@ export default function BSTChapter() { zh={ <> 50 左右都有子树,哪个孩子都不能直接顶上。找 - 中序后继 60(右子树里最小的): - 把 60 的值抄给根,再去右子树删掉原来的 60。 + 中序后继 60(右子树里最小的):把 60 的值抄给根,再去右子树删掉原来的 60。 } /> @@ -1198,14 +1158,10 @@ export default function BSTChapter() {

情况 ③ 的两个「为什么」值得掰开。 为什么后继能当替身? - 中序后继是「恰好比被删值大的下一个数」,中间再没有别的键。 - 它顶上之后,左子树全体仍比它小(它比被删值还大), - 右子树剩余全体仍比它大(它本来就是右子树里最小的)—— + 中序后继是「恰好比被删值大的下一个数」,中间再没有别的键。它顶上之后,左子树全体仍比它小(它比被删值还大),右子树剩余全体仍比它大(它本来就是右子树里最小的)—— 两边的顺序都不破,换成别的键就做不到。 为什么删后继很容易? - 后继 = 从右孩子一路向左的尽头,它必然没有左孩子, - 删它必落入情况 ① 或 ②,递归不会没完没了。 - 对称地,用中序前驱(左子树最大值)也完全可行。 + 后继 = 从右孩子一路向左的尽头,它必然没有左孩子,删它必落入情况 ① 或 ②,递归不会没完没了。对称地,用中序前驱(左子树最大值)也完全可行。

} /> @@ -1232,9 +1188,7 @@ export default function BSTChapter() { zh={

叶子直接删;单孩子让孩子顶;双孩子值换后继、删除转到右子树 - —— 把「删中间的节点」降级成「删边缘的节点」。 - 总代价:定位 + 找后继 + 再删一次, - 全程走的还是一条向下的路径,O(h)。 + —— 把「删中间的节点」降级成「删边缘的节点」。总代价:定位 + 找后继 + 再删一次,全程走的还是一条向下的路径,O(h)。

} /> @@ -1272,8 +1226,7 @@ export default function BSTChapter() { 节点还是二叉树章的 TreeNode(值 + 左右孩子指针)。四个方法里, insert 和 delete 用递归写:注意它们都 返回「修补后的子树根」,由上一层接住 —— - 这个「返回自己让父节点重新牵手」的模式, - 是递归改树的标准做法,好处是不需要专门记录父节点。 + 这个「返回自己让父节点重新牵手」的模式,是递归改树的标准做法,好处是不需要专门记录父节点。

} /> @@ -1424,8 +1377,7 @@ class BST { zh: ( <> 细节:deleteAt 里「叶子」被并进了「只有右孩子」—— - 叶子的 right 是 null,返回它正好等于返回 null。 - 两行代码盖住三种情况。 + 叶子的 right 是 null,返回它正好等于返回 null。两行代码盖住三种情况。 ), }, @@ -1580,8 +1532,7 @@ class BST: <> 易错点:CPython 默认递归深度限制约 1000 —— 退化成链的 BST 上,递归查找可能超限。树可能很深时, - search / insert 写成迭代更稳;delete 因为要修补父子链, - 递归写法最顺手。 + search / insert 写成迭代更稳;delete 因为要修补父子链,递归写法最顺手。 ), }, @@ -1745,8 +1696,7 @@ class BST { zh: ( <> 提醒:比较用 ===;若存的是字符串, - < 会按字典序比较 —— 想按数值比记得先转换, - 或者把比较函数抽出来作为构造参数(工程写法)。 + < 会按字典序比较 —— 想按数值比记得先转换,或者把比较函数抽出来作为构造参数(工程写法)。 ), }, @@ -1786,12 +1736,8 @@ class BST { zh={

§02 的实验室已经演示过: - 有序输入会把 BST 养成一条链。 - 而现实数据偏偏经常有序 —— 按时间戳写入日志、按自增 id 插入记录、 - 按字母序导入词表…… 裸 BST 在生产环境几乎必然退化。 - 解法的思路很直接:插入删除后,如果某处一边太深,就 - 旋转把它扶正。不同的平衡树, - 区别只在「怎么定义太深」和「歪到什么程度才动手」。 + 有序输入会把 BST 养成一条链。而现实数据偏偏经常有序 —— 按时间戳写入日志、按自增 id 插入记录、按字母序导入词表……裸 BST 在生产环境几乎必然退化。解法的思路很直接:插入删除后,如果某处一边太深,就 + 旋转把它扶正。不同的平衡树,区别只在「怎么定义太深」和「歪到什么程度才动手」。

} /> @@ -1822,8 +1768,7 @@ class BST { zh={

AVL(1962,以发明者 Adelson-Velsky 和 Landis 命名)给每个节点定义 - 平衡因子(balance factor)= 左子树高 − 右子树高, - 并要求任何节点的平衡因子 ∈ {"{-1, 0, 1}"}。一旦插入让某个节点的 + 平衡因子(balance factor)= 左子树高 − 右子树高,并要求任何节点的平衡因子 ∈ {"{-1, 0, 1}"}。一旦插入让某个节点的 |BF| 达到 2,立刻旋转修复。看最小的例子 —— 依次插入 3、2、1:

} @@ -1866,8 +1811,7 @@ class BST { zh={ <> 3 的左子树高 1(按边数),右子树为空、记作 −1: - BF = 1 − (−1) = +2,超标。 - 失衡形状是「左左」—— 解法:右旋。 + BF = 1 − (−1) = +2,超标。失衡形状是「左左」—— 解法:右旋。 } /> @@ -1912,9 +1856,7 @@ class BST { } zh={ <> - 右旋 = 让左孩子 2 当新根,3 降级成它的右孩子。 - 转完中序还是 1, 2, 3 —— 旋转改形状,不改顺序, - 这是它合法的根本原因。 + 右旋 = 让左孩子 2 当新根,3 降级成它的右孩子。转完中序还是 1, 2, 3 —— 旋转改形状,不改顺序,这是它合法的根本原因。 } /> @@ -1937,10 +1879,7 @@ class BST { } zh={

- 真实的 AVL 还有「左右 / 右左」等组合形状,需要两次旋转, - 思路相同:把三个值里居中的那个转上来当根。 - AVL 是严格的那一派 —— 高度始终贴近 log n,查找最快; - 代价落在写入上。 + 真实的 AVL 还有「左右 / 右左」等组合形状,需要两次旋转,思路相同:把三个值里居中的那个转上来当根。AVL 是严格的那一派 —— 高度始终贴近 log n,查找最快;代价落在写入上。

} /> @@ -2013,16 +1952,11 @@ class BST {

- 这五条合起来推出一个漂亮的结论:最长路径最多「黑红相间」, - 最短路径可以「全黑」,而两者黑节点数相同(规矩 ⑤)—— - 所以最长路径 ≤ 最短路径 × 2, - 高度被锁死在 O(log n) 量级。它允许树有点歪, - 换来的是每次更新只需常数次旋转:插入最多 2 次,删除最多 3 次。 - AVL 的插入最多需要一次单旋或一次双旋(即最多 2 次单旋),但 AVL 的删除 + 这五条合起来推出一个漂亮的结论:最长路径最多「黑红相间」,最短路径可以「全黑」,而两者黑节点数相同(规矩 ⑤)—— + 所以最长路径 ≤ 最短路径 × 2,高度被锁死在 O(log n) 量级。它允许树有点歪,换来的是每次更新只需常数次旋转:插入最多 2 次,删除最多 3 次。AVL 的插入最多需要一次单旋或一次双旋(即最多 2 次单旋),但 AVL 的删除 可能一路回溯到根、每层都要调整。 不追求完美平衡,只追求足够平衡 —— - 这是典型的工程取舍,也是它进入各家标准库的原因。 - 读多写少选 AVL,读写混合选红黑树。 + 这是典型的工程取舍,也是它进入各家标准库的原因。读多写少选 AVL,读写混合选红黑树。

} @@ -2048,11 +1982,7 @@ class BST { } zh={

- 1978 年 Guibas 和 Sedgewick 在施乐帕克研究中心发表了这套结构。 - 据 Sedgewick 本人回忆,颜色来自实验室的打印机: - 它能印红、黑两色,论文里用红色标注特殊节点最醒目, - 名字就这么定了。一台打印机的墨, - 决定了此后五十年教科书的配色。 + 1978 年 Guibas 和 Sedgewick 在施乐帕克研究中心发表了这套结构。据 Sedgewick 本人回忆,颜色来自实验室的打印机:它能印红、黑两色,论文里用红色标注特殊节点最醒目,名字就这么定了。一台打印机的墨,决定了此后五十年教科书的配色。

} /> @@ -2085,9 +2015,7 @@ class BST {

要不要用树,只由一个问题决定:需不需要顺序? 只做单点查找(这个 key 在不在、对应值是什么),哈希表更快 —— - 平均 O(1) 对树的 O(log n)。 - 只有当需求里出现范围查询、找某个值上下最近的 key、 - 按 key 顺序遍历时,才该选平衡 BST —— + 平均 O(1) 对树的 O(log n)。只有当需求里出现范围查询、找某个值上下最近的 key、按 key 顺序遍历时,才该选平衡 BST —— 这几件事哈希表都做不到,除非把全部数据取出来重新排序。

} @@ -2199,8 +2127,7 @@ class BST { } zh={ <> - Map 只保留插入顺序; - 做题时用「数组 + 二分」顶替,或者自己手写一棵树 + Map 只保留插入顺序;做题时用「数组 + 二分」顶替,或者自己手写一棵树 } /> @@ -2333,9 +2260,7 @@ bisect.insort(arr, 25) # 插入 O(n) —— list 底层还是数组`, <> 这一点要说准:bisect.insort{" "} 查得快,但插入仍是 O(n) —— list 是动态数组(第 1 章的老账)。 - SortedList 写入快得多,是因为它把值分散在许多小块里, - 插入只搬动其中一小块;但它不是平衡树, - 插入在理论上也不是 O(log n),只是在做题和一般业务负载下够快、够省事。 + SortedList 写入快得多,是因为它把值分散在许多小块里,插入只搬动其中一小块;但它不是平衡树,插入在理论上也不是 O(log n),只是在做题和一般业务负载下够快、够省事。 ), }, @@ -2397,8 +2322,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) ), zh: ( <> - 易错点:普通对象的键遍历顺序有一套自己的规则 - (整数型键升序、字符串键按插入顺序),不要拿它当有序容器 —— + 易错点:普通对象的键遍历顺序有一套自己的规则(整数型键升序、字符串键按插入顺序),不要拿它当有序容器 —— 需要有序就显式排序,或者用二分维护。 ), @@ -2438,15 +2362,10 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) } zh={

- MySQL(InnoDB)的索引是 B+ 树 —— 同样是有序树, - 为什么不用内存里更快的红黑树?因为场景变了:数据在磁盘上, - 而磁盘按「页」(通常 16KB)整块读取,读 1 字节和读 16KB 代价几乎一样。 - 红黑树一个节点存一个 key,树高约 log₂n - (百万数据一次查询要走约 20 个节点),意味着最多 20 次磁盘读取。 - B+ 树则让一个节点装满 + MySQL(InnoDB)的索引是 B+ 树 —— 同样是有序树,为什么不用内存里更快的红黑树?因为场景变了:数据在磁盘上,而磁盘按「页」(通常 16KB)整块读取,读 1 字节和读 16KB 代价几乎一样。红黑树一个节点存一个 key,树高约 log₂n + (百万数据一次查询要走约 20 个节点),意味着最多 20 次磁盘读取。B+ 树则让一个节点装满 一整页的几百个 key,分叉数几百,百万数据只需 3~4 层 —— - 矮胖的树 = 极少的读取次数。此外 B+ 树把数据全放叶子层、 - 叶子之间用链表串起来:范围扫描(WHERE id BETWEEN …) + 矮胖的树 = 极少的读取次数。此外 B+ 树把数据全放叶子层、叶子之间用链表串起来:范围扫描(WHERE id BETWEEN …) 沿链表顺序读就行,不用在树上跳来跳去。一句话: 红黑树为内存的随机访问而生,B+ 树为磁盘的按页读取而生 —— 结构没有优劣,只有适不适合当前的介质。 @@ -2494,8 +2413,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) 利用中序 = 升序(第 k 小、最小差、验证、恢复);②{" "} 利用比较扔掉子树 (搜索、插入、删除、最近公共祖先、范围和的剪枝);③{" "} - 反向构造(把有序数据变成平衡 BST)。 - 三道精讲每个套路各占一道。 + 反向构造(把有序数据变成平衡 BST)。三道精讲每个套路各占一道。

} /> @@ -2533,9 +2451,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) zh={

题意:判断一棵二叉树是否为合法 BST。 - 常见错误:几乎每个人的第一版都是「检查每个节点比左孩子大、 - 比右孩子小」——这是错的。 - 规矩管的是整棵子树,不是父子两个人。看这个专门击穿它的反例: + 常见错误:几乎每个人的第一版都是「检查每个节点比左孩子大、比右孩子小」——这是错的。规矩管的是整棵子树,不是父子两个人。看这个专门击穿它的反例:

} /> @@ -2594,8 +2510,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) zh: ( <> 细节:界用 long,因为节点值可能取到{" "} - Integer.MIN_VALUE / MAX_VALUE, - 用 int 当边界会把合法的树判错 —— 面试时写出这一步,回答会更扎实。 + Integer.MIN_VALUE / MAX_VALUE,用 int 当边界会把合法的树判错 —— 面试时写出这一步,回答会更扎实。 ), }, @@ -2678,8 +2593,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) ), zh: ( <> - 顺手:JS 的 ±Infinity 跟任何数比较都符合预期, - 边界处理最省心。 + 顺手:JS 的 ±Infinity 跟任何数比较都符合预期,边界处理最省心。 ), }, @@ -2708,10 +2622,8 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) } zh={

- 每个节点检查一次:时间 O(n),空间 O(h) 递归栈。追问一: - 「另一种解法?」—— 中序遍历,检查序列是否严格递增 - (维护 prev,一旦 cur ≤ prev 即失败),原理就是 §02 的性质, - 两种都要会。追问二:「为什么区间是开区间?」—— + 每个节点检查一次:时间 O(n),空间 O(h) 递归栈。追问一:「另一种解法?」—— 中序遍历,检查序列是否严格递增 + (维护 prev,一旦 cur ≤ prev 即失败),原理就是 §02 的性质,两种都要会。追问二:「为什么区间是开区间?」—— 题目要求严格小于 / 大于,等号也算违规,重复值不合法。

} @@ -2753,8 +2665,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) 题意:返回 BST 中第 k 小的元素。暴力: 任意遍历收集所有值、排序取第 k 个 —— O(n log n),完全没用上 BST。 为什么用中序:排序在建树时就已经完成了 —— - 中序遍历就是按升序逐个吐数, - 我们只需要一边遍历一边数数,数到 k 立刻停: + 中序遍历就是按升序逐个吐数,我们只需要一边遍历一边数数,数到 k 立刻停:

} /> @@ -2861,9 +2772,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) ), zh: ( <> - 为什么用显式栈:弹出第 k 个节点时循环立即返回, - 而且不受递归深度限制,这一点在 Python 里尤其重要(默认上限 1000)。 - 同一段栈模板也是 LC 173(BST 迭代器)的答案。 + 为什么用显式栈:弹出第 k 个节点时循环立即返回,而且不受递归深度限制,这一点在 Python 里尤其重要(默认上限 1000)。同一段栈模板也是 LC 173(BST 迭代器)的答案。 ), }, @@ -2929,12 +2838,10 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) } zh={

- 时间 O(h + k):先沉到最左 O(h),再吐出 k 个;空间 O(h)。 - 经典追问:「如果频繁插入删除,还要频繁查第 k 小呢?」—— + 时间 O(h + k):先沉到最左 O(h),再吐出 k 个;空间 O(h)。经典追问:「如果频繁插入删除,还要频繁查第 k 小呢?」—— 在每个节点上缓存左子树的节点数:查询时若 k ≤ leftSize 往左; k = leftSize + 1 就是自己;否则带着 k − leftSize − 1 往右 —— - 每次 O(h),插入删除时顺路维护计数即可。 - 这就是把二分查找直接做在树上。 + 每次 O(h),插入删除时顺路维护计数即可。这就是把二分查找直接做在树上。

} /> @@ -2975,8 +2882,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n)

题意:把升序数组变成一棵高度平衡的 BST。反面教材: 把元素逐个 insert —— 输入有序,§02 实验室演过,直接退化成链。 - 正解:平衡的本质是「左右节点数均等」, - 而有序数组里谁站中间一目了然: + 正解:平衡的本质是「左右节点数均等」,而有序数组里谁站中间一目了然: 取中点当根,左右两半递归建子树。

} @@ -3046,10 +2952,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) } zh={

- 取中点后,左右两半的长度最多差 1;递归的每一层都维持这个性质, - 于是任何节点的左右子树节点数最多差 1,高度差自然不超过 1。 - 这其实是把「二分查找的决策树」显式地盖了出来:mid 是根, - 两半是子树,二分可能走的每条路都成了树上的一条边。 + 取中点后,左右两半的长度最多差 1;递归的每一层都维持这个性质,于是任何节点的左右子树节点数最多差 1,高度差自然不超过 1。这其实是把「二分查找的决策树」显式地盖了出来:mid 是根,两半是子树,二分可能走的每条路都成了树上的一条边。

} /> @@ -3166,12 +3069,9 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) } zh={

- 每个元素恰好当一次根:时间 O(n),空间 O(log n) 递归栈。 - 追问:「输入换成有序链表呢?」(LC 109)—— - 链表找中点要 O(n),于是有两个思路:每次用快慢指针找中点 - (总 O(n log n));或者反过来用「中序位置法」—— - 先数出长度,再按中序顺序边递归边消费链表节点,依然 O(n)。 - 能把这一层讲清楚,这组题就都能解决了。 + 每个元素恰好当一次根:时间 O(n),空间 O(log n) 递归栈。追问:「输入换成有序链表呢?」(LC 109)—— + 链表找中点要 O(n),于是有两个思路:每次用快慢指针找中点(总 O(n log n));或者反过来用「中序位置法」—— + 先数出长度,再按中序顺序边递归边消费链表节点,依然 O(n)。能把这一层讲清楚,这组题就都能解决了。

} /> @@ -3242,8 +3142,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) ), zh: ( <> - 中序遍历 = 升序:第 k 小、最小差、验证、恢复…… - BST 一大半的题,钥匙都是这一条。 + 中序遍历 = 升序:第 k 小、最小差、验证、恢复……BST 一大半的题,钥匙都是这一条。 ), }, @@ -3259,8 +3158,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) ), zh: ( <> - 查找 / 插入 / 删除都是 O(h)(树高按边数计): - 完全平衡时 h = ⌊log₂n⌋,一般的平衡树 h = O(log n),有序插入退化时 h = n − 1 —— + 查找 / 插入 / 删除都是 O(h)(树高按边数计):完全平衡时 h = ⌊log₂n⌋,一般的平衡树 h = O(log n),有序插入退化时 h = n − 1 —— 说复杂度带上 h,或者把条件说清楚。 ), @@ -3297,9 +3195,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) zh: ( <> 工程中不会直接使用无自平衡机制的 BST:Java 有{" "} - TreeMap / TreeSet(红黑树, - 足够平衡就好)、Python 用第三方 sortedcontainers、 - JS 无内置;磁盘上的数据库索引则换 B+ 树 —— + TreeMap / TreeSet(红黑树,足够平衡就好)、Python 用第三方 sortedcontainers、JS 无内置;磁盘上的数据库索引则换 B+ 树 —— 矮胖省读取次数,叶子链表利于范围扫描。 ), @@ -3316,8 +3212,7 @@ arr.splice(lowerBound(arr, 25), 0, 25); // 有序插入,搬移 O(n) zh: ( <> 哈希表还是 BST,只看一个问题:需不需要顺序? - 单点查找选哈希表;范围查询、找最近的 key、按顺序遍历, - 才轮到有序结构。 + 单点查找选哈希表;范围查询、找最近的 key、按顺序遍历,才轮到有序结构。 ), }, diff --git a/app/bst/viz.tsx b/app/bst/viz.tsx index 413b3d2..175a73b 100644 --- a/app/bst/viz.tsx +++ b/app/bst/viz.tsx @@ -198,8 +198,7 @@ export function BSTLab() { } zh={ <> - {v} 已经在树里 —— 本实现直接忽略重复值, - 工程上更常见的做法是在节点上挂一个计数器。 + {v} 已经在树里 —— 本实现直接忽略重复值,工程上更常见的做法是在节点上挂一个计数器。 } />, @@ -387,9 +386,7 @@ export function BSTLab() { } zh={ <> - 5 个节点,树高 4 —— 树斜成了一条链,查 5 要比较 5 次。 - O(h) 变成了 O(n),和链表一样慢。有序输入是裸 BST 的最坏情况, - 这正是 §05 平衡树要解决的问题。 + 5 个节点,树高 4 —— 树斜成了一条链,查 5 要比较 5 次。O(h) 变成了 O(n),和链表一样慢。有序输入是裸 BST 的最坏情况,这正是 §05 平衡树要解决的问题。 } />, @@ -603,8 +600,7 @@ export function InorderFig() { <> 「左 < 根 < 右」对整棵子树成立 ⇒ 左子树全体 画在根左边、右子树全体画在根右边 ⇒ - 节点的水平位置就是它的排序位次。中序遍历(左 → 根 → 右) - 恰好从左到右逐个读过去,所以输出必然升序 —— + 节点的水平位置就是它的排序位次。中序遍历(左 → 根 → 右)恰好从左到右逐个读过去,所以输出必然升序 —— 这不是巧合,而是规矩的直接推论。 } diff --git a/app/graph/page.tsx b/app/graph/page.tsx index a35eac7..3b72623 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -82,9 +82,7 @@ export default function GraphChapter() { zh: ( <> 图是一组顶点加一组记录它们之间关系的 - 边。地铁站与线路、人与好友、网页与链接、 - 课程与先修 —— 只要有「东西」和「东西之间的联系」,画出来就是一张图。 - 它也是全书最一般的结构:前面学的链表、树、网格,都是它的特例。 + 边。地铁站与线路、人与好友、网页与链接、课程与先修 —— 只要有「东西」和「东西之间的联系」,画出来就是一张图。它也是全书最一般的结构:前面学的链表、树、网格,都是它的特例。 ), }} @@ -126,8 +124,7 @@ export default function GraphChapter() { <>

先回忆一路走来的结构:数组是一排,链表是 - 一条链,树只向下分叉、从不回头。 - 它们都有同一个限制:现实里的关系常常是 + 一条链,树只向下分叉、从不回头。它们都有同一个限制:现实里的关系常常是 多对多、还能绕回来的。

下面四件每天都在发生的事,用前面的结构都装不下:

@@ -155,9 +152,7 @@ export default function GraphChapter() { } zh={ <> - 每个站是一个点,每段轨道是一条边。 - 换乘站连着好几条线,还能坐一圈绕回原地 —— 有分叉、有环, - 树画不出来。 + 每个站是一个点,每段轨道是一条边。换乘站连着好几条线,还能坐一圈绕回原地 —— 有分叉、有环,树画不出来。 } /> @@ -182,9 +177,7 @@ export default function GraphChapter() { } zh={ <> - 每个人是点,「是好友」是无向边,「关注」是有向边。 - A 的朋友的朋友可能又是 A —— 关系交织成网, - 不是层层向下的树。 + 每个人是点,「是好友」是无向边,「关注」是有向边。A 的朋友的朋友可能又是 A —— 关系交织成网,不是层层向下的树。 } /> @@ -209,8 +202,7 @@ export default function GraphChapter() { } zh={ <> - 每门课是点,「先修」是有向边。数据结构要先学数组, - 操作系统要先学 C 语言 —— + 每门课是点,「先修」是有向边。数据结构要先学数组,操作系统要先学 C 语言 —— 这些「谁必须在谁之前」构成一张有向图。 } @@ -237,8 +229,7 @@ export default function GraphChapter() { zh={ <> 每个网页是点,每个超链接是有向边。Google - 当年就是把整个互联网建成一张巨图, - 靠网页之间的链接关系排出搜索结果(PageRank)。 + 当年就是把整个互联网建成一张巨图,靠网页之间的链接关系排出搜索结果(PageRank)。 } /> @@ -466,10 +457,8 @@ export default function GraphChapter() { } zh={

- 这是本章最该带走的一句话。单链表是「每个点只有一条出边、 - 串成一条直线」的图;树是「连通 + 无环 + 指定了一个根」的图; - 网格是「每个格子是点、上下左右相邻是边」的图。 - 所以你早就在和图打交道了 —— 现在,我们把最一般的那种拿出来单独讲。 + 这是本章最该带走的一句话。单链表是「每个点只有一条出边、串成一条直线」的图;树是「连通 + 无环 + 指定了一个根」的图; + 网格是「每个格子是点、上下左右相邻是边」的图。所以你早就在和图打交道了 —— 现在,我们把最一般的那种拿出来单独讲。

} /> @@ -497,12 +486,7 @@ export default function GraphChapter() { } zh={

- 1736 年,数学家欧拉(Euler)研究「柯尼斯堡七桥问题」: - 能不能一次走遍七座桥、每座只过一次?他把陆地抽象成点、 - 桥抽象成边,证明了这不可能 —— 图论就此诞生。 - 两百多年后,同样的「点 + 边」抽象撑起了导航、社交、编译器、 - 芯片布线和整个互联网。把复杂现实抽象成点和边, - 是计算机科学最有用的思维工具之一。 + 1736 年,数学家欧拉(Euler)研究「柯尼斯堡七桥问题」:能不能一次走遍七座桥、每座只过一次?他把陆地抽象成点、桥抽象成边,证明了这不可能 —— 图论就此诞生。两百多年后,同样的「点 + 边」抽象撑起了导航、社交、编译器、芯片布线和整个互联网。把复杂现实抽象成点和边,是计算机科学最有用的思维工具之一。

} /> @@ -536,10 +520,7 @@ export default function GraphChapter() { } zh={

- 数组有「连续内存 + 下标公式」,树有「节点 + 左右引用」。 - 图两样都没有,因为它的形状千变万化。工程上最常用两种表示法, - 它们的取舍就是一场典型的「时间 vs 空间」权衡。 - 点下面的顶点,对照着看同一张图的两种长相: + 数组有「连续内存 + 下标公式」,树有「节点 + 左右引用」。图两样都没有,因为它的形状千变万化。工程上最常用两种表示法,它们的取舍就是一场典型的「时间 vs 空间」权衡。点下面的顶点,对照着看同一张图的两种长相:

} /> @@ -605,11 +586,8 @@ export default function GraphChapter() { } zh={ <> - 每个点挂一串「它的邻居」(数组套数组 / 哈希表套数组)。 - 只存真实存在的边,空间{" "} - ; - 取一个点的所有邻居也很直接。代价:查「i、j 之间有没有边」 - 要扫 i 的邻居串,最坏 O(deg i)。 + 每个点挂一串「它的邻居」(数组套数组 / 哈希表套数组)。只存真实存在的边,空间{" "} + ;取一个点的所有邻居也很直接。代价:查「i、j 之间有没有边」要扫 i 的邻居串,最坏 O(deg i)。 绝大多数做题和工程场景都用它。 } @@ -748,11 +726,8 @@ export default function GraphChapter() { } zh={

- 最朴素的存法:一个数组,每个元素是 [u, v, w] 一条边。 - 它适合「一次性读入全部边再排序」的算法,比如最小生成树的 - Kruskal、并查集判环。但想取某点的邻居得扫全部边,做遍历太慢。 - 所以它常常只是输入格式 —— 题目给你 edge list, - 你第一步往往是把它转成邻接表。 + 最朴素的存法:一个数组,每个元素是 [u, v, w] 一条边。它适合「一次性读入全部边再排序」的算法,比如最小生成树的 + Kruskal、并查集判环。但想取某点的邻居得扫全部边,做遍历太慢。所以它常常只是输入格式 —— 题目给你 edge list,你第一步往往是把它转成邻接表。

} /> @@ -788,8 +763,7 @@ export default function GraphChapter() { } zh={

- 图的绝大多数算法,骨架都是「把每个点访问一遍」。 - 访问图有两种标准走法,它们分别复用了你已经学过的一种结构: + 图的绝大多数算法,骨架都是「把每个点访问一遍」。访问图有两种标准走法,它们分别复用了你已经学过的一种结构:

} /> @@ -819,10 +793,8 @@ export default function GraphChapter() { } zh={ <> - 搜索一层一层向外扩:先访问起点,再访问它所有邻居, - 再访问「邻居里没访问过的邻居」…… 用 - 队列(先进先出)驱动。点是按「离起点几条边」分层出来的, - 所以 BFS 能求无权图里边数最少的路径。 + 搜索一层一层向外扩:先访问起点,再访问它所有邻居,再访问「邻居里没访问过的邻居」……用 + 队列(先进先出)驱动。点是按「离起点几条边」分层出来的,所以 BFS 能求无权图里边数最少的路径。 } /> @@ -851,9 +823,7 @@ export default function GraphChapter() { } zh={ <> - 搜索沿一条路走到走不通,再退回上一个岔口换一条。 - 用栈驱动(或者写成递归,系统调用栈就是那个栈)。 - 适合「探索所有路径 / 连通块 / 找环」这类问题。 + 搜索沿一条路走到走不通,再退回上一个岔口换一条。用栈驱动(或者写成递归,系统调用栈就是那个栈)。适合「探索所有路径 / 连通块 / 找环」这类问题。 } /> @@ -874,8 +844,7 @@ export default function GraphChapter() { } zh={

- 光看文字不如亲眼看。下面这张 8 个点的图,切换 BFS / DFS, - 一步步播放 —— 注意看下方队列 / 栈的内容怎么变、 + 光看文字不如亲眼看。下面这张 8 个点的图,切换 BFS / DFS,一步步播放 —— 注意看下方队列 / 栈的内容怎么变、 visited 集合怎么长,以及节点点亮的顺序 (每个点下面的 #编号就是访问序)。

@@ -909,12 +878,8 @@ export default function GraphChapter() { } zh={

- 这是图与树最本质的区别。树没有环:从根往下走, - 永远不会绕回已经走过的祖先,所以树的遍历(第 7 章)从不需要 - visited。图可能有环:A→B→C→A…… - 没有记录「谁来过」,遍历就会在环里无限打转,程序永远停不下来。 - 所以规则是:进入一个点前先查 visited,进入后立刻标记。 - BFS 里更要「一入队就标记」,而不是出队才标记 —— + 这是图与树最本质的区别。树没有环:从根往下走,永远不会绕回已经走过的祖先,所以树的遍历(第 7 章)从不需要 + visited。图可能有环:A→B→C→A……没有记录「谁来过」,遍历就会在环里无限打转,程序永远停不下来。所以规则是:进入一个点前先查 visited,进入后立刻标记。BFS 里更要「一入队就标记」,而不是出队才标记 —— 否则每一条指向它的边都会把它再塞进队列一次。

} @@ -1059,12 +1024,7 @@ export default function GraphChapter() { } zh={

- 用邻接表时,BFS 和 DFS 的复杂度都是 O(V + E): - 每个顶点被处理一次(贡献 V),每条边被检查一次 - (有向图一次、无向图两端各一次,贡献 E)。 - 这是图论最基础的一条复杂度,后面几乎所有算法都从它长出来。 - 换成邻接矩阵,同样的遍历会变成 O(V²), - 因为取一个点的邻居要扫整整一行。 + 用邻接表时,BFS 和 DFS 的复杂度都是 O(V + E):每个顶点被处理一次(贡献 V),每条边被检查一次(有向图一次、无向图两端各一次,贡献 E)。这是图论最基础的一条复杂度,后面几乎所有算法都从它长出来。换成邻接矩阵,同样的遍历会变成 O(V²),因为取一个点的邻居要扫整整一行。

} /> @@ -1101,8 +1061,7 @@ export default function GraphChapter() {

题目通常给你的是边列表(比如{" "} edges = [[0,1],[0,2],...]),第一步几乎总是把它转成 - 邻接表,然后 BFS / DFS 直接套模板。 - 先看「建图 + BFS」。高亮的两行是关键:顶点是 + 邻接表,然后 BFS / DFS 直接套模板。先看「建图 + BFS」。高亮的两行是关键:顶点是 在入队的那一刻被标记的。

} @@ -1254,8 +1213,7 @@ def bfs(g, start, n): ), zh: ( <> - 易错点:队列一定用 collections.deque, - 它的 popleft() 是 O(1);不要用{" "} + 易错点:队列一定用 collections.deque,它的 popleft() 是 O(1);不要用{" "} list.pop(0)(O(n),会把整个 BFS 拖成 O(V · E))。 ), @@ -1331,8 +1289,7 @@ function bfs(g, start, n) { zh: ( <> 易错点:JS 数组的 shift() 是 O(n) - 搬家。数据量大时用「读指针 head 只增不减」的写法, - 出队 O(1)。 + 搬家。数据量大时用「读指针 head 只增不减」的写法,出队 O(1)。 ), }, @@ -1351,8 +1308,7 @@ function bfs(g, start, n) { } zh={

- DFS 有两种写法:递归(短、直观, - 但图很深时可能导致调用栈溢出)和迭代 + DFS 有两种写法:递归(短、直观,但图很深时可能导致调用栈溢出)和迭代 (用显式栈,安全)。两个都要会:

} @@ -1423,9 +1379,7 @@ void dfsIter(List> g, int start) { ), zh: ( <> - 两版的访问顺序可能不同,因为栈先弹出最后压入的邻居; - 但都是合法的深度优先遍历。递归深度可能达到 V,V 很大时 - (如 10⁵ 个点连成一条链)要用迭代版防止栈溢出。 + 两版的访问顺序可能不同,因为栈先弹出最后压入的邻居;但都是合法的深度优先遍历。递归深度可能达到 V,V 很大时(如 10⁵ 个点连成一条链)要用迭代版防止栈溢出。 ), }, @@ -1560,8 +1514,7 @@ function dfsIter(g, start, n) { zh: ( <> JS 引擎的调用栈也就一万多层,深递归同样会报{" "} - Maximum call stack size exceeded。 - 竞赛或大数据量优先迭代版。 + Maximum call stack size exceeded。竞赛或大数据量优先迭代版。 ), }, @@ -1583,11 +1536,7 @@ function dfsIter(g, start, n) { } zh={

- 最后一块拼图:网格型图。矩阵(第 1 章) - 本身就是一张图 —— 每个格子 (r, c) 是顶点, - 它和上下左右四个格子之间各有一条边。 - 这就是为什么「洪水填充」和「迷宫最短路」属于图论题。 - 遍历时不用真的建邻接表,用一个方向数组 + 最后一块拼图:网格型图。矩阵(第 1 章)本身就是一张图 —— 每个格子 (r, c) 是顶点,它和上下左右四个格子之间各有一条边。这就是为什么「洪水填充」和「迷宫最短路」属于图论题。遍历时不用真的建邻接表,用一个方向数组 就能在一个循环里走完四个方向,这是网格题的标准模板:

} @@ -1698,11 +1647,7 @@ function gridDfs(grid, r, c) { } zh={

- ① 八方向(含对角线):dirs 写 8 个偏移即可。 - ② 直接改原网格的值当 visited(如把 1 改成 2 或 0), - 省掉一个 visited 矩阵 —— 但会破坏输入, - 面试时记得先问一句「可以修改原数组吗」。网格 BFS 同理, - 把栈换成队列即可。 + ① 八方向(含对角线):dirs 写 8 个偏移即可。② 直接改原网格的值当 visited(如把 1 改成 2 或 0),省掉一个 visited 矩阵 —— 但会破坏输入,面试时记得先问一句「可以修改原数组吗」。网格 BFS 同理,把栈换成队列即可。

} /> @@ -1738,9 +1683,7 @@ function gridDfs(grid, r, c) { zh={

和数组、哈希表不同,三种语言都 - 没有开箱即用的图类型。 - 但「用邻接表表示图」的写法高度统一,只是各语言的容器不同。 - 记住每种语言的建表惯用法和 + 没有开箱即用的图类型。但「用邻接表表示图」的写法高度统一,只是各语言的容器不同。记住每种语言的建表惯用法和 visited 用什么,上手任何图题都有章可循:

} @@ -2022,10 +1965,7 @@ const seen = new Set();`, } zh={

- 这是用 JS 刷图论最大的短板:没有优先队列 / 堆。 - 做 Dijkstra 时,要么自己手写一个二叉堆(第 9 章教过), - 要么用「每轮线性扫最小」的 O(V²) 版本 —— 小图上完全够用。 - 面试用 JS 时,提前把手写堆准备好。 + 这是用 JS 刷图论最大的短板:没有优先队列 / 堆。做 Dijkstra 时,要么自己手写一个二叉堆(第 9 章教过),要么用「每轮线性扫最小」的 O(V²) 版本 —— 小图上完全够用。面试用 JS 时,提前把手写堆准备好。

} /> @@ -2079,9 +2019,7 @@ const seen = new Set();`, } zh={

- 一大批 LeetCode 中等题,本质都是「在网格上做 DFS / BFS」: - 岛屿数量、岛屿面积、被围绕的区域、腐烂的橘子…… - 它们共用同一个映射: + 一大批 LeetCode 中等题,本质都是「在网格上做 DFS / BFS」:岛屿数量、岛屿面积、被围绕的区域、腐烂的橘子……它们共用同一个映射: 每个格子是一个顶点,上下左右相邻的格子之间有一条边 ,然后遍历。核心技巧叫「淹没 / 染色」 —— 访问过的格子直接改值,既当 visited 又防重复计数。 @@ -2122,13 +2060,10 @@ const seen = new Set();`, zh={

题意:给一个由 '1'(陆地)和{" "} - '0'(水)组成的网格,数出有几座岛 - (上下左右相连的陆地算一座)。 - 思路:逐格扫描,一旦遇到没被淹过的{" "} - '1',岛屿数 +1,然后从这里 DFS, - 把整座岛的 '1' 全「淹成{" "} - '0'」,同一座岛就不会被重复数。 - 看动画,注意绿色 = 已淹没(已计入某座岛)。 + '0'(水)组成的网格,数出有几座岛(上下左右相连的陆地算一座)。 + 思路:逐格扫描,一旦遇到没被淹过的{" "} + '1',岛屿数 +1,然后从这里 DFS,把整座岛的 '1' 全「淹成{" "} + '0'」,同一座岛就不会被重复数。看动画,注意绿色 = 已淹没(已计入某座岛)。

} /> @@ -2289,12 +2224,7 @@ const seen = new Set();`, } zh={

- 时间 O(行 × 列):每个格子最多被进入一次(淹过就变 0)。 - 空间是递归栈,最坏 O(行 × 列)(整张图全是陆地时)。面试常见追问: - 「不能改原网格怎么办?」(另开 visited 矩阵)、 - 「栈溢出怎么办?」(改 BFS 或迭代 DFS)、 - 「要数最大岛面积?」(LC695,让 DFS 返回淹没的格子数)。 - 淹没法是所有网格连通块题的模板。 + 时间 O(行 × 列):每个格子最多被进入一次(淹过就变 0)。空间是递归栈,最坏 O(行 × 列)(整张图全是陆地时)。面试常见追问:「不能改原网格怎么办?」(另开 visited 矩阵)、「栈溢出怎么办?」(改 BFS 或迭代 DFS)、「要数最大岛面积?」(LC695,让 DFS 返回淹没的格子数)。淹没法是所有网格连通块题的模板。

} /> @@ -2339,17 +2269,14 @@ const seen = new Set();`, zh={ <>

- 排课表、编译依赖、任务调度…… 这类问题都在问同一件事: + 排课表、编译依赖、任务调度……这类问题都在问同一件事: 一堆任务互相有「谁必须在谁之前」的约束,能不能排出一个合法顺序? - 这个顺序就是拓扑排序(topological sort)。 - 它只对有向无环图(DAG)成立 —— 一旦有环 - (A 依赖 B、B 又依赖 A),谁都没法先做,排不出来。 + 这个顺序就是拓扑排序(topological sort)。它只对有向无环图(DAG)成立 —— 一旦有环(A 依赖 B、B 又依赖 A),谁都没法先做,排不出来。

- 最直观的算法是 Kahn 入度法, - 像现实里选课一样自然: + 最直观的算法是 Kahn 入度法,像现实里选课一样自然:

} @@ -2410,8 +2337,7 @@ const seen = new Set();`, } zh={ <> - 出队一门课(修完),它的后继课入度各减 1;减到 0 的又能入队。 - 循环到队空。 + 出队一门课(修完),它的后继课入度各减 1;减到 0 的又能入队。循环到队空。 } /> @@ -2449,11 +2375,9 @@ const seen = new Set();`, zh={

题意:共 numCourses 门课, - prerequisites[i] = [a, b] 表示「修 a 前必须先修 b」。 - 判断能否修完所有课。 - 思路:这就是在问「这张有向图有没有环」—— - 能跑完整个拓扑排序就无环。看 Kahn 逐帧: - 注意每个点下方的入度怎么随出队一路减到 0。 + prerequisites[i] = [a, b] 表示「修 a 前必须先修 b」。判断能否修完所有课。 + 思路:这就是在问「这张有向图有没有环」—— + 能跑完整个拓扑排序就无环。看 Kahn 逐帧:注意每个点下方的入度怎么随出队一路减到 0。

} /> @@ -2628,10 +2552,7 @@ class Solution: 有向图判环有两种正解:①{" "} 拓扑排序(本题),出队数少于总点数即有环;②{" "} DFS 三状态标记(未访问 / 正在当前递归栈中 / 已完成)—— - 碰到「已完成」的点没问题,那条路已经探过了; - 碰到「仍在当前栈中」的点才是环。无向图不能直接套这两种: - 要用并查集,或者 DFS 时忽略「刚走过来的那条边」, - 否则会把父节点当成环。追问延伸:LC210 要输出 + 碰到「已完成」的点没问题,那条路已经探过了;碰到「仍在当前栈中」的点才是环。无向图不能直接套这两种:要用并查集,或者 DFS 时忽略「刚走过来的那条边」,否则会把父节点当成环。追问延伸:LC210 要输出 具体的拓扑序(把出队顺序记下来就是)。复杂度{" "} 。

@@ -2661,8 +2582,7 @@ class Solution: } zh={

- 挑算法前先分清一件事:边带不带权。 - 这决定了用哪把武器: + 挑算法前先分清一件事:边带不带权。这决定了用哪把武器:

} /> @@ -2692,9 +2612,7 @@ class Solution: zh={ <> 每条边代价都相同时,BFS 按层扩散, - 第一次到达某点的层数,就是到它的最少步数。 - 不用任何高级算法,一个队列足矣。 - 「最少几步 / 最少操作次数」类题的首选。 + 第一次到达某点的层数,就是到它的最少步数。不用任何高级算法,一个队列足矣。「最少几步 / 最少操作次数」类题的首选。 } /> @@ -2725,11 +2643,7 @@ class Solution: } zh={ <> - 边有不同权重(距离 / 耗时)时,BFS 失效: - 走的边少不代表权之和小。改用 Dijkstra: - 小根堆每次挑出「当前离起点最近、还没定案」的点, - 定案后松弛它的邻居。前提是所有边权非负, - 实现上呼应第 9 章的堆。 + 边有不同权重(距离 / 耗时)时,BFS 失效:走的边少不代表权之和小。改用 Dijkstra:小根堆每次挑出「当前离起点最近、还没定案」的点,定案后松弛它的邻居。前提是所有边权非负,实现上呼应第 9 章的堆。 } /> @@ -2757,13 +2671,11 @@ class Solution:

重点讲 Dijkstra。它的规则是: - 每次从「未定案的点」里挑 dist 最小的那个, - 它的最短距离此刻就能确定 + 每次从「未定案的点」里挑 dist 最小的那个,它的最短距离此刻就能确定 —— 因为边权非负,任何绕远路只会更贵,不可能反超。定案后,用它去 松弛(relax)邻居: - if dist[u] + w < dist[v]: 更新 dist[v]。 - 看 5 个点的完整演算,注意 dist 是怎么被一次次改小的。 + if dist[u] + w < dist[v]: 更新 dist[v]。看 5 个点的完整演算,注意 dist 是怎么被一次次改小的。

} /> @@ -2801,12 +2713,10 @@ class Solution: zh={

题意:从节点 k 发信号,times[i] = [u, v, w]{" "} - 表示信号从 u 到 v 要 w 时间。求信号传遍所有 n 个节点最少需要多久 - (传不到返回 -1)。 - 思路:标准单源最短路 —— Dijkstra 求出 k + 表示信号从 u 到 v 要 w 时间。求信号传遍所有 n 个节点最少需要多久(传不到返回 -1)。 + 思路:标准单源最短路 —— Dijkstra 求出 k 到每个点的最短时间,答案就是这些最短时间里的最大值 - (最后一个收到信号的点决定总耗时);若有点仍是 ∞ 则不可达, - 返回 -1。 + (最后一个收到信号的点决定总耗时);若有点仍是 ∞ 则不可达,返回 -1。

} /> @@ -2894,9 +2804,7 @@ class Solution: ), zh: ( <> - if (d > dist[u]) continue; 这句很关键: - 堆里可能残留同一个点的旧(更大)距离, - 弹出时发现比当前 dist 还大,就是过期记录,直接跳过。 + if (d > dist[u]) continue; 这句很关键:堆里可能残留同一个点的旧(更大)距离,弹出时发现比当前 dist 还大,就是过期记录,直接跳过。 ), }, @@ -2960,8 +2868,7 @@ class Solution: ), zh: ( <> - Python 的 heapq 是小根堆, - 把元组第一维放距离就会按距离出堆。最后的{" "} + Python 的 heapq 是小根堆,把元组第一维放距离就会按距离出堆。最后的{" "} max(dist[1:]) 跳过下标 0(节点从 1 开始编号)。 ), @@ -3034,8 +2941,7 @@ var networkDelayTime = function (times, n, k) { zh: ( <> 想要正统{" "} - 的 JS 版, - 得自己手写二叉堆(第 9 章)。这里的 O(V²) 版在 LC743 + 的 JS 版,得自己手写二叉堆(第 9 章)。这里的 O(V²) 版在 LC743 的数据规模下足够。 ), @@ -3072,14 +2978,9 @@ var networkDelayTime = function (times, n, k) {

① 为什么用小根堆?为了每次 O(log V) 地取出「当前最近的未定案点」,不用线性扫。②{" "} - 为什么怕负权?Dijkstra 在一个点弹出堆的那一刻就把它的距离 - 定死、永不回头,而这只在边权非负时成立 - (绕路只会更贵)。有负边时,后来才发现的路径可能更便宜, - 某个点会被过早定案,答案就错了。这时改用 Bellman-Ford, - 复杂度 ,它还能判负环。③{" "} + 为什么怕负权?Dijkstra 在一个点弹出堆的那一刻就把它的距离定死、永不回头,而这只在边权非负时成立(绕路只会更贵)。有负边时,后来才发现的路径可能更便宜,某个点会被过早定案,答案就错了。这时改用 Bellman-Ford,复杂度 ,它还能判负环。③{" "} 复杂度?用二叉堆是{" "} - ; - 图连通、E ≥ V - 1 时常简写作 O(E log V)。 + ;图连通、E ≥ V - 1 时常简写作 O(E log V)。

} /> @@ -3107,9 +3008,7 @@ var networkDelayTime = function (times, n, k) { zh={

无权图(或所有边权相同)求最短路,不要用 Dijkstra —— - 直接 BFS 更简单更快。Dijkstra 是「边权不同且非负」时才需要的更通用的算法。 - 看到「最少步数 / 最少操作次数」优先想 BFS; - 看到「最短距离 / 最小花费 + 边权不一」才想 Dijkstra。 + 直接 BFS 更简单更快。Dijkstra 是「边权不同且非负」时才需要的更通用的算法。看到「最少步数 / 最少操作次数」优先想 BFS;看到「最短距离 / 最小花费 + 边权不一」才想 Dijkstra。

} /> @@ -3170,8 +3069,7 @@ var networkDelayTime = function (times, n, k) { zh: ( <> 图 = 顶点 + 边。链表、树、网格都是它的特例 —— - 单链表是「每点一条出边的直线图」,树是「连通且无环的图」, - 网格是「每个格子和上下左右相邻的图」。 + 单链表是「每点一条出边的直线图」,树是「连通且无环的图」,网格是「每个格子和上下左右相邻的图」。 ), }, @@ -3187,8 +3085,8 @@ var networkDelayTime = function (times, n, k) { ), zh: ( <> - 两种表示法:邻接矩阵 占 O(V²) 空间、查边 O(1); - 邻接表 占 O(V + E) 空间、遍历邻居快。 + 两种表示法:邻接矩阵占 O(V²) 空间、查边 O(1); + 邻接表占 O(V + E) 空间、遍历邻居快。 稀疏图默认邻接表,这也是做题时最常用的表示法。 ), @@ -3224,8 +3122,7 @@ var networkDelayTime = function (times, n, k) { ), zh: ( <> - 网格题 = 网格上的 DFS/BFS + 方向数组 dirs; - 「淹没 / 染色」把访问过的格子改值,兼任 visited + 网格题 = 网格上的 DFS/BFS + 方向数组 dirs;「淹没 / 染色」把访问过的格子改值,兼任 visited (LC200 岛屿是模板)。 ), @@ -3243,10 +3140,7 @@ var networkDelayTime = function (times, n, k) { ), zh: ( <> - 拓扑排序只对 有向无环图(DAG) 成立:Kahn 入度法 - (入度 0 入队 → 出队时把后继入度减 1),出不完 = 有环。 - 这是有向图判环的两种标准做法之一(另一种是 DFS 三状态); - 无向图判环要用别的方法。 + 拓扑排序只对有向无环图(DAG)成立:Kahn 入度法(入度 0 入队 → 出队时把后继入度减 1),出不完 = 有环。这是有向图判环的两种标准做法之一(另一种是 DFS 三状态);无向图判环要用别的方法。 ), }, @@ -3266,8 +3160,7 @@ var networkDelayTime = function (times, n, k) { <> 最短路看边权:无权用 BFS(层数就是最少边数); 非负权用 Dijkstra(贪心 + 小根堆,O((V + E) log V)); - 有负权时 Dijkstra 失效(定案后不再回头), - 改用 Bellman-Ford,复杂度 O(V · E)。 + 有负权时 Dijkstra 失效(定案后不再回头),改用 Bellman-Ford,复杂度 O(V · E)。 ), }, diff --git a/app/graph/viz.tsx b/app/graph/viz.tsx index bb9cfc6..9613e45 100644 --- a/app/graph/viz.tsx +++ b/app/graph/viz.tsx @@ -311,11 +311,7 @@ export function TermGraph() { } zh={ <> - 顶点装数据,边记录两点之间的关系; - 一个点连了几条边就是它的度 degree; - 能沿边从一个点走到另一个点就说它们连通 connected; - 起点等于终点的闭合路径叫环 cycle。 - 本章的一切,都由这几个词拼出来。 + 顶点装数据,边记录两点之间的关系;一个点连了几条边就是它的度 degree;能沿边从一个点走到另一个点就说它们连通 connected;起点等于终点的闭合路径叫环 cycle。本章的一切,都由这几个词拼出来。 } /> @@ -368,8 +364,7 @@ export function MiniConcepts() { } zh={ <> - 边没有方向,关系是对称的:A 和 B 互为朋友、地铁双向通车。 - 树就是这一类:无向、连通、且没有环。 + 边没有方向,关系是对称的:A 和 B 互为朋友、地铁双向通车。树就是这一类:无向、连通、且没有环。 } /> @@ -393,8 +388,7 @@ export function MiniConcepts() { } zh={ <> - 边有箭头,关系是单向的:A 关注 B 不等于 B 关注 A、 - 网页 A 链到 B。此时度分成入度(指进来的)和 + 边有箭头,关系是单向的:A 关注 B 不等于 B 关注 A、网页 A 链到 B。此时度分成入度(指进来的)和 出度(指出去的)。单链表就是「每个点只有一条出边」的有向图。 } @@ -420,9 +414,7 @@ export function MiniConcepts() { } zh={ <> - 边上带数字(权 weight):距离、时间、费用。 - 此时「最短路」问的是权之和最小的那条路,而不是边数最少。 - 有没有方向和有没有权是两个独立的选择 —— + 边上带数字(权 weight):距离、时间、费用。此时「最短路」问的是权之和最小的那条路,而不是边数最少。有没有方向和有没有权是两个独立的选择 —— 这里画的恰好既有向又带权,但带权的无向图同样常见。 } @@ -550,8 +542,7 @@ export function ReprLab() { } zh={ <> - V × V 的表格:查「i、j 之间有没有边」是 O(1), - 但不管图多稀疏,都硬占 O(V²) 空间。 + V × V 的表格:查「i、j 之间有没有边」是 O(1),但不管图多稀疏,都硬占 O(V²) 空间。 } /> @@ -618,10 +609,8 @@ export function ReprLab() { } zh={ <> - 顶点 {sel} 的邻居是 [{REPR_ADJ[sel].join(", ")}]: - 矩阵里是第 {sel} 行中所有为 1 的列;邻接表里就是第 {sel}{" "} - 行挂着的那串。两种表示装的是同一张图, - 区别只在于它们让什么变便宜:查边快,还是省空间加遍历邻居快。 + 顶点 {sel} 的邻居是 [{REPR_ADJ[sel].join(", ")}]:矩阵里是第 {sel} 行中所有为 1 的列;邻接表里就是第 {sel}{" "} + 行挂着的那串。两种表示装的是同一张图,区别只在于它们让什么变便宜:查边快,还是省空间加遍历邻居快。 } /> @@ -703,8 +692,7 @@ function buildBfs(): TravFrame[] { } zh={ <> - 起点 0 入队。BFS 用队列(先进先出): - 先被发现的点先被处理,搜索就一层一层往外扩(呼应第 5 章队列)。 + 起点 0 入队。BFS 用队列(先进先出):先被发现的点先被处理,搜索就一层一层往外扩(呼应第 5 章队列)。 一入队就标记 visited,而不是出队才标记 —— 这样同一个点不会被排两次队。 @@ -740,8 +728,7 @@ function buildBfs(): TravFrame[] { zh={ <> 出队 {u}(第 {order[u] + 1} 个访问),把它没见过的邻居{" "} - {newly.join("、")} 塞到队尾。 - 队列里装的正是「下一批要处理的点」。 + {newly.join("、")} 塞到队尾。队列里装的正是「下一批要处理的点」。 } /> @@ -755,8 +742,7 @@ function buildBfs(): TravFrame[] { } zh={ <> - 出队 {u}(第 {order[u] + 1} 个访问), - 邻居全在 visited 里了 —— 无人入队。 + 出队 {u}(第 {order[u] + 1} 个访问),邻居全在 visited 里了 —— 无人入队。 } /> @@ -786,8 +772,7 @@ function buildBfs(): TravFrame[] { <> 队列空,遍历结束。访问序 0 1 2 3 4 5 6 7,严格按层: {"{0} → {1,2} → {3,4,5} → {6,7}"}。第 k 层装的正好是「距起点 k - 条边」的点 —— 这就是 BFS 能求无权图最少边数路径的原因(§06)。 - 一旦边权各不相同,这个结论就不成立,得换 Dijkstra。 + 条边」的点 —— 这就是 BFS 能求无权图最少边数路径的原因(§06)。一旦边权各不相同,这个结论就不成立,得换 Dijkstra。 } /> @@ -823,8 +808,7 @@ function buildDfs(): TravFrame[] { zh={ <> DFS 用栈(写成递归时,系统调用栈就是这个栈)—— - 沿一条路一直走到走不通,再退回上一个岔口换一条。 - 下方栈里显示的是当前节点之下的那段栈,也就是回到起点的路径。 + 沿一条路一直走到走不通,再退回上一个岔口换一条。下方栈里显示的是当前节点之下的那段栈,也就是回到起点的路径。 } /> @@ -874,8 +858,7 @@ function buildDfs(): TravFrame[] { {parent}–{u} {" "} - 往深处走,访问 {u}(第 {order[u] + 1} 个),压栈。 - 只要当前点还有没走过的邻居,DFS 就不回头。 + 往深处走,访问 {u}(第 {order[u] + 1} 个),压栈。只要当前点还有没走过的邻居,DFS 就不回头。 } /> @@ -947,9 +930,7 @@ function buildDfs(): TravFrame[] { zh={ <> 访问序 0 1 3 6 4 2 5 7 —— 和 BFS 完全不同:DFS - 先一头扎到最深(0→1→3→6→…)才回头。visited 绝不能省: - 这张图有环,少了它就会在环里无限打转。树没有环, - 所以树的遍历才不需要 visited。 + 先一头扎到最深(0→1→3→6→…)才回头。visited 绝不能省:这张图有环,少了它就会在环里无限打转。树没有环,所以树的遍历才不需要 visited。 } /> @@ -1149,8 +1130,7 @@ function buildGridFrames(): GridFrame[] { zh={ <> 网格本身就是一张图:每个格子是一个顶点, - 上下左右相邻的两个格子之间有一条边。规则:逐格扫描, - 遇到还没淹过的陆地 1 就把岛屿数 +1,再 DFS + 上下左右相邻的两个格子之间有一条边。规则:逐格扫描,遇到还没淹过的陆地 1 就把岛屿数 +1,再 DFS 把这座岛所有相连的 1 全部「淹成 0」,同一座岛就不会被重复计数。 } @@ -1184,8 +1164,7 @@ function buildGridFrames(): GridFrame[] { {r},{c} - ):把当前陆地标记为已访问(染成水),再朝上下左右四个方向递归。 - 改值本身就充当了 visited,不用另开一个矩阵。 + ):把当前陆地标记为已访问(染成水),再朝上下左右四个方向递归。改值本身就充当了 visited,不用另开一个矩阵。 } />, @@ -1228,8 +1207,7 @@ function buildGridFrames(): GridFrame[] { {r},{c} - ):它不属于任何已淹的岛,于是第 {count} 座岛诞生, - 启动 DFS 淹没整片相连陆地。 + ):它不属于任何已淹的岛,于是第 {count} 座岛诞生,启动 DFS 淹没整片相连陆地。 } />, @@ -1377,9 +1355,7 @@ function buildTopoFrames(): TopoFrame[] { } zh={ <> - 拓扑序只对有向无环图存在。第一步是数每个点的入度: - 有几根箭头指向它,也就是它还有几门没修完的前置课。 - 课程 3 被 1、2 指向,入度 2;课程 0 没人指向,入度 0。 + 拓扑序只对有向无环图存在。第一步是数每个点的入度:有几根箭头指向它,也就是它还有几门没修完的前置课。课程 3 被 1、2 指向,入度 2;课程 0 没人指向,入度 0。 } />, @@ -1469,8 +1445,7 @@ function buildTopoFrames(): TopoFrame[] { zh={ <> 拓扑序:{out.join(" → ")}。全部 {n} 门课都出了队 ⇒{" "} - 图无环,课程表可行。若中途队列空了却还有课没出, - 说明剩下的课互相依赖成环,谁都开不了头 —— 这正是 LC207 + 图无环,课程表可行。若中途队列空了却还有课没出,说明剩下的课互相依赖成环,谁都开不了头 —— 这正是 LC207 的判定,也是有向图判环的两种标准做法之一。 } @@ -1667,11 +1642,8 @@ function buildDijkstra(): DjFrame[] { } zh={ <> - Dijkstra 求单源最短路(权之和最小),前提是所有边权非负。 - 先令 dist[起点 0] = 0,其余为 ∞。 - 小根堆每次弹出「当前 dist 最小、还没定案」的点。 - 因为没有一条边会让总和变小,所以一个点第一次被弹出时, - 它的距离就已经是最终答案。 + Dijkstra 求单源最短路(权之和最小),前提是所有边权非负。先令 dist[起点 0] = 0,其余为 ∞。 + 小根堆每次弹出「当前 dist 最小、还没定案」的点。因为没有一条边会让总和变小,所以一个点第一次被弹出时,它的距离就已经是最终答案。 } />, @@ -1722,8 +1694,7 @@ function buildDijkstra(): DjFrame[] { <> {" "} 据此松弛它的出边: - {updated.map((x) => `dist[${x}]=${dist[x]}`).join("、")}。 - 这些点还没定案,所以可以把它们的临时距离改小。 + {updated.map((x) => `dist[${x}]=${dist[x]}`).join("、")}。这些点还没定案,所以可以把它们的临时距离改小。 ) : ( <> 它没有能让别人变更短的出边。 @@ -1752,12 +1723,9 @@ function buildDijkstra(): DjFrame[] { } zh={ <> - 全部定案。0 到各点的最短距离:[{dist.join(", ")}]。 - 注意 dist[1] 经 2→1 从 4 降到 3、dist[3] 经 1→3 从 6 降到 4 —— - 这两次变化都发生在它们还是临时距离的时候;一旦定案就不会再改。 - 若这是 LC743,网络延迟 = 这些值里的 - 最大值 {Math.max(...dist)}(最后收到信号的点决定总耗时); - 若有点仍是 ∞ 则不可达,返回 -1。 + 全部定案。0 到各点的最短距离:[{dist.join(", ")}]。注意 dist[1] 经 2→1 从 4 降到 3、dist[3] 经 1→3 从 6 降到 4 —— + 这两次变化都发生在它们还是临时距离的时候;一旦定案就不会再改。若这是 LC743,网络延迟 = 这些值里的 + 最大值 {Math.max(...dist)}(最后收到信号的点决定总耗时);若有点仍是 ∞ 则不可达,返回 -1。 } />, diff --git a/app/hash/page.tsx b/app/hash/page.tsx index 63d9664..1f364a3 100644 --- a/app/hash/page.tsx +++ b/app/hash/page.tsx @@ -127,8 +127,7 @@ const F1: ArrayFrame[] = [ zh={ <> i = 3,v = 7:需要 9 − 7 = 2。查表:有,2 的下标是 1 → - 答案 [1, 3]。每个数只被处理一次:一次查 + 一次存,平均都是 O(1), - 整趟 O(n)。 + 答案 [1, 3]。每个数只被处理一次:一次查 + 一次存,平均都是 O(1),整趟 O(n)。 } /> @@ -156,9 +155,7 @@ const F49: ArrayFrame[] = [ } zh={ <> - 异位词的特征:字母相同、顺序不同。给每个词一个签名: - 把字母排序 —— eat / tea / ate 排序后都是 "aet"。 - 签名做 key,词加入该 key 下的那一组。 + 异位词的特征:字母相同、顺序不同。给每个词一个签名:把字母排序 —— eat / tea / ate 排序后都是 "aet"。签名做 key,词加入该 key 下的那一组。 } /> @@ -176,8 +173,7 @@ const F49: ArrayFrame[] = [ } zh={ <> - "eat" → 签名 "aet":表里还没有这个组,新建。 - map = {"{aet: [eat]}"} + "eat" → 签名 "aet":表里还没有这个组,新建。map = {"{aet: [eat]}"} } /> @@ -195,8 +191,7 @@ const F49: ArrayFrame[] = [ } zh={ <> - "tea" → 签名 "aet":组已存在,直接加入。 - map = {"{aet: [eat, tea]}"} + "tea" → 签名 "aet":组已存在,直接加入。map = {"{aet: [eat, tea]}"} } /> @@ -234,8 +229,7 @@ const F49: ArrayFrame[] = [ } zh={ <> - "ate" → 签名 "aet":加入 eat 那组。 - 三个异位词从未两两比较过,却自动聚在了一起。 + "ate" → 签名 "aet":加入 eat 那组。三个异位词从未两两比较过,却自动聚在了一起。 } /> @@ -305,8 +299,7 @@ const F128: ArrayFrame[] = [ } zh={ <> - 第一步:全部丢进 Set = {"{100, 4, 200, 1, 3, 2}"}。从此「x 在不在」 - 平均只要 O(1)。目标是最长的数值连续序列,元素在数组里的位置无所谓。 + 第一步:全部丢进 Set = {"{100, 4, 200, 1, 3, 2}"}。从此「x 在不在」平均只要 O(1)。目标是最长的数值连续序列,元素在数组里的位置无所谓。 } /> @@ -325,8 +318,7 @@ const F128: ArrayFrame[] = [ } zh={ <> - 检查 100:99 不在 Set 里 → 100 是某条序列的起点。 - 向右数:101 也不在 → 这条序列长度为 1。 + 检查 100:99 不在 Set 里 → 100 是某条序列的起点。向右数:101 也不在 → 这条序列长度为 1。 } /> @@ -345,8 +337,7 @@ const F128: ArrayFrame[] = [ } zh={ <> - 检查 4:3 在 Set 里 → 4 不是起点,直接跳过。 - 它属于别人的序列,等那条序列的起点来数它 —— 这一步正是总工作量保持线性的关键。 + 检查 4:3 在 Set 里 → 4 不是起点,直接跳过。它属于别人的序列,等那条序列的起点来数它 —— 这一步正是总工作量保持线性的关键。 } /> @@ -437,8 +428,7 @@ const F128: ArrayFrame[] = [ } zh={ <> - 为什么是 O(n)?每个数最多被摸两次:一次是它自己的起点检查, - 一次是被某条序列「向右数到」。总工作量 ≤ 2n。内层 while + 为什么是 O(n)?每个数最多被摸两次:一次是它自己的起点检查,一次是被某条序列「向右数到」。总工作量 ≤ 2n。内层 while 不是重复劳动,它只是把账记在了起点头上。 } @@ -490,8 +480,7 @@ export default function HashChapter() { zh: ( <> 哈希函数把 key 算成一个数组下标,表直接跳过去 —— - 查找因此不需要逐个扫描已存的数据。代价是: - 两个不同的 key 可能算出同一个下标,而本章余下的篇幅,大半都在讲怎么处理它。 + 查找因此不需要逐个扫描已存的数据。代价是:两个不同的 key 可能算出同一个下标,而本章余下的篇幅,大半都在讲怎么处理它。 ), }} @@ -550,19 +539,14 @@ export default function HashChapter() { zh={ <>

- 在纸质字典里找一个字,有两种查法。第一种:从第一页开始逐页翻, - 翻到为止 —— 这是线性查找,O(n),字典越厚越慢。 - 第二种:用前面的索引,按读音直接翻到那一页,字典多厚几乎无所谓。 - 第二种查法之所以成立,是因为 + 在纸质字典里找一个字,有两种查法。第一种:从第一页开始逐页翻,翻到为止 —— 这是线性查找,O(n),字典越厚越慢。第二种:用前面的索引,按读音直接翻到那一页,字典多厚几乎无所谓。第二种查法之所以成立,是因为 这个字本身,经过一套固定规则,变成了一个位置。

数组早就有这种能力:给下标,O(1) 拿到元素 —— - 因为地址是算出来的,不是找出来的。但下标必须是较小的非负整数。 - 哈希表把这份能力推广到任意 key: + 因为地址是算出来的,不是找出来的。但下标必须是较小的非负整数。哈希表把这份能力推广到任意 key: - 哈希函数把 key(字符串、对象、一对坐标……)算成一个整数, - 这个整数就当数组下标用 + 哈希函数把 key(字符串、对象、一对坐标……)算成一个整数,这个整数就当数组下标用 ,全程不扫描。这就是全部思想。哈希表不是新的存储方式,它是 一个数组 + 一条算下标的规则 + 一套「两个 key 算出同一个下标怎么办」的方案。 @@ -664,13 +648,10 @@ export default function HashChapter() { } zh={

- Python 解析一个变量名,查的是命名空间,而命名空间就是 dict; - 浏览器缓存按 URL 找文件;数据库按相等的键连接两张表时,会临时建一张哈希表(hash join); - Redis 几乎就是一张带网络接口的哈希表。凡是「用名字直达内容」的地方, - 背后多半站着一张哈希表。有一个相关但不同的例子:Git + Python 解析一个变量名,查的是命名空间,而命名空间就是 dict;浏览器缓存按 URL 找文件;数据库按相等的键连接两张表时,会临时建一张哈希表(hash join); + Redis 几乎就是一张带网络接口的哈希表。凡是「用名字直达内容」的地方,背后多半站着一张哈希表。有一个相关但不同的例子:Git 用内容的密码学哈希给每次提交命名 —— - 那是另一类哈希函数,目标是「几乎不可能构造出同名的两份内容」, - 而不是「查得快」。 + 那是另一类哈希函数,目标是「几乎不可能构造出同名的两份内容」,而不是「查得快」。

} /> @@ -702,8 +683,7 @@ export default function HashChapter() { } zh={

- 哈希函数(hash function)接受一个 key, - 返回一个整数。怎么算几乎都行,但有三条要求决定了这张表能不能用 —— + 哈希函数(hash function)接受一个 key,返回一个整数。怎么算几乎都行,但有三条要求决定了这张表能不能用 —— 每一条背后都是一个「不满足会怎样」。

} @@ -797,8 +777,7 @@ export default function HashChapter() { } zh={

- 字符串怎么变成数?最经典的做法是多项式哈希: - 把每个字符的编码乘上 31 的一个幂次,再相加。以{" "} + 字符串怎么变成数?最经典的做法是多项式哈希:把每个字符的编码乘上 31 的一个幂次,再相加。以{" "} "cat" 为例(c = 99、a = 97、t = 116):

} @@ -835,8 +814,7 @@ export default function HashChapter() { 会得到相同的值:字母一样,只是顺序不同。乘上 31 的幂次, 让位置也参与运算 —— 越靠左的字符被乘 31 的次数越多。写成循环就是一行: - h = h × 31 + code(c)。最后一步,把这个大数对桶数取模, - 压进合法的数组下标范围。亲手转一转这台机器: + h = h × 31 + code(c)。最后一步,把这个大数对桶数取模,压进合法的数组下标范围。亲手转一转这台机器:

} /> @@ -867,12 +845,9 @@ export default function HashChapter() { } zh={

- 31 是奇素数。奇数乘子能保住结果的低位信息 —— 这很重要, - 因为桶数通常是 2 的幂,下标就取自低位。它还便宜: - 31 × i 等价于 (i << 5) − i, - 一次移位加一次减法。你刚才在实验室里造出的 Aa 与 BB{" "} - 冲突不是 bug —— 它们在 Java 里的 hashCode 都是 2112。 - 字符串有无限多个,int 只有 2³² 个,按抽屉原理 + 31 是奇素数。奇数乘子能保住结果的低位信息 —— 这很重要,因为桶数通常是 2 的幂,下标就取自低位。它还便宜: + 31 × i 等价于 (i << 5) − i,一次移位加一次减法。你刚才在实验室里造出的 Aa 与 BB{" "} + 冲突不是 bug —— 它们在 Java 里的 hashCode 都是 2112。字符串有无限多个,int 只有 2³² 个,按抽屉原理 必然存在同哈希的 key。冲突不可能被设计掉,只能被处理。

} @@ -921,17 +896,12 @@ export default function HashChapter() { zh={ <>

- 方案一:链地址法(separate chaining)。 - 每个桶挂的不是一个元素,而是一条链表,撞了就接到链上。 - 查找时先哈希定位桶,再沿链逐个比对 key。Java 的 HashMap + 方案一:链地址法(separate chaining)。每个桶挂的不是一个元素,而是一条链表,撞了就接到链上。查找时先哈希定位桶,再沿链逐个比对 key。Java 的 HashMap 和 §04 手写的版本都走这条路。

- 方案二:开放寻址(open addressing)。 - 每个桶最多放一个元素,撞了就按固定规则去找另一个空桶, - 最简单的规则是「往右试下一个」,叫 - 线性探测(linear probing)。CPython 的 dict、 - Rust 标准库的 HashMap 都用开放寻址,它对 CPU 缓存更友好 —— + 方案二:开放寻址(open addressing)。每个桶最多放一个元素,撞了就按固定规则去找另一个空桶,最简单的规则是「往右试下一个」,叫 + 线性探测(linear probing)。CPython 的 dict、Rust 标准库的 HashMap 都用开放寻址,它对 CPU 缓存更友好 —— 下一个被探测的桶通常就在同一条缓存行里。同一批 key,两种策略:

@@ -965,13 +935,7 @@ export default function HashChapter() { } zh={

- 哈希太差,或者输入是攻击者精心构造的,某条链就可能变得很长。 - Java 8 起,当一个桶里大约存到 8 个元素、 - 且总桶数不少于 64 时,这个桶会从链表转成红黑树, - 桶内查找从 O(n) 降到 O(log n)。为什么是 8? - 哈希分布良好时,单桶元素个数近似服从泊松分布, - 达到 8 的概率约为一亿分之六。树化不是为正常数据准备的, - 它是给恶意构造的输入(HashDoS 攻击)留的保险丝。 + 哈希太差,或者输入是攻击者精心构造的,某条链就可能变得很长。Java 8 起,当一个桶里大约存到 8 个元素、且总桶数不少于 64 时,这个桶会从链表转成红黑树,桶内查找从 O(n) 降到 O(log n)。为什么是 8?哈希分布良好时,单桶元素个数近似服从泊松分布,达到 8 的概率约为一亿分之六。树化不是为正常数据准备的,它是给恶意构造的输入(HashDoS 攻击)留的保险丝。

} /> @@ -998,11 +962,7 @@ export default function HashChapter() { } zh={

- 探测表里不能直接把格子改回空。查找会在第一个空格子处停下, - 所以那些被探测挤到后面的元素会突然变得不可达。 - 通用做法是写一块墓碑(tombstone), - 意思是「这里曾经有人」:查找继续往后走,插入可以复用它。 - 所以开放寻址的删除并不真正释放格子,墓碑多了还得重建整张表。 + 探测表里不能直接把格子改回空。查找会在第一个空格子处停下,所以那些被探测挤到后面的元素会突然变得不可达。通用做法是写一块墓碑(tombstone),意思是「这里曾经有人」:查找继续往后走,插入可以复用它。所以开放寻址的删除并不真正释放格子,墓碑多了还得重建整张表。

} /> @@ -1048,23 +1008,13 @@ export default function HashChapter() { <>

桶越满,冲突越频繁。衡量「满」的指标叫 - 负载因子(load factor)= 已存元素数 ÷ 桶数。 - Java HashMap 默认在负载因子超过 0.75 时扩容。 - 扩容 = 申请一个更大的桶数组(容量翻倍),然后把已有的每个 key - 重新安放一次 —— 后半步叫 rehash,而且没得省。 - 因为下标来自 hash % 桶数,桶数一变,key - 该待的地方就变了。举例:98262 % 8 = 6、98262 % 16 = 6, - 这个 key 恰好原地不动;但 98270 % 8 = 6 而 98270 % 16 = 14, - 它就得搬。容量是 2 的幂时,原本在 i 号桶的元素要么留在 i, - 要么搬到 i + 旧容量, + 负载因子(load factor)= 已存元素数 ÷ 桶数。Java HashMap 默认在负载因子超过 0.75 时扩容。扩容 = 申请一个更大的桶数组(容量翻倍),然后把已有的每个 key + 重新安放一次 —— 后半步叫 rehash,而且没得省。因为下标来自 hash % 桶数,桶数一变,key + 该待的地方就变了。举例:98262 % 8 = 6、98262 % 16 = 6,这个 key 恰好原地不动;但 98270 % 8 = 6 而 98270 % 16 = 14,它就得搬。容量是 2 的幂时,原本在 i 号桶的元素要么留在 i,要么搬到 i + 旧容量, 但每个元素都得被访问一次才知道是哪种。

- rehash 意味着某一次插入可能要花 O(n)。由于容量是翻倍的, - 这份代价会摊到之后的插入上,所以插入是均摊 O(1), - 而不是每次都 O(1)。为什么是 0.75?再高,每个桶平均挂的元素更多, - 每次查找要比较的次数变多 —— 只要负载因子有固定上限,平均仍是 O(1), - 只是常数变大、更慢;再低,大片桶空着浪费内存。 + rehash 意味着某一次插入可能要花 O(n)。由于容量是翻倍的,这份代价会摊到之后的插入上,所以插入是均摊 O(1),而不是每次都 O(1)。为什么是 0.75?再高,每个桶平均挂的元素更多,每次查找要比较的次数变多 —— 只要负载因子有固定上限,平均仍是 O(1),只是常数变大、更慢;再低,大片桶空着浪费内存。

} @@ -1189,10 +1139,8 @@ export default function HashChapter() { } zh={

- 哈希表的插入、查找、删除是平均 O(1),依赖两个前提: - 哈希函数分布均匀、负载因子受控(靠扩容维持)。 - 最坏是 O(n),发生在大量 key 落进同一个桶时。 - 只说「哈希表是 O(1)」,平均和前提两样都漏了 —— + 哈希表的插入、查找、删除是平均 O(1),依赖两个前提:哈希函数分布均匀、负载因子受控(靠扩容维持)。 + 最坏是 O(n),发生在大量 key 落进同一个桶时。只说「哈希表是 O(1)」,平均和前提两样都漏了 —— 这是面试作答里最常见的一处失分。

} @@ -1399,9 +1347,7 @@ public class MyHashMap { <> 两个细节:Java 不能直接 new 泛型数组,所以有那行强转; & 0x7fffffff 用来清掉符号位 —— hashCode - 可能为负,而 Java 里负数取模仍是负数,会得到非法下标。 - 真正的 HashMap 更进一步:容量恒为 2 的幂,于是用位与代替取模; - 取低位之前还会用{" "} + 可能为负,而 Java 里负数取模仍是负数,会得到非法下标。真正的 HashMap 更进一步:容量恒为 2 的幂,于是用位与代替取模;取低位之前还会用{" "} h ^ (h >>> 16){" "} 把高位混进低位,避免只在高位不同的 key 全挤进同一个桶。 @@ -1510,10 +1456,8 @@ public class MyHashMap { ), zh: ( <> - 这是教学版。CPython 真正的 dict 用开放寻址而非链地址, - 并且把条目按插入顺序存在另一个紧凑数组里。另外, - str 的 hash() 默认按进程加随机盐 - (防 HashDoS),所以 hash("abc"){" "} + 这是教学版。CPython 真正的 dict 用开放寻址而非链地址,并且把条目按插入顺序存在另一个紧凑数组里。另外, + str 的 hash() 默认按进程加随机盐(防 HashDoS),所以 hash("abc"){" "} 的具体数值每次运行都不同,绝不能依赖它。 ), @@ -1630,12 +1574,8 @@ public class MyHashMap { ), zh: ( <> - 这个简化版放弃了什么:它对 String(key) 计算哈希, - 而任何对象转成字符串都是 "[object Object]", - 于是所有对象 key 都落进同一个桶,在它们之间查找会退化成 O(n)。 - (比较 key 时仍用 ===,所以两个不同的对象依然是两个 key。) - 真正的 Map{" "}按身份对对象做哈希,任何值都能当 key, - 遍历还保持插入顺序。 + 这个简化版放弃了什么:它对 String(key) 计算哈希,而任何对象转成字符串都是 "[object Object]",于是所有对象 key 都落进同一个桶,在它们之间查找会退化成 O(n)。(比较 key 时仍用 ===,所以两个不同的对象依然是两个 key。) + 真正的 Map{" "}按身份对对象做哈希,任何值都能当 key,遍历还保持插入顺序。 ), }, @@ -1661,9 +1601,7 @@ public class MyHashMap { zh={

「put 的流程是什么?」「扩容时发生了什么?」「为什么 get - 平均 O(1)?」—— 每个答案都是上面的某一行,你可以指着它说。 - 没写进来的部分(树化、线程安全、ConcurrentHashMap) - 都是在这套骨架上加东西。 + 平均 O(1)?」—— 每个答案都是上面的某一行,你可以指着它说。没写进来的部分(树化、线程安全、ConcurrentHashMap)都是在这套骨架上加东西。

} /> @@ -1699,8 +1637,7 @@ public class MyHashMap {

每种语言都内置了工业级的哈希表,而且都是两副面孔: Map(字典)存键值对,Set(集合) - 只存 key —— 它就是不带 value 的同一张哈希表, - 所以「在不在」查得和查字典一样快。API 大同小异,陷阱各有不同: + 只存 key —— 它就是不带 value 的同一张哈希表,所以「在不在」查得和查字典一样快。API 大同小异,陷阱各有不同:

} /> @@ -1773,11 +1710,9 @@ Map big = new HashMap<>(10000);`, <> 契约:用自定义类当 key 时, 重写了 equals 就必须重写 hashCode。HashMap 先用 hashCode - 找桶,再在桶内用 equals 比对;两个「相等」的对象若哈希不同, - 会落进不同的桶,equals 根本没机会执行。由此还有第二条: + 找桶,再在桶内用 equals 比对;两个「相等」的对象若哈希不同,会落进不同的桶,equals 根本没机会执行。由此还有第二条: 放进表之后不要再修改 key —— - 条目还待在旧哈希选中的桶里,再也找不到了。HashMap 不保序: - 要插入序用 LinkedHashMap,要排序用 TreeMap。 + 条目还待在旧哈希选中的桶里,再也找不到了。HashMap 不保序:要插入序用 LinkedHashMap,要排序用 TreeMap。 ), }, @@ -1854,10 +1789,8 @@ ok = {(1, 2): "a tuple can be a key"} <> 易错点:可变对象(list / dict /{" "} set)没有 __hash__,不能当 key —— - 内容一变哈希就变,条目会失联。tuple 不可变所以可以, - 前提是它里面装的也全部可哈希。另外,d[k] 与{" "} - d.get(k) 的区别(抛 KeyError 还是返回 None) - 几乎每个新手都会踩一次。 + 内容一变哈希就变,条目会失联。tuple 不可变所以可以,前提是它里面装的也全部可哈希。另外,d[k] 与{" "} + d.get(k) 的区别(抛 KeyError 还是返回 None)几乎每个新手都会踩一次。 ), }, @@ -1925,10 +1858,7 @@ obj["1"]; // "a" —— key 被转成字符串,1 和 "1" 是同一个 ), zh: ( <> - 易错点:Object 的 key 只能是 string 或 symbol, - 数字和对象都会先被转成字符串;它还会继承原型上的 key, - 而用户输入的 __proto__ 当 key 是真实存在的安全问题。 - 遍历顺序也有意外:「长得像数组下标」的 key + 易错点:Object 的 key 只能是 string 或 symbol,数字和对象都会先被转成字符串;它还会继承原型上的 key,而用户输入的 __proto__ 当 key 是真实存在的安全问题。遍历顺序也有意外:「长得像数组下标」的 key 会排在最前面,并按数值升序。要字典就用 Map /{" "} Set;确实需要一个纯净对象时,用{" "} Object.create(null)。 @@ -2146,8 +2076,7 @@ obj["1"]; // "a" —— key 被转成字符串,1 和 "1" 是同一个 的注解干脆叫 @EqualsAndHashCode,Java 的 record 两个都自动生成。整个生态都在执行同一条规则: equals 相等的对象,hashCode 必须相等。HashMap - 的查找分两步:先用 hashCode 找桶,再用 equals 在桶内确认。 - 规则一破,第一步就走进了错误的桶。 + 的查找分两步:先用 hashCode 找桶,再用 equals 在桶内确认。规则一破,第一步就走进了错误的桶。

} /> @@ -2214,8 +2143,7 @@ obj["1"]; // "a" —— key 被转成字符串,1 和 "1" 是同一个 } zh={

- 判重、判环、求交集 —— 只关心某个值在不在, - 不关心它带着什么信息。→ LC 217、202、349、128。 + 判重、判环、求交集 —— 只关心某个值在不在,不关心它带着什么信息。→ LC 217、202、349、128。

} /> @@ -2265,8 +2193,7 @@ obj["1"]; // "a" —— key 被转成字符串,1 和 "1" 是同一个 } zh={

- 把「本质相同」的东西归到同一个 key 下:设计一个签名函数, - 让同类必定得到同一个 key。→ LC 49、383、299。 + 把「本质相同」的东西归到同一个 key 下:设计一个签名函数,让同类必定得到同一个 key。→ LC 49、383、299。

} /> @@ -2308,8 +2235,7 @@ obj["1"]; // "a" —— key 被转成字符串,1 和 "1" 是同一个 在数组中找两个数,和等于 target,返回它们的下标。暴力: 双层循环枚举所有数对,O(n²)。改进的来源: 看看内层循环在干什么 —— 对每个 i,它在 i - 前面的部分里找 target − nums[i],这是一次线性查找。 - 把「已经走过的数」存进哈希表,这次查找就变成一次 O(1) 的查表: + 前面的部分里找 target − nums[i],这是一次线性查找。把「已经走过的数」存进哈希表,这次查找就变成一次 O(1) 的查表:

} /> @@ -2423,10 +2349,8 @@ obj["1"]; // "a" —— key 被转成字符串,1 和 "1" 是同一个 } zh={

- 时间 O(n)、空间 O(n) —— 典型的用空间换时间。 - 追问一:为什么先查再存?为了防止自己和自己配对 —— - target = 8、nums[i] = 4 时,先存后查会让 4 匹配到自己那条记录。 - 追问二:数组有序呢?那就用对撞双指针,O(n) 时间、O(1) 空间(LC + 时间 O(n)、空间 O(n) —— 典型的用空间换时间。追问一:为什么先查再存?为了防止自己和自己配对 —— + target = 8、nums[i] = 4 时,先存后查会让 4 匹配到自己那条记录。追问二:数组有序呢?那就用对撞双指针,O(n) 时间、O(1) 空间(LC 167,数组章讲过)。有序想双指针,无序想哈希表。

} @@ -2465,8 +2389,7 @@ obj["1"]; // "a" —— key 被转成字符串,1 和 "1" 是同一个 题意:把字母相同、顺序不同的词分到同一组。暴力: 两两比较是否互为异位词,O(n² · k)。改进的来源: 与其让词互相比较,不如给每个词一个签名 —— - 一个「异位词相同、其他一律不同」的值。 - 然后一张 Map<签名, 列表> 就会自己把组分好: + 一个「异位词相同、其他一律不同」的值。然后一张 Map<签名, 列表> 就会自己把组分好:

} /> @@ -2586,8 +2509,7 @@ class Solution:

排序签名:每个词 O(k log k),写起来最短。 计数签名:数出 26 个字母各出现几次,拼成 - "a1e1t1" 这样的字符串,每个词 O(k), - 词很长时更划算。两者遵循同一条规则: + "a1e1t1" 这样的字符串,每个词 O(k),词很长时更划算。两者遵循同一条规则: 签名必须恰好刻画「同组」的那个本质。同样的思路会在 LC 249(移位字符串分组)和 LC 205(同构字符串)里再次出现。

@@ -2635,11 +2557,7 @@ class Solution: 题意:在无序数组中,求最长的数值连续 序列的长度,元素位置无所谓,而且要求 O(n)。第一反应: 排序后扫一遍 —— 但排序是 O(n log n),题目不允许。 - 改进的来源:把所有值丢进 Set,「x + 1 在不在」就变成 O(1), - 于是可以从某个值往右走,数出一条序列的长度。 - 但对每个值都这么做仍然是 O(n²):序列 1..100 里, - 从 1 数一遍、从 2 数一遍、从 3 再数一遍。 - 加一条规则就解决了:只从序列的起点开始数 —— + 改进的来源:把所有值丢进 Set,「x + 1 在不在」就变成 O(1),于是可以从某个值往右走,数出一条序列的长度。但对每个值都这么做仍然是 O(n²):序列 1..100 里,从 1 数一遍、从 2 数一遍、从 3 再数一遍。加一条规则就解决了:只从序列的起点开始数 —— 起点就是「x − 1 不在 Set 里」的那个 x:

} @@ -2764,13 +2682,9 @@ class Solution: } zh={

- 这里确实有嵌套的 while,但它只从序列的起点启动, - 而每条序列只有一个起点。把所有 while + 这里确实有嵌套的 while,但它只从序列的起点启动,而每条序列只有一个起点。把所有 while 的迭代加起来,每个值只被「数过」一次。外层 n - 次起点检查,加上内层总共至多 n 步,合起来 O(n)。 - 这和数组章滑动窗口的 2n 论证是同一套均摊推理。 - 追问:能不能遍历 nums 而不是 set?可以, - 但重复元素会白做几次起点检查 —— 遍历 set 更干净。 + 次起点检查,加上内层总共至多 n 步,合起来 O(n)。这和数组章滑动窗口的 2n 论证是同一套均摊推理。追问:能不能遍历 nums 而不是 set?可以,但重复元素会白做几次起点检查 —— 遍历 set 更干净。

} /> @@ -2829,8 +2743,7 @@ class Solution: ), zh: ( <> - 哈希表 = 数组 + 哈希函数 + 冲突处理方案。 - 哈希函数把 key 算成下标,于是数组只给整数下标的 O(1) + 哈希表 = 数组 + 哈希函数 + 冲突处理方案。哈希函数把 key 算成下标,于是数组只给整数下标的 O(1) 直达,任何 key 都能用上。 ), @@ -2848,10 +2761,7 @@ class Solution: ), zh: ( <> - 哈希函数必须确定、均匀、快。可能的 key 比桶多, - 冲突必然发生。链地址法给每个桶挂一条链(Java); - 开放寻址则去探测别的空位(Python), - 它在高负载因子下代价更大,删除还得用墓碑。 + 哈希函数必须确定、均匀、快。可能的 key 比桶多,冲突必然发生。链地址法给每个桶挂一条链(Java);开放寻址则去探测别的空位(Python),它在高负载因子下代价更大,删除还得用墓碑。 ), }, @@ -2870,10 +2780,7 @@ class Solution: zh: ( <> 负载因子越线(Java 默认 0.75)后,表会申请更大的数组并 - 把每个 key 重新安放一次 —— 因为下标依赖桶数。 - 所以某一次插入可能花 O(n),插入的严谨说法是均摊 O(1)。 - 完整表述:哈希均匀且负载因子受控时平均 O(1), - 大量 key 挤进同一个桶时最坏 O(n)。 + 把每个 key 重新安放一次 —— 因为下标依赖桶数。所以某一次插入可能花 O(n),插入的严谨说法是均摊 O(1)。完整表述:哈希均匀且负载因子受控时平均 O(1),大量 key 挤进同一个桶时最坏 O(n)。 ), }, @@ -2891,10 +2798,8 @@ class Solution: ), zh: ( <> - 每种语言各有一个陷阱。Java:重写 equals 就要重写 hashCode, - 而且 key 放进表后不要再改。Python:可变对象不能当 key(list - 不行,元素全可哈希的 tuple 可以),3.7 起 dict 保持插入序。 - JavaScript:用 Map,别用普通 Object —— Object 的 key + 每种语言各有一个陷阱。Java:重写 equals 就要重写 hashCode,而且 key 放进表后不要再改。Python:可变对象不能当 key(list + 不行,元素全可哈希的 tuple 可以),3.7 起 dict 保持插入序。JavaScript:用 Map,别用普通 Object —— Object 的 key 会被转成字符串,原型还会额外贡献 key。 ), @@ -2912,9 +2817,7 @@ class Solution: zh: ( <> 三个解题信号:「见过吗」→ Set;「配对」→ Map - 存「值 → 下标 / 次数」;「分组计数」→ Map<签名, 列表>。 - Set 就是只存 key 的哈希表。前缀和 + 哈希(LC 560) - 就是把「两数之和」用在前缀和上。 + 存「值 → 下标 / 次数」;「分组计数」→ Map<签名, 列表>。Set 就是只存 key 的哈希表。前缀和 + 哈希(LC 560)就是把「两数之和」用在前缀和上。 ), }, diff --git a/app/hash/viz.tsx b/app/hash/viz.tsx index 8d9a9ea..f19be85 100644 --- a/app/hash/viz.tsx +++ b/app/hash/viz.tsx @@ -109,8 +109,7 @@ export function HashLab() { } zh={ <> - 读入字符 '{s.ch}'(编码 {s.code}):旧 h 乘 31,再加上它。 - 每个字符都会改变结果,位置也会 —— 越靠前的字符被乘 31 的次数越多。 + 读入字符 '{s.ch}'(编码 {s.code}):旧 h 乘 31,再加上它。每个字符都会改变结果,位置也会 —— 越靠前的字符被乘 31 的次数越多。 } />, @@ -168,8 +167,7 @@ export function HashLab() { zh={ <> 冲突(collision)。「{w}」和「{occupants.join("、")} - 」被分进了同一个 {t} 号桶 —— 不同的 key、相同的下标。 - 接下来怎么办,就是 §03 的主题。 + 」被分进了同一个 {t} 号桶 —— 不同的 key、相同的下标。接下来怎么办,就是 §03 的主题。 } /> @@ -185,8 +183,7 @@ export function HashLab() { } zh={ <> - 「{w}」落进 {t} 号桶。以后查它:算出同样的哈希、同样的取模, - 一步跳到 {t} 号桶,不需要逐个扫描 —— 这就是查得快的全部原因。 + 「{w}」落进 {t} 号桶。以后查它:算出同样的哈希、同样的取模,一步跳到 {t} 号桶,不需要逐个扫描 —— 这就是查得快的全部原因。 } /> @@ -349,8 +346,7 @@ function buildChainFrames(): ChainFrame[] { } zh={ <> - 6 个单词依次插入 8 个桶。链地址法:每个桶不是只放一个元素, - 而是挂一条链表 —— 撞了就接到链上。 + 6 个单词依次插入 8 个桶。链地址法:每个桶不是只放一个元素,而是挂一条链表 —— 撞了就接到链上。 } /> @@ -374,9 +370,7 @@ function buildChainFrames(): ChainFrame[] { } zh={ <> - 「{w}」也被哈希到 {b} 号桶 —— 冲突发生。处理方式: - 接到该桶链表的尾部。代价:以后查这个桶里的 key, - 要沿链逐个比对。 + 「{w}」也被哈希到 {b} 号桶 —— 冲突发生。处理方式:接到该桶链表的尾部。代价:以后查这个桶里的 key,要沿链逐个比对。 } /> @@ -414,10 +408,8 @@ function buildChainFrames(): ChainFrame[] { } zh={ <> - 插入完毕:最长的链只有 2 个节点,查任何 key 最多比对 2 次。 - 只要哈希分布均匀、负载因子受控,链长的平均值就是常数 —— - 这正是「平均 O(1)」的依据,也说明了它为什么只是平均值: - 把这 6 个词全塞进同一个桶,同样一次查找就要比对 6 次。 + 插入完毕:最长的链只有 2 个节点,查任何 key 最多比对 2 次。只要哈希分布均匀、负载因子受控,链长的平均值就是常数 —— + 这正是「平均 O(1)」的依据,也说明了它为什么只是平均值:把这 6 个词全塞进同一个桶,同样一次查找就要比对 6 次。 } /> @@ -452,8 +444,7 @@ function buildProbeFrames(): ProbeFrame[] { } zh={ <> - 同一批单词,这次用开放寻址 · 线性探测:每个格子只放一个元素, - 撞了就往右找下一个空位,到头绕回 0。 + 同一批单词,这次用开放寻址 · 线性探测:每个格子只放一个元素,撞了就往右找下一个空位,到头绕回 0。 } /> @@ -528,10 +519,8 @@ function buildProbeFrames(): ProbeFrame[] { } zh={ <> - 4~7 号已经连成一片,而且绕过末尾一直接到 0、1 号。 - 一次冲突把元素挤到旁边,让下一次冲突更容易发生,连片就越来越长 —— - 这叫聚集(clustering)。所以开放寻址比链地址法更怕负载因子, - 经典线性探测通常压到 0.5~0.7 就要扩容,而不是 0.75(SwissTable 等现代实现分组探测,可到 0.875,Rust 的 HashMap 就是一例)。 + 4~7 号已经连成一片,而且绕过末尾一直接到 0、1 号。一次冲突把元素挤到旁边,让下一次冲突更容易发生,连片就越来越长 —— + 这叫聚集(clustering)。所以开放寻址比链地址法更怕负载因子,经典线性探测通常压到 0.5~0.7 就要扩容,而不是 0.75(SwissTable 等现代实现分组探测,可到 0.875,Rust 的 HashMap 就是一例)。 } /> diff --git a/app/heap/page.tsx b/app/heap/page.tsx index d60beb1..9809171 100644 --- a/app/heap/page.tsx +++ b/app/heap/page.tsx @@ -54,8 +54,7 @@ const F215: ArrayFrame[] = [ ), zh: ( <> - 维护一个最多装 2 个元素的小根堆,让它始终装着「迄今最大的 2 个」。 - 第一个数是 3:堆没满,直接进。此刻门槛(堆顶,也就是堆里最小的)是 3。 + 维护一个最多装 2 个元素的小根堆,让它始终装着「迄今最大的 2 个」。第一个数是 3:堆没满,直接进。此刻门槛(堆顶,也就是堆里最小的)是 3。 ), }, @@ -93,8 +92,7 @@ const F215: ArrayFrame[] = [ ), zh: ( <> - 下一个数 1(右边这个标记格是待检验的新数,不在堆里)。 - 和门槛比较:1 < 2,连堆里最弱的都比不过,直接丢弃,堆不动。 + 下一个数 1(右边这个标记格是待检验的新数,不在堆里)。和门槛比较:1 < 2,连堆里最弱的都比不过,直接丢弃,堆不动。 ), }, @@ -132,8 +130,7 @@ const F215: ArrayFrame[] = [ ), zh: ( <> - 淘汰 2、修复堆序之后,堆里是 {"{3, 5}"},门槛升到 3。 - 门槛只会越来越高 —— 这正是我们想要的:每替换一次,幸存者就更强一点。 + 淘汰 2、修复堆序之后,堆里是 {"{3, 5}"},门槛升到 3。门槛只会越来越高 —— 这正是我们想要的:每替换一次,幸存者就更强一点。 ), }, @@ -150,8 +147,7 @@ const F215: ArrayFrame[] = [ ), zh: ( <> - 下一个数 6:6 > 3,进,挤掉 3。堆里是 {"{5, 6}"}, - 门槛升到 5。 + 下一个数 6:6 > 3,进,挤掉 3。堆里是 {"{5, 6}"},门槛升到 5。 ), }, @@ -186,9 +182,7 @@ const F215: ArrayFrame[] = [ ), zh: ( <> - 扫完了。堆里是 {"{5, 6}"},正是最大的 2 个,堆顶 5 就是第 2 大的元素。 - 全程 O(n log k) 时间、O(k) 空间。n 很大而 k 很小时, - 这比全排序的 O(n log n) 少做很多工作。 + 扫完了。堆里是 {"{5, 6}"},正是最大的 2 个,堆顶 5 就是第 2 大的元素。全程 O(n log k) 时间、O(k) 空间。n 很大而 k 很小时,这比全排序的 O(n log n) 少做很多工作。 ), }, @@ -215,8 +209,7 @@ const F347: ArrayFrame[] = [ zh: ( <> 第一步 哈希计数:数字 1 出现 3 次,2 出现 2 次, - 3 出现 1 次。第二步把这些频次喂给一个最多装 2 个的小根堆。 - 先放数字 1 的频次 3。 + 3 出现 1 次。第二步把这些频次喂给一个最多装 2 个的小根堆。先放数字 1 的频次 3。 ), }, @@ -235,9 +228,7 @@ const F347: ArrayFrame[] = [ ), zh: ( <> - 数字 2 的频次是 2,入堆并上浮到顶。堆满了, - 门槛就是仍在堆里的最低频次 2。 - 注意格子里画的是频次,不是数字本身。 + 数字 2 的频次是 2,入堆并上浮到顶。堆满了,门槛就是仍在堆里的最低频次 2。注意格子里画的是频次,不是数字本身。 ), }, @@ -277,8 +268,7 @@ const F347: ArrayFrame[] = [ zh: ( <> 堆里剩下频次 {"{2, 3}"},分别属于数字 2 和 1 —— - 就是前 2 高频的元素。计数 O(n),维护堆 O(n log k),合计 O(n log k)。 - 这是你的第一道「两种结构组合」的题:哈希表 + 堆。 + 就是前 2 高频的元素。计数 O(n),维护堆 O(n log k),合计 O(n log k)。这是你的第一道「两种结构组合」的题:哈希表 + 堆。 ), }, @@ -302,8 +292,7 @@ const F23: ArrayFrame[] = [ ), zh: ( <> - 三条升序链表:1→4→5、1→3→4、2→6。把三个头结点放进小根堆, - 得到 {"{1, 1, 2}"}。堆顶是三条链里最小的值,也就是合并结果的下一个数。 + 三条升序链表:1→4→5、1→3→4、2→6。把三个头结点放进小根堆,得到 {"{1, 1, 2}"}。堆顶是三条链里最小的值,也就是合并结果的下一个数。 ), }, @@ -345,8 +334,7 @@ const F23: ArrayFrame[] = [ ), zh: ( <> - 弹出 1(来自链 2),补进它的下一个结点 3。 - 堆里是 {"{2, 3, 4}"}。 + 弹出 1(来自链 2),补进它的下一个结点 3。堆里是 {"{2, 3, 4}"}。
输出:1 1 @@ -411,8 +399,7 @@ const F23: ArrayFrame[] = [ ), zh: ( <> - 接连弹出两个 4。链 1 的 4 后面还有 5,补进堆; - 链 2 的 4 是那条链的最后一个结点,没有东西可补,于是堆缩到 {"{5, 6}"}。 + 接连弹出两个 4。链 1 的 4 后面还有 5,补进堆;链 2 的 4 是那条链的最后一个结点,没有东西可补,于是堆缩到 {"{5, 6}"}。
输出:1 1 2 3 4 4 @@ -441,8 +428,7 @@ const F23: ArrayFrame[] = [
输出:1 1 2 3 4 4 5 6
- 总共 N 个结点,每个结点进堆、出堆各一次,每次堆操作 O(log k), - 所以是 O(N log k) 时间、O(k) 空间 —— 堆里最多只有 k 个结点。 + 总共 N 个结点,每个结点进堆、出堆各一次,每次堆操作 O(log k),所以是 O(N log k) 时间、O(k) 空间 —— 堆里最多只有 k 个结点。 ), }, @@ -496,10 +482,7 @@ export default function HeapChapter() { ), zh: ( <> - 一个永远先把最要紧的元素交给你的容器。它从不做全排序, - 只承诺一件事:堆顶是当前的最小值(或最大值); - 取走它、塞进新值,都只要 O(log n)。 - 承诺得少,所以跑得快。 + 一个永远先把最要紧的元素交给你的容器。它从不做全排序,只承诺一件事:堆顶是当前的最小值(或最大值);取走它、塞进新值,都只要 O(log n)。承诺得少,所以跑得快。 ), }} @@ -573,37 +556,27 @@ export default function HeapChapter() { zh={ <>

- 食堂窗口按到达顺序服务,那是上一章的队列。 - 急诊室不能这么干:五分钟前才到的心梗病人,要排在已经等了一小时的 - 崴脚病人前面。这套流程叫分诊(triage): - 每空出一间诊室,就叫当前最危重的病人进去。 - 没有人去算谁排第二、谁排第三 —— 反正下一次还得重新挑一遍。 + 食堂窗口按到达顺序服务,那是上一章的队列。急诊室不能这么干:五分钟前才到的心梗病人,要排在已经等了一小时的崴脚病人前面。这套流程叫分诊(triage):每空出一间诊室,就叫当前最危重的病人进去。没有人去算谁排第二、谁排第三 —— 反正下一次还得重新挑一遍。

这就是堆(heap)的全部工作:在一堆不断变化的数据里, O(1) 报出最小值(或最大值), - O(log n) 取走它或插入新值。 - 「不断变化」这四个字是关键:数据不是一次给全的,就像病人一直在来。 + O(log n) 取走它或插入新值。「不断变化」这四个字是关键:数据不是一次给全的,就像病人一直在来。

- 那为什么不干脆排个序,让最危重的站第一个? - 可以,但排序做的事远超题目所需:O(n log n) 把 + 那为什么不干脆排个序,让最危重的站第一个?可以,但排序做的事远超题目所需:O(n log n) 把 全部 n 个元素的先后都定死,而你只要 - 其中一个。更麻烦的是更新: - 往有序数组里插一个新病人,要搬移元素,O(n) + 其中一个。更麻烦的是更新:往有序数组里插一个新病人,要搬移元素,O(n) —— 这是第 1 章算过的账。堆的取舍正好相反: - 只维护「谁是最值」这一件事, - 所以插入和删除都是 O(log n)。 + 只维护「谁是最值」这一件事,所以插入和删除都是 O(log n)。

有两个词经常被混用,这里先分清。 优先队列(priority queue)是一个 接口:一个能不断放入、又能随时取出优先级最高者的容器; 二叉堆(binary heap)是这个接口最常见的 - 实现。二者的关系就像「排序」和「快速排序」。 - 几乎所有语言的 PriorityQueue / heapq{" "} - 底层都是二叉堆,本章造的正是这台引擎。 - 它们不是同义词,所以本章说「堆」指结构,说「优先队列」指接口。 + 实现。二者的关系就像「排序」和「快速排序」。几乎所有语言的 PriorityQueue / heapq{" "} + 底层都是二叉堆,本章造的正是这台引擎。它们不是同义词,所以本章说「堆」指结构,说「优先队列」指接口。

} @@ -628,8 +601,7 @@ export default function HeapChapter() { } zh={

- 堆只保证一件事:堆顶是全堆最小(或最大)的元素。 - 其余元素之间的顺序,包括同一层的两个结点, + 堆只保证一件事:堆顶是全堆最小(或最大)的元素。其余元素之间的顺序,包括同一层的两个结点, 没有任何规定。承诺越少,维护越省。

} @@ -654,8 +626,7 @@ export default function HeapChapter() { zh={

插入一个值、取走当前最值,都只走 - 一条与树高等长的路径:O(log n)。 - 只看堆顶不取走则是 O(1)。正因如此,堆能扛住源源不断的数据流。 + 一条与树高等长的路径:O(log n)。只看堆顶不取走则是 O(1)。正因如此,堆能扛住源源不断的数据流。

} /> @@ -680,8 +651,7 @@ export default function HeapChapter() { } zh={

- 任务调度、Dijkstra 最短路、Huffman 编码、Top-K、合并 K 路有序序列…… - 一切「反复取当前最值」的问题,底下几乎都是堆在转。 + 任务调度、Dijkstra 最短路、Huffman 编码、Top-K、合并 K 路有序序列……一切「反复取当前最值」的问题,底下几乎都是堆在转。

} /> @@ -705,8 +675,7 @@ export default function HeapChapter() { zh={

一个常见的混淆:本章的「堆」和堆内存(heap memory) - 毫无关系,只是重名。堆内存是程序运行时动态分配对象的那片区域, - 与调用栈相对。本章的堆是一种数据结构 —— + 毫无关系,只是重名。堆内存是程序运行时动态分配对象的那片区域,与调用栈相对。本章的堆是一种数据结构 —— 一棵有特定形状的树。看到这个词,先分清指的是哪一个。

} @@ -738,8 +707,7 @@ export default function HeapChapter() { } zh={

- 堆是一棵二叉树,但只守两条规矩: - 一条管形状,一条管顺序。 + 堆是一棵二叉树,但只守两条规矩:一条管形状,一条管顺序。

} /> @@ -766,9 +734,7 @@ export default function HeapChapter() { } zh={

- 完全二叉树(complete binary tree):除最后一层外, - 每层都填满;最后一层的结点全部靠左连续排列,中间不留空位 - (第 7 章见过)。这条规矩让树又矮又宽: + 完全二叉树(complete binary tree):除最后一层外,每层都填满;最后一层的结点全部靠左连续排列,中间不留空位(第 7 章见过)。这条规矩让树又矮又宽: n 个结点的高度恰好是 ⌊log₂n⌋。它同时保证没有位置被跳过 —— 这正是下面「塞进数组」的前提。

@@ -796,10 +762,7 @@ export default function HeapChapter() { } zh={

- 每个父结点 ≤ 它的每个孩子。沿着每条路径层层递推, - 根就成了全树最小值 —— 这就是小根堆(min-heap)。 - 这条规矩只约束父子这一对,兄弟之间没有任何关系: - 左孩子完全可以比右孩子大。父 ≥ 子则是 + 每个父结点 ≤ 它的每个孩子。沿着每条路径层层递推,根就成了全树最小值 —— 这就是小根堆(min-heap)。这条规矩只约束父子这一对,兄弟之间没有任何关系:左孩子完全可以比右孩子大。父 ≥ 子则是 大根堆(max-heap),堆顶是最大值,两者完全对称。

} @@ -833,14 +796,9 @@ export default function HeapChapter() { } zh={

- 很多人以为堆就是「从小到大排好的树」。不是。 - 堆只保证每一对父 → 子是有序的, - 同一层的两个结点(兄弟或堂兄弟)之间没有任何规定。 - 所以把底层数组打印出来,常常是{" "} - [1, 3, 2, 7, 4, 5] 这样:看着不像升序, - 但它是合法的堆 —— 1≤3、1≤2、3≤7、3≤4、2≤5,每一对父子都成立。 - 正因为堆一次只理一条链而不是整批数据, - 单次更新才是 O(log n),而不是全排序的 O(n log n)。 + 很多人以为堆就是「从小到大排好的树」。不是。堆只保证每一对父 → 子是有序的, + 同一层的两个结点(兄弟或堂兄弟)之间没有任何规定。所以把底层数组打印出来,常常是{" "} + [1, 3, 2, 7, 4, 5] 这样:看着不像升序,但它是合法的堆 —— 1≤3、1≤2、3≤7、3≤4、2≤5,每一对父子都成立。正因为堆一次只理一条链而不是整批数据,单次更新才是 O(log n),而不是全排序的 O(n log n)。

} /> @@ -863,12 +821,8 @@ export default function HeapChapter() { } zh={

- 现在两条规矩接上了。规矩 ① 说完全二叉树没有空洞, - 于是可以把结点按层、从左到右编号 - (根是 0,下一层是 1、2,再下一层是 3、4、5、6……), - 然后直接铺进一个数组,编号就是下标。 - 因为没有空洞,数组不会浪费任何一格; - 也不需要指针 —— 每一条父子连线都由三条公式 + 现在两条规矩接上了。规矩 ① 说完全二叉树没有空洞,于是可以把结点按层、从左到右编号 + (根是 0,下一层是 1、2,再下一层是 3、4、5、6……),然后直接铺进一个数组,编号就是下标。因为没有空洞,数组不会浪费任何一格;也不需要指针 —— 每一条父子连线都由三条公式 算出来:

} @@ -960,11 +914,8 @@ export default function HeapChapter() {

这个思路你见过。数组那一章里,元素地址 ={" "} 首地址 + 下标 × 元素大小,同样是 - 算出来的,不是存下来的。 - 堆把这套下标算术从「数组元素」搬到了「树的父子关系」上。 - 省掉指针换来两个实在的好处:不占额外内存 - (链式二叉树每个结点要存两根指针), - 以及元素在内存里连续排列、对 CPU 缓存更友好 + 算出来的,不是存下来的。堆把这套下标算术从「数组元素」搬到了「树的父子关系」上。省掉指针换来两个实在的好处:不占额外内存 + (链式二叉树每个结点要存两根指针),以及元素在内存里连续排列、对 CPU 缓存更友好 (还是第 1 章的那份红利)。

} @@ -994,10 +945,8 @@ export default function HeapChapter() {

有些教科书让堆从下标 1 开始,公式会短一点:父 ={" "} i / 2、左孩子 = 2i、右孩子 ={" "} - 2i + 1。代价是下标 0 空着不用。 - 实际实现(Java 的 PriorityQueue、Python 的{" "} - heapq)都从 0 开始,不浪费空间, - 所以本章统一用 0 起步的公式。两套约定都对,别在同一份代码里混用。 + 2i + 1。代价是下标 0 空着不用。实际实现(Java 的 PriorityQueue、Python 的{" "} + heapq)都从 0 开始,不浪费空间,所以本章统一用 0 起步的公式。两套约定都对,别在同一份代码里混用。

} /> @@ -1045,9 +994,7 @@ export default function HeapChapter() {

堆只有两个动作,而且互为镜像:插入时新元素从底往上找位置,叫 上浮(sift up);删除时元素从顶往下找位置,叫 - 下沉(sift down)。 - 在下面的实验室里 push / pop 几次, - 盯着树视图和数组视图一起变化 —— + 下沉(sift down)。在下面的实验室里 push / pop 几次,盯着树视图和数组视图一起变化 —— 它们画的是同一份数据。你会发现所谓堆操作,不过是 比较一对父子、不合规就交换,一路重复到不用再换为止。

@@ -1119,13 +1066,10 @@ export default function HeapChapter() {

  1. - ① 写到数组尾部,也就是完全二叉树的下一个空位。 - 这一步保证形状规矩不被破坏。 + ① 写到数组尾部,也就是完全二叉树的下一个空位。这一步保证形状规矩不被破坏。
  2. - ② 上浮:新值和父结点比较,比父结点小就交换, - 换上去再和新的父结点比,直到「不比父结点小」或「已经到根」为止。 - 最长的路径是从最底层走到根,所以交换次数不超过树高: + ② 上浮:新值和父结点比较,比父结点小就交换,换上去再和新的父结点比,直到「不比父结点小」或「已经到根」为止。最长的路径是从最底层走到根,所以交换次数不超过树高: O(log n)。
@@ -1137,21 +1081,15 @@ export default function HeapChapter() { ① 读出堆顶。它就是答案 —— 小根堆里的最小值。
  • - ② 把最后一个元素搬到堆顶。为什么是最后一个? - 因为删掉末尾不破坏完全二叉树的形状,删掉别的位置会留下一个空洞。 + ② 把最后一个元素搬到堆顶。为什么是最后一个?因为删掉末尾不破坏完全二叉树的形状,删掉别的位置会留下一个空洞。
  • - ③ 下沉。刚补到顶上的这个值多半偏大: - 让它和两个孩子里较小的那个比较,比孩子大就交换、跟着下去, - 直到「比两个孩子都小」或「没有孩子」为止。 - 同样不超过树高:O(log n)。 + ③ 下沉。刚补到顶上的这个值多半偏大:让它和两个孩子里较小的那个比较,比孩子大就交换、跟着下去,直到「比两个孩子都小」或「没有孩子」为止。同样不超过树高:O(log n)。
  • 下沉时为什么必须挑较小的孩子? - 假设你换了较大的那个孩子:它升上去当了父结点, - 而更小的兄弟还在它下面,「父 ≤ 子」当场被破坏。 - 只有把较小的孩子提上来,才能同时压住两个孩子。 + 假设你换了较大的那个孩子:它升上去当了父结点,而更小的兄弟还在它下面,「父 ≤ 子」当场被破坏。只有把较小的孩子提上来,才能同时压住两个孩子。

    } @@ -1291,10 +1229,7 @@ export default function HeapChapter() { zh={

    表里最反直觉的一行是{" "} - heapify:把乱序数组原地整理成堆,只要 O(n)。 - 直觉会说「n 个元素,每个可能下沉 log n 层,应该是 O(n log n)」。 - 点一下实验室里的「随机数组建堆」, - 数数它到底交换了几次 —— 通常远少于预期。原因如下: + heapify:把乱序数组原地整理成堆,只要 O(n)。直觉会说「n 个元素,每个可能下沉 log n 层,应该是 O(n log n)」。点一下实验室里的「随机数组建堆」,数数它到底交换了几次 —— 通常远少于预期。原因如下:

    } /> @@ -1335,17 +1270,10 @@ export default function HeapChapter() { zh={

    做法是 - 从最后一个父结点(下标 n/2 − 1)倒着走到下标 0,逐个下沉, - 这叫 Floyd 建堆。关键在于「谁能沉得远」:在完全二叉树里, - 大约一半的结点是叶子,位于最底层,下沉 0 步; - 上一层约 1/4 的结点最多沉 1 步;再上一层约 1/8 最多沉{" "} - 2 步…… 越能沉得深的结点,数量越稀少。 - 把总步数加起来是 Σ d · n / 2d+1,而 Σ d / 2 - d+1 = 1,所以总交换次数不超过 n,建堆是 O(n)。 - 逐个 push 恰好相反:它让每个新元素往根的方向爬, - 而爬得最远的正是数量最多的底层结点 —— 差的就是这一个 log。 - 记住这条:数据已经全在手上就用 heapify(O(n)); - 数据逐个到达就只能逐个 push。 + 从最后一个父结点(下标 n/2 − 1)倒着走到下标 0,逐个下沉,这叫 Floyd 建堆。关键在于「谁能沉得远」:在完全二叉树里, + 大约一半的结点是叶子,位于最底层,下沉 0 步;上一层约 1/4 的结点最多沉 1 步;再上一层约 1/8 最多沉{" "} + 2 步…… 越能沉得深的结点,数量越稀少。把总步数加起来是 Σ d · n / 2d+1,而 Σ d / 2 + d+1 = 1,所以总交换次数不超过 n,建堆是 O(n)。逐个 push 恰好相反:它让每个新元素往根的方向爬,而爬得最远的正是数量最多的底层结点 —— 差的就是这一个 log。记住这条:数据已经全在手上就用 heapify(O(n));数据逐个到达就只能逐个 push。

    } /> @@ -1377,16 +1305,8 @@ export default function HeapChapter() { } zh={

    - 堆擅长「反复取最值」,但有三件事它不擅长: - 第一,查找某个特定值要 O(n); - 第二,按顺序输出全部元素需要 pop n 次,合计 O(n log n) —— - 这个过程正是堆排序(heap sort), - 它在最好、平均、最坏情况下都是 O(n log n),额外空间 O(1), - 而且不稳定:相等的元素出堆顺序可能和入堆顺序不同; - 第三,堆只把堆顶交给你,所以要拿第 k 小的值, - 通常得先 pop 掉 k − 1 个。 - 如果你需要随时按序访问或做范围查询,请回到第 8 章的 BST / TreeMap。 - 选错结构是面试和工程里最贵的错误。 + 堆擅长「反复取最值」,但有三件事它不擅长:第一,查找某个特定值要 O(n);第二,按顺序输出全部元素需要 pop n 次,合计 O(n log n) —— + 这个过程正是堆排序(heap sort),它在最好、平均、最坏情况下都是 O(n log n),额外空间 O(1),而且不稳定:相等的元素出堆顺序可能和入堆顺序不同;第三,堆只把堆顶交给你,所以要拿第 k 小的值,通常得先 pop 掉 k − 1 个。如果你需要随时按序访问或做范围查询,请回到第 8 章的 BST / TreeMap。选错结构是面试和工程里最贵的错误。

    } /> @@ -1424,12 +1344,8 @@ export default function HeapChapter() { zh={

    把 §03 的动作写成代码。真正干活的只有三个方法: - siftUp(上浮)、siftDown(下沉), - 以及 O(n) 建堆的静态方法 heapify。 - push / pop 只是把「改数组」和 - 「修复堆序」拼在一起。底层用一个普通的可变数组存, - 父子关系全靠 §02 的三条公式算。 - 建议先盖住代码,自己默写一遍 siftDown —— + siftUp(上浮)、siftDown(下沉),以及 O(n) 建堆的静态方法 heapify。 + push / pop 只是把「改数组」和「修复堆序」拼在一起。底层用一个普通的可变数组存,父子关系全靠 §02 的三条公式算。建议先盖住代码,自己默写一遍 siftDown —— 「挑较小的孩子」那一步,是最多人写错的地方。

    } @@ -1569,9 +1485,7 @@ export default function HeapChapter() { ), zh: ( <> - 易错点:比较写的是 a[i] >= a[p], - 相等时不交换,可以避免相等元素之间无意义的来回交换。 - 想要大根堆,把两处比较的方向反过来即可,其余一行都不用改。 + 易错点:比较写的是 a[i] >= a[p],相等时不交换,可以避免相等元素之间无意义的来回交换。想要大根堆,把两处比较的方向反过来即可,其余一行都不用改。 ), }, @@ -1694,8 +1608,7 @@ export default function HeapChapter() { <> 实际项目里别手写:标准库的 heapq{" "} 就是同一套算法的 C 实现(heappush、 - heappop、heapify)。 - 这里手写只为看清内部;§05 教你怎么直接用 heapq。 + heappop、heapify)。这里手写只为看清内部;§05 教你怎么直接用 heapq。 ), }, @@ -1834,11 +1747,7 @@ export default function HeapChapter() { zh: ( <> 为什么这段值得背下来:JavaScript 没有 - 内置的堆或优先队列(§05 详述)。 - 面试和笔试里经常要当场手写一个, - 把这个类练到不用想就能写出来,Top-K 和 Dijkstra 才有把握。 - 可选参数 less 让同一个类也能比较对象, - 例如按频次或按结点值排序;§06 的精讲就用到了它。 + 内置的堆或优先队列(§05 详述)。面试和笔试里经常要当场手写一个,把这个类练到不用想就能写出来,Top-K 和 Dijkstra 才有把握。可选参数 less 让同一个类也能比较对象,例如按频次或按结点值排序;§06 的精讲就用到了它。 ), }, @@ -1911,16 +1820,12 @@ export default function HeapChapter() { } zh={

    - §04 是为了让你理解机制,真正做题时用语言自带的实现。 - 三种语言在这里差别很大:Java 有开箱即用的{" "} + §04 是为了让你理解机制,真正做题时用语言自带的实现。三种语言在这里差别很大:Java 有开箱即用的{" "} PriorityQueue;Python 有 heapq —— 一组直接操作普通 list 的函数; - JavaScript 没有内置的堆, - 这也是上一节要你把手写堆记熟的原因。 - 有两个共同的陷阱要记住: + JavaScript 没有内置的堆,这也是上一节要你把手写堆记熟的原因。有两个共同的陷阱要记住: Java 和 Python 默认都是小根堆,想要大根堆得动手; - 遍历堆不会得到有序结果, - 想按顺序读只能一个个取出来。 + 遍历堆不会得到有序结果,想按顺序读只能一个个取出来。

    } /> @@ -1987,9 +1892,7 @@ PriorityQueue h = new PriorityQueue<>(List.of(5, 1, 3));`, <> 两个陷阱。① 遍历(for (int x : pq) 或{" "} toString())走的是底层数组, - 顺序没有意义;想按升序读只能反复 poll()。 - ② 比较器别写成 x[0] - y[0]: - 两个 int 相减在一大一负时可能溢出,符号就反了 —— + 顺序没有意义;想按升序读只能反复 poll()。② 比较器别写成 x[0] - y[0]:两个 int 相减在一大一负时可能溢出,符号就反了 —— 应当使用 Integer.compare。 ), @@ -2066,10 +1969,8 @@ heapq.heappop(pq) # (3, 'apple')`, <> 易错点:元组比较如果比到某一项是 不可比较的类型(例如两个 freq 相等,接着要比自定义对象), - Python 会抛 TypeError。 - 惯用解法是在中间塞一个自增序号当决胜项,如{" "} - (freq, idx, obj) —— 序号永不重复, - 所以永远比不到对象本身。 + Python 会抛 TypeError。惯用解法是在中间塞一个自增序号当决胜项,如{" "} + (freq, idx, obj) —— 序号永不重复,所以永远比不到对象本身。 ), }, @@ -2137,14 +2038,10 @@ const q = new MinPriorityQueue((x) => x.dist); // dist 最小的先出`, ), zh: ( <> - 两个提醒。① 不要用 arr.sort() 代替堆: - 每次插入后重排是单次 O(n log n),而不是 O(log n), - 数据一大就会超时。② @datastructures-js/priority-queue{" "} + 两个提醒。① 不要用 arr.sort() 代替堆:每次插入后重排是单次 O(n log n),而不是 O(log n),数据一大就会超时。② @datastructures-js/priority-queue{" "} 的 API 在大版本之间变过:旧版本收{" "} {"{ priority: fn }"}、返回{" "} - {"{ element, priority }"} 包装对象, - 新版本直接收函数、直接返回元素本身。 - 既然无法确定评测机装的是哪个版本,自己手写堆更保险, + {"{ element, priority }"} 包装对象,新版本直接收函数、直接返回元素本身。既然无法确定评测机装的是哪个版本,自己手写堆更保险, §06 的精讲也都这样做。 ), @@ -2365,8 +2262,7 @@ const q = new MinPriorityQueue((x) => x.dist); // dist 最小的先出`, ② 反复取最值,取完还要塞回新值 (每一步贪心地用当前最值,再插入更新后的值 —— 任务调度、重构字符串、粉碎石头都是这一类); - ③ 合并 K 路有序序列(K 条链表或 K 行矩阵)。 - 下面三道精讲各挑一个套路。先讲 Top-K 里最出人意料的那一点。 + ③ 合并 K 路有序序列(K 条链表或 K 行矩阵)。下面三道精讲各挑一个套路。先讲 Top-K 里最出人意料的那一点。

    } /> @@ -2406,19 +2302,10 @@ const q = new MinPriorityQueue((x) => x.dist); // dist 最小的先出`, } zh={

    - 第一反应通常是「求第 K 大,当然用大根堆」。其实正好相反: - 标准解法是一个最多装 k 个元素的小根堆。 - 想通这一点,大部分 Top-K 题就变成同一道题了。 - 道理是这样的:你要留住「迄今最大的 k 个」, - 每当有更强的新值进来,就必须淘汰已有 k 个里最弱的那个。 - 这 k 个里最弱的就是它们的最小值, - 而随时 O(1) 读出最小值正是小根堆的本事。 - 于是堆顶成了入围门槛:新值只有比堆顶大才进得来, - 同时把旧堆顶挤出去。扫完 n 个值,堆里就是最大的 k 个, + 第一反应通常是「求第 K 大,当然用大根堆」。其实正好相反:标准解法是一个最多装 k 个元素的小根堆。想通这一点,大部分 Top-K 题就变成同一道题了。道理是这样的:你要留住「迄今最大的 k 个」,每当有更强的新值进来,就必须淘汰已有 k 个里最弱的那个。这 k 个里最弱的就是它们的最小值,而随时 O(1) 读出最小值正是小根堆的本事。于是堆顶成了入围门槛:新值只有比堆顶大才进得来,同时把旧堆顶挤出去。扫完 n 个值,堆里就是最大的 k 个, 堆顶正是第 K 大。镜像情况:求第 K 小就用容量 k 的 大根堆,堆顶是这 k 个候选里最大的。总规律是: - 选那种能把「最该被踢的元素」放在堆顶的堆。 - 代价是 O(n log k) —— 只要 k 远小于 n,就比 O(n log n) 的排序划算。 + 选那种能把「最该被踢的元素」放在堆顶的堆。代价是 O(n log k) —— 只要 k 远小于 n,就比 O(n log n) 的排序划算。

    } /> @@ -2457,10 +2344,8 @@ const q = new MinPriorityQueue((x) => x.dist); // dist 最小的先出`, } zh={

    - 题意:返回数组里第 k 大的元素。重复元素照常计数, - 所以不是第 k 个不同的值。 - 暴力:整个数组排序 O(n log n),取倒数第 k 个。 - 能过,但为了回答关于一个元素的问题,把所有元素的顺序都算了出来。 + 题意:返回数组里第 k 大的元素。重复元素照常计数,所以不是第 k 个不同的值。 + 暴力:整个数组排序 O(n log n),取倒数第 k 个。能过,但为了回答关于一个元素的问题,把所有元素的顺序都算了出来。 正解:套用上面的规律 —— 容量 k 的小根堆,堆顶就是门槛。下面是 k = 2 的过程。

    @@ -2589,15 +2474,8 @@ var findKthLargest = function (nums, k) { } zh={

    - 时间 O(n log k):n 个值,每个至多进出一次容量 k 的堆; - 空间 O(k)。经典追问是「能做到 O(n) 吗?」 - 能,用快速选择(quickselect): - 复用快排的 partition,每轮只递归一边, - 平均 O(n),最坏 O(n²)(随机选基准可以让最坏情况极难出现)。 - 那堆解法为什么还常用?两个原因: - 第一,数据流场景里值源源不断,根本没有完整数组给你 partition; - 第二,数据放不进内存时,容量 k 的堆只要 O(k) 空间。 - 完整的回答要讲清两种解法各自的适用场景。 + 时间 O(n log k):n 个值,每个至多进出一次容量 k 的堆;空间 O(k)。经典追问是「能做到 O(n) 吗?」 + 能,用快速选择(quickselect):复用快排的 partition,每轮只递归一边,平均 O(n),最坏 O(n²)(随机选基准可以让最坏情况极难出现)。那堆解法为什么还常用?两个原因:第一,数据流场景里值源源不断,根本没有完整数组给你 partition;第二,数据放不进内存时,容量 k 的堆只要 O(k) 空间。完整的回答要讲清两种解法各自的适用场景。

    } /> @@ -2638,12 +2516,9 @@ var findKthLargest = function (nums, k) {

    题意:返回数组里出现频率前 k 高的元素。 暴力:统计频率后按频率排序,取前 k 个,O(n log n)。 - 正解:这是你的第一道组合两种结构的题: - 一种结构不够,就串两种 —— - 先用哈希表 O(n) 数出每个值的频次(第 6 章的内容), - 再把这些频次喂给容量 k 的小根堆做 Top-K - (本章的内容)。注意堆是按频次排序的, - 所以门槛是仍在堆里的最低频次。 + 正解:这是你的第一道组合两种结构的题:一种结构不够,就串两种 —— + 先用哈希表 O(n) 数出每个值的频次(第 6 章的内容),再把这些频次喂给容量 k 的小根堆做 Top-K + (本章的内容)。注意堆是按频次排序的,所以门槛是仍在堆里的最低频次。

    } /> @@ -2802,8 +2677,7 @@ class Solution: [...cnt].sort((a,b)=>b[1]-a[1]).slice(0,k).map(p=>p[0]) {" "} - 也能通过,但那是 O(n log n),不是堆的 O(n log k)。 - 桶排序能做到 O(n),是回答追问的好方案。 + 也能通过,但那是 O(n log n),不是堆的 O(n log k)。桶排序能做到 O(n),是回答追问的好方案。 ), }, @@ -2829,12 +2703,8 @@ class Solution: } zh={

    - 计数 O(n),维护堆 O(n log k),合计 O(n log k), - 空间 O(n)(哈希表)。追问是「能做到 O(n) 吗?」 - 能,用桶排序:任何频次都不会超过 n, - 所以开 n + 1 个桶,bucket[f] 装所有出现 f 次的值, - 再从频次最高的桶往下收集,凑够 k 个即可 —— 不用排序也不用堆。 - 「哈希 + 堆」是最好记的通用解,「哈希 + 桶」是它的 O(n) 特化版。 + 计数 O(n),维护堆 O(n log k),合计 O(n log k),空间 O(n)(哈希表)。追问是「能做到 O(n) 吗?」 + 能,用桶排序:任何频次都不会超过 n,所以开 n + 1 个桶,bucket[f] 装所有出现 f 次的值,再从频次最高的桶往下收集,凑够 k 个即可 —— 不用排序也不用堆。「哈希 + 堆」是最好记的通用解,「哈希 + 桶」是它的 O(n) 特化版。

    } /> @@ -2875,13 +2745,8 @@ class Solution:

    题意:把 k 条各自升序的链表合并成一条升序链表。 暴力:把所有结点值倒进数组、排序、再重建链表, - O(N log N)(N 是结点总数)。能过, - 但它浪费了「每条链已经有序」这个条件。 - 正解:合并有序序列时,答案的下一个值一定是 - 当前各条链头结点里最小的那个。 - 谁能报出一个不断变化的集合里的最小值?小根堆。 - 所以堆里只装 k 个头结点: - 取走最小的,再把它的后继补进来。 + O(N log N)(N 是结点总数)。能过,但它浪费了「每条链已经有序」这个条件。 + 正解:合并有序序列时,答案的下一个值一定是当前各条链头结点里最小的那个。谁能报出一个不断变化的集合里的最小值?小根堆。所以堆里只装 k 个头结点:取走最小的,再把它的后继补进来。

    } /> @@ -3003,11 +2868,7 @@ class Solution: zh: ( <> 陷阱:两个元组的 val 相等时, - Python 会接着比下一项。如果那一项就是 node, - 而 ListNode 没有定义 <, - 就会抛 TypeError。 - 在中间放一个唯一且递增的序号 i, - 比较就永远到不了结点本身 —— 这是用 heapq{" "} + Python 会接着比下一项。如果那一项就是 node,而 ListNode 没有定义 <,就会抛 TypeError。在中间放一个唯一且递增的序号 i,比较就永远到不了结点本身 —— 这是用 heapq{" "} 存对象的标准做法。 ), @@ -3078,17 +2939,7 @@ class Solution: } zh={

    - 设结点总数为 N。每个结点恰好进堆、出堆各一次, - 每次堆操作 O(log k) —— 堆里最多只有 k 个结点。 - 所以时间是 O(N log k),额外空间 O(k)。 - 注意这里和链表章的呼应:堆里存的是结点, - 靠 next 就能拿到每条链的剩余部分,不必复制任何值。 - 经典追问是「不用堆行不行?」行,用分治归并: - 每轮把链表两两配对,用「合并两个有序链表」(LC 21)合并每一对, - 链表数随之减半。k 条链 log k 轮合完, - 每轮总共扫 O(N) 个结点,同样是 O(N log k), - 而且不需要堆的 O(k) 空间。(如果把各条链依次并入同一个结果, - 则要 O(Nk),链表章提到的正是这种做法。) + 设结点总数为 N。每个结点恰好进堆、出堆各一次,每次堆操作 O(log k) —— 堆里最多只有 k 个结点。所以时间是 O(N log k),额外空间 O(k)。注意这里和链表章的呼应:堆里存的是结点,靠 next 就能拿到每条链的剩余部分,不必复制任何值。经典追问是「不用堆行不行?」行,用分治归并:每轮把链表两两配对,用「合并两个有序链表」(LC 21)合并每一对,链表数随之减半。k 条链 log k 轮合完,每轮总共扫 O(N) 个结点,同样是 O(N log k),而且不需要堆的 O(k) 空间。(如果把各条链依次并入同一个结果,则要 O(Nk),链表章提到的正是这种做法。)

    } /> @@ -3145,10 +2996,7 @@ class Solution: ), zh: ( <> - 堆只承诺一件事:堆顶是最值。 - 兄弟乱序、底层数组乱序都完全合法。 - 承诺越少,维护越便宜:单次更新 O(log n),而不是全排序的 O(n log n)。 - 这笔取舍就是它的全部设计。 + 堆只承诺一件事:堆顶是最值。兄弟乱序、底层数组乱序都完全合法。承诺越少,维护越便宜:单次更新 O(log n),而不是全排序的 O(n log n)。这笔取舍就是它的全部设计。 ), }, @@ -3165,8 +3013,7 @@ class Solution: zh: ( <> 两条规矩:完全二叉树(又矮又宽,高度 ⌊log₂n⌋,没有空洞) - 和小根堆的 父 ≤ 子。正因为没有空洞, - 整棵树才能塞进数组,每条连线都是算出来的: + 和小根堆的 父 ≤ 子。正因为没有空洞,整棵树才能塞进数组,每条连线都是算出来的: parent = (i−1)/2、children = 2i+1, 2i+2, 不需要指针。 @@ -3205,8 +3052,7 @@ class Solution: <> Top-K 那条反直觉的规律:求第 K 大用容量 k 的 小根堆(堆顶是门槛,比门槛大才进);求第 K 小用 - 大根堆。选那种能把「最该被踢的元素」放在堆顶的堆。 - 代价是 O(n log k)。 + 大根堆。选那种能把「最该被踢的元素」放在堆顶的堆。代价是 O(n log k)。 ), }, @@ -3227,12 +3073,8 @@ class Solution: zh: ( <> 优先队列是接口,二叉堆是常见实现。工程上: - Java 的 PriorityQueue 默认小根堆, - 传 Comparator(如 reverseOrder())可以反向, - 而且遍历无序;Python 的 heapq 到 3.13 为止只有小根堆, - 大根堆靠存负数(3.14 新增了 heappush_max{" "} - 等函数);JavaScript 没有内置实现, - 只能手写或用第三方包。 + Java 的 PriorityQueue 默认小根堆,传 Comparator(如 reverseOrder())可以反向,而且遍历无序;Python 的 heapq 到 3.13 为止只有小根堆,大根堆靠存负数(3.14 新增了 heappush_max{" "} + 等函数);JavaScript 没有内置实现,只能手写或用第三方包。 ), }, diff --git a/app/heap/viz.tsx b/app/heap/viz.tsx index d4b906c..2d78215 100644 --- a/app/heap/viz.tsx +++ b/app/heap/viz.tsx @@ -177,8 +177,7 @@ export function HeapLab() { } zh={ <> - 落位。{v} 上浮了 {swaps} 次,不超过树高({h})。 - push 是 O(log n) —— 它最长只走一条「叶到根」的路。 + 落位。{v} 上浮了 {swaps} 次,不超过树高({h})。push 是 O(log n) —— 它最长只走一条「叶到根」的路。 } />, @@ -213,8 +212,7 @@ export function HeapLab() { } zh={ <> - pop():堆顶 {top} 是堆里最小的值(小根堆唯一的承诺), - 要出队的就是它。 + pop():堆顶 {top} 是堆里最小的值(小根堆唯一的承诺),要出队的就是它。 } />, @@ -249,9 +247,7 @@ export function HeapLab() { } zh={ <> - 第一步:把最后一个元素 {moved} 搬到堆顶。 - 删掉末尾不会破坏完全二叉树的形状,但顶部的堆序多半坏了, - 所以这个值要「下沉」回它该待的位置。 + 第一步:把最后一个元素 {moved} 搬到堆顶。删掉末尾不会破坏完全二叉树的形状,但顶部的堆序多半坏了,所以这个值要「下沉」回它该待的位置。 } />, @@ -300,8 +296,7 @@ export function HeapLab() { } zh={ <> - 第二步 下沉:a[{ci}]={cur} 和较小的孩子 a[{cm}]={child} 交换。 - 换较大的那个,另一边马上会违反「父 ≤ 子」。 + 第二步 下沉:a[{ci}]={cur} 和较小的孩子 a[{cm}]={child} 交换。换较大的那个,另一边马上会违反「父 ≤ 子」。 } />, @@ -327,8 +322,7 @@ export function HeapLab() { } zh={ <> - pop() 完成:弹出 {top},补位元素下沉 {swaps} 次, - 新堆顶是 {newTop}。pop 同样是 O(log n) —— 一条「根到叶」的路。 + pop() 完成:弹出 {top},补位元素下沉 {swaps} 次,新堆顶是 {newTop}。pop 同样是 O(log n) —— 一条「根到叶」的路。 } />, @@ -417,10 +411,7 @@ export function HeapLab() { } zh={ <> - 建堆完成。7 个元素只交换了 {swaps} 次。 - 大约一半的结点是叶子,移动 0 层;约 1/4 最多移 1 层;约 1/8 最多移 2 层…… - 能沉得深的结点恰恰最稀少,所以总步数不超过 n,建堆是 O(n), - 比逐个 push 的 O(n log n) 便宜。 + 建堆完成。7 个元素只交换了 {swaps} 次。大约一半的结点是叶子,移动 0 层;约 1/4 最多移 1 层;约 1/8 最多移 2 层……能沉得深的结点恰恰最稀少,所以总步数不超过 n,建堆是 O(n),比逐个 push 的 O(n log n) 便宜。 } />, @@ -644,10 +635,7 @@ export function HeapMapFig() { zh={ <> 看高亮的这一家三口:结点 [1] 的值是 20,它的孩子在 2 × 1 + 1 ={" "} - [3] 和 2 × 1 + 2 = [4];反过来,[4] 的父结点在 - (4 − 1) / 2 = [1](整数除法)。 - 完全二叉树按层编号没有一个空洞,所以整棵树塞进数组不浪费任何位置, - 而且不需要存父指针或孩子指针 —— 每一条连线都是用下标算出来的。 + [3] 和 2 × 1 + 2 = [4];反过来,[4] 的父结点在(4 − 1) / 2 = [1](整数除法)。完全二叉树按层编号没有一个空洞,所以整棵树塞进数组不浪费任何位置,而且不需要存父指针或孩子指针 —— 每一条连线都是用下标算出来的。 } /> diff --git a/app/home-viz.tsx b/app/home-viz.tsx index dd10396..b3d5e23 100644 --- a/app/home-viz.tsx +++ b/app/home-viz.tsx @@ -232,8 +232,7 @@ const MSG_SHARE = ( } zh={ <> - 执行 b = a:复制的不是盒子,而是纸条上的地址 —— 现在两张标签 - 指向同一个盒子。b 原来的盒子没人指了,等着被回收。 + 执行 b = a:复制的不是盒子,而是纸条上的地址 —— 现在两张标签指向同一个盒子。b 原来的盒子没人指了,等着被回收。 } /> diff --git a/app/linked-list/page.tsx b/app/linked-list/page.tsx index 96dbe22..d9d9278 100644 --- a/app/linked-list/page.tsx +++ b/app/linked-list/page.tsx @@ -79,8 +79,7 @@ export default function LinkedListChapter() { ), zh: ( <> - 每个节点只存一个值和下一个节点的地址。 - 链表交出了连续内存与下标访问,换回来的是: + 每个节点只存一个值和下一个节点的地址。链表交出了连续内存与下标访问,换回来的是: 只要位置前面那个节点已经在手上,插入只需改两根引用,删除只需改一根,不必搬动任何元素 @@ -144,24 +143,18 @@ export default function LinkedListChapter() { <>

    先回放第 1 章最贵的一幕:往数组中间插一个元素,插入点右边的 - 所有元素集体右移一格;删一个,右边全体左移补位。 - 数据量十万条、操作又频繁落在中间时,这份 O(n) + 所有元素集体右移一格;删一个,右边全体左移补位。数据量十万条、操作又频繁落在中间时,这份 O(n) 的搬家成本就是实打实的账单。根源只有一个:数组把 逻辑上的相邻绑定在物理上的相邻 上 —— 两个邻居在内存里就是邻居,中间挤不进人。

    - 链表(linked list)把这道焊缝拆了: - 元素不必住在邻居隔壁,只要每人身上带一张纸条,写着「下一个人住在哪」。 - 你拿到第一个地址(head),照纸条找到第二个,再找第三个…… - 纸条上写着「没有了」(null),这条链就到头了。 + 链表(linked list)把这道焊缝拆了:元素不必住在邻居隔壁,只要每人身上带一张纸条,写着「下一个人住在哪」。你拿到第一个地址(head),照纸条找到第二个,再找第三个……纸条上写着「没有了」(null),这条链就到头了。

    在这样的世界里,「在 A 和 B 之间插入 C」变得极便宜: C 的纸条写上「下一个是 B」,再把 A 的纸条改成「下一个是 C」—— - 只改两张纸条,其他元素一个都不动。 - 代价是门牌号没了:想找第 100 个元素,只能从头一张张纸条追过去。 - 由此有三条家规: + 只改两张纸条,其他元素一个都不动。代价是门牌号没了:想找第 100 个元素,只能从头一张张纸条追过去。由此有三条家规:

    } @@ -185,9 +178,7 @@ export default function LinkedListChapter() { } zh={

    - 节点(node)在内存里想住哪住哪,彼此可以离得很远。 - 好处:不需要一大块连续内存,也永远不会为了扩容整体搬家; - 代价:CPU 猜不到下一个节点在哪,没法提前把它读进缓存。 + 节点(node)在内存里想住哪住哪,彼此可以离得很远。好处:不需要一大块连续内存,也永远不会为了扩容整体搬家;代价:CPU 猜不到下一个节点在哪,没法提前把它读进缓存。

    } /> @@ -208,8 +199,7 @@ export default function LinkedListChapter() { } zh={

    - 每个节点 = 一个值 + 一根 next 引用。 - 链表的顺序只存在于这些引用里,和地址毫无关系 —— + 每个节点 = 一个值 + 一根 next 引用。链表的顺序只存在于这些引用里,和地址毫无关系 —— 所谓「链」,指的就是这一串手拉手的引用。

    } @@ -231,8 +221,7 @@ export default function LinkedListChapter() { } zh={

    - 这里没有「地址 = 首地址 + i × 元素大小」这条公式。 - 想到第 i 个,只能从 head 出发跟 i 次引用 —— + 这里没有「地址 = 首地址 + i × 元素大小」这条公式。想到第 i 个,只能从 head 出发跟 i 次引用 —— 访问是 O(n),这就是它为便宜的插删付出的对价。

    } @@ -262,8 +251,7 @@ export default function LinkedListChapter() { } zh={

    - 内存分配器把空闲块串成一条空闲链表(free list), - 归还一块内存只是改两根引用,不必搬动任何数据;FAT + 内存分配器把空闲块串成一条空闲链表(free list),归还一块内存只是改两根引用,不必搬动任何数据;FAT 文件系统用一张分配表记录每个块的下一个块号,文件就是表里的一条链 —— 所以一个文件可以散落在整块磁盘上。而面试里最出名的组合 —— LRU 缓存(LC 146)= 哈希表 + 双向链表 —— @@ -306,10 +294,7 @@ export default function LinkedListChapter() {

    序章 §03 说过: 引用(reference)本质上就是一个内存地址 —— - 一张写着「去 2096 号找它」的纸条。链表的节点无非是把「值」 - 和「纸条」打包放在一起。下图是同一批值 [7, 2, 9] 的两种住法, - 注意链表三个节点的物理顺序(2096 → 3120 → 1432)和逻辑顺序 - (7 → 2 → 9)毫无关系: + 一张写着「去 2096 号找它」的纸条。链表的节点无非是把「值」和「纸条」打包放在一起。下图是同一批值 [7, 2, 9] 的两种住法,注意链表三个节点的物理顺序(2096 → 3120 → 1432)和逻辑顺序(7 → 2 → 9)毫无关系:

    } /> @@ -337,10 +322,8 @@ export default function LinkedListChapter() { } zh={

    - 数组的 O(1) 来自那条地址公式,而公式成立靠「连续 + 同型」。 - 链表节点散落各处,第 i 个节点的地址 - 只记录在第 i−1 个节点肚子里 —— 想知道它在哪, - 必须先找到前一个。信息的存放方式决定了访问方式:O(n) + 数组的 O(1) 来自那条地址公式,而公式成立靠「连续 + 同型」。链表节点散落各处,第 i 个节点的地址 + 只记录在第 i−1 个节点肚子里 —— 想知道它在哪,必须先找到前一个。信息的存放方式决定了访问方式:O(n) 不是实现不够好,是结构使然。

    } @@ -364,10 +347,7 @@ export default function LinkedListChapter() { } zh={

    - {"{ val: 7, next: 1432 }"} —— 一格值 + 一格地址。 - 在 Java / Python / JS 里,next 就是一个指向下一个节点对象的引用; - 没有下一个时记作 null / None / null, - 它是每条链的终点,也是空指针异常最常见的发生位置。 + {"{ val: 7, next: 1432 }"} —— 一格值 + 一格地址。在 Java / Python / JS 里,next 就是一个指向下一个节点对象的引用;没有下一个时记作 null / None / null,它是每条链的终点,也是空指针异常最常见的发生位置。

    } /> @@ -405,12 +385,8 @@ export default function LinkedListChapter() { zh={

    第 1 章讲过 CPU 缓存行:读 arr[0] 时, - arr[1..15] 因为紧挨着它,顺便一起进了缓存。 - 链表享受不到这个红利 —— 下一个节点在哪,要读完当前节点才知道, - CPU 无法提前加载,几乎每跳一步都可能缓存未命中, - 跑一趟主存。两者的遍历都是 O(n),差的不是复杂度, - 而是常数因子 —— 只是这个常数不小:真机上顺序遍历数组 - 通常快好几倍。工程上的默认选择: + arr[1..15] 因为紧挨着它,顺便一起进了缓存。链表享受不到这个红利 —— 下一个节点在哪,要读完当前节点才知道, + CPU 无法提前加载,几乎每跳一步都可能缓存未命中,跑一趟主存。两者的遍历都是 O(n),差的不是复杂度,而是常数因子 —— 只是这个常数不小:真机上顺序遍历数组通常快好几倍。工程上的默认选择: 先用数组家族,确实需要「拿着节点做 O(1) 插删」时才请链表出场 @@ -594,19 +570,14 @@ export default function LinkedListChapter() { zh={ <>

    - 表里最重要的是第三行的前提。 - 「链表插删 O(1)」这句话被掐头去尾传得太广,完整版是: - 改指针 O(1),找到该改哪根指针 O(n)。 - 所以链表真正的高光场景,是「前驱或节点引用本来就在手上」—— + 表里最重要的是第三行的前提。「链表插删 O(1)」这句话被掐头去尾传得太广,完整版是: + 改指针 O(1),找到该改哪根指针 O(n)。所以链表真正的高光场景,是「前驱或节点引用本来就在手上」—— 比如遍历途中顺手删除,比如 LRU 缓存里哈希表直接把节点递给你。

    - 插入本身只有两步,但顺序决定链表的死活。 - 口诀「先接后断」:新节点先牵住后继( + 插入本身只有两步,但顺序决定链表的死活。口诀「先接后断」:新节点先牵住后继( newNode.next = cur),前驱再改口( - prev.next = newNode)。顺序反了会怎样? - 下面实验室里有一个「反面教材」按钮,亲手丢一次后半条链, - 比背十遍口诀管用: + prev.next = newNode)。顺序反了会怎样?下面实验室里有一个「反面教材」按钮,亲手丢一次后半条链,比背十遍口诀管用:

    } @@ -639,14 +610,10 @@ export default function LinkedListChapter() { } zh={

    - 删除节点 X 通常需要它的前驱,好让前驱的 next 跳过 X。 - 如果只给你 X 本人呢?有个技巧(LC 237):把{" "} + 删除节点 X 通常需要它的前驱,好让前驱的 next 跳过 X。如果只给你 X 本人呢?有个技巧(LC 237):把{" "} X.next 的值抄进 X,然后删掉 X.next{" "} —— 链表的值序列因此正确,代价 O(1)。但它带着两条限制: - X 是最后一个节点时不成立,因为没有下一个值可抄, - 也没法回头找到 X 的前驱;而且真正被留下的节点对象已经换了一个, - 别处若还持有 X.next 的引用, - 那个引用现在指向一个已经不在链上的节点。 + X 是最后一个节点时不成立,因为没有下一个值可抄,也没法回头找到 X 的前驱;而且真正被留下的节点对象已经换了一个,别处若还持有 X.next 的引用,那个引用现在指向一个已经不在链上的节点。

    } /> @@ -679,14 +646,9 @@ export default function LinkedListChapter() { zh={

    ① 先断后接:后半条链失去最后一个引用(上面刚演示过)。②{" "} - 对 null 取字段:对空引用取 .next。 - 循环条件要先判空,而且顺序不能反( + 对 null 取字段:对空引用取 .next。循环条件要先判空,而且顺序不能反( cur != null && cur.next != null —— - && 先算左边,左边成立才算右边, - 两个条件对调就又崩了)。③ 漏掉头节点的特判: - 删头 / 插头时根本没有前驱可改 —— §04 的 dummy 哨兵专治这个。 - 写链表题之前先画图,把指针改动顺序在纸上排好再下手, - 是所有老手的共同习惯。 + && 先算左边,左边成立才算右边,两个条件对调就又崩了)。③ 漏掉头节点的特判:删头 / 插头时根本没有前驱可改 —— §04 的 dummy 哨兵专治这个。写链表题之前先画图,把指针改动顺序在纸上排好再下手,是所有老手的共同习惯。

    } /> @@ -722,8 +684,7 @@ export default function LinkedListChapter() {

    单链表(singly linked list)完整实现。 维护 head、tail、size 三个字段:tail 让尾插降到 O(1),size - 让长度查询降到 O(1) —— 两个「花一点空间买时间」的小决策。 - 所有方法的套路都是同一句话: + 让长度查询降到 O(1) —— 两个「花一点空间买时间」的小决策。所有方法的套路都是同一句话: 找到前驱,改指针:

    } @@ -917,9 +878,7 @@ class MyLinkedList { ), zh: ( <> - 易错点:每个会动 head / tail 的方法都要照顾空链表与 - 单节点两种边界 —— 链表 bug 大多出在这里。 - 测试顺序:空表 → 一个元素 → 两个元素。 + 易错点:每个会动 head / tail 的方法都要照顾空链表与单节点两种边界 —— 链表 bug 大多出在这里。测试顺序:空表 → 一个元素 → 两个元素。 ), }, @@ -1322,8 +1281,7 @@ class MyLinkedList { ), zh: ( <> - 易错点:if (!cur) 判空很顺手, - 但 val 为 0 时 if (!cur.val) 也成立 —— + 易错点:if (!cur) 判空很顺手,但 val 为 0 时 if (!cur.val) 也成立 —— 「节点是否存在」和「值是多少」要分开写。 ), @@ -1350,11 +1308,9 @@ class MyLinkedList { zh={

    双向链表(doubly linked list):把回头路也修上。 - 每个节点多带一根 prev 引用,于是可以从任一节点向两边走。 - 两大收益:① 删除不再需要「找前驱」 —— + 每个节点多带一根 prev 引用,于是可以从任一节点向两边走。两大收益:① 删除不再需要「找前驱」 —— 节点自己就知道前驱是谁,拿到节点即可 O(1) 删除, - LRU 缓存正是靠它;② 支持从尾向头遍历。代价:每个节点多一根引用, - 插入要改四根指针(删除改两根),更容易写错: + LRU 缓存正是靠它;② 支持从尾向头遍历。代价:每个节点多一根引用,插入要改四根指针(删除改两根),更容易写错:

    } /> @@ -1536,12 +1492,9 @@ function remove(node) {

    dummy 哨兵:一个节点换掉一整类 bug。 你可能注意到了,上面 insertAt / removeAt 里 i == 0{" "} - 都要单独写一支。原因很具体:没有任何节点指向头节点, - 「改前驱的 next」这套通用逻辑在它身上无处落笔, - 只能直接给 head 变量赋值。dummy(哑结点 / 哨兵结点) + 都要单独写一支。原因很具体:没有任何节点指向头节点,「改前驱的 next」这套通用逻辑在它身上无处落笔,只能直接给 head 变量赋值。dummy(哑结点 / 哨兵结点) 的办法很直接:造一个假节点站在 head 前面,让 - 每个真节点都有前驱。对比同一道题 - (LC 203:删除所有等于 val 的节点)的两个版本: + 每个真节点都有前驱。对比同一道题(LC 203:删除所有等于 val 的节点)的两个版本:

    } /> @@ -1724,12 +1677,7 @@ var removeElements = function (head, val) { } zh={

    - 一句话判据:答案的头节点可能被改动(删除、在它前面插入、 - 重新接线)时,就用 dummy。删除类(LC 203、19)、 - 构造新链类(LC 21、2)、区间重排类(LC 92、24、25)几乎全中。 - 成本是一个临时节点,换来的是少一个分支、 - 少一类「恰好动到头节点时才触发」的隐藏 bug。 - 记得最后返回 dummy.next 而不是 head。 + 一句话判据:答案的头节点可能被改动(删除、在它前面插入、重新接线)时,就用 dummy。删除类(LC 203、19)、构造新链类(LC 21、2)、区间重排类(LC 92、24、25)几乎全中。成本是一个临时节点,换来的是少一个分支、少一类「恰好动到头节点时才触发」的隐藏 bug。记得最后返回 dummy.next 而不是 head。

    } /> @@ -1768,10 +1716,7 @@ var removeElements = function (head, val) { zh={

    一个可能让你意外的事实: - 三种语言都没有可以直接用于做题的内置单链表。 - 原因正是 §02 那条工程结论 —— 通用场景下动态数组几乎总是更快, - 而单链表值得出场的地方,往往也特殊到值得手写。所以做题时, - 链表就是一个约定俗成的 ListNode, + 三种语言都没有可以直接用于做题的内置单链表。原因正是 §02 那条工程结论 —— 通用场景下动态数组几乎总是更快,而单链表值得出场的地方,往往也特殊到值得手写。所以做题时,链表就是一个约定俗成的 ListNode, LeetCode 帮你定义好,三语言长这样:

    } @@ -1855,8 +1800,7 @@ class ListNode: zh: ( <> 参数名 next 会在这个方法内部遮蔽内置函数{" "} - next()。LeetCode 模板如此, - 自己的工程代码里建议改名。 + next()。LeetCode 模板如此,自己的工程代码里建议改名。 ), }, @@ -2081,12 +2025,8 @@ function ListNode(val, next) { for (int i = 0; i < list.size(); i++) list.get(i) {" "} - 这段代码对 ArrayList 是 O(n),对 LinkedList 是 O(n²): - 每次 get(i) 都要从头(或者从更近的尾)重新走 i 步。 - 十万条数据,前者毫秒级,后者要好几秒。遍历 LinkedList - 必须用 for-each 或迭代器 —— 它们沿着 next 走一遍,整体 O(n)。 - 更实际的建议来自这个类的作者 Joshua Bloch:他说自己也几乎不用它。 - 需要队列用 ArrayDeque,需要列表用 ArrayList,通常都更快。 + 这段代码对 ArrayList 是 O(n),对 LinkedList 是 O(n²):每次 get(i) 都要从头(或者从更近的尾)重新走 i 步。十万条数据,前者毫秒级,后者要好几秒。遍历 LinkedList + 必须用 for-each 或迭代器 —— 它们沿着 next 走一遍,整体 O(n)。更实际的建议来自这个类的作者 Joshua Bloch:他说自己也几乎不用它。需要队列用 ArrayDeque,需要列表用 ArrayList,通常都更快。

    } /> @@ -2114,12 +2054,7 @@ function ListNode(val, next) { } zh={

    - collections.deque 不是一个节点一个元素的教科书链表, - 而是每个节点装一块 64 格的小数组,块与块之间双向链接。 - 这是链表和数组的折中:两端仍然 O(1), - 同时 64 个元素共享一次内存分配、在内存里紧挨着, - 大部分步伐都能吃到 CPU 缓存 —— §02 说的那个缓存问题被削掉一大半。 - 真实工程里的链式结构,大多长这种混血样子。 + collections.deque 不是一个节点一个元素的教科书链表,而是每个节点装一块 64 格的小数组,块与块之间双向链接。这是链表和数组的折中:两端仍然 O(1),同时 64 个元素共享一次内存分配、在内存里紧挨着,大部分步伐都能吃到 CPU 缓存 —— §02 说的那个缓存问题被削掉一大半。真实工程里的链式结构,大多长这种混血样子。

    } /> @@ -2161,9 +2096,7 @@ function ListNode(val, next) { zh={

    数组题多半在拼「想到聪明的遍历方式」,链表题则在拼 - 指针改动的顺序一根不错。 - 好消息是套路极其集中 —— 把下面三招练熟, - 本章题单的 11 道题你都能找到入口: + 指针改动的顺序一根不错。好消息是套路极其集中 —— 把下面三招练熟,本章题单的 11 道题你都能找到入口:

    } /> @@ -2187,9 +2120,7 @@ function ListNode(val, next) { } zh={

    - prev / cur / nxt 三人小队,把箭头逐个调头。 - 整链反转(206)、区间反转(92)、K 组反转(25) - 都是同一个循环的变体 —— 这是链表最核心的基本操作。 + prev / cur / nxt 三人小队,把箭头逐个调头。整链反转(206)、区间反转(92)、K 组反转(25)都是同一个循环的变体 —— 这是链表最核心的基本操作。

    } /> @@ -2215,11 +2146,8 @@ function ListNode(val, next) { } zh={

    - 两个指针速度不同,间距因此可控。快 2 慢 1:找中点(876)、 - 判环(141、142);让 fast 先走 n 步,间距就恒为 n: - 倒数第 n 个(19)。循环条件写成{" "} - fast != null && fast.next != null 时, - 偶数长度的链表,slow 停在两个中点里的第二个。 + 两个指针速度不同,间距因此可控。快 2 慢 1:找中点(876)、判环(141、142);让 fast 先走 n 步,间距就恒为 n:倒数第 n 个(19)。循环条件写成{" "} + fast != null && fast.next != null 时,偶数长度的链表,slow 停在两个中点里的第二个。

    } /> @@ -2242,9 +2170,7 @@ function ListNode(val, next) { } zh={

    - 头节点可能变动?dummy 站到最前面,人人有前驱,特判归零。 - 删除(203、19)、合并构造(21、2)、 - 重排(24、92、25)的标配起手式。 + 头节点可能变动?dummy 站到最前面,人人有前驱,特判归零。删除(203、19)、合并构造(21、2)、重排(24、92、25)的标配起手式。

    } /> @@ -2285,12 +2211,8 @@ function ListNode(val, next) { zh={

    题意:反转单链表,返回新的头节点。暴力: - 把值抄进数组、反转数组、再抄回去 —— O(n) 额外空间, - 而且节点携带复杂对象时「抄值」根本不可行。正解: - 不动任何值,把每根 next 引用原地调头。 - 难点在于:覆盖 cur.next 的瞬间, - 通往后面的路就断了 —— 所以每一轮必须先备份。 - prev / cur / nxt 三人小队,每轮三步: + 把值抄进数组、反转数组、再抄回去 —— O(n) 额外空间,而且节点携带复杂对象时「抄值」根本不可行。正解: + 不动任何值,把每根 next 引用原地调头。难点在于:覆盖 cur.next 的瞬间,通往后面的路就断了 —— 所以每一轮必须先备份。prev / cur / nxt 三人小队,每轮三步: 备份 → 调头 → 前移:

    } @@ -2410,13 +2332,9 @@ function ListNode(val, next) { 时间 O(n)(每个节点处理一次),额外空间 O(1) (三个指针变量)。必考追问一: 为什么返回 prev 而不是 cur? —— - 循环结束的条件是 cur 为 null,此刻 prev 正停在原链的最后一个节点上, - 那就是新链的头。追问二:递归怎么写? ——{" "} + 循环结束的条件是 cur 为 null,此刻 prev 正停在原链的最后一个节点上,那就是新链的头。追问二:递归怎么写? ——{" "} reverseList(head.next) 先把后面整段反转,再用{" "} - head.next.next = head 把自己接到末尾, - 最后 head.next = null 收口。写法优雅, - 但调用栈每个节点占一帧,空间是 O(n) 而不是 O(1), - 链很长时还可能栈溢出 —— 迭代版是更稳妥的答案。 + head.next.next = head 把自己接到末尾,最后 head.next = null 收口。写法优雅,但调用栈每个节点占一帧,空间是 O(n) 而不是 O(1),链很长时还可能栈溢出 —— 迭代版是更稳妥的答案。

    } /> @@ -2457,12 +2375,9 @@ function ListNode(val, next) { zh={

    题意:判断链表里有没有环 —— - 即是否存在某个节点的 next 指回了前面的节点。 - 有环的链表遍历永远不会结束,所以它是「链表操作卡死」的头号嫌犯。 + 即是否存在某个节点的 next 指回了前面的节点。有环的链表遍历永远不会结束,所以它是「链表操作卡死」的头号嫌犯。 暴力:用哈希集合记录见过的节点,重逢即有环 —— O(n) - 时间,但要 O(n) 空间。正解:Floyd 判圈,又名龟兔赛跑: - 两个指针同时出发,slow 每步 1 格,fast 每步 2 格。 - 没有环,fast 先撞上 null;有环,fast 会在环里追上 slow: + 时间,但要 O(n) 空间。正解:Floyd 判圈,又名龟兔赛跑:两个指针同时出发,slow 每步 1 格,fast 每步 2 格。没有环,fast 先撞上 null;有环,fast 会在环里追上 slow:

    } /> @@ -2578,16 +2493,7 @@ function ListNode(val, next) { } zh={

    - 沿着环,量「从 fast 往前走到 slow」的距离。两者都进环之后, - 这个距离是一个不会变成负数的整数;而每走一步,fast 前进 2、 - slow 前进 1,距离恰好减 1。 - 一个每步减 1 的非负整数必然减到 0, - 而 0 就意味着两个指针停在同一个节点上 —— 不存在「擦肩而过」。 - 这也回答了「为什么不是快 3 倍」:相对速度变成 2 时, - 距离每步减 2,可能直接跨过 0 而永远不等于 0。 - 两个指针都从头出发时最终仍会相遇, - 但那需要用同余去证明,而不是这一句话。 - 相对速度为 1 的版本证明最干净、上界也最清楚: + 沿着环,量「从 fast 往前走到 slow」的距离。两者都进环之后,这个距离是一个不会变成负数的整数;而每走一步,fast 前进 2、slow 前进 1,距离恰好减 1。一个每步减 1 的非负整数必然减到 0,而 0 就意味着两个指针停在同一个节点上 —— 不存在「擦肩而过」。这也回答了「为什么不是快 3 倍」:相对速度变成 2 时,距离每步减 2,可能直接跨过 0 而永远不等于 0。两个指针都从头出发时最终仍会相遇,但那需要用同余去证明,而不是这一句话。相对速度为 1 的版本证明最干净、上界也最清楚: slow 进环后不到一圈就会相遇。

    } @@ -2620,11 +2526,7 @@ function ListNode(val, next) {

    时间 O(n),额外空间 O(1) —— 比哈希表解法省掉 O(n) 空间。必考追问: - 环的入口怎么找?(LC 142)相遇后把一个指针放回 head, - 两个指针改为每步一格同速前进,再次相遇处就是入口。 - 背后是等式「头到入口的距离 = 相遇点绕回入口的距离 + 整数圈」, - 由「fast 路程 = 2 × slow 路程」推出。 - 先把 141 的相遇原理讲顺,142 只是一步代数。 + 环的入口怎么找?(LC 142)相遇后把一个指针放回 head,两个指针改为每步一格同速前进,再次相遇处就是入口。背后是等式「头到入口的距离 = 相遇点绕回入口的距离 + 整数圈」,由「fast 路程 = 2 × slow 路程」推出。先把 141 的相遇原理讲顺,142 只是一步代数。

    } /> @@ -2669,8 +2571,7 @@ function ListNode(val, next) {

    题意:两条升序链表,合并成一条升序链表 —— 复用原有节点重新接线,而不是新建值。思路: - 和「合并两摞已排序的考卷」一样:比较两摞最上面那张, - 取小的放进结果堆。难点不在比较, + 和「合并两摞已排序的考卷」一样:比较两摞最上面那张,取小的放进结果堆。难点不在比较, 而在结果链的第一个节点是谁 —— 它可能来自 l1,也可能来自 l2。不想为此写一堆 if? 用 dummy 哨兵起手,tail 从 dummy 出发一路往后挂: @@ -2820,14 +2721,9 @@ function ListNode(val, next) { zh={

    时间 O(n + m)(每个节点被挂一次),额外空间{" "} - O(1)(只多了 dummy 和 tail 两个变量,节点全是复用的)。 - 注意最后那行 tail.next = l1 或 l2: - 链表拼接剩余整段只是一次指针写入,O(1);换成数组合并, - 剩下的元素还得逐个复制。必考追问:合并 K 条呢? - (LC 23,Hard)把各条链依次并入结果是 O(nK); - 用最小堆每次取 K 个当前头节点里最小的,是 O(n log K) —— - 堆章(第 9 章)会讲。这个合并本身也是归并排序的核心步骤, - 链表排序(LC 148)就靠它。 + O(1)(只多了 dummy 和 tail 两个变量,节点全是复用的)。注意最后那行 tail.next = l1 或 l2:链表拼接剩余整段只是一次指针写入,O(1);换成数组合并,剩下的元素还得逐个复制。必考追问:合并 K 条呢? + (LC 23,Hard)把各条链依次并入结果是 O(nK);用最小堆每次取 K 个当前头节点里最小的,是 O(n log K) —— + 堆章(第 9 章)会讲。这个合并本身也是归并排序的核心步骤,链表排序(LC 148)就靠它。

    } /> @@ -2887,10 +2783,8 @@ function ListNode(val, next) { ), zh: ( <> - 链表 = 值 + next 引用的节点散落在内存各处, - 顺序只存在于引用里 —— 没有地址公式, - 访问 / 查找都是 O(n); - 换来的是不需要连续内存、也永远不必为扩容整体搬家。 + 链表 = 值 + next 引用的节点散落在内存各处,顺序只存在于引用里 —— 没有地址公式, + 访问 / 查找都是 O(n);换来的是不需要连续内存、也永远不必为扩容整体搬家。 ), }, @@ -2907,8 +2801,7 @@ function ListNode(val, next) { zh: ( <> 「插删 O(1)」的完整版: - 改指针 O(1),找前驱 O(n)。 - 真正的主场是「节点引用本来就在手上」—— + 改指针 O(1),找前驱 O(n)。真正的主场是「节点引用本来就在手上」—— LRU 缓存(哈希表 + 双向链表)是教科书案例。 ), @@ -2927,9 +2820,7 @@ function ListNode(val, next) { <> 指针操作铁律:先接后断( newNode.next = cur 在前, - prev.next = newNode 在后)。 - 顺序反了,整条后半链会失去最后一个引用。 - 改 next 之前先备份下一个节点。 + prev.next = newNode 在后)。顺序反了,整条后半链会失去最后一个引用。改 next 之前先备份下一个节点。 ), }, @@ -2946,8 +2837,7 @@ function ListNode(val, next) { zh: ( <> 三种套路:三指针反转(备份 → 调头 → 前移)、 - 快慢指针(相对速度 1,距离每步减 1, - 在环里必然相遇)、dummy 哨兵 + 快慢指针(相对速度 1,距离每步减 1,在环里必然相遇)、dummy 哨兵 (人人有前驱,头节点的特判随之消失)。 ), @@ -2964,10 +2854,8 @@ function ListNode(val, next) { ), zh: ( <> - 选型与语言陷阱:默认用数组家族(缓存友好); - 三语言都没有内置单链表,做题时手写 ListNode; - Java 的 LinkedList.get(i) 是 O(n), - 用下标循环遍历就变成 O(n²) —— 它是双端队列,不是数组。 + 选型与语言陷阱:默认用数组家族(缓存友好);三语言都没有内置单链表,做题时手写 ListNode; + Java 的 LinkedList.get(i) 是 O(n),用下标循环遍历就变成 O(n²) —— 它是双端队列,不是数组。 ), }, diff --git a/app/linked-list/viz.tsx b/app/linked-list/viz.tsx index 23eb53f..b24ae79 100644 --- a/app/linked-list/viz.tsx +++ b/app/linked-list/viz.tsx @@ -373,10 +373,8 @@ export function ScatterMap() { } zh={ <> - 数组的三个值排在 1000–1011 这段连续地址上,第 i 个用算式直接算出来; - 链表的三个节点分别在 2096 / 3120 / 1432,逻辑顺序 7 → 2 → 9{" "} - 只存在于 next 引用里,与地址毫无关系。想找第 2 个节点? - 只能从 head 出发,一次跟一根引用。 + 数组的三个值排在 1000–1011 这段连续地址上,第 i 个用算式直接算出来;链表的三个节点分别在 2096 / 3120 / 1432,逻辑顺序 7 → 2 → 9{" "} + 只存在于 next 引用里,与地址毫无关系。想找第 2 个节点?只能从 head 出发,一次跟一根引用。 } /> @@ -439,8 +437,7 @@ export function LinkedLab() { } zh={ <> - 头插第 ① 步 先接:newNode.next = head。 - 新节点先牵住整条链,此刻什么都没丢。 + 头插第 ① 步 先接:newNode.next = head。新节点先牵住整条链,此刻什么都没丢。 } />, @@ -458,8 +455,7 @@ export function LinkedLab() { } zh={ <> - 第 ② 步 换头:head = newNode。两次引用写入, - 成本与链表长度无关 —— 头插是 O(1)。 + 第 ② 步 换头:head = newNode。两次引用写入,成本与链表长度无关 —— 头插是 O(1)。 } />, @@ -479,8 +475,7 @@ export function LinkedLab() { } zh={ <> - 第 ① 步 先接:newNode.next 指向位置 {k} 的节点。 - 旧链完好无损,这一步随时可以反悔。 + 第 ① 步 先接:newNode.next 指向位置 {k} 的节点。旧链完好无损,这一步随时可以反悔。 } />, @@ -498,8 +493,7 @@ export function LinkedLab() { } zh={ <> - 第 ② 步 后断:prev.next = newNode,旧边(虚线)作废。 - 两次指针写入,O(1)。但别忘了:走到 prev 花了 O(n)。 + 第 ② 步 后断:prev.next = newNode,旧边(虚线)作废。两次指针写入,O(1)。但别忘了:走到 prev 花了 O(n)。 } />, @@ -543,8 +537,7 @@ export function LinkedLab() { } zh={ <> - 第 ① 步 绕过:prev.next = target.next。 - 只改这一根指针,目标节点就已经不在链上了。 + 第 ① 步 绕过:prev.next = target.next。只改这一根指针,目标节点就已经不在链上了。 } /> @@ -602,8 +595,7 @@ export function LinkedLab() { } zh={ <> - ⚠ 顺序反了。先断:prev.next = newNode。 - 可是 newNode.next 还是空的(?),而且再没人记得位置 {k} 在哪。 + ⚠ 顺序反了。先断:prev.next = newNode。可是 newNode.next 还是空的(?),而且再没人记得位置 {k} 在哪。 } />, @@ -621,8 +613,7 @@ export function LinkedLab() { } zh={ <> - 位置 {k} 起的整条后半链已经不可达:没有任何引用能到达它们, - 数据等于丢失。这就是「先接后断」四个字要防的事。 + 位置 {k} 起的整条后半链已经不可达:没有任何引用能到达它们,数据等于丢失。这就是「先接后断」四个字要防的事。 } />, @@ -888,8 +879,7 @@ const REV_FRAMES: RevFrame[] = [ } zh={ <> - 初始:prev = null,cur = head。 - prev 是已反转部分的头,cur 是正在处理的节点。目标是把每一根 next + 初始:prev = null,cur = head。prev 是已反转部分的头,cur 是正在处理的节点。目标是把每一根 next 引用原地调头,不新建任何节点。 } @@ -914,8 +904,7 @@ const REV_FRAMES: RevFrame[] = [ } zh={ <> - 第 ① 步 备份:nxt = cur.next,先记住节点 2 在哪。 - 不备份的话,下一行改掉 cur.next,后半条链就再也找不到了 —— + 第 ① 步 备份:nxt = cur.next,先记住节点 2 在哪。不备份的话,下一行改掉 cur.next,后半条链就再也找不到了 —— 和上面的反面教材是同一个问题。 } @@ -940,8 +929,7 @@ const REV_FRAMES: RevFrame[] = [ } zh={ <> - 第 ② 步 调头:cur.next = prev。prev 是 null, - 所以节点 1 现在指向 null —— 它将是新链的最后一个节点。 + 第 ② 步 调头:cur.next = prev。prev 是 null,所以节点 1 现在指向 null —— 它将是新链的最后一个节点。 } /> @@ -963,8 +951,7 @@ const REV_FRAMES: RevFrame[] = [ } zh={ <> - 第 ③ 步 前移:prev = cur,cur = nxt。 - 两个指针一起右移一格。绿色部分已经反转完毕,它的头永远是 prev。 + 第 ③ 步 前移:prev = cur,cur = nxt。两个指针一起右移一格。绿色部分已经反转完毕,它的头永远是 prev。 } /> @@ -988,8 +975,7 @@ const REV_FRAMES: RevFrame[] = [ } zh={ <> - 第二轮,同样三步:备份 nxt = 节点 3,把节点 2 的引用调头指向节点 1,再前移。 - 每一轮只改一根引用,其余原封不动。 + 第二轮,同样三步:备份 nxt = 节点 3,把节点 2 的引用调头指向节点 1,再前移。每一轮只改一根引用,其余原封不动。 } /> @@ -1012,8 +998,7 @@ const REV_FRAMES: RevFrame[] = [ } zh={ <> - 第三轮完成:节点 3 指向节点 2,prev 走到节点 3,cur 走到节点 4。 - 已反转的 [1 ← 2 ← 3] 与未处理的 [4 → ∅] 界限分明。 + 第三轮完成:节点 3 指向节点 2,prev 走到节点 3,cur 走到节点 4。已反转的 [1 ← 2 ← 3] 与未处理的 [4 → ∅] 界限分明。 } /> @@ -1036,8 +1021,7 @@ const REV_FRAMES: RevFrame[] = [ } zh={ <> - 第四轮:nxt 为 null,节点 4 调头指向节点 3,cur 变成 null。 - 循环条件 cur != null 不再成立,循环结束。 + 第四轮:nxt 为 null,节点 4 调头指向节点 3,cur 变成 null。循环条件 cur != null 不再成立,循环结束。 } /> @@ -1061,8 +1045,7 @@ const REV_FRAMES: RevFrame[] = [ } zh={ <> - 返回 prev 而不是 cur:cur 已经是 null,prev 停在原链的最后一个节点上, - 那正是新的头。结果是 4 → 3 → 2 → 1 → ∅。每个节点只处理一次,时间{" "} + 返回 prev 而不是 cur:cur 已经是 null,prev 停在原链的最后一个节点上,那正是新的头。结果是 4 → 3 → 2 → 1 → ∅。每个节点只处理一次,时间{" "} O(n);只用了三个指针变量,额外空间 O(1)。 } @@ -1221,9 +1204,7 @@ const CYC_FRAMES: CycFrame[] = [ } zh={ <> - 第 2 步:slow 到节点 3,fast 到节点 5,两个指针都进入了环。 - 如果没有环,节点 5 就是最后一个节点,fast.next 为 null, - 循环条件 fast != null && fast.next != null{" "} + 第 2 步:slow 到节点 3,fast 到节点 5,两个指针都进入了环。如果没有环,节点 5 就是最后一个节点,fast.next 为 null,循环条件 fast != null && fast.next != null{" "} 会让循环停下并返回 false。 } @@ -1272,10 +1253,7 @@ const CYC_FRAMES: CycFrame[] = [ } zh={ <> - 为什么一定会相遇?沿着环量「从 fast 往前走到 slow」的距离: - 第 2 步时这个距离是 1(5 → 3)。此后每一步 fast 前进 2、slow 前进 1, - 距离恰好减 1。它是一个不会变成负数的整数,所以必然减到 0, - 而 0 就意味着两个指针停在同一个节点上。时间 O(n),额外空间{" "} + 为什么一定会相遇?沿着环量「从 fast 往前走到 slow」的距离:第 2 步时这个距离是 1(5 → 3)。此后每一步 fast 前进 2、slow 前进 1,距离恰好减 1。它是一个不会变成负数的整数,所以必然减到 0,而 0 就意味着两个指针停在同一个节点上。时间 O(n),额外空间{" "} O(1)。用哈希集合记录访问过的节点同样是 O(n) 时间,但要 O(n) 空间。 } @@ -1409,8 +1387,7 @@ const MRG_FRAMES: MrgFrame[] = [ zh={ <> 1 vs 1:用 <= 时相等取 l1,合并保持稳定。 - tail.next 指向这个节点,tail 前移到它上面。 - 节点是整个重新挂接,不拷贝值 —— 这一步是 O(1)。 + tail.next 指向这个节点,tail 前移到它上面。节点是整个重新挂接,不拷贝值 —— 这一步是 O(1)。 } /> @@ -1508,9 +1485,7 @@ const MRG_FRAMES: MrgFrame[] = [ zh={ <> 收尾:l2 剩下的部分用 tail.next = l2{" "} - 整段直接挂上。这只是一次指针写入,O(1);换成数组合并, - 剩余元素还得逐个复制。最后返回 dummy.next,哨兵本身丢弃。 - 时间 O(n + m),额外空间 O(1)。 + 整段直接挂上。这只是一次指针写入,O(1);换成数组合并,剩余元素还得逐个复制。最后返回 dummy.next,哨兵本身丢弃。时间 O(n + m),额外空间 O(1)。 } /> diff --git a/app/page.tsx b/app/page.tsx index 32c6317..d8d9411 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -488,8 +488,7 @@ export default function Home() { <> 每一章:内存图解 → 交互可视化 →{" "} Java / Python / JavaScript{" "} - 三语言对照 → LeetCode 高频题精讲。由易到难,一章一个结构, - 看见它在内存里的样子,而不是背它的定义。 + 三语言对照 → LeetCode 高频题精讲。由易到难,一章一个结构,看见它在内存里的样子,而不是背它的定义。 } /> @@ -572,15 +571,11 @@ export default function Home() { zh={ <>

    - 盯着上面的动画看一轮:7 个数字从头到尾没变过,变的只是 - 它们的排列方式和互相之间的连线。排成一排、手拉手、分层站好、互相勾连 + 盯着上面的动画看一轮:7 个数字从头到尾没变过,变的只是它们的排列方式和互相之间的连线。排成一排、手拉手、分层站好、互相勾连 —— 这就是数组、链表、树、图。

    - 所以“数据结构”一句话说完:数据的组织方式。 - 而组织方式决定了每种操作的成本 —— 图书馆按拼音排书,找书快、插新书慢; - 按到货顺序堆书,插新书快、找书极慢。没有“最好的结构”, - 只有“最适合这批操作的结构”。整本课程,就是带你认识每一种组织方式的 + 所以“数据结构”一句话说完:数据的组织方式。而组织方式决定了每种操作的成本 —— 图书馆按拼音排书,找书快、插新书慢;按到货顺序堆书,插新书快、找书极慢。没有“最好的结构”,只有“最适合这批操作的结构”。整本课程,就是带你认识每一种组织方式的 特性:它擅长什么,不擅长什么,为什么。

    @@ -627,8 +622,7 @@ export default function Home() { } zh={ <> - LeetCode 的每道题,本质都在问同一句话:“这批操作,用哪种组织方式 - 成本最低?” 高频题精讲每章都有。 + LeetCode 的每道题,本质都在问同一句话:“这批操作,用哪种组织方式成本最低?” 高频题精讲每章都有。 } /> @@ -687,10 +681,7 @@ export default function Home() { } zh={

    - 把内存(RAM)想象成一条无限长的街道,街上全是 - 一模一样的小房间,每间房有一个唯一的门牌号(地址), - 每间房能放一个很小的东西(比如一个字节)。CPU 好比一位送件员: - 你给它任何一个门牌号,它都能用同样的时间找到那间房 + 把内存(RAM)想象成一条无限长的街道,街上全是一模一样的小房间,每间房有一个唯一的门牌号(地址),每间房能放一个很小的东西(比如一个字节)。CPU 好比一位送件员:你给它任何一个门牌号,它都能用同样的时间找到那间房 —— 这就是 RAM(Random Access,随机访问)的含义,也是一切 O(1) 的源头。

    } @@ -748,8 +739,7 @@ export default function Home() { <> 左边 5 间连续的房(数组的住法):知道首地址 1000,第 i 个元素 = 1000 + i×4,一步算出。右边零散的房(链表的住法 - ):每间房里额外记着“下一间在哪”,只能一间间跳。 - 这里每个整数占 4 个字节,也就是 4 间房,所以相邻元素的门牌号相差 4。 + ):每间房里额外记着“下一间在哪”,只能一间间跳。这里每个整数占 4 个字节,也就是 4 间房,所以相邻元素的门牌号相差 4。 } /> @@ -776,8 +766,7 @@ export default function Home() { zh={ <> 地址 = 首地址 + 下标 × 元素大小 —— - 数组 O(1) 访问、哈希表定位桶、堆用数组存树,全都建立在这一条乘加公式上。 - 看懂它,后面 14 章会不断和它重逢。 + 数组 O(1) 访问、哈希表定位桶、堆用数组存树,全都建立在这一条乘加公式上。看懂它,后面 14 章会不断和它重逢。 } /> @@ -826,17 +815,12 @@ export default function Home() { zh={ <>

    - 变量可以想成一张标签。它记录的东西分两类: - 像 7 这样的基本类型的值,直接写在变量里; - 而对象、数组这类复合数据,放在内存长街上的某个盒子里, - 变量里存的只是一张写着盒子地址的纸条 —— + 变量可以想成一张标签。它记录的东西分两类:像 7 这样的基本类型的值,直接写在变量里;而对象、数组这类复合数据,放在内存长街上的某个盒子里,变量里存的只是一张写着盒子地址的纸条 —— 这张纸条,就是引用(reference),C 语言里叫指针(pointer)。

    于是 b = a 这行代码有了两种截然不同的含义:基本类型的值是 - 复印数值,引用是复印纸条。 - 复印纸条之后,两张标签指着同一个盒子 —— 通过任何一张标签修改盒子里的东西, - 另一张标签看到的也变了。亲手试一次: + 复印数值,引用是复印纸条。复印纸条之后,两张标签指着同一个盒子 —— 通过任何一张标签修改盒子里的东西,另一张标签看到的也变了。亲手试一次:

    } @@ -878,9 +862,7 @@ System.out.println(a[0]); // 17`, ), zh: ( <> - 规则:8 种基本类型(int/double/boolean…)变量里直接装值; - 其余一切对象(数组、String、自定义类)变量里装的是引用。 - 赋值和传参永远是复制变量里的内容 —— 对象类型复制的就是那张纸条。 + 规则:8 种基本类型(int/double/boolean…)变量里直接装值;其余一切对象(数组、String、自定义类)变量里装的是引用。赋值和传参永远是复制变量里的内容 —— 对象类型复制的就是那张纸条。 ), }, @@ -991,10 +973,8 @@ console.log(a === b); // true —— 比较的是纸条`, } zh={ <> - 第 3 章链表的每个节点里,都存着一张写着「下一个节点在哪」的纸条; - 树的每个节点存着两张(left/right);图的每个顶点存着一组。 - 所谓“链式结构”,就是用纸条把散落在内存各处的盒子串起来。 - 理解了这个实验室,后面各章就都有了基础。 + 第 3 章链表的每个节点里,都存着一张写着「下一个节点在哪」的纸条;树的每个节点存着两张(left/right);图的每个顶点存着一组。 + 所谓“链式结构”,就是用纸条把散落在内存各处的盒子串起来。理解了这个实验室,后面各章就都有了基础。 } /> @@ -1042,8 +1022,7 @@ console.log(a === b); // true —— 比较的是纸条`, zh={ <>

    - 比较两个算法,不能比“谁在我电脑上跑得快”——机器不同、数据不同,结果不可比。 - 计算机科学的做法是问:数据规模 n 变大时,操作次数以什么方式增长? + 比较两个算法,不能比“谁在我电脑上跑得快”——机器不同、数据不同,结果不可比。计算机科学的做法是问:数据规模 n 变大时,操作次数以什么方式增长? 增长趋势用 O(...) 记号表示,只保留最高阶项、扔掉系数和常数: 3n²+5n+20 → O(n²)。

    @@ -1078,8 +1057,7 @@ console.log(a === b); // true —— 比较的是纸条`, zh={ <> ① Big-O ≠ 耗时:它是趋势,不是秒表。n 很小时,O(n²) 完全可能跑赢 - O(n log n)。② 复杂度看的是最坏、平均还是均摊,说的时候请讲清楚 - (哈希表平均 O(1)、最坏 O(n),就是经典例子)。 + O(n log n)。② 复杂度看的是最坏、平均还是均摊,说的时候请讲清楚(哈希表平均 O(1)、最坏 O(n),就是经典例子)。 } /> @@ -1242,8 +1220,7 @@ console.log(a === b); // true —— 比较的是纸条`, } zh={ <> - 直觉类比 → 内存图解 → 操作拆解。每个结构先回答三个问题: - 长什么样?擅长什么?为什么? + 直觉类比 → 内存图解 → 操作拆解。每个结构先回答三个问题:长什么样?擅长什么?为什么? } /> @@ -1265,8 +1242,7 @@ console.log(a === b); // true —— 比较的是纸条`, } zh={ <> - 每章都有可视化 Playground:亲手插入、删除、遍历,看指针怎么跳、 - 内存怎么搬。亲手操作过的结构,才算真正理解。 + 每章都有可视化 Playground:亲手插入、删除、遍历,看指针怎么跳、内存怎么搬。亲手操作过的结构,才算真正理解。 } /> @@ -1314,9 +1290,7 @@ console.log(a === b); // true —— 比较的是纸条`, } zh={ <> - 顶栏的 Java / Python / JS 切换是全站联动的:切一次,所有代码窗口 - 一起换语言。三种语言的数据结构抽象完全一致(数组就是数组,栈就是栈), - 差异只在实现与 API —— 每章的「三语言对照」会把差异一条条挑明。 + 顶栏的 Java / Python / JS 切换是全站联动的:切一次,所有代码窗口一起换语言。三种语言的数据结构抽象完全一致(数组就是数组,栈就是栈),差异只在实现与 API —— 每章的「三语言对照」会把差异一条条挑明。 } /> @@ -1415,8 +1389,7 @@ console.log(a === b); // true —— 比较的是纸条`, ), zh: ( <> - 六个档位记牢:O(1) → O(log n) → O(n) → O(n log n) → O(n²) → O(2ⁿ), - 一百万数据下它们是“瞬间”与“宇宙年龄”的差距。 + 六个档位记牢:O(1) → O(log n) → O(n) → O(n log n) → O(n²) → O(2ⁿ),一百万数据下它们是“瞬间”与“宇宙年龄”的差距。 ), }, diff --git a/app/queue/page.tsx b/app/queue/page.tsx index 422dfa6..119c4cc 100644 --- a/app/queue/page.tsx +++ b/app/queue/page.tsx @@ -53,8 +53,7 @@ const F239: ArrayFrame[] = [ ), zh: ( <> - nums = [5,3,1,4,2,6],k = 3。暴力做法每个窗口扫 k 个元素,O(nk)。 - 单调队列改成 O(1) 直接读出窗口最大值:队列里存的是下标 + nums = [5,3,1,4,2,6],k = 3。暴力做法每个窗口扫 k 个元素,O(nk)。单调队列改成 O(1) 直接读出窗口最大值:队列里存的是下标 ,对应的值从队头到队尾递减。高亮 = 当前在队列中。 ), @@ -107,8 +106,7 @@ const F239: ArrayFrame[] = [ ), zh: ( <> - i = 1:nums[1] = 3 比队尾的 5 小,不弹任何元素,3 从队尾入队。 - 留着它是有意义的:等 5 滑出窗口,3 可能就是最大值。队列:[5, + i = 1:nums[1] = 3 比队尾的 5 小,不弹任何元素,3 从队尾入队。留着它是有意义的:等 5 滑出窗口,3 可能就是最大值。队列:[5, 3],仍然递减。 ), @@ -199,8 +197,7 @@ const F239: ArrayFrame[] = [ zh: ( <> 窗口滑到 [1..3]:队头是下标 0,已经在窗口外 → 从队头弹出 - 。队列:[4],ans = [5, 4]。两端都在工作:队尾淘汰不够大的候选, - 队头清掉已经过期的下标。 + 。队列:[4],ans = [5, 4]。两端都在工作:队尾淘汰不够大的候选,队头清掉已经过期的下标。 ), }, @@ -258,8 +255,7 @@ const F239: ArrayFrame[] = [ ), zh: ( <> - i = 5:6 到来。队尾的 2、随后的 4 都比它小,全部弹出,队列变成 [6]。 - 窗口 [3..5] → ans = [5, 4, 4, 6]。 + i = 5:6 到来。队尾的 2、随后的 4 都比它小,全部弹出,队列变成 [6]。窗口 [3..5] → ans = [5, 4, 4, 6]。 ), }, @@ -285,10 +281,7 @@ const F239: ArrayFrame[] = [ ), zh: ( <> - 收官:ans = [5, 4, 4, 6]。for 里套着 while,看上去像 O(n²),其实不是: - 每个下标只入队一次、最多出队一次(无论从哪一端), - 整趟扫描的队列操作不超过 2n 次 —— O(n)。 - 暴力做法的工作量大约是它的 k 倍。 + 收官:ans = [5, 4, 4, 6]。for 里套着 while,看上去像 O(n²),其实不是:每个下标只入队一次、最多出队一次(无论从哪一端),整趟扫描的队列操作不超过 2n 次 —— O(n)。暴力做法的工作量大约是它的 k 倍。 ), }, @@ -343,9 +336,7 @@ export default function QueueChapter() { ), zh: ( <> - 队列按到达顺序服务:新元素从队尾加入, - 只有队头的元素能离开 —— 取走的永远是等待最久的那个。 - 这条规则叫 FIFO(先进先出)。而 + 队列按到达顺序服务:新元素从队尾加入,只有队头的元素能离开 —— 取走的永远是等待最久的那个。这条规则叫 FIFO(先进先出)。而 双端队列 deque 把限制放开:两端都能插入、都能删除。 ), @@ -383,11 +374,8 @@ export default function QueueChapter() { } zh={

    - 上一章的栈永远先处理最近的那个。现在想象一家打印店只有一台打印机: - 如果永远先打最新提交的文件,只要不断有新任务进来, - 最早那份文件就永远轮不到 —— 计算机科学管这叫 - 饥饿(starvation)。打印任务、外卖订单、客服工单、 - 消息系统,这些场景要的不是最近优先,而是 + 上一章的栈永远先处理最近的那个。现在想象一家打印店只有一台打印机:如果永远先打最新提交的文件,只要不断有新任务进来,最早那份文件就永远轮不到 —— 计算机科学管这叫 + 饥饿(starvation)。打印任务、外卖订单、客服工单、消息系统,这些场景要的不是最近优先,而是 先来先处理。

    } @@ -407,10 +395,7 @@ export default function QueueChapter() { } zh={

    - 为这种顺序而生的结构就是队列(Queue)。 - 画面感是店门口的队伍:新客人从队尾(rear)加入, - 店员只服务队头(front)的人。 - 没有人能越过别人,所以被服务的永远是等待最久的那个 —— + 为这种顺序而生的结构就是队列(Queue)。画面感是店门口的队伍:新客人从队尾(rear)加入,店员只服务队头(front)的人。没有人能越过别人,所以被服务的永远是等待最久的那个 —— 这就是队列的全部承诺,它有个名字: 先进先出,FIFO(First In, First Out)。由此推出三条规矩:

    @@ -473,8 +458,7 @@ export default function QueueChapter() { } zh={

    - 队列不提供访问 / 修改中间元素的操作。和栈一样:能力锁得越小, - 每个操作越快 —— 全部 O(1)。 + 队列不提供访问 / 修改中间元素的操作。和栈一样:能力锁得越小,每个操作越快 —— 全部 O(1)。

    } /> @@ -501,12 +485,9 @@ export default function QueueChapter() { zh={

    操作系统的任务调度队列、打印机的作业队列、Kafka / RabbitMQ - 这类消息队列(请求先排队,后端按自己的节奏消费)。 - 算法这边,第 7 章的树和第 12 章的图都靠队列驱动 - 广度优先搜索(BFS):正是队列让 BFS - 「一层处理完再处理下一层」,而这个顺序就是 BFS - 能在无权图里找到边数最少路径的原因。BFS 后面再讲, - 这一章先把队列本身学扎实。 + 这类消息队列(请求先排队,后端按自己的节奏消费)。算法这边,第 7 章的树和第 12 章的图都靠队列驱动 + 广度优先搜索(BFS):正是队列让 BFS「一层处理完再处理下一层」,而这个顺序就是 BFS + 能在无权图里找到边数最少路径的原因。BFS 后面再讲,这一章先把队列本身学扎实。

    } /> @@ -545,8 +526,7 @@ export default function QueueChapter() { 用数组装队列,麻烦立刻出现:入队在尾部,这是数组的强项(追加 O(1));可出队在头部 —— 数组章讲过,头部删除要让后面的元素整体左移一格, - 一次出队 O(n),队伍越长越慢。 - 不搬移也行,但队头腾出来的格子就再也用不上了。三种设计并排看: + 一次出队 O(n),队伍越长越慢。不搬移也行,但队头腾出来的格子就再也用不上了。三种设计并排看:

    } /> @@ -569,11 +549,9 @@ export default function QueueChapter() { zh={

    设计三就是循环队列(circular queue) - ,工程里也叫环形缓冲区(ring buffer): - 数组在内存里仍是一条直线,但所有下标运算都以{" "} + ,工程里也叫环形缓冲区(ring buffer):数组在内存里仍是一条直线,但所有下标运算都以{" "} % capacity 结尾,于是它在逻辑上 - 变成一个圈 —— front 在一头消费,rear 绕回来在另一头补充, - 只要没装满,永远有格子可用。§03 的环形队列实验室可以亲手转这个圈。 + 变成一个圈 —— front 在一头消费,rear 绕回来在另一头补充,只要没装满,永远有格子可用。§03 的环形队列实验室可以亲手转这个圈。

    } /> @@ -606,8 +584,7 @@ export default function QueueChapter() {

    维护 head、tail 两个指针:出队 = 摘下头节点(O(1)),入队 = 接在尾节点后(O(1))。为什么不反过来?单链表尾部删除要先找到前驱, - O(n),而头删、尾插都不需要遍历 —— 还是那句话:选不用搬移的那端。 - 链表队列不用扩容、也不会装满,代价是每个元素多一个指针,而且缓存不友好。 + O(n),而头删、尾插都不需要遍历 —— 还是那句话:选不用搬移的那端。链表队列不用扩容、也不会装满,代价是每个元素多一个指针,而且缓存不友好。

    } /> @@ -663,8 +640,7 @@ export default function QueueChapter() { } zh={

    - 网卡驱动的收发包缓冲、音频设备的采样缓冲、日志系统的定长缓冲、 - 高频交易框架 LMAX Disruptor 的核心 —— 全是环形缓冲区。原因每次都一样: + 网卡驱动的收发包缓冲、音频设备的采样缓冲、日志系统的定长缓冲、高频交易框架 LMAX Disruptor 的核心 —— 全是环形缓冲区。原因每次都一样: 定长数组 + 两个绕圈下标 = 零分配、零搬移、访问的内存又挨得很近 。生产者和消费者各追各的下标,互不打扰。

    @@ -831,8 +807,7 @@ export default function QueueChapter() { zh={

    动手转几圈,重点看三件事:①出队时没有任何元素被搬移 - ;②rear 走到最后一格时怎么绕回 0;③「满」和「空」为什么会长得一样, - 以及两种区分方案: + ;②rear 走到最后一格时怎么绕回 0;③「满」和「空」为什么会长得一样,以及两种区分方案:

    } /> @@ -863,13 +838,9 @@ export default function QueueChapter() { } zh={

    - 空,是 front 追上了 rear;满,是 rear 绕完整整一圈追上了 front。 - 两种情况下都是 front == rear,光看下标区分不了。 - 设计上必须消歧,标准答案有两个。方案 A:永久留一格空 —— - 开 k + 1 格,(rear + 1) % cap == front 即为满,不需要额外变量, - 代价是牺牲一格。方案 B:维护 size 计数器 —— 空 = size - == 0,满 = size == cap,格子全能用,代价是每次进出都要更新计数器。 - 两种都对;§04 的实现用的是方案 A,环形队列实验室里可以来回切换。 + 空,是 front 追上了 rear;满,是 rear 绕完整整一圈追上了 front。两种情况下都是 front == rear,光看下标区分不了。设计上必须消歧,标准答案有两个。方案 A:永久留一格空 —— + 开 k + 1 格,(rear + 1) % cap == front 即为满,不需要额外变量,代价是牺牲一格。方案 B:维护 size 计数器 —— 空 = size + == 0,满 = size == cap,格子全能用,代价是每次进出都要更新计数器。两种都对;§04 的实现用的是方案 A,环形队列实验室里可以来回切换。

    } /> @@ -904,9 +875,7 @@ export default function QueueChapter() { zh={

    下面这个类就是 LeetCode 622「设计循环队列」的题解 - :定长数组 + front / rear 两个下标 + 取模绕圈,采用方案 A - 「留一格空」(所以构造时多开一格)。每一行都能对应到环形队列实验室 - 里的一个动作: + :定长数组 + front / rear 两个下标 + 取模绕圈,采用方案 A「留一格空」(所以构造时多开一格)。每一行都能对应到环形队列实验室里的一个动作:

    } /> @@ -1080,8 +1049,7 @@ export default function QueueChapter() { zh: ( <> 细节:Python 里 -1 % 8 == 7 - (结果与除数同号),所以不必像 Java / JavaScript 那样先 + len。 - 但如果你跨语言写代码,统一写{" "} + (结果与除数同号),所以不必像 Java / JavaScript 那样先 + len。但如果你跨语言写代码,统一写{" "} (i - 1 + n) % n 最保险。 ), @@ -1195,8 +1163,7 @@ MyCircularQueue.prototype.isFull = function () { zh={

    链表版队列也值得手写一遍。第 7 章各语言的 BFS - 分别用 ArrayDeque、deque 和数组加读指针,并不用链表队列; - 但链表版藏着一个面试常问的边界问题:tail 的归位。 + 分别用 ArrayDeque、deque 和数组加读指针,并不用链表队列;但链表版藏着一个面试常问的边界问题:tail 的归位。

    } /> @@ -1420,11 +1387,8 @@ class LinkedQueue: } zh={

    - 单链表里,头部删除 O(1);有 tail 指针时尾部插入 O(1); - 尾部删除 O(n)(要先找到前驱)。所以出队必须在头、入队必须在尾 —— - 方向反过来,其中一端就退化成 O(n)。 - 这和「数组栈的栈顶在尾部、链表栈的栈顶在头部」是同一套推理: - 哪端不花钱就用哪端。 + 单链表里,头部删除 O(1);有 tail 指针时尾部插入 O(1);尾部删除 O(n)(要先找到前驱)。所以出队必须在头、入队必须在尾 —— + 方向反过来,其中一端就退化成 O(n)。这和「数组栈的栈顶在尾部、链表栈的栈顶在头部」是同一套推理:哪端不花钱就用哪端。

    } /> @@ -1460,8 +1424,7 @@ class LinkedQueue:

    队列是三种语言标准库差异最大的结构:Java 和 Python 都提供了可以直接用的 deque,JavaScript 什么也没有 ——{" "} - Array.prototype.shift() 看着像出队, - 实际是「取走第一个元素,其余整体前移」, + Array.prototype.shift() 看着像出队,实际是「取走第一个元素,其余整体前移」, 一般情况下是 O(n)。逐个看:

    } @@ -1527,8 +1490,7 @@ Queue slower = new java.util.LinkedList<>();`, zh: ( <> 两套 API:offer / poll / peek 在操作无法完成时返回 false - 或 null;add / remove / element 则抛异常。做题一般用前一组。 - 另外注意 ArrayDeque{" "} + 或 null;add / remove / element 则抛异常。做题一般用前一组。另外注意 ArrayDeque{" "} 不允许存 null,因为 null 被它用作「没有元素」的信号。 @@ -1592,8 +1554,7 @@ bad.pop(0) # O(n):抽走第一个元素,其余整体左移 zh: ( <> 底层结构:deque 是块状双向链表 —— - 每个节点是一个能装 64 个元素的小数组块。两端 O(1), - 内存也比「一个元素一个节点」紧凑;代价是中间随机访问{" "} + 每个节点是一个能装 64 个元素的小数组块。两端 O(1),内存也比「一个元素一个节点」紧凑;代价是中间随机访问{" "} q[i] 退化为 O(n)。 list.pop(0) 是用 Python 做题时最常见的性能问题。 @@ -1644,9 +1605,7 @@ const empty = head === q.length; ), zh: ( <> - 取舍:下标法里「已出队」的元素仍占着内存, - 直到整个数组被回收。做题时程序只运行几百毫秒,完全无所谓; - 长期运行的服务里,要么定期用{" "} + 取舍:下标法里「已出队」的元素仍占着内存,直到整个数组被回收。做题时程序只运行几百毫秒,完全无所谓;长期运行的服务里,要么定期用{" "} q = q.slice(head) 整理一次,要么换链表队列。 ), @@ -1830,8 +1789,7 @@ const empty = head === q.length; } zh={

    - 两个 LIFO 叠出一个 FIFO:反转两次等于恢复原序。 - 它是均摊分析的经典例题。LC 232,精讲 A。 + 两个 LIFO 叠出一个 FIFO:反转两次等于恢复原序。它是均摊分析的经典例题。LC 232,精讲 A。

    } /> @@ -1882,8 +1840,7 @@ const empty = head === q.length; zh={

    回文判断(比较两端再向中间收拢)、0-1 BFS(权为 0 - 的边插队头、权为 1 的边排队尾,属于进阶话题,本课不展开)、工作窃取调度(线程把自己这端当栈用, - 其他线程从另一端取走任务)。 + 的边插队头、权为 1 的边排队尾,属于进阶话题,本课不展开)、工作窃取调度(线程把自己这端当栈用,其他线程从另一端取走任务)。

    } /> @@ -1906,8 +1863,7 @@ const empty = head === q.length; zh={

    单调队列(monotonic deque) - 是一种内部元素始终保持有序的双端队列,靠「插入前先弹出」来维持这个顺序。 - 以滑动窗口最大值为例:队列里存的是 + 是一种内部元素始终保持有序的双端队列,靠「插入前先弹出」来维持这个顺序。以滑动窗口最大值为例:队列里存的是 下标而不是值,这些下标对应的值从队头到队尾 递减。维持它的规则有两条,两端各一条:

    @@ -1936,9 +1892,7 @@ const empty = head === q.length; zh={

    下标 i 入队前,把队尾所有值不大于 nums[i] - 的下标弹出。它们既比新元素小,又比新元素更早离开窗口, - 所以不可能再成为任何窗口的最大值。值相等的下标也会被弹掉, - 因此队列里的值是严格递减的。 + 的下标弹出。它们既比新元素小,又比新元素更早离开窗口,所以不可能再成为任何窗口的最大值。值相等的下标也会被弹掉,因此队列里的值是严格递减的。

    } /> @@ -1960,9 +1914,7 @@ const empty = head === q.length; } zh={

    - 队头下标一旦落到窗口之外,就从队头弹出。 - 只有队头可能过期(它是队列里最老的下标), - 所以每步只需要检查一次。 + 队头下标一旦落到窗口之外,就从队头弹出。只有队头可能过期(它是队列里最老的下标),所以每步只需要检查一次。

    } /> @@ -2008,11 +1960,8 @@ const empty = head === q.length; } zh={

    - 代码里 for 套着 while,很多人据此认定是 O(n²) —— 不是。 - 每个下标只入队一次、最多出队一次 - (无论从哪一端),所以整个数组走完,队列操作总数不超过 2n。 - 时间是 O(n);空间 O(k), - 因为队列里的下标数量不会超过窗口长度。 + 代码里 for 套着 while,很多人据此认定是 O(n²) —— 不是。每个下标只入队一次、最多出队一次 + (无论从哪一端),所以整个数组走完,队列操作总数不超过 2n。时间是 O(n);空间 O(k),因为队列里的下标数量不会超过窗口长度。

    } /> @@ -2040,10 +1989,7 @@ const empty = head === q.length; } zh={

    - 两者拿到 O(n) 的方式相同:提前丢掉「永远不可能成为答案」的元素。 - 区别在元素从哪一端离场。单调栈解决「下一个更大元素」这类问题, - 元素被弹出的那一刻就结算答案;单调队列解决窗口类问题, - 多了一条规则:队头的下标会过期 —— + 两者拿到 O(n) 的方式相同:提前丢掉「永远不可能成为答案」的元素。区别在元素从哪一端离场。单调栈解决「下一个更大元素」这类问题,元素被弹出的那一刻就结算答案;单调队列解决窗口类问题,多了一条规则:队头的下标会过期 —— 窗口向前滑,老下标自然失效。栈用一端就够,窗口必须两端都用。

    } @@ -2085,11 +2031,10 @@ const empty = head === q.length; zh={

    题意:只准用栈(LIFO),实现队列的 push / pop / peek / - empty。 思路:栈把顺序反过来 —— 那就再反一次。 - 朴素做法:每次 push 都借助第二个栈把新元素垫到底部,push - O(n)。 更好的做法:不到必须的时候就不搬 —— in - 栈只负责进,out 栈只负责出, - 只有 out 空了才把 in 整体倒过去: + empty。思路:栈把顺序反过来 —— 那就再反一次。 + 朴素做法:每次 push 都借助第二个栈把新元素垫到底部,push + O(n)。更好的做法:不到必须的时候就不搬 —— in + 栈只负责进,out 栈只负责出,只有 out 空了才把 in 整体倒过去:

    } /> @@ -2279,9 +2224,7 @@ MyQueue.prototype._transfer = function () { 单看一次 pop,最坏是 O(n)(正好赶上转移)。改成盯元素 而不是操作:一个元素一生最多被搬动 4 次 —— 进 in、出 in、进 out、出 out,而且每一段最多发生一次(out - 非空时绝不转移,所以没有元素会被转移第二次)。 - n 次操作总搬动不超过 4n 次,平均每次 O(1)。 - 数组扩容(1 + 2 + 4 + … < 2n)、单调栈(每个元素进出各一次,不超过 + 非空时绝不转移,所以没有元素会被转移第二次)。n 次操作总搬动不超过 4n 次,平均每次 O(1)。数组扩容(1 + 2 + 4 + … < 2n)、单调栈(每个元素进出各一次,不超过 2n)算的是同一本账: 均摊分析就是把偶发的大开销摊到所有操作头上。

    @@ -2310,9 +2253,7 @@ MyQueue.prototype._transfer = function () { } zh={

    - push O(1);pop / peek 均摊 O(1);空间 O(n)。追问一: - 「为什么 out 非空时不能转移?」—— 转移过去的元素会压在更早的元素上面, - 顺序立刻就错了。追问二:「反过来用队列实现栈呢?」—— 那是 LC + push O(1);pop / peek 均摊 O(1);空间 O(n)。追问一:「为什么 out 非空时不能转移?」—— 转移过去的元素会压在更早的元素上面,顺序立刻就错了。追问二:「反过来用队列实现栈呢?」—— 那是 LC 225(题单里有):每次 push 后把前面的 n − 1 个元素挪到队尾,push O(n)、pop O(1),两题互为镜像。

    @@ -2360,11 +2301,10 @@ MyQueue.prototype._transfer = function () { zh={

    题意:长度为 k 的窗口从左滑到右,输出每个位置的窗口最大值。 - 暴力:每个窗口扫 k 个元素,O(nk) —— n = 10⁵ 时会超时。 - 为什么能优化:相邻两个窗口共享 k − 1 个元素,暴力做法 + 暴力:每个窗口扫 k 个元素,O(nk) —— n = 10⁵ 时会超时。 + 为什么能优化:相邻两个窗口共享 k − 1 个元素,暴力做法 把几乎相同的一段反复重扫;而且它扔掉了信息 —— - 一个元素只要右边出现了更大的数,它在此后任何窗口里都不可能是最大值, - 这样的元素根本不必保留。把「还有可能的候选」按递减顺序存进 + 一个元素只要右边出现了更大的数,它在此后任何窗口里都不可能是最大值,这样的元素根本不必保留。把「还有可能的候选」按递减顺序存进 deque,就是单调队列:

    } @@ -2540,12 +2480,9 @@ class Solution: } zh={

    - 时间 O(n):每个下标入队一次、最多出队一次, - 所以队列操作总数不超过 2n —— 尽管代码里 for 套着 while。空间 - O(k)。追问一:「求窗口最小值?」—— 所有比较反向,维护递增队列。 - 追问二:「同时要 max 和 min?」—— - 在同一个窗口上并行跑两个单调队列(LC 1438,题单里有)。追问三: - 「为什么存下标不存值?」——过期判断需要位置信息(队头 ≤ i − + 时间 O(n):每个下标入队一次、最多出队一次,所以队列操作总数不超过 2n —— 尽管代码里 for 套着 while。空间 + O(k)。追问一:「求窗口最小值?」—— 所有比较反向,维护递增队列。追问二:「同时要 max 和 min?」—— + 在同一个窗口上并行跑两个单调队列(LC 1438,题单里有)。追问三:「为什么存下标不存值?」——过期判断需要位置信息(队头 ≤ i − k)。下标能查出值,值查不出下标 —— 和单调栈存下标是同一个道理。

    @@ -2625,8 +2562,7 @@ class Solution: ), zh: ( <> - 朴素数组队列只能二选一:每次出队都搬移(O(n)), - 或者让队头腾出的格子永远闲置。循环队列把所有下标都{" "} + 朴素数组队列只能二选一:每次出队都搬移(O(n)),或者让队头腾出的格子永远闲置。循环队列把所有下标都{" "} % cap,一次解决两个问题。此时 front == rear 既可能是满也可能是空,所以设计上要么留一格空,要么 维护 size 计数器。 @@ -2669,8 +2605,7 @@ class Solution: ), zh: ( <> - 双栈模拟队列:反转两次恢复到达顺序;每个元素一生最多被搬动 4 次, - 所以 pop 是均摊 O(1) —— 和数组扩容、单调栈是同一本账。 + 双栈模拟队列:反转两次恢复到达顺序;每个元素一生最多被搬动 4 次,所以 pop 是均摊 O(1) —— 和数组扩容、单调栈是同一本账。 ), }, @@ -2687,9 +2622,7 @@ class Solution: ), zh: ( <> - 单调队列里存的是下标,对应值从队头到队尾递减: - 队尾弹出所有值不大于新元素的下标,队头弹出已经离开窗口的下标。 - 于是队头永远是当前窗口的最大值,整趟扫描是 O(n) —— + 单调队列里存的是下标,对应值从队头到队尾递减:队尾弹出所有值不大于新元素的下标,队头弹出已经离开窗口的下标。于是队头永远是当前窗口的最大值,整趟扫描是 O(n) —— 因为每个下标只入队一次、最多出队一次。 ), diff --git a/app/queue/viz.tsx b/app/queue/viz.tsx index 2cea91e..b701c48 100644 --- a/app/queue/viz.tsx +++ b/app/queue/viz.tsx @@ -121,10 +121,8 @@ export function QueueMemFig() { } zh={ <> - 不搬移任何元素,只把 front 右移一格,出队变成 O(1)。 - 但 slot[0] 从此永远用不上;而且 12 写进 slot[3] - 之后,下一个写入位置是 4,已经越过数组末尾。 - 一个长期运行的服务会不断把数组撑大 —— 而里面其实只有 3 个元素。 + 不搬移任何元素,只把 front 右移一格,出队变成 O(1)。但 slot[0] 从此永远用不上;而且 12 写进 slot[3] + 之后,下一个写入位置是 4,已经越过数组末尾。一个长期运行的服务会不断把数组撑大 —— 而里面其实只有 3 个元素。 } /> @@ -157,8 +155,7 @@ export function QueueMemFig() { <> 设计二加一条规则:所有下标都对容量取模。图里的状态比上面又往前走了一步 —— 7、9 在 slot[2]、slot[3],2 刚从 slot[1] 出队。下一个写入位置本该是 4, - 4 % 4 = 0,于是 12 写进 slot[0],复用了出队腾出的空间。 - 直线在逻辑上变成了圆环:没有元素搬移,也没有格子浪费 —— 这就是 + 4 % 4 = 0,于是 12 写进 slot[0],复用了出队腾出的空间。直线在逻辑上变成了圆环:没有元素搬移,也没有格子浪费 —— 这就是 循环队列。 } @@ -229,8 +226,7 @@ export function RingLab() { zh={ <> 满了。rear 的下一格就是 front —— 再写进去,「满」和「空」都会变成 - front == rear,无法区分。方案 A 永久空出这一格(虚线格), - 所以 8 格最多装 7 个。想用满 8 格,切到方案 B。 + front == rear,无法区分。方案 A 永久空出这一格(虚线格),所以 8 格最多装 7 个。想用满 8 格,切到方案 B。 } /> @@ -577,8 +573,7 @@ const POUR_FRAMES: PourFrame[] = [ } zh={ <> - 规则只有三条:push 一律进 in;pop / peek 一律找 out; - 只有 out 空了,才把 in 整体倒进 out。 + 规则只有三条:push 一律进 in;pop / peek 一律找 out;只有 out 空了,才把 in 整体倒进 out。 } /> @@ -630,8 +625,7 @@ const POUR_FRAMES: PourFrame[] = [ } zh={ <> - pop(),而 out 是空的 → 触发转移:in 的栈顶是 2,它先被弹出, - 压进 out,落在 out 的底部。 + pop(),而 out 是空的 → 触发转移:in 的栈顶是 2,它先被弹出,压进 out,落在 out 的底部。 } /> @@ -651,8 +645,7 @@ const POUR_FRAMES: PourFrame[] = [ } zh={ <> - 1 随后压入,正好落在 out 的栈顶。反转两次等于没反转: - 先到的 1 回到了「最先出去」的位置。 + 1 随后压入,正好落在 out 的栈顶。反转两次等于没反转:先到的 1 回到了「最先出去」的位置。 } /> @@ -692,8 +685,7 @@ const POUR_FRAMES: PourFrame[] = [ zh={ <> push(3):进 in,完全不打扰 out。纪律:只要 out - 里还有更早的元素,就绝不转移 —— 提前转移会把 3 放到 2 前面, - 顺序立刻错乱。 + 里还有更早的元素,就绝不转移
    —— 提前转移会把 3 放到 2 前面,顺序立刻错乱。 } /> @@ -724,9 +716,7 @@ const POUR_FRAMES: PourFrame[] = [ } zh={ <> - pop() = 3:out 空 → 转移把 3 搬过来 → 弹出。算总账: - 每个元素一生最多被搬动 4 次(进 in、出 in、进 out、出 out), - 所以 n 次调用总共不超过 4n 次搬动,即均摊 O(1)。 + pop() = 3:out 空 → 转移把 3 搬过来 → 弹出。算总账:每个元素一生最多被搬动 4 次(进 in、出 in、进 out、出 out),所以 n 次调用总共不超过 4n 次搬动,即均摊 O(1)。 } /> diff --git a/app/stack/page.tsx b/app/stack/page.tsx index c2155b0..bbebd01 100644 --- a/app/stack/page.tsx +++ b/app/stack/page.tsx @@ -50,8 +50,7 @@ const F20: ArrayFrame[] = [ zh={ <> 输入 s = {'"([{}])"'}。规则:每个右括号必须与最近的 - 那个尚未配对的左括号成对 —— 「最近的」正是栈能 O(1) 给你的东西。 - 栈里存的就是「还没等到另一半的左括号」;下面把栈横过来画,右端是栈顶。 + 那个尚未配对的左括号成对 —— 「最近的」正是栈能 O(1) 给你的东西。栈里存的就是「还没等到另一半的左括号」;下面把栈横过来画,右端是栈顶。 } /> @@ -148,8 +147,7 @@ const F20: ArrayFrame[] = [ } zh={ <> - 读到 {"']'"}:{"'{'"} 已经走了,新栈顶是 {"'['"},正好配对,弹出。 - 每个右括号遇到的都是「最近的未配对左括号」—— 这就是 LIFO 与嵌套的咬合点。 + 读到 {"']'"}:{"'{'"} 已经走了,新栈顶是 {"'['"},正好配对,弹出。每个右括号遇到的都是「最近的未配对左括号」—— 这就是 LIFO 与嵌套的咬合点。 } /> @@ -226,9 +224,7 @@ const F155: ArrayFrame[] = [ } zh={ <> - 目标:push / pop / top / getMin 全部 O(1)。做法:主栈旁边再放一个辅助栈, - 每一层记录「push 这一层时,栈内的最小值」。下面把两者画进同一个格子: - 上 = 值,下 = 该层记下的 min。代价是 O(n) 额外空间。 + 目标:push / pop / top / getMin 全部 O(1)。做法:主栈旁边再放一个辅助栈,每一层记录「push 这一层时,栈内的最小值」。下面把两者画进同一个格子:上 = 值,下 = 该层记下的 min。代价是 O(n) 额外空间。 } /> @@ -263,8 +259,7 @@ const F155: ArrayFrame[] = [ } zh={ <> - push(0):这一层的 min = min(0, −2) = −2。注意存的是什么: - 不是 0 本身,而是「包含这一层在内、整段栈的最小值」。 + push(0):这一层的 min = min(0, −2) = −2。注意存的是什么:不是 0 本身,而是「包含这一层在内、整段栈的最小值」。 } /> @@ -325,8 +320,7 @@ const F155: ArrayFrame[] = [ } zh={ <> - pop():−3 连同它那一层的 min 一起弹走,全栈最小值自动回到 −2。 - 为什么可以这样?因为下面那一层记的,正是「−3 入栈之前」那个栈的最小值。 + pop():−3 连同它那一层的 min 一起弹走,全栈最小值自动回到 −2。为什么可以这样?因为下面那一层记的,正是「−3 入栈之前」那个栈的最小值。 } /> @@ -374,9 +368,7 @@ const F739: ArrayFrame[] = [ } zh={ <> - T = [73,74,71,69,75,73],求每天还要等几天才升温。暴力做法是每天向后扫,O(n²)。 - 单调栈里存的是「答案还未确定」的下标,它们的温度自底向上不递增。 - 亮起 = 正在栈中等待,变绿 = 答案已结算。 + T = [73,74,71,69,75,73],求每天还要等几天才升温。暴力做法是每天向后扫,O(n²)。单调栈里存的是「答案还未确定」的下标,它们的温度自底向上不递增。亮起 = 正在栈中等待,变绿 = 答案已结算。 } /> @@ -493,8 +485,7 @@ const F739: ArrayFrame[] = [ } zh={ <> - i=3:69 更冷,继续入栈。栈:[1, 2, 3] —— 每个等待者都比它下面那个冷, - 这就是栈维持的不变量。 + i=3:69 更冷,继续入栈。栈:[1, 2, 3] —— 每个等待者都比它下面那个冷,这就是栈维持的不变量。 } /> @@ -524,8 +515,7 @@ const F739: ArrayFrame[] = [ } zh={ <> - i=4:75 比栈顶的 69 暖 → 弹出 3,ans[3] = 4 − 3 = 1。 - 新栈顶 71 仍然比 75 冷,循环继续。 + i=4:75 比栈顶的 69 暖 → 弹出 3,ans[3] = 4 − 3 = 1。新栈顶 71 仍然比 75 冷,循环继续。 } /> @@ -555,8 +545,7 @@ const F739: ArrayFrame[] = [ } zh={ <> - 继续弹出 2:ans[2] = 4 − 2 = 2。它等了两天,而这两天是在被弹出时 - 用一次减法算出来的 —— 中途完全不用记着它。 + 继续弹出 2:ans[2] = 4 − 2 = 2。它等了两天,而这两天是在被弹出时用一次减法算出来的 —— 中途完全不用记着它。 } /> @@ -586,8 +575,7 @@ const F739: ArrayFrame[] = [ } zh={ <> - 74 也被弹出:ans[1] = 4 − 1 = 3。栈空了,下标 4(75)入栈。 - 一个 75 一次性送走三个等待者 —— 代码里那个 while 循环做的就是这件事。 + 74 也被弹出:ans[1] = 4 − 1 = 3。栈空了,下标 4(75)入栈。一个 75 一次性送走三个等待者 —— 代码里那个 while 循环做的就是这件事。 } /> @@ -640,8 +628,7 @@ const F739: ArrayFrame[] = [ zh={ <> 扫描结束:栈里剩下的 4、5 右边再没有更暖的日子,答案保持 0。结果 - [1,3,2,1,0,0]。每个下标恰好入栈一次、最多出栈一次, - 全程栈操作不超过 2n → O(n)。 + [1,3,2,1,0,0]。每个下标恰好入栈一次、最多出栈一次,全程栈操作不超过 2n → O(n)。 } /> @@ -695,8 +682,7 @@ export default function StackChapter() { ), zh: ( <> - 一摞盘子:放和拿都发生在同一端(栈顶), - 所以取走的永远是最后放进去的那个。就这一条规则 —— 后进先出, + 一摞盘子:放和拿都发生在同一端(栈顶),所以取走的永远是最后放进去的那个。就这一条规则 —— 后进先出, Last In, First Out —— 让它天然贴合嵌套、撤销与函数调用。 ), @@ -749,20 +735,13 @@ export default function StackChapter() { zh={ <>

    - 数组和链表给了我们「存一串东西」的通用能力。但回想几个天天在用的功能: - 编辑器里按 Ctrl+Z,撤销的一定是最近 - 一次修改;浏览器点「后退」,回到的一定是上一个页面; - 一个函数调另一个函数,最后被调用的那个总是最先结束。 - 三件事长得完全不同,骨架却一模一样: + 数组和链表给了我们「存一串东西」的通用能力。但回想几个天天在用的功能:编辑器里按 Ctrl+Z,撤销的一定是最近 + 一次修改;浏览器点「后退」,回到的一定是上一个页面;一个函数调另一个函数,最后被调用的那个总是最先结束。三件事长得完全不同,骨架却一模一样: 最近发生的,最先被处理。

    为这类问题,我们把「随便存取」的自由整个交出去,换一个受限的结构: - 栈(Stack)。想象食堂里那摞叠起来的盘子: - 洗好的盘子放最上面,取盘子也从最上面拿,于是最后放上去的最先被拿走。 - 机制就是这一句:放入和取出发生在同一端,所以取走的永远是最后放进去的那个。 - 这条规则叫 LIFO(Last In, First Out,后进先出)。 - 栈只立三条规矩: + 栈(Stack)。想象食堂里那摞叠起来的盘子:洗好的盘子放最上面,取盘子也从最上面拿,于是最后放上去的最先被拿走。机制就是这一句:放入和取出发生在同一端,所以取走的永远是最后放进去的那个。这条规则叫 LIFO(Last In, First Out,后进先出)。栈只立三条规矩:

    } @@ -785,8 +764,7 @@ export default function StackChapter() { } zh={

    - 放(push)、拿(pop)、看(peek)都只作用于栈顶。 - 栈不提供「动中间那一个」的操作 —— 想拿到它,得先把上面的全拿走。 + 放(push)、拿(pop)、看(peek)都只作用于栈顶。栈不提供「动中间那一个」的操作 —— 想拿到它,得先把上面的全拿走。

    } /> @@ -807,8 +785,7 @@ export default function StackChapter() { } zh={

    - 最后进来的最先出去。这不是限制的副作用,而是目的本身: - 嵌套、撤销、回退,本来就是「最近优先」的顺序。 + 最后进来的最先出去。这不是限制的副作用,而是目的本身:嵌套、撤销、回退,本来就是「最近优先」的顺序。

    } /> @@ -858,9 +835,7 @@ export default function StackChapter() { } zh={

    - 数据结构不只看「能做什么」,也看「禁止做什么」。栈禁止访问中间, - 换回来的是:每个操作都快、顺序绝不出错、实现极其简单。以后看到 - 「最近的先处理」「嵌套配对」「撤销 / 回退」,第一个该想到的就是 + 数据结构不只看「能做什么」,也看「禁止做什么」。栈禁止访问中间,换回来的是:每个操作都快、顺序绝不出错、实现极其简单。以后看到「最近的先处理」「嵌套配对」「撤销 / 回退」,第一个该想到的就是 栈。

    } @@ -881,9 +856,7 @@ export default function StackChapter() { } zh={

    - 编辑器的撤销栈、浏览器的历史栈、JSON / HTML 解析器的标签栈、 - 表达式求值,以及每一门语言运行时的函数调用栈(§03 会讲)…… - 你写的每一行代码,其实都跑在一个栈上。 + 编辑器的撤销栈、浏览器的历史栈、JSON / HTML 解析器的标签栈、表达式求值,以及每一门语言运行时的函数调用栈(§03 会讲)……你写的每一行代码,其实都跑在一个栈上。

    } /> @@ -919,11 +892,9 @@ export default function StackChapter() { } zh={

    - 打开内存看,栈没有自己独特的物理布局:它要么住在 - 一个数组里,要么住在一个链表里。「栈」这个词描述的是 + 打开内存看,栈没有自己独特的物理布局:它要么住在一个数组里,要么住在一个链表里。「栈」这个词描述的是 接口与纪律(只准从一端进出),而不是存储方式 —— - 这叫抽象数据类型(ADT,Abstract Data Type)。 - 同一份合同,两种履行方式: + 这叫抽象数据类型(ADT,Abstract Data Type)。同一份合同,两种履行方式:

    } /> @@ -951,10 +922,7 @@ export default function StackChapter() {

    为什么数组实现的栈顶在尾部,链表实现的栈顶却在 头部?两次的答案是同一句: - 选那端不用搬家、也不用遍历的。 - 数组尾部追加 / 删除是 O(1),放在头部则每次都要全体搬家; - 链表头插 / 头删是 O(1),删尾节点还得从头找到它的前驱。 - 结构变了,原则没变。 + 选那端不用搬家、也不用遍历的。数组尾部追加 / 删除是 O(1),放在头部则每次都要全体搬家;链表头插 / 头删是 O(1),删尾节点还得从头找到它的前驱。结构变了,原则没变。

    } /> @@ -987,15 +955,8 @@ export default function StackChapter() { } zh={

    - 绝大多数标准库选数组实现:连续内存对 CPU 缓存友好, - 扩容的成本也可以接受 —— 单次扩容要复制 n 个元素, - 但容量是按当前规模成比例增长的,摊到每次 push 上是常数。 - 所以数组实现的 push 是均摊 O(1),而不是最坏情况 O(1)。 - 链表实现正相反:push 最坏情况也是 O(1),不会有扩容抖动; - 代价是每次 push 都要分配一个节点,每个节点多背一个{" "} - next 指针,而且节点散落在堆上,访问不如数组缓存友好。 - 「每次操作耗时稳定」这一点在实时性敏感的场景(某些嵌入式 / 音频系统) - 才更重要。Java 的 ArrayDeque、Python 的{" "} + 绝大多数标准库选数组实现:连续内存对 CPU 缓存友好,扩容的成本也可以接受 —— 单次扩容要复制 n 个元素,但容量是按当前规模成比例增长的,摊到每次 push 上是常数。所以数组实现的 push 是均摊 O(1),而不是最坏情况 O(1)。链表实现正相反:push 最坏情况也是 O(1),不会有扩容抖动;代价是每次 push 都要分配一个节点,每个节点多背一个{" "} + next 指针,而且节点散落在堆上,访问不如数组缓存友好。「每次操作耗时稳定」这一点在实时性敏感的场景(某些嵌入式 / 音频系统)才更重要。Java 的 ArrayDeque、Python 的{" "} list、JS 的 Array 全是数组路线。

    } @@ -1170,9 +1131,7 @@ export default function StackChapter() { ArrayDeque 抛 NoSuchElementException ,Python 抛 IndexError,而 JS 的{" "} [].pop() 不报错,直接返回{" "} - undefined —— 静默的那个最危险,错误会跑到很远的地方才暴露。 - 所以模板永远是:先判空,再动手。LC 20 就是直接的例子: - 栈空却遇到右括号,可以立刻返回 false。 + undefined —— 静默的那个最危险,错误会跑到很远的地方才暴露。所以模板永远是:先判空,再动手。LC 20 就是直接的例子:栈空却遇到右括号,可以立刻返回 false。

    } /> @@ -1196,10 +1155,7 @@ export default function StackChapter() {

    接下来看栈在计算机里最重要的一份工作 ——{" "} 调用栈(call stack)。每调用一个函数,就压入一个 - 栈帧(stack frame):帧里装着参数、局部变量, - 以及返回地址(调用方从哪一行继续执行)。 - 每次 return 弹掉一帧。下面是一段小程序, - 演示会逐帧执行它(第 2 行是 main 等待 f 返回的地方): + 栈帧(stack frame):帧里装着参数、局部变量,以及返回地址(调用方从哪一行继续执行)。每次 return 弹掉一帧。下面是一段小程序,演示会逐帧执行它(第 2 行是 main 等待 f 返回的地方):

    } /> @@ -1313,13 +1269,9 @@ main();`, } zh={

    - 操作系统会在内存里划出一块区域专门存放调用栈(称为栈区),每个线程一块。 - 默认大小通常在 1 MB(Windows 和 JVM 线程)到 8 MB(Linux 主线程)之间, - Java 用 -Xss 调整,Linux 用 ulimit -s 调整。一个栈帧几十到几百字节,所以递归深度上限 - 大致在几万层;Python 干脆自己设限,默认只允许 1000 层。 - 这就是为什么处理超深的树 / 图时,工程代码常把递归改写成 - 显式栈 + 迭代(§06 套路三)。任何递归都能这样改写, - 只是改完不一定更好读。 + 操作系统会在内存里划出一块区域专门存放调用栈(称为栈区),每个线程一块。默认大小通常在 1 MB(Windows 和 JVM 线程)到 8 MB(Linux 主线程)之间, + Java 用 -Xss 调整,Linux 用 ulimit -s 调整。一个栈帧几十到几百字节,所以递归深度上限大致在几万层;Python 干脆自己设限,默认只允许 1000 层。这就是为什么处理超深的树 / 图时,工程代码常把递归改写成 + 显式栈 + 迭代(§06 套路三)。任何递归都能这样改写,只是改完不一定更好读。

    } /> @@ -1354,10 +1306,7 @@ main();`, } zh={

    - 用数组章的动态数组亲手造一个栈。你会发现所谓「实现栈」, - 其实就是把数组的能力锁小:只暴露尾部操作, - 其余全部藏起来。Java 版连扩容都自己写,Python / JS 的动态数组自带扩容, - 代码更短 —— 但内存里发生的事完全相同: + 用数组章的动态数组亲手造一个栈。你会发现所谓「实现栈」,其实就是把数组的能力锁小:只暴露尾部操作,其余全部藏起来。Java 版连扩容都自己写,Python / JS 的动态数组自带扩容,代码更短 —— 但内存里发生的事完全相同:

    } /> @@ -1434,8 +1383,7 @@ public class ArrayStack { ), zh: ( <> - 为什么 pop 只是 size 减一:旧值还躺在数组里, - 但已经在「合同」之外,下次 push 会直接覆盖它。删除 = 约定不再看它。 + 为什么 pop 只是 size 减一:旧值还躺在数组里,但已经在「合同」之外,下次 push 会直接覆盖它。删除 = 约定不再看它。 ), }, @@ -1503,9 +1451,7 @@ public class ArrayStack { ), zh: ( <> - 为什么还要封装?list 本来就能 append / pop, - 但拿到 ArrayStack 的人只能按 LIFO 用。 - 把能力锁小,一整类错误就没有发生的机会。 + 为什么还要封装?list 本来就能 append / pop,但拿到 ArrayStack 的人只能按 LIFO 用。把能力锁小,一整类错误就没有发生的机会。 ), }, @@ -1566,8 +1512,7 @@ public class ArrayStack { zh: ( <> 注意:这里主动抛错,补上了原生 Array.pop(){" "} - 在空数组上静默返回 undefined 的问题 —— 封装的另一个价值: - 把语言层面的陷阱挡在算法之外。 + 在空数组上静默返回 undefined 的问题 —— 封装的另一个价值:把语言层面的陷阱挡在算法之外。 ), }, @@ -1663,12 +1608,9 @@ old.push(1); // 能用,但每个方法都带着同步锁`, ,每个方法都是 synchronized ,单线程代码也要付这份用不到的加锁成本;作为 Vector 它还暴露{" "} get(i)、insertElementAt - ,调用方随时能读中间、往中间插,LIFO 纪律形同虚设。 - JDK 文档写明:栈操作应优先使用 ArrayDeque。 - 另外 Deque 有两个细节要记住:push /{" "} + ,调用方随时能读中间、往中间插,LIFO 纪律形同虚设。JDK 文档写明:栈操作应优先使用 ArrayDeque。另外 Deque 有两个细节要记住:push /{" "} pop 都作用在头部 - ,所以把 ArrayDeque 当栈遍历时,顺序是从栈顶到栈底; - 而且 ArrayDeque 不允许存 null。 + ,所以把 ArrayDeque 当栈遍历时,顺序是从栈顶到栈底;而且 ArrayDeque 不允许存 null。 ), }, @@ -1709,8 +1651,7 @@ empty = not stack # 判空的惯用写法 zh: ( <> 易错点:写成 pop(0){" "} - 就变成了头删,其余元素要整体前移,O(n)(数组章讲过)。 - 栈操作永远是不带参数的 pop()。 + 就变成了头删,其余元素要整体前移,O(n)(数组章讲过)。栈操作永远是不带参数的 pop()。 ), }, @@ -1914,8 +1855,7 @@ const empty = stack.length === 0;`, } zh={

    - 括号匹配、相邻消除、嵌套解码:凡是「右半边要找最近的左半边」 - ,就把左半边压栈等着。LC 20、1047、394、150。 + 括号匹配、相邻消除、嵌套解码:凡是「右半边要找最近的左半边」,就把左半边压栈等着。LC 20、1047、394、150。

    } /> @@ -1939,8 +1879,7 @@ const empty = stack.length === 0;`, zh={

    「每个元素右边第一个比它大 / 小的是谁?」→ 单调栈 - ,一遍扫描回答所有问题,把 O(n²) 压成 O(n)。LC 739、496、503、84、42。 - 下面专题详讲。 + ,一遍扫描回答所有问题,把 O(n²) 压成 O(n)。LC 739、496、503、84、42。下面专题详讲。

    } /> @@ -1963,9 +1902,7 @@ const empty = stack.length === 0;`, } zh={

    - 递归本来就跑在调用栈上 —— 把系统的栈换成自己创建的显式栈, - 任何递归都能改成迭代,也就不再受调用栈深度的限制。 - 二叉树迭代遍历(第 7 章)。 + 递归本来就跑在调用栈上 —— 把系统的栈换成自己创建的显式栈,任何递归都能改成迭代,也就不再受调用栈深度的限制。二叉树迭代遍历(第 7 章)。

    } /> @@ -1993,13 +1930,8 @@ const empty = stack.length === 0;`, zh={

    单调栈(monotonic stack) - 是一种栈内元素始终保持有序的栈,靠「入栈前先弹」来维持这个顺序。 - 以「找下一个更大元素」为例:栈里存的是 - 答案还未确定的元素下标,它们的值自底向上不递增。 - 新元素比栈顶大时,它就是栈顶右边第一个更大的元素,于是弹出栈顶并记下答案。 - 为什么被弹出的下标可以彻底忘掉?因为它右边第一个更大的元素刚刚已经找到, - 答案当场记下;栈里只保留还在等待答案的下标,所以它之后不会再被用到。 - 三个问题想清楚,单调栈就通了: + 是一种栈内元素始终保持有序的栈,靠「入栈前先弹」来维持这个顺序。以「找下一个更大元素」为例:栈里存的是 + 答案还未确定的元素下标,它们的值自底向上不递增。新元素比栈顶大时,它就是栈顶右边第一个更大的元素,于是弹出栈顶并记下答案。为什么被弹出的下标可以彻底忘掉?因为它右边第一个更大的元素刚刚已经找到,答案当场记下;栈里只保留还在等待答案的下标,所以它之后不会再被用到。三个问题想清楚,单调栈就通了:

    } /> @@ -2049,8 +1981,7 @@ const empty = stack.length === 0;`, } zh={

    - 新元素破坏单调性时:不递增栈里来了个比栈顶大的, - 就用 while 连续弹,直到栈顶不再比它小(或栈空),然后新元素入栈。 + 新元素破坏单调性时:不递增栈里来了个比栈顶大的,就用 while 连续弹,直到栈顶不再比它小(或栈空),然后新元素入栈。

    } /> @@ -2077,8 +2008,7 @@ const empty = stack.length === 0;`, zh={

    被弹元素的答案就是「弹它的那个元素」:739 结算等待天数 - (下标之差),84 结算以它为高的矩形,42 结算一层雨水。 - 栈里存下标:下标能推出位置和值,值推不出位置。 + (下标之差),84 结算以它为高的矩形,42 结算一层雨水。栈里存下标:下标能推出位置和值,值推不出位置。

    } /> @@ -2102,9 +2032,7 @@ const empty = stack.length === 0;`, zh={

    while 套在 for 里,看着像 O(n²)?算总账:每个下标 - 恰好入栈一次、最多出栈一次,弹出后不再回来, - 全程栈操作不超过 2n。这和数组扩容用的是同一套均摊分析: - 不要盯着最贵的那一步,要算总数。 + 恰好入栈一次、最多出栈一次,弹出后不再回来,全程栈操作不超过 2n。这和数组扩容用的是同一套均摊分析:不要盯着最贵的那一步,要算总数。

    } /> @@ -2145,10 +2073,8 @@ const empty = stack.length === 0;`,

    题意:只含六种括号的字符串,判断是否有效 —— 每个右括号都要与 最近的那个尚未配对的同类左括号成对。 - 暴力:反复扫描,把 ()、[]、 - {"{}"} 这样的相邻对删掉,直到删无可删 —— 每轮 O(n), - 最多 n/2 轮,O(n²)。正解:「最近的未配对左括号」 - 就是栈顶,一遍扫描就够。栈适合这道题不是巧合,这就是原因。 + 暴力:反复扫描,把 ()、[]、 + {"{}"} 这样的相邻对删掉,直到删无可删 —— 每轮 O(n),最多 n/2 轮,O(n²)。正解:「最近的未配对左括号」就是栈顶,一遍扫描就够。栈适合这道题不是巧合,这就是原因。

    } /> @@ -2204,9 +2130,7 @@ const empty = stack.length === 0;`, zh: ( <> 另一种写法:遇到左括号时直接压入它期待的右括号(如{" "} - if (c == '(') stack.push(')')), - 比对时一个 stack.pop() != c 就够,不用查配对表。 - 上面的写法压入左括号,是为了让栈的内容和动画一致。 + if (c == '(') stack.push(')')),比对时一个 stack.pop() != c 就够,不用查配对表。上面的写法压入左括号,是为了让栈的内容和动画一致。 ), }, @@ -2354,7 +2278,7 @@ const empty = stack.length === 0;`, O(1) 返回当前最小值 getMin。暴力:getMin 时扫一遍栈, O(n) —— 正是题目要排除的做法。只存一个全局 min 变量呢? push 时更新没问题,可一旦最小值被 pop 走,第二小是谁?不知道,还得扫。 - 正解:问题出在「历史被丢了」,那就把历史留下来:用一个 + 正解:问题出在「历史被丢了」,那就把历史留下来:用一个 辅助栈记录每次 push 时的最小值,两个栈同进同出:

    } @@ -2530,11 +2454,8 @@ MinStack.prototype.getMin = function () { return this.mins.at(-1); }; // O(1)`,

    关键在栈的纪律本身:栈顶被 pop 后,露出来的恰好就是 这个元素 push 之前的那个栈,而辅助栈在那一层存的 min - 记录的正是那个状态的最小值。换成允许中间删除的结构(比如数组), - 这套「历史快照」立刻失效。是 LIFO 让历史可以被正确回滚。 - 追问:「能省点空间吗?」有两条路。一是只在新值 ≤ 当前最小值时才压辅助栈 - (pop 时相等才弹),平均能省,最坏仍是 O(n); - 二是只用一个栈,遇到新的最小值时压入编码差值{" "} + 记录的正是那个状态的最小值。换成允许中间删除的结构(比如数组),这套「历史快照」立刻失效。是 LIFO 让历史可以被正确回滚。 + 追问:「能省点空间吗?」有两条路。一是只在新值 ≤ 当前最小值时才压辅助栈(pop 时相等才弹),平均能省,最坏仍是 O(n);二是只用一个栈,遇到新的最小值时压入编码差值{" "} 2 * val - min,pop 时反解出上一个最小值 —— 额外空间 O(1),但编码后的值可能达到输入范围的约两倍, 32 位会溢出,必须用 64 位运算。 @@ -2559,8 +2480,7 @@ MinStack.prototype.getMin = function () { return this.mins.at(-1); }; // O(1)`, } zh={

    - 四个操作全部 O(1),辅助栈带来 O(n) 额外空间。 - 这道题真正要带走的是一招通用手法: + 四个操作全部 O(1),辅助栈带来 O(n) 额外空间。这道题真正要带走的是一招通用手法: 「随栈的历史变化的量,用第二个栈把每一层都记下来」 —— 很多「设计一个支持 X 的栈」都是它的变体。

    @@ -2607,8 +2527,7 @@ MinStack.prototype.getMin = function () { return this.mins.at(-1); }; // O(1)`, 才会遇到更高的温度(等不到记 0)。暴力:每天向后线性扫, O(n²) —— n = 10⁵ 时约 10¹⁰ 步,会超时。为什么能优化: 暴力的浪费在于反复重扫同一段:74、71、69 都在等 75, - 75 出现时应该一次性把它们全部结算。 - 把「还在等待的日子」存进栈,新入栈的一定比前一个更冷,这就是单调栈: + 75 出现时应该一次性把它们全部结算。把「还在等待的日子」存进栈,新入栈的一定比前一个更冷,这就是单调栈:

    } /> @@ -2751,13 +2670,8 @@ MinStack.prototype.getMin = function () { return this.mins.at(-1); }; // O(1)`, } zh={

    - 时间 O(n):每个下标入栈一次、最多出栈一次, - 栈操作总数不超过 2n —— 嵌套循环并不意味着 O(n²)。空间 O(n)。 - 追问一:「为什么栈里存下标不存温度?」—— 结算要算 - 两个下标之差,而且下标能推出温度,温度推不出下标。 - 追问二:「找上一个更大的呢?」—— 同一遍扫描里就有: - 下标 i 入栈前,栈顶就是它左边最近的、温度不低于 T[i] 的那一天。 - 追问三:「数组是环形的呢?」—— 下标跑到 2n − 1、访问时用{" "} + 时间 O(n):每个下标入栈一次、最多出栈一次,栈操作总数不超过 2n —— 嵌套循环并不意味着 O(n²)。空间 O(n)。追问一:「为什么栈里存下标不存温度?」—— 结算要算 + 两个下标之差,而且下标能推出温度,温度推不出下标。追问二:「找上一个更大的呢?」—— 同一遍扫描里就有:下标 i 入栈前,栈顶就是它左边最近的、温度不低于 T[i] 的那一天。追问三:「数组是环形的呢?」—— 下标跑到 2n − 1、访问时用{" "} i % n,第二圈只弹不进(LC 503,题单里有)。

    } @@ -2820,8 +2734,7 @@ MinStack.prototype.getMin = function () { return this.mins.at(-1); }; // O(1)`, zh: ( <> 栈 = 只开一个口的容器:pop / peek / isEmpty / size 都是 - O(1);push 在数组实现下是均摊 O(1),在链表实现下最坏也是 O(1), - 代价是每次都要分配一个节点。数组实现栈顶在尾部 + O(1);push 在数组实现下是均摊 O(1),在链表实现下最坏也是 O(1),代价是每次都要分配一个节点。数组实现栈顶在尾部 、链表实现栈顶在头节点 —— 都是选「不用搬家」的那端。 ), @@ -2836,8 +2749,7 @@ MinStack.prototype.getMin = function () { return this.mins.at(-1); }; // O(1)`, ), zh: ( <> - 「最近的先处理」是栈的信号:撤销、后退、括号配对、嵌套解码、 - 函数调用 —— 嵌套结构本身就是 LIFO 顺序。 + 「最近的先处理」是栈的信号:撤销、后退、括号配对、嵌套解码、函数调用 —— 嵌套结构本身就是 LIFO 顺序。 ), }, @@ -2853,9 +2765,7 @@ MinStack.prototype.getMin = function () { return this.mins.at(-1); }; // O(1)`, ), zh: ( <> - 调用栈:每次调用压一帧(参数、局部变量、返回地址), - 每次 return 弹一帧;递归就是函数不断为自己压帧,太深就是栈溢出。 - 出路是显式栈 + 迭代,任何递归都能这样改写。 + 调用栈:每次调用压一帧(参数、局部变量、返回地址),每次 return 弹一帧;递归就是函数不断为自己压帧,太深就是栈溢出。出路是显式栈 + 迭代,任何递归都能这样改写。 ), }, @@ -2871,9 +2781,7 @@ MinStack.prototype.getMin = function () { return this.mins.at(-1); }; // O(1)`, ), zh: ( <> - 单调栈:找下一个更大时,栈内自底向上不递增; - 新元素破坏单调性就弹栈,弹出的瞬间结算被弹元素的答案; - 每个下标入栈一次、最多出栈一次,所以整趟是 O(n)。 + 单调栈:找下一个更大时,栈内自底向上不递增;新元素破坏单调性就弹栈,弹出的瞬间结算被弹元素的答案;每个下标入栈一次、最多出栈一次,所以整趟是 O(n)。 ), }, diff --git a/app/stack/viz.tsx b/app/stack/viz.tsx index 46a2311..6b81c9f 100644 --- a/app/stack/viz.tsx +++ b/app/stack/viz.tsx @@ -111,8 +111,7 @@ export function StackMemFig() { } zh={ <> - ✓ 栈顶 = 头节点:头插 / 头删只改一根指针,最坏情况也是 O(1), - 而且永不扩容。 + ✓ 栈顶 = 头节点:头插 / 头删只改一根指针,最坏情况也是 O(1),而且永不扩容。
    △ 代价:每次 push 都要分配一个节点,每个节点多背一个 next 指针,节点散落在堆上,访问不如数组缓存友好(链表章讲过)。 @@ -170,8 +169,7 @@ export function StackLab() { } zh={ <> - 栈满(容量 {CAPACITY})。定长数组实现会直接拒绝; - 动态数组实现会先扩容到两倍、复制元素,再收下它 —— + 栈满(容量 {CAPACITY})。定长数组实现会直接拒绝;动态数组实现会先扩容到两倍、复制元素,再收下它 —— 这次复制正是 push 只能算「均摊 O(1)」而不是最坏 O(1) 的原因。 } @@ -193,8 +191,7 @@ export function StackLab() { } zh={ <> - push({v}):新盘子只能放到最上面,不惊动下面任何人 —— O(1)。 - top 自动上移一层。 + push({v}):新盘子只能放到最上面,不惊动下面任何人 —— O(1)。top 自动上移一层。 } />, @@ -238,8 +235,7 @@ export function StackLab() { } zh={ <> - pop() = {top.v}:只能拿最上面的盘子 —— O(1)。 - 想拿栈底的?先把上面的全拿走。这不是缺陷,是纪律。 + pop() = {top.v}:只能拿最上面的盘子 —— O(1)。想拿栈底的?先把上面的全拿走。这不是缺陷,是纪律。 } />, @@ -273,8 +269,7 @@ export function StackLab() { } zh={ <> - peek() = {items[items.length - 1].v}:只看不拿,栈保持原样。 - LC 20、LC 739 这类算法每一步都靠它先看一眼栈顶。 + peek() = {items[items.length - 1].v}:只看不拿,栈保持原样。LC 20、LC 739 这类算法每一步都靠它先看一眼栈顶。 } />, @@ -369,8 +364,7 @@ const CS_FRAMES: CSFrame[] = [ } zh={ <> - 程序一启动,main 的栈帧(stack frame)率先入栈。 - 帧里装着这个函数的参数、局部变量,以及「回到哪一行继续」的返回地址。 + 程序一启动,main 的栈帧(stack frame)率先入栈。帧里装着这个函数的参数、局部变量,以及「回到哪一行继续」的返回地址。 } /> @@ -537,8 +531,7 @@ const CS_FRAMES: CSFrame[] = [ <> 再看一种情况:递归忘了写终止条件,栈帧不断堆积 —— 调用栈的地盘(通常 1~8 MB)几毫秒就被填满,程序崩溃。这就是 - 栈溢出:Java 的 StackOverflowError、Python 的 RecursionError。 - 程序员问答网站 Stack Overflow 的名字就来自它。 + 栈溢出:Java 的 StackOverflowError、Python 的 RecursionError。程序员问答网站 Stack Overflow 的名字就来自它。 } /> diff --git a/app/string/page.tsx b/app/string/page.tsx index 0a042df..4e59e7e 100644 --- a/app/string/page.tsx +++ b/app/string/page.tsx @@ -80,8 +80,7 @@ const F125: ArrayFrame[] = [ } zh={ <> - L 的 A 对 R 的 a:都是字母,统一小写后 a = a,这一对匹配。 - 两个指针各向内走一步。 + L 的 A 对 R 的 a:都是字母,统一小写后 a = a,这一对匹配。两个指针各向内走一步。 } /> @@ -111,8 +110,7 @@ const F125: ArrayFrame[] = [ } zh={ <> - L 现在指着 ?:既不是字母也不是数字,不参与判断,直接跳过,L++。 - 这一步不做比较,只挪指针。 + L 现在指着 ?:既不是字母也不是数字,不参与判断,直接跳过,L++。这一步不做比较,只挪指针。 } /> @@ -141,8 +139,7 @@ const F125: ArrayFrame[] = [ } zh={ <> - R 指着 ,:同样是标点,跳过,R--。每个指针各自清理自己那一侧, - 清理完才做比较。 + R 指着 ,:同样是标点,跳过,R--。每个指针各自清理自己那一侧,清理完才做比较。 } /> @@ -235,8 +232,7 @@ const F3: ArrayFrame[] = [ } zh={ <> - 窗口 [l..r] 是当前「无重复字符」的一段;随身带一个 Set, - 记录窗口里现在有哪些字符。 + 窗口 [l..r] 是当前「无重复字符」的一段;随身带一个 Set,记录窗口里现在有哪些字符。 } /> @@ -398,8 +394,7 @@ const F3: ArrayFrame[] = [ } zh={ <> - r=6:b 再次重复。这次要连吐 s[3]=a、s[4]=b 两个,直到窗口里没有 b。 - 窗口 "cb",长度 2。 + r=6:b 再次重复。这次要连吐 s[3]=a、s[4]=b 两个,直到窗口里没有 b。窗口 "cb",长度 2。 } /> @@ -428,8 +423,7 @@ const F3: ArrayFrame[] = [ zh={ <> r=7 同理,窗口缩到 "b"。扫描结束:最长无重复子串是{" "} - "abc",长度 3。l 和 r 都只前进不后退, - 每个字符最多进出窗口一次,所以是 O(n)。 + "abc",长度 3。l 和 r 都只前进不后退,每个字符最多进出窗口一次,所以是 O(n)。 } /> @@ -456,8 +450,7 @@ const F5: ArrayFrame[] = [ zh={ <> 奇数长的回文,中心是一个字符;偶数长的回文,中心是一个空隙。n - 个字符对应 n 个字符中心加 n−1 个空隙中心,共 2n−1 个中心。 - 挨个试,每个中心向两边扩。 + 个字符对应 n 个字符中心加 n−1 个空隙中心,共 2n−1 个中心。挨个试,每个中心向两边扩。 } /> @@ -547,9 +540,7 @@ const F5: ArrayFrame[] = [ } zh={ <> - 中心 i=2(b):左 a = 右 a,得到 "aba",长度 3,与纪录持平。 - 再扩一层:左 b ≠ 右 d,停。让「扩」这个动作成立的不变量是: - 每一步之前 s[l+1..r−1] 已经是回文,所以只要 s[l] = s[r],s[l..r] + 中心 i=2(b):左 a = 右 a,得到 "aba",长度 3,与纪录持平。再扩一层:左 b ≠ 右 d,停。让「扩」这个动作成立的不变量是:每一步之前 s[l+1..r−1] 已经是回文,所以只要 s[l] = s[r],s[l..r] 就也是回文;一层扩不动,以这个中心为轴的更长回文就不可能存在。 } @@ -577,8 +568,7 @@ const F5: ArrayFrame[] = [ zh={ <> 空隙中心也别忘了。以下标 1 和 2 之间的空隙为例:第一步比 a 和 b - 就失败,长度 0。奇偶两套中心都要枚举,不然 "abba" - 这类偶数长回文会被漏掉。 + 就失败,长度 0。奇偶两套中心都要枚举,不然 "abba"这类偶数长回文会被漏掉。 } /> @@ -602,8 +592,7 @@ const F5: ArrayFrame[] = [ } zh={ <> - 中心 i=3(a):左 b ≠ 右 d,长度 1;中心 i=4(d):长度 1。 - 所有中心试完。 + 中心 i=3(a):左 b ≠ 右 d,长度 1;中心 i=4(d):长度 1。所有中心试完。 } /> @@ -708,9 +697,7 @@ export default function StringChapter() { ), zh: ( <> - 一个不许改字的字符数组:读哪个位置都是 O(1), - 但改一个字就要重抄全文。字符的本质是数字, - 而这一章的许多易错点,都与「数字如何变成文字」的编码表有关。 + 一个不许改字的字符数组:读哪个位置都是 O(1),但改一个字就要重抄全文。字符的本质是数字,而这一章的许多易错点,都与「数字如何变成文字」的编码表有关。 ), }} @@ -748,12 +735,9 @@ export default function StringChapter() { } zh={ <> - 上一章的数组像一排可擦写的白板格子: - 想改哪格,擦掉重写就行。字符串像 + 上一章的数组像一排可擦写的白板格子:想改哪格,擦掉重写就行。字符串像 刻在石板上的一行字 —— - 每个字符仍然有自己的格子,从 0 开始编号,这一点和数组一模一样, - 但刻上去就不能再改。想把「明天下雨」改成「明天放晴」? - 只能重新刻一块石板,把没变的字也一并重刻。 + 每个字符仍然有自己的格子,从 0 开始编号,这一点和数组一模一样,但刻上去就不能再改。想把「明天下雨」改成「明天放晴」?只能重新刻一块石板,把没变的字也一并重刻。 } /> @@ -777,9 +761,7 @@ export default function StringChapter() { <> 这条规则叫不可变(immutable),Java 的{" "} String、Python 的 str、JavaScript - 的字符串都遵守它。它是刻意的设计,不是缺陷 - (下面的故事卡会讲原因)。先记住它的后果:所有看起来在「修改」 - 字符串的操作 —— 拼接、替换、大小写转换 —— 背后都在 + 的字符串都遵守它。它是刻意的设计,不是缺陷(下面的故事卡会讲原因)。先记住它的后果:所有看起来在「修改」字符串的操作 —— 拼接、替换、大小写转换 —— 背后都在 整段重抄。这一个事实,直接决定了本章一半的复杂度结论。 } @@ -798,10 +780,8 @@ export default function StringChapter() { } zh={ <> - 还有一件事必须先说破:格子里存的根本不是「字」。计算机只存数字, - 所谓字符,是数字查了一张编码表(character encoding) - 之后的显示结果:65 查表得 'A',23383 查表得「字」。 - 由此有三条家规: + 还有一件事必须先说破:格子里存的根本不是「字」。计算机只存数字,所谓字符,是数字查了一张编码表(character encoding) + 之后的显示结果:65 查表得 'A',23383 查表得「字」。由此有三条家规: } /> @@ -826,8 +806,7 @@ export default function StringChapter() { zh={ <> 创建之后内容固定。replace、 - toUpperCase、拼接统统返回新字符串, - 原件分毫未动 —— 「修改」的真实成本是重抄一遍,O(n)。 + toUpperCase、拼接统统返回新字符串,原件分毫未动 —— 「修改」的真实成本是重抄一遍,O(n)。 } /> @@ -850,9 +829,7 @@ export default function StringChapter() { } zh={ <> - 每个字符都是编码表里的一个编号:'A' = 65、 - 'a' = 97、「字」= 23383。比较、排序、大小写转换, - 本质全是整数运算。 + 每个字符都是编码表里的一个编号:'A' = 65、'a' = 97、「字」= 23383。比较、排序、大小写转换,本质全是整数运算。 } /> @@ -876,9 +853,7 @@ export default function StringChapter() { } zh={ <> - 字符串就像一个只能读、不能写的字符数组:下标 O(1)、支持遍历和切片。 - 但要注意:一个下标不一定等于一个字符(见 §02)。 - 想「原地修改」,先转成真正可变的字符数组,改完再拼回来。 + 字符串就像一个只能读、不能写的字符数组:下标 O(1)、支持遍历和切片。但要注意:一个下标不一定等于一个字符(见 §02)。想「原地修改」,先转成真正可变的字符数组,改完再拼回来。 } /> @@ -913,14 +888,9 @@ export default function StringChapter() { } zh={ <> - ① 安全:字符串是最常见的哈希键(第 6 章)。如果内容能变, - 存进哈希表后再一改,这个键就永远找不回来了;不可变还让 hashCode - 可以算一次就缓存。② 共享:内容不会变,多个变量就能安全地 - 指向同一份 —— Java 的字符串常量池、Python 的字符串驻留都靠它省内存。 - ③ 多线程免锁:不会变的值,多少线程同时读都不会出事。 - 代价只有一个:频繁修改的场景很贵,所以三种语言都配了「可变的替身」 - —— StringBuilder、Python 的 list、JavaScript 的数组。 - §04 我们亲手造一个。 + ① 安全:字符串是最常见的哈希键(第 6 章)。如果内容能变,存进哈希表后再一改,这个键就永远找不回来了;不可变还让 hashCode + 可以算一次就缓存。② 共享:内容不会变,多个变量就能安全地指向同一份 —— Java 的字符串常量池、Python 的字符串驻留都靠它省内存。③ 多线程免锁:不会变的值,多少线程同时读都不会出事。代价只有一个:频繁修改的场景很贵,所以三种语言都配了「可变的替身」 + —— StringBuilder、Python 的 list、JavaScript 的数组。§04 我们亲手造一个。 } /> @@ -960,11 +930,7 @@ export default function StringChapter() { zh={ <> 1963 年的 ASCII 用 0–127 这 128 - 个数字,给英文字母、数字、标点和控制符各发一个编号: - 一个字符一个字节,天下太平 —— 只要你不说英语以外的语言。 - 汉字有几万个,128 个编号塞不下,于是各地各造各的表(GB2312、Big5、 - Shift-JIS……),同一串字节换个国家打开就是乱码。 - 这就是早年大量「乱码」问题的由来。 + 个数字,给英文字母、数字、标点和控制符各发一个编号:一个字符一个字节,天下太平 —— 只要你不说英语以外的语言。汉字有几万个,128 个编号塞不下,于是各地各造各的表(GB2312、Big5、Shift-JIS……),同一串字节换个国家打开就是乱码。这就是早年大量「乱码」问题的由来。 } /> @@ -986,9 +952,7 @@ export default function StringChapter() { } zh={ <> - Unicode 的解法很直接:把全世界的字符排成一张表, - 每个字符发一个唯一编号,叫码点(code point), - 写作 U+XXXX。'A' 是 U+0041,「字」是 U+5B57,🙂 是 + Unicode 的解法很直接:把全世界的字符排成一张表,每个字符发一个唯一编号,叫码点(code point),写作 U+XXXX。'A' 是 U+0041,「字」是 U+5B57,🙂 是 U+1F642 —— 目前已经发出 15 万多个号。但码点只是编号, 编号怎么存成字节是另一件事,这就是 UTF-8 / UTF-16 / UTF-32 三种存法的分工。看「字」这一个字符在各个层次上的样子: @@ -1109,9 +1073,7 @@ export default function StringChapter() { } zh={ <> - UTF-8 能统治互联网,靠的是变长这一手: - 常用的编码短,罕见的才长,而且纯英文文本按 UTF-8 存下来, - 和 1963 年的 ASCII 字节完全一样。它按码点大小分四档: + UTF-8 能统治互联网,靠的是变长这一手:常用的编码短,罕见的才长,而且纯英文文本按 UTF-8 存下来,和 1963 年的 ASCII 字节完全一样。它按码点大小分四档: } /> @@ -1218,10 +1180,7 @@ export default function StringChapter() { Java 和 JavaScript 按 UTF-16 编码单元索引, length 数的是 16 位单元; Python 3 的 str 是码点序列,len{" "} - 数的是码点。注意码点也不等于「人眼看到的一个字符」: - 带音标的字母若写成基字母加组合符号就是两个码点, - 一个家庭 emoji 更是好几个。这些差异在纯英文文本里看不出来, - 一遇到 emoji 就现形 —— 用下面的实验室亲手验证: + 数的是码点。注意码点也不等于「人眼看到的一个字符」:带音标的字母若写成基字母加组合符号就是两个码点,一个家庭 emoji 更是好几个。这些差异在纯英文文本里看不出来,一遇到 emoji 就现形 —— 用下面的实验室亲手验证: } /> @@ -1256,8 +1215,7 @@ export default function StringChapter() { <> 同一个 "👍":你看到 1 个字符;Java 和 JavaScript 的 length = 2(UTF-16 代理对);按 UTF-8 存是{" "} - 4 字节;Python 的 len 是 1(数码点)。 - 按 length 截断字符串、按字节算数据库字段宽度、逐下标遍历 emoji + 4 字节;Python 的 len 是 1(数码点)。按 length 截断字符串、按字节算数据库字段宽度、逐下标遍历 emoji 文本 —— 三个都是真实事故现场。养成一个习惯: 用到长度之前,先问清它数的是什么单位。 @@ -1295,8 +1253,7 @@ export default function StringChapter() { HTML、JSON、HTTP、Git、Linux 文件名……当今互联网绝大多数文本用 UTF-8 传输。原因有三:① 字节层面兼容 ASCII,老系统无痛过渡; ② 以英文为主的文本比 UTF-16 省将近一半空间;③ 字节流自同步 —— - 从任意位置开始都能找到下一个字符边界(首字节和后续字节的位模式不同)。 - Java/JS 选 UTF-16 是 1990 年代「每个字符 2 字节就够了」的历史遗产, + 从任意位置开始都能找到下一个字符边界(首字节和后续字节的位模式不同)。Java/JS 选 UTF-16 是 1990 年代「每个字符 2 字节就够了」的历史遗产, emoji 的流行让这个假设破产,代理对就是那个补丁。Java 9 之后内部会把纯 Latin-1 的字符串存成字节数组,但 API 仍然按 UTF-16 单元计数,你看到的行为没有变。 @@ -1452,9 +1409,7 @@ export default function StringChapter() { } zh={ <> - 全表最值得深挖的是拼接。单次 s + t 是 O(n+m), - 听着还行 —— 真正的问题是循环里的 +=。 - 假设你逐字符攒一个长度为 n 的字符串: + 全表最值得深挖的是拼接。单次 s + t 是 O(n+m),听着还行 —— 真正的问题是循环里的 +=。假设你逐字符攒一个长度为 n 的字符串: } /> @@ -1500,8 +1455,7 @@ export default function StringChapter() { } zh={ <> - 解药是给字符串找一个可变的替身:先在可变容器里攒, - 最后一次性成串 —— 总量回到 O(n)。亲眼看两种策略赛跑: + 解药是给字符串找一个可变的替身:先在可变容器里攒,最后一次性成串 —— 总量回到 O(n)。亲眼看两种策略赛跑: } /> @@ -1548,16 +1502,11 @@ export default function StringChapter() { —— 因为字符串都没法原地续写。Java:Java 8 及以前,编译器把{" "} s += x 展开成「每一圈都 new 一个{" "} StringBuilder 再 toString()」; - Java 9 起改用 invokedynamic(StringConcatFactory)。 - 无论哪种,每一圈都会生成一个新字符串,总量仍是 O(n²), - 必须自己在循环外维护一个 builder。Python: - CPython 有一个优化:当字符串只剩一个引用时可以原地扩容; - 但语言规范不保证,只要多出第二个引用就失效 —— + Java 9 起改用 invokedynamic(StringConcatFactory)。无论哪种,每一圈都会生成一个新字符串,总量仍是 O(n²),必须自己在循环外维护一个 builder。Python: + CPython 有一个优化:当字符串只剩一个引用时可以原地扩容;但语言规范不保证,只要多出第二个引用就失效 —— 惯用法是先把各段收集到 list 里,最后调用一次 "".join()。 JavaScript:V8 把 a + b{" "} - 先表示成一个指向两半的小节点,真正读取时才展平, - 所以循环 += 在实践中常常快于 n²,但展平的成本和内存峰值仍然要付, - 而且没有任何引擎承诺这件事。结论在三种语言里一致: + 先表示成一个指向两半的小节点,真正读取时才展平,所以循环 += 在实践中常常快于 n²,但展平的成本和内存峰值仍然要付,而且没有任何引擎承诺这件事。结论在三种语言里一致: 显式使用可变容器,不要依赖引擎优化。 } @@ -1598,9 +1547,7 @@ export default function StringChapter() { <> 轮子一:字符串构建器。 原理和上一章的动态数组一模一样:内部一个可变字符数组, - append 往空位写(均摊 O(1)),满了就容量翻倍搬一次家, - 只有最后 build 时才一次性拷贝成不可变字符串。 - 看懂它,就看懂了「为什么攒着最后一次成串是 O(n)」: + append 往空位写(均摊 O(1)),满了就容量翻倍搬一次家,只有最后 build 时才一次性拷贝成不可变字符串。看懂它,就看懂了「为什么攒着最后一次成串是 O(n)」: } /> @@ -1678,8 +1625,7 @@ class MyStringBuilder { zh: ( <> JDK 的 StringBuilder 就是这个思路(扩容策略是 - ×2+2)。n 次 append 的总工作量 = n 次写入 + 扩容搬家的拷贝, - 而搬家的拷贝加起来不到 2n,所以整体 O(n),对比循环 += 的 O(n²)。 + ×2+2)。n 次 append 的总工作量 = n 次写入 + 扩容搬家的拷贝,而搬家的拷贝加起来不到 2n,所以整体 O(n),对比循环 += 的 O(n²)。 ), }, @@ -1791,8 +1737,7 @@ console.log(sb.build()); // "hello"`, ), zh: ( <> - V8 让小规模的 += 也不慢,但「攒数组 + join」 - 在任何引擎、任何规模下都稳定 O(n) —— 写库代码时选它。 + V8 让小规模的 += 也不慢,但「攒数组 + join」在任何引擎、任何规模下都稳定 O(n) —— 写库代码时选它。 ), }, @@ -1814,8 +1759,7 @@ console.log(sb.build()); // "hello"`, zh={ <> 轮子二:朴素子串查找(indexOf)。 - 在长度 n 的文本里找长度 m 的模式串:挨个起点试, - 失败就把起点右移一格、从头再比。思路直白,最坏 O(n·m): + 在长度 n 的文本里找长度 m 的模式串:挨个起点试,失败就把起点右移一格、从头再比。思路直白,最坏 O(n·m): } /> @@ -1943,12 +1887,9 @@ console.log(sb.build()); // "hello"`, } zh={ <> - 思想课:KMP —— 失败也是情报。朴素法慢在哪? - 看一次典型的失败:在文本 ABABABC 里找{" "} - ABABC,前 4 个字符 ABAB 都对上了, - 第 5 个失配。朴素法的反应是:起点右移一格, - 把刚才的记忆全部清零,从头再比。 - 但我们明明已经知道文本那 4 个字符就是 ABAB —— + 思想课:KMP —— 失败也是情报。朴素法慢在哪?看一次典型的失败:在文本 ABABABC 里找{" "} + ABABC,前 4 个字符 ABAB 都对上了,第 5 个失配。朴素法的反应是:起点右移一格, + 把刚才的记忆全部清零,从头再比。但我们明明已经知道文本那 4 个字符就是 ABAB —— 这份情报被白白扔掉了。 } @@ -2008,10 +1949,8 @@ KMP: "ABAB" 的开头和结尾最长重叠是 "AB"(长度 2) } zh={ <> - KMP(Knuth–Morris–Pratt)的全部秘密就在上图那一步「滑」:失配时, - 已匹配段 ABAB 的结尾和模式串的 - 开头有一段最长重叠 AB。 - 把模式滑到重叠对齐的位置,重叠部分不用重比 —— + KMP(Knuth–Morris–Pratt)的全部秘密就在上图那一步「滑」:失配时,已匹配段 ABAB 的结尾和模式串的 + 开头有一段最长重叠 AB。把模式滑到重叠对齐的位置,重叠部分不用重比 —— 它们刚刚才比过,一定相等 —— 直接从重叠长度处继续。于是文本指针 永远不回退,整体 O(n+m)。 @@ -2049,8 +1988,7 @@ KMP: "ABAB" 的开头和结尾最长重叠是 "AB"(长度 2) 的最长真前缀的长度,且这个真前缀同时是{" "} pattern[0..i] 的后缀 - 。「真」= 不能是 pattern[0..i] 自己, - 否则答案永远是 i+1。以 ABABC 为例: + 。「真」= 不能是 pattern[0..i] 自己,否则答案永远是 i+1。以 ABABC 为例: } /> @@ -2186,11 +2124,8 @@ KMP: "ABAB" 的开头和结尾最长重叠是 "AB"(长度 2) 在 j 处失配,就让 j 跳到 next[j−1] 再试一次;只有 j 已经是 0 时,文本指针才前进 - 。为什么这样不会漏解?因为 next 给的是最长重叠, - 而所有更短的对齐方式都由「再跳一次」覆盖 —— - 跳一次不行就再跳,沿着链一路退到 0。完整实现如下, - 预处理 next 的代码和匹配的代码长得几乎一样, - 因为预处理本质就是「模式串自己和自己做匹配」: + 。为什么这样不会漏解?因为 next 给的是最长重叠,而所有更短的对齐方式都由「再跳一次」覆盖 —— + 跳一次不行就再跳,沿着链一路退到 0。完整实现如下,预处理 next 的代码和匹配的代码长得几乎一样,因为预处理本质就是「模式串自己和自己做匹配」: } /> @@ -2402,15 +2337,8 @@ KMP: "ABAB" 的开头和结尾最长重叠是 "AB"(长度 2) } zh={ <> - 预处理 O(m) + 匹配 O(n) = O(n+m),额外空间 O(m)。 - 内层的 while 让匹配循环看起来像 O(n·m), - 但换个角度:别数循环,数 j 的变化。外层每转一圈,j 最多加 1, - 所以整趟下来 j 增加的次数不超过 n。内层每转一圈,j 被设成{" "} - next[j−1],而它严格小于 j,所以 j 至少减 1, - 而且 j 永远不小于 0。一个总共只上升 n 次、又不能低于 0 的量, - 总共最多被下降 n 次 —— 因此所有内层循环加起来最多跑 n 次, - 匹配就是 O(n)。预处理里把 j 换成 k、n 换成 m,论证完全一样。 - 面试很少让你默写 KMP,但常考三连问:暴力浪费了什么情报 + 预处理 O(m) + 匹配 O(n) = O(n+m),额外空间 O(m)。内层的 while 让匹配循环看起来像 O(n·m),但换个角度:别数循环,数 j 的变化。外层每转一圈,j 最多加 1,所以整趟下来 j 增加的次数不超过 n。内层每转一圈,j 被设成{" "} + next[j−1],而它严格小于 j,所以 j 至少减 1,而且 j 永远不小于 0。一个总共只上升 n 次、又不能低于 0 的量,总共最多被下降 n 次 —— 因此所有内层循环加起来最多跑 n 次,匹配就是 O(n)。预处理里把 j 换成 k、n 换成 m,论证完全一样。面试很少让你默写 KMP,但常考三连问:暴力浪费了什么情报 (已匹配段的内容是已知的)、next 数组是什么 (每个前缀的最长真前缀且同时是后缀的长度)、 为什么主串指针不用回退(滑动后重叠段已知相等)。 @@ -2517,8 +2445,7 @@ String s = sb.toString(); // "012" zh: ( <> 易错点:== 比引用,equals{" "} - 比内容。两个字面量因为常量池碰巧 == 为 true, - 这正是最容易骗新手的地方。面试追问:StringBuilder(快,不加锁) + 比内容。两个字面量因为常量池碰巧 == 为 true,这正是最容易骗新手的地方。面试追问:StringBuilder(快,不加锁) vs StringBuffer(线程安全,慢),单线程一律前者。 ), @@ -2574,9 +2501,7 @@ s = "".join(parts) # "012":整体 O(n)`, ), zh: ( <> - 易错点:s[0] 返回的是 str 不是数字, - 想拿码点要用 ord();str 是码点序列, - 所以 len("👍") = 1 —— + 易错点:s[0] 返回的是 str 不是数字,想拿码点要用 ord();str 是码点序列,所以 len("👍") = 1 —— 三种语言里唯一数成 1 的。 ), @@ -2636,8 +2561,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, 易错点:处理可能含 emoji 的文本,遍历用{" "} for...of、计数用 Array.from(s).length 、取码点用 codePointAt。charAt、 - length、普通下标都是按 UTF-16 单元算的, - 会把代理对劈成两半。 + length、普通下标都是按 UTF-16 单元算的,会把代理对劈成两半。 ), }, @@ -2853,11 +2777,9 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, * JavaScript 的{" "} s.replace("a", "b"){" "} 传字符串时只替换第一处 —— 想全换用 replaceAll - ,或者正则加 g 标志,这是从其他语言迁移过来最容易踩的一脚。 - † O(n·m) 是朴素扫描的最坏情况,Java 的 indexOf{" "} + ,或者正则加 g 标志,这是从其他语言迁移过来最容易踩的一脚。† O(n·m) 是朴素扫描的最坏情况,Java 的 indexOf{" "} 就是这么做的;CPython 的 str.find{" "} - 用了更聪明的算法,最坏 O(n+m),JavaScript 各引擎实现不一。 - 另外,所有「返回新串」的操作,空间也都是 O(n)。 + 用了更聪明的算法,最坏 O(n+m),JavaScript 各引擎实现不一。另外,所有「返回新串」的操作,空间也都是 O(n)。 } /> @@ -2896,8 +2818,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, zh={ <> 「内容相同的字符串共享同一份内存」有个学名: - 字符串驻留(string interning)。Java 把编译期字面量放进常量池, - 运行期可以手动 s.intern();Python + 字符串驻留(string interning)。Java 把编译期字面量放进常量池,运行期可以手动 s.intern();Python 会自动驻留标识符样式的短字符串,所以{" "} "abc" is "abc"{" "} 有时为 True —— 那是实现细节,不是承诺: @@ -2905,11 +2826,9 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, 判相等用 ==,is{" "} 只用来问「是不是同一个对象」 - 。JavaScript 引擎内部也做同样的共享,但你永远看不见, - 因为 === 对字符串比的是值。所以三种语言的差别正在这里: + 。JavaScript 引擎内部也做同样的共享,但你永远看不见,因为 === 对字符串比的是值。所以三种语言的差别正在这里: Java 必须用 equals,Python 要用 =={" "} - 而不是 is,JavaScript 没有对应的陷阱。 - 这一切成立的前提正是 §01 的不可变 —— + 而不是 is,JavaScript 没有对应的陷阱。这一切成立的前提正是 §01 的不可变 —— 内容会变的对象,谁敢共享? } @@ -2955,11 +2874,9 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, } zh={ <> - 字符串的套路和数组高度同源(两者的结构本来就很接近),但有三种套路属于这一章。 - 拿到题先对信号:回文 / 反转 → 对撞指针; + 字符串的套路和数组高度同源(两者的结构本来就很接近),但有三种套路属于这一章。拿到题先对信号:回文 / 反转 → 对撞指针; 连续子串的最值 → 滑动窗口; - 异位词 / 字符统计 → 计数数组。 - 这三种套路能解决本章题单的大部分题目: + 异位词 / 字符统计 → 计数数组。这三种套路能解决本章题单的大部分题目: } /> @@ -2986,9 +2903,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, } zh={ <> - 两端出发,每步比较一对字符。不变量是:区间之外的部分都已核对完毕。 - 回文验证、原地反转的标配;「中心扩展」是它的反向版: - 从中间向两边推。见 LC 125、344、5。 + 两端出发,每步比较一对字符。不变量是:区间之外的部分都已核对完毕。回文验证、原地反转的标配;「中心扩展」是它的反向版:从中间向两边推。见 LC 125、344、5。 } /> @@ -3013,8 +2928,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, } zh={ <> - 「答案是连续子串 + 窗口内条件可增量维护」= 滑窗。 - 窗口里养一个 Set 或计数器,右端吃、左端吐。见 LC 3、438、76。 + 「答案是连续子串 + 窗口内条件可增量维护」= 滑窗。窗口里养一个 Set 或计数器,右端吃、左端吐。见 LC 3、438、76。 } /> @@ -3039,9 +2953,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, } zh={ <> - 字符集有限且已知时(26 个小写字母 / 128 个 ASCII 码), - 用定长 int 数组代替哈希表:更快、更省、代码更短。 - 见 LC 242、438、383。输入是任意 Unicode 时,还是要换回哈希表。 + 字符集有限且已知时(26 个小写字母 / 128 个 ASCII 码),用定长 int 数组代替哈希表:更快、更省、代码更短。见 LC 242、438、383。输入是任意 Unicode 时,还是要换回哈希表。 } /> @@ -3226,8 +3138,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, zh={ <> 时间 O(n)(每个字符最多被 l 或 r 看一次),额外空间{" "} - O(1)。对比「过滤 + 反转」解法的 O(n) 额外空间, - 这就是原地双指针的意义。追问一:「如果允许 + O(1)。对比「过滤 + 反转」解法的 O(n) 额外空间,这就是原地双指针的意义。追问一:「如果允许 删除至多一个字符再判回文?」(LC 680:失配时分两路试 l+1 和 r−1,各自继续对撞)。追问二:「为什么内层 while 也要带{" "} l < r?」(全是标点的串会让指针越界 —— @@ -3277,8 +3188,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, 题意:求最长的、不含重复字符的连续子串长度。 暴力:枚举所有子串的起终点、逐个检查有无重复,O(n³) (加 Set 可优化到 O(n²))。正解: - 滑窗的两大信号全亮 —— 连续,且条件可增量维护。关键洞察: - 窗口 [l..r] 无重复时,撞上重复字符只需要从左边吐, + 滑窗的两大信号全亮 —— 连续,且条件可增量维护。关键洞察:窗口 [l..r] 无重复时,撞上重复字符只需要从左边吐, l 绝不需要回头 —— 更靠左的起点仍然包含刚被吐掉的那个重复字符: @@ -3427,12 +3337,8 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, <> 循环不变量:每一步结束时,s[l..r] 里没有重复字符。l 和 r 各自最多向前走 n 步,每个字符进出 Set 各一次,所以时间{" "} - O(n),空间 O(min(n, 字符集大小))。追问一: - 「怎么再快一点?」用哈希表记每个字符最后出现的位置, - 撞上重复时 l 直接跳过去,省掉内层循环。追问二: - 「输入含 emoji 怎么办?」§02 学的立刻用上:JavaScript 里要先{" "} - Array.from(s) 按码点切分, - 否则代理对会被劈成两个不是字符的半块。 + O(n),空间 O(min(n, 字符集大小))。追问一:「怎么再快一点?」用哈希表记每个字符最后出现的位置,撞上重复时 l 直接跳过去,省掉内层循环。追问二:「输入含 emoji 怎么办?」§02 学的立刻用上:JavaScript 里要先{" "} + Array.from(s) 按码点切分,否则代理对会被劈成两个不是字符的半块。 } /> @@ -3483,10 +3389,8 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, 题意:找出最长的回文连续子串 (注意是寻找,不是判断)。暴力:枚举所有 O(n²) 个子串、每个花 O(n) 验证 → O(n³)。正解:换个枚举对象 —— - 别枚举子串,枚举回文的中心。回文有个好性质: - 两端各去掉一个字符仍是回文;反过来说, - 从中心一层层向外扩,扩到两侧不等为止, - 就是该中心的最长回文。中心只有 2n−1 个:n 个字符加 n−1 个空隙。 + 别枚举子串,枚举回文的中心。回文有个好性质:两端各去掉一个字符仍是回文;反过来说, + 从中心一层层向外扩,扩到两侧不等为止,就是该中心的最长回文。中心只有 2n−1 个:n 个字符加 n−1 个空隙。 } /> @@ -3669,10 +3573,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, 已经是回文,所以只要 s[l] = s[r],s[l..r] 就也是回文。2n−1 个中心 × 每个最多扩 O(n) 层 → 时间 O(n²),额外空间{" "} O(1)。追问一:「有没有更快的?」有, - Manacher 算法 O(n):在字符间插分隔符统一奇偶, - 再复用已知回文的对称信息;实现复杂,面试说出名字和思想通常就够了。 - 追问二:「动态规划行不行?」行,dp[i][j] 表示 s[i..j] 是否回文, - 同样 O(n²) 时间,但要 O(n²) 空间 —— 中心扩展在空间上明显更优。 + Manacher 算法 O(n):在字符间插分隔符统一奇偶,再复用已知回文的对称信息;实现复杂,面试说出名字和思想通常就够了。追问二:「动态规划行不行?」行,dp[i][j] 表示 s[i..j] 是否回文,同样 O(n²) 时间,但要 O(n²) 空间 —— 中心扩展在空间上明显更优。 } /> @@ -3733,8 +3634,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, ), zh: ( <> - 字符串 = 不可变的字符数组:读 O(1),任何「修改」都是重抄。 - 拼接 O(n+m),循环里的 += 在 Java、Python、JavaScript 里都是 + 字符串 = 不可变的字符数组:读 O(1),任何「修改」都是重抄。拼接 O(n+m),循环里的 += 在 Java、Python、JavaScript 里都是 O(n²) —— 用 StringBuilder、list 或数组攒着,最后一次性成串。 ), @@ -3771,8 +3671,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, zh: ( <> 子串查找:朴素 O(n·m)。KMP 用前缀函数 - (next[i] = pattern[0..i] 的最长真前缀且同时是后缀的长度), - 让主串指针永不回退 → O(n+m)。记住三问:暴力浪费了什么、next + (next[i] = pattern[0..i] 的最长真前缀且同时是后缀的长度),让主串指针永不回退 → O(n+m)。记住三问:暴力浪费了什么、next 是什么、为什么不用回退。 ), @@ -3810,8 +3709,7 @@ for (const ch of "a👍") console.log(ch); // 'a'、'👍':按码点遍历`, <> 语言差异速查:Java == 比引用、equals 比内容;Python 没有 char(长度 1 的 str),拼接用 join,而且 is 不是{" "} - ==;JavaScript 的 === 对字符串比的是值, - 但 charAt 会劈开代理对,处理 emoji 用{" "} + ==;JavaScript 的 === 对字符串比的是值,但 charAt 会劈开代理对,处理 emoji 用{" "} for...of / codePointAt。 ), diff --git a/app/string/viz.tsx b/app/string/viz.tsx index 1378169..f2b424e 100644 --- a/app/string/viz.tsx +++ b/app/string/viz.tsx @@ -227,8 +227,7 @@ export function EncodeLab() { } zh={ <> - 同一个字符,三个身份:你看到的字形、Unicode 给它的编号(码点)、 - 以及硬盘里存下的字节。试试输入一个 emoji,看看会发生什么。 + 同一个字符,三个身份:你看到的字形、Unicode 给它的编号(码点)、以及硬盘里存下的字节。试试输入一个 emoji,看看会发生什么。 } /> @@ -353,8 +352,7 @@ export function ConcatLab() { } zh={ <> - 两种做法完成同一件事:往字符串尾部追加 {N} 个字符。 - 追加次数完全一样,比的是背后总共拷贝了多少字符。 + 两种做法完成同一件事:往字符串尾部追加 {N} 个字符。追加次数完全一样,比的是背后总共拷贝了多少字符。 } /> @@ -372,10 +370,8 @@ export function ConcatLab() { } zh={ <> - 终点:+= 拷贝了 {nv} 个字符(≈ n²/2), - 可变缓冲区只拷贝了 {bv} 个(≈ 2n,含扩容搬家)—— - 相差 {Math.round(nv / bv)} 倍。n 再大十倍,差距就是百倍: - 这就是 O(n²) 与 O(n) 的区别。 + 终点:+= 拷贝了 {nv} 个字符(≈ n²/2),可变缓冲区只拷贝了 {bv} 个(≈ 2n,含扩容搬家)—— + 相差 {Math.round(nv / bv)} 倍。n 再大十倍,差距就是百倍:这就是 O(n²) 与 O(n) 的区别。 } /> @@ -396,8 +392,7 @@ export function ConcatLab() { zh={ <> 第 {step} 次追加:+= 要新建长度 {step}{" "} - 的串并重抄全部旧字符(本次拷贝 {step} 个); - 可变缓冲区只往数组空位写 1 个 + 的串并重抄全部旧字符(本次拷贝 {step} 个);可变缓冲区只往数组空位写 1 个 {builder[step] - builder[step - 1] > 1 ? ",这一步刚好装满,顺带扩容搬家一次" : ""} diff --git a/app/trie/page.tsx b/app/trie/page.tsx index dee7f8b..cc7b93a 100644 --- a/app/trie/page.tsx +++ b/app/trie/page.tsx @@ -67,10 +67,8 @@ const A208_FRAMES: TStepFrame[] = [ } zh={ <> - insert("app"):从 root 出发。a、p、p 都还不存在, - 于是每个字母新建一个节点连上边。走到最后一个 p,把它的{" "} - isEnd = true —— 只有这个标记能说明「app 是一个单词」。 - 图中 l、e 两个节点是淡的,它们在下一步才出现。 + insert("app"):从 root 出发。a、p、p 都还不存在,于是每个字母新建一个节点连上边。走到最后一个 p,把它的{" "} + isEnd = true —— 只有这个标记能说明「app 是一个单词」。图中 l、e 两个节点是淡的,它们在下一步才出现。 } /> @@ -91,8 +89,7 @@ const A208_FRAMES: TStepFrame[] = [ zh={ <> insert("apple"):前三个字母 a-p-p{" "} - 已经存在,直接沿用 —— 相同的开头只存一份。只新建 l、e 两个节点, - 末节点 e 标 isEnd = true。 + 已经存在,直接沿用 —— 相同的开头只存一份。只新建 l、e 两个节点,末节点 e 标 isEnd = true。 } /> @@ -138,8 +135,7 @@ const A208_FRAMES: TStepFrame[] = [ zh={ <> search("ap"):路径走得通,但停下的节点{" "} - isEnd = false —— ap 只是别的词的开头,不是被插入过的单词。 - 所以 search 返回 false,而 startsWith("ap") 返回{" "} + isEnd = false —— ap 只是别的词的开头,不是被插入过的单词。所以 search 返回 false,而 startsWith("ap") 返回{" "} true。一个标记之差,分开了「精确匹配」和「前缀匹配」。 } @@ -182,8 +178,7 @@ const A208_FRAMES: TStepFrame[] = [ zh={ <> search("banana"):root 的 children 里没有 - 'b' 这条边,第一个字母就断路,立即返回 false, - 只花了 1 步。树里存一百万个词,这次查询也还是 1 步。 + 'b' 这条边,第一个字母就断路,立即返回 false,只花了 1 步。树里存一百万个词,这次查询也还是 1 步。 } /> @@ -233,8 +228,7 @@ const A211_FRAMES: TStepFrame[] = [ zh={ <> search(".ad"):第 1 个字符是通配符{" "} - '.',能匹配任意字母。这一步不知道该走哪条边, - 于是 root 底下的 b、d、m 三条边都要试 —— DFS 在这里第一次分叉。 + '.',能匹配任意字母。这一步不知道该走哪条边,于是 root 底下的 b、d、m 三条边都要试 —— DFS 在这里第一次分叉。 } /> @@ -255,8 +249,7 @@ const A211_FRAMES: TStepFrame[] = [ zh={ <> 第 2 个字符是普通的 'a':三条分支各自沿自己的 a - 边下沉一层,分别到达 bad、dad、mad 路径上的 a 节点。 - 普通字符不分叉,只走对应的那一条边。 + 边下沉一层,分别到达 bad、dad、mad 路径上的 a 节点。普通字符不分叉,只走对应的那一条边。 } /> @@ -280,10 +273,7 @@ const A211_FRAMES: TStepFrame[] = [ zh={ <> 第 3 个字符 'd':三条路都停在 isEnd = true{" "} - 的节点上,所以 ".ad" 匹配 bad、dad、mad。 - 真实的 search 只要第一条分支成功就返回 true,这里把三条都画出来, - 是为了看清通配符能碰到哪些词。'.' 的代价, - 就是当前节点的每个 child 都得试一遍。 + 的节点上,所以 ".ad" 匹配 bad、dad、mad。真实的 search 只要第一条分支成功就返回 true,这里把三条都画出来,是为了看清通配符能碰到哪些词。'.' 的代价,就是当前节点的每个 child 都得试一遍。 } /> @@ -334,9 +324,7 @@ export default function TrieChapter() { ), zh: ( <> - Trie 把一组单词存成一棵树:从根走下来的路径拼出单词, - 开头相同的词因此共用同一段节点,每一段相同的开头只存一份。 - 它花掉的是内存,换来的是哈希表答不了的一个问题: + Trie 把一组单词存成一棵树:从根走下来的路径拼出单词,开头相同的词因此共用同一段节点,每一段相同的开头只存一份。它花掉的是内存,换来的是哈希表答不了的一个问题: 哪些已存的词以这段开头? ), @@ -397,13 +385,10 @@ export default function TrieChapter() {

    第 6 章的哈希表是为精确查找而生的:判断{" "} - cat 在不在词典里,它把整个单词算成一个数, - 直接跳到那个槽位。就「已存词数」而言,这是平均 O(1) —— - 尽管它仍要读完整个单词才能算出哈希。可正是这次计算, - 让前缀查询没了指望:cat 和 car{" "} + cat 在不在词典里,它把整个单词算成一个数,直接跳到那个槽位。就「已存词数」而言,这是平均 O(1) —— + 尽管它仍要读完整个单词才能算出哈希。可正是这次计算,让前缀查询没了指望:cat 和 car{" "} 算出的哈希值毫不相干,表里 - 没有任何东西记得它俩共享 ca 这个开头。 - 想列出以 ca 开头的词,它只能读遍全部 N 个词、逐个比对开头 —— + 没有任何东西记得它俩共享 ca 这个开头。想列出以 ca 开头的词,它只能读遍全部 N 个词、逐个比对开头 —— O(N·L)。词典越大越慢。

    @@ -454,11 +439,7 @@ export default function TrieChapter() { } zh={

    - 主意很朴素:把 cat、car、card 三个词竖着写, - 前两个字母 ca 一模一样 —— 那就只存一次, - 在 a 之后再分叉。一万个单词这么存下来,就长成了一棵树。 - 从根走到任意节点,一路读到的字符就是一个前缀; - 想找所有以 ca 开头的词,走到 ca 那个节点, + 主意很朴素:把 cat、car、card 三个词竖着写,前两个字母 ca 一模一样 —— 那就只存一次,在 a 之后再分叉。一万个单词这么存下来,就长成了一棵树。从根走到任意节点,一路读到的字符就是一个前缀;想找所有以 ca 开头的词,走到 ca 那个节点, 它下面整棵子树就是答案,别的词一眼都不用看。

    } @@ -482,8 +463,7 @@ export default function TrieChapter() { zh: ( <> 由 car、card、cat、dog 建成的 Trie。 - car / card / cat 共享 c-a 这个开头(只存一份), - 到 a 之后才分叉;dog 开头不同,自成一支。绿色双圈 = + car / card / cat 共享 c-a 这个开头(只存一份),到 a 之后才分叉;dog 开头不同,自成一支。绿色双圈 = 有单词在此结束(isEnd)。注意 car 结束的那个 r 节点还带着孩子(通向 card)—— §02 会用到这个细节。 @@ -513,8 +493,7 @@ export default function TrieChapter() { zh={

    每条边上贴着一个字符,从根读到任意节点, - 拼出来就是一个前缀。前缀查询在别的结构里很别扭, - 在 Trie 里就是「顺着路往下走」。 + 拼出来就是一个前缀。前缀查询在别的结构里很别扭,在 Trie 里就是「顺着路往下走」。

    } /> @@ -537,8 +516,7 @@ export default function TrieChapter() { } zh={

    - 开头相同的词共用同一批节点。词与词在开头重叠得越多(自然语言单词、 - URL、文件路径),共享的节点就越多。它的另一个名字 + 开头相同的词共用同一批节点。词与词在开头重叠得越多(自然语言单词、URL、文件路径),共享的节点就越多。它的另一个名字 「字典树 / 前缀树」就是这么来的。

    } @@ -564,9 +542,7 @@ export default function TrieChapter() { } zh={

    - 插入或查询一个词的代价 = 这个词的长度 L。 - 树里已经有 10 个词还是 1000 万个词,不出现在这个代价里。 - §03 讲清楚为什么。 + 插入或查询一个词的代价 = 这个词的长度 L。树里已经有 10 个词还是 1000 万个词,不出现在这个代价里。§03 讲清楚为什么。

    } /> @@ -595,9 +571,7 @@ export default function TrieChapter() { zh={

    Trie 一词由 Edward Fredkin 在 1960 年提出,取自单词 retrie - val(检索)的中间四个字母。于是有点尴尬:按词源应该读作 - “tree”,可 tree 已经是另一种结构的名字了,所以多数人读它作 - “try”。两种读法你都会听到,不必纠结;它的中文名是「前缀树 / 字典树」。 + val(检索)的中间四个字母。于是有点尴尬:按词源应该读作“tree”,可 tree 已经是另一种结构的名字了,所以多数人读它作“try”。两种读法你都会听到,不必纠结;它的中文名是「前缀树 / 字典树」。

    } /> @@ -650,12 +624,8 @@ export default function TrieChapter() { } zh={ <> - children(孩子表):从「字符」到「子节点」的映射。 - 二叉树固定两个孩子,Trie 的孩子数由字符集决定: - 只处理小写英文字母就是最多 26 个,可以用长度 26 的数组 - TrieNode[26](下标 = 字母 − 'a'), - 也可以用哈希表 Map<Character, Node>。 - 两者的取舍见 §03。 + children(孩子表):从「字符」到「子节点」的映射。二叉树固定两个孩子,Trie 的孩子数由字符集决定:只处理小写英文字母就是最多 26 个,可以用长度 26 的数组 + TrieNode[26](下标 = 字母 − 'a'),也可以用哈希表 Map<Character, Node>。两者的取舍见 §03。 } /> @@ -692,11 +662,7 @@ export default function TrieChapter() { } zh={

    - 有一点容易看漏:字符并不存在节点里, - 它贴在边上 —— 等价地说,一个节点是由「父亲用哪个字符找到它」来标识的。 - 所以拼出 key 的是路径,不是节点。 - 根节点不代表任何字符,它是所有词共同的起点,一个空前缀。 - 从根往下走 k 条边,读到的 k 个字符,就是一个长度为 k 的前缀。 + 有一点容易看漏:字符并不存在节点里,它贴在边上 —— 等价地说,一个节点是由「父亲用哪个字符找到它」来标识的。所以拼出 key 的是路径,不是节点。根节点不代表任何字符,它是所有词共同的起点,一个空前缀。从根往下走 k 条边,读到的 k 个字符,就是一个长度为 k 的前缀。

    } /> @@ -731,9 +697,7 @@ export default function TrieChapter() { (继续接 t-l-e)。现在问:cat 是一个词吗?没有 isEnd 就无从回答。「这个节点有没有孩子」帮不上忙 —— 它确实有孩子(为了 cattle),可 cat 确确实实是个词;反过来, - catt 也走得通、也有孩子,却不是词。 - 节点本身并不存词,所以这个答案没有别的地方可放, - 只能是节点上的一个标记 isEnd = true:有词在此结束。 + catt 也走得通、也有孩子,却不是词。节点本身并不存词,所以这个答案没有别的地方可放,只能是节点上的一个标记 isEnd = true:有词在此结束。

    } /> @@ -754,9 +718,7 @@ export default function TrieChapter() { ), zh: ( <> - cat 与 cattle 共享 c-a-t。高亮的 t 节点 isEnd = true, - 说明 cat 是一个已存的词,而它同时还有孩子继续通向 cattle。 - 没有这个标记,就分不清「一个已存的词」和「只是路过的节点」。 + cat 与 cattle 共享 c-a-t。高亮的 t 节点 isEnd = true,说明 cat 是一个已存的词,而它同时还有孩子继续通向 cattle。没有这个标记,就分不清「一个已存的词」和「只是路过的节点」。 ), }} @@ -776,8 +738,7 @@ export default function TrieChapter() { } zh={

    - 下面这个实验室里已经住着 5 个词。插入新词,看路径哪一段被复用; - 查询单词或前缀,看路径怎么逐节点点亮。有三种结局值得亲手制造一遍: + 下面这个实验室里已经住着 5 个词。插入新词,看路径哪一段被复用;查询单词或前缀,看路径怎么逐节点点亮。有三种结局值得亲手制造一遍: 命中一个已存的词、是前缀但不是词、 中途断路。

    @@ -810,15 +771,11 @@ export default function TrieChapter() { } zh={

    - Trie 藏在你每天用的东西里:搜索引擎和输入法的自动补全、 - 路由器转发 IP 包时的最长前缀匹配、拼写检查。 - 数据库和文件系统里用的多是它的压缩形态 —— 基数树 Radix Tree + Trie 藏在你每天用的东西里:搜索引擎和输入法的自动补全、路由器转发 IP 包时的最长前缀匹配、拼写检查。数据库和文件系统里用的多是它的压缩形态 —— 基数树 Radix Tree (也叫 Patricia trie): - 把「只有一个孩子的节点链」合并成一条边,一条边上带多个字符, - 省掉大部分空转节点。代价是插入更复杂 —— + 把「只有一个孩子的节点链」合并成一条边,一条边上带多个字符,省掉大部分空转节点。代价是插入更复杂 —— 加一个词可能需要把已有的边一分为二。Redis 用基数树存 stream ID, - Linux 内核也曾用它做页缓存索引。朴素 Trie 想明白了, - 这些不过是它的压缩版。 + Linux 内核也曾用它做页缓存索引。朴素 Trie 想明白了,这些不过是它的压缩版。

    } /> @@ -859,8 +816,7 @@ export default function TrieChapter() { zh={

    Trie 的三个操作骨架完全一样: - 从根出发,拿着输入串的字符一个一个往下走。 - 区别只在两处:路上缺边怎么办,以及到达终点后检查什么。 + 从根出发,拿着输入串的字符一个一个往下走。区别只在两处:路上缺边怎么办,以及到达终点后检查什么。

    } /> @@ -1048,12 +1004,9 @@ export default function TrieChapter() { } zh={

    - 每一步只是一次 child 查找(数组下标,或平均 O(1) 的哈希表查找)。 - 走多少步?L 步 —— 你传进来的字符串有多长。 - 词典里是 10 个词还是 5000 万个词,查 apple{" "} + 每一步只是一次 child 查找(数组下标,或平均 O(1) 的哈希表查找)。走多少步?L 步 —— 你传进来的字符串有多长。词典里是 10 个词还是 5000 万个词,查 apple{" "} 永远是 5 步。哈希表做一次精确查找也是 O(L)(它要读完整个 key - 才能算哈希),所以 Trie 在精确匹配上并不更快。 - Trie 多出来的是前缀查询,选它就是为了这个。 + 才能算哈希),所以 Trie 在精确匹配上并不更快。Trie 多出来的是前缀查询,选它就是为了这个。

    } /> @@ -1077,9 +1030,7 @@ export default function TrieChapter() { } zh={

    - 这份速度是拿内存换的:每条路径上的每个字符都要一个节点, - 而每个节点都得带一张 children 表。这张表怎么存, - 是 Trie 唯一真正的设计抉择: + 这份速度是拿内存换的:每条路径上的每个字符都要一个节点,而每个节点都得带一张 children 表。这张表怎么存,是 Trie 唯一真正的设计抉择:

    } /> @@ -1107,12 +1058,9 @@ export default function TrieChapter() { } zh={

    - 下标 = 字符 − 'a',一次数组读取就拿到孩子, - 常数最小。代价有两个: - 每个节点无论用不用,都占着 26 个指针, - 词稀疏时浪费惊人;而且 26 只覆盖小写 ASCII 字母 —— - 输入里有数字、大写字母或非 ASCII 文本,就得换一张表。 - 适合明确「只有小写字母」的做题场景。 + 下标 = 字符 − 'a',一次数组读取就拿到孩子,常数最小。代价有两个: + 每个节点无论用不用,都占着 26 个指针,词稀疏时浪费惊人;而且 26 只覆盖小写 ASCII 字母 —— + 输入里有数字、大写字母或非 ASCII 文本,就得换一张表。适合明确「只有小写字母」的做题场景。

    } /> @@ -1136,9 +1084,7 @@ export default function TrieChapter() { } zh={

    - 有几个孩子就存几个,稀疏数据省下大量内存; - 而且任何字符都能当键:大写、数字、中文、任意非 ASCII 文本都行。 - 代价是单次查找的常数比数组下标略大。面对真实输入,它是更稳的默认选择。 + 有几个孩子就存几个,稀疏数据省下大量内存;而且任何字符都能当键:大写、数字、中文、任意非 ASCII 文本都行。代价是单次查找的常数比数组下标略大。面对真实输入,它是更稳的默认选择。

    } /> @@ -1325,9 +1271,7 @@ export default function TrieChapter() { zh={

    下面这段代码, - 逐字就是 LeetCode 208「实现 Trie」的满分答案。 - 三个方法共用一个私有小工具 find:沿路径走, - 缺边就返回空。search 和 startsWith{" "} + 逐字就是 LeetCode 208「实现 Trie」的满分答案。三个方法共用一个私有小工具 find:沿路径走,缺边就返回空。search 和 startsWith{" "} 都调它,只在最后一步分道扬镳。对着注释读一遍,再盖住默写一遍{" "} insert,它就是你的了。

    @@ -1430,8 +1374,7 @@ class Trie { <> 26 槽数组版:把 Map<Character, Node>{" "} 换成 Node[] children = new Node[26],用{" "} - c - 'a' 当下标。更快、常数更小, - 但每个节点从此固定占 26 个指针,而且只在输入为小写 ASCII 时成立。 + c - 'a' 当下标。更快、常数更小,但每个节点从此固定占 26 个指针,而且只在输入为小写 ASCII 时成立。 ), }, @@ -1602,8 +1545,7 @@ class Trie { zh: ( <> 易错点:用普通对象 {"{}"} 存 children - 也能跑,但键若是 __proto__ 之类, - 取到的会是原型而不是子节点。建议用{" "} + 也能跑,但键若是 __proto__ 之类,取到的会是原型而不是子节点。建议用{" "} Object.create(null) 或 Map。 26 槽数组版: new Array(26) + c.charCodeAt(0) - 97。 @@ -1679,10 +1621,7 @@ class Trie {

    和数组、哈希表不同,三种语言的标准库里都没有 Trie —— 面试要用就得手写(§04)。所以这一节比的不是 API,而是 - 同一个节点的 children 用什么容器装, - 以及各自要注意什么。抉择还是那两条:定长数组(最快、 - 费内存、只认小写 ASCII)vs 哈希表(稀疏数据省内存、 - 任意字符集)。 + 同一个节点的 children 用什么容器装,以及各自要注意什么。抉择还是那两条:定长数组(最快、费内存、只认小写 ASCII)vs 哈希表(稀疏数据省内存、任意字符集)。

    } /> @@ -1829,14 +1768,10 @@ class Trie { zh={

    「共享前缀 → 省空间」这个直觉,要看数据。词与词 - 在开头重叠得多(自然语言、URL、文件路径、电话号码), - 折叠公共开头确实省;可若词很短、彼此几乎没有共同开头, - Trie 比直接存字符串更费:每个字符一个节点, - 每个节点还带一张 children 表(数组版一律 26 个指针)。 + 在开头重叠得多(自然语言、URL、文件路径、电话号码),折叠公共开头确实省;可若词很短、彼此几乎没有共同开头, + Trie 比直接存字符串更费:每个字符一个节点,每个节点还带一张 children 表(数组版一律 26 个指针)。 指针开销很容易超过省下的字符。所以工程里常用它的压缩形态 —— - 基数树 Radix Tree 或双数组 Trie: - 把「只有一个孩子的节点链」合并成一条边,代价是插入更复杂。 - 结论:选 Trie 是为了前缀查询,不是为了省内存。 + 基数树 Radix Tree 或双数组 Trie:把「只有一个孩子的节点链」合并成一条边,代价是插入更复杂。结论:选 Trie 是为了前缀查询,不是为了省内存。

    } /> @@ -1879,9 +1814,7 @@ class Trie {

    Trie 的题目几乎都是「208 模板 + 一点新东西」。A{" "} 把模板本身逐帧走一遍:插入怎么复用前缀,查询有哪三种结局。 - B 给查询加一个通配符,逼着「走」在节点处分叉, - 变成 DFS。C 把 Trie 和网格回溯结合, - 用它提前砍掉分支 —— 这是 Trie 最有价值的用法。 + B 给查询加一个通配符,逼着「走」在节点处分叉,变成 DFS。C 把 Trie 和网格回溯结合,用它提前砍掉分支 —— 这是 Trie 最有价值的用法。

    } /> @@ -1924,13 +1857,10 @@ class Trie { zh={

    题意:实现 insert / search / startsWith 三个方法。 - 暴力:用一个 HashSet<String> 存所有词 —— + 暴力:用一个 HashSet<String> 存所有词 —— search 确实 O(L),但 startsWith 只能读遍集合里每个词、逐个比对开头, O(N·L),词典一大就无法接受。 - 正解:就是 §04 那份 Trie。代码不再重复, - 我们把它跑一遍:先插入 app, - 再插入 apple(看前缀怎么复用),然后做四次查询, - 把三种结局都看一遍。 + 正解:就是 §04 那份 Trie。代码不再重复,我们把它跑一遍:先插入 app,再插入 apple(看前缀怎么复用),然后做四次查询,把三种结局都看一遍。

    } /> @@ -1976,8 +1906,7 @@ class Trie { × 一张 children 表的大小。追问预备:①{" "} 「startsWith 和 search 差在哪?」—— 差一个{" "} isEnd 判断。②「怎么删除一个词?」—— - 走到末节点清掉 isEnd;若要回收内存,自底向上删掉 - 「没有孩子、也不是任何词尾」的节点。③ + 走到末节点清掉 isEnd;若要回收内存,自底向上删掉「没有孩子、也不是任何词尾」的节点。③ 「怎么统计某前缀下有几个词?」—— 在节点上存一个计数, insert 时沿路每个节点 +1(这就是 LC 677 的思路)。

    @@ -2026,14 +1955,11 @@ class Trie { 题意:设计一个结构支持 addWord,以及 search —— search 的字符串里可能出现 '.',它 匹配任意一个字母。 - 卡点:普通字符告诉你该走哪条边,而{" "} + 卡点:普通字符告诉你该走哪条边,而{" "} '.' - 不告诉你,于是每条子边都得试。 - 「对所有分支各递归一次」的循环,就是带回溯的 DFS。 - 正解:addWord 照搬 LC 208;search 改成递归: - 普通字符只钻对应的那个 child,遇到 '.'{" "} - 就遍历当前节点的所有 child 分别递归, - 任意一条成功就返回 true。 + 不告诉你,于是每条子边都得试。「对所有分支各递归一次」的循环,就是带回溯的 DFS。 + 正解:addWord 照搬 LC 208;search 改成递归:普通字符只钻对应的那个 child,遇到 '.'{" "} + 就遍历当前节点的所有 child 分别递归,任意一条成功就返回 true。

    } /> @@ -2127,9 +2053,7 @@ class WordDictionary { ), zh: ( <> - 复杂度:addWord O(L);没有通配符时 search 也是 O(L)。 - 开头是一串 '.' 的模式会朝 O(26^L) 扇出, - 但搜索访问的节点数不会超过整棵树的节点数,而且缺边会立刻中断一条分支。 + 复杂度:addWord O(L);没有通配符时 search 也是 O(L)。开头是一串 '.' 的模式会朝 O(26^L) 扇出,但搜索访问的节点数不会超过整棵树的节点数,而且缺边会立刻中断一条分支。 ), }, @@ -2191,8 +2115,7 @@ class WordDictionary: ), zh: ( <> - any(...) 天然短路:第一条返回 True 的分支之后, - 其余分支不再尝试。 + any(...) 天然短路:第一条返回 True 的分支之后,其余分支不再尝试。 ), }, @@ -2324,18 +2247,12 @@ class WordDictionary { zh={ <>

    - 题意:给一个字母网格 board 和一个词表 words, - 找出所有能在网格中沿相邻格子(上下左右、 - 同一格不重复用)拼出的单词。 - 暴力:对 words 里每个词各做一次网格搜索。 - 词表一大(几千个词)、很多词开头还相同, - 「从某个 c 出发」这件事会被重复搜千百遍,必然超时。 + 题意:给一个字母网格 board 和一个词表 words,找出所有能在网格中沿相邻格子(上下左右、同一格不重复用)拼出的单词。 + 暴力:对 words 里每个词各做一次网格搜索。词表一大(几千个词)、很多词开头还相同,「从某个 c 出发」这件事会被重复搜千百遍,必然超时。

    - 为什么 Trie 能解决这个问题:先把所有词建成一棵 Trie, - 然后只在网格上走一次, - DFS 每走一步,就在 Trie 上同步下沉一步。 - 两个结果随之而来: + 为什么 Trie 能解决这个问题:先把所有词建成一棵 Trie,然后只在网格上走一次, + DFS 每走一步,就在 Trie 上同步下沉一步。两个结果随之而来:

    } @@ -2378,8 +2295,7 @@ class WordDictionary { zh={ <> 缺边就立刻结束这条分支 —— 这就是剪枝。 - 如果当前字母在当前 Trie 节点上没有对应的 child, - 说明没有任何词这样开头,从这里再往下走都是白走。 + 如果当前字母在当前 Trie 节点上没有对应的 child,说明没有任何词这样开头,从这里再往下走都是白走。 立即返回,整条分支消失。正是这一步把搜索拉回可接受的范围。 } @@ -2424,10 +2340,8 @@ class WordDictionary { zh={

    实现上有两个小技巧。其一, - 在词尾节点直接存整个单词(而不是只放一个布尔位), - 命中时不必回头拼字符串。其二, - 收集之后把该字段清空, - 不用额外记录就完成了去重。 + 在词尾节点直接存整个单词(而不是只放一个布尔位),命中时不必回头拼字符串。其二, + 收集之后把该字段清空,不用额外记录就完成了去重。

    } /> @@ -2631,9 +2545,7 @@ class Solution: ), zh: ( <> - 嵌套 dict 是 Python 里最省事的 Trie 写法 —— 连节点类都不用定义。 - 用一个非字母的键(这里是 '#')存整词, - 不会和普通字符键冲突。 + 嵌套 dict 是 Python 里最省事的 Trie 写法 —— 连节点类都不用定义。用一个非字母的键(这里是 '#')存整词,不会和普通字符键冲突。 ), }, @@ -2710,10 +2622,7 @@ var findWords = function (board, words) { ), zh: ( <> - node[c] ??= {"{}"} 就是「有就复用,没有就建」。 - 用 word 字段存整词、收集后置 null, - 一举完成「收集」和「去重」。这里的键都是单个字符, - 所以用普通对象是安全的。 + node[c] ??= {"{}"} 就是「有就复用,没有就建」。用 word 字段存整词、收集后置 null,一举完成「收集」和「去重」。这里的键都是单个字符,所以用普通对象是安全的。 ), }, @@ -2747,10 +2656,7 @@ var findWords = function (board, words) { 不是「为每个词搜一遍网格」,而是「网格上的一次 DFS 沿着 Trie 走,顺路把所有词都测了」。
    {" "} - Trie 在这里担两个角色:开头相同的词共用一条路径; - 没有任何词有的开头,立刻结束这条分支。这是「多模式串匹配」的通用形状。 - 再往前一步就是 AC 自动机(Aho-Corasick):在 Trie 上加失配指针, - 让搜索永远不必从头重来。 + Trie 在这里担两个角色:开头相同的词共用一条路径;没有任何词有的开头,立刻结束这条分支。这是「多模式串匹配」的通用形状。再往前一步就是 AC 自动机(Aho-Corasick):在 Trie 上加失配指针,让搜索永远不必从头重来。

    } /> @@ -2814,9 +2720,7 @@ var findWords = function (board, words) { zh: ( <> Trie 的存在只有一个理由: - 哈希表把整个 key 算成一个数,丢掉了「开头」这个信息, - 因此答不了前缀问题。Trie 让开头相同的词待在同一条路径上, - 「以 X 开头」就退化成「顺着这条路往下走」。 + 哈希表把整个 key 算成一个数,丢掉了「开头」这个信息,因此答不了前缀问题。Trie 让开头相同的词待在同一条路径上,「以 X 开头」就退化成「顺着这条路往下走」。 ), }, @@ -2835,9 +2739,7 @@ var findWords = function (board, words) { zh: ( <> 结构极简:节点 = children(字符 → 子节点) +{" "} - isEnd(布尔)。字符贴在边上,拼出词的是路径, - 节点本身不存词。isEnd 必不可少 —— 没有它, - 就分不清 cat 和「cattle 路过同一批节点」这两回事。 + isEnd(布尔)。字符贴在边上,拼出词的是路径,节点本身不存词。isEnd 必不可少 —— 没有它,就分不清 cat 和「cattle 路过同一批节点」这两回事。 ), }, @@ -2854,8 +2756,7 @@ var findWords = function (board, words) { zh: ( <> insert / search / startsWith 全是 O(L),L 是 - 字符串长度,不是词数。这份「与词典大小无关」正是这个结构的意义所在。 - search 比 startsWith 只多一个 isEnd 判断。 + 字符串长度,不是词数。这份「与词典大小无关」正是这个结构的意义所在。search 比 startsWith 只多一个 isEnd 判断。 ), }, @@ -2872,10 +2773,8 @@ var findWords = function (board, words) { ), zh: ( <> - children 表是唯一的设计抉择:定长 26 槽数组最快, - 但每个节点占 26 个指针,而且只认小写 ASCII; - 哈希表只存实际存在的孩子、任意字符都能用,常数略大。 - Trie 是用内存买前缀查询,不是反过来。 + children 表是唯一的设计抉择:定长 26 槽数组最快,但每个节点占 26 个指针,而且只认小写 ASCII; + 哈希表只存实际存在的孩子、任意字符都能用,常数略大。Trie 是用内存买前缀查询,不是反过来。 ), }, @@ -2894,9 +2793,7 @@ var findWords = function (board, words) { <> 三大套路:LC 208 模板 →{" "} LC 211,通配符让「走」在节点处分叉成 DFS →{" "} - LC 212,前缀不存在就立刻结束这条网格分支; - 再加一个 0/1 Trie(把整数按比特建树)解最大异或。 - 题面里出现「前缀 / 补全 / 共同开头」,就该想到它。 + LC 212,前缀不存在就立刻结束这条网格分支;再加一个 0/1 Trie(把整数按比特建树)解最大异或。题面里出现「前缀 / 补全 / 共同开头」,就该想到它。 ), }, diff --git a/app/trie/viz.tsx b/app/trie/viz.tsx index 1f7f82c..ccdb4fa 100644 --- a/app/trie/viz.tsx +++ b/app/trie/viz.tsx @@ -206,8 +206,7 @@ export function TrieLab() { zh={ <> 这棵 Trie 里已经存了 car、card、cat、do、dog 五个词。试着插入{" "} - care 或 cab,看路径哪一段被复用;再查询 ca, - 看看「前缀」长什么样。 + care 或 cab,看路径哪一段被复用;再查询 ca,看看「前缀」长什么样。 } />, @@ -313,8 +312,7 @@ export function TrieLab() { } zh={ <> - {w} 之前就插入过。这次只是把同一个末节点的 isEnd 又标了一遍, - 树的形状没有变化。 + {w} 之前就插入过。这次只是把同一个末节点的 isEnd 又标了一遍,树的形状没有变化。 } /> @@ -385,9 +383,7 @@ export function TrieLab() { } zh={ <> - 查询 {w}:算上根节点走到第 {acc.length} 个节点时, - 下一个字母没有对应的 child,路径到此为止。未命中, - 而且整棵树里不可能有以 {w} 开头的词 —— 有的话,它必然长在这条路径上。 + 查询 {w}:算上根节点走到第 {acc.length} 个节点时,下一个字母没有对应的 child,路径到此为止。未命中,而且整棵树里不可能有以 {w} 开头的词 —— 有的话,它必然长在这条路径上。 } />, @@ -447,8 +443,7 @@ export function TrieLab() { zh={ <> search({w}):路径走得通,但末节点 isEnd = false —— {w}{" "} - 只是别的词的开头,不是被插入过的单词。所以 search 返回 false, - 而 startsWith 会返回 true。isEnd 就是为这种情况存在的。 + 只是别的词的开头,不是被插入过的单词。所以 search 返回 false,而 startsWith 会返回 true。isEnd 就是为这种情况存在的。 } />, diff --git a/app/union-find/page.tsx b/app/union-find/page.tsx index 4072b2c..ffd3fec 100644 --- a/app/union-find/page.tsx +++ b/app/union-find/page.tsx @@ -60,8 +60,7 @@ const F547: ArrayFrame[] = [ zh: ( <> 4 个城市,parent = [0,1,2,3],每个城市都是自己所在树的根, - count = 4。矩阵里 M[i][j] = 1 表示 i、j 直接相连。 - 下面逐格扫上三角(j > i)—— 矩阵对称,下三角不用看。 + count = 4。矩阵里 M[i][j] = 1 表示 i、j 直接相连。下面逐格扫上三角(j > i)—— 矩阵对称,下三角不用看。 ), }, @@ -120,8 +119,7 @@ const F547: ArrayFrame[] = [ ), zh: ( <> - M[1][2] = 1,执行 union(1, 2)。find(1) = 1、find(2) = 2 不同, - 于是 parent[1] = 2,count 3 → 2。城市 0 一个字节都没动就跟着过来了: + M[1][2] = 1,执行 union(1, 2)。find(1) = 1、find(2) = 2 不同,于是 parent[1] = 2,count 3 → 2。城市 0 一个字节都没动就跟着过来了: find(0) 现在爬 0 → 1 → 2。 ), @@ -139,8 +137,7 @@ const F547: ArrayFrame[] = [ ), zh: ( <> - 剩下的格子全是 0。扫描结束,count = 2 就是答案: - 省份是 {"{0,1,2}"} 和 {"{3}"}。全程没有做任何图遍历 —— + 剩下的格子全是 0。扫描结束,count = 2 就是答案:省份是 {"{0,1,2}"} 和 {"{3}"}。全程没有做任何图遍历 —— count 在合并过程中一直是对的。 ), @@ -170,8 +167,7 @@ const F684: ArrayFrame[] = [ ), zh: ( <> - 边:[1,2] [2,3] [3,4] [1,4] [1,5]。一棵树多加一条边,恰好含一个环。 - 按给定顺序逐边处理,哪条边的两个端点「已经连通」,哪条就是多余的那条。 + 边:[1,2] [2,3] [3,4] [1,4] [1,5]。一棵树多加一条边,恰好含一个环。按给定顺序逐边处理,哪条边的两个端点「已经连通」,哪条就是多余的那条。 ), }, @@ -238,8 +234,7 @@ const F684: ArrayFrame[] = [ ), zh: ( <> - 边 [3,4]:find(3) = 3、find(4) = 4 不同,合并,parent[3] = 4。 - 此时 1、2、3、4 已经在同一棵树里(1 → 2 → 3 → 4)。 + 边 [3,4]:find(3) = 3、find(4) = 4 不同,合并,parent[3] = 4。此时 1、2、3、4 已经在同一棵树里(1 → 2 → 3 → 4)。 ), }, @@ -269,8 +264,7 @@ const F684: ArrayFrame[] = [ zh: ( <> 边 [1,4]:find(1) 一路爬 1 → 2 → 3 → 4,返回 4,find(4) - 也返回 4。根相同,说明 1 和 4 之间早已有路径, - 这条边一加必然闭合成环。答案是 [1,4]。 + 也返回 4。根相同,说明 1 和 4 之间早已有路径,这条边一加必然闭合成环。答案是 [1,4]。 ), }, @@ -296,9 +290,7 @@ const F684: ArrayFrame[] = [ ), zh: ( <> - 题目要求返回在输入中最后出现的那条成环边,而按顺序逐边处理正好直接给出它: - 环上其余的边都在它之前加入,所以它是唯一一条两端已经连通的边。 - 边 [1,5] 根本不会被检查到。 + 题目要求返回在输入中最后出现的那条成环边,而按顺序逐边处理正好直接给出它:环上其余的边都在它之前加入,所以它是唯一一条两端已经连通的边。边 [1,5] 根本不会被检查到。 ), }, @@ -324,8 +316,7 @@ const F200: ArrayFrame[] = [ ), zh: ( <> - 2×4 网格拍扁成一维:idx = r×4 + c,前 4 格是第 0 行,后 4 格是第 1 行。 - 陆地(值 1)共 5 块,初始 count = 5 —— 先假设每块陆地各是一座岛。 + 2×4 网格拍扁成一维:idx = r×4 + c,前 4 格是第 0 行,后 4 格是第 1 行。陆地(值 1)共 5 块,初始 count = 5 —— 先假设每块陆地各是一座岛。 ), }, @@ -347,8 +338,7 @@ const F200: ArrayFrame[] = [ ), zh: ( <> - 扫到 (0,0):右邻 (0,1) 也是陆地 → union(0, 1),count 5 → 4。 - 每格只看右邻和下邻就够 —— 左邻、上邻在处理更早的格子时已经连过了。 + 扫到 (0,0):右邻 (0,1) 也是陆地 → union(0, 1),count 5 → 4。每格只看右邻和下邻就够 —— 左邻、上邻在处理更早的格子时已经连过了。 ), }, @@ -392,8 +382,7 @@ const F200: ArrayFrame[] = [ ), zh: ( <> - (0,1) 的右邻和下邻都是水,跳过。到 (0,3):右邻出界, - 下邻 (1,3)(idx = 7)是陆地 → union(3, 7),count 3 → 2。 + (0,1) 的右邻和下邻都是水,跳过。到 (0,3):右邻出界,下邻 (1,3)(idx = 7)是陆地 → union(3, 7),count 3 → 2。 ), }, @@ -413,8 +402,7 @@ const F200: ArrayFrame[] = [ ), zh: ( <> - 第 1 行的陆地 (1,0)、(1,3),右邻不是陆地,下邻出界。 - 扫描结束,count = 2,两座岛:{"{(0,0),(0,1),(1,0)}"} 与{" "} + 第 1 行的陆地 (1,0)、(1,3),右邻不是陆地,下邻出界。扫描结束,count = 2,两座岛:{"{(0,0),(0,1),(1,0)}"} 与{" "} {"{(0,3),(1,3)}"}。 ), @@ -477,11 +465,8 @@ export default function UnionFindChapter() { ), zh: ( <> - 它只回答两个问题:两个元素是否在同一个集合; - 把两个集合合并。它把元素组织成一片森林 - —— 一个集合就是一棵树,树根就是这个集合的代表元。 - 一个整型数组装下全部结构,两个优化都开时, - 每次操作的代价近乎常数。 + 它只回答两个问题:两个元素是否在同一个集合;把两个集合合并。它把元素组织成一片森林 + —— 一个集合就是一棵树,树根就是这个集合的代表元。一个整型数组装下全部结构,两个优化都开时,每次操作的代价近乎常数。 ), }} @@ -529,8 +514,7 @@ export default function UnionFindChapter() { 成为好友,B 和 C 成为好友……其间随时有人来问: A 和 D 现在算同一组吗? 「同组」这层关系是可传递的:A—B、B—C、C—D - 都成立,那么 A 和 D 哪怕素未谋面,也属于同一组。 - 这类问题有个名字:动态连通性(dynamic connectivity) + 都成立,那么 A 和 D 哪怕素未谋面,也属于同一组。这类问题有个名字:动态连通性(dynamic connectivity) —— 连接关系随时间增加,查询随时可能到来。

    @@ -565,8 +549,7 @@ export default function UnionFindChapter() { } zh={

    - 每个集合推举一个成员当代表元,也就是根。 - 想知道 A 和 D 是不是同一集合,不必顺着好友关系链走一遍 —— + 每个集合推举一个成员当代表元,也就是根。想知道 A 和 D 是不是同一集合,不必顺着好友关系链走一遍 —— 问一句「A 的代表元是谁?D 的代表元是谁?」 代表元相同就是同一集合。 这一步把「连通吗」变成了「这两个数相等吗」,整个结构的思想就在这里。 @@ -597,8 +580,7 @@ export default function UnionFindChapter() { zh={

    a 和 b 成为好友:先找出两边各自的代表元,再 - 让一个代表元指向另一个。两个集合立刻并成一个。 - 被并走的那个集合里,其余成员一个都不用访问 —— + 让一个代表元指向另一个。两个集合立刻并成一个。被并走的那个集合里,其余成员一个都不用访问 —— 反正它们最终都会爬到同一个根。

    } @@ -622,9 +604,7 @@ export default function UnionFindChapter() { zh={

    为什么不用图遍历?把边存下来,每次从 A 出发做一遍 DFS/BFS - 看能否到达 D 也可以(第 12 章会讲)。问题是每来一次查询就要重新遍历一遍, - 单次 O(V+E),而且边还在不断增加,上一次遍历的结果没法复用。 - 并查集正是为这种形态的问题设计的,它的另一个名字是 + 看能否到达 D 也可以(第 12 章会讲)。问题是每来一次查询就要重新遍历一遍,单次 O(V+E),而且边还在不断增加,上一次遍历的结果没法复用。并查集正是为这种形态的问题设计的,它的另一个名字是 不相交集合(disjoint set union,简称 DSU)。

    } @@ -729,18 +709,13 @@ export default function UnionFindChapter() { <>

    并查集之所以快,是因为它只保留结论,丢掉细节: - union 之后,「A 和 D 是怎么连起来的」这条信息就没有了, - 剩下的只有「它们在同一个集合里」。所以它答不了「A 到 D 怎么走」, - 也答不了「相隔多远」。 + union 之后,「A 和 D 是怎么连起来的」这条信息就没有了,剩下的只有「它们在同一个集合里」。所以它答不了「A 到 D 怎么走」,也答不了「相隔多远」。

    还有第二个限制,而且最常被略过:并查集只能合并, 不能把一个集合拆回去。它没有高效的撤销手段 —— - 合并进来的元素和原本就在里面的元素已经无法区分。 - 如果题目要求删除边,标准做法是先把所有操作读完,再 - 倒着处理,让每一次删除变成一次加入。 - 这要求你能提前看到整个操作序列,也就是所谓的离线做法; - 否则就要用可撤销变体 —— 记一份撤销日志,并放弃路径压缩。 + 合并进来的元素和原本就在里面的元素已经无法区分。如果题目要求删除边,标准做法是先把所有操作读完,再 + 倒着处理,让每一次删除变成一次加入。这要求你能提前看到整个操作序列,也就是所谓的离线做法;否则就要用可撤销变体 —— 记一份撤销日志,并放弃路径压缩。

    } @@ -772,12 +747,7 @@ export default function UnionFindChapter() { zh={

    这个结构由 Galler 和 Fischer 在 1964 年提出,至今仍随处可见。 - Kruskal 最小生成树算法把边按权重排序后逐条加入, - 每加一条之前先问并查集:这条边的两个端点是否已经连通? - 若已连通,加上它就会形成环,于是跳过。 - 编译器在类型推导里用同一个结构合并必须相等的类型变量; - 图像处理用它标记像素的连通区域。核心不过三个短方法, - 而它运行时间的匹配下界要到 1989 年才由 Fredman 和 Saks 证明出来。 + Kruskal 最小生成树算法把边按权重排序后逐条加入,每加一条之前先问并查集:这条边的两个端点是否已经连通?若已连通,加上它就会形成环,于是跳过。编译器在类型推导里用同一个结构合并必须相等的类型变量;图像处理用它标记像素的连通区域。核心不过三个短方法,而它运行时间的匹配下界要到 1989 年才由 Fredman 和 Saks 证明出来。

    } /> @@ -832,8 +802,7 @@ export default function UnionFindChapter() { zh={ <>

    - 并查集的全部数据,就是一个整型数组 parent, - 规则只有两条。 + 并查集的全部数据,就是一个整型数组 parent,规则只有两条。

    • @@ -846,11 +815,8 @@ export default function UnionFindChapter() {

    - 为什么用「自己指自己」来标记根?因为根没有上一级, - 而这个格子总得存点什么。存 i 自己不额外占内存,不需要再开一个标记数组, - 还让终止判断变成一次比较:parent[x] == x —— - 根和非根用同一段循环处理。find 因此写成这样: - 从 x 出发一路往上,直到某个元素指向它自己。 + 为什么用「自己指自己」来标记根?因为根没有上一级,而这个格子总得存点什么。存 i 自己不额外占内存,不需要再开一个标记数组,还让终止判断变成一次比较:parent[x] == x —— + 根和非根用同一段循环处理。find 因此写成这样:从 x 出发一路往上,直到某个元素指向它自己。

    } @@ -938,19 +904,10 @@ find(x) { zh={ <>

    - 换个角度看这个数组:给每个 i 画一条指向 parent[i] 的箭头, - 整个数组就成了一片森林 —— 若干棵树, - 一棵树对应一个集合,树根代表这个集合。 - 这是全书第二次「数组扮演树」: - 堆用 2i+1、2i+2 的下标运算隐式编码父子关系, - 并查集更直接 —— 把每个节点的父节点显式存下来。 - 两种情形里树都只是逻辑形状,物理上始终只有一排连续的整数。 + 换个角度看这个数组:给每个 i 画一条指向 parent[i] 的箭头,整个数组就成了一片森林 —— 若干棵树,一棵树对应一个集合,树根代表这个集合。这是全书第二次「数组扮演树」:堆用 2i+1、2i+2 的下标运算隐式编码父子关系,并查集更直接 —— 把每个节点的父节点显式存下来。两种情形里树都只是逻辑形状,物理上始终只有一排连续的整数。

    - 下面亲手试试:点两个节点, - 看 union 如何先对两边各做一次 find 找到两个根,再把一个根挂到另一个下面; - 同时盯着下方的 parent 数组,确认图里发生的一切, - 不过是数组里改了一个数。然后点 + 下面亲手试试:点两个节点,看 union 如何先对两边各做一次 find 找到两个根,再把一个根挂到另一个下面;同时盯着下方的 parent 数组,确认图里发生的一切,不过是数组里改了一个数。然后点 「最坏顺序 union」,看看会出什么问题。

    @@ -980,11 +937,8 @@ find(x) { } zh={

    - union(0,1) 把 0 挂到 1 下面,union(1,2) 又把这棵树的根挂到 2 下面, - 依此类推。每一步都是把已有的根挂到一个新的单节点下面, - 于是树只长高、不长宽。10 个元素最后连成一条链,find(0) - 要走 9 步才能到根,复杂度 O(n), - 并查集赖以成名的速度荡然无存。问题不在结构本身, + union(0,1) 把 0 挂到 1 下面,union(1,2) 又把这棵树的根挂到 2 下面,依此类推。每一步都是把已有的根挂到一个新的单节点下面,于是树只长高、不长宽。10 个元素最后连成一条链,find(0) + 要走 9 步才能到根,复杂度 O(n),并查集赖以成名的速度荡然无存。问题不在结构本身, 而在 union 选择「谁挂到谁下面」的做法 —— 下一节正是要修这个。

    } @@ -1024,9 +978,7 @@ find(x) { } zh={

    - 上一节的病根是树太高:find 的代价等于节点到根之间的层数。 - 两个优化的目标一致 —— 把树压矮,但它们作用的时刻不同, - 防的问题也不同。两个都重要,而且要能说清各自干了什么。 + 上一节的病根是树太高:find 的代价等于节点到根之间的层数。两个优化的目标一致 —— 把树压矮,但它们作用的时刻不同,防的问题也不同。两个都重要,而且要能说清各自干了什么。

    } /> @@ -1057,8 +1009,7 @@ find(x) { } zh={

    - find(x) 反正要从 x 一路走到根,走完之后,途中经过的每个节点的根都已经确定了, - 没有理由让它们继续指着中间节点。 + find(x) 反正要从 x 一路走到根,走完之后,途中经过的每个节点的根都已经确定了,没有理由让它们继续指着中间节点。 爬到根之后,把沿途每个节点的 parent 直接改成根。

    } @@ -1150,11 +1101,7 @@ find(x) { } zh={

    - 额外代价很小:这条路本来就要走,压缩只是在回程多做几次赋值。 - 收益却是持久的:一个节点被压缩过之后, - 之后对它的 find 只要一步,直到后来的某次 union 在它头上又加了新的根。 - 路径压缩防的是同一条长路径被反复重走。 - 即使不配按秩合并,单独使用它也已经把均摊代价降到{" "} + 额外代价很小:这条路本来就要走,压缩只是在回程多做几次赋值。收益却是持久的:一个节点被压缩过之后,之后对它的 find 只要一步,直到后来的某次 union 在它头上又加了新的根。路径压缩防的是同一条长路径被反复重走。即使不配按秩合并,单独使用它也已经把均摊代价降到{" "} 。

    } @@ -1233,8 +1180,7 @@ find(x) {

    • - 矮树挂到高树下:新高度 = max(高树高, 矮树高 + 1)。 - 只要矮树严格更矮,结果就等于高树的高度,树高 + 矮树挂到高树下:新高度 = max(高树高, 矮树高 + 1)。只要矮树严格更矮,结果就等于高树的高度,树高 不变。
    • @@ -1247,24 +1193,15 @@ find(x) {

    - 所以给每个根记一个 rank(秩), - 它是这棵树高度的上界。union 时,rank 小的根挂到 rank 大的根下面; - 两个 rank 相等时随便挂,并把新根的 rank 加 1。 - 只用这一条,树高就被压在 O(log n) 以内 —— - 因为 rank 为 r 的树至少含 2ʳ 个元素:rank 只在两棵等秩的树合并时才 +1, - 而那至少让规模翻倍。这和动态数组「扩容必翻倍」是同一套论证。 - 也有实现用 size(集合大小)代替 rank, - 小集合挂到大集合下面,界是一样的,还顺带得到每个集合的大小 —— + 所以给每个根记一个 rank(秩),它是这棵树高度的上界。union 时,rank 小的根挂到 rank 大的根下面;两个 rank 相等时随便挂,并把新根的 rank 加 1。只用这一条,树高就被压在 O(log n) 以内 —— + 因为 rank 为 r 的树至少含 2ʳ 个元素:rank 只在两棵等秩的树合并时才 +1,而那至少让规模翻倍。这和动态数组「扩容必翻倍」是同一套论证。也有实现用 size(集合大小)代替 rank,小集合挂到大集合下面,界是一样的,还顺带得到每个集合的大小 —— 好几道题正需要这个。

    - 路径压缩防不了这个问题:它只在 find 时运行, - 而且只能修一条已经形成的路径;按秩合并则让高树根本长不出来。 - 这就是两者不可互相替代的原因。 + 路径压缩防不了这个问题:它只在 find 时运行,而且只能修一条已经形成的路径;按秩合并则让高树根本长不出来。这就是两者不可互相替代的原因。

    - 现在回实验室验证:把两个开关都打开, - 再点一次「最坏顺序 union」,那条 10 个元素的长链会变成一棵深度为 1 的树。 + 现在回实验室验证:把两个开关都打开,再点一次「最坏顺序 union」,那条 10 个元素的长链会变成一棵深度为 1 的树。

    } @@ -1307,16 +1244,11 @@ find(x) { <>

    两个优化都开时,n 个元素上的 m 次操作总代价是 O(m · α(n)), - α 是反阿克曼函数。阿克曼函数比任何原始递归函数增长都快, - 所以它的反函数增长慢到极点:直到 n 约为 2^65536(远超任何机器能容纳的元素个数), + α 是反阿克曼函数。阿克曼函数比任何原始递归函数增长都快,所以它的反函数增长慢到极点:直到 n 约为 2^65536(远超任何机器能容纳的元素个数), α(n) 都不超过 4。实践中每次操作就是常数级的几步。

    - 措辞要小心。这个界是均摊的,不是最坏情形: - 单次 find 仍可能走一条较长的路径,只有把整串操作平均起来代价才这么小。 - 而且 O(α(n)) 不等于 O(1) —— α 确实会增长, - 只是慢得难以想象。说「近乎常数」是准确的,说「它是 O(1)」则不准确。 - 上界的证明来自 Tarjan(1975),远超本课范围。 + 措辞要小心。这个界是均摊的,不是最坏情形:单次 find 仍可能走一条较长的路径,只有把整串操作平均起来代价才这么小。而且 O(α(n)) 不等于 O(1) —— α 确实会增长,只是慢得难以想象。说「近乎常数」是准确的,说「它是 O(1)」则不准确。上界的证明来自 Tarjan(1975),远超本课范围。

    } @@ -1348,15 +1280,9 @@ find(x) { } zh={

    - 它会让 rank 变得不精确,而这没有关系。 - 压缩把树压扁,却不会调低已经记下的 rank, - 所以压缩之后 rank 是树高的上界,而不是树高本身 —— - 这个字段之所以叫「秩」而不叫「高度」,原因就在这里。 - 上界永远成立,所以正确性和复杂度分析都不受影响。 - 实践中也可以只写路径压缩、完全不要 rank,均摊 O(log n), - 做题已经够用。但面试官问「两个优化各自干什么」时,答案必须准确: - 压缩缩短的是已经存在的路径,按秩合并阻止高树形成, - 只有两者叠加才有 O(α(n))。 + 它会让 rank 变得不精确,而这没有关系。压缩把树压扁,却不会调低已经记下的 rank,所以压缩之后 rank 是树高的上界,而不是树高本身 —— + 这个字段之所以叫「秩」而不叫「高度」,原因就在这里。上界永远成立,所以正确性和复杂度分析都不受影响。实践中也可以只写路径压缩、完全不要 rank,均摊 O(log n),做题已经够用。但面试官问「两个优化各自干什么」时,答案必须准确: + 压缩缩短的是已经存在的路径,按秩合并阻止高树形成,只有两者叠加才有 O(α(n))。

    } /> @@ -1391,9 +1317,7 @@ find(x) { } zh={

    - 并查集是极少数值得逐字背熟的数据结构: - 没有哪个标准库提供它,所以在题目里它永远以「手写模板 + 一点建模」 - 的形式出现。下面这版同时带有两个优化,外加两个题目里反复用到的方法: + 并查集是极少数值得逐字背熟的数据结构:没有哪个标准库提供它,所以在题目里它永远以「手写模板 + 一点建模」的形式出现。下面这版同时带有两个优化,外加两个题目里反复用到的方法: connected 判连通,以及当前连通块数量 count。

    } @@ -1493,9 +1417,7 @@ find(x) { ), zh: ( <> - 路径减半(path halving):这个循环不做完整压缩, - 而是边爬边把每个节点改指向它的祖父。一趟循环,无递归、无第二次遍历, - 与按秩合并配合时同样是 O(α(n))。竞赛里最常见的正是这种写法。 + 路径减半(path halving):这个循环不做完整压缩,而是边爬边把每个节点改指向它的祖父。一趟循环,无递归、无第二次遍历,与按秩合并配合时同样是 O(α(n))。竞赛里最常见的正是这种写法。 ), }, @@ -1575,11 +1497,7 @@ find(x) { ), zh: ( <> - 易错点:Python 默认递归深度上限是 1000。 - 开启按秩合并时树高不超过 log₂n,递归本来是安全的; - 但只写路径压缩时,链长可能达到 n,递归版 find 就会抛 RecursionError。 - 所以这里用两趟迭代实现完整的路径压缩,两种情况下都安全。 - Java 和 JavaScript 的调用栈更大,但同样有限,道理相同。 + 易错点:Python 默认递归深度上限是 1000。开启按秩合并时树高不超过 log₂n,递归本来是安全的;但只写路径压缩时,链长可能达到 n,递归版 find 就会抛 RecursionError。所以这里用两趟迭代实现完整的路径压缩,两种情况下都安全。Java 和 JavaScript 的调用栈更大,但同样有限,道理相同。 ), }, @@ -1712,16 +1630,11 @@ find(x) {
  • count 初始为 n,只在真正发生合并时才减 1。 - 每个元素起初各成一个集合,所以有 n 个集合; - 一次成功的 union 把两个集合换成一个,数量正好减 1。 - 而两边根本来就相同的 union 什么也没合并, - 此时若照样减 1,报出的连通块数就会比实际少。 - 这正是 if (ra == rb) return false; 要写在{" "} + 每个元素起初各成一个集合,所以有 n 个集合;一次成功的 union 把两个集合换成一个,数量正好减 1。而两边根本来就相同的 union 什么也没合并,此时若照样减 1,报出的连通块数就会比实际少。这正是 if (ra == rb) return false; 要写在{" "} count-- 之前的原因。
  • - 合并前永远先找两边的根。union 的第一行不可能是别的。 - union 合并的是两个集合,不是那两个具体元素。 + 合并前永远先找两边的根。union 的第一行不可能是别的。union 合并的是两个集合,不是那两个具体元素。
  • @@ -1759,9 +1672,7 @@ find(x) { zh={

    三门语言的标准库都没有并查集,C++ 也没有 —— 只有 Boost - 或竞赛模板库里才有。原因是这个结构太小,而且每道题需要的细节都不一样, - 与其设计一套通用 API,不如花三十秒手写一个贴合题意的。 - 所以真正值得对照的不是 API,而是容器选型。 + 或竞赛模板库里才有。原因是这个结构太小,而且每道题需要的细节都不一样,与其设计一套通用 API,不如花三十秒手写一个贴合题意的。所以真正值得对照的不是 API,而是容器选型。

    } /> @@ -1875,8 +1786,7 @@ find(x) { 有一个实战问题反复出现:key 不是 0 到 n−1 的整数怎么办? LC 721 的 key 是邮箱地址,LC 128 的 key 是任意整数 —— 可能为负,也可能大到 10⁹。答案是第 6 章的哈希表: - 先把每个 key 映射成一个连续的新编号,再照常开数组。 - 哈希表负责翻译,并查集负责合并。 + 先把每个 key 映射成一个连续的新编号,再照常开数组。哈希表负责翻译,并查集负责合并。

    } /> @@ -1975,11 +1885,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, zh={

    直接拿 Map<String, String> 当 parent(key - 映射到它的上一级 key)也能实现并查集,省掉编号那一步。 - 代价是 find 的每一步都从数组下标访问变成一次哈希查找, - 单步慢好几倍。一条实用准则:元素能编号就编号。 - 数组是并查集最好的载体,理由和数组章说过的一样: - 连续内存加下标直达。 + 映射到它的上一级 key)也能实现并查集,省掉编号那一步。代价是 find 的每一步都从数组下标访问变成一次哈希查找,单步慢好几倍。一条实用准则:元素能编号就编号。数组是并查集最好的载体,理由和数组章说过的一样:连续内存加下标直达。

    } /> @@ -2018,8 +1924,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, zh={

    并查集题目里 90% 的代码是同一份模板,真正的考点是 - 建模 —— 把题面翻译成「谁是节点、什么算边」。 - 翻译完成之后,套路只有三种。 + 建模 —— 把题面翻译成「谁是节点、什么算边」。翻译完成之后,套路只有三种。

    } /> @@ -2042,8 +1947,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, } zh={

    - 有几个省份、几座岛、几个组?count 初始为 n, - 每成功 union 一次减 1,扫完直接读答案。LC 547、200、2316。 + 有几个省份、几座岛、几个组?count 初始为 n,每成功 union 一次减 1,扫完直接读答案。LC 547、200、2316。

    } /> @@ -2065,8 +1969,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, } zh={

    - 逐边加入,某条边的两个端点已经连通, - 它就会闭合成环。这与 Kruskal 算法用的判断完全相同。LC 684、685。 + 逐边加入,某条边的两个端点已经连通,它就会闭合成环。这与 Kruskal 算法用的判断完全相同。LC 684、685。

    } /> @@ -2088,8 +1991,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, } zh={

    - 「相等」「相似」「同账户」这类关系,一旦按它分组就有传递性。 - 全部 union 起来,再按根检查或分组。LC 990、721、839。 + 「相等」「相似」「同账户」这类关系,一旦按它分组就有传递性。全部 union 起来,再按根检查或分组。LC 990、721、839。

    } /> @@ -2126,12 +2028,10 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, } zh={

    - 题意:n 个城市,isConnected[i][j] = 1 表示 i、j 直接相连; - 相连关系可传递,求省份(也就是连通块)的个数。 - 暴力做法:对每个没访问过的城市做一次 DFS + 题意:n 个城市,isConnected[i][j] = 1 表示 i、j 直接相连;相连关系可传递,求省份(也就是连通块)的个数。 + 暴力做法:对每个没访问过的城市做一次 DFS 把整个省份标掉,数发起了几次 —— 可行,O(n²),第 12 章会讲。 - 并查集视角:矩阵里的每个 1 就是一条边,逐格 union, - 让 count 从 n 一路减,剩下多少就是几个省 —— 连访问标记数组都不需要。 + 并查集视角:矩阵里的每个 1 就是一条边,逐格 union,让 count 从 n 一路减,剩下多少就是几个省 —— 连访问标记数组都不需要。

    } /> @@ -2252,13 +2152,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, } zh={

    - 时间 O(n²·α(n)) —— 矩阵本身就有 n² 个格子,读一遍已是下限; - 空间 O(n)。追问一:DFS 也是 O(n²),并查集好在哪? - 在这份输入上没有优势。但如果关系是一条条到来、 - 中途要随时报数,并查集增量维护,DFS 则要从头再来。 - 追问二:为什么只扫上三角?矩阵对称,i-j 和 j-i 是同一条边; - 扫两遍只是浪费,不会出错 —— 同一对元素第二次 union 会返回 false, - 什么都不改。 + 时间 O(n²·α(n)) —— 矩阵本身就有 n² 个格子,读一遍已是下限;空间 O(n)。追问一:DFS 也是 O(n²),并查集好在哪?在这份输入上没有优势。但如果关系是一条条到来、中途要随时报数,并查集增量维护,DFS 则要从头再来。追问二:为什么只扫上三角?矩阵对称,i-j 和 j-i 是同一条边;扫两遍只是浪费,不会出错 —— 同一对元素第二次 union 会返回 false,什么都不改。

    } /> @@ -2313,24 +2207,15 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, zh={ <>

    - 题意:一棵 n 个节点的树本应只有 n−1 条边, - 现在多给了 1 条,于是图里恰好含一个环。 - edges 按输入顺序给出,要求返回那条多余的边; - 若有多条候选,返回在输入里最后出现的那条。 + 题意:一棵 n 个节点的树本应只有 n−1 条边,现在多给了 1 条,于是图里恰好含一个环。edges 按输入顺序给出,要求返回那条多余的边;若有多条候选,返回在输入里最后出现的那条。

    - 思路:树的定义是「连通且无环」。逐条边做 union: - 正常情况下一条边的两个端点分属不同集合,合并成功,连通块少一个; - 但一旦某条边的两端已经同根,说明它们之间早有路径, - 再接上这条边就闭合成环 —— 它就是那条冗余边。 + 思路:树的定义是「连通且无环」。逐条边做 union:正常情况下一条边的两个端点分属不同集合,合并成功,连通块少一个;但一旦某条边的两端已经同根,说明它们之间早有路径,再接上这条边就闭合成环 —— 它就是那条冗余边。

    为什么天然就是「最后出现」的那条? - 图里恰好只有一个环。不在环上的边连接的是两个原本分离的部分, - 永远不会触发这个判断;而环上的边中,除了输入顺序里最后一条, - 其余都在环闭合之前就加进去了。所以触发判断的边是 - 唯一可能触发的那条,而它正是环上输入顺序最后的那条边。 - 下面按边逐步合并 parent 数组,冲突边的两端会被标出。 + 图里恰好只有一个环。不在环上的边连接的是两个原本分离的部分,永远不会触发这个判断;而环上的边中,除了输入顺序里最后一条,其余都在环闭合之前就加进去了。所以触发判断的边是 + 唯一可能触发的那条,而它正是环上输入顺序最后的那条边。下面按边逐步合并 parent 数组,冲突边的两端会被标出。

    } @@ -2390,8 +2275,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, zh: ( <> 节点编号从 1 开始,所以数组开 n + 1 格,0 - 号位闲置。用 connected 判断意图最直白; - 也可以写成 if (!uf.union(u, v)) return e; —— + 号位闲置。用 connected 判断意图最直白;也可以写成 if (!uf.union(u, v)) return e; —— §04 让 union 返回 boolean 正是为了这个场景。 ), @@ -2465,13 +2349,8 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, } zh={

    - 时间 O(n·α(n)):n 条边,每条边一次 union, - 每次 union 内含两次 find;空间 O(n) 存 parent 数组。 - 追问:LC 685 冗余连接 II 把图改成有向,难度陡增 —— - 多出来的边会造成两类问题:某个节点入度变成 2(有了两个父节点), - 或者图里出现有向环,两者还可能同时发生。 - 必须先分类讨论、锁定候选边,再用并查集验证, - 不能像本题一样一路 union 到底。 + 时间 O(n·α(n)):n 条边,每条边一次 union,每次 union 内含两次 find;空间 O(n) 存 parent 数组。追问:LC 685 冗余连接 II 把图改成有向,难度陡增 —— + 多出来的边会造成两类问题:某个节点入度变成 2(有了两个父节点),或者图里出现有向环,两者还可能同时发生。必须先分类讨论、锁定候选边,再用并查集验证,不能像本题一样一路 union 到底。

    } /> @@ -2528,21 +2407,15 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`,

    题意:一张只含 '1'(陆地)和 '0'(水) 的网格,上下左右相邻的陆地连成一座岛,求岛屿总数。 - 更常见的解法是 DFS/BFS 淹没法,图论章会细讲。 - 这里换并查集视角:把每个陆地格子看成一个节点, - 相邻的两块陆地之间连一条边,最后剩几个连通块就是几座岛。 + 更常见的解法是 DFS/BFS 淹没法,图论章会细讲。这里换并查集视角:把每个陆地格子看成一个节点,相邻的两块陆地之间连一条边,最后剩几个连通块就是几座岛。

    两个要点。其一,二维坐标 (r, c) 要先拍扁成一维下标{" "} id = r×cols + c 才能放进 parent 数组 —— - 这正是数组章讲过的行主序(row-major)展平。 - 其二,每格只需向右邻和下邻看:左邻、上邻在处理更早的格子时 - 已经连过了,重复检查纯属浪费 —— 和 547 只扫上三角是同一个道理。 + 这正是数组章讲过的行主序(row-major)展平。其二,每格只需向右邻和下邻看:左邻、上邻在处理更早的格子时已经连过了,重复检查纯属浪费 —— 和 547 只扫上三角是同一个道理。

    - count 初始为陆地总数(先假设每块陆地各是一座岛), - 之后每成功 union 一次就少一座岛。 - 下面把 2×4 网格拍扁,扫描并合并,看 count 从 5 递减。 + count 初始为陆地总数(先假设每块陆地各是一座岛),之后每成功 union 一次就少一座岛。下面把 2×4 网格拍扁,扫描并合并,看 count 从 5 递减。

    } @@ -2738,16 +2611,13 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, zh={ <>

    - 平心而论,这道题 DFS 淹没法更短也更好读: - 从任一块陆地出发,递归把整座岛染成水,发起了几次染色就是几座岛。 + 平心而论,这道题 DFS 淹没法更短也更好读:从任一块陆地出发,递归把整座岛染成水,发起了几次染色就是几座岛。 图论章会用这个思路把本题再解一遍。 并查集在这里并不占优:要多写一个模板类,还得手动做二维转一维。

    那并查集的主场在哪?在程序运行过程中不断新增陆地的场景 —— - 见 LC 305《岛屿数量 II》:新陆地一块块冒出来, - 每加一块都要立刻报出当前岛数。DFS 每次都得重扫全图, - 而并查集只需把新格子和它的四个邻居 union 一下,随到随答。 + 见 LC 305《岛屿数量 II》:新陆地一块块冒出来,每加一块都要立刻报出当前岛数。DFS 每次都得重扫全图,而并查集只需把新格子和它的四个邻居 union 一下,随到随答。 静态网格数一次用 DFS,持续动态合并用并查集。

    @@ -2810,8 +2680,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, zh: ( <> 并查集只做两件事:find 返回一个元素所在集合的根,union - 合并两个集合。两个元素连通,当且仅当它们的根相同。 - 它放弃路径信息,换来廉价的动态连通性维护。 + 合并两个集合。两个元素连通,当且仅当它们的根相同。它放弃路径信息,换来廉价的动态连通性维护。 ), }, @@ -2826,9 +2695,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, ), zh: ( <> - 一个 parent 数组就是整片森林。parent[i] = i 标记根, - 不额外占内存,还让 find 的循环条件变成一次比较。 - 继堆之后,这是第二次「数组扮演树」。 + 一个 parent 数组就是整片森林。parent[i] = i 标记根,不额外占内存,还让 find 的循环条件变成一次比较。继堆之后,这是第二次「数组扮演树」。 ), }, @@ -2846,8 +2713,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, zh: ( <> 两个优化各司其职:路径压缩在 find 时压平已经形成的路径, - 按秩合并在 union 时阻止高树长出来。 - 单用其中一个是均摊 O(log n);两个叠加,m 次操作是 O(m·α(n)) —— + 按秩合并在 union 时阻止高树长出来。单用其中一个是均摊 O(log n);两个叠加,m 次操作是 O(m·α(n)) —— 近乎常数,但不是 O(1)。 ), @@ -2864,9 +2730,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, ), zh: ( <> - union 永远先找出两边的根 —— 它合并的是集合,不是个别元素。 - 统计连通块时,count 初始为 n,只在 union 真的合并了才减 1; - 两端本来就同根的 union 绝不能减。 + union 永远先找出两边的根 —— 它合并的是集合,不是个别元素。统计连通块时,count 初始为 n,只在 union 真的合并了才减 1;两端本来就同根的 union 绝不能减。 ), }, @@ -2883,8 +2747,7 @@ uf.union(ids.get("a@x.com"), ids.get("b@x.com"));`, ), zh: ( <> - 选型口诀:连接不断加入、只问连通性 → 并查集; - 要具体路径 → BFS/DFS;key 不是 0 到 n−1 的整数 → + 选型口诀:连接不断加入、只问连通性 → 并查集;要具体路径 → BFS/DFS;key 不是 0 到 n−1 的整数 → 先用哈希表映射成编号;需要把元素从集合里拿走 → 并查集不能拆分,若能提前看到全部操作,就倒序处理。 diff --git a/app/union-find/viz.tsx b/app/union-find/viz.tsx index 7cd59da..af63956 100644 --- a/app/union-find/viz.tsx +++ b/app/union-find/viz.tsx @@ -372,8 +372,7 @@ export function UFLab({ zh={ <> 按秩合并已生效:每次都是矮树挂高树,最深的节点距离根也只有{" "} - {depth} 步。单靠按秩合并,树高就被压在 O(log n) 以内。 - 关掉开关再跑一次,对比一下。 + {depth} 步。单靠按秩合并,树高就被压在 O(log n) 以内。关掉开关再跑一次,对比一下。 } /> @@ -391,9 +390,7 @@ export function UFLab({ zh={ <> 退化情形:不按秩合并时,每次都把旧的根挂到一个新的单节点下面, - 10 个元素连成了一条链。最深的节点距离根 {depth} 步, - 此时 find 的代价是 O(n),和扫一遍链表没有区别。 - 打开下面的开关再跑一次。 + 10 个元素连成了一条链。最深的节点距离根 {depth} 步,此时 find 的代价是 O(n),和扫一遍链表没有区别。打开下面的开关再跑一次。 } /> diff --git a/lib/advanced-problems.tsx b/lib/advanced-problems.tsx index f6382f5..263b759 100644 --- a/lib/advanced-problems.tsx +++ b/lib/advanced-problems.tsx @@ -36,10 +36,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 前缀和:pre[i] = 前 i 个元素之和,sumRange(l, r) = pre[r+1] − pre[l]。 - 预处理 O(n),此后每次查询 O(1),实现只要三行。把它和 LC 307 - 放在一起看:两题只差「数组能不能改」这一个条件,答案就从三行前缀和 - 变成一棵线段树 —— 操作组合决定选型,这对姊妹题讲得最清楚。 + 前缀和:pre[i] = 前 i 个元素之和,sumRange(l, r) = pre[r+1] − pre[l]。预处理 O(n),此后每次查询 O(1),实现只要三行。把它和 LC 307 + 放在一起看:两题只差「数组能不能改」这一个条件,答案就从三行前缀和变成一棵线段树 —— 操作组合决定选型,这对姊妹题讲得最清楚。 ), }, @@ -72,8 +70,7 @@ export const PROBLEMS: Problem[] = [ <> 取一个质数当桶数(比如 769),每个桶挂一条链表(或小的动态数组): hash(key) = key % 769 定位桶,桶内线性查找处理冲突。add / remove / - contains 平均 O(1)。用本章的眼光重看:哈希集合本身就是「数组 + 链表」 - 拼出来的组合结构 —— 你在第 6 章就已经见过组合设计了。 + contains 平均 O(1)。用本章的眼光重看:哈希集合本身就是「数组 + 链表」拼出来的组合结构 —— 你在第 6 章就已经见过组合设计了。 ), }, @@ -107,10 +104,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 哈希表存 key → 链表节点引用(一步定位),双向链表按访问序排列 - (头新尾旧,维护顺序)。get 命中就把节点摘下来插回头部;put 超容量就摘掉 - tail.prev,并同步删除它的哈希条目。哑头哑尾省掉全部判空。 - 必须双向:摘除一个节点要改前驱的 next,单链表的节点拿不到前驱。§02 + 哈希表存 key → 链表节点引用(一步定位),双向链表按访问序排列(头新尾旧,维护顺序)。get 命中就把节点摘下来插回头部;put 超容量就摘掉 + tail.prev,并同步删除它的哈希条目。哑头哑尾省掉全部判空。必须双向:摘除一个节点要改前驱的 next,单链表的节点拿不到前驱。§02 有完整推导 + 逐行实现 + 交互实验室。 ), @@ -145,9 +140,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> pre[i][j] = 以 (0,0) 到 (i−1,j−1) 为对角的矩形和。任意子矩形 = - pre[r2+1][c2+1] − pre[r1][c2+1] − pre[r2+1][c1] + pre[r1][c1]: - 减上、减左,左上角那块被减了两次,再加回来一次。预处理 O(mn),查询 O(1)。 - 建表时用同一个思路:pre[i][j] = 元素 + 上 + 左 − 左上。 + pre[r2+1][c2+1] − pre[r1][c2+1] − pre[r2+1][c1] + pre[r1][c1]:减上、减左,左上角那块被减了两次,再加回来一次。预处理 O(mn),查询 O(1)。建表时用同一个思路:pre[i][j] = 元素 + 上 + 左 − 左上。 ), }, @@ -216,11 +209,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 三份状态:key → (val, freq);freq → 该频次的有序桶(桶内按时间序, - 天然是个小 LRU);再加一个 minFreq 变量。访问 = 把 key 从 freq 桶搬进 - freq+1 桶;淘汰 = 移除 minFreq 桶里最老的 key,也就是「频次最低者中最久未用的那个」。 - minFreq 不用搜索:旧桶被搬空时 +1,插入新 key 时归 1。所有操作 O(1)。 - §03 有分桶图解和三语言核心实现。 + 三份状态:key → (val, freq);freq → 该频次的有序桶(桶内按时间序,天然是个小 LRU);再加一个 minFreq 变量。访问 = 把 key 从 freq 桶搬进 + freq+1 桶;淘汰 = 移除 minFreq 桶里最老的 key,也就是「频次最低者中最久未用的那个」。minFreq 不用搜索:旧桶被搬空时 +1,插入新 key 时归 1。所有操作 O(1)。§03 有分桶图解和三语言核心实现。 ), }, @@ -257,9 +247,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 先把数值离散化成排名 1..n(排序去重后二分定位)。从右到左遍历:ans[i] = - query(rank − 1)(已登记的、比它小的个数),然后 add(rank, 1) 把自己登记进去。 - 「边插入、边问前缀和」正是树状数组的主场,整体 O(n log n)。 - 归并排序统计逆序对也能解,但 BIT 版短得多。 + query(rank − 1)(已登记的、比它小的个数),然后 add(rank, 1) 把自己登记进去。「边插入、边问前缀和」正是树状数组的主场,整体 O(n log n)。归并排序统计逆序对也能解,但 BIT 版短得多。 ), }, @@ -295,9 +283,7 @@ export const PROBLEMS: Problem[] = [ <> 每个节点存一个 next[] 指针数组(长度 = 它的层数)。search 从顶层开始,右邻比目标小就向右走,否则下楼;add 先像 search - 一样走一遍、记下每层最后停留的节点 update[i],再逐层做普通链表插入 - (层数由抛硬币决定:每多长一层的概率是 1/2);erase 同理逐层绕过目标节点。 - 期望 O(log n),§06 有完整三语言实现。 + 一样走一遍、记下每层最后停留的节点 update[i],再逐层做普通链表插入(层数由抛硬币决定:每多长一层的概率是 1/2);erase 同理逐层绕过目标节点。期望 O(log n),§06 有完整三语言实现。 ), }, diff --git a/lib/advanced-quiz.tsx b/lib/advanced-quiz.tsx index 079d6bf..d295aab 100644 --- a/lib/advanced-quiz.tsx +++ b/lib/advanced-quiz.tsx @@ -60,8 +60,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> Java 里 new LinkedHashMap<>(cap, 0.75f, true) 加一个{" "} - removeEldestEntry 覆写,就是一个现成的 LRU。它能做到这一点, - 靠的是内部本来就维护着什么? + removeEldestEntry 覆写,就是一个现成的 LRU。它能做到这一点,靠的是内部本来就维护着什么? ), }, diff --git a/lib/array-problems.tsx b/lib/array-problems.tsx index b77b5ab..395f389 100644 --- a/lib/array-problems.tsx +++ b/lib/array-problems.tsx @@ -112,9 +112,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 标准闭区间二分:while (left ≤ right),nums[mid] 与 target 分三种情况。 - 写完默背三个细节:循环条件、区间怎么收缩、Java 里防溢出的 mid 写法。 - 这是后续所有二分变体(找边界、旋转数组)的地基。 + 标准闭区间二分:while (left ≤ right),nums[mid] 与 target 分三种情况。写完默背三个细节:循环条件、区间怎么收缩、Java 里防溢出的 mid 写法。这是后续所有二分变体(找边界、旋转数组)的地基。 ), }, @@ -141,8 +139,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 与 704 同一个模板,唯一的区别是找不到时返回 left —— - 循环结束时 left 正好是「第一个 ≥ target 的位置」。 - 理解这一点,就理解了二分找边界的本质(C++ 的 lower_bound、Python 的 + 循环结束时 left 正好是「第一个 ≥ target 的位置」。理解这一点,就理解了二分找边界的本质(C++ 的 lower_bound、Python 的 bisect_left)。 ), @@ -171,8 +168,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 三指针从后往前:p1 指 nums1 的有效尾、p2 指 nums2 的尾、p 指 nums1 - 真正的尾。每次把较大者写到 p。从后往前写就不会覆盖还没处理的元素, - 这个「倒着来」的技巧在原地类题目里反复出现。 + 真正的尾。每次把较大者写到 p。从后往前写就不会覆盖还没处理的元素,这个「倒着来」的技巧在原地类题目里反复出现。 ), }, @@ -202,8 +198,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 一次遍历,维护 minPrice 与 maxProfit:每到一天,先用 prices[i] − minPrice - 更新答案,再用 prices[i] 更新 minPrice。时间 O(n)、空间 O(1)。 - 本质是「前缀最小值」思想 —— 很多数组题都是在维护某种前缀信息。 + 更新答案,再用 prices[i] 更新 minPrice。时间 O(n)、空间 O(1)。本质是「前缀最小值」思想 —— 很多数组题都是在维护某种前缀信息。 ), }, @@ -230,8 +225,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> slow 指向「下一个非零元素该放的位置」,fast 扫完全程,遇到非零就与 slow - 交换并 slow++。一次遍历,时间 O(n)、空间 O(1),且保持相对顺序。 - 本章 §08 有逐帧动画。 + 交换并 slow++。一次遍历,时间 O(n)、空间 O(1),且保持相对顺序。本章 §08 有逐帧动画。 ), }, @@ -261,8 +255,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 对撞指针:sum > target 时 right--(只有减小右边的值才能让和变小), - sum < target 时 left++。每一步都能安全排除一个元素,总共 O(n)。 - 「有序 + 找配对」几乎总是对撞指针的信号。 + sum < target 时 left++。每一步都能安全排除一个元素,总共 O(n)。「有序 + 找配对」几乎总是对撞指针的信号。 ), }, @@ -292,8 +285,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 第一遍从左到右,把「左侧前缀积」写进答案数组;第二遍从右到左, - 用一个变量滚动「右侧后缀积」乘上去。时间 O(n),不算输出数组的话额外空间 + 第一遍从左到右,把「左侧前缀积」写进答案数组;第二遍从右到左,用一个变量滚动「右侧后缀积」乘上去。时间 O(n),不算输出数组的话额外空间 O(1)。前缀思想(前缀和 / 前缀积 / 前缀最值)是数组题的常用零件。 ), @@ -320,9 +312,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - Kadane 算法:cur = max(nums[i], cur + nums[i]),ans = max(ans, cur)。 - 一旦前面那段的和变成负数,它只会让后面的和更小,直接丢弃。 - 时间 O(n)、空间 O(1)。它也是进入动态规划最短的一条路:cur + Kadane 算法:cur = max(nums[i], cur + nums[i]),ans = max(ans, cur)。一旦前面那段的和变成负数,它只会让后面的和更小,直接丢弃。时间 O(n)、空间 O(1)。它也是进入动态规划最短的一条路:cur 的状态定义就是「以 i 结尾的子数组的最大和」。 ), @@ -349,8 +339,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 按 start 排序后线性扫描:当前区间的 start ≤ 结果集最后一个区间的 end - 就合并(end 取较大者),否则新开一段。排序主导,总共 O(n log n)。 - 区间题的第一反应:排序 + 一次扫描。 + 就合并(end 取较大者),否则新开一段。排序主导,总共 O(n log n)。区间题的第一反应:排序 + 一次扫描。 ), }, @@ -377,8 +366,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 排序 O(n log n);外层枚举 i(跳过与前一个相同的值),内层用 left/right - 对撞找 −nums[i],命中后两侧同时跳过重复值。总共 O(n²)。 - 「固定一维、把剩下的降为双指针」是三数之和 / 四数之和的通用打法。 + 对撞找 −nums[i],命中后两侧同时跳过重复值。总共 O(n²)。「固定一维、把剩下的降为双指针」是三数之和 / 四数之和的通用打法。 ), }, @@ -403,9 +391,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 先 k %= n,然后 reverse(0, n−1) → reverse(0, k−1) → reverse(k, n−1)。 - 时间 O(n)、空间 O(1)。别忘了 k %= n:k 大于 n 时不取模会越界。 - 翻转技巧是数组原地重排的常客,值得单独记住。 + 先 k %= n,然后 reverse(0, n−1) → reverse(0, k−1) → reverse(k, n−1)。时间 O(n)、空间 O(1)。别忘了 k %= n:k 大于 n 时不取模会越界。翻转技巧是数组原地重排的常客,值得单独记住。 ), }, @@ -432,8 +418,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 四边界模拟:每走完一条边就收缩对应边界(top++ / right-- / bottom-- / - left++),边界交错时停止。走反向的两条边之前要再检查一次 top ≤ bottom、 - left ≤ right,否则单行或单列会被重复输出 —— 这是本题唯一容易出错的地方。 + left++),边界交错时停止。走反向的两条边之前要再检查一次 top ≤ bottom、left ≤ right,否则单行或单列会被重复输出 —— 这是本题唯一容易出错的地方。 ), }, @@ -458,9 +443,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 转置(交换 matrix[i][j] 与 matrix[j][i],只扫上三角)+ 每行 reverse, - 两步都是原地操作,额外空间 O(1)。把一个几何操作拆成两个简单变换, - 比背「四点轮换」公式好记得多。 + 转置(交换 matrix[i][j] 与 matrix[j][i],只扫上三角)+ 每行 reverse,两步都是原地操作,额外空间 O(1)。把一个几何操作拆成两个简单变换,比背「四点轮换」公式好记得多。 ), }, @@ -487,9 +470,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 用第一行和第一列记录「该行 / 该列要清零」,再用两个布尔变量单独记录第一行、 - 第一列自身是否含零。先扫内部区域做标记,再按标记清零,最后处理第一行和第一列。 - 「借数据自身当标记空间」是原地算法的经典手法。 + 用第一行和第一列记录「该行 / 该列要清零」,再用两个布尔变量单独记录第一行、第一列自身是否含零。先扫内部区域做标记,再按标记清零,最后处理第一行和第一列。「借数据自身当标记空间」是原地算法的经典手法。 ), }, @@ -517,8 +498,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 对撞指针 + 两侧最大值:维护 leftMax / rightMax,哪一边的最大值更小就处理哪一边 - —— 那一边的限制已经确定,另一边不可能更矮。累加 max − height 后把指针内移。 - 时间 O(n)、空间 O(1)。它也可以用单调栈解,栈那一章会再遇到同一道题。 + —— 那一边的限制已经确定,另一边不可能更矮。累加 max − height 后把指针内移。时间 O(n)、空间 O(1)。它也可以用单调栈解,栈那一章会再遇到同一道题。 ), }, diff --git a/lib/binary-tree-problems.tsx b/lib/binary-tree-problems.tsx index 793b1d9..1437f74 100644 --- a/lib/binary-tree-problems.tsx +++ b/lib/binary-tree-problems.tsx @@ -42,8 +42,7 @@ export const PROBLEMS: Problem[] = [ 终止条件:两个都空 → true;只有一个空 → false。 合并这一步:值必须相等,并且{" "} same(p.left, q.left) 与{" "} - same(p.right, q.right) 都为 true。较小那棵树的每个节点只访问一次, - 时间 O(n),栈空间 O(h)。这是「双树同步递归」的模板 —— LC 101(对称) + same(p.right, q.right) 都为 true。较小那棵树的每个节点只访问一次,时间 O(n),栈空间 O(h)。这是「双树同步递归」的模板 —— LC 101(对称) 把它改成左对右的镜像版,LC 572(子树)在外面再套一层。 ), @@ -78,11 +77,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 陷阱:最小深度是到叶子的距离,而只有一个孩子的节点不是叶子。 - 直接写 1 + min(左, 右) 会在这种节点上返回 1 —— 空的那侧报了 0。 - 所以递归要特判:一侧为空时只能走另一侧。更干净的解法是 BFS: - 层序遍历,出队时遇到的第一个没有孩子的节点就是答案。它在第一个叶子处就停, - 不必像 DFS 那样把整棵树走完 —— 「求最浅」是 BFS 的主场,「求最深」才是 DFS 的。 + 陷阱:最小深度是到叶子的距离,而只有一个孩子的节点不是叶子。直接写 1 + min(左, 右) 会在这种节点上返回 1 —— 空的那侧报了 0。所以递归要特判:一侧为空时只能走另一侧。更干净的解法是 BFS:层序遍历,出队时遇到的第一个没有孩子的节点就是答案。它在第一个叶子处就停,不必像 DFS 那样把整棵树走完 —— 「求最浅」是 BFS 的主场,「求最深」才是 DFS 的。 ), }, @@ -122,9 +117,7 @@ export const PROBLEMS: Problem[] = [ rest」。终止条件:空节点返回 false;叶子返回{" "} rest == node.val。合并这一步: hasPath(左, rest − val) || hasPath(右, rest − val) —— - 有一条就够。时间 O(n),栈空间 O(h)。判定必须发生在叶子上: - 不能在 rest 减到 0 时提前返回,因为下面可能还有节点继续加。 - 这是「参数下传」型递归的最小样板 —— 参数装着祖先已经贡献的部分。 + 有一条就够。时间 O(n),栈空间 O(h)。判定必须发生在叶子上:不能在 rest 减到 0 时提前返回,因为下面可能还有节点继续加。这是「参数下传」型递归的最小样板 —— 参数装着祖先已经贡献的部分。 ), }, @@ -158,11 +151,8 @@ export const PROBLEMS: Problem[] = [ zh: ( <> DFS 多带一个参数:目前拼好的路径。到叶子时拼成{" "} - "1->2->5" 收进结果。字符串参数在每次调用时被复制, - 每条分支各拿一份,不需要手动撤销;如果改用列表存路径, - 递归返回后必须把最后一个元素弹掉,兄弟分支才能从正确状态出发 —— - 这个「撤销」动作就是回溯(backtracking),本题是它第一次露面。 - 生成全部路径的时间是 O(n · h),因为路径字符串的总长度就是这个量级。 + "1->2->5" 收进结果。字符串参数在每次调用时被复制,每条分支各拿一份,不需要手动撤销;如果改用列表存路径,递归返回后必须把最后一个元素弹掉,兄弟分支才能从正确状态出发 —— + 这个「撤销」动作就是回溯(backtracking),本题是它第一次露面。生成全部路径的时间是 O(n · h),因为路径字符串的总长度就是这个量级。 ), }, @@ -202,12 +192,9 @@ export const PROBLEMS: Problem[] = [ 这是「返回值和答案不是同一个东西」最经典的例子。 返回值的含义: depth(node) 返回「从 node 往下延伸的最长路径上有几个节点」, - depth(null) = 0 —— 这是父节点需要的东西。答案是直径 - (按边数算),用递归之外的一个变量收集。在每个节点上, - depth(左) + depth(右) 正是「在这里拐弯」的那条路径的边数, - 返回前拿它挑战一下最大值。而返回给父亲的是{" "} - 1 + max(depth(左), depth(右)):路径继续往上走时只能取一侧, - 不能分叉。每个节点访问一次,时间 O(n),栈空间 O(h)。LC 124 是同一形状的困难版。 + depth(null) = 0 —— 这是父节点需要的东西。答案是直径(按边数算),用递归之外的一个变量收集。在每个节点上, + depth(左) + depth(右) 正是「在这里拐弯」的那条路径的边数,返回前拿它挑战一下最大值。而返回给父亲的是{" "} + 1 + max(depth(左), depth(右)):路径继续往上走时只能取一侧,不能分叉。每个节点访问一次,时间 O(n),栈空间 O(h)。LC 124 是同一形状的困难版。 ), }, @@ -247,12 +234,10 @@ export const PROBLEMS: Problem[] = [ <> 朴素做法在每个节点都单独调一次求高度的函数,高度被反复重算,最坏 O(n²)。 改法:在同一次后序遍历里既算高度、又判平衡。返回值的含义: - 按节点数计的子树高度(空树为 0、叶子为 1),或者用 −1 表示「下面已经失衡了」。 - 这里改按节点数计高度(本章其他地方按边数计,空树为 −1),正是为了把 −1 空出来当信号。 + 按节点数计的子树高度(空树为 0、叶子为 1),或者用 −1 表示「下面已经失衡了」。这里改按节点数计高度(本章其他地方按边数计,空树为 −1),正是为了把 −1 空出来当信号。 合并这一步:任何一个孩子返回 −1 就立刻返回 −1;否则若{" "} |左 − 右| > 1 返回 −1;否则返回{" "} - 1 + max(左, 右)。一趟走完,时间 O(n),栈空间 O(h)。 - 「用一个特殊返回值携带失败信号」是自底向上递归的常用技巧。 + 1 + max(左, 右)。一趟走完,时间 O(n),栈空间 O(h)。「用一个特殊返回值携带失败信号」是自底向上递归的常用技巧。 ), }, @@ -284,9 +269,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - BFS:层序遍历,每层取最后一个节点,分层靠「先记 size」。 - DFS 版同样简短:按「根 → 右 → 左」的顺序遍历,并把当前深度当参数带下去, - 每个深度第一次到达的节点就是右视图,满足{" "} + BFS:层序遍历,每层取最后一个节点,分层靠「先记 size」。DFS 版同样简短:按「根 → 右 → 左」的顺序遍历,并把当前深度当参数带下去,每个深度第一次到达的节点就是右视图,满足{" "} depth == 结果长度 时收进答案。两者时间都是 O(n); BFS 吃 O(w) 队列空间,DFS 吃 O(h) 栈空间。面试常在你答完一种后追问另一种。 @@ -324,11 +307,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 前序 = [根 | 左子树 | 右子树],中序 = [左子树 | 根 | 右子树]。 - 每一轮:取前序的首元素当根,在中序区间里定位它,读出左半部分的长度 k; - k 就告诉你前序里哪 k 个值属于左子树 —— 两边各自递归到更小的区间。 - 每次都去中序里线性查找是最坏 O(n²);先用哈希表存好「值 → 下标」, - 每次查找降到 O(1),整体 O(n)。前提是节点值互不相同 —— 题目保证了这一点。 + 前序 = [根 | 左子树 | 右子树],中序 = [左子树 | 根 | 右子树]。每一轮:取前序的首元素当根,在中序区间里定位它,读出左半部分的长度 k; + k 就告诉你前序里哪 k 个值属于左子树 —— 两边各自递归到更小的区间。每次都去中序里线性查找是最坏 O(n²);先用哈希表存好「值 → 下标」,每次查找降到 O(1),整体 O(n)。前提是节点值互不相同 —— 题目保证了这一点。 ), }, @@ -366,13 +346,9 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 自底向上:先递归展开左、右子树,再用三步接线 —— 把展开好的左链接到{" "} - root.right,沿 right 走到这条链的尾部, - 把尾部的 right 接上原来的右链,最后{" "} + root.right,沿 right 走到这条链的尾部,把尾部的 right 接上原来的右链,最后{" "} root.left = null。另有一种 Morris 风格的迭代写法, - 额外空间 O(1):对每个节点,把右子树挂到左子树最右节点的下面, - 再把左子树整体移到右边。Morris 风格的做法是靠改写树里的指针来代替栈的; - 真正的 Morris 遍历会把改动过的指针全部复原, - 而本题的改动本身就是要求的结果,所以保留。核心考点: + 额外空间 O(1):对每个节点,把右子树挂到左子树最右节点的下面,再把左子树整体移到右边。Morris 风格的做法是靠改写树里的指针来代替栈的;真正的 Morris 遍历会把改动过的指针全部复原,而本题的改动本身就是要求的结果,所以保留。核心考点: 信任递归已经把子问题做完,只设计「合并」这一步。 ), @@ -419,11 +395,7 @@ export const PROBLEMS: Problem[] = [ lca(node) 在这棵子树里既没有 p 也没有 q 时返回 null; 两个都在时返回最近公共祖先;只找到其中一个时返回找到的那一个。 终止条件:空节点返回 null;节点本身是 p 或 q 就返回自己。 - 合并这一步:左右都返回了非空 ⇒ p、q 分居两侧,当前节点就是最近公共祖先; - 只有一侧非空 ⇒ 把那一侧的结果原样上报。一趟后序,时间 O(n),栈空间 O(h)。 - 它的正确性依赖题目的前提:p 和 q 一定都在树里。 - 如果不保证,q 不存在时函数会把 p 返回上去。这题是全书讲「设计返回值语义」 - 最好的一课:返回值不是答案本身,而是携带足够信息、让父节点能下判断的信号。 + 合并这一步:左右都返回了非空 ⇒ p、q 分居两侧,当前节点就是最近公共祖先;只有一侧非空 ⇒ 把那一侧的结果原样上报。一趟后序,时间 O(n),栈空间 O(h)。它的正确性依赖题目的前提:p 和 q 一定都在树里。如果不保证,q 不存在时函数会把 p 返回上去。这题是全书讲「设计返回值语义」最好的一课:返回值不是答案本身,而是携带足够信息、让父节点能下判断的信号。 ), }, @@ -463,12 +435,9 @@ export const PROBLEMS: Problem[] = [ 返回值的含义: gain(node) 返回「从 node 出发、只向下、只走一个方向 的路径的最大和」,即{" "} - val + max(gain(左), gain(右), 0)。这个 0 是与 543 最大的不同: - 总和为负的子树不如不要。答案单独收集:在每个节点上, - val + max(gain(左), 0) + max(gain(右), 0) 是「在这里拐弯」 - 的最优路径,用它挑战全局最大值。返回值只能带一条腿 —— - 路径经过 node 继续往上时不能分叉。时间 O(n),栈空间 O(h)。 - 把 543、110、124 连着做完,「自底向上返回值 + 单独的全局答案」这一套就掌握了。 + val + max(gain(左), gain(右), 0)。这个 0 是与 543 最大的不同:总和为负的子树不如不要。答案单独收集:在每个节点上, + val + max(gain(左), 0) + max(gain(右), 0) 是「在这里拐弯」的最优路径,用它挑战全局最大值。返回值只能带一条腿 —— + 路径经过 node 继续往上时不能分叉。时间 O(n),栈空间 O(h)。把 543、110、124 连着做完,「自底向上返回值 + 单独的全局答案」这一套就掌握了。 ), }, diff --git a/lib/binary-tree-quiz.tsx b/lib/binary-tree-quiz.tsx index bb5c238..33af5a8 100644 --- a/lib/binary-tree-quiz.tsx +++ b/lib/binary-tree-quiz.tsx @@ -107,8 +107,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 一棵小树:根 4,左孩子 2(它的左右孩子是 1、3),右孩子 6。 - 它的中序遍历输出是?(数字用空格或逗号分隔) + 一棵小树:根 4,左孩子 2(它的左右孩子是 1、3),右孩子 6。它的中序遍历输出是?(数字用空格或逗号分隔) ), }, diff --git a/lib/bst-problems.tsx b/lib/bst-problems.tsx index 29df7ad..a377ac6 100644 --- a/lib/bst-problems.tsx +++ b/lib/bst-problems.tsx @@ -37,9 +37,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 从根出发:相等即命中;小往左、大往右;撞到空孩子就说明整棵树都没有。 - 迭代版一个 while 循环写完,O(h) 时间、O(1) 额外空间。 - 这是 BST 的裸模板题,后面所有题的第一步都藏着这个动作。 + 从根出发:相等即命中;小往左、大往右;撞到空孩子就说明整棵树都没有。迭代版一个 while 循环写完,O(h) 时间、O(1) 额外空间。这是 BST 的裸模板题,后面所有题的第一步都藏着这个动作。 ), }, @@ -73,10 +71,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 两条路:① 任意遍历 + 哈希集合查 k − v,O(n) 时间 O(n) 空间, - 连 BST 性质都不需要;② 中序展开成升序数组 + 对撞双指针(即 LC 167 的做法), - 同样 O(n),但用上了 BST 的有序性。面试中更进一步的答法:用两个 BST 迭代器分别从最小、 - 最大两端向中间逼近,空间压到 O(h)。 + 两条路:① 任意遍历 + 哈希集合查 k − v,O(n) 时间 O(n) 空间,连 BST 性质都不需要;② 中序展开成升序数组 + 对撞双指针(即 LC 167 的做法),同样 O(n),但用上了 BST 的有序性。面试中更进一步的答法:用两个 BST 迭代器分别从最小、最大两端向中间逼近,空间压到 O(h)。 ), }, @@ -110,9 +105,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 中序遍历时维护 prev(上一个访问的值),每步用 cur − prev 更新答案。 - 为什么只看相邻:有序序列里任意两数之差,都不小于它们之间某对相邻数之差。 - 一次中序 O(n)、栈空间 O(h)。口诀:BST 题看到「差值 / 相邻 / 第 k」先想中序。 + 中序遍历时维护 prev(上一个访问的值),每步用 cur − prev 更新答案。为什么只看相邻:有序序列里任意两数之差,都不小于它们之间某对相邻数之差。一次中序 O(n)、栈空间 O(h)。口诀:BST 题看到「差值 / 相邻 / 第 k」先想中序。 ), }, @@ -143,8 +136,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> DFS + 有序性剪枝:val < low → 左子树全体更小,只递归右子树; - val > high → 只递归左子树;落在区间内 → 累加自身 + 两边递归。 - 最坏 O(n),但剪枝能把大量子树整棵跳过 —— + val > high → 只递归左子树;落在区间内 → 累加自身 + 两边递归。最坏 O(n),但剪枝能把大量子树整棵跳过 —— 这题最能体会「BST 的有序性同时也是一种省事的手段」。 ), @@ -178,8 +170,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 递归:node 为空就返回 new TreeNode(v);v 小挂左、大挂右, - 最后返回 node 本身,让父节点重新牵手。O(h)。关键认知: + 递归:node 为空就返回 new TreeNode(v);v 小挂左、大挂右,最后返回 node 本身,让父节点重新牵手。O(h)。关键认知: 插入永远发生在一个空的孩子位置,树的中间结构一个都不动 —— 这正是 BST 比有序数组插入便宜的原因。 @@ -215,10 +206,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 从根出发:p、q 都小于当前节点 → 往左;都大 → 往右;一大一小 - (或其中一个就等于当前节点)→ 当前节点就是答案。一个循环,O(h)。 - 对照普通二叉树的 LC 236:没有有序性就得后序遍历自底向上回传信息,O(n)。 - 有序性把「全树搜索」降成了「单路下降」,两题放一起答最能体现理解深度。 + 从根出发:p、q 都小于当前节点 → 往左;都大 → 往右;一大一小(或其中一个就等于当前节点)→ 当前节点就是答案。一个循环,O(h)。对照普通二叉树的 LC 236:没有有序性就得后序遍历自底向上回传信息,O(n)。有序性把「全树搜索」降成了「单路下降」,两题放一起答最能体现理解深度。 ), }, @@ -253,8 +241,7 @@ export const PROBLEMS: Problem[] = [ 递归定位后分三种情况:没有孩子 → 返回 null;只有一个孩子 → 返回那个孩子,让父节点直接接管;两个孩子 → 把中序后继(右子树最小值)的值抄到当前节点,再去右子树里删掉后继本体 - —— 后继必无左孩子,这次删除一定落入前两种简单情况,递归自然终止。 - O(h)。§04 有完整的三语言实现,这题必须能盲写。 + —— 后继必无左孩子,这次删除一定落入前两种简单情况,递归自然终止。O(h)。§04 有完整的三语言实现,这题必须能盲写。 ), }, @@ -286,9 +273,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 维护显式栈:初始化时把根的「左脊」(根以及一路向左的所有节点)入栈; - next() 弹出栈顶返回,若它有右孩子,就把右孩子的左脊也入栈。 - 每个节点恰好入栈、出栈各一次,next 均摊 O(1)、空间 O(h)。 - 这是「把递归改造成可暂停迭代」的经典范式,653 的双迭代器解法就建立在它之上。 + next() 弹出栈顶返回,若它有右孩子,就把右孩子的左脊也入栈。每个节点恰好入栈、出栈各一次,next 均摊 O(1)、空间 O(h)。这是「把递归改造成可暂停迭代」的经典范式,653 的双迭代器解法就建立在它之上。 ), }, @@ -321,10 +306,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - BST 的中序应严格升序;两个节点被交换会制造一处(原本相邻) - 或两处(原本不相邻)逆序。中序扫描:第一处逆序取前者为 first, - 最后一处逆序取后者为 second,交换两者的值即可。O(n) 时间、O(h) 栈空间。 - 追问 O(1) 空间就答 Morris 中序 —— + BST 的中序应严格升序;两个节点被交换会制造一处(原本相邻)或两处(原本不相邻)逆序。中序扫描:第一处逆序取前者为 first,最后一处逆序取后者为 second,交换两者的值即可。O(n) 时间、O(h) 栈空间。追问 O(1) 空间就答 Morris 中序 —— 借用每个节点的中序前驱空着的右指针当临时的「回程线索」,属于进阶话题,本课不展开。 ), diff --git a/lib/bst-quiz.tsx b/lib/bst-quiz.tsx index 319d15a..722a352 100644 --- a/lib/bst-quiz.tsx +++ b/lib/bst-quiz.tsx @@ -17,8 +17,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 一棵树:根 10,左孩子 5,右孩子 15;15 的左孩子是 6、右孩子是 20。 - 每对父子关系(5<10、15>10、6<15、20>15)都合规 —— 它是 BST 吗? + 一棵树:根 10,左孩子 5,右孩子 15;15 的左孩子是 6、右孩子是 20。每对父子关系(5<10、15>10、6<15、20>15)都合规 —— 它是 BST 吗? ), }, diff --git a/lib/graph-problems.tsx b/lib/graph-problems.tsx index 155209f..470055a 100644 --- a/lib/graph-problems.tsx +++ b/lib/graph-problems.tsx @@ -48,8 +48,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 记下起点原色 old,从起点 DFS/BFS - 向四个方向扩散:只递归「颜色 == old 且在界内」的格子,进门就改成新色。 - 常见错误:若新色 == old 会无限递归,要先判 old == newColor{" "} + 向四个方向扩散:只递归「颜色 == old 且在界内」的格子,进门就改成新色。常见错误:若新色 == old 会无限递归,要先判 old == newColor{" "} 直接返回。网格题的四方向模板{" "} dirs=[[-1,0],[1,0],[0,-1],[0,1]] 从这里练熟。 @@ -92,9 +91,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 对每个未访问的陆地启动 DFS,DFS 返回它淹没的格子数(1 + - 四个方向递归之和),越界或遇水返回 0。主循环用 max 记录最大返回值。 - 时间 O(行 × 列),每格访问一次。 - 「让 DFS 带返回值统计规模」是网格题常见的扩展。 + 四个方向递归之和),越界或遇水返回 0。主循环用 max 记录最大返回值。时间 O(行 × 列),每格访问一次。「让 DFS 带返回值统计规模」是网格题常见的扩展。 ), }, @@ -145,8 +142,7 @@ export const PROBLEMS: Problem[] = [ {" "} 出发 DFS/BFS,把连通的 O 全标成安全(比如临时记为{" "} #)。扫完后,剩下还是 O 的必然被包围 → 改成{" "} - X;标过 # 的还原成 O。 - 「从边界倒着染色」避开了对每个内部区域单独判断是否触边。 + X;标过 # 的还原成 O。「从边界倒着染色」避开了对每个内部区域单独判断是否触边。 ), }, @@ -190,12 +186,9 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 多源 BFS:先把所有初始烂橘子一次性入队(这是本题精髓), - 并记录新鲜橘子总数。然后一层层扩散, + 多源 BFS:先把所有初始烂橘子一次性入队(这是本题精髓),并记录新鲜橘子总数。然后一层层扩散, 每处理完一层 minute += 1 - (用「先记 size」的按层技巧,呼应二叉树层序); - 每腐蚀一个新鲜橘子就把计数减 1。最后若还有新鲜橘子返回 -1。 - 每一步的代价都是同样的一分钟,所以 BFS 的层数正好等于经过的时间 —— + (用「先记 size」的按层技巧,呼应二叉树层序);每腐蚀一个新鲜橘子就把计数减 1。最后若还有新鲜橘子返回 -1。每一步的代价都是同样的一分钟,所以 BFS 的层数正好等于经过的时间 —— 这就是它比 DFS 适合此题的原因。 ), @@ -242,8 +235,7 @@ export const PROBLEMS: Problem[] = [ 用哈希表 Map<原节点, 克隆节点> 当「访问记录 + 去重表」二合一。DFS/BFS 到某点:若 map 里已有克隆就直接返回(防环、防重复);否则新建克隆 - 先存进 map,再递归克隆每个邻居并接到克隆节点的邻居表上。 - 「先存再递归」正是环能终止的原因。图题里 visited + 先存进 map,再递归克隆每个邻居并接到克隆节点的邻居表上。「先存再递归」正是环能终止的原因。图题里 visited 常常就是一张顺便携带数据的哈希表。 ), @@ -287,11 +279,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - Kahn 入度法:建邻接表 + 入度数组,把入度为 0 的课先入队; - 每出队一门课就追加到答案序列,并把它后继课的入度各减 1,减到 0 的入队。 - 若最终答案长度 == 课程数就是合法拓扑序,否则说明剩下的课困在环里, - 返回空数组。DFS 写法同样可行:后序遍历的逆序就是一个拓扑序。 - 两种都是 O(V + E)。 + Kahn 入度法:建邻接表 + 入度数组,把入度为 0 的课先入队;每出队一门课就追加到答案序列,并把它后继课的入度各减 1,减到 0 的入队。若最终答案长度 == 课程数就是合法拓扑序,否则说明剩下的课困在环里,返回空数组。DFS 写法同样可行:后序遍历的逆序就是一个拓扑序。两种都是 O(V + E)。 ), }, @@ -315,8 +303,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 顺着「水往低处流」逐格判断太贵。反过来想:从海洋边界出发, - 水能倒着「爬」到哪些格子? + 顺着「水往低处流」逐格判断太贵。反过来想:从海洋边界出发,水能倒着「爬」到哪些格子? ), }, @@ -336,11 +323,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 逆向思维:分别从太平洋边界(上边 + 左边)和大西洋边界(下边 + 右边)出发, - 只走「高度 ≥ 当前格」的方向 —— 这正是「水往低处流」的反向。 - 各自标记能到达的格子,得到两个布尔矩阵, - 两个集合的交集就是答案。 - 两个关键转念:多个起点一起搜、把「流出去」反转成「能被谁到达」。 + 逆向思维:分别从太平洋边界(上边 + 左边)和大西洋边界(下边 + 右边)出发,只走「高度 ≥ 当前格」的方向 —— 这正是「水往低处流」的反向。各自标记能到达的格子,得到两个布尔矩阵, + 两个集合的交集就是答案。两个关键转念:多个起点一起搜、把「流出去」反转成「能被谁到达」。 ), }, @@ -367,8 +351,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 普通 Dijkstra 会被「最多中转 K 站」绊住: - 到某城最便宜的走法可能中转太多次。步数受限时,要按「轮」推进。 + 普通 Dijkstra 会被「最多中转 K 站」绊住:到某城最便宜的走法可能中转太多次。步数受限时,要按「轮」推进。 ), }, @@ -391,11 +374,8 @@ export const PROBLEMS: Problem[] = [ <> 最多中转 K 站 = 最多走 K + 1 条边,所以做{" "} K + 1 轮 Bellman-Ford 松弛。每一轮都基于 - 上一轮 dist 的快照去更新所有边(关键:用临时数组, - 否则一轮内的连续松弛会相互影响,使走过的边数超过限制)。 - 也可用带「已用步数」的 BFS 分层扩展。普通 Dijkstra - 在这里不成立:它可能通过一条已经太长的路径把某个城市便宜地定案。 - 这题正好点出「最短路算法要看约束选型」。 + 上一轮 dist 的快照去更新所有边(关键:用临时数组,否则一轮内的连续松弛会相互影响,使走过的边数超过限制)。也可用带「已用步数」的 BFS 分层扩展。普通 Dijkstra + 在这里不成立:它可能通过一条已经太长的路径把某个城市便宜地定案。这题正好点出「最短路算法要看约束选型」。 ), }, @@ -440,10 +420,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 隐式图 + BFS:顶点是单词,边是「相差一个字母」。 - 不预先建完整图(边太多),而是 BFS 出队一个词时,枚举每一位换 26 - 个字母,在词表(哈希集合)里找存在且未访问的邻居入队。 - 每条边代价相同,所以 BFS 给出的层数就是最少转换次数。进阶可用 + 隐式图 + BFS:顶点是单词,边是「相差一个字母」。不预先建完整图(边太多),而是 BFS 出队一个词时,枚举每一位换 26 + 个字母,在词表(哈希集合)里找存在且未访问的邻居入队。每条边代价相同,所以 BFS 给出的层数就是最少转换次数。进阶可用 双向 BFS:从首尾同时搜、在中间相遇,两边各自要搜的深度都减半。 ), diff --git a/lib/graph-quiz.tsx b/lib/graph-quiz.tsx index a9edf93..22d8ba5 100644 --- a/lib/graph-quiz.tsx +++ b/lib/graph-quiz.tsx @@ -259,8 +259,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 在一张边权全为 1(或无权)的图上,求从起点到各点的最短路径, - 最合适的算法是?(填算法名) + 在一张边权全为 1(或无权)的图上,求从起点到各点的最短路径,最合适的算法是?(填算法名) ), }, diff --git a/lib/hash-problems.tsx b/lib/hash-problems.tsx index 836e134..98a2afe 100644 --- a/lib/hash-problems.tsx +++ b/lib/hash-problems.tsx @@ -34,10 +34,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 遍历数组,每个元素先问 Set「见过它吗」:见过就返回 true; - 没见过就 add 进去继续。平均 O(n) 时间、O(n) 额外空间。 - 对比排序解法:O(n log n) 时间、不额外占空间。用空间换时间, - 是本章所有题目的共同底色。 + 遍历数组,每个元素先问 Set「见过它吗」:见过就返回 true;没见过就 add 进去继续。平均 O(n) 时间、O(n) 额外空间。对比排序解法:O(n log n) 时间、不额外占空间。用空间换时间,是本章所有题目的共同底色。 ), }, @@ -67,10 +64,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - Map 存「值 → 最近一次出现的下标」。走到 nums[i] 时,若表里有它, - 且 i − 上次下标 ≤ k,返回 true;否则把下标更新成 i。旧下标只会更远, - 以后永远用不上,可以直接覆盖。平均 O(n) 时间、O(n) 空间。 - 用「长度为 k 的滑动窗口 Set」也可以,思路等价。 + Map 存「值 → 最近一次出现的下标」。走到 nums[i] 时,若表里有它,且 i − 上次下标 ≤ k,返回 true;否则把下标更新成 i。旧下标只会更远,以后永远用不上,可以直接覆盖。平均 O(n) 时间、O(n) 空间。用「长度为 k 的滑动窗口 Set」也可以,思路等价。 ), }, @@ -102,9 +96,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 第一遍:统计 magazine 里 26 个字母各有几个。第二遍:遍历 ransomNote - 逐个扣减,任何一个扣成负数就是库存不足,返回 false。字符集固定且很小时, - 用长度 26 的数组代替 HashMap:下标就是字母本身,连哈希函数都不需要。 - 计数数组就是哈希函数最简单的那种哈希表。O(m + n) 时间、O(1) + 逐个扣减,任何一个扣成负数就是库存不足,返回 false。字符集固定且很小时,用长度 26 的数组代替 HashMap:下标就是字母本身,连哈希函数都不需要。计数数组就是哈希函数最简单的那种哈希表。O(m + n) 时间、O(1) 额外空间(26 不随输入增长)。 ), @@ -137,11 +129,9 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 建立双向映射:char → word 和 word → char 两张表。 - 任何一边已经配了别的伙伴,就返回 false。只存单向会漏判: + 建立双向映射:char → word 和 word → char 两张表。任何一边已经配了别的伙伴,就返回 false。只存单向会漏判: pattern = "abba"、s = "dog dog dog dog" 时, - char → word 只记下 a→dog、b→dog,看起来毫无矛盾; - 反向表才能发现 dog 已经被 a 占用。LC 205(同构字符串) + char → word 只记下 a→dog、b→dog,看起来毫无矛盾;反向表才能发现 dog 已经被 a 占用。LC 205(同构字符串) 是两边都换成字符的同型题。 ), @@ -173,10 +163,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 反复把数替换成「各位数字平方和」,过程要么到 1,要么进入一个永不停止的循环。 - 把每个中间值放进 Set,某个值第二次出现就说明入环,返回 false。 - 每次变换要处理这个数的每一位,是 O(log n) 的工作量;Set 让「是否重复」 - 的判断平均只花 O(1)。追问「不用额外空间行吗」:用链表章的快慢指针判环。 + 反复把数替换成「各位数字平方和」,过程要么到 1,要么进入一个永不停止的循环。把每个中间值放进 Set,某个值第二次出现就说明入环,返回 false。每次变换要处理这个数的每一位,是 O(log n) 的工作量;Set 让「是否重复」的判断平均只花 O(1)。追问「不用额外空间行吗」:用链表章的快慢指针判环。 ), }, @@ -207,11 +194,9 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - nums1 全部入 Set A;遍历 nums2,凡在 A 中的加入结果 Set(自动去重), - 最后转成数组。平均 O(m + n)。三种语言都内置了这类运算:Python{" "} + nums1 全部入 Set A;遍历 nums2,凡在 A 中的加入结果 Set(自动去重),最后转成数组。平均 O(m + n)。三种语言都内置了这类运算:Python{" "} set(a) & set(b)、JS filter +{" "} - has、Java retainAll —— 交集、并集这类集合运算, - 正是 Set 这个类型存在的理由。 + has、Java retainAll —— 交集、并集这类集合运算,正是 Set 这个类型存在的理由。 ), }, @@ -243,9 +228,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 把 A + B + C + D = 0 改写成 (A + B) = −(C + D)。第一步:双层循环枚举 - A、B,用 Map 记录每种和出现了几次,O(n²)。第二步:双层循环枚举 C、D, - 查 −(c + d) 在表里出现了几次,累加进答案。时间与空间都是 O(n²), - 从 n⁴ 降到 n²。「折半 + 哈希配对」就是 meet in the middle + A、B,用 Map 记录每种和出现了几次,O(n²)。第二步:双层循环枚举 C、D,查 −(c + d) 在表里出现了几次,累加进答案。时间与空间都是 O(n²),从 n⁴ 降到 n²。「折半 + 哈希配对」就是 meet in the middle 思想的入门款。 ), @@ -288,11 +271,8 @@ export const PROBLEMS: Problem[] = [ pre[j] = k ⟺ pre[j] = pre[i] − k。于是一边扫一边维护 Map「前缀和 → 出现次数」:每到一个位置,先把 map[pre − k] 累加进答案(有几个这样的 j 就有几个合法子数组),再把当前 pre 的计数 +1。 - Map 必须以 {"{0: 1}"} 起步:空前缀也是合法的 j, - 否则从下标 0 开始的子数组会全部漏掉。O(n) 时间、O(n) 空间。 - 元素可能为负,窗口和不单调,滑动窗口在这里失效 —— - 这正是前缀和 + 哈希登场的原因。它是「两数之和」在前缀和世界里的翻版, - 也是本题单最重要的一题。 + Map 必须以 {"{0: 1}"} 起步:空前缀也是合法的 j,否则从下标 0 开始的子数组会全部漏掉。O(n) 时间、O(n) 空间。元素可能为负,窗口和不单调,滑动窗口在这里失效 —— + 这正是前缀和 + 哈希登场的原因。它是「两数之和」在前缀和世界里的翻版,也是本题单最重要的一题。 ), }, @@ -325,11 +305,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 动态数组存值(随机取 O(1)),Map 存「值 → 它在数组里的下标」。 - 删除 x 时:查出下标 i,把数组末尾元素搬到 i、 - 更新它在 Map 里的下标,再弹掉末尾 —— 避开了数组中间删除的 O(n) 搬家。 - swap-with-last 是数组 O(1) 删除的标准手法,代价是不再保持顺序。 - 「哈希表 + 另一种结构」这套组合,后面的 LRU、LFU 缓存还会再用一次。 + 动态数组存值(随机取 O(1)),Map 存「值 → 它在数组里的下标」。删除 x 时:查出下标 i,把数组末尾元素搬到 i、更新它在 Map 里的下标,再弹掉末尾 —— 避开了数组中间删除的 O(n) 搬家。swap-with-last 是数组 O(1) 删除的标准手法,代价是不再保持顺序。「哈希表 + 另一种结构」这套组合,后面的 LRU、LFU 缓存还会再用一次。 ), }, @@ -363,10 +339,8 @@ export const PROBLEMS: Problem[] = [ <> 第一遍:同位置同数字 → bulls++;不匹配的位置,分别给 secret 和 guess 的数字计数(两个长度 10 的计数数组)。第二遍:对每个数字 0–9, - cows += min(cntS[d], cntG[d]) —— 两边库存能配上的部分都是奶牛。 - O(n) 时间、O(1) 额外空间。一遍写法:只用一个计数数组,secret - 的数字 +1、guess 的数字 −1,某个计数越过 0 走向反方向时, - 就说明抵掉了一头奶牛。 + cows += min(cntS[d], cntG[d]) —— 两边库存能配上的部分都是奶牛。O(n) 时间、O(1) 额外空间。一遍写法:只用一个计数数组,secret + 的数字 +1、guess 的数字 −1,某个计数越过 0 走向反方向时,就说明抵掉了一头奶牛。 ), }, diff --git a/lib/heap-problems.tsx b/lib/heap-problems.tsx index 4e9916c..28b6014 100644 --- a/lib/heap-problems.tsx +++ b/lib/heap-problems.tsx @@ -38,10 +38,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 构造时把初始数组压进一个小根堆,并把大小维持在 k;每次 add{" "} - 先入堆,超过 k 就弹掉堆顶。此时堆顶就是「迄今第 k 大」。 - 单次 add 是 O(log k),空间 O(k)。 - 这里不能靠排序:数据流没有「全部」—— 你永远不知道后面还来多少。 - 堆里住着「迄今最大的 k 个」,堆顶就是入围门槛。 + 先入堆,超过 k 就弹掉堆顶。此时堆顶就是「迄今第 k 大」。单次 add 是 O(log k),空间 O(k)。这里不能靠排序:数据流没有「全部」—— 你永远不知道后面还来多少。堆里住着「迄今最大的 k 个」,堆顶就是入围门槛。 ), }, @@ -75,8 +72,7 @@ export const PROBLEMS: Problem[] = [ 全部石头入大根堆。每回合弹两次得到 x ≥ y;若 x ≠ y,把差值 x − y 压回去,直到堆里剩下不超过一块。时间 O(n log n)。Java 用{" "} new PriorityQueue<>(Comparator.reverseOrder()); - Python 因为 heapq 到 3.13 为止只有小根堆,全程存负数。 - 这题正好把两种「大根堆写法」练熟。 + Python 因为 heapq 到 3.13 为止只有小根堆,全程存负数。这题正好把两种「大根堆写法」练熟。 ), }, @@ -108,11 +104,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 与 LC 215 镜像:要前 k 小,就用容量 k 的大根堆。 - 堆顶是当前候选里最远的点,新点只有比它更近才有资格进来。 - 时间 O(n log k),在 n 很大、k 很小时明显优于全排序的 O(n log n)。 - 比较距离时用平方即可,不必开根号:平方在非负数上单调递增,不改变大小关系。 - 常见追问是快速选择,平均 O(n)。 + 与 LC 215 镜像:要前 k 小,就用容量 k 的大根堆。堆顶是当前候选里最远的点,新点只有比它更近才有资格进来。时间 O(n log k),在 n 很大、k 很小时明显优于全排序的 O(n log n)。比较距离时用平方即可,不必开根号:平方在非负数上单调递增,不改变大小关系。常见追问是快速选择,平均 O(n)。 ), }, @@ -144,11 +136,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 先用哈希表计数,再维护一个容量 k 的小根堆。比较器按「频次升序; - 频次相同按单词字典序降序」。后半句看着反直觉,但正是它让 - 「频次最低、且在并列里字典序最靠后」的那个最该淘汰的单词落到堆顶。 - 最后逐个弹出并反转即可。时间 O(n log k)。 - 这题是练「比较器方向感」的最佳题目:永远让最该被踢的元素待在堆顶。 + 先用哈希表计数,再维护一个容量 k 的小根堆。比较器按「频次升序;频次相同按单词字典序降序」。后半句看着反直觉,但正是它让「频次最低、且在并列里字典序最靠后」的那个最该淘汰的单词落到堆顶。最后逐个弹出并反转即可。时间 O(n log k)。这题是练「比较器方向感」的最佳题目:永远让最该被踢的元素待在堆顶。 ), }, @@ -182,11 +170,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 把每行的第一个元素以 (值, 行, 列) 的形式压进小根堆;弹 k − 1 次, - 每弹一次就把同一行的下一个元素补进来,第 k 次的堆顶就是答案。 - 时间 O(k log n)。这是「合并 K 路」模板的直接复用。 - 面试中更进一步的解法是值域二分:猜一个 mid,用 O(n) 数出 ≤ mid 的个数再收缩区间, - 可做到 O(n log(max − min))。能讲清这两种解法,才算真正掌握。 + 把每行的第一个元素以 (值, 行, 列) 的形式压进小根堆;弹 k − 1 次,每弹一次就把同一行的下一个元素补进来,第 k 次的堆顶就是答案。时间 O(k log n)。这是「合并 K 路」模板的直接复用。面试中更进一步的解法是值域二分:猜一个 mid,用 O(n) 数出 ≤ mid 的个数再收缩区间,可做到 O(n log(max − min))。能讲清这两种解法,才算真正掌握。 ), }, @@ -219,11 +203,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 先计数,再按剩余次数入大根堆。每轮弹出剩得最多的字符接到结果上, - 但先不放回堆,以免连着用两次;等下一轮弹出别的字符后, - 再把上一轮那个(次数减一后若仍大于 0)放回去。 - 若某个字符出现次数超过 (n + 1) / 2 则无解 —— 鸽笼原理,间隔不够用。 - 时间 O(n log 26)。「反复取最值,而且取完还要更新计数」正是堆的主场。 + 先计数,再按剩余次数入大根堆。每轮弹出剩得最多的字符接到结果上,但先不放回堆,以免连着用两次;等下一轮弹出别的字符后,再把上一轮那个(次数减一后若仍大于 0)放回去。若某个字符出现次数超过 (n + 1) / 2 则无解 —— 鸽笼原理,间隔不够用。时间 O(n log 26)。「反复取最值,而且取完还要更新计数」正是堆的主场。 ), }, @@ -267,17 +247,11 @@ export const PROBLEMS: Problem[] = [ 对顶堆。大根堆 small{" "} 存较小的一半,堆顶就是左半的最大值;小根堆 large{" "} 存较大的一半,堆顶就是右半的最小值。两条不变量: - small 的所有元素 ≤ large 的所有元素; - 两堆大小相差不超过 1(约定 small 可以多一个)。 - addNum 固定三步:先把 x 压入 small; - 再把 small 的堆顶搬去 large - (这一步恢复第一条不变量,因为搬走的正是左半最大值); - 若 large 反而更多,再搬一个回 small + small 的所有元素 ≤ large 的所有元素;两堆大小相差不超过 1(约定 small 可以多一个)。 + addNum 固定三步:先把 x 压入 small;再把 small 的堆顶搬去 large + (这一步恢复第一条不变量,因为搬走的正是左半最大值);若 large 反而更多,再搬一个回 small (恢复第二条)。两次堆操作,O(log n)。 - findMedian 在总数为奇数时取 small 堆顶, - 偶数时取两个堆顶的平均,O(1)。 - 常见追问「99% 的数据落在 0~100」→ 用计数数组,两端各加一个溢出桶, - 扫 101 个桶即可。 + findMedian 在总数为奇数时取 small 堆顶,偶数时取两个堆顶的平均,O(1)。常见追问「99% 的数据落在 0~100」→ 用计数数组,两端各加一个溢出桶,扫 101 个桶即可。 ), }, @@ -310,13 +284,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 所有项目按启动资本入小根堆,然后做 k 轮: - 每轮先把「当前资本够得着」的项目全部搬进按利润排序的大根堆 - —— 资本只增不减,所以搬进去的项目不用再搬回来; - 再弹出利润最大的做掉,资本随之增加。 - 每个项目在两个堆里各进出至多一次,合计 O(n log n)。 - 这是「双堆接力」模板:一个堆存放还没解锁的候选。 - 它和 LC 295 的对顶堆是两种完全不同的双堆用法。 + 所有项目按启动资本入小根堆,然后做 k 轮:每轮先把「当前资本够得着」的项目全部搬进按利润排序的大根堆 + —— 资本只增不减,所以搬进去的项目不用再搬回来;再弹出利润最大的做掉,资本随之增加。每个项目在两个堆里各进出至多一次,合计 O(n log n)。这是「双堆接力」模板:一个堆存放还没解锁的候选。它和 LC 295 的对顶堆是两种完全不同的双堆用法。 ), }, diff --git a/lib/linked-list-problems.tsx b/lib/linked-list-problems.tsx index e59d15e..1edc7d6 100644 --- a/lib/linked-list-problems.tsx +++ b/lib/linked-list-problems.tsx @@ -36,8 +36,7 @@ export const PROBLEMS: Problem[] = [ <> 单指针一次遍历。若 cur.val == cur.next.val,就{" "} cur.next = cur.next.next 绕过重复节点,并且{" "} - cur 原地不动 —— 后面可能还连着重复;否则 cur 前进一格。 - 头节点永远不会被删,所以这题不需要 dummy。时间 O(n),空间 O(1)。 + cur 原地不动 —— 后面可能还连着重复;否则 cur 前进一格。头节点永远不会被删,所以这题不需要 dummy。时间 O(n),空间 O(1)。 ), }, @@ -71,8 +70,7 @@ export const PROBLEMS: Problem[] = [ 建一个 dummy,让它的 next 指向 head,cur 从 dummy 出发。若{" "} cur.next.val == val 就 cur.next = cur.next.next ,否则 cur 前进。有了 dummy,「删头」和「删中间」是同一行代码 —— - 就是 §04 那组对照示例。最后返回 dummy.next 而不是 head, - 因为原来的头可能已经被删掉了。 + 就是 §04 那组对照示例。最后返回 dummy.next 而不是 head,因为原来的头可能已经被删掉了。 ), }, @@ -105,10 +103,8 @@ export const PROBLEMS: Problem[] = [ 快慢指针。两者都从 head 出发,slow 每步 1 格、fast 每步 2 格,循环条件{" "} fast != null && fast.next != null。fast 走不动时, slow 正好停在中点。长度为偶数时,这种写法停在两个中点里的 - 第二个,正合本题要求。如果你要的是第一个中点(比如把链表切成两半), - 循环条件改成{" "} - fast.next != null && fast.next.next != null。 - 时间 O(n),空间 O(1)。 + 第二个,正合本题要求。如果你要的是第一个中点(比如把链表切成两半),循环条件改成{" "} + fast.next != null && fast.next.next != null。时间 O(n),空间 O(1)。 ), }, @@ -142,10 +138,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - pA 从 A 链头出发,pB 从 B 链头出发;谁走到尾,就跳到另一条链的头继续走。 - 于是两个指针走过的总长度都是 lenA + lenB,走完之后它们距离链尾一样远。 - 结果只有两种:在交点相遇,或者同时变成 null(不相交)。既不用数长度, - 也不用哈希表。时间 O(n + m),空间 O(1)。 + pA 从 A 链头出发,pB 从 B 链头出发;谁走到尾,就跳到另一条链的头继续走。于是两个指针走过的总长度都是 lenA + lenB,走完之后它们距离链尾一样远。结果只有两种:在交点相遇,或者同时变成 null(不相交)。既不用数长度,也不用哈希表。时间 O(n + m),空间 O(1)。 ), }, @@ -175,9 +168,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 三步组合:快慢指针找中点 → 反转后半段 → 两个指针分别从两段的头部同步比较。 - 时间 O(n),空间 O(1),比「把值拷进数组再左右对撞」省掉 O(n) 空间。 - 注意它改动了原链表,严谨的写法会在返回前把后半段再反转回去。 + 三步组合:快慢指针找中点 → 反转后半段 → 两个指针分别从两段的头部同步比较。时间 O(n),空间 O(1),比「把值拷进数组再左右对撞」省掉 O(n) 空间。注意它改动了原链表,严谨的写法会在返回前把后半段再反转回去。 ), }, @@ -212,10 +203,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 两个指针都从 dummy 出发:fast 先走 n + 1 步,然后两人同步前进,直到 fast 为 null。 - 间隔全程不变,所以此刻 slow 正停在待删节点的前驱上,一行{" "} - slow.next = slow.next.next 就完成删除。dummy 兜住了 - 「要删的正是头节点」这种情况。一次遍历,时间 O(n),空间 O(1)。 + 两个指针都从 dummy 出发:fast 先走 n + 1 步,然后两人同步前进,直到 fast 为 null。间隔全程不变,所以此刻 slow 正停在待删节点的前驱上,一行{" "} + slow.next = slow.next.next 就完成删除。dummy 兜住了「要删的正是头节点」这种情况。一次遍历,时间 O(n),空间 O(1)。 ), }, @@ -249,8 +238,7 @@ export const PROBLEMS: Problem[] = [ dummy 站到 head 前面,prev 每次盯住一对 (a, b)。三次写入按顺序是{" "} prev.next = b、a.next = b.next、 b.next = a,然后 prev 跳到 a。要点是先读 b.next{" "} - 再覆盖它 —— 和「先接后断」是同一条规矩。递归版更短,但要 O(n) 栈空间; - 迭代版才是 O(1)。 + 再覆盖它 —— 和「先接后断」是同一条规矩。递归版更短,但要 O(n) 栈空间;迭代版才是 O(1)。 ), }, @@ -283,10 +271,8 @@ export const PROBLEMS: Problem[] = [ zh: ( <> Floyd 判圈的第二阶段:快慢相遇后,把一个指针放回 head,两个指针改为 - 每步一格同速前进,再次相遇处就是环的入口。推导:设头到入口为 a、 - 入口到相遇点为 b、环的剩余部分为 c,由「fast 路程 = 2 × slow 路程」可得 - a = c + k × 环长(k 为非负整数)。面试常要求当场写出这条等式, - 所以先把 §06 精讲 B 的相遇原理弄透。 + 每步一格同速前进,再次相遇处就是环的入口。推导:设头到入口为 a、入口到相遇点为 b、环的剩余部分为 c,由「fast 路程 = 2 × slow 路程」可得 + a = c + k × 环长(k 为非负整数)。面试常要求当场写出这条等式,所以先把 §06 精讲 B 的相遇原理弄透。 ), }, @@ -318,11 +304,9 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 用 dummy + tail 边算边建结果链。每一步 sum = a + b + carry, - 某条链走完了就按 0 计;挂上 sum % 10,并令{" "} + 用 dummy + tail 边算边建结果链。每一步 sum = a + b + carry,某条链走完了就按 0 计;挂上 sum % 10,并令{" "} carry = sum / 10(整数除法)。循环条件是「l1、l2 或 carry - 还有货」。漏挂最后一次进位是这题最常见的错误:5 + 5 必须产生两个节点。 - 时间 O(max(n, m))。 + 还有货」。漏挂最后一次进位是这题最常见的错误:5 + 5 必须产生两个节点。时间 O(max(n, m))。 ), }, @@ -354,11 +338,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 先用 dummy 让 left = 1 不再是特例,然后走到 pre(位置 left 的前一个节点)。 - 接着重复 right − left 次:把 cur.next 摘下来,插到 pre 的正后方。 - 每挪一次就有一个节点被顶到更前面,一趟下来区间自然反转。 - 这比「切段 + 反转 + 缝回」少写一半指针。每一步都建议画图。 - 时间 O(n),空间 O(1)。 + 先用 dummy 让 left = 1 不再是特例,然后走到 pre(位置 left 的前一个节点)。接着重复 right − left 次:把 cur.next 摘下来,插到 pre 的正后方。每挪一次就有一个节点被顶到更前面,一趟下来区间自然反转。这比「切段 + 反转 + 缝回」少写一半指针。每一步都建议画图。时间 O(n),空间 O(1)。 ), }, @@ -392,9 +372,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> dummy 起手,每轮三件事:① 探查剩余节点是否够 k 个(不够就停,保持原序); - ② 用三指针反转这 k 个;③ 缝合 —— 上一段的尾接本段的新头,本段的尾接下一段的头。 - 用 groupPrev / groupNext 两个锚点变量能挡掉大半混乱。它 Hard 在工程化拆解, - 而不是新算法。时间 O(n),空间 O(1)。 + ② 用三指针反转这 k 个;③ 缝合 —— 上一段的尾接本段的新头,本段的尾接下一段的头。用 groupPrev / groupNext 两个锚点变量能挡掉大半混乱。它 Hard 在工程化拆解,而不是新算法。时间 O(n),空间 O(1)。 ), }, diff --git a/lib/queue-problems.tsx b/lib/queue-problems.tsx index ff9ec94..9c3210c 100644 --- a/lib/queue-problems.tsx +++ b/lib/queue-problems.tsx @@ -36,9 +36,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 新时间戳从队尾入队;然后把小于 t − 3000 的时间戳从队头逐个出队; - 队列长度就是答案。时间戳单调递增,所以过期的一定最老、一定聚在队头。 - 每个请求进出队各一次,单次调用均摊 O(1)。这是「时间滑动窗口」的最小模型。 + 新时间戳从队尾入队;然后把小于 t − 3000 的时间戳从队头逐个出队;队列长度就是答案。时间戳单调递增,所以过期的一定最老、一定聚在队头。每个请求进出队各一次,单次调用均摊 O(1)。这是「时间滑动窗口」的最小模型。 ), }, @@ -68,8 +66,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - push 时先把新元素入队,再把它前面的 n − 1 个元素依次出队、重新入队: - 队列旋转一圈,新元素恰好转到队头 → pop 就是普通出队。push O(n)、pop + push 时先把新元素入队,再把它前面的 n − 1 个元素依次出队、重新入队:队列旋转一圈,新元素恰好转到队头 → pop 就是普通出队。push O(n)、pop O(1)。它与本章精讲 A(LC 232)互为镜像,一起做最能看清两种顺序的差别。 ), @@ -102,8 +99,7 @@ export const PROBLEMS: Problem[] = [ <> 定长数组 + front / rear 两个下标,前进一律 (i + 1) % cap{" "} 绕回开头。满 / 空判定二选一:留一格空(开 k + 1 格, - (rear+1)%cap==front 即满),或维护 size 计数器。所有操作 O(1), - 没有任何元素需要移动。 + (rear+1)%cap==front 即满),或维护 size 计数器。所有操作 O(1),没有任何元素需要移动。 ), }, @@ -171,10 +167,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 贪心模拟:按 pushed 依次入栈;每次入栈后,只要栈顶等于 popped - 的当前元素就一直弹并推进 popped 指针。结束时栈空 ⇔ 序列合法。 - 为什么「能弹就弹」是对的?能弹却不弹,后续入栈会把它压得更深, - 此后再也没有机会在正确的时刻弹出。时间 O(n)、空间 O(n), - 也是上一章栈的复习题。 + 的当前元素就一直弹并推进 popped 指针。结束时栈空 ⇔ 序列合法。为什么「能弹就弹」是对的?能弹却不弹,后续入栈会把它压得更深,此后再也没有机会在正确的时刻弹出。时间 O(n)、空间 O(n),也是上一章栈的复习题。 ), }, @@ -205,10 +198,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 两个队列分别存 R、D 议员的下标:每轮取两队队头比较, - 下标小者先行动 → 存活,并以「下标 + n」重新入队(表示下一轮再来); - 下标大者被禁言,直接出队消失。直到一方清空。 - 「下一个行动者」的顺序完全由队列维持,不需要额外记录。O(n)。 + 两个队列分别存 R、D 议员的下标:每轮取两队队头比较,下标小者先行动 → 存活,并以「下标 + n」重新入队(表示下一轮再来);下标大者被禁言,直接出队消失。直到一方清空。「下一个行动者」的顺序完全由队列维持,不需要额外记录。O(n)。 ), }, @@ -242,8 +232,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 滑动窗口 + 两个单调队列:递减队列随时给出窗口 max,递增队列给出窗口 - min。右端扩张后,只要 max − min > limit 就收缩左端,同时把已经 - 离开窗口的下标从两个队头弹掉。它就是 LC 239 + min。右端扩张后,只要 max − min > limit 就收缩左端,同时把已经离开窗口的下标从两个队头弹掉。它就是 LC 239 的直接升级:同一个窗口,同时维护两套单调性。O(n)。 ), @@ -283,8 +272,7 @@ export const PROBLEMS: Problem[] = [ 对前缀和 P 维护递增单调队列。遍历 r 时:①只要队头满足 P[r] − P[队头] ≥ K,就记录长度并弹出队头 —— 这个左端点已经配到最短的搭档,再往后只会更长;②把队尾所有 P[队尾] ≥ - P[r] 的下标弹掉 —— P[r] 既更小又更靠右,它们不可能再成为更好的左端点。 - 两条弹出规则正好用到 deque 的两端,这就是它必须是双端队列的原因。O(n)。 + P[r] 的下标弹掉 —— P[r] 既更小又更靠右,它们不可能再成为更好的左端点。两条弹出规则正好用到 deque 的两端,这就是它必须是双端队列的原因。O(n)。 ), }, diff --git a/lib/stack-problems.tsx b/lib/stack-problems.tsx index 48faf2f..949ed71 100644 --- a/lib/stack-problems.tsx +++ b/lib/stack-problems.tsx @@ -33,10 +33,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 逐字符处理:当前字符与栈顶相同就弹栈(这一对被消除),否则入栈; - 扫完后栈里剩下的就是答案。弹栈后下面那个元素自动成为新栈顶, - 可以直接和后面的字符继续配对 —— 连锁消除不需要任何额外代码。 - 时间 O(n),空间 O(n)。 + 逐字符处理:当前字符与栈顶相同就弹栈(这一对被消除),否则入栈;扫完后栈里剩下的就是答案。弹栈后下面那个元素自动成为新栈顶,可以直接和后面的字符继续配对 —— 连锁消除不需要任何额外代码。时间 O(n),空间 O(n)。 ), }, @@ -67,10 +64,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - in 栈只收 push,out 栈负责 pop / peek; - 只有 out 空了才把 in 整体倒过去。 - 两次反转恰好恢复先来后到。每个元素一生最多被搬 4 次 - (进 in、出 in、进 out、出{" "} + in 栈只收 push,out 栈负责 pop / peek;只有 out 空了才把 in 整体倒过去。两次反转恰好恢复先来后到。每个元素一生最多被搬 4 次(进 in、出 in、进 out、出{" "} out),所以单次操作均摊 O(1)。队列章有逐帧动画和完整推导。 ), @@ -100,9 +94,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 单队列做法:push 时先把新元素入队,再把它前面的 n−1 个元素依次出队、 - 重新入队 —— 队列被旋转一整圈,新元素恰好转到队头。于是 pop 就是一次出队。 - push O(n)、pop O(1),和 LC 232 互为镜像,放在一起做最有感觉。 + 单队列做法:push 时先把新元素入队,再把它前面的 n−1 个元素依次出队、重新入队 —— 队列被旋转一整圈,新元素恰好转到队头。于是 pop 就是一次出队。push O(n)、pop O(1),和 LC 232 互为镜像,放在一起做最有感觉。 ), }, @@ -132,9 +124,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 用栈存有效得分:数字直接入栈;+ 把栈顶两项之和入栈; - D 把栈顶 ×2 入栈;C 弹掉栈顶。最后求和。 - 纯模拟题,练的是「要最近的记录 → 用栈」这个条件反射。 - 时间 O(n),空间 O(n)。 + D 把栈顶 ×2 入栈;C 弹掉栈顶。最后求和。纯模拟题,练的是「要最近的记录 → 用栈」这个条件反射。时间 O(n),空间 O(n)。 ), }, @@ -168,9 +158,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 数字入栈;遇到运算符弹出两个数,算完把结果压回去。注意弹出顺序: - 先弹出的是右操作数,减法和除法会因此出错;除法要向零取整。 - 后缀表达式不需要括号、不需要优先级,这正是编译器和计算器用栈求值的原因。 - 时间 O(n),空间 O(n)。 + 先弹出的是右操作数,减法和除法会因此出错;除法要向零取整。后缀表达式不需要括号、不需要优先级,这正是编译器和计算器用栈求值的原因。时间 O(n),空间 O(n)。 ), }, @@ -201,11 +189,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 遇到 [ 时把「当前已拼好的字符串 + 当前倍数」打包压栈, - 然后清空重新开始;遇到 ] 弹栈, - cur = prev + k × cur。栈里每一层存的是一份未完成的工作, - 和调用栈保存的东西是同一类。本题用递归写也行,两种写法可以逐行互译。 - 时间 O(n)。 + 遇到 [ 时把「当前已拼好的字符串 + 当前倍数」打包压栈,然后清空重新开始;遇到 ] 弹栈, + cur = prev + k × cur。栈里每一层存的是一份未完成的工作,和调用栈保存的东西是同一类。本题用递归写也行,两种写法可以逐行互译。时间 O(n)。 ), }, @@ -234,8 +219,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 对 nums2 跑一遍单调栈:新元素比栈顶大就弹栈, - 并在哈希表里记下「被弹出的值 → 弹它的值」;最后 nums1{" "} + 对 nums2 跑一遍单调栈:新元素比栈顶大就弹栈,并在哈希表里记下「被弹出的值 → 弹它的值」;最后 nums1{" "} 逐个查表。时间 O(n + m)。它和 LC 739 是同一套机制的简化版,建议先做它。 ), @@ -268,8 +252,7 @@ export const PROBLEMS: Problem[] = [ <> 循环数组的标准技巧:下标 i 从 0 跑到 2n−1,访问时用{" "} i % n。第一圈正常「弹栈结算 + 入栈」,第二圈 - 只弹不进 —— 这些下标第一圈已经入过栈,再入一次就重复了。 - 不用真的复制数组。时间 O(n)。 + 只弹不进 —— 这些下标第一圈已经入过栈,再入一次就重复了。不用真的复制数组。时间 O(n)。 ), }, @@ -303,10 +286,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 维护一个递增的下标栈:当前柱子比栈顶矮时,弹出栈顶并结算 - 「以它为高」的矩形 —— 右边界是当前下标,左边界是弹出后的新栈顶, - 宽度 = i − 新栈顶 − 1。首尾各加一根高度为 0 的哨兵柱: - 开头那根免去判空,结尾那根强制清算所有剩余柱子。时间 O(n)。 + 维护一个递增的下标栈:当前柱子比栈顶矮时,弹出栈顶并结算「以它为高」的矩形 —— 右边界是当前下标,左边界是弹出后的新栈顶,宽度 = i − 新栈顶 − 1。首尾各加一根高度为 0 的哨兵柱:开头那根免去判空,结尾那根强制清算所有剩余柱子。时间 O(n)。 ), }, @@ -338,11 +318,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 维护一个递减的下标栈:当前柱子比栈顶高时,栈顶就是凹槽的底,弹出它; - 此时左墙 = 新栈顶,右墙 = 当前柱子,这一层水量 ={" "} - (min(左墙, 右墙) − 底) × (右墙下标 − 左墙下标 − 1)。 - 和数组章的对撞指针解法对照:同一道题,那边按列竖着算,这边按层横着算。 - 两种都能讲清楚,才算真的吃透。时间 O(n)。 + 维护一个递减的下标栈:当前柱子比栈顶高时,栈顶就是凹槽的底,弹出它;此时左墙 = 新栈顶,右墙 = 当前柱子,这一层水量 ={" "} + (min(左墙, 右墙) − 底) × (右墙下标 − 左墙下标 − 1)。和数组章的对撞指针解法对照:同一道题,那边按列竖着算,这边按层横着算。两种都能讲清楚,才算真的吃透。时间 O(n)。 ), }, diff --git a/lib/string-problems.tsx b/lib/string-problems.tsx index 87e24dd..e75514a 100644 --- a/lib/string-problems.tsx +++ b/lib/string-problems.tsx @@ -32,10 +32,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 对撞指针:交换 s[left] 和 s[right],然后两个指针同时向中间走,相遇即止。 - O(n) 时间、O(1) 额外空间。题目特意给 char[]/list - 而不是字符串,是因为 Java、Python、JavaScript 的字符串都不可变, - 「原地反转」只能发生在可变的字符数组上。 + 对撞指针:交换 s[left] 和 s[right],然后两个指针同时向中间走,相遇即止。O(n) 时间、O(1) 额外空间。题目特意给 char[]/list + 而不是字符串,是因为 Java、Python、JavaScript 的字符串都不可变,「原地反转」只能发生在可变的字符数组上。 ), }, @@ -101,8 +99,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 同时维护 s→t 和 t→s 两张映射表,逐位检查:若某位上任一方向与已记录的映射冲突, - 返回 false。只建单向映射会放过 "badc" 和 "baba" + 同时维护 s→t 和 t→s 两张映射表,逐位检查:若某位上任一方向与已记录的映射冲突,返回 false。只建单向映射会放过 "badc" 和 "baba" —— s 的两个不同字符映到了 t 的同一个字符。一次遍历,O(n)。 ), @@ -134,8 +131,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 纵向扫描:比到第 i 列时,只要有一个串长度不足或字符不同就停,答案就是前 i - 个字符。最坏 O(S)(S 为所有字符总数),但通常在第一个分歧列就提前结束。 - 「以第一个串为标尺逐个横向裁剪」的写法复杂度相同。 + 个字符。最坏 O(S)(S 为所有字符总数),但通常在第一个分歧列就提前结束。「以第一个串为标尺逐个横向裁剪」的写法复杂度相同。 ), }, @@ -166,8 +162,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 一行流:按空白 split → 过滤空串 → 倒序 → 用一个空格 join。想做到 O(1) - 额外空间(只有能原地改字符数组的语言才可行)则三步走:先整体 reverse, - 再对每个单词局部 reverse,最后原地压缩多余空格 —— 和 LC 189 + 额外空间(只有能原地改字符数组的语言才可行)则三步走:先整体 reverse,再对每个单词局部 reverse,最后原地压缩多余空格 —— 和 LC 189 轮转数组是同一个翻转技巧。 ), @@ -203,9 +198,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 定长滑动窗口 + 长度 26 的计数数组:窗口右移时,进窗的字符计数 ++、 - 出窗的字符计数 --,再比较窗口计数与 p 的计数。额外维护一个「已匹配的字母种数」 - 可以把这次比较压到 O(1)。整体 O(n)。「子串长度固定」是定长滑窗的强信号 —— + 定长滑动窗口 + 长度 26 的计数数组:窗口右移时,进窗的字符计数 ++、出窗的字符计数 --,再比较窗口计数与 p 的计数。额外维护一个「已匹配的字母种数」可以把这次比较压到 O(1)。整体 O(n)。「子串长度固定」是定长滑窗的强信号 —— 它比通用滑窗少一个收缩循环。 ), @@ -240,9 +233,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 固定流程四步走:① 跳过前导空格;② 读一个可选的 +/ - -;③ 逐位读数字,遇非数字立刻停;④ 每次累加前先判溢出 - (ans > (MAX - digit) / 10 就该截断到 32 位边界)。 - Python 的 int 没有固定宽度,可以最后再 clamp; + -;③ 逐位读数字,遇非数字立刻停;④ 每次累加前先判溢出(ans > (MAX - digit) / 10 就该截断到 32 位边界)。Python 的 int 没有固定宽度,可以最后再 clamp; Java/JS 必须边算边防。这题考的是把需求翻译成不重不漏的分支 —— 值得亲手写三遍。 @@ -280,8 +271,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> KMP:先对 pattern 求前缀函数,lps[i] = 子串{" "} - pattern[0..i] 的最长「真前缀 = 真后缀」长度。匹配时主串指针永不回退, - 失配只把 j 跳到 lps[j-1] 继续比。预处理 O(m) + + pattern[0..i] 的最长「真前缀 = 真后缀」长度。匹配时主串指针永不回退,失配只把 j 跳到 lps[j-1] 继续比。预处理 O(m) + 匹配 O(n)。本章 §04 把 lps 的来历讲透了 —— 背模板之前,先能回答「为什么跳到 lps[j-1] 不会漏解」。 @@ -315,8 +305,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 开 numRows 个收集器(StringBuilder 或 list),用 row{" "} - 指针和方向变量 dir = ±1 走一遍输入,碰到第 0 行或最后一行就反向。 - 每个字符恰好被收集一次,最后按行拼接,O(n)。数学解法(按周期{" "} + 指针和方向变量 dir = ±1 走一遍输入,碰到第 0 行或最后一行就反向。每个字符恰好被收集一次,最后按行拼接,O(n)。数学解法(按周期{" "} 2·numRows - 2 直接算下标)可作为追问时的进阶解法。 ), @@ -354,8 +343,7 @@ export const PROBLEMS: Problem[] = [ 滑窗 + 两个计数器:need 记录 t 中每种字符还欠多少, have 记录已满足的字符种数。r 右扩补欠账,一旦{" "} have 等于 t 的字符种类总数,窗口就合法,这时收缩 l - 找更短答案,直到窗口不再合法为止。每个字符最多进出窗口各一次,O(n)。 - 滑窗三问(维护什么 / 何时扩 / 何时缩)在这题达到最完整的形态。 + 找更短答案,直到窗口不再合法为止。每个字符最多进出窗口各一次,O(n)。滑窗三问(维护什么 / 何时扩 / 何时缩)在这题达到最完整的形态。 ), }, diff --git a/lib/trie-problems.tsx b/lib/trie-problems.tsx index 10017a1..bbd28ff 100644 --- a/lib/trie-problems.tsx +++ b/lib/trie-problems.tsx @@ -40,9 +40,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - insert:沿字符走,缺哪条边就新建节点,末节点 isEnd = true。search: - 同样走到底,还要求末节点 isEnd = true 才算命中。startsWith:走得通即可, - 不看 isEnd。三个方法都是 O(L),L = 传入字符串的长度 —— + insert:沿字符走,缺哪条边就新建节点,末节点 isEnd = true。search:同样走到底,还要求末节点 isEnd = true 才算命中。startsWith:走得通即可,不看 isEnd。三个方法都是 O(L),L = 传入字符串的长度 —— 词典里已经存了多少词,不出现在这个代价里。所有后续 Trie 题都在这三块骨架上加东西。 @@ -81,10 +79,8 @@ export const PROBLEMS: Problem[] = [ zh: ( <> addWord 与 LC 208 相同。search 改成递归 dfs(word, i, node):普通字符只往对应 - child 递归;遇到 '.' 就遍历当前节点的所有 child 分别递归, - 任意一条返回 true 就立刻返回。走到词尾看 isEnd。最坏情况(全是 '.')会扇出到 - O(26^L),但搜索访问的节点数同时也不会超过整棵树的节点数,而且缺边会立刻中断一条分支。 - 这是「Trie + 回溯」的最小案例。 + child 递归;遇到 '.' 就遍历当前节点的所有 child 分别递归,任意一条返回 true 就立刻返回。走到词尾看 isEnd。最坏情况(全是 '.')会扇出到 + O(26^L),但搜索访问的节点数同时也不会超过整棵树的节点数,而且缺边会立刻中断一条分支。这是「Trie + 回溯」的最小案例。 ), }, @@ -115,8 +111,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 用词根建 Trie。遍历句子里每个单词,从 root 逐字符下沉:一旦当前节点 isEnd = - true,说明有词根匹配这段前缀,替换并停止;若中途断路或走完仍没遇到 isEnd, - 保留原词。每个单词 O(自身长度)。这题把「用 Trie 判前缀」用得最直白。 + true,说明有词根匹配这段前缀,替换并停止;若中途断路或走完仍没遇到 isEnd,保留原词。每个单词 O(自身长度)。这题把「用 Trie 判前缀」用得最直白。 ), }, @@ -147,8 +142,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 在键结束的那个节点上多存一个 val。sum(prefix):先 O(L) 走到前缀节点, - 再 DFS 这棵子树累加所有 val。还有更快的写法:insert 时算出 delta = + 在键结束的那个节点上多存一个 val。sum(prefix):先 O(L) 走到前缀节点,再 DFS 这棵子树累加所有 val。还有更快的写法:insert 时算出 delta = 新值 − 该 key 的旧值,把 delta 加到路径上每个节点维护的「前缀累加值」上, sum 就变成读一个数、O(L)。值得记住的套路是「把聚合信息挂在节点上」。 @@ -181,10 +175,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 建 Trie 后 DFS,只往 isEnd = true 的子节点走 —— 这条规则正好等价于 - 「每个前缀也是单词」。记录能到达的最长单词;长度相同取字典序最小, - 按 a→z 顺序遍历子节点、且只在严格更长时才替换答案,就自然满足。 - 本质是在 Trie 上找「每一步都合法」的最长链。 + 建 Trie 后 DFS,只往 isEnd = true 的子节点走 —— 这条规则正好等价于「每个前缀也是单词」。记录能到达的最长单词;长度相同取字典序最小,按 a→z 顺序遍历子节点、且只在严格更长时才替换答案,就自然满足。本质是在 Trie 上找「每一步都合法」的最长链。 ), }, @@ -216,10 +207,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 建 Trie,并在每个节点上存「该前缀下字典序最小的 3 个词」;插入时维护这个列表, - 超过 3 个就丢掉最大的。用户逐字符输入时每次下沉一个节点,直接读节点上的列表; - 一旦断路,后续答案全为空。排序 + 二分也能做,但 Trie 版把「前缀 → 候选」 - 变成一次读取,工程里的补全就是这么搭的。 + 建 Trie,并在每个节点上存「该前缀下字典序最小的 3 个词」;插入时维护这个列表,超过 3 个就丢掉最大的。用户逐字符输入时每次下沉一个节点,直接读节点上的列表;一旦断路,后续答案全为空。排序 + 二分也能做,但 Trie 版把「前缀 → 候选」变成一次读取,工程里的补全就是这么搭的。 ), }, @@ -254,9 +242,7 @@ export const PROBLEMS: Problem[] = [ <> 把 words 建成 Trie,词尾节点上存整个单词。从每个格子出发 DFS, 在网格上走一步,就在 Trie 上下沉一步:当前字母在 Trie 里没有对应 - child 就立刻返回 —— 没有任何词这样开头,整条分支可以直接丢掉。 - 正是这一步砍掉了大部分搜索。走到存着单词的节点就收集它,并把该字段清空以免重复收集。 - 共享前缀的词共用一条路径,所以一次网格遍历就把所有词一起测完了。 + child 就立刻返回 —— 没有任何词这样开头,整条分支可以直接丢掉。正是这一步砍掉了大部分搜索。走到存着单词的节点就收集它,并把该字段清空以免重复收集。共享前缀的词共用一条路径,所以一次网格遍历就把所有词一起测完了。 ), }, @@ -293,11 +279,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 建 0/1 Trie:每个数从最高位(小于 2^31 的数,第 30 位就够)读到第 0 位, - 按该位取 0 分支或 1 分支插入。再让每个数走一遍, - 每一层都优先拐向与自己相反的比特:该分支存在,这一位异或就得 1; - 而高位的价值永远大于其后所有低位之和,所以这样贪心是安全的。取全局最大值。 - 代价 O(n × 位数),取代了 O(n²) 的两两配对。这是 Trie 从「存字符」推广到「存比特」的经典一步。 + 建 0/1 Trie:每个数从最高位(小于 2^31 的数,第 30 位就够)读到第 0 位,按该位取 0 分支或 1 分支插入。再让每个数走一遍, + 每一层都优先拐向与自己相反的比特:该分支存在,这一位异或就得 1;而高位的价值永远大于其后所有低位之和,所以这样贪心是安全的。取全局最大值。代价 O(n × 位数),取代了 O(n²) 的两两配对。这是 Trie 从「存字符」推广到「存比特」的经典一步。 ), }, diff --git a/lib/trie-quiz.tsx b/lib/trie-quiz.tsx index 048be5e..72e2a25 100644 --- a/lib/trie-quiz.tsx +++ b/lib/trie-quiz.tsx @@ -161,8 +161,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - children 用「定长 26 槽数组」还是「哈希表 Map」, - 以下说法正确的有?(多选) + children 用「定长 26 槽数组」还是「哈希表 Map」,以下说法正确的有?(多选) ), }, diff --git a/lib/union-find-problems.tsx b/lib/union-find-problems.tsx index 021997a..1e6d18d 100644 --- a/lib/union-find-problems.tsx +++ b/lib/union-find-problems.tsx @@ -44,10 +44,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 把每条边 union 起来,最后返回{" "} - find(source) === find(destination)。 - 这是并查集最基本的用法 —— 不关心路径长什么样、有多长,只关心两点是否同属一个集合。 - 建议把它当默写模板的第一题:写完 UnionFind 类,主逻辑只有两行。 - 两个优化都开时,总代价 O((V + E)·α(V))。 + find(source) === find(destination)。这是并查集最基本的用法 —— 不关心路径长什么样、有多长,只关心两点是否同属一个集合。建议把它当默写模板的第一题:写完 UnionFind 类,主逻辑只有两行。两个优化都开时,总代价 O((V + E)·α(V))。 ), }, @@ -122,10 +119,7 @@ export const PROBLEMS: Problem[] = [ zh: ( <> 并查集解法:先把所有数存进哈希表(值 → 下标),遍历时若 x+1 也在表里,就 - union(x, x+1) 所在的集合,同时为每个根维护集合大小 size,答案是最大的 size。 - 与哈希章的 O(n) 解法(只从 x−1 不存在的起点向右数)相比,并查集写起来更长, - 但需要的洞察更少 ——「相邻即连边,问最大连通块」。一题两解, - 面试时能对比这两种思路,回答会更完整。 + union(x, x+1) 所在的集合,同时为每个根维护集合大小 size,答案是最大的 size。与哈希章的 O(n) 解法(只从 x−1 不存在的起点向右数)相比,并查集写起来更长,但需要的洞察更少 ——「相邻即连边,问最大连通块」。一题两解,面试时能对比这两种思路,回答会更完整。 ), }, @@ -157,11 +151,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 经典的「先映射再并查集」:用哈希表把每个邮箱映射到一个整数编号(或映射到 - 首次出现的账户下标),同一账户内的所有邮箱互相 union —— - 其实每个都和第一个 union 就够了。最后按根分组、组内排序、拼上用户名。 - 这题演示了处理非整数 key 的标准前置步骤:哈希表负责翻译,并查集负责合并。 - 排序主导,O(n·k·log(n·k))。 + 经典的「先映射再并查集」:用哈希表把每个邮箱映射到一个整数编号(或映射到首次出现的账户下标),同一账户内的所有邮箱互相 union —— + 其实每个都和第一个 union 就够了。最后按根分组、组内排序、拼上用户名。这题演示了处理非整数 key 的标准前置步骤:哈希表负责翻译,并查集负责合并。排序主导,O(n·k·log(n·k))。 ), }, @@ -197,11 +188,8 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - union 所有边,并为每个根维护集合大小 size。设各连通块大小为 s₁…sₖ, - 按顺序遍历一遍连通块并累计「已经数过的节点数」,每块贡献 sᵢ × - (此前累计的节点数) 个点对,从而避免两两枚举。这题是「连通块 + 计数」 - 组合的代表:并查集天生擅长回答「有几块、每块多大」。O(n + E·α(n))。 - 答案要用 64 位整数,计数会超出 32 位范围。 + union 所有边,并为每个根维护集合大小 size。设各连通块大小为 s₁…sₖ,按顺序遍历一遍连通块并累计「已经数过的节点数」,每块贡献 sᵢ × + (此前累计的节点数) 个点对,从而避免两两枚举。这题是「连通块 + 计数」组合的代表:并查集天生擅长回答「有几块、每块多大」。O(n + E·α(n))。答案要用 64 位整数,计数会超出 32 位范围。 ), }, @@ -242,15 +230,9 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 关键洞察:一个连通块里的石头,总能移到只剩 1 颗。取这个连通块的一棵生成树 - (例如 DFS 树,同行或同列的两颗石头之间有边),从叶子开始倒序移除: - 每颗被移除的石头都与它的父节点同行或同列,最后只剩根。 - (随意移走一颗「与别人同行或同列」的石头,可能把连通块拆开: + 关键洞察:一个连通块里的石头,总能移到只剩 1 颗。取这个连通块的一棵生成树(例如 DFS 树,同行或同列的两颗石头之间有边),从叶子开始倒序移除:每颗被移除的石头都与它的父节点同行或同列,最后只剩根。(随意移走一颗「与别人同行或同列」的石头,可能把连通块拆开: (0,0)、(0,1)、(1,1) 三颗石头,先移走 (0,1),剩下两颗就互不共线了。) - 所以答案 = 石头总数 − 连通块数。 - 实现技巧:不必两两比较 O(n²),把「行号 r」和「列号 c + 10001」 - 也当成节点,每颗石头 union(它的行, 它的列),让行列充当中介。 - 这题考的不是模板,是把问题翻译成连通块语言的建模能力。 + 所以答案 = 石头总数 − 连通块数。实现技巧:不必两两比较 O(n²),把「行号 r」和「列号 c + 10001」也当成节点,每颗石头 union(它的行, 它的列),让行列充当中介。这题考的不是模板,是把问题翻译成连通块语言的建模能力。 ), }, @@ -286,13 +268,7 @@ export const PROBLEMS: Problem[] = [ ), zh: ( <> - 两两判断字符串是否相似:相同,或恰好两个位置不同。 - 这样判断就够了,因为所有串互为字母异位词:恰好两个位置不同时,交换这两位必然相等。 - (异位词之间也可能有 3 处或更多处不同,例如 tars 与 star,这样的两个串不相似。) - 相似就 union, - 答案是连通块数。单次判断 O(L),两两枚举 O(n²),总计 O(n²·L)。 - 注意:A 与 B 相似、B 与 C 相似,并不能推出 A 与 C 相似, - 但三者属于同一组 —— 并查集维护的正是这种 + 两两判断字符串是否相似:相同,或恰好两个位置不同。这样判断就够了,因为所有串互为字母异位词:恰好两个位置不同时,交换这两位必然相等。(异位词之间也可能有 3 处或更多处不同,例如 tars 与 star,这样的两个串不相似。)相似就 union,答案是连通块数。单次判断 O(L),两两枚举 O(n²),总计 O(n²·L)。注意:A 与 B 相似、B 与 C 相似,并不能推出 A 与 C 相似,但三者属于同一组 —— 并查集维护的正是这种 「关系本身不传递、分组却要传递」的传递闭包。 ), @@ -331,11 +307,8 @@ export const PROBLEMS: Problem[] = [ <> 有向树里加一条坏边,只有三种情形:① 某节点入度为 2(两个父节点)且无环 —— 删两条入边中较晚出现的那条;② 入度 2 且有环 —— - 删两条候选里位于环上的那条,它可能较早出现,也可能较晚出现, - 所以要靠下面的并查集检验来区分;③ 无入度 2 但有环 —— - 删掉合并时检测到成环的那条。做法是先扫一遍找出入度为 2 的两条候选边, - 再用并查集跳过第二条候选跑一遍:能成树就删它,否则删第一条或成环边。 - 684 只要模板,685 考的是把模板嵌进分类讨论的能力。 + 删两条候选里位于环上的那条,它可能较早出现,也可能较晚出现,所以要靠下面的并查集检验来区分;③ 无入度 2 但有环 —— + 删掉合并时检测到成环的那条。做法是先扫一遍找出入度为 2 的两条候选边,再用并查集跳过第二条候选跑一遍:能成树就删它,否则删第一条或成环边。684 只要模板,685 考的是把模板嵌进分类讨论的能力。 ), }, diff --git a/test/unit/zh-copy-spacing.test.ts b/test/unit/zh-copy-spacing.test.ts new file mode 100644 index 0000000..3926419 --- /dev/null +++ b/test/unit/zh-copy-spacing.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +// JSX trims each line of a text block and joins the lines with one space, so a +// line break inside Chinese copy renders as a stray space ("算。 建议"); a space +// written after full-width punctuation ("。 暴力:") shows up the same way. +// The site's convention keeps a space between Chinese and Latin letters or +// digits ("第 3 章"), after an expression such as O(1), around "——" and around +// math operators, and nowhere else. This scans every TSX file for the rest. + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const HAN = /[㐀-鿿豈-﫿]/u; +const FW = /[ -〿＀-￯]/u; // CJK and full-width punctuation +const ZH = /[㐀-鿿豈-﫿 -〿＀-￯]/u; +const PUNCT = /[、-〃〈-】〔-〟!-/:-?[-`{-・]/u; +const K = ts.SyntaxKind; + +/** An ellipsis that closes Chinese text, not one inside a spaced math sequence. */ +function closesChinese(raw: string, i: number) { + let j = i; + while (j > 0 && raw[j - 1] === "…") j--; + return j > 0 && ZH.test(raw[j - 1]); +} + +/** Whether the line break between raw[i] and the next text should not be a space. */ +function breakIsStray(raw: string, i: number, a: string, b: string) { + if (FW.test(a) && b !== "—") return true; + if (a === "…" && closesChinese(raw, i) && b !== "—") return true; + if (FW.test(b) && a !== "—") return true; + if (HAN.test(a) && (HAN.test(b) || b === "(" || b === "“")) return true; + if (/[,:;?!]/.test(a) && HAN.test(b)) return true; + if (a === ")" && HAN.test(b)) { + const open = raw.lastIndexOf("(", i); + return open > 0 && ZH.test(raw[open - 1]); + } + return false; +} + +/** What JSX renders for a text node. */ +function rendered(raw: string) { + if (!raw.includes("\n")) return raw; + return raw + .split("\n") + .map((l, i, all) => (i === 0 ? l.trimEnd() : i === all.length - 1 ? l.trimStart() : l.trim())) + .filter((l) => l.length) + .join(" "); +} + +function edge(node: ts.Node, sf: ts.SourceFile, last: boolean): string | null | undefined { + if (node.kind === K.JsxText) { + const t = rendered(node.getFullText(sf)); + return t.length ? t[last ? t.length - 1 : 0] : null; + } + if (ts.isJsxExpression(node)) { + const e = node.expression; + if (e && (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e))) + return e.text.length ? e.text[last ? e.text.length - 1 : 0] : null; + return undefined; + } + if (ts.isJsxElement(node) || ts.isJsxFragment(node)) { + const kids = node.children.filter((c) => !(c.kind === K.JsxText && rendered(c.getFullText(sf)) === "")); + return kids.length ? edge(last ? kids[kids.length - 1] : kids[0], sf, last) : null; + } + return undefined; +} + +function strays(file: string): string[] { + const text = readFileSync(file, "utf8"); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const found: string[] = []; + const at = (pos: number, what: string) => + found.push(`${file}:${sf.getLineAndCharacterOfPosition(pos).line + 1} ${what}`); + + const scanText = (start: number, raw: string) => { + for (const m of raw.matchAll(/(\S)([ \t]*\n[ \t\n]*)(\S)/g)) { + if (breakIsStray(raw, m.index!, m[1], m[3])) at(start + m.index!, `line break ${m[1]}⏎${m[3]}`); + } + for (const m of raw.matchAll(/(\S)( +)(\S)/g)) { + const a = m[1], b = m[3]; + const stray = (PUNCT.test(a) || (a === "…" && closesChinese(raw, m.index!))) && ZH.test(b) && b !== "—"; + if (stray) at(start + m.index!, `space ${a} ${b}`); + } + }; + + const visit = (node: ts.Node) => { + if (node.kind === K.JsxText) scanText(node.pos, node.getFullText(sf)); + else if (ts.isStringLiteral(node) && node.parent?.kind === K.JsxAttribute) + scanText(node.getStart(sf), node.getText(sf)); + if (ts.isJsxElement(node) || ts.isJsxFragment(node)) { + const kids = node.children.filter((c) => !(c.kind === K.JsxText && rendered(c.getFullText(sf)) === "")); + kids.forEach((c, i) => { + const prev = kids[i - 1]; + const next = kids[i + 1]; + if (!prev) return; + const a = edge(prev, sf, true); + if (!a || !PUNCT.test(a)) return; + if (ts.isJsxExpression(c) && c.expression && ts.isStringLiteral(c.expression) && /^ +$/.test(c.expression.text) && next) { + const b = edge(next, sf, false); + if (b && ZH.test(b)) at(c.getStart(sf), `{" "} after ${a}`); + } + if (ts.isJsxElement(c) && c.children[0]?.kind === K.JsxText) { + const m = /^( +)(\S)/.exec(c.children[0].getFullText(sf)); + if (m && ZH.test(m[2])) at(c.children[0].pos, `space opening <${c.openingElement.tagName.getText(sf)}> after ${a}`); + } + }); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return found; +} + +function tsxFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? tsxFiles(join(dir, e.name)) : e.name.endsWith(".tsx") ? [join(dir, e.name)] : [], + ); +} + +describe("Chinese copy", () => { + it("has no line break or space that renders as a stray space", () => { + const files = [...tsxFiles("app"), ...tsxFiles("lib")]; + expect(files.length).toBeGreaterThan(40); + expect(files.flatMap(strays)).toEqual([]); + }); +});