王道视频-数据结构-笔记2:线性表

文章目录


0 笔记说明

来源于2020 王道考研 数据结构,博客内容是对自己笔记的书面整理,根据自身学习需要,我可能会增加必要内容。


1 线性表

1.1 线性表的定义

线性表是具有相同数据类型的n(n≥0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则其一般表示为L=(a1,a2,…,ai,ai+1,…,an)。

注意以下几点:

(1)线性表中每个数据元素所占空间大小相同;

(2)线性表中的元素是有限的,并且有前后次序;

(3)ai是线性表中的“第i个”元素,但是线性表中的元素下标从0开始;

(4)a1是表头元素,an是表尾元素;

(5)除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。

1.2 线性表的基本操作

介绍9种基本操作(创建、销毁、增删改查等),注意若参数前有取地址符号&,则代表的是需要对该参数做出修改:

(1)lnitList(&L):初始化表。构造一个空的线性表L,分配内存空间。

(2)DestroyList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间。

(3)Listlnsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e。

(4)ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。

(5)LocateElem(L,e):按值查找元素操作。在表L中查找元素e。

(6)GetElem(L,i):按位置查找元素操作。获取表L中第i个位置的元素的值。

(7)Length(L):求表长。返回线性表L的长度,即L中数据元素的个数。

(8)PrintList(L):输出线性表元素操作。按前后顺序输出线性表L的所有元素值。

(9)Empty(L):判空操作。若L为空表,则返回true,否则返回false。


2 顺序存储的线性表:顺序表

顺序表:用顺序存储的方式实现线性表。顺序存储指的是把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的逻辑顺序关系由存储单元的邻接关系来体现。

设线性表第一个元素的存放位置是Loc(L),每个元素大小为k,则有:
在这里插入图片描述
即第i个元素ai的存放位置为Loc(L)+(i-1)·k。

2.1 静态分配的顺序表

下面是C++代码实现顺序表的静态分配:

#define MaxSize 10 // 定义顺序表的最大长度
typedef struct{
	ElemType data[MaxSize]; // 使用静态数组存放数据元素
	int length; // 表示顺序表的当前长度
}SqList; // 静态分配的顺序表

注意,静态数组一旦分配,大小长度不可变,且会给各个数据元素分配连续的存储空间,静态数组总大小为MaxSize*sizeof(ElemType)。

下面是C++代码实现初始化一个顺序表:

void InitList(SqList &L){
	L.length=0; //初始化顺序表的长度为0
}

注意,初始化时,必须使L.length=0。

2.2 动态分配的顺序表

下面是C++代码实现顺序表的动态分配:

#define InitSize 10 // 定义顺序表的初始长度
typedef struct{
	ElemType *data; // data是指针变量,指向顺序表的起始地址,也就是第一个元素的位置
	int MaxSize; // 表示顺序表的最大容量
	int length; // 表示顺序表的当前长度
}SeqList; // 动态分配的顺序表

在动态分配的顺序表中,可以申请额外的内存空间,也可以释放内存空间,使用的C++函数分别是malloc、free函数。malloc函数返回一个指针,并且必须强制转换为原顺序表中定义的数据元素类型的指针,如下C++代码:

L.data = (ElemType *)malloc(sizeof(ElemType)*size)

在动态分配的顺序表中,当顺序表存满时,可使用malloc扩展顺序表的容量。在这个过程中,为了使所有数据元素存储位置连续,需要将数据元素复制到新的一整片存储区域,然后使用free释放原区域的内存空间。示例C++代码如下:

#include <stdlib.h> //malloc与free函数的头文件
#include <stdio.h>
#define InitSize 10 //线性表默认的最大长度

typedef struct{
    int *data; //data是指针变量,指向顺序表的起始地址,也就是第一个元素的位置
    int MaxSize; //顺序表的最大容量
    int length; //顺序表的当前长度
}SeqList;

void InitList(SeqList &L){
    //初始化顺序表,用malloc申请一片连续的存储空间
    L.data = (int *)malloc(InitSize*sizeof(int));
    L.length = 0;
    L.MaxSize = InitSize;
}

void IncreaseSize(SeqList &L, int len){
    //动态增加顺序表的长度
    int *p=L.data;
    L.data = (int *)malloc((L.MaxSize+len)*sizeof(int));
    for(int i=0; i<L.length; i++)
    L.data[i] = p[i]; //将原数据复制到新区域
    L.MaxSize = L.MaxSize + len; //更改顺序表的最大长度
    free(p); //释放原来的内存空间
}

int main(){
    SeqList L; //声明一个顺序表
    InitList(L); //初始化顺序表
    int i; // 计数变量
    for(i = 0;i<L.MaxSize;i++){
        L.data[i] = i;
        L.length++;
    }
    IncreaseSize(L,5); // 增加顺序表的长度
    i = 0; // 计数变量置为0
    while(i<L.length){
        printf("%d\n", L.data[i]);
        i++;
    }
    return 0;
}

上述C++代码输出如下:

0
1
2
3
4
5
6
7
8
9

2.3 顺序表的特点

顺序表的特点:

(1)随机,一般为读操作。只要知道该单元所在的行和列的地址即可直接访问任一个存储单元。即可以在O(1)时间内找到第i个元素(data[i-1]);

(2)存储密度高,每个结点只存储数据元素(后面讲到的链式存储还会存储额外的指针);

(3)拓展容量不方便,即便采用动态分配的方式实现,拓展长度的时间复杂度也比较高;

(4)插入、删除操作不方便,需要移动大量元素;

2.4 顺序表的基本操作

2.4.1 插入元素操作

本节代码建立在静态分配的顺序表上,而动态分配的顺序表的代码也基本与其一致,C++代码如下::

bool ListInsert(SqList &L, int i, ElemType e){
    if(i<1||i>L.length+1) //判断i的范围是否合适
        return false;
    if(L.length>=MaxSize) //若顺序表已存满,则不能插入新元素
        return false;
    for(int j=L.length;j>=i;j--)
        L.data[j]=L.data[j-1]; //将第i个元素及之后的元素后移
    L.data[i-1]=e;
    L.length++; //长度+1
    return true;
}

好的算法,应该具有“健壮性”。能处理异常情况,并给使用者反馈。在插入元素之前,需要判断输入数据是否合法并给予反馈。

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第9行代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:新元素插入到表尾,则不需要移动元素——即当i=n+1时,语句循环0次。最好时间复杂度=O(1);

(2)最坏情况:新元素插入到表头,需要将原有的n个元素全都向后移动——即i=1时,语句循环n次。最坏时间复杂度=O(n);

(3)平均情况:假设新元素插入到任何一个位置的概率相同,即i=1,2,3,…,n+1的概率都是p=1/(n+1)——当i=1时,循环n次;i=2时,循环n-1次:i=3时,循环n-2次…i=n+1时,循环0次。平均循环次数=np+(n-1)p+(n-2)p+…+p= n/2。平均时间复杂度=O(n/2)=O(n)。

2.4.2 删除元素操作

本节代码也是建立在静态分配的顺序表上,而动态分配的顺序表的代码也基本与其一致。C++代码如下:

bool ListDelete(SqList &L, int i, ElemType &e){
    if(i<1||i>L.length) //判断i的合法性
        return false;
    e=L.data[i-1]; //将删除的元素赋值给e
    for(int j=i;j<L.length;j++) //将第i个元素之后的数据前移
        L.data[j-1]=L.data[j];
    L.length--; //表长-1
    return true;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第7行代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:删除表尾元素,不需要移动其他元素——即i=n时,循环0次。最好时间复杂度=O(1);

(2)最坏情况:删除表头元素,需要将后续的n-1个元素全都向前移动——即i=1时,循环n-1次。最坏时间复杂度=O(n);

(3)平均情况:假设删除任何一个元素的概率相同,即i=1,2,3,…,n的概率都是p=1/n。i=1时,循环n-1次;i=2时,循环n-2次;i=3,循环n-3次….i=n时,循环0次。平均循环次数=(n-1)p+(n-2)p+…+p=(n-1)/2。平均时间复杂度=O((n-1)/2)=O(n)。

2.4.3 查找元素操作

2.4.3.1 按位置查找元素

静态分配和动态分配的顺序表的代码完全一致,如下:

ElemType GetElem(SqList L, int i){
    if(i>L.length || i<1)
        return -99; //约定返回-99代表获取元素失败
    return L.data[i-1];
}

由于顺序表的各个数据元素在内存中连续存放,因此可以根据起始地址和数据元素大小立即找到第i个元素,这就是顺序表的“随机存取”特性。所以上述代码的时间复杂度为O(1)。

2.4.3.2 按数值查找元素

静态分配和动态分配的顺序表的代码完全一致,如下:

int LocateElem(SqList L, ElemType e){
    for(int i=0;i<L.length;i++)
        if(L.data[i]==e)
            return i+1;
    return 0;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第3行的if判断代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:目标元素在表头,则需要比较元素1次,该语句循环1次。最好时间复杂度=O(1);

(2)最坏情况:目标元素在表尾,需要和n个元素进行比较,该语句循环n次。最坏时间复杂度=O(n);

(3)平均情况:假设目标元素在任何一个位置的概率都相同,即是第1,2,3,…,n个元素的概率都是p=1/n——当是第1个时,循环1次;当是第2个时,循环2次…当是第n个时,循环n次。平均循环次数=np+(n-1)p+(n-2)p+…+p= (n+1)/2。平均时间复杂度=O((n+1)/2)=O(n)。


3 链式存储的线性表:链表

在链表中,每个结点除了存放数据元素外,还要存储指向其他结点(如下一个,或者还包括上一个)的指针。链表包括单链表、双链表、循环链表、静态链表。

3.0 链表的特点

1、相比于顺序表,链表存储密度小 ,每个结点由数据域和指针域组成。在相同内存空间中,假设全存满的话,顺序表比链表存储的元素个数更多;

2、逻辑上相邻的结点物理上不必相邻;

3、插入、删除灵活,不必移动结点,只需要改变结点中的指针;

4、查找结点时比顺序表慢。

3.1 单链表

单链表的局限性——无法逆向检索,对于某些基本操作来说写代码不是很方便。

定义单链表的C++代码如下:

typedef struct LNode{ //定义单链表结点类型
    ElemType data; //数据域,存放元素的数据
    struct LNode *next; //指针域,存放指向下一个结点的指针
}LNode, *LinkList;

上面代码等价于如下代码:

struct LNode{ //定义单链表结点类型
    ElemType data; //数据域,存放元素的数据
    struct LNode *next; //指针域,存放指向下一个结点的指针
};
typedef struct LNode LNode;
typedef struct LNode *LinkList;

要声明一个单链表时,只需声明一个头指针L,指向单链表的第一个结点,有两种方式如下:

LNode *L; //声明一个指向单链表第一个结点的指针
LinkList L; //声明一个指向单链表第一个结点的指针,这行代码可读性更强

当强调是一个单链表时,使用LinkList;强调是一个结点时,使用LNode *;

3.1.1 不带头结点的单链表

初始化不带头结点的单链表的C++代码如下:

bool InitList(LinkList &L){
    L=NULL; //初始化为空表,没有任何结点
    return true;
}

判断不带头结点的单链表是否为空的C++代码如下:

bool Empty(LinkList L){ //判断单链表是否为空
    return L==NULL;
}

不带头结点,写代码更麻烦——对第一个数据结点和后续数据结点的处理需要用不同的代码逻辑,而且对空表和非空表的处理需要用不同的代码逻辑。

3.1.2 带头结点的单链表

初始化带头结点的单链表的C++代码如下:

bool InitList(LinkList &L){
    L = (LNode *)malloc(sizeof(LNode)); //创建头结点,不存储数据
    if(L==NULL) //内存不足,创建失败
        return false;
    L->next = NULL; //头结点后无结点
    return true;
}

判断带头结点的单链表是否为空的C++代码如下:

bool Empty(LinkList L){
    return L->next == NULL;
}

带头结点的话,写基本操作的代码时更为方便,继续往下阅读便可以看到这种差别。

3.1.3 单链表的基本操作

3.1.3.1 插入
3.1.3.1.1 按位序插入(带头结点)

C++代码如下:

bool ListInsert(LinkList &L, int i, ElemType e){
    if(i<1)
        return false; //位序i必须大于1
    LNode *p; //指针p指向当前扫描到的结点
    int j=0; //指针p指向的是第j个结点
    p = L; //开始时令p指向头结点,可视为第0个结点
    while(p!=NULL && j<i-1){ //寻找第i-1个结点
        p=p->next;
        j++;
    }
    if(p==NULL) //查找失败
        return false;
    LNode *s=(LNode *)malloc(sizeof(LNode));
    s->data=e;
    s->next=p->next;
    p->next=s; //将结点s插到p结点即第i-1个结点之后
    return true; //插入成功
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第8行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:插在表头,即i=1时,该语句循环0次。最好时间复杂度=O(1);

(2)最坏情况:插在表尾,即i=n+1时,该语句循环n次。最坏时间复杂度=O(n);

(3)平均情况:假设插在任何一个位置的概率都相同,即插在第1,2,3,…,n,n+1个位置的概率都是p=1/(n+1)——当是第1个时,循环0次;当是第2个时,循环1次…当是第n个时,循环n-1次,当是第n+1个时,循环n次。平均循环次数=np+(n-1)p+(n-2)p+…+p=n/2。平均时间复杂度=O(n/2)=O(n)。

3.1.3.1.2 按位序插入(不带头结点)

因为不存在“第0个”结点,因此i=1时需要特殊处理。C++代码如下:

bool ListInsert(LinkList &L, int i, ElemType e){
    if(i<1)
        return false;
    if(i==1){ //第一个结点需要特殊处理
        LNode *s=(LNode *)malloc(sizeof(LNode));
        s->data=e;
        s->next=L;
        L=s; //头指针L指向新的结点
        return true;
    }
    LNode *p; //指针p指向当前扫描到的结点
    int j=1; //指针p指向的是第j个结点
    p=L; //开始时令p指向第1个结点,不是头结点哦
    while(p!=NULL && j<i-1){ //找到第i-1个结点
        p=p->next;
        j++;
    }
    if(p==NULL) //查找失败
        return false;
    LNode *s=(LNode *)malloc(sizeof(LNode));
    s->data=e;
    s->next=p->next;
    p->next=s;
    return true;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第15行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:插在表头,即i=1时,该语句循环0次。最好时间复杂度=O(1);

(2)最坏情况:插在表尾,即i=n+1时,该语句循环n次。最坏时间复杂度=O(n);

(3)平均情况:假设插在任何一个位置的概率都相同,即插在第1,2,3,…,n,n+1个位置的概率都是p=1/(n+1)——当是第1个时,循环0次;当是第2个时,循环1次…当是第n个时,循环n-1次,当是第n+1个时,循环n次。平均循环次数=np+(n-1)p+(n-2)p+…+p=n/2。平均时间复杂度=O(n/2)=O(n)。

3.1.3.1.3 指定结点的后插(有无头结点均适用)

C++代码如下:

bool InsertNextNode(LNode *p, ElemType e){ //在p结点之后插入元素e
    if(p==NULL)
        return false;
    LNode *s= (LNode *)malloc(sizeof(LNode));
    if(s==NULL)
        return false; //内存分配失败
    s->data=e;
    s->next=p->next;
    p->next=s;
    return true;
}

上述代码对有无头结点的单链表均适用,时间复杂度=O(1)。

有了向指定结点的后插的函数,则可以简化【3.1.3.1.1 按位序插入(带头结点)】一节的代码,C++代码如下:

bool ListInsert(LinkList &L, int i, ElemType e){
    if(i<1)
        return false; //位序i必须大于1
    LNode *p; //指针p指向当前扫描到的结点
    int j=0; //指针p指向的是第j个结点
    p = L; //开始时令p指向头结点,可视为第0个结点
    while(p!=NULL && j<i-1){ //寻找第i-1个结点
        p=p->next;
        j++;
    }
    return InsertNextNode(p,e);
}

同样地,【3.1.3.1.2 按位序插入(不带头结点)】一节的代码也可以简化,C++代码如下:

bool ListInsert(LinkList &L, int i, ElemType e){
    if(i<1)
        return false;
    if(i==1){ //第一个结点需要特殊处理
        LNode *s=(LNode *)malloc(sizeof(LNode));
        s->data=e;
        s->next=L;
        L=s; //头指针L指向新的结点
        return true;
    }
    LNode *p; //指针p指向当前扫描到的结点
    int j=1; //指针p指向的是第j个结点
    p=L; //开始时令p指向第1个结点,不是头结点哦
    while(p!=NULL && j<i-1){ //找到第i-1个结点
        p=p->next;
        j++;
    }
    return InsertNextNode(p,e);
}

将某一特定功能写为一个函数,需要时候调用即可,可以减少主程序的代码量,还可以使阅读代码的同学更多关注于程序逻辑而非代码的具体实现。

3.1.3.1.4 指定结点的前插(有无头结点均适用)

在结点p之前插入元素e,C++代码如下:

bool InsertPriorNode(LNode *p, ElemType e){ //在结点p之前插入元素e
    if(p==NULL)
        return false;
    LNode *s=(LNode *)malloc(sizeof(LNode));
    if(s==NULL)
        return false;
    s->next=p->next;
    p->next=s; //新结点s连到p结点之后
    s->data=p->data; //将p中的数据复制到结点s的数据域中
    p->data=e; //将数据e赋值给p结点的数据域
    return false;
}

上述代码对有无头结点的单链表均适用。但是:① 如果传入的参数p为头指针L时,对于有头结点的单链表会使头结点的数据域变为e,而头结点之后的第一个结点(这个结点是刚刚生成的结点s)的数据域可能变成被某脏数据;② 对于无头结点的单链表则不会发生任何意外情况。时间复杂度=O(1)。

在结点p之前插入结点s,C++代码如下:

bool InsertPriorNode(LNode *p, LNode *s){ //在结点p之前插入结点s
    if(p==NULL || s==NULL)
        return false;
    s->next=p->next;
    p->next=s; //将结点s连到结点p之后
    ElemType temp=p->data; //交换数据域
    p->data=s->data;
    s->data=temp;
    return true;
}

上述代码对有无头结点的单链表均适用。但是:① 如果传入的参数p为头指针L时,对于有头结点的单链表会使头结点的数据域变为结点s中的数据,而头结点之后的第一个结点(即结点s)的数据域可能变成被某脏数据;② 对于无头结点的单链表则不会发生任何意外情况。时间复杂度=O(1)。

3.1.3.2 删除
3.1.3.2.1 按位序删除(带头结点)

头结点可以看作第0个结点,找到第i-1个结点,将其指针指向第i+1个结点,井释放第i个结点。C++代码如下:

bool ListDelete(LinkList &L, int i, ElemType &e){
    if(i<1)
        return false;
    LNode *p; //指针p指向当前扫描到的结点
    int j=0; //指针p指向的是第j个结点
    p=L; //开始时令p指向头结点,可视为第0个结点
    while(p!=NULL && j<i-1){ //寻找第i-1个结点
        p=p->next;
        j++;
    }
    if(p==NULL)
        return false;
    if(p->next==NULL) //第i-1个结点之后已无其他结点
        return false;
    LNode *q=p->next; //使q指向被删除结点即第i个结点
    e=q->data; //用e返回第i个结点的数据
    p->next=q->next; //将结点p从链中断开
    free(q); //释放结点q,即第i个结点
    return true;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第7行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:即i=1时,该语句循环0次。最好时间复杂度=O(1);

(2)最坏情况:即i=n时,该语句循环n-1次。最坏时间复杂度=O(n-1)=O(n);

(3)平均情况:假设任何一个位置的概率都相同,即i=1,2,3,…,n的概率都是p=1/n——当是第1个时,循环0次;当是第2个时,循环1次…当是第n个时,循环n-1次。平均循环次数=(n-1)p+(n-2)p+…+p=(n-1)/2。平均时间复杂度=O((n-1)/2)=O(n)。

3.1.3.2.2 按位序删除(不带头结点)

i=1,即删除第一个结点时需要特殊对待,C++代码如下:

bool ListDelete(LinkList &L, int i, ElemType &e){
    if(i<1)
        return false;
    LNode *p; //指针p指向当前扫描到的结点
    int j=1; //指针p指向的是第j个结点
    p=L; //开始时令p指向头结点,可视为第0个结点
    if(i==1){ //第一个结点特殊对待
        L=L->next;
        e=p->data;
        free(p);
        return true;
    }
    while(p!=NULL && j<i-1){ //寻找第i-1个结点
        p=p->next;
        j++;
    }
    if(p==NULL)
        return false;
    if(p->next==NULL) //第i-1个结点之后已无其他结点
        return false;
    LNode *q=p->next; //使q指向被删除结点即第i个结点
    e=q->data; //用e返回第i个结点的数据
    p->next=q->next; //将结点p从链中断开
    free(q); //释放结点q,即第i个结点
    return true;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第13行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:即i=1时,该语句循环0次。最好时间复杂度=O(1);

(2)最坏情况:即i=n时,该语句循环n-1次。最坏时间复杂度=O(n-1)=O(n);

(3)平均情况:假设任何一个位置的概率都相同,即i=1,2,3,…,n的概率都是p=1/n——当是第1个时,循环0次;当是第2个时,循环1次…当是第n个时,循环n-1次。平均循环次数=(n-1)p+(n-2)p+…+p=(n-1)/2。平均时间复杂度=O((n-1)/2)=O(n)。

3.1.3.2.3 指定结点的删除(带头结点)

删除头结点(即传入的参数p为头指针L时)和最后一个结点(即p->next=NULL时)需要特殊对待,C++代码如下:

bool DeleteNode(LinkList &L, LNode *p){ //删除指定结点p
    if(p==NULL || p==L) //p为空或者要删除头结点都会报错
        return false;
    if(p->next==NULL){ //删除的是最后一个结点需要特殊对待
        LNode *q=L;
        while(q->next!=p)
            q=q->next;
        q->next=NULL;
        free(p);
        return true;
    }
    LNode *q=p->next; //令q指向p的后继结点
    p->data=q->data; //结点p的数据域赋值为其后继结点的数据
    p->next=q->next; //将q结点从链中断开
    free(q);
    return true;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第7行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:即p指向除最后一个结点外的任意其他结点时,该语句均循环0次。最好时间复杂度=O(1);

(2)最坏情况:即p为最后一个结点,即p->next=NULL时,该语句循环n-1次。最坏时间复杂度=O(n-1)=O(n);

(3)平均情况:假设p指向任意一个结点的概率都相同,即指向头结点、第1,2,3,…,n个结点的概率都是p=1/(n+1)——当是头结点时,循环0次;第1个结点时,循环0次;第2个结点时,循环0次…当是第n-2个结点时,循环0次;当是第n-1个时,循环0次;当是第n个,即最后一个结点时,循环n-1次。平均循环次数=(n-1)p=(n-1)/(n+1)。平均时间复杂度=O((n-1)/(n+1))=O(1)。

3.1.3.2.4 指定结点的删除(不带头结点)

删除第一个结点(即传入的参数p为头指针L时)不需要特殊对待,删除最后一个结点(即p->next=NULL时)需要特殊对待,C++代码如下:

bool DeleteNode(LinkList &L, LNode *p){ //删除指定结点p
    if(p==NULL) //p为空
        return false;
    if(p->next==NULL){ //删除的是最后一个结点需要特殊对待
        LNode *q=L;
        while(q->next!=p)
            q=q->next;
        q->next=NULL;
        free(p);
        return true;
    }
    LNode *q=p->next; //令q指向p的后继结点
    p->data=q->data; //结点p的数据域赋值为其后继结点的数据
    p->next=q->next; //将q结点从链中断开
    free(q);
    return true;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第7行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:即p指向除最后一个结点外的任意其他结点时,该语句均循环0次。最好时间复杂度=O(1);

(2)最坏情况:即p为最后一个结点,即p->next=NULL时,该语句循环n-1次。最坏时间复杂度=O(n-1)=O(n);

(3)平均情况:假设p指向任意一个结点的概率都相同,即指向第1,2,3,…,n个结点的概率都是p=1/n——当是第1个结点时,循环0次;第2个结点时,循环0次…当是第n-1个时,循环0次;当是第n个,即最后一个结点时,循环n-1次。平均循环次数=(n-1)p=(n-1)/n。平均时间复杂度=O((n-1)/n)=O(1)。

3.1.3.3 查找
3.1.3.3.1 按位查找(带头结点)

C++代码如下:

LNode * GetElem(LinkList L,int i){ //返回第i个结点
    if(i<0)
        return NULL; //i不合适
    LNode *p; //指针p指向当前扫描到的结点
    int j=0; //指针p指向的是第j个结点
    p=L; //开始时令p指向头结点
    while(p!=NULL && j<i){ //寻找第i个结点
        p=p->next;
        j++;
    }
    return p;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第8行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:即i=0,即查找头结点时,该语句循环0次。最好时间复杂度=O(1);

(2)最坏情况:即i=n时,该语句循环n次。最坏时间复杂度=O(n);

(3)平均情况:假设任何一个位置的概率都相同,即i=0,1,2,3,…,n的概率都是p=1/(n+1)——当i=0时,循环0次;当i=1时,循环1次…当i=n时,循环n次。平均循环次数=np+(n-1)p+(n-2)p+…+p=n/2。平均时间复杂度=O(n/2)=O(n)。

3.1.3.3.2 按位查找(不带头结点)

C++代码如下:

LNode * GetElem(LinkList L,int i){ //返回第i个结点
    if(i<=0)
        return NULL; //i不合适
    LNode *p; //指针p指向当前扫描到的结点
    int j=1; //指针p指向的是第j个结点
    p=L; //开始时令p指向第一个结点
    while(p!=NULL && j<i){ //寻找第i个结点
        p=p->next;
        j++;
    }
    return p;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第8行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:即i=1时,该语句循环0次。最好时间复杂度=O(1);

(2)最坏情况:即i=n时,该语句循环n-1次。最坏时间复杂度=O(n-1)=O(n);

(3)平均情况:假设任何一个位置的概率都相同,即i=1,2,3,…,n的概率都是p=1/n——当i=1时,循环0次…当i=n时,循环n-1次。平均循环次数=(n-1)p+(n-2)p+…+p=(n-1)/2。平均时间复杂度=O((n-1)/2)=O(n)。

3.1.3.3.3 按值查找(带头结点)

C++代码如下:

LNode * LocateElem(LinkList L, int e){ //按值查找,返回数据域为e的结点
    LNode *p=L->next; //从第一个结点开始查找
    while(p!=NULL && p->data!=e)
        p=p->next;
    return p;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第4行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:在第一个结点时,该语句循环0次。最好时间复杂度=O(1);

(2)最坏情况:在最后一个结点时,该语句循环n-1次。最坏时间复杂度=O(n-1)=O(n);

(3)平均情况:假设在任意一个结点的概率都相同,即在第1,2,3,…,n个结点的概率都是p=1/n——当是第1个结点时,循环0次;第2个结点时,循环1次…当是第n-1个时,循环n-2次;当是第n个,即最后一个结点时,循环n-1次。平均循环次数=(n-1)p+(n-2)p+…+p=(n-1)/2。平均时间复杂度=O((n-1)/2)=O(n)。

3.1.3.3.4 按值查找(不带头结点)

C++代码如下:

LNode * LocateElem(LinkList L, int e){ //按值查找,返回数据域为e的结点
    LNode *p=L; //从第一个结点开始查找
    while(p!=NULL && p->data!=e)
        p=p->next;
    return p;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第4行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系:

(1)最好情况:在第一个结点时,该语句循环0次。最好时间复杂度=O(1);

(2)最坏情况:在最后一个结点时,该语句循环n-1次。最坏时间复杂度=O(n-1)=O(n);

(3)平均情况:假设在任意一个结点的概率都相同,即在第1,2,3,…,n个结点的概率都是p=1/n——当是第1个结点时,循环0次;第2个结点时,循环1次…当是第n-1个时,循环n-2次;当是第n个,即最后一个结点时,循环n-1次。平均循环次数=(n-1)p+(n-2)p+…+p=(n-1)/2。平均时间复杂度=O((n-1)/2)=O(n)。

3.1.3.4 求表长
3.1.3.4.1 求表长(带头结点)

C++代码如下:

int Length(LinkList L){ //求表长
    int len=0;
    LNode *p=L->next;
    while(p!=NULL){
        p=p->next;
        len++;
    }
    return len;
}

时间复杂度为O(n)。

3.1.3.4.2 求表长(不带头结点)

C++代码如下:

int Length(LinkList L){ //求表长
    int len=0;
    LNode *p=L;
    while(p!=NULL){
        p=p->next;
        len++;
    }
    return len;
}

时间复杂度为O(n)。

3.1.3.5 建立单链表
3.1.3.5.1 尾插法
3.1.3.5.1.1 尾插法(带头结点)

C++代码如下:

LinkList List_TailInsert(LinkList &L){ //每次在表尾插入元素,即正向建立单链表
    L=(LinkList)malloc(sizeof(LNode)); //头指针指向头结点
    LNode *s,*r=L; //r指向最后一个结点,s指向新生成的结点
    int x; //假设输入的数据是int型
    scanf("%d",&x); //需要手动输入各个结点的值
    while(x!=-1){ //约定输入-1时,不再继续建立单链表,因此建好的单链表里的结点的值一定不会出现-1
        s=(LNode *)malloc(sizeof(LNode));
        s->data=x;
        r->next=s;
        r=s; //r总是指向最后一个结点
        scanf("%d",&x);
    }
    r->next=NULL; //尾结点无后继
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第7行的代码)的执行次数与问题规模n的关系,如果建立n个结点的单链表,则该语句循环n次,即时间复杂度为O(n)。

3.1.3.5.1.2 尾插法(不带头结点)

C++代码如下:

LinkList List_TailInsert(LinkList &L){ //每次在表尾插入元素,即正向建立单链表
    L=NULL;
    LNode *s,*r=L; //r指向最后一个结点,s指向新生成的结点
    int x; //假设输入的数据是int类型
    scanf("%d",&x); //需要手动输入各个结点的值
    int i=0; //计数变量,统计输入数据的个数
    while(x!=-1){ //约定输入-1时,不再继续建立单链表,因此建好的单链表里的结点的值一定不会出现-1
        i++; //输入数据的个数+1
        s=(LNode *)malloc(sizeof(LNode));
        s->data=x;
        if(i==1) //生成第一个结点需要特殊对待
            L=s;
        else
            r->next=s;
        r=s; //r总是指向最后一个结点
        scanf("%d",&x);
    }
    if(i!=0) //若i=0,即开始时就输入-1,代表未输入有效数据,此时r=L=NULL,不可能执行下一行代码
        r->next=NULL; //尾结点无后继
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第8行的代码)的执行次数与问题规模n的关系,如果建立n个结点的单链表,则该语句循环n次,即时间复杂度为O(n)。

3.1.3.5.2 头插法
3.1.3.5.2.1 头插法(带头结点)

C++代码如下:

LinkList List_HeadInsert(LinkList &L){ //每次在头节点后插入元素,即逆向建立单链表
    LNode *s;
    L=(LinkList)malloc(sizeof(LNode)); //创建头结点
    L->next=NULL; //初始化为空表
    int x; //假设输入的数据是int类型
    scanf("%d",&x);
    while(x!=-1){
        s=(LNode *)malloc(sizeof(LNode)); //创建新结点
        s->data=x;
        s->next=L->next;
        L->next=s; //将新节点s插入单链表中
        scanf("%d",&x);
    }
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第8行的代码)的执行次数与问题规模n的关系,如果建立n个结点的单链表,则该语句循环n次,即时间复杂度为O(n)。

3.1.3.5.2.2 头插法(不带头结点)

C++代码如下:

LinkList List_HeadInsert(LinkList &L){ //每次在第一个节点前插入元素,即逆向建立单链表
    LNode *s;
    L=NULL; //初始化为空表
    int x; //假设输入的数据是int类型
    scanf("%d",&x);
    while(x!=-1){
        s=(LNode *)malloc(sizeof(LNode)); //创建新结点
        s->data=x;
        s->next=L;
        L=s; //将新节点s插入单链表中
        scanf("%d",&x);
    }
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第7行的代码)的执行次数与问题规模n的关系,如果建立n个结点的单链表,则该语句循环n次,即时间复杂度为O(n)。

3.1.3.6 单链表逆置
3.1.3.6.1 通过新建一个单链表
3.1.3.6.1.1 通过新建一个单链表(带头结点)

C++代码如下:

LinkList ReverseLinkList(LinkList &L){ //通过建立新链表将原链表的数据逆置
    LinkList L1;
    L1=(LNode *)malloc(sizeof(LNode)); //新链表的头结点
    L1->next=NULL;
    LNode *p=L->next; //开始时p指向旧表的第一个结点
    while(p!=NULL){
        LNode *s=(LNode *)malloc(sizeof(LNode)); //创建新结点
        s->data=p->data;
        s->next=L1->next;
        L1->next=s; //将新节点s插到新表的头结点后
        p=p->next; //扫描旧表的结点
    }
    free(L);
    L=L1;
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第7行的代码)的执行次数与问题规模n的关系,如果原链表是有n个结点的单链表,则该语句循环n次,即时间复杂度为O(n)。

3.1.3.6.1.2 通过新建一个单链表(不带头结点)

C++代码如下:

LinkList ReverseLinkList(LinkList &L){ //通过建立新链表将原链表的数据逆置
    LinkList L1;
    L1=NULL;
    LNode *p=L; //开始时p指向旧表的第一个结点
    while(p!=NULL){
        LNode *s=(LNode *)malloc(sizeof(LNode)); //创建新结点
        s->data=p->data;
        s->next=L1;
        L1=s; //将新节点s插到新表的第一个结点前
        p=p->next; //扫描旧表的结点
    }
    free(L);
    L=L1;
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第6行的代码)的执行次数与问题规模n的关系,如果原链表是有n个结点的单链表,则该语句循环n次,即时间复杂度为O(n)。

3.1.3.6.2 我的方法
3.1.3.6.2.1 我的方法(带头结点)

C++代码如下:

LinkList ReverseLinkList(LinkList &L){
    if(L->next==NULL || L->next->next==NULL) //原链表只有头结点或者只有一个结点时,不需要逆置,直接将其返回
        return L;
    LNode *s,*r=L->next,*p=L->next->next;// s指向新结点,r指向原链表的第一个结点(即最终单链表的最后一个结点),p指向当前扫描到的结点,开始为原链表的第二个结点
    while(p!=NULL){
        s=(LNode *)malloc(sizeof(LNode));
        s->data=p->data;
        s->next=L->next;
        L->next=s;
        p=p->next;
    }
    r->next=NULL; //令表尾结点的next指针为NULL
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第6行的代码)的执行次数与问题规模n的关系,如果原链表是有n个结点的单链表,则该语句循环n-1次,即时间复杂度为O(n-1)=O(n)。

3.1.3.6.2.2 我的方法(不带头结点)

C++代码如下:

LinkList ReverseLinkList(LinkList &L){
    if(L==NULL || L->next==NULL) //原链表为空表或者只有一个结点时,不需要逆置,直接将其返回
        return L;
    LNode *s,*r=L,*p=L->next;// s指向新结点,r指向原链表的第一个结点(即最终单链表的最后一个结点),p指向当前扫描到的结点,开始为原链表的第二个结点
    while(p!=NULL){
        s=(LNode *)malloc(sizeof(LNode));
        s->data=p->data;
        s->next=L;
        L=s;
        p=p->next;
    }
    r->next=NULL; //令表尾结点的next指针为NULL
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第6行的代码)的执行次数与问题规模n的关系,如果原链表是有n个结点的单链表,则该语句循环n-1次,即时间复杂度为O(n-1)=O(n)。

3.1.3.6.3 就地逆置
3.1.3.6.3.1 就地逆置(带头结点)

C++代码如下:

LinkList ReverseLinkList(LinkList &L){ //不建立新链表,直接将原单链表逆置
    LNode *r,*p=L->next;// r为p的后继,p为工作指针
    L->next=NULL;
    while(p!=NULL){
        r=p->next;
        p->next=L->next;
        L->next=p;
        p=r;
    }
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第5行的代码)的执行次数与问题规模n的关系,如果原链表是有n个结点的单链表,则该语句循环n次,即时间复杂度为O(n)。

3.1.3.6.3.2 就地逆置(不带头结点)

C++代码如下:

LinkList ReverseLinkList(LinkList &L){ //不建立新链表,直接将原单链表逆置
    LNode *r,*p=L;// r为p的后继,p为工作指针
    L=NULL;
    while(p!=NULL){
        r=p->next;
        p->next=L;
        L=p;
        p=r;
    }
    return L;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第5行的代码)的执行次数与问题规模n的关系,如果原链表是有n个结点的单链表,则该语句循环n次,即时间复杂度为O(n)。

3.2 双链表

本博文提到的双链表均带头结点。

双链表的每个结点有两个指针域,分别指向前一个结点和后一个结点。对于单链表,无法逆向检索,有时候不太方便,但是对于双链表,可进可退,与此同时导致双链表的存储密度更低。

定义双链表结点的C++代码如下:

typedef struct DNode{ //定义双链表的结点类型
    ElemType data; //数据域
    DNode *prior,*next; //前驱指针与后继指针
}DNode,*DLinkList;

3.2.1 双链表的初始化

初始化双链表的C++代码如下:

bool InitDLinkList(DLinkList &L){
    L=(DNode *)malloc(sizeof(DNode)); //分配头结点
    if(L==NULL)
        return false; //内存不足分配失败
    L->prior=NULL; //头结点的prior永远指向NULL
    L->next=NULL; //头结点之后暂无结点
    return true;
}

3.2.2 双链表的判空

判断双链表是否为空的C++代码如下:

bool Empty(DLinkList L){
    if(L->next==NULL)
        return true;
    else
        return false;
}

3.2.3 双链表的插入操作

C++代码如下:

bool InsertNextDNode(DNode *p, DNode *s){ //在结点p之后插入结点s
    if(p==NULL || s==NULL)
        return false;
    s->next=p->next;
    if(p->next!=NULL)
        p->next->prior=s;
    s->prior=p;
    p->next=s;
    return true;
}

时间复杂度为O(1)。

3.2.4 双链表的删除操作

C++代码如下:

bool DeleteNextDNode(DNode *p){ //删除p的后继结点
    if(p==NULL)
        return false;
    DNode *q=p->next; //p的后继结点为q
    if(q==NULL)
        return false; //p无后继结点
    p->next=q->next;
    if(q->next!=NULL) //q结点不是最后一个结点
        q->next->prior=p;
    free(q);
    return true;    
}

时间复杂度为O(1)。

销毁双链表的C++代码如下:

void DestroyList(DLinkList &L){
    while(L->next!=NULL)
        DeleteNextDNode(L); //循环释放每个结点的内存空间
    free(L); //释放头结点
    L=NULL;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第3行的代码)的执行次数与问题规模n的关系,如果原链表是有n个结点的双链表,则该语句循环n次,即时间复杂度为O(n)。

3.3 循环链表

3.3.1 循环单链表

本博文提到的循环单链表均带头结点。

在单链表中,表尾结点的next指针指向 NULL;而在循环单链表中,表尾结点的next指针指向头结点。

下面是定义单链表结点类型的C++代码:

typedef struct LNode{ //定义单链表结点类型
    ElemType data; //数据域
    LNode *next; //指针域
}LNode, *LinkList;

初始化循环单链表的C++代码如下:

bool InitList(LinkList &L){
    L=(LNode *)malloc(sizeof(LNode)); //头结点
    if(L==NULL)
        return false; //内存分配失败
    L->next=L; //头结点的next指针指向自己,也就是指向头结点
    return true;
}

循环单链表判空的C++代码如下:

bool Empty(LinkList L){ //判断循环单链表是否为空
    if(L->next==L)
        return true;
    else
        return false;
}

判断节点p是否为循环单链表的表尾结点的C++代码如下:

bool isTail(LinkList L,LNode *p){ // 判断节点p是否为循环单链表的表尾结点
    if(p->next==L)
        return true;
    else
        return false;
}

对于普通的单链表,从一个结点出发只能找到后续的各个结点;而对于循环单链表,从任意一个结点出发都可以找到其他的所有结点。

3.3.2 循环双链表

本博文提到的循环双链表均带头结点。

在双链表中,表头结点的prior指向NULL,表尾结点的next指向NULL;而在循环双链表中,表头结点的prior指向表尾结点,表尾结点的next指向头结点。

下面是定义双链表结点类型的C++代码:

typedef struct DNode{ //定义双链表的结点类型
    ElemType data; //数据域
    DNode *prior,*next; //前驱指针与后继指针
}DNode,*DLinkList;

初始化循环双链表的C++代码如下:

bool InitDLinkList(DLinkList &L){ //初始化循环双链表
    L=(DNode *)malloc(sizeof(DNode)); //头结点
    if(L==NULL)
        return false; //内存分配失败
    L->prior=L; //头结点的prior指向头结点
    L->next=L; //头结点的next指向头结点
    return true;
}

循环双链表判空的C++代码如下:

bool Empty(DLinkList L){
    if(L->next==L || L->prior==NULL) //判断循环双链表是否为空
        return true;
    else
        return false;
}

判断节点p是否为循环双链表的表尾结点的C++代码如下:

bool isTail(DLinkList L,DNode *p){ //判断节点p是否为循环双链表的表尾结点
    if(p->next==L)
        return true;
    else
        return false;
}

在结点p之后插入结点s的C++代码如下:

bool InsertNextDNode(DNode *p, DNode *s){ //在结点p之后插入结点s
    if(p==NULL || s==NULL)
        return false;
    s->next=p->next;
    p->next->prior=s;
    s->prior=p;
    p->next=s;
    return true;
}

删除p的后继结点的C++代码如下:

bool DeleteNextDNode(DNode *p){ //删除p的后继结点
    if(p==NULL)
        return false;
    DNode *q=p->next; //p的后继结点为q
    if(q==L)
        return false; //p无后继结点
    p->next=q->next;
    q->next->prior=p;
    free(q);
    return true;    
}

3.4 静态链表

静态链表是用数组的方式实现的链表。在静态链表中,指针变成了游标,即下个结点的数组下标;下标为-1表示为表尾元素;0号结点充当头结点。优点:增、删操作不需要大量移动元素。缺点:不能随机访问,只能从头结点开始依次往后查找。

在静态链表中,计算机会分配一整片连续的内存空间,各个结点集中安置。下图为静态链表的一个示例:
在这里插入图片描述
定义静态链表结点的C++代码如下:

#define MaxSize 10 //静态链表的最大长度

typedef struct{
    ElemType data; //数据域
    int next; //下一个元素的数组下标,为int类型
}SLinkList[MaxSize];

SLinkList a; //本行代码不属于定义静态链表结点,只是说明声明一个静态链表的方式,这里定义了一个长度为MaxSize的Node型数组

上述定义静态链表结点的代码与下面的C++代码等价:

#define MaxSize 10 //静态链表的最大长度
struct Node{
    ElemType data; //数据域
    int next; //下一个元素的数组下标,为int类型
};
typedef struct Node SLinkList[MaxSize];

struct Node a[MaxSize]; //本行代码不属于定义静态链表结点,只是说明声明一个静态链表的方式,这里a是一个长度为MaxSize的Node型数组
SLinkList a; //本行代码不属于定义静态链表结点,只是说明声明一个静态链表的方式,这里定义了一个长度为MaxSize的Node型数组a

初始化静态链表的C++代码如下:

bool InitSLinkList(SLinkList &L){
    L[0].next=-1;
    for(int i=1;i<MaxSize;i++){
    	L[i].data=0; //头结点后的其他结点的数据域初始化为0
    	L[i].next=-2; //头结点后的其他结点的指针域初始化为-2,代表空结点
    }
    return true;
}

判断静态链表是否为空的C++代码如下:

bool Empty(SLinkList L){ //判断静态链表是否为空
    if(L[0].next==-1)
        return true;
    else
        return false;
}

3.4.1 删除操作

3.4.1.1 删除序号为i的结点

删除序号为i的结点,即静态数组的第i个结点,与表示的线性表中结点的先后顺序无关。

bool DeleteDNode(SLinkList &L,int i){ //删除序号为i的结点,这个i值与静态数组有关,与表示的线性表中结点的先后顺序无关
    if(i<=0||i>MaxSize-1) //i必须在区间[1,MaxSize-1]中
        return false;
    if(L[0].next==-1||L[i].next==-2) //若表本身为空表或者第i个结点为空结点,则删除失败
        return false;
    for(int j=0;j<MaxSize;j++)
        if(L[j].next==i){ //寻找序号为i的结点的前驱结点,即next=i的结点
            L[j].next=L[i].next;
            L[i].next=-2; //设置为空结点
            L[i].data=0; //数据域设置为0
            break;
        }
    return true;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第8行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系。直接看平均情况:假设【序号为i的结点的前驱】在任意一个结点的概率都相同,即在头结点、第1,2,3,…,n个结点的概率都是p=1/(n+1)——当是头结点时,循环1次;当是第1个结点时,循环2次;第2个结点时,循环3次…当是第n-1个时,循环n次;当是第n个,即最后一个结点时,循环n+1次。平均循环次数=(n+1)p+np+(n-1)p+(n-2)p+…+p=(n+2)/2。平均时间复杂度=O((n+2)/2)=O(n)。

3.4.1.2 删除值为e的结点

C++代码如下:

bool DeleteDNode(SLinkList &L,int e){ //删除值为e的结点
    int k,j=0; //游标
    while(L[j].next!=-1){
        k=L[j].next;
        if(L[k].data==e){
            L[j].next=L[k].next;
            L[k].next=-2; //设置为空结点
            L[k].data=0; //数据域设置为0
            return true;
        }
        j=k;
    }
    return false;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第6行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系。直接看平均情况:假设在任意一个结点的概率都相同,即第1,2,3,…,n个结点的数据域为e概率都是p=1/n——当第1个结点的数据域为e时,循环0次;第2个结点的数据域为e时,循环1次…当第n-1个结点的数据域为e时,循环n-2次;当第n个结点的数据域为e,即最后一个结点时,循环n-1次。平均循环次数=(n-1)p+(n-2)p+…+p=(n-1)/2。平均时间复杂度=O((n-1)/2)=O(n)。

3.4.1.3 删除第i个结点

与【3.4.1.1 删除序号为i的结点】一节不同,本节删除的是线性表的第i个结点,这个i值与表示的线性表中结点的先后顺序有关。C++代码如下:

bool DeleteDNode(SLinkList &L,int i){ //删除线性表的第i个结点
    if(i<=0||i>MaxSize-1||L[0].next==-1) //i在区间[1,MaxSize-1]中;若为空表则失败
        return false;
    int k=0; //游标
    int j=0; //计数变量
    while(j+1!=i){
        j++;
        k=L[k].next;
        if(L[k].next==-1)
            return false; //找到表尾结点之前,如果还没找到第i-1个结点就失败了
    }
    int m=L[k].next;
    L[k].next=L[m].next;
    L[m].next=-2; //设置为空结点
    L[m].data=0; //数据域设置为0
    return true;
}

下面分析上述代码的时间复杂度,还是只需要关注最深层循环语句(第7行的代码)的执行次数与问题规模n(这里n=L.length,即表长)的关系。直接看平均情况:假设删除任意一个结点的概率都相同,即在第1,2,3,…,n个结点的概率都是p=1/n——当是第1个结点时,循环0次;第2个结点时,循环1次…当是第n-1个时,循环n-2次;当是第n个,即最后一个结点时,循环n-1次。平均循环次数=(n-1)p+(n-2)p+…+p=(n-1)/2。平均时间复杂度=O((n-1)/2)=O(n)。

下面是我思考的第二个版本,实现的功能还是删除第i个结点:

bool DeleteDNode(SLinkList &L,int e){ //删除值为e的结点
    int j=0,k=L[0].next; //游标
    if(k==-1) //若为空表则失败
        return false;
    while(L[k].data!=e){
        j=k;
        k=L[j].next;
        if(k==-1)
            return false; //找到表尾结点之前还没找到就失败
    }
    L[j].next=L[k].next;
    L[k].next=-2; //设置为空结点
    L[k].data=0; //数据域设置为0
    return true;
}

自己编程能力还是很弱,在测试函数时没有发现主程序数据有问题,还是一味地看着函数,看了很长很长时间,确实没什么问题,但是运行结果就是不正确,后来写了第二个版本也就是本节第一个版本时回来再看,才发现是数据的问题,自己写的函数是正确的,该死!


4 顺序表vs链表

4.1 在各个方面进行比较

1、存取方式:顺序表可以顺序存取,也可以随机存取,链表只能从表头顺序存取元素。例如在第i个位置上执行存或取的操作,顺序表仅需一次访问,而链表则需从表头开始依次访问i次。

2、逻辑结构与物理结构:采用顺序存储时,逻辑上相邻的元素,对应的物理存储位置也相邻。而采用链式存储时,逻辑上相邻的元素,物理存储位置则不一定相邻,对应的逻辑关系是通过指针链接来表示的。

3、查找、插入和删除操作:对于按值查找,顺序表无序时,两者的时间复杂度均为O(a);对于按序号查找,顺序表支持随机访问,时间复杂度仅为O(1),而链表的平均时间复杂度为O(n)。顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需修改相关结点的指针域即可。由于链表的每个结点都带有指针域,故而存储密度不够大。

4、空间分配:顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,若再加入新元素,则会出现内存溢出,因此需要预先分配足够大的存储空间。但是预先分配过大,可能会导致顺序表后部大量闲置,而预先分配过小,又会造成溢出。动态存储分配虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中没有更大块的连续存储空间,则会导致分配失败。链式存储的结点空间只在需要时申请分配,只要内存有空间就可以分配,操作灵活、高效。

4.2 实际中选那个更合适

1、基于存储的考虑:难以估计线性表的长度或存储规模时,不宜采用顺序表;链表不用事先估计存储规模,但链表的存储密度较低,显然链式存储结构的存储密度是小于1的。

2、基于运算的考虑:在顺序表中按序号访问第i个元素的时间复杂度为O(1),而链表中按序号访问的时间复杂度为O(n),因此若经常做的运算是按序号访问数据元素,则显然顺序表优于链表。在顺序表中进行插入、删除操作时,平均会移动表中一半的元素,当数据元素较大且表较长时,这一点是不应忽视的;在链表中进行插入、删除操作时,虽然也要找插入位置,但操作主要是比较操作,从这个角度考虑显然后者优于前者。

3、基于环境的考虑:顺序表容易实现,任何高级语言中都有数组类型;链表的操作是基于指针的,相对来讲,前者实现较为简单,这也是用户考虑的一个因素。

4.3 总结

两种存储结构各有长短,选择哪一种由实际问题的主要因素决定:

1、表长难以预估,经常要增加、删除元素一一链表;

2、表长可预估,查询、搜索操作较多——顺序表。


5 存储与存取

5.1 存取方式

存取方式分为随机存取和非随机存取(也称顺序存取)。

5.1.1 随机存取

随机存取指存取时间与存储单元的物理位置无关,存取分别指写入与读出操作,可以在相同时间访问一组序列中的任一个元素。即存取第N个数据时,不需要访问前N-1个数据,直接就可以对第N个数据操作。数组就是随机存取。

5.1.2 顺序存取

非随机存取就是顺序存取,只能按照存储顺序存取,与存储位置有关,例如链表。顺序存取就是存取第N个数据时,必须先访问前N-1个数据。

5.2 存储结构

存储结构分为顺序存储结构和随机存储结构。

5.2.1 顺序存储

用一组地址连续的存储单元依次存储线性表的各个数据元素,称作线性表的顺序存储结构。该结构是把逻辑上相邻的结点存储在物理位置上相邻的存储单元中,结点之间的逻辑关系由存储单元的邻接关系来体现。通常顺序存储结构是借助数组来描述的。顺序存储结构的主要优点是节省存储空间,因为分配给数据的存储单元全用于存放结点的数据。可实现任意元素的随机访问,即每一个结点对应一个序号,由该序号可以直接计算出来结点的存储地址。

5.2.2 随机存储

用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。不要求逻辑上相邻的元素在物理位置上也相邻,因此失去了可随机存取的优点。随机存储代表为链式存储。


END

  • 5
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值