1. 树
1.1 树的概念与结构
树是⼀种⾮线性的数据结构,它是由
n
(
n>=0
) 个有限结点组成⼀个具有层次关系的集合。把它
叫做 树是因为它看起来像⼀棵倒挂的树,也就是说它是根朝上,⽽叶朝下的。
•
有⼀个特殊的结点,称为根结点,根结点没有前驱结点。
•
除根结点外,其余结点被分成
M(M>0)
个互不相交的集合
T1
、
T2
、
……
、
Tm
,其中每⼀个集合
Ti(1 <= i <= m)
⼜是⼀棵结构与树类似的⼦树。每棵⼦树的根结点有且只有⼀个前驱,可以有 0
个或多个后继。因此,树是递归定义的。
树形结构中,⼦树之间不能有交集,否则就不是树形结构
⾮树形结构:
•
⼦树是不相交的(如果存在相交就是图了,图以后得课程会有讲解)
•
除了根结点外,每个结点有且仅有⼀个⽗结点
•
⼀棵N个结点的树有N-1条边
1.2 树相关术语
⽗结点/双亲结点
:若⼀个结点含有⼦结点,则这个结点称为其⼦结点的⽗结点; 如上图:A是B 的⽗结点
⼦结点/孩⼦结点
:⼀个结点含有的⼦树的根结点称为该结点的⼦结点; 如上图:B是A的孩⼦结点
结点的度
:⼀个结点有⼏个孩⼦,他的度就是多少;⽐如A的度为6,F的度为2,K的度为0
树的度:⼀棵树中,最⼤的结点的度称为树的度; 如上图:树的度为
6
叶⼦结点/终端结点:度为
0
的结点称为叶结点; 如上图:
B
、
C
、
H
、
I...
等结点为叶结点
分⽀结点/⾮终端结点:度不为
0
的结点; 如上图:
D
、
E
、
F
、
G...
等结点为分⽀结点
兄弟结点:具有相同⽗结点的结点互称为兄弟结点(亲兄弟); 如上图:
B
、
C
是兄弟结点
结点的层次:从根开始定义起,根为第
1
层,根的⼦结点为第
2
层,以此类推;
树的⾼度或深度:树中结点的最⼤层次; 如上图:树的⾼度为
4
结点的祖先:从根到该结点所经分⽀上的所有结点;如上图:
A
是所有结点的祖先
路径:⼀条从树中任意节点出发,沿⽗节点-⼦节点连接,达到任意节点的序列;⽐如A到Q的路径 为: A-E-J-Q;H到Q的路径H-D-A-E-J-Q
⼦孙:以某结点为根的⼦树中任⼀结点都称为该结点的⼦孙。如上图:所有结点都是A的⼦孙
森林:由
m
(
m>0
) 棵互不相交的树的集合称为森林;
1.3 树的表⽰
孩⼦兄弟表⽰法:
树结构相对线性表就⽐较复杂了,要存储表⽰起来就⽐较⿇烦了,既然保存值域,也要保存结点和
结 点之间的关系,实际中树有很多种表⽰⽅式如:双亲表⽰法,孩⼦表⽰法、孩⼦双亲表⽰法以
及孩⼦ 兄弟表⽰法等。我们这⾥就简单的了解其中最常⽤的孩⼦兄弟表⽰法
struct TreeNode
{
struct Node* child; // 左边开始的第⼀个孩⼦结点
struct Node* brother; // 指向其右边的下⼀个兄弟结点
int data; // 结点中的数据域
};
1.4 树形结构实际运⽤场景
⽂件系统是计算机存储和管理⽂件的⼀种⽅式,它利⽤树形结构来组织和管理⽂件和⽂件夹。在⽂
件 系统中,树结构被⼴泛应⽤,它通过⽗结点和⼦结点之间的关系来表⽰不同层级的⽂件和⽂件
夹之间 的关联。
2. ⼆叉树
2.1 概念与结构
在树形结构中,我们最常⽤的就是⼆叉树,⼀棵⼆叉树是结点的⼀个有限集合,该集合由⼀个根结
点加上两棵别称为左⼦树和右⼦树的⼆叉树组成或者为空
从上图可以看出⼆叉树具备以下特点:
1.
⼆叉树不存在度⼤于
2
的结点
2.
⼆叉树的⼦树有左右之分,次序不能颠倒,因此⼆叉树是有序树
注意:对于任意的⼆叉树都是由以下⼏种情况复合⽽成的
现实中的⼆叉树
2.2 特殊的⼆叉树
2.2.1 满⼆叉树
⼀个⼆叉树,如果每⼀个层的结点数都达到最⼤值,则这个⼆叉树就是满⼆叉树。也就是说,如果
⼀ 个⼆叉树的层数为 K
,且结点总数是
2
k
− 1
,则它就是满⼆叉树。
2.2.2 完全⼆叉树
完全⼆叉树是效率很⾼的数据结构,完全⼆叉树是由满⼆叉树⽽引出来的。对于深度为
K
的,有
n
个结点的⼆叉树,当且仅当其每⼀个结点都与深度为K的满⼆叉树中编号从 1
⾄
n
的结点⼀⼀对
应时称之为完全⼆叉树。要注意的是满⼆叉树是⼀种特殊的完全⼆叉树.
💡 ⼆叉树性质根据满⼆叉树的特点可知:1)若规定根结点的层数为 1 ,则⼀棵⾮空⼆叉树的第i层上最多有 2 i −1 个结点2)若规定根结点的层数为 1 ,则深度为 h 的⼆叉树的最⼤结点数是 2 h − 13)若规定根结点的层数为 1 ,具有 n 个结点的满⼆叉树的深度 ( log以2为底, n+1 为对数)
2.3 ⼆叉树存储结构
2.3.1 顺序结构
顺序结构存储就是使⽤数组来存储,⼀般使⽤数组只适合表⽰完全⼆叉树,因为不是完全⼆叉树会
有空间的浪费,完全⼆叉树更适合使⽤顺序结构存储。
现实中我们通常把堆(⼀种⼆叉树)使⽤顺序结构的数组来存储,需要注意的是这⾥的堆和操作系
统 虚拟进程地址空间中的堆是两回事,⼀个是数据结构,⼀个是操作系统中管理内存的⼀块区域
分段。
2.3.2 链式结构
⼆叉树的链式存储结构是指,⽤链表来表⽰⼀棵⼆叉树,即⽤链来指⽰元素的逻辑关系。 通常的
⽅法 是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别⽤来给出该结点左孩
⼦和右孩 ⼦所在的链结点的存储地址 。链式结构⼜分为⼆叉链和三叉链,当前我们学习中⼀般都
是⼆叉链。后 ⾯课程学到⾼阶数据结构如红⿊树等会⽤到三叉链。
3. 实现顺序结构⼆叉树
⼀般堆使⽤顺序结构的数组来存储数据,堆是⼀种特殊的⼆叉树,具有⼆叉树的特性的同时,还具
备其他的特性。
3.1 堆的概念与结构
如果有⼀个关键码的集合 ,把它的所有元素按完全⼆叉树的顺序存储⽅ 式存储,在⼀个⼀维数组
中,并满⾜: ( 且 ), i = 0、
1
、
2...
,则称为⼩堆(或⼤堆)。将根结点最⼤的堆叫做最⼤堆或⼤
根堆,根结点最⼩的堆叫做最⼩堆或⼩根堆。
堆具有以下性质
•
堆中某个结点的值总是不⼤于或不⼩于其⽗结点的值;
•
堆总是⼀棵完全⼆叉树。
💡 ⼆叉树性质• 对于具有 n 个结点的完全⼆叉树,如果按照从上⾄下从左⾄右的数组顺序对所有结点从0 开始编号,则对于序号为 i 的结点有:1. 若 i>0 , i 位置结点的双亲序号: (i-1)/2 ; i=0 , i 为根结点编号,⽆双亲结点2. 若 2i+1<n ,左孩⼦序号: 2i+1 , 2i+1>=n 否则⽆左孩⼦3. 若 2i+2<n ,右孩⼦序号: 2i+2 , 2i+2>=n 否则⽆右孩⼦
3.2 堆的实现
堆底层结构为数组,因此定义堆的结构为:
Heap.h
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>
//结构体 堆 ---数组
typedef int HPDataType;
typedef struct Heap
{
HPDataType* arr;
int size;//有效数据个数
int capacity;//空间大小
}HP;
//初始化
void HPInit(HP* php);
//销毁
void HPDestroy(HP* php);
//向上调整
void AddjustUp(HPDataType* arr,int size);
//插入数据
void HPPush(HP* php, HPDataType x);
//删除数据
void HPPop(HP* php);
// 判空
bool HPEmpty(HP* php);
//打印堆顶数据
HPDataType HPTop(HP* php);
test.c
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
void test01()
{
HP hp;
HPInit(&hp);
int arr[] = { 15,13,10,20,17,19 };
for (int i = 0; i < 6; i++)
{
HPPush(&hp, arr[i]);
}
while (!HPEmpty(&hp))
{
printf("%d ", HPTop(&hp));
HPPop(&hp);
}
HPDestroy(&hp);
}
int main()
{
test01();
return 0;
}
Heap.c
初始化
void HPInit(HP* php) {
assert(php);
php->arr = NULL;
php->size = php->capacity = 0;
}
插入数据
//交换
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y= tmp;
}
//向上调整
void AddjustUp(HPDataType* arr, int size)
{
int parent = (size - 1) / 2;
while (size>=0)
{
if (arr[size] < arr[parent])
{
Swap(&arr[size], &arr[parent]);
size = parent;
parent = (size - 1) / 2;
}
else
{
break;
}
}
}
void HPPush(HP* php, HPDataType x)
{
assert(php);
if (php->capacity == php->size)
{
int newcapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
HPDataType* tmp = (HP*)realloc(php->arr, newcapacity * sizeof(HPDataType));
if (tmp == NULL)
{
perror("realloc fail!");
exit(1);
}
php->arr = tmp;
php->capacity = newcapacity;
}
php->arr[php->size] = x;
AddjustUp(php->arr, php->size);
++php->size;
}
删除数据
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y= tmp;
}
void AddjustDown(HPDataType* arr, int parent, int n)
{
int child = parent * 2 + 1;
while (child<n)
{
if (child+1<n && arr[child] > arr[child + 1])
{
child++;
}
if (arr[child] < arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HPPop(HP* php)
{
assert(php && php->size);
Swap(&php->arr[0], &php->arr[php->size - 1]);
--php->size;
AddjustDown(php->arr, 0, php->size);
}
判空
bool HPEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
打印堆顶数据
HPDataType HPTop(HP* php)
{
assert(php && php->size);
return php->arr[0];
}
3.2.1 向上调整算法
堆的插⼊
将新数据插⼊到数组的尾上,再进⾏向上调整算法,直到满⾜堆。
💡 向上调整算法• 先将元素插⼊到堆的末尾,即最后⼀个孩⼦之后• 插⼊之后如果堆的性质遭到破坏,将新插⼊结点顺着其双双亲往上调整到合适位置即可
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 = (parent - 1) / 2;
}
else
{
break;
}
}
}
void HPPush(HP* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = realloc(php->a, sizeof(HPDataType) * newCapacity);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
php->a = tmp;
php->capacity = newCapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size-1);
}
计算向上调整算法建堆时间复杂度
因为堆是完全⼆叉树,⽽满⼆叉树也是完全⼆叉树,此处为了简化使⽤满⼆叉树来证明(时间复杂度本来看的就是近似值,多⼏个结点不影响最终结果)
分析:
第1层,
2
0
个结点,需要向上移动0层
第2层,
2
1
个结点,需要向上移动1层
第3层,
2
2
个结点,需要向上移动2层
第4层,
2
3
个结点,需要向上移动3层
......
第h层,
2
h
−1
个结点,需要向上移动h-1层
则需要移动结点总的移动步数为:每层结点个数 * 向上调整次数(第⼀层调整次数为0)
T
(
h
) = 2
1
∗ 1 + 2
2
∗ 2 + 2
3
∗ 3 + .. + 2
h
−2
∗ (
h
− 2) + 2
h
−1
∗ (
h
− 1)
①
2 ∗
T
(
h
) = 2
2
∗ 1 + 2
3
∗ 2 + 2
4
∗ 3 + .. + 2
h
−1
∗ (
h
− 2) + 2
h
∗ (
h
− 1)
②
② ⼀ ① 错位相减:
T
(
h
) = −2
1
∗ 1 − (2
2
+ 2
3
+ .. + 2
h
−2
+ 2
h
−1
) + 2
h
∗ (
h
− 1)
T
(
h
) = −2
0
− 2
1
∗ 1 − (2
2
+ 2
3
+ .. + 2
h
−2
+ 2
h
−1
) + 2
h
∗ (
h
− 1) + 2
0
T
(
h
) = −(2
0
+ 2
1
∗ 1 + 2
2
+ 2
3
+ .. + 2
h
−2
+ 2
h
−1
) + 2
h
∗ (
h
− 1) + 2
0
T
(
h
) = −(2
h
− 1) + 2
h
∗ (
h
− 1) + 2
0
T
(
h
) = −(2
h
− 1) + 2
h
∗ (
h
− 1) + 2
0
根据⼆叉树的性质:
n
= 2
h
− 1
和
h
=
log
2
(
n
+ 1)
T
(
n
) = −
N
+ 2
h
∗ (
h
− 1) + 2
0
F
(
h
) = 2
h
(
h
− 2) + 2
F
(
n
) = (
n
+ 1)(log
2
(
n
+ 1) − 2) + 2
由此可得:
💡 向上调整算法建堆时间复杂度为: O ( n ∗ log 2 n )
3.2.2 向下调整算法
堆的删除
删除堆是删除堆顶的数据,将堆顶的数据根最后⼀个数据⼀换,然后删除数组最后⼀个数据,再进⾏向下调整算法。
向下调整算法有⼀个前提:左右⼦树必须是⼀个堆,才能调整。
💡 向下调整算法• 将堆顶元素与堆中最后⼀个元素进⾏交换• 删除堆中最后⼀个元素• 将堆顶元素向下调整到满⾜堆特性为⽌
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
// 假设法,选出左右孩⼦中⼩的那个孩⼦
if (child + 1 < n && a[child + 1] > a[child])
{
++child;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HPPop(HP* php)
{
assert(php);
assert(php->size > 0);
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
计算向下调整算法建堆时间复杂度
分析:
第1层,
2
0
个结点,需要向下移动h-1层
第2层,
2
1
个结点,需要向下移动h-2层
第3层,
2
2
个结点,需要向下移动h-3层
第4层,
2
3
个结点,需要向下移动h-4层
......
第h-1层,
2
h
−2
个结点,需要向下移动1层
则需要移动结点总的移动步数为:每层结点个数 * 向下调整次数
①
T
(
h
) = 2
0
∗ (
h
− 1) + 2
1
∗ (
h
− 2) + 2
2
∗ (
h
− 3) + 2
3
∗ (
h
− 4) + .. + 2
h
−3
∗ 2 + 2
h
−2
∗ 1
②
2 ∗
T
(
h
) = 2
1
∗ (
h
− 1) + 2
2
∗ (
h
− 2) + 2
3
∗ (
h
− 3) + 2
4
∗ (
h
− 4) + ... + 2
h
−2
∗ 2 + 2
h
−1
∗ 1
② ⼀ ① 错位相减:
T
(
h
) = 1 −
h
+ 2
1
+ 2
2
+ 2
3
+ 2
4
+ .. + 2
h
−2
+ 2
h
−1
T
(
h
) = 2
0
+ 2
1
+ 2
2
+ 2
3
+ 2
4
+ . + 2
h
−2
+ 2
h
−1
−
h
T
(
h
) = 2
h
− 1 −
h
根据⼆叉树的性质:
n
= 2
h
− 1
和
h
=
log
2
(
n
+ 1)
T
(
n
) =
n
−
log
2
(
n
+ 1) ≈
n
💡 向下调整算法建堆时间复杂度为: O ( n )
3.3 堆的应⽤
3.3.1 堆排序
版本⼀:基于已有数组建堆、取堆顶元素完成排序版本
// 1、需要堆的数据结构
// 2、空间复杂度 O(N)
void HeapSort(int* a, int n)
{
HP hp;
for(int i = 0; i < n; i++)
{
HPPush(&hp,a[i]);
}
int i = 0;
while (!HPEmpty(&hp))
{
a[i++] = HPTop(&hp);
HPPop(&hp);
}
HPDestroy(&hp);
}
该版本有⼀个前提,必须提供有现成的数据结构堆
版本⼆:数组建堆,⾸尾交换,交换后的堆尾数据从堆中删掉,将堆顶数据向下调整选出次⼤的数据
// 升序,建⼤堆
// 降序,建⼩堆
// O(N*logN)
void HeapSort(int* a, int n)
{
// a数组直接建堆 O(N)
for (int i = (n-1-1)/2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
// O(N*logN)
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
堆排序时间复杂度计算
分析:
第1层, 个结点,交换到根结点后,需要向下
移动0层
2
0
第2层, 个结点,交换到根结点后,需要向下
移动1层
2
1
第3层, 个结点,交换到根结点后,需要向下
移动2层
2
2
第4层, 个结点,交换到根结点后,需要向下
移动3层
2
3
......
第h层, 个结点,交换到根结点后,需要向
下移动h-1层
通过分析发现,堆排序第⼆个循环中的向下调整与建堆中的向上调整算法时间复杂度计算⼀致,此处不再赘述。因此,堆排序的时间复杂度为 O
(
n
+
n
∗ log
n
)
,即
O
(
n
log
n
)
💡 堆排序时间复杂度为: O ( n log n )
3.3.2 TOP-K问题
TOP-K问题:即求数据结合中前K个最⼤的元素或者最⼩的元素,⼀般情况下数据量都⽐较⼤。
⽐如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的⽅式就是排序,但是:如果数据量⾮常⼤,排序就不太可取了
(可能数据都不能⼀下⼦全部加载到内存中)。最佳的⽅式就是⽤堆来解决,基本思路如下:
1)⽤数据集合中前K个元素来建堆
前k个最⼤的元素,则建⼩堆
前k个最⼩的元素,则建⼤堆
2)⽤剩余的N-K个元素依次与堆顶元素来⽐较,不满⾜则替换堆顶元素
将剩余N-K个元素依次与堆顶元素⽐完之后,堆中剩余的K个元素就是所求的前K个最⼩或者最⼤的元素
void CreateNDate()
{
// 造数据
int n = 100000;
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (int i = 0; i < n; ++i)
{
int x = (rand() + i) % 1000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
void topk()
{
printf("请输⼊k:>");
int k = 0;
scanf("%d", &k);
const char* file = "data.txt";
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
int val = 0;
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
perror("malloc error");
return;
}
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &minheap[i]);
}
// 建k个数据的⼩堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(minheap, k, i);
}
int x = 0;
while (fscanf(fout, "%d", &x) != EOF)
{
// 读取剩余数据,⽐堆顶的值⼤,就替换他进堆
if (x > minheap[0])
{
minheap[0] = x;
AdjustDown(minheap, k, 0);
}
}
for (int i = 0; i < k; i++)
{
printf("%d ", minheap[i]);
}
fclose(fout);
}
时间复杂度:
O
(
n
) =
k
+ (
n
−
k
)log
2
k