数据结构学习篇(8)---二叉树

1. 数的结构及概念

1.1 数的概念

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

  • 有一个特殊的结点,称为根结点,根结点没有前驱结点
  • 除根结点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、......、Tm,其中每一个集合Ti(1又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继
  • 因此,树是递归定义的。

注意:树形结构中,子树之间不能有交集,否则就不是树形结构:

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 树的结构体表示

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

传统的静态表示法:

静态的相对来说比较笨拙,一般使用动态表示------孩子兄弟表示法:

cpp 复制代码
struct TreeNode
{
	int val;//节点中的数据域
	struct TreeNode* LeftChlid;//无论父亲节点有多少个孩子,都指向左边第一个孩子
	struct TreeNode* NextBrother;//指向下一个兄弟节点,没有兄弟就指向NULL
};

1.4 树的实际运用

2. 二叉树的概念及结构

2.1 二叉树的概念

二叉树属于树的子集,属于树的一种,就是在树的基础上限定度最多只能为2(也就是小于等于2) 。

从上图就可以看出:

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

注意:对于任意的二叉树都是由以下几种情况复合而成的:

对于二叉树来说,就不需要使用"孩子兄弟表示法"了,因为他最多只有两个孩子,所以想要定义二叉树,只需要定义两个指针,一个指向左孩子,一个指向右孩子。

2.2 特殊的二叉树

1. 满二叉树:每一层的节点都有两个度(也可以说成都有两个孩子)。

2. 完全二叉树:前h-1层都是满的,只有最后一层是不满的,而且最后一层的孩子必须是从左到右连续的。

注意:满二叉树是特殊的完全二叉树,但是完全二叉树不是满二叉树。

通过计算来感受一下满二叉树储存数据的恐怖之处:

2.3 现实的中二叉树

下面这两张图片就能够检验你到底能不能识别出完全二叉树和满二叉树。

2.4 二叉树的存储结构

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

2.4.1 顺序存储

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

对于非完全二叉树想要进行顺序存储,那么在计算父亲或者孩子的下标时,需要首先将非完全二叉树补充成完全二叉树的样子才能进行计算,不然会产生错误,如下图所示:

2.4.2 链式存储

3. 堆

3.1 堆的概念及结构

如果有一个关键码的集合K = { , , ,..., },把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足: 且 = 且 >= ) i = 0,1, 2...,则称为小堆(或大堆)。将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。

上面的这种说法听着好复杂,用自己的话来理解:
堆就一种特殊的完全二叉树,在堆的基础上分为大堆和小堆。

1.大堆:

  • 完全二叉树
  • 任何一个父亲的数据域必须>=孩子的数据域,孩子之间的数据域大小没有关系。

2.小堆:

  • 完全二叉树
  • 任何一个父亲的数据域必须<=孩子的数据域,孩子之间的数据域大小没有关系。

注意:不能说大堆就是降序数组,小堆就是升序数组,因为孩子之间的大小是没有关系的!

3.2 堆的特点

我们可以看到堆的根要么是最大的(大堆)要么是最小的(小堆),那么利用堆的这一特点我们就可以找到数组中所有元素的极值,然后来进行排序,所有堆的排序效率很高。

3.3 堆的实现

3.3.1 堆的定义

堆的本质是数组,所所以需要在堆中包含一个数组,数组元素个数以及空间个数。

cpp 复制代码
typedef int HPDataType;
typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
}HP;

3.3.2 堆的初始化

cpp 复制代码
// 堆的初始化
void HeapInit(HP* hp)
{
	assert(hp);
	hp->a = NULL;
	hp->size = hp->capacity = 0;
}

3.3.3 堆的销毁

cpp 复制代码
// 堆的销毁
void HeapDestory(HP* hp)
{
	assert(hp);
	free(hp->a);
	hp->a = NULL;
	hp->size = hp->capacity = 0;
}

3.3.4 堆的插入

堆的插入其实就是在数组上进行尾插操作,只不过尾插入新的元素之后,要使其还是满足堆的结构,所以就需要对新元素进行向上调整,思路如下:

首先根据新元素的下标来找到他的父母的下标,然后将新元素与父母元素进行大小比较,因为我们要满足还是小堆结构,如果新元素较父母小,则两个元素进行交换,交换之后新元素的下标更新为原来父母的下标,然后基于新的下标继续寻找新的父母的下标,然后继续进行大小比较,直至满足小堆结构方可结束这个循环。

代码实现:

cpp 复制代码
//向上调整算法(实现小堆:把数值小的往上换)
void AdjustUp_s(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;//计算出新元素的父母下标,让新元素与父母数值进行比较
	while (child > 0)//while(parent>=0)-这个判断条件是存在弊端的,但是也能跑
	{
		if (a[child] < a[parent])//如果孩子的数值比父母小,那么两者交换数值
		{
			swap(&a[child], &a[parent]);
			child = parent;//交换之后更新新元素的下标
			parent = (child - 1) / 2;//计算出新的父母的下标继续比较,就这样一层一层往上比较,直至结束
		}
		else
		{
			break;
		}
	}
}


//向上调整算法(实现大堆:把数值大的往上换)
void AdjustUp_b(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;//计算出新元素的父母下标,让新元素与父母数值进行比较
	while (child > 0)//while(parent>=0)-这个判断条件是存在弊端的,但是也能跑
	{
		if (a[child] > a[parent])//如果孩子的数值比父母小,那么两者交换数值
		{
			swap(&a[child], &a[parent]);
			child = parent;//交换之后更新新元素的下标
			parent = (child - 1) / 2;//计算出新的父母的下标继续比较,就这样一层一层往上比较,直至结束
		}
		else
		{
			break;
		}
	}
}

// 堆的插入
void HeapPush(HP* hp, HPDataType x)
{
	assert(hp);
	if (hp->size == hp->capacity)//首先判断空间有没有满,满的话扩容空间
	{
		int newcapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;
		HPDataType* ps = (HPDataType*)realloc(hp->a, newcapacity * sizeof(HPDataType));
		if (ps == NULL) {
			perror("realloc fail");
			return;
		}
		hp->a = ps;
		hp->capacity = newcapacity;
	}
	hp->a[hp->size] = x;//插入新的数值
	hp->size++;//元素个数加1
	AdjustUp_s(hp->a, hp->size - 1);//插入新元素之后要使其还是小堆结构,这个时候就要进行向上调整
	//AdjustUp_b(hp->a, hp->size - 1);//插入新元素之后要使其还是大堆结构,这个时候就要进行向上调整
}

3.3.5 堆的删除

堆的删除删掉的是堆顶的元素,想要实现这个功能,不能直接就对堆顶元素实施头删操作,因为这会破坏原来的小堆结构,所以正确的做法是将堆顶元素和堆的最后一个元素进行交换,然后对其实施尾删操作,这样就删除掉了堆顶元素,但是还需要使堆保持小堆结构,这就需要对新的堆顶元素进行向下调整,向下调整的思路如下:

向下调整的思路和向上调整的思路其实大差不差,堆顶的元素作为父母下标,根据这个下标寻找他的孩子下标,我们默认使用公式来寻找左边孩子的下标,找到之后将左孩子与右孩子的元素进行大小比较,两者中元素小的那个作为孩子下标,然后将孩子与父母的元素进行比较,如果父母的元素大于孩子元素,两者元素就进行交换,然后将父母的下标更新为原来孩子的下标,再利用公式继续寻找下一个孩子的坐标,就这样一直循环,直至调整为小堆结构。

注:向下调整算法必须满足一个前提条件:堆顶元素下面的子树必须保持小堆或者大堆结构!

代码实现:

cpp 复制代码
//向下调整算法:实现小堆调整
void AdjustDown_s(HPDataType* a, int n, int parent)
{
	//知道父母之后要先求相应的孩子下标,这里默认求的左边孩子下标
	int child = 2 * parent + 1;//左边孩子下标
	//进行向下调整
	while (child < n)
	{
		//判断一下到底是左孩子的数值小还是右孩子的数值小
		if (child + 1 < n && a[child] > a[child + 1])
		{
			child++;
		}
		//确认好孩子的下标之后进行数值交换
		if (a[parent] > a[child])
		{
			swap(&a[parent], &a[child]);
			//交换完之后让原来的孩子变成父母继续与下面的孩子作比较
			parent = child;
			child = 2 * parent + 1;
		}
		else
		{
			break;
		}
	}
}

//向下调整算法:实现大堆调整
void AdjustDown_b(HPDataType* a, int n, int parent)
{
	//知道父母之后要先求相应的孩子下标,这里默认求的左边孩子下标
	int child = 2 * parent + 1;//左边孩子下标
	//进行向下调整
	while (child < n)
	{
		//判断一下到底是左孩子的数值大还是右孩子的数值大
		if (child + 1 < n && a[child] < a[child + 1])
		{
			child++;
		}
		//确认好孩子的下标之后进行数值交换
		if (a[parent] < a[child])
		{
			swap(&a[parent], &a[child]);
			//交换完之后让原来的孩子变成父母继续与下面的孩子作比较
			parent = child;
			child = 2 * parent + 1;
		}
		else
		{
			break;
		}
	}
}

// 堆的删除:删除的是堆顶的数据
void HeapPop(HP* hp)
{
	assert(hp);
	assert(hp->size > 0);
	//先把数组中的最后一个与数组中的第一个元素进行交换,然后进行尾删
	swap(&hp->a[0], &hp->a[hp->size - 1]);
	hp->size--;
	//删除之后要使原来还是小堆,这个时候就需要进行向下调整
	AdjustDown_s(hp->a, hp->size, 0);
	////删除之后要使原来还是大堆,这个时候就需要进行向下调整
	//AdjustDown_b(hp->a, hp->size, 0);
}

3.3.6 堆的其他功能实现

cpp 复制代码
// 取堆顶的数据
HPDataType HeapTop(HP* hp)
{
	assert(hp);
	assert(hp->size > 0);
	return hp->a[0];//数组第一个元素就是堆顶元素
}

// 堆的数据个数
int HeapSize(HP* hp)
{
	return hp->size;
}

// 堆的判空
bool HeapEmpty(HP* hp)
{
	assert(hp);
	return hp->size == 0;
}

3.4 堆的应用

3.4.1 堆排序(向上调整建堆)

cpp 复制代码
void HeapTest01()
{
	HP hpp;
	HeapInit(&hpp);
	int arr[] = { 2,4,2,65,86,32,6,9,5 };
	for (size_t i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
	{
		HeapPush(&hpp, arr[i]);
	}
	int i = 0;
	while (!HeapEmpty(&hpp))
	{
		arr[i++] = HeapTop(&hpp);
		HeapPop(&hpp);
	}
	printf("\n");
}

在上面这个图片中可以看到,首先创建了一个数组arr,然后将这个数组的元素插入到建立的堆的数组a中,插入过程会对这些元素进行调整,使其满足大堆或者小堆结构,这个例子中满足的是大堆结构,然后想要实现堆排序就一个一个的读取堆顶的元素依次放到数组arr中,这样数组a中的元素就变成有序排列了,但是使用这种方法需要创建外面的一个数组arr,还要创建堆中的数组,相当于一共开辟了两个数组的空间,增加了空间复杂度,所以这种排序方法并不好用,下面介绍另外一种方法。

注意:这里进行排序完之后得到的数组arr中的元素是升序排列(降序排列)的!

直接将外面创建的数组arr看作是一个还未调整的堆,然后调用向上调整函数对这个堆进行调整,使其满足大堆或者小堆结构,这种方法就不需要多开辟堆中的那个数组a,而是只使用外面的那个数组arr,节省了空间。

代码实现:

cpp 复制代码
//向上调整建堆
void HeapSort(int* arr, int n)
{
	for (int i = 0; i < n; i++) {
		AdjustUp_s(arr, i);//建小堆
        //AdjustUp_b(arr,i);//建大堆
	}
}

注意:这里只是实现了将堆调整为大堆或者小堆,而数组中的元素还不是升序排列(降序排列)的!要实现升序排列(降序排列)还需要继续往下补充代码。

要想使得数组arr中元素为降序排列,惯用思维是使用大堆结构来实现,但是可以设想一下,如果拿到一个大堆结构,提取完第一个堆顶元素之后,我们需要删除这个堆顶元素才能得到数值第二大的元素,但是我们删除第一个堆顶元素之后(数值最大元素),数值第二大的元素会成为堆顶元素,这就会导致原来大堆中的父子关系被破坏,就需要重新调整为大堆结构然后提取到数值第二大的元素,很显然这个操作非常复杂,所以不适用,所以采用小堆结构来实现降序排列是最合适的,具体实现过程示例图如下:

升序排列实现流程图:

降序排列实现的流程图(自己手画的,自己的理解方式,和升序原理是一样的):

代码实现如下(此代码是实现降序排列,升序排列同理):

cpp 复制代码
//堆排序(使用小堆结构实现数组元素的降序排列)
void HeapSort(int* arr, int n)
{
	for (int i = 0; i < n; i++) {
		AdjustUp_s(arr, i);
	}
	int end = n - 1;//堆尾元素下标
	while (end > 0)
	{
		swap(&arr[0], &arr[end]);//交换堆顶和堆尾元素
		//交换之后孤立堆尾元素,将剩下元素使用向下调整法调整为小堆结构
		AdjustDown_s(arr, end, 0);
		end--;//这个操作就是孤立堆尾元素
	}
	//整个循环执行完之后数组arr中元素成为了降序排列
}

3.4.2 堆排序(向下调整建堆)

要使得组成堆的元素呈现升序排列或者降序排列的前提是首先需要将堆调整为大堆或者小堆,上面的方法中使用的想上调整的方法使得堆变为大堆或者小堆结构,但是时间复杂度还不是最低的,所以这一小节使用向下调整的方法来建立大堆或者小堆结构,但是使用向下调整算法有个前提,前面内容也提到过:作为根下面的子树需要满足大堆或者小堆结构,所以为了解决这个问题,所采用的思想是从下往上使用向下调整法,将一个个子树看作是一个局部的堆来进行向下调整,直到所有子树都调整为大堆或者小堆结构,便可以将整个堆调整为大堆或者小堆结构。文字表诉可能不清楚,下面的流程演示可以直观进行感受。

代码实现过程:

cpp 复制代码
//向下调整建堆
void HeapSort(int* arr, int n)
{
	for (int i = (n-1-1)/2; i >= 0; i--) {
		AdjustDown_s(arr,n,i);//建小堆
        //AdjustDown_b(arr,n,i);//建大堆
	}
}

3.4.3 堆排序的时间复杂度分析

向下调整建堆的时间复杂度为O(N),具体推导流程如下:

现在同样也来推理一下使用向上调整建堆的时间复杂度:

所以对比这两种建堆方法可以看出,向下调整建堆的时间复杂度明显比向上调整建堆小 的多,关键之处就在于向下调整建堆可以不对最后一层进行调整,但是向上建堆需要对最后一层的节点进行调整,而最后一层的节点数目是最多的(满二叉树的情况下),将近占了总节点数的一半。或者从另外一个角度看:

  • 对于向下调整建堆的T(h):节点数量多的层 x 调整次数少;节点数量少的层 x 调整次数多。
  • 对于向上调整建堆的T(h):节点数量多的层 x 调整次数多;节点数量少的层 x 调整次数少。
  • 所以向下建堆的T(h)明显比向上建堆的T(h)小。

进行完堆的调整之后,接下来就是进行降序排列或者升序排列,这个过程的时间复杂度其实和向上调整建堆是一样的,为O(N*logN)。推导过程也和向上调整建堆类似,所以具体过程就不写了。

3.5 Top-K问题

对于方法2的代码实现:

cpp 复制代码
void CreateNDate()
{
    //造数据
    int n = 10;
    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;
        fprintf(fin, "%d\n", x);
    }
    fclose(fin);
}

void TestHeap3()
{
    int k;
    printf("请输入k>:");
    scanf("%d", &k);
    int* kminheap = (int*)malloc(sizeof(int) * k);
    if (kminheap == NULL)
    {
        perror("malloc fail");
        return;
    }
    const char* file = "data.txt";
    FILE* fout = fopen(file, "r");
    if (fout == NULL)
    {
        perror("fopen error");
        return;
    }
    
    // 读取文件中前k个数
    for (int i = 0; i < k; i++)
    {
        fscanf(fout, "%d", &kminheap[i]);
    }
    
    // 建K个数的小堆
    for (int i = (k-1-1)/2; i >= 0; i--)
    {
        AdjustDown(kminheap, k, i);
    }
    
    // 读取剩下的N-K个数
    int x = 0;
    while (fscanf(fout, "%d", &x) > 0)
    {
        if (x > kminheap[0])
        {
            kminheap[0] = x;
            AdjustDown(kminheap, k, 0);
        }
    }
    
    printf("最大前%d个数:", k);
    for (int i = 0; i < k; i++)
    {
        printf("%d ", kminheap[i]);
    }
    printf("\n");
}

int main()
{
    CreateNDate();
    //TestHeap3();
    return 0;
}

当然,运行代码之后,我们并不能确定所找出的前K个元素到底是不是所有元素中最大(最小)的,为了解决这个问题,我们可以自行进行测试,我们在制造随机数据的环节中可以先限定随机数据的大小上限,利用取模操作便可以实现:

cpp 复制代码
int x = (rand() + i)/10000000;

这里对随机数据取模了10000000,这样产生的数据的大小都不会超过10000000,(当产生的数据大于10000000时,会被进行取模,最后得到的是取模后的余数,当随机数据小于10000000时,数据的值不会被改变),随后在数据文件中随机修改几个数据(比如说修改5个),使得数据大小大于10000000,然后执行程序,看看显示的前5个最大(最小)的数据是不是之前修改过的数据,如果是则说明程序的运算没有问题,如果不是就说明有问题,这就是一个常用的检测程序的方法。

如果我们修改数据之前不对随机数据进行取模操作,而是直接修改,那么程序运行后显示的数据不一定是被我们修改过的那些数据,而可能是随机生成的数据,这样的话测试就没有意义了,还是判断不了程序有没有问题,所以必须先进行取模操作再进行数据修改!

相关推荐
星轨初途2 小时前
牛客小白月赛126
开发语言·c++·经验分享·笔记·算法
leoufung2 小时前
动态规划DP 自我提问模板
算法·动态规划
爱编程的小吴2 小时前
【力扣练习题】热题100道【哈希】560. 和为 K 的子数组
算法·leetcode·哈希算法
じ☆冷颜〃2 小时前
基于多数据结构融合的密码学性能增强框架
数据结构·经验分享·笔记·python·密码学
Swift社区2 小时前
LeetCode 463 - 岛屿的周长
算法·leetcode·职场和发展
皮卡蛋炒饭.2 小时前
宽搜bfs与深搜dfs
算法·宽度优先
Coder_Boy_2 小时前
基于SpringAI的智能AIOps项目:部署相关容器化部署管理技术图解版
人工智能·spring boot·算法·贪心算法·aiops
王哈哈^_^2 小时前
【完整源码+数据集】道路拥塞数据集,yolo道路拥塞检测数据集 8921 张,交通拥堵识别数据集,路口拥塞识别系统实战教程
深度学习·算法·yolo·目标检测·计算机视觉·分类·毕业设计
leoufung2 小时前
LeetCode 64. Minimum Path Sum 动态规划详解
算法·leetcode·动态规划