1.四数相加
给你四个整数数组 nums1、nums2、nums3 和 nums4 ,数组长度都是 n ,请你计算有多少个元组 (i, j, k, l) 能满足:
0 <= i, j, k, l < n
nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0
示例 1:
输入:nums1 = [1,2], nums2 = [-2,-1], nums3 = [-1,2], nums4 = [0,2]
输出:2
解释:
两个元组如下:
1. (0, 0, 0, 1) -> nums1[0] + nums2[0] + nums3[0] + nums4[1] = 1 + (-2) + (-1) + 2 = 0
2. (1, 1, 0, 0) -> nums1[1] + nums2[1] + nums3[0] + nums4[0] = 2 + (-1) + (-1) + 0 = 0
思路
不需要求四元组是哪四个元素,只要能求出数量就行了
与四数之和不同。比起四数之和不需要考虑去重的操作,例如,四元组所有元素都是0,就不需要去重操作
四数之和:需要考虑去重,因为是在一个数组里找到四个元素相加。
遍历前两个数组找到a+b,遍历后两个数组找到c+d,同时统计次数
为什么会用到哈希法?
遇到哈希法的情况:在一个集合里判断这个元素有没有出现过。
暴力的做法是取4个for循环看看有没有=0的,时间复杂度是n^4,耗时太久,且本题目可以简化为在一个集合中判断是否出现过和满足要求的数值对。
哈希法的解法:
只遍历前面两个数组A和B,从A和B中取出的元素a+b放到一个集合里,再去遍历cd这两个数组,判断集合里有没有想要的元素。
哈希结构的选择
有效字母异位词题目中用的是数组来做哈希表,因为是两个数组,且数组中的字母都是小写字母,数值是可控的,可以用数组的下标来做映射。
但是本题目中,元素数值可能是很大的,都是int类型,数组下标不适合用来做映射(会有大量的空间浪费,数组是连续内存)。
本题目中,不仅需要统计a+b有没有在集合里出现过,还要统计a+b出现了多少次。set容器只有一个key去存储是否出现过,出现过的次数还需要另一个量来存储,也就是value。因此本题目选择map。
因此,本题目的解法为,利用map容器存放a+b的值和出现次数,然后遍历c和d寻找(0-c+d)是否在前面这个map容器中出现过。
时间复杂度
两个数组两个数组的遍历,而不是1+3的遍历,是为了降低时间复杂度。遍历两个数组是n2,遍历后两个数组是n2,整体时间复杂度就是n^2。
伪代码
unordered_map<int,int>map;
//遍历a+b
for(int a:A){//遍历a数组
for(int b:B){//遍历b数组 这是cpp的一种for循环写法
//统计出现次数
map[a+b]++; //映射到a+b对应的value做++操作
//如果没有出现过就insert,这种[]用法同时适用于出现过和没出现过
}
}
count=0;
//遍历完ab之后再遍历cd,查找0-a+b
for(c:C){
for(d:D){
target = 0-(c+d);
//看map中有没有出现过target
if(map.find(target)!=map.end()){
//注意:如果出现过,应该加上value而不是
count = count+map[target]; //value就是map[target]
}
}
}
return count;
map[a+b]++的用法
这行代码 map[a + b]++
是在更新 map
这个无序映射(unordered map)中的一个value
。
map
是一个无序映射(也称为哈希映射)。这是一种数据结构,用于存储键值对。在这个情况下,键是来自数组A和B的元素的和,值是这个和出现的次数。a + b
是来自数组A和数组B的元素的和。map[a + b]
访问与keya + b
关联的value。如果这个key在无序映射中不存在,那么它会被创建。++
运算符会增加它**附加到的变量(也就是value)**的值。- 如果a+b不存在映射中,那么value创建的时候=0,++操作后变成1
因此,map[a + b]++
的含义是“增加与无序映射 map
中键 a + b
关联的值”。如果键 a + b
在映射中不存在,那么在增加之前,会创建它并将其值设为0。
这行代码的作用是计算数组A和B中的每一对数字的和的频率。
map中的[]操作符
注意:key的重复问题
unordered_map的key是不可重复的,也就是说,key有一组a+b之后,后面再出现a+b,就会直接在value上面累加。
在 unordered_map
中,键(key)是唯一的。当你试图插入一个已经存在的键时,这个键对应的值会被更新,而不是插入一个新的键值对。
(底层是哈希表的容器,值都不能重复,unordered_set也不能重复)
完整版
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int,int>map;
for(int a:nums1){
for(int b:nums2){
map[a+b]++;
}
}
int count=0;
for(int c:nums3){
for(int d:nums4){
int target = 0-(c+d);
count=count+map[target];
}
}
return count;
}
};
本题目和有效字母异位词很像,注意本题目和四数之和的区别
2.三数之和
给你一个整数数组 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] 。
注意,输出的顺序和三元组的顺序并不重要。
思路
本题目与两数之和不同, 两数之和是用map存放遍历过的元素,然后在遍历过的元素中寻找和当前遍历的值相加为0的元素。
但是本题目是在一个数组中找到三个元素和=0,并且这个三元组是去重的。两数之和是一找到就直接返回这一组值,且题目提示了只会存在一个有效答案。三数之和是返回所有的答案,并且不能重复。
1.哈希法
哈希法思路:找0-(a+b)
有没有在a+b的数组内出现过。但是本题目需要去重,细节比较多。
虽然两层for循环就可以确定 a 和b 的数值了,可以使用哈希法来确定 0-(a+b) 是否在 数组里出现过,其实这个思路是正确的,但是我们有一个非常棘手的问题,就是题目中说的不可以包含重复的三元组。
把符合条件的三元组放进vector中,然后再去重,这样是非常费时的,因为哈希表中key是无序的,去重的过程非常麻烦,很容易超时,也是这道题目通过率如此之低的根源所在。
去重的过程不好处理,有很多小细节,如果在面试中很难想到位。
时间复杂度可以做到O(n^2),但还是比较费时的,因为不好做剪枝操作。
2.双指针法
注意使用双指针法之前一定要对数组进行排序,又因为这道题目是返回元素值,不需要返回下标,如果是返回下标,就不能排序了,排序之后下标就乱了。
双指针法排序的原因
如果nums[i]+nums[left]+nums[right]>0
当i已经是固定的时候,让这三个数之和减少,righ左移即可。
如果nums[i]+nums[left]+nums[right]<0
left右移即可
去重的注意事项
双指针法nums[i]需要去重,比如有重复元素都在前面;right和left也要做去重
伪代码
整体思路:i先不动,left和right向中间移动,移动的过程中收割三数之和为0的三元组,直到left和right相遇,再移动下一个i。i单独做一个去重,l和r再进行去重。
result[][];//定义二维数组
sort nums;//排序
for(i=0;i<nums.size();i++){//开始遍历
if(nums[i]>0){//先排除不可能的情况
return result; //因为该数组已经排过序了,从低到高,如果第一个数直接>0,那么就没有收集必要了
}
//i的去重
if(i>0&&nums[i]==nums[i-1]){
//此时说明a+b+c的a重复了
continue; //进入下一次循环,回到for,i继续向后移动
}
left = i+1; //left初始位置在i后一位
right = nums.size()-1; //从最后面开始遍历
//移动left和right指针
//边界条件:看相等的时候是否还需要遍历
while(left<right){
//如果left=right,这两个指针都指向了同一个位置,left和right指向同一个数,不满足三元组条件
if(nums[i]+nums[left]+nums[right]>0){
right--;
//注意:只要满足>0,右边界就--,如果=0的话,本身这个if就不会再执行,因此不需要在if里再判断是不是=0
}
if(nums[i]+nums[left]+nums[right]<0){
left++;
}
else{//=0的情况
result.push_back({nums[i],nums[left],nums[right]});//放进二维数组里
//找到三元组之后立刻进行对b和c的去重
if(left<right&&nums[right]==nums[right-1]){
right--;
}
if(left<right&&nums[left]==nums[left+1]){
left++;
}
//防止类似于 0 -1 -1 -1 1 1 1 的情况
}
//最后找到答案之后,双指针同时收缩去找下一组
right--;
left++;
}
}
去重问题1:nums[i]=nums[i+1]
的判断和nums[i]=nums[i-1]
有什么区别?
不能是重复的三元组,但是三元组里面的元素可重复。
由于我们定义的三元组,格式是{i,left,right},结果集里面的元素是可以重复的,所以,i的去重必须和i-1进行对比,而不是和i+1进行对比。
i遍历到后面,发现前一位有-1了,说明这个-1用过了,这个结果集可以跳过。
但是如果是i和i-1做判断,需要防止数组越界,判断i>0
去重问题2:
去重一定要注意去除干净才能继续,如果i++仍然是重复的,需要进行反复的判断,也就是进行continue操作返回for循环里继续执行去重if语句判断。left和right进行去重的时候也需要进行while循环判断。
continue的用法
在本题中,continue关键字的作用是跳过当前迭代中的剩余部分,并立即开始下一次迭代。也就是说,如果满足if(i>0&&nums[i]==nums[i-1])
这个条件,那么continue将会被执行,那么for循环将立即开始下一个迭代,i的值会增加1,然后开始新一轮的迭代。
continue
语句用于结束当前循环,并立即开始下一次的循环。但它只能影响它所在的最近的一层循环,如果在嵌套循环中使用continue
,它只会跳过当前循环中的剩余部分,立即开始下一次的这个内层循环,而不会影响到外层的循环。
for(int i=0; i<3; i++){
for(int j=0; j<3; j++){
if(j==1)
continue;
cout << i << ", " << j << endl;
}
}
在这个例子中,continue
只会影响到内层的循环(j
循环)。当j==1
时,continue
会跳过当前的j
循环的剩余部分,然后j
会增加1,开始下一次的j
循环。但是**i
循环不会受到continue
的影响**,它会正常进行。
while循环的边界条件
while(left<right){
//如果left=right,这两个指针都指向了同一个位置,left和right就是一个数了
}
因为需要找的是abc
三个数字,如果左指针右指针碰到了一起,那么这此时两个指针指向的是同一个数字,本质上,得到的是同一个数字。不符合三元组的条件。
while循环内部重复条件
while循环内部,由于left和right已经发生了变化,因此循环内部如果要用到left<right的大前提,必须再重复一遍。
完整版
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>>result;
sort(nums.begin(),nums.end());
for(int i=0;i<nums.size();i++){
if(nums[i]>0){
return result;
}
if(i>0&&nums[i]==nums[i-1]){
//这里不能用i++,因为i++一次,下一个i可能还是重复的。因此需要反复去重。
//i++;
continue;
}
//如果基本满足且不重复
int left = i+1; //left永远是i后面一个
int right = nums.size()-1; //right永远是最后一个
while(left<right){
if(nums[i]+nums[left]+nums[right]>0) right--;
else if(nums[i]+nums[left]+nums[right]<0) left++;
else{
result.push_back({nums[i],nums[left],nums[right]});//注意这里是尾插,向二维数组中插入一维数组,不是insert
while(left<right&&nums[left+1]==nums[left]){
left++;
}
while(left<right&&nums[right-1]==nums[right]){
right--;
}
left++;
right--;
}
}
}
return result;
}
};
if-else if-else语句的问题
while(left<right){
if(nums[i]+nums[left]+nums[right]>0) right--;
else if(nums[i]+nums[left]+nums[right]<0) left++;
else{
在这个特定的情况下,连续的两个if
语句可能会导致数组越界问题。
假设第一个if
语句成立,那么right
会减1,然后立即进入下一个if
语句,再次计算三数之和并进行比较。这时,如果right
已经是0,那么right
再减1就会导致数组越界。
**如果使用else if
,那么只有在第一个if
语句不成立的情况下,才会检查第二个if
语句。**这样就避免了在right
减1之后立即进行下一个比较,从而避免了数组越界的问题。
同样的道理也适用于left++
。如果left
已经是数组的最后一个元素,再执行left++
就会越界。使用else if
也可以防止这种情况。
所以,在这个特定的情况下,else if
是更好的选择。
if
,else if
,else
的结构适用于排他性的条件,即这些条件是互斥的,同时只能满足一个。
对于一组条件:
- 如果它们是互斥的(即同时只能满足一个),那么你应该使用
if
,else if
,else
结构。 - 如果它们并不互斥(即可能同时满足多个),那么你应该使用连续的
if
结构。
本题代码中,由于你在满足某个条件后改变了left
或right
的值,然后立即进行下一个条件判断,这时候就可能会出现数组越界的问题。所以在这种情况下,你应该使用if
,else if
,else
结构,以保证在改变left
或right
后不立即进行下一个条件判断。
vector二维数组的尾插法与insert
result.push_back({nums[i],nums[left],nums[right]});//注意这里是尾插,向二维数组中插入一维数组,不是insert
insert的用法:
result
是一个vector<vector<int>>
,它的insert
方法是用来在特定位置插入元素的。它的用法如下:
iterator insert (const_iterator position, const T& val);
它接收两个参数,第一个是插入位置,第二个是要插入的值。但在原代码中,直接调用了result.insert({nums[i],nums[left],nums[right]});
,这里没有指定插入的位置,导致代码错误。
**对于二维数组vector<vector<int>>
,如果想在其末尾添加一个新的一维数组,应使用push_back
方法,而不是insert
方法。**修改后的代码如下:
3.四数之和
给你一个由 n 个整数组成的数组 nums ,和一个目标值 target 。请你找出并返回满足下述全部条件且不重复的四元组 [nums[a], nums[b], nums[c], nums[d]] (若两个四元组元素一一对应,则认为两个四元组重复):
0 <= a, b, c, d < n
a、b、c 和 d 互不相同
nums[a] + nums[b] + nums[c] + nums[d] == target
你可以按 任意顺序 返回答案 。
示例 1:
输入:nums = [1,0,-1,0,-2,2], target = 0
输出:[[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]
思路
四数之和和三数之和思路很像,找出四个数字相加=target ,是输入的数字,不是固定为0
实际上,即为在三数之和基础上套上一层for循环
剪枝操作
三数之和中,如果第一步if nums[i]>0,就直接选择返回继续下一个i,这种操作叫做剪枝(也就是直接去除一种可能性)剪枝前提是已经排序完成
注意:
本题目中最小的为k 如果nums[k]>target,不能直接做剪枝操作,因为本题目并不是和为0,而是和为target,且为整数数组,nums可能是负数。因此,target!=0的时候,nums又能取负数,这种情况下是不能直接剪枝nums[k]>target的。
因为有负数的情况下,两数相加并不一定会变得更大。
例如,target=-5,nums=[-4,-1,0,0]
这种情况剪枝会错过结果集
关键为本题目target和nums都可以是负数。
因此本题目的剪枝有前提条件,即为
if(nums[k]>target&&nums[k]>0){ //这里的剪枝条件是否可以不加上target>0?
break;
}
去重操作
nums[k]的去重:
if(k>0&&nums[k]==nums[k-1]){ //防止数组越界必须k>0
continue;
}
下面部分就是三数之和的逻辑,但是进入i循环之后,还要继续对i进行剪枝与去重操作
if(k>0&&nums[k]==nums[k-1]){ //防止数组越界必须k>0
continue;
}
for(i=k+1;i<nums.size();i++){
//这里可以对i进行二级剪枝
if(nums[i]+nums[k]>target&&nums[i]+nums[k]>0&&target>0){
break;//直接剪枝
}
//去重
if(i>(k+1)&&nums[i]==nums[i-1]){//注意这里的i要大于k+1,因为最开始取的是k
continue;
}
left=i+1;
right=nums.size()-1;
if(nums[i]+nums[k]+nums[left]+nums[right]>target){
right--;
}
else if(nums[i]+nums[k]+nums[left]+nums[right]<target){
left++;
}
else{
result.push_back({nums[k],nums[i],nums[left],nums[right]});
while(nums[left]==nums[left+1]){
left++;
}
while*(nums[right]==nums[right-1]){
right--;
}
left++;
right--;
}
}
完整版
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>>result;
sort(nums.begin(),nums.end());
for(int k=0;k<nums.size();k++){
if(nums[k]>0&&nums[k]>target&&target>0){ //&&target>0可以删掉
return result;
}
//不能忘了对nums[k]去重
if(k>0&&nums[k]==nums[k-1]){
continue;
}
for(int i=k+1;i<nums.size();i++){
if(nums[k]+nums[i]>target&&nums[k]+nums[i]>0&&target>0){
break; //这里是break
}
//剪枝完了也要对i去重
if(i>k+1&&nums[i]==nums[i-1]){ //注意去重的时候这里是i>k+1不是i>k?
continue;
}
int left=i+1;
int right = nums.size()-1;
//注意此处大循环:左指针和右指针碰到一起之前,循环都不能结束
while(left<right){
if((long)nums[k]+nums[i]+nums[left]+nums[right]>target){
right--;
}
else if((long)nums[k]+nums[i]+nums[left]+nums[right]<target){
left++;
}
else{
result.push_back(vector<int>{nums[k],nums[i],nums[left],nums[right]});
//注意左右指针去重的时候,一定要强调大前提
while(left<right&&nums[left]==nums[left+1]){
left++;
}
while(left<right&&nums[right]==nums[right-1]){
right--;
}
left++;
right--;
}
}
}
}
return result;
}
};
注意点:
- 大致思路:剪枝–去重–循环查找
- 剪枝的时候一定要注意target>0和nums[k]>0的边界条件
- k,i,左右指针去重的时候,一定要注意前提条件,前提条件需要在括号内重复,例如
k>0
,i>k+1
,left<right
.不能越界。i>k
是没有必要的,因为k已经之前被判断过了。
问题1:(long)nums[k]+nums[i]+nums[left]+nums[right]
为什么必须强调long?
出现的问题:不加long的时候,出现溢出错误
在C++中,int
(整型)通常有32位,可以存储的最大正整数是 2^31 - 1,约等于21亿。当计算的结果超过这个值时,就会发生溢出,得到的结果是不正确的。
在该代码中,数组 nums
是 int
类型,也就是说数组中的每个元素都是 int
类型。当你对四个 int
类型的数进行加法运算时,如果这四个数的和超过了 int
类型的最大值,就会发生溢出。
这就是为什么在代码中需要将 nums[k]
,nums[i]
,nums[left]
,nums[right]
这四个 int
类型的数强制转换为 long
类型。因为 long
类型通常有64位,可以存储的最大正整数远大于 int
类型,因此加法运算不会发生溢出。
补充:(long)nums[k]+nums[i]+nums[left]+nums[right]
是同时转换了4个数字吗
在C++中,(long)nums[k]
这个表达式的含义是将 nums[k]
强制转换为 long
类型。然后在执行 nums[k]+nums[i]+nums[left]+nums[right]
这个加法操作时,C++会遵循所谓的"算术转换"规则。
**当一个表达式中包含多种不同的数据类型时,C++会自动将所有的操作数提升到最高精度的操作数的类型。**在这个例子中,由于 nums[k]
已经被转换为 long
类型,而 long
类型的精度高于 int
类型,所以 nums[i]
、nums[left]
和 nums[right]
都会被自动提升到 long
类型进行计算。这样,整个加法运算就不会发生溢出了。
所以,尽管只显式地将 nums[k]
转换为 long
类型,但在实际的加法运算中,所有的操作数都会被处理为 long
类型。
问题2:第二层循环的for里面不能是return
二层for循环的剪枝那里,应该将return换成break。不满足条件应该退出当前的循环而不是整个循环。
问题3:i
的去重条件是i>k+1
不是i>k
?
k+1不循环是对数值的保护。在特殊情况,比如数组所有元素值相同的时候,第一个元素不进行去重是为了能将四个数值都在数组中的位置初始化出来。就像第一层循环要k>0在进行后面判断一样,一方面是为了可以查询到k-1,另一方面也保证就算所有元素相同,代码也可以初始化出四个数的位置,以此跑一次完整的流程。如果一上来i>k条件被满足了,那么在后面元素相同的情况下第二个数就直接跑到数组末尾了,后面第三个数和第四个数根本没有被设置位置和进行迭代。
也就是说,假设此时k=0那么此时的i=1,按照代码逻辑,此时如果是i>k,碰巧的是此时的nums[i-1]==nuns[i]
这样就错误的舍弃了一种正确的满足条件的四元祖,引发这种错误的根本原因就是第一轮的第一个位置和第二个位置进行比较这是不对的,去重的话应该是每一轮对应的位置进行比较。i>k+1的作用就是让对应的位置相比较。