【Go 数据结构】树与哈希表的具体实现

树是一种由 n 个有限节点组成的具有层次关系的集合。

树的定义:

  1. 节点之间有层次关系,分为父节点和子节点
  2. 有唯一一个的根节点,该节点没有父节点
  3. 除了根节点,每个节点有且只有一个父节点
  4. 每一个节点本身以及它的后代也是一棵树,是一个递归结构
  5. 没有后代的节点成为叶子节点,没有节点的树称为空树

二叉树:每个节点最多只有两个儿子节点的树

满二叉树:叶子节点与叶子节点之间的高度差为 0 的二叉树。即整棵树是满的。树形呈现出满三角形结构。

完全二叉树:完全二叉树是由满二叉树而引出来的。这里我们设二叉树的深度为 k,除了第 k 层以外,其他各层节点树都达到了最大值,且第 k 层所有的节点都连续集中在最左侧。

树常见的数学特征:

  1. 高度为 h 的二叉树至少 h + 1 个节点
  2. 高度为 h 的二叉树至少 2 ^ h + 1 个节点
  3. 含有 n 个节点的二叉树的高度至多为 n - 1
  4. 含有 n 个节点的二叉树的高度至少为 log n
  5. 在二叉树的第 i 层,至多有 2 ^ (i - 1) 个节点

链表实现二叉树

// TreeNode is a tree node
type TreeNode struct {
	Data  string    // data
	Left  *TreeNode // left child
	Right *TreeNode // right child
}

当然了,我们也可以使用 数组 来表示二叉树,但是一般用来表示完全二叉树

对于一棵有 n 个节点的完全二叉树,从上到下,从左到右进行序号编号,对于一个任意节点,编号 i = 0表示树根节点,编号 i 的节点的左右儿子节点编号分别是:2 * i2 * i + 1, 父节点的编号为 i / 2

树的遍历

对于一棵树的遍历,我们有如下四种遍历方法:

  • 先序遍历:先访问根节点,再访问左子树,最后访问右子树
  • 后序遍历:先访问左子树,再访问右子树,最后访问根节点
  • 中序遍历:先访问左子树,再访问根节点,最后访问右子树
  • 层序遍历:每一层从左到右地访问每一个节点

实现树的前三种遍历打印结果:

// PreOrder 先序遍历 根左右
func PreOrder(tree *Node) {
	if tree == nil {
		return
	}
	fmt.Println(tree.Data, " ")
	PreOrder(tree.Left)
	PreOrder(tree.Right)
}

// MidOrder 中序遍历 左根右
func MidOrder(tree *Node) {
	if tree == nil {
		return
	}

	MidOrder(tree.Left)
	fmt.Println(tree.Data, " ")
	MidOrder(tree.Right)
}

// PostOrder 后续遍历 左右根
func PostOrder(tree *Node) {
	if tree == nil {
		return
	}
	PostOrder(tree.Left)
	PostOrder(tree.Right)
	fmt.Println(tree.Data, " ")
}

在实现树的层序遍历的时候,我们一般会使用队列作为辅助数据结构来实现。

  1. 首先将树的树根节点放入到队列中。
  2. 从队列中 Remove 出节点,先打印节点值,如果该节点有左子树,左子树入队,如果该节点有右子树,右子树入队。
  3. 重复2,直到队列中再无其他元素。

在实现之前我们先实现一些辅助函数,此处的函数是基于我们上一次的链式队列的修改。

// LinkNode 定义链表节点
type LinkNode struct {
	Next  *LinkNode
	Value *Node
}

// LinkQueue 定义链表队列
type LinkQueue struct {
	root *LinkNode
	size int
	lock sync.Mutex
}

// Add 入队
func (q *LinkQueue) Add(v *Node) {
	q.lock.Lock()
	defer q.lock.Unlock()

	// 如果队列为空,我们将新节点作为队列的根节点
	if q.root == nil {
		q.root = new(LinkNode)
		q.root.Value = v
	} else {
		// 队列不为空,新建一个节点,采用尾插法实现
		newNode := new(LinkNode)
		newNode.Value = v

		// 找到尾节点
		nowNode := q.root
		if nowNode.Next != nil {
			nowNode = nowNode.Next
		}

		nowNode.Next = newNode
	}
	q.size++
}

// Remove 出队
func (q *LinkQueue) Remove() *Node {
	q.lock.Lock()
	defer q.lock.Unlock()

	if q.size == 0 {
		return nil
	}

	// 找到队头节点
	top := q.root
	v := top.Value

	// 将对头元素出队
	q.root = top.Next

	q.size--
	return v
}

// Size 队列大小
func (q *LinkQueue) Size() int {
	return q.size
}

接下来,实现我们的层序遍历:

// LayerOrder 层序遍历
func LayerOrder(tree *Node) {
	if tree == nil {
		return
	}

	// 借助队列实现层序遍历
	queue := new(LinkQueue)

	// 将根节点入队
	queue.Add(tree)

	// 层序遍历
	for queue.Size() > 0 {
		// 获取队列头元素
		element := queue.Remove()
		// 输出
		fmt.Println(element.Data, " ")

		// 将左右子树入队
		if element.Left != nil {
			queue.Add(element.Left)
		}

		if element.Right != nil {
			queue.Add(element.Right)
		}
	}
}

哈希表

首先,我们来理清楚些概念:

线性查找

线性查找,也被称作顺序查找,是一种非常基础且直观的查找算法。顾名思义,线性查找会按照顺序查找数据,直到找到所需要的数据为止。

线性查找的步骤如下:

  1. 从数据集合的第一个元素开始。
  2. 将当前元素与所查找的目标元素进行比较。
  3. 如果当前元素和目标元素相等,那么返回当前元素的位置,查找结束。
  4. 如果当前元素和目标元素不相等,则继续检查下一个元素。
  5. 如果已经检查完所有元素但还没有找到目标元素,那么返回一个表示“未找到”的结果。

线性查找的优势在于它不需要预先对数据进行排序,这在一些需要频繁插入和删除的场景中会非常有用。此外,对于较小的数据集,线性查找是足够有效的。

但是,对于大规模数据集,线性查找的效率并不高,因为在最坏的情况下,线性查找可能需要检查集合中的每一个元素。

散列查找

哈希查找(也称为散列查找)是一种使用哈希哈希表存储数据,通过哈希函数快速查找数据的方法。

散列查找的步骤如下:

  1. 选择一个哈希函数:哈希函数会接受一个输入(或者叫键),并返回一个整数,这个整数就是在哈希表中存放数据的位置。
  2. 创建哈希表:创建一个可以存放数据的哈希表,通常是一个数组。大小可以视实际情况而定。
  3. 插入数据:当你有一份数据需要插入哈希表时,会把这份数据的键放入哈希函数,得到一个哈希值,然后把数据存放到这个哈希值对应的位置。
  4. 查找数据:当你需要查找一份数据时,也是把这份数据的键放入哈希函数,得到一个哈希值,然后去这个哈希值对应的位置取出数据。由于哈希值的计算速度非常快,所以查找的速度也非常快。

虽然散列查找的速度很快。但是在实际应用中,还需要处理一些复杂的问题,如碰撞问题。当两个键的哈希值相同(这称为哈希碰撞),就需要有一种方法来处理,最常见的处理方法包括开放寻址法链地址法

接下来我们将已经引入开放寻址法和链地址法。

开放寻址法

开放寻址法是解决哈希冲突的一种方法。它的基本思想是如果哈希函数返回的位置已经有数据了,即发生了冲突,那么就从当前位置起,依据某种探查规则,在哈希表中找到另一个位置,直到找到一个空的位置或者达到查找上限。

常见的探查规则有以下三种:

  • 线性探查:线性探查的步骤是,如果哈希函数返回的位置已经有数据了,就顺序往下查找,直到找到一个空的位置。比如初始位置是 i ,那么就依次查找 i+1 , i+2 , i+3 …,直到找到空的位置。这种方法简单,但可能导致数据在哈希表中的分布不均匀,产生一种叫做“聚集”的现象。
  • 二次探查:二次探查的步骤是,如果哈希函数返回的位置已经有数据了,那么就按照平方的规则往下查找,直到找到一个空的位置。比如初始位置是i,那么就依次查找i+1², i+2², i+3²…,直到找到空的位置。这种方法相对于线性探查能更好地防止聚集问题。
  • 双重哈希:双重哈希的步骤是,使用一个额外的哈希函数来解决哈希冲突。比如初始哈希函数返回位置 i ,如果 i 位置已经有数据了,那么就按照另一个哈希函数的规则进行探查,直到找到一个空的位置。这种方法可以避免聚集,但需要计算额外的哈希函数,增加了一些计算复杂性。

开放寻址法的主要优点是实现简单,结构紧凑,不需要额外的链表或数组结构。缺点是可能会有较差的缓存性能,并且需要处理较复杂的删除操作。

链地址法

链地址法也叫做链式哈希,是一种用来解决哈希碰撞问题的方法。当哈希函数返回的位置已经有数据了,即发生哈希碰撞时,链地址法是将这些哈希值相同的元素,放到同一个链表中。

链地址法的步骤如下:

  • 首先,初始化哈希表,每个位置都链接到一个链表,一开始这些链表都是空的。
  • 当我们要插入一个元素时,首先计算这个元素的哈希值,然后找到对应的链表,我们把这个元素插入到链表尾部。
  • 当我们要查找一个元素时,也是首先计算这个元素的哈希值,找到对应的链表,然后在链表中进行顺序查找。

链地址法的优点是处理哈希碰撞简单,不会出现表满的情况;并且在哈希表的大小固定,且哈希值分布均匀时,查找效果较好。它的缺点是需要额外的存储空间来存放指向链表的指针,并且可能存在比较长的链表,会降低查找的效率。

哈希函数

哈希函数(Hash function)是任意长度的输入(也叫做预映射,pre-image),通过散列算法,变换成固定长度的输出,该输出就是哈希值。

哈希函数的构造规则主要基于以下几个目标:

  • 确定性:对于同一个输入,无论执行多少次哈希函数,输出的哈希值始终不变。也就是说,如果 a=b,那么 hash(a)=hash(b)
  • 快速计算:哈希函数需要能快速地计算出哈希值。给定一个输入,进行哈希运算的效率应该很高。
  • 雪崩效应:即使只是微小的输入变化,也会产生巨大的输出变化。换句话说,如果 a≠b,那么 hash(a)hash(b) 的值应该差别很大。
  • 散列均匀:哈希函数应该能保证散列值在哈希表中均匀分布,避免哈希冲突。

哈希函数在很多不同的场合都有应用,例如在数据结构中的哈希表,而在密码学中哈希函数通常用来验证数据的完整性,比如MD5,SHA1,SHA2等。

在目前计算哈希速度最快的哈希算法是 xxhash

说完了一些基础的概念,接下来我们来实现一下简单的链式哈希表。

实现链式哈希表

在介绍先介绍一个小知识点,防止大家疑惑。

我们在实现时,使用到了一个加载因子 factor 这个变量主要用来控制哈希表的扩容与缩容。

我们设定当加载因子 factor <= 0.125 时进行数组缩容,每次将容量对半砍。当加载因子 factor >= 0.75 进行数组扩容,每次将容量翻倍。

定义数据

const (
	// 扩容因子
	expandFactor = 0.75
)

// 键值对
type keyPairs struct {
	key   string
	value interface{}
	next  *keyPairs
}

// HashMap 哈希表
type HashMap struct {
	array        []*keyPairs
	len          int
	capacity     int
	capacityMask int
	lock         sync.Mutex
}

初始化

// NewHashMap 初始化哈希表
func NewHashMap(capacity int) *HashMap {
	// 默认容积为2的幂
	defaultCapacity := 1 << 4
	if capacity <= defaultCapacity {
		capacity = defaultCapacity
	} else {
		capacity = 1 << int(math.Ceil(math.Log2(float64(capacity))))
	}

	// 新建一个哈希表
	hashtable := new(HashMap)
	hashtable.capacity = capacity
	hashtable.capacityMask = capacity - 1
	return hashtable
}

获取长度

// Len 返回哈希表中键值对的个数
func (hashtable *HashMap) Len() int {
	return hashtable.len
}

计算哈希值

// value 计算哈希值
var value = func(key []byte) uint64 {
	h := xxhash.New()
	h.Write(key)
	return h.Sum64()
}

获取下标

// hashIndex 计算哈希值并获取下标
func (hashtable *HashMap) hashIndex(key string, mask int) int {
	// 计算哈希值
	hash := value([]byte(key))
	index := hash & uint64(mask)
	return int(index)
}

插入元素

// Put 插入键值对
func (hashtable *HashMap) Put(key string, value interface{}) {
	hashtable.lock.Lock()
	defer hashtable.lock.Unlock()

	// 获取下标
	index := hashtable.hashIndex(key, hashtable.capacityMask)
	// 此下标在哈希表中的值
	element := hashtable.array[index]
	if element == nil {
		// 此下标没有元素,则插入
		hashtable.array[index] = &keyPairs{
			key:   key,
			value: value,
		}
	} else {
		// 此下标已经有元素,则插入到上一个元素的后面
		var lastPairs *keyPairs

		for element != nil {
			if element.key == key {
				element.value = value
				return
			}
			lastPairs = element
			element = element.next
		}

		// 找不到元素,则插入到最后
		lastPairs.next = &keyPairs{
			key:   key,
			value: value,
		}
	}
	// 长度加一
	newLen := hashtable.len + 1

	// 计算扩容因子,如果长度大于容积的75%,则扩容
	if float64(newLen)/float64(hashtable.capacity) >= expandFactor {
		// 新建一个原来两倍大小的哈希表
		newhashtable := new(HashMap)
		newhashtable.array = make([]*keyPairs, hashtable.capacity*2)
		newhashtable.capacity = hashtable.capacity * 2
		newhashtable.capacityMask = newhashtable.capacity*2 - 1

		// 遍历原哈希表,将元素插入到新哈希表
		for _, pairs := range hashtable.array {
			for pairs != nil {
				newhashtable.Put(pairs.key, pairs.value)
				pairs = pairs.next
			}
		}

		hashtable.array = newhashtable.array
		hashtable.capacity = newhashtable.capacity
		hashtable.capacityMask = newhashtable.capacityMask
	}
	hashtable.len = newLen
}

获取元素

// Get 获取键值对
func (hashtable *HashMap) Get(key string) (value interface{}, ok bool) {
	hashtable.lock.Lock()
	defer hashtable.lock.Unlock()

	// 获取下标
	index := hashtable.hashIndex(key, hashtable.capacityMask)

	// 此下标在哈希表中的值
	element := hashtable.array[index]

	// 遍历元素,如果元素的key等于key,则返回
	for element != nil {
		if element.key == key {
			return element.value, true
		}
		element = element.next
	}
	return nil, false
}

删除元素

// Delete 删除键值对
func (hashtable *HashMap) Delete(key string) {
	hashtable.lock.Lock()
	defer hashtable.lock.Unlock()

	// 获取下标
	index := hashtable.hashIndex(key, hashtable.capacityMask)

	// 此下标在哈希表中的值
	element := hashtable.array[index]

	// 如果为空链表,则直接返回
	if element == nil {
		return
	}

	// 如果第一个元素的key等于key,则删除
	if element.key == key {
		hashtable.array[index] = element.next
		hashtable.len--
		return
	}

	// 下一个键值对
	nextElement := element.next
	for nextElement != nil {
		if nextElement.key == key {
			element.next = nextElement.next
			hashtable.len--
			return
		}
		element = nextElement
		nextElement = nextElement.next
	}
}

遍历哈希表

// Range 遍历哈希表
func (hashtable *HashMap) Range() {
	hashtable.lock.Lock()
	defer hashtable.lock.Unlock()

	for _, pairs := range hashtable.array {
		for pairs != nil {
			fmt.Println(pairs.key, pairs.value)
			pairs = pairs.next
		}
	}
	fmt.Println("len:", hashtable.len)
}

哈希表总结

哈希查找总的来说是一种用空间去换时间的查找算法,时间复杂度达到 O ( 1 ) {O(1)} O(1)级别。

总结

本次我们介绍使用Go语言实现数据结构中的树和哈希表,并且详细介绍了哈希表的具体实现。数据结构这一系列我们没有涉及到具体的细节的讲解,适合有一定数据结构基础的童鞋,本系列代码已经上传至Github,欢迎大家 Star。

  • 33
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值