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 Chữ Số

Đếm Chữ Số
Nguồn: Codeforces Group Độ khó: Dễ Mảng đếm Tính trước

Đề bài

Bạn An đánh số trang cho một quyển sách có $N$ trang. Sau khi đánh số xong, bạn ấy hỏi các bạn các câu hỏi sau:

  • Có bao nhiêu chữ số 0 mà bạn ấy đã sử dụng?
  • Có bao nhiêu chữ số 1 mà bạn ấy đã sử dụng?
  • …
  • Có bao nhiêu chữ số 9 mà bạn ấy đã sử dụng?

Input

  • Dòng đầu tiên là $T$ – số test.
  • $T$ dòng tiếp theo, mỗi dòng là một số nguyên $N$.
  • Chúng ta không quan trọng câu chuyện $N$ chẵn hay $N$ lẻ nhé.

Output

  • In ra $T$ dòng, mỗi dòng gồm 10 số là số lượng chữ số 0, chữ số 1, …, chữ số 9 được sử dụng.

Giới hạn

  • $1 \le T \le 1000$
  • $1 \le N \le 10^6$

Subtask:

  • 50% số test đầu tiên có $T \le 100$, $N \le 10^4$.
  • 50% số test còn lại không có giới hạn gì thêm.
Ví dụ – input
1
1
Ví dụ – output
0 1 0 0 0 0 0 0 0 0

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 học viên · Ưu tiênNộp bài trên hệ thống CLUE OJ

Link này chỉ học viên đã tham gia tổ chức mới vào được. Học viên nên ưu tiên nộp ở đây vì sẽ được chấm nhanh hơn.

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

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 Codeforces

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

Đề bàiHiểu đúng đề: có T bộ test

Trước tiên mình giải thích rõ đề bài vì mình thấy nhiều bạn đọc không kỹ cứ làm nộp sai, đó là ta có $T$ bộ test các bạn nhé, và kết quả sẽ xuất ra $T$ dòng, mỗi dòng có 10 số là số lượng các chữ số từ 0 đến 9 nha các bạn. Ví dụ với bộ test input như sau thì kết quả sẽ xuất ra là:

Ví dụ – input
3
5
10
15
Ví dụ – output
0 1 1 1 1 1 0 0 0 0
1 2 1 1 1 1 1 1 1 1
1 8 2 2 2 2 1 1 1 1
Chữ số0123456789
N = 50111110000
N = 101211111111
N = 151822221111

Giải thích: Dòng đầu tiên $N = 5$ thì từ 1 tới 5 ta sẽ có các chữ số 1, 2, 3, 4, 5 nên tại vị trí các chữ số này sẽ có số lượng là 1, còn các chữ số còn lại sẽ có số lượng là 0, nên ta có dòng kết quả tương ứng là: 0 1 1 1 1 1 0 0 0 0.

Dòng tiếp theo $N = 10$ thì từ 1 tới 10 sẽ là: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10. Từ 1 tới 9 sẽ là các chữ số từ 1 tới 9 đều xuất hiện 1 lần, đến số 10 thì chữ số 1 được xuất hiện thêm 1 lần nữa thành ra 2 lần, và có thêm xuất hiện của chữ số 0. Nên tóm lại với $N = 10$ thì có đủ các chữ số từ 0 đến 9 xuất hiện 1 lần, riêng chữ số 1 xuất hiện 2 lần, nên ta có dòng kết quả tương ứng là: 1 2 1 1 1 1 1 1 1 1.

Dòng tiếp theo $N = 15$ thì tương tự như mình đã phân tích ở 2 trường hợp $N = 5$, $N = 10$ ở trên các bạn tự suy ra hen (gợi ý: các số 11, 12, 13, 14, 15 góp thêm 6 chữ số 1 và mỗi chữ số 2, 3, 4, 5 thêm 1 lần).

Tại vì đề bài đưa ra ví dụ với $T = 1$ test case nên nhiều bạn chưa rành về vụ có nhiều bộ test thế này, vì thế mình thấy các bạn nộp sai nhiều.

Cách 1Vét cạn từng bộ test – O(T · N · log N)

Giờ ta bàn về cách giải quyết cho bài này. Thì một cách đơn giản nhất mà nhiều bạn sẽ nhìn thấy và làm đó là cứ cho vòng lặp chạy $T$ bộ test, với mỗi bộ test đọc số $N$ vào và chạy vòng lặp với giá trị $i$ từ 1 tới $N$, mỗi giá trị $i$ sẽ đi phân tích và đếm số lần xuất hiện của các chữ số từ 0 đến 9 của giá trị $i$ đó, và cứ thế cộng dồn hết lại; sau cùng khi vòng lặp $i$ kết thúc ta in ra kết quả số lượng của từng chữ số. Rồi cứ thế quy trình ở trên lặp lại qua bộ test tiếp theo.

Mảng đếm 10 phần tử thay cho 10 biến

Nói sâu hơn về việc mỗi giá trị $i$ cần đếm xem có bao nhiêu chữ số từ 0 đến 9 xuất hiện trong đó. Thì chỗ này bạn nào mà không có kinh nghiệm thì sẽ làm cách tạo ra 10 biến đếm tương ứng với 10 chữ số từ 0 đến 9, rồi sau đó phải xét 10 trường hợp if else (vd: if(digit == 0) cnt0++; else if(digit == 1) cnt1++; …). Như thế vẫn sẽ đúng nhưng rất dài dòng.

Một cách gọn nhất trong trường hợp này là ta sẽ tạo ra mảng có 10 phần tử để index tương ứng từ 0 đến 9, và nó cũng tương ứng là các chữ số từ 0 đến 9 luôn. Như thế a[i] hiểu là số lần xuất hiện của chữ số $i$. Ví dụ a[3] = 5 thì ta hiểu chữ số 3 xuất hiện 5 lần. Ban đầu mảng 10 phần tử khởi tạo tất cả giá trị là 0. Rồi cứ mỗi chữ số phân tích ra được từ số $i$ thì sẽ gọi a[digit]++;, tức là tăng thêm 1 tại vị trí a[digit]. Sau cùng thì ta duyệt vòng lặp từ 0 đến 9 và in ra các giá trị của mảng tại các vị trí đó thì đó là dãy đáp án.

Tách từng chữ số của một số

Về việc với từng số $i$ tiến hành đi phân tích ra từng chữ số của nó thì cái này quá cơ bản nhưng mình cũng nói luôn kẻo bạn nào không biết: với 1 số thì ta chia lấy dư cho 10 để lấy ra được chữ số cuối cùng của nó, ví dụ: 123 % 10 = 3. Rồi để lấy ra được chữ số tiếp theo thì ta phải loại bỏ đi chữ số mà ta vừa lấy ra bằng cách lấy số đó chia nguyên cho 10, ví dụ: 123 / 10 = 12 (vì cả 2 đều đang là số nguyên nên phép chia 2 số nguyên cho nhau kết quả sẽ ra số nguyên, chứ nếu 1 trong 2 mà là số thực thì kết quả phép chia sẽ là 12.3).

Tách chữ số của 123 (lấy dần từ cuối lên)
123% 10 = 3/ 10 = 12
12% 10 = 2/ 10 = 1
1% 10 = 1/ 10 = 0
0x = 0 → dừng
Lấy được các chữ số 3, 2, 1 → a[3]++, a[2]++, a[1]++.

Rồi số còn lại là 12 thì ta lại lặp lại quy trình trên: 12 % 10 = 2, 12 / 10 = 1, 1 % 10 = 1, 1 / 10 = 0. Đến khi số là 0 rồi thì ta dừng quy trình vì từ bây giờ nó sẽ cứ lặp vô tận 0 % 10 = 0, 0 / 10 = 0, …

Source code để các bạn tham khảo với hướng làm mình trình bày ở trên:

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

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int t;
    cin >> t;
    while(t--){
        int n;
        cin >> n;
        int a[10] = {0};
        for(int i = 1; i <= n; ++i){
            int x = i;
            while(x != 0){
                a[x % 10]++;
                x /= 10;
            }
        }
        for(int i = 0; i < 10; ++i){
            cout << a[i] << " ";
        }
        cout << endl;
    }
    return 0;
}
import sys
input = sys.stdin.readline

t = int(input())
for _ in range(t):
    n = int(input())
    a = [0] * 10
    for i in range(1, n + 1):
        x = i
        while x != 0:
            a[x % 10] += 1
            x //= 10
    print(*a)
C++▶ Chạy tay từng dòng codeCách 1 (vét cạn): mỗi bộ test đếm lại chữ số của 1..N bằng % 10 và / 10, O(T · N · log N).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 ios::sync_with_stdio(0);
6 cin.tie(0);
7 int t;
8 cin >> t;
9 while(t--){
10 int n;
11 cin >> n;
12 int a[10] = {0};
13 for(int i = 1; i <= n; ++i){
14 int x = i;
15 while(x != 0){
16 a[x % 10]++;
17 x /= 10;
18 }
19 }
20 for(int i = 0; i < 10; ++i){
21 cout << a[i] << " ";
22 }
23 cout << endl;
24 }
25 return 0;
26}

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

  1. 2 dòng đầu tiên của hàm main (ios::sync_with_stdio(false); cin.tie(0);): hiểu cơ bản là nó tăng tốc luồng nhập xuất của C++ (tắt đồng bộ với C và bỏ ràng buộc giữa cin và cout), giúp cin/cout chạy nhanh ngang cú pháp nhập xuất của C. Một kinh nghiệm trong các bài cần nhập xuất nhiều, ví dụ như bài này ta thấy có tối đa $T = 1000$ bộ test, nghĩa là ta sẽ cần xuất cả $10 \cdot 1000 = 10{,}000$ số (vì mỗi giá trị $N$ sẽ xuất 10 số), thì ta hãy nên để 2 dòng này vào để chạy nhanh hơn. 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é.
  2. while(t--) là viết gọn của "kiểm tra t != 0 rồi giảm t", tức là vòng lặp chạy đủ $t$ lần. Trong lập trình khi gọi 1 biến ra trong câu lệnh điều kiện mà không có so sánh gì, các bạn hiểu là đang so sánh khác 0 nhé, tức là: if(x) ⇔ if(x != 0). Còn if(!x) ⇔ if(x == 0).
  3. int a[10] = {0}; nghĩa là khởi tạo mảng a 10 phần tử đều mang giá trị 0 hết. Nói chính xác hơn thì bản chất khi ta khởi tạo mảng = {0}; thì nghĩa là gán giá trị 0 cho ô đầu tiên của mảng (a[0]), và nguyên tắc là chỉ cần có danh sách khởi tạo thì toàn bộ các ô còn lại không được ghi rõ sẽ mặc định giá trị 0. Nên nếu ô đầu tiên cũng được khởi tạo là 0 thì toàn bộ mảng đều có giá trị 0 hết nha các bạn.
  4. Gán x = i: với mỗi giá trị $i$ trong vòng lặp for thì ta phải gán nó qua biến $x$ rồi từ đó đi phân tích biến $x$, chứ ta không làm trực tiếp trên biến $i$, bởi vì thao tác chia nguyên cho 10 sẽ làm thay đổi giá trị biến $i$. Khi đó vòng for sẽ bị sai lệch: $i$ bị chia về 0, rồi ++i thành 1, cứ thế $i$ luôn quanh quẩn ở giá trị 1 và vòng lặp for sẽ bị lặp vô tận.
  5. Khởi tạo mảng bên trong vòng lặp T: bước int a[10] = {0}; ta phải để bên trong vòng lặp $T$ để với mỗi bộ test đọc vào sẽ reset lại mảng toàn giá trị 0, chứ nếu ta mà để nó bên trên vòng lặp $T$ thì các giá trị chữ số cứ thế sẽ bị cộng dồn liên tục sẽ bị sai nha các bạn.

Đánh giá độ phức tạp

Không gian$O(1)$ – mảng 10 phần tử
Thời gian$O(T \cdot N \cdot \log N)$ → TLE

Vòng lặp while chạy $T$ lần để đọc $T$ bộ test, với mỗi bộ test đọc vào giá trị $N$ thì sẽ khởi tạo mảng 10 phần tử 0, sau đó có vòng lặp for $i$ chạy từ 1 tới $N$, với mỗi giá trị $i$ lại chạy đủ bấy nhiêu lần số lượng chữ số của $i$ để phân tích ra. Sau cùng thì chạy vòng lặp 10 lần để in ra kết quả của test case đó. Vậy nên độ phức tạp sẽ là $O(T \cdot (10 + N \cdot \log X + 10))$, với $X$ là các số từ 1 đến $N$. Theo quy tắc cộng Big O thì giữa các thành phần cộng với nhau sẽ lấy theo thành phần lớn nhất nên ta có thể kết luận độ phức tạp là $O(T \cdot N \cdot \log X)$.

Trường hợp xấu nhất: TLE
$T = 10^3$, mọi giá trị $N$ đọc vào đều là lớn nhất $N = 10^6$, $\log X$ cứ tính luôn là 6 cho tất cả. Số lần lặp sẽ là $10^3 \cdot 10^6 \cdot 6 = 6 \cdot 10^9$, tức là 6 tỷ lần lặp, vượt ngưỡng tối đa được phép (khoảng $10^8$) cả 60 lần, tức là phải cần cả 60 giây (1 phút) thì nó mới chạy in ra kết quả, trong khi đó đề yêu cầu chỉ được 1 giây thôi. Nên cách làm ở trên là toang rồi nha các bạn.

Tuy nhiên nếu bạn nào chưa nghĩ ra cách làm tối ưu thì cứ cài đặt theo cách bất kỳ, các bạn có thể giống như cách này chẳng hạn. Thậm chí theo mình là nên cài đặt theo cách này, để tuy nó chạy chậm nhưng nó luôn đảm bảo ra cho mình kết quả chính xác, từ đó ta có thể lấy nó làm công cụ kiểm tra tính chính xác cho một cách làm tối ưu khác tiếp theo (vét cạn + sinh test ngẫu nhiên rồi so sánh 2 cách).

Ý tưởngĐừng tính lại: a[N] = a[N − 1] + các chữ số của N

Lưu lại kết quả đã tính?

Giờ ta cần tìm ra một cách làm tốt hơn cho bài này. Ta cần nhìn thấy cách làm ở trên có cái dở là các giá trị $N$ nếu bị trùng nhau thì ta vẫn cứ phải đi tính lại. Tức là nếu giả sử trước đó ta đã xử lý tính ra kết quả của trường hợp $N = 10^6$ rồi, nếu bộ test tiếp theo đọc vào lại gặp $N = 10^6$ thì cách làm ở trên vẫn cứ đi xử lý lại, trong khi đó rõ ràng ta không cần phải xử lý lại vì ta đã tính ra trước đó kết quả của trường hợp này rồi mà. Vậy nên rõ ràng một cách để tốt hơn đó là ta có thể lưu trữ lại các kết quả $N$ đã tính, để mỗi giá trị $N$ mới ta có thể kiểm tra ngay trong $O(1)$ (nhờ vào kỹ thuật mảng đánh dấu) xem kết quả của trường hợp $N$ đó mình đã từng tính chưa. Nếu tính rồi thì ta lấy ra kết quả in ra thôi, khỏi phải đi xử lý.

Tuy nhiên việc tối ưu ở trên cũng chưa thực sự giải quyết được vấn đề. Giả sử $T$ bộ test toàn bộ đều là các giá trị phân biệt khác nhau thì với mỗi giá trị ta vẫn đều phải đi tính như bình thường. Ví dụ khi $T = 1000$ và các giá trị $N$ lần lượt từ $10^6$ trở về $999{,}001$ ($N = 1{,}000{,}000$, $N = 999{,}999$, $N = 999{,}998$, …) thì mọi giá trị $N$ ta đều phải đi tính chứ không có kết quả sẵn. Lúc này cứ căn cứ $N$ theo giá trị nhỏ nhất thì số lần lặp vẫn cỡ $1000 \cdot 999{,}000 \cdot 6 \approx 6$ tỷ lần lặp. Như thế cũng vẫn sẽ bị TLE thôi vì đã quá 100 triệu.

Mấu chốt: kết quả của N nằm sẵn trong kết quả của N − 1

Ta phải nhìn ra mấu chốt như sau: có phải nếu ta đã có kết quả thống kê số lượng các chữ số từ 0 đến 9 của trường hợp $N = 100$ rồi, thì qua trường hợp $N = 101$ tính ra nó vẫn sẽ là kết quả của trường hợp $N = 100$, chỉ đi phân tích thêm số 101 được thêm 1 chữ số 0 và 2 chữ số 1, lấy nó cộng thêm vào kết quả đã có của $N = 100$ là ta có kết quả của trường hợp $N = 101$ đúng không các bạn?

0123456789
a[100]11212020202020202020
+ số 101+1+2
a[101]12232020202020202020

Tức là nếu trường hợp $N = 100$ ta đã có dãy kết quả là 11 21 20 20 20 20 20 20 20 20 thì với trường hợp $N = 101$ ta sẽ cộng thêm vào dãy đó ở chữ số 0 thêm 1 đơn vị và ở chữ số 1 thêm 2 đơn vị, ta sẽ có dãy kết quả mới của trường hợp $N = 101$ là 12 23 20 20 20 20 20 20 20 20. Tại sao lại như vậy? Vì với $N = 100$ nghĩa là thống kê các chữ số từ 1 đến 100, với $N = 101$ nghĩa là thống kê các chữ số từ 1 đến 101, thì tính ra trường hợp $N = 101$ chỉ là có thêm số 101, còn lại từ số 1 đến 100 thì y như trường hợp $N = 100$.

Công thức chuyển
$a[N] = a[N - 1] + (\text{các chữ số của } N)$, với $a[0]$ là dãy 10 số 0. Tính lần lượt $a[1], a[2], \ldots, a[10^6]$ một lần duy nhất, sau đó mỗi bộ test chỉ việc in ra $a[N]$.

Ở trên là điểm mấu chốt, khi ta đã nhìn ra được rồi thì sẽ dễ dàng nhìn ra tư tưởng giải quyết cho bài này. Để tính ra trường hợp $N$ thì ta cần có kết quả từ trường hợp $N - 1$. Ta tiếp tục thấy $N$ lớn nhất là $10^6$ và nhỏ nhất là 1 theo như giới hạn đề bài. Vậy có phải ta đi tính ra các kết quả của các trường hợp từ 1 đến $10^6$ thì ta chỉ chạy vòng lặp đủ $10^6$ lần là có đủ kết quả các trường hợp $N$ từ 1 đến $10^6$ đúng không các bạn? Bước xử lý phân tách các chữ số của $N$ cứ cho rằng lớn nhất là có 6 chữ số thì bước này lặp 6 lần, và mỗi kết quả là dãy 10 số sẽ được gán qua cho trường hợp mới, nên cả thảy ta chỉ tính $10^6 \cdot (10 + 6) = 1.6 \cdot 10^7$ lần lặp và lưu trữ hết vào trong mảng 2 chiều a có $10^6$ dòng, 10 cột. Rồi sau đó chạy vòng lặp $T$ bộ test, với mỗi bộ test đọc vào số $N$ thì ta gọi a[N] chính là dãy 10 kết quả của trường hợp $N$.

Như thế thì số lần lặp cả thảy của bài chỉ là $1.6 \cdot 10^7 + T \cdot 10$, với trường hợp xấu nhất $T = 1000$ thì số lần lặp sẽ là $16{,}010{,}000$ (16 triệu + 10 ngàn) lần lặp. Như thế này sẽ yên tâm không bị TLE vì nó nhỏ hơn ngưỡng giới hạn 100 triệu, cụ thể ít hơn cả 6 lần. Việc tạo mảng 2 chiều a có $10^6$ dòng, 10 cột là cách dùng kỹ thuật mảng đánh dấu để sau đó mỗi khi gặp giá trị $N$ ta chỉ cần gọi a[N] trong $O(1)$ là ra kết quả ngay lập tức.

Mô phỏng ý tưởng

Em nhập $N$ (tối đa 60) rồi bấm Tiếp để xem từng dòng $a[i]$ được chép từ $a[i - 1]$ rồi cộng thêm các chữ số của $i$:

Ví dụ nhanh:

Cách 2Tính trước a[1..10⁶] – AC

Source code để các bạn tham khảo với hướng làm mình trình bày ở trên:

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

void PrintArray(const vector<int> &x){
    for(int i = 0; i < (int)x.size(); ++i){
        cout << x[i] << " ";
    }
    cout << endl;
}

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    vector<vector<int>> a(1000001, vector<int>(10, 0));
    for(int i = 1; i <= 1000000; ++i){
        a[i] = a[i - 1];
        int x = i;
        while(x != 0){
            a[i][x % 10]++;
            x /= 10;
        }
    }
    int t;
    cin >> t;
    while(t--){
        int n;
        cin >> n;
        PrintArray(a[n]);
    }
    return 0;
}
import sys
input = sys.stdin.readline

def PrintArray(x):
    print(*x)

# precompute
a = [[0] * 10 for _ in range(1_000_001)]
for i in range(1, 1_000_001):
    # copy previous row
    a[i] = a[i - 1].copy()
    x = i
    while x != 0:
        a[i][x % 10] += 1
        x //= 10

t = int(input())
for _ in range(t):
    n = int(input())
    PrintArray(a[n])
C++▶ Chạy tay từng dòng codeCách 2: tính trước a[i] = a[i − 1] + các chữ số của i cho mọi i, mỗi bộ test chỉ in a[N].
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4void PrintArray(const vector<int> &x){
5 for(int i = 0; i < (int)x.size(); ++i){
6 cout << x[i] << " ";
7 }
8 cout << endl;
9}
10
11int main(){
12 ios::sync_with_stdio(0);
13 cin.tie(0);
14 vector<vector<int>> a(1000001, vector<int>(10, 0));
15 for(int i = 1; i <= 1000000; ++i){
16 a[i] = a[i - 1];
17 int x = i;
18 while(x != 0){
19 a[i][x % 10]++;
20 x /= 10;
21 }
22 }
23 int t;
24 cin >> t;
25 while(t--){
26 int n;
27 cin >> n;
28 PrintArray(a[n]);
29 }
30 return 0;
31}
Lưu ý về bản Python
Code C++ thì AC nhưng code Python ở trên nộp sẽ bị TLE/MLE. Lý do Python khả năng chạy không được nhanh như C++ nên việc lặp qua cả chục triệu lần là không khả thi trong 1 giây; thêm nữa 1 triệu list Python, mỗi list 10 số tốn rất nhiều bộ nhớ (mình đo được khoảng 1.8 giây và gần 300 MB).

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

  1. vector<vector<int>> a(1000001, vector<int>(10, 0)); nghĩa là khởi tạo vector 2 chiều a có 1,000,001 dòng và mỗi dòng có 10 cột mang giá trị mặc định là 0. Lý do phải khởi tạo 1,000,001 dòng là để index chạy từ 0 đến 1,000,000 (do index tính từ 0), coi như ta bỏ qua không dùng dòng index 0, chỉ dùng từ 1 đến 1 triệu. Ta làm thế vì đề cho $N$ tính từ 1, nên để thống nhất tránh cứ phải rắc rối tính $N - 1$, tốn thêm 10 ô nhớ mà không bị rối rắm có thể dẫn đến sai. (Dòng 0 toàn số 0 cũng chính là điểm xuất phát $a[0]$ cho công thức a[i] = a[i - 1].)
  2. Vì sao dùng vector thay vì mảng thường? Có nhiều lý do. Đầu tiên mảng cần có đến cả 10 triệu phần tử thì không thể khai báo int a[1000001][10] bên trong hàm main được vì sẽ bị tràn bộ nhớ stack, phải dùng con trỏ cấp 2 để cấp phát mảng động, hoặc nếu không thì đưa mảng ra ngoài hàm main làm biến toàn cục thì mới được. Còn nếu trong hàm main mà để int a[1000001][10] các bạn thử chạy trên máy đi sẽ thấy bị tắt chương trình luôn nhé. Tiếp theo đó do ta cần liên tục copy dãy 10 số kết quả của trường hợp $N - 1$ sang trường hợp $N$, nếu dùng vector sẽ rất tiện, chỉ cần làm phép gán a[N] = a[N - 1] thì nó sẽ tự lấy từng phần tử của mảng này gán qua từng phần tử của mảng kia. Nếu dùng mảng thường thì ta phải làm vòng lặp chạy 10 lần để gán qua. Nói chung là gọn hơn được chút xíu ở khâu cài đặt ^^
  3. Hàm PrintArray(const vector<int> &x): để cho gọn thì việc in ra dãy 10 số kết quả ta viết hàm PrintArray nhận tham số đầu vào là vector 1 chiều rồi in ra giá trị các phần tử. Bình thường lẽ ra chỉ cần là vector<int> x thôi đúng không các bạn? Nhưng tại sao lại có thêm const &? Khi truyền tham chiếu (có &) thì sẽ không phải tạo bản sao dữ liệu; ở đây dữ liệu là mảng 10 phần tử thì có & sẽ không phải sao chép 10 phần tử mỗi lần gọi, mà ta có $T$ tối đa 1000 lần nên sẽ tiết kiệm cả 10,000 câu lệnh gán. Còn có thêm const là vì nếu để tham chiếu &, lỡ ta vô tình thay đổi giá trị của nó bên trong hàm (trong khi hàm chỉ cần xuất ra giá trị đâu cần thay đổi gì) thì nó bị thay đổi lại luôn dưới hàm main. Từ khoá const ràng buộc để nếu ta vô tình thay đổi giá trị thì trình biên dịch sẽ báo lỗi ngay, vì const xem đó là hằng không thể thay đổi giá trị được.

Đánh giá độ phức tạp

Không gian$O(10 \cdot 10^6)$
Thời gian$O(10^6 \cdot (10 + 6) + 10 \cdot T)$

Không gian: bộ nhớ cố định 10 triệu phần tử kiểu int → 40 triệu byte (vì int là 4 byte) $/ 1024 \approx 39{,}062.5$ KB $/ 1024 \approx 38.14$ MB. Trong khi đó đề cho giới hạn 128 MB nên ngưỡng ta xài ít hơn cả 3 lần, yên tâm ta sẽ không bị lỗi MLE (Memory Limit Exceeded: quá giới hạn bộ nhớ). (Thực tế mỗi dòng vector còn tốn thêm phần quản lý nên mình đo được khoảng 71 MB, vẫn dư sức.)

Thời gian: như đã phân tích ở cuối phần ý tưởng, khoảng $1.6 \cdot 10^7 + 10 \cdot T$ phép tính.

Ghi chú kiểm tra của mình: cả 2 code C++ và bản Python cách 1 được so với code vét cạn (ghép chuỗi "123…N" rồi đếm từng chữ số) trên 200 bộ test ngẫu nhiên – khớp hết; bản Python cách 2 được so trên 1 bộ 300 test có $N$ tới $10^6$ – khớp. Với test lớn nhất ($T = 1000$, $N$ từ $999{,}001$ đến $10^6$): cách 1 (C++) chạy khoảng 9.6 giây → TLE đúng như phân tích; cách 2 (C++) chạy khoảng 0.1 giây, dùng khoảng 71 MB; cách 2 (Python) khoảng 1.8 giây và gần 300 MB.
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 😇

Ma trận xoắn ốc 2

Ma trận xoắn ốc 2
Nguồn: Codeforces Group Độ khó: Trung bình Ma trận Tìm quy luật Tính trước

Đề bài

Khi học về ma trận (mảng 2 chiều có $M$ hàng, $N$ cột), thầy sẽ ra cho các bạn 1 bài về ma trận xoắn ốc. Ma trận xoắn ốc là một ma trận vuông có $M = N$, được sinh ra khi biết kích thước $N$. Với số 1 ở trung tâm ma trận, di chuyển theo hình xoắn ốc ngược chiều kim đồng hồ, rồi lần lượt điền các số tăng dần 1, 2, 3, 4, 5 …

Một số ví dụ về ma trận xoắn ốc:

Với N = 2
43
12
Với N = 3
543
612
789
Với N = 4
16151413
54312
61211
78910
Với N = 5
1716151413
1854312
1961211
2078910
2122232425

Việc sinh ra ma trận xoắn ốc trở nên nhàm chán, thầy quyết định các bạn không cần phải sinh ra ma trận này làm gì. Thầy chỉ muốn biết tổng của các số trên 2 đường chéo chính của ma trận xoắn ốc là bao nhiêu. Ví dụ:

NTổng các số trên 2 đường chéo
21 + 3 + 2 + 4 = 10
35 + 1 + 9 + 7 + 3 = 25
416 + 4 + 2 + 10 + 13 + 3 + 1 + 7 = 56
517 + 5 + 1 + 9 + 25 + 21 + 7 + 3 + 13 = 101

Vì tổng này quá lớn, các kiểu dữ liệu cơ bản không thể chứa được, thầy chỉ cần các bạn tính được tổng này mod $(10^9 + 9)$ là được.

Input

  • Dòng 1: số $T$ là số test case ($1 \le T \le 10^5$).
  • Dòng 2 đến $T + 1$: chứa số $N$ ($1 \le N \le 10^7$).

Output: gồm $T$ dòng, dòng thứ $i$ chứa kết quả tương ứng với test case thứ $i$.

Subtask

  • 20% số test đầu tiên có $T \le 10$, $N \le 1000$.
  • 20% số test tiếp theo có $T \le 10^3$, $N \le 8000$.
  • 60% số test còn lại không có giới hạn gì thêm.
Ví dụ – input
5
2
3
4
5
10000000
Ví dụ – output
10
25
56
101
679604006

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 😇.

Dành cho học viên · Ưu tiênNộp bài trên hệ thống CLUE OJ

Link này chỉ học viên đã tham gia tổ chức mới vào được. Học viên nên ưu tiên nộp ở đây vì sẽ được chấm nhanh hơn.

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

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 Codeforces

Bài này cách vét cạn có thể lấy được 40% điểm khi mà tạo ra cái ma trận xoắn ốc theo quy luật đề mô tả, không khó để làm được. Bây giờ anh sẽ tập trung vào cách làm tối ưu để full 100% điểm nhé.

Nháp ra giấy và tìm quy luật

Lời khuyên là em hãy nháp ra giấy vẽ hình ma trận xoắn ốc với các trường hợp $N$ từ 2 đến 6 thôi là được rồi; với mỗi trường hợp em ghi ra dãy các số nằm trên 2 đường chéo chính và chéo phụ (các ô màu cam bên dưới) và hãy ghi nó theo thứ tự giá trị tăng dần:

Với N = 2
43
12
Với N = 3
543
612
789
Với N = 4
16151413
54312
61211
78910
Với N = 5
1716151413
1854312
1961211
2078910
2122232425

Em sẽ nhìn ra quy luật như sau (số màu xanh là 4 số mới thêm vào so với trường hợp $N - 2$):

NDãy số trên 2 đường chéo (tăng dần)Tổng
21 2 3 410
31 3 5 7 925
41 2 3 4 7 10 13 1656
51 3 5 7 9 13 17 21 25101
61 2 3 4 7 10 13 16 21 26 31 36170

Nhìn kỹ em sẽ thấy rõ quy luật của nó, nếu vẫn chưa thấy được thì xem tiếp anh sẽ chỉ ra cho em thấy rõ. Hãy chú ý đến 2 trường hợp khởi đầu: trường hợp $N$ chẵn ($N = 2$) và trường hợp $N$ lẻ ($N = 3$). Từ 2 dãy này ta sẽ có các quy luật cho các trường hợp tiếp theo như sau:

  • Trường hợp $N = 4$ sẽ là dãy các số của trường hợp $N = 2$ và thêm 4 giá trị mới.
  • Trường hợp $N = 5$ sẽ là dãy các số của trường hợp $N = 3$ và thêm 4 giá trị mới.
  • Trường hợp $N = 7$ sẽ là dãy các số của trường hợp $N = 5$ và thêm 4 giá trị mới.

Vậy nghĩa là ta cần có được dãy số của trường hợp trước đó của nó (phân rõ theo chẵn/lẻ: nếu trường hợp hiện tại $N$ đang là lẻ thì nghĩa là xét trường hợp $N$ lẻ gần nhất trước đó, và dãy hiện tại sẽ bằng dãy trước đó với thêm 4 số mới; tương tự như vậy với trường hợp $N$ chẵn).

Tìm 4 số mới

Vấn đề 4 số mới của trường hợp $N$ hiện tại cần tìm ra nó bằng quy luật thế nào? Để ý em sẽ thấy quy luật như sau: ví dụ khi $N = 2$ thì dãy là 1 2 3 4. Vậy khi $N = 4$ là trường hợp chẵn tiếp theo thì số cuối cùng trong 4 số sẽ là $N^2$ $= 4^2 = 16$. Khi đã xác định được số cuối cùng trong 4 số thì việc tìm ra 3 số đứng trước nó khá đơn giản, lúc này chỉ là lần lượt lấy giá trị số cuối cùng tìm được trừ đi cho $(N - 1)$ liên tiếp 3 lần là ra.

N = 4
4² = 16
16 − 3 = 13
13 − 3 = 10
10 − 3 = 7
Dãy: 1 2 3 4 7 10 13 16
N = 5
5² = 25
25 − 4 = 21
21 − 4 = 17
17 − 4 = 13
Dãy: 1 3 5 7 9 13 17 21 25

Hoặc đơn giản nhất ta lấy số cuối cùng của trường hợp $N$ trước đó liên tục cộng dồn thêm 4 lần của $(N - 1)$. Ví dụ số cuối cùng của trường hợp $N = 2$ là số 4, vậy 4 số tiếp theo sẽ là: 4 + 3 = 7, 7 + 3 = 10, 10 + 3 = 13, 13 + 3 = 16.

Giờ đây khi đã nhìn ra quy luật tạo dãy số này rồi thì em thử suy nghĩ xem cần phải code thế nào cho dễ dàng đơn giản nhất mà hiệu suất chạy tối ưu, không bị quá thời gian với độ lớn dữ liệu đề bài đưa ra. Lúc này đây cũng sẽ phải tư duy suy nghĩ thêm nhiều đấy nhé 😉, thử sức xem, nếu không được thì xem tiếp phần dưới anh sẽ nói nốt luôn.

Em nhập $N$ rồi bấm Tiếp để xem ma trận lớn dần từ trong ra ngoài, mỗi lần N tăng 2 thì chỉ có thêm đúng 4 số ở 4 góc:

Ví dụ nhanh:
Ô mờ: phần chưa xét · Ô xanh: đường chéo của ma trận cũ (N − 2) · Ô cam: 4 góc mới thêm vào.

Tính trước toàn bộ kết quả

Với những phân tích ở trên thì thấy rằng để tính được giá trị của trường hợp $N$ cần tính được trước đó trường hợp $N - 2$: tính tổng dãy số của trường hợp $N - 2$ rồi lấy kết quả tổng đó cộng thêm tổng của 4 số mới của trường hợp $N$ hiện tại là ra kết quả hiện tại cần tìm của trường hợp $N$:

res[N] = res[N − 2] + N² + (N² − (N−1)) + (N² − 2(N−1)) + (N² − 3(N−1))

Vậy nhìn thấy nếu theo công thức này để tính được trường hợp $N$ hiện tại thì cần tính được các trường hợp $N - 2$, $N - 4$, $N - 6$ … trước đó. Như thế nếu với $T$ bộ test, cứ với mỗi trường hợp $N$ ta lại đi tính từ đầu đến $N$ và lặp đi lặp lại quy trình này $T$ lần, thì như vậy đó sẽ không phải cách làm tốt vì cứ bị tính đi tính lại nhiều lần không cần thiết, và có thể dẫn đến thuật toán bị chạy quá giới hạn thời gian cho phép. Ví dụ đầu tiên $N = 10$ thì ta tính qua các trường hợp $N = 2, 4, 6, 8$ rồi mới có kết quả tính ra $N = 10$. Nhưng tiếp đó lại có yêu cầu tính $N = 8$ thì lúc này đây ta lại đi tính các trường hợp $N = 2, 4, 6$, và em có thấy rõ ràng những trường hợp này mình đã tính trước đó ở trường hợp $N = 10$ rồi không? Nên làm như thế này sẽ không tốt.

Giải pháp xử lý tốt hơn đó là với phạm vi dữ liệu đề bài cho, trường hợp $N$ lớn nhất là $10^7$. Như thế ta sẽ đi tính hết tất cả kết quả các trường hợp $N$ từ 2 đến $10^7$ và lưu trữ các kết quả đó vào trong mảng đáp án tại vị trí index tương ứng, ví dụ res[N] là đáp án của trường hợp $N$. Tạo ra được mảng res lưu trữ hết tất cả các tình huống như thế thì với mỗi bộ test với giá trị $N$ đọc vào chỉ cần gọi res[N] là lấy ra ngay lập tức đáp án chỉ trong $O(1)$ phép tính. Thực tế ta không cần tính đủ $10^7$ trường hợp vì đây chỉ là tình huống xấu nhất, chỉ cần tìm ra Max (số lớn nhất) trong các trường hợp dữ liệu đầu vào rồi tính đến Max là được.

Lưu ý vì dữ liệu kết quả tổng có thể rất lớn nên đề đã ghi rõ kết quả chỉ cần chia lấy dư cho $(10^9 + 9)$. Vậy nên em cũng phải biết phép toán modulo để giảm bớt độ lớn dữ liệu tránh bị tràn số. Chứ bạn nào không biết mà đi cộng tổng các phần tử trong dãy số lại hết sau đó mới chia lấy dư thì cũng chẳng giải quyết được gì cả, chúng ta phải chia lấy dư trong quá trình cộng tổng của các phần tử trong dãy nhé. Bạn nào nếu chưa biết thì có thể xem qua một số phép toán modulo cơ bản như sau:
(a + b) % c = ((a % c) + (b % c)) % c
(a − b) % c = ((a % c) − (b % c)) % c
(a * b) % c = ((a % c) * (b % c)) % c

Vậy nên nếu áp dụng theo tư tưởng giải thuật này thì độ phức tạp bước tạo ra mảng res là $O(\max N)$ với trường hợp lớn nhất $N = 10^7$. Sau đó $O(T)$ để xử lý $T$ bộ test, với trường hợp lớn nhất $T = 10^5$; còn với mỗi bộ test thì chỉ mất $O(1)$ để in ra res[N]. Vậy tổng quát độ phức tạp là $O(\max N + T)$. Trường hợp xấu nhất là khi $T = 10^5$, $N = 10^7$, lúc này sẽ là $O(10^7 + 10^5)$ và vẫn không lo bị TLE, vì nguyên tắc để không bị TLE thì số phép toán tối đa thực hiện trong ngưỡng $(3 \div 5) \times 10^7$, và với giới hạn này vẫn chưa bị vượt qua nên thuật toán này đảm bảo ổn.

Đây là code xử lý theo tư tưởng anh đã nói ở trên cho em tham khảo:

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

const long long M = 1000000009;
const int MAX = 1e7;
long long res[MAX + 10];

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);

    res[1] = 1;  // 1
    res[2] = 10; // 1 2 3 4
    res[3] = 25; // 1 3 5 7 9

    // vì tính i*i nên chắc ăn biến i nên là kiểu long long để không bị tràn dữ liệu
    for(long long i = 4; i <= MAX; i++){
        long long num1 = i * i;
        long long num2 = num1 - (i - 1);
        long long num3 = num2 - (i - 1);
        long long num4 = num3 - (i - 1);
        res[i] = (res[i - 2] + (num1 % M) + (num2 % M) + (num3 % M) + num4 % M) % M;
    }

    int t, x;
    cin >> t;
    while(t--){
        cin >> x;
        cout << res[x] << '\n';
    }
    return 0;
}
M = 1000000009
MAX = 10**7

res = [0] * (MAX + 10)

res[1] = 1   # 1
res[2] = 10 # 1 2 3 4
res[3] = 25 # 1 3 5 7 9

for i in range(4, MAX + 1):
    num1 = i * i
    num2 = num1 - (i - 1)
    num3 = num2 - (i - 1)
    num4 = num3 - (i - 1)
    res[i] = (res[i - 2] + (num1 % M) + (num2 % M) + (num3 % M) + (num4 % M)) % M

import sys
input = sys.stdin.readline

t = int(input())
for _ in range(t):
    x = int(input())
    print(res[x])
C++▶ Chạy tay từng dòng codeTính trước res[N] = res[N − 2] + 4 số ở 4 góc mới, mỗi test chỉ tra mảng.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4const long long M = 1000000009;
5const int MAX = 9;
6long long res[MAX + 10];
7
8int main(){
9 ios::sync_with_stdio(0);
10 cin.tie(0);
11
12 res[1] = 1; // 1
13 res[2] = 10; // 1 2 3 4
14 res[3] = 25; // 1 3 5 7 9
15
16 // vì tính i*i nên chắc ăn biến i nên là kiểu long long để không bị tràn dữ liệu
17 for(long long i = 4; i <= MAX; i++){
18 long long num1 = i * i;
19 long long num2 = num1 - (i - 1);
20 long long num3 = num2 - (i - 1);
21 long long num4 = num3 - (i - 1);
22 res[i] = (res[i - 2] + (num1 % M) + (num2 % M) + (num3 % M) + num4 % M) % M;
23 }
24
25 int t, x;
26 cin >> t;
27 while(t--){
28 cin >> x;
29 cout << res[x] << '\n';
30 }
31 return 0;
32}
Để chạy tay được, khung này đổi MAX = 1e7 thành MAX = 9 (dòng 5), nên test đề bỏ dòng N = 10^7; phần còn lại giữ nguyên code gốc.

Mình chạy thử với $T = 10^5$: bản C++ mất khoảng 0.1 giây. Bản Python thì phải tạo list $10^7$ phần tử và lặp $10^7$ lần nên mất khoảng 7.5 giây và tốn rất nhiều bộ nhớ; bạn nào code Python thì xem phần đọc thêm ngay dưới đây nhé.

O(1)Đọc thêm: công thức tính thẳng cho N bất kỳ

Lúc viết bài anh có nói "có thể sẽ có cách làm tốt hơn đi tính trực tiếp ra kết quả của trường hợp $N$ bất kỳ chỉ trong $O(1)$ bằng cách xác định đúng công thức toán học". Thì thực ra là có thật nha các em 😜. Cộng 4 số mới lại ta được $4N^2 - 6(N - 1)$, tức là mỗi bước cộng thêm $4N^2 - 6N + 6$. Cộng dồn các bước đó (dùng công thức tổng các bình phương) sẽ ra:

N lẻ
S = (4N³ + 3N² + 8N − 9) / 6
N chẵn
S = (4N³ + 3N² + 8N) / 6

Thử lại: $N = 3$: $(108 + 27 + 24 - 9) / 6 = 25$ ✓; $N = 4$: $(256 + 48 + 32) / 6 = 56$ ✓; $N = 5$: $(500 + 75 + 40 - 9) / 6 = 101$ ✓. Mình cũng đã so kết quả của công thức với code ở trên trên $10^5$ test ngẫu nhiên (kể cả $N = 10^7$), kết quả khớp hoàn toàn.

Chỉ có 1 chỗ cần cẩn thận: với $N = 10^7$ thì $4N^3$ lên tới $4 \times 10^{21}$, vượt kiểu long long, nên trong C++ ta phải tính tử số theo modulo rồi nhân với nghịch đảo modulo của 6 (vì $10^9 + 9$ là số nguyên tố nên nghịch đảo của 6 là $6^{10^9+7} \bmod (10^9+9)$, tính bằng lũy thừa nhanh). Còn Python thì số nguyên không giới hạn nên tính thẳng luôn:

Xem code công thức O(1)
#include <bits/stdc++.h>
using namespace std;

const long long M = 1000000009;

long long power(long long a, long long b){ // a^b % M
    long long res = 1;
    a %= M;
    while(b > 0){
        if(b & 1){
            res = res * a % M;
        }
        a = a * a % M;
        b >>= 1;
    }
    return res;
}

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    long long inv6 = power(6, M - 2); // nghịch đảo modulo của 6
    int t;
    cin >> t;
    while(t--){
        long long n;
        cin >> n;
        long long x = n % M;
        long long tu = (4 * (x * x % M) % M * x % M + 3 * (x * x % M) % M + 8 * x) % M;
        if(n % 2 == 1){
            tu = (tu - 9 + M) % M; // N lẻ thì trừ thêm 9
        }
        cout << tu * inv6 % M << '\n';
    }
    return 0;
}
import sys

def main():
    M = 1000000009
    data = sys.stdin.buffer.read().split()
    t = int(data[0])
    out = []
    for i in range(1, t + 1):
        n = int(data[i])
        if n % 2 == 1:
            s = (4 * n**3 + 3 * n**2 + 8 * n - 9) // 6
        else:
            s = (4 * n**3 + 3 * n**2 + 8 * n) // 6
        out.append(s % M)
    print("\n".join(map(str, out)))

main()
C++▶ Chạy tay từng dòng codeCông thức O(1): tính tử số theo modulo rồi nhân nghịch đảo của 6 (lũy thừa nhanh).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4const long long M = 1000000009;
5
6long long power(long long a, long long b){ // a^b % M
7 long long res = 1;
8 a %= M;
9 while(b > 0){
10 if(b & 1){
11 res = res * a % M;
12 }
13 a = a * a % M;
14 b >>= 1;
15 }
16 return res;
17}
18
19int main(){
20 ios::sync_with_stdio(0);
21 cin.tie(0);
22 long long inv6 = power(6, M - 2); // nghịch đảo modulo của 6
23 int t;
24 cin >> t;
25 while(t--){
26 long long n;
27 cin >> n;
28 long long x = n % M;
29 long long tu = (4 * (x * x % M) % M * x % M + 3 * (x * x % M) % M + 8 * x) % M;
30 if(n % 2 == 1){
31 tu = (tu - 9 + M) % M; // N lẻ thì trừ thêm 9
32 }
33 cout << tu * inv6 % M << '\n';
34 }
35 return 0;
36}
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 😇