算法导论第十一章 散列表

一、散列表的概念

  本章介绍了散列表(or hash table)的概念、散列函数的设计及哈希冲突的处理。散列表(为了形象描述,我们通常叫槽)从表意上看是一种数据结构,但把它归为算法思想更为贴切。对于大部分的查找问题,使用散列表能达到O(1)的效率。现在很多大公司在面试大数据的题目时,解决方案里绝对少不了散列表的思想,例如百度的一道面试题:Top K查找问题:

问题描述:
    搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。
    假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是1千万,但如果除去重复后,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就是越热门。),请你统计最热门的10个查询串,要求使用的内存不能超过1G。

当数据量很大的时候,要保证效率,这时采用hash表来处理是最佳的解决方案。(详细可见July的博客:从头到尾解析hash表算法)。

  散列表的实现是通过key-value的形式,将一个需要处理的元素通过某种方式计算key,然后通过key映射到散列表中相应的位置进行value的存储,访问的时候也是同样的思路,其中映射方式通过散列函数来实现。这样简单的一句话,其中蕴含着很多概念:

1)直接寻址表:key == value,直接存储,没有任何计算,前提是value的全域U很小时。

2)散列表:散列函数_HashFunction(value) = key,全域U很大,表有限,通过散列函数将value映射到一个较小的槽,以节省空间。

3)散列函数:散列函数设计的好坏,决定了不同的value映射到相同槽中,即冲突的概率的程度。此有三种设计的思路。

4)散列冲突:好的散列函数能够一定程度上避免冲突,由于随机性,冲突一定会发生。此有两种避免冲突的方法。

二、散列函数的设计:

  好的散列函数的设计能达到事半功倍的效果,那怎么样设计一个好的散列函数呢?回答这个问题需要一定的数学底子,尤其是数论,据前人计算机科学家们多年的总结整理,有这样的三种设计方法,我们不纠结这些方法是如何设计出来,那样就违背了我们学习算法的原则,当然如果你想深究,那是甚好。

1、除法散列法:hash(key) = key % m

其中,m是散列表的大小,该函数的一个指导原则是将m选取为接近散列集合大小的质数

2、乘法散列法:hash(key) = floor( m * ( key * A ) % 1)

其中,floor()表示下取整,m无任何特殊要求,A\in (0,1),Don.Knuth认为A=(√5-1)/2(黄金分割点)最好。

3、全域散列法:hasha,b(key)= ( (a * key + b) % p ) % m

全域散列法的基本思想是:一个固定的散列函数有诸多弊端,譬如说容易被恶意的人随意篡改,将所有关键字映射到一个槽中。这个时候引入随机性可以避免这种弊端:对于每一个关键字,随机选择散列函数,使之独立于要存储的关键字。这样就需要提供一组散列函数供选择,这一组散列函数,能将全域U内的所有关键字映射到槽{0,1,...,m-1}中,故称为全域散列。那如何设计一个全域散列函数类?也是前人已经从数学层面上帮我们想好了,就是上面这个式子。

其中,a \in {0,1,...,m-1}, b \in {0,1,...,m-1},a, b的值都为运行时动态确定,同除法散列一样,m应为质数,p为一个较大的素数。由于a,b的值在运行时随机确定,所以可以形成一个m * (m-1)的散列函数簇。基于随机的思想,全域散列法不管在什么情况下,其平均性能是最好的。

三、Hash冲突的处理

  即使设计了好的Hash函数,但还是不可避免Hash冲突。从宏观上看,有两种方法可以处理Hash冲突,一种是开放寻址法,另一种是链表法。其中,开放寻址法又细分为:线性探查法,二次探查法,双重散列法;链表法和桶排序的思想一样,位每一个槽建立一些个桶来存放散列值相同的value,由于这种方法比较简单,本文就不再做记录,后面的算法实现采用的是这种方法。

1、线性探查:h( key , i ) = ( hash ( key ) + i ) % m

其中,hash(key)为前面所说的任何一种散列函数,线性探查的思想是:当发生冲突的时候,以 +i 个槽的方式寻找空的槽,直到所有槽满了为止,故为线性探查,一般 i=1。这种方法的缺陷是容易陷入群集:即随着元素增加,连续被占用的槽也在增加,表面上看就形成元素的堆积,这样,后续元素的平均查找时间也在增加。

2、二次探查:h ( key, i ) = ( hash( key ) + c1i + c2i2 ) % m

其中,c1、c2为正的辅助常数,i = 0,1,...,m-1,和线性探查不同的是,当发生冲突的时候,后续的探查位置加上一个依赖于 i 的二次方的偏移量,这种探查方式比线性探查要好很多。其中,c1、c2被证明当皆为1/2时性能最好。缺陷是同样群在二次群集的情况,但相对线性探查要好很多。

3、双重探查:h ( key, i ) = ( hash1( key ) + i hash2( key ) ) % m

其中,i = 0,1,...,m-1,hash1( key ) 、hash2( key )均为辅助散列函数,双重试探法的首个探查位置为hash1( key ) 当产生碰撞之后,接下来的探查位置为( hash1( key ) + hash2( key ) ) % m,因此我们发现在双重试探法中,不仅初始探查位置依赖于关键字key,探查序列中的增量hash2(key)同样依赖于关键字key,因而整个散列表提供了m2种不同的探查序列,较之于前两种开放寻址具备了更多的灵活性。

这里需要注意的是:hash2(key)必须与m互素,有一种比较好的方法是:首先取m为素数,然后,hash1( key ) = key % m,hash2( key ) = 1+(k % m‘),其中m‘略小于m,比如m‘ = m-1。

下面使用链表法实现一个全域散列表,即使用全域散列函数:

  1 #include <iostream>
  2 #include <ctime>
  3 #include <vector>
  4 #include <iomanip>
  5 using namespace std;
  6
  7 template<typename T>
  8 class UniversalHashTable {
  9 public:
 10     UniversalHashTable () {
 11         p = 101;    //一个足够大的质数
 12         m = 10;        //槽的个数
 13         _item.resize(m, NULL);
 14
 15         for (int i = 0; i < m; i ++) {
 16             _item[i] = new _Node();
 17             _item[i]->m_pNext = NULL;
 18         }
 19
 20         a = rand() % (p - 1) + 1;
 21         b = rand() % p;
 22     }
 23
 24     ~UniversalHashTable() {
 25         for (int i = 0; i < m; i ++) {
 26             _Node *pT = _item[i]->m_pNext;
 27             while (pT) {
 28                 _Node *p = pT->m_pNext;
 29                 delete pT;
 30                 pT = p;
 31             }
 32         }
 33     }
 34
 35     //向散列表中插入一个元素
 36     void Insert(T const &new_value) {
 37         //始终插入在键表的头,头结点之后的第一个位置
 38         _Node *new_item = new _Node();
 39         new_item->m_nItem = new_value;
 40         new_item->m_pNext = NULL;
 41
 42         int hash_value = _HashFunction(new_value);
 43
 44         new_item->m_pNext = _item[hash_value]->m_pNext;
 45         _item[hash_value]->m_pNext = new_item;
 46     }
 47
 48     //从散列表中删除一个元素
 49     //@return 是否删除成功的这样的元素
 50     bool Delete(T const &delete_value) {
 51         int hash_value = _HashFunction(delete_value);
 52         _Node *root = _item[hash_value];
 53
 54         while (root->m_pNext) {
 55             if (root->m_pNext->m_nItem == delete_value) {
 56                 _Node *temp = root->m_pNext;
 57                 root->m_pNext = root->m_pNext->m_pNext;
 58                 delete temp;
 59                 temp = NULL;
 60                 return true;
 61             }
 62             else
 63                 root = root->m_pNext;
 64         }
 65         return false;
 66     }
 67
 68     //在散列表中搜索一个元素
 69     T * Search(T const &search_value) {
 70         int hash_value = _HashFunction(search_value);
 71         _Node *root = _item[hash_value]->m_pNext;
 72
 73         while(root) {
 74             if (root->m_nItem == search_value) {
 75                 return &(root->m_nItem);
 76             }
 77             else
 78                 root = root->m_pNext;
 79         }
 80         return NULL;
 81     }
 82
 83     //将散列表中所有元素显示在输出流中
 84     void Display(){
 85         for (int i = 0; i < m; i ++) {
 86             _Node *item = _item[i]->m_pNext; //跳过头结点
 87             cout << "槽[" << setw( 3 ) << i << setw( 3 ) << "]";
 88             while(item) {
 89                 cout << " -> " << item->m_nItem;
 90                 item = item->m_pNext;
 91             }
 92             cout << endl;
 93         }
 94     }
 95
 96 private:
 97     struct _Node{
 98         T        m_nItem;
 99         _Node    *m_pNext;
100     };
101
102     //全域散列函数
103     //h(a,b,k) = ((a*k+b) mod p) mod m
104     int _HashFunction(T key) {
105         return static_cast<int>(a * key + b) % p % m;
106     }
107
108     //除法散列
109     int _HashFunctionMul(T key) {
110         return static_cast<int> key % m;
111     }
112
113     //乘法散列
114     //floor表示下取整
115     int _HashFunctionDiv(T key) {
116         return static_cast<int> floor(m * (a * key % 1))
117     }
118
119     int p, m, a, b;
120     vector<_Node *> _item; //用单链表作为散列槽
121 };
122
123 int main()
124 {
125     UniversalHashTable<int> table;
126     cout << "往UniversalHashtable里添加内容[0,50):" << endl;
127     for (int i = 0; i < 50; i ++) {
128         table.Insert(i);
129     }
130     table.Display();
131
132     cout << "开始删除内容[0,5):" << endl;
133     for (int i = 0; i < 5; i ++) {
134         table.Delete(i);
135     }
136     table.Display();
137     for (int i = 0; i < 10; i ++) {
138         int *finded = table.Search(i);
139         cout << "开始检索节点[ " << i << " ]:";
140         if (finded)
141             cout << *finded << endl;
142         else
143             cout << "未找到" << endl;
144     }
145     return 0;
146 }
时间: 2024-11-02 17:50:25

算法导论第十一章 散列表的相关文章

算法导论 第11章 散列表

散列表是主要支持动态集合的插入.搜索和删除等操作,其查找元素的时间在最坏情况下是O(n),但是在是实际情况中,散列表查找的期望是时间是O(1),散列表是普通数组的推广,因为可以通过元素的关键字对数组进行直接定位,所以能够在O(1)时间内访问数组的任意元素. 1.直接寻址表 当关键字的全域较小,即所有可能的关键字的量比较小时,可以建立一个数组,为所有可能的关键字都预留一个空间,这样就可以很快的根据关键字直接找到对应元素,这就是直接寻址表,在直接寻址表的查找.插入和删除操作都是O(1)的时间.. 2

算法导论第11章散列表11.1直接寻址表

/* * IA_11.1DirectAddressTables.cpp * * Created on: Feb 11, 2015 * Author: sunyj */ #include <stdint.h> #include <iostream> #include <string.h> // DIRECT-ADDRESS-SEARCH(T, k) // return T[k] // DIRECT-ADDRESS-INSERT(T, x) // T[x.key] = x

算法导论 第十章 基本数据类型 &amp; 第十一章 散列表(python)

更多的理论细节可以用<数据结构>严蔚敏 看几遍,数据结构很重要是实现算法的很大一部分 下面主要谈谈python什么实现 10.1 栈和队列 栈:后进先出LIFO 队列:先进先出FIFO python 中使用list实现在这些功能 栈:压栈 append() 退栈   pop() 队列:   入队 append() 出队 pop(0) 栈: >>> stack = list() >>> stack.append(3) >>> stack.ap

算法导论之十(十一章散列表11.1-4大数组实现直接寻址方式的字典操作)

11.1-4题目: 我们希望在一个非常大的数组上,通过利用直接寻址的方式来实现一个字典.开始时,该数组中可能包含一些无用信息,但要对整个数组进行初始化是不太实际的,因为该数组的规模太大.请给出在大数组上实现直接寻址字典的方式.每个存储对象占用O(1)空间:SEARCH.INSEART.DELETE操作的时间均为O(1):并且对数据结构初始化的时间为O(1).(提示:可以利用一个附加数组,处理方式类似于栈,其大小等于实际存储在字典中的关键字数目,以帮助确定大数组中某个给定的项是否有效). 想法:

《算法导论》— Chapter 11 散列表

1 序 在很多应用中,都要用到一种动态集合结构,它仅支持INSERT.SEARCH以及DELETE三种字典操作.例如计算机程序设计语言的编译程序需要维护一个符号表,其中元素的关键字为任意字符串,与语言中的标识符相对应.实现字典的一种有效数据结构为散列表. 散列表是普通数组的推广,因为可以对数组进行直接寻址,故可以在O(1)的时间内访问数组的任意元素.对于散列表,最坏情况下查找一个元素的时间与在链表中查找的时间相同,为O(n),但是在实践中,散列表的效率通常是很高的,在一些合理的假设下,散列表中查

第十一章 散列表

摘要: 本章介绍了散列表(hash table)的概念.散列函数的设计及散列冲突的处理.散列表类似与字典的目录,查找的元素都有一个key与之对应,在实践当中,散列技术的效率是很高的,合理的设计散函数和冲突处理方法,可以使得在散列表中查找一个元素的期望时间为O(1).散列表是普通数组概念的推广,在散列表中,不是直接把关键字用作数组下标,而是根据关键字通过散列函数计算出来的.书中介绍散列表非常注重推理和证明,看的时候迷迷糊糊的,再次证明了数学真的很重要.在STL中map容器的功能就是散列表的功能,但

算法导论 第二十一章:不相交集合森林

在不相交集合中的另一种更快的实现中,用有根树来表示集合.树中的每个成员指向其父节点,每棵树的根包含了代表(representative),并且是他自己的父节点.不相交森林即由多棵这样的树组成,如下图所示: [注:(b)是(a)UNION(e,g)的结果] 采用上述表示方法直观上并不比采用链表表示算法更快,但是可以通过"按秩合并"和"路径压缩"来提升效率. 按秩合并(union by rank): 对于每个节点,用秩表示节点高度的一个上界.在按秩合并中,具有较小秩的跟

算法导论 第二十一章:不相交集合的数据结构

不相交集合(Disjoint-set )数据结构保持一组不相交的动态集合S={S(1),S(2),...,S(k)}.每个集合通过一个代表(representative)来识别,即集合中的某个成员.设x表示一个对象,不相交集合支持操作: MAKE-SET(x):建立一个新的结合,其唯一成员(也即代表)就是x.因为各集合是不相交的,故要求x没有在其他集合中出现过. UNION(x,y):将包含x和y的动态集合合并成一个新的集合(即这两个集合的并集).假定在这个操作之前两个集合是不相交的,操作之后,

算法导论 第13章 红黑树

二叉查找树的基本操作包括搜索.插入.删除.取最大和最小值等都能够在O(h)时间复杂度内实现,因此能在期望时间O(lgn)下实现,但是二叉查找树的平衡性在这些操作中并没有得到维护,因此其高度可能会变得很高,当其高度较高时,而二叉查找树的性能就未必比链表好了,所以二叉查找树的集合操作是期望时间O(lgn),最坏情况下为O(n). 红黑树也是一种二叉查找树,它拥有二叉查找树的性质,同时红黑树还有其它一些特殊性质,这使得红黑树的动态集合基本操作在最坏情况下也为O(lgn),红黑树通过给节点增加颜色和其它