哈希表的基础知识
作为最常用的数据结构之一,哈希表的优点是高效,在哈希表中插入/删除/查找,都只需要O(1)的时间。
在Java中,哈希表有两种数据结构,分别是HashSet和HashMap。
| 操作 | HashSet | HashMap |
|---|---|---|
| 添加元素 | add | put putIfAbsent |
| 判断是否存在某元素 | contains | containsKey |
| 删除元素 | remove | remove |
| 元素数目 | size | size |
| 查找元素 | - | get getOrDefault |
| 修改元素的值 | - | replace |
设计一个哈希表
手动实现哈希表是面试中常见的问题,哈希表的插入和查找、删除都是O(1)的时间复杂度,数组满足这种效率要求,因此常用的方式是,建立长度为n的数组,对于将要保存的数字k,使其对n取余,然后放入相应的位置,如果发生哈希冲突,则在slot中建立一个链表来保存数据。
接下来是根据哈希值查找元素的过程,首先对数组长度求余,得到元素位于数组里的哪一个slot,然后对该slot处的链表进行遍历,对比哈希值从而找到目标元素。
由于对链表遍历的时间效率只有O(n),远远小于在数组中查找的O(1),因此必须控制链表长度不能太长,实践中,当哈希表中元素的数目与数组长度比值超过某一个阈值时,需要对数组进行扩容,然后对哈希表中每个元素进行重排。在Java中,起始数组长度为16,loadFactor为0.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缓存
设计实现一个LRU缓存,支持以下操作
- 构造函数 LRUCache(int capacity),容量为正整数
- int get(int key),如果存在则返回数值,不存在则返回-1
- void put(int key, int value),如果已存在则变更值;如果不存在则插入key-value,插入时如果容量达到上限则删除最古老的一个键值对
- get、put时间复杂度都是O(1)
ANSWER
分析思路如下:
get、put时间复杂度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:有效的变位词
给定两个字符串 s 和 t ,编写一个函数来判断它们是不是一组变位词(字母异位词)。
注意:若 s 和 t 中每个字符出现的次数都相同且字符顺序不完全相同,则称 s 和 t 互为变位词(字母异位词)。
ANSWER
对比两个字符串含有的字符是否相同,使用哈希表一类的数据结构。
首先判断长度,如果长度不相等,显然不是变位词。
- 如果字符串只含有小写英文字母,可以用容量为
26的数组,char-'a'作为key,value是该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:变位词组
给定一个字符串数组 strs ,将 变位词 组合在一起。 可以按任意顺序返回结果列表。
示例 1:
输入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
输出: [["bat"],["nat","tan"],["ate","eat","tea"]]
ANSWER
变位词的定义是具有相同的字母,但排列顺序不同。题目要求返回变位词的分组,考虑使用HashMap<T, LinkedList<String>>来作为返回值。
因此问题变成,对于同一组变位词,如何提取他们的共同点作为HashMap的Key。提出2种方式。
- 质数法。将26个字母分别分配一个质数,然后计算单词里各个字母的乘积,如果两个词是变位词,它们的乘积一定相等。如果两个词相互不为变位词,则质数的特性决定了它们的乘积不等。这种方法的时间复杂度是
O(mn),其中m是单词平均长度,n是单词个数 - 字母排序法。将单词里面的字母按照字母表排序后,作为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:外星语言是否排序
某种外星语也使用英文小写字母,但可能顺序 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的长度小于b,a是b的开头子字符串,比较时应当认为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:最小时间差
给定一个 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:00与23: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;
}