数据结构之----散列表

214 阅读16分钟

前面所学的链表,二叉搜索树,AVL树中,元素在存储结构中的位置与元素的关键码之间没有直接的对应关系,搜索一个元素的时候,必须进行一系列的关键码的比较,其效率取决于比较的次数。

散列表是表示集合,字典的另一种有效方法,它将关键码映射到某个位置上来存储元素,取值的时候,根据关键码找到对应的位置来取值。

下面的代码演示了hashtable如何使用

var hash_table = new HashTable();
hash_table.init(3);

hash_table.set("name", "javascript");
hash_table.set("age", 20);
hash_table.set("class", 1);

console.log(hash_table.get('name'));
console.log(hash_table.get('age'));
console.log(hash_table.get('class'));

程序输出结果为

javascript
20
1

1. 散列方法

散列表(HashTable)使用数组实现,如果关键码key和数组索引之间有一个映射关系,那么就可以通过key找到数组中的索引,得到索引后,无论是赋值还是取值,都变得非常方便,利用索引操作数组元素的时间复杂度是O(1)。

但是这样做面临两个问题:

  1. 数组中的索引都是整数,而你使用的关键码可能是一个字符串
  2. 数组中的索引都是连续的,而你使用关键码即便都是整数,也可能超出了数组索引范围,比如数组大小为11,而你的关键码是20,那么这个关键码对应数组的哪个索引呢?

1.1 hash函数

hash,一般翻译成散列,音译是哈希,它把任意长度的输入,通过散列算法变换成固定长度的输出,这个输出结果就是散列值。

Address = Hash(key)
它可以用于文件校验,数字签名,对于我们学习数据结构来说,你只需要知道,给hash函数一个字符串,它返回给你一个整数,这样,就解决了关键码不是整数的问题,不同的key,可能会得到相同的address,这种冲突的是不可避免的,好的hash算法,散列均匀,计算速度快。

1.2 除留余数法

1.1 中解决了关键码不是整数的问题,关键码由非整数,变成了整数。但是还面临着关键码比数组索引大的问题,这时,可以使用除留余数法,假设数组大小为m,则找一个最接近m或者等于m的质数p作为除数,那么求数组索引的方法便是
hash(key) = key % p

为什么除数要取质数呢?如果key是10进制数,假设p=10,那么key%p的结果完全取决于key的个位数是多少,这样得到的结果分布太集中,如果是质数,能最大程度上避免冲突。

2.处理冲突

冲突是不可避免的,首先,hash函数就不可避免的产生冲突,简单来说,你给hash函数两个不同的字符串,它却返回了相同的结果,当然这个概率非常非常的低,真正的冲突不是hash函数造成的,而是除留余数法导致的。假设用11做除数,那么当key=27时,计算出来的索引是5,当key=38的时候,计算出来的索引还是5。这样一来,两个不同的key映射到了相同的数组索引上,而数组的一个元素只能存储一个值,这就叫冲突。

处理冲突最有效的办法是采用开散列方法,数组的一个索引上只能存储一个值,如果,这个值是一个链表呢,链表是可以存储多个值的。这样,就解决了冲突的问题,通过关键码找到数组索引,得到数组里存放的链表,然后通过这个链表来操作数据,需要注意的是,关键码也要做为数据存放到链表的节点上,这样才能实现查找和赋值,使用开散列发得到的链表结构。

function LinkList(){
    // 定义节点
    var Node = function(key, value){
        this.key = key;
        this.value = value;
        this.next = null;
    };

    var length = 0;        // 长度
    var head = null;       // 头节点
    var tail = null;       // 尾节点

    // 添加一个新元素
    this.append = function(key, value){
        if(this.search(key) != null){
            return false;
        }
        // 创建新节点
        var node = new Node(key, value);
        // 如果是空链表
        if(head==null){
            head = node;
            tail = head;
        }else{
            tail.next = node;       // 尾节点指向新创建的节点
            tail = node;            // tail指向链表的最后一个节点
        }
        length += 1;                // 长度加1
        return true;
    };

    // 返回链表大小
    this.length = function(){
        return length;
    };

    // 获得指定位置的节点
    var get_node = function(index){
        if(index < 0 || index >= length){
            return null;
        }
        var curr_node = head;
        var node_index = index;
        while(node_index-- > 0){
            curr_node = curr_node.next;
        }
        return curr_node;
    };

    // 删除指定位置的节点
    this.remove = function(index){
        // 参数不合法
        if(index < 0 || index >= length){
            return null;
        }else{
            var del_node = null;
            // 删除的是头节点
            if(index == 0){
                // head指向下一个节点
                del_node = head;
                head = head.next;
                // 如果head == null,说明之前链表只有一个节点
                if(!head){
                    tail = null;
                }
            }else{
                // 找到索引为index-1的节点
                var pre_node = get_node(index-1);
                del_node = pre_node.next;
                pre_node.next = pre_node.next.next;
                // 如果删除的是尾节点
                if(del_node.next==null){
                    tail = pre_node;
                }
            }

            length -= 1;
            del_node.next = null;
            return del_node;
        }
    };

    // 返回指定位置节点的值
    this.get = function(index){
        var node = get_node(index);
        if(node){
            return node;
        }
        return null;
    };

    this.search = function(key){
        var index = -1;
        var curr_node = head;
        while(curr_node){
            index += 1;
            if(curr_node.key == key){
                return curr_node;
            }else{
                curr_node = curr_node.next;
            }
        }
        return null;
    };

    this.remove_key = function(key){
        var index = this.indexOf(key);
        if(index >=0){
            this.remove(index);
            return true;
        }
        return false;
    };

    this.indexOf = function(key){
        var index = -1;
        var curr_node = head;
        while(curr_node){
            index += 1
            if(curr_node.key == key){
                return index;
            }else{
                curr_node = curr_node.next;
            }
        }
        return -1;
    };

    // isEmpty
    this.isEmpty = function(){
        return length == 0;
    };

    // 返回链表大小
    this.length = function(){
        return length;
    };
    // 返回链表的头节点
    this.get_head = function(){
        return head;
    };
};

3.2 hash函数

hash函数,仍然适用在讲布隆过滤器时所使用的murmurhash3_32_gc

3.3 HashTable类

3.3.1 类定义
function HashTable(){
    var items = [];          // 存储数据
    var divisor = 7;         // 除数
    var key_count = 0;       // key的数量
}
3.3.2 init初始化函数

初始化函数要传入参数size,定义数组的大小,同时算出除数是多少

    // 判断一个数是否为质数
    var is_Prime = function(number){
        for(var i =2;i<number;i++){
            if(number %i == 0){
                return false;
            }
            return true;
        }
    };

    this.init = function(size){
        items = new Array(size);
        // 初始化数组
        for(var i=0;i< size;i++){
            items[i] = new LinkList.LinkList();
        }
        // 设置除数
        var temp = size;
        while(temp >2){
            if(is_Prime(temp)){
                divisor = temp;
                break;
            }
            temp--;
        }
    };

现在,数组里存储的是链表,每个链表里节点个数为0,在实际的使用中,也可以省略这个初始化函数,hashtable的大小,不由使用者控制,而是在对象创建的时候按照默认值设置。

3.3.3 get_index

这个函数传入关键码key,经过散列得到在数组中的索引位置

    var get_index = function(key){
        var tmp_key = key.toString();
        var hash_value = Math.abs(murmurhash3_32_gc(tmp_key, 0));
        return hash_value % divisor;
    };
3.3.4 set方法

set函数传入一个key和一个value,这个key和value是成对出现的,知道key就知道value

    this.set = function(key, value){
        var index = get_index(key);
        var node = items[index].search(key);
        if(node){
            node.value = value;
        }else{
            items[index].append(key, value);
            key_count++;
        }
    };
3.3.5 get方法

通过key获得value

this.get = function(key){
        var index = get_index(key);
        var node = items[index].search(key);
        if(node){
            return node.value;
        }
        return null;
    }
3.3.6 del_key 删除一个key
    this.del_key = function(key){
        var index = get_index(key);
        var res = items[index].remove_key(key);
        if(res){
            key_count--;
        }
        return res;
    };
3.3.7 has_key

判断key是否存在

this.haskey = function(key){
        var index = get_index(key);
        var node = items[index].search(key);
        if(node){
            return true;
        }
        return false;
    };

4. 性能优化

使用开散列方法,虽然解决了冲突问题,但是当存储的key逐渐增加时,还是带来了新的问题,比如数组大小是3,但是放入了100key-value对数据,那么平均每个链表需要存储33个节点,如此,每次修改或是查询时,都要进行多次比较,这样,HashTable的性能就会下降。

为了解决性能问题,可以采用两种方法

  • 使用搜索树
  • 扩容

既然链表的搜索性能差,那么我们就使用AVL树来替代链表,这样,即便存储了33个key,大约经过5次比较就可以找到key所在的位置。

使用AVL树固然可以解决性能问题,但终究有点小题大做,扩容是另一种解决性能的方式,相比使用AVL树,实现起来更加简单。

HashTable 内部存储了存入key的数量,如果这个数量达到了除数的5倍(具体多少倍你可以自己定),那么我们就认为这个HashTable太拥挤了,需要扩大一下空间。用一个临时数组保存items的内容,然后将items扩大为原来的2倍,将临时数组里的保存的数据,重新分配到items中。

4.1 is_too_crowd

判断是否拥挤

    var is_too_crowd = function(){
        if(Math.floor(key_count/divisor)>=5){
            return true;
        }
        return false;
    };

这里的5就是链表的平均长度,你可以自己来定

4.2 expand

扩大容量

    this.expand = function(){
        // 临时数组保存原来的数据
        var tmp_arr = new Array(items.length);
        for(var i=0;i<items.length;i++){
            tmp_arr[i] = items[i];
        }

        // 初始化数组
        items = new Array(items.length*2);
        for(var i=0;i< items.length;i++){
            items[i] = new LinkList.LinkList();
        }
        
        // 设置除数
        var temp = items.length;
        while(temp >2){
            if(is_Prime(temp)){
                divisor = temp;
                break;
            }
            temp--;
        }

        // 把临时数组里的数据导入到items中
        for(var i =0;i<tmp_arr.length;i++){
            var link = tmp_arr[i];
            // 获得链表的头
            var curr_node = link.get_head();
            while(curr_node){
                this.set(curr_node.key, curr_node.value);
                key_count--;
                curr_node = curr_node.next;
            }
        }
    };

4.3 修改后的set方法

    this.set = function(key, value){
        var index = get_index(key);
        var node = items[index].search(key);
        if(node){
            node.value = value;
        }else{
            items[index].append(key, value);
            key_count++;
        }
        // 如果过于拥挤了就扩容
        if(is_too_crowd()){
            this.expand();
        }
    };

哈希表是一种很重要的数据结构, object 是基于hashtable , python也是基于hashtable 理论知识较多,实际代码 hashTable 是基于数组而实现的 1.数组进行插入操作时, 效率比较低 数组进行查找操作时, 1. 如果是基于索引进行查找,效率非常高。 2 如果是基于内容去查找,(name = 'why')效率非常低 数组进行删除操作,效率也不高, 对数组进行变换,使其所有的操作的效率都比较高, 哈希表基于数组, 在插入,查找,删除的效率都比较高 哈希表的速度比树还快,基本可以做到瞬间插曲到数据, 哈希表中的缺点, 哈希表中的数据是无序的, 不能按照特定的顺序来遍历(从小到达)其中的元素, 哈希表中的key 不能进行重复 , 不能放置相同的key , 来保存不同的元素

自己设计的一个编码系统,例如a 是1, b是2 c 是3, z是26, 我们可以加上空格,用0 代替, 就是27个字符 

哈希表

它的结构是数组, 它的神奇之处在于对下标的一种转换, 这种变换我们成为哈希函数, 我们通过哈希函数可以获得hasecod

将某一个名字转换为对应的下标,可以快速找到对应的元素,这个就是一个hash函数 

单词/字符串转下标值, 其实就是字母/文字转数字 

编码方式

自己设计的一个编码系统,例如a 是1, b是2 c 是3, z是26, 我们可以加上空格,用0 代替, 就是27个字符 ,数字相加, 一种转换单词的简单方案就是单词的每个字符的编码求和,幂的连乘 ,我们平时可以使用大于10的数字为 7654 = 7 * 10^3 + 6 * 10^ 2 + 5 * 10^2 + 4。cats = 3 * 27 ^ 3 + 1 * 27 ^ ^ + 17 , 这样得到的数组可以基本保证它的唯一性。不会和其他的字符重复 子主题 1。如果一个单词是zzzzzzzzzz, 一般的英文单词不能超过10个字符, 那么得到的数组都超过 700000000000 , 单词可以表示这么大的下标值, 就算能创建这么大的数组, 事实上有很多无效的单词,创建这么大的数组是没有意义的。相加的方案,将数字相加求和产生的下标太少, 第二种方案产生的下标太多。如果是50000个单词, 可能需要定义一个长度为500000的数字 子主题 1。现在,就找一个方法, 把0 找到70000000范围,压缩到0 到100000。为了看到这个方法如何工作,我们先来看一个小点的数字,压缩为一个小点的空间中。

哈希化
将一个大数字转化为数组范围内下标的过程, 我们称之为哈希化。哈希函数, 通常我们将单词转换为大数字,大数字再进行哈希化的代码的过程放到一个函数中,就这个函数就是哈希希函数。哈希表, 我们将数据插入到这个数组,对整个结构进行封装,我们称之为哈希表。我们在一个1000000 的数组中,我们放50000个单词,已经足够。通过哈希化后的下标仍然有重复.

尽管50000个单词, 我们使用100000个位置来储存,我们通过一个相对比较好的哈希函数来完成, 但依然仍然有冲突.因为,它经过哈希化后,我们不希望这种情况发生,我们希望我们下标对应一个数据项,通常我们这是不可能,冲突不可避免的. 如何解决冲突, 通常有两种方法, 链地址法和开放地址法.

链地址法, 又称为拉链法

将数组中每一个位置,原来存储一个元素,现在存储一个数组或者一个链表,存放的第一个元素,为头部元素,后面的元素依次放进去, 冲突比较少,链表的长度不是很长, 所以查寻速度很快

链地址法解决冲突的方法是将每个数据的单元存储的不再是单个数据而是一个链条。这个链条使用什么数据结构,常见的都是数组或者链表, 数组和链表的效率差不多。新的数据插入后端,使用数组还是链表效率都一样, 新插入的数据使用的频率更高,可以使用链表

开放地址法

主要的方法为,寻找空白的单元格来存放重复的数据

开放地址法解决的方案, 新插入的32应该插入得到82的位置,但是该位置已经有了数据,我们发现其他的位置没有数据,我们可以寻找对应的空白位置来存放这个数据,但是我们到底使用哪一个位置来存放了

寻找空白位置有三种方式

 线性探测 2二次探测 3 再哈希

线性探测 线性探测从探测的位置,如果该位置存在,从index+1 , 每次步长加1, 逐步往后合适的位置来放置32, 空的位置就是合适的位置 在我们上面的例子中, index = 3 的位置就是32该放置的位置.查询32位置的元素, 如果相同了, 就取该地址的数据, 那么就使用index +1 , 依次从上面查找。首先经过哈希化得到index=2 比较2的位置的结果和查询的数据是否相同相同就直接返回, 不同就线性查找,从index +1 的位置开始查找和32一样的谁, 这里有一个需要注意的地方, 如果32的位置没有插入, , 就说明没有值, 如果往下查询,查询到空白的位置, 就暂停查找,查找失败.删除32, 删除操作,不可以将这个位置的内容设置为null, 因为将它设置为null, 会影响和我们之后的其他查询操作,通常我们删除一个位置的数据项是,我们将对其进行特殊处理(-1)

.线性探测的问题, 线性探测可以解决冲突问题,我们在没有任何数据的时候, 插入的22, 23, 24, 25, 25, 那么意味着2-3-4-5-6 位置都有元素, 这种一连串填充的单元叫做聚集, 聚集会影响哈希比表的性能, 插入, 查询和删除都会收到影响。

二次探测,线性探测我们之前连续插入的, 那么新插入的数据可能需要探测的很长的距离, 二次探测主要优化探测时的步长, , , 对步长左了优化又比如 项+ 1 *1 x+ 2 *2 x + 3*3 这样可以依次探测比较长的距离,避免那些聚集带来的影响。 二次探测依然会带来问题, 比如我们连续插入32 -82-113 -192的数字, 他们依次累加的步长相同, 这种会造成步长不一样的聚集, 还是会影响效率, 这种可能性比连续的数据可能性小一些。怎样根本解决这个问题, 让每个步长不不一样.二次探测依然会带来问题, 比如我们连续插入32 -82-113 -192的数字, 他们依次累加的步长相同, 这种会造成步长不一样的聚集, 还是会影响效率, 这种可能性比连续的数据可能性小一些。怎样根本解决这个问题, 让每个步长不不一样.

二次探测依然会带来问题, 比如我们连续插入32 -82-113 -192的数字, 他们依次累加的步长相同, 这种会造成步长不一样的聚集, 还是会影响效率, 这种可能性比连续的数据可能性小一些。怎样根本解决这个问题, 让每个步长不不一样

二次探测依然会带来问题, 比如我们连续插入32 -82-113 -192的数字, 他们依次累加的步长相同, 这种会造成步长不一样的聚集, 还是会影响效率, 这种可能性比连续的数据可能性小一些。怎样根本解决这个问题, 让每个步长不不一样 

数组优点,通过下标获取访问,访问效率高 通过元素内容查找,效率低 直接通过元素对应的内容查找,效率低 效率比较高的方式为, 首先进行排序,再进行二分查找,效率会高一点 在数组中插入和删除, 需要进行大量的位移操作, 效率很低,通过元素内容查找,效率低 

链表, 链表的插入和删除的时候,操作效率很高,直接查找效率很低, 需要从头开始遍历,

插入和操作效率很高,如果需要插入和删除中间, 需要先找到对应的位置

哈希表:在插入和删除和查询的时候,效率高,空间的利用率低,空间中有大量的空位置,

哈希表中元素是无序的,不能按照固定的顺序来遍历哈希表中的元素,

不能快速找出哈希表中的最大值和最小值

树结构: 树结构的空间利用率高,树结构可以快速查重出最大值和最小值。可以按照特定顺序输出,查找效率比数组和链表高, 没有哈希表高,可以有效的输出顺序,可以快速求出最大值和最小值, 树结构是非线性结构,可以做到一对多