找回密码
 立即注册
首页 业界区 业界 重生之数据结构与算法----哈希表

重生之数据结构与算法----哈希表

郗燕岚 2025-6-1 23:34:16
简介

hash的基本原理,可以理解为一个加强版的数组。为什么这么说呢,数组通过index来实现随机访问Log(1),而hash的key也是类似,把key理解为index,本质上还是一个基于数组的随机访问。
那么问题来了,如何把hash的key转换成数组的index呢?
hash函数如何实现

hash函数的作用是把任意长度的输入(key)转化成固定长度的输出(index),通过hash函数,把对象转成一个固定且唯一的非负数整形
首先,我们需要为key寻找一个唯一标识,且最好是整数。对象在内存中的地址是一个很好的选择。
1.png

因为最高位编码的存在,hashcode有可能为负数。因此我们需要保证它为正数。
2.png

我们可以使用补码的原理,直接把最高位强制为0.
  1.         static void Main(string[] args)
  2.         {
  3.             var a1 = "sss".GetHashCode();
  4.             a1 = a1 & 0x7fffffff;
  5.             Console.WriteLine($"hashcode={a1}");
  6.             Console.ReadLine();
  7.         }
复制代码
有了一个唯一的正整数,看上去万事大吉了。但还有一个缺点,生成的正整数太大了。如果使用,会创建一个巨大的数组,这明显不可取。
所以这个时候,我们需要对它进行瘦身,参考上面讲到的环形数组,我们使用求模来保证key在一个合理的范围
  1.         static void Main(string[] args)
  2.         {
  3.             int[] arr = new int[] { 1, 2, 3, 4, 5, 6, 7 };
  4.             var a1 = "sss".GetHashCode();
  5.             a1 = a1 & 0x7fffffff;
  6.             Console.WriteLine($"hashcode={a1}");
  7.             a1 = a1 % arr.Length;
  8.             Console.WriteLine($"a1={a1}");
  9.             Console.ReadLine();
  10.         }
复制代码
求模也比较消耗性能,正经的类库会使用位运算来提高性能,我只是举个例子。
hash冲突

上面简单描述了key如何转换为index过程,如果两个key得到了相同的index,这种问题就叫做hash冲突。
hash冲突无法避免,取模的过程相当于压缩。压缩就必定带来信息损失,信息损失肯定无法一比一还原信息,所以冲突是无法避免的。
面对hash冲突,主流有两种常见解法。

  • 拉链法
    3.png

拉链法的思路是Arr不存储key/Value,而是存储一个链表的地址 。如果多个key映射到同一个index,那么存入这个链表,来解决冲突。

  • 开放寻址法
    4.png

    开放寻址法的思路是,当一个key发现自己的index被占了,它就index+1,直到找到位置为止。
负载因子(Load Factor)

虽然拉链法与开放寻址法解决了hash冲突,但也带来了新的问题。那就是性能下降,尤其是hash冲突严重的时候。
以拉链法举例,hash冲突越严重,链表长度越长。众所周知,链表的查询复杂度为Log(N),因此hash表的查找复杂度取决于链表的长度。
开放寻址法同理可得,你也同样需要遍历整个数组,因为你不知道这个key是真的不存在还是在下一个位置.这个过程中的时间复杂度也是Log(N)
因此,loadFactor应运而生。负载因子代表的是一个hash table装满的程度,负载因子越大,说明key/value越多,越多则hash冲突的可能性越大,从而查找复杂度也越高。
因此当hash table达到负载因子的临界点时,会进行扩容。扩容的过程中会将底层的Array扩大,并对所有对象重新取模,重新分配Index。
负载因子计算公式:size / table.length
5.png

这里有个细微的区别,那就是拉链法的负载因子可以无限大,因为Array并不存储key/value。而使用开发寻址法,则不能超过1。这是它们的原理导致的。
不要被高大上的方法名所忽悠住,本质上一个横向拓展,一个是垂直拓展。
一个简单的拉链法

点击查看代码
  1.     /// <summary>
  2.     /// 拉链法hashtable
  3.     /// 不考虑负载因子与动态扩容的问题
  4.     /// </summary>
  5.     public class ChainingHashTableSimple
  6.     {
  7.         public static void Run()
  8.         {
  9.             var ht = new ChainingHashTableSimple(10);
  10.             ht.Put(1, "value1");
  11.             ht.Put(1, "value2");
  12.             ht.Put(11, "value11");
  13.             ht.Remove(1);
  14.         }
  15.         //一个链表数组,每个元素都是一个链表
  16.         private LinkedList<KVNode>[] _tables;
  17.         public ChainingHashTableSimple(int capactity)
  18.         {
  19.             _tables=new LinkedList<KVNode>[capactity];
  20.         }
  21.         /// <summary>
  22.         /// 简单取模,方便模拟hash冲突
  23.         /// 比如1跟11的hash值都是1
  24.         /// </summary>
  25.         /// <param name="key"></param>
  26.         /// <returns></returns>
  27.         private int Hash(int key)
  28.         {
  29.             return key % _tables.Length ;
  30.         }
  31.         public KVNode? Get(int key)
  32.         {
  33.             var hash = Hash(key);
  34.             //找到hash对应的链表
  35.             var bucket = _tables[hash];
  36.             if (bucket == null)
  37.                 return null;
  38.             //遍历整个链表,找到对应key/value
  39.             //Log(N)
  40.             KVNode node = null;
  41.             foreach (var kv in bucket)
  42.             {
  43.                 if (kv.Key.Equals(key))
  44.                 {
  45.                     node= kv;
  46.                     break;
  47.                 }
  48.             }
  49.             return node;
  50.         }
  51.         /// <summary>
  52.         /// 增/改
  53.         /// </summary>
  54.         /// <param name="key"></param>
  55.         /// <param name="value"></param>
  56.         public void Put(int key, string value)
  57.         {
  58.             var hash=Hash(key);
  59.             var bucket = _tables[hash];
  60.             //初始化链表
  61.             if (bucket == null)
  62.             {
  63.                 bucket = new LinkedList<KVNode>();
  64.                 _tables[hash] = bucket;
  65.             }
  66.             var node = Get(key);
  67.             //新增 or 修改
  68.             //Log(1)
  69.             if (node == null)
  70.             {
  71.                 bucket.AddLast(new KVNode(key,value));
  72.             }
  73.             else
  74.             {
  75.                 node.Value= value;
  76.             }
  77.             
  78.         }
  79.         /// <summary>
  80.         /// 删
  81.         /// </summary>
  82.         /// <param name="key"></param>
  83.         public void Remove(int key)
  84.         {
  85.             var hash = Hash(key);
  86.             var bucket = _tables[hash];
  87.             if (bucket == null)
  88.                 return;
  89.             //遍历整个链表,找到对应key/value
  90.             //Log(N)
  91.             KVNode node = null;
  92.             foreach (var kv in bucket)
  93.             {
  94.                 if (kv.Key.Equals(key))
  95.                 {
  96.                     node = kv;
  97.                     break;
  98.                 }
  99.             }
  100.             if (node == null)
  101.                 return;
  102.             //在知道节点的前提下删除
  103.             //Log(1)
  104.             bucket.Remove(node);
  105.         }
  106.         /// <summary>
  107.         /// 链表的节点,必须同时存储key,value
  108.         /// 否则当hash冲突时,是不知道hash对应的value是哪一个的
  109.         /// </summary>
  110.         public class KVNode
  111.         {
  112.             public int Key { get; set; }
  113.             public string Value { get; set; }
  114.             public KVNode(int key, string value)
  115.             {
  116.                 Key = key;
  117.                 Value = value;
  118.             }
  119.         }
  120.         /// <summary>
  121.         /// 从原理角度出发,当你返回keys/values时
  122.         /// 只能给你一个全新的list
  123.         /// https://www.cnblogs.com/lmy5215006/p/18712729
  124.         /// </summary>
  125.         public List<int> Keys
  126.         {
  127.             get
  128.             {
  129.                 var list=new List<int>();
  130.                 foreach (var kv in _tables)
  131.                 {
  132.                     foreach (var node in kv)
  133.                     {
  134.                         list.Add(node.Key);
  135.                     }
  136.                 }
  137.                 return list;
  138.             }
  139.         }
  140.     }
复制代码
解法还有很多,你也可以用二维数组实现。
一个简单的开放寻址法

点击查看代码
  1.     public class LinearProbingHashTableSimple
  2.     {
  3.         public static void Run()
  4.         {
  5.             var hash = new LinearProbingHashTableSimple();
  6.             hash.Put(1, "value1");
  7.             hash.Put(11, "value11");
  8.             hash.Put(21, "value21");
  9.             hash.Put(31, "value31");
  10.             hash.Remove(21);
  11.         }
  12.         private KVNode[] _tables=new KVNode[10];
  13.         private int Hash(int key)
  14.         {
  15.             return key % _tables.Length;
  16.         }
  17.         public string Get(int key)
  18.         {
  19.             var index = Hash(key);
  20.             //开放寻址,从idex向后找"坑位"
  21.             // 难点1:这里仅仅实现先后查找,如果数组满了。
  22.             // 我们需要从头开始寻找。这就得利用到之前说的环形数组
  23.             while (index < _tables.Length && _tables[index] != null && _tables[index].Key != key)
  24.             {
  25.                 index++;
  26.             }
  27.             return _tables[index].Value;
  28.         }
  29.         public void Put(int key,string value)
  30.         {
  31.             var index=Hash(key);
  32.             var node = _tables[index];
  33.             if (node == null)
  34.             {
  35.                 _tables[index] = new KVNode(key, value);
  36.             }
  37.             else
  38.             {
  39.                 //开放寻址,从idex向后找对应的key
  40.                 while (index < _tables.Length && _tables[index] != null && _tables[index].Key != key)
  41.                 {
  42.                     index++;
  43.                 }
  44.                 _tables[index] = new KVNode(key, value);
  45.             }
  46.         }
  47.         
  48.         public void Remove(int key)
  49.         {
  50.             var index = Hash(key);
  51.             //向后寻址,直到找到key
  52.             while (index < _tables.Length && _tables[index] != null && _tables[index].Key != key)
  53.             {
  54.                 index++;
  55.             }
  56.             //伪代码:删除该元素,并位移后面元素
  57.             //难点2:删除操作比较复杂。你不能无脑移动后续元素。而是只能讲哈希冲突的区间移动。
  58.             for (int i = index; i < _tables.Length; i++)
  59.             {
  60.                 _tables[i] = _tables[i + 1];
  61.             }
  62.             _tables[_tables.Length] = default;
  63.         }
  64.         public class KVNode
  65.         {
  66.             public int Key { get; set; }
  67.             public string Value { get; set; }
  68.             public KVNode(int key, string value)
  69.             {
  70.                 Key = key;
  71.                 Value = value;
  72.             }
  73.         }
  74.     }
复制代码
开放寻址有两个难点:
1:是查找时要利用环形数组来实现头尾遍历
2:在_tables中删除元素时,可以进行类似数组的数据搬移操作,把后面的元素往前挪,保证元素的连续性。但你不能无脑搬移,你只能搬迁当前hash冲突的range
2.1: 如果你不想搬移,可以用一个特殊的占位符来标记,但随着时间的推移,不断的删除插入会导致”大量碎片“。影响get的效率。
基于这个原因,目前大多数编程语言实现hash table. 都使用拉链法。这样维护起来足够简单,负载因子也可以无限大。
个人认为,他们是替代关系,而不是平行关系。
Hash表的变种:双链表加强哈希表

众所周知,哈希表中键的遍历顺序是无序的。是核心原始是因为,hash函数对key进行映射时,有一个因子是你底层数组的长度,也就是一个取模的过程。
但因为动态扩容的存在,所以底层数组的长度是不定的。在扩容的过程中,key的哈希值可能变化,即这个key/value存储在table的索引变了,所以遍历结果的顺序就和之前不一样了.
那如果需要有序的遍历hash table怎么办?
在数据结构与算法中,只要你愿意拿空间换,Log(1) & Sort 都可以兼得!
所以我们的思路就是在不改变hash table 复杂度的前提下,又能够维护排序,又不受扩容影响。那我们只有一个选择,那就是使用链表加强hash.
如果选数组会受到扩容影响
6.png

一个简单的有序hash table

点击查看代码
  1. public class ChainingHashTablePro<T,K>
  2. {
  3.     public static void Run()
  4.     {
  5.         var hash = new ChainingHashTablePro<string, string>();
  6.         hash.Put("aaa", "value1");
  7.         hash.Put("bbb", "value2");
  8.         hash.Put("ccc", "value3");
  9.         hash.Put("ddd", "value4");
  10.         hash.Put("aaa", "value5");
  11.         hash.Remove("ccc");
  12.         foreach (var item in hash.Keys)
  13.         {
  14.             Console.WriteLine(item);
  15.         }
  16.     }
  17.     private KVNode _head, _tail;
  18.     private Dictionary<T, KVNode> _hashTable;
  19.     public ChainingHashTablePro()
  20.     {
  21.         _head = new KVNode(default, default);
  22.         _tail = new KVNode(default, default);
  23.         _hashTable = new Dictionary<T, KVNode>();
  24.         
  25.         _head.Next = _tail;
  26.         _tail.Prev = _head;
  27.     }
  28.     public KVNode? Get(T key)
  29.     {
  30.         if (_hashTable.ContainsKey(key))
  31.         {
  32.             return _hashTable[key];
  33.         }
  34.         return null;
  35.     }
  36.     public void Put(T key,K value)
  37.     {
  38.         var node = Get(key);
  39.         if (node == null)
  40.         {
  41.             node = new KVNode(key, value);
  42.             _hashTable.Add(key, node);
  43.             //在新增时,排序
  44.             var prev = _tail.Prev;
  45.             var next = _tail;
  46.             node.Prev = prev;
  47.             node.Next = next;
  48.             prev.Next = node;
  49.             next.Prev = node;
  50.         }
  51.         else
  52.         {
  53.             _hashTable[key] = new KVNode(key, value);
  54.         }
  55.     }
  56.     public void Remove(T key)
  57.     {
  58.         _hashTable.Remove(key, out var node);
  59.         var next = node.Next;
  60.         var prev = node.Prev;
  61.         prev.Next = next;
  62.         next.Prev = prev;
  63.         node = null;
  64.     }
  65.     /// <summary>
  66.     /// 从_head开始遍历,保证有序
  67.     /// </summary>
  68.     public List<T> Keys
  69.     {
  70.         get
  71.         {
  72.             var list = new List<T>();
  73.             while (_head.Next != null&&_head.Next.Key!=null)
  74.             {
  75.                 list.Add(_head.Next.Key);
  76.                 _head = _head.Next;
  77.             }
  78.             return list;
  79.         }
  80.     }
  81.     public class KVNode
  82.     {
  83.         public T Key { get; set; }
  84.         public K Value { get; set; }
  85.         /// <summary>
  86.         /// 空间换时间
  87.         /// 维护他们插入的顺序,以实现key有序
  88.         /// </summary>
  89.         public KVNode Next { get; set; }
  90.         public KVNode Prev { get; set; }
  91.         public KVNode(T key, K value)
  92.         {
  93.             Key = key;
  94.             Value = value;
  95.         }
  96.     }
  97. }
复制代码

  • 无序遍历
    7.png

  • 有序遍历
    8.png

对比这两种遍历方式,我相信你能get到有序的精髓。
Hash表的变种:数组加强哈希表

如果客户有一个需求,那就是让你在hash table中返回一个随机的key.我们应该怎么弄?
随机key需要均衡随机
开放寻址法思路

链表的底层是数组,很容易想到,数组是最适合随机读取的。那么我们只需要随机一个数作为一个index,似乎问题就迎刃而解了。
  1.         private List<KVNode> _tables;
  2.         public KVNode Random()
  3.         {
  4.             var r = new Random(_tables.Count);
  5.             var i = r.Next();
  6.             return _tables[i];
  7.         }
复制代码
这个前提是数组中没有空洞,比如[1,2,3,4,5],就没有问题.
但如果你的数组是[1,null,3,null,4,5],而你的随机index好死不死的随机到了1。这时候咋办?
根据前面几篇文章的套路,你会想到利用环形数组来实现线性查找。
  1.         private List<KVNode> _tables;
  2.         public KVNode Random2()
  3.         {
  4.             var r = new Random(_tables.Count);
  5.             var i = r.Next();
  6.             var result = _tables[i];
  7.             //环形数组,找到not null
  8.             while (result == null)
  9.             {
  10.                 i = (i + 1) % _tables.Count;
  11.                 result = _tables[i];
  12.             }
  13.             return result;
  14.         }
复制代码
看上去已经完美了,但这里还有两个问题

  • 时间复杂度退化为O(N)
    因为有循环
  • 不均匀
    环形数组的查找方向是固定的,不管你向左还是向右。另一侧被选中的几率会更低。
那如果我不用环形数组,二次随机行不行?
答案依旧是不行
  1.         public KVNode Random3()
  2.         {
  3.             var r = new Random(_tables.Count);
  4.             var i = r.Next();
  5.             var result = _tables[i];
  6.             
  7.             while (result == null)
  8.             {
  9.                 //再随机一次,总能找到有用的
  10.                 i = r.Next();
  11.                 result = _tables[i];
  12.             }
  13.             return result;
  14.         }
复制代码
时间复杂依旧为O(N),因为还是有随机到null的可能。
到目前为止,我们陷入了死胡同。让我们换个思路,用拉链法看能不能行。
拉链法则思路

如果你用拉链法,那你就算踢到铁板了
  1.         private LinkedList<KVNode>[] _tables;
  2.         public KVNode Random()
  3.         {
  4.             var r = new Random(_tables.Length);
  5.             var i = r.Next();
  6.             //bucket是链表,做不到随机访问。只能顺序访问。
  7.                         //时间复杂度O(N)
  8.             var bucket = _tables[i];
  9.             
  10.         }
复制代码
问题好像无解了,我们能想到的办法都尝试了。还有其它办法吗?
终极蛇皮大招

正如我一直强调的一点,任何时间问题都可以靠空间换时间来解决。
如果上面讲的,使用双链表解决顺序访问的问题。那么我们也可以用双数组来解决随机访问的问题
  1.     public class HashTableSimple<T,K>
  2.     {
  3.         public static void Run()
  4.         {
  5.             var hashPro = new HashTableSimple<string, string>();
  6.             hashPro.Put("aaa", "value1");
  7.             hashPro.Put("bbb", "value2");
  8.             hashPro.Put("ccc", "value3");
  9.             hashPro.Put("ddd", "value4");
  10.             hashPro.Put("aaa", "value5");
  11.             hashPro.Remove("ccc");
  12.         }
  13.         private Dictionary<T, K> _hash=new Dictionary<T, K>();
  14.         /// <summary>
  15.         /// 空间换时间
  16.         /// 用一个数组来存储所有的key
  17.         /// </summary>
  18.         private List<T> _keys=new List<T>();
  19.         public void Put(T key,K value)
  20.         {
  21.             if (_hash.ContainsKey(key))
  22.             {
  23.                 _hash[key] = value;
  24.             }
  25.             else
  26.             {
  27.                 _hash.Add(key, value);
  28.                 _keys.Add(key);
  29.             }
  30.         }
  31.         public void Remove(T key)
  32.         {
  33.             _hash.Remove(key);
  34.             //如果key位于数组中间,会涉及到移动元素。O(N)
  35.             //面对随机访问的场景,有一种"奇技淫巧"
  36.             //_keys.Remove(key);
  37.             //要删除key的index
  38.             var index= _keys.IndexOf(key);
  39.             
  40.             //找到最后一个元素
  41.             var lastItem = _keys[_keys.Count - 1];
  42.             //要删除的元素与最后元素交换位置。
  43.             //当然,这样的代价就是数组中的元素顺序会被打乱,
  44.             //但是对于我们当前的场景来说,数组中的元素顺序并不重要,所以打乱了也无所谓。
  45.             _keys[index] = lastItem;
  46.             //取巧,实现array删除的O(1)
  47.             _keys.RemoveAt(_keys.Count - 1);
  48.         }
  49.         /// <summary>
  50.         /// 随机弹出一个key,O(1)
  51.         /// </summary>
  52.         /// <returns></returns>
  53.         public T GetRandomKey()
  54.         {
  55.             var r = new Random(_keys.Count);
  56.             var i = r.Next();
  57.             return _keys[i];
  58.         }
  59.     }
复制代码
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册