数据结构(树)

216 阅读15分钟

二叉树

二叉树是每个节点最多有两个子节点的树结构。二叉树常被用来实现二叉查找树和二叉堆。 image.png

二叉树基本性质

  • 包含n个节点的二叉树的最大深度为n-1
  • 含有n个内部节点的二叉树有n+1个叶节点
  • 完全二叉树的深度为log(n+1)

二叉树的遍历

  • 前序遍历:根节点 ---> 左子树 ---> 右子树
  • 中序遍历:左子树 ---> 根节点 ---> 右子树
  • 后序遍历:左子树 ---> 右子树 ---> 根节点

斜树

斜树(Skewed Tree)是一种非平衡的二叉树。所有节点都只有左子树的二叉树叫做左斜树,所有节点都只有右子树的二叉树叫做右斜树(本质就是链表) image.png

斜树特点

  • 斜树只有一个分支,要么只有左子树,要么只有右子树。
  • 斜树的高度与节点数成线性关系,高度为节点数减一。
  • 左斜树只有左子树,右斜树只有右子树。
  • 斜树的查找时间复杂度为O(n),插入和删除时间复杂度为O(1)。

满二叉树

二叉树中所有非叶子结点的度都是2,且叶子结点都在同一层次上

image.png

满二叉树特点

  • 每个节点要么是叶子节点,要么就有两个子节点(左右子节点不能为空)。
  • 所有叶子节点都在同一层。
  • 每一层上的节点数都是最大值,二叉树的高度为h,除第h层外,其它各层节点数都是最大值,第h层有2^(h-1)个节点。
  • 满二叉树的节点总数为 2^h - 1,h为树的高度。

完全二叉树

如果一个二叉树与满二叉树前m个节点的结构相同,这样的二叉树被称为完全二叉树。

image.png

完全二叉树特点

  • 每个内部节点有两个或零个子节点
  • 叶子节点都尽可能地靠近树的左侧和顶层
  • 除了最后一层,其他每一层的节点数都是最大值,都是满的。
  • 在完全二叉树的最后一层,叶节点都连续地靠左对齐。

二插查找(排序)树(BST)

二叉查找树(Binary Search Tree)是指一棵空树或者具有下列性质的二叉树:

  • 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值。
  • 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值。
  • 任意节点的左、右子树也分别为二叉查找树。
  • 没有键值相等的节点。

image.png

插入

  1. 从根节点开始递归比较 node 和当前节点的键值大小
  2. 如果 node 键值更小,则继续递归插入左子节点
  3. 如果 node 键值更大,则继续递归插入右子节点
  4. 直到找到空位置插入 node 并返回
func Insert(root *TreeNode, node *TreeNode) *TreeNode {
  if root == nil {
    return node
  }  
  if node.Key < root.Key {
    root.Left = Insert(root.Left, node) 
  } else {
    root.Right = Insert(root.Right, node)
  }
  return root
}

删除

  1. 递归比较查找要删除的节点
  2. 如果节点只有一个子节点,则返回该子节点
  3. 如果有两个子节点,则用右子树最小节点替换要删除节点
func Delete(root *TreeNode, key int) *TreeNode {
  if root == nil {
    return nil
  }
  if key < root.Key {
    root.Left = Delete(root.Left, key)
  } else if key > root.Key { 
    root.Right = Delete(root.Right, key)
  } else {
    // root.Key == key, root是要删除的节点
    if root.Left == nil {
      return root.Right 
    } else if root.Right == nil {
      return root.Left
    } else {
      // 左右子树都存在时
      minNode := findMin(root.Right)
      root.Key = minNode.Key
      root.Right = Delete(root.Right, minNode.Key)
    }
  }
  return root
} 

func findMin(node *TreeNode) *TreeNode {
  // 递归找到最左叶子节点
  for node.Left != nil {
    node = node.Left 
  }
  return node
}

平衡二叉树

  • 要么是棵空树,要么其根节点左右子树的深度之差的绝对值不超过1
  • 其左右子树也都是平衡二叉树
  • 二叉树节点的平衡因子定义为该节点的左子树的深度减去右子树的深度。则平衡二叉树的所有节点的平衡因子只可能是-1,0,1。 image.png

平衡二叉树是一种二叉排序树,它通过某种手段使得树中任意一个节点的左右子树高度差不超过1

  • 可以在O(logn)时间内添加、删除和查找节点。
  • 由于高度平衡,中序遍历平衡二叉树可以得到有序的键值序列。
  • 通过旋转或变色来修正树的不平衡。
  • 相比于一般的二叉查找树,它们的插入和删除也可以在O(logn)时间内完成。
  • 普通二叉树可能退化成链表,而平衡二叉树可以保证稳定的查找性能。

AVL树

AVL树也是一棵平衡二叉树同时也是一棵BST树(平衡的BST树)

  • AVL树中任意节点的左右子树的高度差的绝对值(平衡因子)最多为1。
  • 平衡因子等于左子树高度减去右子树高度的结果。
  • 当平衡因子大于1时,需要进行旋转操作来保持平衡。
  • 在AVL树中,插入、删除和查找的时间复杂度都是O(logN)。
  • 常见的旋转操作有左旋、右旋、左右旋和右左旋。
  • 通过旋转操作来修正树的不平衡,使树重新符合AVL树的条件。

插入与删除

插入节点后,AVL树的平衡维护主要分为以下四步:

  1. 正常插入:像BST树一样插入新的节点。
  2. 更新高度:更新受影响节点的高度。
  3. 计算平衡因子:计算节点的左右子树高度差。
  4. 平衡维护:如果平衡因子超出[-1,1]范围,进行相应的旋转调整。

旋转操作包括:

  • LL (左左): 右旋转
  • RR (右右): 左旋转
  • LR (左右): 左旋转再右旋转
  • RL (右左): 右旋转再左旋转

AVL树插入节点后可能需要旋转操作来维持平衡,这是与普通BST树不同的关键。

节点删除,AVL树步骤与插入相似,第一步为像BST树一样进行删除节点,其余步骤相同

AVL树节点

// AVL树节点
type AVLNode struct {
  Key   int
  Value string
  Height int
  Left  *AVLNode
  Right *AVLNode
}

获取节点高度

// 获取节点高度
func getHeight(node *AVLNode) int {
  if node == nil {
    return 0 
  }
  return node.Height
}

计算平衡因子

func getBalanceFactor(node *AVLNode) int {
  return getHeight(node.Left) - getHeight(node.Right)
}

左旋

func leftRotate(node *AVLNode) *AVLNode {
  right := node.Right
  rightLeft := right.Left

  right.Left = node
  node.Right = rightLeft

  // 更新高度
  node.Height = max(getHeight(node.Left), getHeight(node.Right)) + 1
  right.Height = max(getHeight(right.Left), getHeight(right.Right)) + 1

  return right
}

右旋

func rightRotate(node *AVLNode) *AVLNode {
  left := node.Left
  leftRight := left.Right

  left.Right = node
  node.Left = leftRight

  // 更新高度
  node.Height = max(getHeight(node.Left), getHeight(node.Right)) + 1
  left.Height = max(getHeight(left.Left), getHeight(left.Right)) + 1

  return left
}

插入

// 插入节点 
func insert(node *AVLNode, key int, value string) *AVLNode {

  // 1. 递归插入新节点
  if node == nil {
    return &AVLNode{key, value, 1, nil, nil}
  }

  if key < node.Key {
    node.Left = insert(node.Left, key, value)
  } else {
    node.Right = insert(node.Right, key, value) 
  }

  // 2. 更新高度
  node.Height = 1 + max(getHeight(node.Left), getHeight(node.Right))

  // 3. 计算平衡因子 
  balance := getBalanceFactor(node)

  // 4. 平衡维护
  if balance > 1 {
    // LL或LR
    if getBalanceFactor(node.Left) >= 0 {
      // LL 
      return rightRotate(node)
    } else {
      // LR
      node.Left = leftRotate(node.Left)
      return rightRotate(node)
    }
  }

  if balance < -1 {
    // RR或RL
    if getBalanceFactor(node.Right) <= 0 {
      // RR
      return leftRotate(node) 
    } else {
      // RL
      node.Right = rightRotate(node.Right)
      return leftRotate(node)
    }
  }

  return node
}

删除

// 删除节点
func delete(node *AVLNode, key int) *AVLNode {

  if node == nil {
    return nil
  }

  if key < node.Key {
    node.Left = delete(node.Left, key)
  } else if key > node.Key {
    node.Right = delete(node.Right, key)
  } else {

    // 找到要删除的节点

    // 只有一个子节点或无子节点
    if node.Left == nil || node.Right == nil {
      var tmp *AVLNode
      if node.Left != nil {
        tmp = node.Left
      } else {
        tmp = node.Right
      }

      node = nil
      return tmp
    }

    // 有两个子节点,则找到右子树最小节点替换
    minNode := findMin(node.Right)
    node.Key, node.Value = minNode.Key, minNode.Value
    node.Right = delete(node.Right, minNode.Key)
  }

  // 更新高度
  node.Height = 1 + max(getHeight(node.Left), getHeight(node.Right))

  // 计算平衡因子
  balance := getBalanceFactor(node)

  // 平衡维护
  if balance > 1 {
    // LL或LR
    if getBalanceFactor(node.Left) >= 0 {
      return rightRotate(node) // LL
    } else {
     // LR  
      node.Left = leftRotate(node.Left)
      return rightRotate(node)
    }
  }

  if balance < -1 {
     // RR或RL
    if getBalanceFactor(node.Right) <= 0 {
      return leftRotate(node) // RR
    } else {
      // RL
      node.Right = rightRotate(node.Right) 
      return leftRotate(node)
    }
  }

  return node
}

红黑树

红黑树树与AVL树一样,是一棵平衡的BST树

  • 每个结点要么是红的要么是黑的。(红或黑)
  • 根结点是黑的。 (根黑)
  • 每个叶结点(叶结点即指树尾端NIL指针或NULL结点)都是黑的。 (叶黑)
  • 如果一个结点是红的,那么它的两个儿子都是黑的。 (红子黑)
  • 对于任意结点而言,其到叶结点树尾端NIL指针的每条路径都包含相同数目的黑结点。(路径下黑相同)

image.png

红黑树与AVL树

红黑树和AVL树都是通过自平衡来提高搜索性能的平衡二叉树,它们之间有以下几点主要区别:

  • 平衡条件不同
    • AVL树强制要求任何节点的左右子树高度差不超过1。
    • 红黑树强制要求每条路径黑色节点数相同。
  • 平衡操作不同
    • AVL树通过旋转操作维持平衡。
    • 红黑树通过旋转+重新着色来维持平衡。
  • 时间复杂度不同
    • AVL树插入、删除和查找的时间复杂度是O(logN)
    • 红黑树查找时间为O(logN),插入和删除最坏情况下时间复杂度是O(log^2N)
  • 空间消耗不同
    • 由于旋转次数不同,红黑树的空间消耗通常低于AVL树。

总之,AVL树通过严格控制高度平衡,红黑树通过颜色约束来维持平衡,两者都可以实现O(logN)级别的搜索效率。

哈夫曼树

哈夫曼树又称最优二叉树。常常用于数据压缩,是一种带权路径长度最短的二叉树

  • 哈夫曼树是一种带权路径长度最短的二叉树。
  • 树中每个叶子节点代表一个字符,字符出现频率作为权重。
  • 树中每个非叶子节点的权重是其所有子节点的权重总和。
  • 权重越大的节点离根越近,权重小的节点离根越远。
  • 通过递归地构造权重最小的子树,可以得到哈夫曼编码。
  • 哈夫曼编码可以表示每个字符,用于数据压缩。
  • 解码时,根据编码恢复字符,不需要任何字典。

image.png

插入

  1. 计算新节点的权重(字符出现频率)
  2. 创建新节点,暂时作为单节点树
  3. 查找哈夫曼树中权重最小的两个节点,记为node1和node2
  4. 创建一个新的内部节点,权重为node1和node2权重之和
  5. 将这个新内部节点的左右孩子分别设为node1和node2
  6. 从哈夫曼树中移除node1和node2
  7. 将新内部节点插入到哈夫曼树中
  8. 重复步骤3-7,直到所有的节点都在同一棵树内
  9. 最后得到的树就是插入新节点后的哈夫曼树

编码解码

编码

  1. 构建字符出现频率表
  2. 用字符和频率为权重构建哈夫曼树
  3. 从根遍历到每个叶子节点,记录路径,得到哈夫曼编码
  4. 将文本字符替换为相应的哈夫曼编码

解码

  1. 构建相同的哈夫曼树
  2. 从根开始匹配编码
  3. 对应路径到达一个叶子,记录字符
  4. 重复步骤2和3,直到编码全部匹配
  5. 将解码结果连接,恢复原文本

编码只需要哈夫曼树,解码需要树和编码表。

哈夫曼编码压缩率高,但编码时间复杂度较高。适合压缩后传输,接收端解码。

B树

B树(英语: B-tree)是一种自平衡的树,能够保持数据有序。这种数据结构能够让查找数据、顺序访问、插入数据及删除的动作,都在对数时间内完成。B树,概括来说是一种自平衡的m阶树,与自平衡二叉查找树不同,B树适用于读写相对大的数据块的存储系统,例如磁盘。

  • 每个节点存储数据和子节点指针。
  • 根节点至少有两个子节点。
  • 每个中间节点都包含k-1个元素和k个子节点。m/2 <= k <= m
  • 每个叶子节点都包含k-1个元素,且没有子节点。m/2 <= k <= m
  • 所有的叶子节点都位于同一层。
  • 每个节点中的元素从小到大排列,节点当中元素的中间位置与子节点相关。
  • 通过节点内元素的分裂和合并,保持B树的平衡。

image.png

B+树

B+树是一种树数据结构,通常用于关系型数据库(如MySQL)和操作系统的文件系统中。B+ 树的特点是能够保持数据稳定有序,其插入与修改拥有较稳定的对数时间复杂度。B+ 树元素自底向上插入,这与二叉树恰好相反。

在B树基础上,为叶子结点增加链表指针(B树+叶子有序链表),所有关键字都在叶子结点中出现,非叶子结点作为叶子结点的索引;B+树总是到叶子结点才命中。

B+树的非叶子节点不保存数据,只保存子树的临界值(最大或者最小),所以同样大小的节点,B+树相对于B树能够有更多的分支,使得这棵树更加矮胖,查询时做的IO操作次数也更少。

将上一节中的B-Tree优化,由于B+Tree的非叶子节点只存储键值信息,假设每个磁盘块能存储4个键值及指针信息,则变成B+Tree后其结构如下图所示:

image.png

B树与B+树的区别

B树和B+树都是多路平衡查找树,主要区别有:

  • 数据存储位置不同
    • B树的每个节点既存放key也存放data。
    • B+树的非叶子节点只存放key,data仅存放在叶子节点。
  • 查找方式不同
    • B树从根节点开始对key进行二分查找。
    • B+树先在非叶子节点进行二分查找找到range,再在叶子节点上sequential scan。
  • 遍历方式不同
    • B树需中序遍历所有节点。
    • B+树只需遍历叶子节点。
  • 删除操作复杂度不同
    • B树删除需要在节点内维护key的顺序,较为复杂。
    • B+树只需要在叶子节点链表中删除。
  • 剧烈插入删除效率不同
    • B树需要频繁维护非叶子节点,效率较低。
    • B+树直接在叶子节点插入删除即可,效率较高。

总之,B+树通过放弃父节点指向具体数据的链接,降低非叶子节点密度,获得更好的查询和IO效率。

总结

我们知道,实际应用当中,我们经常使用的是查找排序操作,这在我们的各种管理系统、数据库系统、操作系统等当中,十分常用。

下面是一些常见数据结构的优缺点及适用场景:

数组的下标寻址十分迅速,但计算机的内存是有限的,故数组的长度也是有限的,实际应用当中的数据往往十分庞大;而且无序数组的查找最坏情况需要遍历整个数组;后来人们提出了二分查找,二分查找要求数组的构造一定有序,二分法查找解决了普通数组查找复杂度过高的问题。任何一种数组无法解决的问题就是插入、删除操作比较复杂,因此,在一个增删查改比较频繁的数据结构中,数组不会被优先考虑。

普通链表由于它的结构特点被证明根本不适合进行查找。

哈希表是数组和链表的折中,同时它的设计依赖散列函数的设计,数组不能无限长、链表也不适合查找,所以也适合大规模的查找。

二叉查找树因为可能退化成链表,同样不适合进行查找。

AVL树是为了解决可能退化成链表问题,但是AVL树的旋转过程非常麻烦,因此插入和删除很慢,也就是构建AVL树比较麻烦。

红黑树是平衡二叉树和AVL树的折中,因此是比较合适的。集合类中的Map、关联数组具有较高的查询效率,它们的底层实现就是红黑树。

多路查找树是大规模数据存储中,实现索引查询这样一个实际背景下,树节点存储的元素数量是有限的(如果元素数量非常多的话,查找就退化成节点内部的线性查找了),这样导致二叉查找树结构由于树的深度过大而造成磁盘I/O读写过于频繁,进而导致查询效率低下。

B树与自平衡二叉查找树不同,B树适用于读写相对大的数据块的存储系统,例如磁盘。它的应用是文件系统及部分非关系型数据库索引。

B+树在B树基础上,为叶子结点增加链表指针(B树+叶子有序链表),所有关键字都在叶子结点中出现,非叶子结点作为叶子结点的索引;B+树总是到叶子结点才命中。通常用于关系型数据库(如Mysql)和操作系统的文件系统中。

B*树是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针, 在B+树基础上,为非叶子结点也增加链表指针,将结点的最低利用率从1/2提高到2/3。

R树是用来做空间数据存储的树状数据结构。例如给地理位置,矩形和多边形这类多维数据建立索引。

Trie树是自然语言处理中最常用的数据结构,很多字符串处理任务都会用到。Trie树本身是一种有限状态自动机,还有很多变体。什么模式匹配、正则表达式,都与这有关。