(C或C++)大笨蛋也能看懂的数据结构 第一章 前置知识链表

  • 看本文章至少了解C语言基础:一直要学到指针,结构体才能算完

      这部分请自行学习,推荐视频:郝斌C语言
      网上也有同学推荐翁凯C,但我没有看过。    
      免责声明(免骂声明):
    该文章仅用于启发基础不好的同学,不能替代教材,
      也无法与好文章和好老师相提并论。对于学霸和基础很牢的同学,这篇文章可能没有价值,甚至可能误人子弟。
      文章中存在很多疏漏和不足之处,能力有限,欢迎学霸提供修改意见,定从善如流。
      本文仅供参考。  
      文章中展示的代码只是我学习时写下的笔记,并非精炼的算法,
      可能存在冗余和不够聪明的操作。这些代码仅供参考,不能经受深入的推敲。       
      全文仅作为抛砖引玉之用,希望读者在阅读后能更好地理解课本和其他教学视频。
      本章主要讲链表的知识。包括链表的创建,以及增删改查。
    

先来展示代码

   我是用C语言写的,C与C++一脉相承,一法通百法通,大家可以自行尝试用C++写。

 推荐大家直接复制到软件中看,文章中虽然用代码块框起来了,
 但仍旧是难以入目,而且这也不能运行,不如复制到软件中。
 我使用的是VS2022,但VScode,clion,甚至VC++什么的都可以,
 只要方便大家看代码就行,君子不器,没必要纠结工具。
c 复制代码
#include <stdio.h>
#include<malloc.h>
typedef struct List
{
	int data;//数据域
	struct List* pNext; //指针域
}LT, * PLT;
//第8行基于typedef。
//ST表示 struct List
//PST 表示 struct List *。
PLT Create(PLT pHead);
void Travel(PLT pHead);
bool Insert(PLT pHead, int val);
bool Delete(PLT pHead, int pos,int* pVal);
bool Change(PLT pHead, int pos, int val);
bool Search(PLT pHead, int pos, int*val);
//pos position:位置 val:值
int main(void)
{
	PLT pHead = NULL;
	pHead = Create(pHead);
	int val = 0;
	for (int i = 1;i <= 5; i++)//增加五个节点
	{
		Insert(pHead,  i);
		val++;
	}
	
	Delete(pHead, 1, &val);
	//此处删除1号节点测试
	printf("%d\n", val);
	Travel(pHead);
	Change(pHead, 2, 999);
	Search(pHead, 4, &val);
	printf("%d\n", val);
	Travel(pHead);
	return 0;
}

void Travel(PLT pHead)
{	
	PLT p = pHead->pNext;//头节点的指针域,指向第一个节点的指针(地址)
	while (NULL != p)//最后一个节点指针域为 NULL
	{
		printf("%d ", p->data);//节点数据域中的数据
		p = p->pNext;//下一个节点的指针域。
	}
	printf("\n");
	return;
}

/*
头节点:
首节点:第一个有效信息节点
尾节点:最后一个有效节点
头指针:指向头节点的指针变量
尾指针:指向尾节点的指针变量
如果希望通过一个函数来对链表进行处理,我们至少需要接收链表的哪些参数。
答案:只要头指针就行了
只要头指针就可以推算出链表中其他的所有参数
节点:数据|指向下一个节点的指针。
*/

/*
分类:
单链表
双链表:每个节点有两个指针域。

循环链表:能通过任意一个节点找到其他所有的节点。
非循环链表
*/
/*
 增删改查
*/
	
PLT Create(PLT pHead)
{
	pHead = (PLT)malloc(sizeof(LT));
	if(pHead!=NULL)
	{
		pHead->pNext = NULL;
		return pHead;
	}
	else
		return NULL;
}
bool Insert(PLT pHead,  int val)
{
	PLT pNew = (PLT)malloc(sizeof(LT));
	if (pNew == NULL)
		{
			// 处理内存分配失败的情况
			return false;
		}
	PLT pTail = pHead;
	while (pTail->pNext != NULL)
	{
		pTail = pTail->pNext;
	}
	pNew->data = val;
	pTail->pNext = pNew;
	pNew->pNext = NULL;
	//pTail = pNew;
	return true;
}
bool Delete(PLT pHead, int pos ,int* pVal)
//在第pos个节点删除一个节点!pos从1开始
{
	int i = 1;
	int flag = 0;
	PLT q= pHead;
	PLT p =pHead->pNext ;
	while (p != NULL)
	{
		if (pos == i)
		{
			q->pNext = p->pNext;
			*pVal = p->data;
			flag = 1;
			free(p);
			break;
		}
		p = p->pNext;
		q = q->pNext;
		i++;
	}

	if (flag == 1)
		return true;
	else
		return false;

}
bool Change(PLT pHead, int pos, int Val)
{
	int flag = 0;//判定是否实现了操作
	int i = 1;//用这个判断是否找到了节点
	PLT p = pHead;
	
	while (p->pNext != NULL)
	{
		if (i == pos)
		{
			p->pNext->data = Val;
			flag = 1;

			break;
		}
		i++;
		p = p->pNext;
	}
	
	if (flag == 1)
		return true;
	else
		return false;
}
bool Search(PLT pHead, int pos, int*pVal)

{
	int flag = 0;//判定是否实现了操作
	int i = 1;
	PLT p;
	p = pHead->pNext;
	while (p != NULL)
	{
		if (i == pos)
		{
			*pVal=p->data;
			flag = 1;
			break;
		}
		i++;
		p = p->pNext;
	}

	if (flag == 1)
		return true;
	else
		return false;
}

下面开始讲解 分为链表略讲,结构体,主函数,以及增删改查函数几个分块。


链表略讲(单链表为主)

什么是链表?

markdown 复制代码
    链表一种动态数据存储方式,很方便后序增删。    
    重点术语:头节点(头指针),首元节点,前驱,后继,尾节点。   
    想象一下链表就像是**火车**,每个车厢(**节点**)里都装着一段货物(**数据**),
    而每个车厢里都有一扇门(**指针**),这扇门通向下一个车厢(**节点**)。     
    火车头就像是链表的**头节点**,从火车头开始,
    我们可以一个接一个地打开每节车厢的门,逐个查看或修改里面的货物。       
    第一节真正装有有效货物的车厢就是**首元节点(第一个储存了有效数据的节点)。
    而通过门连接着两节车厢,前者是**后者的前驱**,后者是**前者的后继。**
   这样的结构允许我们在不需要调整一整列车厢的情况下,
   方便地在两节车厢之间插入或删除车厢。这灵活性就是链表相较于数组的优势。
markdown 复制代码
  而如果我们要在尾端插入一个节点(车厢)我们有两种方法<br />      **一个是**从车头开始看起,一节节车厢开门,直到有节车厢开了门后发现后面没有车厢了,那让新的车厢直接连接到这节就行了。但这样做每次都要一个个看过去。<br />      **所以有了第二个方法**,尾指针。我们只要有第二个标志物:**车尾**。在车厢一节节增加的过程中,不断标志最尾端的车厢就行了,当所有车厢连起来后摇,它自然指向了尾节点。当要加入新车厢时,只要把它连接到车尾(**尾指针**)就行了。

(单链表通俗意义上讲就是每个节点只含一个指针。自然我们有双链表等等链表,但一法通百法通,dddd。)

链表好处

xml 复制代码
    诸如数组之类的存储结构都需要预先给定存储个数,所开辟的空间是连续的。而且后续要增加或者删除数据很麻烦。<br />       
    如果我有一堆占用空间极大的并且不确定个数的同类型数据要存储,且后续增删操作很频繁。那么数组就不是那么好用了,因为我的计算机不一定正好有那么多连续的空间给这堆数据。<br />       
    链表允许这些数据存储在不连续的空间中,通过指针为引,构成一个完整的存储体系。<br />     
    就如小朋友瞎站,只要他们好好地一个拉一个,那就是可以把所有的小朋友一个不差地点过去。<br />        
    而链表要插入那就只要前一个节点的指针重新指向要插入的指针,
    要插入的节点的指针指向原先的后一个指针就行了。完全不需要挪移地址。
    正如插队,让小朋友把手重新拉就行了。不要小朋友变换位置。

删除也是同理,使前一个节点指向删除节点的指针,让它指向原先由删除节点指针指向的节点,再把删除节点的指针指定为空就行了。

缺点(以单链表为例)

css 复制代码
  数组中只要你知道它是第x个你就可以直接接用a[x-1]找到它了。而单链表中你必须从头开始遍历才行。

结构体的定义

c 复制代码
typedef struct List
{
	int data;//数据域
	struct List* pNext; //指针域
}LT, * PLT;

如果有朋友不知道结构体是什么的,可以先去看郝斌C语言的相关课程,这里只略讲。

  • 夏姬八讲:结构体就是把几种不同的数据类型整合到一起,方便后续定义。比如学生结构体可以包含姓名(string或者char[]),年龄(int),班级(int)等等。
  • 定义方法: struct xxx{ };大括号里面写你要的数据类型 以及名字。还有几种定义方法但此处不讲。
markdown 复制代码
  而用于链表的结构体就要如代码块中所示,需要一个**数据域(代码块中int data)**<br />     和一个**指针域(代码块中structStudent*pNext)**。
markdown 复制代码
    数据域用于存储自己要用的数据,指针域则用于连接一个个节点。

** 指针的本质是存储地址的变量**,正因为每个节点存储了下一个节点的地址我们才能顺藤摸瓜,找到所有节点。

arduino 复制代码
  初学的时候我就很好奇,怎么就能在结构体里用它自己定义一个指针呢,这尼玛不是套娃吗。但是C语言允许这种规则。而且指针本质是存储地址的。结构体自己里面存储一个同类型的节点的地址很合适吧。(有文章讲解这个问题的,但我这里就不多赘述)<br />       另外注意正经算法中,数据域也应该是结构体类型的,一搬会定义一个**struct Data{};**里面存放想要的数据域内容。然后在为链表服务的结构体中这样定义。
c 复制代码
struct Student
{
   struct Data data ;
   struct Student * pNext;
};

PS:本人喜欢在指针前加个p,以区分变量,因为指针是ptr......

为便于理解我就直接定义为int。

而我代码中typedef 作用就是让我们可以偷懒,用typedef修饰后,比如 如下代码。

c 复制代码
typedef struct Student
{
    ......
}ST,*PST;
xml 复制代码
 我们就可以用<br />
 **ST**表示 struct Student。<br />**PST** 表示struct Student *。<br />     
 也可以只定义一个PST,那就去掉逗号和ST就行。<br />     
 如果是要指针,那就要加* 。<br />    
 typedef具体用法与其可以这么做的原因,这里就不讲了。工具会用就行。<br />     
 另外c++中不用**typedef**就可以直接使用结构体名定义对象。
cpp 复制代码
struct Student
{
    ......
};
int main()
{
    Student st1;
    Student* st2;//允许如此
}
arduino 复制代码
   但你如果想要简写比如 ST,PST,
   那你还是要用**typedef**。<br />       
   至此为链表服务的结构体就讲完了。

观察主函数

c 复制代码
PLT Create(PLT pHead);
void Travel(PLT pHead);
bool Insert(PLT pHead, int val);
bool Delete(PLT pHead, int pos,int* pVal);
bool Change(PLT pHead, int pos, int val);
bool Search(PLT pHead, int pos, int*val);
//pos position:位置 val:值
int main(void)
{
	PLT pHead = NULL;
	pHead = Create(pHead);//创建
	int val = 0;
	for (int i = 1;i <= 5; i++)//增加五个节点
	{
		Insert(pHead,  i);//增加
		val++;
	}
	
	Delete(pHead, 1, &val);//删除
	//此处删除1号节点测试
	printf("%d\n", val);
	Travel(pHead);//遍历
	Change(pHead, 2, 999);//修改
	Search(pHead, 4, &val);//查找
	printf("%d\n", val);
	Travel(pHead);
	return 0;
}
   乍一看,主函数简直一坨,无法入目。所以我们化繁为简。
c 复制代码
	pHead = Create(pHead);//创建
	Insert(pHead,  i);//增加
	Delete(pHead, 1, &val);//删除
	Change(pHead, 2, 999);//修改
	Search(pHead, 4, &val);//查找
	Travel(pHead);//遍历
txt 复制代码
删掉细枝末节后,我们可以看到留下来的是几个现在看起来莫名其妙的函数。
但是细看下来我们又可以发现,这些都是我们需要的内容,其中的**核心无疑是创建与增删以及遍历**。
PS: 如果你现在看不懂形参,那你就别看,思而不学则惘,有时候学不会就是想的太多了。
**我们写程序的时候,可以先搞清楚自己要哪些功能(函数),现在主函数里确定这些函数的名称,无须去管形参与返回什么的,这些东西可以在之后具体的函数构建中慢慢确定。
先把骨架搭配后才能更好的填补内容和外皮。 
写好主函数后,我们差不多可以确定我们要如下这些函数:
**create,insert,delete,change,search,travel。
**以对应**创建,添加,删除,修改,查找,遍历。**

函数分讲

Create函数 创建

xml 复制代码
  **链表到底要怎么创建呢?**<br />      
  初学之时我的大脑是糊的,我总是把数组的思维代进链表,<br />     
  我总是想:我怎么知道到底要几个指针,到底有几个节点,这些我都不知道,我怎么创建链表啊?<br />     
  如果你和我一样纠结这些goushi,那你钻牛角尖了,链表的优点就是不用给出具体个数,随时添加与删除。<br />    
  为什么呢?<br />    
  因为地址,<br />    
  只要我头节点里有首元节点的地址,首元节点里存储下一个节点地址,一个个这样下去。我就可以通过头结点将整条链表遍历。
   就像火车,只要有火车头,并且火车头有门,那我们就可以依靠一扇扇门走过所有车箱。
   根本不用管火车有几个车厢,几个门,你要多少后面接多少车厢就行了。
markdown 复制代码
   **看过托马斯小火车的人都知道,决定一辆火车的是它的头。** 所以我们只需要创建火车头(头节点)那链表就创建成功了!<br />     
   **来看代码:**
c 复制代码
PLT Create(PLT pHead)
{
	pHead = (PLT)malloc(sizeof(LT));
	if(pHead!=NULL)
	{
		pHead->pNext = NULL;
		return pHead;
	}
	else
		return NULL;
}
sql 复制代码
   观察这个Create函数的形参和返回类型,分别是PLT pHead,和PLT 这说明我们要传入一个链表的指针,传回一个链表的指针。而pHead 就是我们了解过的头指针。
  所以就是 这个函数就是传入一个叫头指针的指针,传回一个处理好的真正的头指针。
ini 复制代码
只要把这个头指针建立好,那么链表就创建完毕。
在主函数中,我们定义好了**pHead**: PLT pHead = NULL; 
这时pHead时一个空指针。
此时它只是叫头指针,但它实则上平平无奇,啥也不是。就如大学生,叫是叫大学生,但只有被大学函数教好了,他才真是一个大学生,否则
把它定义为NULL是为了初始化,并避免它成为野指针。
接着主函数中 会用**pHead**来接收**Create**函数处理好的头指针: pHead =Create(pHead);
接收了返回值的头指针才是真的头指针。
接下来我们就要看Create函数到底做了什么:
c 复制代码
pHead = (PLT)malloc(sizeof(LT));
	if(pHead!=NULL)
	{
		pHead->pNext = NULL;
		return pHead;
	}
	else
    {
        return NULL;
    }

首先,Create 函数会用mallocpHead 开辟空间。动态开辟后头指针就会一直存在,直到整个程序终结。

正式因为 为它开辟了空间,才使得它能灵活的增删。

我相信大部分人会有疑惑,为什么要动态开辟啊,我不开辟可不可以啊。

静态链表也不是不可以定义,大家都可以定义了试试,但是,这样子它所有的优势荡然无存。

动态开辟保证了,我们只要想增加节点就增加节点,十分灵活。而且我们再创建链表的时候,通常时无法确定我们到底要有几个节点的,所以要动态 开辟。

再问就不礼貌了,有时候问太多为什么要,那你就永远学不会,因为你无法每次都问到答案,一旦问不到,你就给自己设置门槛了!但是我们好像无法抑制这种求知欲。 所以呢,我们可以反其道而行之,多问问为什么不要,你自然回答不上来,那你就会坦然接受了。

PS:大家可以试试Create里面不开辟,那马上编译器就会报错!

​ 接着,我们要判断pHead还是不是空的,因为正常开辟空间后它不会是空的,这一步是检查有没有正常开辟空间,有些人认为这一步可有可无,但这能让程序更安全与更可读。而且有些编译器会因为你没写这一步而报错。 写这一步也是一个良好的习惯! 如果开辟成功,那就会让pHead 的指针域指针指向空:pHead->pNext = NULL;,避免其成为野指针。 最后把实至名归的头指针传回去。

else

return NULL;

但是如果没有正常开辟,那就会返回NULL,是pHead还是原来那个空指针。后续的操作必会报错。


Insert函数 增加

​ 之所以第二个讲这个,是因为里面也涉及到了动态开辟。

代码如下:

c 复制代码
bool Insert(PLT pHead,  int val)
{
	PLT pNew = (PLT)malloc(sizeof(LT));
	if (pNew == NULL)
		{
			// 处理内存分配失败的情况
			return false;
		}
	PLT pTail = pHead;
	while (pTail->pNext != NULL)
	{
		pTail = pTail->pNext;
	}
	pNew->data = val;
	pTail->pNext = pNew;
	pNew->pNext = NULL;
	pTail = pNew;
	return true;
}

​ 首先,它的返回值是一个bool 值,只有truefalse 两种状态,因为增加节点,我们只需要知道它成功了没有,不用返回什么其他东西。当然你把增加后新的pHead 返回也可以,具体情况具体定义。

形参则是:pHead 头节点与Val值,也就是说传入一个链表与要插入的节点的值。这好像没什么好讲的,正如连接车厢,你自然要告诉工人是哪辆火车,以及这节车厢里要装什么。

当然如果你再主函数里,就把节点定义好了。那你直接把节点传进去也行。

开门见山就是一个动态开辟,pNew被开辟了空间,pNew在这里也是工具指针,代指每次的新节点。这里动态开辟就更必要了,你不动态开辟空间,函数运行完后,就直接把pNew给自动释放了,程序直接出错。动态开辟也涉及到变量的生命周期问题,一般变量会在其作用域结束时被释放。动态开辟会给他们续命。

** if (pNew == NULL) { // 处理内存分配失败的情况**

** return false; }**

这几行又是用来检查开辟是否成功的,不多言了。

c 复制代码
PLT pTail = pHead;
	while (pTail->pNext != NULL)
	{
		pTail = pTail->pNext;
	}
	pNew->data = val;
	pTail->pNext = pNew;
	pNew->pNext = NULL;
	//pTail = pNew;

​ 这几行就是核心操作了,我们会先定义一个尾节点使它先指向头结点。然后经过while 循环,找到那个pNext 指向空的节点,让pTail 指向它。这时尾节点才是真正的尾节点。

PS:这里可能有同学看不懂p=p->pNext ,p->pNext就是p的后一个节点。将它赋给p,就可以让工具节点在链表中一个个后移,就像人通过火车门把车厢一个个走过去一样。这是增删改查和遍历中最核心的操作。

每次进到这个函数种时它都会进入while 循环遍历链表,然后才指向尾端。有想法的同志可以试试第二种方法,那种在C++用类 更好实现。

找到尾指针了后,有pNew->data = val;这是把货物装上车厢。

然后就是连接操作了。

首先是让尾指针的指针链接到新节点:pTail->pNext = pNew;

然后再把新节点的指针域指针设置为空:pNew->pNext = NULL; 防止它变为野指针。

** **本来应该有pTail指向pNew的操作,意图是让pTail永远指向尾端,但我们实际上用的是第一种插入法,所以这步冗余了。

这就是插入的要点了。

Travel函数 遍历

p->pNext就是p的后一个节点。将它赋给p,就可以让工具节点在链表中一个个后移,就像人通过火车门把车厢一个个走过去一样。这是增删改查和遍历中最核心的操作。

xml 复制代码
 我直接引用我自己的话,
 这也是遍历的核心。
 就是让人通过火车门将车厢全部走一遍,清点其中的货物。<br />     
c 复制代码
void Travel(PLT pHead)
{	
	PLT p = pHead->pNext;//头节点的指针域,指向第一个节点的指针(地址)
	while (NULL != p)//最后一个节点指针域为 NULL
	{
		printf("%d ", p->data);//节点数据域中的数据
		p = p->pNext;//下一个节点的指针域。
	}
	printf("\n");
	return;
}

​ 因为内置了输出,所以这个函数不需要返回值。

首先函数中定义了一个工具指针,直接让它等于pHead->pNext;因为遍历是要遍历真正存储了数据的有效节点,所以把它设置为头指针没什么意义,但也不是不可以。

接着就是while 循环,只要没有到末尾,也就是p为空的情况,那就以会进行循环。每次都会 **printf("%d ", p->data);**输出节点的数据域的值,然后将指针挪到下一节点:p = p->pNext;

最后用return结束这个函数。也宣告遍历结束。实际算法中遍历其实十分重要,即使遍历,通过对输出的观察,可以让我们更容易发现自己的错误。

但是正经算法函数中一般不能出现输入输出。一般放在主函数里。

Delete Change Search删改查 函数

sql 复制代码
之所以放在一起讲,
    是因为它们大抵是Insert和Travel的换皮。
    如果你已经明白了Insert和Travel,那你很容易模仿出来这三个函数。
c 复制代码
bool Delete(PLT pHead, int pos ,int* pVal)
//在第pos个节点删除一个节点!pos从1开始
{
	int i = 1;
	int flag = 0;
	PLT q= pHead;
	PLT p =pHead->pNext ;
	while (p != NULL)
	{
		if (pos == i)
		{
			q->pNext = p->pNext;
			*pVal = p->data;
			flag = 1;
			free(p);
			break;
		}
		p = p->pNext;
		q = q->pNext;
		i++;
	}

	if (flag == 1)
		return true;
	else
		return false;

}
c 复制代码
bool Change(PLT pHead, int pos, int Val)
{
	int flag = 0;//判定是否实现了操作
	int i = 1;//用这个判断是否找到了节点
	PLT p = pHead;
	
	while (p->pNext != NULL)
	{
		if (i == pos)
		{
			p->pNext->data = Val;
			flag = 1;

			break;
		}
		i++;
		p = p->pNext;
	}
	
	if (flag == 1)
		return true;
	else
		return false;
}
c 复制代码
bool Search(PLT pHead, int pos, int*pVal)

{
	int flag = 0;//判定是否实现了操作
	int i = 1;
	PLT p;
	p = pHead->pNext;
	while (p != NULL)
	{
		if (i == pos)
		{
			*pVal=p->data;
			flag = 1;
			break;
		}
		i++;
		p = p->pNext;
	}

	if (flag == 1)
		return true;
	else
		return false;
}

​ 它们都会接收链表pHead ,以及要操作节点的位置pos ,以及一个Val 或者pVal
Changeval 一个整形变量,是因为它只要用这个Val 替换掉pos 位置的节点的数据域。

DeleteSearchpVal ,一个指针,是因为在主函数中要接收,这两个函数所操作的那个节点的指针域,如果不用指针,那主函数的实参是不会随着函数的形参变化的。

三个函数都有iflag ,前者是用来和pos 对比的,它是while 循环结束的一个隐含的条件,一旦有匹配的ipos ,那就表明找到了要操作的节点,于是会进入if分支进行核心操作:删除,更改,或者查询。然后break跳出循环。而flag 则是一个成功操作与否的工具变量 ,可以指示是否完成了操作,之后,可以根据它的值来确定返回什么。

这三个函数的核心代码咯留给大家自己慢慢看。

至此,链表我们就讲完了。

如果你觉得有用,请给我留言。

如果你还有疑问,请在评论区打出。

如果你有更好的建议,也请留言。

相关推荐
梅茜Mercy2 小时前
数据结构:链表(经典算法例题)详解
数据结构·链表
青春男大2 小时前
java栈--数据结构
java·开发语言·数据结构·学习·eclipse
Zer0_on2 小时前
数据结构栈和队列
c语言·开发语言·数据结构
一只小bit2 小时前
数据结构之栈,队列,树
c语言·开发语言·数据结构·c++
我要学编程(ಥ_ಥ)2 小时前
一文详解“二叉树中的深搜“在算法中的应用
java·数据结构·算法·leetcode·深度优先
szuzhan.gy4 小时前
DS查找—二叉树平衡因子
数据结构·c++·算法
一只码代码的章鱼4 小时前
排序算法 (插入,选择,冒泡,希尔,快速,归并,堆排序)
数据结构·算法·排序算法
青い月の魔女5 小时前
数据结构初阶---二叉树
c语言·数据结构·笔记·学习·算法
我要出家当道士5 小时前
Nginx单向链表 ngx_list_t
数据结构·nginx·链表·c
林的快手5 小时前
209.长度最小的子数组
java·数据结构·数据库·python·算法·leetcode