数据结构基础:线性表及其存储实现

目录

一、线性表的定义与基本概念

二、线性表的顺序存储结构(顺序表)

[2.1 存储方式与寻址](#2.1 存储方式与寻址)

[2.2 基本操作的实现](#2.2 基本操作的实现)

[2.3 性能特征](#2.3 性能特征)

三、线性表的链式存储结构(链表)

[3.1 单链表](#3.1 单链表)

[3.2 双向链表与循环链表](#3.2 双向链表与循环链表)

[3.3 基本操作的实现](#3.3 基本操作的实现)

[3.4 性能特征](#3.4 性能特征)

四、两种存储结构的特点与应用场景

五、经典问题与算法示例

[5.1 单链表的反转](#5.1 单链表的反转)

[5.2 检测链表中的环](#5.2 检测链表中的环)

[5.3 合并两个有序链表](#5.3 合并两个有序链表)

六、总结


一、线性表的定义与基本概念

线性表(Linear List)是由 n(n ≥ 0)个类型相同的数据元素构成的有限序列。当 n = 0 时称为空表。除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。这种"一对一"的逻辑关系是线性结构的基本特征。

线性表支持以下核心操作:

  • 初始化(InitList)

  • 销毁(DestroyList)

  • 插入(ListInsert)

  • 删除(ListDelete)

  • 按位置取值(GetElem)

  • 按值查找(LocateElem)

  • 求长度(ListLength)

  • 判空(IsEmpty)

根据在计算机中存储方式的不同,线性表有两种基本的物理实现:顺序存储结构链式存储结构。下文分别讨论。

二、线性表的顺序存储结构(顺序表)

2.1 存储方式与寻址

顺序表采用一组地址连续的存储单元依次存放线性表中的数据元素,逻辑上相邻的元素在物理位置上也相邻。通常用高级语言中的数组来实现。

设每个元素占用 L 个存储单元,则第 i 个元素(0-based)的存储地址为:

text

复制代码
Loc(a_i) = Loc(a_0) + i × L

该公式使得顺序表能够实现随机访问,即存取任意位置元素的时间复杂度为 O(1)。

2.2 基本操作的实现

以下给出动态顺序表的 C 语言实现。采用动态数组,支持容量扩展。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

#define INIT_SIZE 10
#define INCREMENT 5

typedef int ElemType;

typedef struct {
    ElemType *data;      // 动态数组指针
    int length;          // 当前元素个数
    int capacity;        // 当前分配容量
} SeqList;

// 初始化
void InitList(SeqList *L) {
    L->data = (ElemType *)malloc(INIT_SIZE * sizeof(ElemType));
    if (!L->data) exit(1);
    L->length = 0;
    L->capacity = INIT_SIZE;
}

// 销毁
void DestroyList(SeqList *L) {
    if (L->data) {
        free(L->data);
        L->data = NULL;
        L->length = 0;
        L->capacity = 0;
    }
}

// 插入:在位置 i(0-based)插入元素 e
int ListInsert(SeqList *L, int i, ElemType e) {
    if (i < 0 || i > L->length) return 0;   // 位置非法

    if (L->length >= L->capacity) {
        ElemType *newData = (ElemType *)realloc(L->data, 
            (L->capacity + INCREMENT) * sizeof(ElemType));
        if (!newData) return 0;
        L->data = newData;
        L->capacity += INCREMENT;
    }

    // 从后往前移动元素,空出位置 i
    for (int j = L->length; j > i; j--) {
        L->data[j] = L->data[j - 1];
    }
    L->data[i] = e;
    L->length++;
    return 1;
}

// 删除:删除位置 i 的元素,并用 e 返回其值
int ListDelete(SeqList *L, int i, ElemType *e) {
    if (i < 0 || i >= L->length) return 0;
    *e = L->data[i];
    // 从前往后移动元素,填补空位
    for (int j = i; j < L->length - 1; j++) {
        L->data[j] = L->data[j + 1];
    }
    L->length--;
    return 1;
}

// 按位置取值
int GetElem(SeqList *L, int i, ElemType *e) {
    if (i < 0 || i >= L->length) return 0;
    *e = L->data[i];
    return 1;
}

// 按值查找,返回首次出现的位置,未找到返回 -1
int LocateElem(SeqList *L, ElemType e) {
    for (int i = 0; i < L->length; i++) {
        if (L->data[i] == e) return i;
    }
    return -1;
}

// 求长度
int ListLength(SeqList *L) {
    return L->length;
}

// 判空
int IsEmpty(SeqList *L) {
    return L->length == 0;
}

2.3 性能特征

  • 插入/删除 :平均需要移动约 n/2 个元素,时间复杂度为 O(n)。在表尾操作时为 O(1)。

  • 按位置访问 :通过地址直接计算,时间复杂度为 O(1)

  • 按值查找 :平均比较 n/2 次,时间复杂度为 O(n)

  • 空间利用率:除数据本身外无额外开销,存储密度高。

  • 空间分配:需要预先分配连续空间,扩容时需整体搬移。

三、线性表的链式存储结构(链表)

链表用一组任意的存储单元存放线性表的元素,这些单元在物理上可以不连续。元素间的逻辑关系通过指针来链接。

3.1 单链表

每个节点包含数据域和指针域,最后一个节点的指针域为空。通常引入头结点,其作用是统一空表与非空表的操作,使首元节点的插入删除与其他节点一致。

节点定义:

cpp 复制代码
typedef struct LNode {
    ElemType data;
    struct LNode *next;
} LNode, *LinkList;

3.2 双向链表与循环链表

  • 双向链表 :节点增加指向前驱的指针 prior,使得从任一节点均可向前或向后遍历。

  • 循环链表:尾节点的 next 指向头结点(或首元节点),形成环。双向循环链表则将头结点的 prior 指向尾节点。

这些变体在某些场景下具有优势。

3.3 基本操作的实现

以下为带头结点的单链表核心操作。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

typedef int ElemType;

typedef struct LNode {
    ElemType data;
    struct LNode *next;
} LNode, *LinkList;

// 初始化
void InitList(LinkList *L) {
    *L = (LNode *)malloc(sizeof(LNode));
    if (!(*L)) exit(1);
    (*L)->next = NULL;
}

// 销毁(释放所有节点)
void DestroyList(LinkList L) {
    LNode *p = L;
    while (p) {
        LNode *q = p->next;
        free(p);
        p = q;
    }
}

// 插入:在位置 i(0-based)插入元素 e
int ListInsert(LinkList L, int i, ElemType e) {
    if (i < 0) return 0;
    LNode *p = L;
    int j = 0;
    while (p && j < i) {    // 找到第 i 个节点的前驱
        p = p->next;
        j++;
    }
    if (!p) return 0;       // i 超出范围

    LNode *s = (LNode *)malloc(sizeof(LNode));
    if (!s) return 0;
    s->data = e;
    s->next = p->next;
    p->next = s;
    return 1;
}

// 删除:删除位置 i 的元素,用 e 返回
int ListDelete(LinkList L, int i, ElemType *e) {
    if (i < 0) return 0;
    LNode *p = L;
    int j = 0;
    while (p && j < i) {
        p = p->next;
        j++;
    }
    if (!p || !p->next) return 0;

    LNode *q = p->next;
    *e = q->data;
    p->next = q->next;
    free(q);
    return 1;
}

// 按位置取值
int GetElem(LinkList L, int i, ElemType *e) {
    if (i < 0) return 0;
    LNode *p = L->next;
    int j = 0;
    while (p && j < i) {
        p = p->next;
        j++;
    }
    if (!p) return 0;
    *e = p->data;
    return 1;
}

// 按值查找,返回位置(0-based),未找到返回 -1
int LocateElem(LinkList L, ElemType e) {
    LNode *p = L->next;
    int i = 0;
    while (p) {
        if (p->data == e) return i;
        p = p->next;
        i++;
    }
    return -1;
}

// 求长度
int ListLength(LinkList L) {
    int len = 0;
    LNode *p = L->next;
    while (p) {
        len++;
        p = p->next;
    }
    return len;
}

// 判空
int IsEmpty(LinkList L) {
    return L->next == NULL;
}

3.4 性能特征

  • 插入/删除:若已知插入位置的前驱,则操作时间为 O(1);若需按位置查找,则定位过程为 O(n)。

  • 按位置访问 :必须从表头遍历,时间复杂度为 O(n)

  • 按值查找 :平均遍历约 n/2 个节点,时间复杂度为 O(n)

  • 空间利用率:每个节点额外存储指针(单链表 1 个,双向 2 个),存储密度较低。

  • 空间分配:节点动态申请,无需连续空间,扩展灵活,无内存碎片。

四、两种存储结构的特点与应用场景

顺序表和链表各自具有不同的适应性,选择时需根据具体应用的操作特征来决定。

顺序表适用情形

  • 主要操作为按位置访问,如数组索引、缓存系统。

  • 数据量较固定或变化幅度较小,可预先分配空间。

  • 插入删除操作较少,或主要在表尾进行。

链表适用情形

  • 数据量动态变化,无法预估最大值。

  • 主要操作为插入和删除,且位置分散。

  • 对随机访问需求很低,不需要频繁按位置取值。

在实际系统中,顺序表因其内存连续、缓存友好,在数据量较小时往往表现出更好的实际性能;链表则更适合构造更复杂的数据结构,如邻接表、LRU 缓存等。两者均为线性表的重要实现方式,不应简单评判优劣,而应根据场景合理选用。

五、经典问题与算法示例

线性表的链式存储由于其指针操作具有较高技巧性,是算法考察的重点。以下列举三个典型问题及其标准解法。

5.1 单链表的反转

将单链表逆序,使每个节点的 next 指向前驱。

cpp 复制代码
LNode* ReverseList(LNode *head) {
    LNode *prev = NULL;
    LNode *curr = head;
    LNode *next = NULL;
    while (curr != NULL) {
        next = curr->next;   // 保存后继
        curr->next = prev;   // 反转指针
        prev = curr;
        curr = next;
    }
    return prev;  // 新头结点
}

该方法仅使用常数个辅助指针,空间复杂度 O(1),时间复杂度 O(n)。

5.2 检测链表中的环

利用快慢指针,若存在环则二者必定相遇。

cpp 复制代码
int HasCycle(LNode *head) {
    LNode *slow = head;
    LNode *fast = head;
    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) return 1;
    }
    return 0;
}

5.3 合并两个有序链表

将两个递增有序的单链表合并为一个递增有序链表,可递归实现。

cpp 复制代码
LNode* MergeTwoLists(LNode *l1, LNode *l2) {
    if (l1 == NULL) return l2;
    if (l2 == NULL) return l1;
    if (l1->data < l2->data) {
        l1->next = MergeTwoLists(l1->next, l2);
        return l1;
    } else {
        l2->next = MergeTwoLists(l1, l2->next);
        return l2;
    }
}

递归方法简洁易读,但若链表过长可能导致栈溢出,可改用迭代实现。

六、总结

本文系统介绍了线性表的定义、两种主流物理存储结构及其核心操作的实现方法,并分析了各自的时间与空间性能特征。线性表是所有线性数据结构(的基础,深刻理解其存储原理和操作细节,对后续学习更复杂的数据结构具有重要的奠基作用。

掌握顺序表和链表的实现,不仅有助于通过理论考核,更是编写高效程序的基本功。希望读者能够亲自动手实践以上代码,并进一步思考其在现实问题中的运用。

相关推荐
Rabitebla2 小时前
C++11 新特性详解(一):列表初始化、initializer_list 与右值引用
java·开发语言·数据结构·c++·算法·leetcode·list
zephyr052 小时前
链表实现O(n log n)时间复杂度的排序——归并排序详解
java·数据结构·算法
XWalnut3 小时前
LeetCode刷题 day37
java·数据结构·算法·leetcode
爱跳舞的烤冷面4 小时前
自学嵌入式第19天(数据结构篇——栈)
数据结构
星轨初途5 小时前
LeetCode 热题 100——day11 滑动窗口最大值
数据结构·c++·算法·leetcode·职场和发展
LuminousCPP18 小时前
数据结构-双向循环链表
c语言·数据结构·笔记·链表
白狐_79819 小时前
408数据结构第5章:树与二叉树②——遍历、线索树、森林与哈夫曼
数据结构·算法·深度优先
Jasmine_llq21 小时前
《P15800 [GESP202603 六级] 选数》
数据结构·long long·逆序线性 dp·从后往前推导·转移严格遵循题意·防止总和溢出·复杂度on
疯狂打码的少年21 小时前
【数据结构】串(字符串):基本概念与存储
数据结构·笔记