Fork me on GitHub

《数据结构与算法分析》学习笔记-第五章-散列


散列表只支持二叉查找树所允许的一部分操作。散列是一种用于以常数平均时间执行插入、删除和查找的技术。但是,那些需要元素间任何排序信息的操作将不会得到有效的支持,例如FindMin、FindMax以及以线性时间将排过序的整个表进行打印的操作都是散列所不支持的

5.2 散列函数

  1. 关键字是整数:保证表的大小为素数。直接返回Key mod TableSize
  2. 关键字是字符串:根据horner法则,计算一个(32的)多项式函数。
Index
Hash(const char *Key, int TableSize)
{
    unsigned int HashVal = 0;
    while (*Key != '\0')
        //HashVal = (HashVal << 5) + *Key++;
        HashVal = (HashVal << 5) ^ *Key++;
    
    return HashVal % TableSize;
}

如果关键字特别长,那么散列函数计算起来会花过多的时间,而且前面的字符还会左移出最终的结果。因此这样情况下,不使用所有的字符。此时关键字的长度和性质会影响选择。例如只取奇数位置上的字符来实现散列函数。这里的思想是用计算散列函数省下来的时间来补偿由此产生的对均匀分布函数的轻微干扰

  • 当一个元素插入的位置已经存在另一个元素的时候(散列值相同),就叫做冲突。下面介绍解决冲突的两种方法:分离链接法和开放定址法。

5.3 分离链接法(separate chaining)

  • 将散列到同一个值的所有元素保留到一个表中。比如链表。为方便起见,这些表都有表头;如果空间很紧的话,则可以不用表头。
  • 执行Find:首先根据散列函数判断该遍历哪个表,然后遍历链表返回元素位置
  • 执行Insert: 首先根据散列函数判断该插入哪个表,然后插入元素到链表中。如果要插入重复元,那么通常要留出一个额外的域,这个域当重复元出现时增1.通常将元素插入到表的前端,因为新元素最有可能被最先访问

5.3.1 实现

  • 节点定义: 这里使用了typedef,避免双重指针的混乱
#define MINTABLESIZE 11
struct HashTbl;
typedef struct HashTbl *HashTable;

typedef Stack List;
struct HashTbl
{
    int TableSize;
    List *TheLists;
};
  • InitializeTable
HashTable
InitializeTable(int TableSize)
{
    if (TableSize < MINTABLESIZE) {
        printf("TableSize too small\n");
        return NULL;
    }
    
    HashTable H = NULL;
    H = (HashTable)malloc(sizeof(struct HashTbl));
    if (H == NULL) {
        printf("HashTable malloc failed\n");
        return NULL;
    }
    memset(H, 0, sizeof(struct HashTbl));
    
    H->TableSize = GetNextPrime(TableSize);
    H->TheLists = (List *)malloc(sizeof(List) * H->TableSize);
    if (H->TheLists == NULL) {
        printf("HashTable TheLists malloc failed\n");
        free(H);
        H = NULL;
        return NULL;
    }
    memset(H->TheLists, 0, sizeof(List) * H->TableSize);
    
    int cnt, cnt2;
    for (cnt = 0; cnt < H->TableSize; cnt++) {
        H->TheLists[cnt] = CreateStack();
        if (H->TheLists[cnt] == NULL) {
            printf("H->TheLists[%d]malloc failed\n", cnt);
            for (cnt2 = 0; cnt2 < cnt; cnt2++) {
                if (H->TheLists[cnt2] != NULL) {
                    DistroyStack(H->TheLists[cnt2]);
                    H->TheLists[cnt2] = NULL;
                }
            }
            if (H->TheLists != NULL) {
                free(H->TheLists);
                H->TheLists = NULL;
            }
            if (H != NULL) {
                free(H);
                H = NULL;
            }
            return NULL;
        }
    }
    
    return H;
}
  • Find
PtrToNode
Find(ElementType Key, HashTable H)
{
    if (H == NULL) {
        printf("ERROR: H is NULL\n");
        return NULL;
    }
    
    PtrToNode tmp = NULL;
	tmp = H->TheLists[GetHashSubmit(Key, H->TableSize)]->Next;
	while (tmp != NULL && tmp->Element != Key) {
		tmp = tmp->Next;
	}
    return tmp;
}
  • Insert
void
Insert(ElementType Key, HashTable H)
{
	if (H == NULL) {
		printf("HashTable is NULL\n");
		return;
	}
	
	if (0 != Push(Key, H->TheLists[GetHashSubmit(Key, H->TableSize)])) {
		printf("Insert Key failed\n");
	}
}
  • 散列表的装填因子为散列表的元素个数与散列表大小的比值
  • 执行一次查找所需时间是计算散列函数值所需要的常数事件加上遍历表(list)所用的事件。不成功的查找,也就是遍历整个链表长度。成功的查找则需要遍历大约1+链表长度/2.
  • 装填因子是最重要的。一般法则是使得表的大小尽量与预料的元素个数差不多,也就是让装填因子约等于1.
  • 同时,使表的大小是一个素数以保证一个好的分布,这也是一个好的想法

5.4 开放定址法(Open addressing hashing)

  • 由于分离链接法插入时需要申请内存空间,因此算法速度有些减慢
  • 如有冲突发生,那么就要尝试选择另外的单元,直到找出空的单元为止。更一般的,单元h0(x), h1(x), h2(x),相继被试选,其中hi(x) = (Hash(x) + F(i)) mod TableSize, 且F(0) = 0。函数F是冲突解决方法
  • 因为所有的数据都要置于表内,所以开放定址散列法所需要的表比分离链接散列表大。一般说来,对开放定址散列算法来说,装填因子应该低于0.5
  • 下面来考察三个通常的冲突解决方法

5.4.1 线性探测法

  • 典型情形:F(i) = i。只要想插入的单元已经有元素,就继续遍历到下一个单元,直到找到空的单元插入为止(解决冲突)。这样花费的时间很多,而且即使表相对较空。这样占据的单元会开始形成一些区块,其结果成为一次聚集。于是,散列到区块中的任何关键字都需要多次试选单元才能解决冲突,然后该关键字被添加到相应的区块中
  • 插入 & 不成功的查找的预期探测次数大约都为1/2 (1 + 1/(1 - 装填因子)^2);
  • 对于成功的查找来说,则是1/2(1 + 1/(1 - 装填因子))。可以看出成功查找应该比不成功查找平均花费较少的时间
  • 空单元所占份额为1 - 装填因子。因此预计要探测的单元数为1 / (1 - 装填因子)
  • 一个元素被插入时,可以看成是一次不成功查找的结果,因此可以使用一次不成查找的开销来计算一次成功查找的平均开销

5.4.2 平方探测法

  • 平方探测就是冲突函数为二次函数的探测方法。典型是F(i) = i2。产生冲突时,先寻找当前单元的下20 = 1个单元,如果还是冲突,则寻找当前单元的下2^2 = 4个单元,直到找到空单元为止。
  • 对于线性探测,让元素几乎填满列表并不是个好主意,因为表的性能会下降的厉害。而对于平方探测法,一旦表被填满超过一半,当表的大小不是素数时甚至在表被填满一半之前,就不能保证一次找到一个空单元了。这是因为最多有表的一半可以用作解决冲突的被选位置
  • 定理5.1:如果使用平方探测,且表的大小是素数,那么当表至少有一半是空的时候,总能够插入一个新的元素。
证明:
令表的大小TableSize是一个大于3的素数。我们证明,前[TableSize / 2]个备选位置是互异的。
h(X) + i^2(mod TableSize)和h(X) + j^2(mod TableSize)是这些位置中的两个,其中0 < i, j <= [TableSize / 2]。为推出矛盾,假设这两个位置相同,但i != j,于是

1) h(X) + i^2 = h(X) + j^2 (mod TableSize)
2) i^2 - j^2 = 0
3) (i + j)(i - j) = 0

所以i = -j或者i = j,因为i != j,且i,j都大于0,所以前[TableSize / 2]个备选位置是互异的
  • 由于要被插入的元素,若无任何冲突发生,也可以放到经散列得到的单元,因此任何元素都有[TableSize / 2]个可能被放到的位置,如果最多有[TableSie / 2]个位置可以使用,那么空单元总能够找到
  • 哪怕表有比一半多一个的位置被填满,那么插入都有可能失败
  • 表的大小是素数也非常重要,如果表的大小不是素数,则备选单元的个数也可能锐减
  • 在开放定址散列表中,标准的删除操作不能实行。因为相应的单元可能已经引起过冲突,元素绕过了它存在了别处。因此,开放定址散列表需要懒惰删除。
  • 虽然平方探测排除了一次聚集,但是散列到同一位置上的那些元素将探测相同的备选单元,这叫做二次聚集。对于每次查找,它一般要引起另外的少于一半的探测,因此可以使用双散列,通过一些额外的乘法和除法解决这个问题

5.4.3 双散列

  • F(i) = i * hash2(X)。将第二个散列函数应用到X并在距离hash2(X),2hash2(X)等处探测。hash2(X)选择的不好将会是灾难性的
  • 保证所有的单元都能被探测到
  • hash2(X) = R - (X mod R)这样的函数将起到良好的作用;R为小于TableSize的素数。举例:hash2(49) = 7 - 0 = 7,如果位置9产生冲突,则9 + 7 - 10 = 6,看位置6是否产生冲突,如果仍然冲突,则 6 + 7 - 10 = 3,如果位置3没有冲突则插入位置3.
  • 如果散列表的大小不是素数,那么备选单元就有可能提前用完。如果双散列正确实现,则预期的探测次数几乎和随机冲突解决方法的情形相同,这使得双散列理论上很有吸引力,不过平方探测不需要使用第二个散列函数,从而在时间上可能更简单并且更快

5.5 再散列

  • 对于使用平方探测的开放定址散列法,如果表的元素填的太慢,那么操作时间将会消耗过长,且Insert操作可能失败。一种解决办法是建立另外一个大约两倍大的表,而且使用一个相关的新散列函数。扫描整个原始散列表,计算每个未删除的元素的新散列值并将其插入到新表中
  • 如果再散列是程序的一部分,那么其效果是不显著的,但是如果它作为交互系统的一部分运行,那么其插入引起的再散列的用户就会感到速度缓慢
  • 实现方法:
    1. 只要表填满一半就再散列
    2. 只有插入失败时才再散列
    3. 当表达到某一个装填因子时就再散列
  • 再散列把程序员从表的大小的担心中解放出来,再散列还能用在其他数据结构中,例如队列变满时,可以声明一个双倍大小的数组,并将每一个成员拷贝过来同时释放原来的队列
HashTable
ReHash(HashTable H)
{
	if (H == NULL) {
		printf("H is NULL!\n");
		return NULL;
	}

	int cnt;
	
	int OldTableSize = H->TableSize;
	Cell *OldCells = H->TheCells;
	HashTable newTable = InitializeTable(2 * OldTableSize);
	for (cnt = 0; cnt < OldTableSize; cnt++) {
		if (OldCells[cnt].Info == Legitimate) {
			Insert(newTable, OldCells[cnt].Element);
		}
	}
	DestroyTable(H);
	return newTable;
}

5.6 可扩散列

  • 如果数据量太大以至于装不进主存,可以考虑使用可扩散列。根据上一节的描述,如果表变得过满就要执行再散列,这样代价巨大,因为它需要O(N)次磁盘访问。而可扩散列允许两次磁盘访问执行一次Find,插入操作也需要很少的磁盘访问
  • 目录中的项数为2^D,dL为树叶L所有元素共有的最高位的位数,dL将依赖于特定的树叶,因此dL <= D
  • 如果树叶中的元素满了,即 = M,这时再插入就会分裂成两片树叶,目录也会更新大小。
  • 有可能一片树叶中的元素有多余D + 1个前导位相同时,需要多个目录分裂
  • 存在重复关键字的可能性,若存在多于M个重复关键字,则该苏纳法根本无效,此时需要做出其他的安排
  • 这些比特完全随机是相当重要的,可以通过把这些关键字散列到合理长的整数来完成
  • 可扩散列的特性:基于合理假设即“位模式是均匀分布的”。
    1. 树叶的期望个数为(N/M)log(2)e,因此平均树叶满的程度为ln2 = 0.69。这和B树是一样的
    2. 目录的期望大小即2^D, 为O(N^(1+1/M)M),如果M很小,那么目录可能过分的大。这种情况下,我们可以让树叶包含指向记录的指针而不是实际的记录,这样可以增加M的值,为了维持更小的目录,可以把第二个磁盘访问添加到每个Find操作中去,如果目录太大装不进主存,那么第二个磁盘访问怎么说也还是需要的

总结

  • 散列表可以在常数平均时间实现Insert和Find操作
  • 使用散列表时,设置装填因子特别重要,否则时间界将不再有效
  • 当关键字不是短串或是整数时,仔细选择散列函数也是很重要的
  • 对于分离连接散列法,虽然装填因子比较小时性能不明显降低,但是装填因子还是应该接近1
  • 对于开放定址散列法,除非完全不可避免,否则装填因子不应该超过0.5。如果使用线性探测,那么性能随着装填因子接近于1而急速下降。再散列运算可以通过使表增长或收缩来实现,这样将会保持合理的装填因子。对于空间紧缺并且不可能声明巨大散列表的情况,这是很重要的
  • 二叉查找树可以用来实现Insert & Find。虽然平均时间界为O(logN),但是二叉查找树也支持那些需要序的例程从而更实用。使用散列表不可能找出最小元素。除非准确知道一个字符串,否则散列表也不可能有效的查找它。而二叉查找树可以迅速找到在一定范围内的所有项,散列表是做不到的。不仅如此,O(logN)并不比O(1)大那么多,特别是因为查找树不需要乘法和除法
  • 散列的最坏情况一般来自于实现的缺憾,而有序的输入却可能使二叉树运行的很差。平衡查找树实现的代价很高。因此,如果不需要序的信息以及对输入是否被排序有怀疑,那么就应该选择散列这种数据结构
  • 散列的应用:
    1. 编译器使用散列表跟踪源代码中声明的变量,即符号表。标识符一般都不长,因此其散列函数能够迅速被算出
    2. 图论问题中节点有实际的名字而不是数字。而且输入很可能是一组一组依字母顺序排列的项。如果使用查找树则在效率方面可能会很低
    3. 游戏中的变换表
    4. 在线拼写检验程序

参考文献

  1. Mark Allen Weiss.数据结构与算法分析[M].America, 2007

本文作者: CrazyCatJack

本文链接: https://www.cnblogs.com/CrazyCatJack/p/13340018.html

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

关注博主:如果您觉得该文章对您有帮助,可以点击文章右下角推荐一下,您的支持将成为我最大的动力!


posted @ 2021-02-19 23:53  CrazyCatJack  阅读(922)  评论(0编辑  收藏  举报