前端必会数据结构与算法系列之散列表(四)

502 阅读4分钟

1. 散列函数

散列函数是这样的函数,即无论你给它什么数据,它都还你一个数字。如下图所示:

image.png

所以散列算法的作用是尽可能快地在数据结构中找到一个值,即给定一个键值,然后返回值在表中的地址。专业术语就是将输入映射到数字

散列函数有如下必要条件:

  1. 同样的输入应该由同样的输出。
  2. 它应将不同的输入映射到不同的数字(最理想的情况,不同的输入输出都不相同)

以一个电子邮件地址簿为例,来看一个最常见的散列函数

2. 散列表

散列表其实就是散列函数 + 数组,它将不同的输入映射到不同的数组索引。如下图:

image.png

散列表是字典的一种实现,JavaScript 语言内部就是使用散列表来表示每个对象。此时,对象的每个属性和方法(成员)被存储为 key 对象类型,每个 key 指向对应的对象成员。

2.1 实现一个散列表

  • put(key,value):向散列表增加一个新的项(也能更新散列表)。
  • remove(key):根据键值从散列表中移除值。
  • get(key):返回根据键值检索到的特定的值。

公共方法:

defaultToString(item) {
    if (item === null) {
        return 'NULL';
    } else if (item === undefined) {
        return 'UNDEFINED';
    } else if (typeof item === 'string' || item instanceof String) {
        return `${item}`;
    }
    return item.toString();
}

class ValuePair {
    constructor(key, value) {
        this.key = key;
        this.value = value;
    }
    toString() {
        return `[#${this.key}: ${this.value}]`;
    }
}
class HashTable {
    constructor(toStrFn = defaultToString) {
        this.toStrFn = toStrFn;
        this.table = {};
    }

    // 散列函数
    loseloseHashCode(key) {
        if (typeof key === 'number') { // 如果是数字,直接返回
            return key;
        }
        // 将 key 转换为一个字符串,防止 key 是一个对象而不是字符串
        const tableKey = this.toStrFn(key);
        let hash = 0;
        // 将从 ASCII 表中查到的每个字符对应的 ASCII 值加到 hash 变量中
        for (let i = 0; i < tableKey.length; i++) {
            hash += tableKey.charCodeAt(i);
        }
        // 使用 hash 值和一个任意数做除法的余数,这可以规避操作数超过数值变量最大表示范围的风险。
        return hash % 37;
    }

    hashCode(key) {
        return this.loseloseHashCode(key);
    }

    put(key, value) {
        if (key != null && value != null) {
          const position = this.hashCode(key);
          this.table[position] = new ValuePair(key, value);
          return true;
        }
        return false;
    }

    get(key) {
        const valuePair = this.table[this.hashCode(key)];
        return valuePair == null ? undefined : valuePair.value;
    }

    remove(key) {
        const hash = this.hashCode(key);
        const valuePair = this.table[hash];
        if (valuePair != null) {
            delete this.table[hash];
            return true;
        }
        return false;
    }

    getTable() {
        return this.table;
    }

    isEmpty() {
        return this.size() === 0;
    }

    size() {
        return Object.keys(this.table).length;
    }

    clear() {
        this.table = {};
    }
}

2.2 冲突

在实际应用中,不可能有不同的输入输出都不相同的散列函数。有时候,一些键会有相同的散列值。不同的值在散列表中对应相同位置的时候,我们称其为冲突(如果给两个输入分配位置相同,就出现了冲突)。

以下我们列举几种常用的方法来解决冲突问题。

2.2.1 分离链接法

这种方法较为常见,具体实现为在每一个位置创建一个链表并将元素存储在里面。它是解决冲突的最简单的方法,但是在 HashTable 实例之外还需要额外的存储空间。如下图所示:

image.png

代码实现:

class HashTableSeparateChaining {
    constructor(toStrFn = defaultToString) {
        this.toStrFn = toStrFn;
        this.table = {};
    }

    loseloseHashCode(key) {}

    hashCode(key) {}

    put(key, value) {
        if (key != null && value != null) {
            const position = this.hashCode(key);
            // 如果是第一次向该位置加入元素,我们会在该位置上初始化一个 LinkedList 类的实例
            if (this.table[position] == null) {
                // 在链表章节实现了LinkedList方法
                this.table[position] = new LinkedList();
            }
            this.table[position].push(new ValuePair(key, value));
            return true;
        }
        return false;
    }

    get(key) {
        const position = this.hashCode(key);
        const linkedList = this.table[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) {
        const position = this.hashCode(key);
        const linkedList = this.table[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()) {
                        delete this.table[position];
                    }
                    return true;
                }
                current = current.next;
            }
        }
        return false;
    }

    getTable() {}

    isEmpty() {}

    size() {
        let count = 0;
        // 加上链表的长度
        Object.values(this.table).forEach(linkedList => {
            count += linkedList.size();
        });
        return count;
    }

    clear() {}

2.2.2 线性探查

当想向表中某个位置添加一个新元素的时候,如果索引为 position 的位置已经被占据了,就尝试 position+1 的位置。如果 position+1 的位置也被占据了,就尝试 position+2 的位置,以此类推,直到在散列表中找到一个空闲的位置,如下图所示:

image.png

当我们从散列表中移除一个键值对的时候,如果我们只是移除了元素,就可能在查找有相同 hash(位置)的其他元素时找到一个空的位置,这会导致算法出现问题

线性探查技术分为两种。第一种是软删除方法。我们使用一个特殊的值(标记)来表示键值对被删除了(惰性删除或软删除),而不是真的删除它。经过一段时间,散列表被操作过后,我们会得到一个标记了若干删除位置的散列表。这会逐渐降低散列表的效率,因为搜索键值会随时间变得更慢。下图展示了这个过程。

image.png

第二种方法需要检验是否有必要将一个或多个元素移动到之前的位置。当搜索一个键的时候,这种方法可以避免找到一个空位置。如果移动元素是必要的,我们就需要在散列表中挪动键值对。下图展现了这个过程

image.png

2.3 更好的散列函数

一个表现良好的散列函数是由几个方面构成的:插入和检索元素的时间(即性能),以及较低的冲突可能性。

这是原来的散列函数:

loseloseHashCode(key) {
    if (typeof key === 'number') { // 如果是数字,直接返回
        return key;
    }
    // 将 key 转换为一个字符串,防止 key 是一个对象而不是字符串
    const tableKey = this.toStrFn(key);
    let hash = 0;
    // 将从 ASCII 表中查到的每个字符对应的 ASCII 值加到 hash 变量中
    for (let i = 0; i < tableKey.length; i++) {
        hash += tableKey.charCodeAt(i);
    }
    // 使用 hash 值和一个任意数做除法的余数,这可以规避操作数超过数值变量最大表示范围的风险。
    return hash % 37;
}

改进之后的散列函数

djb2HashCode(key) {
    const tableKey = this.toStrFn(key);
    // 大多数实现都使用 5381
    let hash = 5381;
    for (let i = 0; i < tableKey.length; i++) {
        // 33幻数,指编程中直接使用的常数
        hash = (hash * 33) + tableKey.charCodeAt(i);
    }
    return hash % 1013;
}