数据结构:线性表(C语言实现)

数据结构——线性表

  • 上集回顾:数据结构绪论
  • 一、线性表(Linear List)概述
    • 1.线性表的基本特性
    • 2.线性表的存储结构
  • 二、线性表的抽象数据类型(ADT)
    • 操作详细说明
  • 三、线性表的顺序表示(数组)
    • Ⅰ.顺序表的结构定义
    • Ⅱ.顺序表的基本操作实现
      • 1.初始化数组
      • 2. 插入操作
      • 3. 删除操作
      • 4. 修改操作
      • 5. 查询操作
    • Ⅲ.测试代码
      • 1.代码
      • 2.输出结果
  • 四、线性表的链式表示(链表)
    • Ⅰ.单链表的定义和表示
      • 1.首元结点、头结点、头指针的概念区别
      • 2.对于空链表的理解
    • Ⅱ.定义链表节点结构
    • Ⅲ.链表的基本操作实现
      • 1.初始化链表(创建头节点)
      • 2.按位置取值(获取第pos个元素)
      • 3.查找元素位置
      • 4.头插法插入(在链表头部插入)
      • 5.尾插法插入(在链表尾部插入)
      • 6.指定位置插入
      • 7.删除指定位置元素
      • 8.打印链表函数
  • 五、顺序表和链表的比较
    • ​​1.内存结构与容量管理:​​
    • ​​2.操作效率:​​
    • 3.​​内存利用:​​
    • ​​4.实现复杂度:​​
    • 5.顺序表(数组) vs 链表 详细比较
  • 下集预告:数据结构——栈与队列

上集回顾:数据结构绪论

数据结构:程序世界的基石与艺术: https://blog.csdn.net/R_Feynman_/article/details/148284180?spm=1001.2014.3001.5501

一、线性表(Linear List)概述

 ​​ ​​ ​​ ​​ ​​ ​​ ​​线性表​​是数据结构中最基本、最常用的一种结构,用于存储具有​​线性关系​​的数据元素集合。其特点是数据元素之间存在“一对一”的顺序关系,即除了第一个和最后一个元素外,每个元素都有唯一的前驱和后继。


1.线性表的基本特性

 ​​ ​​ ​​ ​​ ​​ ​​ ​​​​有限性​​:元素个数有限(区别于无限数列)。
 ​​ ​​ ​​ ​​ ​​ ​​ ​​​​有序性​​:元素按线性顺序排列,有明确的先后关系。
 ​​ ​​ ​​ ​​ ​​ ​​ ​​同类型​​:通常要求所有元素属于同一数据类型(某些语言支持泛型时可放宽)。


2.线性表的存储结构

线性表有两种主要存储方式:

顺序存储(顺序表)
​​实现方式​​:用​​数组​​实现,元素在内存中连续存放。
​​特点​​:
支持随机访问(通过下标直接访问,时间复杂度 O(1))。
插入/删除可能需要移动大量元素(平均时间复杂度 O(n))。
需预先分配固定空间,可能浪费内存或溢出。


链式存储(链表)
​​实现方式​​:通过​​结点​​(数据域 + 指针域)动态分配内存,元素物理上非连续。
​​特点​​:
插入/删除效率高(时间复杂度 O(1),若已知位置)。
不支持随机访问,需从头遍历(时间复杂度 O(n))。
无需预先分配空间,内存利用率高。


二、线性表的抽象数据类型(ADT)

 ​​ ​​ ​​ ​​ ​​ ​​ ​​线性表是零个或多个数据元素的有限序列,相邻元素存在序偶关系。线性表ADT定义了其逻辑特性和基本操作,与实现方式无关(顺序存储/链式存储)。

ADT List {
    // 数据对象
    Data: 具有相同类型的数据元素集合 {a₁, a₂, ..., aₙ}, n≥0

    // 关系定义
    Relation: 元素间存在线性序偶关系 <aᵢ, aᵢ₊₁>

    // 基本操作
    InitList(&L)         // 初始化空表
    DestroyList(&L)      // 销毁表
    ListEmpty(L)         // 判空 → 返回布尔值
    ListLength(L)        // 返回元素个数n
    GetElem(L, i, &e)   // 用e返回位置i的元素值
    LocateElem(L, e)     // 返回元素e的位置(无则返回0)
    ListInsert(&L, i, e) // 在位置i插入e(原i及后续元素后移)
    ListDelete(&L, i, &e) // 删除位置i的元素 → 用e返回其值
    PriorElem(L, e, &pre_e) // 获取e的直接前驱 → pre_e
    NextElem(L, e, &next_e) // 获取e的直接后继 → next_e
    TraverseList(L)      // 按序访问所有元素(如打印)
}

操作详细说明

操作名称 输入 输出/效果 前置条件 后置条件
InitList(&L) 创建一个空线性表 L L 被初始化为空表
DestroyList(&L) 线性表 L 释放线性表 L 的所有存储空间 L 已存在且已被初始化 L 被销毁,空间被释放
ListEmpty(L) 线性表 L 返回布尔值(True: 空表, False: 非空) L 已存在且已被初始化 线性表 L 保持不变
ListLength(L) 线性表 L 返回线性表的元素个数 L 已存在且已被初始化 线性表 L 保持不变
GetElem(L, i, &e) L, 位置 i (1≤i≤n) 将位置 i 的元素值赋给 e L 存在且 1≤i≤ListLength(L) 线性表 L 保持不变
LocateElem(L, e) L, 元素 e 返回 e 首次出现的位置(0表示不存在) L 已存在且已被初始化 线性表 L 保持不变
ListInsert(&L, i, e) L, 位置 i (1≤i≤n+1), 元素 e 在位置 i 插入新元素 e L 存在且 1≤i≤ListLength(L)+1 表长度+1,原 i 及后续元素后移
ListDelete(&L, i, &e) L, 位置 i (1≤i≤n) 删除位置 i 的元素,值赋给 e L 存在且 1≤i≤ListLength(L) 表长度-1,原 i+1 及后续元素前移
PriorElem(L, e, &pre_e) L, 元素 e 获取 e 的直接前驱元素赋给 pre_e e 存在且不是首元素 线性表 L 保持不变
NextElem(L, e, &next_e) L, 元素 e 获取 e 的直接后继元素赋给 next_e e 存在且不是尾元素 线性表 L 保持不变
TraverseList(L) L 按顺序访问线性表的所有元素 L 已存在且已被初始化 线性表 L 保持不变

三、线性表的顺序表示(数组)

Ⅰ.顺序表的结构定义

#define MAX_SIZE 100
int array[MAX_SIZE];
int length = 0;  // 当前数组的实际长度

Ⅱ.顺序表的基本操作实现

1.初始化数组

初始化时,将数组长度设置为0。

void InitArray() {
    length = 0;
}	

2. 插入操作

在数组的指定位置插入一个元素。
插入操作需要从后向前移动元素,避免覆盖。
插入后更新数组长度。

int Insert(int index, int value) {
    // 检查是否超出容量
    if (length >= MAX_SIZE) {
        return -1;  // 数组已满,插入失败
    }
    // 检查插入位置是否合法
    if (index < 0 || index > length) {
        return -1;  // 位置不合法
    }
    // 从后向前移动元素,为新元素腾出空间
    for (int i = length; i > index; i--) {
        array[i] = array[i - 1];
    }
    // 插入新元素
    array[index] = value;
    length++;
    return 0;  // 插入成功
}

3. 删除操作

删除数组中指定位置的元素。
从删除位置开始,将后续元素向前移动一位。
减少数组长度。

int Delete(int index) {
    // 检查数组是否为空
    if (length == 0) {
        return -1;  // 数组为空,删除失败
    }
    // 检查删除位置是否合法
    if (index < 0 || index >= length) {
        return -1;  // 位置不合法
    }
    // 从删除位置开始,将后续元素向前移动一位
    for (int i = index; i < length - 1; i++) {
        array[i] = array[i + 1];
    }
    length--;
    return 0;  // 删除成功
}

4. 修改操作

修改数组中指定位置的元素。
无需移动其他元素。

int Update(int index, int value) {
    // 检查数组是否为空
    if (length == 0) {
        return -1;  // 数组为空,修改失败
    }
    // 检查修改位置是否合法
    if (index < 0 || index >= length) {
        return -1;  // 位置不合法
    }
    // 修改元素
    array[index] = value;
    return 0;  // 修改成功
}

5. 查询操作

查询数组中指定位置的元素。

int GetElement(int index, int *value) {
    // 检查数组是否为空
    if (length == 0) {
        return -1;  // 数组为空,查询失败
    }
    // 检查查询位置是否合法
    if (index < 0 || index >= length) {
        return -1;  // 位置不合法
    }
    // 获取元素
    *value = array[index];
    return 0;  // 查询成功
}

Ⅲ.测试代码

1.代码

#include 

#define MAX_SIZE 100
int array[MAX_SIZE];
int length = 0;

void InitArray() {
    length = 0;
}

int Insert(int index, int value) {
    if (length >= MAX_SIZE) {
        return -1;
    }
    if (index < 0 || index > length) {
        return -1;
    }
    for (int i = length; i > index; i--) {
        array[i] = array[i - 1];
    }
    array[index] = value;
    length++;
    return 0;
}

int Delete(int index) {
    if (length == 0) {
        return -1;
    }
    if (index < 0 || index >= length) {
        return -1;
    }
    for (int i = index; i < length - 1; i++) {
        array[i] = array[i + 1];
    }
    length--;
    return 0;
}

int Update(int index, int value) {
    if (length == 0) {
        return -1;
    }
    if (index < 0 || index >= length) {
        return -1;
    }
    array[index] = value;
    return 0;
}

int GetElement(int index, int *value) {
    if (length == 0) {
        return -1;
    }
    if (index < 0 || index >= length) {
        return -1;
    }
    *value = array[index];
    return 0;
}

void PrintArray() {
    printf("Array: ");
    for (int i = 0; i < length; i++) {
        printf("%d ", array[i]);
    }
    printf("\n");
}

int main() {
    InitArray();
    Insert(0, 10);
    Insert(1, 20);
    Insert(2, 30);
    PrintArray();  // 输出:Array: 10 20 30

    Delete(1);
    PrintArray();  // 输出:Array: 10 30

    Update(1, 50);
    PrintArray();  // 输出:Array: 10 50

    int value;
    GetElement(1, &value);
    printf("Element at index 1: %d\n", value);  // 输出:Element at index 1: 50

    return 0;
}

2.输出结果

运行上述代码后,输出如下:

Array: 10 20 30
Array: 10 30
Array: 10 50
Element at index 1: 50

四、线性表的链式表示(链表)

Ⅰ.单链表的定义和表示

 ​​ ​​ ​​ ​​ ​​ ​​ 单链表是一种常见的线性数据结构,它由一系列节点组成,每个节点包含两部分:数据域和指针域
数据域:用于存储数据元素,可以是任意类型的数据,如整数、浮点数、字符串等。
指针域:用于存储指向下一个节点的指针(或引用)。最后一个节点的指针域通常指向空(null 或 None),表示链表的结束。

单链表的特点
 ​​ ​​ ​​ ​​ ​​ ​​ ① 动态存储:链表的节点可以根据需要动态分配和释放内存,因此其大小可以灵活变化,不受数组等静态存储结构的大小限制。
 ​​ ​​ ​​ ​​ ​​ ​​ ② 非连续存储:链表的节点在内存中可以分散存储,不需要像数组那样连续存储,这使得它在插入和删除操作时效率较高,因为不需要移动大量元素。
 ​​ ​​ ​​ ​​ ​​ ​​ ③ 单向性:由于每个节点只包含指向下一个节点的指针,因此单链表只能从头节点开始顺序访问每个节点,无法直接访问中间节点。

 ​​ ​​ ​​ ​​ ​​ ​​ 单链表通常需要一个头指针(或头节点)来标识链表的起始位置。头指针指向头结点,如果链表为空,则头指针指向空。

1.首元结点、头结点、头指针的概念区别


1. ​​结点(Node)​​
​​定义​​:链表的基本组成单元,包含两部分:

​​数据域(Data Field)​​:存储实际数据。
​​指针域(Next Field)​​:存储指向下一个结点的地址。


2. ​​首元结点(First Element Node)​​
​​定义​​:链表中​​第一个存储实际数据​​的结点(非头结点)。

​​特点​​:
是数据域的起点,存储链表的第一个有效数据。
在​​不带头结点的链表​​中,首元结点直接由头指针指向。
在​​带头结点的链表​​中,首元结点是头结点的下一个结点。


3. ​​头指针(Head Pointer)​​
​​定义​​:指向链表第一个结点的指针(无论第一个结点是头结点还是首元结点)。

​​特点​​:
​​必须存在​​,是访问链表的唯一入口。
若链表为空:头指针指向 NULL。
若链表​​带头结点​​:头指针指向头结点(头结点不存储实际数据)。
若链表​​不带头结点​​:头指针直接指向首元结点(存储实际数据)。


4. ​​头结点(Header Node)​​
​​定义​​:附加在链表首元结点之前的​​辅助结点​​,​​不存储实际数据​​。

​​特点​​:
​​数据域无意义​​:通常为空或存储链表长度等元信息。
​​指针域指向首元结点​​:若链表为空,则指针域为 NULL。
​​非必需​​:链表可以没有头结点(由设计需求决定)。

作用​​:
统一操作:简化插入/删除首元结点的逻辑(无需修改头指针)。
避免空指针:空链表时头指针仍指向头结点(而非 NULL)。

2.对于空链表的理解

情况1: 头指针指向头结点(带头结点的空链表)
┌──────────┐     ┌──────────┐
│  头指针  │───▶│  头结点   │───▶ NULL
└──────────┘     └──────────┘
                   (空链表但头指针不为NULL)

情况2: 头指针为NULL(不带头结点的空链表)
┌──────────┐
│  头指针  │───▶ NULL
└──────────┘
(真正的空链表)

① 头指针指向头结点​​
​​状态​​:空链表但​​带头结点​​

注意:
增加头节点后,无论链表是否为空,头指针都是指向头结点的非空指针。如下图所示的非空单链表,头指针指向头结点。

头指针 head
    │
    ▼
┌────────────┐
│  头结点     │ 
│ (数据域空)  │ 
├────────────┤ 
│ next       │───┐ 
└────────────┘   │ 
                 ▼ 
           ┌────────────┐    ┌────────────┐
           │ 元素1      │    │ 元素2      │
           ├────────────┤    ├────────────┤
           │ 数据: A    │    │ 数据: B    │
           ├────────────┤    ├────────────┤
           │ next       │───▶│ next       │───▶ NULL
           └────────────┘    └────────────┘

若为空表,则头节点的指针域为空(判定空表的条件可记为:L -> Next == NULL)

┌──────────┐     ┌──────────┐
│  头指针  │───▶│  头结点   │───▶ NULL
└──────────┘     └──────────┘
                   (空链表但头指针不为NULL)
​​内存结构​​:
头指针 → [头结点] → NULL
       └─数据域可能为空/0/长度等

​​特点​​:
​​头指针非空​​:存在一个头结点作为占位符
​​空链表标识​​:头结点的指针域为NULL

​​操作特性​​:
插入第一个数据时:头结点的指针域指向新结点
删除最后一个数据时:头结点指针域恢复为NULL
头指针​​永远不需要改变​​

② ​​头指针为NULL​​
​​状态​​:完全空链表(​​不带头结点​​)

​​内存结构​​:
头指针 → NULL

​​特点​​:
​​绝对空链表​​:没有任何结点存在

​​操作特性​​:
插入第一个数据时:头指针​​必须修改​​为指向新结点
删除最后一个数据时:头指针​​必须重置​​为NULL
边界操作需额外检查空指针


特征 头指针指向头结点 头指针为NULL
链表类型 带头结点的链表 不带头结点的链表
空链表表示 头结点的指针域为NULL 头指针直接为NULL
头指针稳定性 永远不会改变 插入/删除首元素时需改变
空链表内存占用 ≥1个结点(头结点) 0个结点
首元结点插入 无需特判空链表 必须单独处理空链表情况
代码一致性 所有位置插入/删除操作逻辑统一 首元素操作需特殊处理

Ⅱ.定义链表节点结构

#include 
#include 

// 定义链表节点结构
typedef struct Node {
    int data;           // 数据域
    struct Node *next;  // 指针域
} Node;

Ⅲ.链表的基本操作实现

1.初始化链表(创建头节点)

返回值:指向头节点的指针

Node* initList() {
    Node *head = (Node*)malloc(sizeof(Node));  // 创建头节点
    head->next = NULL;  // 头节点指针域置空
    return head;
}

2.按位置取值(获取第pos个元素)

head: 链表头指针
pos: 位置(从1开始计数)
e: 用来存放取到的元素
返回值:成功返回1,失败返回0

int getElem(Node *head, int pos, int *e) {
    Node *p = head->next;    // 指向第一个实际节点
    int count = 1;           // 计数器
    
    // 遍历链表直到找到位置或到达末尾
    while (p && count < pos) {
        p = p->next;
        count++;
    }
    
    // 若位置无效或链表为空
    if (!p || count > pos) 
        return 0;
    
    *e = p->data;  // 通过指针返回元素值
    return 1;
}

3.查找元素位置

head: 链表头指针
e: 要查找的元素
返回值:元素位置(从1开始),未找到返回0

int locateElem(Node *head, int e) {
    Node *p = head->next;  // 指向第一个实际节点
    int pos = 1;           // 位置计数器
    
    // 遍历链表查找元素
    while (p) {
        if (p->data == e)
            return pos;
        p = p->next;
        pos++;
    }
    return 0;  // 未找到
}

4.头插法插入(在链表头部插入)

head: 链表头指针
e: 要插入的元素

void insertAtHead(Node *head, int e) {
    Node *newNode = (Node*)malloc(sizeof(Node));  // 创建新节点
    newNode->data = e;                           // 设置新节点数据
    newNode->next = head->next;  // 新节点指向原第一个节点
    head->next = newNode;        // 头节点指向新节点
}

5.尾插法插入(在链表尾部插入)

head: 链表头指针
e: 要插入的元素

void insertAtTail(Node *head, int e) {
    Node *newNode = (Node*)malloc(sizeof(Node));  // 创建新节点
    newNode->data = e;
    newNode->next = NULL;
    
    Node *p = head;
    // 遍历到最后一个节点
    while (p->next) {
        p = p->next;
    }
    p->next = newNode;  // 尾节点指向新节点
}

6.指定位置插入

head: 链表头指针
pos: 插入位置(从1开始)
e: 要插入的元素
返回值:成功返回1,失败返回0

int insertAtPos(Node *head, int pos, int e) {
    Node *p = head;
    int count = 0;
    
    // 寻找第pos-1个节点
    while (p && count < pos - 1) {
        p = p->next;
        count++;
    }
    
    // 若位置无效
    if (!p || count > pos - 1)
        return 0;
    
    Node *newNode = (Node*)malloc(sizeof(Node));
    newNode->data = e;
    newNode->next = p->next;  // 新节点指向原位置节点
    p->next = newNode;        // 前一节点指向新节点
    return 1;
}

7.删除指定位置元素

head: 链表头指针
pos: 删除位置(从1开始)
e: 保存删除的元素值
返回值:成功返回1,失败返回0

int deleteAtPos(Node *head, int pos, int *e) {
    Node *p = head;
    int count = 0;
    
    // 寻找第pos-1个节点
    while (p->next && count < pos - 1) {
        p = p->next;
        count++;
    }
    
    // 若位置无效
    if (!(p->next) || count > pos - 1)
        return 0;
    
    Node *delNode = p->next;  // 要删除的节点
    *e = delNode->data;       // 保存删除的值
    p->next = delNode->next;  // 跳过删除节点
    free(delNode);            // 释放内存
    return 1;
}

8.打印链表函数

void printList(Node *head) {
    Node *p = head->next;  // 跳过头节点
    while (p) {
        printf("%d -> ", p->data);
        p = p->next;
    }
    printf("NULL\n");
}

五、顺序表和链表的比较

​​1.内存结构与容量管理:​​

​​顺序表:​​ 核心是连续内存块。像一排座位,元素必须挨着坐。这使得它内存访问效率高(直接计算地址),但也有致命弱点:插入/删除元素中间时需要大量移动其它元素(O(n))。容量固定或需扩容(申请新块 → 搬数据 → 释放旧块,开销大)。
​​链表:​​ 核心是节点(包含数据和指针)和指针链接。像一群人手拉手(或拉前后两人),站哪不重要,记住拉谁就行。插入/删除(尤其在已知位置后)只需修改少量指针(O(1))。空间天然动态,按需分配节点。缺点是无法直接定位第N个元素(O(n)遍历)。

​​2.操作效率:​​

​​随机访问 (Access by Index):​​
顺序表完胜(O(1) vs O(n))。

​​插入/删除 (Insertion/Deletion):​​
在链表​​已知位置​​插入/删除节点时(尤其是头、尾或已知节点前后),链表优势巨大(O(1))。
在顺序表中间插入/删除,需要移动后续元素(O(n))。
在顺序表​​尾端​​插入/删除,若无扩容/缩容,也是O(1);但在链表尾操作,若无尾指针也需要先遍历到尾(O(n)),有尾指针则为O(1)。

查找 (Search by Value):​​
两者都需要遍历(O(n))。如果顺序表有序,可用二分查找达到O(log n),链表则不行。

3.​​内存利用:​​

​​顺序表:​​ 空间密集(元素本身),但有“容量”限制。扩容时可能申请过大的预留空间造成浪费(内部碎片),碎片化问题通常较少。元素本身存储开销小。
​​链表:​​ 空间稀疏(元素 + 指针开销,对于小对象尤为不利)。没有预留空间浪费,按需分配,碎片化问题主要集中在内存分配器层面而非数据结构本身。无“容量”概念。

​​4.实现复杂度:​​

顺序表简单直接(维护数组和大小)。
链表稍复杂(创建节点、维护指针:头指针、尾指针(可选)、双向链表的前驱指针。需处理边界条件(空表、头尾操作)和指针异常,特别是双向链表。

5.顺序表(数组) vs 链表 详细比较

特性 顺序表(数组) 链表 总结对比
存储方式 连续内存空间 分散内存空间(通过指针链接) 顺序表物理连续,链表逻辑连续但物理分散
容量管理 固定大小或需扩容 动态按需分配 链表天然支持动态扩容
随机访问(索引访问) ✅ O(1) (直接寻址) ❌ O(n) (需遍历) 顺序表完胜
头插/删效率 ❌ O(n) (需移动后续元素) ✅ O(1) (仅修改指针) 链表完胜
尾插/删效率 ✅ O(1) (预空间充足时) ✅ O(1) (有尾指针时)/O(n) (无尾指针) 顺序表更稳定
中插/删效率 ❌ O(n) (需移动元素) ⚡ O(1) (定位后修改指针) 链表更优
按值查找 ⚡ O(n) (有序时可O(log n)) ❌ O(n) (必须遍历) 顺序表稍优(可二分)
内存开销 仅元素空间 元素空间+指针空间(每个节点1-2个指针) 链表空间开销更大
内存碎片 内部碎片(预留空间) 外部碎片(多次分配) 顺序表内存利用率更低
缓存友好性 ✅ 连续内存预读取高效 ❌ 分散内存缓存命中率低 顺序表访问性能更高
实现复杂度 简单直观 指针操作需谨慎(空指针/断链等) 链表更易出错
扩容代价 高(O(n)):申请新空间+数据迁移 低(O(1)):只分配单个节点 链表扩容零迁移
迭代器安全性 ❌ 扩容后迭代器失效 ✅ 节点删除仅影响当前节点 链表迭代器更安全
适用场景 ✔️ 高频随机访问
✔️ 数据量稳定
✔️ 注重访问性能
✔️ 频繁插入删除
✔️ 数据量变化大
✔️ 内存受限
根据核心操作选择

下集预告:数据结构——栈与队列

你可能感兴趣的:(数据结构与算法,数据结构,c语言,算法,链表,考研)