WEEK 03

需求变绕了:子查询、CTE 与章法

商品越上越多,公司上线了类目体系(categories 表进场);运营的需求也从「统计一下」变成了「找出买过 A 又没买过 B 的用户」这种绕来绕去的活。你开始需要把复杂需求拆开、有章法地写的本事。
目标:拿到一个绕的需求,能拆成几步、有章法地写出来,而不是硬凑。
0 / 7 天
剧情 商品要归类了。类目是「电子 > 手机 > 配件」这样的树,存在 parent_id 里。第一个需求:按一级类目看销售。
学 · 40 min
  • 自关联表:parent_id 指回本表

    场景 类目是棵树:「电子 > 手机 > 配件」。树要怎么塞进一张表?

    讲解 每行一个类目,加一列 parent_id 指向本表里父类目的 id,顶级类目的 parent_id 为 NULL。这叫邻接表模型:树结构不需要多张表,一列自引用就够,而且加层级不用改表结构。

    试试
    -- 表里长这样:父和子是同一张表里的行
    select id, name, parent_id from categories order by id limit 6;

    易错 树的深度没有上限。任何「固定跳两层 JOIN」的写法都偷偷假设了「树只有两层」--需求一说「所有层级」就得换递归(D18)。

  • 自连接:同一张表起两个别名连自己

    场景 要输出「一级类目名 | 二级类目名」对照表,可父和子在同一张表里。

    讲解 同一张表在 FROM 里出现两次、起两个别名(c 演父、s 演子),连接条件写 s.parent_id = c.id。对数据库来说是两个独立的数据源,对你来说是同一张表的两个角色--这就是自连接的全部秘密。

    试试
    select c.name as 一级类目, s.name as 二级类目
    from categories c
    join categories s on s.parent_id = c.id
    order by 1, 2;

    易错 方向别写反:c.parent_id = s.id 查出来的是「父挂子名」,全表错位但不报错。

  • 类目树的三种基本查询:全表平铺 / 按一级分组 / 查直接孩子

    场景 拿到一棵树,日常打交道的就是这三个动作。

    讲解 ① 平铺:直接 select *,树形靠 parent_id 脑补;② 按一级分组:先筛出顶级类目(parent_id is null),再自连接挂上孩子;③ 查某类目的直接孩子:where parent_id = 某id,只下一层。

    试试
    -- ② 一级类目和它的直接孩子
    select c.name as 一级, s.name as 孩子
    from categories c
    join categories s on s.parent_id = c.id
    order by 1;
    
    -- ③ 「手机」的直接孩子
    select id, name from categories
    where parent_id = (select id from categories where name = '手机');

    易错 「按一级分组」要先问一句:商品的 category_id 挂在几级?可能挂二级、可能直接挂一级,路径不定就是递归的活。

  • 「按一级类目汇总」的连接链要跳两层(明细 -> 商品 -> 二级 -> 一级)

    场景 老板要按一级类目看 GMV。order_items 里只有 product_id,商品的 category_id 还未必是顶级。

    讲解 连接链四步:order_items ->(product_id)products 拿 category_id ->(category_id)categories 第一次进,拿到二级类目 ->(parent_id)categories 第二次进,拿到一级类目的名字。categories 表进两次、两个别名,一个演二级一个演一级。

    试试
    select c1.name as 一级类目,
           sum(i.qty * i.unit_price) as gmv
    from order_items i
    join products   p  on p.id = i.product_id
    join categories c2 on c2.id = p.category_id
    join categories c1 on c1.id = c2.parent_id
    group by 1
    order by gmv desc;

    易错 类目树不止两层时,c2 可能本身就是顶级(parent_id 为 NULL),这些商品的 GMV 会整段消失。先 coalesce 兜底或统一归到根。

练 · 65 min
  1. 跑 seed.sql「W3」段:建 categories 并回填 products.category_id
  2. 自连接输出「一级类目名 | 二级类目名」全表
  3. 按一级类目统计 GMV(order_items -> products -> categories 连两次)
  4. 给定一个二级类目,查出它的父类目
  5. 想想:为什么 products 不直接冗余一级类目 id?写两句利弊进 notes.md
过关按一级类目的 GMV 报表跑通,连接链能画出来。
专注视图 ->
剧情 老板:「找出消费高于平均水平的那批人,我给他们发券。」平均值本身就要一条查询来算--你第一次需要查询里套查询。
学 · 40 min
  • 标量子查询(SELECT 里):必须只返回一行一列

    场景 「消费高于平均水平」--平均水平这个数,得先用一条查询算出来。

    讲解 返回一行一列的子查询叫标量子查询,可以放在任何「单个值」能放的位置:SELECT 列表里、比较运算符右边。它先算一次,结果当一个常量用。

    试试
    select user_id,
           sum(total_amount) as 消费额,
           sum(total_amount) - (select avg(total_amount) from orders) as 高于平均多少
    from orders
    group by user_id;

    易错 子查询返回两行就报错 more than one row returned--而且这是运行时才炸:测试数据少时潜伏,上线数据多了才炸。

  • 派生表(FROM 里):必须起别名

    场景 「每个用户的订单数 + 用户名」--订单数要先聚合,users 的名字在另一张表,两步走。

    讲解 from (子查询) t:先把子查询算成一张临时表,再对这张表连接、过滤。PG 强制要求给这个临时表起别名。它是「先聚合再连接」套路的标准载体--凡是你想说「先把 X 算出来,再和 Y 拼」,就是派生表。

    试试
    select u.name, t.单数
    from (
      select user_id, count(*) as 单数
      from orders
      group by user_id
    ) t
    join users u on u.id = t.user_id;

    易错 忘写别名是语法错;别名和真实表撞名,连接时列的归属会看不清。

  • 条件子查询(WHERE 里):单值与多值

    场景 「有过已支付记录的订单」--过滤条件本身是一张查询的结果。

    讲解 用哪个运算符,取决于子查询吐出什么形状:一行一列= <>一列多行IN / NOT IN;多列的极少(配行构造器)。写之前先问自己:这个子查询大概返回几行几列?

    试试
    select *
    from orders
    where user_id in (select user_id from orders where status = 2);
    -- 单值版:= (select avg(total_amount) from orders)

    易错 ANY / ALL 也接多值一列(如 > ANY 表示「大于其中任意一个」),知道存在即可,可读性差、少用。

  • 相关子查询:什么时候会被执行 N 次

    场景 同一条 SQL,小表上秒出,大数据量上突然慢几十倍--先怀疑它。

    讲解 判断标准:子查询里引用了外层的列(如 o.user_id)就是相关子查询。逻辑上外层每行都要带着自己的值进子查询算一遍,N 行 = N 次。非相关子查询只算一次、结果当常量。明天 D17 整天跟它打交道。

    试试
    -- 相关:每个用户和"他自己的"平均客单价比
    select id, total_amount
    from orders o
    where total_amount >= (select avg(total_amount)
                           from orders o2
                           where o2.user_id = o.user_id);

    易错 EXPLAIN 里看到 SubPlan 出现在大行数节点之下,就是「每行执行一次」的现场,先估一下 总行数 × 子查询成本。

练 · 65 min
  1. 用标量子查询输出「每个用户消费额 − 全站平均消费额」
  2. 用派生表先按用户聚合订单数,再连接 users 输出
  3. WHERE id IN (子查询) 查有过已支付记录的订单
  4. 写一个返回多行的标量子查询,记录报错信息
  5. 同一需求分别用派生表和 CTE 写,对比可读性
过关能说出标量子查询在什么情况下会被执行 N 次(相关子查询)。
专注视图 ->
剧情 同行群里的事故通报:有人用 NOT IN 子查询查「没下过单的用户」,上线后返回空集,被运营当成故障投诉。你要彻底搞懂,别踩同一个坑。
学 · 45 min
  • 相关子查询 vs 非相关子查询的执行方式

    场景 D16 留的尾巴,今天掰开:两种子查询在引擎眼里是两种东西。

    讲解 非相关子查询(不引用外层列)算一次当常量,优化器还常把它改写成 hash semi-join,效率不差。相关子查询(引用了外层列)逻辑上每行执行一次--但现代优化器对 EXISTS / IN 也常能改写成 join,别靠猜,靠 EXPLAIN。看计划里是 Hash Semi Join(已改写)还是 SubPlan(真在逐行执行)。

    试试
    -- 非相关:算一次
    select * from users where city = '上海' and id in (select user_id from orders);
    -- 相关:EXPLAIN 看它被改写没有
    select * from users u
    where exists (select 1 from orders o where o.user_id = u.id and o.status = 2);

    易错 「IN 慢、EXISTS 快」这句老话在 PG 上早已不一定成立--优化器面前两者常常殊途同归,面试要按版本讲。

  • EXISTS 的短路特性:找到一行就返回

    场景 查「下过单的用户」:其实只需要为每个用户找到一个证据

    讲解 exists (子查询) 只关心「有没有行返回」:扫到第一行立即返回 TRUE,剩下的全不看。所以子查询里 select 1 还是 select * 完全无所谓--列表根本不会被取值。NOT EXISTS 同理短路。

    试试
    select u.name
    from users u
    where exists (select 1
                  from orders o
                  where o.user_id = u.id);

    易错 exists 里别写聚合(子查询的意义是「存在性」,不是算数);select 1 是惯例,为了读的人一眼看出「这查的是存在性」。

  • NOT IN + NULL = 空集的完整推导

    场景 事故通报的还原:NOT IN 查「没下过单的用户」,上线返回空集,运营以为系统挂了。

    讲解 一步步推:x NOT IN (a, b, c) 等价于 x<>a AND x<>b AND x<>c。集合里混进一个 NULL,则 x <> NULL 得 UNKNOWN;UNKNOWN 参与 AND,整个表达式永远到不了 TRUE。WHERE 只放行 TRUE --整张表一行都过不了。这不是 bug,是三值逻辑的必然结论(D3 的伏笔今天收了)。

    试试
    select 2 not in (1, 3, null);   -- 结果是 NULL,不是 true!
    select count(*) from users
    where id not in (select user_id from orders);   -- user_id 有脏 NULL 时 = 0 行

    易错 子查询那一列可能含 NULL(脏数据、外连接结果),就永远别用 NOT IN--用 NOT EXISTS。

  • NOT EXISTS 与 LEFT JOIN ... IS NULL 两种反连接写法

    场景 「没下过单的用户」已经有两把枪了,加上 NOT EXISTS 是三把--该常备哪把?

    讲解 三者在正确性上等价:D10 的 LEFT JOIN + IS NULL、今天的 NOT EXISTS、以及有 NULL 陷阱的 NOT IN。性能上现代优化器常给三者生成相同计划;语义安全上 NOT EXISTS 没有 NULL 陷阱、也不要求右表连接列非空。默认写 NOT EXISTS,另两种能看懂能改写

    试试
    select u.name
    from users u
    where not exists (select 1 from orders o where o.user_id = u.id);
    
    -- 等价的 LEFT JOIN 版(D10)
    select u.name
    from users u
    left join orders o on o.user_id = u.id
    where o.id is null;

    易错 面试讲这道题的标准动作:先说「NOT IN 有 NULL 陷阱」,再现场推导,最后给出两种安全写法--一气呵成。

练 · 60 min
  1. 用 IN、EXISTS、JOIN 三种写法查「下过单的用户」,对比 EXPLAIN
  2. 在子查询列里制造 NULL,复现 NOT IN 返回空集
  3. 把第 2 题改成 NOT EXISTS,验证结果正确
  4. 再用 LEFT JOIN ... WHERE b.id IS NULL 写第三遍
  5. 在 12 万行明细上跑三种反连接写法,做一张耗时对比表
过关手上有三写法耗时对比表;能完整讲出 NOT IN 遇 NULL 的推导过程。
专注视图 ->
剧情 老板在类目管理页点了「电子」,想看它下面所有层级类目的汇总销售。层级不固定,普通 JOIN 搞不定。
学 · 45 min
  • WITH RECURSIVE 的三段结构:初始项 + UNION ALL + 递归项

    场景 「电子」下面所有层级--几层不知道,JOIN 写死了就不行。

    讲解 固定模板背下来:WITH RECURSIVE t AS ( 初始项 UNION ALL 递归项 )。初始项(锚点)给出起点:parent_id 指向「电子」的直接孩子;递归项引用 t 自己再往下走一层。执行过程:先算初始项 --> 结果喂给递归项 --> 算出的新行再喂回去 --> 直到没有新行为止。

    试试
    with recursive sub as (
      select id, name, parent_id from categories where parent_id = 1  -- 初始项:直接孩子
      union all
      select c.id, c.name, c.parent_id
      from categories c
      join sub s on c.parent_id = s.id                                -- 递归项:往下再走一层
    )
    select * from sub;

    易错 递归项只能引用 CTE 自己一次;把 UNION ALL 写成 UNION 会去重,语法没错但轮次语义悄悄变了。

  • 递归的终止条件与执行过程

    场景 递归没有 while,它怎么知道该停?

    讲解 终止条件是隐式的:某一轮递归项产出 0 行新数据,递归就停。没有「超过 N 层就停」的开关--树有多深就递归多深。想看清执行过程,在每行带一个 depth 列:初始项 depth=0,递归项 depth+1,结果按 depth 排就是「一层一层剥开」的现场。

    试试
    with recursive sub as (
      select id, name, 0 as depth from categories where id = 1
      union all
      select c.id, c.name, s.depth + 1
      from categories c
      join sub s on c.parent_id = s.id
    )
    select * from sub order by depth;

    易错 数据里有环(a 的父是 b、b 的父是 a)时每轮都有新行,递归永远不停--下一条讲怎么防。

  • 防死循环:记录已访问路径

    场景 脏数据造出环形类目树,一条递归查询把 CPU 跑满。

    讲解 两道保险:① 随身携带「已访问路径」数组,递归项里 where not c.id = any(路径),走过的节点不再走;② 加 depth < 20 硬上限兜底。生产上的递归查询两道都要有。

    试试
    with recursive sub as (
      select id, name, parent_id, 0 as depth, array[id] as path
      from categories where id = 1
      union all
      select c.id, c.name, c.parent_id, s.depth + 1, s.path || c.id
      from categories c
      join sub s on c.parent_id = s.id
      where not c.id = any(s.path)   -- 环保险
        and s.depth < 20             -- 深度保险
    )
    select * from sub;

    易错 递归跑不停时,从另一个窗口查 pg_stat_activity 能看到它 state = active 一直不动,pg_terminate_backend(pid) 停掉它。

  • 累积深度与路径拼接的技巧

    场景 「每个分类的完整路径(电子 > 手机 > 配件)」和「每个分类在第几层」--两个需求一个套路。

    讲解 递归项里维护「随身列」:depth = 父.depth + 1;path = 父.path || ' > ' || 本节点名。每行都带着「我是怎么来的」--这是递归 CTE 的万金油模式,碰到任何递归需求先想这两个随身列怎么带。

    试试
    with recursive tree as (
      select id, name, parent_id, name::text as path, 1 as depth
      from categories where parent_id is null
      union all
      select c.id, c.name, c.parent_id,
             t.path || ' > ' || c.name, t.depth + 1
      from categories c
      join tree t on c.parent_id = t.id
    )
    select name, path, depth from tree order by path;

    易错 路径用 text 拼接,类目名本身含「>」就有歧义;严格场景用数组类型存 path,展示时再 join。

练 · 60 min
  1. 查每个分类的完整路径(如「电子 > 手机 > 配件」)
  2. 给定「电子」,查它的全部子孙分类及其销售汇总
  3. 用递归 CTE 生成 2026 全年日期序列(不用 generate_series)
  4. 算出每个分类所在的层级深度
  5. 自建 employees 表做组织架构下钻,输出每人的汇报链
过关不看资料写出一个递归 CTE,并逐段解释初始项和递归项各做什么。
专注视图 ->
剧情 运营的原话:「近 3 个月,每月新客的 GMV 和老客的 GMV 分开算。」你决定以后拿到需求先拆步骤再动手,用 CTE 把每一步写成能读的段落。
学 · 40 min
  • WITH 基本语法与多级 CTE

    场景 派生表套派生表,三层括号已经读不懂了--需要给每个中间结果起名字。

    讲解 with a as (...), b as (...引用 a...), c as (...引用 b...) select ...:后面的能用前面的,自顶向下一条流水线。给每段起业务名(清洗 / 聚合 / 排名),SQL 从一坨嵌套变成分段的作文--CTE 的核心价值是给思路命名。

    试试
    with 聚合 as (
      select user_id, sum(total_amount) as gmv from orders group by 1
    ),
    带名字 as (
      select u.name, a.gmv from 聚合 a join users u on u.id = a.user_id
    )
    select * from 带名字 order by gmv desc limit 10;

    易错 CTE 名别和真实表名撞(撞了会遮蔽原表,查错极难发现);只能引用「写在它前面」的 CTE。

  • PG 12 起 CTE 默认可内联,之前版本是优化栅栏

    场景 老资料说「CTE 能防止条件推入、能提速」--按版本说话。

    讲解 PG 11 及以前:CTE 一律物化(算一次存临时表),并且是优化栅栏--外层的 where 推不进去。PG 12 起:只被引用一次的 CTE 默认内联(像视图一样展开进主查询,栅栏没了);被引用多次的仍然物化。

    试试
    select version();   -- 先知道自己站在哪个版本上讨论
    -- PG 12+:这个 CTE 会被内联,user_id 条件能推入
    with t as (select * from orders where status = 2)
    select * from t where user_id = '...';

    易错 面试聊 CTE 必带版本号。不加版本说「CTE 是优化栅栏」,遇到懂 PG 的面试官直接扣分。

  • MATERIALIZED / NOT MATERIALIZED 的显式控制

    场景 一个昂贵 的 CTE 要被引用两次--到底算一遍还是两遍?

    讲解 with t as materialized (...) 强制算一次存结果(引用 N 次也只算一次);as not materialized 强制内联(每次引用展开一次)。默认规则记不住没关系--在意就显式写,语义摆在自己手里。

    试试
    with t as materialized (   -- 昂贵计算只跑一次
      select user_id, count(*) as n from orders group by 1
    )
    select (select count(*) from t where n = 1) as 只下一单,
           (select count(*) from t where n >= 2) as 复购;

    易错 物化 = 写临时结果(可能落盘),有真实代价;不是免费缓存。「引用多次的昂贵 CTE」才值得物化。

  • 澄清误区:CTE 本身不是性能优化手段

    场景 网上文章标题《用 CTE 让查询快 10 倍》--你想知道它到底优化了什么。

    讲解 CTE 解决的是可读性和拆解,不是速度。内联的 CTE 和一坨子查询在优化器眼里是同一个东西;物化倒是省了重复计算,但那是「你把逻辑组织对了」的副产品。快是因为索引、是因为少算了东西,从来不是因为 with 这个关键字。

    易错 为了「性能」把查询套 10 层 CTE,既不快也不可读--方向就错了。

  • 复杂需求五步法:定粒度 -> 找主表 -> 逐步补维度 -> 定过滤位置 -> 最后聚合排序

    场景 运营原话:「近 3 个月,每月新客的 GMV 和老客的 GMV 分开算。」--先在注释里拆,再写 SQL。

    讲解 ① 定粒度:一行 = 月份 × 新老客;② 找主表:事实在 orders;③ 补维度:「新客」要判定(首单是否落在当月)--先算每个用户的首单月份;④ 定过滤:近 3 个月在哪一步过滤;⑤ 聚合排序最后写。五步写在注释里,SQL 一段对应一步,写完的需求自己都能复查。

    试试
    -- ① 粒度:月 × 新老客  ② 主表:orders
    with 首单 as (                       -- ③ 维度:首单月份
      select user_id, min(created_at)::date as 首单日 from orders group by 1
    )
    select date_trunc('month', o.created_at) as 月份,
           case when date_trunc('month', f.首单日) = date_trunc('month', o.created_at)
                then '新客' else '老客' end as 客群,
           sum(o.total_amount) as gmv    -- ⑤ 最后聚合
    from orders o
    join 首单 f on f.user_id = o.user_id
    where o.created_at >= date_trunc('month', current_date) - interval '3 months'  -- ④ 过滤
    group by 1, 2
    order by 1, 2;

    易错 不拆就写的人,一半概率写到一半发现粒度错了全部推翻重来。五步法是慢就是快。

练 · 65 min
  1. 把 D13 里最复杂的一题用 CTE 重写,对比可读性
  2. 写一个三级 CTE:清洗 -> 聚合 -> 排名
  3. 同一查询加与不加 MATERIALIZED,对比执行计划差异
  4. 「近 3 个月每月新客 GMV 与老客 GMV」:先写五步拆解,再写 SQL
  5. 写一个被引用两次的 CTE,观察是否被计算两次
过关能说清 PG 里 CTE 什么时候会被物化、物化意味着什么代价。
专注视图 ->
剧情 运营要做「每个类目热销 TOP3」看板。你上次用整表聚合再过滤的写法,数据一大就慢;这次学个新武器。
学 · 40 min
  • LATERAL 让右侧子查询能引用左侧的列

    场景 「每个用户最近 3 笔订单」:子查询里得写「这个用户的」--普通子查询够不着外层。

    讲解 join lateral (子查询):右侧子查询里可以直接用左边表的列。执行直觉:对左表每一行,把它的值代进右查询跑一遍,结果并上来。它是「能返回一整组行的相关子查询」。

    试试
    select u.name, t.*
    from users u
    join lateral (
      select id, total_amount, created_at
      from orders o
      where o.user_id = u.id            -- 引用了左边的 u.id
      order by created_at desc
      limit 3
    ) t on true
    order by u.name, t.created_at desc;

    易错 LATERAL 只能出现在 FROM / JOIN 的右侧;子查询里引用的别名必须在它左边出现过。

  • LEFT JOIN LATERAL (...) ON true 的固定写法

    场景 有的用户没下过单--inner join lateral 会把他们整个吞掉。

    讲解 lateral 子查询没有传统意义的连接条件,语法上用 on true 占位;改成 left join lateral (...) on true,右查询空结果也保留左行(右侧补 NULL)。「每个 X 及其最新 Y,没有也要」= 这句话。

    试试
    select u.name, t.id, t.total_amount
    from users u
    left join lateral (
      select id, total_amount from orders o
      where o.user_id = u.id
      order by created_at desc limit 1
    ) t on true;

    易错 少了 on true 是语法错;该用 left 的场景写成 inner,没下过单的用户静默消失。

  • Top-N per group 的三种解法对比

    场景 同一个需求三条路:整表 row_number、LATERAL、聚合后过滤。选哪条?

    讲解 row_number() over (partition by 组) + 外层过滤:通用、组数无所谓;② LATERAL:每个组只扫自己要的那几行(前提:排序列上有索引),组多且每组取少量时常胜;③ 老式聚合拼接:别用了。D23 会正式展开 ①,今天先用 ② 的身体记住这个对比。

    试试
    -- LATERAL 版:每个一级类目销量前 3 的商品
    select c.name as 类目, t.name as 商品, t.销量
    from categories c
    join lateral (
      select p.name, sum(i.qty) as 销量
      from products p
      join order_items i on i.product_id = p.id
      where p.category_id = c.id
      group by p.id, p.name
      order by 销量 desc
      limit 3
    ) t on true
    order by c.name, t.销量 desc;

    易错 LATERAL 快的前提是「组内排序键有索引支撑」。没有索引时它退化成每组一次排序,可能更慢--所以 D20 练习要做耗时对比表。

  • LATERAL 与相关子查询的关系

    场景 越看越像 D16 的相关子查询--本来就是一家。

    讲解 本质都是「对外层每行执行一次的子查询」。区别在位置和产出:相关子查询放在 WHERE / SELECT 里,返回标量或布尔;LATERAL 放在 FROM 里,返回一组行、成为数据源。心法:要一个数,用相关子查询;要几行几列,用 LATERAL。

    试试
    -- 要一个数(每用户订单数)
    select u.name, (select count(*) from orders o where o.user_id = u.id) as n
    from users u;
    -- 要几行(每用户最近 3 单)-- 上面 LATERAL 的例子

    易错 SELECT 列表里的子查询必须标量;想让它返回多行多列,硬写只会报错,换 LATERAL。

练 · 65 min
  1. 用 LATERAL 查每个用户最近 3 笔订单
  2. 用 LATERAL 查每个一级类目销量前 3 的商品
  3. ROW_NUMBER 把第 2 题再写一遍(预习下周窗口函数语法)
  4. DISTINCT ON 写 N=1 的版本
  5. 三种写法都跑 EXPLAIN ANALYZE,记录耗时做成对比表
过关能说出 LATERAL 在「分组多、每组取少量」场景下为什么可能更快。
专注视图 ->
剧情 三周了。本周测评改成白板模式--纸上手写、不许运行,模拟面试现场。
复盘 · 10 min
  • 整理本周的 CTE / 递归 / LATERAL 模板到 notes.md
测评 · 110 min
  1. 60 分钟纸上手写 6 题(不许运行、不许查文档)--模拟白板环节
  2. 上机逐题验证,统计有几题一次通过
  3. 把写错的地方标红,归入 mistakes.md
  4. 对着镜子讲一遍其中最难那题的解题思路
过关6 题中至少 4 题手写版本能直接跑通。
专注视图 ->