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

Đoạn con liên tiếp có tổng lớn nhất (độ dài từ L đến R)

Đoạn con liên tiếp có tổng lớn nhất (độ dài từ L đến R)
Nguồn: CLUE OJ Độ khó: Trung bình Tổng tiền tố multiset

Đề bài

Đoạn con liên tiếp có tổng lớn nhất (có số âm)

Cho dãy gồm $n$ số nguyên $a_1, a_2, \ldots, a_n$ và hai số nguyên dương $1 \le L \le R \le n$. Hãy tìm một dãy con gồm các phần tử liên tiếp có độ dài $s$ $(L \le s \le R)$ có tổng các phần tử là lớn nhất.

Input:

  • Dòng đầu tiên gồm ba số nguyên $n, L, R$ $(1 \le n \le 10^5)$.
  • Dòng thứ hai chứa $n$ số nguyên $a_1, a_2, \ldots, a_n$ $(|a_i| \le 10^9)$.

Output: Gồm một dòng chứa một số là tổng các phần tử lớn nhất của dãy con tìm được thỏa mãn.

Subtask:

  • Subtask 1 (30%): $n \le 100$;
  • Subtask 2 (30%): $n \le 5000$;
  • Subtask 3 (40%): Không có ràng buộc nào thêm.

Giải thích ví dụ: chọn đoạn $(3, -1, 5)$ ở vị trí 2..4, độ dài $3$ nằm trong $[2, 3]$, tổng bằng $7$. Đoạn $(1, 3, -1, 5)$ có tổng $8$ lớn hơn nhưng dài 4 > R nên không được chọn.

Ví dụ – input
5 2 3
1 3 -1 5 -1
Ví dụ – output
7

Lời mở đầu

Bài "đoạn con có tổng lớn nhất" thì chắc các bạn gặp nhiều rồi, nhưng ở đây có thêm ràng buộc độ dài phải nằm trong $[L, R]$, nên mẹo Kadane quen thuộc không dùng thẳng được nữa. Mình sẽ đi từ cách vét cạn dễ hiểu nhất, rồi tối ưu dần: tổng cộng dồn, rồi tổng tiền tố + multiset để "trượt" một cửa sổ và luôn lấy được giá trị nhỏ nhất trong cửa sổ đó. Đây là kỹ thuật rất hay gặp trong chương Map/Set.

Dành cho học viên · Ưu tiênNộp bài trên hệ thống CLUE OJ

Link này chỉ học viên đã tham gia tổ chức mới vào được. Học viên nên ưu tiên nộp ở đây vì sẽ được chấm nhanh hơn.

Nộp bài trên CLUE OJ
Dành cho tất cả mọi ngườiNộp bài trên Codeforces

Bạn nào muốn thử nộp bài để hệ thống chấm xem có đúng hay không thì dùng link này nhé. Chưa có tài khoản thì tạo tài khoản rồi đăng nhập nộp bài thử hen.

Nộp bài trên Codeforces

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

Bộ testBộ test chính thức có gì?

Bộ test có 100 test, chia nhóm theo kích thước như sau:

TestnĐộ rộng R − L + 1$|a_i|$ lớn nhấtSố âmĐáp án
1–101 … 10≤ 32045%-40 … 44
11–30100≤ 85991.672.84848%-278.883.892 … 4.422.283.233
31–605.000≤ 4.393999.898.38050%-16.519.056.176 … 18.558.110.289
61–70100.000≤ 54.569999.998.85649%6.241.931.093 … 81.551.539.414
71–99100.000≤ 100.000999.999.46549%43.693.108.563 … 141.943.033.673
100100.000≤ 100.0001.000.000.0000%100.000.000.000.000 … 100.000.000.000.000

Đối chiếu subtask của đề với test thật (cả 3 subtask đều khớp với đề) và số test đúng của từng cách:

SubtaskĐề ghiTestCách 1
$O(n^3)$
Cách 2
$O(n^2)$
Cách 3
multiset
1 (30%)$n \le 100$1–3030/3030/3030/30
2 (30%)$n \le 5000$31–6019/3030/3030/30
3 (40%)$n \le 10^5$61–1002/405/4040/40
Hai cái bẫy nằm sẵn trong bộ test
  • Đáp án âm: các test 3, 9–10, 24, 50, 55 có đáp án âm (ví dụ cả dãy đều âm). Nếu em khởi tạo ans = 0 thì sẽ in ra 0 – sai. Phải khởi tạo ans thật nhỏ (LLONG_MIN).
  • Tràn số: test 100 có $10^5$ số đều bằng $10^9$, đáp án là $10^{14}$ – vượt xa int. Tổng phải dùng long long.

Ý tưởngTổng đoạn = hiệu hai tổng tiền tố

Gọi $p_i = a_1 + a_2 + \ldots + a_i$ (với $p_0 = 0$). Khi đó tổng đoạn $a_{i+1..j}$ (dài $j - i$) là $p_j - p_i$.

Cố định điểm cuối $j$. Độ dài $j - i$ phải nằm trong $[L, R]$, tức là $i$ chạy trong khoảng $[j - R,\ j - L]$ (và $i \ge 0$). Muốn $p_j - p_i$ lớn nhất thì phải chọn $p_i$ nhỏ nhất trong khoảng đó.

Thuật toán
Duyệt $j = L, L+1, \ldots, n$. Khi $j$ tăng 1 thì khoảng $[j - R,\ j - L]$ cũng trượt sang phải 1 ô: thêm $p_{j-L}$ vào, bỏ $p_{j-R-1}$ ra. Giữ các giá trị trong một multiset (tự sắp xếp tăng dần, cho phép trùng nhau) thì phần tử nhỏ nhất luôn là *ms.begin(). Đáp án là max của $p_j - \min$.

Vì sao là multiset mà không phải set? Vì các tổng tiền tố có thể trùng nhau: nếu dùng set thì 2 giá trị bằng nhau chỉ lưu 1 lần, đến lúc xoá một cái là mất luôn cái còn lại. Và khi xoá phải dùng ms.erase(ms.find(x)) để xoá đúng một bản sao – viết ms.erase(x) sẽ xoá hết mọi bản sao của $x$.

Em nhập dãy, $L$, $R$ rồi bấm Tiếp để xem cửa sổ trượt trên mảng tổng tiền tố:

Ví dụ nhanh:

Cách 1Vét cạn mọi đoạn – Subtask 1

Thử mọi điểm đầu $i$, mọi điểm cuối $j$ sao cho độ dài $j - i + 1 \in [L, R]$, rồi cộng lại các số từ $a_i$ đến $a_j$. Ba vòng lặp lồng nhau nên là $O(n^3)$ (đúng hơn là $O(n \cdot R^2)$).

C++▶ Chạy tay từng dòng codeCách 1: thử mọi điểm đầu i, điểm cuối j, cộng lại a[i..j] từ đầu – O(n³).
Dữ liệu mẫu:
  1. Vòng j bắt đầu từ i + L - 1 (đoạn dài đúng $L$) và dừng ở min(n, i + R - 1) (đoạn dài $R$, nhưng không được vượt quá $n$).
  2. ans khởi tạo LLONG_MIN để đúng cả khi mọi đoạn đều có tổng âm.
Không gian$O(n)$
Thời gian$O(n^3)$

Kết quả chạy thật: bản C++ đúng 51/100 test (quá thời gian ở test 36, 38, 40, 43, 46–48, 52–54, 59, 61–65, 67, 69–100), test đúng chạy lâu nhất 0.96s; bản Python (chỉ chạy test 1–60) đúng 38/60 test (quá thời gian ở test 31–33, 36–38, 40–48, 51–54, 56, 58–59), test đúng lâu nhất 0.51s. Cách này ăn trọn subtask 1; ngoài ra những test có $R$ nhỏ (cả ở subtask 2 lẫn subtask 3) cũng qua, vì số phép tính thật ra cỡ $n \cdot R^2 / 2$ chứ không hẳn là $n^3$.

Cách 2Cộng dồn khi kéo dài đoạn – Subtask 1, 2

Ở cách 1, khi $j$ tăng thêm 1, ta lại cộng lại từ đầu $a_i + \ldots + a_j$ – rất phí. Thay vào đó, giữ biến s là tổng $a_i..a_j$, mỗi lần kéo dài đoạn chỉ cần s += a[j]. Mất đi một vòng lặp, còn $O(n \cdot R) \le O(n^2)$.

C++▶ Chạy tay từng dòng codeCách 2: giữ tổng s khi kéo dài đoạn, mỗi bước chỉ cộng thêm a[j] – O(n·R).
Dữ liệu mẫu:
Không gian$O(n)$
Thời gian$O(n \cdot R) \le O(n^2)$

Kết quả chạy thật: bản C++ đúng 65/100 test (quá thời gian ở test 61, 63, 67, 69–100), test đúng chạy lâu nhất 0.80s; bản Python (chỉ chạy test 1–80) đúng 45/80 test (quá thời gian ở test 36, 38, 40–41, 43–44, 46–48, 50, 52–55, 59, 61–80), test đúng lâu nhất 0.95s. Ăn trọn subtask 1 và 2. Ở subtask 3, các test 61–70 có $R$ không quá lớn nên vài test vẫn qua, còn các test 71–100 có $L = 1$, $R = n = 10^5$ thì phải duyệt khoảng $5 \cdot 10^9$ cặp $(i, j)$ – quá thời gian.

Cách 3Tổng tiền tố + multiset – Full điểm

Cài đặt đúng ý tưởng ở trên: tính mảng p, rồi trượt cửa sổ $[j - R, j - L]$ với một multiset.

C++▶ Chạy tay từng dòng codeCách 3: tổng tiền tố p, trượt cửa sổ [j − R, j − L] và lấy p nhỏ nhất bằng multiset.
Dữ liệu mẫu:
  1. ms.insert(p[j - L]): chỉ số $i = j - L$ vừa đủ xa để đoạn $a_{i+1..j}$ dài đúng $L$.
  2. if (j - R - 1 >= 0) ms.erase(ms.find(p[j - R - 1])): chỉ số $i = j - R - 1$ cho đoạn dài $R + 1$ – vừa vượt quá giới hạn nên phải bỏ đi. Mỗi giá trị được thêm 1 lần và xoá tối đa 1 lần.
  3. *ms.begin() là phần tử nhỏ nhất của multiset (luôn có ít nhất 1 phần tử vì vừa thêm vào).
  4. Bản Python: Python không có multiset sẵn nên mình dùng heapq lưu cặp $(p_i, i)$. Khi đỉnh heap có chỉ số $i < j - R$ (đã ra khỏi cửa sổ) thì mới bỏ đi – gọi là xoá lười: phần tử cũ nằm dưới đáy heap không làm sai kết quả nên cứ để đó.
Không gian$O(n)$
Thời gian$O(n \log n)$

Kết quả chạy thật: bản C++ đúng 100/100 test, test đúng chạy lâu nhất 0.04s; bản Python đúng 100/100 test, test đúng lâu nhất 0.26s.

Mở rộng: thay multiset bằng hàng đợi hai đầu đơn điệu (deque giữ các chỉ số có $p$ tăng dần) sẽ được $O(n)$. Nhưng với $n = 10^5$ thì multiset $O(n \log n)$ đã quá đủ, lại dễ viết và dễ hiểu hơn.

Tổng kếtKết quả chạy thật trên bộ test

CáchNgôn ngữĐúngTest đúng lâu nhấtGhi chú
1. Vét cạn $O(n^3)$C++51/1000.96sQuá thời gian test 36, 38, 40, 43, 46–48, 52–54, 59, 61–65, 67, 69–100
Python38/600.51sChỉ chạy test 1–60; quá thời gian test 31–33, 36–38, 40–48, 51–54, 56, 58–59
2. Cộng dồn $O(n \cdot R)$C++65/1000.80sQuá thời gian test 61, 63, 67, 69–100
Python45/800.95sChỉ chạy test 1–80; quá thời gian test 36, 38, 40–41, 43–44, 46–48, 50, 52–55, 59, 61–80
3. Tổng tiền tố + multisetC++100/1000.04sFull điểm
Python100/1000.26sFull điểm (heapq + xoá lười)
Ghi chú của mình: thời gian trong bảng là thời gian CPU đo trên máy của mình với giới hạn 1 giây. Cách 3 (multiset) chạy mọi test dưới 0.05 giây với C++. Hai bẫy cần nhớ: khởi tạo ans = LLONG_MIN (có test đáp án âm) và dùng long long (đáp án tới $10^{14}$).
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 😇

Nhận xét