← 返回博客列表

一、什么是二分查找

二分查找(Binary Search)是一种在有序序列上高效定位目标的算法。它的核心思想很简单:每次取中间元素与目标比较,根据单调性排除掉一半的区间,在剩下的一半里继续找。

一句话定义
二分查找 = "猜数字游戏"——每次猜中间值,根据反馈"大了还是小了"排除一半答案。

它的前提是单调性:要么序列本身有序,要么存在某个划分点使得左半边满足某性质、右半边不满足。只要能找到这样的"二段性",就能用二分。

1.1 二分的三种常见形态

类型 问题形式 典型题目
精确查找 找等于 target 的下标 有序数组中查目标值
左边界 找第一个 ≥ target 的位置 lower_bound、插入位置
右边界 找最后一个 ≤ target 的位置 upper_bound、最大满足条件的位置
⚠ 二分最大的坑:边界
二分逻辑本身不难,但循环条件、mid 计算、区间收缩方式三者必须自洽,否则会陷入死循环或漏掉答案。本文统一采用"左闭右闭 [l, r]"区间约定,方便记忆。

二、核心原理与图解

二分查找的每一次迭代都做三件事:取中点 → 比较 → 砍半。关键是每次砍掉的"半"必须是确定不含答案的那一半,这依赖单调性来保证。

2.1 区间收缩过程

以在有序数组 [1, 3, 5, 7, 9, 11, 13, 15, 17, 19] 中查找 target = 11 为例:

二分查找区间收缩过程(target = 11) 第 1 轮:l=0, r=9, mid=4, nums[mid]=9 1 3 5 7 9 11 13 15 17 19 ↑ mid 9 < 11,target 在右半边,l = mid + 1 = 5 第 2 轮:l=5, r=9, mid=7, nums[mid]=15 1 3 5 7 9 11 13 15 17 19 ↑ mid 15 > 11,target 在左半边,r = mid - 1 = 6 第 3 轮:l=5, r=6, mid=5, nums[mid]=11 11 13 ↑ mid,命中!返回 5
图 1:二分查找 3 轮即定位 target = 11

2.2 三种边界模板对比

二分的难点在于区间约定不同,循环条件和收缩方式也不同。下面是三种主流写法的对比图:

三种二分区间约定对比 左闭右闭 [l, r] l r while (l <= r) l = mid + 1 / r = mid - 1 mid = l + (r-l)/2 左闭右开 [l, r) l r while (l < r) l = mid + 1 / r = mid mid = l + (r-l)/2 左开右开 (l, r) l r while (l + 1 < r) l = mid / r = mid mid = l + (r-l)/2 关键区别: 1. 循环条件:闭区间用 <=,开区间用 <(开区间不会取到端点,不会越界) 2. 收缩方式:闭区间 ±1,开区间只改一侧(避免漏掉或死循环) 3. mid 防溢出:l + (r-l)/2 而不是 (l+r)/2(防 l+r 溢出 int)
图 2:三种区间约定的循环条件与收缩方式

2.3 复杂度分析

⏱ 时间复杂度:O(log n)
每次区间缩小一半,n → n/2 → n/4 → ... → 1,共 log₂n 次。

💾 空间复杂度:O(1)
只用 l、r、mid 三个变量,常数空间。

三、通用模板

下面给出三种区间约定的完整模板,按需选用。建议固定一种记熟(推荐左闭右闭),不要在题目里来回切换写法。

// ============= 模板 1:左闭右闭 [l, r](推荐,最直观)=============
int binarySearchClosed(const vector<int>& nums, int target) {
    int l = 0, r = (int)nums.size() - 1;
    while (l <= r) {                  // 注意是 <=,因为 [l,r] 闭区间合法
        int mid = l + (r - l) / 2;    // 防溢出
        if (nums[mid] == target) return mid;
        else if (nums[mid] < target) l = mid + 1;
        else r = mid - 1;
    }
    return -1;  // 未找到
}

// ============= 模板 2:左闭右开 [l, r)(STL 风格)=============
int binarySearchHalfOpen(const vector<int>& nums, int target) {
    int l = 0, r = (int)nums.size();   // r 初始为 size,不取该下标
    while (l < r) {                    // 注意是 <
        int mid = l + (r - l) / 2;
        if (nums[mid] == target) return mid;
        else if (nums[mid] < target) l = mid + 1;
        else r = mid;               // 不 -1,因为 r 是开区间
    }
    return -1;
}

// ============= 模板 3:左开右开 (l, r)(适合求左右边界)=============
// 适合"找第一个满足条件的位置"或"最后一个满足条件的位置"
int lowerBound(const vector<int>& nums, int target) {
    // 找第一个 >= target 的下标
    int l = -1, r = (int)nums.size();  // 开区间,l=-1, r=n
    while (l + 1 < r) {               // 区间内至少还有一个元素
        int mid = l + (r - l) / 2;
        if (nums[mid] < target) l = mid;  // 把 mid 留在左开区间
        else r = mid;                      // nums[mid] >= target,mid 可能是答案
    }
    return r;  // r 就是第一个 >= target 的下标
}
模板三要点
1. 循环条件与区间约定配套——闭区间用 l <= r,开区间用 l < r 或 l+1 < r
2. mid 防溢出——永远用 l + (r-l)/2,不要用 (l+r)/2(l+r 可能超过 INT_MAX)
3. 求左边界用 lower_bound 思路——不要找到等于 target 就返回,而是继续往左压

四、示例一:在有序数组中查找目标值

题目(基础):给定一个升序数组 nums 和目标值 target,返回 target 在数组中的下标,不存在则返回 -1。

4.1 思路与图解

这是二分最基础的形态:精确查找。每次比较 nums[mid] 与 target,相等就返回 mid,不等就根据大小关系砍掉一半。

精确二分:每次比较 mid 与 target 砍掉一半 nums[mid] 与 target 的三种关系: nums[mid] == target → 命中,返回 mid nums[mid] < target → target 在右半边,l = mid+1 nums[mid] > target → target 在左半边,r = mid-1
图 3:精确二分的三种分支决策

4.2 C++ 代码

#include <vector>
using namespace std;

// 在升序数组中查找 target,返回下标,找不到返回 -1
int search(const vector<int>& nums, int target) {
    int l = 0, r = (int)nums.size() - 1;
    while (l <= r) {
        int mid = l + (r - l) / 2;     // 防溢出写法
        if (nums[mid] == target) {
            return mid;                  // 命中
        } else if (nums[mid] < target) {
            l = mid + 1;                  // target 在右半边
        } else {
            r = mid - 1;                  // target 在左半边
        }
    }
    return -1;  // 没找到
}
💡 一个常被忽略的细节
r = (int)nums.size() - 1——当 nums 为空时,size() - 1 会变成一个巨大的无符号数(因为 size() 返回 size_t 是无符号的)。所以一定要强转 int,或先判空。

五、示例二:搜索旋转排序数组(LeetCode 33)

题目:一个升序数组在某个下标处被旋转(例如 [0,1,2,4,5,6,7] 旋转成 [4,5,6,7,0,1,2])。给定旋转后的数组,查找 target,返回下标,不存在返回 -1。要求 O(log n)。

5.1 思路:先判断哪半边有序

旋转数组的难点在于整体无序但局部有序。关键观察:取 mid 后,至少有一半是单调的。先判断左半 [l, mid] 是否有序(nums[l] <= nums[mid]),再判断 target 是否落在有序的那半边,据此决定收缩方向。

旋转数组二分:先判断哪半边有序 示例:nums = [4, 5, 6, 7, 0, 1, 2],target = 0 4 5 6 7 0 1 2 ↑l ↑r 第 1 轮:l=0, r=6, mid=3, nums[mid]=7 nums[l]=4 <= nums[mid]=7 → 左半 [4,5,6,7] 有序 target=0 不在 [4,7] 范围内 → 去右半找:l = mid+1 = 4 第 2 轮:l=4, r=6, mid=5, nums[mid]=1 nums[l]=0 <= nums[mid]=1 → 左半 [0,1] 有序 target=0 在 [0,1] 范围内 → 去左半找:r = mid-1 = 4 第 3 轮:l=4, r=4, mid=4, nums[mid]=0 == target → 返回 4
图 4:旋转数组二分——每轮先判断左半是否有序

5.2 C++ 代码

#include <vector>
using namespace std;

int search(vector<int>& nums, int target) {
    int l = 0, r = (int)nums.size() - 1;
    while (l <= r) {
        int mid = l + (r - l) / 2;
        if (nums[mid] == target) return mid;

        // 关键:判断左半 [l, mid] 是否有序
        if (nums[l] <= nums[mid]) {
            // 左半有序,看 target 是否落在左半
            if (nums[l] <= target && target < nums[mid]) {
                r = mid - 1;   // 在左半
            } else {
                l = mid + 1;   // 在右半
            }
        } else {
            // 右半 [mid, r] 有序
            if (nums[mid] < target && target <= nums[r]) {
                l = mid + 1;   // 在右半
            } else {
                r = mid - 1;   // 在左半
            }
        }
    }
    return -1;
}
💡 两个易错点
1. 判断左半有序用 nums[l] <= nums[mid]——注意有等号,因为 l == mid 时(区间只剩两个元素)也要算有序
2. 判断 target 落在左半用 nums[l] <= target && target < nums[mid]——两端是否取等要仔细,左边闭右边开

六、示例三:寻找两个正序数组的中位数(LeetCode 4)

题目:给定两个大小分别为 m 和 n 的正序数组 nums1 和 nums2,求它们合并后的中位数。要求时间复杂度 O(log(m+n))。

6.1 思路:二分较短数组的分割点

暴力做法是合并后取中位数,O(m+n)。要 O(log) 必须二分。核心思想是:在两个数组上各画一条分割线,使得左半部分元素总数 = (m+n+1)/2,且左半最大值 ≤ 右半最小值。这样中位数就直接来自分割线两侧。

两个正序数组求中位数:找分割线 示例:nums1 = [1, 3, 5, 7],nums2 = [2, 4, 6, 8, 9] nums1: 1 3 i 5 7 nums2: 2 4 6 j 8 9 左半(共 5 个): 1, 3, 2, 4, 6 → 最大值 maxLeft = 6 右半(共 4 个): 5, 7, 8, 9 → 最小值 minRight = 5 问题:6 > 5,分割线不合法! → nums1 分割线太靠右,需要把 i 左移(缩小 i,增大 j) 最终合法分割:i=2, j=3 → maxLeft=4, minRight=5 → 中位数=4.5
图 5:双数组中位数——二分较短数组的分割位置

6.2 C++ 代码

#include <vector>
#include <algorithm>
#include <climits>
using namespace std;

double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
    // 保证 nums1 是较短的那个,这样二分次数是 O(log(min(m,n)))
    if (nums1.size() > nums2.size()) {
        return findMedianSortedArrays(nums2, nums1);
    }

    int m = nums1.size(), n = nums2.size();
    int totalLeft = (m + n + 1) / 2;  // 左半部分元素总数

    // 在 nums1 的 [0, m] 范围内二分找分割点 i
    int l = 0, r = m;
    while (l <= r) {
        int i = l + (r - l) / 2;       // nums1 分割点:前 i 个进左半
        int j = totalLeft - i;          // nums2 分割点:前 j 个进左半

        // 取分割线两侧的四个关键值(处理边界)
        int nums1LeftMax  = (i == 0) ? INT_MIN : nums1[i - 1];
        int nums1RightMin = (i == m) ? INT_MAX : nums1[i];
        int nums2LeftMax  = (j == 0) ? INT_MIN : nums2[j - 1];
        int nums2RightMin = (j == n) ? INT_MAX : nums2[j];

        if (nums1LeftMax <= nums2RightMin && nums2LeftMax <= nums1RightMin) {
            // 找到合法分割
            if ((m + n) % 2 == 1) {
                return (double)max(nums1LeftMax, nums2LeftMax);
            } else {
                return (max(nums1LeftMax, nums2LeftMax)
                      + min(nums1RightMin, nums2RightMin)) / 2.0;
            }
        } else if (nums1LeftMax > nums2RightMin) {
            r = i - 1;  // nums1 分得太靠右,往左找
        } else {
            l = i + 1;  // nums2LeftMax > nums1RightMin,往右找
        }
    }
    return 0.0;  // 不会走到
}
⚠ 这题的几个关键技巧
1. 始终二分较短数组——保证时间复杂度 O(log(min(m,n))),且 j = totalLeft - i 不会越界
2. 用 INT_MIN / INT_MAX 处理边界——分割线在数组最左或最右时,"左半最大"和"右半最小"需要哨兵
3. 合法性条件——nums1LeftMax <= nums2RightMin && nums2LeftMax <= nums1RightMin,缺一不可

七、总结与适用场景

二分查找核心要点

维度 要点
本质 利用单调性/二段性,每次排除一半区间
时间复杂度 O(log n)——每次区间缩小一半
空间复杂度 O(1)——迭代版只需常数变量
识别信号 有序数组查找、求"最大化的最小值/最小化的最大值"、O(log n) 要求
常见变体 精确查找、左右边界、旋转数组、二分答案、双数组分割

典型题目清单

🎯 记忆口诀
"有序二分先判段,mid 比较砍一半;闭区用等开区不,防溢写法记心间。"
第一句讲前提是单调/二段性,第二句讲核心动作,第三句讲循环条件差异,第四句讲 mid 防溢出。掌握这些,二分就稳了。