数据结构 : Hash Table [I]

引子

这篇仍然不讲并行/并发。

Hash table,国内相当一部分书籍将其直译为哈希表,但博主本人喜欢称其为散列表。

散列表支持任何基于 Key-Value 对的插入,检索,删除操作。

比如在 .NET 1.x 版本下,我们可以这样使用:

   10 namespace Lucifer.CSharp.Sample

   11 {

   12     class Program

   13     {

   14         public static void Main()

   15         {

   16             Hashtable table = new Hashtable();

   17 

   18             //插入操作

   19             table[1] = "A";

   20             table.Add(2, "B");

   21             table[3] = "C";

   22 

   23             //检索操作

   24             string a = (string)table[1];

   25             string b = (string)table[2];

   26             string c = (string)table[3];

   27 

   28             //删除操作

   29             table.Remove(1);

   30             table.Remove(2);

   31             table.Remove(3);

   32         }

   33     }

   34 }

而在 .NET 2.0 及以上版本下,我们则这样使用:

   10 namespace Lucifer.CSharp.Sample

   11 {

   12     class Program

   13     {

   14         public static void Main()

   15         {

   16             Dictionary<int, string> table =

   17                 new Dictionary<int, string>();

   18 

   19             //插入操作

   20             table[1] = "A";

   21             table.Add(2, "B");

   22             table[3] = "C";

   23 

   24             //检索操作

   25             string a = table[1];

   26             string b = table[2];

   27             string c;

   28             table.TryGetValue(3, out c);

   29 

   30             //删除操作

   31             table.Remove(1);

   32             table.Remove(2);

   33             table.Remove(3);

   34         }

   35     }

   36 }

众所周知,假如在数组中知道了某个索引的话,也就知道了该索引位置上的值。同理,在散列表中,我们所要做的就是根据 Key 来知道 Value 在表中的位置 。 Key 的作用只不过用来指示位置。而通过 Key 来查找位置,意味着查找时间从顺序查找的 O(N),折半查找的 O(lgN) 骤减至 O(1)

那么我们如何把可能是字符串,数字等的某 Key 转换成表的索引呢?这一步,在 .NET 中由 GetHashCode 方法来完成。当然在散列表内还需要根据 Hash Code 来进一步计算,但我们现在暂且认为通过 KeyGetHashCode 方法我们已经可以找到 Value 了。实际上,对于外部开发人员来说的确不需要再做别的工作了。而这也是 Object 类带有 GetHashCode 虚方法的原因。当我们在使用 Stack<T>,List<T>,Queue<T> 等集合时,根本不需要在乎有没有 GetHashCode 方法,但是如果你想使用 Dictionary<TKey, TValue>,HashSet<T>(.NET 3.5新增) 等集合时,则必须正确重写 GetHashCode 方法,否则这些集合不能正常工作。当然,使用.NET基元类型没有任何问题,因为 Microsoft 已经为这些类型实现了良好的重载。

而在讲解数据结构的书籍里,把 GetHashCode 方法完成的工作称为“散列函数(hash function)”。

散列函数

那么散列函数是如何工作的呢?通常来说,它会把某个数字或者能够转换成数字的类型映射成固定位数的数字。比如 .NET 的 GetHashCode 方法返回 32 位有符号整型。当我们把64 位或更多位数字映射成 32 位时,很显然,这带来了一个复杂问题:两个或多个不同的 Key 可能被散列到同一位置,引起碰撞冲突。这种情况很难避免,因为 Key 的个数比位置要多。当然,如果能够避免碰撞冲突,那就完美了。我们把能够完成这种情况的散列函数叫做完全散列函数(perfect hash function)。

从定义和实现来看,散列函数其实就是伪随机数生成器(PRNG)。总的来说,散列函数的性能通常可以接受,而且也可以把散列函数当作 PNRG 来进行比较。理论上,存在一个完全散列函数。它从不会让数据发生碰撞冲突。实际上,要找到这样的散列函数以及应用该散列函数的实际应用程序太困难了。即使是它最低限度的变体,也相当有限。

实践中,有很多种数据排列。有一些非常随机,另外一些则相当的格式化。一种散列函数很难概括所有的数据类型,即使针对某种数据类型也很困难。我们所能做的就是通过不断尝试来寻找最适合我们需要的散列函数。这也是必须重写 GetHashCode 方法的原因之一。

下面是我们分析选择散列函数的两大要素:

  1. 数据分布。这是衡量散列函数生成散列值好坏的尺度。分析这个需要知道在数据集内发生碰撞冲突的数量,即非唯一的散列值。
  2. 散列函数的效率。这是衡量散列函数生成散列值快慢的尺度。理论上,散列函数非常快。但是也应当注意到,散列函数并不总是保持 O(1) 的时间复杂度。

那么如何来实现散列函数呢?基本上有以下两大方法论:

  1. 加法和乘法。这个方法的主要思想是通过遍历数据,然后以某种计算形式来构造散列值。通常情况下是乘以某个素数的乘法形式。如下图所示:

    Figure1

    目前来说,还没有数学方法能够证明素数和散列函数之间的关系。不过在实践中利用一些素数可以得到很好的结果。
  2. 位移。顾名思义,散列值是通过位移处理获得的。每一次的处理结果都累加,最后返回该值。如下图所示:

    Figure2

此外,还有很多方法可以用来计算散列值。不过这些方法也不外乎是上述两种的变种或综合运用。老实说,一个良好的散列函数很大程度上是靠经验得来。除此之外,别无良方。幸运的是,前人留下了许多经典的散列函数实现。接下来,我们就来了解下这些经典的散列函数。注意,本文所介绍的散列函数均不能使用在诸如加密,数字签名等领域。

关于整型和浮点类型的散列函数,因为都很简单,在这里就不再详细阐述。有兴趣的可以使用 Reflector 等反编译工具自行查看其 GetHashCode 实现。值得一提的是浮点类型要注意使 +0.0 和 -0.0 的散列值结果一致,还有就是 128 位的 Decimal 类型实现。

接下来将详细介绍几个字符串散列函数。

先看下 Java 的字符串散列函数是什么样。注意,本文代码均以C#写就,下同。代码如下:

    8 public int JavaHash(string str)

    9 {

   10     int hashCode = 0;

   11     for (int i = 0; i < str.Length; i++)

   12     {

   13         hashCode = 31 * hashCode + str[i];

   14     }

   15     return hashCode;

   16 }

上述散列函数,一般来讲已经相当好。不过如果字符串很长,我们可能需要对它进行修改。它实际上来自于 K & R 的《The C Programming Language》。其中我们使用的素数 31 可以替换成 31, 131, 1313, 13131, 131313,... 等。看起来,它跟下面这个散列函数很相似。

   18 public int DJBHash(string str)

   19 {

   20     int hashCode = 5381;

   21     for (int i = 0; i < str.Length; i++)

   22     {

   23         hashCode = ((hashCode << 5) + hashCode)

   24             + str[i];

   25     }

   26     return hashCode;

   27 }

该函数最早由 Daniel J. Bernstein 教授展示于新闻组 comp.lang.C 上,是最有效率的散列函数之一。

我们再来看看 .NET 中的字符串散列函数。代码如下:

   29 public unsafe int DotNetHash(string str)

   30 {

   31     fixed(char* charPtr = new String(str.ToCharArray()))

   32     {

   33         int hashCode = (5381 << 16) + 5381;

   34         int numeric = hashCode;

   35         int* intPtr = (int*)charPtr;

   36 

   37         for (int i = str.Length; i > 0; i -= 4)

   38         {

   39             hashCode = ((hashCode << 5) + hashCode +

   40                         (hashCode >> 27)) ^ intPtr[0];

   41             if (i <= 2)

   42             {

   43                 break;

   44             }

   45             numeric = ((numeric << 5) + numeric +

   46                         (numeric >> 27)) ^ intPtr[1];

   47             intPtr += 2;

   48         }

   49         return hashCode + numeric * 1566083941;

   50     }

   51 }

上述代码实际上是大牛唐纳德在他的《The Art Of Computer Programming Volume 3》中的变种。因为老唐的散列函数在某些情况下会有问题,所以 .NET 没有完全采用老唐的办法。老唐提供的散列函数如下:

   53 public int DEKHash(string str)

   54 {

   55     int hashCode = str.Length;

   56     for (int i = 0; i < str.Length; i++)

   57     {

   58         hashCode = ((hashCode << 5) ^ (hashCode >> 27))

   59                     ^ str[i];

   60     }

   61     return hashCode;

   62 }

此外在Unix平台上还有一种广泛采用的散列函数。代码如下:

   64 public int ELFHash(string str)

   65 {

   66     int hashCode = 0;

   67     int numeric = 0;

   68     for (int i = 0; i < str.Length; i++)

   69     {

   70         hashCode = (hashCode << 4) + str[i];

   71         if ((numeric = hashCode & 0xF0000000L) != 0)

   72         {

   73             hashCode ^= (hashCode >> 24);

   74         }

   75         hashCode &= ~numeric;

   76     }

   77     return hashCode;

   78 }

前文讲过,散列函数会带来碰撞冲突,那如何解决这种情况呢?敬请下回分解。

最后给大家提个问题:乘法运算快呢,还是位移运算快?

posted @ 2008-06-18 00:46 Angel Lucifer 阅读(4787) 评论(35)  编辑 收藏 网摘 所属分类: 数据结构

  回复  引用  查看    
#1楼2008-06-18 00:55 | 狼Robot      
学习了.期待下文.
  回复  引用  查看    
#2楼2008-06-18 01:53 | Henry Liang      
回答问题:在C/C++中是位移快,.Net中不知道。期待博主解答。
  回复  引用  查看    
#3楼2008-06-18 07:07 | CareySon      
学习中...
  回复  引用  查看    
#4楼2008-06-18 08:12 | lbq1221119      
定,不错 学习中。。
  回复  引用  查看    
#5楼2008-06-18 08:20 | 包建强      
一道面试题,与HashTable密切相关:
1个100万长度的整型数组中,只有两个元素是一样的,如何在最短时间找出这个元素来?可以不考虑空间影响。

  回复  引用  查看    
#6楼2008-06-18 08:43 | 簡簡單單..      
Mark
  回复  引用  查看    
#7楼2008-06-18 09:03 | 一味      
@Henry Liang
根据我的测试,位移和乘法运算速度是一样的。
测试代码如下:
class Program
{
static void Main(string[] args) {
int times = 1000000000;
long t1;
for (int i = 0; i < 10; i++) {
Console.WriteLine("第{0}次计算结果:", i);
t1 = DateTime.Now.Ticks;

chengfa(times);

Console.WriteLine("乘法计算耗时:{0}毫秒", DateTime.Now.Ticks - t1);

t1 = DateTime.Now.Ticks;

weiyi(times);

Console.WriteLine("位移计算耗时:{0}毫秒", DateTime.Now.Ticks - t1);
}
Console.ReadLine();
}
static void chengfa(int times) {
long rlt = 0;
for (int i = 0; i < times; i++) {
rlt += i * 2;
}
Console.WriteLine("乘法计算结果:{0}", rlt);
}
static void weiyi(int times) {
long rlt = 0;
for (int i = 0; i < times; i++) {
rlt += i <<1;
}
Console.WriteLine("位移计算结果:{0}", rlt);
}

}

  回复  引用  查看    
#8楼2008-06-18 09:10 | jillzhang      
乘法运算快呢,还是位移运算快?
---------------------------------
计算机中移位操作是最快的

  回复  引用    
#9楼2008-06-18 09:40 | wfa[未注册用户]
i*2 会不会被优化掉?
Console.Write的速度太慢,严重影响你的测试。
DateTime.Now.Ticks精度很低。

  回复  引用  查看    
#10楼2008-06-18 09:55 | U2U      
还是就话题
  回复  引用    
#11楼2008-06-18 10:06 | 个性天空[未注册用户]
应该是位运算最快!
  回复  引用    
#12楼2008-06-18 10:29 | hamburger[未注册用户]
乖法本身就是移位的叠加吧
  回复  引用  查看    
#13楼2008-06-18 10:44 | jillzhang      
计算机组成原理或者汇编都讲过这个问题
  回复  引用  查看    
#14楼2008-06-18 12:54 | Jeffrey Zhao      
--引用--------------------------------------------------
包建强: 一道面试题,与HashTable密切相关:
1个100万长度的整型数组中,只有两个元素是一样的,如何在最短时间找出这个元素来?可以不考虑空间影响。
--------------------------------------------------------
小包子现在知道哈希表是O(1)了吧,哈哈

  回复  引用  查看    
#15楼2008-06-18 13:03 | TerryLee      
@一味
为什么不用System.Diagnostics.Stopwatch来测试呢,这个更准确一些。。。

  回复  引用  查看    
#16楼2008-06-18 13:37 | 朝晖的.net      
@Angel Lucifer

我感觉位移运算快,乘法运算不也是通过位移得出吗?
cpu不会乘法,只会加减。
我对我自己不信任,因为我大学基础太差了。

我发现不只大学基础差,而是所以基础都差。
当我们hashtable table =new hashtable()时候,内存是不是分配了两块存储区,上一篇哪个图是这样地,一个存储我们key,另一个存储我们value,value地址是按照key的哈希算法得出,检索速度快但是更多的内存分配,这样是不是也属于空间换时间的一种。基础太差,这样理解对不对?

楼主玩war3啊?因为你的名字让我联想到地。


  回复  引用  查看    
#17楼2008-06-18 16:40 | 戏水      
极度 期待下文。
  回复  引用  查看    
#18楼2008-06-18 16:46 | 戏水      
cpu 有专门的移位指令,貌似只用很少的周期。
一般的比如 乘以2的n次方这样的 乘法 编译器一般会优化成左移n位吧。
至于乘以其他数值,貌似就要叠加了。

但是我貌似以前测试过 。 在.net中移位更慢,
才疏学浅 不知何故。

  回复  引用  查看    
#19楼[楼主]2008-06-18 16:51 | Angel Lucifer      
@一味
可以确定的是 i*2 or i/2 都会被编译器优化成左移或右移1位。

@朝晖的.net
你的理解是正确的。
我不玩WAR3,这个名字很早就用了,大概叫了10年了,呵呵。

@楼上诸位
我来谈一下对乘法和位移谁快谁慢的看法。
其实,这个问题,简单的一言蔽之,有失偏颇。
我们先确定硬件的规格相同。
在x86/x64等有类似MMX指令集的平台上。
实际上,乘法运算速度快,因为有直接的汇编指令对应(这可以使得运算在3个时钟周期内完成),这是硬件上的优化。位移一般需要2~3个时钟周期。
但是要注意,复杂的乘法运算对应的位移运算。而且这不通用,因为有的平台上并没有乘法加速器,位移则是通用的,速度也不慢。注意,有时候使用MMX等加速,乘法也不一定比位移快。

即使是在x86平台上,乘法是否速度快,还要看代码和编译器的质量。
.NET平台就只能靠编译器和JIT是否会对乘法优化了。而C/C++则可以嵌入汇编,手动内联等。编译器的优化也比.NET更厉害。

一些简单的乘除法,比如*2,/2等基本上编译器都会优化成位移操作。其他情况在.NET平台上也就只能指望编译器聪明点了。

  回复  引用  查看    
#20楼2008-06-18 16:59 | Terry Sun      
对“小包子”的这道题目很感兴趣,我用了一个数组来找到相同的值,不知道有没有更好的办法
  回复  引用  查看    
#21楼2008-06-19 04:27 | Sumtec      
@Lucifer:
"理论上,存在一个完全散列函数。它从不会让数据发生碰撞冲突。"
这句话是错误的!任何散列函数都回产生冲突,不存在不冲突的散列函数。如果一个有限集能表示超过该有限集容量的超集的所有元素,那么无论这样的散列函数找起来有多难,人们都会花心思去找。更不要说用有限集去表示无限集的时候了,字符串就是一个无限集。比如假设有限集0-2^32-1,可以表示任意长度的任意字符的所有组合,比方1代表且仅代表A,2代表且仅代表B,如果真能这样,我们就不需要zip、rar了,直接用散列就能压缩了。
正确的说法是,理论上存在一个散列函数,对被计算数据的全集I进行计算,可以产生均匀冲突。即,每一个散列值,所代表的数据量都是一致的。好的散列函数是奔着这个目标去的,如果所有字符串算出来都是数值0,肯定不是一个好的散列算法,都影射到0、1、2也不是一个好算法。
事实上,那只是理论上的问题,实际的应用中,理论上好的算法,在特定场景中可能会失效。例如对英文单词做散列,用一个对不定长二进制值能够进行良好散列的公式,可能会导致对英文单词的散列计算产生不均匀结果。所以还是要讨论特定场景下,才能得出该算法是否接近理论的结果。当然,一般应用,这都不是很重要的问题。

另外,哈希表查值的时间复杂度,更准确应该说是O(n)=C (C>1),而不是O(n)=1。因为哈希表不可能比数组快,每次查值要进行至少一次散列运算,一次模运算,一次比较运算,这已经是无冲突下的最简模式了。有冲突处理的情况下会更大。当然,数据量足够大,冲突足够小的情况下,和对折查找的Log级别比起来,可以认为是近似1。

@包建强:
不考虑空间影响,你的题目的答案就是开一个超大数组,直接应对你的表示空间,例如无符号整型值(4字节),那就开个4G的数组,几乎无敌手(前提是你有那么多内存,也确实不需要考虑空间大小的影响)。但这也是有前提条件的,如果这个100万个数中的任意一个的表示范围是不定长的,例如可以从负无穷到正无穷的任意数字,那么比较实际的还是用哈希表。当然了,如果硬要在这个时候说,那空间别考虑,无穷的空间也能给你提供,还不会影响速度,那照样是用无穷大的数组来最快。这个问题N多年前我在CSDN上和人争过,题目就是没有限制数据表示范围,我的答案就是哈希表。

  回复  引用  查看    
#22楼[楼主]2008-06-19 06:19 | Angel Lucifer      
@Sumtec
仁兄指的是在无法准确了解数据的结构的情况下。如果数据的结构固定,则完全散列函数是存在的。比如Richard J. Cichelli开发了一种构造最小完全散列函数的算法,称为Cichelli方法。Thomas Sager对其进行了扩展,称为FHCD算法。

另外O(1)的时间复杂度并非指的算法时间为1,而是在常量时间内完成。
如果按照仁兄的方法,那么通过索引进行数组访问也不应该算作O(1)。因为这期间也有好多步骤要做。

此外文中也提出了两大衡量散列函数的要素,与仁兄的意见并无冲突。

还请仁兄再次指正,呵呵。

  回复  引用  查看    
#23楼2008-06-19 09:34 | wangyan      
学习,期待下一讲
  回复  引用  查看    
#24楼2008-06-19 09:53 | 朝晖的.net      
@Sumtec

把你和别人讨论这个问题的帖子给我看下吧,我还是不会找。
或者你详细讲一下吧`~3Q~~

  回复  引用  查看    
#25楼2008-06-30 12:28 | Sumtec      
@朝晖的.net:
我也不太记得了,估计你得到Google上搜索一下,Site:csdn.net 哈希表 sumtec

@Angel Lucifer:
Cichelli方法是对字符串进行散列的一种函数,当然也可以推广到对不定长二进制值的计算。这个函数如下:
H(key) = h(first) + h(last) + h(length)
实际上,该方法也并不是所谓的完美函数。第一,该函数在面对woman、women,jan、jun的时候,确实是有冲突的;第二,该方法在大量数据时是很慢的。我想你大概也承认了这一点了。
至于FHCD算法,理论上应该是可以寻找完美散列函数的,不过这种完美函数不能简单的说是“无冲突散列函数”。或者说,这种函数的任务并不是为了制造出一个不会产生冲突的哈希表类。原因还是在于前面说的,永远不可能用有限集表示无限集。又或者说,如果插入一个新的值,就有可能破坏原有的完美函数,需要寻找一个新的完美函数。至少我认为,完美函数仍然是有冲突的,只不过解决冲突的办法变成了换一个新的完美函数。
当然,我并非说完美函数就没用了。完美函数显然比高冲突函数更有用,但这种解决方案也是有特定的优势和劣势的。其优势在于,如果存入哈希表的数据不经常变化,则可以通过该方法减少冲突。其劣势在于,如果插入哈希表次数和访问哈希表的次数接近时,有可能会因为寻找完美函数产生过高的时间消耗。

最后说时间复杂度O(n),这个东西通常讨论的是最高次幂,并且也不讨论系数。但是,有的情况下,我也会讨论系数问题,尤其是系数高于n的情况下。例如数组和哈希表的比较。您说的“如果按照仁兄的方法,那么通过索引进行数组访问也不应该算作O(1)。因为这期间也有好多步骤要做”,我觉得反而有点极端了。
首先我们要确定O(n)得出的结果是什么?比如说O(n)=n,这个n是表示n个机器指令吗?显然不是。O(n)=n,通常是以某种较为费时的操作作为观测目标以及衡量单位的。例如说:比较,跳转,除法,幂运算等。当然,我们通常更笼统的是以某种特定的运算为单位。例如比较数组和哈希表,我们说O(n)=1,不是表示只要一个机器指令,而是说,我们通常认为数组就是最快的了,所以一个数组操作就是一个基本单位,这样我们才好继续讨论哈希表的时间复杂度。事实上,一个数组操作通常也确实是一个机器指令就可以完成的(连续操作的情况下)。比如说,对一个数组循环赋值i,我们可以得到类似如下的机器指令:
mov dx, 1234h
xor cx,cx
xor si,si
loop_start:
mov [dx+si], cx
inc si
inc cx
cmp cx, 100
jne loop_start:

其中mov [dx+si], cx,就是对数组某一个元素的赋值。那么在这样一个基础上,我们确实可以看出来,哈希表的O(n)=C,并且这个C还不是一般的小。如果用数组只要1秒就可以完成的工作,用哈希表也许就需要花10-100秒。这点差别在某些情况下不敏感,某些情况下却是敏感的,就要看具体的情况如何了。

在CSDN上讨论的时候,有的人就是非常敏感,非要说数组比哈希表快。我也承认确实是快,就因为哈希表的O(n)=C,而数组是O(n)=1。但是,对方却不愿意承认数组有可能无法完成哈希表可以完成的某些类型的问题,尤其回避了空间复杂度上的问题。准确说,哈希表是在技术所有数据结构和算法中,对空间复杂度和时间复杂度平衡得最好的其中一个。

  回复  引用  查看    
#26楼2008-06-30 12:30 | Sumtec      
补充一个:
本人观点与楼主的观点并无太大冲突,只是在局部意见上有点差异或者误解。上面的长篇只是表达自己个人观点而已,并非说明楼主观点不正确,甚至剧烈冲突。请勿错误理解。

  回复  引用  查看    
#27楼2008-06-30 12:36 | Sumtec      
@Angel Lucifer:
顺便问问看,你看过.NET的HashTable吗?我粗看了一下,好象是用了桶方法,没有仔细看,所以不确定。你确定吗?

  回复  引用  查看    
#28楼2008-06-30 18:31 | 朝晖的.net      
@Sumtec

very well !
thanks. :^)

  回复  引用  查看    
#29楼[楼主]2008-07-01 23:56 | Angel Lucifer      
@Sumtec
感谢仁兄再次指正,呵呵。

关于 Hashtable 类,散列函数的部分跟本文差不多。它主要的不同还在于冲突解决方案不同,这个会在下篇文章内略有提及。

  回复  引用  查看    
#30楼2008-07-03 14:34 | 银河      
好文章, Sumtec 的评论也是非常好的补充
关注.

  回复  引用  查看    
#31楼2008-09-02 15:32 | 飞鱼2006      
@Sumtec
-----------------------
例如无符号整型值(4字节),那就开个4G的数组
-----------------------
老兄100万个整型数据是4M 不是4G

((4*1000000)/1024)/1024=3.814697265625M

  回复  引用  查看    
#32楼2008-12-13 03:19 | shawnliu      
.net的是再hash来解决冲突的吧 java应该使用的是链接法
  回复  引用  查看    
#33楼2008-12-13 03:19 | shawnliu      
赞一下lz的算法功底 那么偏的hash函数都让你们翻出来了
  回复  引用  查看    
#34楼2009-01-02 12:58 | 蛙蛙池塘      
精彩,第一次阅读,以后再来看
  回复  引用  查看    
#35楼2009-03-04 10:20 | overred      
对于大部分CPU来说,位移要快



发表评论

昵称: [登录] [注册]

主页:

邮箱:(仅博主可见)

评论内容:

  登录  注册

[使用Ctrl+Enter键快速提交评论]

0 1224319




相关文章:

相关链接: