数据结构整理-哈希表篇

本文详细介绍了哈希表的基本概念、操作(如初始化、查询、添加和删除),以及链式地址和开放寻址两种解决哈希冲突的方法。此外,着重探讨了哈希算法的设计,包括目标、特点和几种常见的简单哈希算法,如加法、乘法、异或和旋转哈希,以及使用大质数保证均匀分布的重要性。
摘要由CSDN通过智能技术生成

一:哈希表

哈希表又称散列表,通过键值映射,key-value。可实现高效的元素查询。即向哈希表输入一个键key,就可以在O(1)的时间内查找到value。

除了哈希表之外,数组和链表也可以实现查询功能,时间复杂度之间的关系如下:

发现哈希表的效率是真滴高!

1.哈希表的常见操作

初始化、查询、添加键值对、删除键值对等

使用内置的哈希表map实例如下:

    //初始化哈希表
    unordered_map<int,string> mp;

    //添加键值对
    mp[1287]= "哈罗";
    mp[1345]= "美团";
    mp[1685]= "支付宝";
    mp[1098]= "淘宝";


    //查询操作,向哈希表中输入键key,可以得到value
    string APP=mp[1345];//美团

    //删除操作,在哈希表中删除键值对(key,value)
    mp.erase(1098);//删除淘宝

哈希表的遍历方式有:遍历键值对、遍历键、遍历值。

    //遍历哈希表
    //遍历键值对key->value
    for(auto kv:mp){
        cout<<kv.first<<"->"<<kv.second<<endl;
    }

    //使用迭代器遍历key->value
    for(auto iter = mp.begin(); iter != mp.end(); iter++){
        cout<< iter->first<< "-> "<<iter->second<<endl;
    }

2.哈希表的实现

仅用一个数组实现哈希表,将数组的每个空位成为,每个桶可存储一个键值对,因此查询操作就是找到key所对应的桶,并在桶中获取value值。

key如何定位到对应的桶?通过哈希函数实现。哈希函数的作用:将一个大的输入空间映射到一个较小的输出空间。哈希表中:输入空间是{所有key},输出空间是{所有桶(数组索引)}。也就是说,输入一个key,可以通过哈希函数得到该key所对应的键值对在数组中的位置。

哈希函数计算分为两步:

I:通过某种哈希算法hash()计算得到哈希值;

II:将哈希值对桶数量(数组长度)capacity取模,获得该key对应的数组索引index。

index = hash(key) % capacity

然后,就可以利用index在哈希表中访问对应的桶,从而获取value。

//键值对
    struct Pair{
        public:
        int key;
        string val;
        Pair(int key, string val){
            this->key=key;
            this->val=val;
        }
    };

    //用数组实现哈希表、
    class ArrayHashMap{
     private:
        vector<Pair* > buckets;
     public:
        ArrayHashMap(){
            //初始化数组,假设包含100个桶,capacity=100
            buckets = vector<Pair* > (100);
        }

        ~ArrayHashMap(){
            //释放内存
            for(const auto &bucket : buckets){
                delete bucket;
            }
            buckets.clear();
        }

        //哈希函数
        int hashFunc(int key){
            int index = key % 100;
            return index;
        }

        //c查询操作
        string get(int key){
            int index = hashFunc(key);
            Pair* pair = buckets[index];
            if(pair == nullptr) return "";
            return pair->val;
        }

        //添加操作
        void put(int key , string val){
            Pair *pair = new Pair(key, val);
            int index = hashFunc(key);
            buckets[index]= pair;
        }

        //删除操作
        void remove(int key){
            int index = hashFunc(key);
            //释放内存并置为nullptr
            delete buckets[index];
            buckets[index]= nullptr;
        }

        //获取所有键值对
        vector <Pair* > pairSet(){
            vector<Pair* > pairSet;
            for(Pair *pair: buckets){
                if(pair!=nullptr){
                    pairSet.push_back(pair);
                }
            }
            return pairSet;
        }

        //获取所有键
        vector<int> keySet(){
            vector<int> keySet;
            for(Pair* pair: buckets){
                if(pair!=nullptr){
                    keySet.push_back(pair->key);
                }
            }
            return keySet;
        }

        //获取所有值
        vector<string> valueSet(){
            vector<string> valueSet;
            for(Pair *pair: buckets){
                for(pair!=nullptr){
                    valueSet.push_back(pair->val);
                }
            }
            return valueSet;
        }

        //打印哈希表
        void print(){
            for(Pair* kv: pairSet()){
                cout<<kv->key<<" -> "<<kv->val<<endl;
            }
        }
    };

哈希冲突,由于上面了解到将大的空间key映射到小的输出空间,理论上存在多个输入对应相同输出的情况,这种情况被称作哈希冲突,显然,还洗标容量增大会减少哈希冲突,即可以通过扩容哈希表减少冲突。

负载因子常作为哈希表扩容的触发条件。在Java中负载因子超过0.75,哈希表就会扩容至原来的2倍。

二 :哈希冲突

哈希冲突会导致查询结果错误,严重影响哈希表的可用性,而直接使用哈希表扩容的方法解决冲突,虽然简单粗暴,但是效率太低。因为扩容需要大量的数据搬运和哈希值计算。

哈希表的结构改良:链式地址、开放寻址。

1.链式地址

原始哈希表中,每个桶仅装一个键值对。链式地址:将单个元素转化为链表,将键值对作为链表节点,将所有发生冲突的键值对都储存在一个链表中。如下图

链式地址实现的哈希表,操作方法发生了以下变化:

        查询元素:输入key,经哈希函数得到桶索引,既可以访问链表头节点,然后遍历链表并对比                           key已以查询目标键值对。

        添加元素:先通过哈希函数访问链表头节点,然后将节点(键值对)添加至链表中。

        删除元素:根据哈希函数的访问结果访问链表头部,接着遍历链表以查找目标节点,并将其                             删除。

链式地址的局限性:

        占用空间大:链表包含节点指针,它相比数组更加耗费内存空间。

        查询效率降低:因为还得遍历链表查找对应元素。

实现代码如下,注意包含哈希扩容方法,当负载因子超过2/3时,哈希表扩容至2倍。

//键值对
    struct Pair{
        public:
        int key;
        string val;
        Pair(int key, string val){
            this->key=key;
            this->val=val;
        }
    };

class HashMapChaining{
 private:
    int size; //键值对数量
    int capacity;//哈希表容量
    double loadThres;//触发扩容的负载因子阈值
    int extendRatio; //扩容倍数
    vector<vector<Pair *>> buckets;//桶数组

 public:
    //构造函数
    HashMapChaining():size(0),capacity(4),loadThres(2.0/3.0),extendRatio(2){
        buckets.resize(capacity);
    }

    //析构函数
    ~HashMapChaining(){
        for(auto &bucket : buckets){
            for(Pair *pair:bucket){
                //释放内存
                delete pair;
            }
        }
    }

    //哈希函数
    int hashFunc(int key){
        return key%capacity;
    }

    //负载因子
    double loadFactor(){
        return (double)size/(double)capacity;
    }

    //查询操作
    string get(int key){
        int index = hashFunc(key);
        //遍历桶,若找到key,则返回对应的value
        for(Pair *pair: buckets[index]){
            if(pair->key==key){
                return pair->val;
            }
        }
        //若未找到key,则返回空字符串
        return "";
    }

    //添加操作
    void put(int key, string val){
        //当负载因子超过阈值时,则执行扩容
        if(loadFactor()>loadThres){
            extend();
        }
        int index=hashFunc(key);
        //遍历桶,直到遇到指定的key,则更新对应的val并返回
        for(Pair* pair: buckets[index]){
            if(pair->key==key){
                pair->val=val;
                return ;
            }
        }
        //若找不到该key,将该键值对添加到尾部
        buckets[index].push_back(new Pair(key,val));
        size++;
    }

    //删除操作
    void remove(int key){
        int index=hashFunc(key);
        auto &bucket = buckets[index];
        //遍历桶,从中删除键值对
        for(int i = 0 ; i<bucket.size(); i++){
            if(bucket[i]->key==key){
                Pair *tmp = bucket[i];
                bucket.erase(bucket.begin()+i);//从中删除键值对
                delete tmp;
                size--;
                return;
            }
        }
    }

    //扩容哈希表
    void extend(){
        //暂存原哈希表
        vector<vector<Pair* > > bucketsTmp = buckets;
        //初始化扩容的新哈希表
        capacity *= extendRatio;
        buckets.clear();
        buckets.resize(capacity);
        size=0;
        //将键值对从原哈希表搬运至新哈希表
        for(auto &bucket : bucketsTmp){
            for(Pair *pair:bucket){
                put(pair->key,pair->val);
                //释放内存
                delete pair;
            }
        }
    }

    //打印函数
    void print(){
        for(auto &bucket:buckets){
            cout<<"[ ";
            for(Pair* pair:bucket){
                cout<<pair->key<<" - > "<<pair->val<<",";
            }
            cout<<"]\n";
        }
    }


};

当链表很长时,查询效率 𝑂(𝑛) 很差。此时可以将链表转换为“AVL 树”或“红黑树”,从而 将查询操作的时间复杂度优化至 𝑂(log 𝑛) 。

2.开放寻址

此方式不引入额外的数据结构,通过“多次试探”解决哈希冲突,试探方式:线性试探、平方试探、多次哈希等。具体实现代码略。

三:哈希算法

前面介绍哈希表的工作原理和哈希冲突的处理方法。无论是开放寻址还是链式地址,只能保证哈希表可以在发生冲突时正常工作,而无法减少哈希冲突的发生。此时如果哈希冲突比较频繁,哈希表的行嗯会急剧恶化。

键值对的分布情况由哈希函数决定,哈希函数的计算步骤;先计算哈希值,再对数组长度进行取模。即

index = hash(key) % capacity

当capacity固定时,哈希算法hash()决定了输出值,进而决定了键值对在哈希表中的分布情况。意味着未来降低哈希冲突发生的概率,需要集中注意力到哈希算法上。

1.哈希算法的目标

哈希算法应具备以下特点:

确定性:相同输入,得到相同输出。

效率高:计算哈希值应该足够快,开销小。

均匀分布:应使得键值对尽量均匀分布在哈希表中。

密码储存:保护用户密码的安全性。

数据完整性检查:哈希算法需要具备更高级的安全特性。

单向性:无法通过哈希值反推出关于输入数据的相关信息。

抗碰撞性:极难找到两个不同输入,使得他们的哈希值相同。

雪崩效应:输入的微小变化应当导致输出的显著变化,且不可预测。

2. 哈希算法的设计

哈希算法设计是一个很复杂问题,对于一些要求不高的场景,可以设计一些简单的哈希算法。

加法哈希:对输入的每个字符的ASCII码进行相加,将得到的总和作为哈希值,

乘法哈希:利用乘法的不相关性,每轮乘以一个常数,将各个字符的ASCII码累积到哈希值中,

异或哈希:将输入的每一个元素通过异或操作雷击到一个哈希值中,

旋转哈希:将每个字符的ASCII码累积到一个哈希值中,每次累积之前都对哈希值进行旋转操作。

//加法哈希
int addHash(string key){
    long long hash=0;
    const int MODULUS = 1000000007;
    for(unsigned char c : key){
        hash = (hash + (int)c)%MODULUS;
    }
    return (int)hash;
}

//乘法哈希
int mulHash(string key){
    long long hash = 0;
    const int MODULUS = 1000000007;
    for(unsigned char c : key){
        hash = (31*hash + (int)c)%MODULUS;
    }
    return (int)hash;
}

//异或哈希
int xorHash(string key){
    int hash=0;
    const int MODULUS = 1000000007;
    for(unsigned char c : key){
        hash ^=(int )c;
    }
    return hash&MODULUS;
}

//旋转哈希
int rotHash(string key){
    long long hash=0;
    const int MODULUS = 1000000007;
    for(unsigned char c: key){
        hash=((hash<<4)^(hash>>28)^(int)c)%MODULUS;
    }
    return (int)hash;
}

使用大质数作为模数,可以最大化地保证哈希值的均匀分布哦!

在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA‑1、SHA‑2 和 SHA‑3 等。它们可以将任意长 度的输入数据映射到恒定长度的哈希值。

声明:本人所写内容全部参考hello-algo,仅用于个人复习。

  • 50
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值