算法学习之哈希表算法

113 阅读9分钟

哈希表的理论基础

在学习哈希表的解题思路之前,我们要先学习哈希表的理论基础,首先让我们来看看什么是哈希表

这里我们需要记忆的两点分别是直白来说数组就是一张哈希表以及一般哈希表都是用来快速判断一个元素是否出现在集合里,值得注意的是,如果我们需要判断一个元素是否出现在一个集合里时,我们可以采用数组的方法而非直接构造哈希表,这样有助于提高我们的效率,因为使用哈希表的解题效率往往要比数组要来得慢

哈希表的内容还有Map集合,其底层实现可以用红黑树也可以用哈希表实现,但是这些底层原理对于解题而言并不重要,这里我们只要记住有Map集合可以用就行了,而Map集合内的元素都是两个,不可重复,而Set集合则是一个

哈希表判断元素是否重复出现的应用

这里我们可以用Map集合解题,来看看Map集合解题的代码

class Solution {
    public boolean isAnagram(String s, String t) {
        Map<Character,Integer> map = new HashMap<>();
        for (int i = 0; i < s.length(); i++) {
            Character c = s.charAt(i);
            map.put(c,map.getOrDefault(c,0)+1);
        }
        for (int i = 0; i < t.length(); i++) {
            Character c = t.charAt(i);
            if(map.containsKey(c)){
                map.put(c,map.getOrDefault(c,0)-1);
            }else {
                return false;
            }
        }
        for (Character c:map.keySet()) {
            if(map.get(c)!=0){
                return false;
            }
        }
        return true;
    }
}

这里的解题的基本逻辑就是利用Map集合判断字符串出现的字符是否相等,但是这个代码效率很低,需要改良。这时我们可以使用数组来实现我们所需要的功能,请看代码

class Solution {
    public boolean isAnagram(String s, String t) {
        int[] arr = new int[26];
        for (int i = 0; i < s.length(); i++) {
            int a = s.charAt(i)-'a';
            arr[a]+=1;
        }
        for (int i = 0; i < t.length(); i++) {
            int a = t.charAt(i)-'a';
            arr[a]-=1;
        }
        for (int i = 0; i < arr.length; i++) {
            if(arr[i]!=0){
                return false;
            }
        }
        return true;
    }
}

我们这里利用数组下标表示26位字母,而其上的值表示其出现的次数,后续减少之后判断各字母的出现次数是否相同。利用数组解题的代码不但不会繁杂,而且效率还高,所以能使用数组解决,就不要用什么几把其他集合

我们再来看看及其相似的另一道题目

题解代码:

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

可以看到我们代码的基本逻辑还是差不多的,只是最后判断时的条件改成了>而非!=,这是因为在本体里我们的第二个字符串的出现的字符的次数只要大于第一个的就符合条件了,因此我们如此判断

哈希表判断元素是否重复出现的进一步理解

哈希表用于判断元素是否重复出现时,可以结合Map集合进行使用,我们的目标是尽量不使用集合,但不是说完全不使用,当我们需要的时候,我们还是要用的,请看下题

首先是暴力模拟的代码:

class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {
        List<List<String>> anslist = new ArrayList<>();
        boolean[] judge = new boolean[strs.length];
        for (int i = 0; i < strs.length; i++) {
            if(judge[i]){
                continue;
            }
            List<String> list = new ArrayList<>();
            String s = strs[i];
            int[] arr = new int[26];
            for (int j = 0; j < s.length(); j++) {
                arr[s.charAt(j)-'a']+=1;
            }
            for (int j = 0; j < strs.length; j++) {
                if(judge[j]){
                    continue;
                }
                boolean judge2 = true;
                int[] arr2 = arr.clone();
                String s2 = strs[j];
                for (int k = 0; k < s2.length(); k++) {
                    arr2[s2.charAt(k)-'a']-=1;
                    if(arr2[s2.charAt(k)-'a']<0){
                        judge2=false;
                        break;
                    }
                }
                if(!judge2){
                    for (int k = 0; k < arr2.length; k++) {
                        if(arr2[k]!=0){
                            judge2=false;
                            break;
                        }
                    }
                }
                if(judge2){
                    judge[j]=true;
                    list.add(s2);
                }
            }
            anslist.add(list);
        }
        return anslist;
    }
}

这个代码会超出时间限制,无法通过某个特定案例,因此我们不能采用这种方法,之所以会出现效率很低的情况,是因为我们这个代码为了一次将所需的内容全部加入到对应的集合中导致的。这个时候我们应该要使用上集合,这样我们就不必为了一次加入所有的符合条件的字符串到集合中来提高我们的效率,请看代码:

class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {
        Map<String, List<String>> map = new HashMap<String, List<String>>();
        for (String str : strs) {
            int[] counts = new int[26];
            int length = str.length();
            for (int i = 0; i < length; i++) {
                counts[str.charAt(i) - 'a']++;
            }
            // 将每个出现次数大于 0 的字母和出现次数按顺序拼接成字符串,作为哈希表的键
            StringBuffer sb = new StringBuffer();
            for (int i = 0; i < 26; i++) {
                if (counts[i] != 0) {
                    sb.append((char) ('a' + i));
                    sb.append(counts[i]);
                }
            }
            String key = sb.toString();
            List<String> list = map.getOrDefault(key, new ArrayList<String>());
            list.add(str);
            map.put(key, list);
        }
        return new ArrayList<List<String>>(map.values());
    }
}

这里我们同样使用26位的数组来代表字符串的数量,我们这里利用StringBuffer将字符串与字符进行拼接,每一个对应的字符后面就跟着其出现的的次数,通过这种方法构造的字符串可以用于后面判断两字符串是否拥有相同的字符数量(当然我们也可以恩构造对应的长度的字符串而不是用数字来代替,只是这样太麻烦因此我们不采用),接着我们map集合的含金量就出来了,map集合结合getOrDefault的方式,可以让我们的集合里准确弹出我们所需要的含有相同字符串的集合,然后将该字符串加入之后再将该字符串压入,通过这种方式我们就成功就减少了一个for循环,这样我们就可以通过我们的案例了

接着我们再来讲一种比较取巧的方式,就是其实我们也可以利用排序方法的来解决这个问题,因为本题的要求里只要两个字符串的数量一样就可以了,而不论其是什么排序,因此我们可以使用排序来解决这个问题。请看代码

class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {
        Map<String, List<String>> map = new HashMap<String, List<String>>();
        for (String str : strs) {
            char[] array = str.toCharArray();
            Arrays.sort(array);
            String key = new String(array);
            List<String> list = map.getOrDefault(key, new ArrayList<String>());
            list.add(str);
            map.put(key, list);
        }
        return new ArrayList<List<String>>(map.values());
    }
}

当然这里值得一提的是我们也同样用到了Map集合来辅助我们解题,只不过我们这里不使用数组和StringBuffer进行结合判断,而是直接使用排序后的数组进行判断而已

哈希表结合数组方法进行使用

前面我们说过,数组本身都是一种哈希表结构,那么我们采用数组解题时,我们就可以结合数组里学过的方法,如滑动窗口或者是双指针来提高我们的算法效率

先来看看我们不使用数组里的方法的解题代码:

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        if(p.length()>s.length()){
            return new ArrayList<>();
        }
        List<Integer> list = new ArrayList<>();
        int[] arr = new int[26];
        for (int i = 0; i < p.length(); i++) {
            arr[p.charAt(i)-'a']+=1;
        }
        for (int i = 0; i <= s.length()-p.length(); i++) {
            int[] arr2 = arr.clone();
            String s1 = s.substring(i,i+p.length());
            for (int j = 0; j < s1.length(); j++) {
                arr2[s1.charAt(j)-'a']-=1;
            }
            boolean judge = true;
            for (int j = 0; j < arr2.length; j++) {
                if(arr2[j]!=0){
                    judge=false;
                    break;
                }
            }
            if(judge){
                list.add(i);
            }
        }
        return list;
    }
}

这里使用了字符串的截取方法用于判断,总而言之这个方法的效率不高,其为效率为O(n^2),我们这里是进行暴力对比得到的结果,通过观察我们也可以发现本体与滑动窗口的思想非常类似,那么我们可以试着使用滑动窗口法来解决本题,同时我们使用数组的结构来判断其是否是重复出现的符合条件的元素,请看代码

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        //记录p的所有字母及其个数
        int[] need = new int[26];
        for (int i = 0; i < p.length(); i++) {
            need[p.charAt(i) - 'a']++;
        }
        //startend分别控制窗口的前端和后端
        int start = 0, end = 0;
        int[] window = new int[26];
        List<Integer> ans = new ArrayList<Integer>();
        while (end < s.length()) {
            window[s.charAt(end) - 'a']++; //加入窗口数据
            if (end - start + 1 == p.length()) { //维护一个长度为p.length()的窗口,并更新答案
                if (Arrays.equals(window, need)) ans.add(start);
                window[s.charAt(start) - 'a']--;
                start++;
            }
            end++;
        }
        return ans;
    }
}

这里我们就使用了滑动窗口的方法解决了这题,我们只需要一次遍历就可以达成目标。还记得使用滑动窗口法的三个重点要注意的地方吗?我们来一起复习一下

使用滑动窗口要重点注意以下三点,1.确定双指针分别是从哪里出发。2.确定双指针结束的情况。3.确定双指针的内部的比较逻辑

在本题里,我们的滑动窗口的两个指针都是从起始位置出发,其次我们的结束的情况是当末位指针到达结尾之后就结束,我们滑动窗口内部比较的逻辑是比较某一个片段的不符合p就向左收缩,如果其长度小于p的长度就向右扩大。通过使用滑动窗口的方法,我们成功将本体的效率提高至O(n)

注意,我们上面的题目里,我们的数组哈希表结构都只发挥了比较的作用,虽然只是比较,但是也是很不错的应用,这可比Map效率高多了不是

哈希表结构中Set结构的使用

哈希表中的Set结构可以用于快速确定重复的元素并将其只需要花常数时间进行就能完成比较,只要付出计算哈希值的代价。

显然使用Set集合就很使用做这一题,我们来看看代码

class Solution {
    public int[] intersection(int[] nums1, int[] nums2) {
        if (nums1 == null || nums1.length == 0 || nums2 == null || nums2.length == 0) {
            return new int[0];
        }
        Set<Integer> set1 = new HashSet<>();
        Set<Integer> resSet = new HashSet<>();
        //遍历数组1
        for (int i : nums1) {
            set1.add(i);
        }
        //遍历数组2的过程中判断哈希表中是否存在该元素
        for (int i : nums2) {
            if (set1.contains(i)) {
                resSet.add(i);
            }
        }
        int[] resArr = new int[resSet.size()];
        int index = 0;
        //将结果几何转为数组
        for (int i : resSet) {
            resArr[index++] = i;
        }
        return resArr;
    }
}

我们再来看看这个题目的进阶版,进阶版要求我们要按照顺序返回数组中的重复元素

此时我们可以利用Map集合,通过遍历法来确定数组中的重复元素,只要把第一个数组出现的内容和个数记录下来,然后用第二个数组去判断就可以了

class Solution {
    public int[] intersect(int[] nums1, int[] nums2) {
        Map<Integer,Integer> map = new HashMap<>();
        for (int i = 0; i < nums1.length; i++) {
            map.put(nums1[i],map.getOrDefault(nums1[i],0)+1);
        }
        List<Integer> list = new LinkedList<>();
        for (int i = 0; i < nums2.length; i++) {
            if(map.containsKey(nums2[i]) && map.get(nums2[i])>0){
                list.add(nums2[i]);
                map.put(nums2[i],map.getOrDefault(nums2[i],0)-1);
            }
        }
        Object[] ans = list.toArray();
        int[] arr = new int[ans.length];
        for (int i = 0; i < arr.length; i++) {
            arr[i]= (int) ans[i];
        }
        return arr;
    }
}

当然,使用Map集合的效率还是不足够好的,实际上这题也可以使用哈希表里最基本的数组结构来解题,不过需要结合排序来使用,请看代码

class Solution {
    public int[] intersect(int[] nums1, int[] nums2) {
        Arrays.sort(nums1);
        Arrays.sort(nums2);
        int length1 = nums1.length, length2 = nums2.length;
        int[] intersection = new int[Math.min(length1, length2)];
        int index1 = 0, index2 = 0, index = 0;
        while (index1 < length1 && index2 < length2) {
            if (nums1[index1] < nums2[index2]) {
                index1++;
            } else if (nums1[index1] > nums2[index2]) {
                index2++;
            } else {
                intersection[index] = nums1[index1];
                index1++;
                index2++;
                index++;
            }
        }
        return Arrays.copyOfRange(intersection, 0, index);
    }
}

Set集合的进一步理解

对于哈希标数据结构中的Set集合而言,其最重要的性质就是可以确定集合中是否含有重复元素,我们一般调用Set集合都是要利用其这个性质来解题的,请看代码

class Solution {
    public boolean isHappy(int n) {
        Set<Integer> set = new HashSet<>();
        while (true){
            List<Integer> list = new LinkedList<>();
            long all = 0;
            while (n>0){
                int digital = n%10;
                n=n/10;
                int sum=digital;
                all+=sum*sum;
            }
            if(all==1){
                return true;
            }
            if(set.contains((int)all)){
                return false;
            }else {
                set.add((int) all);
                n= (int) all;
            }
        }
    }
}

这里我们就是利用Set集合的性质来确定是否应该要返回false的情况,当然,实际上我们这个代码还可以再优化,当然上面的代码也已经很不错了,这不过是个锦上添花的行为

class Solution {
    public boolean isHappy(int n) {
        Set<Integer> record = new HashSet<>();
        while (n != 1 && !record.contains(n)) {
            record.add(n);
            n = getNextNumber(n);
        }
        return n == 1;
    }

    private int getNextNumber(int n) {
        int res = 0;
        while (n > 0) {
            int temp = n % 10;
            res += temp * temp;
            n = n / 10;
        }
        return res;
    }
}

Map集合的进一步理解

对于在数组里需要返回下标的题目,如果我们需要使用哈希表结构,一般选择Map结构。其实不止如此,对于任何需要比较一个对象,而返回的却是需要与该对象相关的另一个数据,我们都优先推荐使用Map集合。请看题目

Map集合不但有确定重复元素的功能,而且还能保存另外一个数据,我们可以通过比较是否有重复元素,然后返回另外一个数据的值带完成题目的需求,当然,如何将本题的思路转化为用哈希表数据结构确定重复元素来解题也是一个难点。我们注意到在哈希表中如果我们已知一个key,那么寻找key是否存在是需要花费常数时间,那么我们可以先将元素全部加入到Map中,然后再次遍历数组,每次让target减去当前值判断数组中是否存在这个目标值,若有则说明该目标值的下标和当前下标就是我们所需要的答案

class Solution {
    public int[] twoSum(int[] nums, int target) {
        Map<Integer,Integer> map = new HashMap<>();
        for (int i = 0; i < nums.length; i++) {
            map.put(nums[i],i);
        }
        for (int i = 0; i < nums.length; i++) {
            int temp = target - nums[i];
            if(map.containsKey(temp)){
                if(i!=map.get(temp)){
                    return new int[]{i,map.get(temp)};
                }
            }
        }
        return null;
    }
}

当然实际上,我们的代码还可以做进一步的优化。我们可以第一次遍历时就往Map集合中进行判断并添加元素,这样如果存在符合的值,在一次遍历中就会被查询出来,这样就省去了我们先遍历一遍的过程,这个方法相对于上一个方法而言没那么容易想到,但其对效率的提升是明显的

class Solution {
    public int[] twoSum(int[] nums, int target) {
        Map<Integer,Integer> map = new HashMap<>();
        for (int i = 0; i < nums.length; i++) {
            int temp = target - nums[i];
            if(map.containsKey(temp)){
                return new int[]{i,map.get(temp)};
            }
            map.put(nums[i],i);
        }
        return null;
    }
}

接下来我们来看看更加进阶的关于Map集合的题目

观察这道题可以发现其需要统计四数相加为0的数组的个数,我们需要统计总数,而本题目虽然不要求返回下标,但是对于顺序不同的重复元素其也是计算在内的,因此我们可以利用Map集合进行统计,最后遍历Map集合来获得我们所符合条件的答案个数

class Solution {
    public int fourSumCount(int[] nums1, int[] nums2, int[] nums3, int[] nums4) {
        Map<Integer, Integer> map = new HashMap<>();
        int temp;
        int res = 0;
        //统计两个数组中的元素之和,同时统计出现的次数,放入map
        for (int i : nums1) {
            for (int j : nums2) {
                temp = i + j;
                map.put(temp, map.getOrDefault(temp,0)+1);
            }
        }
        //统计剩余的两个元素的和,在map中找是否存在相加为0的情况,同时记录次数
        for (int i : nums3) {
            for (int j : nums4) {
                temp = i + j;
                if (map.containsKey(0 - temp)) {
                    res += map.get(0 - temp);
                }
            }
        }
        return res;
    }
}

这里我们的key存放关键值,而value存放该值所出现的次数,我们先将两数组遍历然后获得其和放于Map中,接着再遍历另外两数组之和获得相反数,若其相反数在map中,则说明其能组成一个为0的数,此时我们直接获得该关键值的value值即可,该值则为符合条件的答案个数的一部分。当然,这里转化解题思路为两数之和的思路的确比较难想,但是我们可以简单的记住,有关于这一类的求和题目,我们都可以往Map集合和遍历以及结合值的相加来去构建我们的代码,先不用管这个思路对不对,做就完了!

哈希表集合结合双指针的应用

集合常常用于处理数组,那么数组题目中也有数组自己的解题思路,比较经典的就是双指针法,那么我们在使用哈希表结构解题的时候也可以结合使用数组的双指针法来进行解题,请看题目

本题统计三元组,我们按照上面讲过的思路,我们必然要往哈希表的Map结构上去思考,同时我们要想办法将for循环与值的和结合起来。我们注意到本题不要求返回数组下标,那么我们就可以先对数组进行排序,这不用耗费多少时间,因为java内置的排序方法总是非常快的。然后我们去一次遍历整个数组,再用双指针确定左右边界进行解题,由于本体不允许包含重复的三元组,因此我们在循环时还要跳过重复的元素的统计。根据上面的思路,我们可以构造代码如下

class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        Arrays.sort(nums);
        List<List<Integer>> anslist = new LinkedList<>();
        if(nums.length<3 || nums[0]>0){
            return anslist;
        }

        for (int i = 0; i < nums.length; i++) {
            if(i>0 && nums[i]==nums[i-1]){
                continue;
            }
            int len = i+1,right = nums.length-1;
            while (len<right){
                int temp = nums[i]+nums[len]+nums[right];
                if(temp>0){
                    right--;
                    continue;
                }else if(temp<0){
                    len++;
                    continue;
                }else {
                    List<Integer> list = new LinkedList<>();
                    list.add(nums[i]);
                    list.add(nums[len]);
                    list.add(nums[right]);
                    anslist.add(list);
                }
                while (len < right && nums[len]==nums[len+1]){
                    len++;
                }
                while (len < right && nums[right]==nums[right-1]){
                    right--;
                }
                len++;
                right--;
            }
        }
        return anslist;
    }
}

这题比较麻烦的点在于如何构造我们的代码使得其能够跳过重复元素的判断,注意我们第10到第12行,以及第29行到第36行,其作用都是为了跳过相同的数的比较。当然,这里还有许多的代码构造细节,如第18和第21的两个continue的作用是为了保证当我们的扩大或缩小边界时,不会让左右边界再跳过一步以及避免由于随意跳过重复元素导致所需的答案没有全部统计到。还有我们的左右边界定义在循环时的最初的边界之后,这样做虽然不符合直觉,但实际我们得到的结果总是符合题目要求的。最后排序的作用就在于便于我们确定左右边界,这样能够让我们使用双指针法,当然,在本题中我们也同样用了统计其和的方法

接下来让我们来看看上一个题目的进阶版

其大体思路和上面的题目是一致的,不同的是我们要花更多的心思在最开始边界的处理上,简单来说就是在最开始的边界上多加一层for循环而已,请看代码

class Solution {
    public List<List<Integer>> fourSum(int[] nums, int target) {
        if(nums.length<4){
            return new LinkedList<>();
        }
        Arrays.sort(nums);
        List<List<Integer>> anslist = new LinkedList<>();
        for (int i = 0; i < nums.length-1; i++) {
            if(i>0 && nums[i]==nums[i-1]){
                continue;
            }
            for (int j = i+1; j < nums.length; j++) {
                if(j>i+1 && nums[j]==nums[j-1]){
                    continue;
                }
                int len = j+1,right = nums.length-1;
                while (len<right){
                    int temp = nums[i]+nums[j]+nums[len]+nums[right];
                    if(temp>target){
                        right--;
                        continue;
                    }else if(temp<target){
                        len++;
                        continue;
                    }else {
                        List<Integer> list = new LinkedList<>();
                        list.add(nums[i]);
                        list.add(nums[j]);
                        list.add(nums[len]);
                        list.add(nums[right]);
                        anslist.add(list);
                    }
                    while (len < right && nums[len]==nums[len+1]){
                        len++;
                    }
                    while (len < right && nums[right]==nums[right-1]){
                        right--;
                    }
                    len++;
                    right--;
                }
            }
        }
        return anslist;
    }
}

按照这种思路我们也可以解决五数,六数之和,我们也可以依葫芦画瓢,当然如果推广到n数之和的话,我们就应该想些其他更好的方法了,因为我们不能每多一个层级我们就手动多构建一层for循环。当然了,那不是我们现在所能够解决的题目

不过有同学似乎有个疑问,就是我们这里似乎也没有使用哈希表啊,为什么又将其归置为哈希表的一种呢?我会说,可别忘了数组也是一种哈希表的结构

哈希表数据结构总结

首先,对于哈希表我们要明白哈希表底部有什么结构,其中可以通过排序的二叉查找树或者是红黑树来实现,但是那些排序的实现会导致我们插入时的效率降低,当然,其好处在于其内部的数据插入时就已经是排好序了的。如果我们不是需要事先进行过排序的哈希表,我们最好都使用Hashset数据结构进行实现,该底层结构由于不用实现排序,其插入效率要比前二者要高

其次,我们要记住哈希表的Set最关键的作用是用于确定重复元素的存在,如果我们想要确定重复元素的个数或者是返回某些情况下的一些对象组合的数量,我们也推荐使用Map集合来解题

最后是哈希表处理某些数组问题的难点在于我们要想方设法将数组的题目转换思路,用一种可以通过哈希表的形式来实现,这常常会使用到某些和的构造和遍历,这是不容易想到的,对于这种情况我们只推荐一种提升自己的方式,就是熟能生巧。