树是n个(n>=0)个结点的有限集。n=0时称为空树。在任意一棵非空树中:有且仅有一个称为根的结点,其余结点可分为m(m>0)个互不相交的有限集T1,T2…Tm,其中每一个集合本身又是一棵树,并且称为根的子树。如图所示
子树T1和T2就是根结点A的子树,D,G,H,I组成的树又是以B为根结点的子树,E,F,J又是以C为结点的子树。
对于树的定义需注意两点:
1 根结点是唯一的,一棵树中不可能存在多个根结点。
2子树一定是互不相交的。
名称 | 定义 |
---|---|
结点的度 | 结点所含的子树的个数 ,如上图A的度为2 |
叶子结点或终端结点 | 不含有子树的结点,度为0, 上图G,H,I,J,F都为叶子结点 |
非终端结点 | 有子树的结点,度不为0, 上图A,B,C,D,E,都为非终端结点 |
双亲结点 | 若一个结点含有子节点,则这个结点称为子节点的双亲结点, 上图C为E,F的双亲结点 |
孩子结点 | 一个结点的子树的根节点, B,C为A的孩子结点 |
兄弟结点 | 具有相同双亲结点的结点互称为兄弟结点 B,C为兄弟结点 |
树的度 | 一棵树中,最大的结点的度,上图中树的度为3 |
结点的层次 | 根为第一层,根的子结点称为第二层,以此类推,上图D,E,F结点所在的层次为第三层 |
树的高度或深度 | 树中结点的最大层次, 上图中树的高度为4 |
堂兄弟结点 | 双亲在同一层的结点 , 上图D,E,F互为堂兄弟结点 |
结点的祖先 | 从根到该结点所经分支上的所有结点 ,A是所有结点的祖先 |
子孙 | 以某结点为根的子树中任意结点都称为该结点的子孙 , 上图所有结点都是A的子孙 |
森林 | 由m(m>0)棵互不相交的树的集合称为森林 |
实际中树有很多种表示方法,双亲表示法,孩子表示法,孩子双亲表示法以及孩子兄弟表示法,这里我们来了解孩子兄弟表示法
typedef int DataType;
struct Node
{
struct Node* firstChild1; // 指向该结点的第一个孩子结点
struct Node* pNextBrother; // 指向子节点右边第一个兄弟结点
DataType data; // 结点中的数据域
}
二叉树是n(n>=0)个结点的有限集合,该集合或者为空集(为空二叉树),或者由一个根节点和两棵互不相交的,分别称为根节点的左子树和右子树的二叉树组成。
①每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点
②左子树和右子树是有顺序的,次序不能颠倒
①定义:在一棵二叉树中,所有的分支结点都存在左右子树,并且所有叶子结点都在同一层。
注意:叶子结点只能出现在最下面的一层,非叶子结点的度为2
定义:完全二叉树的前n-1层是满二叉树,最后一层可以不满但必须是连续的。
二叉树的顺序存储就是用一维数组存储二叉树中的结点。
我们先来看看完全二叉树的顺序存储
不难得出,用数组存储的方式可以利用下标计算父子间的关系
Leftchild=parent×2+1
Rightchild=parent×2+2
Parent=(child-1)/2
再来看看一般二叉树的顺序存储
可以看出用顺序存储的方式存储一般二叉树会存在空间浪费,所以顺序存储一般用于存储完全二叉树中的结点。数据结构中有种结构叫堆,是具有特殊性质的完全二叉树,用数组存储。下面我们就详细来学习这种数据结构
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大根堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小根堆。
图1:
图2:
图一根节点的值都大于其左右孩子的值,为大根堆
图二根节点的值都小于其左右孩子的值,为小根堆
堆结构体定义
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
堆的逻辑结构是一棵完全二叉树,物理结构上是用数组存储,所以结构体的定义和顺序表一样。
堆的接口实现
void Swap(HPDataType* p1, HPDataType* p2);//交换两个结点的位置
void HeapPrint(Heap* hp);//打印堆
void HeapInit(Heap* hp);//初始化堆
void HeapDestroy(Heap* hp);//销毁堆
void HeapPush(Heap* hp, HPDataType x);//堆内插入元素
void HeapPop(Heap* hp);//删除堆顶元素
HPDataType HeapTop(Heap* hp); //返回堆顶元素
bool HeapEmpty(Heap* hp);//判断堆顶是否为空
int HeapSize(Heap* hp);//返回堆中元素的个数
void AdjustUp(HPDataType* a, int child);//向上调整算法
void AdjustDown(HPDataType* a, int size, int parent);//向下调整算法
堆内插入元素
void HeapPush(Heap* hp, HPDataType x)
{
assert(hp);
if (hp->size == hp->capacity)//堆已满,需要扩容
{
int newcapacity = hp->capacity == 0 ? 4 : hp->capacity * 2; //注意第一次扩容的问题,直接开辟四个空间,其余在原来capacity的基础上扩大2倍
HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * newcapacity);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
hp->a = tmp;
hp->capacity = newcapacity;
}
hp->a[hp->size] = x;
hp->size++;
AdjustUp(hp->a, hp->size - 1);//向上调整算法,插入新的元素后保持原来堆的结构
}
堆内插入元素,物理上直接在size下标处插入新元素。和顺序表一样,在插入时要考虑扩容问题,尤其是第一次扩容。逻辑上要继续保持原来堆的结构(这里以大堆为例),就需要依次调整该结点到根节点所在路径上的所有结点,直到满足大堆的性质即可。
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2;//计算出插入元素的双亲结点的位置
while (child>0)
{
if (a[child] > a[parent])//孩子结点的值比双亲结点的值大就交换位置
{
Swap(&a[child], &a[parent]);
child = parent;//迭代往上走
parent = (child - 1) / 2;
}
else//满足大堆的性质,跳出循环
break;
}
}
向上调整算法,从插入位置一直向上与双亲结点的值比较,如果孩子结点的值大于双亲结点的值就与双亲结点交换位置,直到调整到根节点或者小于双亲结点的值。如果要保持的是小根堆,则判断条件为a[child] < a[parent]
删除堆顶元素
删除堆顶元素,如果直接删除下标为0的元素,会使整个二叉树的结构全部发生变化,
所以这里要先把堆顶的元素和最后一个位置的元素先互调位置,然后再删除数组最后一个位置的元素(即完成了删除堆顶元素),此时根节点的左右子树也依然保持大根堆的性质,只需利用向下调整算法,使整个二叉树保持大根堆的性质。
void HeapPop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
Swap(&hp->a[0], &hp->a[hp->size - 1]);//堆顶元素和数组最后一个元素交换位置
hp->size--;//删除数组中最后一个元素
AdjustDown(hp->a, hp->size, 0);
}
删除了堆顶元素,但此时二叉树不一定符合大根堆的性质,利用向下调整算法调整二叉树
void AdjustDown(HPDataType* a, int size, int parent)
{
int child = parent * 2 + 1;//先利用下标算出根节点左孩子的下标
while (child < size)
{
`if (a[child + 1] > a[child])`//右孩子的下标为左孩子的+1,找出左右孩子中值最大的去和双亲结点的值比较
child = child + 1;
if (a[child] >a[parent])//如果孩子的值大于双亲结点的值,则交换位置
{
Swap(&a[parent], &a[child]);
parent = child;//迭代往下走
child = parent * 2 + 1;
}
else//符合大根堆的性质,跳出循环
{
break;
}
}
}
找出左右孩子中值相对大的结点,去和双亲结点的值进行比较,如果大于双亲结点的值,则需要交换两者的位置,继续迭代向下走,直到调整到最后一个位置或者跳出循环。这里比较左右孩子的值是先假设左孩子的值最大,利用下标算出左孩子的位置,记录为child,此时右孩子的位置即为左孩子的位置+1(child+1),比较二者的值,如果与假设相反,则更换child的值为右孩子的下标。
如果是要保持小根堆,则相应的条件变换为
if (a[child + 1] < a[child])//找出左右孩子中值较小的
if (a[child] <a[parent])//如果孩子的值小于双亲结点的值,则交换位置
void HeapInit(Heap* hp)//初始化堆
{
assert(hp);
hp->a = NULL;
hp->size = hp->capacity = 0;
}
void HeapDestroy(Heap* hp)//销毁堆
{
assert(hp);
free(hp->a);
hp->size = hp->capacity = 0;
}
HPDataType HeapTop(Heap* hp)//返回堆顶元素
{
assert(hp);
assert(hp->size > 0);
return hp->a[0];
}
bool HeapEmpty(Heap* hp)//判断是否为空
{
return hp->size == 0;
}
int HeapSize(Heap* hp)//返回堆中元素的个数
{
return hp->size;
}
void HeapPrint(Heap* hp)//打印堆中的元素
{
assert(hp);
for (int i = 0; i < hp->size; i++)
{
printf("%d ", hp->a[i]);
}
printf("\n");
}
测试堆的函数
void testHeap()
{
Heap hp;
HeapInit(&hp);
HPDataType a[] = { 2,4,5,19,23,59,30,17,18,40};
for (int i = 0; i < sizeof(a) / sizeof(int); i++)
{
HeapPush(&hp, a[i]);
}
HeapPrint(&hp);
HeapPop(&hp);
HeapPrint(&hp);
}
堆排序
给定一个数组,利用堆的性质去排序,该怎么做呢?首先要建一个堆出来,如果用之前写的堆的接口函数去建堆,效率会很低。实际上,利用向上调整算法或者向下调整算法就可以构造一个堆出来。
向上调整算法建堆
int i = 1;
for (i = 1; i < n; i++)//向上调整法建立堆
{
AdjustUp(a,i);
}
将数组中的首元素先作为根节点,然后依次往堆内插入其他元素,每插入一次,就要用向上调整算法调整堆,使堆符合大根堆的性质。
向下调整算法建堆
在写删除堆顶元素函数的时候,我们利用向下调整算法的前提是根结点的左右子树都符合大根堆的性质,因此由于叶子结点没有左右子树,不需要调整的,所以我们应该去找最后一个叶子结点的双亲结点,从这棵二叉树开始调整,依次向前遍历,构造堆。
int i = (n - 1 - 1) / 2;//计算倒数第一个非叶子结点的下标
for (i = (n - 1 - 1) / 2; i >=0; i--)//向下调整法建立堆
{
AdjustDown(a, n, i);
}
用这两种方法建堆都是可行的,如果要建小堆,只需修改向下调整算法或向上调整算法中相关的条件语句。
建堆时间复杂度
向下调整算法建堆
向上调整算法建堆
建堆的时间复杂度(最坏)为每一层的结点个数×要移动的层数,通过比较可得,向下调整建堆的方式较优,我们在堆排序时,也通常选用向下调整法建堆。
建大堆or建小堆
学习了如何建堆,我们就要考虑建哪种堆,假设要求排升序,先考虑建小堆的方式,建好堆后,先取堆顶元素,可以得到最小值,接下来怎么取次小的呢?因为取出堆顶元素,此时堆的关系全乱,要得到次小的数,就要对剩下n-1个数重新建小堆,取堆顶的数,总的时间复杂度为 O(n^2)。
再来考虑建大堆的方式,建大堆,取堆顶的数据,可以得到最大的数,因为是排升序,可以把堆顶的数和最后一个数交换位置,选出了最大的数,也可以保证根的左右子树依旧是大堆(除去最后一个元素),此时对剩下(n-1)个数进行向下调整,使整棵树保持大堆的性质,选出次大的数。每进行一次向下调整,最多调整高度次,时间复杂度为O(logn)。所以建大堆的方式进行堆排序,时间复杂度为O(n*logn)。很明显优于建小堆。因此我们得出结论
排升序,建大堆
排降序,建小堆
int end = n - 1;//end为最后一个元素的下标
while (end > 0)
{
Swap(&a[0], &a[end]);//交换首尾元素的位置
AdjustDown(a, end, 0);//开始向下调整
end--;
}
void HeapSort(int *a,int n)
{
int i = (n - 1 - 1) / 2;
for (i = (n - 1 - 1) / 2; i >=0; i--)//向下调整法建立堆
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)//进行堆排序
{
Swap(&a[0], &a[end]);//交换首尾元素的位置
AdjustDown(a, end, 0);
end--;
}
for (i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
}
Top-K问题
给定N个数据,找出最大的(或最小的)前k个。这种问题在生活中很常见,例如专业排名前10,世界500强,富豪榜等等。这类问题该怎么解决呢?
①直接进行堆排序,时间复杂度为O(N×logN)
②建N个数的大堆,Top,Pop K次,时间复杂度为O(N+logN×K)
当N非常大,K比较小的时候,前两种方法显然不是最优解。我们来看看第三种方法
前K个数建小堆
剩下N-K个数和堆顶数据比较,如果比堆顶数据大,则替换堆顶数据进堆(并要保持小堆的结构),最后在堆内剩下的便是最大的前K个。
看到这里,相信大家也会有些疑问
为什么不是建大堆呢?
如果建大堆的时候,N个数中最大的数也进堆了,会导致最大的这个数跑到堆顶,此时如果剩下N-K个数中还有我们要找的数,这些数就无法进堆。
建小堆为什么一定行?
建小堆,会使相对较大的数跑到堆底,相对较小的数在堆顶,如果N-K个数中还有我们要找的数,这些数一定会比堆顶数大,就一定会进堆。
时间复杂度 O(K+(N-K)*logK)
代码实现
void PrintTopK(int*a,int n,int k)
{
int* Kminheap = (int*)malloc(sizeof(int) * k);
assert(Kminheap);
for (int i = 0; i < k; i++)//前K个元素赋值到Kminheap数组中
{
Kminheap[i] = a[i];
}
for (int j = (k- 1 - 1) / 2; j >= 0; j--)//向下调整法建小堆
{
AdjustDown(Kminheap, k, j);
}
for (int i = k; i < n; i++)//后n-k个元素依次比较,比堆顶元素大就入堆
{
if (a[i] > Kminheap[0])
{
Kminheap[0] = a[i];
AdjustDown(Kminheap, k, 0);//调整堆,符合小堆的结构
}
}
for (int i = 0; i < k; i++)
{
printf("%d ", Kminheap[i]);
}
printf("\n");
}
二叉树的链式结构是指用链表来表示一棵二叉树。二叉树的每个结点最多有两个孩子,所以一般为它设计一个数据域和两个指针域。我们将这样的链表称为二叉链表。当然,在一些高阶的数据结构如红黑树等,我们会再增加一个指向其双亲的指针域,称之为三叉链表。
typedef struct BTNode//二叉链表
{
struct BTNode* left;//左孩子指针
struct BTNode* right;//右孩子指针
BTDataType data;//数据域
}BTNode;
typedef struct BTNode//三叉链表
{
struct BTNode* parent;//双亲指针
struct BTNode* left;//左孩子指针
struct BTNode* right;//右孩子指针
BTDataType data;//数据域
}BTNode;
二叉树的遍历是指从根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次,且仅被访问一次。
遍历方法 | 规则 |
---|---|
前序遍历 | 先访问根节点,然后访问左结点,最后访问右结点 |
中序遍历 | 先访问左结点,然后访问根节点,最后访问右结点 |
后序遍历 | 先访问左结点,然后访问右结点,最后访问根节点 |
层序遍历 | 从树的第一层(根结点)逐层遍历 |
要搞明白二叉树的每一个遍历方式,我们得先创建一棵二叉树,这里便手动快速创建一棵二叉树(并不是二叉树真正的创建方式)
BTNode* BuyNode(BTDataType x)
{
BTNode* node = (BTNode*)malloc(sizeof(BTNode));
node->data = x;
node->left = NULL;
node->right = NULL;
return node;
}
BTNode* CreatBinaryTree()
{
BTNode* node1 = BuyNode('A');
BTNode* node2 = BuyNode('B');
BTNode* node3 = BuyNode('C');
BTNode* node4 = BuyNode('D');
BTNode* node5= BuyNode('E');
BTNode* node6= BuyNode('F');
BTNode* node7 = BuyNode('G');
node1->left = node2;
node1->right = node3;
node2->left = node4;
node3->left = node5;
node3->right = node6;
node5->left = node7;
return node1;
}
二叉树创建好后,我们来看看二叉树遍历的代码实现
前序遍历
void PreOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");//为空的结点也要打印出来,才是真正的遍历顺序
return;
}
printf("%c ", root->data);//先访问根节点
PreOrder(root->left);//递归遍历左子树
PreOrder(root->right);//递归遍历右子树
}
中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
InOrder(root->left);//先访问左子树
printf("%c ", root->data);//再访问根结点
InOrder(root->right);//最后访问右子树
}
后序遍历
void PostOrder(BTNode* root)
{
if (root == NULL)
{
printf("# ");
return;
}
PostOrder(root->left);//先访问左子树
PostOrder(root->right);//再访问右子树
printf("%c ", root->data);//最后访问根结点
}
前序,中序,后序遍历的核心思想都是递归调用。只不过打印结点的时机不同,所以打印出结点的顺序不同。
层序遍历
层序遍历是逐层访问,上一层遍历完才会遍历下一层。我们可以借助队列先进先出的性质,完成层序遍历。
void LevelOrder(BTNode* root)
{
Queue pq;
QueueInit(&pq);
if (root)//根节点不为空,先将根节点入队
{
QueuePush(&pq, root);
}
while (!QueueEmpty(&pq))
{
BTNode* front = QueueFront(&pq);//获取队头元素,并出队
printf("%c ", front->data);
QueuePop(&pq);
if (front->left)//左结点不为空,入队
QueuePush(&pq, front->left);
if (front->right)//右结点不为空,入队
QueuePush(&pq, front->right);
}
printf("\n");
QueueDestroy(&pq);
}
上图二叉树的入队出队顺序为 A入队,A出队,B,C入队,B出队,D入队,C出队,E,F入队,D出队,E出队,G入队,F出队,G出队
即每一次根节点出队,它的左右孩子结点如果不为空,便入队。
打印结果
二叉树练习的核心思想:分治思想,分而治之,将大问题划分为子问题,子问题又划分为更小的子问题。下面的几个练习我们会更加深刻的体会到分治思想在二叉树中的应用。
二叉树中结点的个数
int TreeSize(BTNode* root)
{
if (root == NULL)
return 0;
return TreeSize(root->left) + TreeSize(root->right) + 1;
}
根节点不为空的前提下,转换为左子树的结点个数+右子树的结点个数+1
二叉树中叶子结点的个数
int TreeLeafSize(BTNode* root)
{
if (root == NULL)//为空
return 0;
if (root->left == NULL && root->right == NULL)//为叶子结点
return 1;
return TreeLeafSize(root->left) + TreeLeafSize(root->right);
}
每一棵树的叶子结点都可以转换为求其左子树的叶子结点+右子树的叶子结点
求A树的叶子结点个数可以转换为求A的左右子树的叶子结点个数,左子树结点B的叶子结点个数可以转换为求B的左右子树的结点个数;右子树结点C的叶子结点个数转换为求C的左右子树的叶子结点个数……一直递归下去,直到遇到叶子结点,返回1
求任意层的结点个数
int TreeKLevel(BTNode* root, int k)
{
if (root == NULL)
return 0;
if (k == 1)
return 1;
return TreeKLevel(root->left, k - 1) + TreeKLevel(root->right, k - 1);
}
求A的第K层结点个数可以转换为求B的第K-1层结点个数+C的K-1层结点个数
B的k-1层结点个数转换为求B的左右子树的第K-2层结点个数
C的k-1层结点个数转换为求B的左右子树的第K-2层结点个数
……依次往下递归
求二叉树的深度
int TreeDepth(BTNode* root)
{
if (root == NULL)
return 0;
int LeftTreeDepth = TreeDepth(root->left);
int RightTreeDepth = TreeDepth(root->right);
if (LeftTreeDepth > RightTreeDepth)
return LeftTreeDepth+1;
else
return RightTreeDepth+1;
}
求二叉树的深度可以转换为求其左右子树中深度较大的加1
二叉树中查找值
BTNode* TreeFind(BTNode* root,int x)
{
if (root == NULL)
return NULL;
if (root->data == x)//在根结点找到了
return root;
BTNode* LeftTree = TreeFind(root->left, x);//递归在左子树查找
if (LeftTree)//在左子树找到了
return LeftTree;
BTNode*RightTree= TreeFind(root->right, x);//递归在右子树查找
if (RightTree)//在右子树找到了
return RightTree;
return NULL;//未找到
}
先在根结点找,如果没找到去左子树找,左子树没找到就去右子树找,如果都没找到,返回NULL
判断是否为完全二叉树
我们先回想完全二叉树的性质,前n-1层是满的,第n层可以不满,但必须连续。也就是说,如果最后一层不满,当遇到空,后面的结点都为空,那么我们可以利用层序遍历的特点,当遍历遇到空时,以后的结点都只能为空,遇到不是空的结点,则不是完全二叉树
int TreeComplete(BTNode* root)
{
Queue pq;
QueueInit(&pq);
if (root)
{
QueuePush(&pq, root);
}
while (!QueueEmpty(&pq))
{
BTNode* front = QueueFront(&pq);
QueuePop(&pq);
if (front)//队头元素不为空,就把当前结点的左右孩子结点入队
{
QueuePush(&pq, front->left);
QueuePush(&pq, front->right);
}
else//遇到空,跳出循环
break;
}
while (!QueueEmpty(&pq))
{
BTNode* front = QueueFront(&pq);
QueuePop(&pq);
if (front)//在队列不为空的前提下,如过遇到不是空的结点,则不是完全二叉树
{
QueueDestroy(&pq);
return false;
}
}
QueueDestroy(&pq);
return true;//遍历完剩下的结点,都是空,则是完全二叉树
}
销毁二叉树
void DestroyTree(BTNode* root)
{
if (root == NULL)
return;
DestroyTree(root->left);
DestroyTree(root->right);
printf("%c ", root->data);
free(root);
}
如果先释放根节点,会导致找不到左右子树,所以这里采用后序遍历的方式,最后释放根节点。