数据结构(二):「 线性表 | 顺序表(静态、动态分配) | 链表(单、双、循环、静态链表)」的定义与基本操作

文章目录

  • 第二章 线性表
    • 一、线性表
      • (一)线性表的定义
      • (二)线性表的基本操作
    • 二、顺序表
      • (一)顺序表的定义
      • (二)顺序表的实现
        • 1.静态分配
        • 2.动态分配
        • 3.优缺点
      • (三)顺序表的基本操作
        • 1.插入
        • 2.删除
        • 3.查找
          • (1)按位查找
          • (2)按值查找
    • 三、链表
      • (一)引言
      • (二)单链表
        • 1.单链表的定义
          • (1)typedef的使用问题
          • (2)不带头结点的单链表
          • (3)带头结点的单链表
          • (4)不带/带头结点的区别
        • 2.单链表的插入删除
          • (1)按位序插入(带头结点)
          • (2)按位序插入(不带头结点)
          • (3)指定结点的后插操作
          • (4)指定结点的前插操作
          • (5)按位序删除(带头结点)
          • (6)指定结点的删除
          • (7)小结
        • 3.单链表的查找
          • (1)按位查找
          • (2)按值查找
          • (3)求表的长度
        • 4.单链表的建立
          • (1)尾插法建立单链表
          • (2)头插法建立单链表
          • (3)小结
      • (三)双链表
        • 1.双链表的定义
        • 2.双链表的插入
        • 3.双链表的删除
        • 4.双链表的遍历
      • (四)循环链表
        • 1.循环单链表
          • (1)循环单链表的定义
          • (2)循环单链表的好处
        • 3.循环双链表
          • (1)循环双链表的定义
          • (2)循环双链表的插入
          • (3)循环双链表的删除
      • (五)静态链表
        • 1.静态链表的原理
        • 2.静态链表的定义
          • [*] 一个比较少见的typedef的定义方法
        • 3.静态链表基本操作的简述
          • (1)初始化
          • (2)查找
          • (3)插入
          • (4)删除
          • (5)总结
    • 四、顺序表和链表的对比(总结)
      • (一)逻辑结构
      • (二)存储结构
      • (三)基本操作
        • 1.创建(初始化)
        • 2.销毁
        • 3.插入、删除
        • 4.查找
      • (四)什么时候该使用谁
      • (五)开放式问题的回答思路

第二章 线性表

一、线性表

(一)线性表的定义

线性表是具有相同数据类型的n(n≥0)个数据元素有限序列,其中n为表长,当n=0时线性表是一个空表

注意

  • 各个数据元素的类型都相同。那么,每个数据元素所占空间是一样大的,计算机由此来快速找到某个元素。
  • 是一个序列,是有次序的。
  • 是有限的序列。如:所有的整数按递增的次序排列,是线性表吗?——不是。

若用L命名线性表,则其一般表示为:
L = ( a 1 , a 2 , . . . , a i , a i + 1 , . . . , a n ) L = (a_1,a_2,...,a_i,a_{i+1},...,a_n) L=(a1,a2,...,ai,ai+1,...,an)
注意这个角标,即线性表的位序,是从1开始的。

除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。

线性表的表,就是“List”,即“列表”之意

(二)线性表的基本操作

InitList(&L):初始化表

DestroyList(&L):销毁操作,销毁并释放L所占用的内存空间

ListInsert(&L, i, e):插入操作

ListDelete(&L, i, &e):删除操作,用e返回删除元素的值

LocateElem(L, e):按值查找操作

GetElem(L, i):按位查找操作

其他常用操作:

Length(L):求表长

PrintList(L):输出操作

Empty(L):判空操作

Tips:

  • 对数据的操作——无非就是创建、销毁;增、删、改、查。
  • 在描述基本操作的时候,并不指明具体的参数类型,而是一种抽象的接口定义。
  • 实际开发中,可根据实际需求定义其他的基本操作。
  • 函数名和参数的形式、命名都可改变。但是尽量具有可读性,写成上面这种就很好,都是很好的命名方式了。
  • 什么时候要传入引用”&“——对参数的修改结果需要”带回来“。即操作的是同一份实实在在的数据目标,而不能是一个拷贝的复制品。

问题:为什么要实现对数据结构的基本操作?

  • 团队合作编程,你定义的数据结构要让别人能很方便地使用(封装)。
  • 将常用的操作/运算封装成函数,避免重复工作,降低出错风险。

二、顺序表

(一)顺序表的定义

顺序表——用顺序存储方式实现的线性表

顺序存储——把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。

由于线性表是具有相同数据类型的n个数据元素的有限序列。所以顺序表存储后,可以根据LOC(L)+n*数据元素大小,直接访问到位序为n的元素地址。

如何知道每个数据元素的大小?——C语言 sizeof(ElemType)

(二)顺序表的实现

1.静态分配
#define MaxSize 10	//定义最大长度
typedef struct {
	ElemType data[MaxSize];	//用静态的数组存放数据元素
	int length;		//顺序表的当前长度 
}SqList; 
#include<stdio.h>
#define MaxSize 10
typedef struct{
	int data[MaxSize];
	int length;
}SqList;

//初始化一个顺序表
void InitList(SqList &L){
	for(int i=0; i<MaxSize; i++){
		L.data[i]=0;	//将所有数据元素设置为默认初始值 
	}
	L.length=0;	//顺序表初始长度为0 
} 

int main(){
	SqList L;	//声明一个顺序表L
	InitList(L);	//初始化顺序表L 
}

问题:如果“数组”存满了怎么办?

  • 可以放弃了,顺序表的表长刚开始确定后就无法更改(存储空间是静态的)
  • 如果刚开始就声明一个很大的内存空间呢?——会很浪费空间。
2.动态分配
#define InitSize 10		//顺序表的初始长度
typedef struct {
	ElemType * data;	//指示动态分配数组的指针 
	int MaxSize;		//顺序表的最大容量 
	int length;			//顺序表的当前长度 
}SeqList; 

Key:动态申请和释放内存空间

C语言——malloc、free函数

L.data = (ElemType *)malloc(sizeof(ElemType) * InitSize);

malloc函数返回一个指针,需要强制转型为你定义的数据元素类型指针

C++ ——new、delete关键字

#include<stdlib.h>
#define InitSize 10
typedef struct {
	int * data;
	int MaxSize;
	int length;
};

void InitList(SeqList &L){
	//用malloc函数申请一片连续的内存空间
	L.data = (int *)malloc(InitSize * sizeof(int));
	L.length = 0;
	L.MaxSize = InitSize;
}

//增加动态数组的长度
void IncreaseSize(SeqList &L, int len){
	int *p;
	L.data = (int *)malloc((L.MaxSize + len) * sizeof(int));
	for(int i=0; i<L.length; i++){
		L.data[i] = p[i];	//将数据复制到新区域 
	}
	L.MaxSize = L.MaxSize + len;	//顺序表最大长度加len
	free(p);	//释放原来的内存空间 
}

int main(){
	SeqList L;	//声明一个顺序表L
	InitList(L);	//初始化顺序表
	//......
	IncreaseSize(L, 5);
	return 0; 
} 
3.优缺点

顺序表的特点

  • 随机访问,即可以在O(1)时间内找到第i个元素。即data[i-1]。动态分配、静态分配都是这样的。
  • 存储密度高,每个节点只存储数据元素。它不像链表那样还需要存放一个指针域。
  • 拓展容量不方便。即使采用动态分配的方式实现,拓展长度的时间复杂度也比较高。
  • 插入、删除操作不方便,需要移动大量元素。

(三)顺序表的基本操作

1.插入
#define MaxSize 10
typedef struct {
	int data[MaxSize];
	int length;
}SqList;

void ListInsert(SqList &L, int i, int e){
	for(int j=L.length; j>=i; j--){	//将第i个元素及之后的元素后移 
		L.data[j] = L.data[j-1];
	}
	L.data[i-1] = e;	//在位置i处放入e
	L.length++;		//长度加1 
}

int main(){
	SqList L;	//声明一个顺序表L
	InitList(L);	//初始化顺序表
	//......
	ListInsert(L, 3, 3);
	return 0; 
}

对于插入操作,如果使用者传入了一个不合法的值,那么我们的程序应该可以给予相应的反馈。至少要反馈插入是成功,还是失败了吧。所以我们可以优化一下,如下。

bool ListInsert(SqList &L, int i, int e){
	if(i<1||i>L.length+1) return false;	//判断i的范围是否有效 
	if(L.length>=MaxSize) return false;	//当前存储空间已满,不能插入
	for(int j=L.length; j>=i; j--){
		L.data[j] = L.data[j-1];	//将第i个元素及之后的元素右移 
	}
	L.data[i-1] = e;	//在位置i处放入e
	L.length++;		//长度加1
	return true; 
} 

好的算法,应该具有健壮性。能处理异常情况,并给使用者反馈。

2.删除
bool ListDelete(SqList &L, int i, int &e){
	if(i<1 || i>L.length) return false;	//判断i的范围是否有效
	e = L.data[i-1];	//将被删除的元素赋给e
	for(int j=i; j<L.length; j++){
		L.data[j-1] = L.data[j];	//将第i个位置后的元素前移 
	}
	L.length--;	//线性表长度减1
	return true; 
}

int main(){
	SqList L;	//声明一个顺序表
	InitList(L);	//初始化顺序表
	//......
	int e = -1;	//设变量e用来回显被删除元素 
	if(ListDelete(L, 3, e))
		printf("已删除第3个元素,删除元素值为%d\n",e);
	else
		printf("位序i不合法,删除失败\n");
	return 0;
}
3.查找
(1)按位查找

GetElem(L, i):按位查找操作。获取表L中第i个位置的元素的值。

#define MaxSize 10
typedef struct{
	ElemType data[MaxSize];	//用静态数组存放数据元素
	int length;	//顺序表的当前长度 
}SqList;

ElemType GetElem(SqList L, int i){
	return L.data[i-1];
}
#define InitSize 10
typedef struct{
	ElemType *data;	//用动态分配数组的方式(malloc)
	int MaxSize;	//顺序表的最大容量
	int length;		//顺序表的当前长度
}SeqList;

ElemType GetElem(SeqList L, int i){
	return L.data[i-1];	//虽然是malloc,但是和访问普通数组的写法是一样的,它毕竟不是链表 
}
(2)按值查找

LocateElem(L, e):按值查找操作。在表L中查找具有给定关键字值的元素。

#define InitSize 10
typedef struct{
	ElemType *data;	//动态数组 
	int MaxSize;	//顺序表的最大容量 
	int length;		//顺序表的当前长度 
}SeqList;

//在顺序表L中查找第一个元素值等于e的元素,并返回其位序 
int LocateElem(SeqList L, ElemType e){
	for(int i=0; i<L.length; i++){
		if(L.data[i] == e) return i+1;	//数组下标为i的元素值为e,其位序为i+1 
	}
	return 0;
}

问题:此处,像int、float、char等类型,判断相等可以使用“”来判断。但若是结构类型的数据呢?还可以用“”吗?——不能。

如果是结构类型的数据,判断其是否相等,你需要依次比较该结构体中的各个变量是否相等,最终判断两个结构类型变量是否相等。当然,你也可以将判断两个结构类型是否相等的代码封装成一个函数,以便复用。

如果使用C++、JAVA,你也可以对“==”进行运算符重载。

Tips:但是呢,如果你是在《数据结构》的考研初试的试卷当中,去手写代码的时候,你当然可以直接用“==”来判断是否相等,不论是什么类型,而不需要考虑那么多,因为数据结构考的是一种思想、一种理解,而并不是具体的编程语言的实现。

但是,如果考的是《C语言程序设计》,那么,也许你就要严格按照C语言的语法来写,即使是在试卷上手写代码。

三、链表

(一)引言

链表——用链式存储方式实现的线性表

链表主要有四种:单链表、双链表、循环链表、静态链表。

  • 优点:不要求大片连续空间,改变容量方便
  • 缺点:不可随机存取,要耗费一定空间存指针域

(二)单链表

1.单链表的定义

单链表是链表的一种。

单链表的代码实现:

struct LNode{		//单链表结点的结构类型 
	ElemType data;	//数据域 
	struct LNode *next;	//指针域 
}; 

struct LNode *p = (struct LNode *)malloc(sizeof(struct LNode));	//增加一个新的结点

此处,我们发现,每次定义一个新节点,都要将它定义为struct LNode的类型。

于是我们直接将原结构类型struct LNode使用typedef重命名一下,以简化。

(1)typedef的使用问题

typedef <数据类型> <别名>

例如:

​ typedef int zhengshu;

​ typedef int * zhengshuzhizhen;

这样以后,原本的

​ int a = 1;

​ int * p;

就可以写为

​ zhengshu a = 1;

​ zhengshuzhizhen p;

于是就有了如下的代码:

typedef struct LNode{		//单链表结点的结构类型 
	ElemType data;	//数据域 
	struct LNode *next;	//指针域 
}LNode, *LinkList; 	//把结构类型、结构类型指针,均使用typedef起一个别名,便于后续使用

要表示一个单链表时,只需声明一个头指针L,指向单链表的第一个结点

LNode * L; //声明一个指针,用来指向单链表第一个结点

这样一来,我就可以这样写我的代码:

LinkList L; //声明一个指针,用来指向单链表第一个结点

*但是,也并不是说,写LNode 的地方,都要写作LinkList。因为,代码不论怎么写,始终是为了简洁性、可读性的。看下面这个例子:

typedef struct LNode{
	ElemType * data;
	struct LNode * next;
}LNode, *LinkList;

//单链表查找某元素
LNode * GetElem(LinkList L, int i){	//函数返回值LNode *是想强调其返回结果是一个结点;
    								//参数L类型LinkList是想强调这是一个单链表(头结点代表单链表)
	int j = 1;
	LNode *p = L->next;
	if(i==0) return L;
	if(i<1) return NULL;
	while(p!=NULL && j<i){
		p = p->next;
		j++;
	}
	return p;	//最终返回一个结点,类型为LNode *
}

注意:我此段代码中,并没有将LNode *全部写为LinkList。可以注意到,在我使用LNode *的地方,我往往是想要强调这是一个结点(例如函数返回值LNode *,例如其中用来索引的指针p是LNode *),而在使用LinkList的地方,我想要强调的是这是一个单链表(例如函数的参数L是LinkList,它虽然是一个头结点,但它根本上是代表着一个单链表)

总之,这种重命名的方法(typedef),以及对重命名之后的别名的使用(是有所强调的,而不是一概而论的),希望能够好好体会。

(2)不带头结点的单链表
typedef struct{		//定义单链表结点的结构类型
	ElemType data;		//数据域 
	struct LNode *next;	//指针域 
}LNode, *LinkList;

//初始化一个空的单链表(不带头结点)
bool InitList(LinkList &L) {
	L = NULL;	//空表,暂时还没有任何结点	//初始并设为空,防止脏数据
	return true; 
}

void test(){
	LinkList L;	//声明一个指向单链表的指针	//(1)
	InitList(L);	//初始化一个空表
	//...... 
}

注意:(1)处并没有创建一个结点

要始终去体会,别名的使用是在含义上有所强调的,而不仅仅是理论上来说代码能否编译问题。

//判断单链表是否为空
bool Empty(LinkList L) {
	if(L == NULL) return true;
	else return false;
}

//或者直接这样写,如下:
bool Empty(LinkList L) {
	return (L == NULL);
}
(3)带头结点的单链表
typedef struct LNode{
	ElemType data;
	struct LNode *next;
}LNode, *LinkList;

//初始化一个单链表(带头结点) 
bool InitList(LinkList &L){
	L = (LNode *)malloc(sizeof(LNode));	//分配一个头结点
	if(L == NULL)  return false;	//内存不足,分配失败
	L->next = NULL;	//头结点之后暂时还没有结点
	return true; 
}

void test(){
	LinkList L;	//声明一个指向头结点的指针
	InitList(L);	//初始化一个空表
	//...... 
}

注意:此头结点的数据域是不存储数据元素的,只有指针域有意义。这是为了方便后续的链表操作。

//判断单链表是否为空(带头结点) 
bool Empty(LinkList L) {
	if(L->next == NULL) return true;
	else return false;
}
(4)不带/带头结点的区别
  • 不带头结点,写代码更麻烦。对第一个数据结点和后续数据结点的处理需要用不同的代码逻辑。对空表和非空表的处理需要用不同的代码逻辑。
  • 带头结点,写代码更方便。一般都是带头结点的。
  • 不带头结点,头指针L所指向的下一个结点,就是实际用于存放数据的结点。而带头结点,头指针所指向的结点,也就是头结点,是不存放实际的数据元素的,而头结点指向的下一个结点才会用于存放数据。
  • 不带头结点,空表判断:L==NULL;带头结点,空表判断:L->next==NULL
2.单链表的插入删除
(1)按位序插入(带头结点)

ListInsert(&L, i, e):插入操作。在表L中的第i个位置上插入指定元素e。即找到第i-1个结点,将新结点插入其后。

此时,带头结点的好处就体现出来了。当我插入的位置为1时,可以把头结点看作第0个结点,向头结点后进行插入。由此,我不论在何处插入一个结点,我的处理逻辑都是统一的。

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ElemType e) {
	if(i<1) return false;
	LNode *p;	//指针p指向当前扫描到的结点
	int j = 0;	//当前p指向的是第几个结点
	p = L;	//L指向头结点,头结点是第0个结点(不存数据)
	while(p!=NULL && j<i-1) {	//循环找到第i-1个结点 
		p = p->next;
		j++;
	}
	if(p == NULL) return false;	//i值不合法
	LNode *s = (LNode *)malloc(sizeof(LNode));
	s->data = e;
	s->next = p->next;		//(1)
	p->next = s;			//(2)
	return true;
}

注意(1)和(2)两句千万不能写反。

(2)按位序插入(不带头结点)

ListInsert(&L, i, e):插入操作。在表L中的第i个位置上插入指定元素e。即找到第i-1个结点,将新结点插入其后。

此时,由于不带头结点,也就是不存在“第0个”结点。因此i=1时需要特殊处理。

bool ListInsert(LinkList &L, int i, ElemType e){
	if(i<1) return false;
	if(i == 1){	//插入第1个结点的操作与其它结点操作不同 
		LNode *s = (LNode *)malloc(sizeof(LNode));
		s->data = e;
		s->next = L;
		L = s;	//头指针指向新结点
		return true; 
	}
	LNode *p;	//指针p指向当前扫描到的结点
	int j = 1;	//当前p指向的是第几个结点
	p = L;	//p指向第1个结点(注意:不是头结点)
	while(p!=NULL && j<i-1){	//循环找到第i-1个结点 
		p = p->next;
		j++;
	}
	if(p == NULL){	//i值不合法 
		return false;
	}
	LNode *s = (LNode *)malloc(sizeof(LNode));
	s->data = e;
	s->next = p->next;
	p->next = s;
	return true;
}

如果不带头结点,则插入、删除第1个元素时,需要更改头指针L。

如果带头结点的话,头指针肯定永远都是指向头结点的。

但是除了第1个元素外,后续的元素操作,其逻辑和带头结点的一样。

结论:不带头结点写代码更不方便,推荐使用带头结点。因此除非特别声明,否则默认使用带头结点。

注意:考试中带头结点、不带头结点都有可能考相关的题。

(3)指定结点的后插操作

给定一个结点,在这个结点之后插入一个数据元素e。

由于单链表的链接指针只能往后寻找,所以如果给定一个结点p的话,那么p之后的那些结点我们都是可知的,我们都可以用循环的方式把它们都找出来。

但是p结点之前的,我们就没办法知道了。

//后插操作:在p结点之后插入元素e
bool InsertNextNode(LNode *p, ElemType e) {
	if(p == NULL) return false;
	LNode *s = (LNode *)malloc(sizeof(LNode));
	if(s == NULL) return false;	//内存分配失败
	s->data = e;	//用结点s保存数据元素e
	s->next = p->next;
	p->next = s; 	//将结点s连到p之后
	return true; 
}

当然,这个函数只是在已经找到p结点后执行的操作。其时间复杂度为O(1)。

但是真正进行插入的时候,首先肯定是要先通过循环,找到结点p的。

即如下代码:

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ElemType e) {
	if(i<1) return false;
	LNode *p;	//指针p指向当前扫描到的结点
	int j = 0;	//当前p指向的是第几个结点
	p = L;	//L指向头结点,头结点是第0个结点(不存数据)
	while(p!=NULL && j<i-1) {	//循环找到第i-1个结点 
		p = p->next;
		j++;
	}
	return InsertNextNode(p, e);	//封装(当然,上面那几行也可以封装)
}
(4)指定结点的前插操作

在p结点之前插入元素e。

此时就会出现一个问题:如何找到p结点的前驱?

思路一

我们可以传入一个头指针。当给出头指针之后,那么我们链表的所有信息就都能够知道了。

​ 我们可以从头指针开始,依次遍历各个结点,从而找到p结点的前驱结点,再对p的前驱进行后插操作。

​ 那么用这种方法进行前插,时间复杂度是O(n)。

思路二

​ 我依然是对p进行后插。但是后插过后,我将p结点和新结点,之中的数据域进行互换。最终也能实现前插的效果。

​ 这种的本质是后插,时间复杂度O(1)。

思路二代码实现:

//前插操作:在p结点之前插入元素e
bool InsertPriorNode(LNode *p, ElemType e) {
	if(p == NULL) return false;
	LNode *s = (LNode *)malloc(sizeof(LNode));
	if(s == NULL) return false;	//内存分配失败
	s->next = p->next;
	p->next = s;	//将新结点连到p之后
	s->data = p->data;	//将p中元素复制到s中
	p->data = e;	//p中元素覆盖为e
	return true; 
}

或者:

//前插操作:在p结点之前插入元素e
bool InsertPriorNode(LNode *p, LNode *s) {
	if(p == NULL || s == NULL) return false;
	s->next = p->next;
	p->next = s;	//将s连到p之后
	ElemType temp = p->data;	//交换s和p的数据域 
	p->data = s->data;
	s->data = temp;
	return true; 
}
(5)按位序删除(带头结点)

ListDelete(&L, i, &e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。

即找到第i-1个结点,将其指针指向第i+1个结点,并释放第i个结点。

bool ListDelete(LinkList &L, int i, ElemType &e){
	if(i<1) return false;
	LNode *p;	//指针p指向当前扫描到的结点
	int j = 0;	//当前p指向的是第几个结点
	p = L;	//L指向头结点,头结点是第0个结点(不存数据)
	while(p!=NULL && j<i-1){	//循环找到第i-1个结点 
		p = p->next;
		j++;
	}
	if(p == NULL) return false;	//i值不合法
	if(p->next == NULL) return false;	//第i-1个结点之后已无其它结点
	LNode *q = p->next;	//令q指向被删除结点
	e = q->data;	//用e返回被删除元素的值
	p->next = q->next;	//链表直接跨过q结点 
	free(q);		//释放q结点(即被删除结点) 
	return true;
}
(6)指定结点的删除

删除指定结点p。

DeleteNode(LNode *p)

同样的问题。删除结点p,需要修改其前驱结点的next指针。而其前驱,在单链表中是“未知”的。

思路一:传入头指针,循环找到p的前驱结点。

思路二:偷天换日。将p的后继节点q的数据域先存放在p结点内,之后将q结点从单链表中断开。(类似于结点前插操作思路二)

思路二代码实现:

//删除指定结点p
bool DeleteNode(LNode *p){
	if(p == NULL) return false;
	LNode *q = p->next;	//令q指向p的后继结点
	p->data = p->next->data;	//和后继结点交换数据域
	p->next = q->next;	//链表跨过q处,实现删除q结点
	free(q);
	return true;
}

其时间复杂度为O(1)。

但是有个问题:如果要删除的这个结点,刚好是单链表的最后一个结点。那么在进行p结点与其后继结点数据域的互换的时候,就会出现问题。即p->data = p->next->data;的时候,就会出现空指针的错误。

那么如果是最后一个结点,该怎么办呢。那就只能从头结点开始,循环找到p的前驱,进行删除。时间复杂度O(n)。

到此,应该就能体会到单链表的局限性。无法逆向检索,导致一系列不便之处。

那如果说,各个结点除了next指向后继节点的指针,还有一个指向前驱结点的指针呢?就会很方便了。那么此时就成为了一个双向链表

(7)小结
  • 这些代码都要会写,都很重要。
  • 体会带头结点、不带头结点代码的区别。
  • 体会“封装”的好处。(小功能模块化,易维护;避免重复代码,逻辑清晰简洁)
3.单链表的查找

注:本节只讨论“带头结点”的情况

  • 按位查找

    GetElem(L, i):按位查找操作。获取表L中第i个位置的元素的值。

  • 按值查找

    LocateElem(L, e):按值查找操作。在表L中查找具有给定关键字值的元素。

(1)按位查找

其实我们在上一小节中,在插入删除操作时,已经实现了循环查找第i-1个结点。

此处实际上是对它稍作调整以及封装

//按位查找,返回第i个元素(带头结点) 
LNode * GetElem(LinkList L, int i){
	if(i<0) return NULL;
	LNode * p;	//指针p指向当前扫描到的结点
	int j = 0;	//当前p指向的是第几个结点
	p = L;		//L指向头结点,头结点是第0个结点(不存数据)
	while(p!=NULL && j<i){	//循环找到第i个结点 
		p = p->next;
		j++;
	}
	return p;
}

那么,封装之后。我们上一小节的按位插入按位删除中相应的代码段就都可以直接调用这个封装好的函数来实现。

如下所示:

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ElemType e) {
	if(i<1) return false;
	LNode *p = GetElem(L, i-1);	//找到第i-1个结点
	return InsertNextNode(p, e);	//p后插入新元素e
}
(2)按值查找
//按值查找,找到数据域==e的结点
LNode * LocateElem(LinkList L, ElemType e) {
	LNode *p = L->next;
	//从第1个结点开始查找数据域为e的结点
	while(p!=NULL && p->data != e){
		p = p->next;
	}
	return p;	//找到后返回该结点指针,否则返回NULL 
}

此处同样需要注意一点:由于e的类型是ElemType,其有可能是基本类型,也有可能是结构类型。当其为结构类型时,就不能直接通过"!="来判断了。原理同前文一致,不再赘述。

(3)求表的长度
//求表的长度
int Length(LinkList L) {
	int len = 0;	//统计表长 
	LNode *p = L;
	while(p->next != NULL){
		p = p->next;
		len++;
	}
	return len;
}
4.单链表的建立

如果给你很多个数据元素(ElemType),要把它们存到一个单链表里面,怎么弄?

(本节探讨的是带头结点的情况)

  • 第一步:初始化一个单链表
  • 第二步:每次取一个数据元素,插入到表尾/表头(尾插法、头插法)
(1)尾插法建立单链表

第一步,初始化单链表

typedef struct LNode{	//定义单链表结点类型 
	ElemType data;	//每个结点存放一个数据元素 
	struct LNode *next;	//指针指向下一个结点 
}LNode, *LinkList;

//初始化一个单链表(带头结点)
bool InitList(LinkList &L) {
	L = (LNode *)malloc(sizeof(LNode));	//分配一个头结点
	if(L == NULL) return false;	//内存不足,分配失败
	L->next = NULL;	//头结点之后暂时还没有结点
	return true; 
}

void test(){
	LinkList L;	//声明一个指向单链表的指针
	InitList(L);	//初始化一个空表
	//...... 
}

第二步,向单链表末尾进行后插操作

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L, int i, ElemType e) {
	if(i<1) return false;
	LNode *p;	//指针p指向当前扫描到的结点
	int j=0;	//当前p指向的是第几个结点
	p = L;	//L指向头结点
	while(p!=NULL && j<i-1) {	//循环找到第i-1个结点 
		p = p->next;
		j++;
	}
	if(p == NULL) return false;	//i值不合法
	LNode *s = (LNode *)malloc(sizeof(LNode));
	s->data = e;
	s->next = p->next;
	p->next = s;	//将结点s连到p之后 
	return true;
}

但是此时有一个问题。即每次向表尾插入一个元素,都要从表头开始循环,找到表尾结点。那么若要插入n个元素进去,则时间复杂度为O(n²)。

但是实际上,我们每次向表尾插入新的元素,没有必要每次都从表头再全部遍历一次。我们可以设立一个表尾指针r,专门用于指向表尾。之后若要插入新元素,对表尾指针r做一个后插操作即可。

如何对表尾指针r做后插操作,见[指定结点的后插操作](#3 指定结点的后插操作)。

优化后的逻辑代码如下:(建立了表尾指针r的逻辑)

LinkList List_TailInsert(LinkList &L){
	int x;
	L = (LinkList)malloc(sizeof(LNode));	//建立头结点
    //L->next=NULL;	//这句最好也加上。(不过尾插法在最后将表尾next置空了,所以此处不用加也行)
	LNode *s,*r = L;	//r为表尾指针
	scanf("%d",&x);	//输入结点的值
	while(x!=9999) {
		s = (LNode *)malloc(sizeof(LNode));
		s->data = x;	//输入的x的值作为新结点的数据域 
		r->next = s;	//新结点后插在表尾后面 
		r = s;	//新插入的结点作为表尾结点
		scanf("%d",&x);
	}
	r->next = NULL;	//尾结点后继置空 
	return L; 
}
(2)头插法建立单链表

本质上也是对指定结点(此处为头结点)执行后插操作。

实际上就是在p结点后插操作,只不过p结点固定每次都为头结点而已。

LinkList List_HeadInsert(LinkList &L){
	LNode *s;
	int x;
	L = (LinkList)malloc(sizeof(LNode));	//创建头结点
	L->next = NULL; //初始为空链表 [*]
	scanf("%d",&x);	//输入结点的值
	while(x!=9999) {
		s = (LNode *)malloc(sizeof(LNode));	//创建新结点
		s->data = x; 	//将输入的值赋给新结点的数据域 
		s->next = L->next; 
		L->next = s;	//将新结点插入表中,L为头指针 
		scanf("%d",&x);
	}
	return L;
}

注意:[*]处,初始化空链表的时候,必须将L的next置为NULL。否则插入若干数据结点后,表尾的next会是一个脏数据,而不是NULL。而尾插法为什么不用,是因为尾插法在最后执行了尾结点指针域置空的操作。但总之,你只要是初始化单链表,都先把头指针指向NULL,这是一个好习惯。

重要应用:链表的逆置!!

在使用头插法插入数据的时候,你依次插入a、b、c。那么在此单链表中,从头结点开始,依次为:c、b、a。于是这里引起了一个重要的应用,链表的逆置

也就是再建立一个新的链表,之后将原来的链表从头到尾依次头插法插入新链表中,新链表就是原链表的逆置。

当然,你也可以不建立新链表,而是在原链表,直接依次对每个结点,向原链表的头结点后执行头插,最终得到的结果也是原链表的逆置。

(3)小结
  • 不论是头插法、尾插法,其核心就是单链表的初始化操作,指定结点的后插操作
  • 在使用尾插法的时候,要注意设置一个指向表尾结点的指针。
  • 头插法的重要应用:链表的逆置

(三)双链表

1.双链表的定义

双链表,就是在单链表的基础上,对每个结点再增加一个指针域,这个指针域指向该结点的前驱结点。

  • 单链表:无法逆向检索,有时候不太方便
  • 双链表:可进可退,但存储密度更低一点
typedef struct DNode{	//定义双链表结点类型(D:Double) 
	ElemType data;	//数据域 
	struct DNode *prior, *next;	//前驱和后继指针 
}DNode, *DLinkList;

双链表的初始化(带头结点)

//初始化双链表
bool InitDLinkList(DLinkList &L) {
	L = (DNode *)malloc(sizeof(DNode));	//分配一个头结点
	if(L == NULL)  return false;	//内存不足,分配失败
	L->prior = NULL;	//头结点的prior永远指向NULL
	L->next = NULL;	//头结点之后暂时还没有结点
	return true; 
}

void testDLinkList(){
	DLinkList L;
	InitDLinkList(L);	//初始化双链表 
	//......
}
//判断双链表是否为空(带头结点)
bool Empty(DLinkList L){
	if(L->next == NULL) return true;
	else return false;
}
2.双链表的插入
//在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s) {
	s->next = p->next;
	p->next->prior = s;
	s->prior = p;
	p->next = s;
}

但是,会有一个问题

当p结点为双链表最后一个结点时,在执行该操作时。其中p->next->prior = s;会出现空指针错误。

因此我们要优化一下这段代码,如下所示。

//在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s) {
	if(p == NULL || s == NULL) return false;
	s->next = p->next;
	if(p->next != NULL){	//如果p结点有后继结点 
		p->next->prior = s;
	}	//如果p没有后继节点,则当然不需要修改p的后继节点的前驱指针
	s->prior = p;
	p->next = s;
	return true;
}

同样要注意赋值时的顺序。有些能调换,有些调换了就是错误的逻辑。

实现了双链表后,在对p结点执行前插操作时。就可以立即找到p结点的前驱结点q,再对q进行后插操作即可。实际上都是可以转化为,利用后插来实现。

3.双链表的删除
//删除p的后继结点q
{
	p->next = q->next;
	q->next->prior = p;	//[*]
	free(q);
}

但这样写,同样会有一个问题。就是q为最后一个结点的时候。q的后继节点为NULL,没有前驱结点prior。也就是[*]会引起空指针错误。

//删除p结点的后继结点
bool DeleteNextDNode(DNode *p) {
	if(p == NULL) return false;
	DNode *q = p->next;	//找到p的后继节点q
	if(q == NULL) return false;	//p没有后继
	p->next = q->next;
	if(q->next != NULL) {	//q结点不是最后一个结点 
		q->next->prior = p;
	}
	free(q);
	return true;
}

如果想将此双链表L全部销毁

void DestroyList(DLinkList &L) {
	//循环释放各个数据结点
	while(L->next != NULL) {
		DeleteNextDNode(L);
	}
	free(L);	//释放头结点
	L = NULL;	//头指针指向NULL 
}
4.双链表的遍历
//后向遍历
{
	while(p!=NULL){
		p = p->next;
		//相关操作,如打印,如判断数值==e等 
	}
} 

//前向遍历
{
	while(p!=NULL){
		p = p->prior;
		//相关操作 
	}
} 
//但是这种遍历方法,会把头结点也算在内

//前向遍历
{
	while(p->prior !=NULL) {
		p = p->prior;
	}
} 
//不会包含头结点 

知道怎么前向、后向遍历,那么按位查找、按值查找也就没什么问题。

  • 按位查找:在知道如何前后向遍历的基础上,设置一个遍历次数i,每遍历一次,执行i++,即可实现按位查找。
  • 按值查找:在知道如何前后向遍历的基础上,每遍历到一个结点,判断该结点的数据域是否等于e,即可实现按值查找。

时间复杂度O(n)。

(四)循环链表

1.循环单链表
(1)循环单链表的定义
  • 单链表:表尾结点的next指针指向NULL
  • 循环单链表:表尾结点的next指针指向头结点
typedef struct LNode{	//定义单链表结点类型 
	ElemType data;		//每个结点存放一个数据元素 
	struct LNode *next;	//指针指向下一个结点 
}LNode, *LinkList;
//初始化一个循环单链表
bool InitList(LinkList &L) {
	L = (LNode *)malloc(sizeof(LNode));	//分配一个头结点
	if(L == NULL) return false;	//内存不足,分配失败
	L->next = L;	//头结点next指向头结点
	return true; 
}
//判断循环单链表是否为空
bool Empty(LinkList L) {
	if(L->next = L)	return true;
	else return false;
}
//判断结点p是否为循环单链表的表尾结点
bool isTail(LinkList L, LNode *p) {
	if(p->next == L) return true;
	else return false;
}
(2)循环单链表的好处

单链表

  • 从一个结点出发,只能找到后续的各个结点,而前驱的各个结点,除非获得单链表的表头指针,否则无法得知。
  • 从头结点找到尾部,依次循环遍历,时间复杂度为O(n)。

循环单链表

  • 从一个结点出发可以找到其他任意一个结点。
  • 我们让L不再指向头结点,而是指向尾结点。那么从尾部找到头部,时间复杂度为O(1)。那么此时,我既有尾结点,又有头结点了(往后找一个即可)。而很多时候,链表的操作都是在头部或尾部。那么这样一来,就大大方便了操作,时间复杂度为O(1)。
3.循环双链表
(1)循环双链表的定义
  • 双链表:表头结点的prior指向NULL,表尾结点的next指向NULL
  • 循环双链表:表头节点的prior指向表尾结点,表尾结点的next指向表头节点
//初始化空的循环双链表
bool InitDLinkList(DLinkList &L) {
	L = (DNode *)malloc(sizeof(DNode));	//分配一个头结点
	if(L == NULL)  return false;	//内存不足,分配失败
	L->prior = L;	//头结点的prior指向头结点 
	L->next = L; 	//头结点的next指向头结点
	return true; 
}

void testDLinkList(){
	DLinkList L;
	InitDLinkList(L);	//初始化循环双链表
	//...... 
}
//判断循环双链表是否为空
bool Empty(DLinkList L) {
	if(L->next == L) return true;
	else return false;
}
//判断结点p是否为循环双链表的表尾结点
bool isTail(DLinkList L, DNode *p) {
	if(p->next == L) return true;
	else return false;
}
(2)循环双链表的插入

首先,再看看双链表的插入

//在p结点之后插入s结点
bool InsertNextDNode(DNode *p, DNode *s) {
	s->next = p->next;
	p->next->prior = s;	//[*]
	s->prior = p;
	p->next = s;
}

我们知道,在p为表尾结点的时候,[*]处的语句会出现空指针错误。

但如果我们是循环双链表的话,那么上述代码的逻辑就是完全正确的。

(3)循环双链表的删除

首先,再看看双链表的删除

//删除p的后继结点q
{
	p->next = q->next;
	q->next->prior = p;	//[*]
	free(q);
}

我们知道,在q为表尾结点的时候,[*]处的语句会出现空指针错误。

但如果我们是循环双链表的话,那么上述代码的逻辑就是完全正确的。

(五)静态链表

静态链表的考点不是特别多,其代码实现更是不常考。主要是懂得其逻辑。

1.静态链表的原理
  • 单链表:各个结点在内存中星罗棋布、散落天涯。

单链表中的结点是离散的,分布在内存中的各个角落。每个节点包括一个数据域(数据元素),还有一个指针域(指向下一个结点的指针(地址))。

  • 静态链表:分配一整片连续的内存空间,各个结点集中安置。

静态链表分配一整片的内存空间,其中的数据元素存放在这片内存空间的某些位置。静态链表中的每个结点包含了数据元素,还有下一个结点的数组下标(游标)。

静态链表中,0号结点充当“头结点”,它是不存放数据元素的。

静态链表中每个结点的游标相当于单链表中的指针域。只不过指针域是指明了下一个结点的具体地址,而游标只是指明了在此数组中的下标。

静态链表如果要表示该结点为最后一个结点的话,可以将它的游标的值设为-1。

这样一来,若0号结点的游标为2,那么就可以直接寻找到下标为2的结点的地址。(即静态链表的起始地址addr + sizeof(Node) * 2,实际上就是数组)

2.静态链表的定义
#define MaxSize 10	//静态链表的最大长度 
struct Node{		//静态链表结构类型的定义 
	ElemType data;	//存储的数据元素 
	int next;		//下一个元素的数组下标 
}; 

void testSLinkList(){
    struct Node a[MaxSize];	//数组a作为静态链表
    //......
}
[*] 一个比较少见的typedef的定义方法

如下是课本上给的写法:

#define MaxSize 10	//静态链表的最大长度 
typedef struct {		//静态链表结构类型的定义 
	ElemType data;	//存储的数据元素 
	int next;		//下一个元素的数组下标 
}SLinkList[MaxSize]; 

这种写法没见过,但我们知道它等价于:

#define MaxSize 10	//静态链表的最大长度
struct Node{		//静态链表结构类型的定义 
	ElemType data;	//存储数据元素 
	int next;		//下一个元素的数组下标 
};
typedef struct Node SLinkList[MaxSize];

问题

用typedef给一个结构类型起别名我理解,但是这个别名怎么是一个“数组”呢?

实际上这样写以后。你就可以直接通过SLinkList定义“一个长度为MaxSize的Node型数组”了。

即如下所示,这两个写法是等价的:

void testSLinkList(){
	SLinkList a;
} 

void testSLinkList2(){
	struct Node a[MaxSize];
}

追问:

我理解了这种定义方式的作用了。但是为什么要这样写呢?这样写不别扭吗?为什么不用我们传统的struct Node a[MaxSize];呢?

其实这个地方,和我们之前提到过的LinkLiseLNode *其想要强调含义是一个道理。

我使用SLinkList a;来定义,是想强调我这里正在定义一个静态链表。你一看就明白了,a是一个静态链表。

但是使用struct Node a[MaxSize];,它仅仅是定义一个Node型的数组a。

3.静态链表基本操作的简述
(1)初始化

类似于单链表初始化时需要将头结点的后继设为NULL一样。

静态链表在初始化的时候,要将a[0]的游标,即next设为-1,即它不指向任何一个元素。

此外,还应该将其他的结点游标设一个初始值(例如设为-2),便于判断该结点是否已被占用。(具体原因看下文(3)处)

(2)查找

从头结点出发,根据每个结点的游标指向,依次往后遍历结点。

因此,如果想在静态链表中找到某个位序的结点,那么时间复杂度是O(n)。

注意:我这里说的是位序,而不是数组的下标!

(3)插入

插入位序为i的结点。

  • 第一步:找到一个空的结点,存入数据元素。

    可以有很多种方法,例如对数组进行从前往后(或者从后往前)的扫描,看看哪里是空闲的。

  • 第二步:从头结点出发,找到位序为i-1的结点。

    既然我要插入的结点,其位序为i,那么我肯定要首先找到位序为i-1的结点,然后将其游标做相应修改。

  • 第三步:修改新结点的next。

  • 第四步:修改i-1号结点的next。

注意,此处有一个问题。

在第一步中,我们找到一个空的结点。我们的初衷是好的。

但是,在计算机看来,其每块内存空间,都是有“脏数据”的。

所以我们还应该在对静态链表初始化的时候,把所有的结点的游标先设一个初始值,例如-2。

这样一来,你判断数组中的某结点是否为空闲的,就能够进行判断了(游标是否为-2)。

(4)删除
  • 第一步:从头结点出发,找到其前驱结点。
  • 第二步:修改前驱结点的游标。
  • 第三步:被删除结点的next设为-2。(表示此结点为空闲状态)
(5)总结
  • 静态链表:用数组的方式实现的链表。

  • 优点:增、删操作不需要大量移动元素。

  • 缺点:不能随机存取,只能从头结点开始依次往后查找;容量固定不可变

  • 适用场景:①不支持指针的低级语言;②数据元素数量固定不变的场景(如操作系统的文件分配表FAT)。

当学到操作系统的FAT的时候。就可以知道,它本质上是一个静态链表。

四、顺序表和链表的对比(总结)

(一)逻辑结构

都属于线性表,都是线性结构。

(二)存储结构

顺序表

顺序表是采用顺序存储方式实现的线性表。

  • 优点:由于使用了顺序存储,并且各个数据元素的大小是相等的。因此我们只需要知道这个顺序表的起始地址,那么我们就立即可以找到第i个元素存放的位置。也就是说顺序表拥有随机存取的特性。另一个方面,顺序表每个结点,只需要存储数据元素本身,不需要存储其他的冗余信息,因此顺序表的存储密度更高。

  • 缺点:顺序表的空间是大片的连续空间,分配不方便,且改变容量(不论是静态数组还是动态malloc数组)不方便。

链表

链表是采用链式存储的方式实现的线性表。

  • 优点:离散的小空间,分配方便,改变容量方便。
  • 缺点:当我们要找到第i个结点时,我们只能从表头结点往后依次寻找。所以是不可随机存取的。此外,每个结点除了存储数据元素外,还需存储指针,所以它的存储密度较低。

(三)基本操作

首先,对于任何一种数据结构,都有这几种基本操作:创建、销毁、增删

1.创建(初始化)

顺序表

需要预分配大片连续空间,若分配空间过小,则之后不方便拓展容量;若分配空间过大,则浪费内存资源。

静态分配:静态数组,容量不可改变。

动态分配:动态数组,malloc、free,这样虽然容量可变,但是需要移动大量元素,时间代价高。

链表

只需分配一个头结点(也可以不要头结点,只声明一个头指针),之后方便拓展。

2.销毁

链表

依次删除各个结点(free)。

写一个循环,遍历链表的每个结点,依次进行free。

顺序表

  • 首先,要修改其Length=0。

  • 若顺序表是静态分配的,即声明一个静态数组,那么它是由系统自动回收空间的,当这个数组的生命周期结束时,系统会自动回收;

  • 若顺序表是动态分配的(malloc),则需要手动free。

    由malloc申请的空间,在内存当中属于堆区,在堆区的内存空间不会由系统自动回收。

或者说,在你的程序中,malloc和free必须成对出现。不论是顺序表(malloc申请动态空间)还是链表(malloc申请新结点)。

什么叫堆区,什么叫栈区,此处暂且不讨论。

3.插入、删除

顺序表

由于顺序表中的元素是相邻有序的,那么我们在插入/删除元素时,要将后续元素都后移/前移。

时间复杂度为O(n),时间开销主要来自于移动元素

因此,若数据元素很大,则移动的时间代价很高。(比如一个数据元素就有1MB这么大,那么移动的时候所需的时间代价就很高了)

链表

插入/删除元素只需要修改指针即可。

时间复杂度为O(n),时间开销主要来自于查找目标元素

而对于链表来说,查找数据元素的时间代价是很低的。

综上,对于插入、删除操作来说,链表的效率肯定要比顺序表要高得多。

4.查找

顺序表

  • 按位查找:时间复杂度为O(1),这是因为其能够随机存储。
  • 按值查找:
    • 若表内元素是无序的,则时间复杂度为O(n)
    • 若表内元素的排列本来是有序的,则可通过一些方法来进行更快的查找(如折半查找,其时间复杂度O(log₂n))

链表

  • 按位查找:时间复杂度为O(n)。
  • 按值查找:无论其元素是有序还是无序,都只能从第一个结点开始依次往后遍历,时间复杂度O(n)。

综上,对于查找操作,顺序表的效率是高很多的。

(四)什么时候该使用谁

顺序表链表
弹性(可扩容)TuT^ ^
插入、删除TuT^ ^
查找^ ^TuT
  • 表长难以预估,经常要增加/删除元素 ——链表

    • 如:奶茶店的排队取号系统。

      你不知道哪天人多,哪天人少,而且当一个顾客取号,或取完号,都要频繁的进行增加、删除操作,显然用链表更合适。

  • 表长可预估,查询操作较多 ——顺序表

    • 如:某个班级学生的课堂点名系统

      至少在某个学期内,班级中的人数基本上是一个固定的数值,且点名是一种查询(搜索)的操作,显然用顺序表更合适。

(五)开放式问题的回答思路

注意:对于一些开放式问题的答题思路,例如

  • 请描述顺序表和链表的…(区别、联系…)
  • 实现线性表时,用顺序表还是链表好?

你都可以用这样的思路(框架),来让自己的答题逻辑更加的清晰。

这样的思路(框架):指的是本节中,对顺序表和链表分别从逻辑结构存储结构基本操作三个角度进行了对比,并指出优缺点效率上的差异等。

具体的回答思路,例如:

顺序表和链表的逻辑结构都是线性结构,都属于线性表。

但是二者的存储结构不同,顺序表采用顺序存储…,具有…的特点,从而导致其优点…,缺点…;而链表采用链式存储,具有…的特点,从而导致其优点…,缺点…。

由于采用不同的存储方式实现,因此基本操作的实现效率也不同。当初始化时…;当插入一个数据元素时…;当删除一个数据元素时…;当查找一个数据元素时…。

当然,也并不是说这其中的每一个点都必须写出来,意思就是,你可以根据实际情况,按照这个大致思路进行回答,并且选择把哪些点答上去,哪些可以不答上去。总之,思路是清晰的。

此外,这样的框架性的思路除了便于答题外,也有助于自己的复习、回顾。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

秋秋秋叶

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

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

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

打赏作者

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

抵扣说明:

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

余额充值