Leetcode记录:哈希表

哈希表 (Hash Table)

理论基础

哈希表是一种根据关键字key来访问值value的一种数据结构。它通过把key值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数,存放记录的数组叫做哈希表。因为本质上是通过索引来访问数组,所以哈希表的插入和查找的效率非常高,时间复杂度都是O(1)。相比于直接寻址法,它将全域缩小到可接受的范围。但存在一个问题:不同的key可能会计算出相同的索引,这就是哈希冲突(collision)

一个最简单的冲突解决方法是链接法(chaining),在这种方法中,数组索引对应的空间并不直接存储数据,而是存储一个链表的地址,而数据存在链表中。这样发生冲突时,就可将冲突的key对应的数据存在同一个链表上,当需要取数据时,就先找到key对应的链表,然后遍历链表。

还有一种思想为开放寻址法:如果通过哈希函数计算出的索引所对应的空间已经被占用了,就再找一个还没被占用的空间将数据存进去。常见的体现开放寻址思想的方法有:

  1. 线性探测法: 简单来说就是从当前被占用的空间的索引开始,向下遍历整个数组,直到找到空闲空间为止。
  2. 双重哈希法: 使用多个哈希函数来计算索引,如果第一个哈希函数计算得到的索引所对应的空间已被占用,就用第二个,第二个被占用就用第三个,以此类推,直到计数出没被占用的空间对应的索引。

上面说的方法只能在一定程度上解决哈希冲突,因为毕竟数组的容量有限,当频繁插入数据时,因为数组的容量有限,所以就会使哈希冲突加剧,进而使链表的长度增加,链表的长度增加,就会使得查找的性能降低,这不是我们想看到的结果,所以要对数组扩容。一般装载因子(已插入元素的数量除以数组容量)超过某一阈值时就进行扩容。

因为之前插入的元素都是按照原数组的长度来计算索引的,所以一旦数组扩容后,长度改变,就要重新进行计算,然后将已插入的元素移动到新的位置上,所以数组扩容不仅仅只是将容量增大而已。

另外,我们不难发现哈希函数是整个哈希表的关键。一个好的哈希函数应该近似地满足简单均匀散列假设:每个关键字都被等可能的散列到槽位中的任何一个,并与其他关键字散列到哪个槽位无关。遗憾的是一般无法检查这一条件是否成立。但是有时如果我们知道关键字的概率分布,例如都是随机的实数并且独立均匀分布在$[0,1]$区间,那么哈希函数$h(k) = \lfloor km \rfloor$就能满足简单均匀散列条件。在实际应用中则要根据不同的情况要求来设计好的散列函数。

常见的哈希函数有以下几种:

  1. 除法散列法:通过取$k$除以$m$的余数,将关键字$k$映射到$m$个槽位中的某一个。当应用该方法时,应避免$m$取某些值,例如2的幂次,因为如果$m=2^p$的话,$h(k)$就是$k$的$p$个最低位有效数字。通常一个不太接近2的整数幂的素数是比较好的选择。
  2. 乘法散列法:先用关键字$k$乘上常数$A\in(0,1)$,并提取其小数部分;再用$m$乘上该小数并向下取整,即为$h(k)=\lfloor m(kA \space mod1)\rfloor$。该方法的优点是对$m$的选择不是很关键

对应STL数据结构

当我们想使用哈希法来解决问题的时候,我们一般会选择vector, set或者map数据结构。在C++中,set 和 map 分别提供以下三种数据结构,其底层实现以及优劣如下表所示:

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

std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。

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

std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。

当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。

例题

有效的字符异位词

Leetcode 242. 给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。

使用一个哈希表记录字母出现频数即可:

bool isAnagram(string s, string t) {
    if(s.length() != t.length())
        return false;
    unordered_map<char,int> sMap;
    for(auto c : s){
        sMap[c]++;
    }
    for(auto c :t){
        if(sMap[c] <= 0){
            return false;
        }else{
            sMap[c]--;
        }
    }
    return true;
}

两个数组的交集

Leetcode 349. 给定两个数组 nums1 和 nums2 ,返回它们的交集。输出结果中的每个元素一定是 唯一 的。我们可以不考虑输出结果的顺序。

一种方法是用两个集合,思路简单不详细写了,另一种就是排序+双指针,空间复杂度降低为 O(logm + logn),时间复杂度上升为O(mlogm + nlogn),该方法要注意的是要确保加入元素的唯一性,由于已经进行了排序,也就是要额外判断要加入的元素和最终数组的末尾元素是否相同:

vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
    sort(nums1.begin(), nums1.end());
    sort(nums2.begin(), nums2.end());
    int length1 = nums1.size(), length2 = nums2.size();
    int index1 = 0, index2 = 0;
    vector<int> intersection;
    while (index1 < length1 && index2 < length2) {
        int num1 = nums1[index1], num2 = nums2[index2];
        if (num1 == num2) {
            // 保证加入元素的唯一性
            if (!intersection.size() || num1 != intersection.back()) {
                intersection.push_back(num1);
            }
            index1++;
            index2++;
        } else if (num1 < num2) {
            index1++;
        } else {
            index2++;
        }
    }
    return intersection;
}

快乐数

Leetcode 202. 编写一个算法来判断一个数 $n$ 是不是快乐数。「快乐数」 定义为:

  1. 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
  2. 然后重复这个过程直到这个数变为1,也可能是无限循环但始终变不到1。
  3. 如果这个过程结果为1,那么这个数就是快乐数。

一种方法是利用哈希集合检测循环:对于一个数,我们猜测会有以下三种可能:

  1. 最终会得到 1。
  2. 最终会进入循环。
  3. 值会越来越大,最后接近无穷大。

对于第三种情况,我们考虑对于 3 位数的数字,它不可能大于 243。这意味着它要么被困在 243 以下的循环内,要么跌到 1。4 位或 4 位以上的数字在每一步都会丢失一位,直到降到 3 位为止。所以我们知道,最坏的情况下,算法可能会在 243 以下的所有数字上循环,然后回到它已经到过的一个循环或者回到 1。但它不会无限期地进行下去,所以我们排除第三种选择。

bool isHappy(int n) {
    unordered_set<int> numSet;
    while(n != 1){
        if (numSet.count(n)){
            return false;
        }
        numSet.insert(n);
        int sum = 0;
        while(n!=0){
            int b = n%10;
            sum += b*b;
            n = n/10;
        }
        n= sum;
    }
    return true;
}

既然会构成一个循环,那么我们就可以将这个过程看作是一个隐式的链表,我们要做的就是判断这个链表是否存在环路,那么我们利用快慢指针即可实现:

int getNext(int n) {
    int sum = 0;
    while(n!=0){
        int b = n%10;
        sum += b*b;
        n = n/10;
    }
    return sum;
}
bool isHappy(int n) {
    int slow = n;
    int fast = getNext(n);
    while (fast != 1 && slow != fast) {
        fast = getNext(fast);
        fast = getNext(fast);
        slow = getNext(slow);
    }
    return fast == 1;
}

两数之和

Leetcode 1. 给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。

vector<int> twoSum(vector<int>& nums, int target) {
    unordered_map<int, int> hashtable;
    for (int i = 0; i < nums.size(); ++i) {
        auto it = hashtable.find(target - nums[i]);
        if (it != hashtable.end()) {
            return {it->second, i};
        }
        hashtable[nums[i]] = i;
    }
    return {-1,-1};
}