关于散列表的一些思考

散列表(也叫Hash表)是一种应用较为普遍的数据结构,几乎全部的高级编程语言都内置了散列表这种数据结构。然而散列表在不一样的编程语言中称呼不同,在JavaScript中被称为对象,在Ruby中被称为哈希,而在Python中被称为字典。即使称呼不一样,语法不一样,它们的原理基本相通。程序员

原理解析

在现代的编程语言中,几乎都会有散列表的身影,故而难以忽视它为程序员所带来的种种便利性。散列跟数组是很类似的,较大的区别在于,数组直接经过特定的索引来访问特定位置的数据,而散列表则是先经过散列函数来获取对应的索引,而后再定位到对应的存储位置。这是比较底层的知识了,通常的散列表,在底层都是经过数组来进行存储,利用数组做为底层存储的数据结构最大的好处在于它的随机访问特性,无论数组有多长,访问该数组的时间复杂度都是O(1)算法

固然要设计一个能用的散列表,在底层仅仅用普通的数组是不够的,毕竟咱们须要存储的不只仅是数值类型,还可能会存储字符串为键,字符串为值,或者以字符串为键,某个函数的指针为值 (JavaScript就不少这种状况)的键值对。在这类状况中,咱们须要对底层的结点进行精心设计,才可以让散列表存储更多元化的数据。编程

不管以何种数据类型为键,咱们始终都须要有把键转换成底层数组任意位置索引的能力,经过散列函数能够作到这一点。散列函数是个很考究的东西,设计得很差可能会致使频繁出现多个不一样的键映射到同一个索引值的现象,这种现象称之为冲突,文章的后半部分会看到经常使用的一些解决冲突的方式。除此以外,每次为散列表所分配的空间是有限的,随着元素的插入,散列表会愈来愈满,这时,冲突的概率就越高。故而,咱们须要按期对散列表进行扩张,并把已有的键值对从新映射到新的空间中去,让散列表中的键值对更加分散,下降冲突的概率。这个过程被称为Resize。这个过程可以在必定程度上下降散列表的冲突概率,提升查找效率。数组

接下来会从散列函数,冲突解决,散列查找以及散列表Resize这几个方面来详细谈谈散列表的基本概念。bash

为什么不用链表来存储键值对?

散列表本质上就是用来存储键值对的,其实彻底能够用一个链表来实现键值对存储的功能,这样就不会产生冲突了。举个简单的例子数据结构

typedef struct Node {
  char * key;
  char * value;
  struct Node * next;
} Node;
复制代码

以上的结点可以存储以字符串为键,字符串为值的键值对。假设彻底用链表来实现键值对存储机制,那么每次插入元素,咱们都得遍历整个链表,对比每一个结点中的键。若是键已经存在的话则替换掉原来的值,若是遍历到最后都不能找到相关的结点则建立新的结点并插入到散列的末端。查找的方式其实也相似,一样是遍历整个链表,查找到对应键值对则返回相关的值,当遍历到散列最后都不能找到相关值的话则提示找不到对应结点。删除操做跟插入操做相似,都须要遍历链表,寻找待删除的键值对。dom

这种实现方式虽然操做起来较为简便,也不会有冲突产生,不过最大的问题在于效率。不管是插入,查找仍是删除都须要遍历整个链表,最坏状况下时间复杂度都是O(n)。假设咱们有一个至关庞大的键值对集合,这样的时间成本是难以让人接受的。为此在存储键值对的时候都会采用数组来做为底层的数据结构,得益于它随机访问的特征,不管最终的键值对集合多有庞大,访问任意位置的时间复杂度始终为O(1)。即使这种方式会有产生冲突的可能,但只要散列函数设计得当,Resize的时机合适,散列表访问的时间复杂度都会保持在O(1)左右。编程语言

散列函数

散列函数在维基百科上的解释是函数

散列函数(英语:Hash function)又称散列算法、哈希函数,是一种从任何一种数据中建立小的数字“指纹”的方法。优化

在散列表中所谓的“指纹”其实就是咱们所分配的数组空间的索引值,不一样类型的键须要设计不一样的散列函数,它将会获得一个数值不是太大的索引值。假设咱们用C语言来设计一个以字符串为键,字符串为值的散列表,那么这个散列表的结点能够被设计成

typedef struct Hash {
  char * key; // 指向散列的键
  char * value; // 指向散列的值
  char isNull; // 标识这个散列是否为空
} Hash;
复制代码

以这个结点为基础能够简单地建立一个长度为7的散列表

Hash hash[7]
复制代码

接下来就要设计对应的散列函数了,这个函数的目的是把字符串转换成整型数值。字符串和整型最直接的关联无非就是字符串的长度了,那么咱们能够设计一个最简单的字符串散列函数

#include <string.h>

unsigned long simpleHashString(char * str) {
  return strlen(str);
}
复制代码

不过这个散列函数有两个比较大的问题

  1. 对一样长度的字符串,它的散列值都是相同的。放在数组的角度上来看就是,它们所对应的数组索引值是同样的,这种状况会引起冲突。
  2. 以字符串为键,当它的长度大于7的时候,它所对应的索引值会形成数组越界。

针对第一种状况,能够简单描述为,这个散列函数还不够“散”。能够进一步优化,把字符串中的每一个字节所对应的编码值进行累加如何?

#include <string.h>

unsigned long sum(char * str) {
  unsigned long sum = 0;
  for (unsigned long i = 0; i < strlen(str); i++) {
    sum += str[i];
  }
  return sum;
}

unsigned long simpleHashString(char * str) {
  return strlen(str) + sum(str);
}
复制代码

这样彷佛就能够获得一个更“散”的地址了。接下来还须要考虑第二种状况,索引值太大所形成的越界的问题。要解决越界的问题,最粗暴的方式就是对数组进行扩展。不过依照我前面写的这个不太合格的散列函数,散列函数的值几乎是随着字符串的增加而线性增加。举个例子

int main() {
  printf("%lu", simpleHashString("Hello World"));
}
复制代码

打印结果是1063,这是一个至关大的值了。若是要按这个数值来分配内存的话那么所须要的内存空间是sizeof(Hash) * 1063 => 25512个字节,大概是25KB,这显然不太符合情理。故而须要换个思路去解决问题。为了让散列函数的值坐落在某个范围内,采用模运算就能够很容易作到这一点

unsigned long simpleHashStringWithMod(char * str) {
  return (strlen(str) + sum(str)) % 7;
}
复制代码

这里除数是7,故而散列函数simpleHashStringWithMod所生成索引值的取值范围是0 <= hvalue < 7,刚好落在长度为7的数组索引范围内。若是要借助这个散列函数来存储键值对{'Ruby' => 'Matz', 'Java' => 'James'},那么示意图为

Hash Table

这里只是作个简单的示范,长度为7的散列表会显得有点短,很容易就会产生冲突,接下来会谈谈解决冲突的一些方式。

冲突

前面咱们所设计的散列函数十分简单,然而所分配的空间却最多只可以存储7个键值对,这种状况下很快就会产生冲突。所谓冲突就是不一样的键,通过散列函数处理以后获得相同的散列值。也就是说这个时候,它们都指向了数组的同一个位置。咱们须要寻求一些手段来处理这种冲突,现在用途比较普遍的就有开放地址法以及链地址法,且容我一一道来。

1. 开放地址法

开放地址法实现起来还算比较简单,只不过是当冲突产生的时候经过某种探测手段来在原有的数组上寻找下一个存放键值对位置。若是下个位置也存有东西了则再用相同的探测算法去寻找下下个位置,直到可以找到合适的存储位置为止。目前经常使用的探测方法有

  1. 线性探测法
  2. 平方探测法
  3. 伪随机探测法

不管哪一种探测方法,其实都须要可以保证对于同一个地址输入,第n次探测到的位置老是相同的。

线性探测法很容易理解,简单来说就是下一个索引位置,计算公式为hashNext = (hash(key) + i) mod size。举个直观点的例子,目前散列表中索引为5的位置已经有数据了。当下一个键值对也想在这个位置存放数据的时候,冲突产生了。咱们能够经过线性探测算法来计算下一个存储的位置,也就是(5 + 1) % 7 = 6。若是这个地方也已经有数据了,则再次运用公式(5 + 2) % 7 = 0,若是还有冲突,则继续(5 + 3) % 7 = 1以此类推,直到找到对应的存储位置为止。很明显的一个问题就是当数组越满的时候,冲突的概率越高,越难找到合适的位置。用C语言来实现线性探测函数linearProbing结果以下

int linearProbing(Hash * hash, int address, int size) {
  int orgAddress = address;

  for (int i = 1; !hash[address].isNull; i++) {
    address = (orgAddress + i) % size; // 线性探测
  }
  return address;
}
复制代码

只要散列表还没全满,它总会找到合适的位置的。平方探测法与线性探测法实际上是相似的,区别在于它每次所探测的位置再也不是原有的位置加上i,而是i的平方。平方探测函数quadraticProbing大概以下

int quadraticProbing(Hash * hash, int address, int size) {
  for (int i = 1; !hash[address].isNull; i++) {
    address = (address + i * i) % size; // 平方探测
  }
  return address;
}
复制代码

上面两个算法最大的特色在于,对于相同的地址输入,总会按照一个固定的路线去寻找合适的位置,这样之后要再从散列表中查找对应的键值对就有迹可循了。其实伪随机数也有这种特性,只要随机的种子数据是相同的,那么每次获得的随机序列都是必定的。能够利用下面的程序观察伪随机数的行为

#include <stdio.h>
#include <stdlib.h>

int main() {
    int seed = 100;
    srand(seed);
    int value = 0;
    int i=0;
    for (i=0; i< 5; i++)
    {
        value =rand();
        printf("value is %d\n", value);
    }
}
复制代码

伪随机种子是seed = 100,这个程序不管运行多少次打印的结果老是一致的,在个人计算机上会打印如下数值

value is 1680700
value is 330237489
value is 1203733775
value is 1857601685
value is 594259709
复制代码

利用这个特性,咱们就可以以伪随机的机制来实现伪随机探测函数randomProbing

int randomProbing(Hash *hash, int address, int size) {
  srand(address);
  while (!hash[address].isNull) {
    address = rand() % size;
  }
  return address;
}
复制代码

不管采用哪一种方式,只要有相同的address输入,都会获得相同的查找路线。整体而言,用开放地址法来解决地址冲突问题,在不考虑哈希表Resize的状况下,实现起来仍是比较简单的。不过不难想到,它较大问题在于当散列表满到必定程度的时候,冲突的概率会比较大,这种状况下为了找到合适的位置必需要进行屡次计算。另外还有个问题,就是删除键值对的时候,咱们不能把键值对的数据简单地“删除”掉,并把当前位置设置成空。由于若是直接删除并设置为空的话会出现查找链中断的状况,任何依赖于当前位置所作的搜索都会做废,能够考虑另外维护一个状态来标识当前位置是“空闲”的,代表它曾经有过数据,如今也接受新数据的插入。

PS: 在这个例子中,咱们能够只利用isNull字段来标识不一样状态。用数值0来标识当前结点已经有数据了,用1来标识当前结点是空的,采用2来标识当前结点曾经有过数据,目前处于空闲状态,而且接受新数据的插入。这样就不会出现查找链中断的状况了。不过须要对上面的探测函数稍微作一些调整,这里不展开说。

2. 链地址法

链地址法跟开放地址法的线性探测十分类似,最大的不一样在于线性探测法中的下一个节点是在当前的数组上去寻找,而链地址法则是经过链表的方式去追加结点。实际上所分配数组的每个位置均可以称之为桶,总的来讲,开放地址法产生冲突的时候,会去寻找一个新的桶来存放键值对,而链地址法则是依然使用当前的桶,可是会追加新结点增长桶的深度。示意图大概以下

Link Table

可见它的结点结构是

typedef struct Hash {
  char * key; // 指向散列的键
  char * value; // 指向散列的值
  char isNull; // 标识这个散列是否为空
  struct Hash * next; // 指向下一个结点
} Hash;
复制代码

除了原来的数据字段以外,还须要维护一个指向下一个冲突结点的指针,实际上就是最开始谈到的链表的方式。这种处理方式有个好处就是,产生冲突的时候,再也不须要为了寻找合适的位置而进行大量的探测,只要经过散列函数找到对应桶的位置,而后遍历桶中的链表便可。此外,利用这种方式删除节点也是比较容易的。即使是采用了链地址法,到了必定时候仍是要对散列表进行Resize的,否则等桶太深的时候,依旧不利于查找。

3. 汇总

整体而言,采用开放地址法所须要的内存空间比较少,实现起来也相对简单一些,当冲突产生的时候它是经过探测函数来查找下一个存放的位置。可是删除结点的时候须要另外维护一个状态,才不至于查找链的中断。链地址法则是经过链表来存储冲突数据,这为数据操做带来很多便利性。然而,不管采用哪一种方式,都须要在恰当的时候进行Resize,才可以让时间复杂度保持在O(1)左右。

查找

了解如何插入,那么查找也就不成问题了。对开放地址法而言,插入算法大概以下

  1. 经过散列函数计算出键所对应的散列值。
  2. 根据散列值从数组中找到相对应的索引位置。
  3. 若是这个位置是“空闲”的,则插入数据。若是该键值对已经存在了,则替换掉原来的数据。
  4. 若是这个位置已经有别的数据了,代表冲突已经产生。
  5. 经过特定的探测法,计算下一个能够存放的位置。
  6. 返回第三步。

而查找算法是相似的

  1. 经过散列函数计算出键所对应的散列值。
  2. 根据散列值从数组中找到相对应的索引位置。
  3. 若是这个位置为空的话则直接返回说找不到数据。
  4. 若是这个位置可以匹配当前查找的键,则返回须要查找的数据。
  5. 若是这个位置已经有别的数据,或者状态显示曾经有过别的数据,代表有冲突产生。
  6. 经过特定的探测法,计算下一个位置。
  7. 返回第三步。

链地址法其实也相似,区别在于插入键值对的时候若是识别到冲突,链地址法并不会经过必定的探测法来查找下一个存放数据的位置,而是顺着链表往下搜索,增添新的结点,或者更新已有的结点。查找的时候则是沿着链表往下查找,找到目标数据则直接把结果返回。假设穷尽链表都没法找到对应的数据,代表数据不存在。

重组(Resize)

Resize是专业术语,直接翻译过来是从新设置大小,不过有些书籍会把它翻译成重组,我以为这个翻译更为贴切。当原有的散列表快满的时候,其实咱们不只须要对原有的空间进行扩张,还须要用新的散列函数来从新映射键值对,让键值对能够更加分散地存储。

不过不一样的实现方式进行重组的时机不太同样,对于开放地址法而言,每一个桶都只能存放一个键值对,须要经过特定的探测法把冲突数据存放到相关的位置中。当散列表越满的时候冲突的概率就越大,要找到能够存放数据的地方将会越艰难,这将不利于后续的插入和查找。为此须要找到一个恰当的时机对散列表进行Resize。业界经过载荷因子来评估散列表的负载,载荷因子越大,冲突的可能性就越高。

载荷因子 = 填入表中的元素个数 / 散列表的长度

采用开放地址法来实现的散列表,当载荷因子超过某个阀值的时候就应当对散列表进行Resize了,不过阀值的大小则由设计者自行把控了。据维基百科上的描述,Java的系统库把载荷因子的阀值设置为0.75左右,超过这个值则Resize散列表。

而采用链地址法实现散列的时候,利用链表的特性,每一个桶都可以存放多个结点,所以在链地址法中经过桶的深度来评估一个散列是否须要Resize更有意义。你能够当桶的最大深度超过某个值的时候对原有的散列进行Resize。

不管采用哪一种实现方式,Resize的时机还须要设计者自行把控,不一样的应用场景Resize的时机也可能会有所不一样。Resize操做可让咱们原有的键值对数据更加分散,让散列表插入和查找的时间复杂度保持在O(1)左右。然而Resize毕竟是耗时操做,时间复杂度随着键值对数据的增加而增加,所以不宜操做得过于频繁。

总结

这篇文章主要从原理层面阐述了一个散列表的实现方式,总结了实现一个散列表须要注意的一些事项。须要设计一个较好的散列函数,让键值对更加分散以减小冲突的可能。然而不少时候冲突难以免,咱们须要一些手段来解决冲突,还要在恰当的时候进行Resize。通常而言,散列表的底层是采用了可以随机访问的数据结构,只要散列表中的键值对足够分散,就可以把时间复杂度控制在O(1)左右。

相关文章
相关标签/搜索