Xử Lý Số Nguyên Lớn
1. Bản chất vấn đề & trực giác thuật toán (the core problem & intuition)
Trong ngôn ngữ lập trình C++, kiểu dữ liệu số nguyên có kích thước lớn nhất được hỗ trợ phần cứng là unsigned long long (64-bit, tối đa xấp xỉ $1.84 \times 10^{19}$) hoặc phần mở rộng GCC __int128 (128-bit, tối đa xấp xỉ $3.4 \times 10^{38}$).
Tuy nhiên, trong các bài toán thực tế và đề thi học sinh giỏi (như tính $100!$, tính số Fibonacci thứ $1000$, hoặc tính $2^{10000}$ mà không lấy dư modulo), kết quả có thể dài hàng nghìn đến hàng chục nghìn chữ số. Vì C++ không có sẵn kiểu dữ liệu BigInteger như Python hay Java, lập trình viên thi đấu C++ bắt buộc phải tự mô phỏng các phép tính số học đặt tính rồi tính như toán tiểu học trên mảng ký tự (string) hoặc mảng số nguyên (vector<int>).
Big integer hay modular arithmetic: Chọn vũ khí nào?
| Đề bài yêu cầu | Quy mô kết quả | Vũ khí tối ưu | Kỹ thuật cốt lõi |
|---|---|---|---|
| Tính $A^B \pmod M$ ($B \le 10^{18}$) | $\le M$ | Modulo | Lũy thừa nhị phân $\mathcal{O}(\log B)$ |
| Tính $\frac{A}{B} \pmod M$ | $\le M$ | Modulo | Nghịch đảo Modulo $A \times B^{-1}$ |
| Tính $F_{10^6} \pmod M$ | $\le M$ | Modulo | Nhân ma trận nhị phân $\mathcal{O}(\log N)$ |
| Tính chính xác $2^{10000}$ | $\approx 3011$ chữ số | Big Integer | Lũy thừa nhị phân trên BigInt |
| Tính chính xác $1000!$ | $2568$ chữ số | Big Integer | Nhân BigInt $\times$ int liên tiếp |
| Tính chính xác số Fibonacci $F_{1000}$ | $209$ chữ số | Big Integer | Cộng BigInt + BigInt quy hoạch động |
| Số có $10^5$ chữ số nhưng chỉ cần $\% M$ | $\le M$ | Modulo | Vòng lặp Horner: cur = (cur * 10 + d) % M |
2. Mô phỏng từng bước (visual step-by-step simulation)
Ví dụ 1: Mô phỏng phép cộng số lớn $A = 9876$ và $B = 543$
- Quy tắc: Đảo ngược chuỗi để chữ số hàng đơn vị nằm ở chỉ số
0. - $A' = [6, 7, 8, 9]$, $B' = [3, 4, 5]$.
| Vị trí hàng ($i$) | Cặp chữ số $(A'_i, B'_i)$ | Biến nhớ vào | Phép tính tổng | Ghi nhận & Nhớ mới |
|---|---|---|---|---|
| 0 (Hàng đơn vị) | $(6, 3)$ | $0$ | $6 + 3 + 0 = 9$ | Ghi $9$, nhớ $0$ |
| 1 (Hàng chục) | $(7, 4)$ | $0$ | $7 + 4 + 0 = 11$ | Ghi $1$, nhớ $1$ |
| 2 (Hàng trăm) | $(8, 5)$ | $1$ | $8 + 5 + 1 = 14$ | Ghi $4$, nhớ $1$ |
| 3 (Hàng nghìn) | $(9, 0)$ | $1$ | $9 + 0 + 1 = 10$ | Ghi $0$, nhớ $1$ |
| Dư cuối | — | $1$ | $\text{carry} = 1$ | Ghi $1$, nhớ $0$ |
- Kết quả đảo ngược: $[9, 1, 4, 0, 1] \implies \mathbf{10419}$.
Ví dụ 2: Mô phỏng phép nhân số lớn $A = 48$ với số nhỏ $b = 7$
- $A' = [8, 4]$.
- Bước 0 ($i = 0$): $8 \times 7 + 0 = 56 \implies$ Ghi $6$,
carry$= 5$. - Bước 1 ($i = 1$): $4 \times 7 + 5 = 33 \implies$ Ghi $3$,
carry$= 3$. - Dư cuối: Ghi
carry$= 3$. - Kết quả đảo ngược: $[6, 3, 3] \implies \mathbf{336}$.
3. Lý thuyết cốt lõi & bất biến thuật toán (core invariants)
3.1. Mô hình biểu diễn số lớn & little-endian
- Biểu diễn Little-Endian: Lưu các chữ số theo thứ tự từ hàng thấp đến hàng cao (chữ số hàng đơn vị nằm ở chỉ số
0). - Ưu điểm cốt lõi: Hàng đơn vị nằm ở
index = 0, nên khi cộng, trừ hoặc nhân ta có thể xử lý trực tiếp từ hàng thấp lên hàng cao và truyền biến nhớcarry/borrowsang phần tử kế tiếp ($a[0] \to a[1] \to a[2] \dots$). Ngoài ra, chữ số mới ở cuối có thể được thêm bằngpush_back()với chi phí amortized $\mathcal{O}(1)$. - Biểu diễn Base 10 vs Base $10^9$:
- Base 10 (
string/vector<int>): Mỗi phần tử lưu 1 chữ số thập phân ($0 \dots 9$). - Base $10^9$ (
vector<int>/vector<long long>): Nhóm các cụm 9 chữ số từ phải sang trái. - Cấu trúc dữ liệu: Mỗi chunk lưu kiểu
int($0 \dots 999,999,999$); phép nhân giữa 2 chunks lưu kiểulong long(vì $(10^9 - 1) \times (10^9 - 1) \approx 10^{18} < 2^{63}-1$). - Ví dụ: Số $1234567890123456789$ được tách thành: $$\text{chunks} = [23456789, 123456789, 1]$$ $$\text{Giá trị} = 23456789 + 123456789 \times 10^9 + 1 \times (10^9)^2$$
3.2. Bảng tổng hợp các phép toán số nguyên lớn ($\mathcal{O}(L^2)$)
| Phép toán | Bản chất thuật toán | Độ phức tạp thời gian | Lưu ý quan trọng |
|---|---|---|---|
| So sánh ($A, B$) | So sánh độ dài trước, sau đó so sánh từ điển | $\mathcal{O}(\max(L_A, L_B))$ | Xóa sạch số 0 ở đầu trước khi so sánh |
| Cộng ($A + B$) | Mô phỏng cộng từng hàng kèm biến nhớ carry |
$\mathcal{O}(\max(L_A, L_B))$ | Xử lý carry còn dư sau khi hết chữ số |
| Trừ ($A - B$) | Mô phỏng trừ có mượn borrow ($A \ge B$) |
$\mathcal{O}(L_A)$ | Xóa sạch số $0$ vô nghĩa ở đầu (leading zeros) |
| Nhân nhỏ ($A \times b$) | Nhân từng chữ số của $A$ với số nguyên $b$ | $\mathcal{O}(L_A)$ | Biến carry có thể vượt quá $10$, cần kiểu long long |
| Nhân lớn ($A \times B$) | Tích lũy $C[i + j] += A[i] \times B[j]$ rồi normalize | $\mathcal{O}(L_A \times L_B)$ | Khởi tạo mảng $L_A + L_B$ (áp dụng cho $L \le 5000$) |
| Chia nhỏ ($A / b, A \% b$) | Chia từ hàng cao nhất xuống hàng đơn vị | $\mathcal{O}(L_A)$ | Biến tích lũy cur = cur * 10 + A[i] |
3.3. Thuật toán chia số lớn cho số nhỏ & bất biến horner
Khi chia số lớn $A$ cho số nguyên $b$ ($1 \le b \le 10^9$), ta duyệt từ chữ số hàng cao nhất xuống hàng đơn vị:
string divSmall(string a, long long b) {
string res = "";
long long cur = 0;
for (char c : a) {
cur = cur * 10 + (c - '0');
int digit = cur / b;
res.push_back(char('0' + digit));
cur %= b; // cur luôn là số dư hiện tại
}
// Xóa số 0 vô nghĩa ở đầu
int pos = 0;
while (pos + 1 < (int)res.size() && res[pos] == '0') pos++;
return res.substr(pos);
}
Ghi chú:
BẤT BIẾN TOÁN HỌC CỦA PHÉP CHIA TỪNG BƯỚC:
Vì trước mỗi bước lặp ta luôn duy trì số dư $0 \le cur < b$, nên sau khi nhận thêm một chữ số mới $cur = cur \times 10 + \text{digit}$, giá trị luôn thỏa mãn $cur < 10b$. Do đó thương tại mỗi bước
digit = cur / bchắc chắn luôn nằm trong khoảng $[0, 9]$ (là một chữ số thập phân hợp lệ duy nhất).
3.4. Tối ưu hóa base $10^9$ (chunking optimization)
- Thay vì thực hiện phép nhân trên từng chữ số đơn lẻ (Base 10 có $L$ chữ số), ta nén số lớn thành $\frac{L}{9}$ chunks trong Base $10^9$.
- Đánh giá hiệu năng: Số lượng cặp chunk cần nhân giảm xấp xỉ $\left(\frac{L}{9}\right) \times \left(\frac{L}{9}\right) = \frac{L^2}{81}$ (giảm khoảng 81 lần về số lượng phép nhân chunk). Tốc độ thực tế tăng vọt từ hàng chục lần giúp vượt qua các bài toán $N \le 10^5$.
4. Các bẫy lỗi lập trình kinh điển (bug traps)
-
Quên xóa số 0 vô nghĩa ở đầu (Leading Zeros): * Sau phép trừ (ví dụ $1000 - 999 = 0001$), nếu không xóa số 0 thì chuỗi sẽ in ra
0001. * Cách xử lý:while (res.size() > 1 && res.back() == '0') res.pop_back();. -
Không xét trường hợp số $0$: * Phép nhân $A \times 0$ phải trả về
"0", không được trả về rỗng"". - Biến
carrytrong phép nhân số nhỏ có thể rất lớn: * Trong phép nhân $A \times b$ với $b = 10^9$,carrysau mỗi bước có thể lên tới $10^9$, do đó kiểu dữ liệu củacarrybắt buộc phải làlong long.
5. Mẫu cài đặt chuẩn thi đấu (competitive templates)
# include <bits/stdc++.h>
using namespace std;
// Hàm xóa số 0 vô nghĩa ở đầu chuỗi đảo ngược
void removeLeadingZeros(string &s) {
while (s.size() > 1 && s.back() == '0') {
s.pop_back();
}
}
// Phép cộng 2 số nguyên lớn không âm (A + B)
string addBig(string a, string b) {
reverse(a.begin(), a.end());
reverse(b.begin(), b.end());
string res = "";
int carry = 0;
int n = max(a.size(), b.size());
for (int i = 0; i < n || carry; ++i) {
int sum = carry;
if (i < (int)a.size()) sum += a[i] - '0';
if (i < (int)b.size()) sum += b[i] - '0';
res.push_back((sum % 10) + '0');
carry = sum / 10;
}
reverse(res.begin(), res.end());
return res;
}
// Phép trừ 2 số nguyên lớn không âm (A - B với A >= B)
string subBig(string a, string b) {
reverse(a.begin(), a.end());
reverse(b.begin(), b.end());
string res = "";
int borrow = 0;
for (int i = 0; i < (int)a.size(); ++i) {
int diff = (a[i] - '0') - borrow;
if (i < (int)b.size()) diff -= (b[i] - '0');
if (diff < 0) {
diff += 10;
borrow = 1;
} else {
borrow = 0;
}
res.push_back(diff + '0');
}
removeLeadingZeros(res);
reverse(res.begin(), res.end());
return res;
}
// Phép nhân 2 số nguyên lớn chuẩn mực và an toàn (A * B)
string mulBig(string a, string b) {
if (a == "0" || b == "0") return "0";
reverse(a.begin(), a.end());
reverse(b.begin(), b.end());
int n = a.size(), m = b.size();
vector<int> c(n + m, 0);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
c[i + j] += (a[i] - '0') * (b[j] - '0');
}
}
// Normalize: Đẩy biến nhớ carry sang các ô kế tiếp
for (int i = 0; i + 1 < n + m; ++i) {
c[i + 1] += c[i] / 10;
c[i] %= 10;
}
while (c.size() > 1 && c.back() == 0) {
c.pop_back();
}
string res = "";
for (int i = (int)c.size() - 1; i >= 0; --i) {
res.push_back(c[i] + '0');
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string a, b;
if (!(cin >> a >> b)) return 0;
cout << "A + B = " << addBig(a, b) << "\n";
cout << "A * B = " << mulBig(a, b) << "\n";
return 0;
}