Dãy Chẵn Lẻ (DAYSO)
Đề bài
Bài 3: (7,0 điểm) DÃY CHẴN LẺ
Tranh thủ trong giờ ra chơi, hai bạn Nam và Bình rủ nhau chơi trò tìm số. Hai bạn lần lượt mỗi người viết một số nguyên dương lên bảng, Nam viết số thứ nhất, Bình viết số thứ hai, rồi đến lượt Nam viết số thứ ba,… Cứ tiếp tục như vậy hai bạn viết được một dãy gồm $n$ số $a_1, a_2, \ldots, a_n$. Đến đây hai bạn chưa kịp chơi trò chơi của mình thì đã đến giờ học. Thầy vào lớp, sẵn thấy dãy số trên bảng, thầy đã đặt ra câu đố: Tìm một đoạn liên tiếp các số trong dãy số trên sao cho đoạn đó có nhiều số nhất và số lượng số chẵn trong đoạn bằng số lượng số lẻ trong đoạn đó.
Dữ liệu vào: Cho từ tệp văn bản có tên DAYSO.INP có dạng như sau:
- Dòng đầu tiên ghi số nguyên $n$ $(1 \le n \le 10^6)$.
- Dòng thứ hai ghi dãy $n$ số nguyên $a_1, a_2, \ldots, a_n$ $(1 \le a_i \le 1000,\ i = 1..n)$.
Kết quả: Ghi ra tệp văn bản có tên DAYSO.OUT gồm một số nguyên duy nhất là số lượng số của dãy con tìm được.
Giới hạn:
- Có 60% số test tương ứng 60% số điểm có giá trị $1 \le n \le 10^2$.
- Có 20% số test tương ứng 20% số điểm có giá trị $10^2 \lt n \le 10^3$.
- Có 20% số test tương ứng 20% số điểm có giá trị $10^3 \lt n \le 10^6$.
Đề gốc đọc/ghi file DAYSO.INP / DAYSO.OUT; code bên dưới đọc/ghi chuẩn (bàn phím – màn hình), khi đi thi các bạn bỏ dấu // ở 2 dòng freopen là được. Đề không nói trường hợp không có đoạn nào thoả (ví dụ $n = 1$), code trong bài in ra 0.
8
3 4 2 6 5 8 7 24Lời mở đầu
Bài này mình được một em học viên hỏi và mình đã hướng dẫn chi tiết cách giải quyết cho em ấy. Tin rằng sẽ có giá trị với nhiều bạn nên mình viết lại thành bài giải chi tiết để các bạn tham khảo nhen 😇.
Hồi đó mình giải cho em ấy theo 7 cách khác nhau, đi từ $O(N^3)$ tối ưu xuống $O(N^2)$, rồi xuống $O(N \log N)$ và cuối cùng là $O(N)$. Mình còn viết chương trình sinh test ngẫu nhiên để so đáp án của cả 7 cách với nhau, đảm bảo tính CHÍNH XÁC của thuật toán. Trong bài này mình chọn ra 3 cách tiêu biểu nhất để các bạn thấy rõ con đường tối ưu.
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é:
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 OJGiải thích ví dụ: dãy $3, 4, 2, 6, 5, 8, 7, 2$. Đoạn $6, 5, 8, 7$ (vị trí 4 → 7) có 2 số chẵn ($6, 8$) và 2 số lẻ ($5, 7$) ⇒ thoả, dài 4. Đoạn $5, 8, 7, 2$ cũng thoả và dài 4. Không có đoạn dài 6 hay 8 nào thoả (ví dụ $3, 4, 2, 6, 5, 8$ có tới 4 số chẵn, 2 số lẻ), nên đáp án là $4$. Để ý: đoạn thoả thì số chẵn = số lẻ nên độ dài luôn là số chẵn.
Lộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Mảng cộng dồn, xét mọi đoạn – O(N²)
Cách ngây thơ nhất là xét mọi đoạn $[L, R]$, rồi chạy thêm một vòng lặp đếm số chẵn, số lẻ trong đoạn ⇒ 3 vòng lặp lồng nhau, $O(N^3)$. Với $n \le 100$ (60% số test) thì vẫn chạy kịp.
Để bỏ bớt 1 vòng lặp, ta dùng mảng cộng dồn đếm số chẵn: gọi $p[i]$ là số lượng số chẵn từ $a_1$ đến $a_i$ (với $p[0] = 0$). Khi đó số lượng số chẵn trong đoạn $[L, R]$ là $p[R] - p[L-1]$, tính trong $O(1)$. Đoạn có $R - L + 1$ phần tử, thoả mãn khi số chẵn bằng đúng một nửa số phần tử:
Thêm một mẹo nhỏ: độ dài đoạn thoả luôn chẵn, nên với mỗi $L$ ta cho $R$ chạy từ $L + 1$ và mỗi lần tăng 2 (j += 2), bỏ qua luôn các đoạn có độ dài lẻ, nhanh gấp đôi.
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <iostream>
using namespace std;
int p[1000005]; // p[i] = số lượng số chẵn từ a[1] đến a[i]
int main(){
// freopen("DAYSO.INP", "r", stdin);
// freopen("DAYSO.OUT", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0);
int n, x;
cin >> n;
for(int i = 1; i <= n; ++i){
cin >> x;
p[i] = p[i - 1] + (x % 2 == 0);
}
int Max = 0;
for(int i = 1; i <= n; ++i){
for(int j = i + 1; j <= n; j += 2){
int even = p[j] - p[i - 1];
if(even * 2 == j - i + 1){
Max = max(Max, j - i + 1);
}
}
}
cout << Max;
return 0;
}
import sys
data = sys.stdin.buffer.read().split()
n = int(data[0])
p = [0] * (n + 1) # p[i] = số lượng số chẵn từ a[1] đến a[i]
for i in range(1, n + 1):
p[i] = p[i - 1] + (int(data[i]) % 2 == 0)
Max = 0
for i in range(1, n + 1):
for j in range(i + 1, n + 1, 2):
even = p[j] - p[i - 1]
if even * 2 == j - i + 1:
Max = max(Max, j - i + 1)
print(Max)
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(N)$ cho mảng cộng dồn $p$.
- Thời gian: 2 vòng lặp lồng nhau, khoảng $\frac{N^2}{4}$ lần kiểm tra ⇒ $O(N^2)$.
Cách 2Biến đổi 2p[i] − i, tìm bằng map – O(N log N)
Mấu chốt nằm ở việc biến đổi điều kiện ở cách 1 cho hai vế tách bạch $L$ và $R$. Ta có:
$\Leftrightarrow R - (L-1) = 2p[R] - 2p[L-1]$
$\Leftrightarrow 2p[R] - R = 2p[L-1] - (L-1)$ (chuyển vế đổi dấu)
Vậy đặt $key(i) = 2p[i] - i$, đoạn $[L, R]$ thoả mãn khi và chỉ khi $key(R) = key(L-1)$. Nếu $R - (L-1)$ ra số lẻ thì hai vế không thể bằng nhau, nên không lo bị đếm nhầm đoạn độ dài lẻ.
Bài toán trở thành: với mỗi $L$, tìm vị trí $R$ lớn nhất có $key(R) = key(L-1)$, độ dài đoạn là $R - L + 1$. Cách làm của mình:
- Duyệt $i$ từ $1$ đến $n$, tính $key = 2p[i] - i$ và lưu vào
map:mp[key] = i. Key chính là giá trị $2p[i] - i$, còn value là vị trí $i$. Do $i$ chạy từ nhỏ đến lớn, gặp key trùng thì ghi đè $i$ mới vào ⇒ cuối cùng mỗi key lưu đúng vị trí lớn nhất có key đó. - Duyệt lại $i$ (đóng vai trò $L$) từ $1$ đến $n$: tính $2p[i-1] - (i-1)$, tìm trong map ra value $j$ – chính là $R$ xa nhất. Độ dài đoạn là $j - (i-1) = j - i + 1$, so với
Maxvà cập nhật.
Lấy luôn ví dụ của đề $3, 4, 2, 6, 5, 8, 7, 2$: ta có $key(3) = 2 \cdot 2 - 3 = 1$, $key(5) = 2 \cdot 3 - 5 = 1$, $key(7) = 2 \cdot 4 - 7 = 1$. Khi lưu, mp[1] lần lượt được ghi $3$, rồi ghi đè thành $5$, rồi thành $7$ ⇒ cuối cùng mp[1] = 7, đúng là vị trí lớn nhất. Sau đó xét $L = 4$: tính $key(L-1) = key(3) = 1$, tra map ra $R = 7$ ⇒ đoạn $[4, 7] = 6, 5, 8, 7$ dài $7 - 4 + 1 = 4$ – chính là đáp án. Các bạn xem từng bước trong khung minh hoạ bên dưới nhé.
Minh hoạ: bảng key và tra cứu vị trí xa nhất
Em nhập dãy số rồi bấm Tiếp: giai đoạn 1 tính $p[i]$, $key(i) = 2p[i] - i$ và lưu vị trí lớn nhất của mỗi key; giai đoạn 2 với mỗi $L$ tra $key(L-1)$ ra $R$ xa nhất.
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <iostream>
#include <map>
using namespace std;
int p[1000005]; // p[i] = số lượng số chẵn từ a[1] đến a[i]
int main(){
// freopen("DAYSO.INP", "r", stdin);
// freopen("DAYSO.OUT", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0);
int n, x;
cin >> n;
for(int i = 1; i <= n; ++i){
cin >> x;
p[i] = p[i - 1] + (x % 2 == 0);
}
map<int, int> mp; // key = 2p[i] - i, value = vị trí i lớn nhất
for(int i = 1; i <= n; ++i){
mp[2 * p[i] - i] = i;
}
int Max = 0;
for(int i = 1; i <= n; ++i){
map<int, int>::iterator it = mp.find(2 * p[i - 1] - (i - 1));
if(it != mp.end()){
Max = max(Max, it->second - i + 1);
}
}
cout << Max;
return 0;
}
import sys
data = sys.stdin.buffer.read().split()
n = int(data[0])
p = [0] * (n + 1) # p[i] = số lượng số chẵn từ a[1] đến a[i]
for i in range(1, n + 1):
p[i] = p[i - 1] + (int(data[i]) % 2 == 0)
mp = {} # key = 2p[i] - i, value = vị trí i lớn nhất
for i in range(1, n + 1):
mp[2 * p[i] - i] = i
Max = 0
for i in range(1, n + 1):
key = 2 * p[i - 1] - (i - 1)
if key in mp:
Max = max(Max, mp[key] - i + 1)
print(Max)
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(N)$ cho mảng $p$ và map (tối đa $N$ key).
- Thời gian: 2 vòng lặp rời nhau, mỗi vòng $N$ lần, mỗi lần thao tác trên
mapmất $O(\log N)$ (map cài đặt theo cơ chế cây) ⇒ $O(N \log N)$.
dict (bảng băm) – tra cứu trung bình $O(1)$, nên bản Python của cách 2 thực chất đã là $O(N)$.Cách 3Mảng đánh dấu thay cho map – O(N)
Cách 2 tốn $O(\log N)$ cho mỗi lần tra map. Mình thay map bằng mảng đánh dấu để tra trong $O(1)$. Để ý $0 \le p[i] \le i$ nên:
- $2p[i] - i$ cao lắm là $i \le 10^6$ (khi $p[i] = i$, toàn số chẵn);
- nhưng cẩn thận nó có thể âm, thấp nhất là $-10^6$ (khi $p[i] = 0$, $i = 10^6$).
Vậy mình tạo mảng đánh dấu b hơn 2 triệu phần tử, mỗi lần tính $2p[i] - i$ thì cộng thêm 1 triệu (OFFSET) để chỉ số không bị âm: b[2 * p[i] - i + OFFSET] = i. Phần còn lại y hệt cách 2. Key nào chưa từng xuất hiện thì b bằng 0 (mảng toàn cục), khi đó $0 - i + 1 \le 0$ nên không ảnh hưởng gì tới Max.
Source code để các bạn tham khảo nếu làm theo hướng này:
#include <iostream>
using namespace std;
const int OFFSET = 1000000;
int p[1000005]; // p[i] = số lượng số chẵn từ a[1] đến a[i]
int b[2000005]; // b[key + OFFSET] = vị trí i lớn nhất có 2p[i] - i = key
int main(){
// freopen("DAYSO.INP", "r", stdin);
// freopen("DAYSO.OUT", "w", stdout);
ios::sync_with_stdio(0);
cin.tie(0);
int n, x;
cin >> n;
for(int i = 1; i <= n; ++i){
cin >> x;
p[i] = p[i - 1] + (x % 2 == 0);
}
for(int i = 1; i <= n; ++i){
b[2 * p[i] - i + OFFSET] = i;
}
int Max = 0;
for(int i = 1; i <= n; ++i){
int j = b[2 * p[i - 1] - (i - 1) + OFFSET];
Max = max(Max, j - i + 1);
}
cout << Max;
return 0;
}
import sys
data = sys.stdin.buffer.read().split()
n = int(data[0])
p = [0] * (n + 1) # p[i] = số lượng số chẵn từ a[1] đến a[i]
for i in range(1, n + 1):
p[i] = p[i - 1] + (int(data[i]) % 2 == 0)
OFFSET = n
b = [0] * (2 * n + 1) # b[key + OFFSET] = vị trí i lớn nhất có 2p[i] - i = key
for i in range(1, n + 1):
b[2 * p[i] - i + OFFSET] = i
Max = 0
for i in range(1, n + 1):
j = b[2 * p[i - 1] - (i - 1) + OFFSET]
Max = max(Max, j - i + 1)
print(Max)
Đánh giá độ phức tạp của cách làm này:
- Không gian: mảng $p$ cỡ $10^6$ và mảng đánh dấu $b$ cỡ $2 \cdot 10^6$ phần tử kiểu
int– khoảng 12 MB, thoải mái. - Thời gian: 3 vòng lặp rời nhau, mỗi vòng $N$ lần, mỗi lần $O(1)$ ⇒ $O(N)$.
x rồi cộng dồn thẳng vào p, không cần lưu cả mảng $a$. Bản Python thì mình đặt OFFSET = n và tạo mảng $2n + 1$ phần tử cho đỡ tốn bộ nhớ, vì $|2p[i] - i| \le i \le n$.- 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