Khi số ngẫu nhiên không thực sự ngẫu nhiên: cạm bẫy của toán tử modulo
Nhiều lập trình viên vẫn dùng cách lấy số ngẫu nhiên rồi chia lấy dư (modulo) để chọn phần tử, nhưng cách này phá vỡ tính phân phối đều và tạo ra kết quả lệch lạc. Bài viết phân tích lỗi kinh điển này và đề xuất API random_choice() với trọng số nguyên tương đối để biểu diễn xác suất một cách tường minh.

Chọn ngẫu nhiên một phần tử từ một tập hợp là công việc tưởng chừng đơn giản đến mức ai cũng nghĩ mình làm đúng. Thế nhưng, đằng sau thao tác nhỏ bé ấy lại ẩn chứa một trong những lỗi kinh điển của lập trình: dùng toán tử modulo đã vô tình phá vỡ tính phân phối đều mà hàm sinh số ngẫu nhiên mang lại.
Cách làm quen thuộc và sai lầm ẩn giấu
Một đoạn code điển hình mà bạn gặp ở khắp nơi, đặc biệt là C hay Haskell, có dạng như sau:
Set choices = ten_things();
u64 r = random_u64();
T chosen = choices[r % 10];
Trực giác mách bảo rằng mỗi phần tử có xác suất được chọn là 1/10, tức 10%. Nhưng điều đó chỉ đúng khi số lượng lựa chọn chia hết cho phạm vi của hàm sinh số ngẫu nhiên. Với random_u64() trả về giá trị trong khoảng [0, UINT64_MAX], phép chia lấy dư cho 10 không hề tạo ra phân phối đều.
Hãy xét trường hợp đơn giản hơn: chọn một số nguyên trong khoảng [0, 9], sau đó dùng modulo 3 để chọn một trong ba đối tượng.
- {0, 3, 6, 9} → đối tượng #1
- {1, 4, 7} → đối tượng #2
- {2, 5, 8} → đối tượng #3
Kết quả: đối tượng #1 được chọn 40% số lần, còn #2 và #3 chỉ 30%. Sự bất công bằng này xuất phát từ việc 10 không chia hết cho 3.
Đây là một trong những lỗi kinh điển, đặc biệt khi bạn chỉ có
random_u64()trong tay.
API đúng đắn nên trông như thế nào?
Giải pháp chính xác là một hàm random_between(l, h) đảm bảo mỗi số trong khoảng đóng đều có xác suất bằng nhau. Nhưng theo tác giả bài viết, bản thân việc chỉ cung cấp hàm sinh số ngẫu nhiên đều đã là một thiết kế API thiếu tối ưu — bởi nó dễ khiến người dùng mắc lỗi, lại không cho phép biểu diễn các phân phối phức tạp hơn.
Thay vào đó, nên hướng tới một toán tử tổng quát hơn, buộc lập trình viên đối diện trực tiếp với phân phối xác suất mong muốn:
T chosen = random_choice([
(First, 0.4),
(Second, 0.3),
(Third, 0.3),
])
Cách viết này khiến lỗi lệch lạc trở nên "lộ liễu như ban ngày". Nếu bạn cố tình đặt 40% cho lựa chọn đầu, nó sẽ tự tố cáo sự bất thường ngay trong code.
Trọng số nguyên tương đối: tránh cạm bẫy số thực
Dùng xác suất dạng số thực có nhược điểm: chúng phải cộng đúng bằng 1.0, và số thực động có thể gây sai số tích lũy. Giải pháp quen thuộc là chuyển sang trọng số nguyên tương đối, giống như thư viện chuẩn của Python.
Với trọng số (4, 3, 3), tổng bằng 10, xác suất của lựa chọn thứ hai là 3/10 = 30%. Cách biểu diễn này trực quan hơn: nếu bạn đếm được 10 viên bi xanh và 5 viên bi đỏ, bạn chỉ cần viết [(Blue, 10), (Red, 5)].
Cách triển khai cũng rất đơn giản nếu có sẵn random_between(l, h):
- Giả sử trọng số là 15 + 12 + 3 = 30
- Lấy
choice = random_between(0, 29) - Nếu giá trị thuộc [0, 14] → lựa chọn thứ nhất
- Nếu thuộc [15, 26] → lựa chọn thứ hai
- Nếu thuộc [27, 29] → lựa chọn thứ ba
Nói cách khác, bạn ánh xạ các trọng số nguyên lên các khoảng con của miền giá trị rồi chọn đều trong khoảng đó.
Sai lầm về mặt tư duy: nhầm lẫn giữa giá trị và biến ngẫu nhiên
Vấn đề sâu xa hơn nằm ở chỗ ta đang thao tác trong sai miền tư duy. Thứ ta muốn nói tới không phải là những con số cụ thể, mà là các biến ngẫu nhiên (X, Y, Z). Biến ngẫu nhiên có một đại số riêng, nhưng điều quan trọng nhất là: các toán tử phi tuyến không bảo toàn giá trị kỳ vọng.
Nói cách khác, E[f(X)] không nhất thiết bằng f(E[X]). Đây chính xác là những gì xảy ra với modulo: ta lầm tưởng đang bảo toàn tính chất của giá trị x = random_u64(), nhưng thực chất lại đang làm biến dạng phân phối của cả hàm sinh số ngẫu nhiên.
Ứng dụng thực tế: kiểm thử và fuzzing
Với các kỹ sư dùng nền tảng kiểm thử như Antithesis, việc biểu diễn phân phối trực tiếp tỏ ra cực kỳ hữu ích. Ví dụ, khi muốn kiểm tra các đường dẫn xử lý kích thước tệp khác nhau:
let choices = vec![
(Small, 88),
(Medium, 04),
(Large, 04),
(XLarge, 04),
];
Với cấu hình này, khoảng 7/8 lượt tải lên là blob nhỏ và 1/8 là blob lớn thuộc ba kích thước khác nhau. Khả năng điều chỉnh các con số này giúp khám phá không gian trạng thái hiệu quả hơn nhiều. Bạn thậm chí có thể mở rộng để biểu diễn phân phối Poisson, hoặc thêm lựa chọn động vào vector khi gặp điều kiện "tốt" hay "xấu" nhằm định hướng quá trình tìm kiếm lỗi.
Bài học cho lập trình viên Việt Nam
Trong nhiều dự án phần mềm tại Việt Nam — từ game, ứng dụng thương mại điện tử đến hệ thống backend — việc chọn ngẫu nhiên phần tử xuất hiện ở khắp nơi: chọn sản phẩm gợi ý, chọn A/B test, chọn server cân bằng tải, chọn phần thưởng trong game. Một sai lệch nhỏ về xác suất có thể ảnh hưởng lớn đến tính công bằng và độ tin cậy của hệ thống.
Lần tới khi viết code kiểu arr[random() % arr.length], hãy tự hỏi: liệu tôi có thể biểu diễn trực tiếp phân phối mong muốn không? Nếu câu trả lời là có, bạn sẽ tìm thấy một công cụ phong phú và an toàn hơn nhiều so với phép chia lấy dư tưởng chừng vô hại.