Tìm 3 Phần Tử Có Tổng Bằng Key
Đề bài
Tìm 3 phần tử có tổng bằng key
Cho mảng arr, tìm trong mảng arr có tồn tại 3 phần tử arr[i] + arr[j] + arr[k] = key, với key là giá trị cho trước. In giá trị arr[i], arr[j] và arr[k] ra màn hình.
- Thiết kế giải thuật với ĐPT (độ phức tạp) về thời gian tùy ý.
- Thiết kế giải thuật với ĐPT về thời gian là $O(n^2)$.
Đề gốc là bài thiết kế giải thuật (viết hàm), không có định dạng vào/ra. Để chạy thử và kiểm tra, mình quy ước: dòng đầu gồm $n$ và $key$; dòng thứ hai gồm $n$ số nguyên của mảng ($i, j, k$ là 3 vị trí khác nhau). Nếu tìm được thì in 3 giá trị trên một dòng (có nhiều bộ thì in bộ nào cũng được), không có thì in NO.
6 24
12 3 4 1 6 93 9 12Lời mở đầu
Nay mình đi comment dạo thấy bài này thú vị nè: một bạn hỏi cách làm tối ưu cho câu b. Đây là bài kinh điển (hay gọi là bài 3SUM), rất hợp để anh em luyện cách đánh giá độ phức tạp và làm quen kỹ thuật hai con trỏ 😇.
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ụ: mảng $[12, 3, 4, 1, 6, 9]$ và $key = 24$. Ta có $12 + 3 + 9 = 24$ nên in ra 3 giá trị đó. Cách 1 duyệt theo thứ tự trong mảng nên in 12 3 9, còn cách 2 sắp xếp mảng trước nên in 3 9 12 – cả hai đều đúng.
Lộ trình lời giải, bấm vào từng phần để nhảy tới:
Cách 1Vét cạn tất cả bộ 3 – O(N³)
Ở câu a đề cho thiết kế với độ phức tạp tuỳ ý, miễn giải quyết được vấn đề, thì cứ theo cách đơn giản nhất thôi: vét cạn xét tất cả các bộ 3 vị trí $i \lt j \lt k$. Gặp bộ 3 nào thoả mãn thì in ra và return true ngay, tức là kết thúc hàm tại đó luôn, khỏi mất công xét tiếp. Đến sau cùng không có bộ nào thoả thì return false.
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;
bool Check1(int *a, int n, int key){
for(int i = 0; i < n; ++i){
for(int j = i + 1; j < n; ++j){
for(int k = j + 1; k < n; ++k){
if((long long)a[i] + a[j] + a[k] == key){
cout << a[i] << " " << a[j] << " " << a[k] << endl;
return true;
}
}
}
}
return false;
}
int main(){
int n, key;
cin >> n >> key;
int *a = new int[n];
for(int i = 0; i < n; ++i){
cin >> a[i];
}
if(!Check1(a, n, key)){
cout << "NO" << endl;
}
delete[] a;
return 0;
}
def check1(a, n, key):
for i in range(n):
for j in range(i + 1, n):
for k in range(j + 1, n):
if a[i] + a[j] + a[k] == key:
print(a[i], a[j], a[k])
return True
return False
data = list(map(int, open(0).read().split()))
n, key = data[0], data[1]
a = data[2:2 + n]
if not check1(a, n, key):
print("NO")
Đánh giá độ phức tạp của cách làm này:
- Thời gian: quá đơn giản để nhìn ra, 3 vòng lặp lồng nhau, trường hợp xấu nhất mỗi vòng chạy đủ $N$ lần ⇒ $O(N^3)$ với $N$ là số phần tử của mảng.
- Không gian: $O(N)$ do xử lý trên mảng có $N$ phần tử.
i < n - 2, j < n - 1, k < n. Để vậy cũng ok, vẫn đúng và về lý thuyết vẫn là $O(N^3)$. Tuy nhiên mình khuyên cứ để i < n, j < n, k < n cho gọn mà tính kỹ ra còn tốt hơn: khi để i < n - 2 thì tuy không phải lặp dư 2 lần, nhưng đổi lại cứ mỗi lần so sánh điều kiện vòng lặp là lại phải làm thêm phép tính n - 2. Giả sử $n$ là 1 triệu thì phải làm thêm gần 1 triệu phép trừ, trong khi để i < n thì chỉ lặp dư 2 lần chẳng làm gì mà không phải liên tục tính phép trừ ^^. Tương tự với vòng lặp j nhé! (Thực tế trình biên dịch hiện đại thường tự tối ưu chỗ này, nhưng thói quen viết gọn vẫn tốt.)Cách 2Sắp xếp + hai con trỏ – O(N²)
Ở câu b đề ràng buộc độ phức tạp phải là $O(N^2)$ ⇒ không làm 3 vòng lặp lồng nhau được nữa mà phải tối ưu hơn. $N^2$ thì anh em hiểu là tối đa 2 vòng lặp lồng nhau, mỗi vòng chạy tối đa $N$ lần. Để làm được, ta kết hợp kỹ thuật sort (sắp xếp) và two pointers (hai con trỏ):
- Sắp xếp mảng tăng dần. Dùng thuật tối ưu $O(N \log N)$ thì tốt, còn dùng thuật tệ nhất $O(N^2)$ (như Bubble Sort) thì vẫn đúng yêu cầu đề nhé.
- Duyệt
ilần lượt từng phần tử bắt đầu từ thằng nhỏ nhất. Với mỗia[i], ta tìm 2 thằng còn lại bằng hai con trỏ:left = i + 1(ngay saui) vàright = n - 1(cuối dãy). - Trong khi
left < right: tínhsum = a[i] + a[left] + a[right].sum > key⇒ tổng đang dư, giảmrightxuống để lấy số nhỏ hơn.sum < key⇒ tổng đang thiếu, tăngleftlên để lấy số lớn hơn.sum == key⇒ tìm thấy, in ra vàreturn true.
- Khi
left >= rightmà chưa tìm được thì vớia[i]hiện tại không có cặp nào hợp lệ ⇒ xétitiếp theo. Hết mọiivẫn không có ⇒return false.
sum > key thì ghép a[right] với bất kỳ phần tử nào từ left trở đi cũng chỉ càng lớn hơn ⇒ a[right] không thể nằm trong đáp án, bỏ nó đi (right--). Ngược lại nếu sum < key thì a[left] ghép với bất kỳ phần tử nào đến right cũng chỉ càng nhỏ hơn ⇒ bỏ a[left] (left++). Nếu mảng sắp giảm dần thì làm ngược lại: sum > key thì tăng left, sum < key thì giảm right. Chỗ này anh em suy nghĩ một tí là hiểu hen.a[i] không âm mà đã > key thì dừng luôn, vì càng về sau các số càng lớn, không thể có bộ 3 nào có tổng bằng key nữa (vì sao phải có điều kiện "không âm" thì xem hộp bên dưới).if(a[i] > key) return false; – điều này chỉ đúng khi mảng không có số âm. Nếu có số âm thì sai, ví dụ mảng $[-3, -1, -1]$ với $key = -5$: $a[0] = -3 \gt -5$ nên dừng và trả về false, trong khi $-3 + (-1) + (-1) = -5$. Vì vậy mình sửa điều kiện thành a[i] >= 0 && a[i] > key: khi a[i] >= 0 thì các số phía sau cũng $\ge a[i] \ge 0$, tổng 3 số $\ge a[i] \gt key$ ⇒ dừng mới chắc chắn đúng. Ngoài ra mình tính tổng bằng long long để không bị tràn khi các phần tử lớn cỡ $10^9$ (cách 1 cũng vậy).Minh hoạ hai con trỏ
Em nhập mảng và $key$ rồi bấm Tiếp để xem: mảng được sắp xếp, rồi với mỗi i hai con trỏ L, R tiến lại gần nhau:
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;
bool Check2(int *a, int n, int key){
sort(a, a + n);
for(int i = 0; i < n; ++i){
if(a[i] >= 0 && a[i] > key){
return false;
}
int left = i + 1;
int right = n - 1;
while(left < right){
long long sum = (long long)a[i] + a[left] + a[right];
if(sum > key){
right--;
}
else if(sum < key){
left++;
}
else{
cout << a[i] << " " << a[left] << " " << a[right] << endl;
return true;
}
}
}
return false;
}
int main(){
int n, key;
cin >> n >> key;
int *a = new int[n];
for(int i = 0; i < n; ++i){
cin >> a[i];
}
if(!Check2(a, n, key)){
cout << "NO" << endl;
}
delete[] a;
return 0;
}
import sys
def check2(a, n, key):
a.sort()
for i in range(n):
if a[i] >= 0 and a[i] > key:
return False
left = i + 1
right = n - 1
while left < right:
s = a[i] + a[left] + a[right]
if s > key:
right -= 1
elif s < key:
left += 1
else:
print(a[i], a[left], a[right])
return True
return False
data = list(map(int, sys.stdin.read().split()))
n, key = data[0], data[1]
a = data[2:2 + n]
if not check2(a, n, key):
print("NO")
Đánh giá độ phức tạp của cách làm này:
- Thời gian: bước sắp xếp mình dùng hàm
sorttrong thư việnalgorithmnên là $O(N \log N)$ (tự code Bubble Sort $O(N^2)$ thì vẫn thoả đề). Phần hai con trỏ: trường hợp xấu nhất (không tìm thấy) vòngfor ichạy hết $N$ lần, mỗi lần vòngwhilexét hết các phần tử phía saui(mỗi bướclefttăng hoặcrightgiảm nên không quá $N$ bước) ⇒ $O(N^2)$. Tổng $O(N \log N + N^2)$, theo quy tắc cộng Big-O lấy thằng lớn nhất ⇒ $O(N^2)$, đúng yêu cầu đề. - Không gian: $O(N)$ do xử lý trên mảng có $N$ phần tử.
map với 2 vòng lặp được không – được nha: cố định i, duyệt j phía sau và tra xem đã gặp giá trị key - a[i] - a[j] trong đoạn giữa i và j chưa (dùng set/unordered_set làm mới cho mỗi i). Với unordered_set thì trung bình $O(N^2)$, còn map/set thì thành $O(N^2 \log N)$ và tốn thêm bộ nhớ. Cách hai con trỏ ở trên gọn và nhanh hơn.NO thì đúng là không có bộ 3 nào, còn in 3 số thì đúng là lấy từ mảng và có tổng bằng key – khớp hết. Với $n = 5000$ và không có đáp án: cách 2 bản C++ chạy khoảng 0.03 giây, bản Python khoảng 1.7 giây; cách 1 bản C++ mất khoảng 15–18 giây ⇒ thấy rõ khác biệt giữa $O(N^3)$ và $O(N^2)$.- 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