数据结构(树、二叉树、堆)

树概念及结构

树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。树可以分为根和子树,子树是不相交的。

节点的度:一个节点含有的子树的个数称为该节点的度。度为0的节点叫叶节点,度不为0的节点叫分支节点

兄弟节点:具有相同父节点的节点互称为兄弟节点。

树的度:一棵树中的所有节点当中,度最大的节点的度称为树的度。      

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

树的高度或深度:树中节点的最大层次。 建议从1开始,因为当为空树的高度就是0。

节点的祖先:从根到该节点所经分支上的所有节点。 

子孙:以某节点为根的子树中任一节点都称为该节点的子孙。

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

一颗N个结点的树有N-1条边

补充知识:数组为什么要从0开始,因为a[i]相当于*(a+i),i只能从0开始。

//如何表示树节点的结构成了问题。因为我们并不知道树的度,
//哪怕知道了树的度,又不知道节点的度,定义的child子节点的指针会存在许多浪费。
//解决办法:兄弟孩子表达法。这时候并不存在指针的浪费,通过根节点就可以遍历整颗树
struct TreeNode
{
	int data;
	struct TreeNode* child;
	struct TreeNode* brother;
};

数据结构(树、二叉树、堆)_第1张图片

二叉树概念和结构

一棵二叉树是结点的一个有限集合,该集合由一个根节点加上两棵别称为左子树和右子树的二叉树组成,或者为空。二叉树不存在度大于2的结点;二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树。

数据结构(树、二叉树、堆)_第2张图片

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

完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。也就是完全二叉树的前N-1层是满的,最后一层可以不满,但必须从左到右连续排布。

//二叉树节点结构
struct TreeNode
{
	int data;
	struct TreeNode* left;
	struct TreeNode* right; 
};

对任何一棵二叉树,度为0的节点数一定比度为2的节点数多一个。对于完全二叉树,度为1的节点的个数不是0就是1。 

深度为h的满二叉树的结点数时2^h-1,所以对于具有n个结点的满二叉树的深度h=log(n+1)。

二叉树的存储结构

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

数据结构(树、二叉树、堆)_第3张图片

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

 堆(采用数组实现)

堆的概念

堆逻辑结构上是一个完全二叉树,但是(物理结构) 实际存储过程中是存储在一个数组当中的。

数据结构(树、二叉树、堆)_第4张图片

小堆:树中所有的父节点都小于等于孩子节点

大堆:树中所有的父亲节点都大于等于孩子

孩子与父亲下标的关系:leftchild=parent*2+1;rightchild=parent*2+2;parent=(child-1)/2

HP扩容函数 

typedef struct Heap
{
	HPDataType* a;
	int capacity;
	int size;
}HP;
if (php->capacity == php->size)
{
	//HPDataType* tmp = (HPDataType*)realloc(php->a, (php->capacity == 0 ? 4 : php->capacity * 2) * sizeof(HPDataType//该行代码没有下面两行方便,逻辑是一样的。
	int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
	HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));
	if (tmp == NULL)
	{
		perror("realloc fail");
		return -1;
	}
    php->a = tmp;//扩容成功了才进行赋值,而不是不管是否成功直接赋值,这样更好一些
	php->capacity = newcapacity;
}

HeapInit()和HeapDestroy()函数

void HeapInit(HP* php)
{
	assert(php);
	php->a = NULL;//初始化的时候有两种选择,一种是不开空间,一种是提前开部分空间
	php->size = php->capacity = 0;
}
void HeapDestroy(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->size = php->capacity = 0;
}

HPPush( )函数(AdjustUp( )函数向上调整函数)

HPPush( )函数实际上是像在往一个数组中(该数组已经是一个堆了),我们尝试的再插入一个数据,但是保持数组依旧还是一个堆,这时候的方法是从尾部插入,让后不断地和其父节点进行比较爬升,这个过程中数组的排序会被改变。

建立大堆的关键:通过AdjustUp( )函数,让节点和其父节点比较,如果比父节点大就像上调整

建立小堆的关键:通过AdjustUp( )函数,让节点和其父节点比较,如果比父节点小就像上调整

所以建立大小堆的逻辑在于HPPush( )函数里面的AdjustUp( )函数。

向上调整函数的要求是插入之前是一个堆

//向堆里面插入数据,直接尾插之后还要调整,调整过程中只会影响该节点的祖先。
//这个过程称为向上调整,尾插节点与其父节点比较并调整
void HPPush(HP* php, HPDataType x)
{
	assert(php);
	//扩容
	if (php->capacity == php->size)
	{
		//HPDataType* tmp = (HPDataType*)realloc(php->a, (php->capacity == 0 ? 4 : php->capacity * 2) * sizeof(HPDataType//该行代码没有下面两行方便,逻辑是一样的。
		int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));
		if (tmp == NULL)
		{
			perror("realloc fail");
			return -1;
		}
		php->a = tmp;
		php->capacity = newcapacity;
	}
	php->a[php->size] = x;
	php->size++;
    //向上调整
	AdjustUp(php->a, php->size - 1);//php->size - 1是最后一个结点的下标
}

//向上调整函数
//传入的是数组指针,和需要向上调整的孩子节点的下标
void AdjustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;
    //while (parent >= 0)//这种父节点的判断方式不好,因为当parent第一次为0的时候,进入循环,然后更新孩子结点child=parent=0,更新父亲结点parent=(child-1)/2=0,这下又得进入循环,然后走else也就是break出来,结果没有问题但是属于非正常结束。
	while (child > 0)//简单明了,孩子结点大于0就可以继续比较,一旦等于0就不用再比较了。
	{
		if (a[child] > a[parent])
		{
			swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;//之前写的是return,我感觉也没有问题
		}
	}
}

HPPop()函数(AdjustDown( )函数向下调整函数)

HPPop()函数是pop掉堆顶的元素,然后依然保持是一个堆。此时将堆的首尾元素swap,堆的size减1,堆顶的元素就pop掉了,然后将现在的堆顶元素向下调整就行。

保持大堆的关键:通过AdjustDown( )函数,让节点和其孩子节点比较,如果比孩子点小就像下调整

保持小堆的关键:通过AdjustDown( )函数,让节点和其孩子节点比较,如果比孩子节点大就像下调整

所以pop之后保持大小堆的逻辑在于HPPop()函数里面的AdjustDown( )函数。

向下调整函数的要求是左右子树都是堆

//删除堆顶的数据并且继续保持成一个堆,采用 
//1、根位置跟最后一个交换,方便删除
//2、向下调整,也就是跟它左右孩子当中较大的一个进行比较调整,然后再迭代进行
void HPPop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	swap(&(php->a[0]), &(php->a[php->size - 1]));//其实写成swap(&php->a[0], &php->a[php->size - 1也可以
	php->size--;
	AdjustDown(php->a, php->size, 0);
}
//向下调整,传入的是数组,数组的元素个数,以及需要进行调整的父节点的下标
void AdujustDown(HPDataType* a, int size, int parent)//这个代码有参考。注意还有一个parent的参数,因为向下排序不一定都是从0开始往下排列。
{
    //找孩子结点中较大的那一个
	int child = parent * 2 + 1;//假设左孩子大于右孩子
	while (child < size)
	{
		if (child + 1 < size && a[child] < a[child + 1])//上述假设错误进行调整,调整之前先判断右孩子是否存在。这种处理方法避免了要分别设置一个leftchild和一个rightchild。
		{
			child += 1;
		}
        // 1、孩子大于父亲,交换,继续向下调整
        // 2、孩子小于父亲,则调整结束
		if (a[parent] < a[child])
		{
			swap(&a[parent], &a[child]);
			parent = child;
			child = parent * 2 + 1;
		}
		else//else的情况不能省略,否则哪怕父亲是大于孩子的也会再次进入到循环
		{
			break;
		}
	}
	
}

建堆的方法

1.初始化一个数组,数组元素个数为零,通过HPPush()函数不断地向该数组的压入数据。

//堆的构建(给一个数组,来构建一个对)
//方法一,直接复用现有的函数,先init然后再不断地push时间复杂度太高了
void HeapCreate(HP* php, HPDataType* a, int n)
{
	assert(php);
	HeapInit(php);
	for (int i = 0; i < n; ++i)
	{
		HeapPush(php, a[i]);
	}
}
//另一种建堆方式,本质上和上面一样
int arr1[] = { 33,38,41,15,84,47,79,53,44,67,47,11,26,42,77 };
int arr2[12];
for (int i = 0; i < 12; i++)
{
    //一次读取一个数据
	arr2[i] = arr1[i];
    //对该数据进行向上调整
	AdjustUp(arr2, i);
}
for (int i = 0; i < 12; i++)
{
	printf("%d ", arr2[i]);
}

建堆的时间复杂度是:和上面一种方法恰恰相反,度越高的结点移动的次数越多,而高度越高的结点数量越多。时间复杂度是O(N*logN)。

2.直接将所有的数据存入数组当中,数组的size是n,则从下标parent=(n-1-1)/2开始向下调整,之所以可以这样处理是因为向下调整的方法关键在于左右子树都要求是堆,而叶子结点一定是堆,我们可以从倒数第一个非叶子结点的子树开始调整,一直调整到根结点,就可以调整成为一个堆。

//方法二:从尾节点开始逐个向下调整
void HPCreat(HP* php, HPDataType* a, int n)//这个代码有参考。
{
	php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if (php->a == NULL)
	{
		perror("malloc fail");
		exit -1;
	}
	memcpy(php->a, a, sizeof(HPDataType) * n);
	php->size = php->capacity = n;
	//建堆算法
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)//i = (n - 1 - 1)就是倒数第一个非叶子结点的下标
	{
		AdjustDown(php->a, n, i);
	}
}

建堆的时间复杂度:

假设树的高度时h:

数据结构(树、二叉树、堆)_第5张图片

建堆的时间复杂度是:N-log(N+1)≈O(N),该建堆的方法好处是时间复杂度低,因为高度越高的结点移动的次数越少,而高度越高的结点数量越多。

堆的用法

排序

首先通过上面的建堆算法建立一个大堆,此时堆顶的元素是最大的,然后swap堆顶和堆底的元素,size--,从堆顶开始指向一次向下调整元素之后堆顶就是次大的元素。升序是建大堆,降序是建小堆

void HeapSort(int* a, int n)
{
    //将数组整理称为一个大堆
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}
	for (int i = 0; i < n; ++i)
	{
		printf("%d ", a[i]);
	}
	//大堆建立完成之后,开始进行排升序操作
	int end = n - 1;
	while (end>0)
	{
		swap(&a[end], &a[0]);//将此时大堆中的最大的元素放置到最后
		AdjustDown(a, end, 0);//向下调整之后堆顶就是次大的元素。
		end--;
	}
	printf("\n");
	for (int i = 0; i < n; ++i)
	{
		printf("%d ", a[i]);
	}
}

TopK问题

(将所有的数据压入堆中,选择堆顶的数据然后再pop,继续反复)

数据结构(树、二叉树、堆)_第6张图片

方法二更加实际一些,因为现实过程中很少会有40G的内存。

HPPop的时间复杂度是logN

//求topk的问题并不一定要建一个堆,直接建一个容量为k的数组,装待排序队列的前k个元素,然后用向下调整函数将该数组整理成小堆(向下调整或者向上调整函数的形参并不是堆,而是数组)。然后遍历待排序队列的剩下元素,发现存在比堆顶元素大的就直接替换,然后从堆顶开始执行一次向下调整函数就将堆更新了一遍。
void testheap3()
{
	int minheap[5];
	//打开文件
	FILE* fout = fopen("data.txt", "r");
	if (fout == NULL)
	{
		perror("fopen fail");
		return -1;
	}
	int k = 3;
	//先读取k个数据
	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d", &minheap[i]);
	}
	//建堆
	for (int i = (k - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(minheap, k, i);
	}
    //遍历,寻找topk
	int val = 0;
	while (fscanf(fout, "%d", &val)!=EOF)
	{
		if (val > minheap[0])
		{
			minheap[0] = val;
			AdjustDown(minheap, k, 0);
		}
	}

二叉树(链式二叉树)

链式二叉树的增删查改没有太多的价值,因为若是用来存储数据,用顺序表和链表更好,链式二叉树存储数据的意义是搜索二叉树,搜索二叉树是左边小右边大。搜索二叉树搜索只需要logN次,此时的增删查改才会有意义。

二叉树的遍历

前序遍历/先根遍历:根、左子树、右子树

中序遍历/中根遍历:左子树、根、右子树

后序遍历/后根遍历:左子树、右子树、根

层序遍历:一层一层走,每层从左往右走 

//前序遍历
void PrevOrder(BTNode* root)
{
	if (root != NULL)
	{
		printf("%d ", root->data);
		PrevOrder(root->left);
		PrevOrder(root->right);
	}
	else
	{
		printf("NULL ");
		return;
	}
}

//中序遍历
void InOrder(BTNode* root)
{
	if (root != NULL)
	{
		InOrder(root->left);
		printf("%d ", root->data);
		InOrder(root->right);
	}
	else
	{
		printf("NULL ");
		return;
	}
}

//后序遍历
void PostOrder(BTNode* root)
{
	if (root != NULL)
	{
		PrevOrder(root->left);
		PrevOrder(root->right);
		printf("%d ", root->data);
	}
	else
	{
		printf("NULL ");
		return;
	}
}
//层序遍历
void LevelOrder(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root)
		QueuePush(&q, root);
	
	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		printf("%d ", front->data);
		QueuePop(&q);

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

		if (front->right)
		{
			QueuePush(&q, front->right);
		}
	}
	printf("\n");

	QueueDestroy(&q);
}

二叉树的重要子函数

二叉树所有节点个数
//计算节点的个数
int TreeSize(BTNode* root)
{
	if (root == NULL)
		return 0;

	static int size = 0;
	size++;
	TreeSize(root->left);
	TreeSize(root->right);
	return size;
}

 结果:数据结构(树、二叉树、堆)_第7张图片

存在问题: main函数执行第20行代码的时候,进入到TreeSize函数里面,并不会让size再次置为零,这是由于size设置为了局部静态成员变量。

//优化方法
int TreeSize(BTNode* root)
{
	if (root == NULL)
		return 0;
	else
		return 1 + TreeSize(root->left) + TreeSize(root->right);//整个函数也可以使用一个三目操作符
}
二叉树叶子节点的个数 
//计算叶子节点的个数
int TreeLeafSize(BTNode* root)
{
	if (root->left == NULL && root->right == NULL)
		return 1;
	else
		return TreeLeafSize(root->left) + TreeLeafSize(root->right);
}
//有问题,没有考虑到root为NULL的情况,为NULL的时候继续返回TreeLeafSize(NULL->left) + TreeLeafSize(NULL->right)有问题
int TreeLeafSize(BTNode* root)
{
	if (root == NULL)
		return 0;//考虑节点为空的情况
	else if (root->left == NULL && root->right == NULL)
		return 1;
	else
		return TreeLeafSize(root->left) + TreeLeafSize(root->right);
}
二叉树的高度 
//计算树的高度
int TreeHeight(BTNode* root)
{
	if (root == NULL)
	{
		return 0;
	}
	return 1 + (TreeHeight(root->left) > TreeHeight(root->right) ? TreeHeight(root->left) : TreeHeight(root->right));
}
//但是其时间复杂度太大了TreeHeight(root->left) 和 TreeHeight(root->right计算完之后比较大小,然后并没有保存结果,再去计算需要的TreeHeight(xxx),一直迭代进入下去,时间复杂度不仅仅是二倍的关系。所以返回值采用这种写法极大的浪费资源,二叉树越往下重复计算的次数越多。
//优化
int TreeHeight(BTNode* root)
{
	if (root == NULL)
	{
		return 0;
	}
	int leftheight = TreeHeight(root->left);
	int rightheight = TreeHeight(root->right);
	return leftheight > rightheight ? leftheight + 1 : rightheight + 1;
}
二叉树第k层节点的个数 
//求第k层的节点个数
//我的第k层=左子树的k-1层个数+右子树的第k-1层个数
int TreeKlevelSize(BTNode* root,int k)
{
	if (root == NULL)
	{
		return 0;
	}
	if(k == 1)
	{
		return 1;
	}
	return TreeKlevelSize(root->left, k - 1) + TreeKlevelSize(root->right, k - 1);
}
BinaryTreeDestory()函数
void BinaryTreeDestory(BTNode** root)//二叉树的销毁,采用后续遍历的方式。如果采用前序遍历,一上来就把root干掉了,这个时候root被free了,里面的值也被置成了随机值,就找不到root的左右子树了。这边采用二级指针的优势就是可以直接在destroy函数里面将root置空
{
	if (*root == NULL) 
	{
		return;
	}
	BinaryTreeDestory(&(*root)->left);
	BinaryTreeDestory(&(*root)->right);
	free(*root);
	*root = NULL;
}
//这样写也可以,采用一级指针,在函数外面将root置空
void BinaryTreeDestroy(BTNode* root)
{
	if (root == NULL)
	{
		return;
	}

	BinaryTreeDestroy(root->left);
	BinaryTreeDestroy(root->right);
	free(root);
}
//出了BinaryTreeDestroy函数再将root置空
root=NULL;
找二叉树中值为x的节点
//找节点x
BTNode* TreeFind(BTNode* root, BTDataType x)
{
	if (root == NULL)
	{
		return NULL;
	}
	if (root->data == x)
	{
		return root;
	}
	BTNode* tmp1 = TreeFind(root->left, x);
	if (tmp1)
		return tmp1;
	BTNode* tmp2 = TreeFind(root->right, x);
	if (tmp2)
		return tmp2;
	return NULL;
}

 数据结构(树、二叉树、堆)_第8张图片

结合代码,可知当我们找 TreeFind(root->left, x)(此时设root就是根节点),入返回值是NULL,就是已经遍历了左子树的所有子孙,但是没有找到符合的。加入返回值非NULL,证明左子树有符合要求的节点,并且可以根据代码的结构层层返回过了。

层序遍历

搜索二叉树

搜索二叉树如果走中序是一个升序数列。默认的搜索二叉树是不允许冗余的,所以如果插入一个已经存在的值时,会报错插入失败。普通二叉树在日常生活中价值不大,还不如用链表或者顺序表来存储数据。

二叉树的删除

首先要查找元素是否子啊二叉树当中,如果不存在就返回,否则要删除的节点可以分为一下四种情况:

1、节点无孩子结点(直接删除处理)

2、节点只有左孩子结点(托孤处理,即将左孩子结点连接到该节点的父结点上)

3、节点只有右孩子结点(托孤处理,即将有孩子结点链接到该节点的父结点上)

第一种情况可以合并到第2或者第3种情况当中,也即是节点的左孩子是空或者右孩子是空进行处理

4、节点左右孩子结点都有采用替代法

找到该结点左子树的最大结点maxLeft(也就是左子树最右边的值)或者右子树的最小值(也就是右子树的最左边的值)minRight,让后将maxLeft(或minRight)结点的key和该节点进行交换,然后再将maxLeft(或minRight)结点删除。

时间复杂度

一开始我们会觉得搜索二叉树的时间复杂度是O(logN),但是这是比较理想的情况,因为树有可能会长偏,所以搜索二叉树的时间复杂度是O(N),这也是我们需要优化的地方。所以产生了平衡搜素二叉树。然后就会引出AVL树和红黑树。

搜索二叉树的应用

Key模型就是处理在不在的问题,比如说门禁系统  。 而Key/Value模型是通过一个值查找另外一个值的问题,比如说中英文互译。

//Key/Value模型,主要是将BSTreeNode节点的结构稍加改动一下
template
	struct BSTreeNode
	{
		BSTreeNode* _left;
		BSTreeNode* _right;
		K _key;
		V _value;
		BSTreeNode(const K& key, const V& value)
			:_left(nullptr)
			, _right(nullptr)
			, _key(key)
			, _value(value)
		{}
	};
//虽然现在结点里面多了一个变量,只是增加了Insert这类函数的形参,其余像Find、Erase这类的函数依旧还是按照Key值来进行处理,不考虑Value。
template
	class BSTree
	{
		typedef BSTreeNode Node;
	public:
		bool Insert(const K& key, const V& value)
		{
			if (_root == nullptr)
			{
				_root = new Node(key, value);
				return true;
			}

			```````
		}

		Node* Find(const K& key)
		{
			```````
		}

		bool Erase(const K& key)
		{
			```````
		}
	```````
	private:
		Node* _root = nullptr;
	};

//采用Key/Value模型解决中英互译问题
void TestBSTree4()
{
	key_value::BSTree dict;
	dict.Insert("sort", "排序");
	dict.Insert("left", "左边");
	dict.Insert("right", "右边");
	dict.Insert("string", "字符串");
	dict.Insert("insert", "插入");
	dict.Insert("erase", "删除");
	string str;
	while (cin>>str)//可以采用ctrl+c结束循环,相当于发了一个终止,杀进程的信号,比较暴力。常规的方式采用的是ctrlL+z+换行就是正常结束,ctrlL+z会把流标志设计成失败也就是返回fals e。 
	{
		auto ret = dict.Find(str);
		if (ret)
		{
			cout << ":" << ret->_value << endl;
		}
		else
		{
			cout << "无此单词" << endl;
		}
	}
}
//采用Key/Value模型统计各个元素出现次数
void TestBSTree5()
{
	string arr[] = { "西瓜", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉", "梨"};

	key_value::BSTree countTree;
	for (auto str : arr)
	{
		//key_value::BSTreeNode* ret = countTree.Find(str);
		auto ret = countTree.Find(str);
		if (ret == nullptr)
		{
			countTree.Insert(str, 1);
		}
		else
		{
			ret->_value++;
		}
	}

	countTree.InOrder();
}

                                                                                                                                                                                                                                                                                                                                                                                      

你可能感兴趣的:(数据结构,算法)