数据结构初阶---二叉树---堆

一、树

1.树的概念

树是一种非线性的数据结构,由n(n≥0)个有限结点组成的一个有层次关系的集合。形状类似一棵倒挂的树,根朝上,分支向下。

根结点没有前驱结点,可以有n(n≥0)个后继结点。

其余结点被分为M个互不相交的集合,每一个集合都是一棵子树,每棵子树的根节点均有1个前驱结点,n(n≥0)个后继结点。因此树是递归定义的。

注:除根外的结点被分为互不相交的集合,如果相交,就不是树,而是图了。

子树之间存在交集,不属于树形结构,某结点存在多个父结点即它的前驱结点>1,也不属于树形结构。

2.与树有关的概念

节点的度 :一个节点含有的子树的个数称为该节点的度;
叶节点(终端节点) :度为0的节点称为叶节点;(重要)
分支节点(非终端节点) :度不为0的节点;
父节点(双亲结点) :若一个节点含有子节点,则这个节点称为其子节点的父节点;(重要)
子节点(孩子节点) :一个节点含有的子树的根节点称为该节点的子节点;(重要)
兄弟节点 :具有相同父节点的节点互称为兄弟节点;
树的度 :一棵树中,最大的节点的度称为树的度;
节点的层次 :从根开始定义起,根为第1层,根的子节点为第2层,以此类推;(重要)
树的高度或深度 :树中节点的最大层次;(重要)
堂兄弟节点 :双亲在同一层的节点互为堂兄弟;
节点的祖先 :从根到该节点所经分支上的所有节点;(重要)
子孙 :以某节点为根的子树中任一节点都称为该节点的子孙。 森林:由m(m>0)棵互不相交的树的集合称为森林;

注:关于树的层次,一般而言是从1开始,即根所在层次为1,如果题目给定从0开始,那就从0开始,否则默认从1开始层次,因为对于无结点树而言,层次刚好是0,从0开始那么无结点树层次就为-1。

3.树的表示

如果我们使用数组来表示树,那么我们一般采用数组ArrayChild来存储孩子结点

cpp 复制代码
#define N 6 //假设已知树的度为6
struct TreeNode
{
    TreeNodeTypeData val;
    struct TreeNode* ArrayChild[N];
};

但是,如果这样来表示树,那么相当于每一个分支结点,都有N个数组空间(N是树的度的值),但是树的分支结点不一定都有N个子结点,这样的表示就太过于浪费空间了。

如果用顺序表来表示树型结构,

cpp 复制代码
struct TreeNode
{
    TreeNodeTypeData val;
    SeqList Childlist;
};

那么每个孩子结点都是一个顺序表,但是需要我们去构建一个顺序表,在C语言阶段也是非常费心费力的。

左孩子右兄弟表示法

树形结构的最优表示方法是左孩子右兄弟表示法****。

树形结构中创建两个指针,左孩子指针LeftChild与右兄弟指针RightBrother

cpp 复制代码
struct TreeNode
{
    TreeNodeTypeData val;
    struct TreeNode* LeftChild;
    struct TreeNode* RightBrother;
};

我们通过每一次的根节点访问左孩子,就可以通过左孩子结点访问右兄弟指针找到该层次的其他结点;如果想访问下一层次,那么现在的左孩子结点访问它的左孩子结点,通过它的左孩子结点访问右兄弟指针能够找到其他所有结点。

上图中左孩子右兄弟表示形式里,未标识出来的左孩子LeftChild指针都应该指向NULL。

由此我们只需要访问上一层次某一棵子树的根节点的左孩子结点就可以遍历该层次属于该子树的所有结点。

如A的左孩子为B,A结点访问成员LeftChild得到B结点,B结点访问成员RightBrother就能够访问到同层次属于A孩子的C与D结点。如B的左孩子是E,那么通过B访问到E结点就可以遍历E、F结点。

4.树的实际运用

Linux树状目录结构。

Windows文件目录多盘形式---森林。

二、二叉树

1.概念

二叉树的结点的度均≤2(即每个结点的子结点不超过2个),同时二叉树区分左右子树(即区分左右结点),是有序树。

二叉树是特殊的树。

2.特殊的二叉树

①满二叉树

二叉树的所有结点的度都是2,那么就是满二叉树。对于满二叉树而言,每一层的结点数都达到了最大值。

假设满二叉树的深度为h,结点数为N,那么存在关系:2^h - 1 = N <==> h = log(N+1) (以2为底)

反过来说,如果一个二叉树的层数为K,它的结点数为2^K-1,那么这个二叉树就是满二叉树。

②完全二叉树

完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。

对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。满二叉树是一种特殊的完全二叉树。

通俗来讲,除去最后一层的其它层结点数达到最大值,最后一层结点数不确定但是从左到右一定有序的二叉树就是完全二叉树。

对于完全二叉树来说,由于最后一层的结点有序,但是不确定最后一层结点个数,因此总的结点数N是一个范围,最小的时候最后一层只有1个结点,最大的时候是满二叉树。

那么N的范围为:[2^(h-1)-1+1 , 2^(h) -1] 化简--->[2^(h-1) , 2^(h) -1]

注意,对于完全二叉树来说,最后一层的结点一定是有序的!结点一定是从左至右增加的。

3.二叉树的性质

4.二叉树的存储结构

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

①顺序存储

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

顺序存储--->使用数组存储,只适用于完全二叉树。如果用数组存储的方式对任意二叉树,那么由于每个结点的度不同,会有许多数组空间的浪费,因此数组存储只适用于完全二叉树。

将完全二叉树的结点一层一层的存入数组中,

我们会发现,父子结点的下标是存在联系的。

②链式存储

对于不是完全二叉树和满二叉树的树形结构,我们采取链式结构来进行数据存储。

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

5.堆

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

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

简单的说,如果一个数组的内容满足堆的性质,那么堆就把这个数组数据看做一棵完全二叉树。

堆只分为小(根)堆和大(根)堆,小堆表示所有的父结点的数据都小于等于其子结点;大堆表示所有的父结点的数据都大于等于其子结点。除此之外都不是堆。

有序数组是不是堆?是,但是堆不一定是有序数组。

跟之前数据结构讲的栈不同于内存中的栈类似,

数据结构的堆是一种管理数据的结构,是完全二叉树;

而操作系统中的堆,指的是一个内存区域,这个区域中的空间可以通过malloc、calloc开辟、realloc扩容使用,最后free释放。

堆的物理结构是数组;逻辑结构是完全二叉树。

堆的意义:

1.堆排序---时间复杂度O(N*logN)

2.top k问题

对于堆的实现,我们想象自己操纵的是完全二叉树,但是我们实际上操纵的是数组。

数组实现堆

结构如下,我们来实现一下大(根)堆:

cpp 复制代码
//堆是由读取规定的---与栈和队列一样,堆顶开始读取然后删除
//大堆---父节点始终大于等于子节点
#define HeapDataType int
typedef struct Heap
{
	HeapDataType* a;
	int size;
	int capacity;
}Heap;

接口函数

①初始化HeapInit

void HeapInit(Heap* php);

cpp 复制代码
//初始化
void HeapInit(Heap* php)
{
	assert(php);
	php->a = NULL;
	php->capacity = 0;
	php->size = 0;
}
②销毁HeapDestroy

void HeapDestroy(Heap* php);

cpp 复制代码
//销毁
void HeapDestroy(Heap* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->capacity = 0;
	php->size = 0;
}
③插入HeapPush---O(logN)

void HeapPush(Heap* php, HeapDataType x);

在一个堆中插入一个新的数据x,那么我们就必须得将该数据移动到合适的位置,确保插入后新的数组仍然是一个大堆。

首先先写出扩容判断的代码以及插入的代码:

cpp 复制代码
	//检测容量---不用单独开接口,因为只有一个插入
	if (php->size == php->capacity)
	{
		int NewCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		HeapDataType* tmp = (HeapDataType*)realloc(php->a, sizeof(HeapDataType) * NewCapacity);
		if (tmp == NULL)
		{
			perror("realloc fail");
			exit(-1);
		}
		php->a = tmp;
		php->capacity = NewCapacity;
	}
	//插入数据
	php->a[php->size] = x;
	php->size++;

那么就差最后一步,判断是否需要更换位置:

我们知道,对于大堆而言,父节点的数据始终大于等于子节点的数据,对于目前插入在最后一层的新结点而言,我们需要将其与它的父辈、祖辈,与它的祖先进行比较,如果新结点的数据大于父节点,那么就需要将新结点与父节点交换,然后接着向上比较:

需要交换数据,那么我们封装为一个Swap函数用来交换数据:

cpp 复制代码
//交换函数
void Swap(HeapDataType* p1, HeapDataType* p2)
{
	HeapDataType tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

所以对于插入的值的比较,我们需要通过该结点找到其父结点,根据前面学到的堆的父子结点关系,我们知道,子节点下标child,(child-1)/2就能够得到父节点下标parent

最后的比较要到什么时候终止呢?如果最慢的情况,插入的数据比原根节点都要大,那么就要换到A结点处--->因此循环的终止条件应为child == 0 。那么我们将判断和移动数据的过程封装成一个函数UpAdjust(向上调整)。

向上调整算法UpAdjust

void UpAdjust(Heap* php, int child);

cpp 复制代码
    //向上调整算法
	UpAdjust(php, php->size - 1);

传入的child是插入结点的下标,由于前面插入后size自增1了,所以这个地方传进来的应该是size-1。

cpp 复制代码
//向上调整算法---最坏情况调整深度h次,而完全二叉树的h与logN相关---因此时间复杂度logN
void UpAdjust(Heap* php, int child)
{
	assert(php);
	int parent = (child - 1) / 2;//父节点下标为子节点-1再除2
	while (child > 0)
	{
		if (php->a[parent] < php->a[child])
		{
			Swap(&php->a[parent], &php->a[child]);
			child = parent;
			parent = (parent - 1) / 2;
		}
		else
			break;
	}
}
④删除HeapPop---O(logN)

void HeapPop(Heap* php);

在堆的实现中的删除操作,删除的是堆顶元素---即根结点元素。

但是我们又不能够直接进行覆盖操作,如果直接进行覆盖,一会导致原来的这个父子下标关系改变,摧毁了这个堆;二是数组的头删挪位覆盖时间复杂度为O(N)。

所以就有一种方法:我们先交换堆顶元素与最后一个元素,然后进行尾删--->就删除了堆顶元素。

对于数组结构而言,尾删是非常方便的,size自减1即可。然后这个时候,除了堆顶元素改变,其他位置上的元素均没有变化,其实类似于向上调整 ---我们实现一个向下调整算法DownAdjust。

cpp 复制代码
//删除堆顶结点---时间复杂度O(logN)
void HeapPop(Heap* php)
{
	assert(php);
	Swap(&php->a[0], &php->a[php->size - 1]);//交换--->尾删原堆顶
	php->size--;//尾删原堆顶
	//向下调整算法---O(logN),要将交换上去的结点向下调整
	DownAdjust(php->a, php->size, 0);
}

DownAdjust向下调整算法内,我们将堆顶元素与它的子节点元素中较大的进行判断,如果堆顶元素大,那么符合大堆,不用进行交换元素,反之,堆顶元素小于较大的子结点元素,那么交换二者,将交换后的处于子节点处的该元素继续与这个位置上它的子结点的较大元素进行比较。

向下调整算法DownAdjust

void DownAdjust(HeapDataType* a, int size, int parent);

cpp 复制代码
//向下调整---时间复杂度O(logN)
void DownAdjust(HeapDataType* a, int size, int parent)
{
	int child = parent * 2 + 1;
	while (child < size)
	{
		if ( child+1 < size && a[child] < a[child + 1])//小心越界
		{
			child++;
		}
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = 2 * child + 1;
		}
		else
			break;
	}
}

为了防止if else判断两个子节点谁大的代码冗长,我们选择使用了假设判断法,先将其中的左节点下标给child,然后再if判断如果左节点数据小于右孩子结点,那么将右节点下标给child,实际上就与child自增1没有区别。极端情况下,当我们判断两者大小时,可能出现child+1的越界访问的问题。 因此需要在if的条件中多加上一条child+1<size

找到更大的孩子节点、然后进行比较,这个整体是一个循环过程,因为交换到堆顶的数据最多需要进行深度h次交换,如果该数据足够小的话。循环进行条件就是chiild<size,即下标不能越界。在发现了该节点处的数据大于等于较大子节点的数据时,就满足了大堆性质,直接跳出循环即可。

⑤获取堆顶元素HeapTop

HeapDataType HeapTop(Heap* php);

cpp 复制代码
//获取堆顶元素
HeapDataType HeapTop(Heap* php)
{
	assert(php);
	assert(php->size);

	return php->a[0];
}

有关于获取堆顶元素的接口,我们一般与删除堆顶元素联合起来使用,大堆的堆顶元素读取然后删除,执行k次,就能够找到这个堆中前k大的数据。

同时如果我们也可以这样获取降序的全部数据--->大堆就能够获取降序,小堆能够获取升序数据。

⑥获取堆有效元素个数HeapSize

int HeapSize(Heap* php);

cpp 复制代码
//获取堆有效元素个数
int HeapSize(Heap* php)
{
	assert(php);

	return php->size;
}
⑦判断堆是否为空HeapEmpty

bool HeapEmpty(Heap* php);

cpp 复制代码
//判断堆是否为空
bool HeapEmpty(Heap* php)
{
	assert(php);

	return php->size == 0;
}

堆的应用

1.堆排序问题

时间复杂度O(N*logN)。

上面通过数组实现了堆,那么给定一个数组,我们当然可以将数组的数据插入的堆结构中,进行向上调整,然后通过获取堆顶数据,删除堆顶向下调整的小套餐来打印排序后的数据,但是这样的操作有两个缺点:①需要一个完整的堆结构②原数组其实没有改变

那么实际上不需要如上操作进行排序,堆排序可以对该数组重新排序。

升序--->①建立大堆②进行排序

大堆父结点大于子节点,为什么会升序?===>这就涉及到伪删除,我们利用到堆接口函数删除堆顶元素的实现,本质是将尾部数据与堆顶数据互换再删除堆顶数据,那么其实此时堆顶数据在末尾,只要我们不删除,就能够实现每一次的伪删除会将最大的数据移动到数组尾部。每一次互换之后我们需要进行向下调整,当然调整不会包括互换后的尾结点数据。

降序--->①建立小堆②进行排序

降序为什么建立小堆同上。

下面演示升序:

①升序演示---建立大堆

建堆有两种方式:我们可以通过将每个数组数据从起始开始进行向上调整;也可以从尾结点的父结点开始进行向下调整。目的都是为了将数组数据排放成堆的形式。

从起始开始向上调整:时间复杂度O(N*logN)

cpp 复制代码
	//数组建堆---
	//方法一:第一个数据成堆,其他数据依次入堆然后向上调整 O(N*logN)
	for (int i=1; i < n; i++)
	{
		UpAdjust(a, i);//数据依次向上调整成大堆
	}

从尾结点的父结点开始向下调整:时间复杂度O(N)

cpp 复制代码
	//方法二:从最后一个结点的父结点开始,倒着对所有非叶子结点进行向下调整 O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		DownAdjust(a, n, i);
	}

起始我们也能看出来时间复杂度差在哪里,对于方式二而言,最后一层结点数最多,但是对于方式二却不用对它们进行任何次数的调整,因此时间复杂度比方式一要小。

②升序演示---伪删除/选数

将堆顶与尾部数据交换,然后传入DownAdjust向下调整的end每次遍历减1。第一次交换后传入的size应该是减1之后的===>即下面代码的end = n-1。

cpp 复制代码
	//伪删除---堆顶数据与尾部交换,size--,向下调整===>那么数组尾部就是最大数据 O(NlogN)
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		DownAdjust(a, end, 0);//依次向下调整
		end--;
	}

一个数据在向下调整最多层数h次,N个数据===>N*logN。

选数操作的时间复杂度O(N*logN)。

推排序升序数组:

cpp 复制代码
//堆排序
//堆排序是将给出的数组数据先排序成堆,然后再进行排序
//而不是将数据依次插入实现好的堆中
void HeapSort()//测试---堆排序
{
	int a[] = { 1,5,3,6,8,2,1,5,8 };
	int n = sizeof(a) / sizeof(a[0]);
	//数组建堆---
	//方法一:第一个数据成堆,其他数据依次入堆然后向上调整 O(N*logN)
	for (int i=1; i < n; i++)
	{
		UpAdjust(a, i);//数据依次向上调整成大堆
	}
	//方法二:从最后一个结点的父结点开始,倒着对所有非叶子结点进行向下调整 O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		DownAdjust(a, n, i);
	}
	//伪删除---堆顶数据与尾部交换,size--,向下调整===>那么数组尾部就是最大数据 O(NlogN)
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		DownAdjust(a, end, 0);//依次向下调整
		end--;
	}
	for (int i = 0; i < n; i++)
	{
		printf("%d ", a[i]);
	}
}
2.top k问题
思路一

前面接口函数中提到了top k问题的解决方法,我们可以通过将这些数据以大堆的形式全部插入到堆中,那么再循环k次来删除k次堆顶数据并获取前k个最大的数据。但是如果数据基数N非常大,那么我们需要为这个大堆开辟的空间是非常非常大的,举个例子,N=100亿--->100亿个整型--->约为400亿字节--->约为40G的内存大小,现如今普通人家的电脑内存大小,16-32G,无法完成,而且就算能够完成,也不适用。

思路二---最优解

在top k问题中,如果要在一个非常非常大的N个数据中,找出k个最大的数据,那么我们可以通过这样的办法实现:

创建一个k个数据的小堆,先将最开始的k个数据存入小堆中,然后在剩余的数据依次与小堆堆顶元素比较,如果大于堆顶元素,那么覆盖堆顶元素并向下调整,使其依然满足小堆。那么到最后,小堆中就是最大的k个数据了。这种方法是非常便捷的,时间复杂度为O(N*logk)即O(N),但是空间复杂度只有O(1),而思路一的空间复杂度达到了O(N),而且基本上难以实现。

时间复杂度O(N*logk)==O(N)。

那么通过最优解,就可以从N个数据中找k个最大的数据,假设我们在文件中保存1000000个随机值,然后通过堆来找出k个最大的值:

首先我们在文件中写入N=1000000个随机值:

cpp 复制代码
//TopK问题---在N个数据中找到K个最大的数据
void CreatNData()//在文件中创建1000000个数据
{
	FILE* fin = fopen("data.txt", "w");
	if (fin == NULL)
	{
		perror("fopen fail");
		return;
	}
	int n = 10000000;
	srand(time(0));
	while (n--)
	{
		int x = (rand() + n) % 1000000;//数据都是小于1000000的
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);
}

这里写入的数据模了1000000方便我们后续测试是否成功取出k个最大数据。

我们执行一次CreatNdatra函数,那么就能够创建这个data.txt文件并在其中写入1000000个数据。

由于我们需要取较大数据,那么就需要用小堆===>即原来适用于大堆的向上调整与向下调整算法需要进行细微的修改。

下面是小堆的向上向下调整算法(其实只需调整几个大于小于号)

cpp 复制代码
//小堆的向上向下调整算法
//向上调整算法---最坏情况调整深度h次,而h大概率等于logN相关---因此时间复杂度logN
void UpAdjust_lowheap(HeapDataType* a, int child)
{
	int parent = (child - 1) / 2;//父节点下标为子节点-1再除2
	while (child > 0)
	{
		if (a[parent] > a[child])
		{
			Swap(&a[parent], &a[child]);
			child = parent;
			parent = (parent - 1) / 2;
		}
		else
			break;
	}
}
//向下调整---时间复杂度O(logN)---小堆
void DownAdjust_lowheap(HeapDataType* a, int size, int parent)
{
	int child = parent * 2 + 1;
	while (child < size)
	{
		if (child + 1 < size && a[child] > a[child + 1])//小心越界
		{
			child++;
		}
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = 2 * child + 1;
		}
		else
			break;
	}
}

写一个函数PrintTopK,解决topk问题:

void PrintTopK(FILE* file, int k);

创建一个minheap数组存储k个数据,VS不支持变长数组,那么我们使用malloc开辟k个数据大小的空间给minheap。

cpp 复制代码
int* minheap = (int*)malloc(sizeof(int) * k);

第一步将该文件中的前k个数据存入小堆进行向上调整;

cpp 复制代码
	//读前k个数据建小堆
	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d", &minheap[i]);
		UpAdjust_lowheap(minheap, i);
	}

第二步将文件中剩余的N-k个数据依次遍历与堆顶数据比较,大于等于堆顶数据则替换堆顶数据然后进行向下调整使之依然成小堆。

cpp 复制代码
	//剩下N-K个数据依次遍历与堆顶数据比较,大于等于堆顶数据进入堆
	int x = 0;
	while (fscanf(fout, "%d", &x) != EOF)//先读到x中,若x大于等于堆顶,放入堆顶并向下调整
	{
		if (x >= minheap[0])
		{
			minheap[0] = x;
			DownAdjust_lowheap(minheap, k, 0);
		}
	}

fscanf读取结束返回EOF,那么循环条件可以直接写作如上图所示,将每次遍历的文件中的数据存入x中,x与堆顶数据进行判断,若大于等于堆顶数据,替换堆顶数据并向下调整。

注:scanf函数以及fscanf等函数,默认遇到空格或者换行\n终止。

最后我们可以打印minheap来观察数据,记得最后需要释放堆中开辟的空间以及关闭文件。

cpp 复制代码
	for(int i=0;i<k;i++)
	{
		printf("%d ",minheap[i]);
	}
	free(minheap);
	minheap = NULL;
	fclose(fout);

前面手动添加了取模1000000,我们可以通过手动修改data.txt中的5个数据使其大于1000000,再次执行程序来观察最大值的变化,从而判断程序是否正确无误。

相关推荐
dundunmm1 分钟前
机器学习之交叉熵
人工智能·算法·机器学习·评估方法
掘根18 分钟前
动态规划一
算法·动态规划
机器视觉李小白20 分钟前
VisionPro 机器视觉控件篇 之 CogBlobTool 斑点工具
图像处理·人工智能·笔记·机器视觉·康耐视visionpro
.ccl23 分钟前
第n年共有多少头母牛(重写)
算法
蓝本生23 分钟前
STM32标准库学习之环境搭建
stm32·嵌入式硬件·学习
垂杨有暮鸦⊙_⊙1 小时前
高等动力学中的正则变换
笔记·学习·高等动力学
尚雷55801 小时前
[ClickHouse 运维系列] 数据 TTL 学习笔记整理
笔记·学习·clickhouse
码到成龚2 小时前
SQL server学习05-查询数据表中的数据(上)
数据库·sql·学习
@Dream-fennel2 小时前
数据结构强化篇
java·数据结构·算法