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

LeetCode454.四数相加II

● 今日学习的文章链接和视频链接

代码随想录 (programmercarl.com)

题目链接

454. 四数相加 II - 力扣(LeetCode)

● 自己看到题目的第一想法

    public int fourSumCount(int[] nums1, int[] nums2, int[] nums3, int[] nums4) {
        //我们要求的是四个数相加等于0,如果我们能拆分一个这四个数组,知道其中两个数组的和,然后看另外两个数组的和是否存在相反数
        //首先是我们如何求得前面两个数组的和,我们可以采用遍历的方式把所有的和都存起来,如果有重复的数,要存储重复出现的次数,所以用map存储
        Map<Integer, Integer> map = new HashMap<>();
        for (int i = 0; i < nums1.length; i++) {
            for (int j = 0; j < nums2.length; j++) {
                Integer count = map.getOrDefault(nums1[i] + nums2[j], 0);
                map.put(nums1[i] + nums2[j], ++count);
            }
        }
        //然后再寻找后面两个数组中是否存在相反数
        int res = 0;
        for (int i = 0; i < nums3.length; i++) {
            for (int j = 0; j < nums4.length; j++) {
                int num = nums3[i] + nums4[j];
                if (map.containsKey(-num)) {
                    Integer count = map.get(-num);
                    res += count;
                }
            }
        }
        return res;
    }

 

● 看完代码随想录之后的想法

第一:思考一个问题,最多可能有多少种组合满足题目要求?假如这四个数组的元素都是0长度为4,那么所有满足条件的个数是4x4x4x4=256个,这说明我们不能忽视每一种组合

第二:那么哪种遍历方式的时间复杂度是最小的?如果暴力循环遍历,我们有四层for循环,如果先遍历一个数组,再求剩余三个数组的和也有三层for循环;所以最好的是先求两个数组的和,再求剩下两个数组的和中是否有相反数

    public int fourSumCount(int[] nums1, int[] nums2, int[] nums3, int[] nums4) {
        //我们要求的是四个数相加等于0,如果我们能拆分一个这四个数组,知道其中两个数组的和,然后看另外两个数组的和是否存在相反数
        //首先是我们如何求得前面两个数组的和,我们可以采用遍历的方式把所有的和都存起来,如果有重复的数,要存储重复出现的次数,所以用map存储
        Map<Integer, Integer> map = new HashMap<>();
        for (int i : nums1) {
            for (int j : nums2) {
                Integer count = map.getOrDefault(i + j, 0);
                map.put(i + j, ++count);
            }
        }
        //然后再寻找后面两个数组中是否存在相反数
        int res = 0;
        for (int i : nums3) {
            for (int j : nums4) {
                if (map.containsKey(-(i + j))) {
                    Integer count = map.get(-(i + j));
                    res += count;
                }
            }
        }
        return res;
    }

 

● 自己实现过程中遇到哪些困难

● 今日收获,记录一下自己的学习时长

1h

 

LeetCode383. 赎金信

● 今日学习的文章链接和视频链接

 代码随想录 (programmercarl.com)

题目链接

 383. 赎金信 - 力扣(LeetCode)

● 自己看到题目的第一想法

    public boolean canConstruct(String ransomNote, String magazine) {
        if (ransomNote.length() > magazine.length()) {
            return false;
        }
        int[] dic = new int[26];
        for (int i = 0; i < magazine.length(); i++) {
            char c = magazine.charAt(i);
            dic[c - 'a']++;
        }
        for (int i = 0; i < ransomNote.length(); i++) {
            char c = ransomNote.charAt(i);
            dic[c - 'a']--;
            if (dic[c - 'a'] < 0) {
                return false;
            }
        }
        return true;
    }

● 看完代码随想录之后的想法

这道题是很经典的用数组做哈希表的题目,因为要查询ransomNote上的元素是否在magazine上出现过,而且又都限定是小写字母,所以直接用数组存储magazine的字符出现字数就行

● 自己实现过程中遇到哪些困难

● 今日收获,记录一下自己的学习时长

 0.5h

 

 LeetCode15.三数之和

● 今日学习的文章链接和视频链接

 代码随想录 (programmercarl.com)

题目链接

 15. 三数之和 - 力扣(LeetCode)

● 自己看到题目的第一想法

想着暴力循环,但是后面发现这么做没办法去重,而且时间复杂度也比较高

    public List<List<Integer>> threeSum(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        for (int i = 0; i < nums.length-2; i++) {
            for (int j = i+1; j < nums.length-1; j++) {
                for (int k = j+1; k < nums.length; k++) {
                    List<Integer> tmp = new ArrayList<>();
                    if (nums[i] + nums[j] + nums[k]==0) {
                        tmp.add(nums[i]);
                        tmp.add(nums[j]);
                        tmp.add(nums[k]);
                        res.add(tmp);
                    }
                }
            }
        }
        return res;
    }

● 看完代码随想录之后的想法

所以应该怎么解决一次遍历就可以把三个数算出来呢?在两数之和里面的方法是一边遍历一边把数存到map里,但是三个数就不一样了,因为确定一个数之后还有两个数要同时确定下来;

问题就在于后面两个数怎么同时确定下来,如果要用哈希表存储的话,我们需要存两个数的和以及他们的下标,还要加上去重操作,这个逻辑确实还比较复杂。

考虑双指针法,一轮遍历,两个指针在左右边界上,因为排过序了,所以这里移动指针是有逻辑的,可以省去一些不必要的判断

很重要的细节是考虑如何去重,什么样的数我们不要的?当第一个数确定下来了,后面两个数在遍历的过程中可能会有重复出现的,因为我们已经排好序了,所以重复的数一定是挨着的,所以我们要一直把指针挪到到不重复的数再开始判断。那什么时候去重呢?其实只需要在我们找到和等于目标值的时候再开始去重

那么对于第一个数怎么去重呢?要思考我们是如何把第一个数加到我们的数组中来的,我们是通过不断地向后遍历来加入,那么如果后面的数和前面的数如果重复了,那他们一定是挨着的,所以对于第一个数我们也要去重,

注意思考清楚这里的判断条件为什么是nums[i] == nums[i - 1],为什么是当前遍历的数和之前的数相比,而不是当前遍历的数和遍历之后的数相比?因为后面的数属于双指针遍历的范围,我们不能排除,而前面的数属于单一变量,可以排除

public List<List<Integer>> threeSum2(int[] nums) {
//关键要先排序,这个题目的目标和是三数为0,排完序之后,我们就可以节省一些不必要的操作
Arrays.sort(nums);
List<List<Integer>> res = new ArrayList<>();
for (int i = 0; i < nums.length; i++) {
if (nums[i] > 0) {
return res;
}
List<Integer> list = new ArrayList<>();
int j = i + 1;
int k = nums.length - 1;
if (i >= 1 && nums[i] == nums[i - 1]) {
continue;
}
while (j < k) {
//为了避免指针走过头了错过了我们要保存的值,所以要先判断是否满足目标值
if (nums[i] + nums[j] + nums[k] == 0) {
list.add(nums[i]);
list.add(nums[j]);
list.add(nums[k]);
res.add(list);
list = new ArrayList<>();
//去重
while (j < k && nums[j] == nums[j + 1]) {
j++;
}
while (j < k && nums[k] == nums[k - 1]) {
k--;
}
j++;
k--;
}
//这两行是排好序后,我们挪动指针的核心逻辑
else if (nums[i] + nums[j] + nums[k] > 0) {
k--;
} else {
j++;
}
}
}
return res;
}

● 自己实现过程中遇到哪些困难

在哪里去重出现了问题,一开始把去重的逻辑放在了外面,导致指针移动的逻辑有点问题。仔细想想,去重到底是为了什么?是为了找到仅仅是重复的数,还是为了找到三数之和等于零后重复的数?

● 今日收获,记录一下自己的学习时长

2h

 

 LeetCode18. 四数之和

● 今日学习的文章链接和视频链接

 代码随想录 (programmercarl.com)

题目链接

 18. 四数之和 - 力扣(LeetCode)

● 自己看到题目的第一想法

沿用三数之和的思路,想着能不能用三个指针,但是发现复杂程度提高了很多,因为要判断重复的条件变得很复杂。

那么能不能固定第一个元素,剩下的照搬三数之和的代码呢?好像也不行,因为这四个数的联系是紧密的,依旧是都不能有重复的,想不明白应该怎么写

● 看完代码随想录之后的想法

卡尔的思路和我构思的大体一致,即在三数之和的基础上再套一层循环,但是具体细节我是没想明白的

卡尔把简化循环次数的操作分为了两种,一种是剪枝,一种是去重。

剪枝指的是,如果不满足这个条件,那么后续的元素都不用判断了,方法可以直接返回了;去重指的是对于每一个元素,如果它已经取过当前的数了,那它在后续的遍历过程中就不能再取一次重复的数

对于独立承担遍历功能的元素来说既有剪枝也有去重的操作,而对于双指针内部的元素只有去重没有剪枝一说,我们全部都要遍历到

我觉得我之前没想清楚的原因是没想明白去重其实是针对四元组的每一个元素做的,“我只和过去的自己比”;其实还蛮妙的,每一个元素只和自己不重复比就行,不存在四个元素交叉重复的情况,因为数组是有序的

    public List<List<Integer>> fourSum(int[] nums, int target) {
        Arrays.sort(nums);//-4 -1 -1 0 1 2
        //三数之和是双指针,难道四数之和要三个指针吗
        List<List<Integer>> res = new ArrayList<>();
        for (int k = 0; k < nums.length - 3; k++) {
            if (nums[k] > target / 4) {
                return res;//剪枝
            }
            if (k > 0 && nums[k] == nums[k - 1]) {
                continue;//去重
            }
            for (int i = k + 1; i < nums.length - 2; i++) {
                if (nums[k]+nums[i] > target / 2) {
                    break;//剪枝,跳出当前层次的循环
                }
                if (i > k + 1 && nums[i] == nums[i - 1]) {
                    continue;//去重,k+1代表不会再与上一轮遍历的元素比较了
                }
                int left = i + 1;
                int right = nums.length - 1;
                while (left < right) {
                    int sum = nums[k] + nums[i] + nums[left] + nums[right];
                    if (sum == target) {
                        res.add(Arrays.asList(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--;
                    } else if (sum > target) {
                        right--;
                    } else {
                        left++;
                    }
                }
            }
        }
        return res;
    }

● 自己实现过程中遇到哪些困难

测试的时候遇到一个bug检查了半天,后来发现是内层for循环的剪枝操作不能直接return,应该break,跳出当前层次的循环

● 今日收获,记录一下自己的学习时长

 1.5h

 

总结

  • 什么是哈希表?

我们知道数组通过索引下标就能快速找到元素,但是如果我们想根据元素的值来找到这个结点就需要遍历一遍数组,并且很多时候我们就是要通过名字找到这片空间,通过输入字符就能找到对应的元素也是比较符合记忆习惯。举例来说哈希表的应用:

  1. 字典查找:在一个字典中查找单词的定义或者翻译。哈希表能够以常数时间复杂度进行快速查找,对于大型词典或者翻译表来说,这样的效率优势是非常明显的。

  2. 编译器中的符号表:编译器在编译源代码时需要管理变量、函数、类等符号的信息,这些信息可能需要在各个地方被快速查找。哈希表可以用于编译器的符号表,以便快速查找符号及其关联信息。

  3. 数据库索引:数据库中的索引结构就可以采用哈希表,用于快速定位特定的记录。例如,对于散列键值的数据库字段,哈希索引可以提供快速的数据检索。(当然这种存储结构也有劣势所以后面有引入红黑树)

  4. 缓存实现:在缓存系统中,哈希表可以用来存储缓存的键值对,以便快速检索缓存数据。当需要在内存中快速存取数据时,哈希表可以提供高效的缓存存储结构。

  5. 网络路由表:在网络路由中,路由器需要快速地根据目标地址找到正确的路径。哈希表可以被用来实现路由表,以提高路由查找的效率。

总结来说就是,我们要通过名字找信息,还希望查找效率高O(1),所以有了哈希表这种数据结构

  • 如何实现哈希表?

实际上我们还是要用数组来存储我们的元素,那么不同的key(我们只需要输入我们已知的这个key,哈希表通过key来搜索元素)是怎么和数组的存储空间建立联系的?也就是说,当输入key相同的时候我们要找到数组中的某个固定位置,以获取整个结点信息,那么key和数组下标就需要对应关系。在这里,哈希表有一个哈希函数来帮我们做这个计算。当然鉴于内存限制,数组的大小也不可能无限大,所以通过哈希函数的运算后还要对数组长度取余获得下标位置。那么这里又有一个新的问题出现了,不同的key值会不会刚好被映射到同一个位置了?

答案是当然会,我们的数据集是不确定的,这说明经过哈希函数后数据有可能被映射到各种位置,而数组的空间又是有限的,所以不同的数据很有可能刚好被哈希函数映射到同一个位置了。

新的问题又来了,如何解决哈希碰撞呢?在这里我们有几种不同的方法:

- 拉链法
冲突的数据可以通过链表来存储,因此表的大小可以小于数据集的大小
- 线性探测法
冲突的数据还是要存储在哈希表中,因此表的大小应该大于数据集的大小,因为我们需要容纳下所有的数据
  • 有哪些哈希结构?

哈希表(Hash Table)和哈希结构(Hash-based Data Structure)这两个术语通常可以用来描述相同的概念,即基于哈希函数实现的数据结构。

通常情况下,哈希表或哈希结构指的是一种数据结构,它使用哈希函数将键(key)映射到存储桶(buckets)或索引位置,并在这些位置上存储值(value)。

所以卡尔在这里说的哈希结构可能不是指严格的哈希表,而是我们在刷题的时候运用哪些数据结构来处理需要有类似哈希表这种结构才能解决的问题

  • 刷题时什么问题要用哈希结构?

当我们想判断一个元素在数据集中是否出现过时,用哈希结构可以实现快速的查找

  • 刷题时有哪三种解决问题的哈希结构?

数组、set和map

  • 什么情况下用什么结构?

如果数据集的范围很固定,我们用数组就可以存储下来,那么数组无疑是最好的选择,因为它在内存和时间效率方面都比较优秀,具体的经典题目有:有效的字母异位词、赎金信

如果数据集的范围比价广不受我们控制,那么就不能用数组存储了,这个时候可以考虑用set存储,set可以存储单列元素,也就是说如果不仅想判断元素是否出现过还想要知道一些下标这种额外的信息,那么set就无法满足要求了。

这个时候就要用到map。先说一下set应用的几个题目:两个数的交集、快乐数。这两道题目用set存储,还有一个因素是set自动做了去重的操作。

map应用的几道题目:两数之和、四数相加。这两道题一个是要保存下标,另一个是要保存之前出现的次数,所以需要同时存两种信息,就要使用到map

  • 两道特殊的题目

三数之和、四数之和。这两道题都是在同一个数组里去找满足目标值的元素,并且元素不能重复

这道题目的难点就在于元素要去重,对于每一个元组里的元素都是要做去重操作的,如果想用set来存储,这个判断逻辑是比较复杂的,很难想清楚。

因此我们换一个思路,同样是要降低时间复杂度,之前我们用过双指针法,来实现一次遍历但同时完成两个循环才能做的事情。

在三数之和中,我们每次固定一个元素,剩下的两个元素用头尾指针来区域内寻找,每一个元素只要和之前或者之后要遍历的元素对比就知道是否重复了。

在四数之和中,同样可以用到这个逻辑,外层多一个循环就可以。所以这道题的去重还是对每个元素一一分析比较好想明白。

posted @ 2023-12-19 23:58  xiaoni2023  阅读(12)  评论(0编辑  收藏  举报