Leetcode一刷-哈希表【补卡】-Day6&7/60

142 阅读11分钟

哈希表全攻略

虽然没有学习过哈希表的底层知识,但第一次接触这个概念,觉得python中的dictset容器与之概念相近,但本篇blog以c++的算法实现为主。c++中涉及哈希表的数据结构包括了数组、set和map,对应的容器为std::vector,std::unordered_set,std::unordered_map。后两者优先被使用,因为它们的底层是哈希表,查询和增删效率是最优的,若需要集合或key有序,则使用std::set,std::map。若不仅要求有序,还要求有重复数据的话,就用std::multiset,std::multimap

哈希表的「三数之和」费时超久,噩梦开始的地方!

哈希表三大注意点?

  1. 哈希表常用来快速判断一个元素是否出现在集合中,O(1)的时间复杂度一般就可以实现,但是代价是用空间换取了时间。

哈希表理论基础

哈希表(Hash Table)

哈希表是根据关键码的值而直接访问的数据结构 (又被称为Hash Table,散列表)。

其实数组就是一张哈希表,哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素。可以有效降低时间复杂度。

如查询一个名字是否出现在这所学校里:初始化把这所学校里学生的名字都存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里。将学生姓名映射到哈希表上就涉及到了hash function

哈希函数(Hash Function)

哈希函数,就是把学生的姓名直接映射为哈希表上的索引。如下图所示,通过hashCode把名字转化为数值,一般hashCode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。 哈希函数示意图.png 在做哈希函数映射的时候,会出现两种情况:

  1. hashCode得到的数值大于哈希表的大小?--> 会对数值重新做一个取模的操作
  2. 学生的数量大于哈希表的大小,使得几位学生的名字同时映射到哈希表同一个索引下表的位置 --> 哈希碰撞

哈希碰撞Collisions

在下图中,两位同学都被映射到了同一个下标位置上,这一现象叫做哈希碰撞。 一般将数据规模称为dataSize,哈希表大小称为tableSize。 一般哈希碰撞有两种解决方法,拉链法和线性探测法。 哈希碰撞图示.png

拉链法

将发生冲突位置的元素存储在链表中,通过索引找到小李和小王。 拉链法就是要找到适当的哈希表大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。 拉链法原理图示.png

线性探测法

前提是tableSize>dataSize,即哈希表中有空位。例如冲突的位置放了小李,那么就向下找一个空位来放置小王的信息,所以一定要求tableSize大于dataSize。 线性探测法原理图示.png

常见的三种哈希结构

  • 数组
  • set(集合)
  • map(映射)

在c++中,set和map分别提供了以下三种数据结构,其底层实现及其优劣势如下表: 其中,红黑树作为一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。

集合底层实现是否有序数值是否可以重复能否更改数值查询效率增删效率
std::set红黑树有序O(log n)O(log n)
std::multiset红黑树有序O(log n)O(log n)
std::unordered_set哈希表无序O(1)O(1)
映射底层实现是否有序数值是否可以重复能否更改数值查询效率增删效率
std::map红黑树key有序key不可重复key不可修改O(log n)O(log n)
std::multimap红黑树key有序key可重复key不可修改O(log n)O(log n)
std::unordered_map哈希表key无序key不可重复key不可修改O(1)O(1)

Leetcode相关题目及解法要义

242. 有效的字母异位

题目链接:leetcode.cn/problems/va…

此题解题思路在于为26个小写字母创建一个长度为26的arr数组,并在对应位置上进行次数的加减运算,判断最后状态时的位置数值。 注意c++创建数组的写法,当{}内数据长度不足数组长度时,用0填充。 此外,在遍历字符串s的时候,只需要得到s[i] - 'a' 的相对值即可,不需要记住字符a的ASCII。

class Solution {
public:
    bool isAnagram(string s, string t) {
        int record[26] = {0};
        for (int i = 0; i < s.size(); i++) {
            // 并不需要记住字符a的ASCII,只要求出一个相对数值就可以了
            record[s[i] - 'a']++;
        }
        for (int i = 0; i < t.size(); i++) {
            record[t[i] - 'a']--;
        }
        for (int i = 0; i < 26; i++) {
            if (record[i] != 0) {
                // record数组如果有的元素不为零0,说明字符串s和t 一定是谁多了字符或者谁少了字符。
                return false;
            }
        }
        // record数组所有元素都为零0,说明字符串s和t是字母异位词
        return true;
    }
};

在使用python解题时,需要用ord获得字符a的ASCII值,并计算。或者使用collections中的defaultdict,该函数可以让dict不存在的key默认初始化的value为0。

# python解法1
class Solution:
    def isAnagram(self, s: str, t: str) -> bool:
        record = [0] * 26
        for i in range(len(s)):
            #并不需要记住字符a的ASCII,只要求出一个相对数值就可以了
            record[ord(s[i]) - ord("a")] += 1
        print(record)
        for i in range(len(t)):
            record[ord(t[i]) - ord("a")] -= 1
        for i in range(26):
            if record[i] != 0:
                #record数组如果有的元素不为零0,说明字符串s和t 一定是谁多了字符或者谁少了字符。
                return False
        return True
        
# python解法2
class Solution:
    def isAnagram(self, s: str, t: str) -> bool:
        from collections import defaultdict
        
        s_dict = defaultdict(int)
        t_dict = defaultdict(int)

        for x in s:
            s_dict[x] += 1
        
        for x in t:
            t_dict[x] += 1

        return s_dict == t_dict

349. 两个数组的交集

题目链接:leetcode.cn/problems/in…

如果哈希值比较少,比较分散的话,就不适合用数组进行处理,数组处理会带来空间的极大浪费。 此时,需要使用set相关结构体,包括std::set,std::multiset,std::unordered_set

class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        // 这个题目需要用到 std:: unordered set容器,之前没有用到过;
        // set的添加用.insert
        // set的元素个数统计为.count(key)
        // set的查找用.find(key),返回的是迭代器,若存在,返回该键的元素的迭代器;若不存在,返回set.end();
        unordered_set<int> resultSet;
        // 在vector中放数据,用的是
        // vector<int> v;
        // v.push_back();
        // 每个容器都有自己的迭代器,迭代器是用来遍历容器中的元素
        // v.begin() 返回迭代器,这个迭代器指向容器中第一个数据;
        // v.end() 返回迭代器,这个迭代器指向容器元素的最后一个元素的下一个位置
        // vector<int>:: iterator 拿到vector<int>这种容器的迭代器类型;
        unordered_set<int> nums1Set(nums1.begin(), nums1.end());  // 这个是unordered set容器初始化的写法吧...,传入一个开始和终止的迭代器
        
        for (int num: nums2){ // 这个for的写法是第一次见,类似 in
            if (nums1Set.find(num) != nums1Set.end()){ //若不是最后一个元素下一个位置的返回,就说明这个num在nums1_set中找到了
                resultSet.insert(num);
            }
        }

        return vector<int>(resultSet.begin(), resultSet.end()); // 也就是不定义变量名称直接返回
    }
};

202. 快乐数

题目链接:leetcode.cn/problems/ha…

此题需要掌握各个位上的单数之和的计算技巧 n%10,即用%计算模,再n/=10。 其次,需要明确进入无限循环的条件,此时直接退出循环,返回false

class Solution {
public:
    // 取数值各个位上的单数之和
    int getSum(int n) {
        int sum = 0;
        while (n) {
            sum += (n % 10) * (n % 10);
            n /= 10;
        }
        return sum;
    }
    bool isHappy(int n) {
        unordered_set<int> set;
        while(1) {
            int sum = getSum(n);
            if (sum == 1) {
                return true;
            }
            // 如果这个sum曾经出现过,说明已经陷入了无限循环了,立刻return false
            if (set.find(sum) != set.end()) {
                return false;
            } else {
                set.insert(sum);
            }
            n = sum;
        }
    }
};

1. 两数之和

题目链接:leetcode.cn/problems/tw…

作为力扣的第一题,可以使用时间复杂度为O(n^2)的暴力解法。由于哈希法的提示点在于:当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要使用哈希法解决。 此题,则可以用一个set/map存储所有出现过的元素,然后去查询是否曾经在此出现过。又由于需要查询对应下标,需要使用key value结构来存放,因此,选择map。

class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        // 这个题目的关键在于用一个map作为出现过的数字的索引字典
        // map中所有元素都是pair;
        // 常见的有四种插入方式,可以用最简单的m.insert(pair<int, int>(1,10))
        
        std::unordered_map<int, int> show_map; //已经出现的做索引记录,key为数值,value为索引
        unordered_set<int> recordSet; // 避免返回的索引重复
        int id = 0;
        for (int num: nums){
            // 若出现在了show_map的key中,则返回
            cout << num << " "<< id << endl; 
            if (show_map.find(target-num) != show_map.end()){
                recordSet.insert(id);
                cout << show_map.find(target-num)->second << " " << id << endl;  // 
                recordSet.insert(show_map[target-num]); //c++中如何在map中返回key呢? 是不是会自动排序?
            }
            show_map.insert(pair<int, int>(num, id++));  //注意看这里的写法!是如何将set结果用vector返回的
        }

        return vector<int>(recordSet.begin(), recordSet.end()); 
    }
};

454. 四数相加II

题目链接:leetcode.cn/problems/4s…

此题相对于15小题和18小题简单了很多,可以分两组进行运算,中间结果用哈希表进行存储。

class Solution {
public:
    int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
        // 分组做
        // 这个题目只需要返回有几组就可以了,比较简单
        std::unordered_map<int, int> m1; //先统计前两个整数数组的所有可能情况;
        int answer = 0;
        for (int a: nums1){
            for (int b: nums2){
                m1[a+b] ++ ; //这样写比较简单,说到底还是c++不熟练
                // if (m1.find(a+b) == m1.end()) {
                //     m1.inset(make_pair(a+b,0));
                //     }
                // else {
                //     m1.find(a+b)->second ++; // 记录有几组
                //     }
            }
        }
        for (int c: nums3) {
            for(int d: nums4){
                if (m1.find(-c-d) != m1.end()){
                    answer += m1[-c-d];
                }
            }
        }

    return answer;
    }
};

383. 赎金信

题目链接:leetcode.cn/problems/ra…

此题还是只涉及26个小写字母,可以直接用数组实现哈希表的功能。

class Solution {
public:
    bool canConstruct(string ransomNote, string magazine) {
        // 为什么字母不能存在map里面?但实际上只有26个字母,所以存放在26个长度的数组里会更好
        int arr[26] = {0};  // 利用数组实现哈希表的做法
        for (int i = 0; i < magazine.size(); i++){
            std::cout << magazine[i] << std::endl;
            arr[magazine[i] - 'a'] ++ ;;
        } // 先用magazine向上加
        for (int j = 0; j < ransomNote.size(); j++) {
            arr[ransomNote[j] - 'a'] --;
        }
        // 若元素中出现了负数,说明不是赎金信
        for (int num: arr){
            if (num < 0) return 0;
        }
        return 1;
    }
};

15. 三数之和

题目链接:leetcode.cn/problems/3s…

这个题目是梦破碎的地方,此题本来是可以用哈希表来暴力解,最后去重,但是这样做在leetcode中超时了。 注意:此题需要掌握哈希表和双指针两种解法,但是前者的去重逻辑比较细节,很容易搞错。 两种解法的时间复杂度都是O(n^2)

哈希法C++代码:

class Solution {
public:
    vector<vector<int>> threeSum(vector<int>& nums) {
        // 对于数组来说,== 是用来判断内存地址是否一致,无法判断内部元素是否相同
        // 但是vector可以用==来判断vector内部元素及其顺序是否完全一致的
        // 这个题目单纯的用暴力解法很容易超时;
        vector<vector<int>> result;
        std::sort(nums.begin(), nums.end());  // 提前排序的好处在于先判断最小数是否小于0,且最后的三元组也会是排好序的
        // 寻找 a+b+c = 0; 三者的索引从小到大
        for (int i = 0; i < nums.size(); i++){
            if (nums[i] > 0) break; // 当nums已经排好序了,后面都只会是正的;
            if (i > 0 && nums[i] == nums[i-1]) continue; // 由于输出的三元组元素不重复,a去重操作;
            unordered_set<int> set; // 在每次i遍历时重新生成,用来存放已经出现过的nums[j]作为备选c,等j再做遍历的时候,c若是为曾经出现过的nums[j],则为答案
            for (int j = i + 1; j < nums.size(); j++){
                // 注意b的去重,只有(j > i + 2 && nums[j] == nums[j-1]) 这2个约束是不够的[-2.0,1,1,2] 会忽略[-2,1,1]这个答案
                if (j > i + 2 && nums[j] == nums[j-1] //只有两个连续出现,就跳过c的确定是不合理的,会忽略一些组合,所以要连续三个相同才跳过
                            && nums[j] == nums[j-2]) continue;
                int c = 0 - nums[i] - nums[j]; // 倒取c,并做记录,如果c出现,
                if (set.find(c) != set.end()){ // 说明j在后方定位到了
                    result.push_back({nums[i], nums[j], c}); // 存入结果
                    set.erase(c);  //j确定的b在继续向后的过程中,如果连续再次遇到一个相同的b,则c会被反复使用,以[0,0,0,0,0]作为调试对象
                } else {
                    set.insert(nums[j]); //把j遍历途中的数值存放起来,且不重复,map用的是insert,vector用的是push_back
                }
            }
        }
        return result;
        }   
};

双指针法c++代码

class Solution {
public:
    vector<vector<int>> threeSum(vector<int>& nums) {
        // 利用双指针法解题,主要思想就是利用left,right去做的逼近
        vector<vector<int>> result;
        std::sort(nums.begin(), nums.end()); // 先必须要做排序
        for (int i = 0; i < nums.size(); i++){
            if (nums[i] > 0 ) return result; // 这个时候a的向后滑动就没有意义了
            // 需要对a进行去重的处理
            if (i > 0 && nums[i] == nums[i-1]){
                continue; // 这里需要记住一个反例[-1,-1,0,2],所以是跟前一个比,而不是跟后一个比
            }
            
            int left = i + 1;
            int right = nums.size()-1;  //注意这里的写法,是右闭的
            // 这里的思想就是若是left+right+nums[i] > 0 right就向左移动,同理<0,left就向右移动

            while(left < right){ // 接近二分法的思路

                if (nums[i] + nums[left] +nums[right] < 0){
                    left++;
                } else if (nums[i] + nums[left] +nums[right] > 0){
                    right--;
                } else {
                    cout << i <<endl;
                    result.push_back({nums[i], nums[left], nums[right]}); 
                // 不能先去重, [0,0,0,0]就会没有答案了
                // 双指针先去重到最后一个位置,如[-1,-1,-1,0,0,1,1,2] left应该到第三个-1
                while (left < right && nums[left] == nums[left+1]){
                    left++;
                } //如果不加上left<right约束的话,[0,0,0]会报错,因为left+1的索引会超出nums的长度范围
                while (left < right && nums[right] == nums[right-1]){
                    right--;
                }
                left++;
                right--;
                }
            }
        }   
        return result;
    }   
};

18. 四数之和

题目链接:leetcode.cn/problems/4s…

此题还是用双指针法,只是在三数之和的外面再加一层for循环。 整体思想就是两层for循环nums[k] + nums[i]为确定值,依然是循环内用leftright做双指针,找出nums[k] + nums[i] + nums[left] + nums[right] == target的情况,时间复杂度为O(n^3)

class Solution {
public:
    vector<vector<int>> fourSum(vector<int>& nums, int target) {
        // 用双指针方法解

        std::sort(nums.begin(), nums.end()); //先排序
        vector<vector<int>> result;

        for (int i = 0; i < nums.size(); i++){ // 开始遍历第一个元素a
            if (nums[i] > target/4) { //注意!!这里对a的剪枝操作
                break;
                }
            if (i > 0 && nums[i] == nums[i-1]){// 做a的去重
                continue; //跳过该元素,因为这个元素在a位置的解答已经完成
            }
            // cout << "**************" << nums[i] << "********************" << endl;
            for (int j = i + 1; j < nums.size(); j++){ //开始遍历第二个元素b
                cout << nums[j] << endl; 
                if (j < nums.size() - 3 && nums[i]+nums[j] > target/2) { // 注意这里对b的剪枝操作, 后面跟break,中止这个b的循环,而不是返回
                    break;
                };
                if (j > i+1 && nums[j] == nums[j-1]){ // b的去重
                   continue;
                }
                int left = j + 1;
                int right = nums.size() - 1; //右闭合
                while(left < right){
                    if ((long) nums[i]+nums[j]+nums[left]+nums[right] > target){ //注意这里的类型,防止溢出,测试用例
                    //[-1000000000,-1000000000,-1000000000,-1000000000]
                    // -1
                        // right左移
                        right --;
                    } else if ((long) nums[i]+nums[j]+nums[left]+nums[right] < target){ // 注意这里对(long)的使用,似乎c++中对于计算结果的类型转换需要用括号(long) 而不是 long
                        left++;
                    } else { // 也就是出现了==target的情况
                        result.push_back({nums[i],nums[j],nums[left],nums[right]});
                        // 避免c,d的重复
                        while(left < right && nums[left]==nums[left+1]){
                            left ++;
                        } 
                        while(left< right && nums[right]==nums[right-1]){
                            right--;
                        }
                        left++;
                        right--;
                    }
                }
            }
        }
        return result;
    }
};