Nguyễn Nhật Minh Khôi - VNU University of Science. Biên soạn lại từ bản dịch của Vũ Thị Thiên Anh
Reviewer: Hoàng Xuân Nhật, Trần Quang Lộc
Tìm kiếm nhị phân (hay còn gọi là chặt nhị phân) là một trong số các thuật toán cơ bản của khoa học máy tính. Trong bài viết này, chúng ta sẽ xây dựng một nền tảng lý thuyết, sau đó đưa ra cách cài đặt thuật toán này một cách chuẩn xác.
¶ Bài toán mở đầu: Tìm giá trị cho trước trong một dãy đã sắp xếp
Mở đầu, ta sẽ đến với bài toán sử dụng tìm kiếm nhị phân đơn giản nhất. Đề bài như sau:
Cho trước một dãy được sắp tăng dần, trả về vị trí của phần tử có giá trị trong . Lưu ý: để đơn giản, ta giả sử các phần tử trong mảng có giá trị phân biệt.
Đầu tiên, ta sẽ xét một ví dụ để thấy được tư tưởng của thuật toán.
Cho và , thuật toán sẽ diễn ra như hình dưới:
Ở lượt tìm đầu tiên, không gian tìm kiếm là tập hợp gồm tất cả các chỉ số của mảng. Bắt đầu với việc chọn phần tử trung vị của không gian tìm kiếm hiện tại (chính là ), ta nhận xét . Do theo đề bài mảng được sắp xếp tăng dần, ta biết được tất cả các phần tử có chỉ số đều nhỏ hơn giá trị cần tìm . Do đó, chúng chắc chắn không thể là kết quả, khi đó không gian tìm kiếm có thể thu hẹp lại , tức giảm đi một nửa.
Tương tự, ở lượt tìm thứ hai, ta xét phần tử trung vị của không gian tìm kiếm hiện tại (chính là ), nhận thấy . Cũng do mảng được sắp xếp tăng dần, ta biết được tất cả các phần tử có vị trí từ đều lớn hơn giá trị cần tìm . Do đó, chúng chắc chắn không thể là kết quả, khi đó không gian tìm kiếm sẽ lại giảm đi một nửa .
Ở lượt tìm cuối cùng, ta cũng xét phần tử trung vị của không gian tìm kiếm hiện tại ở vị trí (ở đây số lượng phần tử của không gian tìm kiếm là chẵn, do đó có hai phần tử trung vị, ta có thể chọn một trong hai đều được, ở ví dụ này ta chọn phần tử trung vị đầu tiên), Nhận thấy , ta kết luận chính là vị trí của phần tử cần tìm và dừng thuật toán.
Từ ví dụ trên, ta có thể dễ dàng hiểu được ý tưởng của thuật toán tìm kiếm nhị phân. Đúng như tên gọi, thuật toán sẽ liên tục chia không gian tìm kiếm thành hai nửa và loại một nửa đi. Thuật toán có thể trình bày như sau:
Ta duy trì một không gian tìm kiếm là một dãy con các giá trị có thể là kết quả (ở đây là chỉ số các phần tử trong ). Ban đầu, không gian tìm kiếm là toàn bộ các chỉ số của mảng với là chỉ số phần tử cuối cùng của .
Ở mỗi bước, thuật toán so sánh giá trị cần tìm với phần tử có chỉ số là trung vị trong không gian tìm kiếm. Dựa trên sự so sánh đó, cộng thêm việc ta biết dãy có thứ tự, ta có thể loại một nửa số phần tử của . Lặp đi lặp lại quá trình này, cuối cùng ta sẽ được một không gian tìm kiếm bao gồm một phần tử duy nhất.
Khi đó, nếu phần tử duy nhất đó bằng với giá trị cần tìm thì đó là nghiệm của bài toán, nếu không thì bài toán vô nghiệm.
Ở đây có hai lưu ý khi cài đặt thuật toán:
Do không gian tìm kiếm luôn là một đoạn liên tục các giá trị nguyên, ta không cần lưu tất cả phần tử của không gian khi tìm kiếm mà chỉ cần duy trì hai biến low và high tượng trưng cho phần tử đầu và cuối của đoạn.
Ta có thể tối ưu thuật toán hơn bằng việc dừng sớm nếu trong quá trình so sánh gặp một phần tử trung vị thỏa yêu cầu đề bài chứ không cần đợi đến khi không gian tìm kiếm chỉ còn một phần tử.
Dưới đây là code minh họa viết bằng ngôn ngữ C++. Trong trường hợp giá trị cần tìm không tồn tại trong khoảng tìm kiếm thì thuật toán sẽ trả về
int binary_search(int A[], int sizeA, int target) {
int lo = 1, hi = sizeA;
while (lo <= hi) {
int mid = lo + (hi - lo)/2;
if (A[mid] == target)
return mid;
else if (A[mid] < target)
lo = mid+1;
else
hi = mid-1;
}
// không tìm thấy giá trị target trong mảng A
return -1;
}
Ở mỗi bước, kích thước không gian tìm kiếm bị giảm đi một nửa. Ta dễ thấy rằng độ phức tạp của thuật toán là với là số phần tử ban đầu của không gian tìm kiếm.
Hàm là một hàm tăng rất chậm. Ví dụ như nếu phải tìm kiếm giá trị trong 1 triệu phần tử, với tìm kiếm nhị phân chỉ cần tối đa là 21 bước.
C++ Standard Template Library đã cài đặt sẵn tìm kiếm nhị phân bằng các hàm lower_bound, upper_bound, binary_search, equal_range, bạn đọc có thể tham khảo tùy thuộc vào mục đích sử dụng.
Ở phần trước, ta đã xét dạng đơn giản nhất của tìm kiếm nhị phân. Trong phần này, chúng ta sẽ tổng quát hóa thuật tìm kiếm nhị phân cho một lớp bài toán rộng hơn. Ta sẽ thấy tìm kiếm nhị phân có thể mở rộng để áp dụng cho bất kỳ loại hàm số đơn điệu nào nhận tham số đầu vào là số nguyên. Nói một cách đơn giản, một hàm số đơn điệu là một hàm tăng hoặc một hàm giảm. Trong ví dụ đầu bài, hiển nhiên mảng sắp xếp tăng có thể xem như một "hàm tăng".
¶ Cơ sở lý thuyết: Định lý chính của tìm kiếm nhị phân
Khi gặp một bài toán mà ta đoán được có thể dùng tìm kiếm nhị phân để giải, thì ta phải chứng minh tính đúng đắn suy luận của chúng ta. Do đó, xây dựng một cơ sở lý thuyết vững chắc là vô cùng cần thiết. Sau đây, tôi sẽ trình bày một lớp tổng quát hóa nữa cho các bài toán có thể áp dụng tìm kiếm nhị phân, song song đó là ví dụ thực tế với bài toán mở đầu.
Cho không gian tìm kiếm bao gồm các ứng cử viên cho kết quả của bài toán. Ta định nghĩa môt hàm kiểm tra là hàm nhận một ứng cử viên và trả về giá trị cho biết có hợp lệ hay không (tùy vào bài toán mà định nghĩa hợp lệ sẽ khác nhau). Hiểu đơn giản, hàm là hàm "kiểm tra" một tính chất nào đó, xem một ứng cử viên cho kết quả của bài toán có thỏa tính chất đó không.
Với ví dụ ở đầu bài, thay vì tìm chỉ số của phần tử có giá trị , ta có thể viết lại đề bài thành "tìm chỉ số nhỏ nhất sao cho phần tử ở chỉ số đó lớn hơn hoặc bằng ". Khi đó, không gian tìm kiếm ban đầu (ban đầu mọi chỉ số của mảng đều có thể là kết quả) và trả về nếu và nếu .
Định lý chính (Main Theorem) cho biết rằng: một bài toán chỉ có thể áp dụng tìm kiếm nhị phân nếu và chỉ nếu hàm kiểm tra của bài toán thỏa mãn
Lưu ý rằng tính chất trên của hàm kiểm tra cũng tương đương với tính chất sau:
Sự tương đương ở đây có thể chứng minh bằng phương pháp phản chứng, để tránh bài viết quá dài dòng, phần chứng minh để lại cho bạn đọc.
Định lý chính mang cho chúng ta một thông tin rất quan trọng, đó là điều kiện cần và đủ để một bài toán có thể giải bằng tìm kiếm nhị phân. Để hiểu được tại sao, chúng ta hãy phân tích kĩ hơn ý nghĩa tính chất của hàm mà định lý yêu cầu
Tính chất có thể giải thích như sau: nếu hợp lệ thì mọi phần tử đều hợp lệ. Tính chất này giúp chúng ta loại đi nửa sau của không gian tìm kiếm do đã biết chắc là phần tử nhỏ nhất trong nửa sau hợp lệ, ta ghi nhận là kết quả tạm thời và tiếp tục tìm xem có phần tử nào ở nửa đầu (nhỏ hơn ) hợp lệ hay không.
Tương tự, tính chất có thể giải thích như sau: nếu không hợp lệ thì mọi phần tử đều không hợp lệ. Tính chất này giúp chúng ta loại đi nửa trước của không gian tìm kiếm do đã biết chắc chúng không hợp lệ, ta chỉ quan tâm những phần tử ở nửa sau (lớn hơn ) mà ta chưa biết thông tin chúng có hợp lệ hay không.
Nếu ta tính giá trị cho từng phần tử trong ban đầu, ta sẽ được một dãy liên tiếp các giá trị liền kề một dãy liên tiếp các giá trị (từ nay gọi là dãy ). Dễ thấy ta có thể áp dụng tìm kiếm nhị phân trên dãy mới này để tìm giá trị nhỏ nhất thỏa mãn (hoặc cũng có thể làm cách tìm giá trị lớn nhất mà , tuy nhiên ở đây ta không chọn cách này).
Với ví dụ đầu bài, như đã nói . Dễ thấy thỏa mãn tính chất đầu tiên, do được sắp tăng dần nên nếu thì chắc chắn các phần tử sau đều thỏa . Tương tự ta cũng suy ra được, nếu thì chắc chắn các phần tử trước đều thỏa .
Áp dụng hàm cho từng phần tử của ta có hình sau
Chú ý rằng ta thấy có thể dễ dàng xây dựng định lý chính dựa trên một hàm kiểm tra ngược lại, tức sẽ là một dãy liên tiếp theo sau bởi liên tiếp. Tuy nhiên, ở đây ta sẽ chỉ xét một trường hợp để bài viết ngắn gọn hơn, trường hợp còn lại có thể làm tương tự.
Từ định lý trên, ta rút ra được mấu chốt để giải một bài toán dùng tìm kiếm nhị phân là ta cần thiết kế được hàm hợp lý sao cho thỏa mãn điều kiện trong định lý chính.
Cuối cùng, tại sao ta phải tốn công tổng quát hóa thuật toán này thay vì dùng cách làm đơn giản ở ví dụ đầu? Đó là vì nhiều bài toán không thể ở dưới dạng tìm kiếm một giá trị cụ thể, nhưng ta lại có thể định nghĩa một hàm kiểm tra thỏa yêu cầu định lý chính để có thể áp dụng tìm kiếm nhị phân. Bằng cách đó, ta có thể mở rộng lớp bài toán có thể giải bằng tìm kiếm nhị phân.
Ví dụ điển hình cho việc áp dụng định lý là với bài toán Tìm căn bậc hai, thay vì hỏi "Số nào bình phương lên thì bằng ?" và tìm kiếm tuần tự tất cả các trường hợp, ta có thể định nghĩa hàm trả lời cho câu hỏi " có lớn hơn hoặc bằng hay không?" sau đó dùng tìm kiếm nhị phân để tìm nhỏ nhất thỏa mãn. Với cách làm này ta có thể đơn giản hóa bài toán thành một bài toán yes/no, hơn thế còn giảm độ phức tạp của bài toán từ xuống chỉ còn .
Trước khi cài đặt thuật toán, ta phải trả lời những câu hỏi sau:
Dãy của bạn có dạng ( liên tiếp rồi liên tiếp) hay (ngược lại)? Ở cài đặt phía dưới sẽ mặc định dãy có dạng , vì vậy nếu dãy của bạn có dạng , hãy đảo hàm của bạn ngược lại.
Bài của bạn có luôn có nghiệm không? Nếu không hãy kiểm tra trước khi tìm kiếm nhị phân để tiết kiệm chi phí tính toán.
Mục tiêu của bạn là tìm phần tử lớn nhất hay tìm phần tử nhỏ nhất? Ở đây sẽ trình bày cả hai cách.
Nếu bài toán có nghiệm, hãy đảm bảo giá trị chặn dưới và chặn trên của khoảng tìm kiếm (biến lo và hi) là bắt đầu và kết thúc của một khoảng đóng mà chắc chắn chứa kết quả cần tìm (phần tử đầu tiên mà ). Hãy đảm bảo điều kiện này trong lúc thu hẹp không gian tìm kiếm để tránh xảy ra lỗi.
Phạm vi tìm kiếm đã đủ rộng chưa? Sẽ có nhiều lúc chấm xong bạn nhận ra là mình thiếu vài trường hợp biên. Vì thời gian chạy chỉ tăng theo hàm logarit , bạn hoàn toàn có thể nâng rộng khoảng tìm kiếm ra mà ít khi lo bị quá thời gian. Tuy nhiên, phải để ý các lỗi như tràn mảng, tràn số,...
Luôn kiểm tra trường hợp . Để hiểu lý do tại sao hãy đọc Trường hợp 2 của cài đặt.
TH1: tìm nhỏ nhất mà . Dưới đây là đoạn code mẫu viết bằng C++.
bool P(int x) {
// Logic của hàm P ở đây
return true; // thay giá trị này bằng giá trị đúng logic.
}
int binary_search(int lo, int hi) {
while (lo < hi) {
int mid = lo + (hi-lo)/2;
if (P(mid) == true)
hi = mid;
else
lo = mid+1;
}
if (P(lo) == false)
return -1; // P(x) = false với mọi x thuộc S, bài toán vô nghiệm.
return lo; // lo là giá trị x nhỏ nhất mà P(x) = true
}
Hai dòng quan trọng là và , chúng giúp ta thu hẹp không gian tìm kiếm dần.
Khi , ta có thể bỏ nửa sau của không gian tìm kiếm vì đã biết phần tử trong đó luôn hợp lệ. Tuy nhiên ta vẫn phải giữ trong không gian tìm kiếm mới vì nó có thể là phần tử đầu tiên mà . Do đó không gian tìm kiếm mới sẽ là , ta gán .
Tương tự, khi , ta có thể bỏ nửa đầu (bao gồm cả phần tử ) vì tất cả các phần tử này đều không hợp lệ. Lúc này không gian tìm kiếm mới sẽ là , ta gán .
TH2: tìm lớn nhất mà , suy luận tương tự như trên, ta có đoạn code như sau:
bool P(int x) {
// Logic của hàm P ở đây
return true; // thay giá trị này bằng giá trị đúng logic.
}
int binary_search(int lo, int hi) {
while (lo < hi) {
int mid = lo + (hi-lo+1)/2;
if (P(mid) == true)
hi = mid - 1;
else
lo = mid;
}
if (P(lo) == true)
return -1; // P(x) = true với mọi x thuộc S, bài toán vô nghiệm.
return lo; // lo là giá trị x lớn nhất mà P(x) = false
}
Bạn sẽ thắc mắc rằng tại sao cách tính lại có một tí khác biệt so với thuật toán đầu tiên. Để hiểu được tại sao ta phải làm thế, ta sẽ xét trường hợp sau: trong quá trình tìm kiếm, nếu tại một thời diểm nào đó mà dãy tạo ra bởi các phần tử của không gian tìm kiếm có dạng như sau
false
true
Nếu ta tính , đoạn code sẽ lặp vô hạn. Nó sẽ luôn chọn phần tử trung vị là , nhưng cận dưới sẽ không di chuyển vì nó muốn giữ lại phần tử có thỏa yêu cầu tìm kiếm đó. Do đó, ta thay đổi công thức tính thành , làm như vậy sẽ khiến cận dưới sẽ được làm tròn lên thay vì làm tròn xuống, khi đó nó có thể loại bỏ phần tử trước khi xét phần tử . Có nhiều cách làm khác để thực hiện điều này, tuy nhiên, đây là cách dễ hiểu nhất. Do vậy, hãy luôn luôn kiểm tra thử trường hợp .
Một điều có thể bạn đang thắc mắc nữa là tại sao để tìm trung vị ta tính chứ không phải . Sở dĩ phải làm như vậy là để tránh khả năng xảy ra lỗi làm tròn số nguyên, ta muốn phép chia được làm tròn xuống, về gần với cận dưới, tuy nhiên phép chia làm tròn khác khi có số âm, nên nếu là số âm thì kết quả sẽ bị làm tròn lên. Code như trong mẫu kia giúp quá trình tính toán đều được làm tròn đúng theo logic. Đối với các bài toán mà chỉ cần xử lý giá trị dương thì lỗi này không xảy ra.
Cài đặt với đoạn đóng như trên có ưu điểm là dễ hiểu. Tuy nhiên, quay lại một chút với cơ sở lý thuyết: với dãy có dạng , thực tế ta nên chọn giá trị và mà và để đảm bảo luôn tìm được nghiệm. Do đó sẽ không ổn nếu như dãy của ta đều toàn giá trị (tức vô nghiệm). Trong trường hợp này, cài đặt với đoạn đóng có thêm đoạn kiểm tra để return - 1.
Giải pháp cho trường hợp này chính là cài đặt với nửa khoảng. Để ý rằng trong trường hợp vô nghiệm, cách cài đặt với khoảng đóng sẽ trả về cận trên của đoạn tìm kiếm ban đầu (do chỉ có cận dưới liên tục dịch chuyển lên và chạm tới ). Hơn nữa, thuật toán của ta không bao giờ gọi vì để có , ta đã phải có , nhưng lúc đó thuật toán chắc chắn đã dừng do điều kiện while (lo < hi) trong cài đặt.
Điều này cho ta một ý tưởng: tạo một giá trị ảo và mặc định coi . Ta sẽ tìm kiếm nhị phân trên đoạn mới và nếu bài toán vô nghiệm thì kết quả trả về sẽ là , ta không cần phải kiểm tra để return -1. Sở dĩ gọi là cài đặt với nửa khoảng vì khi truyền tham số cho hàm là và , thực ra ta chỉ muốn kết quả trong nửa khoảng , còn chỉ là giá trị ảo cho biết trường hợp vô nghiệm.
Cài đặt cũng tương tự với đoạn đóng, ngoại trừ việc ta loại bỏ đi đoạn code kiểm tra vô nghiệm:
bool P(int x) {
// Logic của hàm P ở đây
return true; // thay giá trị này bằng giá trị đúng logic.
}
// nhớ rằng ta phải truyền hi lớn hơn một đơn vị
// so với đoạn tìm kiếm thực sự
int binary_search(int lo, int hi) {
while (lo < hi) {
int mid = lo + (hi-lo)/2;
if (P(mid) == true)
hi = mid;
else
lo = mid+1;
}
return lo; // lo là giá trị x nhỏ nhất mà P(x) = true
}
Cách cài đặt này còn có một ưu điểm khác, đó là trong C++ và rất nhiều ngôn ngữ lập trình khác thì mảng bắt đầu từ , vì vậy nếu cần tìm một phần tử nào đó trong mảng thì với cài đặt bằng nửa khoảng tham số truyền vào sẽ rất đẹp, đó là binary_search(0, N) với là số phần tử của mảng. Toàn bộ thư viện STL, lower_bound, upper_bound đều nhận nửa khoảng, và thực tế nguyên lý của các hàm đó cũng như trên: không tìm ra đáp án thì trả về iterator end.
Lưu ý: Với trường hợp ta muốn tìm vị trí phần tử cuối cùng thì nửa khoảng cần tìm sẽ là (lo, hi] và hàm sẽ tự động trả về lo nếu mọi giá trị trong khoảng là .
Đến đây ta sẽ áp dụng những điều vừa học vào một bài cụ thể Leetcode 1011.
Trong bài này, một băng chuyền phải vận chuyển các gói hàng theo thứ tự cho trước trong ngày. Gói hàng thứ có trọng lượng . Biết mỗi ngày băng chuyền chỉ có thể vận chuyển tổng khối lượng tối đa là . Đề bài yêu cầu tìm nhỏ nhất để băng chuyền có thể hoàn thành nhiệm vụ được giao.
Để ý thấy rằng, với một số , ta có thể tính toán được số ngày để chuyển hết các gói hàng sao cho mỗi ngày tổng khối lượng vận chuyển không quá . Ban đầu, giao gói hàng để ngày chuyển. Nếu vận chuyển gói hàng trong ngày không làm tổng khối lượng trong ngày vượt , thì ta sẽ chuyển luôn gói đó trong ngày . Nếu không, ta sẽ chuyển gói đó trong ngày . Làm lần lượt như thế tới khi mọi gói hàng đều được chuyển, ta sẽ có được số ngày tối thiểu cần để chuyển hết gói hàng với tổng khối lượng mỗi ngày không quá .
Quay lại đề bài, ta thấy rằng thực ra vấn đề của bài toán là tìm số nhỏ nhất sao cho số ngày tối thiểu để chuyển hết gói hàng không quá ngày. Như vậy, ta có thể dùng tìm kiếm nhị phân với hàm kiểm tra được xây dựng bằng thuật toán tham lam được trình bày ở trên để kiểm tra các giá trị . Để ý tính chất đơn điệu ở đây thể hiện ở chỗ nếu tăng lên thì số lượng ngày cần dùng chỉ có thể giữ nguyên hoặc giảm đi nên hàm thỏa định lý chính và có thể áp dụng tìm kiếm nhị phân.
Sau đây là đoạn code mẫu bằng C++:
// hàm kiểm tra P
bool check(int capacity, const vector<int>& weights, int days) {
int current_weight = 0; --days;
for(int i = 0; i < weights.size(); ++i) {
if (current_weight + weights[i] <= capacity)
current_weight += weights[i];
else {
--days;
current_weight = weights[i];
}
}
return days >= 0;
}
// hàm tìm kiếm nhị phân
int shipWithinDays(const vector<int>& weights, int days) {
int lo = 0, hi = 0;
for (int i = 0; i < weights.size(); ++i) {
lo = max(lo, weights[i]);
hi += weights[i];
}
while (lo < hi) {
int mid = lo + (hi - lo)/2;
if (check(mid, weights, days))
hi = mid;
else
lo = mid + 1;
}
return lo;
}
Hàm tìm kiếm nhị phân chính là hàm shipWithinDays và hàm kiểm tra là hàm check.
Có một lưu ý về việc chọn cận dưới và cận trên. Cận trên có thể là bất cứ số nguyên nào đủ lớn, ở đây chọn là tổng của tất cả các gói hàng (cho trường hợp tệ nhất cần vận chuyển trong đúng một ngày). Tuy nhiên cận dưới phải bằng ít nhất là khối lượng của gói hàng nặng nhất để tránh trường hợp gói hàng quá lớn để chuyển trong một ngày.
Để kiểm tra thuật toán không bị lặp vô hạn với trường hợp , ta thử một test như sau: và thấy thuật toán hoạt động tốt.
Độ phức tạp thuật toán là với là kích thước của không gian tìm kiếm và là số lượng gói hàng, do đó thuật toán chạy rất nhanh.
Tìm kiếm nhị phân cũng có thể được áp dụng khi không gian tìm kiếm là một đoạn số thực. Thường thì việc xử lý sẽ đơn giản hơn với số nguyên do ta không phải bận tâm về việc dịch chuyển cận:
bool P(double x) {
// Logic của hàm P ở đây
return true; // thay giá trị này bằng giá trị đúng logic.
}
bool isTerminated(double lo, double hi) {
// trả về kết quả của việc kiểm tra
// lo và hi có thỏa điều kiện dừng chưa
}
double binary_search(double lo, double hi) {
while (isTerminated(lo, hi) == false) {
double mid = lo + (hi-lo)/2;
if (P(mid) == true)
hi = mid;
else
lo = mid;
}
// trung bình cộng lo và hi xấp xỉ
// ranh giới giữa false và true
return lo + (hi-lo)/2;
}
Ta thường không tìm được giá trị mục tiêu một cách chính xác mà chỉ có thể xấp xỉ kết quả, đó là lý do có hàm điều kiện dừng isTerminated. Thông thường có 2 cách quyết định khi nào dừng vòng lặp:
Dừng sau một số vòng lặp cố định (fixed): thông thường khi làm bài tập trên TopCoder, chỉ cần lặp khoảng 100 lần là đủ (nhiều khi là thừa) để đạt được độ chính xác mong muốn cho những bài dạng thế này.
Sai số tuyệt đối (absolute error): dừng khi ( thường rất nhỏ, khoảng ). Cách này được sử dụng nếu thời gian chặt và bạn phải tiết kiệm số lần lặp.