上一篇《TypeScript 数据结构与算法:字典》实现了 Typescript
中字典的数据结构与算法,本篇继续实现散列表
。
散列表
(Hash Map
,也叫哈希表
)是一种特殊的字典
实现。在一个普通的字典
中,通过键
直接来获取值
。但是在散列表
中,可以通过哈希函数
先将键
映射为一个内存地址,所以存储的结构由键 -> 值
变为了 键 -> 地址 -> 值
。所以在查询时,可以通过键
映射后的地址
快速取值,避免了耗费性能的迭代查值过程。

散列表
有一些在计算机科学中应用的例子。因为它是字典的一种实现,所以可以用来对数据库进行索引。当我们在关系型数据库(如 MySQL
、Microsoft SQL Server
、Oracle
,等等)中创建一个新的表时,同时创建一个索引可以更快地查询到记录的 key
。在这种情况下,散列表可以用来保存键和对表中记录的引用。
数据结构
注意:在 JS
引擎内部已经将 Map
和 Object
优化为了散列表。所以本篇只是实现类似的优化过程,并没有实际的优化效果。
哈希函数
因为转换出来的 hashCode
是一个内存地址
,所以这个哈希函数的设计最好能满足下面三个原则:
- 哈希值是一个相对比较短的非负整数;
- 相同的键计算储的哈希值必需相同;
- 不同的键计算出来的哈希值应该不同;
toStrFn 辅助方法
由于键
有可能是各种数据类型,所以第一步首先要把键
映射为统一的 String
数据类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
export function defaultToString(item: any): string { if (item === null) { return 'NULL'; } else if (item === undefined) { return 'UNDEFINED'; } else if (typeof item === 'string' || item instanceof String) { return `${item}`; } return item.toString(); }
|
loselose 函数
先从一个简单的哈希函数 loselose
开始,其实算法就是将字符串
各个位上的 UTF-16 Unicode 值
加起来,然后对 37
取余即为哈希值
:
1 2 3 4 5 6 7 8 9 10 11
| const loseloseHashCode = (key: K): number => { if (typeof key === 'number') { return key; } const tableKey = this.toStrFn(key); let hash = 0; for (let i = 0; i < tableKey.length; i++) { hash += tableKey.charCodeAt(i); } return hash % 37; };
|
djb2 函数
可以发现,上述的 loselose
哈希算法有一个很重大缺点,就是不同的源字符串导致出现相同哈希值的概率很高,比如 Jonathan
、Jamie
、Sue
和 Aethelwulf
会有相同的哈希值 5
。
所以改进一下算法,将 hash
初始值设置 5381
,每次迭代时将 hash
乘 33
再累加,最后对 1013
取余,使得出现重复哈希值得概率大幅度降低。
为什么是 DJB2
算法中为 5381
、33
、1013
,而为什么 losolose
算法是 0
、0
、37
,可以看stackOverflow上的一篇回答,简单来说,这些数都是一些幻数
,为了减少重复概率而设计的。
1 2 3 4 5 6 7 8 9 10 11
| const djb2HashCode = (key: K): number => { if (typeof key === 'number') { return key; } const tableKey = this.toStrFn(key); let hash = 5381; for (let i = 0; i < tableKey.length; i++) { hash = hash * 33 + tableKey.charCodeAt(i); } return hash % 1013; };
|
散列表
有了计算哈希值的算法,接下来就可以实现散列表
的数据结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
| import { defaultToString } from '../util';
export default class HashTable<K, V> { protected table: Map<number, V>;
constructor(protected toStrFn: (key: K) => string = defaultToString) { this.table = new Map(); }
private djb2HashCode(key: K): number { if (typeof key === 'number') { return key; } const tableKey = this.toStrFn(key); let hash = 5381; for (let i = 0; i < tableKey.length; i++) { hash = hash * 33 + tableKey.charCodeAt(i); } return hash % 1013; }
hashCode(key: K): number { return this.djb2HashCode(key); }
put(key: K, value: V): boolean { if (key != null && value != null) { const position = this.hashCode(key); this.table.set(position, value); return true; } return false; }
get(key: K): V { return this.table.get(this.hashCode(key)); }
remove(key: K): boolean { return this.table.delete(this.hashCode(key)); }
getTable(): Map<number, V> { return this.table; }
isEmpty(): boolean { return this.size() === 0; }
size(): number { return this.table.size; }
clear() { this.table.clear(); }
toString(): string { if (this.isEmpty()) { return ''; } let objStringList = []; for (const [hashCode, value] of this.table) { objStringList.push(`{${hashCode} => ${value}}`); } return objStringList.join(','); } }
|
这里的散列表实现的其实并不完整,因为即使将 loselose
算法改为使用了 DJB
算法,或者说即使现在非常著名的 MD5
、SHA
、CRC
哈希算法,也没办法避免这用哈希冲突。原因就是由多
映射到少
,必然会出现冲突。导致新添加的键值对如果重复时会覆盖之前添加的值,所以这里需要对算法进行改进。
分离链接散列表
分离链接法
(Separate Chaining Hash Table
)是对散列表数据结构的改进,为散列表的每一个 hashcode
创建一个链表
,并将键值对
存储在链表中,它是解决冲突的最简单的方法。

下面是分离链接散列表的实现(不能仅存储值
,需要存储整个键值对
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
| import { defaultToString } from '../util'; import LinkedList from './linked-list';
export default class HashTableSeparateChaining<K, V> { protected table: Map<number, LinkedList<{ key: K; value: V }>>;
constructor(protected toStrFn: (key: K) => string = defaultToString) { this.table = new Map(); }
private loseloseHashCode(key: K): number { if (typeof key === 'number') { return key; } const tableKey = this.toStrFn(key); let hash = 0; for (let i = 0; i < tableKey.length; i++) { hash += tableKey.charCodeAt(i); } return hash % 37; }
hashCode(key: K): number { return this.loseloseHashCode(key); }
put(key: K, value: V): boolean { if (key != null && value != null) { const position = this.hashCode(key);
if (this.table.get(position) == null) { this.table.set(position, new LinkedList<{ key: K; value: V }>()); } this.table.get(position).push({ key, value }); return true; } return false; }
get(key: K): V { const position = this.hashCode(key); const linkedList = this.table.get(position); if (linkedList != null && !linkedList.isEmpty()) { let current = linkedList.getHead(); while (current != null) { if (current.element.key === key) { return current.element.value; } current = current.next; } } return undefined; }
remove(key: K): boolean { const position = this.hashCode(key); const linkedList = this.table.get(position); if (linkedList != null && !linkedList.isEmpty()) { let current = linkedList.getHead(); while (current != null) { if (current.element.key === key) { linkedList.remove(current.element); if (linkedList.isEmpty()) { this.table.delete(position); } return true; } current = current.next; } } return false; }
isEmpty(): boolean { return this.size() === 0; }
size(): number { let count = 0; for (const [hashCode, linkedList] of this.table) { count += linkedList.size(); } return count; }
clear() { this.table.clear(); }
getTable() { return this.table; }
toString(): string { if (this.isEmpty()) { return ''; }
let objStringList = []; for (const [hashCode, linkedList] of this.table) { let node = linkedList.getHead(); while (node) { objStringList.push(`{${node.element.key} => ${node.element.value}}`); node = node.next; } } return objStringList.join(','); } }
|
线性探查散列表
另一种解决冲突的方法是线性探查
(Linear Probing Hash Table
)。之所以称作线性,是因为它处理冲突的方法是将键值对
直接存储到表中,而不是像分离链接一样存储在单独的数据结构中。
当想向表中添加一个新键值对
的时候,如果索引为 hashCode
的位置已经被占据了,就尝试 hashCode + 1
的位置。如果 hashCode + 1
的位置也被占据了,就尝试 hashCode + 2
的位置,以此类推,直到在散列表中找到一个空闲的位置。

在删除的时候,需要检查是否有必要将后面的键值对
向前挪动移动,避免出现空位置。下图展现了这个过程:

下面是线性探查散列表
的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
| import { defaultToString } from '../util';
export default class HashTableLinearProbing<K, V> { protected table: Map<number, { key: K; value: V }>;
constructor(protected toStrFn: (key: K) => string = defaultToString) { this.table = new Map(); }
private loseloseHashCode(key: K): number { if (typeof key === 'number') { return key; } const tableKey = this.toStrFn(key); let hash = 0; for (let i = 0; i < tableKey.length; i++) { hash += tableKey.charCodeAt(i); } return hash % 37; }
hashCode(key: K): number { return this.loseloseHashCode(key); }
put(key: K, value: V): boolean { if (key != null && value != null) { const position = this.hashCode(key);
if (this.table.get(position) == null) { this.table.set(position, { key, value }); } else { let index = position + 1; while (this.table.get(index) != null) { index++; } this.table.set(index, { key, value }); } return true; } return false; }
get(key: K): V { const position = this.hashCode(key);
if (this.table.get(position) != null) { if (this.table.get(position).key === key) { return this.table.get(position).value; } let index = position + 1; while ( this.table.get(index) != null && this.table.get(index).key !== key ) { index++; } if (this.table.get(index) != null && this.table.get(index).key === key) { return this.table.get(position).value; } } return undefined; }
remove(key: K): boolean { const position = this.hashCode(key);
if (this.table.get(position) != null) { if (this.table.get(position).key === key) { this.table.delete(position); this.verifyRemoveSideEffect(key, position); return true; } let index = position + 1; while ( this.table.get(index) != null && this.table.get(index).key !== key ) { index++; } if (this.table.get(index) != null && this.table.get(index).key === key) { this.table.delete(index); this.verifyRemoveSideEffect(key, index); return true; } } return false; }
private verifyRemoveSideEffect(key: K, removedPosition: number) { const hash = this.hashCode(key); let index = removedPosition + 1; while (this.table.get(index) != null) { const posHash = this.hashCode(this.table.get(index).key); if (posHash <= hash || posHash <= removedPosition) { this.table.set(removedPosition, this.table.get(index)); this.table.delete(index); removedPosition = index; } index++; } }
isEmpty(): boolean { return this.size() === 0; }
size(): number { return this.table.size; }
clear() { this.table.clear(); }
getTable(): Map<number, { key: K; value: V }> { return this.table; }
toString(): string { if (this.isEmpty()) { return ''; }
let objStringList = []; for (const [hashCode, { key, value }] of this.table) { objStringList.push(`{${key} => ${value}}`); } return objStringList.join(','); } }
|
下一篇来分析树
。
前端记事本,不定期更新,欢迎关注!