代码随想录算法训练营day7 | 454.四数相加II、383.赎金信、15.三数之和、18.四数之和


今天是哈希表专题的第二天

废话不多说,直接上题目

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)出现的次数,用下面的例子解释:

    image-20240724153341415

    若当前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

执行过程如图所示:

15.三数之和

剪枝+去重

  1. 剪枝:排除后续元素全为正的数组:每次遍历都需要先判断nums[i]是否大于0。如果nums[i] > 0,则说明数组的第i位后的所有元素都是正的,不可能出现a+b+c == 0的情况

  2. 去重:对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]指向下一个元素

  3. 去重:对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),去重操作与 三数之和 相同

剪枝+去重

  1. 剪枝:target可能为负值,所以用a > target剪枝是错误的,还需要加上条件a >= 0,因此剪枝条件为:a > target && a >= 0。第2级for循环的剪枝条件为:a+b > target && a+b >= 0
  2. 去重:与 三数之和 相同,对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循环,这是一个很巧妙的思想。同时要多体会两道题中的剪枝+去重操作

双指针小结

截止目前,文章中涉及到的双指针法有:

链表相关双指针题目:

双指针法在字符串题目中还有很多应用,后面还会介绍到

  • 24
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值