数据结构-二叉树的顺序存储与堆(堆排序)

什么是树

树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
在这里插入图片描述
在树中有几个名词,我现在画一个树然后我们来对照着理解一下这些名词分别指什么
在这里插入图片描述
根节点:树中有一个特殊的节点,没有父节点,称为根节点。

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

我们在判断一个结构是否是树时,有几点需要注意:

1.树的子树是不能相交的。
2.除了根节点外,每个节点有且只有一个根节点。
3.N个节点的树有N-1条边。

树的表示方法

我们刚刚了解了树的结构是怎样的,那么我们要怎样来表示一个树呢?
树结构相对线性表就比较复杂了,要存储表示起来比较麻烦,实际中树有很多种表示方式,如:双亲表示法,孩子表示法、孩子兄弟表示法等等。我们这里就简单的了解其中最常用的孩子兄弟表示法。

孩子兄弟表示法也叫左孩子右兄弟表示法,及一个节点由三个成员组成,一个用来存放数据,还有两个指针,分别指向节点自己的第一个孩子与兄弟,其结构如图:
在这里插入图片描述

typedef int DataType;
struct Node
{
	struct Node* _firstChild1;  // 指向第一个孩子结点
	struct Node* _pNextBrother; // 指向其下一个兄弟结点
	DataType _data;             // 结点中的数据
};

二叉树

什么是二叉树

二叉树就是一种特殊的树,其最多只有两个子树,分别为左子树和右子树。
二叉树的特点:

  1. 每个结点最多有两棵子树,即二叉树不存在度大于2的结点。
  2. 二叉树的子树有左右之分,其子树的次序不能颠倒。

在这里插入图片描述
上面的这棵树因为长的比较奇特,正好类似于我们二叉树的概念。

特殊的二叉树

二叉树有两种特殊的类型

  1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是(2^k) -1 ,则它就是满二叉树。
  2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
    在这里插入图片描述
    根据定义我们就可以计算 ,一个满二叉树
    假设有k层,那么每层的节点个数就是2(k-1)个。总的节点个数为2k-1个。
    如果我们知道总的节点数为N,那么我们就可以根据N计算树的深度k=log₂(N+1)。
    除此之外,我们还能发现一条规律,在任意一个二叉树中,度为0的节点的个数比度为2的节点的个数多一个。

二叉树的顺序存储

二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
顺序结构就是用数组来存储二叉树,链式结构就是用链表,这里我们先学习顺序结构,链表结构我会在下一篇详细讲解。

普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
为什么说普通二叉树会造成空间浪费呢,我们来看下面的图就明白了
在这里插入图片描述
顺序存储就是在数组中依次存储二叉树的每一层的节点。
在上面的两个二叉树中,我们用绿色的节点表示有效节点,空的节点表示空,同样是存储A~G六个元素,我们使用完全二叉树的结构在数组中存储就会比右边非完全二叉树的数组使用的空间少,因为我们可以挨个利用到我们数组的空间,但是非完全二叉树中我们为了表示节点位置的不同,就必须浪费掉一部分空间,所以说只有完全二叉树适合使用顺序存储。

堆的概念

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

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

向下调整算法

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

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

根据小堆的概念,根节点应该是最小的,所以我们这个小堆的调整分为以下几步:
1.首先选出根节点的两个子节点,找出比较小的那一个与根节点进行比较,如果小的子节点比根小,就交换他们两个。
2.把刚刚进行了交换的哪个子节点作为新的父节点,再与他的两个子节点进行比较,选出小的哪个换到父节点的位置。
3.重复上面的操作,直到我们调整到一个节点时发现它的子节点都为空,说明我们已经调整到了树的最底层,就不用调了。还有一种情况就是在调整过程中,我们发现父节点比两个子节点都小,说明这时已经符合小堆的情况,那我们就也可以跳出来了。
下面是刚刚调整的流程图:
在这里插入图片描述

下面我们来写代码实现一下这个算法

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++;
		}
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

我们可以发现,在数组中如果我们知道了一个父节点的下标,那么它的两个孩子的下标就是parent2+1与parent2+2。
1.首先我们先拿到父节点的位置,然后我们通过父节点找到它的第一个子节点,我们现在还不知道它的两个子节点那个更小,所以我们先选择第一个子节点,然后与第二个进行比较,最后把小的哪个当作与它比较的子节点。
2.判断孩子的位置是否还是数组内的元素,即孩子是否存在。
3.我们寻找的是左孩子,如果左孩子存在,还要判断一下右孩子是否存在,才能让左孩子与有孩子进行比较。(注意:这里的判断一定要写在比较之前,因为与操作符是从前向后运算的,所以如果我们先进行了比较,假如右孩子不存在,那就会访问到数组外的元素)。
4.找到小的哪个孩子,然后让他与父节点比较,如果子节点还小于父节点,就交换他们两个,然后重新置换父节点与子节点。
5.如果父节点比子节点小,那就说明现在的堆已经符合小堆的条件,就跳出循环。

堆的创建

通过刚刚的堆的调整算法,我们实现了把一个非小堆调整成一个小堆,但是我们刚刚的调整是有条件的,即根节点的两个子树都必须是小堆,这个条件就比较苛刻了,假如我们想要把一个数组建成一个堆,那数组中的值能否让子树都保证是小堆显然是不现实的,那么我们就要想办法让根节点的两个子树符合条件。
如何让子树变成小堆呢?我们可以把我们的目光放小,从最小的哪个子树开始,根据我们刚刚的代码,我们发现如果我们要把最后的那个有左右子树的根节点调整为小堆的话,我们只需要比较它与它的两个孩子就好了,当我们调整好了一个小堆,再以它的兄弟作为根进行调整,这样完成后它的夫节点就符合两个子树都是小堆的规定了,然后我们就可以使用同样的算法再调整他们的父节点。
我们发现我们可以以每一个根节点为根,从下往上把他们逐渐调整成小堆。
那么我们如何找到最后一个节点呢,我们发现子节点与父节点的下标还有这样的关系:parent=(child-1)/2,那么我们就可以通过这个公式,找到数组最后一个元素的父节点,同理,我们就可以从后向前挨个找到所有的父节点。
代码实现

int main()
{
    int a[]={15,18,28,34,65,49,49,25,37,27};
    int n=sizeof(a)/sizeof(a[0]);
    for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a,n,i);
	}
	return 0;
}

通过这个代码,我们就实现了把一个数组建成小堆的目的。

堆的接口实现

下面我们就会用代码实现一个堆,并实现它的主要接口
首先先创建一个堆的结构体变量,因为我们要让堆的大小可变,所以应该有三个成员,一个是数组,一个是有效元素的个数,还要一个是创建的空间的容量。

typedef int HPDataType;
struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
};
typedef struct Heap HP;

假设我们要创建一个大堆,那么我们肯定要用到刚刚的向下调整算法,我们只需要对它进行一点小小的改变,就可以实现功能

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++;
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
			break;
	}
}

我们只需要找到两个孩子较大的哪个,然后与父节点比较大的换到上面即可。

堆的构建接口

void HeapInit(HP* php,HPDataType* a, int n)
{
	assert(php);
	php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if (php->a == NULL)
	{
		printf("malloc fial\n");
		exit(-1);
	}
	memcpy(php->a, a, sizeof(HPDataType) * n);
	php->size = n;
	php->capacity = n;
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(php->a, php->size, i);
	}
}

1.在我们拿到需要建堆的数组和堆的地址后,我们先要根据堆的大小开辟一块空间,然后把数组的内容拷贝到刚刚开辟的空间中。
2.根据堆的空间改变有效数据的大小与容量。
3.通过刚刚建堆创建方法,把数组的内容调整成一个大堆。

堆的销毁接口

void HeapDestroy(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->size = php->capacity = 0;
}

1.释放掉开辟的空间,然后把容量和有效数据置空即可。

插入一个数据

我们要在堆里插入一个数据,但是要保证我们插入完后该数组还是一个堆,我们可以把这个数据插入到数组的尾部,然后再对数组进行调整,把它调整为一个堆。
而插入完成后的数组与之前的数组唯一不同的地方就是在尾部多了一个数据,我们重新进行建堆的话又有点太麻烦了,所以我们可以再写一个向上调整的算法

void AdjustUp(int* a, int child)
{
	int parent = (child - 1) / 2;
	while (child)//-1/2不是-0.5是0,整数运算抛弃小数。
	{
		if (a[parent] < a[child])
		{
			Swap(&a[child], &a[parent]);
			{
				child = parent;
				parent = (child - 1) / 2;
			}
		}
		else
			break;
	}
}

1.我们拿到了孩子的下标,然后根据孩子找到父节点。
2.当孩子>0时,就拿孩子与其父节点进行比较,因为我们建立的是大堆,所以如果孩子比父节点大,就把孩子换上去。
3.完成后就重新调整孩子与父亲的位置,重复刚刚的步骤。
(需要注意的是,当我们的孩子一路交换到数组头部时,比如孩子经过运算为0,那么它的父节点的坐标就变成了(0-1)/2,应该是-0.5,但是在计算机中整数的运算要抛弃小数,所以结果就变成了0,这时再进行循环时子节点就不符合循环的条件了,就会跳出循环,同时也代表已经调整到顶上了,调整完成。)
4.发现父节点大于子节点时,或者调整到顶时,就代表调整结束,跳出循环。

当我们有了这样一个算法,我们就可以着手完成插入接口了

void HeapPush(HP* php, HPDataType x)
{
	if (php->size == php->capacity)
	{
		HPDataType* tmp = realloc(php->a, php->capacity * 2 * sizeof(HPDataType));
		if (tmp == NULL)
		{
			printf("realloc fail\n");
			exit(-1);
		}
		php->a = tmp;
		php->capacity *= 2;
	}
	php->a[php->size] = x;
	php->size++;
	//向上调整
	AdjustUp(php->a, php->size - 1);
}

1.首先判断数组是否需要增容,如果需要就增容。
2.把数组的最后一个元素置为指定值,有效元素个数加一。
3.通过刚刚的向上调整算法,对刚刚插入进来的数进行向上调整,使数组满足大堆。

删除堆顶的元素

void HeapPop(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.把堆顶的元素与堆的最后一个元素交换位置,然后让有效数据个数减一,即删除了堆的最后一个数。
3.再对堆进行向下调整,使它符合堆的规定。

返回堆顶的数据

HPDataType HeapTop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	return php->a[0];

1.先声明一下堆的不为空。
2.直接返回堆顶,即数组第一个元素即可。

堆的大小与判空

int HeapSize(HP* php)
{
	assert(php);
	return php->size;
}
bool HeapEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}

1.堆的大小就返回size的值即可。
2.判空就判断size的值是否为空即可。

堆的打印

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

1.可以使用for循环像打印数组的方式一样打印堆。
2.打印数组的方式可能不够直观,我们可以使每打印一行就换行
定义一个记录每行元素个数的变量levelSize,从一开始,一个计数变量,从0开始,打印元素时,每打印一个数,计数就加一,当计数和行数相同时,就打印一个换行符,然后让levelSize变为原来的两倍(完全二叉树的性质,每行的节点个数最多是上一行的两倍),计数变量置为0,再重复打印。

堆的全部接口代码

Heap.h

#pragma once
#include <stdlib.h>
#include <stdio.h>
#include <assert.h>
#include <stdbool.h>
#include <string.h>

typedef int HPDataType;
struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
};
typedef struct Heap HP;
void AdjustDown(int* a, int n, int parent);
void AdjustUp(int* a, int n, int child);
//大堆
void HeapInit(HP* php,HPDataType* a, int n);
void HeapDestroy(HP* php);
//插入一个数据,但是任然保持是堆
void HeapPush(HP* php, HPDataType x);
//删除堆顶的数据,但是任然保持是堆,所以可以找到次大的数据
void HeapPop(HP* php);
HPDataType HeapTop(HP* php);
int HeapSize(HP* php);
bool HeapEmpty(HP* php);
void HeapPrint(HP* php);

Heap.c

#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"

void Swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

void AdjustUp(int* a, int child)
{
	int parent = (child - 1) / 2;
	while (child)//-1/2不是-0.5是0,整数运算抛弃小数。
	{
		if (a[parent] < a[child])
		{
			Swap(&a[child], &a[parent]);
			{
				child = parent;
				parent = (child - 1) / 2;
			}
		}
		else
		{
			break;
		}
	}
}

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++;
		}
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapInit(HP* php,HPDataType* a, int n)
{
	assert(php);
	php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if (php->a == NULL)
	{
		printf("malloc fial\n");
		exit(-1);
	}
	memcpy(php->a, a, sizeof(HPDataType) * n);
	php->size = n;
	php->capacity = n;
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(php->a, php->size, i);
	}
}
void HeapDestroy(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->size = php->capacity = 0;
}
//插入一个数据,但是任然保持是堆
void HeapPush(HP* php, HPDataType x)
{
	if (php->size == php->capacity)
	{
		HPDataType* tmp = realloc(php->a, php->capacity * 2 * sizeof(HPDataType));
		if (tmp == NULL)
		{
			printf("realloc fail\n");
			exit(-1);
		}
		php->a = tmp;
		php->capacity *= 2;
	}
	php->a[php->size] = x;
	php->size++;
	//向上调整
	AdjustUp(php->a, php->size - 1);
}
//删除堆顶的数据,但是任然保持是堆,所以可以找到次大的数据
void HeapPop(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);
}
HPDataType HeapTop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	return php->a[0];
}
int HeapSize(HP* php)
{
	assert(php);
	return php->size;
}
bool HeapEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}

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

堆排序

堆排序是一种非常高效的排序算法。因为我们堆的性质,堆顶的元素是最大的或者最小的,所以我们可以利用这一个特点来对一个数组进行排序。
当我们要使用堆排序是,假如我们要排一个升序,那么我们在建堆方面有两个选择,大堆或小堆,如果我们选择小堆,通过堆选择出最小的元素,那我们选择次小的元素就只能用堆顶后面的元素再进行重新建堆,那我们第一次建堆后的父子关系就要重新排列,我们发先,这样的话我们每找一个数就要建一个堆,而建堆我们又要向上遍历调整,所以时间复杂度为O(N2),非常低效,所以这种选择显然是不正确的。

那么我们就只能选择大堆了,那么大堆如何拍升序呢?
我们可以在刚刚的删除堆顶元素接口找到灵感,在我们建好堆后,这时堆顶的元素就是最大的,我们可以让数组最后一个元素与他进行交换,然后把这个元素踢出堆中,这时最大的数就在这个数组的尾部了,然后我们在对建好的堆进行向下调整,选出次大的元素,进行刚刚一样的操作,这样我们就从后往前拍好了一个升序。

我们分析之后就可以发现这种算法比刚刚的算法高效之处就在于我们选出了最大的数据后不用改变堆的结构,可以通过堆调整这种高效的方式帮我们依次找出最大的元素,而堆每次调整只需要进行深度次,如果一个堆的元素个数是 N,那么一次调整就只需要运算log₂(N+1)次,时间复杂度为O(log₂N),非常高效。

我们讲清楚了它的原理,下面就是堆排序代码的实现了

void HeapSort(int* a,int n)
{
    for(int 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--;
    }
}

其原理在上面已经讲解过了。

以上就是本篇的全部内容。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

c铁柱同学

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值