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 →

Trò Chơi Ghép Số (NUMTRANS)

Trò Chơi Ghép Số (NUMTRANS)
Nguồn: CLUE OJ Độ khó: Trung bình Sắp xếp Tham lam Xâu Nhận xét

Đề bài

NUMTRANS – Trò chơi ghép số

Tèo và Tí chơi thân với nhau, biết Tí học rất giỏi môn Toán nên một hôm Tèo đố Tí bài toán:

Cho $n$ số nguyên dương $a_1, a_2, \ldots, a_n$. Hãy ghép các số đó lại với nhau để được số nguyên dương lớn nhất.

Ví dụ: Với $3$ số $12$, $907$, $91$ ta có $6$ cách ghép được $6$ số là: $1290791$, $1291907$, $9071291$, $9079112$, $9112907$, $9190712$. Trong đó số lớn nhất là $9190712$.

Tất nhiên là bài toán này không thể làm khó Tí được nhưng vì còn bận học bài nên Tí muốn nhờ các bạn viết chương trình giải quyết bài toán trên.

Dữ liệu vào:

  • Dòng đầu chứa số nguyên dương $n$;
  • Dòng thứ hai chứa $n$ số nguyên dương $a_1, a_2, \ldots, a_n$. Hai số liên tiếp cách nhau một dấu cách.

Giới hạn: $1 \le n \le 10^5$; $1 \le a_i \le 10^5$.

Dữ liệu ra:

  • Ghi ra số nguyên dương lớn nhất ghép được.
Ví dụ – input
3
12 907 91
Ví dụ – output
9190712

Lời mở đầu

Các bạn luôn phải nhớ rằng mấy dạng bài kiểu này lúc nào cũng "không muốn" ta nhìn ra hướng giải quyết thực sự của nó, mà cố tình đánh lạc hướng chúng ta khỏi hướng làm tốt nhất. Thậm chí cả test ví dụ cũng cố tình đưa ra những trường hợp làm chúng ta lầm tưởng đi xử lý theo một hướng khác, nên các bạn phải tỉnh táo nhìn kỹ bản chất vấn đề nhé ☺️.

Với bài này, đề cố tình "lái" chúng ta liệt kê hết 6 cách ghép của 3 số – tức là đi theo hướng vét cạn hoán vị. Trong bài viết này mình sẽ đi từ hướng đó (để thấy vì sao không ổn), rồi tới nhận xét mấu chốt và lời giải tối ưu.

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

Mình giải thích ví dụ mẫu để bạn nào chưa hiểu đề sẽ hiểu rõ. Ta có 3 số 12 907 91, ta được quyền xếp chúng theo thứ tự bất kỳ rồi viết liền nhau (không được tách hay đảo chữ số bên trong mỗi số). 3 số thì có $3! = 6$ thứ tự:

Thứ tự ghépSố tạo thành
12 · 907 · 911290791
12 · 91 · 9071291907
907 · 12 · 919071291
907 · 91 · 129079112
91 · 12 · 9079112907
91 · 907 · 129190712 ← lớn nhất

Để ý: $907$ là số lớn nhất nhưng lại không đứng đầu! Vì $91\ldots$ bắt đầu bằng "91" còn $907\ldots$ bắt đầu bằng "90", nên đặt $91$ lên trước sẽ được số lớn hơn. Đây chính là cái bẫy của bài.

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 tất cả hoán vị – O(N!·N)

Làm đúng theo những gì đề "gợi ý": thử hết tất cả các thứ tự ghép, với mỗi thứ tự thì nối các số lại thành một xâu, rồi giữ lại xâu lớn nhất. Với $N$ số ta có $N!$ hoán vị.

Nhưng phép tính giai thừa tăng khủng khiếp:

N101213100$10^5$
N!3 628 800479 001 600≈ 6,2 tỷ158 chữ sốkhông tưởng

Giới hạn 1 giây tương ứng cỡ $(3 \text{ đến } 5) \cdot 10^7$, tối đa $10^8$ phép tính. Cỡ $12!$ thì may ra còn xoay xở, còn $13!$ là hơn 6 tỷ – vượt giới hạn rồi, nói chi $N$ tới $10^5$. Giả sử đề có cho 50% test với $N \le 10$ thì cách này vớt vát được 50% điểm, còn muốn 100% thì chắc chắn không thể chọn cách này.

Từ chết đến bị thương 😅
Thực ra kể cả khi $N$ tối đa chỉ là 10, mình cũng không khuyên làm cách này: nó dẫn các bạn tới bài toán sinh lần lượt các hoán vị – một bài kinh điển mà không khéo là xử lý rất rối. Trong khi cách tối ưu (Cách 3) còn ngắn gọn hơn nhiều! Ở đây mình dùng next_permutation của STL cho gọn, chỉ để các bạn thấy ý tưởng và so sánh.

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

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

int main(){
    int n;
    cin >> n;
    vector<string> a(n);
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    sort(a.begin(), a.end());
    string best = "";
    do{
        string s = "";
        for(int i = 0; i < n; ++i){
            s += a[i];
        }
        if(s > best){
            best = s;
        }
    }while(next_permutation(a.begin(), a.end()));
    cout << best;
    return 0;
}
from itertools import permutations

n = int(input())
a = input().split()
best = ""
for p in permutations(a):
    s = "".join(p)
    if s > best:
        best = s
print(best)

Lưu ý next_permutation chỉ duyệt đủ mọi hoán vị nếu ta bắt đầu từ thứ tự tăng dần, nên phải sort trước. Các bạn xem nó chạy qua đủ 6 hoán vị của ví dụ đề trong khung dưới đây:

C++▶ Chạy tay từng dòng codeCách 1: thử mọi hoán vị bằng next_permutation, giữ xâu ghép lớn nhất.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int main(){
5 int n;
6 cin >> n;
7 vector<string> a(n);
8 for(int i = 0; i < n; ++i){
9 cin >> a[i];
10 }
11 sort(a.begin(), a.end());
12 string best = "";
13 do{
14 string s = "";
15 for(int i = 0; i < n; ++i){
16 s += a[i];
17 }
18 if(s > best){
19 best = s;
20 }
21 }while(next_permutation(a.begin(), a.end()));
22 cout << best;
23 return 0;
24}

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

  • Không gian: $O(N \cdot L)$ để lưu các xâu, với $L \le 6$ là số chữ số tối đa của một số.
  • Thời gian: $N!$ hoán vị, mỗi hoán vị ghép mất $O(N \cdot L)$ ⇒ $O(N! \cdot N \cdot L)$. Chỉ dùng được khi $N$ cỡ 8–10.
Không gian$O(N)$
Thời gian$O(N! \cdot N)$

Cách 2Tìm ra quy tắc so sánh + Interchange Sort – O(N²)

Hãy thử suy nghĩ: nếu các giá trị $a_i$ không nằm trong đoạn $1 \to 10^5$ mà chỉ nằm trong đoạn $1 \to 9$, tức là chỉ có 1 chữ số, thì bài này có phải rất đơn giản không? Chỉ việc sắp xếp mảng giảm dần. Ví dụ mảng $\{5, 3, 1, 9, 8, 5, 3\}$ sắp giảm dần thành $\{9, 8, 5, 5, 3, 3, 1\}$ và đáp án là $9855331$.

Nhưng nếu áp dụng y chang quy tắc đó cho ví dụ $\{12, 907, 91\}$ thì ta được $\{907, 91, 12\}$ → $9079112$, trong khi đáp án đúng là thứ tự $\{91, 907, 12\}$. Nghĩa là trong bài này, "91 lớn hơn 907", rồi "907 lớn hơn 12". Vậy ta phải nhìn ra được quy tắc tụi nó so sánh với nhau để biết thằng nào đứng trước, thằng nào đứng sau. Nhìn ra đúng quy tắc này là mấu chốt của cả bài.

Quy tắc thực ra làm đúng theo luật của bài thôi: có 2 số $a$, $b$ cần so sánh (hiện tại $a$ đứng trước $b$), ta thử cả hai cách ghép ab và ba:

Quy tắc so sánh "ghép thử"
  • Nếu ab ≥ ba thì $a$ đứng trước $b$ là đúng rồi, không cần đổi chỗ.
  • Nếu ba > ab thì phải đổi chỗ, cho $b$ ra trước $a$.
Bài toán chỉ là sắp xếp mảng giảm dần, có điều "lớn hơn" không theo giá trị thông thường mà theo kiểu ghép thử như trên.

Thử áp dụng quy tắc này với thuật toán Interchange Sort (2 vòng lặp lồng nhau mà các bạn hay làm hồi học nhập môn lập trình) cho mảng $\{12, 907, 91\}$:

  1. So sánh $a[0] = 12$ với $a[1] = 907$: hai số ghép được là $12907$ và $90712$. Thấy $90712$ lớn hơn nên đổi chỗ → $\{907, 12, 91\}$.
  2. So sánh $a[0] = 907$ với $a[2] = 91$: $90791$ và $91907$. Thấy $91907$ lớn hơn nên đổi chỗ → $\{91, 12, 907\}$.
  3. So sánh $a[1] = 12$ với $a[2] = 907$: $12907$ và $90712$. Thấy $90712$ lớn hơn nên đổi chỗ → $\{91, 907, 12\}$.

Hết cặp để so sánh. Kết luận số lớn nhất là $9190712$. Đơn giản chưa nè 😁

Minh hoạ Interchange Sort với quy tắc ghép thử

Em nhập dãy số rồi bấm Tiếp để xem từng cặp $(i, j)$ được ghép thử và đổi chỗ thế nào:

Ví dụ nhanh:

Ở đây có một chỗ cần chọn: lưu các số dưới dạng số nguyên hay xâu?

  • Nếu dùng số nguyên: muốn ghép $123$ với $45$ thành $12345$ thì phải tính $123 \cdot 10^2 + 45$, còn $45123 = 45 \cdot 10^3 + 123$ – tức là số này nhân với $10^{\text{(số chữ số của số kia)}}$ rồi cộng số kia. Phải viết thêm hàm đếm chữ số, và mỗi số tới $10^5$ (6 chữ số) nên ghép lại có thể tới 12 chữ số ⇒ phải dùng long long, ép kiểu cẩn thận kẻo các phép nhân trung gian kiểu int bị tràn.
  • Nếu dùng xâu thì gọn hơn rất nhiều: string a = "123", b = "45"; thì a + b = "12345", b + a = "45123". Hai xâu ghép này cùng độ dài, nên so sánh xâu (từng ký tự từ trái qua phải, chỗ nào khác nhau thì ký tự lớn hơn thắng) cũng chính là so sánh hai số. Và C++ đã hỗ trợ sẵn các toán tử >, <, >=, <=, ==, != cho string.
Vì sao so sánh xâu lại đúng như so sánh số?
Vì a + b và b + a luôn có cùng độ dài, và không số nào có chữ số 0 ở đầu (do $a_i \ge 1$). Với hai xâu chữ số cùng độ dài, thứ tự từ điển trùng với thứ tự giá trị. Nhớ là chỉ so sánh được kiểu này khi cùng độ dài nhé: "9" > "100000" theo xâu nhưng $9 \lt 100000$ theo số!

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

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

const int MAX = 1e5 + 5;
string a[MAX];

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    for(int i = 0; i < n - 1; ++i){
        for(int j = i + 1; j < n; ++j){
            if(a[j] + a[i] > a[i] + a[j]){
                swap(a[i], a[j]);
            }
        }
    }
    for(int i = 0; i < n; ++i){
        cout << a[i];
    }
    return 0;
}
n = int(input())
a = input().split()
for i in range(n - 1):
    for j in range(i + 1, n):
        if a[j] + a[i] > a[i] + a[j]:
            a[i], a[j] = a[j], a[i]
print("".join(a))
C++▶ Chạy tay từng dòng codeCách 2: Interchange Sort, đổi chỗ a[i], a[j] khi a[j] + a[i] > a[i] + a[j].
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4const int MAX = 1e5 + 5;
5string a[MAX];
6
7int main(){
8 int n;
9 cin >> n;
10 for(int i = 0; i < n; ++i){
11 cin >> a[i];
12 }
13 for(int i = 0; i < n - 1; ++i){
14 for(int j = i + 1; j < n; ++j){
15 if(a[j] + a[i] > a[i] + a[j]){
16 swap(a[i], a[j]);
17 }
18 }
19 }
20 for(int i = 0; i < n; ++i){
21 cout << a[i];
22 }
23 return 0;
24}

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

  • Không gian: $O(N \cdot L)$ cho mảng xâu ($L \le 6$).
  • Thời gian: 2 vòng lặp lồng nhau ⇒ $\dfrac{N(N-1)}{2}$ lần so sánh, mỗi lần ghép xâu tốn $O(L)$ ⇒ $O(N^2 \cdot L)$.
Không gian$O(N)$
Thời gian$O(N^2)$
Đúng nhưng sẽ TLE
Với $N = 10^5$ thì $N^2 = 10^{10}$, gấp khoảng 100 lần ngưỡng $10^8$ phép tính trong 1 giây ⇒ cần cỡ 100 giây mới ra kết quả ⇒ TLE. Bubble Sort, Selection Sort... cũng $O(N^2)$ nên cùng chung số phận.

Cách 3Quy tắc ghép + sort trong STL – O(N log N)

Giải pháp là dùng hàm sort trong thư viện STL: vừa tối ưu chỉ $O(N \log N)$ lần so sánh, vừa gọn lẹ để gọi ra. Ta chỉ cần viết hàm cmp để quy định việc sắp xếp:

Hàm so sánh
cmp(a, b) trả về true khi $a$ phải đứng trước $b$, tức là khi ghép a + b cho số lớn hơn b + a:
return b + a < a + b;
Phải là so sánh chặt
Hàm cmp cho sort phải là quan hệ "nhỏ hơn" chặt. Nếu viết b + a <= a + b thì khi hai số "bằng nhau" (vd $7$ và $77$, ghép kiểu nào cũng $777$) cmp trả true cả hai chiều – vi phạm yêu cầu của std::sort, có thể chạy sai hoặc thậm chí lỗi truy cập bộ nhớ.
Vì sao "ghép thử" sắp xếp được? (dành cho bạn thích chứng minh)
Gọi $|a|$ là số chữ số của $a$. Ta có $\overline{ab} = a \cdot 10^{|b|} + b$, nên $\overline{ab} \gt \overline{ba} \iff a(10^{|b|} - 1) \gt b(10^{|a|} - 1) \iff \dfrac{a}{10^{|a|} - 1} \gt \dfrac{b}{10^{|b|} - 1}$. Tức là mỗi số ứng với một "giá trị" thực $\dfrac{a}{10^{|a|}-1}$ (chính là số thập phân vô hạn tuần hoàn $0,aaa\ldots$), và quy tắc ghép thử chỉ là so sánh các giá trị này. Nên quy tắc có tính bắc cầu, sắp giảm theo nó là hợp lệ; và nếu thứ tự cuối còn cặp kề nhau đứng "sai" thì đổi chỗ cặp đó sẽ được số lớn hơn – nên thứ tự đã sắp chính là lớn nhất.

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

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

bool cmp(const string &a, const string &b){
    return b + a < a + b;
}

const int MAX = 1e5 + 5;
string a[MAX];

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n;
    cin >> n;
    for(int i = 0; i < n; ++i){
        cin >> a[i];
    }
    sort(a, a + n, cmp);
    for(int i = 0; i < n; ++i){
        cout << a[i];
    }
    return 0;
}
import sys
from functools import cmp_to_key

def cmp(a, b):
    # a đứng trước b nếu ghép a + b lớn hơn b + a
    if b + a < a + b:
        return -1
    if a + b < b + a:
        return 1
    return 0

data = sys.stdin.read().split()
n = int(data[0])
a = data[1:n + 1]
a.sort(key=cmp_to_key(cmp))
print("".join(a))

Trong Python, sort nhận key chứ không nhận hàm so sánh trực tiếp, nên ta bọc cmp bằng functools.cmp_to_key (hàm trả số âm nghĩa là $a$ đứng trước $b$).

Thứ tự từng lần gọi cmp bên trong std::sort phụ thuộc vào cài đặt của thư viện, nên khung chạy tay dưới đây gom cả lệnh sort làm một bước, rồi cho các bạn thấy từng cặp kề nhau sau khi sắp đều đúng quy tắc:

C++▶ Chạy tay từng dòng codeCách 3: sort(a, a + n, cmp) với cmp(a, b) = (b + a < a + b), rồi in liền.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4bool cmp(const string &a, const string &b){
5 return b + a < a + b;
6}
7
8const int MAX = 1e5 + 5;
9string a[MAX];
10
11int main(){
12 ios::sync_with_stdio(0);
13 cin.tie(0);
14 int n;
15 cin >> n;
16 for(int i = 0; i < n; ++i){
17 cin >> a[i];
18 }
19 sort(a, a + n, cmp);
20 for(int i = 0; i < n; ++i){
21 cout << a[i];
22 }
23 return 0;
24}

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

  • Không gian: $O(N \cdot L)$ cho mảng xâu, $L \le 6$.
  • Thời gian: sort thực hiện $O(N \log N)$ lần so sánh, mỗi lần ghép 2 xâu dài tối đa 12 ký tự tốn $O(L)$ ⇒ $O(N \log N \cdot L)$, với $L$ rất nhỏ thì xem như $O(N \log N)$. Đây là bước tốn thời gian nhất của cả bài.
Không gian$O(N)$
Thời gian$O(N \log N)$
Nhớ dùng mảng xâu và in liền
Đáp án có thể dài tới $6 \cdot 10^5$ chữ số – không kiểu số nào chứa nổi, nên ta in lần lượt từng xâu, liền nhau, không có dấu cách.
Ghi chú của mình: code C++ và Python của cả 3 cách ở trên đã được chạy với test đề (ra đúng 9190712) và so khớp với nhau trên 500 bộ test ngẫu nhiên ($n \le 7$, có nhiều số trùng nhau / là tiền tố của nhau, giá trị đến $10^5$), lấy cách vét cạn hoán vị làm chuẩn – khớp hết. Với $n = 10^5$ ngẫu nhiên, Cách 3 bản C++ tốn khoảng 0,1 giây CPU, bản Python khoảng 0,7 giây; còn Cách 2 (Interchange Sort) mới $n = 10^4$ đã tốn khoảng 2,5 giây, nên $n = 10^5$ chắc chắn TLE đúng như phân tích.
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