哈希表与hashtable

1. 定义

如果查找关键字时不需要比较就可以获得需要记录的存储位置,这就称作【散列技术】。它是在记录的存储位置和它的关键字之间建立一个确定的对应关系 f,使得每个关键字 key 对应一个存储位置 f(key)。把这种对应关系 f 称为【散列函数】,又称【哈希(hash)函数】,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为【散列表】或【哈希表(hash table)】。那么关键字对应的记录存储位置就称为【散列地址】。

优点:适用于快速的查找,其时间复杂度为 \(O(1)\)

缺点:占用内存空间比较大。

1.1 散列函数

设计特点

  1. 计算简单(复杂的计算会降低查找的时间)
  2. 散列地址分布均匀(减少哈希冲突)

1.1.1 直接定址法

直接定址法就是取关键字的某个线性函数作为散列地址,即\(f(key)=a*key+b\)

这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是要事先知道关键字的分布情况,适合查找表较小且连续的情况。

1.1.2 数字分析法

如果关键字是位数较多的数字,比如 11 位的手机号,其前3位是接入号,对应不同运营商公司的子品牌;中间四位是 HLR 识别号,表示用户归属地;最后四位才是真正的用户号。

那么如果采用用户手机号作为关键字,很可能前 7 位都是相同的。那么可以选择后四位成为散列地址。同时,为了解决冲突问题,还可以对抽取出来的数字再进行反转、右环移位、左环移位、甚至前两数与后两数叠加等方法。

数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,可以考虑此方法。

1.1.3 平方取中法

假如关键字为 1234,那么对其平方,得到 1522756,再抽取中间的 3 位就是 227,用做散列地址。

平方取中法比较适合不知道关键字的分布,而位数又不是很大的情况。

1.1.4 折叠法

折叠法是将关键字从左到右分割成位数相等的几部分,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。

比如关键字为 9876543210,散列表表长为三位,将其分为四组,987|654|321|0,然后对其叠加求和,即 987+654+321+0=1962,再求后 3 位得到散列地址为 962。

折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。

1.1.5 除留余数法

此方法为最常用的构造散列函数的方法。对于散列表长为 m 的散列函数公式为:\(f(key)=key\mod\ p(p\le m)\),其中,mod 是取模(求余数)的意思。

根据经验,若散列表表长为 m,通常 p 为小于或等于表长(最好接近 m)的最小质数或不包含小于 20 质因子的合数。

1.1.6 随机数法

选择一个随机数,取关键字的随机函数值作为它的散列表地址。当关键字长度不等时,采用这个方法较为合适。

1.2 散列冲突

1.2.1 线性探测法

线性探测法也称为【开放定址法】,即一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列表地址总能找到,并将记录存入,即 \(f_1(key)=(f(key)+d_i)\ MOD\ m\ (d_i=1,2,3,...,m-1)\)

比如,对于如下关键字集合,表长为 12。采用散列函数 \(f(key)=key\ mod\ 12\)

12 67 56 16 25 37 22 29 15 47 48 34

当计算前5个数时,都没有冲突的散列地址,直接存入:

下标 0 1 2 3 4 5 6 7 8 9 10 11
关键字 12 25 16 67 56

当计算 37 时,发现\(f(37)=1\),此时就与 25 所在位置产生冲突。于是应用上面的公式\(f(37)=(f(37)+1)mod\ 12=2\)。于是 37 就存入了下标为 2 的位置:

下标 0 1 2 3 4 5 6 7 8 9 10 11
关键字 12 25 37 16 67 56

接下来的 22、29、15、47 都没有冲突:

下标 0 1 2 3 4 5 6 7 8 9 10 11
关键字 12 25 37 15 16 29 67 56 22 47

当计算 48 时,有 \(f(48)=0\),与 12 所在的位置冲突了,然后 \(f(48)=(f(48)+1)mod\ 12=1\),此时又与 25 所在位置产生冲突,于是 \(f(48)=(f(48)+2)mod\ 12=2\),还是冲突,重复如此计算,直到结果为 6 时才有空位:

下标 0 1 2 3 4 5 6 7 8 9 10 11
关键字 12 25 37 15 16 29 48 67 56 22 47

将这种解决冲突的开放定址法称为【线性探测法】。对于上述例子中 48 和 37 这种本来都不是同义词却需要争夺一个地址的情况,称这种现象为【堆积】。

开放定址法只要在散列表未填满时,总是能找到不发生冲突的地址,是常用的解决冲突的方法

1.2.2 二次探测法

同样的对于上述的例子,当计算 34 时,\(f(34)=10\),与 22 所在位置冲突,但是 22 后面没有空位了,反而其前面存在空位,虽然可以不断求余数得到结果,但是效率很差。

因此改进 \(d_i=1^2,-1^2,2^2,-2^2,...,q^2,-q^2\ (q\le m/2)\),使得可以双向寻找可能的空位置。称这种方法为【二次探测法】。

还有一种方法,在冲突时,对于位移量 \(d_i\) 采用随机函数计算得到,称之为【随机探测法】。

1.2.3 链地址法

将所有关键字为同义词的记录存储在一个单链表中,称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。

2.代码实现

2.1 线性探测哈希表

线性探测哈希表的代码实现如下:

// 桶的状态
enum State {
    UNUSED = 0, // 未曾使用
    USING,      // 正在使用
    REMOVE,     // 已被删除
};

struct Bucket {
    Bucket(int key = 0, State state = UNUSED)
        : val_(key)
        , state_(state) { }

    int   val_;   // 存储的数据
    State state_; // 桶的当前状态
};

class HashTable {
public:
    HashTable(int size = primes_[0], double loadfactor = 0.75);
    ~HashTable();

    // 插入元素
    bool insert(int key);
    // 删除元素
    bool erase(int key);
    // 查询
    bool find(int key) const;
    // 打印
    void print() const;

private:
    // 扩容
    void expand();

private:
    Bucket* table_;      // 可动态扩容的数组
    int     tabSize_;    // 桶的总数量
    int     usedCnt_;    // 已使用的桶数量
    double  loadfactor_; // 装载因子
    int     primeIdx_;   // 素数表下标

    static const int PRIME_SIZE = 10;     // 素数表的大小
    static int       primes_[PRIME_SIZE]; // 素数表
};

int HashTable::primes_[PRIME_SIZE] = { 3, 7, 23, 47, 97, 251, 443, 911, 1471, 42773 };

HashTable::HashTable(int size, double loadfactor)
    : usedCnt_(0)
    , loadfactor_(loadfactor)
    , primeIdx_(0) {
    // 将其调整到最近的素数
    if (size != primes_[0]) {
        for (; primeIdx_ < PRIME_SIZE; primeIdx_++) {
            if (primes_[primeIdx_] > size) {
                break;
            }
        }
    }

    // 如果size过大 已超过最后一个素数 则调整为最后一个素数
    if (primeIdx_ == PRIME_SIZE) {
        primeIdx_--;
    }

    tabSize_ = primes_[primeIdx_];
    table_   = new Bucket[tabSize_];
}

HashTable::~HashTable() {
    delete[] table_;
    table_ = nullptr;
}

bool HashTable::insert(int key) {
    double factor = usedCnt_ * 1.0 / tabSize_;
    if (factor > loadfactor_) {
        // 哈希表扩容
        expand();
    }
  
    int idx = key % tabSize_;

    int i = idx;
    do {
        if (table_[i].state_ != USING) {
            table_[i].state_ = USING;
            table_[i].val_   = key;
            usedCnt_++;
            return true;
        }
        i = (i + 1) % tabSize_;
    } while (i != idx);

    return false;
}

bool HashTable::erase(int key) {
    int idx = key % tabSize_;

    int i = idx;
    do {
        if (table_[i].state_ == USING && table_[i].val_ == key) {
            table_[i].state_ = REMOVE;
            usedCnt_--;
        }
        i = (i + 1) % tabSize_;
    } while (table_[i].state_ != UNUSED && i != idx);

    return true;
}

bool HashTable::find(int key) const {
    int idx = key % tabSize_;

    int i = idx;
    do {
        if (table_[i].state_ == USING && table_[i].val_ == key) {
            return true;
        }
        i = (i + 1) % tabSize_;
    } while (table_[i].state_ != UNUSED && i != idx);

    return false;
}

void HashTable::print() const {
    for (int i = 0; i < tabSize_; i++) {
        if (table_[i].state_ == USING) {
            cout << table_[i].val_ << " ";
        } else {
            cout << "x ";
        }
    }
    cout << endl;
}

void HashTable::expand() {
    ++primeIdx_;
    if (primeIdx_ == PRIME_SIZE) {
        throw "HashTable is too large! Can't expand anymore!";
    }

    Bucket* newTable = new Bucket[primes_[primeIdx_]];
    for (int i = 0; i < tabSize_; i++) {
        if (table_[i].state_ == USING) {
            int idx = table_[i].val_ % primes_[primeIdx_];

            int k = idx;
            do {
                if (newTable[k].state_ != USING) {
                    newTable[k].state_ = USING;
                    newTable[k].val_   = table_[i].val_;
                    break;
                }
                k = (k + 1) % primes_[primeIdx_];
            } while (k != idx);
        }
    }

    delete[] table_;
    table_   = newTable;
    tabSize_ = primes_[primeIdx_];
}

线性探测哈希表的缺陷如下:

  1. 发生哈希冲突时,需要接近 \(O(n)\) 的时间复杂度来解决冲突;
  2. 在多线程环境中,线性探测所用到的基于数组实现的哈希表,只能给全局的表用互斥锁来保证哈希表的原子操作,以确保线程安全。

2.2 链地址法哈希表

在链地址法哈希表中,当发生哈希冲突时,将发生哈希冲突的元素采用链表数据结构连接起来。但是如果发生哈希冲突的元素过多,则会导致链表过长,那么其搜索的时间复杂度就会增加。

其常见的优化手段如下:

  1. 当链表长度过长时,可以将链表转化为红黑树数据结构的形式;
  2. 链式哈希表每个桶都可以创建自己的互斥锁,不同的桶之间可以进行并发操作;

链地址法哈希表的代码实现如下:

class HashTable {
public:
    HashTable(int size = primes_[0], double loadFactor = 0.75);
    ~HashTable() = default;

    // 增加元素
    void insert(int key);
    // 删除元素
    void remove(int key);
    // 查找元素
    bool find(int key) const;
    // 打印
    void print() const;

private:
    // 扩容
    void expand();

private:
    vector<list<int>> table_;      // 哈希表
    int               useCnt_;     // 已使用的桶数量
    double            loadfactor_; // 装载因子
    int               primeIdx_;   // 素数表下标

    static const int PRIME_SIZE = 10;     // 素数表的大小
    static int       primes_[PRIME_SIZE]; // 素数表
};

int HashTable::primes_[PRIME_SIZE] = { 3, 7, 23, 47, 97, 251, 443, 911, 1471, 42773 };

HashTable::HashTable(int size, double loadFactor)
    : useCnt_(0)
    , loadfactor_(loadFactor)
    , primeIdx_(0) {
    if (size != primes_[0]) {
        for (; primeIdx_ < PRIME_SIZE; primeIdx_++) {
            if (primes_[primeIdx_] >= size)
                break;
        }

        if (primeIdx_ == PRIME_SIZE) {
            primeIdx_--;
        }
    }

    table_.resize(primes_[primeIdx_]);
}

void HashTable::insert(int key) {
    double factor = useCnt_ * 1.0 / table_.size();
    if (factor > loadfactor_) {
        expand();
    }

    int idx = key % table_.size();
    if (table_[idx].empty()) {
        useCnt_++;
        table_[idx].emplace_front(key);
    } else {
        auto it = ::find(table_[idx].begin(), table_[idx].end(), key);
        if (it == table_[idx].end()) {
            table_[idx].emplace_front(key);
        }
    }
}

void HashTable::remove(int key) {
    int  idx = key % table_.size();
    auto it  = ::find(table_[idx].begin(), table_[idx].end(), key);
    if (it != table_[idx].end()) {
        table_[idx].erase(it);
        if (table_[idx].empty()) {
            useCnt_--;
        }
    }
}

bool HashTable::find(int key) const {
    int  idx = key % table_.size();
    auto it  = ::find(table_[idx].begin(), table_[idx].end(), key);
    return it != table_[idx].end();
}

void HashTable::print() const {
    cout << "==================" << endl;
    for (int i = 0; i < table_.size(); i++) {
        cout << "[" << i << "] -> ";
        for (auto key : table_[i]) {
            cout << key << " -> ";
        }
        cout << endl;
    }
    cout << "==================" << endl;
}

void HashTable::expand() {
    if (primeIdx_ + 1 == PRIME_SIZE) {
        throw "Hashtable cannot expand anymore!";
    }

    primeIdx_++;
    useCnt_ = 0;

    vector<list<int>> oldTable;
    table_.swap(oldTable);
    table_.resize(primes_[primeIdx_]);

    for (auto list : oldTable) {
        for (auto key : list) {
            int idx = key % table_.size();
            if (table_[idx].empty()) {
                useCnt_++;
            }
            table_[idx].emplace_front(key);
        }
    }
}

3. STL实现

3.1 hashtable

SGI 的 hashtable 实现了哈希表的数据结构,其采用除留余数法来构造散列函数,同时通过开链法来解决哈希冲突的问题。它称 hashtable 表格内的元素为【桶子(bucket)】。

如下为 hashtable 的节点定义:

template<class Value>
struct __hashtable_node {
    __hashtable_node* next;
    Value val;
};

注意,bucket 所维护的 linked list,并不采用 STL 的 list 或 slist,而是自行维护上述的 hashtable node。至于 buckets 结构体,则以 vector 完成,以便有动态扩充能力。

对于 hashtable 的迭代器来说,其前进操作是首先尝试从目前所指的节点出发,前进一个节点,由于节点被安置在 list 内,所以利用节点的 next 指针即可轻易达成前进操作。如果目前节点正巧是 list 的尾端,就跳至下一个 bucket 身上,那正是指向下一个 list 的头部节点。

注意:hashtable 的迭代器没有后退操作,也没有定义所谓的逆向迭代器。

虽然开链法并不要求表格大小必须为质数,但 SGI STL 仍然以质数来设计表格大小,并且先将 28 个质数(逐渐呈现大约 2 倍关系)计算好,以备随时访问,同时提供一个函数__stl_next_prime(),用来查询在这 28 个质数之中,最接近某数并大于某数的质数。

注意,是否重建表格的依据是拿元素个数和 bucket vector 的大小来比,如果前者大于后者,就重建表格。因此,每个 bucket(list) 的最大容量和 bucktes vector 的大小相同。

对于哈希函数来说,由于某些元素型别无法对其进行取模运算,因此 SGI 对内建的所有 hash functions 包装了一层,通过函数 bkt_num() 来获取元素落脚的位置。

<stl_hash_fun.h> 中定义有数个现成的 hash functions,全部都是仿函数。通过【模板特化】机制来计算相应的哈希函数。其中,针对 char、int、long 等整数型别,大部分的 hash functions 什么都不做。但是对于字符串 const char*,则设计有相应的转换函数:

inline size_t __stl_hash_string(const char* s) {
    unsigned long h = 0;
    for(; *s; ++s)
        h = 5 * h + s;
    return size_t(h);
}

而对于其他的型别,例如 string、double、float 等,用户都必须自行为它们定义 hash function。

3.2 hash_set&hash_multiset

hash_set 以 hashtable 作为底层机制。与 set 不同的是,set 的底层机制 RB-tree 有自动排序功能而 hashtable 没有,反映出来的结果就是,set 的元素有自动排序功能而 hash_set 没有。

注意:hash_set 有一些无法处理的型别,除非用户为那些型别撰写 hash function。凡是 hashtable 无法处理者,hash_set 也无法处理。

hash_set 的构造函数在缺省情况下,默认采用大小为 100 的表格,然后将被 hash table 调整为最接近且较大的质数。其插入操作则采用 hashtable 的 insert_unique() 操作,不允许键值重复。

hash_multiset 的特性与 multiset 完全相同,唯一的差别在于它的底层机制是 hashtable。也因此,hash_multiset 的元素并不会被自动排序。

hash_multiset 和 hash_set 实现上唯一的差别在于,前者的元素插入操作采用底层机制 hashtable 的 insert_equal(),而后者采用的是 insert_unique()

3.3 hash_map&hash_multimap

hash_map 以 hashtable 作为底层机制。与 map 不同的是,map 的底层机制 RB-tree 有自动排序功能而 hashtable 没有,反映出来的结果就是,map 的元素有自动排序功能而 hash_map 没有。

map 的特性是,每一个元素都同时拥有一个实值(value)和一个键值(key),这一点在 hash_map 中也是一样的。hash_map 的使用方式与 map 完全相同。

注意:hash_map 有一些无法处理的型别,除非用户为那些型别撰写 hash function。凡是 hashtable 无法处理者,hash_map 也无法处理。

hash_map 的构造函数在缺省情况下,默认采用大小为 100 的表格,然后将被 hash table 调整为最接近且较大的质数。其插入操作则采用 hashtable 的 insert_unique() 操作,不允许键值重复。

hash_multimap 的特性与 multimap 完全相同,唯一的差别在于它的底层机制是 hashtable。也因此,hash_multimap 的元素并不会被自动排序。

hash_multimap 和 hash_map 实现上唯一的差别在于,前者的元素插入操作采用底层机制 hashtable 的 insert_equal(),而后者采用的是 insert_unique()

posted @ 2022-12-31 18:56  Leaos  阅读(83)  评论(0编辑  收藏  举报