数据结构 - 哈希表

简介

  • 哈希表(Hash Table):也被称为 散列表字典,是根据关键码值(Key)直接进行访问的数据结构。具体来说,它通过将关键码值映射到底层数据结构的一个位置上来访问记录,这个映射关系就叫哈希函数,存放记录的数据结构通常为数组,这样就使得哈希表具备非常高效的数据查找等操作。

我们知道,数组存储的是值类型数据,链表存储的是结点(指针域 + 数据域)类型数据,而哈希表,它存储的是键-值对类型数据。

对于数组和链表等数据结构的查找,都是通过对各个结点进行比对判断,而哈希表的底层数据结构虽然是数组,但是它却另辟蹊径,通过对键进行一个映射(哈希函数),从而可以直接找到对应值的索引位置,然后借助数组数据获取的性能,使得哈希表的查找效率极高。

可以看出,哈希表通过哈希函数绕过了数组查找比对的过程,同时又利用了数组数据获取高效的性能,使得哈希表的数据操作性能十分高效。
同时,对于传统的数据结构(如数组,链表等),随着数据量的增加,其查找等操作效率会随着下降,而对于哈希表来说,无论数据量大小,它的查找效率能稳定保持在左右(哈希函数均匀情况下)。这也使得哈希表成为最受欢迎的数据结构之一。

构建哈希表

从上文对哈希表的描述中,可以知道,哈希表具备以下几个特征:

  • 数组:底层数据结构为数组,使之具备高效获取功能。
  • 键值对:存储的结点数据为键值对。
  • 哈希函数:具备一个映射关系(即哈希函数),可以将键值映射到底层数组的某一个索引上,对应的值就存在于该位置。

以下针对上述特征,构建一个简易版哈希表数据结构。如下所示:

:下文中的代码只是用于从底层原理对上层数据结构的实现,未经过严格的单元测试,可能存在严重漏洞,实现上不考虑性能,但着重于理解。

// 头文件:Map.h
#ifndef __MAP_H__
#define __MAP_H__

#define MAX_LENGTH 16

namespace YN {

    template
    class Map;

    template
    struct Entry {
        K key;
        V value;
        // 标识当前结点是否有效
        bool isNull;
        Entry() :isNull(true) {}
        Entry(K _key, V _value) : isNull(true), key(_key), value(_value) {}
    };


    template
    class Iterator {
    protected:
        const Map& map;
    public:
        Iterator(const Map& _map) : map(_map) {}
    public:
        virtual bool hasNext() = 0;
        virtual V next() = 0;
    };

    template
    class MapIterator : public Iterator {
    public:
        MapIterator(const Map& _map);
        virtual ~MapIterator();
    private:
        int curIndex;
        int count;
    public:
        bool hasNext() override;
        V next() override;
    };

    template
    class Map {
    private:
        Entry arr[MAX_LENGTH];
        Iterator* iter;
        int length;
    public:
        Map();
        virtual ~Map();
        // 让 MapIterator 类成为 Map 友元,使得 MapInterator 可以访问 Map 的私有成员
        friend class MapIterator;
    public:
        // 增
        // 改
        void put(const K& key, const V& value);
        // 删
        Entry remove(const K& key);
        // 查
        V& get(const K& key) const;
        Iterator* iterator();
    };
}
#endif

// 源文件:Map.cpp
#include "Map.h"
#include 

using namespace std;
using namespace YN;

namespace YN {

    template
    int hash(const K& key) {
        // 假定 K 类型数据具备 length 方法(鸭子类型)
        return key.length() % MAX_LENGTH;
    }

}

template
YN::MapIterator::MapIterator(const Map& _map) :Iterator(_map), curIndex(0), count(0) {
}

template
YN::MapIterator::~MapIterator() {
}

template
bool YN::MapIterator::hasNext() {
    return this->curIndex < MAX_LENGTH
        && this->count < this->map.length;
}

template
V YN::MapIterator::next() {
    while (this->curIndex < MAX_LENGTH
        && this->count < this->map.length
        && this->map.arr[this->curIndex].isNull) {
        ++this->curIndex;
    }
    ++this->count;
    // 下次遍历从下一个位置开始
    ++this->curIndex;
    return this->map.arr[this->curIndex - 1].value;
}


template
Map::Map() :iter(nullptr), length(0) {
    //  memset(this->arr, 0, sizeof(this->arr));
}

template
Map::~Map() {
    if (this->iter) {
        delete this->iter;
    }
}

template
void Map::put(const K& key, const V& value) {
    // 对键进行哈希计算
    int keyIndex = YN::hash(key);
    // 获取对应元素
    Entry& entry = this->arr[keyIndex];
    // 设置键值对有效
    entry.isNull = false;
    // 依据键哈希索引存入数据
    entry.key = key;
    entry.value = value;
    // 长度加1
    ++this->length;
}

template
Entry Map::remove(const K& key) {
    int keyIndex = YN::hash(key);
    Entry& entry = this->arr[keyIndex];
    // 这里状态设置无效,相当于删除(毕竟 C++ 没有空对象这一说法)
    this->arr[keyIndex].isNull = true;
    --this->length;
    return entry;
}

template
V& Map::get(const K& key) const {
    int keyIndex = YN::hash(key);
    return this->arr[keyIndex];
}

template
Iterator* Map::iterator() {
    if (this->iter) {
        delete this->iter;
    }
    return this->iter = new YN::MapIterator(*this);
}

上述代码中的类Map就是我们对哈希表的抽象,其底层数据结构为一个定长数组Entry arr[MAX_LENGTH],元素为键值对对象Entry
我们对哈希表Map提供了增put、删remove、改put、查get以及遍历操作iterator,提供了这些数据基本操作,Map就具备了可用性。比如:

template
void traverseMap(Map& map) {
    Iterator* iter = map.iterator();
    while (iter->hasNext()) {
        V value = iter->next();
        cout << value << endl;
    }
}

int main() {
    Map map;
    map.put("zhangsan", "13688377123");
    map.put("lisi", "13688377456");
    map.put("wangwu", "13688377789");
    traverseMap(map);

    cout << "-------------remove lisi ----" << endl;

    map.remove("lisi");
    traverseMap(map);

    return 0;
}

一个需要注意的地方就是,每次对数组进行操作的时候,都需要对键进行一个映射,映射由哈希函数负责,上述代码中,我们使用的哈希函数如下所示:

template
int hash(const K& key) {
    // 假定 K 类型数据具备 length 方法(鸭子类型)
    return key.length() % MAX_LENGTH;
}

其实很简单,这里我们只是将传递进来的键按其长度作为标准,取模使得长度索引落在底层数组索引范围内。
:由于 C++ 采用多继承机制,因此其没有像 Java 等其他单继承语言提供的泛型限定机制,因此这里采用类似动态语言的鸭子类型,假定传递的键类型具备有length方法。

但是按照上述哈希函数的特性,如果我们传入的多个键具备相同的长度,比如"aaa""bbb",这样多个不同的键会映射到同一个索引上,导致数据覆盖。对于这种现象,我们称之为键值冲突(Collision),其本质原因就是哈希函数对不同的输入产生相同的输出,也即发生了『哈希碰撞』。

哈希函数设计

从上文分析中,我们可以得出:哈希函数设计的好坏,对哈希表的数据操作效率有直接且深刻的影响

所有的哈希函数都会存在哈希碰撞(即冲突)问题,但一个好的哈希函数,会考虑到关键字的分布特点,以设计出尽量满足关键字分布均匀的哈希函数,这样存在哈希碰撞的概率就会减少很多,哈希表的性能会愈发高效。

简单来讲,哈希函数的构造目标应满足以下两个原则:

  • 哈希地址落在记录总数之内(即哈希结果小于底层数组长度)
  • 尽可能减少哈希碰撞

当前主流的哈希函数构造方法主要有如下几种:

  • 直接定址法:取关键字或关键字的某个线性函数作为哈希地址,即Hash(key)=keyHash(key)=key*a+b
    比如我们上述代码采用关键字的长度,即Hash(key) = length(key)
  • 平方取中法:对关键字进行平方运算,然后取结果的中间几位作为哈希地址。
    比如有以下关键字序列{421,423,436},平方之后的结果为{177241,178929,190096},那么可以取中间的两位数{72,89,00}作为哈希地址。
  • 折叠法:将关键字拆分成位数相同的几部分(最后一部分位数可以不同),然后叠加这几部分内容作为哈希地址。
    比如图书的 ISBN 号为 8903-241-23,则可以拆分为:89,03,24,12,3,然后进行叠加作为哈希地址:Hash(key)=89+03+24+12+3
  • 除留取余法:假设哈希表最大长度为,有一个不大于的质数,则可将关键字对进行取余运算作为哈希地址:Hash(key)=key mod p (p<=m)
    :除留取余法的关键在于质数的选取,理论研究表明,通常取不大于的最大质数可达到最好效果。

更多哈希函数构造方法,可查看:hash函数的构造方法

哈希表冲突解决方法

哈希碰撞无法避免,因此冲突无法避免。

构造一个好的哈希函数是为了尽量让关键字均匀分布在哈希表中,这样对数据的访问效率会更高效,同时在很大的程度上减少冲突,但是仍然存在一定的可能发生哈希碰撞,此时需要对哈希碰撞导致的键值冲突进行处理,这样才能从根本上保证哈希表的数据存储安全(不会因为冲突导致某个键值被覆盖)。

当前主流的哈希冲突解决方法有如下几种方式:

  • 开放寻址法:即当一个关键字和另一个关键字发生冲突时,后面插入的关键字采用某种探测技术(比如:线性探测法,平方探测法...),对哈希表序列进行探测,直到找到一个未存储数据的空单元,则插入其中即可。
    比如,假定现有一个哈希表,其内容为[0,1,2,NULL,4,5,NULL],其中,NULL表示未存储数据,01等表示实际存储的数据值,可以很容易看出,该哈希表使用的哈希函数为:Hash(key)=key%7(7为数组长度),假设此时我们要插入数值8,则Hash(8)=8%7=1,哈希表索引1已经有数据,则此时可以采用线性探测法,沿着哈希表索引1往后依次进行探测,直到探测到索引3,发现没有数据存储,则将8插入到索引3中。后续要获取关键字8时,先对关键字8进行哈希计算,得到1,但是索引1的键值为1,与当前查询的键值不匹配,则可知道存在哈希碰撞,此时只需在当前位置往后进行遍历,依次比对键值,直到找到键值为8的数据即为所求(:哈希表存储的是键值对,当前的例子刚好键和值相同)。

    :可以看到,当发生哈希碰撞时,哈希表退化为线性查找,此时查找时间复杂度退化为。

  • 链地址法:也成为 拉链法,采用的是数组和链表结合的方式,底层数组元素不再是键值对,而是存储一个链表。当发生冲突时,只需将数据插入到冲突地址所在的链表后边即可。当获取数据时,找到关键字所在地址,得到的是一个链表,对该链表进行遍历,然后找到匹配当前关键字的结点即可。

  • 再散列法:即存在多个哈希函数,当某个关键字使用第一个哈希函数发生冲突时,就使用下一个哈希函数,直至不发生冲突。

  • 创建公共溢出区:即创建两个表:基本表 + 溢出表。其中,基本表作为主要的哈希表,数据优先存储到基本表中,但是当发生冲突时,就将数据存储到溢出表中。溢出表的实现应该是一个线性表,当查询数据时,如果基本表中键值不匹配,就线性查找溢出表,直到找到关键字相匹配的数据。

其实,只要牢记哈希表存储的元素为键值对,上述的冲突解决方法就很好理解了。

总结

理论上来说,如果哈希函数是均匀的,则哈希表查询、删除、修改一个元素的时间复杂度为。

但是由于哈希碰撞无法避免,因此哈希表存在冲突问题,因此需要对冲突进行处理,常见的处理方法为 开放寻址法链地址法再散列法创建公共溢出区

当冲突很严重时,即多个关键字都映射到同一个哈希地址时,哈希表的数据操作性能由退化到线性表操作的。

因此,一个设计良好的哈希算法十分重要。可根据需要存储数据的数据量及其特征,采用不同的哈希函数设计。
常见的哈希函数设计方法有:直接定址法平方取中法折叠法除留取余法...

哈希表中其实还有一个概念也挺重要的:装填因子。其定义如下:

装填因子 = 填入表中的元素个数 / 哈希表的长度

因此,当装填因子越趋近于 1 时,则标识哈希表存储空间快满了,此时随便再插入一个数据,很大概率会产生冲突。这时候可以通过动态扩容,从而减小装填因子,减少冲突。

因此,一个好的哈希表应当满足如下 3 个条件:

  • 哈希函数构造良好,数据插入均匀分布
  • 具备冲突处理,具备容错性与健壮性
  • 具备动态扩容能力,能够很好规避数据量变多导致的问题

参考

  • 小朋友学数据结构:哈希表

你可能感兴趣的:(数据结构 - 哈希表)