本系列所有文章都将会收录到GitHub
中统一收藏与管理,欢迎ISSUE
和Star
。
GitHub传送门:Kiner算法算题记
了解了哈希表的底层实现原理,知道了哈希函数的设计、哈希冲突的解决方案以及布隆过滤器的应用场景之后,再来一波算法题加深一下对哈希表的理解吧。
这道题我们其实就可以直接使用拉练法实现哈希表,因为这道题要求删除元素,而链表对于插入和删除元素的效率都是很高的。
/*
* @lc app=leetcode.cn id=705 lang=typescript
*
* [705] 设计哈希集合
*
* https://leetcode-cn.com/problems/design-hashset/description/
*
* algorithms
* Easy (65.04%)
* Likes: 163
* Dislikes: 0
* Total Accepted: 58.3K
* Total Submissions: 89.6K
* Testcase Example: '["MyHashSet","add","add","contains","contains","add","contains","remove","contains"]\n' +
'[[],[1],[2],[1],[3],[2],[2],[2],[2]]'
*
* 不使用任何内建的哈希表库设计一个哈希集合(HashSet)。
*
* 实现 MyHashSet 类:
*
*
* void add(key) 向哈希集合中插入值 key 。
* bool contains(key) 返回哈希集合中是否存在这个值 key 。
* void remove(key) 将给定值 key 从哈希集合中删除。如果哈希集合中没有这个值,什么也不做。
*
*
*
* 示例:
*
*
* 输入:
* ["MyHashSet", "add", "add", "contains", "contains", "add", "contains",
* "remove", "contains"]
* [[], [1], [2], [1], [3], [2], [2], [2], [2]]
* 输出:
* [null, null, null, true, false, null, true, null, false]
*
* 解释:
* MyHashSet myHashSet = new MyHashSet();
* myHashSet.add(1); // set = [1]
* myHashSet.add(2); // set = [1, 2]
* myHashSet.contains(1); // 返回 True
* myHashSet.contains(3); // 返回 False ,(未找到)
* myHashSet.add(2); // set = [1, 2]
* myHashSet.contains(2); // 返回 True
* myHashSet.remove(2); // set = [1]
* myHashSet.contains(2); // 返回 False ,(已移除)
*
*
*
* 提示:
*
*
* 0
* 最多调用 10^4 次 add、remove 和 contains 。
*
*
*
*
* 进阶:你可以不使用内建的哈希集合库解决此问题吗?
*
*/
// @lc code=start
class LinkNode<T> {
constructor(public key: T = undefined, public next: LinkNode<T> = null) {
}
insertAfter(node: LinkNode<T>) {
node.next = this.next;
this.next = node;
}
removeAfter() {
if(!this.next) return;
this.next = this.next.next;
}
}
class MyHashSet {
static size = 100;
private data: LinkNode<number>[] = new Array<LinkNode<number>>(MyHashSet.size);
constructor() {
let n = MyHashSet.size;
// 初始话哈希表的每一个位置为一个空的头结点
while(n) {
this.data[n-1] = new LinkNode<number>();
n-=1;
}
}
/**
* 哈希函数
* 由于我们的key本身就是number类型,我们只需要保证我们返回的索引不是负数即可
* 我们可以使用 key & 0x7ffffff来确保我们的数肯定是正数,然后再将结果与哈希表大小取余
* @param key
* @returns
*/
hashFn(key: number): number {
return (key & 0x7fffffff) % this.data.length;
}
add(key: number): void {
// 如果哈希表中已经存在key,则直接返回
if(this.contains(key)) return;
// 通过哈希函数计算数组下标
const idx = this.hashFn(key);
// 将目标值插入到链表头结点的下一位,为什么不是最后一位的下一位呢?因为我们压根就不关心
// 目标值的插入顺序,我们只需要能够找到目标值即可,因此,插入到头结点的下一位还是插入到
// 最后一个节点的下一位效果是一样的。这就是头插法
this.data[idx].insertAfter(new LinkNode(key));
}
remove(key: number): void {
// 可以无需判断是否存在key,因为如果不存在的话,后面的p.next将为空,不会进入循环
// 而p.removeAfter也排除了如果next为空时将不做任何处理
// 存在则用哈希函数先计算出数组下标
let idx = this.hashFn(key);
let p: LinkNode<number> = this.data[idx];
// 查找到目标节点的上一个节点
while(p.next && p.next.key !== key) p = p.next;
// 找到之后直接删除下一个节点即可
p.removeAfter();
}
contains(key: number): boolean {
// 通过哈希函数计算数组下标
const idx = this.hashFn(key);
let p: LinkNode<number> = this.data[idx].next;
while(p && p.key !== key) p = p.next;
return !!p;
}
}
/**
* Your MyHashSet object will be instantiated and called as such:
* var obj = new MyHashSet()
* obj.add(key)
* obj.remove(key)
* var param_3 = obj.contains(key)
*/
// @lc code=end
这道题起始跟上一题很类似,不过从单纯的存一个key值到存一个键值对,这个就更贴近于我们经常使用的json了。有一个需要注意的点就是当找到key值对应的数据时,需要替换value值
/*
* @lc app=leetcode.cn id=706 lang=typescript
*
* [706] 设计哈希映射
*
* https://leetcode-cn.com/problems/design-hashmap/description/
*
* algorithms
* Easy (64.62%)
* Likes: 200
* Dislikes: 0
* Total Accepted: 48.7K
* Total Submissions: 75.3K
* Testcase Example: '["MyHashMap","put","put","get","get","put","get","remove","get"]\n' +
'[[],[1,1],[2,2],[1],[3],[2,1],[2],[2],[2]]'
*
* 不使用任何内建的哈希表库设计一个哈希映射(HashMap)。
*
* 实现 MyHashMap 类:
*
*
* MyHashMap() 用空映射初始化对象
* void put(int key, int value) 向 HashMap 插入一个键值对 (key, value) 。如果 key
* 已经存在于映射中,则更新其对应的值 value 。
* int get(int key) 返回特定的 key 所映射的 value ;如果映射中不包含 key 的映射,返回 -1 。
* void remove(key) 如果映射中存在 key 的映射,则移除 key 和它所对应的 value 。
*
*
*
*
* 示例:
*
*
* 输入:
* ["MyHashMap", "put", "put", "get", "get", "put", "get", "remove", "get"]
* [[], [1, 1], [2, 2], [1], [3], [2, 1], [2], [2], [2]]
* 输出:
* [null, null, null, 1, -1, null, 1, null, -1]
*
* 解释:
* MyHashMap myHashMap = new MyHashMap();
* myHashMap.put(1, 1); // myHashMap 现在为 [[1,1]]
* myHashMap.put(2, 2); // myHashMap 现在为 [[1,1], [2,2]]
* myHashMap.get(1); // 返回 1 ,myHashMap 现在为 [[1,1], [2,2]]
* myHashMap.get(3); // 返回 -1(未找到),myHashMap 现在为 [[1,1], [2,2]]
* myHashMap.put(2, 1); // myHashMap 现在为 [[1,1], [2,1]](更新已有的值)
* myHashMap.get(2); // 返回 1 ,myHashMap 现在为 [[1,1], [2,1]]
* myHashMap.remove(2); // 删除键为 2 的数据,myHashMap 现在为 [[1,1]]
* myHashMap.get(2); // 返回 -1(未找到),myHashMap 现在为 [[1,1]]
*
*
*
*
* 提示:
*
*
* 0
* 最多调用 10^4 次 put、get 和 remove 方法
*
*
*
*
* 进阶:你能否不使用内置的 HashMap 库解决此问题?
*
*/
// @lc code=start
class LinkNode<T=any, P=any> {
constructor(public key: T = undefined, public value: P = undefined, public next: LinkNode<T> = null) {
}
insertAfter(node: LinkNode<T>) {
node.next = this.next;
this.next = node;
}
removeAfter() {
if(!this.next) return;
this.next = this.next.next;
}
}
class MyHashMap {
static size = 100;
private data: LinkNode<number, number>[] = new Array<LinkNode<number, number>>(MyHashMap.size);
constructor() {
let n = MyHashMap.size;
while(n) {
this.data[n-1] = new LinkNode();
n--;
}
}
hashFn(key: number): number {
return (key & 0x7fffffff) % this.data.length;
}
put(key: number, value: number): void {
// 先通过哈希函数计算出位于哈希表的那个位置
let idx: number = this.hashFn(key);
// 拿出指定位置的链表头结点
let p: LinkNode<number, number> = this.data[idx];
// 不断的查找key对应的节点
while(p.next && p.next.key !== key) p = p.next;
// 如果最终找到了key相同的节点,则直接替换value
if(p.next) {
p.next.value = value;
} else {// 否则插入一个新的链表节点
p.insertAfter(new LinkNode(key, value));
}
}
get(key: number): number {
// 先通过哈希函数计算出位于哈希表的那个位置
let idx: number = this.hashFn(key);
// 拿出指定位置的链表头结点
let p: LinkNode<number, number> = this.data[idx].next;
while(p && p.key !== key) p = p.next;
return p?p.value:-1;
}
remove(key: number): void {
// 先通过哈希函数计算出位于哈希表的那个位置
let idx: number = this.hashFn(key);
// 拿出指定位置的链表头结点
let p: LinkNode<number, number> = this.data[idx];
while(p.next && p.next.key !== key) p = p.next;
p.removeAfter();
}
}
/**
* Your MyHashMap object will be instantiated and called as such:
* var obj = new MyHashMap()
* obj.put(key,value)
* var param_2 = obj.get(key)
* obj.remove(key)
*/
// @lc code=end
有没有觉得这个概念好像很熟悉,之前我们学习链表的时候就有介绍过一下这个LRU缓存淘汰算法,此外,我们Vue的源码当中,也有使用到LRU缓存淘汰算法优化我们的单组件的编译流程。
LRU缓存淘汰算法大概是这样的:在一定大小的空间末尾中可以插入数据,每当我们访问存储空间中的某个数据时,就把当前访问的数据移动到存储区域的末尾,而每当插入数据时发现我们存储区满后,需要将存储空间最前面的数据淘汰掉,然后把新数据插入到存储区域末尾。
介于上述特点,LRU缓存算法通常使用哈希链表来实现,这样既兼顾了哈希表快速索引值的优点,有保留了链表高效插入删除调整位置的优点。为了方便删除和插入,我们选择使用双向链表结合哈希表解决此问题。
class DoubleLinkNode {
constructor(
public key: number = 0,
public value: number = 0,
public prev: DoubleLinkNode = null,
public next: DoubleLinkNode = null
){}
// 在链表将当前节点移除,即如果储存在上一个节点,让上一个节点的下个节点指向当前节点的下个节点
// 如果存在下个节点,即将下个节点的上一个节点指向当前节点的上个节点
// 然后将当前节点的前后两个指针置空回收即可
removeThis(): DoubleLinkNode {
if(this.prev) {
this.prev.next = this.next
};
if(this.next) {
this.next.prev = this.prev
};
this.next = this.prev = null;
return this;
}
// 在当前节点前面插入一个节点
insertBefore(node: DoubleLinkNode) {
node.next = this;
node.prev = this.prev;
if(this.prev){
this.prev.next = node;
}
this.prev = node;
}
// 用于调试输出
output(){
let p = this.next;
while (p) {
let str = '';
if(p.prev) str+=`prev:${p.prev.key};`;
if(p.next) str+=`next:${p.next.key};`;
str+=`key:${p.key};`;
console.log(str);
p = p.next;
}
console.log("=============\n")
}
}
class HashList {
// 定义虚拟头结点和虚拟尾结点,方便快速在头尾操作元素
private hair: DoubleLinkNode = new DoubleLinkNode(-1,-1);
private tail: DoubleLinkNode = new DoubleLinkNode(100,100);
// map是js中实现的现成的哈希表对象
private map: Map<number, DoubleLinkNode> = new Map<number, DoubleLinkNode>();
constructor(private capacity: number){
// 初始时让头结点下一个节点指向尾结点
// 尾结点的上一个节点指向头结点
// hair ↔ tail
// 这样,当我们在后面插入节点时,就可以直接在尾结点前面插入节点,变成
// hair ↔ node ↔ tail
this.hair.next = this.tail;
this.tail.prev = this.hair;
}
put(key: number, value: number) {
const p: DoubleLinkNode = this.map.get(key);
if(p) { // 如果存在,则更新value,并将当前节点移动到最后面
p.value = value;
// 先在链表中删除当前节点
p.removeThis();
// 然后把当前节点重新插入到链表末尾
this.tail.insertBefore(p);
} else {// 如果不存在,则在哈希表中添加一个并插入到链表末尾
const node = new DoubleLinkNode(key, value);
this.tail.insertBefore(node);
this.map.set(key, node);
}
// 当哈希表大小超过限定大小时,删除哈希表头部元素
if(this.map.size > this.capacity) {
this.delete();
}
// this.hair.output();
}
get(key: number): number {
if(this.map.get(key)) {
// 获取时需要将当前访问的数据移动到链表末尾
const node: DoubleLinkNode = this.map.get(key);
// 先将当前节点从链表中删除
node.removeThis();
// 再将这个节点插入到链表末尾
this.tail.insertBefore(node);
return node.value;
};
return -1;
}
delete() {
// 将数据从哈希表中删除
this.map.delete(this.hair.next.key);
this.hair.next.removeThis();
}
}
class LRUCache {
private hashList: HashList;
constructor(capacity: number) {
this.hashList = new HashList(capacity);
}
get(key: number): number {
return this.hashList.get(key);
}
put(key: number, value: number): void {
this.hashList.put(key, value);
}
}
/**
* Your LRUCache object will be instantiated and called as such:
* var obj = new LRUCache(capacity)
* var param_1 = obj.get(key)
* obj.put(key,value)
*/
这道题就是一个简单的短链服务,支持编码和解码功能。难点其实在于如何随机生成一个短网址。我们要生成的短链id仅包含以下字符:
因此,我们需要一个方法生成一个包含上面字符的指定长度的字符串,然后就使用哈希表做一下映射就ok了。
/*
* @lc app=leetcode.cn id=535 lang=typescript
*
* [535] TinyURL 的加密与解密
*
* https://leetcode-cn.com/problems/encode-and-decode-tinyurl/description/
*
* algorithms
* Medium (83.97%)
* Likes: 119
* Dislikes: 0
* Total Accepted: 14.3K
* Total Submissions: 17.1K
* Testcase Example: '"https://leetcode.com/problems/design-tinyurl"'
*
* TinyURL是一种URL简化服务, 比如:当你输入一个URL https://leetcode.com/problems/design-tinyurl
* 时,它将返回一个简化的URL http://tinyurl.com/4e9iAk.
*
* 要求:设计一个 TinyURL 的加密 encode 和解密 decode
* 的方法。你的加密和解密算法如何设计和运作是没有限制的,你只需要保证一个URL可以被加密成一个TinyURL,并且这个TinyURL可以用解密方法恢复成原本的URL。
*
*/
// @lc code=start
/**
* Encodes a URL to a shortened URL.
*/
function randomNum(): number {
// 随机生成一个62以下的整形随机数
// 为什么是62呢?
// 如果生成的随机数是0-25我们就生成小写字母
// 如果是26-51就生成大写字母
// 如果是52-61就生成数字
let num = Math.floor(Math.random()*62);
// 编程小技巧:直接获取相关区间第一个字符的ASCII,加上生成的随机数,就可以在目标区间内生成一个随机的字符
if(num < 26) num += 'a'.charCodeAt(0);
else if(num < 52) num += 'A'.charCodeAt(0) - 26;
else num += '0'.charCodeAt(0) - 52;
return num;
}
function randomStr(len:number = 6): string {
let res = '';
while(len--) {
res += String.fromCharCode(randomNum());
}
return res;
}
// 存储原链接与id的关系
let map: Map<string, string> = new Map<string, string>();
// 存储id于原链接之间的关系,用于判重
let map2: Map<string, string> = new Map<string, string>();
function encode(longUrl: string): string {
// 如果这个链接已经生成过,直接返回
if(map2.has(longUrl)) {
return map2.get(longUrl);
}
// 生成一个随机id
const id = randomStr();
map.set(id, longUrl);
map2.set(longUrl, id);
return id;
};
/**
* Decodes a shortened URL to its original URL.
*/
function decode(shortUrl: string): string {
return map.get(shortUrl);
};
/**
* Your functions will be called as such:
* decode(encode(strs));
*/
// @lc code=end
这道题也是使用哈希表解决问题,只不过我们再存储时,我们的值是字符串出现的次数。然后,还有一个问题要解决,我们要怎么找到所有的字串呢?我们依然有一个编程小技巧,在循环的时候,i代表开始位置,根据子字串长度为10,那么,我们循环结束的终点应该是j=s.length-9
。因为再往后走字串的长度不够10了,循环也没有意义。
/*
* @lc app=leetcode.cn id=187 lang=typescript
*
* [187] 重复的DNA序列
*
* https://leetcode-cn.com/problems/repeated-dna-sequences/description/
*
* algorithms
* Medium (47.53%)
* Likes: 169
* Dislikes: 0
* Total Accepted: 33.8K
* Total Submissions: 71.2K
* Testcase Example: '"AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT"'
*
* 所有 DNA 都由一系列缩写为 'A','C','G' 和 'T' 的核苷酸组成,例如:"ACGAATTCCG"。在研究 DNA 时,识别 DNA
* 中的重复序列有时会对研究非常有帮助。
*
* 编写一个函数来找出所有目标子串,目标子串的长度为 10,且在 DNA 字符串 s 中出现次数超过一次。
*
*
*
* 示例 1:
*
*
* 输入:s = "AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT"
* 输出:["AAAAACCCCC","CCCCCAAAAA"]
*
*
* 示例 2:
*
*
* 输入:s = "AAAAAAAAAAAAA"
* 输出:["AAAAAAAAAA"]
*
*
*
*
* 提示:
*
*
* 0
* s[i] 为 'A'、'C'、'G' 或 'T'
*
*
*/
// @lc code=start
function findRepeatedDnaSequences(s: string): string[] {
// 用于存储字串出现的次数
const map: Map<string, number> = new Map<string, number>();
// 由于字串的长度为10,因此我们循环的范围应该是0~s.length-9
for(let i=0,j=s.length - 9; i<j;i++) {
// 从i的位置开始截取长度为10的字符串
const subStr = s.substr(i, 10);
// 判断字符串是否已经存入哈希表中
const target = map.get(subStr);
if(target!==undefined) {// 如果已经存入过,直接加1
map.set(subStr, target+1);
} else {// 否则设置为1
map.set(subStr, 1);
}
}
// 遍历所有字串并将出现次数大于1的字串放入结果数组
let res: string[] = [];
for(let [key, value] of map) {
if(value>1) res.push(key);
}
return res;
};
// @lc code=end
这道题是比较考验哈希算法思维的一道题。我们可以用这样的方法解答:由于题目要求单词之间不能有重复字母,那么我们首先可以利用哈希映射的思想,将每个单词的字母映射到一个长度为26的二进制数组当中,如果单词中又出现某个字母,就在二进制数组的位置标记为1,如下操作
# 以下为二进制数组,长度为26
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
# 目标单词为ace
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1]
这样,我们要判断两个单词有没有重复的字母,只需要进行按位与运算,只要有一个字母重复了,那么结果肯定不为0,只有当所有的字母都不重复,才能得到0。
找到了不重复的单词了,只需要与上一次的结果取最大值即可。
/*
* @lc app=leetcode.cn id=318 lang=typescript
*
* [318] 最大单词长度乘积
*
* https://leetcode-cn.com/problems/maximum-product-of-word-lengths/description/
*
* algorithms
* Medium (67.32%)
* Likes: 171
* Dislikes: 0
* Total Accepted: 16.3K
* Total Submissions: 24.3K
* Testcase Example: '["abcw","baz","foo","bar","xtfn","abcdef"]'
*
* 给定一个字符串数组 words,找到 length(word[i]) * length(word[j])
* 的最大值,并且这两个单词不含有公共字母。你可以认为每个单词只包含小写字母。如果不存在这样的两个单词,返回 0。
*
* 示例 1:
*
* 输入: ["abcw","baz","foo","bar","xtfn","abcdef"]
* 输出: 16
* 解释: 这两个单词为 "abcw", "xtfn"。
*
* 示例 2:
*
* 输入: ["a","ab","abc","d","cd","bcd","abcd"]
* 输出: 4
* 解释: 这两个单词为 "ab", "cd"。
*
* 示例 3:
*
* 输入: ["a","aa","aaa","aaaa"]
* 输出: 0
* 解释: 不存在这样的两个单词。
*
*/
// @lc code=start
function maxProduct(words: string[]): number {
// 定义一个长度为26位的二进制位标记数组
const mask: number[] = new Array<number>(26);
// 二进制操作,可以不需要默认填充零,这里填充0是为了方便理解,实际可以省略这个步骤
mask.fill(0);
for(let i=0;i<words.length;i++) {
const word = words[i];
for(let char of word) {
// 此处使用了多个编程技巧
// 1. 获取当前字符香蕉与'a'的偏移量,我们需要根据这个偏移量来确定要把标记位放到哪个位置
// 2. 按位左移,我们想要在一个二进制数组上标记对应位置的字母为1,只需要对1进行按位左移偏移量即可
// 3. 按位或:两个中有一个是1就是1,兼顾了单词本身有重复字母的情况,如aaa
mask[i] |= 1 << (char.charCodeAt(0) - 'a'.charCodeAt(0));
}
}
let res = 0;
for(let i=0;i<words.length;i++) {
for(let j=i+1;j<words.length;j++) {
// 只要不为0就说明有重复字母
if(mask[i] & mask[j]) continue;
// 计算最大单词长度乘积
res = Math.max(res, (words[i].length * words[j].length));
}
}
return res;
};
// @lc code=end