【宝宝的第一本数据结构】01 顺序表
本文最后更新于76 天前,其中的信息可能已经过时,如有错误请发送邮件到727189340@qq.com

顺序表

现在,你是一家魔法工厂里的员工,老板交给你一个棘手的问题——整理好一堆魔力卡片,否则你的工作就会不保。

为了整理好这些卡片,你首先想到了魔法卡册:把这些卡按照某种顺序放在魔法卡册中。你可不想弄得太复杂,所以这是一个只有一行的卡册,它非常长,足够装下所有的卡片。假设一张卡片占据的单位空间是 $1$,一共有 $n$ 张卡片,你最多只需要 $n$ 个单位空间,就可以把这些卡片全部收集整齐。

太好了!你刚才发现了数据结构!大量的扑克牌就是数据,为了更好地管理、收纳、存储、处理这些数据,我们发明了魔法卡册,它就是一种数据结构

现在让我们进一步研究魔法卡册吧。


这种只有一行的,像一条线一样的魔法卡册是一种最基础的卡册。我们称呼它为线性表。我们按照顺序一张一张把卡片插下去。每一张卡片都是物理上相邻的。——我们叫它顺序表

不过,魔法卡册并不是普通的卡册,它占据了四次元空间中的一部分,所有放在卡册里的卡片其实都被存储在四次元空间中。所以我们需要先使用魔法开辟出一片空间来制造出这个卡册。首先,你需要先选择一个空间中的点,作为起始点;然后,你需要决定卡册具体有多大,这样四次元空间中一条连续的空间就会被你占据;最后,你还得管理一下卡册里现在存了多少张卡片,万一丢了你可就没工作了。

神秘的四次元空间其实是计算机的内存,内存中每一片区域都有其地址,这就是你选择的起始点的坐标。你使用的魔法——当然就是 C/C++ 语言了!

现在让我们开始施法吧。

创造魔法卡册

魔法卡册的构成 | 顺序表的存储结构

在本文中,我们默认你已经掌握了最基础的魔法——C/C++基础语法和某些函数的用法。在下面的代码中,为了方便理解,注释使用文中提到的比喻,读者可以自己将其转化成计算机语言,或对照《数据结构(C语言版 第2版)》(人民邮电出版社,严蔚敏 著)中的代码进行理解。
出于笔者的习惯,文章大部分的代码均为 C++ 风格。
// 顺序表的存储结构
#define MAXSIZE 100 // 卡册的长度
struct SqList       // 建立一个结构体,之后可以直接调用这个结构体生成一个顺序表
{
    ElemType *elem; // 申请四次元空间,elem 表示首地址
    int length;     // 卡册现在存储卡片的数量
};
代码中的 ElemType 表示一种数据类型,它可以是 intdoublechar 或其他用户自定义的数据类型等。此处仅是为了描述统一。
同样都是 ElemType 的变量也未必是一样的数据类型。请直接将 ElemType 看作一个空格,在其中填充你需要的数据类型就好。

下面解释上面程序中涉及到的一些基础语法,如果你已经忘记了相关的知识可以看看。

#define MAXSIZE 100 // 这是一个宏定义,指的是将 【MAXSIZE】 直接替换成 【100】(不要加分号!)
const int MAXSIZE = 100; // 你也可以这样写,这是定义了一个只读(不可改变的)变量。它们有微妙的差别,但在这里都是一样的

struct Sqlist{}; // 定义了一个结构体,并且命名为 Sqlist,之后你可以直接用 Sqlist A 来声明一个类型为 Sqlist 的结构体 A(不要忘了分号!)
typedef struct{}Sqlist; // 在 C 语言中要这么写,这样你也可以直接 Sqlist A 了;否则你就要写 struct Sqlist A 了,多麻烦!

ElemType *elem; // *代表我们声明的是一个指针。

创造魔法卡册 | 顺序表的创建

在创建的过程中总会发生失败的……失败是成功之母。错误也有很多种,为了方便我们知道究竟发生了什么,我们先来规定一下施展魔法的状态。之后,我们只要通过返回状态,就可以知道我们操作的结果是什么样的。

enum Status
{
OK, // 执行成功
ERROR, // 执行失败
INFEASIBLE, // 参数不合法
OVERFLOW, // 溢出
UNKNOWN // 未知错误
};

enum 是一种基本数据类型,用于定义一组常量。默认地,之后的第一个变量就是 $0$,第二个变量就是 $1$ 以此类推。代码

enum
{
    Zero,
    One,
    Two
}

就表示

#define Zero 0
#define One 1
#define Two 2

这是一种简洁化的表示。

在 C 语言中要这么写:

typedef enum {} Status; // 花括号内内容不变
出于笔者的习惯,当某个函数不需要返回值时,且可能出现各种错误类型时,才会使用 Status 来标识其操作状态。若返回值存在,使用其需求返回值的数据类型;若不会出现错误,则使用 void 类型。
此处的 Status 只是为了更方便地呈现函数运行中可能发生的错误,展现数据结构本身的原理。在实际的问题解决和情景中,一般不使用 Status,请在了解原理后自己根据题目修改,养成自己的代码风格和习惯。

接下来我们来创建魔法卡册。分析这个过程:首先,开辟出一片数组空间,其空间大小为 MAXSIZE,且首地址指向这个空间;然后,现在一张卡片都没放,所以 length 应当是 $0$。

// 创建顺序魔法卡册
Status InitList(SqList &L) // 这个新的空卡册的名字是 L
{
    L.elem = new ElemType[MAXSIZE]; // 分配四次元空间
    if (!L.elem)
        return ERROR; // 分配失败,创建失败
    L.length = 0;     // 现在一张卡都没放
    return OK;        // 创建成功
}
别忘了函数的格式:【函数类型】+【函数名字】+【传入参数】。
在这里,SqList &L 中的 & 是取地址的意思,代表如果函数中修改了该参数,那么会直接修改其本体,而不是仅在函数域内生效。
new 用于分配内存。

现在我们已经有了卡册,就要往里面装卡片了。在这个过程中,我们假设卡片是通过你来输入的。

// 创建一个线性表,填入 n 个元素
Status CreateList(SqList &L, int n)
{
    InitList(L);
    for (int i = 1; i <= n; i++)
    {
        if (L.length + 1 > MAXSIZE)
            return ERROR;
        cin >> L.elem[i];
        L.length++;
    }
    return OK;
}
代码在书写前是加上
using namespace std;
的,否则 cin 就要写成 std::cin 了。因为这里我们使用了 std 的命名空间。
这一串输入在 C 语言中要这么写(如果是 int 类型的话,其他数据类型请自己替换前面的格式符):
scanf("%d",&L.elem[i])

接下来,我们只需要把卡片装进去就好了……?等等,你的老板可不单单希望这个卡册只能放卡片,他有各种各样的需求!

使用魔法卡册 | 顺序表的操作

衡量操作时间:时间复杂度

你只是一个打工的牛马,老板的操作有时候很复杂,需要花掉你很多的时间;有时候则比较简单。为了衡量这个过程,你想要知道对于某一个操作,你需要花多长的时间来处理,我们称之为时间复杂度

关于时间复杂度的定义是复杂艰深的,我们不在此探讨。简单来说,对于 $n$ 张卡片,你的操作次数与 $n$ 越相关,你的时间复杂度越高。这里的相关,指的是随着 $n$ 的增长,你的操作次数的增长情况:例如指数增长、对数增长、平方增长……等等。

举例来说,如果每张卡片都有数字,你的老板想知道两张卡片上的数字:$a$ 和 $b$ 相加的结果 res,显然是

void algorithm() // algorithm 表示算法
{
    res = a + b;
}

无论有多少张卡片,都和这个结果的计算没有关系,我们说它的时间复杂度是 $O(1)$ 的。

但是如果你的老板想知道这 $n$ 张卡片每一张卡片上的数字相加的结果,就不一样了:

// 假设卡册的名字是 L
void algorithm()
{
    int res = 0;
    for (int i = 1; i <= n; i++)
        res += L[i-1];
    return res;
}

这个过程中,你的计算过程和 $n$ 是线性增长的,我们说它的时间复杂度是 $O(N)$。

出于笔者的习惯,在下文中,卡册的卡的数量,即顺序表的长度变量为 length但计算时间复杂度、书写公式等时仍说长度为 $n$
时间复杂度,只是我们衡量算法优劣的其中一个参考,它不是标准。在现实生活中,硬件、编译器优化、生产环境、数据规模……等等都会影响实际的用时,可能时间复杂度小的用时反而更大……读者在学习的过程中需要谨记这一点,不要过分纠结理论与现实的差别。

我们的思考方式

对待一种操作,我们首先要从老板的想法中抽象出关键。这时候就可以考虑有哪些需要的参数。

然后要思考状态的可能性,并想出什么情况下会触发这些状态例如:

  1. 错误:什么情况下会导致这个操作不可能实现
  2. 参数不合法:思考参数的范围等

最后是我们如何实现这种操作,必要的话计算该操作的时间复杂度,衡量最快的算法。

总结地总结我们的思考过程:

  1. 抽象题意
  2. 参数范围
  3. 错误可能
  4. 算法实现
  5. 时间复杂度

老板想要知道某个位置上的卡是什么:顺序表的取值/访问

笔者习惯用数组存储数据时下标从 $1$ 开始。读者可以自己将其转化成从 $0$ 开始或者自己喜欢的方式。只要知道原理即可。这个过程中直接把下标为 $0$ 的数组位置看作不存在。在后文顺序表的插入中需要尤为注意。

抽象题意:查询顺序表 Li 位置上的值

参数范围:考虑参数 i,因为下标从 $1$ 开始,长度为 length,因此 $1 \leq i \leq n$。

错误可能:无

算法实现:直接利用数组的操作读取

// 查询 L 中第 i 个位置上的元素的值,存储在 ans 中
Status GetElem(SqList &L, int i, ElemType &ans)
{
    if (i < 1 || i > L.length) // 判断询问是否合理
        return INFEASIBLE;     // 询问不合理,即参数不合法
    ans = L.elem[i];
    return OK;
}

时间复杂度:这个过程和 $n$ 无关,其时间复杂度为 $O(1)$。

老板想要修改某个位置上的卡:顺序表的修改

抽象题意:修改顺序表 Li 位置上的值,使其变为 val

参数范围:考虑参数 i,因为下标从 $1$ 开始,长度为 length,因此 $1 \leq i \leq n$。

错误可能:无

算法实现:直接利用数组的操作赋值

// 修改 L 中第 i 个位置的元素,使其变为 val
Status ChangeElem(SqList &L, int i, ElemType val)
{
    if (i < 1 || i > L.length)
        return INFEASIBLE;
    L.elem[i] = val;
    return OK;
}

时间复杂度:这个过程和 $n$ 无关,其时间复杂度为 $O(1)$。

老板想要找到某张特定的卡:顺序表的查找

抽象题意:查找顺序表 L 第一个值为 val 的元素的位置

参数范围:无

错误可能:可能找不到,这时候可以返回 $0$

算法实现:最简单(但不是最快)的方式是从第一张开始找,一直找到老板说的卡为止。考虑 for 循环

// 查找 L 中第 1 张值为 val 的元素的位置
// 将结果返回,如果找不到,返回 0
int LocateElem(SqList &L, ElemType val)
{
    for (int i = 1; i <= L.length; i++)
        if (L.elem[i] == val)
            return i;
    return 0;
}

时间复杂度:分析这个过程,在最糟糕的情况下,我们需要找完这个卡册每一张卡,这个过程与 $n$ 是线性增长的,因此其时间复杂度为 $O(N)$。或者说,因为程序中存在一个 for 循环,这个 for 循环贡献了至多 $n$ 次操作,因此时间复杂度为 $O(N)$。

老板想要在某个位置插入一张卡:顺序表的插入

抽象题意:在顺序表 Li 个位置插入值为 val 的元素

参数范围:$i$ 的取值范围应当为 $1 \leq i \leq n+1$,其中在第 $1$ 个位置和第 $n+1$ 个位置插入分别表示在卡册的最前面和最后面插入。

错误可能:如果现在卡册已经装满了,就绝对无法再插入新的卡片了,说明如果当前长度等于最大上限,就会错误

算法实现:插入分成两个部分:第一,目标位置修改,直接利用数组赋值即可;第二,为了保证数据的完整性,其他元素要后移,这个过程用 for 循环逐步赋值即可。值得注意的是,这个过程应该从后往前进行:比喻地说,如果从前往后,那么前一张卡就盖在第二张卡上面了,我们就不知道第二张卡是什么了。

// 在 L 第 i 个位置插入值为 val 的元素
Status InsertElem(SqList &L, int i, ElemType val)
{
    if (i < 1 || i > L.length + 1)
        return INFEASIBLE;
    if (L.length == MAXSIZE)
        return ERROR;
    for (int j = L.length; j >= i; j--) // 从最后一张卡到第 i 张卡,全体右移一位
        L.elem[j + 1] = L.elem[j];
    L.elem[i] = val;
    L.length++;
    return OK;
}

时间复杂度:题目中有一个从 $1$ 到 $n$ 的 for 循环,时间复杂度为 $O(N)$。

老板想要拿出一张卡:顺序表的删除

抽象题意:将顺序表 Li 个位置的元素删除

参数范围:$i$ 的取值范围应当为 $1 \leq i \leq n$

错误可能:无

算法实现:删除分成两个部分:第一,把 i+1length 位置上的元素直接左移,相当于覆盖前一个格子上的卡片;第二,修改 length 的值,使其减一

// 删除 L 第 i 个位置的元素
Status DeleteElem(SqList &L, int i)
{
    if (i < 1 || i > L.length)
        return INFEASIBLE;
    for (int j = i + 1; j <= L.length; j++)
        L.elem[j] = L.elem[j + 1];
    L.length--;
    return OK;
}

时间复杂度:同理,题目中有一个从 $1$ 到 $n$ 的 for 循环,时间复杂度为 $O(N)$。

顺序表总结

  • 顺序表使用一组连续的内存,来存储一组具有相同类型的数据。
  • 访问、改变元素的时间复杂度为 $O(1)$
  • 尾部插入、删除元素的时间复杂度为 $O(1)$
  • 普通情况插入、删除元素的时间复杂度为 $O(N)$

修改日志

2024.09.14
文章发布
2024.09.15
修改文章格式
修正了文章中部分【警告】和【备注】的格式,使其区别于正文。
增加图片
补充了顺序表的图片演示。
2024.09.17
修正文章错误
修正了文章中部分错别字。
2024.10.01
修改文章格式
修正了文章中段落层级的错误关系。

作者

觉得有帮助的话可以投喂煮啵,助力煮啵过上点外卖不用神卷的生活

本文作者:Flaw_Owl
看到这里的朋友们,作者十分感谢!如果你看到任何写得不清楚或是错误之处,或者是有什么希望看到的课程和内容笔记,欢迎在评论区留言!评论区可以单纯的发表文字,也可以使用 Markdown 语法,你可以参照 Markdown 官方教程发出带有格式的评论。

版权说明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0协议,转载请注明文章地址及作者哦

评论

  1. Jock
    Macintosh Edge 129.0.0.0
    已编辑
    2 月前
    2024-10-21 21:49:13

    每一口都是优质好奶ヾ(≧∇≦*)ゝ

    来自北京

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇