解析哈希表

网上看了很多有关的文章,感觉讲得都不够明了(原谅没读过书的我,这些基础知识都是看博客自学的)。所以今天决定来讲讲哈希表

 

哈希表又称散列表,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)

 

posted @ 2015-06-18 10:16  Poiuyt_cyc  阅读(1862)  评论(2编辑  收藏  举报