从三数之和看优化算法的探索路线
在算法学习中,我们经常遇到这样的困扰:能看懂别人的代码,但很难独立想出类似的思路。LeetCode 第 15 题“三数之和”就是一个典型例子。它的最优解在结构上看似巧妙,很多人会直接记住代码,但忽略了更重要的——如何从零推导出这样的思路。
本文将以“三数之和”为起点,完整还原从暴力解到最优解的思维过程,并展示一套可迁移的“优化路径”,帮助你建立真正的算法感知能力。
问题描述
给定一个整数数组 nums
,要求找出所有不重复的三元组 (a, b, c)
,使得:
1 | a + b + c = 0 |
注意:
- 三个数不能重复使用同一个下标
- 结果中不能包含重复的三元组
Step 1:从暴力解出发(时间复杂度 O(n³))
最直接的方式是三重嵌套循环,枚举所有三元组:
1 | for (let i = 0; i < nums.length; i++) { |
虽然可以得到正确答案,但时间复杂度为 O(n³),在实际使用中效率极低。
关键问题:如何减少枚举的次数?有没有可能只枚举部分元素?
Step 2:固定一位,转化为“两数之和”问题(O(n²))
如果我们先固定一个数 a = nums[i]
,问题就变成了在剩下的数组中找出两个数 b
和 c
,满足:
1 | b + c = -a |
也就是说,“三数之和”变成了“在数组的子区间中找出两个数,其和为一个目标值”的问题,这正是经典的“两数之和”。
这一步是关键的结构性转化,使得时间复杂度从 O(n³) 降为 O(n²)。
Step 3:在子数组中寻找两数之和(哈希表 vs 双指针)
常见方法是使用哈希表:
1 | let seen = new Set(); |
该方法时间复杂度为 O(n),加上外层循环总复杂度为 O(n²)。缺点是空间复杂度较高,且处理重复三元组比较麻烦。
更进一步的思考是:如果数组是有序的,是否可以用双指针来优化查找和去重?
Step 4:排序 + 双指针(结构性优化)
将数组排序后,我们可以使用双指针从左右两端向中间逼近,寻找两个数的和是否等于目标值 -a
。
整体思路如下:
- 对数组进行排序,时间复杂度 O(n log n)
- 枚举第一个数
nums[i]
,跳过重复值 - 使用左右指针
left
和right
,在子区间内寻找匹配的两个数 - 在找到满足条件的三元组后,继续跳过重复值以防止结果重复
代码实现如下:
1 | nums.sort((a, b) => a - b); |
最终的时间复杂度依然为 O(n²),但:
- 空间复杂度仅为 O(1)
- 去重处理更为自然
- 结构清晰,逻辑易懂
这就是“三数之和”问题的最优解法。
Step 5:总结优化思路路径
我们可以总结如下的优化路径:
1 | Step 1:三重循环暴力解(O(n³)) |
从这个过程中可以看出,高效解法并不是突发奇想,而是一步步推导和重构的结果。
进阶思考:两数之和也可以使用双指针实现 O(n)
许多人习惯用哈希表解决“两数之和”问题,但如果数组已排序,使用双指针其实更高效、空间更优:
1 | function twoSumSorted(nums, target) { |
该算法时间复杂度为 O(n),空间复杂度为 O(1),结构简洁,效率更高。
当然,对于力扣第一题的两数之和中元素本是无序的情况,如果直接排序会打乱原数组的索引,此时如果一定要使用双指针法,可以使用 const arr = nums.map((val, idx) => [val, idx]);
给每个值打上原始位置的标签。力扣两数之和如果使用双指针法,由于进行了排序,时间复杂度会变为 O(nLogn)。
哈希法善于快速定位值与索引的关系,适用于对下标敏感、目标值唯一确定的问题,如 Two Sum。 双指针法善于在有序数组中压缩搜索空间,更适合多数值组合的情况,如 Three Sum、Four Sum,尤其适用于需要避免重复组合的场景。
建议的训练方法
为了具备独立推导这类解法的能力,建议你在日常刷题中遵循以下步骤:
- 从暴力法开始写起:先解出正确性,再优化效率。
- 关注重复计算:寻找是否存在“固定部分”或“重复结构”。
- 尝试结构性转化:如“固定一位”→“转为子问题”。
- 构建优化工具箱:熟练使用排序、哈希表、双指针、滑动窗口等常见技巧。
- 总结推导过程:把一题背后的优化路径复盘成笔记。
附加笔记:关于 sort()
我们需要知道的
在 JavaScript 里,sort()
是数组对象的一个内置方法,其主要功能是对数组元素进行排序。基础用法
若不传入参数,sort()
会按照 Unicode 编码的顺序对元素进行排序,排序时会先将元素转换为字符串。
1 | const fruits = ['apple', 'banana', 'cherry', 'date']; |
需要注意的是,若直接对数字数组排序,可能会得到不符合预期的结果。
1 | const numbers = [10, 5, 8, 1, 2]; |
这是因为 sort()
默认将元素转换为字符串后再比较,所以 "10"
会排在 "2"
之前。
自定义排序规则
若想实现自定义排序,比如按数字大小排序,就需要给 sort()
传入一个比较函数。该函数要返回一个数值,用于指示两个元素的相对顺序。
比较函数的形式为 function(a, b) {...}
,其返回值规则如下:
- 若返回值小于 0,则
a
会排在b
前面。 - 若返回值等于 0,则
a
和b
的相对位置保持不变。 - 若返回值大于 0,则
a
会排在b
后面。