Dãy số 2 (SEQ2)
Đề bài
SEQ2 – Dãy số 2
Yêu cầu: Tìm giá trị lớn nhất của biểu thức $a_i + a_j - a_k$ $(1 \le i \lt j \lt k \le n)$.
Dữ liệu:
- Dòng đầu tiên chứa số nguyên $n$ $(0 \lt n \le 10^6)$.
- Dòng thứ 2 là các số nguyên $a_1, a_2, \ldots, a_n$.
Kết quả: Một dòng duy nhất ghi giá trị lớn nhất của biểu thức.
Giới hạn: thời gian 1 giây, bộ nhớ 512 MB.
7
8 2 4 -5 9 1 -320Lờ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 😇.
Mình nói luôn bài này là bài "Dãy số 2". Nếu bạn nào vẫn chưa làm được thì trước tiên hãy thử làm bài "Dãy số 1" (tìm hiệu lớn nhất $a_j - a_i$ với $i \lt j$). Bài 1 có thể xem là bài con của bài 2: làm được bài 1 thì sẽ có cơ sở để làm bài 2, vì dạng của nó cũng tương đồng, chỉ là làm rối lên một tí ^^. Hãy đảm bảo là bạn đã cố gắng hết sức mà vẫn không được, hoặc đã tự làm được rồi, thì mới xem tiếp bài giải dưới đây nhé.
Trước khi vào bài, các bạn nhớ giúp mình chuyện giới hạn thời gian: với các hệ thống chấm bài giới hạn 1 giây, toàn bài chỉ nên lặp cỡ $(3 \div 5) \cdot 10^7$ lần là an toàn, tệ nhất $10^8$ lần (lúc này hên xui). Ngưỡng này là cho C/C++; Python, Java, C# chạy chậm hơn nên ngưỡng phải thấp hơn. Đề cho 2 giây thì nhân ngưỡng lên 2, 100 ms thì chia 10, cứ thế mà tính.
Giải thích ví dụ cho bạn nào chưa hiểu đề: dãy là 8 2 4 -5 9 1 -3. Ta chọn 3 vị trí $i \lt j \lt k$, hai số ở trước được cộng, số đứng sau cùng bị trừ. Chọn $a_1 = 8$, $a_5 = 9$ và $a_7 = -3$ ta được $8 + 9 - (-3) = 20$, và không có bộ ba nào cho kết quả lớn hơn ⇒ đáp án $20$.
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 1Vét cạn mọi bộ ba (i, j, k) – O(N³)
Cách làm cơ bản nhất, dễ nhìn ra nhất: xét hết tất cả các bộ ba $(i, j, k)$ thoả $i \lt j \lt k$, tính $a_i + a_j - a_k$ rồi liên tục so sánh để sau cùng tìm ra kết quả lớn nhất trong tất cả các bộ ba hợp lệ – đó chính là đáp án.
Source code để các bạn tham khảo tư tưởng xử lý ở trên:
#include <bits/stdc++.h>
using namespace std;
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int n;
cin >> n;
if(n < 3){
cout << 0;
}
else{
vector<long long> a(n);
for(int i = 0; i < n; ++i){
cin >> a[i];
}
long long Max = LLONG_MIN;
for(int i = 0; i < n; ++i){
for(int j = i + 1; j < n; ++j){
for(int k = j + 1; k < n; ++k){
Max = max(Max, a[i] + a[j] - a[k]);
}
}
}
cout << Max;
}
return 0;
}
import sys
data = sys.stdin.buffer.read().split()
n = int(data[0])
if n < 3:
print(0)
else:
a = list(map(int, data[1:n + 1]))
Max = None
for i in range(n):
for j in range(i + 1, n):
for k in range(j + 1, n):
v = a[i] + a[j] - a[k]
if Max is None or v > Max:
Max = v
print(Max)
Giải thích một số chỗ trong code:
- 3 dòng đầu hàm
main(ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);) giúp đẩy nhanh tốc độ nhập xuất khi code C++. Bài này input có thể lên tới 1 triệu số, số lượng lớn nên các bạn nhớ thêm 3 dòng này vào đầu hàmmainnhé. - Đề không cho giới hạn giá trị các số trong mảng nên để chắc ăn mình chọn kiểu
long long(số nguyên 8 byte, lưu được tới hơn 9 tỷ tỷ), yên tâm không sợ tràn số. Nếu theo thói quen dùngint(chỉ tới hơn 2 tỷ) thì lỡ có giá trị vượt quá là chương trình bị sai. - Mảng có thể lên tới 1 triệu số nên mình dùng
vectorthay vì mảng tĩnh. Kinh nghiệm: mảng tĩnh khai báo trong hàm chỉ nên chứa tối đa cỡ $3 \cdot 10^5$ phần tử, vượt ngưỡng này nên dùng mảng động để tránh tràn bộ nhớ. - Biến
Maxkhởi tạo bằngLLONG_MIN(giá trị nhỏ nhất củalong long) để giá trị đầu tiên đem so sánh chắc chắn lớn hơn nó mà cập nhật lạiMax. Max = max(Max, a[i] + a[j] - a[k]);là cách viết gọn củaif(a[i] + a[j] - a[k] > Max) Max = a[i] + a[j] - a[k];. Hàmmax(a, b)có sẵn trong C++, tương tự có hàmmin.- Khi $n \lt 3$ thì không có bộ ba nào, code in ra
0(giữ đúng như code gốc của mình).
Đá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: $O(N)$ đọc mảng + $O(N^3)$ cho 3 vòng lặp lồng nhau. Theo quy tắc cộng Big-O lấy thành phần lớn nhất ⇒ $O(N^3)$.
Cách 2Quy hoạch động: giữ 2 số lớn nhất phía trước – O(N)
Cách giải trọn vẹn nhất chỉ cần độ phức tạp thời gian $O(N)$, dùng phương pháp Quy hoạch động (Dynamic Programming) – bài này thuộc loại quy hoạch động cơ bản. Mình sẽ cố tiếp cận theo kiểu tư duy thông thường để bạn nào chưa biết quy hoạch động cũng lờ mờ hiểu được, còn bạn nào biết rồi thì đọc vào là hiểu ngay.
Vì sao "Dãy số 1" là bài con của "Dãy số 2"?
Ở bài 1 ta chỉ làm việc trên 2 số $a_j - a_i$ để tìm hiệu lớn nhất. Bài này làm trên 3 số $a_i + a_j - a_k$. Nhưng nếu ta nhóm lại thành $(a_i + a_j) - a_k$ thì nó giống y như bài 1: cụm $(a_i + a_j)$ ở bài 2 đóng vai trò "số đứng trước", còn $a_k$ là "số đứng sau bị trừ". Bản chất vẫn quy về dạng tìm cặp giống bài 1 thôi.
Thêm một phần tử vào cuối thì có gì mới?
Vì $i \lt j \lt k$ nên mảng phải có tối thiểu 3 phần tử. Mảng có 3 phần tử a b c thì chỉ có 1 bộ ba duy nhất a b c, kết quả $a + b - c$. Khi mảng có thêm phần tử thứ 4 thành a b c d, các bộ ba là:
d đều có d đứng cuối, tức là d luôn bị trừ đi; hai số được cộng chọn tuỳ ý trong các số đứng trước.Điểm chung: phần tử mới d luôn đứng cuối – theo lẽ dĩ nhiên là vậy – nên nó chính là $a_k$ bị trừ đi, còn 2 số được cộng nằm trong các phần tử phía trước. Để biểu thức lớn nhất thì quy tắc toán học đơn giản: hai số được cộng nên là 2 số lớn nhất trong các phần tử đứng trước, vì 2 số lớn nhất cộng lại là tổng lớn nhất có thể. Việc trừ $a_k$ là bắt buộc, nhưng ta đảm bảo biểu thức lớn nhất nhờ $a_i + a_j$ là tổng 2 số lớn nhất.
Max để cập nhật. Xét xong $k = N$ thì Max chính là đáp án. Chỉ duyệt mảng một lần ⇒ $O(N)$.Còn bạn nào đã biết quy hoạch động thì đây là 4 bước, y như những gì mình vừa nói nhưng tóm gọn lại:
- Bài toán con: gọi $F[i]$ là giá trị biểu thức lớn nhất thu được khi số bị trừ là $a_i$ (bộ ba kết thúc tại $i$).
- Bài toán cơ sở: $F[1], F[2]$ không tồn tại vì chưa đủ 3 phần tử thì không tính được biểu thức.
- Đáp án: giá trị lớn nhất của các $F[i]$ với $i$ từ $3$ tới $N$.
- Công thức truy hồi: $F[i] = \text{max1}(a_1, \ldots, a_{i-1}) + \text{max2}(a_1, \ldots, a_{i-1}) - a_i$, với max1 là giá trị lớn nhất, max2 là giá trị lớn nhì.
9 9 1 thì max1 = max2 = 9 là hoàn toàn hợp lệ).Minh hoạ cập nhật Max1, Max2
Em nhập dãy rồi bấm Tiếp để xem với mỗi vị trí $k$, hai số lớn nhất phía trước (xanh) được cộng và $a_k$ (đỏ) bị trừ như thế nào:
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(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int n;
cin >> n;
if(n < 3){
cout << 0;
}
else{
vector<long long> a(n + 1);
for(int i = 1; i <= n; ++i){
cin >> a[i];
}
long long Max1 = max(a[1], a[2]);
long long Max2 = min(a[1], a[2]);
long long Max = Max1 + Max2 - a[3];
for(int i = 4; i <= n; ++i){
if(a[i - 1] > Max1){
Max2 = Max1;
Max1 = a[i - 1];
}
else if(a[i - 1] > Max2){
Max2 = a[i - 1];
}
Max = max(Max, Max1 + Max2 - a[i]);
}
cout << Max;
}
return 0;
}
import sys
data = sys.stdin.buffer.read().split()
n = int(data[0])
if n < 3:
print(0)
else:
a = [0] + list(map(int, data[1:n + 1]))
Max1 = max(a[1], a[2])
Max2 = min(a[1], a[2])
Max = Max1 + Max2 - a[3]
for i in range(4, n + 1):
if a[i - 1] > Max1:
Max2 = Max1
Max1 = a[i - 1]
elif a[i - 1] > Max2:
Max2 = a[i - 1]
Max = max(Max, Max1 + Max2 - a[i])
print(Max)
Bấm Tiếp để chạy tay từng dòng code C++ (phần "Dữ liệu" vẽ mảng a, tô xanh hai phần tử đang giữ vai trò Max1, Max2, tô vàng a[i] đang bị trừ):
Giải thích một số chỗ trong code:
- Trong 4 bước quy hoạch động mình có gọi $F[i]$, nghe như cần thêm mảng $F$. Nhưng thực tế không cần: sau cùng ta chỉ cần giá trị lớn nhất của các $F[i]$, nên cứ vừa tính vừa so sánh cập nhật
Maxluôn, khỏi lưu từng $F[i]$ rồi duyệt lại. Max1lưu giá trị lớn nhất,Max2lưu giá trị lớn nhì trong các phần tử trước vị tríi(hai biến có thể bằng nhau nếu giá trị lớn nhất xuất hiện nhiều lần). Ban đầu chúng là max và min của 2 số đầu, cònMaxkhởi tạo bằng bộ ba đầu tiênMax1 + Max2 - a[3].- Vòng lặp bắt đầu từ
i = 4. Mỗi lượt ta đưaa[i - 1]vào xét trước (vdi = 4thì phải xét thêma[3], vì ban đầu mới xéta[1],a[2]). Nếua[i - 1] > Max1thì nó lên làm số lớn nhất – nhưng trước khi gán, ta chuyểnMax1hiện tại xuống choMax2, đểMax2vẫn là số lớn nhất theo sauMax1, không có giá trị nào chen giữa. Còn nếu chỉ lớn hơnMax2thì cập nhậtMax2 = a[i - 1]. - Cập nhật xong
Max1,Max2mới tínhMax1 + Max2 - a[i]và so vớiMax. Kết thúc vòng lặp,Maxlà giá trị biểu thức lớn nhất.
Đá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: $O(N)$ đọc mảng + $O(N)$ xử lý, tức $O(2N)$; bỏ hằng số ⇒ $O(N)$.
Cách 3Không cần mảng: đọc tới đâu xử lý tới đó – O(N), bộ nhớ O(1)
Đến đây thì đã được rồi, nhưng mình trình bày luôn một cách tối ưu hơn nữa: không cần bộ nhớ cho mảng $a$, độ phức tạp không gian chỉ còn $O(1)$ vì chỉ cần vài biến đơn lẻ. Thời gian vẫn $O(N)$ nhưng chỉ cần một vòng lặp duy nhất gộp cả quá trình đọc dữ liệu lẫn xử lý.
Để ý ở cách 2, tại mỗi bước ta chỉ đụng tới đúng 2 phần tử: a[i - 1] (để cập nhật Max1, Max2) và a[i] (để trừ). Vậy chỉ cần một biến c: lúc đầu vòng lặp c đang giữ số vừa đọc trước đó (chính là a[i - 1]) → dùng nó cập nhật Max1, Max2; sau đó cin >> c đọc số mới (chính là a[i]) → tính Max1 + Max2 - c. Ba số đầu đọc riêng vào a, b, c, nên vòng lặp còn chạy n - 3 lần.
#include <bits/stdc++.h>
using namespace std;
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int n;
cin >> n;
if(n < 3){
cout << 0;
}
else{
long long a, b, c;
cin >> a >> b >> c;
long long Max1 = max(a, b);
long long Max2 = min(a, b);
long long Max = Max1 + Max2 - c;
n -= 3;
while(n--){
if(c > Max1){
Max2 = Max1;
Max1 = c;
}
else if(c > Max2){
Max2 = c;
}
cin >> c;
Max = max(Max, Max1 + Max2 - c);
}
cout << Max;
}
return 0;
}
import sys
it = iter(sys.stdin.buffer.read().split())
n = int(next(it))
if n < 3:
print(0)
else:
a, b, c = int(next(it)), int(next(it)), int(next(it))
Max1 = max(a, b)
Max2 = min(a, b)
Max = Max1 + Max2 - c
for _ in range(n - 3):
if c > Max1:
Max2 = Max1
Max1 = c
elif c > Max2:
Max2 = c
c = int(next(it))
Max = max(Max, Max1 + Max2 - c)
print(Max)
Tư tưởng xử lý y như cách 2, chỉ là tối ưu lại không cần mảng và gộp chung đọc dữ liệu + xử lý. Hiểu được cách 2 rồi thì cách này mình tin sẽ không có gì khó hiểu cả 😉.
sys.stdin.buffer.read() cho nhanh, nên thực tế vẫn giữ danh sách các token trong bộ nhớ. Với $N = 10^6$, bản Python của cách 2 và cách 3 chạy khoảng 1.2–1.3 giây trên máy mình, có thể vượt giới hạn 1 giây tuỳ hệ thống chấm; nếu bị TLE thì các bạn nộp bằng C++ nhé.- 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