链表(linked list)


二、双向链表(Doubly Linked List )

双向链表基础

双向链表基础

双向链表的每个结点都有两个指针域:一个指向前驱结点,一个指向后继结点。 因此它既能向后走,也能向前走,访问、插入、删除都更灵活。

双向链表的结点结构

一个典型双向链表结点包含三个部分:前驱指针域、数据域、后继指针域。 数据域可以有一个或多个,取决于实际要保存的数据;两个指针域负责建立前后连接。

双向链表结点:prev | data | next prev 前驱地址 data 数据域 next 后继地址 指向前一个结点 指向后一个结点
前驱指针 `prev` 保存前一个结点的地址,可以从当前结点往前走。
数据域 保存真正的数据内容,可以是一个数据,也可以是一组数据。
后继指针 `next` 保存后一个结点的地址,可以从当前结点往后走。

双向链表如何连接

相邻两个结点之间不是只有一条连接,而是有两条方向相反的连接: 前一个结点的 `next` 指向后一个结点,后一个结点的 `prev` 指回前一个结点。 例如:A->next 指向结点 B,B->prev 指向结点 A。

head NULL A next 结点 A prev B next 结点 B prev C NULL 结点 C A->next 指向结点 B B->prev 指向结点 A B->next 指向结点 C C->prev 指向结点 B next 方向:从前往后 prev 方向:从后往前

为什么更方便

访问更灵活 在单链表中,通常只能从当前结点继续向后走;双向链表可以从当前结点向前或向后移动。
删除更直接 如果已经定位到某个结点,双向链表能直接找到它的前驱和后继,重新连接相邻结点更方便。
插入更方便 在两个结点之间插入新结点时,需要同时维护 `prev` 和 `next`,但不必只依赖从头遍历找前驱。
空间开销更大 每个结点多存一个前驱指针,因此会占用更多内存,这就是典型的“以空间换时间”。
双向链表的核心取舍是:每个结点多花一个指针域的空间,换来更方便的双向访问,以及更灵活的插入和删除操作。

C++ 代码实现示例

下面代码演示了双向链表的基本操作:头插、尾插、指定结点后插入、删除结点和遍历输出。 重点观察每次插入或删除时,`prev` 和 `next` 两个方向的指针如何同时维护。

doubly_linked_list.cpp
头插 / 尾插 / 后插 / 删除 / 遍历
/**************************************************************** 
 * 代码作者: Alex Li
 * 创建时间: 2023-05-22 16:03:37
 * 最后修改: 2025-01-10 22:11:00
 * 文件描述: 双向链表(doubly linked list)
****************************************************************/
#include <iostream>
using namespace std;

// 节点结构定义
struct Node {
  int data;            // 数据域
  struct Node* next;   // 指向下一个节点的指针
  struct Node* prev;   // 指向前一个节点的指针
};

struct Node* head = NULL; // 全局头节点,初始化为空

// 在链表的开头插入节点
void insertFront(int data) {
  // 分配新节点的内存
  struct Node* newNode = new Node;

  // 将数据赋值给新节点
  newNode->data = data;

  // 将新节点的 next 指向当前头节点
  newNode->next = head;

  // 将新节点的 prev 指向空
  newNode->prev = NULL;

  // 如果当前链表不为空,将原头节点的 prev 指向新节点
  if (head != NULL)
    head->prev = newNode;

  // 更新头节点为新节点
  head = newNode;
}

// 在指定节点之后插入新节点
void insertAfter(struct Node* prev_node, int data) {
  // 检查给定的前节点是否为空
  if (prev_node == NULL) {
    cout << "前一个节点不能为空";
    return;
  }

  // 分配新节点的内存
  struct Node* newNode = new Node;

  // 将数据赋值给新节点
  newNode->data = data;

  // 将新节点的 next 指向前节点的 next
  newNode->next = prev_node->next;

  // 将前节点的 next 指向新节点
  prev_node->next = newNode;

  // 将新节点的 prev 指向前节点
  newNode->prev = prev_node;

  // 如果新节点的 next 不为空,将其 next 节点的 prev 指向新节点
  if (newNode->next != NULL)
    newNode->next->prev = newNode;
}

// 在链表末尾插入新节点
void insertEnd(int data) {
  // 分配新节点的内存
  struct Node* newNode = new Node;

  // 将数据赋值给新节点
  newNode->data = data;

  // 将新节点的 next 指向空
  newNode->next = NULL;

  // 临时存储头节点,用于遍历链表
  struct Node* temp = head;

  // 如果链表为空,将新节点作为头节点
  if (head == NULL) {
    newNode->prev = NULL;
    head = newNode;
    return;
  }

  // 如果链表不为空,遍历到链表末尾
  while (temp->next != NULL)
    temp = temp->next;

  // 将最后一个节点的 next 指向新节点
  temp->next = newNode;

  // 将新节点的 prev 指向最后一个节点
  newNode->prev = temp;
}

// 删除链表中的节点
void deleteNode(struct Node* del_node) {
  // 如果头节点或待删除节点为空,则无法删除
  if (head == NULL || del_node == NULL)
    return;

  // 如果待删除节点是头节点,将头节点指向其下一个节点
  if (head == del_node)
    head = del_node->next;

  // 如果待删除节点不是最后一个节点,将其下一个节点的 prev 指向其前一个节点
  if (del_node->next != NULL)
    del_node->next->prev = del_node->prev;

  // 如果待删除节点不是第一个节点,将其前一个节点的 next 指向其下一个节点
  if (del_node->prev != NULL)
    del_node->prev->next = del_node->next;

  // 释放待删除节点的内存
  delete del_node;
}

// 打印链表中的所有节点
void displayList() {
  struct Node* last;
  last = head;
  while (last != NULL) { // 遍历链表并打印数据
    cout << last->data << " ";
    last = last->next;
  }
  if (head == NULL) // 如果链表为空,打印 NULL
    cout << "NULL\n";
  cout << endl;
}

int main() {
  // 初始化空链表并插入节点
  insertEnd(5);
  insertFront(1);
  insertFront(6);
  insertEnd(9);

  // 在头节点后插入 11
  insertAfter(head, 11);

  // 在第二个节点后插入 15
  insertAfter(head->next, 15);

  // 删除链表中的节点
  deleteNode(head->next->next->next->next);

  // 打印链表
  displayList();
}

一句话记住双向链表

结点结构 通常由 `prev`、`data`、`next` 三部分组成。
前驱指针 `prev` 指向前一个结点,支持向前访问。
后继指针 `next` 指向后一个结点,支持向后访问。
主要特点 插入删除更方便,但需要更多指针空间。


三、循环链表(Circular Linked List )

循环链表基础

循环链表基础

循环链表把普通链表的尾部重新接回链表中的起点,让链表不再以空指针结束。 它可以是单向循环链表,也可以是双向循环链表。

单向循环链表

单向循环链表只有一个指针方向。它和单链表最关键的区别是: 最后一个结点的指针不再指向空,而是指向头结点。

head A next B next C next C->next 指回头结点 A,不再指向 NULL
单向 每个结点只有一个 `next` 指针,只能沿一个方向继续访问。
成环 尾结点的 `next` 指向头结点,形成一个闭合环。
没有自然终点 遍历时不能只等 `NULL`,通常要判断是否回到起点。

双向循环链表

双向循环链表的每个数据结点都有两个指针,分别指向直接后继和直接前驱。 因此从任意一个结点出发,都可以方便地访问它的前驱结点和后继结点。

A prev | next B prev | next C prev | next 绿色箭头表示 next 方向,橙色箭头表示 prev 方向;两个方向都形成闭环
从任意结点向后 沿着 `next` 指针可以不断访问直接后继,最终会回到起点。
从任意结点向前 沿着 `prev` 指针可以不断访问直接前驱,最终也会回到起点。
插入删除更灵活 双向指针让结点能同时知道前后相邻结点,调整连接更方便。
空间开销更大 每个结点需要保存两个指针,仍然是用更多空间换更方便的访问。
循环链表遍历时要特别注意终止条件:因为它没有普通链表那样的 `NULL` 终点,通常以“是否回到头结点”作为停止条件。

一句话记住循环链表

循环思想 尾部重新接回起点,让链表形成闭环。
单向循环链表 尾结点的 `next` 指向头结点。
双向循环链表 每个结点既能找前驱,也能找后继。
遍历关键 停止条件通常是“再次回到起点”。

四、静态链表

用数组来表示链表,称为静态链表。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
/**************************************************************** 
 * Description: 静态链表,用过的结点要回收
 * Author: Alex Li
 * Date: 2022-08-04 11:22:42
 * LastEditTime: 2024-08-05 19:39:49
****************************************************************/
#include <iostream>
using namespace std;

const int N = 100010; // 定义常量N,表示数组的最大长度
int n = 0; // 当前链表节点数
int node[N], node_next[N]; // 分别存储节点的值和下一个节点的下标
int head, idx; // 头节点下标和当前可用的节点下标
int free_list_head; // 空闲链表的头节点

// 初始化链表
void init(){
    head = -1; // 初始头节点下标设为-1
    idx = 0; // 初始可用节点下标设为0
    free_list_head = -1; // 初始空闲链表头节点设为-1
}

// 从空闲链表中获取一个空闲下标
int get_free_index() {
    if (free_list_head == -1) {
        return idx++; // 如果空闲链表为空,返回当前可用下标并自增
    } else {
        int free_idx = free_list_head; // 获取空闲链表的头节点
        free_list_head = node_next[free_list_head]; // 更新空闲链表头节点
        return free_idx; // 返回空闲下标
    }
}

// 将x插入到头节点上
void firstNode(int x){
    int new_idx = get_free_index(); // 获取一个空闲下标
    node[new_idx] = x; // 在该下标处插入节点值x
    node_next[new_idx] = head; // 当前节点的next指向之前的头节点
    head = new_idx; // 更新头节点下标为当前节点下标
    n++; // 节点数加1
}

// 将x插入到下标为k的点的后面
void add(int k, int x){
    int new_idx = get_free_index(); // 获取一个空闲下标
    node[new_idx] = x; // 在该下标处插入节点值x
    node_next[new_idx] = node_next[k]; // 当前节点的next指向下标为k节点的下一个节点
    node_next[k] = new_idx; // 更新下标为k节点的next指向当前节点下标
    n++; // 节点数加1
}

// 删除下标为k的节点的后一个节点
void remove(int k){
    if (node_next[k] != -1) { // 确保k节点后面有节点
        int remove_idx = node_next[k]; // 获取要删除节点的下标
        node_next[k] = node_next[remove_idx]; // 更新k节点的next指向被删除节点的next
        node_next[remove_idx] = free_list_head; // 将被删除节点插入到空闲链表的头部
        free_list_head = remove_idx; // 更新空闲链表头节点
        n--; // 节点数减1
    }
}

// 打印链表中的所有节点
void printNode(){
    int i = head; // 从头节点开始
    while (i != -1) { // 遍历链表直到末尾
        cout << node[i] << " "; // 打印当前节点值
        i = node_next[i]; // 移动到下一个节点
    }
    cout << endl;
}

int main(){
    int k, option, position, value;
    init(); // 初始化链表
    do {
        cout << "请选择要执行的操作,输入选项编号:" << endl;
        cout << "0. 退出" << endl;
        cout << "1. 插入头节点" << endl;
        cout << "2. 插入新节点" << endl;
        cout << "3. 删除节点" << endl;
        cout << "4. 打印链表" << endl;
        cin >> option; // 输入选项编号
        switch (option) {
            case 0: 
                break;
            case 1: 
                cout << "输入头节点的值:" << endl; 
                cin >> value; 
                firstNode(value); 
                break; 
            case 2: 
                cout << "输入新节点的位置(插入到k后面)和值:" << endl;
                cin >> k >> value;
                add(k, value); 
                break; 
            case 3:
                cout << "输入要删除的节点位置:" << endl;
                cin >> k;
                remove(k);
                break; 
            case 4: 
                printNode();
                break;
            default: 
                cout << "请输入正确的选项编号" << endl;
        } 
    } while (option != 0); 
    return 0;
}


习题:小熊的果篮

题目描述

小熊的水果店里摆放着一排 nn个水果。每个水果只可能是苹果或桔子,从左到右依次用正整数 1,2,…,n 编号。连续排在一起的同一种水果称为一个“块”。小熊要把这一排水果挑到若干个果篮里,具体方法是:每次都把每一个“块”中最左边的水果同时挑出,组成一个果篮。重复这一操作,直至水果用完。注意,每次挑完一个果篮后,“块”可能会发生变化。比如两个苹果“块”之间的唯一桔子被挑走后,两个苹果“块”就变成了一个“块”。请帮小熊计算每个果篮里包含的水果。

输入格式

第一行,包含一个正整数 n,表示水果的数量。

第二行,包含 n 个空格分隔的整数,其中第 i 个数表示编号为 i 的水果的种类,1 代表苹果,0 代表桔子。

输出格式

输出若干行。

第 i 行表示第 i 次挑出的水果组成的果篮。从小到大排序输出该果篮中所有水果的编号,每两个编号之间用一个空格分隔。

输入输出样例

输入 #1

12
1 1 0 0 1 1 1 0 1 1 0 0

输出 1

1 3 5 8 9 11 
2 4 6 12
7
10

输入 2

20 
1 1 1 1 0 0 0 1 1 1 0 0 1 0 1 1 0 0 0 0

输出2

21 5 8 11 13 14 15 17 
2 6 9 12 16 18
3 7 10 19
4 20

说明/提示

【样例解释 #1】

这是第一组数据的样例说明。
所有水果一开始的情况是 [1,1,0,0,1,1,1,0,1,1,0,0],一共有 6 个块。
在第一次挑水果组成果篮的过程中,编号为 1,3,5,8,9,11的水果被挑了出来。
之后剩下的水果是 [1,0,1,1,1,0],一共 4 个块。
在第二次挑水果组成果篮的过程中,编号为 2,4,6,12 的水果被挑了出来。
之后剩下的水果是 [1,1],只有 1 个块。
在第三次挑水果组成果篮的过程中,编号为 7 的水果被挑了出来。
最后剩下的水果是 [1],只有 1 个块。
在第四次挑水果组成果篮的过程中,编号为10 的水果被挑了出来。

【数据范围】

对于 10%10% 的数据,n≤5n≤5。
对于 30%30% 的数据,n≤1000n≤1000。
对于 70%70% 的数据,n≤50000n≤50000。
对于 100%100% 的数据,1≤n≤2×1051≤n≤2×105。

[2021CSP-J-4][洛谷P7912] [4973]