Tuần 7

CTE và Recursive Queries

SQL cho Interview — Lộ trình 10 tuần

Nhấn phím → để bắt đầu · T để xem mục lục

1. Tổng quan tuần 7

1.1

1.1 Mục tiêu — code dễ đọc và mở khóa lớp bài mới

Sau tuần 6 bạn đã có vũ khí mạnh nhất (window function). Tuần này có hai mục tiêu khác hẳn:

  1. CTE — không thêm khả năng mới, chỉ làm code đẹp và đọc được. Sẽ thấy LC 1321 từ tuần 6 ngắn lại 30%.
  2. Recursive CTE — mở khóa lớp bài hierarchy (cây tổ chức), path finding, sinh chuỗi, gaps and islands.

Sau tuần 7 bạn cần làm được

Ghi chú phương ngữ
MySQL hỗ trợ CTE từ 8.0 (recursive cũng từ 8.0). PostgreSQL từ 8.4. SQL Server từ 2005. SQLite từ 3.8.3. LeetCode hiện chạy MySQL 8+ ⇒ CTE và recursive CTE đều dùng được.
1.2

1.2 CTE = derived table có tên

CTE (Common Table Expression) là cú pháp WITH ... AS (...) đặt ở đầu query. Mỗi CTE giống một bảng tạm chỉ tồn tại trong query đó.

Subquery / Derived table (cũ)

SELECT t.dept, t.avg_sal
FROM (
    SELECT dept, AVG(salary) AS avg_sal
    FROM employees
    GROUP BY dept
) t
WHERE t.avg_sal > 80000;

CTE (mới)

WITH dept_avg AS (
    SELECT dept, AVG(salary) AS avg_sal
    FROM employees
    GROUP BY dept
)
SELECT dept, avg_sal
FROM dept_avg
WHERE avg_sal > 80000;

Cùng kết quả. CTE thắng ở:

2. Basic CTE

2.1

2.1 Cú pháp WITH

WITH cte_name [(col1, col2, ...)] AS (
    SELECT ...
)
SELECT ... FROM cte_name ...;

Cột phần [(col1, col2, ...)] tùy chọn — nếu không khai báo, dùng tên cột từ SELECT bên trong.

Ví dụ — đặt tên cột rõ ràng
WITH top_customers (id, total_spent) AS (
    SELECT customer_id, SUM(total)
    FROM orders
    GROUP BY customer_id
    ORDER BY 2 DESC
    LIMIT 10
)
SELECT c.name, tc.total_spent
FROM top_customers tc
JOIN customers c ON c.customer_id = tc.id;
Quy tắc
2.2

2.2 CTE vs subquery vs derived table

Subquery (WHERE)Derived table (FROM)CTE (WITH)
Đặt ở đâuTrong WHERE/SELECT/HAVINGTrong FROMTrên đầu query
Có tên?KhôngBắt buộc aliasBắt buộc tên CTE
Tái sử dụng?Phải copy-pastePhải copy-pasteTham chiếu nhiều lần
Đa bướcLồng nhau (khó đọc)Lồng FROM (rất khó đọc)Liệt kê tuần tự (dễ đọc)
RecursiveKhôngKhôngCó (WITH RECURSIVE)
PerformanceOptimizer thường inlineOptimizer thường inlineTùy phương ngữ — xem cảnh báo
Bẫy performance — PostgreSQL trước version 12
Trước PG 12, CTE bị "optimization fence" — engine luôn materialize CTE thành bảng tạm, ngay cả khi inline tốt hơn. Trên dữ liệu lớn, query chậm bất ngờ. Từ PG 12+ optimizer thông minh hơn.
MySQL 8+ và SQL Server không có vấn đề này.
2.3

2.3 Multiple CTEs — chuỗi nhiều bước

CTE thứ hai có thể tham chiếu CTE thứ nhất. Đây là điểm mạnh chính.

WITH
    daily_revenue AS (
        SELECT order_date, SUM(total) AS revenue
        FROM orders GROUP BY order_date
    ),
    revenue_with_avg AS (
        SELECT
            order_date,
            revenue,
            AVG(revenue) OVER (
                ORDER BY order_date
                ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
            ) AS ma_7d
        FROM daily_revenue
    ),
    flagged AS (
        SELECT *,
               CASE WHEN revenue > ma_7d * 1.5 THEN 'spike' END AS flag
        FROM revenue_with_avg
    )
SELECT * FROM flagged WHERE flag = 'spike';

Đọc từ trên xuống: mỗi CTE là một bước biến đổi rõ ràng. Cùng yêu cầu viết bằng nested subquery sẽ là 3 tầng SELECT lồng nhau — đọc ngược, debug khó.

2.4

2.4 Refactor — LC 1321 với CTE

Lấy bài Restaurant Growth (Hard) tuần 6, viết lại với CTE.

Tuần 6 — 2 tầng subquery

SELECT visited_on, amount, ROUND(amount/7.0, 2) AS average_amount
FROM (
    SELECT
        visited_on,
        SUM(daily) OVER (ORDER BY visited_on
                         ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS amount,
        ROW_NUMBER() OVER (ORDER BY visited_on) AS rn
    FROM (
        SELECT visited_on, SUM(amount) AS daily
        FROM Customer GROUP BY visited_on
    ) daily_t
) t
WHERE rn >= 7
ORDER BY visited_on;

Tuần 7 — CTE

WITH
    daily_total AS (
        SELECT visited_on, SUM(amount) AS daily
        FROM Customer
        GROUP BY visited_on
    ),
    moving AS (
        SELECT
            visited_on,
            SUM(daily) OVER (ORDER BY visited_on
                             ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS amount,
            ROW_NUMBER() OVER (ORDER BY visited_on) AS rn
        FROM daily_total
    )
SELECT visited_on, amount, ROUND(amount/7.0, 2) AS average_amount
FROM moving
WHERE rn >= 7
ORDER BY visited_on;

Cùng số dòng, nhưng tinh thần khác: hai bước có tên rõ ràng — "tổng theo ngày" rồi "moving sum". Code đọc tự nhiên top-down.

3. Multi-step Decomposition

3.1

3.1 Pattern: chia bài lớn thành các CTE bước

Đây là kỹ năng tư duy quan trọng nhất cho mock interview SQL ở level Medium/Hard. Quy trình 4 bước:

  1. Đọc đề — xác định bảng input, output mong muốn.
  2. Phân rã — đặt tên cho mỗi bước biến đổi (động từ + đối tượng):
    • "Lọc đơn paid" → CTE paid_orders
    • "Tổng theo customer" → CTE customer_totals
    • "Rank theo region" → CTE ranked_customers
  3. Viết stub — khung WITH với từng CTE rỗng, ghi comment yêu cầu mỗi CTE.
  4. Điền chi tiết — từng CTE một, test nhỏ trước khi viết CTE tiếp.
Tại sao quan trọng cho interview
Khi interviewer đưa bài Hard, candidate yếu ngồi gõ ngay. Candidate mạnh nói:
"Tôi sẽ chia bài này thành 3 bước. Bước 1 lọc..., bước 2 aggregate..., bước 3 rank..."
Sau đó viết WITH với 3 CTE comment-only, rồi điền. Tín hiệu structure thinking — interviewer thường accept candidate ngay từ moment này.
3.2

3.2 Worked example — 4 bước CTE

Yêu cầu: với mỗi quốc gia, tìm 3 customer có tổng chi tiêu cao nhất (đã chi trả) và % của họ trên tổng chi tiêu quốc gia.

Phân rã

  1. Lọc đơn paid → paid_orders
  2. Tổng chi tiêu mỗi customer → customer_spend (kèm country)
  3. Rank trong country + tổng country → ranked
  4. Lọc top 3 và tính % → output
WITH
    paid_orders AS (
        SELECT customer_id, total
        FROM orders
        WHERE status = 'paid'
    ),
    customer_spend AS (
        SELECT
            c.country,
            c.customer_id,
            c.name,
            SUM(po.total) AS spent
        FROM customers c
        JOIN paid_orders po ON po.customer_id = c.customer_id
        GROUP BY c.country, c.customer_id, c.name
    ),
    ranked AS (
        SELECT *,
               ROW_NUMBER() OVER (PARTITION BY country ORDER BY spent DESC) AS rk,
               SUM(spent)  OVER (PARTITION BY country)                       AS country_total
        FROM customer_spend
    )
SELECT
    country, name, spent,
    ROUND(100.0 * spent / country_total, 2) AS pct_of_country
FROM ranked
WHERE rk <= 3
ORDER BY country, rk;

Mỗi CTE có thể test riêng — chạy SELECT * FROM customer_spend để debug giữa chừng.

3.3

3.3 Quy tắc sạch khi viết CTE

  1. Đặt tên động từ + đối tượng. filtered_orders, customer_spend, top_3_per_dept. Tránh t1, t2, x, q.
  2. Một CTE = một mục đích. Đừng dồn 3 phép biến đổi vào 1 CTE để "ngắn lại" — chia ra.
  3. Comment khi cần. Một dòng -- gộp các đơn paid theo customer trên đầu CTE giúp người đọc.
  4. Không lạm dụng. Query đơn giản 5 dòng không cần CTE — đọc thẳng dễ hơn.
  5. CTE cho debug. Khi bug, comment câu chính, viết SELECT * FROM cte_name để inspect output trung gian.
Code review checklist
Khi review query có CTE, kiểm tra:

4. Recursive CTE

4.1

4.1 Cấu trúc — Anchor + Recursive

Định nghĩa
Recursive CTE là CTE tham chiếu chính nó. Cấu trúc bắt buộc 2 phần nối bằng UNION ALL:
  1. Anchor — query xuất phát, không tham chiếu CTE.
  2. Recursive — query tham chiếu CTE (chính nó), build dòng mới từ kết quả lần lặp trước.
WITH RECURSIVE cte_name AS (
    -- Anchor: dòng khởi đầu
    SELECT ...
    
    UNION ALL
    
    -- Recursive: từ output trước, sinh dòng mới
    SELECT ...
    FROM cte_name JOIN ... 
    WHERE ...    -- điều kiện dừng (rất quan trọng!)
)
SELECT * FROM cte_name;

Engine xử lý

  1. Chạy anchor → tập kết quả \(R_0\).
  2. Chạy recursive với \(R_0\) → \(R_1\) (dòng mới).
  3. Lặp với \(R_1\) → \(R_2\), v.v.
  4. Dừng khi recursive query trả 0 dòng.
  5. Output cuối = \(R_0 \cup R_1 \cup R_2 \cup \ldots\).

Lưu ý phương ngữ: PostgreSQL/MySQL/SQLite cần keyword RECURSIVE. SQL Server không cần (nhưng vẫn hoạt động nếu thêm).

4.2

4.2 Sinh chuỗi số / ngày

Một use case đơn giản nhất — sinh chuỗi mà không cần bảng có sẵn.

-- Sinh số 1..10
WITH RECURSIVE seq AS (
    SELECT 1 AS n              -- anchor: bắt đầu từ 1
    UNION ALL
    SELECT n + 1 FROM seq      -- recursive: thêm 1
    WHERE n < 10               -- dừng khi đạt 10
)
SELECT * FROM seq;
-- Sinh chuỗi ngày trong năm 2024
WITH RECURSIVE dates AS (
    SELECT DATE '2024-01-01' AS d
    UNION ALL
    SELECT d + INTERVAL 1 DAY FROM dates
    WHERE d < '2024-12-31'
)
SELECT * FROM dates;
Use case interview
"Báo cáo doanh thu mỗi ngày, kể cả ngày 0 đơn." Tuần 4 dùng CROSS JOIN với bảng calendar. Bây giờ có recursive CTE, không cần bảng riêng:
WITH RECURSIVE dates AS (...)
SELECT d.d, COALESCE(SUM(o.total), 0) AS revenue
FROM dates d
LEFT JOIN orders o ON o.order_date = d.d
GROUP BY d.d ORDER BY d.d;

PostgreSQL có hàm tắt generate_series('2024-01-01'::date, '2024-12-31', '1 day') — đẹp hơn. MySQL không có, phải recursive CTE.

4.3

4.3 Hierarchy traversal — cây tổ chức

Schema
Employee(id, name, manager_id) — manager_id trỏ về id (self-FK).

Yêu cầu: tất cả nhân viên trực tiếp/gián tiếp report cho manager 5 (LC 1270 dạng tổng quát)

WITH RECURSIVE subordinates AS (
    -- Anchor: nhân viên trực tiếp report manager 5
    SELECT id, name, manager_id, 1 AS level
    FROM Employee
    WHERE manager_id = 5
    
    UNION ALL
    
    -- Recursive: nhân viên report cho người đã trong subordinates
    SELECT e.id, e.name, e.manager_id, s.level + 1
    FROM Employee e
    JOIN subordinates s ON e.manager_id = s.id
)
SELECT * FROM subordinates ORDER BY level, id;
5 Manager 7 8 9 level=1 (anchor) 12 13 15 level=2 (recursive lần 1)

Recursive CTE leo xuống từng tầng cho đến khi không còn nhân viên report cho ai trong tập hiện tại.

4.4

4.4 Path tracking và termination safety

Track path đi qua

Có thể tích lũy đường đi như chuỗi:

WITH RECURSIVE org_path AS (
    SELECT id, name, manager_id, CAST(name AS CHAR(500)) AS path
    FROM Employee WHERE manager_id IS NULL  -- CEO
    UNION ALL
    SELECT e.id, e.name, e.manager_id,
           CONCAT(p.path, ' > ', e.name)
    FROM Employee e
    JOIN org_path p ON e.manager_id = p.id
)
SELECT * FROM org_path;

Output: "CEO > VP Eng > Director > Manager > IC".

Cảnh báo infinite loop
Nếu graph có chu trình (A là manager của B, B là manager của A — bug data), recursive CTE chạy mãi. Phòng:
-- Track visited bằng path string
WHERE NOT FIND_IN_SET(e.id, p.path)  -- MySQL
WHERE p.path NOT LIKE '%' || e.id || '%'  -- PostgreSQL

5. Gaps and Islands

5.1

5.1 Pattern gaps and islands — định nghĩa

Pattern
Cho một chuỗi giá trị có thứ tự (số, ngày). Chia thành các "island" — chuỗi liên tiếp — và "gap" — khoảng trống giữa chúng.
Use case kinh điển
Ví dụ cụ thể
User login dates: [01-01, 01-02, 01-03, 01-07, 01-08, 01-15]

Yêu cầu interview thường là: "tìm island dài nhất" hoặc "đếm số island" hoặc "list start/end của mỗi island".

5.2

5.2 Trick ROW_NUMBER — chìa khóa vàng

Pattern đẹp nhất cho gaps and islands sử dụng tính chất:

Nếu các dòng liên tiếp (theo date) có ROW_NUMBER liên tiếp (1, 2, 3, ...), thì date − ROW_NUMBERhằng số trên cùng một island.

login_daterow_numdiff = date − row_numisland
2024-01-0112023-12-31A
2024-01-0222023-12-31A
2024-01-0332023-12-31A
2024-01-0742024-01-03B
2024-01-0852024-01-03B
2024-01-1562024-01-09C

Cột diff hoạt động như "island ID" — group theo cột này để aggregate mỗi island.

WITH islands AS (
    SELECT
        user_id, login_date,
        DATE_SUB(login_date, INTERVAL ROW_NUMBER() OVER (
            PARTITION BY user_id ORDER BY login_date
        ) DAY) AS island_id
    FROM logins
)
SELECT user_id,
       MIN(login_date) AS start_date,
       MAX(login_date) AS end_date,
       COUNT(*)        AS streak_length
FROM islands
GROUP BY user_id, island_id
ORDER BY user_id, start_date;
5.3

5.3 Variant — gaps and islands trên giá trị

Pattern tổng quát hơn: tìm chuỗi liên tiếp dòng có cùng "trạng thái".

Bài toán
Bảng server_status(time, status) với status 'up' hoặc 'down'. Tìm các khoảng thời gian server liên tục up.

Trick: 2 ROW_NUMBER trừ nhau

WITH numbered AS (
    SELECT
        time, status,
        ROW_NUMBER() OVER (ORDER BY time) AS rn_all,
        ROW_NUMBER() OVER (PARTITION BY status ORDER BY time) AS rn_status
    FROM server_status
)
SELECT
    status,
    MIN(time) AS island_start,
    MAX(time) AS island_end,
    COUNT(*) AS duration
FROM numbered
GROUP BY status, rn_all - rn_status
HAVING status = 'up'
ORDER BY island_start;
Vì sao trick này hoạt động
Trên cùng một island (status không đổi), rn_allrn_status đều tăng cùng tốc độ — hiệu hằng số. Khi status đổi, rn_all tiếp tục nhưng rn_status reset (cho status khác) hoặc lệch (cho cùng status quay lại) → hiệu thay đổi → island mới.

6. Worked Examples

6.1

6.1 LeetCode 180 — Consecutive Numbers (Medium)

Schema

Logs(id INT PK auto-increment, num INT)

Yêu cầu

Tìm các số xuất hiện ít nhất 3 lần liên tiếp (theo id tăng dần).

Cách 1 — LAG (gọn nhất)

WITH window_3 AS (
    SELECT
        num,
        LAG(num, 1) OVER (ORDER BY id) AS prev1,
        LAG(num, 2) OVER (ORDER BY id) AS prev2
    FROM Logs
)
SELECT DISTINCT num AS ConsecutiveNums
FROM window_3
WHERE num = prev1 AND num = prev2;

Cách 2 — gaps and islands

WITH islands AS (
    SELECT num,
           id - ROW_NUMBER() OVER (PARTITION BY num ORDER BY id) AS island
    FROM Logs
)
SELECT DISTINCT num AS ConsecutiveNums
FROM islands
GROUP BY num, island
HAVING COUNT(*) >= 3;

Cách 2 tổng quát hơn — đổi 3 thành 5 chỉ là sửa HAVING. Cách 1 phải thêm LAG(num, 3), LAG(num, 4)...

6.2

6.2 LeetCode 1270 — All People Report to Given Manager (Medium)

Schema

Employees(employee_id INT PK, employee_name VARCHAR, manager_id INT) -- Ai cũng có manager. Manager 1 (head) report cho chính mình.

Yêu cầu

Tìm tất cả nhân viên report (trực tiếp/gián tiếp) cho manager employee_id = 1, với độ sâu tối đa 3 cấp.

Lời giải — recursive CTE

WITH RECURSIVE subordinates AS (
    -- Anchor: nhân viên trực tiếp report cho 1 (loại trừ chính 1)
    SELECT employee_id, manager_id, 1 AS depth
    FROM Employees
    WHERE manager_id = 1 AND employee_id <> 1
    
    UNION ALL
    
    -- Recursive: từ subordinates, leo xuống tầng tiếp
    SELECT e.employee_id, e.manager_id, s.depth + 1
    FROM Employees e
    JOIN subordinates s ON e.manager_id = s.employee_id
    WHERE s.depth < 3   -- giới hạn tối đa 3 cấp
)
SELECT employee_id FROM subordinates;
Lưu ý
Bài LC nói "tối đa 3 cấp" — predicate depth < 3 trong recursive query là vital. Không có nó, query vẫn chạy đúng (giả sử graph hữu hạn) nhưng chậm hơn nếu cây sâu hơn.
6.3

6.3 LeetCode 601 — Human Traffic of Stadium (Hard)

Schema

Stadium(id INT, visit_date DATE, people INT) -- (id) là PK, mỗi ngày có 1 dòng

Yêu cầu

Tìm các dòng có ít nhất 3 ngày liên tiếp với people ≥ 100. Trả tất cả các dòng trong các chuỗi đó.

Phân rã

  1. Lọc chỉ ngày có ≥ 100 → popular.
  2. Trên popular, tính island theo trick id − ROW_NUMBER → tagged.
  3. Đếm size mỗi island, giữ island ≥ 3 → output.

Lời giải

WITH
    popular AS (
        SELECT * FROM Stadium WHERE people >= 100
    ),
    tagged AS (
        SELECT *,
               id - ROW_NUMBER() OVER (ORDER BY id) AS island
        FROM popular
    ),
    big_islands AS (
        SELECT island
        FROM tagged
        GROUP BY island
        HAVING COUNT(*) >= 3
    )
SELECT id, visit_date, people
FROM tagged
WHERE island IN (SELECT island FROM big_islands)
ORDER BY visit_date;

Bài Hard trong 15 dòng nhờ kết hợp CTE multi-step + trick gaps and islands. Không có CTE, code lồng 3 tầng subquery — gần như không debug được.

7. Knowledge Check

7.Q

Knowledge Check — Tuần 7

Q1: Recursive CTE có 2 phần nối bằng UNION ALL. Phần nào gọi là "anchor"?
Anchor là query khởi đầu — không tham chiếu CTE. Engine chạy nó trước để có tập \(R_0\). Phần thứ hai (recursive) tham chiếu CTE để build tầng tiếp. Hai phần nối bằng UNION ALL bắt buộc — UNION sẽ dedup mỗi vòng và làm chậm.
Q2: Pattern gaps and islands dùng date - ROW_NUMBER() để làm gì?
Trên cùng island, date tăng đều 1 đơn vị, ROW_NUMBER cũng tăng 1 đơn vị → hiệu là hằng số. Khi gap (nhảy ngày), date tăng >1 nhưng ROW_NUMBER vẫn tăng 1 → hiệu thay đổi → island mới. GROUP BY hiệu này tách các island.
Q3: Khi nào CTE là chọn lựa TỐT NHẤT (so với subquery/derived table)?
CTE không nhanh hơn subquery về performance (đôi khi chậm hơn trên PG cũ). Lợi ích thực sự là (1) đặt tên có nghĩa cho bước trung gian, (2) tái sử dụng nhiều lần, (3) recursive. Đối với query đơn giản, viết thẳng đọc dễ hơn.

8. Bài tập về nhà

8.1

8.1 Bài tập LeetCode (5 problem)

  1. 180. Consecutive Numbers (Medium) — đã giải tại lớp; viết cả hai cách (LAG và gaps-islands), so sánh.
  2. 1270. All People Report to the Given Manager (Medium) — đã giải tại lớp.
  3. 601. Human Traffic of Stadium (Hard) — đã giải tại lớp.
  4. 1454. Active Users (Medium) — gaps and islands variant: tìm user có ≥ 5 ngày login liên tiếp.
  5. 262. Trips and Users (Hard) — JOIN + filter phức tạp; viết bằng CTE multi-step (3-4 CTE).

Bonus — recursive challenge

Cho schema cây tổ chức Employee(id, name, manager_id):

  1. Liệt kê toàn bộ subtree dưới một manager bất kỳ kèm depth (đã làm).
  2. Mở rộng: tính path từ CEO đến mỗi nhân viên dưới dạng "CEO > VP > Director > Manager".
  3. Mở rộng: với mỗi nhân viên, đếm số subordinates (trực tiếp + gián tiếp).

Đây là bài tập nền cho graph traversal trong system design interview — không chỉ SQL.

8.2

8.2 Đọc thêm và tự kiểm tra

Đọc

Tự kiểm tra (không nhìn slide)

Tuần sau — Pattern Interview Tổng hợp
Tuần 8: tổng hợp 6 pattern phổ biến nhất trong interview (Top-N per group, Median, Gaps & Islands, Consecutive sequences, Pivot, Cumulative metrics) với mỗi pattern 1-2 bài LeetCode đại diện. Đây là tuần "rèn phản xạ" — sau tuần 8 bạn nhận ra pattern trong 30 giây đầu của mỗi bài.

Mục lục

Nhấn T hoặc Escape để đóng