文章目录
- 454.四数相加II
- 思路
- 383.赎金信
- 思路
- 15.三数之和
- 思路
- 剪枝+去重
- 18.四数之和
- 思路
- 剪枝+去重
- 复习:C++中的类型转换方法
- 总结
今天是哈希表专题的第二天
废话不多说,直接上题目
454.四数相加II
建议:本题是 使用map 巧妙解决的问题,好好体会一下 哈希法 如何提高程序执行效率,降低时间复杂度,当然使用哈希法 会提高空间复杂度,但一般来说我们都是舍空间 换时间, 工业开发也是这样。
题目链接:454. 四数相加 II - 力扣(LeetCode)
题目说明:本题需要考虑四个数组如何组合,加和为0。只需要计算有多少种组合,而不需要写出具体的组合方式。同时,本题认为:对于数组中位置不同的元素a、b,即使a与b的值相同(a == b),a、b也被视为不同的元素,所以我们不需要“去重”,比如四个数组的元素都是n个0,则认为有不只一个组合,每个组合都是0000、0000···,区别在于不同组合中的0取自数组的不同位置(比如第一个组合的第一个0取自nums1[0]
,第二个组合的第一个0取自nums1[1]
),这是本题与后面的一道题目 18.四数之和 的不同之处
思路
这道题目解法很巧妙,但是有迹可循:前一篇文章day6中的最后一道题 1.两数之和 思路与本题相似,我们可以参照 两数之和 中的思路
两数之和中,需要在同一个数组中找到相匹配(a+b == target)的元素a、b的下标,思路是:遍历一遍整个数组,同时创建一个map映射作为哈希表记录遍历过的元素的值和下标。每遍历到一个元素a,就从哈希表中查找是否有相匹配的元素b,即auto it = map.find(target-a)。若b在哈希表中,则返回a和b的下标,即return {i, it->second},遍历结束;否则,将元素a的 值和下标 作为键值对,添加到map中,继续遍历
我们把这个思路应用到这道题中:首先遍历数组A、B,使用map记录数组A、B的和(a+b),然后双层for循环,求数组C、D的和(c+d),在 map中查找是否存在相匹配的a+b(相匹配指的是a+b+c+d == 0),即auto it = map.find(0-(c+d))。这是一个大概的思路,我们接下来讨论一些细节问题
-
为什么不用数组或集合作为哈希表
数组作为哈希表乍一看可以,但是本题中a+b的值是没有大小限制的,这就戳到了数组的痛点:键的大小必须有大小限制,因此不能使用数组作为哈希表
集合中只能存储键a+b的值,无法存储a+b出现次数的信息,因此也不能使用集合作为哈希表
-
map的键值是什么
题目需要求出a+b+c+d == 0的组合数量,而不用列出具体的组合的方式。如果0-(c+d)在map中找到了,即it != map.end(),那么it->second应该是a+b+c+d == 0的组合数量,这个组合的数量 等于 a+b=0-(c+d)出现的次数,用下面的例子解释:
若当前c=-1,d=2,那么要从map中找到a+b = 0-(c+d) = -1。map中,a+b = -1共出现了一次:a=1,b=-2,所以当c=-1,d=2时,a+b+c+d == 0这样的组合只有一个,就是a+b=-1出现的次数
通过上述分析可知,我们使用0-(c+d)在map中寻找,需要获得a+b=0-(c+d)的出现次数,因此map的键值对为**<a+b的值val, a+b=val的出现次数>**
-
能不能只用A构建map,然后再遍历B、C、D三个数组寻找0-(b+c+d)
可以,但是时间复杂度是O(n3),而我们两两遍历,时间复杂度是O(n2),因此还是用A、B构建map,然后再遍历C、D更加合适
-
整体思路
首先遍历数组A、B,使用map记录数组A、B的和(a+b),key存放a和b两数之和,value存放a和b两数之和出现的次数。然后遍历数组C、D,求数组C、D的和(c+d),在map中查找是否存在相匹配的a+b(相匹配指的是a+b+c+d == 0),即auto it = map.find(0-(c+d))。如果找到了,即it != map.end(),那么count += it->second(count加上key对应的value),统计这个组合的数量;否则不做处理。遍历完C和D后返回count,这个count就是四个数组中a+b+c+d == 0的组合数量
代码实现
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
// 思路就是先进行两个数组A、B的加法,得到一个哈希表,key为a+b的值,value为这个值出现的次数
// 然后双层for循环,先对C、D做加法,和为c+d,然后再哈希表中查找0-(c+d),如果找到了,则这个键对应的值是a+b+c+d=0组合的出现次数
std::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)
{
auto it = map.find(0-(c+d));
if( it != map.end())
{
count += it->second;
}
}
}
return count;
}
};
- 时间复杂度: O(n2)
- 空间复杂度: O(n2),最坏情况下A和B的值各不相同,相加产生的数字个数为 n2
383.赎金信
本题 和 242.有效的字母异位词 是一个思路 ,算是拓展题
题目链接:383. 赎金信 - 力扣(LeetCode)
思路
本题中两个字符串都是用小写字母组成,key的大小是有限制的,所以我们可以使用数组实现哈希表。数组的key是 字符串magazine中的各个字符-‘a’,value是 字符串magazine中的各个字符出现的次数
由于map需要维护红黑树或者哈希表,而且还要做哈希函数,所以很费时,而且map的空间消耗要比数组的大,所以能用数组就不map,使用数组更加简单有效
代码实现
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
vector<int> record(26, 0);
if(ransomNote.size() > magazine.size())
{
return false;
}
// 首先统计magazine中各个字出现的次数
for(char c : magazine)
{
record[c-'a']++;
}
// 然后检查这些字符是否能组成ransomNote
for(char c : ransomNote)
{
if(--record[c-'a'] < 0) // 注意是--record,不是record--,要先减再判断
{
return false;
}
}
return true;
}
};
- 时间复杂度: O(n)
- 空间复杂度: O(1)
15.三数之和
建议:本题虽然和 两数之和 很像,也能用哈希法,但用哈希法会很麻烦,双指针法才是正解,可以先看视频理解一下 双指针法的思路。
题目链接:15. 三数之和 - 力扣(LeetCode)
这道题的测试用例有点癫😧
思路
本题的难点在于找到不重复的三元组,这就需要去重,本题与454.四数之和II是不同的
哈希法查找元素是没有顺序的,去重的时候会很麻烦,本题使用排序+双指针法
排序+双指针法
-
初始化:首先将数组排序,然后有一层for循环遍历数组,下标i从0的开始,同时定义一个下标left定义在i+1的位置上,定义下标right在数组末尾的位置上
-
寻找过程:要在数组中找到三元组{a, b, c}使得a+b+c == 0,这里a = nums[i],b=nums[left],c=nums[right]
接下来如何移动left 和 right 呢?有三种情况:
- 如果a + b + c > 0,则说明nums[right]太大了,right–
- 如果a + b + c < 0,则说明nums[left]太小了,left++
- 如果a + b + c == 0,则找到了一个三元组{a, b, c},我们将它用一个二维数组存放起来,然后左右指针同时紧缩,即依次做right-- 和 left++
-
结束本次查找,遍历下一个元素:当left与right相遇时,我们结束left和right的移动,然后i++,移动到下一个元素,继续进行上述步骤,直至数组遍历完了
注意:while循环终止条件是right > left
还是right >= left
呢?对于这样的边界条件,我们可以设身处地的想一想,当right == left
时,nums[left]
和nums[right]
是同一个元素,此时找到的a+b+c == 0的三元组没有意义,因此循环条件是right > left
执行过程如图所示:
剪枝+去重
-
剪枝:排除后续元素全为正的数组:每次遍历都需要先判断nums[i]是否大于0。如果nums[i] > 0,则说明数组的第i位后的所有元素都是正的,不可能出现a+b+c == 0的情况
-
去重:对a去重,判断
nums[i] == nums[i-1]
,若成立,则continue比如有数组nums = {-1, -1, 2, 2}:当
i=1
时,nums[i] = -1
,而nums[i-1] = -1
,则说明-1在之前已经使用过了,即本次查找的a 和 上一次查找的a重复了。由于 上一次查找的left和right范围{-1, 2, 2} 包含了 本次查找的left和right范围{2, 2},所以在a相同的情况下,本次查找的三元组 是 上一次查找查找的三元组的子集,这就出现了三元组的重复,所以当nums[i] == nums[i-1]
成立时,直接continue,nums[i]指向下一个元素 -
去重:对b和c去重,每次找到一个三元组后判断。假设有数组nums = {-2, -1, -1, 3, 3},当a=-2时,按照双指针法过程,会得到两个三元组结果:{-2, -1, 3}和{-2, -1, 3}。
为什么会出现重复呢?分析来说:第一次循环b、c的下标left=1,right=4,a+b+c=0,然后left和right同时收缩。第二次循环b、c的下标left=2,right=3,a+b+c=0,所以这个三元组也被存放到结果集中了。明显这两次的结果出现了重复,原因就在于第一次循环中,
nums[right] == nums[right+1]
,nums[left] == nums[left-1]
,因此left和right同时收缩后,b和c的结果还是一样的。对b和c去重,就要判断nums[right] == nums[right+1] 或 nums[left] == nums[left-1]是否成立,如果成立,则需要对right-- 或 对left++
代码实现
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
int left, right;
for(int i=0; i<nums.size(); ++i)
{
if(nums[i] > 0)
{
break;
}
// 去重a
if(i > 0 && nums[i] == nums[i-1])
{
continue;
}
left = i+1;
right = nums.size()-1;
while(right > 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]});
left++;
right--;
// 去重b和c
while(right > left && nums[right] == nums[right+1])
{
right--;
}
while(right > left && nums[left] == nums[left-1])
{
left++;
}
}
}
}
return result;
}
};
- 时间复杂度: O(n^2),可以看到使用双指针减少了一层for循环,时间复杂度降低了一个量级
- 空间复杂度: O(1)
18.四数之和
建议: 要比较一下,本题和 454.四数相加II 的区别,为什么 454.四数相加II 会简单很多,这个想明白了,对本题理解就深刻了。 本题 思路整体和 三数之和一样的,都是双指针,但写的时候 有很多小细节,需要注意,建议先看视频
题目链接:18. 四数之和 - 力扣(LeetCode)
思路
这道题和 三数之和 思路相同,都是使用排序+双指针法。注意这道题需要a+b+c+d == target,所以在剪枝上与 三数之和 有所不同。
整体思路:首先对nums进行排序,然后用两层for循环遍历数组nums,获得a = nums[i]和b = nums[j],在第二层for循环中,使用双指针left和right查找与a、b相匹配的c、d(四个变量 加和为target),去重操作与 三数之和 相同
剪枝+去重
- 剪枝:target可能为负值,所以用a > target剪枝是错误的,还需要加上条件a >= 0,因此剪枝条件为:a > target && a >= 0。第2级for循环的剪枝条件为:a+b > target && a+b >= 0
- 去重:与 三数之和 相同,对a、b去重需要判断nums[i] == nums[i-1],避免对重复的a、b进行查找;对c、d去重需要判断 nums[left] == nums[left-1] 以及 nums[right] == nums[right+1],若成立,则left++ 或 right–
注意:本题有溢出情况,需要将sum转化为long long型再进行判断
代码实现
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
int left, right;
for(int i=0; i<nums.size(); ++i)
{
// 剪枝处理
if(nums[i] > target && nums[i] >= 0)
{
break;
}
// 对nums[i]去重
if(i>0 && nums[i] == nums[i-1])
{
continue;
}
for(int j=i+1; j<nums.size(); ++j)
{
// 2级剪枝处理
if(nums[i] + nums[j] > target && nums[i] + nums[j] >= 0)
{
break;
}
// 对nums[j]去重
if(j > i+1 && nums[j] == nums[j-1])
{
continue;
}
left = j+1;
right = nums.size()-1;
while(right > left)
{
// 测试用例中有溢出情况,所以要用long long计算sum
long long sum = static_cast<long long>(nums[i]) + nums[j] + nums[left] + nums[right];
if(sum > target)
{
right--;
}else if(sum < target)
{
left++;
}else
{
result.push_back(vector<int>{nums[i], nums[j], nums[left], nums[right]});
// 找到答案时,双指针同时收缩
left++;
right--;
// 双指针去重
while(right > left && nums[right] == nums[right+1])
{
right--;
}
while(right > left && nums[left] == nums[left-1])
{
left++;
}
}
}
}
}
return result;
}
};
- 时间复杂度: O(n^3)
- 空间复杂度: O(1)
复习:C++中的类型转换方法
static_cast
static_cast
是 C++ 中的一种类型转换运算符,用于在编译时执行显式类型转换。它提供了一种安全且明确的方式来进行类型转换,确保在转换过程中进行适当的检查,除了基本数据类型之间的转换(如int转换为double)、指针类型之间的转换(int* 转换为 double*)外,static_cast
还可以进行类层次结构中的转换:
假设有一个基类 Base
和一个派生类 Derived
:
class Base {
public:
virtual void show() {
std::cout << "Base class" << std::endl;
}
};
class Derived : public Base {
public:
void show() override {
std::cout << "Derived class" << std::endl;
}
};
Base* basePtr = new Derived();
Derived* derivedPtr = static_cast<Derived*>(basePtr); // 将基类指针转换为派生类指针
derivedPtr->show(); // 输出 "Derived class"
dynamic_cast
用于运行时类型检查和转换,通常用于多态类型之间的转换。适用于具有继承关系的指针或引用类型之间的转换。
class Base {
virtual void foo() {} // 必须有虚函数才能使用 dynamic_cast
};
class Derived : public Base {};
Base* basePtr = new Derived();
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); // 类层次结构中的转换
if (derivedPtr) {
// 成功转换
} else {
// 转换失败
}
reinterpret_cast
用于在几乎所有类型之间进行位级别的转换,适用于指针类型之间、整数类型与指针类型之间的转换。使用时需谨慎,因为它可能导致未定义行为。
int a = 10;
void* ptr = reinterpret_cast<void*>(&a); // 将 int* 转换为 void*
int* intPtr = reinterpret_cast<int*>(ptr); // 将 vo
感谢 chatgpt
总结
今天完成了四道题,这四道题都比较难,需要好好体会。四数相加II 使用的方法与 day6文章中的最后一题 两数之和 思想类似,但是又有所不同,主要体现在对map的键值对的设定。赎金信比较简单,需要注意magazine的每个字符只能用一次。三数之和和四数之和是今天的重点,这两道题的难点在于如何去重,四数相加II不需要去重,所以比这两道题简单。使用哈希法查找元素的时候,查找的结果是无顺序的,因此去重很麻烦,这两道题使用哈希法就不合适了。这两道题更好的解法是排序+双指针,双指针降低了时间复杂度的数量级。用双指针替代两层for循环,这是一个很巧妙的思想。同时要多体会两道题中的剪枝+去重操作
双指针小结:
截止目前,文章中涉及到的双指针法有:
- 27.移除元素(opens new window)
- 15.三数之和(opens new window)
- 18.四数之和(opens new window)
链表相关双指针题目:
- 206.反转链表(opens new window)
- 19.删除链表的倒数第N个节点(opens new window)
- 面试题 02.07. 链表相交(opens new window)
- 142题.环形链表II(opens new window)
双指针法在字符串题目中还有很多应用,后面还会介绍到