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([]); + }); +});