数据结构-线性表


数据结构-线性表


image


2.1 线性表的定义和基本运算

2.1.1 线性表的逻辑定义

线性表(Linear List)是最简单和最常用的一种数据结构,它是由 \(n\) 个数据元素(节点)\(a_{1},a_{2},...,a_{n}\) 组成的有限序列。其中元素的个数 n 为表的长度。当 n = 0 时,称为空表,非空的线性表记为:
\( (a_{1},a_{2},...,a_{i-1},a_{i},a_{i+1},...,a_{n}) \)

线性表逻辑特征:

  1. 有且仅有一个称为开始元素的 \(a_{1}\), 它没有前趋,仅有一个直接后继 \(a_{2}\);
  2. 有且仅有一个称为终端元素的 \(a_{n}\), 它没有后继,仅有一个直接前趋;
  3. 其余元素 \(a_{i}(2<=i<=n-1)\) 称为内部元素,它们都有且仅有一个直接前趋 \(a_{i-1}\) 和一个直接后继 \(a_{i+1}\)

2.1.2 线性表的基本运算

1. 置空表 InitList(L) : 构造一个空的线性表
2. 求表长 ListLength(L) : 返回元素个数
3. 取表中的第 i 个元素 GetNode(L,i):返回第 i 个元素
4. 按值查找 LocateNode(L,x):按值查找,不存在返回0
5. 插入 InsertList(L,i,x):在第 i 个元素之前插入一个新元素
6. 删除 DeleteList(L,i): 删除表中第 i 个元素

2.2 线性表的顺序存储和基本运算的实现

2.2.1 线性表的顺序存储

线性表的顺序存储指的是将线性表的数据元素按其逻辑次序依次存入一组地址连续的存储单元里,用这种方法存储的线性表称为顺序表。

假设线性表中所有元素的类型相同,且每个元素需占用 d 个存储单元,其中第一个单元的存储位置(地址)就是该元素的存储位置。那么,线性表中的第 i+1 个元素的存储位置 \(LOC(a_i+1)\) 和第 i 个元素的存储位置 \(LOC(a_i)\) 有关系:

\[ LOC(a_i+1) = LOC(a_i)+d \]

一般来说,线性表的第 i 个元素 \(a_i\) 的存储位置为:

\[LOC(a_i) = LOC(a_i)+(i-1)*d \]

其中,\(LOC(a_i)\) 是线性表的第 i 个元素的 \(a_i\) 的存储位置,通常称为基地址。

线性表的这种机内表示称为线性表的顺序存储结构。它的特点是,元素在表中的相邻关系,在计算机内也存在着相邻关系。每个元素 \(a_i\) 的存储地址是该元素在表中的位置 i 的线性函数,只要知道基地址和每个元素占用的单元数(元素的大小),就可求出任意元素的存储地址。因此只要确定了线性表存储的起始位置,线性表中任意一个元素都可随机存取,所以顺序表是一种随机存取结构。

由于高级程序设计语言中的数组类型具有随机存取的特性,因此,通常用数组来表述顺序表。另外,除了存储线性表的节点外,还需要一个变量来标识线性表的当前长度,所以用下面的结构类型来定义顺序表类型:

#define ListSize 100
typedef int DateType;
typedef struct {
	DateType data[ListSize];
	int length;
}SeqList;

顺序表在内存中的存储表示如下图,图中存储地址 b 为基地址\(LOC(a_1)\)。因为 C 语言的数组下标从 0 开始,因此使用时要特别注意。假设 L 是 SeqList 类型的顺序表,则开始节点 \(a_1\) 存放在 L.data[0] 中,终端节点 \(a_n\) 存放在 L.data[n-1] 中。同理,若 p 为一个指定顺序表的指针变量,则 \(a_1\)\(a_n\) 分别存放在 p->data[0] 和 p->data[n-1] 中。
image

2.2.2 顺序表上的基本运算的实现

随机存取第 i 个节点,使用 L.data[i-1]

1. 插入运算

void InsertList(SeqList *L, int i, DataType x) {
    //1. 首先判断 i 节点是否在实际范围内
    if (i < 1) {
        printf("i 节点非法: i = %d\n", i);
        return;
    }
    //2. 判断 i 添加之后是否在实际范围内
    if (i > L->length + 1) {
        printf("overflow: i 节点大于数组实际容量\n");
        return;
    }
    //3. 判断数组实际长度是否大于数组容量
    if (L->length >= ListSize) {
        printf("数组实际容量超过或等于数组存储容量\n");
        return;
    }
    //4. 循环后移
    for (int j = L->length - 1; j >= i - 1; j--) {
        L->data[j + 1] = L->data[j];
    }
    //5. 添加元素
    L->data[i - 1] = x;
    L->length++;
}

2. 删除运算

DataType DeleteList(SeqList *L, int i) {
    int j;
    DataType x;
    if (i < 1) {
        printf("index overflow!!!");
        exit(0);
    }
    if (i >= L->length) {
        printf("overflow!!!");
        exit(0);
    }
    x = L->data[i];

    for (int j = i; j <= L->length; ++j) {
        L->data[j-1] = L->data[j];
    }
    L->length--;
    return x;
}

3. 顺序表上的其他运算举例

转置:

SeqList Converts(SeqList L){
    DataType x;
    int i,k;
    k = L.length/2;
    for (int i = 0; i < k; ++i) {
        x = L.data[i];
        L.data[i] = L.data[L.length-i-1];
        L.data[L.length-i-1] = x;
    }
    return L;
}

最大值&最小值

void MaxMin(SeqList L,DataType *max,DataType *min,int *k,int *j){
    int i;
    *max = L.data[0];
    *min = L.data[0];
    *k = *j = 1;
    for (int i = 1; i < L.length; ++i) {
        if(L.data[i]>*max){
            *max = L.data[i];
            *k = i;
        } else if(L.data[i]<*min){
            *min = L.data[i];
            *j = i;
        }
    }
}

2.3 线性表的链式存储结构

线性表顺序存储结构的特点是,在逻辑关系上相邻的两个元素在物理位置上也是相邻的,因此可以随机存取表中的任一元素。But,当经常需要插入和删除操作运算时,则需要移动大量的元素,而采用链式存储结构时就可以避免这些移动。然而,由于链式存储结构存储线性表数据元素的存储空间可能是连续的,也可能是不连续的,因而链表的节点是不可以随机存取的。链式存储是最常用的存储方式之一,不仅可以用来表示线性表,而且还可以是用来表示各种非线性的数据结构,在以后的文章中我们将反复使用这种存储方式。

2.3.1 单链表(线性链表)

在使用链式存储结构表示每个数据元素 \(a_i\) 时,除了存储 \(a_i\) 本身的信息之外,还需要一个存储指示其后继元素 \(a_{i+1}\) 存储位置的指针,由这两部分组成元素 \(a_i\) 的存储映像称为结点。它包括两个域(字段):存储数据元素的域称为数据域,存储直接后继存储地址的域称为指针域。利用这种存储方式表示的线性表称为链表,链表中一个节点的存储结构为:

image

n 个节点链成一个链表,即为线性表 \((a_1,a_2,...,a_n)\) 的链式存储结构。由于这种链表的每个节点只包含一个指针域,又称单链表,抽象表示如下图:
image

显然,单链表中每个节点的存储地址时存放在其直接前趋节点的指针域(*next)中,而开始节点无直接前趋,因此设立指针 head 指向开始系欸但。又由于终端节点无后继节结点,所以终端节点的指针域为空,即NULL。如果链表中一个节点也没有,则为空链表,这是 head = NULL.

由此可见,一个单链表可由头指针唯一确定,因此单链表可用头指针的名字来命名。

typedef int DataType; // 元素类型
typedef struct node { // 节点类型定义
    DataType data; // 节点数据字段
    struct node *next; // 节点指针字段
} ListNode;
// 定义别名类型: linklist指针
typedef ListNode *LinkList;
ListNode *p;// 定义节点指针变量
LinkList head;// 定义链表头指针变量

注意,这里的 LinkList 和 ListNode * 是不同名字的同一指针类型,取名的不同是为了在概念上更明确。特别值得注意的是指针变量和指针指向的变量(结点变量)这两个概念。指针变量的值要么为空(NULL),不指向任何节点;要么非空,即它的值是一个节点的存储地址。指针变量所指向的节点地址并没有具体说明,而是在程序的执行过程中需要存放结点时才产生,是通过 C 语言的标砖函数 malloc() 实现的。例如,给指针变量 p 分配一个节点的地址: p = (ListNode * )malloc(sizeof(ListNode)); 该语句的功能是申请分配一个类型为 ListNode 的结点的地址空间,并将其首地址存入指针变量 p 中。当结点不需要时可以用标砖函数 free(p) 释放结点存储空间。

链表中的结点变量是通过指针变量来访问的。因为在 C 语言中使用 p-> 来表示 p 所指向的变量的,又由于结点类型是一个结构类型,因此可用 p->datap->next 分别表示结点的数据域变量和指针域变量。注意,当 p 值为空值时,则不指向任何结点,此时不能通过 p 来访问结点,否则会引起程序错误。

2.3.2 单链表上的基本运算

1. 建立单链表

动态建立单链表的常用方法有两种:头插法和尾插法

1.1 头插法

头插法建表是从一个空表开始,重复读入数据,生成新结点,将读入的数据存放到新节点的数据域中,然后将新节点插入到当前链表的表头上,知道读入结束标志为止。
假设线性表中结点的数据域为字符型,具体算法如下:

LinkList CreateListF() {
    LinkList head;
    ListNode *p;
    char ch;
    head = NULL;
    ch = getchar();
    while (ch!='\n'){
        p = (ListNode*)malloc(sizeof(ListNode));
        p->data = ch;
        p->next = head;
        head = p;
        ch = getchar();
    }
    return head;
}

例如,在空链表 head 中依次插入数据域分别为 a、b、c 的结点之后,将 x 为数据域的新结点 p 插入到当前链表表头,其指针的修改变化如下图:

image

1.2 尾插法

头插法建立链表是将新结点插入在表头,算法比较简单,但新建链表中结点的次序是和输入时的顺序相反,理解是不太直观。若需要和输入次序一致,则可使用尾插法建立链表。该方法是将新节点插入在当前链表的表尾上,因此需要增设一个尾指针 rear,使其始终指向链表的尾结点。例如在 head 中依次插入 a、b、c 的结点之后,将 x 为数据域的新结点 p 插入到当前链表表尾,其指针的修改变化情况如下图:

image
假设线性表中结点的数据域为字符型,具体算法如下:

LinkList CreateListR() {
    LinkList head, rear;
    head = rear = NULL;
    ListNode *p;
    char ch;
    ch = getchar();
    while (ch != '\n') {
        p = (ListNode *) malloc(sizeof(ListNode));
        p->data = ch;
        if (head == NULL) {
            head = p;
        } else {
            rear->next = p;
        }
        rear = p;
        ch = getchar();
    }
    if (rear->next != NULL) {
        rear->next = NULL;
    }
    return head;
}

为了简化算法,方便操作,可在链表的开始结点之前附加一个结点,并称其为头节点。带头节点的单链表结构如下:

image

在引入头结点后,尾插法建立单链表的算法可简化为:

LinkList CreatListR1() {
    LinkList head = (ListNode *) malloc(sizeof(ListNode));
    ListNode *p, *r;
    r = head;
    DataType ch;
    while ((ch = getchar()) != '\n') {
        p = (ListNode *) malloc(sizeof(ListNode));
        p->data = ch;
        r->next = p;
        r = p;
    }
    r->next = NULL;
    return head;
}

2. 查找运算(带头节点)

在单链表中,任何两个结点的存储位置之间没有固定的联系,每个结点的存储位置,包含在其前趋的指针域中。因此,在单链表中存储第 i 个结点时,必须从表头结点开始搜索,所以链表结构是非随机存取的存储结构。若链表带头结点时,就应特别注意头结点和表头结点(即开始结点)的区别。

1. 按结点序号查找

在单链表中要查找第 i 个结点时,必须从链表的第 1 个结点(开始结点:序号为1)开始,序号为 0 的是头结点,p 指向当前结点,j 为计数器,其初始值为 1,当 p 扫描下一个结点时,计数器加1.当 i = j 时,指针 p 所指向的结点就是要找的结点。
算法如下:

ListNode *GetNodei(LinkList head, int i) {
    ListNode *p;
    int j;
    p = head->next;
    j = 1;
    while (p != NULL && j < i) {
        p = p->next;
        ++j;
    }
    if (j == i) {
        return p;
    }
    return NULL;
}
2. 按结点值查找

在单链表中按值查找结点,就是从链表的开始结点出发,顺链逐个将结点的值和给定值 k 进行比较,若遇到相等的值,则返回该结点的存储位置,否则返回NULL。

ListNode *LocateNodek(LinkList head, DataType k) {
    ListNode *p = head->next;
    while (p && p->data != k) {
        p = p->next;
    }
    return p;
}

3. 插入运算

从前面顺序表的插入运算可知,插入运算是将值为 x 的新结点插入到表的第 i 个结点的位置上,即插入到 \(a_{i-1}\)\(a_i\) 之间。然而,链表和顺序表的插入运算是不同的,顺序表在插入时要移动大量的结点,而链表在插入时不需要移动结点,但需要移动指针来进行位置查找。

其算法思想:先使 p 指向 \(a_{i-1}\) 的位置,然后生成一个数据域指为 x 的新节点 *s,再进行插入操作,插入过程如下:

image

具体算法如下:

void InsertList(LinkList head, int i, DataType x) {
    ListNode *p, *s;
    int j;
    p = head;
    j = 0;
    while (p && j < i - 1) {
        p = p->next;
        ++j;
    }
    if (p == NULL) {
        printf("ERROR\n");
        return;
    } else {
        s = (ListNode*) malloc(sizeof(ListNode));
        s->data = x;
        s->next = p->next;
        p->next = s;
    }
}

4. 删除运算

删除运算就是将链表的第 i 个结点从表中删去。由于第 i 个结点的存储地址是存储第 \(i-1\) 个结点的指针域 next 中,因此要先使 p 指向第 \(i-1\) 个结点,然后使得 p-> next 指向第 i+1 个结点,再将第 i 个结点释放掉。操作过程如下:

image

具体算法如下:

DataType DeleteList(LinkList head, int i) {
    ListNode *p, *s;
    DataType x;
    int j;
    p = head;
    j = 0;
    while (p && j < i - 1) {
        p = p->next;
        ++j;
    }
    if(p==NULL){
        printf("位置错误\n");
        exit(0);
    } else{
        s = p->next;
        p->next = s->next;
        x = s->data;
        free(s);
        return x;
    }
}

5. 单链表上运算举例

例1:将一个头结点指针为 a 的带头结点的单链表 A 分解称两个单链表 A 和 B,其中头结点分别为 a 和 b,使得 A 链表中含有链表 A 中序号奇数的元素,而 B 链表中含有原链表中序号为偶数的元素,并保持原来的相对顺序。

void split(LinkList a, LinkList b) {
    ListNode *p, *r, *s;
    p = a->next;
    r = a;
    s = b;
    while (p) {
        r->next = p;
        r = p;
        p = p->next;
        if (p) {
            s->next = p;
            s = p;
            p = p->next;
        }
    }
    r->next = s->next = NULL;
}

例2: 假设头指针为 La 和 Lb 的单链表(带头结点)分别为线性表 A 和 B 的存储结构,两个链表都是按结点数据值递增有序的。试写一个算法,将这两个单链表合并为一个有序链表 Lc。

LinkList MergerList(LinkList La, LinkList Lb) {
    ListNode *pa, *pb, *pc;
    LinkList Lc;
    pa = La->next;
    pb = Lb->next;
    Lc = pc = La;
    while (pa && pb) {
        if (pa->data <= pb->data) {
            pc->next = pa;
            pc = pa;
            pa = pa->next;
        } else {
            pc->next = pb;
            pc = pb;
            pb = pb->next;
        }
    }
    pc->next = pa!=NULL?pa:pb;
    free(Lb);
    return Lc;
}

2.3.3 循环链表

循环链表是链式存储结构的另一种形式。其特定是单链表的最后一个结点(终端结点)的指针域不为空,而是指向链表的头节点,使整个链表构成一个环。因此,从表中任一结点开始都可以访问表中其他结点。这种结构形式的链表称为单循环链表。类似的还可以有多重链的循环链表。

再单循环链表中,为了使空表和非空表的处理一致,需要设置头结点。非空的单循环链表如下:

image

循环链表的结点类型与单链表完全相同,在操作上也与单链表基本一致,差别仅在算法中循环的结束判断条件不再是 p 或者 p->next 是否为空,而是它们是否等于头部指针。

在用头指针表示的单循环链表中,查找任何结点都必须从开始结点查起,而在实际应用中,表的操作常常会在表尾进行,此时若用尾指针表示单循环链表,可使某些操作简化。仅设置尾指针的单循环链表如下:

image

例1:已知有一个结点数据域为整型,且按从大到小顺序排列的头结点指针为 L 的非空单循环链表,试写一个算法插入一个结点(其数据域为x)至循环链表的适当位置,使之保持链表的有序性。

void InsertCycleList(LinkList L, int x) {
    ListNode *s, *p, *q;
    s = (ListNode *) malloc(sizeof(ListNode));
    s->data = x;
    p = L;
    q = p->next;
    while (q->data > x && q != L) {
        p = p->next;
        q = p->next;
    }
    p->next = s;
    s->next = q;
}

2.3.4 双向链表

以上讨论的单链表和单循环链表的结点只设有一个其直接后继的指针域,因此,从某个结点触发只能顺指针相后访问其他接待你。若需要查找结点的直接前趋,则需要从头指针开始查找某结点的直接前趋结点。若希望从表中快速确定一个结点的直接前趋,只要在单链表的结点类型中增加一个指向其直接前趋的指针域 prior 即可。这样形成的链表中有两条不同方向的链,因此称之为双向链表。双向链表及其结点类型描述如下:

typedef struct dlnode {
    DataType data;
    struct dlnode *prior, *next;
} DLNode;
typedef DLNode *DLinkList;

同单链表类似,双向链表一般也由指针 head 唯一确定。为了某些操作运算的方便,双链表也增设一个头结点,若将尾结点和头结点链接起来也就构成循环链表。因此,我们所讨论的往往是这种双向循环链表,其结点结构、空双向链表和非空双向链表如下图:

image

在单链表的给定结点前插入一个新结点,必须使用一指针指向给定结点的直接前趋,由于单链表是单向的,因此需要从表头开始向后搜索链表才能找到已知结点的直接前趋。同理,在单链表上删除给定结点的运算也同样存在这样的问题。而双向链表既有向前链接的链,又有向后链接的链,所以在做链表上结点的插入和删除操作时就显得十分方便。

例如,在双向链表的给定结点前插入一界定的操作过程如下图:

image

实现算法如下:

void DLInsert(DLNode *p,DataType x){
    DLNode *s = (DLNode*) malloc(sizeof(DLNode));
    s->data = x;
    s->prior = p->prior;
    s->next = p->next;
    p->prior->next = s;
    p->prior =s;
}

因为不再需要查找指向删除结点的前趋结点的指针,所以在双向链表上删除 *p 的算法更简单,其删除操作过程如下图:

image

实现算法如下:

DataType DLDelete(DLNode *p){
    DataType  x;
    p->prior->next = p->next;
    p->next->prior = p->prior;
    x = p->data;
    free(p);
    return x;
}

注意:与单链表的插入和删除操作不同的是,在双向链表中进行结点插入和删除时,必须同时修改两个方向上的指针。

例:[单项循环链表改造为双向循环链表]
假设有一个头结点指针为 head 的循环双向链表,其结点类型包括三个域(字段):prior\data\next 其中,data 为数据域,next为指针域,指向其后继结点,prior 也为指针域,其值为空(NULL),因此该双向链表其实就是一个单循环链表。试写一算法,将其实现真正的栓修改那个循环链表。

void Trans(DLinkList head) {
    DLNode *p;
    p = head;
    while (p->next != head) {
        p->next->prior = p;
        p = p->next;
    }
    head->prior = p;
}

2.4 顺序表和链表的比较

线性表有两种存储结构:

  • 顺序存储结构(顺序表)
  • 链式存储结构(链表)

顺序表结构:可以随机存取表中存取,插入和删除操作时,需要移动大量元素。
链式存储结构:无法随机存取,解决了插入和删除需要移动大量元素的问题。

以上两种结构,各有特点,那么我们该如何选择呢?通常我们要根据实际问题进行决定。

1. 时间性能

如果在实际问题中,对线性表的操作时经常性的查找运算,顺序表形式存储优先。
若需要经常性的插入和删除元素,以链式存储结构优先。

2. 空间性能

对数据量大小能事先知道的应用,适合使用顺序存储结构。
对数据量变化较大的应用,以链式存储结构优先。

对于线性表结点的存储密度问题,也是选择存储结构的一个重要依据。所谓存储密度就是结点空间利用率:

\[存储密度 = (结点数据域所占空间) / (整个结点所占空间) \]

结点存储密度越大,存储空间的利用率就越高。
顺序表的存储密度是 1,链表结点的存储密度小于 1.


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM