个人主页:@Sherry的成长之路
学习社区:Sherry的成长之路(个人社区)
专栏链接:练题
长路漫漫浩浩,万事皆有期待
这一期的题我刷的心力交瘁,感觉题目很类似,但是就是做不出来,第三道卡了一会,后来自己模拟了一遍才理解了。
同时这一篇的题,也告诉我们不是所有这种类型的题,用哈希算法来解都简单,后两种用指针法做比哈希表解更好
题目大意就是在四个数组中,找到一个四元组相加的和为0,为什么说它较为简单呢?原因有两个它并不涉及到去重,也就是说四元组中元素可以重复,只要保证它们是来自于不同的数组就可以了。
大体思路为:用一个map哈希结构来保存前两个数组的元素之和的所有可能情况,那么问题来了我们为什么要用到map而不是其他的哈希结构呢?原因很简单,四个数组中元素个数不固定,相加大小也不固定,用数组可能会存不下,不用set的原因是因为我们需要统计前两个数组和相同的答案在哈希中出现了几次,所用map更适合,为什么要统计这个,在代码给出后会进行更详细解释。
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int,int> umap;//key:a+b的数值,value:a+b数值出现的次数
for(int a:nums1)// 遍历大A和大B数组,统计两个数组元素之和,和出现的次数,放到map中
{
for(int b:nums2)
{
umap[a+b]++;
}
}
int count=0;// 统计a+b+c+d = 0 出现的次数
for(int c:nums3)// 在遍历大C和大D数组,找到如果 0-(c+d) 在map中出现过的话,就把map中key对应的value也就是出现次数统计出来。
{
for(int d:nums4)
{
if(umap.find(0-(c+d))!=umap.end())
{
count+=umap[0-(c+d)];
}
}
}
return count;
}
};
auto是c++的独特书写语法,在看完上述代码,应该对于为什么要使用map有了一点模糊的想法,下面我给大家详细解答一下。由于该题目,要求四元组的值不同,只要四元组各答案的下标没有和其他四元组一一对应的重复就可以,如果这样讲有点抽象,我来举例说明,当后两个数和为-5的时候说明我们应该在前两个数里面找到5来相结合,这显而易见,那么我们如果找出a[1]+b[2]=-5,a[2]+b[3]=-5,a[3]+b[3]=-5诸如此类的情况呢?这些构成四元组的答案,实际上都是合法的,这就是我们为什么要存储相同的数值不同的数组下标构成的答案的原因。
时间复杂度: O(n^2)
空间复杂度: O(n^2),最坏情况下A和B的值各不相同,相加产生的数字个数为 n^2
383. 赎金信 - 力扣(LeetCode)
暴力解法
第一个思路其实就是暴力枚举了,两层for循环,不断去寻找,代码如下:
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
for (int i = 0; i < magazine.length(); i++)
{
for (int j = 0; j < ransomNote.length(); j++)
{
// 在ransomNote中找到和magazine相同的字符
if (magazine[i] == ransomNote[j])
{
ransomNote.erase(ransomNote.begin() + j); // ransomNote删除这个字符
break;
}
}
}
// 如果ransomNote为空,则说明magazine的字符可以组成ransomNote
if (ransomNote.length() == 0)
{
return true;
}
return false;
}
};
时间复杂度: O(n^2)
空间复杂度: O(1)
这道题的思路和上一期的那道有效字母异位词思路差不太多,这道题是在一个字符串里找字符看能否拼成另一个字符串,字符只能用一次,和那道题的不同之处在于,上期的那道题我们两个字符串的字母构成要求是一样的,这道题仅仅需要用一个字符串里的全部或部分字符来构成另一个字符串,这就足够了。
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int record[26] = {0};
//add
if (ransomNote.size() > magazine.size())
{
return false;
}
for (int i = 0; i < magazine.length(); i++)
{
// 通过record数据记录 magazine里各个字符出现次数
record[magazine[i]-'a'] ++;
}
for (int j = 0; j < ransomNote.length(); j++)
{
// 遍历ransomNote,在record里对应的字符个数做--操作
record[ransomNote[j]-'a']--;
// 如果小于零说明ransomNote里出现的字符,magazine没有
if(record[ransomNote[j]-'a'] < 0)
{
return false;
}
}
return true;
}
};
15. 三数之和 - 力扣(LeetCode)
其实这道题目使用哈希法并不十分合适,因为在去重的操作中有很多细节需要注意,很难直接写出没有bug的代码。而且使用哈希法 在使用两层for循环的时候,能做的剪枝操作很有限,虽然时间复杂度是O(n^2),也是可以在leetcode上通过,但是程序的执行时间依然比较长 。
这道题我认为思路上还是有难度在的,重要的是去重的思路,这道题是使用指针来实现的,题目大意是在一个给定的数组内,寻找一个三元组的和来等于0,该题为什么可以应用于指针解法呢?一个重要的判断是因为,该题返回的是数组中的元素组合,元素组合相加等于0,而并非让我们返回下标,使用双指针思路,一定要注意排序,而返回数组下标则不能排序,会打乱下标顺序。
先给出代码
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
// 找出a + b + c = 0
// a = nums[i], b = nums[left], c = nums[right]
for (int i = 0; i < nums.size(); i++)
{
// 排序之后如果第一个元素已经大于零,那么无论如何组合都不可能凑成三元组,直接返回结果就可以了
if (nums[i] > 0)
{
return result;
}
// 错误去重a方法,将会漏掉-1,-1,2 这种情况
/*
if (nums[i] == nums[i + 1]) {
continue;
}
*/
// 正确去重a方法
if (i > 0 && nums[i] == nums[i - 1])
{
continue;
}
int left = i + 1;
int right = nums.size() - 1;
while (right > left)
{
// 去重复逻辑如果放在这里,0,0,0 的情况,可能直接导致 right<=left 了,从而漏掉了 0,0,0 这种三元组
/*
while (right > left && nums[right] == nums[right - 1]) right--;
while (right > left && nums[left] == nums[left + 1]) left++;
*/
if (nums[i] + nums[left] + nums[right] > 0) right--;
else if (nums[i] + nums[left] + nums[right] < 0) left++;
else {
result.push_back(vector<int>{nums[i], nums[left], nums[right]});
// 去重逻辑应该放在找到一个三元组之后,对b 和 c去重
while (right > left && nums[right] == nums[right - 1]) right--;
while (right > left && nums[left] == nums[left + 1]) left++;
// 找到答案时,双指针同时收缩
right--;
left++;
}
}
}
return result;
}
};
第一层循环里第一个if相当于剪枝操作,排序后的第一个数组元素如果大于0,那么说明该数组不可能有三元组相加等于0了,这是一个技巧可以帮助我们来过滤一些数组,提高运行效率。第二个if是帮助第一个循环里的i去重的,取到的重复元素假设为1,那么这个1包含了你下个1之后的所有元素组合,他们的情况都是完全一样的,所以我们并不需要重复遍历。
第二个循环中代码核心主要是排序部分,起初left指向的是i的下一个位置,而right指向的是数组的最后一个元素,如果三个数相加和大于0那么将right向前挪动一位,如果小于0,那么就将left向后挪一位,这就体现了为什么使用指针法,为什么要对数组进行排序!当前面的调整完毕后,找到一组数据将其放入结果数组中,之后就到了最关键的一步,用两个while循环来去重left和right指向的值,当left大于等于right了立马跳出。那么为什么之后我们还是需要进行left++和right- -呢?不是只有left和right不跟相邻元素相等时才跳出来的吗?我们再进行left++和right的- -操作会不会略过去一些答案呢?
试想一下以下两种不同的情境:
第一种:没有相同的元素,当我们将答案中的一个三元组存放到结果数组后,由于没有数字重复,无法通过循环来调整left和right位置那么left和right不能得到更新会使循环陷入死循环。
第二种情况:存在相同的元素,在存放一个三元组后,我们发现left或者right相邻值有重复的情况,亦或是两者都存在相邻值与之前答案的元素相同的情况,那么我们直接进入while循环,重点来了,在循环内我们是判断left和left++,right和right- -的数有没有出现重复的情况,这样看来,我们结束循环跳出来之后的left和right指向了我们重复元素的最后一次重复的地方,因为它的确不和下一个我们要判断的数字相等,所以跳了出来,如果没有left++和right- -来调整,那么我们还会遍历到之前答案的数,这也就是为什么我们一定要有left++和right- -的缘故。
那么可不可以将left++和right–放到前面呢?答案也是可以的,如果大家觉得先判断去重,再缩小范围的思路有点怪,也可以先left++和right–调整搜索范围,再对left和right进行一个去重的判断,只不过那样我们的去重逻辑有一点小变化,这时你需要将left和它的前一个作比较,right和它的后一个作比较,理由也是和上一个思路一样的,为了避免重复遍历答案,目的是直接将此时的left和right直接置到和上一个答案三元组不同的元素中。
时间复杂度: O(n)
空间复杂度: O(1)
关于 三数之和
● 对于continue和break在三数之和的区别: continue是下一个i 还存在有可能的情况, break是无论后面多少个i我们是确定不会再出现这样的情况了
● 三数之和去重为什么碰到相邻相同的元素跳过:
一个是因为之前已经判断过,不必再判断,一个是因为必须要减枝,不然会超时,还有就是题目要求不能重复,再次执行会导致重复添加数据。
● 三数之和这么去重,是怎么保证a的去重,而没有把合适的b也去掉了呢?
因为b在a后面,当a的数字确定,后面b+c的值也确定,比如序列-2,-2,4,8,16,第一次遇到某一个a=-2的时候已经把后面所有b+c=2的情况跑完了,第二次再次遇到a=-2的时候已经没有跑的意义了,更遑论有没有去掉合适的b,而且有可能第一次遇到a=-2的时候,可能正好有b=-2,c=4这种情况,但你第二次遇到a=-2,再去跑,就会发现甚至这种情况还会被漏掉。 nums[i] == nums[i-1]基本就是应对这两种情况
● 对于哈希法,b和c去重的逻辑:
对于 b 的去重,一般可以和 a 一样检查当前的 b 是否和前一个 b 相同,如果相同,则跳过当前的 b。这样可以保证每个 b 只被使用一次。但是这种方法有一个问题,就是如果数组中有连续三个或以上相同的元素,那么第一个和第二个元素都会被跳过,导致漏掉一些可能的解。例如,如果数组中有三个0,那么[0,0,0]就是一个有效的解,但是用这种方法就会被忽略。
为了解决这个问题,可以改进一下条件,只有当前的 b 和前两个 b 都相同时才跳过当前的 b。这样可以保 证至少有一个 b 被使用,并且不会出现重复。
对于 c 的去重,利用哈希集合的特性,在找到一个 c 后将其从哈希集合中删除。这样可以保证每个 c 只被使用一次且不会出现重复。
18. 四数之和 - 力扣(LeetCode)
这道题的解题思路和上一道的三数之和思路十分类似,题目大意是给你一个数组,要求返回若干四元组和为target,在剪枝方面与上一题有所不同,因为上一题是总和等于0,那么排序后第一个元素大于0,直接剪枝,但是这道题是要求四元组和构成target,而target可能是负数,所以不能单纯判断其大于0,那么可不可以单纯判断nums[i]>target呢?也是不可以的,因为如果第一个元素是大于target的负数,但是第二个元素也是一个负数,那说不定就落下一个答案了。这些细节我们都需要注意,先看代码
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] > target && nums[k] >= 0)
{
break; // 这里使用break,统一通过最后的return返回
}
// 对nums[k]去重
if (k > 0 && nums[k] == nums[k - 1])
{
continue;
}
for (int i = k + 1; i < nums.size(); i++)
{
// 2级剪枝处理
if (nums[k] + nums[i] > target && nums[k] + nums[i] >= 0)
{
break;
}
// 对nums[i]去重
if (i > k + 1 && nums[i] == nums[i - 1])
{
continue;
}
int left = i + 1;
int right = nums.size() - 1;
while (right > left)
{
// nums[k] + nums[i] + nums[left] + nums[right] > target 会溢出
if ((long) nums[k] + nums[i] + nums[left] + nums[right] > target) {
right--;
// nums[k] + nums[i] + nums[left] + nums[right] < target 会溢出
}
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]});
// 对nums[left]和nums[right]去重
while (right > left && nums[right] == nums[right - 1]) right--;
while (right > left && nums[left] == nums[left + 1]) left++;
// 找到答案时,双指针同时收缩
right--;
left++;
}
}
}
}
return result;
}
};
代码不算短,有很多嵌套逻辑,但是整体和三数之和是一样的,只不过四数之和多了一层循环仅此而已,同样的前两层可以做适当的剪枝操作,每一层都需要进行判重,防止四元组重复,
需要注意的点在于,剪枝操作和三数之和有所不同,最后一层判断时,要加上强制类型转换为long类型,以防止数据和过大,int放不下。
关于 四数之和
● 四数之和里针对剪枝的if语句进行了讨论,在第二层剪枝中使用if(nums[k] + nums[i] > target && nums[i] > 0)剪枝范围会更大(原本是if (nums[k] + nums[i] > target && nums[k] + nums[i] >= 0))
● 剪枝条件必须要加,leetcode上有一组[100000, 100000, 100000, 100000]的输入,target为-2^31 ,这组数据应该输出空数组,但是这四个数相加会溢出等于-2^31次方,然后输出[100000, 100000, 100000, 100000]的四元组,这是错误的,所以剪枝条件必须要加,判断 nums[k] + nums[i] 是正数又大于target后,可以直接return,因为排序后正数往后都是正数了,相加只会越来越大,没理由能找到满足target的四元组。
今天的题都有一定难度,相关的思想需要多复习回顾。接下来,我们继续进行算法练习·。希望我的文章和讲解能对大家的学习提供一些帮助。
当然,本文仍有许多不足之处,欢迎各位小伙伴们随时私信交流、批评指正!我们下期见~