数据结构与算法05:跳表和散列表

目录

【跳表】

跳表的实现原理

如何确定跳表的层高?

【散列表】

散列函数的设计

散列冲突

(1)开放寻址法(Open Addressing)

(2)链表法(chaining)

装载因子

如何设计一个比较合理高效的散列表?

散列表的应用:单词拼写检查

散列表的应用:LRU缓存淘汰算法

【每日一练:整数和罗马数字互转】


【跳表】

在 数据结构与算法02:数组和链表_浮尘笔记的博客-CSDN博客 中说过链表插入和删除的时间复杂度是O(1),但是查找数据的时间复杂度是O(n),即使是有序的链表也是如此,那么有没有办法优化一下查找的时间复杂度呢?当然有,可以对有序链表增加“索引”,改造后的数据结构就叫做 跳表(跳跃表),如下图所示:

96313eea59fc474a97d04b225519d8a8.png

原始的链表中如果要查找48,需要经历8次查询,但是添加了两级索引之后,只需要4次即可查到。注意:跳表的前提必须是一个有序的链表

想象一个场景,网上购物填写收货地址的时候,如果把全国所有的区县都平铺开到一个下拉菜单里面去找,会相当费劲,但是使用了“省-市-区”三级联动之后就可以很方便的找到自己所在的区县,这里前两层的“省和市”,就可以理解为跳表的索引。 

跳表可以支持快速的插入、删除、查找操作,从上面的示例图中可以看出来,跳表的空间复杂度是 O(n),在跳表中查询任意数据的时间复杂度是O(logn),跳表中插入和删除操作的时间复杂度也是 O(logn)。跳表实际上运用了“空间换时间”的思维,在链表的基础上增加了索引。如果将包含 n 个结点的单链表构造成跳表,就需要额外再用接近 n 个结点的存储空间,当实际存储的数据对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。想象一下,为什么在数据量大的MySQL表中一定要建立索引呢?其实是同样的道理。

为什么说跳表中插入和删除操作的时间复杂度也是 O(logn)呢?因为如果只是单链表,插入和删除的时间复杂度是O(1),但是现在需要在插入和删除的时候把索引的变动也维护进去。那么,你是否想到了在MySQL中为什么最好不要建立太多的索引?也是同样的道理。

跳表的实现原理

由于在跳表中查找数据是从高往底、从左往右查找的,所以跳表需要记录跳表的数据值、用于排序的分数、层高(索引的高度)、递归存储每一层前进的指针。使用Go语言实现一个跳表的核心 代码 如下:

// 跳表节点结构体
type skipListNode struct {
	v        interface{}     //跳表保存的值
	score    int             //用于排序的分数
	level    int             //层高
	forwards []*skipListNode //每层前进指针,递归
}

// 新建跳表节点
func newSkipListNode(v interface{}, score, level int) *skipListNode {
	return &skipListNode{
		v:        v,
		score:    score,
		forwards: make([]*skipListNode, level, level),
		level:    level,
	}
}

// 跳表结构体
type SkipList struct {
	head   *skipListNode //跳表头结点
	level  int           //跳表当前层高
	length int           //跳表长度
}

// 实例化跳表对象
func NewSkipList() *SkipList {
	// 初始化头结点数据
	head := newSkipListNode(0, 0, MAX_LEVEL) //&{0 0 3 [<nil> <nil> <nil>]}
	return &SkipList{head, 1, 0}
}

// 查找跳表中的元素
func (sl *SkipList) Find(v interface{}, score int) *skipListNode {
	if nil == v || sl.length == 0 {
		return nil
	}
	cur := sl.head
	for i := sl.level - 1; i >= 0; i-- {
		for nil != cur.forwards[i] {
			if cur.forwards[i].score == score && cur.forwards[i].v == v {
				return cur.forwards[i]
			} else if cur.forwards[i].score > score {
				break
			}
			cur = cur.forwards[i]
		}
	}
	return nil
}

如何确定跳表的层高?

最理想的状态下,跳表的每一层都应该包含下一层一半的节点,且同一层指针跨越的节点数量是一样的,就像上面图中所示那样,从上到下的节点数量是2、5、9、17....,也就是(2^n)+1,层数一共是 logN 层,在每一层中最多只会跳跃一次,每一层最多访问两个节点,整体搜索时间复杂度为 O(logN)。但是这样会存在一个问题,那就是在跳表中动态插入和删除的时候,需要不断地调整每一个节点的层数,因为这个层数完全取决于该节点处于链表中的第几个位置。有可能在某个位置插入一个新元素,就要对大量的索引进行调整,性能肯定会下降。

为了避免这个情况,可以采用一定的算法来决定每一层跳跃多少个节点,比如可以使用一定数值范围的随机数,或者50%概率(第一层时 100% 会被插入,第二层只有 50% 的概率会被插入,第三层是 25% 的概率会被插入),这样一来每一层节点之间的间距也会相对均匀,在更新和查找之间取了一个平衡。

比如使用下面4层结构的一个跳表,要插入元素87,过程如下:

这样插入之后,假如将来需要删除87这个节点,也只会删除1、2、3层,第4层就不用改动了。 关于添加元素的核心代码如下:

// 给跳表中插入元素和索引
func (sl *SkipList) Insert(v interface{}, score int) int {
	if nil == v {
		return 1
	}
	cur := sl.head                       //当前需要插入的位置,也就是头结点信息
	update := [MAX_LEVEL]*skipListNode{} //每一层需要更新的数据,组成一个数组

	i := MAX_LEVEL - 1
	for ; i >= 0; i-- {
		for nil != cur.forwards[i] {
			//... 省略边界校验的逻辑
			cur = cur.forwards[i]
		}
		if nil == cur.forwards[i] {
			update[i] = cur
		}
	}

	//通过随机算法获取该节点层数
	level := 1
	for i := 1; i < MAX_LEVEL; i++ {
		if rand.Int31()%7 == 1 {
			level++
		}
	}

	//创建一个新的跳表节点
	newNode := newSkipListNode(v, score, level)

	//原有节点连接
	for i := 0; i <= level-1; i++ {
		next := update[i].forwards[i]
		update[i].forwards[i] = newNode
		newNode.forwards[i] = next
	}

	//如果当前节点的层数大于之前跳表的层数
	//更新当前跳表层数
	if level > sl.level {
		sl.level = level
	}

	//更新跳表长度
	sl.length++

	return 0
}

【问】Redis 为什么选择用跳表实现有序集合(Sorted Set)?为什么不用红黑树呢?

【答】Redis 的有序集合有个重要的功能就是按照区间Score查找数据,可以参考 redis笔记04-无序集合和有序集合_有序集合无序集合_浮尘笔记的博客-CSDN博客 了解详细用法。对于按照区间查找数据的操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。虽然红黑树也可以实现快速的插入、删除和查找操作,但是效率没有跳表高。还有一个原因就是跳表实现起来相对比较容易。关于红黑树后面再说。

【散列表】

对于一个相对较大的任意长度的数据,而且这个数据可能不是存储在连续空间,把这个数据映射到一个相对较小空间的数组里,这里面提到的较小空间的数组就是一个散列表,也可以叫做“哈希表”或者“Hash表”,这个实现映射的过程就是一个散列函数,可以用 hash(key)=value 来表示。散列表的本质是一个数组,可以在O(1)的时间复杂度查找元素。

上面的概念听上去有点绕,我举个例子你感受下。比如现在有个散列函数是对输入的编号数字%100(对100取余数),将得到的余数存储到一个散列表数组中,效果如下:

 

正常情况下哈希值算出来应该是一个正确的数组的索引值,如果哈希值是负数,说明这个哈希算法设计的有问题。 

散列表的优势:可以非常快速的插入、删除、查找元素,无论多少数据,插入和删除的时间复杂度都接近常量;散列表的查找速度比树还要快。散列表的不足之处:散列表中的数据不是有序的,所以不能以一种固定的方式(比如从小到大)来遍历其中的元素,而且散列表中的key是不允许重复的。总结就是:散列表实现了关键字到数组地址的映射,可以在常数时间复杂度内通过关键字查找到数据。

散列函数的设计

散列函数的设计不能太复杂,否则会消耗很多计算时间,也就间接的影响到散列表的性能。散列函数生成的值要尽可能随机并且均匀分布,这样才能让散列冲突尽可能降低。

一般可以参考下面几种方式来设计一个散列函数:

  • 直接寻址法:哈希函数为关键字到地址的线性函数。如 F(key)=a*key+b,这里 a和b是设置好的常数。
  • 数字分析法:假设关键字集合中的每个关键字key都是由s位数字组成(k1, k2, ..., Ks),可以从中提取分布均匀的若干位组成哈希地址,比如手机号后四位作为散列值。
  • 平方取中法:如果关键字的每一位都有某些数字重复出现,并且频率很高,可以先求关键字的平方值,通过平方扩大差异,然后取中间几位作为最终存储地址。
  • 折叠法:如果关键字的位数很多,可以将关键字分割为几个等长的部分,取它们的叠加和的值(舍去进位)作为哈希地址。
  • 计算余数法:预先设置一个数x, 然后对关键字进行取余运算,即 key % x,也就是上面图中所示的方法。

关于散列函数的三个特点:

  • 在同一个散列函数中输入一个任意的原始数据,都可以得到一个相应的哈希值;
  • 在同一个散列函数中输入两个相同的原始数据,它们总会得到相同的哈希值;
  • 在同一个散列函数中输入两个不同的原始数据,它们也有可能得到相同的哈希值,也就是:散列冲突,比如上面图中的余数为2的就存在两个原始数据。

散列冲突

不同的两个或者多个原始数据经过散列函数计算后,是有可能得到一个相同的哈希值,这就是散列冲突。因为数组的存储空间有限,也会加大散列冲突的概率。

常用的散列冲突解决方法有两类,分别是:开放寻址法链表法

(1)开放寻址法(Open Addressing)

开放寻址法 是在数组中寻找一个还未被使用的位置,然后将新的值插入,其实是尽可能的利用数组原本的空间而不去开辟额外的空间来保存值。最简单的实现方法就是沿着数组索引往下一个一个地去寻找还未被使用的空间,这种方法也叫做 线性探测(Linear Probing)。当数据量比较小、装载因子小的时候,适合采用开放寻址法。

比如上面示例中的 030502 在散列后发现位置2已经被占用了,那么就继续向后寻找空闲空间,找到了3还没被使用,就会把它插入到3的位置,如下图所示。假如再来一个应该散列到2的位置的数据,此时发现3也被占用了,那么会继续向后寻找。

实际上这种方法是存在一些问题的,比如向后寻找到数组的一个新的位置,就需要额外记录原来本身的散列信息,才能查找到对应的数据。而且如果散列表已经满了,还得考虑动态扩容的问题。查找元素的时候如果在散列表中的对应位置没有找到,那么还要不断的往后遍历。

当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久,极端情况下可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。

除了上面说的线性探测之外,还有另外两种探测方法,分别是 二次探测(Quadratic probing)双重散列(Double hashing),其实原理都差不多。二次探测是在探测的时候步长变成了原来的“二次方”,探测的下标序列是 hash(key)+0,hash(key)+1^2,hash(key)+2^2,... ;双重散列就是要使用一组散列函数 hash1(key),hash2(key),hash3(key)…… 如果用第一个散列函数计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

(2)链表法(chaining)

相比开放寻址法,链表法相对来说简单一些,是一种更常用的散列冲突解决办法。就是在散列表的数组中再维护一个链表,如下图所示:

当插入元素的时候只需要通过散列函数计算出对应的散列槽位,然后将其插入到对应链表中即可,所以插入的时间复杂度是 O(1);当查找和删除一个元素时同样通过散列函数计算出对应的槽位,然后遍历链表查找或者删除,查找或删除操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。极端情况下,如果有人恶意攻击,所有的数据都散列到了同一个槽位中,那么散列表就会退化为链表,查询的时间复杂度就会退化为O(n)。

如果散列表中有 10 万个数据,退化成链表后的查询效率就下降了 10 万倍。如果之前运行 100 次查询只需要 0.1 秒,那现在就需要 1 万秒。这样就有可能因为查询操作消耗大量 CPU 或者线程资源,导致系统无法响应。这就是散列表碰撞攻击的基本原理。

这种基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,相比开放寻址法更加灵活,比如可以继续使用“空间换时间”的策略,把散列表中每个槽位里面的链表改造成“跳表”、“二叉树”、“红黑树”等其它数据结构。

装载因子

当散列表中空闲位置不多的时候,散列冲突的概率就会提高,一般用装载因子(load factor)来表示空位的多少。装载因子越大,说明空闲位置越少,也就是冲突越多,散列表的性能会下降。

装载因子的计算公式是:填入表中的元素个数 / 散列表的长度,比如当前散列表的长度是100,已填入表中的元素个数为80,那么装载因子就是0.8。

装载因子越来越大的时候,可以重新申请一个更大的散列表(动态扩容),并且将数据搬移到这个新散列表中。假设每次扩容都申请一个原来散列表大小两倍的空间,如果原来的装载因子是0.8,那么扩容后的装载因子就变成了 0.4。散列表扩容后,由于散列表的大小变了,数据的存储位置也变了,所以需要通过散列函数重新计算每个数据的存储位置并搬移数据,所以时间复杂度是 O(n)。

扩容的时机如何控制?当插入数据的时候检测到装载因子过大,最好不要在这个时候一次性搬移所有数据,因为有可能非常耗时导致服务瘫痪。比较合理的做法是:将扩容操作穿插在插入操作的过程中分批次完成,当装载因子到达设定的阈值之后,只申请新空间但并不将老的数据搬移到新散列表中,当有新数据要插入时将这个新数据插入到新散列表中的同时,也从老的散列表中拿出一个数据放入到新散列表,每次插入新数据都重复这个操作。相当于把一次性的搬移操作分散到了多次,压力相对就比较小了。同时在查找数据的时候,如果新的散列表中没有找到,则需要在旧的散列表中再次查找,因为有可能要查找的元素还没搬移到新的散列表中。

如何设计一个比较合理高效的散列表?

可以从以下几个方面考虑:

  • 设计一个合适的散列函数,支持快速的查询、插入、删除操作,并且尽可能让散列后的值随机且均匀分布;
  • 定义装载因子阈值,并且设计动态扩容策略,需要保证内存占用合理,不能浪费过多的内存空间;
  • 选择合适的散列冲突解决方法,保证性能稳定,极端情况下散列表的性能也不会退化到无法接受的情况。

散列表的应用:单词拼写检查

在word或者代码编辑器中一般默认都会有单词检查功能,如果写错了单词会提示,如下图所示。那么这种单词拼接检查是如何高效实现的?

问题分析:常用的英文单词有 20 万个左右,假设单词的平均长度是 10 个字母,平均一个单词占用 10 个字节的内存空间,那 20 万英文单词大约占 2MB 的存储空间,就算放大 10 倍也就是 20MB。对于现在的计算机来说,这个大小完全可以放在内存里面,所以可以使用散列表来存储整个英文单词词典。

实现方法:当用户输入某个英文单词时,拿用户输入的单词去散列表中查找,如果查到了说明拼写正确;如果没有查到说明拼写可能有误。使用散列表这种数据结构,可以快速判断是否存在拼写错误。

散列表的应用:LRU缓存淘汰算法

在 数据结构与算法02:数组和链表_浮尘笔记的博客-CSDN博客 这篇文章中使用原始的链表实现了一个LRU淘汰算法,由于不管缓存有没有满,都需要遍历一遍链表,所以基于链表实现LRU淘汰算法的时间复杂度为 O(n),并不是一个理想的结果。如果使用散列表来实现LRU算法,可以把添加、删除、查找的时间复杂度都降为O(1),参考下图:

  • 查找数据:散列表中查找数据的时间复杂度接近 O(1),所以通过散列表可以很快地在缓存中找到一个数据。当找到数据之后还需要将它移动到双向链表的尾部。
  • 删除数据:需要找到数据所在的结点然后将结点删除,借助散列表可以在 O(1) 时间复杂度里找到要删除的结点。因为双向链表可以通过前驱指针 O(1) 时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要 O(1) 的时间复杂度。
  • 添加数据:先看这个数据是否已经在缓存中,如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。 

参考资料:20 | 散列表(下):为什么散列表和链表经常会一起使用?-极客时间

【每日一练:整数和罗马数字互转】

力扣12. 整数转罗马数字

罗马数字包含以下七种字符: I(1), V(5), X(10), L(50),C(100),D(500) 和 M(1000)。
例如, 罗马数字 2 写做 II ,即为两个并列的 1。12 写做 XII ,即为 X + II 。 27 写做  XXVII, 即为 XX + V + II 。

示例 1: 输入: num = 3,输出: "III";
示例 2: 输入: num = 4,输出: "IV"。

思路:可以将所有罗马数字的不同符号及对应整数放在字典中。时间复杂度: O(N),空间复杂度: O(1)。

func intToRoman(num int) string {
	// 初始化了一个 一一对应的map,方便后面取出符号。
	lookupSymbol := []string{"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"}
	lookupNum := []int{1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1}
	roman := ""
	for i, symbol := range lookupSymbol {
		val := lookupNum[i]
		for num >= val {
			roman += symbol
			num -= val
		}
	}
	return roman
}

func main() {
	fmt.Println(intToRoman(3)) //III
	fmt.Println(intToRoman(4)) //IV
}

力扣13. 罗马数字转整数

示例 3: 输入: s = "IX",输出: 9
示例 4: 输入: s = "LVIII",输出: 58,解释: L = 50, V= 5, III = 3.

思路:小的数字,限于(I、X 和 C)在大的数字左边,所表示的数等于大数减去小数所得的数,例如IV = 4。所以如果当前罗马数字的值比前面一个大,说明这一段的值应当是减去上一个值。否则,应将当前值加入到最后结果中并开始下一次记录,例如:VI = 5 + 1, II = 1+1。时间复杂度: O(N) 空间复杂度: O(1)。

func romanToInt(s string) int {
	// 初始化了一个一一对应的map,方便后面取出符号。
	lookup := make(map[byte]int)
	lookup['I'] = 1
	lookup['V'] = 5
	lookup['X'] = 10
	lookup['L'] = 50
	lookup['C'] = 100
	lookup['D'] = 500
	lookup['M'] = 1000

	res := 0
	for i, _ := range s {
		if i > 0 && lookup[s[i]] > lookup[s[i-1]] {
			res += lookup[s[i]] - 2*lookup[s[i-1]]
		} else {
			res += lookup[s[i]]
		}
	}
	return res
}

func main() {
	fmt.Println(romanToInt("IX"))    //9
	fmt.Println(romanToInt("LVIII")) //58
}

代码:https://gitee.com/rxbook/go-algo-demo/blob/master/leetcode/intToRoman.go 

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

浮尘笔记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值