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

Tìm Ký Tự Thứ K

Tìm Ký Tự Thứ K
Nguồn: Codeforces Group Độ khó: Trung bình Chia để trị Xử lý chuỗi

Đề bài

Cho một xâu ký tự có độ dài $n$, từ xâu này có thể tạo ra một xâu ký tự dài vô tận bằng cách lặp lại thao tác sau: xét xâu $s_1$, lấy ký tự cuối cùng của xâu đó đảo lên đầu, ta được một xâu mới là $s_2$; sau đó ghép xâu $s_2$ vào cuối xâu $s_1$ ta được một xâu $s$ mới. Ví dụ:

RYUU → RYUUURYU → RYUUURYUURYUUURY → …

Nhiệm vụ của Grenore là tìm ký tự thứ $k$ trong xâu này. Do chỉ được thử một lần, Grenore muốn nhờ các bạn tìm giúp cậu.

Dữ liệu vào

  • Dòng đầu tiên gồm một số nguyên $k$ ($1 \le k \le 10^{18}$).
  • Dòng thứ hai chứa một xâu ký tự $s$ ($1 \le |s| \le 30$), $s$ chỉ bao gồm các chữ cái viết hoa.

Dữ liệu ra: một ký tự duy nhất là ký tự thứ $k$ xuất hiện trong xâu.

Input #1
8
RYUU
Output #1
U
Input #2
8
ABC
Output #2
A

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

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

Chưa đạtCách 1: sinh chuỗi theo đúng quy luật

Một cách xử lý đơn giản nhất mà ta dễ dàng nhìn ra là làm theo đúng quy luật của đề bài: nếu $k$ lớn hơn độ dài chuỗi $s$ thì cứ sinh ra chuỗi $s$ mới theo quy tắc đề bài, đến khi nào $k \le$ độ dài chuỗi $s$ thì ký tự cần tìm chính là s[k - 1].

Xem code sinh chuỗi (bị MLE/TLE)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    long long k;
    cin >> k;
    string s;
    cin >> s;
    while(k > (long long)s.length()){
        string t = s;
        s.push_back(t.back());
        t.pop_back();
        s += t;
    }
    cout << s[k - 1];
    return 0;
}
import sys

k, *rest = sys.stdin.read().split()
k = int(k)
s = rest[0]
while k > len(s):
    t = s
    s += t[-1]
    t = t[:-1]
    s += t
print(s[k - 1])
C++▶ Chạy tay từng dòng codeCách 1: sinh chuỗi thật đến khi đủ k ký tự (chỉ chạy được với k nhỏ).
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 long long k;
8 cin >> k;
9 string s;
10 cin >> s;
11 while(k > (long long)s.length()){
12 string t = s;
13 s.push_back(t.back());
14 t.pop_back();
15 s += t;
16 }
17 cout << s[k - 1];
18 return 0;
19}

Giải thích: vòng lặp lặp liên tục khi k > s.length(), nghĩa là chuỗi $s$ chưa chứa được ký tự tại vị trí $k$. Ta tạo chuỗi t lưu giá trị hiện tại của $s$. Đề bài mô tả là đem ký tự cuối cùng lên đầu rồi nối vào sau chuỗi hiện tại; ta không nhất thiết phải xoá rồi chèn vào đầu (vì chèn vào đầu thì toàn bộ phần tử phải nhích xuống), mà cho $s$ nhận ký tự cuối của t trước, sau đó t bỏ ký tự cuối đi rồi nối t vào sau $s$. Kết quả vẫn y hệt:

  • s.push_back(t.back()); – thêm ký tự cuối cùng của t vào cuối $s$.
  • t.pop_back(); – xoá ký tự cuối cùng của t.
  • s += t; – nối chuỗi t vào đằng sau chuỗi $s$.

Độ phức tạp không gian: chuỗi bản chất là mảng 1 chiều các ký tự, để ra kết quả thì chuỗi $s$ phải chứa ít nhất $K$ ký tự, nên là $O(K)$. Trường hợp xấu nhất $K = 10^{18}$ thì cần $10^{18}$ byte bộ nhớ (mỗi ký tự char chiếm 1 byte), chắc chắn bị lỗi MLE (Memory Limit Exceeded). Thậm chí chạy trên máy tính của các bạn thì chương trình bị tắt luôn. Các bạn thử tính nhé, 1 KB = 1024 byte, 1 MB = 1024 KB, 1 GB = 1024 MB:

10^18 byte ≈ 976 562 500 000 000 KB ≈ 953 674 316 406 MB ≈ 931 322 575 GB

Sơ sơ là hơn 931 triệu GB! Ví dụ phổ thông ta hay mua ổ cứng 500 GB thì phải cần hơn 1 triệu 8 trăm ngàn cái ổ cứng như vậy mới lưu hết. Các bạn thấy kinh khủng chưa?

Độ phức tạp thời gian: chuỗi sinh ra theo nguyên tắc gấp đôi lên nên vòng while chỉ lặp $O(\log_2 K)$ lần; push_back, pop_back là $O(1)$; nhưng s += t là $O(N)$ với $N$ là độ dài t, cộng lại toàn bộ các lần nối là $O(K)$. Với $K = 10^{18}$ thì quá ngưỡng $10^8$ nên chắc chắn cũng TLE. Vậy cách làm này không ổn chút nào rồi.

100%Cách 2: chia để trị – lần ngược vị trí K

Ta cần tìm ký tự tại vị trí thứ $K$ (tính từ 1, từ trái qua phải) nhưng không thể đi tạo ra chuỗi được. Để giải quyết bài này ta dùng phương pháp chia để trị (divide and conquer). Hãy xét chuỗi ban đầu là "ABC", qua các phép biến đổi ta được (L là độ dài chuỗi):

LChuỗi (phần in đậm là chuỗi s₂ vừa ghép vào)
3ABC
6ABCCAB
12ABCCABBABCCA
24ABCCABBABCCAAABCCABBABCC

Nếu $K \le 3$ (độ dài chuỗi gốc) thì ta biết ngay đáp án là s[K - 1]. Còn nếu $K > 3$, giả sử cần tìm vị trí $K = 22$: nó thuộc chuỗi có $L = 24$. Nhưng các bạn thử lần ngược lại đi: nó chính là ký tự đứng ở vị trí thứ 9 của chuỗi $L = 12$; lần ngược tiếp, nó là ký tự ở vị trí thứ 2 của chuỗi $L = 6$; mà $2 \le 3$ nên đáp án là s[1], tức là ký tự B.

Tại sao lần ngược được như vậy? Vì chuỗi mới có độ dài gấp đôi chuỗi cũ: nửa đầu chính là chuỗi cũ, còn nửa sau là chuỗi cũ bị "xoay" 1 ký tự (ký tự cuối lên đầu). Nên vị trí thứ $p$ trong nửa sau (tính từ đầu nửa sau) ứng với vị trí $p - 1$ của chuỗi cũ, riêng $p = 1$ là ký tự cuối cùng của chuỗi cũ.

Quy luật
Từ $L$ là độ dài chuỗi gốc, cứ nhân đôi $X = L, 2L, 4L, \ldots$ đến giá trị lớn nhất mà vẫn nhỏ hơn K thì dừng, rồi cập nhật $K = K - X - 1$. Nếu $K$ ra 0 thì gán lại $K = X$. Lặp lại quy trình đến khi $K \le L$ thì in ra s[K - 1].

Vì sao khi $K = 0$ thì gán lại $K = X$? Vì $K = 0$ nghĩa là trước khi trừ đi 1 thì $K$ là vị trí 1 của nửa sau, tức ký tự đầu tiên của chuỗi mới được sinh ra – mà ký tự đó chính là ký tự cuối cùng của chuỗi trước đó (vị trí $X$). Các bạn xem vài ví dụ với chuỗi "ABC" nhé:

K = 22:

KX lớn nhất < KCập nhật
221222 − 12 − 1 = 9
969 − 6 − 1 = 2
2—K ≤ L = 3 ⇒ đáp án s[1] = B

K = 20:

KX lớn nhất < KCập nhật
201220 − 12 − 1 = 7
767 − 6 − 1 = 0 ⇒ bằng 0 nên gán lại K = 6
636 − 3 − 1 = 2
2—K ≤ L = 3 ⇒ đáp án s[1] = B

K = 18:

KX lớn nhất < KCập nhật
181218 − 12 − 1 = 5
535 − 3 − 1 = 1
1—K ≤ L = 3 ⇒ đáp án s[0] = A

K = 17:

KX lớn nhất < KCập nhật
171217 − 12 − 1 = 4
434 − 3 − 1 = 0 ⇒ bằng 0 nên gán lại K = 3
3—K ≤ L = 3 ⇒ đáp án s[2] = C

Em nhập $K$ và xâu $s$ rồi bấm Tiếp để xem từng lần lần ngược: tìm $X$ lớn nhất nhỏ hơn $K$, thấy $K$ nằm ở nửa sau (màu cam), rồi đưa về vị trí tương ứng ở nửa đầu (màu xanh). Nếu chuỗi đủ ngắn mình vẽ luôn từng ký tự cho em kiểm tra:

Ví dụ nhanh:

Và $K = 15$: $15 - 12 - 1 = 2 \Rightarrow$ s[1] = B. Rồi đến đây các bạn đã rõ công thức làm bài này rồi hen.

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

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    long long k;
    cin >> k;
    string s;
    cin >> s;
    long long l = (long long)s.length();
    while(k > l){
        long long x = l;
        while(x * 2 < k){
            x *= 2;
        }
        k = k - x - 1;
        if(k == 0){
            k = x;
        }
    }
    cout << s[k - 1];
    return 0;
}
import sys

k, *rest = sys.stdin.read().split()
k = int(k)
s = rest[0]
l = len(s)
while k > l:
    x = l
    while x * 2 < k:
        x *= 2
    k = k - x - 1
    if k == 0:
        k = x
print(s[k - 1])
C++▶ Chạy tay từng dòng codeCách 2: chia để trị, lần ngược vị trí k về chuỗi gốc (k tới 10^18, dùng BigInt).
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 long long k;
8 cin >> k;
9 string s;
10 cin >> s;
11 long long l = (long long)s.length();
12 while(k > l){
13 long long x = l;
14 while(x * 2 < k){
15 x *= 2;
16 }
17 k = k - x - 1;
18 if(k == 0){
19 k = x;
20 }
21 }
22 cout << s[k - 1];
23 return 0;
24}

Giải thích: vòng lặp lặp liên tục nếu $K > L$, chỉ dừng khi $K \le L$ thì trả về s[K - 1]. Bên trong: tạo biến $X = L$, cứ nhân đôi $X$ nếu X * 2 < K để được giá trị $X$ lớn nhất nhỏ hơn $K$, rồi cập nhật $K = K - X - 1$; nếu $K$ bằng 0 thì gán lại $K = X$.

Độ phức tạp không gian: $O(N)$ với $N$ là độ dài chuỗi $s$ đầu vào, tối đa chỉ 30 ký tự nên yên tâm không bị MLE như cách trước hehe.

Độ phức tạp thời gian: vòng while trong cùng là $O(\log K)$, và giá trị $K$ liên tục giảm đi rất nhiều sau mỗi lần. Các bạn thử đặt biến đếm cnt trong vòng lặp trong cùng, chạy trường hợp xấu nhất $S$ = "A" ($L = 1$) và $K = 10^{18}$ thì thấy số lần lặp cả thảy chỉ khoảng 1 000 lần. Nên yên tâm không sợ bị TLE nhé các bạn. Lưu ý $X$ có thể lên tới gần $10^{18}$ nên X * 2 cỡ $2 \cdot 10^{18}$, vẫn nằm gọn trong long long.

Mình đã kiểm tra cả 2 cách (C++ và Python) với 300 bộ test ngẫu nhiên so với cách sinh chuỗi thật, đều cho cùng kết quả; với $K = 10^{18}$ cả 2 bản đều chạy tức thì.

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