第1章 线性表之顺序表:从零开始构建你的第一个数据结构
大家好,我是你们的老朋友。今天咱们来聊聊线性表里最基础、也最常用的一种实现——顺序表。
说实话,我刚开始学数据结构那会儿,觉得顺序表不就是个数组嘛,有啥好学的?后来在项目中吃过亏才明白——数组是语言提供的,顺序表是自己设计的。这两者之间,差着工程思维的距离。
1.1 什么是顺序表?
顺序表,说白了就是用一块连续的内存空间来存储数据元素。元素一个挨一个排好,逻辑上相邻,物理上也相邻。
你想想看,这跟数组有什么区别?
嗯,区别在于:数组是静态的,顺序表是动态的。数组的长度一旦定义就不能变了,而顺序表可以动态扩容。这就是为什么我们在工程中更倾向于用顺序表——你永远不知道用户会塞多少数据进来。
核心特征:
- 元素在内存中连续存储
- 支持随机访问(O(1)时间复杂度)
- 插入和删除需要移动元素(O(n)时间复杂度)
- 可以动态扩容
1.2 顺序表的定义与初始化
先来看看怎么定义一个顺序表。我个人习惯用结构体来封装,这样数据和方法都在一起,清晰明了。
#define INIT_CAPACITY 10 // 初始容量
typedef struct {
int *data; // 指向动态数组的指针
int length; // 当前元素个数
int capacity; // 当前最大容量
} SeqList;
这里要注意:data是一个指针,它指向堆上分配的内存。length是实际存了多少个元素,capacity是当前能存多少个。
初始化函数怎么写?
// 初始化顺序表
int initList(SeqList *list) {
list->data = (int *)malloc(INIT_CAPACITY * sizeof(int));
if (list->data == NULL) {
return -1; // 内存分配失败
}
list->length = 0;
list->capacity = INIT_CAPACITY;
return 0;
}
小提示:初始化时一定要检查malloc的返回值。我曾经在一个项目中忘了检查,结果线上环境内存不足时直接段错误,排查了半天才发现是这里的问题。
1.3 插入操作
插入操作是顺序表的核心操作之一。它的逻辑是:在指定位置插入一个新元素,该位置之后的元素全部后移一位。
// 在位置pos插入元素value(pos从0开始)
int insertList(SeqList *list, int pos, int value) {
// 检查位置合法性
if (pos < 0 || pos > list->length) {
return -1; // 位置不合法
}
// 检查是否需要扩容
if (list->length >= list->capacity) {
if (expandList(list) != 0) {
return -2; // 扩容失败
}
}
// 从后往前移动元素
for (int i = list->length; i > pos; i--) {
list->data[i] = list->data[i - 1];
}
// 插入新元素
list->data[pos] = value;
list->length++;
return 0;
}
为什么从后往前移动?你想想看,如果从前往后移,前面的元素会把后面的覆盖掉,数据就丢了。这个细节,我当年面试的时候就答错过。
注意:插入位置pos的范围是[0, length]。如果pos等于length,相当于在末尾追加,不需要移动元素。如果pos大于length,那就是非法操作了。
1.4 删除操作
删除操作和插入相反:把指定位置的元素删掉,后面的元素全部前移一位。
// 删除位置pos的元素,通过value返回被删除的值
int deleteList(SeqList *list, int pos, int *value) {
// 检查位置合法性
if (pos < 0 || pos >= list->length) {
return -1;
}
// 取出被删除的元素
*value = list->data[pos];
// 从前往后移动元素
for (int i = pos; i < list->length - 1; i++) {
list->data[i] = list->data[i + 1];
}
list->length--;
return 0;
}
这里有个小细节:删除后我们并没有释放内存,只是把length减1了。为什么?因为后面可能还要插入新元素,留着空间可以避免频繁扩容。这是一种空间换时间的策略。
1.5 查找操作
查找操作分两种:按值查找和按位置查找。
按位置查找很简单,直接通过下标访问就行,时间复杂度O(1)。
// 按位置查找
int getElement(SeqList *list, int pos, int *value) {
if (pos < 0 || pos >= list->length) {
return -1;
}
*value = list->data[pos];
return 0;
}
按值查找需要遍历整个表,时间复杂度O(n)。
// 按值查找,返回第一个匹配的位置
int findElement(SeqList *list, int value) {
for (int i = 0; i < list->length; i++) {
if (list->data[i] == value) {
return i;
}
}
return -1; // 没找到
}
1.6 动态扩容策略
这是顺序表最精彩的部分。当表满了怎么办?我们需要申请一块更大的内存,把旧数据搬过去。
// 扩容:每次扩容为原来的2倍
int expandList(SeqList *list) {
int newCapacity = list->capacity * 2;
int *newData = (int *)realloc(list->data, newCapacity * sizeof(int));
if (newData == NULL) {
return -1; // 扩容失败
}
list->data = newData;
list->capacity = newCapacity;
return 0;
}
为什么扩容倍数是2?不是1.5?不是3?
这里面有讲究:
- 如果扩容倍数太小(比如1.1),会频繁触发扩容,性能差
- 如果扩容倍数太大(比如3),会浪费大量内存
- 2倍是一个折中方案,时间效率和空间效率都比较均衡
我的经验:在嵌入式系统中,内存比较金贵,我一般用1.5倍扩容。在服务器端,内存充裕,用2倍扩容更省时间。没有银弹,要根据场景选。
知识体系总览
下面这张图把顺序表的核心知识点串起来了,你可以对照着复习:
完整代码示例
最后,我把所有操作整合成一个完整的示例,方便你直接运行测试:
#include <stdio.h>
#include <stdlib.h>
#define INIT_CAPACITY 10
typedef struct {
int *data;
int length;
int capacity;
} SeqList;
// 初始化
int initList(SeqList *list) {
list->data = (int *)malloc(INIT_CAPACITY * sizeof(int));
if (list->data == NULL) return -1;
list->length = 0;
list->capacity = INIT_CAPACITY;
return 0;
}
// 扩容
int expandList(SeqList *list) {
int newCapacity = list->capacity * 2;
int *newData = (int *)realloc(list->data, newCapacity * sizeof(int));
if (newData == NULL) return -1;
list->data = newData;
list->capacity = newCapacity;
return 0;
}
// 插入
int insertList(SeqList *list, int pos, int value) {
if (pos < 0 || pos > list->length) return -1;
if (list->length >= list->capacity) {
if (expandList(list) != 0) return -2;
}
for (int i = list->length; i > pos; i--) {
list->data[i] = list->data[i - 1];
}
list->data[pos] = value;
list->length++;
return 0;
}
// 删除
int deleteList(SeqList *list, int pos, int *value) {
if (pos < 0 || pos >= list->length) return -1;
*value = list->data[pos];
for (int i = pos; i < list->length - 1; i++) {
list->data[i] = list->data[i + 1];
}
list->length--;
return 0;
}
// 按位置查找
int getElement(SeqList *list, int pos, int *value) {
if (pos < 0 || pos >= list->length) return -1;
*value = list->data[pos];
return 0;
}
// 按值查找
int findElement(SeqList *list, int value) {
for (int i = 0; i < list->length; i++) {
if (list->data[i] == value) return i;
}
return -1;
}
// 打印
void printList(SeqList *list) {
printf("[");
for (int i = 0; i < list->length; i++) {
printf("%d", list->data[i]);
if (i < list->length - 1) printf(", ");
}
printf("]\n");
}
// 销毁
void destroyList(SeqList *list) {
free(list->data);
list->data = NULL;
list->length = 0;
list->capacity = 0;
}
int main() {
SeqList list;
initList(&list);
// 插入测试
insertList(&list, 0, 10);
insertList(&list, 1, 20);
insertList(&list, 2, 30);
insertList(&list, 1, 15); // 在中间插入
printList(&list); // [10, 15, 20, 30]
// 删除测试
int val;
deleteList(&list, 2, &val);
printf("删除的元素: %d\n", val); // 20
printList(&list); // [10, 15, 30]
// 查找测试
int pos = findElement(&list, 15);
printf("15的位置: %d\n", pos); // 1
destroyList(&list);
return 0;
}
避坑指南:我曾经在项目中忘记调用destroyList释放内存,导致内存泄漏。虽然程序退出时系统会回收,但如果是长期运行的服务,内存会越占越多。记住:malloc和free要成对出现。
好了,顺序表的内容就讲到这里。代码不多,但每个细节都值得你亲手敲一遍。下一章咱们聊聊链表,那又是另一番天地了。
公众号:蓝海资料掘金营,微信deep3321