顺序表
对于大部分的数据结构,需要实现的基本功能即增删改查。
顺序表可以理解成更智能的数组,在C/C++中使用内置类型的数组有的时候是一件很难抉择的事情,因为可能开大了会造成空间浪费,开小了不够存储。
于是顺序表应运而生,顺序表可以默认为我们开好一段好的内存,并且支持动态扩容。
操作
在正式写操作之前,我们先想好对于一个顺序表应当关注它的哪些因素:
1、首先最重要的我们得知道咱们的元素存在哪儿吧,所以一个指向存储数据的堆空间的指针是必要的
2、我们什么时候知道要扩容了呢?当表内元素数量和表的容量相等的时候需要扩容,于是一个用来记录元素数量的字段size 以及 容量的字段capacity是必要的
根据上述,我们可以对顺序表做出如下封装:
typedef int SeqType; // 顺序表存储数据的类型,暂定为int
typedef struct SeqList
{
SeqType* ptr;
int size;
int capacity;
}SQ; // C语言的结构体和它的名称为一体不可分割,为了方便用typedef重命名一下
1、初始化
默认一下顺序表的初始长度为defalut_size,通过宏来定义它:
#define default_size 5
下面就是通过这个宏,对顺序表做出的初始化
void Init(SQ* pSeq)
{
pSeq->ptr = (SeqType*)malloc(sizeof(SeqType) * default_size);// 向堆上申请内存
if(!pSeq->ptr)
{
// 处理一下异常
perror("malloc failed\n");
return;
}
pSeq->size = 0;
p->capacity = default_size;
}
2、销毁
不再使用一个顺序表时,一定要记得手动销毁,否则内存泄漏的问题就出来了(初始化以及后续的扩容都是在堆上申请空间)
void DesTroy(SQ* pSeq)
{
free(pSeq->ptr);
pSeq->ptr = NULL;
p->size = p->capacity = 0;
}
3、增
在往顺序表里面增加元素之前,咱们先带着顺序表底层是一个动态数组的想法去思考思考。
1、既然是插入,首先咱们得考虑考虑扩容的问题,如果
pSeq->size == pSeq->capacity
那我们就得扩容了
2、直接插入元素会面临什么问题?如果是在尾部插入元素,在判断需不需要扩容的逻辑之后直接在size位置处插入即可。
那要是在头部或者 [0, size - 1] 位置处拆入数据呢,直接插入必然会导致旧数据被覆盖的问题
3.1 尾插(push_back)
void push_back(SQ* pSeq,SeqType val)
{
assert(pSeq);
if(pSeq->size == pSeq->capacity)
{
SeqType* tmp = (SeqType*)realloc(sizeof(SeqType) * pSeq->capacity * 2);
if(!tmp)
{
perror("realloc failed\n");
return;
}
pSeq->ptr = tmp;
pSeq->capacity *= 2;
}
pSeq->ptr[size++] = val;
}
3.2 任意位置插入
我们把要插入的位置设为pos,要插入pos位置处,就要将 [pos, size - 1] 这个区间内所有的数据整体向后移动一位
在这里有个小细节,仍然是为了防止数据被覆盖的问题,我们是从pos开始移动数据,还是从size-1位置处开始移动数据?
答案 是从size-1处移动,否则仍然会导致数据被覆盖的问题
void push(SQ* pSeq, int pos, SeqType val)
{
assert(pSeq);
if (pos < 0 || pos > p->size)
{
printf("插入位置非法");
return;
}
if (pSeq->size == pSeq->capacity)
{
SLDatatype* tmp = (SLDatatype*)realloc(pSeq->ptr, sizeof(SeqType) * pSeq->capacity * 2);
if (tmp == NULL)
{
perror("realloc");
return;
}
memcpy(tmp, pSeq->ptr, sizeof(SeqType) * pSeq->capacity);
pSeq->ptr = tmp;
pSeq->capacity *= 2;
}
int i = 0;
for (i = pSeq->size - 1; i > pos - 1; --i)
{
pSeq->ptr[i] = pSeq->ptr[i - 1];
}
pSeq->ptr[pos] = x;
pSeq->size++;
}
在不考虑扩容的前提下,很容易能看出顺序表的插入的时间复杂度最坏是O(N)(非尾插), 最好是O(1)
4、删
需要指出的是,顺序表的底层仍然是一段连续的空间,我们删除其中的数据并不是字面意义上的 “删除”,在计算机内部,删除 和 可以被覆盖 是一个概念,所以我们删除pos位置处的数据是用其后的所有数据向前整体移动一位。
4.1 尾删(pop_back)
void pop_back(SQ* pSeq)
{
assert(pSeq);
pSeq->size--;
}
4.2 任意位置删除(pop)
和任意位置插入一样,也是挪动数据实现删除
void pop(SQ* pSeq, int pos)
{
assert(pSeq);
if(pos < 0 || pos >= pSeq->size)
{
printf("位置非法");
return;
}
for(int i = pos;i < pSeq->size -1;++i)
{
pSeq->ptr[i] = pSeq->ptr[i + 1];
}
pSeq->size--;
}
5、改
顺序表的修改很简单,给一个合法的位置,将该位置处的数据置为需要修改的数据即可
void Change(PQ* pSeq, int pos, SeqType val)
{
assert(pSeq);
if(pos < 0 || pos >= pSeq->size)
{
printf("位置非法");
return;
}
pSeq->ptr[pos] = val;
}
6、查
6.1 按下标查找(O(1))
SeqType FindPos(SQ* pSeq, int pos)
{
assert(pSeq);
if(pos < 0 || pos >= pSeq->size)
{
printf("位置非法");
return;
}
return pSeq->ptr[pos];
}
6.2 按值查找,返回下标
SeqType FindVal(SQ* pSeq, SeqType val)
{
for(int i = 0;i < pSeq->size;++i)
{
if(pSeq->ptr[i] == val)
return i;
}
return -1; // 没找到,返回一个非法的下标
}
总结:
关于顺序表:
底层实现:顺序表的底层通常实现为动态数组,这意味着它可以根据需要自动调整容量。当数组填满时,它会创建一个更大的数组,并将所有元素从旧数组复制到新数组中,这个过程的时间复杂度是O(N)。
1、查找性能:顺序表支持通过索引快速访问元素,时间复杂度为O(1)。但如果是按值查找元素,而不是索引,那么需要遍历整个数组,时间复杂度为O(N)。
2、增加元素:向顺序表的末尾添加一个元素通常是一个较快的操作,平均时间复杂度为O(1),因为仅当数组需要扩容时才会变为O(N)。但如果在数组的开始或中间插入元素,需要移动后续所有元素以空出位置,这时的时间复杂度为O(N)。
3、删除元素:与插入操作类似,从数组末尾删除元素通常很快,平均时间复杂度为O(1)。但从数组的开始或中间删除元素同样需要移动后续所有元素以填补空位,时间复杂度为O(N)。
4、空间效率:虽然动态数组可以根据需要调整大小,但这种调整可能导致额外的内存空间被暂时占用,特别是在数组缩小时。此外,为了避免频繁调整大小,通常会预留一些额外空间,这也可能导致空间利用率不是最优的。
总的来说,顺序表的使用场景,适合于插入和删除操作不频繁,而查找操作较多的情况。