数据结构与算法—散列表

Posted 为了维护世界和平_

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构与算法—散列表相关的知识,希望对你有一定的参考价值。

目录

散列表

散列函数

散列冲突解决

1、开放寻址法

1.1 线性探测

1.2 二次探测

1.3 双重散列

2、链表法

使用场景

单词查找

散列表与链表的结合使用LRU

散列表总结

散列表实例


散列表

Word 单词拼写功能,如何实现的?散列表(Hash Table)

散列表用的是数组支持按照下标随机访问数据的特性。散列表其实是数组的一种扩展。

用空间换时间

散列表就是用数组支持按照下标随机访问的时候,时间复杂度O(1)的特性

通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应的下标位置。当查询键值元素时,用散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。

散列函数

Hash(key)  key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值

散列函数设计的要求

  1. 散列函数计算得到的散列值是一个非负整数
  2. 如果key1=key2,hash(key1) = hash(key2)
  3. 如果key1不等于key2,那hash(key1)不等于hash(key2) 

散列冲突无法避免,如著名的MD5,SHA,CRC.而且数组的存储空间有限,也会增大散列冲突的概率。

1、设计散列函数

要点1,散列函数的设计不能太复杂;2、散列函数生成的值要尽可能随机并且均匀分布。

方法:直接寻址法,平方取中法,折叠法,随机数法。

Hash("nice") = (("n" - "a") * 26 * 26 *26 + ("i" - "a")*26 * 26 + ("c" - "a")*26 + ("e"-"a"))

散列冲突解决

1、开放寻址法

如果出现散列冲突,重新探测一个空闲位置,将其插入。

探测方法:

1.1 线性探测

插入:线性探测,存储位置被占用,就从当前位置开始,依次往后查找(尾部结束,接着从头找),直到找到空闲位置为止。

查找:通过散列函数求出要查找元素的键值对应的散列值,然后从数组中下标为散列值的元素和查找的元素。如果相等,找到。如果没有,顺序往后依次查找。如果变量数组中空位置,还没有找到,则说明查找元素并没有在散列列表中。

删除:特殊标记deleted。当线性探测查找的时候,遇到标记为deleted空间。并不是停下来,而且继续探测。

缺点,当插入的数据越来越多,散列冲突的可能性越来越大,空闲位置越来越少,探测时间越久。

1.2 二次探测

与线性探测很像,线性探测每次探测的步长是1,hash(key)+0,hash(key)+1, hash(key)+2

二次探测每次探测的步长变成原来的二次方 hash(key)+0 hash(key)+1^2 hash(key)+2^2

1.3 双重散列

不仅仅要使用一个散列函数,使用一组散列函数hash1(key),hash2(key) ,hash3(key)。函数依次使用,直到找到空闲位置。

不管那种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率会大大提高。一般情况下,尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子来表示空位的多少。

        散列表的装载因子 填入表中元素个数 / 散列表的长度

装载因子越大,数目空闲位置越少,冲突越多,散列表的性能会下降。

装载因子过大解决方法

动态扩容,降低装载因子

装载因子的阈值设置要权衡时间、空间复杂度。如果内存空间多,对执行效率要求高,可以降低负载因子的阈值;反之,增加负载因子。

动态扩容,可能需要多次。可以将扩容操作穿插在插入操作过程中,分批完成。当装载因子大于阈值后,值申请空间,并不将老的数据搬移到新散列表中。

动态扩容期间的插入操作

当有新数据要插入时,将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入新散列表。每次插入一个数据到散列表。重复上面的过程,老的数据搬移到新散列表中。插入操作的时间不受影响。时间复杂度O(1)

  • 优点:散列表中的数据都存储在数组中,可以有效利用CPU缓存,加快查询速度。
  • 缺点:删除数据比较麻烦,需要标记已删除的数据

当数据量比较小,装载因子小 

2、链表法

在散列表中,每个会对应一条链表。所有散列值相同的元素我们都放到相同桶位对应的链表中。

当插入时,只需要通过散列函数计算出对应的散列桶位。

将其插入到对应链表中。时间复杂度O(1)

查找,删除一个元素。计算对应的桶,然后遍历链表查找或删除。复杂度与链表长度k成正比O(k)

  • 优点:对内存利用率比较好,需要时申请;对于大装载因子的容忍度更高,只要散列函数的随机值均匀,即使装载因子10,也就是链表的长度变长,查找效率下降,但比顺序表查找快很多。
  • 链表的升华,更加高效的散列表;
  • 缺点:链表因为要存储指针,对于较小的对象,比较耗内存。链表节点零散分布,不连续,对CPU缓存不友好。
  • 使用跳表、红黑树取代链表。即使冲突,在极端情况下,查找时间O(logn)

 

使用场景

1、单词查找

常用英文单词20万左右。假设单词平均长度是10个字母,占用10字节空间。

  •  20万英文单词,大约占用2MB空间。这个大小完全可以放在内存里。
  • 当用户输入某个英文单词时,拿用户输入的单词去散列表中查找。如果查到,则说明拼写正确。

2、10万条URL访问日志

如何按照访问次数给URL排序?

  • 遍历10万条数据,以URL为key,访问次数为value,存入散列表中。同时记录最大访问的次数K。时间复杂度O(n)
  • 如果K不是很大,可以使用桶排序,时间复杂度O(n),如果K很大,(如大于10万),就使用快速排序 O(nlogn) 

3、两个字符串数组比较

有两个字符串数组,每个数组有10万条字符串,如何快速找出两个数组中相同的字符串。

  •  以第一个字符串构建散列表,key为字符串,value表示出现次数。
  • 变量第二个字符串数组,以字符串key在散列表中查找,如果value大于0,则说明存在相同字符串。时间复杂度O(n)

4、散列表与链表的结合使用LRU

借助散列表,可以把LRU缓存淘汰算法的时间复杂度降低为O(1)

  • 当需要缓存数据时,先在链表中查找这个数据,没有找到,直接将数据放到链表尾部;如果找到了,就把它移动到链表的尾部。所以单纯的使用链表实现LRU缓存淘汰算法的时间复杂度很高O(n)
  • 将散列表与链表一起使用,时间复杂度O(1) 

散列表总结

工业级散列表的特性

1,支持快速插入语、删除查找

2、内存占用,不能浪费过多内存;

3、稳定性,极端情况下的退化

实现散列表

1、设计合适的散列函数

2、定义装载因子阈值,并支持动态扩容

3、选择合适的散列冲突解决方法

散列表实例



#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <time.h>

#define HASH_SHIFT 4
#define HASH_SIZE (1<<HASH_SHIFT)
#define HASH_MASK (HASH_SIZE - 1)


struct hash_table
	unsigned int used;
	unsigned long entry[HASH_SIZE];
;


void hash_table_reset(struct hash_table *table)

	int i;
	table->used = 0;
	for(i = 0;i<HASH_SIZE;i++)
		table->entry[i] = ~0;


unsigned int hash_function(unsigned long value)

	return value & HASH_MASK;



void dump_hash_table(struct hash_table *table)

	int i;
	for(i = 0;i<HASH_SIZE;i++)
	
		if(table->entry[i] == ~0)
			printf("%2u: ",i);
		else
			printf("%2u: %2u\\n",i,table->entry[i],hash_function(table->entry[i]));
	


void hash_function_test()

	int i;
	srandom(time(NULL));

	for(i = 0;i<10;i++)
	
		unsigned long val = random();
		printf("%10u->%2u",val,hash_function(val));
	


unsigned int next_probe(unsigned int pre_key)

	return(pre_key + 1) & HASH_MASK;


void next_probe_test()

	int i;
	unsigned int key1,key2;

	key1 = 0;
	for(i = 0;i<HASH_SIZE;i++)
	
		key2 = next_probe(key1);
		printf("%2u -> %2u\\n",key1,key2);
		key1 = key2;
	


void hash_table_add(struct hash_table *table,unsigned long value)

	unsigned int key = hash_function(value);

	if(table->used >= HASH_SIZE)
		return;

	while(table->entry[key] != ~0)
		key = next_probe(key);

	table->entry[key] = value;
	table->used++;


unsigned int hash_table_slot(struct hash_table *table,unsigned long value)

	int i;
	unsigned int key = hash_function(value);

	for(i = 0;i<HASH_SIZE;i++)
		if(table->entry[key] == value || table->entry[key] == ~0)
			break;

		key = next_probe(key);
	

	return key;


bool hash_table_find(struct hash_table *table,unsigned long value)

	return table->entry[hash_table_slot(table,value)] == value;



void hash_table_del(struct hash_table *table,unsigned long value)

	unsigned int i,k,j;
	if(!hash_table_find(table,value))
		return;

	//find
	i = j = hash_table_slot(table,value);

	while(true)
	
		table->entry[i] = ~0;
		do
			j = next_probe(j);
			if(table->entry[j] == ~0)
				return;

			k = hash_function(table->entry[j]);
		while((i<=j)?(i<k && k<=j) : (i<k || k<=j));

		table->entry[i] = table->entry[j];
		i = j;
	
	table->used++;


void hash_table_add_test()

	struct hash_table table;
	hash_table_reset(&table);

	hash_table_add(&table,1234);

	int ret;
	ret = hash_table_find(&table,1234);
	printf("ret res=%d\\n",ret);


void hash_table_del_test()

	struct hash_table table;
	int i;
	hash_table_reset(&table);
	for(i = 0;i<HASH_SIZE;i++)
		hash_table_add(&table,i);

	dump_hash_table(&table);

	hash_table_del(&table,5);

	dump_hash_table(&table);


void main()

	//hash_table_add_test();
	hash_table_del_test();
	return;

以上是关于数据结构与算法—散列表的主要内容,如果未能解决你的问题,请参考以下文章

Python算法哈希存储哈希表散列表原理

算法——散列表

查找算法总结散列表

数据结构 散列表

JAVA数据结构与算法之哈希表

数据结构和算法: 散列表