Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học

Mô Tả Mảng (CSES 1746 Array Description)

Mô Tả Mảng (CSES 1746 Array Description)
Nguồn: CSES 1746 – Array Description Độ khó: Trung bình Quy hoạch động Đệ quy có nhớ

Đề bài

Bạn biết rằng một mảng có $n$ số nguyên giữa $1$ và $m$ và chênh lệch tuyệt đối giữa hai giá trị liền kề nhiều nhất là $1$.

Cho một mô tả về mảng mà trong đó một số giá trị có thể không xác định, nhiệm vụ của bạn là đếm số lượng mảng khớp với mô tả.

Input

  • Dòng đầu vào đầu tiên có hai số nguyên $n$ và $m$: kích thước mảng và giới hạn trên cho mỗi giá trị.
  • Dòng tiếp theo có $n$ số nguyên $x_1, x_2, \dots, x_n$: nội dung của mảng. Giá trị $0$ biểu thị một giá trị không xác định.

Output

  • In một số nguyên: số lượng dãy chia lấy dư cho $10^9 + 7$.

Constraints

  • $1 \le n \le 10^5$
  • $1 \le m \le 100$
  • $0 \le x_i \le m$
Ví dụ – input
3 5
2 0 2
Ví dụ – output
3

Note: các dãy $[2, 1, 2]$, $[2, 2, 2]$, $[2, 3, 2]$ khớp với mô tả.

Lời mở đầu

Thêm một bài quy hoạch động đếm rất hay trong bộ CSES. Cũng như mọi khi, mình đi từ vét cạn, cải tiến lên đệ quy có nhớ (top-down), rồi công thức truy hồi (bottom-up), cuối cùng rút gọn bộ nhớ còn mảng 1 chiều. Hy vọng mang đến được giá trị cho các bạn 😇.

Dành cho tất cả mọi ngườiNộp bài trên LQDOJ

Link này công khai, bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì dùng link này nhé. Chưa có tài khoản thì tạo tài khoản rồi đăng nhập nộp bài thử hen.

Nộp bài trên LQDOJ

Lộ trình lời giải, bấm vào từng phần để nhảy tới:

Tóm tắtHiểu đề cho thật đơn giản

Trước tiên mình sẽ tóm tắt lại đề bài một cách đơn giản nhất để bạn nào chưa hiểu như sau:

Đề bài nói gì?
Cho bạn một mảng gồm $n$ số nguyên $a[1], a[2], \dots, a[n]$ $(1 \le n \le 10^5)$ và số $m$ $(1 \le m \le 100)$, $0 \le a[i] \le m$. Có những giá trị trong mảng ở dạng không xác định ($a[i] = 0$), những phần tử đó ta có thể thay bằng số bất kỳ từ $1$ tới $m$. Hãy đếm số lượng mảng tạo ra được sao cho chênh lệch tuyệt đối giữa hai giá trị liền kề nhiều nhất là 1. In ra số lượng đó chia lấy dư cho $10^9 + 7$.

Ví dụ: $n = 3$, $m = 5$, $a = \{2, 0, 2\}$. Output: $3$. Ta có thể tạo ra các mảng $\{2, 1, 2\}$, $\{2, 2, 2\}$, $\{2, 3, 2\}$.

Cách 1Vét cạn đệ quy

Cách làm đơn giản nhất của bài này chính là vét cạn, tức là thử mọi trường hợp có thể. Phần tử nào trong mảng mà $= 0$ thì ta thay thử lần lượt các giá trị từ $1$ tới $m$ vào, sau đó duyệt qua mảng để kiểm tra chênh lệch giữa hai giá trị liền kề có vượt quá $1$ không. Cách này thì chắc chắn bị TLE rồi, và cũng khó để cải tiến lên nữa: giả sử toàn bộ các phần tử đều $= 0$, mỗi phần tử thử $m$ trường hợp, suy ra có tổng cộng $m^n$ trường hợp. Với $m = 100$, $n = 10^5$ thì có $10^{2 \cdot 10^5}$ trường hợp, không thể thử hết trong 1 giây được. Vậy ta đến với cách vét cạn cải tiến hơn nha.

Ta sẽ thấy rằng nếu phần tử thứ $n$ của ta là $x$ thì phần tử thứ $n - 1$ chỉ có thể là $x - 1$ hoặc $x$ hoặc $x + 1$ mà thôi, vì độ chênh lệch giữa các phần tử liên tiếp không được vượt quá $1$ mà.

phần tử n − 1phần tử n x − 1 x x + 1 x Try(n, m, x) = Try(n−1, m, x−1) + Try(n−1, m, x) + Try(n−1, m, x+1)

Từ ý tưởng đó ta xây dựng hàm đệ quy như sau:

  • Hàm Try(n, m, x) trả về số lượng dãy có chiều dài $n$, thoả mãn điều kiện chênh lệch giữa các phần tử liên tiếp không vượt quá $1$, các phần tử nằm trong khoảng từ $1$ tới $m$, khớp với mô tả, và phần tử thứ $n$ bằng $x$ ($a[n] = x$).

Điều kiện dừng. Ta đang xét phần tử thứ $n$ và $a[n]$ sẽ $= x$. Ta có:

1/ Nếu $x$ không nằm trong khoảng từ $1$ tới $m$ thì đó là dãy không hợp lệ, trả về $0$. Tức là khi $x < 1$ hoặc $x > m$:

C++
if(x < 1 || x > m) {
    return 0;
}
Python
if x < 1 or x > m:
    return 0

2/ Nếu $a[n]$ là một giá trị xác định ($a[n] \ne 0$) mà $a[n]$ còn khác $x$ thì cũng không có dãy nào hợp lệ, vì ta cần dãy có phần tử thứ $n$ bằng $x$ mà $a[n]$ lại $\ne x$. Còn nếu $a[n] = 0$ thì đây là giá trị không xác định, ta hoàn toàn có thể gán $a[n] = x$ nha. Nên phải kiểm tra $a[n] \ne 0$ rồi mới kiểm tra $a[n] \ne x$:

C++
if(a[n] && a[n] != x) {
    return 0;
}
Python
if a[n] and a[n] != x:
    return 0

3/ Đến đây ta chắc chắn ($a[n] = 0$ hoặc $a[n] = x$) và $1 \le x \le m$. Lúc này nếu $n = 1$, tức dãy chỉ có chiều dài $1$, thì với phần tử thứ $n = x$ ta có đúng một dãy, trả về $1$:

C++
if(n == 1) {
    return 1;
}
Python
if n == 1:
    return 1

Xử lý chạy. Số lượng dãy chiều dài $n$ với $a[n] = x$ $=$ (số dãy chiều dài $n - 1$ với $a[n - 1] = x - 1$) $+$ (số dãy chiều dài $n - 1$ với $a[n - 1] = x$) $+$ (số dãy chiều dài $n - 1$ với $a[n - 1] = x + 1$). Viết lại:

Công thức
$$Try(n, m, x) = \big(Try(n - 1, m, x - 1) + Try(n - 1, m, x) + Try(n - 1, m, x + 1)\big) \bmod (10^9 + 7)$$

Và vì cần lấy phần dư cho $10^9 + 7$ nên hãy nhớ chia dư lúc trả về. Kết quả của bài chia làm 2 trường hợp:

  • TH1: $a[n] \ne 0$ (xác định) thì kết quả chính là số dãy chiều dài $n$ có phần tử cuối $= a[n]$, tức Try(n, m, a[n]).
  • TH2: $a[n] = 0$ (không xác định) thì phần tử cuối có thể là $1, 2, \dots, m$, kết quả $=$ Try(n, m, 1) + Try(n, m, 2) + … + Try(n, m, m). Ta lưu vào biến res và sau mỗi lần cộng phải chia dư luôn, vì theo đồng dư thức $(a + b) \bmod m$ $= \big((a \bmod m) + (b \bmod m)\big) \bmod m$.

Sau đây là source code của mình về ý tưởng trên cho bạn nào cần tham khảo nha:

Code vét cạn đệ quy – 17/27 test
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const ll mod = (ll)1e9 + 7;
const int N = (int)1e5 + 5;
int a[N];
ll Try(int n, int m, int x) {
    if(x < 1 || x > m) {
        return 0;
    }
    if(a[n] && a[n] != x) {
        return 0;
    }
    if(n == 1) {
        return 1;
    }
    return (Try(n - 1, m, x - 1) + Try(n - 1, m, x) + Try(n - 1, m, x + 1)) % mod;
}
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    ll res = 0;
    if(a[n]) {
        res = Try(n, m, a[n]);
    }
    else {
        for(int i = 1; i <= m; ++i) {
            res += Try(n, m, i);
            res %= mod;
        }
    }
    cout << res;
    return 0;
}
import sys
sys.setrecursionlimit(10**5)
MOD = 10**9 + 7
N = 10**5 + 5
a = [0] * N

def Try(n, m, x):
    if x < 1 or x > m:
        return 0
    if a[n] and a[n] != x:
        return 0
    if n == 1:
        return 1
    return (Try(n - 1, m, x - 1) + Try(n - 1, m, x) + Try(n - 1, m, x + 1)) % MOD

# main
n, m = map(int, sys.stdin.readline().split())
arr = list(map(int, sys.stdin.readline().split()))
for i in range(1, n + 1):
    a[i] = arr[i - 1]
res = 0
if a[n]:
    res = Try(n, m, a[n])
else:
    for i in range(1, m + 1):
        res += Try(n, m, i)
        res %= MOD
print(res)
C++▶ Chạy tay từng dòng codeCách 1: đệ quy Try(n, m, x), phần tử trước chỉ có thể là x − 1, x, x + 1.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5const ll mod = (ll)1e9 + 7;
6const int N = (int)1e5 + 5;
7int a[N];
8ll Try(int n, int m, int x) {
9 if(x < 1 || x > m) {
10 return 0;
11 }
12 if(a[n] && a[n] != x) {
13 return 0;
14 }
15 if(n == 1) {
16 return 1;
17 }
18 return (Try(n - 1, m, x - 1) + Try(n - 1, m, x) + Try(n - 1, m, x + 1)) % mod;
19}
20int main() {
21 ios::sync_with_stdio(0);
22 cin.tie(0);
23 int n, m;
24 cin >> n >> m;
25 for(int i = 1; i <= n; ++i) {
26 cin >> a[i];
27 }
28 ll res = 0;
29 if(a[n]) {
30 res = Try(n, m, a[n]);
31 }
32 else {
33 for(int i = 1; i <= m; ++i) {
34 res += Try(n, m, i);
35 res %= mod;
36 }
37 }
38 cout << res;
39 return 0;
40}

Giải thích một số chỗ trong code

  1. ios::sync_with_stdio(0); cin.tie(0); 2 dòng đầu tiên của hàm main sẽ giúp đẩy nhanh tốc độ nhập xuất dữ liệu nếu các bạn đang code theo ngôn ngữ C++ (để hiểu vì sao thì các bạn cứ gõ mấy từ này lên Google là sẽ ra rất nhiều bài viết giải thích nhé), nên kinh nghiệm trong các bài mà cần nhập xuất nhiều thì ta nên sử dụng 2 dòng này. Trong bài này đầu vào có thể lên tới $10^5$ số nên các bạn nên thêm 2 dòng này vào nha. Tất nhiên nếu các bạn code theo cú pháp của C thì chẳng cần mấy dòng đó làm gì, tuy nhiên thường khi code các bài lập trình thi đấu thế này ta dùng C++ vì cú pháp ngắn gọn cũng như nhiều hỗ trợ của nó, vậy nên bạn nào code C++ thì nhớ thêm 2 dòng này vào đầu hàm main nhé.
Không gian$O(n)$ (độ sâu đệ quy)
Thời gian$O(3^n)$ – hàm mũ

Với cách trên chắc chắn ta sẽ bị TLE, cụ thể ta ăn được 17 trên 27 test nha. Tuy nhiên hãy vẫn nên cài đặt nó để sau này lấy nó làm công cụ kiểm tra tính chính xác cho cách làm tối ưu, hoặc biết đâu ta có thể cải tiến nó thành quy hoạch động top-down.

Cách 2Quy hoạch động top-down (đệ quy có nhớ)

Lý do code trên chạy lâu chính là vì nếu ta đã tính 1 vấn đề rồi mà lần sau gặp lại thì vẫn phải đi tính lại nó, việc tính đi tính lại này tốn rất nhiều thời gian. Tư tưởng của thuật toán là dùng 1 kiểu dữ liệu (mảng, vector, map…) để lưu trữ những kết quả đã tính, gặp lại thì chỉ việc trả về kết quả mà thôi. Ta làm như sau:

  1. Tham số nào thay đổi? Hàm Try() có 3 tham số $n, m, x$, chỉ có $n$ và $x$ thay đổi ở mỗi lần gọi đệ quy, còn $m$ thì không. Vậy ta tạo mảng 2 chiều F, phần tử F[n][x] lưu kết quả của Try(n, m, x).
  2. Giới hạn tham số? $n$ tối đa $10^5$, $x$ tối đa $m = 100$. Vậy ta tạo mảng F[10^5 + 5][100 + 5], mình thường cộng thêm 5 để thừa ra vài phần tử, tránh truy cập ra ngoài phạm vi mảng.
  3. Giá trị hàm không thể trả về? Hàm Try() nhỏ nhất trả về $0$, vậy $-1$ là giá trị không thể trả về. Ta lấp đầy F = -1; sau mục điều kiện dừng, nếu F[n][x] != -1 nghĩa là đã tính rồi, chỉ việc trả về F[n][x]. Ngược lại thì đi tính như phần xử lý chạy, gán kết quả vào F[n][x] rồi trả về.
Code quy hoạch động top-down – AC
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const ll mod = (ll)1e9 + 7;
const int N = (int)1e5 + 5;
int a[N];
ll f[N][105];
ll Try(int n, int m, int x) {
    if(x < 1 || x > m) {
        return 0;
    }
    if(a[n] && x != a[n]) {
        return 0;
    }
    if(n == 1) {
        return 1;
    }
    if(f[n][x] != -1) {
        return f[n][x];
    }
    return f[n][x] = (Try(n - 1, m, x - 1) + Try(n - 1, m, x) + Try(n - 1, m, x + 1)) % mod;
}
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    fill(&f[0][0], &f[n][m + 1], -1);
    ll res = 0;
    if(a[n]) {
        res = Try(n, m, a[n]);
    }
    else {
        for(int i = 1; i <= m; ++i) {
            res += Try(n, m, i);
            res %= mod;
        }
    }
    cout << res;
    return 0;
}
import sys
sys.setrecursionlimit(10**5)
MOD = 10**9 + 7
N = 10**5 + 5
a = [0] * N
f = None

def Try(n, m, x):
    if x < 1 or x > m:
        return 0
    if a[n] and x != a[n]:
        return 0
    if n == 1:
        return 1
    if f[n][x] != -1:
        return f[n][x]
    f[n][x] = (Try(n - 1, m, x - 1) + Try(n - 1, m, x) + Try(n - 1, m, x + 1)) % MOD
    return f[n][x]

n, m = map(int, sys.stdin.readline().split())
arr = list(map(int, sys.stdin.readline().split()))
for i in range(1, n + 1):
    a[i] = arr[i - 1]
f = [[-1] * (m + 2) for _ in range(n + 1)]
res = 0
if a[n]:
    res = Try(n, m, a[n])
else:
    for i in range(1, m + 1):
        res += Try(n, m, i)
        res %= MOD
print(res)
C++▶ Chạy tay từng dòng codeCách 2: đệ quy có nhớ, lưu f[n][x] để không tính lại.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5const ll mod = (ll)1e9 + 7;
6const int N = (int)1e5 + 5;
7int a[N];
8ll f[N][105];
9ll Try(int n, int m, int x) {
10 if(x < 1 || x > m) {
11 return 0;
12 }
13 if(a[n] && x != a[n]) {
14 return 0;
15 }
16 if(n == 1) {
17 return 1;
18 }
19 if(f[n][x] != -1) {
20 return f[n][x];
21 }
22 return f[n][x] = (Try(n - 1, m, x - 1) + Try(n - 1, m, x) + Try(n - 1, m, x + 1)) % mod;
23}
24int main() {
25 ios::sync_with_stdio(0);
26 cin.tie(0);
27 int n, m;
28 cin >> n >> m;
29 for(int i = 1; i <= n; ++i) {
30 cin >> a[i];
31 }
32 fill(&f[0][0], &f[n][m + 1], -1);
33 ll res = 0;
34 if(a[n]) {
35 res = Try(n, m, a[n]);
36 }
37 else {
38 for(int i = 1; i <= m; ++i) {
39 res += Try(n, m, i);
40 res %= mod;
41 }
42 }
43 cout << res;
44 return 0;
45}

Giải thích một số chỗ trong code

  1. return f[n][x] = (Try(…) + Try(…) + Try(…)) % mod; ở đây chính là ta gán f[n][x] = … rồi sau đó return f[n][x].
  2. fill(&f[0][0], &f[n][m + 1], -1); đây chính là bước lấp đầy mảng F bằng $-1$. Bạn nào không biết hàm fill thì dùng vòng for vẫn được nha, độ phức tạp như nhau.
Không gian$O(n \cdot m)$
Thời gian$O(n \cdot m)$

Không gian thật ra là $O(n \cdot m + n)$ vì dùng mảng f và mảng a, nhưng theo quy tắc BigO thì là $O(n \cdot m)$. Thời gian: trường hợp xấu nhất hàm Try() phải lấp đầy toàn bộ mảng F, thay $n = 10^5$, $m = 100$ cần khoảng $10^7$ phép tính, hoàn toàn dưới 1 giây nha. Thực tế còn tốn thêm thời gian cho việc gọi đệ quy, nhưng với code trên ta đã AC (Accepted) bài này rồi.

Lưu ý
Code C++ thì AC nhưng code Python thì bị TLE do Python đệ quy chạy chậm hơn rất nhiều (đệ quy sâu tới $10^5$ tầng còn có thể bị lỗi RecursionError).

Cách 3Quy hoạch động bottom-up F[i][j]

Ta đã có cách quy hoạch động top-down rồi thì ta cũng sẽ có cách bottom-up, chính là anh em sẽ tạo ra công thức truy hồi đó. Cho anh em nào đã biết cơ bản về quy hoạch động rồi thì ta có 4 bước như sau:

Bước 1 – Bài toán con
Gọi $F[i][j]$ là số dãy độ dài $i$ (khi xét $i$ phần tử đầu) thoả mãn điều kiện đề bài với phần tử cuối cùng (phần tử thứ $i$) $= j$.
Bước 2 – Bài toán cơ sở
1/ Phần tử chỉ được nằm trong khoảng từ $1$ tới $m$, nên $F[i][j]$ với $j < 1$ hoặc $j > m$ sẽ $= 0$. Để tiện nhất ta lấp đầy mảng F = 0 bằng cách khai báo mảng toàn cục (toàn bộ phần tử được khởi tạo sẵn $= 0$).
2/ Khi dãy có chiều dài $1$ ($i = 1$):
– $a[1] \ne 0$: chỉ có 1 dãy có phần tử cuối $= a[1]$, tức $F[1][a[1]] = 1$, còn lại đều $= 0$ (đã khởi tạo sẵn).
– $a[1] = 0$: có 1 dãy cho mỗi giá trị cuối $1, 2, \dots, m$, nên $F[1][j] = 1$ với $1 \le j \le m$: fill(&f[1][1], &f[1][m + 1], 1);
Bước 3 – Đáp án
$F[n][1] + F[n][2]$ $+ \dots + F[n][m]$, nhớ chia dư sau mỗi lần cộng. Nhiều bạn thắc mắc sao không cần kiểm tra phần tử thứ $n$ có xác định hay không: nếu $a[n]$ xác định thì $F[n][j] = 0$ với mọi $j \ne a[n]$ (không tồn tại dãy như thế), nên cộng luôn như trên cũng ra đúng $F[n][a[n]]$.
Bước 4 – Công thức truy hồi
1/ $a[i] \ne 0$: các $F[i][j]$ với $j \ne a[i]$ đều $= 0$ (đã khởi tạo sẵn), ta chỉ cần quan tâm $F[i][a[i]]$: $$F[i][a[i]] = \big(F[i-1][a[i]-1] + F[i-1][a[i]] + F[i-1][a[i]+1]\big) \bmod (10^9+7)$$ 2/ $a[i] = 0$: phần tử cuối $j$ có thể là $1, 2, \dots, m$, làm vòng for(j) từ $1$ tới $m$: $$F[i][j] = \big(F[i-1][j-1] + F[i-1][j] + F[i-1][j+1]\big) \bmod (10^9+7)$$

Vậy lỡ mà $j - 1 < 1$ hoặc $j + 1 > m$ thì sao? Lúc đó chắc chắn F[i-1][j-1] hoặc F[i-1][j+1] $= 0$ vì ta khởi tạo toàn bộ mảng bằng $0$. Nhờ việc mình cộng thêm 5 lúc khai báo nên ta mới có những phần tử này nha, các bạn phải cẩn thận đó, nếu khai báo vừa đủ dùng thì F[i-1][j+1] có thể khiến code bị lỗi.

Em nhập $m$ và mảng mô tả rồi bấm Tiếp để xem bảng $F$ được lấp từng hàng (cột $0$ và cột $m + 1$ là 2 cột "đệm" luôn bằng $0$):

Ví dụ nhanh:
Code quy hoạch động bottom-up – AC
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const ll mod = (ll)1e9 + 7;
const int N = (int)1e5 + 5;
int a[N];
ll f[N][105];
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    if(a[1]) {
        f[1][a[1]] = 1;
    }
    else {
        fill(&f[1][1], &f[1][m + 1], 1);
    }
    for(int i = 2; i <= n; ++i) {
        if(a[i]) {
            f[i][a[i]] = (f[i - 1][a[i] - 1] + f[i - 1][a[i]] + f[i - 1][a[i] + 1]) % mod;
        }
        else {
            for(int j = 1; j <= m; ++j) {
                f[i][j] = (f[i - 1][j - 1] + f[i - 1][j] + f[i - 1][j + 1]) % mod;
            }
        }
    }
    ll res = 0;
    for(int j = 1; j <= m; ++j) {
        res = (res + f[n][j]) % mod;
    }
    cout << res;
    return 0;
}
import sys
MOD = 10**9 + 7
N = 10**5 + 5
a = [0] * N
n, m = map(int, sys.stdin.readline().split())
arr = list(map(int, sys.stdin.readline().split()))
for i in range(1, n + 1):
    a[i] = arr[i - 1]
# chỉ giữ 2 hàng: hàng i và hàng i - 1 (luân phiên theo i % 2)
f = [[0] * (m + 2) for _ in range(2)]
if a[1]:
    f[1][a[1]] = 1
else:
    for j in range(1, m + 1):
        f[1][j] = 1
for i in range(2, n + 1):
    cur = i % 2
    prev = 1 - cur
    for j in range(m + 2):
        f[cur][j] = 0
    if a[i]:
        x = a[i]
        f[cur][x] = (f[prev][x - 1] + f[prev][x] + f[prev][x + 1]) % MOD
    else:
        for j in range(1, m + 1):
            f[cur][j] = (f[prev][j - 1] + f[prev][j] + f[prev][j + 1]) % MOD
res = sum(f[n % 2][1:m + 1]) % MOD
print(res)
C++▶ Chạy tay từng dòng codeCách 3: bottom-up, lấp bảng f[i][j] từng hàng.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5const ll mod = (ll)1e9 + 7;
6const int N = (int)1e5 + 5;
7int a[N];
8ll f[N][105];
9int main() {
10 ios::sync_with_stdio(0);
11 cin.tie(0);
12 int n, m;
13 cin >> n >> m;
14 for(int i = 1; i <= n; ++i) {
15 cin >> a[i];
16 }
17 if(a[1]) {
18 f[1][a[1]] = 1;
19 }
20 else {
21 fill(&f[1][1], &f[1][m + 1], 1);
22 }
23 for(int i = 2; i <= n; ++i) {
24 if(a[i]) {
25 f[i][a[i]] = (f[i - 1][a[i] - 1] + f[i - 1][a[i]] + f[i - 1][a[i] + 1]) % mod;
26 }
27 else {
28 for(int j = 1; j <= m; ++j) {
29 f[i][j] = (f[i - 1][j - 1] + f[i - 1][j] + f[i - 1][j + 1]) % mod;
30 }
31 }
32 }
33 ll res = 0;
34 for(int j = 1; j <= m; ++j) {
35 res = (res + f[n][j]) % mod;
36 }
37 cout << res;
38 return 0;
39}

Bản Python ở trên chỉ giữ 2 hàng f[0] và f[1] dùng luân phiên theo i % 2 (hàng $i$ chỉ cần hàng $i - 1$), ý tưởng y hệt bản C++.

Không gian$O(n \cdot m)$
Thời gian$O(n \cdot m)$

Độ phức tạp vẫn giống cách top-down, nhưng code sẽ chạy nhanh hơn và ăn ít bộ nhớ hơn một chút vì không phải tốn cho việc gọi đệ quy nữa.

Cách 4Rút gọn còn mảng 1 chiều

Mình hoàn toàn có thể rút gọn từ mảng 2 chiều thành mảng 1 chiều. Việc này giúp tiết kiệm bộ nhớ hơn và code chạy nhanh hơn (truy cập mảng 1 chiều nhanh hơn mảng 2 chiều), chứ độ phức tạp thời gian thì vẫn như cách trên nha. Lúc này ta bỏ $F[i][j]$ về chỉ còn $F[j]$. Trường hợp cơ sở: nếu $a[1]$ xác định thì $F[a[1]] = 1$, ngược lại gán từ $F[1]$ tới $F[m] = 1$:

C++
if(a[1]) {
    f[a[1]] = 1;
}
else {
    fill(f + 1, f + m + 1, 1);
}
Python
if a[1]:
    f[a[1]] = 1
else:
    for j in range(1, m + 1):
        f[j] = 1

Các bạn tham khảo đoạn xử lý chính trước cho dễ hình dung nha:

C++
for(int i = 2; i <= n; ++i) {
    if(a[i]) {
        ll tmp = (f[a[i] - 1] + f[a[i]] + f[a[i] + 1]) % mod;
        if(a[i - 1]) {
            f[a[i - 1]] = 0;
        }
        else {
            fill(f + 1, f + m + 1, 0);
        }
        f[a[i]] = tmp;
    }
    else {
        for(int j = m; j >= 1; --j) {
            preF[j] = f[j];
            f[j] = (f[j - 1] + f[j] + preF[j + 1]) % mod;
        }
    }
}
Python
for i in range(2, n + 1):
    if a[i]:
        tmp = (f[a[i] - 1] + f[a[i]] + f[a[i] + 1]) % MOD
        if a[i - 1]:
            f[a[i - 1]] = 0
        else:
            for j in range(1, m + 1):
                f[j] = 0
        f[a[i]] = tmp
    else:
        for j in range(m, 0, -1):
            preF[j] = f[j]
            f[j] = (f[j - 1] + f[j] + preF[j + 1]) % MOD
  • $a[i]$ xác định: công thức sau khi bỏ $[i]$ là $F[a[i]]$ $= (F[a[i]-1] + F[a[i]] + F[a[i]+1]) \bmod (10^9+7)$. Nhưng các phần tử còn lại phải được gán $= 0$. Vậy ta tính vào biến tạm tmp trước. Sau đó nếu $a[i-1]$ lúc trước xác định thì chỉ có $F[a[i-1]] \ne 0$, còn lại đều bằng $0$ rồi, ta gán $F[a[i-1]] = 0$. Ngược lại nếu $a[i-1] = 0$ thì phải cho toàn bộ mảng $F = 0$ (mình dùng hàm fill). Sau tất cả ta gán $F[a[i]] = tmp$.
  • $a[i]$ không xác định: nếu viết $F[j] = F[j-1] + F[j] + F[j+1]$ là sai! Vòng for(j) chạy ngược nên $F[j-1]$ đúng là $F[i-1][j-1]$, $F[j]$ đúng là $F[i-1][j]$, còn $F[j+1]$ thì đã bị cập nhật thành $F[i][j+1]$ chứ không phải $F[i-1][j+1]$ ta cần. Vậy ta tạo mảng preF (previous F): trước khi cập nhật thì lưu preF[j] = F[j], để khi $j$ giảm xuống thì preF[j+1] chính là $F[i-1][j+1]$: $$F[j] = \big(F[j-1] + F[j] + preF[j+1]\big) \bmod (10^9+7)$$
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const ll mod = (ll)1e9 + 7;
const int N = (int)1e5 + 5;
int a[N];
ll f[105], preF[105];
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; ++i) {
        cin >> a[i];
    }
    if(a[1]) {
        f[a[1]] = 1;
    }
    else {
        fill(f + 1, f + m + 1, 1);
    }
    for(int i = 2; i <= n; ++i) {
        if(a[i]) {
            ll tmp = (f[a[i] - 1] + f[a[i]] + f[a[i] + 1]) % mod;
            if(a[i - 1]) {
                f[a[i - 1]] = 0;
            }
            else {
                fill(f + 1, f + m + 1, 0);
            }
            f[a[i]] = tmp;
        }
        else {
            for(int j = m; j >= 1; --j) {
                preF[j] = f[j];
                f[j] = (f[j - 1] + f[j] + preF[j + 1]) % mod;
            }
        }
    }
    ll res = 0;
    for(int i = 1; i <= m; ++i) {
        res = (res + f[i]) % mod;
    }
    cout << res;
    return 0;
}
import sys
MOD = 10**9 + 7
data = sys.stdin.buffer.read().split()
n, m = int(data[0]), int(data[1])
a = [0] + [int(v) for v in data[2:2 + n]]
f = [0] * (m + 2)
preF = [0] * (m + 2)
if a[1]:
    f[a[1]] = 1
else:
    for j in range(1, m + 1):
        f[j] = 1
for i in range(2, n + 1):
    if a[i]:
        tmp = (f[a[i] - 1] + f[a[i]] + f[a[i] + 1]) % MOD
        if a[i - 1]:
            f[a[i - 1]] = 0
        else:
            for j in range(1, m + 1):
                f[j] = 0
        f[a[i]] = tmp
    else:
        for j in range(m, 0, -1):
            preF[j] = f[j]
            f[j] = (f[j - 1] + f[j] + preF[j + 1]) % MOD
print(sum(f[1:m + 1]) % MOD)
C++▶ Chạy tay từng dòng codeCách 4: chỉ dùng mảng 1 chiều f và preF.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2#define endl '\n'
3using namespace std;
4typedef long long ll;
5const ll mod = (ll)1e9 + 7;
6const int N = (int)1e5 + 5;
7int a[N];
8ll f[105], preF[105];
9int main() {
10 ios::sync_with_stdio(0);
11 cin.tie(0);
12 int n, m;
13 cin >> n >> m;
14 for(int i = 1; i <= n; ++i) {
15 cin >> a[i];
16 }
17 if(a[1]) {
18 f[a[1]] = 1;
19 }
20 else {
21 fill(f + 1, f + m + 1, 1);
22 }
23 for(int i = 2; i <= n; ++i) {
24 if(a[i]) {
25 ll tmp = (f[a[i] - 1] + f[a[i]] + f[a[i] + 1]) % mod;
26 if(a[i - 1]) {
27 f[a[i - 1]] = 0;
28 }
29 else {
30 fill(f + 1, f + m + 1, 0);
31 }
32 f[a[i]] = tmp;
33 }
34 else {
35 for(int j = m; j >= 1; --j) {
36 preF[j] = f[j];
37 f[j] = (f[j - 1] + f[j] + preF[j + 1]) % mod;
38 }
39 }
40 }
41 ll res = 0;
42 for(int i = 1; i <= m; ++i) {
43 res = (res + f[i]) % mod;
44 }
45 cout << res;
46 return 0;
47}
Không gian$O(n)$
Thời gian$O(n \cdot m)$

Không gian thật ra là $O(n + 2m)$ (mảng a, f, preF), theo BigO là $O(n)$. Thời gian vẫn là $O(n \cdot m)$ nhưng chạy nhanh hơn rất nhiều vì giờ chỉ còn thao tác trên mảng 1 chiều. Và các bạn hoàn toàn có thể tiết kiệm bộ nhớ hơn nữa bằng việc vừa đọc vào vừa xử lý, lúc đó độ phức tạp không gian chỉ còn $O(m)$ thôi.

Ghi chú kiểm tra của mình: 4 code C++ và các bản Python của Cách 1, 2, 3 là của mình trong bài viết gốc, bản Python mảng 1 chiều của Cách 4 là mình viết thêm. Cả 8 chương trình đã được kiểm tra với 250 bộ test ngẫu nhiên ($n \le 7$, $m \le 5$, nhiều số $0$) so với chương trình vét cạn thử hết $m^n$ mảng, đều khớp. Với test lớn nhất $n = 10^5$, $m = 100$ toàn số $0$ (đáp án $879725732$): Cách 2 chạy khoảng 0.33 giây, Cách 3 khoảng 0.14 giây, Cách 4 chỉ khoảng 0.03 giây. Các bản Python: Cách 2 bị RecursionError (đệ quy quá sâu), Cách 3 và 4 mất khoảng 5–6 giây, nên nộp bài thì dùng C++ nhé.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇

Nhận xét