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

资讯详情

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

算法训练营首日复盘:二分查找与双指针的边界掌控

算法训练营首日复盘:二分查找与双指针的边界掌控 1. 训练营第一天的真实打开方式很多人以为算法训练营的第一天会直接甩出高深莫测的内容什么动规、图论、贪心一股脑全上。但真正的训练营首日几乎所有靠谱的带教都会把二分查找和双指针放在最前面。这俩题目并不是入门中最简单的但它们是最能帮你建立“算法思维框架”的两个知识点。第一天我自己的节奏是这样的上午花 40 分钟过一遍二分查找的边界推导把三种模板对着例子手动跑一遍下午开始刷双指针的经典题从暴力解出发一步一步压缩时间复杂度。整天下来的体感是——二分靠“脑子想清区间”双指针靠“手上画清移动条件”。这篇文章就是把这一天的完整经历复盘出来从模板推导、实战题目、现场踩坑到自查方法一次性打包给你。适合谁看适合刚刷 LeetCode 五十题以内、想系统搭算法框架的新手也适合面试前想快速回滚这两个高频考点的同学。别小看这块二分和双指针看着简单但能把边界一次写对、把双指针的移动理由讲清楚的人在面试中比例并不高。2. 二分查找看点不是模板而是区间不变量2.1 猜数字游戏背后的“减半”逻辑想一想小时候玩过的猜数字游戏对方心里想一个 1 到 100 的数你每次猜一个数对方告诉你“大了”还是“小了”。最笨的方法是从 1 猜到 100最聪明的方法是每次猜中间一次淘汰一半的可能性。二分查找本质就是这个过程的程序化表达前提是数据排好序。但等你真上手写代码时常常会发现思想图个乐代码却总是卡在边界。比如left 0, right n - 1还是left 0, right n循环条件是left right还是left right更新时是mid - 1还是mid。这些细节一旦组合起来就能衍生出六种以上不同写法每种似乎都对但我第一天实测下来如果一个训练营的同学把六种写法都背最后十有八九会在面试时写混。与其记模板不如抓住一个东西你选的区间形式是什么这个区间又保持不变的性质是什么。这就是我们常说的循环不变量。2.2 三种区间模板的推导与取舍以查找一个有序数组中是否存在某个值target为例先把三种最常见区间列出来区间形式初始化循环条件mid 的更新方式适用场景左闭右闭[L, R]L0, Rn-1L RR m - 1/L m 1标准查找最直观左闭右开[L, R)L0, RnL RR m/L m 1找左边界、C STL 风格左开右闭(L, R]L-1, Rn-1L RR m - 1/L m找右边界、对称美感我第一天主动要求自己把第一种写到肌肉记忆然后再去理解第二种。为什么因为左闭右闭的语义最贴近生活L和R都是有效下标查完mid后排除它所以是mid - 1和mid 1逻辑上没有模糊地带。而左闭右开区间里R是“取不到的下标”所以排除 mid 后可以直接R m不用减一。这里还藏着一个常见错误mid (L R) / 2。在数组特别大时L R可能溢出尤其是 C 和 Java 里用 int 时。推荐写法是int mid L (R - L) / 2;这个式子用减法代替加法避免了溢出同时也保证了 mid 永远偏向左边向下取整。第一天训练营里老师专门点名任何模板里mid的计算都统一用这个形式养成习惯。2.3 从查找值到查找边界的进阶用法数组中有重复元素时标准二分只能回答“有没有”但实际更常问的是找到第一个大于等于target的位置lower_bound。这个需求在 C 的 STL 里有现成函数但手写才是面试考点。// 左闭右开写法返回第一个 target 的下标 int lower_bound(vectorint nums, int target) { int L 0, R nums.size(); while (L R) { int mid L (R - L) / 2; if (nums[mid] target) { L mid 1; } else { R mid; } } return L; }写这个函数的要点在于当nums[mid] target时mid 本身可能是答案也可能是答案再往前一点的位置所以只能收缩R mid而不是R mid - 1。一旦写错就会把正确答案排除出去。这是二分里很容易暗中出错、又很难一眼看出的问题。还有个上界版本找最后一个小于等于 target 的下标需要配合左开右闭区间或者把mid改成向高位偏移mid L (R - L) / 2 1。写多了你会发现左闭右闭区间几乎推不出上界版本必须改区间这也是为什么理解多种区间很重要。2.4 二分不只是“有序数组查找”训练营首日容易产生一个误区认为只有有序数组才能用二分。实际并非如此。只要你能找到一个“单调的分界函数”把搜索空间每一步切掉一半就可以二分。比如旋转数组找最小值、求平方根、浮点数精度控制、甚至“猜版本好坏”这类问题背地里全是二分。// 求 x 的平方根整数部分 int mySqrt(int x) { int L 0, R x, ans -1; while (L R) { int mid L (R - L) / 2; if ((long long)mid * mid x) { ans mid; L mid 1; } else { R mid - 1; } } return ans; }这里注意mid * mid可能溢出 int所以必须强转 long long。这也是训练营现场很容易被忽略、然后超时或越界的一个坑。二分写法本身不难但很多边界细节比如数据范围、负数情况、重复元素都是需要单独处理的。3. 双指针从二重循环到线性扫描3.1 为什么暴力枚举会超时双指针却快拿一个经典例子来说“在一个排好序的数组中找两个数使它们和为 target”。最直接的做法是两层循环枚举所有数对时间复杂度 O(n²)。当 n 是 10⁵ 量级时运算次数大约是 10¹⁰本地跑都慢别说在线评测了。双指针的思路是先用一个指针指向最左边另一个指向最右边。两数之和如果偏大说明需要减小总和就只能把右指针向左移如果偏小就把左指针向右移。这样每一轮至少移动一个指针总共只会移动 n 次时间复杂度降到 O(n)。vectorint twoSum(vectorint nums, int target) { int L 0, R nums.size() - 1; while (L R) { int sum nums[L] nums[R]; if (sum target) return {L, R}; else if (sum target) L; else R--; } return {}; }这个题能优化成功的原因有两个数组有序 需要两数组合。数组有序让指针移动有方向性两数组合让状态可以用两端点表示。如果你已经对无序数组做排序再用双指针那整体复杂度就是排序的 O(n log n)仍然远优于双重循环。3.2 对撞指针的典型场景两数之和与回文判断两数之和是对撞指针的标准出场几乎每个训练营都会用这道题开场。只要记住“和太小动左、和太大动右”基本不会写错。但我见过很多人在改成“三数之和”时开始混乱因为需要固定一个数再对剩下的区间做双指针。这里的关键是保证去重逻辑不丢。拿三数之和来说先排序外层循环固定i内层在[i1, n-1]区间做对撞查找。每次找到一个组合后左右指针都要跳过重复值否则会输出重复答案。这个跳重的细节代码上就两行 while 循环但逻辑上很多人会忘。对撞指针另一个常考场景是判断回文串。字符串“rac ecar”这种一个指针在头、一个指针在尾比对字符并逐步向中间移动一旦不等就直接退出。这比反转字符串再比对更快也更省空间。如果题目允许删掉一个字符再判断回文那么对撞指针依然好写出现不等时分别试删左和删右。3.3 快慢指针的“跑道追及”与原地去重对撞指针是从两端往中间凑快慢指针则是一个走两步、一个走一步。最常见于链表题比如判断链表是否有环——快指针如果追上慢指针说明有环如果快指针先碰到空说明无环。bool hasCycle(ListNode* head) { if (!head || !head-next) return false; ListNode* slow head; ListNode* fast head-next; while (slow ! fast) { if (!fast || !fast-next) return false; slow slow-next; fast fast-next-next; } return true; }但快慢指针不只服务链表。在数组场景里它一样能实现“原地去重”。例如有序数组去重要求把不重复的元素放到数组前部并返回长度。用慢指针指示已保留的尾部用快指针去扫描新元素遇到不同值就把它搬到慢指针后面。int removeDuplicates(vectorint nums) { if (nums.empty()) return 0; int slow 0; for (int fast 1; fast nums.size(); fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; }这类题的常见错误是nums[fast] ! nums[slow]比较错位置比如比较nums[fast]和nums[fast-1]后又忘记 slow 的维护。第一天多写几个快慢指针题之后再看链表题会顺得多。3.4 滑动窗口同向双指针的高级形态滑动窗口本质上也是双指针只是左右指针同向移动中间夹着的区间像一个窗口。典型场景是找“无重复字符的最长子串”“长度最小的子数组”等。窗口需要满足某个条件时右指针扩张不满足时左指针收缩。// 无重复字符的最长子串 int lengthOfLongestSubstring(string s) { unordered_setchar window; int L 0, ans 0; for (int R 0; R s.size(); R) { while (window.count(s[R])) { window.erase(s[L]); L; } window.insert(s[R]); ans max(ans, R - L 1); } return ans; }这里最容易出问题的是while和if的选择。什么时候用 while 收缩左边界当左边界需要连续移动多次直到窗口重新满足条件时就必须用 while。比如上面代码里窗口内可能有一堆重复字符只删一次并不够所以必须反复收缩。如果只是“至多存在一个重复字符”这类放松条件收缩逻辑又不一样。滑动窗口的核心模板掌握后面试中遇到字符串、数组的“连续子串/子数组”类问题概率上大概率能套。它相当于把双重循环的很多无效枚举通过窗口条件直接剪掉了。4. 训练营首日实战一场 4 道题的完整复盘4.1 P1: 标准二分查找模板热身训练营第一个任务不是 LeetCode 编号题而是手写一个标准二分查找。题目描述很朴素有序无重复数组查找 target 是否存在存在返回下标否则返回 -1。这道题的作用是逼你选定一种区间模板把变量初始化、循环条件、更新方式这三个环节一次性写对。我当时选了左闭右闭区间第一遍写就撞了一个错误把R mid - 1写成了R mid。表面看没影响如果 target 恰好是nums[mid-1]正好等于目标改成R mid后来循环会继续但因为 mid 仍可能等于 R导致死循环风险。训练营老师给了一个判断死循环的方法用三个元素的数组手动人肉执行。比如[1, 3, 5]查找 5写下每一步的 L、R、mid三行下来就能发现L1, R2, mid1时如果走了L mid分支就永远停在原地。这样定位问题比自己对着屏幕发呆快得多。这个热身题的价值不在“会写”而在确认你选定的区间模板能在各种边界target 在最左边、最右边、不在数组内都保持一致的表现。后续做题我都会先把自己的模板写在草稿纸上再套题避免临时发挥。4.2 P2: lower_bound 与 upper_bound 的边界漫游第二道题是手写lower_bound第一个 target 的下标。这题比标准二分多了一点变化当nums[mid] target时不能直接返回因为左侧可能还有相等元素。所以把nums[mid] target作为移动左指针的条件其他情况都收缩右边界。我第一遍写的版本有一个隐蔽错误初始R n - 1并采用左闭右闭结果在 target 大于数组中所有元素时返回的下标是n-1而不是n。这才意识到左闭右闭本身无法表达“越界找不到”的位置强行改来判断又容易引进 if 分支。后来切换到左闭右开[L, R)统一用R n一切变得顺畅。写完这两个函数我额外用白板推了upper_bound第一个 target 的位置然后把两者相减得到区间内相等元素的个数。这个配套练习强烈推荐你也做一遍——它相当于把二分背后的查找语义打通了。4.3 P3: 两数之和 II对撞指针模板题目已按升序排列的整数数组返回两个数的下标使它们相加等于 target。注意下标从 1 开始。这道题代码量很小但训练营要求不能直接写代码先口述思路为什么双指针一定能找到答案如果数组无序这个策略还有效吗其实这是面试中常见的追问方向。训练营的做法是让同学把这个“证明”讲出来假设唯一解在 (i, j)当左指针在 i 左边、右指针在 j 右边时此时左指针对应的数小于 nums[i]右指针对应的数大于 nums[j]所以二者之和无法恰好等于 target又不满足移动条件最终左右指针会一步步收敛到 (i, j)。用这种方式学算法比背题更有用。它能帮你建立“这个算法为什么正确”的直觉而不是换个数字就蒙。4.4 P4: 合并两个有序数组从后往前双指针这是训练营里的“思维转折题”两个有序数组其中一个有足够空间容纳另一个要求合并结果放回第一个数组且不使用额外数组。常规想法是新建一个数组然后归并但题目限制了空间所以需要反向思考。从 nums1 的尾部开始放元素用三个指针p1 指向 nums1 有效数字末尾p2 指向 nums2 末尾p 指向 nums1 数组最末尾。每次比较 p1 和 p2 指向的值较大者放到 p 处。void merge(vectorint nums1, int m, vectorint nums2, int n) { int p1 m - 1, p2 n - 1, p m n - 1; while (p2 0) { if (p1 0 nums1[p1] nums2[p2]) { nums1[p--] nums1[p1--]; } else { nums1[p--] nums2[p2--]; } } }这道题最大的价值是展示双指针的方向选择如何影响写码难度。如果从前往后合并就得搬移元素复杂度退化到 O(n²)。从后往前填直接把归并过程的覆盖问题绕过去了。训练营里我跟同桌说这类题就像玩华容道——知道从哪里下手后面就很顺。4.5 当天的作业与复盘方式训练营结束时留了三道作业搜索旋转排序数组二分变形、最长回文子串双指针中心扩展、长度最小的子数组滑动窗口。每道题要求提交“思路五步”问题类型、暴力思路复杂度、优化切入点、核心模板、边界测试用例。这个方法我很推荐。第一天练的题目质量固然重要但复盘的方式更重要。把每一题压缩成五步笔记之后二刷时每道题只需一分钟就能唤醒全部记忆。我后来面试复习数据结构与算法时靠的就是这些当天的结构化笔记而不是重新翻题解。5. 首日高频问题排查从死循环到越界的实录集合训练营开始前的热身测验里我统计了同学们最容易犯的错误集中整理成一张表。这些错误不仅出现在第一天之后刷题也会反复出现建议收藏。症状常见原因排查方向解决示例二分查找死循环更新L mid且 mid 无法右移检查区间是否还有“卡住不动”的可能改用L mid 1或让 mid 向上取整二分答案错误没有处理重复元素直接返回 mid分析相等时应该收缩哪侧参考 lower_bound 模板mid 计算溢出(L R) / 2在极限数据下越界改用减法形式L (R - L) / 2快慢指针空指针未判断fast-next是否为空链表尾节点单独检查先判 !fast双指针无限循环移动条件用if而非while检查左边界是否一次移动不够窗口收缩改用 while合并数组覆盖数据从前往后 merge覆盖了原值改为从后往前填充见上文 merge 代码边界下标越界R nums.size()后用nums[R]确认区间开闭与下标关系左闭右开时避免访问R指针移动方向弄反和大于 target 时移动了左指针明确当前和偏大需要减小右移左指针会使和更大应右指针左移这里挑三个最典型的展开讲。死循环是最搞心态的。有一次第二道题我写左闭右开区间循环条件L R中间偏分支走了L mid然后mid是L (R-L)/2。当 区间只有两个元素时L mid等于没移动于是死循环。排查方式就是人肉跑一遍小数组或者干脆记忆一条铁律只要用L midmid 就必须向上取整只要用R midmid 就必须向下取整。窗口收缩用 if 还是 while。滑动窗口里如果目标条件是“窗口内最多一个重复字符”那遇到重复字符时收缩一次可能就够了但如果目标是“窗口内不能有重复字符”那就得收缩到完全没有重复为止。训练营中有不少人在无重复字符最长子串上一开始用 if结果重复出现两个以上同类字符时窗口还是含重答案自然错。合并数组覆盖问题。有一个同学在合并两个有序数组时开了新数组又复制回去通过了在线测评但被老师追问“如果空间复杂度要求 O(1) 怎么做”直接卡壳。从后往前合并这个思路建议在理解双指针时就牢牢记下不只“从头到尾”能双指针从后往前也是一种常见方向尤其在链表和数组尾部相关场景里非常好用。6. 选模板时的一些个人倾向训练营第一天结束后很多同学问到底应该固定用哪一套二分模板我的建议是标准查找用左闭右闭边界查找用左闭右开。两套就够但必须把区间不变量说清楚。左闭右闭的好处是直观每一个人都看得懂L R表示区间还有元素循环结束后L R。它的副作用是求边界类问题时需要额外处理“没找到”的语义。左闭右开的好处是R天然是排除区间的分界点数组越界下标可以直接作为 R 的初值STL 和大多数 C 库也都采用这种语义。缺点是对新手来说R m这种“不减一”写法容易让人困惑。我第一天晚上的心得是不要指望套一个万能模板解决所有二分题但也不要一会儿换一种写法。选一套作为主力另一套作为辅助所有题目先明确区间再动手比任何模板都重要。双指针这边也有同样的倾向对撞、快慢、滑动窗口三种玩法最好各自归纳一个固定套路。对撞关注“移动方向由比较结果决定”快慢关注“速度差带来的追及”滑动窗口关注“右进左出维持窗口状态”。把这三种模式在脑海中刻成模板遇到新题先判断属于哪一类再往模板里填条件效率会高很多。7. 最后分享一个边写边查的小技巧训练营现场有段时间大家写得飞快但提交前错误百出。老师教了一个办法提交前花 30 秒用三组边界用例过一遍代码逻辑。最小规模数组长度为 0 或 1你的指针和循环条件是否仍然自洽全匹配所有元素都等于 target你的查找结果是否符合语义单端越界target 小于最小值/大于最大值返回下标或索引是否越界这三组用例不需要真的跑只要在心里一行一行过就能过滤掉大部分低级错误。我在之后刷题中一直保持这个习惯LeetCode 一次通过率明显提升。第一天训练营给我最大的收获不是会了多少道题而是明白了一点算法题拼的不是灵感是对基础的掌控力。二分和双指针作为“性价比最高”的两个基础算法值得你花一整周去彻底啃透而不只是一下午。把边界、移动条件、模板背后的不变量都搞清楚之后后面的二叉树、回溯、动规都会轻松很多。
返回列表