Tuần 6

Window Functions — vũ khí giải Hard

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 6

1.1

1.1 Đây là tuần "click moment"

Window functions là kỹ năng phân biệt sinh viên SQL trung bình với senior. Nếu bạn nắm được tuần này, bạn sẽ:

Kế hoạch tuần

  1. Hiểu OVER clause — kiến trúc cơ bản.
  2. Ranking: ROW_NUMBER, RANK, DENSE_RANK, NTILE.
  3. Navigation: LAG, LEAD.
  4. Aggregate window: SUM/AVG OVER, frame clause (đây là chỗ hay sai nhất).
  5. Value functions: FIRST_VALUE, LAST_VALUE, NTH_VALUE.
Cảnh báo về frame clause
Phần frame clause (ROWS/RANGE) là phần sinh viên hay bỏ qua — và sai bài "running total có ties" hoặc "moving average" mà không hiểu vì sao. Section 5 sẽ giải quyết triệt để.
1.2

1.2 Mental model — "view nhỏ qua mỗi dòng"

Window function khác với aggregate ở điểm cốt lõi:

Aggregate (GROUP BY)Window function
Số dòng đầu vàonn
Số dòng đầu ra1 dòng / nhóm (collapse)n dòng (preserve)
Cột không-aggregatePhải trong GROUP BYTùy ý — không bị ràng buộc
Mỗi dòng outputĐại diện một nhómĐại diện một dòng gốc, kèm "view" lên dữ liệu xung quanh

Hình dung: với mỗi dòng, engine cho bạn nhìn qua một "cửa sổ" (window) đến tập dữ liệu — bạn có thể tính chỉ số dựa trên dữ liệu trong cửa sổ đó, mà không mất chính dòng đang xét.

So sánh trực tiếp
-- GROUP BY: 1 dòng / phòng
SELECT dept, AVG(salary) FROM employees GROUP BY dept;

-- Window: mọi dòng employee, kèm AVG phòng họ
SELECT name, dept, salary,
       AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;
Window cho bạn tính chỉ số tham chiếu mà không mất chi tiết.

2. Cấu trúc OVER clause

2.1

2.1 Anatomy của OVER

function_name([args]) OVER (
    [PARTITION BY col1, col2, ...]    -- (1) phân hoạch tập
    [ORDER BY     col3 [ASC|DESC]]    -- (2) thứ tự trong mỗi partition
    [frame_clause]                     -- (3) khung tính toán
)

Ba thành phần đều tùy chọn, nhưng kết hợp khác nhau cho semantic khác nhau:

Có gìÝ nghĩa
chỉ OVER ()Toàn bộ bảng là một window — tính trên cả bảng
chỉ PARTITION BYChia tập thành nhóm độc lập, không thứ tự
chỉ ORDER BYToàn bộ tập, có thứ tự — frame mặc định áp dụng
cả haiPhổ biến nhất — chia nhóm rồi sắp xếp trong nhóm
Bẫy quan trọng cần thuộc
Khi có ORDER BY mà không khai báo frame, mặc định là RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — tức là "từ đầu partition đến dòng hiện tại". Đây là nguồn bug "running total có ties" sẽ giải thích ở Section 5.
2.2

2.2 PARTITION BY — phân hoạch không reduce

employees name dept sal AnEng100 BoMkt80 ChiEng120 DuOps90 EnMkt70 FaEng110 PARTITION BY dept 3 partitions độc lập Eng: An 100, Chi 120, Fa 110 tính chỉ trong nhóm này Mkt: Bo 80, En 70 Ops: Du 90 Window function tính trên mỗi nhóm — KHÔNG reduce, vẫn 6 dòng output

So với GROUP BY: GROUP BY collapse mỗi nhóm thành 1 dòng. PARTITION BY tạo phạm vi tính trong khi vẫn trả 6 dòng gốc.

2.3

2.3 GROUP BY vs PARTITION BY — bảng so sánh quan trọng

GROUP BYPARTITION BY (window)
Vị tríMệnh đề riêng GROUP BYTrong OVER (...)
Dòng output1 dòng / nhómn dòng (tất cả input)
Cột non-aggregatePhải trong GROUP BYLấy thoải mái — chính cột của dòng đó
Filter sauHAVINGKhông có WHERE/HAVING — phải bao subquery/CTE
Use caseTổng kết, báo cáoRanking, running total, so dòng-dòng, chỉ số tham chiếu
Quan trọng — không filter trực tiếp window
-- SAI — không thể WHERE trên window function trực tiếp
SELECT name, ROW_NUMBER() OVER (...) AS rn
FROM employees WHERE rn = 1;

-- ĐÚNG — bao bằng subquery hoặc CTE
SELECT * FROM (
    SELECT name, ROW_NUMBER() OVER (...) AS rn FROM employees
) t WHERE rn = 1;
Lý do: window function chạy sau WHERE/GROUP BY/HAVING trong thứ tự thực thi. Bạn chỉ truy cập kết quả nó qua một "tầng" subquery hoặc CTE.

3. Ranking Functions

3.1

3.1 ROW_NUMBER — đánh số duy nhất

Định nghĩa
ROW_NUMBER() OVER (...) gán số thứ tự 1, 2, 3, ... cho mỗi dòng theo thứ tự ORDER BY trong window. Luôn duy nhất, không có ties.
-- Đánh số employee theo salary giảm dần, trong từng phòng
SELECT name, dept, salary,
       ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees;
namedeptsalaryrn
ChiEng1201
FaEng1102
AnEng1003
BoMkt801
EnMkt702
Pattern — Top-N per group
SELECT name, dept, salary FROM (
    SELECT name, dept, salary,
           ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
    FROM employees
) t WHERE rn <= 3;
Đây là pattern thay thế cho cả correlated subquery và self-join + derived table tuần 5. Gọn hơn, nhanh hơn, xử lý ties tốt hơn (sẽ thấy ở slide tiếp).
3.2

3.2 ROW_NUMBER vs RANK vs DENSE_RANK

Đây là khác biệt phải thuộc. Cho dữ liệu salary {120, 100, 100, 95}:

SalaryROW_NUMBERRANKDENSE_RANK
120111
100222
10032 (ties)2 (ties)
9544 (skip 3)3 (no skip)

Quy tắc thuộc

Khi nào chọn cái nào
LeetCode 178 ("Rank Scores") chính là DENSE_RANK. LeetCode 185 ("Department Top Three Salaries") là DENSE_RANK kèm subquery.
3.3

3.3 Pattern: Top-N per group (signature pattern)

Đây là pattern interview xuất hiện nhiều nhất trong các bài Medium/Hard. Xương sống:

SELECT cols
FROM (
    SELECT cols,
           ROW_NUMBER() OVER (
               PARTITION BY group_key
               ORDER BY rank_key [DESC]
           ) AS rn
    FROM table
) t
WHERE rn <= N;

Variants

Yêu cầuFunction
"Top 3 nhân viên cao lương nhất mỗi phòng"ROW_NUMBER (cố định 3 dòng)
"Top 3 mức lương khác nhau mỗi phòng"DENSE_RANK
"Top 3 hạng (có thể >3 nhân viên)"RANK
"Đơn mới nhất của mỗi khách"ROW_NUMBER với ORDER BY date DESC, lấy rn = 1
"Sản phẩm bán chạy thứ 2 mỗi category"DENSE_RANK với rn = 2
Bẫy ROW_NUMBER với ties
"Đơn mới nhất của mỗi khách" với ORDER BY date — nếu hai đơn cùng ngày, ROW_NUMBER chọn ngẫu nhiên một. Phải thêm tie-breaker:
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY date DESC, order_id DESC)
Trong mock interview, nói rõ tie-breaker này — interviewer thường hỏi "nếu ties thì sao?".
3.4

3.4 NTILE — chia thành N nhóm

Định nghĩa
NTILE(N) chia partition (theo ORDER BY) thành N nhóm gần bằng nhau, gán số 1..N cho mỗi dòng.
-- Chia khách thành 4 quartile theo doanh thu
SELECT
    customer_id, revenue,
    NTILE(4) OVER (ORDER BY revenue DESC) AS quartile
FROM customer_revenue;
-- quartile 1 = top 25% spenders, 4 = bottom 25%

Nếu số dòng không chia hết cho N, các nhóm đầu nhận thêm 1 dòng.

Use case interview
Khác PERCENT_RANK
PERCENT_RANK() trả tỷ lệ continuous (0 đến 1), NTILE(N) trả discrete bucket. Cho phân tích thống kê chuẩn, dùng PERCENT_RANK; cho phân nhóm reporting, dùng NTILE.

4. LAG và LEAD

4.1

4.1 LAG — nhìn về dòng trước

Định nghĩa
LAG(col, offset, default) OVER (...) trả về giá trị cột của dòng trước dòng hiện tại theo ORDER BY trong window. Nếu không có dòng trước, trả default (mặc định NULL).
-- So sánh doanh thu hôm nay với hôm qua, mỗi store
SELECT
    store_id, sale_date, revenue,
    LAG(revenue) OVER (PARTITION BY store_id ORDER BY sale_date) AS prev_revenue,
    revenue - LAG(revenue) OVER (PARTITION BY store_id ORDER BY sale_date) AS delta
FROM daily_sales;

Tham số đầy đủ

LAG(col, 2, 0)        -- nhìn về dòng cách 2, default 0 nếu thiếu
LAG(col, 1, NULL)     -- equivalent với LAG(col)
Pattern interview
"Tính chênh lệch dòng-liền-kề" — trước window function phải dùng self-join hoặc correlated subquery (thấy tuần 4–5). Với LAG, đây là one-liner:
revenue - LAG(revenue) OVER (PARTITION BY store_id ORDER BY date) AS day_over_day
4.2

4.2 LEAD — nhìn về dòng sau

LEAD đối xứng với LAG — nhìn về dòng sau.

-- Dự đoán: ngày tiếp theo có sale lớn hơn hôm nay không?
SELECT
    sale_date, revenue,
    LEAD(revenue) OVER (ORDER BY sale_date) AS next_revenue,
    CASE WHEN LEAD(revenue) OVER (ORDER BY sale_date) > revenue
         THEN 'increasing'
         ELSE 'flat or decreasing'
    END AS trend
FROM daily_sales;
Pattern: gaps and islands (preview)
Bài "tìm chuỗi đăng nhập liên tiếp" thường dùng LAG để check "có gap với dòng trước không":
SELECT user_id, login_date,
       CASE WHEN DATEDIFF(login_date, LAG(login_date) OVER (PARTITION BY user_id ORDER BY login_date)) = 1
            THEN 'consecutive' ELSE 'new streak'
       END AS streak_type
FROM logins;
Tuần 8 sẽ học pattern gaps and islands hoàn chỉnh.
4.3

4.3 Refactor: Rising Temperature (LC 197)

Bài này tuần 4 đã giải bằng self-join. Giờ với LAG, gọn hơn nhiều.

Cách self-join (tuần 4)

SELECT today.id
FROM Weather today
JOIN Weather yesterday
  ON yesterday.recordDate = today.recordDate - INTERVAL 1 DAY
 AND today.temperature > yesterday.temperature;

Cách window function (tuần 6)

SELECT id FROM (
    SELECT id, recordDate, temperature,
           LAG(temperature) OVER (ORDER BY recordDate) AS prev_temp,
           LAG(recordDate)  OVER (ORDER BY recordDate) AS prev_date
    FROM Weather
) t
WHERE temperature > prev_temp
  AND DATEDIFF(recordDate, prev_date) = 1;
Trade-offs
Window function dài hơn về dòng trong bài này, nhưng:

5. Aggregate Window Functions

5.1

5.1 SUM/AVG/COUNT OVER

Mọi aggregate function (SUM, AVG, COUNT, MIN, MAX) có thể chạy như window — không collapse dòng.

-- Mỗi đơn, kèm tổng đơn của khách đó
SELECT order_id, customer_id, total,
       SUM(total) OVER (PARTITION BY customer_id) AS customer_total,
       total / SUM(total) OVER (PARTITION BY customer_id) AS share_of_customer
FROM orders;

Đây là pattern "tỷ lệ trong nhóm" — rất phổ biến cho dashboard và phân tích.

Use case — % of total
-- Thị phần mỗi sản phẩm trong category
SELECT
    product_id, category, revenue,
    100.0 * revenue / SUM(revenue) OVER (PARTITION BY category) AS pct_of_category,
    100.0 * revenue / SUM(revenue) OVER ()                       AS pct_of_total
FROM product_revenue;
Hai chỉ số trong một query — tuần 5 phải dùng 2 subquery hoặc 2 lần JOIN.
5.2

5.2 Running total — thêm ORDER BY

Khi OVER có cả PARTITION BY và ORDER BY, aggregate trở thành running — tích lũy đến dòng hiện tại.

-- Tổng tích lũy doanh thu theo ngày, từng store
SELECT
    store_id, sale_date, revenue,
    SUM(revenue) OVER (
        PARTITION BY store_id
        ORDER BY sale_date
    ) AS running_total
FROM daily_sales;
storedaterevrunning_total
101-01100100
101-0250150
101-0380230
201-01200200
201-02150350

Frame mặc định: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — từ đầu partition đến dòng hiện tại.

5.3

5.3 Frame clause: ROWS vs RANGE

Frame định nghĩa bao nhiêu dòng trong partition tham gia tính cho mỗi dòng.

-- Cú pháp đầy đủ
function() OVER (
    PARTITION BY ...
    ORDER BY ...
    {ROWS | RANGE} BETWEEN start AND end
)

start/end values

ROWS vs RANGE — khác biệt CRITICAL

ModeĐơn vịKhi có ties trên ORDER BY
ROWSSố dòng vật lýPhân biệt rõ — mỗi dòng là 1 đơn vị
RANGEPhạm vi giá trị logicTất cả dòng cùng giá trị ORDER BY được gộp
Bẫy "running total có ties"

Hai đơn cùng ngày, frame mặc định là RANGE ⇒ cả hai được tính trong cùng "step" của running total — running_total nhảy bậc lớn. Sinh viên thường mong "tăng dần từng đơn" (ROWS).

-- ROWS — running total tăng từng dòng, kể cả cùng date
SUM(amount) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
-- RANGE — các dòng cùng date đều có cùng running_total
SUM(amount) OVER (ORDER BY date RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
Khuyên: khi viết running total, luôn explicit ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — phòng bug.
5.4

5.4 Moving average — sliding frame

Moving average / rolling sum: frame "trượt" với dòng hiện tại.

-- Moving 7-day average
SELECT
    sale_date, revenue,
    AVG(revenue) OVER (
        ORDER BY sale_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS ma_7d
FROM daily_sales;
FrameÝ nghĩa
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW7 dòng gần nhất kể cả hôm nay (MA-7)
ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWINGCentered moving avg, 7 dòng quanh hiện tại
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWCumulative (running total)
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING"Reverse cumulative" — tổng từ đây đến hết
Edge case — đầu chuỗi
Cho MA-7, 6 ngày đầu chỉ có 1, 2, ... dòng để tính → MA chia trên ít hơn 7 dòng (avg vẫn hợp lệ). Nếu đề yêu cầu "chỉ tính khi có đủ 7 dòng", thêm điều kiện:
CASE WHEN ROW_NUMBER() OVER (ORDER BY sale_date) >= 7 THEN ma_7d END
-- hoặc lọc trong WHERE bao ngoài

6. FIRST_VALUE, LAST_VALUE, NTH_VALUE

6.1

6.1 Value functions và bẫy LAST_VALUE

-- Đơn đầu tiên và cuối cùng của mỗi khách
SELECT
    customer_id, order_id, order_date,
    FIRST_VALUE(order_id) OVER (PARTITION BY customer_id ORDER BY order_date) AS first_order,
    LAST_VALUE (order_id) OVER (
        PARTITION BY customer_id
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS last_order
FROM orders;
Bẫy LAST_VALUE — phải explicit frame

Frame mặc định khi có ORDER BY là RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Với LAST_VALUE, "last" trong frame này chính là CURRENT ROW — không phải cuối partition!

Phải thêm ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING để LAST_VALUE hoạt động như mong đợi.

Đây là bug rất phổ biến trong code production và là câu hỏi follow-up senior interview hay xuất hiện. Pattern thay thế: dùng FIRST_VALUE(...) OVER (... ORDER BY ... DESC) — đảo thứ tự.

NTH_VALUE(col, n): lấy giá trị dòng thứ n trong frame. Ít dùng hơn — đa số bài "thứ N" giải bằng ranking.

7. Worked Examples

7.1

7.1 LeetCode 178 — Rank Scores (Medium)

Schema

Scores(id INT PK, score DECIMAL(3,2))

Yêu cầu

Trả về (score, rank) sắp xếp giảm dần. Score bằng nhau cùng rank, không skip rank.

Phân rã — chọn function nào?

"Bằng nhau cùng rank" + "không skip" ⇒ DENSE_RANK.

Lời giải

SELECT
    score,
    DENSE_RANK() OVER (ORDER BY score DESC) AS "rank"
FROM Scores
ORDER BY score DESC;
Chú ý keyword "rank"
rank là từ khóa reserved ở một số dialect. Trong LeetCode (MySQL), bao bằng backticks `rank`. Trong PostgreSQL, double quotes "rank". Tốt nhất đổi alias thành score_rank nếu được.
7.2

7.2 LeetCode 185 — Department Top Three Salaries (Hard)

Schema

Employee(id INT PK, name, salary INT, departmentId INT) Department(id INT PK, name)

Yêu cầu

Mỗi phòng, trả về 3 mức lương cao nhất khác nhau và tất cả nhân viên ở các mức đó. Có thể nhiều hơn 3 nhân viên/phòng nếu có ties.

Phân rã

  1. "3 mức lương khác nhau" ⇒ DENSE_RANK (không phải ROW_NUMBER).
  2. "Mỗi phòng" ⇒ PARTITION BY departmentId.
  3. Lọc rank ≤ 3 ⇒ phải bao subquery (không filter window trực tiếp).
  4. JOIN Department để lấy tên phòng.

Lời giải

SELECT
    d.name AS Department,
    e.name AS Employee,
    e.salary AS Salary
FROM (
    SELECT
        name, salary, departmentId,
        DENSE_RANK() OVER (PARTITION BY departmentId ORDER BY salary DESC) AS rk
    FROM Employee
) e
JOIN Department d ON d.id = e.departmentId
WHERE e.rk <= 3;

Đây là bài SQL Hard — và lời giải chỉ 10 dòng nhờ window function. Không có window function, sẽ cần correlated subquery phức tạp với 3 EXISTS lồng nhau.

7.3

7.3 LeetCode 1321 — Restaurant Growth (Hard)

Schema

Customer(customer_id, name, visited_on DATE, amount INT) -- Một khách có thể có nhiều dòng cùng visited_on

Yêu cầu

Tính moving sum và moving average 7 ngày của tổng amount, bắt đầu từ ngày thứ 7. Trả (visited_on, amount, average_amount), với amount là tổng 7 ngày, average_amount = amount/7 (làm tròn 2 chữ số).

Phân rã

  1. Trước hết — gộp các dòng cùng ngày: GROUP BY visited_on rồi SUM amount.
  2. Trên kết quả gộp, áp window SUM với frame ROWS BETWEEN 6 PRECEDING AND CURRENT ROW.
  3. Lọc dòng có ít hơn 7 ngày trước đó — yêu cầu thứ tự ≥ 7.

Lời giải

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;

Bài Hard điển hình — mix GROUP BY với window function trong 2 tầng subquery. Tuần 7 (CTE) sẽ làm code này dễ đọc hơn nhiều.

8. Knowledge Check

8.Q

Knowledge Check — Tuần 6

Q1: Salary {120, 100, 100, 95}. Yêu cầu "trả về top-3 mức lương khác nhau". Function nào?
Yêu cầu "mức lương khác nhau" ⇒ DENSE_RANK đếm số mức phân biệt: 120→1, 100→2, 100→2, 95→3. ROW_NUMBER bỏ sót dòng 100 thứ hai. RANK bỏ sót 95 (rank=4 do skip). NTILE phân nhóm gần đều, không phù hợp với "top mức".
Q2: SUM(amount) OVER (ORDER BY date) trên dữ liệu có hai dòng cùng date 2024-03-15 (amount 100 và 200). Running total cho hai dòng đó là gì? (Frame mặc định.)
Frame mặc định khi có ORDER BY là RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE coi tất cả dòng cùng giá trị ORDER BY là một "step" → cả hai dòng cùng date đều bao gồm cả hai trong sum. Để tăng từng dòng, phải explicit ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
Q3: Query nào ĐÚNG cho "top-1 đơn cao nhất mỗi khách"?
Window function chạy SAU WHERE/HAVING trong thứ tự thực thi → không filter trực tiếp được. A gây lỗi cú pháp/runtime. C cũng sai — HAVING không thấy alias rn. B đúng — bao window trong subquery rồi filter ở tầng ngoài. CTE (tuần 7) là alternative đẹp hơn.

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

9.1

9.1 Bài tập LeetCode (5 problem, mix Medium/Hard)

  1. 178. Rank Scores (Medium) — đã giải tại lớp.
  2. 185. Department Top Three Salaries (Hard) — đã giải tại lớp.
  3. 184. Department Highest Salary (Medium) — bài tuần 5; viết lại bằng RANK hoặc DENSE_RANK và so sánh dòng code.
  4. 1158. Market Analysis I (Medium) — JOIN + COUNT + GROUP BY, KHÔNG cần window. Dùng để check bạn không over-engineer.
  5. 1321. Restaurant Growth (Hard) — đã giải tại lớp; viết lại nhưng dùng CTE preview (bao WITH daily AS (...)) — tuần 7 chính thức.

Bonus — speed challenge

Cài SQLFiddle hoặc DB Fiddle local. Generate dataset 1M dòng (loại orders). Đo thời gian:

Trả lời điển hình: window 5–50× nhanh hơn correlated subquery, tùy index và optimizer.

9.2

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

Đọc

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

Tuần sau — CTE và Recursive Queries
Tuần 7: WITH clause cho code dễ đọc, recursive CTE cho hierarchy/path/sequence generation. Bạn đã thấy bài 1321 cần 2 tầng subquery — CTE sẽ làm code đó dễ đọc hơn rất nhiều. Recursive CTE mở khóa lớp bài "đường đi trong cây" và "gaps & islands" hoàn chỉnh.

Mục lục

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