Bài 23.4: Bài tập thực hành tìm kiếm nâng cao trong C++

Bài 23.4: Bài tập thực hành tìm kiếm nâng cao trong C++
Chào mừng quay trở lại với chuỗi bài blog về C++! Sau khi đã nắm vững các khái niệm cơ bản và các cấu trúc dữ liệu quen thuộc, chúng ta sẽ cùng nhau đi sâu vào một chủ đề cực kỳ quan trọng trong lập trình: Tìm kiếm.
Tìm kiếm là một thao tác cơ bản và thiết yếu. Tuy nhiên, không phải lúc nào việc tìm kiếm cũng chỉ đơn giản là duyệt qua từng phần tử từ đầu đến cuối. Đối với các tập dữ liệu lớn hoặc khi cần tìm kiếm theo các tiêu chí phức tạp, chúng ta cần đến các kỹ thuật và thuật toán tìm kiếm nâng cao. C++ Standard Library (STL) cung cấp một bộ công cụ mạnh mẽ giúp chúng ta thực hiện điều này một cách hiệu quả và ngắn gọn.
Trong bài thực hành này, chúng ta sẽ khám phá và sử dụng một số thuật toán tìm kiếm nâng cao phổ biến nhất có sẵn trong STL, tập trung vào thư viện <algorithm>
.
1. Nhắc Lại find
- Tìm Kiếm Tuyến Tính Cơ Bản
Trước khi đi xa hơn, hãy nhắc lại về hàm find
. Đây là thuật toán tìm kiếm tuyến tính (linear search) cơ bản nhất. Nó duyệt qua từng phần tử trong một phạm vi (range
) từ đầu đến cuối cho đến khi tìm thấy phần tử khớp hoặc duyệt hết phạm vi.
Đặc điểm:
- Không yêu cầu phạm vi phải được sắp xếp.
- Hiệu quả với các tập dữ liệu nhỏ hoặc khi phần tử cần tìm có khả năng nằm ở đầu.
- Độ phức tạp thời gian: O(n) trong trường hợp xấu nhất (n là số phần tử).
Ví dụ:
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
int main() {
vector<int> numbers = {10, 5, 20, 15, 25};
int value_to_find = 15;
// Sử dụng find để tìm kiếm
auto it = find(numbers.begin(), numbers.end(), value_to_find);
// Kiểm tra kết quả
if (it != numbers.end()) {
cout << "Tim thay " << value_to_find << " tai vi tri index: " << distance(numbers.begin(), it) << endl;
} else {
cout << value_to_find << " khong co trong vector." << endl;
}
vector<string> words = {"apple", "banana", "cherry"};
string word_to_find = "banana";
auto it_word = find(words.begin(), words.end(), word_to_find);
if (it_word != words.end()) {
cout << "Tim thay '" << word_to_find << "' trong vector string." << endl;
} else {
cout << "'" << word_to_find << "' khong co trong vector string." << endl;
}
return 0;
}
Giải thích:
find
nhận vào hai iterator chỉ định phạm vi tìm kiếm (numbers.begin()
,numbers.end()
) và giá trị cần tìm (value_to_find
).- Nó trả về một iterator trỏ đến phần tử đầu tiên tìm thấy khớp với giá trị, hoặc
numbers.end()
nếu không tìm thấy. - Chúng ta so sánh iterator trả về với
numbers.end()
để xác định xem phần tử có tồn tại hay không. distance(numbers.begin(), it)
giúp tính toán chỉ mục (index) của phần tử tìm thấy.
find
rất đơn giản và linh hoạt (vì không yêu cầu sắp xếp), nhưng với tập dữ liệu lớn và việc tìm kiếm diễn ra thường xuyên, hiệu suất của nó có thể trở thành vấn đề.
2. binary_search
- Sức Mạnh Của Dữ Liệu Đã Sắp Xếp
Khi dữ liệu của bạn đã được sắp xếp (sorted), bạn có thể tận dụng thuật toán tìm kiếm nhị phân (binary search), một trong những thuật toán tìm kiếm hiệu quả nhất. binary_search
trong C++ STL cho phép chúng ta làm điều này.
Đặc điểm:
- Bắt buộc: Phạm vi tìm kiếm phải được sắp xếp theo thứ tự không giảm (non-decreasing).
- Chỉ kiểm tra sự tồn tại của phần tử (trả về
bool
). Nó không trả về vị trí của phần tử. - Độ phức tạp thời gian: O(log n) - cực kỳ nhanh với dữ liệu lớn so với O(n).
Ví dụ:
#include <iostream>
#include <vector>
#include <algorithm> // Bao gồm sort và binary_search
int main() {
vector<int> sorted_numbers = {5, 10, 15, 20, 25}; // Dữ liệu đã được sắp xếp
int value_to_find_exist = 15;
int value_to_find_not_exist = 12;
// Sử dụng binary_search
bool found_exist = binary_search(sorted_numbers.begin(), sorted_numbers.end(), value_to_find_exist);
bool found_not_exist = binary_search(sorted_numbers.begin(), sorted_numbers.end(), value_to_find_not_exist);
if (found_exist) {
cout << "Tim thay " << value_to_find_exist << " trong vector (su dung binary_search)." << endl;
} else {
cout << value_to_find_exist << " khong co trong vector (su dung binary_search)." << endl;
}
if (found_not_exist) {
cout << "Tim thay " << value_to_find_not_exist << " trong vector (su dung binary_search)." << endl;
} else {
cout << value_to_find_not_exist << " khong co trong vector (su dung binary_search)." << endl;
}
// Ví dụ với dữ liệu chưa sắp xếp (cần sắp xếp trước)
vector<int> unsorted_numbers = {30, 10, 50, 20, 40};
sort(unsorted_numbers.begin(), unsorted_numbers.end()); // Sắp xếp!
int value_to_find_after_sort = 40;
bool found_after_sort = binary_search(unsorted_numbers.begin(), unsorted_numbers.end(), value_to_find_after_sort);
if (found_after_sort) {
cout << "Tim thay " << value_to_find_after_sort << " trong vector sau khi sap xep (su dung binary_search)." << endl;
}
return 0;
}
Giải thích:
binary_search
cũng nhận vào phạm vi và giá trị cần tìm.- Điểm khác biệt quan trọng là nó yêu cầu phạm vi phải được sắp xếp. Nếu không, kết quả sẽ không chính xác.
- Nó chỉ trả về
true
nếu phần tử tồn tại trong phạm vi, vàfalse
nếu không. Nó không cho biết vị trí. - Trước khi sử dụng
binary_search
trên dữ liệu chưa sắp xếp, bạn phải sắp xếp nó trước bằngsort
. Chi phí sắp xếp ban đầu (thường là O(n log n)) có thể được bù đắp nhanh chóng nếu bạn thực hiện nhiều thao tác tìm kiếm sau đó (mỗi lần tìm kiếm chỉ mất O(log n)).
3. lower_bound
và upper_bound
- Tìm Kiếm Vị Trí Cận Dưới và Cận Trên
Đôi khi, bạn không chỉ muốn biết liệu một giá trị có tồn tại hay không, mà còn muốn biết vị trí mà nó (hoặc một giá trị lớn hơn nó) nên nằm ở đâu. lower_bound
và upper_bound
là các công cụ hoàn hảo cho việc này, đặc biệt hữu ích khi làm việc với dữ liệu đã sắp xếp hoặc khi cần tìm kiếm một phạm vi các phần tử có cùng giá trị.
Cả hai hàm này đều yêu cầu phạm vi đầu vào phải được sắp xếp.
lower_bound(first, last, val)
: Trả về một iterator trỏ đến phần tử đầu tiên trong phạm vi[first, last)
không nhỏ hơnval
. Nếu tất cả phần tử đều nhỏ hơnval
, nó trả vềlast
.upper_bound(first, last, val)
: Trả về một iterator trỏ đến phần tử đầu tiên trong phạm vi[first, last)
lớn hơnval
. Nếu tất cả phần tử đều nhỏ hơn hoặc bằngval
, nó trả vềlast
.
Ứng dụng phổ biến:
- Tìm vị trí để chèn một phần tử mới mà vẫn giữ được tính sắp xếp.
- Đếm số lần xuất hiện của một giá trị cụ thể.
Ví dụ:
#include <iostream>
#include <vector>
#include <algorithm> // Bao gồm lower_bound, upper_bound
#include <iterator> // Bao gồm distance
int main() {
vector<int> sorted_numbers = {10, 20, 20, 30, 40, 50}; // Dữ liệu đã được sắp xếp
// Sử dụng lower_bound
int value_lower = 20;
auto lower_it = lower_bound(sorted_numbers.begin(), sorted_numbers.end(), value_lower);
cout << "lower_bound cho gia tri " << value_lower << " tro den vi tri index: "
<< distance(sorted_numbers.begin(), lower_it) << endl;
// Output: 1 (phần tử đầu tiên >= 20 là sorted_numbers[1])
int value_lower_not_exist = 25;
auto lower_it_not_exist = lower_bound(sorted_numbers.begin(), sorted_numbers.end(), value_lower_not_exist);
cout << "lower_bound cho gia tri " << value_lower_not_exist << " tro den vi tri index: "
<< distance(sorted_numbers.begin(), lower_it_not_exist) << endl;
// Output: 3 (phần tử đầu tiên >= 25 là sorted_numbers[3] = 30)
// Sử dụng upper_bound
int value_upper = 20;
auto upper_it = upper_bound(sorted_numbers.begin(), sorted_numbers.end(), value_upper);
cout << "upper_bound cho gia tri " << value_upper << " tro den vi tri index: "
<< distance(sorted_numbers.begin(), upper_it) << endl;
// Output: 3 (phần tử đầu tiên > 20 là sorted_numbers[3] = 30)
int value_upper_not_exist = 55; // Giá trị lớn hơn tất cả
auto upper_it_not_exist = upper_bound(sorted_numbers.begin(), sorted_numbers.end(), value_upper_not_exist);
cout << "upper_bound cho gia tri " << value_upper_not_exist << " tro den vi tri index: "
<< distance(sorted_numbers.begin(), upper_it_not_exist) << endl;
// Output: 6 (tro đến end())
// Ứng dụng: Đếm số lần xuất hiện của giá trị 20
auto count = distance(lower_it, upper_it);
cout << "So lan xuat hien cua gia tri 20: " << count << endl; // Output: 2
return 0;
}
Giải thích:
lower_bound
vàupper_bound
đều hoạt động trên phạm vi đã sắp xếp.lower_bound
tìm điểm bắt đầu của nhóm các phần tử có giá trịval
.upper_bound
tìm điểm kết thúc của nhóm các phần tử có giá trịval
(iterator trỏ đến phần tử ngay sau phần tử cuối cùng có giá trịval
).- Khoảng iterator
[lower_it, upper_it)
chính là phạm vi chứa tất cả các phần tử có giá trị bằngval
. - Chúng ta có thể tính số phần tử trong khoảng này bằng
distance(lower_it, upper_it)
.
4. equal_range
- Tìm Kiếm Toàn Bộ Phạm Vi Bằng Nhau
Hàm equal_range
là sự kết hợp tiện lợi của lower_bound
và upper_bound
. Nó thực hiện cả hai phép tìm kiếm và trả về một pair
chứa hai iterator: iterator trả về từ lower_bound
và iterator trả về từ upper_bound
.
Đặc điểm:
- Bắt buộc: Phạm vi tìm kiếm phải được sắp xếp.
- Trả về một
pair<Iterator, Iterator>
chỉ định phạm vi[lower_bound_iterator, upper_bound_iterator)
chứa tất cả các phần tử bằng với giá trị cần tìm. - Độ phức tạp thời gian: O(log n).
Ví dụ:
#include <iostream>
#include <vector>
#include <algorithm> // Bao gồm equal_range
#include <iterator> // Bao gồm distance
#include <utility> // Bao gồm pair
int main() {
vector<int> sorted_numbers = {10, 20, 20, 30, 40, 50}; // Dữ liệu đã được sắp xếp
int value_to_find = 20;
// Sử dụng equal_range
auto range = equal_range(sorted_numbers.begin(), sorted_numbers.end(), value_to_find);
// range.first là iterator từ lower_bound
// range.second là iterator từ upper_bound
cout << "Phan tu dau tien >= " << value_to_find << " tai index: "
<< distance(sorted_numbers.begin(), range.first) << endl;
cout << "Phan tu dau tien > " << value_to_find << " tai index: "
<< distance(sorted_numbers.begin(), range.second) << endl;
// Đếm số lần xuất hiện
auto count = distance(range.first, range.second);
cout << "So lan xuat hien cua gia tri " << value_to_find << ": " << count << endl;
// Kiểm tra xem giá trị có tồn tại không (bằng cách kiểm tra xem phạm vi có rỗng không)
if (range.first != range.second) {
cout << value_to_find << " ton tai trong vector." << endl;
} else {
cout << value_to_find << " khong ton tai trong vector (hoac khong co phan tu nao bang)." << endl;
}
return 0;
}
Giải thích:
equal_range
trả về một cặp iterator định nghĩa phạm vi của các phần tử bằng giá trị tìm kiếm.range.first
tương đương với kết quả củalower_bound
.range.second
tương đương với kết quả củaupper_bound
.- Sự khác biệt giữa hai iterator này (
distance(range.first, range.second)
) cho ta biết có bao nhiêu phần tử bằng với giá trị cần tìm. - Nếu
range.first == range.second
, điều đó có nghĩa là không có phần tử nào bằng với giá trị tìm kiếm trong phạm vi.
5. find_if
- Tìm Kiếm Với Điều Kiện Tùy Chỉnh
Đôi khi, bạn cần tìm kiếm một phần tử không phải dựa trên giá trị cụ thể, mà dựa trên một điều kiện nhất định. Ví dụ, tìm số chẵn đầu tiên, tìm chuỗi rỗng đầu tiên, hoặc tìm đối tượng đầu tiên thỏa mãn một thuộc tính nào đó. find_if
được thiết kế để giải quyết vấn đề này một cách linh hoạt.
Đặc điểm:
- Không yêu cầu phạm vi phải được sắp xếp.
- Tìm kiếm phần tử đầu tiên mà một predicate (hàm hoặc lambda trả về
bool
) trả vềtrue
cho phần tử đó. - Độ phức tạp thời gian: O(n) trong trường hợp xấu nhất.
Ví dụ:
#include <iostream>
#include <vector>
#include <algorithm> // Bao gồm find_if
#include <string>
#include <iterator> // Bao gồm distance
// Một predicate (hàm kiểm tra điều kiện)
bool isEven(int n) {
return n % 2 == 0;
}
int main() {
vector<int> numbers = {1, 3, 5, 8, 9, 10, 12};
// Tìm số chẵn đầu tiên sử dụng hàm
auto it_even = find_if(numbers.begin(), numbers.end(), isEven);
if (it_even != numbers.end()) {
cout << "So chan dau tien la: " << *it_even
<< " tai vi tri index: " << distance(numbers.begin(), it_even) << endl;
} else {
cout << "Khong tim thay so chan nao." << endl;
}
vector<string> words = {"cat", "dog", "elephant", "fox"};
// Tìm chuỗi có độ dài lớn hơn 5 sử dụng lambda expression
auto it_long_word = find_if(words.begin(), words.end(),
[](const string& s){
return s.length() > 5;
});
if (it_long_word != words.end()) {
cout << "Chuoi dau tien co do dai > 5 la: '" << *it_long_word << "'"
<< " tai vi tri index: " << distance(words.begin(), it_long_word) << endl;
} else {
cout << "Khong tim thay chuoi nao co do dai > 5." << endl;
}
return 0;
}
Giải thích:
find_if
nhận phạm vi và một predicate. Predicate này là một đối tượng có thể được gọi (hàm, function object, lambda expression) nhận một tham số (là một phần tử trong phạm vi) và trả vềbool
.- Nếu predicate trả về
true
cho một phần tử,find_if
sẽ dừng lại và trả về iterator trỏ đến phần tử đó. - Nếu predicate trả về
false
cho tất cả các phần tử trong phạm vi, nó trả về iteratorend()
. - Ví dụ trên minh họa việc sử dụng cả hàm thường (
isEven
) và lambda expression (để kiểm tra độ dài chuỗi) làm predicate. Lambda expression thường được ưa chuộng vì tính gọn nhẹ khi predicate đơn giản và chỉ dùng một lần.
Comments