数据结构与算法之美(十)树、二叉树、二叉查找树、平衡二叉查找树、红黑树

二叉树

  • 介绍
    • 树(Tree)
    • 二叉树(Binary Tree)
      • 二叉树的存储
        • 1. 链式存储
        • 2. 数组顺序存储
      • 二叉树的遍历
        • 前序遍历
        • 中序遍历
        • 后序遍历
        • 层次遍历
      • 思考题
    • 二叉查找树(Binary Search Tree)
      • 二叉查找树的各种操作
        • 查找
        • 插入
        • 删除
        • 时间复杂度分析
      • 思考题
    • 平衡二叉查找树
      • 介绍
      • 常见的平衡二叉查找树
        • AVL树
        • 伸展树(Splay Tree)
        • 树堆(Treap)
        • 红黑树(Red-Black Tree, R-B Tree)
          • 介绍
          • 优点
          • 定义
          • 证明红黑树近似平衡
          • 红黑树的实现

介绍

二叉树是一种非线性表数据结构,接下来会按照以下顺序来讲解:树、二叉树、二叉查找树、平衡二叉查找树、红黑树、递归树。

树(Tree)

父节点
子节点
兄弟节点:父节点是同一个节点的节点
根节点:没有父节点的节点
叶子节点:没有子节点的节点

节点的高度:该节点到叶子节点的最长边数
节点的深度:该节点到根节点的边数
节点的层数:该节点深度 + 1

树的高度:根节点的高度
数据结构与算法之美(十)树、二叉树、二叉查找树、平衡二叉查找树、红黑树_第1张图片

二叉树(Binary Tree)

二叉树:每个节点最多有两个子节点,分别是左子节点和右子节点
满二叉树:除了叶子节点外,每个节点都有左子节点和右子节点
完全二叉树:叶子节点都在最底下两层、最后一层的叶子节点整体靠左排列、除了最后一层其他层的节点个数都要达到最大。优点是:在基于数组的顺序存储法存二叉树时,仅浪费下标0的位置。所以如果某棵树是完全二叉树时,用数组存储无疑是最节省内存的一种方式堆就是一种完全二叉树,常用数组存储。

二叉树的存储

1. 链式存储

每个节点有3个子段:数据、left指针、right指针

2. 数组顺序存储

i=0的位置不放数据,根节点存储在下标i=1的位置,则对于在i下标的节点,它的左子节点在2*i的位置、右子节点在2*i+1的位置。

特点:省内存,适合完全二叉树、堆(也是一种完全二叉树)。

二叉树的遍历

前、中、后序,表示的是某节点相对于它的左右子树的先、中、后顺序。

从前中后序遍历图,可以看得出每个结点最多被访问2遍,所以二叉树前中后序遍历的时间复杂度为 O ( n ) O(n) O(n),其中n为节点数
数据结构与算法之美(十)树、二叉树、二叉查找树、平衡二叉查找树、红黑树_第2张图片

前序遍历

对于树中的任意节点来说,先打印这个节点,再打印它的左子树,最后打印它的右子树。

递推公式: t r a v e r s e ( r ) = { p r i n t ( r ) , t r a v e r s e ( r − > l e f t ) , t r a v e r s e ( r − > r i g h t ) } traverse(r) = \{print(r), traverse(r->left), traverse(r->right)\} traverse(r)={print(r),traverse(r>left),traverse(r>right)}

代码:

void preOrder(Node* root)
{
	if(root == nullptr) return; //终止条件
	print(root) // 伪代码
	preOrder(root->left);
	preOrder(root->right);
}

中序遍历

对于树中的任意节点来说,先打印它的左子树,再打印这个节点,最后打印它的右子树。

递推公式: t r a v e r s e ( r ) = { t r a v e r s e ( r − > l e f t ) , p r i n t ( r ) , t r a v e r s e ( r − > r i g h t ) } traverse(r) = \{traverse(r->left), print(r), traverse(r->right)\} traverse(r)={traverse(r>left),print(r),traverse(r>right)}

代码:

void inorder(Node* root)
{
	if(root == nullptr) return;
	inorder(root->left);
	print(root);
	inorder(root->right);
}

后序遍历

对于树中的任意节点来说,先打印它的左子树,再打印它的右子树,最后打印这个节点。

递推公式: t r a v e r s e ( r ) = { t r a v e r s e ( r − > l e f t ) , t r a v e r s e ( r − > r i g h t ) , p r i n t ( r ) } traverse(r) = \{traverse(r->left), traverse(r->right), print(r)\} traverse(r)={traverse(r>left),traverse(r>right),print(r)}

代码:

void postorder(Node* root)
{
	if(root == nullptr) return;
	postorder(root->left);
	postorder(root->right);
	print(root);
}

层次遍历

可以看作以根节点为起点,图的广度优先遍历。
代码:

// 待添加

思考题

1. 给定一组数据,比如1,3,5,6,9,10,可以构建出多少种不同的二叉树?
补充知识:排列组合的20种解法总结https://www.bilibili.com/read/cv6224946

(1)首先确定有n个节点能构建出多少种不同的二叉树形状
(2)然后确考虑n个数字有多少种排列方法
以上二者相乘是最后的答案。

对于(1):卡特兰数f(n)=f(n-1)f(0) + f(n-2)f(1) + f(n-3)f(2) + … + f(1)f(n-2) + f(n-1)f(0)=C(n, 2n)/(n+1)
分析过程是递归的思想,详细见:n个节点总共能创建几种不同的二叉树

对于(2):如果没有重复数字, A n n = n ! A_n^n=n! Ann=n!种;如果有m1个重复的a1、有m2个重复的a2,那么 A n n A m 1 m 1 A m 2 m 2 = n ! m 1 ! m 2 ! \frac{A_n^n} {A_{m1}^{m1} A_{m2}^{m2}}=\frac{n!}{m1!m2!} Am1m1Am2m2Ann=m1!m2!n!

二叉查找树(Binary Search Tree)

二叉查找树(也叫二叉搜索树):树中的任意一个节点,其左子树中的节点的值都小于这个节点的值,而右子树节点的值都大于这个节点的值。

二叉查找树的优点:

  • 能够O(logn)查找、插入、删除一个数据。
  • 中序遍历查找二叉树,可以输出有序数据序列,相当于排序的时间复杂度 O ( n ) O(n) O(n)

如果二叉查找树有重复元素,怎么表示呢?

  • 方法一:每个节点用链表或支持动态扩容的数组等数据结构
  • 方法二:如果是相同值,当作>的值来处理,放在右子树里面

二叉查找树的各种操作

可以查找、插入、删除节点,另外还可以查找最大节点、最小节点、前驱节点、后继节点。

查找

  • 如果要查找的数等于根节点,就返回;
  • 如果要查找的数小于根节点,就在左子树中递归查找;
  • 如果要查找的数大于根节点,就在右子树中递归查找。
// 递归写法,自己瞎写,不保证对
node* find(node* root, int val)
{
	// 终止条件
	if(root == nullptr) return nullptr;
	if(root->data == val)
	{
		return root;
	}
	// 递归
	if(root->data > val) return find(root->left, val);
	else return find(root->right, val);
}
// 迭代写法,自己瞎写,不保证对
node* find(node* root, int val)
{
	node* p = root;
	while(p != nullptr)
	{
		if(p->data == val) return p;
		if(p->data > val) p = p->left;
		else p = p->right;
	}
	return nullptr;
}

插入

  • 如果要插入的数据比根节点小,
    • 如果左子树为空,就插入到左子节点;
    • 如果左子树不为空,就继续递归遍历左子树,找插入位置;
  • 如果要插入的数据比根节点大,
    • 如果右子树为空,就插入到右子节点;
    • 如果右子树不为空,就继续递归遍历右子树,找插入位置。
// 递归写法,自己瞎写,不保证对
void insertBinarySearchTree(Node* root, int val)
{
	// 终止条件
	if(root == nullptr)
	{
		root = Node(val);
		return ;
	}
	if(root->data == val) return ;
	if(root->data > val && root->left == nullptr)
	{
		root->left = Node(val);
		return ;
	}
	if(root->data < val && root->right == nullptr)
	{
		root->right = Node(val);
		return ;
	}
	
	// 递归过程
	if(root->data > val) insertBinarySearchTree(root->left, val);
	else insertBinarySearchTree(root->right, val);
}
// 迭代写法
void insertBinarySearchTree(Node* root, int val)
{
	if(root == nullptr)
	{
		root = Node(val);
		return ;
	}
	Node* p = root;
	while(p != nullptr)
	{
		if(p->data == val) return ;
		if(p->data > val)
		{
			if(p->left == nullptr)
			{
				p->left = Node(val);
				return;
			}
			else
			{
				p = p->left;
			}
		}
		else
		{
			if(p->right == nullptr)
			{
				p->right = Node(val);
				return;
			}
			else
			{
				p = p->right;
			}
		}
	}
}

删除

  • 如果要删除的节点没有子节点,直接将父节点指向要删除节点的指针置空;
  • 如果要删除的节点只有一个子节点,直接将父节点指向要删除节点的指针指向要该子节点;
  • 如果要删除的节点有两个子节点,谁来继位呢?要找左右子树中大小最中间的节点来继位,比如左子树中最大的、或右子树中最小的
void deleteBinarySearchTree(Node* root, int val)
{
	if(root == nullptr || root->data == val) return nullptr;
	
	// 先找到要删除的节点
	Node* p = root;
	Node* pp = nullptr; // 记录p的父节点
	while(p != nullptr)
	{
		pp = p;
		if(p->data > val) p = p->left;
		else if(p->data < val) p = p->right;
	}
	if(p == nullptr) return ; // 没有找到要删除的节点
	
	// 删除要删除的节点
	// 1. 如果有左子节点和右子节点:找右子树里的最小节点
	if(p->left != nullptr && p->right != nullptr)
	{
		Node* min_p = p->right;
		Node* min_pp = p; // min_p的父节点
		while(min_p->left != nullptr)
		{
			min_pp = min_p;
			min_p = min_p->left;
		}
		// 此时min_p就是右子树里的最小节点
		p->data = min_p->data; // 更换值
		// 删除最小节点???为什么是这样写
		p = min_p;
		pp = min_pp;
	}
	
	
	// 2. 如果是叶节点||只有左子节点||只有右子节点
	Node* child; // 记录p的子节点
	if(p->left == nullptr && p->right == nullptr) child = nullptr;
	else if(p->left == nullptr && p->right != nullptr) child = p->right;
	else if(p->left != nullptr && p->right == nullptr) child = p->left;
	
	if(pp->left == p) pp->left = child;
	else pp->right = child;
}

时间复杂度分析

  • 最坏时间复杂度:左右子树极度不平衡时(例如都只有左子树),会退化成链表,查找的时间复杂度就是 O ( n ) O(n) O(n),n是节点个数

  • 最好时间复杂度:左右子树很平衡(完全二叉树、或满二叉树),查找、插入、删除都跟二叉查找树的高度成正比,所以问题转换成,如何求一棵包含n个节点的完全二叉树的高度?答案是 O ( l o g n ) O(logn) O(logn),n是节点个数。

求解过程,假设节点个数为n,最大层数为L(高度H=L-1),对于完全二叉树:
n > = 1 + 2 + 4 + . . . + 2 ( L − 2 ) + 1 n >= 1+2+4+...+2^{(L-2)} + 1 n>=1+2+4+...+2(L2)+1
n < = 1 + 2 + 4 + . . . + 2 ( L − 2 ) + 2 ( L − 1 ) n <= 1+2+4+...+2^{(L-2)} + 2^{(L-1)} n<=1+2+4+...+2(L2)+2(L1)
数据结构与算法之美(十)树、二叉树、二叉查找树、平衡二叉查找树、红黑树_第3张图片
所以高度 H < = l o g 2 n H<=log_2n H<=log2n,也即时间复杂度 < = l o g n <=logn <=logn。由此也可见,二叉查找树树的平衡性会极大影响其复杂度,所以提出了很多种平衡二叉查找树,其中红黑树就是一种平衡二叉查找树。

思考题

1. 求一棵二叉树的高度

  • 方法1: 层次遍历。每多一层+1
  • 方法2:递归。二叉树的高度 = max(左子树高度, 右子树高度)+1

2. 为什么散列表能够O(1)查找,还需要二叉查找树这种O(logn)查找的数据结构呢?

  • 散列表数据无序
  • 散列表扩容耗时很多,且遇到散列冲突时性能不稳定;平衡二叉查找树的性能非常稳定在O(logn)
  • 哈希函数的计算也是需要时间的,不一定比logn小
  • 散列表的构造比二叉查找树复杂,二叉查找树之需要考虑平衡性
  • 散列表的装载因子不能太大,所以会浪费一定的存储空间

平衡二叉查找树

介绍

平衡二叉查找树:二叉树中任意一个节点的左右子树的高度相差不能大于1。

满二叉树、完全二叉树都是平衡二叉树,但非完全二叉树也有可能是平衡二叉树。

由来:解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。

常见的平衡二叉查找树

AVL树

严格的平衡二叉查找树,任意节点的左右子树的高度相差不超过1。

伸展树(Splay Tree)

树堆(Treap)

红黑树(Red-Black Tree, R-B Tree)

介绍

红黑树是不严格的平衡二叉查找树,从根节点到各个叶子节点的最长路径有可能比最短路径大一倍。

优点

近似平衡:

  • 相对于AVL树,插入、删除维护成本低
  • 相对于Splay Tree、Treap,查找更稳定
  • 红黑树树的高度近似为 l o g 2 n log_2n log2n,因此插入、删除、查找操作的时间复杂度都是 O ( l o g n ) O(logn) O(logn)(竞品是跳表)
定义
  • 节点是红色或黑色:
  • 根节点是黑色;
  • 叶子节点都是黑色(不存储数据,是NIL);
  • 每个红色结点的两个子结点都是黑色;
  • 从任一节结点到其每个叶子的所有路径都包含相同数目的黑色结点。
证明红黑树近似平衡

即证明红黑树的高度稳定趋近 l o g 2 n log_2n log2n(极其平衡的二叉树的高度约为 l o g 2 n log_2n log2n

  • 首先,如果我们将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树会变成四叉树,从四叉树中取出某些节点放到叶节点位置,四叉树就变成了完全二叉树,所以仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度要小,所以黑色四叉树的高度 < = l o g 2 n <=log_2n <=log2n(???没懂)
  • 把红色节点加回去,红黑树一个红色节点就至少要有一个黑色节点,所以最长路径 < = 2 l o g 2 n <=2log_2n <=2log2n,也即红黑树的高度近似 2 l o g 2 n 2log_2n 2log2n
红黑树的实现

如何在插入、删除节点的过程中将不平衡的二叉树调整成平衡的:左旋、右旋、改变颜色。

你可能感兴趣的:(数据结构与算法之美,数据结构,算法,b树)