Wiki › Thuật toán › Toán học › Nguyên lý Dirichlet và tính tuần hoàn

Nguyên lý Dirichlet và tính tuần hoàn

huunguyen huunguyen Updated Tháng 9. 29, 2026 Đọc 19 phút 5 lượt xem

Nguyên lý Dirichlet (nguyên lý chuồng bồ câu, pigeonhole principle) phát biểu một điều hiển nhiên: nhét n+1 con bồ câu vào n chuồng thì có chuồng chứa ít nhất 2 con. Trong lập trình thi đấu, nguyên lý này là công cụ chứng minh sự tồn tại mạnh nhất: nó cho biết một đáp án chắc chắn có (nên chỉ cần tìm, không cần xét trường hợp "vô nghiệm"), cho biết mọi dãy truy hồi trên tập trạng thái hữu hạn đều tuần hoàn (nên xK với K=1018 tính được bằng O(số trạng thái) thay vì O(K)), và cho phép cắt vét cạn: khi dữ liệu đủ lớn thì đáp án đã chắc chắn, chỉ còn trường hợp dữ liệu nhỏ mới phải duyệt.

Ý tưởng / Trực giác

Dạng cơ bản và dạng tổng quát
  • Dạng cơ bản. Đặt n+1 vật vào n hộp thì có một hộp chứa ít nhất 2 vật.
  • Dạng tổng quát. Đặt N vật vào k hộp thì có một hộp chứa ít nhất ⌈N/k⌉ vật.

Chứng minh dạng tổng quát bằng phản chứng: nếu mọi hộp chứa không quá ⌈N/k⌉−1 vật thì tổng số vật không quá k(⌈N/k⌉−1)<k·(N/k)=N, mâu thuẫn.

Ví dụ: trong N số nguyên bất kỳ, xếp mỗi số vào "hộp" là số dư của nó khi chia cho k. Có một số dư được ít nhất ⌈N/k⌉ số dùng chung, và hiệu hai số bất kỳ trong nhóm đó đều chia hết cho k.

Cái khó khi áp dụng không nằm ở nguyên lý mà ở việc chọn đúng "bồ câu" và "chuồng". Ba mô hình dưới đây phủ gần hết các bài thi.

Mô hình 1: tổng tiền tố modulo n

Bài toán kinh điển. Cho n số nguyên a1,…,an. Chứng minh luôn có một đoạn con liên tiếp khác rỗng có tổng chia hết cho n, và in ra một đoạn như vậy.

Đặt P0=0 và Pj=a1+⋯+aj. Tổng đoạn al+1+⋯+ar bằng Pr−Pl, nên nó chia hết cho n khi và chỉ khi Pl≡Pr(modn).

  • Bồ câu: n+1 tổng tiền tố P0,P1,…,Pn.
  • Chuồng: n số dư 0,1,…,n−1.

Vậy có hai chỉ số l<r với Pl≡Pr, và đoạn [l+1,r] là đáp án. Chú ý vai trò của P0=0: không có nó thì chỉ có n bồ câu cho n chuồng và lập luận sụp đổ.

Lập luận này là kiến tạo: duyệt j từ 0 lên, ghi lại lần đầu gặp mỗi số dư; gặp lại một số dư đã có là xong. Vì chỉ có n số dư nên vòng lặp dừng sau không quá n bước. Đây là khuôn mẫu chung của các bài "luôn tồn tại đáp án, in một đáp án bất kỳ": chứng minh tồn tại bằng Dirichlet, rồi biến chứng minh thành thuật toán.

Kết quả tổng quát hơn: với modulo m bất kỳ và ít nhất m số, luôn có đoạn con liên tiếp (và do đó cũng là một dãy con) có tổng chia hết cho m.

Mô hình 2: dãy trên tập trạng thái hữu hạn thì tuần hoàn

Xét dãy xi+1=f(xi), trong đó mỗi xi thuộc một tập S gồm |S| trạng thái. Trong |S|+1 giá trị x0,x1,…,x|S| phải có hai giá trị trùng nhau. Xét lần đầu tiên dãy gặp lại một giá trị đã xuất hiện: đó là tại chỉ số μ+λ, với xμ+λ=xμ, trong đó μ nhỏ nhất và λ≥1 cũng nhỏ nhất (các giá trị x0,…,xμ+λ−1 đôi một khác nhau). Vì xi+1 chỉ phụ thuộc vào xi, từ đó trở đi dãy lặp lại y hệt: xi+λ=xivới mọi i≥μ.

Hình dạng dãy giống chữ ρ (rho): một "đuôi" dài μ rồi một "vòng" dài λ, và μ+λ≤|S| (vì μ+λ giá trị đầu đôi một khác nhau). Đó chính là một đường đi trong đồ thị hàm.

Điều kiện then chốt: trạng thái phải chứa đủ thông tin để xác định bước tiếp theo. Với dãy Fibonacci theo modulo m, Fi+1=Fi+Fi−1 phụ thuộc hai số trước, nên trạng thái là cặp (Fimodm, Fi+1modm). Có m2 cặp, vậy dãy Fmodm tuần hoàn. Hơn nữa phép chuyển (a,b)↦(b,a+b) đảo ngược được ((b,c)↦(c−b,b)), nên không có đuôi: dãy tuần hoàn thuần túy từ F0. Chu kỳ này gọi là chu kỳ Pisano π(m), và người ta chứng minh được π(m)≤6m.

Tổng quát: mọi dãy truy hồi tuyến tính xi=c1xi−1+⋯+cdxi−d(modm) đều tuần hoàn (có thể có đuôi nếu cd không khả nghịch modulo m), vì trạng thái d số liên tiếp chỉ có md khả năng.

Mô hình 3: "nếu n đủ lớn thì luôn có đáp án"

Nhiều bài có ràng buộc "lệch": n tới 106 nhưng m chỉ tới 103. Đó thường là dấu hiệu của Dirichlet. Ví dụ: hỏi có dãy con (không cần liên tiếp) nào có tổng chia hết cho m không.

  • Nếu n≥m: theo mô hình 1 luôn có đoạn liên tiếp như vậy, in YES ngay.
  • Nếu n<m≤1000: quy hoạch động cái túi theo số dư, chỉ tốn O(n·m), không quá 106 phép tính.

Quy hoạch động O(n·m) trực tiếp với n=106 là 109 phép tính, quá chậm. Nhận xét Dirichlet cắt hẳn phần khó. Các dạng cắt tương tự hay gặp:

  • Xâu chỉ gồm chữ cái thường (a..z) mà có độ dài >26 thì chắc chắn có ký tự lặp lại.
  • Dãy dài n≥2m gồm các số trong [0,2m) (tức có n+1>2m tiền tố XOR, tính cả P0=0) chắc chắn có đoạn con XOR bằng 0.
  • Có hơn k số thì chắc chắn có hai số cùng số dư khi chia cho k, nên đáp án của bài "tìm cặp có hiệu chia hết cho k" luôn có, chỉ cần xét k+1 số đầu.

Ví dụ chạy tay

Đoạn con có tổng chia hết cho n

a=[3,1,4,1,5], n=5. Tính tổng tiền tố và số dư, ghi lại lần đầu mỗi số dư xuất hiện:

 j        :   0    1    2    3    4    5
 a_j      :   -    3    1    4    1    5
 P_j      :   0    3    4    8    9   14
 P_j mod 5:   0    3    4    3    4    4
                   ^         ^
                   |_________|  cùng số dư 3 (j = 1 và j = 3)

 firstPos:  j=0: dư 0 -> ghi 0
            j=1: dư 3 -> ghi 1
            j=2: dư 4 -> ghi 2
            j=3: dư 3 -> ĐÃ CÓ tại 1  => dừng

Đoạn tìm được là [l+1,r]=[2,3]: a2+a3=1+4=5, chia hết cho 5. Để ý đoạn này không bắt đầu từ a1; không có tiền tố nào (3,4,8,9,14) chia hết cho 5, nên phải có P0 và phải so sánh mọi cặp số dư, không chỉ tìm Pj≡0.

Chu kỳ Pisano modulo 3

Trạng thái là cặp (Fi,Fi+1)mod3. Dừng khi cặp (0,1) xuất hiện lại:

 i        :  0  1  2  3  4  5  6  7  8  9
 F_i mod 3:  0  1  1  2  0  2  2  1  0  1
             \__/                    \__/
            (0,1)                   (0,1)  lặp lại tại i = 8

 => pi(3) = 8 ; ví dụ F_100 mod 3 = F_(100 mod 8) mod 3 = F_4 mod 3 = 0

Chú ý: giá trị 1 xuất hiện ở i=1 rồi lại ở i=2 nhưng dãy chưa lặp, vì cặp (1,1)≠(0,1). Chỉ một số thì không đủ làm trạng thái. Kiểm tra: F100=354224848179261915075 có tổng chữ số 93, chia hết cho 3.

Dãy có đuôi: xi+1=(xi2+1)mod10, x0=3
 i  :  0    1    2    3    4    5    6    7
 x_i:  3 -> 0 -> 1 -> 2 -> 5 -> 6 -> 7 -> 0   (x_7 = x_1)

          3 -> 0 -> 1 -> 2
               ^         |
               |         v
               7 <- 6 <- 5

 đuôi mu = 1 (chỉ có số 3), vòng lambda = 6 (0 1 2 5 6 7)

Để tính x100: vì 100≥μ nên x100=xμ+(100−μ)modλ=x1+99mod6=x1+3=x4=5. Nếu quên trừ μ mà lấy x100mod6=x4 thì tình cờ đúng ở đây, nhưng với K=102 ta được x102mod6=x0=3, trong khi đáp án đúng là x1+101mod6=x6=7 (số 3 không bao giờ xuất hiện lại).

Cài đặt

Tìm đoạn con có tổng chia hết cho n

Đọc n và n số (có thể âm), in một cặp l,r với al+⋯+ar chia hết cho n.

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    scanf("%d", &n);
    vector<long long> a(n + 1);
    for (int i = 1; i <= n; i++) scanf("%lld", &a[i]);

    // firstPos[r] = smallest prefix index j with P_j mod n == r, or -1
    vector<int> firstPos(n, -1);
    firstPos[0] = 0;                        // P_0 = 0
    long long p = 0;
    for (int j = 1; j <= n; j++) {
        p = ((p + a[j]) % n + n) % n;       // keep remainder in [0, n)
        if (firstPos[p] != -1) {            // same remainder seen before
            int l = firstPos[p] + 1, r = j;  // a[l..r] has sum divisible by n
            printf("%d %d\n", l, r);
            return 0;
        }
        firstPos[p] = j;
    }
    // unreachable: n+1 prefixes, only n remainders
    return 0;
}

Bản Python (toán tử % của Python luôn trả về số không âm khi modulo dương):

import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = list(map(int, data[1:1 + n]))
    first_pos = [-1] * n      # first prefix index with this remainder
    first_pos[0] = 0          # P_0 = 0
    p = 0
    for j in range(1, n + 1):
        p = (p + a[j - 1]) % n    # Python % is already non-negative
        if first_pos[p] != -1:
            print(first_pos[p] + 1, j)
            return
        first_pos[p] = j

main()
Cắt vét cạn: dãy con có tổng chia hết cho m

n≤106, m≤103. Nếu n≥m thì đáp án chắc chắn là YES; ngược lại quy hoạch động theo số dư.

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    scanf("%d %d", &n, &m);
    vector<int> a(n);
    for (auto &v : a) { scanf("%d", &v); v %= m; }

    // Pigeonhole: n >= m numbers always contain a block with sum % m == 0
    if (n >= m) { puts("YES"); return 0; }

    // Now n < m <= 1000: subset-sum DP over remainders, O(n*m)
    vector<char> can(m, 0), nxt;
    for (int v : a) {
        nxt = can;
        nxt[v] = 1;                          // subset {v} alone
        for (int r = 0; r < m; r++)
            if (can[r]) nxt[(r + v) % m] = 1;
        can.swap(nxt);
        if (can[0]) { puts("YES"); return 0; }
    }
    puts("NO");
}
Chu kỳ Pisano và Fnmodm với n khổng lồ
#include <bits/stdc++.h>
using namespace std;

// Pisano period of Fibonacci mod m (m >= 2). Always <= 6m.
long long pisano(long long m) {
    long long a = 0, b = 1;                 // (F_0, F_1)
    for (long long i = 1; ; i++) {
        long long c = (a + b) % m;
        a = b; b = c;                       // now (F_i, F_{i+1})
        if (a == 0 && b == 1) return i;     // back to the start pair
    }
}

// F_n mod m for huge n: reduce n by the period, then iterate
long long fibMod(unsigned long long n, long long m) {
    if (m == 1) return 0;
    long long p = pisano(m);
    n %= p;
    long long a = 0, b = 1;
    for (unsigned long long i = 0; i < n; i++) {
        long long c = (a + b) % m;
        a = b; b = c;
    }
    return a;
}

int main() {
    for (int m : {2, 3, 5, 7, 10, 1000}) printf("pi(%d) = %lld\n", m, pisano(m));
    printf("F_(10^18) mod 1000 = %lld\n", fibMod(1000000000000000000ULL, 1000));
}

Kết quả: π(2)=3, π(3)=8, π(5)=20, π(7)=16, π(10)=60, π(1000)=1500 và F1018mod1000=875. Chỉ cần so sánh với cặp đầu (0,1) vì dãy Fibonacci modulo m tuần hoàn thuần túy (không có đuôi).

Tìm đuôi μ, vòng λ rồi tua nhanh tới bước K

Khuôn tổng quát cho mọi dãy xi+1=f(xi) trên M trạng thái đánh số 0..M−1: lưu thời điểm gặp đầu tiên của mỗi trạng thái.

#include <bits/stdc++.h>
using namespace std;

int main() {
    // x_{i+1} = f(x_i) on states 0..M-1; find x_K for huge K
    long long M = 10, x0 = 3, K;
    auto f = [&](long long x) { return (x * x + 1) % M; };
    scanf("%lld", &K);

    vector<long long> seen(M, -1);          // seen[s] = first step index of state s
    vector<long long> seq;                  // seq[i] = x_i
    long long x = x0;
    for (long long i = 0; ; i++) {
        if (i == K) { printf("%lld\n", x); return 0; }  // reached K before any repeat
        if (seen[x] != -1) {                // repeat: x_i == x_mu
            long long mu = seen[x], lam = i - mu;
            printf("mu=%lld lambda=%lld -> x_K = %lld\n",
                   mu, lam, seq[mu + (K - mu) % lam]);
            return 0;
        }
        seen[x] = i;
        seq.push_back(x);
        x = f(x);
    }
}

Với K=100 chương trình in mu=1 lambda=6 -> x_K = 5, khớp ví dụ chạy tay. Khi trạng thái là một bộ nhiều thành phần (ô và hướng của robot, cặp số Fibonacci...), mã hóa bộ đó thành một số nguyên để dùng mảng seen, hoặc dùng map. Nếu không đủ bộ nhớ cho seen, dùng thuật toán rùa và thỏ của Floyd trong trang đồ thị hàm, chỉ tốn O(1) bộ nhớ.

Độ phức tạp

Bài toán Thời gian Bộ nhớ Lý do
Đoạn con tổng chia hết cho n O(n) O(n) Mỗi bước tăng j ghi thêm một số dư mới; chỉ có n số dư nên dừng sau ≤n bước. Mảng firstPos có n ô.
Dãy con tổng chia hết cho m O(n+min(n,m)·m) O(m) Đọc dữ liệu O(n); quy hoạch động chỉ chạy khi n<m, nên tốn O(m2) trong trường hợp xấu nhất.
Chu kỳ Pisano π(m) O(π(m))=O(m) O(1) Vòng lặp chạy đúng π(m)≤6m lần, chỉ giữ hai số.
Tua nhanh dãy xi+1=f(xi) O(M·Tf) O(M) Theo Dirichlet, trong M+1 bước đầu phải có trạng thái lặp; Tf là thời gian tính f một lần. Sau đó xK lấy ra trong O(1) bất kể K lớn đến đâu.

So với cách ngây thơ: mô phỏng K=1018 bước là bất khả thi, còn tua nhanh chỉ tốn cỡ số trạng thái. Nhưng nếu số trạng thái lớn (ví dụ Fibonacci modulo m=109+7 có π(m) có thể lên tới cỡ 2·109) thì chu kỳ không giúp được; khi đó dùng lũy thừa ma trận O(logK).

⚠️ Lỗi thường gặp

  • Quên P0=0. Chỉ xét P1,…,Pn thì còn n bồ câu cho n chuồng, không còn đảm bảo có hai số trùng nhau, và bỏ sót các đoạn bắt đầu từ vị trí 1. Luôn đặt firstPos[0] = 0 trước vòng lặp.
  • Số dư âm trong C++. Với ai<0, p % n có thể âm và truy cập firstPos[-3] gây lỗi bộ nhớ hoặc kết quả sai. Chuẩn hóa bằng ((p % n) + n) % n. Python không bị lỗi này.
  • Chỉ tìm tiền tố chia hết. Đi tìm Pj≡0(modn) là chưa đủ: ở ví dụ chạy tay không tiền tố nào chia hết cho 5, đáp án là đoạn [2,3] nằm giữa dãy. Phải so khớp hai tiền tố cùng số dư bất kỳ.
  • Trạng thái thiếu thông tin. Thấy Fimodm lặp lại một giá trị rồi kết luận đã hết chu kỳ là sai (dãy modulo 3: 0,1,1,… lặp số 1 ngay nhưng chu kỳ là 8). Trạng thái phải là cặp (Fi,Fi+1). Tương tự, robot di chuyển trên lưới cần trạng thái là (ô, hướng), không chỉ ô.
  • Quên phần đuôi μ. Dãy có phép chuyển đảo ngược được (như Fibonacci) thì tuần hoàn ngay từ đầu, nhưng dãy tổng quát có thể có đuôi, nên công thức đúng là xK=xμ+(K−μ)modλ khi K≥μ, và xK lấy trực tiếp khi K<μ. Dùng xKmodλ sẽ sai như ví dụ K=102 ở trên.
  • Tràn số khi tính modulo. Với m tới 106, tích hai số dư lên tới 1012, vượt int. Dùng long long cho mọi phép nhân modulo và cho chỉ số K tới 1018.
  • Tin rằng chu kỳ luôn nhỏ. Số trạng thái của Fibonacci modulo m là m2, nhưng π(m)≤6m nhờ tính chất riêng của Fibonacci. Với truy hồi bậc d khác, chu kỳ có thể cỡ md. Ước lượng số trạng thái trước khi quyết định tua nhanh hay dùng lũy thừa ma trận.
  • Ngưỡng cắt sai một đơn vị. Kết luận "luôn có đáp án" phải được chứng minh đúng ngưỡng. Ở bài dãy con chia hết cho m, ngưỡng là n≥m (có m+1 tiền tố). Chọn ngưỡng n>m vẫn đúng nhưng phí; chọn n≥m−1 thì sai (ví dụ n=1,m=2,a=[1] đáp án là NO).

Biến thể / Mở rộng

  • Đếm thay vì tìm. Muốn đếm số đoạn con có tổng chia hết cho n, đếm số cặp tiền tố cùng số dư: nếu số dư r xuất hiện cr lần trong P0,…,Pn thì đáp án là ∑r(cr2). Muốn tìm đoạn dài nhất, lưu lần xuất hiện đầu tiên của mỗi số dư và lấy max(j−firstPos[Pjmodn]).
  • XOR thay cho tổng. Đoạn con có XOR bằng 0 tương ứng với hai tiền tố XOR bằng nhau. Các số trong [0,2m) có 2m giá trị tiền tố khả dĩ, nên dãy dài n≥2m luôn có đoạn con XOR bằng 0.
  • Hai dãy tuần hoàn chạy song song. Nếu dãy A có chu kỳ p và dãy B có chu kỳ q thì cặp (Ai,Bi) có chu kỳ lcm(p,q). Mô phỏng một chu kỳ chung rồi nhân lên.
  • Tính chất của chu kỳ Pisano. π(m) tính được từ phân tích m=∏piei: π(m)=lcmiπ(piei) (theo định lý số dư Trung Hoa), và π(10k)=15·10k−1 với k≥3.
  • Liên hệ đồ thị hàm. Mọi dãy xi+1=f(xi) là đường đi trên đồ thị hàm. Khi cần trả lời nhiều truy vấn "đi K bước từ đỉnh v" với nhiều v khác nhau, dùng binary lifting ở trang đó thay vì tìm chu trình riêng cho từng truy vấn.
  • Khi chu kỳ quá dài. Dãy truy hồi tuyến tính với modulo lớn thì dùng lũy thừa ma trận hoặc nhân ma trận trong quy hoạch động; nếu chỉ biết vài chục số hạng đầu của một dãy tuyến tính, Berlekamp-Massey tìm lại công thức truy hồi.

Bài tập luyện

Luyện kỹ thuật
  • Dãy con chia hết cho 7 (div7) (Trung cấp) đoạn con liên tiếp dài nhất có tổng chia hết cho 7: chỉ có 7 số dư tiền tố, lưu lần xuất hiện đầu tiên của mỗi số dư như mô hình 1.
  • Oẳn tù tì tuần hoàn (rpsgame) (Trung cấp) hai dãy tuần hoàn chu kỳ m và k chạy song song nên cả trận có chu kỳ lcm(m,k)≤106, trong khi n tới 2·109: đếm trên một chu kỳ rồi nhân lên.
  • Dãy không lặp XOR (woolseq) (Trung cấp) dãy không có đoạn XOR bằng 0 khi và chỉ khi các tiền tố XOR đôi một khác nhau; Dirichlet cho ngay đáp án 0 khi n≥2m, còn lại là một tích đếm.
  • Du Thuyền Xa Xỉ (cruise) (Trung cấp) sau mỗi lượt M lệnh, cảng mới chỉ phụ thuộc cảng cũ; chỉ có N≤1000 cảng nên dãy cảng vào vòng lặp sau không quá N lượt, tìm μ,λ rồi tua nhanh K≤109 lượt.
  • Tổng chia hết cho m (modsum) (Kỳ cựu) bài cắt vét cạn điển hình: n≥m thì luôn YES, ngược lại n<m≤1000 nên quy hoạch động theo số dư chỉ tốn O(m2).
Đề VOI
  • Bản vanxơ Fibonacci (voi12_fibval) (Nâng cao) VOI 2012: nốt nhạc ở vị trí i chỉ phụ thuộc Fimod7. Hãy tính chu kỳ Pisano modulo 7 và nghĩ xem với đoạn đủ dài thì đáp án còn phụ thuộc vào vị trí bắt đầu hay không.
  • Dãy Fibonacci (voi17_fibseq) (Nâng cao) VOI 2017: để ý ràng buộc k≤n và nghĩ xem mô hình tổng tiền tố modulo k nói gì về việc có luôn tồn tại đáp án hay không.
  • Robot (voi18_robot) (Nâng cao) VOI 2018: chọn trạng thái đủ thông tin (ô và hướng) trong một vùng hữu hạn quanh mê cung; số trạng thái hữu hạn nên robot phải rơi vào chu trình, khi đó rút gọn thời gian S theo độ dài chu trình.
Trang này có hữu ích không? Đăng nhập để trả lời

Bình luận

Không có bình luận tại thời điểm này.

gnatmake 12.2.0 a68g 3.1.2 nasm 2.16.1 as_x64 2.47 awk 1.3.4 gcc 16.2.0 dotnet 10.0.402 g++ 16.2.0 g++-themis 16.2.0 g++17 16.2.0 g++20 16.2.0 g++23 16.2.0 clang++ 22.1.8 dmd 2.113.0 dart 3.13.6 gforth 0.7.3 gfortran 15.3.0 go 1.27.3 groovyc 5.1.3 javac 26.0.2.1 node 26.12.1 julia 1.13.2 kotlinc 2.4.21 lean 4.35.0 sbcl 2.2.9 lua 5.5.1 nim 2.2.12 fpc 3.2.2 fpc-themis 3.2.2 perl 5.42.3 php 8.5.12 pike 8.0 swipl 9.0.4 pypy3 7.3.23 python3 3.15.1 racket 8.7 ruby 4.0.8 rustc 1.99.1 csc 5.3.0 ctoj-scratch 0.0.1 sed 4.9 swiftc 6.4.1 tclsh 8.6 bun 1.4.4 deno 2.9.8 v 0.5.2 zig 0.17.0