本文整理来源 《轻松学算法——互联网算法面试宝典》/赵烨 编著java
数组是一堆数据按照顺序放入的固定长度空间。面试
固定的长度的情景下,例如游戏中的快捷键等。算法
数组的致命缺点是固定长度,若是没法对长度进行预估。这时候就须要采用集合,其实集合也是基于数组实现的。数据库
集合的概念比较宽泛,如下主要指可变长度列表(也叫动态数组)编程
以java中的ArrayList为例,它是一个数组的列表,其实就是数组的扩展,或者说是可变长度的数组。数组
具体的实现方式是内部存在一个数组,同时存在一个标记,标记标识着数组里放了多少数据。这样咱们往里面放数据时,就会知道数据应该放在内部数组的哪一个位置,可是这个数组也会存在长度限制,若是超过这个长度限制,内部会从新建立一个数组,将旧的数组复制到新的数组当中,这样就能够继续添加数据了。缓存
在外部,咱们就感受不到ArrayList是有长度限制的,内部已经处理好了。网络
public class ArrayList<T> { 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地址。
目前,这个哈希函数比较经常使用的实现方法比较多,一般须要考虑几个因素:关键字的长度、哈希表的大小、关键字的分布状况、记录的查找频率,等等。
下面简单介绍几种哈希函数:
散列表为何会冲突?是由于不一样的key经过哈希函数可能会产生相同的地址。
实际上就是当须要存储时,对Key进行哈希以后,发现这个地址已经有值了,这时改怎么办?不能放在这个地址,否则以前的映射就会被覆盖。这是计算出来的地址进行探测再哈希,好比日后移动一位,若是没有人占用,就用这个地址。若是超过长度,则对总长度取余。这里移动的地址就是产生冲突时的增列序量。
在产生冲突以后,使用关键字的其余部分继续计算地址,若是仍是有冲突,则继续使用其余部分再计算地址。这种方式的缺点是时间增长了。
链地址法其实就是对Key经过哈希以后落到同一个地址上的值,作一个链表。其实在不少高级语言的实现中,也是使用这种方式处理冲突的。
这种方式是创建一个公共溢出区,当地址存在冲突时,把新的地址房子公共溢出区里。
一个好的散列表设计,除了须要选择一个性能较好的哈希函数,不然冲突时没法避免的,因此一般还须要一个好的冲突解决方式。
举例,采用除留取余法做为哈希函数、链地址法做为解决冲突的方式。
这里以Key的类型为整型数字为例,用Key对长度8取余,一定会等到很大的冲突,这时么个地址并不放真正的值,而是记录一个链表的起始地址。固然,在实际状况下咱们不会让这个哈希表这么短,只是简单举例子。
经过这种方式,咱们能够快速地知道Key所对应的Value在哪一个地址上,若是这个地址的链表比较长,则也须要一个一个地去检索。
一般状况下,新增元素若是遇到冲突,那么链表会有两种的方式去插入元素。
这里以整数为例,对于其余类型不少编程语言都具备必定的实现方式。好比Java中的每一个对象都有个hashCode方法,经过获取字符串的这个方法,就能够将一个字符串轻松的转换成整型。固然这种方法还可能返回负数,这也是能够直接使用绝对值解决的。
散列表有两种用法:一种是Key的值与Value的值同样,通常咱们称这种状况的结构为Set(集合);而若是Key和Value所对应的内容不同时,那么咱们称这种状况为Map,也就是人们俗称的键值对集合。
根据散列表的存储结构,咱们能够得出散列表的如下特色。
根据散列表的特色能够想到,散列表比较适合无序、须要快速访问的状况
一般咱们在开发程序的时候,对于一些经常使用的信息会作存储,用的就是散列表。好比咱们要缓存用户信息,通常用户信息都会有惟一表示的字段,好比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<K, V> { /** * 默认散列表的初始长度 * 设置小一点,这样咱们可以清楚看到扩容。 * 实际使用过程当中是能够初始化传参的,扩容是很是损耗性能的 */ 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<K, V>(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<K, V>(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, Value> { 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() { HashTable<Integer,Integer> hashTable = 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)); } }