数组、集合和散列表
1.1 要用就要提前想好的数据结构——数组
要用就要提前想好?为什么?这其实是由数组的一个特点决定的,那就是对于数组这个数据结构,在用它之前必须提前想好它的长度;有了长度,才能知道该为这个存储结构开辟多少空间;而在决定了长度之后,不管我们最后往里面填充的数据够不够长,没有用到的空间也就都浪费了;如果我们想往这个数组中放入的数据超过了提前设定好的长度,那么是不可行的,因为空间只有这么大。
1.1.1 什么是数组
数组(Array),就是把有限个数据类型一样的元素按顺序放在一起,用一个变量命名,然后通过编号可以按顺序访同指定位置的元素的一个有序集合。
其实简单来说,就是为了方便而把这些元素放在一起。我们通过编号去获取每个元素,这个编号叫作下标或者索引(Index),一般的语言是从0开始的。
我们常说的数组一般指一维数组,当然还有多维数组,虽然多维数组并不常用。
多维的实现其实是数组的某些元素本身也是一个数组,这里以一个标准的二维数组为例进行介绍。其实,二维数组相当于每个元素的长度都一样的一个一维数组(也就是我们常说的数组)。
在很多弱语言中,并不要求每个元素的长度都一样,可以某些元素是数组(长度可以不一样),某些元素不是数组,甚至每个元素的数据类型都不同。这里讲的二维数组指的是标准的二维数组。
注:弱类型语言也叫作弱类型定义语言,简称弱语言。弱语言一般对语言的标准没有特别的要求。比如在 JavaScript 中用 var 声明变量,不会指定该变量是哪种类型,如果想更多地了解弱语言,则请参考JavaScript,该语言主要用于前端开发。强语言对编写规则比较有要求。
1.1.2 数组的存储结构
在了解了什么是数组之后,我们来看下数组的存储结构,
1.数组的存储结构
首先我们来看下一维数组的存储结构,如图1-1所示。
其实,我们先要确定一个值,也就是数组的长度:然后,系统会根据我们声明的数据类型开辟一些空间(当然,每种数据类型需要开辟的空间也不一样)。这时,这些空间就归这个变量所有了。一般在编程语言的实现中,这些空间会默认对我们声明的数据类型赋值,比如整型值是0,布尔值是false,等等。所以有以下几种情况。
(1)只声明了指定长度的空间,没有初始化值(以整型为例,所有值都会默认为0,如图1-2所示)。
(2)声明了指定长度的空间,初始化了部分值(以整型为例,未初始化的值都会默认为0,如图1-3所示)。
(3)声明了指定长度的空间,初始化了全部的值,如图1-4所示。
2. 数组在编程语言中的初始化及操作
在多数语言中,数组的声明都是非常简单的,一般有下面几种声明方式(以Java语言、整型为例,其他语言、数据类型差异不大)。
int[] num1 = new int[]; int[] num2 = {1, 2, 3}; int[] num3 = new int[3]; num3[0] = 1; num3[1] = 2; num3[2] = 3;
数组指定位置的元素的值,是通过下标获取的,下标在大部分语言中是从0开始的。
int[] num = {1, 2, 3}; int a = num[0]; // a的值为0 int b = num[1]; // b的值为1
为数组赋值,和获取元素的值类似,可以直接赋值。
int[] num = {1, 2, 3}; num[1] = 10; // 现在num的数组元素分别为1, 10, 3
数组常用的另一种方式是按顺序访问每一个值,一般通过编程语言中的循环语句实现(比如for循环)。
int[] num = { 1, 2, 3 }; for (int i = 0; i < num.length; i++) { System.out.println(num[i]); }
上面展示了循环打印数组的值的代码,其中 num.length 可以获取数组的长度。细心的同学可以发现这个 length 后面没有括号,是的,这个 length 是数组的内置属性(以Java为例,有些语言会同时提供两种或更多的获取数组长度的方式)。
下面我们看下二维数组的存储结构,如图1-5所示。
二维数组的初始化方式实际上和一维数组没有太大的区别,只不过我们需要提前确定第1维和第2维的长度。
在图1-5中,第1维的长度为3,每维的元素又是一个长度为6的数组。
3. 多维数组在编程语言中的初始化及操作
由于多维数组与一维数组的初始化及访问区别不大,下面集中进行列举。
int[][] num = new int[3][3]; int[][] num2 = { { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } }; int a = num2[1][0];// a的值是4 int b = num2[1].length;// b的值为3,即第二维的长度
二维数组的访问方式其实和一维数组和多维数组的访问方式没什么区别,我们只需要理解为数组的元素也是个数组。
1.1.3 数组的特点
因为本身存储的方式,数组有如下特点。
1. 定长
数组的长度是固定的,这是数组最重要的一个特点。只要我们在声明时确定了数组的长度,在赋值时就算没有给数组的所有元素赋值,未赋值的元素也是有初始默认值的;而如果我们在赋值时发现数组的长度不够用,这时也没有什么好办法,因为数组的长度无法改变。要是想继续存放数据,就只能重新声明一个数组了。
2. 按顺序访问
我们在访问一个数组中的某个元素时,必须从第1个元素开始按顺序访问,直到访问到指定位置的元素。这里想说明一点,虽然我们在开发时可以直接通过下标访问指定位置的元素,但是实际上计算机在处理时也是按顺序访问的。
1.1.4 数组的适用场景
数组其实是一个非常简单的数据结构,用起来也比较简单。但是,数组是所有数据结构的基础,我们必须掌握好数组,才能更好地学习其他数据结构和算法。数组适合在什么时候用,其实根据数组的特点我们就可以想到,由于数组的长度一般是固定的,所以在不会出现变化的业务上比较适合使用数组。
注:本书所举例的适用场景只是可以并且多数会这样做,并不是说这种使用方式是最优的或者可不替代的、实际上有些团队可能会有一些更好的实现方式。
1.技能快捷键
不知道大家对RPG(ARPG)等类似的游戏了解多少,在这类游戏中会有一排快捷键技能格,比如F1~F9这样9个快捷键技能格,我们每个人可以把自己惯用的技能拖动到这些技能格上,这样就可以直接通过技能快捷键操控技能了。一般在这种设计中,一个游戏的快捷键格子会有固定的个数。于是,我们在程序里就可以通过数组来存储每个人的技能快捷键对应的技能(当然,肯定会通过一定的映射存到数据库之类的磁盘上)。
2.优酷8+1
先声明,我没有参与制作优酷8+1,这里只是举例。优酷8+1是什么?打开优酷页,会看到最上面的1个大图、8个小图,这就是优酷8+1了。
这里我们可以声明一个长度为9的数组,里面的每个元素是一个对象(对象是高级语言中的一个名词,包含了一系列的变量和方法),这些对象至少应该包含图片地址(用于展示图片)和URL地址(用于在单击后跳转)。
到这里,我们应该已经很清晰地认识到了数组的劣势,那就是在用之前必须提前确定数组的长度,而后不管我们的技能是否需要增加快捷键位,或者优酷首页从8+1变成了11+1,都会导致对程序进行一定的改动。这时我们就该认识一下数组的升级版——集合了。
1.2 升级版数组——集合
数组的致命缺点就是长度固定,如果我们一开始不确定长度,该怎么办呢?这时就需要集合了,其实集合也是基于数组实现的。在许多高级语言中,集合是对数组的一个拓展,我们在向里面放数据时,想放多少就可以放多少,不用在意集合到底能放多少(当然得内存够用才行)。
1.2.1 什么是集合
集合的概念有些宽泛。本节讲的集合主要是可变长度的列表(也叫作动态数组)。下面这些都是集合。
- 列表:一般是有序的集合,特点就是有顺序,比如链表、队列、栈等。
- 集:一般是无序的集合,特点就是没有顺序并且数据不能重复,多数语言是使用散列表实现的,支持对集进行添加、删除、查找包含等操作。
- 多重集:一般是无序的集合,特点是没有顺序,但是数据可以有重复的值,支持对集进行添加、删除、查找包含、查找一个元素在集中的个数等操作。多重集一般可以通过排序转换为列表。
- 关联数组:其实多数语言是使用散列表实现的,就是可以通过键(Key)获取到值(Value),同样是没有顺序的。
- 树、图:同样是集合,我们会在后面进行详细了解。
1.2.2 集合的实现
本节说的集合在数据结构书中本来是不会有的,但我还是决定介绍一下,这不管是对我们拓展思路还是了解更多的内容,都是有帮助的。
这里以 Java 中的 ArrayList 为例,它是一个数组的列表,其实就是数组的拓展,或者说是可变长度的数组。
上面一直提到,这个 ArrayList 是基于数组实现的,这如何理解呢?其实就是在 ArrayList 里有个属性,这个属性是一个数组。另外,还会有个属性记录我们放了多少数据,这样我们再向其中放数据时,就会知道该向这个内部数组的哪个位置放数据了,但是这个数组也会有长度限制,若超过了这个限制该怎么办呢?当超过这个限制时,其内部会创建一个具有更长的长度的数组,然后把旧数组的数据复制到新数组里面,这样就可以继续往里面放数据了。
在外部,我们感觉不到这个 ArrayList 是有长度限制的,它在自己内部都处理好了。下面我们通过图1-6来形象地理解一下这个流程吧。
注:在面向对象的编程语害中一般把成员变量称为属性。
下面我们先来简单地用代码实现这个变长数组。
public class MyArrayList { private static final int INITIAL_SIZE = 10; private int size = 0; private int[] array; public MyArrayList() { array = new int[INITIAL_SIZE]; } public MyArrayList(int initial) { if (initial <= 10) { initial = INITIAL_SIZE; } array = new int[initial]; } /** * 添加元素 * * @param num */ public void add(int num) { if (size == array.length) { array = Arrays.copyOf(array, size * 2); } array[size++] = num; } /** * 获取指定位置的元素值 * * @param i * @return */ public int get(int i) { if (i >= size) { throw new IndexOutOfBoundsException("获取的元素位置超过了最大长度"); } return array[i]; } /** * 设置指定位置的元素值 * * @param i * @param num * @return */ public int set(int i, int num) { int oldNum = get(i); array[i] = num; return oldNum; } /** * 获取变长数组的长度 * * @return */ public int size() { return size; } }
这里以整型为例简单实现了变长数组。
可以看到,其中有两个属性:一个是array,就是内部数组;另一个是 size,用来存当前变长数组的长度。当调用 add 向变长数组中放值时,要确认内部数组是否足够放这个值,若不够,就生成一个长度是原数组长度的两倍的新数组,并且复制旧数组的数据到新数组里,再放值。
这里新建数组并复制旧数组的数据,是通过 Java 内部的一个工具类实现的,底层调用的是本地方法(native),效率很高。
在调用过程中,我们完全不用在意其内部是怎么实现的,只需往里面添加值、获取值就好了。
很多编程语言的实现,要比这个实现复杂得多,因为除了这些简单的操作,还需要一些更复杂的操作,另外要考虑很多其他问题,比如这个数组的增幅怎样设置比较合理等(上面的代码是每次直接增加为两倍。当然,这部分实现对于每种语言,甚至每种语言的每个版本都不一定一样)。
1.2.3 集合的特点
集合的特点,也和它的实现有关,那就是变长。变长是相对而言的,内部还是通过数组实现的,只是在不够长时根据一定的策略生成一个更长的数组,把旧数组中的数据复制到新数组里使用。
所以在正常情况下会有两个系统开销:、个是数组总是比我们实际使用的长度长,所以存在空间浪费:另一个是当数组不够长时,需要新建一个更长的数组,同时把旧数组的数据复制到新数组中,这个操作会比较消耗系统的性能。
1.2.4 集合的适用场景
集合的适用场景很多。现在基本上所有的批量查询及获得一定条件的数据列表,都使用变长数组。比如查询某游戏中一个玩家包裹里的所有物品,若不清楚物品的数量,则会用变长数组去存储返回的结果。
博客的文章列表、评论列表等,只要涉及列表,就会有集合的身影。
是不是有了变长数组就够了?当然不够,在后面的算法学习中,我们会了解到数组的查询效率是很低的,所以要使用一些更复杂的其他数据结构,来帮助我们完成更高效的算法实现。
1.2.5 数组与变长数组的性能
虽然集合这个变长数组比普通数组高级一些,但它本质上是基于数组实现的,所以与数组的性能差不多。
对数组的操作,并不像我们看到的那么直观,计算机需要根据我们具体操作的位置,从头到尾一个一个地寻找到指定的位置,所以在数组中增加元素、修改元素、获取元素等操作的时间复杂度都为O(n)。
变长数组也有性能损耗的问题,在插入元素时若发现其中的固定数组长度不够,则需要新建更长的数组,还得复制元素,这都会造成性能损耗。
注:在算法中,每种算法的性能指标一般有两个,即时间复杂度和空间复杂度。在设计算法的过程中,时间复杂度和空间复杂度往往是互相影响的,所以一般会在其中根据实际应用场景寻找一个最优的实现。
- 时间复杂度:在计算机科学中,算法的时间复杂度是一个数,它定量描述了该算法的运行时间,这是一个关于代表算法输入值的字符串的长度的函数,常用大O符号描述,不包括这个函数的低阶项和首项系数,它实际上描述了算法执行的时间。
- 空间复杂度:是对一个算法在运行过程中临时占用存储空间大小的量度。
1.3 数组的其他应用——散列表
我们在前面提到集合其实是有很多种的,散列表也算是集合的一种。为什么需要散列表呢?实际上顺序存储的结构类型需要一个一个地按顺序访问元素,当这个总量很大且我们所要访问的元素比较靠后时,性能就会很低。
散列表是一种空间换时间的存储结构,是在算法中提升效率的一种比较常用的方式,但是所需空间太大也会让人头疼,所以通常需要在二者之间权衡。我们会在之后的具体算法章节中得到更多的领悟。
1.3.1 什么是散列表
让我们想一下,若在手机通信录中查找一个人,那我们应该不会从第1个人一直找下去,因为这样实在是太慢了。我们其实是这样做的:首先看这个人的名字的首字母是什么,比如姓张,那么我们一定会滑到最后,因为“Z”姓的名字都在最后。
还有在查字典时,要查找一个单词,肯定不会从头翻到尾,而是首先通过这个单词的首字母,找到对应的那一页;再找第2个字母、第3个字母...这样可以快速跳到那个单词所在的页。
其实这里就用到了散列表的思想。
散列表,又叫哈希表(HashTable),是能够通过给定的关键字的值直接访问到具体对应的值的一个数据结构。也就是说,把关键字映射到一个表中的位置来直接访问记录,以加快访问速度。
通常,我们把这个关键字称为Key,把对应的记录称为Value,所以也可以说是通过Key访问一个映射表来得到Value的地址。而这个映射表,也叫作散列函数或者哈希函数,存放记录的数组叫作散列表。
其中有个特殊情况,就是通过不同的Key,可能访问到同一个地址,这种现象叫作碰撞(Collision)。而通过某个Key一定会得到唯一的Value地址。
目前,这个哈希函数比较常用的实现方法比较多,通常需要考虑几个因索:关键字的长度、哈希表的大小、关键字的分布情况、记录的查找频率,等等。
下面简单介绍几种哈希函数。
1. 直接寻址法
取关键字或关键字的某个线性函数值为散列地址。
2. 数字分析法
通过对数据的分析,发现数据中冲突较少的部分,并构造散列地址。例如同学们的学号,通常同一届学生的学号,其中前面的部分差别不太大,所以用后面的部分来构造散列地址。
3.平方取中法
当无法确定关键字里哪几位的分布相对比较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为散列地址。这是因为:计算平方之后的中间几位和关键字中的每一位都相关,所以不同的关键字会以较高的概率产生不同的散列地址。
4.取随机数法
使用一个随机函数,取关键字的随机值作为散列地址,这种方式通常用于关键字长度不同的场合。
5.除留取余法
取关键字被某个不大于散列表的表长n的数m除后所得的余数p为散列地址。这种方式也可以在用过其他方法后再使用。该函数对m的选择很重要,一般取素数或者直接用n。
1.3.2 对散列表函数产生冲突的解决办法
散列表为什么会产生冲突呢?前面提到过,有时不同的Key通过哈希函数可能会得到相同的地址,这在我们操作时可能会对数据造成覆盖、丢失。之所以产生冲突是由于哈希数有时对不同的Key计算之后获得了相同的地址。冲突的处理方式也有很多,下面介绍几种。
1. 开放地址法(也叫开放寻址法)
实际上就是当需要存储值时,对Key哈希之后,发现这个地址已经有值了,这时该怎么办?不能放在这个地址,不然之前的映射会被覆盖。这时对计算出来的地址进行一个探测再哈希,比如往后移动一个地址,如果没人占用,就用这个地址。如果超过最大长度,则可以对总长度取余。这里移动的地址是产生冲突时的增列序量。
2.再哈希法
在产生冲突之后,使用关键字的其他部分继续计算地址,如果还是有冲突,则继续使用其他部分再计算地址。这种方式的缺点是时间增加了。
3. 链地址法
链地址法其实就是对Key通过哈希之后落在同一个地址上的值,做一个链表。其实在很多高级语言的实现当中,也是使用这种方式处理冲突的,我们会在后面着重学习这种方式。
4. 建立一个公共溢出区
这种方式是建立一个公共溢出区,当地址存在冲突时,把新的地址放在公共溢出区里。
1.3.3 散列表的存储结构
一个好的散列表设计,除了需要选择一个性能较好的哈希函数,否则冲突是无法避免的,所以通常还需要有一个好的冲突处理方式。这里我们选择除留取余法作为哈希函数,选择链地址法作为冲突处理方式。具体存储结构如图下1-7所示。
在大多数时候,我们通过数组定义散列表,如图1-7所示,这时我们声明的数组长度是8,也就是说散列表的长度为8。
这里以 Key 的类型为整型数字为例,用 Key 对长度8取余,必定会得到很大的冲突,这时每个地址并不放真正的值,而是记录一个链表的起始地址。当然,在实际情况下我们不会让这个哈希表这么短,这里只是举个简单的例子。
通过这种方式,我们可以快速地找到 Key 所对应的 Value 在哪个地址上,如果在这个地址上链表比较长,则也需要一个个地去检索。
在通常情况下,新增元素时如果遇到了冲突,那么链表会有两种方式去插入元素。
- 一种方式是直接把新元素的下一个元素指向原来链表的第1个元素,然后把刚刚对应上的那个地址链表头的下一个元素指向新建的元素。这种方式的好处是在插入元素时会比较快,因为不需要遍历链表,而是直接改变头部的指向关系。
- 另一种方式是使链表元素有序,这种方式的劣势就是在每次插入元素时需要遍历链表,在中间打开链表、插入元素。
注:这里涉及了一些链表知识,读者可以简单地将其理解为一条链子,每个元素都会有一个指向下一个元素地址的指针。也可以将其理解为数组,是有序的、挨着的。
有的读者会问,这里是以整数为例的,万一这个 Key 需要是一个字符串类型呢?其实在很多编程语言的实现中,都会存在将一个类型转换为整型的方法,比如 Java 中的每个对象都有个 hashCode 方法,通过获取字符串的这个方法,可以将一个字符串轻松地转换为整型。当然,这种方法还可能返回负数,这也是可以直接使用绝对值解决的。
1.3.4 散列表的特点
散列表有两种用法:一种是 Key 的值与 Value 的值一样,一般我们称这种情况的结构为Set(集合);而如果 Key 和 Value 所对应的内容不一样时,那么我们称这种情况为 Map。也就是人们俗称的键值对集合。
根据散列表的存储结构,我们可以得出散列表的以下特点。
1. 访问速度很快
由于散列表有散列函数,可以将指定的 Key 都映射到一个地址上,所以在访问一个Key(键)对应的Value(值)时,根本不需要一个一个地进行查找,可以直接跳到那个地址。所以我们在对散列表进行添加、删除、修改、查找等任何操作时,速度都很快。
2. 需要额外的空间
首先,散列表实际上是存不满的,如果一个散列表刚好能够存满,那么肯定是个巧合。而且当散列表中元素的使用率越来越高时,性能会下降,所以一般会选择扩容来解决这个问题。
另外,如果有冲突的话,则也是需要额外的空间去存储的,比如链地址法,不但需要额外的空间,甚至需要使用其他数据结构。
这个特点有个很常用的词可以表达,叫作“空间换时间”,在大多数时候,对于算法的实现,为了能够有更好的性能,往往会考虑牺牲些空间,让算法能够更快些。
3.无序
散列表还有一个非常明显的特点,那就是无序。为了能够更快地访问元素,散列表是根据散列函数直接找到存储地址的,这样我们的访问速度就能够更快,但是对于有序访问却没有办法去应对。
4. 可能会产生碰撞
没有完美的散列函数,无论如何总会产生冲突,这时就需要采用冲突解决方案,这也使散列表更加复杂。通常在不同的高级语言的实现中,对于冲突的解决方案不一定一样。
1.3.5 散列表的适用场景
根据散列表的特点可以想到,散列表比较适合无序、需要快速访问的情况。
1. 缓存
通常我们开发程序时,对一些常用的信息会做缓存,用的就是散列表,比如我们要缓存用户的信息,一般用户的信息都会有唯一标识的字段,比如ID。这时做缓存,可以把ID作为 Key,而 value 用来存储用户的详细信息,这里的 Value 通常是一个对象(高级语言中的术语,前面提到过),包含用户的一些关键字段,比如名字、年龄等。
在我们每次需要获取一个用户的信息时,就不用与数据库这类的本地磁盘存储交互了(其实在大多数时候,数据库可能与我们的服务不在一台机器上,还会有相应的网络性能损耗),可以直接从内存中得到结果。这样不仅能够快速获取数据,也能够减轻数据库的压力。
有时我们要查询一些数据,这些数据与其他数据是有关联的,如果我们进行数据库的关联查询,那么效率会非常低,这时可以分为两部分进行查询:将被关联的部分放入散列表中,只需要遍历一遍;对于另一部分数据,则通过程序手动关联,速度会很快,并且由于我们是通过散列表的 Key、value 的对应关系对应数据的,所以性能也会比较好。
我之前所在的一家公司曾要做一个大查询,查询和数据组装的时间达到了40秒,当然,数据量本身也比较大。但是,40秒实在让人无法忍受,于是我优化了这段代码,发现可以通过散列表处理来减少很多重复的查询,最终做到了4秒左右的查询耗时,速度快了很多。
2.快速查找
这里说的查找,不是排序,而是在集合中找出是否存在指定的元素。这样的场景很多,比如我们要在指定的用户列表中查找是否存在指定的用户,这时就可以使用散列表了.在这个场景下使用的散列表其实是在上面提到的Set类型,实际上不需要 Value 这个值。
还有一个场景,我们一般对网站的操作会有个地址黑名单,我们认为某些IP有大量的非法操作,于是封锁了这些IP对我们网站的访问。这个IP是如何存储的呢?就是用的散列表。当一个访问行为发送过来时,我们会获取其IP,判断其是否存在于黑名单中,如果存在,则禁止其访问。这种情况也是使用的Set。
当然,对于上面说的两个例子,用列表也是可以实现的,但是访问速度会受到很大的影响,尤其是列表越来越长时,查找速度会很慢。散列表则不会。
1.3.6 散列表的性能分析
散列表的访问,如果没有碰撞,那么我们完全可以认为对元素的访问是O(1)的时间复杂度,因为对于任何元素的访问,都可以通过散列函数直接得到元素的值所在的地址。
但是实际上不可能没有碰撞,所以我们不得不对碰撞进行一定的处理。
我们常用链表方式进行解决(当然,也有一些语言使用开放寻址方式解决,Java 使用链表解决),由于可能会产生碰撞,而碰撞之后的访问需要遍历链表,所以时间复杂度将变为O(L),其中L为链表的长度。当然,在大多数时候不一定会碰撞,而很多Key也不一定刚好都碰撞到一个地址上,所以性能还是很不错的。
上面提到了一个情况,那就是有可能分配的地址即散列表的元素大部分被使用了,这时再向散列表中添加元索,就很容易产生碰撞了,甚至散列表分配的地址越在后面使用,越容易被占用。这时该怎么办呢?解决办法很简单,就是上面提到的——扩容。
比如之前在存储结构一节举例的,散列表长度只有8,很容易被占满,一般不会等到真的占满了才去扩容,而是会提前扩容。这里涉及一个叫作扩充因子的术语(也叫作载荷因子,意思是达到这个值时,其性能就不好了),是一个小数,在使用散列表的过程中,不会等到把所有地址都用完了才去扩容,而是会在占用地址达到散列表长度乘以扩容因子的这个值时去扩容,一般的扩容会在原有的长度基础上乘以2作为新的长度。
这里可以直接告诉大家,在Java中,扩容因了默认为0.75(很多语言都是0.75,这算是个经验数值吧),以之前的存储结构一节的总长度是8为例,当占用长度达到6时,就会扩容,而扩容后的长度会变为16。
当然,扩容有很多工作要做,除了简单地增加原本的散列表长度,还需要把之前那些由于碰撞而存放在一个地址的链表上的元素重新进行哈希运算,有可能之前存在碰撞的元素,现在不会碰撞了(比如图1-7中值为1和9的数,由于现在总长度为16了,所以它们通过除留取余法,不会指到同一个地址了)。
下面展示如何用代码实现一个简单的散列表,这里用数组代替散列表元素(在真实的高级语言实现中,大多数元素都是一个特别的数组,每个元素对应一个地址),每个数组元素作为一个地址。
首先需要一个元素类,这个类用于存储Key及Value,实际上就是链表上的每一个元素。实现起来非常简单。
public class Test { public static void main(String[] args) { MyHashTable table = new MyHashTable(); table.put(1, 10); table.put(2, 20); table.put(5, 50);// 和key为1的元素落到一个散列表地址上了,实际使用长度为2 System.out.println(table.getLength());// 散列表长为4 table.put(3, 30);// 总长度为4,加上该元素后长度就大于等于3了,所以扩容 System.out.println(table.getLength());// 散列表长为8 // 在扩容后4个元素又分别落到不同的地址上 table.put(6, 60);// 使用了5个地址 table.put(7, 70);// 使用了6个地址,为8的0.75倍,又需要扩容 System.out.println(table.getLength());// 散列表长为16 System.out.println(table.get(1));// 10 System.out.println(table.get(3));// 30 System.out.println(table.get(5));// 50 System.out.println(table.get(6));// 60 } } class Entry { int key; int value; Entry next; public Entry(int key, int value, Entry next) { super(); this.key = key; this.value = value; this.next = next; } } class MyHashTable { /** * 默认散列表的初始化长度 设置小一点,这样我们能够清楚地看到扩容 在实际使用中其实可以在初始化时传参,要知道,扩容也是很损耗性能的 */ private static final int DEFAULT_INITIAL_CAPACITY = 4; /** * 扩容因子 */ private static final float LOAD_FACTOR = 0.75f; /** * 散列表数组 */ private Entry[] table = new Entry[DEFAULT_INITIAL_CAPACITY]; private int size = 0;// 散列表元素的个数 private int use = 0;// 散列表使用地址的个数 /** * 根据key,通过哈希函数获取位于散列表数组中的哪个位置 * * @param key * @return */ private int hash(int key) { return key % table.length; } /** * 扩容 */ private void resize() { int newLength = table.length * 2; Entry[] oldTable = table; table = new Entry[newLength]; use = 0; for (int i = 0; i < oldTable.length; i++) { if (oldTable[i] != null && oldTable[i].next != null) { Entry e = oldTable[i]; while (null != e.next) { Entry next = e.next; // 重新计算哈希值,放入新的地址中 int index = hash(next.key); if (table[index] == null) { use++; table[index] = new Entry(-1, -1, null); } Entry temp = table[index].next; Entry newEntry = new Entry(next.key, next.value, temp); table[index].next = newEntry; e = next; } } } } public void put(int key, int value) { int index = hash(key); if (table[index] == null) { table[index] = new Entry(-1, -1, null); } Entry e = table[index]; if (e.next == null) { // 不存在值,向链表添加,有可能扩容,要用table属性 table[index].next = new Entry(key, value, null); size++; use++; // 不存在值,说明是个未用过的地址,需要判断是否需要扩容 if (use >= table.length * LOAD_FACTOR) { resize(); } } else { // 本身存在值,修改已有的值 for (e = e.next; e != null; e = e.next) { int k = e.key; if (k == key) { e.value = value; return; } } // 存在不同的值,直接向链表中添加元素 Entry temp = table[index].next; Entry newEntry = new Entry(key, value, temp); table[index].next = newEntry; size++; } } /** * 删除 * * @param key */ public void remove(int key) { int index = hash(key); Entry e = table[index]; Entry pre = table[index]; if (e != null && e.next != null) { for (e = e.next; e != null; pre = e, e = e.next) { int k = e.key; if (k == key) { pre.next = e.next; size--; return; } } } } /** * 获取 * * @param key * @return */ public int get(int key) { int index = hash(key); Entry e = table[index]; if (e != null && e.next != null) { for (e = e.next; e != null; e = e.next) { int k = e.key; if (k == key) { return e.value; } } } // 若没有找到,则返回-1 return -1; } /** * 获取散列表中元素的个数 * * @return */ public int size() { return size; } /** * 本身散列表是不该有这个方法的,在这里只是为了让我们知道它确实扩容了 * * @return */ public int getLength() { return table.length; } }