DHDataHọc
TheoryLesson 1/86 min read+10 XP

Bài toán top-N

Bạn sẽ học được

  • Phân biệt ROW_NUMBER, RANK và DENSE_RANK khi 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:

  1. "Top 3" tính theo gì? Tổng doanh thu, số đơn, hay giá trị trung bình?
  2. Đồ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?
  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 thuROW_NUMBERRANKDENSE_RANK
500.000111
500.000211
400.000332
300.000443
  • 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_idcustomer_idorder_dateamount
1A2026-01-03500000
2B2026-01-05500000
3C2026-01-08400000
4D2026-01-09300000
5A2026-02-02700000

Bước 1 — tổng hợp theo tháng và khách.

code
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.

code
, 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:

thangcustomer_idrevenuern
2026-01-01A5000001
2026-01-01B5000002
2026-01-01C4000003

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:

code
        dense_rank() over (
            partition by thang
            order by revenue desc
        ) as dr
...
where dr <= 3

Vớ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":

code
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àiCách tiếp cận
Top 1 mỗi nhómrow_number() = 1 hoặc distinct on (nhom) của Postgres
Top N mỗi nhómrow_number() over (partition by nhom ...)
Top N toàn bảngorder by ... limit N
Top N kèm đồng hạngdense_rank()
Top N theo tỷ lệntile(10) rồi lọc nhóm
Top N của tháng gần nhấtLọ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:

code
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:

  1. 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?"
  2. 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."
  3. Viết SQL: đặt tên CTE theo nghĩa (monthly, ranked), không dùng t1, t2.
  4. 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

  1. Viết top 3 khách hàng theo doanh thu mỗi tháng, xử lý đồng hạng bằng DENSE_RANK.
  2. Viết top 1 sản phẩm bán chạy mỗi danh mục, dùng distinct on.
  3. Tính doanh thu lũy kế của mỗi khách theo thời gian bằng window function.
  4. 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.
  5. 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ứngNguyên nhânCách sửa
Mỗi tháng chỉ có 3 dòng toàn bảngThiếu PARTITION BYThê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 đơnChưa GROUP BYTổng hợp trong CTE trước
Mất khách đồng hạng thứ baDùng ROW_NUMBER cho yêu cầu "top 3 mức"Đổi sang DENSE_RANK
Tháng không có đơn bị thiếuBảng nguồn không có dònggenerate_series + left join
column must appear in GROUP BYChọn cột không gộpThêm vào GROUP BY hoặc bỏ khỏi SELECT

Tóm tắt

  • ROW_NUMBER cho đúng N dòng; RANK/DENSE_RANK cho đồng hạng.
  • Luôn tổng hợp trước, xếp hạng sau.
  • PARTITION BY là 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