【算法】哈希表法四部曲

哈希表

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。

关键码值与地址一一映射

适用场景

适用于关键字与某一值一一对应,即 可使用键值对map,而hashmap是键值对中较好的实现类

关键词:一一对应

遍历哈希表的四种方式

public static void main(String[] args) {
  Map<String,String> map=new HashMap<String,String>();
        map.put("1", "value1");
        map.put("2", "value2");
        map.put("3", "value3");
        map.put("4", "value4");
        
        //第一种:普通使用,二次取值
        // 遍历键,取出值
        System.out.println("\n通过Map.keySet遍历key和value:");  
        for(String key:map.keySet()) {
          System.out.println("Key: "+key+" Value: "+map.get(key));
        }
        
        //第二种
        // 使用Map.entrySet()的迭代器
        System.out.println("\n通过Map.entrySet使用iterator遍历key和value: ");  
        Iterator map1it=map.entrySet().iterator();
        while(map1it.hasNext()) {
          Map.Entry<String, String> entry=(Entry<String, String>) map1it.next();
          System.out.println("Key: "+entry.getKey()+" Value: "+entry.getValue());
        }
        
        //第三种:推荐,尤其是容量大时
        // foreach
        System.out.println("\n通过Map.entrySet遍历key和value");  
        for(Map.Entry<String, String> entry: map.entrySet()) {
          System.out.println("Key: "+ entry.getKey()+ " Value: "+entry.getValue());
        }
        
        //第四种  
        // 遍历value
        System.out.println("\n通过Map.values()遍历所有的value,但不能遍历key");  
        for(String v:map.values()) {
          System.out.println("The value is "+v);
        }
 }

输出结果:

通过Map.keySet遍历key和value:
Key: 1 Value: value1
Key: 2 Value: value2
Key: 3 Value: value3
Key: 4 Value: value4

通过Map.entrySet使用iterator遍历key和value: 
Key: 1 Value: value1
Key: 2 Value: value2
Key: 3 Value: value3
Key: 4 Value: value4

通过Map.entrySet遍历key和value
Key: 1 Value: value1
Key: 2 Value: value2
Key: 3 Value: value3
Key: 4 Value: value4

通过Map.values()遍历所有的value,但不能遍历key
The value is value1
The value is value2
The value is value3
The value is value4

【推荐】使用entrySet遍历Map类集合KV,而不是keySet方式进行遍历。
说明:keySet 其实是遍历了2次,一次是转为Iterator对象,另一次是从hashMap中取出key所对应的value。而entrySet只是遍历了一次就把key和value都放到了entry中,效
率更高。如果是JDK8,使用Map.foreach方法。
正例:values()返回的是V值集合,是一个list集合对象; keySet()返回的是K值集合,是一个Set集合对象; entrySet()返回的是K-V值组合集合。

记录数组中元素出现频数

遍历nums1,使用哈希表存储关键字,以及他们出现的次数

方法一:遇到空的就赋初值,非空就+1

// 1. 遍历nums1,使用哈希表存储关键字,以及他们出现的次数
Map<Integer, Integer> map = new HashMap<>();

for (int i = 0; i < nums1.length; i++) {
    if (map.get(nums1[i]) != null) {
        map.put(nums1[i], map.get(nums1[i])+1);
    } else {
        map.put(nums1[i], 1);
    }
}

方法二:使用getOrDefault()

Map<Integer, Integer> map = new HashMap<Integer, Integer>();

for (int num : nums1) {
    int count = map.getOrDefault(num, 0) + 1;
    map.put(num, count);
}

方法三:如果元素固定,那么我们可以就使用一个一维数组来存储他们出现的频数。这也是哈希表法。
遍历字符串 p,记录字符频数

int[] sArr = new int[26];

for (int i = 0; i < p.length(); i++) {
    sArr[s.charAt(i) - 'a']++;  
}

方法四:如果要求元素只出现一次 或者判断是否有重复元素,那就可以用哈希集合

Set<Integer, Integer> set = new HashSet<Integer>();

for (int num : nums1) {
    // 添加此元素至 Set,加入失败那就代表有重复
    if(!set.add(num)) {
        return false;
    }
}

实例

1. 两数之和

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

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。

你可以按任意顺序返回答案。

 

示例 1:
输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。

示例 2:
输入:nums = [3,2,4], target = 6
输出:[1,2]

示例 3:
输入:nums = [3,3], target = 6
输出:[0,1]

答案

class Solution {
    public int[] twoSum(int[] nums, int target) {

        // 哈希表用来存放(关键字,下标)
        Map<Integer, Integer> map = new HashMap<>();

        // 遍历数组,每次遇到一个元素判断哈希表里面有没有与其对应的target - nums[i]元素
        // 如果有就返回下标,如果没有就把它的关键字和下标放进去,
        for (int i = 0; i < nums.length; i++) {

            Integer index = map.get(target - nums[i]);
            if (index != null) {
                return new int[]{index, i};
            } else {
                map.put(nums[i], i);
            }
        }
        return null;
    }
}

有效的字母异位词

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

示例 1:

输入: s = “anagram”, t = “nagaram”
输出: true
示例 2:

输入: s = “rat”, t = “car”
输出: false
说明:
你可以假设字符串只包含小写字母。

进阶:
如果输入字符串包含 unicode 字符怎么办?你能否调整你的解法来应对这种情况?

答案

方法一:排序

t 是 s 的异位词等价于「两个字符串排序后相等」。因此我们可以对字符串 s 和 t 分别排序,看排序后的字符串是否相等即可判断。此外,如果 s 和 t 的长度不同,t 必然不是 s 的异位词。

Java

class Solution {
    public boolean isAnagram(String s, String t) {
        if (s.length() != t.length()) {
            return false;
        }
        char[] str1 = s.toCharArray();
        char[] str2 = t.toCharArray();
        Arrays.sort(str1);
        Arrays.sort(str2);
        return Arrays.equals(str1, str2);
    }
}

复杂度分析

  • 时间复杂度:\(O(n \log n)\),其中 n 为 s 的长度。排序的时间复杂度为 \(O(n\log n)\),比较两个字符串是否相等时间复杂度为 \(O(n)\),因此总体时间复杂度为 \(O(n \log n+n)=O(n\log n)\)

  • 空间复杂度:\(O(\log n)\)。排序需要 \(O(\log n)\) 的空间复杂度。注意,在某些语言(比如 Java & JavaScript)中字符串是不可变的,因此我们需要额外的 \(O(n)\) 的空间来拷贝字符串。但是我们忽略这一复杂度分析,因为:

这依赖于语言的细节;
这取决于函数的设计方式,例如,可以将函数参数类型更改为 char[]。

方法二:哈希表

前面我们说过了关键码值与地址一一映射,就可以称为哈希表(即 散列表),所以此处的方法也可以称为哈希表法。

从另一个角度考虑,t 是 s 的异位词等价于「两个字符串中字符出现的种类和次数均相等」。由于字符串只包含 26 个小写字母,因此我们可以维护一个长度为 26 的频次数组 \(\textit{table}\),先遍历记录字符串 s 中字符出现的频次,然后遍历字符串 t,减去 \(\textit{table}\) 中对应的频次,如果出现 \(\textit{table}[i]<0\),则说明 t 包含一个不在 s 中的额外字符,返回 \(\text{false}\) 即可。

Java

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

对于进阶问题,\(\text{Unicode}\) 是为了解决传统字符编码的局限性而产生的方案,它为每个语言中的字符规定了一个唯一的二进制编码。而 \(\text{Unicode}\) 中可能存在一个字符对应多个字节的问题,为了让计算机知道多少字节表示一个字符,面向传输的编码方式的 \(\text{UTF}-8\)\(\text{UTF}-16\) 也随之诞生逐渐广泛使用,具体相关的知识读者可以继续查阅相关资料拓展视野,这里不再展开。

回到本题,进阶问题的核心点在于「字符是离散未知的」,因此我们用哈希表维护对应字符的频次即可。同时读者需要注意 \(\text{Unicode}\) 一个字符可能对应多个字节的问题,不同语言对于字符串读取处理的方式是不同的。

Java

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

复杂度分析

  • 时间复杂度:\(O(n)\),其中 n 为 s 的长度。

  • 空间复杂度:\(O(S)\),其中 S 为字符集大小,此处 \(S=26\)

剑指 Offer 61. 扑克牌中的顺子

从扑克牌中随机抽5张牌,判断是不是一个顺子,即这5张牌是不是连续的。2~10为数字本身,A为1,J为11,Q为12,K为13,而大、小王为 0 ,可以看成任意数字。A 不能视为 14。

示例 1:

输入: [1,2,3,4,5]
输出: True

示例 2:

输入: [0,0,1,2,5]
输出: True

答案

class Solution {
    public boolean isStraight(int[] nums) {
        Set<Integer> repeat = new HashSet<>();
        int max = 0, min = 14;
        for(int num : nums) {
            if(num == 0) continue; // 跳过大小王
            max = Math.max(max, num); // 最大牌
            min = Math.min(min, num); // 最小牌
            // 添加此牌至 Set,加入失败那就代表有重复
            if(!repeat.add(num)) return false; // 若有重复,提前返回 false
             
        }
        return max - min + 1 <= 5; // 最大牌 - 最小牌 + 1 <= 5 则可构成顺子,因为包含有0、0、0、9、11的情况
    }
}
Tags: