目录
1. 双链表
1.1 双链表的初始化
1.2 双链表的插入操作
1.3 双链表的删除操作
1.4 双链表的遍历
2. 循环链表
2.1 循环单链表
2.2 循环双链表
3. 静态链表
4. 顺序表和链表的比较
5. 相关练习
1. 双链表
单链表结点中只有一个指向其后继的指针,使得单链表只能从头结点依次顺序地向后遍历。要访问某个结点的前驱结点(插入、删除操作时),只能从头开始遍历,因为需要从头开始遍历,所以访问其前驱结点的时间复杂度为O(n),访问后继结点的时间复杂度为O(1)。
由于单链表存在上述的缺点,所以引入双链表,双链表能够非常明显的克服以上的缺点,双链表中有两个指针prior和next,分别指向其前驱结点和后继结点。
typedef struct DNode //初始化双链表的结点类型
{
ElemType data; //双链表的数据域
struct DNode *prior, *next; //双链表的前驱指针和后继指针
}DNode,*DLinklist;
双链表在单链表的结点中增加了一个指向其前驱的prior指针,因此双链表中的按值查找和按位查找的操作与单链表相同。但双链表的插入和删除操作的实现上,与单链表有着较大的不同,这是因为 “链” 变化时也需要对prior指针做出修改,关键在于对链进行修改的操作应该建立在链不能断的前提下。此外,双链表不同于单链表的地方在于双链表可以很方便的找到其前驱结点。所以双链表找到前驱结点和后继结点的时间复杂度相同,因此插入和删除操作的时间复杂度仅为O(1)。
1.1 双链表的初始化
//初始化双链表
bool InitDLinkList(DLinkList &L)
{
L = (DNode *)malloc(sizeof(DNode)); //动态开辟一个头结点
if (L == NULL) //内存不足,分配失败
return false;
L->prior = NULL; //头结点的前驱指针永远指向NULL
L->next = NULL; //初始化时头结点的后继暂时还没有结点
return true;
}
1.2 双链表的插入操作
双链表不同于单链表,双链表存在两个指针,在进行插入操作时,既需要处理好后继指针,也需要同时处理好前驱指针。
①②③④步骤分别对应于图中的一二三四步;
①:s->next=p->next; //p的后继指针原本指向c,把p的后继指针赋值给s的后继指针,那么s的后继指针就会指向c;
②:p->next->prior=s; //把s的地址给到p的后继结点的前驱指针,那么p的后继结点的前驱指针也就是c的前驱指针会指向s;
③:s->prior=p; //把p指针的地址给到动态开辟空间s的前驱结点,那么动态开辟空间的前驱指针就会指向p;
④:p->next=s; //把s给到p的后继结点,那么p的后继指针就会指向动态开辟的空间s;
注意:①和②步必须在④步之前,否则 *p 的后继结点的指针就会丢掉,导致插入失败。 因为如果先执行④,那么p的后继结点就会指向s,而不会指向p的后一个结点;当④执行完以后,再去执行①,s的后继结点指向p的后继结点时,由于p的后继结点已经指向了s,那么①语句会实现s的后继结点指向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;
s->prior = p;
p->next = s;
return true;
}
1.3 双链表的删除操作
①:p->next=q->next; //把q的后继指针赋值给p的后继指针,q的后继指针原本指向c,赋值给p的后继指针以后,p的后继指针会指向c;
②:q->next->prior=p; //把p的地址给q的后继指针的前驱指针,q的后继结点就是c,p给到c的前驱指针,c的前驱指针就会指向p;
//删除p结点的后继结点
bool DeleteNextDNode(DNode *p)
{
if (p == NULL)
return false;
DNode *q = p->next; //用指针q指向p 的后继结点
if (q == NULL)
return false;
p->next = q->next;
if (q->next != NULL)//判断被删除的结点q是不是最后一个结点
q->next->prior = p;
free(q);
return true;
}
1.4 双链表的遍历
//向后遍历
while(p!=NULL)
{
p=p->next;//依次向后遍历
}
//向前遍历
while(p!=NULL)
{
p=p->prior;//依次向前遍历
}
2. 循环链表
2.1 循环单链表
循环单链表和单链表的区别在于:表中最后一个结点的指针不是NULL,而是改为指向头结点,如此使得整个链表形成一个环。
单纯的单链表是最后一个结点的指针域是指向NULL,表示链表结束,同时也是判断链表是否结束的一种标志。
而循环单链表中,表尾结点 *r 的next域指向L,也就是表尾结点的指针域指向头结点,故表中没有指针域为NULL的结点,因此,循环单链表的判空条件不是头结点的指针是否为空,而是他是否等于头指针。
循环单链表的插入、删除算法与单链表的几乎一致,因为循环单链表是一个 “环” ,所以循环单链表不同的是如果操作在表尾执行,需要让单链表保持继续循环,也就是使单链表始终保持是一个环,因此在任何一个位置上的插入和删除操作都是等价的,无需判断是否是表尾。
在单链表中只能从表头结点开始往后顺序遍历整个链表,而循环单链表可以从表中任意一个结点开始遍历整个链表。
对单链表进行操作时,通常设头指针和尾指针;而对循环单链表进行操作时,通常只设尾指针,这是为了使操作效率更高,因为单链表是需要从头指针进行依次遍历的,其时间复杂度为O(n),而从尾指针到头指针,也就是r到r->next,只需要一步操作即可,因此从尾指针找到头指针只需要O(1)的时间复杂度。
2.2 循环双链表
通过对循环单链表的学习,循环双链表建立在双链表的基础之上,双链表具有前驱指针prior和后继指针next,能够弥补单链表单向顺序遍历的缺点,循环双链表就是在双链表的特性基础之上,使双链表形成一个环。循环双链表的头结点prior指针还要指向表尾结点。
再循环双链表L中,某结点 *p为尾结点时,p->next=L;当循环双链表为空表时,其头结点的prior域和next域都等于L。
3. 静态链表
静态链表借助数组来描述线性表的链式存储结构,结点也有数据域data和指针域next,不同的是,静态链表的指针是结点的相对地址(数组下标),又称游标。和顺序表一样,静态链表也要预先分配一块连续的内存空间。
#define MaxSize 50 //静态链表的最大长度
typedef struct //静态链表的结构类型定义
{
ElemType data; //静态链表的数据域data
int next; //静态链表的指针域,静态链表不同于单链表的最大区别就是静态链表的指针是对应数组的下标,官方点的称为游标,需要特别注意
}SLinkList[MaxSize];
//以上的代码等价于
#define MaxSize 10
struct Node
{
ElemType data; //静态链表结构类型定义
int next; //存储数据元素
};
typedef struct Node SLinkList[MaxSize];//定义结构体中一个结构变量
//可以用SLinkList定义“一个长度为MaxSize的Node型数组”
静态链表以next==-1作为其结束的标志。静态链表的插入、删除操作与动态链表的相同,只需要修改指针,而不需要移动元素。
单链表初始化时需要将头结点的指针next指向NULL,静态链表初始化时需要将头结点指针next指向-1,因为-1是其结束标志。
查找:
静态链表是从头结点出发根据游标挨个往后遍历结点,所以其时间复杂度是O(n)。
插入位序为 i 的结点(这里不妨假设插入位序为5的结点):
- 首先需要申请开辟一个结点,用来存入数据元素;
- 从头结点按照游标依次找到位序为i-1的结点,该例子中依次找到位序为4的结点,之所以找到位序为4的结点,是因为位序为4的结点对应下一个一定是位序为5的结点,找到位序为4的结点,更改其游标值,使得下一次遍历时能通过游标找到位序为5的结点;
- 修改新结点的next值;将新结点的next值更改为-1,作为静态链表的结束标志;
删除一个结点:
首先也是开辟一块空间,用来存储数据元素,然后从头结点出发依次找到其前驱结点,修改前驱结点的游标值,使得前驱结点的游标值不再指向被删除结点;最后将被删除结点的游标值设为一个特殊的数值,该数值用来标记空闲结点。可以设为-2 等;
总 结 :
- 静态链表是用数组的方式实现的列表。
- 优点:增删操作不需要大量移动元素
- 缺点:不能随机存取,只能从头结点开始依次往后查找
- 切记:静态链表一旦建立,其存储容量是固定不能变的。
- 适用场景:1. 静态链表在不支持指针的高级语言(例如Basic)中,是一个非常巧妙的设计方法。2. 数据元素的数量固定不变的场景(如操作系统的文件分配表FAT)
4. 顺序表和链表的比较
1. 存取读写方式
顺序表是可以顺序存取的,也可以进行随机存取,而链表只能从表头依靠表头指针依次向后遍历存取元素。比方说:第i个位置上执行存取操作,顺序只需要依次随机存取即可完成操作,而链表则需要从表头依次遍历执行i次完成该操作。
2. 逻辑结构与物理结构
采用顺序存储时,逻辑上相邻的元素,对应物理存储位置也相邻。而采用链式存储时,逻辑上相邻的元素,对应的物理存储位置不一定相邻,对应的逻辑关系是通过指针链来表示的。
3. 查找、插入和删除操作
对于按值查找,顺序表无序时,两者的时间复杂度均为O(n);顺序表有序时,可采用折半查找,此时的时间复杂度为O(log以2为底n的对数)。
对于按位查找,顺序表支持随机访问,时间复杂度为O(1),只需要通过一次访问便可以找到相应的元素,而链表则需要通过头指针依次遍历整个链表才能找到相应的元素,因此链表的平均时间复杂度为O(n)。顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需要修改相关结点的指针域即可。
链表的每个结点都带有指针域,故存储密度不大。
4. 空间分配
顺序存储在静态存储分配的情形下,一旦存储空间装满就不能扩充,若再加入新元素,则会出现内存溢出的情形,因此需要预先分配足够大的存储空间。预先分配过大,则会导致顺序表的后部出现大量闲置,造成空间上的浪费;如果预先分配过小,实际使用时,预先分配的空间不够,这时再加入新元素,会造成内存溢出的情形。动态存储分配虽然可以解决内存溢出的问题,也就是存储空间可以随时扩充,但是顺序表逻辑结构上相邻的元素,其物理结构上也相邻,因此需要移动大量的元素,导致操作效率降低。并且根据C语言内存动态开辟的规则,如果内存中有更大块的连续存储空间,那么动态开辟内存优先使用更大块的连续存储空间,将以前不够存储的那块空间舍弃,并将已经存储的数据转移到更大块存储空间上相对应的位置上。而如果没有更大块的连续存储空间,则会导致分配失败。
链式存储的结点空间只在需要时申请分配,只要有内存空间就可以分配,而不用管内存空间是否连续,操作灵活、搞笑。
实际应用中,需要综合考虑,选取存储结构?
1. 基于存储的考虑
在事先无法估计线性表的长度或存储规模时,不建议采用顺序表;链表不用事先估计存储规模,但是链表既需要数据域data,又需要指针域next,因此链表的存储密度较低,显然链式存储结构的存储密度是小于1的。
2. 基于运算的考虑
在顺序表中按位查找时,因为顺序表可以随机访问,所以其时间复杂度为O(1),而链表则需要借助头指针依次遍历,其时间复杂度显然为O(n),所以如果需要经常访问数据元素,显然顺序表优于链表。
在顺序表中进行插入、删除操作时,平均需要移动一半的元素,这是因为顺序表中逻辑结构上相邻的元素,其物理结构上也相邻,因此在顺序表中进行插入、删除操作后,也需要对其后的元素进行前移或者后移的操作。当数据元素信息量较大时,这一影响因素不应被忽视。
3. 基于环境的考虑
顺序表是基于数组的,任何高级语言都有数组类型,因此操作起来也比较简单;链表的操作时基于指针的。相对而言,顺序表的实现更加简单。
5. 相关练习
1. 带头结点的双循环链表L为空的条件是?
L->prior==L&&L->next==L
因为双循环链表既存在前驱指针prior,也存在后继指针next,所以当两个指针都指向头指针L时表示双循环链表L为空。
2. 一个链表最常用的操作是在末尾插入结点和删除结点,则选用( )最节省时间?
带头结点的双循环列表
因为在链表的末尾插入和删除一个结点,需要同时更改末尾结点和其前驱结点的指针域,而寻找末尾结点和其前驱结点又需要从头结点依次向后遍历,因此只有带头结点的双循环链表所需要的时间最少。
3. 在双链表中向p所指的结点之前插入一个结点q的操作为?
p->prior->next=q; q->next=p; q->prior=p->prior; p->prior=q;
在双链表中操作时,切记一个原则:先处理插入结点和其后继结点之间的关系,否则就会导致插入失败。
4. 设对n(n>1)个元素的线性表的运算只有4种:删除第一个元素;删除最后一个元素;在第一个元素之前插入新元素;在最后一个元素之后插入新元素,最好使用?
只有头结点指针没有尾结点指针的循环双链表
因为有头结点的情况下才能依次从头结点遍历找到每个结点。
5. 某线性表用带头结点的循环单链表存储,头指针为head,当head->next->next=head成立时,线性表长度可能是(0或者1)。
6. 已知一个带有表头结点的双向循环链表L,结点结构为prev data next,其中prev和next分别是指向其直接前驱和直接后继结点的指针。现在要删除指针p所指的结点,正确的语句序列是?
P->next->prev=p->prev; p->prev->next=p->next; free(p);
7. 已知表头元素为c的单链表在内存中的存储状态如下表所示
现将 f 存放于1014H处并插入单链表,若 f 在逻辑上位于 a 和 e 之间,则a,e,f 的 “链接地址” 依次是 1014H,1004H,1010H。
单链表的链接结构如上图所示:因为题目中说c是表头元素,c的地址为1008H,c的链接地址1000H就等价于c->next,也就是链接到a的地址,依次类推单链表的链接结构如图所示,若此时插入f 元素到a 和 e 之间,则 a f e 三者要保证,a的链接地址一定要指向f 的地址,f 的链接地址一定要指向e 的地址,e 的链接地址并没有规定需要指向什么,在链表中,只有上一个结点的指针指向下一个结点,才能保证上一个结点找到下一个结点。
所以a e f 三者的链接地址依次是 1014H,1004H,1010H。
8. 设计一个递归算法,删除不带头结点的单链表L中所有值为x的结点。
//设f(L,x)的功能是删除以L为首结点指针的单链表中所有值等于x的结点 //显然有f(L->next,x)的功能是删除以L->next为首结点指针的单链表中所有值等于x的结点 void Del_x_3(LinkList &L, ElemType x) //递归实现在表L中删除值为x的结点 { LNode *p; //定义结点p if (L == NULL) return; //if判断语句实际意思是这样的:假设链表为0 1 2 3 4 5 3 6 3 7,假设x为3,想要删除3,链表是需要依次从头结点开始遍历的 //如果找到了数据域为3的结点,进入判断语句,此时,让定义的指针p指向数据域为x的结点L //L指向下一个结点L->next,让链表开始遍历 //此时指针p指向数据域为x的结点,free释放指针p指向的结点,达到删除数据为x结点的目的 //开始递归调用,因为p指针是指向已经找到数据域为x的下一个结点,L=L->next; //所以下一次递归从已经找到的结点为x的下一个结点开始递归 if (L->data == x) { p = L; //删除*L,并让L指向下一个结点 L = L->next; free(p); Del_x_3(L, x); //递归调用 } //else表示下一个结点的数据域不是x,这个时候从已经扫描过的结点的下一个结点开始递归,也就是L->next else //else表示L所指的结点的值不为x Del_x_3(L->next, x); //递归调用 }
9. 在带头结点的单链表L中,删除所有值为x的结点,并释放其空间,假设值为x的结点不唯一,试编写算法以实现上述操作。
算法思想一:用p从头至尾扫描单链表,pre指向*p结点的前驱。若p所指结点的值为x,则删除该结点,并让p指向下一个结点,否则让pre、p指针同步后移一个结点。
void Del_x_1(LinkList &L, ElemType x) { LNode *p = L->next, *prev = L, *q; //这里定义指针p永远指向头指针的下一个结点,指针prev为L的前驱结点,指针q作为过渡指针使用 while (p != NULL) //只要L的下一个结点不是NULL空结点,我就执行下述操作 { if (p->data == x) { q = p; //让过渡指针(辅助指针)指向这个特殊的结点,也是为了方便后续释放该结点 p = p->next; //移动指针p指向下一个结点 prev->next = p; //因为上一个操作已经让p指向了下一个结点,此时让前驱指针指向它的下下个结点,就跳过了数据域为x的结点q free(q); //释放结点q,达到删除的目的 } else //本次扫描的这一个结点的数据域不等于x { prev = p; //让前驱指针指向下一个结点 p = p->next; //让指针p也指向其下一个结点,其目的是为了保证只要找到数据域为x的结点,就利用其前驱指针指向下下个指针的方法,删除中间结点 } } }
算法思想二:采用尾插法建立单链表。用p指针扫描L所有结点,当其值不为x时,将其链接到L之后,否则将其释放。
void Del_x_2(LinkList &L, ElemType x) { LNode *p = L->next, *r = L, *q; //定义结点p指向L的后继结点,r指向尾结点,那么r->next就指向头结点,q作为其辅助结点,也可以称为过渡结点 while (p != NULL)//只要下一个结点不是空结点NULL,就执行下述操作 { if (p->data!= x) //p结点的数据域不为x时将其链接到L尾部 { r->next = p; r = p; p = p->next; //继续扫描 } else //else表示此时数据域的内容为x { q = p;//辅助结点q指向数据域为x的结点 p = p->next;//继续扫描,一次遍历 free(q);//释放掉数据域为x的结点 } } r->next = NULL;//插入尾结点的后一个结点值为NULL }
10. 设L为带头结点的单链表,编写算法实现从尾到头反向输出每个结点的值。
算法思想:可以借助一个栈来实现,每当经过一个结点,就将该结点的数据域内容放进栈中,因为遍历链表一定是从头到尾的,所以栈的最底部一定放的是头结点,依次遍历存放以后,栈的顶部存放的一定是链表尾部结点数据域的内容,这样打印栈即可实现从尾到头输出每个结点值的目的。
同时也可以使用递归来实现,每当访问一个结点时,先递归输出他后面的结点,再输出结点本身,这样循环下来链表就可以实现反向输出了。
void R_Print(LinkList L) {//实现从尾到头输出的重点在于首先找到最后一个结点,因为链表遍历一定是从头到尾顺序开始遍历的 if (L->next != NULL) //只要下一个结点不是空,我就进入下一个结点 { R_Print(L->next); //因为要反过来输出,所以需要不断递归以找到最后一个结点 } if (L != NULL) print(L->data);//判断最后一个结点是否为空结点,如果不是打印该结点的数据域 }
11. 试编写在带头结点的单链表L中删除一个最小值结点的高效算法(假设最小值结点是唯一的)。
算法思想:只要是删除结点,一定要去设结点的前驱指针,因为如果让定义的指针指向被删除结点,那么是没有办法删除该结点的,要删除结点,一定要利用其前驱指针指向后继结点进而删除中间结点的方法。
所以用p从头到尾扫描单链表,pre是指向*p结点的前驱,用minp表示保存最小值结点的指针,minpre表示保存最小结点数值的前驱指针。一边扫描,一边进行比较,倘若p->data小于minp->data,就将p pre分别赋值给minp和minpre,使得minp和minpre具有记忆功能,使得两指针永远指向最小值结点和最小值结点的前驱结点,最后利用minpre将minp结点删除,也就是释放该结点。
LinkList delete_Min(LinkList &L) { LNode *pre = L, *p = pre->next; //p为工作指针,pre为其前驱 LNode *minpre = pre, *minp = p; //minp为最小值的工作指针,minpre为最小值结点的前驱指针 while (p != NULL) { if (p->data < minp->data)//我们在写程序时默认minp就是存储链表中最小值的数据域的,一旦还有比我们默认的值还小,那么就将更小的值赋给minp { minp = p; minpre = pre;//将其前驱指针也赋值给最小值前驱指针 } pre = p; p = p->next; //继续扫描下一个结点 } minpre->next = minp->next; //删除最小值结点 free(minp); return L; }
12. 试编写算法将带头结点的单链表就地逆置。所谓 “就地” 是指辅助空间复杂度为O(1)。
算法思想:将头结点摘下,然后从第一个结点开始,依次插入到头结点的后面(也就是头插法建立单链表),直到最后一个结点为止,这样下来就实现了链表的逆置。
LinkList Reverse_1(LinkList L) //L是带头结点的单链表,该算法实现将L就地逆序 { LNode *p, *r; //p为工作指针,r为p的后继 p = L->next; //p指针指向L的后继结点,表示从第一个元素结点开始 L->next = NULL; //先将头结点L的next域置为NULL while (p != NULL) { r = p->next; //将p的后继指针暂存到r指针中 p->next = L->next; //以下两步操作实现插入结点操作 L->next = p; p = r; //将后继结点指针赋值给p,方便下次插入结点 } reture L; }
13. 有一个带头结点的单链表L,设计一个算法使其元素递增有序。
void Sort(LinkList &L) { LNode *p = L->next, *pre; //定义头指针和其前驱指针 LNode *r = p->next; //指针r是表示第二部分的第一个结点,因为需要拿第二部分的结点依次和第一部分的结点进行比较排序,所以需要先定义第二部分的首结点,防止断链 p->next = NULL; //执行断链操作,保证第一部分只有头结点+头结点的后继结点 p = r; //因为上面程序已经执行断链操作了,所以需要给第二部分的首结点一个指针r,否则无法操作第二个结点 while (p != NULL) { r = p->next; //保存p的后继结点的指针 pre = L; //pre的用处是判断第二部分比较的结点放在第一部分上的什么位置 while (pre->next != NULL&&pre->next->data < p->data)//因为是递增有序,pre->next->data表示第二部分首结点的数据元素, //p->data表示第一部分结点的元素值,进入while循环的条件是第二部分首结点的元素值小于第一部分结点的元素值 //当然一旦离开结点,那么一定意味着第二部分扫描的结点数据域大小大于了第一部分结点的大小,因为是递增,所以要的就是大于,放在第一部分结点的后部分 { pre = pre->next; //进入while循环意味着小于,不符合要求,那么pre指针在第二部分依次向后遍历 } p->next = pre->next; //以下两条代码实现在第一部分后面插入结点,该结点对应的数据域是递增的,因为如果是比第一部分的结点数据域值还小的话,是不允许离开while循环的 pre->next = p; p = r; //扫描原单链表中剩下的结点 } }
14. 给定两个单链表,编写算法找出两个链表的公共结点。
算法思想:先遍历两个链表得到他们的长度,求出两个链表的长度之差。在长的链表上先遍历长度之差个结点之后,再同步遍历两个链表,直到找到相同的结点,或者一直到链表结束。
LinkList Search_1st_Common(LinkList L1, LinkList L2)
{
int len1 = Length(L1), len2 = Length(L2); //计算两个链表的表长
LinkList longList, shortList; //分别指向表长较长和较短的链表
if (len1 > len2) //如果L1的表长较长
{
longList = L1->next;
shortList = L2->next; //使两个表示表长短的指针分别指向L1和L2
dist = len1 - len2; //计算表长之差
}
else //否则就是len1小于len2
{
longList = L2->next;
shortList = L1->next;
dist = len2 - len1; //计算表长之差
}
while (dist--) //让L1和L2中较长的链表先遍历到第dist个结点,然后同步
{
longList = longList->next; //指针依次向后遍历
while (longList != NULL) //同步寻找共同结点
{
if (longList == shortList) //找到第一个公共结点
{
return longList;
}
else
{
longList = longList->next;
shortList = shortList->next;
}
}
}
return NULL;
}