Wiki Thuật toán Quy hoạch động (DP) DP nhân ma trận (Matrix Exponentiation)

DP nhân ma trận (Matrix Exponentiation)

huunguyen huunguyen Updated Tháng sáu 1, 2026

Lũy thừa ma trận (matrix exponentiation) là kỹ thuật biểu diễn một công thức truy hồi tuyến tính dưới dạng phép nhân ma trận, rồi dùng lũy thừa nhanh (binary exponentiation) để tính số hạng thứ N trong thời gian O(k3logN) thay vì O(N) như khi lặp tuần tự. Nhờ đó ta tính được những bài mà N lên tới 1018 — quá lớn để duyệt từng bước, nhưng log2(1018)60 nên chỉ cần khoảng 60 phép nhân ma trận.

Kỹ thuật này áp dụng cho mọi DP/truy hồi mà trạng thái tiếp theo là tổ hợp tuyến tính của một số cố định trạng thái trước đó: dãy Fibonacci, đếm số đường đi đúng N bước trên đồ thị, đếm cách lát/xếp với chiều dài khổng lồ, v.v.


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

Giả sử ta gom k giá trị gần nhất của dãy vào một vector trạng thái. Với Fibonacci (Fn=Fn1+Fn2), chọn vector 2 chiều (FnFn1).

Câu hỏi mấu chốt: có tồn tại một ma trận cố định M biến vector trạng thái tại bước n thành vector tại bước n+1 không? Nếu truy hồi là tuyến tính thì có:

(Fn+1Fn)=(1110)(FnFn1)

Hàng đầu (11) nói "Fn+1=1·Fn+1·Fn1" — đúng công thức truy hồi. Hàng hai (10) chỉ là "Fn=Fn" để dịch cửa sổ trượt xuống một bậc.

M không đổi giữa các bước, áp M liên tiếp n1 lần đưa vector ban đầu tới vector thứ n:

(FnFn1)=Mn1(F1F0)

Vì sao đúng? Phép nhân ma trận có tính kết hợp: M·(M(Mv))=Mn1v. Mà lũy thừa Mn1 tính được bằng kỹ thuật bình phương liên tiếp, giống lũy thừa số nguyên: M13=M8·M4·M1 (13 = 1101 nhị phân). Mỗi lần ta hoặc bình phương M (tăng số mũ lên gấp đôi), hoặc nhân kết quả với M hiện tại — tổng cộng chỉ O(logN) phép nhân ma trận. Đây chính là chỗ đổi O(N) thành O(logN).

Trực giác tổng quát: mỗi phép nhân ma trận = "đi một bước truy hồi"; lũy thừa ma trận = "đi N bước một lúc" bằng cách gộp các bước theo lũy thừa của 2.


Ví dụ chạy tay

Tính F5 với F0=0,F1=1 (đáp số đúng là 5). Dùng M=(1110), cần M4(F5F4)=M4(F1F0).

Số mũ 4=1002. Lũy thừa nhanh duyệt bit từ thấp lên cao, giữ result (khởi tạo = ma trận đơn vị I) và base (khởi tạo = M):

p = 4 (binary 100)        result = [1 0]   base = [1 1]
                                   [0 1]          [1 0]

--- bit 0 của p = 0 -> KHÔNG nhân result ---
   base = base*base = M^2:
        [1 1][1 1]   [2 1]
        [1 0][1 0] = [1 1]
   p >>= 1  ->  p = 2 (binary 10)

   result = [1 0]   base = [2 1]
            [0 1]          [1 1]

--- bit 0 của p = 0 -> KHÔNG nhân result ---
   base = base*base = M^4:
        [2 1][2 1]   [5 3]
        [1 1][1 1] = [3 2]
   p >>= 1  ->  p = 1 (binary 1)

   result = [1 0]   base = [5 3]
            [0 1]          [3 2]

--- bit 0 của p = 1 -> NHÂN result ---  (ô đang ghi: result)
   result = result*base = I*M^4 = [5 3]
                                  [3 2]
   p >>= 1  ->  p = 0  -> DỪNG

Vậy M4=(5332). Nhân với vector đầu:

(F5F4)=(5332)(F1F0)=(5332)(10)=(53)

Đọc ra F5=5, F4=3 — khớp. Chú ý phần tử Mn[0][0] chính bằng Fn+1: một mẹo hay để lấy kết quả mà không cần nhân vector.


Cài đặt

Bộ khung dùng chung cho mọi bài: kiểu ma trận, phép nhân (có lấy modulo), và lũy thừa nhanh.

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

const long long MOD = 1e9 + 7;
typedef vector<vector<long long>> Matrix;

int sz; // kích thước ma trận (k x k)

// Nhân hai ma trận k x k, lấy modulo MOD
Matrix multiply(const Matrix& A, const Matrix& B) {
    Matrix C(sz, vector<long long>(sz, 0));
    for (int i = 0; i < sz; i++)
        for (int k = 0; k < sz; k++) {
            if (A[i][k] == 0) continue;        // bỏ qua phần tử 0 để tăng tốc
            for (int j = 0; j < sz; j++)
                // A[i][k], B[k][j] < MOD ~ 1e9 nên tích < 1e18, vừa khít long long
                C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MOD;
        }
    return C;
}

// Tính A^p bằng bình phương liên tiếp
Matrix matpow(Matrix A, long long p) {
    Matrix result(sz, vector<long long>(sz, 0));
    for (int i = 0; i < sz; i++) result[i][i] = 1; // ma trận đơn vị I
    while (p > 0) {
        if (p & 1) result = multiply(result, A);   // bit hiện tại = 1: gộp vào kết quả
        A = multiply(A, A);                          // bình phương: số mũ gấp đôi
        p >>= 1;
    }
    return result;
}

Ví dụ 1 — Fibonacci thứ n (n1018), với F0=0,F1=1:

long long fibonacci(long long n) {
    if (n == 0) return 0;
    sz = 2;
    Matrix M = {{1, 1}, {1, 0}};
    Matrix R = matpow(M, n);   // M^n[0][0] = F_{n+1}, M^n[0][1] = F_n
    return R[0][1];            // chính là F_n
}

Ví dụ 2 — Truy hồi tuyến tính bậc k tổng quát f(n)=c1f(n1)++ckf(nk):

// init = {f(0), f(1), ..., f(k-1)}; c = {c1, c2, ..., ck}
long long solve(long long n, vector<long long> c, vector<long long> init) {
    int k = c.size();
    sz = k;
    if (n < k) return init[n];
    // Hàng đầu là các hệ số; các hàng dưới dịch cửa sổ xuống một bậc
    Matrix M(k, vector<long long>(k, 0));
    for (int j = 0; j < k; j++) M[0][j] = c[j] % MOD;
    for (int i = 1; i < k; i++) M[i][i-1] = 1;

    // Trạng thái ban đầu V = [f(k-1), f(k-2), ..., f(0)]^T (giảm dần)
    Matrix R = matpow(M, n - (k - 1));
    long long ans = 0;
    for (int j = 0; j < k; j++)
        ans = (ans + R[0][j] * init[k - 1 - j]) % MOD;
    return ans;
}

Ví dụ 3 — Đếm số đường đi đúng N bước trên đồ thị: nếu A là ma trận kề (A[i][j] = số cạnh từ i tới j) thì AN[s][t] = số đường đi (theo cạnh, có thể lặp đỉnh) độ dài đúng N từ s tới t. Chỉ cần lập A rồi matpow(A, N).


Độ phức tạp

Lý giải
Thời gian O(k3logN) Mỗi phép nhân hai ma trận k×k tốn O(k3) (ba vòng lặp lồng nhau); lũy thừa nhanh thực hiện O(logN) phép nhân.
Bộ nhớ O(k2) Chỉ lưu vài ma trận k×k cùng lúc (result, base, ma trận tích tạm).

Ước lượng thực tế: với k100N1018, ta có k3log2N106×606×107 — chạy trong khoảng dưới một giây. Khi k lớn hơn (vd k=200) thì k3=8×106 và tổng lên ~5×108, cần tối ưu hằng số (bỏ phần tử 0, dùng mảng phẳng thay vector<vector>).


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

  • Tràn số khi nhân. Tích A[i][k]×B[k][j] với hai thừa số cỡ 109 cho ra ~1018, sát trần long long (9.2×1018). Nếu cộng dồn nhiều tích trước khi lấy modulo, hoặc dùng int, sẽ tràn. Cách tránh: ép kiểu long long, lấy % MOD ngay sau mỗi lần cộng một tích.
  • Khởi tạo sai phần tử trung tính. matpow phải bắt đầu result = ma trận đơn vị I (đường chéo 1, còn lại 0), không phải ma trận 0. Lấy ma trận 0 thì mọi tích về 0 và kết quả luôn sai. (Trong biến thể min-plus, phần tử trung tính lại là ma trận có 0 trên đường chéo, + ngoài đường chéo — đừng nhầm.)
  • Lệch số mũ (off-by-one). Phải xác định rõ vector ban đầu ứng với chỉ số nào. Với (FnFn1)=Mn1(F1F0) thì số mũ là n1, không phải n. Sai một bậc cho ra Fn±1. Luôn kiểm bằng một giá trị nhỏ tự tính tay (như F5=5 ở trên).
  • Quên xử lý N nhỏ. Khi N<k (vd hỏi F0 hay f(1) với truy hồi bậc 3), số mũ n(k1) có thể âm, làm matpow chạy sai. Phải trả thẳng init[n] cho các trường hợp cơ sở trước khi gọi lũy thừa.
  • Modulo âm. Khi truy hồi có hệ số trừ (vd f(n)=f(n1)f(n2)), kết quả % MOD có thể âm. Sửa bằng ((x % MOD) + MOD) % MOD.
  • Tưởng nhanh nhưng vẫn O(N) ẩn. Nếu lỡ tính MN bằng vòng lặp nhân N lần thay vì bình phương liên tiếp, độ phức tạp tụt về O(k3N) — vô dụng với N=1018. Đảm bảo p >>= 1 chứ không phải p--.

Biến thể / Mở rộng

  • Thêm hằng số / đa thức vào truy hồi. Với f(n)=f(n1)+f(n2)+n+c, đưa thêm các biến phụ (n và hằng 1) vào vector trạng thái để giữ tính tuyến tính. Ví dụ vector [f(n),f(n1),n,1]T và ma trận tương ứng tự cập nhật nn+1.
  • Min-plus (tropical) matrix exponentiation. Thay phép (×,+) trong nhân ma trận bằng (+,min): C[i][j]=mink(A[i][k]+B[k][j]). Khi đó Ak[s][t] cho đường đi ngắn nhất dùng đúng k cạnh (phần tử trung tính là + ngoài đường chéo, 0 trên đường chéo). Đây là kỹ thuật cho bài graphpath2.
  • Lũy thừa ma trận trên số mũ. Khi dãy nhân (vd fx=c2x6fx1fx2fx3), lấy logarit/quan tâm số mũ của từng thừa số rồi dùng matrix exp trên các số mũ (theo modulo p1 nhờ định lý Fermat nhỏ). Đây là bài prodrec.
  • Liên hệ. Kỹ thuật bình phương liên tiếp giống hệt lũy thừa nhanh số nguyên; ý tưởng "gộp nhiều bước truy hồi" cũng xuất hiện trong nhiều bài quy hoạch động.

Bài tập luyện

gnatmake 12.2.0 a68g 3.1.2 nasm 2.16.1 as_x64 2.46 awk 1.3.4 gcc 16.1.0 csc 6.12.0.200 g++ 16.1.0 g++-themis 16.1.0 g++17 16.1.0 g++20 16.1.0 g++23 16.1.0 clang++ 22.1.6 dmd 2.112.0 dart 3.12.1 gforth 0.7.3 gfortran 12.2.0 go 1.26.3 groovyc 5.0.6 javac 25.0.3 node 26.2.0 kotlinc 2.3.21 sbcl 2.2.9 lua 5.4.8 nim 2.2.10 fpc 3.2.2 fpc-themis 3.2.2 perl 5.36.0 php 8.5.6 pike 8.0 pypy3 7.3.23 python3 3.14.5 racket 8.7 ruby 4.0.5 rustc 1.96.0 csc 5.3.0 ctoj-scratch 0.0.1 sed 4.9 tclsh 8.6 bun 1.3.14 deno 2.8.1 v 0.5.1 zig 0.16.0