From 722bebf720bd65d8f550dda73f1a08db297bbeab Mon Sep 17 00:00:00 2001 From: WEIREN FENG Date: Thu, 8 Oct 2026 16:48:06 -0700 Subject: [PATCH] Split each chapter's problems and quiz so the atlas loads only problems Every lib/-data.tsx exported both PROBLEMS and QUIZ. The chapter page uses both, so webpack kept both in the module it shares with /atlas, and the atlas loaded thirteen chapters' quiz text it never shows (A#28). Each module is now two: lib/-problems.tsx (PROBLEMS, plus any constant only the problems use) and lib/-quiz.tsx (QUIZ). The arrays themselves are byte-identical to before. Chapter pages import both; app/atlas/page.tsx imports only the problem modules. CLAUDE.md's file map lists the new pair. /atlas First Load JS drops from 297 kB to 213 kB. Chapter pages are unchanged or up to 2 kB smaller. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- app/advanced/page.tsx | 3 +- app/array/page.tsx | 3 +- app/atlas/page.tsx | 26 +- app/binary-tree/page.tsx | 3 +- app/bst/page.tsx | 3 +- app/graph/page.tsx | 3 +- app/hash/page.tsx | 3 +- app/heap/page.tsx | 3 +- app/linked-list/page.tsx | 3 +- app/queue/page.tsx | 3 +- app/stack/page.tsx | 3 +- app/string/page.tsx | 3 +- app/trie/page.tsx | 3 +- app/union-find/page.tsx | 3 +- lib/advanced-problems.tsx | 305 ++++++++++++ lib/{advanced-data.tsx => advanced-quiz.tsx} | 304 +----------- lib/{array-data.tsx => array-problems.tsx} | 347 +------------- lib/array-quiz.tsx | 346 +++++++++++++ ...tree-data.tsx => binary-tree-problems.tsx} | 332 +------------ lib/binary-tree-quiz.tsx | 333 +++++++++++++ lib/bst-problems.tsx | 333 +++++++++++++ lib/{bst-data.tsx => bst-quiz.tsx} | 332 +------------ lib/graph-problems.tsx | 452 +++++++++++++++++ lib/{graph-data.tsx => graph-quiz.tsx} | 453 +----------------- lib/hash-problems.tsx | 374 +++++++++++++++ lib/{hash-data.tsx => hash-quiz.tsx} | 373 +------------- lib/{heap-data.tsx => heap-problems.tsx} | 272 +---------- lib/heap-quiz.tsx | 273 +++++++++++ ...list-data.tsx => linked-list-problems.tsx} | 298 +----------- lib/linked-list-quiz.tsx | 299 ++++++++++++ lib/queue-problems.tsx | 292 +++++++++++ lib/{queue-data.tsx => queue-quiz.tsx} | 291 +---------- lib/stack-problems.tsx | 350 ++++++++++++++ lib/{stack-data.tsx => stack-quiz.tsx} | 351 +------------- lib/{string-data.tsx => string-problems.tsx} | 274 +---------- lib/string-quiz.tsx | 275 +++++++++++ lib/trie-problems.tsx | 305 ++++++++++++ lib/{trie-data.tsx => trie-quiz.tsx} | 304 +----------- ...-find-data.tsx => union-find-problems.tsx} | 297 +----------- lib/union-find-quiz.tsx | 297 ++++++++++++ 41 files changed, 4306 insertions(+), 4224 deletions(-) create mode 100644 lib/advanced-problems.tsx rename lib/{advanced-data.tsx => advanced-quiz.tsx} (56%) rename lib/{array-data.tsx => array-problems.tsx} (57%) create mode 100644 lib/array-quiz.tsx rename lib/{binary-tree-data.tsx => binary-tree-problems.tsx} (53%) create mode 100644 lib/binary-tree-quiz.tsx create mode 100644 lib/bst-problems.tsx rename lib/{bst-data.tsx => bst-quiz.tsx} (51%) create mode 100644 lib/graph-problems.tsx rename lib/{graph-data.tsx => graph-quiz.tsx} (53%) create mode 100644 lib/hash-problems.tsx rename lib/{hash-data.tsx => hash-quiz.tsx} (53%) rename lib/{heap-data.tsx => heap-problems.tsx} (51%) create mode 100644 lib/heap-quiz.tsx rename lib/{linked-list-data.tsx => linked-list-problems.tsx} (50%) create mode 100644 lib/linked-list-quiz.tsx create mode 100644 lib/queue-problems.tsx rename lib/{queue-data.tsx => queue-quiz.tsx} (56%) create mode 100644 lib/stack-problems.tsx rename lib/{stack-data.tsx => stack-quiz.tsx} (53%) rename lib/{string-data.tsx => string-problems.tsx} (50%) create mode 100644 lib/string-quiz.tsx create mode 100644 lib/trie-problems.tsx rename lib/{trie-data.tsx => trie-quiz.tsx} (51%) rename lib/{union-find-data.tsx => union-find-problems.tsx} (50%) create mode 100644 lib/union-find-quiz.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 6dab832..903dc25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,8 @@ lib/highlight.tsx lib/progress.tsx lib/curriculum.ts lib/i18n.tsx app//page.tsx chapter page ("use client"; data + composition) app//viz.tsx visualizations specific to this chapter app//chapter.css styles specific to this chapter (imported by page.tsx) -lib/-data.tsx this chapter's PROBLEMS + QUIZ data +lib/-problems.tsx this chapter's PROBLEMS (the atlas imports these too) +lib/-quiz.tsx this chapter's QUIZ (kept apart so the atlas does not load it) ``` Each chapter's palette comes free from `
` diff --git a/app/advanced/page.tsx b/app/advanced/page.tsx index 4daeb53..c62cfcf 100644 --- a/app/advanced/page.tsx +++ b/app/advanced/page.tsx @@ -25,7 +25,8 @@ import { CodeTabs, CodeBlock } from "@/lib/code"; import { ArrayStepper, type ArrayFrame } from "@/lib/stepper"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/advanced-data"; +import { PROBLEMS } from "@/lib/advanced-problems"; +import { QUIZ } from "@/lib/advanced-quiz"; import { T } from "@/lib/i18n"; import { LRUAnatomy, diff --git a/app/array/page.tsx b/app/array/page.tsx index ab88452..5f83551 100644 --- a/app/array/page.tsx +++ b/app/array/page.tsx @@ -21,7 +21,8 @@ import { CodeTabs } from "@/lib/code"; import { ArrayStepper, type ArrayFrame } from "@/lib/stepper"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/array-data"; +import { PROBLEMS } from "@/lib/array-problems"; +import { QUIZ } from "@/lib/array-quiz"; import { T } from "@/lib/i18n"; import { IndexLab, ShiftLab, GrowLab, MatrixLab } from "./viz"; diff --git a/app/atlas/page.tsx b/app/atlas/page.tsx index 99216f2..de2ba2c 100644 --- a/app/atlas/page.tsx +++ b/app/atlas/page.tsx @@ -23,19 +23,19 @@ import { CHAPTERS, type ChapterId } from "@/lib/curriculum"; import { useL, T, type Loc } from "@/lib/i18n"; import { DecisionLab } from "./viz"; -import { PROBLEMS as P_ARRAY } from "@/lib/array-data"; -import { PROBLEMS as P_STRING } from "@/lib/string-data"; -import { PROBLEMS as P_LIST } from "@/lib/linked-list-data"; -import { PROBLEMS as P_STACK } from "@/lib/stack-data"; -import { PROBLEMS as P_QUEUE } from "@/lib/queue-data"; -import { PROBLEMS as P_HASH } from "@/lib/hash-data"; -import { PROBLEMS as P_BTREE } from "@/lib/binary-tree-data"; -import { PROBLEMS as P_BST } from "@/lib/bst-data"; -import { PROBLEMS as P_HEAP } from "@/lib/heap-data"; -import { PROBLEMS as P_TRIE } from "@/lib/trie-data"; -import { PROBLEMS as P_UF } from "@/lib/union-find-data"; -import { PROBLEMS as P_GRAPH } from "@/lib/graph-data"; -import { PROBLEMS as P_ADV } from "@/lib/advanced-data"; +import { PROBLEMS as P_ARRAY } from "@/lib/array-problems"; +import { PROBLEMS as P_STRING } from "@/lib/string-problems"; +import { PROBLEMS as P_LIST } from "@/lib/linked-list-problems"; +import { PROBLEMS as P_STACK } from "@/lib/stack-problems"; +import { PROBLEMS as P_QUEUE } from "@/lib/queue-problems"; +import { PROBLEMS as P_HASH } from "@/lib/hash-problems"; +import { PROBLEMS as P_BTREE } from "@/lib/binary-tree-problems"; +import { PROBLEMS as P_BST } from "@/lib/bst-problems"; +import { PROBLEMS as P_HEAP } from "@/lib/heap-problems"; +import { PROBLEMS as P_TRIE } from "@/lib/trie-problems"; +import { PROBLEMS as P_UF } from "@/lib/union-find-problems"; +import { PROBLEMS as P_GRAPH } from "@/lib/graph-problems"; +import { PROBLEMS as P_ADV } from "@/lib/advanced-problems"; /* ---------- Course-wide problem set groups ---------- */ diff --git a/app/binary-tree/page.tsx b/app/binary-tree/page.tsx index 0d31632..f1fa169 100644 --- a/app/binary-tree/page.tsx +++ b/app/binary-tree/page.tsx @@ -21,7 +21,8 @@ import { import { CodeTabs } from "@/lib/code"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/binary-tree-data"; +import { PROBLEMS } from "@/lib/binary-tree-problems"; +import { QUIZ } from "@/lib/binary-tree-quiz"; import { T } from "@/lib/i18n"; import { TermTree, diff --git a/app/bst/page.tsx b/app/bst/page.tsx index 9eeb808..1628169 100644 --- a/app/bst/page.tsx +++ b/app/bst/page.tsx @@ -28,7 +28,8 @@ import { ArrayStepper, type ArrayFrame } from "@/lib/stepper"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; import { T, type Loc } from "@/lib/i18n"; -import { PROBLEMS, QUIZ } from "@/lib/bst-data"; +import { PROBLEMS } from "@/lib/bst-problems"; +import { QUIZ } from "@/lib/bst-quiz"; import { BSTLab, InorderFig, diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 5f00ddc..a35eac7 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -23,7 +23,8 @@ import { import { CodeTabs } from "@/lib/code"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/graph-data"; +import { PROBLEMS } from "@/lib/graph-problems"; +import { QUIZ } from "@/lib/graph-quiz"; import { T } from "@/lib/i18n"; import { TermGraph, diff --git a/app/hash/page.tsx b/app/hash/page.tsx index ce85dae..63d9664 100644 --- a/app/hash/page.tsx +++ b/app/hash/page.tsx @@ -21,7 +21,8 @@ import { CodeTabs } from "@/lib/code"; import { ArrayStepper, type ArrayFrame } from "@/lib/stepper"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/hash-data"; +import { PROBLEMS } from "@/lib/hash-problems"; +import { QUIZ } from "@/lib/hash-quiz"; import { T } from "@/lib/i18n"; import { HashLab, CollisionLab } from "./viz"; import "./chapter.css"; diff --git a/app/heap/page.tsx b/app/heap/page.tsx index d85fbf7..d60beb1 100644 --- a/app/heap/page.tsx +++ b/app/heap/page.tsx @@ -27,7 +27,8 @@ import { CodeTabs } from "@/lib/code"; import { ArrayStepper, type ArrayFrame } from "@/lib/stepper"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/heap-data"; +import { PROBLEMS } from "@/lib/heap-problems"; +import { QUIZ } from "@/lib/heap-quiz"; import { T } from "@/lib/i18n"; import { HeapLab, HeapMapFig } from "./viz"; diff --git a/app/linked-list/page.tsx b/app/linked-list/page.tsx index 27ed605..96dbe22 100644 --- a/app/linked-list/page.tsx +++ b/app/linked-list/page.tsx @@ -25,7 +25,8 @@ import { import { CodeTabs } from "@/lib/code"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/linked-list-data"; +import { PROBLEMS } from "@/lib/linked-list-problems"; +import { QUIZ } from "@/lib/linked-list-quiz"; import { T } from "@/lib/i18n"; import { ScatterMap, LinkedLab, ReverseAnim, CycleAnim, MergeAnim } from "./viz"; import "./chapter.css"; diff --git a/app/queue/page.tsx b/app/queue/page.tsx index 09cf2e2..422dfa6 100644 --- a/app/queue/page.tsx +++ b/app/queue/page.tsx @@ -27,7 +27,8 @@ import { CodeTabs } from "@/lib/code"; import { ArrayStepper, type ArrayFrame } from "@/lib/stepper"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/queue-data"; +import { PROBLEMS } from "@/lib/queue-problems"; +import { QUIZ } from "@/lib/queue-quiz"; import { T } from "@/lib/i18n"; import { QueueMemFig, RingLab, TwoStackPour } from "./viz"; diff --git a/app/stack/page.tsx b/app/stack/page.tsx index 7041094..c2155b0 100644 --- a/app/stack/page.tsx +++ b/app/stack/page.tsx @@ -24,7 +24,8 @@ import { CodeTabs } from "@/lib/code"; import { ArrayStepper, type ArrayFrame } from "@/lib/stepper"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/stack-data"; +import { PROBLEMS } from "@/lib/stack-problems"; +import { QUIZ } from "@/lib/stack-quiz"; import { T } from "@/lib/i18n"; import { StackMemFig, StackLab, CallStackDemo } from "./viz"; diff --git a/app/string/page.tsx b/app/string/page.tsx index 639a368..0a042df 100644 --- a/app/string/page.tsx +++ b/app/string/page.tsx @@ -25,7 +25,8 @@ import { ArrayStepper, type ArrayFrame } from "@/lib/stepper"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; import { T, useL } from "@/lib/i18n"; -import { PROBLEMS, QUIZ } from "@/lib/string-data"; +import { PROBLEMS } from "@/lib/string-problems"; +import { QUIZ } from "@/lib/string-quiz"; import { EncodeLab, ConcatLab } from "./viz"; import "./chapter.css"; diff --git a/app/trie/page.tsx b/app/trie/page.tsx index 11bf90c..dee7f8b 100644 --- a/app/trie/page.tsx +++ b/app/trie/page.tsx @@ -20,7 +20,8 @@ import { import { CodeTabs } from "@/lib/code"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/trie-data"; +import { PROBLEMS } from "@/lib/trie-problems"; +import { QUIZ } from "@/lib/trie-quiz"; import { T } from "@/lib/i18n"; import { TrieLab, diff --git a/app/union-find/page.tsx b/app/union-find/page.tsx index 306d9c5..4072b2c 100644 --- a/app/union-find/page.tsx +++ b/app/union-find/page.tsx @@ -35,7 +35,8 @@ import { CodeTabs } from "@/lib/code"; import { ArrayStepper, type ArrayFrame } from "@/lib/stepper"; import { ProblemSet } from "@/lib/problems"; import { Quiz } from "@/lib/quiz"; -import { PROBLEMS, QUIZ } from "@/lib/union-find-data"; +import { PROBLEMS } from "@/lib/union-find-problems"; +import { QUIZ } from "@/lib/union-find-quiz"; import { T } from "@/lib/i18n"; import { UFLab } from "./viz"; import "./chapter.css"; diff --git a/lib/advanced-problems.tsx b/lib/advanced-problems.tsx new file mode 100644 index 0000000..f6382f5 --- /dev/null +++ b/lib/advanced-problems.tsx @@ -0,0 +1,305 @@ +// Chapter 13 · Composition and advanced structures — problem set. +// The closing quiz is in lib/advanced-quiz.tsx; /atlas imports only this file. +// Problems are picked around this chapter's "composite machines": LRU/LFU (hash table + +// linked list), the prefix-sum baseline, segment tree / Fenwick tree, skip list, plus one +// design problem that ties back to the hash table chapter. +// hint points a direction without spoilers; key explains the optimal solution in one paragraph. +// +// Bilingual: title / tags / hint / key are all { en, zh } pairs; problem titles use the official LeetCode English names. + +import type { Problem } from "@/lib/problems"; + +export const PROBLEMS: Problem[] = [ + { + lc: 303, + title: { en: "Range Sum Query - Immutable", zh: "区域和检索 - 数组不可变" }, + d: "easy", + tags: [ + { en: "prefix sum", zh: "前缀和" }, + { en: "choosing a structure", zh: "选型对照组" }, + ], + hint: { + en: "The array never changes after it is loaded, and you only ask for range sums. Do you really need a segment tree here? Think about the cheapest structure that still answers every query fast.", + zh: "数据加载后永远不改、只查区间和 —— 真的需要线段树这种重型结构吗?先想想最便宜的方案。", + }, + key: { + en: ( + <> + Prefix sums. Let pre[i] be the sum of the first i elements. Then + sumRange(l, r) = pre[r+1] − pre[l]. Building the table costs O(n) once, + and every query after that is O(1). Compare this problem with LC 307: + the only difference is that the array becomes mutable, and that single + change is what forces you to move from three lines of prefix sums to a + segment tree. These two problems are the clearest example of how the + operation mix decides the structure. + + ), + zh: ( + <> + 前缀和:pre[i] = 前 i 个元素之和,sumRange(l, r) = pre[r+1] − pre[l]。 + 预处理 O(n),此后每次查询 O(1),实现只要三行。把它和 LC 307 + 放在一起看:两题只差「数组能不能改」这一个条件,答案就从三行前缀和 + 变成一棵线段树 —— 操作组合决定选型,这对姊妹题讲得最清楚。 + + ), + }, + }, + { + lc: 705, + title: { en: "Design HashSet", zh: "设计哈希集合" }, + d: "easy", + tags: [ + { en: "design", zh: "设计" }, + { en: "array + linked list", zh: "数组+链表" }, + { en: "hashing review", zh: "哈希回顾" }, + ], + hint: { + en: "This is chapter 6 again: an array of buckets plus separate chaining. You already know enough to build one from scratch.", + zh: "回头串联第 6 章:桶数组 + 链地址法,你完全有能力从零实现一个。", + }, + key: { + en: ( + <> + Use a prime number of buckets, for example 769. Each bucket holds a + linked list (or a small dynamic array). hash(key) = key % 769 picks the + bucket, and a linear scan inside that bucket resolves collisions. add, + remove, and contains are O(1) on average. Read it with this chapter in + mind: a hash set is itself an array combined with linked lists, so you + already met composite design back in chapter 6. + + ), + zh: ( + <> + 取一个质数当桶数(比如 769),每个桶挂一条链表(或小的动态数组): + hash(key) = key % 769 定位桶,桶内线性查找处理冲突。add / remove / + contains 平均 O(1)。用本章的眼光重看:哈希集合本身就是「数组 + 链表」 + 拼出来的组合结构 —— 你在第 6 章就已经见过组合设计了。 + + ), + }, + }, + { + lc: 146, + title: { en: "LRU Cache", zh: "LRU 缓存" }, + d: "medium", + tags: [ + { en: "hash map + doubly linked list", zh: "哈希+双向链表" }, + { en: "O(1) design", zh: "O(1) 设计" }, + { en: "core of this chapter", zh: "本章重头戏" }, + ], + hint: { + en: "Both get and put must be O(1). One structure answers \"where is this key\", the other answers \"how old is it\". Neither can do the other's job.", + zh: "get/put 都要 O(1):一个结构回答「在哪」,另一个回答「多旧」,谁也替代不了谁。", + }, + key: { + en: ( + <> + The hash map stores key to node reference, so you reach any node in one + step. The doubly linked list keeps the nodes in access order, newest at + the head and oldest at the tail. On a get hit, unlink the node and link + it back at the head. On a put that exceeds capacity, unlink tail.prev + and also delete its hash map entry. Dummy head and tail nodes + remove every null check. The list must be doubly linked: unlinking a + node means updating its predecessor's next pointer, and a singly + linked node has no way to reach its predecessor. §02 has the full + derivation, a line-by-line implementation, and an interactive lab. + + ), + zh: ( + <> + 哈希表存 key → 链表节点引用(一步定位),双向链表按访问序排列 + (头新尾旧,维护顺序)。get 命中就把节点摘下来插回头部;put 超容量就摘掉 + tail.prev,并同步删除它的哈希条目。哑头哑尾省掉全部判空。 + 必须双向:摘除一个节点要改前驱的 next,单链表的节点拿不到前驱。§02 + 有完整推导 + 逐行实现 + 交互实验室。 + + ), + }, + }, + { + lc: 304, + title: { + en: "Range Sum Query 2D - Immutable", + zh: "二维区域和检索 - 矩阵不可变", + }, + d: "medium", + tags: [ + { en: "2D prefix sum", zh: "二维前缀和" }, + { en: "inclusion-exclusion", zh: "容斥" }, + ], + hint: { + en: "Extend prefix sums to two dimensions. Take the big rectangle, subtract the strip above and the strip on the left, and notice that one corner block was subtracted twice.", + zh: "把一维前缀和推广到二维:大矩形减掉上面一条、左边一条 —— 但左上角那一块被减了两次。", + }, + key: { + en: ( + <> + Let pre[i][j] be the sum of the rectangle from (0,0) to (i−1,j−1). Then + any submatrix sum is pre[r2+1][c2+1] − pre[r1][c2+1] − pre[r2+1][c1] + + pre[r1][c1]. You subtract the top strip and the left strip, the + top-left block gets subtracted twice, so you add it back once. Building + the table is O(mn) and each query is O(1). The build step uses the same + idea: pre[i][j] = element + above + left − top-left. + + ), + 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] = 元素 + 上 + 左 − 左上。 + + ), + }, + }, + { + lc: 307, + title: { en: "Range Sum Query - Mutable", zh: "区域和检索 - 数组可修改" }, + d: "medium", + tags: [ + { en: "segment tree", zh: "线段树" }, + { en: "Fenwick tree", zh: "树状数组" }, + { en: "update + query", zh: "改+查" }, + ], + hint: { + en: "Updates and queries are mixed. A prefix sum table has to be rebuilt in O(n) after every update. Either of the two range structures in this chapter solves it.", + zh: "又要改又要查,前缀和一改就要 O(n) 重建 —— 本章两台「区间机器」任选其一。", + }, + key: { + en: ( + <> + Segment tree: update walks from the leaf back up to the root, and query + handles three cases per node (no overlap, full cover, partial overlap). + Both are O(log n), and §04 has the line-by-line implementation. Fenwick + tree: about 60% as much code. update first computes delta = val − + a[i], then adds delta along i += lowbit(i). A prefix query walks i −= + lowbit(i) and adds up the segments, and a range sum is the difference + of two prefix sums. Walkthrough B compares both solutions. + + ), + zh: ( + <> + 线段树:update 从叶到根回溯重算,query 分三种相交情况处理,双 O(log n) + (§04 有逐行实现)。树状数组:代码量约为线段树的六成 —— update 先算 + delta = val − a[i],再沿 i += lowbit(i) 一路加上去;查询沿 + i −= lowbit(i) 拼前缀和,区间和 = 两次前缀相减。精讲 B 有两种解法的完整对照。 + + ), + }, + }, + { + lc: 460, + title: { en: "LFU Cache", zh: "LFU 缓存" }, + d: "hard", + tags: [ + { en: "frequency buckets", zh: "频次分桶" }, + { en: "two hash maps", zh: "双哈希" }, + { en: "minFreq", zh: "minFreq" }, + ], + hint: { + en: "One dimension more than LRU: compare use counts first, and break ties by which key was used least recently. Try giving every frequency its own bucket.", + zh: "比 LRU 多一个维度:先比使用次数,次数相同再比「最近」。试着给每个频次开一个桶。", + }, + key: { + en: ( + <> + Three pieces of state: key to (value, freq); freq to an ordered bucket + holding every key with that frequency (ordered by time, so each bucket + is a small LRU); and a minFreq variable. A hit moves the key from the + freq bucket into the freq+1 bucket. An eviction removes the oldest key + in the minFreq bucket, which is exactly the least recently used key + among the least frequently used ones. minFreq never needs a search: it + increases by 1 when the old bucket becomes empty, and it resets to 1 + whenever a new key is inserted. Every operation is O(1). §03 has the + bucket diagram and the core implementation in three languages. + + ), + zh: ( + <> + 三份状态:key → (val, freq);freq → 该频次的有序桶(桶内按时间序, + 天然是个小 LRU);再加一个 minFreq 变量。访问 = 把 key 从 freq 桶搬进 + freq+1 桶;淘汰 = 移除 minFreq 桶里最老的 key,也就是「频次最低者中最久未用的那个」。 + minFreq 不用搜索:旧桶被搬空时 +1,插入新 key 时归 1。所有操作 O(1)。 + §03 有分桶图解和三语言核心实现。 + + ), + }, + }, + { + lc: 315, + title: { + en: "Count of Smaller Numbers After Self", + zh: "计算右侧小于当前元素的个数", + }, + d: "hard", + tags: [ + { en: "Fenwick tree", zh: "树状数组" }, + { en: "coordinate compression", zh: "离散化" }, + { en: "scan right to left", zh: "倒序扫描" }, + ], + hint: { + en: "Scan from right to left. The question becomes: among the values already seen, how many are smaller than the current one? That is a prefix count that keeps changing.", + zh: "从右往左扫,问题就变成:「已经出现过的数里,比我小的有几个?」—— 一个会不断变化的计数前缀和。", + }, + key: { + en: ( + <> + First compress the values into ranks 1..n (sort, remove duplicates, + then binary search each value). Walk the array from right to left: + ans[i] = query(rank − 1), which counts the already registered values + smaller than this one, then call add(rank, 1) to register the current + value. "Insert and ask for a prefix count at the same time" + is exactly what a Fenwick tree is for, and the whole solution is O(n + log n). Counting inversions with merge sort also works, but the Fenwick + version is much shorter. + + ), + zh: ( + <> + 先把数值离散化成排名 1..n(排序去重后二分定位)。从右到左遍历:ans[i] = + query(rank − 1)(已登记的、比它小的个数),然后 add(rank, 1) 把自己登记进去。 + 「边插入、边问前缀和」正是树状数组的主场,整体 O(n log n)。 + 归并排序统计逆序对也能解,但 BIT 版短得多。 + + ), + }, + }, + { + lc: 1206, + title: { en: "Design Skiplist", zh: "设计跳表" }, + d: "hard", + tags: [ + { en: "skip list", zh: "跳表" }, + { en: "randomization", zh: "随机化" }, + { en: "multi-level index", zh: "多层索引" }, + ], + hint: { + en: "A sorted linked list plus several express lanes above it. Start at the top level, move right while you can, and drop down one level when the next node would overshoot. A coin flip decides how tall a new node is.", + zh: "有序链表 + 多层「快线」:从最高层开始,向右走,过头就下楼。插入时抛硬币决定新节点有几层。", + }, + key: { + en: ( + <> + Each node stores an array next[] whose length is the node's + height. search starts at the top level, moves right while the next + value is smaller than the target, and drops a level otherwise. add + walks the same way first and records the last node visited on each + level in update[i], then performs an ordinary linked-list insertion on + each of its levels. The height comes from repeated coin flips, so each + extra level has probability 1/2. erase walks the same way and then + bypasses the target node level by level. Expected cost is O(log n), and + §06 has the full implementation in three languages. + + ), + zh: ( + <> + 每个节点存一个 next[] 指针数组(长度 = 它的层数)。search + 从顶层开始,右邻比目标小就向右走,否则下楼;add 先像 search + 一样走一遍、记下每层最后停留的节点 update[i],再逐层做普通链表插入 + (层数由抛硬币决定:每多长一层的概率是 1/2);erase 同理逐层绕过目标节点。 + 期望 O(log n),§06 有完整三语言实现。 + + ), + }, + }, +]; diff --git a/lib/advanced-data.tsx b/lib/advanced-quiz.tsx similarity index 56% rename from lib/advanced-data.tsx rename to lib/advanced-quiz.tsx index 451bb0e..079d6bf 100644 --- a/lib/advanced-data.tsx +++ b/lib/advanced-quiz.tsx @@ -1,309 +1,9 @@ -// Chapter 13 · Composition and advanced structures — problem set and quiz data. -// Problems are picked around this chapter's "composite machines": LRU/LFU (hash table + -// linked list), the prefix-sum baseline, segment tree / Fenwick tree, skip list, plus one -// design problem that ties back to the hash table chapter. -// hint points a direction without spoilers; key explains the optimal solution in one paragraph. +// Chapter 13 · Composition and advanced structures — closing quiz (the problem set is in lib/advanced-problems.tsx). // -// Bilingual: title / tags / hint / key are all { en, zh } pairs; problem titles use the official LeetCode English names. +// Bilingual: every question, option and explanation is a { en, zh } pair. -import type { Problem } from "@/lib/problems"; import type { QuizItem } from "@/lib/quiz"; -export const PROBLEMS: Problem[] = [ - { - lc: 303, - title: { en: "Range Sum Query - Immutable", zh: "区域和检索 - 数组不可变" }, - d: "easy", - tags: [ - { en: "prefix sum", zh: "前缀和" }, - { en: "choosing a structure", zh: "选型对照组" }, - ], - hint: { - en: "The array never changes after it is loaded, and you only ask for range sums. Do you really need a segment tree here? Think about the cheapest structure that still answers every query fast.", - zh: "数据加载后永远不改、只查区间和 —— 真的需要线段树这种重型结构吗?先想想最便宜的方案。", - }, - key: { - en: ( - <> - Prefix sums. Let pre[i] be the sum of the first i elements. Then - sumRange(l, r) = pre[r+1] − pre[l]. Building the table costs O(n) once, - and every query after that is O(1). Compare this problem with LC 307: - the only difference is that the array becomes mutable, and that single - change is what forces you to move from three lines of prefix sums to a - segment tree. These two problems are the clearest example of how the - operation mix decides the structure. - - ), - zh: ( - <> - 前缀和:pre[i] = 前 i 个元素之和,sumRange(l, r) = pre[r+1] − pre[l]。 - 预处理 O(n),此后每次查询 O(1),实现只要三行。把它和 LC 307 - 放在一起看:两题只差「数组能不能改」这一个条件,答案就从三行前缀和 - 变成一棵线段树 —— 操作组合决定选型,这对姊妹题讲得最清楚。 - - ), - }, - }, - { - lc: 705, - title: { en: "Design HashSet", zh: "设计哈希集合" }, - d: "easy", - tags: [ - { en: "design", zh: "设计" }, - { en: "array + linked list", zh: "数组+链表" }, - { en: "hashing review", zh: "哈希回顾" }, - ], - hint: { - en: "This is chapter 6 again: an array of buckets plus separate chaining. You already know enough to build one from scratch.", - zh: "回头串联第 6 章:桶数组 + 链地址法,你完全有能力从零实现一个。", - }, - key: { - en: ( - <> - Use a prime number of buckets, for example 769. Each bucket holds a - linked list (or a small dynamic array). hash(key) = key % 769 picks the - bucket, and a linear scan inside that bucket resolves collisions. add, - remove, and contains are O(1) on average. Read it with this chapter in - mind: a hash set is itself an array combined with linked lists, so you - already met composite design back in chapter 6. - - ), - zh: ( - <> - 取一个质数当桶数(比如 769),每个桶挂一条链表(或小的动态数组): - hash(key) = key % 769 定位桶,桶内线性查找处理冲突。add / remove / - contains 平均 O(1)。用本章的眼光重看:哈希集合本身就是「数组 + 链表」 - 拼出来的组合结构 —— 你在第 6 章就已经见过组合设计了。 - - ), - }, - }, - { - lc: 146, - title: { en: "LRU Cache", zh: "LRU 缓存" }, - d: "medium", - tags: [ - { en: "hash map + doubly linked list", zh: "哈希+双向链表" }, - { en: "O(1) design", zh: "O(1) 设计" }, - { en: "core of this chapter", zh: "本章重头戏" }, - ], - hint: { - en: "Both get and put must be O(1). One structure answers \"where is this key\", the other answers \"how old is it\". Neither can do the other's job.", - zh: "get/put 都要 O(1):一个结构回答「在哪」,另一个回答「多旧」,谁也替代不了谁。", - }, - key: { - en: ( - <> - The hash map stores key to node reference, so you reach any node in one - step. The doubly linked list keeps the nodes in access order, newest at - the head and oldest at the tail. On a get hit, unlink the node and link - it back at the head. On a put that exceeds capacity, unlink tail.prev - and also delete its hash map entry. Dummy head and tail nodes - remove every null check. The list must be doubly linked: unlinking a - node means updating its predecessor's next pointer, and a singly - linked node has no way to reach its predecessor. §02 has the full - derivation, a line-by-line implementation, and an interactive lab. - - ), - zh: ( - <> - 哈希表存 key → 链表节点引用(一步定位),双向链表按访问序排列 - (头新尾旧,维护顺序)。get 命中就把节点摘下来插回头部;put 超容量就摘掉 - tail.prev,并同步删除它的哈希条目。哑头哑尾省掉全部判空。 - 必须双向:摘除一个节点要改前驱的 next,单链表的节点拿不到前驱。§02 - 有完整推导 + 逐行实现 + 交互实验室。 - - ), - }, - }, - { - lc: 304, - title: { - en: "Range Sum Query 2D - Immutable", - zh: "二维区域和检索 - 矩阵不可变", - }, - d: "medium", - tags: [ - { en: "2D prefix sum", zh: "二维前缀和" }, - { en: "inclusion-exclusion", zh: "容斥" }, - ], - hint: { - en: "Extend prefix sums to two dimensions. Take the big rectangle, subtract the strip above and the strip on the left, and notice that one corner block was subtracted twice.", - zh: "把一维前缀和推广到二维:大矩形减掉上面一条、左边一条 —— 但左上角那一块被减了两次。", - }, - key: { - en: ( - <> - Let pre[i][j] be the sum of the rectangle from (0,0) to (i−1,j−1). Then - any submatrix sum is pre[r2+1][c2+1] − pre[r1][c2+1] − pre[r2+1][c1] + - pre[r1][c1]. You subtract the top strip and the left strip, the - top-left block gets subtracted twice, so you add it back once. Building - the table is O(mn) and each query is O(1). The build step uses the same - idea: pre[i][j] = element + above + left − top-left. - - ), - 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] = 元素 + 上 + 左 − 左上。 - - ), - }, - }, - { - lc: 307, - title: { en: "Range Sum Query - Mutable", zh: "区域和检索 - 数组可修改" }, - d: "medium", - tags: [ - { en: "segment tree", zh: "线段树" }, - { en: "Fenwick tree", zh: "树状数组" }, - { en: "update + query", zh: "改+查" }, - ], - hint: { - en: "Updates and queries are mixed. A prefix sum table has to be rebuilt in O(n) after every update. Either of the two range structures in this chapter solves it.", - zh: "又要改又要查,前缀和一改就要 O(n) 重建 —— 本章两台「区间机器」任选其一。", - }, - key: { - en: ( - <> - Segment tree: update walks from the leaf back up to the root, and query - handles three cases per node (no overlap, full cover, partial overlap). - Both are O(log n), and §04 has the line-by-line implementation. Fenwick - tree: about 60% as much code. update first computes delta = val − - a[i], then adds delta along i += lowbit(i). A prefix query walks i −= - lowbit(i) and adds up the segments, and a range sum is the difference - of two prefix sums. Walkthrough B compares both solutions. - - ), - zh: ( - <> - 线段树:update 从叶到根回溯重算,query 分三种相交情况处理,双 O(log n) - (§04 有逐行实现)。树状数组:代码量约为线段树的六成 —— update 先算 - delta = val − a[i],再沿 i += lowbit(i) 一路加上去;查询沿 - i −= lowbit(i) 拼前缀和,区间和 = 两次前缀相减。精讲 B 有两种解法的完整对照。 - - ), - }, - }, - { - lc: 460, - title: { en: "LFU Cache", zh: "LFU 缓存" }, - d: "hard", - tags: [ - { en: "frequency buckets", zh: "频次分桶" }, - { en: "two hash maps", zh: "双哈希" }, - { en: "minFreq", zh: "minFreq" }, - ], - hint: { - en: "One dimension more than LRU: compare use counts first, and break ties by which key was used least recently. Try giving every frequency its own bucket.", - zh: "比 LRU 多一个维度:先比使用次数,次数相同再比「最近」。试着给每个频次开一个桶。", - }, - key: { - en: ( - <> - Three pieces of state: key to (value, freq); freq to an ordered bucket - holding every key with that frequency (ordered by time, so each bucket - is a small LRU); and a minFreq variable. A hit moves the key from the - freq bucket into the freq+1 bucket. An eviction removes the oldest key - in the minFreq bucket, which is exactly the least recently used key - among the least frequently used ones. minFreq never needs a search: it - increases by 1 when the old bucket becomes empty, and it resets to 1 - whenever a new key is inserted. Every operation is O(1). §03 has the - bucket diagram and the core implementation in three languages. - - ), - zh: ( - <> - 三份状态:key → (val, freq);freq → 该频次的有序桶(桶内按时间序, - 天然是个小 LRU);再加一个 minFreq 变量。访问 = 把 key 从 freq 桶搬进 - freq+1 桶;淘汰 = 移除 minFreq 桶里最老的 key,也就是「频次最低者中最久未用的那个」。 - minFreq 不用搜索:旧桶被搬空时 +1,插入新 key 时归 1。所有操作 O(1)。 - §03 有分桶图解和三语言核心实现。 - - ), - }, - }, - { - lc: 315, - title: { - en: "Count of Smaller Numbers After Self", - zh: "计算右侧小于当前元素的个数", - }, - d: "hard", - tags: [ - { en: "Fenwick tree", zh: "树状数组" }, - { en: "coordinate compression", zh: "离散化" }, - { en: "scan right to left", zh: "倒序扫描" }, - ], - hint: { - en: "Scan from right to left. The question becomes: among the values already seen, how many are smaller than the current one? That is a prefix count that keeps changing.", - zh: "从右往左扫,问题就变成:「已经出现过的数里,比我小的有几个?」—— 一个会不断变化的计数前缀和。", - }, - key: { - en: ( - <> - First compress the values into ranks 1..n (sort, remove duplicates, - then binary search each value). Walk the array from right to left: - ans[i] = query(rank − 1), which counts the already registered values - smaller than this one, then call add(rank, 1) to register the current - value. "Insert and ask for a prefix count at the same time" - is exactly what a Fenwick tree is for, and the whole solution is O(n - log n). Counting inversions with merge sort also works, but the Fenwick - version is much shorter. - - ), - zh: ( - <> - 先把数值离散化成排名 1..n(排序去重后二分定位)。从右到左遍历:ans[i] = - query(rank − 1)(已登记的、比它小的个数),然后 add(rank, 1) 把自己登记进去。 - 「边插入、边问前缀和」正是树状数组的主场,整体 O(n log n)。 - 归并排序统计逆序对也能解,但 BIT 版短得多。 - - ), - }, - }, - { - lc: 1206, - title: { en: "Design Skiplist", zh: "设计跳表" }, - d: "hard", - tags: [ - { en: "skip list", zh: "跳表" }, - { en: "randomization", zh: "随机化" }, - { en: "multi-level index", zh: "多层索引" }, - ], - hint: { - en: "A sorted linked list plus several express lanes above it. Start at the top level, move right while you can, and drop down one level when the next node would overshoot. A coin flip decides how tall a new node is.", - zh: "有序链表 + 多层「快线」:从最高层开始,向右走,过头就下楼。插入时抛硬币决定新节点有几层。", - }, - key: { - en: ( - <> - Each node stores an array next[] whose length is the node's - height. search starts at the top level, moves right while the next - value is smaller than the target, and drops a level otherwise. add - walks the same way first and records the last node visited on each - level in update[i], then performs an ordinary linked-list insertion on - each of its levels. The height comes from repeated coin flips, so each - extra level has probability 1/2. erase walks the same way and then - bypasses the target node level by level. Expected cost is O(log n), and - §06 has the full implementation in three languages. - - ), - zh: ( - <> - 每个节点存一个 next[] 指针数组(长度 = 它的层数)。search - 从顶层开始,右邻比目标小就向右走,否则下楼;add 先像 search - 一样走一遍、记下每层最后停留的节点 update[i],再逐层做普通链表插入 - (层数由抛硬币决定:每多长一层的概率是 1/2);erase 同理逐层绕过目标节点。 - 期望 O(log n),§06 有完整三语言实现。 - - ), - }, - }, -]; - export const QUIZ: QuizItem[] = [ { type: "choice", diff --git a/lib/array-data.tsx b/lib/array-problems.tsx similarity index 57% rename from lib/array-data.tsx rename to lib/array-problems.tsx index 5b102d3..b77b5ab 100644 --- a/lib/array-data.tsx +++ b/lib/array-problems.tsx @@ -1,13 +1,13 @@ -// Chapter 1 · Arrays — problem set and quiz data. +// Chapter 1 · Arrays — problem set. +// The closing quiz is in lib/array-quiz.tsx; /atlas imports only this file. // Problems are high-frequency array questions from LeetCode Hot 100 / NeetCode 150, ramping // from Easy to Hard; hint points a direction without spoilers, key explains the optimal // solution in one paragraph. // -// Bilingual: title / tags / hint / key and the entire quiz come in { en, zh } pairs; +// Bilingual: title / tags / hint / key come in { en, zh } pairs; // the en side of each problem title uses the official LeetCode English name. import type { Problem } from "@/lib/problems"; -import type { QuizItem } from "@/lib/quiz"; const TAG = { twoPointers: { en: "Two pointers", zh: "双指针" }, @@ -524,344 +524,3 @@ export const PROBLEMS: Problem[] = [ }, }, ]; - -export const QUIZ: QuizItem[] = [ - { - type: "choice", - q: { - en: "What is the time complexity of inserting one element at the front of an array of length n?", - zh: "在长度为 n 的数组头部插入一个元素,时间复杂度是?", - }, - opts: [ - { - en: "O(n), because every existing element shifts one slot to the right", - zh: "O(n) —— 所有元素都要向后搬一格", - }, - { en: "O(1), you just put it at the front", zh: "O(1) —— 直接放在最前面" }, - { en: "O(log n)", zh: "O(log n)" }, - { - en: "It depends on the value being inserted", - zh: "取决于插入的值是多少", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "The first slot is occupied by arr[0]. To free it, all n existing elements have to move right.", - zh: "最前面的位置被 arr[0] 占着 —— 想空出它,后面 n 个元素必须全体右移。", - }, - { - en: "log n comes from halving a range each step. Inserting moves elements one by one.", - zh: "log n 来自「每步减半」,插入是实打实的逐个搬动。", - }, - { - en: "The number of moves depends only on the position and the length, never on the value.", - zh: "搬动次数只和位置、长度有关,和插入的值毫无关系。", - }, - ], - why: { - en: "Inserting at the front shifts all n elements right. It is one of the most expensive array operations, and it is exactly why linked lists exist (chapter 3).", - zh: "头部插入要右移全部 n 个元素,是数组最贵的操作之一 —— 这正是链表存在的理由(第 3 章见)。", - }, - }, - { - type: "choice", - q: { - en: "What is the most accurate description of appending to a dynamic array (ArrayList, Python list, JS Array)?", - zh: "动态数组(ArrayList / list / JS Array)尾部追加的复杂度,最准确的说法是?", - }, - opts: [ - { - en: "O(1) amortized: a resize costs O(n) now and then, but spread over all appends the average is constant", - zh: "均摊 O(1):偶尔一次 O(n) 扩容,摊到每次操作上是常数", - }, - { en: "Always O(1), resizing costs nothing", zh: "永远 O(1),扩容不花代价" }, - { - en: "O(n), because every append may trigger a resize", - zh: "O(n),因为每次都可能扩容", - }, - { - en: "O(log n), because the capacity grows by a factor", - zh: "O(log n),因为容量是按倍数增长的", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "The resize really does copy every element, so a single append is O(n) in the worst case. It is only the average over many appends that is constant.", - zh: "扩容那一次真的拷贝了全部元素,所以单次追加在最坏情况下是 O(n)。只有摊到多次之后,平均才是常数。", - }, - { - en: "A resize is rare: after the capacity grows, you have to fill the new space before it grows again, so most appends only write one value.", - zh: "扩容是低频事件:容量增长之后,要把新空间填满才会再次扩容,大多数追加只是写入一个值。", - }, - { - en: "Growing by a factor changes how often a resize happens. The amortized cost of one append is a constant, not log n.", - zh: "按倍数增长影响的是扩容的「频率」,单次追加的均摊成本是常数,不是 log n。", - }, - ], - why: { - en: "With doubling, n appends copy about 1+2+4+…+n < 2n elements in total, so the total cost is O(n) and the average per append is constant. The precise wording is 'O(1) amortized', not 'O(1) worst case'. Any growth factor greater than 1 gives the same result.", - zh: "按翻倍扩容时,n 次追加的总拷贝量约为 1+2+4+…+n < 2n,总成本 O(n),平均到每次是常数。准确说法是「均摊 O(1)」而不是「最坏 O(1)」。任何大于 1 的增长倍数都能得到同样的结论。", - }, - }, - { - type: "fill", - q: { - en: ( - <> - A long array (8 bytes per element) starts at address - 2000. What is the address of arr[3]? - - ), - zh: ( - <> - 一个 long 数组(每个元素 8 字节)首地址是 2000,那么{" "} - arr[3] 的地址是? - - ), - }, - placeholder: { en: "Type the address…", zh: "输入地址…" }, - answers: ["2024"], - hint: { - en: "Use the formula: address = base address + index × element size = 2000 + 3 × 8.", - zh: "套公式:地址 = 首地址 + 下标 × 元素大小 = 2000 + 3 × 8。", - }, - why: { - en: "2000 + 3 × 8 = 2024. The same formula explains why indexing starts at 0: an index is an offset, and the first element is 0 units from the base address.", - zh: "2000 + 3 × 8 = 2024。这条公式同时解释了「为什么下标从 0 开始」:下标本质是偏移量,第一个元素偏移为 0。", - }, - }, - { - type: "choice", - q: { - en: "In Java, what is the main difference between int[] and ArrayList?", - zh: "Java 里 int[] 与 ArrayList 最核心的区别是?", - }, - opts: [ - { - en: "int[] has a fixed length and stores primitive int values; ArrayList resizes itself and stores Integer objects, so it pays for boxing", - zh: "int[] 定长、存原始 int;ArrayList 自动扩容、存的是 Integer 对象(有装箱开销)", - }, - { - en: "They are only different names for the same thing", - zh: "两者只是名字不同,底层完全一样", - }, - { - en: "ArrayList is not implemented with an array", - zh: "ArrayList 不是用数组实现的", - }, - { - en: "int[] has more operations than ArrayList", - zh: "int[] 比 ArrayList 功能更多", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "One has a fixed length and stores values directly; the other resizes itself and stores object references. The memory layout and the performance both differ.", - zh: "一个长度固定、直接存值;一个自动扩容、存对象引用 —— 内存布局和性能都不同。", - }, - { - en: "It is the opposite: an ArrayList holds an Object[] inside, and when that is full it copies into a larger one (about 1.5 times).", - zh: "恰恰相反:ArrayList 内部就是一个 Object[],满了就拷贝到更大的数组(约 1.5 倍)。", - }, - { - en: "The other way round: ArrayList has add, remove, contains, and more, while int[] only has length.", - zh: "反了:ArrayList 有 add / remove / contains 等一整套方法,int[] 只有 length。", - }, - ], - why: { - en: "int[] is a fixed-length array of primitives. ArrayList holds an Object[] and grows it by about 1.5, and generics can only hold wrapper objects such as Integer, so numeric work pays for boxing and unboxing.", - zh: "int[] 是定长的原始类型数组;ArrayList 底层是 Object[],按约 1.5 倍扩容,泛型只能装 Integer 这类包装对象,数值密集场景要付出装箱 / 拆箱的代价。", - }, - }, - { - type: "choice", - q: { - en: "In JavaScript, what are the complexities of arr.pop() and arr.shift()?", - zh: "JavaScript 里 arr.pop() 和 arr.shift() 的复杂度分别是?", - }, - opts: [ - { - en: "pop is O(1) and shift is O(n), because removing the first element shifts all the others", - zh: "pop 是 O(1),shift 是 O(n) —— 移除头部元素后所有元素都要前移", - }, - { - en: "Both are O(1), the two ends behave the same", - zh: "都是 O(1),两端操作没区别", - }, - { en: "Both are O(n)", zh: "都是 O(n)" }, - { en: "pop is O(n) and shift is O(1)", zh: "pop 是 O(n),shift 是 O(1)" }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Only the end of an array is free. Once the first element is removed, every remaining index drops by one, so everything moves.", - zh: "数组只有尾部是自由的:头部被移除后,剩下所有元素的下标都要减一,必须集体搬动。", - }, - { - en: "pop touches only the last slot and moves nothing, so it is O(1).", - zh: "pop 只动最后一格,不需要搬动任何元素,是 O(1)。", - }, - { - en: "The two are the other way round: pop at the end moves nothing, while shift at the front moves everything.", - zh: "方向反了:尾部 pop 无人需要挪动;头部 shift 才要全体前移。", - }, - ], - why: { - en: "For the same reason push is O(1) and unshift is O(n). If you need to work at both ends, use a deque (chapter 5).", - zh: "同理 push 是 O(1)、unshift 是 O(n)。需要频繁在头部操作,就该换成双端队列(第 5 章)。", - }, - }, - { - type: "multi", - q: { - en: "Which of these array operations are O(1)? (Select all that apply.)", - zh: "以下哪些数组操作是 O(1)?(多选)", - }, - opts: [ - { en: "Reading arr[i] by index", zh: "按下标读取 arr[i]" }, - { en: "Writing arr[i] = x by index", zh: "按下标覆写 arr[i] = x" }, - { - en: "Appending at the end when there is free capacity", - zh: "尾部追加(容量足够时)", - }, - { - en: "Inserting an element in the middle", - zh: "在中间位置插入一个元素", - }, - ], - correct: [0, 1, 2], - missHint: { - en: "Reading, writing, and appending into free capacity all move no other element. Check which one you left out.", - zh: "读、写、以及容量足够时的尾部追加都不需要搬动任何其他元素 —— 再检查一遍你漏了哪个。", - }, - extraHint: { - en: "Inserting in the middle shifts every element on the right by one slot, so it is O(n) and does not belong here.", - zh: "中间插入必须把右侧元素全部右移一格,是 O(n),不能选它。", - }, - why: { - en: "Any operation that moves no other element is O(1). Any operation that has to make room or close a gap is O(n). That one rule produces the whole complexity table for arrays.", - zh: "凡是不需要搬动其他元素的操作都是 O(1);凡是要腾位置或补空位的都是 O(n)。用这一条就能推出数组的整张复杂度表。", - }, - }, - { - type: "fill", - q: { - en: ( - <> - A matrix with 3 rows and 5 columns is flattened into one array in - row-major order. What is the flat index of matrix[2][3]? - - ), - zh: ( - <> - 一个 3 行 5 列的矩阵按行优先铺平成一维数组,matrix[2][3]{" "} - 对应的一维下标是? - - ), - }, - placeholder: { en: "Type the index…", zh: "输入下标…" }, - answers: ["13"], - hint: { - en: "The formula: row × number of columns + column = 2 × 5 + 3.", - zh: "公式:行号 × 列数 + 列号 = 2 × 5 + 3。", - }, - why: { - en: "2 × 5 + 3 = 13. Two dimensions are a way of reading one dimension, using the same multiply-and-add formula.", - zh: "2 × 5 + 3 = 13。二维只是一维的一种读法,用的还是那条乘加公式。", - }, - }, - { - type: "choice", - q: { - en: "In a hand-written dynamic array, if the resize added only one slot instead of doubling the capacity, what would n appends cost in total?", - zh: "手写动态数组时,如果把「容量翻倍」改成「每次只 +1 个格子」,连续追加 n 次的总代价会变成?", - }, - opts: [ - { - en: "O(n²), because every append copies everything: 1+2+…+n", - zh: "O(n²) —— 每次追加都触发一次全量拷贝,1+2+…+n", - }, - { - en: "Still O(1) amortized, the growth strategy does not matter", - zh: "还是均摊 O(1),扩容策略不影响总代价", - }, - { en: "O(n log n)", zh: "O(n log n)" }, - { - en: "O(n), with a slightly larger constant", - zh: "O(n),只是常数略大", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "The amortized O(1) result comes from growing by a factor. That makes resizes rarer and rarer. With +1, every single append copies the whole array.", - zh: "均摊 O(1) 正是「按倍数增长」换来的:倍增让扩容越来越稀疏;+1 策略下每次追加都要搬全部元素。", - }, - { - en: "Nothing here halves a range at each step, so no logarithm appears.", - zh: "这里没有任何「每步减半」的结构,log 不会凭空出现。", - }, - { - en: "The total number of copies is 1+2+…+n ≈ n²/2, so the total cost is O(n²), not O(n).", - zh: "总拷贝量是 1+2+…+n ≈ n²/2,所以总代价是 O(n²),不是 O(n)。", - }, - ], - why: { - en: "With a +1 resize, the k-th append copies k−1 old elements, so the total is 1+2+…+n = O(n²). Growing by a factor is what spreads the copying thin enough to give O(1) amortized.", - zh: "+1 扩容时,第 k 次追加要拷贝 k−1 个旧元素,总代价 1+2+…+n = O(n²)。按倍数增长才能把搬动摊薄成均摊 O(1),这是动态数组设计的关键。", - }, - }, - { - type: "choice", - q: { - en: "Binary search runs in O(log n) only when two conditions hold at the same time. Which two?", - zh: "二分查找能做到 O(log n),需要同时满足哪两个前提?", - }, - opts: [ - { - en: "The data is sorted, and it supports O(1) random access (an array, for example)", - zh: "数据有序 + 支持 O(1) 随机访问(比如数组)", - }, - { - en: "Sorted is enough, the storage does not matter", - zh: "数据有序就够了,存在哪都行", - }, - { - en: "An array is enough, sorted or not", - zh: "只要是数组就行,有没有序无所谓", - }, - { - en: "The data must be smaller than one million elements", - zh: "数据量必须小于一百万", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "You cannot binary search a sorted linked list: reaching the middle element already takes O(n) steps. Random access is required.", - zh: "有序链表没法二分:光是走到「中间那个」就要 O(n) 步 —— 随机访问不能缺。", - }, - { - en: "In an unsorted array, after comparing with the middle you still do not know which half to keep. Sorted order is required.", - zh: "无序数组里,和中间元素比完仍然不知道该留哪一半 —— 有序不能缺。", - }, - { - en: "Binary search has no size limit. The larger the input, the bigger its advantage.", - zh: "二分对规模没有上限,规模越大它的优势越明显。", - }, - ], - why: { - en: "Sorted order is what makes 'which half can I discard' answerable. Random access is what makes 'jump to the middle' O(1). Only together do they give log n, and that combination is what an array provides.", - zh: "有序保证「该丢哪一半」可以判断,随机访问保证「跳到中间」是 O(1)。两者合力才有 log n,而这正是数组能提供的组合。", - }, - }, -]; diff --git a/lib/array-quiz.tsx b/lib/array-quiz.tsx new file mode 100644 index 0000000..12b5a68 --- /dev/null +++ b/lib/array-quiz.tsx @@ -0,0 +1,346 @@ +// Chapter 1 · Arrays — closing quiz (the problem set is in lib/array-problems.tsx). +// +// Bilingual: every question, option and explanation is a { en, zh } pair. + +import type { QuizItem } from "@/lib/quiz"; + +export const QUIZ: QuizItem[] = [ + { + type: "choice", + q: { + en: "What is the time complexity of inserting one element at the front of an array of length n?", + zh: "在长度为 n 的数组头部插入一个元素,时间复杂度是?", + }, + opts: [ + { + en: "O(n), because every existing element shifts one slot to the right", + zh: "O(n) —— 所有元素都要向后搬一格", + }, + { en: "O(1), you just put it at the front", zh: "O(1) —— 直接放在最前面" }, + { en: "O(log n)", zh: "O(log n)" }, + { + en: "It depends on the value being inserted", + zh: "取决于插入的值是多少", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "The first slot is occupied by arr[0]. To free it, all n existing elements have to move right.", + zh: "最前面的位置被 arr[0] 占着 —— 想空出它,后面 n 个元素必须全体右移。", + }, + { + en: "log n comes from halving a range each step. Inserting moves elements one by one.", + zh: "log n 来自「每步减半」,插入是实打实的逐个搬动。", + }, + { + en: "The number of moves depends only on the position and the length, never on the value.", + zh: "搬动次数只和位置、长度有关,和插入的值毫无关系。", + }, + ], + why: { + en: "Inserting at the front shifts all n elements right. It is one of the most expensive array operations, and it is exactly why linked lists exist (chapter 3).", + zh: "头部插入要右移全部 n 个元素,是数组最贵的操作之一 —— 这正是链表存在的理由(第 3 章见)。", + }, + }, + { + type: "choice", + q: { + en: "What is the most accurate description of appending to a dynamic array (ArrayList, Python list, JS Array)?", + zh: "动态数组(ArrayList / list / JS Array)尾部追加的复杂度,最准确的说法是?", + }, + opts: [ + { + en: "O(1) amortized: a resize costs O(n) now and then, but spread over all appends the average is constant", + zh: "均摊 O(1):偶尔一次 O(n) 扩容,摊到每次操作上是常数", + }, + { en: "Always O(1), resizing costs nothing", zh: "永远 O(1),扩容不花代价" }, + { + en: "O(n), because every append may trigger a resize", + zh: "O(n),因为每次都可能扩容", + }, + { + en: "O(log n), because the capacity grows by a factor", + zh: "O(log n),因为容量是按倍数增长的", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "The resize really does copy every element, so a single append is O(n) in the worst case. It is only the average over many appends that is constant.", + zh: "扩容那一次真的拷贝了全部元素,所以单次追加在最坏情况下是 O(n)。只有摊到多次之后,平均才是常数。", + }, + { + en: "A resize is rare: after the capacity grows, you have to fill the new space before it grows again, so most appends only write one value.", + zh: "扩容是低频事件:容量增长之后,要把新空间填满才会再次扩容,大多数追加只是写入一个值。", + }, + { + en: "Growing by a factor changes how often a resize happens. The amortized cost of one append is a constant, not log n.", + zh: "按倍数增长影响的是扩容的「频率」,单次追加的均摊成本是常数,不是 log n。", + }, + ], + why: { + en: "With doubling, n appends copy about 1+2+4+…+n < 2n elements in total, so the total cost is O(n) and the average per append is constant. The precise wording is 'O(1) amortized', not 'O(1) worst case'. Any growth factor greater than 1 gives the same result.", + zh: "按翻倍扩容时,n 次追加的总拷贝量约为 1+2+4+…+n < 2n,总成本 O(n),平均到每次是常数。准确说法是「均摊 O(1)」而不是「最坏 O(1)」。任何大于 1 的增长倍数都能得到同样的结论。", + }, + }, + { + type: "fill", + q: { + en: ( + <> + A long array (8 bytes per element) starts at address + 2000. What is the address of arr[3]? + + ), + zh: ( + <> + 一个 long 数组(每个元素 8 字节)首地址是 2000,那么{" "} + arr[3] 的地址是? + + ), + }, + placeholder: { en: "Type the address…", zh: "输入地址…" }, + answers: ["2024"], + hint: { + en: "Use the formula: address = base address + index × element size = 2000 + 3 × 8.", + zh: "套公式:地址 = 首地址 + 下标 × 元素大小 = 2000 + 3 × 8。", + }, + why: { + en: "2000 + 3 × 8 = 2024. The same formula explains why indexing starts at 0: an index is an offset, and the first element is 0 units from the base address.", + zh: "2000 + 3 × 8 = 2024。这条公式同时解释了「为什么下标从 0 开始」:下标本质是偏移量,第一个元素偏移为 0。", + }, + }, + { + type: "choice", + q: { + en: "In Java, what is the main difference between int[] and ArrayList?", + zh: "Java 里 int[] 与 ArrayList 最核心的区别是?", + }, + opts: [ + { + en: "int[] has a fixed length and stores primitive int values; ArrayList resizes itself and stores Integer objects, so it pays for boxing", + zh: "int[] 定长、存原始 int;ArrayList 自动扩容、存的是 Integer 对象(有装箱开销)", + }, + { + en: "They are only different names for the same thing", + zh: "两者只是名字不同,底层完全一样", + }, + { + en: "ArrayList is not implemented with an array", + zh: "ArrayList 不是用数组实现的", + }, + { + en: "int[] has more operations than ArrayList", + zh: "int[] 比 ArrayList 功能更多", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "One has a fixed length and stores values directly; the other resizes itself and stores object references. The memory layout and the performance both differ.", + zh: "一个长度固定、直接存值;一个自动扩容、存对象引用 —— 内存布局和性能都不同。", + }, + { + en: "It is the opposite: an ArrayList holds an Object[] inside, and when that is full it copies into a larger one (about 1.5 times).", + zh: "恰恰相反:ArrayList 内部就是一个 Object[],满了就拷贝到更大的数组(约 1.5 倍)。", + }, + { + en: "The other way round: ArrayList has add, remove, contains, and more, while int[] only has length.", + zh: "反了:ArrayList 有 add / remove / contains 等一整套方法,int[] 只有 length。", + }, + ], + why: { + en: "int[] is a fixed-length array of primitives. ArrayList holds an Object[] and grows it by about 1.5, and generics can only hold wrapper objects such as Integer, so numeric work pays for boxing and unboxing.", + zh: "int[] 是定长的原始类型数组;ArrayList 底层是 Object[],按约 1.5 倍扩容,泛型只能装 Integer 这类包装对象,数值密集场景要付出装箱 / 拆箱的代价。", + }, + }, + { + type: "choice", + q: { + en: "In JavaScript, what are the complexities of arr.pop() and arr.shift()?", + zh: "JavaScript 里 arr.pop() 和 arr.shift() 的复杂度分别是?", + }, + opts: [ + { + en: "pop is O(1) and shift is O(n), because removing the first element shifts all the others", + zh: "pop 是 O(1),shift 是 O(n) —— 移除头部元素后所有元素都要前移", + }, + { + en: "Both are O(1), the two ends behave the same", + zh: "都是 O(1),两端操作没区别", + }, + { en: "Both are O(n)", zh: "都是 O(n)" }, + { en: "pop is O(n) and shift is O(1)", zh: "pop 是 O(n),shift 是 O(1)" }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Only the end of an array is free. Once the first element is removed, every remaining index drops by one, so everything moves.", + zh: "数组只有尾部是自由的:头部被移除后,剩下所有元素的下标都要减一,必须集体搬动。", + }, + { + en: "pop touches only the last slot and moves nothing, so it is O(1).", + zh: "pop 只动最后一格,不需要搬动任何元素,是 O(1)。", + }, + { + en: "The two are the other way round: pop at the end moves nothing, while shift at the front moves everything.", + zh: "方向反了:尾部 pop 无人需要挪动;头部 shift 才要全体前移。", + }, + ], + why: { + en: "For the same reason push is O(1) and unshift is O(n). If you need to work at both ends, use a deque (chapter 5).", + zh: "同理 push 是 O(1)、unshift 是 O(n)。需要频繁在头部操作,就该换成双端队列(第 5 章)。", + }, + }, + { + type: "multi", + q: { + en: "Which of these array operations are O(1)? (Select all that apply.)", + zh: "以下哪些数组操作是 O(1)?(多选)", + }, + opts: [ + { en: "Reading arr[i] by index", zh: "按下标读取 arr[i]" }, + { en: "Writing arr[i] = x by index", zh: "按下标覆写 arr[i] = x" }, + { + en: "Appending at the end when there is free capacity", + zh: "尾部追加(容量足够时)", + }, + { + en: "Inserting an element in the middle", + zh: "在中间位置插入一个元素", + }, + ], + correct: [0, 1, 2], + missHint: { + en: "Reading, writing, and appending into free capacity all move no other element. Check which one you left out.", + zh: "读、写、以及容量足够时的尾部追加都不需要搬动任何其他元素 —— 再检查一遍你漏了哪个。", + }, + extraHint: { + en: "Inserting in the middle shifts every element on the right by one slot, so it is O(n) and does not belong here.", + zh: "中间插入必须把右侧元素全部右移一格,是 O(n),不能选它。", + }, + why: { + en: "Any operation that moves no other element is O(1). Any operation that has to make room or close a gap is O(n). That one rule produces the whole complexity table for arrays.", + zh: "凡是不需要搬动其他元素的操作都是 O(1);凡是要腾位置或补空位的都是 O(n)。用这一条就能推出数组的整张复杂度表。", + }, + }, + { + type: "fill", + q: { + en: ( + <> + A matrix with 3 rows and 5 columns is flattened into one array in + row-major order. What is the flat index of matrix[2][3]? + + ), + zh: ( + <> + 一个 3 行 5 列的矩阵按行优先铺平成一维数组,matrix[2][3]{" "} + 对应的一维下标是? + + ), + }, + placeholder: { en: "Type the index…", zh: "输入下标…" }, + answers: ["13"], + hint: { + en: "The formula: row × number of columns + column = 2 × 5 + 3.", + zh: "公式:行号 × 列数 + 列号 = 2 × 5 + 3。", + }, + why: { + en: "2 × 5 + 3 = 13. Two dimensions are a way of reading one dimension, using the same multiply-and-add formula.", + zh: "2 × 5 + 3 = 13。二维只是一维的一种读法,用的还是那条乘加公式。", + }, + }, + { + type: "choice", + q: { + en: "In a hand-written dynamic array, if the resize added only one slot instead of doubling the capacity, what would n appends cost in total?", + zh: "手写动态数组时,如果把「容量翻倍」改成「每次只 +1 个格子」,连续追加 n 次的总代价会变成?", + }, + opts: [ + { + en: "O(n²), because every append copies everything: 1+2+…+n", + zh: "O(n²) —— 每次追加都触发一次全量拷贝,1+2+…+n", + }, + { + en: "Still O(1) amortized, the growth strategy does not matter", + zh: "还是均摊 O(1),扩容策略不影响总代价", + }, + { en: "O(n log n)", zh: "O(n log n)" }, + { + en: "O(n), with a slightly larger constant", + zh: "O(n),只是常数略大", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "The amortized O(1) result comes from growing by a factor. That makes resizes rarer and rarer. With +1, every single append copies the whole array.", + zh: "均摊 O(1) 正是「按倍数增长」换来的:倍增让扩容越来越稀疏;+1 策略下每次追加都要搬全部元素。", + }, + { + en: "Nothing here halves a range at each step, so no logarithm appears.", + zh: "这里没有任何「每步减半」的结构,log 不会凭空出现。", + }, + { + en: "The total number of copies is 1+2+…+n ≈ n²/2, so the total cost is O(n²), not O(n).", + zh: "总拷贝量是 1+2+…+n ≈ n²/2,所以总代价是 O(n²),不是 O(n)。", + }, + ], + why: { + en: "With a +1 resize, the k-th append copies k−1 old elements, so the total is 1+2+…+n = O(n²). Growing by a factor is what spreads the copying thin enough to give O(1) amortized.", + zh: "+1 扩容时,第 k 次追加要拷贝 k−1 个旧元素,总代价 1+2+…+n = O(n²)。按倍数增长才能把搬动摊薄成均摊 O(1),这是动态数组设计的关键。", + }, + }, + { + type: "choice", + q: { + en: "Binary search runs in O(log n) only when two conditions hold at the same time. Which two?", + zh: "二分查找能做到 O(log n),需要同时满足哪两个前提?", + }, + opts: [ + { + en: "The data is sorted, and it supports O(1) random access (an array, for example)", + zh: "数据有序 + 支持 O(1) 随机访问(比如数组)", + }, + { + en: "Sorted is enough, the storage does not matter", + zh: "数据有序就够了,存在哪都行", + }, + { + en: "An array is enough, sorted or not", + zh: "只要是数组就行,有没有序无所谓", + }, + { + en: "The data must be smaller than one million elements", + zh: "数据量必须小于一百万", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "You cannot binary search a sorted linked list: reaching the middle element already takes O(n) steps. Random access is required.", + zh: "有序链表没法二分:光是走到「中间那个」就要 O(n) 步 —— 随机访问不能缺。", + }, + { + en: "In an unsorted array, after comparing with the middle you still do not know which half to keep. Sorted order is required.", + zh: "无序数组里,和中间元素比完仍然不知道该留哪一半 —— 有序不能缺。", + }, + { + en: "Binary search has no size limit. The larger the input, the bigger its advantage.", + zh: "二分对规模没有上限,规模越大它的优势越明显。", + }, + ], + why: { + en: "Sorted order is what makes 'which half can I discard' answerable. Random access is what makes 'jump to the middle' O(1). Only together do they give log n, and that combination is what an array provides.", + zh: "有序保证「该丢哪一半」可以判断,随机访问保证「跳到中间」是 O(1)。两者合力才有 log n,而这正是数组能提供的组合。", + }, + }, +]; diff --git a/lib/binary-tree-data.tsx b/lib/binary-tree-problems.tsx similarity index 53% rename from lib/binary-tree-data.tsx rename to lib/binary-tree-problems.tsx index 762349e..793b1d9 100644 --- a/lib/binary-tree-data.tsx +++ b/lib/binary-tree-problems.tsx @@ -1,11 +1,11 @@ -// Chapter 7 · Binary trees — problem set and quiz data (English default / Chinese toggle). +// Chapter 7 · Binary trees — problem set (English default / Chinese toggle). +// The closing quiz is in lib/binary-tree-quiz.tsx; /atlas imports only this file. // Problems follow the theme "the answer for a tree = what the root does + the answer for the left // subtree + the answer for the right subtree": bottom-up, top-down, BFS by level, construction and // ancestor problems, closing with LC 124 to show that the return value ≠ the answer. // Every key first states what the recursive function returns, then why the combining step is valid. import type { Problem } from "@/lib/problems"; -import type { QuizItem } from "@/lib/quiz"; export const PROBLEMS: Problem[] = [ { @@ -474,331 +474,3 @@ export const PROBLEMS: Problem[] = [ }, }, ]; - -export const QUIZ: QuizItem[] = [ - { - type: "choice", - q: { - en: "Which statement about depth and height is correct? (Counting edges, the usual convention.)", - zh: "关于「深度」和「高度」,下面哪句是对的?(按主流约定:数边)", - }, - opts: [ - { - en: "Depth is counted downward from the root (the root has depth 0); height is counted upward from the deepest leaf (a leaf has height 0)", - zh: "深度从根往下数(根的深度是 0),高度从最深的叶往上数(叶的高度是 0)—— 方向相反", - }, - { - en: "Depth and height are two names for the same quantity", - zh: "深度和高度是同一个量的两种叫法", - }, - { - en: "Depth is counted from the leaves upward, height from the root downward", - zh: "深度从叶往上数,高度从根往下数", - }, - { - en: "Depth can only be computed with BFS, height only with DFS", - zh: "深度只能用 BFS 算,高度只能用 DFS 算", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "For a single node the two are usually different. Depth measures the distance to the root; height measures the distance to the node's deepest descendant. Only for the whole tree do they meet: the height of the root equals the largest depth in the tree.", - zh: "对单个节点来说两者通常不同:深度看它离根多远,高度看它离自己最深的后代多远。只有对整棵树,根的高度才恰好等于树里最大的深度。", - }, - { - en: "The directions are swapped. Depth is measured from the root, so it grows as you go down. Height is measured from the deepest descendant, so it grows as you go up.", - zh: "方向说反了:深度的参照物是根,越往下越深;高度的参照物是最深的后代,越往上越高。", - }, - { - en: "Neither is tied to a traversal. Depth fits a top-down recursion that passes the current depth as a parameter, and height fits a bottom-up recursion that returns a value, but BFS and DFS can compute both.", - zh: "两者都和遍历方式无关:深度适合自顶向下用参数传,高度适合自底向上用返回值算,但 BFS 和 DFS 都求得出来。", - }, - ], - why: { - en: "The depth of a node is the number of edges from the root down to it, so the root has depth 0. The height of a node is the number of edges from it down to its deepest descendant, so a leaf has height 0. The height of the tree is the height of the root. These two directions match the two recursive styles in section 07: depth travels down in a parameter, height travels up in a return value.", - zh: "节点的深度 = 从根到它的边数,所以根的深度是 0;节点的高度 = 从它到最深后代的边数,所以叶子的高度是 0;整棵树的高度 = 根的高度。这两个方向正好对应 §07 的两种递归做法:深度靠参数往下传,高度靠返回值往上传。", - }, - }, - { - type: "choice", - q: { - en: "Which is the correct definition of a complete binary tree (完全二叉树)?", - zh: "「完全二叉树(complete binary tree)」的正确判定标准是?", - }, - opts: [ - { - en: "Every level is completely filled except possibly the last, and the last level is filled from left to right with no gaps", - zh: "除最后一层外每层都填满,且最后一层的节点从左到右连续排列、中间没有空缺", - }, - { - en: "Every node has either two children or no children", - zh: "每个节点要么有两个孩子,要么一个孩子都没有", - }, - { - en: "All leaves are at the same depth and every level is completely filled", - zh: "所有叶子都在同一深度,且每一层都填满", - }, - { - en: "The heights of the left and right subtrees differ by at most 1, at every node", - zh: "每个节点的左右子树高度差都不超过 1", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "That defines a full binary tree (Chinese: 真二叉树). A full tree may have gaps in the middle of a level; a complete tree may not.", - zh: "那是「真二叉树 / full binary tree」的定义。真二叉树允许某一层中间出现空缺,完全二叉树不允许。", - }, - { - en: "That defines a perfect binary tree (Chinese: 满二叉树). Every perfect tree is complete, but not the other way round.", - zh: "那是「满二叉树 / perfect binary tree」的定义。满二叉树一定是完全二叉树,反过来不成立。", - }, - { - en: "That is the balanced condition from LC 110. Being balanced says nothing about the nodes being packed to the left.", - zh: "那是「平衡二叉树」的条件(LC 110)。平衡只管高度差,不管节点有没有挤在左边。", - }, - ], - why: { - en: "Filling every level except the last, and filling the last from left to right, means the level-order positions form one unbroken run. That is exactly what array storage needs: put the node with level-order position i at index i, and its children land at 2i+1 and 2i+2 with no unused slots. This is why the heap in chapter 09 is built on a complete binary tree.", - zh: "「除最后一层外全满,且最后一层靠左连续」意味着按层序编号是一段没有空洞的连续区间。这正是数组存储需要的:层序第 i 个节点放在下标 i,它的孩子恰好落在 2i+1 和 2i+2,没有一个格子被浪费。这就是第 9 章的堆用完全二叉树当骨架的原因。", - }, - }, - { - type: "fill", - q: { - en: ( - <> - A small tree: root 4, left child 2 (whose children are 1 and 3), right - child 6. What does its inorder traversal print? (Separate the - numbers with spaces or commas.) - - ), - zh: ( - <> - 一棵小树:根 4,左孩子 2(它的左右孩子是 1、3),右孩子 6。 - 它的中序遍历输出是?(数字用空格或逗号分隔) - - ), - }, - placeholder: { en: "for example: 1 2 3 …", zh: "如:1 2 3 …" }, - answers: ["12346", "1 2 3 4 6", "1,2,3,4,6", "1、2、3、4、6"], - hint: { - en: "Inorder is left, then root, then right, and the same rule applies inside every subtree: the whole left subtree of 4 is finished before 4 is printed.", - zh: "中序 = 左 → 根 → 右,并且对每棵子树都成立:先把 4 的整棵左子树走完,才轮到 4。", - }, - why: { - en: "Inorder finishes the left subtree of 4 first (1, then 2, then 3), then prints the root 4, then the right subtree 6, giving 1 2 3 4 6. The result is sorted, and that is not a coincidence: this tree is a binary search tree, and 'inorder on a BST gives sorted order' is where the next chapter starts.", - zh: "中序先走完 4 的左子树(1 → 2 → 3),再输出根 4,最后是右子树 6,得到 1 2 3 4 6。结果正好从小到大,这不是巧合:这棵树是二叉搜索树,而「BST 的中序 = 升序」正是下一章的开场白。", - }, - }, - { - type: "choice", - q: { - en: "A recursive function has no base case. What happens when it runs?", - zh: "递归函数忘了写终止条件(base case),运行时会发生什么?", - }, - opts: [ - { - en: "It calls itself forever, the call stack keeps growing, and the program fails with a stack overflow (StackOverflowError / RecursionError)", - zh: "函数无限自我调用,调用栈不断堆高,最终栈溢出(StackOverflowError / RecursionError)", - }, - { - en: "The compiler reports an error, so the program never runs", - zh: "编译器会报错,程序根本无法运行", - }, - { - en: "The function returns undefined or null and the program continues", - zh: "函数直接返回 undefined / null,程序继续跑", - }, - { - en: "The CPU detects the infinite loop and breaks out of it", - zh: "CPU 会自动检测死循环并跳出", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "A compiler does not check whether a recursion terminates. In general that cannot be decided automatically; it is the halting problem. The compiler only checks the syntax and the types.", - zh: "编译器不检查递归会不会停 —— 这在一般情况下无法自动判定(停机问题),它只管语法和类型。", - }, - { - en: "A branch with no return can indeed produce an empty value, but only if execution reaches it. A recursion with no base case never gets that far; the stack runs out first.", - zh: "没有 return 的分支确实可能返回空值,但前提是执行流走得到那里。没有终止条件的递归根本轮不到返回,栈先耗尽了。", - }, - { - en: "The CPU has no idea what an infinite loop is. It faithfully performs each call and pushes each stack frame until the stack space given by the operating system runs out.", - zh: "CPU 不懂什么叫死循环,它只是忠实地执行每一次调用、压入每一个栈帧,直到操作系统给的栈空间用完。", - }, - ], - why: { - en: "Each call pushes a new frame on the call stack, holding the parameters, the local variables, and the return address. No base case means the pushing never stops, and the stack space (usually a few megabytes) is exhausted quickly: Java and JavaScript raise a stack overflow error, Python raises RecursionError at its recursion limit, which defaults to about 1000. So the first question when writing a recursion on a tree is always: what do I do with an empty node?", - zh: "每次调用都要在调用栈上压一个新栈帧(参数、局部变量、返回地址)。没有终止条件就是无限压栈,栈空间(通常几 MB)很快耗尽:Java 和 JavaScript 抛栈溢出错误,Python 在递归深度上限(默认约 1000)处抛 RecursionError。所以写树上的递归,第一件事永远是问:空节点怎么办?", - }, - }, - { - type: "choice", - q: { - en: "Level-order traversal (BFS) needs which helper data structure, and why?", - zh: "层序遍历(BFS)必须借助哪种辅助数据结构?为什么?", - }, - opts: [ - { - en: "A queue. The node discovered first is processed first, and first in, first out is exactly what keeps one level ahead of the next", - zh: "队列 —— 先发现的节点先处理,先进先出恰好保证「一层处理完才轮到下一层」", - }, - { - en: "A stack. Last in, first out is what walks the tree level by level", - zh: "栈 —— 后进先出才能一层一层地走", - }, - { - en: "A hash map, to record which level each node belongs to", - zh: "哈希表 —— 记录每个节点在第几层", - }, - { - en: "Nothing. Plain recursion is enough", - zh: "不需要任何辅助结构,递归就行", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "A stack is last in, first out, so a child that was just discovered is processed immediately and the walk dives to the bottom. That is DFS. Replacing the queue with a stack changes the algorithm.", - zh: "栈是后进先出:刚发现的孩子会被立刻处理,一路扎到底 —— 那是 DFS。把 BFS 的队列换成栈,算法就变了性质。", - }, - { - en: "A hash map can record level numbers, but it cannot decide the processing order, and the order is what a traversal is. A queue is an ordering device by construction.", - zh: "哈希表能记层号,却给不出「处理顺序」—— 顺序才是遍历的本体,而队列天生就是顺序机器。", - }, - { - en: "Recursion runs on the call stack, which is depth-first by nature. You can simulate level order with recursion by passing the depth down, but you are working against the stack instead of with it.", - zh: "递归的底层是调用栈,天然深度优先。用递归带着深度参数也能模拟层序,但那是在对抗栈的本能,反而绕远。", - }, - ], - why: { - en: "The invariant of BFS: at any moment the queue holds nodes from at most two neighboring levels, and nodes of the same level are queued left to right. Dequeue one node, enqueue its children at the back, and first in, first out guarantees a whole level leaves before the next one starts. To split the output into levels, record the queue size before the inner loop and dequeue exactly that many nodes. This is the queue from chapter 05 doing real work.", - zh: "BFS 的不变量:任一时刻队列里的节点最多横跨相邻两层,且同层节点按从左到右排队。出队一个、孩子入队尾 —— 先进先出保证整层出完才轮到下一层。想把输出切成一层一层,就在内层循环前先记下队列长度,本轮只出队这么多个。这正是第 5 章的队列在干实事。", - }, - }, - { - type: "choice", - q: { - en: "For a binary tree with n nodes, what is the possible range of the height h? (Counting edges.)", - zh: "n 个节点的二叉树,高度 h 的可能范围是?(数边)", - }, - opts: [ - { - en: "As low as ⌊log₂ n⌋ when every level is packed, as high as n−1 when the tree degenerates into a chain", - zh: "最矮 ⌊log₂n⌋(每层塞满),最高 n−1(退化成一条链)", - }, - { en: "Always exactly log₂ n", zh: "恒等于 log₂n" }, - { en: "Always exactly n−1", zh: "恒等于 n−1" }, - { en: "As low as 1, as high as log₂ n", zh: "最矮 1,最高 log₂n" }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "⌊log₂ n⌋ is only the lower bound, reached when the tree is packed as tightly as possible. Nothing forces a binary tree to be packed; an unlucky insertion order makes it lean.", - zh: "⌊log₂n⌋ 只是「塞得最紧」时的下界。没有任何规则强迫二叉树塞满,插入顺序不巧就会歪。", - }, - { - en: "n−1 is the upper bound, reached when every node has one child and the tree is really a linked list. That is the worst case, not the rule.", - zh: "n−1 是上界:每个节点只有一个孩子,树退化成链表。那是最坏情况,不是必然。", - }, - { - en: "The two ends are swapped. Logarithmic is the best case (shortest) and linear is the worst case (tallest).", - zh: "两头反了:log 级别是最好情况(最矮),n 级别是最坏情况(最高)。", - }, - ], - why: { - en: "With the same n nodes the shape can differ enormously: packed, the height is ⌊log₂ n⌋; degenerate, it is n−1. Tree algorithms are usually described as O(h) rather than O(log n), because h is only logarithmic when the tree is balanced. Forcing that balance is the whole motivation for the next chapter.", - zh: "同样 n 个节点,形状可以天差地别:塞满时高度是 ⌊log₂n⌋,退化成链时是 n−1。树上算法的复杂度通常写成 O(h) 而不是 O(log n),因为只有树平衡时 h 才是对数级。如何强制这个平衡,正是下一章的全部动机。", - }, - }, - { - type: "choice", - q: { - en: "What is the real difference between the top-down and bottom-up recursive styles?", - zh: "「自顶向下」和「自底向上」两种递归做法的本质区别是?", - }, - opts: [ - { - en: "Top-down carries information down in the parameters and works at the preorder position; bottom-up carries the subtree answers up in the return value and works at the postorder position", - zh: "自顶向下把信息用参数带下去、在前序位置干活;自底向上靠返回值把子树答案传上来、在后序位置干活", - }, - { en: "Top-down uses BFS, bottom-up uses DFS", zh: "自顶向下用 BFS,自底向上用 DFS" }, - { - en: "Top-down is faster, bottom-up uses less memory", - zh: "自顶向下更快,自底向上更省内存", - }, - { - en: "Only the code style differs; either one is equally easy for any problem", - zh: "只是代码风格不同,任何题两种写法难度都一样", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Both are DFS recursions. The difference is the direction the information travels (parameters down versus return values up), not the traversal algorithm.", - zh: "两者都是 DFS 递归。区别在信息的流向(参数向下 vs 返回值向上),不在遍历算法。", - }, - { - en: "Their asymptotic costs are usually the same, since each node is visited once either way. You choose based on where the information comes from, not on performance.", - zh: "两者的渐近复杂度通常相同(每个节点都只访问一次)。选哪个看信息来源,不看性能。", - }, - { - en: "Choosing the wrong direction makes the code much more awkward. Depth depends on the ancestors, so passing it down is natural; height and diameter depend on the descendants, so the children must answer first.", - zh: "方向选错会明显别扭:深度依赖祖先,顺着往下传最自然;高度、直径依赖子孙,必须等孩子先答完。题目本身有偏好。", - }, - ], - why: { - en: "The test: if the answer depends on what lies between the root and the current node (its depth, the sum along the path), pass that state down in a parameter. If the answer depends on the current node's subtree (its height, its node count, its diameter), collect the children's return values and combine them. LC 112 is the model for the first, LC 543 and LC 124 for the second.", - zh: "判断方法:答案依赖「从根到我这一路的信息」(深度、路径和)→ 自顶向下传参;答案依赖「我的子树的信息」(高度、节点数、直径)→ 自底向上收返回值。LC 112 是前者的样板,LC 543、124 是后者的样板。", - }, - }, - { - type: "choice", - q: { - en: "Given only two traversal sequences, which combination determines a binary tree uniquely?", - zh: "只给两种遍历序列,哪种组合能唯一确定(恢复)一棵二叉树?", - }, - opts: [ - { - en: "Preorder + inorder works, and postorder + inorder works; preorder + postorder does not", - zh: "前序 + 中序(或后序 + 中序)可以;前序 + 后序不行", - }, - { en: "Any two of them work", zh: "任何两种组合都可以" }, - { - en: "No two of them work; you always need three", - zh: "任何两种组合都不行,至少要三种", - }, - { en: "Only level-order + preorder works", zh: "只有层序 + 前序可以" }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Preorder + postorder does not work. When a node has only one child, both sequences look the same whether that child is the left one or the right one.", - zh: "前序 + 后序不行:节点只有一个孩子时,不论那孩子是左是右,两种序列都长一个样。", - }, - { - en: "Two are enough, as long as one of them is the inorder. Only the inorder splits the remaining nodes into a left part and a right part around the root.", - zh: "两种就够,关键是其中必须有中序:只有它能以根为界,把剩下的节点切成左右两半。", - }, - { - en: "Level-order + preorder has the same gap: neither one splits left from right. The classic working combinations are preorder or postorder (which identify the root) together with inorder (which splits the sides). Level-order + inorder also works, for the same reason.", - zh: "层序 + 前序同样缺「切分左右」的能力。经典可行组合是前序或后序(定根)+ 中序(分左右);层序 + 中序同理也可以。", - }, - ], - why: { - en: "Rebuilding a tree needs two things at every step: who is the root, and which nodes go to each side. The preorder gives the root as its first value and the postorder as its last, but only the inorder answers the second question, because everything left of the root in the inorder belongs to the left subtree. Preorder + postorder answers the first question twice and the second never: for a root 1 with a single child 2, the preorder is [1,2] and the postorder is [2,1] whether 2 is the left child or the right child.", - zh: "恢复一棵树每一步需要两件事:谁是根,以及剩下的节点各归哪一侧。前序的第一个值和后序的最后一个值都能定根,但只有中序能回答第二件事 —— 中序里根左边的全是左子树。前序 + 后序把第一件事回答了两遍,第二件事一遍也没答:根 1 只带一个孩子 2 时,不论 2 是左孩子还是右孩子,前序都是 [1,2]、后序都是 [2,1]。", - }, - }, -]; diff --git a/lib/binary-tree-quiz.tsx b/lib/binary-tree-quiz.tsx new file mode 100644 index 0000000..bb5c238 --- /dev/null +++ b/lib/binary-tree-quiz.tsx @@ -0,0 +1,333 @@ +// Chapter 7 · Binary trees — closing quiz (the problem set is in lib/binary-tree-problems.tsx). +// +// Bilingual: every question, option and explanation is a { en, zh } pair. + +import type { QuizItem } from "@/lib/quiz"; + +export const QUIZ: QuizItem[] = [ + { + type: "choice", + q: { + en: "Which statement about depth and height is correct? (Counting edges, the usual convention.)", + zh: "关于「深度」和「高度」,下面哪句是对的?(按主流约定:数边)", + }, + opts: [ + { + en: "Depth is counted downward from the root (the root has depth 0); height is counted upward from the deepest leaf (a leaf has height 0)", + zh: "深度从根往下数(根的深度是 0),高度从最深的叶往上数(叶的高度是 0)—— 方向相反", + }, + { + en: "Depth and height are two names for the same quantity", + zh: "深度和高度是同一个量的两种叫法", + }, + { + en: "Depth is counted from the leaves upward, height from the root downward", + zh: "深度从叶往上数,高度从根往下数", + }, + { + en: "Depth can only be computed with BFS, height only with DFS", + zh: "深度只能用 BFS 算,高度只能用 DFS 算", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "For a single node the two are usually different. Depth measures the distance to the root; height measures the distance to the node's deepest descendant. Only for the whole tree do they meet: the height of the root equals the largest depth in the tree.", + zh: "对单个节点来说两者通常不同:深度看它离根多远,高度看它离自己最深的后代多远。只有对整棵树,根的高度才恰好等于树里最大的深度。", + }, + { + en: "The directions are swapped. Depth is measured from the root, so it grows as you go down. Height is measured from the deepest descendant, so it grows as you go up.", + zh: "方向说反了:深度的参照物是根,越往下越深;高度的参照物是最深的后代,越往上越高。", + }, + { + en: "Neither is tied to a traversal. Depth fits a top-down recursion that passes the current depth as a parameter, and height fits a bottom-up recursion that returns a value, but BFS and DFS can compute both.", + zh: "两者都和遍历方式无关:深度适合自顶向下用参数传,高度适合自底向上用返回值算,但 BFS 和 DFS 都求得出来。", + }, + ], + why: { + en: "The depth of a node is the number of edges from the root down to it, so the root has depth 0. The height of a node is the number of edges from it down to its deepest descendant, so a leaf has height 0. The height of the tree is the height of the root. These two directions match the two recursive styles in section 07: depth travels down in a parameter, height travels up in a return value.", + zh: "节点的深度 = 从根到它的边数,所以根的深度是 0;节点的高度 = 从它到最深后代的边数,所以叶子的高度是 0;整棵树的高度 = 根的高度。这两个方向正好对应 §07 的两种递归做法:深度靠参数往下传,高度靠返回值往上传。", + }, + }, + { + type: "choice", + q: { + en: "Which is the correct definition of a complete binary tree (完全二叉树)?", + zh: "「完全二叉树(complete binary tree)」的正确判定标准是?", + }, + opts: [ + { + en: "Every level is completely filled except possibly the last, and the last level is filled from left to right with no gaps", + zh: "除最后一层外每层都填满,且最后一层的节点从左到右连续排列、中间没有空缺", + }, + { + en: "Every node has either two children or no children", + zh: "每个节点要么有两个孩子,要么一个孩子都没有", + }, + { + en: "All leaves are at the same depth and every level is completely filled", + zh: "所有叶子都在同一深度,且每一层都填满", + }, + { + en: "The heights of the left and right subtrees differ by at most 1, at every node", + zh: "每个节点的左右子树高度差都不超过 1", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "That defines a full binary tree (Chinese: 真二叉树). A full tree may have gaps in the middle of a level; a complete tree may not.", + zh: "那是「真二叉树 / full binary tree」的定义。真二叉树允许某一层中间出现空缺,完全二叉树不允许。", + }, + { + en: "That defines a perfect binary tree (Chinese: 满二叉树). Every perfect tree is complete, but not the other way round.", + zh: "那是「满二叉树 / perfect binary tree」的定义。满二叉树一定是完全二叉树,反过来不成立。", + }, + { + en: "That is the balanced condition from LC 110. Being balanced says nothing about the nodes being packed to the left.", + zh: "那是「平衡二叉树」的条件(LC 110)。平衡只管高度差,不管节点有没有挤在左边。", + }, + ], + why: { + en: "Filling every level except the last, and filling the last from left to right, means the level-order positions form one unbroken run. That is exactly what array storage needs: put the node with level-order position i at index i, and its children land at 2i+1 and 2i+2 with no unused slots. This is why the heap in chapter 09 is built on a complete binary tree.", + zh: "「除最后一层外全满,且最后一层靠左连续」意味着按层序编号是一段没有空洞的连续区间。这正是数组存储需要的:层序第 i 个节点放在下标 i,它的孩子恰好落在 2i+1 和 2i+2,没有一个格子被浪费。这就是第 9 章的堆用完全二叉树当骨架的原因。", + }, + }, + { + type: "fill", + q: { + en: ( + <> + A small tree: root 4, left child 2 (whose children are 1 and 3), right + child 6. What does its inorder traversal print? (Separate the + numbers with spaces or commas.) + + ), + zh: ( + <> + 一棵小树:根 4,左孩子 2(它的左右孩子是 1、3),右孩子 6。 + 它的中序遍历输出是?(数字用空格或逗号分隔) + + ), + }, + placeholder: { en: "for example: 1 2 3 …", zh: "如:1 2 3 …" }, + answers: ["12346", "1 2 3 4 6", "1,2,3,4,6", "1、2、3、4、6"], + hint: { + en: "Inorder is left, then root, then right, and the same rule applies inside every subtree: the whole left subtree of 4 is finished before 4 is printed.", + zh: "中序 = 左 → 根 → 右,并且对每棵子树都成立:先把 4 的整棵左子树走完,才轮到 4。", + }, + why: { + en: "Inorder finishes the left subtree of 4 first (1, then 2, then 3), then prints the root 4, then the right subtree 6, giving 1 2 3 4 6. The result is sorted, and that is not a coincidence: this tree is a binary search tree, and 'inorder on a BST gives sorted order' is where the next chapter starts.", + zh: "中序先走完 4 的左子树(1 → 2 → 3),再输出根 4,最后是右子树 6,得到 1 2 3 4 6。结果正好从小到大,这不是巧合:这棵树是二叉搜索树,而「BST 的中序 = 升序」正是下一章的开场白。", + }, + }, + { + type: "choice", + q: { + en: "A recursive function has no base case. What happens when it runs?", + zh: "递归函数忘了写终止条件(base case),运行时会发生什么?", + }, + opts: [ + { + en: "It calls itself forever, the call stack keeps growing, and the program fails with a stack overflow (StackOverflowError / RecursionError)", + zh: "函数无限自我调用,调用栈不断堆高,最终栈溢出(StackOverflowError / RecursionError)", + }, + { + en: "The compiler reports an error, so the program never runs", + zh: "编译器会报错,程序根本无法运行", + }, + { + en: "The function returns undefined or null and the program continues", + zh: "函数直接返回 undefined / null,程序继续跑", + }, + { + en: "The CPU detects the infinite loop and breaks out of it", + zh: "CPU 会自动检测死循环并跳出", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "A compiler does not check whether a recursion terminates. In general that cannot be decided automatically; it is the halting problem. The compiler only checks the syntax and the types.", + zh: "编译器不检查递归会不会停 —— 这在一般情况下无法自动判定(停机问题),它只管语法和类型。", + }, + { + en: "A branch with no return can indeed produce an empty value, but only if execution reaches it. A recursion with no base case never gets that far; the stack runs out first.", + zh: "没有 return 的分支确实可能返回空值,但前提是执行流走得到那里。没有终止条件的递归根本轮不到返回,栈先耗尽了。", + }, + { + en: "The CPU has no idea what an infinite loop is. It faithfully performs each call and pushes each stack frame until the stack space given by the operating system runs out.", + zh: "CPU 不懂什么叫死循环,它只是忠实地执行每一次调用、压入每一个栈帧,直到操作系统给的栈空间用完。", + }, + ], + why: { + en: "Each call pushes a new frame on the call stack, holding the parameters, the local variables, and the return address. No base case means the pushing never stops, and the stack space (usually a few megabytes) is exhausted quickly: Java and JavaScript raise a stack overflow error, Python raises RecursionError at its recursion limit, which defaults to about 1000. So the first question when writing a recursion on a tree is always: what do I do with an empty node?", + zh: "每次调用都要在调用栈上压一个新栈帧(参数、局部变量、返回地址)。没有终止条件就是无限压栈,栈空间(通常几 MB)很快耗尽:Java 和 JavaScript 抛栈溢出错误,Python 在递归深度上限(默认约 1000)处抛 RecursionError。所以写树上的递归,第一件事永远是问:空节点怎么办?", + }, + }, + { + type: "choice", + q: { + en: "Level-order traversal (BFS) needs which helper data structure, and why?", + zh: "层序遍历(BFS)必须借助哪种辅助数据结构?为什么?", + }, + opts: [ + { + en: "A queue. The node discovered first is processed first, and first in, first out is exactly what keeps one level ahead of the next", + zh: "队列 —— 先发现的节点先处理,先进先出恰好保证「一层处理完才轮到下一层」", + }, + { + en: "A stack. Last in, first out is what walks the tree level by level", + zh: "栈 —— 后进先出才能一层一层地走", + }, + { + en: "A hash map, to record which level each node belongs to", + zh: "哈希表 —— 记录每个节点在第几层", + }, + { + en: "Nothing. Plain recursion is enough", + zh: "不需要任何辅助结构,递归就行", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "A stack is last in, first out, so a child that was just discovered is processed immediately and the walk dives to the bottom. That is DFS. Replacing the queue with a stack changes the algorithm.", + zh: "栈是后进先出:刚发现的孩子会被立刻处理,一路扎到底 —— 那是 DFS。把 BFS 的队列换成栈,算法就变了性质。", + }, + { + en: "A hash map can record level numbers, but it cannot decide the processing order, and the order is what a traversal is. A queue is an ordering device by construction.", + zh: "哈希表能记层号,却给不出「处理顺序」—— 顺序才是遍历的本体,而队列天生就是顺序机器。", + }, + { + en: "Recursion runs on the call stack, which is depth-first by nature. You can simulate level order with recursion by passing the depth down, but you are working against the stack instead of with it.", + zh: "递归的底层是调用栈,天然深度优先。用递归带着深度参数也能模拟层序,但那是在对抗栈的本能,反而绕远。", + }, + ], + why: { + en: "The invariant of BFS: at any moment the queue holds nodes from at most two neighboring levels, and nodes of the same level are queued left to right. Dequeue one node, enqueue its children at the back, and first in, first out guarantees a whole level leaves before the next one starts. To split the output into levels, record the queue size before the inner loop and dequeue exactly that many nodes. This is the queue from chapter 05 doing real work.", + zh: "BFS 的不变量:任一时刻队列里的节点最多横跨相邻两层,且同层节点按从左到右排队。出队一个、孩子入队尾 —— 先进先出保证整层出完才轮到下一层。想把输出切成一层一层,就在内层循环前先记下队列长度,本轮只出队这么多个。这正是第 5 章的队列在干实事。", + }, + }, + { + type: "choice", + q: { + en: "For a binary tree with n nodes, what is the possible range of the height h? (Counting edges.)", + zh: "n 个节点的二叉树,高度 h 的可能范围是?(数边)", + }, + opts: [ + { + en: "As low as ⌊log₂ n⌋ when every level is packed, as high as n−1 when the tree degenerates into a chain", + zh: "最矮 ⌊log₂n⌋(每层塞满),最高 n−1(退化成一条链)", + }, + { en: "Always exactly log₂ n", zh: "恒等于 log₂n" }, + { en: "Always exactly n−1", zh: "恒等于 n−1" }, + { en: "As low as 1, as high as log₂ n", zh: "最矮 1,最高 log₂n" }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "⌊log₂ n⌋ is only the lower bound, reached when the tree is packed as tightly as possible. Nothing forces a binary tree to be packed; an unlucky insertion order makes it lean.", + zh: "⌊log₂n⌋ 只是「塞得最紧」时的下界。没有任何规则强迫二叉树塞满,插入顺序不巧就会歪。", + }, + { + en: "n−1 is the upper bound, reached when every node has one child and the tree is really a linked list. That is the worst case, not the rule.", + zh: "n−1 是上界:每个节点只有一个孩子,树退化成链表。那是最坏情况,不是必然。", + }, + { + en: "The two ends are swapped. Logarithmic is the best case (shortest) and linear is the worst case (tallest).", + zh: "两头反了:log 级别是最好情况(最矮),n 级别是最坏情况(最高)。", + }, + ], + why: { + en: "With the same n nodes the shape can differ enormously: packed, the height is ⌊log₂ n⌋; degenerate, it is n−1. Tree algorithms are usually described as O(h) rather than O(log n), because h is only logarithmic when the tree is balanced. Forcing that balance is the whole motivation for the next chapter.", + zh: "同样 n 个节点,形状可以天差地别:塞满时高度是 ⌊log₂n⌋,退化成链时是 n−1。树上算法的复杂度通常写成 O(h) 而不是 O(log n),因为只有树平衡时 h 才是对数级。如何强制这个平衡,正是下一章的全部动机。", + }, + }, + { + type: "choice", + q: { + en: "What is the real difference between the top-down and bottom-up recursive styles?", + zh: "「自顶向下」和「自底向上」两种递归做法的本质区别是?", + }, + opts: [ + { + en: "Top-down carries information down in the parameters and works at the preorder position; bottom-up carries the subtree answers up in the return value and works at the postorder position", + zh: "自顶向下把信息用参数带下去、在前序位置干活;自底向上靠返回值把子树答案传上来、在后序位置干活", + }, + { en: "Top-down uses BFS, bottom-up uses DFS", zh: "自顶向下用 BFS,自底向上用 DFS" }, + { + en: "Top-down is faster, bottom-up uses less memory", + zh: "自顶向下更快,自底向上更省内存", + }, + { + en: "Only the code style differs; either one is equally easy for any problem", + zh: "只是代码风格不同,任何题两种写法难度都一样", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Both are DFS recursions. The difference is the direction the information travels (parameters down versus return values up), not the traversal algorithm.", + zh: "两者都是 DFS 递归。区别在信息的流向(参数向下 vs 返回值向上),不在遍历算法。", + }, + { + en: "Their asymptotic costs are usually the same, since each node is visited once either way. You choose based on where the information comes from, not on performance.", + zh: "两者的渐近复杂度通常相同(每个节点都只访问一次)。选哪个看信息来源,不看性能。", + }, + { + en: "Choosing the wrong direction makes the code much more awkward. Depth depends on the ancestors, so passing it down is natural; height and diameter depend on the descendants, so the children must answer first.", + zh: "方向选错会明显别扭:深度依赖祖先,顺着往下传最自然;高度、直径依赖子孙,必须等孩子先答完。题目本身有偏好。", + }, + ], + why: { + en: "The test: if the answer depends on what lies between the root and the current node (its depth, the sum along the path), pass that state down in a parameter. If the answer depends on the current node's subtree (its height, its node count, its diameter), collect the children's return values and combine them. LC 112 is the model for the first, LC 543 and LC 124 for the second.", + zh: "判断方法:答案依赖「从根到我这一路的信息」(深度、路径和)→ 自顶向下传参;答案依赖「我的子树的信息」(高度、节点数、直径)→ 自底向上收返回值。LC 112 是前者的样板,LC 543、124 是后者的样板。", + }, + }, + { + type: "choice", + q: { + en: "Given only two traversal sequences, which combination determines a binary tree uniquely?", + zh: "只给两种遍历序列,哪种组合能唯一确定(恢复)一棵二叉树?", + }, + opts: [ + { + en: "Preorder + inorder works, and postorder + inorder works; preorder + postorder does not", + zh: "前序 + 中序(或后序 + 中序)可以;前序 + 后序不行", + }, + { en: "Any two of them work", zh: "任何两种组合都可以" }, + { + en: "No two of them work; you always need three", + zh: "任何两种组合都不行,至少要三种", + }, + { en: "Only level-order + preorder works", zh: "只有层序 + 前序可以" }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Preorder + postorder does not work. When a node has only one child, both sequences look the same whether that child is the left one or the right one.", + zh: "前序 + 后序不行:节点只有一个孩子时,不论那孩子是左是右,两种序列都长一个样。", + }, + { + en: "Two are enough, as long as one of them is the inorder. Only the inorder splits the remaining nodes into a left part and a right part around the root.", + zh: "两种就够,关键是其中必须有中序:只有它能以根为界,把剩下的节点切成左右两半。", + }, + { + en: "Level-order + preorder has the same gap: neither one splits left from right. The classic working combinations are preorder or postorder (which identify the root) together with inorder (which splits the sides). Level-order + inorder also works, for the same reason.", + zh: "层序 + 前序同样缺「切分左右」的能力。经典可行组合是前序或后序(定根)+ 中序(分左右);层序 + 中序同理也可以。", + }, + ], + why: { + en: "Rebuilding a tree needs two things at every step: who is the root, and which nodes go to each side. The preorder gives the root as its first value and the postorder as its last, but only the inorder answers the second question, because everything left of the root in the inorder belongs to the left subtree. Preorder + postorder answers the first question twice and the second never: for a root 1 with a single child 2, the preorder is [1,2] and the postorder is [2,1] whether 2 is the left child or the right child.", + zh: "恢复一棵树每一步需要两件事:谁是根,以及剩下的节点各归哪一侧。前序的第一个值和后序的最后一个值都能定根,但只有中序能回答第二件事 —— 中序里根左边的全是左子树。前序 + 后序把第一件事回答了两遍,第二件事一遍也没答:根 1 只带一个孩子 2 时,不论 2 是左孩子还是右孩子,前序都是 [1,2]、后序都是 [2,1]。", + }, + }, +]; diff --git a/lib/bst-problems.tsx b/lib/bst-problems.tsx new file mode 100644 index 0000000..29df7ad --- /dev/null +++ b/lib/bst-problems.tsx @@ -0,0 +1,333 @@ +// Chapter 8 · Binary search trees — problem set. +// The closing quiz is in lib/bst-quiz.tsx; /atlas imports only this file. +// Problems follow three threads: basic operations (700/701/450), exploiting the sorted order +// (653/530/235/938), and cases where that order is broken or hidden behind an API (99/173). +// hint points a direction only; key explains the optimal solution in one paragraph. +// +// Bilingual: title / tags / hint / key are { en, zh } pairs. +// Problem titles use the official LeetCode English name on en and the official Chinese name on zh. + +import type { Problem } from "@/lib/problems"; + +export const PROBLEMS: Problem[] = [ + { + lc: 700, + title: { + en: "Search in a Binary Search Tree", + zh: "二叉搜索树中的搜索", + }, + d: "easy", + tags: [ + { en: "core operation", zh: "基本操作" }, + { en: "one path down", zh: "一路下坠" }, + ], + hint: { + en: "The move from §02: at every node you ask one question — is the target smaller or larger than this node?", + zh: "§02 的招牌动作:每到一个节点只问一个问题 —— 目标比我小还是比我大?", + }, + key: { + en: ( + <> + Start at the root. Equal means found. Smaller goes left, larger goes + right. Reaching an empty child proves the value is not in the tree at + all. The iterative version is one while loop, O(h) time and O(1) extra + space. This is the bare template, and the first step of every later + problem is this same move. + + ), + zh: ( + <> + 从根出发:相等即命中;小往左、大往右;撞到空孩子就说明整棵树都没有。 + 迭代版一个 while 循环写完,O(h) 时间、O(1) 额外空间。 + 这是 BST 的裸模板题,后面所有题的第一步都藏着这个动作。 + + ), + }, + }, + { + lc: 653, + title: { + en: "Two Sum IV - Input is a BST", + zh: "两数之和 IV · 输入二叉搜索树", + }, + d: "easy", + tags: [ + { en: "in-order", zh: "中序" }, + { en: "hash / two pointers", zh: "哈希 / 双指针" }, + ], + hint: { + en: "This is Two Sum in disguise. What can you turn the BST into first, so the old method applies?", + zh: "它本质是「两数之和」—— 你可以把 BST 先「变成」什么,再套老办法?", + }, + key: { + en: ( + <> + Two routes. First: traverse in any order and use a hash set to look for + k − v. O(n) time and O(n) space, and it does not use the BST property + at all. Second: flatten the tree with in-order traversal to get a + sorted array, then use the two pointers from LC 167. Also O(n), but it + uses the BST's order. A stronger answer for an interview: run two BST + iterators, one from the smallest end and one from the largest, and move + them towards each other. That keeps space at O(h). + + ), + zh: ( + <> + 两条路:① 任意遍历 + 哈希集合查 k − v,O(n) 时间 O(n) 空间, + 连 BST 性质都不需要;② 中序展开成升序数组 + 对撞双指针(即 LC 167 的做法), + 同样 O(n),但用上了 BST 的有序性。面试中更进一步的答法:用两个 BST 迭代器分别从最小、 + 最大两端向中间逼近,空间压到 O(h)。 + + ), + }, + }, + { + lc: 530, + title: { + en: "Minimum Absolute Difference in BST", + zh: "二叉搜索树的最小绝对差", + }, + d: "easy", + tags: [ + { en: "in-order", zh: "中序" }, + { en: "adjacent difference", zh: "相邻差" }, + ], + hint: { + en: "In a sorted sequence the smallest difference is always between two neighbors. How does a BST produce a sorted sequence?", + zh: "升序序列里,最小差值一定出现在相邻两个数之间 —— BST 怎么变出升序序列?", + }, + key: { + en: ( + <> + Traverse in order and keep prev, the previously visited value. At each + step update the answer with cur − prev. Why only neighbors: in a + sorted sequence, the difference between any two values is at least as + large as the difference between some adjacent pair between them. One + in-order pass, O(n) time and O(h) stack space. Rule of thumb: when a + BST problem mentions difference, neighbor, or k-th, think in-order + first. + + ), + zh: ( + <> + 中序遍历时维护 prev(上一个访问的值),每步用 cur − prev 更新答案。 + 为什么只看相邻:有序序列里任意两数之差,都不小于它们之间某对相邻数之差。 + 一次中序 O(n)、栈空间 O(h)。口诀:BST 题看到「差值 / 相邻 / 第 k」先想中序。 + + ), + }, + }, + { + lc: 938, + title: { en: "Range Sum of BST", zh: "二叉搜索树的范围和" }, + d: "easy", + tags: [ + { en: "pruning", zh: "剪枝" }, + { en: "DFS", zh: "DFS" }, + ], + hint: { + en: "If the current value is already smaller than low, is there any reason to look at its left subtree?", + zh: "当前节点值已经小于 low 时,它的整个左子树还有必要看吗?", + }, + key: { + en: ( + <> + DFS with pruning based on the ordering. If val < low, every value in + the left subtree is smaller still, so only the right subtree is worth + visiting. If val > high, only the left subtree is. If val is inside + the range, add it and recurse both ways. The worst case is O(n), but + the pruning skips whole subtrees at once. This problem is the clearest + demonstration that the ordering of a BST is also a way to avoid work. + + ), + zh: ( + <> + DFS + 有序性剪枝:val < low → 左子树全体更小,只递归右子树; + val > high → 只递归左子树;落在区间内 → 累加自身 + 两边递归。 + 最坏 O(n),但剪枝能把大量子树整棵跳过 —— + 这题最能体会「BST 的有序性同时也是一种省事的手段」。 + + ), + }, + }, + { + lc: 701, + title: { + en: "Insert into a Binary Search Tree", + zh: "二叉搜索树中的插入操作", + }, + d: "medium", + tags: [ + { en: "core operation", zh: "基本操作" }, + { en: "recursion", zh: "递归" }, + ], + hint: { + en: "Follow the search path until it reaches an empty slot. That slot is the only place the new node can go, and no existing node has to move.", + zh: "沿查找路线走到空位,那里就是新节点唯一的家 —— 不需要挪动任何现有节点。", + }, + key: { + en: ( + <> + Recursion: if node is empty, return new TreeNode(v). A smaller value + goes left, a larger one goes right, and the function returns node + itself so the parent can reattach it. O(h). The point to remember:{" "} + an insert always happens at an empty child slot, and no part of + the existing structure moves. That is why a BST inserts more cheaply + than a sorted array. + + ), + zh: ( + <> + 递归:node 为空就返回 new TreeNode(v);v 小挂左、大挂右, + 最后返回 node 本身,让父节点重新牵手。O(h)。关键认知: + 插入永远发生在一个空的孩子位置,树的中间结构一个都不动 —— + 这正是 BST 比有序数组插入便宜的原因。 + + ), + }, + }, + { + lc: 235, + title: { + en: "Lowest Common Ancestor of a Binary Search Tree", + zh: "二叉搜索树的最近公共祖先", + }, + d: "medium", + tags: [ + { en: "ordering", zh: "有序性" }, + { en: "the split point", zh: "分流点" }, + ], + hint: { + en: "Walk down from the root. p and q keep going the same way — until one node sends them in different directions.", + zh: "从根往下走,p 和 q 一直同路 —— 直到某个节点把它们分向两边。", + }, + key: { + en: ( + <> + From the root: if p and q are both smaller than the current node, go + left; if both are larger, go right; if one is on each side (or one + equals the current node), the current node is the answer. One loop, + O(h). Compare this with LC 236 on a plain binary tree: without the + ordering you need a post-order traversal that passes information back + up, O(n). The ordering turns a search of the whole tree into a single + walk down one path. Answering both together shows you understand why. + + ), + zh: ( + <> + 从根出发:p、q 都小于当前节点 → 往左;都大 → 往右;一大一小 + (或其中一个就等于当前节点)→ 当前节点就是答案。一个循环,O(h)。 + 对照普通二叉树的 LC 236:没有有序性就得后序遍历自底向上回传信息,O(n)。 + 有序性把「全树搜索」降成了「单路下降」,两题放一起答最能体现理解深度。 + + ), + }, + }, + { + lc: 450, + title: { en: "Delete Node in a BST", zh: "删除二叉搜索树中的节点" }, + d: "medium", + tags: [ + { en: "three delete cases", zh: "删除三情况" }, + { en: "in-order successor", zh: "中序后继" }, + ], + hint: { + en: "Review the three cases in §03. With two children, which node can take the place of the deleted one without breaking the ordering?", + zh: "先复习 §03 三种情况 —— 双孩子时,谁能顶替被删节点而不破坏「左小右大」?", + }, + key: { + en: ( + <> + Locate the node by recursion, then split into three cases. No child: + return null. One child: return that child, so the parent links to it + directly. Two children: copy the value of the in-order successor (the + smallest value in the right subtree) into this node, then delete the + successor from the right subtree. The successor has no left child, so + that second deletion always falls into one of the two easy cases and + the recursion terminates. O(h). §04 has the full implementation in + three languages, and you should be able to write this one from memory. + + ), + zh: ( + <> + 递归定位后分三种情况:没有孩子 → 返回 null;只有一个孩子 → + 返回那个孩子,让父节点直接接管;两个孩子 → + 把中序后继(右子树最小值)的值抄到当前节点,再去右子树里删掉后继本体 + —— 后继必无左孩子,这次删除一定落入前两种简单情况,递归自然终止。 + O(h)。§04 有完整的三语言实现,这题必须能盲写。 + + ), + }, + }, + { + lc: 173, + title: { en: "Binary Search Tree Iterator", zh: "二叉搜索树迭代器" }, + d: "medium", + tags: [ + { en: "in-order, on demand", zh: "受控中序" }, + { en: "explicit stack", zh: "显式栈" }, + ], + hint: { + en: "Recursive in-order runs to the end in one go. An iterator must stop after each value, so you have to manage the stack yourself.", + zh: "递归中序是「一口气跑完」,迭代器要求「随叫随到」—— 把递归栈自己管起来。", + }, + key: { + en: ( + <> + Keep an explicit stack. On construction, push the left spine of the + root, that is, the root and every node reached by going left. next() + pops the top and returns it; if that node has a right child, push the + left spine of the right child. Every node is pushed once and popped + once, so next() is O(1) amortized and space is O(h). This is the + standard way to turn a recursion into something you can pause, and the + two-iterator solution for LC 653 is built on it. + + ), + zh: ( + <> + 维护显式栈:初始化时把根的「左脊」(根以及一路向左的所有节点)入栈; + next() 弹出栈顶返回,若它有右孩子,就把右孩子的左脊也入栈。 + 每个节点恰好入栈、出栈各一次,next 均摊 O(1)、空间 O(h)。 + 这是「把递归改造成可暂停迭代」的经典范式,653 的双迭代器解法就建立在它之上。 + + ), + }, + }, + { + lc: 99, + title: { en: "Recover Binary Search Tree", zh: "恢复二叉搜索树" }, + d: "medium", + tags: [ + { en: "in-order", zh: "中序" }, + { en: "inversions", zh: "逆序对" }, + ], + hint: { + en: "Write out the in-order sequence. When exactly two nodes have been swapped, how many places show a value larger than the one after it?", + zh: "把中序序列写出来:恰好两个节点被交换后,序列里会出现几处「前 > 后」?", + }, + key: { + en: ( + <> + The in-order sequence of a BST must be strictly increasing. Swapping + two nodes creates one inversion (if they were adjacent) or two (if they + were not). Scan in order: take the earlier value of the first inversion + as first, and the later value of the last inversion as second, then + swap the two values. O(n) time and O(h) stack space. If the interviewer + asks for O(1) space, the answer is Morris in-order traversal, which + borrows the empty right pointer of each node's in-order predecessor as + a temporary link back up. It is an advanced topic this course does not + cover. + + ), + zh: ( + <> + BST 的中序应严格升序;两个节点被交换会制造一处(原本相邻) + 或两处(原本不相邻)逆序。中序扫描:第一处逆序取前者为 first, + 最后一处逆序取后者为 second,交换两者的值即可。O(n) 时间、O(h) 栈空间。 + 追问 O(1) 空间就答 Morris 中序 —— + 借用每个节点的中序前驱空着的右指针当临时的「回程线索」,属于进阶话题,本课不展开。 + + ), + }, + }, +]; diff --git a/lib/bst-data.tsx b/lib/bst-quiz.tsx similarity index 51% rename from lib/bst-data.tsx rename to lib/bst-quiz.tsx index 0cec07b..319d15a 100644 --- a/lib/bst-data.tsx +++ b/lib/bst-quiz.tsx @@ -1,337 +1,9 @@ -// Chapter 8 · Binary search trees — problem set and quiz data. -// Problems follow three threads: basic operations (700/701/450), exploiting the sorted order -// (653/530/235/938), and cases where that order is broken or hidden behind an API (99/173). -// hint points a direction only; key explains the optimal solution in one paragraph. +// Chapter 8 · Binary search trees — closing quiz (the problem set is in lib/bst-problems.tsx). // -// Bilingual: title / tags / hint / key and every quiz string are { en, zh } pairs. -// Problem titles use the official LeetCode English name on en and the official Chinese name on zh. +// Bilingual: every question, option and explanation is a { en, zh } pair. -import type { Problem } from "@/lib/problems"; import type { QuizItem } from "@/lib/quiz"; -export const PROBLEMS: Problem[] = [ - { - lc: 700, - title: { - en: "Search in a Binary Search Tree", - zh: "二叉搜索树中的搜索", - }, - d: "easy", - tags: [ - { en: "core operation", zh: "基本操作" }, - { en: "one path down", zh: "一路下坠" }, - ], - hint: { - en: "The move from §02: at every node you ask one question — is the target smaller or larger than this node?", - zh: "§02 的招牌动作:每到一个节点只问一个问题 —— 目标比我小还是比我大?", - }, - key: { - en: ( - <> - Start at the root. Equal means found. Smaller goes left, larger goes - right. Reaching an empty child proves the value is not in the tree at - all. The iterative version is one while loop, O(h) time and O(1) extra - space. This is the bare template, and the first step of every later - problem is this same move. - - ), - zh: ( - <> - 从根出发:相等即命中;小往左、大往右;撞到空孩子就说明整棵树都没有。 - 迭代版一个 while 循环写完,O(h) 时间、O(1) 额外空间。 - 这是 BST 的裸模板题,后面所有题的第一步都藏着这个动作。 - - ), - }, - }, - { - lc: 653, - title: { - en: "Two Sum IV - Input is a BST", - zh: "两数之和 IV · 输入二叉搜索树", - }, - d: "easy", - tags: [ - { en: "in-order", zh: "中序" }, - { en: "hash / two pointers", zh: "哈希 / 双指针" }, - ], - hint: { - en: "This is Two Sum in disguise. What can you turn the BST into first, so the old method applies?", - zh: "它本质是「两数之和」—— 你可以把 BST 先「变成」什么,再套老办法?", - }, - key: { - en: ( - <> - Two routes. First: traverse in any order and use a hash set to look for - k − v. O(n) time and O(n) space, and it does not use the BST property - at all. Second: flatten the tree with in-order traversal to get a - sorted array, then use the two pointers from LC 167. Also O(n), but it - uses the BST's order. A stronger answer for an interview: run two BST - iterators, one from the smallest end and one from the largest, and move - them towards each other. That keeps space at O(h). - - ), - zh: ( - <> - 两条路:① 任意遍历 + 哈希集合查 k − v,O(n) 时间 O(n) 空间, - 连 BST 性质都不需要;② 中序展开成升序数组 + 对撞双指针(即 LC 167 的做法), - 同样 O(n),但用上了 BST 的有序性。面试中更进一步的答法:用两个 BST 迭代器分别从最小、 - 最大两端向中间逼近,空间压到 O(h)。 - - ), - }, - }, - { - lc: 530, - title: { - en: "Minimum Absolute Difference in BST", - zh: "二叉搜索树的最小绝对差", - }, - d: "easy", - tags: [ - { en: "in-order", zh: "中序" }, - { en: "adjacent difference", zh: "相邻差" }, - ], - hint: { - en: "In a sorted sequence the smallest difference is always between two neighbors. How does a BST produce a sorted sequence?", - zh: "升序序列里,最小差值一定出现在相邻两个数之间 —— BST 怎么变出升序序列?", - }, - key: { - en: ( - <> - Traverse in order and keep prev, the previously visited value. At each - step update the answer with cur − prev. Why only neighbors: in a - sorted sequence, the difference between any two values is at least as - large as the difference between some adjacent pair between them. One - in-order pass, O(n) time and O(h) stack space. Rule of thumb: when a - BST problem mentions difference, neighbor, or k-th, think in-order - first. - - ), - zh: ( - <> - 中序遍历时维护 prev(上一个访问的值),每步用 cur − prev 更新答案。 - 为什么只看相邻:有序序列里任意两数之差,都不小于它们之间某对相邻数之差。 - 一次中序 O(n)、栈空间 O(h)。口诀:BST 题看到「差值 / 相邻 / 第 k」先想中序。 - - ), - }, - }, - { - lc: 938, - title: { en: "Range Sum of BST", zh: "二叉搜索树的范围和" }, - d: "easy", - tags: [ - { en: "pruning", zh: "剪枝" }, - { en: "DFS", zh: "DFS" }, - ], - hint: { - en: "If the current value is already smaller than low, is there any reason to look at its left subtree?", - zh: "当前节点值已经小于 low 时,它的整个左子树还有必要看吗?", - }, - key: { - en: ( - <> - DFS with pruning based on the ordering. If val < low, every value in - the left subtree is smaller still, so only the right subtree is worth - visiting. If val > high, only the left subtree is. If val is inside - the range, add it and recurse both ways. The worst case is O(n), but - the pruning skips whole subtrees at once. This problem is the clearest - demonstration that the ordering of a BST is also a way to avoid work. - - ), - zh: ( - <> - DFS + 有序性剪枝:val < low → 左子树全体更小,只递归右子树; - val > high → 只递归左子树;落在区间内 → 累加自身 + 两边递归。 - 最坏 O(n),但剪枝能把大量子树整棵跳过 —— - 这题最能体会「BST 的有序性同时也是一种省事的手段」。 - - ), - }, - }, - { - lc: 701, - title: { - en: "Insert into a Binary Search Tree", - zh: "二叉搜索树中的插入操作", - }, - d: "medium", - tags: [ - { en: "core operation", zh: "基本操作" }, - { en: "recursion", zh: "递归" }, - ], - hint: { - en: "Follow the search path until it reaches an empty slot. That slot is the only place the new node can go, and no existing node has to move.", - zh: "沿查找路线走到空位,那里就是新节点唯一的家 —— 不需要挪动任何现有节点。", - }, - key: { - en: ( - <> - Recursion: if node is empty, return new TreeNode(v). A smaller value - goes left, a larger one goes right, and the function returns node - itself so the parent can reattach it. O(h). The point to remember:{" "} - an insert always happens at an empty child slot, and no part of - the existing structure moves. That is why a BST inserts more cheaply - than a sorted array. - - ), - zh: ( - <> - 递归:node 为空就返回 new TreeNode(v);v 小挂左、大挂右, - 最后返回 node 本身,让父节点重新牵手。O(h)。关键认知: - 插入永远发生在一个空的孩子位置,树的中间结构一个都不动 —— - 这正是 BST 比有序数组插入便宜的原因。 - - ), - }, - }, - { - lc: 235, - title: { - en: "Lowest Common Ancestor of a Binary Search Tree", - zh: "二叉搜索树的最近公共祖先", - }, - d: "medium", - tags: [ - { en: "ordering", zh: "有序性" }, - { en: "the split point", zh: "分流点" }, - ], - hint: { - en: "Walk down from the root. p and q keep going the same way — until one node sends them in different directions.", - zh: "从根往下走,p 和 q 一直同路 —— 直到某个节点把它们分向两边。", - }, - key: { - en: ( - <> - From the root: if p and q are both smaller than the current node, go - left; if both are larger, go right; if one is on each side (or one - equals the current node), the current node is the answer. One loop, - O(h). Compare this with LC 236 on a plain binary tree: without the - ordering you need a post-order traversal that passes information back - up, O(n). The ordering turns a search of the whole tree into a single - walk down one path. Answering both together shows you understand why. - - ), - zh: ( - <> - 从根出发:p、q 都小于当前节点 → 往左;都大 → 往右;一大一小 - (或其中一个就等于当前节点)→ 当前节点就是答案。一个循环,O(h)。 - 对照普通二叉树的 LC 236:没有有序性就得后序遍历自底向上回传信息,O(n)。 - 有序性把「全树搜索」降成了「单路下降」,两题放一起答最能体现理解深度。 - - ), - }, - }, - { - lc: 450, - title: { en: "Delete Node in a BST", zh: "删除二叉搜索树中的节点" }, - d: "medium", - tags: [ - { en: "three delete cases", zh: "删除三情况" }, - { en: "in-order successor", zh: "中序后继" }, - ], - hint: { - en: "Review the three cases in §03. With two children, which node can take the place of the deleted one without breaking the ordering?", - zh: "先复习 §03 三种情况 —— 双孩子时,谁能顶替被删节点而不破坏「左小右大」?", - }, - key: { - en: ( - <> - Locate the node by recursion, then split into three cases. No child: - return null. One child: return that child, so the parent links to it - directly. Two children: copy the value of the in-order successor (the - smallest value in the right subtree) into this node, then delete the - successor from the right subtree. The successor has no left child, so - that second deletion always falls into one of the two easy cases and - the recursion terminates. O(h). §04 has the full implementation in - three languages, and you should be able to write this one from memory. - - ), - zh: ( - <> - 递归定位后分三种情况:没有孩子 → 返回 null;只有一个孩子 → - 返回那个孩子,让父节点直接接管;两个孩子 → - 把中序后继(右子树最小值)的值抄到当前节点,再去右子树里删掉后继本体 - —— 后继必无左孩子,这次删除一定落入前两种简单情况,递归自然终止。 - O(h)。§04 有完整的三语言实现,这题必须能盲写。 - - ), - }, - }, - { - lc: 173, - title: { en: "Binary Search Tree Iterator", zh: "二叉搜索树迭代器" }, - d: "medium", - tags: [ - { en: "in-order, on demand", zh: "受控中序" }, - { en: "explicit stack", zh: "显式栈" }, - ], - hint: { - en: "Recursive in-order runs to the end in one go. An iterator must stop after each value, so you have to manage the stack yourself.", - zh: "递归中序是「一口气跑完」,迭代器要求「随叫随到」—— 把递归栈自己管起来。", - }, - key: { - en: ( - <> - Keep an explicit stack. On construction, push the left spine of the - root, that is, the root and every node reached by going left. next() - pops the top and returns it; if that node has a right child, push the - left spine of the right child. Every node is pushed once and popped - once, so next() is O(1) amortized and space is O(h). This is the - standard way to turn a recursion into something you can pause, and the - two-iterator solution for LC 653 is built on it. - - ), - zh: ( - <> - 维护显式栈:初始化时把根的「左脊」(根以及一路向左的所有节点)入栈; - next() 弹出栈顶返回,若它有右孩子,就把右孩子的左脊也入栈。 - 每个节点恰好入栈、出栈各一次,next 均摊 O(1)、空间 O(h)。 - 这是「把递归改造成可暂停迭代」的经典范式,653 的双迭代器解法就建立在它之上。 - - ), - }, - }, - { - lc: 99, - title: { en: "Recover Binary Search Tree", zh: "恢复二叉搜索树" }, - d: "medium", - tags: [ - { en: "in-order", zh: "中序" }, - { en: "inversions", zh: "逆序对" }, - ], - hint: { - en: "Write out the in-order sequence. When exactly two nodes have been swapped, how many places show a value larger than the one after it?", - zh: "把中序序列写出来:恰好两个节点被交换后,序列里会出现几处「前 > 后」?", - }, - key: { - en: ( - <> - The in-order sequence of a BST must be strictly increasing. Swapping - two nodes creates one inversion (if they were adjacent) or two (if they - were not). Scan in order: take the earlier value of the first inversion - as first, and the later value of the last inversion as second, then - swap the two values. O(n) time and O(h) stack space. If the interviewer - asks for O(1) space, the answer is Morris in-order traversal, which - borrows the empty right pointer of each node's in-order predecessor as - a temporary link back up. It is an advanced topic this course does not - cover. - - ), - zh: ( - <> - BST 的中序应严格升序;两个节点被交换会制造一处(原本相邻) - 或两处(原本不相邻)逆序。中序扫描:第一处逆序取前者为 first, - 最后一处逆序取后者为 second,交换两者的值即可。O(n) 时间、O(h) 栈空间。 - 追问 O(1) 空间就答 Morris 中序 —— - 借用每个节点的中序前驱空着的右指针当临时的「回程线索」,属于进阶话题,本课不展开。 - - ), - }, - }, -]; - export const QUIZ: QuizItem[] = [ { type: "choice", diff --git a/lib/graph-problems.tsx b/lib/graph-problems.tsx new file mode 100644 index 0000000..155209f --- /dev/null +++ b/lib/graph-problems.tsx @@ -0,0 +1,452 @@ +// Chapter 12 · Graphs — problem set (English default / Chinese toggle). +// The closing quiz is in lib/graph-quiz.tsx; /atlas imports only this file. +// Problems are high-frequency LeetCode graph questions — grid DFS/BFS, topological sort, shortest +// paths, implicit graphs — ordered from easy to hard. +// hint points a direction without spoilers; key explains the optimal solution in one paragraph. +// Bilingual: title / tags / hint / key are written as { en, zh }. +// Problem titles use the official LeetCode English name; zh keeps the official Chinese name. + +import type { Problem } from "@/lib/problems"; + +export const PROBLEMS: Problem[] = [ + { + lc: 733, + title: { en: "Flood Fill", zh: "图像渲染(Flood Fill)" }, + d: "easy", + tags: [ + { en: "Grid", zh: "网格" }, + { en: "DFS/BFS", zh: "DFS/BFS" }, + { en: "Coloring", zh: "染色" }, + ], + hint: { + en: ( + <> + Start at the given cell and repaint every cell that has the same + original color and is connected to it. This is the simplest form of + the island problems. + + ), + zh: ( + <> + 从起点出发,把「和它同色且连通」的格子全染成新色 —— + 这就是岛屿题的最朴素版本。 + + ), + }, + key: { + en: ( + <> + Record the starting color as old. Run DFS or BFS from the + start cell and recurse only into cells that are inside the grid and + still hold old; repaint each one on entry. The common + mistake: if the new color equals old, the recursion never + terminates, so return early when old == newColor. This is + also where you learn the four-direction grid template{" "} + dirs=[[-1,0],[1,0],[0,-1],[0,1]]. + + ), + zh: ( + <> + 记下起点原色 old,从起点 DFS/BFS + 向四个方向扩散:只递归「颜色 == old 且在界内」的格子,进门就改成新色。 + 常见错误:若新色 == old 会无限递归,要先判 old == newColor{" "} + 直接返回。网格题的四方向模板{" "} + dirs=[[-1,0],[1,0],[0,-1],[0,1]] 从这里练熟。 + + ), + }, + }, + { + lc: 695, + title: { en: "Max Area of Island", zh: "岛屿的最大面积" }, + d: "medium", + tags: [ + { en: "Grid", zh: "网格" }, + { en: "DFS", zh: "DFS" }, + { en: "Counting", zh: "计数" }, + ], + hint: { + en: ( + <> + Same sinking method as LC 200. Instead of adding 1 to an island + counter, add up how many cells this one DFS sank. + + ), + zh: ( + <> + 和 LC200 同一套淹没法,只是把「岛屿计数 +1」换成「累加这次 DFS + 淹了几块地」。 + + ), + }, + key: { + en: ( + <> + Start a DFS from every unvisited land cell. The DFS returns the number + of cells it sank: 1 plus the sum of the four recursive calls. Out of + bounds or water returns 0. The main loop keeps the maximum returned + value. Time is O(rows × cols), because each cell is visited once. + Letting DFS return a count is a common extra step in grid problems. + + ), + zh: ( + <> + 对每个未访问的陆地启动 DFS,DFS 返回它淹没的格子数(1 + + 四个方向递归之和),越界或遇水返回 0。主循环用 max 记录最大返回值。 + 时间 O(行 × 列),每格访问一次。 + 「让 DFS 带返回值统计规模」是网格题常见的扩展。 + + ), + }, + }, + { + lc: 130, + title: { en: "Surrounded Regions", zh: "被围绕的区域" }, + d: "medium", + tags: [ + { en: "Grid", zh: "网格" }, + { en: "Reverse thinking", zh: "反向思考" }, + { en: "Border DFS", zh: "边界 DFS" }, + ], + hint: { + en: ( + <> + Finding the surrounded O regions directly is awkward. Turn + it around: any region that touches the border can never be surrounded. + + ), + zh: ( + <> + 直接找「被包围的 O」很难判边界;反过来 —— + 谁碰到了边界,谁就一定不会被包围。 + + ), + }, + key: { + en: ( + <> + Solve the opposite problem. Run DFS or BFS from{" "} + + every O on the four borders + {" "} + and mark all connected O cells as safe (for example with a + temporary #). After that pass, every cell still holding{" "} + O must be surrounded, so change it to X; turn + each # back into O. Coloring from the border + inward avoids testing each interior region separately for whether it + touches an edge. + + ), + zh: ( + <> + 正难则反:先从 + + 四条边上的每个 O + {" "} + 出发 DFS/BFS,把连通的 O 全标成安全(比如临时记为{" "} + #)。扫完后,剩下还是 O 的必然被包围 → 改成{" "} + X;标过 # 的还原成 O。 + 「从边界倒着染色」避开了对每个内部区域单独判断是否触边。 + + ), + }, + }, + { + lc: 994, + title: { en: "Rotting Oranges", zh: "腐烂的橘子" }, + d: "medium", + tags: [ + { en: "Multi-source BFS", zh: "多源 BFS" }, + { en: "Grid", zh: "网格" }, + { en: "Level by level", zh: "按层" }, + ], + hint: { + en: ( + <> + Every rotten orange starts spreading at minute 0 at the same time. + Rather than spreading from one of them, put all of them into the queue + before the loop starts. + + ), + zh: ( + <> + 所有烂橘子在第 0 分钟「同时」开始腐蚀 —— + 与其一个个扩散,不如让它们一起入队。 + + ), + }, + key: { + en: ( + <> + Multi-source BFS: push every initially rotten orange into the + queue before the loop, and count the fresh ones. Then spread one level + at a time and increase minute by 1 after each finished level{" "} + (record the queue size first, the same level-by-level trick as binary + tree level order). Decrease the fresh counter for every orange you rot. + If any fresh orange is left at the end, return -1. Every step costs the + same one minute, so the BFS level number is exactly the elapsed time. + That is why BFS fits this problem and DFS does not. + + ), + zh: ( + <> + 多源 BFS:先把所有初始烂橘子一次性入队(这是本题精髓), + 并记录新鲜橘子总数。然后一层层扩散, + 每处理完一层 minute += 1 + (用「先记 size」的按层技巧,呼应二叉树层序); + 每腐蚀一个新鲜橘子就把计数减 1。最后若还有新鲜橘子返回 -1。 + 每一步的代价都是同样的一分钟,所以 BFS 的层数正好等于经过的时间 —— + 这就是它比 DFS 适合此题的原因。 + + ), + }, + }, + { + lc: 133, + title: { en: "Clone Graph", zh: "克隆图" }, + d: "medium", + tags: [ + { en: "DFS/BFS", zh: "DFS/BFS" }, + { en: "Hash map", zh: "哈希表" }, + { en: "Building a graph", zh: "建图" }, + ], + hint: { + en: ( + <> + You copy while you traverse. The hard part: one node can be reached + through several edges, so how do you make sure it is cloned only once? + + ), + zh: ( + <> + 边遍历边复制,难点是「一个节点可能被多条边指到」—— + 怎么保证只克隆一次? + + ), + }, + key: { + en: ( + <> + Use a hash map Map<original, clone> as both the + visited record and the deduplication table. When DFS or BFS reaches a + node: if the map already holds a clone, return it (this stops cycles + and duplicates); otherwise create the clone, store it in the map + first, then clone each neighbor recursively and append it to the + clone’s neighbor list. Storing the clone before recursing + is what makes a cycle terminate. In graph problems the visited set is + often a hash map that carries extra data like this. + + ), + zh: ( + <> + 用哈希表 Map<原节点, 克隆节点> + 当「访问记录 + 去重表」二合一。DFS/BFS 到某点:若 map + 里已有克隆就直接返回(防环、防重复);否则新建克隆 + 先存进 map,再递归克隆每个邻居并接到克隆节点的邻居表上。 + 「先存再递归」正是环能终止的原因。图题里 visited + 常常就是一张顺便携带数据的哈希表。 + + ), + }, + }, + { + lc: 210, + title: { en: "Course Schedule II", zh: "课程表 II" }, + d: "medium", + tags: [ + { en: "Topological sort", zh: "拓扑排序" }, + { en: "Kahn", zh: "Kahn" }, + { en: "Directed graph", zh: "有向图" }, + ], + hint: { + en: ( + <> + LC 207 only asks whether you can finish. This one asks for an actual + order, and the topological order is the answer itself. + + ), + zh: ( + <> + LC207 只问「能不能修完」,本题要你给出「具体的上课顺序」—— + 拓扑序本身就是答案。 + + ), + }, + key: { + en: ( + <> + Kahn’s algorithm: build an adjacency list and an in-degree + array, then enqueue every course with in-degree 0. Each time you + dequeue a course, append it to the answer and decrease the in-degree + of each successor by 1; enqueue a successor when its in-degree reaches + 0. If the answer finally has the same length as the number of courses, + it is a valid topological order; otherwise the remaining courses sit + on a cycle, so return an empty array. The DFS variant works too: the + reverse of the post-order is a topological order. Both are O(V + E). + + ), + zh: ( + <> + Kahn 入度法:建邻接表 + 入度数组,把入度为 0 的课先入队; + 每出队一门课就追加到答案序列,并把它后继课的入度各减 1,减到 0 的入队。 + 若最终答案长度 == 课程数就是合法拓扑序,否则说明剩下的课困在环里, + 返回空数组。DFS 写法同样可行:后序遍历的逆序就是一个拓扑序。 + 两种都是 O(V + E)。 + + ), + }, + }, + { + lc: 417, + title: { en: "Pacific Atlantic Water Flow", zh: "太平洋大西洋水流问题" }, + d: "medium", + tags: [ + { en: "Grid", zh: "网格" }, + { en: "Reverse DFS/BFS", zh: "反向 BFS/DFS" }, + { en: "Multi-source", zh: "多源" }, + ], + hint: { + en: ( + <> + Following the water downhill from every cell is too expensive. Turn it + around: starting from the ocean borders, which cells can the water + climb back to? + + ), + zh: ( + <> + 顺着「水往低处流」逐格判断太贵。反过来想:从海洋边界出发, + 水能倒着「爬」到哪些格子? + + ), + }, + key: { + en: ( + <> + Reverse the direction of the search. Start from the Pacific border + (top row and left column) and from the Atlantic border (bottom row and + right column) separately. Move only into a neighbor whose height is{" "} + greater than or equal to the current cell, which is the reverse + of flowing downhill. Each search marks the cells it can reach, giving + two boolean matrices. The intersection of the two sets is the + answer. Two ideas to take away: search from many sources at once, and + invert “flows out to” into “can be reached + from”. + + ), + zh: ( + <> + 逆向思维:分别从太平洋边界(上边 + 左边)和大西洋边界(下边 + 右边)出发, + 只走「高度 ≥ 当前格」的方向 —— 这正是「水往低处流」的反向。 + 各自标记能到达的格子,得到两个布尔矩阵, + 两个集合的交集就是答案。 + 两个关键转念:多个起点一起搜、把「流出去」反转成「能被谁到达」。 + + ), + }, + }, + { + lc: 787, + title: { + en: "Cheapest Flights Within K Stops", + zh: "K 站中转内最便宜的航班", + }, + d: "medium", + tags: [ + { en: "Shortest path", zh: "最短路" }, + { en: "Bounded BFS", zh: "限步 BFS" }, + { en: "Bellman-Ford", zh: "Bellman-Ford" }, + ], + hint: { + en: ( + <> + Plain Dijkstra breaks on the “at most K stops” limit: the + cheapest way to reach a city may use too many stops. When the number + of steps is bounded, advance round by round. + + ), + zh: ( + <> + 普通 Dijkstra 会被「最多中转 K 站」绊住: + 到某城最便宜的走法可能中转太多次。步数受限时,要按「轮」推进。 + + ), + }, + key: { + en: ( + <> + At most K stops means at most K + 1 edges, so run{" "} + K + 1 rounds of Bellman-Ford relaxation. Each round must relax + every edge against a{" "} + snapshot of the previous round’s dist (this is the point: + use a temporary array, otherwise two relaxations chain inside one + round and the path uses more edges than allowed). A BFS that carries + the number of steps used works as well. Plain Dijkstra is not valid + here, because it can settle a city cheaply through a path that is + already too long. The lesson: choose a shortest-path algorithm by the + constraints, not by habit. + + ), + zh: ( + <> + 最多中转 K 站 = 最多走 K + 1 条边,所以做{" "} + K + 1 轮 Bellman-Ford 松弛。每一轮都基于 + 上一轮 dist 的快照去更新所有边(关键:用临时数组, + 否则一轮内的连续松弛会相互影响,使走过的边数超过限制)。 + 也可用带「已用步数」的 BFS 分层扩展。普通 Dijkstra + 在这里不成立:它可能通过一条已经太长的路径把某个城市便宜地定案。 + 这题正好点出「最短路算法要看约束选型」。 + + ), + }, + }, + { + lc: 127, + title: { en: "Word Ladder", zh: "单词接龙" }, + d: "hard", + tags: [ + { en: "Implicit graph BFS", zh: "隐式图 BFS" }, + { en: "Shortest path", zh: "最短路" }, + { en: "Building edges", zh: "建边" }, + ], + hint: { + en: ( + <> + Treat each word as a vertex and “differs by exactly one + letter” as an edge. Then the question is a shortest path in an + unweighted graph. + + ), + zh: ( + <> + 把每个单词看成一个顶点,「改一个字母能互变」就是一条边 —— + 这不就是求无权图最短路吗? + + ), + }, + key: { + en: ( + <> + Implicit graph plus BFS. The vertices are words; an edge joins + two words that differ in one letter. Do not build the whole graph + first, because there are too many edges. Instead, when BFS dequeues a + word, try replacing each position with each of the 26 letters and look + the result up in the word list (a hash set); enqueue it if it exists + and has not been visited. Every edge costs the same, so BFS gives the + fewest transformations. As a follow-up, bidirectional BFS{" "} + searches from both ends and stops when the two fronts meet, which + halves the depth each side has to explore. + + ), + zh: ( + <> + 隐式图 + BFS:顶点是单词,边是「相差一个字母」。 + 不预先建完整图(边太多),而是 BFS 出队一个词时,枚举每一位换 26 + 个字母,在词表(哈希集合)里找存在且未访问的邻居入队。 + 每条边代价相同,所以 BFS 给出的层数就是最少转换次数。进阶可用 + 双向 BFS:从首尾同时搜、在中间相遇,两边各自要搜的深度都减半。 + + ), + }, + }, +]; diff --git a/lib/graph-data.tsx b/lib/graph-quiz.tsx similarity index 53% rename from lib/graph-data.tsx rename to lib/graph-quiz.tsx index 6431840..a9edf93 100644 --- a/lib/graph-data.tsx +++ b/lib/graph-quiz.tsx @@ -1,456 +1,9 @@ -// Chapter 12 · Graphs — problem set and quiz data (English default / Chinese toggle). -// Problems are high-frequency LeetCode graph questions — grid DFS/BFS, topological sort, shortest -// paths, implicit graphs — ordered from easy to hard. -// hint points a direction without spoilers; key explains the optimal solution in one paragraph. -// Bilingual: title / tags / hint / key and every quiz string are written as { en, zh }. -// Problem titles use the official LeetCode English name; zh keeps the official Chinese name. +// Chapter 12 · Graphs — closing quiz (the problem set is in lib/graph-problems.tsx). +// +// Bilingual: every question, option and explanation is a { en, zh } pair. -import type { Problem } from "@/lib/problems"; import type { QuizItem } from "@/lib/quiz"; -export const PROBLEMS: Problem[] = [ - { - lc: 733, - title: { en: "Flood Fill", zh: "图像渲染(Flood Fill)" }, - d: "easy", - tags: [ - { en: "Grid", zh: "网格" }, - { en: "DFS/BFS", zh: "DFS/BFS" }, - { en: "Coloring", zh: "染色" }, - ], - hint: { - en: ( - <> - Start at the given cell and repaint every cell that has the same - original color and is connected to it. This is the simplest form of - the island problems. - - ), - zh: ( - <> - 从起点出发,把「和它同色且连通」的格子全染成新色 —— - 这就是岛屿题的最朴素版本。 - - ), - }, - key: { - en: ( - <> - Record the starting color as old. Run DFS or BFS from the - start cell and recurse only into cells that are inside the grid and - still hold old; repaint each one on entry. The common - mistake: if the new color equals old, the recursion never - terminates, so return early when old == newColor. This is - also where you learn the four-direction grid template{" "} - dirs=[[-1,0],[1,0],[0,-1],[0,1]]. - - ), - zh: ( - <> - 记下起点原色 old,从起点 DFS/BFS - 向四个方向扩散:只递归「颜色 == old 且在界内」的格子,进门就改成新色。 - 常见错误:若新色 == old 会无限递归,要先判 old == newColor{" "} - 直接返回。网格题的四方向模板{" "} - dirs=[[-1,0],[1,0],[0,-1],[0,1]] 从这里练熟。 - - ), - }, - }, - { - lc: 695, - title: { en: "Max Area of Island", zh: "岛屿的最大面积" }, - d: "medium", - tags: [ - { en: "Grid", zh: "网格" }, - { en: "DFS", zh: "DFS" }, - { en: "Counting", zh: "计数" }, - ], - hint: { - en: ( - <> - Same sinking method as LC 200. Instead of adding 1 to an island - counter, add up how many cells this one DFS sank. - - ), - zh: ( - <> - 和 LC200 同一套淹没法,只是把「岛屿计数 +1」换成「累加这次 DFS - 淹了几块地」。 - - ), - }, - key: { - en: ( - <> - Start a DFS from every unvisited land cell. The DFS returns the number - of cells it sank: 1 plus the sum of the four recursive calls. Out of - bounds or water returns 0. The main loop keeps the maximum returned - value. Time is O(rows × cols), because each cell is visited once. - Letting DFS return a count is a common extra step in grid problems. - - ), - zh: ( - <> - 对每个未访问的陆地启动 DFS,DFS 返回它淹没的格子数(1 + - 四个方向递归之和),越界或遇水返回 0。主循环用 max 记录最大返回值。 - 时间 O(行 × 列),每格访问一次。 - 「让 DFS 带返回值统计规模」是网格题常见的扩展。 - - ), - }, - }, - { - lc: 130, - title: { en: "Surrounded Regions", zh: "被围绕的区域" }, - d: "medium", - tags: [ - { en: "Grid", zh: "网格" }, - { en: "Reverse thinking", zh: "反向思考" }, - { en: "Border DFS", zh: "边界 DFS" }, - ], - hint: { - en: ( - <> - Finding the surrounded O regions directly is awkward. Turn - it around: any region that touches the border can never be surrounded. - - ), - zh: ( - <> - 直接找「被包围的 O」很难判边界;反过来 —— - 谁碰到了边界,谁就一定不会被包围。 - - ), - }, - key: { - en: ( - <> - Solve the opposite problem. Run DFS or BFS from{" "} - - every O on the four borders - {" "} - and mark all connected O cells as safe (for example with a - temporary #). After that pass, every cell still holding{" "} - O must be surrounded, so change it to X; turn - each # back into O. Coloring from the border - inward avoids testing each interior region separately for whether it - touches an edge. - - ), - zh: ( - <> - 正难则反:先从 - - 四条边上的每个 O - {" "} - 出发 DFS/BFS,把连通的 O 全标成安全(比如临时记为{" "} - #)。扫完后,剩下还是 O 的必然被包围 → 改成{" "} - X;标过 # 的还原成 O。 - 「从边界倒着染色」避开了对每个内部区域单独判断是否触边。 - - ), - }, - }, - { - lc: 994, - title: { en: "Rotting Oranges", zh: "腐烂的橘子" }, - d: "medium", - tags: [ - { en: "Multi-source BFS", zh: "多源 BFS" }, - { en: "Grid", zh: "网格" }, - { en: "Level by level", zh: "按层" }, - ], - hint: { - en: ( - <> - Every rotten orange starts spreading at minute 0 at the same time. - Rather than spreading from one of them, put all of them into the queue - before the loop starts. - - ), - zh: ( - <> - 所有烂橘子在第 0 分钟「同时」开始腐蚀 —— - 与其一个个扩散,不如让它们一起入队。 - - ), - }, - key: { - en: ( - <> - Multi-source BFS: push every initially rotten orange into the - queue before the loop, and count the fresh ones. Then spread one level - at a time and increase minute by 1 after each finished level{" "} - (record the queue size first, the same level-by-level trick as binary - tree level order). Decrease the fresh counter for every orange you rot. - If any fresh orange is left at the end, return -1. Every step costs the - same one minute, so the BFS level number is exactly the elapsed time. - That is why BFS fits this problem and DFS does not. - - ), - zh: ( - <> - 多源 BFS:先把所有初始烂橘子一次性入队(这是本题精髓), - 并记录新鲜橘子总数。然后一层层扩散, - 每处理完一层 minute += 1 - (用「先记 size」的按层技巧,呼应二叉树层序); - 每腐蚀一个新鲜橘子就把计数减 1。最后若还有新鲜橘子返回 -1。 - 每一步的代价都是同样的一分钟,所以 BFS 的层数正好等于经过的时间 —— - 这就是它比 DFS 适合此题的原因。 - - ), - }, - }, - { - lc: 133, - title: { en: "Clone Graph", zh: "克隆图" }, - d: "medium", - tags: [ - { en: "DFS/BFS", zh: "DFS/BFS" }, - { en: "Hash map", zh: "哈希表" }, - { en: "Building a graph", zh: "建图" }, - ], - hint: { - en: ( - <> - You copy while you traverse. The hard part: one node can be reached - through several edges, so how do you make sure it is cloned only once? - - ), - zh: ( - <> - 边遍历边复制,难点是「一个节点可能被多条边指到」—— - 怎么保证只克隆一次? - - ), - }, - key: { - en: ( - <> - Use a hash map Map<original, clone> as both the - visited record and the deduplication table. When DFS or BFS reaches a - node: if the map already holds a clone, return it (this stops cycles - and duplicates); otherwise create the clone, store it in the map - first, then clone each neighbor recursively and append it to the - clone’s neighbor list. Storing the clone before recursing - is what makes a cycle terminate. In graph problems the visited set is - often a hash map that carries extra data like this. - - ), - zh: ( - <> - 用哈希表 Map<原节点, 克隆节点> - 当「访问记录 + 去重表」二合一。DFS/BFS 到某点:若 map - 里已有克隆就直接返回(防环、防重复);否则新建克隆 - 先存进 map,再递归克隆每个邻居并接到克隆节点的邻居表上。 - 「先存再递归」正是环能终止的原因。图题里 visited - 常常就是一张顺便携带数据的哈希表。 - - ), - }, - }, - { - lc: 210, - title: { en: "Course Schedule II", zh: "课程表 II" }, - d: "medium", - tags: [ - { en: "Topological sort", zh: "拓扑排序" }, - { en: "Kahn", zh: "Kahn" }, - { en: "Directed graph", zh: "有向图" }, - ], - hint: { - en: ( - <> - LC 207 only asks whether you can finish. This one asks for an actual - order, and the topological order is the answer itself. - - ), - zh: ( - <> - LC207 只问「能不能修完」,本题要你给出「具体的上课顺序」—— - 拓扑序本身就是答案。 - - ), - }, - key: { - en: ( - <> - Kahn’s algorithm: build an adjacency list and an in-degree - array, then enqueue every course with in-degree 0. Each time you - dequeue a course, append it to the answer and decrease the in-degree - of each successor by 1; enqueue a successor when its in-degree reaches - 0. If the answer finally has the same length as the number of courses, - it is a valid topological order; otherwise the remaining courses sit - on a cycle, so return an empty array. The DFS variant works too: the - reverse of the post-order is a topological order. Both are O(V + E). - - ), - zh: ( - <> - Kahn 入度法:建邻接表 + 入度数组,把入度为 0 的课先入队; - 每出队一门课就追加到答案序列,并把它后继课的入度各减 1,减到 0 的入队。 - 若最终答案长度 == 课程数就是合法拓扑序,否则说明剩下的课困在环里, - 返回空数组。DFS 写法同样可行:后序遍历的逆序就是一个拓扑序。 - 两种都是 O(V + E)。 - - ), - }, - }, - { - lc: 417, - title: { en: "Pacific Atlantic Water Flow", zh: "太平洋大西洋水流问题" }, - d: "medium", - tags: [ - { en: "Grid", zh: "网格" }, - { en: "Reverse DFS/BFS", zh: "反向 BFS/DFS" }, - { en: "Multi-source", zh: "多源" }, - ], - hint: { - en: ( - <> - Following the water downhill from every cell is too expensive. Turn it - around: starting from the ocean borders, which cells can the water - climb back to? - - ), - zh: ( - <> - 顺着「水往低处流」逐格判断太贵。反过来想:从海洋边界出发, - 水能倒着「爬」到哪些格子? - - ), - }, - key: { - en: ( - <> - Reverse the direction of the search. Start from the Pacific border - (top row and left column) and from the Atlantic border (bottom row and - right column) separately. Move only into a neighbor whose height is{" "} - greater than or equal to the current cell, which is the reverse - of flowing downhill. Each search marks the cells it can reach, giving - two boolean matrices. The intersection of the two sets is the - answer. Two ideas to take away: search from many sources at once, and - invert “flows out to” into “can be reached - from”. - - ), - zh: ( - <> - 逆向思维:分别从太平洋边界(上边 + 左边)和大西洋边界(下边 + 右边)出发, - 只走「高度 ≥ 当前格」的方向 —— 这正是「水往低处流」的反向。 - 各自标记能到达的格子,得到两个布尔矩阵, - 两个集合的交集就是答案。 - 两个关键转念:多个起点一起搜、把「流出去」反转成「能被谁到达」。 - - ), - }, - }, - { - lc: 787, - title: { - en: "Cheapest Flights Within K Stops", - zh: "K 站中转内最便宜的航班", - }, - d: "medium", - tags: [ - { en: "Shortest path", zh: "最短路" }, - { en: "Bounded BFS", zh: "限步 BFS" }, - { en: "Bellman-Ford", zh: "Bellman-Ford" }, - ], - hint: { - en: ( - <> - Plain Dijkstra breaks on the “at most K stops” limit: the - cheapest way to reach a city may use too many stops. When the number - of steps is bounded, advance round by round. - - ), - zh: ( - <> - 普通 Dijkstra 会被「最多中转 K 站」绊住: - 到某城最便宜的走法可能中转太多次。步数受限时,要按「轮」推进。 - - ), - }, - key: { - en: ( - <> - At most K stops means at most K + 1 edges, so run{" "} - K + 1 rounds of Bellman-Ford relaxation. Each round must relax - every edge against a{" "} - snapshot of the previous round’s dist (this is the point: - use a temporary array, otherwise two relaxations chain inside one - round and the path uses more edges than allowed). A BFS that carries - the number of steps used works as well. Plain Dijkstra is not valid - here, because it can settle a city cheaply through a path that is - already too long. The lesson: choose a shortest-path algorithm by the - constraints, not by habit. - - ), - zh: ( - <> - 最多中转 K 站 = 最多走 K + 1 条边,所以做{" "} - K + 1 轮 Bellman-Ford 松弛。每一轮都基于 - 上一轮 dist 的快照去更新所有边(关键:用临时数组, - 否则一轮内的连续松弛会相互影响,使走过的边数超过限制)。 - 也可用带「已用步数」的 BFS 分层扩展。普通 Dijkstra - 在这里不成立:它可能通过一条已经太长的路径把某个城市便宜地定案。 - 这题正好点出「最短路算法要看约束选型」。 - - ), - }, - }, - { - lc: 127, - title: { en: "Word Ladder", zh: "单词接龙" }, - d: "hard", - tags: [ - { en: "Implicit graph BFS", zh: "隐式图 BFS" }, - { en: "Shortest path", zh: "最短路" }, - { en: "Building edges", zh: "建边" }, - ], - hint: { - en: ( - <> - Treat each word as a vertex and “differs by exactly one - letter” as an edge. Then the question is a shortest path in an - unweighted graph. - - ), - zh: ( - <> - 把每个单词看成一个顶点,「改一个字母能互变」就是一条边 —— - 这不就是求无权图最短路吗? - - ), - }, - key: { - en: ( - <> - Implicit graph plus BFS. The vertices are words; an edge joins - two words that differ in one letter. Do not build the whole graph - first, because there are too many edges. Instead, when BFS dequeues a - word, try replacing each position with each of the 26 letters and look - the result up in the word list (a hash set); enqueue it if it exists - and has not been visited. Every edge costs the same, so BFS gives the - fewest transformations. As a follow-up, bidirectional BFS{" "} - searches from both ends and stops when the two fronts meet, which - halves the depth each side has to explore. - - ), - zh: ( - <> - 隐式图 + BFS:顶点是单词,边是「相差一个字母」。 - 不预先建完整图(边太多),而是 BFS 出队一个词时,枚举每一位换 26 - 个字母,在词表(哈希集合)里找存在且未访问的邻居入队。 - 每条边代价相同,所以 BFS 给出的层数就是最少转换次数。进阶可用 - 双向 BFS:从首尾同时搜、在中间相遇,两边各自要搜的深度都减半。 - - ), - }, - }, -]; - export const QUIZ: QuizItem[] = [ { type: "choice", diff --git a/lib/hash-problems.tsx b/lib/hash-problems.tsx new file mode 100644 index 0000000..836e134 --- /dev/null +++ b/lib/hash-problems.tsx @@ -0,0 +1,374 @@ +// Chapter 6 · Hash tables — problem set (English default / Chinese toggle). +// The closing quiz is in lib/hash-quiz.tsx; /atlas imports only this file. +// Problems center on the three hash-table signals (have I seen it? / pairing / group counting), +// plus prefix sum with a hash table and composition with other structures; +// hint points a direction without spoilers, key explains the optimal solution in one paragraph. +// +// Bilingual: title / tags / hint / key are all written as { en, zh }, +// and the en side of each problem title uses the official LeetCode English name. + +import type { Problem } from "@/lib/problems"; + +export const PROBLEMS: Problem[] = [ + { + lc: 217, + title: { en: "Contains Duplicate", zh: "存在重复元素" }, + d: "easy", + tags: [ + { en: "Set", zh: "Set" }, + { en: "Seen before?", zh: "见过吗" }, + ], + hint: { + en: "The question is only \"has this value appeared before?\". That is exactly what a set answers. Ask while you walk through the array.", + zh: "问题只有「这个值出现过吗」—— 这正是 Set 的岗位描述。一边遍历一边问。", + }, + key: { + en: ( + <> + Walk through the array. For each element, ask the set whether it is + already there. If it is, return true. If it is not, add it and + continue. Time O(n) on average, extra space O(n). The sorting solution + costs O(n log n) time and no extra space. Trading memory for time is + the pattern behind every problem in this chapter. + + ), + zh: ( + <> + 遍历数组,每个元素先问 Set「见过它吗」:见过就返回 true; + 没见过就 add 进去继续。平均 O(n) 时间、O(n) 额外空间。 + 对比排序解法:O(n log n) 时间、不额外占空间。用空间换时间, + 是本章所有题目的共同底色。 + + ), + }, + }, + { + lc: 219, + title: { en: "Contains Duplicate II", zh: "存在重复元素 II" }, + d: "easy", + tags: [ + { en: "Map", zh: "Map" }, + { en: "Latest index", zh: "最近下标" }, + ], + hint: { + en: "\"Seen before\" is no longer enough. You also need where you last saw it, so the set becomes a map.", + zh: "光知道「见过」不够,还要知道「最近一次在哪见的」—— Set 升级成 Map。", + }, + key: { + en: ( + <> + Keep a map from value to the index where you last saw that value. At + position i, if the value is in the map and i minus the stored index is + at most k, return true. Otherwise store i as the new index. The older + index is always further away, so it can never help later and can be + overwritten. Time O(n) on average, space O(n). A sliding window that + holds a set of the last k values works too and is the same idea. + + ), + zh: ( + <> + Map 存「值 → 最近一次出现的下标」。走到 nums[i] 时,若表里有它, + 且 i − 上次下标 ≤ k,返回 true;否则把下标更新成 i。旧下标只会更远, + 以后永远用不上,可以直接覆盖。平均 O(n) 时间、O(n) 空间。 + 用「长度为 k 的滑动窗口 Set」也可以,思路等价。 + + ), + }, + }, + { + lc: 383, + title: { en: "Ransom Note", zh: "赎金信" }, + d: "easy", + tags: [ + { en: "Counting", zh: "计数" }, + { en: "Character table", zh: "字符表" }, + ], + hint: { + en: "Each letter in magazine is stock. ransomNote is the order. Is there enough stock of every letter?", + zh: "magazine 里每个字母是「库存」,ransomNote 是「订单」—— 每种字母的库存够不够?", + }, + key: { + en: ( + <> + First pass: count the 26 letters of magazine. Second pass: subtract one + for each letter of ransomNote. If any count goes below zero, the stock + is not enough, so return false. When the alphabet is fixed and small, + an array of length 26 replaces the hash map. The index is the letter + itself, so no hash function is needed at all. A counting array is a + hash table with the simplest possible hash. Time O(m + n), extra space + O(1) because 26 does not grow with the input. + + ), + zh: ( + <> + 第一遍:统计 magazine 里 26 个字母各有几个。第二遍:遍历 ransomNote + 逐个扣减,任何一个扣成负数就是库存不足,返回 false。字符集固定且很小时, + 用长度 26 的数组代替 HashMap:下标就是字母本身,连哈希函数都不需要。 + 计数数组就是哈希函数最简单的那种哈希表。O(m + n) 时间、O(1) + 额外空间(26 不随输入增长)。 + + ), + }, + }, + { + lc: 290, + title: { en: "Word Pattern", zh: "单词规律" }, + d: "easy", + tags: [ + { en: "Map", zh: "Map" }, + { en: "Two-way mapping", zh: "双向映射" }, + ], + hint: { + en: "One map from letter to word is not enough. You also need word to letter. Think about what a single map fails to catch.", + zh: "只存「字母 → 单词」一张表不够,还要存「单词 → 字母」—— 想想单向会漏掉什么。", + }, + key: { + en: ( + <> + Build the mapping in both directions: one map from character to + word, one from word to character. If either side already has a + different partner, return false. One direction is not enough: with + pattern "abba" and the string "dog dog dog dog", + the character to word map only records a to dog and b to dog, which + looks consistent. The word to character map catches it, because dog is + already taken by a. LC 205 (Isomorphic Strings) is the same problem + with characters on both sides. + + ), + zh: ( + <> + 建立双向映射:char → word 和 word → char 两张表。 + 任何一边已经配了别的伙伴,就返回 false。只存单向会漏判: + pattern = "abba"、s = "dog dog dog dog" 时, + char → word 只记下 a→dog、b→dog,看起来毫无矛盾; + 反向表才能发现 dog 已经被 a 占用。LC 205(同构字符串) + 是两边都换成字符的同型题。 + + ), + }, + }, + { + lc: 202, + title: { en: "Happy Number", zh: "快乐数" }, + d: "easy", + tags: [ + { en: "Set", zh: "Set" }, + { en: "Cycle detection", zh: "判环" }, + ], + hint: { + en: "A number that is not happy repeats itself forever. \"Repeats\" means some value shows up a second time, and a set notices that immediately.", + zh: "不快乐的数会一直绕圈。「绕圈」= 某个值第二次出现 —— Set 一眼就能看出来。", + }, + key: { + en: ( + <> + Repeatedly replace the number by the sum of the squares of its digits. + The process either reaches 1, or it enters a cycle and never stops. Put + every intermediate value into a set. If a value appears again, you are + in a cycle, so return false. Each transformation touches the digits of + the number, which is O(log n) work, and the set makes the repeat check + O(1) on average. A follow-up asks for constant space: use the fast and + slow pointer cycle detection from the linked list chapter. + + ), + zh: ( + <> + 反复把数替换成「各位数字平方和」,过程要么到 1,要么进入一个永不停止的循环。 + 把每个中间值放进 Set,某个值第二次出现就说明入环,返回 false。 + 每次变换要处理这个数的每一位,是 O(log n) 的工作量;Set 让「是否重复」 + 的判断平均只花 O(1)。追问「不用额外空间行吗」:用链表章的快慢指针判环。 + + ), + }, + }, + { + lc: 349, + title: { en: "Intersection of Two Arrays", zh: "两个数组的交集" }, + d: "easy", + tags: [ + { en: "Set", zh: "Set" }, + { en: "Deduplication", zh: "去重" }, + ], + hint: { + en: "Put one array into a set, then check the other array against it. The result must not contain duplicates.", + zh: "把一个数组装进 Set,再拿另一个数组来查。结果本身还要去重。", + }, + key: { + en: ( + <> + Put all of nums1 into a set A. Walk through nums2 and add every value + that is in A to a result set, which removes duplicates for you. Convert + the result set to an array at the end. Time O(m + n) on average. Every + language has this built in: set(a) & set(b) in Python,{" "} + filter plus has in JavaScript,{" "} + retainAll in Java. Set operations like intersection and + union are the reason the type exists. + + ), + zh: ( + <> + nums1 全部入 Set A;遍历 nums2,凡在 A 中的加入结果 Set(自动去重), + 最后转成数组。平均 O(m + n)。三种语言都内置了这类运算:Python{" "} + set(a) & set(b)、JS filter +{" "} + has、Java retainAll —— 交集、并集这类集合运算, + 正是 Set 这个类型存在的理由。 + + ), + }, + }, + { + lc: 454, + title: { en: "4Sum II", zh: "四数相加 II" }, + d: "medium", + tags: [ + { en: "Map", zh: "Map" }, + { en: "Split and pair", zh: "分组配对" }, + ], + hint: { + en: "Four nested loops give O(n⁴). What happens if you split the four arrays into two halves of two?", + zh: "四层循环 O(n⁴) 显然不行。把四个数组拆成 2 + 2 呢?", + }, + key: { + en: ( + <> + Rewrite A + B + C + D = 0 as (A + B) = −(C + D). First, two nested + loops over A and B fill a map from each sum to how many times it + occurred, which costs O(n²). Then two nested loops over C and D look up + −(c + d) in the map and add its count to the answer. Time and space are + both O(n²), down from n⁴. Splitting the search in half and pairing the + halves through a hash table is the entry-level version of meet in the + middle. + + ), + 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 + 思想的入门款。 + + ), + }, + }, + { + lc: 560, + title: { en: "Subarray Sum Equals K", zh: "和为 K 的子数组" }, + d: "medium", + tags: [ + { en: "Prefix sum", zh: "前缀和" }, + { en: "Map counting", zh: "Map 计数" }, + { en: "Must know", zh: "必会" }, + ], + hint: { + en: "The sum of a subarray is the difference of two prefix sums. Can \"find a range\" become \"find a prefix you have already seen\"?", + zh: "子数组和 = 两个前缀和之差。「找一段区间」能不能变成「找一个之前出现过的前缀」?", + }, + key: { + en: ( + <> + Let pre[i] be the sum of the first i numbers. The subarray (j, i] sums + to k exactly when pre[i] − pre[j] = k, that is when{" "} + pre[j] = pre[i] − k. So make one pass and keep a map from prefix + sum to how many times it has occurred. At each position, first add + map[pre − k] to the answer, because every such j gives one valid + subarray, then increase the count of the current pre by one.{" "} + The map must start with {"{0: 1}"}: the empty prefix is a valid + j, and without it every subarray that starts at index 0 is missed. Time + O(n), space O(n). The values may be negative, so the sliding window + does not work here — the window sum is not monotonic. That is exactly + why prefix sums plus a hash table are needed. This is Two Sum + transplanted into the world of prefix sums, and it is the most + important problem in this list. + + ), + zh: ( + <> + 设前缀和 pre[i] = 前 i 个数之和,则子数组 (j, i] 的和为 k ⟺ pre[i] − + pre[j] = k ⟺ pre[j] = pre[i] − k。于是一边扫一边维护 Map「前缀和 + → 出现次数」:每到一个位置,先把 map[pre − k] 累加进答案(有几个这样的 j + 就有几个合法子数组),再把当前 pre 的计数 +1。 + Map 必须以 {"{0: 1}"} 起步:空前缀也是合法的 j, + 否则从下标 0 开始的子数组会全部漏掉。O(n) 时间、O(n) 空间。 + 元素可能为负,窗口和不单调,滑动窗口在这里失效 —— + 这正是前缀和 + 哈希登场的原因。它是「两数之和」在前缀和世界里的翻版, + 也是本题单最重要的一题。 + + ), + }, + }, + { + lc: 380, + title: { en: "Insert Delete GetRandom O(1)", zh: "O(1) 时间插入、删除和获取随机元素" }, + d: "medium", + tags: [ + { en: "Map + array", zh: "Map + 数组" }, + { en: "Combined structures", zh: "结构组合" }, + ], + hint: { + en: "Random access needs an array. O(1) lookup needs a hash map. When one structure is not enough, use two together.", + zh: "随机取要数组(下标随机),快速定位要哈希 —— 一个结构不够,就两个一起上。", + }, + key: { + en: ( + <> + A dynamic array holds the values, so picking a random one is O(1). A + map holds value to its index in that array. To delete x: look up its + index i, move the last element of the array into position i, + update that element's index in the map, then pop the last slot. + This avoids the O(n) shifting that deleting from the middle of an array + would cost. Swap with last is the standard way to delete from an array + in O(1), and the price is that the order is not preserved. The same + combination of a map and another structure returns for LRU and LFU + caches. + + ), + zh: ( + <> + 动态数组存值(随机取 O(1)),Map 存「值 → 它在数组里的下标」。 + 删除 x 时:查出下标 i,把数组末尾元素搬到 i、 + 更新它在 Map 里的下标,再弹掉末尾 —— 避开了数组中间删除的 O(n) 搬家。 + swap-with-last 是数组 O(1) 删除的标准手法,代价是不再保持顺序。 + 「哈希表 + 另一种结构」这套组合,后面的 LRU、LFU 缓存还会再用一次。 + + ), + }, + }, + { + lc: 299, + title: { en: "Bulls and Cows", zh: "猜数字游戏" }, + d: "medium", + tags: [ + { en: "Counting", zh: "计数" }, + { en: "Two passes / one pass", zh: "两遍/一遍" }, + ], + hint: { + en: "Bulls are easy: same position and same digit. For cows, compare the leftover digits on each side.", + zh: "公牛好数:同位置同数字。奶牛要看两边「没配上」的数字各剩多少。", + }, + key: { + en: ( + <> + First pass: if secret and guess have the same digit at the same + position, increase bulls. For every position that does not match, count + that digit separately for secret and for guess, using two arrays of + length 10. Second pass: for each digit 0 to 9, add min(cntS[d], + cntG[d]) to cows, because that many can be paired up. Time O(n), extra + space O(1). One-pass version: use a single counting array, add one for + each secret digit and subtract one for each guess digit; whenever a + count crosses zero in the opposite direction, one cow has been matched. + + ), + zh: ( + <> + 第一遍:同位置同数字 → bulls++;不匹配的位置,分别给 secret 和 guess + 的数字计数(两个长度 10 的计数数组)。第二遍:对每个数字 0–9, + cows += min(cntS[d], cntG[d]) —— 两边库存能配上的部分都是奶牛。 + O(n) 时间、O(1) 额外空间。一遍写法:只用一个计数数组,secret + 的数字 +1、guess 的数字 −1,某个计数越过 0 走向反方向时, + 就说明抵掉了一头奶牛。 + + ), + }, + }, +]; diff --git a/lib/hash-data.tsx b/lib/hash-quiz.tsx similarity index 53% rename from lib/hash-data.tsx rename to lib/hash-quiz.tsx index 9bf7161..3203515 100644 --- a/lib/hash-data.tsx +++ b/lib/hash-quiz.tsx @@ -1,378 +1,9 @@ -// Chapter 6 · Hash tables — problem set and quiz data (English default / Chinese toggle). -// Problems center on the three hash-table signals (have I seen it? / pairing / group counting), -// plus prefix sum with a hash table and composition with other structures; -// hint points a direction without spoilers, key explains the optimal solution in one paragraph. +// Chapter 6 · Hash tables — closing quiz (the problem set is in lib/hash-problems.tsx). // -// Bilingual: title / tags / hint / key / q / opts / why are all written as { en, zh }, -// and the en side of each problem title uses the official LeetCode English name. +// Bilingual: every question, option and explanation is a { en, zh } pair. -import type { Problem } from "@/lib/problems"; import type { QuizItem } from "@/lib/quiz"; -export const PROBLEMS: Problem[] = [ - { - lc: 217, - title: { en: "Contains Duplicate", zh: "存在重复元素" }, - d: "easy", - tags: [ - { en: "Set", zh: "Set" }, - { en: "Seen before?", zh: "见过吗" }, - ], - hint: { - en: "The question is only \"has this value appeared before?\". That is exactly what a set answers. Ask while you walk through the array.", - zh: "问题只有「这个值出现过吗」—— 这正是 Set 的岗位描述。一边遍历一边问。", - }, - key: { - en: ( - <> - Walk through the array. For each element, ask the set whether it is - already there. If it is, return true. If it is not, add it and - continue. Time O(n) on average, extra space O(n). The sorting solution - costs O(n log n) time and no extra space. Trading memory for time is - the pattern behind every problem in this chapter. - - ), - zh: ( - <> - 遍历数组,每个元素先问 Set「见过它吗」:见过就返回 true; - 没见过就 add 进去继续。平均 O(n) 时间、O(n) 额外空间。 - 对比排序解法:O(n log n) 时间、不额外占空间。用空间换时间, - 是本章所有题目的共同底色。 - - ), - }, - }, - { - lc: 219, - title: { en: "Contains Duplicate II", zh: "存在重复元素 II" }, - d: "easy", - tags: [ - { en: "Map", zh: "Map" }, - { en: "Latest index", zh: "最近下标" }, - ], - hint: { - en: "\"Seen before\" is no longer enough. You also need where you last saw it, so the set becomes a map.", - zh: "光知道「见过」不够,还要知道「最近一次在哪见的」—— Set 升级成 Map。", - }, - key: { - en: ( - <> - Keep a map from value to the index where you last saw that value. At - position i, if the value is in the map and i minus the stored index is - at most k, return true. Otherwise store i as the new index. The older - index is always further away, so it can never help later and can be - overwritten. Time O(n) on average, space O(n). A sliding window that - holds a set of the last k values works too and is the same idea. - - ), - zh: ( - <> - Map 存「值 → 最近一次出现的下标」。走到 nums[i] 时,若表里有它, - 且 i − 上次下标 ≤ k,返回 true;否则把下标更新成 i。旧下标只会更远, - 以后永远用不上,可以直接覆盖。平均 O(n) 时间、O(n) 空间。 - 用「长度为 k 的滑动窗口 Set」也可以,思路等价。 - - ), - }, - }, - { - lc: 383, - title: { en: "Ransom Note", zh: "赎金信" }, - d: "easy", - tags: [ - { en: "Counting", zh: "计数" }, - { en: "Character table", zh: "字符表" }, - ], - hint: { - en: "Each letter in magazine is stock. ransomNote is the order. Is there enough stock of every letter?", - zh: "magazine 里每个字母是「库存」,ransomNote 是「订单」—— 每种字母的库存够不够?", - }, - key: { - en: ( - <> - First pass: count the 26 letters of magazine. Second pass: subtract one - for each letter of ransomNote. If any count goes below zero, the stock - is not enough, so return false. When the alphabet is fixed and small, - an array of length 26 replaces the hash map. The index is the letter - itself, so no hash function is needed at all. A counting array is a - hash table with the simplest possible hash. Time O(m + n), extra space - O(1) because 26 does not grow with the input. - - ), - zh: ( - <> - 第一遍:统计 magazine 里 26 个字母各有几个。第二遍:遍历 ransomNote - 逐个扣减,任何一个扣成负数就是库存不足,返回 false。字符集固定且很小时, - 用长度 26 的数组代替 HashMap:下标就是字母本身,连哈希函数都不需要。 - 计数数组就是哈希函数最简单的那种哈希表。O(m + n) 时间、O(1) - 额外空间(26 不随输入增长)。 - - ), - }, - }, - { - lc: 290, - title: { en: "Word Pattern", zh: "单词规律" }, - d: "easy", - tags: [ - { en: "Map", zh: "Map" }, - { en: "Two-way mapping", zh: "双向映射" }, - ], - hint: { - en: "One map from letter to word is not enough. You also need word to letter. Think about what a single map fails to catch.", - zh: "只存「字母 → 单词」一张表不够,还要存「单词 → 字母」—— 想想单向会漏掉什么。", - }, - key: { - en: ( - <> - Build the mapping in both directions: one map from character to - word, one from word to character. If either side already has a - different partner, return false. One direction is not enough: with - pattern "abba" and the string "dog dog dog dog", - the character to word map only records a to dog and b to dog, which - looks consistent. The word to character map catches it, because dog is - already taken by a. LC 205 (Isomorphic Strings) is the same problem - with characters on both sides. - - ), - zh: ( - <> - 建立双向映射:char → word 和 word → char 两张表。 - 任何一边已经配了别的伙伴,就返回 false。只存单向会漏判: - pattern = "abba"、s = "dog dog dog dog" 时, - char → word 只记下 a→dog、b→dog,看起来毫无矛盾; - 反向表才能发现 dog 已经被 a 占用。LC 205(同构字符串) - 是两边都换成字符的同型题。 - - ), - }, - }, - { - lc: 202, - title: { en: "Happy Number", zh: "快乐数" }, - d: "easy", - tags: [ - { en: "Set", zh: "Set" }, - { en: "Cycle detection", zh: "判环" }, - ], - hint: { - en: "A number that is not happy repeats itself forever. \"Repeats\" means some value shows up a second time, and a set notices that immediately.", - zh: "不快乐的数会一直绕圈。「绕圈」= 某个值第二次出现 —— Set 一眼就能看出来。", - }, - key: { - en: ( - <> - Repeatedly replace the number by the sum of the squares of its digits. - The process either reaches 1, or it enters a cycle and never stops. Put - every intermediate value into a set. If a value appears again, you are - in a cycle, so return false. Each transformation touches the digits of - the number, which is O(log n) work, and the set makes the repeat check - O(1) on average. A follow-up asks for constant space: use the fast and - slow pointer cycle detection from the linked list chapter. - - ), - zh: ( - <> - 反复把数替换成「各位数字平方和」,过程要么到 1,要么进入一个永不停止的循环。 - 把每个中间值放进 Set,某个值第二次出现就说明入环,返回 false。 - 每次变换要处理这个数的每一位,是 O(log n) 的工作量;Set 让「是否重复」 - 的判断平均只花 O(1)。追问「不用额外空间行吗」:用链表章的快慢指针判环。 - - ), - }, - }, - { - lc: 349, - title: { en: "Intersection of Two Arrays", zh: "两个数组的交集" }, - d: "easy", - tags: [ - { en: "Set", zh: "Set" }, - { en: "Deduplication", zh: "去重" }, - ], - hint: { - en: "Put one array into a set, then check the other array against it. The result must not contain duplicates.", - zh: "把一个数组装进 Set,再拿另一个数组来查。结果本身还要去重。", - }, - key: { - en: ( - <> - Put all of nums1 into a set A. Walk through nums2 and add every value - that is in A to a result set, which removes duplicates for you. Convert - the result set to an array at the end. Time O(m + n) on average. Every - language has this built in: set(a) & set(b) in Python,{" "} - filter plus has in JavaScript,{" "} - retainAll in Java. Set operations like intersection and - union are the reason the type exists. - - ), - zh: ( - <> - nums1 全部入 Set A;遍历 nums2,凡在 A 中的加入结果 Set(自动去重), - 最后转成数组。平均 O(m + n)。三种语言都内置了这类运算:Python{" "} - set(a) & set(b)、JS filter +{" "} - has、Java retainAll —— 交集、并集这类集合运算, - 正是 Set 这个类型存在的理由。 - - ), - }, - }, - { - lc: 454, - title: { en: "4Sum II", zh: "四数相加 II" }, - d: "medium", - tags: [ - { en: "Map", zh: "Map" }, - { en: "Split and pair", zh: "分组配对" }, - ], - hint: { - en: "Four nested loops give O(n⁴). What happens if you split the four arrays into two halves of two?", - zh: "四层循环 O(n⁴) 显然不行。把四个数组拆成 2 + 2 呢?", - }, - key: { - en: ( - <> - Rewrite A + B + C + D = 0 as (A + B) = −(C + D). First, two nested - loops over A and B fill a map from each sum to how many times it - occurred, which costs O(n²). Then two nested loops over C and D look up - −(c + d) in the map and add its count to the answer. Time and space are - both O(n²), down from n⁴. Splitting the search in half and pairing the - halves through a hash table is the entry-level version of meet in the - middle. - - ), - 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 - 思想的入门款。 - - ), - }, - }, - { - lc: 560, - title: { en: "Subarray Sum Equals K", zh: "和为 K 的子数组" }, - d: "medium", - tags: [ - { en: "Prefix sum", zh: "前缀和" }, - { en: "Map counting", zh: "Map 计数" }, - { en: "Must know", zh: "必会" }, - ], - hint: { - en: "The sum of a subarray is the difference of two prefix sums. Can \"find a range\" become \"find a prefix you have already seen\"?", - zh: "子数组和 = 两个前缀和之差。「找一段区间」能不能变成「找一个之前出现过的前缀」?", - }, - key: { - en: ( - <> - Let pre[i] be the sum of the first i numbers. The subarray (j, i] sums - to k exactly when pre[i] − pre[j] = k, that is when{" "} - pre[j] = pre[i] − k. So make one pass and keep a map from prefix - sum to how many times it has occurred. At each position, first add - map[pre − k] to the answer, because every such j gives one valid - subarray, then increase the count of the current pre by one.{" "} - The map must start with {"{0: 1}"}: the empty prefix is a valid - j, and without it every subarray that starts at index 0 is missed. Time - O(n), space O(n). The values may be negative, so the sliding window - does not work here — the window sum is not monotonic. That is exactly - why prefix sums plus a hash table are needed. This is Two Sum - transplanted into the world of prefix sums, and it is the most - important problem in this list. - - ), - zh: ( - <> - 设前缀和 pre[i] = 前 i 个数之和,则子数组 (j, i] 的和为 k ⟺ pre[i] − - pre[j] = k ⟺ pre[j] = pre[i] − k。于是一边扫一边维护 Map「前缀和 - → 出现次数」:每到一个位置,先把 map[pre − k] 累加进答案(有几个这样的 j - 就有几个合法子数组),再把当前 pre 的计数 +1。 - Map 必须以 {"{0: 1}"} 起步:空前缀也是合法的 j, - 否则从下标 0 开始的子数组会全部漏掉。O(n) 时间、O(n) 空间。 - 元素可能为负,窗口和不单调,滑动窗口在这里失效 —— - 这正是前缀和 + 哈希登场的原因。它是「两数之和」在前缀和世界里的翻版, - 也是本题单最重要的一题。 - - ), - }, - }, - { - lc: 380, - title: { en: "Insert Delete GetRandom O(1)", zh: "O(1) 时间插入、删除和获取随机元素" }, - d: "medium", - tags: [ - { en: "Map + array", zh: "Map + 数组" }, - { en: "Combined structures", zh: "结构组合" }, - ], - hint: { - en: "Random access needs an array. O(1) lookup needs a hash map. When one structure is not enough, use two together.", - zh: "随机取要数组(下标随机),快速定位要哈希 —— 一个结构不够,就两个一起上。", - }, - key: { - en: ( - <> - A dynamic array holds the values, so picking a random one is O(1). A - map holds value to its index in that array. To delete x: look up its - index i, move the last element of the array into position i, - update that element's index in the map, then pop the last slot. - This avoids the O(n) shifting that deleting from the middle of an array - would cost. Swap with last is the standard way to delete from an array - in O(1), and the price is that the order is not preserved. The same - combination of a map and another structure returns for LRU and LFU - caches. - - ), - zh: ( - <> - 动态数组存值(随机取 O(1)),Map 存「值 → 它在数组里的下标」。 - 删除 x 时:查出下标 i,把数组末尾元素搬到 i、 - 更新它在 Map 里的下标,再弹掉末尾 —— 避开了数组中间删除的 O(n) 搬家。 - swap-with-last 是数组 O(1) 删除的标准手法,代价是不再保持顺序。 - 「哈希表 + 另一种结构」这套组合,后面的 LRU、LFU 缓存还会再用一次。 - - ), - }, - }, - { - lc: 299, - title: { en: "Bulls and Cows", zh: "猜数字游戏" }, - d: "medium", - tags: [ - { en: "Counting", zh: "计数" }, - { en: "Two passes / one pass", zh: "两遍/一遍" }, - ], - hint: { - en: "Bulls are easy: same position and same digit. For cows, compare the leftover digits on each side.", - zh: "公牛好数:同位置同数字。奶牛要看两边「没配上」的数字各剩多少。", - }, - key: { - en: ( - <> - First pass: if secret and guess have the same digit at the same - position, increase bulls. For every position that does not match, count - that digit separately for secret and for guess, using two arrays of - length 10. Second pass: for each digit 0 to 9, add min(cntS[d], - cntG[d]) to cows, because that many can be paired up. Time O(n), extra - space O(1). One-pass version: use a single counting array, add one for - each secret digit and subtract one for each guess digit; whenever a - count crosses zero in the opposite direction, one cow has been matched. - - ), - zh: ( - <> - 第一遍:同位置同数字 → bulls++;不匹配的位置,分别给 secret 和 guess - 的数字计数(两个长度 10 的计数数组)。第二遍:对每个数字 0–9, - cows += min(cntS[d], cntG[d]) —— 两边库存能配上的部分都是奶牛。 - O(n) 时间、O(1) 额外空间。一遍写法:只用一个计数数组,secret - 的数字 +1、guess 的数字 −1,某个计数越过 0 走向反方向时, - 就说明抵掉了一头奶牛。 - - ), - }, - }, -]; - export const QUIZ: QuizItem[] = [ { type: "multi", diff --git a/lib/heap-data.tsx b/lib/heap-problems.tsx similarity index 51% rename from lib/heap-data.tsx rename to lib/heap-problems.tsx index 00da098..4e9916c 100644 --- a/lib/heap-data.tsx +++ b/lib/heap-problems.tsx @@ -1,11 +1,11 @@ -// Chapter 9 · Heaps and priority queues — problem set and quiz data (English default / Chinese toggle). +// Chapter 9 · Heaps and priority queues — problem set (English default / Chinese toggle). +// The closing quiz is in lib/heap-quiz.tsx; /atlas imports only this file. // Problem-set arc: Top-K threshold heap (703/973/692) → repeatedly taking the extreme (1046/767) → // merging K sorted sources (378) → dual heaps (295/502). LC 295 is the centerpiece; its key covers it in depth. // // Problem titles use the official LeetCode English name; tags / hint / key are all { en, zh } pairs. import type { Problem } from "@/lib/problems"; -import type { QuizItem } from "@/lib/quiz"; export const PROBLEMS: Problem[] = [ { @@ -322,271 +322,3 @@ export const PROBLEMS: Problem[] = [ }, }, ]; - -export const QUIZ: QuizItem[] = [ - { - type: "multi", - q: { - en: "Which of these does a min-heap guarantee? (Select all that apply.)", - zh: "一个小根堆能保证以下哪些事?(多选)", - }, - opts: [ - { - en: "The root is the smallest value in the whole heap.", - zh: "堆顶是全场最小值", - }, - { - en: "Every parent is ≤ each of its children.", - zh: "每个父结点 ≤ 它的每个孩子", - }, - { en: "The left child is ≤ the right child.", zh: "左孩子 ≤ 右孩子" }, - { - en: "The underlying array is sorted from start to end.", - zh: "底层数组从头到尾是升序的", - }, - ], - correct: [0, 1], - missHint: { - en: "A heap promises two related things: the root holds the minimum, and every parent-child pair is ordered. You left one of them out.", - zh: "堆的承诺其实是相关的两条:堆顶是最小值,以及每一对父子有序。你漏掉了其中一条。", - }, - extraHint: { - en: "Siblings are not constrained at all, and the array only satisfies the heap order, not full sorted order. Neither C nor D is guaranteed, so leave both unselected.", - zh: "兄弟之间没有任何约束,底层数组也只满足「堆序」而不是「全序」。C 和 D 都无法保证,两项都不应选。", - }, - why: { - en: "A heap maintains one rule: parent ≤ child. The root being the minimum follows from that rule. Siblings in any order and an unsorted array are both perfectly legal. Fewer promises means cheaper maintenance: O(log n) per update instead of O(n log n) for a full sort.", - zh: "堆只维护一条规则:父 ≤ 子。「堆顶最小」是这条规则的推论。兄弟乱序、数组乱序都完全合法。承诺越少,维护越便宜:单次更新 O(log n),而不是全排序的 O(n log n)。", - }, - }, - { - type: "fill", - q: { - en: "A heap is stored in an array. What is the index of the parent of index 7?", - zh: "用数组存堆,下标 7 的结点,它父结点的下标是?", - }, - placeholder: { en: "Type an index…", zh: "输入下标…" }, - answers: ["3"], - hint: { - en: "parent = (i − 1) / 2 with integer division, so (7 − 1) / 2 = ?", - zh: "parent = (i − 1) / 2,整数除法向下取整:(7 − 1) / 2 = ?", - }, - why: { - en: "(7 − 1) / 2 = 3. Check it the other way: the children of 3 are 2 × 3 + 1 = 7 and 2 × 3 + 2 = 8. A complete binary tree numbered level by level has no gaps, so parent and child links do not need pointers. All three positions are computed from the index, the same idea as computing an array element's address.", - zh: "(7 − 1) / 2 = 3;反过来验证:3 的孩子是 2 × 3 + 1 = 7 和 2 × 3 + 2 = 8。完全二叉树按层编号没有空洞,所以父子关系不需要指针,三个位置全靠下标算出来 —— 和数组按下标算地址是同一个思想。", - }, - }, - { - type: "choice", - q: { - en: "push and pop are O(log n). What is the reason?", - zh: "push / pop 的复杂度是 O(log n),根本原因是?", - }, - opts: [ - { - en: "Sifting up or down follows one path between a leaf and the root, and a complete binary tree of n nodes has height ⌊log₂n⌋.", - zh: "上浮 / 下沉最多走一条根与叶之间的路径,而 n 个结点的完全二叉树高度是 ⌊log₂n⌋", - }, - { - en: "Each operation scans half of the array.", - zh: "每次操作要扫描一半的数组", - }, - { - en: "Each operation sorts the heap again.", - zh: "每次操作后要把堆重新排序", - }, - { - en: "It is really O(1), because only the root or the last slot changes.", - zh: "其实是 O(1),因为只动堆顶或堆尾", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Sifting follows a single chain: each comparison moves one level up or down. It never scans all the elements on a level.", - zh: "上浮 / 下沉走的是一条链:每比较一次就上或下一层,不会横着扫完某一层的所有元素。", - }, - { - en: "A heap never sorts itself. It only repairs the one parent-child chain that was broken and leaves everything else untouched.", - zh: "堆从不做全排序,它只修复被破坏的那一条父子链,其余部分原封不动。", - }, - { - en: "Writing into the last slot or reading the root is indeed O(1), but the heap order still has to be repaired afterwards, and that path is as long as the height of the tree.", - zh: "写进堆尾、读取堆顶本身确实是 O(1),但之后必须上浮 / 下沉恢复堆序,那条路径的长度就是树高。", - }, - ], - why: { - en: "A complete binary tree with n nodes has height ⌊log₂n⌋. Every swap moves the element one level, so the number of swaps is at most the height. All of the heap's speed comes from the tree being short.", - zh: "n 个结点的完全二叉树高度是 ⌊log₂n⌋,每次交换让元素上或下移一层,所以交换次数不超过树高。堆的全部效率都来自「完全二叉树够矮」。", - }, - }, - { - type: "choice", - q: { - en: "To find the k-th largest element of an array, which heap does the standard solution use?", - zh: "求数组中「第 K 大」的元素,标准堆解法用哪种堆?", - }, - opts: [ - { - en: "A min-heap of size k. Its root is the entry threshold, and a new value only enters if it is larger.", - zh: "容量 K 的小根堆 —— 堆顶是入围门槛,新数比门槛大才进", - }, - { en: "A max-heap of size k.", zh: "容量 K 的大根堆" }, - { - en: "Put every element into a max-heap, then pop k times.", - zh: "全部元素入大根堆,连续弹 K 次", - }, - { - en: "Put every element into a min-heap, then pop n − k times.", - zh: "全部元素入小根堆,连续弹 n−K 次", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "A max-heap of size k evicts the largest of the k it holds, so you would end up with the k smallest values. That direction solves the k smallest, not the k largest.", - zh: "容量 K 的大根堆每次淘汰的是「这 K 个里最大的」,最后剩下的是 K 个最小值 —— 方向反了。求前 K 小才用大根堆。", - }, - { - en: "This works, but every element goes into the heap: O(n) space and O(n + k log n) time. In a stream you cannot store everything, while the size-k heap needs only O(k) space.", - zh: "这样做能得到正确答案,但所有元素都入堆:空间 O(n),时间 O(n + K log n);数据流场景根本存不下全部,而容量 K 的方案只要 O(K) 空间。", - }, - { - en: "This also stores all n elements, so O(n) space, and popping n − k times costs more than maintaining a heap of size k.", - zh: "同样要把 n 个元素全部入堆,空间 O(n);弹 n−K 次也比维护容量 K 的堆更贵。", - }, - ], - why: { - en: "The heap holds the k largest values seen so far. When a better value arrives, the one to evict is the smallest of those k, so you need to read that smallest value at any moment, and that is a min-heap. The root is both the weakest survivor and the current answer for the k-th largest.", - zh: "堆里住着「迄今最大的 K 个」。有更强的新数进来时,该踢的是这 K 个里最小的,所以你要能随时读到这个最小值 —— 那就是小根堆。堆顶既是最弱的幸存者,也是当前的第 K 大。", - }, - }, - { - type: "choice", - q: { - en: "Up to Python 3.13, heapq is a min-heap only. What is the usual way to get a max-heap there?", - zh: "到 Python 3.13 为止,heapq 只有小根堆。在这些版本里想要大根堆,惯用技巧是?", - }, - opts: [ - { - en: "Store the negation of every value, and negate again when you pop.", - zh: "所有数取负存入,弹出时再取负还原", - }, - { - en: "Call heapq.maxheap() to switch mode.", - zh: "调用 heapq.maxheap() 切换模式", - }, - { - en: "Pass reverse=True to heappush.", - zh: "给 heappush 传 reverse=True", - }, - { - en: "Call sort(reverse=True) first, then use the list as a heap.", - zh: "先 sort(reverse=True) 再当堆用", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "There is no such function. heapq is a set of functions that operate on a plain list, not a class, and it has no max-heap switch.", - zh: "heapq 没有这个函数 —— 它是一组操作 list 的函数,不是类,更没有大根堆开关。", - }, - { - en: "reverse is a parameter of sorted and list.sort. heappush does not accept it.", - zh: "reverse 是 sorted / list.sort 的参数,heappush 不认识它。", - }, - { - en: "Sorting happens once. As soon as a new element is pushed the order is broken again, and sorting costs O(n log n) per update instead of O(log n).", - zh: "排序是一次性的:新元素进来后有序性立刻失效,而且每次更新 O(n log n) 比堆的 O(log n) 贵得多。", - }, - ], - why: { - en: "Negation maps the largest value to the smallest, so the min-heap does the work unchanged. For compound elements, negate the sort key inside a tuple, such as (-freq, word). Strings cannot be negated, so for a text key you need another approach, such as a wrapper class that defines its own comparison.", - zh: "取负把「最大」映射成「最小」,小根堆照常工作。复合元素则把排序键取负打包成元组,如 (-freq, word)。注意字符串不能取负 —— 那种情况要换办法,例如写一个自定义比较逻辑的包装类。", - }, - }, - { - type: "choice", - q: { - en: "You already have n elements in an array. What is the best way to turn them into a heap, and at what cost?", - zh: "把 n 个已有元素建成堆,最优做法和复杂度是?", - }, - opts: [ - { - en: "Sift down from the last internal node backwards to index 0 (Floyd's method), O(n).", - zh: "从最后一个父结点倒着逐个下沉(Floyd 建堆),O(n)", - }, - { - en: "Push them one by one into an empty heap, O(n log n), and that is already optimal.", - zh: "逐个 push 进空堆,O(n log n),这已是最优", - }, - { - en: "Sort the array first and use it directly as a heap, O(n log n).", - zh: "先排序再直接当堆用,O(n log n)", - }, - { en: "Any method is O(log n).", zh: "无论怎么做都是 O(log n)" }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Pushing one by one is correct and is O(n log n), but it is not the best. Sifting down from the bottom up saves the log factor.", - zh: "逐个 push 确实可行,也确实是 O(n log n),但不是最优 —— 自底向上下沉能省掉一个 log。", - }, - { - en: "A sorted ascending array is a valid min-heap, but sorting itself costs O(n log n), which is more work than the heap property requires.", - zh: "升序数组确实是合法的小根堆,但排序本身就要 O(n log n),做了远超堆序所需的工作。", - }, - { - en: "Just looking at each of the n elements once already costs O(n), so no method can be faster than O(n).", - zh: "光把 n 个元素各看一眼就要 O(n),总复杂度不可能低于 O(n)。", - }, - ], - why: { - en: "About half of the nodes are leaves and sift down 0 levels, about a quarter sift down at most 1 level, about an eighth at most 2, and so on. The nodes that could move far are the rare ones. The total is Σ d · n / 2^(d+1), which sums to at most n, so building the heap is O(n). Python's heapq.heapify does exactly this.", - zh: "大约一半的结点是叶子,下沉 0 步;上一层约 1/4 最多沉 1 步;再上一层约 1/8 最多沉 2 步……能沉得深的结点恰恰最稀少。总步数 Σ d · n / 2^(d+1) 加起来不超过 n,所以建堆是 O(n)。Python 的 heapq.heapify 用的就是这个方法。", - }, - }, - { - type: "choice", - q: { - en: "In Java, what order does for (int x : priorityQueue) produce?", - zh: "Java 里 for (int x : priorityQueue) 遍历,输出顺序是?", - }, - opts: [ - { - en: "No useful order is guaranteed. To read the elements in order you must call poll() repeatedly.", - zh: "不保证任何有用的顺序 —— 想要有序只能连续 poll()", - }, - { - en: "Ascending, since it is called a priority queue.", - zh: "从小到大(它毕竟叫优先队列)", - }, - { en: "Descending.", zh: "从大到小" }, - { en: "The same order the elements were added.", zh: "与插入顺序相同" }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "The iterator walks the backing array, and that array only satisfies the heap order: parents before children, with no rule between siblings or cousins. Printing [1, 3, 2, 7, 4] is completely normal.", - zh: "迭代器走的是底层数组,而数组只满足「堆序」:父子有序,兄弟和堂兄弟之间可以任意排列 —— 打印出 [1, 3, 2, 7, 4] 完全正常。", - }, - { - en: "Same reason: the array of a max-heap only guarantees the parent-child relation, not a descending sequence.", - zh: "同理,大根堆的数组同样只保证父子关系,不是降序序列。", - }, - { - en: "Insertion already triggers sift-up swaps, so the arrival order is destroyed as elements are added.", - zh: "元素入队时就经历了上浮交换,插入顺序早已被打乱。", - }, - ], - why: { - en: "The only promise a heap makes is that the root is the extreme value. To get a sorted sequence, call poll() in a loop, at O(log n) each. If you need ordered access all the time, a heap is the wrong structure; use a TreeMap or sort once. This is the most common mistake with PriorityQueue in practice.", - zh: "堆的承诺只有「堆顶是最值」。需要有序序列就循环 poll(),每次 O(log n)。如果你一直需要有序访问,那说明选错了结构 —— 该用 TreeMap 或一次性排序。这是 PriorityQueue 在实战中最容易出错的地方。", - }, - }, -]; diff --git a/lib/heap-quiz.tsx b/lib/heap-quiz.tsx new file mode 100644 index 0000000..1a90745 --- /dev/null +++ b/lib/heap-quiz.tsx @@ -0,0 +1,273 @@ +// Chapter 9 · Heaps and priority queues — closing quiz (the problem set is in lib/heap-problems.tsx). +// +// Bilingual: every question, option and explanation is a { en, zh } pair. + +import type { QuizItem } from "@/lib/quiz"; + +export const QUIZ: QuizItem[] = [ + { + type: "multi", + q: { + en: "Which of these does a min-heap guarantee? (Select all that apply.)", + zh: "一个小根堆能保证以下哪些事?(多选)", + }, + opts: [ + { + en: "The root is the smallest value in the whole heap.", + zh: "堆顶是全场最小值", + }, + { + en: "Every parent is ≤ each of its children.", + zh: "每个父结点 ≤ 它的每个孩子", + }, + { en: "The left child is ≤ the right child.", zh: "左孩子 ≤ 右孩子" }, + { + en: "The underlying array is sorted from start to end.", + zh: "底层数组从头到尾是升序的", + }, + ], + correct: [0, 1], + missHint: { + en: "A heap promises two related things: the root holds the minimum, and every parent-child pair is ordered. You left one of them out.", + zh: "堆的承诺其实是相关的两条:堆顶是最小值,以及每一对父子有序。你漏掉了其中一条。", + }, + extraHint: { + en: "Siblings are not constrained at all, and the array only satisfies the heap order, not full sorted order. Neither C nor D is guaranteed, so leave both unselected.", + zh: "兄弟之间没有任何约束,底层数组也只满足「堆序」而不是「全序」。C 和 D 都无法保证,两项都不应选。", + }, + why: { + en: "A heap maintains one rule: parent ≤ child. The root being the minimum follows from that rule. Siblings in any order and an unsorted array are both perfectly legal. Fewer promises means cheaper maintenance: O(log n) per update instead of O(n log n) for a full sort.", + zh: "堆只维护一条规则:父 ≤ 子。「堆顶最小」是这条规则的推论。兄弟乱序、数组乱序都完全合法。承诺越少,维护越便宜:单次更新 O(log n),而不是全排序的 O(n log n)。", + }, + }, + { + type: "fill", + q: { + en: "A heap is stored in an array. What is the index of the parent of index 7?", + zh: "用数组存堆,下标 7 的结点,它父结点的下标是?", + }, + placeholder: { en: "Type an index…", zh: "输入下标…" }, + answers: ["3"], + hint: { + en: "parent = (i − 1) / 2 with integer division, so (7 − 1) / 2 = ?", + zh: "parent = (i − 1) / 2,整数除法向下取整:(7 − 1) / 2 = ?", + }, + why: { + en: "(7 − 1) / 2 = 3. Check it the other way: the children of 3 are 2 × 3 + 1 = 7 and 2 × 3 + 2 = 8. A complete binary tree numbered level by level has no gaps, so parent and child links do not need pointers. All three positions are computed from the index, the same idea as computing an array element's address.", + zh: "(7 − 1) / 2 = 3;反过来验证:3 的孩子是 2 × 3 + 1 = 7 和 2 × 3 + 2 = 8。完全二叉树按层编号没有空洞,所以父子关系不需要指针,三个位置全靠下标算出来 —— 和数组按下标算地址是同一个思想。", + }, + }, + { + type: "choice", + q: { + en: "push and pop are O(log n). What is the reason?", + zh: "push / pop 的复杂度是 O(log n),根本原因是?", + }, + opts: [ + { + en: "Sifting up or down follows one path between a leaf and the root, and a complete binary tree of n nodes has height ⌊log₂n⌋.", + zh: "上浮 / 下沉最多走一条根与叶之间的路径,而 n 个结点的完全二叉树高度是 ⌊log₂n⌋", + }, + { + en: "Each operation scans half of the array.", + zh: "每次操作要扫描一半的数组", + }, + { + en: "Each operation sorts the heap again.", + zh: "每次操作后要把堆重新排序", + }, + { + en: "It is really O(1), because only the root or the last slot changes.", + zh: "其实是 O(1),因为只动堆顶或堆尾", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Sifting follows a single chain: each comparison moves one level up or down. It never scans all the elements on a level.", + zh: "上浮 / 下沉走的是一条链:每比较一次就上或下一层,不会横着扫完某一层的所有元素。", + }, + { + en: "A heap never sorts itself. It only repairs the one parent-child chain that was broken and leaves everything else untouched.", + zh: "堆从不做全排序,它只修复被破坏的那一条父子链,其余部分原封不动。", + }, + { + en: "Writing into the last slot or reading the root is indeed O(1), but the heap order still has to be repaired afterwards, and that path is as long as the height of the tree.", + zh: "写进堆尾、读取堆顶本身确实是 O(1),但之后必须上浮 / 下沉恢复堆序,那条路径的长度就是树高。", + }, + ], + why: { + en: "A complete binary tree with n nodes has height ⌊log₂n⌋. Every swap moves the element one level, so the number of swaps is at most the height. All of the heap's speed comes from the tree being short.", + zh: "n 个结点的完全二叉树高度是 ⌊log₂n⌋,每次交换让元素上或下移一层,所以交换次数不超过树高。堆的全部效率都来自「完全二叉树够矮」。", + }, + }, + { + type: "choice", + q: { + en: "To find the k-th largest element of an array, which heap does the standard solution use?", + zh: "求数组中「第 K 大」的元素,标准堆解法用哪种堆?", + }, + opts: [ + { + en: "A min-heap of size k. Its root is the entry threshold, and a new value only enters if it is larger.", + zh: "容量 K 的小根堆 —— 堆顶是入围门槛,新数比门槛大才进", + }, + { en: "A max-heap of size k.", zh: "容量 K 的大根堆" }, + { + en: "Put every element into a max-heap, then pop k times.", + zh: "全部元素入大根堆,连续弹 K 次", + }, + { + en: "Put every element into a min-heap, then pop n − k times.", + zh: "全部元素入小根堆,连续弹 n−K 次", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "A max-heap of size k evicts the largest of the k it holds, so you would end up with the k smallest values. That direction solves the k smallest, not the k largest.", + zh: "容量 K 的大根堆每次淘汰的是「这 K 个里最大的」,最后剩下的是 K 个最小值 —— 方向反了。求前 K 小才用大根堆。", + }, + { + en: "This works, but every element goes into the heap: O(n) space and O(n + k log n) time. In a stream you cannot store everything, while the size-k heap needs only O(k) space.", + zh: "这样做能得到正确答案,但所有元素都入堆:空间 O(n),时间 O(n + K log n);数据流场景根本存不下全部,而容量 K 的方案只要 O(K) 空间。", + }, + { + en: "This also stores all n elements, so O(n) space, and popping n − k times costs more than maintaining a heap of size k.", + zh: "同样要把 n 个元素全部入堆,空间 O(n);弹 n−K 次也比维护容量 K 的堆更贵。", + }, + ], + why: { + en: "The heap holds the k largest values seen so far. When a better value arrives, the one to evict is the smallest of those k, so you need to read that smallest value at any moment, and that is a min-heap. The root is both the weakest survivor and the current answer for the k-th largest.", + zh: "堆里住着「迄今最大的 K 个」。有更强的新数进来时,该踢的是这 K 个里最小的,所以你要能随时读到这个最小值 —— 那就是小根堆。堆顶既是最弱的幸存者,也是当前的第 K 大。", + }, + }, + { + type: "choice", + q: { + en: "Up to Python 3.13, heapq is a min-heap only. What is the usual way to get a max-heap there?", + zh: "到 Python 3.13 为止,heapq 只有小根堆。在这些版本里想要大根堆,惯用技巧是?", + }, + opts: [ + { + en: "Store the negation of every value, and negate again when you pop.", + zh: "所有数取负存入,弹出时再取负还原", + }, + { + en: "Call heapq.maxheap() to switch mode.", + zh: "调用 heapq.maxheap() 切换模式", + }, + { + en: "Pass reverse=True to heappush.", + zh: "给 heappush 传 reverse=True", + }, + { + en: "Call sort(reverse=True) first, then use the list as a heap.", + zh: "先 sort(reverse=True) 再当堆用", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "There is no such function. heapq is a set of functions that operate on a plain list, not a class, and it has no max-heap switch.", + zh: "heapq 没有这个函数 —— 它是一组操作 list 的函数,不是类,更没有大根堆开关。", + }, + { + en: "reverse is a parameter of sorted and list.sort. heappush does not accept it.", + zh: "reverse 是 sorted / list.sort 的参数,heappush 不认识它。", + }, + { + en: "Sorting happens once. As soon as a new element is pushed the order is broken again, and sorting costs O(n log n) per update instead of O(log n).", + zh: "排序是一次性的:新元素进来后有序性立刻失效,而且每次更新 O(n log n) 比堆的 O(log n) 贵得多。", + }, + ], + why: { + en: "Negation maps the largest value to the smallest, so the min-heap does the work unchanged. For compound elements, negate the sort key inside a tuple, such as (-freq, word). Strings cannot be negated, so for a text key you need another approach, such as a wrapper class that defines its own comparison.", + zh: "取负把「最大」映射成「最小」,小根堆照常工作。复合元素则把排序键取负打包成元组,如 (-freq, word)。注意字符串不能取负 —— 那种情况要换办法,例如写一个自定义比较逻辑的包装类。", + }, + }, + { + type: "choice", + q: { + en: "You already have n elements in an array. What is the best way to turn them into a heap, and at what cost?", + zh: "把 n 个已有元素建成堆,最优做法和复杂度是?", + }, + opts: [ + { + en: "Sift down from the last internal node backwards to index 0 (Floyd's method), O(n).", + zh: "从最后一个父结点倒着逐个下沉(Floyd 建堆),O(n)", + }, + { + en: "Push them one by one into an empty heap, O(n log n), and that is already optimal.", + zh: "逐个 push 进空堆,O(n log n),这已是最优", + }, + { + en: "Sort the array first and use it directly as a heap, O(n log n).", + zh: "先排序再直接当堆用,O(n log n)", + }, + { en: "Any method is O(log n).", zh: "无论怎么做都是 O(log n)" }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Pushing one by one is correct and is O(n log n), but it is not the best. Sifting down from the bottom up saves the log factor.", + zh: "逐个 push 确实可行,也确实是 O(n log n),但不是最优 —— 自底向上下沉能省掉一个 log。", + }, + { + en: "A sorted ascending array is a valid min-heap, but sorting itself costs O(n log n), which is more work than the heap property requires.", + zh: "升序数组确实是合法的小根堆,但排序本身就要 O(n log n),做了远超堆序所需的工作。", + }, + { + en: "Just looking at each of the n elements once already costs O(n), so no method can be faster than O(n).", + zh: "光把 n 个元素各看一眼就要 O(n),总复杂度不可能低于 O(n)。", + }, + ], + why: { + en: "About half of the nodes are leaves and sift down 0 levels, about a quarter sift down at most 1 level, about an eighth at most 2, and so on. The nodes that could move far are the rare ones. The total is Σ d · n / 2^(d+1), which sums to at most n, so building the heap is O(n). Python's heapq.heapify does exactly this.", + zh: "大约一半的结点是叶子,下沉 0 步;上一层约 1/4 最多沉 1 步;再上一层约 1/8 最多沉 2 步……能沉得深的结点恰恰最稀少。总步数 Σ d · n / 2^(d+1) 加起来不超过 n,所以建堆是 O(n)。Python 的 heapq.heapify 用的就是这个方法。", + }, + }, + { + type: "choice", + q: { + en: "In Java, what order does for (int x : priorityQueue) produce?", + zh: "Java 里 for (int x : priorityQueue) 遍历,输出顺序是?", + }, + opts: [ + { + en: "No useful order is guaranteed. To read the elements in order you must call poll() repeatedly.", + zh: "不保证任何有用的顺序 —— 想要有序只能连续 poll()", + }, + { + en: "Ascending, since it is called a priority queue.", + zh: "从小到大(它毕竟叫优先队列)", + }, + { en: "Descending.", zh: "从大到小" }, + { en: "The same order the elements were added.", zh: "与插入顺序相同" }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "The iterator walks the backing array, and that array only satisfies the heap order: parents before children, with no rule between siblings or cousins. Printing [1, 3, 2, 7, 4] is completely normal.", + zh: "迭代器走的是底层数组,而数组只满足「堆序」:父子有序,兄弟和堂兄弟之间可以任意排列 —— 打印出 [1, 3, 2, 7, 4] 完全正常。", + }, + { + en: "Same reason: the array of a max-heap only guarantees the parent-child relation, not a descending sequence.", + zh: "同理,大根堆的数组同样只保证父子关系,不是降序序列。", + }, + { + en: "Insertion already triggers sift-up swaps, so the arrival order is destroyed as elements are added.", + zh: "元素入队时就经历了上浮交换,插入顺序早已被打乱。", + }, + ], + why: { + en: "The only promise a heap makes is that the root is the extreme value. To get a sorted sequence, call poll() in a loop, at O(log n) each. If you need ordered access all the time, a heap is the wrong structure; use a TreeMap or sort once. This is the most common mistake with PriorityQueue in practice.", + zh: "堆的承诺只有「堆顶是最值」。需要有序序列就循环 poll(),每次 O(log n)。如果你一直需要有序访问,那说明选错了结构 —— 该用 TreeMap 或一次性排序。这是 PriorityQueue 在实战中最容易出错的地方。", + }, + }, +]; diff --git a/lib/linked-list-data.tsx b/lib/linked-list-problems.tsx similarity index 50% rename from lib/linked-list-data.tsx rename to lib/linked-list-problems.tsx index ca01bc9..e59d15e 100644 --- a/lib/linked-list-data.tsx +++ b/lib/linked-list-problems.tsx @@ -1,10 +1,10 @@ -// Chapter 3 · Linked lists — problem set and quiz data (English default / Chinese toggle). +// Chapter 3 · Linked lists — problem set (English default / Chinese toggle). +// The closing quiz is in lib/linked-list-quiz.tsx; /atlas imports only this file. // Problems cover deletion/traversal, fast-and-slow pointers, dummy sentinels, reversal, and doubly // linked list synthesis, ramping from Easy to Hard; // hint points a direction without spoilers, key explains the optimal solution in one paragraph. import type { Problem } from "@/lib/problems"; -import type { QuizItem } from "@/lib/quiz"; export const PROBLEMS: Problem[] = [ { @@ -400,297 +400,3 @@ export const PROBLEMS: Problem[] = [ }, }, ]; - -export const QUIZ: QuizItem[] = [ - { - type: "choice", - q: { - en: "Under what condition is it true that inserting into or deleting from a linked list is O(1)?", - zh: "「链表插入 / 删除是 O(1)」这句话,完整的前提是什么?", - }, - opts: [ - { - en: "You already hold a reference to the node before the position. Finding that node can itself take O(n).", - zh: "你已经持有该位置前驱节点的引用 —— 找到这个前驱本身可能要 O(n)", - }, - { - en: "It always holds. Inserting and deleting in a linked list is simply faster than in an array.", - zh: "无条件成立,链表插删就是比数组快", - }, - { - en: "The linked list has to be sorted.", - zh: "链表必须是有序的", - }, - { - en: "It only holds while the list is shorter than 100 nodes.", - zh: "只在链表长度小于 100 时成立", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Inserting at position i costs O(n) in total: walking to node i-1 is O(n), and only the pointer writes are O(1). The fast part is the rewiring, not the search.", - zh: "「在位置 i 插入」整体其实是 O(n):走到第 i−1 个节点花 O(n),只有改指针那一步是 O(1)。快的是改指针,不是找位置。", - }, - { - en: "Order does not change the cost of insertion or deletion. And a sorted linked list still cannot be binary searched, because there is no random access.", - zh: "有序与否不影响插删成本。而且链表就算有序也没法二分 —— 它没有随机访问。", - }, - { - en: "Complexity describes how cost grows with size. It is not a statement about any particular length.", - zh: "复杂度描述的是成本随规模增长的趋势,与具体长度无关。", - }, - ], - why: { - en: "Insertion and deletion are two parts: find the predecessor, then rewrite pointers. The rewriting is always O(1); the search is usually O(n). So a linked list wins when the reference is already in your hand, for example in an LRU cache where a hash map hands you the node directly.", - zh: "插删由两部分组成:找前驱、改指针。改指针恒为 O(1),找前驱一般是 O(n)。所以链表真正占优的场景,是「引用本来就在手上」—— 比如 LRU 缓存里,哈希表直接把节点递给你。", - }, - }, - { - type: "choice", - q: { - en: "You are inserting a new node between prev and cur. What is the correct order of the two pointer writes?", - zh: "要在 prev 和 cur 之间插入新节点 node,两次指针写入的正确顺序是?", - }, - opts: [ - { - en: "node.next = cur first (connect), then prev.next = node (disconnect).", - zh: "先 node.next = cur(先接),再 prev.next = node(后断)", - }, - { - en: "prev.next = node first, then node.next = cur.", - zh: "先 prev.next = node,再 node.next = cur", - }, - { - en: "The order does not matter, the result is the same.", - zh: "两步顺序无所谓,结果一样", - }, - { - en: "Only prev.next = node is needed.", - zh: "只需要 prev.next = node 一步", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Once prev.next points at node, nothing refers to cur any more, so the rest of the list is unreachable unless you saved cur in a variable first. Connect before you disconnect.", - zh: "prev.next 一旦改指向 node,就再没有任何引用记得 cur,后半条链变得不可达 —— 除非你提前把 cur 存在变量里。默认写法必须先接后断。", - }, - { - en: "The order decides whether the list survives. Reversed, everything from cur onwards is lost. The Wrong order button in the lab above shows it happening.", - zh: "顺序恰恰决定了链表的死活:反过来会弄丢 cur 开始的整个后半段。上面实验室的「反面教材」按钮演示的就是它。", - }, - { - en: "If you only write prev.next, node.next is still null, so the list ends at node and everything after it is lost.", - zh: "只改 prev.next 的话,node.next 还是 null,链表在 node 处断头,后面全丢。", - }, - ], - why: { - en: "Connect before you disconnect. The new node takes hold of the rest of the list first (node.next = cur), so nothing is lost at any point, and only then does prev switch to the new node. Reversed, every node from cur onwards loses its last reference.", - zh: "口诀是「先接后断」:新节点先牵住后半条链(node.next = cur),全程不丢任何东西;然后 prev 才改挂新节点。顺序反了,cur 起的所有节点都会失去最后一个引用。", - }, - }, - { - type: "choice", - q: { - en: "Cycle detection: once both pointers are inside the cycle, why must the fast pointer meet the slow one instead of stepping over it?", - zh: "快慢指针判环:两者都进环之后,为什么 fast 一定会遇上 slow,而不会「跳过去」?", - }, - opts: [ - { - en: "Fast gains exactly one node on slow per step, so the distance between them shrinks by exactly 1 each step and must reach 0.", - zh: "fast 每步比 slow 多走 1 格,两者的距离每步恰好缩小 1,必然减到 0", - }, - { - en: "Because the length of a cycle is always even.", - zh: "因为环的长度一定是偶数", - }, - { - en: "They meet by luck; the algorithm fails with small probability.", - zh: "靠运气才相遇,算法有小概率失败", - }, - { - en: "Because slow stops and waits for fast.", - zh: "因为 slow 会停下来等 fast", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "The cycle can have any length. What matters is that the relative speed is 1, so the gap passes through every value down to 0 and cannot jump over it.", - zh: "环长可以是任意值(奇偶都行)。关键在于相对速度是 1:距离一格一格地减,不可能「隔着 1 格互相穿过」。", - }, - { - en: "This is a deterministic algorithm. The distance is a whole number that decreases by 1 every step, so 0 is unavoidable.", - zh: "这是确定性算法:距离是一个整数,每步减 1,必然经过 0,没有任何概率成分。", - }, - { - en: "Both pointers move on every step. The meeting comes from the difference in speed, not from one of them waiting.", - zh: "两个指针每一轮都在动。相遇靠的是速度差,不是谁等谁。", - }, - ], - why: { - en: "Measure the distance from fast forward to slow along the cycle. Fast moves 2 and slow moves 1, so that distance drops by exactly 1 per step. It is a whole number that cannot become negative, so it reaches 0, and 0 means both pointers are on the same node. Slow meets fast in fewer steps than one full lap. If fast moved 3 nodes per step the distance would drop by 2 and could pass over 0, so this simple argument would no longer work.", - zh: "沿着环,量「从 fast 往前走到 slow」的距离:fast 每步走 2、slow 每步走 1,这个距离每步恰好减 1。它是一个不会变成负数的整数,所以必然减到 0,而 0 就意味着两个指针停在同一个节点上。slow 进环后不到一圈就会相遇。如果 fast 每步走 3 格,距离每步减 2,就可能跨过 0,这条简洁的论证便不再成立。", - }, - }, - { - type: "multi", - q: { - en: "In which situations should you prefer an array over a linked list? (Select all that apply)", - zh: "以下哪些场景应该优先选「数组」而不是链表?(多选)", - }, - opts: [ - { - en: "You need frequent random access by index.", - zh: "需要大量按下标随机访问", - }, - { - en: "Sequential traversal is performance sensitive and you want the CPU cache to help.", - zh: "顺序遍历的性能敏感,希望 CPU 缓存帮上忙", - }, - { - en: "You already hold a node reference and insert or delete next to it constantly.", - zh: "手里拿着节点引用,要在它旁边频繁插入 / 删除", - }, - { - en: "The number of elements is basically fixed and insertions and deletions are rare.", - zh: "元素总量基本固定,很少插删", - }, - ], - correct: [0, 1, 3], - missHint: { - en: "An array wins in three ways: index arithmetic, contiguous memory the cache can prefetch, and no shifting when the size is stable. Count the options again.", - zh: "数组的三个主场:下标直达、连续内存(缓存可以预取)、规模稳定时没有搬家成本。对照选项再数一遍。", - }, - extraHint: { - en: "Inserting or deleting next to a node you already hold is the one case where a linked list clearly wins. An array would move O(n) elements, so this option does not belong.", - zh: "「拿着节点引用就地插删」正是链表明显胜出的唯一场景 —— 数组要搬 O(n) 个元素,所以这一项不能选。", - }, - why: { - en: "An array stores elements contiguously: index access is O(1), traversal is cache-friendly, and a stable size means nothing ever shifts. A linked list wins on O(1) insertion and deletion at a position you already hold, and on not needing one large contiguous block. Both traverse in O(n); the cache difference is a constant factor, but a large one.", - zh: "数组连续存放:下标访问 O(1),顺序遍历对缓存友好,规模稳定时也不会有搬家成本。链表赢在「位置已知时的 O(1) 插删」,以及不需要一大块连续内存。两者遍历都是 O(n),缓存带来的是常数因子上的差距 —— 只是这个常数不小。", - }, - }, - { - type: "fill", - q: { - en: ( - <> - Java's LinkedList is a doubly linked list. What is - the time complexity of list.get(i)? (Answer in O(...) - form) - - ), - zh: ( - <> - Java 的 LinkedList 是双向链表,那么{" "} - list.get(i) 的时间复杂度是?(用 O(…) 作答) - - ), - }, - placeholder: { en: "For example O(1)…", zh: "输入复杂度,如 O(1)…" }, - answers: ["O(n)", "o(n)", "On"], - hint: { - en: "A linked list has no address formula. get(i) walks node by node from the head, or from the tail if that end is closer.", - zh: "链表没有地址公式 —— get(i) 只能从头(或从更近的尾)一个 next 一个 next 地走过去。", - }, - why: { - en: "LinkedList.get(i) is O(n), because it walks to position i one node at a time. The classic accident: looping with for (int i = 0; i < list.size(); i++) list.get(i) over a LinkedList costs O(n^2). Traverse it with a for-each loop or an iterator instead, or use ArrayList in the first place.", - zh: "LinkedList.get(i) 是 O(n):它要一个节点一个节点地走到位置 i。经典事故是用 for (int i = 0; i < list.size(); i++) list.get(i) 遍历 LinkedList,总成本 O(n²)。遍历它要用 for-each 或迭代器 —— 或者一开始就选 ArrayList。", - }, - }, - { - type: "choice", - q: { - en: "What does a dummy (sentinel) node actually do?", - zh: "dummy(哑结点 / 哨兵)技巧真正解决的是什么?", - }, - opts: [ - { - en: "It gives the head a predecessor, so every node can be reached through some node's next field and the head needs no special case.", - zh: "让头节点也有前驱,于是每个节点都能通过某个节点的 next 访问到,头部特判随之消失", - }, - { - en: "It makes access to the list faster.", - zh: "提升链表的访问速度", - }, - { - en: "It saves memory.", - zh: "省内存", - }, - { - en: "It prevents the list from forming a cycle.", - zh: "防止链表成环", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "A dummy changes no complexity at all. It changes the shape of the code: one branch fewer, one class of bug fewer.", - zh: "dummy 不改变任何复杂度 —— 它改变的是代码形状:少一个分支,少一类 bug。", - }, - { - en: "It costs one extra node. That node buys you a whole category of boundary bugs removed, which is usually a good trade.", - zh: "它反而要多花一个节点的内存 —— 用一个节点换掉一整类边界 bug,通常很划算。", - }, - { - en: "A dummy has nothing to do with cycles. Cycle detection is what fast and slow pointers are for.", - zh: "dummy 和环没有关系;判环靠的是快慢指针。", - }, - ], - why: { - en: "The head is special because nothing points at it: deleting it means assigning to the head variable, and inserting before it means the same, so both need their own branch. A dummy node placed in front of the head gives it a predecessor, so one uniform 'operate on cur.next' loop handles every position. Return dummy.next at the end. LC 203, 19, 21, and 25 all become shorter this way.", - zh: "头节点特殊,是因为没有任何节点指向它:删它、在它前面插入,都得直接给 head 变量赋值,于是各需要一个分支。dummy 站到 head 前面,头节点就有了前驱,一套「操作 cur.next」的循环就能处理所有位置,最后返回 dummy.next。LC 203、19、21、25 都因此变短。", - }, - }, - { - type: "choice", - q: { - en: "There are two ways to find the middle of a list: (1) count the length n, then walk n/2 steps; (2) fast and slow pointers in one pass. Which statement is correct?", - zh: "找链表中点有两种做法:① 先遍历数出长度 n,再走 n/2 步;② 快慢指针一次遍历。哪个说法正确?", - }, - opts: [ - { - en: "Both are O(n). Fast and slow pointers need only one loop and no length up front, and the same template also works on a list that may contain a cycle.", - zh: "两种都是 O(n);快慢指针只需一个循环、不必先知道长度,同一模板还能用于可能有环的链表", - }, - { - en: "Fast and slow pointers are O(log n); counting the length is O(n).", - zh: "快慢指针是 O(log n),数长度是 O(n)", - }, - { - en: "Counting the length is wrong; it does not give the middle.", - zh: "数长度法是错的,得不到中点", - }, - { - en: "Fast and slow pointers take half as many steps in total.", - zh: "快慢指针的总步数只有数长度法的一半", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Nothing here halves the remaining work: slow takes n/2 steps while fast takes n. Both are linear. A linked list offers no O(log n) way to reach a position.", - zh: "这里没有任何「每次减半」的结构:slow 走 n/2 步,fast 走 n 步,都是线性的。链表上不存在 O(log n) 的定位方式。", - }, - { - en: "Counting the length works perfectly well. It is two loops of O(n) each, and it is arguably easier to get right. It just reads the list twice.", - zh: "数长度法完全正确,两个循环各 O(n),写起来甚至更不容易错 —— 只是要把链表读两遍。", - }, - { - en: "Add it up: fast and slow take n/2 + n = 1.5n pointer moves, counting takes n + n/2 = 1.5n. They tie. The difference is the number of loops, not the number of steps.", - zh: "算总账:快慢指针 n/2 + n = 1.5n 步,数长度法 n + n/2 = 1.5n 步 —— 打平。区别在循环的个数,不在步数。", - }, - ], - why: { - en: "Both are correct and both are O(n), and the total number of pointer moves is 1.5n either way. Fast and slow pointers win because they need one loop and no length in advance, and because the same template also handles a list that may contain a cycle, where counting the length would never finish, as well as palindrome checking. That is why it is the expected answer in an interview.", - zh: "两种都对、都是 O(n),总的指针移动次数也都是 1.5n。快慢指针的优势在于只需一个循环、不必事先知道长度,而且同一套模板还能处理可能有环的链表(有环时「先数长度」根本不会结束)和回文链表。这就是它成为面试默认答案的原因。", - }, - }, -]; diff --git a/lib/linked-list-quiz.tsx b/lib/linked-list-quiz.tsx new file mode 100644 index 0000000..7f1c4e7 --- /dev/null +++ b/lib/linked-list-quiz.tsx @@ -0,0 +1,299 @@ +// Chapter 3 · Linked lists — closing quiz (the problem set is in lib/linked-list-problems.tsx). +// +// Bilingual: every question, option and explanation is a { en, zh } pair. + +import type { QuizItem } from "@/lib/quiz"; + +export const QUIZ: QuizItem[] = [ + { + type: "choice", + q: { + en: "Under what condition is it true that inserting into or deleting from a linked list is O(1)?", + zh: "「链表插入 / 删除是 O(1)」这句话,完整的前提是什么?", + }, + opts: [ + { + en: "You already hold a reference to the node before the position. Finding that node can itself take O(n).", + zh: "你已经持有该位置前驱节点的引用 —— 找到这个前驱本身可能要 O(n)", + }, + { + en: "It always holds. Inserting and deleting in a linked list is simply faster than in an array.", + zh: "无条件成立,链表插删就是比数组快", + }, + { + en: "The linked list has to be sorted.", + zh: "链表必须是有序的", + }, + { + en: "It only holds while the list is shorter than 100 nodes.", + zh: "只在链表长度小于 100 时成立", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Inserting at position i costs O(n) in total: walking to node i-1 is O(n), and only the pointer writes are O(1). The fast part is the rewiring, not the search.", + zh: "「在位置 i 插入」整体其实是 O(n):走到第 i−1 个节点花 O(n),只有改指针那一步是 O(1)。快的是改指针,不是找位置。", + }, + { + en: "Order does not change the cost of insertion or deletion. And a sorted linked list still cannot be binary searched, because there is no random access.", + zh: "有序与否不影响插删成本。而且链表就算有序也没法二分 —— 它没有随机访问。", + }, + { + en: "Complexity describes how cost grows with size. It is not a statement about any particular length.", + zh: "复杂度描述的是成本随规模增长的趋势,与具体长度无关。", + }, + ], + why: { + en: "Insertion and deletion are two parts: find the predecessor, then rewrite pointers. The rewriting is always O(1); the search is usually O(n). So a linked list wins when the reference is already in your hand, for example in an LRU cache where a hash map hands you the node directly.", + zh: "插删由两部分组成:找前驱、改指针。改指针恒为 O(1),找前驱一般是 O(n)。所以链表真正占优的场景,是「引用本来就在手上」—— 比如 LRU 缓存里,哈希表直接把节点递给你。", + }, + }, + { + type: "choice", + q: { + en: "You are inserting a new node between prev and cur. What is the correct order of the two pointer writes?", + zh: "要在 prev 和 cur 之间插入新节点 node,两次指针写入的正确顺序是?", + }, + opts: [ + { + en: "node.next = cur first (connect), then prev.next = node (disconnect).", + zh: "先 node.next = cur(先接),再 prev.next = node(后断)", + }, + { + en: "prev.next = node first, then node.next = cur.", + zh: "先 prev.next = node,再 node.next = cur", + }, + { + en: "The order does not matter, the result is the same.", + zh: "两步顺序无所谓,结果一样", + }, + { + en: "Only prev.next = node is needed.", + zh: "只需要 prev.next = node 一步", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Once prev.next points at node, nothing refers to cur any more, so the rest of the list is unreachable unless you saved cur in a variable first. Connect before you disconnect.", + zh: "prev.next 一旦改指向 node,就再没有任何引用记得 cur,后半条链变得不可达 —— 除非你提前把 cur 存在变量里。默认写法必须先接后断。", + }, + { + en: "The order decides whether the list survives. Reversed, everything from cur onwards is lost. The Wrong order button in the lab above shows it happening.", + zh: "顺序恰恰决定了链表的死活:反过来会弄丢 cur 开始的整个后半段。上面实验室的「反面教材」按钮演示的就是它。", + }, + { + en: "If you only write prev.next, node.next is still null, so the list ends at node and everything after it is lost.", + zh: "只改 prev.next 的话,node.next 还是 null,链表在 node 处断头,后面全丢。", + }, + ], + why: { + en: "Connect before you disconnect. The new node takes hold of the rest of the list first (node.next = cur), so nothing is lost at any point, and only then does prev switch to the new node. Reversed, every node from cur onwards loses its last reference.", + zh: "口诀是「先接后断」:新节点先牵住后半条链(node.next = cur),全程不丢任何东西;然后 prev 才改挂新节点。顺序反了,cur 起的所有节点都会失去最后一个引用。", + }, + }, + { + type: "choice", + q: { + en: "Cycle detection: once both pointers are inside the cycle, why must the fast pointer meet the slow one instead of stepping over it?", + zh: "快慢指针判环:两者都进环之后,为什么 fast 一定会遇上 slow,而不会「跳过去」?", + }, + opts: [ + { + en: "Fast gains exactly one node on slow per step, so the distance between them shrinks by exactly 1 each step and must reach 0.", + zh: "fast 每步比 slow 多走 1 格,两者的距离每步恰好缩小 1,必然减到 0", + }, + { + en: "Because the length of a cycle is always even.", + zh: "因为环的长度一定是偶数", + }, + { + en: "They meet by luck; the algorithm fails with small probability.", + zh: "靠运气才相遇,算法有小概率失败", + }, + { + en: "Because slow stops and waits for fast.", + zh: "因为 slow 会停下来等 fast", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "The cycle can have any length. What matters is that the relative speed is 1, so the gap passes through every value down to 0 and cannot jump over it.", + zh: "环长可以是任意值(奇偶都行)。关键在于相对速度是 1:距离一格一格地减,不可能「隔着 1 格互相穿过」。", + }, + { + en: "This is a deterministic algorithm. The distance is a whole number that decreases by 1 every step, so 0 is unavoidable.", + zh: "这是确定性算法:距离是一个整数,每步减 1,必然经过 0,没有任何概率成分。", + }, + { + en: "Both pointers move on every step. The meeting comes from the difference in speed, not from one of them waiting.", + zh: "两个指针每一轮都在动。相遇靠的是速度差,不是谁等谁。", + }, + ], + why: { + en: "Measure the distance from fast forward to slow along the cycle. Fast moves 2 and slow moves 1, so that distance drops by exactly 1 per step. It is a whole number that cannot become negative, so it reaches 0, and 0 means both pointers are on the same node. Slow meets fast in fewer steps than one full lap. If fast moved 3 nodes per step the distance would drop by 2 and could pass over 0, so this simple argument would no longer work.", + zh: "沿着环,量「从 fast 往前走到 slow」的距离:fast 每步走 2、slow 每步走 1,这个距离每步恰好减 1。它是一个不会变成负数的整数,所以必然减到 0,而 0 就意味着两个指针停在同一个节点上。slow 进环后不到一圈就会相遇。如果 fast 每步走 3 格,距离每步减 2,就可能跨过 0,这条简洁的论证便不再成立。", + }, + }, + { + type: "multi", + q: { + en: "In which situations should you prefer an array over a linked list? (Select all that apply)", + zh: "以下哪些场景应该优先选「数组」而不是链表?(多选)", + }, + opts: [ + { + en: "You need frequent random access by index.", + zh: "需要大量按下标随机访问", + }, + { + en: "Sequential traversal is performance sensitive and you want the CPU cache to help.", + zh: "顺序遍历的性能敏感,希望 CPU 缓存帮上忙", + }, + { + en: "You already hold a node reference and insert or delete next to it constantly.", + zh: "手里拿着节点引用,要在它旁边频繁插入 / 删除", + }, + { + en: "The number of elements is basically fixed and insertions and deletions are rare.", + zh: "元素总量基本固定,很少插删", + }, + ], + correct: [0, 1, 3], + missHint: { + en: "An array wins in three ways: index arithmetic, contiguous memory the cache can prefetch, and no shifting when the size is stable. Count the options again.", + zh: "数组的三个主场:下标直达、连续内存(缓存可以预取)、规模稳定时没有搬家成本。对照选项再数一遍。", + }, + extraHint: { + en: "Inserting or deleting next to a node you already hold is the one case where a linked list clearly wins. An array would move O(n) elements, so this option does not belong.", + zh: "「拿着节点引用就地插删」正是链表明显胜出的唯一场景 —— 数组要搬 O(n) 个元素,所以这一项不能选。", + }, + why: { + en: "An array stores elements contiguously: index access is O(1), traversal is cache-friendly, and a stable size means nothing ever shifts. A linked list wins on O(1) insertion and deletion at a position you already hold, and on not needing one large contiguous block. Both traverse in O(n); the cache difference is a constant factor, but a large one.", + zh: "数组连续存放:下标访问 O(1),顺序遍历对缓存友好,规模稳定时也不会有搬家成本。链表赢在「位置已知时的 O(1) 插删」,以及不需要一大块连续内存。两者遍历都是 O(n),缓存带来的是常数因子上的差距 —— 只是这个常数不小。", + }, + }, + { + type: "fill", + q: { + en: ( + <> + Java's LinkedList is a doubly linked list. What is + the time complexity of list.get(i)? (Answer in O(...) + form) + + ), + zh: ( + <> + Java 的 LinkedList 是双向链表,那么{" "} + list.get(i) 的时间复杂度是?(用 O(…) 作答) + + ), + }, + placeholder: { en: "For example O(1)…", zh: "输入复杂度,如 O(1)…" }, + answers: ["O(n)", "o(n)", "On"], + hint: { + en: "A linked list has no address formula. get(i) walks node by node from the head, or from the tail if that end is closer.", + zh: "链表没有地址公式 —— get(i) 只能从头(或从更近的尾)一个 next 一个 next 地走过去。", + }, + why: { + en: "LinkedList.get(i) is O(n), because it walks to position i one node at a time. The classic accident: looping with for (int i = 0; i < list.size(); i++) list.get(i) over a LinkedList costs O(n^2). Traverse it with a for-each loop or an iterator instead, or use ArrayList in the first place.", + zh: "LinkedList.get(i) 是 O(n):它要一个节点一个节点地走到位置 i。经典事故是用 for (int i = 0; i < list.size(); i++) list.get(i) 遍历 LinkedList,总成本 O(n²)。遍历它要用 for-each 或迭代器 —— 或者一开始就选 ArrayList。", + }, + }, + { + type: "choice", + q: { + en: "What does a dummy (sentinel) node actually do?", + zh: "dummy(哑结点 / 哨兵)技巧真正解决的是什么?", + }, + opts: [ + { + en: "It gives the head a predecessor, so every node can be reached through some node's next field and the head needs no special case.", + zh: "让头节点也有前驱,于是每个节点都能通过某个节点的 next 访问到,头部特判随之消失", + }, + { + en: "It makes access to the list faster.", + zh: "提升链表的访问速度", + }, + { + en: "It saves memory.", + zh: "省内存", + }, + { + en: "It prevents the list from forming a cycle.", + zh: "防止链表成环", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "A dummy changes no complexity at all. It changes the shape of the code: one branch fewer, one class of bug fewer.", + zh: "dummy 不改变任何复杂度 —— 它改变的是代码形状:少一个分支,少一类 bug。", + }, + { + en: "It costs one extra node. That node buys you a whole category of boundary bugs removed, which is usually a good trade.", + zh: "它反而要多花一个节点的内存 —— 用一个节点换掉一整类边界 bug,通常很划算。", + }, + { + en: "A dummy has nothing to do with cycles. Cycle detection is what fast and slow pointers are for.", + zh: "dummy 和环没有关系;判环靠的是快慢指针。", + }, + ], + why: { + en: "The head is special because nothing points at it: deleting it means assigning to the head variable, and inserting before it means the same, so both need their own branch. A dummy node placed in front of the head gives it a predecessor, so one uniform 'operate on cur.next' loop handles every position. Return dummy.next at the end. LC 203, 19, 21, and 25 all become shorter this way.", + zh: "头节点特殊,是因为没有任何节点指向它:删它、在它前面插入,都得直接给 head 变量赋值,于是各需要一个分支。dummy 站到 head 前面,头节点就有了前驱,一套「操作 cur.next」的循环就能处理所有位置,最后返回 dummy.next。LC 203、19、21、25 都因此变短。", + }, + }, + { + type: "choice", + q: { + en: "There are two ways to find the middle of a list: (1) count the length n, then walk n/2 steps; (2) fast and slow pointers in one pass. Which statement is correct?", + zh: "找链表中点有两种做法:① 先遍历数出长度 n,再走 n/2 步;② 快慢指针一次遍历。哪个说法正确?", + }, + opts: [ + { + en: "Both are O(n). Fast and slow pointers need only one loop and no length up front, and the same template also works on a list that may contain a cycle.", + zh: "两种都是 O(n);快慢指针只需一个循环、不必先知道长度,同一模板还能用于可能有环的链表", + }, + { + en: "Fast and slow pointers are O(log n); counting the length is O(n).", + zh: "快慢指针是 O(log n),数长度是 O(n)", + }, + { + en: "Counting the length is wrong; it does not give the middle.", + zh: "数长度法是错的,得不到中点", + }, + { + en: "Fast and slow pointers take half as many steps in total.", + zh: "快慢指针的总步数只有数长度法的一半", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Nothing here halves the remaining work: slow takes n/2 steps while fast takes n. Both are linear. A linked list offers no O(log n) way to reach a position.", + zh: "这里没有任何「每次减半」的结构:slow 走 n/2 步,fast 走 n 步,都是线性的。链表上不存在 O(log n) 的定位方式。", + }, + { + en: "Counting the length works perfectly well. It is two loops of O(n) each, and it is arguably easier to get right. It just reads the list twice.", + zh: "数长度法完全正确,两个循环各 O(n),写起来甚至更不容易错 —— 只是要把链表读两遍。", + }, + { + en: "Add it up: fast and slow take n/2 + n = 1.5n pointer moves, counting takes n + n/2 = 1.5n. They tie. The difference is the number of loops, not the number of steps.", + zh: "算总账:快慢指针 n/2 + n = 1.5n 步,数长度法 n + n/2 = 1.5n 步 —— 打平。区别在循环的个数,不在步数。", + }, + ], + why: { + en: "Both are correct and both are O(n), and the total number of pointer moves is 1.5n either way. Fast and slow pointers win because they need one loop and no length in advance, and because the same template also handles a list that may contain a cycle, where counting the length would never finish, as well as palindrome checking. That is why it is the expected answer in an interview.", + zh: "两种都对、都是 O(n),总的指针移动次数也都是 1.5n。快慢指针的优势在于只需一个循环、不必事先知道长度,而且同一套模板还能处理可能有环的链表(有环时「先数长度」根本不会结束)和回文链表。这就是它成为面试默认答案的原因。", + }, + }, +]; diff --git a/lib/queue-problems.tsx b/lib/queue-problems.tsx new file mode 100644 index 0000000..ff9ec94 --- /dev/null +++ b/lib/queue-problems.tsx @@ -0,0 +1,292 @@ +// Chapter 5 · Queues and deques — problem set (English default / Chinese toggle). +// The closing quiz is in lib/queue-quiz.tsx; /atlas imports only this file. +// Problems ramp from plain simulation up to a "prefix sum + monotonic queue" Hard; hint points a +// direction only, key explains the optimal solution in one paragraph. +// Note: LC 346 (Moving Average from Data Stream) in the original set is premium-only and has been +// replaced by 641 / 946. +// +// Bilingual: title / tags / hint / key are all written as { en, zh }, +// and the en side of each problem title uses the official LeetCode English name. + +import type { Problem } from "@/lib/problems"; + +export const PROBLEMS: Problem[] = [ + { + lc: 933, + title: { en: "Number of Recent Calls", zh: "最近的请求次数" }, + d: "easy", + tags: [ + { en: "Queue", zh: "队列" }, + { en: "Time window", zh: "时间窗口" }, + ], + hint: { + en: "Only requests from the last 3000 ms count. Which end do expired requests leave from, and which end do new requests enter?", + zh: "只关心最近 3000ms 内的请求 —— 过期的请求从哪一端离开?新请求从哪一端进来?", + }, + key: { + en: ( + <> + Add the new timestamp at the back. Then remove timestamps smaller than + t − 3000 from the front, one at a time. The length of the queue is the + answer. Timestamps arrive in increasing order, so the expired ones are + always the oldest and always sit at the front. Each request enters and + leaves once, so a call costs O(1) amortized. This is the smallest + possible example of a sliding time window. + + ), + zh: ( + <> + 新时间戳从队尾入队;然后把小于 t − 3000 的时间戳从队头逐个出队; + 队列长度就是答案。时间戳单调递增,所以过期的一定最老、一定聚在队头。 + 每个请求进出队各一次,单次调用均摊 O(1)。这是「时间滑动窗口」的最小模型。 + + ), + }, + }, + { + lc: 225, + title: { en: "Implement Stack using Queues", zh: "用队列实现栈" }, + d: "easy", + tags: [ + { en: "Queue", zh: "队列" }, + { en: "Rotation", zh: "旋转" }, + ], + hint: { + en: "A new element joins at the back. How do you rotate it to the front so that it becomes the next one to leave?", + zh: "新元素入队后排在队尾 —— 怎么让它「转」到队头,变成下一个出队的?", + }, + key: { + en: ( + <> + On push, add the new element at the back, then dequeue the n − 1 + elements in front of it and enqueue them again. The queue rotates once + and the new element ends up at the front, so pop becomes an ordinary + dequeue. push is O(n) and pop is O(1). This problem is the mirror + image of walkthrough A (LC 232); solving both makes the difference + between the two orders clear. + + ), + zh: ( + <> + push 时先把新元素入队,再把它前面的 n − 1 个元素依次出队、重新入队: + 队列旋转一圈,新元素恰好转到队头 → pop 就是普通出队。push O(n)、pop + O(1)。它与本章精讲 A(LC 232)互为镜像,一起做最能看清两种顺序的差别。 + + ), + }, + }, + { + lc: 622, + title: { en: "Design Circular Queue", zh: "设计循环队列" }, + d: "medium", + tags: [ + { en: "Circular queue", zh: "循环队列" }, + { en: "Modulo", zh: "取模" }, + ], + hint: { + en: "The implementation in §04 of this chapter is exactly this problem. Write it yourself first, and go back only if you get stuck.", + zh: "本章 §04 的手写实现就是这道题 —— 先自己写,卡住了再回去看。", + }, + key: { + en: ( + <> + A fixed-length array plus a front and a rear index. Every step forward + is (i + 1) % cap, so the index wraps back to 0 at the end + of the array. Pick one of the two ways to tell full from empty: keep + one slot empty (allocate k + 1 slots; full when (rear + 1) % cap == + front), or track a size counter. Every operation is O(1) and no + element ever moves. + + ), + zh: ( + <> + 定长数组 + front / rear 两个下标,前进一律 (i + 1) % cap{" "} + 绕回开头。满 / 空判定二选一:留一格空(开 k + 1 格, + (rear+1)%cap==front 即满),或维护 size 计数器。所有操作 O(1), + 没有任何元素需要移动。 + + ), + }, + }, + { + lc: 641, + title: { en: "Design Circular Deque", zh: "设计循环双端队列" }, + d: "medium", + tags: [ + { en: "Circular queue", zh: "循环队列" }, + { en: "Deque", zh: "deque" }, + ], + hint: { + en: "LC 622 with both ends open. How do you move an index one step backward without producing a negative value?", + zh: "622 的加强版:两端都要能进出。下标往「后退」一步,怎么算才不会变成负数?", + }, + key: { + en: ( + <> + Add the two missing directions to LC 622: inserting at the front moves + front back one step and then writes, and deleting at the back moves + rear back one step. The standard way to step backward is{" "} + (i - 1 + cap) % cap — add cap before taking the + remainder, because in Java and JavaScript % returns a + negative result for a negative left operand. Finishing this problem + means you have implemented the core of ArrayDeque. + + ), + zh: ( + <> + 在 622 基础上补两个方向:队头插入 = front 先退一步再写,队尾删除 = + rear 先退一步。后退的标准写法是 (i − 1 + cap) % cap —— + 先加 cap 再取模,因为 Java / JavaScript 的 %{" "} + 在被除数为负时结果也为负。写完它,就等于自行实现了 ArrayDeque + 的核心逻辑。 + + ), + }, + }, + { + lc: 946, + title: { en: "Validate Stack Sequences", zh: "验证栈序列" }, + d: "medium", + tags: [ + { en: "Stack", zh: "栈" }, + { en: "Simulation", zh: "模拟" }, + { en: "Greedy", zh: "贪心" }, + ], + hint: { + en: "Do not reason about it, run it. Push in the order given by pushed, pop whenever you can, and see whether the whole sequence plays out.", + zh: "别推理,直接演:按 pushed 顺序真的往栈里压,能弹就弹,看最后能不能演完。", + }, + key: { + en: ( + <> + Simulate greedily. Push the elements of pushed one by one. After each + push, while the top equals the current element of popped, pop it and + advance the popped pointer. The sequence is valid if and only if the + stack is empty at the end. Why is popping as early as possible + correct? If you could pop an element and do not, later pushes bury it + deeper, and it can never be popped at the right moment again. Time + O(n), space O(n), and a good review of the previous chapter. + + ), + zh: ( + <> + 贪心模拟:按 pushed 依次入栈;每次入栈后,只要栈顶等于 popped + 的当前元素就一直弹并推进 popped 指针。结束时栈空 ⇔ 序列合法。 + 为什么「能弹就弹」是对的?能弹却不弹,后续入栈会把它压得更深, + 此后再也没有机会在正确的时刻弹出。时间 O(n)、空间 O(n), + 也是上一章栈的复习题。 + + ), + }, + }, + { + lc: 649, + title: { en: "Dota2 Senate", zh: "Dota2 参议院" }, + d: "medium", + tags: [ + { en: "Queue", zh: "队列" }, + { en: "Greedy", zh: "贪心" }, + { en: "Round-based", zh: "循环处理" }, + ], + hint: { + en: "The best move for each senator is to ban the opponent who acts next. Rounds, and whoever comes first acts first — which structure keeps that order?", + zh: "每个议员的最优策略是禁掉「下一个即将行动的对手」。回合制 + 先来先行动 → 用什么结构排班?", + }, + key: { + en: ( + <> + Keep two queues holding the indices of the R senators and of + the D senators. Each round, compare the two front indices. The smaller + one acts first and survives, and it is enqueued again with index + n, + which means it will act again in the next round. The larger one is + banned and simply leaves. Repeat until one queue is empty. The queue + keeps the order of who acts next without any extra bookkeeping. O(n). + + ), + zh: ( + <> + 两个队列分别存 R、D 议员的下标:每轮取两队队头比较, + 下标小者先行动 → 存活,并以「下标 + n」重新入队(表示下一轮再来); + 下标大者被禁言,直接出队消失。直到一方清空。 + 「下一个行动者」的顺序完全由队列维持,不需要额外记录。O(n)。 + + ), + }, + }, + { + lc: 1438, + title: { + en: "Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit", + zh: "绝对差不超过限制的最长连续子数组", + }, + d: "medium", + tags: [ + { en: "Sliding window", zh: "滑动窗口" }, + { en: "Two monotonic deques", zh: "双单调队列" }, + ], + hint: { + en: "The window is valid when max − min ≤ limit. A decreasing deque gives the window maximum. What gives the minimum?", + zh: "窗口合法 ⇔ 窗口内 max − min ≤ limit。滑窗最大值用递减队列 —— 最小值呢?", + }, + key: { + en: ( + <> + A sliding window with two monotonic deques. The decreasing deque gives + the window maximum, and an increasing deque gives the window minimum. + After extending the right end, shrink the left end while max − min + > limit, and remove from the front of both deques any index that + has left the window. This is LC 239 with two orders kept at once over + the same window. O(n). + + ), + zh: ( + <> + 滑动窗口 + 两个单调队列:递减队列随时给出窗口 max,递增队列给出窗口 + min。右端扩张后,只要 max − min > limit 就收缩左端,同时把已经 + 离开窗口的下标从两个队头弹掉。它就是 LC 239 + 的直接升级:同一个窗口,同时维护两套单调性。O(n)。 + + ), + }, + }, + { + lc: 862, + title: { + en: "Shortest Subarray with Sum at Least K", + zh: "和至少为 K 的最短子数组", + }, + d: "hard", + tags: [ + { en: "Prefix sum", zh: "前缀和" }, + { en: "Monotonic deque", zh: "单调队列" }, + ], + hint: { + en: "The array may contain negative numbers, so the window sum does not grow as the window grows and a plain sliding window fails. Turn it into prefix sums first: find P[r] − P[l] ≥ K with r − l as small as possible.", + zh: "数组含负数,窗口和不随窗口变长而变大,普通滑窗失效。先转成前缀和:找 P[r] − P[l] ≥ K 且 r − l 最短。", + }, + key: { + en: ( + <> + Keep an increasing monotonic deque of prefix-sum indices. At + each r: (1) while the front satisfies P[r] − P[front] ≥ K, record the + length and remove the front, because that left endpoint has + already found its shortest partner and any later r would only give a + longer subarray; (2) remove from the back every index with P[back] ≥ + P[r], because P[r] is both smaller and further right, so those indices + can never be a better left endpoint again. The two rules use the two + ends of the deque, which is exactly why this needs a deque and not a + queue. O(n). + + ), + zh: ( + <> + 对前缀和 P 维护递增单调队列。遍历 r 时:①只要队头满足 P[r] − + P[队头] ≥ K,就记录长度并弹出队头 —— + 这个左端点已经配到最短的搭档,再往后只会更长;②把队尾所有 P[队尾] ≥ + P[r] 的下标弹掉 —— P[r] 既更小又更靠右,它们不可能再成为更好的左端点。 + 两条弹出规则正好用到 deque 的两端,这就是它必须是双端队列的原因。O(n)。 + + ), + }, + }, +]; diff --git a/lib/queue-data.tsx b/lib/queue-quiz.tsx similarity index 56% rename from lib/queue-data.tsx rename to lib/queue-quiz.tsx index 6190976..184a497 100644 --- a/lib/queue-data.tsx +++ b/lib/queue-quiz.tsx @@ -1,296 +1,9 @@ -// Chapter 5 · Queues and deques — problem set and quiz data (English default / Chinese toggle). -// Problems ramp from plain simulation up to a "prefix sum + monotonic queue" Hard; hint points a -// direction only, key explains the optimal solution in one paragraph. -// Note: LC 346 (Moving Average from Data Stream) in the original set is premium-only and has been -// replaced by 641 / 946. +// Chapter 5 · Queues and deques — closing quiz (the problem set is in lib/queue-problems.tsx). // -// Bilingual: title / tags / hint / key / q / opts / why are all written as { en, zh }, -// and the en side of each problem title uses the official LeetCode English name. +// Bilingual: every question, option and explanation is a { en, zh } pair. -import type { Problem } from "@/lib/problems"; import type { QuizItem } from "@/lib/quiz"; -export const PROBLEMS: Problem[] = [ - { - lc: 933, - title: { en: "Number of Recent Calls", zh: "最近的请求次数" }, - d: "easy", - tags: [ - { en: "Queue", zh: "队列" }, - { en: "Time window", zh: "时间窗口" }, - ], - hint: { - en: "Only requests from the last 3000 ms count. Which end do expired requests leave from, and which end do new requests enter?", - zh: "只关心最近 3000ms 内的请求 —— 过期的请求从哪一端离开?新请求从哪一端进来?", - }, - key: { - en: ( - <> - Add the new timestamp at the back. Then remove timestamps smaller than - t − 3000 from the front, one at a time. The length of the queue is the - answer. Timestamps arrive in increasing order, so the expired ones are - always the oldest and always sit at the front. Each request enters and - leaves once, so a call costs O(1) amortized. This is the smallest - possible example of a sliding time window. - - ), - zh: ( - <> - 新时间戳从队尾入队;然后把小于 t − 3000 的时间戳从队头逐个出队; - 队列长度就是答案。时间戳单调递增,所以过期的一定最老、一定聚在队头。 - 每个请求进出队各一次,单次调用均摊 O(1)。这是「时间滑动窗口」的最小模型。 - - ), - }, - }, - { - lc: 225, - title: { en: "Implement Stack using Queues", zh: "用队列实现栈" }, - d: "easy", - tags: [ - { en: "Queue", zh: "队列" }, - { en: "Rotation", zh: "旋转" }, - ], - hint: { - en: "A new element joins at the back. How do you rotate it to the front so that it becomes the next one to leave?", - zh: "新元素入队后排在队尾 —— 怎么让它「转」到队头,变成下一个出队的?", - }, - key: { - en: ( - <> - On push, add the new element at the back, then dequeue the n − 1 - elements in front of it and enqueue them again. The queue rotates once - and the new element ends up at the front, so pop becomes an ordinary - dequeue. push is O(n) and pop is O(1). This problem is the mirror - image of walkthrough A (LC 232); solving both makes the difference - between the two orders clear. - - ), - zh: ( - <> - push 时先把新元素入队,再把它前面的 n − 1 个元素依次出队、重新入队: - 队列旋转一圈,新元素恰好转到队头 → pop 就是普通出队。push O(n)、pop - O(1)。它与本章精讲 A(LC 232)互为镜像,一起做最能看清两种顺序的差别。 - - ), - }, - }, - { - lc: 622, - title: { en: "Design Circular Queue", zh: "设计循环队列" }, - d: "medium", - tags: [ - { en: "Circular queue", zh: "循环队列" }, - { en: "Modulo", zh: "取模" }, - ], - hint: { - en: "The implementation in §04 of this chapter is exactly this problem. Write it yourself first, and go back only if you get stuck.", - zh: "本章 §04 的手写实现就是这道题 —— 先自己写,卡住了再回去看。", - }, - key: { - en: ( - <> - A fixed-length array plus a front and a rear index. Every step forward - is (i + 1) % cap, so the index wraps back to 0 at the end - of the array. Pick one of the two ways to tell full from empty: keep - one slot empty (allocate k + 1 slots; full when (rear + 1) % cap == - front), or track a size counter. Every operation is O(1) and no - element ever moves. - - ), - zh: ( - <> - 定长数组 + front / rear 两个下标,前进一律 (i + 1) % cap{" "} - 绕回开头。满 / 空判定二选一:留一格空(开 k + 1 格, - (rear+1)%cap==front 即满),或维护 size 计数器。所有操作 O(1), - 没有任何元素需要移动。 - - ), - }, - }, - { - lc: 641, - title: { en: "Design Circular Deque", zh: "设计循环双端队列" }, - d: "medium", - tags: [ - { en: "Circular queue", zh: "循环队列" }, - { en: "Deque", zh: "deque" }, - ], - hint: { - en: "LC 622 with both ends open. How do you move an index one step backward without producing a negative value?", - zh: "622 的加强版:两端都要能进出。下标往「后退」一步,怎么算才不会变成负数?", - }, - key: { - en: ( - <> - Add the two missing directions to LC 622: inserting at the front moves - front back one step and then writes, and deleting at the back moves - rear back one step. The standard way to step backward is{" "} - (i - 1 + cap) % cap — add cap before taking the - remainder, because in Java and JavaScript % returns a - negative result for a negative left operand. Finishing this problem - means you have implemented the core of ArrayDeque. - - ), - zh: ( - <> - 在 622 基础上补两个方向:队头插入 = front 先退一步再写,队尾删除 = - rear 先退一步。后退的标准写法是 (i − 1 + cap) % cap —— - 先加 cap 再取模,因为 Java / JavaScript 的 %{" "} - 在被除数为负时结果也为负。写完它,就等于自行实现了 ArrayDeque - 的核心逻辑。 - - ), - }, - }, - { - lc: 946, - title: { en: "Validate Stack Sequences", zh: "验证栈序列" }, - d: "medium", - tags: [ - { en: "Stack", zh: "栈" }, - { en: "Simulation", zh: "模拟" }, - { en: "Greedy", zh: "贪心" }, - ], - hint: { - en: "Do not reason about it, run it. Push in the order given by pushed, pop whenever you can, and see whether the whole sequence plays out.", - zh: "别推理,直接演:按 pushed 顺序真的往栈里压,能弹就弹,看最后能不能演完。", - }, - key: { - en: ( - <> - Simulate greedily. Push the elements of pushed one by one. After each - push, while the top equals the current element of popped, pop it and - advance the popped pointer. The sequence is valid if and only if the - stack is empty at the end. Why is popping as early as possible - correct? If you could pop an element and do not, later pushes bury it - deeper, and it can never be popped at the right moment again. Time - O(n), space O(n), and a good review of the previous chapter. - - ), - zh: ( - <> - 贪心模拟:按 pushed 依次入栈;每次入栈后,只要栈顶等于 popped - 的当前元素就一直弹并推进 popped 指针。结束时栈空 ⇔ 序列合法。 - 为什么「能弹就弹」是对的?能弹却不弹,后续入栈会把它压得更深, - 此后再也没有机会在正确的时刻弹出。时间 O(n)、空间 O(n), - 也是上一章栈的复习题。 - - ), - }, - }, - { - lc: 649, - title: { en: "Dota2 Senate", zh: "Dota2 参议院" }, - d: "medium", - tags: [ - { en: "Queue", zh: "队列" }, - { en: "Greedy", zh: "贪心" }, - { en: "Round-based", zh: "循环处理" }, - ], - hint: { - en: "The best move for each senator is to ban the opponent who acts next. Rounds, and whoever comes first acts first — which structure keeps that order?", - zh: "每个议员的最优策略是禁掉「下一个即将行动的对手」。回合制 + 先来先行动 → 用什么结构排班?", - }, - key: { - en: ( - <> - Keep two queues holding the indices of the R senators and of - the D senators. Each round, compare the two front indices. The smaller - one acts first and survives, and it is enqueued again with index + n, - which means it will act again in the next round. The larger one is - banned and simply leaves. Repeat until one queue is empty. The queue - keeps the order of who acts next without any extra bookkeeping. O(n). - - ), - zh: ( - <> - 两个队列分别存 R、D 议员的下标:每轮取两队队头比较, - 下标小者先行动 → 存活,并以「下标 + n」重新入队(表示下一轮再来); - 下标大者被禁言,直接出队消失。直到一方清空。 - 「下一个行动者」的顺序完全由队列维持,不需要额外记录。O(n)。 - - ), - }, - }, - { - lc: 1438, - title: { - en: "Longest Continuous Subarray With Absolute Diff Less Than or Equal to Limit", - zh: "绝对差不超过限制的最长连续子数组", - }, - d: "medium", - tags: [ - { en: "Sliding window", zh: "滑动窗口" }, - { en: "Two monotonic deques", zh: "双单调队列" }, - ], - hint: { - en: "The window is valid when max − min ≤ limit. A decreasing deque gives the window maximum. What gives the minimum?", - zh: "窗口合法 ⇔ 窗口内 max − min ≤ limit。滑窗最大值用递减队列 —— 最小值呢?", - }, - key: { - en: ( - <> - A sliding window with two monotonic deques. The decreasing deque gives - the window maximum, and an increasing deque gives the window minimum. - After extending the right end, shrink the left end while max − min - > limit, and remove from the front of both deques any index that - has left the window. This is LC 239 with two orders kept at once over - the same window. O(n). - - ), - zh: ( - <> - 滑动窗口 + 两个单调队列:递减队列随时给出窗口 max,递增队列给出窗口 - min。右端扩张后,只要 max − min > limit 就收缩左端,同时把已经 - 离开窗口的下标从两个队头弹掉。它就是 LC 239 - 的直接升级:同一个窗口,同时维护两套单调性。O(n)。 - - ), - }, - }, - { - lc: 862, - title: { - en: "Shortest Subarray with Sum at Least K", - zh: "和至少为 K 的最短子数组", - }, - d: "hard", - tags: [ - { en: "Prefix sum", zh: "前缀和" }, - { en: "Monotonic deque", zh: "单调队列" }, - ], - hint: { - en: "The array may contain negative numbers, so the window sum does not grow as the window grows and a plain sliding window fails. Turn it into prefix sums first: find P[r] − P[l] ≥ K with r − l as small as possible.", - zh: "数组含负数,窗口和不随窗口变长而变大,普通滑窗失效。先转成前缀和:找 P[r] − P[l] ≥ K 且 r − l 最短。", - }, - key: { - en: ( - <> - Keep an increasing monotonic deque of prefix-sum indices. At - each r: (1) while the front satisfies P[r] − P[front] ≥ K, record the - length and remove the front, because that left endpoint has - already found its shortest partner and any later r would only give a - longer subarray; (2) remove from the back every index with P[back] ≥ - P[r], because P[r] is both smaller and further right, so those indices - can never be a better left endpoint again. The two rules use the two - ends of the deque, which is exactly why this needs a deque and not a - queue. O(n). - - ), - zh: ( - <> - 对前缀和 P 维护递增单调队列。遍历 r 时:①只要队头满足 P[r] − - P[队头] ≥ K,就记录长度并弹出队头 —— - 这个左端点已经配到最短的搭档,再往后只会更长;②把队尾所有 P[队尾] ≥ - P[r] 的下标弹掉 —— P[r] 既更小又更靠右,它们不可能再成为更好的左端点。 - 两条弹出规则正好用到 deque 的两端,这就是它必须是双端队列的原因。O(n)。 - - ), - }, - }, -]; - export const QUIZ: QuizItem[] = [ { type: "choice", diff --git a/lib/stack-problems.tsx b/lib/stack-problems.tsx new file mode 100644 index 0000000..48faf2f --- /dev/null +++ b/lib/stack-problems.tsx @@ -0,0 +1,350 @@ +// Chapter 4 · Stacks — problem set (English default / Chinese toggle). +// The closing quiz is in lib/stack-quiz.tsx; /atlas imports only this file. +// Problems ramp from pairwise cancellation up to monotonic stack Hards; hint points a direction +// without spoilers, key explains the optimal solution in one paragraph. + +import type { Problem } from "@/lib/problems"; + +export const PROBLEMS: Problem[] = [ + { + lc: 1047, + title: { + en: "Remove All Adjacent Duplicates In String", + zh: "删除字符串中的所有相邻重复项", + }, + d: "easy", + tags: [ + { en: "Stack", zh: "栈" }, + { en: "Pair cancellation", zh: "配对消除" }, + ], + hint: { + en: "A character cancels with the one most recently kept. Which structure gives you the most recent item in O(1)?", + zh: "当前字符只和「最近留下的那个」抵消。哪种结构能 O(1) 拿到「最近的」?", + }, + key: { + en: ( + <> + Push characters one by one. If the current character equals the top of + the stack, pop instead of pushing: that pair is removed. Otherwise + push it. What remains in the stack at the end is the answer. After a + pop, the element below becomes the new top, so it can cancel with the + next character without any extra code. Time O(n), space O(n). + + ), + zh: ( + <> + 逐字符处理:当前字符与栈顶相同就弹栈(这一对被消除),否则入栈; + 扫完后栈里剩下的就是答案。弹栈后下面那个元素自动成为新栈顶, + 可以直接和后面的字符继续配对 —— 连锁消除不需要任何额外代码。 + 时间 O(n),空间 O(n)。 + + ), + }, + }, + { + lc: 232, + title: { en: "Implement Queue using Stacks", zh: "用栈实现队列" }, + d: "easy", + tags: [ + { en: "Two stacks", zh: "双栈" }, + { en: "Amortized analysis", zh: "均摊分析" }, + ], + hint: { + en: "One stack reverses the order of the elements. What does a second reversal give you?", + zh: "一个栈会把顺序反过来 —— 那再反一次呢?", + }, + key: { + en: ( + <> + The in stack only receives pushes. The out{" "} + stack answers pop and peek. Move everything from in to{" "} + out only when out is empty. Two reversals + restore the original arrival order. Each element is moved at most four + times in its life (into in, out of in, into{" "} + out, out of out), so each operation is O(1) + amortized. The queue chapter walks through this frame by frame. + + ), + zh: ( + <> + in 栈只收 push,out 栈负责 pop / peek; + 只有 out 空了才把 in 整体倒过去。 + 两次反转恰好恢复先来后到。每个元素一生最多被搬 4 次 + (进 in、出 in、进 out、出{" "} + out),所以单次操作均摊 O(1)。队列章有逐帧动画和完整推导。 + + ), + }, + }, + { + lc: 225, + title: { en: "Implement Stack using Queues", zh: "用队列实现栈" }, + d: "easy", + tags: [ + { en: "Queue", zh: "队列" }, + { en: "Rotation", zh: "旋转" }, + ], + hint: { + en: "A new element joins at the back of the queue. Can you rotate the queue so it ends up at the front?", + zh: "新元素入队后排在最后 —— 有没有办法让它「转」到队头去?", + }, + key: { + en: ( + <> + The single-queue version: on push, enqueue the new element, then + dequeue and re-enqueue the n−1 elements in front of it. The queue + rotates by one full turn and the new element ends up at the front. Now + pop is just a dequeue. Push is O(n) and pop is O(1) — the mirror image + of LC 232, so solve the two together. + + ), + zh: ( + <> + 单队列做法:push 时先把新元素入队,再把它前面的 n−1 个元素依次出队、 + 重新入队 —— 队列被旋转一整圈,新元素恰好转到队头。于是 pop 就是一次出队。 + push O(n)、pop O(1),和 LC 232 互为镜像,放在一起做最有感觉。 + + ), + }, + }, + { + lc: 682, + title: { en: "Baseball Game", zh: "棒球比赛" }, + d: "easy", + tags: [ + { en: "Stack", zh: "栈" }, + { en: "Simulation", zh: "模拟" }, + ], + hint: { + en: "Every operation only refers to the one or two most recent valid scores. Where do you keep the most recent records?", + zh: "每条指令都只关心「最近的一两次有效得分」—— 用什么存最近的记录?", + }, + key: { + en: ( + <> + Keep the valid scores in a stack. A number is pushed directly.{" "} + + pushes the sum of the top two. D pushes + twice the top. C pops the top. Sum the stack at the end. + It is a pure simulation, and the point of it is the reflex: “the + most recent records” means a stack. Time O(n), space O(n). + + ), + zh: ( + <> + 用栈存有效得分:数字直接入栈;+ 把栈顶两项之和入栈; + D 把栈顶 ×2 入栈;C 弹掉栈顶。最后求和。 + 纯模拟题,练的是「要最近的记录 → 用栈」这个条件反射。 + 时间 O(n),空间 O(n)。 + + ), + }, + }, + { + lc: 150, + title: { + en: "Evaluate Reverse Polish Notation", + zh: "逆波兰表达式求值", + }, + d: "medium", + tags: [ + { en: "Stack", zh: "栈" }, + { en: "Expression evaluation", zh: "表达式求值" }, + ], + hint: { + en: "In postfix notation an operator always comes after its two operands. Where are the two most recent numbers waiting?", + zh: "后缀表达式里,运算符永远跟在它的两个操作数后面 —— 「最近的两个数」在哪里等你?", + }, + key: { + en: ( + <> + Push numbers. On an operator, pop two values, compute, and push the + result back. Watch the order: the value you pop first is the right + operand, which matters for subtraction and division. Division + truncates toward zero. Postfix needs no parentheses and no precedence + rules, which is exactly why compilers and calculators evaluate with a + stack. Time O(n), space O(n). + + ), + zh: ( + <> + 数字入栈;遇到运算符弹出两个数,算完把结果压回去。注意弹出顺序: + 先弹出的是右操作数,减法和除法会因此出错;除法要向零取整。 + 后缀表达式不需要括号、不需要优先级,这正是编译器和计算器用栈求值的原因。 + 时间 O(n),空间 O(n)。 + + ), + }, + }, + { + lc: 394, + title: { en: "Decode String", zh: "字符串解码" }, + d: "medium", + tags: [ + { en: "Stack", zh: "栈" }, + { en: "Nested structure", zh: "嵌套结构" }, + ], + hint: { + en: "In 3[a2[c]] the inner bracket must be decoded first. Innermost first is another way of saying most recent first.", + zh: "3[a2[c]] —— 嵌套的括号,里层必须先解码。「里层优先」换个说法就是「最近的优先」。", + }, + key: { + en: ( + <> + On [, push the string built so far together with the + current repeat count, then start a fresh empty string. On{" "} + ], pop that pair and set{" "} + cur = prev + k * cur. The stack holds one unfinished + piece of work per nesting level, which is the same thing a call stack + holds. This problem can also be written with recursion, and the two + versions translate into each other line by line. Time O(n). + + ), + zh: ( + <> + 遇到 [ 时把「当前已拼好的字符串 + 当前倍数」打包压栈, + 然后清空重新开始;遇到 ] 弹栈, + cur = prev + k × cur。栈里每一层存的是一份未完成的工作, + 和调用栈保存的东西是同一类。本题用递归写也行,两种写法可以逐行互译。 + 时间 O(n)。 + + ), + }, + }, + { + lc: 496, + title: { en: "Next Greater Element I", zh: "下一个更大元素 I" }, + d: "easy", + tags: [ + { en: "Monotonic stack", zh: "单调栈" }, + { en: "Hash table", zh: "哈希表" }, + ], + hint: { + en: "Precompute the next greater element for every value in nums2, then answer the questions from nums1 by lookup.", + zh: "先对 nums2 把每个元素的「下一个更大」全求出来存好,再回答 nums1 的提问。", + }, + key: { + en: ( + <> + Run one monotonic stack pass over nums2. When a new value + is greater than the top, pop and record{" "} + popped value → new value in a hash table. Then look up + each element of nums1. Time O(n + m). This is the same + machinery as LC 739 in a simpler setting, so solve it first. + + ), + zh: ( + <> + 对 nums2 跑一遍单调栈:新元素比栈顶大就弹栈, + 并在哈希表里记下「被弹出的值 → 弹它的值」;最后 nums1{" "} + 逐个查表。时间 O(n + m)。它和 LC 739 是同一套机制的简化版,建议先做它。 + + ), + }, + }, + { + lc: 503, + title: { en: "Next Greater Element II", zh: "下一个更大元素 II" }, + d: "medium", + tags: [ + { en: "Monotonic stack", zh: "单调栈" }, + { en: "Circular array", zh: "循环数组" }, + ], + hint: { + en: "The array is circular, so the next element after the last one is the first one. Try walking the array twice without copying it.", + zh: "数组是环形的,最后一个元素的「下一个」会绕回开头 —— 试试不复制数组、直接走两圈。", + }, + key: { + en: ( + <> + The standard trick for circular arrays: let i run from 0 + to 2n−1 and index with i % n. On the first pass, pop and + settle answers as usual and then push. On the second pass,{" "} + only pop, never push, because those indices already got their + chance in the first pass and pushing them again would duplicate work. + No copy of the array is needed. Time O(n). + + ), + zh: ( + <> + 循环数组的标准技巧:下标 i 从 0 跑到 2n−1,访问时用{" "} + i % n。第一圈正常「弹栈结算 + 入栈」,第二圈 + 只弹不进 —— 这些下标第一圈已经入过栈,再入一次就重复了。 + 不用真的复制数组。时间 O(n)。 + + ), + }, + }, + { + lc: 84, + title: { + en: "Largest Rectangle in Histogram", + zh: "柱状图中最大的矩形", + }, + d: "hard", + tags: [ + { en: "Monotonic stack", zh: "单调栈" }, + { en: "Sentinel", zh: "哨兵" }, + ], + hint: { + en: "For the largest rectangle whose height is one particular bar, the limits are the first shorter bar on each side. That is next smaller in both directions.", + zh: "以某根柱子为高的最大矩形,边界是它左右两侧第一根比它矮的柱子 —— 两个方向的「下一个更小」。", + }, + key: { + en: ( + <> + Keep an increasing stack of indices. When the current bar is shorter + than the top, pop the top and settle the rectangle whose height is + that bar: the right limit is the current index, the left limit is the + index now on top, so the width is{" "} + i − stack.top − 1. Add a bar of height 0 at each end as a + sentinel: the one in front removes the empty-stack check, and the one + at the end forces every remaining bar to be settled. Time O(n). + + ), + zh: ( + <> + 维护一个递增的下标栈:当前柱子比栈顶矮时,弹出栈顶并结算 + 「以它为高」的矩形 —— 右边界是当前下标,左边界是弹出后的新栈顶, + 宽度 = i − 新栈顶 − 1。首尾各加一根高度为 0 的哨兵柱: + 开头那根免去判空,结尾那根强制清算所有剩余柱子。时间 O(n)。 + + ), + }, + }, + { + lc: 42, + title: { en: "Trapping Rain Water", zh: "接雨水" }, + d: "hard", + tags: [ + { en: "Monotonic stack", zh: "单调栈" }, + { en: "Two solutions", zh: "一题两解" }, + ], + hint: { + en: "The array chapter solved this column by column with two pointers. A stack lets you settle the water one horizontal layer at a time, each time the bottom of a pit is popped.", + zh: "数组章用对撞指针「竖着」按列算过它 —— 单调栈可以「横着」按层算:凹槽的底被弹出时,一层水就结算了。", + }, + key: { + en: ( + <> + Keep a decreasing stack of indices. When the current bar is taller + than the top, the top is the bottom of a pit, so pop it. The left wall + is the index now on top and the right wall is the current index, so + this layer holds{" "} + (min(left, right) − bottom) * (rightIndex − leftIndex − 1) + . Compare it with the two-pointer solution in the array chapter: the + same problem, settled by vertical columns there and by horizontal + layers here. Being able to explain both is the real goal. Time O(n). + + ), + zh: ( + <> + 维护一个递减的下标栈:当前柱子比栈顶高时,栈顶就是凹槽的底,弹出它; + 此时左墙 = 新栈顶,右墙 = 当前柱子,这一层水量 ={" "} + (min(左墙, 右墙) − 底) × (右墙下标 − 左墙下标 − 1)。 + 和数组章的对撞指针解法对照:同一道题,那边按列竖着算,这边按层横着算。 + 两种都能讲清楚,才算真的吃透。时间 O(n)。 + + ), + }, + }, +]; diff --git a/lib/stack-data.tsx b/lib/stack-quiz.tsx similarity index 53% rename from lib/stack-data.tsx rename to lib/stack-quiz.tsx index 4dcccb2..9650f59 100644 --- a/lib/stack-data.tsx +++ b/lib/stack-quiz.tsx @@ -1,354 +1,9 @@ -// Chapter 4 · Stacks — problem set and quiz data (English default / Chinese toggle). -// Problems ramp from pairwise cancellation up to monotonic stack Hards; hint points a direction -// without spoilers, key explains the optimal solution in one paragraph. +// Chapter 4 · Stacks — closing quiz (the problem set is in lib/stack-problems.tsx). +// +// Bilingual: every question, option and explanation is a { en, zh } pair. -import type { Problem } from "@/lib/problems"; import type { QuizItem } from "@/lib/quiz"; -export const PROBLEMS: Problem[] = [ - { - lc: 1047, - title: { - en: "Remove All Adjacent Duplicates In String", - zh: "删除字符串中的所有相邻重复项", - }, - d: "easy", - tags: [ - { en: "Stack", zh: "栈" }, - { en: "Pair cancellation", zh: "配对消除" }, - ], - hint: { - en: "A character cancels with the one most recently kept. Which structure gives you the most recent item in O(1)?", - zh: "当前字符只和「最近留下的那个」抵消。哪种结构能 O(1) 拿到「最近的」?", - }, - key: { - en: ( - <> - Push characters one by one. If the current character equals the top of - the stack, pop instead of pushing: that pair is removed. Otherwise - push it. What remains in the stack at the end is the answer. After a - pop, the element below becomes the new top, so it can cancel with the - next character without any extra code. Time O(n), space O(n). - - ), - zh: ( - <> - 逐字符处理:当前字符与栈顶相同就弹栈(这一对被消除),否则入栈; - 扫完后栈里剩下的就是答案。弹栈后下面那个元素自动成为新栈顶, - 可以直接和后面的字符继续配对 —— 连锁消除不需要任何额外代码。 - 时间 O(n),空间 O(n)。 - - ), - }, - }, - { - lc: 232, - title: { en: "Implement Queue using Stacks", zh: "用栈实现队列" }, - d: "easy", - tags: [ - { en: "Two stacks", zh: "双栈" }, - { en: "Amortized analysis", zh: "均摊分析" }, - ], - hint: { - en: "One stack reverses the order of the elements. What does a second reversal give you?", - zh: "一个栈会把顺序反过来 —— 那再反一次呢?", - }, - key: { - en: ( - <> - The in stack only receives pushes. The out{" "} - stack answers pop and peek. Move everything from in to{" "} - out only when out is empty. Two reversals - restore the original arrival order. Each element is moved at most four - times in its life (into in, out of in, into{" "} - out, out of out), so each operation is O(1) - amortized. The queue chapter walks through this frame by frame. - - ), - zh: ( - <> - in 栈只收 push,out 栈负责 pop / peek; - 只有 out 空了才把 in 整体倒过去。 - 两次反转恰好恢复先来后到。每个元素一生最多被搬 4 次 - (进 in、出 in、进 out、出{" "} - out),所以单次操作均摊 O(1)。队列章有逐帧动画和完整推导。 - - ), - }, - }, - { - lc: 225, - title: { en: "Implement Stack using Queues", zh: "用队列实现栈" }, - d: "easy", - tags: [ - { en: "Queue", zh: "队列" }, - { en: "Rotation", zh: "旋转" }, - ], - hint: { - en: "A new element joins at the back of the queue. Can you rotate the queue so it ends up at the front?", - zh: "新元素入队后排在最后 —— 有没有办法让它「转」到队头去?", - }, - key: { - en: ( - <> - The single-queue version: on push, enqueue the new element, then - dequeue and re-enqueue the n−1 elements in front of it. The queue - rotates by one full turn and the new element ends up at the front. Now - pop is just a dequeue. Push is O(n) and pop is O(1) — the mirror image - of LC 232, so solve the two together. - - ), - zh: ( - <> - 单队列做法:push 时先把新元素入队,再把它前面的 n−1 个元素依次出队、 - 重新入队 —— 队列被旋转一整圈,新元素恰好转到队头。于是 pop 就是一次出队。 - push O(n)、pop O(1),和 LC 232 互为镜像,放在一起做最有感觉。 - - ), - }, - }, - { - lc: 682, - title: { en: "Baseball Game", zh: "棒球比赛" }, - d: "easy", - tags: [ - { en: "Stack", zh: "栈" }, - { en: "Simulation", zh: "模拟" }, - ], - hint: { - en: "Every operation only refers to the one or two most recent valid scores. Where do you keep the most recent records?", - zh: "每条指令都只关心「最近的一两次有效得分」—— 用什么存最近的记录?", - }, - key: { - en: ( - <> - Keep the valid scores in a stack. A number is pushed directly.{" "} - + pushes the sum of the top two. D pushes - twice the top. C pops the top. Sum the stack at the end. - It is a pure simulation, and the point of it is the reflex: “the - most recent records” means a stack. Time O(n), space O(n). - - ), - zh: ( - <> - 用栈存有效得分:数字直接入栈;+ 把栈顶两项之和入栈; - D 把栈顶 ×2 入栈;C 弹掉栈顶。最后求和。 - 纯模拟题,练的是「要最近的记录 → 用栈」这个条件反射。 - 时间 O(n),空间 O(n)。 - - ), - }, - }, - { - lc: 150, - title: { - en: "Evaluate Reverse Polish Notation", - zh: "逆波兰表达式求值", - }, - d: "medium", - tags: [ - { en: "Stack", zh: "栈" }, - { en: "Expression evaluation", zh: "表达式求值" }, - ], - hint: { - en: "In postfix notation an operator always comes after its two operands. Where are the two most recent numbers waiting?", - zh: "后缀表达式里,运算符永远跟在它的两个操作数后面 —— 「最近的两个数」在哪里等你?", - }, - key: { - en: ( - <> - Push numbers. On an operator, pop two values, compute, and push the - result back. Watch the order: the value you pop first is the right - operand, which matters for subtraction and division. Division - truncates toward zero. Postfix needs no parentheses and no precedence - rules, which is exactly why compilers and calculators evaluate with a - stack. Time O(n), space O(n). - - ), - zh: ( - <> - 数字入栈;遇到运算符弹出两个数,算完把结果压回去。注意弹出顺序: - 先弹出的是右操作数,减法和除法会因此出错;除法要向零取整。 - 后缀表达式不需要括号、不需要优先级,这正是编译器和计算器用栈求值的原因。 - 时间 O(n),空间 O(n)。 - - ), - }, - }, - { - lc: 394, - title: { en: "Decode String", zh: "字符串解码" }, - d: "medium", - tags: [ - { en: "Stack", zh: "栈" }, - { en: "Nested structure", zh: "嵌套结构" }, - ], - hint: { - en: "In 3[a2[c]] the inner bracket must be decoded first. Innermost first is another way of saying most recent first.", - zh: "3[a2[c]] —— 嵌套的括号,里层必须先解码。「里层优先」换个说法就是「最近的优先」。", - }, - key: { - en: ( - <> - On [, push the string built so far together with the - current repeat count, then start a fresh empty string. On{" "} - ], pop that pair and set{" "} - cur = prev + k * cur. The stack holds one unfinished - piece of work per nesting level, which is the same thing a call stack - holds. This problem can also be written with recursion, and the two - versions translate into each other line by line. Time O(n). - - ), - zh: ( - <> - 遇到 [ 时把「当前已拼好的字符串 + 当前倍数」打包压栈, - 然后清空重新开始;遇到 ] 弹栈, - cur = prev + k × cur。栈里每一层存的是一份未完成的工作, - 和调用栈保存的东西是同一类。本题用递归写也行,两种写法可以逐行互译。 - 时间 O(n)。 - - ), - }, - }, - { - lc: 496, - title: { en: "Next Greater Element I", zh: "下一个更大元素 I" }, - d: "easy", - tags: [ - { en: "Monotonic stack", zh: "单调栈" }, - { en: "Hash table", zh: "哈希表" }, - ], - hint: { - en: "Precompute the next greater element for every value in nums2, then answer the questions from nums1 by lookup.", - zh: "先对 nums2 把每个元素的「下一个更大」全求出来存好,再回答 nums1 的提问。", - }, - key: { - en: ( - <> - Run one monotonic stack pass over nums2. When a new value - is greater than the top, pop and record{" "} - popped value → new value in a hash table. Then look up - each element of nums1. Time O(n + m). This is the same - machinery as LC 739 in a simpler setting, so solve it first. - - ), - zh: ( - <> - 对 nums2 跑一遍单调栈:新元素比栈顶大就弹栈, - 并在哈希表里记下「被弹出的值 → 弹它的值」;最后 nums1{" "} - 逐个查表。时间 O(n + m)。它和 LC 739 是同一套机制的简化版,建议先做它。 - - ), - }, - }, - { - lc: 503, - title: { en: "Next Greater Element II", zh: "下一个更大元素 II" }, - d: "medium", - tags: [ - { en: "Monotonic stack", zh: "单调栈" }, - { en: "Circular array", zh: "循环数组" }, - ], - hint: { - en: "The array is circular, so the next element after the last one is the first one. Try walking the array twice without copying it.", - zh: "数组是环形的,最后一个元素的「下一个」会绕回开头 —— 试试不复制数组、直接走两圈。", - }, - key: { - en: ( - <> - The standard trick for circular arrays: let i run from 0 - to 2n−1 and index with i % n. On the first pass, pop and - settle answers as usual and then push. On the second pass,{" "} - only pop, never push, because those indices already got their - chance in the first pass and pushing them again would duplicate work. - No copy of the array is needed. Time O(n). - - ), - zh: ( - <> - 循环数组的标准技巧:下标 i 从 0 跑到 2n−1,访问时用{" "} - i % n。第一圈正常「弹栈结算 + 入栈」,第二圈 - 只弹不进 —— 这些下标第一圈已经入过栈,再入一次就重复了。 - 不用真的复制数组。时间 O(n)。 - - ), - }, - }, - { - lc: 84, - title: { - en: "Largest Rectangle in Histogram", - zh: "柱状图中最大的矩形", - }, - d: "hard", - tags: [ - { en: "Monotonic stack", zh: "单调栈" }, - { en: "Sentinel", zh: "哨兵" }, - ], - hint: { - en: "For the largest rectangle whose height is one particular bar, the limits are the first shorter bar on each side. That is next smaller in both directions.", - zh: "以某根柱子为高的最大矩形,边界是它左右两侧第一根比它矮的柱子 —— 两个方向的「下一个更小」。", - }, - key: { - en: ( - <> - Keep an increasing stack of indices. When the current bar is shorter - than the top, pop the top and settle the rectangle whose height is - that bar: the right limit is the current index, the left limit is the - index now on top, so the width is{" "} - i − stack.top − 1. Add a bar of height 0 at each end as a - sentinel: the one in front removes the empty-stack check, and the one - at the end forces every remaining bar to be settled. Time O(n). - - ), - zh: ( - <> - 维护一个递增的下标栈:当前柱子比栈顶矮时,弹出栈顶并结算 - 「以它为高」的矩形 —— 右边界是当前下标,左边界是弹出后的新栈顶, - 宽度 = i − 新栈顶 − 1。首尾各加一根高度为 0 的哨兵柱: - 开头那根免去判空,结尾那根强制清算所有剩余柱子。时间 O(n)。 - - ), - }, - }, - { - lc: 42, - title: { en: "Trapping Rain Water", zh: "接雨水" }, - d: "hard", - tags: [ - { en: "Monotonic stack", zh: "单调栈" }, - { en: "Two solutions", zh: "一题两解" }, - ], - hint: { - en: "The array chapter solved this column by column with two pointers. A stack lets you settle the water one horizontal layer at a time, each time the bottom of a pit is popped.", - zh: "数组章用对撞指针「竖着」按列算过它 —— 单调栈可以「横着」按层算:凹槽的底被弹出时,一层水就结算了。", - }, - key: { - en: ( - <> - Keep a decreasing stack of indices. When the current bar is taller - than the top, the top is the bottom of a pit, so pop it. The left wall - is the index now on top and the right wall is the current index, so - this layer holds{" "} - (min(left, right) − bottom) * (rightIndex − leftIndex − 1) - . Compare it with the two-pointer solution in the array chapter: the - same problem, settled by vertical columns there and by horizontal - layers here. Being able to explain both is the real goal. Time O(n). - - ), - zh: ( - <> - 维护一个递减的下标栈:当前柱子比栈顶高时,栈顶就是凹槽的底,弹出它; - 此时左墙 = 新栈顶,右墙 = 当前柱子,这一层水量 ={" "} - (min(左墙, 右墙) − 底) × (右墙下标 − 左墙下标 − 1)。 - 和数组章的对撞指针解法对照:同一道题,那边按列竖着算,这边按层横着算。 - 两种都能讲清楚,才算真的吃透。时间 O(n)。 - - ), - }, - }, -]; - export const QUIZ: QuizItem[] = [ { type: "choice", diff --git a/lib/string-data.tsx b/lib/string-problems.tsx similarity index 50% rename from lib/string-data.tsx rename to lib/string-problems.tsx index acbba36..87e24dd 100644 --- a/lib/string-data.tsx +++ b/lib/string-problems.tsx @@ -1,11 +1,11 @@ -// Chapter 2 · Strings — problem set and quiz data (bilingual). +// Chapter 2 · Strings — problem set (bilingual). +// The closing quiz is in lib/string-quiz.tsx; /atlas imports only this file. // Problems cover two pointers from both ends, counting arrays, sliding window, simulation, and KMP, // ramping from Easy to Hard; hint points a direction without spoilers, key explains the optimal // solution in one paragraph. // Problem titles use the official LeetCode English name on en and the official Chinese name on zh. import type { Problem } from "@/lib/problems"; -import type { QuizItem } from "@/lib/quiz"; export const PROBLEMS: Problem[] = [ { @@ -361,273 +361,3 @@ export const PROBLEMS: Problem[] = [ }, }, ]; - -export const QUIZ: QuizItem[] = [ - { - type: "choice", - q: { - en: "What does it mean exactly to say that a string is immutable?", - zh: "「字符串不可变(immutable)」的准确含义是?", - }, - opts: [ - { - en: "Once a string object is created its contents never change. Every operation that looks like an edit returns a new string.", - zh: "字符串对象一旦创建,内容永不改变;所有看起来在「修改」的操作都返回一个新字符串", - }, - { - en: "Once a variable is assigned a string, it can never point at another string.", - zh: "字符串变量赋值之后就不能再指向别的字符串", - }, - { - en: "A string cannot take part in operations such as concatenation or replacement.", - zh: "字符串不能参与拼接、替换等操作", - }, - { - en: "A string can only hold letters, not digits or symbols.", - zh: "字符串只能存字母,不能存数字和符号", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "A variable can be repointed at any time. Writing s = s + \"!\" makes s point at a new string. Immutability is about the contents of the object, not the variable.", - zh: "变量随时可以改指向:s = s + \"!\" 就让 s 指向了新串。不可变说的是「对象内容」,不是「变量」。", - }, - { - en: "The opposite is true. Concatenation and replacement both work. Their result is simply a newly built string, and the original is untouched.", - zh: "恰恰相反,拼接和替换都可以做 —— 只是结果是新造的字符串,原件分毫未动。", - }, - { - en: "A string can hold any character. Immutability restricts whether you can edit it in place, not what it can store.", - zh: "字符串能装任何字符。不可变约束的是「能不能原地改」,和存什么内容无关。", - }, - ], - why: { - en: "Immutable means the contents of the object are fixed. replace, toUpperCase, and concatenation all look like edits but build a new string instead. Half the complexity results in this chapter follow from that one fact: concatenation is O(n + m), and += inside a loop is O(n²).", - zh: "不可变 = 对象内容一经创建就不能修改。replace、toUpperCase、拼接看起来在「改」,其实都在重抄一份新的 —— 本章一半的复杂度结论都由这一个事实推出:拼接 O(n+m)、循环 += O(n²)。", - }, - }, - { - type: "choice", - q: { - en: "You run s += c n times in a loop, appending one character each time. What is the total time complexity?", - zh: "在循环里执行 n 次 s += c(每次追加 1 个字符),总时间复杂度是?", - }, - opts: [ - { - en: "O(n²), because each += copies the whole old string: 1 + 2 + … + n character copies", - zh: "O(n²) —— 每次 += 都要重抄整个旧串,共 1+2+…+n 次拷贝", - }, - { en: "O(n), because each step adds only one character", zh: "O(n) —— 每次只加了 1 个字符" }, - { en: "O(n log n)", zh: "O(n log n)" }, - { en: "O(1), string operations are fast", zh: "O(1) —— 字符串操作都很快" }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "“Only one character” is the illusion. Because the string is immutable, nothing can be appended to the old one, so the i-th += allocates a string of length i and copies every old character into it.", - zh: "「只加 1 个字符」是错觉:不可变意味着没法在旧串尾部续写,第 i 次 += 要新建长 i 的串并拷贝全部旧字符。", - }, - { - en: "A log factor comes from halving the work at each step. Here every step copies the whole string again, which is the sum of an arithmetic series.", - zh: "log 来自「每步减半」的结构;这里是实打实的逐次全量重抄,是等差数列求和。", - }, - { - en: "One step looks cheap, but the total is 1 + 2 + … + n = n(n+1)/2. The counter in the concatenation race (§03) shows how far apart the two approaches get.", - zh: "单次看着快,累计成本是 1+2+…+n = n(n+1)/2 —— §03「拼接赛跑」里的计数器会告诉你两者差距有多大。", - }, - ], - why: { - en: "The i-th concatenation copies about i characters, so the total is 1 + 2 + … + n ≈ n²/2. This applies to Java, Python, and JavaScript alike. The fix is the same in all three: append into a mutable container and build the string once at the end, which brings the total back to O(n).", - zh: "第 i 次拼接拷贝约 i 个字符,总量 1+2+…+n ≈ n²/2。Java、Python、JavaScript 都一样。解法也一样:先往可变容器里攒,最后一次性成串 —— 总量回到 O(n)。", - }, - }, - { - type: "choice", - q: { - en: "In Java, what is the correct way to compare the contents of two strings?", - zh: "Java 里比较两个字符串的内容,正确做法是?", - }, - opts: [ - { - en: "a.equals(b), because == compares references, that is, whether the two are the same object", - zh: "用 a.equals(b) —— == 比较的是引用(是不是同一个对象)", - }, - { en: "a == b, short and correct", zh: "用 a == b,简洁又正确" }, - { - en: "Convert both to char[] and compare position by position; nothing else is reliable", - zh: "先转成 char[] 再逐位比,别的都不可靠", - }, - { en: "a.compareTo(b) > 0", zh: "用 a.compareTo(b) > 0" }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "== only asks whether the two are the same object. Two literals happen to be equal because the compiler puts them in the string pool, but new String(\"data\") exposes the mistake immediately.", - zh: "== 只问「是不是同一个对象」。两个字面量因为常量池碰巧相等,但 new String(\"data\") 立刻现原形。", - }, - { - en: "equals already compares character by character. There is no need to unpack the string into an array yourself.", - zh: "equals 内部就是逐字符比较,已经替你写好了,不需要手工拆数组。", - }, - { - en: "compareTo returns the lexicographic ordering as a negative number, zero, or a positive number. Using > 0 to test equality is the wrong test; equality is compareTo(b) == 0, and equals says it more clearly.", - zh: "compareTo 返回的是字典序关系(负 / 零 / 正)。用 > 0 判断「相等」逻辑就错了;真要用它,相等是 == 0,但判相等直接用 equals 更清楚。", - }, - ], - why: { - en: "In Java, == on objects compares references and equals compares contents. The string pool makes \"abc\" == \"abc\" true for compile-time literals, which creates the false impression that == works. It stops working with new String or with a string built at run time. Note that this trap is specific to Java: in JavaScript === on strings compares values, and in Python you use == for equality and keep is for identity.", - zh: "Java 里 == 比引用、equals 比内容。常量池让编译期字面量 \"abc\" == \"abc\" 为 true,给人「== 能用」的错觉,一遇到 new String 或运行期拼接就失效。注意这个陷阱是 Java 特有的:JavaScript 的 === 对字符串比的是值,Python 判相等用 ==、is 只问是不是同一个对象。", - }, - }, - { - type: "multi", - q: { - en: "Which of these statements about UTF-8 are correct? (Select all that apply.)", - zh: "关于 UTF-8 编码,下列说法正确的有?(多选)", - }, - opts: [ - { - en: "Characters in the ASCII range (English letters, digits) take 1 byte", - zh: "ASCII 范围的字符(英文字母、数字)只占 1 个字节", - }, - { - en: "A common Chinese character usually takes 3 bytes", - zh: "常用中文汉字通常占 3 个字节", - }, - { - en: "In UTF-8 every character takes exactly 2 bytes", - zh: "UTF-8 里每个字符固定占 2 个字节", - }, - { en: "Most emoji take 4 bytes", zh: "绝大多数 emoji 占 4 个字节" }, - ], - correct: [0, 1, 3], - missHint: { - en: "Think back to the encoding lab (§02). With the input “A字🙂”, how many bytes does each of the three characters take? 1, 3, and 4. Which options does that match?", - zh: "回想 §02 的「编码实验室」:输入「A字🙂」时,三个字符的字节数各是多少?1、3、4 —— 对应哪几个选项?", - }, - extraHint: { - en: "“Exactly 2 bytes” describes UCS-2, or UTF-16 inside the Basic Multilingual Plane. UTF-8 exists precisely because it is variable length: 1 to 4 bytes.", - zh: "「固定 2 字节」描述的是 UCS-2 或 UTF-16 在基本多文种平面内的情况。UTF-8 的立身之本恰恰是变长:1 到 4 字节都有。", - }, - why: { - en: "UTF-8 chooses the byte length from the code point: up to U+007F takes 1 byte and is byte-identical to ASCII, common Chinese characters fall in the 3-byte range, and emoji have code points above U+FFFF and take 4 bytes. Saving space on common text while staying compatible with ASCII is why it became the standard on the web.", - zh: "UTF-8 按码点大小变长编码:≤ U+007F 用 1 字节(字节层面完全兼容 ASCII),常用汉字落在 3 字节区,emoji 的码点超过 U+FFFF 要 4 字节。「常用文本省空间 + 兼容 ASCII」正是它成为互联网标准的原因。", - }, - }, - { - type: "choice", - q: { - en: "Why does Python have no separate char type?", - zh: "为什么 Python 没有单独的 char(字符)类型?", - }, - opts: [ - { - en: "The designers decided a str of length 1 can do everything a character type would do, and one type is simpler than two", - zh: "设计者认为长度为 1 的 str 足以胜任字符的所有职责,统一成一种类型更简单", - }, - { - en: "Python is too slow to support a character type", - zh: "Python 性能太差,支持不了字符类型", - }, - { - en: "Python strings are mutable, so a character type is unnecessary", - zh: "因为 Python 的字符串是可变的,不需要字符类型", - }, - { - en: "It is a historical bug that was never fixed", - zh: "历史遗留 bug,官方一直没修", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Performance has nothing to do with it. It is a deliberate language design: s[0] returns a str of length 1, and every string operation still applies to it, so there is no second API to learn.", - zh: "和性能无关 —— 这是刻意的语言设计:s[0] 返回一个长度为 1 的 str,所有字符串操作对它照常适用,不用学两套 API。", - }, - { - en: "Python str is immutable, exactly like Java String and JavaScript strings. Whether a type is mutable is a separate question from whether a character type exists.", - zh: "Python 的 str 恰恰是不可变的,和 Java String、JavaScript 字符串一样。可变与否和有没有 char 类型是两回事。", - }, - { - en: "It is a documented design decision (“there should be one obvious way to do it”), not a bug.", - zh: "这是写进设计哲学的决定(「应该只有一种明显的做法」),不是 bug。", - }, - ], - why: { - en: "Python treats a character as a string of length 1: indexing, slicing, and iteration all return str. The cost is that you call ord() or chr() explicitly to move between a character and its code point, while Java char and JavaScript charCodeAt give you a number directly.", - zh: "Python 把「字符」统一为长度 1 的字符串:下标、切片、遍历返回的都是 str。代价是想在字符和码点之间转换要显式调 ord()/chr(),而 Java 的 char、JavaScript 的 charCodeAt 直接就是数字。", - }, - }, - { - type: "choice", - q: { - en: "Which signal should make you think of a sliding window first?", - zh: "看到什么信号,应该第一时间想到「滑动窗口」?", - }, - opts: [ - { - en: "You need the best contiguous substring or subarray, and the condition inside the window can be updated step by step as elements enter and leave", - zh: "求「连续子串 / 子数组」的最值,且窗口内维护的条件能随进出增量更新", - }, - { - en: "The problem mentions strings at all", - zh: "只要题目里出现「字符串」三个字", - }, - { - en: "You need to enumerate every subsequence, which may skip elements", - zh: "需要枚举所有子序列(可以不连续)的时候", - }, - { en: "The array has to be sorted first", zh: "数组必须先排好序的时候" }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "String problems also use two pointers, counting arrays, KMP, and dynamic programming. A sliding window needs the two specific features: contiguous, and incrementally maintainable.", - zh: "字符串题还有对撞指针、计数数组、KMP、DP 等一大家子 —— 滑窗只认「连续 + 可增量维护」这两个特征。", - }, - { - en: "A subsequence may skip elements, so it is not one contiguous range. A window cannot slide over a gap; that belongs to dynamic programming or backtracking.", - zh: "子序列可以跳着选,不是一段连续区间 —— 窗口滑不出「跳跃」,那是 DP / 回溯的地盘。", - }, - { - en: "Sorting usually destroys the window, because the point of the window is that it preserves the original order and adjacency.", - zh: "排序反而常常毁掉滑窗:窗口的意义就在于保持原序列的连续性和顺序。", - }, - ], - why: { - en: "A sliding window needs two things. First, the answer is a contiguous range. Second, when one element enters or leaves, the quantity you maintain (a sum, a set of counts, a set of seen characters) can be updated in O(1). LC 3, 209, 438, and 76 all satisfy both.", - zh: "滑窗两大前提:① 答案是连续区间;② 窗口进 / 出一个元素时,维护的量(和、计数、去重集合)能 O(1) 更新。LC 3、209、438、76 全部命中这两条。", - }, - }, - { - type: "fill", - q: { - en: ( - <> - In JavaScript, what is the value of "👍".length? - - ), - zh: ( - <> - JavaScript 里,"👍".length 的值是多少? - - ), - }, - placeholder: { en: "Enter a number…", zh: "输入一个数字…" }, - answers: ["2"], - hint: { - en: "JavaScript length counts UTF-16 code units. The code point of 👍 is above U+FFFF, so it needs a pair of code units, called a surrogate pair.", - zh: "JavaScript 的 length 数的是 UTF-16 编码单元;👍 的码点超出了 U+FFFF,需要一对编码单元(代理对)来表示。", - }, - why: { - en: "The code point of 👍 is U+1F44D, which is above U+FFFF, so UTF-16 stores it as two code units and length is 2. To count code points instead, use Array.from(s).length or iterate with for...of. Java behaves the same way; Python 3 returns 1 because str is indexed by code point.", - zh: "👍 的码点是 U+1F44D,超过 U+FFFF,在 UTF-16 里要拆成两个编码单元(代理对),所以 length 是 2。想按码点数,用 Array.from(s).length 或 for...of 遍历。Java 的行为相同;Python 3 的 len 是 1,因为 str 按码点索引。", - }, - }, -]; diff --git a/lib/string-quiz.tsx b/lib/string-quiz.tsx new file mode 100644 index 0000000..6ad2ac7 --- /dev/null +++ b/lib/string-quiz.tsx @@ -0,0 +1,275 @@ +// Chapter 2 · Strings — closing quiz (the problem set is in lib/string-problems.tsx). +// +// Bilingual: every question, option and explanation is a { en, zh } pair. + +import type { QuizItem } from "@/lib/quiz"; + +export const QUIZ: QuizItem[] = [ + { + type: "choice", + q: { + en: "What does it mean exactly to say that a string is immutable?", + zh: "「字符串不可变(immutable)」的准确含义是?", + }, + opts: [ + { + en: "Once a string object is created its contents never change. Every operation that looks like an edit returns a new string.", + zh: "字符串对象一旦创建,内容永不改变;所有看起来在「修改」的操作都返回一个新字符串", + }, + { + en: "Once a variable is assigned a string, it can never point at another string.", + zh: "字符串变量赋值之后就不能再指向别的字符串", + }, + { + en: "A string cannot take part in operations such as concatenation or replacement.", + zh: "字符串不能参与拼接、替换等操作", + }, + { + en: "A string can only hold letters, not digits or symbols.", + zh: "字符串只能存字母,不能存数字和符号", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "A variable can be repointed at any time. Writing s = s + \"!\" makes s point at a new string. Immutability is about the contents of the object, not the variable.", + zh: "变量随时可以改指向:s = s + \"!\" 就让 s 指向了新串。不可变说的是「对象内容」,不是「变量」。", + }, + { + en: "The opposite is true. Concatenation and replacement both work. Their result is simply a newly built string, and the original is untouched.", + zh: "恰恰相反,拼接和替换都可以做 —— 只是结果是新造的字符串,原件分毫未动。", + }, + { + en: "A string can hold any character. Immutability restricts whether you can edit it in place, not what it can store.", + zh: "字符串能装任何字符。不可变约束的是「能不能原地改」,和存什么内容无关。", + }, + ], + why: { + en: "Immutable means the contents of the object are fixed. replace, toUpperCase, and concatenation all look like edits but build a new string instead. Half the complexity results in this chapter follow from that one fact: concatenation is O(n + m), and += inside a loop is O(n²).", + zh: "不可变 = 对象内容一经创建就不能修改。replace、toUpperCase、拼接看起来在「改」,其实都在重抄一份新的 —— 本章一半的复杂度结论都由这一个事实推出:拼接 O(n+m)、循环 += O(n²)。", + }, + }, + { + type: "choice", + q: { + en: "You run s += c n times in a loop, appending one character each time. What is the total time complexity?", + zh: "在循环里执行 n 次 s += c(每次追加 1 个字符),总时间复杂度是?", + }, + opts: [ + { + en: "O(n²), because each += copies the whole old string: 1 + 2 + … + n character copies", + zh: "O(n²) —— 每次 += 都要重抄整个旧串,共 1+2+…+n 次拷贝", + }, + { en: "O(n), because each step adds only one character", zh: "O(n) —— 每次只加了 1 个字符" }, + { en: "O(n log n)", zh: "O(n log n)" }, + { en: "O(1), string operations are fast", zh: "O(1) —— 字符串操作都很快" }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "“Only one character” is the illusion. Because the string is immutable, nothing can be appended to the old one, so the i-th += allocates a string of length i and copies every old character into it.", + zh: "「只加 1 个字符」是错觉:不可变意味着没法在旧串尾部续写,第 i 次 += 要新建长 i 的串并拷贝全部旧字符。", + }, + { + en: "A log factor comes from halving the work at each step. Here every step copies the whole string again, which is the sum of an arithmetic series.", + zh: "log 来自「每步减半」的结构;这里是实打实的逐次全量重抄,是等差数列求和。", + }, + { + en: "One step looks cheap, but the total is 1 + 2 + … + n = n(n+1)/2. The counter in the concatenation race (§03) shows how far apart the two approaches get.", + zh: "单次看着快,累计成本是 1+2+…+n = n(n+1)/2 —— §03「拼接赛跑」里的计数器会告诉你两者差距有多大。", + }, + ], + why: { + en: "The i-th concatenation copies about i characters, so the total is 1 + 2 + … + n ≈ n²/2. This applies to Java, Python, and JavaScript alike. The fix is the same in all three: append into a mutable container and build the string once at the end, which brings the total back to O(n).", + zh: "第 i 次拼接拷贝约 i 个字符,总量 1+2+…+n ≈ n²/2。Java、Python、JavaScript 都一样。解法也一样:先往可变容器里攒,最后一次性成串 —— 总量回到 O(n)。", + }, + }, + { + type: "choice", + q: { + en: "In Java, what is the correct way to compare the contents of two strings?", + zh: "Java 里比较两个字符串的内容,正确做法是?", + }, + opts: [ + { + en: "a.equals(b), because == compares references, that is, whether the two are the same object", + zh: "用 a.equals(b) —— == 比较的是引用(是不是同一个对象)", + }, + { en: "a == b, short and correct", zh: "用 a == b,简洁又正确" }, + { + en: "Convert both to char[] and compare position by position; nothing else is reliable", + zh: "先转成 char[] 再逐位比,别的都不可靠", + }, + { en: "a.compareTo(b) > 0", zh: "用 a.compareTo(b) > 0" }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "== only asks whether the two are the same object. Two literals happen to be equal because the compiler puts them in the string pool, but new String(\"data\") exposes the mistake immediately.", + zh: "== 只问「是不是同一个对象」。两个字面量因为常量池碰巧相等,但 new String(\"data\") 立刻现原形。", + }, + { + en: "equals already compares character by character. There is no need to unpack the string into an array yourself.", + zh: "equals 内部就是逐字符比较,已经替你写好了,不需要手工拆数组。", + }, + { + en: "compareTo returns the lexicographic ordering as a negative number, zero, or a positive number. Using > 0 to test equality is the wrong test; equality is compareTo(b) == 0, and equals says it more clearly.", + zh: "compareTo 返回的是字典序关系(负 / 零 / 正)。用 > 0 判断「相等」逻辑就错了;真要用它,相等是 == 0,但判相等直接用 equals 更清楚。", + }, + ], + why: { + en: "In Java, == on objects compares references and equals compares contents. The string pool makes \"abc\" == \"abc\" true for compile-time literals, which creates the false impression that == works. It stops working with new String or with a string built at run time. Note that this trap is specific to Java: in JavaScript === on strings compares values, and in Python you use == for equality and keep is for identity.", + zh: "Java 里 == 比引用、equals 比内容。常量池让编译期字面量 \"abc\" == \"abc\" 为 true,给人「== 能用」的错觉,一遇到 new String 或运行期拼接就失效。注意这个陷阱是 Java 特有的:JavaScript 的 === 对字符串比的是值,Python 判相等用 ==、is 只问是不是同一个对象。", + }, + }, + { + type: "multi", + q: { + en: "Which of these statements about UTF-8 are correct? (Select all that apply.)", + zh: "关于 UTF-8 编码,下列说法正确的有?(多选)", + }, + opts: [ + { + en: "Characters in the ASCII range (English letters, digits) take 1 byte", + zh: "ASCII 范围的字符(英文字母、数字)只占 1 个字节", + }, + { + en: "A common Chinese character usually takes 3 bytes", + zh: "常用中文汉字通常占 3 个字节", + }, + { + en: "In UTF-8 every character takes exactly 2 bytes", + zh: "UTF-8 里每个字符固定占 2 个字节", + }, + { en: "Most emoji take 4 bytes", zh: "绝大多数 emoji 占 4 个字节" }, + ], + correct: [0, 1, 3], + missHint: { + en: "Think back to the encoding lab (§02). With the input “A字🙂”, how many bytes does each of the three characters take? 1, 3, and 4. Which options does that match?", + zh: "回想 §02 的「编码实验室」:输入「A字🙂」时,三个字符的字节数各是多少?1、3、4 —— 对应哪几个选项?", + }, + extraHint: { + en: "“Exactly 2 bytes” describes UCS-2, or UTF-16 inside the Basic Multilingual Plane. UTF-8 exists precisely because it is variable length: 1 to 4 bytes.", + zh: "「固定 2 字节」描述的是 UCS-2 或 UTF-16 在基本多文种平面内的情况。UTF-8 的立身之本恰恰是变长:1 到 4 字节都有。", + }, + why: { + en: "UTF-8 chooses the byte length from the code point: up to U+007F takes 1 byte and is byte-identical to ASCII, common Chinese characters fall in the 3-byte range, and emoji have code points above U+FFFF and take 4 bytes. Saving space on common text while staying compatible with ASCII is why it became the standard on the web.", + zh: "UTF-8 按码点大小变长编码:≤ U+007F 用 1 字节(字节层面完全兼容 ASCII),常用汉字落在 3 字节区,emoji 的码点超过 U+FFFF 要 4 字节。「常用文本省空间 + 兼容 ASCII」正是它成为互联网标准的原因。", + }, + }, + { + type: "choice", + q: { + en: "Why does Python have no separate char type?", + zh: "为什么 Python 没有单独的 char(字符)类型?", + }, + opts: [ + { + en: "The designers decided a str of length 1 can do everything a character type would do, and one type is simpler than two", + zh: "设计者认为长度为 1 的 str 足以胜任字符的所有职责,统一成一种类型更简单", + }, + { + en: "Python is too slow to support a character type", + zh: "Python 性能太差,支持不了字符类型", + }, + { + en: "Python strings are mutable, so a character type is unnecessary", + zh: "因为 Python 的字符串是可变的,不需要字符类型", + }, + { + en: "It is a historical bug that was never fixed", + zh: "历史遗留 bug,官方一直没修", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Performance has nothing to do with it. It is a deliberate language design: s[0] returns a str of length 1, and every string operation still applies to it, so there is no second API to learn.", + zh: "和性能无关 —— 这是刻意的语言设计:s[0] 返回一个长度为 1 的 str,所有字符串操作对它照常适用,不用学两套 API。", + }, + { + en: "Python str is immutable, exactly like Java String and JavaScript strings. Whether a type is mutable is a separate question from whether a character type exists.", + zh: "Python 的 str 恰恰是不可变的,和 Java String、JavaScript 字符串一样。可变与否和有没有 char 类型是两回事。", + }, + { + en: "It is a documented design decision (“there should be one obvious way to do it”), not a bug.", + zh: "这是写进设计哲学的决定(「应该只有一种明显的做法」),不是 bug。", + }, + ], + why: { + en: "Python treats a character as a string of length 1: indexing, slicing, and iteration all return str. The cost is that you call ord() or chr() explicitly to move between a character and its code point, while Java char and JavaScript charCodeAt give you a number directly.", + zh: "Python 把「字符」统一为长度 1 的字符串:下标、切片、遍历返回的都是 str。代价是想在字符和码点之间转换要显式调 ord()/chr(),而 Java 的 char、JavaScript 的 charCodeAt 直接就是数字。", + }, + }, + { + type: "choice", + q: { + en: "Which signal should make you think of a sliding window first?", + zh: "看到什么信号,应该第一时间想到「滑动窗口」?", + }, + opts: [ + { + en: "You need the best contiguous substring or subarray, and the condition inside the window can be updated step by step as elements enter and leave", + zh: "求「连续子串 / 子数组」的最值,且窗口内维护的条件能随进出增量更新", + }, + { + en: "The problem mentions strings at all", + zh: "只要题目里出现「字符串」三个字", + }, + { + en: "You need to enumerate every subsequence, which may skip elements", + zh: "需要枚举所有子序列(可以不连续)的时候", + }, + { en: "The array has to be sorted first", zh: "数组必须先排好序的时候" }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "String problems also use two pointers, counting arrays, KMP, and dynamic programming. A sliding window needs the two specific features: contiguous, and incrementally maintainable.", + zh: "字符串题还有对撞指针、计数数组、KMP、DP 等一大家子 —— 滑窗只认「连续 + 可增量维护」这两个特征。", + }, + { + en: "A subsequence may skip elements, so it is not one contiguous range. A window cannot slide over a gap; that belongs to dynamic programming or backtracking.", + zh: "子序列可以跳着选,不是一段连续区间 —— 窗口滑不出「跳跃」,那是 DP / 回溯的地盘。", + }, + { + en: "Sorting usually destroys the window, because the point of the window is that it preserves the original order and adjacency.", + zh: "排序反而常常毁掉滑窗:窗口的意义就在于保持原序列的连续性和顺序。", + }, + ], + why: { + en: "A sliding window needs two things. First, the answer is a contiguous range. Second, when one element enters or leaves, the quantity you maintain (a sum, a set of counts, a set of seen characters) can be updated in O(1). LC 3, 209, 438, and 76 all satisfy both.", + zh: "滑窗两大前提:① 答案是连续区间;② 窗口进 / 出一个元素时,维护的量(和、计数、去重集合)能 O(1) 更新。LC 3、209、438、76 全部命中这两条。", + }, + }, + { + type: "fill", + q: { + en: ( + <> + In JavaScript, what is the value of "👍".length? + + ), + zh: ( + <> + JavaScript 里,"👍".length 的值是多少? + + ), + }, + placeholder: { en: "Enter a number…", zh: "输入一个数字…" }, + answers: ["2"], + hint: { + en: "JavaScript length counts UTF-16 code units. The code point of 👍 is above U+FFFF, so it needs a pair of code units, called a surrogate pair.", + zh: "JavaScript 的 length 数的是 UTF-16 编码单元;👍 的码点超出了 U+FFFF,需要一对编码单元(代理对)来表示。", + }, + why: { + en: "The code point of 👍 is U+1F44D, which is above U+FFFF, so UTF-16 stores it as two code units and length is 2. To count code points instead, use Array.from(s).length or iterate with for...of. Java behaves the same way; Python 3 returns 1 because str is indexed by code point.", + zh: "👍 的码点是 U+1F44D,超过 U+FFFF,在 UTF-16 里要拆成两个编码单元(代理对),所以 length 是 2。想按码点数,用 Array.from(s).length 或 for...of 遍历。Java 的行为相同;Python 3 的 len 是 1,因为 str 按码点索引。", + }, + }, +]; diff --git a/lib/trie-problems.tsx b/lib/trie-problems.tsx new file mode 100644 index 0000000..10017a1 --- /dev/null +++ b/lib/trie-problems.tsx @@ -0,0 +1,305 @@ +// Chapter 10 · Tries (prefix trees) — problem set (English default / Chinese toggle). +// The closing quiz is in lib/trie-quiz.tsx; /atlas imports only this file. +// Problems focus on three patterns — prefix matching, dictionary trie, and 0-1 trie — ordered from +// easy to hard; hint points a direction without spoilers, key explains the optimal solution in one +// paragraph. +// +// Bilingual: title / tags / hint / key are all written as { en, zh }, +// and the en side of each problem title uses the official LeetCode English name. + +import type { Problem } from "@/lib/problems"; + +export const PROBLEMS: Problem[] = [ + { + lc: 208, + title: { + en: "Implement Trie (Prefix Tree)", + zh: "实现 Trie(前缀树)", + }, + d: "medium", + tags: [ + { en: "Template", zh: "模板题" }, + { en: "Trie", zh: "字典树" }, + ], + hint: { + en: "§04 of this chapter is the complete answer. A node holds children (a map, or an array of fixed alphabet size) plus one boolean isEnd. All three methods do the same thing: walk down the path.", + zh: "本章 §04 就是它的完整答案。节点 = children(Map 或定长数组)+ isEnd 布尔位,三个方法都在「沿路径走」。", + }, + key: { + en: ( + <> + insert: follow the characters, create a node wherever the edge is + missing, and set isEnd = true on the last node. search: follow the + characters, and return true only if you arrive and that node has isEnd + = true. startsWith: the same walk, but arriving is enough — isEnd is + not checked. All three cost O(L), where L is the length of the string + you passed in. The number of words already stored does not appear in + that cost. Every later Trie problem adds something to these three + skeletons. + + ), + zh: ( + <> + insert:沿字符走,缺哪条边就新建节点,末节点 isEnd = true。search: + 同样走到底,还要求末节点 isEnd = true 才算命中。startsWith:走得通即可, + 不看 isEnd。三个方法都是 O(L),L = 传入字符串的长度 —— + 词典里已经存了多少词,不出现在这个代价里。所有后续 Trie + 题都在这三块骨架上加东西。 + + ), + }, + }, + { + lc: 211, + title: { + en: "Design Add and Search Words Data Structure", + zh: "添加与搜索单词 - 数据结构设计", + }, + d: "medium", + tags: [ + { en: "Trie", zh: "字典树" }, + { en: "DFS", zh: "DFS" }, + { en: "Wildcard", zh: "通配符" }, + ], + hint: { + en: "A normal letter tells you which edge to take. A '.' does not, so try every child edge in turn.", + zh: "普通字母告诉你该走哪条边;'.' 不告诉你,那就每条子边都试一遍。", + }, + key: { + en: ( + <> + addWord is the same as LC 208. search becomes a recursive walk dfs + (word, i, node). A normal character recurses into that one child. A + '.' loops over every child of the current node and recurses into + each one, returning true as soon as any branch succeeds. At the end of + the word, check isEnd. The worst case, a pattern made only of dots, + fans out to O(26^L), but the search also never visits more nodes than + the trie contains, and a missing edge stops a branch immediately. This + is the smallest example of Trie plus backtracking. + + ), + zh: ( + <> + addWord 与 LC 208 相同。search 改成递归 dfs(word, i, node):普通字符只往对应 + child 递归;遇到 '.' 就遍历当前节点的所有 child 分别递归, + 任意一条返回 true 就立刻返回。走到词尾看 isEnd。最坏情况(全是 '.')会扇出到 + O(26^L),但搜索访问的节点数同时也不会超过整棵树的节点数,而且缺边会立刻中断一条分支。 + 这是「Trie + 回溯」的最小案例。 + + ), + }, + }, + { + lc: 648, + title: { en: "Replace Words", zh: "单词替换" }, + d: "medium", + tags: [ + { en: "Trie", zh: "字典树" }, + { en: "Prefix matching", zh: "前缀匹配" }, + ], + hint: { + en: "Put every root word into a trie. For each word in the sentence, walk down from the top and stop at the first node with isEnd = true. That node is the shortest matching root.", + zh: "把所有词根放进 Trie。对句子里的每个单词,从根沿 Trie 走,第一次遇到 isEnd 就停 —— 那就是最短词根。", + }, + key: { + en: ( + <> + Build the trie from the root words. For each word in the sentence, + descend from the root one character at a time. As soon as the current + node has isEnd = true, a root word matches this prefix, so replace the + word and stop. If the path breaks, or you reach the end of the word + without meeting isEnd, keep the original word. Each word costs O(its + own length). This is the most direct use of a trie as a prefix test. + + ), + zh: ( + <> + 用词根建 Trie。遍历句子里每个单词,从 root 逐字符下沉:一旦当前节点 isEnd = + true,说明有词根匹配这段前缀,替换并停止;若中途断路或走完仍没遇到 isEnd, + 保留原词。每个单词 O(自身长度)。这题把「用 Trie 判前缀」用得最直白。 + + ), + }, + }, + { + lc: 677, + title: { en: "Map Sum Pairs", zh: "键值映射" }, + d: "medium", + tags: [ + { en: "Trie", zh: "字典树" }, + { en: "Prefix sum", zh: "前缀求和" }, + ], + hint: { + en: "insert stores val on the last node. sum(prefix) walks to the prefix node, then adds up the values of every word in the subtree below it.", + zh: "insert 时把 val 存在末节点;sum(prefix) = 先走到前缀节点,再把它子树里所有词的 val 加起来。", + }, + key: { + en: ( + <> + Store one more field, val, on the node where a key ends. sum(prefix) + walks O(L) to the prefix node, then runs a DFS over that subtree and + adds up every stored val. There is a faster version: on insert, + compute delta = new value − old value for this key, and add delta to a + running prefix total kept on every node along the path. sum then + reads one number and costs O(L). The pattern worth remembering is + storing an aggregate on the node itself. + + ), + zh: ( + <> + 在键结束的那个节点上多存一个 val。sum(prefix):先 O(L) 走到前缀节点, + 再 DFS 这棵子树累加所有 val。还有更快的写法:insert 时算出 delta = + 新值 − 该 key 的旧值,把 delta 加到路径上每个节点维护的「前缀累加值」上, + sum 就变成读一个数、O(L)。值得记住的套路是「把聚合信息挂在节点上」。 + + ), + }, + }, + { + lc: 720, + title: { en: "Longest Word in Dictionary", zh: "词典中最长的单词" }, + d: "medium", + tags: [ + { en: "Trie", zh: "字典树" }, + { en: "DFS", zh: "DFS" }, + ], + hint: { + en: "A word qualifies only if every one of its prefixes is also a word in the dictionary, which means every node along its path must have isEnd = true.", + zh: "一个词能入选,要求它的每一个前缀也都是词典里的词 —— 也就是路径上每个节点都得 isEnd = true。", + }, + key: { + en: ( + <> + Build the trie, then run a DFS that only descends into children with + isEnd = true. That rule is exactly the requirement that every prefix is + also a word. Keep the longest word you can reach. If two are the same + length, the answer is the smaller one in alphabetical order, and + visiting children from a to z and only replacing the answer when the + new word is strictly longer gives that for you. The problem is a search + for the longest chain where every step is itself valid. + + ), + zh: ( + <> + 建 Trie 后 DFS,只往 isEnd = true 的子节点走 —— 这条规则正好等价于 + 「每个前缀也是单词」。记录能到达的最长单词;长度相同取字典序最小, + 按 a→z 顺序遍历子节点、且只在严格更长时才替换答案,就自然满足。 + 本质是在 Trie 上找「每一步都合法」的最长链。 + + ), + }, + }, + { + lc: 1268, + title: { en: "Search Suggestions System", zh: "搜索推荐系统" }, + d: "medium", + tags: [ + { en: "Trie", zh: "字典树" }, + { en: "Autocomplete", zh: "自动补全" }, + ], + hint: { + en: "This is a search box in miniature: after each typed letter, return the 3 alphabetically smallest products that start with what has been typed so far.", + zh: "这就是搜索框补全的原型:每输入一个字母,给出以当前已输入内容为前缀、字典序最小的 3 个商品。", + }, + key: { + en: ( + <> + Build the trie and keep, on each node, a list of the 3 alphabetically + smallest words under that prefix. Maintain the list during insertion + and drop the largest whenever it grows past 3. As the user types, walk + one node per character and read the list directly. Once the path + breaks, every later answer is empty. Sorting the words and binary + searching also solves the problem, but the trie version turns prefix to + suggestions into a single read, which is how autocomplete is usually + built. + + ), + zh: ( + <> + 建 Trie,并在每个节点上存「该前缀下字典序最小的 3 个词」;插入时维护这个列表, + 超过 3 个就丢掉最大的。用户逐字符输入时每次下沉一个节点,直接读节点上的列表; + 一旦断路,后续答案全为空。排序 + 二分也能做,但 Trie 版把「前缀 → 候选」 + 变成一次读取,工程里的补全就是这么搭的。 + + ), + }, + }, + { + lc: 212, + title: { en: "Word Search II", zh: "单词搜索 II" }, + d: "hard", + tags: [ + { en: "Trie", zh: "字典树" }, + { en: "Grid backtracking", zh: "网格回溯" }, + { en: "Pruning", zh: "剪枝" }, + ], + hint: { + en: "Running a separate grid search for each word is too slow. Turn it around: build one trie from all the words and let a single DFS follow the trie, turning back as soon as the prefix does not exist.", + zh: "对每个词单独在网格里回溯会超时。反过来:把所有词建成一棵 Trie,让 DFS 沿着 Trie 走 —— 前缀不存在就立刻掉头。", + }, + key: { + en: ( + <> + Build a trie from words and store the whole word on its last node. Then + DFS from every cell, moving down the trie in step with the grid. + If the current letter has no matching child in the trie,{" "} + return immediately — no word starts this way, so the entire + branch can be dropped. That single check is what removes most of the + search. When you land on a node that holds a word, collect it and clear + the field so it is not collected twice. One grid traversal tests all + the words at once, because words that share a prefix share one path. + + ), + zh: ( + <> + 把 words 建成 Trie,词尾节点上存整个单词。从每个格子出发 DFS, + 在网格上走一步,就在 Trie 上下沉一步:当前字母在 Trie 里没有对应 + child 就立刻返回 —— 没有任何词这样开头,整条分支可以直接丢掉。 + 正是这一步砍掉了大部分搜索。走到存着单词的节点就收集它,并把该字段清空以免重复收集。 + 共享前缀的词共用一条路径,所以一次网格遍历就把所有词一起测完了。 + + ), + }, + }, + { + lc: 421, + title: { + en: "Maximum XOR of Two Numbers in an Array", + zh: "数组中两个数的最大异或值", + }, + d: "medium", + tags: [ + { en: "0/1 Trie", zh: "0-1 Trie" }, + { en: "Bit manipulation", zh: "位运算" }, + { en: "Greedy", zh: "贪心" }, + ], + hint: { + en: "Insert each number into a trie that has only two branches, 0 and 1, one bit at a time from the highest bit down. XOR gives 1 on a bit exactly when the two bits differ, so at every bit you want the opposite one.", + zh: "把每个数按二进制从高位到低位插进一棵「只有 0/1 两个分支」的 Trie。异或在某一位得 1,当且仅当两个比特不同 —— 所以每一位都想找和自己相反的比特。", + }, + key: { + en: ( + <> + Build a 0/1 trie: read each number from its highest bit (bit 30 is + enough for values below 2^31) down to bit 0, taking the 0 branch or the + 1 branch. Then walk each number through the tree again and{" "} + prefer the branch holding the opposite bit at every level. If + that branch exists, this bit of the XOR becomes 1, and a higher bit is + always worth more than all lower bits together, so the greedy choice is + safe. Keep the largest result. The cost is O(n × number of bits), which + replaces the O(n²) comparison of every pair. This is the classic step + from storing characters to storing bits. + + ), + zh: ( + <> + 建 0/1 Trie:每个数从最高位(小于 2^31 的数,第 30 位就够)读到第 0 位, + 按该位取 0 分支或 1 分支插入。再让每个数走一遍, + 每一层都优先拐向与自己相反的比特:该分支存在,这一位异或就得 1; + 而高位的价值永远大于其后所有低位之和,所以这样贪心是安全的。取全局最大值。 + 代价 O(n × 位数),取代了 O(n²) 的两两配对。这是 Trie 从「存字符」推广到「存比特」的经典一步。 + + ), + }, + }, +]; diff --git a/lib/trie-data.tsx b/lib/trie-quiz.tsx similarity index 51% rename from lib/trie-data.tsx rename to lib/trie-quiz.tsx index bb685b0..048be5e 100644 --- a/lib/trie-data.tsx +++ b/lib/trie-quiz.tsx @@ -1,309 +1,9 @@ -// Chapter 10 · Tries (prefix trees) — problem set and quiz data (English default / Chinese toggle). -// Problems focus on three patterns — prefix matching, dictionary trie, and 0-1 trie — ordered from -// easy to hard; hint points a direction without spoilers, key explains the optimal solution in one -// paragraph. +// Chapter 10 · Tries (prefix trees) — closing quiz (the problem set is in lib/trie-problems.tsx). // -// Bilingual: title / tags / hint / key / q / opts / why are all written as { en, zh }, -// and the en side of each problem title uses the official LeetCode English name. +// Bilingual: every question, option and explanation is a { en, zh } pair. -import type { Problem } from "@/lib/problems"; import type { QuizItem } from "@/lib/quiz"; -export const PROBLEMS: Problem[] = [ - { - lc: 208, - title: { - en: "Implement Trie (Prefix Tree)", - zh: "实现 Trie(前缀树)", - }, - d: "medium", - tags: [ - { en: "Template", zh: "模板题" }, - { en: "Trie", zh: "字典树" }, - ], - hint: { - en: "§04 of this chapter is the complete answer. A node holds children (a map, or an array of fixed alphabet size) plus one boolean isEnd. All three methods do the same thing: walk down the path.", - zh: "本章 §04 就是它的完整答案。节点 = children(Map 或定长数组)+ isEnd 布尔位,三个方法都在「沿路径走」。", - }, - key: { - en: ( - <> - insert: follow the characters, create a node wherever the edge is - missing, and set isEnd = true on the last node. search: follow the - characters, and return true only if you arrive and that node has isEnd - = true. startsWith: the same walk, but arriving is enough — isEnd is - not checked. All three cost O(L), where L is the length of the string - you passed in. The number of words already stored does not appear in - that cost. Every later Trie problem adds something to these three - skeletons. - - ), - zh: ( - <> - insert:沿字符走,缺哪条边就新建节点,末节点 isEnd = true。search: - 同样走到底,还要求末节点 isEnd = true 才算命中。startsWith:走得通即可, - 不看 isEnd。三个方法都是 O(L),L = 传入字符串的长度 —— - 词典里已经存了多少词,不出现在这个代价里。所有后续 Trie - 题都在这三块骨架上加东西。 - - ), - }, - }, - { - lc: 211, - title: { - en: "Design Add and Search Words Data Structure", - zh: "添加与搜索单词 - 数据结构设计", - }, - d: "medium", - tags: [ - { en: "Trie", zh: "字典树" }, - { en: "DFS", zh: "DFS" }, - { en: "Wildcard", zh: "通配符" }, - ], - hint: { - en: "A normal letter tells you which edge to take. A '.' does not, so try every child edge in turn.", - zh: "普通字母告诉你该走哪条边;'.' 不告诉你,那就每条子边都试一遍。", - }, - key: { - en: ( - <> - addWord is the same as LC 208. search becomes a recursive walk dfs - (word, i, node). A normal character recurses into that one child. A - '.' loops over every child of the current node and recurses into - each one, returning true as soon as any branch succeeds. At the end of - the word, check isEnd. The worst case, a pattern made only of dots, - fans out to O(26^L), but the search also never visits more nodes than - the trie contains, and a missing edge stops a branch immediately. This - is the smallest example of Trie plus backtracking. - - ), - zh: ( - <> - addWord 与 LC 208 相同。search 改成递归 dfs(word, i, node):普通字符只往对应 - child 递归;遇到 '.' 就遍历当前节点的所有 child 分别递归, - 任意一条返回 true 就立刻返回。走到词尾看 isEnd。最坏情况(全是 '.')会扇出到 - O(26^L),但搜索访问的节点数同时也不会超过整棵树的节点数,而且缺边会立刻中断一条分支。 - 这是「Trie + 回溯」的最小案例。 - - ), - }, - }, - { - lc: 648, - title: { en: "Replace Words", zh: "单词替换" }, - d: "medium", - tags: [ - { en: "Trie", zh: "字典树" }, - { en: "Prefix matching", zh: "前缀匹配" }, - ], - hint: { - en: "Put every root word into a trie. For each word in the sentence, walk down from the top and stop at the first node with isEnd = true. That node is the shortest matching root.", - zh: "把所有词根放进 Trie。对句子里的每个单词,从根沿 Trie 走,第一次遇到 isEnd 就停 —— 那就是最短词根。", - }, - key: { - en: ( - <> - Build the trie from the root words. For each word in the sentence, - descend from the root one character at a time. As soon as the current - node has isEnd = true, a root word matches this prefix, so replace the - word and stop. If the path breaks, or you reach the end of the word - without meeting isEnd, keep the original word. Each word costs O(its - own length). This is the most direct use of a trie as a prefix test. - - ), - zh: ( - <> - 用词根建 Trie。遍历句子里每个单词,从 root 逐字符下沉:一旦当前节点 isEnd = - true,说明有词根匹配这段前缀,替换并停止;若中途断路或走完仍没遇到 isEnd, - 保留原词。每个单词 O(自身长度)。这题把「用 Trie 判前缀」用得最直白。 - - ), - }, - }, - { - lc: 677, - title: { en: "Map Sum Pairs", zh: "键值映射" }, - d: "medium", - tags: [ - { en: "Trie", zh: "字典树" }, - { en: "Prefix sum", zh: "前缀求和" }, - ], - hint: { - en: "insert stores val on the last node. sum(prefix) walks to the prefix node, then adds up the values of every word in the subtree below it.", - zh: "insert 时把 val 存在末节点;sum(prefix) = 先走到前缀节点,再把它子树里所有词的 val 加起来。", - }, - key: { - en: ( - <> - Store one more field, val, on the node where a key ends. sum(prefix) - walks O(L) to the prefix node, then runs a DFS over that subtree and - adds up every stored val. There is a faster version: on insert, - compute delta = new value − old value for this key, and add delta to a - running prefix total kept on every node along the path. sum then - reads one number and costs O(L). The pattern worth remembering is - storing an aggregate on the node itself. - - ), - zh: ( - <> - 在键结束的那个节点上多存一个 val。sum(prefix):先 O(L) 走到前缀节点, - 再 DFS 这棵子树累加所有 val。还有更快的写法:insert 时算出 delta = - 新值 − 该 key 的旧值,把 delta 加到路径上每个节点维护的「前缀累加值」上, - sum 就变成读一个数、O(L)。值得记住的套路是「把聚合信息挂在节点上」。 - - ), - }, - }, - { - lc: 720, - title: { en: "Longest Word in Dictionary", zh: "词典中最长的单词" }, - d: "medium", - tags: [ - { en: "Trie", zh: "字典树" }, - { en: "DFS", zh: "DFS" }, - ], - hint: { - en: "A word qualifies only if every one of its prefixes is also a word in the dictionary, which means every node along its path must have isEnd = true.", - zh: "一个词能入选,要求它的每一个前缀也都是词典里的词 —— 也就是路径上每个节点都得 isEnd = true。", - }, - key: { - en: ( - <> - Build the trie, then run a DFS that only descends into children with - isEnd = true. That rule is exactly the requirement that every prefix is - also a word. Keep the longest word you can reach. If two are the same - length, the answer is the smaller one in alphabetical order, and - visiting children from a to z and only replacing the answer when the - new word is strictly longer gives that for you. The problem is a search - for the longest chain where every step is itself valid. - - ), - zh: ( - <> - 建 Trie 后 DFS,只往 isEnd = true 的子节点走 —— 这条规则正好等价于 - 「每个前缀也是单词」。记录能到达的最长单词;长度相同取字典序最小, - 按 a→z 顺序遍历子节点、且只在严格更长时才替换答案,就自然满足。 - 本质是在 Trie 上找「每一步都合法」的最长链。 - - ), - }, - }, - { - lc: 1268, - title: { en: "Search Suggestions System", zh: "搜索推荐系统" }, - d: "medium", - tags: [ - { en: "Trie", zh: "字典树" }, - { en: "Autocomplete", zh: "自动补全" }, - ], - hint: { - en: "This is a search box in miniature: after each typed letter, return the 3 alphabetically smallest products that start with what has been typed so far.", - zh: "这就是搜索框补全的原型:每输入一个字母,给出以当前已输入内容为前缀、字典序最小的 3 个商品。", - }, - key: { - en: ( - <> - Build the trie and keep, on each node, a list of the 3 alphabetically - smallest words under that prefix. Maintain the list during insertion - and drop the largest whenever it grows past 3. As the user types, walk - one node per character and read the list directly. Once the path - breaks, every later answer is empty. Sorting the words and binary - searching also solves the problem, but the trie version turns prefix to - suggestions into a single read, which is how autocomplete is usually - built. - - ), - zh: ( - <> - 建 Trie,并在每个节点上存「该前缀下字典序最小的 3 个词」;插入时维护这个列表, - 超过 3 个就丢掉最大的。用户逐字符输入时每次下沉一个节点,直接读节点上的列表; - 一旦断路,后续答案全为空。排序 + 二分也能做,但 Trie 版把「前缀 → 候选」 - 变成一次读取,工程里的补全就是这么搭的。 - - ), - }, - }, - { - lc: 212, - title: { en: "Word Search II", zh: "单词搜索 II" }, - d: "hard", - tags: [ - { en: "Trie", zh: "字典树" }, - { en: "Grid backtracking", zh: "网格回溯" }, - { en: "Pruning", zh: "剪枝" }, - ], - hint: { - en: "Running a separate grid search for each word is too slow. Turn it around: build one trie from all the words and let a single DFS follow the trie, turning back as soon as the prefix does not exist.", - zh: "对每个词单独在网格里回溯会超时。反过来:把所有词建成一棵 Trie,让 DFS 沿着 Trie 走 —— 前缀不存在就立刻掉头。", - }, - key: { - en: ( - <> - Build a trie from words and store the whole word on its last node. Then - DFS from every cell, moving down the trie in step with the grid. - If the current letter has no matching child in the trie,{" "} - return immediately — no word starts this way, so the entire - branch can be dropped. That single check is what removes most of the - search. When you land on a node that holds a word, collect it and clear - the field so it is not collected twice. One grid traversal tests all - the words at once, because words that share a prefix share one path. - - ), - zh: ( - <> - 把 words 建成 Trie,词尾节点上存整个单词。从每个格子出发 DFS, - 在网格上走一步,就在 Trie 上下沉一步:当前字母在 Trie 里没有对应 - child 就立刻返回 —— 没有任何词这样开头,整条分支可以直接丢掉。 - 正是这一步砍掉了大部分搜索。走到存着单词的节点就收集它,并把该字段清空以免重复收集。 - 共享前缀的词共用一条路径,所以一次网格遍历就把所有词一起测完了。 - - ), - }, - }, - { - lc: 421, - title: { - en: "Maximum XOR of Two Numbers in an Array", - zh: "数组中两个数的最大异或值", - }, - d: "medium", - tags: [ - { en: "0/1 Trie", zh: "0-1 Trie" }, - { en: "Bit manipulation", zh: "位运算" }, - { en: "Greedy", zh: "贪心" }, - ], - hint: { - en: "Insert each number into a trie that has only two branches, 0 and 1, one bit at a time from the highest bit down. XOR gives 1 on a bit exactly when the two bits differ, so at every bit you want the opposite one.", - zh: "把每个数按二进制从高位到低位插进一棵「只有 0/1 两个分支」的 Trie。异或在某一位得 1,当且仅当两个比特不同 —— 所以每一位都想找和自己相反的比特。", - }, - key: { - en: ( - <> - Build a 0/1 trie: read each number from its highest bit (bit 30 is - enough for values below 2^31) down to bit 0, taking the 0 branch or the - 1 branch. Then walk each number through the tree again and{" "} - prefer the branch holding the opposite bit at every level. If - that branch exists, this bit of the XOR becomes 1, and a higher bit is - always worth more than all lower bits together, so the greedy choice is - safe. Keep the largest result. The cost is O(n × number of bits), which - replaces the O(n²) comparison of every pair. This is the classic step - from storing characters to storing bits. - - ), - zh: ( - <> - 建 0/1 Trie:每个数从最高位(小于 2^31 的数,第 30 位就够)读到第 0 位, - 按该位取 0 分支或 1 分支插入。再让每个数走一遍, - 每一层都优先拐向与自己相反的比特:该分支存在,这一位异或就得 1; - 而高位的价值永远大于其后所有低位之和,所以这样贪心是安全的。取全局最大值。 - 代价 O(n × 位数),取代了 O(n²) 的两两配对。这是 Trie 从「存字符」推广到「存比特」的经典一步。 - - ), - }, - }, -]; - export const QUIZ: QuizItem[] = [ { type: "choice", diff --git a/lib/union-find-data.tsx b/lib/union-find-problems.tsx similarity index 50% rename from lib/union-find-data.tsx rename to lib/union-find-problems.tsx index 4a8213d..021997a 100644 --- a/lib/union-find-data.tsx +++ b/lib/union-find-problems.tsx @@ -1,17 +1,17 @@ -// Chapter 11 · Union-find — problem set and quiz data (English default / Chinese toggle). +// Chapter 11 · Union-find — problem set (English default / Chinese toggle). +// The closing quiz is in lib/union-find-quiz.tsx; /atlas imports only this file. // Union-find problems are frequent but tightly clustered: connectivity checks, counting connected // components, merging equivalence classes, and cycle detection. // The set runs easy to hard; LC 305 is premium-only, so 2316 stands in (same "count the connected // components" idea). // -// Problem titles use the official LeetCode English name; tags / hint / key and the quiz strings are -// all { en, zh } pairs. +// Problem titles use the official LeetCode English name; tags / hint / key are all +// { en, zh } pairs. // Complexity convention: with both optimizations enabled, m operations cost O(m·α(n)) in total, where // α is the inverse Ackermann function; every n that actually fits in memory has α(n) ≤ 4 — the whole // chapter says "near-constant" rather than writing O(1). import type { Problem } from "@/lib/problems"; -import type { QuizItem } from "@/lib/quiz"; export const PROBLEMS: Problem[] = [ { @@ -341,292 +341,3 @@ export const PROBLEMS: Problem[] = [ }, }, ]; - -export const QUIZ: QuizItem[] = [ - { - type: "choice", - q: { - en: "What exactly does find(x) return in a Union-Find structure?", - zh: "并查集里 find(x) 的准确语义是什么?", - }, - opts: [ - { - en: "The representative of the set that contains x, that is the root of its tree, so two elements are in the same set when their roots are equal", - zh: "x 所在集合的代表元(所在树的根)—— 根相同就说明两个元素同属一个集合", - }, - { en: "The index of x in the array", zh: "x 在数组里的下标" }, - { - en: "The direct parent of x, that is parent[x]", - zh: "x 的直接父节点 parent[x]", - }, - { - en: "The number of elements in the set that contains x", - zh: "x 所在集合的元素个数", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "x is already the index, or has already been mapped to an index by a hash map. Nothing needs to be looked up. find looks for the root.", - zh: "x 本身就是下标(或已被哈希表映射成下标),不需要「找」。find 找的是它的根。", - }, - { - en: "parent[x] is only one level up, and it is usually not the root. find keeps climbing until it reaches the r where parent[r] == r.", - zh: "parent[x] 只是「上一级」,通常不是根 —— find 要一路爬到 parent[r] == r 的那个 r 才停。", - }, - { - en: "The size of a set is only known if you maintain an extra size array. find answers a different question: who is the representative.", - zh: "集合大小要额外维护一个 size 数组才知道,find 只回答「代表元是谁」。", - }, - ], - why: { - en: "find returns the representative of a set, so find(a) === find(b) is exactly the same statement as \"a and b are connected\". Which element is chosen as the representative does not matter. What matters is that one set always gives one answer, which is how Union-Find turns \"are they connected\" into \"are these two numbers equal\".", - zh: "find 返回集合的代表元,所以 find(a) === find(b) 与「a、b 连通」是同一句话。代表元具体是谁并不重要,重要的是同一集合的答案唯一 —— 并查集正是这样把「连通吗」变成「相等吗」。", - }, - }, - { - type: "choice", - q: { - en: "Why must union(a, b) find both roots first and then point one root at the other? Why not just write parent[a] = b?", - zh: "union(a, b) 为什么必须先 find 出两边的根,再让一个根指向另一个根?直接 parent[a] = b 不行吗?", - }, - opts: [ - { - en: "It is wrong: union merges two sets. Changing parent[a] directly tears a out of its own tree and leaves the rest of its set behind.", - zh: "不行 —— union 合并的是两个集合,直接改 parent[a] 会把 a 从原来的树里撕出来,原集合的其余成员被丢下", - }, - { en: "It works, it is just a little slower", zh: "可以,只是稍微慢一点" }, - { - en: "It is wrong, because parent[a] is only allowed to equal a", - zh: "不行,因为 parent[a] 只能等于 a", - }, - { - en: "It works as long as neither a nor b is a root", - zh: "可以,只要 a 和 b 都不是根", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "This is not about speed, it is about correctness. The descendants of a follow it, but everything above a in its old tree keeps the old root, so one set is split into two.", - zh: "这不是快慢问题,是正确性问题:a 的子孙会跟着走,但 a 头上那条祖先链仍留着旧的根,原来的一个集合被切成了两个。", - }, - { - en: "parent[a] = a is just how \"a is a root\" is written down. A non-root element points at another element by definition.", - zh: "parent[a] = a 只是「a 是根」的写法,非根节点的 parent 本来就指向别人。", - }, - { - en: "The opposite is true. Only changing the parent of a root moves the whole tree, that is the whole set, in one assignment.", - zh: "恰恰相反:只有改「根」的 parent,才能一次赋值把整棵树(整个集合)带过去。", - }, - ], - why: { - en: "Every element of a tree reaches the same root, so changing the parent of that root moves the entire set at once. Changing a non-root element only moves that one element and breaks its old set apart.", - zh: "一棵树里的每个元素最终都爬到同一个根,所以改掉这个根的 parent,整个集合一次性换了代表元。改一个非根节点,只会把它一个人挪走,还把原集合拆散。", - }, - }, - { - type: "choice", - q: { - en: "What does path compression change?", - zh: "路径压缩(path compression)到底改变了什么?", - }, - opts: [ - { - en: "It repoints the nodes visited during find directly at the root, which makes the tree flatter, so later find calls on those nodes are shorter", - zh: "把 find 沿途经过的节点直接改挂到根上,树变扁,这些节点之后的 find 就更短", - }, - { - en: "It changes which set an element belongs to", - zh: "改变了集合的划分 —— 压缩后某些元素换了集合", - }, - { - en: "It lowers the cost of union from O(n) to O(1)", - zh: "把 union 的复杂度从 O(n) 降到 O(1)", - }, - { - en: "It reduces the memory used by the parent array", - zh: "减少了 parent 数组占用的内存", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Compression only changes who a node hangs under. The root it reaches stays the same, so every element keeps its set. Only the shape of the tree changes.", - zh: "压缩只改「挂在谁下面」,不改「最终爬到哪个根」—— 每个元素的集合归属完全不变,变的只是树的形状。", - }, - { - en: "Once the two roots are known, union is already a single assignment. The expensive part is find, and that is what compression makes cheaper.", - zh: "拿到两个根之后,union 本来就只是一次赋值;贵的是 find,压缩优化的正是 find。", - }, - { - en: "The parent array keeps exactly the same number of slots. Compression saves time, not space.", - zh: "parent 数组一格没多一格没少,省的是时间不是空间。", - }, - ], - why: { - en: "find has to walk from x up to the root anyway. Path compression uses that walk to repoint every node along it at the root, so the tree gets flatter as it is used. It is one of the two optimizations behind the O(α(n)) amortized bound.", - zh: "find 本来就要从 x 走到根,路径压缩顺手把沿途每个节点都改指向根,树越用越扁。它是 O(α(n)) 均摊复杂度背后的两个优化之一。", - }, - }, - { - type: "choice", - q: { - en: "What does union by rank prevent?", - zh: "按秩合并(union by rank)防的是什么?", - }, - opts: [ - { - en: "It prevents attaching the taller tree under the shorter one, which would make the merged tree one level taller than it needs to be", - zh: "防止把高树挂到矮树下面,让合并后的树比必要的高度多一层", - }, - { - en: "It prevents merging two sets that already have the same root", - zh: "防止两个根相同的集合被重复合并", - }, - { - en: "It prevents the parent array from going out of bounds", - zh: "防止 parent 数组越界", - }, - { - en: "It prevents path compression from stopping working", - zh: "防止路径压缩失效", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "That case is handled at the top of union: find both roots and compare them. No rank is needed for it.", - zh: "根相同的情况在 union 开头判掉就行(find 两次比较一下),不需要 rank。", - }, - { - en: "Going out of bounds is an indexing mistake and has nothing to do with the merge strategy.", - zh: "越界是写代码的事,和合并策略无关。", - }, - { - en: "The two optimizations are independent. Neither depends on the other, they just work best together.", - zh: "两个优化各自独立,谁也不依赖谁,只是叠加使用效果最好。", - }, - ], - why: { - en: "Attach the shorter tree under the taller one and the merged height is max(taller, shorter + 1) = taller, so the height does not grow. Do it the other way and the height always grows by 1. The heights only grow when the two trees are equally tall, and that is why union by rank alone already keeps the height within O(log n).", - zh: "矮树挂到高树下,合并后的高度是 max(高树高, 矮树高 + 1) = 高树高,不变;反过来挂,高度必然 +1。只有两棵树等高时高度才会增长,所以单靠按秩合并,树高就已经压在 O(log n) 以内。", - }, - }, - { - type: "choice", - q: { - en: "With both optimizations, m operations on n elements cost O(m·α(n)), where α is the inverse Ackermann function. What is the right intuition for α(n)?", - zh: "两个优化都开启后,n 个元素上的 m 次操作总代价是 O(m·α(n)),α 是反阿克曼函数。对 α(n) 的正确直觉是?", - }, - opts: [ - { - en: "It grows extremely slowly: for every n you could ever store, α(n) is at most 4, so each operation is effectively constant", - zh: "增长极慢:任何你存得下的 n,α(n) 都不超过 4 —— 每次操作可以当成近乎常数", - }, - { - en: "α(n) is about log n, the same order as binary search", - zh: "α(n) ≈ log n,和二分查找一个量级", - }, - { - en: "α(n) is only a theoretical bound, and in practice the structure runs far slower than that", - zh: "α(n) 是个理论值,实际运行远比它慢", - }, - { - en: "α(n) grows linearly in n, only with a small constant", - zh: "α(n) 随 n 线性增长,只是系数很小", - }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "It grows far more slowly than log n. log₂(10⁸⁰) is about 266, while α(10⁸⁰) is 4. Even log* and log log n grow faster than α.", - zh: "它比 log n 慢得多:log₂(10⁸⁰) ≈ 266,而 α(10⁸⁰) = 4。连 log*、log log n 都比 α 增长得快。", - }, - { - en: "In practice an optimized Union-Find runs close to plain array access. α(n) ≤ 4 is the precise way of saying \"effectively constant\".", - zh: "实测里优化后的并查集快得接近数组随机访问 —— α(n) ≤ 4 正是「近乎常数」的精确说法。", - }, - { - en: "Not close. α is the inverse of the Ackermann function, which is one of the fastest growing functions, so its inverse is one of the slowest growing.", - zh: "差得远:α 是阿克曼函数(增长最猛的函数之一)的反函数,所以它是增长最慢的函数之一。", - }, - ], - why: { - en: "The Ackermann function grows extremely fast, so its inverse grows extremely slowly: α(n) does not exceed 4 for any n up to 2^65536, far more elements than any machine could hold. That is why the cost is called effectively constant rather than O(1). It is an amortized bound with a proof (Tarjan, 1975), not an approximation.", - zh: "阿克曼函数增长极快,它的反函数因此增长极慢:直到 n 大约为 2^65536,α(n) 都不超过 4,而这么多元素远超任何机器能容纳的数量。所以说它「近乎常数」而不写成 O(1) —— 这是一个有证明的均摊上界(Tarjan, 1975),不是粗略的近似。", - }, - }, - { - type: "choice", - q: { - en: "Both answer connectivity questions. When is Union-Find clearly better than DFS or BFS?", - zh: "同样是判连通,什么场景下并查集明显优于 DFS/BFS?", - }, - opts: [ - { - en: "Edges keep arriving over time and connectivity queries are mixed in between them. Union-Find updates incrementally, while DFS has to traverse again for every query.", - zh: "边不断动态加入、期间穿插大量「连通吗」查询 —— 并查集增量维护,DFS 每问一次都要重新遍历", - }, - { - en: "When you need to output the actual path between two vertices", - zh: "需要输出两点之间的具体路径时", - }, - { en: "When edges have to be deleted", zh: "需要删除边的时候" }, - { en: "When the graph is a tree", zh: "图是一棵树的时候" }, - ], - correct: 0, - wrong: [ - undefined, - { - en: "Union-Find only records who is in which set. The path information is dropped during union, so a path needs DFS or BFS.", - zh: "并查集只记「谁和谁同集合」,路径信息在 union 时就丢掉了 —— 要路径必须 DFS/BFS。", - }, - { - en: "A standard Union-Find merges but never splits, so deletion is its weak point. It needs a rollback variant, or the whole sequence of operations processed in reverse.", - zh: "标准并查集只合并、不拆分,删边正是它的短板 —— 要么用可撤销变体,要么把整串操作倒着处理。", - }, - { - en: "On a static tree one DFS preprocessing pass is enough. Union-Find is for the case where edges keep being added.", - zh: "静态的树上判连通,DFS 一次预处理也够;并查集的主场是「边一直在加」的动态过程。", - }, - ], - why: { - en: "Edges keep arriving and you only ask whether two elements are connected: use Union-Find. You need the path itself or a traversal order: use DFS or BFS. Edges have to be removed: neither works directly, and the usual fix is to process the operations in reverse so that every deletion becomes an addition.", - zh: "边一直在加、只问连通性 → 并查集;要具体路径或遍历顺序 → DFS/BFS;要删边 → 两者都不直接可用,常见做法是把操作倒序处理,让每次删除变成一次加入。", - }, - }, - { - type: "fill", - q: { - en: ( - <> - You count components with Union-Find. There are n = 10 elements at the - start, so count = 10. You run 7 unions, and in 2 of them the two sides - already had the same root. What is count now? - - ), - zh: ( - <> - 用并查集统计连通块:初始 n = 10 个元素,count = 10。执行 7 次 - union,其中 2 次发现两边的根本来就相同。现在 count 是多少? - - ), - }, - placeholder: { en: "Type a number…", zh: "输入数字…" }, - answers: ["5"], - hint: { - en: "count only decreases when two different sets actually become one. A union whose two sides share a root changes nothing.", - zh: "count 只在「两个不同集合真的并成一个」时减 1;两边根相同的 union 什么都没改。", - }, - why: { - en: "Only 5 of the 7 unions merged two different sets, and each of those lowers count by 1: 10 − 5 = 5. The other 2 changed nothing, so decrementing for them would report fewer components than really exist. That is why union returns a boolean and count is only decremented when it returns true.", - zh: "7 次 union 里只有 5 次真的把两个不同集合并成一个,每次 count 减 1:10 − 5 = 5。另外 2 次什么也没改,如果照样减 1,报出的连通块数就会比实际少。这正是 union 要返回 boolean、只在返回 true 时才 count−− 的原因。", - }, - }, -]; diff --git a/lib/union-find-quiz.tsx b/lib/union-find-quiz.tsx new file mode 100644 index 0000000..ffac32d --- /dev/null +++ b/lib/union-find-quiz.tsx @@ -0,0 +1,297 @@ +// Chapter 11 · Union-find — closing quiz (the problem set is in lib/union-find-problems.tsx). +// +// Bilingual: every question, option and explanation is a { en, zh } pair. +// Complexity convention: with both optimizations enabled, m operations cost O(m·α(n)) in total, where +// α is the inverse Ackermann function; every n that actually fits in memory has α(n) ≤ 4 — the whole +// chapter says "near-constant" rather than writing O(1). + +import type { QuizItem } from "@/lib/quiz"; + +export const QUIZ: QuizItem[] = [ + { + type: "choice", + q: { + en: "What exactly does find(x) return in a Union-Find structure?", + zh: "并查集里 find(x) 的准确语义是什么?", + }, + opts: [ + { + en: "The representative of the set that contains x, that is the root of its tree, so two elements are in the same set when their roots are equal", + zh: "x 所在集合的代表元(所在树的根)—— 根相同就说明两个元素同属一个集合", + }, + { en: "The index of x in the array", zh: "x 在数组里的下标" }, + { + en: "The direct parent of x, that is parent[x]", + zh: "x 的直接父节点 parent[x]", + }, + { + en: "The number of elements in the set that contains x", + zh: "x 所在集合的元素个数", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "x is already the index, or has already been mapped to an index by a hash map. Nothing needs to be looked up. find looks for the root.", + zh: "x 本身就是下标(或已被哈希表映射成下标),不需要「找」。find 找的是它的根。", + }, + { + en: "parent[x] is only one level up, and it is usually not the root. find keeps climbing until it reaches the r where parent[r] == r.", + zh: "parent[x] 只是「上一级」,通常不是根 —— find 要一路爬到 parent[r] == r 的那个 r 才停。", + }, + { + en: "The size of a set is only known if you maintain an extra size array. find answers a different question: who is the representative.", + zh: "集合大小要额外维护一个 size 数组才知道,find 只回答「代表元是谁」。", + }, + ], + why: { + en: "find returns the representative of a set, so find(a) === find(b) is exactly the same statement as \"a and b are connected\". Which element is chosen as the representative does not matter. What matters is that one set always gives one answer, which is how Union-Find turns \"are they connected\" into \"are these two numbers equal\".", + zh: "find 返回集合的代表元,所以 find(a) === find(b) 与「a、b 连通」是同一句话。代表元具体是谁并不重要,重要的是同一集合的答案唯一 —— 并查集正是这样把「连通吗」变成「相等吗」。", + }, + }, + { + type: "choice", + q: { + en: "Why must union(a, b) find both roots first and then point one root at the other? Why not just write parent[a] = b?", + zh: "union(a, b) 为什么必须先 find 出两边的根,再让一个根指向另一个根?直接 parent[a] = b 不行吗?", + }, + opts: [ + { + en: "It is wrong: union merges two sets. Changing parent[a] directly tears a out of its own tree and leaves the rest of its set behind.", + zh: "不行 —— union 合并的是两个集合,直接改 parent[a] 会把 a 从原来的树里撕出来,原集合的其余成员被丢下", + }, + { en: "It works, it is just a little slower", zh: "可以,只是稍微慢一点" }, + { + en: "It is wrong, because parent[a] is only allowed to equal a", + zh: "不行,因为 parent[a] 只能等于 a", + }, + { + en: "It works as long as neither a nor b is a root", + zh: "可以,只要 a 和 b 都不是根", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "This is not about speed, it is about correctness. The descendants of a follow it, but everything above a in its old tree keeps the old root, so one set is split into two.", + zh: "这不是快慢问题,是正确性问题:a 的子孙会跟着走,但 a 头上那条祖先链仍留着旧的根,原来的一个集合被切成了两个。", + }, + { + en: "parent[a] = a is just how \"a is a root\" is written down. A non-root element points at another element by definition.", + zh: "parent[a] = a 只是「a 是根」的写法,非根节点的 parent 本来就指向别人。", + }, + { + en: "The opposite is true. Only changing the parent of a root moves the whole tree, that is the whole set, in one assignment.", + zh: "恰恰相反:只有改「根」的 parent,才能一次赋值把整棵树(整个集合)带过去。", + }, + ], + why: { + en: "Every element of a tree reaches the same root, so changing the parent of that root moves the entire set at once. Changing a non-root element only moves that one element and breaks its old set apart.", + zh: "一棵树里的每个元素最终都爬到同一个根,所以改掉这个根的 parent,整个集合一次性换了代表元。改一个非根节点,只会把它一个人挪走,还把原集合拆散。", + }, + }, + { + type: "choice", + q: { + en: "What does path compression change?", + zh: "路径压缩(path compression)到底改变了什么?", + }, + opts: [ + { + en: "It repoints the nodes visited during find directly at the root, which makes the tree flatter, so later find calls on those nodes are shorter", + zh: "把 find 沿途经过的节点直接改挂到根上,树变扁,这些节点之后的 find 就更短", + }, + { + en: "It changes which set an element belongs to", + zh: "改变了集合的划分 —— 压缩后某些元素换了集合", + }, + { + en: "It lowers the cost of union from O(n) to O(1)", + zh: "把 union 的复杂度从 O(n) 降到 O(1)", + }, + { + en: "It reduces the memory used by the parent array", + zh: "减少了 parent 数组占用的内存", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Compression only changes who a node hangs under. The root it reaches stays the same, so every element keeps its set. Only the shape of the tree changes.", + zh: "压缩只改「挂在谁下面」,不改「最终爬到哪个根」—— 每个元素的集合归属完全不变,变的只是树的形状。", + }, + { + en: "Once the two roots are known, union is already a single assignment. The expensive part is find, and that is what compression makes cheaper.", + zh: "拿到两个根之后,union 本来就只是一次赋值;贵的是 find,压缩优化的正是 find。", + }, + { + en: "The parent array keeps exactly the same number of slots. Compression saves time, not space.", + zh: "parent 数组一格没多一格没少,省的是时间不是空间。", + }, + ], + why: { + en: "find has to walk from x up to the root anyway. Path compression uses that walk to repoint every node along it at the root, so the tree gets flatter as it is used. It is one of the two optimizations behind the O(α(n)) amortized bound.", + zh: "find 本来就要从 x 走到根,路径压缩顺手把沿途每个节点都改指向根,树越用越扁。它是 O(α(n)) 均摊复杂度背后的两个优化之一。", + }, + }, + { + type: "choice", + q: { + en: "What does union by rank prevent?", + zh: "按秩合并(union by rank)防的是什么?", + }, + opts: [ + { + en: "It prevents attaching the taller tree under the shorter one, which would make the merged tree one level taller than it needs to be", + zh: "防止把高树挂到矮树下面,让合并后的树比必要的高度多一层", + }, + { + en: "It prevents merging two sets that already have the same root", + zh: "防止两个根相同的集合被重复合并", + }, + { + en: "It prevents the parent array from going out of bounds", + zh: "防止 parent 数组越界", + }, + { + en: "It prevents path compression from stopping working", + zh: "防止路径压缩失效", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "That case is handled at the top of union: find both roots and compare them. No rank is needed for it.", + zh: "根相同的情况在 union 开头判掉就行(find 两次比较一下),不需要 rank。", + }, + { + en: "Going out of bounds is an indexing mistake and has nothing to do with the merge strategy.", + zh: "越界是写代码的事,和合并策略无关。", + }, + { + en: "The two optimizations are independent. Neither depends on the other, they just work best together.", + zh: "两个优化各自独立,谁也不依赖谁,只是叠加使用效果最好。", + }, + ], + why: { + en: "Attach the shorter tree under the taller one and the merged height is max(taller, shorter + 1) = taller, so the height does not grow. Do it the other way and the height always grows by 1. The heights only grow when the two trees are equally tall, and that is why union by rank alone already keeps the height within O(log n).", + zh: "矮树挂到高树下,合并后的高度是 max(高树高, 矮树高 + 1) = 高树高,不变;反过来挂,高度必然 +1。只有两棵树等高时高度才会增长,所以单靠按秩合并,树高就已经压在 O(log n) 以内。", + }, + }, + { + type: "choice", + q: { + en: "With both optimizations, m operations on n elements cost O(m·α(n)), where α is the inverse Ackermann function. What is the right intuition for α(n)?", + zh: "两个优化都开启后,n 个元素上的 m 次操作总代价是 O(m·α(n)),α 是反阿克曼函数。对 α(n) 的正确直觉是?", + }, + opts: [ + { + en: "It grows extremely slowly: for every n you could ever store, α(n) is at most 4, so each operation is effectively constant", + zh: "增长极慢:任何你存得下的 n,α(n) 都不超过 4 —— 每次操作可以当成近乎常数", + }, + { + en: "α(n) is about log n, the same order as binary search", + zh: "α(n) ≈ log n,和二分查找一个量级", + }, + { + en: "α(n) is only a theoretical bound, and in practice the structure runs far slower than that", + zh: "α(n) 是个理论值,实际运行远比它慢", + }, + { + en: "α(n) grows linearly in n, only with a small constant", + zh: "α(n) 随 n 线性增长,只是系数很小", + }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "It grows far more slowly than log n. log₂(10⁸⁰) is about 266, while α(10⁸⁰) is 4. Even log* and log log n grow faster than α.", + zh: "它比 log n 慢得多:log₂(10⁸⁰) ≈ 266,而 α(10⁸⁰) = 4。连 log*、log log n 都比 α 增长得快。", + }, + { + en: "In practice an optimized Union-Find runs close to plain array access. α(n) ≤ 4 is the precise way of saying \"effectively constant\".", + zh: "实测里优化后的并查集快得接近数组随机访问 —— α(n) ≤ 4 正是「近乎常数」的精确说法。", + }, + { + en: "Not close. α is the inverse of the Ackermann function, which is one of the fastest growing functions, so its inverse is one of the slowest growing.", + zh: "差得远:α 是阿克曼函数(增长最猛的函数之一)的反函数,所以它是增长最慢的函数之一。", + }, + ], + why: { + en: "The Ackermann function grows extremely fast, so its inverse grows extremely slowly: α(n) does not exceed 4 for any n up to 2^65536, far more elements than any machine could hold. That is why the cost is called effectively constant rather than O(1). It is an amortized bound with a proof (Tarjan, 1975), not an approximation.", + zh: "阿克曼函数增长极快,它的反函数因此增长极慢:直到 n 大约为 2^65536,α(n) 都不超过 4,而这么多元素远超任何机器能容纳的数量。所以说它「近乎常数」而不写成 O(1) —— 这是一个有证明的均摊上界(Tarjan, 1975),不是粗略的近似。", + }, + }, + { + type: "choice", + q: { + en: "Both answer connectivity questions. When is Union-Find clearly better than DFS or BFS?", + zh: "同样是判连通,什么场景下并查集明显优于 DFS/BFS?", + }, + opts: [ + { + en: "Edges keep arriving over time and connectivity queries are mixed in between them. Union-Find updates incrementally, while DFS has to traverse again for every query.", + zh: "边不断动态加入、期间穿插大量「连通吗」查询 —— 并查集增量维护,DFS 每问一次都要重新遍历", + }, + { + en: "When you need to output the actual path between two vertices", + zh: "需要输出两点之间的具体路径时", + }, + { en: "When edges have to be deleted", zh: "需要删除边的时候" }, + { en: "When the graph is a tree", zh: "图是一棵树的时候" }, + ], + correct: 0, + wrong: [ + undefined, + { + en: "Union-Find only records who is in which set. The path information is dropped during union, so a path needs DFS or BFS.", + zh: "并查集只记「谁和谁同集合」,路径信息在 union 时就丢掉了 —— 要路径必须 DFS/BFS。", + }, + { + en: "A standard Union-Find merges but never splits, so deletion is its weak point. It needs a rollback variant, or the whole sequence of operations processed in reverse.", + zh: "标准并查集只合并、不拆分,删边正是它的短板 —— 要么用可撤销变体,要么把整串操作倒着处理。", + }, + { + en: "On a static tree one DFS preprocessing pass is enough. Union-Find is for the case where edges keep being added.", + zh: "静态的树上判连通,DFS 一次预处理也够;并查集的主场是「边一直在加」的动态过程。", + }, + ], + why: { + en: "Edges keep arriving and you only ask whether two elements are connected: use Union-Find. You need the path itself or a traversal order: use DFS or BFS. Edges have to be removed: neither works directly, and the usual fix is to process the operations in reverse so that every deletion becomes an addition.", + zh: "边一直在加、只问连通性 → 并查集;要具体路径或遍历顺序 → DFS/BFS;要删边 → 两者都不直接可用,常见做法是把操作倒序处理,让每次删除变成一次加入。", + }, + }, + { + type: "fill", + q: { + en: ( + <> + You count components with Union-Find. There are n = 10 elements at the + start, so count = 10. You run 7 unions, and in 2 of them the two sides + already had the same root. What is count now? + + ), + zh: ( + <> + 用并查集统计连通块:初始 n = 10 个元素,count = 10。执行 7 次 + union,其中 2 次发现两边的根本来就相同。现在 count 是多少? + + ), + }, + placeholder: { en: "Type a number…", zh: "输入数字…" }, + answers: ["5"], + hint: { + en: "count only decreases when two different sets actually become one. A union whose two sides share a root changes nothing.", + zh: "count 只在「两个不同集合真的并成一个」时减 1;两边根相同的 union 什么都没改。", + }, + why: { + en: "Only 5 of the 7 unions merged two different sets, and each of those lowers count by 1: 10 − 5 = 5. The other 2 changed nothing, so decrementing for them would report fewer components than really exist. That is why union returns a boolean and count is only decremented when it returns true.", + zh: "7 次 union 里只有 5 次真的把两个不同集合并成一个,每次 count 减 1:10 − 5 = 5。另外 2 次什么也没改,如果照样减 1,报出的连通块数就会比实际少。这正是 union 要返回 boolean、只在返回 true 时才 count−− 的原因。", + }, + }, +];