ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

两数之和有序数组版:从暴力到双指针的算法优化之路

两数之和有序数组版:从暴力到双指针的算法优化之路 两数之和这题凡是刷过LeetCode的人基本都见过。但很多人在拿到“有序数组版”时反而犹豫了一下——不是不会做而是没想明白“有序”这个条件到底该怎么用。数据结构与算法里这类题的价值恰恰不在代码本身而在你能否看穿“有序”带来的额外信息。这篇博文就专门拆一拆“两数之和有序数组版”从暴力枚举一路讲到双指针把复杂度算清楚把边界条件讲明白顺带聊聊面试里这道题到底在考什么。适合正在刷题找工作的人、准备数据结构期末或考研的人以及想把基础算法真正吃透的初学者。1. 这道题到底在考什么——为什么有序数组值得单独开一篇1.1 “两数之和”家族从无序到有序的题目变形图谱很多人会问两数之和无序版用哈希表做有序版还能有什么新花样其实这类题目是有清晰变形脉络的。最原始的两数之和LeetCode 1给定无序数组要求找出两个数之和等于目标值经典解法是哈希表一次遍历边存边查时间复杂度O(n)空间复杂度O(n)。而两数之和有序数组版LeetCode 167同样要求找两个数但数组已经是升序排列这时哈希表依然能做但显然不是最优解。为什么说“有序”是一个大幅降低问题难度的条件因为排序让数组有了单调性。你可以从两端同时向中间逼近左边元素小右边元素大两数之和偏小时往右走偏大时往左走这就是双指针的雏形。更进一步这道题还可以扩展到三数之和、四数之和甚至“最接近目标值的三数之和”每一层变体都建立在两数之和的基础之上。如果你把两数之和有序数组版的双指针思想吃透了后面这些题几乎都是同一个套路的反复应用。很多算法教材会把这类题目叫“对撞指针”或“双指针-相向而行”但我觉得更本质的理解是有序数组给了你一个“排除区间”的能力。无序数组你只能靠哈希表去查有序数组你可以在比较大小之后放心的丢弃半边搜索空间。这种“利用数据本身特性缩小搜索范围”的思路才是面试官真正想看到的东西。1.2 有序条件带来的信息增量排序不是白送的我们不妨用生活化的例子理解一下为什么有序数组这么重要。假设你在字典里找一个单词字典是乱序的你只能一页页翻字典按字母排序你就可以直接翻到大概的位置再根据前后关系左右调整。有序数组的“两数之和”也是如此你在数组两端各站一个指针根据当前和与目标值的差距来决定移动方向。以升序数组为例左指针指向当前最小值右指针指向当前最大值。如果numbers[left] numbers[right]小于target说明以当前左指针为起点、以右指针为终点的所有组合都太小因为右指针已经是能配的最大值了那么左指针往右挪一位才有希望。反过来如果两数和大于target说明以当前右指针为终点、以左指针为起点的所有组合都太大右指针往左挪才对。每比较一次就排除掉一行或一列候选答案效率自然高。这个信息增量说白了就是四个字单调性。有了单调性你才能在“和大了”与“和小了”之间做出方向判断而不是像无序数组那样无头苍蝇一样乱找。所以这道题真正的考点是你是否知道排序所衍生的结构性优势并把它转化为具体的算法策略。这也是为什么很多面试官宁可让你做有序版也不让你做无序版——无序版背下哈希表模板就行有序版才是检验你有没有理解算法本质的试金石。2. 暴力枚举与二分优化——先把朴素解法吃透再谈双指针2.1 暴力枚举O(n²)的“笨办法”为什么也要会写我见过不少同学一上来就直奔双指针问起暴力怎么写反而磕磕绊绊。其实暴力枚举是理解整道题的起点也是面试里被追问“还有没有更优解法”时的对照基准。双指针的优化到底优化了什么优化了多少没有暴力做参照这些数字都说不清楚。暴力思路很简单枚举数组中每一个数再去它后面的所有数里找有没有合适的搭档。用Java写的话大致是public int[] twoSumBruteForce(int[] numbers, int target) { for (int i 0; i numbers.length - 1; i) { for (int j i 1; j numbers.length; j) { if (numbers[i] numbers[j] target) { return new int[]{i 1, j 1}; } } } return new int[]{-1, -1}; }这里有个细节要注意内层循环从i1开始而不是从0开始否则会出现自己加自己的情况也会出现(i,j)和(j,i)重复配对。时间复杂度方面外层循环执行n次内层循环平均执行n/2次所以总比较次数是n(n-1)/2数量级就是O(n²)。空间复杂度O(1)因为没有额外开数组或哈希表。有人可能觉得学暴力没意义但实际业务里如果数据量很小比如n小于100暴力枚举反而更好用代码短不易出错可读性也强。刷题和工程的区别就在这里不是所有场景都要追求理论最优。写一遍暴力还有个额外的好处你会在心里对“优化空间有多大”产生直觉。n是10000时n²就是1亿次运算肉眼可见的卡顿这时候你自然会去思考怎么降复杂度。2.2 二分查找优化O(n log n)的过渡方案暴力之后很多人会想到既然数组有序那遍历每个数时用二分查找去找target - numbers[i]不就行了这个思路完全正确也是从O(n²)到O(n log n)的一大步面试时作为过渡方案讲出来比直接蹦双指针更有逻辑递进感。具体做法是固定第一个数i在i1到n-1这个区间里二分搜索目标差值。注意搜索区间的起点是i1不是i也不是0因为题目要求两个数的下标不能相同而且我们要避免重复配对。Java代码大致是这样public int[] twoSumBinary(int[] numbers, int target) { for (int i 0; i numbers.length; i) { int complement target - numbers[i]; int left i 1; int right numbers.length - 1; while (left right) { int mid left (right - left) / 2; if (numbers[mid] complement) { return new int[]{i 1, mid 1}; } else if (numbers[mid] complement) { left mid 1; } else { right mid - 1; } } } return new int[]{-1, -1}; }二分查找本身的复杂度是O(log n)外层还有一个n次循环所以总复杂度是O(n log n)。空间复杂度O(1)。这个方案不算差在n很大的时候比暴力快得多而且思路直接、容易验证正确性。但它还不是最优因为有序数组提供的单调性只被用来做单点查找没有被用来做区间排除。这里我想多说一句如果你在面试里先讲了二分方案再被问到“能不能更快”你再引出双指针面试官通常会点头因为你展示的是逐步思考的过程而不是甩一个答案。算法面试的本质不是拼谁背的模板多而是拼谁能在约束条件下分析出合理的优化路径。3. 双指针解法深入拆解——有序数组的“版本答案”3.1 双指针为什么能收敛单调性证明思路双指针解法是这道题的最优解时间复杂度O(n)空间复杂度O(1)。但很多人都只是记住了“left、right--”的写法说不清楚为什么这个过程不会漏掉正确答案。我尝试用一个简单的思路来证明方便你在面试现场也能讲得清楚。假设数组是升序的答案可能是任意一对下标(l, r)满足l r且numbers[l] numbers[r] target。我们从left 0、right n - 1开始每次比较当前和与target如果当前和小于target说明numbers[left]配不上当前这个right因为right已经是区间内最大的值了和它配对都达不到target那left和任何[left1, right]区间内的数配对只会更小更不可能达到target。所以left这一整行都可以排除左指针右移一位。如果当前和大于target说明numbers[right]太大了即使配上区间内最小的left都超过target那right和任何[left, right-1]区间内的数配对只会更大都不可能等于target。所以right这一整列都可以排除右指针左移一位。这个过程可以类比成在一个从左下到右上的矩阵里搜索目标值每一轮比较都会排除一整行或一整列剩下一个更小的矩阵。最多移动n步指针就会相遇此时要么已经找到答案要么所有组合都被排除了。因为题目保证有唯一解所以双指针最终一定会停在答案的那对下标上。这里的关键点在于每步排除的候选组合里一定不包含正确答案所以不会“错过”。如果你把范围划得更细一点就会发现双指针其实是在维护一个“有可能成为答案的下标区间”区间不断缩小答案始终落在区间内直到区间里只有答案。3.2 代码实现细节与边界处理双指针版本的代码很短但细节很多。标准Java写法如下public int[] twoSum(int[] numbers, int target) { int left 0; int right numbers.length - 1; while (left right) { int sum numbers[left] numbers[right]; if (sum target) { return new int[]{left 1, right 1}; } else if (sum target) { left; } else { right--; } } return new int[]{-1, -1}; }先看循环条件。这里用的是left right不是left right。如果允许left right那么同一个位置的数会被用两次可能误判。比如numbers [1, 3, 6], target 6left right时numbers[2] numbers[2] 12不等于6但numbers [3, 3], target 6时如果left right两个3来自同一个下标虽然值等于6但下标重复了不符合题意。所以必须严格left right。再看返回结果。LeetCode 167的返回要求是下标从1开始所以要在代码里加1。很多人在这里栽过跟头返回了从0开始的下标或者返回了原数组下标再加自己额外封装。面试时一定要先确认题目要求的下标基准。最后看找不到的情况。题目保证必有解但工程上你仍然要处理找不到的情况。返回{-1, -1}是常见做法也有人返回null或空数组。我个人习惯是给一个显眼的哨兵值方便调用方判断。3.3 双指针 vs 二分 vs 哈希表三种方案的横向对比很多人学这道题的时候只知道“有序数组用双指针”却说不清楚为什么不用哈希表。这里我把三种方案放在一起对比你一看就明白。方案时间复杂度空间复杂度适用场景暴力枚举O(n²)O(1)数据量极小追求代码简单二分查找O(n log n)O(1)有序数组对时间要求不极端哈希表O(n)O(n)无序数组空间不是瓶颈双指针O(n)O(1)有序数组时间空间双优这个表格很有说服力双指针在有序数组场景下时间复杂度和哈希表并列最优空间复杂度却只有O(1)。哈希表表面上看也能做到O(n)但它在无序版本里才是必要选择在有序数组上属于“杀鸡用牛刀”还白白浪费了空间。面试时我通常这样回答如果数组无序哈希表是首选但题目给出了有序这个条件双指针能够把额外的空间省掉。实际工程里数据量大的时候O(n)空间和O(1)空间的差距可能很致命尤其是内存受限的环境比如嵌入式设备、核心服务里的热路径。这也是为什么双指针方案在面试官那里得分更高它体现了你对空间成本的敏感度。4. 从LeetCode 167到真实业务——这题在面试和工程里怎么用4.1 面试官到底想考察你什么很多面试者以为这种“简单题”就是走过场其实不然。两数之和有序数组版是面试官非常喜欢考查的基础题因为它能同时验证你的算法基础、分析能力和代码功底。你可能被问到的问题包括为什么有序数组下双指针不会漏解这个问题考的是你对单调性的理解答不出就只能说明你背了模板。如果题目要求返回所有满足条件的组合你会怎么改这会把双指针代码扩展成一个while循环加去重的版本能考出你处理重复值的能力。如果数组里有重复元素你的解法会出问题吗比如numbers [1, 2, 2, 4], target 6双指针会找到哪一对其实只要题目说返回任意一对那找到(2,4)也没错但如果要求所有组合你就得考虑在指针移动时跳过重复值。你能现场算一算时间复杂度吗这个问题看似简单实际上很考验你对循环过程的理解我会建议你把“每次移动一个指针最多移动n步”这个逻辑讲清楚。把这些想清楚再进面试室你就不只是在背诵代码而是真的有分析框架了。我自己带新人面试时最怕听到的回答就是“这题我会双指针”但问一句为什么就沉默了。4.2 实际业务场景从下标匹配到思想迁移抛开刷题不谈双指针思想在业务代码里出现的频率远超想象。最常见的是合并两个有序数组Merge Sorted Array用双指针从尾部向头部写能省掉临时数组归并排序的合并步骤也是双指针链表的快慢指针找中间节点、判断环同样是双指针。可以说双指针是除了哈希表之外最高频的算法思想之一。在两数之和这个层面上业务场景其实也不少。比如你在处理一批订单数据数据已经按金额排序需要找到两笔订单金额之和等于某个预算值这时候双指针就能直接派上用场。又比如日志系统里按时间戳排序的两条记录需要找到某个时间窗口内是否有记录组合满足特定条件本质上也是两数之和的思路。我自己在实际项目中遇到过类似需求一个风控系统里维护了按信用分排序的用户列表需要找出两个用户的信用分加起来达到某个阈值的组合用于联合授信评估。当时我第一反应就是在有序Score列表上用双指针遍历一遍搞定性能完全在可控范围内。所以别小看这道题它的思想骨架在很多看起来不相关的场景里都反复出现。4.3 进阶变体与扩展训练线路如果你想趁热打铁把这类题吃得更透我建议按下面的线路去练三数之和LeetCode 15排序后固定一个数剩下两个数用双指针逼近核心是把问题降维成“有序数组两数之和”。最接近的三数之和LeetCode 16还是排序加双指针只不过判断条件从“等于target”变成“与target的差的绝对值最小”。四数之和LeetCode 18排序后固定两个数剩下两个数双指针注意去重逻辑。有序矩阵搜索LeetCode 240在一个每行每列都递增的矩阵里找目标值从右上角开始每次排除一行或一列本质和双指针是同一套思维。这条线走完你会发现“数据结构与算法”里的很多题都是同一棵树的枝条树根是“在有序数据上利用单调性缩小搜索空间”两数之和是最简单的叶子三数之和、四数之和、搜索二维矩阵都是在此基础上不断加枝叶。刷题讲究的是建立这种联系而不是题海战术。5. 常见错误与排查心得——我实测踩过的坑5.1 下标从0开始还是从1开始这是最容易踩的坑没有之一。LeetCode 167明确要求返回的下标从1开始但很多人脑子里还停留在数组下标从0开始的惯性里直接返回left和right结果提交上去报错。我记得自己第一次做这题时也是这样明明思路全对就栽在差一错误off-by-one上。更坑的是有些题目实现里返回int[]有些返回List如果你不能确定题目要求的基准最好在代码注释里写清楚。我自己的习惯是写完代码先检查两处一处是while条件一处是返回值有没有1。这两个位置只要都对了这道题基本就稳了。5.2 指针移动条件的理解偏差第二个常见错误是把指针移动方向搞反。我见过不少同学写出这样的逻辑sum target时right--sum target时left。方向一错整个搜索过程就乱套了。为什么sum target时要移动左指针因为升序数组里左指针扩大可以让和变大右指针已经是最大值了向左移只会让和更小。反过来sum target时要移动右指针因为右指针向左可以让和变小左指针向右只会让和更大。一句话记忆法和太小就加码左指针右移和太大就减码右指针左移。理解了这个逻辑你就不需要背代码了每次遇到题都能自己推导出来。5.3 重复值带来的困惑题目保证有唯一解且没说数组里没有重复值。比如numbers [1, 2, 2, 3, 5], target 4双指针会先算156太大右指针左移到3再算134找到答案。这里的重复值2其实完全没有影响因为双指针会直接跳过它。但如果题目改成“返回所有组合”重复值就会带来问题。还是上面的例子你要找所有等于4的组合除了(1,3)之外还有没有别的两个2加起来是4但下标不同算不算两对这个需要和面试官确认“按值去重”还是“按下标去重”。如果按值去重那(2,2)只有一种如果按下标去重可能有两种。确定规则后再写代码否则白写。5.4 调试技巧与测试用例设计一个有效的调试方法是纸笔模拟。把数组写在一张纸上left和right各画一个箭头每比较一次就移动箭头直到箭头相遇。这个过程中你会直观感觉到“排除区间”在缩小也能发现自己到底哪一步理解偏差了。测试用例方面我建议至少准备这几类最短有效数组numbers [1, 2], target 3检验没有重复时的最简路径。只有一对答案且分布在两端numbers [1, 3, 7, 8, 10], target 11答案在1和10检验双指针能一路扫到端点。重复值混合numbers [1, 2, 2, 3, 5], target 4检验重复值不影响查找。负数参与numbers [-3, -1, 0, 2, 4], target 1检验负数和正数混合时指针移动是否正确。找不到答案numbers [1, 2, 3], target 7检验返回哨兵值的路径。每一类都有独特价值最短用例确认边界条件两端用例确认搜索路径重复值用例确认不会死循环负数用例确认大小关系判断在所有数值情况下都成立找不到用例确认返回处理逻辑。能把测试用例设计到这个颗粒度说明你真的理解了这道题。6. 最后再分享一点个人经验我带了很久的算法新人发现一个规律凡是能把暴力写法、二分写法、双指针写法三者之间关系讲清楚的人后面学任何算法都快。为什么因为他们的脑子里建立了一条“优化链”——先有一个正确但慢的方案再逐步利用数据特性减少计算量。这种思维模式比任何一道题的答案都有价值。一个很实用的做法是每次刷完题后写三行笔记这题用了什么数据特性、用了什么算法策略、从最差到最优经历了哪几步。比如这题就可以写“有序 → 单调性 → 对撞双指针O(n²)暴力 → O(n log n)二分 → O(n)双指针”。过半个月再回看这些笔记你会发现自己对算法的理解在不知不觉中变得立体了。两数之和有序数组版不是什么高深题目但它值得你花功夫认真对待。把它嚼透了你得到的不仅是一道题的答案而是一整套“从暴力到最优”的分析习惯。这习惯才是刷题刷到最后真正沉淀下来的东西。
返回列表