1、什么是哈希表(散列表)
要说哈希表,我们必须先了解一种新的存储方式—散列技术。 散列技术是指在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使每一个关键字都对应一个存储位置。即:存储位置=f(关键字)。这样,在查找的过程中,只需要通过这个对应关系f 找到给定值key的映射f(key)。只要集合中存在关键字和key相等的记录,则必在存储位置f(key)处。我们把这种对应关系f 称为散列函数或哈希函数。 按照这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为哈希表。所得的存储地址称为哈希地址或散列地址。
key :就是关键值的意思,在哈希表中的 key 值是不允许重复的,就说明它是唯一的,可以通过唯一的 key 来查找相应的 value 值。
2.哈希表的工作原理
1.当你想要存储一个键值对时,哈希表首先会使用哈希函数对键进行哈希计算,得到一个哈希值。键值对(Key-Value Pair)是一种常用的数据结构,用来存储两个相关联的元素。在这个对应关系中,“键”(Key)是唯一的,用于标识和查找与之关联的"值"(Value)。
举个例子,假设我们有一个学生名单的数据库,我们可能会用学生的I学号作为键,学生的详细信息(如名字、年龄、班级等)作为值。这样,当我们需要查找一个学生的信息时,我们只需要知道他的学号,就可以快速找到他的所有信息。在这个例子中,学生的学号和他的信息就形成了一个键值对。
2.然后,哈希表会使用这个哈希值(经过hash函数处理)作为索引,将键值对(Key-Value Pair)存储在一个特定的桶或槽中(存储实际数据(键值对)的地方),通常叫做Entry。
3.当你想要获取某个键对应的值时,哈希表会再次使用哈希函数对键进行哈希计算,找到对应的桶或槽(存储实际数据(键值对)的地方),然后返回其中的值。
3、哈希函数的构造方法
①、直接定址法:不常用
取关键字或关键字的某个线性函数值为哈希地址:
即:H(key) = key 或 H(key) = a*key+b
优点:简单,均匀,不会产生冲突;缺点:需要实现直到关键字的分布情况,适合查找表比较小且连续的情况。
②、数字分析法
数字分析法用于处理关键字是位数比较多的数字,通过抽取关键字的一部分进行操作,计算哈希存储位置的方法。
例如:关键字是手机号时,众所周知,我们的11位手机号中,前三位是接入号,一般对应不同运营商的子品牌;中间四位是HLR识别号,表示用户号的归属地;最后四位才是真正的用户号,所以我们可以选择后四位成为哈希地址,对其在进行相应操作来减少冲突。 (超市会员)
数字分析法适合处理关键字位数比较大的情况,事先知道关键字的分布且关键字的若干位分布均匀。
③、平方取中法
具体方法很简单:先对关键字取平方,然后选取中间几位为哈希地址;取的位数由表长决定,适用于不知道关键字的分布,而位数又不是很大的情况。
④、折叠法
将关键字分成位数相同的几部分(最后一部分位数 可以不同),然后求这几部分的叠加和(舍去进位),并按照散列表的表长,取后几位作为哈希地址。
适用于关键字位数很多,而且关键字每一位上数字分布大致均匀。
⑤、随机数法
选择一个随机数,取关键字的随机函数值作为他的哈希地址。
即:f(key) = random (key) 当关键字的长度不等时,采用这个方法构造哈希函数较为合适。当遇到特殊字符的关键字时,需要将其转换为某种数字。
⑥、除留余数法
此方法为最常用的构造哈希函数方法。对于哈希表长为m的哈希函数公式为:
f(key) = key mod p (p <= m) (mod 是取模(求余数)的意思)
此方法不仅可以对关键字直接取模,也可以在折叠、平方取中之后再取模。
所以,本方法的关键在于选择合适的p,若是p选择的不好,就可能产生 同义词;根据前人经验,若散列表的表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
现在有这样一组数据集合 {1, 7, 6, 4, 5, 9}。
并且把哈希函数设置为:hash(key) = key % capacity(其中 capacity 为存储元素底层空间总的大小)。
然后我们把该集合存储在 capacity 为 10 的哈希表中,则各元素存储位置对应如下:
在这里插入图片描述
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快,但是也会存在一些问题。
向哈希表中插入一个关键码值:通过哈希函数解析关键字,并将对应值存放到该区块中。
比如:15通过哈希函数 Hash(key) = 15% 10 = 5,得出应将 15分配到5 所在的区块中。
在哈希表中搜索一个关键码值:通过哈希函数解析关键字,并在特定的区块搜索该关键字对应的值。
比如:查找 4,通过哈希函数,得出 4应该在 4 所对应的区块中。再在4这个区块中查找,找到4。
比如:查找 99,通过哈希函数,得出 99应该在 9 所对应的区块中。然后我们从 9 对应的区块中继续搜索,但并没有找到对应值,则说明 99不在哈希表中。
4.哈希冲突
哈希冲突(Hash Collision):不同的关键字通过同一个哈希函数可能得到同一哈希地址,即 key1 ≠ key2,而 Hash(key1) = Hash(key2),这种现象称为哈希冲突。
就比如上述中
key1:15 != key2:25,他们得到的都在5对应的区块中。
理想状态下,我们的哈希函数是完美的一对一映射,即一个关键字(key)对应一个值(value),不需要处理冲突。但是一般情况下,不同的关键字 key 可能对应了同一个值 value,这就发生了哈希冲突。
设计再好的哈希函数也无法完全避免哈希冲突。所以就需要通过一定的方法来解决哈希冲突问题。常用的哈希冲突解决方法主要是两类:「开放地址法(Open Addressing)」 和 「链地址法(Chaining)」。
4.1 开放地址法
开放地址法(Open Addressing):指的是将哈希表中的「空地址」向处理冲突开放。当哈希表未满时,处理冲突时需要尝试另外的单元,直到找到空的单元为止。
当发生冲突时,开放地址法按照下面的方法求得后继哈希地址:H(i) = (Hash(key) + F(i)) % m,i = 1, 2, 3, …, n (n ≤ m - 1)。
H(i) 是在处理冲突中得到的地址序列。即在第 1 次冲突(i = 1)时经过处理得到一个新地址 H(1),如果在 H(1) 处仍然发生冲突(i = 2)时经过处理时得到另一个新地址 H(2) …… 如此下去,直到求得的 H(n) 不再发生冲突。
Hash(key) 是哈希函数,m 是哈希表表长,对哈希表长取余的目的是为了使得到的下一个地址一定落在哈希表中。
F(i) 是冲突解决方法,取法可以有以下几种:
线性探测法: F ( i ) = 1 , 2 , 3 , . . . , m − 1 F(i) = 1, 2, 3, ..., m - 1 F(i)=1,2,3,...,m−1。
二次探测法: F ( i ) = 1 2 , − 1 2 , 2 2 , − 2 2 , . . . , ± n 2 ( n ≤ m / 2 ) F(i) = 1^2, -1^2, 2^2, -2^2, ..., \pm n^2(n \le m / 2) F(i)=12,−12,22,−22,...,±n2(n≤m/2)。
伪随机数序列: F ( i ) = 伪随机数序列 F(i) = 伪随机数序列 F(i)=伪随机数序列。
举个例子说说明一下如何用以上三种冲突解决方法处理冲突,并得到新地址 H(i)。例如,在长度为 11 的哈希表中已经填有关键字分别为 28、49、18 的记录(哈希函数为 Hash(key) = key % 11)。现在将插入关键字为 38 的新纪录。根据哈希函数得到的哈希地址为 5,产生冲突。接下来分别使用这三种冲突解决方法处理冲突。
使用线性探测法:得到下一个地址 H(1) = (5 + 1) % 11 = 6,仍然冲突;继续求出 H(2) = (5 + 2) % 11 = 7,仍然冲突;继续求出 H(3) = (5 + 3) % 11 = 8,8 对应的地址为空,处理冲突过程结束,记录填入哈希表中序号为 8 的位置。
使用二次探测法:得到下一个地址 H(1) = (5 + 11) % 11 = 6,仍然冲突;继续求出 H(2) = (5 - 11) % 11 = 4,4 对应的地址为空,处理冲突过程结束,记录填入哈希表中序号为 4 的位置。
使用伪随机数序列:假设伪随机数为 9,则得到下一个地址 H(1) = (9 + 5) % 11 = 3,3 对应的地址为空,处理冲突过程结束,记录填入哈希表中序号为 3 的位置。
4.2 链地址法
链地址法(Chaining):将具有相同哈希地址的元素(或记录)存储在同一个线性链表中。
链地址法是一种更加常用的哈希冲突解决方法。相比于开放地址法,链地址法更加简单。
我们假设哈希函数产生的哈希地址区间为 [0, m - 1],哈希表的表长为 m。则可以将哈希表定义为一个有 m 个头节点组成的链表指针数组 T。
这样在插入关键字的时候,我们只需要通过哈希函数 Hash(key) 计算出对应的哈希地址 i,然后将其以链表节点的形式插入到以 T[i] 为头节点的单链表中。在链表中插入位置可以在表头或表尾,也可以在中间。
而在在查询关键字的时候,我们只需要通过哈希函数 Hash(key) 计算出对应的哈希地址 i,然后将对应位置上的链表整个扫描一遍,比较链表中每个链节点的键值与查询的键值是否一致。对于哈希地址比较均匀的哈希函数来说,理论上讲,k = n // m,其中 n 为关键字的个数,m 为哈希表的表长。
举个例子来说明如何使用链地址法处理冲突。假设现在要存入的关键字集合 keys = [88, 60, 65, 69, 90, 39, 07, 06, 14, 44, 52, 70, 21, 45, 19, 32]。再假定哈希函数为 Hash(key) = key % 13,哈希表的表长 m = 13,哈希地址范围为 [0, m - 1]。将这些关键字使用链地址法处理冲突,并按顺序加入哈希表中
5.哈希表,数组,链表对比
数组的最大特点:寻址容易,插入和删除困难;
链表的特点正好相反:寻址困难,而插入和删除操作容易。
哈希表的特点:寻址插入和删除操作都容易。
数组寻址时间复杂度:
我们只要知道了数组下标,也就是数据在数组中的位置,比如下标 2,就可以计算得到这个数据在内存中的位置 ,从而对这个位置的数据 进行快速读写访问,时间复杂度为 O(1)。
随机快速读写是数组的一个重要特性,但是要随机访问数据,必须知道数据在数组中的下标。如果我们只是知道数据的值,想要在数组中找到这个值,那么就只能遍历整个数组,时间复杂度为 O(N)。
链表的寻址时间复杂度:
因为链表是不连续存储的,要想在链表中查找一个数据,只能遍历链表,所以链表的查找复杂度总是 O(N)。
哈希表的寻址时间复杂度:
通过key寻找值,时间复杂度为 O(1)。但是如果上述15和25通过哈希函数都得到5,则会出现哈希冲突,得到一个链表,时间复杂度为 O(1)这句话则出现矛盾,但在实际情况中,哈希冲突暂可以忽略,则时间复杂度为 O(1)。
6.代码实现:
MyHashMap类存放哈希表的属性以及各种方法的实现
package HaHa; public class MyHashMap { private Node[] array; // 数组的每个元素都代表一条独立的链表 int size; public MyHashMap() { array = new Node[7]; size = 0; } int capacity = 10; //设置capacity public String get(int key) { int index = key % capacity; // 通过 index 这个下标,可以从数组中得到一条链表 // 链表的头结点就是 array[index]; Node head = array[index]; Node yi = head; //遍历整条链表 while (yi != null) { // 比较 key 和 yi.key 是否相等 if (key == yi.key) { // 查询到就返回该 key 的 value 值 return yi.value; } yi = yi.next; } // 说明链表都遍历完了,也没有找到 key,说明 key 不在哈希表中 // 返回 null,表示没有找到 return null; } public String put(int key, String value) { int index = key % capacity; //得到代表链表的头结点 Node head = array[index]; //遍历链表,查找 key 是否存在(如果存在,则是更新操作;否则是放入操作) Node yi = head; while ( yi != null){ if (key == yi.key) { // 找到了说明存在,进行更新操作 String oldValue = yi.value; yi.value = value; return oldValue; // 返回原来的 value ,代表是更新 } yi = yi.next; } // 遍历完成,没有找到,则进行插入操作 //把 key、value 装到链表的结点中 Node node = new Node(key, value); // 使用头插 node.next = array[index]; array[index] = node; size++; //返回 null,代表插入 return null; } public String remove(int key) { size--; int index = key % capacity; // 如果第一个结点的 key 就是要删除的 key,没有前驱结点 if (array[index] != null && key == array[index].key) { Node head = array[index]; array[index] = array[index].next; return head.value; } Node prev = null; //记录前驱结点 Node yi = array[index]; while (yi != null) { if (key == yi.key) { // 删除链表中的结点,需要前驱结点 prev.next = yi.next; // 删除 yi 结点 return yi.value; } prev = yi; yi = yi.next; } return null; } public int size() { return size; } }
Node 类,代表的是链表的结点;
package HaHa; public class Node { public int key; public String value; public Node next; public Node(int key, String value) { this.key = key; this.value = value; } }
测试类:
package HaHa; import javax.jws.WebParam; public class Main { public static void main(String[] args) { MyHashMap m1 = new MyHashMap(); m1.put(11,"one"); m1.put(22,"hahahahahhahah"); m1.put(3,"666666666"); System.out.println( m1.get( 11)); System.out.println( m1.get(222)); System.out.println(m1.size()); System.out.println("-------------------------------"); // m1.put(4,"666666666"); m1.remove(22); // System.out.println( m1.get(11)); System.out.println(m1.size()); } }