Trò chơi 4: Game 2048 trong C++ và Python: mảng 2 chiều, trượt và gộp một hàng, xoay ma trận cho 4 hướng, máy tự chơi bằng tham lam
Lời mở đầu
Năm 2014, một bạn sinh viên 19 tuổi người Ý tên Gabriele Cirulli viết trò 2048 trong một dịp cuối tuần, đăng lên mạng cho vui… và chỉ sau một tuần đã có hàng triệu người chơi. Luật chơi gói gọn trong ba câu:
- Bàn 4 × 4, mỗi ô trống hoặc chứa một lũy thừa của 2. Mỗi lượt em bấm một trong 4 hướng, mọi ô trượt hết cỡ về hướng đó.
- Hai ô cùng số va vào nhau thì gộp thành một ô gấp đôi, và em được cộng điểm bằng đúng số mới. Mỗi ô chỉ được gộp một lần trong một lượt.
- Sau mỗi lượt có thay đổi, một ô mới (thường là 2, đôi khi là 4) hiện ra ở một ô trống ngẫu nhiên. Tạo được ô 2048 là thắng; bàn đầy mà không gộp được nữa là thua.
Hình 1. Bấm ← trên bàn bên trái: hàng 1 có 2 2 gộp thành 4; hàng 2 có ba số 4 thì chỉ cặp sát mép trái gộp thành 8, số 4 còn lại đứng cạnh; hàng 3 có hai số 8 cách một ô trống vẫn gộp được. Tổng cộng +28 điểm.
Trông thì đơn giản, nhưng khi tự viết code, rất nhiều bạn ra kết quả sai ở đúng những hàng như 2 2 4 hay 4 4 4. Bài này dạy em làm chuẩn từng bước: nếu cần ôn mảng 2 chiều, em xem lại bài 7: Mảng 2 chiều; kỹ thuật dồn phần tử bằng hai chỉ số ở bài 17: Hai con trỏ; các phép xoay, lật, chuyển vị ma trận ở chuyên đề 4: Mô phỏng trên lưới; còn chiến thuật cho máy chơi dựa trên bài 18: Tham lam.
2 2 4 thành 8); chỉ viết một hàm trượt trái rồi dùng chuyển vị, đảo hàng để làm đủ 4 hướng; kiểm tra nước đi hợp lệ, sinh ô mới bằng hạt giống để ván chơi tái lập được, kiểm tra hết nước đi; viết máy tự chơi bằng hàm đánh giá bàn (số ô trống, ô lớn ở góc) và tham lam nhìn trước 2 nước. Có khung CHƠI THỬ game 2048 thật, 3 mô phỏng bấm từng bước, 2 khung chạy tay và 8 bài tập kèm gợi ý.Lộ trình bài viết, bấm vào từng phần để nhảy tới:
Phần 1Chơi thử trước đã!
Học lập trình một trò chơi thì phải chơi nó trước đã. Khung dưới đây là game 2048 thật, viết đúng bằng các hàm em sẽ học trong bài. Trên máy tính, em bấm vào bàn rồi dùng phím mũi tên (hoặc W, A, S, D); trên điện thoại thì vuốt trên bàn hoặc bấm 4 nút mũi tên. Bí quá thì bấm “💡 Máy gợi ý hướng đi”, hoặc để máy đi giúp một nước.
Thanh điều khiển bên dưới bàn dùng để xem máy tự chơi cả ván: bấm Tiếp ▶ để máy đi từng nước kèm lời giải thích, Tự chạy để xem liền một mạch, ⏭ để nhảy tới cuối ván. Dừng ở bước nào cũng được, em có thể tự chơi tiếp từ chính bàn đó. Ô Hạt giống quyết định vị trí các ô mới: cùng hạt giống thì cùng một ván, nên em có thể rủ bạn thi đấu trên cùng một ván xem ai điểm cao hơn.
Chơi vài ván em sẽ để ý: muốn đi xa thì phải giữ nhiều ô trống và dồn ô lớn nhất vào một góc, rồi xếp các ô nhỏ dần ra xa góc đó. Đây chính là hai ý mà phần 5 dùng để dạy máy chơi. Bây giờ ta “mổ xẻ” xem chương trình phải làm gì sau mỗi lần em bấm phím:
| Bước | Việc cần làm | Kỹ thuật | Ở phần |
|---|---|---|---|
| 1 | Trượt và gộp từng hàng (hoặc từng cột) theo hướng bấm | hai con trỏ trên mảng 1 chiều | 2 |
| 2 | Làm được cả 4 hướng mà không viết 4 lần code | chuyển vị, đảo hàng ma trận | 3 |
| 3 | Bàn không đổi thì bỏ qua; có đổi thì cộng điểm, sinh ô mới | so sánh 2 mảng, số ngẫu nhiên | 4 |
| 4 | Kiểm tra còn nước đi hay đã thua | duyệt ô kề trên lưới | 4 |
| 5 | (Máy chơi) thử mọi hướng, chấm điểm bàn, chọn hướng tốt nhất | tham lam, nhìn trước | 5 |
Bảng 1. Một lượt chơi 2048 gồm những việc gì. Cả trò chơi chỉ là mảng 2 chiều cộng với vài vòng lặp!
a[4][4] (C++) hoặc danh sách các danh sách (Python). Ô trống lưu số 0, ô có số lưu đúng giá trị. Ta đánh số hàng $i$ từ trên xuống, cột $j$ từ trái sang, bắt đầu từ 0, giống bài 7. Ô lớn nhất trên bàn 4 × 4 về lý thuyết có thể tới $2^{17} = 131072$, nên kiểu int là đủ cho bàn chơi; nhưng tổng điểm và các bài tập với bàn lớn thì nên dùng long long.Phần 2Trượt và gộp một hàng
Ta bắt đầu từ bài toán nhỏ nhất: một hàng bấm sang trái. Nhìn kỹ luật chơi, mỗi lượt gồm hai việc xen kẽ nhau:
- Trượt: các ô có số dồn sát về bên trái, giữ nguyên thứ tự. Nói cách khác là bỏ hết các số 0.
- Gộp: đi từ trái sang phải, hai số đứng liền nhau sau khi bỏ số 0 mà bằng nhau thì gộp thành một số gấp đôi. Ô vừa được tạo ra không được gộp tiếp trong lượt đó.
Ta dùng hai con trỏ giống bài toán “dồn các số khác 0 về đầu mảng” ở bài 17: con trỏ i đọc từng ô của hàng cũ a, con trỏ k là vị trí sẽ ghi tiếp theo trong hàng mới b. Thêm một biến cờ canMerge cho biết ô cuối cùng b[k − 1] có còn được gộp không. Với mỗi a[i] khác 0:
- nếu
canMergeđúng vàb[k − 1] == a[i]: gộp,b[k − 1]nhân đôi, cộng điểm, rồi khoá lại (canMerge = false); - ngược lại: đặt
a[i]vàob[k], tăngk, và mở cờ (canMerge = true) vì ô mới này còn chờ được gộp.
Em bấm Tiếp để xem hai con trỏ làm việc. Sau đó đổi sang chế độ “Cài sai: quên khoá” với hàng 2 2 4 để thấy lỗi kinh điển tận mắt:
| Hàng trước khi bấm ← | Cài đúng (mỗi ô gộp tối đa 1 lần) | Cài sai (quên khoá ô vừa gộp) |
|---|---|---|
2 2 4 0 | 4 4 0 0 (+4) | 8 0 0 0 (+12) |
2 2 2 2 | 4 4 0 0 (+8) | 4 4 0 0 (+8) |
4 4 4 0 | 8 4 0 0 (+8) | 8 4 0 0 (+8) |
4 4 8 8 | 8 16 0 0 (+24) | 16 8 0 0 (+24) |
8 0 0 8 | 16 0 0 0 (+16) | 16 0 0 0 (+16) |
2 4 8 16 | 2 4 8 16 (+0) | 2 4 8 16 (+0) |
2 2 4 8 | 4 4 8 0 (+4) | 16 0 0 0 (+28) |
Bảng 2. Cài sai cho kết quả khác ở mọi hàng mà một ô vừa gộp lại bằng ô kế tiếp. Hàng 2 2 4 8 thậm chí bị “gộp dây chuyền” thành một ô 16!
2 2 4, cặp 2 2 gộp thành 4. Nếu không khoá ô 4 vừa tạo, nó lại thấy số 4 kế tiếp bằng mình và gộp tiếp thành 8. Game thật cho ra 4 4 0, vì luật nói mỗi ô chỉ gộp tối đa 1 lần mỗi lượt. Lỗi thứ hai hay gặp là gộp sai thứ tự: hàng 4 4 4 bấm ← phải ra 8 4 (cặp sát mép đích gộp trước), không phải 4 8. Duyệt từ mép đích đi ra như code dưới đây thì tự động đúng cả hai.Xem code C++ / Python: trượt và gộp một hàng sang trái (bấm để mở)
#include <bits/stdc++.h>
using namespace std;
// Trượt và gộp một hàng sang TRÁI theo luật 2048, trả về số điểm được cộng
long long slideLeft(vector<long long> &a){
int n = a.size(), k = 0; // k: ô tiếp theo sẽ ghi trong hàng mới b
vector<long long> b(n, 0);
bool canMerge = false; // ô b[k - 1] còn được gộp nữa không?
long long gain = 0;
for(int i = 0; i < n; i++){
if(a[i] == 0) continue; // bỏ qua ô trống = trượt
if(canMerge && b[k - 1] == a[i]){
b[k - 1] *= 2; // gộp hai ô bằng nhau
gain += b[k - 1];
canMerge = false; // ô vừa gộp bị khoá trong lượt này
} else {
b[k++] = a[i]; // đặt sát bên trái
canMerge = true;
}
}
a = b;
return gain;
}
int main(){
int n;
cin >> n;
vector<long long> a(n);
for(auto &x : a) cin >> x;
long long g = slideLeft(a);
for(int i = 0; i < n; i++) cout << a[i] << " ";
cout << "\n" << g;
return 0;
}
import sys
def slide_left(a):
"""Trượt và gộp một hàng sang TRÁI, trả về (hàng mới, điểm cộng)."""
b, gain, can_merge = [], 0, False
for v in a:
if v == 0: # bỏ qua ô trống = trượt
continue
if can_merge and b[-1] == v:
b[-1] *= 2 # gộp hai ô bằng nhau
gain += b[-1]
can_merge = False # ô vừa gộp bị khoá trong lượt này
else:
b.append(v) # đặt sát bên trái
can_merge = True
return b + [0] * (len(a) - len(b)), gain
data = sys.stdin.read().split()
n = int(data[0])
row, g = slide_left(list(map(int, data[1:n + 1])))
print(*row)
print(g)
Bây giờ chạy tay từng dòng code C++. Em thử cả dữ liệu “bẫy” 2 2 4 để thấy dòng canMerge = false cứu ta như thế nào:
Độ phức tạp. Mỗi ô của hàng cũ được đọc đúng một lần và mỗi ô của hàng mới được ghi tối đa hai lần, nên trượt một hàng dài $n$ tốn $O(n)$. Cả bàn $n \times n$ có $n$ hàng, một lượt chơi tốn $O(n^2)$, nghĩa là với bàn 4 × 4 chỉ khoảng vài chục phép tính. Máy tính làm được hàng triệu lượt mỗi giây, đủ để ở phần 5 cho máy “thử trước” rất nhiều nước.
Phần 3Một hàm trượt trái cho cả 4 hướng
Có hàm trượt trái rồi, các hướng còn lại thì sao? Cách “cục súc” là viết thêm 3 hàm gần giống hệt nhau, chỉ khác chiều vòng lặp. Cách đó dài và rất dễ sai một dấu cộng trừ. Mẹo hay hơn: biến đổi bàn sao cho hướng cần đi trở thành hướng trái, trượt trái, rồi biến đổi ngược lại. Hai phép biến đổi ta cần đều đã gặp ở chuyên đề 4 (phần xoay, lật ma trận):
- Đảo từng hàng (lật ngang): mép phải thành mép trái. Đi phải = đảo hàng → trượt trái → đảo lại.
- Chuyển vị $b[j][i] = a[i][j]$: cột thành hàng, đỉnh cột thành đầu hàng. Đi lên = chuyển vị → trượt trái → chuyển vị lại.
- Đi xuống = chuyển vị, rồi đảo hàng (đáy cột thành đầu hàng) → trượt trái → làm ngược lại theo thứ tự ngược: đảo hàng, rồi chuyển vị.
| Hướng bấm | Biến đổi trước | Trượt | Biến đổi sau | Mép đích thành |
|---|---|---|---|---|
| ← Trái | (không) | trái từng hàng | (không) | đầu hàng |
| → Phải | đảo từng hàng | trái từng hàng | đảo từng hàng | đầu hàng (sau khi đảo) |
| ↑ Lên | chuyển vị | trái từng hàng | chuyển vị | đầu hàng = đỉnh cột |
| ↓ Xuống | chuyển vị, rồi đảo hàng | trái từng hàng | đảo hàng, rồi chuyển vị | đầu hàng = đáy cột |
Bảng 3. Mỗi phép biến đổi đều tự là nghịch đảo của chính nó (làm hai lần thì về như cũ), nên “biến đổi sau” chỉ là làm lại các bước “biến đổi trước” theo thứ tự ngược.
Vì sao phải đảo hàng chứ không trượt sang phải luôn cho xong? Vì luật gộp ưu tiên các ô gần mép đích trước: hàng 2 2 2 bấm → phải ra 0 2 4. Sau khi đảo, mép đích nằm ở đầu hàng, hàm trượt trái duyệt từ đầu hàng nên tự động gộp đúng cặp. Em xem từng bước với hướng Lên (mặc định), rồi thử Xuống:
| Hướng | Bàn sau khi đi (từ bàn mặc định của mô phỏng) | Điểm |
|---|---|---|
| ← Trái | 4 4 0 0 | +36 |
| ↑ Lên | 4 8 2 8 | +20 |
| → Phải | 0 0 4 4 | +36 |
| ↓ Xuống | 0 0 2 0 | +20 |
Bảng 4. Bốn nước đi khác nhau từ cùng một bàn 2 0 2 4 / 2 4 4 4 / 0 4 8 8 / 2 2 2 2. Em tự kiểm tra lại vài cột bằng tay nhé.
Xem code C++ / Python: một nước đi 2048 theo 4 hướng bằng chuyển vị và đảo hàng (bấm để mở)
#include <bits/stdc++.h>
using namespace std;
int n;
vector<vector<long long>> a;
long long slideLeft(vector<long long> &row){ // như phần 2
int k = 0;
vector<long long> b(n, 0);
bool canMerge = false;
long long gain = 0;
for(int i = 0; i < n; i++){
if(row[i] == 0) continue;
if(canMerge && b[k - 1] == row[i]){ b[k - 1] *= 2; gain += b[k - 1]; canMerge = false; }
else { b[k++] = row[i]; canMerge = true; }
}
row = b;
return gain;
}
void reverseRows(){ // lật ngang: đảo từng hàng
for(auto &r : a) reverse(r.begin(), r.end());
}
void transpose(){ // chuyển vị: a[i][j] <-> a[j][i]
for(int i = 0; i < n; i++)
for(int j = i + 1; j < n; j++) swap(a[i][j], a[j][i]);
}
long long moveLeft(){
long long g = 0;
for(auto &r : a) g += slideLeft(r);
return g;
}
long long doMove(char d){
long long g;
if(d == 'L') g = moveLeft();
else if(d == 'R'){ reverseRows(); g = moveLeft(); reverseRows(); }
else if(d == 'U'){ transpose(); g = moveLeft(); transpose(); }
else { transpose(); reverseRows(); g = moveLeft(); reverseRows(); transpose(); } // 'D'
return g;
}
int main(){
cin >> n;
a.assign(n, vector<long long>(n));
for(auto &r : a) for(auto &x : r) cin >> x;
char d;
cin >> d;
long long g = doMove(d);
for(auto &r : a){
for(auto x : r) cout << x << " ";
cout << "\n";
}
cout << g;
return 0;
}
import sys
def slide_left(a): # như phần 2
b, gain, can_merge = [], 0, False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; gain += b[-1]; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b)), gain
def transpose(b):
return [list(col) for col in zip(*b)]
def reverse_rows(b):
return [r[::-1] for r in b]
def move_left(b):
res, g = [], 0
for r in b:
x, y = slide_left(r)
res.append(x); g += y
return res, g
def do_move(b, d):
if d == 'L':
return move_left(b)
if d == 'R':
x, g = move_left(reverse_rows(b)); return reverse_rows(x), g
if d == 'U':
x, g = move_left(transpose(b)); return transpose(x), g
x, g = move_left(reverse_rows(transpose(b))) # 'D'
return transpose(reverse_rows(x)), g
data = sys.stdin.read().split()
n = int(data[0])
b = [list(map(int, data[1 + i * n:1 + (i + 1) * n])) for i in range(n)]
b, g = do_move(b, data[1 + n * n])
for r in b:
print(*r)
print(g)
long long doMove(char d){
if(d == 'U' || d == 'D') transpose(); // cột thành hàng
if(d == 'R' || d == 'D') reverseRows(); // mép đích về đầu hàng
long long g = moveLeft(); // chỉ một hàm duy nhất!
if(d == 'R' || d == 'D') reverseRows(); // làm ngược lại...
if(d == 'U' || d == 'D') transpose(); // ...theo thứ tự ngược
return g;
}Mẹo này dùng được cho rất nhiều bài lưới khác: chỉ giải cho một hướng (hay một góc, một cạnh), rồi xoay, lật dữ liệu để quy các trường hợp còn lại về trường hợp đã giải. Code ngắn hơn, ít chỗ sai hơn.transpose() đổi chỗ a[i][j] với a[j][i] cho mọi j > i. Nếu em cho j chạy từ 0 thì mỗi cặp bị đổi hai lần và ma trận… y nguyên như cũ! Bàn 2048 luôn vuông nên chuyển vị tại chỗ được; với lưới $n \times m$ không vuông thì phải tạo mảng mới kích thước $m \times n$ như chuyên đề 4.Phần 4Ô mới, điểm số và kiểm tra hết nước đi
Có hàm đi 4 hướng rồi, ghép thành trò chơi hoàn chỉnh chỉ còn ba việc nhỏ.
1. Nước đi hợp lệ. Nếu bấm một hướng mà bàn không thay đổi (chẳng hạn mọi ô đã dồn sát trái mà em vẫn bấm ←), lượt đó không được tính và không sinh ô mới. Cách kiểm tra đơn giản nhất: lưu bản sao bàn trước khi đi rồi so sánh với bàn sau khi đi. Trong C++, hai vector so sánh được bằng ==, mảng tĩnh thì dùng memcmp; trong Python, hai danh sách so sánh bằng ==.
2. Sinh ô mới. Gom chỉ số các ô trống vào một danh sách, chọn ngẫu nhiên một phần tử, đặt vào đó số 2 với xác suất 90% hoặc số 4 với xác suất 10%. Máy tính không có “ngẫu nhiên thật”: nó dùng công thức sinh ra dãy số trông như ngẫu nhiên, bắt đầu từ một số gọi là hạt giống (seed). Bài này dùng công thức rất gọn $s \leftarrow s \cdot 48271 \bmod (2^{31} - 1)$. Cùng hạt giống thì cùng dãy số, nên ván chơi tái lập được: rất quý khi gỡ lỗi hay khi chấm bài tự động.
3. Hết nước đi. Bàn luôn có ít nhất một ô có số, nên còn đi được khi và chỉ khi bàn còn ô trống (khi đó luôn có một ô có số đứng cạnh ô trống và trượt vào được), hoặc có hai ô kề nhau bằng nhau (kề ngang thì bấm trái/phải sẽ gộp, kề dọc thì bấm lên/xuống sẽ gộp). Mẹo nhỏ: mỗi cặp ô kề chỉ cần xét một lần, nên với mỗi ô ta chỉ so với ô bên phải và ô bên dưới, khỏi dùng đủ 4 hướng như ở chuyên đề 4.
Xem code C++ / Python: kiểm tra còn nước đi hay không (bấm để mở)
#include <bits/stdc++.h>
using namespace std;
int a[505][505];
int main(){
int n;
cin >> n;
for(int i = 0; i < n; i++)
for(int j = 0; j < n; j++) cin >> a[i][j];
for(int i = 0; i < n; i++)
for(int j = 0; j < n; j++){
if(a[i][j] == 0){ cout << "YES"; return 0; } // còn ô trống
if(j + 1 < n && a[i][j] == a[i][j + 1]){ cout << "YES"; return 0; } // gộp được với ô bên phải
if(i + 1 < n && a[i][j] == a[i + 1][j]){ cout << "YES"; return 0; } // gộp được với ô bên dưới
}
cout << "NO";
return 0;
}
import sys
def can_move(b):
n = len(b)
for i in range(n):
for j in range(n):
if b[i][j] == 0: # còn ô trống
return True
if j + 1 < n and b[i][j] == b[i][j + 1]: # gộp được với ô bên phải
return True
if i + 1 < n and b[i][j] == b[i + 1][j]: # gộp được với ô bên dưới
return True
return False
data = sys.stdin.read().split()
n = int(data[0])
b = [list(map(int, data[1 + i * n:1 + (i + 1) * n])) for i in range(n)]
print("YES" if can_move(b) else "NO")
Ghép tất cả lại, ta được game 2048 chạy trên màn hình console. Chương trình đọc hạt giống, rồi đọc từng ký tự L, U, R, D như những lần bấm phím. Cuối cùng in bàn, điểm, số nước đã đi và trạng thái. Vì dùng cùng công thức sinh số và cùng cách chọn ô trống (đánh số ô theo hàng, từ trái sang phải) với khung CHƠI THỬ, nên em nhập hạt giống 2048 rồi bấm ← ↑ ← ↑ trong khung CHƠI THỬ sẽ thấy đúng bàn mà chương trình in ra với dữ liệu mẫu đầu tiên!
Xem code C++ / Python: game 2048 hoàn chỉnh chạy trên console (hạt giống + chuỗi phím) (bấm để mở)
#include <bits/stdc++.h>
using namespace std;
const int N = 4;
long long a[N][N], seed;
long long rnd(){ // sinh số giả ngẫu nhiên: cùng seed thì cùng dãy số
seed = seed * 48271 % 2147483647;
return seed;
}
void spawn(){ // ô mới: 2 (90%) hoặc 4 (10%) ở một ô trống ngẫu nhiên
vector<int> e;
for(int p = 0; p < N * N; p++) if(a[p / N][p % N] == 0) e.push_back(p);
if(e.empty()) return;
int p = e[rnd() % e.size()];
a[p / N][p % N] = (rnd() % 10 == 0) ? 4 : 2;
}
long long slideLeft(long long r[]){
long long b[N] = {0}, gain = 0;
int k = 0;
bool canMerge = false;
for(int i = 0; i < N; i++){
if(r[i] == 0) continue;
if(canMerge && b[k - 1] == r[i]){ b[k - 1] *= 2; gain += b[k - 1]; canMerge = false; }
else { b[k++] = r[i]; canMerge = true; }
}
for(int i = 0; i < N; i++) r[i] = b[i];
return gain;
}
void reverseRows(){ for(int i = 0; i < N; i++) reverse(a[i], a[i] + N); }
void transpose(){ for(int i = 0; i < N; i++) for(int j = i + 1; j < N; j++) swap(a[i][j], a[j][i]); }
long long moveLeft(){ long long g = 0; for(int i = 0; i < N; i++) g += slideLeft(a[i]); return g; }
long long doMove(char d){
long long g;
if(d == 'L') g = moveLeft();
else if(d == 'R'){ reverseRows(); g = moveLeft(); reverseRows(); }
else if(d == 'U'){ transpose(); g = moveLeft(); transpose(); }
else { transpose(); reverseRows(); g = moveLeft(); reverseRows(); transpose(); }
return g;
}
bool canMove(){
for(int i = 0; i < N; i++)
for(int j = 0; j < N; j++)
if(a[i][j] == 0 || (j + 1 < N && a[i][j] == a[i][j + 1]) || (i + 1 < N && a[i][j] == a[i + 1][j])) return true;
return false;
}
int main(){
cin >> seed;
spawn(); spawn(); // bàn mở đầu có 2 ô
long long score = 0, moves = 0;
char c;
while(cin >> c){ // mỗi ký tự L, U, R, D là một lần bấm phím
if(!canMove()) break;
long long old[N][N];
memcpy(old, a, sizeof a);
long long g = doMove(c);
if(memcmp(old, a, sizeof a) == 0) continue; // bàn không đổi: không tính nước, không sinh ô
score += g; moves++;
spawn();
}
for(int i = 0; i < N; i++){
for(int j = 0; j < N; j++) cout << a[i][j] << " ";
cout << "\n";
}
cout << score << " " << moves << "\n" << (canMove() ? "CONTINUE" : "GAME OVER");
return 0;
}
import sys
N = 4
seed = 0
def rnd(): # sinh số giả ngẫu nhiên: cùng seed thì cùng dãy số
global seed
seed = seed * 48271 % 2147483647
return seed
def spawn(b): # ô mới: 2 (90%) hoặc 4 (10%) ở một ô trống ngẫu nhiên
e = [p for p in range(N * N) if b[p // N][p % N] == 0]
if e:
p = e[rnd() % len(e)]
b[p // N][p % N] = 4 if rnd() % 10 == 0 else 2
def slide_left(a):
b, gain, can_merge = [], 0, False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; gain += b[-1]; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b)), gain
def transpose(b): return [list(c) for c in zip(*b)]
def reverse_rows(b): return [r[::-1] for r in b]
def move_left(b):
res, g = [], 0
for r in b:
x, y = slide_left(r); res.append(x); g += y
return res, g
def do_move(b, d):
if d == 'L': return move_left(b)
if d == 'R':
x, g = move_left(reverse_rows(b)); return reverse_rows(x), g
if d == 'U':
x, g = move_left(transpose(b)); return transpose(x), g
x, g = move_left(reverse_rows(transpose(b)))
return transpose(reverse_rows(x)), g
def can_move(b):
for i in range(N):
for j in range(N):
if b[i][j] == 0 or (j + 1 < N and b[i][j] == b[i][j + 1]) or (i + 1 < N and b[i][j] == b[i + 1][j]):
return True
return False
data = sys.stdin.read().split()
seed = int(data[0])
b = [[0] * N for _ in range(N)]
spawn(b); spawn(b) # bàn mở đầu có 2 ô
score = moves = 0
for c in ''.join(data[1:]): # mỗi ký tự L, U, R, D là một lần bấm phím
if not can_move(b):
break
nb, g = do_move(b, c)
if nb == b: # bàn không đổi: không tính nước, không sinh ô
continue
b = nb; score += g; moves += 1
spawn(b)
for r in b:
print(*r)
print(score, moves)
print("CONTINUE" if can_move(b) else "GAME OVER")
seed = time(0) % 2147483646 + 1; (C++) hoặc seed = int(time.time()) % 2147483646 + 1 (Python). Hạt giống phải khác 0, vì $0 \cdot 48271$ mãi mãi bằng 0. Còn khi đi thi, đề thường cho sẵn vị trí ô mới (bài tập 4), để kết quả là duy nhất.Phần 5Dạy máy tự chơi: hàm đánh giá và tham lam nhìn trước
Máy tính không “nhìn” được bàn như người. Ta phải biến cảm giác “bàn này đẹp” thành một con số, gọi là hàm đánh giá. Từ kinh nghiệm chơi ở phần 1, bàn tốt là bàn:
- có nhiều ô trống: còn chỗ cho ô mới, khó bị thua. Mỗi ô trống cho 10 điểm.
- có ô lớn nhất nằm ở góc (ở đây chọn góc trái trên): các ô lớn không chắn đường nhau. Thưởng thêm đúng bằng giá trị ô lớn nhất nếu nó ở góc.
$$\text{điểm bàn} = 10 \times (\text{số ô trống}) + (\text{ô lớn nhất, nếu nó ở góc trái trên; ngược lại } 0)$$
Tham lam nhìn trước 1 nước (như bài 18): thử cả 4 hướng; hướng nào làm bàn không đổi thì bỏ; với các hướng còn lại, điểm của hướng = điểm gộp được + điểm bàn thu được. Chọn hướng điểm cao nhất, bằng nhau thì ưu tiên theo thứ tự ← ↑ → ↓ (để ô lớn dần dồn về góc trái trên). Máy chỉ cần 4 lần thử cho mỗi nước!
Nhìn trước 2 nước: với mỗi hướng thứ nhất, máy thử tiếp cả 4 hướng thứ hai và lấy kết quả tốt nhất. Tổng cộng $4 \times 4 = 16$ lần thử, vẫn nhanh như chớp. Ở đây ta bỏ qua ô mới sinh ra giữa hai nước cho đơn giản. Em xem máy chấm điểm từng hướng trên một bàn cụ thể:
| Hướng | Gộp | Ô trống sau đó | Thưởng góc | Điểm (nhìn 1 nước) | Điểm (nhìn 2 nước) |
|---|---|---|---|---|---|
| ← Trái | +8 | 6 | 64 | 132 | 334 |
| ↑ Lên | +128 | 6 | 128 | 316 | 316 |
| → Phải | +8 | 6 | 64 | 132 | 172 |
| ↓ Xuống | +128 | 6 | 0 | 188 | 316 |
Bảng 5. Bàn mặc định của mô phỏng: 64 16 8 2 / 64 8 4 0 / 4 4 2 0 / 2 0 0 0. Nhìn 1 nước, máy chọn ↑ Lên vì gộp ngay hai ô 64 vào góc. Nhìn 2 nước, máy phát hiện đi ← Trái trước (gộp 4 4 ở hàng thứ ba) rồi mới đi lên vẫn gộp được hai ô 64, lại còn thêm ô trống, nên chọn ← Trái.
Xem code C++ / Python: máy chọn hướng đi bằng tham lam nhìn trước 2 nước (bấm để mở)
#include <bits/stdc++.h>
using namespace std;
typedef vector<vector<long long>> Board;
int n;
const string DIRS = "LURD";
long long slideLeft(vector<long long> &row){
int k = 0;
vector<long long> b(n, 0);
bool canMerge = false;
long long gain = 0;
for(int i = 0; i < n; i++){
if(row[i] == 0) continue;
if(canMerge && b[k - 1] == row[i]){ b[k - 1] *= 2; gain += b[k - 1]; canMerge = false; }
else { b[k++] = row[i]; canMerge = true; }
}
row = b;
return gain;
}
void reverseRows(Board &a){ for(auto &r : a) reverse(r.begin(), r.end()); }
void transpose(Board &a){ for(int i = 0; i < n; i++) for(int j = i + 1; j < n; j++) swap(a[i][j], a[j][i]); }
long long doMove(Board &a, char d){ // đi theo hướng d trên bản sao a, trả về điểm gộp
if(d == 'U' || d == 'D') transpose(a);
if(d == 'R' || d == 'D') reverseRows(a);
long long g = 0;
for(auto &r : a) g += slideLeft(r);
if(d == 'R' || d == 'D') reverseRows(a);
if(d == 'U' || d == 'D') transpose(a);
return g;
}
long long evalBoard(const Board &a){ // 10 x số ô trống + thưởng nếu ô lớn nhất ở góc trái trên
long long e = 0, mx = 0;
for(auto &r : a) for(auto x : r){ if(x == 0) e++; mx = max(mx, x); }
return 10 * e + (a[0][0] == mx ? mx : 0);
}
int main(){
cin >> n;
Board a(n, vector<long long>(n));
for(auto &r : a) for(auto &x : r) cin >> x;
long long bestScore = -1;
char best = '?';
for(char d : DIRS){
Board b1 = a;
long long g1 = doMove(b1, d);
if(b1 == a){ cout << -1 << " "; continue; } // hướng không hợp lệ
long long s2 = -1; // nhìn trước thêm 1 nước
for(char d2 : DIRS){
Board b2 = b1;
long long g2 = doMove(b2, d2);
if(b2 != b1) s2 = max(s2, g2 + evalBoard(b2));
}
long long s = g1 + (s2 >= 0 ? s2 : evalBoard(b1));
cout << s << " ";
if(s > bestScore){ bestScore = s; best = d; } // bằng điểm: giữ hướng đứng trước
}
cout << "\n";
if(best == '?') cout << "NONE";
else cout << best;
return 0;
}
import sys
def slide_left(a):
b, gain, can_merge = [], 0, False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; gain += b[-1]; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b)), gain
def do_move(b, d): # trả về (bàn mới, điểm gộp), không sửa b
t = b
if d in 'UD': t = [list(c) for c in zip(*t)]
if d in 'RD': t = [r[::-1] for r in t]
res, g = [], 0
for r in t:
x, y = slide_left(r); res.append(x); g += y
if d in 'RD': res = [r[::-1] for r in res]
if d in 'UD': res = [list(c) for c in zip(*res)]
return res, g
def eval_board(b): # 10 x số ô trống + thưởng nếu ô lớn nhất ở góc trái trên
mx = max(max(r) for r in b)
return 10 * sum(r.count(0) for r in b) + (mx if b[0][0] == mx else 0)
data = sys.stdin.read().split()
n = int(data[0])
a = [list(map(int, data[1 + i * n:1 + (i + 1) * n])) for i in range(n)]
best, best_score, out = 'NONE', -1, []
for d in 'LURD':
b1, g1 = do_move(a, d)
if b1 == a: # hướng không hợp lệ
out.append(-1); continue
s2 = -1 # nhìn trước thêm 1 nước
for d2 in 'LURD':
b2, g2 = do_move(b1, d2)
if b2 != b1:
s2 = max(s2, g2 + eval_board(b2))
s = g1 + (s2 if s2 >= 0 else eval_board(b1))
out.append(s)
if s > best_score: # bằng điểm: giữ hướng đứng trước
best, best_score = d, s
print(*out)
print(best)
Chính đoạn code này là “bộ não” của nút 💡 Máy gợi ý và phần phát lại máy chơi trong khung CHƠI THỬ. Thử 40 hạt giống khác nhau, máy nhìn 1 nước thường dừng ở ô 256, còn nhìn 2 nước thường đạt 512, có ván lên tới 1024. Không tệ cho vài chục dòng code! Muốn mạnh hơn nữa, người ta làm theo các hướng sau:
- Hàm đánh giá tốt hơn: thưởng cho các hàng, cột đơn điệu (giảm dần ra xa góc), phạt các ô kề nhau chênh lệch quá lớn.
- Tính cả ô mới: sau mỗi nước của máy, xét mọi vị trí ô mới có thể hiện ra (cùng xác suất 2 hay 4) rồi lấy trung bình điểm. Thuật toán này tên là expectimax, họ hàng gần của thuật toán minimax mà ta sẽ học ở trò chơi tiếp theo.
- Nhìn sâu hơn bằng đệ quy: nhìn trước $k$ nước tốn $4^k$ lần thử, nên phải cân nhắc giữa độ sâu và tốc độ.
- Đếm số phần tử cực đại trong ma trận (Dễ)
- Xóa Phần Tử Trong Ma Trận (TABLEDEL) (Dễ)
- Khôi Phục Mảng A Từ Bảng Tổng (Dễ)
- Cyclically Rotating a Grid (LeetCode 1914) (Trung bình)
- Ma trận xoắn ốc 2 (Trung bình)
- Trò Chơi (HSG9 Hà Nội 2025 – Bài 4) (Khá)
Bài tậpLuyện tập với game 2048
Cho một hàng gồm $n$ ô ($1 \le n \le 2 \cdot 10^5$), mỗi ô là 0 (trống) hoặc một lũy thừa của 2 không quá $2^{30}$. Người chơi bấm phím →. In ra hàng sau khi trượt và gộp theo luật 2048, và ở dòng thứ hai là số điểm được cộng (tổng các số mới tạo thành do gộp).
8
2 2 2 0 4 4 8 00 0 0 0 2 4 8 8
12📝 Nộp bài, chấm tự động trên ClueOJ ↗
💡 Gợi ý
long long.Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
int main(){
ios::sync_with_stdio(false); cin.tie(nullptr);
int n;
cin >> n;
vector<long long> a(n), b(n, 0);
for(auto &x : a) cin >> x;
int k = n - 1; // ghi từ mép phải sang trái
bool canMerge = false;
long long gain = 0;
for(int i = n - 1; i >= 0; i--){ // đọc từ phải sang trái
if(a[i] == 0) continue;
if(canMerge && b[k + 1] == a[i]){ b[k + 1] *= 2; gain += b[k + 1]; canMerge = false; }
else { b[k--] = a[i]; canMerge = true; }
}
for(int i = 0; i < n; i++) cout << b[i] << " ";
cout << "\n" << gain;
return 0;
}
import sys
def slide_left(a):
b, gain, can_merge = [], 0, False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; gain += b[-1]; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b)), gain
data = sys.stdin.buffer.read().split()
n = int(data[0])
a = list(map(int, data[1:n + 1]))
row, g = slide_left(a[::-1]) # đảo hàng, trượt trái, đảo lại
print(*row[::-1])
print(g)
Cho bàn 2048 kích thước $n \times n$ ($2 \le n \le 500$). Hướng bấm được gọi là đi được nếu bấm hướng đó làm bàn thay đổi. In ra các hướng đi được theo thứ tự L U R D (trái, lên, phải, xuống), cách nhau một dấu cách; nếu không có hướng nào thì in NONE.
3
2 4 8
2 16 8
4 4 32L U R D📝 Nộp bài, chấm tự động trên ClueOJ ↗
💡 Gợi ý
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
int n;
int a[505][505];
int get(char d, int line, int t){ // ô thứ t (tính từ mép đích) của hàng/cột thứ line
if(d == 'L') return a[line][t];
if(d == 'R') return a[line][n - 1 - t];
if(d == 'U') return a[t][line];
return a[n - 1 - t][line]; // 'D'
}
bool valid(char d){
for(int line = 0; line < n; line++)
for(int t = 0; t + 1 < n; t++){
int x = get(d, line, t), y = get(d, line, t + 1); // x gần mép đích hơn y
if(y != 0 && (x == 0 || x == y)) return true; // y trượt vào chỗ trống, hoặc gộp với x
}
return false;
}
int main(){
ios::sync_with_stdio(false); cin.tie(nullptr);
cin >> n;
for(int i = 0; i < n; i++) for(int j = 0; j < n; j++) cin >> a[i][j];
string res;
for(char d : string("LURD")) if(valid(d)){ if(!res.empty()) res += ' '; res += d; }
cout << (res.empty() ? "NONE" : res);
return 0;
}
import sys
def valid(b, d):
n = len(b)
for line in range(n):
if d == 'L': seq = b[line]
elif d == 'R': seq = b[line][::-1]
elif d == 'U': seq = [b[t][line] for t in range(n)]
else: seq = [b[t][line] for t in range(n - 1, -1, -1)]
for t in range(n - 1): # seq[t] gần mép đích hơn seq[t + 1]
x, y = seq[t], seq[t + 1]
if y != 0 and (x == 0 or x == y): # y trượt vào chỗ trống, hoặc gộp với x
return True
return False
data = sys.stdin.buffer.read().split()
n = int(data[0])
b = [list(map(int, data[1 + i * n:1 + (i + 1) * n])) for i in range(n)]
res = [d for d in 'LURD' if valid(b, d)]
print(' '.join(res) if res else 'NONE')
Cho bàn $n \times n$ ($2 \le n \le 50$) và xâu $s$ gồm các ký tự L U R D ($|s| \le 1000$), mỗi ký tự là một lần bấm phím. Trong bài này không sinh ô mới. In ra bàn sau khi bấm hết xâu và tổng số điểm.
4
2 0 2 4
2 4 4 4
0 4 8 8
2 2 2 2
LLU8 8 4 0
2 16 0 0
4 0 0 0
8 0 0 0
52📝 Nộp bài, chấm tự động trên ClueOJ ↗
💡 Gợi ý
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
int n;
vector<vector<long long>> a;
long long slideLeft(vector<long long> &row){ // như phần 2
int k = 0;
vector<long long> b(n, 0);
bool canMerge = false;
long long gain = 0;
for(int i = 0; i < n; i++){
if(row[i] == 0) continue;
if(canMerge && b[k - 1] == row[i]){ b[k - 1] *= 2; gain += b[k - 1]; canMerge = false; }
else { b[k++] = row[i]; canMerge = true; }
}
row = b;
return gain;
}
void reverseRows(){ // lật ngang: đảo từng hàng
for(auto &r : a) reverse(r.begin(), r.end());
}
void transpose(){ // chuyển vị: a[i][j] <-> a[j][i]
for(int i = 0; i < n; i++)
for(int j = i + 1; j < n; j++) swap(a[i][j], a[j][i]);
}
long long moveLeft(){
long long g = 0;
for(auto &r : a) g += slideLeft(r);
return g;
}
long long doMove(char d){
long long g;
if(d == 'L') g = moveLeft();
else if(d == 'R'){ reverseRows(); g = moveLeft(); reverseRows(); }
else if(d == 'U'){ transpose(); g = moveLeft(); transpose(); }
else { transpose(); reverseRows(); g = moveLeft(); reverseRows(); transpose(); } // 'D'
return g;
}
int main(){
cin >> n;
a.assign(n, vector<long long>(n));
for(auto &r : a) for(auto &x : r) cin >> x;
string s;
cin >> s;
long long g = 0;
for(char d : s) g += doMove(d); // bàn không đổi thì doMove trả về 0, không sao
for(auto &r : a){
for(auto x : r) cout << x << " ";
cout << "\n";
}
cout << g;
return 0;
}
import sys
def slide_left(a): # như phần 2
b, gain, can_merge = [], 0, False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; gain += b[-1]; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b)), gain
def transpose(b):
return [list(col) for col in zip(*b)]
def reverse_rows(b):
return [r[::-1] for r in b]
def move_left(b):
res, g = [], 0
for r in b:
x, y = slide_left(r)
res.append(x); g += y
return res, g
def do_move(b, d):
if d == 'L':
return move_left(b)
if d == 'R':
x, g = move_left(reverse_rows(b)); return reverse_rows(x), g
if d == 'U':
x, g = move_left(transpose(b)); return transpose(x), g
x, g = move_left(reverse_rows(transpose(b))) # 'D'
return transpose(reverse_rows(x)), g
data = sys.stdin.read().split()
n = int(data[0])
b = [list(map(int, data[1 + i * n:1 + (i + 1) * n])) for i in range(n)]
g = 0
for d in data[1 + n * n]: # bàn không đổi thì điểm cộng bằng 0, không sao
b, x = do_move(b, d)
g += x
for r in b:
print(*r)
print(g)
Trên bàn 4 × 4 người ta bấm $q$ lần ($1 \le q \le 10^5$). Mỗi lần cho một dòng d r c v: hướng bấm $d$, và vị trí ô mới (hàng $r$, cột $c$, đánh số từ 1) với giá trị $v \in \{2, 4\}$. Nếu nước đi làm bàn thay đổi thì cộng điểm và đặt ô mới (đề đảm bảo ô đó trống sau nước đi); nếu bàn không đổi thì bỏ qua cả dòng. In ra tổng điểm, số nước hợp lệ, ô lớn nhất; dòng sau in YES nếu bàn cuối cùng còn nước đi, ngược lại NO.
2 2 0 0
0 0 0 0
0 0 0 0
0 0 0 0
4
L 1 4 2
L 4 4 4
U 2 1 2
U 3 3 24 3 4
YES📝 Nộp bài, chấm tự động trên ClueOJ ↗
💡 Gợi ý
4 0 0 2 nên vẫn đổi thành 4 2 0 0, đặt 4 ở (4, 4); nước 3 ↑ đưa ô 4 ở góc dưới lên; nước 4 ↑ không đổi gì nên bỏ qua. Cuối cùng kiểm tra hết nước đi như khung chạy tay ở phần 4. Mỗi nước chỉ tốn khoảng 50 phép tính trên bàn 4 × 4.Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
typedef vector<vector<long long>> Board;
const int N = 4;
long long slideLeft(vector<long long> &row){
int k = 0;
vector<long long> b(N, 0);
bool canMerge = false;
long long gain = 0;
for(int i = 0; i < N; i++){
if(row[i] == 0) continue;
if(canMerge && b[k - 1] == row[i]){ b[k - 1] *= 2; gain += b[k - 1]; canMerge = false; }
else { b[k++] = row[i]; canMerge = true; }
}
row = b;
return gain;
}
void reverseRows(Board &a){ for(auto &r : a) reverse(r.begin(), r.end()); }
void transpose(Board &a){ for(int i = 0; i < N; i++) for(int j = i + 1; j < N; j++) swap(a[i][j], a[j][i]); }
long long doMove(Board &a, char d){
if(d == 'U' || d == 'D') transpose(a);
if(d == 'R' || d == 'D') reverseRows(a);
long long g = 0;
for(auto &r : a) g += slideLeft(r);
if(d == 'R' || d == 'D') reverseRows(a);
if(d == 'U' || d == 'D') transpose(a);
return g;
}
bool canMove(const Board &a){
for(int i = 0; i < N; i++)
for(int j = 0; j < N; j++)
if(a[i][j] == 0 || (j + 1 < N && a[i][j] == a[i][j + 1]) || (i + 1 < N && a[i][j] == a[i + 1][j])) return true;
return false;
}
int main(){
ios::sync_with_stdio(false); cin.tie(nullptr);
Board a(N, vector<long long>(N));
for(auto &r : a) for(auto &x : r) cin >> x;
int q;
cin >> q;
long long score = 0, moves = 0;
while(q--){
char d; int r, c; long long v;
cin >> d >> r >> c >> v;
Board old = a;
long long g = doMove(a, d);
if(a == old) continue; // không đổi: bỏ qua cả ô mới
score += g; moves++;
a[r - 1][c - 1] = v;
}
long long mx = 0;
for(auto &r : a) for(auto x : r) mx = max(mx, x);
cout << score << " " << moves << " " << mx << "\n" << (canMove(a) ? "YES" : "NO");
return 0;
}
import sys
N = 4
def slide_left(a):
b, gain, can_merge = [], 0, False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; gain += b[-1]; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b)), gain
def do_move(b, d):
t = b
if d in 'UD': t = [list(c) for c in zip(*t)]
if d in 'RD': t = [r[::-1] for r in t]
res, g = [], 0
for r in t:
x, y = slide_left(r); res.append(x); g += y
if d in 'RD': res = [r[::-1] for r in res]
if d in 'UD': res = [list(c) for c in zip(*res)]
return res, g
def can_move(b):
for i in range(N):
for j in range(N):
if b[i][j] == 0 or (j + 1 < N and b[i][j] == b[i][j + 1]) or (i + 1 < N and b[i][j] == b[i + 1][j]):
return True
return False
data = sys.stdin.buffer.read().split()
b = [list(map(int, data[i * N:(i + 1) * N])) for i in range(N)]
q = int(data[16])
score = moves = 0
p = 17
for _ in range(q):
d = data[p].decode(); r = int(data[p + 1]); c = int(data[p + 2]); v = int(data[p + 3])
p += 4
nb, g = do_move(b, d)
if nb == b: # không đổi: bỏ qua cả ô mới
continue
b = nb; score += g; moves += 1
b[r - 1][c - 1] = v
print(score, moves, max(max(r) for r in b))
print("YES" if can_move(b) else "NO")
Cho một hàng $n$ ô ($1 \le n \le 2000$), mỗi ô là 0 hoặc lũy thừa của 2 không quá $2^{20}$. Người chơi bấm ← liên tục cho tới khi bấm thêm cũng không làm hàng thay đổi (không sinh ô mới). In ra số lần bấm có làm thay đổi và tổng điểm, rồi in hàng cuối cùng.
6
2 2 4 8 0 164 60
32 0 0 0 0 0📝 Nộp bài, chấm tự động trên ClueOJ ↗
💡 Gợi ý
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
long long slideLeft(vector<long long> &a){
int n = a.size(), k = 0;
vector<long long> b(n, 0);
bool canMerge = false;
long long gain = 0;
for(int i = 0; i < n; i++){
if(a[i] == 0) continue;
if(canMerge && b[k - 1] == a[i]){ b[k - 1] *= 2; gain += b[k - 1]; canMerge = false; }
else { b[k++] = a[i]; canMerge = true; }
}
a = b;
return gain;
}
int main(){
int n;
cin >> n;
vector<long long> a(n);
for(auto &x : a) cin >> x;
long long presses = 0, total = 0;
while(true){
vector<long long> old = a;
long long g = slideLeft(a);
if(a == old) break; // hàng đứng yên: dừng
presses++; total += g;
}
cout << presses << " " << total << "\n";
for(auto x : a) cout << x << " ";
return 0;
}
import sys
def slide_left(a):
b, gain, can_merge = [], 0, False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; gain += b[-1]; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b)), gain
data = sys.stdin.read().split()
n = int(data[0])
a = list(map(int, data[1:n + 1]))
presses = total = 0
while True:
b, g = slide_left(a)
if b == a: # hàng đứng yên: dừng
break
a = b; presses += 1; total += g
print(presses, total)
print(*a)
Cho bàn $n \times n$ ($2 \le n \le 200$). Với mỗi hướng đi được (làm bàn thay đổi), đếm số ô trống của bàn sau khi đi (không sinh ô mới). In ra hướng cho nhiều ô trống nhất và số ô trống đó; nếu bằng nhau thì chọn hướng đứng trước trong thứ tự L U R D. Nếu không có hướng nào đi được, in NONE.
4
64 16 8 2
64 8 4 0
4 4 2 0
2 0 0 0L 6📝 Nộp bài, chấm tự động trên ClueOJ ↗
💡 Gợi ý
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
typedef vector<vector<long long>> Board;
int n;
long long slideLeft(vector<long long> &row){
int k = 0;
vector<long long> b(n, 0);
bool canMerge = false;
long long gain = 0;
for(int i = 0; i < n; i++){
if(row[i] == 0) continue;
if(canMerge && b[k - 1] == row[i]){ b[k - 1] *= 2; gain += b[k - 1]; canMerge = false; }
else { b[k++] = row[i]; canMerge = true; }
}
row = b;
return gain;
}
void reverseRows(Board &a){ for(auto &r : a) reverse(r.begin(), r.end()); }
void transpose(Board &a){ for(int i = 0; i < n; i++) for(int j = i + 1; j < n; j++) swap(a[i][j], a[j][i]); }
long long doMove(Board &a, char d){
if(d == 'U' || d == 'D') transpose(a);
if(d == 'R' || d == 'D') reverseRows(a);
long long g = 0;
for(auto &r : a) g += slideLeft(r);
if(d == 'R' || d == 'D') reverseRows(a);
if(d == 'U' || d == 'D') transpose(a);
return g;
}
int main(){
ios::sync_with_stdio(false); cin.tie(nullptr);
cin >> n;
Board a(n, vector<long long>(n));
for(auto &r : a) for(auto &x : r) cin >> x;
int best = -1;
char bd = '?';
for(char d : string("LURD")){
Board b = a;
doMove(b, d);
if(b == a) continue; // hướng không hợp lệ
int e = 0;
for(auto &r : b) for(auto x : r) if(x == 0) e++;
if(e > best){ best = e; bd = d; } // bằng nhau: giữ hướng đứng trước
}
if(bd == '?') cout << "NONE";
else cout << bd << " " << best;
return 0;
}
import sys
def slide_left(a):
b, can_merge = [], False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b))
def do_move(b, d):
t = b
if d in 'UD': t = [list(c) for c in zip(*t)]
if d in 'RD': t = [r[::-1] for r in t]
res = [slide_left(r) for r in t]
if d in 'RD': res = [r[::-1] for r in res]
if d in 'UD': res = [list(c) for c in zip(*res)]
return res
data = sys.stdin.buffer.read().split()
n = int(data[0])
a = [list(map(int, data[1 + i * n:1 + (i + 1) * n])) for i in range(n)]
best, bd = -1, None
for d in 'LURD':
b = do_move(a, d)
if b == a: # hướng không hợp lệ
continue
e = sum(r.count(0) for r in b)
if e > best: # bằng nhau: giữ hướng đứng trước
best, bd = e, d
print(f"{bd} {best}" if bd else "NONE")
Cho bàn $n \times n$ ($2 \le n \le 100$), không sinh ô mới. Người chơi muốn đi hai nước để tổng điểm gộp lớn nhất (cả hai nước đều phải làm bàn thay đổi; nếu sau nước thứ nhất không còn nước nào đi được thì chỉ tính nước thứ nhất). In ra hướng của nước thứ nhất và tổng điểm lớn nhất; bằng nhau thì chọn hướng đứng trước trong thứ tự L U R D. Không có nước nào thì in NONE.
4
2 2 4 0
0 0 0 8
0 0 0 0
0 0 0 0L 12📝 Nộp bài, chấm tự động trên ClueOJ ↗
💡 Gợi ý
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
typedef vector<vector<long long>> Board;
int n;
long long slideLeft(vector<long long> &row){
int k = 0;
vector<long long> b(n, 0);
bool canMerge = false;
long long gain = 0;
for(int i = 0; i < n; i++){
if(row[i] == 0) continue;
if(canMerge && b[k - 1] == row[i]){ b[k - 1] *= 2; gain += b[k - 1]; canMerge = false; }
else { b[k++] = row[i]; canMerge = true; }
}
row = b;
return gain;
}
void reverseRows(Board &a){ for(auto &r : a) reverse(r.begin(), r.end()); }
void transpose(Board &a){ for(int i = 0; i < n; i++) for(int j = i + 1; j < n; j++) swap(a[i][j], a[j][i]); }
long long doMove(Board &a, char d){
if(d == 'U' || d == 'D') transpose(a);
if(d == 'R' || d == 'D') reverseRows(a);
long long g = 0;
for(auto &r : a) g += slideLeft(r);
if(d == 'R' || d == 'D') reverseRows(a);
if(d == 'U' || d == 'D') transpose(a);
return g;
}
int main(){
ios::sync_with_stdio(false); cin.tie(nullptr);
cin >> n;
Board a(n, vector<long long>(n));
for(auto &r : a) for(auto &x : r) cin >> x;
long long best = -1;
char bd = '?';
for(char d : string("LURD")){
Board b1 = a;
long long g1 = doMove(b1, d);
if(b1 == a) continue; // nước 1 không hợp lệ
long long s = g1; // nếu không có nước 2 thì chỉ tính nước 1
for(char d2 : string("LURD")){
Board b2 = b1;
long long g2 = doMove(b2, d2);
if(b2 != b1) s = max(s, g1 + g2);
}
if(s > best){ best = s; bd = d; }
}
if(bd == '?') cout << "NONE";
else cout << bd << " " << best;
return 0;
}
import sys
def slide_left(a):
b, gain, can_merge = [], 0, False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; gain += b[-1]; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b)), gain
def do_move(b, d):
t = b
if d in 'UD': t = [list(c) for c in zip(*t)]
if d in 'RD': t = [r[::-1] for r in t]
res, g = [], 0
for r in t:
x, y = slide_left(r); res.append(x); g += y
if d in 'RD': res = [r[::-1] for r in res]
if d in 'UD': res = [list(c) for c in zip(*res)]
return res, g
data = sys.stdin.buffer.read().split()
n = int(data[0])
a = [list(map(int, data[1 + i * n:1 + (i + 1) * n])) for i in range(n)]
best, bd = -1, None
for d in 'LURD':
b1, g1 = do_move(a, d)
if b1 == a: # nước 1 không hợp lệ
continue
s = g1 # nếu không có nước 2 thì chỉ tính nước 1
for d2 in 'LURD':
b2, g2 = do_move(b1, d2)
if b2 != b1:
s = max(s, g1 + g2)
if s > best:
best, bd = s, d
print(f"{bd} {best}" if bd else "NONE")
Cho bàn $n \times n$ ($2 \le n \le 4$), các ô là 0 hoặc lũy thừa của 2 không quá $2^{16}$, và xâu phím $s$ ($1 \le |s| \le 100$). Người chơi bấm hết xâu $s$, rồi lại bấm lại từ đầu, tổng cộng lặp $K$ lần ($1 \le K \le 10^{18}$). Không sinh ô mới. In ra bàn cuối cùng và tổng điểm.
4 1000000000000
8 0 2 2
8 2 4 4
4 2 2 4
8 0 4 8
LDRUR0 16 4 8
0 4 2 4
0 0 8 16
0 0 0 0
56📝 Nộp bài, chấm tự động trên ClueOJ ↗
💡 Gợi ý
map; khi bàn sau lượt $t$ trùng bàn sau lượt $f$, chu kỳ dài $t - f$. Số lượt còn lại $K - t$ chia cho độ dài chu kỳ: phần nguyên là số vòng trọn, phần dư $r$ cho biết bàn cuối là bàn sau lượt $f + r$. Điểm cũng tính như vậy (thật ra trong chu kỳ không thể có phép gộp nào, vì mỗi phép gộp làm giảm số ô có số). Ví dụ: lượt đầu tiên gộp được 56 điểm; từ đó các ô chỉ “chạy vòng” quanh bàn: bàn sau lượt 8 trùng bàn sau lượt 1, chu kỳ dài 7. $10^{12} - 8$ chia 7 dư 3, nên bàn cuối là bàn sau lượt $1 + 3 = 4$, và tổng điểm vẫn là 56.Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
typedef vector<vector<long long>> Board;
int n;
long long slideLeft(vector<long long> &row){
int k = 0;
vector<long long> b(n, 0);
bool canMerge = false;
long long gain = 0;
for(int i = 0; i < n; i++){
if(row[i] == 0) continue;
if(canMerge && b[k - 1] == row[i]){ b[k - 1] *= 2; gain += b[k - 1]; canMerge = false; }
else { b[k++] = row[i]; canMerge = true; }
}
row = b;
return gain;
}
void reverseRows(Board &a){ for(auto &r : a) reverse(r.begin(), r.end()); }
void transpose(Board &a){ for(int i = 0; i < n; i++) for(int j = i + 1; j < n; j++) swap(a[i][j], a[j][i]); }
long long doMove(Board &a, char d){
if(d == 'U' || d == 'D') transpose(a);
if(d == 'R' || d == 'D') reverseRows(a);
long long g = 0;
for(auto &r : a) g += slideLeft(r);
if(d == 'R' || d == 'D') reverseRows(a);
if(d == 'U' || d == 'D') transpose(a);
return g;
}
int main(){
long long K;
string s;
cin >> n >> K;
Board a(n, vector<long long>(n));
for(auto &r : a) for(auto &x : r) cin >> x;
cin >> s;
map<Board, long long> seen; // bàn -> sau bao nhiêu lượt thì gặp
vector<Board> boards; // boards[t]: bàn sau t lượt
vector<long long> sc; // sc[t]: tổng điểm sau t lượt
boards.push_back(a); sc.push_back(0); seen[a] = 0;
long long t = 0;
while(t < K){
long long g = 0;
for(char d : s) g += doMove(a, d);
t++;
boards.push_back(a); sc.push_back(sc.back() + g);
if(seen.count(a)){ // gặp lại bàn cũ: có chu kỳ
long long f = seen[a], len = t - f, rest = K - t;
long long full = rest / len, r = rest % len;
long long total = sc[t] + full * (sc[t] - sc[f]) + (sc[f + r] - sc[f]);
a = boards[f + r];
for(auto &row : a){ for(auto x : row) cout << x << " "; cout << "\n"; }
cout << total;
return 0;
}
seen[a] = t;
}
for(auto &row : a){ for(auto x : row) cout << x << " "; cout << "\n"; }
cout << sc[t];
return 0;
}
import sys
def slide_left(a):
b, gain, can_merge = [], 0, False
for v in a:
if v == 0:
continue
if can_merge and b[-1] == v:
b[-1] *= 2; gain += b[-1]; can_merge = False
else:
b.append(v); can_merge = True
return b + [0] * (len(a) - len(b)), gain
def do_move(b, d):
t = b
if d in 'UD': t = [list(c) for c in zip(*t)]
if d in 'RD': t = [r[::-1] for r in t]
res, g = [], 0
for r in t:
x, y = slide_left(r); res.append(x); g += y
if d in 'RD': res = [r[::-1] for r in res]
if d in 'UD': res = [list(c) for c in zip(*res)]
return res, g
data = sys.stdin.read().split()
n, K = int(data[0]), int(data[1])
a = [list(map(int, data[2 + i * n:2 + (i + 1) * n])) for i in range(n)]
s = data[2 + n * n]
key = lambda b: tuple(map(tuple, b))
seen = {key(a): 0} # bàn -> sau bao nhiêu lượt thì gặp
boards, sc = [a], [0] # boards[t], sc[t]: bàn và tổng điểm sau t lượt
t = 0
ans = None
while t < K:
g = 0
for d in s:
a, x = do_move(a, d)
g += x
t += 1
boards.append(a); sc.append(sc[-1] + g)
if key(a) in seen: # gặp lại bàn cũ: có chu kỳ
f = seen[key(a)]
ln, rest = t - f, K - t
full, r = divmod(rest, ln)
ans = (boards[f + r], sc[t] + full * (sc[t] - sc[f]) + (sc[f + r] - sc[f]))
break
seen[key(a)] = t
if ans is None:
ans = (a, sc[t])
for row in ans[0]:
print(*row)
print(ans[1])
- 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.
- 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
Đăng nhận xét