数据结构之二叉树

1. 树的基本概念

在提及二叉树之前,我们需要先了解树的基本概念。

1.1 定义

树是一种非线性 的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的

树的第一个节点称为根节点,其没有前驱节点。

除根节点外,其余节点被分成M个互不相交的集合,每一个集合是与树结构基本相同的子树。因此树是递归定义的。

1.2 相关性质

结点的度:一个结点含有的子树的个数称为该结点的度; 如上图:A的为6

叶结点或终端结点:度为0的结点称为叶结点; 如上图:B、C、H、I...等结点为叶结点

非终端结点或分支结点:度不为0的结点; 如上图:D、E、F、G...等结点为分支结点

双亲结点或父结点:若一个结点含有子结点,则这个结点称为其子结点的父结点; 如上图:A是B的父结点

孩子结点或子结点:一个结点含有的子树的根结点称为该结点的子结点; 如上图:B是A的孩子结点

兄弟结点:具有相同父结点的结点互称为兄弟结点; 如上图:B、C是兄弟结点

树的度:一棵树中,最大的结点的度称为树的度; 如上图:树的度为6

结点的层次:从根开始定义起,根为第1层,根的子结点为第2层,以此类推;

树的高度或深度:树中结点的最大层次; 如上图:树的高度为4

堂兄弟结点:双亲在同一层的结点互为堂兄弟;如上图:H、I互为兄弟结点

结点的祖先:从根到该结点所经分支上的所有结点;如上图:A是所有结点的祖先

子孙:以某结点为根的子树中任一结点都称为该结点的子孙。如上图:所有结点都是A的子孙

森林:由m(m>0)棵互不相交的树的集合称为森林;

1.3 树的表示方法

树的表示方法有很多种,比如双亲表示法,孩子双亲表示法以及孩子兄弟表示法等,最常用的是孩子兄弟表示法:

c 复制代码
  typedef int DataType;
struct Node
//左孩子,右兄弟
{
 struct Node* firstChild1;    // 第一个孩子结点
 struct Node* pNextBrother;   // 指向其下一个兄弟结点
 DataType data;               // 结点中的数据域
};

2. 二叉树

2.1 基本概念

一颗二叉树是度最大为2的树,空树也是二叉树。

从上图可以看出:

  1. 二叉树不存在度大于2的结点
  2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树

每颗二叉树都有一下几种情况复合而成:

2.2 特殊的二叉树

2.2.1 满二叉树

一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是 2 k − 1 2^k -1 2k−1 ,则它就是满二叉树。

2.2.2 完全二叉树

完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树。

2.3 二叉树的性质

  1. 若规定根结点的层数为1,则一棵非空二叉树的第i层上最多有 2 ( i − 1 ) 2^{(i-1)} 2(i−1) 个结点.
  2. 若规定根结点的层数为1,则深度为h的二叉树的最大结点数是 2 h − 1 2^h-1 2h−1.
  3. 对任何一棵二叉树, 如果度为0其叶结点个数为 n 0 n_0 n0, 度为2的分支结点个数为 n 2 n_2 n2,则有 n 0 n_0 n0= n 2 n_2 n2+1
  4. 若规定根结点的层数为1,具有n个结点的满二叉树的深度h= l o g 2 ( n + 1 ) log_2(n+1) log2(n+1) . (ps: l o g 2 ( n + 1 ) log_2(n+1) log2(n+1)是log以2为底,n+1为对数)
  5. 对于具有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否则无右孩子

2.4 二叉树的存储结构

2.4.1 顺序存储

顺序存储指用数组来存储二叉树,一般只适合表示完全二叉树,如果是非完全二叉树使用数组进行存储则效率不高。其在逻辑结构上是一颗二叉树,物理结构上是数组。

2.4.2 链式存储

链式存储指用链表来表示二叉树,即用链元素来表示逻辑关系。链式结构一般有二叉链和三叉链

c 复制代码
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
    struct BinTreeNode* left;   // 指向当前结点左孩子
    struct BinTreeNode* right;  // 指向当前结点右孩子
    BTDataType data;            // 当前结点值域
}

// 三叉链
struct BinaryTreeNode
{
    struct BinTreeNode* parent; // 指向当前结点的双亲
    struct BinTreeNode* left;   // 指向当前结点左孩子
    struct BinTreeNode* right;  // 指向当前结点右孩子
    BTDataType data;            // 当前结点值域
};

2.4 二叉树的顺序结构及实现

现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。

2.4.1 堆的概念及结构

如果有一个关键码的集合K = { k 0 k_0 k0, k 1 k_1 k1, k 2 k_2 k2,..., k n − 1 k_{n-1} kn−1},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: K i K_i Ki <= K 2 ∗ i + 1 K_{2*i+1} K2∗i+1 且 K i K_i Ki<= K 2 ∗ i + 2 K_{2*i+2} K2∗i+2 ( K i K_i Ki >= K 2 ∗ i + 1 K_{2*i+1} K2∗i+1 且 K i K_i Ki >= K 2 ∗ i + 2 K_{2*i+2} K2∗i+2) i = 0,1,2...,则称为小堆(或大堆)。将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。

堆的性质:

  • 堆中某个结点的值总是不大于或不小于其父结点的值;
  • 堆总是一棵完全二叉树。

2.4.2 堆的实现

  1. 向下调整算法
    向下调整算法有一个前提:左右子树必须都是堆。
    例子:

int array[] = {27,15,19,18,28,34,65,49,25,37};

c 复制代码
void adjust_down(hp_data_type* a, int n, int parent)
{
	assert(a);
	int child = parent * 2 + 1;

	while (child < n) //当孩子节点下标大于等于最后数组最后元素下标时,就不用继续比下去
	{
		if ((child + 1 < n) && a[child] < a[child + 1])//左孩子+1就是右孩子, 为什么child+1<n,而不是<=,因为n为元素个数
		{
			child++;
		}
		if (a[child] > a[parent]) //建大堆还是小堆通过符号来更改
		{
			swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

2.4.3 建堆的时间复杂度

因为堆是完全二叉树,而满二叉树也是完全二叉树,所以这里用满二叉树来举例子:

2.4.4 堆的插入

c 复制代码
void adjust_up(hp_data_type* a, int child) //向上调整,子节点大于父节点,就互换
{
	int parent = (child - 1) / 2;
	while (child > 0) //当子节点循环到根时就停止
	{
		if (a[parent] < a[child])
		{
			swap(&a[child], &a[parent]);
			//hp_data_type temp = a[parent];
			//a[parent] = a[child];
			//a[child] = temp;
			child = parent;
			parent = (child - 1) / 2;
		}
		else
			break;
	}
}

记住:向下调整建堆(在无序的数组上调整),向上调整插入(建立堆时可以每插入一次调整一次)。

2.4.4 堆的删除

删除堆是删除堆顶的数据,将堆顶的数据根最后一个数据一换,然后删除数组最后一个数据,再进行向下调整算法

2.4.5 堆的功能实现

heap.h

c 复制代码
#pragma once
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <stdbool.h>
#include <time.h>

#define INIT_CAP 4
#define INC_CAP 2

typedef int hp_data_type;

//大堆
typedef struct heap
{
	hp_data_type* a;
	int size; //总指向最后一个元素的下一位
	int capacity;
}hp;

void hp_init(hp* php);

//前提:左右子树都是大堆或小堆
void hp_push(hp* php, hp_data_type x); //思路:向上调整,让子节点与其父节点相比,层层往上交换,直到满足条件
void hp_pop(hp* php); //思路:第一个和最后一个元素互换位置,让size--,然后将调整后的第一个元素往下调整,直到满足条件
//尾插头删

hp_data_type hp_top(hp* php);
bool hp_empty(hp* php);
int hp_size(hp* php);
void hp_destroy(hp* php);

void adjust_up(hp_data_type* a, int child);
void adjust_down(hp_data_type* a, int n, int parent); //n为数据个数,parent为父节点下标
void swap(hp_data_type* a, hp_data_type* b);

heap.c

c 复制代码
#include"heap.h"

void swap(hp_data_type* a, hp_data_type* b)
{
	hp_data_type temp = *a;
	*a = *b;
	*b = temp;
}

void adjust_up(hp_data_type* a, int child) //向上调整,子节点大于父节点,就互换
{
	int parent = (child - 1) / 2;
	while (child > 0) //当子节点循环到根时就停止
	{
		if (a[parent] < a[child])
		{
			swap(&a[child], &a[parent]);
			//hp_data_type temp = a[parent];
			//a[parent] = a[child];
			//a[child] = temp;
			child = parent;
			parent = (child - 1) / 2;
		}
		else
			break;
	}
}

void adjust_down(hp_data_type* a, int n, int parent)
{
	assert(a);
	int child = parent * 2 + 1;

	while (child < n) //当孩子节点下标大于等于最后数组最后元素下标时,就不用继续比下去
	{
		if ((child + 1 < n) && a[child] < a[child + 1])//左孩子+1就是右孩子, 为什么child+1<n,而不是<=,因为n为元素个数
		{
			child++;
		}
		if (a[child] > a[parent]) //建大堆还是小堆通过符号来更改
		{
			swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

static void contain_expand(hp* php)
{
	assert(php);
	if (php->capacity == php->size)
	{
		hp_data_type* temp = (hp_data_type*)realloc(php->a, sizeof(hp_data_type) * (php->capacity + INC_CAP));
		if (temp == NULL)
		{
			perror("realloc fail.");
			return;
		}
		php->a = temp;
		php->capacity += INC_CAP;
	}
}

void hp_init(hp* php)
{
	assert(php);
	php->a = (hp_data_type*)malloc(sizeof(hp_data_type) * INIT_CAP);
	if (php->a == NULL)
	{
		perror("malloc fail.");
		return;
	}
	php->capacity = INIT_CAP;
	php->size = 0;
}

void hp_push(hp* php, hp_data_type x)
{
	assert(php);
	contain_expand(php);
	php->a[php->size] = x;
	adjust_up(php->a, php->size);
	php->size++;
}

void hp_pop(hp* php)
{
	assert(php);
	assert(!hp_empty(php));
	swap(&php->a[0], &php->a[php->size - 1]); //第一个和最后一个先交换
	adjust_down(php->a, --php->size, 0); //再向下调整,同时size往前移
}

hp_data_type hp_top(hp* php)
{
	assert(php);
	return php->a[0];
}

bool hp_empty(hp* php)
{
	assert(php);
	if (php->size == 0)
		return true;
	else
		return false;
}

int hp_size(hp* php)
{
	assert(php);
	return php->size;
}

void hp_destroy(hp* php)
{
	assert(php);
	free(php->a);
}

2.4.6 堆的应用

  1. 堆排序

堆排序即利用堆的思想来进行排序,总共分为两个步骤:

  1. 建堆
    • 升序:建大堆
    • 降序:建小堆
c 复制代码
//排升序,用大堆
void heap_sort(int* a, int n) //n为元素个数, 堆排序,整个复杂度O(N*logN)
{
	//注意不同建堆方法的开始调整位置
	//法一:向上调整建堆:复杂度为O(N*logN)
	//for (int i = 1; i < n; i++) //头元素不用调,从第二个元素开始调,将第二个元素设为孩子
	//{
	//	adjust_up(a, i); 
	//}
	//法二:向下调整建堆,从最后一个子树开始调,一直调到第一个子树,复杂度为O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--) //找最后一个叶节点的父节点
	{
		adjust_down(a, n, i);
	}

	int end = n - 1;
	while (end > 0) //开始排序,复杂度:O(N*logN)
	{
		swap(&a[0], &a[end]); //第一个数一定是最大的数,第一个和最后一个调换
		adjust_down(a, end, 0); //调整堆,堆的元素个数少了1个(已经往前挪了1个)
		end--; //使最后节点往前挪
	}
}
  1. topk问题

TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大

比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。

对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:

  1. 用数据集合中前K个元素来建堆
    • 前k个最大的元素,则建小堆
    • 前k个最小的元素,则建大堆
  2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素

将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。

c 复制代码
void create_data(int n) //创建数据
{
	srand(time(NULL));
	const char* name = "data.txt";
	FILE* fin = fopen(name, "w");
	if (fin == NULL)
	{
		perror("fopen fail");
		return;
	}
	while (n--)
	{
		fprintf(fin, "%d\n", rand()%10000); //将小于10000的数存进文件中
	}
	fclose(fin);
}

void top_k(int n, int top) //找出文件中最大的top个数, n为数据个数
//思路:建小堆,当元素比小堆堆顶的数大的时候,就进入堆
{
	const char* name = "data.txt";
	FILE* fout = fopen(name, "r");
	if (fout == NULL)
	{
		perror("fopen fail");
		return;
	}

	int* a = (int*)malloc(sizeof(int) * top);

	//先放前top个元素进去,建成小堆
	for (int i = 0; i < top; i++)
	{
		fscanf(fout, "%d", &a[i]);
	}
	for (int i = (top - 1 - 1) / 2; i >= 0; i--) 
	{
		adjust_down_low_heap(a, top, i); //这是小堆
	}
	
	//数据比较,入堆
	int val = 0;
	for (int i = top; i < n; i++)
	{
		fscanf(fout,"%d", &val); //从文件中读取数据并写入val,因为上面已经读取过,光标已经移动到第top+1个数据的位置
		if (val > a[0])
		{
			a[0] = val;
			adjust_down_low_heap(a, top, 0); //堆里面根是最小的数,只要比根大,就进数据并向下调整小堆
		}
	}

	//进行堆排序(升序)
	heap_sort(a, top); //将小堆改成大堆进行升序排序

	//打印前top个
	for (int i = 0; i < top; i++)
	{
		printf("%d ", a[i]);
	}
	printf("\n");

	free(a);
	fclose(fout);
}

int main()
{
	//test_1();
	create_data(1000);
	top_k(1000, 20);

	return 0;
}

结束语:本章内容较多,需要仔细琢磨。

相关推荐
真的想上岸啊10 分钟前
c语言第一个小游戏:贪吃蛇小游戏05
c语言·算法·链表
pedestrian_h1 小时前
Spring AI 开发本地deepseek对话快速上手笔记
java·spring boot·笔记·llm·ollama·deepseek
&Cheems1 小时前
ZYNQ笔记(二十):Clocking Wizard 动态配置
笔记·fpga开发
努力毕业的小土博^_^1 小时前
【深度学习|学习笔记】 Generalized additive model广义可加模型(GAM)详解,附代码
人工智能·笔记·深度学习·神经网络·学习
怪小庄吖1 小时前
7系列 之 I/O标准和终端技术
经验分享·笔记·fpga开发·硬件架构·硬件工程·xilinx 7系列 fpga·i/o标准和终端技术
czy87874751 小时前
两种常见的C语言实现64位无符号整数乘以64位无符号整数的实现方法
c语言·算法
想睡hhh1 小时前
c++进阶——哈希表的实现
开发语言·数据结构·c++·散列表·哈希
chao_7892 小时前
手撕算法(定制整理版2)
笔记·算法
打鱼又晒网3 小时前
数据类型:List
数据结构·list
灰原A3 小时前
摆脱拖延症的详细计划示例
笔记