1. 介绍
当需要根据给定的值需要快速得到想要值的时候,散列表是一个非常有用的数据结构,假设你在一家杂货店上班。有顾客来买东西时,你得在一个本子中查
找价格,如果本子的内容不是按字母顺序排列的,你可以使用简单查找法,从头到尾以一个一个的找,时间复杂度为O(n),如果本子的内容是按字母顺序排列的,可使用二分查找来找出苹果的价格,这需要的时间会短一些,为O(log n)。
二分查找的速度非常快。但作为收银员,在本子中查找价格是件很痛苦的事情,哪怕本子的内容是有序的。要是能够记住所有商品价格,就马上知道答案而且不用查找了。
散列函数是这样的函数,简单的说,即无论你给它什么数据,它都会给你一个代表位置的数字。
但散列函数必须满足一些要求。
- 它必须是一致的。例如,假设你输入apple时得到的是4,那么每次输入apple时,得到的都必须为4。如果不是这样,散列表将毫无用处。
- 它应将不同的输入映射到不同的数字。 例如,如果一个散列函数不管输入是什么都返回1,
它就不是好的散列函数。最理想的情况是,将不同的输入映射到不同的数字。
2. 原理介绍
散列函数返回的数字到底有什么用了?下面我们来看下这个数字是如何帮助我们快速查找的。
首先创建一个空数组:
你将在这个数组中存储商品的价格。下面来将商品的价格加入到这个数组中,放到数组中的位置由商品名称经过散列函数处理的结果决定,例如
- Apple 的散列值为3
- Milk 的散列值为0
- 计算其他商品的散列值
放入了Apple以及Milk后数组,变成了下面的样子:
现在假设需要知道Apple的价格。你无需在数组中查找,只需将Apple作为输入
交给散列函数, 他会告诉你苹果的价格存储在索引3处,于是你就得到了苹果的价格。
散列函数准确地指出了价格的存储位置,你根本不用查找!之所以能够这样,具体原因如下。
- 散列函数总是将同样的输入映射到相同的索引。每次你输入Apple,得到的都是同一个
数字。因此,你可首先使用它来确定将鳄梨的价格存储在什么地方,并在以后使用它来
确定鳄梨的价格存储在什么地方。 - 散列函数将不同的输入尽量映射到不同的索引。
- 散列函数知道数组有多大,只返回有效的索引。如果数组包含5个元素,散列函数就不会返回无效索引100
3. 重要特性
3.1 冲突
还是以查询商品价格为例,假设你有一个长度为26的数组,而你使用的散列函数也很简单,就是按照字母表顺序来分配商品在数组中的位置。
对于该种情况,Apple(苹果)与Avocados(鳄梨)分配的位置相同,该种情况被称为冲突。处理冲突的方式很多,最简单的办法如下:如果两个键映射到了同一个位置,就在这个位置存储一个链表,结构如下:
在这个例子中, apple和avocado映射到了同一个位置,因此在这个位置存储一个链表。在需要查询香蕉的价格时,速度依然很快。但在需要查询苹果的价格时,速度要慢些:你必须在相应
的链表中找到apple。如果这个链表很短也没什么,但假设你工作的杂货店只销售名称以字母A打头的商品的话,会出现下面的情况:
- 第一个位置包含了所有的商品,此时的查找变成了链表查找
- 除了第一个位置外,其他的位置都是空的,资源分配严重不均!
通过前面的描述,可以说明散列函数的好坏很重要,最理想的情况是,散列函数将键均匀地映射到散列表的不同位置。
3.2 性能
在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,因此它兼具两者的优点!但在最糟情况下,散列表的各种操作的速度都很慢。
因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突,需要有:
- 较低的填装因子;
- 较好的散列函数
3.2.1 填装因子
散列表的填装因子=散列表包含的元素数/位置总数,如果一个元素需要放入包含3个位置的散列表中,则填装因子就为1/3.
填装因子大于1意味着商品数量超过了数组的位置数。一旦填装因子开始增大,你就需要在散列表中添加位置,这被称为调整长度(resizing) 。
例如,假设有一个像下面这样相当满的散列表(数组长度为4,已经包含3个元素),你就需要调整它的长度。为此,
- 你首先创建一个更长的新数组:通常将数组增长一倍,长度现在变成了8
- 你需要使用函数hash将所有的元素都插入到这个新的散列表中
这个新散列表的填装因子为3/8,比原来低多了!填装因子越低,发生冲突的可能性越小,散列表的性能越高,一个不错的经验规则是:一旦填装因子大于0.7,就调整散列表的长度
由于重新调整大小涉及到重新分配内存以及重新插入操作,因此调整长度是开销很大的。
3.2.2 良好的散列结构
良好的散列函数让数组中的值呈均匀分布,糕的散列函数让值扎堆,导致大量的冲突。
4. 总结
- 散列表适合用于模拟映射关系
- 平均情况下,散列表所有操作的运行时间都为O(1)
- 最糟情况下,散列表所有操作的运行时间都为O(n)
- 你可能不需要自己去实现散列表,每种语言基本都提供了散列表实现
- 一个不错的经验规则是:一旦填装因子大于0.7,就调整散列表的长度