Sort Tầng Tưởng Dễ Mà Khó Như Cuộc Đời
Hôm đó tôi nhận được một ticket tưởng chừng vô hại…
Buổi sáng thứ Hai, cà phê chưa kịp nguội, tôi mở Jira và thấy một ticket với tiêu đề hiền lành đến mức đáng ngờ:
“Danh sách tầng hiển thị không theo thứ tự.”
Chỉ vậy thôi. Không có screenshot. Không có reproduce steps. Không có gì hết.
Tôi gõ vào máy, mở màn hình danh sách tầng lên, và thấy:
Tầng 10
Tầng 15
Tầng 2
Tầng 22
Tầng 3
Tầng 7
À. Tầng 10 đứng trước Tầng 2. Tầng 22 đứng trước Tầng 3. Kiểu như cái danh sách này đang sắp xếp theo… bảng chữ cái? Theo kiểu “1” < “2” < “3” nhưng “10” lại so sánh ký tự từng chữ một nên “10” < “2” vì “1” < “2”.
Tôi nhìn cái kết quả đó một lúc. Rồi nhìn lại. Rồi uống một ngụm cà phê.
“Oke bro. Hôm nay mày sort."
Vấn Đề Và Bối Cảnh
Tại sao lại lộn xộn vậy?
Vấn đề gốc rễ nằm ở cách database trả về dữ liệu. Mặc định, khi query danh sách tầng, kết quả được sort theo createdAt DESC — tức là tầng nào tạo sau thì hiện trước. Cái này hoàn toàn không có ý nghĩa gì với người dùng muốn xem “Tầng 1 rồi đến Tầng 2 rồi đến Tầng 3”.
Nhưng khi tôi nhìn vào database thực tế, vấn đề còn phức tạp hơn nhiều. Dữ liệu từ hàng chục khách sạn khác nhau, mỗi nơi đặt tên tầng theo một kiểu riêng:
Tầng 3 ← tiếng Việt, có dấu
Floor 02 ← tiếng Anh, có số 0 ở đầu
FLOOR 07 ← tiếng Anh, toàn in hoa
ZONE 11 ← kiểu zone
ZONE11 ← cũng zone nhưng không có dấu cách
Zone 9 ← zone nhưng viết hoa chữ đầu thôi
Sân Tập bóng bàn S103 - 01 ← tên dài, có nhiều số
BBQ CV Zone 2 - 08 ← tên kiểu khu BBQ
Floor 12a ← có suffix chữ cái
FLOOR 12A ← cũng 12a nhưng in hoa
Hầm B1 ← tầng hầm
BT4 ← biệt thự, viết tắt
Khu Biệt thự 12 ← biệt thự, viết đầy đủ
Nếu dùng sort mặc định theo kiểu so sánh chuỗi thông thường (< và >), sẽ xảy ra hai vấn đề chính:
Vấn đề 1 — Lexicographic sort (sort theo bảng chữ cái thuần túy):
Before (sai): Tầng 10, Tầng 15, Tầng 2, Tầng 22, Tầng 3
After (đúng): Tầng 2, Tầng 3, Tầng 10, Tầng 15, Tầng 22
Vì khi so chuỗi ký tự, "10" < "2" do '1' < '2'. Giống như xếp học sinh theo tên mà tên “An” được xếp trước “Bảo” nhưng “An Khoa” lại đứng trước “Bảo” vì chỉ nhìn chữ cái đầu.
Vấn đề 2 — Phân nhóm không nhất quán:
Before (sai): "Floor 12" và "FLOOR 07" nằm hai nhóm khác nhau
"ZONE 11" và "Zone 9" cũng hai nhóm khác nhau
After (đúng): Tất cả Floor/FLOOR/floor → một nhóm
Tất cả ZONE/Zone/zone → một nhóm
Giải Pháp
Ý tưởng: “Chụp hình khuôn” của tên tầng
Thay vì cố gắng viết một danh sách prefix (“à Floor thì strip ra, Tầng thì strip ra…"), chúng tôi nghĩ theo hướng khác: tự động phát hiện pattern.
Ý tưởng đơn giản: thay tất cả các con số bằng ký hiệu #. Thế là mọi tên tầng đều có một “khuôn”:
"Tầng 3" → khuôn: "tầng#"
"Tầng 22" → khuôn: "tầng#"
"Floor 12" → khuôn: "floor#"
"FLOOR 07" → khuôn: "floor#" (sau khi lowercase)
"ZONE 11" → khuôn: "zone#"
"ZONE11" → khuôn: "zone#" (space quanh # được collapse)
"Sân Tập bóng bàn S103 - 01" → khuôn: "sân tập bóng bàn s#-#"
"BBQ CV Zone 2 - 08" → khuôn: "bbq cv zone#-#"
Hai cái tên cùng khuôn → cùng nhóm. Đơn giản thế thôi. Không cần hardcode bất cứ từ nào.
Đây là thuật toán extractSegments:
function extractSegments(str) {
const normalized = String(str || '').trim().replace(/\s+/g, ' ');
const parts = normalized.split(/(\d+)/);
const nums = [];
const rawTokens = [];
const lowerTokens = [];
parts.forEach((part, i) => {
if (i % 2 === 1) {
nums.push(parseInt(part, 10)); // giữ số nguyên
} else {
rawTokens.push(part.trim()); // giữ nguyên case
lowerTokens.push(part.toLowerCase().trim()); // lowercase để group
}
});
const pattern = rawTokens.join('#').replace(/\s*#\s*/g, '#');
const groupKey = lowerTokens.join('#').replace(/\s*#\s*/g, '#')
.replace(/#[a-z]$/, '#'); // "floor#a" → "floor#"
return { pattern, groupKey, nums };
}
Chú ý cái trick nhỏ ở cuối: replace(/#[a-z]$/, '#') — strip một chữ cái cuối sau #. Vì thế "Floor 12a", "Floor 12b", "FLOOR 12A" đều về cùng nhóm "floor#" thay vì tạo ra ba nhóm riêng.
Ba bước sort
Sau khi có khuôn rồi, sort theo ba bước:
1. So groupKey → "bbq cv zone#-#" < "floor#" < "tầng#" < "zone#"
(sắp xếp theo alphabet, nhóm "B" trước nhóm "F" trước "T"...)
2. So số → Tầng 2 < Tầng 3 < Tầng 10 < Tầng 22
(so số nguyên, không phải chuỗi)
3. Tiebreak → "Floor 12" < "FLOOR 12"
(case-sensitive tiebreak nếu cùng nhóm + cùng số)
Vấn đề tiếng Việt và Intl.Collator
Đây là chỗ tinh tế nhất. JavaScript thuần dùng < và > để so chuỗi theo Unicode code point. Nhưng với tiếng Việt, điều đó không đúng:
"bóng" < "bong" // → false (sai về mặt ngữ nghĩa tiếng Việt)
"FLOOR" < "floor" // → false (vì 'F' có code point nhỏ hơn 'f')
Giải pháp: dùng Intl.Collator — API chuẩn của JavaScript để so chuỗi theo ngôn ngữ cụ thể.
// Cho grouping: không phân biệt HOA/thường, nhưng phân biệt dấu
const COLLATOR_GROUP = new Intl.Collator('vi', { sensitivity: 'accent' });
// → "FLOOR" == "floor", nhưng "Bóng" != "Bong"
// Cho tiebreak: phân biệt cả HOA/thường lẫn dấu
const COLLATOR_TIEBREAK = new Intl.Collator('vi', { sensitivity: 'variant' });
// → "Floor 12" < "FLOOR 12"
Hai collator được tạo một lần ở module level (không tạo lại trong mỗi lần so sánh) để tránh tốn performance.
Pros vs Cons
Pros:
- Tự động phát hiện pattern — không cần hardcode “Floor”, “Tầng”, “Zone”… Khách sạn nào đặt tên kiểu gì cũng sort đúng
- Hoạt động cho cả tiếng Việt, tiếng Anh, lẫn ký hiệu đặc biệt
- Case-insensitive cho grouping nhưng case-sensitive cho tiebreak
- Dấu tiếng Việt được xử lý đúng qua
Intl.Collator('vi')
Cons:
- Chỉ sort phía application (sau khi đã fetch hết dữ liệu từ DB), không thể dùng làm ORDER BY trong SQL
- Vì vậy phải fetch toàn bộ danh sách (limit 99999) rồi sort trong memory — với dữ liệu thực tế thì ổn, nhưng nếu số lượng tầng lên đến hàng triệu thì cần xem lại
- Logic groupKey
replace(/#[a-z]$/, '#')chỉ strip một ký tự cuối — nếu suffix là12abthì không gộp được
Kiến Thức Đại Học Ứng Dụng
Môn: Cấu trúc dữ liệu và Giải thuật
Bài toán sort này thực ra là bài toán custom comparator — một khái niệm rất cơ bản trong môn Giải thuật. Thầy dạy: “Muốn sort theo tiêu chí nào thì viết comparator trả về số âm, số 0, hoặc số dương.” Hồi đó tôi gật đầu, thi xong quên luôn.
Nhưng đây chính là bài toán đó — comparator của chúng tôi không đơn giản là a - b, mà là một hàm 3 bước với priority: group trước, số sau, tiebreak cuối. Đây là ứng dụng thực tế của multi-key sort hay còn gọi là lexicographic order trên tuple — so tuple (groupKey, nums[], pattern) theo thứ tự ưu tiên.
Môn: Lý thuyết ngôn ngữ hình thức (Automata)
Hàm extractSegments thực ra là một dạng tokenizer đơn giản: tách chuỗi ra thành xen kẽ text token và number token bằng regex split(/(\d+)/). Đây chính xác là bước “lexical analysis” mà môn Automata dạy. Hồi đó tôi tưởng chỉ dùng để viết compiler. Ai ngờ sort tầng khách sạn cũng cần đến.
Bài Học Rút Ra
- Ticket “nhỏ” không bao giờ nhỏ. “Danh sách tầng hiển thị không theo thứ tự” nghe thì đơn giản nhưng ẩn sau đó là cả một mớ dữ liệu không nhất quán từ hàng trăm khách sạn.
- Đừng hardcode. Nếu viết
if name.startsWith("Tầng")hayif name.startsWith("Floor")thì sẽ miss hàng chục pattern khác. Tự động phát hiện pattern tốt hơn là liệt kê thủ công. <và>trong JavaScript là bẫy với Unicode. Với tiếng Việt (và nhiều ngôn ngữ khác), hãy dùngIntl.Collator. Nó tồn tại chính xác vì lý do này.- Tạo collator một lần, dùng nhiều lần.
new Intl.Collator(...)không rẻ. Tạo ở module level, không tạo trong vòng lặp. - Cà phê buổi sáng thứ Hai là vũ khí bí mật. Không có nó, ticket này có thể mất cả ngày.