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ã hóa Ceasar

Mã hóa Ceasar
Nguồn: LuyenCode Độ khó: Dễ Xâu ký tự Mã ASCII

Đề bài

Trước công nguyên, nhà quân sự người La Mã Julius Ceasar đã nghĩ ra phương pháp mã hóa một bản tin như sau: thay thế mỗi chữ trong bản tin bằng chữ đứng sau nó $k$ vị trí trong bảng chữ cái. Giả sử chọn $k = 3$, ta có bảng chuyển đổi như sau:

Chữ ban đầuabcdefghijklmnopqrstuvwxyz
Chữ thay thếdefghijklmnopqrstuvwxyzabc

Giả sử bản tin là: 'attack' thì sau khi mã hóa sẽ có bản mã 'dwwdfn' và Ceasar gửi bản mã cho cấp dưới.

Nhận được bản mã và khóa, cấp dưới của Ceasar chưa biết giải mã làm sao. Bạn hãy giúp họ đi nào.

Dữ liệu vào:

  • Dòng đầu tiên là bản tin đã được mã hóa, chỉ gồm các chữ cái la tinh thường, chiều dài không quá 100 ký tự.
  • Dòng thứ 2 là số nguyên thể hiện khóa $k$ ($1 \le k \le 25$).

Dữ liệu ra: bản tin ban đầu.

Ví dụ – input
wyvnyhttpun
7
Ví dụ – output
programming

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 😇. Các bạn nào muốn thử nộp bài để hệ thống chấm có đúng hay không thì link bài đây nha:

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

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 LuyenCode

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

Chuẩn bịGiới hạn 20 ms thì được lặp bao nhiêu lần?

Trước khi đi vào vấn đề chính của bài thì mình muốn các bạn phải đảm bảo biết qua điều này để về sau các bạn xem mới hiểu.

Với các hệ thống web chấm bài giới hạn thời gian chạy tối đa 1 giây thì các bạn sẽ rất dễ bị rơi vào lỗi Time Limit Exceeded (TLE: chạy quá giới hạn thời gian cho phép). Như mình đã nói rất nhiều lần: nếu không muốn bị TLE thì tối đa số phép toán trên toàn bài của các bạn chỉ nên ở ngưỡng $(3 \text{ đến } 5) \cdot 10^7$ – ngưỡng an toàn với phần lớn hệ thống chấm bài; với một số hệ thống server mạnh hơn thì có thể nâng lên $10^8$ nhưng cũng hên xui nhé.

Thực ra ta không phải đếm chi tiết từng phép toán mà dựa theo số lần vòng lặp chạy, bởi bên trong vòng lặp nó xử lý lặp đi lặp lại nhiều phép toán. Lưu ý ngưỡng trên là với C/C++ thôi nhé, các ngôn ngữ chạy chậm hơn như Python, C#, Java thì ngưỡng phải thấp hơn. Còn nếu đề cho 2 giây thì các bạn cứ tỷ lệ thuận nhân 2 lên, 3 giây thì nhân 3, 100 ms tức là $\frac{1}{10}$ giây thì chia 10, cứ thế thôi.

Áp dụng cho bài này
Giới hạn thời gian là 20 ms $= \frac{20}{1000} = \frac{1}{50}$ giây. Lấy ngưỡng 1 giây chia cho 50 → ngưỡng cho 20 ms là khoảng $(6 \text{ đến } 10) \cdot 10^5$, cao lắm là $2 \cdot 10^6$ lần lặp.

Bài này phải nói là nên ở mức dễ sẽ đúng hơn là mức trung bình, vì chuỗi chỉ dài tối đa 100 là quá nhỏ, dù cho cách làm tệ nhất vẫn không sợ bị chạy quá thời gian (tất nhiên nếu tệ quá mức thì vẫn bị nhé 🤪). Tuy nhiên chưa chắc bạn nào cũng giải quyết theo cách gọn gàng – mình đã xem qua code của bạn hỏi bài thì thấy bạn xử lý khá rườm rà phức tạp, nên ở đây mình sẽ hướng dẫn một cách rất gọn, các bạn chú ý nha.

Nền tảngKý tự thực chất là số nguyên (mã ASCII)

Để làm được bài này thì các bạn phải hiểu quy luật mã ASCII của các ký tự. Bản chất kiểu dữ liệu ký tự trong C/C++ chính là kiểu số nguyên, bởi các ký tự được quản lý thông qua mã ASCII của nó – có thể hiểu là "mã số" của ký tự. 26 chữ cái từ 'a' đến 'z' tương ứng với các số nguyên từ 97 đến 122: 97 là mã của 'a', 98 là mã của 'b', 99 là mã của 'c', ... đến 122 là mã của 'z'.

Ký tựabcdefghijklmnopqrstuvwxyz
Mã ASCII979899100101102103104105106107108109110111112113114115116117118119120121122

Ví dụ đoạn code sau:

#include <iostream>
using namespace std;
int main(){
    char x = 'a';
    cout << x << " " << (int)x;
    return 0;
}
x = 'a'
print(x, ord(x))

Sẽ thấy kết quả in ra là: a 97.

Nếu trước dòng cout ta thêm câu lệnh x++; thì kết quả sẽ là b 98, vì $x$ đang là 97 được cộng thêm 1 thành 98, in ra giá trị $x$ lúc này chính là ký tự 'b'. Tương tự nếu x += 3; thì 97 + 3 = 100 là ký tự 'd'. Rồi từ 100 mà x -= 2 còn 98 thì in ra chính là 'b'. Các bạn chạy thử đoạn code sau:

#include <iostream>
using namespace std;
int main(){
    char x = 'a';
    x += 3;
    cout << x << " " << (int)x << endl;
    x -= 2;
    cout << x << " " << (int)x;
    return 0;
}
x = 'a'
x = chr(ord(x) + 3)
print(x, ord(x))
x = chr(ord(x) - 2)
print(x, ord(x))

Sẽ thấy kết quả in ra là:

d 100
b 98

Đến đây thì các bạn đã hiểu quy luật thao tác với mã ASCII của các ký tự rồi hen. Nó chính là mấu chốt để ta giải quyết nhanh gọn bài này.

Ý tưởngLùi $k$ bước, tụt khỏi 'a' thì cộng 26

Nhìn lại cách mã hóa

Quy luật mã hóa là từ vị trí ký tự hiện tại cộng lên $k$ để lấy ra ký tự thay thế. Mã ASCII của các chữ cái chạy từ 97 ('a') đến 122 ('z'); cứ cộng lên $k$ nếu vượt quá 122 thì bắt đầu lại từ 97. Ví dụ ký tự đang xét là 'x' (mã 120) và $k = 4$ thì ký tự thay thế sẽ là 'b' (mã 98): $120 + 4 = 124$ đã vượt 122 nên trừ cho 122 được $124 - 122 = 2$; rồi do bắt đầu lại từ 'a' (mã 97) nên $2 + 97 - 1 = 98$. Ta phải $-1$ vì chính ký tự 'a' cũng được tính. Còn nếu cộng $k$ mà vẫn $\le 122$ thì quá khoẻ, cứ in ra ký tự có mã đó thôi.

a97b98c99d100…v118w119x120y121z122 -1 -1: tụt khỏi 'a' thì quay về 'z' -1 -1 'b' lùi 4 bước: 98 - 4 = 94 < 97 94 + 26 = 120 = 'x'

Giải mã: làm ngược lại

Bài này muốn ta đi giải mã thì làm ngược lại thôi: ký tự hiện tại trừ đi $k$, nếu nhỏ hơn 97 (mã của 'a') thì bắt đầu lại từ 122 (mã của 'z') và lùi tiếp cho đủ. Ví dụ ngược lại với trường hợp trên: ký tự đang xét là 'b' (mã 98), $k = 4$ thì $98 - 4 = 94 < 97$, nghĩa là cần lùi thêm $97 - 94 = 3$ ký tự bắt đầu từ 'z': $122 - 3 = 119$, rồi nhớ $+1$ do tính luôn chính ký tự 'z' → kết quả là 120, mã của 'x'.

Vậy khi xét mã ASCII của ký tự $s[i] - k$ mà ra kết quả $< 97$ thì đáp án là ký tự có mã:

$$\begin{aligned}&122 - (97 - (s[i] - k)) + 1\\ =\ &122 - 97 + s[i] - k + 1\\ =\ &25 + s[i] - k + 1\\ =\ &s[i] - k + 26\end{aligned}$$
Chốt lại
Xét đến ký tự $s[i]$ thì cứ lấy s[i] -= k; nếu kết quả nhỏ hơn 97 thì cộng thêm 26, rồi in ký tự có mã đó ra – đó chính là ký tự gốc. Còn nếu trừ $k$ mà vẫn $\ge 97$ thì cứ in ra luôn, khỏi phải nặng đầu tính quy luật gì cả.

Bài này đơn giản ở chỗ $k$ tối đa chỉ 25 (nhỏ hơn 26 chữ cái), nên khi mã hóa hay giải mã thì cao lắm chỉ quay vòng 1 lần thôi: dù là 'a' (97) với $k = 25$ thì $97 - 25 = 72 < 97$, cộng 26 được 98 là 'b' – vẫn chỉ cần cộng 26 đúng 1 lần.

Minh hoạGiải mã từng ký tự

Em nhập bản mã (chữ thường) và khóa $k$ rồi bấm Tiếp để xem từng ký tự lùi $k$ bước trên bảng chữ cái, khi nào bị tụt khỏi 'a' thì quay vòng về cuối:

Ví dụ nhanh:

CodeGiải mã – O(N)

Source code các bạn tham khảo để nhìn thấy rõ với tất cả những gì mình đã phân tích ở trên:

#include <bits/stdc++.h>
using namespace std;
int main(){
    string s;
    int k;
    cin >> s >> k;
    for(int i = 0; i < (int)s.length(); ++i){
        s[i] -= k;
        if(s[i] < 97){
            s[i] += 26;
        }
        cout << s[i];
    }
}
s = input()
k = int(input())
for i in range(len(s)):
    c = ord(s[i]) - k
    if c < ord('a'):
        c += 26
    print(chr(c), end="")
C++▶ Chạy tay từng dòng codeGiải mã: trừ k, tụt dưới 97 thì cộng 26 – O(N).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3int main(){
4 string s;
5 int k;
6 cin >> s >> k;
7 for(int i = 0; i < (int)s.length(); ++i){
8 s[i] -= k;
9 if(s[i] < 97){
10 s[i] += 26;
11 }
12 cout << s[i];
13 }
14}

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

Không gian$O(1)$
Thời gian$O(N)$ với $N$ là độ dài chuỗi

Chuỗi dài tối đa 100 thì vòng lặp chỉ chạy 100 lần, yên tâm là nhỏ hơn rất nhiều so với ngưỡng $(6 \text{ đến } 10) \cdot 10^5$ của 20 ms mình nói đầu bài. Vậy cách làm này sẽ không bị TLE.

Lưu ý với Python
Code Python ở trên nộp vào bài này sẽ bị TLE, không phải do thuật toán mà do giới hạn 20 ms quá chặt – riêng việc khởi động trình thông dịch Python đã tốn hơn thế. Nếu đề cho 1 giây thì code Python trên sẽ AC. Các bạn có thể thử nộp code Python cho bài giống hệt bên NTUCoder sẽ AC: NTUCoder – bài 90.

Mở rộngNếu $k$ rất lớn? Nếu đề bắt mã hóa?

Khi $k$ lên tới 100 triệu

Đến đây hết rồi nhưng đã lỡ làm bài này mình cũng nói luôn: giả sử có bài tương tự nhưng $k$ có thể vượt quá 25, ví dụ tối đa 100 triệu, nghĩa là có thể phải quay vòng bảng chữ cái rất nhiều lần chứ không phải tối đa 1 lần như bài hiện tại. Sẽ rất dở nếu bạn nào cứ lùi từng bước đủ $k$ lần, 100 triệu lần thì quá 1 giây luôn rồi chứ đừng nói 20 ms.

Nhưng tinh ý một chút: bảng chữ cái có 26 chữ, cứ lùi đủ 26 bước là quay về đúng chữ cũ. Nên ta chỉ cần lấy phần dư k %= 26, phần $\lfloor k / 26 \rfloor$ vòng trọn vẹn thì bỏ qua vì chẳng làm thay đổi gì, sau đó giữ nguyên bước xử lý như bài hiện tại. Ví dụ đang xét 'a' với $k = 26$ thì k %= 26 còn 0 – vẫn là 'a'; với $k = 27$ thì còn 1 – lùi 1 ký tự được 'z'.

cin >> s >> k;
k %= 26;   // bỏ qua các vòng trọn vẹn, chỉ giữ phần dư
// ... phần còn lại giữ nguyên như code giải mã ở trên
Ghi chú của mình: trong bài gốc trên Facebook mình có viết nhầm thành k %= 25; đúng phải là k %= 26 vì chu kỳ quay vòng bằng đúng số chữ cái (26). Mình đã sửa lại ở trên cho chuẩn.

Nếu đề yêu cầu mã hóa thay vì giải mã

Lúc này làm như phân tích đầu tiên: xét mã ASCII của $s[i] + k$, nếu ra kết quả $> 122$ thì đáp án là ký tự có mã:

$$s[i] + k - 122 + 97 - 1 = s[i] + k - 26$$

Nghĩa là xét đến ký tự $s[i]$ ta cứ cộng $k$, nếu kết quả lớn hơn 122 thì trừ đi 26 rồi in ký tự có mã đó ra; còn nếu vẫn $\le 122$ thì in ra luôn.

Code mã hóa (chiều ngược lại)
#include <bits/stdc++.h>
using namespace std;
int main(){
    string s;
    int k;
    cin >> s >> k;
    for(int i = 0; i < (int)s.length(); ++i){
        int c = s[i] + k;
        if(c > 122){
            c -= 26;
        }
        cout << (char)c;
    }
}
s = input()
k = int(input())
for c in s:
    c = ord(c) + k
    if c > ord('z'):
        c -= 26
    print(chr(c), end="")
C++▶ Chạy tay từng dòng codeMã hóa: cộng k vào biến int c, vượt 122 thì trừ 26 – O(N).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3int main(){
4 string s;
5 int k;
6 cin >> s >> k;
7 for(int i = 0; i < (int)s.length(); ++i){
8 int c = s[i] + k;
9 if(c > 122){
10 c -= 26;
11 }
12 cout << (char)c;
13 }
14}
Không gian$O(1)$
Thời gian$O(N)$
Cẩn thận tràn kiểu char
Ở bài gốc mình viết s[i] += k; rồi if(s[i] > 122) giống bản giải mã. Nhưng kiểu char chỉ chứa tối đa 127, mà 'z' + 25 = 147 sẽ bị tràn thành số âm, điều kiện > 122 sai luôn và in ra ký tự rác (ví dụ xyz với $k = 25$). Bản giải mã không gặp chuyện này vì trừ đi thì nhỏ nhất cũng là $97 - 25 = 72$. Nên ở code trên mình tính trong biến int c cho an toàn.
Kết quả kiểm tra: code C++ và Python (giải mã lẫn mã hóa) đã được chạy với 250 bộ test ngẫu nhiên (chuỗi dài tới 100, đủ mọi $k$ từ 1 đến 25, có cả chuỗi chứa đủ 26 chữ cái), so với cách vét cạn lùi/tiến từng bước một trên bảng chữ cái – khớp hết. Với chuỗi dài 100 ký tự, bản C++ chạy chưa tới 1 ms.
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 😇

Nhận xét