目录
引子
查找(Searching)在人们的日常生活中是一个常用的操作。
查找是可以分为静态查找和动态查找的。
所谓静态查找,是指集合中的记录是固定的,仅仅查找,不涉及插入和删除的操作,不改变集合的元素。一般用数组就可以满足了。
动态查找,集合中的元素是会频繁变化的,除了查找功能,还要求具备插入和删除功能。
接下来了解下面两种静态查找方法。
第一种:顺序查找(时间复杂度为n)
#define MAX 100
int SequentialSearch(int a[MAX],int length,int x)//x为查找元素
{
int num;
int i;
for(i = 0;i<length;i++)
{
if(a[i]==x)
{
num = i;
break;
}
}
if(i==length)
{
printf("此元素不存在/n");
return -1;
}
else
return num;//返回元素位置
}
第二种:二分查找(时间复杂度为log n)
注* 二分法要求数组是有序的
#define MAX 100
int BinarySearch(int a[MAX],int length,int x)
{
int left = 0;
int right = length - 1;
while(left<=right)
{
int mid = (right + left) / 2;
if(x>a[mid])
left = mid + 1;
else if(x<a[mid])
right = mid - 1;
else
return mid;
}
return -1;
}
若数据不是有序时,我们可以采用其他的非线性表数据结构---树。
3.1 树
3.1.1 树的定义
树(Tree)是n(n>=0)个结点的有限集。它是一种一对多的数据结构。
n = 0 时为空树。
在任意一颗非空树中:
- 根(Root)结点仅有一个;
- 当 n > 1时,其余顶点均互不相交,余下任意一结点都是一棵树,这些树被称为根的子树(SubTree). 树的结点具有相同数据类型及层次关系。
3.1.2 树的基本术语
- 结点的度:一个结点的度是其子树的个数;
- 树的度:树的所有结点中最大的度数;
- 叶结点:度数为0的结点;
- 父结点:有子树的结点;
- 结点的层次:规定根结点在第一层其他任意结点的层数是其父结点的层数加一;
- 树的深度:树所有结点中的最大层次。
3.1.3 树的表示方法
在这里只讲孩子兄弟表示法。(它最常用)
它的结点结构如图所示:
为了更好的理解,也可以参考下面这个图:
以上图中,Data是数据域,左孩子、右孩子都是指针域,左孩子存储的是该结点的左结点的存储地址,右结点存储的是该结点的右结点的存储地址。
结构定义代码如下:
struct TNode
{
int data;//结点数据
struct TNode* left;//指向左节点
struct TNode* right;//指向右结点
};
3.2 二叉树
二叉树(Binary Tree)是n(n>=0)个结点的有限集合。它是由根节点和称为其左子树和右子树的两个不相交的二叉树组成。
一般二叉树可具有五种基本形态,如下图所示:
3.2.1 特殊的二叉树
首先是斜二叉树(也叫退化二叉树):所有结点都只有左(右)子树的二叉树。
接着是完美二叉树(又叫满二叉树):所有分支结点都存在左子树和右子树,并且所有的叶子都在同一层。
最后是完全二叉树:对树中n个结点进行编号(按从上到下、从左到右的顺序),编号位置与完美二叉树的编号位置相同。(下图中:第一个是完美二叉树,第二个是完全二叉树)
3.2.2 二叉树的性质
-
在二叉树第i层最多有个结点;
- 深度为k的二叉树最多有个结点;
- 对任意一个非空的二叉树T,若表示叶节点的个数、是度数为2的非叶结点个数,那么两者关系满足;
- 有n个结点的完全二叉树的深度为;(表示不大于x的最大整数)
- 若有一颗有n个结点的完全二叉树,其结点按顺序编号,对任意结点i有:
(1)i = 1,则结点i是二叉树的根;
(2)2i > n,则无左结点;
(3)2i + 1> n,则无右结点.
3.2.3 二叉树的存储结构
存储二叉树时,除了存储它的每个结点的数据,结点之间的逻辑关系也应该得到体现。
1. 二叉树的顺序存储结构
这种结构是用一组连续的存储单元进行存储的(数组),结点的父子关系是通过它们的相对位置来反映的,无需指针。下图是一个完全二叉树:
2. 二叉树的链式存储结构
下图是一个一般的二叉树(已补充为完全二叉树):
从图中可以看出,为了补充成完全二叉树而增加的“虚”结点,浪费了存储单元。
为了解决这一问题,还是采用链表来存储。
结构图仍然可以参考这个:
3.2.4 二叉树的操作
判断二叉树是否为空比较简单,所以这里重点写遍历和创建二叉树。
1. 二叉树的遍历
树的遍历是指访问树的每个结点,且每个结点仅被访问一次。
二叉树的遍历根据二叉树的构成以及访问顺序可分为先序遍历、中序遍历、后序遍历和层序遍历。
(1)中序遍历:即先访问左子树,再访问根结点,最后访问右子树。
void InorderTraversal(BinaryTree tree)
{
if(tree)
{
InorderTraversal(tree->left);
printf("%d ",tree->data);
InorderTraversal(tree->right);
}
}
(2)先序遍历:先访问根结点,再访问左子树,最后访问右子树。
void PerorderTraversal(BinaryTree tree)
{
if(tree)
{
printf("%d ",tree->data);
PerorderTraversal(tree->left);
PerorderTraversal(tree->right);
}
}
(3)后序遍历:先访问左子树,再访问右子树,最后访问根节点。
void PostorderTraversal(BinaryTree tree)
{
if(tree)
{
PostorderTraversal(tree->left);
PostorderTraversal(tree->right);
printf("%d ",tree->data);
}
}
“先”、“中”、“后”其实主要是看根结点的顺序,除此都是先遍历左子树,再遍历右节点。
(4)以上都是递归的算法,下面是一个非递归的遍历。(以中序遍历为例)
void InorderTraversal(BinaryTree tree)
{
BinaryTree t;
Stack S = CreateStack();//创建空栈S
t = tree;//从根结点开始
while(t||!IsEmpty(S))
{
while(t)//一直向左,并将沿途的结点压入堆栈
{
Push(S,t);
t = t->left;
}
t = Pop(S,t);
printf("%d ",t->data);//打印结点
t = t->right;//转向右子树
}
}
(5)层序遍历:按树的层次,从上到下、从左到右的顺序依次访问。(先遇到的先访问)
void LevelorderTraversal(BinaryTree tree)
{
Queue Q;
BinaryTree t;
if(!tree)
return;//空树直接返回
Q = CreateQueue();//创建空队列Q
Add(Q,tree);//将根结点指针入队
while(!IsEmpty(Q))
{
t = DeleteQ(Q);//队列中取出一个元素
printf("%d ",t->data);//访问取出队列的结点
if(t->left)
Add(Q,t->left);//左孩子非空入队
if(t->right)
Add(Q,t->right);//右孩子非空入队
}
}
2. 二叉树的创建
树是非线性结构,创建一颗二叉树必须先确定树中结点的输入顺序,常见的方法有先序创建和层序创建。
(1)层序创建:结点输入顺序是从上到下、从左到右的,空结点输入0。 理解可参考下图:
3.3 二叉搜索树
3.3.1 二叉搜索树的定义
二叉搜索树(Binary Search Tree)也叫二叉排序树或二叉查找树,它是一种对排序和查找都很有用的特殊二叉树。
二叉搜索树可以为空,若不为空,它就满足:
- 非空左子树的所有键值小于其根结点的键值;
- 非空右子树的所有键值大于其根结点的键值;(左小右大)
- 左、右子树都是二叉搜索树。
3.3.2 二叉搜索树的动态查找
二叉搜索树是施加了一定约束的特殊二叉树,它一般用链表存储,结点与普通二叉树的代码完全一样。以下三个函数是普通二叉树所没有的:
- Position Find(BinTree tree,ElementType x): 从树中查找x并返回其地址。
- Position FindMin(BinTree tree): 从树中查找最小值并返回其地址。
- Position FindMax(BinTree tree): 从树中查找最大值并返回其地址。
(1)Find查找
Position Find(BinTree tree,ElementType x)
{
if(!tree)
return NULL;
if(x>tree->data)
return Find(tree->right,x);
else if(x<tree->data)
return Find(tree->left,x);
else
return tree;
}
以上采用的递归方法,接下来使用非递归函数。
Position Find(BinaryTree tree,ElementType x)
{
while(tree)
{
if(x>tree->data)
tree = tree->right;
else if(x<tree->data)
tree = tree->left;
else
break;
}
return tree;
}
(2)查找最大值和最小值
根据二叉搜索树的性质特点,最小元素一定在树的最左分支的叶节点上,最大元素一定在树的最右分支的叶结点上。
查找最小元素的递归函数:
Position FindMin(BinaryTree tree)
{
//最小值在最左边
if(!tree)//二叉搜索树为空,直接返回NULL
return NULL;
else if(!tree->left)//找到最左端返回
return tree;
else //沿左分支递归查找
return FindMin(tree->left);
}
查找最大元素的非递归函数:
Position FindMax(BinaryTree tree)
{
if(tree)
while(tree->right)
tree = tree->right;//沿着右分支一直向下,直到最右端
return tree;
}
3.3.3 二叉搜索树的插入
首先要在tree里查找x,若存在,可以不用进行插入;若不存在,查找停止的位置就是要插入的位置。
BinaryTree Insert(BinaryTree tree,ElementType x)
{
if(!tree)//若树为空,生成并返回一个结点的二叉搜索树
{
tree = (BinaryTree)malloc(sizeof(struct TNode));
tree->data = x;
tree->left = tree->right = NULL;
}
else
{
//寻找x的位置
if(x<tree->data)
tree->left = Insert(tree,x);//递归插入左子树
else if(x>tree->data)
tree->right = Insert(tree,x);//递归插入右子树
}
return tree;
}
3.3.4 二叉搜索树的删除
二叉搜索树的删除操作相比上面的操作要更为复杂。
删除操作,首先要先找到需要删除元素的位置,根据位置的不同,删除操作也有所不同。一般是考虑以下三种情况:
首先删除的是叶节点。这是最简单的一种。可以直接删除,再将其父结点的指针置空即可。
接着删除的是只有一个孩子的结点。这种情况,在删除之前需要修改其父结点的指针,指向要删除节点的孩子结点。
最后删除的是有左、右两颗子树的结点。这里关于用哪一颗子树的根节点来填充删除结点的位置,有两种选择:一种选择是取其右子树中的最小元素;另外一种则是取其左子树的最大元素。
参考代码如下:
BinaryTree Delete(BinaryTree tree,ElementType x)
{
struct TNode* T = (struct TNode*)malloc(sizeof(TNode));
if(!tree)
printf("删除结点未找到\n");
else
{
if(x<tree->data)
tree->left = Delete(tree->left,x);
else if(x>tree->data)
tree->right = Delete(tree->right,x);
else
{
//tree已经为待删结点
if(tree->left&&tree->right)//若待删结点既有左结点,又有右结点
{
T = FindMin(tree->right);//此处采用的是第一种选择,右子树的最小值
//也可换为 T = FindMax(tree->left);
tree->data = T->data;
tree->right = Delete(tree->right,tree->data);//再从右子树中删除最小值
}
else//待删除结点有一个子结点或没有子结点
{
T = tree;
if(!tree->left)//只有右孩子或者没有子结点
tree = tree->right;
else//只有左结点
tree = tree->left;
free(T);
}
}
}
return tree;
}
3.4 平衡二叉树
二叉搜索树查找的时间复杂度是由查找过程中的比较次数来衡量的,而比较是从根结点到叶结点的路径进行的,它又取决于树的深度。
树的深度又受二叉树的类型影响,例如:对于完全二叉树来说,它的查找复杂度为O(log N);若是二叉树退化为一颗单枝树的极端(斜二叉树)时,它的查找复杂度为O(N)。
假定二叉搜索树中每个结点的查找概率都是相同的,那么我们称查找所有结点的比较次数的平均值为树的“平均查找长度"(Average Search Length,ASL)。
一棵树的ASL值越小,与完全二叉树就越接近,它的查找时间复杂度也就越接近O(log N)。
3.4.1 平衡二叉树的定义
平衡二叉树(Balanced Binary Tree)又称为AVL树,它的查找、删除、插入均可在O(log N)时间内完成。AVL树可以为空,若不为空,则具有以下性质:
- 任一结点的左、右子树均为AVL树;
- 根结点左、右子树高度的绝对值不超过1.
平衡因子(Balance Factor,BF),简单地说就是左、右子树的高度差,BF(T) = 。
设是高度为h平衡二叉树的最小结点数。表示斐波那契序列。
有两种关系式:
h 0 1 1 1 2 1 2 4 2 3 7 3 ... ... ...
给定结点数为n的AVL树的最大高度为.
3.4.2 平衡二叉树的调整
当向AVL树中插入新的结点时,可能会破坏了树的平衡,这时候就需要做树的“平衡化”处理。
1. 单旋调整
如上图所示,(1)是一颗平衡树,平衡因子的绝对值最大都没有超过1;(2)是在(1)的基础上开始插入新的结点“4”,此时平衡因子的绝对值最大已经超过1,在(2)中,“1”为“发现者”,“4”为“麻烦结点”,“4”在“1”的右子树的右边,这个叫RR插入,它需要RR旋转(右单旋)。
(1)是最开始的AVL树,(2)在(1)的基础上插入了一个新结点,此时“3”是“发现者”,“5”是那个“破坏结点”, “5”在“3”的左子树的左边,这个叫LL插入,它需要LL旋转(左单旋)。
2. 双旋调整
双旋分为两种,“左-右双旋”和“右-左双旋”,它是两次单旋的合成结果。
上图为“左-右双旋”,它的调整方式是:将E置于A的位置,A及其右子树调整为E的右子树。
上图为“右-左双旋”,它的调整方式是:将D置于A的位置,A及其左子树调整为D的左子树。
3.5 树的应用
3.5.1 堆及其操作
通过之前的学习,知道队列的基本特征“先进先出”,前面的元素未处理完,后面的元素只能等待。但是,实际应用中,总有些情况需要优先处理,需要特权,此时就要用到堆(Heap)。
1. 堆的定义
堆(Heap)是一种特殊的队列。从堆中取出元素的顺序是按照元素的优先级大小排序的。
2. 堆的表示
堆最常用的结构是二叉树,若非特指,它就是一颗完全二叉树。
由于结点排布及其规律,所以通常不必用指针,而是用数组来实现堆的存储。
用数组来表示完全二叉树是堆的第一个特性,称为堆的结构特性;堆的另一特性是有序性,即任一结点元素的数值与其子结点所存储的值是相关的。
据此,又分为了两种基本堆:最大堆(MaxHeap) 和最小堆(MinHeap)。
最大堆,任一结点的值大于或等于其子结点的值。(根节点元素的值在整个堆中是最大的)
最小堆,任一结点的值小于或等于其子结点的值。(根节点元素的值是整个堆中最小的)
3. 最大堆操作
最大堆的操作一般就是创建、判断堆的空满、插入、删除。
首先看创建:
#define MAXDATA 1000
typedef struct HNode* Heap;//Heap为指向堆结构体的指针
struct HNode
{
ElementType* data;//动态数组的指针,存放堆中的元素
int size;//当前堆中的数量
int capacity;//堆的容量
};
//将Heap重新命名为MaxHeap和MinHeap来表示最大堆和最小堆
typedef Heap MaxHeap;
typedef Heap MinHeap;
//创建最大堆
MaxHeap CreateHeap(int MaxSize)
{
MaxHeap H = (MaxHeap)malloc(sizeof(struct HNode));//给结构体分配空间
H->data = (ElementType*)malloc(sizeof(ElementType)*(MaxSize+1));//给动态数组分配空间
H-> size = 0;//初始堆为空
H->capacity = MaxSize;
H->data[0] = MAXDATA;//最大堆的哨兵元素,保证根节点值最大
//设好哨兵后,索引就从1开始
return H;
}
接着看插入,插入之前是要判断堆是否已经满的情况:
bool IsFull(MaxHeap H)
{
return(H->size==H->capacity);
}
bool Insert(MaxHeap H,ElementType x)
{
int i;
if(IsFull(H)){
printf("堆已经满了\n");
return false;
}
//i是指向插入后堆中的最后一个元素的位置
for(i = H->size + 1;H->data[i/2]<x;i = i/2)// i/2 是叶结点的位置
H->data[i] = H->[i/2];
H->data[i] = x;
return true;
}
还有删除操作,删除之前需要判断堆是否为空的情况:(最大堆的删除就是取出根结点的元素)
#define ERROR -1
bool IsEmpty(MaxHeap H)
{
return(H->size==0);
}
//删除最大堆中的最大值并返回
ElementType DeleteMax(MaxHeap H)
{
int parent,child;
ElementType MaxItem,x;
if(IsEmpty(H))
{
printf("最大堆为空\n");
return ERROR;
}
MaxItem = H->data[1];//把要删的结点保存起来
x = H->data[H->size--];//x存放最后一个结点并减小堆的大小
//整个for循环的目的就是寻找x的位置
for(parent = 1;parent*2<=H->size;parent = child)
{//从根节点开始
child = parent * 2;//child为左子结点,child+1为右子结点
if((child!=H->size)&&(H->data[child] < H->data[child+1]))
//如果右子节点存在且比左子节点大
child++;//child指向左节点
if(x>H->data[child])//x节点的值比子节点大,则找到了插入位置,退出循环
break;
else
H->data[parent] = H->data[child];//把子结点中大的那一个换(child)上去
}
H->data[parent] = x;
return MaxItem;//返回已删除的数值
}
3.5.2 哈夫曼树
1. 哈夫曼树的定义
哈夫曼树(Huffman Tree)是一种用于数据压缩的树形结构。它是一种带权路径长度最短的二叉树,又称为最优二叉树。
*带权路径长度:从根结点到该结点之间的路径长度与该结点上所带权值的乘积。
每个叶结点的带权路径长度之和就是这棵树的带权路径长度(Weighted Path Length,WPL).
例如:有四个结点,给定权值{11,21,31,41},用此组权值构造哈夫曼树,步骤如下:
(1)在给定的权值中,选择最小和次小的权值作为叶结点,其根结点为最小与次小数值相加;
(2)把其根结点加入剩余的权值中进行比较;
(3)重复(1)、(2)操作,直到只剩下一颗二叉树时,这就是哈夫曼树。
*注:也许同一组权值所构成的哈夫曼树形态不同,但带权路径长度一定相同,并且一定是最小值。
2. 哈夫曼树的构造
typedef struct HTNode* HuffmanTree;//哈夫曼树类型
struct HTNode
{
int weight;//结点权值
HuffmanTree left;//指向左子树
HuffmanTree right;//指向右指数
}
HuffmanTree Huffman(MinHeap H)//此处最小堆的元素类型为HuffmanTree
{
int i,N;
HuffmanTree T;
BuildHeap(H);
N = H->size;
for(i=1;i<N;i++)
{
T = (HuffmanTree)malloc(sizeof(struct HTNode));
T->left = DeleteMin(H);
T->right = DeleteMin(H);
T->weight = T->left->weight + T->right->weight;
Insert(H,T);
}
return DeleteMin(H);
}
3. 哈夫曼编码
哈夫曼编码是一种高效的编码方式,在信息存储和传输过程中,用于对信息进行压缩。
首先要明白什么是编码。编码是把人类能看懂的信息转化为计算机能识别的二进制形式。
一般最常见的就是ASCII码。ASCII码中,把每一个字符表示成特定的8位二进制数,很显然,它是一个等长编码。等长编码有一个很大的缺点,由于计算机的存储空间和网络传输的宽带是有限的,若等长编码太长会造成一定的空间浪费。
当然,也有不等长编码。它解决了空间浪费上的问题,但是,它具有二义性。例如:0表示a,00表示b,那么000可以是aaa也可以是ab。
为了解决不等长编码的二义性,就不得不说哈夫曼编码了。
给定权值{12,17,18,21,26},画哈夫曼树,把结点左边当作0,结点右边当成1,哈夫曼树从根结点到每一个叶结点(权值)的路径构成的编码,这就是哈夫曼编码。如图所示:
从图中可以看出,权值都在叶结点上。
它既避免了空间上的浪费,又没有二义性,每个值权(字符)的编码都是独一无二的。
3.5.3 集合及其运算
1. 集合
集合是一种常见的数据表示方式。
集合运算包括交、并、补、查以及判定一个数据是否是某一集合中的元素。
查并集:集合并、查某元素属于什么集合。
这里使用树的结构是最简便的,与之前父子关系指针有所不同,它是由子结点指向父结点的(双亲表示法)。
这里选择用数组进行存储。
2. 集合运算
(1)查找某个元素所在的集合
int Find(setType s[],ElementType x)//x为查找的元素
{
int i;
for(i=0;i<Maxsize&&s[i].data!=x;i++)//Maxsize为全局变量,表示数组S的最大长度
if(i>=Maxsize)
return -1;//未查找到该元素,返回-1
for(;s[i].parent>=0;i=s[i].parent)
return i;//返回x所在集合的根结点
}
(2)集合的并运算
就是要把两个元素所在的集合合并起来(两个元素不在同一集合)。
首先要查找x,y两个元素所在集合树的根结点;
接着判断,若根结点不同,则将其中一个根结点的父结点指针设置为另一个根结点的数组下标;
若根结点相同,则不做操作,因为本身就在同一集合中,没必要再次合并。
void Union(SetType s[],ElementType x,ElementType y)
{
int Root1,Root2;
Root1 = Find(s,x);
Root2 = Find(s,y);
if(Root1!=Root2)
s[Root2].parent = Root1;
}