剑指offer算法课(五)哈希表

88 阅读10分钟

哈希表的基础知识

作为最常用的数据结构之一,哈希表的优点是高效,在哈希表中插入/删除/查找,都只需要O(1)的时间。

在Java中,哈希表有两种数据结构,分别是HashSetHashMap

操作HashSetHashMap
添加元素addput putIfAbsent
判断是否存在某元素containscontainsKey
删除元素removeremove
元素数目sizesize
查找元素-get getOrDefault
修改元素的值-replace

设计一个哈希表

手动实现哈希表是面试中常见的问题,哈希表的插入和查找、删除都是O(1)的时间复杂度,数组满足这种效率要求,因此常用的方式是,建立长度为n的数组,对于将要保存的数字k,使其对n取余,然后放入相应的位置,如果发生哈希冲突,则在slot中建立一个链表来保存数据。

接下来是根据哈希值查找元素的过程,首先对数组长度求余,得到元素位于数组里的哪一个slot,然后对该slot处的链表进行遍历,对比哈希值从而找到目标元素。

由于对链表遍历的时间效率只有O(n),远远小于在数组中查找的O(1),因此必须控制链表长度不能太长,实践中,当哈希表中元素的数目与数组长度比值超过某一个阈值时,需要对数组进行扩容,然后对哈希表中每个元素进行重排。在Java中,起始数组长度为16loadFactor0.75,该链表的阈值为16*0.75=12,当元素数量超过12时,就以2倍的方式进行扩容。


面试题30:插入、删除和随机访问都是O(1)的容器

leetcode.cn/problems/Fo… 设计一个支持在平均 时间复杂度 O(1) 下,执行以下操作的数据结构:

insert(val):当元素 val 不存在时返回 true ,并向集合中插入该项,否则返回 false 。
remove(val):当元素 val 存在时返回 true ,并从集合中移除该项,否则返回 false 。
getRandom:随机返回现有集合中的一项。每个元素应该有 相同的概率 被返回。

ANSWER

HashMap可以实现以O(1)时间复杂度来插入、删除,但是无法支持随机访问元素。如果我们使用数组,在已知数组长度的前提下,是可以随机返回其中的某个元素的(数组中必须没有空位)。

因此,我们把数据保存在数组里,同时使用HashMap来记录数值-下标的对应关系。

class RandomizedSet {
    HashMap<Integer, Integer> numToLocation;
    ArrayList<Integer> nums;

    public RandomizedSet() {
        numToLocation = new HashMap<>();
        nums = new ArrayList<>();
    }

    public boolean insert (int val) {
        if (numToLocation.containsKey(val)) {
            return false;
        }
        numToLocation.put(val, nums.size()); // 数值-下标
        nums.add(val);
        return true;
    }

    public boolean remove(int val) {
        if (!numToLocation.containsKey(val)) {
            return fasle;
        }
        int location = numToLocation.get(val);
        numToLocation.put(nums.get(nums.size() - 1), location);
        numToLocation.remove(val);
        nums.set(location, nums.get(nums.size() - 1));
        nums.remove(nums.size() - 1); // 交换,将尾部元素填补空位,随后size-1
        return true;
    }

    public int getRandom() {
        Random random = new Random();
        int r = random.nextInt(nums.size());
        return nums.get(r);
    }
}

唯一有难度的地方在于删除元素后,要调整最后一个元素在数组里的位置,使其与被删除的slot交换,同时更新查找表。


面试题31:LRU缓存

leetcode.cn/problems/Or…

设计实现一个LRU缓存,支持以下操作

  • 构造函数 LRUCache(int capacity),容量为正整数
  • int get(int key),如果存在则返回数值,不存在则返回-1
  • void put(int key, int value),如果已存在则变更值;如果不存在则插入key-value,插入时如果容量达到上限则删除最古老的一个键值对
  • get、put时间复杂度都是O(1)

ANSWER

分析思路如下:

  • getput时间复杂度O(1) --> 使用HashMap作为查找表,直接保存key-value
  • 记录最少使用的那个元素 --> 既然是有时序要求,则选用链表类数据结构;既要支持尾部入队,也要支持头部出队,Java中的队列Queue接口是最合适的

注意,在LRUCache中get和put操作都会影响使用频率

这里使用自定义双向链表来实现,为什么没有直接用Java中的Queue,比如ArrayList呢?因为ArrayList内部其实也是使用双链表来实现的。并且它还没有用到哨兵节点,实现反而更加复杂。

class ListNode {
    public int key;
    public int value;
    public ListNode next;
    public ListNode prev;
    public ListNode(int k, int v) {
        key = k;
        value = v;
    }
}

class LRUCache {
    private ListNode head;
    private ListNode tail;
    private Map<Integer, ListNode> map;
    int capacity;
    public LRUCache(int cap) {
        map = new HashMap<>();
        head = new ListNode(-1, -1);
        tail = new ListNode(-1, -1);
        head.next = tail;
        tail.prev = head;
        capacity = cap;
    }
}

public int get (int key) {
    if (node == null) {
        return -1;
    }
    moveToTail(node, node.value); // get后,移动到链表尾
    return node.value;
}

public void put(int key, int value) {
    if (map.containsKey(key)) moveToTail(map.get(key), value); // 如果key已存在,则将其移动到链表尾部,表示最近使用过,容量无变化
    else {
        if (map.size() == capacity) { // 直接用map的大小,无需再维护一个当前元素数量的变量
            ListNode toBeDeleted = head.next; // 哨兵节点的下一个,真实头部
            deleteNode(toBeDeleted);
            map.remove(toBeDeleted.key);
        }
        ListNode node = new ListNode(key, value);
        insertToTail(node);
        map.put(key, node);
    }
}

// 将node添加到链表末尾,然后判断长度是否超出capacity,若超出则删除头部节点
public void moveToTail(ListNode node, int newValue) {
    deleteNode(node);
    node.value = newValue;
    insertToTail(node);
}

public void deleteNode(ListNode node) {
    ListNode prevNode = node.prev;
    ListNode nextNode = node.next;
    node.prev.next = node.next;
    node.next.prev = node.prev;
}

public void insertToTail(ListNode node) {
    node.prev = tail.prev;
    node.next = tail;
    node.prev.next = node;
    tail.prev = node;
}    

面试题32:有效的变位词

leetcode.cn/problems/dK…

给定两个字符串 s 和 t ,编写一个函数来判断它们是不是一组变位词(字母异位词)。
注意:若 s 和 t 中每个字符出现的次数都相同且字符顺序不完全相同,则称 s 和 t 互为变位词(字母异位词)。

ANSWER

对比两个字符串含有的字符是否相同,使用哈希表一类的数据结构。

首先判断长度,如果长度不相等,显然不是变位词。

  • 如果字符串只含有小写英文字母,可以用容量为26的数组,char-'a'作为keyvalue是该char出现的次数
  • 如果字符取值范围不仅限于小写英文字母(ASCII码为8bit,字符数256;Unicode则是16bit共65536),就需要使用HashMap来降低空间占用

时间复杂度O(m+n)

// 只含英文小写
public boolean isAnagram(String str1, String str2) {
    if(str1.length != str2.length) return false;
    int[] counts = new int[26];
    for (char ch : str1.toCharArray()) counts[ch-'a']++;
    for (char ch : str2.toCharArray()) {
        if (counts[ch-'a'] == 0) return false; // 在str2里的字符,却不在str1里
        counts[ch-'a-]--;
    }
    return true;
}

// Unicode,使用HashMap代替数组
public boolean isAnagram(String str1, String str2) {
    if (str1.length() != str2.length()) return false;
    Map<Character, Integer> counts = new HashMap<>();
    for (char ch : str1.toCharArray()) counts.put(ch, counts.getOrDefault(ch, 0) + 1);
    for (char ch : str2.toCharArray()) {
        if (counts.getOrDefault(ch, 0) == 0) return false;
        counts.put(ch, counts.get(ch) - 1):
    }
    return true;
}

面试题33:变位词组

leetcode.cn/problems/sf…

给定一个字符串数组 strs ,将 变位词 组合在一起。 可以按任意顺序返回结果列表。
示例 1:
输入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
输出: [["bat"],["nat","tan"],["ate","eat","tea"]]

ANSWER

变位词的定义是具有相同的字母,但排列顺序不同。题目要求返回变位词的分组,考虑使用HashMap<T, LinkedList<String>>来作为返回值。

因此问题变成,对于同一组变位词,如何提取他们的共同点作为HashMapKey。提出2种方式。

  1. 质数法。将26个字母分别分配一个质数,然后计算单词里各个字母的乘积,如果两个词是变位词,它们的乘积一定相等。如果两个词相互不为变位词,则质数的特性决定了它们的乘积不等。这种方法的时间复杂度是O(mn),其中m是单词平均长度,n是单词个数
  2. 字母排序法。将单词里面的字母按照字母表排序后,作为Key。这种方法的时间复杂度是O(mnlogm)

下文展示字母排序法的实现。

public List<List<String>> groupAnagrams(String[] strs) {
    Map<String, List<String>> groups = new HashMap<>();
    for (String str : strs) {
        String sortedStr = new String(Arrays.sort(str.toCharArray()));
        groups.putIfAbsent(sortedStr, new LinkedList<String>());
        groups.get(sortedStr).add(str);
    }
    return new LinkedList<>(groups.values());;
}

面试题34:外星语言是否排序

leetcode.cn/problems/lw…

某种外星语也使用英文小写字母,但可能顺序 order 不同。字母表的顺序(order)是一些小写字母的排列。

给定一组用外星语书写的单词 words,以及其字母表的顺序 order,只有当给定的单词在这种外星语中按字典序排列时,返回 true;否则,返回 false。

示例 1:

输入:words = ["hello","leetcode"], order = "hlabcdefgijkmnopqrstuvwxyz"
输出:true
解释:在该语言的字母表中,'h' 位于 'l' 之前,所以单词序列是按字典序排列的。

ANSWER

这道题目最大的难点在于理解题意。

结合common sense,通常我们把a~z叫做字典序,String默认的compare函数就是按照字典序来比较的。本题相当于重新定了一个字典序,然后判断输入的一系列String是否是已经依照新的字典序升序排列。

假设输入数组长度为m,每个单词平均长度为n,需要对m进行一次遍历,期间比较相邻两个词的先后顺序,逐字母比较。需要注意,如果字符串a的长度小于bab的开头子字符串,比较时应当认为a<b

用容量为26的数组代替哈希表,建立字符序号索引,用于单词之间两两比较。

public boolean isAlienSorted(String[] words, String order) {
    int[] orderArray = new int[order.length()];
    for (int i=0; i<order.length(); i++) orderArray[order.charAt(i)-'a'] = i;
    for (int i=0; i<words.length-1; i++) {
        if (!isSorted[words[i], words[i+1], orderArray) return false;
    }
    return true;
}

private boolean isSorted(String word1, String word2, int[] order) {
    int i=0;
    for ( ; i<word1.length() && i<word2.length(); i++) {
        char ch1 = word1.charAt(i);
        char ch2 = word2.charAt(i);
        if (order[ch1-'a'] < order[ch2-'a']) return true;
        if (order[ch1-'a'] > order[ch2-'a']) return false;
    }
    return i==word1.length() // word1已遍历结束,word2长度>=word1
}

面试题35:最小时间差

leetcode.cn/problems/56…

给定一个 24 小时制(小时:分钟 "HH:MM")的时间列表,找出列表中任意两个时间的最小时间差并以分钟数表示。

示例 1:
输入:timePoints = ["23:59","00:00"]
输出:1

示例 2:
输入:timePoints = ["00:00","23:59","00:00"]
输出:0

ANSWER

暴力法: 双层循环两两比较,时间复杂度O(n^2)

哈希表法: 因为输入时间精确到分钟,一天内共有24*60=1440分钟,因此可以用一个1440容量的bool数组来表示每个时间slot,首先扫描一遍输入,把相应时间点在数组中置为true,在这个过程中如果发现之前已经输入过这个时间,则直接返回最小时间差0

  • 在扫描前,先判断输入数组的长度是否大于1440,如果大于则说明至少有2个相同时间点,可以直接返回最小时间差0
  • 扫描过程中,使用双指针,指针1记录上一个时间值,指针2记录下一个,同时计算它们的差值,更新min,需要扫描一遍
  • 注意边界条件,0:0023:59的时间差是1min,而不是23h59min

这种实现方式需要1440常量长度的数组,空间复杂度O(1),扫描输入两遍,时间复杂度O(n)

public int findMinDifference(List<String> timePoints) {
    if (timePoints.size() > 1440) return 0; <-- 重点1,输入至少有2个重复时间
    boolean minuteFlags[] = new boolean[1440];
    for (String time : timePoints) {
        String t[] = time.split(":");
        int min = Integer.parseInt(t[0])*60 + Integer.parseInt(t[1]);
        if (minuteFlags[min]) return 0;
        minuteFlags[min] = true;
    }
    // 已经建立好哈希表,接下来找出其中最小的时间间隔
    return helper(minuteFlags[min]);
}

// 下标就是时间戳
private int helper(boolean[] minuteFlags) {
    int minDiff = Integer.MAX_VALUE;
    int prev = -1; // 记录上一个出现的时间点
    int first = Integer.MAX_VALUE;
    int last = -1;
    for (int i=0; i<minuteFlags.length; i++) {
        if (minuteFlags[i]) {
            if (prev >= 0) minDiff = Math.min(i-prev, minDiff);
            prev = i;
            first = Math.min(i, first);
            last = Math.max(i, last);
        }
    }
    minDiff = Math.min(first + minuteFlags.length - last, minDiff); // 边界条件,首尾相比
    return minDiff;
}