题目描述
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
请必须使用时间复杂度为 O(log n) 的算法。
示例 1:
输入: nums = [1,3,5,6], target = 5
输出: 2
示例 2:
输入: nums = [1,3,5,6], target = 2
输出: 1
示例 3:
输入: nums = [1,3,5,6], target = 7
输出: 4
解题思路总览
| 方法 | 核心思想 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|---|
| 二分查找(标准版) | 每次排除一半,逐步逼近目标 | O(log n) | O(1) | 最常用,需掌握 |
| 二分查找(左边界版) | 找第一个大于等于 target 的位置 | O(log n) | O(1) | 本质与标准版相同,写法不同 |
| lower_bound 模拟 | 模拟 C++ STL lower_bound | O(log n) | O(1) | 标准库思想 |
| 直接遍历(不推荐) | 线性扫描数组 | O(n) | O(1) | 不满足题目要求,仅作对比 |
方法一:标准二分查找
代码实现
cpp
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int n = nums.size();
int l = 0, r = n - 1;
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;
}
}
return l;
}
};
算法流程图
目标:在有序数组 [1,3,5,6] 中查找 target = 2
初始状态:l = 0, r = 3
第1轮比较:
l=0, r=3, mid=1
nums[1]=3 > 2,右半部分排除
r = mid - 1 = 0
第2轮比较:
l=0, r=0, mid=0
nums[0]=1 < 2,左半部分排除
l = mid + 1 = 1
第3轮比较:
l=1, r=0
l > r,循环结束
return l = 1
结果:插入位置为 1
逐行解析
cpp
int n = nums.size();
获取数组长度 n,用于初始化右边界。
cpp
int l = 0, r = n - 1;
初始化左右边界指针:
- l 指向数组起始位置(候选插入位置的最左端)
- r 指向数组末尾位置(候选插入位置的最右端)
cpp
while (l <= r) {
循环条件是 l <= r,当 l > r 时说明搜索空间已经缩小到空集,循环终止。
cpp
int mid = l + (r - l) / 2;
计算中间位置。使用 l + (r - l) / 2 而非 (l + r) / 2,是为了防止大数相加时的整数溢出。
cpp
if (nums[mid] == target) {
return mid;
}
找到目标值,直接返回索引。
cpp
} else if (nums[mid] < target) {
l = mid + 1;
}
中间值小于目标值,说明目标值若存在,必在 mid+1, r 区间,因此将左边界右移。
cpp
} else {
r = mid - 1;
}
中间值大于目标值,说明目标值若存在,必在 l, mid-1 区间,因此将右边界左移。
cpp
return l;
循环终止时,l 的位置即为插入位置。此时 l > r,l 是第一个大于 target 的位置。
复杂度分析
| 复杂度 | 分析 |
|---|---|
| 时间 | 每次循环将区间缩小一半,最多循环 log2(n) 次 |
| 空间 | 仅使用常数个变量(n, l, r, mid),空间复杂度 O(1) |
方法二:左边界二分查找
代码实现
cpp
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int n = nums.size();
int l = 0, r = n; // 注意:右边界初始化为 n,而非 n-1
while (l < r) { // 注意:循环条件是 l < r
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid;
}
}
return l;
}
};
与方法一的对比
| 对比项 | 方法一(标准版) | 方法二(左边界版) |
|---|---|---|
| 右边界初始化 | r = n - 1 | r = n |
| 循环条件 | l <= r | l < r |
| numsmid < target 时 | l = mid + 1 | l = mid + 1 |
| numsmid >= target 时 | r = mid - 1 | r = mid |
| 返回值 | l(需理解推导) | l(直观理解:第一个 >= target 的位置) |
核心区别解释
方法二的循环条件是 l < r,不会出现 l > r 的情况。当循环结束时,l == r,此时 l 指向第一个大于等于 target 的位置,也就是插入位置。
这种写法更符合"找左边界"的思想,语义更清晰。
算法流程图
目标:在有序数组 [1,3,5,6] 中查找 target = 2
初始状态:l = 0, r = 4(n=4)
第1轮比较:
l=0, r=4, mid=2
nums[2]=5 >= 2,r = mid = 2
第2轮比较:
l=0, r=2, mid=1
nums[1]=3 >= 2,r = mid = 1
第3轮比较:
l=0, r=1, mid=0
nums[0]=1 < 2,l = mid + 1 = 1
第4轮比较:
l=1, r=1
l == r,循环结束
return l = 1
结果:插入位置为 1
逐行解析
cpp
int l = 0, r = n;
右边界初始化为 n(数组长度),表示搜索区间为 [0, n),即整个数组范围加上"插入到末尾"的可能性。
cpp
while (l < r) {
循环条件是 l < r,当 l == r 时循环终止,此时 l 指向目标位置。
cpp
int mid = l + (r - l) / 2;
计算中间位置。注意:mid 始终小于 r,因此不会发生死循环。
cpp
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid;
}
关键逻辑:
- 如果 numsmid 小于 target,说明插入位置在 mid 右侧,更新 l = mid + 1
- 如果 numsmid 大于等于 target,说明插入位置在 mid 或其左侧,更新 r = mid
cpp
return l;
循环结束时 l == r,l 指向第一个大于等于 target 的位置,即为插入位置。
复杂度分析
| 复杂度 | 分析 |
|---|---|
| 时间 | 每次循环将区间缩小一半,最多循环 log2(n) 次 |
| 空间 | 仅使用常数个变量,空间复杂度 O(1) |
方法三:模拟 lower_bound
代码实现
cpp
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
return lower_bound(nums.begin(), nums.end(), target) - nums.begin();
}
private:
int lower_bound(vector<int>::iterator begin,
vector<int>::iterator end,
int target) {
while (begin < end) {
auto mid = begin + (end - begin) / 2;
if (*mid < target) {
begin = mid + 1;
} else {
end = mid;
}
}
return begin - nums.begin();
}
};
核心思想
lower_bound 是 C++ STL 中的标准库函数,返回第一个大于等于给定值的迭代器。本方法模拟了这一实现。
复杂度分析
| 复杂度 | 分析 |
|---|---|
| 时间 | O(log n) |
| 空间 | O(1)(不含输入输出空间) |
方法四:直接遍历(不推荐)
代码实现
cpp
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
for (int i = 0; i < nums.size(); i++) {
if (nums[i] >= target) {
return i;
}
}
return nums.size();
}
};
算法流程图
目标:在有序数组 [1,3,5,6] 中查找 target = 2
遍历数组:
i=0: nums[0]=1 < 2,继续
i=1: nums[1]=3 >= 2,找到插入位置
return 1
结果:插入位置为 1
复杂度分析
| 复杂度 | 分析 |
|---|---|
| 时间 | 最坏情况遍历整个数组 O(n) |
| 空间 | O(1) |
不推荐原因
题目要求时间复杂度为 O(log n),直接遍历不满足要求。但该方法思路简单,在面试中可以作为"最直观解法"先写出,再优化为二分查找。
边界情况分析
情况1:数组为空
输入: nums = [], target = 5
分析: n = 0,l = 0,r = -1
循环不执行,直接 return l = 0
结果:正确,插入位置为 0
情况2:目标值小于所有元素
输入: nums = [1,3,5,6], target = 0
分析:
第1轮:mid=1, nums[1]=3 > 0,r=0
第2轮:mid=0, nums[0]=1 > 0,r=-1
循环结束,return l = 0
结果:正确,插入位置为 0
情况3:目标值大于所有元素
输入: nums = [1,3,5,6], target = 7
分析:
第1轮:mid=1, nums[1]=3 < 7,l=2
第2轮:mid=2, nums[2]=5 < 7,l=3
第3轮:mid=3, nums[3]=6 < 7,l=4
循环结束,l = 4
结果:正确,插入位置为 4(数组末尾)
情况4:目标值等于首元素
输入: nums = [1,3,5,6], target = 1
分析: mid=1, nums[1]=3 > 1,r=0
mid=0, nums[0]=1 == 1
结果:直接返回 0
情况5:目标值等于末元素
输入: nums = [1,3,5,6], target = 6
分析: 逐步二分,最终 nums[3] == 6
结果:直接返回 3
面试追问 FAQ
| 问题 | 回答 |
|---|---|
| 为什么返回 l 而不是 r? | 循环结束时 l > r(标准版)或 l == r(左边界版)。l 指向第一个大于目标值的位置,也就是目标值应该插入的位置 |
| 如何处理大整数溢出? | 使用 mid = l + (r - l) / 2 代替 mid = (l + r) / 2,避免相加溢出 |
| 如果数组为空会怎样? | n = 0,标准版 r = -1,循环不执行直接返回 l = 0;左边界版 r = 0,循环不执行直接返回 l = 0 |
| 如何处理重复元素? | 标准版返回任意一个等于 target 的索引;左边界版返回第一个等于 target 的位置(因为找的是 >= target 的左边界) |
| 二分查找的变形有哪些? | 查找左边界、查找右边界、搜索旋转排序数组、寻找峰值等 |
循环条件 l <= r 和 l < r 有什么区别? |
l <= r 最后会得到 l > r;l < r 最后会得到 l == r。两者都能完成二分,区别在于初始边界和返回值含义的理解 |
| 为什么左边界版本把 r 初始化为 n 而不是 n-1? | 因为搜索区间是 [0, n),即包含"插入到末尾"的情况。如果初始化为 n-1,当 target 大于所有元素时无法正确返回 n |
相关题目
| 题目 | 难度 | 核心区别 |
|---|---|---|
| 34. 在排序数组中查找元素的第一个和最后一个位置 | 困难 | 需要同时找左边界和右边界 |
| 74. 搜索二维矩阵 | 中等 | 二维矩阵的二分查找 |
| 240. 搜索二维矩阵 II | 中等 | 每行每列均递增 |
| 278. 第一个错误的版本 | 简单 | 二分查找的简单应用 |
| 704. 二分查找 | 简单 | 标准二分查找 |
| 875. 爱吃香蕉的可莉娜 | 中等 | 二分查找与单调性 |
总结
| 要点 | 说明 |
|---|---|
| 核心思想 | 利用有序数组的单调性,每次排除一半元素,将搜索范围缩小到 O(log n) |
| 关键变量 | l(左边界)、r(右边界)、mid(中间位置) |
| 终止条件 | 标准版 l > r;左边界版 l == r |
| 返回值 | l 即为插入位置(第一个大于等于 target 的位置) |
| 时间复杂度 | O(log n) |
| 空间复杂度 | O(1) |
| 防溢出技巧 | 使用 mid = l + (r - l) / 2 代替 mid = (l + r) / 2 |