Skip to content

2.12 光标记住结点:双向链表,编辑器2.1与2.2

2.12.1 把光标改成结点指针

上一节的结论是:链表插入删除本身O(1),慢在每次从头走到位置。而编辑器的光标一直停在编辑位置附近,没有理由每敲一个键都从头重走一遍。让光标直接记住结点,而不是记一个整数。

光标记哪个结点?编辑器的插入点在|处,|前面那个字符的结点就是"插入时的前一个结点",让光标指向它最方便:敲字符就是在光标结点后面插入,然后光标移到新结点上。光标在行首时,|前面没有字符,让它指向头结点——这正是头结点的用处,行首不再特殊。

text
                                cursor


┌───┬───┐     ┌───┬───┐     ┌───┬───┐     ┌───┬───┐
│   │ ●─┼────►│ c │ ●─┼────►│ a │ ●─┼────►│ t │ ╱ │
└───┴───┘     └───┴───┘     └───┴───┘     └───┴───┘
 头结点

屏幕显示:ca|t

2.12.2 结点位置操作集:先在单链表上实现

有一个问题。现在的Insert(L, i, x)、Delete(L, i)只接受整数位置,编辑器手里拿着的是结点指针,没法调用它们。这不是链表的问题,是2.2节定义操作集时默认了"位置就是整数"。这个默认对顺序表天然合适,对链表就不合适了。

所以要给线性表增加一组以结点为位置的操作。按编辑器的需求来定:敲字符要"在光标后面插入",退格要"删掉光标这个结点",左移右移要"前一个、后一个",显示要"从头开始逐个取"。六个操作:

操作含义
Head(L)头结点,即第0个元素之前的位置
Next(p)p的后一个位置
Prev(L, p)p的前一个位置
Data(p)p处的元素
InsertAfter(L, p, x)在p后面插入x
DeleteNode(L, p)删除p这个结点,p不能是头结点

先在现有的单链表上实现。前四个和InsertAfter都很直接,InsertAfter就是2.9节Insert去掉定位那一段:

c
Node *Head(LinkList *L) { return L->head; }
Node *Next(Node *p)     { return p->next; }
char  Data(Node *p)     { return p->data; }

int InsertAfter(LinkList *L, Node *p, char x) {
    Node *q = NewNode(x);
    if (q == NULL) return 0;
    q->next = p->next;
    p->next = q;
    L->length++;
    return 1;
}

Prev和DeleteNode就没这么顺利。单链表的结点只记着后一个,前一个在哪没有任何记录,Prev唯一的办法是从头结点开始走,走到某个结点的next等于p为止。DeleteNode要把p从链上摘下来,必须改p前一个结点的next,所以它也得先调Prev:

c
Node *Prev(LinkList *L, Node *p) {     /* 单链表上只能从头走 */
    Node *q = L->head;
    while (q->next != p) q = q->next;
    return q;
}

int DeleteNode(LinkList *L, Node *p) {
    Node *prev = Prev(L, p);           /* 这一步是O(n)的 */
    prev->next = p->next;
    free(p);
    L->length--;
    return 1;
}

要看清楚:这两个函数从外面看和其他操作一样,一次调用;但里面各藏着一个从头走的循环,O(n)。接口对了,代价还在。

顺便说明,原来的六个整数位置操作一个不少,而且可以用新操作改写:Insert(L, i, x)就是InsertAfter(L, NodeAt(L, i−1), x),Delete(L, i)就是DeleteNode(L, NodeAt(L, i))。两套接口是一套东西。

还要说明的是,2.10节能做到"main一行不改",是因为只换了实现、没动接口;这次main要改,是因为接口变了。接口本身也会影响效率,2.13节会回头讨论。

2.12.3 编辑器2.1

main只用这六个操作。光标是Node *cursor,初始指向头结点:

c
void Show(LinkList *L, Node *cursor) {
    if (cursor == Head(L)) putchar('|');
    for (Node *p = Next(Head(L)); p != NULL; p = Next(p)) {
        putchar(Data(p));
        if (p == cursor) putchar('|');
    }
    putchar('\n');
}

int main(void) {
    LinkList text;
    Node *cursor;
    char cmd, ch;

    if (!Init(&text)) return 1;
    cursor = Head(&text);                     /* 行首 */
    while (scanf(" %c", &cmd) == 1 && cmd != 'q') {
        switch (cmd) {
        case 'i':
            scanf(" %c", &ch);
            if (ch == '_') ch = ' ';
            if (InsertAfter(&text, cursor, ch)) cursor = Next(cursor);
            else printf("插入失败\n");
            break;
        case 'd':
            if (cursor != Head(&text)) {
                Node *dead = cursor;
                cursor = Prev(&text, cursor);
                DeleteNode(&text, dead);
            }
            break;
        case 'l':
            if (cursor != Head(&text)) cursor = Prev(&text, cursor);
            break;
        case 'r':
            if (Next(cursor) != NULL) cursor = Next(cursor);
            break;
        }
        Show(&text, cursor);
    }
    Destroy(&text);
    return 0;
}

退格里的顺序有讲究:先把光标退到前一个,再删原来的光标结点;反过来先删,就没法再问它的前一个是谁了。

Show现在顺着结点只走一遍,O(n),上一节O(n²)的问题解决了。

输入前面那段会话,输出仍然是c|ca|cat|ca|tcax|tca|tcat|。功能对了,再看效率。敲字符是InsertAfter加Next,O(1);右移是Next,O(1);显示O(n)。但退格和左移各调用一次Prev,单链表上是O(n)。问题解决了一半。

2.12.4 双向链表

根源是单链表的结点只记后一个。既然如此,让每个结点把前一个也记下来:

c
typedef struct Node {
    struct Node *prev;    /* 前一个结点的地址 */
    char         data;
    struct Node *next;    /* 下一个结点的地址 */
} Node;

每个结点三个格子,中间是数据,两边各一个地址。这样的链表叫双向链表。头结点的prev是NULL,最后一个结点的next是NULL:

text
head


┌───┬───┬───┐    ┌───┬───┬───┐    ┌───┬───┬───┐    ┌───┬───┬───┐
│ ╱ │   │ ●─┼───►│ ●─┼ c │ ●─┼───►│ ●─┼ a │ ●─┼───►│ ●─┼ t │ ╱ │
│   │   │   │◄───┼─● │   │   │◄───┼─● │   │   │◄───┼─● │   │   │
└───┴───┴───┘    └───┴───┴───┘    └───┴───┴───┘    └───┴───┴───┘
    头结点

代价是每个结点多占一个地址的空间。换来的是:站在任何一个结点上,前一个和后一个都是一步可达。

接口不变,重写四个实现。NewNode多初始化一个prev:

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

Prev一步完成:

c
Node *Prev(LinkList *L, Node *p) { return p->prev; }

参数L现在用不上了,保留它是为了签名和单链表版本一致,main不用改。

InsertAfter在p后面插q,要接四根线:q的prev和next,p的next,以及p原来的后一个的prev。p可能是最后一个结点,这时它没有后一个,那一根线不用接:

c
int InsertAfter(LinkList *L, Node *p, char x) {
    Node *q = NewNode(x);
    if (q == NULL) return 0;
    q->prev = p;                              /* q的前一个是p */
    q->next = p->next;                        /* q的后一个是p原来的后一个 */
    if (p->next != NULL) p->next->prev = q;   /* 那个后一个的前一个改为q */
    p->next = q;                              /* p的后一个改为q */
    L->length++;
    return 1;
}

顺序仍然要注意:前三行都要用到p->next,即p原来的后一个,所以改p->next的那一行放最后。

DeleteNode不再需要从头找前一个:让p的前一个越过p指向p的后一个,让p的后一个(如果有)越过p指回p的前一个,然后释放p。头结点永远不会被删,所以p->prev一定存在:

c
int DeleteNode(LinkList *L, Node *p) {
    p->prev->next = p->next;
    if (p->next != NULL) p->next->prev = p->prev;
    free(p);
    L->length--;
    return 1;
}

Head、Next、Data、Length、Get、Locate、NodeAt、Destroy都不用改。整数位置的Insert和Delete改成调用新操作,prev指针就自动维护好了:

c
int Insert(LinkList *L, int i, char x) {
    if (i < 0 || i > L->length) return 0;
    return InsertAfter(L, NodeAt(L, i - 1), x);
}

int Delete(LinkList *L, int i) {
    if (i < 0 || i >= L->length) return 0;
    return DeleteNode(L, NodeAt(L, i));
}

2.12.5 编辑器2.2

main一行不改,再跑一遍

编辑器2.1的main和Show一个字不动,重新编译,输入同一段会话,输出完全相同。这就是编辑器2.2。这次改的是实现、不是接口,所以和2.10节一样,应用层不动。

用这段会话跟踪一遍,看双向链表上光标怎么动:

  • i c:光标在头结点,InsertAfter在头结点后面插c:c的prev是头结点、next是NULL,头结点的next改为c;光标移到c。显示c|
  • i ai t:同理,光标依次到a、t。显示cat|
  • l:光标从t退到Prev(t),一步读到a。显示ca|t
  • i x:在a后面插x:x的prev是a、next是t,t的prev改为x,a的next改为x;光标到x。显示cax|t
  • d:光标结点是x,先退到Prev(x)即a,再DeleteNode(x):a的next改为t,t的prev改为a,释放x。显示ca|t
  • r:光标到Next(a)即t。显示cat|

每一步都只动了光标附近的几根线,和整行有多长没有关系。

时间复杂度

六个结点位置操作在两种链表上的复杂度:

操作单链表双向链表
Head、Next、DataO(1)O(1)
InsertAfterO(1)O(1)
PrevO(n)O(1)
DeleteNodeO(n)O(1)

再把编辑器的几个动作放在一起,和之前的版本比:

动作1.1 顺序表2.0 链表,整数光标2.1 单链表,结点光标2.2 双向链表,结点光标
敲字符O(n)O(n)O(1)O(1)
退格O(n)O(n)O(n)O(1)
左移O(1)O(1)O(n)O(1)
右移O(1)O(1)O(1)O(1)
显示O(n)O(n²)O(n)O(n)

2.6节提出的问题——在行首打字慢——到这里才真正解决:不管光标在哪,敲一个字符、退格、移动光标都是O(1)。显示O(n)无法避免,n个字符总要一个个打出来。

回头看这一路:换成链表本身并没有让编辑器变快(2.0),让它变快的是"让光标记住结点"这个改动,而它要求接口能表达"结点位置";接口定对之后,单链表上还有两个操作藏着O(n),最后为了前后都能一步走,把链表改成了双向。存储结构、接口、应用三者是一起变的。下一节把顺序表和链表放在一起,讨论什么情况下该选哪个。

到这里你有了:双向链表和结点位置操作集,以及敲字符、退格、移动光标都是O(1)的编辑器2.2。 学到的:换存储结构本身不一定变快,接口能不能表达"位置"同样关键。 下一步:把顺序表和链表放在一起比较,看什么场景选哪个。