经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » 程序设计 » 编程经验 » 查看文章
重生之数据结构与算法----哈希表
来源:cnblogs  作者:叫我安不理  时间:2025/3/7 9:11:58  对本文有异议

简介

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

那么问题来了,如何把hash的key转换成数组的index呢?

hash函数如何实现

hash函数的作用是把任意长度的输入(key)转化成固定长度的输出(index),通过hash函数,把对象转成一个固定且唯一的非负数整形
首先,我们需要为key寻找一个唯一标识,且最好是整数。对象在内存中的地址是一个很好的选择。
image
因为最高位编码的存在,hashcode有可能为负数。因此我们需要保证它为正数。
image
我们可以使用补码的原理,直接把最高位强制为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冲突,主流有两种常见解法。

  1. 拉链法
    image

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

  1. 开放寻址法
    image
    开放寻址法的思路是,当一个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
image

这里有个细微的区别,那就是拉链法的负载因子可以无限大,因为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. /// <summary>
  79. /// 删
  80. /// </summary>
  81. /// <param name="key"></param>
  82. public void Remove(int key)
  83. {
  84. var hash = Hash(key);
  85. var bucket = _tables[hash];
  86. if (bucket == null)
  87. return;
  88. //遍历整个链表,找到对应key/value
  89. //Log(N)
  90. KVNode node = null;
  91. foreach (var kv in bucket)
  92. {
  93. if (kv.Key.Equals(key))
  94. {
  95. node = kv;
  96. break;
  97. }
  98. }
  99. if (node == null)
  100. return;
  101. //在知道节点的前提下删除
  102. //Log(1)
  103. bucket.Remove(node);
  104. }
  105. /// <summary>
  106. /// 链表的节点,必须同时存储key,value
  107. /// 否则当hash冲突时,是不知道hash对应的value是哪一个的
  108. /// </summary>
  109. public class KVNode
  110. {
  111. public int Key { get; set; }
  112. public string Value { get; set; }
  113. public KVNode(int key, string value)
  114. {
  115. Key = key;
  116. Value = value;
  117. }
  118. }
  119. /// <summary>
  120. /// 从原理角度出发,当你返回keys/values时
  121. /// 只能给你一个全新的list
  122. /// https://www.cnblogs.com/lmy5215006/p/18712729
  123. /// </summary>
  124. public List<int> Keys
  125. {
  126. get
  127. {
  128. var list=new List<int>();
  129. foreach (var kv in _tables)
  130. {
  131. foreach (var node in kv)
  132. {
  133. list.Add(node.Key);
  134. }
  135. }
  136. return list;
  137. }
  138. }
  139. }

解法还有很多,你也可以用二维数组实现。

一个简单的开放寻址法

点击查看代码
  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. public void Remove(int key)
  48. {
  49. var index = Hash(key);
  50. //向后寻址,直到找到key
  51. while (index < _tables.Length && _tables[index] != null && _tables[index].Key != key)
  52. {
  53. index++;
  54. }
  55. //伪代码:删除该元素,并位移后面元素
  56. //难点2:删除操作比较复杂。你不能无脑移动后续元素。而是只能讲哈希冲突的区间移动。
  57. for (int i = index; i < _tables.Length; i++)
  58. {
  59. _tables[i] = _tables[i + 1];
  60. }
  61. _tables[_tables.Length] = default;
  62. }
  63. public class KVNode
  64. {
  65. public int Key { get; set; }
  66. public string Value { get; set; }
  67. public KVNode(int key, string value)
  68. {
  69. Key = key;
  70. Value = value;
  71. }
  72. }
  73. }

开放寻址有两个难点:
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.

如果选数组会受到扩容影响

image

一个简单的有序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. _head.Next = _tail;
  25. _tail.Prev = _head;
  26. }
  27. public KVNode? Get(T key)
  28. {
  29. if (_hashTable.ContainsKey(key))
  30. {
  31. return _hashTable[key];
  32. }
  33. return null;
  34. }
  35. public void Put(T key,K value)
  36. {
  37. var node = Get(key);
  38. if (node == null)
  39. {
  40. node = new KVNode(key, value);
  41. _hashTable.Add(key, node);
  42. //在新增时,排序
  43. var prev = _tail.Prev;
  44. var next = _tail;
  45. node.Prev = prev;
  46. node.Next = next;
  47. prev.Next = node;
  48. next.Prev = node;
  49. }
  50. else
  51. {
  52. _hashTable[key] = new KVNode(key, value);
  53. }
  54. }
  55. public void Remove(T key)
  56. {
  57. _hashTable.Remove(key, out var node);
  58. var next = node.Next;
  59. var prev = node.Prev;
  60. prev.Next = next;
  61. next.Prev = prev;
  62. node = null;
  63. }
  64. /// <summary>
  65. /// 从_head开始遍历,保证有序
  66. /// </summary>
  67. public List<T> Keys
  68. {
  69. get
  70. {
  71. var list = new List<T>();
  72. while (_head.Next != null&&_head.Next.Key!=null)
  73. {
  74. list.Add(_head.Next.Key);
  75. _head = _head.Next;
  76. }
  77. return list;
  78. }
  79. }
  80. public class KVNode
  81. {
  82. public T Key { get; set; }
  83. public K Value { get; set; }
  84. /// <summary>
  85. /// 空间换时间
  86. /// 维护他们插入的顺序,以实现key有序
  87. /// </summary>
  88. public KVNode Next { get; set; }
  89. public KVNode Prev { get; set; }
  90. public KVNode(T key, K value)
  91. {
  92. Key = key;
  93. Value = value;
  94. }
  95. }
  96. }
  1. 无序遍历
    image
  2. 有序遍历
    image

对比这两种遍历方式,我相信你能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. }

看上去已经完美了,但这里还有两个问题

  1. 时间复杂度退化为O(N)
    因为有循环
  2. 不均匀
    环形数组的查找方向是固定的,不管你向左还是向右。另一侧被选中的几率会更低。

那如果我不用环形数组,二次随机行不行?
答案依旧是不行

  1. public KVNode Random3()
  2. {
  3. var r = new Random(_tables.Count);
  4. var i = r.Next();
  5. var result = _tables[i];
  6. while (result == null)
  7. {
  8. //再随机一次,总能找到有用的
  9. i = r.Next();
  10. result = _tables[i];
  11. }
  12. return result;
  13. }

时间复杂依旧为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. }

问题好像无解了,我们能想到的办法都尝试了。还有其它办法吗?

终极蛇皮大招

正如我一直强调的一点,任何时间问题都可以靠空间换时间来解决。
如果上面讲的,使用双链表解决顺序访问的问题。那么我们也可以用双数组来解决随机访问的问题

  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. var lastItem = _keys[_keys.Count - 1];
  41. //要删除的元素与最后元素交换位置。
  42. //当然,这样的代价就是数组中的元素顺序会被打乱,
  43. //但是对于我们当前的场景来说,数组中的元素顺序并不重要,所以打乱了也无所谓。
  44. _keys[index] = lastItem;
  45. //取巧,实现array删除的O(1)
  46. _keys.RemoveAt(_keys.Count - 1);
  47. }
  48. /// <summary>
  49. /// 随机弹出一个key,O(1)
  50. /// </summary>
  51. /// <returns></returns>
  52. public T GetRandomKey()
  53. {
  54. var r = new Random(_keys.Count);
  55. var i = r.Next();
  56. return _keys[i];
  57. }
  58. }

原文链接:https://www.cnblogs.com/lmy5215006/p/18748028

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号