前言
最近在研究算法,刚好看到算法的散列表(Hash table,也叫哈希表), 也确实是一个很有意思的东西。
一般散列表都是用来快速判断一个元素是否出现集合里,而当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构:
- 数组
- set (集合)
- map(映射)
看到了 Map 和 Set 都和散列表有着莫名的关系,我发现虽然之前这些 ES6 的语法已经用了很多次了,但是都没有花时间去深究。正好学习了散列表,那就去好好研究一下他们的底层原理,也去试着手写一个简易的 Map,并实现他的所有方法,开干!
散列表
首先,讲讲算法中的散列表。散列算法的作用是尽可能快地在数据结构中找到一个值。如果要查询一个数据通过枚举的话时间复杂度是 O(n),而使用散列函数可以直接通过索引快速检索到该值,只需要 O(1) 就可以做到。
散列函数可以通过 hashCode 方法获取 key 参数的位置,也就是通过特定编码方式将不同数据格式的 key 转化为不同的数值,然后可以根据数值取到相对应的值。但是如果 hashCode 得到的数值大于散列表的大小,可以该数值做一个取模的操作,这样就可以规避操作数超过数值变量最大表示范围的风险。
冲突解决
但是,有时候一些键会有相同的散列值,就不可避免的产生冲突,一般解决冲突有两种主要的方法, 拉链法和线性探查。
- 拉链法:是解决冲突的最简单的方法,为散列表的每一个位置创建一个链表并将元素存储在里面,也就是所谓的数组 + 链表的形式,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
- 线性探查:是另一种解决冲突的有效方法,而之所以叫线性,因为它处理冲突的方法是将元素直接存储到表中,而不是单独的数据结构中。当想要添加一个新元素时,如果对应的位置被占据,就迭代散列表,直到找到一个空闲的位置。如果使用线性探查的话,需要注意的一个问题是数组的可用位置可能会被用完,所以可能还需要创建一个更大的数组并且将元素复制到新数组中去。
一个良好的散列函数是由几个方面构成的:插入和检索元素的时间,以及较低的冲突可能性。
Map
Map 介绍
我们平时常见的三种哈希结构就是数组、set 和 map,接下来看看这次的主角 map。
Map
对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者原始值) 都可以作为一个键或一个值。
传统的 JavaScript 对象 Object 用字符串当作键,这给它的使用带来了很大的限制。
为了解决这个问题,ES6 提供了新的 Map 数据结构。与 Object 只能使用数值、字符串或者符号作为键不同,Map 可以使用任何 JS 数据类型作为键,也就是说“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。而 Map 内部使用 SameValueZero 比较操作,基本上相当于使用严格对象相等的标准来检查键的匹配性。
与 Object 类似,映射的值是没有限制的,但是相较于 Object,Map 是一种更完善的 Hash 结构实现。如果你需要“键值对”的数据结构,Map 比 Object 更合适。
Map 方法使用
Map 属性和操作方法
Map 结构的实例有以下属性和操作方法。
属性/方法 | 作用 |
---|---|
size 属性 | size 是可访问属性,用于返回 一个 Map 对象的成员数量。 |
set(key, value) | set 方法设置键名 key 对应的键值为 value,然后返回整个 Map 结构。如果 key 已经有值,则键值会被更新,否则就新生成该键。 |
get(key) | get 方法读取 key 对应的键值,如果找不到 key ,返回 undefined。 |
has(key) | has 方法返回一个布尔值,表示某个键是否在当前 Map 对象之中。 |
delete(key) | delete 方法删除某个键,返回 true 。如果删除失败,返回 false。 |
clear() | clear 方法清除所有成员,没有返回值。 |
Map 遍历方法
方法 | 作用 |
---|---|
keys() | keys() 返回一个引用的 Iterator 对象。它包含按照顺序插入 Map 对象中每个元素的key值。 |
values() | values() 方法返回一个新的Iterator对象。它包含按顺序插入Map对象中每个元素的value值。 |
entries() | entries() 方法返回一个新的包含 [key, value] 对的 Iterator 对象,返回的迭代器的迭代顺序与 Map 对象的插入顺序相同。 |
forEach() | forEach() 方法按照插入顺序依次对 Map 中每个键/值对执行一次给定的函数 |
手写一个 Map
接下来就顺着 map 的属性和方法手写一个简易版 map。
初始化
首先定义一个 MyMap,然后在其原型链上创建 init 方法开执行初始化操作,当然也可以使用 class 来创建 MyMap。
function MyMap() {
this.init()
}
MyMap.prototype.init = function () {
// 散列表长度
this.size = 0;
// bucket 为散列表结构:数组 + 链表,初始化时每个链表的 next 指向头指针
this.bucket = new Array(8).fill(0).map(() => {
return {
next: null
}
});
};
const map = new MyMap();
console.log(map);
定义 hash 方法
在 MyMap 的原型链上创建一个 hash 方法给新增的数据分类,让他们存储在指定的链表上,方便快速查找,也可以供后续的 get、set、delete 等方法使用。
MyMap.prototype.hash = function (key) {
let index = 0;
// 根据 key 的类型来给数据分类,返回指定的 index,也可以使用其他方式
if (typeof key == "object") {
index = 0;
} else if (typeof key === "undefined") {
index = 1;
} else if (typeof key === "null") {
index = 2;
} else if (typeof key === "boolean") {
index = 3;
} else if (typeof key === "number") {
// 给数字执行求余取模操作
index = key % this.bucket.length;
} else if (typeof key == "string") {
for (let i = 0; i < key.length; i++) {
// 求取字符串每个字符的 Unicode 编码之和
index += key.charCodeAt(i);
}
// 给字符串 index 执行求余取模操作
index = index % this.bucket.length;
}
return index;
}
创建 set 方法
MyMap.prototype.set = function (key, value) {
// 根据 key 值获取到对应的链表下标,得到要操作的链表
const i = this.hash(key);
let listNode = this.bucket[i];
// 遍历链表,在链表尾部追加数据
while (listNode.next) {
// 如果有 key 存在,执行更新操作,并返回自身对象
if (listNode.key === key) {
listNode.value = value;
return this;
}
// 没找到链表向下移动
listNode = listNode.next;
}
// 如果遍历完都没有找到,就执行新增操作
listNode.next = {
key,
value,
next: null
};
this.size++;
return this;
};
const map = new MyMap();
map.set('0', 'foo');
map.set(1, 'bar');
map.set({}, "baz");
console.log(map);
创建 get 方法
MyMap.prototype.get = function (key) {
const i = this.hash(key);
let listNode = this.bucket[i];
// 遍历链表,找到指定的key并返回value,没有找到就返回undefined
while (listNode.next) {
if (listNode.next.key === key) {
return listNode.next.value;
}
// 没找到链表向下移动
listNode = listNode.next;
}
return undefined;
};
const map = new MyMap();
map.set('0', 'foo');
map.set(1, 'bar');
map.set({}, "baz");
console.log('get 0', map.get(0)); // get 0 undefined
console.log('get 1', map.get(1)); // get 1 bar
创建 has 方法
MyMap.prototype.has = function (key) {
const i = this.hash(key);
let listNode = this.bucket[i];
// 遍历链表,找到指定的key,如果有返回true,反之返回false
while (listNode.next) {
if (listNode.next.key === key) {
return true;
}
// 没找到链表向下移动
listNode = listNode.next;
}
return false;
};
const map = new MyMap();
map.set('0', 'foo');
map.set(1, 'bar');
map.set({}, "baz");
console.log('has 0', map.get(0)); // has 0 false
console.log('has 1', map.get(1)); // has 1 true
创建 delete 方法
MyMap.prototype.delete = function (key) {
const i = this.hash(key);
let listNode = this.bucket[i];
// 遍历链表,找到指定的key,如果存在改变链表next指向并返回true,反之返回false
while (listNode.next) {
if (listNode.next.key === key) {
listNode.next = listNode.next.next;
this.size--;
return true;
}
// 没找到链表向下移动
listNode = listNode.next;
}
return false;
};
const map = new MyMap();
map.set('0', 'foo');
map.set(1, 'bar');
map.set({}, "baz");
console.log('delete "0"', map.delete('0')); // delete "0" true
console.log(map);
创建 clear 方法
执行初始化操作即可。
MyMap.prototype.clear = function () {
this.init();
};
创建 entries 方法
可以直接遍历数组的每个链表,然后返回一个新的包含 [key, value]
对的 Iterator
对象。
MyMap.prototype.entries = function* () {
for (let i = 0; i < this.bucket.length; i++) {
let listNode = this.bucket[i];
while (listNode.next) {
if (listNode.next.key) {
yield [listNode.next.key, listNode.next.value];
}
listNode = listNode.next;
}
}
};
但是这样写虽然可以实现简易的 entries 方法,但是不能记录 map 的原始插入顺序,所以可以添加一个头尾节点来记录顺序。
-
修改 init 方法,并在 set 以及 delete 方法中移动头尾节点
MyMap.prototype.init = function () { // 散列表长度 this.size = 0; // bucket 为散列表结构:数组 + 链表,初始化时每个链表的 next 指向头指针 this.bucket = new Array(8).fill(0).map(() => { return { next: null } }); // 记录头尾节点 this.head = { next: null }; this.tail = null; }; MyMap.prototype.set = function (key, value) { // 根据 key 值获取到对应的链表下标,得到要操作的链表 const i = this.hash(key); let listNode = this.bucket[i]; let flag = false; // 遍历链表,在链表尾部追加数据 while (listNode.next) { // 如果有 key 存在,执行更新操作,并返回自身对象 if (listNode.next.key === key) { listNode.next.value = value; // return this; flag = true; break; } // 没找到链表向下移动 listNode = listNode.next; } // 如果存在,更新 head 节点中的 value if (flag) { listNode = this.head; while (listNode.next) { if (listNode.next.key === key) { listNode.next.value = value; return this; } listNode = listNode.next; } } const node = { key, value, next: null }; // 如果遍历完都没有找到,就执行新增操作 listNode.next = node; // 给头尾节点赋值,记录散列表的顺序 if (this.size === 0) { this.head.next = node this.tail = this.head.next; } else { this.tail.next = node this.tail = this.tail.next; } this.size++; return this; }; MyMap.prototype.delete = function (key) { const i = this.hash(key); let listNode = this.bucket[i]; let flag = false; // 遍历链表,找到指定的key,如果存在改变链表next指向同时改变head节点中的next指向并返回true,反之返回false while (listNode.next) { if (listNode.next.key === key) { listNode.next = listNode.next.next; this.size--; flag = true; break } // 没找到链表向下移动 listNode = listNode.next; } if (flag) { listNode = this.head; while (listNode.next) { if (listNode.next.key === key) { listNode.next = listNode.next.next; break; } listNode = listNode.next; } } return flag; };
-
遍历头节点,返回一个新的包含
[key, value]
对的Iterator
对象。MyMap.prototype.entries = function* () { let listNode = this.head.next; // 从头节点开始按照顺序遍历 while (listNode) { if (listNode.key) { yield [listNode.key, listNode.value]; } listNode = listNode.next; } };
const map = new MyMap(); map.set('0', 'foo'); map.set(1, 'bar'); map.set({}, "baz"); const iterator = map.entries(); console.log(iterator.next().value); // ['0', 'foo'] console.log(iterator.next().value); // [1, 'bar'] console.log(iterator.next().value); // [{}, 'baz'] console.log(map);
创建 values 方法
values 方法与 entries 方法相似。
MyMap.prototype.values = function* () {
let listNode = this.head.next;
// 从头节点开始按照顺序遍历
while (listNode) {
if (listNode.value) {
yield listNode.value;
}
listNode = listNode.next;
}
};
创建 keys 方法
keys 方法同样与 entries 方法相似。
MyMap.prototype.keys = function* () {
let listNode = this.head.next;
// 从头节点开始按照顺序遍历
while (listNode) {
if (listNode.key) {
yield listNode.key;
}
listNode = listNode.next;
}
};
创建 @@iterator 方法
@@iterator
属性的初始值与 entries
属性的初始值是同一个函数对象。
MyMap.prototype[Symbol.iterator] = MyMap.prototype.entries;
创建 forEach 方法
// forEach(fn, context)方法
MyMap.prototype.forEach = function (fn, context = this) {
let listNode = this.head.next;
// 从头节点开始按照顺序遍历
while (listNode) {
if (listNode.key) {
fn.call(context, listNode.value, listNode.key);
}
listNode = listNode.next;
}
};
const map = new MyMap();
map.set('0', 'foo');
map.set(1, 'bar');
map.set({}, "baz");
function logMapElements(value, key, map) {
console.log(`m[${key}] = ${value}`);
}
map.forEach(logMapElements);
console.log(map);
完整代码
代码比较长,如果有兴趣可以查看 gitee.com/sosir/handw…
总结
map 大多数的特性都可以通过 Object 类型实现,但是如果代码设计大量的查询、插入和删除操作,map 的性能无疑更加。也就是说,当我们遇到了要快速判断一个元素是否存在或者需要大量新增、删除操作,就要考虑 map 这种散列表结构了。
但是散列表也是牺牲了空间换取了时间,我们还是需要使用额外的数组、set、map来存放数据,才能实现快速的查找,将读取的效率做到极致,而且散列表的查找效率主要还取决于构造散列表时选取的散列函数和处理冲突的方法。
总之,散列表并不是万能的,但是如果在遇到了要快速判断一个元素是否存在等操作的时候,就应该第一时间想到使用散列表来完成。