数据结构与算法精讲 · 第12篇 三语言实现:Python / Java / C++
一、引言
🤔 思考:在一个包含 100 万个有序数字的数组中查找某个目标值,最多需要多少次比较?
答案是 20 次。没错,只需要 20 次------这就是二分查找的魔力。
二分查找是有序数组中的最强武器。它将线性搜索的 O(n) 时间复杂度降到了 O(log n),在数据量越大时优势越明显:
| 数据规模 | 线性搜索(最坏) | 二分查找(最坏) |
|---|---|---|
| 100 | 100 次 | 7 次 |
| 10,000 | 10,000 次 | 14 次 |
| 1,000,000 | 1,000,000 次 | 20 次 |
| 1,000,000,000 | 10 亿次 | 30 次 |
但二分查找有一个著名的"坑"------边界条件。
🤔 思考:为什么同样是二分查找,有的人写出来 bug-free,有的人写出来总是死循环或越界?
答案在于区间定义 。闭区间 [left, right] 和左闭右开 [left, right) 两种区间定义,决定了循环条件、指针移动方式完全不同。理解了这一点,二分查找的所有变体都能一通百通。
本文将围绕两个经典题目展开:
-
704. 二分查找(Easy)------ 标准二分查找,掌握基本功
-
34. 在排序数组中查找元素的第一个和最后一个位置(Medium)------ 边界查找,掌握左右边界模板
二、二分查找基础
2.1 什么是二分查找?
二分查找(Binary Search) 是一种在 有序数组 中查找目标值的算法。它的核心思想是:每次将搜索范围缩小一半。
算法步骤:
-
确定搜索区间
[left, right] -
计算中间位置
mid = left + (right - left) / 2 -
比较
nums[mid]与target:-
如果
nums[mid] == target:找到目标,返回mid -
如果
nums[mid] < target:目标在右半部分,left = mid + 1 -
如果
nums[mid] > target:目标在左半部分,right = mid - 1
-
-
重复步骤 2-3,直到区间为空
2.2 时间复杂度
-
最好情况:O(1)------第一次就命中
-
最坏情况:O(log n)------需要一直搜索到区间为空
-
空间复杂度:O(1)------仅需常数级额外空间
2.3 前提条件
-
数组有序(升序或降序)
-
随机访问(数组支持 O(1) 的索引访问)
2.4 两种区间定义
二分查找的"循环不变量"是区间定义。区间的定义决定了代码的写法。
| 区间类型 | 初始化 | 循环条件 | 右区间更新 | 左区间更新 |
|---|---|---|---|---|
| 闭区间 left, right | right = len-1 | left <= right | right = mid-1 | left = mid+1 |
| 左闭右开 [left, right) | right = len | left < right | right = mid | left = mid+1 |
🤔 思考 :为什么闭区间用
left <= right,而左闭右开用left < right?因为前者区间内包含right指向的元素,而后者不包含------当left == right时,左闭右开区间已经为空了。
2.5 二分查找过程图解
以下图展示在有序数组 nums = [-1, 0, 3, 5, 9, 12] 中查找 target = 9 的过程(闭区间版本):

三、704. 二分查找(Easy)
3.1 题目描述
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
示例 1:
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
示例 2:
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1
3.2 解法思路
这是最基础的二分查找,使用闭区间 [left, right] 实现。
核心逻辑:
-
初始化
left = 0, right = len(nums) - 1 -
循环条件
left <= right(区间非空) -
每次比较
nums[mid]与target,缩小搜索范围
🤔 思考 :为什么
mid的计算要用left + (right - left) / 2而不是(left + right) / 2?因为当left + right很大时可能溢出整型范围。left + (right - left) / 2这种写法避免了溢出风险。
3.3 代码实现
Python
class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) - 1 # 闭区间 [left, right]
while left <= right: # 区间非空
mid = left + (right - left) // 2 # 防止溢出
if nums[mid] == target:
return mid # 找到目标,返回索引
elif nums[mid] < target:
left = mid + 1 # 目标在右半部分
else:
right = mid - 1 # 目标在左半部分
return -1 # 未找到
Java
class Solution {
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1; // 闭区间 [left, right]
while (left <= right) { // 区间非空
int mid = left + (right - left) / 2; // 防止溢出
if (nums[mid] == target) {
return mid; // 找到目标,返回索引
} else if (nums[mid] < target) {
left = mid + 1; // 目标在右半部分
} else {
right = mid - 1; // 目标在左半部分
}
}
return -1; // 未找到
}
}
C++
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1; // 闭区间 [left, right]
while (left <= right) { // 区间非空
int mid = left + (right - left) / 2; // 防止溢出
if (nums[mid] == target) {
return mid; // 找到目标,返回索引
} else if (nums[mid] < target) {
left = mid + 1; // 目标在右半部分
} else {
right = mid - 1; // 目标在左半部分
}
}
return -1; // 未找到
}
};
3.4 左闭右开版本对比
作为对比,下面是左闭右开 [left, right) 的实现:
Python
class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) # 左闭右开 [left, right)
while left < right: # left == right 时区间为空
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1 # mid 已经检查过,左区间从 mid+1 开始
else:
right = mid # 注意:右区间是 [left, mid),mid 是开区间,所以 right = mid
return -1
🤔 思考 :对比闭区间版本,左闭右开版本有两个关键区别:①
right初始化为len(nums)而不是len(nums)-1;② 循环条件为left < right而不是left <= right;③ 更新right时用right = mid而不是right = mid - 1。这三个区别都由"区间定义"决定,理解了区间定义就不会混淆。
3.5 复杂度分析
| 维度 | 分析 |
|---|---|
| 时间复杂度 | O(log n),n 为数组长度。每次搜索范围缩小一半 |
| 空间复杂度 | O(1),仅使用常数级变量 |
3.6 易错点
-
循环条件混淆 :闭区间用
left <= right,左闭右开用left < right。写反了会导致死循环或漏掉元素 -
mid 计算溢出 :
(left + right) / 2在 left 和 right 都很大时可能溢出,应使用left + (right - left) / 2 -
区间更新遗漏 :更新区间时,
left = mid + 1和right = mid - 1要确保 mid 已经被排除 -
数组未排序:二分查找的前提是数组有序,如果数组无序结果会出错
3.7 查找过程图解
在 nums = [-1, 0, 3, 5, 9, 12] 中查找 target = 9 的完整过程:

四、34. 在排序数组中查找元素的第一个和最后一个位置(Medium)
4.1 题目描述
给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。
示例 1:
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]
示例 2:
输入: nums = [5,7,7,8,8,10], target = 6
输出: [-1,-1]
示例 3:
输入: nums = [], target = 0
输出: [-1,-1]
4.2 解法一:找到目标后左右收缩(不推荐)
思路:先用二分查找找到任意一个目标值的位置,然后向左线性扫描找左边界,向右线性扫描找右边界。
问题:最坏情况下(数组中所有元素都等于 target),线性扫描的复杂度为 O(n),不满足题目 O(log n) 的要求。
🤔 思考 :为什么解法一不推荐?因为当数组中有大量重复元素时,线性扫描会退化到 O(n)。例如
nums = [8,8,8,...,8],找到中间后向左向右扫描 n/2 次,总复杂度 O(n)。
4.3 解法二:分离查找左右边界模板(推荐,O(log n))
核心思想 :使用两次二分查找,分别寻找左边界和右边界。关键在于------找到 target 后不立即返回,而是继续收缩搜索区间。
左边界查找
-
当
nums[mid] == target时,不返回,而是将right = mid - 1,继续在左半部分搜索 -
最终退出循环时,
left指向第一个等于 target 的位置(如果存在)
右边界查找
-
当
nums[mid] == target时,不返回,而是将left = mid + 1,继续在右半部分搜索 -
最终退出循环时,
right指向最后一个等于 target 的位置(如果存在)
🤔 思考 :为什么左边界查找后
left指向第一个目标值?因为当nums[mid] == target时,我们让right = mid - 1,把所有等于 target 的元素都排除在搜索区间之外,最后left会停在第一个 target 的位置。同理,右边界查找让left = mid + 1,最后right停在最后一个 target 的位置。
4.4 代码实现
Python
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
def find_left() -> int:
"""查找左边界"""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1
else: # nums[mid] >= target
# 当 nums[mid] == target 时,继续向左搜索
right = mid - 1
# 退出循环后,left 指向第一个 >= target 的位置
return left
def find_right() -> int:
"""查找右边界"""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] <= target:
# 当 nums[mid] == target 时,继续向右搜索
left = mid + 1
else: # nums[mid] > target
right = mid - 1
# 退出循环后,right 指向最后一个 <= target 的位置
return right
left_idx = find_left()
right_idx = find_right()
# 检查是否找到 target
if left_idx <= right_idx and left_idx < len(nums) and nums[left_idx] == target:
return [left_idx, right_idx]
return [-1, -1]
Java
class Solution {
public int[] searchRange(int[] nums, int target) {
int leftIdx = findLeft(nums, target);
int rightIdx = findRight(nums, target);
// 检查是否找到 target
if (leftIdx <= rightIdx && leftIdx < nums.length && nums[leftIdx] == target) {
return new int[]{leftIdx, rightIdx};
}
return new int[]{-1, -1};
}
private int findLeft(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else { // nums[mid] >= target
// 当 nums[mid] == target 时,继续向左搜索
right = mid - 1;
}
}
// 退出循环后,left 指向第一个 >= target 的位置
return left;
}
private int findRight(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) {
// 当 nums[mid] == target 时,继续向右搜索
left = mid + 1;
} else { // nums[mid] > target
right = mid - 1;
}
}
// 退出循环后,right 指向最后一个 <= target 的位置
return right;
}
}
C++
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
int leftIdx = findLeft(nums, target);
int rightIdx = findRight(nums, target);
// 检查是否找到 target
if (leftIdx <= rightIdx && leftIdx < nums.size() && nums[leftIdx] == target) {
return {leftIdx, rightIdx};
}
return {-1, -1};
}
private:
int findLeft(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else { // nums[mid] >= target
// 当 nums[mid] == target 时,继续向左搜索
right = mid - 1;
}
}
// 退出循环后,left 指向第一个 >= target 的位置
return left;
}
int findRight(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) {
// 当 nums[mid] == target 时,继续向右搜索
left = mid + 1;
} else { // nums[mid] > target
right = mid - 1;
}
}
// 退出循环后,right 指向最后一个 <= target 的位置
return right;
}
};
4.5 复杂度分析
| 维度 | 分析 |
|---|---|
| 时间复杂度 | O(log n),两次二分查找,每次 O(log n) |
| 空间复杂度 | O(1),仅使用常数级变量 |
4.6 易错点
-
边界判断 :查找完成后,需要验证
leftIdx <= rightIdx且nums[leftIdx] == target,确保 target 确实存在 -
越界问题 :
leftIdx可能等于len(nums)(当 target 大于所有元素时),rightIdx可能等于-1(当 target 小于所有元素时) -
左边界查找条件 :
nums[mid] < target时向右,else(包括==)时向左------等于 target 时继续向左收缩 -
右边界查找条件 :
nums[mid] <= target时向右(包括等于),else时向左------等于 target 时继续向右收缩 -
空数组处理 :
nums为空时,findLeft返回 0,findRight返回 -1,leftIdx > rightIdx,触发[-1, -1]逻辑
4.7 查找左右边界过程图解
在 nums = [5, 7, 7, 8, 8, 10] 中查找 target = 8 的左右边界:

五、查找边界模板总结
基于闭区间 [left, right] 和左闭右开 [left, right),我们可以整理出四种二分查找模板。
5.1 闭区间模板 left, right
模板一:标准二分查找(704)
def binary_search(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
模板二:查找左边界(34 左边界)
def find_left(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1
else: # >= target,继续向左
right = mid - 1
return left # 第一个 >= target 的位置
模板三:查找右边界(34 右边界)
def find_right(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] <= target:
left = mid + 1 # <= target,继续向右
else:
right = mid - 1
return right # 最后一个 <= target 的位置
5.2 左闭右开模板 [left, right)
模板四:左闭右开标准二分
def binary_search_left_open(nums, target):
left, right = 0, len(nums) # 注意 right = len(nums)
while left < right: # 注意条件
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid # 注意:right = mid 而不是 mid - 1
return -1
5.3 模板对比
| 功能 | 闭区间 | 左闭右开 |
|---|---|---|
| 初始化 | left=0, right=len-1 | left=0, right=len |
| 循环条件 | left <= right | left < right |
| 右区间更新 | right = mid - 1 | right = mid |
| 左区间更新 | left = mid + 1 | left = mid + 1 |
| 查找左边界 | 返回 left | 返回 left |
| 查找右边界 | 返回 right | 返回 right - 1 |
🤔 思考 :建议初学者先掌握闭区间 版本。因为闭区间更直观------
left和right都指向实际存在的元素,left <= right的循环条件也更容易理解。在闭区间版本熟练之后,再学习左闭右开版本。
六、对比总结
| 对比维度 | 704. 二分查找 | 34. 查找首个和最后一个位置 |
|---|---|---|
| 难度 | ⭐ Easy | ⭐⭐ Medium |
| 重复元素 | 数组无重复元素 | 数组可能有重复元素 |
| 返回值 | 单个索引(或 -1) | 左右边界 [left, right](或 -1, -1) |
| 核心技巧 | 标准二分查找 | 左右边界查找模板 |
| 边界处理 | 找到 target 立即返回 | 找到 target 后继续收缩区间 |
| 循环条件 | left <= right |
left <= right |
| 时间复杂度 | O(log n) | O(log n) |
| 空间复杂度 | O(1) | O(1) |
| 面试频率 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
核心感悟:
-
704 题是二分查找的基本功------理解区间定义、循环条件、指针移动
-
34 题是二分查找的进阶应用------处理重复元素时的边界查找
-
两题共同体现了二分查找的核心思想:每次排除一半不可能的空间
七、家族题
掌握了二分查找的基础和边界查找后,以下题目可以一通百通:
69. x 的平方根(Easy)
-
题目:计算并返回 x 的平方根,只保留整数部分
-
解法 :在
[0, x]范围内二分查找,找到最大的mid使得mid * mid <= x -
核心思想 :二分查找搜索空间------不一定要在数组上二分,可以在答案范围上二分
-
关键点 :注意
mid * mid可能溢出,使用long或mid <= x / mid
162. 寻找峰值(Medium)
-
题目:在一个无序数组中找到一个峰值元素(比左右邻居都大),返回任意一个峰值
-
解法 :二分查找------如果
nums[mid] < nums[mid+1],峰值在右边;否则在左边 -
核心思想 :二分查找不一定需要有序数组------只要有"二段性"就可以二分
-
关键点:峰值一定存在(数组两端假设为 -∞),利用爬坡方向缩小搜索范围
33. 搜索旋转排序数组(Medium)
-
题目 :在一个旋转过的有序数组中搜索目标值(如
[4,5,6,7,0,1,2]) -
解法:二分查找------先判断 mid 在左半段还是右半段,再决定搜索方向
-
核心思想 :旋转数组只有一半是有序的------利用有序部分判断 target 是否在其中
-
关键点 :判断
nums[left] <= nums[mid]确定左半段是否有序
278. 第一个错误的版本(Easy)
-
题目:查找第一个导致错误的版本(类似找左边界)
-
解法 :二分查找------如果
isBadVersion(mid)为 true,继续向左搜索;否则向右 -
核心思想 :二分查找找第一个满足条件的值------本质是左边界查找
-
关键点:这是 34 题左边界模板的直接应用
家族题难度梯度

八、结语
🤔 思考:回顾一下,二分查找看似简单,为什么能让无数面试者翻车?
二分查找的核心价值在于------它展示了"分而治之"思想的威力:每次排除一半不可能的空间,将 O(n) 的问题降到 O(log n)。
三个关键记忆点:
-
循环不变量 :区间的定义决定了代码------闭区间
[left, right]vs 左闭右开[left, right) -
边界查找 :找到 target 后不立即返回,继续收缩区间------
right = mid - 1找左边界,left = mid + 1找右边界 -
二段性:二分查找的核心不是"有序",而是"二段性"------只要能找到某个条件将数组分成两部分,就可以用二分
刷题建议:
-
先手写 3 遍 704 题的闭区间版本,直到能闭着眼写出来
-
再手写 3 遍 34 题的左右边界查找模板
-
然后刷家族题:278(左边界)→ 69(答案范围二分)→ 162(无序二分)→ 33(旋转数组二分)
-
面试时,先明确告诉面试官"我用的是闭区间版本",然后写出代码
下一篇预告 :双指针专题------从两数之和到盛最多水的容器,全面掌握双指针技巧。