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

Số Tình Nghĩa

Số Tình Nghĩa
Nguồn: Codeforces Group Độ khó: Dễ Sinh nhị phân Sinh hoán vị lower_bound

Đề bài

Số "tình nghĩa" là số thoả mãn các điều kiện sau:

  • các chữ số của nó chỉ bao gồm 4 hoặc 7;
  • số lượng chữ số $4$ bằng số lượng chữ số $7$.

Ví dụ: $4774$, $477744$ là các ví dụ về số "tình nghĩa".

Yêu cầu: Cho trước số nguyên dương $n$. Hãy in ra số "tình nghĩa" nhỏ nhất mà không nhỏ hơn $n$.

Input

  • Dòng thứ nhất chứa số $t$ $(1 \le t \le 100)$ – thể hiện số lượng testcase.
  • $t$ dòng tiếp theo, mỗi dòng chứa số nguyên $n$ $(1 \le n \le 10^9)$.

Output

  • Ứng với mỗi testcase, in ra đáp án cần tìm.
Ví dụ – input
2
1
73
Ví dụ – output
47
74

Giải thích: số tình nghĩa nhỏ nhất là $47$ nên với $n = 1$ đáp án là $47$. Với $n = 73$ thì $47 < 73$ không được, số tình nghĩa kế tiếp là $74 \ge 73$ nên đáp án là $74$.

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:

Ý tưởngChỉ có 4 hoặc 7 → dãy nhị phân

Chỉ có 2 lựa chọn: hoặc 4, hoặc 7. Thấy có 2 lựa chọn như vậy thì hiểu ngay ra dãy nhị phân rồi nè. Cho bit 0 là số 4, bit 1 là số 7. Ví dụ dãy nhị phân 0110 thì sẽ là số 4774:

Dãy nhị phânSố tình nghĩa
0↓4
1↓7
1↓7
0↓4
2 bit 0 ↔ 2 chữ số 4  ·  2 bit 1 ↔ 2 chữ số 7

Vì số chữ số 4 phải bằng số chữ số 7 nên độ dài của số tình nghĩa luôn là số chẵn. Vậy ta chỉ sinh dãy nhị phân có các chiều dài chẵn, không cần sinh các dãy nhị phân có chiều dài lẻ làm gì: sinh độ dài $2, 4, 6, 8, 10$. Vì số $n$ tối đa $10^9$ (có 10 chữ số) nên chỉ cần sinh tối đa dãy nhị phân chiều dài $10$ là phủ hết tụi nó rồi (số tình nghĩa độ dài 10 nhỏ nhất là $4444477777 > 10^9$).

Và với từng dãy chỉ lấy những dãy nào có số lượng bit 0 bằng số lượng bit 1. Đếm thử xem có bao nhiêu số như vậy: độ dài $L$ thì chọn $\frac{L}{2}$ vị trí cho bit 0 trong $L$ vị trí, tức là $C_L^{L/2}$ số:

Độ dài $L$Số dãy nhị phân $2^L$Số tình nghĩaNhỏ nhấtLớn nhất
2424774
416644777744
66420444777777444
8256704444777777774444
10102425244444777777777744444
Tổng350

Đó thì cứ tạo ra hết tất cả các số. Và bản chất của các dãy nhị phân khi sinh theo thứ tự từ 00…0 tới 11…1 nó cũng sẽ có tính chất tăng dần (bit 0 ↔ chữ số 4 nhỏ hơn, bit 1 ↔ chữ số 7 lớn hơn, cùng độ dài thì so sánh từ trái qua giống hệt nhau), và độ dài ngắn sinh trước độ dài dài, nên danh sách các số được tạo ra mặc nhiên sẽ có thứ tự tăng dần, ta cũng không cần phải đi sắp xếp. Em sẽ thấy chỉ có 350 số thôi.

Và danh sách các số đã có thứ tự tăng dần thì với mỗi test case em đi tìm kiếm nhị phân ra kết quả số đầu tiên thoả $\ge n$ thôi, gọi hàm lower_bound là xong.

Tóm tắt thuật toán
1) Sinh mọi dãy nhị phân độ dài $2, 4, 6, 8, 10$; dãy nào có số bit 0 = số bit 1 thì đổi thành số (0 → 4, 1 → 7) rồi đưa vào cuối danh sách res.
2) res tự động tăng dần (350 số). Mỗi truy vấn $n$: in *lower_bound(res.begin(), res.end(), n).

Minh hoạSinh nhị phân ra số tình nghĩa

Em chọn độ dài $L$ (2, 4 hoặc 6) và nhập $n$, rồi bấm Tiếp để xem từng dãy nhị phân được sinh ra, dãy nào cân bằng thì đổi thành số tình nghĩa, cuối cùng tìm số đầu tiên $\ge n$:

Ví dụ nhanh:

Cách 1Sinh nhị phân + lower_bound

Code sinh nhị phân mình tự viết theo cơ chế quen thuộc: từ dãy hiện tại, đi từ cuối dãy ngược về tìm bit 0 đầu tiên, bật nó lên 1 và cho toàn bộ các bit phía sau về 0. Nếu không còn bit 0 nào (dãy toàn 1) thì vòng lặp kết thúc ($i = -1$).

#include <bits/stdc++.h>
using namespace std;
int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n = 2;
    vector<long long> res;
    while(n <= 10){
        int a[n] = {0};
        int i;
        do{
            int cnt0 = 0;
            for(int i = 0; i < n; ++i){
                if(a[i] == 0){
                    cnt0++;
                }
            }
            if(cnt0 == n - cnt0){
                long long sum = 0;
                for(int i = 0; i < n; ++i){
                    sum = sum * 10;
                    if(a[i] == 0){
                        sum += 4;
                    }
                    else{
                        sum += 7;
                    }
                }
                res.push_back(sum);
            }
            for(i = n - 1; i >= 0; --i){
                if(a[i] == 0){
                    a[i] = 1;
                    for(i++; i < n; ++i){
                        a[i] = 0;
                    }
                    break;
                }
            }
        }while(i != -1);
        n += 2;
    }
    int t;
    cin >> t;
    while(t--){
        long long n;
        cin >> n;
        cout << *lower_bound(res.begin(), res.end(), n) << '\n';
    }
    return 0;
}
from bisect import bisect_left

# Sinh các dãy nhị phân độ dài chẵn 2, 4, ..., 10 theo thứ tự tăng dần
# bit 0 -> chữ số 4, bit 1 -> chữ số 7; chỉ lấy dãy có số bit 0 = số bit 1
res = []
for n in range(2, 11, 2):
    for mask in range(1 << n):                  # mask tăng dần = dãy nhị phân tăng dần
        bits = [(mask >> (n - 1 - i)) & 1 for i in range(n)]
        if bits.count(0) == n - bits.count(0):
            s = 0
            for b in bits:
                s = s * 10 + (7 if b else 4)
            res.append(s)

data = open(0).read().split()
t = int(data[0])
out = []
for k in range(1, t + 1):
    n = int(data[k])
    out.append(str(res[bisect_left(res, n)]))   # giống lower_bound
print("\n".join(out))
C++▶ Chạy tay từng dòng codeCách 1: sinh nhị phân độ dài 2, 4, …, 10 rồi lower_bound trên vector.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3int main(){
4 ios::sync_with_stdio(0);
5 cin.tie(0);
6 int n = 2;
7 vector<long long> res;
8 while(n <= 10){
9 int a[n] = {0};
10 int i;
11 do{
12 int cnt0 = 0;
13 for(int i = 0; i < n; ++i){
14 if(a[i] == 0){
15 cnt0++;
16 }
17 }
18 if(cnt0 == n - cnt0){
19 long long sum = 0;
20 for(int i = 0; i < n; ++i){
21 sum = sum * 10;
22 if(a[i] == 0){
23 sum += 4;
24 }
25 else{
26 sum += 7;
27 }
28 }
29 res.push_back(sum);
30 }
31 for(i = n - 1; i >= 0; --i){
32 if(a[i] == 0){
33 a[i] = 1;
34 for(i++; i < n; ++i){
35 a[i] = 0;
36 }
37 break;
38 }
39 }
40 }while(i != -1);
41 n += 2;
42 }
43 int t;
44 cin >> t;
45 while(t--){
46 long long n;
47 cin >> n;
48 cout << *lower_bound(res.begin(), res.end(), n) << '\n';
49 }
50 return 0;
51}

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

  1. int a[n] = {0}; tạo dãy nhị phân đầu tiên toàn bit 0 (ứng với số $44\ldots4$).
  2. Vòng for(i = n - 1; i >= 0; --i) sinh dãy nhị phân kế tiếp. Biến i khai báo bên ngoài do…while để sau vòng lặp còn dùng được: nếu không tìm thấy bit 0 nào thì i giảm tới $-1$ → điều kiện while(i != -1) sai → đã sinh xong độ dài này.
  3. Trong main có 2 biến tên n: int n ở trên là độ dài dãy nhị phân, còn long long n trong vòng while(t--) là số $n$ của từng test (biến bên trong che biến bên ngoài).
  4. lower_bound(res.begin(), res.end(), n) trả về vị trí phần tử đầu tiên $\ge n$ trong mảng đã tăng dần bằng tìm kiếm nhị phân, dấu * phía trước để lấy giá trị tại vị trí đó.

Đánh giá độ phức tạp: phần sinh chạy cố định $2^2 + 2^4 + \ldots + 2^{10} \approx 1\,364$ dãy, mỗi dãy $O(10)$; mỗi truy vấn $O(\log 350)$.

Không gian$O(350)$ – mảng res
Thời gian$O(10 \cdot 2^{10} + t \log 350)$

Code này nộp bài thì time = 0.02 s và memory = 3.1 MB là tốc độ & bộ nhớ tối ưu nhất (tính đến thời điểm mình viết bài này).

Cách 2Sinh hoán vị + set

Hoặc có 1 hướng xử lý khác mà không dùng đến sinh nhị phân, đó là sinh hoán vị. Cũng vẫn như trên ta sẽ sinh ra các dãy hoán vị có độ dài chẵn: $2, 4, 6, 8, 10$. Ví dụ với độ dài 4 thì khởi đầu sẽ là: 1 2 3 4, thì ta hiểu ứng với số lẻ sẽ là số 7, ứng với số chẵn sẽ là số 4. Độ dài chẵn nên số lượng số lẻ luôn bằng số lượng số chẵn → số tạo ra chắc chắn có số chữ số 4 bằng số chữ số 7.

#Hoán vịSố tạo raVào set
11 2 3 47474số mới
21 2 4 37447số mới
31 3 2 47744số mới
41 3 4 27744trùng, set bỏ qua
51 4 2 37447trùng, set bỏ qua
61 4 3 27474trùng, set bỏ qua
72 1 3 44774số mới
82 1 4 34747số mới
… tổng cộng $4! = 24$ hoán vị nhưng chỉ có $C_4^2 = 6$ số khác nhau.

Vì có thể bị trùng nên là ta sẽ dùng cấu trúc dữ liệu set để lưu trữ các số được tạo ra và sẽ đảm bảo không bị trùng, và dữ liệu trong set cũng được sắp xếp theo thứ tự tăng dần, từ đó ta tìm kiếm nhị phân luôn bằng se.lower_bound(n).

Với việc sinh nhị phân trước đó thì ta phải tự viết code cơ chế sinh nhị phân, nhưng với việc sinh hoán vị thì C++ đã hỗ trợ sẵn hàm next_permutation sẽ rất tiện lợi nhanh gọn cho ta: nó biến dãy thành hoán vị kế tiếp theo thứ tự từ điển và trả về false khi dãy đã là hoán vị cuối cùng. Vậy áp dụng ý tưởng trên vào thì code sẽ như sau:

#include <bits/stdc++.h>
using namespace std;
int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n = 2;
    set<long long> se;
    while(n <= 10){
        int a[n + 1];
        for(int i = 1; i <= n; ++i){
            a[i] = i;
        }
        do{
            long long sum = 0;
            for(int i = 1; i <= n; ++i){
                sum *= 10;
                if(a[i] % 2 != 0){
                    sum += 7;
                }
                else{
                    sum += 4;
                }
            }
            se.insert(sum);
        } while (next_permutation(a + 1, a + n + 1));
        n += 2;
    }
    int t;
    cin >> t;
    while(t--){
        long long n;
        cin >> n;
        cout << *se.lower_bound(n) << '\n';
    }
    return 0;
}
from bisect import bisect_left
from itertools import permutations

# Sinh hoán vị của 1..n (n = 2, 4, ..., 10): số lẻ -> 7, số chẵn -> 4
# Dùng set để bỏ các số trùng, rồi sắp xếp để tìm kiếm nhị phân
se = set()
for n in range(2, 11, 2):
    for p in permutations(range(1, n + 1)):
        s = 0
        for x in p:
            s = s * 10 + (7 if x % 2 else 4)
        se.add(s)
res = sorted(se)

data = open(0).read().split()
t = int(data[0])
out = []
for k in range(1, t + 1):
    n = int(data[k])
    out.append(str(res[bisect_left(res, n)]))
print("\n".join(out))
C++▶ Chạy tay từng dòng codeCách 2: sinh hoán vị bằng next_permutation, lưu vào set rồi se.lower_bound.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3int main(){
4 ios::sync_with_stdio(0);
5 cin.tie(0);
6 int n = 2;
7 set<long long> se;
8 while(n <= 10){
9 int a[n + 1];
10 for(int i = 1; i <= n; ++i){
11 a[i] = i;
12 }
13 do{
14 long long sum = 0;
15 for(int i = 1; i <= n; ++i){
16 sum *= 10;
17 if(a[i] % 2 != 0){
18 sum += 7;
19 }
20 else{
21 sum += 4;
22 }
23 }
24 se.insert(sum);
25 } while (next_permutation(a + 1, a + n + 1));
26 n += 2;
27 }
28 int t;
29 cin >> t;
30 while(t--){
31 long long n;
32 cin >> n;
33 cout << *se.lower_bound(n) << '\n';
34 }
35 return 0;
36}

Với code này thì time = 0.59 s và memory = 3.2 MB. Time chạy lâu hơn là vì dùng cấu trúc dữ liệu set: bản chất set là cấu trúc cây nên mỗi dữ liệu đưa vào sẽ mất chi phí $O(\log)$ chứ không tối ưu như mảng chỉ là $O(1)$ khi đưa mỗi phần tử vào cuối. Mà ở đây số lần đưa vào set rất nhiều: mỗi hoán vị một lần, độ dài 10 thôi đã là $10! = 3\,628\,800$ lần.

Không gian$O(350)$ – set
Thời gian$O(10 \cdot 10! \cdot \log 350 + t \log 350)$

Cách 3Không cần sinh độ dài 10

Một suy nghĩ tối ưu thêm là bài này cho $N$ tối đa là $10^9$, và đề yêu cầu tìm ra số tình nghĩa nhỏ nhất thoả $\ge N$. Vậy ta có cần sinh ra các số tình nghĩa có độ dài 10 không? Vì ta đã biết số tình nghĩa độ dài 8 lớn nhất sẽ là $77774444$, vậy nếu số $N$ mà lớn hơn số này, tức là $N$ từ $77774445$ đến $10^9$, thì đáp án sẽ là số tình nghĩa tiếp theo, là số có độ dài 10 nhỏ nhất: 4444477777.

độ dài 2 → 847 … 77774444sinh bằng hoán vị, lưu vào set
77774445 … 109không có số tình nghĩa nào
độ dài 104444477777đáp án cho mọi n > 77774444

Vậy lúc này ta chỉ cần sinh các số tình nghĩa có độ dài $2, 4, 6, 8$ thôi và xét thêm điều kiện ràng buộc là được. Ta chỉnh lại code dùng set ở trên như sau:

#include <bits/stdc++.h>
using namespace std;
int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n = 2;
    set<long long> se;
    while(n <= 8){
        int a[n + 1];
        for(int i = 1; i <= n; ++i){
            a[i] = i;
        }
        do{
            long long sum = 0;
            for(int i = 1; i <= n; ++i){
                sum *= 10;
                if(a[i] % 2 != 0){
                    sum += 7;
                }
                else{
                    sum += 4;
                }
            }
            se.insert(sum);
        } while (next_permutation(a + 1, a + n + 1));
        n += 2;
    }
    int t;
    cin >> t;
    while(t--){
        long long n;
        cin >> n;
        if(n > 77774444){
            cout << 4444477777;
        }
        else{
            cout << *se.lower_bound(n);
        }
        cout << '\n';
    }
    return 0;
}
from bisect import bisect_left
from itertools import permutations

# Chỉ sinh hoán vị độ dài 2, 4, 6, 8; n > 77774444 thì đáp án là 4444477777
se = set()
for n in range(2, 9, 2):
    for p in permutations(range(1, n + 1)):
        s = 0
        for x in p:
            s = s * 10 + (7 if x % 2 else 4)
        se.add(s)
res = sorted(se)

data = open(0).read().split()
t = int(data[0])
out = []
for k in range(1, t + 1):
    n = int(data[k])
    if n > 77774444:
        out.append("4444477777")
    else:
        out.append(str(res[bisect_left(res, n)]))
print("\n".join(out))
C++▶ Chạy tay từng dòng codeCách 3: chỉ sinh tới độ dài 8, n > 77774444 thì in thẳng 4444477777.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3int main(){
4 ios::sync_with_stdio(0);
5 cin.tie(0);
6 int n = 2;
7 set<long long> se;
8 while(n <= 8){
9 int a[n + 1];
10 for(int i = 1; i <= n; ++i){
11 a[i] = i;
12 }
13 do{
14 long long sum = 0;
15 for(int i = 1; i <= n; ++i){
16 sum *= 10;
17 if(a[i] % 2 != 0){
18 sum += 7;
19 }
20 else{
21 sum += 4;
22 }
23 }
24 se.insert(sum);
25 } while (next_permutation(a + 1, a + n + 1));
26 n += 2;
27 }
28 int t;
29 cin >> t;
30 while(t--){
31 long long n;
32 cin >> n;
33 if(n > 77774444){
34 cout << 4444477777;
35 }
36 else{
37 cout << *se.lower_bound(n);
38 }
39 cout << '\n';
40 }
41 return 0;
42}

Và kết quả time chạy giờ đây là 0.02 s, bằng với time chạy nhanh nhất của bài đó. Tại sao chỉ 1 thay đổi nhỏ mà lại rút ngắn time từ 0.59 s xuống còn 0.02 s? Ta hãy hiểu rằng với độ dài $X$ thì sẽ có $X!$ hoán vị. Nên khi ta bỏ qua việc liệt kê ra hết các hoán vị có độ dài 10 nghĩa là ta đã bỏ qua được việc phải xét $10! = 3\,628\,800$ dãy hoán vị. Mà mỗi dãy có độ dài 10 nên tổng số phần tử đã bỏ qua được là $3\,628\,800 \times 10$ $= 36\,288\,000$ (36 triệu 288 ngàn) phần tử. Chỉ 1 thay đổi nhỏ nhưng đã giúp giảm tải rất nhiều phép xử lý cho máy tính, vì thế mới tăng tốc được như vậy.

So sánhSinh nhị phân hay sinh hoán vị?

Vậy nếu so sánh giữa cách làm sinh nhị phân và cách làm sinh hoán vị thì nên làm cách nào hơn? Thì ta hiểu rằng với sinh nhị phân, dãy nhị phân có độ dài $X$ ta sẽ chỉ có $2^X$ dãy thôi, trong khi đó với dãy hoán vị thì là $X!$ dãy. Em xem bảng này là thấy được sự chênh lệch rất lớn:

Độ dài $X$Sinh nhị phân $2^X$Sinh hoán vị $X!$
242
41624
664720
825640.320
101.0243.628.800
124.096479.001.600
224.194.3041.124.000.727.777.607.680.000

Nếu bài này mà nhu cầu cần phải sinh ra độ dài 12 thì với việc sinh nhị phân thì $2^{12} = 4\,096$ vẫn bình thường không vấn đề gì, nhưng nếu sinh hoán vị thì $12! = 479\,001\,600$ (hơn 479 triệu) thì chắc chắn sẽ bị TLE. Nếu giả sử bài này mà số tình nghĩa cần sinh ra có độ dài lên tới 22 chữ số thì cách làm sinh nhị phân cũng vẫn sẽ ổn vì $22 \cdot 2^{22} = 92\,274\,688$ (92 triệu 274 ngàn 688) vẫn dưới ngưỡng 100 triệu của 1 giây, nhưng nếu là sinh hoán vị thì $22! = 1\,124\,000\,727\,777\,607\,680\,000$ thì em nghĩ máy tính chạy nổi không? 😅

Kinh nghiệm phòng thi
Tuy nhiên với ngữ cảnh bài này số tình nghĩa cần sinh ra có độ dài tối đa là 10 thì cách làm sinh hoán vị cũng vẫn ổn. Và nếu trong ngữ cảnh phòng thi có tính thời gian làm bài thì nếu là anh, anh cũng dùng cách sinh hoán vị để lợi dụng hàm next_permutation đã có sẵn gọi vào cho nhanh, thay vì tự code ra cách sinh nhị phân, để còn thời gian làm các bài khác.
Ghi chú của mình: 3 code C++ ở trên là của anh, các bản Python là mình viết thêm. Tất cả đã được kiểm tra với 220 bộ test ngẫu nhiên (mỗi bộ tới 100 truy vấn, có cả các giá trị sát biên như 77774444, 77774445, $10^9$), so với cách vét cạn liệt kê mọi số chỉ gồm chữ số 4, 7 dài tới 10 chữ số – khớp hết. Với 100 truy vấn lớn nhất: Cách 1 C++ chạy khoảng 0.005 giây, Cách 2 khoảng 0.2 giây, Cách 3 khoảng 0.01 giây. Bản Python Cách 1 và Cách 3 khoảng 0.05 giây, còn Python Cách 2 mất khoảng 4.5 giây (vì phải duyệt $10!$ hoán vị) nên chỉ để tham khảo, nộp Python thì em dùng Cách 1 hoặc Cách 3 nhé.
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇