数据结构——二叉树(2)

211 阅读7分钟

如何把一棵二叉树存入计算机中呢?它有两种存储结构.

顺序存储结构

即使用数组进行存储. image.png

问:如何确定它们的父子关系呢?

可以通过下标来确定它们的父子关系

若parent为父节点下标,leftchild为左孩子下标,rightchild为右孩子下标.

leftchild = 2*parent + 1

rightchild = 2*parent + 2

parent = (child - 1) / 2

无论是左孩子下标还是右孩子下标,都使用统一的方式算父亲的下标。

因为整数的运算会忽略掉小数:

image.png

优点:可以用下标来确定它们的父子关系

缺点:只适合表示完全二叉树,不是完全二叉树会有空间的浪费

image.png

不管有多少个结点,为了保证使用下标来确定父子关系,

空出来的结点也必须分配空间,不存储有效数据.

顺序结构的实现 —— 堆

堆,是一种数据结构,它总是一棵完全二叉树,使用数组来存储.

堆的分类

小堆:父结点的值 <= 子结点的值

大堆:父结点的值 >= 子结点的值

image.png

堆的意义

1 堆排序 ——NlogN;

2 堆顶总是max或min,可以选出前k个数,解决topk问题.

堆的实现【以小堆为例】

1 定义堆的结构体

typedef int HeapDataType;//堆存的数据类型默认是int
typedef struct Heap
{
	HeapDataType* data;//动态开辟的数组空间
	int capacity;//容量
	int num;//已有的数据量
}Heap;

2 堆的初始化和销毁

#define InitialCapacity 5 //初始容量
void HeapInit(Heap* hp)
{
	assert(hp);
	HeapDataType* tmp = (HeapDataType*)malloc(sizeof(HeapDataType) * InitialCapacity);
	assert(tmp);
	hp->data = tmp;
	hp->capacity = InitialCapacity;
	hp->num = 0;
}

void HeapDestroy(Heap* hp)
{
	assert(hp);
	free(hp->data);
	hp->data = NULL;
	hp->capacity = hp->num = 0;
}

3 向堆中插入数据

A 数据满了,仍要插入数据要先扩容.

B 逻辑上应该插入到后面,保持完全二叉树,即数组尾插

C 插入之后还需要把数组整成小堆,插入的数据只会影响它的祖先结点 image.png

此时要使用向上调整算法

主要思路:插入结点与父结点比较,

若大于父结点则停止调整;(此时已经成为小堆)

若小于父结点则交换,然后更新插入结点的下标,重新比较它和它的新父结点

//最多调整高度次,所以时间复杂度是logN
void adjustUp(HeapDataType* a, int insert)//向上调整算法
{
	assert(a);
        //父结点的下标
	int parent = (insert - 1) / 2;
        //若插入结点调整到根结点,调整一定结束
	while (insert > 0)
	{
		if (a[insert] < a[parent])
		{
                        //交换两个结点
			Swap(&a[insert], &a[parent]);
                        //插入结点的下标更新
			insert = parent;
                        //更新插入结点的父结点
			parent = (insert - 1) / 2;
		}
		else
			return;
	}
}

void HeapPush(Heap* hp, HeapDataType x)
{
	assert(hp);
	//判断扩容
	if (hp->num == hp->capacity)
	{
		int newCapacity = 2 * hp->capacity;
		HeapDataType* tmp = (HeapDataType*)realloc(hp->data, sizeof(HeapDataType) * newCapacity);
		assert(tmp);
		hp->data = tmp;
		hp->capacity = newCapacity;
	}
	//尾插到数组
	hp->data[hp->num++] = x;
	//重新把数据整成堆
	adjustUp(hp->data, hp->num - 1);
}

4 删除堆顶的数据

image.png

思路:

将第一个结点与最后一个结点交换 image.png

堆中的数据量减1,可以直接将原来堆顶的数据清掉

image.png

此时堆顶结点的左子树仍是小堆,右子树仍然是小堆.

使用向下调整算法:

1 倒过来的新堆顶结点pour与最小的子结点比较

2 若小于该子结点说明pour小于它的两个子结点,调整完成

3 若大于该子结点则交换,更新pour的下标,继续和它的新最小子结点比较

//最多调整高度次,时间复杂度仍为logN
void adjustDown(HeapDataType* a, int num, int pour)//有num个元素,从倒过来的结点pour开始调整
{
	assert(a);
        //假设最小的孩子是左孩子
	int lesschild = pour * 2 + 1;
        //当pour是叶子结点时,调整结束
	while (lesschild <= num - 1)
	{
                //判断是否有右孩子
		if (lesschild + 1 <= num - 1)
		{
                        //假设错误,最小的孩子调整为右孩子
			if (a[lesschild] > a[lesschild + 1])
				lesschild++;
		}
		//若倒结点比最小的孩子大
		if (a[pour] > a[lesschild])
		{
			Swap(&a[pour], &a[lesschild]);
                        //更新倒结点下标
			pour = lesschild;
                        //更新倒结点的孩子
			lesschild = 2 * pour + 1;
		}
		else
			return;
	}
}
void HeapPop(Heap* hp)//删除堆顶的数据
{
	assert(hp);
	assert(hp->num > 0);//堆中数据量大于0
        //交换头尾结点
	Swap(&hp->data[0], &hp->data[hp->num - 1]);
        //删除尾结点
	hp->num--;
        //使用向下调整算法重新调成堆
	adjustDown(hp->data, hp->num, 0);
}

注意

使用向下调整算法的前提是pour结点的左右子树都为大堆或小堆;

pour不一定总是堆顶,只要某个结点满足左右子树均是小堆或均是大堆,即可使用

意义

堆顶总是max或min。删除堆顶元素后,再把剩下的数调成堆,又能选出次大或次小的数

堆排序

堆排序:

先把传过来的数组建成堆,再通过删除堆顶的方式选出max或min.

问题:升序建小堆吗? image.png

所以升序建大堆,而降序建小堆.

建大堆可以选出最大的数到堆顶,此时“删除栈顶”的方式将它与最后一个数互换,

就把最大的数放到了最后一个位置,接着忽略这个位置,剩下的数向下调整成堆,

选出次大的数放到新的末尾,实现堆排序.

void HeapSort(int* arr, int n)
{
	assert(arr);
	//建堆,以建小堆为例,排降序
	int i = 0;
	/*for (i = 1; i < n; i++)
	{
		//向上调整建堆
                //从头遍历整个二叉树,将它的每个结点都依次进行向上调整,最后成小堆
                //将arr[i]向上调整
		adjustUp(arr, i);
	}*/
	for (i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		//向下调整建堆
                //为了保证左子树和右子树都是小堆,可以从最后一个非叶子结点开始调整
                //arr[n-1]——最后一个叶子结点
                //arr[[(n-1)-1]/2] —— 最后一个叶子结点的父结点
		adjustDown(arr, n, i);
                //传数组元素个数是保证向下调整的结束条件是待调整结点调到叶子结点
	}
	for (i = n - 1; i > 0; i-- )//当只有一个元素要排序时,即i == 0,循环结束
	{
                //交换首尾元素,将max放到末尾
		Swap(&arr[0], &arr[i]);
                //前面的i指的是数组下标
                //注意这里传递的i对函数指的是数组元素个数,向下调整时会忽略已经排好的max
		adjustDown(arr, i , 0);
	}
}

设树的高度为K.

向上调整建堆的时间复杂度计算

向上调整建堆时间复杂度是O(NlogN):

image.png

向下调整建堆的时间复杂的计算

向下调整建堆的时间复杂度是O(N):

image.png

topk问题

问题描述

在大量数据中 找前k个最大或最小的元素.

如世界500强,专业前10名,广东省最热门的10个餐厅…………

解决方案

设数据量为N,N远大于k

方案1:排序O(NlogN),若数据量特别大,排序不太可取;

方案2:建N个数的堆,取堆顶k次,删除堆顶(k-1)次:

取最大的前K个数据:建大堆.

向下调整建大堆O(N), Pop一次O(logN),

所以时间复杂度为O(N+k*logN)

空间问题:假设N非常大,如N是100亿,k是10,如何求解?

100亿个整数占用多少空间?

1G = 1024MB = 10241024KB= 10241024*1024byte = 10亿字节左右

所以大概占用40G左右。

内存不能同时存下这些数据,无法使用前两种方式解决topk问题.

方案3

主要思路:

建K个数据大小的堆,

让剩下的(N-k)个数据依次与堆顶的数比较,

若满足条件则替换堆顶的数,

从而让目标数进堆,让其它数出堆.

例:找前k个大的数

目标:让较大的数进堆,让小的数出堆,最后堆中的数一定是前k个大的数.

若建大堆,堆顶的数可能一开始就是所有数据的max,

那只能让比它小的数进堆。。。

建小堆,剩下的(N-k)个数与堆顶比较,

若大于堆顶的数则替换堆顶的数,(即较小数出堆,较大数进堆)

同时向下调整成新的堆,

最后堆里的数就是前k个大的数

void PrintTopk(int* data, int n, int k)
{
	//取前k个大的数
	assert(data);
	int* KMinHeap = (int*)malloc(sizeof(int) * k);
	assert(KMinHeap);
	int i = 0;
	//先用前k个数据建小堆
	for (i = 0; i < k; i++)
	{
		KMinHeap[i] = data[i];
	}
	for (i = (k - 1 - 1) / 2; i >= 0; i--)
	{
		adjustDown(KMinHeap, k, i);
	}
	//把剩下的(N-k)个数据依次和堆顶比较,若比堆顶的数据大则替换掉堆顶的数据
	for (i = k; i < n; i++)
	{
		if (data[i] > KMinHeap[0])
		{
			Swap(&data[i], &KMinHeap[0]);
			//向下调整重新调成堆
			adjustDown(KMinHeap, k, 0);
		}
	}
	for (i = 0; i < k; i++)
	{
		printf("%d ", KMinHeap[i]);
	}
	putchar('\n');
}

void testPrintTopk()
{
	//随机时间种子
	srand(time(0));
	int arr[1000] = { 0 };
	for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
	{
		//随机生成小于10000的数放到数组中
		arr[i] = rand() % 10000;
	}
	//给任意几个数组位置赋大于10000的值
	arr[99] = 10005;
	arr[50] = 19922;
	arr[577] = 99999;
	arr[578] = 88689;
	arr[55] = 22222;
	//选出前5个大的数
	PrintTopk(arr, sizeof(arr) / sizeof(arr[0]), 5);
}

image.png