树
树是一种由 n
个有限节点组成的具有层次关系的集合。
树的定义:
- 节点之间有层次关系,分为父节点和子节点
- 有唯一一个的根节点,该节点没有父节点
- 除了根节点,每个节点有且只有一个父节点
- 每一个节点本身以及它的后代也是一棵树,是一个递归结构
- 没有后代的节点成为叶子节点,没有节点的树称为空树
二叉树:每个节点最多只有两个儿子节点的树
满二叉树 :叶子节点与叶子节点之间的高度差为 0
的二叉树。即整棵树是满的。树形呈现出满三角形结构。
完全二叉树 :完全二叉树是由满二叉树而引出来的。这里我们设二叉树的深度为 k
,除了第 k
层以外,其他各层节点树都达到了最大值,且第 k
层所有的节点都连续集中在最左侧。
树常见的数学特征:
- 高度为
h
的二叉树至少h + 1
个节点 - 高度为
h
的二叉树至少2 ^ h + 1
个节点 - 含有
n
个节点的二叉树的高度至多为n - 1
- 含有
n
个节点的二叉树的高度至少为log n
- 在二叉树的第
i
层,至多有2 ^ (i - 1)
个节点
链表实现二叉树
go
// TreeNode is a tree node
type TreeNode struct {
Data string // data
Left *TreeNode // left child
Right *TreeNode // right child
}
当然了,我们也可以使用 数组 来表示二叉树,但是一般用来表示完全二叉树。
对于一棵有 n
个节点的完全二叉树,从上到下,从左到右进行序号编号,对于一个任意节点,编号 i = 0
表示树根节点,编号 i
的节点的左右儿子节点编号分别是:2 * i
, 2 * i + 1
, 父节点的编号为 i / 2
。
树的遍历
对于一棵树的遍历,我们有如下四种遍历方法:
- 先序遍历:先访问根节点,再访问左子树,最后访问右子树
- 后序遍历:先访问左子树,再访问右子树,最后访问根节点
- 中序遍历:先访问左子树,再访问根节点,最后访问右子树
- 层序遍历:每一层从左到右地访问每一个节点
实现树的前三种遍历打印结果:
go
// 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, " ")
}
在实现树的层序遍历的时候,我们一般会使用队列作为辅助数据结构来实现。
- 首先将树的树根节点放入到队列中。
- 从队列中
Remove
出节点,先打印节点值,如果该节点有左子树,左子树入队,如果该节点有右子树,右子树入队。 - 重复2,直到队列中再无其他元素。
在实现之前我们先实现一些辅助函数,此处的函数是基于我们上一次的链式队列的修改。
go
// 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
}
接下来,实现我们的层序遍历:
go
// 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)
}
}
}
哈希表
首先,我们来理清楚些概念:
线性查找
线性查找,也被称作顺序查找,是一种非常基础且直观的查找算法。顾名思义,线性查找会按照顺序查找数据,直到找到所需要的数据为止。
线性查找的步骤如下:
- 从数据集合的第一个元素开始。
- 将当前元素与所查找的目标元素进行比较。
- 如果当前元素和目标元素相等,那么返回当前元素的位置,查找结束。
- 如果当前元素和目标元素不相等,则继续检查下一个元素。
- 如果已经检查完所有元素但还没有找到目标元素,那么返回一个表示"未找到"的结果。
线性查找的优势在于它不需要预先对数据进行排序,这在一些需要频繁插入和删除的场景中会非常有用。此外,对于较小的数据集,线性查找是足够有效的。
但是,对于大规模数据集,线性查找的效率并不高,因为在最坏的情况下,线性查找可能需要检查集合中的每一个元素。
散列查找
哈希查找(也称为散列查找)是一种使用哈希哈希表存储数据,通过哈希函数快速查找数据的方法。
散列查找的步骤如下:
- 选择一个哈希函数:哈希函数会接受一个输入(或者叫键),并返回一个整数,这个整数就是在哈希表中存放数据的位置。
- 创建哈希表:创建一个可以存放数据的哈希表,通常是一个数组。大小可以视实际情况而定。
- 插入数据:当你有一份数据需要插入哈希表时,会把这份数据的键放入哈希函数,得到一个哈希值,然后把数据存放到这个哈希值对应的位置。
- 查找数据:当你需要查找一份数据时,也是把这份数据的键放入哈希函数,得到一个哈希值,然后去这个哈希值对应的位置取出数据。由于哈希值的计算速度非常快,所以查找的速度也非常快。
虽然散列查找的速度很快。但是在实际应用中,还需要处理一些复杂的问题,如碰撞问题。当两个键的哈希值相同(这称为哈希碰撞),就需要有一种方法来处理,最常见的处理方法包括开放寻址法 和链地址法。
接下来我们将已经引入开放寻址法和链地址法。
开放寻址法
开放寻址法是解决哈希冲突的一种方法。它的基本思想是如果哈希函数返回的位置已经有数据了,即发生了冲突,那么就从当前位置起,依据某种探查规则,在哈希表中找到另一个位置,直到找到一个空的位置或者达到查找上限。
常见的探查规则有以下三种:
- 线性探查:线性探查的步骤是,如果哈希函数返回的位置已经有数据了,就顺序往下查找,直到找到一个空的位置。比如初始位置是
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
进行数组扩容,每次将容量翻倍。
定义数据
go
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
}
初始化
go
// 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
}
获取长度
go
// Len 返回哈希表中键值对的个数
func (hashtable *HashMap) Len() int {
return hashtable.len
}
计算哈希值
go
// value 计算哈希值
var value = func(key []byte) uint64 {
h := xxhash.New()
h.Write(key)
return h.Sum64()
}
获取下标
go
// hashIndex 计算哈希值并获取下标
func (hashtable *HashMap) hashIndex(key string, mask int) int {
// 计算哈希值
hash := value([]byte(key))
index := hash & uint64(mask)
return int(index)
}
插入元素
go
// 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
}
获取元素
go
// 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
}
删除元素
go
// 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
}
}
遍历哈希表
go
// 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。