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
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Số Không Giảm

Số Không Giảm
Nguồn: Bài tập học viên Độ khó: Dễ Vét cạn Mảng Sinh kế tiếp

Đề bài

SỐ KHÔNG GIẢM

Một số gọi là số không giảm nếu các chữ số từ trái qua phải tạo thành dãy không giảm (tức là không có chữ số nào phía sau nhỏ hơn chữ số ngay trước nó). Viết chương trình liệt kê các số không giảm có $N$ chữ số $(1 \lt N \lt 7)$.

Input:

  • Dòng đầu ghi số bộ test.
  • Mỗi bộ test ghi số $N$.

Output:

  • Với mỗi bộ test, ghi ra lần lượt các số không giảm có $N$ chữ số, các số cách nhau một khoảng trống.
  • Hết một bộ test thì xuống dòng.

Giới hạn thời gian: 1s. Giới hạn bộ nhớ: 65536 Kb.

Ví dụ – input
1
2
Ví dụ – output
11 12 13 14 15 16 17 18 19 22 23 24 25 26 27 28 29 33 34 35 36 37 38 39 44 45 46 47 48 49 55 56 57 58 59 66 67 68 69 77 78 79 88 89 99

Ghi chú: kết quả của một test được viết trên một dòng (ở đề gốc, bảng ví dụ chia thành nhiều dòng chỉ vì kích thước màn hình).

Lời mở đầu

Bài này là bài làm của một em học viên chưa được AC, em ấy không biết sửa làm sao cho đúng. Mình vừa sửa lỗi vừa góp ý cách làm tốt hơn cho em ấy, thấy cũng hay nên tổng hợp lại thành bài viết để anh em cùng tham khảo. Anh em cũng thử sức suy nghĩ bài này trước khi đọc tiếp nhé 💪.

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é:

Link nộp bài

Bấm vào nút bên dưới để nộp bài và được hệ thống CLUE OJ chấm tự động.

Nộp bài trên CLUE OJ

Giải thích ví dụ: với $N = 2$ ta cần mọi số có 2 chữ số $\overline{ab}$ mà $a \le b$. Chữ số đầu $a$ không thể là 0 (vì là số có 2 chữ số), mà $b \ge a$ nên mọi chữ số đều từ 1 đến 9. Do đó dãy bắt đầu từ 11, 12, ..., 19, rồi 22, ..., 29, rồi 33, ... và kết thúc ở 99 – tổng cộng 45 số. Các số như 10, 20, 21, 31, 32... bị loại vì chữ số sau nhỏ hơn chữ số trước.

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 các số có đúng N chữ số – O(9·10^(N−1)·N)

Bài làm ban đầu của em học viên

Đây là bài làm của em ấy (chưa được AC):

#include <stdio.h>
int soKhongGiam(long j){
    int next=j%10;
    int total=0;
    while(j>0){
        int last=j%10;
        if(last<=next){
            next=0;
            next+=last;
            total++;
        }
        j/=10;
    }
    return total;
}

int soChuSo(long j){
    int count=0;
    while(j>0){
        count++;
        j/=10;
    }
    return count;
}

int main () {
    int a;
    scanf("%d",&a);
    for(int i=1;i<=a;i++){
        int N;
        scanf("%d",&N);
        for(long j=10;j<=999999;j++){
            if(soChuSo(j)==N && soKhongGiam(j)==soChuSo(j)){
                printf("%ld ",j);
            }
        }
        printf("\n");
    }
    return 0;
}

Ý tưởng của em ấy: duyệt mọi số $j$ từ 10 đến 999999, số nào có đúng $N$ chữ số và "không giảm" thì in ra. Để kiểm tra không giảm, hàm soKhongGiam đi từ chữ số cuối lên, dùng biến total đếm số lần thằng đứng sau $\ge$ thằng đứng trước; nếu cuối cùng total bằng số lượng chữ số thì toàn bộ các chữ số đều không giảm.

Tư tưởng này nó đúng nhưng mà tệ lắm, vì nó luôn phải "vét cạn" trong mọi tình huống. Có 2 chỗ lãng phí lớn:

  1. Hàm kiểm tra không dừng sớm. Ví dụ với số 54321, ngay lập tức ta thấy chữ số cuối cùng là 1 không thỏa $\ge$ chữ số trên nó là 2, lẽ ra ta kết thúc được ngay lúc này. Nhưng không, code của em vẫn xét tiếp cặp (2, 3), (3, 4), (4, 5) để sau cùng return total (lúc này là 0) rồi thấy 0 khác số lượng chữ số (là 5) ⇒ không in ra. Mình thêm 2 dòng else break; vào để gặp cặp sai là thoát luôn.
  2. Vòng for lúc nào cũng xét từ 10 đến 999999 kể cả khi $N$ chỉ là 2. Lẽ ra xét hết 99 là đủ rồi, đâu nhất thiết phải xét thêm từ 100 đến 999999 rồi gọi hàm đếm chữ số để thấy nó khác $N$. Mình chỉ cần xét từ pow(10, N-1) đến < pow(10, N). Ví dụ $N = 3$ thì pow(10, 2) = 100, pow(10, 3) = 1000, tức là xét từ 100 đến 999 – đúng các số có 3 chữ số.

Khi chỉ xét các số có đúng $N$ chữ số thì cũng không cần hàm soChuSo nữa: chỉ cần kiểm tra soKhongGiam(j) == N. Bởi vì khi nó bằng $N$ nghĩa là vòng lặp chạy đến cuối, mọi cặp đều thỏa số sau $\ge$ số trước; còn nếu khác $N$ thì chắc chắn đã bị break từ sớm. Sửa lại những chỗ đó là code của em được AC (0.94s).

Mình kiểm tra lại
Mình cho hàm soKhongGiam ban đầu của em ấy chạy thử với mọi số từ 10 đến 999999 thì kết quả kiểm tra vẫn đúng hết. Vậy chỗ chưa qua của bài làm ban đầu nhiều khả năng là do chạy quá chậm: mỗi bộ test đều phải duyệt gần 1 triệu số, mỗi số lại gọi hàm đếm chữ số tới 2 lần và hàm kiểm tra không dừng sớm.

Code gọn hơn của mình

Thực ra tư tưởng thì vẫn như nhau như hướng mình đã góp ý ở trên. Tuy nhiên cách thể hiện code của mình gọn hơn xíu: hàm KiemTraLaSoKhongGiam so sánh từng cặp chữ số từ dưới đi lên (vd: 12345 thì so sánh (5, 4), (4, 3), (3, 2), (2, 1)), nếu tại cặp nào mà số sau < số trước (theo đúng thì phải $\ge$) thì ngay lập tức return 0 kết thúc hàm luôn; còn nếu xét đủ hết các cặp thì kết thúc vòng lặp và return 1 xác nhận số này thỏa mãn. Ở hàm main tạo 2 biến start = pow(10, n-1) và end = pow(10, n), duyệt từ start đến < end, số nào thỏa thì in ra.

Source code để các bạn tham khảo nếu làm theo hướng này:

#include <stdio.h>
#include <math.h>

int KiemTraLaSoKhongGiam(long long x)
{
    long long ChuSoCuoi = x % 10;
    x /= 10;
    while (x != 0)
    {
        if (ChuSoCuoi < x % 10)
            return 0;

        ChuSoCuoi = x % 10;
        x /= 10;
    }
    return 1;
}

int main()
{
    long long t;
    scanf("%lld", &t);

    while (t--)
    {
        long long n;
        scanf("%lld", &n);

        long long start = pow(10, n - 1);
        long long end = pow(10, n);

        for (; start < end; start++)
        {
            if (KiemTraLaSoKhongGiam(start) == 1)
                printf("%lld ", start);
        }
        printf("\n");
    }

    return 0;
}
def kiem_tra_la_so_khong_giam(x):
    chu_so_cuoi = x % 10
    x //= 10
    while x != 0:
        if chu_so_cuoi < x % 10:
            return False
        chu_so_cuoi = x % 10
        x //= 10
    return True


t = int(input())
for _ in range(t):
    n = int(input())
    start = 10 ** (n - 1)
    end = 10 ** n
    res = []
    for x in range(start, end):
        if kiem_tra_la_so_khong_giam(x):
            res.append(str(x))
    print(' '.join(res))

Khung chạy tay (để dễ theo dõi, mình giới hạn $N = 2$ – 90 số từ 10 đến 99):

C++▶ Chạy tay từng dòng codeCách 1: duyệt start từ 10^(n-1) đến dưới 10^n, số nào không giảm thì in.
Dữ liệu mẫu:
1#include <stdio.h>
2#include <math.h>
3
4int KiemTraLaSoKhongGiam(long long x)
5{
6 long long ChuSoCuoi = x % 10;
7 x /= 10;
8 while (x != 0)
9 {
10 if (ChuSoCuoi < x % 10)
11 return 0;
12
13 ChuSoCuoi = x % 10;
14 x /= 10;
15 }
16 return 1;
17}
18
19int main()
20{
21 long long t;
22 scanf("%lld", &t);
23
24 while (t--)
25 {
26 long long n;
27 scanf("%lld", &n);
28
29 long long start = pow(10, n - 1);
30 long long end = pow(10, n);
31
32 for (; start < end; start++)
33 {
34 if (KiemTraLaSoKhongGiam(start) == 1)
35 printf("%lld ", start);
36 }
37 printf("\n");
38 }
39
40 return 0;
41}

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

  • Không gian: $O(1)$.
  • Thời gian: mỗi bộ test duyệt $9 \cdot 10^{N-1}$ số, mỗi số kiểm tra tối đa $N$ chữ số ⇒ $O(9 \cdot 10^{N-1} \cdot N)$. Với $N = 6$ là 900000 số.
Không gian$O(1)$
Thời gian$O(9 \cdot 10^{N-1} \cdot N)$
Chạy được nhưng sát giới hạn
Code của em sau khi sửa chạy 0.94s, còn code gọn hơn của mình chạy 0.77s – đều AC nhưng gần sát 1 giây rồi đó nha. Bài này mà cho giới hạn lớn hơn, ví dụ $N = 9$ chữ số, thì cách làm hiện tại sẽ bị TLE chắc luôn.

Cách 2Sinh kế tiếp kiểu "đồng hồ" bằng mảng – O(số lượng kết quả · N)

Ý tưởng chạy vẫn AC, tuy nhiên đây cũng chưa phải là ý tưởng tốt nhất 🙂. Ý tưởng tốt hơn là ta nhận thấy rõ quy luật của dãy số không giảm này. Ví dụ nhé: giả sử $N = 3$, tức là xét trong phạm vi các số có 3 chữ số; các chữ số từ trái qua phải mình ký hiệu là $a$, $b$, $c$ đi hen. Các bạn sẽ nhận ra điều gì?

  • Đầu tiên $a$, $b$, $c$ đều là 1 hết, tức là 111.
  • Sau đó tăng biến $c$ liên tục lên 1 đơn vị, tức là $c$ sẽ lần lượt là 2, 3, ..., 9; ứng với mỗi lần đó là in ra 112, 113, ..., 119.
  • Rồi khi $c$ đã là 9, nếu cộng tiếp nữa lên 10 thì nó sẽ kích hoạt tăng biến trước nó (là biến $b$) lên 1 đơn vị, tức là $b$ đang là 1 thì giờ thành 2, và $c$ sẽ bằng biến $b$ đó, tức là cũng là 2 ⇒ ta có 122.
  • Rồi lặp lại quy trình tăng $c$ lên 1 đơn vị, cứ thế đến khi nào nó là 10 thì lại tăng $b$ lên 1 rồi reset $c = b$. Cứ thế đến khi $b$ tăng lên đến 10 thì lại tăng $a$ lên 1 và reset lại $b = a$, $c = b$.
  • Cứ vậy đến khi nào $a = 10$ thì dừng, nghĩa là số cuối cùng là 999.
Giống hệt đồng hồ giờ:phút:giây
Bắt đầu một ngày mới đồng hồ là 00:00:00, rồi cứ thế giây tăng 1 đơn vị đến khi chạm 60 thì biến lại thành 0 và phút tăng 1 đơn vị; phút chạm 60 thì phút về 0 và giờ tăng 1. Bài này cũng giống quy luật như vậy, nhưng khác 1 xíu: khi $b$ tăng lên 1 đơn vị thì $c$ không reset về 0 mà reset bằng $b$ để đảm bảo số không giảm; khi $a$ tăng lên 1 thì $b$, $c$ đều reset về bằng $a$.

Như thế thì cách này đúng chất chỉ xét qua đúng 165 số thỏa mãn với trường hợp $N = 3$, trong khi cách 1 phải xét đủ từ 100 đến 999 tức là cả 900 số. Càng lên $N$ lớn thì chênh lệch càng nhiều:

$N$23456
Số không giảm (cách 2 duyệt)4516549512873003
Số có $N$ chữ số (cách 1 duyệt)90900900090000900000

Cài đặt thì gọn nhất là dùng mảng a[1..N] lưu $N$ chữ số (thay cho các biến $a$, $b$, $c$...). Để sinh số kế tiếp: từ phải sang trái tìm chữ số đầu tiên $a_k$ còn tăng được (tức là $a_k \ne 9$ – các chữ số bằng 9 phía sau chính là những "bánh xe" đã chạm 10); tăng $a_k$ lên 1, rồi reset mọi chữ số phía sau về bằng $a_k$. Nếu không còn chữ số nào tăng được ($k = 0$, tức là số $99..9$) thì dừng.

Minh hoạ "đồng hồ" số không giảm

Các bạn nhập $N$ rồi bấm Tiếp để xem từng số được sinh ra (vàng: chữ số vừa tăng, xanh: các chữ số được reset theo nó):

Ví dụ nhanh:

Source code để các bạn tham khảo nếu làm theo hướng này:

#include <stdio.h>

int main()
{
    int t;
    scanf("%d", &t);

    while (t--)
    {
        int n;
        scanf("%d", &n);

        int a[10];
        for (int i = 1; i <= n; i++)
            a[i] = 1;

        while (true)
        {
            for (int i = 1; i <= n; i++)
                printf("%d", a[i]);
            printf(" ");

            int k = n;
            while (k >= 1 && a[k] == 9)
                k--;
            if (k == 0)
                break;

            a[k]++;
            for (int i = k + 1; i <= n; i++)
                a[i] = a[k];
        }
        printf("\n");
    }

    return 0;
}
t = int(input())
for _ in range(t):
    n = int(input())
    a = [0] + [1] * n          # dùng a[1..n] cho giống bản C++
    res = []
    while True:
        res.append(''.join(map(str, a[1:])))
        k = n
        while k >= 1 and a[k] == 9:
            k -= 1
        if k == 0:
            break
        a[k] += 1
        for i in range(k + 1, n + 1):
            a[i] = a[k]
    print(' '.join(res))

Khung chạy tay (giới hạn $N \le 3$ để số bước không quá nhiều):

C++▶ Chạy tay từng dòng codeCách 2: mảng a[1..n] chạy như đồng hồ – tăng chữ số cuối chưa bằng 9, reset các chữ số sau bằng nó.
Dữ liệu mẫu:
1#include <stdio.h>
2
3int main()
4{
5 int t;
6 scanf("%d", &t);
7
8 while (t--)
9 {
10 int n;
11 scanf("%d", &n);
12
13 int a[10];
14 for (int i = 1; i <= n; i++)
15 a[i] = 1;
16
17 while (true)
18 {
19 for (int i = 1; i <= n; i++)
20 printf("%d", a[i]);
21 printf(" ");
22
23 int k = n;
24 while (k >= 1 && a[k] == 9)
25 k--;
26 if (k == 0)
27 break;
28
29 a[k]++;
30 for (int i = k + 1; i <= n; i++)
31 a[i] = a[k];
32 }
33 printf("\n");
34 }
35
36 return 0;
37}

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

  • Không gian: mảng $N$ phần tử – $O(N)$.
  • Thời gian: số lượng số không giảm có $N$ chữ số chính là số cách chọn $N$ chữ số từ 1 đến 9 có lặp (sắp xếp tăng dần), bằng $C_{N+8}^{8}$; mỗi số in ra mất $O(N)$ ⇒ $O(C_{N+8}^{8} \cdot N)$ – với $N = 6$ chỉ có 3003 số. Đây là mức tốt nhất có thể, vì đằng nào cũng phải in ra từng ấy số.
Không gian$O(N)$
Thời gian$O(C_{N+8}^{8} \cdot N)$
Học được gì từ bài này
Nếu chưa học đến mảng thì làm theo cách 1 là đủ để AC. Nhưng khi đã học đến mảng rồi thì các bạn hãy nhớ bài này mà quay lại áp dụng theo cách dùng mảng và xét quy luật như trên. Quan trọng nhất khi làm 1 bài là mình học được nhiều kiến thức từ nó 🙂.
Ghi chú của mình: code C++ và Python của cả 2 cách đã được kiểm tra với mọi $N$ từ 2 đến 6, so với danh sách sinh bằng tổ hợp lặp (combinations_with_replacement) – khớp hết, số lượng đúng 45, 165, 495, 1287, 3003; thêm 100 bộ test ngẫu nhiên nhiều $N$ cũng khớp. Với 100 bộ test $N = 6$: cách 1 (C++) chạy khoảng 0.39 giây, cách 2 (C++) khoảng 0.09 giây. Ở dòng for trong code của mình, mình viết for (; start < end; start++) thay cho for (start; ...) để trình biên dịch khỏi cảnh báo – ý nghĩa y hệt.
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 😇
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét