Kiner算法刷题记(十一):哈希表与布隆过滤器(手撕算法篇)

系列文章导引

  • 系列文章导引

开源项目

本系列所有文章都将会收录到GitHub中统一收藏与管理,欢迎ISSUEStar

GitHub传送门:Kiner算法算题记

前言

了解了哈希表的底层实现原理,知道了哈希函数的设计、哈希冲突的解决方案以及布隆过滤器的应用场景之后,再来一波算法题加深一下对哈希表的理解吧。

705. 设计哈希集合

解题思路

这道题我们其实就可以直接使用拉练法实现哈希表,因为这道题要求删除元素,而链表对于插入和删除元素的效率都是很高的。

代码演示

/*
 * @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


706. 设计哈希映射

解题思路

这道题起始跟上一题很类似,不过从单纯的存一个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


面试题 16.25. LRU 缓存

解题思路

有没有觉得这个概念好像很熟悉,之前我们学习链表的时候就有介绍过一下这个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)
 */

535. TinyURL 的加密与解密

解题思路

这道题就是一个简单的短链服务,支持编码和解码功能。难点其实在于如何随机生成一个短网址。我们要生成的短链id仅包含以下字符:

  • a-z: ASCII:97~122
  • A-ZASCII:65-90
  • 0-9ASCII:48-57

因此,我们需要一个方法生成一个包含上面字符的指定长度的字符串,然后就使用哈希表做一下映射就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


187. 重复的DNA序列

解题思路

这道题也是使用哈希表解决问题,只不过我们再存储时,我们的值是字符串出现的次数。然后,还有一个问题要解决,我们要怎么找到所有的字串呢?我们依然有一个编程小技巧,在循环的时候,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


318. 最大单词长度乘积

解题思路

这道题是比较考验哈希算法思维的一道题。我们可以用这样的方法解答:由于题目要求单词之间不能有重复字母,那么我们首先可以利用哈希映射的思想,将每个单词的字母映射到一个长度为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


你可能感兴趣的:(前端基础,知识梳理,数据结构,算法,数据结构,哈希表,刷题)