本文目录:
数据结构分类
1、数组
2、栈
3、队列
4、链表
5、树
6、散列表
7、堆
8、图
数据结构分类
数据结构是指相互之间存在着一种或多种关系的数据元素的集合和该集合中数据元素之间的关系组成 。
常用的数据结构有:数组,栈,链表,队列,树,图,堆,散列表等,如图所示:
每一种数据结构都有着独特的数据存储方式,下面为大家介绍它们的结构和优缺点。
1、数组
数组是可以再内存中连续存储多个元素的结构,在内存中的分配也是连续的,数组中的元素通过数组下标进行访问,数组下标从0开始。例如下面这段代码就是将数组的第一个元素赋值为 1。
int[] data = new int[100];data[0] = 1;
1
2
优点:
1、按照索引查询元素速度快
2、按照索引遍历数组方便
缺点:
1、数组的大小固定后就无法扩容了
2、数组只能存储一种类型的数据
3、添加,删除的操作慢,因为要移动其他的元素。
适用场景:
频繁查询,对存储空间要求不大,很少增加和删除的情况。
2、栈
栈是一种特殊的线性表,仅能在线性表的一端操作,栈顶允许操作,栈底不允许操作。 栈的特点是:先进后出,或者说是后进先出,从栈顶放入元素的操作叫入栈,取出元素叫出栈。
栈的结构就像一个集装箱,越先放进去的东西越晚才能拿出来,所以,栈常应用于实现递归功能方面的场景,例如斐波那契数列。
3、队列
队列与栈一样,也是一种线性表,不同的是,队列可以在一端添加元素,在另一端取出元素,也就是:先进先出。从一端放入元素的操作称为入队,取出元素为出队,示例图如下:
使用场景:因为队列先进先出的特点,在多线程阻塞队列管理中非常适用。
4、链表
链表是物理存储单元上非连续的、非顺序的存储结构,数据元素的逻辑顺序是通过链表的指针地址实现,每个元素包含两个结点,一个是存储元素的数据域 (内存空间),另一个是指向下一个结点地址的指针域。根据指针的指向,链表能形成不同的结构,例如单链表,双向链表,循环链表等。
链表的优点:
链表是很常用的一种数据结构,不需要初始化容量,可以任意加减元素;
添加或者删除元素时只需要改变前后两个元素结点的指针域指向地址即可,所以添加,删除很快;
缺点:
因为含有大量的指针域,占用空间较大;
查找元素需要遍历链表来查找,非常耗时。
适用场景:
数据量较小,需要频繁增加,删除操作的场景
5、树
树是一种数据结构,它是由n(n>=1)个有限节点组成一个具有层次关系的集合(即,树不能为null)。把它叫做 “树” 是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:
每个节点有零个或多个子节点;
没有父节点的节点称为根节点;
每一个非根节点有且只有一个父节点;
除了根节点外,每个子节点可以分为多个不相交的子树;
在日常的应用中,我们讨论和用的更多的是树的其中一种结构,就是二叉树。
二叉树是树的特殊一种,具有如下特点:
1、每个结点最多有两颗子树,结点的度最大为2。
2、左子树和右子树是有顺序的,次序不能颠倒。
3、即使某结点只有一个子树,也要区分左右子树。
二叉树是一种比较有用的折中方案,它添加,删除元素都很快,并且在查找方面也有很多的算法优化,所以,二叉树既有链表的好处,也有数组的好处,是两者的优化方案,在处理大批量的动态数据方面非常有用。
扩展:
二叉树有很多扩展的数据结构,包括平衡二叉树、红黑树、B+树等,这些数据结构二叉树的基础上衍生了很多的功能,在实际应用中广泛用到,例如mysql的数据库索引结构用的就是B+树,还有HashMap的底层源码中用到了红黑树。这些二叉树的功能强大,但算法上比较复杂,想学习的话还是需要花时间去深入的。
二叉树基本概念
- 定义
二叉树是每个节点最多有两棵子树的树结构。通常子树被称作“左子树”和“右子树”。二叉树常被用于实现二叉查找树和二叉堆。
- 相关性质
二叉树的每个结点至多只有2棵子树(不存在度大于2的结点),二叉树的子树有左右之分,次序不能颠倒。
二叉树的第i层至多有2^(i-1)个结点;深度为k的二叉树至多有2^k-1个结点。
一棵深度为k,且有2^k-1个节点的二叉树称之为 满二叉树 ;
深度为k,有n个节点的二叉树,当且仅当其每一个节点都与深度为k的满二叉树中,序号为1至n的节点对应时,称之为 完全二叉树 。
- 三种遍历方法
在二叉树的一些应用中,常常要求在树中查找具有某种特征的节点,或者对树中全部节点进行某种处理,这就涉及到二叉树的遍历。二叉树主要是由3个基本单元组成,根节点、左子树和右子树。如果限定先左后右,那么根据这三个部分遍历的顺序不同,可以分为先序遍历、中序遍历和后续遍历三种。
(1) 先序遍历 若二叉树为空,则空操作,否则先访问根节点,再先序遍历左子树,最后先序遍历右子树。 (2) 中序遍历 若二叉树为空,则空操作,否则先中序遍历左子树,再访问根节点,最后中序遍历右子树。(3) 后序遍历 若二叉树为空,则空操作,否则先后序遍历左子树访问根节点,再后序遍历右子树,最后访问根节点。
给定二叉树写出三种遍历结果
- 树和二叉树的区别
(1) 二叉树每个节点最多有2个子节点,树则无限制。 (2) 二叉树中节点的子树分为左子树和右子树,即使某节点只有一棵子树,也要指明该子树是左子树还是右子树,即二叉树是有序的。 (3) 树决不能为空,它至少有一个节点,而一棵二叉树可以是空的。
上面我们主要对二叉树的相关概念进行了介绍,下面我们将从二叉查找树开始,介绍二叉树的几种常见类型,同时将之前的理论部分用代码实现出来。
二叉查找树(二叉搜索树)
- 定义
二叉查找树就是二叉排序树,也叫二叉搜索树。二叉查找树或者是一棵空树,或者是具有下列性质的二叉树: (1) 若左子树不空,则左子树上所有结点的值均小于它的根结点的值;(2) 若右子树不空,则右子树上所有结点的值均大于它的根结点的值;(3) 左、右子树也分别为二叉排序树;(4) 没有键值相等的结点。
典型的二叉查找树的构建过程
- 性能分析
对于二叉查找树来说,当给定值相同但顺序不同时,所构建的二叉查找树形态是不同的,下面看一个例子。
不同形态平衡二叉树的ASL不同
可以看到,含有n个节点的二叉查找树的平均查找长度和树的形态有关。最坏情况下,当先后插入的关键字有序时,构成的二叉查找树蜕变为单支树,树的深度为n,其平均查找长度(n+1)/2(和顺序查找相同),最好的情况是二叉查找树的形态和折半查找的判定树相同,其平均查找长度和log2(n)成正比。平均情况下,二叉查找树的平均查找长度和logn是等数量级的,所以为了获得更好的性能,通常在二叉查找树的构建过程需要进行“平衡化处理”,之后我们将介绍平衡二叉树和红黑树,这些均可以使查找树的高度为O(log(n))。
- 代码10 二叉树的节点
-
class TreeNode<E> {
-
E element;
-
TreeNode<E> left;
-
TreeNode<E> right;
-
public TreeNode(E e) {
-
element = e;
-
}
-
}
二叉查找树的三种遍历都可以直接用递归的方法来实现:
- 代码12 先序遍历
-
protected void preorder(TreeNode<E> root) {
-
if (root == null)
-
return;
-
System.out.println(root.element + " ");
-
preorder(root.left);
-
preorder(root.right);
-
}
- 代码13 中序遍历
-
protected void inorder(TreeNode<E> root) {
-
if (root == null)
-
return;
-
inorder(root.left);
-
System.out.println(root.element + " ");
-
inorder(root.right);
-
}
- 代码14 后序遍历
-
protected void postorder(TreeNode<E> root) {
-
if (root == null)
-
return;
-
postorder(root.left);
-
postorder(root.right);
-
System.out.println(root.element + " ");
-
}
- 代码15 二叉查找树的简单实现
-
/**
-
* @author JackalTsc
-
*/
-
public class MyBinSearchTree<E extends Comparable<E>> {
-
// 根
-
private TreeNode<E> root;
-
// 默认构造函数
-
public MyBinSearchTree() {
-
}
-
// 二叉查找树的搜索
-
public boolean search(E e) {
-
TreeNode<E> current = root;
-
while (current != null) {
-
if (e.compareTo(current.element) < 0) {
-
current = current.left;
-
} else if (e.compareTo(current.element) > 0) {
-
current = current.right;
-
} else {
-
return true;
-
}
-
}
-
return false;
-
}
-
// 二叉查找树的插入
-
public boolean insert(E e) {
-
// 如果之前是空二叉树 插入的元素就作为根节点
-
if (root == null) {
-
root = createNewNode(e);
-
} else {
-
// 否则就从根节点开始遍历 直到找到合适的父节点
-
TreeNode<E> parent = null;
-
TreeNode<E> current = root;
-
while (current != null) {
-
if (e.compareTo(current.element) < 0) {
-
parent = current;
-
current = current.left;
-
} else if (e.compareTo(current.element) > 0) {
-
parent = current;
-
current = current.right;
-
} else {
-
return false;
-
}
-
}
-
// 插入
-
if (e.compareTo(parent.element) < 0) {
-
parent.left = createNewNode(e);
-
} else {
-
parent.right = createNewNode(e);
-
}
-
}
-
return true;
-
}
-
// 创建新的节点
-
protected TreeNode<E> createNewNode(E e) {
-
return new TreeNode(e);
-
}
-
}
-
// 二叉树的节点
-
class TreeNode<E extends Comparable<E>> {
-
E element;
-
TreeNode<E> left;
-
TreeNode<E> right;
-
public TreeNode(E e) {
-
element = e;
-
}
-
}
先说一下如何删除二叉树查找树的节点吧。总共有三种情况
1.被删除的节点是叶子节点,这时候只要把这个节点删除,再把指向这个节点的父节点指针置为空就行
2.被删除的节点有左子树,或者有右子树,而且只有其中一个,那么只要把当前删除节点的父节点指向被删除节点的左子树或者右子树就行。
3.被删除的节点既有左子树而且又有右子树,这时候需要把左子树的最右边的节点或者右子树最左边的节点提到被删除节点的位置,为什么要这样呢,根据二叉查找树的性质,父节点的指针一定比所有左子树的节点值大而且比右子树的节点的值小,为了删除父节点不破坏二叉查找树的平衡性,应当把左子树最大的节点或者右子树最小的节点放在父节点的位置,这样的话才能维护二叉查找树的平衡性。(我是找的右子树的最小节点)
上面的代码15主要展示了一个自己实现的简单的二叉查找树,其中包括了几个常见的操作,当然更多的操作还是需要大家自己去完成。因为在二叉查找树中删除节点的操作比较复杂,所以下面我详细介绍一下这里。
- 二叉查找树中删除节点分析
要在二叉查找树中删除一个元素,首先需要定位包含该元素的节点,以及它的父节点。假设current指向二叉查找树中包含该元素的节点,而parent指向current节点的父节点,current节点可能是parent节点的左孩子,也可能是右孩子。这里需要考虑两种情况:
- current节点没有左孩子,那么只需要将patent节点和current节点的右孩子相连。
- current节点有一个左孩子,假设rightMost指向current节点的左子树中最大元素的节点,而parentOfRightMost指向rightMost节点的父节点。那么先使用rightMost节点中的元素值替换current节点中的元素值,将parentOfRightMost节点和rightMost节点的左孩子相连,然后删除rightMost节点。
-
// 二叉搜索树删除节点
-
public boolean delete(E e) {
-
TreeNode<E> parent = null;
-
TreeNode<E> current = root;
-
// 找到要删除的节点的位置
-
while (current != null) {
-
if (e.compareTo(current.element) < 0) {
-
parent = current;
-
current = current.left;
-
} else if (e.compareTo(current.element) > 0) {
-
parent = current;
-
current = current.right;
-
} else {
-
break;
-
}
-
}
-
// 没找到要删除的节点
-
if (current == null) {
-
return false;
-
}
-
// 考虑第一种情况
-
if (current.left == null) {
-
if (parent == null) {
-
root = current.right;
-
} else {
-
if (e.compareTo(parent.element) < 0) {
-
parent.left = current.right;
-
} else {
-
parent.right = current.right;
-
}
-
}
-
} else { // 考虑第二种情况
-
TreeNode<E> parentOfRightMost = current;
-
TreeNode<E> rightMost = current.left;
-
// 找到左子树中最大的元素节点
-
while (rightMost.right != null) {
-
parentOfRightMost = rightMost;
-
rightMost = rightMost.right;
-
}
-
// 替换
-
current.element = rightMost.element;
-
// parentOfRightMost和rightMost左孩子相连
-
if (parentOfRightMost.right == rightMost) {
-
parentOfRightMost.right = rightMost.left;
-
} else {
-
parentOfRightMost.left = rightMost.left;
-
}
-
}
-
return true;
-
}
平衡二叉树
平衡二叉树又称AVL树,它或者是一棵空树,或者是具有下列性质的二叉树:它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。
平衡二叉树
AVL树是最先发明的自平衡二叉查找树算法。在AVL中任何节点的两个儿子子树的高度最大差别为1,所以它也被称为高度平衡树,n个结点的AVL树最大深度约1.44log2n。查找、插入和删除在平均和最坏情况下都是O(log n)。增加和删除可能需要通过一次或多次树旋转来重新平衡这个树。
一.平衡二叉查找树
平衡二叉查找树是带有平衡条件的二叉查找树。平衡条件:每个节点的左子树和右子树的高度差最多为1二叉查找树(其中空树的高度为-1)。
二、平衡二叉树算法思想
若向平衡二叉树中插入一个新结点后破坏了平衡二叉树的平衡性。首先要找出插入新结点后失去平衡的最小子树根结点的指针。然后再调整这个子树中有关结点之间的链接关系,使之成为新的平衡子树。当失去平衡的最小子树被调整为平衡子树后,原有其他所有不平衡子树无需调整,整个二叉排序树就又成为一棵平衡二叉树。
失去平衡的最小子树是指以离插入结点最近,且平衡因子绝对值大于1的结点作为根的子树。假设用A表示失去平衡的最小子树的根结点,则调整该子树的操作可归纳为下列四种情况。
当对一颗AVL树进行插入操作,可能会导致AVL树不平衡,此时,我们就需要做平衡处理,假设重新平衡的节点为Q,则不平衡会下列四种情况:
在Q的左孩子的左子树插入 (LL)
在Q的左孩子的右子树插入 (LR)
在Q的右孩子的左子树插入 (RL)
在Q的右孩子的右子树插入 (RR)
旋转算法需要借助于两个功能的辅助,一个是求树的高度,一个是求两个高度的最大值。这里规定,一棵空树的高度为-1,只有一个根节点的树的高度为0,以后每多一层高度加1。为了解决指针NULL这种情况,写了一个求高度的函数,这个函数还是很有必要的。
代码:
// return the height of node ptrTree or -1 if NULL;
//只有一个根节点的高度为0,空树的高度-1
int NodeHeight(PtrToNode ptrTree)
{
return ptrTree==NULL ? -1 :ptrTree->height;
}
int max(int a,int b)
{
return a<b ? b : a;
}
(1)LL型平衡旋转法
由于在A的左孩子B的左子树上插入结点F,使A的平衡因子由1增至2而失去平衡。故需进行一次顺时针旋转操作。 即将A的左孩子B向右上旋转代替A作为根结点,A向右下旋转成为B的右子树的根结点。而原来B的右子树则变成A的左子树。
对应的代码:
思路:先把B的右子树变为A的左子树,在把A作为B的右子树
//LL旋转
void RotateWithLeft(PtrToNode &k1)
{
PtrToNode k= k1->left; //保存节点的左子树
k1->left = k->right; //k节点的右子树作为k1的左子树
k->right =k1; //把k1的k的右子树
//到此树旋转完成,更新树的深度,以k,k1为节点的树的深度发生了变化;
k1->height= max(NodeHeight(k1->left),NodeHeight(k1->right))+1;
k->height= max(NodeHeight(k->left),NodeHeight(k->right))+1;
//根节点发生了变化,右k1变为了k,因为传的参数是引用,程序默认k1为根节点
//k为局部变量,离开作用域,变量就会销毁,因此需要返回根节点,只不过是通过引用的方式罢了;
k1=k;
}
(2)RR型平衡旋转法
由于在A的右孩子C 的右子树上插入结点F,使A的平衡因子由-1减至-2而失去平衡。故需进行一次逆时针旋转操作。即将A的右孩子C向左上旋转代替A作为根结点,A向左下旋转成为C的左子树的根结点。而原来C的左子树则变成A的右子树。
思路:先把C的左子树作为A的右子树,在把A作为C的左子树。
代码:
void RotateWithRight(PtrToNode &k2)
{
PtrToNode k= k2->right; //保存节点的右子树
k2->right=k->left;
k->left=k2;
//到此树旋转完成,更新树的深度,以k,k1为节点的树的深度发生了变化;
k2->height= max(NodeHeight(k2->left),NodeHeight(k2->right))+1;
k->height= max(NodeHeight(k->left),NodeHeight(k->right))+1;
//根节点发生了变化,右k1变为了k,因为传的参数是引用,程序默认k1为根节点
//k为局部变量,离开作用域,变量就会销毁,因此需要返回根节点,只不过是通过引用的方式罢了;
k2=k;
}
(3)LR型平衡旋转法
由于在A的左孩子B的右子数上插入结点F,使A的平衡因子由1增至2而失去平衡。故需进行两次旋转操作(先逆时针,后顺时针)。即先将A结点的左孩子B的右子树的根结点D向左上旋转提升到B结点的位置,然后再把该D结点向右上旋转提升到A结点的位置。即先使之成为LL型,再按LL型处理。
如图中所示,先将圈圈的部分进行逆时针旋转(RR旋转),使之转换为LL型,再进行LL旋转;(双旋转)
代码:
void DoubleRotateWithLeft(PtrToNode &k3)
{
RotateWithRight(k3->left);
RotateWithLeft(k3);
}
(4)RL型平衡旋转法
由于在A的右孩子C的左子树上插入结点F,使A的平衡因子由-1减至-2而失去平衡。故需进行两次旋转操作(先顺时针,后逆时针),即先将A结点的右孩子C的左子树的根结点D向右上旋转提升到C结点的位置,然后再把该D结点向左上旋转提升到A结点的位置。即先使之成为RR型,再按RR型处理。
如图中所示,先将圈圈的部分进行顺时针旋转(LL旋转),使之转换为RR型,再进行RR旋转;(双旋转)
代码:
void DoubleRotateWithRight(PtrToNode &k3)
{
RotateWithLeft(k3->right);
RotateWithRight(k3);
}
三、AVL的查找、删除、插入
1.AVL树的类型声明
//平衡二叉树的结构体
typedef int elementType;
typedef struct AVLNODE
{
elementType data;
struct AVLNODE * left;
struct AVLNODE * right;
int height; //以此节点为根,树的高度;
unsigned int freq;//此节点保存的数据出现的频率
}AvlNode,*PtrToNode;</span>
2.插入
AVL树的插入和二叉查找树的插入相似,只是AVL树的插入可能会破坏树的平衡性。对AVL树而言,插入完成后,需要判断树的平衡性是否被破坏,然后进行相应的旋转处理使之成为平衡树。
(1)左平衡处理
所谓左平衡处理,就是某一根结点的左子树比右子树过高,从而失去了平衡。在节点的左子树进行插入操作使此节点失去平衡,需要左平衡处理。
//左平衡处理
void LeftBalance(PtrToNode &node)
{
PtrToNode ptrTmp=node->left;
if(NodeHeight(ptrTmp->left)-NodeHeight(ptrTmp->right)==-1)
{
//右子树高于左子树,在右子树插入的
DoubleRotateWithLeft(node); //LR
}
else
{
RotateWithLeft(node); //LL
}
}
需要判断是在失去平衡的节点的左孩子的左子树还右子树进行插入的,左子树插入(LL旋转),右子树插入(LR旋转)。
(2)右平衡处理
类似左平衡处理,所谓右平衡处理,就是某一根结点的右子树比左子树过高,从而失去了平衡。
//右平衡处理
void RightBalance(PtrToNode &node)
{
PtrToNode ptrTmp=node->right;
if(NodeHeight(ptrTmp->right)-NodeHeight(ptrTmp->left)==-1)
{ //左子树比右子树高,说明在左子树插入的
DoubleRotateWithRight(node); //RL
}
else
{
RotateWithRight(node); //RR
}
}
需要判断是在失去平衡的节点的右孩子的左子树还右子树进行插入的,左子树插入(RL旋转),右子树插入(RR旋转)。
(3) 插入函数的编写
void AVL_Insert(PtrToNode &node,elementType x)
{
if(NULL==node) //找到插入的节点的位置
{
node =(struct AVLNODE *) malloc(sizeof(struct AVLNODE ));
node->data=x;
node->height=0;
node->freq = 1;
node->left=NULL;
node->right=NULL;
}
else if(x<node->data) //在左子树插入
{
AVL_Insert(node->left,x);
//判断是否破坏AVL树的平衡性
if (NodeHeight(node->left)-NodeHeight(node->right)==2)
LeftBalance(node); //左平衡处理
}
else if(node->data<x) //在右子树插入
{
AVL_Insert(node->right,x);
//判断是否破坏AVL树的平衡性
if (NodeHeight(node->right)-NodeHeight(node->left)==2)
RightBalance(node); //右平衡处理
}
else
{node->freq++;}
node->height = max(NodeHeight(node->left),NodeHeight(node->right)) +1 ; //跟新树的高度
}
2. 查找
由于AVL树是有序的二叉查找树,要查找的元素比节点的数据大,则在右子树查找;比节点的数据小,在左子树中查找;与节点的数据相等,返回该节点。
PtrToNode AVL_Find(PtrToNode & node,elementType x)
{
if (node==NULL) //没找到元素
{
return NULL;
}
else if(x<node->data)
{
return AVL_Find(node->left,x); //在左子树里面查找
}
else if(node->data<x)
{
return AVL_Find(node->right,x); //在右子树里面查找
}
else //相等
return node;
}
3.删除
对二叉查找树,我们知道删除的结点可能有三种情况:(1)为叶子结点,(2)左子树或右子树有一个为空,(3)左右子树都不空。假设删除节点为A。
对于(1):直接删除即可。
对于(2):删除的方法,A的父节点绕过A节点使其指向A左子树(右子树为空)、右子树(左子树为空时)。
对于(3):一般的删除策略:用A的左子树最大数据或右子树最小数据(假设B节点)代替A节点的数据,并递归地删除B节点。
AVL的树的删除策略与二叉查找树的删除策略相似,只是删除节点后造成树失去平衡性,需要做平衡处理。
void AVL_Delete(PtrToNode &node,elementType x)
{
if(NULL==node) //空树直接返回
return;
if(x<node->data) //在左子树中查找
{
AVL_Delete(node->left,x);
if(NodeHeight(node->right)-NodeHeight(node->left)==2) //树左平衡处理
RightBalance(node);
}
else if(node->data<x) //在右子树中查找
{
AVL_Delete(node->right,x);
if(NodeHeight(node->left)-NodeHeight(node->right)==2) //树右平衡处理
LeftBalance(node);
}
else //找到要删除的元素节点
{
if(node->left==NULL) //左子树为空
{
PtrToNode ptrTmp = node;
node=node->right; //用右孩子代替此节点
free(ptrTmp); //释放内存
}
else if(node->right==NULL) //右子树为空
{
PtrToNode ptrTmp = node;
node=node->left; //用左孩子代替此节点
free(ptrTmp);
}
else //左右子树都不为空
{
//一般的删除策略是左子树的最小数据 或 右子树的最小数据 代替该节点
PtrToNode ptrTmp=node->left; //从左子树中查找
while(ptrTmp->right!=NULL) ptrTmp=ptrTmp->right;
//此时的ptrTmp指向左子树中的最大元素
node->data = ptrTmp->data;
AVL_Delete(node->left,ptrTmp->data); //递归的删除该节点
}
}
//更新节点的高度
if(node)
node->height = max(NodeHeight(node->left),NodeHeight(node->right));
}
4.遍历打印输出(中序)
//中序遍历
void print(PtrToNode & root)
{
if (NULL == root)
{
return ;
}
print(root->left);
printf("%d ",root->data);
print(root->right);
}
5.测试代码
int main()
{
//C++引入的引用概念,可以直接对树的节点进行插入操作,而不用返回树的根节点
PtrToNode root =NULL;
/* for(int i=0;i<5;i++)
{
AVL_Insert(root,i);
}
*/
AVL_Insert(root,4);
AVL_Insert(root,2);
AVL_Insert(root,6);
AVL_Insert(root,1);
AVL_Insert(root,3);
AVL_Insert(root,5);
AVL_Insert(root,7);
AVL_Insert(root,16);
AVL_Insert(root,15);
print(root);
printf("\n%d\n",root->height);
AVL_Delete(root,15);
AVL_Delete(root,5);
print(root);
PtrToNode y=AVL_Find(root,15);
if (y==NULL)
{
printf("没有查找到15\n");
}
else
{
printf("所在节点的高度:%d\n",y->height);
if (NULL!=y->left)
{
printf("所在节点的左孩子:%d\n",y->left->data);
}
if (NULL!=y->right)
{
printf("所在节点的右孩子:%d\n",y->right->data);
}
}
}
平衡二叉树性能分析
平衡二叉树的性能优势:
很显然,平衡二叉树的优势在于不会出现普通二叉查找树的最差情况。其查找的时间复杂度为O(logN)。
平衡二叉树的缺陷:
(1) 很遗憾的是,为了保证高度平衡,动态插入和删除的代价也随之增加。红黑树是更加高效的查找结构。
(2) 所有二叉查找树结构的查找代价都与树高是紧密相关的,能否通过减少树高来进一步降低查找代价呢。我们可以通过多路查找树的结构(B树和B+树等)来做到这一点。
(3) 在大数据量查找环境下(比如说系统磁盘里的文件目录,数据库中的记录查询 等),所有的二叉查找树结构(BST、AVL、RBT)都不合适。如此大规模的数据量(几G数据),全部组织成平衡二叉树放在内存中是不可能做到的。那么把这棵树放在磁盘中吧。问题就来了:假如构造的平衡二叉树深度有1W层。那么从根节点出发到叶子节点很可能就需要1W次的硬盘IO读写。大家都知道,硬盘的机械部件读写数据的速度远远赶不上纯电子媒体的内存。 查找效率在IO读写过程中将会付出巨大的代价。在大规模数据查询这样一个实际应用背景下,平衡二叉树的效率就很成问题了。
上面提到的红黑树和多路查找树都是属于深度有界查找树(depth-bounded tree —DBT)
红黑树
红黑树是平衡二叉树的一种,它保证在最坏情况下基本动态集合操作的事件复杂度为O(log n)。红黑树和平衡二叉树区别如下:(1) 红黑树放弃了追求完全平衡,追求大致平衡,在与平衡二叉树的时间复杂度相差不大的情况下,保证每次插入最多只需要三次旋转就能达到平衡,实现起来也更为简单。(2) 平衡二叉树追求绝对平衡,条件比较苛刻,实现起来比较麻烦,每次插入新节点之后需要旋转的次数不能预知。点击查看更多
来自:https://blog.csdn.net/lpp0900320123/article/details/39367451
3.1红黑树的性质
红黑树在原有的排序二叉树增加了如下几个要求:
- 性质 1:每个节点要么是红色,要么是黑色。
- 性质 2:根节点永远是黑色的。
- 性质 3:所有的叶节点都是空节点(即 null),并且是黑色的。
- 性质 4:每个红色节点的两个子节点都是黑色。(从每个叶子到根的路径上不会有两个连续的红色节点)
- 性质 5:从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点。
一、红黑树的介绍
先来看下算法导论对R-B Tree的介绍:
红黑树,一种二叉查找树,但在每个结点上增加一个存储位表示结点的颜色,可以是Red或Black。
通过对任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出俩倍,因而是接近平衡的。
红黑树,作为一棵二叉查找树,满足二叉查找树的一般性质。下面,来了解下 二叉查找树的一般性质。
二叉查找树
二叉查找树,也称有序二叉树(ordered binary tree),或已排序二叉树(sorted binary tree),是指一棵空树或者具有下列性质的二叉树:
若任意节点的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
若任意节点的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
任意节点的左、右子树也分别为二叉查找树。
没有键值相等的节点(no duplicate nodes)。
因为一棵由n个结点随机构造的二叉查找树的高度为lgn,所以顺理成章,二叉查找树的一般操作的执行时间为O(lgn)。但二叉查找树若退化成了一棵具有n个结点的线性链后,则这些操作最坏情况运行时间为O(n)。
红黑树虽然本质上是一棵二叉查找树,但它在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n)。
但它是如何保证一棵n个结点的红黑树的高度始终保持在logn的呢?这就引出了红黑树的5个性质:
1每个结点要么是红的要么是黑的。
2根结点是黑的。
3每个叶结点(叶结点即指树尾端NIL指针或NULL结点)都是黑的。
4如果一个结点是红的,那么它的两个儿子都是黑的。
5 对于任意结点而言,其到叶结点树尾端NIL指针的每条路径都包含相同数目的黑结点。
正是红黑树的这5条性质,使一棵n个结点的红黑树始终保持了logn的高度,从而也就解释了上面所说的“红黑树的查找、插入、删除的时间复杂度最坏为O(log n)”这一结论成立的原因。
(注:上述第3、5点性质中所说的NULL结点,包括wikipedia.算法导论上所认为的叶子结点即为树尾端的NIL指针,或者说NULL结点。然百度百科以及网上一些其它博文直接说的叶结点,则易引起误会,因此叶结点非子结点)
如下图所示,即是一颗红黑树(下图引自wikipedia:http://t.cn/hgvH1l):
此图忽略了叶子和根部的父结点。同时,上文中我们所说的 "叶结点" 或"NULL结点",如上图所示,它不包含数据而只充当树在此结束的指示,这些节点在绘图中经常被省略,望看到此文后的读者朋友注意。
https://blog.csdn.net/github_38818603/article/details/82491522
6、散列表
散列表,也叫哈希表,这里先说一下哈希(hash)表的定义:哈希表是一种根据关键码去寻找值的数据映射结构,该结构通过把关键码映射的位置去寻找存放值的地方
记录的存储位置=f(key)
这里的对应关系 f 成为散列函数,又称为哈希 (hash函数),而散列表就是把Key通过一个固定的算法函数既所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里,这种存储空间可以充分利用数组的查找优势来查找元素,所以查找的速度很快。
哈希表在应用中也是比较常见的,就如Java中有些集合类就是借鉴了哈希原理构造的,例如HashMap,HashTable等,利用hash表的优势,对于集合的查找元素时非常方便的,然而,因为哈希表是基于数组衍生的数据结构,在添加删除元素方面是比较慢的,所以很多时候需要用到一种数组链表来做,也就是拉链法。拉链法是数组结合链表的一种结构,较早前的hashMap底层的存储就是采用这种结构,直到jdk1.8之后才换成了数组加红黑树的结构
哈希表的应用场景很多,当然也有很多问题要考虑,比如哈希冲突的问题,如果处理的不好会浪费大量的时间,导致应用崩溃。
一、散列表相关概念
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。建立了关键字与存储位置的映射关系,公式如下:
存储位置 = f(关键字)
这里把这种对应关系f称为散列函数,又称为哈希(Hash)函数。详情见:Java中hashCode的作用。
采用散列技术将记录存在在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表。那么,关键字对应的记录存储位置称为散列地址。
散列技术既是一种存储方法也是一种查找方法。散列技术的记录之间不存在什么逻辑关系,它只与关键字有关,因此,散列主要是面向查找的存储结构。
二、散列函数的构造方法
2.1 直接定址法
所谓直接定址法就是说,取关键字的某个线性函数值为散列地址,即
优点:简单、均匀,也不会产生冲突。
缺点:需要事先知道关键字的分布情况,适合查找表较小且连续的情况。
由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。
2.2 数字分析法
如果关键字时位数较多的数字,比如11位的手机号"130****1234",其中前三位是接入号;中间四位是HLR识别号,表示用户号的归属地;后四为才是真正的用户号。如下图所示。
如果现在要存储某家公司的登记表,若用手机号作为关键字,极有可能前7位都是相同的,选择后四位成为散列地址就是不错的选择。若容易出现冲突,对抽取出来 的数字再进行反转、右环位移等。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各个位置。
数字分析法通过适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布比较均匀,就可以考虑用这个方法。
2.3 平方取中法
这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。
平方取中法比较适合不知道关键字的分布,而位数又不是很大的情况。
2.4 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
比如关键字是9876543210,散列表表长为三位,将它分为四组,987|654|321|0,然后将它们叠加求和987 + 654 + 321 + 0 = 1962,再求后3位得到散列地址962。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
2.5 除留余数法
此方法为最常用的构造散列函数方法。对于散列表长为m的散列函数公式为:
mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可以再折叠、平方取中后再取模。
很显然,本方法的关键在于选择合适的p,p如果选不好,就可能会容易产生冲突。
根据前辈们的经验,若散列表的表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
2.6 随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。也就是f(key) = random(key)。这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。
总之,现实中,应该视不同的情况采用不同的散列函数,这里只能给出一些考虑的因素来提供参考:
(1)计算散列地址所需的时间(计算简单)
(2)关键字的长度;
(3)散列表的长度;
(4)关键字的分布情况(均匀);
(5)记录查找的频率。
综合以上等因素,才能决策选择哪种散列函数更合适。
三、处理散列冲突的方法
在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。市场会碰到两个关键字key1 != key2,但是却有f(key1) = f(key2),这种现象称为冲突。出现冲突将会造成查找错误,因此可以通过精心设计散列函数让冲突尽可能的少,但是不能完全避免。
3.1 开放定址法
所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
它的公式为:
比如说,关键字集合为{12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34},表长为12。散列函数f(key) = key mod 12。
当计算前5个数{12, 67, 56, 16, 25}时,都是没有冲突的散列地址,直接存入,如下表所示。
计算key = 37时,发现f(37) = 1,此时就与25所在的位置冲突。于是应用上面的公式f(37) = (f(37) + 1) mod 12 =2,。于是将37存入下标为2的位置。如下表所示。
接下来22,29,15,47都没有冲突,正常的存入,如下标所示。
到了48,计算得到f(48) = 0,与12所在的0位置冲突了,不要紧,我们f(48) = (f(48) + 1) mod 12 = 1,此时又与25所在的位置冲突。于是f(48) = (f(48) + 2) mod 12 = 2,还是冲突......一直到f(48) = (f(48) + 6) mod 12 = 6时,才有空位,如下表所示。
把这种解决冲突的开放定址法称为线性探测法。
考虑深一步,如果发生这样的情况,当最后一个key = 34,f(key) = 10,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以不断地求余后得到结果,但效率很差。因此可以改进di=12, -12, 22, -22.........q2, -q2(q<= m/2),这样就等于是可以双向寻找到可能的空位置。对于34来说,取di = -1即可找到空位置了。另外,增加平方运算的目的是为了不让关键字都聚集在某一块区域。称这种方法为二次探测法。
还有一种方法,在冲突时,对于位移量di采用随机函数计算得到,称之为随机探测法。
既然是随机,那么查找的时候不也随机生成di 吗?如何取得相同的地址呢?这里的随机其实是伪随机数。伪随机数就是说,如果设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,在查找时,用同样的随机种子,它每次得到的数列是想通的,相同的di 当然可以得到相同的散列地址。
总之,开放定址法只要在散列表未填满时,总是能找到不发生冲突的地址,是常用的解决冲突的方法。
3.2 再散列函数法
对于散列表来说,可以事先准备多个散列函数。
这里RHi 就是不同的散列函数,可以把前面说的除留余数、折叠、平方取中全部用上。每当发生散列地址冲突时,就换一个散列函数计算。
这种方法能够使得关键字不产生聚集,但相应地也增加了计算的时间。
3.3 链地址法
将所有关键字为同义词的记录存储在一个单链表中,称这种表为同义词子表,在散列表中只存储所有同义词子表前面的指针。对于关键字集合{12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34},用前面同样的12为余数,进行除留余数法,可以得到下图结构。
此时,已经不存在什么冲突换地址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保证。当然,这也就带来了查找时需要遍历单链表的性能损耗。
3.4 公共溢出区法
这个方法其实更好理解,你冲突是吧?那重新给你找个地址。为所有冲突的关键字建立一个公共的溢出区来存放。
就前面的例子而言,共有三个关键字37、48、34与之前的关键字位置有冲突,那就将它们存储到溢出表中。如下图所示。
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表中进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
四、散列表查找实现
#include <stdio.h>
#include <stdlib.h>
#define OK 1
#define ERROR 0
#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 //定义散列表表未数组的长度
#define NULLKEY -32768
typedef struct
{
int *elem; //数据元素存储基地址,动态分配数组
int count; //当前数据元素个数
}HashTable;
int m = 0; //散列表长,全局变量
//初始化散列表
int InitHashTable(HashTable *h)
{
int i;
m = HASHSIZE;
h->elem = (int *)malloc(sizeof(int) * m );
if(h->elem == NULL)
{
fprintf(stderr, "malloc() error.\n");
return ERROR;
}
for(i = 0; i < m; i++)
{
h->elem[i] = NULLKEY;
}
return OK;
}
//散列函数
int Hash(int key)
{
return key % m; //除留余数法
}
//插入关键字进散列表
void InsertHash(HashTable *h, int key)
{
int addr = Hash(key); //求散列地址
while(h->elem[addr] != NULLKEY) //如果不为空,则冲突
{
addr = (addr + 1) % m; //开放地址法的线性探测
}
h->elem[addr] = key; //直到有空位后插入关键字
}
//散列表查找关键字
int SearchHash(HashTable h, int key)
{
int addr = Hash(key); //求散列地址
while(h.elem[addr] != key) //如果不为空,则冲突
{
addr = (addr + 1) % m; //开放地址法的线性探测
if(h.elem[addr] == NULLKEY || addr == Hash(key))
{
//如果循环回原点
printf("查找失败, %d 不在Hash表中.\n", key);
return UNSUCCESS;
}
}
printf("查找成功,%d 在Hash表第 %d 个位置.\n", key, addr);
return SUCCESS;
}
int main(int argc, char **argv)
{
int i = 0;
int num = 0;
HashTable h;
//初始化Hash表
InitHashTable(&h);
//未插入数据之前,打印Hash表
printf("未插入数据之前,Hash表中内容为:\n");
for(i = 0; i < HASHSIZE; i++)
{
printf("%d ", h.elem[i]);
}
printf("\\n");
//插入数据
printf("现在插入数据,请输入(A代表结束哦).\n");
while(scanf("%d", &i) == 1 && num < HASHSIZE)
{
if(i == 'a')
{
break;
}
num++;
InsertHash(&h,i);
if(num > HASHSIZE)
{
printf("插入数据超过Hash表大小\n");
return ERROR;
}
}
//打印插入数据后Hash表的内容
printf("插入数据后Hash表的内容为:\n");
for(i = 0; i < HASHSIZE; i++)
{
printf("%d ", h.elem[i]);
}
printf("\n");
printf("现在进行查询.\n");
SearchHash(h, 12);
SearchHash(h, 100);
return 0;
}
五、散列表的性能分析
如果没有冲突,散列查找是所介绍过的查找中效率最高的。因为它的时间复杂度为O(1)。但是,没有冲突的散列只是一种理想,在实际应用中,冲突是不可避免的。
那散列查找的平均查找长度取决于哪些因素呢?
(1)散列函数是否均匀
散列函数的好坏直接影响着出现冲突的频繁程度,但是,不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的(为什么??),因此,可以不考虑它对平均查找长度的影响。
(2)处理冲突的方法
相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。如线性探测处理冲突可能会产生堆积,显然就没有二次探测好,而链地址法处理冲突不会产生任何堆积,因而具有更好的平均查找性能。
(3)散列表的装填因子
所谓的装填因子a = 填入表中的记录个数/散列表长度。a标志着散列表的装满的程度。当填入的记录越多,a就越大,产生冲突的可能性就越大。也就说,散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数。
不管记录个数n有多大,总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时散列表的查找时间复杂度就是O(1)了。为了这个目标,通常将散列表的空间设置的比查找表集合大。
六、散列表的适应范围
散列技术最适合的求解问题是查找与给定值相等的记录。对于查找来说,简化了比较过程,效率会大大提高。
但是,散列技术不具备很多常规数据结构的能力,比如
- 同样的关键字,对应很多记录的情况,不适合用散列技术;
- 散列表也不适合范围查找等等。
引用:http://blog.chinaunix.net/uid-26548237-id-3480645.html
7、堆
堆是一种比较特殊的数据结构,可以被看做一棵树的数组对象,具有以下的性质:
堆中某个节点的值总是不大于或不小于其父节点的值;
堆总是一棵完全二叉树。
将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。常见的堆有二叉堆、斐波那契堆等。
堆的定义如下:n个元素的序列{k1,k2,ki,…,kn}当且仅当满足下关系时,称之为堆。
(ki <= k2i,ki <= k2i+1)或者(ki >= k2i,ki >= k2i+1), (i = 1,2,3,4…n/2),满足前者的表达式的成为小顶堆,满足后者表达式的为大顶堆,这两者的结构图可以用完全二叉树排列出来,示例图如下:
因为堆有序的特点,一般用来做数组中的排序,称为堆排序。
8、图
图是由结点的有穷集合V和边的集合E组成。其中,为了与树形结构加以区别,在图结构中常常将结点称为顶点,边是顶点的有序偶对,若两个顶点之间存在一条边,就表示这两个顶点具有相邻关系。
按照顶点指向的方向可分为无向图和有向图:
图是一种比较复杂的数据结构,在存储数据上有着比较复杂和高效的算法,分别有邻接矩阵 、邻接表、十字链表、邻接多重表、边集数组等存储结构,这里不做展开,读者有兴趣可以自己学习深入。
来源:CSDN
原文:https://blog.csdn.net/yeyazhishang/article/details/82353846