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


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

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

本文做者: CrazyCatJackgit

本文连接: https://www.cnblogs.com/CrazyCatJack/p/13340018.html程序员

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

关注博主:若是您以为该文章对您有帮助,能够点击文章右下角推荐一下,您的支持将成为我最大的动力!数组

相关文章
相关标签/搜索