Menu

Số ngẫu nhiên trong C: rand, srand và lấy số trong một khoảng

Cách tạo số ngẫu nhiên trong C với rand() và RAND_MAX, vì sao chỉ khởi tạo seed bằng srand(time(NULL)) đúng một lần, đưa giá trị vào một khoảng bằng % cùng độ lệch nhỏ mà nó gây ra, số thực ngẫu nhiên và dãy số lặp lại được.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Số ngẫu nhiên trong C đến từ hai hàm trong <stdlib.h>: rand() sinh ra giá trị tiếp theo, còn srand() đặt điểm bắt đầu. Chúng không thật sự ngẫu nhiên: đó là một dãy giả ngẫu nhiên (pseudorandom), được tính một cách tất định từ một seed. Với mật mã học đây là hạn chế, còn với việc kiểm thử thì lại là một tính năng.

rand() và RAND_MAX

rand() trả về một int nằm đâu đó từ 0 đến RAND_MAX, tính cả hai đầu. RAND_MAX là một macro được đảm bảo ít nhất bằng 32767; trên Linux và macOS nó bằng 2147483647.

Chạy đoạn code trên hai lần. Các con số giống hệt nhau cả hai lần, và đó không phải là bug.

Khởi tạo seed bằng srand

Nếu không gọi srand, dãy số hoạt động như thể bạn đã gọi srand(1). Cùng seed thì cùng dãy, lần chạy nào cũng vậy. Muốn mỗi lần chạy ra số khác nhau, hãy dùng seed là một thứ luôn thay đổi, theo thông lệ là thời gian hiện tại:

time(NULL) trong <time.h> trả về số giây tính từ đầu năm 1970, nên mỗi lần chạy có một seed khác nhau. Phép ép kiểu sang unsigned int để tắt cảnh báo về việc thu hẹp time_t.

Ba quy tắc về seed mà ai cũng từng làm sai:

Chỉ khởi tạo seed đúng một lần, ở đầu main. Gọi srand trước mỗi lần rand() là lỗi kinh điển: trong một vòng lặp chạy xong chưa tới một giây, time(NULL) trả về cùng một giá trị ở mọi lần lặp, nên bạn khởi tạo lại với cùng một số và rand() lần nào cũng trả về cùng giá trị đầu tiên. Kết quả là một cột các số "ngẫu nhiên" giống hệt nhau.

Đừng khởi tạo lại seed để "tăng" độ ngẫu nhiên. Chất lượng của bộ sinh số đến từ trạng thái bên trong tiến dần lên; đặt lại trạng thái đó là vứt bỏ cả dãy số.

time(NULL) có độ phân giải một giây. Hai chương trình khởi chạy trong cùng một giây sẽ nhận cùng một dãy. Với trò chơi thì không sao, nhưng sai với bất cứ thứ gì cần tính độc lập.

Lấy số trong một khoảng

Cách viết chuẩn dùng toán tử chia lấy dư:

rand() % n            /* 0 to n-1 */
rand() % n + min      /* min to min+n-1 */

Để lấy từ min đến max, tính cả hai đầu, số lượng giá trị có thể có là max - min + 1:

+ 1 chính là chỗ hay sinh lỗi lệch một (off-by-one). rand() % 6 cho từ 0 đến 5, nên tung xúc xắc là rand() % 6 + 1. Viết rand() % 7 + 1 để "lấy cả số 6" thì bạn sẽ có một con xúc xắc bảy mặt.

Nói thẳng về modulo bias

rand() % n không phân bố đều hoàn hảo trừ khi n chia hết RAND_MAX + 1.

Hãy nghĩ với số nhỏ. Nếu RAND_MAX là 9, tức rand() trả về từ 0 đến 9 với mười giá trị có khả năng như nhau, thì rand() % 3 đưa 0,3,6,9 về 0; 1,4,7 về 1; và 2,5,8 về 2. Kết quả 0 có bốn cách xuất hiện trên mười, còn kết quả 1 và 2 mỗi cái ba cách. Số 0 có khả năng ra cao hơn 33%.

Độ lệch tương tự cũng tồn tại với RAND_MAX thật, chỉ nhỏ hơn rất nhiều: các giá trị dư ra là (RAND_MAX + 1) % n kết quả đầu tiên, mỗi kết quả được thêm một cơ hội trên khoảng 2,1 tỷ. Với tung xúc xắc, xáo bài hay mô phỏng, mức đó không thể đo được: cứ dùng % rồi làm tiếp.

Khi độ lệch thật sự quan trọng, như công việc thống kê hay bất cứ thứ gì liên quan đến bảo mật, hãy loại bỏ các giá trị dư ra thay vì gộp chúng vào:

Vòng lặp vứt bỏ khoảng giá trị nhỏ gây ra độ lệch và rút lại số khác. Nó kết thúc rất nhanh vì phần bị loại chỉ là một phần cực nhỏ của toàn bộ.

Với tính ngẫu nhiên thật sự nhạy cảm về bảo mật, rand() là công cụ sai dù bạn cẩn thận đến đâu: hãy dùng arc4random_buf trên macOS và BSD, getrandom() trên Linux, hoặc BCryptGenRandom trên Windows.

Số thực ngẫu nhiên

Chia cho RAND_MAX để nằm trong [0.0, 1.0], rồi co giãn theo ý muốn:

Phép ép kiểu ở (double) rand() là bắt buộc. Thiếu nó, rand() / RAND_MAX là phép chia số nguyên và gần như luôn bằng 0, chỉ bằng 1 khi may mắn trúng giá trị lớn nhất với xác suất một trên hai tỷ. Đó là bug kiểu "số thực ngẫu nhiên toàn ra 0". Xem ép kiểu để hiểu vì sao.

Dãy số lặp lại được

Một seed cố định cho ra cùng một dãy ở mọi lần chạy, đúng thứ bạn cần cho kiểm thử, một buổi gỡ lỗi, hay một trò chơi có mã màn chơi để chia sẻ:

Seed 42 lần nào cũng sinh ra đúng năm số ấy, trong lần chạy này và mọi lần chạy khác trên cùng thư viện. Chính khả năng lặp lại đó là lý do một chương trình mô phỏng nên cho phép chọn seed: bình thường chạy theo đồng hồ, còn khi tái hiện bug thì truyền vào một seed cố định.

Một lưu ý: dãy số của một seed không khả chuyển. Các thư viện C khác nhau dùng các bộ sinh số khác nhau, nên seed 42 trên glibc và seed 42 trên Windows cho ra các số khác nhau. Lặp lại được trên một máy, nhưng không giữa các máy.

Trò chơi xúc xắc

Gộp tất cả lại: khởi tạo seed một lần, một hàm phụ để lấy số trong khoảng, và một mảng đếm kết quả:

Biểu đồ tần suất nên cao nhất ở 7 và thấp dần về phía 2 và 12: có sáu cách để ra 7 và chỉ một cách để ra 2 hoặc 12. Một bộ sinh số cho ra phân bố phẳng ở đây là bộ sinh số hỏng.

Hai trang liên quan: thư viện chuẩn giới thiệu phần còn lại của <stdlib.h>, còn các hàm toán học nói về <math.h>, thứ bạn sẽ cần ngay khi giá trị ngẫu nhiên đi vào các phép tính thật.

Câu hỏi thường gặp

Làm sao tạo số ngẫu nhiên trong C?

Thêm <stdlib.h>, khởi tạo seed một lần ở đầu main bằng srand((unsigned) time(NULL)) (cần <time.h>), rồi gọi rand() cho mỗi giá trị. rand() trả về một int từ 0 đến RAND_MAX, tính cả hai đầu.

Làm sao lấy số ngẫu nhiên giữa hai giá trị trong C?

Dùng rand() % (max - min + 1) + min. Với xúc xắc từ 1 đến 6, đó là rand() % 6 + 1. Phép % n đưa kết quả về khoảng 0..n-1, còn cộng min thì dịch khoảng đó đi. Chỉ cần nhớ số lượng giá trị phải tính cả hai đầu mút, và đó chính là việc của + 1.

Vì sao chương trình C in ra cùng các số ngẫu nhiên mỗi lần chạy?

Vì bạn chưa gọi srand. Không có seed, rand() hoạt động như thể seed là 1, nên lần chạy nào cũng ra đúng một dãy số. Gọi srand((unsigned) time(NULL)) một lần khi chương trình bắt đầu. Chỉ một lần thôi, đừng gọi trước mỗi lần rand(), vì như vậy còn tệ hơn.

Modulo bias trong sinh số ngẫu nhiên là gì?

rand() % n chỉ phân bố đều hoàn hảo khi n chia hết RAND_MAX + 1. Nếu không, vài giá trị đầu tiên xuất hiện thêm một lần trên toàn bộ khoảng, nên khả năng ra chúng cao hơn một chút xíu. Với RAND_MAX bằng 2147483647 và n nhỏ, độ lệch này thấp hơn nhiều so với mức mà một trò chơi hay mô phỏng có thể nhận ra, nhưng với mật mã học hay thống kê, hãy dùng vòng lặp loại bỏ hoặc một bộ sinh số đúng chuẩn.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu