各位老铁,咱们书接上回!上一章,我们一起领略了排序界“闪电侠”——快速排序的风采。今天,阿扩要给大家介绍一位在有序数组中堪称“导航犬”级别的高效搜索算法——二分查找 (Binary Search)!
想象一下,你面前有一本厚厚的、按字母顺序排列的英文字典,想找一个单词 “heuristic”。你是从第一页一个一个翻呢,还是直接翻到中间,看看 “h” 在前半部分还是后半部分,然后不断缩小范围?
我相信聪明的你一定会选择后者!恭喜你,你已经不自觉地用上了二分查找的思想!
章节二:有序数组的“导航犬”:二分查找 - (二分查找 / Binary Search)
🎯 二分查找:大海捞针?不,是精准定位!
二分查找,也叫折半查找,是一种在有序数组中查找某一特定元素的搜索算法。它的核心思想非常简单粗暴但有效:
- 从中间劈开: 找到数组的中间元素。
- 比对大小:
- 如果中间元素正好是你要找的,恭喜,找到了!
- 如果目标元素小于中间元素,说明目标只可能在数组的左半部分。
- 如果目标元素大于中间元素,说明目标只可能在数组的右半部分。
- 缩小范围,重复: 在确定了目标元素可能存在的子数组后,重复上述步骤,直到找到元素或者子数组为空(说明元素不存在)。
是不是有点像玩“猜数字”游戏?我心里想一个1到100的数,你来猜。
你说50,我说“小了”。
你接着在51到100之间猜75,我说“大了”。
然后你在51到74之间猜… 每次都把搜索范围缩小一半,效率杠杠的!
🚀 “导航犬”如何带路:图解搜索路径
光说不练假把式,咱们来看一个具体的例子。假设我们有一个有序数组 arr = [2, 5, 7, 8, 11, 12, 15, 18],我们要查找元素 target = 12。
-
初始状态:
low = 0(指向2)
high = 7(指向18)
数组:[2, 5, 7, 8, 11, 12, 15, 18] -
第一次查找:
mid = low + (high - low) // 2 = 0 + (7 - 0) // 2 = 3
arr[mid]即arr[3]是8。
因为target (12) > arr[mid] (8),所以目标在右半部分。
更新low = mid + 1 = 3 + 1 = 4。
新的搜索范围:[11, 12, 15, 18](索引从4到7) -
第二次查找:
low = 4(指向11)
high = 7(指向18)
mid = low + (high - low) // 2 = 4 + (7 - 4) // 2 = 4 + 1 = 5
arr[mid]即arr[5]是12。
因为target (12) == arr[mid] (12),找到了!返回索引5。
下面用流程图来更直观地展示这个搜索过程:
这个图清晰地展示了二分查找是如何一步步缩小范围,最终定位到目标或者确定目标不存在的。
⚡️ 为何它能如此高效?—— 性能剖析
二分查找的“快”体现在其惊人的时间复杂度上:O(log n)。
n是数组中元素的数量。log n(通常是log以2为底) 表示,即使数组非常大,也只需要很少的几次比较就能找到元素。- 比如,一个有100万个元素的数组,
log₂(1,000,000)大约是 20。也就是说,最多只需要比较20次左右就能确定结果! - 相比之下,如果我们用朴素的线性查找(一个一个比),最坏情况下需要比较100万次!
- 比如,一个有100万个元素的数组,
为什么是 O(log n)?
因为每一次比较,我们都将搜索范围缩小了一半。
假设数组长度为 n。
第1次比较后,范围变为 n/2。
第2次比较后,范围变为 n/4。
第k次比较后,范围变为 n / (2^k)。
当范围缩小到1时,我们就找到了元素(或者确定它不存在)。所以,n / (2^k) = 1,解出来就是 2^k = n,即 k = log₂n。
空间复杂度:
- 迭代实现: O(1)。我们只需要几个变量(
low,high,mid)来存储边界和中间值,不需要额外的空间。 - 递归实现: O(log n)。因为每次递归调用都会在调用栈上占用空间,递归深度最多为
log n。
因此,在实际应用中,迭代版本的二分查找通常更受欢迎,因为它避免了递归带来的额外开销和潜在的栈溢出风险(尽管对于log n的深度,栈溢出很少见)。
💻 Show Me The Code! (Java & Python 双语教学)
理论讲得再好,不如代码来得实在!阿扩这就为大家奉上 Java 和 Python 版本的迭代二分查找代码。
Java 版本 (迭代):
public class BinarySearchJava {
/**
* 二分查找 (迭代实现)
* @param arr 已排序的数组
* @param target 要查找的目标值
* @return 目标值在数组中的索引,如果不存在则返回 -1
*/
public static int binarySearch(int[] arr, int target) {
if (arr == null || arr.length == 0) {
return -1; // 处理空数组或null数组
}
int low = 0;
int high = arr.length - 1;
// 循环条件:low <= high 确保当low和high指向同一个元素时也能进行比较
while (low <= high) {
// 使用 low + (high - low) / 2 防止 (low + high) 可能的整数溢出
int mid = low + (high - low) / 2;
if (arr[mid] == target) {
return mid; // 找到了
} else if (arr[mid] < target) {
low = mid + 1; // 目标在右半部分
} else { // arr[mid] > target
high = mid - 1; // 目标在左半部分
}
}
return -1; // 循环结束仍未找到,说明目标不存在
}
public static void main(String[] args) {
int[] sortedArray = {2, 5, 7, 8, 11, 12, 15, 18, 22, 25, 30};
System.out.println("Array: " + java.util.Arrays.toString(sortedArray));
int target1 = 12;
int index1 = binarySearch(sortedArray, target1);
System.out.println("Target: " + target1 + ", Index: " + index1); // Expected: 5
int target2 = 6;
int index2 = binarySearch(sortedArray, target2);
System.out.println("Target: " + target2 + ", Index: " + index2); // Expected: -1
int target3 = 2;
int index3 = binarySearch(sortedArray, target3);
System.out.println("Target: " + target3 + ", Index: " + index3); // Expected: 0
int target4 = 30;
int index4 = binarySearch(sortedArray, target4);
System.out.println("Target: " + target4 + ", Index: " + index4); // Expected: 10
int[] emptyArray = {};
int target5 = 5;
int index5 = binarySearch(emptyArray, target5);
System.out.println("Target: " + target5 + " in empty array, Index: " + index5); // Expected: -1
int[] singleElementArray = {10};
int target6 = 10;
int index6 = binarySearch(singleElementArray, target6);
System.out.println("Target: " + target6 + " in single element array, Index: " + index6); // Expected: 0
int target7 = 7;
int index7 = binarySearch(singleElementArray, target7);
System.out.println("Target: " + target7 + " in single element array, Index: " + index7); // Expected: -1
}
}
代码说明 (Java):
low <= high是循环的关键条件。如果写成low < high,当low和high相等(即只剩一个元素)时会提前退出,可能漏掉这个元素。mid = low + (high - low) / 2;这种写法是为了防止low + high在low和high都很大时发生整数溢出。虽然在Java中int的范围很大,但在某些语言或特定场景下这是个好习惯。- 如果
arr[mid] < target,说明目标在右边,所以新的搜索区间是[mid + 1, high]。 - 如果
arr[mid] > target,说明目标在左边,所以新的搜索区间是[low, mid - 1]。
Python 版本 (迭代):
def binary_search(arr, target):
"""
二分查找 (迭代实现)
:param arr: 已排序的列表
:param target: 要查找的目标值
:return: 目标值在列表中的索引,如果不存在则返回 -1
"""
if not arr:
return -1 # 处理空列表
low = 0
high = len(arr) - 1
while low <= high:
# Python的整数可以自动处理大数,所以 mid = (low + high) // 2 通常没问题
# 但为了习惯和跨语言一致性, low + (high - low) // 2 也是很好的写法
mid = low + (high - low) // 2
if arr[mid] == target:
return mid # 找到了
elif arr[mid] < target:
low = mid + 1 # 目标在右半部分
else: # arr[mid] > target
high = mid - 1 # 目标在左半部分
return -1 # 循环结束仍未找到,说明目标不存在
if __name__ == "__main__":
sorted_list = [2, 5, 7, 8, 11, 12, 15, 18, 22, 25, 30]
print(f"List: {sorted_list}")
target1 = 12
index1 = binary_search(sorted_list, target1)
print(f"Target: {target1}, Index: {index1}") # Expected: 5
target2 = 6
index2 = binary_search(sorted_list, target2)
print(f"Target: {target2}, Index: {index2}") # Expected: -1
target3 = 2
index3 = binary_search(sorted_list, target3)
print(f"Target: {target3}, Index: {index3}") # Expected: 0
target4 = 30
index4 = binary_search(sorted_list, target4)
print(f"Target: {target4}, Index: {index4}") # Expected: 10
empty_list = []
target5 = 5
index5 = binary_search(empty_list, target5)
print(f"Target: {target5} in empty list, Index: {index5}") # Expected: -1
single_element_list = [10]
target6 = 10
index6 = binary_search(single_element_list, target6)
print(f"Target: {target6} in single element list, Index: {index6}") # Expected: 0
target7 = 7
index7 = binary_search(single_element_list, target7)
print(f"Target: {target7} in single element list, Index: {index7}") # Expected: -1
代码说明 (Python):
- Python的
//运算符表示整数除法,确保mid是一个整数索引。 - 逻辑与Java版本基本一致,体现了算法思想的普适性。
是不是感觉二分查找的代码出奇地简洁?但别小看它,这几行代码里可藏着不少细节和“坑”呢!
⚠️ 二分查找的“坑点”与注意事项:细节决定成败!
别看二分查找原理简单,代码也不长,但面试时手写二分查找,很多人都会在边界条件上翻车!阿扩给大家总结几个常见的“坑点”:
- 循环条件
while (low <= high)vswhile (low < high):low <= high是正确的。这意味着当low和high相等时,循环还会执行一次,检查这最后一个元素。如果用low < high,当low和high指向同一个元素时,循环会终止,可能会错过目标。
mid的计算:mid = (low + high) // 2:在某些语言中,如果low和high都非常大,low + high可能会导致整数溢出。所以mid = low + (high - low) // 2是更安全的选择。Python的整数类型可以处理任意大小,所以这个问题不明显,但养成好习惯没坏处。
low和high的更新:- 当
arr[mid] < target时,目标在右侧,新的左边界应该是mid + 1,因为arr[mid]已经不是目标了。 - 当
arr[mid] > target时,目标在左侧,新的右边界应该是mid - 1,因为arr[mid]已经不是目标了。 - 如果这里写成了
low = mid或high = mid,可能会导致死循环(例如,当low和mid相等,且arr[mid] < target时,low不会改变)。
- 当
- 数组必须有序: 这是二分查找的绝对前提!如果数组无序,二分查找的结果是不可预测的,甚至可能是错误的。
- 处理目标不存在的情况: 循环结束后,如果还没找到,就应该返回一个特殊值(比如
-1)表示未找到。
把这些细节想明白了,手写二分查找就稳了!
🌍 二分查找的应用场景:无处不在的效率提升
只要你有一堆有序的数据,并且需要快速从中找到某个特定的项,二分查找就能大显身手:
- 标准库函数: 很多编程语言的标准库都提供了基于二分查找的函数,例如 Java 中的
Arrays.binarySearch(),Python 中的bisect模块。 - 查找特定值: 最直接的应用,如我们上面的例子。
- 寻找满足条件的边界:
- 在一个有序数组中,找到第一个大于等于
x的元素。 - 找到最后一个小于等于
x的元素。 - 这些变种在面试中非常常见,需要对二分查找的边界条件有更灵活的调整。
- 在一个有序数组中,找到第一个大于等于
- 判断某个值是否存在。
- 某些算法的子过程: 例如,在一些需要确定一个最优解范围的问题中,如果解空间具有单调性,可以用二分答案的思想。
- 数据库索引: 数据库的B树、B+树索引,其核心思想也借鉴了二分查找这种不断缩小范围的策略。
👍 优点 vs. 👎 缺点:客观看待
优点:
- 极高效率: O(log n) 的时间复杂度,对于大规模数据查找速度飞快。
- 实现简单: 迭代版本的代码非常简洁。
缺点:
- 依赖有序数据: 最大的限制,必须作用于有序数组。如果数据是无序的,需要先进行排序(排序本身有时间开销,如O(n log n))。
- 不适合频繁插入/删除的场景: 因为数组的插入和删除操作时间复杂度是 O(n),如果数据变动频繁,维护有序数组的成本会很高。这种情况下,平衡二叉搜索树等数据结构可能更合适。
💡 阿扩小结:划重点!
今天我们一起探索了有序数组中的“导航犬”——二分查找:
- 核心前提: 数组必须有序!
- 核心思想: 不断将搜索区间折半,直到找到目标或区间为空。
- 超高效率: 时间复杂度 O(log n),空间复杂度 O(1) (迭代版)。
- 细节魔鬼: 注意循环条件 (
low <= high) 和边界更新 (mid + 1,mid - 1)。 - 应用广泛: 从简单查找特定值到寻找复杂条件的边界。
二分查找虽然简单,但它是很多复杂算法和数据结构的基础。深刻理解它,能让你在解决问题时多一个强有力的工具。是不是瞬间感觉自己的算法武器库又升级了?
好啦,老铁们,关于二分查找的分享就到这里!希望大家都能掌握这个面试中的常客,项目中的效率尖兵。如果觉得阿扩讲得还行,点赞、收藏、转发支持一下呗!
下一章,我们将进入图的世界,聊聊图的遍历算法之一——广度优先搜索 (BFS),看看它是如何像水波纹一样层层推进,解决迷宫等问题的。精彩继续,不要走开哦!
有任何问题或者想深入探讨的,评论区见!阿扩等着你们!🚀

被折叠的 条评论
为什么被折叠?



