avatar

zian

A text-focused Halo theme

  • Java
  • 面试
  • 首页
  • C语音
  • liunx
  • 数据结构与算法
  • 控制台
Home 链表
文章

链表

Posted 2025-01-14 Updated 2025-01- 15
By Administrator
49~63 min read

链表概念

内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。

链表(linked list)是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过指针相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。

链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续。

链表定义与存储方式

图 4-5 链表定义与存储方式

观察图 4-5 ,链表的组成单位是节点(node)对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。

  • 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
  • 尾节点指向的是 NULL。
  • 在 C、C++、Go 和 Rust 等支持指针的语言中,上述“引用”应被替换为“指针”。

如以下代码所示,链表节点 ListNode 除了包含值,还需额外保存一个引用(指针)。因此在相同数据量下,链表比数组占用更多的内存空间。

链表分类:

  • 单向链表:单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 NULL 。
  • 环形链表:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。(可以是单向的环形链表 也可以双向的环形链表 )
  • 双向链表:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。

单向链表

分类:

  1. 包含头节点(声明一个固定的头结点)
  2. 不包含头结点(头结点不是固定,每次往头部插入,都会更新一次头结点 )

含头结点的单向链表相关方法

#include <stdio.h>
#include <stdlib.h>

// 注意:当一个结构体内部需要使用自己的时候不能使用 匿名结构体,在结构体内部声明的时候不能使用别名。
typedef struct node {
    int data;  // 节点数据
    struct node* next; // 节点下一个节点的指针
} Node;


// 创建新节点
Node* createNode(int data) {
    Node* node = malloc(sizeof(Node));
    node->data = data; // 节点的数据
    node->next = NULL; // 创建出来新节点是不知道需要指向那个节点的
    return node;
}


// 头部插入
void addHead(Node* head, int data) {
    Node* node = createNode(data); // 创建一个新节点
    node->next = head->next; // 新节点的 next 指向原来头节点的 next 
    head->next = node; // 头结点.next 指向新节点
}

// 遍历节点
void forEach(Node* head) {
    // 空链表不需要遍历
    if ( head == NULL || head.next == NULL) {
        return;
    }

    Node* node = head->next;
    while (node != NULL) {
        printf("%d ", node->data);
        // 去到下一个节点
        node = node->next;
    }
    printf("\n");
}


/// @brief 在指定节点的后面插入的一个新节点
/// @param n0  节点
/// @param data  新节点的数据
void afterInsert(Node* n0, int data) {
    Node* node = createNode(data);

    // 新增节点 next ,指向原来节点的  next
    node->next = n0->next;

    // 原来节点的 next 指向新增的节点
    n0->next = node;
}

/// @brief 根据值查询,第一个符合条件的节点进行返回,如果没有查找到范湖NULL
/// @param head 头结点(包含头结点 (头结点是数据无效的 ))
/// @param data  (要查找的节点的值)
/// @return 节点或者 NULLL
Node* findNode(Node* head, int data) {
    if (head->next == NULL) {
        return NULL;
    }

    Node* node = head->next;
    while (node != NULL) {
        // 找到第一个符合的节点进行返回
        if (node->data == data) {
            return node;
        }
        node = node->next;
    }

    // 如果节点不存在返回NULL
    return NULL;

}
 

void tailInsert(Node* head, int data) {
    // 遍历出尾节点
    // 头节点直接 next 可能会出现 next 为空 (也就链表为空, head 既是头节点也是尾节点)
    //  Node* node = head->next;     // 解决,从头结点开始判断
    Node* node = head;

    // node.next 下一个如果为 NULL ,就代表该节点就是尾部节点了
    while (node->next != NULL) {
        node = node->next;
    }
    Node* newNode = createNode(data);
    node->next = newNode;
}

// 删除节点
void delNode(Node* head, int val) {
    Node* node = head;
    // 找到要被删除的节点的前一个节点 
    while (node->next != NULL) {
        if (node->next->data == val) {
            break;
        }
        node = node->next;
    }
  
    if (node->next != NULL && node->next->data == val) {
        Node* rmNode = node->next;
        // 删除节点的前一个节点的 next 指向 , 删除节点的 next 
        node->next = rmNode->next;
        // 删除节点的堆空间释放掉
        free(rmNode);
    }
}



// 向指定值插入节点
void insertNode(Node* head, int target, int data) {
    // 找到需要插入的位置
    Node* node = head->next;
    while (node != NULL) {
        // 找到了
        if (node->data == target) {
            break;
        }

        node = node->next;
    }
    // 只有 node 不为空的时候找到了
    if (node != NULL) {
        // 创建新节点
        Node* newNode = createNode(data);
        newNode->next = node->next;
        node->next = newNode;
    }
}


// 销毁链表 ,一个节点一个节点来进行销毁
void destroyList(Node** head) {
    Node* node = (*head)->next;
    while (node != NULL) {
        // 获取当前节点
        Node* temp = node;

        // 去到当前的节点的下一个节点
        node = node->next;

        // 释放当前节点 , 不用将当前节点设置为 NULL ,
        //  原因是一旦头结点被释放掉,就没有方法可以使用该指针了
        free(temp);
    }

    // 释放头节点
    free(*head);
    // 防止头指针成为野指针
    *head = NULL;
}

int main(int argc, char const* argv[]) {
    Node* head = createNode(-1);
    addHead(head, 1);
    addHead(head, 2);
    addHead(head, 3);

    forEach(head);
    // 销毁
    destroyList(&head);
    printf("释放链表: %d \n", head == NULL);
    return 0;
}

含头节点的特点:

  1. 头结点保存的数据是无效的,头结点的 next 指向第一个包含有效数据的节点。
  2. 每一次向头部插入数据,头结点的 next 指向的新创建出来的节点。

wKgP3GHSZ4-Ae0wlAAAsib7TRUc638.png

不含头的单向链表相关方法

#include <stdio.h>
#include <stdlib.h>

typedef struct node {
    int data;
    struct node* next;
} Node;


// 创建新节点
Node* createNode(int data) {
    Node* node = malloc(sizeof(Node));
    node->data = data;
    node->next = NULL;
    return node;
}


// 头部插入
void addHead(Node** head, int data) {
    Node* node = createNode(data);
    // node->next = head->next;
    // head->next = node;
    node->next = *head;
    *head = node;
}

// 遍历节点
void forEach(Node* head) {
    Node* node = head;
    while (node != NULL) {
        printf("%d ", node->data);
        node = node->next;
    }
    printf("\n");
}



// 实现 void insert(ListNode *n0, int data) 给 n0节点后面插入一个节点

/// @brief 在指定节点的后面插入的一个新节点
/// @param n0  节点
/// @param data  新节点的数据
void afterInsert(Node* n0, int data) {
    Node* node = createNode(data);

    // 新增节点 next ,指向原来节点的  next
    node->next = n0->next;

    // 原来节点的 next 指向新增的节点
    n0->next = node;
}

/// @brief 根据值查询,第一个符合条件的节点进行返回,如果没有查找到范湖NULL
/// @param head 头结点(包含头结点 (头结点是数据无效的 ))
/// @param data  (要查找的节点的值)
/// @return 节点或者 NULLL
Node* findNode(Node* head, int data) {
    Node* node = head;
    while (node != NULL) {
        // 找到第一个符合的节点进行返回
        if (node->data == data) {
            return node;
        }
        node = node->next;
    }

    // 如果节点不存在返回NULL
    return NULL;

}

// 实现 void tail_head 尾查法(向链表末尾插入节点), 
// 遍历整个链表,找到 NULL 之前的那个节点(也就是最后的那个节点)
void addLast(Node* head, int data) {
    Node* newNode = createNode(data);

    // 第二个元素的位置
    Node* node = head;
    while (node != NULL) {
        // 找到最后一个节点
        if (node->next == NULL) {
            break;
        }
        node = node->next;
    }

    node->next = newNode;
}


// 实现 删除第一个等于指定值节点 void insert(int data) 
// 通过循环,来判断如果一个节点的下一个元素的数据 == val ,就找到了
// 节点.next = 删除节点的.next 
void removeNode(Node** head, int val) {
    Node* node = *head;

    // 删除头部的第一个元素
    if (node->data == val) {
        *head = node->next;
        node = NULL;
        return;
    }

    while (node->next != NULL) {
        if (node->next->data == val) {
            break;
        }

        node = node->next;
    }
    if (node == NULL || node->next == NULL) return;

    // 删除节点的前一个节点的 next 指向 , 删除节点的 next 
    Node* rmNode = node->next;
    node->next = rmNode->next;
    // 释放
    free(rmNode);

}



int main(int argc, char const* argv[]) {
    Node* head = createNode(1);
    // addHead(&head, 2);
    addHead(&head, 3);
    // addHead(&head, 4);

    addLast(head, 8);
    addLast(head, 7);
    addLast(head, 6);
    addLast(head, 5);



    forEach(head);
    // printf("----------------------\n");

    // removeNode(&head, 8);
    // Node* n = findNode(head, 8);
    // if (n != NULL) {
    //     printf("n = %d \n", n->data);
    // }

    // forEach(head);
    return 0;
}

特点:

  1. 头结点也保存这有效的数据。
  2. 往头插入数据,实际上是不断地更新头结点
  3. 创建头节点的时候就需要传递有效数据

图片:

无头结点.png

使用注意点:

  1. 在函数中修改外部函数的变量,调用函数的地方需要将变量的地址传递过来(地址传递)
  2. 无头结点的,其实是头节点的不断地更新的为新的节点。

双向链表

代码:

#include <stdio.h>
#include <malloc.h>
// 定义结构体
typedef struct node {
    int data;
    struct node* prev;// 节点前一个节点的地址 (前驱节点)
    struct node* next; // 节点下一个节点的地址 (后继节点)
} Node;


// 创建新节点
Node* createNode(int data) {
    Node* node = malloc(sizeof(Node));
    node->data = data;
    node->prev = NULL;
    node->next = NULL;
    return node;
}

// 头插法
void headInsert(Node* head, int data) {
    Node* newNode = createNode(data);

    // 空链表
    if (head->next != NULL) {
        // 原来头节点的下一个节点 , 的前指针指向新的节点
        head->next->prev = newNode;
    }
    // 新节点的后继节点 = 头结点的原来的 next 节点
    newNode->next = head->next;
    // 新节点的前驱节点 = head 
    newNode->prev = head;
    // 头结点的后继节点 = 新的节点。
    head->next = newNode;
}


// 删除指定节点的元素
void delNode(Node* head, int val) {

    Node* node = head->next;
    // 空链表无法删除。
    if (node == NULL) {
        return;
    }
    while (node != NULL) {
        // 找到要删除的节点了
        if (node->data == val) {
            break;
        }
        node = node->next;
    }

    printf("删除  = %d \n", node->data);
    node->prev->next = node->next;
    if (node->next != NULL) {
        node->next->prev = node->prev;
    }


    free(node);
}

// 销毁: 每一个节点都是堆空间的,需要使用 free 进行释放, 一个节点一个节点的销毁
// 销毁的效果:node 往前走一步,就销毁原来的node位置的节点占用的堆空间.
void destroyList(Node** head) {
    Node* node = (*head)->next;
    while (node != NULL) {
       // 需要被销毁的节点
        Node* temp = node;
        // 释放当前节点之前需要节点先去到下一个节点。
        node = node->next;

        // 释放当前节点释放堆空间
        free(node);
    }
    // 释放头节点
    free(*head);
    *head = NULL;
}

// 遍历
void forEach(Node* head) {
    Node* node = head->next;
    printf("正序: ");
    Node* lastNode = NULL;
    while (node != NULL) {
        // 找到尾节点
        if (node->next == NULL) {
            lastNode = node;
        }

        printf("%d ", node->data);
        node = node->next;
    }
    printf("\n");
    // 倒序遍历需要从尾节点开始遍历 ,所有需要先获取的尾节点
    printf("倒序: ");
    while (lastNode != head) {
        printf("%d ", lastNode->data);
        lastNode = lastNode->prev;
    }
    printf("\n");

}



int main(int argc, char const* argv[]) {
    // 创建一个含头的节点
    Node* head = createNode(-1);
    headInsert(head, 9);
    headInsert(head, 8);
    headInsert(head, 7);
    headInsert(head, 6);
    headInsert(head, 5);
    forEach(head);

    delNode(head, 5);
    forEach(head);

    destroyList(&head);
    printf("%d \n" , head == NULL);

    // printf("%d \n", head->next->data);
    return 0;
}

新增节点的图片:

双向链表.png

循环链表

循环双向链表 (重点)

f3b852728c154374a707ec37aaebe5ed.png

特点:

  1. 第一个节点既是头节点也是尾节点。
  2. 当只有一个节点的时候, 该节点的 next 指向自己,该节点的 prev 指向自己。
  3. 遍历的节点的时候的如果移动的 node == head 就代表遍历完整个链表了。

代码:

#include <stdio.h>
#include <stdlib.h>

typedef struct node {
    int data;
    struct node* prev;
    struct node* next;
} Node;



// 创建节点
Node* createNode(int data) {
    Node* node = malloc(sizeof(Node));
    node->data = data;
    node->prev = node;
    node->next = node;

    return node;
}


// 头插法
void headInsert(Node* head, int data) {
    Node* node = createNode(data);

    // next方向 
    // node 的后继节点 指向 头结点的后继节点
    node->next = head->next;
    // 头结点的后继节点指向的新增节点。
    head->next = node;

    // prev 方向
    // node 的 后继节点已经更新为 head 的后继节点 , 
    // 该节点的前驱节点指向新增的节点
    node->next->prev = node;
    // 新增节点的前驱节指向头节点
    node->prev = head;


}

// 删除节点 
void delNode(Node* head, int val) {
    // 头部开始找
    Node* node = head->next;

    // 空链表
    if (node == NULL) {
        return;
    }

    // 找到要删除的节点 , 开始找到头节点(尾节点)
    while (node != head) {
        if (node->data == val) {
            break;
        }
        node = node->next;
    }

    // 判断
    if (node != NULL && node->data == val) {
        // 删除节点的前一个的 next  指向 ,删除节点的next 
        node->prev->next = node->next;

        // 删除节点的后一个 prev 指向 , 删除节点的 node.prev 
        node->next->prev = node->prev;
    }
}

// 销毁整个列表 : 遍历整个链表 , 回收每一个节点的堆空间。
void destroyList(Node** head) {
    Node* node = (*head)->next;
    // 注意:循环完整个数组的条件是 node == head ,当前节点一旦为 head 就证明链表已经循环到头了
    while (node != *head) {
        Node* temp = node;
        node = node->next;
        temp->prev = NULL;
        free(temp);
    }
    // free(node);
    free(*head);
    printf("%d \n" , (*head)->data);
    *head = NULL;
}


// 尾部插入
void tailInsert(Node* head, int data) {
    Node* newNode = createNode(data);

    // prev 方向
    //  新增的节点的前驱节点的 指向 头节点的前驱节点
    newNode->prev = head->prev;
    // 头节点的前驱节点指向新增的节点
    head->prev = newNode;

    // next 方法  
    // newnode 的前驱节点已经更新成了 head 原来的 prev 
    // 该的节点的后继节点指向新增的节点
    newNode->prev->next = newNode;
    // 新增节点的后继节点指向头节点
    newNode->next = head;

}

// 遍历
void foreachList(Node* head, int isReversal) {
    // 倒序遍历
    if (isReversal) {
        printf("倒序: ");
        Node* node = head->prev;
        while (node != head) {
            printf("%d ", node->data);
            node = node->prev;
        }

    } else {
        printf("正序: ");
        // 正序遍历
        Node* node = head->next;
        while (node != head) {
            printf("%d ", node->data);
            node = node->next;
        }
    }

    printf("\n");
}

int main(int argc, char const* argv[]) {
    // 头节点
    Node* head = createNode(-1);
    headInsert(head, 1);
    headInsert(head, 2);
    tailInsert(head, 4);
    tailInsert(head, 5);

    foreachList(head, 0);

    // 删除
    delNode(head, 4);
    delNode(head, 1);
    // delNode(head, 66);
    foreachList(head, 0);

    destroyList(&head);
    printf("%d \n", head == NULL);

    return 0;
}

头插入图片:

Snipaste_2025-01-14_23-07-31.png

插入新节点:

Snipaste_2025-01-14_23-06-03.png

循环单向链表

特点:

  1. 从头节点开始从一个方向开始指,最后指回头结点
  2. 第一个节点,既是头节点也是尾节点
  3. 当只有一个节点的时候,该节点的 next 指自己。

图片:

Snipaste_2025-01-14_23-13-00.png

代码:

#include <stdio.h>
#include <stdlib.h>

typedef struct node {
    int data;
    struct node* next;
} Node;


// 创建节点
Node* createNode(int data) {
    Node* node = malloc(sizeof(Node));
    node->data = data;
    node->next = node; // 自己指向自己
    return node;
}


// 头插法
void  headInsert(Node* head, int data) {
    Node* newNode = createNode(data);

    // 头部插入
    newNode->next = head->next;
    head->next = newNode;
}


// 遍历
void forEachList(Node* head) {
    Node* node = head->next;
    // 循环的结束条件当前节点 = 头节点了
    while (node != head) {  // 循环链表  
        printf("%d ", node->data);
        node = node->next;
    }

    printf("\n");
}

int main(int argc, char const* argv[]) {
    // 做单向循环链表,只做头插法
    Node* head = createNode(-1);
    headInsert(head, 1);
    headInsert(head, 2);
    headInsert(head, 3);
    headInsert(head, 4);
    forEachList(head);
    return 0;
}

总结:

  1. 创建新节点的代码,只考虑初始化的头结点的就行了
  2. 遍历循环链表的时候,循环的结束条件, ndoe == head 一直移动的节点 == 头结点的时候就代表循环完一次链表了
  3. 单向链表删除元素的时候需要先获取删除元素的前一个节点。

链表的使用场景:

单向链表通常用于实现栈、队列、哈希表和图等数据结构。

  • 栈与队列:当插入和删除操作都在链表的一端进行时,它表现的特性为先进后出,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现的特性为先进先出,对应队列。
  • 哈希表:链式地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
  • 图:邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。

双向链表常用于需要快速查找前一个和后一个元素的场景。

  • 高级数据结构:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
  • 浏览器历史:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
  • LRU 算法:在缓存淘汰(LRU)算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。

环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。

  • 时间片轮转调度算法:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环操作可以通过环形链表来实现。
  • 数据缓冲区:在某些数据缓冲区的实现中,也可能会使用环形链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环形链表,以便实现无缝播放。
数据结构与算法
License:  CC BY 4.0
Share

Further Reading

Jan 15, 2025

栈和队列

栈(stack)是一种遵循先入后出(后入先出) 逻辑的线性数据结构。 我们可以将栈类比为桌面上的一摞盘子,如果想取出底部的盘子,则需要先将上面的盘子依次移走。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈这种数据结构。 如图 5-1 所示,我们把堆叠元素的顶部称为“栈顶”,底部称

Jan 14, 2025

链表

链表概念 内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。 链表(linked list)是一种线性数据结构,其中的每个元素都是

Jan 14, 2025

数据结构与算法

算法定义 算法(algorithm)是在有限时间内解决特定问题的一组指令或操作步骤,它具有以下特性。 问题是明确的,包含清晰的输入和输出定义。 具有可行性,能够在有限步骤、时间和内存空间下完成。 各步骤都有确定的含义,在相同的输入和运行条件下,输出始终相同。 数据结构定义 数据结构(data str

OLDER

数据结构与算法

NEWER

栈和队列

Recently Updated

  • 其他
  • Elasticsearch 面试
  • Spring 面试
  • RabbitMQ 面试
  • Redis 面试

Trending Tags

ruoyi docker java

Contents

©2025 zian. Some rights reserved.

Using the Halo theme Chirpy