Przeglądaj źródła

课程:加入课前零食与互动材料

Brianhad 1 miesiąc temu
rodzic
commit
52bd18832a
97 zmienionych plików z 6669 dodań i 17 usunięć
  1. 3 0
      .gitignore
  2. 3 2
      README.md
  3. 53 4
      scripts/check_course_docs.py
  4. 124 0
      互动程序/01-欧拉路径/00-从散步到欧拉回路.py
  5. 120 0
      互动程序/02-强连通与双连通/00-从互相绕路到缩点.py
  6. 219 0
      互动程序/02-强连通与双连通/00-从共享房间到圆方树.py
  7. 69 0
      互动程序/03-Bitset/00-把64个开关装进一个格子.py
  8. 94 0
      互动程序/04-KMP与Manacher/00-不从头再看的字符串.py
  9. 103 0
      互动程序/05-最短路与类最短路/00-从乱走到松弛.py
  10. 85 0
      互动程序/06-最小与最大生成树/00-只修够用的路.py
  11. 164 0
      互动程序/06-最小与最大生成树/00-把合并史写成树.py
  12. 98 0
      互动程序/07-二分图/00-两支队伍与奇怪的圈.py
  13. 131 0
      互动程序/08-高级线段树与树状数组/00-会变化的区间和.py
  14. 73 0
      互动程序/09-基环树/00-树多长出一条边.py
  15. 105 0
      互动程序/10-高斯消元与CRT/00-开关与错开的时钟.py
  16. 200 0
      互动程序/11-平衡树/00-从排好队到会分身的树.py
  17. 275 0
      互动程序/11-平衡树/00-把一段话翻过来.py
  18. 236 0
      互动程序/11-平衡树/00-把相同的一段装进一个盒子.py
  19. 271 0
      互动程序/11-平衡树/00-让刚访问的点靠近你.py
  20. 24 0
      互动程序/README.md
  21. 30 0
      课程/00-课程使用说明.md
  22. 249 0
      课程/01-欧拉路径/00-课前零食-从散步到欧拉回路.md
  23. 2 0
      课程/01-欧拉路径/01-判定构造与建模.md
  24. 240 0
      课程/02-强连通与双连通/00-课前零食-从互相绕路到缩点.md
  25. 231 0
      课程/02-强连通与双连通/00-课前零食-从共享房间到圆方树.md
  26. 2 0
      课程/02-强连通与双连通/01-强连通分量与缩点.md
  27. 6 0
      课程/02-强连通与双连通/03-割点点双连通与圆方树入口.md
  28. 228 0
      课程/03-Bitset/00-课前零食-把64个开关装进一个格子.md
  29. 2 0
      课程/03-Bitset/01-字级并行集合模型与动态匹配.md
  30. 228 0
      课程/04-KMP与Manacher/00-课前零食-不从头再看的字符串.md
  31. 2 0
      课程/04-KMP与Manacher/01-KMP失配边界周期与差分建模.md
  32. 215 0
      课程/05-最短路与类最短路/00-课前零食-从乱走到松弛.md
  33. 2 0
      课程/05-最短路与类最短路/01-建图松弛与01状态图.md
  34. 210 0
      课程/06-最小与最大生成树/00-课前零食-只修够用的路.md
  35. 213 0
      课程/06-最小与最大生成树/00-课前零食-把合并史写成树.md
  36. 2 0
      课程/06-最小与最大生成树/01-割性质与隐式完全图建模.md
  37. 6 0
      课程/06-最小与最大生成树/03-Kruskal重构树与XORMST.md
  38. 201 0
      课程/07-二分图/00-课前零食-两支队伍与奇怪的圈.md
  39. 2 0
      课程/07-二分图/01-二分染色与构造选择.md
  40. 222 0
      课程/08-高级线段树与树状数组/00-课前零食-会变化的区间和.md
  41. 2 0
      课程/08-高级线段树与树状数组/01-Fenwick离线计数与序关系.md
  42. 215 0
      课程/09-基环树/00-课前零食-树多长出一条边.md
  43. 2 0
      课程/09-基环树/01-剥叶找环与环上统计.md
  44. 260 0
      课程/10-高斯消元与CRT/00-课前零食-开关与错开的时钟.md
  45. 2 0
      课程/10-高斯消元与CRT/01-高斯消元秩解空间与XOR系统.md
  46. 336 0
      课程/11-平衡树/00-课前零食-从排好队到会分身的树.md
  47. 234 0
      课程/11-平衡树/00-课前零食-把一段话翻过来.md
  48. 225 0
      课程/11-平衡树/00-课前零食-把相同的一段装进一个盒子.md
  49. 217 0
      课程/11-平衡树/00-课前零食-让刚访问的点靠近你.md
  50. 2 0
      课程/11-平衡树/01-FHQ-Treap按值分裂与顺序统计.md
  51. 6 0
      课程/11-平衡树/02-隐式FHQ-Treap序列操作.md
  52. 6 0
      课程/11-平衡树/03-Splay伸展与区间暴露.md
  53. 6 0
      课程/11-平衡树/04-高阶扩展-随机输入契约与ODT.md
  54. 12 11
      课程/README.md
  55. 12 0
      课程/插图/01-欧拉路径/00-欧拉-01-回路拼接.svg
  56. 12 0
      课程/插图/01-欧拉路径/00-欧拉-02-度数配对.svg
  57. 21 0
      课程/插图/02-强连通与双连通/00-圆方树-01-共享点双块.svg
  58. 15 0
      课程/插图/02-强连通与双连通/00-圆方树-02-边栈与封口.svg
  59. 11 0
      课程/插图/02-强连通与双连通/00-连通-01-缩点.svg
  60. 9 0
      课程/插图/02-强连通与双连通/00-连通-02-桥与绕路.svg
  61. 15 0
      课程/插图/02-强连通与双连通/03-圆方树-01-原图到圆方树.svg
  62. 9 0
      课程/插图/02-强连通与双连通/03-圆方树-02-路径经过割点.svg
  63. 8 0
      课程/插图/03-Bitset/00-Bitset-01-打包.svg
  64. 10 0
      课程/插图/03-Bitset/00-Bitset-02-集合运算.svg
  65. 9 0
      课程/插图/04-KMP与Manacher/00-字符串-01-KMP重叠.svg
  66. 7 0
      课程/插图/04-KMP与Manacher/00-字符串-02-回文镜像.svg
  67. 9 0
      课程/插图/05-最短路与类最短路/00-最短路-01-零权反例.svg
  68. 7 0
      课程/插图/05-最短路与类最短路/00-最短路-02-松弛.svg
  69. 14 0
      课程/插图/06-最小与最大生成树/00-KRT-01-DSU与事件卡.svg
  70. 10 0
      课程/插图/06-最小与最大生成树/00-KRT-02-LCA就是相遇.svg
  71. 8 0
      课程/插图/06-最小与最大生成树/00-MST-01-Kruskal.svg
  72. 6 0
      课程/插图/06-最小与最大生成树/00-MST-02-割性质.svg
  73. 10 0
      课程/插图/06-最小与最大生成树/03-KRT-01-合并时间树.svg
  74. 9 0
      课程/插图/06-最小与最大生成树/03-KRT-02-XOR最高位分组.svg
  75. 7 0
      课程/插图/07-二分图/00-二分图-01-两侧.svg
  76. 6 0
      课程/插图/07-二分图/00-二分图-02-奇环.svg
  77. 7 0
      课程/插图/08-高级线段树与树状数组/00-区间-01-Fenwick覆盖.svg
  78. 7 0
      课程/插图/08-高级线段树与树状数组/00-区间-02-线段树分块.svg
  79. 5 0
      课程/插图/09-基环树/00-基环树-01-多一条边.svg
  80. 5 0
      课程/插图/09-基环树/00-基环树-02-剥叶.svg
  81. 5 0
      课程/插图/10-高斯消元与CRT/00-代数-01-XOR消元.svg
  82. 7 0
      课程/插图/10-高斯消元与CRT/00-代数-02-时钟对齐.svg
  83. 12 0
      课程/插图/11-平衡树/00-ODT-01-把数组压成段.svg
  84. 10 0
      课程/插图/11-平衡树/00-ODT-02-split切边界.svg
  85. 12 0
      课程/插图/11-平衡树/00-Splay-01-旋转不改中序.svg
  86. 8 0
      课程/插图/11-平衡树/00-Splay-02-zig的三种节奏.svg
  87. 7 0
      课程/插图/11-平衡树/00-Treap-01-数组与退化树.svg
  88. 5 0
      课程/插图/11-平衡树/00-Treap-02-双不变量.svg
  89. 7 0
      课程/插图/11-平衡树/00-Treap-03-分裂合并.svg
  90. 11 0
      课程/插图/11-平衡树/00-文艺-01-隐式位置与size.svg
  91. 14 0
      课程/插图/11-平衡树/00-文艺-02-双哨兵与区间暴露.svg
  92. 8 0
      课程/插图/11-平衡树/02-隐式Treap-01-三段分裂.svg
  93. 12 0
      课程/插图/11-平衡树/02-隐式Treap-02-rev标记.svg
  94. 8 0
      课程/插图/11-平衡树/03-Splay-01-三种旋转.svg
  95. 10 0
      课程/插图/11-平衡树/03-Splay-02-双哨兵暴露.svg
  96. 9 0
      课程/插图/11-平衡树/04-ODT-01-切出区间.svg
  97. 8 0
      课程/插图/11-平衡树/04-ODT-02-随机契约与反例.svg

+ 3 - 0
.gitignore

@@ -1,2 +1,5 @@
 problems/
 .commandcode/
+.DS_Store
+__pycache__/
+*.py[cod]

+ 3 - 2
README.md

@@ -1,9 +1,10 @@
 # Code-source-Lesson
 
-面向提高+ / 省选- 的信息学竞赛课程资料,覆盖图论、字符串、数据结构等 12 个主题,配套精选例题与 GNU C++17 参考实现。
+面向提高+ / 省选- 的信息学竞赛课程资料,覆盖图论、字符串、数据结构等 12 个主题,配套精选例题与 GNU C++17 参考实现。每个算法主题另有一篇面向普及+的“00-课前零食”:从暴力想法引出算法,配 SVG 图示与可直接运行的 Python 小互动程序。
 
 - 课程正文:[`课程/`](课程/README.md)
 - 参考实现:[`参考实现/`](参考实现/)
+- 课前零食互动程序:[`互动程序/`](互动程序/README.md)
 - 选题范围:[`主题.md`](主题.md)
 
 ## 许可 / License
@@ -13,6 +14,6 @@
 | 内容 | 路径 | 协议 |
 |---|---|---|
 | 课程文档 | `课程/`、`主题.md`、`AGENTS.md`、`README.md` | [CC BY-SA 4.0](LICENSE-docs) |
-| 参考实现代码 | `参考实现/`、`scripts/` | [MIT](LICENSE) |
+| 参考实现与互动程序代码 | `参考实现/`、`互动程序/`、`scripts/` | [MIT](LICENSE) |
 
 简言之:文档允许商用与二创,但二创须以相同协议共享;代码以 MIT 宽松许可,可自由商用与闭源引用。

+ 53 - 4
scripts/check_course_docs.py

@@ -3,6 +3,7 @@
 
 from __future__ import annotations
 
+import ast
 import re
 import sys
 from pathlib import Path
@@ -20,19 +21,42 @@ REQUIRED = (
     "课后训练建议",
 )
 
+# 「课前零食」不是缩短版的正式课:它没有 OJ 例题或 C++ 附件,
+# 但必须有从直觉到算法的引导、可运行的小实验和可读的图示。
+SNACK_REQUIRED = (
+    "本节目标",
+    "前置知识",
+    "互动规则",
+    "从零搭框架",
+    "问题动机",
+    "步步式引导",
+    "核心知识",
+    "实现指南",
+    "纸上模拟",
+    "互动小实验室",
+    "扩展与联系",
+    "总结与自检",
+    "一周预热安排",
+)
+
 
 def main() -> int:
     root = Path(sys.argv[1] if len(sys.argv) > 1 else "课程")
     errors: list[str] = []
+    all_documents = sorted(root.rglob("*.md"))
     lessons = [
         path
-        for path in sorted(root.rglob("*.md"))
+        for path in all_documents
         if path.name != "README.md" and not path.name.startswith("00-")
     ]
-    for path in lessons:
+    snacks = [
+        path for path in all_documents if path.name.startswith("00-课前零食-")
+    ]
+    for path in lessons + snacks:
         text = path.read_text(encoding="utf-8")
         headings = "\n".join(re.findall(r"^#{1,6}\s+(.+)$", text, flags=re.MULTILINE))
-        for required in REQUIRED:
+        required_sections = SNACK_REQUIRED if path in snacks else REQUIRED
+        for required in required_sections:
             if required not in headings:
                 errors.append(f"{path}: missing section containing {required!r}")
         if "<details>" not in text:
@@ -42,6 +66,31 @@ def main() -> int:
         if re.search(r"^\\\[\s*$|^\\\]\s*$", text, flags=re.MULTILINE):
             errors.append(f"{path}: display LaTeX must use $$ delimiters, not \\[ or \\]")
 
+        if path in snacks:
+            if len(text) < 4500:
+                errors.append(f"{path}: snack is too short for an interactive scaffold")
+            pauses = len(re.findall(r"^>\s*停顿", text, flags=re.MULTILINE))
+            if pauses < 3:
+                errors.append(f"{path}: snack needs at least three prediction pauses")
+            if text.count("<details>") < 5:
+                errors.append(f"{path}: snack needs several staged feedback blocks")
+            svg_links = re.findall(r"!\[[^]]*\]\(([^)]+\.svg)\)", text)
+            if len(svg_links) < 2:
+                errors.append(f"{path}: snack needs at least two SVG illustrations")
+            python_links = re.findall(r"\]\(([^)]*互动程序/[^)]*\.py)\)", text)
+            if not python_links:
+                errors.append(f"{path}: snack needs a linked Python mini-lab")
+            for target in python_links:
+                lab = (path.parent / target).resolve()
+                if not lab.exists():
+                    continue  # The generic local-link pass below reports this too.
+                try:
+                    ast.parse(lab.read_text(encoding="utf-8"), filename=str(lab))
+                except SyntaxError as error:
+                    errors.append(f"{path}: Python mini-lab has syntax error: {error}")
+            if "精选例题" in headings:
+                errors.append(f"{path}: snack must not contain formal OJ examples")
+
     for path in sorted(root.rglob("*.md")):
         text = path.read_text(encoding="utf-8")
         prose = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
@@ -55,7 +104,7 @@ def main() -> int:
 
     for error in errors:
         print(error, file=sys.stderr)
-    print(f"lessons={len(lessons)} errors={len(errors)}")
+    print(f"lessons={len(lessons)} snacks={len(snacks)} errors={len(errors)}")
     return 1 if errors else 0
 
 

+ 124 - 0
互动程序/01-欧拉路径/00-从散步到欧拉回路.py

@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""课前零食:用很小的图观察欧拉路径和 Hierholzer 栈。"""
+
+from __future__ import annotations
+
+from collections import defaultdict, deque
+
+
+DEFAULT_EDGES = [
+    ("A", "B"), ("B", "C"), ("C", "A"),
+    ("A", "D"), ("D", "E"), ("E", "A"),
+]
+
+
+def ask(prompt: str, default: str = "") -> str:
+    try:
+        value = input(prompt).strip()
+    except EOFError:
+        value = ""
+    return value or default
+
+
+def parse_edges(raw: str) -> list[tuple[str, str]]:
+    edges: list[tuple[str, str]] = []
+    for token in raw.replace(",", ",").split(","):
+        token = token.strip()
+        if not token:
+            continue
+        if "-" not in token:
+            raise ValueError(f"{token!r} 缺少连字符,例如 A-B")
+        left, right = (part.strip() for part in token.split("-", 1))
+        if not left or not right:
+            raise ValueError(f"{token!r} 的端点不能为空")
+        edges.append((left, right))
+    if not edges:
+        raise ValueError("至少输入一条边")
+    return edges
+
+
+def build_graph(edges: list[tuple[str, str]]) -> dict[str, list[tuple[str, int]]]:
+    graph: dict[str, list[tuple[str, int]]] = defaultdict(list)
+    for edge_id, (u, v) in enumerate(edges):
+        graph[u].append((v, edge_id))
+        graph[v].append((u, edge_id))
+    return graph
+
+
+def nonzero_connected(graph: dict[str, list[tuple[str, int]]]) -> bool:
+    active = [vertex for vertex, neighbors in graph.items() if neighbors]
+    if not active:
+        return True
+    seen = {active[0]}
+    queue: deque[str] = deque([active[0]])
+    while queue:
+        u = queue.popleft()
+        for v, _ in graph[u]:
+            if v not in seen:
+                seen.add(v)
+                queue.append(v)
+    return all(vertex in seen for vertex in active)
+
+
+def hierholzer_trace(
+    graph: dict[str, list[tuple[str, int]]], start: str, edge_count: int
+) -> list[str]:
+    used = [False] * edge_count
+    pointer = {vertex: 0 for vertex in graph}
+    stack = [start]
+    reversed_answer: list[str] = []
+    print("\n--- 栈的动作日志 ---")
+    while stack:
+        u = stack[-1]
+        while (
+            pointer[u] < len(graph[u])
+            and used[graph[u][pointer[u]][1]]
+        ):
+            pointer[u] += 1
+        if pointer[u] == len(graph[u]):
+            finished = stack.pop()
+            reversed_answer.append(finished)
+            print(f"{finished} 没有未用边:弹栈,倒序答案为 {reversed_answer}")
+            continue
+        v, edge_id = graph[u][pointer[u]]
+        used[edge_id] = True
+        stack.append(v)
+        print(f"走边 #{edge_id}: {u}-{v},栈变成 {' -> '.join(stack)}")
+    answer = list(reversed(reversed_answer))
+    return answer
+
+
+def main() -> None:
+    print("欧拉小散步:输入边时用 A-B,B-C 这种格式。")
+    raw = ask("直接回车用双三角形;或输入自己的边: ")
+    try:
+        edges = DEFAULT_EDGES if not raw else parse_edges(raw)
+    except ValueError as error:
+        print(f"输入不太像一张图:{error}。这次先用预设图。")
+        edges = DEFAULT_EDGES
+
+    graph = build_graph(edges)
+    degree = {vertex: len(neighbors) for vertex, neighbors in graph.items()}
+    odd = [vertex for vertex, value in degree.items() if value % 2 == 1]
+    print("\n边:", ", ".join(f"{u}-{v}" for u, v in edges))
+    print("度数:", ", ".join(f"{v}:{degree[v]}" for v in sorted(degree)))
+    print("奇度点:", odd or "没有")
+
+    connected = nonzero_connected(graph)
+    possible = connected and len(odd) in (0, 2)
+    print("非孤立点连通:", "是" if connected else "否")
+    if not possible:
+        print("\n这张图没有欧拉路径:先别让栈背锅,条件已经不成立。")
+        return
+
+    start = odd[0] if odd else min(graph)
+    print(f"\n条件成立,从 {start} 开始。注意:收集答案的顺序会反过来。")
+    answer = hierholzer_trace(graph, start, len(edges))
+    print("\n最终欧拉路线:", " -> ".join(answer))
+    print(
+        f"长度检查:{len(answer)} 个顶点,应该是边数 + 1 = {len(edges) + 1}。"
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 120 - 0
互动程序/02-强连通与双连通/00-从互相绕路到缩点.py

@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""课前零食:在小图上追踪 SCC、桥和割点。"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+
+
+DIRECTED = [("A", "B"), ("B", "C"), ("C", "A"), ("C", "D"), ("D", "E"), ("E", "D"), ("E", "F")]
+UNDIRECTED = [("A", "B"), ("B", "C"), ("C", "A"), ("C", "D"), ("D", "E"), ("E", "F"), ("F", "D")]
+
+
+def ask(prompt: str, default: str) -> str:
+    try:
+        return input(prompt).strip() or default
+    except EOFError:
+        return default
+
+
+def scc_lab() -> None:
+    graph: dict[str, list[str]] = defaultdict(list)
+    reverse: dict[str, list[str]] = defaultdict(list)
+    vertices = set()
+    for u, v in DIRECTED:
+        graph[u].append(v)
+        reverse[v].append(u)
+        vertices.update((u, v))
+    finish: list[str] = []
+    seen: set[str] = set()
+
+    def first_dfs(u: str) -> None:
+        seen.add(u)
+        print(f"原图进入 {u}")
+        for v in graph[u]:
+            if v not in seen:
+                first_dfs(v)
+        finish.append(u)
+        print(f"  {u} 的后继都处理完,完成序列:{finish}")
+
+    print("\n原图边:", ", ".join(f"{u}->{v}" for u, v in DIRECTED))
+    for u in sorted(vertices):
+        if u not in seen:
+            first_dfs(u)
+    print("\n反向完成顺序:", list(reversed(finish)))
+    assigned: set[str] = set()
+    groups: list[list[str]] = []
+
+    def second_dfs(u: str, group: list[str]) -> None:
+        assigned.add(u)
+        group.append(u)
+        for v in reverse[u]:
+            if v not in assigned:
+                second_dfs(v, group)
+
+    for u in reversed(finish):
+        if u not in assigned:
+            group: list[str] = []
+            second_dfs(u, group)
+            groups.append(sorted(group))
+            print("反图收下一团:", sorted(group))
+    print("缩点后只剩单向关系;团内部的绕路已经不必重复计算。")
+
+
+def fragility_lab() -> None:
+    graph: dict[str, list[tuple[str, int]]] = defaultdict(list)
+    vertices = set()
+    for edge_id, (u, v) in enumerate(UNDIRECTED):
+        graph[u].append((v, edge_id))
+        graph[v].append((u, edge_id))
+        vertices.update((u, v))
+    dfn: dict[str, int] = {}
+    low: dict[str, int] = {}
+    bridges: list[tuple[str, str]] = []
+    cuts: set[str] = set()
+    timer = 0
+
+    def dfs(u: str, parent_edge: int | None) -> None:
+        nonlocal timer
+        timer += 1
+        dfn[u] = low[u] = timer
+        children = 0
+        for v, edge_id in graph[u]:
+            if edge_id == parent_edge:
+                continue
+            if v not in dfn:
+                children += 1
+                dfs(v, edge_id)
+                low[u] = min(low[u], low[v])
+                print(f"回到 {u}:孩子 {v} 的 low={low[v]},所以 low[{u}]={low[u]}")
+                if low[v] > dfn[u]:
+                    bridges.append((u, v))
+                if parent_edge is not None and low[v] >= dfn[u]:
+                    cuts.add(u)
+            else:
+                low[u] = min(low[u], dfn[v])
+                print(f"{u} 看见返祖/已访问边到 {v}:low[{u}] 更新为 {low[u]}")
+        if parent_edge is None and children >= 2:
+            cuts.add(u)
+
+    print("\n无向图边:", ", ".join(f"{u}-{v}" for u, v in UNDIRECTED))
+    for u in sorted(vertices):
+        if u not in dfn:
+            dfs(u, None)
+    print("\ndfn:", dfn)
+    print("low:", low)
+    print("桥:", bridges or "没有")
+    print("割点:", sorted(cuts) or "没有")
+
+
+def main() -> None:
+    print("连通小实验室:1=SCC 缩点,2=桥与割点。")
+    choice = ask("选择(直接回车为 1): ", "1")
+    if choice == "2":
+        fragility_lab()
+    else:
+        scc_lab()
+
+
+if __name__ == "__main__":
+    main()

+ 219 - 0
互动程序/02-强连通与双连通/00-从共享房间到圆方树.py

@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""课前零食:从删点实验到圆方树的可操作小黑板。"""
+
+from __future__ import annotations
+
+from collections import deque
+
+
+VERTICES = tuple("ABCDEFG")
+EDGES = (
+    ("A", "B"),
+    ("B", "C"),
+    ("C", "A"),
+    ("C", "D"),
+    ("D", "E"),
+    ("E", "C"),
+    ("C", "F"),
+    ("F", "G"),
+)
+
+
+def make_graph() -> dict[str, list[tuple[str, int]]]:
+    graph = {vertex: [] for vertex in VERTICES}
+    for edge_id, (u, v) in enumerate(EDGES):
+        graph[u].append((v, edge_id))
+        graph[v].append((u, edge_id))
+    return graph
+
+
+GRAPH = make_graph()
+
+
+def tarjan_blocks(with_trace: bool = False) -> tuple[list[list[int]], dict[str, int], dict[str, int], list[str]]:
+    """返回按原边编号排序的点双边块、dfn、low 和可读日志。"""
+    dfn: dict[str, int] = {}
+    low: dict[str, int] = {}
+    edge_stack: list[int] = []
+    blocks: list[list[int]] = []
+    trace: list[str] = []
+    timer = 0
+
+    def say(message: str) -> None:
+        if with_trace:
+            trace.append(message)
+
+    def dfs(u: str, parent_edge: int | None) -> None:
+        nonlocal timer
+        timer += 1
+        dfn[u] = low[u] = timer
+        say(f"进入 {u}:dfn=low={timer}")
+        for v, edge_id in GRAPH[u]:
+            if edge_id == parent_edge:
+                continue
+            if v not in dfn:
+                edge_stack.append(edge_id)
+                say(f"  压边 {u}-{v};栈={edge_names(edge_stack)}")
+                dfs(v, edge_id)
+                low[u] = min(low[u], low[v])
+                say(f"  {v} 返回 {u}:low[{u}] 更新为 {low[u]}")
+                if low[v] >= dfn[u]:
+                    block: list[int] = []
+                    while True:
+                        popped = edge_stack.pop()
+                        block.append(popped)
+                        if popped == edge_id:
+                            break
+                    say(
+                        f"  low[{v}]={low[v]} ≥ dfn[{u}]={dfn[u]}:"
+                        f"弹出 {edge_names(block)},封成一个点双块"
+                    )
+                    blocks.append(block)
+            elif dfn[v] < dfn[u]:
+                edge_stack.append(edge_id)
+                low[u] = min(low[u], dfn[v])
+                say(
+                    f"  看见返祖边 {u}-{v}:压栈,"
+                    f"low[{u}] 更新为 {low[u]}"
+                )
+
+    for start in VERTICES:
+        if start not in dfn:
+            dfs(start, None)
+
+    blocks.sort(key=min)
+    return blocks, dfn, low, trace
+
+
+def edge_name(edge_id: int) -> str:
+    u, v = EDGES[edge_id]
+    return f"{u}-{v}"
+
+
+def edge_names(edge_ids: list[int]) -> str:
+    return "[" + ", ".join(edge_name(edge_id) for edge_id in edge_ids) + "]"
+
+
+def block_members(block: list[int]) -> list[str]:
+    return sorted({vertex for edge_id in block for vertex in EDGES[edge_id]})
+
+
+def build_block_cut_tree(blocks: list[list[int]]) -> dict[str, list[str]]:
+    tree: dict[str, list[str]] = {vertex: [] for vertex in VERTICES}
+    for index, block in enumerate(blocks, start=1):
+        square = f"Q{index}"
+        tree[square] = []
+        for vertex in block_members(block):
+            tree[vertex].append(square)
+            tree[square].append(vertex)
+    return tree
+
+
+def components_after_delete(removed: str) -> list[list[str]]:
+    remaining = set(VERTICES) - {removed}
+    components: list[list[str]] = []
+    while remaining:
+        start = min(remaining)
+        queue = deque([start])
+        remaining.remove(start)
+        component = [start]
+        while queue:
+            u = queue.popleft()
+            for v, _ in GRAPH[u]:
+                if v in remaining and v != removed:
+                    remaining.remove(v)
+                    queue.append(v)
+                    component.append(v)
+        components.append(sorted(component))
+    return components
+
+
+def tree_path(tree: dict[str, list[str]], start: str, target: str) -> list[str] | None:
+    if start not in tree or target not in tree:
+        return None
+    parent = {start: None}
+    queue = deque([start])
+    while queue:
+        u = queue.popleft()
+        if u == target:
+            break
+        for v in tree[u]:
+            if v not in parent:
+                parent[v] = u
+                queue.append(v)
+    if target not in parent:
+        return None
+    path: list[str] = []
+    current: str | None = target
+    while current is not None:
+        path.append(current)
+        current = parent[current]
+    return list(reversed(path))
+
+
+def show(blocks: list[list[int]], dfn: dict[str, int], low: dict[str, int]) -> None:
+    print("\n原图边:", ", ".join(edge_name(edge_id) for edge_id in range(len(EDGES))))
+    print("DFS 账本:", "  ".join(f"{u}: dfn={dfn[u]}, low={low[u]}" for u in VERTICES))
+    print("点双块(按原边编号排序后命名):")
+    for index, block in enumerate(blocks, start=1):
+        print(f"  Q{index}: 边 {edge_names(block)};成员 {block_members(block)}")
+    tree = build_block_cut_tree(blocks)
+    print("圆方树邻接表:")
+    for node in list(VERTICES) + [f"Q{i}" for i in range(1, len(blocks) + 1)]:
+        print(f"  {node}: {' '.join(tree[node])}")
+
+
+def warmup() -> None:
+    print("=== 任务 0:先当删点的暴力程序 ===")
+    try:
+        input("先预测删掉 C 后会有几块;写在纸上后按回车: ")
+    except EOFError:
+        pass
+    print("核对:删 C 后是 {A,B}、{D,E}、{F,G} 三块。")
+    try:
+        input("再猜:谁可以同时属于 ABC 和 CDE 两个结实小团?按回车: ")
+    except EOFError:
+        pass
+    print("核对:C。圆方树不复制 C,而是给每个块新建方点。")
+
+
+def main() -> None:
+    blocks, dfn, low, trace = tarjan_blocks(with_trace=True)
+    tree = build_block_cut_tree(blocks)
+    print("圆方树小实验室:所有边都是固定的小图,目标是看清状态。")
+    warmup()
+    show(blocks, dfn, low)
+    print("\n命令:show;delete C;path A G;trace;q。")
+    while True:
+        try:
+            raw = input("\n> ").strip()
+        except EOFError:
+            raw = "q"
+        if not raw or raw in {"q", "quit", "exit"}:
+            return
+        parts = raw.split()
+        if parts == ["show"]:
+            show(blocks, dfn, low)
+        elif parts == ["trace"]:
+            print("\nTarjan 边栈日志:")
+            for line in trace:
+                print(line)
+        elif len(parts) == 2 and parts[0] == "delete" and parts[1] in VERTICES:
+            removed = parts[1]
+            components = components_after_delete(removed)
+            print(f"删 {removed} 后:", "  ".join("{" + ",".join(c) + "}" for c in components))
+            print("连通块数:", len(components))
+        elif len(parts) == 3 and parts[0] == "path":
+            path = tree_path(tree, parts[1], parts[2])
+            if path is None:
+                print("节点名应为 A..G 或 Q1..Q4。")
+            else:
+                print("圆方树唯一路径:", " → ".join(path))
+                cuts = [node for node in path if node in VERTICES and len(tree[node]) > 1]
+                print("路径中的割点候选:", cuts or "无")
+        else:
+            print("小提示:试试 delete C、path A G、trace。")
+
+
+if __name__ == "__main__":
+    main()

+ 69 - 0
互动程序/03-Bitset/00-把64个开关装进一个格子.py

@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""课前零食:用 8 位显微镜观察 bitset 运算。"""
+
+from __future__ import annotations
+
+
+WIDTH = 8
+MASK = (1 << WIDTH) - 1
+
+
+def ask(prompt: str, default: str) -> str:
+    try:
+        return input(prompt).strip() or default
+    except EOFError:
+        return default
+
+
+def read_bits(prompt: str, default: str) -> int:
+    raw = ask(prompt, default)
+    if len(raw) != WIDTH or any(ch not in "01" for ch in raw):
+        print(f"{raw!r} 不是 {WIDTH} 位 01 串;本次使用 {default}。")
+        raw = default
+    return int(raw, 2)
+
+
+def bits(value: int) -> str:
+    return format(value & MASK, f"0{WIDTH}b")
+
+
+def members(value: int) -> list[int]:
+    return [index for index in range(WIDTH) if (value >> index) & 1]
+
+
+def show(name: str, value: int) -> None:
+    print(f"{name:>7} = {bits(value)},集合成员(最低位编号 0)={members(value)}")
+
+
+def main() -> None:
+    print("Bitset 显微镜:左边是高位,最右边是编号 0。")
+    a = read_bits("A(回车为 10110110): ", "10110110")
+    b = read_bits("B(回车为 01101100): ", "01101100")
+    show("A", a)
+    show("B", b)
+    operation = ask("操作:all / & / | / ^ / << / >>(回车为 all): ", "all")
+
+    if operation in ("all", "&"):
+        show("A & B", a & b)
+        print("  每一位都问一次:两盏灯是否同时亮?")
+    if operation in ("all", "|"):
+        show("A | B", a | b)
+    if operation in ("all", "^"):
+        show("A ^ B", a ^ b)
+    if operation in ("all", "<<", ">>"):
+        shift_text = ask("平移多少格(回车为 2): ", "2")
+        try:
+            shift = max(0, int(shift_text))
+        except ValueError:
+            shift = 2
+        if operation in ("all", "<<"):
+            show(f"A << {shift}", (a << shift) & MASK)
+            print("  左移出去的 1 不会搬家到另一端;它们离开了这个固定长度集合。")
+        if operation in ("all", ">>"):
+            show(f"A >> {shift}", a >> shift)
+            print("  右移时低位掉出集合,高位补 0。")
+    print(f"\n置位数 popcount(A)={a.bit_count()}。8 位是演示,真实一字通常有 64 位。")
+
+
+if __name__ == "__main__":
+    main()

+ 94 - 0
互动程序/04-KMP与Manacher/00-不从头再看的字符串.py

@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""课前零食:追踪 KMP 的 border 与 Manacher 的镜像半径。"""
+
+from __future__ import annotations
+
+
+def ask(prompt: str, default: str) -> str:
+    try:
+        return input(prompt).strip() or default
+    except EOFError:
+        return default
+
+
+def prefix_function(pattern: str) -> list[int]:
+    pi = [0] * len(pattern)
+    j = 0
+    for i in range(1, len(pattern)):
+        while j and pattern[i] != pattern[j]:
+            print(f"i={i},{pattern[i]} != {pattern[j]}:j 从 {j} 跳到 pi[{j - 1}]={pi[j - 1]}")
+            j = pi[j - 1]
+        if pattern[i] == pattern[j]:
+            j += 1
+        pi[i] = j
+        print(f"pi[{i}]={j}({pattern[:i + 1]!r} 的最长 border)")
+    return pi
+
+
+def naive_count(text: str, pattern: str) -> int:
+    comparisons = 0
+    for start in range(len(text) - len(pattern) + 1):
+        for j, char in enumerate(pattern):
+            comparisons += 1
+            if text[start + j] != char:
+                break
+    return comparisons
+
+
+def kmp_lab() -> None:
+    pattern = ask("模式串(回车为 ABABAC): ", "ABABAC")
+    text = ask("文本串(回车为 ABABABACABA): ", "ABABABACABA")
+    if not pattern:
+        print("空模式串没有可演示的失配;请下次输入至少一个字符。")
+        return
+    print("\n--- 构建前缀函数 ---")
+    pi = prefix_function(pattern)
+    print("pi =", pi)
+    print("\n--- 扫描文本 ---")
+    j = 0
+    matches: list[int] = []
+    for i, char in enumerate(text):
+        while j and char != pattern[j]:
+            print(f"文本[{i}]={char} 失配:j {j} -> {pi[j - 1]}")
+            j = pi[j - 1]
+        if char == pattern[j]:
+            j += 1
+        if j == len(pattern):
+            matches.append(i - len(pattern) + 1)
+            print(f"在位置 {matches[-1]} 匹配成功;保留 border,j -> {pi[j - 1]}")
+            j = pi[j - 1]
+    print("匹配位置:", matches or "没有")
+    print(f"朴素法会做 {naive_count(text, pattern)} 次字符比较(KMP 只维护一条 border 链)。")
+
+
+def manacher_lab() -> None:
+    text = ask("字符串(回车为 abacaba): ", "abacaba")
+    if not text:
+        print("空串的回文半径没有戏份。")
+        return
+    radius = [0] * len(text)
+    left, right = 0, -1
+    print("\n--- 奇长度回文半径(含中心) ---")
+    for center in range(len(text)):
+        radius[center] = 1 if center > right else min(radius[left + right - center], right - center + 1)
+        source = "从 1 开始" if center > right else f"借镜像 {left + right - center}"
+        while center - radius[center] >= 0 and center + radius[center] < len(text) and text[center - radius[center]] == text[center + radius[center]]:
+            radius[center] += 1
+        if center + radius[center] - 1 > right:
+            left = center - radius[center] + 1
+            right = center + radius[center] - 1
+        covered = text[center - radius[center] + 1:center + radius[center]]
+        print(f"中心 {center}={text[center]!r}:{source},半径 {radius[center]},覆盖 {covered!r},当前 [L,R]=[{left},{right}]")
+    print("半径数组:", radius)
+
+
+def main() -> None:
+    choice = ask("字符串实验室:k=KMP,m=Manacher(回车为 k): ", "k").lower()
+    if choice == "m":
+        manacher_lab()
+    else:
+        kmp_lab()
+
+
+if __name__ == "__main__":
+    main()

+ 103 - 0
互动程序/05-最短路与类最短路/00-从乱走到松弛.py

@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""课前零食:比较 BFS、0-1 BFS 与 Dijkstra 的取点顺序。"""
+
+from __future__ import annotations
+
+from collections import deque
+import heapq
+
+
+EDGES = [
+    ("S", "A", 1), ("S", "B", 0), ("B", "A", 0),
+    ("A", "T", 1), ("B", "T", 1), ("S", "C", 1), ("C", "T", 1),
+]
+
+
+def ask(prompt: str, default: str) -> str:
+    try:
+        return input(prompt).strip() or default
+    except EOFError:
+        return default
+
+
+def graph() -> dict[str, list[tuple[str, int]]]:
+    result: dict[str, list[tuple[str, int]]] = {}
+    for u, v, weight in EDGES:
+        result.setdefault(u, []).append((v, weight))
+        result.setdefault(v, [])
+    return result
+
+
+def zero_one_bfs(adj: dict[str, list[tuple[str, int]]]) -> None:
+    dist = {u: float("inf") for u in adj}
+    dist["S"] = 0
+    work: deque[str] = deque(["S"])
+    print("\n--- 0-1 BFS ---")
+    while work:
+        u = work.popleft()
+        print(f"取出 {u},dist={dist[u]},双端队列={list(work)}")
+        for v, weight in adj[u]:
+            candidate = dist[u] + weight
+            if candidate < dist[v]:
+                old = dist[v]
+                dist[v] = candidate
+                if weight == 0:
+                    work.appendleft(v)
+                    place = "队首(0 边)"
+                else:
+                    work.append(v)
+                    place = "队尾(1 边)"
+                print(f"  松弛 {u}->{v}({weight}):{old} -> {candidate},放{place}")
+    print("最终距离:", dist)
+
+
+def dijkstra(adj: dict[str, list[tuple[str, int]]]) -> None:
+    dist = {u: float("inf") for u in adj}
+    dist["S"] = 0
+    heap: list[tuple[int, str]] = [(0, "S")]
+    print("\n--- Dijkstra ---")
+    while heap:
+        current, u = heapq.heappop(heap)
+        if current != dist[u]:
+            print(f"弹出旧条目 ({current}, {u}),当前已是 {dist[u]},跳过。")
+            continue
+        print(f"定案候选 {u},距离 {current}")
+        for v, weight in adj[u]:
+            candidate = current + weight
+            if candidate < dist[v]:
+                old = dist[v]
+                dist[v] = candidate
+                heapq.heappush(heap, (candidate, v))
+                print(f"  松弛 {u}->{v}({weight}):{old} -> {candidate},推入堆")
+    print("最终距离:", dist)
+
+
+def bfs_unit_edges(adj: dict[str, list[tuple[str, int]]]) -> None:
+    dist = {u: -1 for u in adj}
+    dist["S"] = 0
+    work: deque[str] = deque(["S"])
+    print("\n--- 把每条边都当 1 的 BFS(仅作对照)---")
+    while work:
+        u = work.popleft()
+        for v, _ in adj[u]:
+            if dist[v] == -1:
+                dist[v] = dist[u] + 1
+                work.append(v)
+    print("按边数得到:", dist)
+    print("它把 S->A 当作 1 步最短,却看不见 S->B->A 的总权为 0。")
+
+
+def main() -> None:
+    print("预设有向边:", ", ".join(f"{u}->{v}({w})" for u, v, w in EDGES))
+    choice = ask("选择 b=BFS 对照、z=0-1 BFS、d=Dijkstra(回车为 z): ", "z").lower()
+    adj = graph()
+    if choice == "b":
+        bfs_unit_edges(adj)
+    elif choice == "d":
+        dijkstra(adj)
+    else:
+        zero_one_bfs(adj)
+
+
+if __name__ == "__main__":
+    main()

+ 85 - 0
互动程序/06-最小与最大生成树/00-只修够用的路.py

@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""课前零食:追踪 Kruskal 和并查集的每一次合并。"""
+
+from __future__ import annotations
+
+
+DEFAULT = [
+    ("A", "B", 1), ("D", "E", 1), ("A", "C", 2), ("C", "D", 2),
+    ("B", "C", 3), ("B", "D", 4), ("C", "E", 5),
+]
+
+
+def ask(prompt: str, default: str = "") -> str:
+    try:
+        return input(prompt).strip() or default
+    except EOFError:
+        return default
+
+
+def parse_edges(raw: str) -> list[tuple[str, str, int]]:
+    result: list[tuple[str, str, int]] = []
+    for token in raw.replace(",", ",").split(","):
+        parts = [part.strip() for part in token.split("-")]
+        if len(parts) != 3 or not parts[0] or not parts[1]:
+            raise ValueError("每条边应形如 A-B-3")
+        result.append((parts[0], parts[1], int(parts[2])))
+    return result
+
+
+class DSU:
+    def __init__(self, vertices: set[str]) -> None:
+        self.parent = {vertex: vertex for vertex in vertices}
+        self.size = {vertex: 1 for vertex in vertices}
+
+    def find(self, vertex: str) -> str:
+        if self.parent[vertex] != vertex:
+            self.parent[vertex] = self.find(self.parent[vertex])
+        return self.parent[vertex]
+
+    def union(self, left: str, right: str) -> bool:
+        left, right = self.find(left), self.find(right)
+        if left == right:
+            return False
+        if self.size[left] < self.size[right]:
+            left, right = right, left
+        self.parent[right] = left
+        self.size[left] += self.size[right]
+        return True
+
+    def groups(self) -> list[list[str]]:
+        result: dict[str, list[str]] = {}
+        for vertex in self.parent:
+            result.setdefault(self.find(vertex), []).append(vertex)
+        return [sorted(group) for group in result.values()]
+
+
+def main() -> None:
+    raw = ask("边表 A-B-3,B-C-1(回车用预设): ")
+    try:
+        edges = DEFAULT if not raw else parse_edges(raw)
+    except (ValueError, TypeError) as error:
+        print(f"输入格式有问题:{error};使用预设。")
+        edges = DEFAULT
+    vertices = {vertex for edge in edges for vertex in edge[:2]}
+    dsu = DSU(vertices)
+    chosen: list[tuple[str, str, int]] = []
+    total = 0
+    print("\n--- 按权排序后 ---")
+    for u, v, weight in sorted(edges, key=lambda edge: edge[2]):
+        before = (dsu.find(u), dsu.find(v))
+        if dsu.union(u, v):
+            chosen.append((u, v, weight))
+            total += weight
+            print(f"选 {u}-{v} ({weight}):分量 {before[0]} 与 {before[1]} 合并 -> {dsu.groups()}")
+        else:
+            print(f"跳过 {u}-{v} ({weight}):两端都在分量 {before[0]},会闭合环")
+    print("\n选中的边:", chosen)
+    if len(chosen) == len(vertices) - 1:
+        print(f"MST 总权={total},恰好选了 {len(vertices) - 1} 条边。")
+    else:
+        print("图不连通,只得到最小生成森林:", dsu.groups())
+
+
+if __name__ == "__main__":
+    main()

+ 164 - 0
互动程序/06-最小与最大生成树/00-把合并史写成树.py

@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""课前零食:把 Kruskal 的成功合并打印成一棵历史树。"""
+
+from __future__ import annotations
+
+
+EDGES = (
+    (1, 2, 2),
+    (3, 4, 3),
+    (2, 3, 5),
+    (4, 5, 6),
+    (1, 5, 9),
+)
+
+
+class ReconstructionTree:
+    def __init__(self) -> None:
+        self.dsu_parent: dict[int, int] = {vertex: vertex for vertex in range(1, 6)}
+        self.tree_parent: dict[int, int | None] = {vertex: None for vertex in range(1, 6)}
+        self.children: dict[int, list[int]] = {vertex: [] for vertex in range(1, 6)}
+        self.value: dict[int, int | None] = {vertex: None for vertex in range(1, 6)}
+        self.logs: list[str] = []
+        self.next_node = 6
+        self.root = self.build()
+        self.leaves: dict[int, list[int]] = {}
+        self.fill_leaves(self.root)
+
+    def find(self, node: int) -> int:
+        while self.dsu_parent[node] != node:
+            node = self.dsu_parent[node]
+        return node
+
+    def build(self) -> int:
+        for u, v, weight in EDGES:
+            a = self.find(u)
+            b = self.find(v)
+            if a == b:
+                self.logs.append(f"边 ({u},{v},w={weight}):两端已在事件 {a} 下,跳过。")
+                continue
+            event = self.next_node
+            self.next_node += 1
+            self.children[event] = [a, b]
+            self.value[event] = weight
+            self.tree_parent[a] = event
+            self.tree_parent[b] = event
+            self.tree_parent[event] = None
+            self.dsu_parent[a] = event
+            self.dsu_parent[b] = event
+            self.dsu_parent[event] = event
+            self.logs.append(
+                f"边 ({u},{v},w={weight}):合并根 {a} 与 {b},"
+                f"新建事件 {event}@{weight}。"
+            )
+        return self.find(1)
+
+    def fill_leaves(self, node: int) -> list[int]:
+        if not self.children[node]:
+            answer = [node]
+        else:
+            answer = []
+            for child in self.children[node]:
+                answer.extend(self.fill_leaves(child))
+        self.leaves[node] = sorted(answer)
+        return self.leaves[node]
+
+    def ancestors(self, node: int) -> list[int]:
+        chain = [node]
+        while self.tree_parent[chain[-1]] is not None:
+            chain.append(self.tree_parent[chain[-1]])
+        return chain
+
+    def lca(self, u: int, v: int) -> int:
+        seen = set(self.ancestors(u))
+        for node in self.ancestors(v):
+            if node in seen:
+                return node
+        raise RuntimeError("同一棵重构树中一定有 LCA")
+
+    def component_at(self, vertex: int, threshold: int) -> int:
+        node = vertex
+        while (
+            self.tree_parent[node] is not None
+            and self.value[self.tree_parent[node]] is not None
+            and self.value[self.tree_parent[node]] <= threshold
+        ):
+            node = self.tree_parent[node]
+        return node
+
+    def print_tree(self, node: int | None = None, prefix: str = "", side: str = "根") -> None:
+        if node is None:
+            node = self.root
+        children = self.children[node]
+        if len(children) == 2:
+            self.print_tree(children[1], prefix + "    ", "右")
+        label = str(node) if self.value[node] is None else f"{node}@{self.value[node]}"
+        print(f"{prefix}{side}: {label}  覆盖叶子 {self.leaves[node]}")
+        if len(children) == 2:
+            self.print_tree(children[0], prefix + "    ", "左")
+
+    def show(self) -> None:
+        print("\n成功合并的时间线:")
+        for line in self.logs:
+            print(" ", line)
+        print("\n重构树(@ 后为发生权值):")
+        self.print_tree()
+        print("例如 LCA(2,4)=", self.lca(2, 4), ",权值=", self.value[self.lca(2, 4)])
+
+
+def warmup() -> None:
+    print("=== 任务 0:先让并查集健忘一次 ===")
+    print("边按权为 2、3、5、6、9 处理。")
+    try:
+        input("请预测:2 与 4 首次同一泡泡是在权值几?按回车: ")
+    except EOFError:
+        pass
+    print("准备核对。注意:最终 DSU 只告诉你“五个人在一起”,不会保存这个答案。")
+
+
+def main() -> None:
+    tree = ReconstructionTree()
+    print("Kruskal 重构树小实验室:事件节点 6..9 是每次成功 union 的合影。")
+    warmup()
+    tree.show()
+    print("\n命令:show;q 2 4;at 2 3;at 2 6;q。")
+    while True:
+        try:
+            raw = input("\n> ").strip()
+        except EOFError:
+            raw = "q"
+        if not raw or raw in {"q", "quit", "exit"}:
+            return
+        parts = raw.split()
+        if parts == ["show"]:
+            tree.show()
+        elif len(parts) == 3 and parts[0] == "q":
+            try:
+                u, v = int(parts[1]), int(parts[2])
+                if not (1 <= u <= 5 and 1 <= v <= 5):
+                    raise ValueError
+                event = tree.lca(u, v)
+                print(
+                    f"{u} 与 {v} 的最近共同事件是 {event}@{tree.value[event]};"
+                    f"首次连通阈值为 {tree.value[event]}。"
+                )
+            except ValueError:
+                print("点编号应在 1..5。")
+        elif len(parts) == 3 and parts[0] == "at":
+            try:
+                vertex, threshold = int(parts[1]), int(parts[2])
+                if not 1 <= vertex <= 5:
+                    raise ValueError
+                event = tree.component_at(vertex, threshold)
+                print(
+                    f"阈值 {threshold} 时,{vertex} 上升到事件 {event}"
+                    f";所在块的原叶为 {tree.leaves[event]}。"
+                )
+            except ValueError:
+                print("格式示例:at 2 5。")
+        else:
+            print("小提示:q 2 4 问首次连通;at 2 3 问某阈值时的块。")
+
+
+if __name__ == "__main__":
+    main()

+ 98 - 0
互动程序/07-二分图/00-两支队伍与奇怪的圈.py

@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+"""课前零食:交替染色,并在冲突时给出一条奇环。"""
+
+from __future__ import annotations
+
+from collections import defaultdict, deque
+
+
+GOOD = [("A", "B"), ("A", "D"), ("C", "B"), ("C", "D"), ("E", "F")]
+BAD = [("A", "B"), ("B", "C"), ("C", "A"), ("C", "D")]
+
+
+def ask(prompt: str, default: str) -> str:
+    try:
+        return input(prompt).strip() or default
+    except EOFError:
+        return default
+
+
+def parse_edges(raw: str) -> list[tuple[str, str]]:
+    result: list[tuple[str, str]] = []
+    for token in raw.replace(",", ",").split(","):
+        left, right = (part.strip() for part in token.split("-", 1))
+        if not left or not right:
+            raise ValueError("边应形如 A-B")
+        result.append((left, right))
+    return result
+
+
+def odd_cycle(u: str, v: str, parent: dict[str, str | None]) -> list[str]:
+    up: list[str] = []
+    cursor: str | None = u
+    while cursor is not None:
+        up.append(cursor)
+        cursor = parent[cursor]
+    vp: list[str] = []
+    cursor = v
+    while cursor is not None:
+        vp.append(cursor)
+        cursor = parent[cursor]
+    position = {vertex: index for index, vertex in enumerate(up)}
+    common = next(vertex for vertex in vp if vertex in position)
+    left = up[:position[common] + 1]
+    right = vp[:vp.index(common)]
+    return left + list(reversed(right)) + [u]
+
+
+def color_graph(edges: list[tuple[str, str]]) -> None:
+    graph: dict[str, list[str]] = defaultdict(list)
+    for u, v in edges:
+        graph[u].append(v)
+        graph[v].append(u)
+    color = {vertex: -1 for vertex in graph}
+    parent: dict[str, str | None] = {}
+    for start in sorted(graph):
+        if color[start] != -1:
+            continue
+        color[start] = 0
+        parent[start] = None
+        queue: deque[str] = deque([start])
+        print(f"\n从 {start} 染成 0 开始")
+        while queue:
+            u = queue.popleft()
+            for v in graph[u]:
+                if color[v] == -1:
+                    color[v] = 1 - color[u]
+                    parent[v] = u
+                    queue.append(v)
+                    print(f"  边 {u}-{v}:把 {v} 染成 {color[v]}")
+                elif color[v] == color[u]:
+                    cycle = odd_cycle(u, v, parent)
+                    print(f"  冲突!{u} 与 {v} 都是颜色 {color[u]}。")
+                    print("  一条奇环:", " -> ".join(cycle))
+                    return
+    left = sorted(vertex for vertex, value in color.items() if value == 0)
+    right = sorted(vertex for vertex, value in color.items() if value == 1)
+    print("\n可二染:左侧(0)=", left, ";右侧(1)=", right)
+    print("每个连通分量可以整体交换左右两侧。")
+
+
+def main() -> None:
+    choice = ask("选择 g=可二染预设,b=奇环预设,c=自定义(回车为 g): ", "g").lower()
+    if choice == "b":
+        edges = BAD
+    elif choice == "c":
+        try:
+            edges = parse_edges(ask("输入边 A-B,B-C: ", "A-B,B-C,C-A"))
+        except (ValueError, IndexError) as error:
+            print(f"格式不对:{error};改用奇环预设。")
+            edges = BAD
+    else:
+        edges = GOOD
+    print("边:", ", ".join(f"{u}-{v}" for u, v in edges))
+    color_graph(edges)
+
+
+if __name__ == "__main__":
+    main()

+ 131 - 0
互动程序/08-高级线段树与树状数组/00-会变化的区间和.py

@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""课前零食:在同一数组上观察 Fenwick 与线段树的区间摘要。"""
+
+from __future__ import annotations
+
+
+DEFAULT = [3, 1, 4, 1, 5, 9]
+
+
+def ask(prompt: str, default: str = "q") -> str:
+    try:
+        return input(prompt).strip() or default
+    except EOFError:
+        return default
+
+
+def lowbit(value: int) -> int:
+    return value & -value
+
+
+class Fenwick:
+    def __init__(self, values: list[int]) -> None:
+        self.n = len(values)
+        self.tree = [0] * (self.n + 1)
+        for index, value in enumerate(values, 1):
+            self.add(index, value, quiet=True)
+
+    def add(self, index: int, delta: int, quiet: bool = False) -> None:
+        touched: list[int] = []
+        while index <= self.n:
+            self.tree[index] += delta
+            touched.append(index)
+            index += lowbit(index)
+        if not quiet:
+            print("Fenwick 更新的摘要下标:", touched)
+
+    def prefix(self, index: int) -> int:
+        answer = 0
+        blocks: list[tuple[int, int]] = []
+        while index > 0:
+            answer += self.tree[index]
+            blocks.append((index - lowbit(index) + 1, index))
+            index -= lowbit(index)
+        print("Fenwick 用到的块:", blocks)
+        return answer
+
+    def show(self) -> None:
+        for index in range(1, self.n + 1):
+            left = index - lowbit(index) + 1
+            print(f"tree[{index}]={self.tree[index]:>2},负责 [{left},{index}]")
+
+
+class SegmentTree:
+    def __init__(self, values: list[int]) -> None:
+        self.n = 1
+        while self.n < len(values):
+            self.n *= 2
+        self.tree = [0] * (2 * self.n)
+        self.tree[self.n:self.n + len(values)] = values
+        for index in range(self.n - 1, 0, -1):
+            self.tree[index] = self.tree[index * 2] + self.tree[index * 2 + 1]
+
+    def set(self, index: int, value: int) -> None:
+        node = self.n + index - 1
+        self.tree[node] = value
+        touched = [node]
+        node //= 2
+        while node:
+            self.tree[node] = self.tree[node * 2] + self.tree[node * 2 + 1]
+            touched.append(node)
+            node //= 2
+        print("线段树向上重算的节点:", touched)
+
+    def query(self, left: int, right: int) -> int:
+        left += self.n - 1
+        right += self.n - 1
+        answer = 0
+        used: list[int] = []
+        while left <= right:
+            if left % 2 == 1:
+                answer += self.tree[left]
+                used.append(left)
+                left += 1
+            if right % 2 == 0:
+                answer += self.tree[right]
+                used.append(right)
+                right -= 1
+            left //= 2
+            right //= 2
+        print("线段树直接拿到的节点:", used)
+        return answer
+
+
+def main() -> None:
+    values = DEFAULT[:]
+    fenwick = Fenwick(values)
+    segment = SegmentTree(values)
+    print("初始数组(下标从 1):", values)
+    print("命令:sum l r;set i x;show;q。直接回车退出。")
+    while True:
+        command = ask("\n> ")
+        if command in {"q", "quit", "exit"}:
+            break
+        parts = command.split()
+        try:
+            if parts[0] == "show":
+                fenwick.show()
+            elif parts[0] == "sum" and len(parts) == 3:
+                left, right = map(int, parts[1:])
+                if not (1 <= left <= right <= len(values)):
+                    raise ValueError("下标应在数组范围内")
+                fenwick_answer = fenwick.prefix(right) - fenwick.prefix(left - 1)
+                segment_answer = segment.query(left, right)
+                print(f"区间 [{left},{right}]:Fenwick={fenwick_answer},线段树={segment_answer}")
+            elif parts[0] == "set" and len(parts) == 3:
+                index, value = map(int, parts[1:])
+                if not 1 <= index <= len(values):
+                    raise ValueError("下标应在数组范围内")
+                delta = value - values[index - 1]
+                values[index - 1] = value
+                fenwick.add(index, delta)
+                segment.set(index, value)
+                print("新数组:", values)
+            else:
+                raise ValueError("命令格式不对")
+        except (ValueError, IndexError) as error:
+            print("小提示:", error)
+
+
+if __name__ == "__main__":
+    main()

+ 73 - 0
互动程序/09-基环树/00-树多长出一条边.py

@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""课前零食:用队列剥离无向基环树或函数图的挂树。"""
+
+from __future__ import annotations
+
+from collections import defaultdict, deque
+
+
+UNDIRECTED = [("A", "B"), ("B", "C"), ("C", "A"), ("B", "D"), ("D", "E"), ("C", "F"), ("C", "G")]
+FUNCTIONAL = {"A": "B", "B": "C", "C": "A", "D": "C", "E": "D", "F": "E"}
+
+
+def ask(prompt: str, default: str) -> str:
+    try:
+        return input(prompt).strip() or default
+    except EOFError:
+        return default
+
+
+def undirected_lab() -> None:
+    graph: dict[str, list[str]] = defaultdict(list)
+    for u, v in UNDIRECTED:
+        graph[u].append(v)
+        graph[v].append(u)
+    degree = {u: len(graph[u]) for u in graph}
+    removed = {u: False for u in graph}
+    queue: deque[str] = deque(u for u in graph if degree[u] == 1)
+    print("边:", ", ".join(f"{u}-{v}" for u, v in UNDIRECTED))
+    print("初始叶子:", list(queue))
+    while queue:
+        u = queue.popleft()
+        removed[u] = True
+        print(f"删掉叶子 {u},它一定不在环上。")
+        for v in graph[u]:
+            if not removed[v]:
+                degree[v] -= 1
+                print(f"  {v} 的剩余度数变为 {degree[v]}")
+                if degree[v] == 1:
+                    queue.append(v)
+                    print(f"  {v} 新成为叶子,入队。")
+    print("剩余环点:", sorted(u for u in graph if not removed[u]))
+
+
+def functional_lab() -> None:
+    indegree = {u: 0 for u in FUNCTIONAL}
+    for v in FUNCTIONAL.values():
+        indegree[v] += 1
+    queue: deque[str] = deque(u for u, value in indegree.items() if value == 0)
+    removed: set[str] = set()
+    print("箭头:", ", ".join(f"{u}->{v}" for u, v in FUNCTIONAL.items()))
+    print("初始入度 0:", list(queue))
+    while queue:
+        u = queue.popleft()
+        removed.add(u)
+        v = FUNCTIONAL[u]
+        indegree[v] -= 1
+        print(f"删 {u},使 {v} 入度变为 {indegree[v]}")
+        if indegree[v] == 0:
+            queue.append(v)
+            print(f"  {v} 也不可能在环上,入队。")
+    print("剩余有向环点:", sorted(set(FUNCTIONAL) - removed))
+
+
+def main() -> None:
+    choice = ask("选择 u=无向基环树,f=函数图(回车为 u): ", "u").lower()
+    if choice == "f":
+        functional_lab()
+    else:
+        undirected_lab()
+
+
+if __name__ == "__main__":
+    main()

+ 105 - 0
互动程序/10-高斯消元与CRT/00-开关与错开的时钟.py

@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+"""课前零食:追踪 XOR 高斯消元与非互质 CRT 合并。"""
+
+from __future__ import annotations
+
+from math import gcd
+
+
+MATRIX = [
+    [1, 1, 0, 1],  # x1 xor x2 = 1
+    [0, 1, 1, 0],  # x2 xor x3 = 0
+    [1, 0, 1, 1],  # x1 xor x3 = 1(依赖前两行)
+]
+
+
+def ask(prompt: str, default: str) -> str:
+    try:
+        return input(prompt).strip() or default
+    except EOFError:
+        return default
+
+
+def print_matrix(matrix: list[list[int]]) -> None:
+    for row in matrix:
+        print("  ", " ".join(map(str, row[:-1])), "|", row[-1])
+
+
+def xor_lab() -> None:
+    matrix = [row[:] for row in MATRIX]
+    variables = len(matrix[0]) - 1
+    pivot_row = 0
+    print("\n增广矩阵(最后一列为右侧常数):")
+    print_matrix(matrix)
+    for column in range(variables):
+        pivot = next((r for r in range(pivot_row, len(matrix)) if matrix[r][column]), None)
+        if pivot is None:
+            print(f"第 {column + 1} 列没有主元:对应变量暂时自由。")
+            continue
+        matrix[pivot_row], matrix[pivot] = matrix[pivot], matrix[pivot_row]
+        print(f"\n选第 {column + 1} 列主元,放到第 {pivot_row + 1} 行。")
+        for row in range(len(matrix)):
+            if row != pivot_row and matrix[row][column]:
+                matrix[row] = [a ^ b for a, b in zip(matrix[row], matrix[pivot_row])]
+                print(f"  第 {row + 1} 行 XOR 主元行")
+        print_matrix(matrix)
+        pivot_row += 1
+    contradiction = any(not any(row[:-1]) and row[-1] for row in matrix)
+    print("\n秩 =", pivot_row)
+    if contradiction:
+        print("出现 0=1:无解。")
+    elif pivot_row < variables:
+        print("相容但有自由变量:多解。")
+    else:
+        print("每个变量有主元:唯一解。")
+
+
+def exgcd(a: int, b: int) -> tuple[int, int, int]:
+    if b == 0:
+        return a, 1, 0
+    g, x, y = exgcd(b, a % b)
+    return g, y, x - (a // b) * y
+
+
+def merge(a: int, m: int, b: int, n: int) -> tuple[int, int] | None:
+    g = gcd(m, n)
+    print(f"gcd({m},{n})={g},差 b-a={b - a}")
+    if (b - a) % g:
+        return None
+    reduced_m, reduced_n = m // g, n // g
+    _, inverse, _ = exgcd(reduced_m, reduced_n)
+    t = ((b - a) // g * inverse) % reduced_n
+    modulus = m * reduced_n
+    value = (a + m * t) % modulus
+    print(f"解出 t={t},所以 x={value} (mod {modulus})")
+    return value, modulus
+
+
+def crt_lab() -> None:
+    raw = ask("输入 a m b n(回车为 1 4 3 6): ", "1 4 3 6")
+    try:
+        a, m, b, n = map(int, raw.split())
+        if m <= 0 or n <= 0:
+            raise ValueError
+    except ValueError:
+        print("格式应为四个整数 a m b n;使用预设。")
+        a, m, b, n = 1, 4, 3, 6
+    print(f"\n合并 x≡{a} (mod {m}) 与 x≡{b} (mod {n})")
+    result = merge(a, m, b, n)
+    if result is None:
+        print("不兼容:两只时钟永远不会同时指向这些余数。")
+    else:
+        value, modulus = result
+        print(f"验算:{value}%{m}={value % m},{value}%{n}={value % n};以后每隔 {modulus} 重复。")
+
+
+def main() -> None:
+    choice = ask("选择 x=XOR 消元,c=CRT 合并(回车为 x): ", "x").lower()
+    if choice == "c":
+        crt_lab()
+    else:
+        xor_lab()
+
+
+if __name__ == "__main__":
+    main()

+ 200 - 0
互动程序/11-平衡树/00-从排好队到会分身的树.py

@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""课前零食:可操作地追踪一棵按值 FHQ Treap。"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import random
+
+
+RNG = random.Random(20260903)
+
+
+@dataclass
+class Node:
+    key: int
+    priority: int
+    count: int = 1
+    size: int = 1
+    left: Node | None = None
+    right: Node | None = None
+
+
+def node_size(node: Node | None) -> int:
+    return node.size if node else 0
+
+
+def pull(node: Node | None, log: list[str]) -> None:
+    if node:
+        node.size = node_size(node.left) + node.count + node_size(node.right)
+        log.append(f"pull({node.key}):size={node.size}")
+
+
+def split(node: Node | None, key: int, log: list[str]) -> tuple[Node | None, Node | None]:
+    """返回所有键 <= key 的树,以及所有键 > key 的树。"""
+    if node is None:
+        return None, None
+    if node.key <= key:
+        log.append(f"split({node.key}, {key}):{node.key} 放左半,继续切右子树")
+        middle, right = split(node.right, key, log)
+        node.right = middle
+        pull(node, log)
+        return node, right
+    log.append(f"split({node.key}, {key}):{node.key} 放右半,继续切左子树")
+    left, middle = split(node.left, key, log)
+    node.left = middle
+    pull(node, log)
+    return left, node
+
+
+def merge(left: Node | None, right: Node | None, log: list[str]) -> Node | None:
+    """前提:left 的所有键 <= right 的所有键。"""
+    if left is None:
+        return right
+    if right is None:
+        return left
+    if left.priority > right.priority:
+        log.append(f"merge:{left.key}(p={left.priority}) 优先级更高,做根")
+        left.right = merge(left.right, right, log)
+        pull(left, log)
+        return left
+    log.append(f"merge:{right.key}(p={right.priority}) 优先级更高,做根")
+    right.left = merge(left, right.left, log)
+    pull(right, log)
+    return right
+
+
+def insert(root: Node | None, key: int) -> tuple[Node | None, list[str]]:
+    log: list[str] = []
+    less, at_least = split(root, key - 1, log)
+    equal, greater = split(at_least, key, log)
+    if equal:
+        equal.count += 1
+        pull(equal, log)
+        log.append(f"键 {key} 已存在:count 增为 {equal.count}")
+    else:
+        equal = Node(key=key, priority=RNG.randrange(1, 100))
+        log.append(f"新建 {key},随机优先级为 {equal.priority}")
+    return merge(less, merge(equal, greater, log), log), log
+
+
+def erase_one(root: Node | None, key: int) -> tuple[Node | None, list[str]]:
+    log: list[str] = []
+    less, at_least = split(root, key - 1, log)
+    equal, greater = split(at_least, key, log)
+    if equal is None:
+        log.append(f"没有键 {key},原树接回去。")
+    elif equal.count > 1:
+        equal.count -= 1
+        pull(equal, log)
+        log.append(f"键 {key} 的 count 降为 {equal.count}")
+    else:
+        log.append(f"删除唯一的键 {key},把它的左右孩子合并。")
+        equal = merge(equal.left, equal.right, log)
+    return merge(less, merge(equal, greater, log), log), log
+
+
+def rank(root: Node | None, key: int) -> int:
+    answer = 1
+    while root:
+        if key <= root.key:
+            root = root.left
+        else:
+            answer += node_size(root.left) + root.count
+            root = root.right
+    return answer
+
+
+def kth(root: Node | None, k: int) -> int | None:
+    while root:
+        left_count = node_size(root.left)
+        if k <= left_count:
+            root = root.left
+        elif k <= left_count + root.count:
+            return root.key
+        else:
+            k -= left_count + root.count
+            root = root.right
+    return None
+
+
+def inorder(root: Node | None) -> list[int]:
+    if root is None:
+        return []
+    return inorder(root.left) + [root.key] * root.count + inorder(root.right)
+
+
+def show_tree(root: Node | None, prefix: str = "", side: str = "根") -> None:
+    if root is None:
+        return
+    show_tree(root.right, prefix + "    ", "右")
+    print(f"{prefix}{side}: {root.key} [p={root.priority}, cnt={root.count}, size={root.size}]")
+    show_tree(root.left, prefix + "    ", "左")
+
+
+def print_log(log: list[str]) -> None:
+    for item in log:
+        print("  ", item)
+
+
+def warmup() -> None:
+    """让实验从数组/BST 的失败开始,而不是直接倾倒递归日志。"""
+    print("\n=== 任务 0:先当人脑数据结构 ===")
+    print("集合是 [4, 2, 7, 5, 2]。请先在纸上排成 [2, 2, 4, 5, 7]。")
+    try:
+        input("若插入 6,有序数组中哪几个元素要右移?写下预测后按回车: ")
+    except EOFError:
+        pass
+    print("答案检查:6 放在 5 与 7 之间,至少要把 7 向右搬一格。")
+    print("再想一想递增插入 1,2,3,4 的普通 BST:它会变成链。")
+    try:
+        input("四张优先级卡 2/35、4/60、5/83、7/20 中,谁应做 Treap 根?按回车核对: ")
+    except EOFError:
+        pass
+    print("答案检查:5 的优先级 83 最大;键值左右关系仍由 key 决定。")
+    print("接下来的日志只验证你刚搭出的框架:切范围、改目标块、合并、pull。")
+
+
+def main() -> None:
+    root: Node | None = None
+    print("FHQ Treap 小实验室:固定随机种子,方便复盘。")
+    warmup()
+    for value in (4, 2, 7, 5, 2):
+        root, log = insert(root, value)
+        print(f"\n插入 {value}:")
+        print_log(log)
+    print("\n初始树:")
+    show_tree(root)
+    print("中序(应有序):", inorder(root))
+    print("命令:+ x;- x;rank x;k k;show;q。直接回车退出。")
+    while True:
+        try:
+            raw = input("\n> ").strip()
+        except EOFError:
+            raw = "q"
+        if not raw or raw in {"q", "quit", "exit"}:
+            break
+        parts = raw.split()
+        try:
+            if parts[0] == "+" and len(parts) == 2:
+                root, log = insert(root, int(parts[1]))
+                print_log(log)
+            elif parts[0] == "-" and len(parts) == 2:
+                root, log = erase_one(root, int(parts[1]))
+                print_log(log)
+            elif parts[0] == "rank" and len(parts) == 2:
+                print(f"rank({parts[1]}) = {rank(root, int(parts[1]))}")
+            elif parts[0] == "k" and len(parts) == 2:
+                value = kth(root, int(parts[1]))
+                print("第 k 小:" if value is not None else "k 超出元素总数。", value if value is not None else "")
+            elif parts[0] == "show":
+                show_tree(root)
+                print("中序:", inorder(root))
+            else:
+                raise ValueError("命令格式不对")
+        except ValueError as error:
+            print("小提示:", error)
+
+
+if __name__ == "__main__":
+    main()

+ 275 - 0
互动程序/11-平衡树/00-把一段话翻过来.py

@@ -0,0 +1,275 @@
+#!/usr/bin/env python3
+"""课前零食:带双哨兵、rev Lazy 的隐式 Splay 小实验室。"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class Node:
+    label: str
+    value: int
+    left: Node | None = None
+    right: Node | None = None
+    parent: Node | None = None
+    size: int = 1
+    total: int = 0
+    rev: bool = False
+
+    def __post_init__(self) -> None:
+        self.total = self.value
+
+
+def node_size(node: Node | None) -> int:
+    return node.size if node else 0
+
+
+def node_sum(node: Node | None) -> int:
+    return node.total if node else 0
+
+
+class SequenceSplay:
+    def __init__(self) -> None:
+        self.model = list("ABCDEF")
+        labels = ["L"] + self.model + ["R"]
+        values = [0, 10, 20, 30, 40, 50, 60, 0]
+        nodes = [Node(label, value) for label, value in zip(labels, values)]
+        self.root = self.build_balanced(nodes)
+
+    def build_balanced(self, nodes: list[Node]) -> Node | None:
+        if not nodes:
+            return None
+        middle = len(nodes) // 2
+        root = nodes[middle]
+        root.left = self.build_balanced(nodes[:middle])
+        root.right = self.build_balanced(nodes[middle + 1 :])
+        if root.left:
+            root.left.parent = root
+        if root.right:
+            root.right.parent = root
+        self.pull(root)
+        return root
+
+    def pull(self, node: Node | None) -> None:
+        if node:
+            node.size = 1 + node_size(node.left) + node_size(node.right)
+            node.total = node.value + node_sum(node.left) + node_sum(node.right)
+
+    def apply_rev(self, node: Node | None) -> None:
+        if node is None:
+            return
+        node.left, node.right = node.right, node.left
+        node.rev = not node.rev
+
+    def push(self, node: Node | None) -> None:
+        if node and node.rev:
+            self.apply_rev(node.left)
+            self.apply_rev(node.right)
+            node.rev = False
+
+    def push_path(self, node: Node) -> None:
+        chain: list[Node] = []
+        current: Node | None = node
+        while current:
+            chain.append(current)
+            current = current.parent
+        for ancestor in reversed(chain):
+            self.push(ancestor)
+
+    def is_right(self, node: Node) -> bool:
+        return node.parent is not None and node.parent.right is node
+
+    def rotate(self, node: Node, log: list[str]) -> None:
+        parent = node.parent
+        if parent is None:
+            return
+        grand = parent.parent
+        if node is parent.left:
+            middle = node.right
+            node.right = parent
+            parent.left = middle
+            direction = "右旋"
+        else:
+            middle = node.left
+            node.left = parent
+            parent.right = middle
+            direction = "左旋"
+        if middle:
+            middle.parent = parent
+        parent.parent = node
+        node.parent = grand
+        if grand is None:
+            self.root = node
+        elif parent is grand.left:
+            grand.left = node
+        else:
+            grand.right = node
+        self.pull(parent)
+        self.pull(node)
+        log.append(f"{direction}:{node.label} 绕 {parent.label}。")
+
+    def splay(self, node: Node, goal: Node | None, log: list[str]) -> None:
+        self.push_path(node)
+        while node.parent is not goal:
+            parent = node.parent
+            if parent is None:
+                break
+            grand = parent.parent
+            if grand is goal:
+                self.rotate(node, log)
+            elif self.is_right(node) == self.is_right(parent):
+                self.rotate(parent, log)
+                self.rotate(node, log)
+            else:
+                self.rotate(node, log)
+                self.rotate(node, log)
+
+    def kth(self, rank: int) -> Node:
+        if not 1 <= rank <= node_size(self.root):
+            raise ValueError("排名越界")
+        current = self.root
+        while current:
+            self.push(current)
+            left_count = node_size(current.left)
+            if rank <= left_count:
+                current = current.left
+            elif rank == left_count + 1:
+                return current
+            else:
+                rank -= left_count + 1
+                current = current.right
+        raise RuntimeError("size 账本损坏")
+
+    def expose(self, left: int, right: int) -> tuple[Node, Node, Node, list[str]]:
+        if not (1 <= left <= right <= len(self.model)):
+            raise ValueError("区间应满足 1 ≤ l ≤ r ≤ 当前长度")
+        log: list[str] = []
+        x = self.kth(left)
+        log.append(f"x=kth({left})={x.label},先伸到根。")
+        self.splay(x, None, log)
+        y = self.kth(right + 2)
+        log.append(f"y=kth({right + 2})={y.label},再伸到 x 的右儿子位置。")
+        self.splay(y, x, log)
+        mid = y.left
+        if mid is None:
+            raise RuntimeError("合法非空区间不应暴露为空")
+        return x, y, mid, log
+
+    def reverse(self, left: int, right: int) -> list[str]:
+        x, y, mid, log = self.expose(left, right)
+        self.apply_rev(mid)
+        self.pull(y)
+        self.pull(x)
+        self.model[left - 1 : right] = reversed(self.model[left - 1 : right])
+        log.append(f"对 y.left={mid.label} 的子树 apply_rev;不逐项移动字符。")
+        return log
+
+    def interval_sum(self, left: int, right: int) -> tuple[int, list[str]]:
+        _, _, mid, log = self.expose(left, right)
+        log.append(f"读取 mid.sum={mid.total}。")
+        return mid.total, log
+
+    def inorder_nodes(self, node: Node | None = None, top: bool = True) -> list[Node]:
+        if top:
+            node = self.root
+        if node is None:
+            return []
+        self.push(node)
+        return self.inorder_nodes(node.left, top=False) + [node] + self.inorder_nodes(node.right, top=False)
+
+    def body_labels(self) -> list[str]:
+        return [node.label for node in self.inorder_nodes()[1:-1]]
+
+    def body_values(self) -> list[int]:
+        return [node.value for node in self.inorder_nodes()[1:-1]]
+
+    def show(self) -> None:
+        nodes = self.inorder_nodes()
+        print("完整中序:", " ".join(node.label for node in nodes))
+        print("正文:", " ".join(self.body_labels()))
+        print("正文数值:", self.body_values())
+        print("提示:L、R 只占位置,不计正文答案。")
+
+    def check(self) -> tuple[bool, str]:
+        def dfs(node: Node | None, parent: Node | None) -> tuple[bool, int, int, str]:
+            if node is None:
+                return True, 0, 0, ""
+            self.push(node)
+            if node.parent is not parent:
+                return False, 0, 0, f"{node.label} 的 parent 指针错误"
+            ok_l, left_size, left_sum, why = dfs(node.left, node)
+            if not ok_l:
+                return False, 0, 0, why
+            ok_r, right_size, right_sum, why = dfs(node.right, node)
+            if not ok_r:
+                return False, 0, 0, why
+            expected_size = left_size + 1 + right_size
+            expected_sum = left_sum + node.value + right_sum
+            if node.size != expected_size or node.total != expected_sum:
+                return False, 0, 0, f"{node.label} 的 size/sum 摘要错误"
+            return True, expected_size, expected_sum, ""
+        ok, _, _, why = dfs(self.root, None)
+        if not ok:
+            return False, why
+        if self.body_labels() != self.model:
+            return False, f"树中正文 {self.body_labels()} 与列表验算 {self.model} 不同"
+        return True, "parent、size、sum、Lazy 后的逻辑序列均正确"
+
+
+def print_log(log: list[str]) -> None:
+    for line in log:
+        print(" ", line)
+
+
+def warmup() -> None:
+    print("=== 任务 0:先预测双哨兵的夹子 ===")
+    print("正文为 A B C D E F,目标区间是 [2,5]。")
+    try:
+        input("请先写 x=kth(l) 和 y=kth(r+2) 分别是谁,再按回车: ")
+    except EOFError:
+        pass
+    print("核对:x=A,y=F;B,C,D,E 会成为 y.left。")
+
+
+def main() -> None:
+    sequence = SequenceSplay()
+    print("文艺平衡树小实验室:隐式 Splay + 双哨兵 + rev Lazy。")
+    warmup()
+    sequence.show()
+    print("\n命令:show;expose 2 5;rev 2 5;sum 2 5;check;q。")
+    while True:
+        try:
+            raw = input("\n> ").strip()
+        except EOFError:
+            raw = "q"
+        if not raw or raw in {"q", "quit", "exit"}:
+            return
+        parts = raw.split()
+        try:
+            if parts == ["show"]:
+                sequence.show()
+            elif parts == ["check"]:
+                ok, message = sequence.check()
+                print("通过:" if ok else "失败:", message)
+            elif len(parts) == 3 and parts[0] == "expose":
+                left, right = int(parts[1]), int(parts[2])
+                x, y, mid, log = sequence.expose(left, right)
+                print_log(log)
+                print(f"暴露完成:x={x.label},y={y.label},y.left 的根是 {mid.label}。")
+                print("y.left 的中序:", " ".join(node.label for node in sequence.inorder_nodes(mid, top=False)))
+            elif len(parts) == 3 and parts[0] == "rev":
+                print_log(sequence.reverse(int(parts[1]), int(parts[2])))
+                sequence.show()
+            elif len(parts) == 3 and parts[0] == "sum":
+                answer, log = sequence.interval_sum(int(parts[1]), int(parts[2]))
+                print_log(log)
+                print("区间和:", answer)
+            else:
+                raise ValueError
+        except ValueError as error:
+            print("小提示:", error or "试试 rev 2 5。")
+
+
+if __name__ == "__main__":
+    main()

+ 236 - 0
互动程序/11-平衡树/00-把相同的一段装进一个盒子.py

@@ -0,0 +1,236 @@
+#!/usr/bin/env python3
+"""课前零食:用可见段卡模拟 ODT 的 split 与区间操作。"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class Segment:
+    left: int
+    right: int
+    value: int
+
+    @property
+    def length(self) -> int:
+        return self.right - self.left + 1
+
+    def show(self) -> str:
+        return f"[{self.left},{self.right},v={self.value}]"
+
+
+class SegmentLab:
+    def __init__(self) -> None:
+        self.model = [1, 1, 1, 4, 4, 2, 2, 2, 2]
+        self.segments = [
+            Segment(1, 3, 1),
+            Segment(4, 5, 4),
+            Segment(6, 9, 2),
+        ]
+
+    @property
+    def n(self) -> int:
+        return len(self.model)
+
+    def normalize(self) -> None:
+        merged: list[Segment] = []
+        for segment in self.segments:
+            if (
+                merged
+                and merged[-1].right + 1 == segment.left
+                and merged[-1].value == segment.value
+            ):
+                merged[-1].right = segment.right
+            else:
+                merged.append(Segment(segment.left, segment.right, segment.value))
+        self.segments = merged
+
+    def split(self, position: int, log: list[str]) -> int:
+        if not 1 <= position <= self.n + 1:
+            raise ValueError("切分位置应在 1..n+1")
+        if position == self.n + 1:
+            log.append("split(n+1):这是尾后边界,不新建段。")
+            return len(self.segments)
+        for index, segment in enumerate(self.segments):
+            if segment.left <= position <= segment.right:
+                if position == segment.left:
+                    log.append(f"split({position}):刚好在 {segment.show()} 左端,什么也不切。")
+                    return index
+                left_piece = Segment(segment.left, position - 1, segment.value)
+                right_piece = Segment(position, segment.right, segment.value)
+                self.segments[index : index + 1] = [left_piece, right_piece]
+                log.append(
+                    f"split({position}):{segment.show()} 变成 "
+                    f"{left_piece.show()} 与 {right_piece.show()}。"
+                )
+                return index + 1
+        raise RuntimeError("段卡没有完整覆盖数组")
+
+    def bounds(self, left: int, right: int, log: list[str]) -> tuple[int, int]:
+        if not 1 <= left <= right <= self.n:
+            raise ValueError("区间应满足 1 ≤ l ≤ r ≤ n")
+        end_position = right + 1
+        self.split(end_position, log)
+        begin = self.split(left, log)
+        # C++ set 的 end 迭代器在 split(left) 后仍有效;本实验室为可读性
+        # 使用列表,左侧插入可能令整数下标右移,故重新定位同一边界。
+        if end_position == self.n + 1:
+            end = len(self.segments)
+        else:
+            end = next(
+                index
+                for index, segment in enumerate(self.segments)
+                if segment.left == end_position
+            )
+        return begin, end
+
+    def add(self, left: int, right: int, delta: int) -> list[str]:
+        log: list[str] = []
+        begin, end = self.bounds(left, right, log)
+        touched = end - begin
+        for index in range(begin, end):
+            self.segments[index].value += delta
+        for index in range(left - 1, right):
+            self.model[index] += delta
+        self.normalize()
+        log.append(f"区间加触及 {touched} 张完整段卡;规范化后共有 {len(self.segments)} 段。")
+        return log
+
+    def assign(self, left: int, right: int, value: int) -> list[str]:
+        log: list[str] = []
+        begin, end = self.bounds(left, right, log)
+        removed = end - begin
+        self.segments[begin:end] = [Segment(left, right, value)]
+        self.model[left - 1 : right] = [value] * (right - left + 1)
+        self.normalize()
+        log.append(
+            f"赋值删除中央 {removed} 张卡,插入一张 [{left},{right},v={value}];"
+            f"规范化后共有 {len(self.segments)} 段。"
+        )
+        return log
+
+    def kth(self, left: int, right: int, rank: int) -> tuple[int, list[str]]:
+        log: list[str] = []
+        begin, end = self.bounds(left, right, log)
+        pieces = sorted(
+            (self.segments[index].value, self.segments[index].length)
+            for index in range(begin, end)
+        )
+        total = sum(length for _, length in pieces)
+        if not 1 <= rank <= total:
+            raise ValueError("k 超出区间长度")
+        original_rank = rank
+        for value, length in pieces:
+            if rank <= length:
+                log.append(f"按值排序后的段贡献为 {pieces};第 {original_rank} 小落在值 {value}。")
+                return value, log
+            rank -= length
+        raise RuntimeError("长度账本损坏")
+
+    def power_sum(self, left: int, right: int, exponent: int, modulus: int) -> tuple[int, list[str]]:
+        if modulus <= 0:
+            raise ValueError("模数必须为正")
+        log: list[str] = []
+        begin, end = self.bounds(left, right, log)
+        answer = 0
+        for index in range(begin, end):
+            segment = self.segments[index]
+            answer = (answer + segment.length * pow(segment.value, exponent, modulus)) % modulus
+        log.append(f"幂和只遍历了 {end - begin} 张段卡。")
+        return answer, log
+
+    def attack(self) -> list[str]:
+        self.model = list(range(1, self.n + 1))
+        self.segments = [Segment(index, index, value) for index, value in enumerate(self.model, start=1)]
+        log = [
+            f"现在每个位置值都不同:共有 {len(self.segments)} 张单点段卡。",
+            "接下来 add(1,n,1) 仍需扫过所有段;相邻差不变,无法压缩。",
+        ]
+        return log
+
+    def show(self) -> None:
+        print("段卡:", "  ".join(segment.show() for segment in self.segments))
+        print("数组:", self.model)
+        print("段数:", len(self.segments))
+
+    def check(self) -> tuple[bool, str]:
+        expanded: list[int] = []
+        previous_right = 0
+        for segment in self.segments:
+            if segment.left != previous_right + 1 or segment.left > segment.right:
+                return False, "段卡没有按顺序无缝覆盖"
+            expanded.extend([segment.value] * segment.length)
+            previous_right = segment.right
+        if previous_right != self.n:
+            return False, "段卡没有覆盖到 n"
+        if expanded != self.model:
+            return False, f"段卡展开 {expanded} 与数组验算 {self.model} 不同"
+        return True, "段卡覆盖、数值与普通数组均一致"
+
+
+def print_log(log: list[str]) -> None:
+    for line in log:
+        print(" ", line)
+
+
+def warmup() -> None:
+    print("=== 任务 0:先猜边界,不要先背 split ===")
+    print("初始段卡是 [1,3,1] [4,5,4] [6,9,2]。")
+    try:
+        input("预测 split(2) 会把哪张卡拆成哪两张,再按回车: ")
+    except EOFError:
+        pass
+    print("核对:[1,3,1] 变为 [1,1,1] 与 [2,3,1],数组数值完全不变。")
+
+
+def main() -> None:
+    lab = SegmentLab()
+    print("ODT 段卡小实验室:这里用列表展示卡片,方便看清 split。")
+    warmup()
+    lab.show()
+    print("\n命令:show;split 2;add 2 7 3;assign 3 8 9;kth 1 9 5;pow 1 9 2 97;attack;check;q。")
+    while True:
+        try:
+            raw = input("\n> ").strip()
+        except EOFError:
+            raw = "q"
+        if not raw or raw in {"q", "quit", "exit"}:
+            return
+        parts = raw.split()
+        try:
+            if parts == ["show"]:
+                lab.show()
+            elif parts == ["attack"]:
+                print_log(lab.attack())
+                lab.show()
+            elif parts == ["check"]:
+                ok, message = lab.check()
+                print("通过:" if ok else "失败:", message)
+            elif len(parts) == 2 and parts[0] == "split":
+                log: list[str] = []
+                lab.split(int(parts[1]), log)
+                print_log(log)
+                lab.show()
+            elif len(parts) == 4 and parts[0] == "add":
+                print_log(lab.add(int(parts[1]), int(parts[2]), int(parts[3])))
+                lab.show()
+            elif len(parts) == 4 and parts[0] == "assign":
+                print_log(lab.assign(int(parts[1]), int(parts[2]), int(parts[3])))
+                lab.show()
+            elif len(parts) == 4 and parts[0] == "kth":
+                answer, log = lab.kth(int(parts[1]), int(parts[2]), int(parts[3]))
+                print_log(log)
+                print("答案:", answer)
+            elif len(parts) == 5 and parts[0] == "pow":
+                answer, log = lab.power_sum(int(parts[1]), int(parts[2]), int(parts[3]), int(parts[4]))
+                print_log(log)
+                print("答案:", answer)
+            else:
+                raise ValueError("命令格式不对")
+        except ValueError as error:
+            print("小提示:", error)
+
+
+if __name__ == "__main__":
+    main()

+ 271 - 0
互动程序/11-平衡树/00-让刚访问的点靠近你.py

@@ -0,0 +1,271 @@
+#!/usr/bin/env python3
+"""课前零食:一个可查看 zig、zig-zig、zig-zag 的小型 Splay 集合。"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class Node:
+    key: int
+    left: Node | None = None
+    right: Node | None = None
+    parent: Node | None = None
+    size: int = 1
+
+
+def size(node: Node | None) -> int:
+    return node.size if node else 0
+
+
+class SplaySet:
+    def __init__(self) -> None:
+        self.root: Node | None = None
+
+    def pull(self, node: Node | None) -> None:
+        if node:
+            node.size = 1 + size(node.left) + size(node.right)
+
+    def pull_to_root(self, node: Node | None) -> None:
+        while node:
+            self.pull(node)
+            node = node.parent
+
+    def rotate(self, x: Node, log: list[str]) -> None:
+        p = x.parent
+        if p is None:
+            return
+        g = p.parent
+        if x is p.left:
+            middle = x.right
+            x.right = p
+            p.left = middle
+            direction = "右旋"
+        else:
+            middle = x.left
+            x.left = p
+            p.right = middle
+            direction = "左旋"
+        if middle:
+            middle.parent = p
+        p.parent = x
+        x.parent = g
+        if g is None:
+            self.root = x
+        elif p is g.left:
+            g.left = x
+        else:
+            g.right = x
+        self.pull(p)
+        self.pull(x)
+        log.append(f"{direction}:让 {x.key} 绕 {p.key} 上来。")
+
+    def is_right(self, node: Node) -> bool:
+        return node.parent is not None and node.parent.right is node
+
+    def splay(self, x: Node, goal: Node | None, log: list[str]) -> None:
+        while x.parent is not goal:
+            p = x.parent
+            if p is None:
+                break
+            g = p.parent
+            if g is goal:
+                log.append("zig:父亲已是目标父亲。")
+                self.rotate(x, log)
+            elif self.is_right(x) == self.is_right(p):
+                log.append("zig-zig:祖孙同向,先旋父亲再旋当前点。")
+                self.rotate(p, log)
+                self.rotate(x, log)
+            else:
+                log.append("zig-zag:祖孙异向,当前点连续旋两次。")
+                self.rotate(x, log)
+                self.rotate(x, log)
+
+    def insert_plain(self, key: int) -> None:
+        """仅用于造出固定的初始 BST;不触发 Splay。"""
+        if self.root is None:
+            self.root = Node(key)
+            return
+        current = self.root
+        while True:
+            if key < current.key:
+                if current.left is None:
+                    current.left = Node(key=key, parent=current)
+                    self.pull_to_root(current)
+                    return
+                current = current.left
+            else:
+                if current.right is None:
+                    current.right = Node(key=key, parent=current)
+                    self.pull_to_root(current)
+                    return
+                current = current.right
+
+    def locate(self, key: int) -> tuple[Node | None, Node | None]:
+        current = self.root
+        last: Node | None = None
+        while current:
+            last = current
+            if key == current.key:
+                return current, last
+            current = current.left if key < current.key else current.right
+        return None, last
+
+    def find(self, key: int) -> tuple[bool, list[str]]:
+        node, last = self.locate(key)
+        log: list[str] = []
+        if node:
+            log.append(f"找到 {key},开始把它伸到根。")
+            self.splay(node, None, log)
+            return True, log
+        if last:
+            log.append(f"没找到 {key},但把最后访问的 {last.key} 伸到根。")
+            self.splay(last, None, log)
+        return False, log
+
+    def add(self, key: int) -> list[str]:
+        if self.root is None:
+            self.root = Node(key)
+            return [f"空树插入 {key},它就是根。"]
+        node, last = self.locate(key)
+        log: list[str] = []
+        if node:
+            log.append(f"{key} 已存在,仍把它伸到根。")
+            self.splay(node, None, log)
+            return log
+        assert last is not None
+        fresh = Node(key=key, parent=last)
+        if key < last.key:
+            last.left = fresh
+        else:
+            last.right = fresh
+        self.pull_to_root(last)
+        log.append(f"把 {key} 当普通 BST 新叶插入,再 Splay。")
+        self.splay(fresh, None, log)
+        return log
+
+    def delete(self, key: int) -> tuple[bool, list[str]]:
+        found, log = self.find(key)
+        if not found:
+            return False, log
+        assert self.root is not None and self.root.key == key
+        left, right = self.root.left, self.root.right
+        if left:
+            left.parent = None
+        if right:
+            right.parent = None
+        self.root.left = self.root.right = None
+        if left is None:
+            self.root = right
+            log.append("左树为空,右树直接成为新根。")
+            return True, log
+        self.root = left
+        maximum = left
+        while maximum.right:
+            maximum = maximum.right
+        log.append(f"左树最大点是 {maximum.key};在左树内把它伸根后接右树。")
+        self.splay(maximum, None, log)
+        assert self.root is maximum
+        self.root.right = right
+        if right:
+            right.parent = self.root
+        self.pull(self.root)
+        return True, log
+
+    def inorder(self) -> list[int]:
+        def dfs(node: Node | None) -> list[int]:
+            if node is None:
+                return []
+            return dfs(node.left) + [node.key] + dfs(node.right)
+        return dfs(self.root)
+
+    def show_tree(self, node: Node | None = None, prefix: str = "", side: str = "根") -> None:
+        if node is None and not prefix:
+            node = self.root
+        if node is None:
+            return
+        self.show_tree(node.right, prefix + "    ", "右")
+        parent = node.parent.key if node.parent else "-"
+        print(f"{prefix}{side}: {node.key} [parent={parent}, size={node.size}]")
+        self.show_tree(node.left, prefix + "    ", "左")
+
+    def check(self) -> tuple[bool, str]:
+        def dfs(node: Node | None, low: int | None, high: int | None, parent: Node | None) -> tuple[bool, int, str]:
+            if node is None:
+                return True, 0, ""
+            if node.parent is not parent:
+                return False, 0, f"{node.key} 的 parent 指针不对"
+            if (low is not None and node.key <= low) or (high is not None and node.key >= high):
+                return False, 0, f"{node.key} 的 BST 范围不对"
+            ok_l, left_size, why = dfs(node.left, low, node.key, node)
+            if not ok_l:
+                return False, 0, why
+            ok_r, right_size, why = dfs(node.right, node.key, high, node)
+            if not ok_r:
+                return False, 0, why
+            if node.size != left_size + 1 + right_size:
+                return False, 0, f"{node.key} 的 size 应为 {left_size + 1 + right_size}"
+            return True, node.size, ""
+        ok, _, why = dfs(self.root, None, None, None)
+        return ok, why or "BST、parent、size 均正确"
+
+
+def print_log(log: list[str]) -> None:
+    for line in log:
+        print(" ", line)
+
+
+def warmup() -> None:
+    print("=== 任务 0:先预测 7 怎样上来 ===")
+    print("初始树根是 10,5 在左,7 是 5 的右儿子。")
+    try:
+        input("请写下它是 zig-zig 还是 zig-zag,再按回车: ")
+    except EOFError:
+        pass
+    print("核对:7 相对 5 向右,5 相对 10 向左,是 zig-zag。")
+
+
+def main() -> None:
+    tree = SplaySet()
+    for key in (10, 5, 15, 3, 7, 12, 18):
+        tree.insert_plain(key)
+    print("Splay 基本操作小实验室:固定初始 BST,不使用随机数。")
+    warmup()
+    tree.show_tree()
+    print("中序:", tree.inorder())
+    print("\n命令:find 7;add 6;del 10;show;check;q。")
+    while True:
+        try:
+            raw = input("\n> ").strip()
+        except EOFError:
+            raw = "q"
+        if not raw or raw in {"q", "quit", "exit"}:
+            return
+        parts = raw.split()
+        try:
+            if parts == ["show"]:
+                tree.show_tree()
+                print("中序:", tree.inorder())
+            elif parts == ["check"]:
+                ok, message = tree.check()
+                print("通过:" if ok else "失败:", message)
+            elif len(parts) == 2 and parts[0] == "find":
+                found, log = tree.find(int(parts[1]))
+                print_log(log)
+                print("找到。" if found else "未找到。", "当前根:", tree.root.key if tree.root else "空")
+            elif len(parts) == 2 and parts[0] == "add":
+                print_log(tree.add(int(parts[1])))
+                print("当前根:", tree.root.key if tree.root else "空")
+            elif len(parts) == 2 and parts[0] == "del":
+                deleted, log = tree.delete(int(parts[1]))
+                print_log(log)
+                print("已删除。" if deleted else "没有该键。")
+            else:
+                raise ValueError
+        except ValueError:
+            print("小提示:命令示例是 find 7、add 6、del 10。")
+
+
+if __name__ == "__main__":
+    main()

+ 24 - 0
互动程序/README.md

@@ -0,0 +1,24 @@
+# 课前零食互动程序
+
+每个 `00-课前零食` 都链接到这里的一份 Python 3 小实验室。它们只使用标准库,不联网、不读写仓库文件;可以放心反复运行、改输入、观察中间状态。
+
+在仓库根目录执行:
+
+```bash
+python3 互动程序/11-平衡树/00-从排好队到会分身的树.py
+```
+
+每个程序都提供预设数据:直接按回车即可开始。预设输入故意很小,目标是看清算法如何改变对象,而不是测试机器能跑多快。想自己实验时,按菜单输入相应数据;格式错误会给出提示并保留重试机会。
+
+推荐按零食课的顺序使用:先在纸上完成“对象—状态—动作—账本”和停顿问题,写下对下一步的预测;再运行程序逐行验证。程序输出与自己的预测不同时,优先回到纸上的状态表找漏掉的信息,而不是直接把输出抄走。
+
+这些程序是**学习可视化工具**,不是 OJ 提交代码,也不替代课程中的类 Python 伪代码或 C++17 参考实现。
+## 专项零食实验室
+
+除各主题的总预热外,下面四类跨越较大的结构也各有一份独立实验室;它们同样遵循“先纸上预测,再运行”的顺序。
+
+- [圆方树:从共享房间到圆方树](02-强连通与双连通/00-从共享房间到圆方树.py)
+- [Kruskal 重构树:把合并史写成树](06-最小与最大生成树/00-把合并史写成树.py)
+- [Splay:让刚访问的点靠近你](11-平衡树/00-让刚访问的点靠近你.py)
+- [文艺平衡树:把一段话翻过来](11-平衡树/00-把一段话翻过来.py)
+- [ODT:把相同的一段装进一个盒子](11-平衡树/00-把相同的一段装进一个盒子.py)

+ 30 - 0
课程/00-课程使用说明.md

@@ -21,6 +21,36 @@
 
 </details>
 
+## 先吃一份课前零食
+
+每个算法大主题的首篇正式课前都有一篇 `00-课前零食`。它面向已会 C++、STL、基础 DFS/BFS 的普及+同学,建议在正式课前约一周完成。零食课不用 OJ 例题:它会从最暴力结构或做法出发,用贯穿小例、SVG 图示、停顿问题和类 Python 伪代码推到算法为什么出现。
+
+每篇还链接一份位于根目录 `互动程序/` 的 Python 3 终端小实验室。无需安装第三方库;直接按文档中的命令运行,按回车可使用预设数据。它的任务是让你观察状态变化和不变量,不是让你背一段可提交代码。
+
+若读正式课时发现某个名词像空中楼阁,优先回到对应主题的零食课,而不是硬着头皮往下翻。
+
+圆方树、Kruskal 重构树、Splay、文艺平衡树和 ODT 的思维跨度更大,课程索引还安排了它们各自的专项零食。它们不是重复定义:会分别从“块怎样重叠”“DSU 忘了什么”“旋转保住什么”“位置怎样取代值”“段数为何可能失控”起步。
+
+### 零食课的互动协议
+
+零食课不是“读到算法名,再点开答案”的缩短版讲义。请准备一张纸,按下面四栏工作;每一轮都先写,再展开折叠区或运行程序。
+
+| 纸上栏位 | 要写什么 | 它防止什么 |
+|---|---|---|
+| 对象 | 此刻正在处理哪些点、边、字符、区间或数? | 一开始就被术语带跑 |
+| 状态 | 为了继续做下一步,当前必须记住什么? | 只会背数组名,不懂数组意义 |
+| 动作 | 本轮允许做哪一步?会改变哪些状态? | 把算法看成不可解释的黑箱 |
+| 账本 | 哪些工作重复了、哪条性质必须一直成立? | 只知道“快了”,不知道为什么 |
+
+每个零食课至少经过四步:
+
+1. **先当人脑暴力程序。**按题中小数据一步一步做,并把每一次比较、搬动、回退或枚举记下来。
+2. **让暴力法真的撞墙。**不是接受“它很慢”,而是指出哪个状态被重复算、哪种选择会走死、哪一项信息丢了。
+3. **只发明一个新部件。**例如一个摘要、一条边界、一份颜色、一棵局部树;写清它存什么、何时更新、它替你省掉什么。
+4. **再让程序当验算员。**先预测下一行日志,再按回车查看;预测错了也不是失败,而是找出自己漏掉的状态。
+
+程序若要输入命令,先把“我预测会发生什么”写成一句话。例如:`sum 2 5` 前写下你认为 Fenwick 会拿哪几块;`+ 6` 前画出 Treap 的键值左右关系。读懂输出不等于完成,能在输出前预测才算搭起框架。
+
 ## 两条阅读路线
 
 ### 尝试路线

+ 249 - 0
课程/01-欧拉路径/00-课前零食-从散步到欧拉回路.md

@@ -0,0 +1,249 @@
+# 课前零食:从散步到欧拉回路
+
+> 建议在正式学习欧拉路径前 5--7 天吃完。今天不做 OJ 题;我们只把“每条边走一次”这件事看懂,免得一上来就被 `Hierholzer` 这个名字唬住。
+
+## 本节目标
+
+学完后,你能:
+
+- 分清“每个点一次”的哈密顿路径和“每条边一次”的欧拉路径(Eulerian trail);
+- 用“进入要有离开配对”的想法自己推出无向图的奇度点条件;
+- 解释为什么不能只把普通 DFS 的访问顺序当作答案;
+- 看懂栈版 Hierholzer 如何把小回路接回大回路,并带着这张地图进入[正式课](01-判定构造与建模.md)。
+
+## 前置知识与课前自测
+
+只需要:无向图、邻接表、栈,以及“每条边有两个端点”。先不要查答案,拿纸画一画。
+
+1. 三角形的三个点都已经访问过后,还有没有可能存在一条没有走过的边?
+2. 一条从 $S$ 到 $T$ 的路线经过中间点 $X$ 时,为什么 $X$ 用掉的边常常能两两配对?
+3. 图里有两个互不相连的三角形;每个点度数都是 2。能从某一点一笔走完所有边吗?
+
+<details><summary>先核对想法</summary>
+
+1. 有可能。欧拉问题把“边”当待完成的工作;普通 DFS 通常把“点”当待访问的对象。
+2. 每一次从 $X$ 进入,若路线还没结束,就要从 $X$ 沿另一条未用边离开。
+3. 不能。度数条件只管每个局部入口能否配对;非孤立点还必须处在同一连通块中。
+
+</details>
+
+## 互动规则:把边剪成卡片,再开始走
+
+今天不要先在脑中想“DFS”。拿纸画点、把每条边编号成一张卡;每走一条边,就把那张卡翻面。你的纸上账本固定有四栏:
+
+| 对象 | 状态 | 本轮动作 | 必须不变的事 |
+|---|---|---|---|
+| 路口与道路 | 当前站在哪、哪些边已用 | 选一张相连的未用边走过去 | 一张道路卡最多翻一次 |
+| 正在形成的路线 | 路线首尾、尚未收尾的部分 | 继续走或把尾巴收起来 | 已收好的部分不应再需要新边 |
+
+先写下预测,再看折叠答案。今天的目标不是记住 `Hierholzer` 的拼写,而是能回答:**为什么“走过点”不够,为什么“无路可走”反而是好消息?**
+
+## 从零搭框架:先当一次笨拙的邮递员
+
+### 第 0 步:点的任务和边的任务不是同一道题
+
+画三角形 $A-B-C-A$。若任务是“每个点至少到一次”,从 $A$ 走到 $B$ 再到 $C$ 就可以停;若任务是“每条边恰好一次”,此时边 $C-A$ 还躺在地上。请在三条边旁分别写 `已用/未用`,不要只给点打访问标记。
+
+> 停顿 0:普通 DFS 遇到已访问的 $A$ 时通常会怎样做?为什么这会误伤边 $C-A$?
+
+<details><summary>展开:第一个状态修正</summary>
+
+普通 DFS 往往不再递归到已访问点;但欧拉问题允许、甚至经常必须沿一条新边回到旧点。于是 `visited_vertex` 不是主要账本,真正的账本是 `used_edge[id]`。重边要有不同编号;自环也会在同一个点的邻接表里出现两次。
+
+</details>
+
+### 第 1 步:故意从错误的起点出发
+
+画边:`A-B, B-C, C-A, C-D, D-E`。先从 $A$ 出发,并尽量“看到哪条未用边就走哪条”:
+
+```text
+A -> B -> C -> D -> E
+```
+
+现在手上还有哪张边卡?当前站在哪里?还能回去拿它吗?
+
+> 停顿 1:别立即改路线。请写出所有点的度数,并圈出奇数。你认为这条欧拉路径的两个端点被允许是谁?
+
+<details><summary>展开:失败不是因为手气差</summary>
+
+未用边是 `C-A`,但人已在 $E$,无法瞬移回去。度数为 $A:2,B:2,C:3,D:2,E:1$;奇度点只有 $C,E$,所以若存在欧拉路径,必须从 $C$ 或 $E$ 出发,而不是任意点。正确路线之一为 `E→D→C→A→B→C`。
+
+这让我们发明第一条框架规则:先检查局部的“进出账”是否能配平,再考虑如何构造。
+
+</details>
+
+### 第 2 步:把进出账写成一张表
+
+对一条路线内部的点,每次沿边进入,都要沿另一条未用边离开。请给下表补上“未配对的动作”:
+
+| 点的位置 | 进入次数与离开次数的关系 | 允许的度数奇偶 |
+|---|---|---|
+| 中间点 |  |  |
+| 起点(非回路) |  |  |
+| 终点(非回路) |  |  |
+| 回路中的所有点 |  |  |
+
+<details><summary>展开:无向图的度数条件从账本长出来</summary>
+
+中间点的进入和离开配对,度数为偶数;起点多一次离开、终点多一次进入,所以各是奇数;回路没有端点,所有度数为偶数。因此奇度点数只能为 0 或 2。它只是必要条件:两堆互不连通的三角形全为偶度,却仍无法用一条行走覆盖。
+
+</details>
+
+### 第 3 步:路线为什么不能只是一条向前的指针
+
+重新画两个共用 $A$ 的三角形。先只绕左三角形得到 `A→B→C→A`。此时路线已经闭合,但右三角形的边还未用。请在这条路线中圈出一个还能出发的点,并试着把右三角形的回路写进原路线的哪个空隙。
+
+> 停顿 3:如果你把新回路直接接在路线末尾,会得到一条连续路线吗?为什么必须“插入”而不是“追加”?
+
+<details><summary>展开:把局部回路嵌进旧路线</summary>
+
+右三角形也从 $A$ 出发并回到 $A$,可写成 `A→D→E→A`。把它替换旧路线里的那个 `A`,得到 `A→D→E→A→B→C→A`。追加在末尾也恰好可行是因为旧路线尾端是 A;一般剩余边连接的是旧路线中间某点,只有“在该点插入”才连续。
+
+</details>
+
+### 第 4 步:把“以后再插回去”交给栈
+
+现在给框架命名之前,先写下需要记住什么:
+
+```text
+current / stack:还没有收尾的一段行走
+used_edge[id]:每张道路卡是否已翻面
+ptr[u]:u 的邻接表已经翻到哪里
+answer:已经确认应放在最终路线尾部的点
+```
+
+当栈顶还有未用边,就继续向下走;当它没有未用边,这个点以后不可能再接新道路,才可以从栈顶弹出、收进 `answer`。这不是“回溯撤销边”,而是终于知道一段路线的结尾应该放在哪里。
+
+## 问题动机:把邮递员派去试路会怎样
+
+想象每条边是一条必须巡查一次的小路。最暴力的想法是:站在当前点,枚举下一条尚未巡查的路;走不通就撤销,换一条。若有很多岔路,选择序列会像排列一样迅速爆炸。邮递员还没出门,搜索树已经开始长蘑菇了。
+
+看这张图:两个三角形共用 $A$。从 $A$ 先绕完左三角形,仍然回到 $A$;右三角形并没有消失,只是应该被接到已有路线中间。
+
+![两个可拼接的小回路](../插图/01-欧拉路径/00-欧拉-01-回路拼接.svg)
+
+关键转变是:不猜整条路线,而是维护一条**已经闭合、可以继续插入小回路的路线**。
+
+## 步步式引导
+
+### 1. 先让每个路口把账算平
+
+从路线内部的点 $X$ 看:每次进来都要出去,所以已使用的关联边成对出现。只有起点允许多一次离开,终点允许多一次进入。因此无向图中:
+
+- 全部点度数为偶数时,可以期待一条回到起点的欧拉回路;
+- 恰有两个奇度点时,它们只能分别是路径的两个端点;
+- 奇度点超过两个时,账永远配不平,不用开始搜索。
+
+> 停顿:一条自环给一个点贡献几度?请在纸上画“进”和“出”各一次。
+
+<details><summary>展开</summary>
+
+是 2 度。虽然边两端写的是同一个点,它仍提供一次进入和一次离开;实现邻接表时也要把它当作两份邻接记录。
+
+</details>
+
+![在一个点处配对进入与离开](../插图/01-欧拉路径/00-欧拉-02-度数配对.svg)
+
+### 2. 找到一个回路后,为什么可以放心收起来
+
+从某个还有未用边的点出发,一直沿未用边走。若每个点的剩余度数仍是偶数,就不会在别处“只进不能出”;最后会回到出发点,得到一个小回路。若它的起点在旧路线里,把这个小回路插在该点的位置,旧路线和新回路都没有丢边,也没有重边。
+
+这就是 Hierholzer 算法的发明思路:**先走到无路可走,再把完成的尾巴反向收进答案。**栈恰好替我们记住“回到哪里插”。
+
+### 3. 把插回动作改写成栈
+
+设 `used[e]` 表示边 `e` 是否已经领过巡查单,`ptr[u]` 表示邻接表中已经检查到的位置。每条边只会被 `ptr` 跳过一次、被标记一次。
+
+```text
+# start 是奇度点之一;若没有奇度点,任取有边的点
+stack = [start]
+answer = []                 # 这里收集的是倒序路线
+while stack is not empty:
+    u = stack.top()
+    advance ptr[u] past used edges
+    if u still has an unused edge e = (u, v):
+        used[e] = true
+        stack.push(v)       # 先继续把一条小回路走深
+    else:
+        answer.append(stack.pop())
+return reverse(answer)
+```
+
+## 纸上模拟:先预测弹栈顺序,再看答案顺序
+
+对边编号:`0:A-B, 1:B-C, 2:C-A, 3:A-D, 4:D-E, 5:E-A`,从 $A$ 出发。请填写前六次“向下走”的栈;之后不要急着反转,先预测谁最先进入 `answer`。
+
+| 动作 | 走的边 | 栈(底到顶) | 倒序 `answer` |
+|---|---|---|---|
+| 开始 | — | `A` | `[]` |
+| 向下走 | 0 |  |  |
+| 向下走 | 1 |  |  |
+| 向下走 | 2 |  |  |
+| 向下走 | 3 |  |  |
+| 向下走 | 4 |  |  |
+| 向下走 | 5 |  |  |
+| 无边可走,弹栈 | — |  |  |
+
+<details><summary>展开:为什么答案反过来</summary>
+
+六次向下走后栈为 `A,B,C,A,D,E,A`。最后一个 $A$ 没有未用边,最先进入倒序答案;随后依次是 `E,D,A,C,B,A`。反转得到 `A,B,C,A,D,E,A`。每个点被收进答案时,以它为栈顶的未用边已经清空,所以它恰应位于尚未收好路线的末端。
+
+</details>
+
+`answer` 的一个点被加入时,说明从它出发的所有未用边都已经处理完;它正好该排在剩余路线的末尾。倒过来以后,每相邻两个点对应一条已走边。
+
+## 核心知识:小回路不会把大路线弄乱
+
+算法维护两个不变量:
+
+1. 栈中相邻顶点之间恰对应一条不同的已使用边,因此栈代表当前尚未收尾的一段行走;
+2. 只有栈顶已经没有未用边时才弹出,所以被写入 `answer` 的顶点之后不再需要插入新的边。
+
+每次前进恰新增一条边;每次回退只收集已经走完的部分。若所有非孤立点连通且奇度数为 0 或 2,最终会用尽全部边。反向答案的长度应为 $E+1$;这是很实用的验收器:少了说明漏边,多了说明某条边重复使用。
+
+时间复杂度是 $O(V+E)$,空间是 $O(V+E)$。它比回溯快的原因不是“栈很神奇”,而是它完全不撤销已确定的边:每条边只有一次职业生涯。
+
+## 实现指南
+
+- 无向边必须有**边编号**。不能只标记 `used[u][v]`:重边会互相误伤,自环也会变得尴尬。
+- 只检查度数还不够;从任意非孤立点 DFS/BFS 后,所有度数非零的点都应被看见。
+- 输出前检查答案长度是否为 `edge_count + 1`。正式课会进一步处理有向边、字典序和建模。
+- `ptr[u]` 是防止反复扫描陈旧边的关键。没有它,表面上像 $O(E)$ 的代码可能变成不停翻旧抽屉。
+
+## 互动小实验室
+
+运行 [从散步到欧拉回路.py](../../互动程序/01-欧拉路径/00-从散步到欧拉回路.py)。直接回车使用“双三角形”预设;程序会:
+
+1. 列出每个点的度数和奇度点;
+2. 检查非孤立点连通性;
+3. 一步步打印“沿边前进”与“栈顶无边、收进答案”;
+4. 最后核对路线是否刚好包含 $E+1$ 个顶点。
+
+试着把一条边删掉,再预测奇度点会怎样变化。不要害怕把图弄坏:程序不会给图记仇。
+
+## 扩展与联系
+
+有向图把“度数偶偶配对”换成“入度与出度配对”:内部点要求 `in = out`,两个端点分别允许相差 1。若任务要求访问每个**点**一次,则它已经是另一个世界的哈密顿问题,不能把今天的度数结论搬过去。
+
+正式课会把“词尾接词首”“给边定向”等题面翻译成边的使用问题;零食课先帮你练出识别信号:题目反复强调“每条关系/道路/相邻窗口恰好一次”时,先想到欧拉,而不是对点做普通 DFS。
+
+## 总结与自检
+
+1. 为什么两个奇度点只能是端点,而不能是任意中间点?
+2. `answer` 为什么要反转?
+3. 两个不连通的偶度环为什么仍无解?
+4. 重边为何迫使我们按边编号标记?
+
+<details><summary>自检答案</summary>
+
+中间点的进入和离开必须配对;栈回退先得到终点;一条路线不能跨连通块;两条同端点边是不同工作,不能共用一个布尔标记。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:手画两个三角形,标出“点访问”和“边使用”的不同。
+- 第 2 天:对三张小图数度数,故意加入自环和重边。
+- 第 3 天:在纸上执行一次栈版伪代码。
+- 第 4 天:运行互动程序并改一条边,解释输出变化。
+- 第 5 天:复述两个不变量,再进入[正式课](01-判定构造与建模.md)。

+ 2 - 0
课程/01-欧拉路径/01-判定构造与建模.md

@@ -1,5 +1,7 @@
 # 欧拉路径:从“每条边恰好一次”到字符串与定向构造
 
+> 觉得“边恰好一次”和 `Hierholzer` 还陌生?先完成 [课前零食:从散步到欧拉回路](00-课前零食-从散步到欧拉回路.md),并运行其中的小实验室。
+
 ## 本节目标
 
 学完后,你应能:

+ 240 - 0
课程/02-强连通与双连通/00-课前零食-从互相绕路到缩点.md

@@ -0,0 +1,240 @@
+# 课前零食:从互相绕路到缩点
+
+> 这杯零食给“会 DFS、但一看到 `low` 就想把电脑合上”的同学。先把图压成更好理解的形状,再进正式课学严谨的一次 DFS 写法。
+
+## 本节目标
+
+学完后,你能:
+
+- 用“能去也能回来”辨认强连通分量(Strongly Connected Component, SCC);
+- 明白为什么一群互相可达的点可以缩成一个点,且缩完一定没有有向环;
+- 用删边、删点的小实验认识桥(bridge)和割点(articulation point);
+- 知道正式课里的 `dfn/low/栈` 各在避免什么重复工作。
+
+## 前置知识与课前自测
+
+前置:有向边、无向边、DFS/BFS、连通块。请先回答:
+
+1. 有向边 $A\to B$ 存在时,$B$ 一定能回到 $A$ 吗?
+2. 若 $A$ 能来回到 $B$,$B$ 能来回到 $C$,$A$ 与 $C$ 的关系如何?
+3. 一条无向边若躺在一个环上,删掉它会把环断成两个连通块吗?
+
+<details><summary>答案与最小理由</summary>
+
+1. 不一定;箭头不是自动折返券。2. $A$ 与 $C$ 也互相可达,因此“互相可达”可以把点分组。3. 不会,环的另一侧仍能绕过去;这正是“不是桥”的证据。
+
+</details>
+
+## 互动规则:先把“能到”写成箭头账本
+
+这节有两张不同的图,千万别混在一张纸上:一张是**有向图**,问“能去又能回来”;另一张是**无向图**,问“删掉谁后断开”。每张纸都画四栏:`我从谁出发`、`这一步沿了哪条边`、`我还能回到谁`、`这条边/点被删后谁失去联系`。
+
+遇到 `low`、缩点、桥这些词时,先把它们盖住。你要先亲自回答:**哪些点真的扮演同一种“可达角色”?哪条路只是绕远,哪条路是唯一通道?**
+
+## 从零搭框架:把重复问路压成一张地图
+
+### 第 0 步:先区分单向能到与互相能到
+
+画有向边:`A→B, B→C, C→A, C→D, D→E, E→D, E→F`。从每个点出发,先只做最普通的 DFS/BFS,并把下表填成“能/不能”:
+
+| 出发点 | 能到 A | 能到 C | 能到 D | 能到 F |
+|---|---|---|---|---|
+| A |  |  |  |  |
+| D |  |  |  |  |
+| F |  |  |  |  |
+
+> 停顿 0:`A` 能到 `D`,`D` 能到 `A` 吗?哪些点既能从 A 到达、又能回到 A?先圈成一团。
+
+<details><summary>展开:先得到最原始的分组</summary>
+
+`A,B,C` 可以两两来回;`D,E` 可以两两来回;`F` 只能留在自己的单点团。单向可达不是对称关系,不能直接分组;“互相可达”才可以。
+
+</details>
+
+### 第 1 步:让暴力法自己暴露重复工作
+
+若对每个点都做一次 DFS,就能得到完整的可达表,复杂度约为 $O(V(V+E))$。请在刚才的图上标色:从 A 的 DFS 会反复看 `A,B,C` 的环;从 B 再出发时,又会把同一个环里的边检查一遍。
+
+> 停顿 1:把 `A,B,C` 压成一个大圆,`D,E` 压成一个大圆,`F` 保留单点。压完后边还剩怎样的方向?有没有环?
+
+<details><summary>展开:缩点不是丢信息,而是删掉无用区分</summary>
+
+得到三个点 `ABC→DE→F`。团内任何点对“能否到团外”的角色相同,许多问题不必再区分它们。若缩点后仍有环,环上团可以沿环互相到达,本应合为同一团,矛盾;所以缩点图一定是 DAG。
+
+</details>
+
+### 第 2 步:先证明“互相可达”能安全分组
+
+请为下面三句各写一条路线拼接理由,而不是只写“显然”:
+
+1. 任意点能回到自己吗?
+2. 若 $u$ 能来回到 $v$,那么 $v$ 能来回到 $u$ 吗?
+3. 若 $u$ 能来回到 $v$,$v$ 能来回到 $w$,怎样拼出 $u$ 到 $w$ 和 $w$ 到 $u$ 的路线?
+
+<details><summary>展开:等价关系给了我们缩点许可</summary>
+
+三条分别是自反、对称、传递。尤其传递性允许把一圈关系扩成一个最大团,而不是得到许多互相重叠的小团。SCC 是“互相可达”的等价类,不是 DFS 恰好走到的一坨点。
+
+</details>
+
+### 第 3 步:如何不做 $n$ 次 DFS 就把团找出来
+
+先接受一个适合手算的框架:第一次 DFS 不急着分组,只记录“一个点的所有后继都处理完”的**完成时刻**;第二次把所有箭头倒过来,按完成时刻从晚到早出发收集点。
+
+请在下表中模拟从 A 的第一次 DFS。每当一个点没有未访问后继时,把它写进 `finish`:
+
+| 返回到的点 | 此时哪些后继已处理完 | `finish` 尾部 |
+|---|---|---|
+| F |  |  |
+| E |  |  |
+| D |  |  |
+| C |  |  |
+
+> 停顿 3:为什么第二轮要把箭头反过来?若仍在原图按倒序出发,从 A 会不会一路跑到 D、E、F?
+
+<details><summary>展开:反图把“团的入口”封起来</summary>
+
+第一次完成序列为 `F,E,D,C,B,A`。在反图中先从 A 出发只能收集 `A,B,C`,不会沿原来 `C→D` 的方向越界;之后收 `D,E`,最后收 F。把两次 DFS 看成一种易懂的脚手架;正式课会把它压缩成 Tarjan 的一次 DFS、栈和 `low` 不变量。
+
+</details>
+
+### 第 4 步:换成无向图,问题完全换了
+
+擦掉箭头,画边:`A-B, B-C, C-A, C-D, D-E, E-F, F-D`。先不用任何公式,做两个物理实验:
+
+1. 用橡皮擦掉 `A-B`;A 和 B 还能通过谁相遇?
+2. 擦掉 `C-D`;左右两坨还能相遇吗?
+3. 擦掉点 C(连同边);哪几坨会分开?
+
+> 停顿 4:给“删边断开”和“删点断开”各起一个临时名字,再看下方正式名字。
+
+<details><summary>展开:桥和割点也是“有没有绕路”</summary>
+
+`A-B` 在三角形中可绕 `A-C-B`,不是桥;`C-D` 无替代路线,是桥;删 C 会把左侧和右侧分开,C 是割点。这里不再问“能否回来”,而是问一棵 DFS 子树有没有通往祖先的旁路;它与 SCC 的 `low` 名字相同但含义不同。
+
+</details>
+
+## 问题动机:重复问路太浪费
+
+如果要知道任意两点是否互相可达,最暴力的做法是从每个点都 DFS 一遍。$n$ 个点会得到 $O(n(n+m))$ 的工作量;更糟的是,我们反复发现同一个环里的点本来就是“同一团”。
+
+例如 $A\to B\to C\to A$,再有 $C\to D$,$D\leftrightarrow E$,$E\to F$。在团内从谁出发都能绕回来;真正有价值的是团与团之间的单向关系。
+
+![把互相可达的点缩成一个点](../插图/02-强连通与双连通/00-连通-01-缩点.svg)
+
+## 步步式引导
+
+### 1. “互相可达”为什么正好可以分组
+
+把 $u$ 与 $v$ 放同组,当且仅当 $u$ 能到 $v$ 且 $v$ 能到 $u$。它有三种好性质:自己能到自己、关系可以倒过来、两段来回路可以接起来。因此它是等价关系,所有点被自然切成若干个 SCC。
+
+若缩点图还有一个环,例如团 $P\to Q\to R\to P$,那么三个团里的任何点都能沿这些箭头互相到达,本应是同一团,矛盾。所以缩点后的图是 DAG。这个结论让很多“乱图”变成可以按方向依次处理的图。
+
+### 2. 从两次走路自然得到 SCC
+
+一种适合入门理解的办法是 Kosaraju:
+
+1. 在原图 DFS,某点的所有能继续走的后代处理完后,记下它的完成顺序;
+2. 把所有箭头反向;
+3. 按完成顺序倒着出发,在反图中一次 DFS 能收集到的一团就是一个 SCC。
+
+为什么不会串到别团?把一团看成缩点 DAG 的一个点。第一次 DFS 中,沿箭头能到达的团会更早完成;倒序挑出的团不会在反图中走进尚未收集的“错误团”。正式课的 Tarjan 把这两次旅行压缩进一趟 DFS:`low` 负责告诉我们能回到多早,栈负责暂存尚未封口的一团。
+
+```text
+finish = []
+for every vertex u not visited:
+    dfs_original(u) and append u when it finishes
+
+for u in reverse(finish):
+    if u not assigned:
+        component = dfs_on_reversed_graph(u)
+        output component
+```
+
+### 3. 无向图的“脆弱处”是另一种问题
+
+现在把箭头擦掉。若删一条边后图被切开,它是桥;若删一个点(以及相连边)后图被切开,它是割点。它们都在问:某棵 DFS 子树能否借一条边绕回祖先?
+
+![环边可以绕开,桥边不能](../插图/02-强连通与双连通/00-连通-02-桥与绕路.svg)
+
+这里有一个容易混的对照:SCC 的 `low` 关注“仍在当前栈里的可回到位置”;桥/割点的 `low` 关注“子树能否绕到祖先”。名字相同,工作许可证不同,别把两张表硬嫁接。
+
+## 纸上模拟:两张表,两个完全不同的问题
+
+先对有向图完成 Kosaraju 的第二轮。把第一轮的 `finish=[F,E,D,C,B,A]` 写在纸上,倒过来依次尝试 `A,B,C,D,E,F`。每次从尚未分配的点出发,在**反图**中记录本轮收进的点:
+
+| 尝试起点 | 是否已分配 | 反图 DFS 收到的点 | 新分量 |
+|---|---|---|---|
+| A |  |  |  |
+| B |  |  |  |
+| C |  |  |  |
+| D |  |  |  |
+| F |  |  |  |
+
+<details><summary>展开:不要把“倒序”误读成“倒着走路线”</summary>
+
+从 A 在反图收 `{A,B,C}`;B、C 已分配,跳过;从 D 收 `{D,E}`;最后从 F 收 `{F}`。倒序的是“选择新起点的完成顺序”,每一轮 DFS 仍沿反图箭头正向走。
+
+</details>
+
+再对无向图做一个低链前的人工验证。删除 `C-D` 后,以 A 为起点能访问到哪些点?删除 `A-B` 呢?请把结果写进:
+
+| 删除对象 | A 所在连通块 | 另一侧还有谁 | 结论 |
+|---|---|---|---|
+| 边 `C-D` |  |  |  |
+| 边 `A-B` |  |  |  |
+| 点 `C` |  |  |  |
+
+<details><summary>展开:low 公式要对应一条可见的旁路</summary>
+
+删 `C-D` 时 A 侧是 `{A,B,C}`,D 侧是 `{D,E,F}`,它是桥;删 `A-B` 后所有点仍连通,因为有 `A-C-B`;删 C 后 A、B 与 D、E、F 分开,C 是割点。正式算法不是真的逐个删除,它只在 DFS 中记录“子树能绕到多早的祖先”,一次性推导出同样结论。
+
+</details>
+
+## 核心知识:删掉什么,谁就暴露出来
+
+在无向 DFS 树中,若孩子 `v` 的整棵子树不能沿返祖边回到 `u` 之前的祖先,即 `low[v] > dfn[u]`,树边 $(u,v)$ 是桥。若非根 `u` 存在孩子使 `low[v] >= dfn[u]`,删除 `u` 后那棵子树无法接到上方,`u` 是割点。根要特殊处理:它有至少两个 DFS 孩子时才是割点。
+
+这些条件不是魔法公式:把 `low[v]` 读成人话——“从 $v$ 子树出发,最早还能绕回 DFS 树的哪位祖先”。
+
+小图上暴力删边/删点当然可以用来验证答案,但每次重做连通性会是 $O((n+m)^2)$ 量级。`dfn/low` 让一趟 DFS 汇总所有绕路信息,达到 $O(n+m)$。
+
+## 实现指南
+
+- 有向 SCC 和无向桥不能混用邻接表语义;先在纸上写清楚每条边是否有方向。
+- 无向图要按**边编号**排除“父边”,不能按父节点排除;否则平行边会被误判成桥。
+- SCC 缩点后若要建新图,可去重边,但先保留原边才能正确找分量。
+- 递归 DFS 面对很深的链可能爆栈;正式提交时按题目范围改迭代或调整实现策略。
+
+## 互动小实验室
+
+运行 [从互相绕路到缩点.py](../../互动程序/02-强连通与双连通/00-从互相绕路到缩点.py)。选择:
+
+- `1`:观察原图完成顺序、反图 DFS 和缩点结果;
+- `2`:观察删桥/割点的低链值,并比较“有环可绕”和“桥无处绕”。
+
+先在纸上预测 $C\to D$ 所在的位置会不会把两个 SCC 合并,再让程序揭晓。程序不是神谕,它只是一个不嫌你反复按回车的黑板。
+
+## 扩展与联系
+
+缩点 DAG 是许多 DP、最少补边和可达性题的起点;桥缩成边双连通分量后得到桥树;割点和点双连通分量会在后续圆方树中重现。现在只需牢牢记住两句话:有向图先看“能否回来”,无向图先问“有没有绕路”。
+
+## 总结与自检
+
+1. 缩点图为什么不可能有环?
+2. 为什么平行边能救下一条看似孤独的树边?
+3. 桥条件的严格大于和割点条件的非严格大于,分别对应什么?
+
+<details><summary>自检答案</summary>
+
+缩点环会让环上团互相可达;另一条平行边提供了绕回父亲的路;桥要求完全绕不到父亲,割点只要求绕不到父亲之上。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:画三点有向环与单向链,圈出互相可达组。
+- 第 2 天:手做一次“原图完成、反图收集”。
+- 第 3 天:在无向小图上暴力删一条边、删一个点。
+- 第 4 天:运行互动程序,读出一组 `dfn/low` 的人话含义。
+- 第 5 天:进入[强连通分量与缩点正式课](01-强连通分量与缩点.md)。

+ 231 - 0
课程/02-强连通与双连通/00-课前零食-从共享房间到圆方树.md

@@ -0,0 +1,231 @@
+# 课前零食:从共享房间到圆方树
+
+> 圆方树不是“再给图换一套很玄的名字”。它处理的是一个很朴素的尴尬:两个结实的小团体可以共用同一个人;把人硬塞进互不重叠的组里,反而把信息弄丢了。今天我们先把这件尴尬画出来,再给“团体”发一张方形身份证。
+
+## 本节目标
+
+学完后,你能:
+
+- 从删点实验中认出割点与点双连通分量(vertex-biconnected component)分别在描述什么;
+- 解释为什么点双分量可以共享割点,不能像强连通分量那样直接缩成互不重叠的点;
+- 手工把一张无向图变成圆方树(block-cut tree),并在树上读出必经的割点;
+- 跟踪边栈在何时弹出一个点双块,知道条件 low[child] ≥ dfn[parent] 的人话含义;
+- 用“原点是圆、块是方”的统一视角,为后面的路径统计和树上 DP 搭好框架。
+
+## 前置知识与课前自测
+
+前置:无向图、DFS 树、桥和割点的直觉。不会写 Tarjan 也没关系;本节会先用暴力删除验证。
+
+1. 删除一个点 C 后图分成两块,C 自己该归到哪一块?
+2. 三角形 A-B-C-A 中,删掉 B 后 A 与 C 还连通吗?
+3. 两个“删掉任一点仍有路绕开”的大块,能共享两个原图顶点吗?
+
+<details><summary>先别急着看:自测答案</summary>
+
+1. 它往往同时是多块的门口,硬选一边会丢掉另一边的归属。2. 仍连通,直接走 A-C。3. 不能。若共享两个点,这两块可经两个公共点拼成更大的点双块;真正的重叠只能是一处割点。
+
+</details>
+
+## 互动规则:把图当作一栋有门的房子
+
+请在纸上画下面的无向图;边都是双向走廊:
+
+~~~text
+三角形一:A-B-C-A
+三角形二:C-D-E-C
+走廊链:  C-F-G
+~~~
+
+纸上分四栏:**对象**(现在有哪些房间、走廊)、**状态**(删掉谁、DFS 到哪)、**动作**(本轮能沿哪条走廊)、**账本**(哪些点属于哪些结实团体)。每次遇到“停顿”,先写答案,再展开或运行程序。
+
+贯穿问题不是“背出圆方树定义”,而是:从 A 去 E、从 A 去 G 时,哪些门卫绝对绕不过?如果后来要问很多次路径,能否只在一棵树上回答?
+
+## 从零搭框架:先让“一个人属于两组”真的发生
+
+### 第 0 步:暴力拆房,先不许用 low
+
+依次把 C、F 从图上擦掉,并从 A 出发标记还可到达的点。记录:
+
+| 被删除的点 | A 一侧还看见谁 | 另一侧有哪些点 | 这说明什么 |
+|---|---|---|---|
+| C |  |  |  |
+| F |  |  |  |
+
+> 停顿 0:删 C 后,A、B、D、E、F、G 分成哪几堆?C 若是“某个团的成员”,它要属于几团?
+
+<details><summary>展开:第一处不能硬分组的地方</summary>
+
+删 C 后有 {A,B}、{D,E}、{F,G} 三堆,所以 C 是割点。它同时是三角形 ABC、三角形 CDE 与走廊 CF 的端点。若把 C 只分给其中一个团,另两个团与 C 的连接信息就被擦掉了;这正是“普通缩点”不够用的原因。
+
+</details>
+
+### 第 1 步:先找真正结实的局部
+
+把三角形 ABC 圈起来。删除其中任一顶点,剩下两点仍有边相连;所以它内部没有一个单点能把其余成员拆开。CDE 也一样。单条走廊 CF 虽然没有绕路,但在以边为单位的点双分解里,它也形成一个含 C、F 的块:它提醒我们这条连接只经由两端。
+
+> 停顿 1:为什么“删 C 后 A 到 E 断了”不等于“ABC 这个三角形不结实”?请分别写出这两个问题删除后的剩余对象。
+
+<details><summary>展开:块内结实,与块间门口是两层问题</summary>
+
+ABC 问的是只看 A、B、C 这个小块时,删一个点后剩余点是否仍连通;删 C 后 A-B 仍有边。A 到 E 的问题却跨了两个块,所有跨越都要经过 C。点双块负责保存局部可绕性,割点负责说明块与块之间的唯一门口。
+
+</details>
+
+### 第 2 步:发明方点,而不是复制原点
+
+试过“给每个点双块涂一种颜色”后,C 会被迫涂三种颜色。不要复制 C;复制的是**块这个关系**。做法如下:
+
+1. 原图每个顶点保留一枚圆点;
+2. 每个点双块新建一枚方点;
+3. 方点与属于该块的全部圆点连边。
+
+请为 ABC、CDE、CF、FG 分别画方点 Q1、Q2、Q3、Q4,再连到对应圆点。
+
+![一个割点可同时属于多个点双块](../插图/02-强连通与双连通/00-圆方树-01-共享点双块.svg)
+
+> 停顿 2:在你新画出的图中,圆点 C 的度数是多少?从圆点 A 到圆点 G 的唯一路径经过哪些方点和圆点?
+
+<details><summary>展开:把重叠改写成树边</summary>
+
+C 连 Q1、Q2、Q3,度数为 3。A 到 G 的路径是 A-Q1-C-Q3-F-Q4-G;其中 C、F 是原图中绕不过的割点。原图的“块重叠”被方点吸收后,连接关系变成树状,不再需要在一张有环图里猜所有绕路。
+
+</details>
+
+### 第 3 步:为什么结果真会是一棵树
+
+先只看连通原图。每加入一个新点双块,它若和已经出现的块共享两个原点,就会与旧块合并成更大的点双块,矛盾;因此它只会通过一个割点接到已有部分。每个新方点像给树挂上一颗新叶子,圆方图不会出现环。
+
+这一步很重要:圆方树不是“把原图画得好看”,而是用一棵树保存原图的**块—割点骨架**。原图的一条边落在某个方点内部;跨块路径则只能在圆方树中穿过相应圆点。
+
+## 问题动机:暴力删点会重复做太多次
+
+刚才逐个擦点很直观,但若有 n 个点、m 条边,每删一个点都重做一次 DFS,会接近 O(n(n+m))。我们需要在一次 DFS 中记住:某个孩子子树有没有一条边能绕回父亲之前的祖先。
+
+![DFS 的一段走廊何时封成一个块](../插图/02-强连通与双连通/00-圆方树-02-边栈与封口.svg)
+
+## 步步式引导:把尚未封口的走廊放进边栈
+
+### 1. 边栈里放的不是“访问顺序”,而是暂未归属的边
+
+从 A DFS。每走一条树边,或看见一条通往祖先的返祖边,就把**边编号**压进栈。为什么不是压顶点?因为 C 需要出现在多个块里;一条边却恰好属于一个点双块,最后可以放心交出去。
+
+令 dfn[u] 是 u 首次被看到的时间,low[u] 是从 u 的 DFS 子树出发,经零条或多条树边、至多一条返祖边,能碰到的最早祖先时间。
+
+当树边 u-v 满足:
+
+$$
+low[v]\ge dfn[u],
+$$
+
+v 子树再也不能绕到 u 的上方。栈顶直到边 u-v 为止的走廊恰好封成一个点双块;把它们弹出,新建一个方点。
+
+### 2. 先翻译不等式,再看伪代码
+
+- low[v] < dfn[u]:子树能从侧门绕到 u 的祖先,暂时不能封口;
+- low[v] = dfn[u]:能绕回 u 本人,却到不了上方;在 u 这里封口;
+- low[v] > dfn[u]:连 u 也绕不回,这条树边还是桥;它仍对应一个只有这条边的块。
+
+> 停顿 3:在图中从 C 走到 F,再走到 G。G 返回 F 时为什么必然封出 FG?F 返回 C 时为什么又会封出 CF?
+
+<details><summary>展开:没有旁路的走廊会逐段结账</summary>
+
+G 没有返祖边,low[G]=dfn[G],大于 dfn[F],所以弹出 FG。随后 F 的 low 仍是 dfn[F],大于 dfn[C],再弹出 CF。桥不是“圆方树做不了的边”;它恰好是一个极小的方块,只连接它的两个端点。
+
+</details>
+
+~~~text
+DFS(u, parent_edge):
+    dfn[u] = low[u] = next_time()
+    for edge e=(u,v):
+        if e is parent_edge: continue
+        if v is unvisited:
+            push e onto edge_stack
+            DFS(v, e)
+            low[u] = min(low[u], low[v])
+            if low[v] >= dfn[u]:
+                pop edges through e as one block
+                create one square node for their endpoints
+        else if dfn[v] < dfn[u]:
+            push e onto edge_stack
+            low[u] = min(low[u], dfn[v])
+~~~
+
+适用条件:这里是静态无向图。重边必须按边编号跳过父边,不能把“连到父亲”全跳过;否则另一条平行边提供的绕路会消失。
+
+## 核心知识:圆点与方点各自承诺什么
+
+| 节点 | 表示 | 它连向谁 | 读路径时意味着什么 |
+|---|---|---|---|
+| 圆点 | 原图一个顶点 | 包含它的方点 | 可能是割点,也可能只是块内成员 |
+| 方点 | 一个点双连通块 | 该块的原顶点 | 在块内部可以绕开单个成员 |
+
+原图连通时,圆方树是一棵树;原图不连通时得到森林。两原点间在原图跨越的点双块顺序,就是圆方树上两圆点之间的路径。若题目询问“从 u 到 v 必经多少个割点”,常可在这条树路径上统计度数大于 1 的圆点(但要按题目是否计端点调整)。
+
+正确性的账本有两层:边栈弹出的边在边界出现前不能和更早边组成同一块;边界出现时它们又通过 DFS 路径和返祖边连成最大块。每条边压、弹各一次,Tarjan 与建树总计 O(n+m),树有至多 n 加上块数个节点。
+
+## 实现指南
+
+- 给每个原点预留圆方树编号 1..n;新方点从 n+1 起编号,弹出一块才分配。
+- 弹栈时收集边的两个端点;对每个不同端点 v,加圆方树边 square-v。
+- 用时间戳数组去重“本块出现的端点”,不要为每块清空 O(n) 的集合。
+- 根是否是割点的判定需要 DFS 儿子数;但边栈的点双块弹出仍按 low[child] ≥ dfn[parent] 执行。
+- 孤立点没有边栈块;题目若要把它放进圆方树,可只保留孤立圆点。不要无依据地造一个方点。
+
+## 纸上模拟:让边栈真的开一次账
+
+按邻接顺序 A→B→C→D→E→F→G DFS。先在纸上补全表;返祖边 E-C 和 C-A 是最关键的两笔。
+
+| 返回的孩子边 | low[孩子] | 与父亲 dfn 比较 | 弹出的边 | 新方点 |
+|---|---:|---|---|---|
+| D-E |  |  |  |  |
+| C-D |  |  |  |  |
+| F-G |  |  |  |  |
+| C-F |  |  |  |  |
+| A-B |  |  |  |  |
+
+<details><summary>展开:一份可核对的边栈流水线</summary>
+
+E 经返祖边回到 C,所以 low[E]=dfn[C];D 返回 C 时 low[D]=dfn[C],弹出 C-D、D-E、E-C,得到 CDE。G 返回 F 时弹 FG;F 返回 C 时弹 CF。C 还能经 C-A 回到 dfn[A],所以 B 返回 A 时才弹 A-B、B-C、C-A,得到 ABC。每一批弹出的边只交给一个方点。
+
+</details>
+
+接着在你画出的圆方树上圈出 A 到 E、D 到 G 两条路径。问自己:若原图某条块内边替换成另一条块内边,圆方树路径会变吗?不会;圆方树刻意忘掉了块内部细节,只保留“从哪个门进、从哪个门出”的结构。
+
+## 互动小实验室
+
+运行 [从共享房间到圆方树.py](../../互动程序/02-强连通与双连通/00-从共享房间到圆方树.py)。它会先请你预测删 C 的结果,再打印边栈的压入、弹出和生成的方点。可输入:
+
+- show:查看原图、点双块和圆方树邻接表;
+- delete C:用朴素 DFS 验证删点后的连通块;
+- path A G:查看圆方树上两圆点的路径;
+- trace:重新逐步打印 Tarjan 的账本。
+
+先预测 Q1 到 Q4 中哪一个方点会连接 C,再运行;程序只是验算员,不会替你补纸上的箭头。
+
+## 扩展与联系
+
+- 圆方树也叫块割树;“圆/方”只是方便画图的称呼,不代表图论中另一个神秘对象。
+- 边双连通分量彼此不共享原点,缩成桥树即可;点双块共享割点,才需要额外方点。
+- 块内若要继续处理最短路、计数或 DP,圆方树不足以代替原图;它只保存块与块之间的骨架。
+- 动态加删边会改变点双块边界,静态 Tarjan 与静态圆方树不能直接维护。
+
+## 总结与自检
+
+1. 为什么方点代表点双块、圆点仍代表原图顶点?
+2. 两个点双块为什么至多共享一个原点?
+3. low[child] ≥ dfn[parent] 的一句人话是什么?
+4. 为什么边栈比顶点栈适合提取点双块?
+
+<details><summary>自检答案</summary>
+
+方点把可重叠的“成员关系”外置;共享两个点会使两块合并;孩子子树无法绕到父亲上方;割点可以反复属于不同块,边却恰好只交给一个块。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:在两三角形共一点的图上暴力删点,写下连通块。
+- 第 2 天:手建 Q1、Q2、Q3、Q4,沿圆方树回答必经门口。
+- 第 3 天:只模拟一次边栈的压入和第一次弹块。
+- 第 4 天:运行互动程序,比较暴力删点和 low 的结账时刻。
+- 第 5 天:进入[割点、点双连通分量与圆方树入口正式课](03-割点点双连通与圆方树入口.md)。

+ 2 - 0
课程/02-强连通与双连通/01-强连通分量与缩点.md

@@ -1,5 +1,7 @@
 # 强连通分量与缩点:把有向环压成 DAG
 
+> 若“互相可达”“缩点”和 `low` 还没有直觉,先完成 [课前零食:从互相绕路到缩点](00-课前零食-从互相绕路到缩点.md)。
+
 ## 本节目标
 
 学完后,你应能从“互相可达”识别强连通分量(Strongly Connected Component, SCC),推导 Tarjan 的栈不变量,并在缩点 DAG 上解决选点、可达终点和最少补边问题。

+ 6 - 0
课程/02-强连通与双连通/03-割点点双连通与圆方树入口.md

@@ -1,5 +1,7 @@
 # 割点、点双连通分量与圆方树入口
 
+> 若“一个点为什么能属于多个块”还没有画清,先完成 [课前零食:从共享房间到圆方树](00-课前零食-从共享房间到圆方树.md)。它从暴力删点、边栈结账走到圆方图,附有可操作的 Python 验算。
+
 ## 本节目标
 
 学完后,你应能判断割点(articulation point),用边栈提取点双连通分量(vertex-biconnected component, v-BCC),区分点双与边双,并理解为什么点双分量重叠于割点、需要用圆方树表达。
@@ -34,6 +36,8 @@ $$
 
 为什么用边栈而不是点栈?割点属于多个点双分量,不能在第一次弹出后永久删除;边却恰属于一个这样的块。
 
+![从原图的重叠点双块,到圆方树的成员关系](../插图/02-强连通与双连通/03-圆方树-01-原图到圆方树.svg)
+
 ## 核心知识
 
 ### 三类概念对照
@@ -46,6 +50,8 @@ $$
 
 圆方树中,原顶点作为圆点,每个点双分量新建方点,并连接方点与属于该分量的原顶点。原图连通时得到一棵树;路径问题由“必须经过哪些割点”转成树路径问题。
 
+![树路径把“必经割点”显式化](../插图/02-强连通与双连通/03-圆方树-02-路径经过割点.svg)
+
 ### 正确性与复杂度
 
 对树边 $u-v$,若 `low[v] < dfn[u]`,子树能绕过 $u$ 回到更早祖先,不在此处封闭。若 `low[v] >= dfn[u]`,从该树边之后压入的边无法与更早边共同处于一个更大的点双分量,应在这里恰好弹出。每条边压栈、弹栈至多一次,总时间 $O(V+E)$,空间 $O(V+E)$。

+ 228 - 0
课程/03-Bitset/00-课前零食-把64个开关装进一个格子.md

@@ -0,0 +1,228 @@
+# 课前零食:把 64 个开关装进一个格子
+
+> 今天的主角不是某个玄学优化,而是一句很朴素的话:既然一排判断都只回答“是/否”,能不能让 CPU 一次搬 64 个?答案是能,但账本仍要老老实实地算。
+
+## 本节目标
+
+学完后,你能:
+
+- 把一个布尔数组看成集合,把第 $i$ 位为 1 看成“元素 $i$ 在集合中”;
+- 用 AND、OR、XOR、移位分别表达交、并、奇偶出现和整体平移;
+- 解释 `bitset` 的一次整集合操作为何是 $O(N/w)$ 而不是魔法 $O(1)$;
+- 看懂为什么这类优化适合“大量同形布尔判断”,却不适合每一位都走不同分支的任务。
+
+## 前置知识与课前自测
+
+前置:二进制、位运算 `& | ^ << >>`、数组下标。
+
+1. 若位串 `00101000` 的最右位是编号 0,哪些编号在集合中?
+2. 两个集合交集中的元素,应在两个位串的哪种运算后留下 1?
+3. 长度 $10^5$ 的位集合做一次 `&`,CPU 真只做一条机器指令吗?
+
+<details><summary>答案</summary>
+
+1. 编号 3 和 5。2. AND。3. 不会;它通常要处理约 $\lceil10^5/64\rceil$ 个机器字,只是每一字同时处理了 64 个布尔值。
+
+</details>
+
+## 互动规则:把 0 和 1 当成一排真的开关
+
+这节不从 `std::bitset` 开始。请先在纸上画编号 `0..7` 的八个格;约定**最右格是编号 0**。每一轮都写四栏:`第 i 格代表什么对象`、`这一轮逐格做了什么`、`能否把很多格做成同一种动作`、`越界格去哪了`。
+
+程序里显示的字符串从左到右是高位到低位;纸上编号却从右往左。这个小别扭故意保留:你要亲手翻译一次,之后才能真正控制位运算,而不是靠眼熟。
+
+## 从零搭框架:从八次 if 到一次整字操作
+
+### 第 0 步:先让布尔数组说人话
+
+令 `like_A[i]` 表示编号 `i` 的人是否在 A 的名单,`like_B[i]` 同理。把下面两行翻译成集合成员:
+
+```text
+A = 10110110
+B = 01101100
+```
+
+> 停顿 0:请从右往左编号,分别列出 A、B 中为 1 的下标;再圈出两边同时为 1 的下标。不要先算二进制整数。
+
+<details><summary>展开:位串只是压缩后的布尔数组</summary>
+
+A 的成员为 `{1,2,4,5,7}`,B 的成员为 `{2,3,5,6}`,共同成员为 `{2,5}`。第 $i$ 位就是一句 `bool`;它不是一个神秘的“位技巧对象”。
+
+</details>
+
+### 第 1 步:先写出最朴素循环和它的账本
+
+要找共同成员,人脑会写:
+
+```text
+for i in 0..N-1:
+    if A[i] and B[i]:
+        keep i
+```
+
+请在 $N=8$ 的例子中逐列写下两盏灯的状态与结果。然后把 $N$ 改成 64、$10^5$,只估算“要做多少次彼此独立的同形判断”,不必猜具体秒数。
+
+> 停顿 1:这 8 次 `and` 之间有前后依赖吗?第 7 格的答案会影响第 0 格吗?
+
+<details><summary>展开:发现可并行的真正条件</summary>
+
+没有依赖:每一列只看自己的两个布尔值,并采用同一规则。于是我们不必改变答案定义,只需改变处理单位——把连续 64 个布尔值装成一个 64 位机器字。若每格下一步都要访问不同位置或依赖前一格结果,就不能直接这样打包。
+
+</details>
+
+### 第 2 步:先为一位写真值表,再允许它批量工作
+
+不要背 `& | ^`。给单个位置填表:
+
+| A 的这一位 | B 的这一位 | “都在名单中” | “至少一边在” | “恰好一边在” |
+|---:|---:|---:|---:|---:|
+| 0 | 0 |  |  |  |
+| 0 | 1 |  |  |  |
+| 1 | 0 |  |  |  |
+| 1 | 1 |  |  |  |
+
+> 停顿 2:分别给三列取运算符名字;为什么“出现奇数次”要用 XOR 而不是 OR?
+
+<details><summary>展开:集合语言与机器语言一一对应</summary>
+
+“都在”是 AND, “至少一边”是 OR,“恰好一边/出现次数奇偶”是 XOR。两次出现的元素在 XOR 中变回 0,正好符合模 2 计数;OR 会把两次出现仍错误地保留成 1。
+
+</details>
+
+### 第 3 步:移位不是搬数字,是让集合成员改编号
+
+在纸上写 `reach={0,2,5}`。若一个统一动作把每个位置编号加 3,请先把集合手算成新集合;再把它写成一个 8 位串,做左移 3 位,最后裁掉超过 7 的成员。
+
+> 停顿 3:若不同位置分别加 `a[i]`,还能用一次 `<<` 吗?为什么?
+
+<details><summary>展开:一条移位代表许多相同转移</summary>
+
+`{0,2,5}` 变为 `{3,5,8}`,在长度 8 的集合里只留下 `{3,5}`。`reach << 3` 正是在所有置位位置同时加同一个数;若每个位置的步长不同,就没有一条统一移位可代替逐点转移。
+
+</details>
+
+### 第 4 步:速度账本要算“字”,不是假装一切 O(1)
+
+把长度 $N$ 的布尔数组切成 $\lceil N/64\rceil$ 个字。请填写:
+
+| 长度 N | 一次整集合 AND 至少处理几字 | 外层枚举 $N$ 次时总字操作约多少 |
+|---:|---:|---:|
+| 64 |  |  |
+| 1024 |  |  |
+| $10^5$ |  |  |
+
+这一步是 Bitset 的复杂度框架:先识别“每位同形”,再按机器字估算;不是看到 `bitset` 就把复杂度涂成 $O(1)$。
+
+## 问题动机:逐个打勾并不总是必要
+
+假设有两张很长的“喜欢这部电影的人”名单。要找共同喜欢的人,最直白的循环会逐个人比较:`if likes_a[i] and likes_b[i]`。比较本身没有错,但 64 次彼此独立的小判断可以装进一个 64 位整数,一次 AND 就交给硬件并行完成。
+
+![把多个布尔值装入一个机器字](../插图/03-Bitset/00-Bitset-01-打包.svg)
+
+注意这个词:**并行的是同形操作,不是任意算法。**若每个位置都要访问不同的邻居、走不同分支,装进同一个格子也救不了数据依赖。
+
+## 步步式引导
+
+### 1. 从集合语言翻译到位运算
+
+令第 $i$ 位表示元素 $i$ 是否在集合里。两集合 $A,B$ 的每一位独立决定:
+
+| 集合问题 | 位运算 | 这一位为什么正确 |
+|---|---|---|
+| $A\cap B$ | `A & B` | 两边都选中才留下 |
+| $A\cup B$ | `A | B` | 至少一边选中就留下 |
+| 恰好一边选中 | `A ^ B` | 两个 1 合成 0,符合奇偶 |
+| 全体中不在 $A$ | `~A` | 0 与 1 互换;要记得裁掉越界位 |
+| 编号整体加 $k$ | `A << k` | 每个置位位置向高位平移 |
+
+![同一位上的集合运算](../插图/03-Bitset/00-Bitset-02-集合运算.svg)
+
+### 2. 速度从哪里来,又没有从哪里来
+
+把长度 $N$ 的集合分成 $\lceil N/w\rceil$ 个机器字,常见机器字宽 $w=64$。整集合 AND/OR/XOR 的工作量是:
+
+$$
+O\left(\frac{N}{w}\right).
+$$
+
+这不是把 $N$ 个信息凭空删掉,而是改变处理单位。外层若仍枚举 $N^2$ 对点,总复杂度仍约为 $O(N^3/w)$;先估算总机器字操作次数,才知道优化有没有把题从“过不了”带到“能过”。
+
+### 3. 一次移位为什么能表示很多状态转移
+
+若 `reach[i]=1` 表示“能到位置 $i$”,而一个动作让位置编号都加 3,则新集合可写成 `reach << 3`。再与其他来源 OR 起来,就是许多状态同时转移。它要求每个状态的转移规则相同;若第 $i$ 个位置要加 `a[i]`,就不能直接用一条统一移位。
+
+```text
+# bits 是一个长度 N 的布尔集合,mask 只保留 0..N-1 位
+common = group_a AND group_b
+next_reach = ((reach << step_1) OR (reach << step_2)) AND mask
+how_many = popcount(common)
+```
+
+## 纸上模拟:把一行位串完整翻译三次
+
+仍用 `A=10110110`、`B=01101100`。先不使用程序,依次完成:
+
+1. 每列做 AND,写出 8 位结果;把结果译回集合。
+2. 每列做 XOR,说明下标 2、5 为什么消失或保留。
+3. 把 A 左移 2 位后与 `11111111` 相与;哪些原成员因越界消失?
+4. 若长度不是 8 而是 6,`~A` 后为什么还必须与 `00111111` 相与?
+
+<details><summary>展开:结果与边界账</summary>
+
+AND 为 `00100100`,集合 `{2,5}`;XOR 为 `11011010`,集合 `{1,3,4,6,7}`。A 左移 2 后(保留 8 位)为 `11011000`,原编号 7 越界消失。长度为 6 时,整数的 `~` 会连同“第 6 位以上不存在的成员”也翻成 1,必须用掩码裁回合法宇宙。
+
+</details>
+
+## 核心知识:边界位也是集合成员
+
+`~A` 在整数上会把无限高位也翻成 1;固定长度 `std::bitset<N>` 会自动限制范围,自己用 Python/整数模拟时必须 `& mask`。左移溢出的成员应离开集合,右移低位补 0。最常见错误不是 `&` 写成 `|`,而是“位编号从左还是从右数”在两段代码里悄悄换了口音。
+
+空间为 $O(N/w)$ 个机器字。若大小在编译时固定,C++ 的 `std::bitset<N>` 很方便;若大小运行时才知道,正式课会讨论 `vector<uint64_t>` 的分块处理。
+
+### 一点点 SIMD:它是表亲,不是同义词
+
+Bitset 的 AND 是把一个机器字里的**每一位**当成一个布尔对象;SIMD(Single Instruction, Multiple Data)则让一条指令同时处理多个固定宽度的“通道”,例如 8 个 `int32` 或多组 64 位字。两者都在利用硬件并行,但研究对象不同:
+
+| 工具 | 一次并行的对象 | 适合的表达 |
+|---|---|---|
+| Bitset | 一个字中的许多布尔位 | 集合交并、可达状态、掩码 |
+| SIMD | 寄存器中的多条数值通道 | 批量加减、比较、向量/矩阵小块计算 |
+
+在非常长的动态 bitset 中,底层循环有时能让编译器把多个 64 位字再用 SIMD 一批处理;但 C++ 标准不保证 `std::bitset` 一定自动向量化。先选对算法、估算 $N/w$、再用 profiler 证明确有瓶颈,最后才考虑编译器优化选项或 intrinsics。SIMD 是一把好电动螺丝刀,不是见到螺丝就必须掏出来。
+
+## 实现指南
+
+- 先在纸上约定:最低位是否对应下标 0;之后打印、移位和取位都用同一约定。
+- 需要 `count()` 时,确认它统计的是置位数,不是集合中元素值之和。
+- `~`、左移后记得和长度掩码相与;否则调试输出会长得像一只没有尽头的毛毛虫。
+- 估算 `N/w` 时别遗漏外层循环,也别把缓存、常数当成无限小。
+
+## 互动小实验室
+
+运行 [把64个开关装进一个格子.py](../../互动程序/03-Bitset/00-把64个开关装进一个格子.py)。它先用 8 位版本展示位号,再让你选择 `&`、`|`、`^` 或移位;最后把结果重新翻译成集合。
+
+先预测 `10110110 & 01101100` 的集合交集,再按回车看答案。8 位只是显微镜,真正的机器字会一次装下 64 位。
+
+## 扩展与联系
+
+Bitset 常出现在邻接集合交、可达集合传播、背包布尔可达性和字符串位置匹配中。它不能替代需要最小值、路径恢复或每个位置异构转移的 DP;这时“能否并行”要先经过数据依赖的同意。若数据不是布尔集合而是一排相同宽度的数值,再回头考虑 SIMD 会更自然。
+
+## 总结与自检
+
+1. 为什么 XOR 对应“出现次数的奇偶”,而不对应普通并集?
+2. 哪个条件使 `reach << k` 成为合法的一批转移?
+3. 长度固定为 $N$ 时,`~bits` 后为什么要裁边?
+
+<details><summary>自检答案</summary>
+
+两个 1 XOR 后变 0;所有置位的位置必须采用同一平移规则;整数补码没有“只到第 N 位”的天然边界。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:把 8 个位置写成一串二进制,互相翻译集合与位串。
+- 第 2 天:手算四种基本位运算和一次移位。
+- 第 3 天:用 $N/w$ 估算三种不同规模的整集合操作。
+- 第 4 天:运行互动程序,故意测试 `~` 与越界移位。
+- 第 5 天:带着“同形布尔转移”的识别信号进入[正式课](01-字级并行集合模型与动态匹配.md)。

+ 2 - 0
课程/03-Bitset/01-字级并行集合模型与动态匹配.md

@@ -1,5 +1,7 @@
 # Bitset:把一排布尔状态压进机器字
 
+> 若位运算仍像一串符号,先完成 [课前零食:把 64 个开关装进一个格子](00-课前零食-把64个开关装进一个格子.md);其中也区分了 Bitset 与 SIMD 的关系。
+
 ## 本节目标
 
 学完后,你应能:

+ 228 - 0
课程/04-KMP与Manacher/00-课前零食-不从头再看的字符串.md

@@ -0,0 +1,228 @@
+# 课前零食:不从头再看的字符串
+
+> 字符串算法最容易被误会成“记数组模板”。这节先做两件很人类的事:匹配失败时利用已经看过的文字;找回文时利用已经量过的对称。以后数组名忘了,也能把路重新走出来。
+
+## 本节目标
+
+学完后,你能:
+
+- 解释朴素匹配为什么会重复比较同一批字符;
+- 用 border(既是前缀又是后缀的真子串)推出 KMP 的失配跳转;
+- 从逐中心扩展回文,理解 Manacher 为什么能借用镜像半径;
+- 分清“最长 border”“匹配长度”和“回文半径”三种相似但不同的量。
+
+## 前置知识与课前自测
+
+前置:字符串下标、前缀/后缀、循环。尝试:
+
+1. 模式串 `AAAA` 在文本 `AAAAA` 中出现几次?
+2. `ABABA` 的最长真前后缀相同部分是什么,长度多少?
+3. 以一个字符为中心扩展回文时,左右边界各移动几格?
+
+<details><summary>答案</summary>
+
+1. 两次,位置 0 和 1;匹配后清零会漏重叠。2. `ABA`,长度 3。3. 每次同时向外各移动一格,直到越界或两侧字符不同。
+
+</details>
+
+## 互动规则:字符要排队,已经比较过的证据要留档
+
+准备两条纸带:文本 `ABABABACABA` 与模式 `ABABAC`。每次比较都在文本和模式下方画一条竖线,并记录 `文本位置 i`、`已连续匹配长度 j`、`下一次从模式哪一格继续`。遇到回文时,改画一条字符串纸带,记录 `中心`、`已确认左右边界`、`还能免费借到多少对称信息`。
+
+本节的互动约束是:**每次跳过比较之前,都要写出一份已经成立的证据。**若说不出证据,就不能跳。
+
+## 从零搭框架:先找出哪些字符被白看了
+
+### 第 0 步:像尺子一样滑动模式串
+
+把模式 `ABABAC` 从文本第 0 位对齐,逐字符比较。前五位 `ABABA` 相同,第六位文本是 `B`、模式是 `C`,失配。朴素法会把模式右移一格,再从模式第 0 位比起。
+
+> 停顿 0:失配前已经确认的 `ABABA` 中,列出所有既是前缀又是后缀的非空字符串。不要只写最长一个。
+
+<details><summary>展开:失败时手里还握着什么</summary>
+
+`ABABA` 的 border 有 `A`、`ABA`。这表示刚匹配段的尾部 `ABA` 同时又能充当模式开头;把状态清成 0 会把这份已知相等白白扔掉。
+
+</details>
+
+### 第 1 步:把“已匹配多少”当成唯一状态
+
+先不写数组。令 `j` 表示“文本当前位置之前,连续匹配了模式前缀的多少字符”。请在这段扫描中填表:
+
+| 读到的文本字符 | 读前 `j` | 比较的模式字符 | 成功/失配 | 读后 `j` |
+|---|---:|---|---|---:|
+| A | 0 |  |  |  |
+| B | 1 |  |  |  |
+| A | 2 |  |  |  |
+| B(第一次失配处) | 5 |  |  |  |
+
+> 停顿 1:失配后如果保留长度 3,下一次为什么应把当前文本字符再与模式第 3 位比较,而不是直接跳到下一个文本字符?
+
+<details><summary>展开:KMP 从不漏掉当前字符</summary>
+
+已匹配长度从 5 跳到 `pi[4]=3`,但当前文本的 `B` 还没有和模式第 3 位比较;它可能正好接在 `ABA` 后成为 `ABAB`。KMP 回退的是模式状态 `j`,不是文本指针 `i`,所以不会漏掉重叠匹配。
+
+</details>
+
+### 第 2 步:亲手发明 `pi`,不要先背下标
+
+对模式 `A B A B A C`,每写到一个新位置,就问:“当前整个前缀的**最长真前缀**,也是它的后缀吗?”先写字符串,再写长度:
+
+| 前缀 | 所有可用 border | 最长长度 `pi` |
+|---|---|---:|
+| `A` |  |  |
+| `AB` |  |  |
+| `ABA` |  |  |
+| `ABAB` |  |  |
+| `ABABA` |  |  |
+| `ABABAC` |  |  |
+
+> 停顿 2:在给 `ABABAC` 的最后一个 C 求 `pi` 时,为什么先从长度 3 尝试,而不是从长度 4、2、1 乱猜?
+
+<details><summary>展开:border 链就是候选清单</summary>
+
+前五项的 `pi` 是 `0,0,1,2,3`。最后 C 无法接在长度 3 的 border `ABA` 后,于是尝试该 border 的最长 border(长度 1),再失败才归 0。任何更长候选已被失配排除,任何可能更短候选一定在 border 链上;这就是 `j=pi[j-1]` 的来源。
+
+</details>
+
+### 第 3 步:回文也先做暴力,再数重复比较
+
+写 `abacaba`,对每个位置当中心,左右同时扩。请先填写中心 3(字符 c)能扩到多远,再填写中心 2(字符 a)能扩到多远。然后把中心 3 已经确认的回文区间画成 `[0,6]`。
+
+> 停顿 3:中心 4 关于中心 3 的镜像是谁?镜像中心已有半径为什么能先给中心 4 一段免费下界?
+
+<details><summary>展开:镜像不是猜测,是已知对称的复制</summary>
+
+中心 4 的镜像是 2。区间 `[0,6]` 已确认回文,镜像中心 2 向左右相等的字符对,映到中心 4 也必相等;只有可能越过右边界 6 的部分需要重新比较。于是状态不是“每个中心独立扩多远”,而是“当前最右已验证回文边界在哪里”。
+
+</details>
+
+### 第 4 步:写出两份框架账本
+
+| 算法 | 当前状态 | 可复用的证据 | 何时必须做新比较 |
+|---|---|---|---|
+| KMP |  |  |  |
+| Manacher |  |  |  |
+
+<details><summary>展开:两种不同的复用</summary>
+
+KMP 的状态是已匹配长度 `j`,证据是前缀与后缀相等,只有沿 border 链仍不匹配时才比较;Manacher 的状态包含中心半径与全局 `[L,R]`,证据是已确认回文对称,只有尝试扩出 $R$ 时才比较新字符。它们都不是“不比较”,而是拒绝重复比较已经有证的部分。
+
+</details>
+
+## 问题动机:已经比较过的字,不该再加班
+
+朴素匹配把模式串放在文本每个起点:一旦第 $j$ 位失配,就把模式整体右移一格,再从第 0 位重新比较。若模式是 `ABABAC`,已经匹配了 `ABABA` 后失败,前面的 `ABA` 其实仍可能是下一次匹配的开头;全部扔掉等于让字符白读一遍。
+
+![朴素匹配重复比较的重叠部分](../插图/04-KMP与Manacher/00-字符串-01-KMP重叠.svg)
+
+回文也有同一种浪费:逐个中心向两边扩展时,已知一个长回文内部的镜像位置,仍会重新对比很多字符。我们需要给“已经确认的对称”开收据。
+
+## 步步式引导
+
+### 1. KMP:失败后能保留多少匹配长度
+
+设已经匹配模式前缀 `pattern[0..j-1]`。如果下一个字符失配,仍可能成为新开头的部分必须同时:
+
+- 是刚才匹配段的后缀;
+- 又是模式串的前缀。
+
+这正是 border。令 `pi[i]` 是 `pattern[0..i]` 的最长 border 长度。失配时不试 `j-1,j-2,...`,而把候选直接跳到 `pi[j-1]`;若仍失配,再沿 border 链继续跳。
+
+```text
+j = 0
+for i in 1..len(pattern)-1:
+    while j > 0 and pattern[i] != pattern[j]:
+        j = pi[j - 1]          # 更短、但仍可能的 border
+    if pattern[i] == pattern[j]:
+        j += 1
+    pi[i] = j
+
+# 扫描文本时用同一段 while;j == len(pattern) 时记录一次匹配
+```
+
+每次 `j` 回退都严格变小,而每个文本字符只会推动它前进有限次,因此匹配是 $O(n+m)$。KMP 不是“不回退”,而是只回退到有证据的位置。
+
+### 2. Manacher:镜子先送你一个下界
+
+先只看奇长度回文。`radius[i]` 表示以 `i` 为中心、向一侧能扩多远(包含中心,最小为 1)。维护目前最靠右的已知回文区间 $[L,R]$。当新中心 $i\le R$ 时,它关于旧中心的镜像 `mirror=L+R-i` 已经有半径;`radius[i]` 至少能取 `min(radius[mirror], R-i+1)`,然后只检查可能越过 $R$ 的新字符。
+
+![一个已知回文把信息借给镜像中心](../插图/04-KMP与Manacher/00-字符串-02-回文镜像.svg)
+
+```text
+L = 0; R = -1
+for center in 0..n-1:
+    radius[center] = 1 if center > R else min(radius[L + R - center], R - center + 1)
+    while characters just outside this radius are equal:
+        radius[center] += 1
+    if center + radius[center] - 1 > R:
+        L = center - radius[center] + 1
+        R = center + radius[center] - 1
+```
+
+## 纸上模拟:先写跳转理由,再允许状态变化
+
+对 KMP,按下表从 `ABABA` 的失配开始填:
+
+| 当前 `j` | 已匹配后缀 | 它是否也是模式前缀 | 下一候选 `j` | 当前文本字符是否重读 |
+|---:|---|---|---:|---|
+| 5 | `ABABA` | — |  |  |
+| 3 | `ABA` | — |  |  |
+| 1 | `A` | — |  |  |
+
+对 Manacher,给 `abacaba` 写出奇半径数组,并圈出哪几个中心位于已有最右区间内。请特别写出中心 5 为什么一开始可借中心 1 的半径,却仍可能需要截断。
+
+<details><summary>展开:两个“先借后验”的完整答案</summary>
+
+KMP 从 5 跳到 3,再在当前文本字符上继续比较;若仍失配才跳到 1、0。Manacher 的奇半径为 `[1,2,1,4,1,2,1]`;中心 5 镜像为 1,但可免费使用的半径还受当前右边界限制,实际初值为 `min(radius[1], R-5+1)`。这份 `min` 正是在说“镜像证据不能越过我们尚未验证的边界”。
+
+</details>
+
+偶长度回文只需把中心放在两个字符间、调整边界;正式课会统一下标约定。Manacher 快的理由同样不是跳过真相,而是每次额外比较若成功都会把全局右边界推得更远,总推进不超过 $n$。
+
+## 核心知识:相同的“复用”,不同的证据
+
+KMP 复用的是“前缀和后缀已经相同”的事实;Manacher 复用的是“一个区间已经左右对称”的事实。两者都维护可证明的已知区域,而不是凭感觉跳过字符。
+
+| 名称 | 它描述什么 | 典型下标错误 |
+|---|---|---|
+| `pi[i]` | 前缀 `0..i` 的最长真 border 长度 | 误把整个串算成 border |
+| `j` | 当前已匹配模式前缀长度 | 失配后访问 `pi[j]` 而非 `pi[j-1]` |
+| `radius[i]` | 中心向外覆盖的半径 | 把“含中心”与“不含中心”混用 |
+
+## 实现指南
+
+- 先决定字符串是 0-based 还是 1-based;KMP 的 `j=0` 时不能访问 `pi[-1]`。
+- 匹配成功后别直接清零,应令 `j=pi[j-1]` 才能保留重叠匹配。
+- Manacher 的奇、偶半径建议分开数组写,直到你能稳定画出各自覆盖区间。
+- Unicode 文本在竞赛里通常不是问题;但若实际按字节处理,别把“字符”与“字节”想成同一个单位。
+
+## 互动小实验室
+
+运行 [不从头再看的字符串.py](../../互动程序/04-KMP与Manacher/00-不从头再看的字符串.py)。选择 `k` 可以看前缀函数构建、失配回跳和朴素比较次数;选择 `m` 可以看每个中心如何借镜像半径再向外扩。
+
+先故意把模式换成 `AAAA`,观察“成功后保留 border”为什么会找到重叠出现。字符没有忘性,只有朴素循环比较健忘。
+
+## 扩展与联系
+
+Border 链还能找周期、统计前缀出现次数;回文半径还能统计回文子串和回答中心覆盖问题。两者都只适用于比较关系稳定的线性序列;若字符会被中途修改,通常需要不同的数据结构。
+
+## 总结与自检
+
+1. KMP 失配时为什么 `pi[j-1]` 是合理的下一候选?
+2. Manacher 在 $i\le R$ 时,为什么不能总把镜像半径原封不动抄来?
+3. 两个算法各自的全局“不会重复劳动”证据是什么?
+
+<details><summary>自检答案</summary>
+
+它是已匹配段最长仍可作新前缀的后缀;镜像回文可能越过当前已确认右界,越界处仍需比较;KMP 的候选沿 border 链减少,Manacher 的额外成功扩展会推进 $R$。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:列出 `ABABA` 的所有 border。
+- 第 2 天:手动对 `ABABAC` 做一次 `pi`。
+- 第 3 天:画一个回文,给每个中心量奇半径。
+- 第 4 天:运行互动程序,比较朴素匹配与 KMP 的字符比较。
+- 第 5 天:进入[KMP 正式课](01-KMP失配边界周期与差分建模.md),之后再读[Manacher 正式课](02-Manacher回文半径与区间计数.md)。

+ 2 - 0
课程/04-KMP与Manacher/01-KMP失配边界周期与差分建模.md

@@ -1,5 +1,7 @@
 # KMP:失配跳转、Border、周期与差分建模
 
+> 建议先完成 [课前零食:不从头再看的字符串](00-课前零食-不从头再看的字符串.md),亲手跟一次 border 回跳后再读本节。
+
 ## 本节目标
 
 学完后,你应能从朴素匹配推导前缀函数(prefix function),解释失配为何跳到 `pi[j-1]`,并用 border 链解决重叠匹配、周期、前后缀统计及平移不变序列匹配。

+ 215 - 0
课程/05-最短路与类最短路/00-课前零食-从乱走到松弛.md

@@ -0,0 +1,215 @@
+# 课前零食:从乱走到松弛
+
+> “最短路”听起来像地图题,其实它是在练一件很通用的事:不断拿到一个更好的到达方案,并且知道什么时候可以不再怀疑它。这节先把这件事练顺手。
+
+## 本节目标
+
+学完后,你能:
+
+- 把“到达某状态的最小代价”翻译成点、边和边权;
+- 从按层 BFS 推出距离的含义,再发现权值不同时为什么需要松弛;
+- 区分 BFS、0-1 BFS 和 Dijkstra 各自能保证什么;
+- 给出负边让 Dijkstra 失效的原因,而不是只背“不能有负边”。
+
+## 前置知识与课前自测
+
+前置:图、队列、优先队列、整数加法。
+
+1. 所有道路长度都是 1 时,BFS 第一次到达一个点为什么就是最少边数?
+2. 若有一条长度 1 的直达路,以及两条长度 0 的绕路,边数最少的路线一定最短吗?
+3. `dist[v]` 初始设为无穷大,后来变小,这个数是答案还是“目前已知的上界”?
+
+<details><summary>答案</summary>
+
+1. 队列按走过边数逐层扩展,前面层数更小。2. 不一定,总代价最小才是目标。3. 是上界:它对应一条真实已知路线,但以后可能发现更短路线。
+
+</details>
+
+## 互动规则:把每个“到达价格”写成可撤销的报价
+
+画预设有向图,并在每个点旁留一个格子写 `dist`。初始只有起点 S 写 0,其余写 `∞`。每发现一条路线,都要写成“从谁来 + 边权 = 新报价”;报价变小才允许覆盖旧值。纸上不要写“最短”二字,直到你能证明某点不可能再更便宜。
+
+本节反复使用四栏:`已确定的点`、`待处理的候选`、`每点当前报价 dist`、`一条边松弛后谁被重新安排`。这比一开始记 BFS/Dijkstra 名字更重要。
+
+## 从零搭框架:先让路线枚举失败,再保留每点最便宜的代表
+
+### 第 0 步:把路径写成动作序列
+
+从 S 到 T 有时有多条走法,甚至可以绕环无限走。先画无权图 `S-A-T` 与 `S-B-T`。列出所有长度至多 2 的路线,并把每个点第一次被碰到时的“走了几条边”写上。
+
+> 停顿 0:若所有边都花 1,为什么队列里先放入的点一定不比后来放入的点远?请用“层”而不是算法名解释。
+
+<details><summary>展开:BFS 的第一份不变量</summary>
+
+从 S 出发的点距离 0;先入队的是距离 1;处理完它们才会产生距离 2。FIFO 队列维护按层扩张,所以第一次到达一个点已经用了最少边数。这里的状态是“最少边数”,不是什么通用最小代价。
+
+</details>
+
+### 第 1 步:给边贴价格,观察“先到”失去资格
+
+画 `S→A(1), S→B(0), B→A(0)`。请按普通 BFS 的邻接顺序先处理 `S→A` 再处理 `S→B`,写出 A 第一次的报价;随后走 B 的边,写出新报价。
+
+> 停顿 1:A 已经“访问过”了,为什么仍必须允许它被改成更小的 0?如果拒绝更新,错的是哪条路线?
+
+<details><summary>展开:从 visited 变成 dist 上界</summary>
+
+A 首次得到 1,后来通过 `S→B→A` 得到 0。权不同时,“第一次访问”不再是证书;`dist[A]` 只能表示目前已知的一条真实路线价格,即上界。新路线更便宜时要松弛并重新安排 A。
+
+</details>
+
+### 第 2 步:先手算一句松弛,再把它当作唯一更新入口
+
+对每条边 $u\to v$、权 $w$,把下句抄在纸上,给 `S→B(0)` 与 `B→A(0)` 分别代数:
+
+$$
+\text{若 }dist[u]+w<dist[v]\text{,就用 }dist[u]+w\text{ 覆盖 }dist[v].
+$$
+
+请写出每次覆盖前后 A、B 的值。然后回答:这句话为什么不会把 `dist[v]` 压到真答案**以下**?
+
+<details><summary>展开:每个报价都要有路线发票</summary>
+
+`dist[u]` 若有限,就对应一条真实 S 到 u 的路线;再接边 $(u,v)$ 仍是一条真实 S 到 v 的路线。因此候选只可能是真答案或更大的上界,不会凭空小于真答案。松弛的正确性起点是“报价有发票”,不是堆的写法。
+
+</details>
+
+### 第 3 步:同一句松弛,为什么要三种工作队列
+
+对下面三类边权,先在纸上给新报价贴标签:应当优先处理、稍后处理、还是可定案?
+
+| 边权 | 新报价相对当前层 | 合理的容器 | 你要维护的顺序 |
+|---|---|---|---|
+| 全为 1 |  |  |  |
+| 只有 0/1 |  |  |  |
+| 非负任意整数 |  |  |  |
+
+> 停顿 3:0 边的新点为什么应塞到双端队列前端?Dijkstra 为什么不允许负边?先用一张具体反例说明。
+
+<details><summary>展开:容器是在维护“谁可能最便宜”</summary>
+
+全 1 用 FIFO 层序;0 边不增加价格,应放队首,1 边放队尾;非负任意权用最小堆取当前最小报价。负边能让一个看似较远的未处理点绕回来压低已弹出的点,破坏“当前最小可定案”的论证。
+
+</details>
+
+### 第 4 步:状态可能不止地点
+
+想象到同一地点 P 时,开关是开或关会决定可走的路。请写出:`P,开` 与 `P,关` 能否合并成一个 `dist[P]`?若不能,图中的一个点究竟代表什么?
+
+<details><summary>展开:最短路先建状态图,再选算法</summary>
+
+不能合并,因为后续动作集合不同。一个图点代表“足以决定未来合法动作与代价的状态”,可写成 `(位置,模式)`。正式课的难点常在这里;BFS/Dijkstra 只是状态图建好后的调度员。
+
+</details>
+
+## 问题动机:枚举路线会让环偷偷繁殖
+
+从起点枚举所有走法,遇到环甚至会有无限多条路线;即使只枚举简单路径,数量也可能指数级。真正应该保存的不是“所有走法”,而是每个状态目前最便宜的到达价格。若一条新边让价格更低,就更新它;这一步叫**松弛**(relaxation)。
+
+![边数少不等于权和小](../插图/05-最短路与类最短路/00-最短路-01-零权反例.svg)
+
+上图中从 $S$ 直接到 $A$ 要 1,但 $S\to B\to A$ 的总权是 0。普通 BFS 若把“先到”误当成“永远最好”,就会做出错误承诺。
+
+## 步步式引导
+
+### 1. 全部边权为 1:层数就是代价
+
+若每次动作代价相同,距离等于经过边数。BFS 的队列让所有距离为 $d$ 的状态先于距离为 $d+1$ 的状态出队;第一次访问就可以定案。
+
+```text
+dist[start] = 0
+queue.push(start)
+while queue not empty:
+    u = queue.pop_front()
+    for v in neighbors[u]:
+        if dist[v] is INF:
+            dist[v] = dist[u] + 1
+            queue.push_back(v)
+```
+
+### 2. 权值不同:先到不再有法律效力
+
+对边 $u\to v$、权 $w$,若 `dist[u] + w < dist[v]`,我们发现一条更短的真实路线,便更新:
+
+```text
+if dist[u] + w < dist[v]:
+    dist[v] = dist[u] + w
+    put v back into the work structure
+```
+
+![松弛把已知上界逐步压低](../插图/05-最短路与类最短路/00-最短路-02-松弛.svg)
+
+不同算法的差别主要是“下一个处理谁”,而非这句松弛本身。
+
+| 边权条件 | 适合的取出顺序 | 核心理由 |
+|---|---|---|
+| 都为 1 | FIFO 队列 | 层数单调 |
+| 只有 0/1 | 双端队列 | 0 边放前面,1 边放后面 |
+| 都非负 | 最小堆 | 当前最小距离不会被未来绕路压低 |
+
+### 3. Dijkstra 为什么敢“定案”
+
+从堆中取出未定案且 `dist` 最小的 $u$。假设存在一条尚未发现的更短路线到 $u$,它从已定案区域第一次跨出的边会到达某个未定案点 $x$。由于边权非负,那条路线到 $x$ 的长度不大于到 $u$ 的长度,$x$ 本应更早被取出,矛盾。
+
+负边会拆掉这个论证:一个先看起来较远的点能借负边回来,把已经定案的距离再压低。不是堆不够努力,是“非负”这张保证书被撕掉了。
+
+## 纸上模拟:把报价变化写成时间线
+
+对边 `S→A(1), S→B(0), B→A(0), A→T(1), B→T(1)`,从 S 开始。先预测,再填写:
+
+| 取出/处理谁 | 当前 `dist` | 尝试的边 | 是否松弛 | 新工作队列或堆 |
+|---|---|---|---|---|
+| S |  | `S→A(1)` |  |  |
+| S |  | `S→B(0)` |  |  |
+| B |  | `B→A(0)` |  |  |
+| B |  | `B→T(1)` |  |  |
+
+然后把 A 以距离 1 的旧条目也画进最小堆;它晚些弹出时为何必须被跳过?
+
+<details><summary>展开:旧条目不是 bug</summary>
+
+S 后有 `dist[A]=1,dist[B]=0`;处理 B 后 A 变 0、T 变 1。堆中旧 `(1,A)` 仍在,但当前 `dist[A]=0`,它代表一条已被更优路线淘汰的报价,跳过即可。允许旧条目避免了昂贵的堆内删除,同时仍只会按真实最小报价扩张。
+
+</details>
+
+## 核心知识:距离是可证的上界,定案是额外结论
+
+任何时刻 `dist[v]` 要么是无穷大,要么对应一条从起点到 $v$ 的实际路线,因此永远不会小于真答案。松弛只会让上界变紧。BFS、0-1 BFS、Dijkstra 分别在各自边权条件下安排处理顺序,保证某个点被正确时机取出后,其上界已经等于真最短路。
+
+0-1 BFS 中,走 0 边的新状态应放队首,走 1 边放队尾;这模拟了按距离递增处理,不必每次使用堆。三者的图上时间通常为 $O(V+E)$、$O(V+E)$、$O((V+E)\log V)$。
+
+## 实现指南
+
+- `INF` 取足够大的 `long long`,常用 `max/4`,避免 `INF+w` 溢出。
+- Dijkstra 的堆中允许旧条目;弹出时若条目距离不等于当前 `dist[u]`,跳过它。
+- 0-1 BFS 只允许权 0 或 1;不要看到“小权值”就强行塞进双端队列。
+- 状态不一定是原图顶点。若“是否开过开关”影响下一步,就应把 `(位置, 开关状态)` 看作不同点。
+
+## 互动小实验室
+
+运行 [从乱走到松弛.py](../../互动程序/05-最短路与类最短路/00-从乱走到松弛.py)。可选择 BFS、0-1 BFS 或 Dijkstra;预设图会逐条打印松弛,以及状态进入队列、队首或堆的原因。
+
+建议先选 0-1 BFS,预测 $A$ 会先以距离 1 被发现、后以距离 0 被改写;再选 Dijkstra,观察旧堆条目为何被跳过。
+
+## 扩展与联系
+
+最短路的“状态扩点”会通向正式课中的免费切换、时间周期和差分约束。若存在可达负环,最短值可以无限下降,此时问题不再是“找一个有限最短路”;若图是 DAG,按拓扑序松弛能处理某些负边。
+
+## 总结与自检
+
+1. 一次松弛为什么不会把距离错误地压到真答案以下?
+2. 0-1 BFS 中权 0 的新点为什么放队首?
+3. Dijkstra 的“最小未定案点可定案”具体依赖哪条条件?
+
+<details><summary>自检答案</summary>
+
+它由一条真实路线拼成;它与当前层同价,应先于加 1 的点处理;所有边权非负。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:在单位边小图上手做 BFS 层号。
+- 第 2 天:画出零权反例,解释普通 BFS 的失败。
+- 第 3 天:给每条边写一次可能的松弛不等式。
+- 第 4 天:运行互动程序,比较 0-1 BFS 和 Dijkstra 的工作队列。
+- 第 5 天:进入[最短路模型选择正式课](01-建图松弛与01状态图.md)。

+ 2 - 0
课程/05-最短路与类最短路/01-建图松弛与01状态图.md

@@ -1,5 +1,7 @@
 # 最短路模型选择:建图、松弛与 0-1 状态图
 
+> 若“松弛”和“何时定案”还陌生,先完成 [课前零食:从乱走到松弛](00-课前零食-从乱走到松弛.md)。
+
 ## 本节目标
 
 学完后,你应能把“达到状态的最小代价”建成图,按边权选择 BFS、0-1 BFS 或 Dijkstra,解释松弛不变量,并通过扩展状态处理免费切换等全局模式。

+ 210 - 0
课程/06-最小与最大生成树/00-课前零食-只修够用的路.md

@@ -0,0 +1,210 @@
+# 课前零食:只修够用的路
+
+> 城市想全部连通,预算却嫌每条路都贵。今天的原则很克制:能让两个已经连通的地方再绕一圈的路,先别修;能把两块大陆第一次连起来的便宜路,很值得认真看一眼。
+
+## 本节目标
+
+学完后,你能:
+
+- 说出生成树(spanning tree)为何有 $n-1$ 条边、为何不能有环;
+- 从枚举所有连通边集的失败,推出按权排序、逐步合并连通块的 Kruskal 思路;
+- 用“切开当前连通块”的交换想法理解割性质;
+- 手动模拟并查集(Disjoint Set Union, DSU)的 `find/union`。
+
+## 前置知识与课前自测
+
+前置:无向带权图、排序、连通性。
+
+1. 一棵有 $n$ 个点的树为什么正好有 $n-1$ 条边?
+2. 已经有一条从 $A$ 到 $B$ 的路时,再加入直接边 $A-B$ 会产生什么结构?
+3. 若图不连通,是否存在一棵覆盖全部点的生成树?
+
+<details><summary>答案</summary>
+
+1. 从一个点开始,每加一条不成环的边最多新接入一个点,要接入其余 $n-1$ 个点恰需这么多边。2. 一个环。3. 不存在;此时只能谈每个连通块各自的最小生成森林。
+
+</details>
+
+## 互动规则:每条路先问“它连起了什么”,再问“它多贵”
+
+请把边写成卡片 `(端点,价格)`,把每个当前连通块圈成一个泡泡。每翻一张边卡,都在纸上回答四件事:`它两端在哪两个泡泡`、`加它会不会闭环`、`若加入泡泡如何合并`、`为什么可以暂时相信这条边`。不要一上来就把边按算法名分类。
+
+今天的目标是亲自搭出“森林 → 连通块 → 割 → 安全边”的框架;并查集只是这份账本的高效记事员。
+
+## 从零搭框架:先删掉显然多余的路
+
+### 第 0 步:从一张小图枚举“够用的连通方案”
+
+画四个点和五条无向边:`A-B(1), A-C(2), B-C(3), C-D(2), B-D(4)`。先不要排序;任选几组边,检查它们是否让所有点连通。每组都数一数边数和是否含环。
+
+> 停顿 0:若一个连通方案有 4 个点却选了 4 条边,它一定含环吗?删掉环上一条边后,连通性为什么不会消失?
+
+<details><summary>展开:生成树先来自“去掉冗余”</summary>
+
+4 点连通无环只需 3 条边;连通图多出一条边就形成环。环上任意一条边的两端还可沿环另一侧相连,所以至少有一条路不是维持连通的必需品。最小生成树不可能为纯绕圈的冗余边付钱。
+
+</details>
+
+### 第 1 步:让“当前已经连好”成为状态
+
+从四个单点泡泡 `{A},{B},{C},{D}` 开始,按价格查看边卡。先看 `A-B(1)`:它连接两个不同泡泡;再看 `A-C(2)`;随后看 `C-D(2)`;最后再看 `B-C(3)`。
+
+请填:
+
+| 正在看的边 | 两端当前泡泡 | 加入/跳过 | 加入后泡泡 | 理由 |
+|---|---|---|---|---|
+| A-B(1) |  |  |  |  |
+| A-C(2) |  |  |  |  |
+| C-D(2) |  |  |  |  |
+| B-C(3) |  |  |  |  |
+
+> 停顿 1:到 `B-C(3)` 时,为什么不需要真的在图上 DFS 寻路,也能知道它会成环?
+
+<details><summary>展开:森林状态足够回答环问题</summary>
+
+前三条边后四点已经在同一泡泡;B、C 属于同一连通块意味着当前森林已有 B 到 C 的唯一树路径,加入 `B-C` 会闭合环。我们只需维护“代表元是否相同”,这正是 DSU 的问题。
+
+</details>
+
+### 第 2 步:为什么可以先看便宜边,而不是贪心碰运气
+
+把当前泡泡 $S={A,B}$ 用一条虚线和外界隔开。跨线边有 `A-C(2), B-C(3), B-D(4)`。请先选择最轻跨线边;再假设某棵最优树没有它,沿那棵树从 S 走向外部时,找第一条跨虚线的边。
+
+> 停顿 2:把最轻边加进那棵最优树后为什么出现环?环上为什么一定有另一条跨虚线的边可以删?
+
+<details><summary>展开:割性质是可见的交换</summary>
+
+加新边把两端接通,旧树中原本两端已有路径,故出现环;从 S 出去又回到 S 的环必须跨割至少两次,所以有另一条跨割边。新边不重于它,替换后仍不更贵。于是至少存在一棵最优树包含当前最轻跨割边;这不是“便宜看着顺眼”,而是可交换的证据。
+
+</details>
+
+### 第 3 步:先手写并查集的最小版本
+
+给 `A,B,C,D` 各写一个 `parent`,初始都指向自己。依序 `union(A,B)`,`union(A,C)`,`union(C,D)`,每步跟父亲指针到根。然后再做 `find(B),find(C)`。
+
+> 停顿 3:`find` 的返回值到底代表一条路径,还是一个集合的名字?路径压缩改的是正确性还是让下次更快?
+
+<details><summary>展开:DSU 不知道树形路线,只知道同一泡泡</summary>
+
+根是集合代表,不是原图中的“父边”。路径压缩把查找路上的点直接连到根,不改变哪些点在同一集合,只减少未来追父亲的长度。按大小合并与路径压缩让大量查询近似常数摊还。
+
+</details>
+
+### 第 4 步:把框架写成一句可执行的话
+
+请补全:`状态是 ______;候选按 ______ 顺序来;若两端 ______ 就选;选后 ______;停止条件是 ______。`
+
+<details><summary>展开:Kruskal 的真正骨架</summary>
+
+状态是当前森林/连通块;候选按权从小到大;两端在不同集合就选;选后合并集合;选够 $n-1$ 条边或候选耗尽。排序解决“先看谁”,DSU 解决“它是否闭环”,割性质解决“为什么可以选”。
+
+</details>
+
+## 问题动机:把所有选路方案列出来不现实
+
+一张图有 $m$ 条边,枚举“选或不选”已有 $2^m$ 种子集;还要逐份检查是否连通、是否无环。我们需要一种不回头的大原则:每次只看一条当前最便宜的边,若它连接两个不同连通块,就加入;若两端早已在同一块,加入只会造环,跳过。
+
+![Kruskal 按权从小到大选边](../插图/06-最小与最大生成树/00-MST-01-Kruskal.svg)
+
+这就是 Kruskal。它不像是在“猜整棵树”,而是在慢慢把很多孤岛焊成一个整体。
+
+## 步步式引导
+
+### 1. 为什么“环”是可疑对象
+
+若一套连通方案含一个环,删掉环上任意一条边,环的另外一侧仍让两端连通;所以至少有一条边是多余的。最优方案当然不会为纯粹的绕圈付钱,因而最小生成树一定无环。
+
+反过来,只要当前森林的两端属于不同连通块,加入一条边不会形成环,并恰好把两个块并成一个。这个判定不需要真的画完整路径;DSU 维护每个点所属的代表元即可。
+
+```text
+sort edges by weight ascending
+for (u, v, w) in edges:
+    if find(u) != find(v):
+        choose (u, v, w)
+        union(u, v)
+    else:
+        skip it             # 它会在当前森林中闭合一个环
+stop after choosing n - 1 edges
+```
+
+### 2. 最便宜的跨块边为什么安全
+
+把某个当前连通块 $S$ 和外面切开。Kruskal 正在看的边 $e$ 是尚未考虑边中最轻、又跨过这个割的一条。取任意一棵最小生成树:若它已有 $e$,很好;若没有,加入 $e$ 会出现一个环,环上必有另一条也跨这个割的边 $f$。因为 $e$ 不比 $f$ 重,删 $f$ 换 $e$ 不会让总权变大。
+
+![割上的便宜边可替换较贵边](../插图/06-最小与最大生成树/00-MST-02-割性质.svg)
+
+这叫割性质。注意它说“至少存在一棵 MST 可选这条最轻边”;同权时树的形状可能不唯一,但最小总权不受影响。
+
+## 核心知识:并查集只回答“是不是同一块”
+
+DSU 的每个集合有一个根。`find(x)` 沿父亲指针找到根,并把路上节点直接挂到根(路径压缩);`union(a,b)` 把较小的树挂到较大的树(按大小/秩合并)。它不保存具体路径,专职快速回答“两个点目前是否已经连通”。
+
+```text
+find(x):
+    if parent[x] == x: return x
+    parent[x] = find(parent[x])
+    return parent[x]
+
+union(a, b):
+    ra, rb = find(a), find(b)
+    if ra == rb: return false
+    attach smaller root below larger root
+    return true
+```
+
+## 纸上模拟:把一条边从候选变成证据
+
+对边表 `A-B(1),D-E(1),A-C(2),C-D(2),B-C(3),B-D(4),C-E(5)`,画五个单点泡泡,按权执行:
+
+| 边 | `find(u),find(v)` | 选/跳 | 当前已选边 | 分量 |
+|---|---|---|---|---|
+| A-B(1) |  |  |  |  |
+| D-E(1) |  |  |  |  |
+| A-C(2) |  |  |  |  |
+| C-D(2) |  |  |  |  |
+| B-C(3) |  |  |  |  |
+
+<details><summary>展开:先选够,再拒绝剩余环边</summary>
+
+前四条分别把 `{A,B}`、`{D,E}`、`{A,B,C}`、`{A,B,C,D,E}` 合并,已选 4 条边,正好是 5 点树所需数目。`B-C` 的两个 `find` 已相同,跳过。请注意“已连通”是跳过理由;“它价格更贵”本身不是逻辑理由。
+
+</details>
+
+排序是 $O(m\log m)$,DSU 的总工作近似 $O(m\alpha(n))$,其中 $\alpha$ 小得像竞赛时限里的一粒芝麻;所以整体由排序主导。
+
+## 实现指南
+
+- 边权、答案和排序对象分开保存;答案可能超出 `int`。
+- 处理同权边时,基础求总权可以任意顺序;若题目问“某边是否必在每棵 MST”,要留到正式课的同权批处理。
+- 选边数不足 $n-1$ 说明原图不连通;不要输出一棵假装覆盖全图的树。
+- 最大生成树只需把排序改为从大到小,安全性证明完全对称。
+
+## 互动小实验室
+
+运行 [只修够用的路.py](../../互动程序/06-最小与最大生成树/00-只修够用的路.py)。直接回车会使用 5 个城市的预设图。每条边出现时,程序会打印两个端点所在分量、是否选中、若跳过则展示它闭合的环意图。
+
+可以输入自己的 `A-B-3,B-C-1` 边表;先猜哪条边会因为“已经连通”被拒绝,再看 DSU 的回答。
+
+## 扩展与联系
+
+Prim 是从一个已选点集向外找最轻边,背后仍是割性质;“建超级源”可把建站费与连线费统一进一张图。正式课会讨论隐式完全图、替换边和 MST 边分类——它们都要求先把今天这个安全交换说清楚。
+
+## 总结与自检
+
+1. 为什么 `find(u)==find(v)` 时加入边一定成环?
+2. 割性质的交换中,为什么环上一定存在一条能被替换的跨割边?
+3. 同权边会破坏最小总权的正确性吗?
+
+<details><summary>自检答案</summary>
+
+当前森林中已有 $u$ 到 $v$ 的路径;从 $S$ 出去又回到 $S$ 的环必须跨割偶数次;不会,只会使可选 MST 不唯一。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:手画一棵 5 点树,确认边数。
+- 第 2 天:对一个环删不同边,观察连通性。
+- 第 3 天:模拟 `find/union` 的父亲数组。
+- 第 4 天:运行互动程序,记录每次跳过边的理由。
+- 第 5 天:进入[割性质与隐式完全图正式课](01-割性质与隐式完全图建模.md)。

+ 213 - 0
课程/06-最小与最大生成树/00-课前零食-把合并史写成树.md

@@ -0,0 +1,213 @@
+# 课前零食:把合并史写成树
+
+> 并查集很擅长回答“现在是不是一伙”,却有一点健忘:所有人最终合并后,它不记得谁先握手、谁后入群。Kruskal 重构树的想法像给每次合并拍一张合影,并把合影按发生顺序叠成一棵树。
+
+## 本节目标
+
+学完后,你能:
+
+- 手工按边权或时间运行 Kruskal,维护当前连通块;
+- 发现普通 DSU 丢失“两个点第一次连通在何时”的历史;
+- 在每次成功合并时新建父节点,构造 Kruskal 重构树(Kruskal reconstruction tree);
+- 用两原点的 LCA 读出首次连通阈值,并解释树上父亲权值为何单调;
+- 区分最小生成树式递增处理与最大生成树式递减处理的阈值含义。
+
+## 前置知识与课前自测
+
+前置:无向带权图、Kruskal、并查集、树的祖先概念。LCA 的倍增实现可留到正式课。
+
+1. Kruskal 扫到一条连接已在同一连通块内两点的边时,为什么跳过它?
+2. 边按权从小到大加入后,“1 和 4 连通”的最早时刻会不会晚于“1 和 4 最终连通”的时刻?
+3. 若 DSU 最后只剩一个根,能从父数组直接看出 2 与 4 在权值 5 才相遇吗?
+
+<details><summary>先做判断:自测答案</summary>
+
+1. 会在当前森林中形成环。2. 不会;最终连通只是说明某个有限阈值存在,最早时刻是更精确的信息。3. 通常不能:DSU 父亲只为快速 find 服务,路径压缩还会把过程抹得更干净。
+
+</details>
+
+## 互动规则:把“群聊合并”记成事件卡
+
+用 1 到 5 五张人物卡和下列边卡:
+
+~~~text
+(1,2,2)   (3,4,3)   (2,3,5)   (4,5,6)   (1,5,9)
+~~~
+
+边卡第三项是时间/权值。纸上画四栏:**对象**(当前有哪些群)、**状态**(每群的代表)、**动作**(拿起哪张边卡)、**账本**(本次真的把哪两群合并、时间是多少)。先别画一棵大树,先让 DSU 的健忘症露面。
+
+## 从零搭框架:DSU 会合并,但不会讲故事
+
+### 第 0 步:先按时间翻边卡
+
+从小到大处理卡片。每次只问:两端现在在同一个泡泡里吗?若不在,就把两个泡泡画成一个更大的泡泡。
+
+| 当前边 | 两端处理前的泡泡 | 选不选 | 处理后的泡泡 |
+|---|---|---|---|
+| (1,2,2) |  |  |  |
+| (3,4,3) |  |  |  |
+| (2,3,5) |  |  |  |
+| (4,5,6) |  |  |  |
+| (1,5,9) |  |  |  |
+
+> 停顿 0:处理到权值 5 后,1 与 4 是否连通?处理到权值 3 后呢?请不要看最终结果再倒推。
+
+<details><summary>展开:阈值连通的第一张表</summary>
+
+权值 2 合并 {1},{2};权值 3 合并 {3},{4};权值 5 合并 {1,2} 与 {3,4},此时 1、4 首次连通;权值 6 把 5 接入。最后 (1,5,9) 已在同一块,跳过。边权不只是代价,也可以读成“允许使用到何时”。
+
+</details>
+
+### 第 1 步:故意问 DSU 一个它答不好的问题
+
+现在问:2 与 4 是在何时首次连通?如果你只保存普通 DSU 的最后父数组,所有叶子可能都指向同一代表;若开着路径压缩,连“先经过谁”也看不到。重跑一遍当然能回答一个询问,很多询问就会把相同历史反复回放。
+
+> 停顿 1:在处理权值 6 后,若只留最终集合 {1,2,3,4,5},你还区分得出 2-4 的首次连通时间和 1-5 的首次连通时间吗?缺的究竟是一个数,还是一段结构?
+
+<details><summary>展开:缺的是合并的谱系</summary>
+
+2-4 在 5 连通,1-5 在 6 连通。不是给每个点多存一个“最后时间”就能解决:一个点会和许多别的点在不同时间相遇。我们需要保存“哪个连通块在何时由哪两个旧块合成”的树状谱系。
+
+</details>
+
+### 第 2 步:每次合并,不选旧根当新根
+
+第一次成功合并 1、2 时,拿一张新事件卡 6,写上权值 2;让它的孩子是原点 1、2。DSU 的新代表也改成 6。第二次成功合并 3、4,拿事件卡 7,写上 3。第三次合并两个大泡泡,拿事件卡 8,写上 5,孩子正是 6、7。最后拿事件卡 9,写上 6,孩子是 8、原点 5。
+
+![普通并查集只留下当前代表,重构树留下合并事件](../插图/06-最小与最大生成树/00-KRT-01-DSU与事件卡.svg)
+
+> 停顿 2:节点 8 的两个孩子为什么必须是 6、7,而不能直接接 1、2、3、4 四个叶子?若直接接四个叶子,丢失了哪两次发生在更早时刻的合并?
+
+<details><summary>展开:内部节点是一次二元事件,不是一团成员清单</summary>
+
+6 和 7 分别记录了权值 2、3 时已发生的合并。8 的含义是“在权值 5,这两个已连通块第一次成为同一块”;保留两个孩子才保留了嵌套时间。树的叶子仍是原点,内部节点才是事件。
+
+</details>
+
+### 第 3 步:把“何时相遇”翻译成最近共同事件
+
+在刚画的树中,2 的祖先链是 2→6→8→9,4 的是 4→7→8→9。第一次相同的祖先是 8,卡上写 5。因此:
+
+$$
+\operatorname{first\_connect}(u,v)
+=
+\operatorname{value}(\operatorname{LCA}(u,v)).
+$$
+
+![两片叶子的最近共同事件就是首次连通阈值](../插图/06-最小与最大生成树/00-KRT-02-LCA就是相遇.svg)
+
+不是因为 LCA 有魔法,而是任何时刻 T 对应“删掉所有 value>T 的祖先连接”。u、v 在同一块,当且仅当它们已共同落在某个 value≤T 的事件节点下;最小这样的 T 就是它们最低的共同祖先权值。
+
+## 问题动机:把很多次时间旅行压成一次建树
+
+若有 q 个问题,每个都问“两点在多大阈值后连通”或“阈值 T 时点 u 的连通块多大”,重跑 Kruskal 是 O(qm log m) 量级的重复劳动。重构树把所有阈值的合并历史一次性展开为至多 2n-1 个节点;随后用 LCA、祖先跳跃或子树信息查询。
+
+## 步步式引导:节点权的方向决定你问的阈值
+
+### 1. 递增 Kruskal:向根的时间不减
+
+本节按边权递增处理。新事件一定发生在两个旧事件之后,所以从叶到根,节点权不减。对阈值 T,点 u 所在的“当前块”是 u 向上走、但不跨过 value>T 的最高祖先。它的叶子数可预先存在该祖先的 subtree_size 中。
+
+~~~text
+build_reconstruction_tree(edges in nondecreasing value):
+    make every original vertex a DSU root and a leaf
+    for (u, v, w) in edges:
+        a = find(u); b = find(v)
+        if a == b: continue
+        event = new_node(value=w, children=[a, b])
+        DSU_parent[a] = event
+        DSU_parent[b] = event
+        DSU_parent[event] = event
+~~~
+
+这里的 DSU 根不再一定是原顶点,它是“当前连通块在重构树中的根”。这正是实现中最容易写错的身份切换。
+
+### 2. 最大生成树式处理:方向反过来,故事不变
+
+若按边权从大到小合并,内部节点权从叶到根不增。它适合问“只保留权值至少 W 的边,两点是否已连通”或最大瓶颈路径。不要机械套递增版的比较号:先在纸上写清 T 是“允许不超过”还是“允许至少”,再决定向祖先跳的条件。
+
+> 停顿 3:若从大到小处理边,两个点的 LCA 权值 8,表示它们在保留权值至少多少的边时开始连通?阈值往更低还是更高移动会让连通块变大?
+
+<details><summary>展开:最大版的读法</summary>
+
+LCA 权 8 表示只允许权值至少 8 时,它们首次连通;把下限继续降低,会加入更多边,连通块只会变大。树仍在记录合并史,只是“时间轴”从大权向小权播放。
+
+</details>
+
+## 核心知识:三张账本要分开
+
+| 账本 | 存什么 | 为什么不能省 |
+|---|---|---|
+| DSU | 当前每个连通块的重构树根 | 快速判断这条边是否带来新合并 |
+| 重构树 | 每次成功合并的两个旧根与权值 | 保留所有历史阈值 |
+| 树上预处理 | parent、depth、up、subtree_size | 快速求 LCA、跳到阈值位置 |
+
+树最多有 n 个叶与 n-1 个成功合并节点;若原图不连通,会得到森林,常加一个虚根或按题目逐树处理。建立阶段排序 O(m log m),DSU O(m α(n));LCA 预处理 O(n log n),每次两点阈值查询通常 O(log n)。
+
+在一棵 MST 上,u 到 v 路径的最大边权也等于递增重构树 LCA 的权值:Kruskal 在该时刻恰把二者连起来。这是“最小瓶颈路径”与重构树相遇的地方;但不要反过来以为重构树本身就是原 MST,它记录的是合并关系,不保留被跳过的所有原边。
+
+## 实现指南
+
+- 数组容量至少开到 2n;连通图最多生成 n-1 个内部节点。
+- 一次合并创建 event 后,必须让两个**当前 DSU 根**的父亲改为 event;不能只写原边端点 u、v。
+- 用重构树邻接表或 children 数组单独做 DFS/LCA;不要把 DSU 路径压缩后的父指针当树父亲。
+- 若题目边权相同,多个合并节点权相同没有问题;“首次连通阈值”仍正确,树形可随同权处理顺序不同。
+- 若要问阈值块大小,预处理每个重构树节点包含多少原叶;内部 event 的叶子数是两个孩子之和。
+
+## 纸上模拟:给每次握手一张合影
+
+请把下表补完,再画 6、7、8、9:
+
+| 新节点 | 发生权值 | 左孩子 | 右孩子 | 覆盖的原点 |
+|---|---:|---|---|---|
+| 6 |  |  |  |  |
+| 7 |  |  |  |  |
+| 8 |  |  |  |  |
+| 9 |  |  |  |  |
+
+<details><summary>展开:完整的合并史</summary>
+
+6@2 的孩子是 1、2;7@3 的孩子是 3、4;8@5 的孩子是 6、7,覆盖 1,2,3,4;9@6 的孩子是 8、5,覆盖全部五点。2 与 4 的 LCA 是 8,首次相遇为 5;1 与 5 的 LCA 是 9,首次相遇为 6。
+
+</details>
+
+再设阈值 T=3、5、6,分别圈出点 2 所属的最高允许祖先。你应该得到 {1,2}、{1,2,3,4}、{1,2,3,4,5}。这就是“向上跳到不超过 T 的最高事件”的具体含义。
+
+## 互动小实验室
+
+运行 [把合并史写成树.py](../../互动程序/06-最小与最大生成树/00-把合并史写成树.py)。它先让你预测 2 与 4 何时相遇,再逐条打印 Kruskal 边卡与新事件节点。可输入:
+
+- show:查看重构树的侧视图和每个节点的叶子数;
+- q 2 4:问两点的首次连通阈值;
+- at 2 3:问阈值 3 时 2 所在的原点集合;
+- at 2 6:比较同一点在更高阈值下的块。
+
+先自己画祖先链,再让程序做 LCA;不要把“它输出 5”误当成已经理解了为什么是 5。
+
+## 扩展与联系
+
+- 重构树常用于按时间加边、按高度淹没、按温度连通等“单调合并”问题;权值不一定真是边长。
+- 若边会删除,合并历史不再单调,普通重构树不能直接倒放维护。
+- 对连续编号顶点区间,若能把“整个区间连通”化为相邻点阈值的最大值,可再接 RMQ/线段树;这是正式课的一条重要转化。
+- XOR-MST 也用 Kruskal 思想,但它的难点是不用显式列出完全图边;不要把它和重构树混成同一个模板。
+
+## 总结与自检
+
+1. 重构树的叶和内部节点各代表什么?
+2. 为什么内部节点的两个孩子必须是当前 DSU 根?
+3. 两叶 LCA 的权值为什么是首次连通阈值?
+4. 递增版和递减版分别对应哪一种单调阈值?
+
+<details><summary>自检答案</summary>
+
+叶是原点、内部节点是一次成功合并;孩子是两个完整旧连通块;它是最早同时包含两叶的事件;递增版允许边权不超过 T,递减版保留边权至少 W。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:手做一次 Kruskal 泡泡合并,记录每个阈值的块。
+- 第 2 天:不看答案画出 6、7、8、9 四张事件卡。
+- 第 3 天:用 LCA 手答三对点的首次连通权。
+- 第 4 天:运行互动程序,比较 DSU 最终状态与完整合并史。
+- 第 5 天:进入[Kruskal 重构树与 XOR-MST 正式课](03-Kruskal重构树与XORMST.md)。

+ 2 - 0
课程/06-最小与最大生成树/01-割性质与隐式完全图建模.md

@@ -1,5 +1,7 @@
 # 最小生成树:割性质、Kruskal 与隐式完全图
 
+> 若还没有把“连通块合并”和割性质连起来,先完成 [课前零食:只修够用的路](00-课前零食-只修够用的路.md)。
+
 ## 本节目标
 
 学完后,你应能证明 Kruskal/Prim,使用割性质安全削减完全图候选边,并用超级源把“选设施或连线”统一成生成树。

+ 6 - 0
课程/06-最小与最大生成树/03-Kruskal重构树与XORMST.md

@@ -1,5 +1,7 @@
 # 类生成树:Kruskal 重构树与 XOR-MST
 
+> 若普通并查集的“最终状态”与“合并历史”还容易混,先完成 [课前零食:把合并史写成树](00-课前零食-把合并史写成树.md)。它先用边卡做阈值连通,再把每次 union 画成事件节点。
+
 ## 本节目标
 
 学完后,你应能把并查集合并历史显式建成 Kruskal 重构树,用 LCA 回答“两点何时连通”,并利用 XOR 权值的最高不同位递归削减完全图。
@@ -31,8 +33,12 @@ $$
 =\min\{k:\ u,v\text{ 在前 }k\text{ 条边中连通}\}.
 $$
 
+![递增合并的时间轴如何长成重构树](../插图/06-最小与最大生成树/03-KRT-01-合并时间树.svg)
+
 XOR-MST 则使用最高位分层。若一组数在位 $b$ 同时含 0/1,任意连接两组的边都至少花 $2^b$;生成树必须至少选一条跨边,而组内可独立递归。只需求两组间最小 XOR,而不是显式生成所有跨边。
 
+![XOR-MST 的最高不同位分治与唯一跨组连接](../插图/06-最小与最大生成树/03-KRT-02-XOR最高位分组.svg)
+
 ## 实现指南
 
 - 重构树最多有 $2n-1$ 个节点,数组按 `2*n` 开。

+ 201 - 0
课程/07-二分图/00-课前零食-两支队伍与奇怪的圈.md

@@ -0,0 +1,201 @@
+# 课前零食:两支队伍与奇怪的圈
+
+> 如果所有“不能同队”的关系都能安排成左队和右队,图就是二分图。听起来像排座位,真正的捣蛋鬼却是一种很具体的结构:奇数长度的圈。
+
+## 本节目标
+
+学完后,你能:
+
+- 把二分图理解成“每条边都跨越两种颜色”的图;
+- 用 BFS/DFS 的交替染色法判定二分图,并从同色冲突找到奇环;
+- 说明为什么一个连通二分分量的两种染色只差整体翻转;
+- 为正式课中的构造、匹配与 Kőnig 定理准备“左右两侧”的模型。
+
+## 前置知识与课前自测
+
+前置:无向图、BFS/DFS、连通分量。
+
+1. 一条边的两个端点若必须异色,走过两条连续边后颜色会变成原色还是另一色?
+2. 三角形能否被两种颜色染得每条边两端不同?
+3. 一个孤立点放左边还是右边会影响已有边的合法性吗?
+
+<details><summary>答案</summary>
+
+1. 变回原色。2. 不能:第三条边会连接同色点。3. 不会;它本身就是一个可自由翻转的小分量。
+
+</details>
+
+## 互动规则:每条边都是一句“你们不能同色”
+
+先不要把颜色当漂亮的涂料。给每个点留一个格子,填 `未定 / 0 / 1`;每处理一条边,就把它读成约束:“若 u 是 0,v 必须是 1。”纸上同时记录 `是谁迫使谁染色`,这样冲突时才能沿证据回去找圈。
+
+本节只有一个框架问题:**局部的“不同”约束能否一路传播而不自相矛盾?**先让自己撞到矛盾,再给它正式名字“奇环”。
+
+## 从零搭框架:从猜所有队伍到让边替你决定
+
+### 第 0 步:先用两种颜色安排一条链
+
+画路径 `A-B-C-D`。任意给 A 涂 0,沿边依次写出其余颜色。然后把最后一条边 `D-A` 加上,观察结果;再把路径改为 `A-B-C-A`。
+
+> 停顿 0:为什么长度为 4 的环可以回到 A 的原色,而长度为 3 的环会要求 A 既是 0 又是 1?请逐边写颜色,不要直接说“奇环”。
+
+<details><summary>展开:每走一步都翻转一次</summary>
+
+走一条边颜色翻转;偶数步回到原色,奇数步回到反色。偶环最后的约束与起点一致,奇环最后一条边却要求起点异于自己,矛盾。这就是奇环不能二染的最小原因。
+
+</details>
+
+### 第 1 步:让 BFS/DFS 只做约束传播
+
+画边 `A-B,A-D,C-B,C-D,E-F`。从 A 开始,给 A 染 0;每遇到未染邻点就染反色。请填写:
+
+| 当前取出的点 | 它的颜色 | 看到的未染邻点 | 给出的颜色 | 父亲证据 |
+|---|---:|---|---:|---|
+| A | 0 |  |  |  |
+| B | 1 |  |  |  |
+| E | 0 |  |  |  |
+
+> 停顿 1:为什么处理完 A 所在分量后,仍要从 E 重新开始?`visited` 与 `color` 在这里分别负责什么?
+
+<details><summary>展开:颜色是带内容的访问标记</summary>
+
+边无法把消息传到另一个连通分量,所以每个未染点都要自立门户。`color=-1` 同时表示未访问;0/1 不只表示访问过,还记录它相对根的路径长度奇偶。最终两侧为 `{A,C,E}` 与 `{B,D,F}`,每个分量可整体翻转。
+
+</details>
+
+### 第 2 步:让同色冲突自己画出一条奇环
+
+在图 `A-B,B-C,C-A,C-D` 中,从 A 染 0。你会得到 B、C 都是 1,然后检查边 B-C。请保存每个点的父亲:`parent[B]=A,parent[C]=A`。
+
+> 停顿 2:从 B 沿父亲回根、从 C 沿父亲回根,再加冲突边 B-C,会得到哪一圈?为什么它长度是奇数?
+
+<details><summary>展开:冲突边是一份反例证书</summary>
+
+得到 `B→A→C→B`。B、C 同色意味着它们到共同根的路径长度同奇偶;两条树路径合起来是偶数长度,再加一条冲突边便是奇数。算法不仅说“不行”,还给出一条能拿在手里的奇环。
+
+</details>
+
+### 第 3 步:一个连通分量为什么只有两个可选方案
+
+把可二染分量的根 A 从 0 改成 1,不重新搜索,只把所有点颜色翻转。请检验每条边是否仍跨色。然后试图只翻转 C,观察哪条边先坏。
+
+> 停顿 3:题目若要求恰好 K 个点在左侧,为什么不能把每个点随意改色?真正可自由选择的单位是什么?
+
+<details><summary>展开:自由度在分量层,不在点层</summary>
+
+连通分量内任一点颜色被根到它的路径奇偶固定,只有根的初始色能选;因此一个分量只有“当前分法”与“整体翻转”两种。多个分量才给出多次独立选择,正式课会把这些选择接到背包与构造恢复上。
+
+</details>
+
+### 第 4 步:把两侧理解成角色,而不只是颜色
+
+若边表示“左侧任务可交给右侧机器”,或“相邻点不能同组”,请分别写出颜色 0/1 在题面中的角色。再问:若输入本身已经把对象分成男/女、工人/任务两类,还需要先跑二染吗?
+
+<details><summary>展开:二分图是关系的类型检查</summary>
+
+有天然两类时,先检查边是否都跨类;没有天然两类时,染色在发明两类。后续匹配只允许跨两侧配对,所以“能否二染”常是把题面关系变成左右接口的第一步。
+
+</details>
+
+## 问题动机:逐边猜队伍会有 $2^n$ 种选择
+
+给每个点猜“左/右”看似简单,但 $n$ 个点有 $2^n$ 种分配。边提供了更强的传播规则:给一个起点染 0,沿每条边的另一端必须是 1,再沿下一条边又必须是 0。一次 BFS/DFS 就能把一个连通块的所有颜色推出来。
+
+![交替染色形成左右两侧](../插图/07-二分图/00-二分图-01-两侧.svg)
+
+真正需要检查的是:某条边的两端会不会被迫同色。若会,沿 BFS/DFS 树从两端回到共同祖先,再加上这条冲突边,正好得到一个奇环。
+
+## 步步式引导
+
+### 1. 为什么奇环一定失败
+
+沿一个环交替染色。走偶数步回到起点会要求原色;走奇数步回到起点却要求反色,矛盾。所以任何奇环都不是二分图。
+
+反过来,若交替染色遇到同色边 $(u,v)$,DFS/BFS 树上从根到 $u$、从根到 $v$ 的路径长度同奇偶;两条路径加这条边形成奇环。因此“能二染”与“没有奇环”是充要关系,而不是一条单向小技巧。
+
+![同色冲突揭露奇环](../插图/07-二分图/00-二分图-02-奇环.svg)
+
+```text
+color[all] = UNCOLORED
+for every vertex start:
+    if color[start] is UNCOLORED:
+        color[start] = 0
+        queue.push(start)
+        while queue not empty:
+            u = queue.pop_front()
+            for v in neighbors[u]:
+                if color[v] is UNCOLORED:
+                    color[v] = 1 - color[u]
+                    queue.push(v)
+                else if color[v] == color[u]:
+                    report "not bipartite"
+```
+
+## 纸上模拟:一边传播,一边保留冲突证据
+
+对 `A-B,B-C,C-A,C-D`,从 A=0 开始,填写:
+
+| 队列取出 | `color` 已知值 | 处理的边 | 采取动作 | `parent` 新记录 |
+|---|---|---|---|---|
+| A |  | A-B |  |  |
+| A |  | A-C |  |  |
+| B |  | B-C |  |  |
+
+然后在另一个纸面上给 `A-B,A-D,C-B,C-D,E-F` 染色;把两个分量的两侧大小写为 `(2,2)` 与 `(1,1)`,试着整体翻转其中一个。
+
+<details><summary>展开:算法状态与题目后续选择</summary>
+
+第一张图在 B-C 处发现同色冲突,并用父亲恢复三角形。第二张图无冲突,第一分量左右均 2,第二均 1;翻转不破坏任何边,只交换每个分量给全局左侧贡献的大小。颜色传播解决可行性,分量翻转保留构造自由度,两件事不要揉成一团。
+
+</details>
+
+### 2. 一个分量为什么只有两种方案
+
+确定根的颜色后,任一点到根的路径长度奇偶决定其颜色;若图无奇环,任意两条路径的奇偶不会矛盾。因此整个连通分量只有“当前 0/1 分配”和“全部交换 0/1”两种选择。多个分量互不影响,才会出现“每团选左还是右”的背包或构造问题。
+
+### 3. 从两支队伍到匹配
+
+二分匹配只在左右两侧间配对:一条边表示“这个左对象可以和这个右对象搭档”。今天不求最大匹配,只先练模型辨认:若对象天然有两类、关系只应跨类出现,先检查二分结构;若图不是二分图,别急着套匹配。
+
+## 核心知识:颜色是边约束传播后的证书
+
+`color[u]` 不是一个随便的标签,而是“从起点到 $u$ 的路径长度奇偶”的证书。每次把未染邻点设成反色,都满足当前边;遇到同色边则给出不可行的具体证据。全图要从每个未染点启动,因为非连通分量没有边替你传话。
+
+时间和空间都是 $O(V+E)$。自环立即连接同一点与自己,强迫它与自己异色,因此是最短的奇环;重边不会破坏二分性,只是重复检查同一约束。
+
+## 实现指南
+
+- 用 `-1` 表示未染,避免把颜色 0 误当“没访问”。
+- 保存 `parent` 和 `depth` 能在冲突时还原一个奇环,调试非常有用。
+- 题目若要求两侧人数恰好为某值,不能随意固定每个分量的方向;要保留“翻转”选择。
+- 有向图题若问相邻端点能否分两类,通常先忽略方向判断底层无向图,但必须看题意是否允许这样做。
+
+## 互动小实验室
+
+运行 [两支队伍与奇怪的圈.py](../../互动程序/07-二分图/00-两支队伍与奇怪的圈.py)。选择 `g` 看一个可二染图如何分左右两侧;选择 `b` 看一条同色冲突边如何还原奇环。也可输入自己的无向边表。
+
+建议先挑一个三角形,试图亲手给三点染色;当第三条边开始抗议时,再让程序把它说出来。
+
+## 扩展与联系
+
+连通二分分量的翻转会通向分组背包;交替路径会通向二分匹配;“没有奇环”还会出现在图的奇偶约束和差分建模中。若边约束不只是“不同”,例如要求颜色差为某个模数,就需要更一般的带权并查集或图染色工具。
+
+## 总结与自检
+
+1. 同色冲突边为什么会构成奇环证据?
+2. 为什么每个连通分量只能整体翻转,不能单独翻一个点?
+3. 自环为什么直接否定二分性?
+
+<details><summary>自检答案</summary>
+
+两端到根深度同奇偶,树路径合起来再加冲突边长度为奇数;边会把所有点的相对颜色固定;它要求同一点异于自己。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:给路径、偶环、奇环各手染一次。
+- 第 2 天:练习从同色边画回奇环。
+- 第 3 天:把两个不连通分量的两侧翻转写成选择表。
+- 第 4 天:运行互动程序,输入含自环或重边的图。
+- 第 5 天:进入[二分染色与构造选择正式课](01-二分染色与构造选择.md)。

+ 2 - 0
课程/07-二分图/01-二分染色与构造选择.md

@@ -1,5 +1,7 @@
 # 二分图:染色、不变量与构造选择
 
+> 先完成 [课前零食:两支队伍与奇怪的圈](00-课前零食-两支队伍与奇怪的圈.md),再把“奇环冲突”带进构造题。
+
 ## 本节目标
 
 学完后,你应能用奇环刻画二分图,处理非连通分量的两种翻转选择,并把“每点只能做源或汇”“三标签相邻差 1”等构造条件还原成二分染色。

+ 222 - 0
课程/08-高级线段树与树状数组/00-课前零食-会变化的区间和.md

@@ -0,0 +1,222 @@
+# 课前零食:会变化的区间和
+
+> 静态前缀和很舒服:问一段和只要相减。可一旦数组会改,旧前缀和就像一串被推倒的多米诺骨牌。今天先发明两种“只修少量摘要”的办法:Fenwick Tree 和 Segment Tree。
+
+## 本节目标
+
+学完后,你能:
+
+- 从静态前缀和的优点和单点修改的代价,推出需要分层摘要;
+- 把 `lowbit(i)` 理解成 Fenwick 节点负责的区间长度,而不只是一段位运算;
+- 理解线段树节点为何存一个区间的合并结果、查询为何只取少数节点;
+- 知道 Fenwick 与线段树各自适合什么聚合操作,为正式课的高级变式打地基。
+
+## 前置知识与课前自测
+
+前置:数组、二进制最低位、区间和、递归的基本概念。
+
+1. 前缀和 `pre[r]-pre[l-1]` 为什么能得到区间 $[l,r]$ 的和?
+2. 修改 `a[2]` 后,哪些 `pre[i]` 必须更新?
+3. `i & -i` 在二进制中会留下 `i` 的哪一部分?
+
+<details><summary>答案</summary>
+
+1. 前 $l-1$ 个元素在两份和中抵消。2. 所有 $i\ge2$ 的前缀和,最坏 $O(n)$ 个。3. 最低位的 1 及其对应数值,例如 `12=1100₂` 的 `lowbit` 是 4。
+
+</details>
+
+## 互动规则:每个格子都必须说清“我替谁记账”
+
+准备数组 `a=[3,1,4,1,5,9]`,下标从 1 开始。每次结构中出现一个新格子,都在它下面写清楚:**它汇总原数组的哪一段?**不要只记录 `tree[i]` 的数值。纸上账本分成:`原数组`、`摘要数组/节点`、`一次查询拿了哪些摘要`、`一次修改通知了哪些摘要`。
+
+本节的互动目标是从“完整前缀和”亲手发明“局部摘要”。若某个节点的负责区间说不出来,它还不是你真正会用的数据结构。
+
+## 从零搭框架:让前缀和先享受、再付出代价
+
+### 第 0 步:先把静态前缀和手算出来
+
+对 `a=[3,1,4,1,5,9]` 写 `pre[0]=0`,再逐项填 `pre[i]=pre[i-1]+a[i]`。用它回答 `sum(2,5)`。
+
+> 停顿 0:为什么只需 `pre[5]-pre[1]`?请在两个和里圈出被抵消的元素,而不是只引用公式。
+
+<details><summary>展开:静态摘要的威力</summary>
+
+`pre=[0,3,4,8,9,14,23]`,`sum(2,5)=14-3=11`。前 1 项在两份前缀中都出现而抵消;静态时一次预处理换来 $O(1)$ 查询。
+
+</details>
+
+### 第 1 步:亲手修改一次,数清谁被连累
+
+把 `a[3]` 从 4 改成 10。不要重算前缀和前先猜:`pre[1]`,`pre[2]`,`pre[3]` 到 `pre[6]` 中,哪些应改变?在每个应改变项后写 `+6`。
+
+> 停顿 1:若有十万次“改单点、问区间”,完整前缀和的哪一部分会反复重做?我们真正想保留的是什么,又不想每次重算什么?
+
+<details><summary>展开:局部摘要的需求</summary>
+
+只有 `pre[3]..pre[6]` 都加 6,单次修改最坏碰 $O(n)$ 项。我们想继续保留“一个摘要代表一段连续元素”的好处,但让一个元素只影响少量层级摘要,而不是所有后缀。
+
+</details>
+
+### 第 2 步:先人工分块,再发现二进制块
+
+把下标 1..8 先粗暴分成 `[1,4]`、`[5,8]` 两块。问 `prefix(6)` 时,你会拿整块 `[1,4]`,再处理 `[5,6]`。请继续把 `[5,6]` 看成一个摘要块。
+
+现在写下 `1..8` 的二进制和 `lowbit(i)`:
+
+| i | 二进制 | `lowbit(i)` | 若 `tree[i]` 负责尾块,它负责哪段 |
+|---:|---|---:|---|
+| 1 |  |  |  |
+| 2 |  |  |  |
+| 4 |  |  |  |
+| 6 |  |  |  |
+| 8 |  |  |  |
+
+> 停顿 2:从 6 不断减 `lowbit` 会走到哪些下标?这些块为什么刚好不重不漏地覆盖 `[1,6]`?
+
+<details><summary>展开:Fenwick 是一套可拼接的尾块目录</summary>
+
+6 的 `lowbit` 为 2,先拿 `[5,6]`;6-2=4,再拿 `[1,4]`;4-4=0 停止。二进制每次去掉最低位 1,得到的尾块恰好从右向左拼满前缀。`tree[6]` 不是第 6 个元素,而是 `[5,6]` 的和。
+
+</details>
+
+### 第 3 步:更新为什么反向加 lowbit
+
+仍改位置 3,先问:哪些尾块包含 3?从下标 3 开始不断加 `lowbit`,把走到的下标和对应区间写出来。
+
+> 停顿 3:为什么 3、4、8 都需要更新,而 6 不需要?请用“负责区间是否包含 3”解释,别只背跳法。
+
+<details><summary>展开:查询和更新是一对反向目录操作</summary>
+
+3 负责 `[3,3]`,4 负责 `[1,4]`,8 负责 `[1,8]`,它们都包含位置 3;6 负责 `[5,6]`,不含 3。减 lowbit 是把前缀拆成块,加 lowbit 是通知所有包住该点的块。
+
+</details>
+
+### 第 4 步:当“相减”不成立时,换一棵摘要树
+
+若查询的是区间最小值,写两个前缀最小值:`min(1..r)` 与 `min(1..l-1)`。请尝试从它们恢复 `min(l..r)`,再构造一个反例说明失败。
+
+然后把 `[1,8]` 二分为 `[1,4],[5,8]`,继续二分。每个节点记录自己区间的最小值或和。请回答:查询 `[2,7]` 时,哪些完全包含的节点可以直接交答案,哪些边界节点还需继续拆?
+
+<details><summary>展开:线段树只要求摘要可合并</summary>
+
+最小值不能像和一样相减;例如两个前缀最小值都为 1,无法知道区间内部最小值。线段树让每个节点保存一段摘要,查询只拿完全被覆盖的节点,再用 `combine` 合并。它不要求可逆,只要求合并规则与区间划分一致。
+
+</details>
+
+## 问题动机:一边快查询,一边快修改,不能全靠一张表
+
+原数组直接求区间和,每次查询 $O(n)$、修改 $O(1)$;完整前缀和反过来,查询 $O(1)$、修改 $O(n)$。如果有十万次查询和修改,任何一边全重算都会累趴。解决方案是保留许多**局部摘要**,一次操作只触碰对数层。
+
+![Fenwick 节点保存的尾部区间](../插图/08-高级线段树与树状数组/00-区间-01-Fenwick覆盖.svg)
+
+## 步步式引导
+
+### 1. Fenwick:按二进制块拼前缀
+
+从 1 开始编号。`tree[i]` 不保存单点,而保存区间:
+
+$$
+[i-\operatorname{lowbit}(i)+1, i].
+$$
+
+求前缀和时,从 `i` 开始反复减去 `lowbit(i)`,每次吃掉一个不重叠的尾部块;更新位置 `i` 时,反复加 `lowbit(i)`,恰好通知所有包含它的摘要块。
+
+```text
+prefix_sum(i):
+    result = 0
+    while i > 0:
+        result += tree[i]
+        i -= lowbit(i)
+    return result
+
+add(i, delta):
+    while i <= n:
+        tree[i] += delta
+        i += lowbit(i)
+```
+
+`lowbit` 不是随机跳跃:二进制最低位 1 指出当前位置结尾、大小合适的块。每次跳会去掉一个 1,最多 $O(\log n)$ 次。
+
+### 2. 线段树:让每个区间都有一个小管家
+
+若操作不只是可相减的和,例如区间最小值,`prefix_min(r)-prefix_min(l-1)` 没有意义。线段树把 $[1,n]$ 反复二分,每个节点保存其区间的合并结果;查询 $[l,r]$ 时,完全落在目标内的节点直接拿摘要,相交一部分才继续向下问。
+
+![线段树把区间递归分块](../插图/08-高级线段树与树状数组/00-区间-02-线段树分块.svg)
+
+```text
+query(node, left, right, ql, qr):
+    if [left, right] is fully inside [ql, qr]:
+        return summary[node]
+    split at mid
+    combine answers from overlapping children
+
+point_set(node, left, right, index, value):
+    if left == right: summary[node] = value; return
+    recurse into the child containing index
+    summary[node] = combine(left_child, right_child)
+```
+
+## 纸上模拟:让同一条查询走两条不同的摘要路线
+
+初始数组仍为 `[3,1,4,1,5,9]`。先完成 `sum(2,5)`,再执行 `set(3,10)`,最后再问一次 `sum(2,5)`。每次都填:
+
+| 操作 | Fenwick 拿/改的块 | 线段树拿/重算的区间节点 | 结果 |
+|---|---|---|---|
+| `sum(2,5)` |  |  |  |
+| `set(3,10)` |  |  |  |
+| `sum(2,5)` |  |  |  |
+
+<details><summary>展开:两种结构省掉的是同一种重复劳动</summary>
+
+第一次答案为 11,更新后为 17。Fenwick 用 `prefix(5)-prefix(1)`,其中会拼 `[5,5]`、`[1,4]` 等尾块;线段树取若干完全覆盖的区间节点并合并。它们的形状不同,但都避免了逐元素重算整段,操作只触碰对数层摘要。
+
+</details>
+
+每层至多拆开目标区间两边,树高为 $O(\log n)$,所以单点改与区间问都是 $O(\log n)$。
+
+## 核心知识:选择结构前先问“摘要能不能合并、能不能反推”
+
+Fenwick 最擅长可用前缀差还原区间的操作,例如和、计数、XOR;取最小值通常不能从两个前缀最小值相减出来。线段树只要求 `combine` 满足结合性,能维护和、最值、最大子段和等更丰富摘要,但常数和实现复杂度较高。
+
+两者都不是“自动处理所有区间操作”的万能盒:若更新影响整段、查询也问整段,普通节点摘要不够,需要 Lazy 标记;若值域很大或版本很多,正式课会继续引入动态开点、可持久化与合并分裂。
+
+## 实现指南
+
+- Fenwick 通常 1-based;`i=0` 时 `lowbit(0)=0` 会原地踏步,千万别让循环从 0 开始。
+- 查询闭区间和统一写成 `prefix(r)-prefix(l-1)`,不要一会儿闭区间一会儿半开区间。
+- 线段树的 `combine`、空区间单位元、叶子含义必须一致;求最小值的单位元不是 0。
+- 先写小数组并打印每个节点代表的区间,再追求“精简模板”;短代码不等于短思考。
+
+## 互动小实验室
+
+运行 [会变化的区间和.py](../../互动程序/08-高级线段树与树状数组/00-会变化的区间和.py)。它同时维护一份 Fenwick 与一棵线段树;可以输入:
+
+- `sum l r`:看 Fenwick 分解出的前缀块和线段树选中的节点;
+- `set i x`:看一次修改向上的 Fenwick 摘要和线段树祖先;
+- `show`:列出每个 `tree[i]` 真正负责的区间。
+
+直接回车会退出,适合先读默认摘要;推荐先输入 `sum 2 5`,再 `set 3 10`,再问一次同一区间。
+
+## 扩展与联系
+
+Fenwick 的“按二进制块前缀分解”常用于离线计数和逆序关系;线段树的“摘要 + 合并”则是区间 DP、扫描线、可持久化结构的母语。两者共享的识别信号是:数组会变化,但每次只问一个可合并的区间性质。
+
+## 总结与自检
+
+1. Fenwick 的 `tree[i]` 为什么不是 `a[i]`?
+2. 为什么区间最小值不适合直接做“两个前缀最小值相减”?
+3. 线段树为何只递归到与查询区间相交的部分?
+
+<details><summary>自检答案</summary>
+
+它保存一个二进制尾块摘要;最小值没有可逆减法;完全不相交的节点对答案没有贡献,完全包含的节点已有足够摘要。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:写出 1 到 8 的 `lowbit` 和各自负责区间。
+- 第 2 天:用 Fenwick 块拼出 `prefix_sum(13)`。
+- 第 3 天:给 8 个数画一棵区间二叉树。
+- 第 4 天:运行互动程序,比较同一查询的两种分解。
+- 第 5 天:进入[Fenwick 离线计数正式课](01-Fenwick离线计数与序关系.md)。

+ 2 - 0
课程/08-高级线段树与树状数组/01-Fenwick离线计数与序关系.md

@@ -1,5 +1,7 @@
 # Fenwick 进阶:把值域顺序变成前缀计数
 
+> 本节默认会基础 Fenwick。若 `lowbit` 和区间摘要尚不牢,先完成 [课前零食:会变化的区间和](00-课前零食-会变化的区间和.md)。
+
 ## 本节目标
 
 在已掌握树状数组单点加、前缀和的基础上,学会把三元关系、左右频次关系和带负数子段和统一改写成“扫描线 + 已处理集合中的秩查询”。

+ 215 - 0
课程/09-基环树/00-课前零食-树多长出一条边.md

@@ -0,0 +1,215 @@
+# 课前零食:树多长出一条边
+
+> 树的世界很规矩:两点之间只有一条简单路。基环树只多了一条边,就突然多出一个能绕圈的地方。诀窍不是假装它还是树,而是先把那一圈找出来。
+
+## 本节目标
+
+学完后,你能:
+
+- 识别“连通 $n$ 点 $n$ 边”为什么恰有一个简单环;
+- 用不断剥离叶子的过程把环从挂树中露出来;
+- 将无向基环树与每点出度为 1 的函数图联系起来;
+- 理解为什么环上决策常需单独处理首尾,而树上的信息可向环汇总。
+
+## 前置知识与课前自测
+
+前置:树、度数、队列、DFS/BFS。
+
+1. 连通树有 $n$ 个点时有多少条边?
+2. 在树上额外加一条边,为什么至少会产生一个环?
+3. 一个度数为 1 的叶子可能在一个简单环上吗?
+
+<details><summary>答案</summary>
+
+1. $n-1$。2. 新边两端在树中原本已有唯一一条路径,新边和这条路径闭合成环。3. 不可能;环上的点至少接着环的前一条和后一条边,度数至少为 2。
+
+</details>
+
+## 互动规则:每次删点前先说出“它为什么不可能在环上”
+
+准备图 `A-B,B-C,C-A,B-D,D-E,C-F,C-G`。每个点旁先写当前度数;每删一个点,划掉它、给未删邻点的度数减 1,并写一句证词:“它为什么不可能在唯一环上?”不要一开始把剩余点叫“环”,先让删除过程证明它。
+
+函数图实验另用一张纸:每点恰一条出箭头,记录的是**入度**。这两种剥离动作看起来相像,但删的理由不同。
+
+## 从零搭框架:先从树的唯一性出发
+
+### 第 0 步:树为什么没有绕圈的余地
+
+画一棵 5 点树,例如 `A-B,A-C,C-D,C-E`。从 A 到 D 找路径;再尝试找一条不重复边的第二条路径。请数边数,并尝试删掉任意一条边。
+
+> 停顿 0:为什么连通且无环的 5 点图必须有 4 条边?若有第 5 条边,它的两个端点原本在树中已有怎样的关系?
+
+<details><summary>展开:额外边只能闭合已有路径</summary>
+
+树中两点之间有唯一简单路径。第 5 条边连接其两个端点时,和原路径合成一个环;连通 $n$ 点 $n$ 边图因此至少有环。它只比树多一条边,所以不会凭空长出两份独立绕圈空间,恰有一个简单环。
+
+</details>
+
+### 第 1 步:先把“肯定不在环上”的点找出来
+
+回到预设图。请填初始度数,圈出所有度数为 1 的点;然后假装你要在一个环上绕路,问:能否经过度数 1 的 E 后离开它而不重复边?
+
+| 点 | A | B | C | D | E | F | G |
+|---|---:|---:|---:|---:|---:|---:|---:|
+| 初始度数 |  |  |  |  |  |  |  |
+| 被删后剩余度数 |  |  |  |  |  |  |  |
+
+> 停顿 1:删 E 后 D 变成什么?为什么 D 这时才获得“绝不在环上”的证书?
+
+<details><summary>展开:剥叶不是猜环,而是排除非环</summary>
+
+初始叶子为 E、F、G。叶子只有一条未删边,若从它进入就没有另一条边离开,故不在环上。删 E 后 D 的剩余度数变 1,D 也可删;最后 A、B、C 均剩度数 2,形成唯一环。每次删点都只传播“它的邻居少了一条边”。
+
+</details>
+
+### 第 2 步:把队列状态说完整
+
+请把无向剥叶的状态补全:
+
+```text
+degree[u] = ______
+queue = ______
+removed[u] = ______
+弹出 u 后,对每个 ______ 的邻点 v:______
+```
+
+> 停顿 2:为什么不直接把所有当前度数小于等于 1 的点反复扫描?队列替你省掉了什么重复劳动?
+
+<details><summary>展开:每条边只负责一次度数通知</summary>
+
+`degree` 是未删邻边数;队列初始装度数 1 点;`removed` 防止重复处理;删 u 时仅对未删邻点减度数,若变 1 入队。扫描全图会反复寻找新叶子;队列只在某点刚满足条件时通知一次,总边访问线性。
+
+</details>
+
+### 第 3 步:函数图为什么改剥入度 0
+
+画箭头 `A→B,B→C,C→A,D→C,E→D,F→E`。沿每点唯一出边前进,先从 F 开始写路线。请标出所有入度为 0 的点并按队列删除。
+
+> 停顿 3:为什么出度 1 的 F 仍可以先删?为什么删 F 后该减少 E 的入度而不是出度?
+
+<details><summary>展开:谁没人指向,谁不可能在有向环上</summary>
+
+环上每个点至少被环的前驱指向,入度不可能为 0;每点出度固定为 1,所以它不能作为筛子。删 F 代表“没有任何路线会绕进 F 再成环”,其唯一出边原本贡献给 E 的入度,故要减 E 的入度。最后剩 A、B、C 的有向环。
+
+</details>
+
+### 第 4 步:环找出来后,问题才被允许分家
+
+请把预设无向图分为“环 `A-B-C-A`”和挂在 B/C 上的树。若每个删掉的叶子有一个值要汇总到离环更近的点,应该在“入队、弹出、还是最后遍历环”哪个时刻累加?
+
+<details><summary>展开:外向内的删除顺序就是汇总顺序</summary>
+
+在弹出叶子时把它的贡献加给唯一未删邻点;这样 D 会先收到 E,再把完整贡献送给 B。环点最后拿到各自挂树摘要。之后环上的首尾相邻关系必须单独处理,不能当成普通树父子关系。
+
+</details>
+
+## 问题动机:把基环树当普通树会在哪儿摔倒
+
+若从任一点 DFS,把访问过的邻点全当父亲或已处理点,环会让“父子关系唯一”的假设失效;做树形 DP 时,环的首尾也会互相依赖。可如果先把所有挂在环外的树枝削掉,最后留下的正是唯一环,问题就分成“环外树”和“环上小问题”两部分。
+
+![树加一条边后出现唯一环](../插图/09-基环树/00-基环树-01-多一条边.svg)
+
+## 步步式引导
+
+### 1. 为什么是“唯一”环
+
+一棵连通 $n$ 点树有 $n-1$ 边;额外加一条边会得到至少一个环。若存在两个不同简单环,删其中一个环上一条不被另一个环必需的边仍可能保持连通,边数便可继续降到低于树的下界,矛盾。更直观地说:从树加的那条边只有一次机会闭合原来的唯一树路径。
+
+### 2. 叶子为什么可以安全剥掉
+
+叶子不在环上,删掉它不会删掉任何环。删叶子后,它唯一邻点的度数减 1;这个邻点可能变成新的叶子,继续入队。直到队列空,所有剩余点度数都至少为 2;在连通单环图中,它们恰好组成那条环。
+
+![一层层剥叶后只剩环](../插图/09-基环树/00-基环树-02-剥叶.svg)
+
+```text
+queue = every vertex whose degree is 1
+removed[all] = false
+while queue not empty:
+    u = queue.pop_front()
+    removed[u] = true
+    for v in neighbors[u]:
+        if not removed[v]:
+            degree[v] -= 1
+            if degree[v] == 1:
+                queue.push_back(v)
+cycle_vertices = every vertex not removed
+```
+
+这和拓扑排序很像:不是按有向先修关系剥入度 0,而是按“绝不可能在环上”的无向叶子剥度数 1。
+
+### 3. 函数图只是箭头版的同一故事
+
+若每个点恰有一条出边,沿箭头一直走必会重复,因此每个弱连通块有一个有向环,其他点都是指向环的树。把入度为 0 的点不断删掉,剩下的就是环点;方向换了,但“剥掉不能在环上的部分”的思想没换。
+
+```text
+queue = all vertices with indegree 0
+while queue not empty:
+    u = queue.pop_front()
+    v = to[u]
+    indegree[v] -= 1
+    if indegree[v] == 0:
+        queue.push_back(v)
+remaining vertices are directed-cycle vertices
+```
+
+## 纸上模拟:让队列自己露出核心
+
+对预设无向图,填写每一轮队列和度数变化:
+
+| 弹出点 | 它的未删邻点 | 邻点新度数 | 新入队点 | 当前剩余点 |
+|---|---|---:|---|---|
+| E |  |  |  |  |
+| F |  |  |  |  |
+| G |  |  |  |  |
+| D |  |  |  |  |
+
+再对函数图写入度序列,按 `F,E,D` 删除。最后请分别写一句:“无向图剩余点为何组成环?”“函数图剩余点为何组成有向环?”
+
+<details><summary>展开:相似队列,两个不变量</summary>
+
+无向图中 E 让 D 变叶,F、G 使 C 的度数降到 2,D 再使 B 降到 2,剩 A/B/C;函数图中 F 使 E 入度 0,E 使 D 入度 0,最后 A/B/C 都仍有环内前驱。一个维护未删度数,一个维护未删入度;它们都在排除“不可能属于核心”的点。
+
+</details>
+
+## 核心知识:先切环,后做树,再回到环
+
+剥叶顺序天然从外向内。若每个被删点有一个要向父亲汇总的值,可以在弹出时累加到唯一未删邻点;最后每个环点拿到了自己挂树的完整信息。环上若有“相邻不能同时选”之类约束,常把一个点强制选或不选,做两次链 DP,避免把首尾当作互不相干。
+
+整个剥叶过程每条边导致至多一次度数下降,复杂度 $O(n)$。适用前提很重要:无向部分须是连通 $n$ 点 $n$ 边;一般图可能剩多个环或复杂核心,不能把“剩余点都是唯一环”照搬。
+
+## 实现指南
+
+- 无向图初始化队列的条件是 `degree == 1`,函数图是 `indegree == 0`;别把两种剪刀拿反。
+- 标记已删除点,避免一条边在两端被删后继续重复减度数。
+- 环点通常还需按环顺序走一圈;“未删除”只是集合,不自动给出环顺序。
+- 如果图允许不连通,应对每个分量分别判断;`n` 点 `n` 边只保证全图环数总量,不保证每块各一个。
+
+## 互动小实验室
+
+运行 [树多长出一条边.py](../../互动程序/09-基环树/00-树多长出一条边.py)。选择 `u` 可观察无向图从叶子到环的剥离顺序;选择 `f` 可观察函数图的入度剥离。程序会在每一步打印哪一个点已不可能在环上。
+
+先预测预设图里 $D$ 何时变成叶子;然后改看函数图,比较“度数 1”和“入度 0”这两个相似却不同的条件。
+
+## 扩展与联系
+
+基环树的“核心 + 挂树”分解会通向环上计数、删环边直径和函数迭代;更一般的图中,反复剥叶留下的称为 2-core。树形 DP 能处理挂树,环上的首尾依赖则提醒你不要把一圈强行掰成一条没有说明的链。
+
+## 总结与自检
+
+1. 为什么叶子不可能是唯一环的一部分?
+2. 剩余点集合为何还不等于“已经得到环顺序”?
+3. 函数图剥的是入度而不是出度,原因是什么?
+
+<details><summary>自检答案</summary>
+
+环上点至少有两条环边;集合没有记录相邻次序;每点出度固定为 1,只有没有人指向的点不可能在任何有向环上。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:从一棵树加一条边,圈出产生的环。
+- 第 2 天:手做一次度数 1 剥叶队列。
+- 第 3 天:把箭头版函数图的入度写在每个点旁。
+- 第 4 天:运行互动程序,解释每个被删点为何不可能在环上。
+- 第 5 天:进入[剥叶找环与环上统计正式课](01-剥叶找环与环上统计.md)。

+ 2 - 0
课程/09-基环树/01-剥叶找环与环上统计.md

@@ -1,5 +1,7 @@
 # 基环树:剥叶找环、挂树大小与函数图计数
 
+> 先完成 [课前零食:树多长出一条边](00-课前零食-树多长出一条边.md),熟悉“先剥挂树、后看环”的视角。
+
 ## 本节目标
 
 识别无向连通 $n$ 点 $n$ 边图与每点出度 1 的函数图结构;用拓扑剥叶找出环,向环根汇总挂树信息,并完成环上独立决策。

+ 260 - 0
课程/10-高斯消元与CRT/00-课前零食-开关与错开的时钟.md

@@ -0,0 +1,260 @@
+# 课前零食:开关与错开的时钟
+
+> 这是一份双拼零食。前半杯问“很多开关怎样一起满足条件”,后半杯问“两个不同周期什么时候再次对齐”。它们表面不像,骨子里都在做一件事:保留约束、消掉多余自由度。
+
+## 本节目标
+
+学完后,你能:
+
+- 把“按一次算翻转”翻译成 XOR 线性方程;
+- 解释高斯消元的行变换为什么不改变解集,并用秩判断无解/多解;
+- 把同余 $x\equiv a\pmod m$ 读成一个按周期响的时钟;
+- 手推两个同余条件的合并,并知道非互质模数何时冲突。
+
+## 前置知识与课前自测
+
+前置:方程、模运算、XOR、最大公因数。
+
+1. 两次按同一个翻转开关后,灯的状态为什么回到原处?
+2. 方程 $x+y=1$ 与 $x+y=1$ 写两遍,会提供两条独立信息吗?
+3. $x\equiv1\pmod4$ 与 $x\equiv2\pmod6$ 有公共解吗?
+
+<details><summary>答案</summary>
+
+1. 在 XOR 世界中 $1\oplus1=0$。2. 不会,第二行完全重复。3. 没有;两边对 $\gcd(4,6)=2$ 的余数不同,分别是 1 和 0。
+
+</details>
+
+## 互动规则:每个符号都要翻译回一个约束
+
+这节分成两个工作台。开关台上,`x_i=0/1` 不是抽象字母,而是“第 i 个开关按/不按”;每一行方程是一盏灯的要求。时钟台上,`x≡a (mod m)` 不是公式,而是“从 a 开始每隔 m 出现一次”的时间点列。
+
+每次变形都要写一张收据:**变形前哪些答案可行,变形后是否恰好还是这些答案?**不能回答这句时,先不要做下一步消元或合并。
+
+## 从零搭框架:先枚举小世界,再把重复候选压成约束
+
+### 第 0 步:三个开关时,人脑能枚举什么
+
+令 `x1,x2,x3` 表示三个开关是否按。要求是:
+
+$$
+x_1\oplus x_2=1,\qquad x_2\oplus x_3=0.
+$$
+
+请列出 `000` 到 `111` 的八种按法,先筛第一条约束,再筛第二条。把留下的按法写出来。
+
+> 停顿 0:如果变成 30 个开关,枚举从哪里爆炸?两条方程里哪一个变量出现了两次、可能被消掉?
+
+<details><summary>展开:从候选表到行</summary>
+
+合法按法有 `100` 与 `011`。30 个开关有 $2^{30}$ 种候选,不能逐份测试。两式都含 `x2`,把两边同时 XOR 就得到 `x1 xor x3 = 1`,并没有列举任何新候选,却把共同变量消掉。
+
+</details>
+
+### 第 1 步:先验证“行 XOR 行”没有偷偷改题
+
+给第一行取一组满足它的值,给第二行也取同一组值。若两行都成立,把等式左右各 XOR 一次,结果一定成立吗?反过来,若第一行与“第一 XOR 第二行”成立,能否再 XOR 第一行恢复第二行?
+
+> 停顿 1:这两句构成了什么样的双向证明?为什么它比“消元看起来合理”更可靠?
+
+<details><summary>展开:初等行变换保留解集</summary>
+
+两行同时成立时 XOR 后成立;已知第一行和 XOR 后的行,再 XOR 第一行便恢复第二行。因此替换一行不会增解也不会漏解,是解集等价变换。交换两行也只是换顺序。消元每一步都应靠这样的可逆/等价理由站住。
+
+</details>
+
+### 第 2 步:让主元和秩从纸上的阶梯长出来
+
+把三行写成增广矩阵:
+
+```text
+1 1 0 | 1
+0 1 1 | 0
+1 0 1 | 1
+```
+
+先在第 1 列找一个 1 当主元,再用它 XOR 掉第 3 行第 1 列;随后处理第 2 列。每处理一列,在纸上圈出一个“首次独立约束”。
+
+> 停顿 2:最后若出现 `0 0 0 | 1`,它翻译成什么人话?若某列从头到尾没主元,哪个变量得到什么自由?
+
+<details><summary>展开:秩是独立约束数</summary>
+
+全 0 系数配 1 是 `0=1`,无解;没有主元的列对应可自由选择的变量。相容时,秩等于独立方程数;变量数减秩就是自由度。XOR 域中主元非零只能是 1,所以用整行 XOR 即可消去。
+
+</details>
+
+### 第 3 步:换到时钟台,先真的把时间点列出来
+
+写两行时间:
+
+```text
+x ≡ 1 (mod 4): 1, 5, 9, 13, ...
+x ≡ 3 (mod 6): 3, 9, 15, ...
+```
+
+请圈出第一次相交,再写出下一次相交。然后把第一个条件写成 `x=1+4t`,代入第二个条件。
+
+> 停顿 3:为什么不必继续从 0、1、2、3……逐个试?新变量 t 代表什么动作?
+
+<details><summary>展开:CRT 也在压缩候选表</summary>
+
+相交为 9、21,周期为 12。`t` 表示沿第一个时钟每跳一次 4 的次数;代入把“所有满足第一个条件的候选”压成一个关于 t 的同余。求 t 后就一次性得到共同候选,而不是枚举时间线。
+
+</details>
+
+### 第 4 步:非互质时先检查两只时钟能否对齐
+
+比较:
+
+$$
+x\equiv1\pmod4,\ x\equiv3\pmod6
+$$
+
+与:
+
+$$
+x\equiv1\pmod4,\ x\equiv2\pmod6.
+$$
+
+请分别把两边对 2 的余数写出来。为什么第一对有机会、第二对永远无机会?
+
+<details><summary>展开:gcd 是共同刻度</summary>
+
+模 4 与模 6 都按 2 的共同刻度重复。第一对余数均为 1,兼容;第二对一个为 1、一个为 0,冲突。一般先检查 $g=\gcd(m,n)$ 是否整除 $b-a$,通过后才能除掉共同因子、在互质模数下求逆。
+
+</details>
+
+### 第 5 步:写出两条框架主线
+
+| 工作台 | 原始暴力对象 | 新状态/摘要 | 合法变形 | 最终判断 |
+|---|---|---|---|---|
+| 开关 |  |  |  |  |
+| 时钟 |  |  |  |  |
+
+<details><summary>展开:它们都在维护解集合</summary>
+
+开关从 $2^n$ 赋值到矩阵行与主元,靠等价行变换,判断无解/秩/自由度;时钟从一长串候选时间到“余数 + 模数”,靠兼容合并,判断无解或新的周期。两者不必硬说成同一算法,但都要求每一步不改变可行解集合。
+
+</details>
+
+## 问题动机:枚举按法和枚举日期都长得很快
+
+有 $n$ 个开关时,“按/不按”已有 $2^n$ 种组合;有两个周期很大的时钟时,从 0 开始逐个试时间也可能要等很久。两类暴力都在重复探索同一个约束空间。我们要学会合并约束,而不是逐个候选试探。
+
+![XOR 方程用行变换消去变量](../插图/10-高斯消元与CRT/00-代数-01-XOR消元.svg)
+
+## 步步式引导
+
+### 1. 开关:把“是否按”当成变量
+
+令 $x_i\in\{0,1\}$ 表示第 $i$ 个开关是否按。某盏灯被若干开关影响,要求最终翻成目标状态,就是这些 $x_i$ 的 XOR 等于 0 或 1。例如:
+
+$$
+x_1\oplus x_2=1,\qquad x_2\oplus x_3=0.
+$$
+
+两条方程 XOR 在一起相当于“左右两边同时加同一式子”,解集不变,却能消掉共同变量。交换方程、用一行 XOR 另一行,都是不改变解集的合法行变换。
+
+```text
+row = 0
+for each column col:
+    find a row p >= row with matrix[p][col] = 1
+    if no p: this variable is free; continue
+    swap matrix[p], matrix[row]
+    for every other row r with matrix[r][col] = 1:
+        matrix[r] = matrix[r] XOR matrix[row]
+    row += 1
+rank = row
+```
+
+若得到 `0 = 1`,无解;若相容且秩等于变量数,每个变量被钉死;若秩较小,有自由变量。XOR 域里非零主元永远是 1,不需要除法。
+
+### 2. 时钟:先让一个周期负责移动
+
+要合并:
+
+$$
+x\equiv a\pmod m,\qquad x\equiv b\pmod n,
+$$
+
+从第一个条件出发,所有候选写成 $x=a+mt$。代入第二个条件,得到:
+
+$$
+mt\equiv b-a\pmod n.
+$$
+
+若 $g=\gcd(m,n)$ 不整除 $b-a$,两只时钟永远错开;若能整除,先除以 $g$,此时 $m/g$ 与 $n/g$ 互质,可用扩展欧几里得找到 $m/g$ 在模 $n/g$ 下的逆元,解出 $t$。
+
+![两个周期在兼容时重新对齐](../插图/10-高斯消元与CRT/00-代数-02-时钟对齐.svg)
+
+```text
+merge(a mod m, b mod n):
+    g = gcd(m, n)
+    if (b - a) % g != 0: return impossible
+    solve t = ((b-a)/g) * inverse(m/g mod n/g) mod (n/g)
+    x = a + m * t
+    return x mod lcm(m,n), lcm(m,n)
+```
+
+## 纸上模拟:两次消元、一次合并,都写出收据
+
+先对增广矩阵做两步:`R3 ^= R1`,再用第 2 行消去第 1、3 行的第 2 列。每步写下矩阵与“为什么解集不变”。然后合并 `1 mod 4` 与 `3 mod 6`:
+
+| CRT 阶段 | 式子 | 得到的信息 |
+|---|---|---|
+| 参数化 | `x=1+4t` |  |
+| 代入第二条件 |  |  |
+| gcd 兼容性 |  |  |
+| 解出 t |  |  |
+| 新余数/模数 |  |  |
+
+<details><summary>展开:不要省略兼容性这一行</summary>
+
+消元最后得到一条重复行,秩为 2、有一个自由变量。CRT 中 $1+4t≡3\pmod6$,即 $4t≡2\pmod6$;gcd 为 2 且整除 2,约为 $2t≡1\pmod3$,得 $t≡2\pmod3$,最小 $x=9$,新模数 $operatorname{lcm}(4,6)=12$。若兼容性失败,后面“求逆”根本没有许可。
+
+</details>
+
+反复合并就得到中国剩余定理(Chinese Remainder Theorem, CRT)的多条件版本。互质只是最常见的简化情况,不是非互质时可以假装不存在的细节。
+
+## 核心知识:每一步都要保留“解集合”
+
+高斯消元中,一行代表一个约束;合法行变换不增不减满足全部约束的变量赋值。CRT 中,一个同余代表一串等间隔候选;合并后返回的是同时满足两串候选的更稀疏序列。两者都能用“新表达和旧表达等价吗”检查正确性。
+
+XOR 消元若有 $e$ 条方程、$v$ 个变量,朴素复杂度约 $O(ev\min(e,v))$;位集优化可加速整行 XOR。逐步 CRT 的整数乘法可能溢出,模数累乘前要确认题目范围或使用更宽整数。
+
+## 实现指南
+
+- 明确数域:实数、模质数、XOR 的“加法/除法”规则不同,不能只换一个 `%`。
+- 扫描矛盾行时要看系数全 0 且常数非 0;不要仅凭 `rank` 猜无解。
+- CRT 合并后把余数规范到 `[0, modulus)`;负的 `%` 在 C++ 里需要修正。
+- 求逆前先确认互质;不存在逆元时,先按 gcd 约掉而不是硬做快速幂。
+
+## 互动小实验室
+
+运行 [开关与错开的时钟.py](../../互动程序/10-高斯消元与CRT/00-开关与错开的时钟.py)。选 `x` 会显示每次选主元和 XOR 消元后的矩阵;选 `c` 会逐步合并两组同余,并显示 gcd 兼容性检查。
+
+请先预测预设 CRT 中 $x\equiv1\pmod4, x\equiv3\pmod6$ 的最小非负解,再让程序验算每个余数;再把第二个余数改成 2,看看冲突在哪一步暴露。
+
+## 扩展与联系
+
+高斯消元会通向解空间、线性基和图的环空间;CRT 会通向周期事件、模方程和状态压缩。二者都只在相应代数结构中正确:模合数下不是每个非零数都有逆元,带负权图也不是线性方程——不要给每个困难问题都套一副代数眼镜。
+
+## 总结与自检
+
+1. XOR 消元为什么允许“某一行 XOR 到另一行”?
+2. 秩小于变量数且没有矛盾行,为什么会有多解?
+3. CRT 合并中 `(b-a) % gcd(m,n)` 检查到底比较了什么?
+
+<details><summary>自检答案</summary>
+
+对等式两边同时 XOR 同一式不改变真假;至少有一个变量没被独立约束固定;两个余数在共同周期单位上是否一致。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:写三变量 XOR 方程,手动 XOR 两行消掉一个变量。
+- 第 2 天:构造一行 `0=1`,辨认无解。
+- 第 3 天:在数轴上列出两个小模数的余数序列。
+- 第 4 天:运行互动程序,分别观察秩与 gcd 的作用。
+- 第 5 天:进入[高斯消元正式课](01-高斯消元秩解空间与XOR系统.md),再读 CRT 小节。

+ 2 - 0
课程/10-高斯消元与CRT/01-高斯消元秩解空间与XOR系统.md

@@ -1,5 +1,7 @@
 # 高斯消元:秩、解空间与不同数域
 
+> 建议先完成 [课前零食:开关与错开的时钟](00-课前零食-开关与错开的时钟.md),它从 XOR 开关和周期对齐引出本节两条主线。
+
 ## 本节目标
 
 能把开关/线性关系写成增广矩阵,在实数、有限域和 XOR 域中选择正确消元运算;判断无解、唯一解、多解,并由秩计算自由变量数。

+ 336 - 0
课程/11-平衡树/00-课前零食-从排好队到会分身的树.md

@@ -0,0 +1,336 @@
+# 课前零食:从排好队到会分身的树
+
+> 这一份零食专门给 FHQ Treap 准备。正式课一开口就是 `split/merge`,确实像有人把你带到乐高城堡前,只递来两块奇形零件说“拼吧”。今天我们从最笨的队伍开始,一块一块看它们为什么会出现。
+
+## 本节目标
+
+学完后,你能:
+
+- 比较无序数组、有序数组、普通 BST 在动态有序集合上的优缺点;
+- 同时说出 Treap 的 BST 键值不变量和随机优先级堆不变量;
+- 从“按键切开”和“把两段接回去”的需求发明 `split` 与 `merge`;
+- 手动追踪 `pull`、按值 `split`、`merge`、插入、删除一个重复值和第 $k$ 小;
+- 知道 FHQ Treap 给的是期望 $O(\log n)$,而非无条件的最坏界。
+
+## 前置知识与课前自测
+
+前置:数组、二分查找、递归、二叉树、随机数的基本直觉。
+
+1. 排好序的数组中查一个值很快,为什么在中间插入一个新值仍可能慢?
+2. 把递增键依次插入普通 BST,会长成什么形状?
+3. 一个节点若知道左右子树分别有多少元素,怎样判断第 $k$ 小在左、自己还是右?
+
+<details><summary>答案</summary>
+
+1. 后面元素都要整体右移,最坏 $O(n)$。2. 一条链,查找和插入退化到 $O(n)$。3. 设左子树大小为 `L`、本节点重复数为 `cnt`:$k\le L$ 去左;$L<k\le L+cnt$ 就是自己;否则去右并令 $k-=L+cnt$。
+
+</details>
+
+## 互动规则:今天先别急着相信树
+
+这节要用同一组数反复做实验:
+
+$$
+S=[4,2,7,5,2].
+$$
+
+请在纸上画四栏:`结构里存了什么`、`本次操作看了/搬了哪些元素`、`操作后仍必须成立的性质`、`下一步还缺什么`。每读到“停顿”时先停下来;不要把折叠区当成自动播放按钮。
+
+我们的贯穿任务是依次执行:插入 `6`、删除一个 `2`、求 `rank(6)`、求第 `3` 小。它们不是题目,而是用来观察结构是否真的在工作。你要反复问自己:**我现在维护的是一串数、一个顺序,还是一棵带摘要的树?**
+
+## 从零搭框架:先把人脑会做的事拆开
+
+### 第 0 步:把“有序集合”翻译成四种动作
+
+先不许说 Treap,也不许说 `split`。对一个能重复的整数集合,写下下面四句话各自真正需要的信息:
+
+| 动作 | 人脑最直接的做法 | 若没有顺序,最先卡在哪 |
+|---|---|---|
+| `insert(x)` | 把 `x` 放进某个容器 | 放哪儿才能仍有序? |
+| `erase_one(x)` | 找到一个 `x` 并删掉 | 怎么确认只删一个? |
+| `rank(x)` | 数有多少数严格小于 `x` | 要把所有数都看一遍吗? |
+| `kth(k)` | 排序后取第 `k` 格 | 每次都重新排序吗? |
+
+> 停顿 0:在 $S$ 中执行 `rank(6)`。请先圈出严格小于 6 的元素,并写下你看了几张“数卡”。
+
+<details><summary>展开:最暴力账本</summary>
+
+小于 6 的是 `4,2,5,2`,所以排名是 5(排名从 1 开始)。无序数组必须看 5 张卡;求第 3 小还要排序或反复找最小值。这里的第一个瓶颈不是“不会比较”,而是每次询问都把同一批大小关系重算了。
+
+</details>
+
+### 第 1 步:先试两种数组,让代价亲自出现
+
+**无序数组**把 `6` 直接追加,几乎不用移动;但 `rank(6)` 和 `kth(3)` 都要重新认识全体成员。**有序数组**先把 $S$ 写成:
+
+```text
+下标:  1  2  3  4  5
+数值:  2  2  4  5  7
+```
+
+现在插入 `6`。请在纸上画一个空格,并用箭头表示谁要右移。
+
+> 停顿 1:二分能很快找到 `6` 应在 `5` 和 `7` 之间。那为什么插入仍不是对数复杂度?这一步到底移动了哪几格?
+
+<details><summary>展开:找到位置不等于完成插入</summary>
+
+`6` 应放在第 5 格,原来的 `7` 必须从第 5 格搬到第 6 格。这个例子只搬 1 个元素;若插入最小值,后面所有元素都要右移。二分查找只解决“问哪里”,没有解决“给它腾位置”。
+
+数组实验给出第一个设计要求:**希望保留有序性,同时插入/删除只改少数局部连接,而不是搬一大段连续内存。**
+
+</details>
+
+### 第 2 步:把“左边更小、右边更大”画成路口
+
+二叉搜索树(Binary Search Tree, BST)把一个数放在节点上。对键 `4`,小于它的数永远向左,大于它的数永远向右;相同键先暂时记在同一张卡的 `count` 里。请按顺序插入 `4,2,7,5,2`,每一步只回答两个问题:
+
+1. 比较当前键后,下一步只能去左还是只能去右?
+2. 到空位置或相同键时,哪一项局部信息要改变?
+
+建议把每个节点画成卡片:
+
+```text
+key = 4     count = 1
+left = ?    right = ?
+```
+
+> 停顿 2:在你画出的 BST 中,找 `6` 会经过哪些键?插入第二个 `2` 时,为什么“另建一个同键节点”和“把 `count` 加一”都可行,但后续规则必须统一?
+
+<details><summary>展开:BST 已经省掉了什么</summary>
+
+找 `6` 会经过 `4→7→5`,最后落在 `5` 的右侧。BST 每次比较都能排除一整侧子树,所以若树矮,操作只需走一条根到叶路径。重复值若合并成 `count`,`size` 必须把 `count` 全算进去;若分开建节点,则每个节点贡献 1。两种账本都能用,混用时第 $k$ 小必错。
+
+</details>
+
+### 第 3 步:请你亲手把 BST 攻击成一条链
+
+重新从空树依次插入 `1,2,3,4,5,6`。不要跳步,画出每条新边。然后在表中填写:
+
+| 插入到 | 需要比较几次 | 新节点深度 | 树高 |
+|---|---:|---:|---:|
+| 1 | 0 | 0 | 0 |
+| 2 |  |  |  |
+| 3 |  |  |  |
+| 6 |  |  |  |
+
+> 停顿 3:这棵树和有序数组的“从左走到右”相比,真的躲开线性代价了吗?若想修复它,**绝不能**破坏哪一件事?
+
+<details><summary>展开:新的账本要求</summary>
+
+它变成向右的链;插入第 6 个数要比较 5 次,高度也是 5。我们必须保住 BST 的键值顺序,否则排名与二分路线都没了;但还要让“谁更靠近根”不再由输入先后顺序独裁。于是需要第二把、与键无关的尺子来决定树形。
+
+</details>
+
+### 第 4 步:给每张键卡发一张随机优先级卡
+
+现在用下面这组**固定优先级**做纸上实验,先别把它当随机数:
+
+| 键 `key` | 2 | 4 | 5 | 7 |
+|---|---:|---:|---:|---:|
+| 优先级 `priority` | 35 | 60 | 83 | 20 |
+
+约定“大优先级在上”。请先回答:最高优先级是谁?它的左边只能放哪些键,右边只能放哪些键?然后在左右两边重复同样问题。
+
+> 停顿 4:不看下方答案,画出整棵树,并在每条边旁写两句证词:一条关于键大小,一条关于优先级大小。
+
+<details><summary>展开:两条不变量第一次同时落地</summary>
+
+根是 `5/83`。左侧键为 `2,4`,其中优先级更高的 `4/60` 做左孩子,`2/35` 是它的左孩子;右侧是 `7/20`。中序读出 `2,4,5,7`,所以键值有序;每个父亲优先级更大,所以满足堆序。
+
+这不是“先按优先级排序再插入”的技巧,而是同时维护两条不变量:键决定中序,优先级决定根。随机发优先级时,递增键也不再必然做成一条链。
+
+</details>
+
+### 第 5 步:没有 `split` 时,插入到底在重复做什么
+
+想把 `6` 放进刚才的树。你当然可以沿 `5→7` 找位置,再想办法让优先级关系恢复;但请换一种描述:我们真正想要的是把旧集合切成“比 6 小”“等于 6”“比 6 大”三堆,处理中间那堆,再按原顺序接回去。
+
+这句话就是 FHQ Treap 的框架,不是代码技巧:
+
+```text
+输入集合
+    ↓ 按一个边界切开
+几块各自仍合法的树
+    ↓ 只处理目标块
+按键范围正确地接回
+```
+
+> 停顿 5:对边界 `x=4`,把上一步的树切成两棵。根 `5` 应去左树还是右树?既然根去右树,哪一侧子树完全不用递归?
+
+<details><summary>展开:这正是 split 的递归方向</summary>
+
+`5>4`,所以根和它的右子树(键都大于 5)一定属于右树;只有左子树可能混有 `≤4` 和 `>4` 的键,才需继续切。到 `4` 时,`4≤4`,它和左子树整体进入左树,继续切它的右子树。递归只沿一条可能跨边界的路径走。
+
+</details>
+
+## 问题动机:队伍既要有序,又要经常插队
+
+动态有序多重集常问:插入/删除一个数、某数排名、第 $k$ 小、前驱后继。三个朴素选项各有一处疼:
+
+| 结构 | 擅长 | 动态时的瓶颈 |
+|---|---|---|
+| 无序数组 | 末尾插入 | 排名、第 $k$ 小要扫描/排序 |
+| 有序数组 | 二分、按下标取数 | 中间插删要搬很多元素 |
+| 普通 BST | 按键走左/右 | 恶意或递增插入会变成链 |
+
+![有序数组插入需要搬动尾部](../插图/11-平衡树/00-Treap-01-数组与退化树.svg)
+
+我们希望像 BST 一样按键找位置,又希望树高不会总被输入顺序欺负。Treap 的做法非常俏皮:键 `key` 决定中序顺序,另发一张独立随机的优先级 `priority` 决定谁更靠近根。键管排队,优先级管谁坐树顶;两位领导分工,不抢麦。
+
+## 步步式引导
+
+### 1. 两条不变量如何同时存在
+
+Treap 同时满足:
+
+1. **BST 不变量**:左子树键不大于当前键,右子树键不小于当前键(重复值的精确约定必须全程统一);
+2. **堆不变量**:本讲取“大优先级在上”,父亲 `priority` 大于等于孩子。
+
+给每个键独立随机优先级后,键的中序顺序固定,树形等同于这些键按优先级建出的笛卡尔树(Cartesian tree)。随机优先级让任一键出现在很深位置的概率很小,所以树高与随机 BST 同阶,期望 $O(\log n)$。
+
+![键值保持中序、优先级决定树顶](../插图/11-平衡树/00-Treap-02-双不变量.svg)
+
+这里“随机”只影响复杂度,不影响集合内容正确性。若随机源很差或面对能预测优先级的对手,树仍可能长歪;别把“期望”听成“永远不会翻车”。
+
+### 2. 发明 `split`:按键把一棵树变成两棵
+
+设 `split(root, x)` 返回 `(a,b)`,其中 `a` 所有键 `<=x`,`b` 所有键 `>x`。观察根:
+
+- 若 `root.key<=x`,根和左子树必在 `a`;只需继续切右子树,把切出的左半接回 `root.right`;
+- 否则根和右子树必在 `b`;只需继续切左子树,把切出的右半接回 `root.left`。
+
+```text
+split(root, x):
+    if root is null: return (null, null)
+    if root.key <= x:
+        (middle, right) = split(root.right, x)
+        root.right = middle
+        pull(root)
+        return (root, right)
+    else:
+        (left, middle) = split(root.left, x)
+        root.left = middle
+        pull(root)
+        return (left, root)
+```
+
+你没有“重建”任何树,只改递归路径上的一条孩子指针。其余子树原封不动,BST 和堆性质仍成立。
+
+### 3. 发明 `merge`:已排好前后关系的两棵树接回去
+
+`merge(a,b)` 的前提是:`a` 所有键都不大于 `b` 所有键。谁应做根?优先级更高者。若 `a` 的根优先级更高,`a.left` 不动,只需把 `a.right` 和 `b` 继续合并;否则对称处理。
+
+![split 先切开,merge 再按优先级接回](../插图/11-平衡树/00-Treap-03-分裂合并.svg)
+
+```text
+merge(a, b):
+    if a is null: return b
+    if b is null: return a
+    if a.priority > b.priority:
+        a.right = merge(a.right, b)
+        pull(a)
+        return a
+    else:
+        b.left = merge(a, b.left)
+        pull(b)
+        return b
+
+pull(node):
+    node.size = size(node.left) + node.count + size(node.right)
+```
+
+`pull` 每次孩子变动后立刻重算。它很朴素,却是排名和第 $k$ 小不会悄悄坏掉的账房先生。
+
+### 4. 所有操作都变成“切、处理、接”
+
+为了清晰处理重复值,这里令一个节点有 `count` 个相同键。插入/删除键 $x$ 时,先切出 `<x`、`=x`、`>x` 三块:
+
+```text
+(less, at_least) = split(root, x - 1)
+(equal, greater) = split(at_least, x)
+
+insert x:  equal.count += 1, or equal = new_node(x)
+erase one x: if equal exists: equal.count -= 1; remove it if count becomes 0
+root = merge(less, merge(equal, greater))
+```
+
+正式课会采用“重复值各建一个节点”的统一写法;两种方案都可以,但不要把 `count` 的 `size` 公式和“每个节点只算 1”混在一起。
+
+## 纸上模拟:把“切、处理、接”写成可检查的流水线
+
+下面不许只在脑中说“切一下”。仍从中序多重集 `[2,2,4,5,7]` 开始,执行 `insert(6)`。每一格先自己填,再核对。
+
+| 阶段 | 你应写下的键集合 | 这一阶段允许改什么 | 需要保持什么 |
+|---|---|---|---|
+| 初始 |  | 无 | 中序有序、每个 `size` 正确 |
+| `split(root,5)` | `less` / `at_least` | 递归路径的孩子指针 | `less≤5<at_least` |
+| `split(at_least,6)` | `equal` / `greater` | 同上 | `equal=6`,其余大于 6 |
+| 新建节点 |  | `count` 或新节点 | 新节点是合法单点 Treap |
+| 两次 `merge` |  | 连接边与 `pull` | 左树键范围不越过右树 |
+
+<details><summary>展开:插入 6 的完整账本</summary>
+
+第一次切后 `less=[2,2,4,5]`、`at_least=[7]`;第二次切后 `equal=[]`、`greater=[7]`。新建 `6` 后,先合并 `6` 与 `[7]`,再合并 `[2,2,4,5]` 与结果。无论优先级使根具体长成什么样,中序必须是 `[2,2,4,5,6,7]`。每一处孩子指针改变后都调 `pull`,否则某个祖先仍误以为子树只有旧大小。
+
+</details>
+
+接着从 `[2,2,4,5,6,7]` 删除一个 `2`,并求第 3 小。请把这两个问题拆开:
+
+1. 删除一个 `2` 时,三次切分后的 `equal` 里应有多少个元素?`count` 从几变成几?
+2. 假设当前根为 `5`,左子树元素数为 3;第 3 小首先向哪边走?再假设左孩子为 `4`、它左子树大小为 2,第 3 小落在哪里?
+
+<details><summary>展开:别跳过 size 的含义</summary>
+
+`equal` 是一张 `key=2,count=2` 的卡;删一个后变成 `count=1`,不是把整张卡丢掉。第 3 小在根 `5` 处满足 $3\le left\_size$,先去左;在 `4` 处满足 $left\_size+1=3$,答案为 4。每次走向一侧,都是依据已被 `pull` 维护的“这一整块有多少元素”。
+
+</details>
+
+## 核心知识:每次递归都在维护哪些账
+
+`split` 的返回值满足键范围分界;`merge` 的输入满足整体键序;二者只让高优先级根处在上方,因此保留堆序。每次改孩子后 `pull`,所以 `size` 始终等于子树元素总数。于是沿 `left_size` 走第 $k$ 小时,丢弃的元素数恰好正确,不会漏也不会重。
+
+每个基本操作只沿一条期望对数高的递归路径走,故插入、删除、排名和第 $k$ 小期望 $O(\log n)$,空间 $O(n)$。若题目要求严格最坏 $O(\log n)$,要看红黑树、AVL 等确定性结构;FHQ Treap 不假装有这张证书。
+
+## 实现指南
+
+- 统一 `split` 边界:本篇是左边 `<=x`、右边 `>x`。若改成 `<x`/`>=x`,所有插删公式都要一起改。
+- 节点编号/指针为 0 或 `None` 时,`size(空)=0`;每次改完 `left/right` 后再 `pull`。
+- 随机优先级应使用质量较好的、独立的生成器;`rand()` 低位和固定可预测模式都不理想。
+- `kth(k)` 前检查 $1\le k\le size(root)$;递归深度在极端退化时可能危险。
+- 调试时打印中序序列(应有序)和每个节点优先级(父亲应不小于孩子);两条不变量分开查,定位快很多。
+
+## 互动小实验室
+
+运行 [从排好队到会分身的树.py](../../互动程序/11-平衡树/00-从排好队到会分身的树.py)。程序先要求你预测有序数组的搬动和优先级根,再使用固定种子的随机优先级构建同一棵树;因此每次树形一致、便于复盘。启动日志会插入一组小数并打印 `split/merge/pull`;之后可以输入:
+
+- `+ x` / `- x`:插入或删除一个整数;
+- `rank x`:查询严格小于 `x` 的元素数加一;
+- `k k`:查询第 `k` 小;
+- `show`:打印侧着的树、中序序列和每个节点的 `size`。
+
+建议先执行 `+ 6`,猜一猜它被切到哪一边;再 `- 2` 两次,观察重复值的 `count` 如何影响 `size`。树会“分身”只是指针改了归属,不会偷偷复制数据,放心玩。
+
+## 扩展与联系
+
+把 `split` 的依据从键改成子树大小,就得到下一节的隐式 Treap,可维护序列区间;给节点加 Lazy 标记可支持区间翻转。Splay 用旋转把最近访问点伸到根,适用思想不同;ODT 则依赖输入契约,不能当作平衡树的安全替代品。
+
+## 总结与自检
+
+1. `merge(a,b)` 为什么一定要先保证所有 `a` 的键不大于所有 `b` 的键?
+2. `split` 时为什么只递归到一侧孩子?
+3. `pull` 若漏调一次,第 $k$ 小为什么可能错误?
+4. 随机优先级改变的是正确性还是复杂度保证?
+
+<details><summary>自检答案</summary>
+
+否则接回去会破坏 BST 中序;根和一整侧子树的归属已由键比较确定;祖先的 `size` 不再等于真实元素数;它不改变有序集合内容,只影响树高的期望界。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:比较有序数组插入和递增 BST 的形状。
+- 第 2 天:给 4 个键随手发优先级,画出同时满足两条不变量的树。
+- 第 3 天:手做一次 `split(root, x)`,在每一步标注左右键范围。
+- 第 4 天:手做一次 `merge`,每次选择优先级更高的根。
+- 第 5 天:运行互动程序并做插删、排名、第 $k$ 小;随后进入[FHQ Treap 正式课](01-FHQ-Treap按值分裂与顺序统计.md)。

+ 234 - 0
课程/11-平衡树/00-课前零食-把一段话翻过来.md

@@ -0,0 +1,234 @@
+# 课前零食:把一段话翻过来
+
+> “文艺平衡树”是竞赛圈一个俏皮叫法:它通常不是指某一种额外发明的平衡条件,而是指把一段序列藏进平衡树的中序遍历,然后优雅地做翻转、剪贴、求和。值可以是字母、温度、角色台词;树真正维护的是它们的**位置关系**。今天我们就拿一句话开刀。
+
+## 本节目标
+
+学完后,你能:
+
+- 从数组翻转中段的搬动代价,提出“把序列表示成一棵树”的需求;
+- 区分按键有序树与隐式序列树:后者按中序位置而非元素值走路;
+- 用子树大小找到第 k 个位置,并说出它维护的状态语义;
+- 用两个哨兵和两次 Splay 把区间 [l,r] 暴露为一棵完整子树;
+- 解释 Lazy 翻转为何只交换左右孩子并翻转标记,不必立刻触碰全部后代。
+
+## 前置知识与课前自测
+
+前置:序列下标、二叉树中序、子树大小。若已完成 [让刚访问的点靠近你](00-课前零食-让刚访问的点靠近你.md),会更顺;还没完成也可先从本节的纸上实验理解模型。
+
+1. 数组中翻转长度 k 的一段,至少会触碰多少个位置?
+2. 序列 [7,2,7] 中,值 7 能唯一决定“第几个元素”吗?
+3. 一棵树若维护 left_size,怎样寻找中序第 k 个节点?
+
+<details><summary>先写答案:自测答案</summary>
+
+1. 通常要读写 O(k) 个位置。2. 不能,重复值会让“按值找位置”失效。3. k≤left_size 去左;k=left_size+1 就是当前点;否则去右,并令 k 减去 left_size+1。
+
+</details>
+
+## 互动规则:先把“文字”当成位置卡,而不是字符
+
+在纸上写一排卡片:
+
+~~~text
+A  B  C  D  E  F
+1  2  3  4  5  6
+~~~
+
+贯穿任务有三步:翻转区间 [2,5]、询问 [2,5] 的元素数、再翻转 [1,6]。纸上仍写四栏:**对象**(哪一段卡片)、**状态**(size、sum、rev)、**动作**(分出还是暴露哪一段)、**账本**(中序位置是否仍等于逻辑序列)。先猜结果,再看图或程序。
+
+“文艺”不在于把字母写得漂亮;它有趣在于,整段文本好像被一只手翻了过来,而程序只在根附近改了几条指针和一个小标签。
+
+## 从零搭框架:从笨拙的数组编辑到隐式位置
+
+### 第 0 步:先让数组亲自搬一次
+
+将 B,C,D,E 反转,得到 A,E,D,C,B,F。若还要每次询问区间和或最小值,数组可以预处理前缀;但一次中段翻转之后,许多前缀信息全失效,重新建表又要 O(n)。
+
+> 停顿 0:若连续做 reverse(2,5)、reverse(1,6)、reverse(3,4),数组里哪些位置会被反复触碰?你能指出被重复维护的“前缀账本”吗?
+
+<details><summary>展开:瓶颈不是不会 reverse,而是每次都铺开整段</summary>
+
+第一次至少触碰 B,C,D,E,第二次触碰全部六项;任何依赖原顺序的前缀摘要都要跟着更新。我们想让“一个连续区间”在结构中成为一个可整体拿起的对象,而非散落在数组许多格里。
+
+</details>
+
+### 第 1 步:不要让元素值充当树的键
+
+若把字母/数值当 BST 键,重复字母、相同分数会没有唯一位置;更糟的是,字典序与句子位置毫无关系。隐式平衡树只要求:**中序遍历的第 1、2、3……个节点,就是序列的第 1、2、3……项。**
+
+于是节点不必存排序键,但必须存:
+
+$$
+size[u]=size[left[u]]+1+size[right[u]].
+$$
+
+这份 size 就是“这张卡左边藏了多少张卡”的账本。
+
+![隐式键不是 value,而是中序位置](../插图/11-平衡树/00-文艺-01-隐式位置与size.svg)
+
+> 停顿 1:在一棵中序为 A,B,C,D,E,F 的树中,若节点 D 左子树 size 为 3,D 是第几项?若要找第 5 项,第一次比较 k 与什么数?
+
+<details><summary>展开:位置会随旋转变化,语义不会</summary>
+
+D 是第 4 项;找第 5 项先比较 5 与 left_size+1=4,所以去右并把 k 改为 1。树形如何旋转不重要,只要每次改孩子后 pull,逻辑中序位置就正确。
+
+</details>
+
+### 第 2 步:先用三段思想把目标隔离
+
+任何区间操作都可先在脑中切成:
+
+~~~text
+前缀 | 目标区间 | 后缀
+~~~
+
+FHQ Treap 会真的做两次 split;Splay 常用一个更戏剧化的动作:让目标区间变成某个节点的左子树。想取得 [l,r],需要一对恰好夹住它的边界:
+
+- 左边界:原序列第 l 项之前的节点;
+- 右边界:原序列第 r 项之后的节点。
+
+两端没有前驱或后继怎么办?给序列两侧各放一个永不参与答案的哨兵 L、R。完整中序变为 L,A,B,C,D,E,F,R;这不是多余装饰,而是把所有边界情况变成同一种情况。
+
+> 停顿 2:在带左哨兵的完整中序中,原区间 [2,5] 左边界的全树排名是多少?右边界之后的节点全树排名又是多少?
+
+<details><summary>展开:两次排名的偏移</summary>
+
+完整排名是 L=1,A=2,B=3,C=4,D=5,E=6,F=7,R=8。原 [2,5] 的左边界是 A,排名 l=2;右边界之后是 F,排名 r+2=7。记住“左边界取 l、右边界取 r+2”,就不用为 l=1 或 r=n 写特判。
+
+</details>
+
+### 第 3 步:让两道边界把区间夹成一棵子树
+
+令 x=kth(l),y=kth(r+2)。先把 x splay 到根,再把 y splay 到 x 的右儿子位置。因为中序中所有目标元素严格在 x 与 y 之间,y 的左子树恰好就是 [l,r]。
+
+![双哨兵把任何区间夹成后继的左子树](../插图/11-平衡树/00-文艺-02-双哨兵与区间暴露.svg)
+
+~~~text
+expose(l, r):
+    x = kth(l)          # 左哨兵已计入排名
+    splay(x, goal=null)
+    y = kth(r + 2)
+    splay(y, goal=x)
+    return left_child(y)
+~~~
+
+这叫区间暴露(interval exposure)。它解决的瓶颈是“连续区间在树上并不天然是一棵子树”;两次 Splay 后,目标变成一棵真的可整体操作的子树。
+
+## 问题动机:翻一整段,不想逐项翻
+
+得到目标子树 mid 后,最笨的办法是中序列出它、反转列表、逐个塞回。可反转一棵二叉树的中序,只需对每个节点交换左右孩子;若直接递归每个节点仍是 O(length)。Lazy 标记让我们先对根写一笔“这整棵树待翻转”,等以后真的走进孩子再下传。
+
+## 步步式引导:一个 rev 标签如何替一整段人跑腿
+
+### 1. 对一个子树根应用翻转
+
+~~~text
+apply_rev(u):
+    swap(left[u], right[u])
+    rev[u] = rev[u] XOR 1
+~~~
+
+交换左右会把该子树的中序整体倒过来;两次翻转应抵消,所以标签用 XOR/布尔取反复合。若节点维护 sum,翻转不改变和;若维护首字符、尾字符或方向敏感哈希,则更新规则必须跟着交换,不能只偷懒改一个 rev。
+
+> 停顿 3:对 A,B,C,D,E,F 暴露 [2,5] 后,只在 mid 根调用 apply_rev。此时若你立刻中序遍历、却从不 push,为什么可能读到旧顺序?什么时候必须把 rev 下传?
+
+<details><summary>展开:标记代表逻辑顺序,访问才兑现细节</summary>
+
+根交换儿子已让整棵 mid 的逻辑顺序反向,但孙辈可能还带着未兑现的方向。下一次要沿孩子指针找第 k 个、继续 split/splay 或输出时,应先 push,把父亲的翻转动作传给孩子。只读已由摘要正确维护的 size/sum 时不一定要展开所有后代。
+
+</details>
+
+### 2. 为什么 Splay 前要 push 祖先链
+
+Splay 会根据“x 是父亲左还是右儿子”决定旋转。若祖先带 rev,逻辑上的左/右与当前裸指针可能不同。于是从目标祖先到 x 的路径必须先自顶向下 push;这是文艺平衡树最容易出现的“明明旋对了,字符串却倒着”的 bug 来源。
+
+### 3. 文艺树不等于只能翻转
+
+目标子树一旦暴露,sum、min、max、hash、最大子段和都可作为摘要;区间加、赋值、翻转等 Lazy 也可组合,只是每种标记都要明确:
+
+1. 它作用于哪个摘要;
+2. 两个标记先后到来时如何复合;
+3. push 时对子树做什么。
+
+不能因为一棵树“文艺”就把任意操作混上去;例如区间排序会改变大量相对顺序,通常没有一个常数大小 Lazy 能表达。
+
+## 核心知识:序列语义、结构语义与摘要语义
+
+| 账本 | 维护什么 | 何时更新 |
+|---|---|---|
+| 中序语义 | 节点排列就是逻辑序列 | 旋转、split/merge 后仍须保住 |
+| size | 子树含多少位置 | 每次改孩子后 pull |
+| 摘要 | sum/min/hash 等区间答案 | pull 或 apply 标记时 |
+| rev | 这棵子树还欠一次方向翻转 | apply 时复合,走入孩子前 push |
+
+Splay 通过摊还 O(log n) 的两次伸展暴露区间;一次 apply_rev 为 O(1),所以翻转、区间查询的摊还复杂度通常为 O(log n)。FHQ Treap 以两次 split 加 merge 达到期望 O(log n),接口不同,核心都是“把连续区间变成独立子树”。
+
+## 实现指南
+
+- 两个哨兵也参与 size;若维护 sum,哨兵 value 取 0,输出时不把它们当正文。
+- 完整序列下 kth 的 k 从 1 开始;先固定“原序列下标从 1 开始”的约定,才不会把 l 与 l+1 混掉。
+- expose 后修改 mid,随后要从 y 向上 pull,至少更新 y 和 x;本节互动程序也会检查根摘要。
+- splay 前收集目标到 goal 之上的祖先链,倒序 push;只 push x 自己是不够的。
+- 翻转对 sum 是不变的,但对前后缀、哈希和最大字段常有方向;设计摘要前先写出反转后的变换公式。
+- 若只需静态区间和,Fenwick/线段树简单得多;文艺树的价值在于动态改变顺序。
+
+## 纸上模拟:把 B,C,D,E 夹住后翻过去
+
+完整中序是 L,A,B,C,D,E,F,R。对原 [2,5],先填写:
+
+| 项目 | 你应写下的内容 |
+|---|---|
+| x=kth(l) |  |
+| y=kth(r+2) |  |
+| y.left 的中序 |  |
+| apply_rev 后逻辑中序 |  |
+| 去掉哨兵后的最终序列 |  |
+
+<details><summary>展开:一次暴露的完整答案</summary>
+
+x=A,y=F,y.left 的中序为 B,C,D,E。对它 apply_rev 后为 E,D,C,B;全序列为 L,A,E,D,C,B,F,R,去掉哨兵即 A,E,D,C,B,F。注意 A 和 F 是夹子,不属于被翻转对象。
+
+</details>
+
+再做 reverse(1,6)。这次 x=L、y=R;它看起来像特殊边界,哨兵恰好让它与普通区间使用同一行 expose 伪代码。这就是哨兵最值得的地方:少的不是两行 if,而是一类容易错的思维分叉。
+
+## 互动小实验室
+
+运行 [把一段话翻过来.py](../../互动程序/11-平衡树/00-把一段话翻过来.py)。程序先请你预测 [2,5] 的边界 x、y,再用带双哨兵的隐式 Splay 暴露该区间。可输入:
+
+- show:打印当前正文、中序位置和哨兵位置;
+- rev 2 5:翻转一段;
+- sum 2 5:查询一段数字摘要;
+- expose 2 5:只展示两次 Splay 后 y.left 的区间;
+- check:拿普通列表作小规模验算,检查 size、sum、parent 与逻辑序列。
+
+先写下你认为 y.left 应是哪几张卡,再执行 expose。若输出与预期不同,优先检查排名是否把左哨兵算进去了。
+
+## 扩展与联系
+
+- “文艺平衡树”是竞赛语境的昵称;可以用 Splay、FHQ Treap、甚至 Rope 一类结构实现动态序列,关键是中序位置和区间切分。
+- [隐式 FHQ Treap 正式课](02-隐式FHQ-Treap序列操作.md)用 split/merge 暴露同一类区间;[Splay 正式课](03-Splay伸展与区间暴露.md)用双哨兵展示另一种接口。
+- 字符串哈希做区间翻转时,通常要同时维护正向与反向哈希;只维护一个方向会失去 O(1) 合并能力。
+- 若操作只有区间翻转、没有插删,某些题可用离线技巧或块状结构;先比较约束,别为了名字“文艺”而过度建树。
+
+## 总结与自检
+
+1. 为什么隐式序列树不能把 value 当 BST 键?
+2. 原区间 [l,r] 在双哨兵 Splay 中如何选择两道边界?
+3. rev 为什么要交换左右孩子并 XOR 标记?
+4. 哪些时机必须 push Lazy?
+
+<details><summary>自检答案</summary>
+
+重复值与位置无关;x 是 kth(l),y 是 kth(r+2),目标为 y.left;交换改变中序方向、XOR 表示两次抵消;走向孩子、按排名定位和旋转前的祖先链都必须兑现逻辑标记。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:用数组连续翻三段,记录哪些前缀信息反复失效。
+- 第 2 天:给一棵小树标 size,手找第 1、4、6 项。
+- 第 3 天:在 L,A,B,C,D,E,F,R 上手做一次 expose(2,5)。
+- 第 4 天:手做 apply_rev,并写下哪次访问需要 push。
+- 第 5 天:运行互动程序后进入[隐式 FHQ Treap](02-隐式FHQ-Treap序列操作.md)或[Splay 区间暴露](03-Splay伸展与区间暴露.md)。

+ 225 - 0
课程/11-平衡树/00-课前零食-把相同的一段装进一个盒子.md

@@ -0,0 +1,225 @@
+# 课前零食:把相同的一段装进一个盒子
+
+> ODT(Ordered Disjoint Tree,也常被戏称为 Chtholly Tree)很会抓住一种幸运时刻:数组里相同的值恰好连成了长段。它不承诺把任意区间操作都变成魔法;它只是把“许多格恰好一样”压进一张卡片。今天既要看它何时聪明,也要亲手把它弄到最狼狈。
+
+## 本节目标
+
+学完后,你能:
+
+- 从逐格数组观察出连续相同值可以压成一个区间段;
+- 用有序、互不重叠的段卡维护当前数组,并说清每张卡的语义;
+- 手动执行 split(pos),理解它只切边界、不改变任何数组值;
+- 用 split(l)、split(r+1) 精确取出一个操作区间,并模拟加法、赋值与第 k 小;
+- 区分 ODT 的正确性与复杂度保证:前者对任意输入成立,后者可能依赖随机操作或题目契约。
+
+## 前置知识与课前自测
+
+前置:数组下标、区间、排序、C++ 的有序集合 std::set 的直觉。暂时不需要会写迭代器。
+
+1. 数组 5,5,5,2,2,8 可以压成几张“相同值段卡”?
+2. 若一张卡代表 [3,7] 全为 4,位置 5 的值是多少?
+3. 为了让 [l,r] 恰好是完整卡片的并,为什么右边界应当在 r+1 处切,而不是 r 处切?
+
+<details><summary>先写答案:自测答案</summary>
+
+1. 三张: [1,3,5]、[4,5,2]、[6,6,8]。2. 是 4。3. 切 r+1 后,覆盖 r 的那张卡完整留在区间内;切 r 会把 r 错放到右侧。
+
+</details>
+
+## 互动规则:段卡不是数组的摘要魔法
+
+在纸上写初始数组:
+
+~~~text
+位置:1 2 3 4 5 6 7 8 9
+数值:1 1 1 4 4 2 2 2 2
+~~~
+
+把它压为三张段卡:
+
+~~~text
+[1,3,val=1]   [4,5,val=4]   [6,9,val=2]
+~~~
+
+纸上四栏仍是:**对象**(哪些段卡)、**状态**(每张 l,r,value)、**动作**(切、遍历、删、插)、**账本**(卡片覆盖是否刚好是 1..n、是否重叠)。贯穿任务是先 add(2,7,+3),再 assign(3,8,9),最后问 kth(1,9,5)。
+
+请把“ODT 很快”这句话暂时锁进抽屉。今天先确认它到底少做了哪一类工作,又在哪些数据上根本少不了。
+
+## 从零搭框架:先看见重复格子,再发明段卡
+
+### 第 0 步:逐格账本里重复了什么
+
+若要给 [6,9] 每格加 3,数组做法会访问四个格;若这四格本来同为 2,它们经历的是一模一样的动作。我们不妨只写一句“[6,9] 从 2 变为 5”。
+
+> 停顿 0:初始数组中若 query(1,9) 只问“值为 2 的元素有多少”,逐格看需要读几格?段卡要读几张卡?它省掉的是比较,还是重复保存的状态?
+
+<details><summary>展开:段数 k 才是 ODT 的真实工作量</summary>
+
+逐格看 9 格;段卡看 3 张,直接由 [6,9] 的长度知道有 4 个 2。它不是把每个数算得更快,而是让一串相同状态共用一份 value。以后一次操作触及多少,主要取决于覆盖了多少段 k,而不是区间长度。
+
+</details>
+
+### 第 1 步:给每张段卡写一份严格合同
+
+一张卡 (l,r,value) 的合同是:
+
+1. 它代表数组每个位置 i∈[l,r] 的值都为 value;
+2. 段卡按 l 递增,互不重叠;
+3. 所有段卡的并恰好覆盖 [1,n]。
+
+若相邻两张卡 value 相同,**可以**合并成更长段,得到“极大等值段”的更整洁表示。很多经典 ODT 实现的正确性并不强制每次加法后立刻合并相等邻段;它只要求每张卡内部等值、覆盖不乱。赋值会直接把一整段范围压为一张卡,才是常见性能来源。把“可合并的优化”误当成“每一步必须合并的不变量”,很容易写乱迭代器。
+
+![同值连续格子被一张段卡代表](../插图/11-平衡树/00-ODT-01-把数组压成段.svg)
+
+> 停顿 1:对 [4,5,val=4],若只把位置 5 改为 7,改后至少需要几张段卡?请写出每张的左右端点和值。
+
+<details><summary>展开:切开不是破坏数组</summary>
+
+需要 [4,4,4]、[5,5,7] 两张。段卡数量增加了,但每个位置的语义仍和逐格数组一致。ODT 的所有细节都围绕“先把边界切齐,再按完整卡片操作”。
+
+</details>
+
+### 第 2 步:发明 split(pos),只做一件小事
+
+要在 pos 开始操作,先找到包含 pos 的段 [l,r,v]:
+
+- 若 pos=l,边界已齐,直接返回这张卡;
+- 若 l<pos≤r,把它替换为 [l,pos-1,v] 与 [pos,r,v],返回右半;
+- 若 pos=n+1,允许把它理解成“尾后迭代器”,不新建任何卡。
+
+~~~text
+split(pos):
+    it = the segment [l,r,v] containing pos
+    if pos == l: return it
+    replace [l,r,v] by [l,pos-1,v] and [pos,r,v]
+    return iterator of [pos,r,v]
+~~~
+
+![在两道边界切开,中央就成为完整段的连续区间](../插图/11-平衡树/00-ODT-02-split切边界.svg)
+
+> 停顿 2:初始段卡上执行 split(2) 后会出现哪四张或几张卡?执行 split(8) 呢?每一次切开后,所有位置的数值有没有改变?
+
+<details><summary>展开:只多了边界,没有改数值</summary>
+
+split(2) 把 [1,3,1] 变成 [1,1,1]、[2,3,1];总共四张卡。split(8) 把 [6,9,2] 变成 [6,7,2]、[8,9,2]。切前切后逐项数组都仍是 1,1,1,4,4,2,2,2,2;split 只是让之后的区间端点能对齐。
+
+</details>
+
+### 第 3 步:两个 split 把 [l,r] 变成一段卡片范围
+
+先令 begin=split(l),再令 end=split(r+1)。此时从 begin 走到 end 之前,恰好是一串被完整覆盖的段卡:
+
+~~~text
+begin = split(l)
+end = split(r + 1)
+# [begin, end) 里的所有段,刚好拼成原数组 [l,r]
+~~~
+
+这比“判断每一张卡和区间相交多少”干净得多。加法可以逐卡改 value;赋值可删除 begin 到 end 的全部卡,再插入一张 [l,r,x];第 k 小则收集每张的 (value,length),按 value 排序并扣长度。
+
+## 问题动机:它为什么能处理一些线段树不喜欢的混合操作
+
+若每次询问都临时问区间第 k 小、临时幂次的幂和,常规线段树很难维护一个小而固定的摘要;逐元素则太慢。ODT 换研究对象:不直接按元素数 n 工作,而按当前段数 k 工作。区间赋值在很多随机过程里会抹掉一串旧断点、压缩 k,因此某些题故意把随机生成方式写进题面。
+
+但这不是“ODT 的 O(log n) 定理”。它的有序集合定位/切边界约 O(log k),之后仍要逐张访问覆盖的 k 段;k 可以是 n。
+
+## 步步式引导:同一框架下,正确性与复杂度分开验收
+
+### 1. 加法:逐段改 value,可能不减少段数
+
+对 add(l,r,x),切边界后遍历中央段,令 value+=x。每张段内所有位置都收到同一个加数,所以语义正确。可是不同段加同一个 x 后通常仍不同;段数不一定下降。
+
+### 2. 赋值:删除中央,插回一张统一段
+
+对 assign(l,r,x),先切边界,擦掉中央所有卡,再插入 [l,r,x]。这一步把可能很多段压缩为一张;若相邻卡也恰为 x,可选择再合并。它是 ODT 在随机大区间赋值里常常轻盈的原因。
+
+### 3. 构造它的坏日子
+
+从 1,2,3,…,n(每格不同)开始,重复做 add(1,n,1)。每次 n 个值仍互不相同,段数始终 Θ(n),每次都必须扫 Θ(n) 张卡。正确性一丝不差,复杂度却会退化。
+
+> 停顿 3:为什么“统一给全数组加 1”不能把 1,2,3,… 合并?请写出加后相邻两格的差。若改成 assign(1,n,0),段数怎样变化?
+
+<details><summary>展开:同一个动作,不同的势能</summary>
+
+加后变 2,3,4,…,相邻差仍为 1,所以没有相等邻段可合并;赋值后所有位置都为 0,中央 n 张卡直接变一张。ODT 的效率来自值与操作分布制造的压缩,不来自“区间”这两个字本身。
+
+</details>
+
+## 核心知识:三种说法不要混用
+
+| 说法 | 是否总成立 | 含义 |
+|---|---|---|
+| 段卡语义正确 | 是 | 每段内等值,段卡有序、不重、完整覆盖 |
+| split 不改数组 | 是 | 只把一张等值卡分成两张同值卡 |
+| 一次操作很快 | 不总是 | 取决于被访问段数、以及题目给出的随机/压缩契约 |
+
+若当前操作触及 k 张段卡,边界定位约 O(log k),遍历约 O(k),第 k 小额外有排序代价。最坏情况下 k=Θ(n)。所以做题前必须写清:输入是否随机生成?是否有频繁覆盖长区间的赋值?能否构造大量互异段?如果不能给出令人信服的段数控制,就把 ODT 当作实验工具,而非提交方案。
+
+## 实现指南
+
+- C++ 通常按 l 在 std::set 中排序;先找到含 pos 的段,再 split。
+- 必须先取 end=split(r+1),再取 begin=split(l),否则擦除时可能用到已失效迭代器。
+- 赋值后插入 [l,r,x];若自行实现相邻合并,先明确谁有资格被 erase,别在遍历时回头改已失效迭代器。
+- 第 k 小的 k 是按元素次数而非段数扣减:一段 [l,r,v] 的贡献是 r-l+1 个 v。
+- Python 实验室故意用列表保存段卡,便于看见切分;正式课才讨论 std::set 的迭代器纪律。
+- 对用户任意给出的操作序列,不要声称 ODT 有均摊对数保证。
+
+## 纸上模拟:先切齐,再给卡片动手术
+
+从 [1,3,1]、[4,5,4]、[6,9,2] 开始,执行 add(2,7,+3)。填写:
+
+| 阶段 | 段卡序列 | 哪些卡被实际改值 |
+|---|---|---|
+| split(2) 后 |  |  |
+| split(8) 后 |  |  |
+| 加 3 后 |  |  |
+
+<details><summary>展开:一份可检查的流水线</summary>
+
+split(2) 后有 [1,1,1]、[2,3,1]、[4,5,4]、[6,9,2];split(8) 后再把最后段切成 [6,7,2]、[8,9,2]。中央三张 [2,3,1]、[4,5,4]、[6,7,2] 分别改为值 4、7、5;两端 [1,1,1] 与 [8,9,2] 完全不动。逐项结果为 1,4,4,7,7,5,5,2,2。
+
+</details>
+
+接着 assign(3,8,9)。先预测它会删掉中央几张卡、再留下几张;再问 kth(1,9,5) 时,别把“第 5 张段卡”误当成“第 5 小元素”。
+
+## 互动小实验室
+
+运行 [把相同的一段装进一个盒子.py](../../互动程序/11-平衡树/00-把相同的一段装进一个盒子.py)。实验室使用可读的段卡列表,不是 OJ 的 std::set 代码;它会显示每次 split、被触及段数和普通数组验算。可输入:
+
+- show:查看段卡和展开后的数组;
+- split 2:只切一个边界,观察数值不变;
+- add 2 7 3:逐段加;
+- assign 3 8 9:删中央、插一张段;
+- kth 1 9 5:按段长度找第 5 小;
+- pow 1 9 2 97:按段计算幂和模 97;
+- attack:切换到全异值数组,体验一次全区间加必须扫很多段。
+
+先猜“这次会碰几张卡”,再执行命令;它比背 split 的四行代码更能告诉你 ODT 是否适合一题。
+
+## 扩展与联系
+
+- ODT 只借用了平衡树的有序集合来找断点;它不像 Segment Tree、Treap、Splay 那样维护固定的树形区间摘要。
+- [ODT 正式扩展课](04-高阶扩展-随机输入契约与ODT.md)会结合指定伪随机生成器,严格讨论何种题面事实支持期望复杂度。
+- “极大等值段”与“若干等值段”的表示都可语义正确;前者更直观,后者在某些实现中少一次合并操作。先固定不变量再写代码。
+- 若需要稳健的区间加、赋值、最值/和,Lazy Segment Tree 通常更可靠;若需动态顺序,考虑隐式 Treap/Splay;ODT 的独特优势来自段数小。
+
+## 总结与自检
+
+1. split(pos) 改变的是数组值还是段卡边界?
+2. 为什么取 [l,r] 应切 l 和 r+1?
+3. ODT 一次操作的主要代价与什么量有关?
+4. 为什么全异值数组反复全区间加是坏例子?
+
+<details><summary>自检答案</summary>
+
+只改边界;中央卡要完整覆盖到 r;与被遍历的段数 k 有关;相邻差不变,k 长期保持 Θ(n)。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:把三组小数组压成段卡,练习写覆盖合同。
+- 第 2 天:手做 split(1)、split(l)、split(n+1)。
+- 第 3 天:用两个 split 取出 [l,r],再做 add 与 assign。
+- 第 4 天:构造全异值坏例,并解释为何赋值反而压缩。
+- 第 5 天:运行互动程序后进入[ODT 正式扩展课](04-高阶扩展-随机输入契约与ODT.md)。

+ 217 - 0
课程/11-平衡树/00-课前零食-让刚访问的点靠近你.md

@@ -0,0 +1,217 @@
+# 课前零食:让刚访问的点靠近你
+
+> Splay 的口头禅很大胆:我不保证每一次都快,但我会把刚被你找过的节点拉到眼前。它不像 FHQ Treap 那样给每张卡发随机优先级,而是每次访问后认真整理桌面。今天先只学这件事:旋一下,到底保住了什么,又为什么要旋两下。
+
+## 本节目标
+
+学完后,你能:
+
+- 说明 BST 旋转为何不改变中序顺序;
+- 在纸上区分 zig、zig-zig、zig-zag 三种 Splay 步;
+- 手动把一个被访问的节点伸展(splay)到根,检查父子指针与子树大小;
+- 从查找、插入、删除三个朴素需求出发理解“访问后伸展”的接口;
+- 正确表述 Splay 的复杂度:单次可能线性,操作序列摊还 O(log n)。
+
+## 前置知识与课前自测
+
+前置:二叉搜索树、左右儿子、父指针、子树大小。还没有接触 Lazy 标记也可以。
+
+1. BST 的中序遍历为什么是升序?
+2. 把一条边的父子关系旋转后,节点本身的键会被交换吗?
+3. 若总是访问一棵链最深处的节点,一次操作的旋转次数可能是 O(n) 吗?
+
+<details><summary>先答再看:自测答案</summary>
+
+1. 每个节点左子树键更小、右子树键更大,递归展开即为升序。2. 不交换键,只改局部指针。3. 可能;Splay 承诺的是一串操作的平均账,而不是每次都给最坏对数界。
+
+</details>
+
+## 互动规则:每一次旋转都要写“中序证据”
+
+在纸上先画这棵普通 BST:
+
+~~~text
+        10
+       /  \
+      5    15
+     / \   / \
+    3   7 12 18
+~~~
+
+贯穿任务是依次做:查找 7、插入 6、删除 10。纸上分四栏:**对象**(当前节点和三块子树)、**状态**(parent、left、right、size)、**动作**(本轮绕谁旋转)、**账本**(中序和父指针是否仍正确)。每次旋转后,把中序写一遍;这有点笨,但能抓到大多数 Splay 第一版的幽灵 bug。
+
+## 从零搭框架:先让“常访问”变成一个真实问题
+
+### 第 0 步:BST 已经会找路,但它不记得你常走哪条路
+
+从根查找 7:比较 7 与 10,向左;比较 7 与 5,向右;找到 7。若接下来又查 7、又查 7,每次仍要走同样两条边。对很深的节点,重复走路比比较本身更烦。
+
+> 停顿 0:如果现在连续查找 7 五次,普通 BST 会总共经过哪些节点?哪一段路径被反复走了五遍?
+
+<details><summary>展开:我们想改变的是树形,不是键的顺序</summary>
+
+每次都经过 10→5→7。把 7 放近根能减少以后访问它的路径;但不能直接把数值 7 与 10 交换,否则 BST 的中序顺序会坏。我们只能重新接局部边,同时保住所有键的左小右大关系。
+
+</details>
+
+### 第 1 步:只旋一条边,先证明中序没变
+
+设 x 是 p 的右儿子,x 的左子树叫 B;p 的左子树叫 A,x 的右子树叫 C。对 p 做一次左旋,x 上来、p 下去,B 从 x 的左边转为 p 的右边:
+
+~~~text
+旋前:          旋后:
+    p              x
+   / \            / \
+  A   x    →     p   C
+     / \        / \
+    B   C      A   B
+~~~
+
+因为 A < p < B < x < C,旋前旋后中序都是 A,p,B,x,C。
+
+![一次旋转只改局部边,却保留中序](../插图/11-平衡树/00-Splay-01-旋转不改中序.svg)
+
+> 停顿 1:若 x 是 p 的左儿子,应该用左旋还是右旋?B 在旋转后应该挂到 p 的哪一侧?请用不等式检查,而不要靠镜像记忆。
+
+<details><summary>展开:镜像规则从键范围推出</summary>
+
+x 为左儿子时做右旋;x 的右子树 B 比 x 大、比 p 小,所以旋后 B 成为 p 的左子树。真正要记的是 B 的键范围,而不是“图向哪边倒”。
+
+</details>
+
+### 第 2 步:只把 x 一次次旋上去,哪里不够好
+
+假设 x、父亲 p、祖父 g 连成同向链,例如 x 是 p 的左儿子且 p 是 g 的左儿子。若只旋 x 一次,p 会被拉下,但长链的整体形状没有得到充分压缩。Splay 的规定是:先旋 p 绕 g,再旋 x 绕 p;这叫 zig-zig。
+
+若 x 和 p 方向相反,例如 x 是 p 的右儿子、p 是 g 的左儿子,则连续旋 x 两次;这叫 zig-zag。若 p 就是根,只剩一次 zig。
+
+![三种局部情形,不是三段咒语](../插图/11-平衡树/00-Splay-02-zig的三种节奏.svg)
+
+> 停顿 2:在初始树中,x=7、p=5、g=10。它属于哪一种?第一转绕谁,第二转绕谁?请画出两次后的根。
+
+<details><summary>展开:7 是 zig-zag</summary>
+
+7 是 5 的右儿子,而 5 是 10 的左儿子,方向相反。先让 7 绕 5 左旋,再让 7 绕 10 右旋,7 成为根。中序仍为 3,5,7,10,12,15,18;只是以后访问 7 不必再经过 10、5。
+
+</details>
+
+### 第 3 步:把三种情形写成一个循环
+
+Splay 的目标可以是根,也可以是某个指定父亲 goal。后一种接口会在“文艺平衡树”中暴露区间:先把左边界伸到根,再把右边界伸到它的右儿子位置。
+
+~~~text
+splay(x, goal):
+    while parent[x] is not goal:
+        p = parent[x]
+        g = parent[p]
+        if g is goal:
+            rotate(x)                         # zig
+        else if x and p are both left children,
+             or both right children:
+            rotate(p); rotate(x)              # zig-zig
+        else:
+            rotate(x); rotate(x)              # zig-zag
+~~~
+
+每次 rotate 前后,都要更新被移动的两个节点的 size;若以后加了翻转等 Lazy 标记,还要先从祖先到 x 依次 push,免得逻辑左右与指针左右不一致。
+
+## 问题动机:访问并不总是随机的
+
+红黑树、AVL 等结构每次都努力保持严格高度界;Splay 换了一个愿望:最近访问的点、相近键或经常使用的区间,应该逐渐靠近根。它的优势不是“每次比别人少一转”,而是允许一次访问花较多旋转,换取随后很多次访问变便宜。
+
+因此绝不能把“查找 7 后 7 在根”误解为一个小优化;它是所有操作完成后统一执行的自调整规则。
+
+## 步步式引导:查、插、删都先把对象拉到手边
+
+### 1. 查找:即使没找到,最后访问点也有价值
+
+按 BST 规则走到 x 或空指针。若找到了 x,就 splay(x, null)。若没找到,最后停在某个叶边附近,也可把最后访问的节点伸根;它正是新键将来插入的邻居。
+
+### 2. 插入:先按 BST 放入,再伸到根
+
+最直白的入门实现:普通 BST 插入新叶 x,设置 parent,再 splay(x, null)。这样根必是 x,左右子树天然分别存比 x 小和比 x 大的键。重复键的处理需统一:可存 count,也可规定同键向一侧走;本节实验室用无重复集合,避免把两个变量混在一起。
+
+### 3. 删除:先把待删点变成根
+
+找到 x 并 splay 到根,断开左树 L 和右树 R。若 L 非空,取 L 的最大节点 m;在 L 内把 m splay 到根,它不会有右儿子,再把 R 挂为它的右儿子。若 L 为空,R 直接成为新根。
+
+> 停顿 3:为什么“左树最大点”没有右儿子?把 R 接到它右边后,BST 键范围为什么仍正确?
+
+<details><summary>展开:删除不是把两个任意根硬粘</summary>
+
+L 中没有比最大点 m 更大的键,所以 m 没有右儿子。L 的所有键都小于 x,R 的所有键都大于 x,也就都大于 m;将 R 作为 m 的右树既保持中序,也不遗漏任何节点。
+
+</details>
+
+## 核心知识:正确性和复杂度的边界
+
+旋转只改变 A,p,B,x,C 五块的连接,键范围证明保证中序不变;连续旋转因此始终保留 BST。每次 splay 循环都让 x 向目标父亲靠近,最终停止。若维护 size,每次改变孩子后由下而上 pull,size 仍等于左右子树节点数加一。
+
+Splay 的访问引理以子树大小定义势能,说明任意 m 次操作总时间为 O(m log n) 的量级(更精细表述可包含初末势能项)。单次访问一条长度 n 的链仍可能 O(n)。所以它适合摊还保证,不能拿来应对题面明确要求每次最坏 O(log n) 的接口承诺。
+
+## 实现指南
+
+- rotate(x) 前要知道 p、g,以及 x 是 p 的左还是右;修改 g 到 p 的那条边时不要漏掉“p 原来是根”的情况。
+- 每次改 child 都同步 child.parent;根的 parent 必须是 null/0。
+- 推荐写 is_right(x) 只比较 parent[x].right 是否等于 x;不要靠键值猜方向。
+- pull 的顺序是先 pull 被拉下去的 p,再 pull 上来的 x。
+- 若有 Lazy,先把从根到 x 的祖先链收集起来,自顶向下 push;否则 kth 和区间暴露会在反转后的逻辑序列上走错。
+- 递归深度不是 Splay 的实现需求,旋转版通常更适合避免深链递归。
+
+## 纸上模拟:把 7 伸上来,不跳过任何一条指针
+
+从初始 BST 查到 7 后,填表:
+
+| 阶段 | x 的父亲 | x 的祖父 | 情形 | 本轮后根 | 必须 pull 谁 |
+|---|---|---|---|---|---|
+| 开始 |  |  |  |  |  |
+| 第一次旋转后 |  |  |  |  |  |
+| 第二次旋转后 |  |  |  |  |  |
+
+<details><summary>展开:一份最小可检查轨迹</summary>
+
+开始时 p=5、g=10,是 zig-zag。第一次 rotate(7) 后,7 成为 10 的左儿子、5 成为 7 的左儿子;第二次 rotate(7) 后,7 为根、10 为右儿子。每次先 pull 被放低的节点,再 pull 7;最后中序和原来完全相同。
+
+</details>
+
+再自己插入 6:普通 BST 会把它放在 5 的右侧、7 的左侧。把 6 splay 到根之前,先预测它会经历 zig-zag 还是 zig-zig;若你能根据左右方向而非死记答案判断,就已经握住 Splay 的骨头了。
+
+## 互动小实验室
+
+运行 [让刚访问的点靠近你.py](../../互动程序/11-平衡树/00-让刚访问的点靠近你.py)。它从同一棵小 BST 出发,先请你预测查找 7 的两次旋转,再打印每个 zig、zig-zig、zig-zag。可输入:
+
+- find 7:查找并把最后访问点伸到根;
+- add 6:插入后伸展新点;
+- del 10:删除一个键,观察两棵子树如何重新接回;
+- show:查看侧视树、中序和 size;
+- check:检查 parent、BST、中序、size 四份账本。
+
+建议先执行 find 7,再 show;然后在纸上预测 add 6 的第一个祖孙形状。树会动,键的排序不会动,这正是游戏规则。
+
+## 扩展与联系
+
+- FHQ Treap 也维护有序集合,但靠随机优先级和 split/merge;Splay 靠访问后的旋转,两者都不应被简化成“另一个模板”。
+- 双哨兵、按排名 kth、区间翻转会把 Splay 变成序列结构;下一份零食会从一句话如何倒过来开始。
+- Splay 的动态最优性猜想很有名,但不需要把它当作已证明事实;本课只使用已知的摊还对数访问界。
+- 若操作完全离线、没有动态插删,数组、Fenwick 或线段树常更简单;Splay 不是“能转就该转”。
+
+## 总结与自检
+
+1. 一次旋转为什么不改变 BST 的中序?
+2. zig-zig 与 zig-zag 的判断依据是什么?
+3. 删除根时为何可用左树最大点接上右树?
+4. Splay 的 O(log n) 是单次还是摊还结论?
+
+<details><summary>自检答案</summary>
+
+靠三块子树的键范围重接;看 x 与父亲相对方向是否相同;它没有右儿子且右树全比它大;是整个操作序列的摊还结论。
+
+</details>
+
+## 一周预热安排
+
+- 第 1 天:画一次左旋、一次右旋,并在两图下写同一份中序。
+- 第 2 天:对三种祖孙关系分别标出旋转顺序。
+- 第 3 天:手做 find 7 和 add 6,检查 parent 与 size。
+- 第 4 天:运行互动程序,再用 check 追一遍四份账本。
+- 第 5 天:先完成[把一段话翻过来](00-课前零食-把一段话翻过来.md),再进入[Splay 正式课](03-Splay伸展与区间暴露.md)。

+ 2 - 0
课程/11-平衡树/01-FHQ-Treap按值分裂与顺序统计.md

@@ -1,5 +1,7 @@
 # FHQ Treap:按值分裂、合并与顺序统计
 
+> 若 `split/merge` 像两块突然递来的乐高,先完成 [课前零食:从排好队到会分身的树](00-课前零食-从排好队到会分身的树.md)。其中从有序数组、普通 BST 一直走到 `pull`、分裂与合并,并配有可操作实验室。
+
 ## 本节目标
 
 掌握无旋 Treap(FHQ Treap)的两条原语 `split/merge`,维护动态有序多重集的插入、删除、第 k 小、排名及极值删除。

+ 6 - 0
课程/11-平衡树/02-隐式FHQ-Treap序列操作.md

@@ -1,5 +1,7 @@
 # 隐式 FHQ Treap:按排名分裂、区间翻转与剪贴
 
+> 若“值”和“位置”还容易混,先完成 [课前零食:把一段话翻过来](00-课前零食-把一段话翻过来.md)。它用同一段文字分别预演隐式下标、双哨兵和翻转 Lazy;本节换成 FHQ 的 split/merge 接口。
+
 ## 本节目标
 
 把中序位置而非键作为顺序,使用子树大小按前 k 项分裂;维护翻转 Lazy,并完成区间反转、剪切和拼接。
@@ -31,6 +33,10 @@ prefix, mid = split(prefix_and_mid, l-1)
 
 对 `mid` 打标记,再按原顺序 merge 回去。
 
+![两次按排名 split 把区间独立成 mid](../插图/11-平衡树/02-隐式Treap-01-三段分裂.svg)
+
+![翻转标记如何延迟到真正访问子树时再下传](../插图/11-平衡树/02-隐式Treap-02-rev标记.svg)
+
 ## 实现指南
 
 - split/merge 进入节点前先 push Lazy,否则逻辑位置与儿子指针不一致。

+ 6 - 0
课程/11-平衡树/03-Splay伸展与区间暴露.md

@@ -1,5 +1,7 @@
 # Splay:伸展、势能与双哨兵区间暴露
 
+> 建议先完成 [让刚访问的点靠近你](00-课前零食-让刚访问的点靠近你.md),再用 [把一段话翻过来](00-课前零食-把一段话翻过来.md) 预演双哨兵和 Lazy。前者从旋转的中序证据出发,后者把区间真的夹成一棵子树。
+
 ## 本节目标
 
 理解 Splay 通过 zig/zig-zig/zig-zag 把访问点伸到目标父亲,掌握按值有序集与按排名序列两种模式,并用双哨兵把任意区间暴露为一棵子树。
@@ -30,8 +32,12 @@ Splay 不维护颜色/随机优先级。每次访问后把目标伸到根或指
 
 它保持 BST 中序不变,并把近期访问点移近根。Sleator–Tarjan 势能分析给出摊还对数,但不能声称每次最坏对数。
 
+![zig、zig-zig、zig-zag 的旋转顺序](../插图/11-平衡树/03-Splay-01-三种旋转.svg)
+
 隐式序列中加入左右哨兵。要暴露原区间 $[l,r]$:把前驱(全树第 l)伸到根,再把后继(全树第 r+2)伸成前驱右儿子;后继的左子树恰是目标区间。
 
+![双哨兵如何把目标区间暴露为一棵子树](../插图/11-平衡树/03-Splay-02-双哨兵暴露.svg)
+
 ## 实现指南
 
 - 旋转前把从目标祖先到目标的 Lazy 全部 push,避免按逻辑排名旋错。

+ 6 - 0
课程/11-平衡树/04-高阶扩展-随机输入契约与ODT.md

@@ -1,5 +1,7 @@
 # 高阶扩展:随机输入契约与有序区间树
 
+> 建议先完成 [课前零食:把相同的一段装进一个盒子](00-课前零食-把相同的一段装进一个盒子.md)。它从段卡、split 边界和一个故意构造的坏例子出发,避免把 ODT 误当成万能的均摊对数结构。
+
 ## 本节目标
 
 理解有序区间树(Ordered Disjoint Tree, ODT/Chtholly Tree)的真实适用条件:它维护等值段,依靠随机数据中的区间赋值压缩段数;能说明其期望性能来源,也能构造对抗数据证明它不是通用平衡树模板。
@@ -24,6 +26,8 @@
 
 四类操作无法由常规固定摘要自然统一;题面把操作全部随机生成,是在邀请我们把“等值段数量的期望受控”纳入复杂度分析,而不是寻找一个虚构的最坏情况对数模板。
 
+![先在两端切齐,中央才是可直接遍历的段集合](../插图/11-平衡树/04-ODT-01-切出区间.svg)
+
 ## 精选例题:Willem, Chtholly and Seniorious
 
 ### 来源与题意
@@ -62,6 +66,8 @@ ODT 维护当前等值段。统一切出 `[l,r]` 后:
 
 确定性最坏复杂度仍可达 $O(nm)$:交替制造大量单点不同值,再查询整段即可扫描 $\Theta(n)$ 段。这里通过的依据是题目指定的伪随机生成过程与大量随机区间赋值带来的期望段数控制,不可写成“ODT 保证均摊 $O(\log n)$”。
 
+![随机赋值的压缩与全异值坏例属于两种不同复杂度世界](../插图/11-平衡树/04-ODT-02-随机契约与反例.svg)
+
 ## 实现指南、核心伪代码与附件
 
 ```text

+ 12 - 11
课程/README.md

@@ -8,23 +8,24 @@
 - 每篇讲义都有“尝试路线”和“直接学习路线”。没有时间独立做例题时,也应完成约束翻译、错误路线辨析、关键观察与代码走读。
 - 正式例题以 VJudge 为统一入口,同时保留原题来源。题面事实来自 `problems/` 中保存的 MHTML;正文只作自包含中文转述。
 - 正文只保留类 Python 核心伪代码;完整 C++ 放在仓库根目录 `参考实现/` 作为独立附件,并由统一脚本逐文件编译。
+- 每个算法主题的 `00-课前零食` 是正式课前约一周的必修预热:不用 OJ 题,改用“对象—状态—动作—账本”的纸上搭架、小例逐轮模拟、SVG 图示和无需第三方库的 Python 小互动程序。先预测、再展开、最后运行程序;运行方式见[互动程序说明](../互动程序/README.md)。跨度特别大的圆方树、Kruskal 重构树、Splay、文艺平衡树和 ODT 另设专项零食,按下表插入相应正式课之前。
 - `完成` 只表示结构、来源、代码编译、可执行样例/对拍和链接均已检查;其余状态不能视为成品。
 
 ## 建议顺序与状态
 
 | 顺序 | 主题与子主题 | 真正前置 | 状态 |
 |---|---|---|---|
-| 01 | [欧拉路](01-欧拉路径/01-判定构造与建模.md):判定、Hierholzer、字符串拼接、边定向、掩码图欧拉回路 | DFS、度数、连通性 | 完成(共 2 节) |
-| 02 | [强连通与双连通](02-强连通与双连通/01-强连通分量与缩点.md):SCC、缩点、桥、割点、边双/点双 | DFS、栈、树边与返祖边 | 完成(共 3 节) |
-| 03 | [Bitset](03-Bitset/01-字级并行集合模型与动态匹配.md):字级并行、集合运算、可达性、补集子掩码隐式图 | 位运算、复杂度估算 | 完成(共 2 节) |
-| 04 | [KMP 与 Manacher](04-KMP与Manacher/01-KMP失配边界周期与差分建模.md):失配、border/周期、回文半径与计数 | 字符串下标、前缀思想 | 完成(共 2 节) |
-| 05 | [最短路与类最短路](05-最短路与类最短路/01-建图松弛与01状态图.md):模型选择、状态扩展、周期内进度、差分约束 | 基础图论、堆、松弛 | 完成(共 4 节) |
-| 06 | [最小/大生成树](06-最小与最大生成树/01-割性质与隐式完全图建模.md):边分类、替换边、关联图最大森林、同步缩点 | 并查集、树上倍增 | 完成(共 4 节) |
-| 07 | [二分图](07-二分图/01-二分染色与构造选择.md):染色、匹配、Kőnig 定理、关联图欧拉平衡 | BFS/DFS、增广思想 | 完成(共 4 节) |
-| 08 | [高级 Segment Tree / Fenwick Tree](08-高级线段树与树状数组/01-Fenwick离线计数与序关系.md):离线计数、动态开点、合并/分裂、可持久化、Beats | 已掌握基础树状数组与 Lazy Segment Tree | 完成(共 5 节) |
-| 09 | [基环树](09-基环树/01-剥叶找环与环上统计.md):找环、剥叶、删环边直径、函数图代数构造 | 树形 DP、拓扑剥叶 | 完成(共 2 节) |
-| 10 | [高斯消元与 CRT](10-高斯消元与CRT/01-高斯消元秩解空间与XOR系统.md):有限域/XOR、零空间覆盖、环空间、非互质周期压缩 | 模运算、扩展欧几里得 | 完成(共 3 节) |
-| 11 | [平衡树](11-平衡树/01-FHQ-Treap按值分裂与顺序统计.md):FHQ Treap、Splay、随机输入契约与 ODT | BST、随机化、Lazy 标记 | 完成(共 4 节) |
+| 01 | [欧拉路径零食](01-欧拉路径/00-课前零食-从散步到欧拉回路.md) → [欧拉路](01-欧拉路径/01-判定构造与建模.md):判定、Hierholzer、字符串拼接、边定向、掩码图欧拉回路 | DFS、度数、连通性 | 完成(共 3 节,含零食) |
+| 02 | [强/双连通零食](02-强连通与双连通/00-课前零食-从互相绕路到缩点.md) → [SCC/桥/割点基础](02-强连通与双连通/01-强连通分量与缩点.md) → [圆方树零食](02-强连通与双连通/00-课前零食-从共享房间到圆方树.md) → [点双/圆方树入口](02-强连通与双连通/03-割点点双连通与圆方树入口.md):SCC、缩点、桥、割点、边双/点双 | DFS、栈、树边与返祖边 | 完成(共 5 节,含零食) |
+| 03 | [Bitset 零食](03-Bitset/00-课前零食-把64个开关装进一个格子.md) → [Bitset](03-Bitset/01-字级并行集合模型与动态匹配.md):字级并行、集合运算、可达性、补集子掩码隐式图 | 位运算、复杂度估算 | 完成(共 3 节,含零食) |
+| 04 | [KMP/Manacher 零食](04-KMP与Manacher/00-课前零食-不从头再看的字符串.md) → [KMP 与 Manacher](04-KMP与Manacher/01-KMP失配边界周期与差分建模.md):失配、border/周期、回文半径与计数 | 字符串下标、前缀思想 | 完成(共 3 节,含零食) |
+| 05 | [最短路零食](05-最短路与类最短路/00-课前零食-从乱走到松弛.md) → [最短路与类最短路](05-最短路与类最短路/01-建图松弛与01状态图.md):模型选择、状态扩展、周期内进度、差分约束 | 基础图论、堆、松弛 | 完成(共 5 节,含零食) |
+| 06 | [生成树零食](06-最小与最大生成树/00-课前零食-只修够用的路.md) → [最小/大生成树](06-最小与最大生成树/01-割性质与隐式完全图建模.md) → [Kruskal 重构树零食](06-最小与最大生成树/00-课前零食-把合并史写成树.md) → [重构树与 XOR-MST](06-最小与最大生成树/03-Kruskal重构树与XORMST.md):边分类、替换边、关联图最大森林、同步缩点 | 并查集、树上倍增 | 完成(共 6 节,含零食) |
+| 07 | [二分图零食](07-二分图/00-课前零食-两支队伍与奇怪的圈.md) → [二分图](07-二分图/01-二分染色与构造选择.md):染色、匹配、Kőnig 定理、关联图欧拉平衡 | BFS/DFS、增广思想 | 完成(共 5 节,含零食) |
+| 08 | [Segment/Fenwick 零食](08-高级线段树与树状数组/00-课前零食-会变化的区间和.md) → [高级 Segment Tree / Fenwick Tree](08-高级线段树与树状数组/01-Fenwick离线计数与序关系.md):离线计数、动态开点、合并/分裂、可持久化、Beats | 已掌握基础树状数组与 Lazy Segment Tree | 完成(共 6 节,含零食) |
+| 09 | [基环树零食](09-基环树/00-课前零食-树多长出一条边.md) → [基环树](09-基环树/01-剥叶找环与环上统计.md):找环、剥叶、删环边直径、函数图代数构造 | 树形 DP、拓扑剥叶 | 完成(共 3 节,含零食) |
+| 10 | [高斯/CRT 零食](10-高斯消元与CRT/00-课前零食-开关与错开的时钟.md) → [高斯消元与 CRT](10-高斯消元与CRT/01-高斯消元秩解空间与XOR系统.md):有限域/XOR、零空间覆盖、环空间、非互质周期压缩 | 模运算、扩展欧几里得 | 完成(共 4 节,含零食) |
+| 11 | [FHQ 零食](11-平衡树/00-课前零食-从排好队到会分身的树.md) → [按值 FHQ Treap](11-平衡树/01-FHQ-Treap按值分裂与顺序统计.md) → [Splay 基本操作零食](11-平衡树/00-课前零食-让刚访问的点靠近你.md) → [文艺平衡树零食](11-平衡树/00-课前零食-把一段话翻过来.md) → [隐式 FHQ Treap](11-平衡树/02-隐式FHQ-Treap序列操作.md) → [Splay 区间暴露](11-平衡树/03-Splay伸展与区间暴露.md) → [ODT 零食](11-平衡树/00-课前零食-把相同的一段装进一个盒子.md) → [ODT 扩展](11-平衡树/04-高阶扩展-随机输入契约与ODT.md):FHQ Treap、Splay、随机输入契约与 ODT | BST、随机化、Lazy 标记 | 完成(共 8 节,含零食) |
 | 12 | [2016–2026 历年真题](12-历年真题/01-2016至2018期望最短路与基环建模.md):图论、字符串/代数、数据结构、综合建模 | 前 11 个主题中对应章节 | 完成(共 10 节、19 道真题) |
 
 ## 历年真题学习路线

+ 12 - 0
课程/插图/01-欧拉路径/00-欧拉-01-回路拼接.svg

@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 300" role="img" aria-labelledby="title desc">
+  <title id="title">两个在 A 点拼接的欧拉小回路</title>
+  <desc id="desc">左三角形 A B C 和右三角形 A D E 共用顶点 A,说明小回路可插入旧路线。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#4c6380;stroke-width:5;fill:none}.node{fill:#fff;stroke:#1d4ed8;stroke-width:4}.hot{fill:#fef3c7;stroke:#d97706;stroke-width:4}.note{font-size:20px}.label{font-size:27px;font-weight:700}</style>
+  <text x="28" y="38" class="note">先绕左边,再回到 A;右边仍可以从 A 接进去。</text>
+  <path class="edge" d="M170 150 L310 70 L310 230 Z"/><path class="edge" d="M170 150 L480 70 L610 190 Z"/>
+  <circle class="hot" cx="170" cy="150" r="29"/><circle class="node" cx="310" cy="70" r="25"/><circle class="node" cx="310" cy="230" r="25"/><circle class="node" cx="480" cy="70" r="25"/><circle class="node" cx="610" cy="190" r="25"/>
+  <text class="label" x="160" y="160">A</text><text class="label" x="300" y="80">B</text><text class="label" x="300" y="240">C</text><text class="label" x="470" y="80">D</text><text class="label" x="600" y="200">E</text>
+  <path d="M104 253 C72 216 70 101 109 61" stroke="#d97706" stroke-width="4" fill="none" marker-end="url(#arrow)"/>
+  <defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10z" fill="#d97706"/></marker></defs>
+  <text x="28" y="283" class="note">A 是可以“插回路”的路口。</text>
+</svg>

+ 12 - 0
课程/插图/01-欧拉路径/00-欧拉-02-度数配对.svg

@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 280" role="img" aria-labelledby="title desc">
+  <title id="title">欧拉路径中间点的度数配对</title>
+  <desc id="desc">四条边在中间点 X 成两组进入离开配对,起终点可各剩一条。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.line{stroke:#64748b;stroke-width:5}.pair{stroke:#16a34a;stroke-width:7}.node{fill:#eff6ff;stroke:#2563eb;stroke-width:4}.label{font-size:27px;font-weight:700}.note{font-size:20px}</style>
+  <text x="32" y="35" class="note">路线内部的 X:每次进来,都要从另一条边离开。</text>
+  <line class="line" x1="135" y1="220" x2="370" y2="140"/><line class="line" x1="135" y1="65" x2="370" y2="140"/><line class="line" x1="605" y1="65" x2="370" y2="140"/><line class="line" x1="605" y1="220" x2="370" y2="140"/>
+  <circle class="node" cx="370" cy="140" r="42"/><text class="label" x="360" y="150">X</text>
+  <path class="pair" d="M150 64 Q300 28 355 113" fill="none" marker-end="url(#g)"/><path class="pair" d="M385 113 Q455 28 590 64" fill="none" marker-end="url(#g)"/>
+  <path d="M150 220 Q300 258 355 167" stroke="#d97706" stroke-width="7" fill="none" marker-end="url(#o)"/><path d="M385 167 Q455 258 590 220" stroke="#d97706" stroke-width="7" fill="none" marker-end="url(#o)"/>
+  <text x="47" y="261" class="note">绿色一对、橙色一对:所以中间点度数为偶数。</text>
+  <defs><marker id="g" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#16a34a"/></marker><marker id="o" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#d97706"/></marker></defs>
+</svg>

+ 21 - 0
课程/插图/02-强连通与双连通/00-圆方树-01-共享点双块.svg

@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="940" height="390" viewBox="0 0 940 390">
+  <style>
+    .t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.cut{fill:#fff7ed;stroke:#ea580c;stroke-width:4}.q{fill:#ecfdf5;stroke:#059669;stroke-width:3}.e{stroke:#64748b;stroke-width:3}.hot{stroke:#f97316;stroke-width:5}
+  </style>
+  <rect width="940" height="390" fill="#f8fafc"/><text x="42" y="38" class="t" font-weight="bold">原图:C 同时站在几个结实小团体的门口</text>
+  <line x1="120" y1="120" x2="250" y2="220" class="e"/><line x1="250" y1="220" x2="70" y2="250" class="e"/><line x1="70" y1="250" x2="120" y2="120" class="e"/>
+  <line x1="250" y1="220" x2="400" y2="115" class="e"/><line x1="400" y1="115" x2="445" y2="270" class="e"/><line x1="445" y1="270" x2="250" y2="220" class="e"/>
+  <line x1="250" y1="220" x2="320" y2="335" class="hot"/><line x1="320" y1="335" x2="445" y2="335" class="hot"/>
+  <g class="n"><circle cx="120" cy="120" r="23"/><circle cx="70" cy="250" r="23"/><circle cx="400" cy="115" r="23"/><circle cx="445" cy="270" r="23"/><circle cx="445" cy="335" r="23"/></g>
+  <circle cx="250" cy="220" r="25" class="cut"/><circle cx="320" cy="335" r="25" class="cut"/>
+  <g class="t" text-anchor="middle"><text x="120" y="126">A</text><text x="70" y="256">B</text><text x="250" y="226">C</text><text x="400" y="121">D</text><text x="445" y="276">E</text><text x="320" y="341">F</text><text x="445" y="341">G</text></g>
+  <text x="145" y="300" class="s">删 C,三处会分开</text>
+  <line x1="498" y1="18" x2="498" y2="372" stroke="#cbd5e1" stroke-width="2"/>
+  <text x="540" y="38" class="t" font-weight="bold">圆方树:复制“块”,不复制 C</text>
+  <g class="e"><line x1="570" y1="130" x2="680" y2="190"/><line x1="615" y1="285" x2="680" y2="190"/><line x1="680" y1="190" x2="790" y2="120"/><line x1="680" y1="190" x2="800" y2="260"/><line x1="680" y1="190" x2="730" y2="325"/><line x1="730" y1="325" x2="860" y2="325"/></g>
+  <g class="q"><rect x="650" y="160" width="60" height="60" rx="8"/><rect x="760" y="90" width="60" height="60" rx="8"/><rect x="770" y="230" width="60" height="60" rx="8"/><rect x="700" y="295" width="60" height="60" rx="8"/></g>
+  <g class="n"><circle cx="570" cy="130" r="22"/><circle cx="615" cy="285" r="22"/><circle cx="860" cy="325" r="22"/></g>
+  <circle cx="680" cy="190" r="24" class="cut"/>
+  <g class="t" text-anchor="middle"><text x="570" y="136">A</text><text x="615" y="291">B</text><text x="680" y="196">C</text><text x="680" y="196" class="s" dy="43">Q1</text><text x="790" y="126">Q2</text><text x="800" y="266">Q3</text><text x="730" y="331">Q4</text><text x="860" y="331">G</text></g>
+  <text x="543" y="370" class="s">圆点=原顶点;方点=点双块;此处只画出骨架中的部分成员</text>
+</svg>

+ 15 - 0
课程/插图/02-强连通与双连通/00-圆方树-02-边栈与封口.svg

@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="940" height="360" viewBox="0 0 940 360">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:white;stroke:#2563eb;stroke-width:3}.tree{stroke:#2563eb;stroke-width:4}.back{stroke:#f97316;stroke-width:3;stroke-dasharray:8 6;fill:none}.card{fill:#fff7ed;stroke:#f97316;stroke-width:2}.stack{fill:#ecfeff;stroke:#0891b2;stroke-width:3}</style>
+  <rect width="940" height="360" fill="#f8fafc"/><text x="35" y="34" class="t" font-weight="bold">边栈:一段 DFS 子树再也绕不到父亲上方时,整批走廊结账</text>
+  <g class="tree"><line x1="120" y1="90" x2="230" y2="160"/><line x1="230" y1="160" x2="340" y2="235"/><line x1="340" y1="235" x2="445" y2="305"/><line x1="230" y1="160" x2="340" y2="85"/><line x1="340" y1="85" x2="445" y2="155"/></g>
+  <path d="M445 155 Q360 205 230 160" class="back"/><path d="M445 305 Q340 335 230 160" class="back"/>
+  <g class="n"><circle cx="120" cy="90" r="20"/><circle cx="230" cy="160" r="20"/><circle cx="340" cy="235" r="20"/><circle cx="445" cy="305" r="20"/><circle cx="340" cy="85" r="20"/><circle cx="445" cy="155" r="20"/></g>
+  <g class="t" text-anchor="middle"><text x="120" y="96">A</text><text x="230" y="166">C</text><text x="340" y="241">F</text><text x="445" y="311">G</text><text x="340" y="91">D</text><text x="445" y="161">E</text></g>
+  <text x="56" y="340" class="s">蓝实线=树边;橙虚线=返祖边。F-G 没有虚线旁路,先封成一个块。</text>
+  <line x1="535" y1="56" x2="535" y2="332" stroke="#cbd5e1" stroke-width="2"/>
+  <text x="580" y="84" class="t" font-weight="bold">栈顶</text>
+  <rect x="580" y="102" width="250" height="42" rx="7" class="card"/><text x="598" y="129" class="t">F-G</text><text x="705" y="129" class="s">low[G] &gt; dfn[F]</text>
+  <rect x="580" y="150" width="250" height="42" rx="7" class="card"/><text x="598" y="177" class="t">C-F</text><text x="705" y="177" class="s">随后 low[F] &gt; dfn[C]</text>
+  <rect x="580" y="198" width="250" height="42" rx="7" class="stack"/><text x="598" y="225" class="t">C-D, D-E, E-C</text><text x="598" y="258" class="s">low[D] = dfn[C],三角形一起弹出</text>
+  <text x="580" y="305" class="s">每条边只进栈、出栈一次;方点就在一次弹栈时出生。</text>
+</svg>

+ 11 - 0
课程/插图/02-强连通与双连通/00-连通-01-缩点.svg

@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 300" role="img" aria-labelledby="title desc">
+  <title id="title">强连通分量缩点为 DAG</title><desc id="desc">三个互相可达团被缩成三个点,团之间只保留单向箭头。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.blob{fill:#dbeafe;stroke:#2563eb;stroke-width:4}.node{fill:#fff;stroke:#2563eb;stroke-width:3}.arr{stroke:#475569;stroke-width:4;fill:none}.label{font-size:25px;font-weight:700}.note{font-size:19px}</style>
+  <text x="30" y="34" class="note">团内任意两点都能来回;缩成一个点后,只关心团与团的方向。</text>
+  <ellipse class="blob" cx="160" cy="160" rx="115" ry="83"/><circle class="node" cx="120" cy="150" r="23"/><circle class="node" cx="180" cy="105" r="23"/><circle class="node" cx="205" cy="190" r="23"/><text class="label" x="111" y="159">A</text><text class="label" x="171" y="114">B</text><text class="label" x="196" y="199">C</text>
+  <ellipse class="blob" cx="425" cy="160" rx="85" ry="68"/><circle class="node" cx="398" cy="145" r="23"/><circle class="node" cx="452" cy="177" r="23"/><text class="label" x="389" y="154">D</text><text class="label" x="443" y="186">E</text>
+  <ellipse class="blob" cx="655" cy="160" rx="55" ry="48"/><circle class="node" cx="655" cy="160" r="23"/><text class="label" x="647" y="169">F</text>
+  <path class="arr" d="M275 160 L334 160" marker-end="url(#a)"/><path class="arr" d="M510 160 L594 160" marker-end="url(#a)"/>
+  <text x="97" y="274" class="note">{A,B,C}</text><text x="380" y="274" class="note">{D,E}</text><text x="628" y="274" class="note">{F}</text>
+  <defs><marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#475569"/></marker></defs>
+</svg>

+ 9 - 0
课程/插图/02-强连通与双连通/00-连通-02-桥与绕路.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 280" role="img" aria-labelledby="title desc">
+  <title id="title">环边的替代路线与桥</title><desc id="desc">左侧三角形的任意边有替代路线,连接右侧环的一条边是桥。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#64748b;stroke-width:5;fill:none}.bridge{stroke:#dc2626;stroke-width:7}.node{fill:#fff;stroke:#2563eb;stroke-width:4}.note{font-size:19px}.label{font-size:24px;font-weight:700}</style>
+  <text x="28" y="34" class="note">左边删一条仍可绕环;红边一删,左右两坨再也见不着。</text>
+  <path class="edge" d="M105 160 L225 70 L280 210 Z"/><path class="bridge" d="M280 210 L455 170"/><path class="edge" d="M455 170 L570 75 L690 180 Z"/>
+  <circle class="node" cx="105" cy="160" r="21"/><circle class="node" cx="225" cy="70" r="21"/><circle class="node" cx="280" cy="210" r="21"/><circle class="node" cx="455" cy="170" r="21"/><circle class="node" cx="570" cy="75" r="21"/><circle class="node" cx="690" cy="180" r="21"/>
+  <text class="label" x="96" y="169">A</text><text class="label" x="216" y="79">B</text><text class="label" x="271" y="219">C</text><text class="label" x="446" y="179">D</text><text class="label" x="561" y="84">E</text><text class="label" x="681" y="189">F</text>
+  <text x="321" y="238" fill="#dc2626" style="font-family:system-ui,sans-serif;font-size:20px;font-weight:700">桥</text>
+</svg>

+ 15 - 0
课程/插图/02-强连通与双连通/03-圆方树-01-原图到圆方树.svg

@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="980" height="330" viewBox="0 0 980 330">
+  <style>.t{font:16px sans-serif;fill:#172554}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.cut{fill:#ffedd5;stroke:#ea580c;stroke-width:4}.q{fill:#d1fae5;stroke:#059669;stroke-width:3}.e{stroke:#64748b;stroke-width:3}.a{stroke:#16a34a;stroke-width:4}</style>
+  <rect width="980" height="330" fill="white"/><text x="38" y="32" class="t" font-weight="bold">原图的重叠块</text>
+  <g class="e"><line x1="100" y1="105" x2="210" y2="175"/><line x1="210" y1="175" x2="65" y2="230"/><line x1="65" y1="230" x2="100" y2="105"/><line x1="210" y1="175" x2="330" y2="105"/><line x1="330" y1="105" x2="370" y2="235"/><line x1="370" y1="235" x2="210" y2="175"/></g>
+  <g class="n"><circle cx="100" cy="105" r="21"/><circle cx="65" cy="230" r="21"/><circle cx="330" cy="105" r="21"/><circle cx="370" cy="235" r="21"/></g><circle cx="210" cy="175" r="24" class="cut"/>
+  <g class="t" text-anchor="middle"><text x="100" y="111">A</text><text x="65" y="236">B</text><text x="210" y="181">C</text><text x="330" y="111">D</text><text x="370" y="241">E</text></g>
+  <text x="55" y="295" class="s">C 同属 ABC 与 CDE</text>
+  <path d="M440 164 H515" class="a" fill="none"/><path d="M515 164 l-14 -8 v16z" fill="#16a34a"/><text x="445" y="142" class="s">把成员关系外置</text>
+  <text x="570" y="32" class="t" font-weight="bold">圆方树的块—割点骨架</text>
+  <g class="e"><line x1="625" y1="105" x2="735" y2="165"/><line x1="685" y1="250" x2="735" y2="165"/><line x1="735" y1="165" x2="845" y2="105"/><line x1="735" y1="165" x2="855" y2="240"/></g>
+  <g class="q"><rect x="705" y="135" width="60" height="60" rx="8"/><rect x="815" y="75" width="60" height="60" rx="8"/></g>
+  <g class="n"><circle cx="625" cy="105" r="21"/><circle cx="685" cy="250" r="21"/><circle cx="855" cy="240" r="21"/></g><circle cx="735" cy="165" r="24" class="cut"/>
+  <g class="t" text-anchor="middle"><text x="625" y="111">A</text><text x="685" y="256">B</text><text x="735" y="171">C</text><text x="735" y="171" class="s" dy="42">Q1</text><text x="845" y="111">Q2</text><text x="855" y="246">E</text></g>
+  <text x="570" y="295" class="s">圆点保留原点;每个方点代表一个点双分量。</text>
+</svg>

+ 9 - 0
课程/插图/02-强连通与双连通/03-圆方树-02-路径经过割点.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="900" height="280" viewBox="0 0 900 280">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:white;stroke:#2563eb;stroke-width:3}.cut{fill:#ffedd5;stroke:#ea580c;stroke-width:4}.q{fill:#dcfce7;stroke:#16a34a;stroke-width:3}.e{stroke:#94a3b8;stroke-width:4}.p{stroke:#dc2626;stroke-width:7}</style>
+  <rect width="900" height="280" fill="#f8fafc"/><text x="32" y="32" class="t" font-weight="bold">原图里难以看清的“必经割点”,在圆方树中变成唯一树路径</text>
+  <g class="e"><line x1="90" y1="140" x2="190" y2="140"/><line x1="190" y1="140" x2="300" y2="140"/><line x1="300" y1="140" x2="410" y2="140"/><line x1="410" y1="140" x2="520" y2="140"/><line x1="520" y1="140" x2="630" y2="140"/><line x1="630" y1="140" x2="740" y2="140"/></g>
+  <g class="p"><line x1="90" y1="140" x2="740" y2="140"/></g>
+  <g class="n"><circle cx="90" cy="140" r="23"/><circle cx="740" cy="140" r="23"/></g><g class="q"><rect x="167" y="117" width="46" height="46" rx="7"/><rect x="387" y="117" width="46" height="46" rx="7"/><rect x="607" y="117" width="46" height="46" rx="7"/></g><circle cx="300" cy="140" r="25" class="cut"/><circle cx="520" cy="140" r="25" class="cut"/>
+  <g class="t" text-anchor="middle"><text x="90" y="146">u</text><text x="190" y="146">Q1</text><text x="300" y="146">C</text><text x="410" y="146">Q2</text><text x="520" y="146">F</text><text x="630" y="146">Q3</text><text x="740" y="146">v</text></g>
+  <text x="205" y="220" class="s">若问题问 u 到 v 必经哪些原图顶点,沿唯一树路径统计橙色圆点即可。</text>
+</svg>

+ 8 - 0
课程/插图/03-Bitset/00-Bitset-01-打包.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 270" role="img" aria-labelledby="title desc">
+  <title id="title">八个布尔开关打包进一个字</title><desc id="desc">八个单独开关对应一个八位机器字。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.box{stroke:#2563eb;stroke-width:3}.on{fill:#bfdbfe}.off{fill:#f8fafc}.word{fill:#eff6ff;stroke:#1d4ed8;stroke-width:4}.note{font-size:19px}.label{font-size:22px;font-weight:700}</style>
+  <text x="28" y="32" class="note">原来逐格记“在/不在”,现在把 8 格排成一个小机器字。</text>
+  <g transform="translate(45 75)"><rect class="box on" x="0" y="0" width="66" height="66"/><rect class="box off" x="70" y="0" width="66" height="66"/><rect class="box on" x="140" y="0" width="66" height="66"/><rect class="box on" x="210" y="0" width="66" height="66"/><rect class="box off" x="280" y="0" width="66" height="66"/><rect class="box on" x="350" y="0" width="66" height="66"/><rect class="box off" x="420" y="0" width="66" height="66"/><rect class="box on" x="490" y="0" width="66" height="66"/><text class="label" x="25" y="43">1</text><text class="label" x="95" y="43">0</text><text class="label" x="165" y="43">1</text><text class="label" x="235" y="43">1</text><text class="label" x="305" y="43">0</text><text class="label" x="375" y="43">1</text><text class="label" x="445" y="43">0</text><text class="label" x="515" y="43">1</text></g>
+  <path d="M330 150 L330 191" stroke="#475569" stroke-width="4" marker-end="url(#a)"/><rect class="word" x="235" y="202" width="192" height="48" rx="10"/><text class="label" x="266" y="234">10110101</text>
+  <defs><marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#475569"/></marker></defs>
+</svg>

+ 10 - 0
课程/插图/03-Bitset/00-Bitset-02-集合运算.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 290" role="img" aria-labelledby="title desc">
+  <title id="title">Bitset 的 AND OR XOR</title><desc id="desc">两行位串按列做与或异或,显示不同集合运算结果。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.on{fill:#bfdbfe;stroke:#2563eb;stroke-width:2}.off{fill:#f8fafc;stroke:#94a3b8;stroke-width:2}.note{font-size:18px}.label{font-size:20px;font-weight:700}</style>
+  <text x="28" y="30" class="note">每一列互不干扰;CPU 可以把许多列一起算。</text>
+  <text class="label" x="35" y="91">A</text><text class="label" x="35" y="145">B</text><text class="label" x="35" y="217">A &amp; B</text>
+  <g transform="translate(135 63)"><rect class="on" x="0" y="0" width="55" height="40"/><rect class="off" x="58" y="0" width="55" height="40"/><rect class="on" x="116" y="0" width="55" height="40"/><rect class="on" x="174" y="0" width="55" height="40"/><rect class="off" x="232" y="0" width="55" height="40"/><rect class="on" x="290" y="0" width="55" height="40"/><rect class="off" x="348" y="0" width="55" height="40"/><rect class="on" x="406" y="0" width="55" height="40"/>
+  <rect class="off" x="0" y="55" width="55" height="40"/><rect class="on" x="58" y="55" width="55" height="40"/><rect class="on" x="116" y="55" width="55" height="40"/><rect class="off" x="174" y="55" width="55" height="40"/><rect class="on" x="232" y="55" width="55" height="40"/><rect class="on" x="290" y="55" width="55" height="40"/><rect class="off" x="348" y="55" width="55" height="40"/><rect class="off" x="406" y="55" width="55" height="40"/>
+  <rect class="off" x="0" y="128" width="55" height="40"/><rect class="off" x="58" y="128" width="55" height="40"/><rect class="on" x="116" y="128" width="55" height="40"/><rect class="off" x="174" y="128" width="55" height="40"/><rect class="off" x="232" y="128" width="55" height="40"/><rect class="on" x="290" y="128" width="55" height="40"/><rect class="off" x="348" y="128" width="55" height="40"/><rect class="off" x="406" y="128" width="55" height="40"/></g>
+  <text x="135" y="272" class="note">蓝色格为 1:交集只保留两行同时亮的列。</text>
+</svg>

+ 9 - 0
课程/插图/04-KMP与Manacher/00-字符串-01-KMP重叠.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 270" role="img" aria-labelledby="title desc">
+  <title id="title">KMP 中失配后保留的重叠</title><desc id="desc">模式 ABABAC 匹配 ABABA 后失配,ABA 同时是前缀和后缀,应被保留。</desc>
+  <style>text{font-family:ui-monospace,monospace;fill:#172033}.hot{fill:#fde68a}.cold{fill:#f8fafc}.box{stroke:#64748b;stroke-width:2}.note{font-family:system-ui,sans-serif;font-size:19px;fill:#172033}.label{font-size:25px;font-weight:700}</style>
+  <text x="28" y="31" class="note">已经匹配 ABABA,下一位失配;黄色 ABA 仍是下一轮可能的开头。</text>
+  <text class="label" x="38" y="101">文本</text><text class="label" x="38" y="177">模式</text>
+  <g transform="translate(145 69)"><rect class="hot box" x="0" y="0" width="65" height="48"/><rect class="hot box" x="65" y="0" width="65" height="48"/><rect class="hot box" x="130" y="0" width="65" height="48"/><rect class="hot box" x="195" y="0" width="65" height="48"/><rect class="hot box" x="260" y="0" width="65" height="48"/><rect class="cold box" x="325" y="0" width="65" height="48"/><text x="21" y="33">A</text><text x="86" y="33">B</text><text x="151" y="33">A</text><text x="216" y="33">B</text><text x="281" y="33">A</text><text x="346" y="33">B</text></g>
+  <g transform="translate(145 145)"><rect class="hot box" x="0" y="0" width="65" height="48"/><rect class="hot box" x="65" y="0" width="65" height="48"/><rect class="hot box" x="130" y="0" width="65" height="48"/><rect class="hot box" x="195" y="0" width="65" height="48"/><rect class="hot box" x="260" y="0" width="65" height="48"/><rect class="cold box" x="325" y="0" width="65" height="48"/><text x="21" y="33">A</text><text x="86" y="33">B</text><text x="151" y="33">A</text><text x="216" y="33">B</text><text x="281" y="33">A</text><text x="346" y="33" fill="#dc2626">C</text></g>
+  <path d="M150 218 C190 255 300 255 340 218" stroke="#d97706" stroke-width="4" fill="none"/><text x="205" y="266" class="note">后缀 ABA = 前缀 ABA</text>
+</svg>

+ 7 - 0
课程/插图/04-KMP与Manacher/00-字符串-02-回文镜像.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 260" role="img" aria-labelledby="title desc">
+  <title id="title">Manacher 的镜像半径</title><desc id="desc">已知回文区间内,新中心可从其镜像中心借用至少一部分半径。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.cell{fill:#f8fafc;stroke:#64748b;stroke-width:2}.inside{fill:#dbeafe}.center{fill:#fef3c7;stroke:#d97706;stroke-width:4}.note{font-size:18px}.label{font-size:21px;font-weight:700}</style>
+  <text x="28" y="30" class="note">蓝框 [L,R] 已验证为回文;右侧中心 i 可向左借镜像 m 的信息。</text>
+  <g transform="translate(108 88)"><rect class="cell inside" x="0" y="0" width="72" height="55"/><rect class="cell inside" x="72" y="0" width="72" height="55"/><rect class="cell inside" x="144" y="0" width="72" height="55"/><rect class="cell inside" x="216" y="0" width="72" height="55"/><rect class="cell inside" x="288" y="0" width="72" height="55"/><rect class="cell inside" x="360" y="0" width="72" height="55"/><rect class="cell inside" x="432" y="0" width="72" height="55"/><rect class="cell" x="504" y="0" width="72" height="55"/><text class="label" x="28" y="35">a</text><text class="label" x="100" y="35">b</text><text class="label" x="172" y="35">a</text><text class="label" x="244" y="35">c</text><text class="label" x="316" y="35">a</text><text class="label" x="388" y="35">b</text><text class="label" x="460" y="35">a</text><text class="label" x="532" y="35">x</text><circle class="center" cx="180" cy="27" r="22"/><circle class="center" cx="396" cy="27" r="22"/></g>
+  <path d="M324 75 L324 170" stroke="#475569" stroke-width="3" stroke-dasharray="8 6"/><text x="278" y="194" class="note">旧中心</text><text x="251" y="221" class="note">m</text><text x="468" y="221" class="note">i</text><text x="105" y="245" class="note">i 的镜像 m = L + R - i;超过 R 的部分才需要新比较。</text>
+</svg>

+ 9 - 0
课程/插图/05-最短路与类最短路/00-最短路-01-零权反例.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 280" role="img" aria-labelledby="title desc">
+  <title id="title">普通 BFS 在零权边上的反例</title><desc id="desc">S 到 A 有权一的直边,S 到 B 到 A 两条零权边总权零。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#64748b;stroke-width:5;fill:none}.zero{stroke:#16a34a;stroke-width:6;fill:none}.node{fill:#fff;stroke:#2563eb;stroke-width:4}.note{font-size:19px}.label{font-size:25px;font-weight:700}</style>
+  <text x="28" y="33" class="note">一条边不一定比两条边便宜:BFS 最小化的是边数,不是权和。</text>
+  <path class="edge" d="M135 145 L610 145" marker-end="url(#g)"/><path class="zero" d="M135 145 L360 65 L610 145" marker-end="url(#g)"/>
+  <circle class="node" cx="135" cy="145" r="30"/><circle class="node" cx="360" cy="65" r="30"/><circle class="node" cx="610" cy="145" r="30"/><text class="label" x="126" y="154">S</text><text class="label" x="351" y="74">B</text><text class="label" x="601" y="154">A</text>
+  <text x="370" y="132" class="note">权 1</text><text x="220" y="84" class="note" fill="#15803d">权 0</text><text x="484" y="84" class="note" fill="#15803d">权 0</text><text x="230" y="246" class="note">S→A:1;S→B→A:0。</text>
+  <defs><marker id="g" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#475569"/></marker></defs>
+</svg>

+ 7 - 0
课程/插图/05-最短路与类最短路/00-最短路-02-松弛.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 270" role="img" aria-labelledby="title desc">
+  <title id="title">松弛逐步压低距离上界</title><desc id="desc">先通过直边得到距离七,后发现经过 B 的路线把距离降低到三。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#64748b;stroke-width:5;fill:none}.new{stroke:#16a34a;stroke-width:6;fill:none}.node{fill:#fff;stroke:#2563eb;stroke-width:4}.box{fill:#fef3c7;stroke:#d97706;stroke-width:3}.note{font-size:19px}.label{font-size:24px;font-weight:700}</style>
+  <text x="26" y="31" class="note">dist 不是刻在石头上的答案;发现更便宜的真实路线,就把上界压低。</text>
+  <path class="edge" d="M120 150 L605 150" marker-end="url(#a)"/><path class="new" d="M120 150 L350 70 L605 150" marker-end="url(#a)"/><circle class="node" cx="120" cy="150" r="29"/><circle class="node" cx="350" cy="70" r="29"/><circle class="node" cx="605" cy="150" r="29"/><text class="label" x="111" y="159">S</text><text class="label" x="341" y="79">B</text><text class="label" x="596" y="159">T</text><text x="365" y="137" class="note">7</text><text x="220" y="84" class="note" fill="#15803d">1</text><text x="484" y="84" class="note" fill="#15803d">2</text>
+  <rect class="box" x="243" y="205" width="300" height="43" rx="8"/><text x="270" y="234" class="note">dist[T]:∞ → 7 → 3</text><defs><marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#475569"/></marker></defs>
+</svg>

+ 14 - 0
课程/插图/06-最小与最大生成树/00-KRT-01-DSU与事件卡.svg

@@ -0,0 +1,14 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="960" height="360" viewBox="0 0 960 360">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.c{fill:#eff6ff;stroke:#2563eb;stroke-width:3}.event{fill:#fff7ed;stroke:#ea580c;stroke-width:3}.edge{stroke:#64748b;stroke-width:3}.a{stroke:#16a34a;stroke-width:4}</style>
+  <rect width="960" height="360" fill="#f8fafc"/><text x="32" y="35" class="t" font-weight="bold">DSU 的现在:只记住一个大泡泡</text>
+  <ellipse cx="205" cy="185" rx="160" ry="82" fill="#dbeafe" stroke="#2563eb" stroke-width="3"/>
+  <g class="c"><circle cx="95" cy="185" r="23"/><circle cx="155" cy="135" r="23"/><circle cx="215" cy="205" r="23"/><circle cx="280" cy="135" r="23"/><circle cx="325" cy="215" r="23"/></g>
+  <g class="t" text-anchor="middle"><text x="95" y="191">1</text><text x="155" y="141">2</text><text x="215" y="211">3</text><text x="280" y="141">4</text><text x="325" y="221">5</text></g>
+  <text x="72" y="295" class="s">都在同一集合,却忘了 2 与 4 何时先相遇</text>
+  <path d="M400 180 H485" class="a" fill="none"/><path d="M485 180 l-14 -8 v16z" fill="#16a34a"/><text x="410" y="155" class="s">保存每次合并事件</text>
+  <text x="545" y="35" class="t" font-weight="bold">重构树的历史:内部节点是一张事件卡</text>
+  <g class="edge"><line x1="745" y1="85" x2="675" y2="160"/><line x1="745" y1="85" x2="835" y2="245"/><line x1="675" y1="160" x2="620" y2="245"/><line x1="675" y1="160" x2="740" y2="245"/><line x1="620" y1="245" x2="580" y2="315"/><line x1="620" y1="245" x2="650" y2="315"/><line x1="740" y1="245" x2="720" y2="315"/><line x1="740" y1="245" x2="790" y2="315"/></g>
+  <g class="event"><rect x="715" y="58" width="60" height="42" rx="7"/><rect x="645" y="138" width="60" height="42" rx="7"/><rect x="590" y="223" width="60" height="42" rx="7"/><rect x="710" y="223" width="60" height="42" rx="7"/></g>
+  <g class="c"><circle cx="580" cy="315" r="19"/><circle cx="650" cy="315" r="19"/><circle cx="720" cy="315" r="19"/><circle cx="790" cy="315" r="19"/><circle cx="835" cy="245" r="19"/></g>
+  <g class="t" text-anchor="middle"><text x="745" y="85">9@6</text><text x="675" y="165">8@5</text><text x="620" y="250">6@2</text><text x="740" y="250">7@3</text><text x="580" y="321">1</text><text x="650" y="321">2</text><text x="720" y="321">3</text><text x="790" y="321">4</text><text x="835" y="251">5</text></g>
+</svg>

+ 10 - 0
课程/插图/06-最小与最大生成树/00-KRT-02-LCA就是相遇.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="900" height="330" viewBox="0 0 900 330">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.event{fill:#fff7ed;stroke:#ea580c;stroke-width:3}.e{stroke:#94a3b8;stroke-width:3}.p1{stroke:#2563eb;stroke-width:6}.p2{stroke:#dc2626;stroke-width:6}</style>
+  <rect width="900" height="330" fill="white"/><text x="35" y="34" class="t" font-weight="bold">从叶 2、4 向上找:第一次会合的事件卡 8@5 就是首次连通阈值</text>
+  <g class="e"><line x1="450" y1="70" x2="330" y2="150"/><line x1="450" y1="70" x2="620" y2="235"/><line x1="330" y1="150" x2="220" y2="235"/><line x1="330" y1="150" x2="440" y2="235"/><line x1="220" y1="235" x2="145" y2="295"/><line x1="220" y1="235" x2="285" y2="295"/><line x1="440" y1="235" x2="390" y2="295"/><line x1="440" y1="235" x2="510" y2="295"/></g>
+  <g class="p1"><line x1="285" y1="295" x2="220" y2="235"/><line x1="220" y1="235" x2="330" y2="150"/></g><g class="p2"><line x1="510" y1="295" x2="440" y2="235"/><line x1="440" y1="235" x2="330" y2="150"/></g>
+  <g class="event"><rect x="420" y="48" width="60" height="44" rx="7"/><rect x="300" y="128" width="60" height="44" rx="7"/><rect x="190" y="213" width="60" height="44" rx="7"/><rect x="410" y="213" width="60" height="44" rx="7"/></g>
+  <g class="n"><circle cx="145" cy="295" r="18"/><circle cx="285" cy="295" r="18"/><circle cx="390" cy="295" r="18"/><circle cx="510" cy="295" r="18"/><circle cx="620" cy="235" r="18"/></g>
+  <g class="t" text-anchor="middle"><text x="450" y="76">9@6</text><text x="330" y="156">8@5</text><text x="220" y="241">6@2</text><text x="440" y="241">7@3</text><text x="145" y="301">1</text><text x="285" y="301">2</text><text x="390" y="301">3</text><text x="510" y="301">4</text><text x="620" y="241">5</text></g>
+  <text x="88" y="200" class="s" fill="#2563eb">2 的祖先链</text><text x="530" y="190" class="s" fill="#dc2626">4 的祖先链</text>
+</svg>

+ 8 - 0
课程/插图/06-最小与最大生成树/00-MST-01-Kruskal.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 290" role="img" aria-labelledby="title desc">
+  <title id="title">Kruskal 选边过程</title><desc id="desc">蓝绿边被选入生成树,灰色边因构成环被跳过。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#94a3b8;stroke-width:4}.pick{stroke:#16a34a;stroke-width:7}.node{fill:#fff;stroke:#2563eb;stroke-width:4}.note{font-size:18px}.label{font-size:23px;font-weight:700}</style>
+  <text x="25" y="30" class="note">从最便宜边开始;绿色边把不同分量连起来,灰边会绕成圈。</text>
+  <line class="pick" x1="135" y1="160" x2="280" y2="76"/><line class="edge" x1="135" y1="160" x2="320" y2="220"/><line class="edge" x1="280" y1="76" x2="320" y2="220"/><line class="pick" x1="320" y1="220" x2="505" y2="160"/><line class="edge" x1="280" y1="76" x2="505" y2="160"/><line class="pick" x1="505" y1="160" x2="670" y2="82"/><line class="edge" x1="320" y1="220" x2="670" y2="82"/>
+  <circle class="node" cx="135" cy="160" r="25"/><circle class="node" cx="280" cy="76" r="25"/><circle class="node" cx="320" cy="220" r="25"/><circle class="node" cx="505" cy="160" r="25"/><circle class="node" cx="670" cy="82" r="25"/><text class="label" x="126" y="169">A</text><text class="label" x="271" y="85">B</text><text class="label" x="311" y="229">C</text><text class="label" x="496" y="169">D</text><text class="label" x="661" y="91">E</text>
+  <text x="190" y="106" class="note">1</text><text x="202" y="210" class="note">2</text><text x="291" y="155" class="note">3</text><text x="398" y="210" class="note">2</text><text x="390" y="98" class="note">4</text><text x="575" y="99" class="note">1</text><text x="490" y="255" class="note">5</text>
+</svg>

+ 6 - 0
课程/插图/06-最小与最大生成树/00-MST-02-割性质.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 280" role="img" aria-labelledby="title desc">
+  <title id="title">割性质中的安全边</title><desc id="desc">虚线将图分为当前分量和外部,最轻跨割边可安全加入。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.cut{stroke:#d97706;stroke-width:4;stroke-dasharray:12 9}.edge{stroke:#94a3b8;stroke-width:4}.safe{stroke:#16a34a;stroke-width:7}.node{fill:#fff;stroke:#2563eb;stroke-width:4}.note{font-size:19px}.label{font-size:22px;font-weight:700}</style>
+  <text x="28" y="31" class="note">把当前分量 S 和外部切开;最轻跨线边(绿色)可以安全加入。</text><path class="cut" d="M380 54 L380 245"/><text x="333" y="70" class="note">割</text>
+  <line class="edge" x1="120" y1="150" x2="270" y2="90"/><line class="safe" x1="270" y1="90" x2="540" y2="100"/><line class="edge" x1="120" y1="150" x2="630" y2="190"/><line class="edge" x1="270" y1="90" x2="650" y2="60"/><circle class="node" cx="120" cy="150" r="25"/><circle class="node" cx="270" cy="90" r="25"/><circle class="node" cx="540" cy="100" r="25"/><circle class="node" cx="630" cy="190" r="25"/><circle class="node" cx="650" cy="60" r="25"/><text class="label" x="111" y="159">S</text><text class="label" x="261" y="99">S</text><text x="385" y="90" class="note" fill="#15803d">最轻 2</text><text x="385" y="196" class="note">较重 5</text><text x="416" y="53" class="note">较重 6</text><text x="83" y="245" class="note">左边:当前森林的一个分量</text>
+</svg>

+ 10 - 0
课程/插图/06-最小与最大生成树/03-KRT-01-合并时间树.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="940" height="340" viewBox="0 0 940 340">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.event{fill:#fff7ed;stroke:#ea580c;stroke-width:3}.e{stroke:#64748b;stroke-width:3}.time{stroke:#16a34a;stroke-width:3;stroke-dasharray:6 5}</style>
+  <rect width="940" height="340" fill="#f8fafc"/><text x="38" y="33" class="t" font-weight="bold">递增 Kruskal 的每次成功 union 都向上创建一个更晚的事件节点</text>
+  <line x1="65" y1="278" x2="885" y2="278" class="time"/><text x="70" y="308" class="s">叶:原顶点</text><text x="735" y="308" class="s">向根:合并阈值不减</text>
+  <g class="e"><line x1="475" y1="60" x2="360" y2="145"/><line x1="475" y1="60" x2="630" y2="220"/><line x1="360" y1="145" x2="255" y2="225"/><line x1="360" y1="145" x2="455" y2="225"/><line x1="255" y1="225" x2="185" y2="278"/><line x1="255" y1="225" x2="310" y2="278"/><line x1="455" y1="225" x2="410" y2="278"/><line x1="455" y1="225" x2="510" y2="278"/></g>
+  <g class="event"><rect x="445" y="38" width="60" height="44" rx="7"/><rect x="330" y="123" width="60" height="44" rx="7"/><rect x="225" y="203" width="60" height="44" rx="7"/><rect x="425" y="203" width="60" height="44" rx="7"/></g>
+  <g class="n"><circle cx="185" cy="278" r="18"/><circle cx="310" cy="278" r="18"/><circle cx="410" cy="278" r="18"/><circle cx="510" cy="278" r="18"/><circle cx="630" cy="220" r="18"/></g>
+  <g class="t" text-anchor="middle"><text x="475" y="66">9: 6</text><text x="360" y="151">8: 5</text><text x="255" y="231">6: 2</text><text x="455" y="231">7: 3</text><text x="185" y="284">1</text><text x="310" y="284">2</text><text x="410" y="284">3</text><text x="510" y="284">4</text><text x="630" y="226">5</text></g>
+  <text x="680" y="134" class="s">LCA(2,4)=8</text><text x="680" y="158" class="s">所以首次连通权为 5</text>
+</svg>

+ 9 - 0
课程/插图/06-最小与最大生成树/03-KRT-02-XOR最高位分组.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="940" height="300" viewBox="0 0 940 300">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.box0{fill:#dbeafe;stroke:#2563eb;stroke-width:3}.box1{fill:#fee2e2;stroke:#dc2626;stroke-width:3}.e{stroke:#64748b;stroke-width:3}.cross{stroke:#ea580c;stroke-width:5}.bit{font:24px monospace;fill:#0f172a}</style>
+  <rect width="940" height="300" fill="white"/><text x="32" y="34" class="t" font-weight="bold">XOR-MST:按当前最高不同位切成两组,组内递归、组间只补一条最便宜边</text>
+  <rect x="70" y="92" width="300" height="145" rx="14" class="box0"/><rect x="570" y="92" width="300" height="145" rx="14" class="box1"/>
+  <text x="92" y="125" class="t" font-weight="bold">当前位 b 为 0 的组</text><text x="592" y="125" class="t" font-weight="bold">当前位 b 为 1 的组</text>
+  <text x="105" y="165" class="bit">0 01 10</text><text x="105" y="205" class="bit">0 10 11</text><text x="605" y="165" class="bit">1 00 01</text><text x="605" y="205" class="bit">1 01 11</text>
+  <line x1="370" y1="164" x2="570" y2="164" class="cross"/><path d="M570 164 l-16 -9 v18z" fill="#ea580c"/><text x="385" y="145" class="s">跨组必带 2^b</text><text x="395" y="205" class="s">低位再用 Trie 求最小</text>
+  <text x="72" y="270" class="s">同组中的更高位前缀相同;跨组边的最高不同位固定,故可独立递归。</text>
+</svg>

+ 7 - 0
课程/插图/07-二分图/00-二分图-01-两侧.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 285" role="img" aria-labelledby="title desc">
+  <title id="title">二分图的左右两侧</title><desc id="desc">蓝色与橙色节点分别在两侧,所有边都跨越两侧。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#94a3b8;stroke-width:4}.left{fill:#dbeafe;stroke:#2563eb;stroke-width:4}.right{fill:#ffedd5;stroke:#ea580c;stroke-width:4}.note{font-size:19px}.label{font-size:23px;font-weight:700}</style>
+  <text x="28" y="31" class="note">所有边跨越左右两侧;同侧之间没有边。</text><text x="166" y="68" class="note">颜色 0</text><text x="568" y="68" class="note">颜色 1</text>
+  <line class="edge" x1="190" y1="115" x2="610" y2="115"/><line class="edge" x1="190" y1="115" x2="610" y2="215"/><line class="edge" x1="190" y1="215" x2="610" y2="115"/><line class="edge" x1="190" y1="215" x2="610" y2="215"/>
+  <circle class="left" cx="190" cy="115" r="28"/><circle class="left" cx="190" cy="215" r="28"/><circle class="right" cx="610" cy="115" r="28"/><circle class="right" cx="610" cy="215" r="28"/><text class="label" x="181" y="124">A</text><text class="label" x="181" y="224">C</text><text class="label" x="601" y="124">B</text><text class="label" x="601" y="224">D</text>
+</svg>

+ 6 - 0
课程/插图/07-二分图/00-二分图-02-奇环.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 270" role="img" aria-labelledby="title desc">
+  <title id="title">奇环造成同色冲突</title><desc id="desc">三角形交替染色后 A 和 C 同色,但 A C 相邻,产生矛盾。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#64748b;stroke-width:5}.bad{stroke:#dc2626;stroke-width:7}.blue{fill:#dbeafe;stroke:#2563eb;stroke-width:4}.orange{fill:#ffedd5;stroke:#ea580c;stroke-width:4}.note{font-size:19px}.label{font-size:25px;font-weight:700}</style>
+  <text x="28" y="32" class="note">A→B→C 交替后 A、C 同色;红边 A-C 无法同时跨两色。</text><line class="edge" x1="200" y1="190" x2="400" y2="70"/><line class="edge" x1="400" y1="70" x2="600" y2="190"/><line class="bad" x1="600" y1="190" x2="200" y2="190"/>
+  <circle class="blue" cx="200" cy="190" r="30"/><circle class="orange" cx="400" cy="70" r="30"/><circle class="blue" cx="600" cy="190" r="30"/><text class="label" x="191" y="199">A</text><text class="label" x="391" y="79">B</text><text class="label" x="591" y="199">C</text><text x="354" y="245" style="font-family:system-ui,sans-serif;font-size:20px;fill:#dc2626">同色冲突</text>
+</svg>

+ 7 - 0
课程/插图/08-高级线段树与树状数组/00-区间-01-Fenwick覆盖.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 280" role="img" aria-labelledby="title desc">
+  <title id="title">Fenwick 树节点覆盖区间</title><desc id="desc">tree 四覆盖一到四,tree 六覆盖五到六,前缀六可以由两个块相加。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.cell{fill:#f8fafc;stroke:#64748b;stroke-width:2}.block1{fill:#bfdbfe;stroke:#2563eb;stroke-width:3}.block2{fill:#fed7aa;stroke:#ea580c;stroke-width:3}.note{font-size:18px}.label{font-size:20px;font-weight:700}</style>
+  <text x="26" y="30" class="note">prefix(6) 不是逐个加 1..6:tree[6] 管 [5,6],tree[4] 管 [1,4]。</text>
+  <g transform="translate(90 105)"><rect class="block1" x="0" y="0" width="80" height="55"/><rect class="block1" x="80" y="0" width="80" height="55"/><rect class="block1" x="160" y="0" width="80" height="55"/><rect class="block1" x="240" y="0" width="80" height="55"/><rect class="block2" x="320" y="0" width="80" height="55"/><rect class="block2" x="400" y="0" width="80" height="55"/><rect class="cell" x="480" y="0" width="80" height="55"/><rect class="cell" x="560" y="0" width="80" height="55"/><text class="label" x="34" y="35">1</text><text class="label" x="114" y="35">2</text><text class="label" x="194" y="35">3</text><text class="label" x="274" y="35">4</text><text class="label" x="354" y="35">5</text><text class="label" x="434" y="35">6</text><text class="label" x="514" y="35">7</text><text class="label" x="594" y="35">8</text></g>
+  <path d="M250 95 L250 64" stroke="#2563eb" stroke-width="4"/><text x="170" y="56" class="note" fill="#1d4ed8">tree[4] = [1,4]</text><path d="M530 95 L530 64" stroke="#ea580c" stroke-width="4"/><text x="450" y="56" class="note" fill="#c2410c">tree[6] = [5,6]</text><text x="191" y="234" class="note">lowbit(6)=2,6 → 4 → 0:每一步拿走一个不重叠尾块。</text>
+</svg>

+ 7 - 0
课程/插图/08-高级线段树与树状数组/00-区间-02-线段树分块.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 310" role="img" aria-labelledby="title desc">
+  <title id="title">线段树的区间分治摘要</title><desc id="desc">根代表一到八,子节点代表更小区间,查询二到七可由少数节点合并。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.line{stroke:#94a3b8;stroke-width:3}.node{fill:#eff6ff;stroke:#2563eb;stroke-width:3}.hit{fill:#dcfce7;stroke:#16a34a;stroke-width:4}.note{font-size:18px}.label{font-size:18px;font-weight:700}</style>
+  <text x="25" y="28" class="note">每个节点保管一个区间摘要;查询只拿完全落入目标的节点。</text>
+  <line class="line" x1="420" y1="70" x2="240" y2="130"/><line class="line" x1="420" y1="70" x2="600" y2="130"/><line class="line" x1="240" y1="160" x2="140" y2="220"/><line class="line" x1="240" y1="160" x2="340" y2="220"/><line class="line" x1="600" y1="160" x2="500" y2="220"/><line class="line" x1="600" y1="160" x2="700" y2="220"/>
+  <rect class="node" x="375" y="42" width="90" height="36" rx="8"/><text class="label" x="394" y="66">[1,8]</text><rect class="node" x="195" y="122" width="90" height="36" rx="8"/><text class="label" x="214" y="146">[1,4]</text><rect class="node" x="555" y="122" width="90" height="36" rx="8"/><text class="label" x="574" y="146">[5,8]</text><rect class="hit" x="95" y="212" width="90" height="36" rx="8"/><text class="label" x="114" y="236">[1,2]</text><rect class="hit" x="295" y="212" width="90" height="36" rx="8"/><text class="label" x="314" y="236">[3,4]</text><rect class="hit" x="455" y="212" width="90" height="36" rx="8"/><text class="label" x="474" y="236">[5,6]</text><rect class="hit" x="655" y="212" width="90" height="36" rx="8"/><text class="label" x="674" y="236">[7,8]</text><text x="235" y="290" class="note">例如查询 [2,7] 时,绿色节点的边界还会继续细分;不相交部分不访问。</text>
+</svg>

+ 5 - 0
课程/插图/09-基环树/00-基环树-01-多一条边.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 290" role="img" aria-labelledby="title desc">
+  <title id="title">树加一条边形成基环树</title><desc id="desc">灰色边是一棵树,红色额外边和树路径形成唯一环。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.tree{stroke:#94a3b8;stroke-width:5}.extra{stroke:#dc2626;stroke-width:7}.node{fill:#fff;stroke:#2563eb;stroke-width:4}.note{font-size:19px}.label{font-size:23px;font-weight:700}</style>
+  <text x="27" y="31" class="note">先有一棵树;红色额外边把它两端的唯一树路径闭成一圈。</text><line class="tree" x1="170" y1="160" x2="335" y2="80"/><line class="tree" x1="335" y1="80" x2="510" y2="155"/><line class="tree" x1="510" y1="155" x2="680" y2="85"/><line class="tree" x1="510" y1="155" x2="625" y2="235"/><line class="extra" x1="170" y1="160" x2="510" y2="155"/><circle class="node" cx="170" cy="160" r="26"/><circle class="node" cx="335" cy="80" r="26"/><circle class="node" cx="510" cy="155" r="26"/><circle class="node" cx="680" cy="85" r="26"/><circle class="node" cx="625" cy="235" r="26"/><text class="label" x="161" y="169">A</text><text class="label" x="326" y="89">B</text><text class="label" x="501" y="164">C</text><text class="label" x="671" y="94">D</text><text class="label" x="616" y="244">E</text><text x="260" y="210" style="font-family:system-ui,sans-serif;font-size:20px;fill:#dc2626">额外边</text>
+</svg>

+ 5 - 0
课程/插图/09-基环树/00-基环树-02-剥叶.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 300" role="img" aria-labelledby="title desc">
+  <title id="title">基环树剥叶过程</title><desc id="desc">叶子 E F G 被剥离后,D 成为叶子,最后只余 A B C 环。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#94a3b8;stroke-width:4}.cycle{stroke:#2563eb;stroke-width:6;fill:none}.leaf{fill:#fee2e2;stroke:#dc2626;stroke-width:4}.core{fill:#dbeafe;stroke:#2563eb;stroke-width:4}.note{font-size:18px}.label{font-size:22px;font-weight:700}</style>
+  <text x="24" y="30" class="note">先删红色叶子;它们消失后 D 也会变叶子,蓝色三角形最后留下。</text><path class="cycle" d="M240 165 L380 70 L505 190 Z"/><line class="edge" x1="380" y1="70" x2="380" y2="190"/><line class="edge" x1="380" y1="190" x2="380" y2="250"/><line class="edge" x1="505" y1="190" x2="650" y2="235"/><line class="edge" x1="505" y1="190" x2="660" y2="110"/><circle class="core" cx="240" cy="165" r="25"/><circle class="core" cx="380" cy="70" r="25"/><circle class="core" cx="505" cy="190" r="25"/><circle class="leaf" cx="380" cy="190" r="25"/><circle class="leaf" cx="380" cy="250" r="25"/><circle class="leaf" cx="650" cy="235" r="25"/><circle class="leaf" cx="660" cy="110" r="25"/><text class="label" x="231" y="174">A</text><text class="label" x="371" y="79">B</text><text class="label" x="496" y="199">C</text><text class="label" x="371" y="199">D</text><text class="label" x="371" y="259">E</text><text class="label" x="641" y="244">F</text><text class="label" x="651" y="119">G</text><text x="82" y="280" class="note">队列顺序可为 E,F,G,D;剩余 A,B,C。</text>
+</svg>

+ 5 - 0
课程/插图/10-高斯消元与CRT/00-代数-01-XOR消元.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 290" role="img" aria-labelledby="title desc">
+  <title id="title">XOR 高斯消元</title><desc id="desc">矩阵第二步将第一行异或到第三行,消去第一列并得到依赖行。</desc>
+  <style>text{font-family:ui-monospace,monospace;fill:#172033}.box{fill:#eff6ff;stroke:#2563eb;stroke-width:3}.arrow{stroke:#d97706;stroke-width:4;fill:none}.note{font-family:system-ui,sans-serif;font-size:18px;fill:#172033}.hi{fill:#dcfce7}</style>
+  <text x="25" y="30" class="note">用一行 XOR 另一行:解集不变,却能让某列只留下一个主元。</text><rect class="box" x="80" y="65" width="230" height="170" rx="10"/><text x="120" y="110">1 1 0 | 1</text><text x="120" y="155">0 1 1 | 0</text><text x="120" y="200">1 0 1 | 1</text><path class="arrow" d="M342 150 L470 150" marker-end="url(#a)"/><text x="332" y="125" class="note">R3 ^= R1</text><rect class="box" x="505" y="65" width="230" height="170" rx="10"/><text x="545" y="110">1 1 0 | 1</text><text x="545" y="155">0 1 1 | 0</text><rect class="hi" x="535" y="169" width="155" height="43"/><text x="545" y="200">0 1 1 | 0</text><text x="83" y="270" class="note">重复行表示一条约束由其他约束推出,不会增加秩。</text><defs><marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#d97706"/></marker></defs>
+</svg>

+ 7 - 0
课程/插图/10-高斯消元与CRT/00-代数-02-时钟对齐.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 280" role="img" aria-labelledby="title desc">
+  <title id="title">CRT 周期对齐</title><desc id="desc">模四余一和模六余三的时间序列在九处首次相交,并以十二为周期重复。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.dot1{fill:#2563eb}.dot2{fill:#ea580c}.hit{fill:#16a34a;stroke:#14532d;stroke-width:3}.line{stroke:#cbd5e1;stroke-width:3}.note{font-size:18px}.label{font-size:18px;font-weight:700}</style>
+  <text x="25" y="29" class="note">x≡1 (mod 4):1,5,9,…;x≡3 (mod 6):3,9,…;第一个共同时间是 9。</text><line class="line" x1="95" y1="105" x2="770" y2="105"/><line class="line" x1="95" y1="205" x2="770" y2="205"/><text class="label" x="30" y="110">mod 4</text><text class="label" x="30" y="210">mod 6</text>
+  <g class="note"><text x="112" y="250">0</text><text x="167" y="250">1</text><text x="222" y="250">2</text><text x="277" y="250">3</text><text x="332" y="250">4</text><text x="387" y="250">5</text><text x="442" y="250">6</text><text x="497" y="250">7</text><text x="552" y="250">8</text><text x="607" y="250">9</text><text x="657" y="250">10</text><text x="712" y="250">11</text></g>
+  <circle class="dot1" cx="175" cy="105" r="9"/><circle class="dot1" cx="395" cy="105" r="9"/><circle class="hit" cx="615" cy="105" r="12"/><circle class="dot2" cx="285" cy="205" r="9"/><circle class="hit" cx="615" cy="205" r="12"/><path d="M615 119 L615 191" stroke="#16a34a" stroke-width="4"/><text x="576" y="74" class="note" fill="#15803d">x=9</text>
+</svg>

+ 12 - 0
课程/插图/11-平衡树/00-ODT-01-把数组压成段.svg

@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="940" height="285" viewBox="0 0 940 285">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.cell1{fill:#dbeafe;stroke:#2563eb;stroke-width:2}.cell4{fill:#fef3c7;stroke:#d97706;stroke-width:2}.cell2{fill:#dcfce7;stroke:#16a34a;stroke-width:2}.card1{fill:#dbeafe;stroke:#2563eb;stroke-width:3}.card4{fill:#fef3c7;stroke:#d97706;stroke-width:3}.card2{fill:#dcfce7;stroke:#16a34a;stroke-width:3}.a{stroke:#16a34a;stroke-width:4;fill:none}</style>
+  <rect width="940" height="285" fill="#f8fafc"/><text x="34" y="34" class="t" font-weight="bold">连续相同的格子可以共用一张段卡:工作量从元素数转向段数</text>
+  <text x="42" y="84" class="s">逐格数组</text>
+  <g transform="translate(145 55)"><rect x="0" y="0" width="55" height="55" class="cell1"/><rect x="55" y="0" width="55" height="55" class="cell1"/><rect x="110" y="0" width="55" height="55" class="cell1"/><rect x="165" y="0" width="55" height="55" class="cell4"/><rect x="220" y="0" width="55" height="55" class="cell4"/><rect x="275" y="0" width="55" height="55" class="cell2"/><rect x="330" y="0" width="55" height="55" class="cell2"/><rect x="385" y="0" width="55" height="55" class="cell2"/><rect x="440" y="0" width="55" height="55" class="cell2"/>
+  <g class="t" text-anchor="middle"><text x="27" y="34">1</text><text x="82" y="34">1</text><text x="137" y="34">1</text><text x="192" y="34">4</text><text x="247" y="34">4</text><text x="302" y="34">2</text><text x="357" y="34">2</text><text x="412" y="34">2</text><text x="467" y="34">2</text></g></g>
+  <path d="M390 128 v38" class="a"/><path d="M390 166 l-9 -14 h18z" fill="#16a34a"/>
+  <text x="42" y="204" class="s">ODT 段卡</text>
+  <rect x="145" y="175" width="165" height="54" rx="8" class="card1"/><rect x="330" y="175" width="110" height="54" rx="8" class="card4"/><rect x="460" y="175" width="220" height="54" rx="8" class="card2"/>
+  <g class="t" text-anchor="middle"><text x="227" y="207">[1,3,v=1]</text><text x="385" y="207">[4,5,v=4]</text><text x="570" y="207">[6,9,v=2]</text></g>
+  <text x="705" y="204" class="s">9 个位置 → 3 张卡</text>
+</svg>

+ 10 - 0
课程/插图/11-平衡树/00-ODT-02-split切边界.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="950" height="315" viewBox="0 0 950 315">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.card{fill:#dcfce7;stroke:#16a34a;stroke-width:3}.target{fill:#fef3c7;stroke:#d97706;stroke-width:4}.dash{stroke:#dc2626;stroke-width:3;stroke-dasharray:7 5}.a{stroke:#2563eb;stroke-width:4;fill:none}</style>
+  <rect width="950" height="315" fill="white"/><text x="32" y="34" class="t" font-weight="bold">先在 l 与 r+1 切齐:中央恰好成为完整段卡的连续区间</text>
+  <rect x="70" y="105" width="240" height="58" rx="9" class="card"/><rect x="310" y="105" width="180" height="58" rx="9" class="target"/><rect x="490" y="105" width="250" height="58" rx="9" class="card"/>
+  <g class="t" text-anchor="middle"><text x="190" y="140">左边未受影响</text><text x="400" y="140">操作区间 [l,r]</text><text x="615" y="140">右边未受影响</text></g>
+  <line x1="310" y1="76" x2="310" y2="195" class="dash"/><line x1="490" y1="76" x2="490" y2="195" class="dash"/>
+  <text x="287" y="67" class="s">split(l)</text><text x="453" y="67" class="s">split(r+1)</text>
+  <path d="M400 190 v52" class="a"/><path d="M400 242 l-9 -14 h18z" fill="#2563eb"/>
+  <text x="185" y="272" class="s">之后可逐卡加、删掉中央再插一张赋值卡、或按每卡 length 做 kth/幂和。</text>
+</svg>

+ 12 - 0
课程/插图/11-平衡树/00-Splay-01-旋转不改中序.svg

@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="930" height="300" viewBox="0 0 930 300">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.e{stroke:#64748b;stroke-width:3}.a{stroke:#16a34a;stroke-width:4}.sub{fill:#eff6ff;stroke:#60a5fa;stroke-width:2}</style>
+  <rect width="930" height="300" fill="#f8fafc"/><text x="32" y="35" class="t" font-weight="bold">左旋的证据:局部指针变了,中序 A,p,B,x,C 没变</text>
+  <text x="180" y="70" class="s">旋转前</text><line x1="175" y1="105" x2="120" y2="180" class="e"/><line x1="175" y1="105" x2="250" y2="170" class="e"/><line x1="250" y1="170" x2="215" y2="240" class="e"/><line x1="250" y1="170" x2="300" y2="240" class="e"/>
+  <circle cx="175" cy="105" r="23" class="n"/><circle cx="250" cy="170" r="23" class="n"/><rect x="95" y="180" width="50" height="32" rx="6" class="sub"/><rect x="190" y="240" width="50" height="32" rx="6" class="sub"/><rect x="275" y="240" width="50" height="32" rx="6" class="sub"/>
+  <g class="t" text-anchor="middle"><text x="175" y="111">p</text><text x="250" y="176">x</text><text x="120" y="202">A</text><text x="215" y="262">B</text><text x="300" y="262">C</text></g>
+  <path d="M382 155 H505" class="a" fill="none"/><path d="M505 155 l-15 -9 v18z" fill="#16a34a"/><text x="395" y="130" class="s">x 绕 p 上来</text>
+  <text x="640" y="70" class="s">旋转后</text><line x1="655" y1="105" x2="575" y2="170" class="e"/><line x1="655" y1="105" x2="730" y2="180" class="e"/><line x1="575" y1="170" x2="535" y2="240" class="e"/><line x1="575" y1="170" x2="620" y2="240" class="e"/>
+  <circle cx="655" cy="105" r="23" class="n"/><circle cx="575" cy="170" r="23" class="n"/><rect x="510" y="240" width="50" height="32" rx="6" class="sub"/><rect x="595" y="240" width="50" height="32" rx="6" class="sub"/><rect x="705" y="180" width="50" height="32" rx="6" class="sub"/>
+  <g class="t" text-anchor="middle"><text x="655" y="111">x</text><text x="575" y="176">p</text><text x="535" y="262">A</text><text x="620" y="262">B</text><text x="730" y="202">C</text></g>
+  <text x="280" y="285" class="s">键范围:A &lt; p &lt; B &lt; x &lt; C;B 只是换到 p 的右边。</text>
+</svg>

+ 8 - 0
课程/插图/11-平衡树/00-Splay-02-zig的三种节奏.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="990" height="340" viewBox="0 0 990 340">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.x{fill:#fef3c7;stroke:#d97706;stroke-width:4}.e{stroke:#64748b;stroke-width:3}.arrow{stroke:#16a34a;stroke-width:4;fill:none}</style>
+  <rect width="990" height="340" fill="white"/><text x="30" y="33" class="t" font-weight="bold">Splay 每轮只看 x、父亲 p、祖父 g 的相对方向</text>
+  <g transform="translate(35 65)"><text x="65" y="0" class="t">zig:p 是根</text><line x1="70" y1="42" x2="70" y2="105" class="e"/><circle cx="70" cy="42" r="21" class="n"/><circle cx="70" cy="115" r="21" class="x"/><text x="70" y="48" class="t" text-anchor="middle">p</text><text x="70" y="121" class="t" text-anchor="middle">x</text><path d="M135 90 h75" class="arrow"/><path d="M210 90 l-12 -7 v14z" fill="#16a34a"/><text x="125" y="145" class="s">rotate(x) 一次</text></g>
+  <g transform="translate(345 65)"><text x="30" y="0" class="t">zig-zig:同向</text><line x1="75" y1="42" x2="75" y2="100" class="e"/><line x1="75" y1="100" x2="75" y2="160" class="e"/><circle cx="75" cy="42" r="20" class="n"/><circle cx="75" cy="100" r="20" class="n"/><circle cx="75" cy="160" r="20" class="x"/><g class="t" text-anchor="middle"><text x="75" y="48">g</text><text x="75" y="106">p</text><text x="75" y="166">x</text></g><path d="M145 100 h85" class="arrow"/><path d="M230 100 l-12 -7 v14z" fill="#16a34a"/><text x="20" y="210" class="s">先 rotate(p),再 rotate(x)</text></g>
+  <g transform="translate(675 65)"><text x="20" y="0" class="t">zig-zag:异向</text><line x1="70" y1="42" x2="35" y2="100" class="e"/><line x1="35" y1="100" x2="85" y2="160" class="e"/><circle cx="70" cy="42" r="20" class="n"/><circle cx="35" cy="100" r="20" class="n"/><circle cx="85" cy="160" r="20" class="x"/><g class="t" text-anchor="middle"><text x="70" y="48">g</text><text x="35" y="106">p</text><text x="85" y="166">x</text></g><path d="M145 100 h85" class="arrow"/><path d="M230 100 l-12 -7 v14z" fill="#16a34a"/><text x="12" y="210" class="s">rotate(x) 连续两次</text></g>
+  <text x="35" y="305" class="s">黄色 x 是目标;“同向/异向”只看 x 相对 p 与 p 相对 g 的左右方向,不看键的大小。</text>
+</svg>

+ 7 - 0
课程/插图/11-平衡树/00-Treap-01-数组与退化树.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 300" role="img" aria-labelledby="title desc">
+  <title id="title">有序数组插入与退化 BST</title><desc id="desc">有序数组插入中间需移动尾部,递增键插入普通二叉搜索树会成为链。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.cell{fill:#eff6ff;stroke:#2563eb;stroke-width:3}.new{fill:#dcfce7;stroke:#16a34a;stroke-width:3}.move{stroke:#d97706;stroke-width:4;fill:none}.edge{stroke:#64748b;stroke-width:4}.node{fill:#fff;stroke:#2563eb;stroke-width:3}.note{font-size:18px}.label{font-size:21px;font-weight:700}</style>
+  <text x="25" y="30" class="note">左:有序数组插入要移动尾部;右:递增键让普通 BST 变成一条链。</text><text x="110" y="65" class="note">有序数组 [2,4,7,9],插入 5</text>
+  <g transform="translate(100 95)"><rect class="cell" x="0" y="0" width="70" height="48"/><rect class="cell" x="70" y="0" width="70" height="48"/><rect class="new" x="140" y="0" width="70" height="48"/><rect class="cell" x="210" y="0" width="70" height="48"/><rect class="cell" x="280" y="0" width="70" height="48"/><text class="label" x="26" y="32">2</text><text class="label" x="96" y="32">4</text><text class="label" x="166" y="32">5</text><text class="label" x="236" y="32">7</text><text class="label" x="306" y="32">9</text></g><path class="move" d="M345 172 L405 172" marker-end="url(#a)"/><text x="155" y="205" class="note">7、9 要右移</text>
+  <text x="575" y="65" class="note">递增插入 2,4,5,7</text><line class="edge" x1="625" y1="105" x2="700" y2="145"/><line class="edge" x1="700" y1="145" x2="775" y2="185"/><line class="edge" x1="775" y1="185" x2="845" y2="225"/><circle class="node" cx="625" cy="105" r="22"/><circle class="node" cx="700" cy="145" r="22"/><circle class="node" cx="775" cy="185" r="22"/><circle class="node" cx="845" cy="225" r="22"/><text class="label" x="617" y="113">2</text><text class="label" x="692" y="153">4</text><text class="label" x="767" y="193">5</text><text class="label" x="837" y="233">7</text><text x="580" y="275" class="note">高度变成 n,查找也慢。</text><defs><marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#d97706"/></marker></defs>
+</svg>

+ 5 - 0
课程/插图/11-平衡树/00-Treap-02-双不变量.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 305" role="img" aria-labelledby="title desc">
+  <title id="title">Treap 的键值和优先级双不变量</title><desc id="desc">节点键遵守左小右大,优先级遵守父大于孩子。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#64748b;stroke-width:4}.node{fill:#eff6ff;stroke:#2563eb;stroke-width:4}.root{fill:#fef3c7;stroke:#d97706;stroke-width:4}.note{font-size:18px}.label{font-size:18px;font-weight:700}</style>
+  <text x="25" y="30" class="note">每个节点写作 key / priority:中序按 key,树顶按 priority。</text><line class="edge" x1="440" y1="96" x2="265" y2="190"/><line class="edge" x1="440" y1="96" x2="620" y2="190"/><line class="edge" x1="265" y1="190" x2="170" y2="260"/><line class="edge" x1="620" y1="190" x2="710" y2="260"/><circle class="root" cx="440" cy="96" r="42"/><circle class="node" cx="265" cy="190" r="42"/><circle class="node" cx="620" cy="190" r="42"/><circle class="node" cx="170" cy="260" r="36"/><circle class="node" cx="710" cy="260" r="36"/><text class="label" x="413" y="91">4 / 92</text><text class="label" x="238" y="185">2 / 51</text><text class="label" x="593" y="185">7 / 74</text><text class="label" x="148" y="256">1/18</text><text class="label" x="688" y="256">9/33</text><text x="55" y="82" class="note">左边键更小</text><text x="673" y="82" class="note">右边键更大</text><text x="285" y="42" class="note" fill="#b45309">92 比孩子优先级都大</text>
+</svg>

+ 7 - 0
课程/插图/11-平衡树/00-Treap-03-分裂合并.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 920 320" role="img" aria-labelledby="title desc">
+  <title id="title">FHQ Treap 的 split 与 merge</title><desc id="desc">一棵树按键四分成键不大于四和键大于四的两棵树,再按优先级合并。</desc>
+  <style>text{font-family:system-ui,sans-serif;fill:#172033}.edge{stroke:#64748b;stroke-width:3}.node{fill:#eff6ff;stroke:#2563eb;stroke-width:3}.left{fill:#dcfce7;stroke:#16a34a;stroke-width:3}.right{fill:#ffedd5;stroke:#ea580c;stroke-width:3}.arr{stroke:#d97706;stroke-width:4;fill:none}.note{font-size:17px}.label{font-size:18px;font-weight:700}</style>
+  <text x="24" y="28" class="note">split(root,4):键 ≤4 去左树,键 >4 去右树;merge 再由优先级决定根。</text>
+  <text x="100" y="65" class="note">原树</text><line class="edge" x1="155" y1="105" x2="105" y2="155"/><line class="edge" x1="155" y1="105" x2="205" y2="155"/><line class="edge" x1="205" y1="155" x2="235" y2="205"/><circle class="node" cx="155" cy="105" r="25"/><circle class="node" cx="105" cy="155" r="25"/><circle class="node" cx="205" cy="155" r="25"/><circle class="node" cx="235" cy="205" r="25"/><text class="label" x="148" y="112">4</text><text class="label" x="98" y="162">2</text><text class="label" x="198" y="162">7</text><text class="label" x="228" y="212">5</text><path class="arr" d="M270 145 L360 145" marker-end="url(#a)"/>
+  <text x="395" y="65" class="note">左半(≤4)</text><circle class="left" cx="445" cy="105" r="25"/><circle class="left" cx="390" cy="165" r="25"/><line class="edge" x1="445" y1="105" x2="390" y2="165"/><text class="label" x="438" y="112">4</text><text class="label" x="383" y="172">2</text><text x="580" y="65" class="note">右半(>4)</text><circle class="right" cx="650" cy="105" r="25"/><circle class="right" cx="705" cy="165" r="25"/><line class="edge" x1="650" y1="105" x2="705" y2="165"/><text class="label" x="643" y="112">7</text><text class="label" x="698" y="172">5</text><path class="arr" d="M520 250 L615 250" marker-end="url(#a)"/><text x="466" y="278" class="note">merge:比较两根优先级</text><defs><marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#d97706"/></marker></defs>
+</svg>

+ 11 - 0
课程/插图/11-平衡树/00-文艺-01-隐式位置与size.svg

@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="930" height="330" viewBox="0 0 930 330">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.e{stroke:#64748b;stroke-width:3}.badge{fill:#dbeafe;stroke:#2563eb;stroke-width:2}.key{fill:#fff7ed;stroke:#ea580c;stroke-width:2}</style>
+  <rect width="930" height="330" fill="#f8fafc"/><text x="30" y="34" class="t" font-weight="bold">隐式树的“键”藏在中序位置;value 即使重复也不影响定位</text>
+  <g class="e"><line x1="450" y1="78" x2="300" y2="170"/><line x1="450" y1="78" x2="610" y2="170"/><line x1="300" y1="170" x2="210" y2="260"/><line x1="300" y1="170" x2="375" y2="260"/><line x1="610" y1="170" x2="535" y2="260"/><line x1="610" y1="170" x2="700" y2="260"/></g>
+  <g class="n"><circle cx="450" cy="78" r="28"/><circle cx="300" cy="170" r="28"/><circle cx="610" cy="170" r="28"/><circle cx="210" cy="260" r="28"/><circle cx="375" cy="260" r="28"/><circle cx="535" cy="260" r="28"/><circle cx="700" cy="260" r="28"/></g>
+  <g class="t" text-anchor="middle"><text x="450" y="78">D</text><text x="300" y="170">B</text><text x="610" y="170">F</text><text x="210" y="260">A</text><text x="375" y="260">C</text><text x="535" y="260">E</text><text x="700" y="260">G</text></g>
+  <g class="s" text-anchor="middle"><text x="450" y="99">size=7</text><text x="300" y="191">size=3</text><text x="610" y="191">size=3</text></g>
+  <rect x="60" y="55" width="135" height="58" rx="8" class="key"/><text x="75" y="80" class="t">问:第 5 项?</text><text x="75" y="102" class="s">不比较字母大小</text>
+  <path d="M195 95 H250" stroke="#ea580c" stroke-width="3"/><text x="60" y="155" class="s">根 D 左 size=3:</text><text x="60" y="177" class="s">5 &gt; 3+1,去右</text><text x="60" y="199" class="s">并把 k 改为 1。</text>
+  <text x="330" y="315" class="s">中序 A,B,C,D,E,F,G 才是位置 1..7;树形可转,顺序语义不转。</text>
+</svg>

+ 14 - 0
课程/插图/11-平衡树/00-文艺-02-双哨兵与区间暴露.svg

@@ -0,0 +1,14 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="960" height="330" viewBox="0 0 960 330">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.card{fill:#fff;stroke:#2563eb;stroke-width:3}.sent{fill:#e2e8f0;stroke:#475569;stroke-width:3}.mid{fill:#fef3c7;stroke:#d97706;stroke-width:4}.e{stroke:#64748b;stroke-width:3}.tree{fill:#dcfce7;stroke:#16a34a;stroke-width:3}</style>
+  <rect width="960" height="330" fill="white"/><text x="32" y="34" class="t" font-weight="bold">双哨兵:先用 A 与 F 夹住 B..E,再把 B..E 变成 F 的左子树</text>
+  <text x="45" y="78" class="s">完整中序:</text>
+  <g transform="translate(130 55)"><rect x="0" y="0" width="58" height="42" rx="7" class="sent"/><rect x="65" y="0" width="58" height="42" rx="7" class="card"/><rect x="130" y="0" width="58" height="42" rx="7" class="mid"/><rect x="195" y="0" width="58" height="42" rx="7" class="mid"/><rect x="260" y="0" width="58" height="42" rx="7" class="mid"/><rect x="325" y="0" width="58" height="42" rx="7" class="mid"/><rect x="390" y="0" width="58" height="42" rx="7" class="card"/><rect x="455" y="0" width="58" height="42" rx="7" class="sent"/>
+  <g class="t" text-anchor="middle"><text x="29" y="27">L</text><text x="94" y="27">A</text><text x="159" y="27">B</text><text x="224" y="27">C</text><text x="289" y="27">D</text><text x="354" y="27">E</text><text x="419" y="27">F</text><text x="484" y="27">R</text></g></g>
+  <text x="205" y="125" class="s">x=kth(l)=A</text><text x="510" y="125" class="s">y=kth(r+2)=F</text>
+  <path d="M340 145 C370 155 395 160 430 165" stroke="#16a34a" stroke-width="4" fill="none"/><path d="M430 165 l-14 -9 v18z" fill="#16a34a"/>
+  <text x="350" y="150" class="s">splay x 到根,再让 y 成为 x 的右儿子</text>
+  <circle cx="300" cy="235" r="27" class="card"/><circle cx="620" cy="235" r="27" class="card"/><rect x="410" y="220" width="125" height="55" rx="9" class="tree"/>
+  <g class="e"><line x1="327" y1="235" x2="410" y2="247"/><line x1="535" y1="247" x2="593" y2="235"/></g>
+  <g class="t" text-anchor="middle"><text x="300" y="241">A=x</text><text x="472" y="244">B,C,D,E</text><text x="472" y="263" class="s">y.left</text><text x="620" y="241">F=y</text></g>
+  <text x="250" y="310" class="s">目标区间不再散在多个分支,而成为可整体打 rev、读 sum 的一棵子树。</text>
+</svg>

+ 8 - 0
课程/插图/11-平衡树/02-隐式Treap-01-三段分裂.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="940" height="300" viewBox="0 0 940 300">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.seg{fill:#fff;stroke:#2563eb;stroke-width:3}.mid{fill:#fef3c7;stroke:#d97706;stroke-width:4}.a{stroke:#16a34a;stroke-width:4;fill:none}</style>
+  <rect width="940" height="300" fill="#f8fafc"/><text x="32" y="34" class="t" font-weight="bold">隐式 FHQ:按排名 split 两次,把 [l,r] 单独拿成 mid 根</text>
+  <g transform="translate(50 95)"><rect x="0" y="0" width="450" height="58" rx="10" class="seg"/><line x1="130" y1="0" x2="130" y2="58" stroke="#2563eb" stroke-width="3"/><line x1="330" y1="0" x2="330" y2="58" stroke="#d97706" stroke-width="4"/><text x="65" y="35" class="t" text-anchor="middle">前缀 1..l-1</text><text x="230" y="35" class="t" text-anchor="middle">目标 l..r</text><text x="390" y="35" class="t" text-anchor="middle">后缀</text></g>
+  <path d="M510 125 H620" class="a"/><path d="M620 125 l-14 -8 v16z" fill="#16a34a"/><text x="520" y="98" class="s">split(root,r),再 split(左,l-1)</text>
+  <g transform="translate(650 75)"><rect x="0" y="0" width="80" height="55" rx="8" class="seg"/><rect x="95" y="0" width="80" height="55" rx="8" class="mid"/><rect x="190" y="0" width="80" height="55" rx="8" class="seg"/><g class="t" text-anchor="middle"><text x="40" y="33">left</text><text x="135" y="33">mid</text><text x="230" y="33">right</text></g></g>
+  <text x="57" y="235" class="s">改变区间内容:只操作 mid;再 merge(left, merge(mid,right)),每段内部中序不变。</text>
+</svg>

+ 12 - 0
课程/插图/11-平衡树/02-隐式Treap-02-rev标记.svg

@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="940" height="315" viewBox="0 0 940 315">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.tag{fill:#fef3c7;stroke:#d97706;stroke-width:3}.e{stroke:#64748b;stroke-width:3}.a{stroke:#dc2626;stroke-width:4;fill:none}</style>
+  <rect width="940" height="315" fill="white"/><text x="32" y="34" class="t" font-weight="bold">rev Lazy:不逐项翻转,只交换根的左右孩子并翻转一位标记</text>
+  <g class="e"><line x1="200" y1="100" x2="115" y2="205"/><line x1="200" y1="100" x2="285" y2="205"/></g><circle cx="200" cy="100" r="28" class="n"/><circle cx="115" cy="205" r="28" class="n"/><circle cx="285" cy="205" r="28" class="n"/>
+  <g class="t" text-anchor="middle"><text x="200" y="106">mid</text><text x="115" y="211">左段</text><text x="285" y="211">右段</text></g>
+  <rect x="160" y="45" width="80" height="30" rx="7" class="tag"/><text x="200" y="66" class="t" text-anchor="middle">rev ^= 1</text>
+  <path d="M355 155 H505" class="a"/><path d="M505 155 l-14 -8 v16z" fill="#dc2626"/><text x="378" y="132" class="s">swap(left,right)</text>
+  <g class="e"><line x1="680" y1="100" x2="765" y2="205"/><line x1="680" y1="100" x2="595" y2="205"/></g><circle cx="680" cy="100" r="28" class="n"/><circle cx="595" cy="205" r="28" class="n"/><circle cx="765" cy="205" r="28" class="n"/>
+  <g class="t" text-anchor="middle"><text x="680" y="106">mid</text><text x="595" y="211">右段</text><text x="765" y="211">左段</text></g>
+  <rect x="640" y="45" width="80" height="30" rx="7" class="tag"/><text x="680" y="66" class="t" text-anchor="middle">rev ^= 1</text>
+  <text x="55" y="285" class="s">下一次真正走入孩子前再 push;两次翻转会抵消,故标记用 XOR/布尔取反复合。</text>
+</svg>

+ 8 - 0
课程/插图/11-平衡树/03-Splay-01-三种旋转.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="990" height="355" viewBox="0 0 990 355">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.x{fill:#fef3c7;stroke:#d97706;stroke-width:4}.e{stroke:#64748b;stroke-width:3}.a{stroke:#dc2626;stroke-width:4;fill:none}</style>
+  <rect width="990" height="355" fill="#f8fafc"/><text x="28" y="33" class="t" font-weight="bold">Splay 的三种局部动作:旋转顺序由祖孙方向决定</text>
+  <g transform="translate(35 75)"><text x="50" y="0" class="t">zig</text><line x1="65" y1="35" x2="65" y2="97" class="e"/><circle cx="65" cy="35" r="20" class="n"/><circle cx="65" cy="107" r="20" class="x"/><text x="65" y="41" class="t" text-anchor="middle">p</text><text x="65" y="113" class="t" text-anchor="middle">x</text><text x="8" y="155" class="s">父亲即 goal</text></g>
+  <g transform="translate(330 75)"><text x="33" y="0" class="t">zig-zig</text><line x1="60" y1="35" x2="60" y2="96" class="e"/><line x1="60" y1="96" x2="60" y2="157" class="e"/><circle cx="60" cy="35" r="20" class="n"/><circle cx="60" cy="96" r="20" class="n"/><circle cx="60" cy="157" r="20" class="x"/><g class="t" text-anchor="middle"><text x="60" y="41">g</text><text x="60" y="102">p</text><text x="60" y="163">x</text></g><path d="M135 96 h60" class="a"/><path d="M195 96 l-12 -7 v14z" fill="#dc2626"/><text x="-7" y="210" class="s">同向:先 p,后 x</text></g>
+  <g transform="translate(655 75)"><text x="33" y="0" class="t">zig-zag</text><line x1="70" y1="35" x2="35" y2="96" class="e"/><line x1="35" y1="96" x2="85" y2="157" class="e"/><circle cx="70" cy="35" r="20" class="n"/><circle cx="35" cy="96" r="20" class="n"/><circle cx="85" cy="157" r="20" class="x"/><g class="t" text-anchor="middle"><text x="70" y="41">g</text><text x="35" y="102">p</text><text x="85" y="163">x</text></g><path d="M145 96 h60" class="a"/><path d="M205 96 l-12 -7 v14z" fill="#dc2626"/><text x="-8" y="210" class="s">异向:x 连旋两次</text></g>
+  <text x="35" y="323" class="s">每次 rotate 都保留中序;每次 splay 循环让 x 更靠近 goal。带 Lazy 时,旋前须从祖先到 x 依次 push。</text>
+</svg>

+ 10 - 0
课程/插图/11-平衡树/03-Splay-02-双哨兵暴露.svg

@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="950" height="330" viewBox="0 0 950 330">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.n{fill:#fff;stroke:#2563eb;stroke-width:3}.sent{fill:#e2e8f0;stroke:#475569;stroke-width:3}.mid{fill:#fef3c7;stroke:#d97706;stroke-width:4}.e{stroke:#64748b;stroke-width:3}.a{stroke:#16a34a;stroke-width:5;fill:none}</style>
+  <rect width="950" height="330" fill="white"/><text x="32" y="34" class="t" font-weight="bold">双哨兵区间暴露:x=kth(l) 为根,y=kth(r+2) 为 x 的右儿子</text>
+  <g class="e"><line x1="280" y1="105" x2="520" y2="175"/><line x1="520" y1="175" x2="405" y2="250"/><line x1="520" y1="175" x2="700" y2="250"/></g>
+  <circle cx="280" cy="105" r="29" class="sent"/><circle cx="520" cy="175" r="29" class="sent"/><rect x="340" y="222" width="130" height="56" rx="9" class="mid"/><circle cx="700" cy="250" r="29" class="n"/>
+  <g class="t" text-anchor="middle"><text x="280" y="111">x</text><text x="280" y="137" class="s">前驱/哨兵</text><text x="520" y="181">y</text><text x="520" y="207" class="s">后继/哨兵</text><text x="405" y="246">目标 [l,r]</text><text x="405" y="268" class="s">y.left</text><text x="700" y="256">其余后缀</text></g>
+  <path d="M136 78 C175 50 222 52 255 77" class="a"/><path d="M255 77 l-14 -9 v18z" fill="#16a34a"/><text x="48" y="72" class="s">先 splay(x, root)</text>
+  <path d="M620 88 C620 120 590 145 550 155" class="a"/><path d="M550 155 l10 -13 5 16z" fill="#16a34a"/><text x="625" y="80" class="s">再 splay(y, x)</text>
+  <text x="145" y="316" class="s">翻转:对黄色子树 apply_rev;求和:直接读它的 sum。两端哨兵消除了 l=1、r=n 特判。</text>
+</svg>

+ 9 - 0
课程/插图/11-平衡树/04-ODT-01-切出区间.svg

@@ -0,0 +1,9 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="940" height="300" viewBox="0 0 940 300">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.seg{fill:#fff;stroke:#2563eb;stroke-width:3}.mid{fill:#fef3c7;stroke:#d97706;stroke-width:4}.a{stroke:#16a34a;stroke-width:4;fill:none}</style>
+  <rect width="940" height="300" fill="#f8fafc"/><text x="32" y="34" class="t" font-weight="bold">ODT 的统一接口:split(l)、split(r+1) 后,中间迭代器范围恰好覆盖 [l,r]</text>
+  <g transform="translate(55 90)"><rect x="0" y="0" width="145" height="55" rx="8" class="seg"/><rect x="145" y="0" width="130" height="55" rx="8" class="mid"/><rect x="275" y="0" width="160" height="55" rx="8" class="mid"/><rect x="435" y="0" width="140" height="55" rx="8" class="seg"/><g class="t" text-anchor="middle"><text x="72" y="33">前缀段</text><text x="210" y="33">中央段 1</text><text x="355" y="33">中央段 2</text><text x="505" y="33">后缀段</text></g></g>
+  <line x1="200" y1="70" x2="200" y2="170" stroke="#dc2626" stroke-width="3" stroke-dasharray="7 5"/><line x1="635" y1="70" x2="635" y2="170" stroke="#dc2626" stroke-width="3" stroke-dasharray="7 5"/>
+  <text x="173" y="61" class="s">begin</text><text x="610" y="61" class="s">end</text>
+  <path d="M360 170 v46" class="a"/><path d="M360 216 l-9 -14 h18z" fill="#16a34a"/>
+  <text x="70" y="250" class="s">add:逐段改 value;assign:erase(begin,end) 再插 [l,r,x];查询:按 (value,length) 聚合。</text>
+</svg>

+ 8 - 0
课程/插图/11-平衡树/04-ODT-02-随机契约与反例.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="950" height="310" viewBox="0 0 950 310">
+  <style>.t{font:16px sans-serif;fill:#1f2937}.s{font:13px sans-serif;fill:#475569}.good{fill:#dcfce7;stroke:#16a34a;stroke-width:3}.bad{fill:#fee2e2;stroke:#dc2626;stroke-width:3}.cell{fill:#fff;stroke:#dc2626;stroke-width:2}.a{stroke:#64748b;stroke-width:3}</style>
+  <rect width="950" height="310" fill="white"/><text x="32" y="34" class="t" font-weight="bold">复杂度的分界不是“是否用了 set”,而是当前段数能否被题面过程压住</text>
+  <rect x="65" y="76" width="345" height="165" rx="12" class="good"/><text x="86" y="107" class="t" font-weight="bold">题目给出随机长区间赋值</text><rect x="95" y="138" width="275" height="44" rx="7" fill="#fff" stroke="#16a34a" stroke-width="2"/><text x="232" y="166" class="t" text-anchor="middle">许多旧段  →  一张新段</text><text x="87" y="215" class="s">段数常被压缩;但这是输入契约,</text><text x="87" y="234" class="s">不是每个 ODT 操作的最坏界。</text>
+  <rect x="535" y="76" width="345" height="165" rx="12" class="bad"/><text x="556" y="107" class="t" font-weight="bold">对抗:全异值 + 全区间加</text>
+  <g transform="translate(560 135)"><rect x="0" y="0" width="30" height="35" class="cell"/><rect x="30" y="0" width="30" height="35" class="cell"/><rect x="60" y="0" width="30" height="35" class="cell"/><rect x="90" y="0" width="30" height="35" class="cell"/><rect x="120" y="0" width="30" height="35" class="cell"/><rect x="150" y="0" width="30" height="35" class="cell"/><rect x="180" y="0" width="30" height="35" class="cell"/><rect x="210" y="0" width="30" height="35" class="cell"/><rect x="240" y="0" width="30" height="35" class="cell"/><text x="135" y="59" class="s" text-anchor="middle">每格一段,add(1,n,+1) 仍要扫 n 段</text></g>
+  <text x="557" y="225" class="s">值差仍为 1,段数长期 Θ(n)。</text>
+</svg>