线性表的链式表示——链表

目录

一、单链表

1、单链表的定义

2、单链表的基本操作 

(1)单链表的初始化

(2)插入操作

(3)删除操作

(4)查找操作

(5)求表长操作

(6)单链表的建立

二、双链表

三、循环链表 

1、循环单链表

2、循环双链表

四、静态链表 

五、顺序表和链表的比较

1、存取方式

2、逻辑结构与物理结构

3、基本操作

4、实际应用


一、单链表

1、单链表的定义

        线性表的链式存储又称为单链表,它是通过一组任意的存储单元来存储线性表中的数据元素。为了建立数据元素之间的线性关系,对每个链表节点,除存放元素自身的信息之外,还需要存放一个指向其后继的指针。单链表结点结构如图所示,其中data为数据域,存放数据元素;next为指针域,存放其后继结点的地址。

        利用单链表可以解决顺序表需要大量连续存储单元的缺点,但附加的指针域,也存在浪费存储空间的缺点。由于单链表的元素离散地分布在存储空间中,因此是非随机存取的存储结构,即不能直接找到表中某个特定节点。查找特定节点时,需要从表头开始遍历,依次查找。

        通常用头指针L(或head)来标识一个单链表,指出链表的起始地址,头指针为NULL时表示一个空表。此外,为了操作方便,在单链表第一个数据结点之前附加一个结点,称为头结点。头结点数据域可以不设任何信息,但也可以记录表长等信息。单链表带头结点时,头指针L指向头结点,单链表不带头结点时,头指针L指向第一个数据结点。表尾结点的指针域为NULL(用“^”表示)。

        引入头结点,可以带来两个优点:

        ①由于第一个数据结点的位置被存放在头结点的指针域中,因此在链表的第一个位置上的操作和在表的其他位置上的操作一致,无需特别处理。

        ②无论链表是否为空,其头指针都是指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也就得到了统一。

        单链表中结点类型的描述如下:

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

        如果要增加一个新的结点,则需在内存中申请一个结点所需空间,并用指针p指向这个结点。

struct LNode *p = (struct LNode*)malloc(sizeof(struct LNode));

        当我们想要定义一个新的结点或者指向结点的指针时,每次都要写struct LNode,这样写相对来说比较麻烦,所以我们可以使用关键字typedef,将数据类型重命名。

typedef <数据类型> <别名>
typedef struct LNode LNode;
LNode *p = (LNode*)malloc(sizeof(Lnode);

         进一步简化,有:

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

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

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

LNode *L;    //声明一个指向单链表第一个结点的指针
//或LinkList L;    //声明一个指向单链表第一个结点的指针

        以上两者是等价的,效果相同,但是它们强调的意义是不相同的。例如在下面的查找节点操作中,分别使用了LNode *和LinkList,合适的地方使用合适的名字,代码可读性更高。

2、单链表的基本操作 

(1)单链表的初始化

        带头结点和不带头结点的单链表初始化操作是不同的。带头结点的单链表初始化时,需要创建一个头结点,并让头指针指向头结点,头结点的next域初始化为NULL。不带头结点的单链表初始化,只需将头指针L初始化为NULL。

        不带头结点:

        带头结点:

(2)插入操作

        按位序插入

        插入结点操作将值为e的新结点插入到单链表第i个位置。先检查插入位置的合法性,然后找到待插入位置的前驱,即第i-1个结点,再在其后插入。

//在第i个位置插入元素e(带头结点)
bool ListInsert(LinkList &L,int i,ElemType e){
	if(i<1)    
		return false;
	LNode *p;    //指针p指向当前扫描到的结点
	int j=0;    //记录当前结点的位序,头结点是第0个结点
	p = L;    //L指向头结点
	while(p!=NULL && j<i-1){    //循环找到第i-1个结点
		p=p->next;
		j++;
	}
	if(p==NULL)         
		return false;   /*假设有3个数据元素,要插入到第5个位置上,经过while循环,
                         p会指向NULL,所以i值不合法,返回false*/
	LNode *s =(LNode *)malloc(sizeof(LNode));    //为新结点分配内存空间
	s->data = e;    //将e的值存入新结点
	s->next=p->next;    //s结点的next指向p结点的next
	p->next=s;        //将结点s连到p之后
	return true;    //插入成功
}
/*注意s->next=p->next; 和 p->next=s;  顺序不能颠倒,
  否则先执行p->next=s,p指向了s结点,而s->next=p->next
  相当于s结点自己指向了自己,显然是错误的。*/

        若插入位置i=1,则算法时间复杂度为O(1),若插入位置为元素末尾,则算法时间复杂度为O(n),所以与顺序表的插入算法的平均时间复杂度计算类似,该算法的平均时间复杂度为O(n)。需要注意的是,当链表不带头结点时,需要判断插入位置i是否为1,若是,则要做特殊处理,将头指针L指向新的首结点。当链表带头结点时,插入位置i=1不用做特殊处理。

//在第i个位置插入元素e(不带头结点)
bool ListInsert(LinkList &L,int i,ElemType e){
	if(i<1)    
		return false;
    if(i==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指向第一个结点
	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;    //将e的值存入新结点
	s->next=p->next;    //s结点的next指向p结点的next
	p->next=s;        //将结点s连到p之后
	return true;
}

        显然如果不带头结点,则插入、删除第一个元素时,需要更改头指针L,实现起来相对麻烦。

        指定结点后插操作

//后插操作:在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;  
}

        显然这段代码的时间复杂度为O(1)。

        指定结点前插操作

        若要进行前插操作,则需先找到插入结点的前驱,再对其进行后插操作。由此可知,对结点的前插操作均可转化为后插操作,前提是从单链表的头结点开始顺序查找到其前驱结点,时间复杂度为O(n)。

        此外,可采用另一种方式将其转化为后插操作来实现,设待插入结点为*s,,我们仍将*s插入到*p的后面,然后将p->data与s->data交换,这样做既满足逻辑关系,又能使得时间复杂度为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;        //将结点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;    //交换数据域部分
    p->data=s->data;
    s->data=temp;
	return true;  
}

(3)删除操作

        按位序删除

        删除结点操作是将单链表的第i个结点删除。先检查删除位置的合法性,然后查找表中第i-1个结点,即被删结点的前驱,再删除第i个结点。假设*q为被删结点,*p为找到的被删结点的前驱,仅需修改*p的指针域,将*p的指针域指向*q的下一结点,然后释放*q的空间。

//按位序删除(带头结点)
bool ListDelete(LinkList &L,int i,ElemType &e){
	if(i<1)    
		return false;
	LNode *p;    //指针p指向当前扫描到的结点
	int j=0;    //记录当前结点的位序,头结点是第0个结点
	p = L;    //L指向头结点
	while(p!=NULL && j<i-1){    //循环找到第i-1个结点
		p=p->next;
		j++;
	}
	if(p==NULL)         //i值不合法
		return false;   
    if(p->next == NULL)    //第i-1个结点之后再无其他结点
        return false;
	LNode *q=p->next;    //令q指向被删除结点
    e = q->data;        //用e返回元素的值
    p->next=q->next;    //将*q结点从链中“断开”
    free(q);        //释放结点的存储空间
	return true;    //删除成功
}

        同插入算法一样,该算法的主要时间也耗费在查找操作上,时间复杂度为O(n)。当链表不带头结点时,需要判断被删结点是否为首结点,若是,则要做特别处理,将头指针指向新的首结点,当链表带头结点时,删除首结点和删除其他结点的操作是相同的。

//按位序删除(不带头结点)
bool ListDelete(LinkList &L,int i,ElemType &e){
	if(i<1)    
		return false;
	LNode *p;    //指针p指向当前扫描到的结点
    LNode* q;
    if(i == 1)//删除第一个结点的情况
	{
		p = L;
		e = p->data;
		q = L->next;
		free(p);//释放p结点
		L = q;
		return true;    //删除成功
	}
	int j=1;    //当前p指向的是第几个结点
	p = L;    //L指向头结点
	while(p!=NULL && j<i-1){    //循环找到第i-1个结点
		p=p->next;
		j++;
	}
	if(p==NULL)         //i值不合法
		return false;   
    if(p->next == NULL)    //第i-1个结点之后再无其他结点
        return false;
	q=p->next;    //令q指向被删除结点
    e = q->data;        //用e返回元素的值
    p->next=q->next;    //将*q结点从链中“断开”
    free(q);        //释放结点的存储空间
	return true;    //删除成功
}

        删除指定结点

        要删除某个给定结点*p,通常的做法是先从链表的头结点开始顺序找到其前驱,然后执行删除操作。其实,删除结点*p的操作可用删除*p的后继来实现,实质就是将其后继的值赋予其自身,然后再删除其后继,也能使得时间复杂度为O(1)。

//删除指定结点p
bool DeleteNlode(LNode *p){
    if(p==NULL)
        return false;
    LNode *q=p->next;    //令q指向*p的后继结点
    p->data=p->next->data;    //和后继结点交换数据域
    p->next=q->next;    //将*q结点从链中“断开”
    free(q);            //释放后继结点的存储空间
    return true;
}

        但是这段代码也存在一个问题,如果p是最后一个结点,那么它的后继结点为空,则不能交换数据域,出现空指针的错误,此时只能从表头开始依次寻找p的前驱,然后再进行删除结点的操作,时间复杂度为O(n)。由此可以看出单链表的局限性:无法逆向检索,有时候不太方便。

(4)查找操作

        按位序查找结点

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

还可以写成如下形式

LNode *GetElem(LinkList L,int i){
	int j=1;    //记录当前结点的位序,指向的是第一个结点(不是头结点)
	LNode *p = L->next;    //L指向头结点,p指向L的next也就是第一个结点
    if(i==0)    
		return L;        //如果i=0,则返回头结点
    if(i<1)    
		return NULL;
	while(p!=NULL && j<i){    //循环找到第i个结点
		p=p->next;
		j++;
	}
    return p;   
}

        按位序查找操作的时间复杂度为O(n)。

        封装

        由于插入和删除操作都用到了查找操作,插入还使用了后插操作,所以我们可以将代码进行封装。

        按值查找

        按值查找算法时间复杂度为O(n)。 

(5)求表长操作

        求表长操作是计算单链表中数据节点的个数,需要从第一个结点依次访问,为此需设置一个计数变量,每访问一个结点,其值加1。

//带头结点
int length(LinkList L){
	int len=0;		//计数变量,初始为0
	LNode *p=L;
	while(p->next!=NULL){
		p=p->next;
		len++;	//每访问一个结点,计数加1
	}
	return len;
}
	

        不带头结点求表长时,只需要将len的初始值设为1就可以了,该算法时间复杂度为O(n)。

(6)单链表的建立

        头插法

        该方法从一个空表开始,生成新结点,并将读取到的数据存放到新结点的数据域中,然后将新结点插入到当前链表的表头,即头结点之后。

LinkList List_HeadInsert(LinkList &L){	//逆向建立单链表
	LNode *s;int x;			//设元素类型为整型
	L=(LNode*)malloc(sizeof(LNode));	//创建头结点
	L->next=NULL;			//初始为空链表
	scanf("%d",&x);			//输入结点的值
	while(x!=9999){			//输入9999表示结束
		s=(LNode*)malloc(sizeof(LNode));//创建新结点
		s->data=x;
		s->next=L->next;
		L->next=s;			//将新结点插入表中
		scanf("%d",&x);
	}
	return L;
}

         采用头插法建立单链表时,读入数据的顺序与生成的链表中元素的顺序是相反的,可以采用实现链表的逆置。每个结点插入的时间为O(1),设单链表长为n,则总时间复杂度为O(n)。

        尾插法

        对于上图所示的尾插法用到的是后插操作,另外需要设置变量length记录链表长度,每次取一个数据元素,调用后插操作其插入length+1的位置上,然后再把length+1。但是每次都要从头开始遍历,从插入第一个结点开始,每次遍历的次数以此为0、1、2……n-1,所以算法时间复杂度为0+1+2+……+(n-1)=O(n²),算法时间复杂度较高,我们没有必要每次都重复遍历。

        所以在尾插法中,当我们将新结点插入到当前链表的表尾时,必须增加一个尾指针r,使其始终指向当前链表的尾结点。

LinkList List_TailInsert(LinkList &L){	//正向建立单链表
	int x;			//设元素类型为整型
	L=(LNode*)malloc(sizeof(LNode));	//创建头结点
	LNode *s,*r=L;			//r为表尾指针
	scanf("%d",&x);			//输入结点的值
	while(x!=9999){			//输入9999表示结束
		s=(LNode*)malloc(sizeof(LNode));//创建新结点
		s->data=x;
		r->next=s;
		r=s;			//r指向新的表尾结点
		scanf("%d",&x);
	}
	r->next=NULL;		//尾结点指针置空
	return L;
}

        因为附设了一个指向表尾结点的指针,所以时间复杂度和头插法相同为O(n)。

二、双链表

        单链表结点中只有一个指向其后继的指针,使得单链表只能从前往后依次遍历。要访问某个结点的前驱(插入、删除操作时),只能从头开始遍历,访问前驱的时间复杂度为O(n)。为了克服单链表的这个缺点,引入了双链表,双链表结点中有两个指针prior和next,分别指向其直接前驱和直接后继。表头结点的prior和表尾结点的next域都是NULL。

         双链表中结点类型的描述如下:

typedef struct DNode{    //定义双链表结点类型
    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);
	//后续代码……
}

         双链表在单链表节点中增加了一个指向其前驱的指针prior,因此按值查找和按位查找的操作与单链表相同。但双链表在插入和删除操作的实现上,与单链表有着较大的不同。这是因为“链”变化时也需要对指针prior做出修改,其关键是保证在修改的过程中不断链。此外,双链表可以很方便地找到当前结点的前驱,因此,插入、删除操作的时间复杂度仅为O(1)。

        双链表的插入操作

//在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;
	s->prior=p;
	p->next=s;
	return true;
}
	

        由于双链表带有指向前驱结点的prior指针,当我们进行按位插入和前插操作时,只需要将其转化为后插操作即可实现。

        双链表的删除操作

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

        销毁双链表

void DestoryList(DLinkList &L){
	//循环释放各个数据结点
	while(L->next!=NULL)
		DeleteNextDNode(L);
	free(L);	//释放头结点
	L=NULL;		//头指针指向NULL
}

        双链表的遍历

三、循环链表 

1、循环单链表

        循环单链表的定义

        循环单链表的初始化 

        对于单链表来说,从一个结点出发只能找到它后续的各个结点,而循环单链表从一个结点出发可以找到其他任何一个结点,从尾部找到头部,时间复杂度为O(1)。所以在删除操作中,循环单链表方便我们找到删除结点的前驱,修改它的指针。如果我们经常对表头或表尾进行操作时,我们可以让L指向表尾元素(插入、删除时可能需要修改L)。

2、循环双链表

        循环双链表的定义

        循环双链表的初始化 

        循环双链表的插入 

         循环双链表的删除

四、静态链表 

        静态链表是用数组来描述线性表的链式存储结构,结点也有数据域data和指针域next,与前面所讲的链表中的指针不同的是,这里的指针是结点在数组中的相对地址(数组下标),又称游标。和顺序表一样,静态链表也要预先分配一块连续的内存空间。

        静态链表结构类型的描述

        上述两段代码是等价的, 可由下面的代码验证

        SLinkList b相当于定义了一个长度为MaxSize的Node型数组。

        简述静态链表的基本操作

        静态链表以next==-1作为其结束的标志。在初始化静态链表时,我们把a[0]的next设为-1。在进行查找操作时,从头结点出发挨个往后遍历结点,所以当我们要查找某个位序的结点时,时间复杂度为O(n)。静态链表的插入、删除操作与动态链表的相同,只需要修改指针,而不需要移动元素。静态链表的优点是增、删操作不需要移动大量元素;缺点是不能随机存取,只能从头结点开始依次往后查找,且容量固定不变。静态链表适用于不支持指针的低级语言以及数据元素数量固定不变的场景(如操作系统的文件分配表FAT)。

五、顺序表和链表的比较

1、存取方式

        顺序表可以顺序存取,也可以随机存取,链表只能从表头开始依次顺序存取。

2、逻辑结构与物理结构

        采用顺序存储时,逻辑上相邻的元素,对应的物理存储位置也相邻。而采用链式存储时,逻辑上相邻的元素,物理存储位置不一定相邻,对应的逻辑关系是通过指针链接来表示的。

3、基本操作

        (创)

        顺序表需要预分配大片连续空间。若分配空间过小,则之后不方便扩展容量;若分配空间过大,则浪费内存资源。动态存储分配虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中没有更大块的连续存储空间,则会导致分配失败。链式存储只需分配一个头结点(也可以不要头结点,只声明一个头指针),之后方便扩展。

        (销)

        对于链表的销毁操作,我们使用free函数依次删除链表中的各个结点。而对于顺序表来说,首先我们将它的length修改为0,这一步只是在逻辑上使得顺序表变成了一个空表,具体实现上,如果我们使用的是静态分配方式,也就意味着顺序表所占用的存储空间是通过声明一个静态数组的方式请求系统分配的,那么这片存储空间的回收是由系统自动进行的,当定义的静态数组生命周期结束,系统就会自动回收空间;如果采用的是动态分配方式,动态数组是由malloc函数申请的一片空间,需要手动写free函数进行释放,系统不会自动回收。

        (增、删)

        对于顺序表来说,插入/删除元素要将后续元素后移/前移,而对于链表,插入/删除元素只需要修改指针即可。两者的时间复杂度均为O(n),顺序表的时间开销主要来自移动元素,而链表的时间开销主要来自查找目标元素。若数据元素很大,则移动的时间代价很高,而查找的时间代价更低。

        (查找)

        对于按值查找,顺序表无序时,两者的时间复杂度均为O(n);顺序表有序时,可采用折半查找,此时的时间复杂度为O(log₂n)。对于按位查找,顺序表支持随机访问,时间复杂度仅为O(1),而链表的时间复杂度为O(n)。

4、实际应用

        基于存储考虑,难以估计线性表的长度或存储规模时,不宜采用顺序表;链表不用事先估计存储规模,但链表的存储密度较低,显然链式存储结构的存储密度是小于1的。

        基于运算考虑,在顺序表中按序访问的时间复杂度为O(1),因此若经常做的运算是按序号访问数据元素,显然顺序表优于链表。表长可预估、查询(搜索)操作较多时使用顺序表,表长难以预估、经常要增加/删除元素时使用链表。

        基于环境考虑,顺序表容易实现,任何高级语言中都有数组类型;链表的操作是基于指针的,相对来讲,前者实现较为简单。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值