1、1本文根据他人博文整理而来,尊重原创。在前面的系列文章中,依次介绍了基于无序列表的顺序查找,基于有序数组的二分查找,平衡查找树,以及红黑树,下图是他们在平均以及最差情况下的时间复杂度:可以看到在时间复杂度上,红黑树在平均情况下插入,查找以及删除上都达到了 lgN 的时间复杂度。那么有没有查找效率更高的数据结构呢,答案就是本文接下来要介绍了散列表,也叫哈希表(Hash Table)什么是哈希表哈希表就是一种以 键- 值(key-indexed) 存储数据的结构,我们只要输入待查找的值即 key,即可查找到其对应的值。哈希的思路很简单,如果所有的键都是整数,那么就可以使用一个简单的无序数组来实现:
2、将键作为索引,值即为其对应的值,这样就可以快速访问任意键的值。这是对于简单的键的情况,我们将其扩展到可以处理更加复杂的类型的键。使用哈希查找有两个步骤:1. 使用哈希函数将被查找的键转换为数组的索引。在理想的情况下,不同的键会被转换为不同的索引值,但是在有些情况下我们需要处理多个键被哈希到同一个索引值的情况。所以哈希查找的第二个步骤就是处理冲突2. 处理哈希碰撞冲突。有很多处理哈希碰撞冲突的方法,本文后面会介绍拉链法和线性探测法。哈希表是一个在时间和空间上做出权衡的经典例子。如果没有内存限制,那么可以直接将键作为数组的索引。那么所有的查找时间复杂度为 O(1);如果没有时间限制,那么我们可以使
3、用无序数组并进行顺序查找,这样只需要2很少的内存。哈希表使用了适度的时间和空间来在这两个极端之间找到了平衡。只需要调整哈希函数算法即可在时间和空间上做出取舍。哈希函数哈希查找第一步就是使用哈希函数将键映射成索引。这种映射函数就是哈希函数。如果我们有一个保存 0-M 数组,那么我们就需要一个能够将任意键转换为该数组范围内的索引(0M-1)的哈希函数。哈希函数需要易于计算并且能够均匀分布所有键。比如举个简单的例子,使用手机号码后三位就比前三位作为 key 更好,因为前三位手机号码的重复率很高。再比如使用身份证号码出生年月位数要比使用前几位数要更好。在实际中,我们的键并不都是数字,有可能是字符串,还
4、有可能是几个值的组合等,所以我们需要实现自己的哈希函数。1. 正整数获取正整数哈希值最常用的方法是使用除留余数法。即对于大小为素数 M 的数组,对于任意正整数 k,计算 k 除以 M 的余数。M 一般取素数。2. 字符串将字符串作为键的时候,我们也可以将他作为一个大的整数,采用保留除余法。我们可以将组成字符串的每一个字符取值然后进行哈希,比如public int GetHashCode(string str)char s = str.ToCharArray();int hash = 0;for (int i = 0; i : SymbolTables where TKey : ICompara
5、ble, IEquatableprivate int M;/散列表大小private SequentSearchSymbolTable st;/public SeperateChainingHashSet(): this(997)public SeperateChainingHashSet(int m)this.M = m;5st = new SequentSearchSymbolTablem;for (int i = 0; i ();private int hash(TKey key)return (key.GetHashCode() public override TValue Get(T
6、Key key)return sthash(key).Get(key);public override void Put(TKey key, TValue value)sthash(key).Put(key, value);可以看到,该实现中使用 Get 方法来获取指定 key 的 Value 值,我们首先通过 hash 方法来找到 key 对应的索引值,即找到SequentSearchSymbolTable 数组中存储该元素的查找表,然后调用查找表的 Get 方法,根据 key 找到对应的 Value。 Put 方法用来存储键值对,首先通过 hash 方法找到改 key 对应的哈希值,然后找
7、到SequentSearchSymbolTable 数组中存储该元素的查找表,然后调用查找表的 Put 方法,将键值对存储起来。 hash 方法来计算 key 的哈希值, 这里首先通过取与 /符号表中键值对的总数private int M = 16;/线性探测表的大小private TKey keys;7private TValue values;public LinearProbingHashSet()keys = new TKeyM;values = new TValueM;private int hash(TKey key)return (key.GetHashCode() public
8、 override TValue Get(TKey key)for (int i = hash(key); keysi != null; i = (i + 1) % M)if (key.Equals(keysi) return valuesi; return default(TValue);public override void Put(TKey key, TValue value)int hashCode = hash(key);for (int i = hashCode; keysi != null; i = (i + 1) % M)if (keysi.Equals(key)/如果和已有
9、的 key 相等,则用新值覆盖valuesi = value;return;/插入keysi = key;valuesi = value;线性探查(Linear Probing)方式虽然简单,但是有一些问题,它会导致同类哈希的聚集。在存入的时候存在冲突,在查找的时候冲突依然存在。性能分析我们可以看到,哈希表存储和查找数据的时候分为两步,第一步为将键通过哈希函数映射为数组中的索引, 这个过程可以认为是只需要常数时间的。第二步是,如果出现哈希值冲突,如何解决,前面介绍了拉链法和线性探测法下面就这两种方法进行讨论:8对于拉链法,查找的效率在于链表的长度,一般的我们应该保证长度在 M/8M/2 之间,
10、如果链表的长度大于M/2,我们可以扩充链表长度。如果长度在 0M/8 时,我们可以缩小链表。对于线性探测法,也是如此,但是动态调整数组的大小需要对所有的值从新进行重新散列并插入新的表中。不管是拉链法还是散列法,这种动态调整链表或者数组的大小以提高查询效率的同时,还应该考虑动态改变链表或者数组大小的成本。散列表长度加倍的插入需要进行大量的探测, 这种均摊成本在很多时候需要考虑。哈希碰撞攻击我们知道如果哈希函数选择不当会使得大量的键都会映射到相同的索引上,不管是采用拉链法还是开放寻址法解决冲突,在后面查找的时候都需要进行多次探测或者查找, 在很多时候会使得哈希表的查找效率退化,而不再是常数时间。下
11、图清楚的描述了退化后的哈希表:哈希表攻击就是通过精心构造哈希函数,使得所有的键经过哈希函数后都映射到同一个或者几个索引上,将哈希表退化为了一个单链表,这样哈希表的各种操作,比如插入,查找都从 O(1)退化到了链表的查找操作,这样就会消耗大量的 CPU 资源,导致系统无法响应,从而达到拒绝服务供给(Denial of Service, Dos)的目的。之前由于多种编程语言的哈希算法的“非随机”而出现了 Hash 碰撞的 DoS 安全漏洞,在 ASP.NET 中也曾出现过这一问题。在.NET 中 String 的哈希值内部实现中,通过使用哈希值随机化来对这种问题进行了限制,通过对碰撞次数设置阈值,
12、超过该阈值就对哈希函数进行随机化,这也是防止哈希表退化的一种做法。下面是 BCL 中 string 类型的GetHashCode 方法的实现,可以看到,当碰撞超过一定次数的时候,就会开启条件编译,对哈希函数进行随机化。ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail), SecuritySafeCritical, _DynamicallyInvokablepublic override unsafe int GetHashCode()if (HashHelpers.s_UseRandomizedStringHash
13、ing)9return InternalMarvin32HashString(this, this.Length, 0L);fixed (char* str = (char*) this)char* chPtr = str;int num = 0x15051505;int num2 = num;int* numPtr = (int*) chPtr;int length = this.Length;while (length 2)num = (num 0x1b) numPtr0;num2 = (num2 0x1b) numPtr1;numPtr += 2;length -= 4;if (leng
14、th 0)num = (num 0x1b) numPtr0;return (num + (num2 * 0x5d588b65);.NET 中哈希的实现我们可以通过在线源码查看.NET 中 Dictionary,类型的实现,我们知道任何作为 key 的值添加到 Dictionary 中时,首先会获取 key 的 hashcode,然后将其映射到不同的 bucket 中去:public Dictionary(int capacity, IEqualityComparer comparer) if (capacity 0) Initialize(capacity);parer = comparer
15、? EqualityComparer.Default;在 Dictionary 初始化的时候,会如果传入了大小,会初始化 bucket 就是调用 Initialize 方法:private void Initialize(int capacity) int size = HashHelpers.GetPrime(capacity);buckets = new intsize;for (int i = 0; i = 0; i = entriesi.next) if (entriesi.hashCode = hashCode entriesi.value = value;version+;retu
16、rn; #if FEATURE_RANDOMIZED_STRING_HASHINGcollisionCount+;#endifint index;if (freeCount 0) index = freeList;freeList = entriesindex.next;freeCount-;else if (count = entries.Length)Resize();targetBucket = hashCode % buckets.Length;index = count;count+;entriesindex.hashCode = hashCode;entriesindex.next
17、 = bucketstargetBucket;entriesindex.key = key;entriesindex.value = value;11bucketstargetBucket = index;version+;#if FEATURE_RANDOMIZED_STRING_HASHINGif(collisionCount HashHelpers.HashCollisionThreshold Resize(entries.Length, true);#endif首先,根据 key 获取其 hashcode,然后将 hashcode 除以 backet 的大小取余映射到目标 backet
18、 中,然后遍历该 bucket 存储的链表,如果找到和 key 相同的值,如果不允许后添加的键与存在的键相同替换值(add) ,则抛出异常,如果允许,则替换之前的值,然后返回。如果没有找到,则将新添加的值放到新的 bucket 中,当空余空间不足的时候,会进行扩容操作(Resize),然后重新 hash 到目标 bucket。这里面需要注意的是 Resize 操作比较消耗资源。总结前面几篇文章先后介绍了基于无序列表的顺序查找,基于有序数组的二分查找,平衡查找树,以及红黑树,本篇文章最后介绍了查找算法中的最后一类即符号表又称哈希表,并介绍了哈希函数以及处理哈希冲突的两种方法:拉链法和线性探测法。
19、各种查找算法的最坏和平均条件下各种操作的时间复杂度如下图:12在实际编写代码中,如何选择合适的数据结构需要根据具体的数据规模,查找效率要求,时间和空间局限来做出合适的选择。希望本文以及前面的几篇文章对您有所帮助。13说明:本程序建立的哈希表示意图:哈希函数为对哈希表长取余源代码:cpp view plain copy1. /* 2. * 哈希表算法实现 3. * (c)copyright 2013,jdh 4. * All Right Reserved 5. *文件名 :main.c 6. *程序员 :jdh 7. */ 8. 9. #include 10. #include 11. 12.
20、/* 13. * 宏定义 14. */ 15. 16. /* 17. * 数据类型重定义 18. */ 19. 20. #define uint8_t unsigned char 21. #define uint16_t unsigned short 22. #define uint32_t unsigned long 23. 24. /* 25. * 哈希表长度 26. */ 1427. 28. #define HASH_TABLE_LEN 100 29. 30. /* 31. * 数据结构 32. */ 33. /链表节点 34. typedef struct _Link_Node 35.
21、 36. uint16_t id; 37. uint16_t data; 38. struct _Link_Node *next; 39. Link_Node,*Link_Node_Ptr; 40. 41. /哈希表头 42. typedef struct _Hash_Header 43. 44. struct _Link_Node *next; 45. Hash_Header,*Hash_Header_Ptr; 46. 47. /* 48. * 全局变量 49. */ 50. 51. /哈希表 52. Hash_Header_Ptr Hash_TableHASH_TABLE_LEN; 53.
22、 54. /* 55. * 函数 56. */ 57. 58. /* 59. * 哈希表函数 60. *说明 : 61. *1.用哈希函数生成 id 对应的哈希表中的位置 62. 输入:id 63. 返回: 位置 64. */ 65. 66. uint8_t hash_func(uint16_t id) 67. 68. uint8_t pos = 0; 69. 70. pos = id % HASH_TABLE_LEN; 71. 72. return pos; 73. 74. 75. /* 1576. * 初始化节点 77. *返回 :结点指针 78. */ 79. 80. Link_Node
23、_Ptr init_link_node(void) 81. 82. Link_Node_Ptr node; 83. 84. /申请节点 85. node = (Link_Node_Ptr) malloc(sizeof(Link_Node); 86. /初始化长度为 0 87. node-next = NULL; 88. 89. return node; 90. 91. 92. /* 93. * 初始化哈希表头结点 94. *返回哈希表头结点指针 95. */ 96. 97. Hash_Header_Ptr init_hash_header_node(void) 98. 99. Hash_Hea
24、der_Ptr node; 100. 101. /申请节点 102. node = (Hash_Header_Ptr) malloc(sizeof(Hash_Header); 103. /初始化长度为 0 104. node-next = NULL; 105. 106. return node; 107. 108. 109. 110. /* 111. * 哈希表初始化 112. *说明 : 113. *1.初始化哈希表 Hash_Table 114. *2.哈希表长度最大不能超过 256 115. */ 116. 117. void init_hash_table(void) 118. 119
25、. uint8_t i = 0; 120. 121. for (i = 0;i next = NULL; 16125. 126. 127. 128. /* 129. * 在哈希表增加节点 130. *说明 : 131. *1.在哈希表的某个链表末增加数据 132. 输入:new_node: 新节点 133. */ 134. 135. void append_link_node(Link_Node_Ptr new_node) 136. 137. Link_Node_Ptr node; 138. uint8_t pos = 0; 139. 140. /新节点下一个指向为空 141. new_nod
26、e-next = NULL; 142. 143. /用哈希函数获得位置 144. pos = hash_func(new_node-id); 145. 146. /判断是否为空链表 147. if (Hash_Tablepos-next = NULL) 148. 149. /空链表 150. Hash_Tablepos-next = new_node; 151. 152. else 153. 154. /不是空链表 155. /获取根节点 156. node = Hash_Tablepos-next; 157. 158. /遍历 159. while (node-next != NULL) 1
27、60. 161. node = node-next; 162. 163. 164. /插入 165. node-next = new_node; 166. 167. 168. 169. /* 170. * 在哈希表查询节点 171. *说明 : 172. *1.知道在哈希表某处的单链表中,并开始遍历. 173. *2.返回的是查询节点的前一个节点指针.这么做是为了做删除操作. 17174. 输入:pos:哈希表数组位置,从 0 开始计数 175. id:所需要查询节点的 id 176. root:如果是根节点,则*root = 1,否则为 0 177. 返回: 所需查询的节点的前一个节点指针,
28、如果是根节点则返回根节点,失败返回 0 178. */ 179. 180. Link_Node_Ptr search_link_node(uint16_t id,uint8_t *root) 181. 182. Link_Node_Ptr node; 183. uint8_t pos = 0; 184. 185. /用哈希函数获得位置 186. pos = hash_func(id); 187. 188. /获取根节点 189. node = Hash_Tablepos-next; 190. 191. /判断单链表是否存在 192. if (node = NULL) 193. 194. ret
29、urn 0; 195. 196. 197. /判断是否是根节点 198. if (node-id = id) 199. 200. /是根节点 201. *root = 1; 202. return node; 203. 204. else 205. 206. /不是根节点 207. *root = 0; 208. /遍历 209. while (node-next != NULL) 210. 211. if (node-next-id = id) 212. 213. return node; 214. 215. else 216. 217. node = node-next; 218. 219
30、. 220. 221. return 0; 222. 18223. 224. 225. /* 226. * 在哈希表删除节点 227. *说明 : 228. *1.删除的不是当前节点,而是当前节点后的一个节点 229. 输入:node: 删除此节点后面的一个节点 230. new_node:新节点 231. */ 232. 233. void delete_link_node(Link_Node_Ptr node) 234. 235. Link_Node_Ptr delete_node; 236. 237. /重定向需要删除的前一个节点 238. delete_node = node-next
31、; 239. node-next = delete_node-next; 240. 241. /删除节点 242. free(delete_node); 243. delete_node = NULL; 244. 245. 246. /* 247. * 在哈希表删除根节点 248. 输入:node: 根节点 249. */ 250. 251. void delete_link_root_node(Link_Node_Ptr node) 252. 253. uint8_t pos = 0; 254. 255. /用哈希函数获得位置 256. pos = hash_func(node-id); 2
32、57. 258. /哈希表头清空 259. if (node != NULL) 260. 261. Hash_Tablepos-next = node-next; 262. /删除节点 263. free(node); 264. node = NULL; 265. 266. 267. 268. /* 269. * 获得哈希表中所有节点数 270. 输入:node: 根节点 271. */ 19272. 273. uint16_t get_node_num(void) 274. 275. Link_Node_Ptr node; 276. uint16_t i = 0; 277. uint16_t
33、 num = 0; 278. 279. /遍历 280. for (i = 0;i next; 284. /遍历 285. while (node != NULL) 286. 287. num+; 288. node = node-next; 289. 290. 291. 292. return num; 293. 294. 295. /* 296. * 从哈希表中获得对应序号的节点 297. *参数 :index:序号.从 1 开始,最大值为节点总数值 298. * root:如果是根节点,则*root = 1,否则为 0 299. 返回: 所需查询的节点的前一个节点指针,如果是根节点则返回
34、根节点,失败返回 0 300. */ 301. 302. Link_Node_Ptr get_node_from_index(uint16_t index,uint8_t *root) 303. 304. Link_Node_Ptr node; 305. uint16_t i = 0; 306. uint16_t num = 0; 307. 308. /遍历 309. for (i = 0;i next; 313. /判断单链表是否存在 314. if (node = NULL) 315. 316. continue; 317. 318. 319. /根节点 320. num+; 20321.
35、 if (num = index) 322. 323. /是根节点 324. *root = 1; 325. return node; 326. 327. 328. /遍历 329. while (node-next != NULL) 330. 331. num+; 332. if (num = index) 333. 334. /不是根节点 335. *root = 0; 336. return node; 337. 338. node = node-next; 339. 340. 341. 342. return 0; 343. 344. 345. /* 346. * 删除 hash 表中
36、所有节点 347. */ 348. 349. void drop_hash() 350. 351. Link_Node_Ptr node; 352. uint16_t i = 0; 353. Link_Node_Ptr node_next; 354. 355. /遍历 356. for (i = 0;i next; 360. 361. while (1) 362. 363. /判断单链表是否存在 364. if (node = NULL) 365. 366. /不存在 367. Hash_Tablei-next = NULL; 368. break; 369. 21370. 371. /根节点下一个节点 372. node_next = node-next; 373. /删除根节点 374. free(node); 375. /重指定根节点 376. node = node_next; 377. 378. 379. 380. 381. /* 382. * 输出所有节点 383. */ 384. 385. void printf_hash() 386. 387. Link_Node_Ptr node; 3