本文整理来源 《轻松学算法——互联网算法面试宝典》/赵烨 编著
数组
自我解读
数组是一堆数据按照顺序放入的固定长度空间。
- 数组的长度固定,所以在声明时需要指定数组长度。如果长度不够用,也没有什么办法,想要继续存放数据,只能重新声明一个数组空间。
- 数据只能够按顺序访问,虽然开发时可以通过下标直接访问指定位置元素,但是实际上计算机在处理时也是按照顺序访问的
使用场景
固定的长度的情景下,例如游戏中的快捷键等。
升级版数组——集合
数组的致命缺点是固定长度,如果无法对长度进行预估。这时候就需要采用集合,其实集合也是基于数组实现的。
集合的概念比较宽泛,以下主要指可变长度列表(也叫动态数组)
- 列表:一般是有序的集合,特点就是有顺序,比如链表、队列、栈等。
- 集:一般是无序的集合,特点就是没有顺序切数据不能重复,多数语言是使用散列表实现,支持对集进行添加、删除、查找包含等操作。
- 多重集:一般是无需的集合,特点是没有顺序,但是数据可以有重复值,只是对集进行添加、删除、查找包含、查找一个操作在集合的个数等操作。多重一般可以通过排序转换成列表。
- 关联数组:其实多数语言是使用散列表实现的,就是通过键(key)获取到值(value)。同样是没有顺序的。
- 树、图:同样是集合。
集合的实现
以java中的ArrayList为例,它是一个数组的列表,其实就是数组的扩展,或者说是可变长度的数组。
具体的实现方式是内部存在一个数组,同时存在一个标记,标记标识着数组里放了多少数据。这样我们往里面放数据时,就会知道数据应该放在内部数组的哪个位置,但是这个数组也会存在长度限制,如果超过这个长度限制,内部会重新创建一个数组,将旧的数组复制到新的数组当中,这样就可以继续添加数据了。
在外部,我们就感觉不到ArrayList是有长度限制的,内部已经处理好了。
public class ArrayList{ private static final int INITIAL_SIZE = 10; private int size; private Object[] array; public ArrayList() { array = new Object[INITIAL_SIZE]; } public ArrayList(int initial) { if (initial <= 0) { initial = INITIAL_SIZE; } array = new Object[initial]; } /** * 添加元素 * * @param data 新元素 */ public void add(T data) { if (size == array.length) { array = Arrays.copyOf(array, size * 2); } array[size++] = data; } /** * 获取指定位置的元素值 * * @param i 指定位置 * @return 元素值 */ @SuppressWarnings(value = "unchecked") public T get(int i) { if (i > size) { throw new IndexOutOfBoundsException("获取元素的位置超过了最大长度"); } return (T) array[i]; } /** * 修改指定位置的元素值 * * @param i 指定位置 * @param data 新的值 * @return 旧的值 */ public T set(int i, T data) { T oldData = get(i); array[i] = data; return oldData; } /** * 获取变长数组的长度 * * @return 数组长度 */ public int size() { return size; }}
集合的特点
集合的特点和实现有关,那就是变长。边长是相对而言的,内部还是根据数组实现的,只是在不够长时根据一定的策略生成一个更长的数组,把旧的数组中的数组复制到新的数组中使用。
正常情况下会有两个系统开销:一个是数组总是比我们实际使用的长度长,所以存在空间浪费;拎一个是当数组长度不够长时,需要新建一个更长的数组,同时把旧数组的数据复制到新数组中,这个操纵比较消耗系统性能。
集合的适用场景
集合的使用场景很多。现在基本上所有的批量查询及获得一定条件的数据列表,都使用变长数组去存储返回的结果。比如查询某游戏中的一个瓦加包囊里的所有物品,若不清楚物品的数量,则会用变长数组去存储返回的结果。
博客的文章列表、评论列表等,只要涉及列表,就会有集合的身影。
变长数组的查询效率很低,所有需要使用一些复杂的数据结构,帮助我们完成更高效的算法实现。
数组与变长数组的性能
虽然集合这个变长数组比普通数组高级一些,但是本质还是基于数组实现的,所以与数组的性能差不多。
对数组的操作,计算机需要根据我们提供具体操作的位置,从头到尾一个一个地寻找到指定位置,所以在数组中增加元素、修改元素、获取元素等操纵的时间复杂度都为O(n)。
变长数组也有性能损耗的问题,在插入元素时发现其中的固定长度不够,则需要新建更长的数组,还要复制元素,都会造成性能的损耗。
散列表
集合其实是很多种,散列表也算集合中的一种。实际上顺序的存储是按照一个一个地按顺序访问元素,当这个总量很大且我们所要访问的元素比较靠后,性能就会很低。
散列表是一种空间换时间的存储结构,是在算法中提高效率的一种比较常用的方式,但是所需要空间太大也会让人头疼,所以通常是两者之间权衡。
什么是散列表
试想一下,我们要在手机通讯录中查询一个人,我们很少会从第1个人开始往下一直找,因为这样太慢了。我们经常做的事情是根据这个人的名字首字母去查询。比如姓张,那么我们一定会滑到最后,因为“Z”姓的名字都在最后。
还有在查字典的时候,要查找一个单词,肯定不会从第一个开始查,而是从这个单词的首字母,找到之后再找第2个字母、第三个字母以此类推,这样就可以快速跳到哪个单词所在页。
其实这就用到散列表的思想。
散列表,幼教哈希表(Hash Table),能够通过给定的关键字的值去直接访问到具体的对应值的一个数据结构。也就是说,把关键字映射到一个表中的位置来直接访问记录,以加快访问速度。
通常我们把关键字称为Key,把对应的记录称为Value,所以也可以说是通过Key访问一个映射表来得到Value的地址。而这个映射表,也叫作散列函数或哈希函数,存放记录的数组叫做散列表。
其中有个特殊情况,就是通过不同的Key,可能访问到同一个地址,这种现象叫做碰撞(Collision)。而通过某个Key一定会得到唯一的Value地址。
目前,这个哈希函数比较常用的实现方法比较多,通常需要考虑几个因素:关键字的长度、哈希表的大小、关键字的分布情况、记录的查找频率,等等。
下面简单介绍几种哈希函数:
- 直接寻址法:取关键字或关键字的某个线性函数值为散列地址。
- 数字分析法:通过对数据的分析,发现数据中冲突较少的部分,并构造散列地址。例如学号,通常同一届学生的学号,其中前面的部分差别不太大,所以用后面部分来构造散列地址。
- 平方取中法:当无法确定关键字里哪几位的比较平均时,可以先求出关键字的平方值,然后按需要取平方值的中间极为作为散列地址。这是因为:计算平方之后的中间极为和关键字中的每一位都相关,所以不同的关键字会以较高的概率产生不同的散列地址。
- 取随机数法:使用一个随机函数,取关键字的随机值作为散列地址,这种方式通常用于关键字的长度不同的场合。
- 除留取余法:取关键字被某个不大于散列表表长n的数m除后所得的玉树p为散列地址。这种方式也可以在用过其他方法之后在使用。该函数对m的选择很重要,一般取素数或者直接用n。
对散列表函数产生冲突的解决办法
散列表为什么会冲突?是因为不同的key通过哈希函数可能会产生相同的地址。
开放地址法(开放寻址法)
实际上就是当需要存储时,对Key进行哈希之后,发现这个地址已经有值了,这时改怎么办?不能放在这个地址,不然之前的映射就会被覆盖。这是计算出来的地址进行探测再哈希,比如往后移动一位,如果没有人占用,就用这个地址。如果超过长度,则对总长度取余。这里移动的地址就是产生冲突时的增列序量。
再哈希
在产生冲突之后,使用关键字的其他部分继续计算地址,如果还是有冲突,则继续使用其他部分再计算地址。这种方式的缺点是时间增加了。
链地址法
链地址法其实就是对Key通过哈希之后落到同一个地址上的值,做一个链表。其实在很多高级语言的实现中,也是使用这种方式处理冲突的。
建立一个公共溢出区
这种方式是建立一个公共溢出区,当地址存在冲突时,把新的地址房子公共溢出区里。
散列表的存储结构
一个好的散列表设计,除了需要选择一个性能较好的哈希函数,否则冲突时无法避免的,所以通常还需要一个好的冲突解决方式。
举例,采用除留取余法作为哈希函数、链地址法作为解决冲突的方式。
这里以Key的类型为整型数字为例,用Key对长度8取余,必定会等到很大的冲突,这时么个地址并不放真正的值,而是记录一个链表的起始地址。当然,在实际情况下我们不会让这个哈希表这么短,只是简单举例子。
通过这种方式,我们可以快速地知道Key所对应的Value在哪个地址上,如果这个地址的链表比较长,则也需要一个一个地去检索。
通常情况下,新增元素如果遇到冲突,那么链表会有两种的方式去插入元素。
- 一种方式是直接把新元素的下一个元素指向原来链表的第1个元素,然后把刚刚对应上的那个地址链表头的下一个元素指向新建的元素。这种方式的好处是在插入元素时会比较快,因为不需要遍历链表,而是直接改变头部的指向关系。
- 另一种方式是使链表元素有序,这种方式的劣势就是在每次插入元素时需要遍历链表,在中间打开链表插入元素。
这里以整数为例,对于其他类型很多编程语言都具有一定的实现方式。比如Java中的每个对象都有个hashCode方法,通过获取字符串的这个方法,就可以将一个字符串轻松的转换成整型。当然这种方法还可能返回负数,这也是可以直接使用绝对值解决的。
散列表的特点
散列表有两种用法:一种是Key的值与Value的值一样,一般我们称这种情况的结构为Set(集合);而如果Key和Value所对应的内容不一样时,那么我们称这种情况为Map,也就是人们俗称的键值对集合。
根据散列表的存储结构,我们可以得出散列表的以下特点。
- 访问速度很快:由于散列表有散列函数,可以将制定的Key都用射到一个地址上,所以在访问一个Key(键)对应的Value(值)时,根本不需要一个一个地进行查找,可以直接跳到那个地址。所以我们在对散列表进行添加、删除、修改、查找等操作时,速度都很快。
- 需要额外的空间:首先,散列表实际上是存不满的,如果一个散列表刚好能够存满,那么肯定是一个巧合。而且当散列表中的元素的使用率越来越高时性能就会下降,所以一般会选择扩容来解决这个问题。另外,如果有冲突的话,则也需要额外的空间去存储的,比如链表地址法,不但需要额外的空间,甚至还需要使用其他的数据结构。这个特点很常用的表达,叫做“空间换时间”,在大多数的时候,对于算法的实现,为了能够更好的性能,往往会考虑牺牲些空间,让算法能够更快些。
- 无序:散列表有个非常明显的特点就是无序。为了更快的访问元素,散列表是根据散列函数直接找到存储地址的,这样我们的访问速度就能够更快,但是对于有序访问就没有办法应对了。
- 可能会产生碰撞:没有完美的散列函数,无论如何总会产生冲突,这是就需要采用冲突解决方案,这也使散列表更加复杂。通常在不同的高级语言的实现,对于冲突的解决方案不一定一样。
散列表的适用场景
根据散列表的特点可以想到,散列表比较适合无序、需要快速访问的情况
缓存
通常我们在开发程序的时候,对于一些常用的信息会做存储,用的就是散列表。比如我们要缓存用户信息,一般用户信息都会有唯一表示的字段,比如ID。这时做缓存,可以把ID作为Key,而Value用来存储用户的详细信息,这里的Value通常是一个对象,包含用户的一些关键字段,比如名字、年龄等。
在我们每次需要获取一个用户信息是,就不用与数据库这类的本地磁盘存储交互了(大多数时候,数据库可能与我们的服务不在一台机器上,还会有相应的网络性能损耗),可以直接从内存中得到结果。这样不仅可以快速获取数据,也能够减轻数据库的压力。
快速查找
这里说的查找,不是排序,而是在集合中找到是否存在指定元素。
这种场景很多,比如我们要在指定的用户列表中查找是否存在指定的用户,这时就可以使用散列表了。在这种场景下使用的散列表其实就是上面提到的Set类型,实际上不需要Value这个值。
还有一个场景,我们一般对网站的操作会有一个ip地址黑名单,我们认为某些ip有大量非法操作,于是封锁了这个ip对我们网站的访问。这个ip是如何存储的呢?就是使用散列表。当一个访问行为发送过来时,我们会获取其ip,判断是否存在于黑名单中,如果存在,则禁止访问。这种情况也是使用Set。
以上两种情况使用列表也可以解决,当列表越来越长时,查找速度很慢。散列表则不会。
散列表性能分析
散列表的访问,如果没有碰撞,那么我们完全可以认为对元素的访问是O(1)的时间复杂度,因为对于任何元素的访问,都可以通过散列函数得到元素的值所在地址。
但实际上是不可能没有碰撞,所以我们不得不对碰撞进行一定的处理。
我们常用链表的方式进行解决(当然,也有一些语言使用开放寻址方式解决,Java使用链表解决),由于可能会产生碰撞,而碰撞之后访问需要遍历列表,所以时间复杂度将变成O(L),其中L为链表的长度。当然,在大多数时候不一定会碰撞,而很多Key也不一定刚好碰撞到一个地址上,所以性能还是很不错的。
如果可能分配的地址即散列表的元素大部分被使用了,这时再向散列表中添加元素,就会很容易产生碰撞了,甚至散列表分配的地址越往后使用,越容易被占用。这种情况下就可以使用扩容。
在使用散列表的时候,一般不会等到真的占满了才会去扩容,而是会提前扩容。这里设计一个扩容因子的术语(也叫做载荷因子),是一个小数,在使用散列表的过程中,不会等到把所有地址都用完才去扩容,而是会在占用到地址达到散列表长度乘以扩容因子的这个值时去扩容,一般的扩容是在原有的基础上乘以2作为新的长度。
Java中扩容因子默认为0.75。
扩容的时候,除了简单的增加原来散列表的长度,还需要把之前那些由于碰撞而存放在一个地址的链表上的元素重新进行哈希运算,有可能之前存在碰撞的元素,现在就不会碰撞了。
public class HashTable{ /** * 默认散列表的初始长度 * 设置小一点,这样我们能够清楚看到扩容。 * 实际使用过程中是可以初始化传参的,扩容是非常损耗性能的 */ 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; /** * 添加元素 * * @param key 键 * @param value 值 */ public void put(K key, V value) { int index = hash(key); if (table[index] == null) { table[index] = new Entry (null, null, 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) { Object k = e.key; if (Objects.equals(k, key)) { e.value = value; return; } } //不存在相同的值,直接向链表中添加元素 Entry temp = table[index].next; //往链表开头添加元素 table[index].next = new Entry<>(key, value, temp); size++; } } /** * 删除 * * @param key 键 */ private void remove(K 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) { Object k = e.key; if (Objects.equals(k, key)) { pre.next = e.next; size--; return; } } } } /** * 获取 * * @param key 键 * @return 值 */ @SuppressWarnings("unchecked") public V get(K key) { int index = hash(key); Entry e = table[index]; if (e != null && e.next != null) { for (e = e.next; e != null; e = e.next) { Object k = e.key; if (Objects.equals(k, key)) { return (V) e.value; } } } //如果没有找到,则返回null; return null; } /** * 获取散列表中的元素个数 * * @return 元素个数 */ public int size() { return size; } /** * 非散列表该有的方法,只是为了让我们知道确实扩容了 * * @return 散列表长度 */ public int getLength() { return table.length; } /** * 扩容 */ @SuppressWarnings("unchecked") private void resize() { int newLength = table.length * 2; Entry[] oldTable = table; table = new Entry[newLength]; use = 0; for (Entry anOldTable : oldTable) { if (anOldTable != null && anOldTable.next != null) { Entry e = anOldTable; while (null != e.next) { Entry next = e.next; //重新计算哈希值,放入新的地址中 int index = hash((K) next.key); if (table[index] == null) { use++; table[index] = new Entry (null, null, null); } Entry temp = table[index].next; table[index].next = new Entry<>((K) next.key, (V) next.value, temp); e = next; } } } } /** * 根据Key的hashCode,通过哈希函数获取位域散列表数组中的哪个位置 * * @param key 键 * @return 散列值 */ private int hash(K key) { return key.hashCode() % table.length; } public class Entry { Key key; Value value; Entry next; Entry(Key key, Value value, Entry next) { this.key = key; this.value = value; this.next = next; } }}
测试:
public class HashTableTest { @Test public void main() { HashTablehashTable = new HashTable<>(); hashTable.put(1,10); hashTable.put(2,20); //和key为1的元素落到了同一个散列表地址上了,实际是一个长度为2 hashTable.put(5,50); //散列表长度为4 Assert.assertEquals(4,hashTable.getLength()); //总长度为4,添加上钙元素就大于等于3了,需要进行扩容 hashTable.put(3,30); //散列长度为8 Assert.assertEquals(8,hashTable.getLength()); //在扩容后4个元素分别落在不同的地址上 //使用了第5个地址 hashTable.put(6,60); //使用了第6个地址,为8的0.75倍,又需要扩容 hashTable.put(7,70); Assert.assertEquals(16,hashTable.getLength()); Assert.assertEquals(10, (int) hashTable.get(1)); Assert.assertEquals(30,(int)hashTable.get(3)); Assert.assertEquals(50,(int)hashTable.get(5)); Assert.assertEquals(60,(int)hashTable.get(6)); }}