今天的四道题着实费了不少功夫, 只有第2题比较简单。第1题在看过题解后实现上并不难,第3、4题不仅剪枝思路需要考虑周到,实现也容易出错。第3题的哈希方法版题解着实很难理解,相比之下双指针法要容易很多。第1、4题,第3、4题之间都有相似之处,需要对比它们的不同之处。
第1题(454.四数相加II)在冥思苦想后,得到的思路是对4组数分别两两配对,比如第一次1、2,3、4分别配对,第2次1、3,2、4分别配对······总共配对3次。每次配对后的两组分别进行双重循环,从第一组的2个小组中分别取2个数取和放到unordered_map中,再在第二组的2个小组双重循环时取和,在unordered_map中查找其相反数,从而实现O(n²logn)的时间复杂度。
看了题解后发现基本思路没问题,但考虑得过于复杂了。因为四数之和等于0的话,那么不论如何分组,分别对组内两数字求和,得到的两个结果都必定为相反数。也就是说,我自己思路中的第一次配对,已经涵盖了所有可能的情况,所以只需要1、2组,3、4组分别配对即可,无需后面的多次配对。
#include<unordered_map>
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 ans = 0;
for (int c : nums3) {
for (int d : nums4) {
auto it = map.find(0 - c - d);
if (it != map.end()) {
ans += it->second;
}
}
}
return ans;
}
};
从题解代码中学习到了遍历vector比较方便的方法,如代码7、8、13、14行。
二刷:没有用map,错用成了set,导致漏了(a + b)想等的重复值。
第2题(383. 赎金信)比较简单,只需先统计出magazine中各字符出现次数,再遍历ransomNote,将对应字符的次数减一,再判断次数是否小于0即可。在实现过程中才意识到这个题目同242.有效的字母异位词一样并不需要unordered_map,只需长度为26的int数组即可。
#include<unordered_map>
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int map[26] = {0};
for (char c : magazine) {
map[c - 'a']++;
}
for (char c : ransomNote) {
if (--map[c - 'a'] < 0) {
return false;
}
}
return true;
}
};
二刷:忘记判断map中字符对应数字是否等于0。
第3题(15. 三数之和)是近期遇到的最难的一道题,只能相当双重循环的哈希法,但这个方法的去重部分还是不会。看过哈希法的题解后,发现这个问题的关键是去重,总结为以下几个去重的关键点:
- a(三个数中第一个)的去重有2种写法,应该用哪种?
if (nums[i] == nums[i + 1]) {
continue;
}
这种写法在连续的a的末尾才开始尝试a,之前的部分就会被略过,所以面对{-1, -1 ,2}这种情况时就会出错。正确的写法应该是在连续的a的开始就尝试a,再忽略掉以后的:
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
- b的去重应该和a一样还是有所区别?(这里是思考了许久的关键问题)因为b不仅涉及自身,还是c所在范围的开始,b和c可能相等(如a,b,c分别为-2,1,1),所以如果使用与a相同的去重方法的话,就会错误去重掉b和c相等且符合要求的情况,所以在连续3次重复时才跳过,而非连续2次重复就跳过。
- c的去重看似不需要也不应该,但c与b是相关的,在a确定的情况下,如果c取了重复的值,那么b的取值也一定是与之前重复的,所以有必要对c进行去重。
解决了去重的难题后的代码如下:
#include<algorithm>
#include<unordered_set>
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end());
vector<vector<int>> ans;
int n = nums.size();
for (int i = 0; i < n - 2; ++i) {
if (nums[i] > 0) {
return ans;
}
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
else if (nums[i] + nums[i + 1] + nums[i + 2] > 0) {
continue;
}
else if (nums[i] + nums[n - 2] + nums[n - 1] < 0) {
continue;
}
unordered_set<int> set;
for (int j = i + 1; j < n; ++j) {
if (j > i + 2
&& nums[j] == nums[j - 1]
&& nums[j - 1] == nums[j - 2]) { // b和c可能相等(如a,b,c分别为-2,1,1),所以第3个重复时才跳过
continue;
}
int c = 0 - nums[i] - nums[j];
if (set.find(c) != set.end()) {
ans.push_back({nums[i], nums[j], c});
set.erase(c);
}
else {
set.insert(nums[j]);
}
}
}
return ans;
}
};
哈希法的题解代码中还有2个需要关注的地方。第1个是剪枝,在外层循环一开始时,可以利用已经排好序的数组的边界和当前a的值进行一些简单判断,达到剪枝的目的。第2个是注意vector和数组在sort()的使用上有所不同,传入的参数是.begin()和.end()。
哈希法最终的时间,空间消耗都较高,且实现复杂去重非常容易出错,所以双指针法才是正解。双指针法自己同样想不出来,只得去看题解。双指针的思路是外层循环遍历a(与哈希法一样需要去重),内层制定指向b,c的左右指针,初始时分别指向a的右邻居和数组最后一位,再根据a、b、c三者的和与0的关系分别进行右移和左移。和恰好等于0时就保存一个答案并对b,c进行去重操作。代码如下:
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end());
vector<vector<int>> ans;
int n = nums.size();
for (int i = 0; i < n - 2; ++i) {
if (nums[i] > 0) {
return ans;
}
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
else if (nums[i] + nums[i + 1] + nums[i + 2] > 0) {
continue;
}
else if (nums[i] + nums[n - 2] + nums[n - 1] < 0) {
continue;
}
int l = i + 1, r = n - 1;
while (l < r) {
if (nums[i] + nums[l] + nums[r] < 0) {
++l;
}
else if (nums[i] + nums[l] + nums[r] > 0) {
--r;
}
else {
ans.push_back({nums[i], nums[l], nums[r]});
l++;
r--;
while (l < r && nums[l] == nums[l - 1]) {
l++;
}
while (l < r && nums[r] == nums[r + 1]) {
r--;
}
}
}
}
return ans;
}
};
时间和空间消耗相比哈希法都大大减少,但还有些需要注意的地方:
- 三者和等于0时,添加完答案后,需要在此时对b,c去重。其他2种情况的去重不必要,因为即便不去重,按照代码逻辑也会在下次循环时进行指针移动操作。
- 对b,c去重时,循环条件里也需像外层循环一样加上l < r,否则可能导致数组越界。
二刷:没想到解法。
第4题(18. 四数之和)相比于第1题(454.四数相加II)的不同之处在于:4个数组变为1个数组,所以需要去重,哈希方法不再合适;
相比于第3题(15. 三数之和),除了3个数变为4个数之外的不同在于:目标和不再是0。
这道题也直接看了视频题解,整体思路与第3题一致,多了一层循环的同时在剪枝部分稍有不同。由于上面的第2个不同,循环中的当前位置之后还可能包含负数,所以在剪枝时需要确保当前数字 >= 0,在此基础上再判断当前数字与target的关系来决定是否剪枝(17~19,31~33行),其改进版的剪枝也是如此(13~16,27~30行)。
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
sort(nums.begin(), nums.end());
vector<vector<int>> ans;
int n = nums.size();
for (int i = 0; i < n - 3; ++i) {
if (i > 0 && nums[i] == nums[i - 1]) { // 去重
continue;
}
if ((long) nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] >= 0
&& (long) nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target) { // break型剪枝
break;
}
// if (nums[i] >= 0 && nums[i] > target) { // break型剪枝(被上面的break型剪枝所包含)
// break;
// }
if ((long) nums[i] + nums[n - 3] + nums[n - 2] + nums[n - 1] < target) { // continue型剪枝
continue;
}
for (int j = i + 1; j < n - 2; ++j) {
if (j > i + 1 && nums[j] == nums[j - 1]) { // 去重
continue;
}
if ((long) nums[i] + nums[j] + nums[j + 1] + nums[j + 2] >= 0
&& (long) nums[i] + nums[j] + nums[j + 1] + nums[j + 2] > target) { // break型剪枝
break;
}
// if (nums[i] + nums[j] >= 0 && nums[i] + nums[j] > target) { // break型剪枝(被上面的break型剪枝所包含)
// break;
// }
if ((long) nums[i] + nums[j] + nums[n - 2] + nums[n - 1] < target) { // continue型剪枝
continue;
}
int l = j + 1, r = n - 1;
while (l < r) {
if ((long) nums[i] + nums[j] + nums[l] + nums[r] < target) {
l++;
}
else if ((long) nums[i] + nums[j] + nums[l] + nums[r] > target) {
r--;
}
else {
ans.push_back({nums[i], nums[j], nums[l], nums[r]});
l++;
r--;
while (l < r && nums[l] == nums[l - 1]) {
l++;
}
while (l < r && nums[r] == nums[r + 1]) {
r--;
}
}
}
}
}
return ans;
}
};
代码实现过程也并不顺利,踩了下面几个坑:
- 错把continue型的剪枝写成了break。说明剪枝时用break还是continue很重要,容易出错(在这里耗了不少时间)。
- 4个数字相加时会溢出int范围,需要转为long。这里学习到在两int数字比较大小时,可以直接把可能溢出的一个转为long,另一个int可以不用转。
二刷:continue和break没注意分清楚。