当前位置: 首页 > news >正文

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. 二分查找

题目链接:二分查找

题目描述:
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target  ,写一个函数搜索 nums 中的 target,如果 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

提示:
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比,然后根据结果缩小范围。具体来说:

  1. 初始化左右边界:left = 0right = nums.size() - 1(因为数组下标从0开始,最后一个元素下标是size-1);
  2. 循环查找:只要left <= right(注意这里是“<=”,后面说原因),就计算中间下标mid
  3. 比较nums[mid]target
    • 相等:找到目标,记录下标,跳出循环;
    • nums[mid] > target:说明目标在左边,把右边界移到mid - 1(因为mid已经查过了,不用再考虑);
    • nums[mid] < target:说明目标在右边,把左边界移到mid + 1(同理,mid不用再考虑);
  4. 如果循环结束都没找到,返回-1。

二分查找什么时候结束?

可能有人会想:为什么循环条件不能用left < right?比如数组只剩一个元素时,leftright相等,这时候left < right不成立,循环就结束了,那这个元素不就漏查了吗?

对!就是这个原因——比如数组[5]target是5:初始left=0right=0left <= right成立,进去计算mid=0,发现nums[mid]==target,返回0——正确。

如果target是3,数组还是[5]:第一次循环mid=0nums[mid] > target,所以right=mid-1=-1。这时候left=0right=-1left > 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() - 1right = nums.size()(可能导致下标越界)
mid计算left + (right - left)/2(left+right)/2(left/right过大时溢出)
循环条件left <= rightleft < 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;
}

答案:

  1. right应初始化为nums.size()-1
  2. 循环条件应是left <= right
  3. mid计算应是left + (right - left)/2

总结+预告

今天我们从“二段性”这个核心点出发,拆解了二分查找的基础逻辑,通过LeetCode 704题实现了朴素二分查找的代码,也踩了右边界初始化、mid计算溢出这些常见的“坑”。其实二分查找的本质就是“用条件划分范围,逐步缩小查找空间”,只要抓住这个核心,再复杂的变形也能捋清楚。

不过今天的题目里,数组元素是“不重复”的,所以找到target后直接返回即可。但如果数组里有重复元素,比如[1,2,2,3],要找2第一次出现的位置或者最后一次出现的位置,朴素二分就不够用了——这就需要用到我们之前提到的“左边界查找”和“右边界查找”模板。

明天要一起研究的是 LeetCode 34题:在排序数组中查找元素的第一个和最后一个位置,有个小问题可以先想想:如果数组是[1,2,2,2,3]target=2,你觉得“左边界”和“右边界”分别是多少?用今天的朴素二分查找,能直接找到吗?为什么?明天我们就用这个例子拆解“左边界查找”的逻辑~

http://www.dtcms.com/a/352792.html

相关文章:

  • Https之(四)国密GMTLS
  • 【Redis#8】Redis 数据结构 -- Zset 类型
  • 改造thinkphp6的命令行工具和分批次导出大量数据
  • GTCB:引领金融革命,打造数字经济时代标杆
  • 【js】加密库sha.js 严重漏洞速查
  • UTXO 模型及扩展模型
  • 香港数字资产交易市场蓬勃发展,监管与创新并驾齐驱
  • 完整实验命令解析:从集群搭建到负载均衡配置(2)
  • 记录使用ruoyi-flowable开发部署中出现的问题以及解决方法(二)
  • 电脑开机显示器不亮
  • 智能安防:以AI重塑安全新边界
  • 欧盟《人工智能法案》生效一年主要实施进展概览(一)
  • docker-runc not installed on system
  • 【科研绘图系列】R语言在海洋生态学数据可视化中的应用:以浮游植物叶绿素和初级生产力为例
  • Kafka 4.0 兼容性矩阵解读、升级顺序与降级边界
  • [特殊字符]论一个 bug 如何经过千难万险占领线上
  • 大数据毕业设计选题推荐-基于大数据的城镇居民食品消费量数据分析与可视化系统-Hadoop-Spark-数据可视化-BigData
  • electron应用开发:命令npm install electron的执行逻辑
  • 搜狗拼音输入法的一个bug
  • 解锁Java分布式魔法:CAP与BASE的奇幻冒险
  • 如何安装 mysql-installer-community-8.0.21.0.tar.gz(Linux 详细教程附安装包下载)​
  • 配置ipv6
  • UE5蓝图接口的创建和使用方法
  • 【C语言强化训练16天】--从基础到进阶的蜕变之旅:Day14
  • 在 Ubuntu 系统上安装 MySQL
  • KDMS V4 重磅升级,智能迁移赋能国产化替代!
  • Ubuntu下MySQL、MongoDB与Redis:从安装到协同的完整指南
  • Hive中的with子句
  • 学习游戏制作记录(存档点和丢失货币的保存以及敌人的货币掉落)8.27
  • 【OpenGL】LearnOpenGL学习笔记16 - 帧缓冲(FBO)、渲染缓冲(RBO)