解析哈希表
网上看了很多有关的文章,感觉讲得都不够明了(原谅没读过书的我,这些基础知识都是看博客自学的)。所以今天决定来讲讲哈希表
哈希表又称散列表,C#里最经典的就是Hashtable和Dictionary。特别是后面的Dictionary,大家都用得非常多。他们是以键值对的形式存储的,通过key就可以查到value,而且查询速度非常快。它内部是如何实现的呢?查询快又是为什么呢?它又有什么缺点吗?下面就来一一说明
首先,我们要开辟一个容器(数组)来存储要插入的元素,既然知道字典是以键值对的形式来存储,那我们的容器应该是这样的
//容器实体 private struct bucket { public object key; public object value; } private bucket[] buckets = null;//容器
比如我现在想插入一个key为3B,value为1,我们就会通过key.GetHashCode()得到一个哈希值(int),然后再将这个值通过计算,转换成数组的索引,然后在索引位置存储这对key和value
下图k代表key,v代表value
有了眉目后,我们可以开始动手构建代码了
class Program { static void Main(string[] args) { MyHashTable table = new MyHashTable(); table.Add("3B", 1); table.Add("WEA", 2); var a = table.Find("3B"); } } public class MyHashTable { private struct bucket { public object key; public object value; } private bucket[] buckets = null;//容器 public MyHashTable() { buckets = new bucket[10]; } public void Add(object key, object value) { uint index = (uint)key.GetHashCode() % (uint)buckets.Length; bucket temp = new bucket() { key = key, value = value }; buckets[index] = temp; } public object Find(object key) { uint index = (uint)key.GetHashCode() % (uint)buckets.Length; var item = buckets[index]; if (item.key != key) throw new Exception("不存在该键"); return item.value; } }
Add的时候我们调用key.GetHashCode()可以得到它的哈希值(int),然后再将其转换为32位正整型,然后%数组的长度。得到索引的位置。a=b%c,a的范围为0~c-1。所以一定不会越界。但问题来了。
Q1.如果不同的key通过计算后得到了相同的索引,怎么办?这时我们该如何存储,如何查找?
table.Add("WEA", 2);
table.Add("QQ1", 2);
这时他们通过uint index = (uint)key.GetHashCode() % (uint)buckets.Length;计算出来的index都是8,下文中我们将这种情况称为冲突
Q2.容器大小初始化时为10,如果我插入的元素超过了10个,我要扩充容器。扩充完后容器(数组的长度)会发现变化,这时通过
uint index = (uint)key.GetHashCode() % (uint)buckets.Length
计算出来的结果就会出现不一样,我们该如何解决?
A1.我们先改造容器(数组buckets),数组的每个元素不是一个bucket。而是一个bucket链表(如果不知道链表的可以当它是一个集合)。这样我们就可以将转换后相同索引的多个元素都存在同一个位置。在查询时我们索引到这个bucket链表。然后再一个个找key相同的元素。
A2.我们可以重新做一次排列(相当于调用Add方法)
比如我们初始化时数组长度为10。全部被占用时,我们将数组扩展成20。这时我们就
uint index = (uint)key.GetHashCode() % 20;
然后再插入到新的数组中,这时排列就是正确的了。有点重新映射的味道。
附加一点,容器的大小(length,也就是数组的长度)与添加的元素的数量(elementcount)之间有一个关系。如果容器的大小不变,元素数量越多则出现冲突的可能性就越大。
比如我容器大小是2,我现在添加了一个元素到下标0的位置。而我下个元素添加时发生冲突的概率就有50%。如果容器大小是3,则重复的概率只有33.33%。
我们不可能为了避免重复,一开始就将容器弄得很大,就像我们不可能用食堂那种超大的锅子来煮1杯米的饭。
也不可能任由它冲突(反正我们再在链表里一个个查就行了)这样就失去了它查找性能的优势了,就相当于线性查找了。
我们如何把握这中间的平衡呢?
float r=elementcount/length; r最好处于0.6~0.7之间。而微软则是采用0.62(近乎黄金分割点啊)。如果r>0.62,我们就扩展容器。
class Program { public static void Main() { MyHash set = new MyHash(); List<string> list = new List<string>() { "ef","ab","ff","gg","ee","zf","ase","fge","qweg","qalspo","goo2","1qwe","wet93" }; for (int i = 0; i < list.Count; i++) { var item = list[i]; set.Add(item, i); } var value = set.Find("gg"); } } public class MyHash { //容器实体 private struct bucket { public object key; public object value; } private LinkedList<bucket>[] buckets = null;//容器 private int count;//已存元素数量 private int step = 10;//扩展时增加的数量 public MyHash() { IninialBuckets(step); } private void IninialBuckets(int length) { if (buckets == null)//如果为空,则初始化容器 { buckets = new LinkedList<bucket>[length]; return; } //否则,则扩展容器 length = length + buckets.Length; var newBuckets = new LinkedList<bucket>[length]; count = 0; foreach (LinkedList<bucket> linkList in buckets) { if (linkList == null) continue; foreach (bucket item in linkList) { int index = GetIndex(item.key, length); InserintoBuckets(index, item.key, item.value, newBuckets);//重新排列 } } buckets = newBuckets; } private int GetIndex(object key, int length) { return (int)((uint)key.GetHashCode() % (uint)length); } private void InserintoBuckets(int index, object key, object value, LinkedList<bucket>[] buckets) { var linkList = buckets[index]; if (linkList == null) linkList = new LinkedList<bucket>(); if (linkList.Count(x => x.key == key) > 0) throw new Exception("已存在key:" + key); bucket item = new bucket() { key = key, value = value }; linkList.AddLast(item); buckets[index] = linkList; count++; } public void Add(object key, object value) { if ((float)count / (float)buckets.Length > 0.62)//如果大于0.62。我们就扩展我们的容器 { IninialBuckets(step); } int index = GetIndex(key, buckets.Length); InserintoBuckets(index, key, value, buckets); } public object Find(object key) { int index = GetIndex(key, buckets.Length); var linkList = buckets[index]; if (linkList == null) throw new Exception("不存在此键"); bucket item = linkList.FirstOrDefault(x => x.key == key); if (item.key == null) throw new Exception("不存在此键"); return item.value; } }
这时我们要根据某个key查value时,我们就将key同过计算转换为索引 (uint)key.GetHashCode() % (uint)length
所以在理想的情况下(不同的key通过hash后计算出来的索引不同,也就是无冲突),查找时的时间复杂度是O(1)。这就是它的优势。如果冲突了,接下来我们的查找就是线性的了。所以可以看出,哈希表这种数据结构最重要的是算法本身。
现在大家也可以看出哈希表的优势了,无论数据量再大,查找都是O(1) (理想情况下)。但它的前提就是牺牲空间。
大致原理就是这样,但我们常用的Hashtable等它们并不是这样构造的,它们解决冲突时使用的是双重散列法,它探测地址的方法如下:
h(key, i) = h1(key) + i * h2(key)
其中哈希函数h1和h2的公式如下:
h1(key) = key.GetHashCode()
h2(key) = 1 + (((h1(key) >> 5) + 1) % (buckets.length- 1))
由于使用了二度哈希,最终的h(key, i)的值有可能会大于容器,所以需要对h(key, i)进行模运算,最终计算的哈希地址为:
哈希地址 = h(key, i) % buckets.length
而且容器扩展也是有讲究的。
因为这些知识对刚接触的朋友不太好理解,所以没放到文章中去将。只要明白原理,这些都变得非常简单了。
另外附带微软的dictionary实现,有兴趣的可以去看看微软是怎么做的
http://referencesource.microsoft.com/#mscorlib (当然,你要搜索dictionary)