《从零入门数据结构与算法(二):线性表详解——从顺序表原理到静态与动态实现》

上一篇,我们把复杂度这个概念彻底拆了一遍。时间复杂度和空间复杂度,各自是什么、怎么算,结合一个个经典例子和图像,掰开揉碎地讲了个透。我们还把常见的复杂度拉出来做了对比,看了各种排序算法的复杂度表格。最后,用轮转数组这道力扣题,把"算法思路有很多种"这句话落到了实处,三种不同的思路,从超时到通过,再到把时间压到O(n)、空间守到O(1),一趟走下来,算法和复杂度的关系算是看明白了。

而这一篇,我们就要翻开顺序表和链表的新篇章了。初阶的数据结构与算法,我们把知识点分成三块。第一块,复杂度。第二块,顺序表、链表、栈和队列、二叉树。第三块,排序。第一块的复杂度,已经讲完了。从这篇开始,我们正式进入第二块顺序表和链表。

目录

一、认识线性表

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

[2.1 顺序表的基本概念](#2.1 顺序表的基本概念)

[2.2 顺序表的两种实现方式](#2.2 顺序表的两种实现方式)

[2.2.1 静态顺序表](#2.2.1 静态顺序表)

[2.2.2 动态顺序表](#2.2.2 动态顺序表)

[2.3 顺序表的结构设计](#2.3 顺序表的结构设计)

[2.3.1 静态顺序表的结构定义](#2.3.1 静态顺序表的结构定义)

[2.3.2 动态顺序表的结构定义](#2.3.2 动态顺序表的结构定义)

[2.4 动手实现动态顺序表:从尾插入手](#2.4 动手实现动态顺序表:从尾插入手)

[2.4.1 动态顺序表的基本实现](#2.4.1 动态顺序表的基本实现)

①SeqList.h:声明顺序表结构与相关接口

②SeqList.c:实现顺序表功能

③test.c:编写测试代码,验证尾插功能

[2.5 静态与动态顺序表的完整代码实现](#2.5 静态与动态顺序表的完整代码实现)

[2.5.1 静态顺序表的实现](#2.5.1 静态顺序表的实现)

①SeqList.h:定义静态顺序表结构与接口

②SeqList.c:实现静态顺序表相关操作

③test.c:测试静态顺序表

[2.5.2 动态顺序表的实现](#2.5.2 动态顺序表的实现)

①SeqList.h:定义动态顺序表结构与接口

②SeqList.c:实现动态顺序表及尾插操作

③test.c:测试动态顺序表


一、认识线性表

线性表(Linear List),说的是n个具有相同特性的数据元素,排成的一个有限序列。这东西在实际开发里太常见了,顺序表、链表、栈、队列、字符串,全都是线性表家族的成员。

线性表在逻辑上是一条连续的直线,元素一个挨着一个,次序清清楚楚。可到了物理层面,就不一定了。它在内存里通常有两种落脚方式:一种是数组,元素挤在连续的空间里;另一种是链式结构,元素各住各的,靠指针牵线搭桥。

所以,线性表可以从两个维度来看:逻辑结构 和物理结构。逻辑结构是人为抽象出来的模型,物理结构才是它在内存里的真实存储形态。

所谓逻辑结构,说白了就是人想象出来的那套秩序;而物理结构,是数据实打实躺在内存里的样子。

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

2.1 顺序表的基本概念

顺序表,是一段用物理地址连续的存储单元,依次把数据元素排进去的线性结构。平时,它一般就用数组来存储。那问题来了:数组就是顺序表吗?顺序表就是数组吗?能这么直接划等号吗?

不能。真要说清楚,得这么看:顺序表的底层结构是数组,它是在数组之上做了一层封装,实现了常见的增删查改等接口。

换句话说,数据结构要干的活儿,就是把顺序表那套增删查改的接口函数,一个一个实现出来。

那数组和顺序表,到底啥关系?打个比方吧。

数组就像街边的苍蝇小馆,几口大锅、几张桌子,能填饱肚子,但没什么讲究。顺序表呢,更像米其林五星级餐厅,同样的食材,人家后厨有一套精细的流程,摆盘、上菜、服务,样样都有章法。底层都是"吃饭"这件事,可一个粗放,一个精细,体验天差地别。

数组给你的是最原始的存储能力,而顺序表在它之上,把插入、删除、查找、修改这些操作,做成了规范、好用的接口。用起来顺手,管起来也省心。

2.2 顺序表的两种实现方式

顺序表分两种:静态顺序表 和动态顺序表。

2.2.1 静态顺序表

静态顺序表,就是用定长数组来存元素。开篇就定死,多大就是多大。

它的毛病也很直白:空间给少了,不够用,程序直接趴窝;给多了,用不完,白白浪费。两头不讨好,尺寸全靠拍脑袋估。估准了算运气,估不准是常态。

2.2.2 动态顺序表

动态顺序表就不一样了,按需申请空间。要多少,给多少;不够了,再扩。空间利用率上去了,也不用再为"到底开多大"纠结半天。

静态和动态,一个把尺寸焊死,一个随用随调。各有各的适用场景,但真要灵活,还得看动态的。

2.3 顺序表的结构设计

我们分别来看静态顺序表和动态顺序表,各自长什么样。

2.3.1 静态顺序表的结构定义

静态顺序表,结构简单到不能再简单,一个定长数组,加一个记录当前元素个数的变量。

2.3.2 动态顺序表的结构定义

动态顺序表,把定长数组换成了一个指针,再配上容量和元素个数两个变量。

2.4 动手实现动态顺序表:从尾插入手

静态顺序表和动态顺序表,我们这里只实现动态顺序表。但这并不意味着静态顺序表就没有学习的必要了,这一点得先说明白。

实现要分成三个文件来做。因为是顺序表,名字就叫"SeqList"。用三个文件实现一个功能,这套路我们在扫雷游戏那儿就打过交道了。简单捋一下三个文件各自的分工:

分别创建三个文件SeqList.h头文件、SeqList.c源文件、test.c测试文件。

下面会逐一介绍三个文件怎么写。每个小标题下面的第一段代码,就是动态顺序表的代码实现;代码后面,再抽丝剥茧地展开详解。

2.4.1 动态顺序表的基本实现

先看三个文件的原貌,再一个个拆开讲。代码里藏着几个要命的坑,我们边看边说。

①SeqList.h:声明顺序表结构与相关接口
cpp 复制代码
#pragma once
#include <stdio.h>

typedef int SLDataType;

typedef struct SeqList
{
    SLDataType* arr;
    int size;
    int capacity;
} SL;

void SLInit(SL* ps);
void SLPushBack(SL* ps, SLDataType x);

头文件里,SLDataType是类型别名,方便以后换成别的数据类型。结构体三个成员,arr指向动态数组,size记有效数据个数,capacity记总容量。

②SeqList.c:实现顺序表功能
cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1

#include "SeqList.h"
#include <stdlib.h>

void SLInit(SL* ps)
{
    ps->arr = NULL;
    ps->size = ps->capacity = 0;
}

void SLPushBack(SL* ps, SLDataType x)
{
    if (ps->size == ps->capacity)
    {
        int newCapacity = ps->capacity == 0 ? 4 : 2 * ps->capacity;
        SLDataType* tmp = (SLDataType*)realloc(ps->arr, newCapacity * sizeof(SLDataType));
        if (tmp == NULL)
        {
            perror("realloc fail!");
            exit(1);
        }
        ps->arr = tmp;
        ps->capacity = newCapacity;
    }
    ps->arr[ps->size++] = x;
}
③test.c:编写测试代码,验证尾插功能
cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1

#include "SeqList.h"

void test01()
{
    SL sl;
    SLInit(&sl);

    SLPushBack(&sl, 1);
    SLPushBack(&sl, 2);
    SLPushBack(&sl, 3);
    SLPushBack(&sl, 4);
}

int main()
{
    test01();
    return 0;
}
2.5 静态与动态顺序表的完整代码实现

这块内容牵扯到结构体的知识。学数据结构,对结构体得有足够的认识,不然后面全是空中楼阁。

先看一段代码:

cpp 复制代码
#pragma once

#define N 100
typedef int SQDataType;

struct SeqList
{
    SQDataType a[N];
    int size;
}

// 增删查改等接口函数

先说#pragma once

这行代码,博主在C语言专栏那篇文章里介绍过,截图就不重复了。它的功能很单纯:避免头文件被重复包含。

如果你不想用它,也可以写成这样,效果完全等价:

cpp 复制代码
#ifdef __TEST_H__
#define __TEST_H__

#define N 100
typedef int SQDataType;

struct SeqList
{
    SQDataType a[N];
    int size;
}

// 增删查改等接口函数
#endif // __TEST_H__

一个用编译器的扩展指令,一个用预处理宏,两条路,同一个终点。

为什么类型用typedef,常量用#define?

这里有个容易犯迷糊的点。定义数据类型,我们一般用typedef;定义普通常量,才用#define。比如N是个常量100,用#define 没问题。

这么做的意义在哪?假设顺序表的数组长度写死成10:

cpp 复制代码
struct SeqList
{
    int a[10];
    int size;
}

哪天想改成100,或者1000,你就得满代码翻,一个地方一个地方地改。但换成这样:

cpp 复制代码
#define N 100

struct SeqList
{
    SQDataType a[N];
    int size;
}

a\[\]里直接放N,想改的时候,就在定义处把N动一下,其他地方纹丝不动。

存储的数据类型也一样。typedef int SLDataType;一写,以后想存char,就改成char;想存double,就改成double:

cpp 复制代码
typedef char SLDataType;
typedef float SLDataType;
typedef double SLDataType;

只动一行,全局生效。这就是typedef的好处,把类型抽象出来,改一处,通全盘。

初始化怎么做?

初始化可以用循环,也可以直接用memset。memset是按字节来初始化的,用起来利索。

cpp 复制代码
#pragma once

#define N 100
typedef int SQDataType;

struct SeqList
{
    SQDataType a[N];
    int size;
}

// 增删查改等接口函数
void SeqListInit(struct SeqList sl);

我们C语言里写结构体,不能直接用结构体名,得带上struct关键字。uu们是不是觉得这样太长了?那就简写嘛。

cpp 复制代码
#ifdef __TEST_H__
#define __TEST_H__

#define N 100
typedef int SQDataType;

struct SeqList
{
    SQDataType a[N];
    int size;
}
typedef struct SeqList SL;

void SeqListInit(struct SeqList sl);
// 增删查改等接口函数
#endif // __TEST_H__

当然,还有另一种写法,很多书、很多老师都这么写:

cpp 复制代码
#ifdef __TEST_H__
#define __TEST_H__

#define N 100
typedef int SQDataType;

typedef struct SeqList
{
    SQDataType a[N];
    int size;
} SL;

void SeqListInit(struct SeqList sl);
// 增删查改等接口函数
#endif // __TEST_H__

这样就不用再写长长的一截struct SeqList了,直接写简写SL:

cpp 复制代码
#ifdef __TEST_H__
#define __TEST_H__

#define N 100
typedef int SQDataType;

typedef struct SeqList
{
    SQDataType a[N];
    int size;
} SL;

void SeqListInit(SL sl);
// 增删查改等接口函数
#endif // __TEST_H__

是不是简单多了?

这里再提醒一下uu们:敲代码,别一股脑全写完再测试。写一个,测一个。

代码一长,一口气写完再运行,唰地蹦出几十个错误,你盯着屏幕反复调,调了一两个小时,再一运行,又炸出几十个错误,人当场就崩了。

写一个测一个,有问题也只在这几行代码里,好找、好改,犯不着自己吓自己。

顺序表嘛,本质就是个数组。uu们可能觉得N这个变量名不好理解,那我们就把它写明白:

cpp 复制代码
#ifdef __TEST_H__
#define __TEST_H__

#define MAX_SIZE 100
typedef int SQDataType;

typedef struct SeqList
{
    SQDataType a[MAX_SIZE];
    int size;
} SL;

void SeqListInit(SL s);
// 增删查改等接口函数
#endif // __TEST_H__

这下是不是一眼就懂MAX_SIZE,数组最大容量,清清楚楚。初始化写起来也顺:

cpp 复制代码
#include "SeqList.h"

void SeqListInit(SL s)
{
    memset(sl.a, 0, sizeof(SQDataType) * MAX_SIZE);
    sl.size = 0;
    // sl.a 说明是按字节初始化
    // 初始化多少个字节呢?MAX_SIZE 个
    // 每个有多大呢?sizeof(SQDataType)------SQDataType 这个类型每一个的大小
}

到这里,定义数组大小为MAX_SIZE的好处就更明显了。想改数组大小?只在.h文件里动一下MAX_SIZE,所有包含这个头文件的地方,全都跟着变。方便得不像话。

定义数据类型也一样,本质上都是增强程序的可维护性------不用满世界找着改。

那我们现在开始,写一点,测一点。程序要输入输出,得包含头文件。不知道memset在哪个头文件?查一下嘛,<cstring>或者<string.h>,这俩是一样的。

cpp 复制代码
#ifdef __TEST_H__
#define __TEST_H__

#include <stdio.h>
#include <string.h>

// 增强程序可维护性
#define MAX_SIZE 100
typedef int SQDataType;

typedef struct SeqList
{
    SQDataType a[MAX_SIZE];
    int size;
} SL;

void SeqListInit(SL s);
// 增删查改等接口函数
#endif // __TEST_H__

然后来到test.c,先写个简单的:

cpp 复制代码
#include "SeqList.h"

void TestSeqList1()
{
    SL sl;
    SeqListInit(SL s1);
    // 调接口进行初始化
}

int main()
{
    return 0;
}

定义完了,先跑一跑看看。调试的时候,可以结合VS调试技巧F9打断点,F5调试,程序就停在断点处。

不过这里我们就不能这么写了,前面那几处得改一改。

2.5.1 静态顺序表的实现

我们C语言里学过通讯录,那个结构,跟静态顺序表一个路子,定义一个宏,尺寸就写死了。

①SeqList.h:定义静态顺序表结构与接口
cpp 复制代码
#ifndef __TEST_H__
#define __TEST_H__

#include <stdio.h>
#include <string.h>

// 增强程序可维护性
#define MAX_SIZE 10   // 100 个、500 个有点太大了,先来 10 个
typedef int SQDataType;

typedef struct SeqList
{
    SQDataType a[MAX_SIZE];
    int size;
} SL;

// 增删查改等接口函数
void SeqListInit(SL* ps);
void SeqListPushBack(SL* ps, SQDataType x);   // 尾插
void SeqListPushFront(SL* ps, SQDataType x);  // 头插
void SeqListPopBack(SL* ps);                  // 尾删
void SeqListPopFront(SL* ps);                 // 头删

#endif

这里的命名,uu们如果自己有别的习惯,也完全可以。博主这里是跟着C++ STL标准库的命名走的,算是比较规范的写法:

cpp 复制代码
void SeqListPushBack(SL* ps, SQDataType x);   // 尾插
void SeqListPushFront(SL* ps, SQDataType x);  // 头插
void SeqListPopBack(SL* ps);                  // 尾删
void SeqListPopFront(SL* ps);                 // 头删

当然,接口不止这些。还是老原则,写一个,测一个。

②SeqList.c:实现静态顺序表相关操作
cpp 复制代码
#include "SeqList.h"

void SeqListInit(SL* ps)
{
    memset(ps->a, 0, sizeof(SQDataType) * MAX_SIZE);
    ps->size = 0;
}

// 头插、尾插、头删、尾删
void SeqListPushBack(SL* ps, SQDataType x)
{
    if (ps->size >= MAX_SIZE)
    {
        printf("SeqList is Full\n");
        return;
    }
    ps->a[ps->size] = x;
    ps->size++;
}

// 下面几个大致也是这个套路,就不一一展开了
void SeqListPushFront(SL* ps, SQDataType x);
void SeqListPopBack(SL* ps);
void SeqListPopFront(SL* ps);

从这里就能看出,静态顺序表不是一块好料。你想存1001个数据,就得把MAX_SIZE定义成10000,白白空着一大片。总结一下它的毛病:

给少了不够用,给多了用不完,浪费空间,控制不灵活。

正因如此,我们才转向更灵活的动态顺序表。静态顺序表,就介绍到这儿。

③test.c:测试静态顺序表
cpp 复制代码
#include "SeqList.h"

void TestSeqList1()
{
    SL sl;
    SeqListInit(&sl);
    // 调接口进行初始化
}

int main()
{
    TestSeqList1();
    return 0;
}

这里为什么要传指针呢?

因为形参只是实参的一份临时拷贝,形参和实参各有各的空间,彼此独立。你在函数里改形参,外面那个实参根本不知道。所以,得把地址传进去。传了地址,函数就能顺着地址找到实参本尊,改的就是它了。

那为什么传地址就能影响到实参?

因为地址指向的就是实参所在的那块内存。函数拿到地址后,通过指针解引用,直接操作的就是实参本身。拷贝的是地址,可地址指向的,是同一块地方。所以,形参改的,就是实参。

2.5.2 动态顺序表的实现
①SeqList.h:定义动态顺序表结构与接口
cpp 复制代码
#ifdef __SEQLIST__H__
#define __SEQLIST__H__

#include <stdio.h>
#include <string.h>

// 增强程序可维护性
typedef int SLTDataType;

typedef struct SeqList
{
    SLTDataType* a;
    int size;      // 有效数据的个数
    int capacity;  // 容量
} SL;

// 增删查改等接口函数
void SeqListInit(SL* ps);
void SeqListPrint(SL* ps);
void SeqListPushBack(SL* ps, SLTDataType x);   // 尾插
void SeqListPushFront(SL* ps, SLTDataType x);  // 头插
void SeqListPopBack(SL* ps);                   // 尾删
void SeqListPopFront(SL* ps);                  // 头删

#endif
②SeqList.c:实现动态顺序表及尾插操作
cpp 复制代码
#include "SeqList.h"

void SeqListInit(SL* ps)
{
    // 粗暴一点,先置为空,再改成 0,然后 capacity 也改为 0
    ps->a = NULL;
    ps->size = 0;
    ps->capacity = 0;
    // 当然,我们也可以直接 malloc
}

// 头插、尾插、头删、尾删
void SeqListPushBack(SL* ps, SLTDataType x)
{
    // 满了就要扩容
    if (ps->size == ps->capacity)
    {
        // 扩容一般扩两倍。一个一个太慢了,三倍又太多了,C++ 标准库一般是扩两倍
        // 我们有两种思路:一种是新开辟一个两倍的空间,然后把原来的内容复制过去;
        // 另一种是 realloc
        int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
        // 第一次给多少没什么讲究,给个 10 也可以,只是一开始不要给太大,给太大就浪费了
        // 动态管理,就是开始给小一点,不够了慢慢增加
        // 最小给个 1
        SLTDataType* tmp = realloc(ps->a, newcapacity * sizeof(SLTDataType));
        // realloc 有可能失败
        if (tmp == NULL)
        {
            printf("realloc fail\n");
            exit(-1);
        }
        else
        {
            ps->a = tmp;
            // ps->capacity *= 2;  // 这里就不是乘两倍了,直接等于 newcapacity
            ps->capacity = newcapacity;
        }
    }
    ps->a[ps->size] = x;
    ps->size++;
}

void SeqListPushFront(SL* ps, SLTDataType x);
void SeqListPopBack(SL* ps);
void SeqListPopFront(SL* ps);

void SeqListPrint(SL* ps)
{
    for (int i = 0; i < ps->size; ++i)
    {
        printf("%d ", ps->a[i]);
    }
    printf("\n");
}
③test.c:测试动态顺序表
cpp 复制代码
#include "SeqList.h"

void TestSeqList1()
{
    SL sl;
    SeqListInit(&sl);

    SeqListPushBack(&sl, 1);
    SeqListPushBack(&sl, 2);
    SeqListPushBack(&sl, 3);
    SeqListPushBack(&sl, 4);
    SeqListPushBack(&sl, 5);
    SeqListPushBack(&sl, 6);
    SeqListPushBack(&sl, 7);
    SeqListPushBack(&sl, 8);
    SeqListPushBack(&sl, 9);
    SeqListPushBack(&sl, 10);
    SeqListPushBack(&sl, 11);

    SeqListPrint(&sl);
}

int main()
{
    TestSeqList1();
    return 0;
}

这就是尾插。只要内存够,就能一直插下去。它不像静态顺序表那样,得提前定死存多少个,不够了就扩容,可以一直尾插。这就是动态顺序表相较于静态顺序表的优越性。

那么uu们可能要问了:realloc不是在原来空间的基础上扩容吗?如果原来空间是0呢?

realloc的文档里有一句说明:原来空间可以为空。只不过这样一来,realloc就等同于malloc,如果原来空间为空,它直接申请一块新空间,跟malloc一模一样。

像头插、指定插入、指定删除这些知识点,博主会专门整理一篇博客。当然,一篇肯定讲不完,给主包一点时间整理。或者,铸币主包把它们融进顺序表和链表的博客里,也是可行的。


如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连支持。你的每一次反馈,都是我继续硬核输出的最大动力。

相关推荐
wuyk5551 小时前
样章-第03讲-FreeRTOS在16KB-RAM上的裁剪与陷阱
c语言·开发语言·stm32·单片机·嵌入式硬件·物联网·系统架构
爱读书的小胖1 小时前
Codewars Sum of a Sequence Hard Core Version C 解题分析
c语言·数据结构·算法
夏虫不可与之言冰1 小时前
使用Flash Download Tool给ESP32烧录固件
c语言·c++·windows·单片机·物联网·iot
晴天的雨.9921 小时前
【C++】 priority_queue知识点总结
开发语言·数据结构·c++
闻缺陷则喜何志丹1 小时前
概要设计说明书应包括的内容
数据结构
mmmmath_311 小时前
LeetCode.199.二叉树的右视图
数据结构·leetcode
Shaoshing16 小时前
前缀和与差分
java·数据结构·算法·前缀和·差分
mmmmath_316 小时前
LeetCode.429.N叉树的层序遍历
数据结构·leetcode
wuyk55516 小时前
嵌入式驱动开发:量产级工程化实战(第9篇)——低功耗下的通信可靠性
c语言·驱动开发·stm32·单片机·嵌入式硬件