数据结构——哈希表的详解与实现

数据结构——哈希表(HashTable)

1.前言

​ 当我们频繁的查找数据中的某个元素时,我们通常会选择数组来存放数据,因为数组的的内存是连续的,可以直接通过下标访问数据,但是它添加和删除数据比较麻烦;虽然链表解决了数组添加和删除数据效率低的问题,但是它查找的效率却很低。在实际业务中,经常会遇到频繁CRUD数据的情况,而数组和链表显然无法满足高效率的需求,有没有一种数据结构即可以满足快速的获取数据,又可以实现快速的修改和删除数据呢?没错,那就是哈希表!

注意:虽然哈希表弥补了数组与链表的不足,但是它是牺牲内存来提高性能(阅读完本文就会明白它为什么会耗内存),因此要根据具体的业务场景来选取和使用。因此,没有一种数据结构是绝对完美的!其优势也是其鸡肋!

2.哈希表的定义

​ 哈希表,也称为散列表。是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数,存放记录的数组叫做哈希表

注意记住上面加粗的关键字

3.哈希函数

​ 哈希表,其本质是一个数组,在创建哈希表时,初始化一个数组,将元素唯一对应的key映射成该数组中的一个下标,而这个映射过程称之为哈希化,把实现哈希化的函数称之为哈希函数。

​ 哈希函数让key与数组的下标一一对应,由于数组本身支持随机访问,所以,当查找key时,只需要O(1)的查找操作,也就是实现了不经过任何比较,一次便能得到所查记录,这就是哈希表查找速度快的重要原因。

实现哈希化的方式

a.相加法

​ 这是最简单的方法,直接将key的每一字符的ASCII值相加,例如abc: 97+98+99=294,但是按照这种方式去计算下标很容易出现重复的下标,而在数组中一个下标只能存储一个值,如果存入后来的值显然会导致之前的值被覆盖。

b.幂的连乘

​ 幂的连乘相对于相加法不再那么容易出现下标值相同的情况,那什么是幂的连乘呢?其实,我们平时使用的大于10的数字都是可以以一种幂的连乘的形式来唯一表示,如7582 = 7 * 10³ + 5 * 10² + 8 * 10 + 2。那么字符也可以用这种形式来计算其下标,如abc:97 * 10² + 98 * 10 + 99 = 10719。这样可以基本保证计算出的下标具有唯一性。

​ 但是如果存在yyyyyyyyyyyyyyyy这样的字符,计算出来的下标值显然很大,而实际情况中我们用不了这么大的数组去存放元素,创建如此大的数组也没有什么意义。

总结

​ 对于以上两种方案,第一种计算出来的哈希值太小,容易出现重复;而第二种方案计算出来的哈希值又过于太大,浪费空间。

​ 第二种方案可以避免下标重复,但是计算的哈希值太大,这里我们可以用一种方式将这个哈希值压缩到当前数组的长度范围类。下面举个例子:

​ 有5000个单词,我们通常会定义5000个长度的数组去存储,而在实际情况下,需要定义更大的空间来存储这些单词,因为不能保证每个单词在哈希化的时候都映射在不同的位置。假设我们定义一个两倍的大小空间10000来存储这5000个单词。显然abc计算出来的下标值已经大于10000了,那么我们要对10719压缩到0-10000这个范围,有一种简单的方式就是取余法,如:10719%10000=719,这使得10719被压缩到了这个数组的范围内。

​ 尽管上面提到的方式能够有效的降低哈希值相同的概率,但是也无法避免会出现相同哈希值的情况(如某个单词的哈希值可能是20719),如果出现哈希值相同的情况,我们就将其称为哈希冲突。虽然我们希望冲突不发生,但在是实际情况中我们无法避免。既然冲突无法避免,我们只能去处理冲突。

处理哈希冲突

a.链地址法

​ 链地址法也称为拉链法,它是较为常见的解决哈希冲突的方法。我们来看一幅图:
在这里插入图片描述

图片解释:

  • 将一组数据存储到长度为13的链表中,在下标为1的位置我们发现有多个数字(1,14,27,79),这些数字通过取余法,计算出的下标都为1,这就产生了哈希冲突,因此我们只能将这些数据全部保存到这个位置,所以我们需要一个数组或者是链表来存储这些数据。

  • 在这里我们假设用链表来存储,一旦出现哈希冲突将数据放入到链表头部

  • 当要查询时,我们先根据哈希化后计算出的下标值,拿到哈希表中对应的链表,再在链表中依次查询我们要查询的数据。

在哈希表中到底选用数组还是链表?

  • 我认为二者均可,效率也相当。因为当我们根据key在哈希化计算出下标值的时候,无论拿到数组或者是链表,都需要通过线性查找对应的数据,这个时候使用数组或者链表其效率都差不多。
  • 在Java的HashMap中和其他很多语言的实现中,采用的是链表,因为在链表中可以将数据快速的插入到头部,这样在线性查询取数据时会更快。(通常认为新插入的数据被访问的概率会更高一些,所以要放在头部)。
  • 不过我认为这也要根据实际的业务情况选取,比如微信好友,不见得新添加的好友联系的频率更高,反而时老友联系的更频繁。因此我认为数组和链表都可以。
b.开放地址法

开放地址法最本质的原理就是寻找空白的空间来处理冲突。

我们再来看副图:
在这里插入图片描述
从图片可以看出,开放地址法是寻找空白的空间来处理冲突的数据,而开放地址法分为三种不同的寻址方式:线性探测、二次探测、再哈希法。

线性探测

​ 即线性的探测空白的空间。所以你大概知道新插入的101应该放在什么位置了吧!没错,就是4位置!

插入101:

  • 101经哈希化计算出来的下标值为1,但是在插入的时候发现该位置已经存在一个11了
  • 此时通过线性探测,即将101的哈希值加1,找到空白的位置为止,而4就是空白的位置

查询101:

  • 首先计算101映射的哈希值,即下标值index = 1
  • 根据下标值查询,发现1位置的值和我们查询不同,依次线性探测,即index+1,知道找到我们要查值为值
  • 如果在线性探测的时候遇到空白位置,停止探测,说明该哈希表中没有我们想要的值

线形探测存在的问题:

删除101:

  • 当我们通过线性探测删除101时,我们不能把它所在的位置直接置为空,为什么呢?
  • 如果直接将其置空,会影响后续的查询操作,因此我们在删除数据项后应对该位置做特殊标记

问题:哈希表中接近被填满时,向表中插入数据就会效率很低,当hash表真的被填满了,这时候算法应该停止,在这之前应该对数组进行扩展,对hash表中的数据进行转移。

聚集:当哈希表越来越满时聚集越来越严重,这导致产生非常长的探测长度,后续的数据插入将会非常费时。通常数据超过三分之二满时性能下降严重,因此设计哈希表关键确保不会超过这个数据容量的一半,最多不超过三分之二。

二次探测

二次探测和线性探测原理一样,只不过每次不再以index加1的方式来寻找空白位置,比如:index + x,x可以是任意自然数,将其称为步长。其x按规律变化。

二次探测虽然在一定程度上解决了聚集的问题,但是聚集仍然会存在,要避免聚集,必须保证其步长的不同,那么再哈希化就是解决这样的问题。

再哈希化

即保证不同的key使用不同的步长;

不过满足再哈希化需要具备两个条件:

  1. 与第一个哈希函数不同(计算出来的步长还是原来的位置)

  2. 不能输出0(步长不能为0)

    计算机专家已经计算出了一种很好的哈希函数了,即stepSize = primeNum - (key % primeNum );其中stepSize为步长,primeNum 为小于数组长度的质数。

装填因子(LoadFactor)

装填因子即哈希表中现有的数据项个数/哈希表的长度。(装填因子 = 总数据项/哈希表长度)

开放地址法的装填因子最大为1,因为存放的数据项的个数最多只能是哈希表的长度。

而链地址法的装填因子可以大于1,因为存放的数据项个数不受哈希表长度的限制,可以无限存储,但是会影响效率。

开放地址法和链地址法的效率问题
  1. 当然不出现哈希冲突,效率更高
  2. 一旦出现哈希冲突,存取数据的效率取决于探测空白时间的长短
  3. 平均探测的长度和平均探测的时间受装填因子的影响,随着装填因子的变大,探测的长度和时间也就会长一些
  4. 经过相关的分析和比较链地址法的效率要高于开放地址法(具体的比较可以自己查阅相关资料)

因此,在实际开发中利用链地址法的情况会多一些,Java中的HashMap的实现就是采用的链地址法。

优秀的哈希函数

哈希表的有点在于其速度快,因此在设计时一定要遵循这个原则。而哈希表的核心之一就在于哈希函数,因此哈希函数的效率直接影响到哈希表的效率。

设计哈希函数必须满足以下条件:

1.快速的就算:快速的计算hashCode,需要尽可能减少乘法和除法的次数(计算机做乘除法是比较费“脑子”的)。

  • 我们前面采用幂的连乘来计算hashCode,即abc: 97 * 10² + 98 * 10 + 99 = 10719,直观的发现其乘法的次数比较多,我们可以通过秦九韶算法(自己去了解,我记得中学的时候就学了,国外叫做霍纳算法)来减少乘法的次数,以达到提高效率的目的。即:abc:10 * (92 * 10 + 98) + 99,显然乘法的次数减少了一次。当多项式越多,减少次数就会多一些,那是相当影响效率的!
  1. 均匀的分布:即从根本上解决哈希冲突,让元素在哈希表中较为均匀的分布
    • java的HashMap中采用位运算来计算hashCode,其效率比取模高(推荐使用)。 在这里插入图片描述
    • 当然也可以使用取模运算,要保证其更均匀,哈希表的长度最好是质数(至于为什么,专家研究出来的)。

哈希函数的实现

		/// <summary>
        /// 用于key的哈希化,位运算计算hashCode,效率更高
        /// </summary>
        /// <param name="str"></param>
        /// <returns></returns>
        public int GetHashCode(string str)
        {
            // 判断str不能为空或者""
            if (str == null || str.Length < 1)
            {
                throw new Exception("参数不合法");
            }
            // 定义hashcode
            long hashCode = 0;
            for (int i = 0; i < str.Length; i++)
            {
                hashCode = 31 * hashCode + str.ToCharArray(i, 1)[0];
            }
            // 位运算
            hashCode = hashCode & limit - 1; // 此处的limit为哈希表的长度
            // 取模运算(效率相对低)
            // hashCode = hashCode % limit;
            return (int)hashCode;
        }

4.哈希表的扩容

  • 为什么要扩容?
    • 前面提到哈希表的效率受装填因子的影响;无论使用拉链法还是开放地址法,随着数据的增加,装填因子也会变大,进而导致哈希表的效率越低。
    • 我们也可以根据实际的情况想一想为什么数据增多,哈希表的效率会就降低。
    • 当使用拉链法时,如果哈希表不进行扩容,无论多少条数据我们都可以存储,但是我们知道当有哈希冲突的时候,会追加到对应的链表中,数据一旦增多,其哈希表中的每个元素的链表存储的数据就会增多,这显然会影响查询的效率。在Java中,当哈希表中的某个链表的长度大于11的时候,会采用红黑树(后面会讲,你只需要知道它的查询效率比链表高就行)来存储,有兴趣可以去读一读Java实现HashMap的源码。
    • 当使用开放地址法的时候,装填因子越大,在插入数据的时候寻找空白位置的时间就会变长,查询数据也是如此。
  • 扩容的时机?
    • 经过专业的、实际的验证,当装填因子大于0.75的时候,对哈希表进行扩容是非常合适的。

5.哈希表的代码实现

注意:这里利用的数组+链表的实现哈希表,所以实现的前提是实现链表,可参考数据结构——链表

using System;
using TestCSharp.DoubleLinkedList;


namespace ConsoleApplication1
{
    class HashTable
    {
        private DoubleLinkedList[] storage; // 用于存放元素
        private int count = 0;// 存放的元素个数
        private int limit;//最多存放多少个元素
        private const float LOAD_FACTOR = 0.75f;  // 加载因子,用于再哈希
        private const int INITIAL_CAPACITY = 1 << 4; // 初始容量,在Java的HashMap中默认为16

        public HashTable()
        {
            limit = INITIAL_CAPACITY;
            storage = new DoubleLinkedList[limit];
        }

        // 允许外界传入初始容量
        public HashTable(int initialCapcity)
        {
            limit = initialCapcity;
            storage = new DoubleLinkedList[limit];
        }

        /// <summary>
        /// 重写ToString,方便测试
        /// </summary>
        /// <returns></returns>
        public override string ToString()
        {
            string str = "";
            for (int i = 0; i < storage.Length; i++)
            {
                DoubleLinkedList list = storage[i];
                if (list != null)
                {
                    str += "[";
                    for (int j = 0; j < list.Size(); j++)
                    {
                        Object[] tuple = (Object[])list.Get(j);
                        str += "{key:" + tuple[0] + ",value:" + tuple[1] + "}";
                        if (j != list.Size() - 1)
                        {
                            str += ",";
                        }
                    }

                    str += "]\n";
                }
                else
                {
                    str += "null\n";
                }
            }
            Console.WriteLine("size:" + Size());
            Console.WriteLine("limit:" + limit);
            return str;
        }

        /// <summary>
        /// 判断一个数是否为质数
        /// </summary>
        /// <param name="num"></param>
        /// <returns></returns>
        public bool IsPrime(int num)
        {
            if(num <= 1)
            {
                return false;
            }
            //有一种相对来说比较省力的方法。可以用最接近这个数字的自然数平方根以下的数字再去试除,效率会高不少
            int temp = (int)Math.Sqrt(num);
            for(int i = 2;i <= temp;i++)
            {
                if(num % i == 0)
                {
                    return false;
                }
            }
            return true;
        }

        /// <summary>
        /// 获取质数,如果当前数不是质数,依次累加,直到拿到质数为止
        /// </summary>
        /// <param name="num"></param>
        /// <returns></returns>
        public int GetPrime(int num)
        {
            while (!IsPrime(num))
            {
                num++;
            }
            return num;
        }

        /// <summary>
        /// 用于key的哈希化,位运算计算hashCode,效率更高
        /// </summary>
        /// <param name="str"></param>
        /// <returns></returns>
        public int GetHashCode(string str)
        {
            // 判断str不能为空或者""
            if (str == null || str.Length < 1)
            {
                throw new Exception("参数不合法");
            }
            // 定义hashcode
            long hashCode = 0;
            for (int i = 0; i < str.Length; i++)
            {
                hashCode = 31 * hashCode + str.ToCharArray(i, 1)[0];
            }
            // 位运算
            hashCode = hashCode & limit - 1;
            // 取模运算(效率相对低)
            // hashCode = hashCode % limit;
            return (int)hashCode;
        }

        /// <summary>
        /// 添加键值对,或根据键修改值
        /// </summary>
        /// <param name="key">键</param>
        /// <param name="value">值</param>
        public void Put(string key,Object value)
        {
            // 判断值是否合法
            if(value == null || value.Equals(""))
            {
                Console.Error.Write("传入的value不合法");
                return;
            }

            // 根据key计算下标值
            int index = GetHashCode(key);
            // 根据下标获取链表对象
            DoubleLinkedList bucket = storage[index];
            // 判断bucket是否为空
            if(bucket == null)
            {
                // 初始化
                bucket = new DoubleLinkedList();
                storage[index] = bucket;
            }
            // 判断bucket里面的元素是否含有key
            for (int i = 0; i < bucket.Size(); i++)
            {
                // 这里用一个数组来存放键值对,0》key,1》value
                Object[] tuple = (Object[])bucket.Get(i);
                if (tuple[0].Equals(key))
                {
                    tuple[1] = value;
                    return;
                }

            }
            Object[] tuple2 = { key, value };
            /*
             在java的HashMap里面新添加的键值对通常放在链表的前面,
             它认为新添加的元素可能获取的机会大一些,提高查询的效率
             */
            bucket.Insert(0, tuple2);
            //bucket.Append(tuple2);
            count++;

            // 判断元素的个数是否达到扩容的条件
            if((float)count/limit > LOAD_FACTOR)
            {
                // 对哈希表进行扩容
                //limit = GetPrime(2 * limit);
                // 重置哈希表
                Resize(2 * limit);
            }

        }

        /// <summary>
        /// 根据key获取存放的数据
        /// </summary>
        /// <param name="key">传入的key</param>
        /// <returns></returns>
        public Object Get(string key)
        {
            // 判断key是否合法
            if(key == null || key.Length < 1)
            {
                throw new Exception("传入的键不合法");
            }
            // 计算key的下标值
            int index = GetHashCode(key);

            // 获取对应的链表
            DoubleLinkedList bucket = storage[index];

            if(bucket != null)
            {
                for(int i = 0;i < bucket.Length; i++)
                {
                    Object[] tuple = (Object[])bucket.Get(i);
                    if (tuple[0].Equals(key))
                    {
                        return tuple[1];
                    }
                }
            }
            return null;
        }

        /// <summary>
        /// 根据键删除元素
        /// </summary>
        /// <param name="key">键</param>
        /// <returns>返回删除的元素的value</returns>
        public Object Remove(string key)
        {
            // 判断键是否合法
            if(key == null || key.Length < 0)
            {
                throw new Exception("参数不合法");
            }
            // 根据key计算下标
            int index = GetHashCode(key);
            // 根据下标获取链表
            DoubleLinkedList bucket = storage[index];
            if(bucket != null)
            {
                for(int i = 0;i < bucket.Size(); i++)
                {
                    Object[] tuple = (Object[])bucket.Get(i);
                    if (tuple[0].Equals(key))
                    {
                        bucket.Remove(tuple);
                        count--;
                        // 到达一定条件时缩小哈希表的容量,避免内存浪费
                        if(limit > INITIAL_CAPACITY && (float)count/limit < 1 - LOAD_FACTOR)
                        {
                            // 缩小哈希表的容量
                            Resize(limit / 2);
                        }
                        return tuple[1];
                    }
                }
            }
            return null;
        }

        /// <summary>
        /// 重置哈希表的容量
        /// </summary>
        /// <param name="newlimit">新容量</param>
        public void Resize(int newlimit)
        {
            // 获取旧的哈希表
            DoubleLinkedList[] oldStorage = storage;

            // 重置属性
            limit = newlimit;
            count = 0;
            storage = new DoubleLinkedList[limit];

            // 将原来的值放入新的storage
            for (int i = 0; i < oldStorage.Length; i++)
            {
                // 获取桶
                DoubleLinkedList bucket = oldStorage[i];
                if(bucket != null)
                {
                    for (int j = 0; j < bucket.Size(); j++)
                    {
                        Object[] tuple = (Object[])bucket.Get(j);
                        // 获取key
                        string key = (string)tuple[0];
                        // 获取value
                        Object value = tuple[1];
                        // 重新放入键值对
                        Put(key,value);
                     }
                }
            }
        }
        
        /// <summary>
        /// 判断哈希表是否为空
        /// </summary>
        /// <returns></returns>
        public bool IsEmpty()
        {
            return count == 0;
        }

        /// <summary>
        /// 获取哈希表的长度
        /// </summary>
        /// <returns></returns>
        public int Size()
        {
            return count;
        }
        
        // 测试
        public static void Main(string[] args)
        {

            HashTable hashTable = new HashTable(8);
        
            hashTable.Put("name", "why");
            hashTable.Put("age", 18);
            hashTable.Put("sex", "男");
            hashTable.Put("sex", "女");
            hashTable.Put("birthday", "19980422");
            hashTable.Put("address", "四川成都");
            hashTable.Put("IDCard", "3233232323213232");

            Console.WriteLine(hashTable);
            hashTable.Put("phone", "1612121212122");

            //hashTable.Resize(11);
            Console.WriteLine("--------------------------------");
            Console.WriteLine(hashTable);

            hashTable.Remove("name");
            hashTable.Remove("age");
            hashTable.Remove("phone");
            //hashTable.Remove("sex");
            //hashTable.Remove("address");
            Console.WriteLine("--------------------------------");
            Console.WriteLine(hashTable);
            Console.ReadKey();
        }
    }
}

6.总结

哈希表就是一个数组,数组的每个元素是一个单向链表; 在put( k, v )添加键值对时, 先根据键的hashCode计算数组的索引值(下标), 访问数组元素, 如果该元素为null, 创建一个新的结点保存到数组元素中; 如果数组元素不为null, 遍历链表的各个结点, 如果有某个结点的key与当前的键equals相等, 就使用新的值v替换结点原来的value值, 如果整个链表中所有结点的key都不匹配, 就创建一个新的结点插入到链表的头部。当哈希表的装载因子大于0.75时,保存原来的哈希表,扩大哈希表的初始容量为原来的2倍,将原来的哈希表中的每个数据项重新添加到扩容后的哈希表中。

在romove(k)时,先根据键的计算对应的索引值(下标),在根据下标访问数组元素,如果该元素为null,说明数组中没有该键值对,如果数组元素不为null,遍历链表的各个节点,如果存在这个key,将其对应的value值返回。

  • 8
    点赞
  • 48
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值