LeetCode 15:三数之和|排序 + 双指针,如何避免重复答案?

目录

LeetCode 15:三数之和|排序 + 双指针,如何避免重复答案?

一、题目要求

题目链接:LeetCode 15:三数之和

给定一个整数数组 nums,找出所有满足以下条件的三元组:

text 复制代码
nums[i] + nums[j] + nums[k] = 0

其中 i、j、k 必须是三个不同的下标,并且答案中不能包含重复的三元组。

例如:

python 复制代码
nums = [-1, 0, 1, 2, -1, -4]

输出:

python 复制代码
[[-1, -1, 2], [-1, 0, 1]]

注意:题目要求返回的是数字本身,而不是下标。另外,三元组的排列顺序不同,并不代表它们是不同的答案。


二、思路:三个数字,真的需要同时寻找吗?

题目要求三个数字相加等于 0。

最直接的办法当然是枚举三个数字,检查它们的和是否为 0。

但如果使用三层循环,时间复杂度就会达到 O(n³)。

那么,有没有办法减少一层搜索?

不妨先固定其中一个数字。

假设固定的是:

python 复制代码
nums[i]

原本需要满足:

text 复制代码
nums[i] + nums[j] + nums[k] = 0

把 nums[i] 移到右边:

text 复制代码
nums[j] + nums[k] = -nums[i]

这样一来,问题就发生了变化。

我们不再需要同时寻找三个数字,而是:

  1. 固定第一个数字 nums[i]。
  2. 计算另外两个数字需要凑出的和 need = -nums[i]。
  3. 在剩余数组中寻找两个数字,使它们相加等于 need。

也就是说,三数之和可以拆成:固定一个数字 + 寻找另外两个数字。

接下来需要考虑的是:剩下两个数字怎么找,才能避免再次使用两层循环?


三、排序 + 双指针:寻找剩下两个数字

如果剩下两个数字也用两层循环枚举,那么整体仍然是 O(n³)。

但如果数组有序,我们就可以根据两个数字的总和,判断应该移动哪一边。

因此,先对数组排序:

python 复制代码
nums.sort()

例如:

text 复制代码
原数组:[-1, 0, 1, 2, -1, -4]

排序后:[-4, -1, -1, 0, 1, 2]

现在先固定一个数字 nums[i],再让两个指针分别指向它右边的第一个数字和数组末尾:

python 复制代码
j = i + 1
k = n - 1

其中:

  • i:固定第一个数字。
  • j:左指针,寻找第二个数字。
  • k:右指针,寻找第三个数字。

为什么从 i + 1 开始?

因为 nums[i] 已经被固定,我们只需要在它后面寻找另外两个数字,既不会重复使用同一个下标,也不需要重新考虑前面的组合。

1. 当前总和太小

假设:

text 复制代码
nums = [-4, -1, -1, 0, 1, 2]
         ↑   ↑            ↑
         i   j            k

固定:

text 复制代码
nums[i] = -4
need = 4

另外两个数字需要相加等于 4。

但现在:

text 复制代码
nums[j] + nums[k] = -1 + 2 = 1

发现 1 < 4。

因为数组已经排序,2 是当前搜索范围中最大的数字,连 -1 + 2 都不够,那么 -1 和范围内其他数字相加也不可能达到 4。

所以可以排除当前左端数字,让左指针向右移动:

python 复制代码
j += 1

2. 当前总和太大

反过来,如果:

text 复制代码
nums[j] + nums[k] > need

说明另外两个数字的和太大。

因为 nums[j] 已经是当前范围内最小的数字,连它与 nums[k] 相加都超过目标,那么 nums[k] 与范围内其他数字相加只会更大或相等。

因此可以排除右端数字:

python 复制代码
k -= 1

3. 当前总和刚好等于 need

如果:

text 复制代码
nums[j] + nums[k] == need

说明:

text 复制代码
nums[i] + nums[j] + nums[k] == 0

找到一个满足条件的三元组:

python 复制代码
result.append([nums[i], nums[j], nums[k]])

但这里还不能直接结束。

因为题目要求找出所有不同的三元组,而不是找到一个就返回。

因此,记录答案以后,还要继续移动左右指针,寻找其他可能的组合。

到这里,基本的搜索规则就确定了:

text 复制代码
固定 nums[i]
    ↓
need = -nums[i]
    ↓
在 i 后面使用左右双指针
    ↓
总和太小 → j += 1
总和太大 → k -= 1
总和相等 → 记录答案,继续寻找

不过,这道题与普通两数之和还有一个很大的区别:重复答案。


四、为什么需要去重?

例如:

python 复制代码
nums = [-2, 0, 0, 2, 2]

我们可能多次找到:

text 复制代码
-2 + 0 + 2 = 0

但正确答案只能是:

python 复制代码
[[-2, 0, 2]]

因为即使使用的是不同下标,只要三个数字相同,就属于重复的三元组。

所以,除了找到答案,还需要考虑如何避免重复。

这里主要有两个位置需要去重。

1. 固定数字 numsi 的去重

例如:

text 复制代码
nums = [-2, -2, 0, 2, 2]
        ↑
        i

如果第一个 -2 已经处理完,那么当 i 移动到第二个 -2 时,就没有必要重新搜索一遍。

因为固定的数字相同,后面可能找到的三元组也会重复。

因此:

python 复制代码
if i > 0 and nums[i] == nums[i - 1]:
    continue

意思是:如果当前固定的数字与上一个相同,就跳过这次搜索。

这里要特别注意:

不能只要看到重复数字就全部跳过。

例如:

python 复制代码
nums = [-1, -1, 2]

正确答案就是:

python 复制代码
[[-1, -1, 2]]

我们允许三元组内部出现相同的数字,只是不允许结果列表中出现重复的三元组。

因此,固定数字的去重只针对不同轮次的 i,不能因此禁止另外两个指针使用相同数值。

2. 找到答案后,左右指针也需要去重

例如:

text 复制代码
nums = [-2, 0, 0, 2, 2]
        ↑   ↑        ↑
        i   j        k

找到:

text 复制代码
-2 + 0 + 2 = 0

之后,先记录答案:

python 复制代码
result.append([nums[i], nums[j], nums[k]])

然后移动左右指针:

python 复制代码
j += 1
k -= 1

但新位置上的数字可能与刚刚使用过的数字相同。

所以还需要跳过这些重复值:

python 复制代码
while j < k and nums[j] == nums[j - 1]:
    j += 1

while j < k and nums[k] == nums[k + 1]:
    k -= 1

为什么分别比较 j - 1 和 k + 1?

因为我们已经先执行了:

python 复制代码
j += 1
k -= 1

所以:

  • j - 1 是左指针刚刚经过的位置。
  • k + 1 是右指针刚刚经过的位置。

如果新位置的数字与刚才一样,就继续跳过,直到遇到不同的数字,或者两个指针相遇。

这样就能避免同一个固定数字下,重复记录相同的三元组。


五、完整解法:排序 + 固定一个数字 + 双指针 + 去重

现在把前面的思路组合起来:

python 复制代码
class Solution:
    def threeSum(self, nums: list[int]) -> list[list[int]]:
        result = []
        nums.sort()

        n = len(nums)

        for i in range(n - 2):
            # 固定数字去重
            if i > 0 and nums[i] == nums[i - 1]:
                continue

            need = -nums[i]

            j = i + 1
            k = n - 1

            while j < k:
                current = nums[j] + nums[k]

                if current < need:
                    j += 1

                elif current > need:
                    k -= 1

                else:
                    result.append([nums[i], nums[j], nums[k]])

                    j += 1
                    k -= 1

                    # 跳过左侧重复数字
                    while j < k and nums[j] == nums[j - 1]:
                        j += 1

                    # 跳过右侧重复数字
                    while j < k and nums[k] == nums[k + 1]:
                        k -= 1

        return result

到这里,已经能够正确解决这道题。

容易踩的坑

① 找到答案后,指针必须继续移动。

如果只是把答案添加到 result,但没有改变 j 和 k,下一轮仍然会检查同一组数字。

这会导致循环无法结束,结果列表不断增长,甚至出现内存超限。

② 返回的是数字,不是下标。

应该记录:

python 复制代码
[nums[i], nums[j], nums[k]]

而不是:

python 复制代码
[i, j, k]

③ 去重不等于禁止使用相同的数字。

例如 [-1, -1, 2] 是合法答案。

我们要排除的是重复三元组,而不是所有重复元素。

④ 固定数字只需要遍历到 n - 3。

代码中使用:

python 复制代码
for i in range(n - 2):

因为固定一个数字以后,右边至少还需要两个元素。

如果数组长度小于 3,循环自然不会执行,最终返回空列表。


六、进一步优化:利用排序进行剪枝

前面的解法已经能够通过题目,时间复杂度也达到了 O(n²)。

不过既然数组已经排序,我们还能不能利用它的大小关系,提前排除一些根本不可能得到答案的情况?

例如,每次固定 nums[i] 后,我们都会让左右指针搜索一遍。

但有些时候,甚至不需要进入双指针循环,就已经能够判断当前数字不可能组成答案。

这就是剪枝。

优化 1:最大的两个数字都不够,跳过当前 i

例如:

text 复制代码
nums = [-20, -10, -5, 1, 2, 3]
        ↑
        i

固定:

text 复制代码
nums[i] = -20

即使选择后面最大的两个数字:

text 复制代码
-20 + 2 + 3 = -15

仍然小于 0。

这说明固定 -20 时,无论另外两个数字怎么选,都不可能得到答案。

因此可以直接跳过当前 i:

python 复制代码
if nums[i] + nums[n - 2] + nums[n - 1] < 0:
    continue

这里使用 continue,因为当前固定数字太小,不代表后面更大的固定数字也不行。

优化 2:最小的两个数字都超了,直接结束

反过来,如果固定当前数字以后,选择右边最小的两个数字,总和都已经大于 0 呢?

例如:

text 复制代码
nums = [-5, -2, 4, 6, 8, 10]
               ↑
               i

固定 4,选择右边最小的两个数字:

text 复制代码
4 + 6 + 8 = 18

已经大于 0。

既然这是当前固定数字能够得到的最小总和,那么其他组合只会更大。

而且数组已经排序,后面的固定数字也只会更大,因此后续同样不可能找到答案。

所以:

python 复制代码
if nums[i] + nums[i + 1] + nums[i + 2] > 0:
    break

这里使用 break,直接结束外层循环。

两种剪枝的区别

判断 说明 操作
最大可能的和仍然小于 0 当前固定数字太小 continue
最小可能的和已经大于 0 当前及后续固定数字都不可能成功 break

简单理解:

  • 太小了: 换下一个固定数字试试。
  • 太大了: 后面只会更大,可以直接结束。

七、最终优化版代码

在基本解法的基础上,加入两个剪枝条件:

python 复制代码
class Solution:
    def threeSum(self, nums: list[int]) -> list[list[int]]:
        result = []
        nums.sort()

        n = len(nums)

        for i in range(n - 2):
            if i > 0 and nums[i] == nums[i - 1]:
                continue

            # 最大的两个数字都不够
            if nums[i] + nums[n - 2] + nums[n - 1] < 0:
                continue

            # 最小的两个数字都超了
            if nums[i] + nums[i + 1] + nums[i + 2] > 0:
                break

            need = -nums[i]

            j = i + 1
            k = n - 1

            while j < k:
                current = nums[j] + nums[k]

                if current < need:
                    j += 1

                elif current > need:
                    k -= 1

                else:
                    result.append([nums[i], nums[j], nums[k]])

                    j += 1
                    k -= 1

                    while j < k and nums[j] == nums[j - 1]:
                        j += 1

                    while j < k and nums[k] == nums[k + 1]:
                        k -= 1

        return result

剪枝不会改变最坏时间复杂度,但可以减少一些不必要的搜索。

需要注意的是,这两个剪枝是进一步优化,并不是双指针算法能够成立的必要条件。


八、复杂度分析

时间复杂度:O(n²)

首先对数组排序:

python 复制代码
nums.sort()

排序的时间复杂度为 O(n log n)。

然后通过外层循环固定第一个数字:

python 复制代码
for i in range(n - 2):

最多需要 O(n) 次。

每次固定一个数字后,左右指针从两端向中间移动,总移动次数最多为 O(n)。

因此双指针搜索部分的时间复杂度为:

text 复制代码
O(n) × O(n) = O(n²)

最终由 O(n²) 主导。

额外空间复杂度:

双指针搜索本身只使用固定数量的变量,为 O(1)。

但 Python 的排序可能需要额外空间,返回的结果列表也需要占用内存,因此整个实现的实际空间开销不能简单地全部算成 O(1)。


九、总结

这道题最关键的思路,是把寻找三个数字的问题拆开:

先固定一个数字,再寻找另外两个数字。

这样就不需要用三层循环枚举所有组合。

然后利用数组有序的特点,通过左右双指针寻找剩下两个数字,把搜索的时间复杂度从 O(n³) 降到 O(n²)。

不过,三数之和还有一个不能忽略的问题:去重。

题目允许不同位置上的数字相同,但不允许答案中出现重复的三元组。因此需要分别处理固定数字的重复,以及找到答案后左右指针遇到的重复数字。

至于最后的剪枝,只是在基本解法正确的基础上,进一步利用排序后的大小关系减少无效搜索。

如果想继续理解「为什么左右双指针可以安全地排除某一端的数字」,也可以参考我之前整理的 LeetCode 167:两数之和 II|双指针思路解析。

两道题的共同点是利用有序性缩小搜索范围,而这道题额外需要解决的,就是固定一个数字后如何寻找所有不重复的组合。

相关推荐
weixin199701080161 小时前
《1688消息服务落地方案:alibaba.message.* 与 Webhook 回调的可靠性与重试策略》(附Python源码)
开发语言·python
北风toto1 小时前
数据库笔记:Armstrong 公理系统与集合论的深度辨析
数据库·笔记
EasyBr指纹浏览器1 小时前
我的开源项目:小红书笔记归档,把来源和缺项一起留下
笔记·小红书·开源工具·素材管理·内容归档
海海不掉头发1 小时前
图论及其应用_课堂笔记整理_CSDN博客
笔记·图论
-dzk-1 小时前
【多维动态规划】LC 1143.最长公共子序列
算法·动态规划
志尊宝1 小时前
Vue3 零基础每日笔记(075):图片懒加载与骨架屏
javascript·vue.js·笔记
一只专注api接口开发的技术猿1 小时前
Open‑Claw 实战|告别页面逆向,快速搭建电商商品监控与数据分析完整方案
大数据·数据库·python·数据挖掘·数据分析
C++ 老炮儿的技术栈1 小时前
char (*csConnectName)[256]; 和 char csConnectName [256] 有什么区别
java·c语言·开发语言·c++·人工智能·算法·c
Omics Pro1 小时前
全新可重分析!代谢组质谱专用
数据库·人工智能·算法·机器学习·自然语言处理