章节二:有序数组的“导航犬”:二分查找 - (二分查找 / Binary Search)

各位老铁,咱们书接上回!上一章,我们一起领略了排序界“闪电侠”——快速排序的风采。今天,阿扩要给大家介绍一位在有序数组中堪称“导航犬”级别的高效搜索算法——二分查找 (Binary Search)

想象一下,你面前有一本厚厚的、按字母顺序排列的英文字典,想找一个单词 “heuristic”。你是从第一页一个一个翻呢,还是直接翻到中间,看看 “h” 在前半部分还是后半部分,然后不断缩小范围?

我相信聪明的你一定会选择后者!恭喜你,你已经不自觉地用上了二分查找的思想!


章节二:有序数组的“导航犬”:二分查找 - (二分查找 / Binary Search)

🎯 二分查找:大海捞针?不,是精准定位!

二分查找,也叫折半查找,是一种在有序数组中查找某一特定元素的搜索算法。它的核心思想非常简单粗暴但有效:

  1. 从中间劈开: 找到数组的中间元素。
  2. 比对大小:
    • 如果中间元素正好是你要找的,恭喜,找到了!
    • 如果目标元素小于中间元素,说明目标只可能在数组的左半部分
    • 如果目标元素大于中间元素,说明目标只可能在数组的右半部分
  3. 缩小范围,重复: 在确定了目标元素可能存在的子数组后,重复上述步骤,直到找到元素或者子数组为空(说明元素不存在)。

是不是有点像玩“猜数字”游戏?我心里想一个1到100的数,你来猜。
你说50,我说“小了”。
你接着在51到100之间猜75,我说“大了”。
然后你在51到74之间猜… 每次都把搜索范围缩小一半,效率杠杠的!

🚀 “导航犬”如何带路:图解搜索路径

光说不练假把式,咱们来看一个具体的例子。假设我们有一个有序数组 arr = [2, 5, 7, 8, 11, 12, 15, 18],我们要查找元素 target = 12

  1. 初始状态:
    low = 0 (指向 2)
    high = 7 (指向 18)
    数组:[2, 5, 7, 8, 11, 12, 15, 18]

  2. 第一次查找:
    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)

  3. 第二次查找:
    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

下面用流程图来更直观地展示这个搜索过程:

target(12) > arr[mid](8)
target(12) == arr[mid](12)
target(6) < arr[mid](8)
target(6) > arr[mid](5)
target(6) < arr[mid](7)
low(2) > high(1)
开始: arr=[2,5,7,8,11,12,15,18], target=12, low=0, high=7
mid = (0+7)//2 = 3, arr[3]=8
更新: low=mid+1=4, high=7, 范围:[11,12,15,18]
mid = (4+7)//2 = 5, arr[5]=12
找到! 返回索引 5
如果target=6
mid = (0+7)//2 = 3, arr[3]=8
更新: low=0, high=mid-1=2, 范围:[2,5,7]
mid = (0+2)//2 = 1, arr[1]=5
更新: low=mid+1=2, high=2, 范围:[7]
mid = (2+2)//2 = 2, arr[2]=7
更新: low=2, high=mid-1=1
未找到! 循环结束

这个图清晰地展示了二分查找是如何一步步缩小范围,最终定位到目标或者确定目标不存在的。

⚡️ 为何它能如此高效?—— 性能剖析

二分查找的“快”体现在其惊人的时间复杂度上:O(log n)

  • n 是数组中元素的数量。
  • log n (通常是log以2为底) 表示,即使数组非常大,也只需要很少的几次比较就能找到元素。
    • 比如,一个有100万个元素的数组,log₂(1,000,000) 大约是 20。也就是说,最多只需要比较20次左右就能确定结果!
    • 相比之下,如果我们用朴素的线性查找(一个一个比),最坏情况下需要比较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):

  1. low <= high 是循环的关键条件。如果写成 low < high,当 lowhigh 相等(即只剩一个元素)时会提前退出,可能漏掉这个元素。
  2. mid = low + (high - low) / 2; 这种写法是为了防止 low + highlowhigh 都很大时发生整数溢出。虽然在Java中 int 的范围很大,但在某些语言或特定场景下这是个好习惯。
  3. 如果 arr[mid] < target,说明目标在右边,所以新的搜索区间是 [mid + 1, high]
  4. 如果 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):

  1. Python的 // 运算符表示整数除法,确保 mid 是一个整数索引。
  2. 逻辑与Java版本基本一致,体现了算法思想的普适性。

是不是感觉二分查找的代码出奇地简洁?但别小看它,这几行代码里可藏着不少细节和“坑”呢!

⚠️ 二分查找的“坑点”与注意事项:细节决定成败!

别看二分查找原理简单,代码也不长,但面试时手写二分查找,很多人都会在边界条件上翻车!阿扩给大家总结几个常见的“坑点”:

  1. 循环条件 while (low <= high) vs while (low < high)
    • low <= high 是正确的。这意味着当 lowhigh 相等时,循环还会执行一次,检查这最后一个元素。如果用 low < high,当 lowhigh 指向同一个元素时,循环会终止,可能会错过目标。
  2. mid 的计算:
    • mid = (low + high) // 2:在某些语言中,如果 lowhigh 都非常大,low + high 可能会导致整数溢出。所以 mid = low + (high - low) // 2 是更安全的选择。Python的整数类型可以处理任意大小,所以这个问题不明显,但养成好习惯没坏处。
  3. lowhigh 的更新:
    • arr[mid] < target 时,目标在右侧,新的左边界应该是 mid + 1,因为 arr[mid] 已经不是目标了。
    • arr[mid] > target 时,目标在左侧,新的右边界应该是 mid - 1,因为 arr[mid] 已经不是目标了。
    • 如果这里写成了 low = midhigh = mid,可能会导致死循环(例如,当 lowmid 相等,且 arr[mid] < target 时,low 不会改变)。
  4. 数组必须有序: 这是二分查找的绝对前提!如果数组无序,二分查找的结果是不可预测的,甚至可能是错误的。
  5. 处理目标不存在的情况: 循环结束后,如果还没找到,就应该返回一个特殊值(比如 -1)表示未找到。

把这些细节想明白了,手写二分查找就稳了!

🌍 二分查找的应用场景:无处不在的效率提升

只要你有一堆有序的数据,并且需要快速从中找到某个特定的项,二分查找就能大显身手:

  1. 标准库函数: 很多编程语言的标准库都提供了基于二分查找的函数,例如 Java 中的 Arrays.binarySearch(),Python 中的 bisect 模块。
  2. 查找特定值: 最直接的应用,如我们上面的例子。
  3. 寻找满足条件的边界:
    • 在一个有序数组中,找到第一个大于等于 x 的元素。
    • 找到最后一个小于等于 x 的元素。
    • 这些变种在面试中非常常见,需要对二分查找的边界条件有更灵活的调整。
  4. 判断某个值是否存在。
  5. 某些算法的子过程: 例如,在一些需要确定一个最优解范围的问题中,如果解空间具有单调性,可以用二分答案的思想。
  6. 数据库索引: 数据库的B树、B+树索引,其核心思想也借鉴了二分查找这种不断缩小范围的策略。
👍 优点 vs. 👎 缺点:客观看待

优点:

  • 极高效率: O(log n) 的时间复杂度,对于大规模数据查找速度飞快。
  • 实现简单: 迭代版本的代码非常简洁。

缺点:

  • 依赖有序数据: 最大的限制,必须作用于有序数组。如果数据是无序的,需要先进行排序(排序本身有时间开销,如O(n log n))。
  • 不适合频繁插入/删除的场景: 因为数组的插入和删除操作时间复杂度是 O(n),如果数据变动频繁,维护有序数组的成本会很高。这种情况下,平衡二叉搜索树等数据结构可能更合适。
💡 阿扩小结:划重点!

今天我们一起探索了有序数组中的“导航犬”——二分查找:

  1. 核心前提: 数组必须有序
  2. 核心思想: 不断将搜索区间折半,直到找到目标或区间为空。
  3. 超高效率: 时间复杂度 O(log n),空间复杂度 O(1) (迭代版)。
  4. 细节魔鬼: 注意循环条件 (low <= high) 和边界更新 (mid + 1, mid - 1)。
  5. 应用广泛: 从简单查找特定值到寻找复杂条件的边界。

二分查找虽然简单,但它是很多复杂算法和数据结构的基础。深刻理解它,能让你在解决问题时多一个强有力的工具。是不是瞬间感觉自己的算法武器库又升级了?


好啦,老铁们,关于二分查找的分享就到这里!希望大家都能掌握这个面试中的常客,项目中的效率尖兵。如果觉得阿扩讲得还行,点赞、收藏、转发支持一下呗!

下一章,我们将进入图的世界,聊聊图的遍历算法之一——广度优先搜索 (BFS),看看它是如何像水波纹一样层层推进,解决迷宫等问题的。精彩继续,不要走开哦!

有任何问题或者想深入探讨的,评论区见!阿扩等着你们!🚀

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

杨小扩

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值