[转载] 散列表(Hash Table)从理论到实用(上)
转载自:白话算法(6) 散列表(Hash Table)从理论到实用(上)
处理实际问题的一般数学方法是,首先提炼出问题的本质元素,然后把它看作一个比现实无限宽广的可能性系统,这个系统中的实质关系可以通过一般化的推理来论证理解,并可归纳成一般公式,而这个一般公式适用于任何特殊情况。
——R.A. Fisher
在一个解决方案的复杂性之中,理论或者概念的部分通常只占有限的一小部分。理论无法做实际的工作——否则它也不成其为理论了。从理论到实用,需要经过一系列的发明。从实用到更加实用、更加通用,往往需要增加更多的复杂性。有时,这一过程远远超越科学的范畴,成为艺术家的乐园。有时,这一过程引入了过多不必要的复杂性,只是因为人类的自私、愚蠢和目光短浅。
科学不会也不能处理奇迹。科学只能处理重复的事件,艺术却不同。艺术是“就是如此”。在一个创作诞生以前,它是 Nothing——它没有来由、毫无征兆;诞生之后,它就是存在,是合理,是自然和美。我们所谈论的算法,作为一门实用的科学,既有科学的一面,也有艺术的一面。作为科学,它的结构可以分析,它的行为可以预测,它的属性可以量化,它的正确性可以证明。作为艺术,在一个算法诞生之后,有时我们只能说“它能工作”,仅此而已;对于它是如何来到这个世界上的,我们一无所知——这里没有“因为……所以……”,也不是简单的从一般到特殊。创造,似乎和生命一般神秘。我们可以给造物穿上漂亮的科学外衣,欣赏它内在的一致性,但是,最让人着迷的创造性的那一部分,却完全无法加以描述。
所以,当我们进行散列表的从理论到实用之旅时,如果你察觉到一些没有解释的跨越,请不要见怪吧。如果没有这些跨越,我们就完全可以设计一个程序发明这些算法,我们所要学习的算法也就完全会是另外一个样子了。
O(n) 查找和 O(1) 查找,两个模型
如果想知道在《伊利亚随笔选》这本书里是否有一个“囿”字,该怎么做呢?我们只有从第一页的第一行开始,一个字一个字地向后看去,直到找到这个字为止。如果直到最后一页的最后一个字都没有找到它,我们就知道这本书里根本没有这个字。所以,这项工作的复杂度是 O(n)。
再假设有这样一本《会计专用字帖》,它只有9页,每一页上有一个大写的数字:
当会计想要练习“柒”字时,只要她事先知道页码和内容的对应关系,就可以直接翻到第7页,实现 O(1) 复杂度的查找。通过这个模型我们知道,要想达成 O(1) 复杂度的查找,必须满足3个条件:
1. 存储单元(例如一页纸)中存储的内容(例如大写数字)与存储单元的地址(例如页码)必须是一一对应的。
2. 这种一一对应的关系(例如大写数字“柒”在第7页)必须是可以预先知道的。
3. 存储单元是可以随机读取的。这里“随机读取”的意思是可以以任意的顺序读取每个存储单元,并且每次读取所需时间都是相同的。与此相对的,读取磁带里的一首歌就不是随机的——想听第5首歌就不如听第一首歌来得那么方便。
在计算机上实现 O(1) 查找
先来看计算机的硬件设备。计算机的内存支持随机存取,从它的名字 RAM(random-access memory) 可以看得出对于这一点它还真有一点引以为傲呢。
既然硬件支持,我们就可以准备在计算机上模拟会计专业字帖了。第一项任务是向操作系统申请9个存储单元。这里有个小问题,我们得到的存储单元的地址很可能并不是从1到9,而是从134456开始的。好在我们并不需要直接跟操作系统打交道,高级语言会为我们搞定这些琐事。当我们使用高级语言创建一个数组时,相当于申请了一块连续的存储空间,数组的下标是每个存储单元(抽象)的地址。这样我们第一个 O(1) 复杂度的容器 SingleIntSet 很容易就可以完成了,它只能存储 0~9 这10个数字:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
public class SingleIntSet { private object [] _values = new object [10]; public void Add( int item) { _values[item] = item; } public void Remove( int item) { _values[item] = null ; } public bool Contains( int item) { if (_values[item] == null ) return false ; else return ( int )_values[item] == item; } } |
测试一下:
1
2
3
4
5
6
7
8
|
static void Main( string [] args) { SingleIntSet set = new SingleIntSet(); set .Add(3); set .Add(7); Console.WriteLine( set .Contains(3)); // 输出 true Console.WriteLine( set .Contains(5)); // 输出 false } |
新术语:使用高级语言创建了一个整型数组时(例如 int[] values = new int[10]),我们不再把 values[7] 称为“一个存储单元”,因为存储单元的大小是一个字节,在32位操作系统上,values[7] 的大小是4字节,所以我们要使用一个新术语,把 values[7] 称为 values 数组的一个槽(slot)。
SingleIntSet2(说实话我真不喜欢这个名字,谁会喜欢?!)
新需求!同样只需要保存10个数字,只不过这次不是保存0~9,而是需要保存10~19,怎么办?很简单,实现一个槽里的值与地址的映射函数 H() 即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
public class SingleIntSet2 { private object [] _values = new object [10]; private int H( int value) { return value - 10; } public void Add( int item) { _values[H(item)] = item; } public void Remove( int item) { _values[H(item)] = null ; } public bool Contains( int item) { if (_values[H(item)] == null ) return false ; else return ( int )_values[H(item)] == item; } } |
测试的时候,使用10~19范围内的数字:
1
2
3
4
5
6
7
8
|
static void Main( string [] args) { SingleIntSet2 set = new SingleIntSet2(); set .Add(13); set .Add(17); Console.WriteLine( set .Contains(13)); // 输出 true Console.WriteLine( set .Contains(15)); // 输出 false } |
房子不够住,难道睡马路?
这次,还是存储10个数字,只不过数字的范围是0~19。如何把20个数字存放到10个槽里?还能怎么办,2人住1间咯。略微修改一下 H() 函数,其它代码不变:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
public class SingleIntSet3 { private object [] _values = new object [10]; private int H( int value) { if (value >= 0 && value <= 9) return value; else return value - 10; } // ... } |
测试一下:
1
2
3
4
5
6
7
8
9
10
11
12
|
static void Main( string [] args) { SingleIntSet3 set = new SingleIntSet3(); set .Add(3); set .Add(17); Console.WriteLine( set .Contains(3)); // 输出 true Console.WriteLine( set .Contains(17)); // 输出 true Console.WriteLine( set .Contains(13)); // 输出 false set .Add(13); Console.WriteLine( set .Contains(13)); // 输出 true Console.WriteLine( set .Contains(3)); // 输出 false. 但是应该输出 true 才对! } |
最后一行的结果不对!2人住1间是行不通的,数据受不了这委屈。但是米有办法,除非 1) 我们预先知道所有的10个输入;2) 这10个输入一旦决定就不再更改,否则无论怎么设计 H() 函数都无法避免2人住一间的情况,这时我们就说发生了碰撞(collision)。
用链接法处理碰撞
处理碰撞最简单的一个办法是链接法(chaining)。链接法就是让发生碰撞的2人住2间,但是共用1个公共地址。为了简单起见,可以让数组的每个槽都指向一个链表:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
public class SingleIntSet4 { private object [] _values = new object [10]; private int H( int value) { if (value >= 0 && value <= 9) return value; else return value - 10; } public void Add( int item) { if (_values[H(item)] == null ) { LinkedList< int > ls = new LinkedList< int >(); ls.AddFirst(item); _values[H(item)] = ls; } else { LinkedList< int > ls = _values[H(item)] as LinkedList< int >; ls.AddLast(item); } } public void Remove( int item) { LinkedList< int > ls = _values[H(item)] as LinkedList< int >; ls.Remove(item); } public bool Contains( int item) { if (_values[H(item)] == null ) { return false ; } else { LinkedList< int > ls = _values[H(item)] as LinkedList< int >; return ls.Contains(item); } } } |
测试一下,这次得到了正确的结果:
1
2
3
4
5
6
7
8
9
10
11
12
|
static void Main( string [] args) { SingleIntSet4 set = new SingleIntSet4(); set .Add(3); set .Add(17); Console.WriteLine( set .Contains(3)); // 输出 true Console.WriteLine( set .Contains(17)); // 输出 true Console.WriteLine( set .Contains(13)); // 输出 false set .Add(13); Console.WriteLine( set .Contains(13)); // 输出 true Console.WriteLine( set .Contains(3)); // 输出 true } |
如何让21亿人使用10个地址?
好吧,有了链接法,我们有了足够的房子以应对可能发生的碰撞。但是我们仍然希望碰撞发生的几率越小越好,特别是当我们把数值范围由 0~19 扩大到 0~int.MaxValue 时候。有什么办法能把21亿个数值映射成10个数值,并且尽量减少碰撞?
除法散列法
h(k) = k mod m
其中,k为槽中的数值,m是数组的大小(为了简单起见本例中固定为10)。这样我们得到第一个正整数范围内通用的 IntSet:
1
2
3
4
5
6
7
8
9
10
|
public class IntSet { private object [] _values = new object [10]; private int H( int value) { return value % 10; } // 其它部分与 SingleIntSet4 相同 } |
测试一下 IntSet.H() 工作得怎么样:
1
2
3
|
Console.WriteLine(H(3)); // 输出 3 Console.WriteLine(H(13)); // 输出 3 Console.WriteLine(H(17)); // 输出 7 |
挖藕,只发生了一次碰撞!它竟然与手写版的 SingleIntSet4.H() 工作得一样好。除法散列法为什么有效呢?魔术一旦揭开谜底总是显得平平无奇:
其一,如果小学课程还想得起来的话,应该还记得再大的数除以10的余数都一定介于0~9之间,以此作为下标访问数组自然不用担心越界啦。
其二,让 h() 得出 1 的 k 的数量与让 h() 得出 2 的 k 的数量相同,这样才不容易产生碰撞。
其三,让 h() 得出 1 的 k 是 1、11、21、31……101、111、121……也就是说导致碰撞的 k 值比较分散。这是很重要的,因为在实际使用 IntSet 的时候,存储的值经常是紧挨着的,譬如年龄、序号、身份证号码等等。
需要注意的是 m 不应是 2 的幂即 2p 的形式,此时 h(k) 将等于 k 的二进制的最低 p 位。以 m = 23 = 8 为例,如下图所示:
以 k = 170 为例,h(k) = 170 mod 8 = (27 + 25 + 23 + 0*22 + 21 + 0*20) mod 23 = (24*23 + 22*23 + 23 + 0*22 + 21 + 0*20) mod 23 = 0*22 + 21 + 0*20
也就是说只有最低的 p 位不能被 2p 整除。这有什么问题呢?问题是我们不想假设 k 的分布,所以通常希望 h(k) 的值依赖于 k 的所有位而不是最低 p 位。天知道 k 不会是“11010000、00110000、10010000……” 这种样子(假设有个白痴操作系统喜欢先在高位分配一个对象的 Id,而我们又希望把这个 Id 作为 k 的时候,杯具就发生了)。
当用户指定数组的大小之后,我们要找到一个与之最接近的质数作为实际的 m 值,为了速度,我们把常用的质数预存在一张质数表中,新的 IntSet2 允许用户指定它的容量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
public class IntSet2 { private object [] _values; public IntSet2( int capacity) { int size = GetPrime(capacity); _values = new object [size]; } private int H( int value) { return value % _values.Length; } // 质数表 private readonly int [] primes = { 3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239, 293, 353, 431, 521, 631, 761, 919, 1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591, 17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523, 108631, 130363, 156437, 187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827, 807403, 968897, 1162687, 1395263, 1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471, 7199369}; // 判断 candidate 是否是质数 private bool IsPrime( int candidate) { if ((candidate & 1) != 0) // 是奇数 { int limit = ( int )Math.Sqrt(candidate); for ( int divisor = 3; divisor <= limit; divisor += 2) // divisor = 3、5、7...candidate的平方根 { if ((candidate % divisor) == 0) return false ; } return true ; } return (candidate == 2); // 除了2,其它偶数全都不是质数 } // 如果 min 是质数,返回 min;否则返回比 min 稍大的那个质数 private int GetPrime( int min) { // 从质数表中查找比 min 稍大的质数 for ( int i = 0; i < primes.Length; i++) { int prime = primes[i]; if (prime >= min) return prime; } // min 超过了质数表的范围时,探查 min 之后的每一个奇数,直到发现下一个质数 for ( int i = (min | 1); i < Int32.MaxValue; i += 2) { if (IsPrime(i)) return i; } return min; } // 其它部分与 IntSet 相同 } |
注:质数表 primes 和 IsPrime()、GetPrime() 函数都是 Copy 自 .net framwork2.0 源代码的 Hashtable.cs
乘法散列法
h(k) = ⌊m(kA mod 1)⌋
其中,A 是一个大于0小于1的常数,例如可以取 A = 2654435769 / 232。kA mod 1 的意思是取 kA 的小数部分。C# 代码可以像这样:
1
2
3
4
5
|
private readonly double A = 2654435769 / Math.Pow(2, 32); int H( int value) { return ( int )(_values.Length * (value * A % 1)); } |
关于那个神奇数字的来历以及如何利用计算机的位操作更快地实现 H(),可参见《算法导论》 P138。
乘法散列法的缺点是不如除法散列法那么均匀,可以比较一下 k 取 0~1000 满足 m = 100,h(k)=1 的 k 的分布:
除法散列法,h(k) = k mod 100 k h(k) 跨度 1 1 - 101 1 100 201 1 100 301 1 100 401 1 100 501 1 100 601 1 100 701 1 100 801 1 100 901 1 100 |
乘法散列法,h(k) = 100*(kA mod 1) k h(k) 跨度 34 1 - 123 1 89 178 1 55 267 1 89 411 1 144 500 1 89 644 1 144 733 1 89 788 1 55 877 1 89 |
到目前为止,还有3个遗憾:
1. 只支持正整数。
2. 链接法虽然简单、直接,却不是处理碰撞的唯一的方法。人家 .net framework 的 Hashtable 可是用的更好的开放寻址法。
3. 只能在创建时指定容器的大小,不能自动扩张。
让我们喘口气先,这些留在下一篇继续战斗。
[转载] 散列表(Hash Table)从理论到实用(上)的更多相关文章
- [转载] 散列表(Hash Table)从理论到实用(中)
转载自:白话算法(6) 散列表(Hash Table)从理论到实用(中) 不用链接法,还有别的方法能处理碰撞吗?扪心自问,我不敢问这个问题.链接法如此的自然.直接,以至于我不敢相信还有别的(甚至是更好 ...
- [转载] 散列表(Hash Table) 从理论到实用(下)
转载自: 白话算法(6) 散列表(Hash Table) 从理论到实用(下) [澈丹,我想要个钻戒.][小北,等等吧,等我再修行两年,你把我烧了,舍利子比钻戒值钱.] ——自扯自蛋 无论开发一个程序还 ...
- 白话算法(6) 散列表(Hash Table)从理论到实用(上)
处理实际问题的一般数学方法是,首先提炼出问题的本质元素,然后把它看作一个比现实无限宽广的可能性系统,这个系统中的实质关系可以通过一般化的推理来论证理解,并可归纳成一般公式,而这个一般公式适用于任何特殊 ...
- 白话算法(6) 散列表(Hash Table)从理论到实用(中)
不用链接法,还有别的方法能处理碰撞吗?扪心自问,我不敢问这个问题.链接法如此的自然.直接,以至于我不敢相信还有别的(甚至是更好的)方法.推动科技进步的人,永远是那些敢于问出比外行更天真.更外行的问题, ...
- 白话算法(6) 散列表(Hash Table) 从理论到实用(下)
[澈丹,我想要个钻戒.][小北,等等吧,等我再修行两年,你把我烧了,舍利子比钻戒值钱.] ——自扯自蛋 无论开发一个程序还是谈一场恋爱,都差不多要经历这么4个阶段: 1)从零开始.没有束缚的轻松感.似 ...
- Java 集合 散列表hash table
Java 集合 散列表hash table @author ixenos 摘要:hash table用链表数组实现.解决散列表的冲突:开放地址法 和 链地址法(冲突链表方式) hash table 是 ...
- 散列表(Hash table)及其构造
散列表(Hash table) 散列表,是根据关键码值(Key value)而直接进行访问的数据结构.它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度.这个映射函数叫做散列函数,存放记录 ...
- 散列表(Hash Table)
散列表(hash table): 也称为哈希表. 根据wikipedia的定义:是根据关键字(Key value)而直接访问在内存存储位置的数据结构.也就是说,它通过把键值通过一个函数的计算,映射到表 ...
- 算法导论-散列表(Hash Table)-大量数据快速查找算法
目录 引言 直接寻址 散列寻址 散列函数 除法散列 乘法散列 全域散列 完全散列 碰撞处理方法 链表法 开放寻址法 线性探查 二次探查 双重散列 随机散列 再散列问题 完整源码(C++) 参考资料 内 ...
随机推荐
- 使用WebView加载assets下的html文件
有时候,我们需要将html文件以及所用到的图片都放在 assets/html/ 目录下.然后在页面上通过WebView来显示出来,比如给页面一个默认的显示,这样子看起来效果要好很多.代码如下: pri ...
- Hadoop I/O操作原理整理
I/O操作中的数据检查 校验和方式是检查数据完整性的重要方式.一般会通过对比新旧校验和来确定数据情况,如果两者不同则说明数据已经损坏.比如,在传输数据前生成了一个校验和,将数据传输到目的主机时再次计算 ...
- JavaScript插件架构
1.HTML布局规则 默认情况下,所有的插件都可以通过设置特定的HTML代码和相应的属性来实现.也就是说,在网页加载的时候,JavaScript代码会自动检测这些标记,并自动绑定相应的事件,而无需添加 ...
- Unity3D使用Assetbundle打包加载(Prefab、场景)
之前有一篇文章中我们相惜讨论了Assetbundle的原理,如果对原理还不太了解的朋友可以看这一篇文章:Unity游戏开发使用Assetbundle加载场景的原理 本篇文章我们将说说assetbund ...
- JSON库之性能比较:JSON.simple VS GSON VS Jackson VS JSONP
从http://www.open-open.com/lib/view/open1434377191317.html 转载 Java中哪个JSON库的解析速度是最快的? JSON已经成为当前服务器与WE ...
- HttpRequest.UserAgent 属性 (System.Web)
获取客户端浏览器的原始用户代理信息.
- mysql <-> sqlite
在做程序时,sqlite数据很方便.用mysql数据导出到sqlite的步骤:(用csv文件过渡) ------------------------------- 先导出到csv文件 ------ ...
- c#lock语句及在单例模式中应用
C#中的lock语句是怎么回事,有什么作用? C#中的lock语句将lock中的语句块视为临界区,让多线程访问临界区代码时,必须顺序访问.他的作用是在多线程环境下,确保临界区中的对象只被一个线程操作, ...
- [DFNews] 入侵汽车控制刹车和油门?——速度与激情6 的节奏?
原文跳转: http://arstechnica.com/security/2013/07/disabling-a-cars-brakes-and-speed-by-hacking-its-compu ...
- 使用GIT进行源码管理——GIT托管服务
虽然GIT是分布式代码管理,但是仍然需要一个集中存储服务以实现团队协作和代码备份的.对于企业的私有代码来说,大多是自建GIT托管服务.但对于开源项目和个人的私有项目,往往是选择一个GIT托管网站,这样 ...