从零开始的数据结构与算法(五):双向链表与双向循环链表

529 阅读8分钟

1 定义

在前两节中介绍了单向链表的定义与基本的使用。相对于线性存储而言,链表具有更加灵活的使用,可以不受空间大小的约束。但单链表在使用中只能通过 next 指针进行单向的遍历,一路到底不能回头,在面临大数据的处理时会造成时间的浪费。所以,在此基础上产生了双向链表。

单向链表逻辑结构
双向链表结

双向链表顾名思义,就是可以进行前驱或后继的遍历。在结构上就是在单链表的基础上,增加了 prior 指针指向节点的前驱,配合 next 指针实现链表的双向遍历。

因为多了 prior 指针,所以在插入、删除等操作中需要对新的指针进行额外的处理,来保证操作前后链表的正确逻辑结构。

类似于单向循环链表,当在逻辑结构上尾节点与首节点相连时,双向链表便成了双向循环链表。其中,不仅可以通过尾节点的 next 指针获取到首节点,也可以通过首节点的 prior 指针获取到尾节点。

2 双向链表

2.1 结构定义

typedef int ElementType;    // 自定义数据元素类型
typedef int Status;         // 自定义状态码类型
static int SUCCESS = 0;     // 成功状态码
static int ERROR = -1;      // 失败状态吗

/// 定义单向链表的节点
struct Node {
    ElementType data;       // 数据域
    struct Node *next;      // 指针域,指向逻辑中的下一个节点
    struct Node *next;      // 指针域,指向逻辑中的下一个节点
};

双向链表在单向链表的基础上多个 prior 指针,用于指向前驱结点。

2.2 创建头结点

/// 双向链表的初始化
static Status initLinkList(LinkList *l) {
    // 创建一个头结点,作为哨兵节点
    *l = (LinkList)malloc(sizeof(struct Node));
    // 内存分配失败
    if (*l == NULL) {
        return ERROR;
    }
    // 置空指针域
    (*l)->prior = NULL;
    (*l)->next = NULL;
    return SUCCESS;
}

同单向链表的初始化,但需要设置 prior 避免出现野指针。

2.3 创建链表

/// 链表的初始化
static Status createLinkList(LinkList *l) {
    LinkList p = *l;
    // 新增节点
    for (int i = 0; i < 10; i++) {
        // 创建节点
        LinkList temp = (LinkList)malloc(sizeof(struct Node));
        temp->data = i;
        temp->prior = NULL;
        temp->next = NULL;
        // 插入新的节点
        p->next = temp;
        temp->prior = p;
        // 移动 p 的位置,即移动到 temp
        p = p->next;
    }
    return SUCCESS;
}

2.4 插入

双向链表的插入

图标表示了双向链表插入的逻辑。顺序不固定,但其中第 4 号线必须在 2 号线之前执行,否则改变了 A 的后继将会丢失 B 节点。

为方便记忆,可先执行 1、4 改变 temp,将 temp “挂”在链表上,再执行 2、3 将原来的虚线重新指向。

/// 双向链表的插入
static Status insertIntoLinkList(LinkList *l, int i, ElementType e) {
    int j = 1;
    LinkList p = *l;
    // 遍历链表寻找插入位置
    while (p && j < i) {
        p = p->next;
        j++;
    }
    // 位置寻找错误
    if (!p || j > i) {
        return ERROR;
    }
    // 此时 p 为位置 i 的前一个节点
    // 创建一个新的节点
    LinkList temp = (LinkList)malloc(sizeof(LinkList *));
    temp->data = e;
    // 将 p 的后继作为 temp 的后继
    temp->next = p->next;
    temp->prior = p;
    // 如果 p 有后继,则需要修改后继的指针
    if (p->next) {
        p->next->prior = temp;
    }
    // 将 temp 和 p 进行关联,需要执行完上述步骤再执行
    p->next = temp;
    return SUCCESS;
}

插入时前面的遍历和单向链表的步骤是一样的。不同的是多了两个 prior 指针的设置。单量链表是两个指针的设置,双向链表是四个指针的设置。

操作中需要注意先后顺序,一定要先设置 p 后继的指针,再修改 p 的后继,防止节点的丢失。

2.5 删除

/// 删除双向链表的节点
static Status deleteFromLinkList(LinkList *l, int i, ElementType *e) {
    int j = 1;
    LinkList p = (*l)->next;
    // 遍历链表寻找删除位置
    while (p && j < (i - 1)) {
        p = p->next;
        j++;
    }
    // 位置寻找错误
    if (!p || j != (i - 1)) {
        return ERROR;
    }
    // 此时 p 为位置 i 的前一个节点
    // 获取被删除的节点
    LinkList delTemp = p->next;
    // 数据返回
    *e = delTemp->data;
    // 处理被删除节点的后继
    if (delTemp->next) {
        delTemp->next->prior = delTemp->prior;
    }
    // 处理被删除节点的前驱
    p->next = delTemp->next;
    // 释放被删除的节点
    free(delTemp);
    return SUCCESS;
}

2.6 其他

双向链表的遍历打印、清空、取值等操作与单向链表的操作是一样的,代码参考前两节,这里不再赘述。重点在双向链表的插入与删除等操作。

2.7 使用

int main() {
    // 节点声明
    LinkList l;
    // 初始化链表头结点
    if (initLinkList(&l) == SUCCESS) {
        printf("链表初始化成功\n");
    } else {
        printf("链表初始化失败\n");
        return 0;
    }
    
    printf("创建双向链表\n");
    createLinkList(&l);
    traverseLinkList(l);
    
    printf("链表插入数据\n");
    for (int i = 1; i < 5; i++) {
        insertIntoLinkList(&l, 1, i * 5);
        traverseLinkList(l);
    }
    
    ElementType e1;
    deleteFromLinkList(&l, 4, &e1);
    printf("链表删除第4个数据 %d\n", e1);
    traverseLinkList(l);
		
    return 0;
}

打印结果:

链表初始化成功
创建双向链表
LinkList: 0 1 2 3 4 5 6 7 8 9 
链表插入数据
LinkList: 5 0 1 2 3 4 5 6 7 8 9 
LinkList: 10 5 0 1 2 3 4 5 6 7 8 9 
LinkList: 15 10 5 0 1 2 3 4 5 6 7 8 9 
LinkList: 20 15 10 5 0 1 2 3 4 5 6 7 8 9 
链表删除第4个数据 5
LinkList: 20 15 10 0 1 2 3 4 5 6 7 8 9 

3 双向循环链表

单节点双向循环链表

双向循环链表逻辑结构

双向循环链表是指双向链表在逻辑意义上,头尾节点相连的结构。在下面的使用中,为避免首元结点的特殊处理,创建了一个头结点保证所有节点都不是第一个节点。大部分操作与上述普通的双向链表大体一致,所以在下面代码将着重描述不一致的地方。

3.1 结构定义

双向循环链表的结构定义和普通的双向循环链表是一样的。参考上面的代码。

3.2 创建头结点

/// 双向循环链表的初始化
static Status initLinkList(LinkList *l) {
    *l = (LinkList)malloc(sizeof(struct Node));
    if (*l == NULL) {
        return ERROR;
    }
    // 和普通双向链表不同的是,前驱和后继都是自己
    //(*l)->prior = NULL;
    //(*l)->next = NULL;
    (*l)->prior = *l;
    (*l)->next = *l;
    return SUCCESS;
}

3.3 创建链表

/// 创建双向循环链表
static Status createLinkList(LinkList *l) {
    LinkList p = *l;
    for (int i = 0; i < 10; i++) {
        LinkList temp = (LinkList)malloc(sizeof(struct Node));
        temp->data = i;
        p->next = temp;
        temp->prior = p;
        // 将头节点的 prior 指向尾结点
        p->prior = temp;
        // 将尾节点的 next 指向头结点
        temp->next = *l;
        p = p->next;
    }
    return SUCCESS;
}

3.4 插入与删除等

对于双向链表的插入与删除,从逻辑上讲,等于在一个普通的双向链表中间进行插入与删除操作,不用考虑是否是首结点或尾节点,因为每个节点都有前驱与后继。

本质上双向循环链表是一种特殊的双向链表,所以,上述对普通双向链表的插入与删除的操作,可以用来操作双向循环链表。从逻辑上来讲因为不需要判断所以逻辑反而更简单,所以不再赘述了。

3.5 遍历

至于其他的遍历、读取等操作,和普通的单向循环链表一样。

/// 循环链表的遍历打印
static Status traverseLinkList(LinkList l) {
    // 因为默认有头结点,所以从头结点的 next 开始
    LinkList p = l->next;
    printf("LinkList: ");
    // p == l 作为遍历的结束条件
    while (p && p != l) {
        printf("%d ", p->data);
        p = p->next;
    }
    printf("\n");
    return SUCCESS;
}

3.6 使用

int main() {
    // 节点声明
    LinkList l;
    
    // 初始化链表头结点
    if (initLinkList(&l) == SUCCESS) {
        printf("链表初始化成功\n");
    } else {
        printf("链表初始化失败\n");
        return 0;
    }
    
    printf("创建双向链表\n");
    createLinkList(&l);
    traverseLinkList(l);
    
    printf("链表插入数据\n");
    for (int i = 0; i < 5; i++) {
        insertIntoLinkList(&l, 1, i * 2);
        traverseLinkList(l);
    }
        
    ElementType e;
    deleteFromLinkList(&l, 4, &e);
    printf("链表删除第4个数据 %d\n", e);
    traverseLinkList(l);
    
    return 0;
}

打印结果:

链表初始化成功
创建双向链表
LinkList: 0 1 2 3 4 5 6 7 8 9 
链表插入数据
LinkList: 0 0 1 2 3 4 5 6 7 8 9 
LinkList: 2 0 0 1 2 3 4 5 6 7 8 9 
LinkList: 4 2 0 0 1 2 3 4 5 6 7 8 9 
LinkList: 6 4 2 0 0 1 2 3 4 5 6 7 8 9 
LinkList: 8 6 4 2 0 0 1 2 3 4 5 6 7 8 9 
LinkList: 8 6 4 2 0 0 1 2 3 4 5 6 7 8 9 
链表删除第4个数据 2
LinkList: 8 6 4 0 0 1 2 3 4 5 6 7 8 9 

4 总结

  • 双向链表在操作中不仅要考虑 next 指针,还要考虑 prior 指针的设置。
  • 注意指针设置的先后顺序,避免野指针的出现以及节点的丢失。
  • 在增删过程中,可以通过创建辅助头结点避免首节点的特殊处理。
  • 在增删过程中,需要对是否是尾节点进行判断,如果有后继需要对后继的指针进行设置,这点和单链表有些不同。