三数之和
引言
编写通过所有测试案例的代码并不简单,通常需要深思熟虑和理性分析。虽然这些代码能够通过所有的测试案例,但如果不了解代码背后的思考过程,那么这些代码可能并不容易被理解和接受。我编写刷题笔记的初衷,是希望能够与读者们分享一个完整的代码是如何在逐步的理性思考下形成的。我非常欢迎读者的批评和指正,因为我知道我的观点可能并不完全正确,您的反馈将帮助我不断进步。如果我的笔记能给您带来哪怕是一点点的启示,我也会感到非常荣幸。同时,我也希望我的分享能够激发您的灵感和思考,让我们一起在编程的道路上不断前行~
三数之和
题目描述
给你一个整数数组 nums
,判断是否存在三元组 [nums[i], nums[j], nums[k]]
满足 i != j
、i != k
且 j != k
,同时还满足 nums[i] + nums[j] + nums[k] == 0
。
请你返回所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例
示例1
- 输入:nums = [-1,0,1,2,-1,-4]
- 输出:[[-1,-1,2],[-1,0,1]]
示例2
- 输入:nums = [0,1,1]
- 输出:[]
- 解释:唯一可能的三元组和不为 0 。
示例3
- 输入:nums = [0,0,0]
- 输出:[[0,0,0]]
- 解释:唯一可能的三元组和为 0 。
提示
- 3 <= nums.length <= 3000
- -10 <= nums[i] <= 10
解决方案3:【双指针】
在博客【LeetCode刷题笔记(6-1)】【Python】【三数之和】【哈希表】【中等】中,我们详尽地探讨了如何利用【哈希表】精妙设计算法,从而顺利通关【三数之和】这一挑战。
在此过程中,我们深度挖掘了问题细节,确保算法能够通过所有的测试案例(基于哈希表设计的算法在【三数之和】上很容易超时) —> 这不仅是一次技术上的磨练,更是对细心与耐心的极致考验。
问题1:为什么【三数之和】问题可以用【双指针】来解决?
在博客【LeetCode刷题笔记(6-1)】【Python】【三数之和】【哈希表】【中等】中,我们从两数之和的解决方案中汲取灵感,通过举一反三,巧妙地运用哈希表来解决【三数之和】问题。而对于【双指针】的使用,背后又隐藏着怎样的深刻逻辑呢?—> 先回到三数之和的本质:查找目标值。
在【哈希表】解决方案中,我们通过两层遍历的方式,首先固定两个值,然后利用哈希表快速查找剩下的一个值。那么,除了哈希表外,是否还有其他算法可以达到类似的效果呢?答案是肯定的,那就是方法。
。这种方法的优势在于,它避免了哈希表的开销,同时保持了查找的效率。
我们可以把双指针分为左指针left
和右指针right
。在循环遍历数组nums
的每个元素nums[i]
时,设计算法找到正确的nums[left]
和nums[right]
, 共同组成满足题意的三元组[nums[i], nums[left], nums[right]]
。
代码的初始版本如下:
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
# 如果输入的数组为空或长度小于3,直接返回空列表
if not nums or len(nums) < 3:
return []
# 对输入的数组进行排序
nums.sort()
# 获取列表的长度
n = len(nums)
# 初始化结果列表
result_list = []
# 遍历数组中的每个元素 --> 固定元素nums[i]
for i in range(n):
# 初始化左指针和右指针,分别指向剩余两个元素的位置
left = 0
right = n - 1
# 当左指针小于右指针时,进行循环
while left < right:
# 如果三个元素的和等于0,则将这三个元素添加到结果列表中,并同时移动左指针和右指针
if nums[i] + nums[left] + nums[right] == 0:
result_list.append([nums[i], nums[left], nums[right]])
left += 1 # 左指针右移
right -= 1 # 右指针左移
# 如果三个元素的和大于0,则右指针向左移动,以减少和的值
elif nums[i] + nums[left] + nums[right] > 0:
right -= 1
else: # 如果三个元素的和小于0,则左指针向右移动,以增加和的值
left += 1
return result_list # 返回结果列表
可以看到,虽然结果列表中,包含了正确的三元组,但更多的是重复的三元组(如上图的蓝色框部分),甚至还出现重复利用数组元素的三元组(上图红色框,重复利用元素2)。
尽管初始的代码版本问题很大,但还是有一些小细节值得拎出来说明一下:
细节1:为什么要对数组nums进行排序?
如果您已看完这篇博客,您会知道其中一个原因是为了方便去重。但目前的【初始代码】压根与去重无关。其实,现阶段对数组nums
进行排序的原因是为了方便确定左指针left
和右指针right
的移动方向。
- 如果数组
nums
是乱序的,如果nums[i] + nums[left] + nums[right] > 0
,下一步是移动左指针还是右指针?显然是不确定的。 - 如果数组
nums
是有序的,比如说从小到大排列。那么left
右移 ==> 三元组之和将增加;同理,right
左移 ==> 三元组之和将减少;那么nums[i] + nums[left] + nums[right] > 0
,下一步自然是right
左移,使三元组之和进一步接近0。
从细节1的分析中我们可以看出,对数组nums
进行排序对于应用双指针算法解决三数之和问题至关重要。甚至可以说,方便去重只是这个过程中的一个额外的好处,就像“买一送一”一样。
细节2:while left < right:
这个语句能否取等,即while left <= right:
?
不能。我们可以从左右指针的定义中找到答案 ==> 分别指向剩余两个元素的位置。如果left == right
成立 ⇒ 三元组会包含同一元素!
细节3:当三元组元素满足nums[i] + nums[left] + nums[right] == 0
时,为什么下一步要同时进行left
右移和right
左移?
- 如果我们只进行
left
右移,相当于nums[i]
和nums[left]
是固定的。想要nums[i] + nums[left] + nums[right] == 0
继续成立,nums[right]即使元素位置变了,但元素值不可能变 ==> 只移动一个指针没有意义,都不移动更没有意义 —> 同时进行left
右移和right
左移。
想清楚三个细节后,紧接着就要思考为什么这个代码会生成这么多重复的三元组,甚至还出现重复利用数组元素的三元组?
问题1:左指针left
不应该初始化为0
如果我们把左指针left
初始化为0 ⇒ 在【双指针】查找剩余两个元素时,左指针left
的右移和右指针right
的左移都可能碰上事先固定的元素nums[i]
⇒ 出现重复利用数组元素的三元组。既然0不行,那么初始化为[0, i]的任何数都会出现这样的问题。
那将左指针left
初始化为i+1?
是的,这样既可以保证左指针left
的右移和右指针right
的左移不可能会碰到事先固定的元素nums[i]
,也不会出现遗漏某个三元组的情况。
代码如下(见修改1):
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
# 如果输入的数组为空或长度小于3,直接返回空列表
if not nums or len(nums) < 3:
return []
# 对输入的数组进行排序
nums.sort()
# 获取列表的长度
n = len(nums)
# 初始化结果列表
result_list = []
# 遍历数组中的每个元素 --> 固定元素nums[i]
for i in range(n):
# 初始化左指针和右指针, 分别指向剩余两个元素的位置
left = i + 1 # 修改1:初始化为i+1
right = n - 1
# 当左指针小于右指针时,进行循环
while left < right:
# 如果三个元素的和等于0,则将这三个元素添加到结果列表中,并同时移动左指针和右指针
if nums[i] + nums[left] + nums[right] == 0:
result_list.append([nums[i], nums[left], nums[right]])
left += 1 # 左指针右移
right -= 1 # 右指针左移
# 如果三个元素的和大于0,则右指针向左移动,以减少和的值
elif nums[i] + nums[left] + nums[right] > 0:
right -= 1
else: # 如果三个元素的和小于0,则左指针向右移动,以增加和的值
left += 1
return result_list # 返回结果列表
可以看到,相比于上一版代码,这一版结果虽然仍然存在重复的三元组,但已经没有出现重复利用数组元素的三元组!<— 这是由于这版代码中, i < left < right
的关系是恒成立的,保证了不可能重复利用数组元素。
问题2:为什么仍然存在重复的三元组[-1, 0, 1]呢?
数组[-1,0,1,2,-1,-4]
排序后的结果为[-4, -1, -1, 0, 1, 2]
⇒ 当遍历数组元素,依次固定元素值时,元素-1
会连续两次作为固定的值 ⇒ 结果列表中多了一个三元组!!!
由于数组是有序的,我们可以很简单的对这种情况进行去重操作,代码如下(见修改2):
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
# 如果输入的数组为空或长度小于3,直接返回空列表
if not nums or len(nums) < 3:
return []
# 对输入的数组进行排序
nums.sort()
print(nums)
# 获取列表的长度
n = len(nums)
# 初始化结果列表
result_list = []
# 遍历数组中的每个元素 --> 固定元素nums[i]
for i in range(n):
# 修改2:去除连续相同的元素值
if i > 0 and nums[i] == nums[i-1]:
continue
# 初始化左指针和右指针, 分别指向剩余两个元素的位置
left = i + 1 # 修改1:初始化为i+1
right = n - 1
# 当左指针小于右指针时,进行循环
while left < right:
# 如果三个元素的和等于0,则将这三个元素添加到结果列表中,并同时移动左指针和右指针
if nums[i] + nums[left] + nums[right] == 0:
result_list.append([nums[i], nums[left], nums[right]])
left += 1 # 左指针右移
right -= 1 # 右指针左移
# 如果三个元素的和大于0,则右指针向左移动,以减少和的值
elif nums[i] + nums[left] + nums[right] > 0:
right -= 1
else: # 如果三个元素的和小于0,则左指针向右移动,以增加和的值
left += 1
return result_list # 返回结果列表
可以看到,目前代码已经通过132个测试用例,但仍然会出现重复的三元组。
问题3:为什么仍然存在重复的三元组[-2, 0, 2]呢?,我们可以从排序后的数组[-2, 0, 0, 2, 2]
看出,0和2分别重复了两次 ⇒ 当我们固定元素-2时,并快速找到[-2, 0, 2]
这个正确的三元组,此时左指针left
右移,右指针right
左移。巧的是,左指针left
依然指向元素0,右指针依然指向元素2 ⇒ 出现重复的三元组[-2, 0, 2]。
因此我们在找到正确三元组的同时,应该把与左指针left
指向的元素相同的过滤掉,也把与右指针right
指向的元素相同的过滤掉。代码如下(见修改3):
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
# 如果输入的数组为空或长度小于3,直接返回空列表
if not nums or len(nums) < 3:
return []
# 对输入的数组进行排序
nums.sort()
print(nums)
# 获取列表的长度
n = len(nums)
# 初始化结果列表
result_list = []
# 遍历数组中的每个元素 --> 固定元素nums[i]
for i in range(n):
# 修改2:去除连续相同的元素值
if i > 0 and nums[i] == nums[i-1]:
continue
# 初始化左指针和右指针, 分别指向剩余两个元素的位置
left = i + 1 # 修改1:初始化为i+1
right = n - 1
# 当左指针小于右指针时,进行循环
while left < right:
# 如果三个元素的和等于0,则将这三个元素添加到结果列表中,并同时移动左指针和右指针
if nums[i] + nums[left] + nums[right] == 0:
result_list.append([nums[i], nums[left], nums[right]])
# 修改3
while left < right and nums[left] == nums[left+1]:
left += 1 # 如果左边的元素相等,则移动左指针
while left < right and nums[right] == nums[right-1]:
right -= 1 # 如果右边的元素相等,则移动右指针
left += 1 # 左指针右移
right -= 1 # 右指针左移
# 如果三个元素的和大于0,则右指针向左移动,以减少和的值
elif nums[i] + nums[left] + nums[right] > 0:
right -= 1
else: # 如果三个元素的和小于0,则左指针向右移动,以增加和的值
left += 1
return result_list # 返回结果列表
可以看到,目前代码已经能够通过所有测试案例,但代码运行时间显然还是比较长的。
问题4:还有没有进一步优化的可能性?
有的。
首先,数组nums
是排好序的,而我们的目标是找三个元素,让它们的和为0 ⇒ 如果当前固定的值为nums[i] > 0 ⇒ 根据i < left < right
, 即nums[i] < nums[i] < nums[i]
恒成立,不可能找到和为0的三元组,此时应该直接返回结果。
问题5:nums[i] = 0 的情况是否要保留,即返回条件是否可改为nums[i] >= 0
?
不可以,因为可能存在[0, 0, 0]
这个正确的三元组!代码如下(见修改4):
完整代码:
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
# 如果输入的数组为空或长度小于3,直接返回空列表
if not nums or len(nums) < 3:
return []
# 对输入的数组进行排序
nums.sort()
# print(nums)
# 获取列表的长度
n = len(nums)
# 初始化结果列表
result_list = []
# 遍历数组中的每个元素 --> 固定元素nums[i]
for i in range(n):
# 修改2:去除连续相同的元素值
if i > 0 and nums[i] == nums[i-1]:
continue
# 修改4
if nums[i] > 0:
return result_list
# 初始化左指针和右指针, 分别指向剩余两个元素的位置
left = i + 1 # 修改1:初始化为i+1
right = n - 1
# 当左指针小于右指针时,进行循环
while left < right:
# 如果三个元素的和等于0,则将这三个元素添加到结果列表中,并同时移动左指针和右指针
if nums[i] + nums[left] + nums[right] == 0:
result_list.append([nums[i], nums[left], nums[right]])
# 修改3
while left < right and nums[left] == nums[left+1]:
left += 1 # 如果左边的元素相等,则移动左指针
while left < right and nums[right] == nums[right-1]:
right -= 1 # 如果右边的元素相等,则移动右指针
left += 1 # 左指针右移
right -= 1 # 右指针左移
# 如果三个元素的和大于0,则右指针向左移动,以减少和的值
elif nums[i] + nums[left] + nums[right] > 0:
right -= 1
else: # 如果三个元素的和小于0,则左指针向右移动,以增加和的值
left += 1
return result_list # 返回结果列表
运行结果:
复杂度分析
- 时间复杂度:O(N),其中 N 是数组
nums
元素的数量。 - 空间复杂度:O(N)
- 存放数组排序后的新数组 ===> O(N)
结束语
- 亲爱的读者,感谢您花时间阅读我们的博客。我们非常重视您的反馈和意见,因此在这里鼓励您对我们的博客进行评论。
- 您的建议和看法对我们来说非常重要,这有助于我们更好地了解您的需求,并提供更高质量的内容和服务。
- 无论您是喜欢我们的博客还是对其有任何疑问或建议,我们都非常期待您的留言。让我们一起互动,共同进步!谢谢您的支持和参与!
- 我会坚持不懈地创作,并持续优化博文质量,为您提供更好的阅读体验。
- 谢谢您的阅读!