【C语言版】数据结构教程(二)线性表

 【内容简介】本文整理数据结构(C语言版)相关内容的复习笔记,供各位朋友借鉴学习。本章内容更偏于记忆和理解,请读者们耐心阅读。同时,这里提醒各位读者,尽管书本上说本书用的是 C 语言版,但是中间用到的许多操作都是需要在 C++ 环境下才可以运行的。

2.1 线性表的类型定义

        线性表(linear_list)是最常用且是最简单的一种数据结构。在介绍它之前,我们先来了解什么是线性结构。

        什么是线性结构?线性结构的定义是:在数据元素的非空有限集中,(1)存在唯一的一个被称为“第一个”的数据元素;(2)存在唯一的一个被称为“最后一个”的数据元素;(3)除第一个之外,集合中的每个数据元素均只有一个前驱;(4)除最后一个之外,集合中每个数据元素均只有一个后继。

        由此,实际上,线性表就是一个线性结构,其中由 n 个数据元素排列而成。比如:(A, B, C, ... , Z),这个由26个英文字母组成的字母表就是一个线性表。但对于一些比较复杂的线性表,一个数据元素往往由若干个数据项组成。这时,我们称由若干个数据项组成的数据元素为记录(record),而含有大量记录的线性表被称为文件(file)

【例1】下面这张学生健康登记表也是一个线性表。其中每个学生的情况都是一个数据元素,它由姓名、学号、性别、年龄、班级和健康状况等 6 个数据项组成。

                从上面的例子可见,线性表中数据元素可以是任意类型,但同一个线性表中的所有元素必定具有相同属性,也就是说属于同一数据对象。一般情况下,我们可以将线性表记为:

(a_1, ... , a_i, a_{ i +1}, ... , a_n)

        线性表中元素的个数 n 定义为线性表的长度,n = 0 时称为空表。其中 a_i 是第 i 个数据元素,称 i 为数据元素 a_i 在线性表中的位序。

        线性表中还有许多其他的操作,以下是抽象数据类型线性表的定义

         对于上述定义的抽象数据类型线性表,还可进行一些更复杂的操作,例如:将两个及以上的线性表合并成一个线性表;把一个线性表拆成两个或两个以上的线性表;复制一个线性表等等。在上图中有一些比较常用的操作,这些在之后的算法书写中都是可以直接使用的,因此需要牢记!

        在数据结构的学习过程中,算法的书写必定是重中之重,这里我们先来看一个例子:

【例2】已知线性表 A 和 B 中的数据元素按值非递减有序排列,现要求将 A 和 B 归并为一个新的线性表 C,并且 C 中的数据元素仍按照非递减有序排列。试编写算法满足上述要求。

例如:A = { 1, 2, 4, 8 };B = { 3, 7, 10, 11, 12 },则 C = { 1, 2, 3, 4, 7, 8, 10, 11, 12 }。

【算法思路】由于两个线性表 A, B 都已经按顺序排好了,只需要先设 C 是空表,将 A, B 中的元素按大小顺序依次放入 C 中即可。具体可以设两个指针分别指向 A 和 B 中元素,再按插入顺序后移即可。(请注意,这里书写时用的是类 C 语言,并不是真正的 C 语言!!!)

void MergeList(List A, List B, List &C) {
    // 已知线性表A,B,C中数据元素按值非递减排列。
    // 归并A,B得到的新的线性表C,C的数据元素也按值非递减排列
    InitList(C);
    i = j = 1; k = 0;
    A_len = ListLength(A); B_len = ListLength(B);
    while((i <= A_len) && (j <= B_len)) { // A,B均非空
        GetElem(A, i, ai); GetElem(B, j, bj);
        if(ai <= bj) {ListInsert(C, ++k, ai); ++i;}
        else {ListInsert(C, ++k, bj); ++j;}
    } 
    while(i <= A) {
        GetElem(A, i++, ai); ListInsert(C, ++k, ai);
    }
    while(j <= B) {
        GetElem(B, j++, bj); ListInsert(C, ++k, bj);
    }
}// MergeList

【时间复杂度分析】该算法的时间复杂度为 O(ListLength(A) + ListLength(B))。后面两个循环最终只会执行一个。 

2.2 线性表的顺序表示和实现

2.2.1 线性表的顺序存储表示

        线性表的顺序表示指的是表中的数据元素不仅逻辑上相邻,其物理次序也相邻。有时,我们也称这种存储结构的线性表为顺序表

        由于线性表的长度可变,且所需最大存储空间随问题的不同而不同,因此在 C 语言中可以使用动态分配的一维数组来表示线性表,描述如下:

PS: 这里请注意,之后初始化时的动态分配是 C++ 特有的,而不是 C 语言所有的写法!

#define MAX 100
typedef struct {
    ElemType *elem;
    int length;
} SqList;

2.2.2 顺序表中基本操作的实现

         这里我们主要针对上面提到的一些线性表的操作,用算法来实现一下。

【1】初始化:顺序表的初始化操作就是构造一个空的顺序表。

// 动态分配线性表的存储区域可以更有效地利用系统的资源,当不需要时可以销毁操作
int InitList(SqList &L) {
    // 构造一个空的线性表
    L.elem = new ElemType[MAX];  // 为顺序表分配空间
    if (!L.elem) exit(OVERFLOW); // 存储分配失败退出
    L.length = 0;                // 空表长度为0
    return 1;
}

【2】取值和查找:在顺序表中取某一个元素的值和查找某一个元素分别为复杂度 O(1) 和 O(n) 的算法。

int GetElem(SqList L, int i, ElemType &e) {
    if (i < 1 || i > L.length) return 0;
    
    e = L.elem[i - 1]; // elem[i-1]单元存储第 i 个数据元素

    return 1;
}
int LocateElem(SqList L, ElemType e) {
    // 在顺序表中查找值为e的数据元素,返回其序号
    for (int i = 0; i < L.length; i++) {
        if (L.elem[i] == e) return i + 1;
    }
    return 0;
}

【3】插入:插入时主要的时间消耗在于需要将插入位置之后的其它元素统一向后移动。

int InsertList(SqList &L, int i, ElemType e) {
    if ((i < 1) || (i > L.length + 1)) return 0;
    if (L.length == MAX) return 0;
    for (int j = L.length - 1; j >= i - 1; j--) {
        L.elem[j + 1] = L.elem[j];
    }
    L.elem[i - 1] = e;
    ++L.length;
    return 1;
}

【4】删除:这个和插入差不多,只不过需要将删除之后的其它元素统一向前移动。这里就不写代码示例了,留给读者自行尝试完成,应该比较简单(原谅我说这么恶毒的话)

2.3 线性表的链式表示和实现

2.3.1 单链表的定义和表示

        线性表的链式表示中最突出的特点就是相邻的结点在内存中的存储并不一定是连续的,也就意味着我们不能用下标进行访问链表中的某一个结点。而为了能表示每个数据元素 a_i 与其直接相邻的数据元素 a_{i+1} 之间的逻辑关系,我们使用指针来串联整个链表。又由于此链表中每个结点都只包含一个指针域,故又称线性链表或单链表

        由于整个单链表线性相连,我们不难意识到整个链表的逻辑关系都可以由头指针唯一确定。其余的所有结点只需要和头指针或头指针之后已经连接上的结点相连即可。

        以下是结构体链表的结点的一般创建形式,其中 int num; 可以由任意需要保存的数据代替。

struct LNode {
    int num;            // 结点中保留的数据
    struct LNode *next; // 指针指向下一个结点
};

        一般情况下,一个单链表的第一个存储了数据的结点之前会放置一个不存储数据的头结点。当然头结点也可以存储一些完整的数据,比如:当数据元素为整数型时,头结点的数据域中可以存储该线性表的长度。

2.3.2 单链表中基本操作的实现

        这里我们主要针对上面提到的一些单链表的基本操作,用算法来实现一下。首先这里的结点我们使用的是:

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

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

【1】初始化:生成一个新结点作为头结点,并将其头结点的指针域置空(指向 NULL)。

Node* initList() {
    Node* head = (Node*) malloc (sizeof(Node));
    if (!head) {
        exit(1);    // 内存分配失败,结束程序
    }
    head->next = NULL;  // 初始化头节点指针域为空
    return head;
}

【2】取值:由于链表中无法通过下标访问结点,因此我们如果想要获取某结点的数据域,我们需要顺着链表依次查找。这个过程比较简单,这里就不展开了,读者可以自行尝试。

【3】插入:链表的插入有两种,分别是头插法和尾插法。顾名思义,就是每次在头部插入新的结点或是每次在尾部插入新的结点。

// 尾插法
void insertNodeInEnd(Node* head, int data) {
    Node* newNode = (Node*) malloc (sizeof(Node));
    if (!newNode) {
        exit(1);    // 内存分配失败,结束程序
    }
    newNode->data = data;
    newNode->next = NULL;

    // 找到链表的最后一个节点
    Node* curr = head;
    while (curr->next != NULL) {
        curr = curr->next;
    }

    // 将新节点插入链表
    curr->next = newNode;
}
// 头插法
void insertNodeInHead(Node* head, int data) {
    Node* newNode = (Node*) malloc (sizeof(Node));
    if (!newNode) {
        exit(1);    // 内存分配失败,结束程序
    }
    newNode->data = data;
    newNode->next = head->next;
    // 将head前移一位
    head->next = newNode;
}

【4】删除结点:

void deleteNode(Node* head, int data) {
    if (head == NULL || head->next == NULL) {   // 空链表或只有一个节点的链表
        return;
    }
    // 如果要删除的节点是头节点
    if (head->next->data == data) {
        Node* temp = head->next;    // 暂存要删除的节点
        head->next = temp->next;    // 修改头节点的指针域,跳过要删除的节点
        free(temp); // 释放temp的内存空间
        return;
    }
    // 如果要删除的节点不是头节点
    Node* curr = head;
    while (curr->next != NULL && curr->next->data != data) {    // 找到要删除的节点的前一个节点
        curr = curr->next;
    }
    if (curr->next == NULL) {   // 如果没有找到该节点
        return;
    }
    Node* temp = curr->next;    // 暂存要删除的节点
    curr->next = temp->next;    // 修改该节点的指针域,跳过要删除的节点
    free(temp); // 释放temp的内存空间
}

2.3.3 循环链表

        循环链表是一种特殊的单链表,它和一般的单链表不同之处在于,表中最后一个结点的指针域指向头结点,整个链表形成一个环。这种链表对于某些特殊的问题还是有好处的,不过这需要看情况讨论了。

2.3.4 双向链表

         对于单链表中的某个结点而言,如果我们想知道它的下一个结点的数据,我们可以通过指针直接得到,执行时间为 O(1),但如果我们想知道它的上一个结点的数据,我们必须从头开始查找,执行时间为 O(n)。为了克服这种缺点,我们可以利用双向链表。

        顾名思义,双向链表的结点中有两个指针域,一个直接指向下一个结点,一个直接指向上一个结点。大体如下:

struct DulNode {
    int num;
    struct DulNode *prior; // 指向前驱
    struct DulNode *next;  // 指向后驱
};

        这里我们展示一下双向链表的插入和删除的一般算法,仅供参考。

#include <cstdio>
#include <cstdlib>
using namespace std;
typedef struct Node {
    int num;
    struct Node *prior;
    struct Node *next;
} Node;

bool ListInsert(Node* head, int i, int number) {
    Node *p = head;
    while (i--) {
        if (p == NULL) return 0;
        p = p -> next;
    }
    Node *s = new Node;
    s -> num = number;
    s -> prior = p -> prior;
    p -> prior -> next = s;
    s -> next = p;
    p -> prior = s;
    return 1;
}

bool ListDelete(Node* head, int i) {
    Node *p = head;
    while (i--) {
        if (p == NULL) return 0;
        p = p -> next;
    }
    p -> prior -> next = p -> next;
    p -> next -> prior = p -> prior;
    free(p);
    return 1;
}

2.4 总结

        以上便是我们第二章线性表的内容,接下来我们将要学习的是栈和队列,请同学们做好准备哦。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值