三遍定律:
理解了单链表本文的理解易如反掌,单链表请点击这里
理解了单链表本文的理解易如反掌,单链表请点击这里
理解了单链表本文的理解易如反掌,单链表请点击这里
1.循环链表
将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表(circular linked list)。
循环链表可以从任意一个结点出发,访问到链表的全部结点。
为了使空链表与非空链表处理一致,我们通常设一个头结点,当然,这并不是说,循环链表一定要头结点,这需要注意。循环链表带有头结点的空链表如图:
对于非空的循环链表就如图:
循环链表和单链表的主要差异就在于循环的判断条件上,原来是判断 p->next 是否为空,现在则是
p->next 不等于头结点,则循环未结束。
在单链表中,我们有了头结点时,我们可以用O(1)的时间访问第一个结点,但对于要访问到最后一个结点,却需要O(n)时间,因为我们需要将单链表全部扫描一遍。
有没有可能用O(1)的时间由链表指针访问到最后一个结点呢?当然可以。
不过我们需要改造一下这个循环链表,不用头指针,而是用指向终端结点的尾指针来表示循环链表,此时查找开始结点和终端结点都很方便了。如图:
从上图中可以看到,终端结点用尾指针 rear 指示,则查找终端结点是 O(1),而开始结点,其实就是rear->next->next,其时间复杂也为 O(1)。
举个程序的例子,要将两个循环链表合并成一个表时,有了尾指针就非常简单了。比如下面的这两个循环链表,它们的尾指针分别是rearA和rearB,如图:
/* 保存 A 表的头结点,即 ① */
p = rearA->next;
/* 将本是指向 B 表的第一个结点(不是头结点)赋值给 RearA->next,即 ② */
rearA->next = rearB->next->next;
q = rearB->next;
/* 将原 A 表的头结点赋值给 rearB->next,即 ③ */
rearB->next = p;
/* 释放 q */
free(q);
2.双向链表
我们的单链表,总是从头到尾找结点,难道就不可以正反遍历都可以吗?当然可以,只不过需要加点东西而已。
我们在单链表中,有了next指针,这就使得我们要查找下一结点的时间复杂度为O(1)。可是如果我们要查找的是上一结点的话,那最坏的时间复杂度就是O(n)了,因为我们每次都要从头开始遍历查找。
为了克服单向性这一缺点,我们的老科学家们,设计出了双向链表。双向链表(double linkedlist)是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。所以在双向链表中的结点都有两个指针域,一个指向直接后继,另一个指向直接前驱。
/* 线性表的双向链表存储结构 */
typedef struct DulNode{
ElemType data;
struct DuLNode *prior; /* 直接前驱指针 */
struct DuLNode *next; /* 直接后继指针 */
} DulNode, *DuLinkList;
既然单链表也可以有循环链表,那么双向链表当然也可以是循环表。双向链表的循环带头结点的空链表如图:
由于这是双向链表,那么对于链表中的某一个结点p,它的 后继的前驱 和 它的 前驱的后继 自然也是它自己,即:
p->next->prior = p = p->prior->next
双向链表是单链表中扩展出来的结构,所以它的很多操作是和单链表相同的,比如求长度的ListLength,查找元素的 GetElem,获得元素位置的 LocateElem 等,这些操作都只要涉及一个方向的指针即可,另一指针多了也不能提供什么帮助。
就像人生一样,想享乐就得先努力,欲收获就得付代价。双向链表既然是比单链表多了如可以反向遍历查找等数据结构,那么也就需要付出一些小的代价:在插入和删除时,需要更改两个指针变量。
插入操作时,其实并不复杂,不过顺序很重要,千万不能写反了。
我们现在假设存储元素e的结点为s,要实现将结点s插入到结点p和p->next之间需要下面几步,如图:
/* 把 p 赋值给 s 的前驱,如图中 ① */
s->prior = p;
/* 把p->next赋值给s的后继,如图中 ② */
s->next = p->next;
/* 把s赋值给p->next的前驱,如图中③ */
p->next->prior = s;
/* 把s赋值给p的后继,如图中④ */
p->next = s;
关键在于它们的顺序,由于第2步和第3步都用到了p->next。如果第4步先执行,则会使得p->next提前变成了s,使得插入的工作完不成。所以我们不妨把上面这张图在理解的基础上记忆,顺序是先搞定s的前驱和后继,再搞定后结点的前驱,最后解决前结点的后继。
如果插入操作理解了,那么删除操作,就比较简单了。
若要删除结点p,只需要下面两步骤,如图:
/* 把p->next赋值给p->prior的后继,如图中① */
p->prior->next = p->next;
/* 把p->prior赋值给p->next的前驱,如图中② */
p->next->prior = p->prior;
/* 释放结点 */
free(p);
双向链表相对于单链表来说,要更复杂一些,毕竟它多了prior指针,对于插入和删除时,需要格外小心。另外它由于每个结点都需要记录两份指针,所以在空间上是要占用略多一些的。不过,由于它良好的对称性,使得对某个结点的前后结点的操作,带来了方便,可以有效提高算法的时间性能。说白了,就是用空间来换时间。
3.静态链表
C语言具有指针这一强大的功能,也是众多计算机领域的人用来描述数据结构首选C语言的原因之一。指针可以使C非常容易的操作内存中的地址和数据,这比其他高级语言更加灵活方便。Java、C#等面向对象语言,虽然不使用指针,但因为启用了对象引用机制,从某种角度也间接实现了指针的某些作用。但对于其他一些语言,如Basic、Fortran等早期的编程语言对于一些数据结构的操作就没有那么方便了。
有前辈想出来用数组来代替指针,描述单链表:首先让数组的元素都是由两个数据域组成,data和cur。也就是说,数组的每个下标都对应一个data和一个cur。数据域data,用来存放数据元素,也就是通常我们要处理的数据;而cur相当于单链表中的next指针,存放该元素的后继在数组中的下标,我们把cur叫做游标。
我们把这种用数组描述的链表叫做静态链表,这种描述方法还有起名叫做游标实现法。
为了我们方便插入数据,通常会把数组建立得大一些,以便有一些空闲空间可以便于插入时不至于溢出。
用代码描述一下:
/* 线性表的静态链表存储结构 */
/* 假设链表的最大长度是1000 */
#define MAXSIZE 1000
typedef struct
{
ElemType data;
/* 游标(Cursor),为0时表示无指向 */
int cur;
} Component,
StaticLinkList[MAXSIZE];
对数组第一个和最后一个元素作为特殊元素处理,不存数据。我们通常把未被使用的数组元素称为备用链表。而数组第一个元素,即下标为0的元素的cur就存放备用链表的第一个结点的下标;而数组的最后一个元素的cur则存放第一个有数值的元素的下标,相当于单链表中的头结点作用,当整个链表为空时,则为0。如图:
假设我们已经将数据存入静态链表,比如分别存放着“甲”、“乙”、“丁”、“戊”、“己”、“庚”等数据,则它的状态如图所示:
此时“甲”这里就存有下一元素“乙”的游标2,“乙”则存有下一元素“丁”的下标3。而“庚”是最后一个有值元素,所以它的cur设置为0。而最后一个元素的cur则因“甲”是第一有值元素而存有它的下标为1。而第一个元素则因空闲空间的第一个元素下标为7,所以它的cur存有7。
(1)静态链表的插入操作
静态链表中解决的是:如何用静态模拟动态链表结构的存储空间的分配,需要时申请,无用时释放。
在动态链表中,结点的申请和释放分别借用 malloc() 和 free() 两个函数实现。在静态链表中,操作的是数组,不存在像动态链表的结点申请和释放问题,所以我们需要自己实现这两个函数,才可以做插入和删除操作。
为了辨明数组中哪些分量未被使用,解决办法是将所有未被使用过的及被删除的分量用游标链成一个备用的链表,每当进行插入时,便可以从备用链表上取的第一个结点作为待插入的新结点。
/* 若备用空间链表非空,则返回分配的结点下标,否则返回 0 */
int Malloc_SLL(StaticLinkList space)
{
/* 当前数组第一个元素的 cur 存的值,就是要返回的第一个备用的空闲的下标 */
int i = space[0].cur;
/* 由于要拿出一个分量来使用了,所以我们就得把它的下一个分量用来做备用 */
if (space[0].cur)
space[0].cur = space[i].cur;
return i;
}
这段代码,一方面它的作用就是返回一个下标值,这个值就是数组头元素的cur存的第一个空闲的下标。从上面的图示例子来看,其实就是返回7。
那么既然下标为7的分量准备要使用了,就得有接替者,所以就把分量7的cur值赋值给头元素,也就是把8给space[0].cur,之后就可以继续分配新的空闲分量,实现类似malloc()函数的作用。
现在我们如果需要在“乙”和“丁”之间,插入一个值为“丙”的元素,按照以前顺序存储结构的做法,应该要把“丁”、“戊”、“己”、“庚”这些元素都往后移一位。但目前不需要,因为我们有了新的手段。
新元素“丙”,想插队是吧?可以,你先悄悄地在队伍最后一排第7个游标位置待着,我一会就能帮你搞定。我接着找到了“乙”,告诉他,你的cur不是游标为3的“丁”了,这点小钱,意思意思,你把你的下一位的游标改为7就可以了。“乙”叹了口气,收了钱把cur值改了。此时再回到“丙”那里,说你把你的cur改为3。就这样,在绝大多数人都不知道的情况下,整个排队的次序发生了改变。如图:
/* 在 L 中第 i 个元素之前插入新的数据元素 e */
Status ListInsert(StaticLinkList L, int i, ElemType e)
{
int j, k, l;
/* 注意 k 首先是最后一个元素的下标 */
k = MAX_SIZE - 1;
if (i < 1 || i > ListLength(L) + 1)
return ERROR;
/* 获得空闲分量的下标 */
j = Malloc_SLL(L);
if (j)
{
/* 将数据赋值给此分量的data */
L[j].data = e;
/* 找到第i个元素之前的位置 */
for (l = 1; l <= i - 1; l++)
k = L[k].cur;
/* 把第i个元素之前的cur赋值给新元素的cur */
L[j].cur = L[k].cur;
/* 把新元素的下标赋值给第i个元素之前元素的cur */
L[k].cur = j;
return OK;
}
return ERROR;
}
当我们执行插入语句时,我们的目的是要在“乙”和“丁”之间插入“丙”。调用代码时,输入 i 值为 3。
让 k = MAX_SIZE-1=999。
j = Malloc_SSL(L) = 7。此时下标为 0 的 cur 也因为 7 要被占用而更改备用链表的值为 8。
for 循环 l 由 1 到 2,执行两次。代码 k = L[k].cur ; 使得 k = 999,得到 k = L[999].cur = 1,再得到 k = L[1].cur = 2。
L[j].cur = L[k].cur ; 因 j = 7,而 k = 2 得到L[7].cur=L[2].cur = 3。这就是刚才我说的让“丙”把它的cur 改为 3的意思。
L[k].cur = j;意思就是L[2].cur = 7。也就是让“乙”得点好处,把它的 cur 改为指向“丙”的下标 7。
就这样,我们实现了在数组中,实现不移动元素,插入了数据的操作。
(2)静态链表的删除操作
删除元素时,原来是需要释放结点的函数 free()。现在我们要自己实现它:
/* 删除在 L 中第 i 个元素 e */
Status ListDelete(StaticLinkList L, int i)
{
int j, k;
if (i < 1 || i > ListLength(L))
return ERROR;
k = MAX_SIZE - 1;
for (j = 1; j <= i; j++)
k = L[k].cur;
j = L[k].cur;
L[k].cur = L[i].cur;
Free_SSL(L,j); /* 看下文 */
return OK;
}
将下标为k的空闲结点回收到备用链表代码:
/* 将下标为k的空闲结点回收到备用链表 */
void Free_SSL(StaticLinkList space, int k)
{
/* 把第一个元素 cur 值赋给要删除的分量 cur */
space[k].cur = space[0].cur;
/* 把要删除的分量下标赋值给第一个元素的 cur */
space[0].cur = k;
}
甲现在要走,这个位置就空出来了,也就是,未来如果有新人来,最优先考虑这里,所以原来的第一个空位分量,即下标是 8 的分量,它降级了,把 8 给“甲”所在下标为 1 的分量的 cur,也就是 space[1].cur = space[0].cur = 8,而 space[0].cur = k = 1 其实就是让这个删除的位置成为第一个优先空位,把它存入第一个元素的 cur 中,如图:
上图: