Chia Đoạn (SEG)
Đề bài
SEG – Chia đoạn · Giới hạn thời gian: 1.0 giây · Giới hạn bộ nhớ: 128 megabyte
Cho một băng số gồm $n$ số nguyên dương $a_1, a_2, \ldots, a_n$, mỗi số được viết trên một ô. Hãy cắt băng số này thành nhiều đoạn nhất sao cho tổng các phần tử trong các đoạn là bằng nhau.
Dữ liệu vào:
- Dòng đầu ghi $n$ $(n \le 1000)$.
- Trong $n$ dòng tiếp theo, dòng thứ $i$ ghi một số nguyên dương $a_i$ $(1 \le a_i \le 1000)$.
Dữ liệu ra: Là số lượng đoạn nhiều nhất chia được.
8
10
2
6
2
5
2
1
23Giải thích: Đoạn 1: $10$. Đoạn 2: $2 + 6 + 2 = 10$. Đoạn 3: $5 + 2 + 1 + 2 = 10$.
Lời mở đầu
Bài này mình được một bạn học viên hỏi nên sẵn tiện mình viết bài giải chi tiết để nhiều bạn được tham khảo luôn. Hy vọng mang đến được giá trị cho các bạn 😇.
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 OJNhắc lại ngưỡng quen thuộc: với giới hạn 1 giây thì toàn bài chỉ nên lặp khoảng $(3 \div 5) \cdot 10^7$ lần (C/C++) là an toàn, $10^8$ thì hên xui. Đề này cho đúng 1 giây nên cứ canh theo ngưỡng đó. Và có 2 kinh nghiệm mình muốn chia sẻ với các bạn:
- Có ý tưởng rồi thì khoan hãy cài đặt, hãy đánh giá độ phức tạp trước: tổng số lần lặp có vượt ngưỡng không? Ổn thì mới cài, không ổn thì đừng mất công (trừ khi không nghĩ ra cách nào tốt hơn và đành vét điểm test nhỏ).
- Đứng trước mọi bài, đầu tiên hãy nhìn theo cách cơ bản nhất (vét cạn, duyệt trâu). Nhẩm độ phức tạp, nếu AC được thì cài luôn cho nhanh gọn – trong phòng thi giải càng nhanh càng lợi thế, tội gì nặng đầu nghĩ cách tối ưu mà lỡ cài sai thì toang. Còn khi không bị áp lực thời gian thì nghĩ thêm cách tối ưu là điều tốt để rèn tư duy.
Giải thích ví dụ: băng số $10, 2, 6, 2, 5, 2, 1, 2$ có tổng 30. Cắt thành 3 đoạn $[10]$, $[2, 6, 2]$, $[5, 2, 1, 2]$ thì đoạn nào cũng có tổng 10. Không cắt được nhiều hơn: ví dụ muốn 5 đoạn thì mỗi đoạn tổng 6 nhưng ô đầu tiên đã là 10 rồi. Lưu ý các đoạn phải liên tiếp và phủ kín cả băng số. Băng số nào cũng có ít nhất 1 cách là để nguyên cả băng (1 đoạn).
Lộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Thử từng tổng của đoạn đầu tiên – O(N²)
Nếu là mình đang trong phòng thi thì mình sẽ làm theo cách cơ bản nhất như sau. Đề không cho trước tổng mỗi đoạn là bao nhiêu, nhưng đoạn đầu tiên luôn bắt đầu từ ô đầu tiên, nên tổng mỗi đoạn chỉ có thể là tổng của 1, 2, 3, …, $n$ phần tử đầu tiên. Ta thử lần lượt từng giá trị đó làm sum, rồi xem phần còn lại của dãy có gom hết được thành các đoạn liên tiếp có tổng bằng sum không. Nếu được thì đếm số đoạn (tính luôn đoạn đầu) và so sánh với Max.
s = 0 rồi cộng dồn lần lượt các phần tử phía sau:
s < sum: chưa đủ, cộng tiếp phần tử kế.s == sum: cắt được 1 đoạn ⇒ tăng biến đếm, resets = 0, xét tiếp.s > sum: vượt rồi ⇒ dừng, không thể cắt với tổng này.
s == 0) thì mới cập nhật Max. Nếu có một đoạn cuối không đủ tổng thì các đoạn cắt trước đó coi như vô nghĩa.Chạy tay với ví dụ của đề, $a = \{10, 2, 6, 2, 5, 2, 1, 2\}$ (đánh số từ 0):
- Đoạn đầu là $a[0]$,
sum = 10,cnt = 1. Từ $a[1]$: $s = 2$, rồi $s = 8$, rồi $s = 10 = sum$ ⇒ cắt,cnt = 2, reset $s = 0$. Tiếp $s = 5, 7, 8, 10$ ⇒ cắt,cnt = 3. Hết phần tử, $s = 0$ ⇒Max = 3. - Đoạn đầu là $a[0..1]$,
sum = 12: $a[2] + a[3] = 8 \lt 12$, cộng thêm $a[4]$ thành $13 \gt 12$ ⇒ không cắt được. - Đoạn đầu là $a[0..2]$,
sum = 18: tổng toàn bộ phần còn lại chỉ là $12 \lt 18$ ⇒ không cắt được. Đoạn đầu càng dài thìsumcàng lớn, phần còn lại càng nhỏ, nên từ đây không còn cách nào nữa (trừ để nguyên cả băng).
Vậy đáp án là 3. Các bạn nhập dãy vào khung dưới và bấm Tiếp để xem từng bước cắt nhé (mỗi đoạn đã cắt được tô một màu, ô cam là đoạn đang gom dở, ô đỏ là chỗ bị vượt tổng):
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 main(){
int n, sum = 0, Max = -1;
cin >> n;
int a[n];
for(int i = 0; i < n; ++i){
cin >> a[i];
}
for(int i = 0; i < n; ++i){
sum += a[i];
int s = 0, cnt = 1;
for(int j = i + 1; j < n; ++j){
s += a[j];
if(s > sum){
break;
}
else if(s == sum){
cnt++;
s = 0;
}
}
if(s == 0){
Max = max(Max, cnt);
}
}
cout << Max;
return 0;
}
import sys
data = sys.stdin.read().split()
n = int(data[0])
a = [int(x) for x in data[1:1 + n]]
total, Max = 0, -1
for i in range(n):
total += a[i]
s, cnt = 0, 1
for j in range(i + 1, n):
s += a[j]
if s > total:
break
elif s == total:
cnt += 1
s = 0
if s == 0:
Max = max(Max, cnt)
print(Max)
Giải thích một số chỗ trong code:
- Trong vòng
for j, nếus > sumthìbreak: đó là dấu hiệu không thể chia phần còn lại thành các đoạn có tổngsum, khỏi xét tiếp. - Sau vòng
for j(vẫn trongfor i), chỉ cập nhậtMaxkhis == 0: nghĩa là đã duyệt tới phần tử cuối cùng và đoạn cuối cũng vừa đủ tổng nênsđược reset về 0. Nếus != 0thì đoạn cuối không bằngsum, các đoạn cắt trước đó cũng vô nghĩa.
Đánh giá độ phức tạp của cách làm này:
- Không gian: $O(N)$ cho mảng $a$.
- Thời gian: hai vòng lặp lồng nhau ⇒ $O(N^2)$. Với $N \le 10^3$ thì chỉ khoảng $10^6$ lần lặp, an toàn, không sợ TLE.
Cách 2Cải tiến: dừng sớm ở 2 chỗ – vẫn O(N²) nhưng nhanh hơn
Nếu đang trong phòng thi thì mình chỉ code Cách 1 thôi, còn khi có thêm thời gian thì nhìn ra thêm 2 chỗ có thể kết thúc sớm:
for j mà cnt == 1 && s < sum (chưa cắt được đoạn nào và s lúc này chính là tổng của cả phần còn lại) thì break luôn vòng for i.sum lớn hơn, các đoạn còn lại phải nạp thêm phần tử để đủ tổng, nên số đoạn chỉ có thể giảm. Ví dụ $a = \{2, 2, 2, 2\}$: đoạn đầu 1 phần tử ⇒ $|2|2|2|2|$ được 4 đoạn; đoạn đầu 2 phần tử ⇒ $|2, 2|2, 2|$ chỉ còn 2 đoạn. Vậy chỉ cần thêm break ngay sau chỗ cập nhật Max.Max = -1 như bản gốc thì sẽ in ra $-1$, trong khi đáp án đúng là 1. Cách sửa đơn giản: khởi tạo Max = 1 (cả băng luôn là 1 đoạn hợp lệ). Code dưới đây đã sửa chỗ này.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 main(){
int n, sum = 0, Max = 1;
cin >> n;
int a[n];
for(int i = 0; i < n; ++i){
cin >> a[i];
}
for(int i = 0; i < n; ++i){
sum += a[i];
int s = 0, cnt = 1;
for(int j = i + 1; j < n; ++j){
s += a[j];
if(s > sum){
break;
}
else if(s == sum){
cnt++;
s = 0;
}
}
if(s == 0){
Max = max(Max, cnt);
break;
}
else if(cnt == 1 && s < sum){
break;
}
}
cout << Max;
return 0;
}
import sys
data = sys.stdin.read().split()
n = int(data[0])
a = [int(x) for x in data[1:1 + n]]
total, Max = 0, 1
for i in range(n):
total += a[i]
s, cnt = 0, 1
for j in range(i + 1, n):
s += a[j]
if s > total:
break
elif s == total:
cnt += 1
s = 0
if s == 0:
Max = max(Max, cnt)
break
elif cnt == 1 and s < total:
break
print(Max)
Đánh giá độ phức tạp: chạy nhanh hơn nhiều trong thực tế, nhưng trường hợp xấu nhất vẫn là $O(N^2)$ thời gian, $O(N)$ không gian.
Cách 3Mảng cộng dồn + tìm kiếm nhị phân – O(N log² N)
Giả sử $N$ lên đến $10^6$ thì các cách trên đều TLE hết. Muốn tối ưu hơn thì phải nhìn ra cái dở của cách hiện tại: với phần còn lại, ta cứ phải đi tuần tự (vòng for j) cộng dồn rồi so sánh. Có cách gì tốt hơn không?
Sẽ tốt hơn nếu ngay từ đầu ta lưu mảng $a$ là mảng cộng dồn: $a[i]$ là tổng các phần tử từ đầu đến chính vị trí $i$. Với ví dụ của đề:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| giá trị | 10 | 2 | 6 | 2 | 5 | 2 | 1 | 2 |
| cộng dồn a[i] | 10 | 12 | 18 | 20 | 25 | 27 | 28 | 30 |
Vì các phần tử đều dương nên mảng cộng dồn tăng dần – à há, các bạn nhìn ra rồi chứ? Ta có thể tìm kiếm nhị phân thay cho tìm kiếm tuyến tính! Chia được thành các đoạn tổng $a[i]$ nghĩa là các giá trị $2a[i], 3a[i], \ldots$ phải lần lượt xuất hiện trong mảng cộng dồn, và giá trị cuối cùng tìm thấy phải nằm ở phần tử cuối mảng.
cnt = 1, x = 2 * a[i]. Tìm nhị phân $x$ trong phần mảng phía sau vị trí vừa tìm được. Thấy thì cnt++, x += a[i] rồi tìm tiếp; không thấy thì cách chia này hỏng. Tìm được tới phần tử cuối mảng thì đó là đáp án (theo nhận xét "dừng sớm thứ 2" ở trên).Với ví dụ: $i = 0$, $a[0] = 10$, tìm $x = 20$ từ vị trí 1 ⇒ thấy ở $j = 3$, cnt = 2, $x = 30$. Tìm $30$ từ vị trí 4 ⇒ thấy ở $j = 7$ là phần tử cuối, cnt = 3. Kết luận đáp án 3.
Vì $a_i \ge 1$ nên mảng cộng dồn tăng ngặt, không có 2 giá trị trùng nhau, cứ yên tâm tìm nhị phân. Hàm binary_search có sẵn của STL chỉ cho biết có tồn tại hay không chứ không trả về vị trí, nên ta dùng lower_bound: trả về vị trí phần tử đầu tiên $\ge x$. Nếu vị trí đó có giá trị $== x$ thì tìm thấy, còn $\gt x$ (hoặc vị trí $== n$) thì không tồn tại $x$. Bạn nào chưa biết lower_bound thì nên tìm hiểu thêm nhé, nó rất hữu ích.
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 main(){
int n;
cin >> n;
int a[n], sum = 0, x, Max = -1;
for(int i = 0; i < n; ++i){
cin >> x;
sum += x;
a[i] = sum;
}
for(int i = 0; i < n; ++i){
int cnt = 1, j = i, check = 1;
x = 2 * a[i];
while(j + 1 < n){
j = lower_bound(a + j + 1, a + n, x) - a;
if(j != n && a[j] == x){
cnt++;
x += a[i];
}
else{
check = 0;
break;
}
}
if(check == 1){
Max = max(Max, cnt);
break;
}
}
cout << Max;
return 0;
}
import sys
from bisect import bisect_left
data = sys.stdin.read().split()
n = int(data[0])
a = []
total = 0
for i in range(n):
total += int(data[1 + i])
a.append(total)
Max = -1
for i in range(n):
cnt, j, check = 1, i, True
x = 2 * a[i]
while j + 1 < n:
j = bisect_left(a, x, j + 1, n)
if j != n and a[j] == x:
cnt += 1
x += a[i]
else:
check = False
break
if check:
Max = max(Max, cnt)
break
print(Max)
Giải thích một số chỗ trong code:
- Tạo mảng cộng dồn ngay lúc đọc: đọc vào
x, cộng vàosum, rồi gána[i] = sum, không cần thêm vòng lặp riêng. j = lower_bound(a + j + 1, a + n, x) - a: lần tìm sau bắt đầu từ ngay sau vị trí tìm được lần trước. Vì vậy ban đầu khởi tạoj = i(lần đầu tìm từ $i + 1$), và điều kiệnwhile(j + 1 < n)đảm bảo vị trí bắt đầu tìm còn hợp lệ.- Không tìm thấy thì
lower_boundtrả về đúngn, nên phải kiểm traj != ntrước rồi mới xéta[j] == x– để ngược lại mà lỡj == nthì truy cập ngoài mảng, có thể văng lỗi đó nha. - Cờ
checkban đầu bằng 1, gặp chỗ không tìm thấy thì gán 0 và thoát. Sau cùng nếucheck == 1thì cập nhậtMaxvàbreakluôn vì đó là đáp án lớn nhất. (Với $i = n - 1$ vòngwhilekhông chạy,checkvẫn là 1, nên chắc chắn luôn có đáp án ít nhất là 1.)
Khung chạy tay (để dễ theo dõi, giới hạn $n \le 15$):
Đá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.
- Thời gian: vòng
for iduyệt tuần tự, với mỗi $i$ ta tìm nhị phân trên phần còn lại của mảng, mỗi lần tìm $O(\log N)$. Với mỗi $i$ thì số lần tìm thành công không quá số đoạn tổng $a[i]$ có thể có, cỡ $N / (i + 1)$ trong trường hợp xấu (các phần tử nhỏ), cộng lại theo chuỗi điều hòa $N(1 + \frac{1}{2} + \frac{1}{3} + \ldots) \approx N \ln N$ lần tìm. Vậy xấu nhất khoảng $O(N \log^2 N)$, còn thực tế thường chỉ cỡ $O(N \log N)$ – rất nhanh.
Max = 1 ở Cách 2 (bản gốc khởi tạo Max = -1 in sai $-1$ với test như $6, 1, 1$). Với $n = 1000$ cả 3 cách C++ đều chạy dưới 0.03 giây, Python dưới 0.3 giây.- 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