线性表---顺序表概述及应用
目录
基本认识
它具体是怎么存储的
它有何特点
主要优点
主要缺点
顺序表的基本实现
1.建立顺序表
2.顺序表的常用算法
1. 初始化顺序表 InitList
2. 销毁顺序表 DestroyList
3. 判断空表 ListEmpty
4. 获取表长 ListLength
5. 输出所有元素 DisplayList
6. 获取指定位置元素 GetElem
7. 查找元素位置 LocateElem
8. 插入元素 ListInsert
9. 删除元素 ListDelete
总结与注意事项
简单应用实例
问题1:
解法一:
解法二:
小总结
问题2:
解法一:
解法二:
小总结
问题3:
解法一:
解法二:
小总结
本文重点讲述线性表中的顺序表存储结构及基本的实现过程,包含我自身的心得及思考,愿诸位能相比书上内容更好理解。
基本认识
什么是顺序表?按一般的官方定义来看,线性表的顺序存储结构简称为顺序表,也就是一种采用连续存储空间实现线性表的数据结构,逻辑上相邻的元素,其物理存储位置也相邻。
说白了你可以想象为:
- 电影院连续座位:所有座位号是连续的,你可以凭票号(索引)直接找到自己的位置。
- 火车车厢:每节车厢顺序连接,乘客(数据)按票号顺序就坐。
它具体是怎么存储的
顺序表在内存中占用一块连续的内存空间。假设我们有一个存储语文,数学,英语的顺序表,其物理存储可能如下所示:
存储空间地址 | 数组下标 | 存储元素 |
---|---|---|
0x001 | 0 | 语文 |
0x002 | 1 | 数学 |
0x003 | 2 | 英语 |
0x004 | 3 | 空 |
0x005 | 4 | 空 |
可以看到,元素的逻辑顺序(语文→数学→英语)与它们在内存中的物理存储顺序完全一致。每个元素的地址是连续的,并且可以通过 起始地址 + 下标 × 元素大小
的公式直接计算得出,无需遍历
它有何特点
主要优点
- 1. 高效的随机访问:由于存储空间连续,可以通过下标直接访问任何位置的元素,时间复杂度为 O(1)。这是顺序表最核心的优势。
- 2.高存储密度:顺序表只存储元素本身的数据,不需要像链表那样为指针额外分配存储空间,因此内存利用率较高。
- 3. 实现简单:基于数组构建,操作逻辑相对直观,易于理解和实现。
主要缺点
- 插入删除效率较低:在中间或头部进行插入或删除操作时,为了保持元素的连续性,需要移动大量元素。平均时间复杂度为 O(n)。
- 容量固定,不够灵活:静态顺序表的大小在编译时就已确定,难以适应动态变化的数据量。动态顺序表虽然可以扩容,但扩容操作本身(申请新空间、复制数据、释放旧空间)耗时且带来额外开销。
- 可能浪费内存:如果预先分配的空间远大于实际需要,会造成内存闲置;如果分配不足,又可能频繁扩容影响性能。
顺序表的基本实现
前面都是些令人烦躁的又臭又长的定义及概念,说重要不重要,说不重要它又对其顺序表的理解起了特别大的作用,但学习是为了完成实现的需要,也就是实践。
1.建立顺序表
这里我们来学习整体创建顺序表,即由数组元素a[0..n-1]创建顺序表L。说白了就是把数组a依次放进顺序表中,并将n赋给顺序表当长度域。现在我们来看看怎么实现:
首当其冲我们应该整一个也就是声明一个顺序存储类型:
typedef struct
{int data[MaxSize];int length;
}SqList;
这个结构体应该不难理解,定义一个data数组来存储元素,length来存储长度。
data数据内容也并非是int类型的,大家想存什么就用什么类型。
大多都是在开头搞一个“typedef int ElemType”来方便类型的改变。
接下来就是具体算法:
void CreateList(SqList *&L,int a[],int n)
{int i=0,k=0; //i用来存放a的逻辑地址,k来存放L的个数;L=(SqList *)malloc(sizeof(SqList)); //分配存放线性表的空间while(i<n)//依次循环存放数值a{L->data[k]=a[i];k++;i++;}L->length=k;//存入长度
}
大致应该不难看懂,就是这么个实现过程,也就是为什么L前面要加个&可能有些人就不知道,这个&符号是C++语言中提供的一种引用运算符,说白了就是实参会随形参的变化而变化。
我们就来举个例子看看
int a=4;//声明变量
int b=8;
void swap(int &x,int &y)
{int tmp=x;x=y;y=tmp;
}
其中的形参x,y前面都有“&”,那么我们执行swap(a,b)时,实参a=8,b=4。大致就是这么个意思。
2.顺序表的常用算法
下面我用通俗易懂的方式逐个解释顺序表最基本的函数。
1. 初始化顺序表 InitList
void InitList(SqList *&L) {L = (SqList *)malloc(sizeof(SqList*));L->length = 0;
}
-
作用:为顺序表申请一块内存空间,并设置初始长度为0,准备开始使用。
-
通俗理解:就像你去图书馆占一张空桌子(申请内存),并且桌子上还没有书(
length=0
)。 -
注意:这里有一个小问题。
sizeof(SqList*)
计算的是指针的大小,而不是整个SqList
结构体的大小。这可能会导致分配的内存空间不足。通常应该写成sizeof(SqList)
。
2. 销毁顺序表 DestroyList
void DestroyList(SqList *&L) {free(L); //释放L的空间
}
-
作用:释放顺序表所占用的内存空间。
-
通俗理解:离开图书馆,把之前占的桌子还给管理员,以便他人使用。
-
注意:如果顺序表内部还指向其他动态内存(在你的代码中,
data
是静态数组,所以没问题),需要先释放内部内存再释放表本身。
3. 判断空表 ListEmpty
bool ListEmpty(SqList *L) {return (L->length == 0);
}
-
作用:检查顺序表是否为空(没有任何元素)。
-
通俗理解:看看桌子上是不是一本书都没有。
4. 获取表长 ListLength
int ListLength(SqList *L) {return (L->length);
}
-
作用:返回顺序表中当前元素的个数。
-
通俗理解:数数桌子上现在有多少本书。
5. 输出所有元素 DisplayList
void DisplayList(SqList *L) {for (int i = 0; i < L->length; i++) {cout << L->data[i] << endl;}
}
-
作用:从第一个元素到最后一个元素,逐个打印出顺序表的所有内容。
-
通俗理解:把桌子上所有的书的名字从头到尾念一遍。
6. 获取指定位置元素 GetElem
bool GetElem(SqList *L, int i, int e) {if (i < 1 || i > L->length)return false;e = L->data[i - 1];return true;
}
-
作用:获取顺序表中第
i
个位置上的元素值,并存入变量e
。 -
通俗理解:直接看看第
i
本书的书名是什么。位置i
指的是逻辑上的第几本(从1开始数),而数组下标是从0开始的,所以要用i-1
。 -
注意:这个函数有一个问题。参数
e
应该是引用传递(int &e
)或者指针传递(int *e
),否则对形参e
的赋值无法传递给调用处的实参。现在这样写是无效的。
7. 查找元素位置 LocateElem
int LocateElem(SqList *L, int e) {int i = 0;while (i < L->length && e != L->data[i])i++;if (i >= L->length) // 原代码是 i>L->length,但i最大等于L->length-1,所以应改为>=return 0;elsereturn i + 1;
}
-
作用:在顺序表中查找某个值
e
,如果找到,返回它是第几个元素(位序);如果找不到,返回0。 -
通俗理解:从第一本书开始,一本一本地找书名叫《e》的书,找到后就告诉它是第几本。如果所有书都翻遍了还没找到,就说没有这本书。
-
小修改:循环条件
i < L->length
已经确保i
不会等于L->length
,所以后面的判断条件if (i > L->length)
永远不会为真。通常查找失败返回0,所以条件可以改为if (i >= L->length)
。
8. 插入元素 ListInsert
bool ListInsert(SqList *&L, int i, int e) {int j;if (i < 1 || i > L->length + 1 || L->length == MaxSize)return false;i--;for (j = L->length; j > i; j--) {L->data[j] = L->data[j - 1];}L->data[i] = e;L->length++;return true;
}
-
作用:在顺序表的第
i
个位置之前插入一个新元素e
。 -
通俗理解:假设桌子上有5本书排成一排,你想在第3本书前面插入一本新书。那么你需要先把第3本、第4本、第5本书都往后挪一个位置,腾出第三个位置的空位,才能把新书放进去。插入后书的总数(
length
)就加1了。 -
关键点:
-
合法性检查:插入位置
i
必须在1
到length+1
之间(可以在最后面追加)。并且表不能满 (L->length == MaxSize
)。 -
移动元素:从最后一个元素开始,到第
i
个元素为止,依次向后移动一个位置,为新元素腾出空间 ``。这是顺序表插入操作开销最大的地方。 -
位置转换:逻辑位序
i
转换成数组下标需要i-1
。
-
9. 删除元素 ListDelete
bool ListDelete(SqList *&L, int i, int e) {int j;if (i < 1 || i > L->length)return false;i--;e = L->data[i];for (j = i; j < L->length - 1; j++)L->data[j] = L->data[j + 1];L->length--;return true;
}
-
作用:删除顺序表中第
i
个位置的元素,并通过e
返回被删除元素的值。 -
通俗理解:假设你想拿走桌子上的第3本书。你先把这本书的名字记下来(
e = L->data[i]
),然后把第4本、第5本书都往前挪一个位置,覆盖掉第三本书的位置。这样原来第三本书就被“删除”了,书的总数(length
)也减1了。 -
关键点:
-
合法性检查:删除位置
i
必须在1
到length
之间(只能删除已有的元素)。 -
移动元素:从第
i+1
个元素开始,到最后一个元素为止,依次向前移动一个位置,覆盖掉被删除的元素 ``。这是顺序表删除操作开销最大的地方。 -
注意:和
GetElem
类似,这里的参数e
也应该是引用传递(int &e
)或指针传递(int *e
),否则无法将删除的值带回去。
-
总结与注意事项
-
核心特点:顺序表的最大优点是随机访问快(通过下标直接定位,时间复杂度O(1)),但插入和删除效率相对较低(需要移动元素,平均时间复杂度O(n))。
-
常见错误:
-
InitList
中的malloc
参数应该是sizeof(SqList)
。 -
GetElem
和ListDelete
中的参数e
需要改为引用或指针传递才能真正改变实参。 -
注意数组下标从0开始,而逻辑上的位序是从1开始的,所有涉及到位置的操作基本都需要
i-1
进行转换。 -
边界条件:时刻注意检查位置参数的合法性(是否在有效范围内)以及表是否已满或已空。
-
以上就是顺序表最常见的9种用法,对于我们初学者要注意的是逻辑序号和物理序号的区别,不然容易搞懵。
简单应用实例
问题1:
设计一个算法,删除其中所有值等于x的元素,要求时间复杂度为O(n),空间复杂度为O(1)。
解法一:
特性 | 整体建表法 (delnode1) |
---|---|
核心思想 | 重建新表:遍历原表,只保留不等于 |
指针角色 |
|
终止条件 |
|
最终长度 |
|
通俗比喻 | 淘金:把沙子(x)筛掉,只收集金子(非x)放到新桶里。 |
void delnode1(SqList *&L,int x)//整体建表法,删除与x相等的值
{int k=0,i;for(i=0;i<L->length;i++){if(x!=L->data[i]){L->data[k]=L->data[i];k++;}}L->length=k;
}
解法二:
特性 | 元素移动法 (delnode2) |
---|---|
核心思想 | 覆盖前进:遍历原表,跳过等于 |
指针角色 |
|
终止条件 |
|
最终长度 |
|
通俗比喻 | 填坑:遇到一个坑(x)就记下来,后面的石头(非x)往前填这些坑。 |
void delnode2(SqList *&L,int x)
{int k=0,i=0;while(i<L->length){if(L->data[i]==x)k++;elseL->data[i-k]=L->data[i];i++;}L->length-=k;
}
小总结
- 整体建表法 (delnode1) 更直观,容易理解和实现。它明确地“构建”了一个只包含所需元素的新表。
- 元素移动法 (delnode2) 的思维更巧妙。它通过累计的偏移量
k
动态计算每个保留元素应该存放的最终位置。 - 虽然在这个特定问题上两种方法效率相同,但整体建表法的逻辑通常更不容易出错,尤其是在更复杂的条件判断下。
- 这两种方法都体现了双指针技巧的精髓(一个指针
i
用于遍历,另一个指针k
用于指示下一个有效位置或记录偏移),这是解决许多线性表问题的关键。
问题2:
设计一个算法,以第一个元素为分界线,将所有小于或等于它的元素移到该分界线前面,反之移到后面。
解法一:
特性 | Partition1 (交换法) |
---|---|
核心思想 | 双向扫描,及时交换:从两端向中间扫描,发现不符合位置条件的元素对就交换。 |
基准处理 | 始终参与比较和可能的交换 |
元素移动 | 交换 |
终止条件 | 两指针相遇( |
最终操作 | 交换基准与相遇点元素 |
通俗比喻 | 两头分类交换:像两个人从两头一起检查,不对的就互相交换手里的物品。 |
void partition1(SqList *&L)
{int i=0, j=L->length-1; // i 指向头(基准的下一个方向),j 指向尾while (i < j){// 先从右往左找第一个**不大于**基准的元素(<=基准)while (i<j && L->data[j] > L->data[0])j--;// 再从左往右找第一个**大于**基准的元素(>基准)while (i<j && L->data[i] <= L->data[0])i++;// 如果左右指针未相遇,交换这两个元素if (i < j)swap(L->data[i], L->data[j]);}// 最后将基准元素交换到中间位置swap(L->data[0], L->data[i]);
}
解法二:
特性 | Partition2 (空位填充法) |
---|---|
核心思想 | 空位填充:备份基准,先从右找小于基准的填左空位,再从左找大于基准的填右空位,最后基准归位。 |
基准处理 | 先备份,原位置视为空位,最后放入正确位置 |
元素移动 | 覆盖 |
终止条件 | 两指针相遇( |
最终操作 | 将基准值放入相遇空位 |
通俗比喻 | 挖坑填数:先挖走基准(坑),从另一边找合适的土(数)来填,最后把基准填到最后的坑里。 |
void partition1(SqList *&L)
{int i=0, j=L->length-1; // i 指向头(基准的下一个方向),j 指向尾while (i < j){// 先从右往左找第一个**不大于**基准的元素(<=基准)while (i<j && L->data[j] > L->data[0])j--;// 再从左往右找第一个**大于**基准的元素(>基准)while (i<j && L->data[i] <= L->data[0])i++;// 如果左右指针未相遇,交换这两个元素if (i < j)swap(L->data[i], L->data[j]);}// 最后将基准元素交换到中间位置swap(L->data[0], L->data[i]);
}
小总结
- 共同点:两种算法的时间复杂度都是 O(n),空间复杂度都是 O(1),且都非常高效。
- 主要区别:
partition1
在扫描过程中通过交换元素来调整位置。partition2
通过覆盖来填充空位,最后才将基准归位,交换次数更少(理论上比partition1
少一半),通常性能稍优一些。
- 注意事项:
- 循环条件
i < j
必须严格,否则会越界或错误。 - 内层循环的条件
L->data[j] > pivot
和L->data[i] <= pivot
要注意包含等号的情况,以确保划分的正确性。 - 必须先进行 j 的扫描,再进行 i 的扫描,以确保最后
i
和j
相遇的位置是一个小于等于基准的元素,这样才能正确安置基准。
- 循环条件
- 为何是快速排序的核心:这个操作一次能将一个元素(基准)放到其最终位置,并将数据集分成两个子集,然后再递归地对子集进行同样的操作,这正是快速排序“分治”思想的体现
问题3:
将一个顺序表中所有的奇数移到偶数前面。
解法一:
特性 | Move1 (两端扫描交换法) |
---|---|
核心思想 | 两端逼近,交换无效元素:从两端向中间扫描,左指针找偶数,右指针找奇数,交换它们。 |
指针含义 |
|
终止条件 | 两指针相遇 ( |
交换条件 | 左指针找到偶数且右指针找到奇数时 ( |
通俗比喻 | 两头分类交换:像两个人从两头一起检查,不对的就互相交换手里的物品。 |
void move1(SqList *&L) //将所有奇数项移到偶数项前面
{int i = 0, j = L->length - 1; // i从头部开始,j从尾部开始while (i < j) // 当i和j未相遇时循环{while (i < j && L->data[j] % 2 == 0) // 从右往左找奇数j--;while (i < j && L->data[i] % 2 != 0) // 从左往右找偶数i++;if (i < j) // 如果找到了且i仍在j左边swap(L->data[i], L->data[j]); // 交换这两个元素}
}
具体过程如下:
解法二:
特性 | Move2 (奇数分区法) |
---|---|
核心思想 | 单遍扫描,扩展奇数区:从左到右扫描,维护一个“奇数区”,将遇到的奇数交换到该区域的末尾。 |
指针含义 |
|
终止条件 | 遍历完所有元素 ( |
交换条件 | 当 |
通俗比喻 | 收集奇数:像玩扑克时,把所有奇数牌一张张抽出来放到左手(奇数区),右手继续翻剩下的牌。 |
void move2(SqList *&L) //此乃奇数分域法
{int i = -1, j = 0; // i是奇数区的边界,初始为-1;j用于遍历for (j; j < L->length; j++) // 遍历整个顺序表{if (L->data[j] % 2 == 1) // 如果当前元素是奇数{i++; // 奇数区的边界向右扩大一位if (j != i) // 如果当前奇数不在它该在的位置上swap(L->data[i], L->data[j]); // 就把它交换到奇数区的末尾}}
}
具体实现如下:
小总结
方面 | Move1 (两端扫描交换法) | Move2 (奇数分区法) |
---|---|---|
时间复杂度 | O(n) | O(n) |
空间复杂度 | O(1) | O(1) |
交换次数 | 相对较多(每次找到一对交换一次) | 相对较少(每个奇数最多交换一次) |
特点 | 思路直观,类似快速排序的分区操作 | 代码简洁,一次遍历,类似插入排序的思想 |
适用场景 | 通用 | 尤其适用于奇数较少的情况,交换次数少 |
总的来说我感觉跟问题二有异曲同工之处,大差不差,搞懂问题二的应用也就水到渠成了。
总的来说顺序表的基本用法就是这么多了,当然还用更多的用法就需要大家自己去刷题学习了。
请大家点点关注和点赞,后面我一定会分享更多实用的项目的