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

Tìm Chữ Số (CHUSO2)

Tìm Chữ Số (CHUSO2)
Nguồn: NTUCoder (CHUSO2) Độ khó: Trung bình Toán học Xử lý chữ số

Đề bài

CHUSO2 – Tìm chữ số · Giới hạn thời gian: 1.0 giây · Giới hạn bộ nhớ: 128 megabyte

Cho một số nguyên $N$ được tạo thành bằng cách viết liên tiếp tất cả các số từ $1$ đến $10^{15}$. Vậy các chữ số đầu tiên của $N$ là:

N = 1234567891011121314151617181920...

Hãy tìm chữ số thứ $p$ của $N$.

Dữ liệu nhập:

  • Là số nguyên $p$ $(1 \le p \le 10^{15})$.

Dữ liệu xuất:

  • Là chữ số thứ $p$ của $N$.
Ví dụ 1 – input
5
Ví dụ 1 – output
5
Ví dụ 2 – input
11
Ví dụ 2 – output
0
Ví dụ 3 – input
30
Ví dụ 3 – output
2

Lời mở đầu

Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇. Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:

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

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 NTUCoder

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

Cách 1Vét cạn: ghép từng số, đếm từng chữ số – O(p)

Một cách làm cơ bản nhất có thể nhìn ra ngay đó là vét cạn xét tuần tự, nghĩa là ta sẽ tạo ra chuỗi số được ghép liên tục từ các số tự nhiên tăng dần rồi với mỗi số ta xét đếm các chữ số của nó từ trái qua phải, mỗi lần như vậy tăng biến đếm lên. Khi nào đếm bằng với $p$ thì vòng lặp dừng lại, ngay lúc đó nó đang ở chữ số nào thì đó là kết quả; còn nếu đếm vẫn còn nhỏ hơn $p$ thì cứ ghép tiếp thêm số vào rồi lặp lại quy trình như trên.

Để cho đơn giản, mỗi số $x$ ta chuyển nó thành chuỗi để từ đó có thể nhanh chóng xét đến từng chữ số bên trong nó từ trái qua phải, chứ nếu để nó ở dạng số thì việc xét từng chữ số từ trái qua phải cũng đòi hỏi xử lý phức tạp.

Chuỗi N (số nhỏ phía trên là vị trí). Ví dụ p = 11 → chữ số 0 (thuộc số 10)
112233445566778899101110121131141152161173181194201215…

Source code cho tư tưởng vét cạn trình bày ở trên để các bạn tham khảo:

Code vét cạn O(p) – chỉ dùng được khi p nhỏ
#include <bits/stdc++.h>
using namespace std;

int main(){
    long long p, cnt = 0, x = 1;
    cin >> p;
    char res;
    while(cnt < p){
        string s = to_string(x);
        for(int i = 0; i < (int)s.length(); ++i){
            cnt++;
            if(cnt == p){
                res = s[i];
                break;
            }
        }
        x++;
    }
    cout << res;
    return 0;
}
p = int(input())
cnt = 0
x = 1
while cnt < p:
    s = str(x)
    for ch in s:
        cnt += 1
        if cnt == p:
            res = ch
            break
    x += 1
print(res)
C++▶ Chạy tay từng dòng codeCách 1: vét cạn, ghép từng số x rồi đếm từng chữ số, O(p).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 long long p, cnt = 0, x = 1;
6 cin >> p;
7 char res;
8 while(cnt < p){
9 string s = to_string(x);
10 for(int i = 0; i < (int)s.length(); ++i){
11 cnt++;
12 if(cnt == p){
13 res = s[i];
14 break;
15 }
16 }
17 x++;
18 }
19 cout << res;
20 return 0;
21}

Đánh giá độ phức tạp của cách làm này:

Không gian$O(1)$
Thời gian$O(p)$ với $p$ là vị trí chữ số cần tìm

Với code ở trên, vòng lặp sẽ lặp đủ $p$ lần. Theo như giới hạn đề bài đưa ra, $p$ có thể lên đến tối đa là $10^{15}$, nếu trường hợp xấu nhất đó xảy ra thì chắc chắn code ở trên sẽ bị TLE. Thậm chí không cần phải là trường hợp xấu nhất đó nữa, bởi ta đã biết khi giới hạn thời gian là 1 giây thì số lần lặp tối đa để không bị TLE sẽ là $(3 \text{ đến } 5) \cdot 10^7$ hay cao lắm là $10^8$, nên chỉ cần $p$ lớn hơn $10^8$ thôi là code ở trên đã bị TLE rồi chứ chưa cần phải đến $10^{15}$.

Vậy nên code ở trên tuy ngắn gọn, đơn giản và không đòi hỏi tư duy nhiều nhưng nó không tối ưu. Nhưng ta vẫn cứ cài đặt nó để qua đó nhìn ra những cái không tốt của nó, làm cơ sở cho ta tìm ra một cách làm tối ưu hơn. Cũng nhờ code vét cạn đó mà ta có thể làm ra công cụ kiểm tra tính chính xác cho cách làm tối ưu (sinh test ngẫu nhiên rồi so kết quả 2 code).

Ý tưởngĐếm nhanh theo nhóm số có cùng số chữ số

Giờ ta đi thảo luận cách xử lý tối ưu cho bài này, nó chỉ cần các bạn vận dụng một chút tư duy toán học thôi mà hehe. Cái dở của code ở trên là nó cứ đi xét tuần tự các số tự nhiên rồi với từng số lại đi xét tuần tự từng chữ số của số đó. Trong khi đó ta không cần thiết phải nhọc công như vậy.

Hãy để ý rằng chuỗi $N$ là các số ghép liên tục bắt đầu từ $1$:

  • Các số có 1 chữ số (từ 1 đến 9) cả thảy có $9 \cdot 1 = 9$ chữ số.
  • Các số có 2 chữ số (từ 10 đến 99) có $99 - 10 + 1 = 90$ số, nên có $90 \cdot 2 = 180$ chữ số.
  • Các số có 3 chữ số (từ 100 đến 999) có $900$ số, nên có $900 \cdot 3 = 2700$ chữ số.
  • Cứ như thế, với các số có 4, 5, 6, 7, 8, 9 chữ số sẽ là: $36 \cdot 10^3$, $45 \cdot 10^4$, $54 \cdot 10^5$, $63 \cdot 10^6$, $72 \cdot 10^7$, $81 \cdot 10^8$... Tổng quát: nhóm số có $i$ chữ số đóng góp $\boxed{9 \cdot i \cdot 10^{i-1}}$ chữ số.
Số chữ số $i$Các sốBao nhiêu sốSố chữ số đóng gópCộng dồn
11 → 9999
210 → 9990180189
3100 → 9999002.7002.889
41.000 → 9.9999.00036.00038.889
510.000 → 99.99990.000450.000488.889
……………
1410.000.000.000.000 → 99.999.999.999.99990.000.000.000.0001.260.000.000.000.0001.388.888.888.888.889

Cộng dồn đến nhóm 14 chữ số đã vượt $10^{15}$ nên chỉ cần xét $i$ từ 1 đến 14.

Ví dụ p = 250

Giả sử ta cần tìm chữ số tại vị trí $p = 250$:

  • $250 - 9 = 241 \ge 0$ → ta có thể bỏ qua các số có 1 chữ số.
  • $241 - 180 = 61 \ge 0$ → bỏ qua tiếp các số có 2 chữ số.
  • $61 - 2700$ ra số âm → chữ số thứ $p$ nằm trong đoạn của các số có 3 chữ số, tức là trong đoạn từ $100$ đến $999$, và nó là chữ số thứ $61$ của đoạn này.

Vậy chữ số đó thuộc về số nào cụ thể? Các số trong đoạn này đều có 3 chữ số, nên ta lấy $61 / 3 = 20$, $61 \,\%\, 3 = 1$. Vì phép chia lấy dư ra kết quả khác 0, nghĩa là đã đi hết trọn 20 số và còn lấn sang số bên cạnh. Vậy tính từ số $100$ là số thứ 1 thì số thứ 21 chính là số chứa vị trí thứ $p$, tức là số $100 + 21 - 1 = 120$, và chữ số cần tìm nằm tại vị trí thứ $1$ (vì $61 \,\%\, 3 = 1$) tính từ trái qua phải của số đó → đáp án là chữ số 1.

p = 250 rơi vào nhóm 3 chữ số, còn lại 61 → 61 = 3·20 + 1
1 chữ số
9
2 chữ số
180
3 chữ số: 2700 · p còn 61
100101…118119 120121
20 số trọn vẹn (100 → 119) chiếm 60 chữ số, chữ số thứ 61 là chữ số đầu tiên của 120 → đáp án 1

Nếu giả sử $p = 249$ thì sau khi bỏ qua các số có 1, 2 chữ số, phần còn lại là $60$: $60 / 3 = 20$, $60 \,\%\, 3 = 0$. Vì phần dư là 0 nên nó không lấn sang số tiếp theo mà dừng đúng ở số thứ 20 tính từ 100, tức là $100 + 20 - 1 = 119$, và chữ số cần tìm là chữ số cuối cùng của 119 → đáp án là 9.

Mẹo kiểm chứng
Các bạn có thể kiểm chứng qua đoạn code vét cạn hồi đầu: trong câu lệnh điều kiện cnt == p, trước dòng gán res = s[i] thì thử thêm 1 dòng cout << "x = " << x << endl; để xem ngay tại vị trí đó là đang xét số nào.

Minh hoạNhảy qua từng nhóm số

Em nhập $p$ (tối đa $10^{15}$) rồi bấm Tiếp để xem từng bước: trừ dần số chữ số của từng nhóm, rồi tìm ra số chứa chữ số thứ $p$:

Ví dụ nhanh:

Cách 2Lưu số chữ số từng nhóm vào mảng a

Nếu đã nhìn ra quy luật ở trên rồi thì việc còn lại là cài đặt hiện thực ra thôi. Chỗ này nếu bạn nào không xử lý khéo thì cũng dài dòng phức tạp lắm đây.

Trước tiên ta cần một mảng 1 chiều $a$ lưu trữ lại số lượng chữ số của nhóm các số có 1, 2, 3, 4... chữ số, cụ thể là: $9 \cdot 10^0$, $18 \cdot 10^1$, $27 \cdot 10^2$, $36 \cdot 10^3$, $45 \cdot 10^4$... Thử nhẩm một chút trên máy tính: $p$ tối đa là $10^{15}$, nếu ta cứ cộng dồn $9 \cdot 10^0 + 18 \cdot 10^1 + 27 \cdot 10^2 + \ldots$ đến khi tổng vượt qua ngưỡng $10^{15}$ thì dừng, ta sẽ thấy cần lưu trữ đến mức $126 \cdot 10^{13}$, tức là mảng cần 14 phần tử (số mũ từ $10^0$ đến $10^{13}$).

Để cho thuận tiện, vì ta đang tính số lượng chữ số từ 1 trở đi, nên để trùng khớp với vị trí index thì ta cho mảng $a$ có 15 phần tử (index từ 0 đến 14), bỏ qua không dùng index 0: $a[i] = 9 \cdot i \cdot 10^{i-1}$ là số chữ số của nhóm các số có $i$ chữ số.

  1. Cho $i$ chạy từ 1: nếu $p - a[i] \ge 0$ thì trừ $p$ đi $a[i]$ và $i$++. Đến khi trừ ra số âm thì dừng: chữ số thứ $p$ nằm trong nhóm các số có $i$ chữ số, và nó là chữ số thứ $p$ (giá trị $p$ còn lại) của nhóm.
  2. Số đầu tiên có $i$ chữ số là $10^{i-1}$. Ta cho $x = 10^{i-1} + p/i - 1$ (ví dụ trên: $x = 100 + 61/3 - 1 = 119$).
  3. Xét $p \,\%\, i$: nếu khác 0 thì $x$++ (thành 120), đổi $x$ sang chuỗi và lấy ký tự tại index $(p \,\%\, i) - 1$ (phải trừ 1 vì index chuỗi tính từ 0).
  4. Nếu $p \,\%\, i = 0$ thì không cần $x$++, chữ số cần tìm là chữ số cuối cùng của $x$, lấy luôn bằng $x \,\%\, 10$, không cần mất công đổi $x$ sang chuỗi làm gì cả.

Source code cho tư tưởng trình bày ở trên để các bạn tham khảo:

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

int main(){
    long long p, a[15], i;
    cin >> p;
    for(i = 1; i <= 14; ++i){
        a[i] = 9 * i * pow(10, i - 1);
    }
    i = 1;
    while(p - a[i] >= 0){
        p -= a[i];
        i++;
    }
    long long x = (long long)pow(10, i - 1) + p/i - 1;
    p %= i;
    if(p == 0){
        cout << x % 10;
    }
    else{
        x++;
        string s = to_string(x);
        cout << s[p - 1];
    }
    return 0;
}
p = int(input())
a = [0] * 15
for i in range(1, 15):
    a[i] = 9 * i * (10 ** (i - 1))
i = 1
while p - a[i] >= 0:
    p -= a[i]
    i += 1
x = (10 ** (i - 1)) + (p // i) - 1
p %= i
if p == 0:
    print(x % 10)
else:
    x += 1
    s = str(x)
    print(s[p - 1])
C++▶ Chạy tay từng dòng codeCách 2: mảng a lưu số chữ số từng nhóm, trừ dần để tìm nhóm rồi tính x.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 long long p, a[15], i;
6 cin >> p;
7 for(i = 1; i <= 14; ++i){
8 a[i] = 9 * i * pow(10, i - 1);
9 }
10 i = 1;
11 while(p - a[i] >= 0){
12 p -= a[i];
13 i++;
14 }
15 long long x = (long long)pow(10, i - 1) + p/i - 1;
16 p %= i;
17 if(p == 0){
18 cout << x % 10;
19 }
20 else{
21 x++;
22 string s = to_string(x);
23 cout << s[p - 1];
24 }
25 return 0;
26}

Đánh giá độ phức tạp của cách làm này:

Không gian$O(N)$ với $N = 14$ là số phần tử mảng $a$
Thời gian$O(N \cdot \log i)$ – thực tế chỉ vài chục phép tính

Vòng lặp đầu chạy 14 lần để tạo ra mảng $a$, bên trong có dùng hàm pow để tính $10^{i-1}$ (cỡ $O(\log i)$). Vòng while tìm nhóm trong trường hợp xấu nhất cũng chỉ chạy 14 lần. Tính $10^{i-1}$ để tạo ra $x$ mất $O(\log i)$, đổi $x$ sang chuỗi mất $O(L)$ với $L$ là số chữ số của $x$. Còn các phép chia, trừ, chia lấy dư, truy xuất index trong chuỗi đều là $O(1)$. Theo quy tắc cộng Big-O thì lấy thành phần lớn nhất: $O(N \cdot \log i)$ với $N = 14$ là số phần tử mảng $a$.

Mà $N$ tối đa 14, $\log_2 14$ coi như là 4, nên $N \cdot \log i$ chỉ cỡ $14 \cdot 4 = 56$ phép tính. Vậy nên chắc chắn cách này không thể nào bị TLE được, trong phạm vi giới hạn của bài thì độ phức tạp này gần như là hằng số luôn rồi, mà độ phức tạp hằng số là tối ưu nhất rồi các bạn nhé.

Cách 3Bỏ luôn mảng a, gộp vào 1 vòng for

Thực ra đoạn code ở trên ta có thể tối ưu thêm nữa, ngay từ đầu mình cũng muốn trình bày luôn nhưng sợ gộp nhiều quá các bạn sẽ khó hiểu nên phần này mới nói sau.

  • Bước đầu tiên tạo ra mảng $a$ có đủ 14 phần tử, trong khi có thể $p$ không đủ lớn để cần đến 14 phần tử như vậy. Lỡ ví dụ $p$ chỉ là 5 thì việc tạo ra 14 phần tử là không cần thiết.
  • Và ta có cần thiết phải lưu các giá trị đó vào mảng không? Sau cùng chỉ là lấy $p$ trừ cho các giá trị đó đến khi nào trừ mà ra số âm thì dừng lại, rồi lấy vị trí $i$ đó mà đi tính tiếp, lúc đó chẳng cần $a[i]$ làm gì cả.

Vậy ta cho vòng lặp chạy $i$ từ 1 đến 14, tính ra giá trị $x = 9 \cdot i \cdot 10^{i-1}$ ngay tại chỗ, nếu $p$ trừ được thì trừ, không trừ được thì break kết thúc. Mọi thứ còn lại y như code trước đó.

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

int main(){
    long long p, x, i;
    cin >> p;
    for(i = 1; i <= 14; ++i){
        x = 9 * i * pow(10, i - 1);
        if(p - x >= 0){
            p -= x;
        }
        else{
            break;
        }
    }
    x = (long long)pow(10, i - 1) + p/i - 1;
    p %= i;
    if(p == 0){
        cout << x % 10;
    }
    else{
        x++;
        string s = to_string(x);
        cout << s[p - 1];
    }
    return 0;
}
p = int(input())
for i in range(1, 15):
    x = 9 * i * (10 ** (i - 1))
    if p - x >= 0:
        p -= x
    else:
        break
x = (10 ** (i - 1)) + (p // i) - 1
p %= i
if p == 0:
    print(x % 10)
else:
    x += 1
    s = str(x)
    print(s[p - 1])
C++▶ Chạy tay từng dòng codeCách 3: tính số chữ số từng nhóm ngay trong vòng for, dừng sớm bằng break.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 long long p, x, i;
6 cin >> p;
7 for(i = 1; i <= 14; ++i){
8 x = 9 * i * pow(10, i - 1);
9 if(p - x >= 0){
10 p -= x;
11 }
12 else{
13 break;
14 }
15 }
16 x = (long long)pow(10, i - 1) + p/i - 1;
17 p %= i;
18 if(p == 0){
19 cout << x % 10;
20 }
21 else{
22 x++;
23 string s = to_string(x);
24 cout << s[p - 1];
25 }
26 return 0;
27}

Lúc này độ phức tạp không gian đã được cải tiến so với code trước, giờ chỉ là $O(1)$:

Không gian$O(1)$
Thời giantối đa 14 lần lặp – gần như hằng số

Còn về thời gian thì được tối ưu hơn code trước ở chỗ không cần vòng lặp while riêng mà đã gộp luôn vào vòng lặp for đầu tiên. Trường hợp xấu nhất khi $p$ rất lớn cỡ $10^{15}$ thì cũng tương tự code trước, nhưng khi $p$ không quá lớn thì code này nhanh hơn nhiều vì không phải cứ xét đủ 14 lần mà có thể dừng sớm. Và code này về mặt trình bày cũng gọn hơn các bạn thấy không?

Ghi chú của mình: code C++ và Python ở trên là của anh. Cả 3 cách (C++ lẫn Python) đã được kiểm tra với gần 300 bộ test: $p$ ngẫu nhiên đến $10^6$ (so với chuỗi ghép thật), $p$ ngẫu nhiên đến $10^{15}$ và đúng các vị trí ranh giới giữa 2 nhóm (9, 10, 189, 190, 2889, ...) – khớp hết. Với $p = 10^{15}$, Cách 2 và Cách 3 chạy chưa tới 0.01 giây; còn Cách 1 vét cạn với $p = 10^8$ đã mất khoảng 0.4 giây, và với $p = 10^{15}$ thì chạy mãi không xong.
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 😇

Mario Nhảy Cột

Mario Nhảy Cột
Nguồn: NTUCoder (MARIO3) Độ khó: Trung bình Quy hoạch động Đệ quy

Đề bài

MARIO là một trò chơi rất quen thuộc với các bạn trẻ. Trong trò chơi, muốn kết thúc một cửa chơi, MARIO phải nhảy để kéo một lá cờ từ đỉnh xuống dưới chân cột cờ.

Trong phiên bản mới, MARIO đang ở bờ bên trái sông còn cột cờ được đặt tại bờ bên phải sông, trên sông có $N$ chiếc cọc gỗ để giúp MARIO sang sông. MARIO có thể nhảy di chuyển từ cọc $i$ bước sang cọc $i + 1$, hoặc nhảy sang cọc $i + 2$ hoặc nhảy sang cọc $i + 3$. Tuy nhiên, người thiết kế trò chơi có làm khó người chơi bằng cách thiết kế một vài chiếc cọc lung lay, vài chiếc cọc khác lại bị mục nát.

  • Với một chiếc cọc $i$ lung lay: MARIO chỉ có thể bước từ cọc $i - 1$ tới cọc $i$, và từ cọc $i$ chỉ có thể bước sang cọc $i + 1$ hoặc nhảy sang cọc $i + 2$.
  • Với một chiếc cọc bị mục nát: MARIO không thể đứng trên đó vì nó sẽ gãy và MARIO sẽ bị rơi xuống sông.
  • MARIO chỉ có thể đi tiến lên phía trước chứ không thể lùi lại khi đi trên cọc để qua sông.

Cu Tý nhà ta đã rất nhiều lần qua được sông, vì cậu là một game thủ siêu hạng. Tuy nhiên, lần này cậu lại nảy sinh ý nghĩ là phải qua sông theo một cách thật độc đáo để cho đám bạn phải thán phục, vì vậy cậu muốn biết có bao nhiêu cách để qua được sông, từ đó mới chọn ra cách độc đáo nhất. Bạn hãy lập trình, trả lời câu hỏi giúp Cu Tý.

Dữ liệu nhập:

  • Dòng 1: chứa số nguyên dương $N$ $(1 \le N \le 1000)$.
  • Dòng 2: chứa $N$ số nguyên $A_1, A_2, \ldots, A_N$, với $A_i \in \{0, 1, 2\}$. Trong đó:
    • $A_i = 0$: nếu cọc $i$ là cọc tốt;
    • $A_i = 1$: nếu cọc $i$ là cọc lung lay;
    • $A_i = 2$: nếu cọc $i$ là cọc bị mục.

Kết quả: là số lượng cách đi để có thể qua sông. Do số lượng này rất lớn nên chỉ cần in ra 9 chữ số cuối (mod $10^9$).

Lưu ý: bờ trái và bờ phải có chức năng tương tự như cọc tốt.

Giới hạn: thời gian 1 giây, bộ nhớ 128 MB.

Ví dụ 1 – input
4
0 1 2 0
Ví dụ 1 – output
2
Ví dụ 2 – input
5
0 0 2 1 2
Ví dụ 2 – output
0

Giải thích:

  • Ví dụ 1: MARIO sẽ có 2 cách đi như sau:
    • Cách 1: từ bờ bên trái bước đến cọc 1, từ cọc 1 nhảy đến cọc 4, từ cọc 4 bước sang bờ bên phải.
    • Cách 2: từ bờ bên trái bước đến cọc 1, từ cọc 1 bước sang cọc 2, từ cọc 2 nhảy sang cọc 4, từ cọc 4 bước sang bờ bên phải.
  • Ví dụ 2: MARIO không thể qua sông vì: từ bờ có thể đến cọc 1, cọc 2:
    • Từ cọc 1, không thể sang cọc 3 (vì cọc 3 mục), không thể sang cọc 4 (vì cọc 4 lung lay nên chỉ có thể bước từ cọc 3 sang).
    • Từ cọc 2, không thể sang cọc 3 (vì cọc 3 mục), không thể sang cọc 4 (vì cọc 4 lung lay nên chỉ có thể bước từ cọc 3 sang), không thể sang cọc 5 (vì cọc 5 mục).
Bờ 0 0 1 2 0 Bờ 5 cọc 1cọc 2cọc 3cọc 4 Cách 1: 0 → 1 → 4 → 5 Cách 2: 0 → 1 → 2 → 4 → 5

Lời mở đầu

Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇. Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì link bài đây nhé:

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

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 NTUCoder

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: Mario cần đi từ bờ bên này sang bờ bên kia. Bờ bên này có số thứ tự là $0$, bờ bên kia có số thứ tự là $n + 1$, ở giữa 2 bờ có 1 con sông có $n$ cái cọc $(1 \le n \le 1000)$ để Mario nhảy lên qua sông. Có 3 loại cọc trên sông:

  • Cọc tốt ($a[i] = 0$): Mario có thể nhảy từ cọc $i - 3$ hoặc $i - 2$ hoặc $i - 1$ đến cọc $i$, và từ cọc $i$ có thể nhảy tới cọc $i + 1$ hoặc $i + 2$ hoặc $i + 3$.
  • Cọc lung lay ($a[i] = 1$): Mario chỉ có thể nhảy từ cọc $i - 1$ tới cọc $i$, và từ cọc $i$ chỉ có thể nhảy tới cọc $i + 1$ hoặc $i + 2$.
  • Cọc mục nát ($a[i] = 2$): Mario không thể đứng được, nói cách khác nếu Mario nhảy lên cọc này thì Mario sẽ bị rớt xuống sông.
0 cọc tốt: nhảy tới được 1, 2, 3 bước
1 cọc lung lay: chỉ bước vào từ cọc ngay trước, đi tiếp 1 hoặc 2 bước
2 cọc mục: không được đứng

Mario chỉ có thể đi tiến lên phía trước chứ không thể đi lùi. Hãy in ra số lượng cách đi để có thể qua sông chia dư cho $10^9$. Lưu ý là hai bên bờ cũng được tính như một cọc tốt, nói cách khác $a[0] = a[n + 1] = 0$.

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

Cách 1Vét cạn bằng đệ quy – đi ngược từ bờ phải

Cách làm đầu tiên của bài này sẽ là vét cạn nha, tức là ta sẽ thử mọi trường hợp đó. Cách làm này sẽ dùng tới đệ quy và sẽ khá là phức tạp cho bạn nào chưa quen với đệ quy, nhưng nó là cách dễ nghĩ tới nhất vì ý tưởng của nó là thử mọi trường hợp có thể xảy ra mà. Giờ mình sẽ phân tích rồi cài đặt để hi vọng các bạn sẽ học được thêm điều gì đó.

Đi xuôi hay đi ngược?

Ý tưởng của việc thử hết tất cả trường hợp sẽ là từ 1 cọc ta có thể đi tới những cọc nào hoặc là có những cọc nào có thể đi tới cọc ta đang xét. Thì ở đây ta sẽ lựa cái nào thuận tiện nhất để cài đặt thôi.

Nếu ta mà chọn "từ 1 cọc ta có thể đi tới những cọc nào" thì đầu tiên ta phải xét cọc đang xét là cọc loại gì, nó nhảy tới được cọc nào, cọc nó nhảy tới mà là cọc lung lay thì nó có nhảy được không (vì giả dụ giờ cọc ta đang xét là cọc $i$, mà cọc $i + 2$ là cọc lung lay thì làm sao nhảy tới được). Cũng khá là phức tạp. Vậy nên theo mình thì chúng ta sẽ nên làm theo cách có những cọc nào có thể đi tới cọc ta đang xét.

Tổng quát thì giờ giả sử ta đang xét cọc $i$:

  • Nếu $a[i] = 0$ (cọc tốt) thì để nhảy tới cọc $i$ có thể là từ cọc $i - 1$ hoặc $i - 2$, vì dù cọc trước đó có lung lay hay tốt thì ta đều có thể nhảy xa 1 đến 2 cọc; còn nếu tới từ cọc $i - 3$ thì phải check là $a[i - 3] = 0$ vì chỉ có cọc tốt mới nhảy được 3 bước.
  • Nếu $a[i] = 1$ (cọc lung lay) thì chỉ có thể nhảy từ cọc $i - 1$ tới thôi.
  • Nếu $a[i] = 2$ (cọc mục nát) thì sẽ không tới từ cọc nào cả vì cọc bị hỏng mà, nhảy vô để rớt xuống sông à 😂.

Ta sẽ bắt đầu xét từ bờ bên phải, tức từ $n + 1$, rồi xét cứ dần về bờ bên trái; nếu có lúc $i = 0$, tức là có đường để từ bờ bên trái qua bờ bên phải thì ta tăng biến đếm cnt lên thôi. (Còn cọc mục ở vị trí $i - 1$, $i - 2$ thì cũng không sao: khi gọi tới nó, nó rơi vào trường hợp $a = 2$ và tự dừng.)

Hàm Try(i)

Giờ ta sẽ gọi hàm Try(int i) sẽ giúp ta thử mọi trường hợp đến cọc $i$:

  • Nếu $i = 0$, ta sẽ tăng biến đếm.
  • Nếu $i > 0$ (ta phải check nhé, không thì nhỡ $i$ âm cái là lỗi chương trình đó), thì ta sẽ xét: nếu $a[i] = 0$ thì ta sẽ gọi Try(i - 1) rồi Try(i - 2), rồi ta sẽ check (i - 3 >= 0 && a[i - 3] == 0), nếu có thì ta mới gọi Try(i - 3). Ta cần check $i - 3 \ge 0$ để đảm bảo a[i - 3] không bị lỗi truy cập, và $a[i - 3] = 0$ tức là cọc tốt thì mới nhảy tới $i$ được.
  • Nếu mà $a[i] = 1$ thì ta chỉ việc Try(i - 1) vì để tới cọc lung lay thứ $i$ thì chỉ có thể nhảy từ cọc $i - 1$ mà.
  • Trường hợp còn lại là $a[i] = 2$ tức là cọc mục nát thì ta kệ thôi, không gọi đệ quy nữa.

Đây là source code cho bạn nào muốn tham khảo cách làm trên của mình nha:

Code vét cạn (TLE từ test 5)
#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const ll mod = (ll)1e9;
int n;
int a[1005];
ll cnt = 0;
void Try(int i){
    if(i == 0){
        cnt++;
        cnt %= mod;
    }
    else if(i > 0){
        if(a[i] == 0){
            Try(i - 1);
            Try(i - 2);
            if(i - 3 >= 0 && a[i - 3] == 0){
                Try(i - 3);
            }
        }
        else if(a[i] == 1){
            Try(i - 1);
        }
    }
}
int main(){
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    cin >> n;
    a[0] = a[n + 1] = 0;
    for(int i = 1; i <= n; ++i){
        cin >> a[i];
    }
    Try(n + 1);
    cout << cnt;
    return 0;
}
import sys
sys.setrecursionlimit(2000)  # Tránh lỗi đệ quy sâu
MOD = int(1e9)
cnt = 0
def Try(i):
    global cnt
    if i == 0:
        cnt += 1
        cnt %= MOD
    elif i > 0:
        if a[i] == 0:
            Try(i - 1)
            Try(i - 2)
            if i - 3 >= 0 and a[i - 3] == 0:
                Try(i - 3)
        elif a[i] == 1:
            Try(i - 1)
n = int(input())
a = [0] + list(map(int, input().split())) + [0]
Try(n + 1)
print(cnt)
C++▶ Chạy tay từng dòng codeCách 1: vét cạn đệ quy, lùi từ bờ phải về bờ trá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;
6int n;
7int a[1005];
8ll cnt = 0;
9void Try(int i){
10 if(i == 0){
11 cnt++;
12 cnt %= mod;
13 }
14 else if(i > 0){
15 if(a[i] == 0){
16 Try(i - 1);
17 Try(i - 2);
18 if(i - 3 >= 0 && a[i - 3] == 0){
19 Try(i - 3);
20 }
21 }
22 else if(a[i] == 1){
23 Try(i - 1);
24 }
25 }
26}
27int main(){
28 ios_base::sync_with_stdio(0);
29 cin.tie(0);
30 cin >> n;
31 a[0] = a[n + 1] = 0;
32 for(int i = 1; i <= n; ++i){
33 cin >> a[i];
34 }
35 Try(n + 1);
36 cout << cnt;
37 return 0;
38}

Giải thích 1 số chỗ trong cách làm trên:

  1. ios_base::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é). Kinh nghiệm là bài nào dữ liệu đầu vào nhiều, như bài này có thể lên đến $10^3$ số, thì ta nên để 2 dòng này vào để chạy nhanh hơn.
  2. cnt %= mod: ở đây ta đang áp dụng đồng dư thức về phép cộng nha các bạn, dành cho bạn nào chưa biết thì $(a + b) \bmod m$ $= ((a \bmod m) + (b \bmod m)) \bmod m$. Nên thay vì ta cộng xong cnt rồi mới chia dư thì cnt nó sẽ rất to, ta sẽ chia dư ở mỗi lần cộng luôn.
  3. a[0] = a[n + 1] = 0: vì hai bên bờ được coi như là cọc tốt mà, nên ta cho luôn bằng 0.
  4. Try(n + 1): ta đang thử mọi trường hợp để đến được bờ bên phải thì ta sẽ phải bắt đầu từ bờ bên phải.

Đánh giá độ phức tạp của cách làm trên:

Không gian$O(n)$
Thời gianhàm mũ (khoảng $1.84^n$ lời gọi khi toàn cọc tốt)

Thời gian thực sự tính chính xác thì mình cũng khó chính xác được cho các bạn. Nhưng chắc chắn cách này sẽ bị TLE, vì các bạn cứ thử nhập $N = 40$ và toàn là số 0 thì cách trên phải chờ rất lâu mới ra kết quả, trong khi đó đề bài đang yêu cầu phải ra kết quả trong 1 giây. Mà chỉ mới là $N = 40$ thôi mà còn chờ lâu như thế trong khi đề bài cho $N$ tối đa 1000 thì còn lâu cỡ nào nữa. Thực tế nộp cách này lên web thì chỉ vượt qua được 4 test và tại test 5 bị TLE (tổng cộng có 24 test).

Vậy cách trên không thể AC được rồi, tuy nhiên hãy vẫn nên cài đặt nó để sau này ta 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.

Ghi chú cho việc viết tắt của mình: AC: Accepted, TLE: Time Limit Exceeded, WA: Wrong Answer. Có nghĩa lần lượt là được chấp thuận, quá thời gian cho phép, sai kết quả nha.

Cách 2Quy hoạch động – 4 bước

Để mà có thể AC được bài này thì ta sẽ dùng phương pháp Quy Hoạch Động (Dynamic Programming) để giải quyết nha các bạn. Nhìn lại cách vét cạn: Try(i) bị gọi đi gọi lại rất nhiều lần với cùng một $i$, mà lần nào cũng ra cùng một số cách. Vậy thì tính 1 lần rồi lưu lại thôi! Cho bạn 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: Xác định bài toán con

Gọi $F[i]$ với $0 \le i \le n + 1$ chính là số cách có thể để ta từ bờ bên trái tới được cọc thứ $i$. Ở bờ bên trái ta gọi là cọc thứ $0$, còn bờ bên phải chính là cọc thứ $n + 1$.

Bước 2: Xác định bài toán cơ sở

  • Ở bờ bên trái tức cọc thứ 0: mặc định lúc đầu ta đang đứng ở đó rồi nên ta sẽ có $F[0] = 1$.
  • Ở những cọc mục nát, tức $a[i] = 2$: vì ta sẽ không nhảy tới những cọc này, nên số cách tới cọc mục nát sẽ bằng 0, tức $F[i] = 0$. Ta sẽ khởi tạo mảng $F$ toàn bộ bằng 0 để tiện cho việc nếu gặp $a[i] = 2$ thì ta sẽ không phải xét nữa. Để làm điều đó, ta chỉ cần khai báo mảng $F$ là mảng toàn cục là chương trình sẽ tự gán mọi phần tử bằng 0 rồi.
  • Cọc đầu tiên: nếu nó là cọc mục nát thì mặc định số cách tới nó bằng 0 rồi, còn nếu nó là cọc lung lay hay cọc tốt thì số cách tới nó đều bằng 1, vì để tới nó thì chỉ có thể từ cọc 0 ra mà.
  • Cọc thứ 2: nếu nó là cọc mục nát thì mặc định bằng 0; nếu nó là cọc lung lay thì số cách tới nó bằng số cách tới cọc 1, vì cọc 2 lung lay nên chỉ có thể tới từ cọc 1; còn nếu nó là cọc tốt thì số cách tới cọc 2 bằng số cách tới cọc 1 cộng thêm 1, vì ta có thể từ cọc 1 sang cọc 2 hoặc từ cọc 0 nhảy thẳng sang cọc 2.

Vậy tổng kết lại các trường hợp cơ sở của ta sẽ là:

$F[0] = 1$khởi tạo mọi $F[i] = 0$ (gặp $a[i] = 2$ thì không xét nữa)
$a[1] = 0$ hoặc $a[1] = 1$$F[1] = 1$
$a[2] = 0$$F[2] = F[1] + 1$
$a[2] = 1$$F[2] = F[1]$

Bước 3: Xác định đáp án của bài toán

Hiển nhiên đáp án của bài toán sẽ là $F[n + 1]$ rồi, vì ta đang cần tìm số cách để có thể từ bờ bên trái tới được bờ bên phải, tức là tới được cọc thứ $n + 1$ mà.

Bước 4: Xác định công thức truy hồi

Giờ giả sử ta đang xét cọc $i$. Nếu $a[i] = 0$, ta có thể tới từ cọc $i - 1$ và $i - 2$, và sẽ có thể tới được từ cọc $i - 3$ nếu $a[i - 3] = 0$ như đã phân tích ở phần vét cạn trên. Nếu ta có 10 cách để tới cọc $i - 1$ thì ta chỉ cần nhảy thêm 1 bước là tới cọc $i$ rồi, ta có 5 cách để tới cọc $i - 2$ thì chỉ cần nhảy thêm 1 bước từ $i - 2$ tới $i$ là đến cọc $i$ rồi, vậy chẳng phải số cách tới cọc $i$ là 15 cách. Vậy thì ta chắc chắn rằng nếu $a[i] = 0$ thì $F[i] = F[i - 1] + F[i - 2]$; và nếu $a[i - 3] = 0$ thì $F[i] \mathrel{+}= F[i - 3]$, vì lúc này cọc $i - 3$ là cọc tốt, giả dụ có 2 cách đến cọc $i - 3$ thì cũng sẽ có thêm 2 cách tới cọc $i$ nhờ từ cọc $i - 3$ ta nhảy thẳng tới cọc $i$.

Còn nếu $a[i] = 1$ thì sao? Thì chứng tỏ để tới cọc $i$ thì chỉ có thể đi từ cọc $i - 1$. Vậy số cách để tới cọc $i$ sẽ bằng số cách để tới cọc $i - 1$, hay $F[i] = F[i - 1]$.

Trường hợp $a[i] = 2$ thì ta sẽ không xét, mặc định $F[i]$ đã bằng 0 rồi.

Công thức truy hồi
  1. Nếu $a[i] = 0$: $F[i] = F[i - 1] + F[i - 2]$; nếu $a[i - 3] = 0$ thì $F[i] \mathrel{+}= F[i - 3]$.
  2. Nếu $a[i] = 1$: $F[i] = F[i - 1]$.
  3. Nếu $a[i] = 2$: $F[i] = 0$.

Minh hoạTính F từng cọc

Em nhập dãy loại cọc $a_1, a_2, \ldots, a_n$ (mỗi số là 0, 1 hoặc 2, tối đa 14 cọc) rồi bấm Tiếp để xem $F$ của từng cọc được cộng từ những cọc nào (mũi tên xanh là được cộng, ô gạch là không được):

Ví dụ nhanh:

CodeQuy hoạch động O(N)

Đây là source code cho bạn nào cần tham khảo cách làm trên của mình nha:

#include<bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const ll mod = (ll)1e9;
ll f[1005];
int a[1005];
int main(){
    ios_base::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    a[0] = a[n + 1] = 0;
    for(int i = 1; i <= n; ++i){
        cin >> a[i];
    }
    f[0] = 1;
    if(a[1] == 0 || a[1] == 1){
        f[1] = 1;
    }
    if(a[2] == 0){
        f[2] = f[1] + 1;
    }
    else if(a[2] == 1){
        f[2] = f[1];
    }
    for(int i = 3; i <= n + 1; ++i){
        if(a[i] == 0){
            f[i] = f[i - 1] + f[i - 2];
            if(a[i - 3] == 0){
                f[i] += f[i - 3];
            }
            f[i] %= mod;
        }
        else if(a[i] == 1){
            f[i] = f[i - 1];
        }
    }
    cout << f[n + 1];
    return 0;
}
MOD = int(1e9)
f = [0] * 1005
a = [0] * 1005
n = int(input())
a[0] = a[n + 1] = 0
arr = list(map(int, input().split()))
for i in range(1, n + 1):
    a[i] = arr[i - 1]
f[0] = 1
if a[1] == 0 or a[1] == 1:
    f[1] = 1
if a[2] == 0:
    f[2] = f[1] + 1
elif a[2] == 1:
    f[2] = f[1]
for i in range(3, n + 2):
    if a[i] == 0:
        f[i] = f[i - 1] + f[i - 2]
        if a[i - 3] == 0:
            f[i] += f[i - 3]
        f[i] %= MOD
    elif a[i] == 1:
        f[i] = f[i - 1]
print(f[n + 1])
C++▶ Chạy tay từng dòng codeCách 2: quy hoạch động F[i] = số cách tới cọc i, O(N).
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;
6ll f[1005];
7int a[1005];
8int main(){
9 ios_base::sync_with_stdio(0);
10 cin.tie(0);
11 int n;
12 cin >> n;
13 a[0] = a[n + 1] = 0;
14 for(int i = 1; i <= n; ++i){
15 cin >> a[i];
16 }
17 f[0] = 1;
18 if(a[1] == 0 || a[1] == 1){
19 f[1] = 1;
20 }
21 if(a[2] == 0){
22 f[2] = f[1] + 1;
23 }
24 else if(a[2] == 1){
25 f[2] = f[1];
26 }
27 for(int i = 3; i <= n + 1; ++i){
28 if(a[i] == 0){
29 f[i] = f[i - 1] + f[i - 2];
30 if(a[i - 3] == 0){
31 f[i] += f[i - 3];
32 }
33 f[i] %= mod;
34 }
35 else if(a[i] == 1){
36 f[i] = f[i - 1];
37 }
38 }
39 cout << f[n + 1];
40 return 0;
41}

Giải thích 1 số chỗ trong cách làm ở trên: for(int i = 3; ...) ta sẽ bắt đầu từ $i = 3$, vì $i = 0, 1, 2$ đã được ta khởi tạo ở trên trường hợp cơ sở, và khi ta bắt đầu từ $i = 3$ thì i - 3 sẽ không mang giá trị âm nữa nên không cần phải check $i - 3 \ge 0$ nữa. Nhánh $a[i] = 1$ không cần chia dư vì $F[i - 1]$ đã nhỏ hơn $10^9$ sẵn rồi; còn tổng 3 số nhỏ hơn $3 \cdot 10^9$ thì long long chứa thoải mái.

Đánh giá độ phức tạp của cách làm trên:

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

Chính xác là $O(2n)$ cho cả không gian (vì ta dùng 2 mảng) và thời gian (vì ta đọc vào xong rồi mới xử lý). Bạn nào muốn thì hoàn toàn có thể xử lý trong lúc đọc dữ liệu để tối ưu hơn nữa nha.

Ghi chú của mình: code C++ và Python ở trên là của anh. Cả 4 bản (vét cạn + quy hoạch động, C++ + Python) đã được kiểm tra với 250 bộ test ngẫu nhiên ($n \le 14$), so với một cách đếm trâu độc lập (đi xuôi từ bờ trái, thử mọi bước nhảy hợp lệ) – khớp hết, kể cả 2 ví dụ của đề. Với $n = 1000$ còn so thêm với quy hoạch động tính bằng số nguyên lớn rồi mới lấy mod – đúng. Bản C++ chạy khoảng 0.01 giây, Python khoảng 0.03 giây. Riêng code vét cạn với $n = 30$ toàn số 0 đã mất hơn 1 giây.
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 😇