Files

11 KiB
Raw Permalink Blame History

C++算法讲义: 二分查找

**1. 引言 **

  • 问题引入:猜数字游戏 从 1 到 1000 猜一个数字,你会怎么猜?

    • 线性查找 (老实人):从 1 开始逐个猜。最坏情况猜 1000 次,$O(N)$。
    • 二分查找 (聪明人):每次猜中间,根据反馈排除一半区间。最坏情况只需 10 次 (2^{10} > 1000),$O(\log N)$。 当数据量达到 42 亿时,线性查找需 42 亿次,而二分查找仅需 32 次,效率差距天壤之别!
  • 核心前提 二分查找之所以高效,是因为它有一个前提:数据必须具有单调性(即有序)。如果数据是乱序的,折半策略将失效。就像在乱七八糟的纸堆里找东西,你不可能“折半”查找。

  • 学习价值 掌握二分查找是 C++ 算法学习者的必备技能:

    • 高效搜索:在大量有序数据中快速定位,提升程序性能。
    • 解决变体问题:很多复杂问题通过转化,也能应用二分查找思想。
    • 面试高频考点:它不仅考察代码实现,更考察对边界和变体问题的处理能力。
  • 学习目标 学完此部分,你应该能:

    1. 理解二分查找的分治原理和对数时间复杂度。
    2. 掌握数据有序性这一核心前提。
    3. 熟练编写经典二分查找模板,并理解其闭区间 l, r、l <= r 循环条件和 mid 计算方式。
    4. 掌握修改模板,解决查找第一个/最后一个出现的目标值等变体问题。
    5. 识别死循环陷阱,并正确处理 mid 计算与边界更新关系。

2. 核心思想

2.1 二分查找的定义

二分查找(Binary Search)是一种基于分治策略的高效搜索算法。它利用数据的有序性,每轮将搜索范围减半,直到找到目标元素或搜索区间为空。

类比词典查单词:打开中间页,如果单词靠前就往前翻,靠后就往后翻,每次都排除一半无关的页码。

2.2 基本原理:三指针与区间缩减

在有序数组 arr 中查找 target,我们使用三个“指针”:

  • l (左边界):搜索区间的头部索引。
  • r (右边界):搜索区间的尾部索引。
  • mid (中间点):mid = l + (r - l) / 2。

核心步骤(循环执行):

  1. 计算 mid:mid = l + (r - l) / 2 (向下取整)。这种写法能有效防止 l + r 溢出。
  2. 比较 arr[mid] 与 target:
    • arr[mid] < target:目标在 mid 的右边,更新 l = mid + 1。
    • arr[mid] > target:目标在 mid 的左边,更新 r = mid - 1。
    • arr[mid] == target:找到目标,返回 mid。
2.3 经典二分查找模板 (闭区间 [l, r])

我们推荐使用闭区间 [l, r] 的写法,因为它清晰直观,不易出错。

// 在有序数组 nums 中查找 target,找到返回下标,没找到返回 -1
int bs(const vector<int>& nums, int target) {
    int l = 0;
    int r = nums.size() - 1; // r 是最后一个元素的下标,[l, r] 闭区间

    // 循环条件:当 l <= r 时,表示搜索区间 [l, r] 依然有效
    // 即使 l == r,区间中仍有一个元素,需要检查
    while (l <= r) {      
        int mid = l + (r - l) / 2; // 防溢出写法,向下取整

        if (nums[mid] == target) {
            return mid;          // 找到了,直接返回
        } else if (nums[mid] < target) {
            l = mid + 1;      // 目标在右半边,新的左边界变为 mid + 1
        } else { // nums[mid] > target
            r = mid - 1;     // 目标在左半边,新的右边界变为 mid - 1
        }
    }
    // 循环结束,说明搜索区间为空(l > r),target 不存在于数组中
    return -1; 
}

代码解读要点:

  1. r = nums.size() - 1;:r 初始化为最后一个元素的下标,定义 [l, r] 为闭区间。
  2. while (l <= r):关键条件。当 l == r 时,区间中仍有 1 个元素,需要检查。
  3. l = mid + 1; 和 r = mid - 1;:当 nums[mid] 不等于 target 时,mid 肯定不是目标,所以可以直接排除 mid。

3. 实例

3.1 查找第一个出现的元素 (OJ:B0189 )

题目: 有序数组 nums 可能包含重复元素。查找 target 第一次出现的索引,未找到返回 -1。

解决思路:找到了别停,继续向左收缩! 当 nums[mid] == target 时,先记录 mid 可能是答案,然后向左 (r = mid - 1) 继续搜索,看是否存在更靠前的 target。

// 查找第一次出现 target 的索引
int bsFirst(const vector<int>& nums, int target) {
    int l = 0;           // 左边界索引
    int r = nums.size() - 1; // 右边界索引
    int ans = -1;       // 记录可能的结果

    while (l <= r) {
        int mid = l + (r - l) / 2;

        if (nums[mid] == target) {
            ans = mid;      // 记录当前索引
            r = mid - 1;    // 继续向左收缩,找更靠前的
        } else if (nums[mid] < target) {
            l = mid + 1;     // 目标在右侧
        } else { // nums[mid] > target
            r = mid - 1;    // 目标在左侧
        }
    }
    return ans; // 返回记录的第一个 target 的索引
}

流程 (查找 2 在 [1, 2, 2, 2, 3, 5, 7, 9]):

  1. l=0, r=7, mid=3 (指向第二个 2)。nums[3]==2。ans=3,r=2。
  2. l=0, r=2, mid=1 (指向第一个 2)。nums[1]==2。ans=1,r=0。
  3. l=0, r=0, mid=0 (指向 1)。nums[0]==1 < 2。l=1。
  4. l=1, r=0。l > r,循环结束。返回 ans = 1。
3.2 查找最后一个出现的元素

题目: 寻找这个数字在序列中最后一次出现的下标。

思路:与查找第一个出现的元素类似,但策略相反——找到了别停,继续向右收缩! 当 nums[mid] == target 时,先记录 mid 可能是答案,然后向右 (l = mid + 1) 继续搜索,看是否存在更靠后的 target。

// 查找最后一个出现 target 的索引
int bsLast(const vector<int>& nums, int target) {
    int l = 0;
    int r = nums.size() - 1;
    int ans = -1;

    while (l <= r) {
        int mid = l + (r - l) / 2;

        if (nums[mid] == target) {
            ans = mid;      // 记录当前索引
            l = mid + 1;    // 继续向右收缩,找更靠后的
        } else if (nums[mid] < target) {
            l = mid + 1;     // 目标在右侧
        } else { // nums[mid] > target
            r = mid - 1;    // 目标在左侧
        }
    }
    return ans;
}
3.3 区间收缩排除法 (左闭右开 [l, r))

这是一种不同风格的二分查找模板,它不是在循环中“找到就返回”,而是不断缩小区间,直到区间只剩下一个元素,最后再检查这个元素是不是我们要找的。

// 查找第一个等于或大于 target 的元素
int bsExclude(const vector<int>& nums, int target) {
    int l = 0; 
    int r = nums.size(); // [l, r) 左闭右开,r 初始化为 size
    
    // 循环条件:当 l < r 时,表示搜索区间 [l, r) 依然有效
    // 最终 l == r,指向第一个满足条件的元素或 size
    while (l < r) {
        int mid = l + (r - l) / 2; // 向下取整
        if (nums[mid] >= target) {
            r = mid; // mid 可能是答案,或者答案在 mid 左边,所以把 r 收缩到 mid (不排除 mid)
        } else { // nums[mid] < target
            l = mid + 1; // mid 太小,排除 mid,把 l 移到 mid + 1
        }                   
    }
    // 循环结束时,l == r。这个 l 就是第一个 >= target 的位置。
    // 需要后处理:检查 l 是否越界且 nums[l] 是否等于 target。
    if (l < nums.size() && nums[l] == target) {
        return l;
    } else {
        return -1;
    }
}

代码解读要点:

  1. 区间定义:[l, r) (左闭右开)。r 初始化为 nums.size()。
  2. while (l < r):当 l == r 时循环终止。
  3. if (nums[mid] >= target) r = mid;:mid 可能含有答案,因此 mid 不被排除。
  4. else l = mid + 1;:mid 肯定不是答案,因此 mid 被排除。
  5. 后处理:循环结束时 l == r,这个 l 是第一个 >= target 的位置。需要额外检查。
3.4 陷阱:二分查找的死循环

死循环常发生在区间只剩 2 个元素时 (l, l+1)。 原因:mid 的取整方向与边界更新方向不一致。

  • 如果策略是 l = mid (保留 mid 作为左边界),且 mid 是向下取整(偏向 l),那么当 r = l+1 时 mid 永远等于 l,导致 l = mid 陷入 l=l 的死循环。
  • 解决方案:
    • 当 l = mid 更新时,mid 必须向上取整:mid = l + (r - l + 1) / 2。
    • 当 r = mid 更新时,mid 必须向下取整:mid = l + (r - l) / 2。

逻辑本质: 二分查找的死循环,永远发生在区间还剩 2 个元素的时候。mid 必须跳过那个被保留的边界,去测试另一个元素,这样区间才能缩小。

示例:寻找最后一个 target,使用 [l, r) 和 l = mid 策略时,正确的 mid 计算

// 查找最后一个等于 target 的元素
int bsLastOpt(const vector<int>& nums, int target) {
    int l = 0; 
    int r = nums.size(); // [l, r) 左闭右开
    
    int lastIdx = -1; // 记录可能的最后一个 target 索引

    while (l < r) {
        // mid 向上取整,以避免 l = mid 时死循环
        int mid = l + (r - l + 1) / 2; // 注意这里 +1,让 mid 偏向右边

        if (nums[mid] <= target) { // mid 可能是答案,或答案在 mid 右边,包含 mid
            lastIdx = mid; // 记录当前可能的答案
            l = mid;       // 保留 mid,向右搜索 [mid, r)
        } else { // nums[mid] > target // mid 太大了,答案在 mid 左边,不包含 mid
            r = mid - 1; // 排除 mid,向左搜索 [l, mid-1)
        }                   
    }
    // 循环结束,检查 lastIdx 是否有效且等于 target
    if (lastIdx != -1 && nums[lastIdx] == target) {
        return lastIdx;
    } else {
        return -1;
    }
}

4. 总结

  • 要点回顾
    1. 核心:利用有序性,每次排除一半搜索空间,$O(\log N)$。
    2. 前提:数据必须有序。
    3. 经典模板 ([l, r] 闭区间):r = size - 1; while (l <= r); l = mid + 1; r = mid - 1; 找到即可返回。
    4. 变体:
      • 查找第一个:nums[mid] == target 时,ans = mid; r = mid - 1; (向左收缩)。
      • 查找最后一个:nums[mid] == target 时,ans = mid; l = mid + 1; (向右收缩)。
    5. 区间收缩排除法 ([l, r) 左闭右开):r = size; while (l < r); 最终 l == r 需后处理。
    6. 死循环:mid 取整方向需与边界更新方向一致。
      • l = mid 时,mid 向上取整 (mid = l + (r - l + 1) / 2)。
      • r = mid 时,mid 向下取整 (mid = l + (r - l) / 2)。