数据结构—二叉树

二叉树

  • 树概念及结构
    • 树的概念
    • 树的相关概念
    • 树的表示
    • 树在实际中的运用(表示文件系统的目录树结构)
  • 二叉树概念及结构
    • 概念
    • 现实中的二叉树及结构
    • 特殊的二叉树
    • 二叉树的性质
    • 二叉树的存储结构
  • 二叉树的顺序结构及实现
    • 二叉树的顺序结构
    • 堆的概念及结构
    • 堆的实现
    • 堆的应用
      • 堆排序
      • TOP-K问题
  • 二叉树链式结构的实现
    • 前置说明
    • 二叉树的遍历
      • 前序、中序以及后序遍历
      • 层序遍历
    • 二叉树链式结构的相关操作

树概念及结构

树的概念

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

  • 有一个特殊的结点,称为根结点,根节点没有前驱结点
  • 除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继
  • 因此,树是递归定义的。
    数据结构—二叉树_第1张图片

注意:树形结构中,子树之间不能有交集,否则就不是树形结构
数据结构—二叉树_第2张图片

树的相关概念

数据结构—二叉树_第3张图片

  • 节点的度:一个节点含有的子树(子节点)的个数称为该节点的度; 如上图:A的为6
  • 叶节点或终端节点:度为0的节点称为叶节点; 如上图:B、C、H、I…等节点为叶节点
  • 非终端节点或分支节点:度不为0的节点; 如上图:D、E、F、G…等节点为分支节点
  • 双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点
  • 孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点
  • 兄弟节点:具有相同父节点的节点互称为兄弟节点(兄弟指的是亲兄弟); 如上图:B、C是兄弟节点
  • 树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6
  • 节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;
  • 树的高度或深度:树中节点的最大层次; 如上图:树的高度为4(高度是从一开始计时的,而不是零)、树的高度为3(从0开始计算)。
    注意: 树的高度或深度如果从0开始算,那么空树就是-1;如果从1开始算,那么空树就是0;
  • 堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为堂兄弟节点
  • 节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先
  • 子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙
  • 森林:由m(m>0)棵互不相交的树的集合称为森林(并查集就是一个森林)

注:

  • 节点有些地方写的是这个结点,有些地方写的是这个节点,这两个都是可以的没有什么区别,使用时尽量统一使用同一个名称不要混。
  • 第一个进来的节点—根
  • 计算树的高度或深度时建议从一开始,因为按从一开始的方式来计算空树的高度是0,只有根节点的树的高度是1。而按从零开始的方式来计算空树的高度是-1,只有根节点的树的高度是0。建议使用这种理论。

树的表示

如果知道树的度就可以用以下方式表示树:

struct TreeNode
{
	int data;
	struct TreeNode* subs[6]; //树的度
};

注意:这种树的表示方式会有内存浪费,因为有的节点的度不一定都和树的度一样。

在不知道树的度的情况下可以用以下方式表示树:

struct TreeNode
{
	int data;
	SeqList subs; //顺序表存储节点的指针
};

树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法孩子表示法孩子双亲表示法以及孩子兄弟表示法等。其中最常用的是孩子兄弟表示法

双亲表示法:

struct TreeNode
{
	int data;
	struct TreeNode* parent
};
//双亲表示法(但不实用)

左孩子右兄弟表示法(孩子兄弟表示法)

typedef int DataType;
struct Node
{
 struct Node* _firstChild1; // 左边开始的第一个孩子结点
 struct Node* _pNextBrother; // 指向其下一个(右边)兄弟结点
 DataType _data; // 结点中的数据域 
};

数据结构—二叉树_第4张图片

应用:Windows操作系统利用这种结构表示文件系统

树在实际中的运用(表示文件系统的目录树结构)

数据结构—二叉树_第5张图片

二叉树概念及结构

概念

一棵二叉树是结点的一个有限集合,该集合:

  1. 或者为空
  2. 由一个根节点加上两棵别称为左子树和右子树的二叉树组成

数据结构—二叉树_第6张图片

注意:

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

注意:对于任意的二叉树都是由以下几种情况复合而成的(这些又称为二叉树):
数据结构—二叉树_第7张图片

现实中的二叉树及结构

数据结构—二叉树_第8张图片

特殊的二叉树

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

完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。

数据结构—二叉树_第9张图片

总结:

  • 满二叉树的每一层都是满的
  • 完全二叉树是前K-1层都是满的,最后一层不一定满,但是最后一层从左到右必须是连续的。
  • 满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树
  • 完全二叉树的总节点个数范围是[2^(k-1) , 2^k-1]。完全二叉树最少节点个数的情况是前K-1层是满的最后一层只有一个节点,最多的情况是满二叉树的情况。
    数据结构—二叉树_第10张图片
  • 注意的是满二叉树是一种特殊的完全二叉树(最后一层满)。

二叉树的性质

  • 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2^(i-1) 个结点

  • 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是 2^h-1

  • 对任何一棵二叉树, 如果度为0其叶结点个数为n0 , 度为2的分支结点个数为n2 ,则有 n0=n2 +1

  • 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= log2(n+1) (ps:log2(n+1) 是log以2为底,n+1为对数),这个公式是通过2^k-1=n计算出来的

  • 完全二叉树中度为一的最少有0个,最多有1个

  • 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:

    1. 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点
    1. 若2i+1=n否则无左孩子
    1. 若2i+2=n否则无右孩子

二叉树的存储结构

二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。

顺序存储

顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。

数据结构—二叉树_第11张图片

链式存储

二叉树的链式存储结构是指用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前使用的一般都是二叉链。

数据结构—二叉树_第12张图片

typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
	struct BinTreeNode* _pLeft; // 指向当前节点左孩子
 	struct BinTreeNode* _pRight; // 指向当前节点右孩子
 	BTDataType _data; // 当前节点值域
}


// 三叉链
struct BinaryTreeNode
{
 	struct BinTreeNode* _pParent; // 指向当前节点的双亲
 	struct BinTreeNode* _pLeft; // 指向当前节点左孩子
 	struct BinTreeNode* _pRight; // 指向当前节点右孩子
 	BTDataType _data; // 当前节点值域
}

**总结:**满二叉树和完全二叉树适合用顺序存储,因为节点是挨着存放的。但是并不意味着只有这两个特殊的二叉树能使用顺序存储,普通的二叉树也可以。这种的二叉树使用顺序存储会存在一定的空间浪费。说明顺序存储只适合存储完全二叉树或者满二叉树。

二叉树的顺序结构及实现

二叉树的顺序结构

普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。

数据结构—二叉树_第13张图片

注:

  • 数据结构和操作系统这两门学科中都有栈和堆这两个名词,这两个名词之间没有关联关系。栈和堆在数据结构中栈是后进先出的线性表数据结构、堆是用于排序以及选top数的二叉树数据结构。栈和堆在操作系统中栈是函数调用会建立栈帧(其中栈帧就是在栈上开空间的)、堆是动态开辟内存空间在堆上开辟,它们两个是对内存的一个区域的划分的名称(栈和堆是内存划分两个区域的名称)。

  • 堆是用数组存储表示的完全二叉树

堆的概念及结构

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

堆的性质:

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

数据结构—二叉树_第14张图片

总结:

  • 小根堆(小堆)的特点是树中的所有父亲都小于或者等于孩子
  • 大根堆(大堆)的特点是树中的所有父亲都大于或者等于孩子
  • 大根堆和小根堆的特点是根是最大值和根是最小值

堆的实现

堆向下调整算法

现在给出一个数组,逻辑上看做一颗完全二叉树。可以通过从根节点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆(小堆或大堆)(根下面的左右子树是小堆或者大堆),才能调整。

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

数据结构—二叉树_第15张图片
堆向下调整算法是对于根而言选出左右孩子小(大)的那一个,跟父亲比如果比父亲小(大)则交换。接着再以要交换的节点作为父亲循环往复向下调整,直到左右孩子不存在或者没有发生交换则终止。

注:堆向下调整算法是针对根下面左右子树都是堆的特征的树调整成堆

总结:

  • 堆向下调整算法条件是根左右孩子都是小(大)堆,就可以向下调整
  • 堆向下调整算法的步骤:
    1. 从根开始不断向下调
    1. 选出左右孩子中小(大)的,跟父亲比较(1)如果比父亲小(大),跟父亲交换。以小(大)的孩子位置继续往下调。最坏调到叶子终止。(2)如果比父亲大(小)则终止。

堆向下调整算法:

void Swap(int* px, int* py)
{
	int tmp = *px;
	*px = *py;
	*py = tmp;
}

// 条件:左右子树都是小堆/大堆
void AdjustDown(int* a, int n, int parent)  //这里之所以写节点向下调整的位置,是因为创建堆是从倒数第一个非叶子节点开始的其所在位置不一定是根节点。
{
	int child = parent * 2 + 1;
	while (child < n)  //停止持续向下调整的条件:其一是判断父亲是否是叶子节点(孩子是否存在),用数组下标判断是否越界
	{
		// 选出左右孩子中小 or 大的那个
		if (child+1 < n && a[child+1] > a[child])  //注意当左孩子存在时右孩子不一定存在有可能发生越界,如果右孩子不存在则左孩子就是孩子中最小(大)的
		{
			++child;
		}

		// 1、如果小 or 大的孩子比父亲小 or 大,则交换,继续往下调整
		// 2、如果小 or 大 的孩子比父亲大 or 小,则结束调整(停止持续向下调整的条件其二)
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

堆的创建

给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在可以通过算法,把它构建成一个堆。根节点左右子树不是堆,这时可以从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,就可以调整成堆。

int a[] = {1,5,3,8,7,6};

数据结构—二叉树_第16张图片

堆的创建:

void HeapCreat(int* a, int n)
{
	//建堆
	for (int i = (n-1-1)/2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}
}

建堆时间复杂度

因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个节点不影响最终结果):

数据结构—二叉树_第17张图片

堆的定义

typedef int HPDataTpye;

typedef struct Heap
{
	HPDataTpye* a;  //堆的物理结构为数组
	int size;       //存储有效数据个数(最后一个数据的下一个位置)
	int capacity;   //存储数据的容量
}HP;

注:优先级队列(priority_queue)的底层用堆实现的,优先级队列不满足先进先出,是按优先级出队列的。

堆的初始化

堆的初始化首先要开辟出一段和调用方传递过来的数组一样大的空间,并将该数组中的数据拷贝到新开辟的数组空间中去,其次对新申请一段空间的数组中的数据进行建堆,最后将记录堆中有效数据个数和记录堆容量的变量进行初始化即可。

void HeapInit(HP* php, HPDataTpye* a, int n)
{
	assert(php);

	php->a = (HPDataTpye*)malloc(sizeof(HPDataTpye)*n);
	if (php->a == NULL)
	{
		printf("malloc fail\n");
		exit(-1);
	}

	memcpy(php->a, a, sizeof(HPDataTpye)*n);

	// 建堆
	for (int i = (n-2)/2; i >= 0; --i)
	{
		AdjustDown(php->a, n, i);
	}

	php->size = n;
	php->capacity = n;
}

堆的销毁

堆的销毁是将申请的数组空间释放掉并将指向这个数组的指针置空,最后将记录堆中有效数据个数和记录堆容量的变量置成0即可。

void HeapDestroy(HP* php)
{
	assert(php);

	free(php->a);
	php->a = NULL;
	php->size = php->capacity = 0;
}

堆的打印

堆的打印只需将数组中的数据循环打印一遍即可

void HeapPrint(HP* php)
{
	for (int i = 0; i < php->size; ++i)
	{
		printf("%d ", php->a[i]);
	}
	printf("\n");
}

向上调整算法

先插入一个10到数组的尾上,再进行向上调整算法,直到满足堆。

数据结构—二叉树_第18张图片
向上调整算法是在大堆中如果该节点的大小比父亲要小就终止,如果比父亲大则需要和父亲进行交换,然后再向上迭代,直到调到根节点为止。

注:

  • 向上调整算法适用于在堆中尾插一个数据
  • 在堆中尾插一个数据,该数只会影响该节点顺着其双亲往上直至根节点的所在路径,其他节点并不会受到影响
void AdjustUp(int* a, int child)
{
	int parent = (child - 1) / 2;
	
	//while (parent >= 0)  //当parent小于0终止调整   写这个条件是不对的 parent不会小于0(parent = (child - 1) / 2;),当parent=0时进入循环child=0,parent=0,会再次进入循环直到break跳出循环。写这个条件虽然结果是对的但是逻辑是错的。
	   
	while (child > 0)
	{
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else  //孩子小于父亲(在大堆中)
		{
			break;
		}
	}
}

注意:可以利用向上调整算法可以建堆

给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在可以通过算法,把它构建成一个堆。根节点左右子树不是堆,这时可以从根的左孩子节点(数组中的第二个元素)开始调整,一直调整到最后一个节点的树,就可以调整成堆(把第一个数看作一个堆,第二个数插入进去,前两个数就是堆了,依次类推)。

注:向上调整的前提是其他的数是个堆

堆的创建:

void HeapCreat(int* a, int n)
{
	//建堆
	for (int i = 1; i < n; ++i)
	{
		AdjustUp(a, i);
	}
}

利用向上调整算法建堆的时间复杂度为O(N)。

堆的插入

堆的插入首先判断堆的存储空间是否已满,如果已满需要进行扩容,增容成功需要将堆的容量进行修改,然后插入数据(只需在数组尾部插入数据即可),再将记录堆中有效数据个数的变量进行++,最后再用向上调整算法将这个数组继续保持成堆即可。

注:堆和线性表是有所不同的,堆不是线性表,虽然堆的物理结构是数组(连续的、线性的),但堆的逻辑结构(实际中表示的东西)是二叉树。因此堆是不分头插、尾插的。

// 插入x,保持它继续是堆
void HeapPush(HP* php, HPDataTpye x)
{
	assert(php);
	if (php->size == php->capacity)
	{
		HPDataTpye* tmp = (HPDataTpye*)realloc(php->a, php->capacity * 2 * sizeof(HPDataTpye));
		if (php->a == NULL)
		{
			printf("realloc fail\n");
			exit(-1);
		}
		php->capacity *= 2;
	}

	php->a[php->size] = x;
	php->size++;

	AdjustUp(php->a, php->size - 1);
}

堆的删除

堆的删除是删除最大的数(在大堆中),把次大的找出来( 删除堆顶数据,删除后保持它继续是堆)。堆的删除可以先将数组中的第一个数据删除(将后面的数依次挪到前面来),再这些数重新建堆,不过时间复杂度为O(N),时间效率太低。更优的方式是首先把数组中的第一个和最后一个数进行交换,然后删除最后一个数,最后再使用向下调整算法继续保持堆即可。其中这个算法的时间复杂度为O( l o g N logN logN)。

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

数据结构—二叉树_第19张图片

// 删除堆顶数据,删除后保持它继续是堆
void HeapPop(HP* php)
{
	assert(php);
	assert(!HeapEmpty(php));

	Swap(&php->a[0], &php->a[php->size - 1]);
	--php->size;

	AdjustDown(php->a, php->size, 0);
}

获取堆顶的数据

获取堆顶的数据只需将数组中第一个元素返回即可

// 获取堆顶的数据,也就是最值
HPDataTpye HeapTop(HP* php)
{
	assert(php);
	assert(!HeapEmpty(php));

	return php->a[0];
}

堆的判空

堆的判空只需判断堆中的有效数据个数是否为零,如果为零则返回真,否则返回假。

bool HeapEmpty(HP* php)
{
	assert(php);

	return php->size == 0;
}

获取堆中数据个数

获取堆中数据个数只需将记录堆中有效数据个数的变量返回即可

int HeapSize(HP* php)
{
	assert(php);

	return php->size;
}

堆的应用

堆排序

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

  1. 建堆
  • 升序:建大堆
  • 降序:建小堆
  1. 利用堆删除思想来进行排序

数据结构—二叉树_第20张图片

堆排序首先要建堆,其次记录数组中最后一个元素的位置,将选出来的最小(大)值所在的第一个位置和最后位置的数进行交换,紧接着将交换完之后的从根开始进行向下调整(将数组的个数进行减一从而达到不用将最后一个数算到堆中的目的,因为最小(大)的数已经排到最后了),从而选出次小(大)的数,然后将记录数组中最后一个元素的位置向前挪动一个位置,接着将选出的次小(大)的数的所在位置和记录数组位置中的数进行交换,依此类推循环往复直到记录数组下标的位置为0时终止。

注:

  • 建堆:给了一个随机数组,可以把这个随机数组看作完全二叉树,通过从倒数的第一个非叶子节点的子树开始调整(利用向下调整算法),一直调整到根节点的树,就可以调整成堆(大堆或小堆)。
  • 堆排序的升序是建大堆,降序是建小堆
  • 排升序建小堆是不好的原因是建小堆选出最小的数,最小的数是放在第一个位置,紧接着选次小的数……不断选下去,直到排好升序。首先通过n个数建堆选出最小的数,其次再通过n-1个数建堆选出次小的数,依此类推完成升序。但是要对剩下的数进行建堆节点的关系就变了(之前建好的堆一点用不上,重新建堆代价非常大),建堆的时间复杂度是O(N),那么整体的时间复杂度为O( N 2 N^2 N2)。如果是建堆(升序建小堆)去选数还不如直接遍历选。排升序建小堆可以,但是效率太低,堆的价值没有得到体现。
  • 排升序建大堆,建大堆选出最大的数,把最大的数换到最后,紧接着选出次大的数换到倒数第二的位置上,依此类推完成升序。注意选出最大数之后再选次大的数的过程中树的结构(堆结构没有被破坏)中的左、右子树都是个大堆(不用将最后一个数算到堆中,最后一个数不用动),此时就不需要建堆只需进行向下调整即可。向下调整算法的时间复杂度为O( l o g N logN logN),整体堆排序算法的时间复杂度为O(N l o g N logN logN)。

堆排序实现:

void Swap(int* px, int* py)
{
	int tmp = *px;
	*px = *py;
	*py = tmp;
}

// 条件:左右子树都是小堆/大堆
void AdjustDown(int* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		// 选出左右孩子中小(大)的那个
		if (child+1 < n && a[child+1] > a[child])
		{
			++child;
		}

		// 1、如果小(大)的孩子比父亲小(大),则交换,继续往下调整
		// 2、如果小(大)的孩子比父亲大(小),则结束调整
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

// 堆排序 -> 效率更高
// 堆排序的时间复杂度为O(N*logN)
void HeapSort(int* a, int n)
{
	// 排升序->建大堆   
	// 排降序->建小堆
	// 建堆的时间复杂度 O(N)
	for (int i = (n-1-1)/2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}

	// 向下调整算法(最多调整高度次)  时间复杂度为O(logN)
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}
}

TOP-K问题

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

对于Top-K问题,能想到的方法有:

  1. 排序,时间复杂度为O(N l o g N logN logN)
  2. 建立一个N个数的堆(优先级队列),不断选数,选出前K个数。时间复杂度为O(N+K l o g N logN logN)
  3. 假设N非常大(N为10亿数),要找出最大前K个,内存中存放不下是存放到文件中的,前面的两个方法都不能用。这时解决方案是建K个数的小堆,剩下的数如果比堆顶大,就替换堆顶的数据进堆,最后小堆中的K个数就是最大的K个数

注:找数据集合中前K个最大的元素要建小堆,这是因为找最大的建大堆,有可能最大的数就放在这个堆里面,最大的数放在堆顶,而其他数(前K大的数)这些就进不来了,只能找到最大的,但是前K大的数就找不出来了,而建小堆的话,只要最大的前K个数里面的某一个数没进堆里面,那说明堆里面都放的是比这K个数小的数,而小堆最小的数是放到上面的,那最大的K个数来了以后,它肯定比堆顶的数大(这是因为当最大前K个数中的一些数没进堆,那么说明其他数在堆里面,堆中这些没进TOP-K的数肯定比TOP-K中没进堆的要小;当最大的前K个数中某一个来了以后,一定比堆顶的数据大,一定能进堆)。

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

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

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

TOP-K的实现:

void PrintTopK(int* a, int n, int k)
{
	HP hp;
	HeapInit(&hp, a, k);

	for (int i = k; i < n; ++i)
	{
		if (a[i] > HeapTop(&hp))
		{
			HeapPop(&hp);    
			HeapPush(&hp, a[i]);

			//还可以用另外一种方法:将堆顶的数据进行修改,再用向下调整算法保持堆即可
		}
	}

	HeapPrint(&hp);

	HeapDestroy(&hp);
}

二叉树链式结构的实现

前置说明

二叉树的定义

typedef char BTDataType;

typedef struct BinaryTreeNode
{
	BTDataType data;
	struct BinaryTreeNode* left;
	struct BinaryTreeNode* right;
}BTNode;

注:普通二叉树的增删查改是没有意义,因此开始不用学习增删查改,主要是学习它的结构。

二叉树:

  1. 空树
  2. 非空:根节点,根节点的左子树、根节点的右子树组成的。

数据结构—二叉树_第21张图片

注:从二叉树中可以看出,二叉树定义是递归式的,因此后序基本操作中基本都是按照该概念实现的。

二叉树的遍历

二叉树的遍历有四种遍历形式:前序遍历(先根遍历)、中序遍历(中根遍历)、后序遍历、层序遍历

前序、中序以及后序遍历

学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉树中的节点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。

数据结构—二叉树_第22张图片

按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:

  • 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。
  • 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
  • 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。

由于被访问的结点必是某子树的根,所以N(Node)、L(Left subtree)和R(Right subtree)又可解释为
根、根的左子树和根的右子树。NLR、LNR和LRN分别又称为先根遍历、中根遍历和后根遍历。

二叉树前序遍历

二叉树前序遍历首先如果这棵树为空直接返回(因为空树没法访问),如果这棵树不为空那么把这棵树分为三部分:根、左子树、右子树,而左子树和右子树又要分成根、左子树、右子树,依此类推,直到这棵子树是空时才终止。

void PreOrder(BTNode* root) {
	if (root == NULL) {
		printf("NULL ");  //这里遇到空树也进行打印 
		return;
	}

	printf("%c ", root->_data);
	PreOrder(root->_left);
	PreOrder(root->_right);
}

前序遍历递归图解:

数据结构—二叉树_第23张图片

数据结构—二叉树_第24张图片

二叉树中序遍历

// 二叉树中序遍历
void InOrder(BTNode* root)
{
	if (root == NULL) {
		printf("NULL ");
		return;
	}

	InOrder(root->_left);
	printf("%c ", root->_data);
	InOrder(root->_right);
}

二叉树后序遍历

// 二叉树后序遍历
void PostOrder(BTNode* root)
{
	if (root == NULL) {
		printf("NULL ");
		return;
	}

	PostOrder(root->_left);
	PostOrder(root->_right);
	printf("%c ", root->_data);
}

注:递归不一定是分治,但是基本上都算是符合分治的思想的(大部分都是,有些地方不一定),基本上大部分的分治都可以用递归

层序遍历

层序遍历:除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根节点所在层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第2层上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。

数据结构—二叉树_第25张图片

层序遍历使用递归不方便遍历,使用队列来进行遍历,先把根放进队列,根出来的时候把左右孩子放到队列中去,接着上一层出来带下一层(当一层出完了下一层就都进去了),当队列为空层序遍历就结束了(核心思路是先入第一层根节点,上一层出来带入下一层,直到队列为空结束)。

// 层序遍历
void BinaryTreeLevelOrder(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root)
	{
		QueuePush(&q, root);
	}

	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);
		printf("%c ", front->data);

		if (front->left)
		{
			QueuePush(&q, front->left);
		}

		if (front->right)
		{
			QueuePush(&q, front->right);
		}
	}

	printf("\n");

	QueueDestory(&q);
}

注:前序(更严格的深度优先遍历)、后序、中序遍历都是深度优先遍历;层序遍历是广度优先遍历

二叉树链式结构的相关操作

二叉树节点个数

方法:

  1. 定义一个全局变量(或者静态变量),通过递归遍历的方式计算出二叉树节点个数。这个方法需要注意每次计算二叉树节点个数时需要将全局变量重置一下,此外还有线程安全问题。
  2. 在函数中添加一个形参(该形参是指向实参(用于记录二叉树节点个数的局部变量)的指针),通过指针解引用遍历的方式来对同一个变量进行++,从而计算出二叉树节点个数。这种方法不存在线程安全问题
  3. 利用分治思想(把大问题分成小问题),判断当前指针所指向的节点是否为空,如果为空直接返回0,否则把当前树分成根、左子树、右子树,然后通过左子树的递归、右子树的递归从而完成当前节点左、右子树的节点个数的计算,再加上当前节点就可以计算出总的二叉树的节点个数。
// 二叉树节点个数

// 1、遍历  -- 全局变量
int size = 0;
void BinaryTreeSize(BTNode* root)
{
	if (root == NULL)
		return;
	else
		++size;

	BinaryTreeSize(root->left);
	BinaryTreeSize(root->right);
}

// 2、遍历  -- 局部变量
void BinaryTreeSize(BTNode* root, int* psize)
{
	if (root == NULL)
		return;
	else
		++(*psize);

	BinaryTreeSize(root->left, psize);
	BinaryTreeSize(root->right, psize);
}

// 分治
int BinaryTreeSize(BTNode* root)
{
	return root == NULL ? 0 : 1 
		+ BinaryTreeSize(root->left) 
		+ BinaryTreeSize(root->right);     //利用二叉树后序递归的思想完成二叉树节点个数
}

二叉树叶子节点个数

二叉树叶子节点个数利用分治的思想,判断当前指针所指向的节点是否为空,如果为空直接返回0,如果当前节点是叶子节点那么返回1,否则把当前树分成根、左子树、右子树,那么当前树中的叶子节点的个数就是左、右子树的叶子节点个数之和。

// 二叉树叶子节点个数
int BinaryTreeLeafSize(BTNode* root)
{
	if (root == NULL)
	{
		return 0;
	}
	else if (root->left == NULL && root->right == NULL)
	{
		return 1;
	}
	else
	{
		return BinaryTreeLeafSize(root->left)
			+ BinaryTreeLeafSize(root->right);
	}
}

二叉树第k层节点个数(k>=1)

判断当前指针所指向的节点是否为空,如果为空直接返回0,如果k为第一层时则直接返回1(因为当前节点正是所求层的节点),都不满足的情况下求当前树的第k层节点个数等于求左、右子树的第k-1层节点个数之和

// 二叉树第k层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{
	if (root == NULL)
		return 0;
	
	if (k == 1)
		return 1;

	return BinaryTreeLevelKSize(root->left, k - 1)
		+ BinaryTreeLevelKSize(root->right, k - 1);
}

二叉树深度(高度)

判断当前指针所指向的节点是否为空,如果为空直接返回0,否则求当前二叉树的深度等于左、右子树的深度的较大值+1。

注:深度(高度):二叉树中最长的那条路径的节点个数

// 二叉树深度/高度
int BinaryTreeDepth(BTNode* root)
{
	if (root == NULL)
	{
		return 0;
	}

	int leftDepth = BinaryTreeDepth(root->left);
	int rightDepth = BinaryTreeDepth(root->right);

	return leftDepth > rightDepth ? leftDepth + 1 : rightDepth + 1;
}

二叉树查找值为x的节点

二叉树查找值为x的节点首先判断当前指针所指向的节点是否为空,如果为空直接返回NULL,接着如果当前指针所指向的节点的值和所要找的x相等那么返回当前指针,然后先去左子树找,找到了就返回找到的节点的指针,如果左子树没找到,那么就去右子树找,如果找到了返回找到的节点的指针,最后还没找到返回空指针。

// 二叉树查找值为x的节点

BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
	if (root == NULL)
	{
		return NULL;
	}

	if (root->data == x)	
	{
		return root;
	}

	BTNode* retLeft = BinaryTreeFind(root->left, x);
	if (retLeft)
	{
		return retLeft;
	}
	
	BTNode* retRight = BinaryTreeFind(root->right, x);
	if (retRight)
	{
		return retRight;
	}

	return NULL;
}

二叉树销毁

二叉树销毁是要把每个节点都释放掉,要递归销毁这棵树,判断当前指针所指向的节点是否为空,如果为空直接返回。(如果当前节点不是空,那么不建议销毁当前树,把当前节点销毁了,释放掉这个当前节点,这个节点里面存的内容就会被置成随机值也就找不到该节点的左、右节点,建议采取后序的方式销毁。)如果当前节点不是空,先销毁当前树的左子树,再去销毁当前树的右子树,最后销毁当前节点即可。

// 二叉树销毁
void BinaryTreeDestory(BTNode* root)
{
	if (root == NULL)
	{
		return;
	}

	BinaryTreeDestory(root->left);
	BinaryTreeDestory(root->right);
	free(root);
}

注:

  • 二叉树销毁函数中的参数可以是二级指针(可以解决野指针的问题)也可以是一级指针(可以保持接口的一致性,但是还需要指针置空避免野指针问题)
  • 前序+中序以及后序+中序能恢复重建二叉树,这是因为前序和后序能确定根节点而中序能根据根节点划分左右子树的中序区间。但是只有后续或者前序是不能构建出二叉树的。

判断二叉树是否是完全二叉树

判断二叉树是否是完全二叉树,利用层序遍历把空也入队列,如果是完全二叉树那么非空是连续的、空也是连续的;如果不是完全二叉树那么非空不是连续的、空不连续。当出到空以后,队列中全是空就是完全二叉树;其中出队列的过程中如果有非空那么就不是完全二叉函数。

// 判断二叉树是否是完全二叉树
bool BinaryTreeComplete(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root)
	{
		QueuePush(&q, root);
	}

	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);
		if (front == NULL)
		{
			break;
		}

		QueuePush(&q, front->left);
		QueuePush(&q, front->right);

	}

	// 出到空,以后,队列中全是空,就是完全二叉树
	// 还有非空,就不是完全二叉树
	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);
		if (front)
		{
			QueueDestory(&q);  //防止内存泄漏(return之前要把队列销毁掉)
			return false;
		}
	}

	QueueDestory(&q);
	return true;
}

你可能感兴趣的:(数据结构,二叉树,数据结构,c语言,面试题,c++)