Dữ liệu & CPU/Số nguyên và tràn số — two's complement và khi phép cộng ra số âm
3/23
Bài 3 / 23~16 phútBiểu diễn dữ liệuMiễn phí lượt xem

Số nguyên và tràn số — two's complement và khi phép cộng ra số âm

Cách máy lưu số nguyên âm bằng two's complement, vì sao 2 tỷ + 2 tỷ ra số âm, và khi nào int đủ khi nào cần long. Tránh bug tràn số trong code thật.

TL;DR: Máy lưu số âm bằng two's complement — đảo toàn bộ bit rồi cộng 1. Cách này giúp phép cộng và trừ dùng chung một mạch, và không có hai biểu diễn của số 0. Khi cộng hai số int vượt 2.147.483.647, kết quả wrap-around về âm chứ không báo lỗi — đây là tràn số (overflow). Python int vô hạn nên không tràn; Java/C/Go có kích thước cố định nên tràn âm thầm. Chọn int khi miền giá trị chắc chắn trong 32-bit, long khi đếm mili-giây, tiền, hay bất kỳ thứ gì có thể vượt 2 tỷ.

Năm 2012, video "Gangnam Style" của PSY phá mọi kỷ lục YouTube. Kỹ sư Google thở phào — bộ đếm view dùng kiểu int 32-bit, tối đa khoảng 2,147,483,647 lượt. Đến tháng 12/2014, con số ấy không đủ nữa. Nếu bộ đếm tràn, nó sẽ nhảy sang âm — hàng tỷ lượt xem hiển thị thành số âm trên giao diện. Google phải vá khẩn cấp, chuyển sang int64. Đây không phải tai nạn — đây là hành vi được định nghĩa rõ của kiểu số nguyên kích thước cố định.

Bài này giải thích vì sao tràn số xảy ra, cơ chế two's complement cho phép máy biểu diễn số âm, và cách chọn kiểu để tránh bug ấy trong code thật.

1. Analogy — đồng hồ đo kilômét quay vòng

Xe máy cũ có đồng hồ cơ 6 chữ số: 000000 đến 999999. Chạy thêm 1 km sau 999999 không báo lỗi — nó quay về 000000. Tương tự, cộng thêm vào 999999 thì ta ra 000001 — ngỡ như chạy ít hơn, thực ra đã quay vòng.

Số nguyên trong máy tính hoạt động theo cùng nguyên lý, chỉ là "đồng hồ" đếm theo nhị phân và có điểm gập ở giữa (để chứa cả âm lẫn dương).

Đồng hồ kmSố nguyên 32-bit
Tối đa 999999Tối đa 2147483647 (INT_MAX)
Quay về 000000 khi trànQuay về -2147483648 (INT_MIN) khi tràn
6 chữ số thập phân32 chữ số nhị phân
Không báo lỗi khi trànKhông báo lỗi khi tràn (hầu hết ngôn ngữ)
💡 Cách nhớ

Tràn số giống đồng hồ km quay vòng — máy không báo lỗi, cứ chạy tiếp. Bug chỉ lộ khi bạn đọc kết quả ra và thấy số âm vô lý.

2. Số nguyên không dấu và có dấu

2.1 Không dấu (unsigned): toàn bộ dải là dương

Với n bit, unsigned biểu diễn dải 0 đến 2^n − 1. Mọi tổ hợp bit đều là số không âm.

Kích thướcDải unsigned
8 bit0 đến 255
16 bit0 đến 65.535
32 bit0 đến 4.294.967.295
64 bit0 đến khoảng 1,8 × 10^19

Ví dụ thực tế: địa chỉ IPv4 (4 byte, mỗi byte 0255), ảnh PNG lưu màu pixel.

2.2 Có dấu (signed): two's complement

Hầu hết ngôn ngữ dùng two's complement để biểu diễn số âm. Ý tưởng: cắt dải n-bit thành đôi, nửa trên là số âm.

Quy tắc lấy số âm từ số dương:

  1. Viết số dương dưới dạng nhị phân n bit.
  2. Đảo toàn bộ bit (0 thành 1, 1 thành 0) — gọi là bitwise NOT.
  3. Cộng thêm 1.

Ví dụ với 8 bit, tìm biểu diễn của -3:

+3  =  0000 0011
NOT =  1111 1100
+1  =  1111 1101   <- day la -3 trong two's complement

Kiểm tra ngược: lấy 1111 1101, đảo bit ra 0000 0010, cộng 1 ra 0000 0011 = 3. Đúng.

Vì sao chọn two's complement, không phải cách khác?

Cách ngây thơ nhất là dùng 1 bit cao nhất làm "dấu" (sign-magnitude): 0 = dương, 1 = âm. Cách này có hai vấn đề nghiêm trọng: (1) tồn tại hai biểu diễn của số 0 (0000 00001000 0000), gây logic phức tạp khi so sánh; (2) phép cộng và phép trừ cần mạch riêng, làm chip tốn kém hơn.

Two's complement giải quyết cả hai: chỉ có một biểu diễn của số 0, và phép cộng số âm với số dương dùng chung mạch cộng với số unsigned — CPU không cần phân biệt dấu khi thực hiện phép tính.

  1111 1101   (-3 trong two's complement)
+ 0000 0011   (+3)
-----------
1 0000 0000   -> bit carry tran ra ngoai 8 bit, phan con lai = 0 ✓

Dải giá trị có dấu 32-bit:

  • Tối thiểu: −2^31 = −2.147.483.648 (thường gọi INT_MIN)
  • Tối đa: 2^31 − 1 = 2.147.483.647 (thường gọi INT_MAX)

Lý do tối thiểu có trị tuyệt đối lớn hơn tối đa 1 đơn vị: bit pattern 1000...0 (bit cao nhất là 1, còn lại là 0) là INT_MIN, và không có +INT_MIN tương ứng trong dải 32-bit.

flowchart LR
  A["0 den 2^31-1\n(bit cao nhat = 0)"] -->|"wrap-around"| B["−2^31 den −1\n(bit cao nhat = 1)"]
  B -->|"wrap-around"| A

3. Tràn số — wrap-around modular

Khi cộng hai số có dấu mà kết quả vượt INT_MAX, bit carry tràn ra ngoài và phần còn lại là một số âm. Đây là signed overflow.

// Java: int la 32-bit signed
int a = 2_000_000_000;   // 2 ty
int b = 2_000_000_000;   // 2 ty
int c = a + b;           // mong 4 ty, nhung 4 ty > INT_MAX
System.out.println(c);   // -294967296 (so am, sai hoan toan)
# Python: int la bignum, khong tran
a = 2_000_000_000
b = 2_000_000_000
c = a + b
print(c)  # 4000000000, dung
// Go: int32 tran, int64 khong tran
var a int32 = 2_000_000_000
var b int32 = 2_000_000_000
fmt.Println(a + b)  // -294967296

var x int64 = 2_000_000_000
var y int64 = 2_000_000_000
fmt.Println(x + y)  // 4000000000

Khác biệt quan trọng giữa ngôn ngữ:

Ngôn ngữHành vi khi tràn signed
C/C++Undefined behavior — compiler có thể làm bất cứ thứ gì
JavaWrap-around modular (xác định, nhưng không báo lỗi)
PythonKhông tràn — int là bignum tự mở rộng
GoWrap-around modular cho kiểu kích thước cố định
RustPanic ở debug mode, wrap ở release (hoặc dùng wrapping_add)

C/C++ undefined behavior khi tràn signed là điều đặc biệt nguy hiểm: compiler được phép giả sử tràn không xảy ra và tối ưu hoá dựa trên giả sử đó, khiến code hoạt động sai theo những cách không thể đoán trước. Đây là nguồn gốc của nhiều lỗ hổng bảo mật nghiêm trọng.

📚 Đào sâu (tuỳ chọn) — hai sự cố tràn số nổi tiếng

Ariane 5 — 1996: Tên lửa Ariane 5 của ESA phát nổ 37 giây sau khi phóng. Nguyên nhân: phần mềm dẫn đường tái sử dụng từ Ariane 4 ép kiểu một số 64-bit (vận tốc nằm ngang, đơn vị mm/giây) xuống biến 16-bit. Vận tốc Ariane 5 lớn hơn Ariane 4 nhiều, giá trị 64-bit không vừa 16-bit — gây overflow, hệ thống ném exception, tắt máy tính dẫn đường, tên lửa mất kiểm soát. Thiệt hại: 370 triệu USD. Bài học: không tái sử dụng component mà không kiểm tra miền giá trị ở ngữ cảnh mới.

Binary search mid — 2006: Joshua Bloch (tác giả "Effective Java") phát hiện bug kinh điển trong java.util.Arrays.binarySearch và nhiều thư viện khác: tính chỉ số giữa bằng (low + high) / 2. Khi lowhigh đều lớn (ví dụ mảng có hàng trăm triệu phần tử), low + high tràn INT_MAX ra số âm, rồi chia 2 cho chỉ số âm — gây ArrayIndexOutOfBoundsException. Fix: dùng low + (high - low) / 2.

4. Áp dụng vào code của bạn

4.1 Chọn int hay long

Nguyên tắc: chọn kiểu nhỏ nhất chứa đủ miền giá trị tối đa có thể xảy ra.

Tình huốngNên dùngLý do
Đếm số phần tử mảng (≤ vài triệu)int2 tỷ đủ rộng
Mili-giây từ epoch (Unix timestamp × 1000)longVượt INT_MAX từ năm 2001
Tiền theo đơn vị nhỏ nhất (đồng, xu)longTổng hoá đơn vài triệu người dễ vượt 2 tỷ
Đếm view, lượt like mạng xã hộilongGangnam Style đã chứng minh
ID database sinh tự độnglongTable lớn dễ vượt 2 tỷ dòng

4.2 Sửa bug binary search — sai rồi đúng

// SAI: (low + high) tran neu ca hai lon
int mid = (low + high) / 2;

// DUNG: (high - low) luon nho, khong tran
int mid = low + (high - low) / 2;

// CACH KHAC (Java): dung unsigned shift right
int mid = (low + high) >>> 1;

Cách >>> 1 (unsigned right shift) hoạt động vì kể cả khi low + high tràn sang bit dấu, dịch phải không dấu vẫn cho kết quả số học đúng cho giá trị dương lớn.

4.3 Phát hiện và phòng tránh tràn số

Java — dùng Math.addExact:

// Math.addExact nem ArithmeticException khi tran thay vi wrap
try {
    long result = Math.addExact(a, b);
} catch (ArithmeticException e) {
    // xu ly truong hop tran so
}

Kiểm tra trước khi cộng (ngôn ngữ không có addExact):

// C: kiem tra truoc khi cong
if (a > 0 && b > INT_MAX - a) {
    // tran duong
}
if (a < 0 && b < INT_MIN - a) {
    // tran am
}

Dùng kiểu rộng hơn:

// Ep sang long truoc khi nhan/cong, tra ve int sau khi kiem tra
long product = (long) a * b;   // ep truoc khi nhan
if (product > Integer.MAX_VALUE) {
    throw new ArithmeticException("overflow");
}
return (int) product;

5. Liên hệ các bài khác

  • Bài 01 — Bit, byte và hệ cơ số: nền tảng cần thiết — công thức 2^n giải thích tại sao INT_MAX = 2^31 − 1.
  • Bài 03 — Số thực IEEE 754: số thực cũng có tràn (infinity) và mất độ chính xác — bài sau; cơ chế khác nhưng cùng gốc rễ "n bit hữu hạn".
  • Bài thao tác bit (module sau): two's complement liên quan trực tiếp tới các phép dịch bit và bitmask — hiểu now giúp bài đó dễ hơn nhiều.

6. Tóm tắt

  • Unsigned n bit: dải 0 đến 2^n − 1. Signed n bit (two's complement): −2^(n−1) đến 2^(n−1) − 1.
  • int 32-bit: INT_MIN = −2.147.483.648, INT_MAX = 2.147.483.647.
  • Two's complement — lấy số âm bằng đảo bit rồi cộng 1. Ưu điểm: một biểu diễn duy nhất của 0, phép cộng dùng chung mạch với unsigned.
  • Tràn số (overflow) không báo lỗi — kết quả wrap-around sang âm (Java, Go) hoặc undefined behavior (C/C++).
  • Python int là bignum, không giới hạn kích thước, không tràn.
  • Bug binary search (low + high) / 2 tràn khi cả hai lớn — fix bằng low + (high - low) / 2.
  • Chọn long khi giá trị có thể vượt 2 tỷ: timestamp mili-giây, tiền, đếm view, ID lớn.

7. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao two's complement được chọn để biểu diễn số âm thay vì dùng 1 bit riêng làm dấu (sign-magnitude)?
Sign-magnitude có hai vấn đề nghiêm trọng: tồn tại hai biểu diễn của số 0 (0000 00001000 0000), khiến so sánh số phức tạp; và phép cộng số âm cần mạch riêng vì logic khác với cộng số dương. Two's complement giải quyết cả hai: chỉ một biểu diễn của 0, và mạch cộng hoạt động đúng cho cả số âm lẫn dương mà không cần phân biệt dấu.
Q2
Máy tính 8-bit dùng two's complement lưu -1. Dãy bit trông thế nào? Suy luận thế nào?
Bắt đầu từ 0000 0001 (+1). Đảo bit ra 1111 1110. Cộng 1 ra 1111 1111 — đó là -1 trong 8-bit two's complement. Kiểm tra: cộng 1111 1111 với 0000 0001 ra 1 0000 0000, bit thứ 9 tràn ra ngoài, phần 8-bit còn lại là 0000 0000 = 0. Đúng: −1 + 1 = 0.
Q3
Đoạn Java sau in gì? Giải thích cơ chế.
In ra -2147483648 (tức Integer.MIN_VALUE). Khi cộng 1 vào Integer.MAX_VALUE (= 2.147.483.647), kết quả vượt giới hạn 32-bit — bit carry tràn ra ngoài, phần còn lại là 1000 0000 0000 0000 0000 0000 0000 0000 trong nhị phân, tức -2.147.483.648 theo two's complement. Java không ném exception — wrap-around âm thầm là hành vi xác định của kiểu int.
Q4
Tại sao dùng `low + (high - low) / 2` thay vì `(low + high) / 2` khi tính chỉ số giữa trong binary search?
Khi lowhigh đều là số dương lớn (ví dụ mảng hàng trăm triệu phần tử), low + high có thể vượt INT_MAX — kết quả tràn sang số âm, chia 2 cho chỉ số âm, truy cập mảng tại chỉ số âm gây crash. Cách low + (high - low) / 2 an toàn vì high - low luôn nhỏ hơn hoặc bằng kích thước mảng — không tràn. Bug này tồn tại trong java.util.Arrays.binarySearch qua nhiều năm trước khi Joshua Bloch phát hiện và công bố năm 2006.
Q5
Vì sao Python không bị tràn số khi cộng hai số nguyên rất lớn, còn Java thì có?
Python dùng kiểu intbignum — kích thước tự động mở rộng theo giá trị, được cấp phát trên heap. Khi số lớn hơn, Python xin thêm bộ nhớ, không giới hạn độ dài bit. Java dùng kiểu nguyên thuỷ int (32-bit) và long (64-bit) có kích thước cố định trong register CPU — phép cộng thực hiện trực tiếp bằng lệnh máy ADD, bit tràn bị bỏ đi. Tốc độ Java nhanh hơn nhiều vì không cần allocate heap; Python an toàn hơn nhưng phép tính số lớn chậm hơn.
Q6
Bạn viết hàm tính tổng doanh thu tháng từ hàng triệu giao dịch, mỗi giao dịch có thể tới 50 triệu đồng. Nên dùng `int` hay `long`? Vì sao?
Dùng long. Nếu có 10 triệu giao dịch mỗi giao dịch 50 triệu đồng, tổng là 500 nghìn tỷ đồng — vượt xa INT_MAX (khoảng 2,1 tỷ). Dùng int để tích lũy tổng sẽ tràn nhiều lần, cho kết quả sai hoàn toàn mà không báo lỗi. long 64-bit chứa được đến hơn 9,2 × 10^18 — đủ cho mọi tình huống tài chính thực tế. Với tiền tệ, nguyên tắc an toàn: luôn dùng long (hoặc BigDecimal nếu cần phân số) thay vì int.

Bài tiếp theo: Số thực IEEE 754

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên