C++ 力扣 704.二分查找 基础二分查找 题解 每日一题
文章目录
- 二分查找:从基础原理到代码实现
- 二分查找的特点:细节是坑,学会是宝
- 算法重点:原理不只是“有序”,模板要懂不要背
- 题目描述:LeetCode 704. 二分查找
- 为什么这道题值得弄懂?
- 为什么可以用二分查找?
- 暴力算法解法
- 二分查找解法
- 核心逻辑:三种情况的处理
- 二分查找什么时候结束?
- 为什么二分查找一定是对的?
- 时间复杂度
- 代码
- 为什么是二分不是三分、四分?
- 细节:这些坑别踩
- 快速测试:你能找出这些错误吗?
- 总结+预告
这是封面原图,嘿嘿:

二分查找:从基础原理到代码实现
二分查找,这个在算法世界里算不上复杂却总让人在细节上栽跟头的算法,估计不少人都有过类似经历——明明原理一听就懂,上手写却总写出死循环,要么就是边界条件处理得一塌糊涂。但只要真正摸透了它的规律,就会发现它其实是个“只要学会就简单”的典型,今天咱们就借着LeetCode 704.二分查找这道基础题,把它的来龙去脉说清楚。
二分查找的特点:细节是坑,学会是宝
为啥二分查找总让人觉得“看着简单写着难”?核心就是细节太多:左边界是left
还是left+1
?右边界该初始化成nums.size()
还是nums.size()-1
?循环条件用left < right
还是left <= right
?这些小地方稍不注意,要么死循环,要么漏查元素。
但它的优点也很突出:时间复杂度是O(log n)
,比起暴力遍历的O(n)
,在数据量大的时候效率天差地别。比如要在100万个元素里找一个数,暴力遍历最多可能查100万次,而二分查找最多只要20次(因为2^20≈100万)——这就是它能成为面试高频考点的原因。
算法重点:原理不只是“有序”,模板要懂不要背
1.原理:不只是“有序”,更是“二段性”
「核心」
二分查找的本质不是“有序”,而是数组具有 “二段性” ——简单说就是:能找到一个“判断条件”,把数组分成两部分,一部分一定满足条件,另一部分一定不满足,这样就能通过一次判断排除一半元素。
比如在书架上找一本《Python编程》,书架是按书名首字母排序的。你随便抽一本中间的书,比如《Java编程》(首字母J),发现它在P的左边,那你就知道《Python编程》一定在右边——这就是生活中的“二段性”。
回到这道题,数组是升序的,“判断条件”就可以是“元素是否小于target”:左边的元素都小于target,右边的元素都大于等于target(或者反过来)。正是因为有了这种“二段性”,我们才能每次拿中间元素和target比,然后果断排除左边或右边的一半,不用逐个遍历。
2.模板:理解逻辑比死记代码重要
二分查找确实有“模板”,但千万别死背——就像手里拿着卡塞尔装备部塞给你的屠龙的武器,却忘了怎么用,那还不如不用。常见的模板有三种:
- 朴素二分查找(今天这道题用的就是这个):适合找“唯一存在的元素”,简单但有局限性;
- 查找左边界:适合找“元素第一次出现的位置”;
- 查找右边界:适合找“元素最后一次出现的位置”。
后两种更万能,细节也更多,咱们明天讲LeetCode 34题的时候再细聊,今天先把最基础的“朴素二分”吃透。
题目描述:LeetCode 704. 二分查找
题目链接:二分查找
题目描述:
示例 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
提示:
1.你可以假设 nums 中的所有元素是不重复的。
2.n 将在 [1, 10000]之间。
3.nums 的每个元素都将在 [-9999, 9999]之间。
为什么这道题值得弄懂?
这题是二分查找的“入门第一课”,看似简单,却藏着二分的核心逻辑:如何通过“二段性”缩小范围,如何处理边界条件,如何避免死循环。把这道题吃透了,后面学左边界、右边界查找时,就能少走很多弯路。
为什么可以用二分查找?
刚才提到了“二段性”,这里再具体说一下。这道题里,数组是升序的,假设我们随便选一个中间元素nums[mid]
,和target
比一下,会出现三种情况:
- 如果
nums[mid] == target
:直接找到答案,返回mid; - 如果
nums[mid] > target
:因为数组升序,所以mid
右边的元素肯定都比target
大,不用看了,下次只查左边; - 如果
nums[mid] < target
:同理,mid
左边的元素肯定都比target
小,下次只查右边。
你看,通过“中间元素和target的大小关系”这个条件,我们每次都能把查找范围缩小一半——这就是“二段性”的体现,也是二分查找能做到O(log n)
的根本原因。
暴力算法解法
既然题目要求O(log n)
,那肯定不能用暴力,但咱们还是先说说暴力解法,对比一下就能更直观感受到二分的优势。
暴力解法很简单:从头到尾遍历数组,逐个比较元素和target
。如果找到相等的,就返回下标;遍历完都没找到,就返回-1。代码大概长这样:
int search(vector<int>& nums, int target) {for (int i = 0; i < nums.size(); i++) {if (nums[i] == target) {return i;}}return -1;
}
这代码肯定能跑通,但时间复杂度是O(n)
——如果数组有10000个元素,最坏情况要循环10000次。现在对两个方法的时间复杂度没有太多概念没有关系,后面我们会详细说到
二分查找解法
核心逻辑:三种情况的处理
刚才其实已经说了核心思路:每次取中间元素mid
,和target
比,然后根据结果缩小范围。具体来说:
- 初始化左右边界:
left = 0
,right = nums.size() - 1
(因为数组下标从0开始,最后一个元素下标是size-1
); - 循环查找:只要
left <= right
(注意这里是“<=”,后面说原因),就计算中间下标mid
; - 比较
nums[mid]
和target
:- 相等:找到目标,记录下标,跳出循环;
nums[mid] > target
:说明目标在左边,把右边界移到mid - 1
(因为mid
已经查过了,不用再考虑);nums[mid] < target
:说明目标在右边,把左边界移到mid + 1
(同理,mid
不用再考虑);
- 如果循环结束都没找到,返回-1。
二分查找什么时候结束?
可能有人会想:为什么循环条件不能用left < right
?比如数组只剩一个元素时,left
和right
相等,这时候left < right
不成立,循环就结束了,那这个元素不就漏查了吗?
对!就是这个原因——比如数组[5]
,target
是5:初始left=0
,right=0
,left <= right
成立,进去计算mid=0
,发现nums[mid]==target
,返回0——正确。
如果target
是3,数组还是[5]
:第一次循环mid=0
,nums[mid] > target
,所以right=mid-1=-1
。这时候left=0
,right=-1
,left > right
,循环结束,返回-1——也正确。
为什么二分查找一定是对的?
这道题里,数组是升序的,单调性非常明确。只要数组是单调的,那“中间元素和target的大小关系”就一定能准确划分“左边全小/右边全大”(或反过来),不会出现“漏查”的情况。每次缩小范围都是“安全”的,所以最终一定能找到目标(如果存在),或者正确判断不存在。
时间复杂度
二分查找每次都把查找范围缩小一半,比如初始范围是n个元素,第一次查完剩n/2,第二次剩n/4,……,直到范围缩小到0。这个过程就像“2的多少次方等于n”,也就是log₂n次,所以时间复杂度是O(log n)
。
对比O(n)
和O(log n)
:
- 如果n=10000,
O(n)
最多查10000次,O(log n)
最多查14次; - 如果n=1e8(1亿),
O(n)
要查1亿次,O(log n)
只要27次; - 题目里举的例子更夸张:2^32≈4e9,
O(n)
要查4e9次,O(log n)
只要32次——这就是O(log n)
的恐怖效率。
代码
下面是我写的代码,结合注释咱们再捋一遍细节:
class Solution {
public:int search(vector<int>& nums, int target) {// 初始化左边界为0,右边界为数组最后一个元素的下标int right = nums.size() - 1, left = 0;// 用于记录结果,默认-1(没找到)int ret = -1;// 循环条件:left <= right(确保所有可能的位置都查过)while (left <= right) { // 🌟 闭区间循环条件!别漏了"="// 计算中间下标:用left + (right - left)/2代替(left+right)/2,避免溢出int middle = left + (right - left) / 2; // 🌟 防溢出!别写成(right+left)/2// 如果中间元素等于target,找到目标,记录下标并跳出循环if (nums[middle] == target) {ret = middle;break;}// 如果中间元素大于target,说明目标在左边,右边界左移到middle-1else if (nums[middle] > target) {right = middle - 1;}// 如果中间元素小于target,说明目标在右边,左边界右移到middle+1else {left = middle + 1;}}return ret;}
};
这里有个细节必须提:计算middle
的时候,为什么用left + (right - left)/2
而不是(left + right)/2
?
📌 记住:计算mid永远用 left + (right - left)/2,不用(right+left)/2!
两者数学结果相同,但前者能避免left和right过大时的整数溢出(比如left=230,right=230时,right+left会超INT_MAX)。
为什么是二分不是三分、四分?
有人可能会想:既然二分能缩小一半范围,那三分、四分是不是更快?理论上每次缩小更多范围,次数应该更少?
其实不一定。咱们先写个三分查找的例子感受下:
// 三分查找示例(针对升序数组找target)
int ternarySearch(vector<int>& nums, int target) {int left = 0, right = nums.size() - 1;while (left <= right) {// 把范围分成三份,找两个中间点int mid1 = left + (right - left) / 3;int mid2 = right - (right - left) / 3;if (nums[mid1] == target) return mid1;if (nums[mid2] == target) return mid2;// 根据target位置缩小范围if (target < nums[mid1]) {right = mid1 - 1;} else if (target > nums[mid2]) {left = mid2 + 1;} else {left = mid1 + 1;right = mid2 - 1;}}return -1;
}
四分查找原理类似,就是分的段更多,中间点更多。
但为什么实际中几乎没人用三分、四分?因为:
- 时间复杂度差距不大:二分是
O(log₂n)
,三分是O(log₃n)
,四分是O(log₄n)
。但log₂n ≈ 1.58log₃n ≈ 2log₄n,差距很小。比如n=1e6,二分要20次,三分只要12次,四分只要10次——次数少了,但每次循环里的操作变多了(三分要算两个中间点,判断两次); - 代码复杂度上升:分的段越多,边界条件越复杂,越容易出错,维护成本高;
- 实际效率未必更高:虽然次数少,但每次循环的计算、判断步骤多,整体耗时可能反而比二分更长。
咱们可以写个简单的程序测试下(用随机数组+多次查找计时):
#include <iostream>
#include <vector>
#include <random>
#include <chrono>using namespace std;// 二分查找
int binarySearch(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) return mid;else if (nums[mid] > target) right = mid - 1;else left = mid + 1;}return -1;
}// 三分查找
int ternarySearch(vector<int>& nums, int target) {int left = 0, right = nums.size() - 1;while (left <= right) {int mid1 = left + (right - left) / 3;int mid2 = right - (right - left) / 3;if (nums[mid1] == target) return mid1;if (nums[mid2] == target) return mid2;if (target < nums[mid1]) right = mid1 - 1;else if (target > nums[mid2]) left = mid2 + 1;else {left = mid1 + 1;right = mid2 - 1;}}return -1;
}int main() {// 生成一个100万个元素的升序数组int n = 1000000;vector<int> nums(n);for (int i = 0; i < n; i++) {nums[i] = i;}// 随机生成1000个目标值(确保在数组范围内)random_device rd;mt19937 gen(rd());uniform_int_distribution<> dist(0, n-1);vector<int> targets(1000);for (int i = 0; i < 1000; i++) {targets[i] = dist(gen);}// 测试二分查找时间auto start = chrono::high_resolution_clock::now();for (int t : targets) {binarySearch(nums, t);}auto end = chrono::high_resolution_clock::now();chrono::duration<double> binaryTime = end - start;cout << "二分查找总时间:" << binaryTime.count() << "秒" << endl;// 测试三分查找时间start = chrono::high_resolution_clock::now();for (int t : targets) {ternarySearch(nums, t);}end = chrono::high_resolution_clock::now();chrono::duration<double> ternaryTime = end - start;cout << "三分查找总时间:" << ternaryTime.count() << "秒" << endl;return 0;
}
我跑了几次,二分查找总时间大概在0.0002秒左右,三分查找大概在0.0004秒左右——反而更慢。所以除非是极特殊的场景,否则二分查找是性价比最高的选择。
细节:这些坑别踩
常见问题 | 正确做法 | 错误案例(为什么错) |
---|---|---|
右边界初始化 | right = nums.size() - 1 | right = nums.size() (可能导致下标越界) |
mid计算 | left + (right - left)/2 | (left+right)/2 (left/right过大时溢出) |
循环条件 | left <= right | left < right (会漏掉left==right时的元素) |
三个点联动起来记:“闭区间初始化(右边界取尾下标)+ 安全算 mid + 循环到相等”,二分查找的边界问题基本就绕不开了
快速测试:你能找出这些错误吗?
int search(vector<int>& nums, int target) {int left = 0, right = nums.size(); // 错误1while (left < right) { // 错误2int mid = (left + right) / 2; // 错误3if (nums[mid] == target) return mid;else if (nums[mid] > target) right = mid - 1;else left = mid + 1;}return -1;
}
答案:
- right应初始化为
nums.size()-1
- 循环条件应是
left <= right
- mid计算应是
left + (right - left)/2
总结+预告
今天我们从“二段性”这个核心点出发,拆解了二分查找的基础逻辑,通过LeetCode 704题实现了朴素二分查找的代码,也踩了右边界初始化、mid
计算溢出这些常见的“坑”。其实二分查找的本质就是“用条件划分范围,逐步缩小查找空间”,只要抓住这个核心,再复杂的变形也能捋清楚。
不过今天的题目里,数组元素是“不重复”的,所以找到target
后直接返回即可。但如果数组里有重复元素,比如[1,2,2,3]
,要找2
第一次出现的位置或者最后一次出现的位置,朴素二分就不够用了——这就需要用到我们之前提到的“左边界查找”和“右边界查找”模板。
明天要一起研究的是 LeetCode 34题:在排序数组中查找元素的第一个和最后一个位置,有个小问题可以先想想:如果数组是[1,2,2,2,3]
,target=2
,你觉得“左边界”和“右边界”分别是多少?用今天的朴素二分查找,能直接找到吗?为什么?明天我们就用这个例子拆解“左边界查找”的逻辑~