Bài 21.3: Tìm kiếm nhị phân binary_search trong C++

Bài 21.3: Tìm kiếm nhị phân binary_search trong C++
Chào mừng các bạn quay trở lại với series blog về C++! Hôm nay, chúng ta sẽ cùng nhau khám phá một trong những giải thuật tìm kiếm mạnh mẽ và hiệu quả nhất khi làm việc với dữ liệu đã được sắp xếp: Tìm kiếm nhị phân (Binary Search). C++ Standard Library (STL) cung cấp cho chúng ta một công cụ tiện lợi để thực hiện điều này: hàm binary_search
.
Tại sao cần Tìm kiếm nhị phân?
Trong thế giới lập trình, việc tìm kiếm một phần tử nào đó trong một tập hợp dữ liệu là một tác vụ cực kỳ phổ biến. Phương pháp đơn giản nhất là Tìm kiếm tuyến tính (Linear Search): đi qua từng phần tử một từ đầu đến cuối cho đến khi tìm thấy hoặc hết tập hợp. Phương pháp này luôn đúng, nhưng với tập dữ liệu lớn, nó có thể rất chậm.
Ví dụ, để tìm một số trong danh sách 1 triệu số chưa sắp xếp, trong trường hợp xấu nhất, bạn có thể phải kiểm tra cả 1 triệu số!
Tìm kiếm nhị phân ra đời để giải quyết vấn đề hiệu suất này, nhưng với một điều kiện tiên quyết rất quan trọng: dữ liệu phải được sắp xếp!
Tìm kiếm nhị phân hoạt động như thế nào?
Hãy tưởng tượng bạn đang tìm một từ trong một cuốn từ điển dày. Bạn không bắt đầu từ trang đầu tiên và đọc từng từ một đúng không? Chắc chắn là không! Bạn sẽ:
- Mở cuốn từ điển ra ở khoảng giữa.
- Xem từ ở trang đó.
- Nếu từ cần tìm nhỏ hơn từ đang xem, bạn biết nó phải nằm ở nửa đầu cuốn từ điển.
- Nếu từ cần tìm lớn hơn từ đang xem, bạn biết nó phải nằm ở nửa sau.
- Bạn loại bỏ nửa không chứa từ đó và lặp lại quá trình trên với nửa còn lại cho đến khi tìm thấy từ hoặc không còn trang nào để tìm.
Đây chính là ý tưởng cốt lõi của tìm kiếm nhị phân: chia để trị (divide and conquer). Ở mỗi bước, nó loại bỏ được một nửa số phần tử còn lại, giúp giảm đáng kể số bước tìm kiếm.
Về mặt hiệu suất, tìm kiếm nhị phân có độ phức tạp thời gian là O(log n), trong khi tìm kiếm tuyến tính là O(n). Với 1 triệu phần tử:
- Tìm kiếm tuyến tính: Tối đa 1.000.000 bước.
- Tìm kiếm nhị phân: log₂(1.000.000) ≈ 20 bước!
Sự khác biệt này trở nên khổng lồ khi dữ liệu ngày càng lớn.
Sử dụng binary_search
trong C++
C++ cung cấp hàm binary_search
trong thư viện <algorithm>
để thực hiện tìm kiếm nhị phân một cách tiện lợi.
binary_search
làm gì?
Hàm này kiểm tra xem một phạm vi (range) đã được sắp xếp có chứa một giá trị cụ thể hay không. Nó chỉ trả về true
(nếu tìm thấy) hoặc false
(nếu không tìm thấy). Nó không cho biết vị trí của phần tử (nếu có).
Cú pháp cơ bản
#include <algorithm> // Cần include thư viện này
#include <vector> // hoặc bất kỳ container nào bạn dùng
#include <iostream>
// ...
bool found = binary_search(first, last, value);
Trong đó:
first
,last
: Là các iterator định nghĩa phạm vi tìm kiếm (thường làcontainer.begin()
vàcontainer.end()
). Phạm vi này là[first, last)
, tức là bao gồmfirst
nhưng không bao gồmlast
.value
: Là giá trị bạn muốn tìm kiếm.
Điều quan trọng nhất cần nhớ: Phạm vi [first, last)
PHẢI được sắp xếp theo thứ tự tăng dần (mặc định sử dụng toán tử <
) hoặc theo một tiêu chí sắp xếp nhất định. Nếu không, kết quả trả về là không xác định (undefined behavior) hoặc sai.
Ví dụ 1: Tìm kiếm thành công trong vector
Hãy xem một ví dụ đơn giản với vector<int>
đã được sắp xếp.
#include <iostream>
#include <vector>
#include <algorithm> // For binary_search
int main() {
vector<int> sorted_numbers = {10, 20, 30, 40, 50, 60, 70, 80, 90, 100};
int value_to_find = 50;
// Sử dụng binary_search
bool found = binary_search(sorted_numbers.begin(), sorted_numbers.end(), value_to_find);
if (found) {
cout << "Gia tri " << value_to_find << " duoc tim thay trong vector." << endl;
} else {
cout << "Gia tri " << value_to_find << " khong duoc tim thay trong vector." << endl;
}
return 0;
}
- Giải thích code:
- Chúng ta khởi tạo một
vector
tên làsorted_numbers
với các số đã được sắp xếp. - Biến
value_to_find
chứa giá trị cần tìm (50). - Hàm
binary_search
được gọi với phạm vi từsorted_numbers.begin()
đếnsorted_numbers.end()
và giá trị 50. - Vì 50 có trong vector,
binary_search
trả vềtrue
, và chương trình in ra thông báo "Gia tri 50 duoc tim thay...".
- Chúng ta khởi tạo một
Ví dụ 2: Tìm kiếm giá trị không tồn tại
Bây giờ, hãy thử tìm một giá trị không có trong vector đó.
#include <iostream>
#include <vector>
#include <algorithm> // For binary_search
int main() {
vector<int> sorted_numbers = {10, 20, 30, 40, 50, 60, 70, 80, 90, 100};
int value_to_find = 55; // Giá trị này không có trong vector
// Sử dụng binary_search
bool found = binary_search(sorted_numbers.begin(), sorted_numbers.end(), value_to_find);
if (found) {
cout << "Gia tri " << value_to_find << " duoc tim thay trong vector." << endl;
} else {
cout << "Gia tri " << value_to_find << " khong duoc tim thay trong vector." << endl;
}
return 0;
}
- Giải thích code:
- Tương tự ví dụ trước, nhưng lần này chúng ta tìm giá trị 55.
- Vì 55 không tồn tại trong vector,
binary_search
sẽ thực hiện quá trình tìm kiếm nhị phân và kết luận rằng nó không có mặt. - Hàm trả về
false
, và chương trình in ra thông báo "Gia tri 55 khong duoc tim thay...".
Ví dụ 3: Với container khác (ví dụ array
)
binary_search
hoạt động trên mọi phạm vi được định nghĩa bởi iterator phù hợp, không chỉ vector
. Hãy thử với array
.
#include <iostream>
#include <array> // For array
#include <algorithm> // For binary_search
#include <string> // For string
int main() {
array<string, 5> sorted_words = {"apple", "banana", "cherry", "date", "elderberry"}; // Đã sắp xếp theo alphabet
string target_word = "cherry";
// Sử dụng binary_search
bool found = binary_search(sorted_words.begin(), sorted_words.end(), target_word);
if (found) {
cout << "Tu '" << target_word << "' duoc tim thay." << endl;
} else {
cout << "Tu '" << target_word << "' khong duoc tim thay." << endl;
}
return 0;
}
- Giải thích code:
- Chúng ta dùng
array
chứa các chuỗistring
đã sắp xếp theo thứ tự từ điển. - Tìm kiếm từ
"cherry"
. - Hàm
binary_search
hoạt động bình thường với các iterator củaarray
và kiểu dữ liệustring
, trả vềtrue
.
- Chúng ta dùng
Ví dụ 4: Điều gì xảy ra nếu dữ liệu KHÔNG SẮP XẾP?
Đây là lỗi phổ biến nhất khi sử dụng binary_search
. Nếu bạn gọi nó trên dữ liệu chưa được sắp xếp, kết quả sẽ không đáng tin cậy.
#include <iostream>
#include <vector>
#include <algorithm> // For binary_search, sort
int main() {
vector<int> unsorted_numbers = {50, 10, 80, 30, 60, 20, 100, 40, 90, 70}; // CHƯA SẮP XẾP!
int value_to_find = 20;
cout << "Tim kiem tren du lieu CHUA SAP XEP:" << endl;
bool found_unsorted = binary_search(unsorted_numbers.begin(), unsorted_numbers.end(), value_to_find);
if (found_unsorted) {
cout << "Gia tri " << value_to_find << " duoc tim thay (KET QUA CO THE SAI!)." << endl;
} else {
cout << "Gia tri " << value_to_find << " khong duoc tim thay (KET QUA CO THE SAI!)." << endl;
}
cout << "\nSap xep du lieu..." << endl;
sort(unsorted_numbers.begin(), unsorted_numbers.end()); // Sắp xếp vector
cout << "Tim kiem tren du lieu DA SAP XEP:" << endl;
bool found_sorted = binary_search(unsorted_numbers.begin(), unsorted_numbers.end(), value_to_find);
if (found_sorted) {
cout << "Gia tri " << value_to_find << " duoc tim thay (KET QUA CHINH XAC)." << endl;
} else {
cout << "Gia tri " << value_to_find << " khong duoc tim thay (KET QUA CHINH XAC)." << endl;
}
return 0;
}
- Giải thích code:
- Chúng ta tạo một vector
unsorted_numbers
chưa được sắp xếp. - Lần gọi
binary_search
đầu tiên trênunsorted_numbers
có thể cho kết quả sai. Nó có thể báo tìm thấy hoặc không tìm thấy 20, và kết quả đó không đáng tin cậy. - Sau đó, chúng ta sử dụng
sort
(cũng từ<algorithm>
) để sắp xếp vector. Bây giờ vector đã là `{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}}$. - Lần gọi
binary_search
thứ hai trên vector đã sắp xếp sẽ cho kết quả chính xác (tìm thấy 20). - Bài học quan trọng: Luôn đảm bảo phạm vi tìm kiếm được sắp xếp trước khi gọi
binary_search
!
- Chúng ta tạo một vector
Về hàm so sánh (Comparator)
Phiên bản mặc định của binary_search
sử dụng toán tử <
để so sánh các phần tử và giá trị tìm kiếm. Nếu bạn làm việc với các kiểu dữ liệu phức tạp hoặc cần một tiêu chí sắp xếp khác (ví dụ: sắp xếp giảm dần, sắp xếp theo một trường cụ thể của đối tượng), bạn có thể sử dụng phiên bản overload của binary_search
nhận thêm một hàm so sánh nhị phân (binary predicate). Hàm này nhận hai đối số và trả về true
nếu đối số thứ nhất được coi là "nhỏ hơn" đối số thứ hai theo tiêu chí của bạn.
// Ví dụ về hàm so sánh tùy chỉnh (không cần code đầy đủ, chỉ minh họa khái niệm)
bool compare_descending(int a, int b) {
return a > b; // Sắp xếp giảm dần
}
// ... trong main() hoặc một hàm khác
// vector<int> sorted_descending_numbers = {100, 90, ..., 10};
// int value_to_find = 50;
// bool found = binary_search(sorted_descending_numbers.begin(), sorted_descending_numbers.end(), value_to_find, compare_descending);
Lưu ý: Khi sử dụng hàm so sánh tùy chỉnh, phạm vi tìm kiếm của bạn cũng phải được sắp xếp dựa trên cùng một tiêu chí đó.
Bài tập ví dụ: C++ Bài 10.B1: Tổng bằng X
Cho một dãy số nguyên đã được sắp xếp không giảm \(a\) có \(n\) phần tử. Hãy tìm hai vị trí khác nhau bất kỳ sao cho tổng của hai phần tử ở hai vị trí đó có giá trị là \(x\).
INPUT FORMAT
Dòng đầu tiên chứa số nguyên \(n\) và \(x\) (\( 1 \leq n \leq 10^6\)).
Dòng thứ hai chứa \(n\) số nguyên, mỗi số các nhau một dấu cách \((1 \leq a_i, x < 10^{9})\).
OUTPUT FORMAT
In ra hai số nguyên là vị trí tìm được, nếu không có đáp án in ra No solution
.
Ví dụ:
Input
7 16
2 5 6 8 10 12 15
Output
3 5
Giải thích ví dụ mẫu
- Ví dụ 1:
- Dữ liệu:
a = [2, 5, 6, 8, 10, 12, 15]
,x = 16
- Giải thích: Tìm hai chỉ số sao cho tổng hai phần tử tại các chỉ số đó bằng 16. Vị trí
3
(6) và5
(10) là một cặp hợp lệ vì6 + 10 = 16
.
- Dữ liệu:
<br>
Lời giải bài tập này: Tại đây
Group giải đáp thắc mắc: Lập trình 24h
Fanpage CLB: CLB lập trình Full House- Việt Nam
Youtube: CLB Lập Trình Full House
Tuyệt vời, đây là bài tập kinh điển sử dụng kỹ thuật hai con trỏ trên mảng đã sắp xếp. Yêu cầu là tìm hai vị trí khác nhau có tổng bằng x
.
Hướng dẫn giải bài này bằng C++ (không cung cấp code hoàn chỉnh):
Đọc Input:
- Đọc hai số nguyên
n
vàx
. - Đọc
n
số nguyên vào một containerstd
phù hợp, ví dụ nhưvector
. Mảng đã được đảm bảo sắp xếp không giảm.
- Đọc hai số nguyên
Xác định Kỹ thuật:
- Vì mảng đã được sắp xếp, kỹ thuật "hai con trỏ" (two-pointer technique) là lựa chọn tối ưu nhất về mặt thời gian. Kỹ thuật này có độ phức tạp O(n).
Áp dụng Kỹ thuật Hai Con Trỏ:
- Khởi tạo hai con trỏ (hoặc chỉ số): một con trỏ
left
ở vị trí đầu tiên của mảng (chỉ số 0), và một con trỏright
ở vị trí cuối cùng của mảng (chỉ sốn-1
). - Sử dụng một vòng lặp
while
chạy chừng nào con trỏleft
còn nhỏ hơn con trỏright
(left < right
). Điều kiệnleft < right
đảm bảo chúng ta luôn xét hai vị trí khác nhau. - Bên trong vòng lặp:
- Tính tổng của hai phần tử tại vị trí
left
vàright
:current_sum = a[left] + a[right]
. - So sánh
current_sum
vớix
:- Nếu
current_sum == x
: Chúng ta đã tìm thấy một cặp có tổng bằngx
. Đây chính là đáp án. In ra vị trí của chúng (nhớ là chỉ số trong đề bài là 1-based, nên cần inleft + 1
vàright + 1
). Sau đó, dừng chương trình. - Nếu
current_sum < x
: Tổng hiện tại quá nhỏ. Để tăng tổng, chúng ta cần một số lớn hơn. Vì mảng đã sắp xếp, cách để tăng tổng là di chuyển con trỏleft
sang phải (left++
) để lấy một phần tử lớn hơn ở bên trái. - Nếu
current_sum > x
: Tổng hiện tại quá lớn. Để giảm tổng, chúng ta cần một số nhỏ hơn. Cách để giảm tổng là di chuyển con trỏright
sang trái (right--
) để lấy một phần tử nhỏ hơn ở bên phải.
- Nếu
- Tính tổng của hai phần tử tại vị trí
- Khởi tạo hai con trỏ (hoặc chỉ số): một con trỏ
Xử lý Trường hợp Không Có Đáp án:
- Nếu vòng lặp
while (left < right)
kết thúc (tức làleft >= right
) mà chúng ta vẫn chưa tìm thấy cặp nào có tổng bằngx
, điều đó có nghĩa là không có cặp nào như vậy tồn tại trong mảng. - Sau khi vòng lặp kết thúc, nếu chưa tìm được đáp án, in ra "No solution".
- Nếu vòng lặp
Lưu ý về Tối ưu I/O (không bắt buộc nhưng khuyến khích cho bài lớn):
- Với
n
lên đến 10^6, việc đọc/ghi dữ liệu có thể tốn thời gian. Có thể sử dụngios_base::sync_with_stdio(false); cin.tie(NULL);
ở đầu hàmmain
để tăng tốc độ đọc/ghi quacin
vàcout
.
- Với
Kiểu dữ liệu:
- Các phần tử
a_i
vàx
có thể lên tới gần 10^9. Tổng của hai phần tử có thể lên tới gần 2 10^9. Kiểu dữ liệuint
chuẩn 32-bit thường có phạm vi đủ lớn (khoảng -2 10^9 đến +2 * 10^9). Tuy nhiên, để an toàn hơn, đặc biệt khi tính tổng, có thể xem xét sử dụnglong long
cho biến lưu tổngcurrent_sum
, mặc dùint
có thể đủ trong trường hợp này dựa trên ràng buộc đề bài1 <= a_i, x < 10^9
.
- Các phần tử
Comments