数据结构:线性表

         依旧按照笔者的对于数据结构的学习路线来编写的

        这篇文章主要是按照书上的例子,来进行学习。会详细记录线性表的原理

        刚开始学线性表指针不太熟悉可以先看看笔者这一篇文章。这一篇文章笔者使用了静态数组模拟单链表,没有使用指针,方便理解

线性表的定义与特点

定义:

        n(n>=0)个数据特征相同(每个数据元素所占空间相同)的元素构成的有序(有次序)序列

特点:

        存在唯一的一个被称作“第一个”的数据元素

        存在唯一的一个被称作“最后一个”的数据元素

        除第一个元素之外,结构中的每一个数据元素均只有一个前驱

        除最后一个元素之外,结构中的每个数据元素均只有一个后驱

线性表的类型定义

        六种主要的操作:创 销 增 删 改 查

不成文的规定

        ElemType 一般定义在宏定义中,表示该表储存的基本数据类型

        MaxSize 定义线性表的最大长度

        函数名中L前面添加了&符号,说明此时我们需要对L中元素进行更改;同样的L前面没有&符号,则不需要对其元素进行修改

线性表的几种常见的类型

线性表的顺序存储

顺序表

        线性表的顺序存储又叫顺序表,他的存储单元是地址连续的,并依次存储线性表中的数据元素。从而使得逻辑上相邻的两个元素在物理位置上也相邻

        表中元素的逻辑顺序和物理顺序是相同的

        我们假设顺序表L的存储起始位置为LOC(A),sizeof(ElemType)是每个数据元素所占用存储空间的大小,则表L所对应的顺序存储结构如图所示:

但是要注意的是,线性表中元素的位序是从1开始的,而数组中元素下标是从0开始的

        以下是顺序表中的主要操作:

定义
#define InitSize 10
#define ElemType int
typedef struct
{
    ElemType *data;  // 存储线性表数据的数组指针
    int MaxSize, length;  // 线性表的最大容量和当前长度
} SqList;  // 定义一个顺序存储的线性表结构

        本文中的线性表均使用动态分配编写

初始化
void InitList(SqList &L)
// 初始化顺序表
{
    L.data = new ElemType[InitSize];
    L.MaxSize = InitSize;
    L.length = 0;
}

        此处的初始化在C语言与C++中有些许区别

C语言

L.data=(ElemType*)malloc(InitSize*sizeof(ElemType));

C++

L.data = new ElemType[InitSize];

        需要注意的地方是,动态分配并不是链式存储,它同样属于顺序存储,物理结构并没有发生变化,依旧是随机存储方式,只是分配空间大小可以在运行时动态决定

插入
bool ListInsert(SqList &L, int i, ElemType e)
// 在顺序表的第i个位置插入元素e
{
    // 此代码片段用于检查插入或删除操作的位置是否有效,并且检查列表是否已满。
    if (i < 1 || i > L.length + 1)// 如果插入或删除的位置超出列表的有效范围(即小于1或大于列表长度加1),则返回false。
    {
        return false;
    }
    if (L.length >= L.MaxSize)// 如果列表的长度已经达到或超过其最大容量(MaxSize),则返回false。
    {
        return false;
    }
    for (int j = L.length; j >= i; j--)
    {
        L.data[j + 1] = L.data[j];
    }
    L.data[i - 1] = e;
    L.length++;
    return true;
}
删除

        此处有两种删除操作,分别为删除元素不返回,删除元素并返回

        我们在此处可以通过函数的重载来实现

bool ListDelete(SqList &L, int i)
// 删除顺序表中第i个位置的元素
{
    if (i < 1 || i > L.length)
    {
        return false;
    }
    if (L.MaxSize >= InitSize)
    {
        return false;
    }
    for (int j = i; j < L.length; j++)
    {
        L.data[j - 1] = L.data[j];
    }
    L.length--;
    return true;
}

ElemType e;
bool ListDelete(SqList &L, ElemType &e, int i)//在此处,我们调整了ElemType的类型,避免了与上面函数的冲突
// 删除顺序表中第i个位置的元素,并返回该元素
{
    if (i < 1 || i > L.length)
    {
        return false;
    }
    if (L.MaxSize >= InitSize)
    {
        return false;
    }
    e = L.data[i - 1];
    for (int j = i; j < L.length; j++)
    {
        L.data[j - 1] = L.data[j];
    }
    L.length--;
    return true;
}
修改
bool ChangeElem(SqList &L, int i, ElemType newElem)
// 更改顺序表中第i个位置的元素为newElem
{
    if (i < 1 || i > L.length)
    {
        return false; // 位置无效
    }
    L.data[i - 1] = newElem; // 更改元素值
    return true;
}
查找
int LocateElem(SqList L, ElemType e)
// 查找顺序表中第一个值为e的元素的位置
{
    for (int i = 0; i < L.length; i++)
    {
        if (L.data[i] == e)
        {
            return i + 1;
        }
    }
    return 0;
}

        在上述函数中,我们在开始进行修改时,都会针对添加用于判断的边界条件,以提高代码的健壮性

测试

        由于测试需要,我们将在文件中添加如下函数,用于打印改顺序表

void PrintList(SqList L)
// 打印顺序表
{
    for (int i = 0; i < L.length; i++)
    {
        cout << L.data[i] << " ";
    }
    cout << endl;
}

 mian函数

int main()
{
    SqList L;
    InitList(L);

    // 插入元素
    ListInsert(L, 1, 10);
    ListInsert(L, 2, 20);
    ListInsert(L, 3, 30);
    cout << "插入元素后的顺序表: ";
    PrintList(L);

    // 删除元素
    int deletedElem;
    if (ListDelete(L, deletedElem, 2))
    {
        cout << "删除的元素: " << deletedElem << endl;
    }
    cout << "删除元素后的顺序表: ";
    PrintList(L);

    // 更改元素
    ChangeElem(L, 1, 15);
    cout << "更改元素后的顺序表: ";
    PrintList(L);

    // 查找元素
    int pos = LocateElem(L, 15);
    if (pos != 0)
    {
        cout << "元素 15 的位置: " << pos << endl;
    }
    else
    {
        cout << "元素 15 不存在" << endl;
    }

    // 释放动态分配的内存
    delete[] L.data;

    return 0;
}

结果

线性表的链式存储

        通过学习了顺序存储,我们发现了一个问题,顺序表在使用时需要连续的存储空间,所以我们能不能想一种方法,在没有连续空间时,也能正常的进行存储呢?

        链式存储应运而生,他可以直接指向下一数据的位置,所以我们在编写的时候可以不使用连续的存储空间。而且在插入和删除操作时,也不需要移动元素,只需要修改指针。但是凡事都用但是,这样也丧失顺序表随机存储的优点。

单链表

        线性表的链式存储又叫单链表,它是指通过一组任意的存储单元来存储线性表中的数据元素。为了在不同的数据元素中建立线性关系,对于每个节点,我们会将其空间分为两部分,一部分来存放数据(数据域),一部分来存放相应的后续指针(指针域)。

        如图,data表示数据域,存放数据元素;next表示指针域,存放后续结点。

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

        定义一个链表结点结构体 LNode,包含数据域 data 和指向下一个结点的指针 next,同时定义了一个指向 LNode 结构体的指针类型 LinkList

        虽然单链表可以解决顺序表需要大量存储空间的缺点,但是它毕竟将一部分存储空间拿出来用于存储指针了,所以在空间复杂度方面会逊色于顺序表。

        一般的,我们会使用头指针L(或head)来标识一个单链表,用于指出链表的首地址,头指针再为NULL的时候表示该表是一个空表。进而我们衍生出了两种类型的链表,有头结点的链表和无头结点的链表。也就是说,单链表的第一个数据元素之前可以附加一个结点(头节点),头节点可以不包含任何信息,也可以存放表长信息

        单链表带头结点时,头指针L指向头指针;单链表不带头结点时,头指针L则指向第一个数据结点。表尾结点的指针域为NULL(用^表示)

        头结点与头指针的关系:头结点存在与否,头指针都指向链表的第一个结点,而头结点是带头结点的链表中的第一个结点

        引入头结点后,会带来两个优点:

                由于第一个数据结点的位置被存放在头结点的指针域中,因此在整个链表的操作中,任何位置的操作均为一致的,无需添加额外的特殊处理

                无论链表是否为空,空表中的头结点的指针域都为空(头指针都是指向头结点的非空指针),所以空表与非空链表的操作也是统一的

初始化

        带头结点和不带头结点的单链表初始化操作时不同的,带头结点的初始化时需要新建一个头结点,并让头指针指向头结点,头结点的next域初始化为NULL

        有头

bool InitList(LinkList &L)
{
    L = new LNode;
    L->next = NULL;
    return true;
}

        无头 

bool InitList(LinkList &L)
{
    L = NULL;
    return true;
}
插入

        将值为x的结点插入到单链表的第i个位置。那么我们需要先检查插入位置的合法性,并且找到插入位置的前驱(即第i-1个元素),再在其后插入。

        操作如下图所示:

         在进行插入操作时不能调换①和②的顺序,否则会丢失原来的后续指针。

bool ListInsert(LinkList &L, int i, ElemType e)
{
    LNode *p = L;// 定位第i-1个位置的节点指针
    int j = 0;//记录当前结点的位置,头结点是第0个结点
    while (p != NULL && j < i - 1)//循环找到第i-1个位置的节点指针
    {
        p = p->next;
        j++;
    }
    if (p == NULL)//i值不合法
    {
        return false;
    }
    LNode *s = new LNode;
    s->data = e;
    s->next = p->next;//操作步骤①
    p->next = s;//操作步骤②
    return true;
}

        该方法的时间开销主要集中在查找第i-1个元素(时间复杂度为O(n)),真正的插入操作时间复杂度仅为O(1)

       这种方法是从每个结点的后面进行的操作,所以就叫做后插操作。那么有后插肯定也有前插操作

        前插操作就是在每个数据元素的前面进行插入操作,我们就是需要将代码中的

    s->data = e;
    s->next = p->next;//操作步骤①
    p->next = s;//操作步骤②

替换为

    ElemType temp;
    s->next = p->next;
    p->next = s;
    temp = p->data;
    p->data = s->data;
    s->data = temp;

即可进行前插操作 

删除 

        删除第i个结点,我们需要先判断其位置的合法性,然后再查找表中的第i-1个结点,即被删除元素的前驱(因为我们需要时改前驱的next指针指向所删除元素的下一个元素),再删除第i个结点。

        其操作如下图所示

         所以我们假设结点*p为被删除结点的前驱,为了实现这一删除操作,我们仅仅只需要修改*p的指针域,将*p的指针域next指向*q的下一结点,之后再释放*q的存储空间

        单纯的删除

bool ListDelete(LinkList &L, int i)
{
    LNode *p = L;
    int j = 0;
    while (p != NULL && j < i - 1)
    {
        p = p->next;
        j++;
    }
    if (p == NULL || p->next == NULL)
    {
        return false;
    }
    LNode *q = p->next;//令q指向被删除节点
    p->next = q->next;//将*q(结点从链中断开)的下一个节点链接到*p的下一个节点
    delete q;
    // free(q);
    return true;
}

        返回被删除的值的删除

bool ListDelete(LinkList &L, int i, ElemType e)
{
    LNode *p = L;
    int j = 0;
    while (p != NULL && j < i - 1)
    {
        p = p->next;
        j++;
    }
    if (p == NULL || p->next == NULL)
    {
        return false;
    }
    LNode *q = p->next; // 令q指向被删除节点
    e=q->data;//将e返回元素的值
    p->next = q->next;  // 将*q(结点从链中断开)的下一个节点链接到*p的下一个节点
    delete q;
    // free(q);
    return true;
}

        代码中被注释掉的 free 函数是因为笔者在学习时认为 free 与 delete 函数都可以用于释放动态分配的空间,运行之后确实也没有报错。但是再查找过后,发现两者确实存在一定的区别:

  • free 是C语言中的函数,用于释放由 malloccallocrealloc 等函数分配的内存
  • delete 是C++中的操作符,用于释放由 new 操作符分配的内存

         而笔者前文又使用了 new 来分配空间,所以此处就应该使用 delete 来释放空间

修改

        朴实无华的遍历来找到这个结点,完了修改一下他的数据域,就没了。

bool ChanageElem(LinkList &L, int i, ElemType e)
{
    LNode *p = L;
    int j = 0;
    while (p != NULL && j < i)
    {
        p = p->next;
        j++;
    }
    if (p == NULL)
    {
        return false;
    }
    p->data = e; // 修改元素的值
    return true;
}

        其实看到这,我们也能发现,代码中有许多的片段是重复的,比如说用于遍历和判断位置合法性的代码:

    LNode *p = L;
    int j = 0;
    while (p != NULL && j < i - 1)
    {
        p = p->next;
        j++;
    }
    if (p == NULL)
    {
        return false;
    }

         我们当然也可以把他们抽象出来形成一个函数,以提高编写的效率,但是同时我们也降低了代码的可读性,至于怎么权衡那就全看读者的理解了

查找 

        查找一般分为两类,按位查找和按值查找

        按位查找就顾名思义,按照位置(位序)查找元素。从单链表的第一个结点开始,沿着next域从前完后依次查找。若找到第i个结点,则返回改结点的指针;若i大于单链表的长度,则返回NULL

LNode *GetElem(LinkList L, int i)
{
    if (i < 1 || i > Length(L))//此处的Length函数将会再后文编写
    {
        return NULL;
    }
    LNode *p = L;
    int j = 0;
    while (j < i && p != NULL)//当j与i的值相等时,该循环就会停止,p则会保留当前的指针域
    {
        p = p->next;
        j++;
    }
    return p;
}

        按值查找则是从单链表的第一个结点开始,从前往后依次比较表中各个结点的数据域,若某个结点的data域与我们索要查找的值e相同,则返回该结点的指针;若整个链表均未发现数据域与e相同的结点,则返回NULL

LNode *LocateElem(LinkList L, ElemType e)
{
    LNode *p = L->next;// 通过将指针 p 指向链表 L 的下一个节点(即链表的第一个实际数据节点),来访问链表的第一个元素
    while (p != NULL && p->data != e)
    {
        p = p->next;
    }
    return p;
}
求表长

        循环一遍链表,从而求出表长(大力出奇迹)

int Length(LinkList L)
{
    int len = 0;
    LNode *p = L;
    while (p->next != NULL)
    {
        len++;
        p = p->next;
    }
    return len;
}
头插法建立单链表

        该方法是为了从一个空表开始,不断的生成新的结点,并将读取到的数据存放到新的结点的数据域中,然后将新的结点插入到当前链表的开头(头结点之后)

        如下图所示:

LinkList List_HeadInsert(LinkList &L)
{
    LNode *s;
    int x;
    L = new LNode;
    L->next = NULL;
    cin >> x;
    while (x != 9999)
    {
        s = new LNode;//首先,创建一个新节点s,并将其数据域设置为输入值x
        s->data = x;//然后,将新节点s的next指针指向当前链表的头节点L的next指针所指向的节点
        s->next = L->next;
        L->next = s;//最后将链表头节点L的next指针指向新节点s,从而完成新节点的插入
        cin >> x;//插入操作完成后,从标准输入读取下一个数据值x
    }
    return L;
}

            采用头插法时,读入的数据顺序与生成的链表中的元素顺序是相反的,所以此时我们可以实现对于链表的逆置。

尾插法建立单链表

        头插法建立单链表看起来简单,但是细细一想,生成的链表中结点的次序和输入数据的顺序不一样,这玩意就很难受。我们肯定希望有一种方法能使得两者顺序一致,因此尾插法应运而生。该方法主要是将新节点插入到当前链表的表尾。为此我们需要添加一个尾指针r,使其始终指向当前链表的尾结点

        如下图所示:

LinkList List_TailInsert(LinkList &L)
{
    LNode *s, *r;
    int x;
    L = new LNode;
    r = L;//主要也就是这个地方有点区别
    cin >> x;
    while (x != 9999)
    {
        s = new LNode;
        s->data = x;
        r->next = s;//保证s结点始终指向最末端
        r = s;
        cin >> x;
    }
    r->next = NULL;//最后更新为NULL
    return L;
}

单链表很重要,是整个链表的基础,不光是后面的双链表循环链表的基础,也是后面的栈和队列基础(这俩都是操作受限的线性表)

测试
	LinkList L;
	if (!InitList(L))
	{
		cout << "初始化链表失败" << endl;
		return -1;
	}
	cout << "初始化链表成功" << endl;

	// 测试头插法创建链表
	cout << "测试头插法创建链表:" << endl;
	// 模拟输入数据
	// int headInsertData[] = {5, 4, 3, 2, 1, 9999};
	int headInsertData[] = {1, 2, 3, 4, 5};
	for (auto i : headInsertData)
	{
		ListHeadInsert(L, 1, i);
	}
	LNode *p = L;
	while (p->next != NULL)
	{
		cout << p->data << " ";
		p = p->next;
	}
	cout << endl;

	// 测试尾插法创建链表
	cout << "测试尾插法创建链表:" << endl;
	// 模拟输入数据
	int tailInsertData[] = {6, 7, 8, 9, 10};
	for (auto i : tailInsertData)
	{
		ListTailInsert(L, Length(L), i);
	}
	p = L;
	while (p->next != NULL)
	{
		cout << p->data << " ";
		p = p->next;
	}
	cout << endl;

	// 测试插入元素
	cout << "测试插入元素:" << endl;
	if (ListTailInsert(L, 1, 100))
	{
		p = L;
		while (p->next != NULL)
		{
			cout << p->data << " ";
			p = p->next;
		}
		cout << endl;
	}
	else
	{
		cout << "插入元素失败" << endl;
	}

	// 测试删除元素
	cout << "测试删除元素:" << endl;
	if (ListDelete(L, 1))
	{
		p = L;
		while (p->next != NULL)
		{
			cout << p->data << " ";
			p = p->next;
		}
		cout << endl;
	}
	else
	{
		cout << "删除元素失败" << endl;
	}

	// 测试修改元素
	cout << "测试修改元素:" << endl;
	if (ChanageElem(L, 1, 200))
	{
		p = L;
		while (p->next != NULL)
		{
			cout << p->data << " ";
			p = p->next;
		}
		cout << endl;
	}
	else
	{
		cout << "修改元素失败" << endl;
	}

	// 测试获取元素
	cout << "测试获取元素:" << endl;
	p = GetElem(L, 1);
	if (p != NULL)
	{
		cout << "第1个元素的值是: " << p->data << endl;
	}
	else
	{
		cout << "获取元素失败" << endl;
	}

	// 测试定位元素
	cout << "测试定位元素:" << endl;
	p = LocateElem(L, 200);
	if (p != NULL)
	{
		cout << "找到元素200";
	}
	else
	{
		cout << "未找到元素200";
	}
	return 0;

测试结果如下,符合预期 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值