Bài toán top-N
Bạn sẽ học được
- Phân biệt
ROW_NUMBER,RANKvàDENSE_RANKkhi có đồng hạng. - Viết top-N theo từng nhóm bằng
PARTITION BY. - Xử lý trường hợp nhiều dòng cùng đứng ở biên của top-N.
- Nhận ra đề bài mơ hồ và hỏi lại trước khi viết SQL.
- Trình bày lời giải trong phỏng vấn theo trình tự rõ ràng.
Khái niệm cốt lõi
Bài toán top-N là dạng câu hỏi SQL xuất hiện nhiều nhất trong phỏng vấn Data Analyst. Đề bài thường chỉ một dòng:
"Cho bảng đơn hàng, tìm 3 khách hàng có doanh thu cao nhất mỗi tháng."
Trước khi viết SQL, phải làm rõ ba điểm:
- "Top 3" tính theo gì? Tổng doanh thu, số đơn, hay giá trị trung bình?
- Đồng hạng xử lý thế nào? Nếu ba người cùng 500.000 và người thứ tư cũng 500.000, lấy 4 người hay cắt ở 3?
- Tháng rỗng có cần hiện không? Tháng không có đơn thì bảng kết quả có dòng nào không?
Trong phỏng vấn, nói ra ba giả định này trước khi viết code được tính điểm cao hơn việc viết đúng một câu SQL mà không giải thích.
Ba hàm xếp hạng khác nhau đúng ở điểm đồng hạng:
| Doanh thu | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 500.000 | 1 | 1 | 1 |
| 500.000 | 2 | 1 | 1 |
| 400.000 | 3 | 3 | 2 |
| 300.000 | 4 | 4 | 3 |
ROW_NUMBER: luôn khác nhau, kể cả khi giá trị bằng nhau. Cần thêm tiêu chí phụ để kết quả ổn định.RANK: bằng nhau thì cùng hạng, nhưng hạng kế tiếp bị nhảy — 1, 1, 3.DENSE_RANK: bằng nhau cùng hạng, hạng kế tiếp không nhảy — 1, 1, 2.
Nếu đề nói "đúng 3 dòng", dùng ROW_NUMBER. Nếu đề nói "tất cả khách hàng có doanh thu thuộc top 3 mức cao nhất", dùng DENSE_RANK.
Ví dụ chạy được
Bảng orders:
| order_id | customer_id | order_date | amount |
|---|---|---|---|
| 1 | A | 2026-01-03 | 500000 |
| 2 | B | 2026-01-05 | 500000 |
| 3 | C | 2026-01-08 | 400000 |
| 4 | D | 2026-01-09 | 300000 |
| 5 | A | 2026-02-02 | 700000 |
Bước 1 — tổng hợp theo tháng và khách.
with monthly as (
select
date_trunc('month', order_date)::date as thang,
customer_id,
sum(amount) as revenue
from orders
group by 1, 2
)Bước 2 — xếp hạng trong từng tháng.
, ranked as (
select
thang,
customer_id,
revenue,
row_number() over (
partition by thang
order by revenue desc, customer_id
) as rn
from monthly
)
select thang, customer_id, revenue
from ranked
where rn <= 3
order by thang, rn;Kết quả tháng 1:
| thang | customer_id | revenue | rn |
|---|---|---|---|
| 2026-01-01 | A | 500000 | 1 |
| 2026-01-01 | B | 500000 | 2 |
| 2026-01-01 | C | 400000 | 3 |
Tiêu chí phụ customer_id trong ORDER BY đảm bảo A luôn đứng trước B. Nếu bỏ đi, thứ tự hai người bằng điểm có thể thay đổi giữa các lần chạy — kết quả không tái lập được.
Muốn giữ mọi người đồng hạng ở vị trí thứ ba:
dense_rank() over (
partition by thang
order by revenue desc
) as dr
...
where dr <= 3Với dữ liệu trên, nếu D cũng có 400.000, cả C và D đều có dr = 3 và đều được trả về — tổng cộng 4 dòng cho tháng 1.
Cách khác không dùng window function — phù hợp khi phỏng vấn viên hỏi "không dùng ROW_NUMBER thì làm sao":
select m.thang, m.customer_id, m.revenue
from monthly m
where (
select count(*)
from monthly m2
where m2.thang = m.thang
and (m2.revenue > m.revenue
or (m2.revenue = m.revenue and m2.customer_id <= m.customer_id))
) <= 3;Cách này đúng nhưng chậm hơn nhiều vì chạy truy vấn con cho từng dòng. Nêu được cả hai và giải thích đánh đổi là điểm cộng.
Các biến thể hay gặp
| Đề bài | Cách tiếp cận |
|---|---|
| Top 1 mỗi nhóm | row_number() = 1 hoặc distinct on (nhom) của Postgres |
| Top N mỗi nhóm | row_number() over (partition by nhom ...) |
| Top N toàn bảng | order by ... limit N |
| Top N kèm đồng hạng | dense_rank() |
| Top N theo tỷ lệ | ntile(10) rồi lọc nhóm |
| Top N của tháng gần nhất | Lọc tháng trước khi xếp hạng |
Postgres có cú pháp riêng distinct on — ngắn gọn cho top 1 nhưng không mở rộng cho top 3:
select distinct on (thang) thang, customer_id, revenue
from monthly
order by thang, revenue desc, customer_id;BigQuery và Snowflake hỗ trợ QUALIFY, cho phép lọc trực tiếp trên kết quả window function. Postgres không có, nên phải dùng CTE hoặc truy vấn con.
Bẫy thường gặp trong phỏng vấn
Xếp hạng trước khi tổng hợp. Nếu bảng có nhiều đơn mỗi khách, row_number() over (order by amount desc) xếp hạng từng đơn, không phải từng khách. Phải group by trước.
Quên PARTITION BY. Thiếu partition nghĩa là xếp hạng toàn bảng; mỗi tháng chỉ còn đúng 3 dòng tổng cộng thay vì 3 dòng mỗi tháng.
Đếm sai đơn vị. count(*) đếm số đơn, count(distinct customer_id) đếm số khách. Đề hỏi "khách hàng mua nhiều nhất" thì phải là số đơn của mỗi khách, không phải tổng số đơn.
Bỏ qua NULL. sum(amount) bỏ qua NULL; count(amount) cũng bỏ qua. Nếu đề hỏi "số đơn", dùng count(*) hoặc count(order_id).
Không xử lý tháng thiếu dữ liệu. Nếu cần hiện đủ 12 tháng kể cả tháng không có đơn, phải tạo dải tháng bằng generate_series rồi left join.
Cách trình bày trong phỏng vấn
Một lời giải tốt đi theo bốn bước, nói thành tiếng:
- Làm rõ yêu cầu: "Em hiểu là top 3 theo tổng doanh thu mỗi tháng, và nếu đồng hạng ở vị trí 3 thì em dùng
DENSE_RANKđể giữ đủ. Có đúng ý anh/chị không?" - Nêu cấu trúc: "Em sẽ tổng hợp theo tháng và khách trước, sau đó xếp hạng trong từng tháng."
- Viết SQL: đặt tên CTE theo nghĩa (
monthly,ranked), không dùngt1,t2. - Kiểm tra: "Em kiểm tra trường hợp một khách có nhiều đơn trong tháng và trường hợp hai khách bằng điểm."
Nếu bí, nói ra hướng suy nghĩ thay vì im lặng. Phỏng vấn viên đánh giá quá trình, không chỉ kết quả.
Thực hành
- Viết top 3 khách hàng theo doanh thu mỗi tháng, xử lý đồng hạng bằng
DENSE_RANK. - Viết top 1 sản phẩm bán chạy mỗi danh mục, dùng
distinct on. - Tính doanh thu lũy kế của mỗi khách theo thời gian bằng window function.
- Cho bảng có 5 đơn của cùng một khách, giải thích vì sao xếp hạng trước khi gộp là sai.
- Trả lời miệng trong 3 phút: "Top 10% khách hàng theo chi tiêu nghĩa là gì?"
Lỗi thường gặp
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Mỗi tháng chỉ có 3 dòng toàn bảng | Thiếu PARTITION BY | Thêm partition by thang |
| Kết quả thay đổi giữa các lần chạy | Đồng hạng không có tiêu chí phụ | Thêm customer_id vào ORDER BY |
| Xếp hạng theo từng đơn | Chưa GROUP BY | Tổng hợp trong CTE trước |
| Mất khách đồng hạng thứ ba | Dùng ROW_NUMBER cho yêu cầu "top 3 mức" | Đổi sang DENSE_RANK |
| Tháng không có đơn bị thiếu | Bảng nguồn không có dòng | generate_series + left join |
column must appear in GROUP BY | Chọn cột không gộp | Thêm vào GROUP BY hoặc bỏ khỏi SELECT |
Tóm tắt
ROW_NUMBERcho đúng N dòng;RANK/DENSE_RANKcho đồng hạng.- Luôn tổng hợp trước, xếp hạng sau.
PARTITION BYlà phần quyết định "top N theo nhóm".- Nêu giả định và hỏi lại trước khi viết SQL.
- Trình bày theo bốn bước: làm rõ, cấu trúc, viết, kiểm tra.
Use ← → to move between lessons