在 JavaScript 中实现二分查找
假设你正在一个包含 100,000 个商品价格的已排序数组中进行搜索。JavaScript 内置的 indexOf() 方法会从头开始逐个检查每个元素——这可能需要进行 100,000 次比较。而二分查找最多只需要 17 次。这就是为什么 JavaScript 二分查找算法如此重要,也是为什么值得你自己编写它的原因——因为 JavaScript 并没有提供内置的二分查找方法。
本文将介绍二分查找的工作原理、如何实现迭代和递归两种版本,以及何时应该使用它而不是线性查找。
核心要点
- 二分查找仅适用于已排序数组,时间复杂度为 O(log n),而线性查找为 O(n)。
- 迭代方法是大多数使用场景的首选——它可读性强、效率高,且空间复杂度为 O(1)。
- 递归版本虽然优雅,但会消耗 O(log n) 的栈空间,忘记
return递归调用是一个常见错误。 - 在应用二分查找之前,务必使用数值比较器(
(a, b) => a - b)对数字数组进行排序——JavaScript 默认的sort()方法是按字典序排序的。
什么是二分查找?为什么它需要已排序数组?
二分查找是一种分治算法。它通过反复将搜索空间减半,直到找到目标值或穷尽所有可能性。
关键在于:它只适用于已排序数组。该算法假设如果目标值大于中间元素,它必定在右半部分。这个假设在未排序数据上完全不成立——你会得到错误的结果而不会报错。
步骤如下:
- 找到当前搜索范围的中间元素
- 如果它等于目标值,返回其索引
- 如果目标值较小,搜索左半部分
- 如果目标值较大,搜索右半部分
- 重复以上步骤,直到找到目标或搜索范围为空
在 JavaScript 中实现二分查找:迭代方法
迭代版本使用 while 循环和两个指针。这是大多数情况下的首选方法——可读性强、效率高,且空间复杂度为 O(1)。
function binarySearch(arr, target) {
let start = 0
let end = arr.length - 1
while (start <= end) {
const mid = Math.floor((start + end) / 2)
if (arr[mid] === target) {
return mid // 返回索引
} else if (arr[mid] < target) {
start = mid + 1 // 搜索右半部分
} else {
end = mid - 1 // 搜索左半部分
}
}
return -1 // 未找到目标
}
const prices = [10, 25, 47, 89, 134, 200]
console.log(binarySearch(prices, 89)) // 3
console.log(binarySearch(prices, 50)) // -1
注意,这个函数返回的是目标值的索引,而不是布尔值。在实际应用中,这几乎总是更有用的。
JavaScript 中的递归二分查找
递归版本在每次调用时传递更新后的边界索引。它虽然优雅,但会使用 O(log n) 的栈空间——每次递归调用都会占用一个栈帧。
function binarySearchRecursive(arr, target, start = 0, end = arr.length - 1) {
if (start > end) return -1
const mid = Math.floor((start + end) / 2)
if (arr[mid] === target) return mid
return arr[mid] < target
? binarySearchRecursive(arr, target, mid + 1, end)
: binarySearchRecursive(arr, target, start, mid - 1)
}
const scores = [3, 11, 22, 45, 67, 91]
console.log(binarySearchRecursive(scores, 22)) // 2
console.log(binarySearchRecursive(scores, 50)) // -1
一个常见错误:忘记 return 递归调用。如果没有 return,即使元素存在,函数也会返回 undefined。
Discover how at OpenReplay.com.
搜索前先排序
如果你的数组还没有排序,你需要先对它进行排序。JavaScript 默认的 Array.sort() 方法是按字典序排序的,这会破坏数值比较:
[10, 9, 100].sort() // [10, 100, 9] — 错误
[10, 9, 100].sort((a, b) => a - b) // [9, 10, 100] — 正确
在运行二分查找之前,务必对数字数组使用数值比较器。
二分查找 vs 线性查找:何时使用哪种?
| 线性查找 | 二分查找 | |
|---|---|---|
| 数组必须排序 | 否 | 是 |
| 时间复杂度 | O(n) | O(log n) |
| 适合小数组 | ✅ | ❌ |
| 适合大数组 | ❌ | ✅ |
| 多次搜索 | 低效 | 高效 |
对于一个包含 10,000 个元素的数组,线性查找最多需要 10,000 次比较。二分查找最多只需要 14 次。如果你只需要在一个小型未排序数组中搜索一次,indexOf() 就足够了。如果你需要在大型已排序数据中重复搜索,二分查找才是正确的工具。
总结
一旦理解了已排序数组的要求,在 JavaScript 中实现二分查找就很简单了。默认使用迭代版本——它更简洁,避免了调用栈开销。当你的代码结构从递归中受益时再使用递归版本,但要记得始终 return 递归调用。并且在搜索之前,务必验证你的数组已使用正确的数值比较器进行了排序。
常见问题
是的,只要字符串数组按字母顺序排序,二分查找就适用。JavaScript 中的比较运算符默认按字典序处理字符串比较,因此相同的算法逻辑也适用。只需确保使用默认的 sort 方法对数组进行排序,该方法已经按字典序对字符串进行排序。
JavaScript 数组设计得很灵活,可以容纳混合类型,这使得内置二分查找变得不切实际,因为它需要一个已排序的、类型统一的集合。像 Lodash 这样的库为此提供了 sortedIndexOf 方法,但对于原生 JavaScript,你需要自己编写实现。
标准实现会返回一个匹配元素的索引,但不一定是第一个或最后一个出现的位置。如果你需要重复值的第一个或最后一个索引,必须修改算法,使其在找到匹配项后继续在左半部分或右半部分搜索。
两者的时间复杂度都是 O(log n),因此它们的原始速度几乎相同。但是,迭代版本避免了递归函数调用的开销,并使用恒定的内存,这使得它在实践中稍微更高效。对于非常深的递归,递归版本还可能面临栈溢出的风险。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.