尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

二分查找与移除元素:双指针边界处理与数组原地操作实战

二分查找与移除元素:双指针边界处理与数组原地操作实战 题目拿到手里第一反应是又是烂大街的入门题但真把这俩放在一起练过的人都知道水挺深。二分查找看着就几行代码调边界能调到怀疑人生移除元素看着简单双指针的推导过程没想透的话换个题目照样抓瞎。这两个题恰好卡在“看得懂题解”和“自己写得对”的分水岭上也是面试里最高频的送分题和失分题。我见过太多人刷题两个月二分查找还是靠背模板移除元素只会暴力两层循环。不是不努力是没把背后的套路拆开揉碎。这篇就把二分查找的边界逻辑、移除元素的原地操作原理、双指针怎么从暴力解一步步推出来全给你捋清楚。适合刚接触算法的初学者也适合准备面试但边界老翻车的选手。看完能直接上手写也能顺手解决“数组区间最大值”“有序数组去重”这类变体题。1. 整体设计与思路拆解1.1 二分查找的定位练的是边界感不是背模板二分查找解决的核心问题非常单一在有序数组里找一个目标值。听起来简单但它是整个算法体系里第一个要求你“精确控制循环不变量”的题目。所谓循环不变量就是每轮循环开始前你的查找区间一定是符合某个约定的整个算法基于这个约定一步步收缩直到区间为空。实际写的时候90%的人第一次都会写出死循环或者漏掉边界元素。为什么因为大家默认“数组下标”是一个模糊的东西写while (left right)还是while (left right)全靠运气。这不是智商问题是缺少一套严格的区间思考方式。我后面会详细拆。二分查找的工程价值也远超刷题本身。它在有序数据检索、数据库索引的B树查找、算法竞赛里的最大化最小值问题、求方程近似解等场景里都是地基。你甚至可以把它理解成“通过比较有序信息把搜索空间砍半”的通用思维模型。想通这一点后面学二分答案、三分查找都会顺畅很多。1.2 移除元素的本质理解数组的“假删除”第二个题目是“移除元素”给定一个数组nums和一个值val要求原地移除所有等于val的元素返回移除后数组的新长度。难点在于“原地”——不申请额外空间而且数组在内存里是连续的一段空间你不能真正“删掉”某个位置只能把后面的元素往前面覆盖。这就是数组和链表的本质区别。链表删除节点是改指针数组删除元素是搬家。很多初学者会下意识用erase、splice这类语言自带方法但那些方法底层也是O(n)的移动而且题目往往禁止。手动实现移除元素的真正考验是你怎么用最少的遍历次数、最少的移动次数把不等于val的元素“挤”到数组前面来。这个题目最精彩的解法是双指针。双指针不是一个具体算法而是一种“用两个游标配合完成一趟遍历”的思维工具后面在有序数组去重、移动零、链表找环、三数之和里全是它。所以移除元素这道题表面考数组操作实际考的是你能不能从暴力解里抽象出双指针模型。2. 二分查找核心细节与实操要点2.1 区间定义左闭右闭还是左闭右开二分查找所有边界问题的根源就是区间定义不统一。写之前必须想清楚一件事你维护的查找区间是[left, right]还是[left, right)这两个写法都有大量拥护者没有谁对谁错但你自己必须从一而终。我推荐新手从“左闭右闭”入手也就是[left, right]理由很直接它最符合人的直觉两个端点都能取到不用记“右开区间为什么mid要加一”这种反直觉操作。写左闭右闭区间的核心约定有三条初始区间是[0, nums.length - 1]因为数组下标0到n-1都能取。循环条件是while (left right)因为当left right时区间里还有一个元素不能退出。收缩区间时如果nums[mid] target则right mid - 1如果nums[mid] target则left mid 1。因为mid已经检查过了必须从区间里剔除。对应代码长这样Cint binarySearch(vectorint nums, int target) { int left 0; int right nums.size() - 1; // 左闭右闭 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; }这里有个必须啰嗦的细节mid (left right) / 2在left和right都很大时可能整数溢出所以写成left (right - left) / 2更安全。这个习惯在Java、C里都必须养成Python不会溢出但面试官看到你能主动写这个式子印象分会不一样。再看左闭右开[left, right)的写法。核心变化是right初始是nums.size()循环条件是left right收缩时如果nums[mid] targetright mid不是mid - 1因为右侧开区间不包含rightmid作为新的右边界正好被排除在下一轮区间外。int binarySearch(vectorint nums, int target) { int left 0; int right nums.size(); // 左闭右开 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; } return -1; }这两套写法拿到同一个用例[1, 2, 3, 4, 5]找3结果一模一样。但搜索范围的语义完全不同。我实际教学的经验是新手统一学左闭右闭先写对一百遍再去理解左闭右开这样脑子里不会打架。如果你在LeeCode刷题时会看到有的题解写while (l r)有的写while (l r)别慌先看它初始化r的时候有没有减一有减一多半是闭区间没减一多半是开区间。抓住这个观察点任何模板都能快速读懂。2.2 三种常见写法与记忆锚点实际使用中二分查找主要有三个变体不只是“找一个值”那么简单标准查找找目标值存在返回下标不存在返回-1。就是上面写的版本。找左边界找第一个大于等于target的位置。典型场景是“有序数组里有多少个小于target的元素”“插入位置”。找右边界找第一个大于target的位置或者最后一个等于target的位置。拿“左边界”举例最经典的应用是LeetCode 35搜索插入位置。要求在一个有序数组中找到target若存在返回下标不存在返回它应该插入的位置。这个题用标准查找的变体int searchInsert(vectorint 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) left mid 1; else right mid - 1; } return left; // 插入位置就是left }为什么最后返回left因为循环退出时left right而left恰好停在“第一个大于等于target”的位置。比如数组[1, 3, 5, 6]target2时走一遍流程mid1位置的值3比2大right变0mid0位置的值1比2小left变1此时left1right0循环退出left正好是2应该插入的位置。找左右边界这类变体的记忆锚点可以这样理解标准查找是“遇到相等的就返回”左边界是“遇到相等的也不急着返回继续往左压缩最后left停在哪哪就是第一个满足条件的位置”。最笨但也最稳的记忆方式是把“第一个大于等于target的位置”翻译成“二分查找加一点贪心”每次发现mid满足条件就记录结果并收缩右边界不满足就收缩左边界。2.3 二分查找的现场推演与自测用例写二分查找的代码不难难在验证自己的边界。我每写完一个二分固定拿下面几组用例测空数组比如[]要保证不崩。单元素数组[5]找5能返回0找6返回-1。两个元素[2, 4]找2、找4、找3三种结果。目标在开头和结尾比如[1, 2, 3, 4, 5]找1和找5。数组里有重复值比如[1, 2, 2, 2, 3]找2看你想要哪个下标。新手最容易忽略的是“单元素数组”和“目标在边界”这两类。很多人的代码在数组长度为1时直接死循环因为left 0right 0如果循环条件是left right压根不进循环返回-1看似没错但找5时数组[5]也会返回-1这就错得很隐蔽。所以写完一定要手动跑一遍边界用例别只测中间值。我之前还踩过一个更隐蔽的坑用Python写二分时直接mid (left right) // 2这个不会溢出但如果你是在while循环里每次重新计算mid一定要记得mid是整数别写成浮点数去比较。还有在JS里数组大时(left right)可能超过安全整数范围同样要用left ((right - left) 1)。这些细节在本地跑小数据发现不了一上大数据就崩。提示二分查找的循环不变量比代码本身更重要。写完代码盯住三个点自查初始化是否满足区间约定、循环退出时区间是否确实空了、每次收缩是否排除掉了mid。三个点全对代码基本不会有边界bug。3. 数组中移除元素实操过程与核心环节实现3.1 暴力解法先让代码跑起来再谈优化移除元素最直白的思路是遍历数组遇到等于val的元素就把后面所有元素往前移一位然后把数组长度减一。因为数组是连续存储的这个“前移”操作天然就是用内层循环逐个赋值。int removeElement(vectorint nums, int val) { int n nums.size(); for (int i 0; i n; i) { if (nums[i] val) { for (int j i; j n - 1; j) { nums[j] nums[j 1]; } i--; // 当前位置被后面元素填充必须重新检查 n--; // 长度减一 } } return n; }这个解法的时间复杂度是O(n²)但它的价值在于让你看清“移除元素”这件事在数组层面到底做了什么覆盖、缩长、回退指针。我建议所有新手先把这个版本写一遍感受一下那段i--和n--有多容易忘。忘了i--连续两个val相邻时会漏删忘了n--返回长度不对。暴力解法还有一个变体不移动整个数组而是遇到val时把当前元素和最后一个元素交换再缩长。这样单次删除是O(1)但会打乱元素顺序。这个思路后面会演进成“相向双指针”先埋个伏笔。3.2 快慢双指针一趟遍历完成的推导过程暴力解法的瓶颈在于每次删除都触发一次全量移动而很多移动是重复劳动。比如数组[1, 2, 3, 4, 5]要删除3暴力法会把4、5各移一次。要是删多个元素后面的元素可能被移动好几遍。快慢双指针的思路是换个角度看问题我不关心“删了哪些”只关心“留下来的元素按原顺序排到前面”。既然数组覆盖是免不了的那就让每个留下来的元素恰好被写一次。具体做法维护两个指针slow和fastfast负责探路遍历每个位置slow负责记录“下一个留下来的元素应该放在哪”。fast每遇到一个不等于val的元素就把它复制到nums[slow]位置然后slow加一fast遇到等于val的元素直接跳过。int removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } return slow; }这个代码简洁到让人怀疑是不是真的对。用[3, 2, 2, 3]删3演示fast0nums[0]3等于val跳过slow仍为0。fast1nums[1]2不等于valnums[0]2slow变1。fast2nums[2]2不等于valnums[1]2slow变2。fast3nums[3]3等于val跳过。返回slow2数组前两位是[2, 2]正确。这个解法的关键洞察是slow永远指向“新数组的尾部”fast永远指向“旧数组的当前检查位”。新数组的元素全部来自旧数组中不等于val的元素顺序天然保持。中间覆盖旧元素完全无所谓因为那些被覆盖的位置要么已经检查过要么是等于val要被淘汰的。我在实际讲这个解法的时候喜欢让学员先画一张双指针的轨迹图把数组画成格子slow和fast分别是两个箭头标出每轮循环后两个箭头的位置。画完三四个用例双指针的理解就焊死在脑子里了。这也解释了为什么数组去重、移动零这类题都是同一个套路——它们都是“有选择地把前面的元素搬到后面去”。注意快慢双指针返回的是新数组长度但数组后几位还残留着旧数据。比如上面的例子返回2后nums[2]和nums[3]仍然是2和3。这在算法题里没关系因为题目只要求处理前k位但如果你在工程代码里做类似操作记得补一个截断或者清空处理否则会产生脏数据。3.3 相向双指针顺序不重要时的极致优化快慢双指针虽然只要一趟遍历但每次覆盖都是一次赋值。还有没有更省的办法有——前提是题目不要求保持原顺序。LeetCode 27就明确说“元素的顺序可以改变”这时候可以用相向双指针把要删除的元素直接和后面的元素交换减少赋值次数。思路是左指针left从0出发右指针right从末尾出发。左边遇到等于val的元素就把nums[left]和nums[right]交换或者说直接用right位置的值覆盖left位置right左移左边遇到不等于val的元素left右移。左右指针相遇时就是新数组的边界。int removeElement(vectorint nums, int val) { int left 0, right nums.size() - 1; while (left right) { if (nums[left] val) { nums[left] nums[right]; right--; // 注意left不急着加因为换过来的值也可能等于val } else { left; } } return left; }这个写法比快慢指针少了很多赋值操作因为把等于val的元素扔到后面是不用管顺序的反正最后返回前k位后面全是垃圾。但它的陷阱也很明显nums[left]被覆盖后新换来的值可能还是val所以left不能像快慢指针那样直接加一而是要留在原地再判断一次。这个细节我见过至少几十个人栽过。什么时候用快慢什么时候用相向我的经验是题目要求保持相对顺序比如移除元素后再判断是否为回文数组用快慢题目明确说顺序无所谓想极致省操作用相向。还有一类变体比如把0全部移到数组末尾本质上也是相向双指针的思路但要求保持非零元素相对顺序就退化成双指针加一个交换操作。这些变体做多了会形成条件反射。4. 常见问题与排查技巧实录4.1 二分查找的死循环与错位问题问题1写while (left right)却用right mid - 1收缩会导致什么典型场景数组[1, 2, 3]target1left0right2mid1nums[1]2 1right0。此时left0right0left right不成立循环退出返回-1但1明明在数组里。这就是区间不匹配导致的漏检。用左闭右开却套了闭区间的收缩逻辑必然出事。问题2mid永远不变导致死循环。如果出现left mid这种更新方式且left和right相差1时mid会一直等于left区间永远无法收缩。比如left0right1mid0如果nums[mid] target你写left mid那left永远为0死循环。正确的写法是left mid 1或者把mid改成mid (left right 1) / 2即上取整。问题3找左右边界时返回left还是right搞混。这个最简单有效的验证方法是拿空数组和单元素数组跑一下看返回值和你的预期是否一致。我在面过的人里有一半会在“返回left还是left1”这里卡壳。实际上只要你锁死一套区间约定左边界就是“第一个满足条件的位置”返回left右边界就是“最后一个满足条件的位置”返回left - 1。下面整理一张速查表写法初始化right循环条件收缩右边界收缩左边界死循环风险闭区间size()-1left rightright mid - 1left mid 1低开区间size()left rightright midleft mid 1低上取整size()-1left rightright mid - 1left mid需要配合记忆第三行是查找“最后一个等于target”等场景的上取整写法mid (left right 1) / 2left和right差1时mid取右值避免死循环。4.2 移除元素的越界与残留问题问题1暴力解法忘了i--导致的连续漏删。数组[1, 2, 2, 3]删2i1时删掉第一个2数组变为[1, 2, 3]没执行i--的话i继续走i2指向3第二个2永远没被检查。处理办法是删除元素后i--回退一格这也是暴力解法的标志性动作。问题2快慢指针返回长度正确但后半段残留旧值导致测试时打印整个数组出现幻觉。比如[3, 2, 2, 3]删3返回2如果打印整个数组会看到[2, 2, 2, 3]。很多人以为算法错了其实没理解“新长度之外的数据无意义”这个约定。处理办法是只打印前k个元素或者先把后段清空。问题3相向双指针里left和right的相遇条件写错。用left right还是left right会直接影响最后一个元素的处理。如果用left right当left等于right时循环退出此时左指针指向的元素没有被判断是否等于val。稳妥做法是left right让相遇点也参与一次判断。我第一次写相向指针时就是用left right导致单元素数组删val时返回1而不是0排查了半天才发现是循环条件的问题。4.3 面试实战经验与工程延伸面试官考这两个题想看的不是你能不能默写模板而是三个能力边界意识、复杂度分析、变体迁移能力。我建议的面试节奏是先确认题目细节数组是否有序能否改变元素顺序返回新长度还是新数组这些一句话就能问完问清楚能省大量debug时间。主动说出你的解法和复杂度比如“我先说暴力解O(n²)然后立刻优化到双指针O(n)空间复杂度O(1)”。主动补测试用例面试官一般不会拦你说“我拿几个用例验证一下”这反而是加分项。优先说空数组、单元素、全等于val、全不等于val、val在首尾这五类。工程延伸方面二分查找思想在Java的Arrays.binarySearch、C的lower_bound/upper_bound、Python的bisect模块里都有直接对应物。理解裸题后建议去刷“在排序数组中查找元素的第一个和最后一个位置”LeetCode 34这是一道把左右边界一起考透的题做完它二分查找相关变体基本就稳了。移除元素的工程场景比想象中多日志系统清理无效字段、数据处理管道过滤异常值、游戏开发里道具列表移除已使用物品这些都涉及“原地整理数组/列表”。双指针也不只在数组里用链表里找中间节点快慢指针、删除链表倒数第N个节点前后指针全是同一个思想。所以这两个题看着基础其实是在给后续一整条算法主线打地基。5. 写在最后的一点私货这两个题目我前前后后给上百人讲过也看着不少学员从“背题解”到“自己推出来”的质变。我自己最大的体会是算法这玩意儿纯靠看没有用必须自己把每个边界条件亲手画一遍、写一遍、跑一遍。二分查找的所有坑几乎都来自区间定义没有预先想清楚移除元素的所有坑几乎都来自没有理解数组“覆盖即删除”的特性。把这层窗户纸捅破这两个题就再也不会成为你的拦路虎。最后分享一个我自查的小习惯每次写完一个算法题我强制自己说出“为什么这个循环能终止”和“为什么这个返回值是对的”。这两句话能解释清楚代码基本不可能有大问题解释不清哪怕测试全过也建议再多想想。面试考场上最怕的不是写错是写对了但说不出道理那比不会写更减分。
返回列表