Skip to content

2.9 链表的六个操作与头结点

2.9.1 初始化、求长度、取第i个元素、查找

初始化和求长度

空表是head为NULL、length为0:

c
void Init(LinkList *L) {
    L->head = NULL;
    L->length = 0;
}

int Length(LinkList *L) {
    return L->length;
}

取第i个元素

顺序表的Get是一次下标运算。链表没有下标,第i个结点在哪只有一个办法知道:从head出发,顺着next走i步。

cat为例,Get(L, 2)要取t。从head出发,走0步是c,走1步到a,走2步到t:

text
head


┌───┬───┐     ┌───┬───┐     ┌───┬───┐
│ c │ ●─┼────►│ a │ ●─┼────►│ t │ ╱ │
└───┴───┘     └───┴───┘     └───┴───┘
  p 第0步       p 第1步       p 第2步

用一个指针p记当前走到哪,每走一步p = p->next,走够i步停下,p->data就是要的元素:

c
char Get(LinkList *L, int i) {
    if (i < 0 || i >= L->length) return '\0';
    Node *p = L->head;
    for (int k = 0; k < i; k++)
        p = p->next;
    return p->data;
}

把Get(L, 2)带进去走一遍:p先等于head,指向c;k=0时p = p->next,指向a;k=1时再走一步,指向t;k=2不满足k<2,循环结束,返回p->data即t。

"从head出发走到第i个结点"这个动作,后面的Insert和Delete也要用,单独抽成一个函数:

c
Node *NodeAt(LinkList *L, int i) {   /* 返回第i个结点的地址 */
    Node *p = L->head;
    for (int k = 0; k < i; k++)
        p = p->next;
    return p;
}

Get就变成一行:return NodeAt(L, i)->data;

查找

Locate也是从head走到尾,只是每到一个结点比较一下数据,同时用一个计数器记走到了第几个:

c
int Locate(LinkList *L, char x) {
    int i = 0;
    for (Node *p = L->head; p != NULL; p = p->next, i++)
        if (p->data == x) return i;
    return -1;
}

Locate(L, 't'):p在c,不等,i变1;p在a,不等,i变2;p在t,相等,返回2。

2.9.2 在第i个位置插入:三种位置

这是链表最重要的操作,也是它相对顺序表的优势所在:不挪动任何结点,新结点随便放在哪个空格子里,只改地址。插入位置不同,要改的地址不同,分三种情况看。

插在第0个位置。 比如在cat最前面插入b。新结点b要成为第一个,那么b的next应该指向原来的第一个结点c,而head应该改为指向b。要改的两个地址是b的next和head:

text
head ──────────────┐

┌───┬───┐     ┌───┬───┐     ┌───┬───┐     ┌───┬───┐
│ b │ ●─┼────►│ c │ ●─┼────►│ a │ ●─┼────►│ t │ ╱ │
└───┴───┘     └───┴───┘     └───┴───┘     └───┴───┘

  └── head 改为指向这里

空表也是这种情况:head是NULL,b的next得到NULL,正好是末尾标记;head改为指向b。不需要额外处理。

插在中间。 比如在a和t之间插入x,即Insert(L, 2, 'x'),结果应为caxt。要改的是x的next和a的next,head不动。但这里有一个顺序表没有的麻烦:我们手里只有head,a在哪并不知道。所以第一步不是改地址,而是找到a——从head出发走一步,这正是NodeAt(L, 1)。找到之后才能改。下面几张图把地址标出来,看清右格里的数字怎么变。假设x放在102号。

第一步,从head走到第1个结点a,用prev记住它:

text
head = 100


┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐
│ c │ 108 │────►│ a │ 104 │────►│ t │  0  │
└───┴─────┘     └───┴─────┘     └───┴─────┘
    100             108             104
                   prev

第二步,造出x结点,此时它的next还是0,谁也不指向它:

text
┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐
│ c │ 108 │────►│ a │ 104 │────►│ t │  0  │
└───┴─────┘     └───┴─────┘     └───┴─────┘
    100             108             104
                   prev
                          ┌───┬─────┐
                          │ x │  0  │
                          └───┴─────┘
                              102

第三步,让x的next指向t。t的地址是104,它现在记在prev的右格里,把它抄到x的右格:

text
┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐
│ c │ 108 │────►│ a │ 104 │────►│ t │  0  │
└───┴─────┘     └───┴─────┘     └───┴─────┘
    100             108          ▲  104
                   prev          │
                          ┌───┬──┴──┐
                          │ x │ 104 │
                          └───┴─────┘
                              102

第四步,让prev的next指向x。把a的右格从104改成102:

text
┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐
│ c │ 108 │────►│ a │ 102 │     │ t │  0  │
└───┴─────┘     └───┴───┬─┘     └───┴─────┘
    100             108 │        ▲  104
                   prev │        │
                        │ ┌───┬──┴──┐
                        └►│ x │ 104 │
                          └───┴─────┘
                              102

从head出发:100→108→102→104,读出caxt。c、t两个结点一个没动,只改了两个右格。

第三步和第四步的顺序不能反。如果先做第四步,把a的右格改成102,那么t的地址104就没人记着了——它原本只存在a的右格里。这时再做第三步,想让x指向t,已经不知道t在哪。先让新结点指向后一个,再让前一个指向新结点,这是链表插入必须记住的顺序。

注意四步里只有后两步是真正的插入,第一步"找到前一个结点"是走出来的,位置越靠后走得越远。这一点2.11节比较效率时会再说。

插在末尾。 比如在t后面插入s,即Insert(L, 3, 's'),得到cats。找到第2个结点t,让s的next指向t的next(是NULL),再让t的next指向s。和中间插入是同一套动作,只是"后一个"恰好是NULL,不需要单独处理。

所以三种情况其实是两种:第0个位置改head,其余位置找到第i−1个结点prev、改prev的next。写成代码:

c
int Insert(LinkList *L, int i, char x) {
    if (i < 0 || i > L->length) return 0;
    Node *q = NewNode(x);
    if (q == NULL) return 0;
    if (i == 0) {                     /* 插在最前面:没有前一个结点 */
        q->next = L->head;
        L->head = q;
    } else {
        Node *prev = NodeAt(L, i - 1);   /* 先找到前一个结点 */
        q->next = prev->next;            /* 先:新结点指向后一个 */
        prev->next = q;                  /* 后:前一个指向新结点 */
    }
    L->length++;
    return 1;
}

2.9.3 用main从空表跑一遍

2.8.2节的cat是手工用->next->next搭的,现在用Insert从空表把它造出来,再插一个x,看结果是否和上面的图一致:

c
int main(void) {
    LinkList L;
    Init(&L);
    const char *s = "cat";
    for (int i = 0; s[i]; i++) Insert(&L, i, s[i]);   /* 逐个追加 */
    Insert(&L, 2, 'x');

    for (Node *p = L.head; p != NULL; p = p->next)
        putchar(p->data);              /* 输出 caxt */
    putchar('\n');
    return 0;
}

为了能和图对上,假设NewNode依次拿到的地址是100、108、104,x拿到102。逐次跟踪。

第一次,Insert(&L, 0, 'c')。表是空的,head为NULL。q是新结点c,在100号。i等于0,走第一个分支:q->next = L->head,c的next得到NULL;L->head = q,head变成100。长度变1。

text
改之前                     改之后
head = NULL                head = 100

┌───┬─────┐                  ▼
│ c │  0  │                ┌───┬─────┐
└───┴─────┘                │ c │  0  │
    100                    └───┴─────┘
                               100

第二次,Insert(&L, 1, 'a')。q是a,在108号。i不为0,走第二个分支:prev = NodeAt(L, 0),从head走0步,得到c;q->next = prev->next,c的next是NULL,a的next得到NULL;prev->next = q,c的next变成108。长度变2。这是"插在末尾"的情况:

text
head = 100


┌───┬─────┐     ┌───┬─────┐
│ c │ 108 │────►│ a │  0  │
└───┴─────┘     └───┴─────┘
    100             108
   prev

第三次插t同理,也是追加,prev是a,t在104号。三次之后表是cat,head=100,三个结点的next依次是108、104、0,和2.8.1节的图完全一样。

第四次,Insert(&L, 2, 'x')。q是x,在102号。prev = NodeAt(L, 1),走一步得到a;q->next = prev->next,把a的next(104)抄给x;prev->next = q,a的next变成102。长度变4。这就是上面"插在中间"的四步,插完的样子:

text
head = 100


┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐
│ c │ 108 │────►│ a │ 102 │────►│ x │ 104 │────►│ t │  0  │
└───┴─────┘     └───┴─────┘     └───┴─────┘     └───┴─────┘
    100             108             102             104

程序输出caxt

2.9.4 头结点

Insert的代码里有一个if分支:插在最前面时改的是head,其余位置改的是某个结点的next。Delete也会碰到同样的事:删第0个要改head,删其他的改前一个结点的next。两个操作各多一个分支,不算大问题,但有个技巧能把分支去掉。

办法是在链表最前面固定放一个不存数据的结点,叫头结点。head永远指向头结点,真正的第0个元素是头结点的next:

text
head


┌───┬───┐     ┌───┬───┐     ┌───┬───┐     ┌───┬───┐
│   │ ●─┼────►│ c │ ●─┼────►│ a │ ●─┼────►│ t │ ╱ │
└───┴───┘     └───┴───┘     └───┴───┘     └───┴───┘
 头结点          第0个         第1个         第2个

这样一来,第0个元素前面也有一个结点了。在任何位置i插入,都是"找到第i−1个结点,改它的next",i=0时第−1个就是头结点,不再特殊。空表也不再是head为NULL,而是head指向一个next为NULL的头结点。

加头结点后,三个函数要调整。Init要造出头结点;NodeAt从头结点出发,走0步得到的是头结点,也就是"第−1个",走i+1步到第i个;Insert的if分支消失:

c
int Init(LinkList *L) {
    L->head = NewNode('\0');          /* 头结点,数据无意义 */
    if (L->head == NULL) return 0;
    L->length = 0;
    return 1;
}

Node *NodeAt(LinkList *L, int i) {   /* i=-1 时返回头结点 */
    Node *p = L->head;
    for (int k = -1; k < i; k++)
        p = p->next;
    return p;
}

int Insert(LinkList *L, int i, char x) {
    if (i < 0 || i > L->length) return 0;
    Node *q = NewNode(x);
    if (q == NULL) return 0;
    Node *prev = NodeAt(L, i - 1);
    q->next = prev->next;
    prev->next = q;
    L->length++;
    return 1;
}

Get用的是NodeAt,不用改。Locate的遍历改成从L->head->next开始,跳过头结点。上面的main也要把打印循环的起点改成L.head->next,否则会把头结点里那个'\0'也打印出来;改完输出仍是caxt。从现在起都用头结点版本。

2.9.5 删除第i个元素与再次测试

以把刚插进去的x再删掉为例,即Delete(L, 2),caxt回到cat。同样不挪动任何结点,只改一个地址:让a的next越过x直接指向t。然后x这个结点没人指向了,把它的空间还给系统。

和插入一样,第一步先要找到前一个结点a。现在有了头结点,head指向头结点,NodeAt(L, 1)从头结点出发走两步到a。假设头结点在116号。

改之前,就是插入完成的样子,前面多了头结点:

text
head = 116


┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐
│   │ 100 │────►│ c │ 108 │────►│ a │ 102 │────►│ x │ 104 │────►│ t │  0  │
└───┴─────┘     └───┴─────┘     └───┴─────┘     └───┴─────┘     └───┴─────┘
    116             100             108             102             104
  头结点                           prev              q

第二步,用q记住要删的结点x(它是prev的next)。第三步,把a的右格从102改成104——这个104在x的右格里,抄过来:

text
head = 116


┌───┬─────┐     ┌───┬─────┐     ┌───┬─────┐                     ┌───┬─────┐
│   │ 100 │────►│ c │ 108 │────►│ a │ 104 │────────────────────►│ t │  0  │
└───┴─────┘     └───┴─────┘     └───┴─────┘     ┌───┬─────┐     └───┴─────┘
    116             100             108         │ x │ 104 │         104
  头结点                           prev         └───┴─────┘
                                                    102
                                                     q

x还在102号,右格还记着104,但从head出发已经走不到它了:116→100→108→104,读出cat。第四步,把102号的空间free掉。这正是插入的后两步倒着走:插入是把t的地址从a抄给x、再让a指向x;删除是把t的地址从x抄回a、再释放x。

第二步"用q记住x"不能省。改完a的右格之后,x就没人指向了,如果事先没记下它的地址,就再也找不到它,既拿不到104,也没法释放。

c
int Delete(LinkList *L, int i) {
    if (i < 0 || i >= L->length) return 0;
    Node *prev = NodeAt(L, i - 1);    /* 第一步:找到前一个,i=0时是头结点 */
    Node *q = prev->next;             /* 第二步:记住要删的 */
    prev->next = q->next;             /* 第三步:越过q */
    free(q);                          /* 第四步:释放 */
    L->length--;
    return 1;
}

free(q)必须放在最后。释放之后q指向的空间就不属于我们了,再读q->next是错误。先取用、后释放。

如果不用头结点,Delete会和无头结点版的Insert一样需要一个i=0的分支,读者可以自己试着写一写。

再用main跑一遍

把Insert和Delete放在一起测试,打印循环从头结点的下一个开始:

c
int main(void) {
    LinkList L;
    if (!Init(&L)) return 1;
    const char *s = "cat";
    for (int i = 0; s[i]; i++) Insert(&L, i, s[i]);
    Insert(&L, 2, 'x');               /* caxt */
    Delete(&L, 2);                    /* cat  */

    for (Node *p = L.head->next; p != NULL; p = p->next)
        putchar(p->data);
    putchar('\n');
    return 0;
}

跟踪Delete(&L, 2)prev = NodeAt(L, 1),从头结点走两步得到a;q = prev->next是x;prev->next = q->next把x的next(104)赋给a的next;free(q)释放102号;长度变3。程序输出cat

2.9.6 销毁与完整版本

销毁

链表的结点是一个个申请的,也要一个个释放,包括头结点。遍历时有个陷阱:free掉当前结点后,就不能再从它读next了,所以要先记下下一个再释放当前:

c
void Destroy(LinkList *L) {
    Node *p = L->head;
    while (p != NULL) {
        Node *next = p->next;         /* 先记下下一个 */
        free(p);
        p = next;
    }
    L->head = NULL;
    L->length = 0;
}

完整版本

把头结点版本的所有函数放在一起,从现在起本章都用这一套:

c
typedef struct Node {
    char         data;
    struct Node *next;
} Node;

typedef struct {
    Node *head;       /* 指向头结点 */
    int   length;
} LinkList;

Node *NewNode(char x) {
    Node *p = (Node *)malloc(sizeof(Node));
    if (p == NULL) return NULL;
    p->data = x;
    p->next = NULL;
    return p;
}

Node *NodeAt(LinkList *L, int i) {   /* 第i个结点,i=-1为头结点 */
    Node *p = L->head;
    for (int k = -1; k < i; k++)
        p = p->next;
    return p;
}

int Init(LinkList *L) {
    L->head = NewNode('\0');
    if (L->head == NULL) return 0;
    L->length = 0;
    return 1;
}

int Length(LinkList *L) {
    return L->length;
}

char Get(LinkList *L, int i) {
    if (i < 0 || i >= L->length) return '\0';
    return NodeAt(L, i)->data;
}

int Insert(LinkList *L, int i, char x) {
    if (i < 0 || i > L->length) return 0;
    Node *q = NewNode(x);
    if (q == NULL) return 0;
    Node *prev = NodeAt(L, i - 1);
    q->next = prev->next;
    prev->next = q;
    L->length++;
    return 1;
}

int Delete(LinkList *L, int i) {
    if (i < 0 || i >= L->length) return 0;
    Node *prev = NodeAt(L, i - 1);
    Node *q = prev->next;
    prev->next = q->next;
    free(q);
    L->length--;
    return 1;
}

int Locate(LinkList *L, char x) {
    int i = 0;
    for (Node *p = L->head->next; p != NULL; p = p->next, i++)
        if (p->data == x) return i;
    return -1;
}

void Destroy(LinkList *L) {
    Node *p = L->head;
    while (p != NULL) {
        Node *next = p->next;
        free(p);
        p = next;
    }
    L->head = NULL;
    L->length = 0;
}

对照2.4.4的顺序表版本:六个操作的函数名、参数、返回值完全相同,只是类型从SeqList换成了LinkList。这就是下一节能把编辑器原样搬过来的前提。

到这里你有了:头结点版单链表的六个操作,以及一个从空表造出cat、插入再删除的测试程序。 接口:和顺序表的六个函数完全相同。 下一步:把编辑器原样搬到链表上。