掘金 #日新计划更文活动
题目
15. 三数之和
给你一个整数数组 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]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。
注意,输出的顺序和三元组的顺序并不重要。
示例 2:
输入: nums = [0,1,1]
输出: []
解释: 唯一可能的三元组和不为 0 。
示例 3:
输入: nums = [0,0,0]
输出: [[0,0,0]]
解释: 唯一可能的三元组和为 0 。
class Solution {
func threeSum(_ nums: [Int]) -> [[Int]] {
}
}
解题思路🙋🏻 ♀️
读题: 这个题目, 首先i, j, k互不相等
返回值是个数组. 这个数组里面[abc] 相加之和为 0
如果 nums < 3 , 直接空数组.
不知道怎么搞啊...... 直接三层循环,来一波暴力解题
after 暴力
双指针法
-
先排序 固定一个左边的数值
first, 然后开始找右边的两个数. 只要是 左边的和 -
固定两个 point
left 和 right
right 需要 移动, right = right - 1
这个左右移动规则是. 如果 left + right > first
那么 右边要减小(向左移动).
如果, left + right < first 那么, 左边要增加(向右边移动),
当最终找到了一个合适的三元数组,我们储存起来, 然后移动左边的first , 进行下一次查找.
边界思考🤔
代码
这题没啥思路先搞个 暴力解法.....
class Solution {
func threeSum(_ nums: [Int]) -> [[Int]] {
// 存储最终的结果
var result: [[Int]] = []
// 用于去重,确保我们的结果中不会有重复的三元组
var seen: Set<[Int]> = []
// 外层循环,选择第一个数字
for i in 0..<nums.count {
// 第二层循环,选择第二个数字,从第一个数字后的位置开始
for j in i+1..<nums.count {
// 第三层循环,选择第三个数字,从第二个数字后的位置开始
for k in j+1..<nums.count {
// 检查这三个数字的和是否为0
if nums[i] + nums[j] + nums[k] == 0 {
// 如果和为0,对这三个数字排序,确保它们的顺序始终一致
let triplet = [nums[i], nums[j], nums[k]].sorted()
// 检查我们之前是否已经找到过这个三元组
if !seen.contains(triplet) {
// 如果没有找到过,添加到结果中,并将其添加到“已见过”的集合中
result.append(triplet)
seen.insert(triplet)
}
}
}
}
}
// 返回最终的结果
return result
}
}
// 定义解决方案类
class Solution {
// 主要的解决方法
func threeSum(_ nums: [Int]) -> [[Int]] {
// 如果数组的数量小于3,则直接返回空数组,因为我们不能形成三元组
if nums.count < 3 {
return []
}
// 对数组进行排序,这是为了后续能够更加方便地避免重复的三元组
var sortNums = nums.sorted()
// 用于存放结果的数组
var result:[[Int]] = []
// 开始主循环,遍历每一个数字
for i in 0..<nums.count {
// 如果当前的数字与前一个数字相同,则跳过,以避免重复的三元组
if i > 0 && sortNums[i] == sortNums[i-1] {
continue
}
// 定义左右指针,用于后续的双指针遍历
var left:Int = i + 1
var right:Int = nums.count - 1
// 当左指针小于右指针时,进行双指针遍历
while left < right {
// 计算当前左右指针指向的两个数字之和
let sum = sortNums[left] + sortNums[right]
// 如果三数之和大于0,则将右指针左移
if sum + sortNums[i] > 0 {
right -= 1
}
// 如果三数之和小于0,则将左指针右移
else if sum + sortNums[i] < 0 {
left += 1
}
// 如果三数之和等于0,则记录这个三元组,并同时移动左右指针
else {
// 避免左指针重复
while left < right && sortNums[left] == sortNums[left + 1] {
left += 1
}
// 避免右指针重复
while left < right && sortNums[right] == sortNums[right - 1] {
right -= 1
}
// 记录当前三元组
result.append([sortNums[i],sortNums[left],sortNums[right]])
// 移动左右指针
left += 1
right -= 1
}
}
}
// 返回结果数组
return result
}
}
时空复杂度分析
时间复杂度:
- 排序数组: (O(n \log n)),其中 (n) 是数组
nums的长度。 - 遍历数组: (O(n))。
- 对于每个数字,最坏情况下使用双指针遍历整个数组: (O(n))。
因此,总的时间复杂度是 (O(n \log n) + O(n^2) = O(n^2))。
空间复杂度:
- 排序后的数组:(O(n))。
- 结果数组:在最坏的情况下,每个元素都可能参与到结果中,但考虑到三元组的性质,实际的结果数组大小远小于 (n^3),但为了简化分析,我们可以认为其大小为 (O(n^3))。
- 其他变量(如指针)使用的空间是常数,即 (O(1))。
但在这个问题的背景下,我们知道结果的三元组数量是有限的(不可能有 (n^3) 个不同的三元组和为0),所以实际的空间复杂度主要取决于排序后的数组和其他常数空间,即 (O(n))。
综上,这个算法的时间复杂度是 (O(n^2)),空间复杂度是 O(n)。
错误与反思
// 跳过重复的元素
while left < right && sortedNums[left] == sortedNums[left + 1] { left += 1 }
while left < right && sortedNums[right] == sortedNums[right - 1] { right -= 1 }
这两行代码的核心目的是跳过重复的元素,以防止解集中出现重复的三元组。
首先,我们的数组是排序过的。这意味着重复的数字会被放在一起。因此,检查连续的元素是否相同是检测重复的一种有效方法。
现在,我们一步一步来解释这两行:
while left < right && sortedNums[left] == sortedNums[left + 1] { left += 1 }
这一行代码的目的是跳过左指针left的重复元素。
left < right:这是一个安全检查,确保left指针不会越过right指针。sortedNums[left] == sortedNums[left + 1]:这检查当前left指针指向的元素是否与其旁边的元素相同。如果是,则意味着有重复。left += 1:如果当前元素与下一个元素相同(即重复),我们就移动left指针。
因此,这个while循环会一直运行,直到left指针不再指向重复的元素。
while left < right && sortedNums[right] == sortedNums[right - 1] { right -= 1 }
这一行代码的目的与上一行相同,但是针对的是right指针。
left < right:同样是一个安全检查。sortedNums[right] == sortedNums[right - 1]:检查right指针是否指向重复的元素。right -= 1:如果当前元素与前一个元素相同(即重复),我们就移动right指针。
这个while循环会一直运行,直到right指针不再指向重复的元素。
总结:这两行代码确保了当我们找到一个满足条件的三元组后,我们不会因为left或right指针指向的重复元素而再次记录相同的三元组。
- 未处理重复值:如果不考虑重复值,结果中可能会有重复的三元组。
- 双指针移动不当:在找到一个解后,必须正确移动左右指针,否则可能会遗漏某些解或者添加重复的解。
- 边界条件:例如,当数组长度小于3时应立即返回空数组,以及循环的范围和条件。
` // 如果当前的数字与前一个数字相同,则跳过,以避免重复的三元组
if i > 0 && sortNums[i] == sortNums[i-1] {
continue
}`
在 for 循环中,范围是 0..<nums.count,而不是 0..<sortNums.count。虽然这两者在此场景中是等价的,但为了代码的一致性和可读性,最好使用 sortNums.count。
为什么会发生这样的错误?
- 当编写代码时,可能先写了
nums.count,后来又决定使用排序后的数组,但忘记了更新循环范围。
如何避免这样的错误?
- 当你对一个变量进行修改或替换时,确保更新了与该变量相关的所有引用。
概念
核心概念:
-
排序 (Sorting):排序是这个问题的一个关键步骤。通过对数组进行排序,我们可以方便地避免重复的三元组,并使用双指针技巧来找到所有的解。
-
双指针技巧 (Two Pointers Technique):在排序后的数组中,固定一个数字后,我们使用两个指针(一个从左边开始,另一个从右边开始)来查找两个数字,使得它们的和满足特定的条件。在这个问题中,我们希望找到两个数字,使得它们的和加上固定的数字等于0。
-
数组去重 (De-duplication of Arrays):由于结果中不允许有重复的三元组,我们需要使用一种方法来避免添加重复的三元组。在排序后的数组中,我们可以通过检查连续的相同数字来实现这一点。
-
枚举 (Enumeration):尽管这个概念在这个问题中可能不是非常明显,但我们实际上是在对数组中的每个数字进行枚举,然后使用双指针技巧来找到与其组成和为0的三元组。
这些核心概念不仅适用于这个特定的问题,而且在其他很多算法问题中也都很有用。掌握这些概念可以帮助你更有效地解决类似的问题。
使用场景与应用
1. 最需要学习的概念:
- 排序 (Sorting): 对数据进行排序使得其满足某种顺序,这可以简化后续的操作和搜索过程。
- 双指针技巧 (Two Pointers Technique): 通过使用两个指针来同时遍历数组,可以有效地解决某些问题,特别是与数组和列表相关的问题。
- 数组去重 (De-duplication of Arrays): 在处理数据时,去重是一个常见的需求,特别是当我们不希望在结果中有重复的元素时。
实际应用场景及技术点:
- 搜索引擎排名 (Search Engine Ranking):
- 技术点: 排序。搜索引擎需要根据网页的相关性和其他因素对搜索结果进行排序。
- 音乐播放器中的快进和快退 (Fast Forward and Rewind in Music Player):
- 技术点: 双指针。一般的快进和快退可以通过两个指针实现,一个指针指向当前播放的位置,另一个指针指向用户希望跳转到的位置。
- 电子邮件或消息应用中的查找重复联系人 (Finding Duplicate Contacts in Email or Messaging Apps):
- 技术点: 数组去重。在导入联系人或同步联系人时,可能会有重复的联系人,需要使用去重技术来删除这些重复的联系人。
2. iOS app 开发的实际使用场景及与本题技术的应用:
- 图片浏览器中的缩放功能 (Zooming in Image Viewer):
- 技术点: 双指针。用户可以使用两个手指进行缩放,这时可以使用双指针技巧来检测两个手指的位置,并据此调整图片的大小。
- 联系人应用中的排序和查找功能 (Sorting and Searching in Contacts App):
- 技术点: 排序。联系人可以按照姓名、公司或其他属性进行排序。双指针也可以用于快速地查找联系人。
- 音乐播放器中的去重功能 (De-duplication in Music Player):
- 技术点: 数组去重。当用户导入歌曲或播放列表时,可能会有重复的歌曲,这时需要使用去重技术来删除这些重复的歌曲。