数据结构与算法--单向链表与单向循环链表

357 阅读13分钟

前面研究了顺序表示下的线性表, 接下来一起来看一下链式存储结构下的线性表--链表

链表

链表是线性表的一种链式存储结构, 与顺序存储不同, 链表是 用一组任意的存储单元进行数据元素的存储, 这些存储单元可以是连续的, 也可以是不连续的。为了表示每个数据元素与其直接后继数据元素的逻辑关系, 链表除了存储本身的信息之外, 还需要存储其直接后继的存储位置信息。这两部分信息组成一个 "结点"。

结点

如上图, 存储数据的部分叫做 数据域 , 存储下一个结点地址的部分称为 指针域 , 这一系列的结点通过 指针域 所存储的地址信息进行逻辑关系相连, 这就组成了我们今天要研究的 链表 。关于链表将其分为 单向链表双向链表 两部分来研究, 下面先来看一下 单向链表

单向链表

特点

根据上面的单向链表的结构图可以总结出以下特点:

  1. 方向是单向的, 对链表的访问需要从头部开始按顺序读取
  2. 物理结构不相邻, 需要借助 指针域的信息才能访问 后继结点
  3. 因为是单向的, 只能访问后继结点, 不能反向访问
  4. 在进行 插入/删除 操作时, 需要修改涉及到的结点的前驱和后继
  5. 每次插入操作时动态申请内存, 不需要考虑存储上限
  6. 删除操作时需要释放结点内存, 防止内存泄漏

关于头结点

头结点是我们在链表的第一个元素结点(首元结点) 之前设置的一个结点, 他的 数据域可以不存储任何信息, 指针域指向链表的首元结点。添加头结点的目的是为了减少在对链表进行 添加/删除 时遇到的特殊情况的处理, 降低程序的复杂性。(有了头结点以后, 在 删除/添加 时就不用再去考虑首元结点与首指针之间的关系了)

注意: 添加了头结点的链表在进行销毁时需要对头结点进行销毁操作。

设计

结构设计

单向链表的结构与顺序表的区别在于, 除去 数据域 之外单向链表还需要引入 指针域 , 所以其结构设计如下:

/* ElemType类型根据实际情况而定,这里假设为int */
typedef int ElementType;

typedef struct Node{
    ElementType data;
    struct Node *next;
}Node;

typedef struct Node * LinkList;

初始化

int initLinkList(LinkList *L) {
    // 头结点分配空间, 并让首指针指向头结点
    *L = (LinkList)malloc(sizeof(Node));
    // 存储空间分配失败, 抛出异常
    if (*L == NULL) exit(0);
    // 头结点的指针域置空
    (*L)->next = NULL;
    return OK;
}

插入

插入需要考虑两种情况, 一种是需要插入特定位置时, 另一种是不需要插入特定位置, 那么就需要制定一个插入规则, 常用的规则主要有 前插法 和 后插法。

插入特定位置

算法步骤:

  1. 遍历链表, 找到所要插入位置的前一个位置的结点, 定义一个局部变量接收一下
  2. 判断找到的结点存不存在
  3. 创建新节点, 并将需要插入的值赋值给新节点的数据域
  4. 将找到的结点的后继赋值给新节点的后继
  5. 将新节点赋值给找到的结点的后继

注意: 这里的顺序是不能改变的, 为了防止链表断裂, 新的结点C必须先于结点A的后继结点B建立联系, 因为A结点是我们已经找到的, 所以之后再将A与C建立联系。

int insertLinkList(LinkList *L, int i, ElementType e) {
    int j = 1;
    LinkList temp, addNode;
    temp = *L;
    // 遍历寻找 i 的上一个结点
    while (temp && j < i) {
        temp = temp->next;
        j++;
    }
    // 判断结点是否存在
    if (!temp || j > i) return ERROR;
    // 创建用来插入的新结点
    addNode = (LinkList)malloc(sizeof(Node));
    // 把想要插入的值给新的结点
    addNode->data = e;
    // 新的结点的后继指向 temp 的后继
    addNode->next = temp->next;
    // temp 的后继指向新节点
    temp->next = addNode;
    
    return OK;
}

插入非特定位置

  • 前插法

如上图, 前插法就像其名字一样, 每次在首元结点之前做插入, 这样只需要拿到头结点, 然后在头结点的后面做插入就可以了

算法如下:

  1. 生成新的结点, 将需要插入的值赋给 data
  2. 将新节点的 next 指向头结点的 next
  3. 将头结点的 next 指向新的结点
int headInsertLinkList(LinkList *L, ElementType e) {
    // 创建新的结点
    LinkList p = (LinkList)malloc(sizeof(Node));
    p->data = e;
    // 结点p 指向 头结点的后继 (也就是首元结点)
    p->next = (*L)->next;
    // 头结点 指向 结点p (取代之前的首元结点)
    (*L)->next = p;
    
    return OK;
}
  • 后插法

后插法就是在链表的尾部做插入操作, 这种操作就需要找到链表的尾结点, 然后再把新的结点放到尾结点的后面就可以了

算法如下:

  1. 创建新的结点, 将新节点的 next 指向 NULL , 因为尾结点是没有后继的
  2. 通过一个 while 循环找到尾结点
  3. 将尾结点的 next 指向 新的结点
int tailInsertLinkList(LinkList *L, ElementType e) {
    // 创建新的结点
    LinkList p = (LinkList)malloc(sizeof(Node));
    p->data = e;
    p->next = NULL;
    // 建立一个尾结点 tailNode, 通过while循环遍历到尾部找到尾结点
    LinkList tailNode = (*L)->next;
    while (tailNode->next) {
        tailNode = tailNode->next;
    }
    // 将新的结点放到尾结点之后
    tailNode->next = p;
    
    return OK;
}

取值

由于链表在内存中不是连续的, 所以不能通过下标直接获取结点, 需要通过当前结点的指针域找到下一个结点。这里在取值时我们顶一个一个临时结点, 用来保存每一步循环之后的结点信息, 然后循环到想要取值的位置时, 直接读取这个临时结点的值就可以了。

代码如下:

int getElem(LinkList *L, int i, ElementType *e) {
    // 创建临时结点 p
    LinkList p;
    // 让 p 指向首元结点
    p = (*L)->next;
    // 循环到要取结点的位置 i
    int j = 1;
    while (j < i && p) {
        p = p->next;
        ++j;
    }
    // 判断如果 p 不存在直接返回error
    // 如果 j > i, 说明 传入的 i是小于1的值, 不合法直接返回error
    if (!p || j > i) return ERROR;
    // 赋值操作
    *e = p->data;
    
    return OK;
}
// 打印
int printLinkList(LinkList L) {
    LinkList p;
    p = L->next;
    while (p) {
        printf("%d\t", p->data);
        p = p->next;
    }
    printf("\n");
    
    return OK;
}

删除

删除结点如上图, 我们要把 A, B, C 三个结点的 B 结点移除出去, 首先需要找到 A 结点, 也就是 B 的前驱, 然后定义一个 临时结点来指向 B (注意在将B移除以后还需要对这块内存进行释放 ), 然后就是将 A 的后继指向 临时结点的后继就可以了, 最后就是需要把 B 的空间进行释放。

算法实现:

  1. 判断传入的删除位置是否合法
  2. 定义一个临时结点p, 用来保存当前遍历到的结点, 然后进行循环遍历, 找到要删结点的前驱结点
  3. 遍历结束以后需要看一下结点 p 的后继是否存在, 因为我们要删除的就是 p 的后继
  4. 定义 temp 结点指向要删除的结点 (也就是 p->next), 让 p 结点的后继指向 要删结点的后继, 这样 需要删除的结点就被分离出去了
  5. 最后需要把 temp 结点释放掉
int deletElem(LinkList *L, int i, ElementType *e) {
    // 删除位置必须从1开始
    if (i < 1) return ERROR;
    // 定义两个临时结点
    LinkList p, temp;
    p = *L;
    // 找到要删结点 i 位置的前驱, (注意这里是 j < i)
    for (int j = 1; j < i && p->next; j++) {
        p = p->next;
    }
    // 判断 p 结点的后继结点(也就是要删除的结点) 存不存在, 不存在的话就没有删除的必要了
    if (!p->next) return ERROR;
    // 用我们的临时结点 temp 去接收 要删的结点
    // 将要删结点的前驱 p 的后继指向 要删结点的后继
    temp = p->next;
    p->next = temp->next;
    
    *e = temp->data;
    // 释放内存
    free(temp);
    
    return OK;
}

调用

int main(int argc, const char * argv[]) {
    // insert code here...
    
    printf("初始化\n");
    // 创建
    LinkList p;
    initLinkList(&p);
    printLinkList(p);
    printf("插入\n");
    // 插入
    insertLinkList(&p, 1, 10);
    insertLinkList(&p, 0, 20);
    printLinkList(p);
    
    insertLinkList(&p, 1, 30);
    insertLinkList(&p, 44, 25);
    printLinkList(p);
    printf("前插法\n");
    // 前插法
    headInsertLinkList(&p, 99);
    headInsertLinkList(&p, 22);
    printLinkList(p);
    printf("后插法\n");
    // 后插法
    tailInsertLinkList(&p, 88);
    tailInsertLinkList(&p, 55);
    printLinkList(p);
    printf("取值\n");
    // 取值
    ElementType a;
    getElem(&p, 2, &a);
    printf("a = %d\n", a);
    ElementType b;
    getElem(&p, 99, &b);
    printf("b = %d\n", b);
    printf("删除\n");
    // 删除
    ElementType c;
    deletElem(&p, 99, &c);
    printf("c = %d\n", c);
    ElementType d;
    deletElem(&p, 4, &d);
    printf("d = %d\n", d);
    
    printLinkList(p);
    return 0;
}

// 输出结果
初始化

插入
10	
30	10	
前插法
22	99	30	10	
后插法
22	99	30	10	88	55	
取值
a = 99
b = 0
删除
c = 0
d = 10
22	99	30	88	55	
Program ended with exit code: 0

单向循环链表

如上图, 单向循环链表与单向链表的不同点在于, 单向循环链表的最后一个节点的指针指向链表头部, 而不是指向NULL, 即单向循环链表的尾结点是指向头结点的 (空表的时候就是头结点指向本身) 。

设计

注: 因为大部分的算法步骤跟单向链表是相似的, 所以这里不再描述具体步骤, 只在需要注意的地方添加说明。

结构设计

/* ElemType类型根据实际情况而定,这里假设为int */
typedef int ElementType;

typedef struct Node{
    ElementType data;
    struct Node *next;
}Node;

typedef struct Node * LinkList;

只是尾结点的指向不同, 所以在结构设计上单向链表和单向循环链表是一致的。

初始化

// 初始化
int initLinkList(LinkList *L) {
    // 创建头结点
    *L = (LinkList)malloc(sizeof(Node));
    // 判断是否创建成功
    if (*L == NULL) exit(0);
    // 给数据域随便赋值, 将头结点的 next 指向本身
    (*L)->data = -1;
    (*L)->next = (*L);
    
    return OK;
}

初始化时跟单向链表的不同点在于, 在最后需要将头结点的 next 指向其自己

插入

插入特定位置

// 插入
int insertLinkList(LinkList *L, int i, ElementType e) {
    // 创建遍历记录结点和待插入的新节点
    LinkList p, temp;
    temp = (*L);
    // 创建结点
    p = (LinkList)malloc(sizeof(Node));
    p->data = e;
    // 通过 while 循环找到待插入位置的上一个结点
    // (temp->next != (*L) 用来判断 temp 是否是尾结点
    int j = 1;
    while (j < i && (temp->next != (*L))) {
        temp = temp->next;
        ++j;
    }
    // 对 i 的合理性判断, 如果 j > i, i应该是 <1 的值, 不满足
    // 如果 i > j+1, 说明 i-1 > j, 即 j 不是 i 的前一个结点 (当 i 的值超出链表的长度 +1 时出现)
    if (j > i || (i > j+1)) return ERROR;
    // 插入操作, 将新节点的 next 指向 temp 的next
    // 将 temp 的 next 指向 新节点p
    p->next = temp->next;
    temp->next = p;
    return OK;
}

插入时需要注意的点在于 循环的判断条件, 因为循环链表是收尾相连的, 所以判断条件应该是 当前结点的 next 指向头结点

插入非特定位置

  • 前插法
// 前插法
int headInsertLinkList(LinkList *L, ElementType e) {
    LinkList p;
    // 创建结点 | 判断是否创建成功
    p = (LinkList)malloc(sizeof(Node));
    if (!p) return ERROR;
    p->data = e;
    // 新节点的 next 指向 头结点的后继(非空表时是首元结点, 空表时是头结点)
    p->next = (*L)->next;
    // 头结点的 next 指向 新节点
    (*L)->next = p;
    
    return OK;
}
  • 后插法
// 后插法
int tailInsertLinkList(LinkList *L, ElementType e) {
    LinkList p, temp;
    // 创建结点 | 判断是否创建成功
    p = (LinkList)malloc(sizeof(Node));
    p->data = e;
    if (!p) return ERROR;
    // 循环找到尾结点
    temp = *L;
    do {
        temp = temp->next;
    } while (temp->next != *L);
    // 插入操作
    p->next = temp->next;
    temp->next = p;
    
    return OK;
}

删除

// 删除
int deletElem(LinkList *L, int i, ElementType *e) {
    // 空表
    if ((*L)->next == (*L)) return ERROR;
    //
    LinkList p, temp;
    p = (*L);
    // 循环找到要删除结点的上一个结点
    int j = 1;
    while (j < i && (p->next != *L)) {
        p = p->next;
        j++;
    }
    // 判断
    // j != i, 循环提前因为 p->next == *L 跳出, 给的 i 值超过链表长度; 或者 i 值 小于1 造成
    // p->next == *L, 要删除结点的位置刚好在 尾结点的后面时产生, 如果不加这个判断就会把头结点干掉了
    if (j != i || (p->next == *L)) return ERROR;
    
    // 用定义的 temp接管要删除的结点, 然后将其从链表中断开
    temp = p->next;
    p->next = temp->next;
    // 将删除加点的内容给e, 然后释放掉
    *e = temp->data;
    free(temp);
    
    return OK;
}

删除时基本与单链表类似, 不同点在于对于要删除位置的判断, 然后在删除过后对结点进行释放。

查找

// 查找
int getElem(LinkList *L, int i, ElementType *e) {
    LinkList p;
    p = (*L);
    // 循环找到查找的结点
    int j = 1;
    while (j <= i && (p->next != *L)) {
        p = p->next;
        j++;
    };
    // 对 i 值的合理性判断
    if (j-1 != i) return ERROR;
    // 赋值
    *e = p->data;
    
    return OK;
}
// 打印
int printLinkList(LinkList L) {
    LinkList p;
    p = L;
    
    do {
        p = p->next;
        printf("%d\t", p->data);
    } while (p->next != L);

    printf("\n");
    return OK;
}

调用

int main(int argc, const char * argv[]) {
    // insert code here...
    printf("Hello, World!\n");
    
    printf("初始化---------------\n");
    LinkList L;
    initLinkList(&L);
    printf("插入---------------\n");
    insertLinkList(&L, 1, 10);
    printLinkList(L);
    insertLinkList(&L, 2, 20);
    printLinkList(L);
    insertLinkList(&L, 1, 30);
    printLinkList(L);
    insertLinkList(&L, 4, 40);
    printLinkList(L);
    printf("头插法---------------\n");
    headInsertLinkList(&L, 12);
    printLinkList(L);
    headInsertLinkList(&L, 13);
    printLinkList(L);
    printf("尾插法---------------\n");
    tailInsertLinkList(&L, 22);
    printLinkList(L);
    tailInsertLinkList(&L, 23);
    printLinkList(L);
    printf("删除---------------\n");
    ElementType a;
    deletElem(&L, 1, &a);
    printLinkList(L);
    printf("删除了 %d\n", a);
    ElementType b;
    deletElem(&L, 5, &b);
    printLinkList(L);
    printf("删除了 %d\n", b);
    printf("查找---------------\n");
    ElementType c;
    getElem(&L, 1, &c);
    printf("找到了 %d\n", c);
    ElementType d;
    getElem(&L, 4, &d);
    printf("找到了 %d\n", d);
    
    return 0;
}
// 打印结果
Hello, World!
初始化---------------
插入---------------
10	
10	20	
30	10	20	
30	10	20	40	
头插法---------------
12	30	10	20	40	
13	12	30	10	20	40	
尾插法---------------
13	12	30	10	20	40	22	
13	12	30	10	20	40	22	23	
删除---------------
12	30	10	20	40	22	23	
删除了 13
12	30	10	20	22	23	
删除了 40
查找---------------
找到了 12
找到了 20
Program ended with exit code: 0

总结

本次内容主要涉及了 链表 的相关概念, 以及 单向链表单向循环链表 的结构和基本操作的算法设计。如果有不足的地方或者不正确的地方可以在回复中留言, 本人看到以后一定积极改正。

和谐学习, 不急不躁~