散列表,实际上也可以叫做Hash Table. 他其实是一种数据结构, 类似字典也是key/value pair. 或者应该说,是字典 base on Hash Table. 因为散列表才是真正的key/value pair. 而本质上,hash table 的结构, 是在key与value之间加上一层映射函数的.

hash table

首先, 我们的值都是存在RAM中的, 并且都有表示的唯一id.(前文介绍过,python中有id()方法,可以找到value的RAM地址). 如上图所示. 里面的buckets 实际上是一个列表的数据类型, 并且,每一个位置都有唯一的一个索引进行标识. 我们的key, 通过hash function 最终找到那个index, 然后从buckets中取回value. 就是这样的一个过程.
所以,散列表的特点是, 删除,插入,取出数据很快(O(1)), 但是如果涉及到值查找,则效率就相当低了.

Hash Table 映射

经过上面的介绍, 了解散列表实际上 最重要的部分是hash function. 他的原理过程是这样的:

# hash function
hash = hashfunc(key)
index = hash % array_size

array_size 就是存储内容的总长度. hashfunc通过key映射出来一个值, 经过取余操作,得到一个index 这就是值的地址. 但是, 有没有可能出现,两个key经过处理,映射出同一个key值?
从概率上来说,是很有可能的. 在Hash Table 中叫做 collision. 所以, 一个完备的Hash Table 必须有个planB 去解决这样的现象.
另外,Hash Table 的长度是预先设定好的(最好是prime number--质数->137), 但长度是可变的。 其中, 初始化一个Hash Table的长度真的是一个数学问题, 关系到构建一个hash function的难度.

Building a Hash Table

一个基本的Hash Table 应该具有 存储内容的 associated array,映射关系的 hash function 两个基本tip.
所以, 我们可以创建一个Hash Table 类

class HashTable{
    constructor(){
        this.table = new Array(137);
        this.hash_function = xxx
    }
}

一个hash映射函数, 有一个比较简单的, 取余法~ 即, 当你的键值为整数的时候, 通过对其进行数组长度的取余:(上面说到过)

index = hash % array_size

得到你的index值。 聪明的童鞋,可能会联想到上面要求键长为质数 就是这个道理. 如果你的size是一个合数, 那么, 当hash值大于size时, 出现碰撞的可能性, 将会上升一个level. 即, 质数是一个唯一性的选择. 上面,你也可以不用137, 你可以替代为自己想要的长度, 但记住,大一点好.
当,我们的键值为Number类型的话, 那么上面的hash_function就可以写为:

class HashTable{
    constructor(){
        this.size = 137;
        this.table = new Array(this.size);
    }
    hash_function(key){
        return key%this.size;
    }
    add(key,value){
        let index = this.hash_function(key);
        this.table[index] = value;
    }
}

如果键值是使用Number类型的话, 估计这种数据结构基本上就蹲墙角了. 一般情况下, key 是String类型的. 所以, 这就给hash_function 增加难度了. 将String转换为数字的办法在每种编程语言里面都有, 这里以js为例, 在js中,提供了charCodeAt()来找出, 对应字符在Unicode(0xnnnnnn)里面的序号. 英文字符还好,都是在ASCII编码占据前排的位置, 但是Chinese不一样, 是国人后面硬加进去的. 如果你用一些生僻字作为键名的话, 我送你两个字 呵呵 .因为, 如果这些生僻字在字符表中的位置大于65536,charCodeAt()是找不出来的, 你只能使用,还未普遍支持的codePointAt.
两个方法的基本格式为:

  • str.charCodeAt(index): index 就是字母出现的顺序. 返回Number. 兼容性很好

  • str.codePointAt(index): 同上. 只是兼容性差一点, 但对字符的检测更全.

这里我们先使用charCodeAt来实现String 转 Number的效果.

 hash_function(key){
        let num=0;
        for(var i=0;i<key.length;i++){
            num+=key.charCodeAt(i)
        }
        return num%this.size;
    }

但是这种方法,很容易产生碰撞. 我们可以试验一下,存入多个key.

let a_table = new HashTable();
let keys = ["David", "Jennifer", "Donnie", "Raymond",
                      "Cynthia", "Mike", "Clayton", "Danny", "Jonathan"];
for(var key of keys){
    a_table.add(key,key)
}
for(var key of keys){
    console.log(`key is ${key} value is ${a_table.get(key)}`);
}

其中的key我们故意放入了两个Number后和值是一样的--Raymond,Clayton
所以, 这注定会发生collision, 结果大家尝试一样就可以了. 如果仅仅根据key产生的Number的话, 可能还是会有点不保险, 所以, 一些算法书籍提出可以使用 秦久韶(又命: Horne) 算法. 即,将每次得到的charcode 乘以一个质数(比较小就可以了)

 hash_function(key){
        let prime_number = 31;
        let num=0;
        for(var i=0;i<key.length;i++){
            num = num*prime_number + key.charCodeAt(i)
        }
        return num%this.size;
    }

那how to solve it?

Collision resolution

碰撞解决的办法有很多. 实际上,他的原理就是如果当两个键值映射到同一个位置时进行区分。要知道,想要解决一个问题,总会带来另外一个问题, 如果你想解决重复,那就需要更多的空间来区分重复。通常,可以使用开链法,或者线性探测. 这是目前使用最广的两个方法.

Separate chaining

开链法的特点就是在每个array的slot内, 在放一个array, 或者在放一个linked list 这都是可以的。 关键在于,他是一个二维结构. 所以, 查询的复杂度会增加. 我们使用代码来模拟一下:
首先,需要生成一个二维数组
(自己做吧)
然后,主要就是模拟add和get方法.

add(key,value){
        let index = this.hash_function(key),
            seq = 0;
        if(this.table[index]===undefined){
            this.table[index][seq] = key;
            this.table[index][seq+1] = value;
        }else{
            seq+=2;
            while (this.table[index][seq]!==undefined) {
                seq+=2;
            }
            this.table[index][seq] = key;
            this.table[index][seq+1]=value;
        }
    }
    get(key){
        let index = this.hash_function(key),
            seq = 0;
        if(this.table[index][seq]===key){
            return this.table[index][seq+1];
        }else{
            seq+=2;
            while (this.table[index][seq]!==key) {
                seq+=2;
            }
            return this.table[index][seq+1];
        }
    }

这样,就可以确保, 即使两个内容重复,但是,依然可以存储. 另外, 如果你嫌一开始就创建两个二维数组麻烦,当然,你也可以等到重复的时候再创建,这些都是没问题的.
另外一种方法是, 线性探测.

Linear Probing

线性探测实际上也是一个二维. 他拥有两个表,一个是key table, 一个是value table. 两个table的值是同步的. 即key的index对应着value的index.
线性探测原理是, 当如果两个key指向同一个slot时, 则将第二个key向后索引一位(index+1), 然后空的slot,然后插入. 因为, Hash Table 值的分布就是一个随机分布, 很难保证每个值附近都存在已有的值. 利用这里点,这个算法也就诞生了.
该方法和开链法比较起来,他的主要优势是,使用于存储量大的Hash Table. 引用一段

这里有一个公式,常常 可以帮助我们选择使用哪种碰撞解决办法:如果数组的大小是待存储数据个数的 1.5 倍, 那么使用开链法;如果数组的大小是待存储数据的两倍及两倍以上时,那么使用线性探 测法。

接下来,我们用代码来模拟一下.

class HashTable{
    constructor(){
        this.size = 137;
        this.keys = new Array(this.size);
        this.values = new Array(this.size);
    }
    hash_function(key){
        let num=0;
        for(var i=0;i<key.length;i++){
            num+=key.charCodeAt(i)
        }
        return num%this.size;
    }
    add(key,value){
        let index = this.hash_function(key);
        if(this.keys[index]===undefined){
            this.keys[index] = key;
            this.values[index] = value;
        }else{
        // 出现重复情况
            index++;
            while (this.keys[index]!==undefined) {
                index++;
            }
            this.keys[index] = key;
            this.values[index] = value;
        }
    }
    get(key){
        let index = this.hash_function(key);
        if(this.keys[index]===key){
            return this.values[index];
        }else{
        // 遇到重复的index
            index++;
            while (this.keys[index]!==key) {
                index++;
            }
            return this.values[index];
        }
    }
}

不论是开链法还是线性查找法,都是collision resolution 中的一小部分. 当然, 还有其他适用于不同场景的碰撞检测办法. 这些,只有真正当有需求的时候, 才会去了解一下. 但, 这两种方法,实际上已经够用一阵子了。

总结一下:
Hash Table


villainhr
7.8k 声望2.2k 粉丝

下一篇 »
Iterator

引用和评论

0 条评论