Asen Và Boyan
Đề bài
DPCOING – Asen và Boyan
Asen và Boyan đang chơi trò chơi sau đây. Họ chọn hai số nguyên dương $K$ và $L$ khác nhau và bắt đầu trò chơi với một tháp gồm $N$ xu. Asen luôn chơi đầu tiên, Boyan – thứ hai, sau đó – Asen một lần nữa, sau đó là Boyan, v.v.
Các cậu bé lần lượt có thể lấy $1$, $K$ hoặc $L$ xu từ tháp. Người chiến thắng là người lấy đồng xu cuối cùng. Sau một thời gian dài chơi, Asen nhận ra rằng có những trường hợp anh ta có thể thắng, bất kể Boyan chơi như thế nào. Và trong tất cả các trường hợp khác, Boyan cẩn thận có thể giành chiến thắng, bất kể Asen chơi như thế nào.
Vì vậy, trước khi bắt đầu trò chơi, Asen rất háo hức muốn biết họ có thể chiến thắng trong trường hợp nào. Viết một chương trình giúp Asen dự đoán kết quả trò chơi cho $K$, $L$ và $N$ đã cho.
Dữ liệu vào: Dòng đầu tiên của đầu vào tiêu chuẩn chứa các số nguyên $K$, $L$ và $m$ $(1 \lt K \lt L \lt 10,\ 3 \lt m \lt 1000)$. Dòng thứ hai chứa $m$ số nguyên $N_1, N_2, \ldots, N_m$ $(1 \le N_i \le 10^6)$ đại diện cho số lượng xu trong mỗi lượt chơi.
Kết quả: In ra một chuỗi có độ dài $m$ gồm các chữ cái A và B. Nếu Asen thắng trò chơi thứ $i$ (cho dù đối thủ chơi như thế nào), thì chữ cái thứ $i$ của chuỗi phải là A. Khi Boyan thắng trò chơi thứ $i$ (cho dù Asen chơi như thế nào), chữ cái thứ $i$ của chuỗi phải là B.
2 3 5
3 12 113 25714 88888ABAAB2 3 5
6 18 21 5 4AAAAB2 3 3
110 111 112AABVí dụ 3 có $m = 3$ dù ràng buộc ghi $3 \lt m$ – không ảnh hưởng gì tới cách làm, code bên dưới chạy được với mọi $m \ge 1$.
Lời mở đầu
Đây là một bài "trò chơi đối kháng" rất cơ bản và rất hay để làm quen với cách tư duy trạng thái thắng / trạng thái thua. Mình sẽ đi từ cách làm "y như đề nói" (2 hàm cho 2 người gọi qua lại), lên đệ quy có nhớ, rồi nhận ra một điều thú vị là A hay B không quan trọng, cuối cùng là quy hoạch động bottom up. Hy vọng mang đến được giá trị cho các bạn 😇.
Mình tóm tắt lại đề cho đơn giản: A (Asen) và B (Boyan) chọn 2 số nguyên dương $K \lt L$ rồi chơi với $N$ xu, A luôn đi trước, sau đó tới B, rồi lại A… Mỗi lượt được lấy đúng $1$ xu, đúng $K$ xu hoặc đúng $L$ xu (ví dụ tháp còn 2 xu mà $K = 3$, $L = 4$ thì chỉ được lấy 1 xu; tháp còn 4 xu thì lấy được 1, 3 hoặc 4 xu). Ai lấy được đồng xu cuối cùng thì thắng. Với mỗi $N_i$, cho biết ai có cách thắng bất kể người kia chơi thế nào: A thì in A, B thì in B.
Giải thích ví dụ 2 ($K = 2$, $L = 3$):
- $N = 6$: A lấy $2$ xu, còn $4$ xu. B lấy 1 → còn 3 → A lấy hết 3 xu, A thắng. B lấy 2 → còn 2 → A lấy hết, A thắng. B lấy 3 → còn 1 → A lấy xu cuối, A thắng. B làm gì cũng thua, vậy A thắng.
- $N = 4$: A lấy 1 → còn 3 → B lấy hết, B thắng. A lấy 2 → còn 2 → B lấy hết, B thắng. A lấy 3 → còn 1 → B lấy nốt, B thắng. A làm gì cũng thua, vậy B thắng.
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 OJLộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Làm đúng như đề: 2 hàm TryA và TryB gọi qua lại
Đầu tiên mình nói rõ cách gọi tắt. Đề bảo "A có thể thắng B dù B chơi như thế nào" – nghĩa là tồn tại ít nhất 1 cách chọn giúp A thắng dù B chơi thế nào. Mình gọi tắt là "A luôn thắng" (không phải A chọn kiểu gì cũng thắng nha, mà là A luôn có cách để thắng).
Cách đơn giản nhất là làm như đề nói: tạo 2 hàm, 1 hàm là A chọn, 1 hàm là B chọn, luân phiên nhau thử mọi khả năng. Số xu còn lại $\ge L$ thì thử cả 3 cách (lấy 1, $K$, $L$ xu); số xu $\ge K$ nhưng $\lt L$ thì thử 2 cách (lấy 1, $K$ xu); số xu $\lt K$ thì chỉ lấy được 1 xu.
Nhưng khoai ở chỗ: trong hàm A chọn phải gọi hàm B chọn (sau A là B), trong hàm B chọn lại phải gọi hàm A chọn. Vậy khai báo hàm nào trước? Bạn nào chưa biết thì sẽ bí chỗ này 😅. Cách xử lý là khai báo nguyên mẫu hàm (prototype):
bool TryB(int n); // khai báo nguyên mẫu trước
bool TryA(int n) {
// ... gọi TryB thoải mái
}
bool TryB(int n) {
// ... gọi TryA như bình thường
}
Dòng bool TryB(int n); đặt trước TryA giúp compiler biết TryB tồn tại, nên trong TryA gọi TryB không bị bắt lỗi. Còn TryB viết sau TryA nên gọi TryA bình thường.
Hàm: TryA(n) trả về true nếu với $n$ đồng xu và A đang tới lượt, A luôn thắng; false nếu A luôn thua. TryB(n) tương tự cho B.
Điều kiện dừng: khi còn $0$ đồng xu thì người đang tới lượt thua – vì tới lượt mình mà hết xu rồi, chứng tỏ đồng xu cuối cùng đã bị người kia lấy. Nên ở cả 2 hàm: if(n == 0) return false;
Xử lý chạy (xét hàm TryA, TryB y hệt chỉ đổi tên hàm được gọi):
- $n \ge L$ – A có 3 lựa chọn, còn lại $n - 1$, $n - K$, $n - L$ xu và tới lượt B.
- A lấy 1 xu: nếu với $n - 1$ xu B luôn thắng (
TryB(n - 1)=true) thì lựa chọn này dẫn tới A thua; ngược lại thì A thắng. Vậy kết quả của lựa chọn này là!TryB(n - 1)– B thắng thì A thua, B thua thì A thắng, trái ngược nhau nên dùng toán tử phủ định!. - A lấy $K$ xu:
!TryB(n - K). A lấy $L$ xu:!TryB(n - L).
||):return (!TryB(n - 1) || !TryB(n - K) || !TryB(n - L)); - A lấy 1 xu: nếu với $n - 1$ xu B luôn thắng (
- $K \le n \lt L$ – chỉ còn 2 lựa chọn:
return (!TryB(n - 1) || !TryB(n - K)); - $n \lt K$ – chỉ lấy được 1 xu:
return !TryB(n - 1);
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
int K, L, m;
int N[1005];
bool TryB(int n);
bool TryA(int n) {
if(n == 0) {
return false;
}
if(n >= L) {
return (!TryB(n - 1) || !TryB(n - K) || !TryB(n - L));
}
else if(n >= K) {
return (!TryB(n - 1) || !TryB(n - K));
}
return !TryB(n - 1);
}
bool TryB(int n) {
if(n == 0) {
return false;
}
if(n >= L) {
return (!TryA(n - 1) || !TryA(n - K) || !TryA(n - L));
}
else if(n >= K) {
return (!TryA(n - 1) || !TryA(n - K));
}
return !TryA(n - 1);
}
int main() {
// freopen("dpcoing.inp", "r", stdin);
// freopen("dpcoing.out", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0);
cin >> K >> L >> m;
for(int i = 1; i <= m; ++i) {
cin >> N[i];
if(TryA(N[i])) {
cout << "A";
}
else {
cout << "B";
}
}
return 0;
}
import sys
sys.setrecursionlimit(10000)
def TryA(n):
if n == 0:
return False
if n >= L:
return not TryB(n - 1) or not TryB(n - K) or not TryB(n - L)
elif n >= K:
return not TryB(n - 1) or not TryB(n - K)
return not TryB(n - 1)
def TryB(n):
if n == 0:
return False
if n >= L:
return not TryA(n - 1) or not TryA(n - K) or not TryA(n - L)
elif n >= K:
return not TryA(n - 1) or not TryA(n - K)
return not TryA(n - 1)
data = sys.stdin.read().split()
K, L, m = int(data[0]), int(data[1]), int(data[2])
N = [int(x) for x in data[3:3 + m]]
print("".join("A" if TryA(x) else "B" for x in N))
Giải thích 1 số chỗ trong code:
bool TryB(int n);– khai báo nguyên mẫu để trongTryAgọi đượcTryB.else if(n >= K)– tới đượcelsethì đã có $n \lt L$ rồi, chỉ cần kiểm tra thêm $n \ge K$.return !TryB(n - 1);– nếu rơi vào 2 trường hợp trên thì đãreturnrồi, tới được dòng này chứng tỏ $n \lt K$.if(TryA(N[i])) cout << "A"; else cout << "B";– gọiTryAvì A là người chọn đầu tiên. A luôn thắng thì in A; ngược lại A luôn thua tức B luôn thắng thì in B.- Code gốc của mình có 2 dòng
freopen("dpcoing.inp", ...)/freopen("dpcoing.out", ...); nộp trên CLUE OJ (đọc/ghi chuẩn) thì để ở dạng chú thích như trên, thi offline cần file thì bỏ//đi nhé.
or), biểu thức X || Y || Z được tính từ trái sang phải và dừng ngay khi gặp giá trị đúng. Nên nếu !TryB(n - 1) đã đúng thì TryB(n - K), TryB(n - L) không bị gọi nữa. Các bạn để ý điều này trong khung chạy tay bên dưới.Bấm Tiếp để xem 2 hàm gọi qua lại thế nào (N nhỏ thôi, không là cây đệ quy nở to lắm 😅):
Đánh giá độ phức tạp: mỗi lời gọi có thể tách ra tới 3 nhánh, số lời gọi tăng theo hàm mũ của $N$ – trong khi $N$ lên tới $10^6$. Mình thử với $N = 110$ (ví dụ 3) thì chạy mấy phút vẫn chưa xong.
Cách 2Đệ quy có nhớ với 2 mảng fA, fB – O(N)
Nếu đã từng tính TryA(n) rồi, gặp lại ta lại phải tính lại – có rất nhiều bài toán con trùng lặp như vậy (các bạn thấy trong khung chạy tay ở trên, ví dụ TryA(1) được gọi nhiều lần). Giải pháp là lưu lại kết quả đã tính, gặp lại thì lấy ra dùng luôn.
Nhìn vào TryA(n) và TryB(n): đầu vào chỉ có $n$ thay đổi mỗi lần gọi đệ quy. Vậy ta tạo 2 mảng 1 chiều fA, fB: fA[n] lưu kết quả của TryA(n), fB[n] lưu kết quả của TryB(n), mỗi mảng $10^6$ phần tử (vì $n$ tối đa $10^6$).
Hàm chỉ trả về true/false tức $1$/$0$, nên giá trị "chưa tính" phải khác $0$ và $1$. Vì vậy ta dùng mảng int và khởi tạo toàn bộ bằng $-1$. fA[n] != -1 → đã tính, trả về luôn; fA[n] == -1 → tính TryA(n), lưu vào fA[n] rồi trả về. Tương tự cho fB.
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int nMax = (int)1e6 + 5;
int K, L, m;
int N[1005];
bool TryB(int n);
int fA[nMax];
bool TryA(int n) {
if(n == 0) {
return false;
}
if(fA[n] != -1) {
return fA[n];
}
if(n >= L) {
return fA[n] = (!TryB(n - 1) || !TryB(n - K) || !TryB(n - L));
}
else if(n >= K) {
return fA[n] = (!TryB(n - 1) || !TryB(n - K));
}
return fA[n] = !TryB(n - 1);
}
int fB[nMax];
bool TryB(int n) {
if(n == 0) {
return false;
}
if(fB[n] != -1) {
return fB[n];
}
if(n >= L) {
return fB[n] = (!TryA(n - 1) || !TryA(n - K) || !TryA(n - L));
}
else if(n >= K) {
return fB[n] = (!TryA(n - 1) || !TryA(n - K));
}
return fB[n] = !TryA(n - 1);
}
int main() {
// freopen("dpcoing.inp", "r", stdin);
// freopen("dpcoing.out", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0);
fill(fA, fA + nMax, -1);
fill(fB, fB + nMax, -1);
cin >> K >> L >> m;
for(int i = 1; i <= m; ++i) {
cin >> N[i];
if(TryA(N[i])) {
cout << "A";
}
else {
cout << "B";
}
}
return 0;
}
import sys
import threading
def main():
data = sys.stdin.read().split()
K, L, m = int(data[0]), int(data[1]), int(data[2])
N = [int(x) for x in data[3:3 + m]]
nMax = max(N) + 1
fA = [-1] * nMax
fB = [-1] * nMax
def TryA(n):
if n == 0:
return False
if fA[n] != -1:
return fA[n]
if n >= L:
fA[n] = not TryB(n - 1) or not TryB(n - K) or not TryB(n - L)
elif n >= K:
fA[n] = not TryB(n - 1) or not TryB(n - K)
else:
fA[n] = not TryB(n - 1)
return fA[n]
def TryB(n):
if n == 0:
return False
if fB[n] != -1:
return fB[n]
if n >= L:
fB[n] = not TryA(n - 1) or not TryA(n - K) or not TryA(n - L)
elif n >= K:
fB[n] = not TryA(n - 1) or not TryA(n - K)
else:
fB[n] = not TryA(n - 1)
return fB[n]
print("".join("A" if TryA(x) else "B" for x in N))
# đệ quy sâu tới cả triệu tầng: tăng giới hạn đệ quy và kích thước stack
sys.setrecursionlimit(3000000)
threading.stack_size(1 << 29)
t = threading.Thread(target=main)
t.start()
t.join()
Giải thích 1 số chỗ trong code:
return fA[n] = ...;– gánfA[n] = ...xong rồi mớireturn fA[n].fill(fA, fA + nMax, -1);– lấp đầy mảngfAbằng $-1$.
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(nMax)$ với $nMax = 10^6$. Thực chất là $O(2 \cdot nMax + m)$, theo quy tắc big O là $O(nMax)$.
- Thời gian: $O(nMax)$, thực chất $O(2 \cdot nMax)$ vì trường hợp tệ nhất phải lấp đầy cả 2 mảng
fA,fB; lấp đầy rồi thì mỗi lần gọiTryA(N[i])chỉ mất $O(1)$.
Với cách này là đã AC bài này rồi. Nhưng nói về tối ưu thì chưa phải tốt nhất, thời gian chạy vẫn khá lâu.
TryA(1000000) thì nó gọi TryB(999999), rồi TryA(999998)… sâu tới khoảng $10^6$ tầng đệ quy. Hệ thống chấm thường cho stack lớn nên vẫn AC, nhưng khi mình chạy thử trên máy Linux với stack mặc định 8 MB thì cách 2 và cách 3 đều bị lỗi Segmentation fault (tràn stack) ngay với $N = 10^6$; tăng giới hạn stack (ulimit -s unlimited) thì chạy đúng trong khoảng 0.05 giây. Bản Python mình phải tăng sys.setrecursionlimit và chạy trong một threading.Thread có stack lớn mới chạy được. Đây cũng là một lý do nên học thêm cách 4 – không đệ quy, không lo tràn stack.Cách 3A hay B không quan trọng – chỉ cần 1 hàm Try
Giờ ta thấy một điều rất hay: hàm TryA() và TryB() chả khác gì nhau cả! Nếu trong TryA() ta không gọi TryB() mà gọi chính TryA() thì sao, còn đúng không? Câu trả lời là vẫn đúng, vẫn AC như thường 😁.
Lúc này ta nhận ra: việc A hay B cầm $n$ đồng xu không quan trọng, quan trọng là ai đi trước. Lúc đầu A đi trước; sau khi A lấy thì B thành người đi trước với đống xu còn lại; sau khi B lấy thì A lại đi trước với đống còn lại… Câu hỏi chỉ còn là: với $n$ đồng xu, người đi trước luôn thắng hay luôn thua?
Ta xoá hàm TryB(), đổi tên TryA(n) thành Try(n), mảng nhớ cũng chỉ còn một mảng f. Bản chất mỗi lần gọi đệ quy là một lần đổi phiên người đi trước; nên kết quả của ta là phủ định của kết quả người kia (người đi trước với đống còn lại): người kia thua (false) thì ta thắng (true), người kia thắng thì ta thua.
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int nMax = (int)1e6 + 5;
int K, L, m;
int N[1005];
int f[nMax];
bool Try(int n) {
if(n == 0) {
return false;
}
if(f[n] != -1) {
return f[n];
}
if(n >= L) {
return f[n] = (!Try(n - 1) || !Try(n - K) || !Try(n - L));
}
else if(n >= K) {
return f[n] = (!Try(n - 1) || !Try(n - K));
}
return f[n] = !Try(n - 1);
}
int main() {
// freopen("dpcoing.inp", "r", stdin);
// freopen("dpcoing.out", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0);
fill(f, f + nMax, -1);
cin >> K >> L >> m;
for(int i = 1; i <= m; ++i) {
cin >> N[i];
if(Try(N[i])) {
cout << "A";
}
else {
cout << "B";
}
}
return 0;
}
import sys
import threading
def main():
data = sys.stdin.read().split()
K, L, m = int(data[0]), int(data[1]), int(data[2])
N = [int(x) for x in data[3:3 + m]]
f = [-1] * (max(N) + 1)
def Try(n):
if n == 0:
return False
if f[n] != -1:
return f[n]
if n >= L:
f[n] = not Try(n - 1) or not Try(n - K) or not Try(n - L)
elif n >= K:
f[n] = not Try(n - 1) or not Try(n - K)
else:
f[n] = not Try(n - 1)
return f[n]
print("".join("A" if Try(x) else "B" for x in N))
# đệ quy sâu tới cả triệu tầng: tăng giới hạn đệ quy và kích thước stack
sys.setrecursionlimit(3000000)
threading.stack_size(1 << 29)
t = threading.Thread(target=main)
t.start()
t.join()
Đánh giá độ phức tạp: không gian $O(nMax)$ – giờ chỉ còn 1 mảng f; thời gian $O(nMax)$ – tệ nhất cũng chỉ lấp đầy mảng f, sau đó mỗi lần gọi Try(N[i]) là $O(1)$. So với cách 2 thì chạy nhanh hơn một chút, đặc biệt là ăn ít bộ nhớ hơn, code cũng ngắn gọn hơn.
Cách 4Quy hoạch động bottom up – O(N)
Đã có top down thì ta cũng có bottom up – tức là tạo ra công thức truy hồi, mà công thức này thì giống hệt phần top down thôi. Cho ae nào đã biết cơ bản về quy hoạch động rồi thì ta có 4 bước:
Bước 1 – Bài toán con: gọi $F[i]$ là kết quả của người chọn trước với $i$ đồng xu: F[i] = true nghĩa là người đi trước luôn thắng, F[i] = false nghĩa là người đi trước luôn thua.
Bước 2 – Trường hợp cơ sở: còn $0$ đồng xu thì người chọn trước thua: F[0] = false.
Bước 3 – Đáp án: chính là $F[N_i]$: true thì người đi trước là A thắng → in A; false thì A thua → B thắng → in B. Để tối ưu, ta chỉ tính bảng $F$ một lần tới $N_{max}$ (số xu lớn nhất trong $m$ ván), các ván nhỏ hơn đều đã được tính trong đó; chứ với mỗi $N_i$ lại tính lại từ đầu thì có thể bị quá thời gian đó nha.
Bước 4 – Công thức truy hồi: chuyển thẳng từ top down. Xét $i$ đồng xu với $i \ge L$, ta có 3 lựa chọn: lấy 1 xu thì người kia thành người đi trước với $i - 1$ xu, kết quả lựa chọn này là !F[i - 1]; lấy $K$ xu được !F[i - K]; lấy $L$ xu được !F[i - L]. Chỉ cần 1 lựa chọn đúng là thắng, nên dùng phép hoặc. Các trường hợp còn lại bỏ bớt lựa chọn không lấy được:
F[i] = !F[i-1] || !F[i-K] || !F[i-L]khi $i \ge L$F[i] = !F[i-1] || !F[i-K]khi $K \le i \lt L$F[i] = !F[i-1]khi $i \lt K$
Minh hoạ bảng thắng / thua
Các bạn nhập $K$, $L$ và số xu lớn nhất rồi bấm Tiếp để xem bảng $F$ được lấp dần. Ô xanh = người đi trước thắng, ô đỏ = thua. Với mỗi ô $i$, các ô viền nét đứt là $i - 1$, $i - K$, $i - L$ (các trạng thái có thể đưa đối thủ tới); ô viền xanh đậm là nước đi thắng – đẩy đối thủ vào ô đỏ:
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
typedef long long ll;
const int nMax = (int)1e6 + 5;
int N[1005];
bool f[nMax];
int main() {
// freopen("dpcoing.inp", "r", stdin);
// freopen("dpcoing.out", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0);
int K, L, m;
cin >> K >> L >> m;
int n = 0;
for(int i = 1; i <= m; ++i) {
cin >> N[i];
n = max(n, N[i]);
}
for(int i = 1; i <= n; ++i) {
if(i >= L) {
f[i] = (!f[i - 1]) || (!f[i - K]) || (!f[i - L]);
}
else if(i >= K) {
f[i] = (!f[i - 1]) || (!f[i - K]);
}
else {
f[i] = (!f[i - 1]);
}
}
for(int i = 1; i <= m; ++i) {
if(f[N[i]]) {
cout << "A";
}
else {
cout << "B";
}
}
return 0;
}
import sys
data = sys.stdin.read().split()
K, L, m = int(data[0]), int(data[1]), int(data[2])
N = [int(x) for x in data[3:3 + m]]
n = max(N)
f = [False] * (n + 1)
for i in range(1, n + 1):
if i >= L:
f[i] = (not f[i - 1]) or (not f[i - K]) or (not f[i - L])
elif i >= K:
f[i] = (not f[i - 1]) or (not f[i - K])
else:
f[i] = not f[i - 1]
print("".join("A" if f[x] else "B" for x in N))
Đánh giá độ phức tạp: độ phức tạp thời gian và không gian vẫn là $O(N_{max})$, nhưng code chạy nhanh hơn và ăn ít bộ nhớ hơn do không tốn cho việc gọi đệ quy, mảng f cũng chỉ cần kiểu bool. Và như mình nói ở cách 2, không có đệ quy thì cũng không lo tràn stack.
- 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