散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。
我们通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,我们用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。散列函数,顾名思义,它是一个函数。我们可以把它定义成 hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。
散列函数设计的基本要求:
1.散列函数计算得到的散列值是一个非负整数;
2.如果 key1 = key2,那 hash(key1) == hash(key2);
3.如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的,无法完全避免这种冲突,这种冲突称之为散列冲突。
我们常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。
对于开放寻址冲突解决方法,有线性探测法(Linear Probing),二次探测(Quadratic probing)和双重散列(Double hashing)。
开放寻址法,数组内存空间连续,有效利用CPU缓存;结构简单,序列化起来简单等优点。删除数据比较麻烦;比链表冲突代价更高;比链表更浪费内存空间等缺点。
开放寻址法插入
开放寻址法查找
开放寻址法删除
链表法,内存利用率高;对大装载因子的容忍度更高;不适用于较小的对象的存储,消耗更多内存;对CPU缓存不友好。
链表法插入
当数据量比较小、装载因子小的时候,适合采用开放寻址法。基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
工业级的散列表特性:
1.支持快速的查询、插入、删除操作;
2.内存占用合理,不能浪费过多的内存空间;
3.性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况
三个方面考虑设计工业级散列表的思路:
1.设计一个合适的散列函数。我们要尽可能让散列后的值随机且均匀分布,这样会尽可能地减少散列冲突,即便冲突之后,分配到每个槽内的数据也比较均匀。除此之外,散列函数的设计也不能太复杂,太复杂就会太耗时间,也会影响散列表的性能。
2.定义装载因子阈值,并且设计动态扩容策略。
3.选择合适的散列冲突解决方案。对比了开放寻址法和链表法两种方法的优劣和适应的场景。大部分情况下,链表法更加普适。而且,我们还可以通过将链表法中的链表改造成其他动态查找数据结构,比如红黑树,来避免散列表时间复杂度退化成 O(n),抵御散列碰撞攻击。但是,对于小规模数据、装载因子不高的散列表,比较适合用开放寻址法。
散列表和链表(跳表)的结合使用。散列表用数组实现,可以通过下标随机访问;而通过散列函数之后数据被打乱不再有序。链表(跳表)可以使数据有序。所以两者结合使用可以发挥各自优势实现一些功能。
散列表和链表结合使用实现LRU:
package lru_cache
const (
hostbit = uint64(^uint(0)) == ^uint64(0)
LENGTH = 100
)
type lruNode struct {
prev *lruNode
next *lruNode
key int // lru key
value int // lru value
hnext *lruNode // 拉链
}
type LRUCache struct {
node []lruNode // hash list
head *lruNode // lru head node
tail *lruNode // lru tail node
capacity int //
used int //
}
func Constructor(capacity int) LRUCache {
return LRUCache{
node: make([]lruNode, LENGTH),
head: nil,
tail: nil,
capacity: capacity,
used: 0,
}
}
func (this *LRUCache) Get(key int) int {
if this.tail == nil {
return -1
}
if tmp := this.searchNode(key); tmp != nil {
this.moveToTail(tmp)
return tmp.value
}
return -1
}
func (this *LRUCache) Put(key int, value int) {
// 1. 首次插入数据
// 2. 插入数据不在 LRU 中
// 3. 插入数据在 LRU 中
// 4. 插入数据不在 LRU 中, 并且 LRU 已满
if tmp := this.searchNode(key); tmp != nil {
tmp.value = value
this.moveToTail(tmp)
return
}
this.addNode(key, value)
if this.used > this.capacity {
this.delNode()
}
}
func (this *LRUCache) addNode(key int, value int) {
newNode := &lruNode{
key: key,
value: value,
}
//列表中的拉链的处理
tmp := &this.node[hash(key)]
newNode.hnext = tmp.hnext
tmp.hnext = newNode
this.used++
//链表的处理
if this.tail == nil {
this.tail, this.head = newNode, newNode
return
}
this.tail.next = newNode
newNode.prev = this.tail
this.tail = newNode
}
func (this *LRUCache) delNode() {
if this.head == nil {
return
}
prev := &this.node[hash(this.head.key)]
tmp := prev.hnext
//散列表拉链中查找
for tmp != nil && tmp.key != this.head.key {
prev = tmp
tmp = tmp.hnext
}
//散列表拉链删除tmp节点的处理
if tmp == nil {
return
}
prev.hnext = tmp.hnext
//链表的处理
this.head = this.head.next
this.head.prev = nil
this.used--
}
func (this *LRUCache) searchNode(key int) *lruNode {
if this.tail == nil {
return nil
}
// 查找
tmp := this.node[hash(key)].hnext
for tmp != nil {
if tmp.key == key {
return tmp
}
tmp = tmp.hnext
}
return nil
}
func (this *LRUCache) moveToTail(node *lruNode) {
//要移动到链表尾部的节点已经在尾部了
if this.tail == node {
return
}
//要移动到链表尾部的节点在头部
if this.head == node {
this.head = node.next
this.head.prev = nil
} else {
node.next.prev = node.prev
node.prev.next = node.next
}
node.next = nil
this.tail.next = node
node.prev = this.tail
this.tail = node
}
func hash(key int) int {
if hostbit {
return (key ^ (key >> 32)) & (LENGTH - 1)
}
return (key ^ (key >> 16)) & (LENGTH - 1)
}
以上内容摘自《数据结构与算法之美》课程,来学习更多精彩内容吧。


本文深入探讨了散列表的工作原理,包括如何通过散列函数将键值映射为数组下标,以及散列冲突的解决方法,如开放寻址法和链表法。此外,还讨论了工业级散列表的设计考量,以及散列表与链表结合使用实现LRU缓存的实例。
1416

被折叠的 条评论
为什么被折叠?



