Trong khoa học máy tính, tìm kiếm nhị phân (tiếng Anh: binary search), còn gọi là tìm kiếm nửa khoảng (half-interval search),[1]tìm kiếm logarit (logarithmic search),[2] hay chặt nhị phân (binary chop),[3] là một thuật toán tìm kiếm xác định vị trí của một giá trị cần tìm trong một mảng đã được sắp xếp.[4][5] Thuật toán tiến hành so sánh giá trị cần tìm với phần tử đứng giữa mảng. Nếu hai giá trị không bằng nhau, phần nửa mảng không chứa giá trị cần tìm sẽ bị bỏ qua và tiếp tục tìm kiếm trên nửa còn lại, một lần nữa lấy phần tử ở giữa và so sánh với giá trị cần tìm, cứ thế lặp lại cho đến khi tìm thấy giá trị đó. Nếu phép tìm kiếm kết thúc khi nửa còn lại trống thì giá trị cần tìm không có trong mảng.
Tìm kiếm nhị phân chạy theo thời gian logarit trong trường hợp tệ nhất, thực hiện bước so sánh, trong đó là số phần tử trong mảng, là kí hiệu O lớn, và là phép toán logarit.[6] Thuật toán tìm kiếm nhị phân nhanh hơn so với tìm kiếm tuyến tính, ngoại trừ các trường hợp mảng có kích thước nhỏ. Tuy nhiên, mảng phải được sắp xếp trước khi áp dụng tìm kiếm nhị phân. Một số cấu trúc dữ liệu chuyên dụng được thiết kế cho việc tìm kiếm nhanh, ví dụ như bảng băm, có thể thực hiện tìm hiệu quả hơn tìm kiếm nhị phân. Tuy nhiên, thuật toán này có thể dùng để giải quyết nhiều loại vấn đề hơn, như tìm phần tử nhỏ nhất hoặc lớn nhất tiếp theo trong mảng so với giá trị cho trước, kể cả khi nó không có trong mảng.
Có nhiều biến thể của phép tìm kiếm nhị phân. Cụ thể, phương pháp "đổ xuống một phần" (fractional cascading) giúp tăng tốc tìm kiếm nhị phân với cùng một giá trị trong nhiều mảng. Phương pháp này cho phép giải quyết hiệu quả một số vấn đề về tìm kiếm trong hình học tính toán và nhiều lĩnh vực khác. Thuật toán tìm kiếm mũ (exponential search) mở rộng tìm kiếm nhị phân sang các danh sách không có giới hạn. Các cấu trúc dữ liệu cây tìm kiếm nhị phân và B-cây được xây dựng dựa trên tìm kiếm nhị phân.
Thuật toán
Thuật toán tìm kiếm nhị phân hoạt động trên các mảng đã được sắp xếp. Thuật toán bắt đầu bằng việc so sánh một phần tử đứng chính giữa mảng với giá trị cần tìm. Nếu bằng nhau, vị trí của nó trong mảng sẽ được trả về. Nếu giá trị cần tìm nhỏ hơn phần tử này, quá trình tìm kiếm tiếp tục ở nửa nhỏ hơn của mảng. Nếu giá trị cần tìm lớn hơn phần tử ở giữa, quá trình tìm kiếm tiếp tục ở nửa lớn hơn của mảng. Bằng cách này, ở mỗi phép lặp thuật toán có thể loại bỏ nửa mảng mà giá trị cần tìm chắc chắn không xuất hiện.[7]
Quy trình
Cho một mảng có phần tử với các giá trị hoặc bản ghiđã được sắp xếp sao cho , và giá trị cần tìm , chương trình con sau đây sử dụng tìm kiếm nhị phân để tìm chỉ số của trong .[7]
Gán với giá trị và với giá trị .
Nếu , quá trình tìm kiếm thất bại.
Gán (vị trí của phần tử đứng giữa) với giá trị floor của , tức là số nguyên lớn nhất nhỏ hơn hoặc bằng .
Nếu , gán với và quay lại bước 2.
Nếu , gán với và quay lại bước 2.
Khi , quá trình tìm kiếm hoàn tất; trả về .
Quy trình lặp này dùng hai biến và để lưu giới hạn tìm kiếm. Quy trình này có thể diễn giải bằng mã giả như dưới đây, trong đó tên các biến và kiểu giữ nguyên so với như trên, floor là hàm floor, và không_thành_công là một giá trị cụ thể thông báo tìm kiếm thất bại.[8]
function binary_search(A, n, T) is
L:= 0
R:= n − 1
while L ≤ R do
m:= floor((L + R) / 2)
if A[m] < T then
L:= m + 1
else if A[m] > T then
R:= m - 1
else:
return m
return không_thành_công
Ngoài ra, thuật toán có thể lấy giá trị ceiling của . Điều này có thể thay đổi kết quả nếu giá trị cần tìm xuất hiện nhiều hơn một lần trong mảng.
Cách làm khác
Trong quy trình trên, thuật toán kiểm tra phần tử ở giữa () có bằng giá trị cần tìm () không ở mỗi phép lặp. Một số cách làm bỏ qua bước này ở mỗi phép lặp. Khi đó thuật toán chỉ kiểm tra điều này khi chỉ còn một phần tử còn lại (khi ). Nhờ đó mà vòng lặp so sánh được thực hiện nhanh hơn do mỗi phép lặp đã loại bỏ được một bước so sánh. Tuy nhiên, với cách làm này thì số phép lặp trung bình tăng lên.[9]
Gán (vị trí của phần tử đứng giữa) với giá trị ceiling của , tức là số nguyên nhỏ nhất lớn hơn hoặc bằng .
Nếu , gán với .
Trường hợp còn lại, ; gán với .
Khi , quá trình tìm kiếm hoàn tất. Nếu , trả về . Nếu không, tìm kiếm thất bại.
Với ceil là hàm ceiling, mã giả cho phiên bản này như sau:
function binary_search_alternative(A, n, T) is
L:= 0
R:= n − 1
while L != R do
m:= ceil((L + R) / 2)
if A[m] > T then
R:= m - 1
else:
L:= m
if A[L] = T thenreturn L
return không_thành_công
Phần tử lặp lại
Quy trình trên có thể trả về bất cứ chỉ số nào có giá trị phần tử bằng giá trị cần tìm, kể cả khi trong mảng có các phần tử xuất hiện lặp lại. Ví dụ, với mảng và giá trị cần tìm là , thuật toán có thể trả về một trong hai kết quả đúng là phần tử thứ 4 (chỉ số là 3) hoặc thứ 5 (chỉ số là 4). Quy trình chuẩn trong trường hợp này sẽ trả về phần tử thứ 4 (chỉ số 3). Thuật toán này không phải lúc nào cũng trả về vị trí đầu tiên xuất hiện giá trị cần tìm (ví dụ với mảng , kết quả trả về vẫn là phần tử thứ 4). Tuy nhiên, có một số trường hợp cần phải tìm phần tử nằm xa nhất về phía bên trái hoặc bên phải mang giá trị cần tìm khi giá trị này xuất hiện lặp lại trong mảng. Trong ví dụ trên, phần tử thứ 4 là phần tử đứng xa nhất về bên trái mang giá trị 4, trong khi phần tử thứ 5 là phần từ đứng xa nhất về bên phải mang giá trị 4. Cách làm thứ hai ở trên sẽ luôn trả về chỉ số của phần tử đứng xa nhất về bên phải nếu tồn tại các phần tử lặp lại.[10]
Quy trình tìm phần tử xa nhất về bên trái
Để tìm phần tử xa nhất về bên trái, ta có thể dùng quy trình sau:[11]
Gán với giá trị và với .
Khi ,
Gán (vị trí của phần tử đứng giữa) với giá trị floor của , tức là số nguyên lớn nhất nhỏ hơn hoặc bằng .
Nếu , gán với .
Trường hợp còn lại, ; gán với .
Trả về .
Nếu và thì là phần tử đứng xa nhất về bên trái có giá trị bằng . Kể cả khi không nằm trong mảng, là hạng của trong mảng, hay số phần tử trong mảng nhỏ hơn .
Với floor là hàm floor, mã giả cho phiên bản này như sau:
function binary_search_leftmost(A, n, T):
L:= 0
R:= n
while L < R:
m:= floor((L + R) / 2)
if A[m] < T:
L:= m + 1
else:
R:= m
return L
Quy trình tìm phần tử xa nhất về bên phải
Để tìm phần tử xa nhất về bên phải, ta có thể dùng quy trình sau:[11]
Gán với giá trị và với .
Khi ,
Gán (vị trí của phần tử đứng giữa) với giá trị floor của , tức là số nguyên lớn nhất nhỏ hơn hoặc bằng .
Nếu , gán với .
Trường hợp còn lại, ; gán với .
Trả về .
Nếu và , thì là phần tử đứng xa nhất về phía bên phải có giá trị bằng . Kể cả khi không có trong mảng, sẽ là số phần tử trong mảng lớn hơn .
Với floor là hàm floor, mã giả cho phiên bản này như sau:
function binary_search_rightmost(A, n, T):
L:= 0
R:= n
while L < R:
m:= floor((L + R) / 2)
if A[m] > T:
R:= m
else:
L:= m + 1
return R - 1
Hiệu suất
Một cây biểu diễn tìm kiếm nhị phân. Mảng được dùng trong ví dụ này là và giá trị cần tìm là .Trường hợp tệ nhất là khi quá trình tìm kiếm phải đi tới bậc sâu nhất của cây, còn trường hợp tốt nhất là khi giá trị cần tìm chính là phần tử đứng giữa.
Về mặt số phép so sánh, hiệu suất của thuật toán tìm kiếm nhị phân có thể được phân tích bằng cách biểu diễn quá trình chạy thuật toán trên cây nhị phân. Nút gốc của cây là phần tử đứng giữa mảng. Phần tử đứng giữa nửa nhỏ hơn là nút con bên trái của nút gốc, và phần tử đứng giữa nửa lớn hơn là nút con bên phải của nút gốc. Phần còn lại của cây cũng được dựng như vậy. Bắt đầu từ nút gốc, tùy theo giá trị cần tìm nhỏ hơn hay lớn hơn nút đang xét mà thuật toán sẽ duyệt theo cây con bên trái hay bên phải.[6][12]
Trong trường hợp tệ nhất, thuật toán thực hiện lần lặp so sánh, trong đó là ký hiệu của hàm floor cho kết quả là số nguyên lớn nhất nhỏ hơn hoặc bằng giá trị bên trong, và là phép logarit nhị phân. Nguyên nhân là do khi gặp phải trường hợp tệ nhất, thuật toán phải đi tới bậc sâu nhất trong cây, và số bậc trong cây luôn bằng với bất cứ phép tìm kiếm nhị phân nào.
Trường hợp tệ nhất còn có thể xảy ra khi giá trị cần tìm không có trong mảng. Nếu ở dạng thì trường hợp này luôn xảy ra. Nếu không, thuật toán có thể thực hiện phép lặp nếu phải đi tới bậc sâu nhất của cây. Tuy nhiên, nếu thuật toán kết thúc ngay ở bậc sâu thứ hai của cây, quá trình tìm kiếm có thể chỉ cần thực hiện phép lặp, ít hơn trường hợp tệ nhất một phép.[13]
Giả sử mỗi phần tử có xác suất được thực hiện tìm kiếm như nhau, trung bình thuật toán tìm kiếm nhị phân thực hiện phép lặp khi phần tử cần tìm có mặt trong mảng, hay xấp xỉ bằng . Khi phần tử cần tìm không có trong mảng, thuật toán thực hiện trung bình phép lặp, nếu giả sử khoảng giữa và bên ngoài các phần tử đều có xác suất được tìm như nhau.[12]
Trong trường hợp tốt nhất, khi giá trị cần tìm nằm ngay giữa mảng, vị trí của nó được trả về ngay sau một phép lặp.[14]
Về mặt số phép lặp, không có thuật toán tìm kiếm nào chỉ bằng so sánh các phần tử có thể có hiệu suất trung bình hay thậm chí trong trường hợp tệ nhất tốt hơn tìm kiếm nhị phân. Cây so sánh biểu diễn tìm kiếm nhị phân có số bậc ít nhất có thể do mọi bậc phía trên bậc thấp nhất của cây đều được lấp đầy hoàn toàn.[a] Nếu không, thuật toán tìm kiếm có thể loại bỏ một vài phần tử trong mỗi phép lặp, làm tăng số phép lặp cần thực hiện trong trường hợp trung bình và tệ nhất. Các thuật toán tìm kiếm khác dùng cách so sánh đều bị rơi vào trường hợp này, do mặc dù chúng có thể chạy nhanh hơn với một số giá trị cần tìm nhất định, hiệu suất trung bình trên tất cả phần tử vẫn kém hơn tìm kiếm nhị phân. Bằng cách chia mảng thành hai nửa, thuật toán tìm kiếm nhị phân đảm bảo được kích thước của hai mảng con giống nhau nhất có thể.[15]
Độ phức tạp không gian
Thuật toán tìm kiếm nhị phân cần ba con trỏ để trỏ tới các phần tử, bất kể kích thước của mảng; đó có thể là chỉ số các phần tử trong mảng hoặc con trỏ tới địa chỉ trong bộ nhớ. Tuy nhiên, thuật toán cần ít nhất bit để mã hóa một con trỏ tới một phần tử trong mảng có phần tử.[16] Do đó, độ phức tạp không gian của tìm kiếm nhị phân là . Ngoài ra, thuật toán còn cần không gian để lưu trữ mảng.
Trường hợp trung bình
Số phép lặp trung bình được thực hiện bởi thuật toán tùy thuộc vào xác suất mỗi phần tử được thực hiện tìm kiếm. Trường hợp trung bình khi tìm kiếm thành công và không thành công là khác nhau. Ta giả sử mỗi phần tử có xác suất được tìm như nhau nếu tìm kiếm thành công. Nếu tìm kiếm không thành công, ta giả sử các khoảng giữa và ngoài các phần tử có xác suất được tìm giống nhau. Trường hợp trung bình khi tìm kiếm thành công là số phép lặp cần thực hiện để tìm mọi phần tử chính xác một lần, chia cho , số phần tử trong mảng. Trường hợp trung bình khi tìm kiếm không thành công là số phép lặp cần thực hiện để tìm một phần tử trong mọi khoảng chính xác một lần, chia cho khoảng.[12]
Tìm kiếm thành công
Khi biểu diễn bằng cây nhị phân, một lần tìm kiếm thành công có thể được biểu diễn bằng một đường đi từ gốc tới nút cần tìm, gọi là đường đi trong (internal path). Độ dài của một đường đi là số cạnh (đường nối giữa các nút) mà đường đó đi qua. Số phép lặp mà một lần tìm kiếm thực hiện, với điều kiện độ dài của đường đi tương ứng với lần tìm đó là , là tính cả phép lặp ban đầu. Độ dài đường đi trong (internal path length) là tổng độ dài tất cả các đường đi trong. Do chỉ có một đường đi duy nhất từ gốc tới bất cứ nút nào, mỗi đường trong đó biểu diễn cho một phép tìm kiếm phần tử tương ứng. Nếu có phần tử, và độ dài đường trong là , thì số phép lặp trung bình cho một lần tìm kiếm thành công là , trong đó đã cộng thêm một bước lặp ở đầu.[12]
Do tìm kiếm nhị phân là thuật toán tối ưu cho việc tìm kiếm bằng cách so sánh, bài toán được đơn giản hóa thành việc tính độ dài đường đi trong tối thiểu của tất cả cây nhị phân có nút, tức là bằng:[17]
Ví dụ, với một mảng 7 phần tử, phần tử gốc cần một phép lặp, hai phần tử bên dưới gốc cần hai phép lặp, và bốn phần tử dưới nữa gần ba phép lặp. Trong trường hợp này, độ dài đường đi trong là:[17]
Số phép lặp trung bình sẽ là dựa theo công thức với trường hợp trung bình. Tổng có thể rút gọn thành:[15]
Với là một số nguyên, phương trình trên tương đương với phương trình trong trường hợp trung bình cho một lần tìm kiếm thành công như đã chỉ ra ở trên.
Tìm kiếm không thành công
Ta có thể biểu diễn lần tìm kiếm không thành công bằng cách thêm vào cây các nút ngoài, tạo thành cây nhị phân mở rộng. Nếu một nút trong, hoặc một nút có trong cây, có ít hơn hai nút con, thì ta sẽ thêm vào các nút con bổ sung, gọi là các nút ngoài, để mỗi nút trong đều có hai nút con. By doing so, an unsuccessful search can be represented as a path to an external node, whose parent is the single element that remains during the last iteration. Đường đi ngoài (external path) là đường đi từ gốc đến một nút ngoài. Độ dài đường đi ngoài (external path length) là tổng độ dài tất cả đường đi ngoài unique. Nếu số phần tử là ( là số nguyên dương), và độ dài đường đi ngoài là , thì số phép lặp trung bình cho một lần tìm kiếm không thành công là , trong đó đã tính một phép lặp lúc đầu. Trong công thức này, ta lấy độ dài đường đi ngoài chia cho chứ không phải vì có đường đi ngoài biểu diễn cho các khoảng giữa và ngoài các phần tử trong mảng.[15]
Tương tự, bài toán này có thể đơn giản hóa thành bài toán tính độ dài đường đi ngoài tối thiếu của tất cả cây nhị phân có nút. Với tất cả các cây nhị phân, độ dài đường đi ngoài bằng độ dài đường đi trong cộng với .[17] Thay vào ta có:[15]
Thay vào công thức tính , ta có thể xác định trường hợp trung bình cho những lần tìm kiếm không thành công:[15]
Hiệu suất của các cách thay thế
Mỗi phép lặp trong cách tìm kiếm nhị phân trên thực hiện một hoặc hai lần so sánh để kiểm tra phần tử ở giữa có bằng với giá trị cần tìm không. Giả sử mỗi phần tử có xác suất được tìm bằng nhau, mỗi phép lặp thực hiện trung bình 1,5 lần so sánh. Một cách làm khác của thuật toán sẽ thực hiện kiểm tra phần tử giữa ở cuối mỗi lần tìm kiếm. Theo cách này, trung bình mỗi phép lặp bỏ qua 0,5 lần so sánh, tiết kiệm thời gian hơn một chút trên mỗi phép lặp ở hầu hết các loại máy tính. Tuy nhiên, cách này lại khiến mỗi lần tìm kiếm luôn phải thực hiện số phép lặp tối đa, trung bình mỗi lần tìm cộng thêm một phép lặp. Vì vòng lặp so sánh chỉ được thực hiện lần trong trường hợp tệ nhất, việc tăng nhẹ hiệu suất ở mỗi phép lặp không thể bù lại cho phép lặp cần thực hiện thêm khi không lớn.[b][19][20]
Thời gian chạy và mức sử dụng cache
Trong quá trình phân tích hiệu suất của thuật toán, một vấn đề khác cần lưu ý là thời gian cần để so sánh hai phần tử. Với các số nguyên và chuỗi ký tự, khoảng thời gian này tăng tuyến tính do độ dài mã hóa (thường là số bit) của các phần tử tăng theo. Ví dụ, một cặp số nguyên dương 64 bit sẽ cần phải so sánh số bit gấp đôi so với một cặp số nguyên dương 32 bit. Trường hợp tệ nhất được đạt đến khi hai số nguyên bằng nhau. Điều này có thể đáng lưu tâm khi độ dài mã hóa của các phần tử ở mức lớn, ví dụ như khi so sánh các số thuộc kiểu nguyên lớn hơn hay so sánh các chuỗi ký tự dài, khiến cho việc so sánh rất tốn thời gian. Hơn nữa, so với các số nguyên hoặc chuỗi ký tự ngắn thì so sánh các giá trị dấu phẩy động (cách biểu diễn số thông dụng nhất của số thực) thường còn tốn thời gian hơn..
Trên hầu hết các kiểu kiến trúc máy tính, bộ vi xử lý có bộ nhớ cache riêng tách biệt khỏi RAM. Do nằm ngay trong bộ xử lý, bộ nhớ cache có thể truy cập nhanh hơn nhưng thường chỉ chứa được ít dữ liệu hơn RAM. Do đó, hầu hết các bộ xử lý lưu địa chỉ những vùng bộ nhớ đã được truy cập gần đây và cả những vùng bộ nhớ xung quanh chúng. Ví dụ, khi truy cập vào một phần tử trong mảng, phần tử này, cùng với cả những phần tử được lưu gần đó, có thể được lưu vào trong RAM để sau đó có thể truy cập nhanh hơn được vào các phần tử khác liền kề (tính địa phương trong truy xuất - locality of reference). Với một mảng đã sắp xếp, quá trình tìm kiếm nhị phân có thể phải nhảy sang các vùng bộ nhớ nằm xa nếu mảng có kích thước lớn, không giống như các thuật toán (như tìm kiếm tuyến tính hay dò tuyến tính trong bảng băm) truy cập các phần tử theo thứ tự lần lượt. Điều này làm thời gian chạy của thuật toán tăng lên đôi chút với các mảng lớn trên hầu hết các hệ thống.[21]
Tìm kiếm nhị phân với các hệ thống khác
Áp dụng tìm kiếm nhị phân với các mảng đã được sắp xếp là một giải pháp rất không hiệu quả khi xen giữa các phép chèn và xóa còn là các phép truy hồi, mỗi thao tác đó phải mất thời gian để thực hiện. Ngoài ra, yêu cầu phải sắp xếp trước các mảng có thể làm phức tạp việc sử dụng bộ nhớ, đặc biệt là khi các phần tử thường được chèn vào trong mảng.[22] Một số cấu trúc dữ liệu khác có thể hỗ trợ các thao tác chèn và xóa hiệu quả hơn nhiều. Tìm kiếm nhị phân có thể được dùng để thực hiện so khớp chính xác và thao tác quan hệ tập hợp (xác định giá trị cần tìm có phải là một tập hợp nhiều giá trị không). Hai bài toán này cũng có thể được thực hiện nhanh hơn trên các cấu trúc dữ liệu khác. Tuy nhiên, khác với các hệ thống tìm kiếm khác, tìm kiếm nhị phân hiệu quả hơn trong phép so khớp xấp xỉ, thường chỉ thực hiện trong thời gian bất kể kiểu hay cấu trúc của các giá trị.[23] Ngoài ra còn có một số phép toán, như tìm phần tử nhỏ nhất và lớn nhất, có thể được thực hiện hiệu quả trên mảng đã được sắp xếp.[24]
Tìm kiếm tuyến tính
Tìm kiếm tuyến tính là một thuật toán tìm kiếm đơn giản: nó kiểm tra mọi bản ghi cho tới khi tìm thấy giá trị cần tìm. Tìm kiếm tuyến tính có thể được thực hiện trên danh sách liên kết, cho phép các thao tác chèn và xóa nhanh hơn so với mảng. Tìm kiếm nhị phân nhanh hơn tìm kiếm tuyến tính trên các mảng đã được sắp xếp trừ khi mảng có kích thước nhỏ, mặc dù trước khi tìm kiếm cần phải thực hiện sắp xếp lại mảng trước.[c][26] Tất cả các thuật toán sắp xếp dựa trên thao tác so sánh các phần tử, như sắp xếp nhanh và sắp xếp trộn, cần ít nhất phép so sánh trong trường hợp tệ nhất.[27] Khác với tìm kiếm tuyến tính, tìm kiếm nhị phân có thể được ứng dụng để tìm so khớp xấp xỉ một cách hiệu quả. Một số thao tác như tìm phần tử nhỏ nhất và lớn nhất có thể được thực hiện hiệu quả chỉ khi mảng đã được sắp xếp.[28]
Cây
Phép tìm kiếm trên cây tìm kiếm nhị phân sử dụng một thuật toán tương tự tìm kiếm nhị phân.
Cây tìm kiếm nhị phân là một cấu trúc dữ liệu dạng cây nhị phân hoạt động dựa trên nguyên lý của tìm kiếm nhị phân. Các bản ghi trong cây được biểu diễn theo thứ tự đã được sắp xếp, và mỗi bản ghi có thể được tìm bằng một thuật toán tương tự tìm kiếm nhị phân, thực hiện trung bình theo thời gian logarit. Các phép chèn và xóa cũng cần trung bình thời gian logarit trong cây tìm kiếm nhị phân. Chúng có thể được thực hiện nhanh hơn so với các thao tác chèn và xóa theo thời gian tuyến tính trên các mảng đã được sắp xếp, đồng thời các thao tác có thể thực hiện trên mảng đã được sắp xếp cũng có thể được thực hiện trên cây nhị phân, bao gồm cả các phép truy vấn khoảng và truy vấn xấp xỉ.[23][29]
Tuy nhiên, tìm kiếm nhị phân thường hiệu quả hơn trong việc tìm kiếm, do các cây nhị phân hầu như đều không cân bằng tuyệt đối, khiến hiệu suất kém hơn đôi chút so với tìm kiếm nhị phân. Điều này thậm chí vẫn áp dụng với các cây tìm kiếm nhị phân tự cân bằng, vì chúng hiếm khi tạo ra cây có số bậc nhỏ nhất có thể. Ngoại trừ các cây tìm kiếm nhị phân đã cân bằng, việc có quá ít số nút trong có đúng hai nút con có thể khiến cây bị mất cân bằng rất nhiều, khiến thời gian tìm kiếm ở trường hợp trung bình và tệ nhất có thể tiến tới ngưỡng phép so sánh.[d] Cây tìm kiếm nhị phân cần nhiều không gian hơn so với mảng đã được sắp xếp.[31]
Cây tìm kiếm nhị phân hữu ích nhất cho việc tìm kiềm nhanh trong các ổ cứng ngoài, bởi cây tìm kiếm nhị phân có thể được dựng hiệu quả trong các hệ thống tập tin. Cấu trúc B-cây tổng quát hóa phương pháp tổ chức cây này. B-cây thường được dùng để tổ chức các ổ lưu trữ dài hạn như cơ sở dữ liệu và hệ thống tập tin.[32][33]
Băm
Với các mảng kết hợp, bảng băm, một cấu trúc dữ liệu dùng hàm băm để ánh xạ các khóa thành các bản ghi, thường hoạt động nhanh hơn tìm kiếm nhị phân trên một mảng chứa các bản ghi đã được sắp xếp.[34] Hầu hết các cách triển khai bảng băm chỉ yêu cầu trung bình thời gian hằng số khấu hao.[e][36] Tuy nhiên, việc băm có thể không hữu ích khi thực hiện các bài toán so khớp xấp xỉ, ví dụ như tính khóa nhỏ nhất tiếp theo, khóa lớn nhất tiếp theo và khóa gần nhất, do khi tìm kiếm thất bại, thông tin duy nhất được đưa ra là giá trị cần tìm không có trong bất cứ bản ghi nào.[37] Tìm kiếm nhị phân là cách làm lý tưởng đối với những bài toán như vậy, khi chỉ thực hiện với thời gian chạy logarit. Tìm kiếm nhị phân cũng hỗ trợ so khớp xấp xỉ. Một số phép toán, như tìm phần tử nhỏ nhất và lớn nhất, có thể được thực hiện hiệu quả trên các mảng đã được sắp xếp, nhưng không thể trên bảng băm.[23]
Các thuật toán quan hệ tập hợp
Một vấn đề liên quan tới tìm kiếm là quan hệ tập hợp. Bất cứ thuật toán nào thực hiện tra cứu, như tìm kiếm nhị phân, cũng có thể dùng cho quan hệ tập hợp. Một số thuật toán khác được thiết kế phù hợp hơn với quan hệ tập hợp. Mảng bit là cách làm đơn giản nhất, thường được dùng khi số khóa bị giới hạn. Cấu trúc này lưu trữ các bit một cách nhỏ gọn, mỗi bit biểu diễn cho một khóa trong khoảng cho trước. Mảng bit hoạt động rất nhanh, chỉ trong thời gian .[38] Một loại mảng Judy là Judy1 có thể xử lý hiệu quả các khóa 64-bit.[39]
Nếu chỉ cần kết quả xấp xỉ, ta có thể sử dụng bộ lọc Bloom, một cấu trúc dữ liệu xác suất khác dựa trên hàm băm, lưu trữ một tập hợp các khóa bằng cách sử dụng một mảng bit và nhiều hàm băm để mã hóa các khóa. Các bộ lọc Bloom chiếm ít không gian hơn nhiều so với các mảng bit trong hầu hết mọi trường hợp và không hoạt động chậm hơn là bao: với hàm băm, các phép truy vấn kiểm tra phần tử chỉ cần thời gian . Tuy nhiên, sử dụng bộ lọc Bloom có thể đem kết quả dương tính giả.[f][g][41]
Các cấu trúc dữ liệu khác
Còn có các cấu trúc dữ liệu khác có thể cải thiện tìm kiếm nhị phân trong một số trường hợp với cả mục đích tìm kiếm lẫn các thao tác khác có thể được thực hiện trên các mảng đã được sắp xếp. Ví dụ, các phép tìm kiếm, so khớp xấp xỉ và các thao tác khác trên mảng đã được sắp xếp có thể được thực hiện hiệu quả hơn tìm kiếm nhị phân nếu áp dụng trên các cấu trúc dữ liệu chuyên biệt như cây van Emde Boas, fusion trees, trie và mảng bit. Các cấu trúc dữ liệu chuyên biệt này thường chỉ nhanh hơn vì chúng sử dụng các tính chất của những khóa có điều kiện nhất định (thông thường những khóa này là các số nguyên nhỏ), và do đó thời gian chạy hoặc không gian cần sử dụng có thể lớn nếu không đáp ứng được các điều kiện ấy.[23] Với điều kiện có thể sắp xếp được các khóa, các thao tác này luôn luôn có thể được thực hiện hiệu quả trên một mảng đã được sắp xếp trước, không phụ thuộc vào các khóa. Một số cấu trúc như mảng Judy sử dụng kết hợp nhiều phương pháp nhằm đơn giản hóa điều kiện trên, nhưng vẫn giữ được sự hiệu quả và khả năng thực hiện so khớp xấp xỉ.[39]
Tìm kiếm nhị phân đồng nhất lưu khoảng cách giữa phần tử giữa hiện tại và hai phần tử giữa ở các vòng lặp tiếp theo thay vì sử dụng các giới hạn cụ thể.
Thay vì lưu các giới hạn trên và dưới, tìm kiếm nhị phân đồng nhất lưu khoảng cách về chỉ số của phần tử đứng giữa ở vòng lặp này với vòng lặp tiếp theo. Thuật toán sẽ lập một bảng dò tìm (lookup table) chứa các khoảng cách này. Ví dụ, nếu mảng cần tìm là [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], phần tử giữa () sẽ là 6. Trong trường hợp này, phần tử giữa của mảng con bên trái ([1, 2, 3, 4, 5]) là 3 và phần tử giữa của mảng con bên phải ([7, 8, 9, 10, 11]) là 9. Thuật toán sẽ lưu giá trị 3, chính là khoảng cách từ hai chỉ số con trên tới phần tử giữa ban đầu là 6.[42] Để giảm không gian tìm kiếm, thuật toán sẽ cộng thêm hoặc trừ bớt lượng thay đổi này so với chỉ số của phần tử đứng giữa. Tìm kiếm nhị phân đồng nhất có thể chạy nhanh hơn trên các hệ thống không có khả năng tính được điểm giữa một cách hiệu quả, như trên các máy tính thập phân.[43]
Minh họa thuật toán tìm kiếm mũ tìm giới hạn trên để thực hiện tìm kiếm nhị phân
Tìm kiếm mũ là thuật toán mở rộng của tìm kiếm nhị phân với các danh sách không có giới hạn. Thuật toán bắt đầu bằng việc tìm phần tử đầu tiên có chỉ số là một lũy thừa của hai và đồng thời có giá trị lớn hơn giá trị cần tìm. Sau đó, chỉ số này sẽ được đặt làm giới hạn trên, và quá trình tìm kiếm tiếp tục dưới dạng tìm kiếm nhị phân. Một lần tìm kiếm thực hiện phép lặp trước khi bắt đầu tìm kiếm nhị phân, cộng thêm nhiều nhất phép lặp khi thực hiện tìm kiếm nhị phân, trong đó là vị trí của giá trị cần tìm. Tìm kiếm mũ cũng hoạt động với các danh sách có giới hạn, nhưng chỉ hiệu quả hơn tìm kiếm nhị phân khi giá trị cần tìm nằm gần đầu mảng.[44]
Minh họa thuật toán tìm kiếm nội suy sử dụng nội suy tuyến tính. Trong trường hợp này, việc tìm kiếm không cần thực hiện nữa do vị trí ước lượng của giá trị cần tìm trong mảng đã chính xác. Các cách áp dụng khác có thể dùng một hàm khác để ước lượng vị trí của giá trị cần tìm.
Thay vì tính điểm giữa, tìm kiếm nội suy ước lượng vị trí của giá trị cần tìm dựa vào các phần tử lớn nhất, nhỏ nhất và kích thước của mảng. Thuật toán này hoạt động dựa trên thực tế rằng trong nhiều trường hợp, vị trí đứng giữa có thể không phải vị trí ước lượng tốt nhất. Ví dụ, nếu giá trị cần tìm nằm gần với phần tử lớn nhất trong mảng, nhiều khả năng nó sẽ nằm ở cuối mảng.[45]
Một loại hàm nội suy thường được sử dụng là nội suy tuyến tính. Nếu là mảng cần tìm, lần lượt là các giới hạn dưới và trên, và là giá trị cần tìm, thì giá trị cần tìm được ước lượng nằm ở khoảng khoảng cách giữa và . Khi áp dụng nội suy tuyến tính, and the distribution of the array elements is uniform or near uniform, tìm kiếm nội suy thực hiện phép so sánh.[45][46][47]
Trong thực tế, tìm kiếm nội suy chạy chậm hơn tìm kiếm nhị phân đối với các mảng nhỏ, do tìm kiếm nội suy cần nhiều tính toán hơn. Độ phức tạp thời gian của thuật toán này tăng chậm hơn so với tìm kiếm nhị phân, nhưng chỉ bù đắp được cho phần tính toán cộng thêm khi mảng có kích thước lớn.[45]
Trong đổ xuống một phần, mỗi mảng chứa các con trỏ trỏ tới mỗi phần tử thứ hai của một mảng khác, do đó chỉ cần thực hiện một lần tìm kiếm nhị phân để tìm trong tất cả các mảng.
Đổ xuống một phần là kỹ thuật nhằm làm tăng tốc tìm kiếm nhị phân với cùng một giá trị trong nhiều mảng khác nhau đã được sắp xếp. Nếu thực hiện tìm từng mảng tách riêng nhau ta phải cần thời gian, trong đó là số mảng. Phương pháp đổ xuống một phần làm giảm con số này xuống còn bằng cách lưu các thông tin cụ thể ở mỗi mảng về mỗi phần tử và vị trí của chúng ở các mảng khác.[48][49]
Phương pháp đổ xuống một phần ban đầu được phát triển để giải quyết các bài toán trong hình học tính toán. Phương pháp này đã được áp dụng trong nhiều lĩnh vực khác, như khai phá dữ liệu và định tuyến giao thức Internet.[48]
Tổng quát với đồ thị
Tìm kiếm nhị phân đã được tổng quát để hoạt động với một số loại đồ thị, trong đó giá trị cần tìm được lưu trong một đỉnh thay vì phần tử của một mảng. Cây tìm kiếm nhị phân chính là một trong số những cách tổng quát đó—khi một đỉnh (nút) trong cây được truy vấn, thuật toán sẽ xác định hoặc là đỉnh đó chính là giá trị cần tìm, hoặc đi tìm cây con có giá trị cần tìm. Quá trình này còn có thể khái quát hơn nữa như sau: cho một đồ thị vô hướng, trọng số dương và một đỉnh cần tìm, khi truy vấn một đỉnh, thuật toán sẽ xác định hoặc đỉnh đó bằng đỉnh cần tìm, hoặc tìm được cạnh kề nằm trong đường đi ngắn nhất từ đỉnh đang xét đến đỉnh cần tìm. Thuật toán tìm kiếm nhị phân tiêu chuẩn là trường hợp đặc biệt khi đồ thị chỉ là một đường đi. Tương tự, các cây tìm kiếm nhị phân là trường hợp mà các cạnh thuộc cây con bên trái hoặc bên phải được tìm ra nếu đỉnh được truy vấn không bằng đỉnh cần tìm. Với mọi đồ thị vô hướng có trọng số dương, luôn tồn tại một thuật toán có thể tìm ra một đỉnh cho trước trong phép truy vấn ở trường hợp tệ nhất.[50]
Tìm kiếm nhị phân nhiễu
Trong tìm kiếm nhị phân nhiễu, chắc chắn tồn tại xác suất phép so sánh cho kết quả sai.
Các thuật toán tìm kiếm nhị phân nhiễu (noisy binary search) có thể giải quyết các trường hợp mà thuật toán không thể so sánh chính xác các phần tử trong mảng. Ở mỗi cặp phần tử đều tồn tại một xác suất nhất định rằng thuật toán thực hiện sai phép so sánh. Tìm kiếm nhị phân nhiễu có thể tìm vị trí chính xác của giá trị cần tìm với một xác suất cho trước, nhằm kiểm soát độ chính xác của kết quả. Mỗi lần tìm kiếm nhị phân nhiễu phải thực hiện trung bình ít nhất phép so sánh, trong đó là hàm entropy nhị phân và là xác suất một lần tìm kiếm cho ra kết quả sai.[51][52][53] Bài toán tìm kiếm nhị phân nhiễu có thể được coi là một trường hợp của trò chơi Rényi-Ulam,[54] một biến thể của trò chơi Hai mươi câu hỏi trong đó các câu trả lời có thể sai.[55]
Tìm kiếm nhị phân lượng tử
Các máy tính cổ điển khi thực hiện tìm kiếm nhị phân bị giới hạn số phép lặp ở mức trong trường hợp tệ nhất. Các thuật toán lượng tử dành cho tìm kiếm nhị phân vẫn chỉ bó buộc trong khoảng phép truy vấn (biểu diễn cho phép lặp trong trường hợp cổ điển), nhưng có hệ số nhỏ hơn một, do đó làm giảm độ phức tạp thời gian trên các máy tính lượng tử. Bất cứ thao tác tìm kiếm nhị phân lượng tử chính xác nào—tức là thao tác luôn cho kết quả đúng—cần ít nhất phép truy vấn ở trường hợp tệ nhất, trong đó là hàm logarit tự nhiên.[56] Có một phương pháp tìm kiếm nhị phân lượng tử chính xác chạy trong phép truy vấn ở trường hợp tệ nhất.[57] So với các thuật toán khác, thuật toán Grover là thuật toán lượng tử tối ưu cho việc tìm kiếm trong một danh sách các phần tử chưa được sắp xếp, với phép truy vấn.[58]
Lịch sử
Ý tưởng về việc sắp xếp một danh sách để tìm kiếm được nhanh hơn đã có từ thời cổ xưa. Ví dụ được biết tới sớm nhất là tấm bảng của Inakibit-Anu từ thời Babylon vào k. 200 TCN. Tấm bảng này chứa khoảng 500 con số ở hệ thập lục phân và các số nghịch đảo của chúng được sắp xếp theo thứ tự từ điển, khiến việc tìm kiếm dễ dàng hơn. Ngoài ra trên Quần đảo Aegean còn phát hiện được một vài danh sách những cái tên được sắp xếp theo chữ cái đầu tiên. Catholicon, một cuốn từ điển Latin được hoàn thành vào năm 1286 SCN, là tác phẩm đầu tiên mô tả các quy tắc sắp xếp các từ theo thứ tự bảng chữ cái, thay vì chỉ theo vài ký tự đầu tiên.[10]
Vào năm 1946, John Mauchly là người đầu tiên nhắc tới tìm kiếm nhị phân trong Moore School Lectures, một khóa học về máy tính tại Trường Kỹ thuật Điện tử Moore, Đại học Pennsylvania.[59] Năm 1957, William Wesley Peterson xuất bản phương pháp đầu tiên cho giải thuật tìm kiếm nội suy.[10][60] Mọi thuật toán tìm kiếm nhị phân đã được xuất bản chỉ hoạt động với các mảng có độ dài ở dạng [h] cho tới năm 1960, khi Derrick Henry Lehmer cho xuất bản một thuật toán tìm kiếm nhị phân có thể hoạt động với mọi mảng.[62] Năm 1962, Hermann Bottenbruch trình bày thuật toán tìm kiếm nhị phân được áp dụng bằng ngôn ngữ ALGOL 60, trong đó bước kiểm tra phần tử giữa được đưa xuống cuối, làm tăng số phép lặp trung bình thêm một, nhưng giảm được một bước so sánh mỗi phép lặp.[9] Thuật toán uniform binary search được phát triển bởi A. K. Chandra tại Đại học Stanford vào năm 1971.[10] Năm 1986, Bernard Chazelle và Leonidas J. Guibas giới thiệu phương pháp đổ xuống một phần (fractional cascading) nhằm giải quyết nhiều vấn đề về tìm kiếm trong hình học tính toán.[48][63][64]
Thư viện hỗ trợ
Nhiều thư viện chuẩn của các ngôn ngữ có các chương trình con thực hiện tìm kiếm nhị phân:
C cung cấp hàmbsearch() trong thư viện chuẩn, thường được triển khai bằng tìm kiếm nhị phân, mặc dù tiêu chuẩn chính thức không yêu cầu.[65]
COBOL cung cấp động từ SEARCH ALL có chức năng thực hiện tìm kiếm nhị phân trên các bảng được sắp xếp trong COBOL.[67]
Gói thư viện chuẩn sort của Go cung cấp các hàm Search, SearchInts, SearchFloat64s, và SearchStrings, lần lượt có chức năng thực hiện tìm kiếm nhị phân tổng quát, tìm kiếm các lát số nguyên, số thập phân dấu phẩy động và chuỗi ký tự.[68]
Java cung cấp một bộ các phương thức static chồngbinarySearch() trong các lớp Arrays và Collections chứa trong gói java.util chuẩn, lần lượt có chức năng thực hiện tìm kiếm nhị phân trên các mảng trong Java và trên các List.[69][70]
Microsoft's .NET Framework 2.0 cung cấp các phiên bản static tổng quát của thuật toán tìm kiếm nhị phân trong các lớp collection base. Ví dụ như phương thức BinarySearch<T>(T[] array, T value) trong System.Array[71]
Lớp Array của Ruby có chứa phương thức bsearch có cả chức năng so khớp xấp xỉ.[75]
Ghi chú và tham khảo
Ghi chú
^Bất cứ thuật toán tìm kiếm nào chỉ dựa trên các phép so sánh có thể được biểu diễn bằng cây so sánh nhị phân. Đường đi trong (internal path) là bất kỳ đường nào đi từ gốc tới một nút có sẵn. Gọi là độ dài đường đi trong (internal path length), hay tổng độ dài tất cả các đường đi trong của cây. Nếu mỗi phần tử có xác suất được tìm như nhau, trường hợp trung bình là , hoặc đơn giản hơn là một cộng với trung bình toàn bộ độ dài các đường đi trong trong cây. Nguyên nhân là do các đường đi trong biểu diễn các phần tử mà thuật toán tìm kiếm tiến hành so sánh với giá trị cần tìm. Độ dài của các đường này biểu diễn số phép lặp sau nút gốc. Làm phép cộng trung bình tổng độ dài các đường này với thêm một phép lặp nữa ở nút gốc sẽ ra trường hợp trung bình. Do đó, để giảm tối đa số phép so sánh trung bình, cần phải giảm độ dài đường đi trong . Thực chất cây biểu diễn tìm kiếm nhị phân đã giảm tối đa độ dài đường đi trong. Knuth 1998 chứng minh được rằng độ dài đường đi ngoài (external path - the path length over all nodes where both children are present for each already-existing node) được giảm tối đa khi các nút ngoài (các nút không có nút con) nằm trong hai bậc liên tiếp của cây. Điều này cũng áp dụng với các đường đi trong do độ dài đường đi trong có liên hệ tuyến tính với độ dài đường đi ngoài . Với mỗi cây có nút, . Khi mỗi cây con có số nút bằng nhau, tương đương với khi mảng được chia thành hai nửa ở mỗi phép lặp, các nút ngoài và các nút cha phía trong của chúng nằm trong vòng hai bậc. Như vậy, thuật toán tìm kiếm nhị phân đã giảm được tối đa số phép so sánh trung bình do cây so sánh của thuật toán này có độ dài đường trong nhỏ nhất.[12]
^Knuth 1998 cho thấy trên mẫu máy tính MIX của ông, được Knuth thiết kế để biểu diễn cho một chiếc máy tính thông thường, rằng thời gian chạy trung bình của cách làm này cho một lần tìm thành công là đơn vị thời gian so với đơn vị với tìm kiếm nhị phân thông thường. Độ phức tạp thời gian của cách làm này tăng nhẹ ở tốc độ chậm hơn, nhưng độ phức tạp ban đầu lớn hơn. [18]
^Knuth 1998 đã thực hiện phân tích hiệu suất thời gian của cả hai thuật toán tìm kiếm này. Trên chiếc máy tính MIX của Knuth, được ông thiết kế để biểu diễn cho một chiếc máy tính thông thường, tìm kiếm nhị phân trung bình mất đơn vị thời gian cho một lần tìm kiếm thành công, trong khi tìm kiếm tuyến tính khi có một sentinel node ở cuối danh sách mất đơn vị. Tìm kiếm tuyến tính có độ phức tạp ban đầu thấp hơn do nó chỉ yêu cầu các tính toán tối thiểu, nhưng sau đó nhanh chóng vượt qua tìm kiếm nhị phân về độ phức tạp. Trên máy tính MIX, binary search only outperforms linear search with a sentinel if .[12][25]
^Đưa các giá trị vào theo thứ tự được sắp xếp hay theo quy luật khóa thấp nhất-cao nhất luân phiên sẽ tạo ra một cây tìm kiếm nhị phân có thời gian tìm kiếm tối đa ở trung bình và tệ nhất.[30]
^Một số cách triển khai bảng băm có thể thực hiện tìm kiếm với thời gian hằng số đảm bảo.[35]
^This is because simply setting all of the bits which the hash functions point to for a specific key can affect queries for other keys which have a common hash location for one or more of the functions.[40]
^Có một số bản cải thiện của bộ lọc Bloom giúp cải thiện độ phức tạp hoặc cho phép thao tác xóa; ví dụ, bộ lọc cuckoo sử dụng cách băm cuckoo nhằm những mục đích trên.[40]
^ abKnuth 1998, §6.2.1 ("Searching an ordered table"), phân mục "Algorithm B".
^Knuth 1998, §6.2.1 ("Searching an ordered table"), subsection "Algorithm B".
^ abcBottenbruch, Hermann (ngày 1 tháng 4 năm 1962). “Structure and use of ALGOL 60”. Journal of the ACM. 9 (2): 161–221. doi:10.1145/321119.321120. ISSN0004-5411. Procedure is described at p. 214 (§43), titled "Program for Binary Search".
^ abcdeKnuth 1998, §6.2.1 ("Searching an ordered table"), phân mục "History and bibliography".
^ abFan, Bin; Andersen, Dave G.; Kaminsky, Michael; Mitzenmacher, Michael D. (2014). Cuckoo filter: practically better than Bloom. Proceedings of the 10th ACM International on Conference on Emerging Networking Experiments and Technologies. tr. 75–88. doi:10.1145/2674005.2674994.
^Pelc, Andrzej (2002). “Searching games with errors—fifty years of coping with liars”. Theoretical Computer Science. 270 (1–2): 71–109. doi:10.1016/S0304-3975(01)00303-6.
^Rényi, Alfréd (1961). “On a problem in information theory”. Magyar Tudományos Akadémia Matematikai Kutató Intézetének Közleményei (bằng tiếng Hungary). 6: 505–516. MR0143666.
^Lehmer, Derrick (1960). Teaching combinatorial tricks to a computer. Proceedings of Symposia in Applied Mathematics. 10. tr. 180–181. doi:10.1090/psapm/010.
Chang, Shi-Kuo (2003). Data structures and algorithms. Software Engineering and Knowledge Engineering. 13. Singapore: World Scientific. ISBN978-981-238-348-8.
Goldman, Sally A.; Goldman, Kenneth J. (2008). A practical guide to data structures and algorithms using Java. Boca Raton, Florida: CRC Press. ISBN978-1-58488-455-2.