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 (HSG9 Hà Nội 2025 – Bài 4)

Trò Chơi (HSG9 Hà Nội 2025 – Bài 4)
Nguồn: Đề thi HSG lớp 9 Hà Nội 2025 – Bài 4 Độ khó: Khá Mô phỏng Chia lấy dư Nhận xét

Đề bài

TRÒ CHƠI

Cho một bảng vuông kích thước $N \times N$, $N$ là số lẻ. Các hàng của bảng được đánh số từ $1$ tới $N$, từ trên xuống dưới; các cột của bảng được đánh số từ $1$ tới $N$, từ trái sang phải. Ban đầu, các số từ $1$ đến $N^2$ được ghi vào bảng này lần lượt từ trái sang phải, từ trên xuống dưới. Khi $N = 5$ thì bảng vuông sẽ có dạng như Hình 1.

12345
112345
2678910
31112131415
41617181920
52122232425
Hình 1

Luật chơi: Có $Q$ lượt chơi, mỗi lượt chơi quản trò sẽ cấp cho người chơi thông tin là ba số nguyên $P, X, Y$ $(1 \le P \le N^2;\ 1 \le X, Y \le N)$. Người chơi cần đưa số nguyên $P$ đến vị trí hàng $X$ cột $Y$ với số lần dịch bảng nhỏ nhất bằng cách sau:

  • Dịch các số trên hàng chứa số $P$ sang phải hoặc sang trái một ô theo vòng tròn cho đến khi số $P$ nằm trên cột $Y$;
  • Dịch các số trên cột $Y$ lên trên hoặc xuống dưới một ô theo vòng tròn cho đến khi số $P$ nằm trên hàng $X$;
  • Mỗi thao tác dịch hàng hoặc cột như trên được tính là một lần dịch bảng. Bảng đầu tiên của lượt chơi sau chính là bảng kết thúc của lượt chơi trước.

Ví dụ: Xét bảng vuông $5 \times 5$, cần dịch số $17$ trên bảng ban đầu đến vị trí hàng $2$ cột $5$. Để số lần dịch bảng nhỏ nhất thực hiện như Hình 2.

Bảng ban đầu – Lượt 1
12345
678910
1112131415
1617181920
2122232425
Lần 1 – Dịch sang trái
12345
678910
1112131415
1718192016
2122232425
Lần 2 – Dịch sang trái
12345
678910
1112131415
1819201617
2122232425
Lần 3 – Dịch lên trên
123410
678915
1112131417
1819201625
212223245
Lần 4 – Dịch lên trên
123415
678917
1112131425
181920165
2122232410
Hình 2 (ô tô xám: hàng/cột vừa bị dịch; ô viền tím: số P = 17)

Yêu cầu: Cho thông tin của $Q$ lượt chơi. Hãy lập trình đưa ra số lần dịch bảng nhỏ nhất tìm được tương ứng với mỗi lượt.

Dữ liệu vào từ file văn bản TROCHOI.INP:

  • Dòng đầu tiên chứa hai số nguyên dương $N, Q$ $(1 \le N \lt 30000;\ 1 \le Q \le 2000)$;
  • $Q$ dòng sau, mỗi dòng chứa ba số nguyên dương $P, X, Y$ $(1 \le P \le N^2;\ 1 \le X, Y \le N)$ mô tả thông tin của một lượt chơi.

Kết quả ghi ra file văn bản TROCHOI.OUT: Gồm $Q$ dòng, dòng thứ $i$ tương ứng là số lần dịch bảng nhỏ nhất tìm được trong lượt chơi thứ $i$.

Ràng buộc:

  • Có $40\%$ số test ứng với $40\%$ số điểm của bài thoả mãn: $N \lt 100;\ Q \le 100$;
  • $40\%$ số test khác ứng với $40\%$ số điểm của bài thoả mãn: $N \lt 1500;\ Q \le 1500$;
  • $20\%$ số test còn lại ứng với $20\%$ số điểm không có ràng buộc gì thêm.

Lưu ý: đề thi gốc đọc/ghi file TROCHOI.INP / TROCHOI.OUT; trên CLUE OJ thì đọc từ bàn phím và in ra màn hình (code bên dưới có đoạn if(fopen(...)) nên nộp kiểu nào cũng chạy đúng).

TROCHOI.INP
5 3
17 2 5
5 4 2
18 1 1
TROCHOI.OUT
4
2
4

Giải thích (theo đề):

  • Lượt chơi đầu tiên được mô tả bởi Hình 2;
  • Lượt chơi thứ hai thực hiện 2 lần dịch bảng như sau:
Bảng ban đầu – Lượt 2
123415
678917
1112131425
181920165
2122232410
Lần 1 – Dịch sang phải
123415
678917
1112131425
518192016
2122232410
Lần 2 – Dịch sang phải
123415
678917
1112131425
165181920
2122232410
Lượt chơi thứ hai (số P = 5 đưa về hàng 4 cột 2)
  • Lượt chơi thứ ba thực hiện 2 lần dịch hàng 4 sang trái và 2 lần dịch cột 1 xuống dưới.

Lời mở đầu

Đây là bài 4 trong đề thi học sinh giỏi Tin học lớp 9 thành phố Hà Nội năm 2025. Đề nhìn thì dài, nhiều hình nhưng thực chất là một bài mô phỏng kết hợp vài nhận xét khá thú vị. Mình viết lời giải theo đúng 3 subtask của đề: từ dịch bảng trâu, đến dịch nhanh bằng chia lấy dư, rồi cuối cùng là bỏ hẳn cái bảng đi, chỉ theo dõi toạ độ. 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é:

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 thêm ví dụ để bạn nào chưa hiểu đề sẽ hiểu rõ. Mỗi lượt chơi luôn gồm 2 giai đoạn theo đúng thứ tự:

  1. Dịch hàng đang chứa $P$ (sang trái hoặc sang phải, vòng tròn) cho tới khi $P$ nằm ở cột $Y$. "Vòng tròn" nghĩa là dịch sang trái thì số ở cột $1$ sẽ quay vòng về cột $N$, dịch sang phải thì số ở cột $N$ quay về cột $1$.
  2. Dịch cột $Y$ (lên hoặc xuống, vòng tròn) cho tới khi $P$ nằm ở hàng $X$.

Lượt 1: số $17$ đang ở hàng $4$ cột $2$, cần tới hàng $2$ cột $5$. Trên hàng, đi sang trái: cột $2 \to 1 \to 5$ mất 2 lần; đi sang phải: $2 \to 3 \to 4 \to 5$ mất 3 lần ⇒ chọn sang trái. Trên cột $5$, đi lên: hàng $4 \to 3 \to 2$ mất 2 lần; đi xuống: $4 \to 5 \to 1 \to 2$ mất 3 lần ⇒ chọn lên trên. Tổng $2 + 2 = 4$.

Lượt 2 dùng tiếp bảng sau lượt 1: số $5$ đang ở hàng $4$ cột $5$, cần tới hàng $4$ cột $2$: dịch phải $2$ lần là xong ($5 \to 1 \to 2$), cột thì đã đúng hàng rồi nên không cần dịch ⇒ $2$. Lượt 3: số $18$ ở hàng $4$ cột $3$ → dịch trái $2$ lần về cột $1$, rồi trên cột $1$ đi từ hàng $4$ xuống $5 \to 1$ mất $2$ lần ⇒ $4$.

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

Subtask 1Dịch bảng trâu – O(Q·N²)

Ở subtask này $N \lt 100$, $Q \le 100$ nên ta cứ lưu nguyên cái bảng và làm đúng như đề. Trước hết là một nhận xét quan trọng:

Vì N lẻ nên cách dịch ít nhất là duy nhất
Trên một hàng (hoặc một cột) có $N$ ô xếp vòng tròn, muốn đưa một số từ vị trí $val$ tới vị trí $tag \ne val$ thì: số lần dịch sang trái + số lần dịch sang phải $= N$. Vì $N$ lẻ nên hai số này không bao giờ bằng nhau ⇒ luôn có đúng một hướng tốt nhất. Còn nếu $val = tag$ thì không cần dịch lần nào.

Thêm nữa, việc chọn dịch trái hay phải (trên hàng) và việc chọn dịch lên hay xuống (trên cột) có thể coi là hai thao tác độc lập: dịch hàng xong thì $P$ chắc chắn nằm ở cột $Y$, còn đang ở hàng nào thì vẫn là hàng cũ, nên số lần dịch cột ít nhất không phụ thuộc vào việc lúc nãy ta dịch hàng theo hướng nào. Và tất nhiên cũng không phụ thuộc vào các lượt trước dịch như thế nào – các lượt trước chỉ quyết định $P$ đang nằm ở đâu thôi.

Mình viết 2 hàm tính số lần dịch:

  • left(val, tag): số lần dịch sang trái (hoặc lên trên nếu là cột) để đưa vị trí $val$ về vị trí $tag$. Mỗi lần dịch trái, vị trí giảm đi $1$ (từ $1$ quay vòng về $N$). Nếu $val \gt tag$ thì cần $val - tag$ lần; nếu $val \le tag$ thì phải đi về $1$ rồi vòng qua $N$: cần $val + (N - tag)$ lần.
  • right(val, tag): số lần dịch sang phải (hoặc xuống dưới). Nếu $val \le tag$ thì cần $tag - val$ lần; ngược lại cần $N - val + tag$ lần.

Để ý khi $val = tag$ thì left trả về $N$ còn right trả về $0$, nên code (chọn trái chỉ khi mn_l < mn_r) sẽ chọn "dịch phải 0 lần" – đúng là không dịch gì cả.

Với mỗi lượt chơi, ta tìm xem số $P$ hiện đang nằm ở ô $(u, v)$ nào trên bảng, tính hướng dịch tốt nhất trên hàng $u$, rồi thực hiện dịch trâu từng lần một (mỗi lần dịch hàng là dời cả $N$ số). Sau đó $P$ đã ở cột $Y$, ta làm tương tự với cột $Y$. Các bạn nhập thử vài lượt chơi vào khung minh hoạ dưới đây để xem từng lần dịch bảng nha:

Ví dụ nhanh:

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 n, q, p, x, y;
int a[105][105];

int left(int val, int tag){
    if (val <= tag) return val + (n - tag);
    else return abs(tag - val);
}

int right(int val, int tag){
    if (val <= tag) return abs(tag - val);
    else return n - val + tag;
}

int main(){
    if(fopen("trochoi.inp", "r")){
        freopen("trochoi.inp", "r", stdin);
        freopen("trochoi.out", "w", stdout);
    }
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> n >> q;
    for (int i = 1; i <= n; i++){
        for (int j = 1; j <= n; j++) a[i][j] = (i - 1) * n + j;
    }
    for (int i = 1; i <= q; i++){
        cin >> p >> x >> y;
        int res = 0;
        bool ok = 0;
        for (int u = 1; u <= n; u++){
            for (int v = 1; v <= n; v++){
                if (a[u][v] == p){
                    int mn_l = left(v, y);
                    int mn_r = right(v, y);
                    if (mn_l < mn_r){
                        res += mn_l;
                        while (mn_l--){
                            int g = a[u][1];
                            for (int j = 1; j < n; j++) a[u][j] = a[u][j + 1];
                            a[u][n] = g;
                        }
                    }
                    else{
                        res += mn_r;
                        while (mn_r--){
                            int g = a[u][n];
                            for (int j = n; j > 1; j--) a[u][j] = a[u][j - 1];
                            a[u][1] = g;
                        }
                    }
                    v = y;
                    mn_l = left(u, x);
                    mn_r = right(u, x);
                    if (mn_l < mn_r){
                        res += mn_l;
                        while (mn_l--){
                            int g = a[1][v];
                            for (int j = 1; j < n; j++) a[j][v] = a[j + 1][v];
                            a[n][v] = g;
                        }
                    }
                    else{
                        res += mn_r;
                        while (mn_r--){
                            int g = a[n][v];
                            for (int j = n; j > 1; j--) a[j][v] = a[j - 1][v];
                            a[1][v] = g;
                        }
                    }
                    ok = 1;
                    cout << res << endl;
                    break;
                }
            }
            if (ok) break;
        }
    }
}
import sys, os


def left(val, tag):
    # số lần dịch sang TRÁI để đi từ vị trí val tới vị trí tag
    if val <= tag:
        return val + (n - tag)
    return abs(tag - val)


def right(val, tag):
    # số lần dịch sang PHẢI để đi từ vị trí val tới vị trí tag
    if val <= tag:
        return abs(tag - val)
    return n - val + tag


if os.path.exists('trochoi.inp'):
    sys.stdin = open('trochoi.inp')
    sys.stdout = open('trochoi.out', 'w')
data = sys.stdin.read().split()
n, q = int(data[0]), int(data[1])
a = [[0] * (n + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
    for j in range(1, n + 1):
        a[i][j] = (i - 1) * n + j
out = []
pos = 2
for _ in range(q):
    p, x, y = int(data[pos]), int(data[pos + 1]), int(data[pos + 2])
    pos += 3
    res = 0
    found = False
    for u in range(1, n + 1):
        for v in range(1, n + 1):
            if a[u][v] == p:
                mn_l, mn_r = left(v, y), right(v, y)
                if mn_l < mn_r:
                    res += mn_l
                    for _t in range(mn_l):          # dịch hàng u sang trái 1 ô
                        g = a[u][1]
                        for j in range(1, n):
                            a[u][j] = a[u][j + 1]
                        a[u][n] = g
                else:
                    res += mn_r
                    for _t in range(mn_r):          # dịch hàng u sang phải 1 ô
                        g = a[u][n]
                        for j in range(n, 1, -1):
                            a[u][j] = a[u][j - 1]
                        a[u][1] = g
                v = y
                mn_l, mn_r = left(u, x), right(u, x)
                if mn_l < mn_r:
                    res += mn_l
                    for _t in range(mn_l):          # dịch cột v lên trên 1 ô
                        g = a[1][v]
                        for j in range(1, n):
                            a[j][v] = a[j + 1][v]
                        a[n][v] = g
                else:
                    res += mn_r
                    for _t in range(mn_r):          # dịch cột v xuống dưới 1 ô
                        g = a[n][v]
                        for j in range(n, 1, -1):
                            a[j][v] = a[j - 1][v]
                        a[1][v] = g
                found = True
                out.append(str(res))
                break
        if found:
            break
print('\n'.join(out))
sys.stdout.flush()

Giải thích 1 số chỗ trong code ở trên:

  1. Dịch hàng $u$ sang trái 1 ô: cất g = a[u][1], dời a[u][j] = a[u][j + 1] với $j$ từ $1$ tới $N - 1$, rồi đặt a[u][n] = g (số ở đầu quay vòng về cuối). Dịch sang phải thì ngược lại: cất a[u][n], dời từ cuối về đầu, rồi đặt vào a[u][1]. Dịch cột lên/xuống cũng y hệt, chỉ đổi chỉ số hàng thành chỉ số cột.
  2. while (mn_l--): lặp đúng mn_l lần (kiểm tra giá trị hiện tại khác $0$ rồi mới giảm).
  3. Sau khi dịch hàng xong, $P$ chắc chắn nằm ở cột $Y$ nên mình gán luôn v = y, còn hàng vẫn là $u$.
  4. Biến ok dùng để thoát cả 2 vòng lặp tìm kiếm khi đã xử lý xong số $P$.
C++▶ Chạy tay từng dòng codeSubtask 1: tìm ô chứa P, tính hướng dịch ít nhất bằng left/right rồi dịch trâu từng ô một.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2using namespace std;
3
4int n, q, p, x, y;
5int a[105][105];
6
7int left(int val, int tag){
8 if (val <= tag) return val + (n - tag);
9 else return abs(tag - val);
10}
11
12int right(int val, int tag){
13 if (val <= tag) return abs(tag - val);
14 else return n - val + tag;
15}
16
17int main(){
18 if(fopen("trochoi.inp", "r")){
19 freopen("trochoi.inp", "r", stdin);
20 freopen("trochoi.out", "w", stdout);
21 }
22 ios::sync_with_stdio(0);
23 cin.tie(0);
24 cin >> n >> q;
25 for (int i = 1; i <= n; i++){
26 for (int j = 1; j <= n; j++) a[i][j] = (i - 1) * n + j;
27 }
28 for (int i = 1; i <= q; i++){
29 cin >> p >> x >> y;
30 int res = 0;
31 bool ok = 0;
32 for (int u = 1; u <= n; u++){
33 for (int v = 1; v <= n; v++){
34 if (a[u][v] == p){
35 int mn_l = left(v, y);
36 int mn_r = right(v, y);
37 if (mn_l < mn_r){
38 res += mn_l;
39 while (mn_l--){
40 int g = a[u][1];
41 for (int j = 1; j < n; j++) a[u][j] = a[u][j + 1];
42 a[u][n] = g;
43 }
44 }
45 else{
46 res += mn_r;
47 while (mn_r--){
48 int g = a[u][n];
49 for (int j = n; j > 1; j--) a[u][j] = a[u][j - 1];
50 a[u][1] = g;
51 }
52 }
53 v = y;
54 mn_l = left(u, x);
55 mn_r = right(u, x);
56 if (mn_l < mn_r){
57 res += mn_l;
58 while (mn_l--){
59 int g = a[1][v];
60 for (int j = 1; j < n; j++) a[j][v] = a[j + 1][v];
61 a[n][v] = g;
62 }
63 }
64 else{
65 res += mn_r;
66 while (mn_r--){
67 int g = a[n][v];
68 for (int j = n; j > 1; j--) a[j][v] = a[j - 1][v];
69 a[1][v] = g;
70 }
71 }
72 ok = 1;
73 cout << res << endl;
74 break;
75 }
76 }
77 if (ok) break;
78 }
79 }
80}

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

  • Không gian: lưu cả bảng $N \times N$: $O(N^2)$.
  • Thời gian: mỗi lượt, tìm số $P$ tốn $O(N^2)$; dịch hàng/cột tối đa khoảng $N/2$ lần, mỗi lần dời $N$ số nên cũng $O(N^2)$. Tổng là $O(Q \cdot N^2)$. Với $N \lt 100$, $Q \le 100$ thì chỉ cỡ $10^6$ phép tính.
Không gian$O(N^2)$
Thời gian$O(Q \cdot N^2)$

Subtask 2Dịch nhanh bằng chia lấy dư – O(Q·N²)

Thay vì thực hiện xoay trâu từng lần một, ta nhận thấy rằng nếu ta biết hướng xoay và số lượt xoay nhỏ nhất thì ta luôn suy ra được vị trí của một ô sau khi xoay. Chép hàng (hoặc cột) cũ ra mảng old, rồi tính luôn hàng mới chỉ với một vòng lặp:

  • Dịch trái $k$ ô: ô thứ $i$ của hàng mới nhận số ở ô thứ $i + k$ của hàng cũ (vòng tròn), tức là a[u][i] = old[(i + k - 1) % n + 1].
  • Dịch phải $k$ ô: ô thứ $i$ nhận số ở ô thứ $i - k$ của hàng cũ, tức là a[u][i] = old[(i - k - 1 + n * n) % n + 1].
Vì sao lại "-1 rồi +1" và "+ n * n"?
Chỉ số của mình chạy từ $1$ đến $N$, còn phép % cho kết quả từ $0$ đến $N - 1$. Nên ta trừ $1$ để đưa về hệ đếm từ $0$, lấy dư, rồi cộng $1$ lại. Còn i - k - 1 có thể âm, mà trong C++ phép % với số âm cho ra số âm (ví dụ -2 % 5 = -2), nên ta cộng thêm một bội của $N$ (ở đây là $N \cdot N$) cho chắc chắn không âm. Vì $k \lt N$ nên thật ra cộng $N$ là đủ rồi.

Ví dụ hàng $4$ của bảng ban đầu khi $N = 5$ là 16 17 18 19 20, dịch trái $k = 2$: ô $1$ nhận old[(1 + 2 - 1) % 5 + 1] = old[3] = 18, ô $4$ nhận old[(4 + 1) % 5 + 1] = old[1] = 16… ra đúng 18 19 20 16 17 như Hình 2.

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 n, q, p, x, y;
int a[1505][1505];
int old[1505];

int left(int val, int tag){
    if (val <= tag) return val + (n - tag);
    else return abs(tag - val);
}

int right(int val, int tag){
    if (val <= tag) return abs(tag - val);
    else return n - val + tag;
}

int main(){
    if(fopen("trochoi.inp", "r")){
        freopen("trochoi.inp", "r", stdin);
        freopen("trochoi.out", "w", stdout);
    }
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> n >> q;
    for (int i = 1; i <= n; i++){
        for (int j = 1; j <= n; j++) a[i][j] = (i - 1) * n + j;
    }
    for (int i = 1; i <= q; i++){
        cin >> p >> x >> y;
        int res = 0;
        bool ok = 0;
        for (int u = 1; u <= n; u++){
            for (int v = 1; v <= n; v++){
                if (a[u][v] == p){
                    int mn_l = left(v, y);
                    int mn_r = right(v, y);
                    if (mn_l < mn_r){
                        res += mn_l;
                        for (int i = 1; i <= n; i++) old[i] = a[u][i];
                        for (int i = 1; i <= n; i++) a[u][i] = old[(i + mn_l - 1) % n + 1];
                    }
                    else{
                        res += mn_r;
                        for (int i = 1; i <= n; i++) old[i] = a[u][i];
                        for (int i = 1; i <= n; i++) a[u][i] = old[(i - mn_r - 1 + n * n) % n + 1];
                    }
                    v = y;
                    mn_l = left(u, x);
                    mn_r = right(u, x);
                    if (mn_l < mn_r){
                        res += mn_l;
                        for (int i = 1; i <= n; i++) old[i] = a[i][v];
                        for (int i = 1; i <= n; i++) a[i][v] = old[(i + mn_l - 1) % n + 1];
                    }
                    else{
                        res += mn_r;
                        for (int i = 1; i <= n; i++) old[i] = a[i][v];
                        for (int i = 1; i <= n; i++) a[i][v] = old[(i - mn_r - 1 + n * n) % n + 1];
                    }
                    ok = 1;
                    cout << res << endl;
                    break;
                }
            }
            if (ok) break;
        }
    }
}
import sys, os


def left(val, tag):
    if val <= tag:
        return val + (n - tag)
    return abs(tag - val)


def right(val, tag):
    if val <= tag:
        return abs(tag - val)
    return n - val + tag


if os.path.exists('trochoi.inp'):
    sys.stdin = open('trochoi.inp')
    sys.stdout = open('trochoi.out', 'w')
data = sys.stdin.read().split()
n, q = int(data[0]), int(data[1])
# a[u][v] với u, v tính từ 1 (cột 0, hàng 0 bỏ trống)
a = [[0] * (n + 1)] + [[0] + [(i - 1) * n + j for j in range(1, n + 1)] for i in range(1, n + 1)]
out = []
pos = 2
for _ in range(q):
    p, x, y = int(data[pos]), int(data[pos + 1]), int(data[pos + 2])
    pos += 3
    res = 0
    for u in range(1, n + 1):
        if p in a[u]:                 # tìm hàng chứa p
            v = a[u].index(p)
            break
    mn_l, mn_r = left(v, y), right(v, y)
    old = a[u][:]
    if mn_l < mn_r:
        res += mn_l
        for i in range(1, n + 1):
            a[u][i] = old[(i + mn_l - 1) % n + 1]
    else:
        res += mn_r
        for i in range(1, n + 1):
            a[u][i] = old[(i - mn_r - 1) % n + 1]
    v = y
    mn_l, mn_r = left(u, x), right(u, x)
    old = [a[i][v] for i in range(n + 1)]
    if mn_l < mn_r:
        res += mn_l
        for i in range(1, n + 1):
            a[i][v] = old[(i + mn_l - 1) % n + 1]
    else:
        res += mn_r
        for i in range(1, n + 1):
            a[i][v] = old[(i - mn_r - 1) % n + 1]
    out.append(str(res))
print('\n'.join(out))
sys.stdout.flush()
C++▶ Chạy tay từng dòng codeSubtask 2: như subtask 1 nhưng dịch k ô một lần bằng công thức chia lấy dư trên mảng old.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2using namespace std;
3
4int n, q, p, x, y;
5int a[1505][1505];
6int old[1505];
7
8int left(int val, int tag){
9 if (val <= tag) return val + (n - tag);
10 else return abs(tag - val);
11}
12
13int right(int val, int tag){
14 if (val <= tag) return abs(tag - val);
15 else return n - val + tag;
16}
17
18int main(){
19 if(fopen("trochoi.inp", "r")){
20 freopen("trochoi.inp", "r", stdin);
21 freopen("trochoi.out", "w", stdout);
22 }
23 ios::sync_with_stdio(0);
24 cin.tie(0);
25 cin >> n >> q;
26 for (int i = 1; i <= n; i++){
27 for (int j = 1; j <= n; j++) a[i][j] = (i - 1) * n + j;
28 }
29 for (int i = 1; i <= q; i++){
30 cin >> p >> x >> y;
31 int res = 0;
32 bool ok = 0;
33 for (int u = 1; u <= n; u++){
34 for (int v = 1; v <= n; v++){
35 if (a[u][v] == p){
36 int mn_l = left(v, y);
37 int mn_r = right(v, y);
38 if (mn_l < mn_r){
39 res += mn_l;
40 for (int i = 1; i <= n; i++) old[i] = a[u][i];
41 for (int i = 1; i <= n; i++) a[u][i] = old[(i + mn_l - 1) % n + 1];
42 }
43 else{
44 res += mn_r;
45 for (int i = 1; i <= n; i++) old[i] = a[u][i];
46 for (int i = 1; i <= n; i++) a[u][i] = old[(i - mn_r - 1 + n * n) % n + 1];
47 }
48 v = y;
49 mn_l = left(u, x);
50 mn_r = right(u, x);
51 if (mn_l < mn_r){
52 res += mn_l;
53 for (int i = 1; i <= n; i++) old[i] = a[i][v];
54 for (int i = 1; i <= n; i++) a[i][v] = old[(i + mn_l - 1) % n + 1];
55 }
56 else{
57 res += mn_r;
58 for (int i = 1; i <= n; i++) old[i] = a[i][v];
59 for (int i = 1; i <= n; i++) a[i][v] = old[(i - mn_r - 1 + n * n) % n + 1];
60 }
61 ok = 1;
62 cout << res << endl;
63 break;
64 }
65 }
66 if (ok) break;
67 }
68 }
69}

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

  • Không gian: vẫn lưu cả bảng: $O(N^2)$ (bảng $1505 \times 1505$ số int khoảng 9 MB).
  • Thời gian: phần dịch bảng giờ chỉ còn $O(N)$ mỗi lượt. Nhưng phần tìm số $P$ trên bảng vẫn tốn $O(N^2)$ mỗi lượt, nên tổng vẫn là $O(Q \cdot N^2)$ – chỉ nhanh hơn subtask 1 ở hằng số.
Không gian$O(N^2)$
Thời gian$O(Q \cdot N^2)$
Cẩn thận: bước tìm P mới là "nút thắt"
Mình đo thử code trên với test lớn nhất của subtask 2 ($N = 1499$, $Q = 1500$, ngẫu nhiên) thì mất khoảng 4 giây, gần như toàn bộ là do 2 vòng lặp tìm $P$ (khoảng $Q \cdot N^2 / 2 \approx 1.7 \cdot 10^9$ phép so sánh). Nếu muốn chắc ăn subtask 2, các bạn có thể lưu thêm 2 mảng vị trí hang[P], cot[P] (kích thước $N^2$) và cập nhật lại chúng mỗi khi dịch một hàng/cột (mất $O(N)$) – khi đó mỗi lượt chỉ còn $O(N)$. Tuy nhiên ý tưởng ở subtask 3 dưới đây còn gọn hơn và ăn trọn $100\%$ luôn.

Subtask 3Bỏ cái bảng đi, chỉ truy vết toạ độ – O(Q²)

Ở subtask cuối $N$ lên tới gần $30000$: bảng có tới $9 \cdot 10^8$ ô, không thể lưu nổi. Nhưng $Q \le 2000$ lại khá nhỏ. Hướng đi của chúng ta sẽ thay đổi một chút: không lưu bảng nữa, chỉ theo dõi toạ độ của đúng số $P$ cần hỏi.

Với mỗi ván $i$, ta gọi $(idx, idy)$ là toạ độ của số $P$ ngay trước ván chơi thứ $i$. Ban đầu (trước ván $1$) số $P$ nằm ở hàng $\left\lfloor \dfrac{P - 1}{N} \right\rfloor + 1$, cột $(P - 1) \bmod N + 1$. Ta nhận thấy rằng toạ độ của $P$ chỉ thay đổi khi có một ván $j$ nào đó dịch đúng hàng $idx$ hoặc đúng cột $idy$ đang chứa nó. Các số khác bị dịch đi đâu thì kệ chúng, ta không quan tâm!

Ý tưởng chính
Với mỗi ván $j$ đã chơi, lưu lại 4 thông tin: hàng đã dịch choose_x[j] và độ dời cột chg_x[j] (âm = dịch trái, dương = dịch phải); cột đã dịch choose_y[j] và độ dời hàng chg_y[j] (âm = dịch lên, dương = dịch xuống). Đến ván $i$, ta "chơi lại" các ván $j = 1, 2, \ldots, i - 1$ chỉ với toạ độ của $P$:
  • Nếu choose_x[j] == idx (hàng chứa $P$ bị dịch) thì $idy \leftarrow (idy - 1 + chg\_x[j]) \bmod N + 1$.
  • Sau đó nếu choose_y[j] == idy (cột chứa $P$ bị dịch) thì $idx \leftarrow (idx - 1 + chg\_y[j]) \bmod N + 1$.

Phải xét hàng trước rồi mới tới cột đúng như thứ tự trong một ván chơi, vì có thể việc dịch hàng ở ván $j$ đẩy $P$ vào đúng cột sắp bị dịch của ván $j$. Biết toạ độ của số đó trước ván $i$ rồi thì ta dễ dàng tính được số lượt xoay, hướng xoay trên hàng và cột để đưa số tới đúng vị trí mục tiêu bằng 2 hàm left, right như cũ. Lưu ý là phải lưu lại số lượt xoay cần thực hiện, hướng xoay trên hàng và cột, địa chỉ hàng và cột thực hiện xoay tại ván $i$ để có dữ liệu cho việc tìm toạ độ ở những ván tiếp theo: hàng bị dịch ở ván $i$ chính là $idx$, còn cột bị dịch chính là $Y$.

Các bạn xem khung minh hoạ dưới đây: bảng chỉ để vẽ cho dễ nhìn thôi, các ô đều trống – thuật toán chỉ cần biết số $P$ đang ở đâu và nhật ký các ván trước:

Ví dụ nhanh:

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 n, q, p, x, y;
int choose_x[2005];
int choose_y[2005];
int chg_x[2005];
int chg_y[2005];

int left(int val, int tag){
    if (val <= tag) return val + (n - tag);
    else return abs(tag - val);
}

int right(int val, int tag){
    if (val <= tag) return abs(tag - val);
    else return n - val + tag;
}

int main(){
    if(fopen("trochoi.inp", "r")){
        freopen("trochoi.inp", "r", stdin);
        freopen("trochoi.out", "w", stdout);
    }
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> n >> q;
    for (int i = 1; i <= q; i++){
        cin >> p >> x >> y;
        int idx = (p - 1) / n + 1;
        int idy = (p - 1) % n + 1;
        for (int j = 1; j < i; j++){
            if (choose_x[j] == idx){
                idy--;
                idy += chg_x[j];
                idy += n * n;
                idy %= n;
                idy++;
            }
            if (choose_y[j] == idy){
                idx--;
                idx += chg_y[j];
                idx += n * n;
                idx %= n;
                idx++;
            }
        }
        choose_x[i] = idx;
        choose_y[i] = y;
        int mn_x_left = left(idy, y);
        int mn_x_right = right(idy, y);
        if (mn_x_left < mn_x_right) chg_x[i] += -mn_x_left;
        else chg_x[i] += mn_x_right;
        int mn_y_left = left(idx, x);
        int mn_y_right = right(idx, x);
        if (mn_y_left < mn_y_right) chg_y[i] += -mn_y_left;
        else chg_y[i] += mn_y_right;
        cout << min(mn_y_left, mn_y_right) + min(mn_x_left, mn_x_right) << '\n';
    }
}
import sys, os


def left(val, tag):
    if val <= tag:
        return val + (n - tag)
    return abs(tag - val)


def right(val, tag):
    if val <= tag:
        return abs(tag - val)
    return n - val + tag


if os.path.exists('trochoi.inp'):
    sys.stdin = open('trochoi.inp')
    sys.stdout = open('trochoi.out', 'w')
data = sys.stdin.read().split()
n, q = int(data[0]), int(data[1])
choose_x = [0] * (q + 1)   # hàng đã dịch ở lượt j
choose_y = [0] * (q + 1)   # cột đã dịch ở lượt j
chg_x = [0] * (q + 1)      # độ dời cột khi dịch hàng (âm = sang trái)
chg_y = [0] * (q + 1)      # độ dời hàng khi dịch cột (âm = lên trên)
out = []
pos = 2
for i in range(1, q + 1):
    p, x, y = int(data[pos]), int(data[pos + 1]), int(data[pos + 2])
    pos += 3
    idx = (p - 1) // n + 1
    idy = (p - 1) % n + 1
    for j in range(1, i):
        if choose_x[j] == idx:
            idy = (idy - 1 + chg_x[j]) % n + 1
        if choose_y[j] == idy:
            idx = (idx - 1 + chg_y[j]) % n + 1
    choose_x[i] = idx
    choose_y[i] = y
    mn_x_left, mn_x_right = left(idy, y), right(idy, y)
    chg_x[i] = -mn_x_left if mn_x_left < mn_x_right else mn_x_right
    mn_y_left, mn_y_right = left(idx, x), right(idx, x)
    chg_y[i] = -mn_y_left if mn_y_left < mn_y_right else mn_y_right
    out.append(str(min(mn_y_left, mn_y_right) + min(mn_x_left, mn_x_right)))
print('\n'.join(out))
sys.stdout.flush()

Giải thích 1 số chỗ trong code ở trên:

  1. Đoạn idy--; idy += chg_x[j]; idy += n * n; idy %= n; idy++; chính là công thức $idy \leftarrow (idy - 1 + chg\_x[j]) \bmod N + 1$ viết tách ra từng bước. Cộng thêm n * n để số không bị âm trước khi lấy dư (vì chg_x[j] có thể âm). $N \lt 30000$ nên $N^2 \lt 9 \cdot 10^8$ vẫn vừa kiểu int.
  2. chg_x[i] += -mn_x_left: dịch trái thì cột giảm nên lưu số âm, dịch phải thì lưu số dương. Mảng toàn cục ban đầu bằng $0$ nên += ở đây cũng như gán.
  3. Tên biến hơi dễ nhầm các bạn để ý nha: mn_x_left/right là số lần dịch trên hàng (làm thay đổi cột $idy$), còn mn_y_left/right là số lần dịch trên cột (làm thay đổi hàng $idx$).
C++▶ Chạy tay từng dòng codeSubtask 3: tính toạ độ ban đầu của P, chơi lại các ván trước chỉ với toạ độ, rồi lưu nhật ký ván hiện tại.
Dữ liệu mẫu:
1#include<bits/stdc++.h>
2using namespace std;
3
4int n, q, p, x, y;
5int choose_x[2005];
6int choose_y[2005];
7int chg_x[2005];
8int chg_y[2005];
9
10int left(int val, int tag){
11 if (val <= tag) return val + (n - tag);
12 else return abs(tag - val);
13}
14
15int right(int val, int tag){
16 if (val <= tag) return abs(tag - val);
17 else return n - val + tag;
18}
19
20int main(){
21 if(fopen("trochoi.inp", "r")){
22 freopen("trochoi.inp", "r", stdin);
23 freopen("trochoi.out", "w", stdout);
24 }
25 ios::sync_with_stdio(0);
26 cin.tie(0);
27 cin >> n >> q;
28 for (int i = 1; i <= q; i++){
29 cin >> p >> x >> y;
30 int idx = (p - 1) / n + 1;
31 int idy = (p - 1) % n + 1;
32 for (int j = 1; j < i; j++){
33 if (choose_x[j] == idx){
34 idy--;
35 idy += chg_x[j];
36 idy += n * n;
37 idy %= n;
38 idy++;
39 }
40 if (choose_y[j] == idy){
41 idx--;
42 idx += chg_y[j];
43 idx += n * n;
44 idx %= n;
45 idx++;
46 }
47 }
48 choose_x[i] = idx;
49 choose_y[i] = y;
50 int mn_x_left = left(idy, y);
51 int mn_x_right = right(idy, y);
52 if (mn_x_left < mn_x_right) chg_x[i] += -mn_x_left;
53 else chg_x[i] += mn_x_right;
54 int mn_y_left = left(idx, x);
55 int mn_y_right = right(idx, x);
56 if (mn_y_left < mn_y_right) chg_y[i] += -mn_y_left;
57 else chg_y[i] += mn_y_right;
58 cout << min(mn_y_left, mn_y_right) + min(mn_x_left, mn_x_right) << '\n';
59 }
60}

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

  • Không gian: chỉ 4 mảng nhật ký độ dài $Q$: $O(Q)$. Không còn phụ thuộc vào $N$ nữa.
  • Thời gian: ván thứ $i$ phải chơi lại $i - 1$ ván trước, mỗi ván $O(1)$: tổng $1 + 2 + \ldots + (Q - 1) = O(Q^2)$. Với $Q = 2000$ chỉ khoảng $2 \cdot 10^6$ phép tính.
Không gian$O(Q)$
Thời gian$O(Q^2)$
Ghi chú của mình: code C++ và Python của cả 3 subtask đã được kiểm tra với 400 bộ test ngẫu nhiên nhỏ ($N$ lẻ $\le 11$, tối đa 30 lượt), so với một code vét cạn viết riêng (thử cả 2 hướng dịch từng ô một) – khớp hết. Test lớn: subtask 1 với $N = 99$, $Q = 100$ chạy khoảng 0.01 giây; subtask 2 với $N = 1499$, $Q = 1500$ mất khoảng 4 giây (C++) như đã nói ở trên, bản Python còn chậm hơn nhiều; subtask 3 với $N = 29999$, $Q = 2000$ chạy tức thì (C++ khoảng 0.01 giây, Python khoảng 0.3 giây). Ba code cho cùng kết quả trên các test lớn mà cả ba cùng chạy được.
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