要理解为什么SequentialList myList;不用加*,需要从变量的类型本质、内存存储方式以及函数参数的设计意图三个层面来分析:
一、先明确两个核心概念:普通变量 vs 指针变量
C++ 中变量的声明决定了它的内存形态和使用方式:
1. SequentialList myList;:声明结构体的实际实例(普通变量)
- 这行代码在栈内存中创建了一个SequentialList类型的完整实例,它本身占据实际的内存空间,包含三个成员:
- eleType* elements(指针成员)
- int capacity(整型成员)
- int size(整型成员)
- 你可以直接通过点运算符(.)访问它的成员(比如myList.size),也可以通过&myList获取它的内存地址(这是一个SequentialList*类型的指针)。
2. SequentialList* myList;:声明指向结构体的指针变量
- 这行代码只在栈内存中创建了一个指针变量(大小通常为 4/8 字节,取决于系统),它的作用是存储一个内存地址,但本身并不是结构体实例。
- 此时myList是一个野指针(初始值随机),它没有指向任何有效的SequentialList实例。如果直接使用myList->capacity,会访问非法内存,导致程序崩溃。
- 若要使用指针,必须先为它分配有效的实例,比如:cpp
运行
SequentialList* myList = new SequentialList(); // 堆上创建结构体实例,指针指向该实例
二、结合代码中函数的参数设计分析
代码中的函数(如initializeList、insertElement等)的参数都是SequentialList* list(结构体指针),这是为了实现对原结构体实例的修改(C++ 函数参数默认是值传递,传指针可以避免拷贝,且能修改原变量)。
但这并不意味着我们必须声明指针变量,因为:
- 对于普通变量myList,我们可以通过&myList(取地址运算符)获取它的地址,这个地址的类型正好是SequentialList*,可以直接传给函数。
比如:initializeList(&myList, 10); 就是把myList的地址传给initializeList的list参数,函数内部通过指针list操作的就是外部的myList实例。
如果我们声明的是指针变量SequentialList* myList;,反而需要额外的初始化步骤(否则是野指针),这会增加代码的复杂度,而这里完全没有必要。
三、两种写法的内存对比
| 写法 | 内存位置 | 是否是有效实例 | 函数调用方式 | 内存管理 |
| SequentialList myList; | 栈 | 是(完整实例) | initializeList(&myList, 10); | 栈内存由系统自动释放 |
| SequentialList* myList; | 栈(指针) | 否(野指针) | 需先myList = new SequentialList();,再initializeList(myList, 10); | 堆内存需手动delete myList; |
四、总结:为什么这里不用加*?
- 需求是 “结构体实例” 而非 “指针”:我们需要的是一个能存储capacity、size和elements的实际结构体对象,而SequentialList myList;直接创建了这个对象,满足需求。
- 传地址即可适配指针参数:函数需要SequentialList*参数,通过&myList取普通变量的地址,就能完美匹配参数类型,无需额外声明指针变量。
- 避免野指针风险:如果声明SequentialList* myList;而不初始化,会导致野指针,引发程序崩溃;而普通变量是栈上的有效实例,无此风险。
拓展:如果非要用指针怎么办?
如果坚持用SequentialList* myList;,代码需要修改为:
cpp
运行
// 声明指针并在堆上创建实例
SequentialList* myList = new SequentialList();
// 调用函数(直接传指针,无需&)
initializeList(myList, 10);
// … 其他操作 …
// 不仅要释放elements,还要释放结构体本身
destroyList(myList);
delete myList; // 释放堆上的SequentialList实例
这种写法更复杂,且容易忘记delete myList导致内存泄漏,因此原代码的写法更简洁、安全。
当顺序表的容量已满时,需要进行…-知识星球
Chen 提问:
为什么这个地方是capacity*2而不是capacity++
当顺序表的容量已满时,需要进行扩容。扩容的方式是将容量扩大为原来的两倍,即 L->capacity * 2。这样做的原因是为了减少频繁扩容的次数,提高顺序表的性能。 因为扩容操作本身需要将所有元素重新进行一次拷贝,时间复杂度 O(n),如果每次扩容只是将容量增加1,那么当顺序表中的元素数量不断增加时,扩容的次数会非常频繁,从而导致性能下降。而将容量扩大为原来的两倍,可以有效地减少扩容的次数,确保算上扩容以后,均摊时间复杂度为 O(1),提高顺序表的效率。 为了均摊时间复杂度是O(1)呢? 某一次内存重分配完了以后, Capacity 达到了 n,那么表示这里有 n/2 个位置的元素是空的,剩下 n/2 的一半元素被拷贝了1次,剩下的一半的一半的元素拷贝了2次,… 那么所有元素被拷贝的次数就是: n/4 * 1 + n/8 * 2 + n/16 * 4 + … = n 所以当插入 n 个元素后,拷贝的总时间复杂度就是 O(n),每个元素的均摊时间复杂度就是 O(1)。 试着用最简单的语言解释
已完成思考
我们用生活化的例子 + 最直白的计算,把这个问题拆成两部分讲清楚:
一、先搞懂:为什么不选capacity++(每次只加 1)?
假设顺序表的扩容是 “满了就加 1”,把这个过程想象成搬仓库:
- 你有个小仓库,只能放 1 个箱子(容量 1),放满后,要换能放 2 个的仓库,得把1 个箱子搬过去(拷贝 1 个元素);
- 再放 1 个,仓库又满了,换能放 3 个的仓库,得把2 个箱子搬过去(拷贝 2 个元素);
- 再放 1 个,又满了,换能放 4 个的仓库,得把3 个箱子搬过去(拷贝 3 个元素);
……
直到仓库能放n个箱子,你总共搬了1+2+3+…+(n-1)次箱子,这个总数约等于n²/2(比如 n=100,就要搬约 5000 次)。
简单说:每次只加 1,扩容太频繁,搬箱子(拷贝元素)的总次数会爆炸式增长,性能差到没法用。
二、再理解:为什么选capacity*2(每次扩 2 倍)?
还是用搬仓库的例子,这次满了就换2 倍大的仓库:
- 初始仓库能放 1 个,放满后换能放 2 个的,搬1 个箱子;
- 放满 2 个后,换能放 4 个的,搬2 个箱子;
- 放满 4 个后,换能放 8 个的,搬4 个箱子;
- 放满 8 个后,换能放 16 个的,搬8 个箱子;
……
直到仓库能放n个箱子,你总共搬了1+2+4+8+…次,这个总数永远小于 n(比如 n=16,只搬了 1+2+4+8=15 次;n=32,只搬了 31 次)。
核心差异:2 倍扩容让 “搬仓库” 的次数极少,总搬箱次数和最终的箱子数差不多。
三、最后说:为什么均摊时间复杂度是 O (1)?
“均摊时间复杂度” 就是把所有搬箱子的耗时,平摊到每一次 “放箱子” 的操作上,看平均每次放箱子要花多少时间。
举个具体例子:插入 8 个元素(箱子),用 2 倍扩容:
- 插入第 1 个:仓库满了,扩容到 2,搬 1 个箱子(总耗时:1 次插入 + 1 次搬);
- 插入第 2 个:仓库满了,扩容到 4,搬 2 个箱子(总耗时:1 次插入 + 2 次搬);
- 插入第 3、4 个:不用扩容(总耗时:2 次插入);
- 插入第 4 个后满了,扩容到 8,搬 4 个箱子(总耗时:1 次插入 + 4 次搬);
- 插入第 5-8 个:不用扩容(总耗时:4 次插入)。
总耗时统计:
- 插入操作:8 次(每个元素 1 次);
- 搬箱子(拷贝):1+2+4=7 次;
- 总计:8+7=15 次操作。
平均到每个元素:15÷8≈1.875 次操作 —— 这个数是固定的常数(不管插入多少元素,平均下来每次都接近 1 次操作),这就是均摊时间复杂度 O (1)。
一句话总结
每次扩容 2 倍,虽然偶尔有一次插入要 “搬很多箱子”,但把这个耗时平摊到很多次不用搬的插入上,平均下来每次插入的耗时几乎不变;而每次只加 1,每插一次都要 “搬箱子”,越往后越慢。
剩下 n/2 的一半元素被拷贝了1次,剩下的一半的一半的元素拷贝了2次、
已完成思考
我们用具体的数值例子(比如最终容量 n=8) + 直白的分组,把这句话拆解得明明白白,保证一看就懂:
先明确前提(原文的场景)
当某次扩容后容量达到 n(比如 n=8),因为是翻倍扩容,这次扩容是从n/2=4扩容到n=8的。
所以扩容后:
- 总容量n=8,其中元素数量是 n/2=4(都是从旧数组拷贝过来的);
- 剩下的n/2=4个位置是空的(这就是原文里 “这里有 n/2 个位置的元素是空的”)。
我们要统计的是:这 4 个元素(n/2)从被插入到现在,一共被拷贝了多少次。
再拆解 “一半元素被拷贝 1 次,一半的一半拷贝 2 次……”
以n=8(容量)、n/2=4(元素数)为例:
把这 4 个元素按 **“加入顺序” 分组 **,看每组被拷贝的次数:
| 分组(元素数量) | 对应原文的描述 | 被拷贝的次数 | 具体解释 |
| 2 个元素(4 的一半) | n/2 的一半(n/4=2) | 1 次 | 这 2 个元素是在容量 4 时加入的,只经历了 “4→8” 这 1 次扩容,所以只被拷贝 1 次。 |
| 1 个元素(4 的一半的一半) | n/2 的一半的一半(n/8=1) | 2 次 | 这个元素是在容量 2 时加入的,经历了 “2→4” 和 “4→8”2 次扩容,所以被拷贝 2 次。 |
| 1 个元素(继续拆分) | n/2 的一半的一半的一半 | 3 次 | 这个元素是在容量 1 时加入的,经历了 “1→2”“2→4”“4→8”3 次扩容,所以被拷贝 3 次。 |
如果把容量放大到n=16(更易观察规律),规律会更明显:
- 扩容后容量 n=16,元素数 n/2=8;
- 4 个元素(8 的一半):在容量 8 时加入,只经历 “8→16”,被拷贝 1 次;
- 2 个元素(8 的一半的一半):在容量 4 时加入,经历 “4→8”“8→16”,被拷贝 2 次;
- 1 个元素(8 的一半的一半的一半):在容量 2 时加入,经历 3 次扩容,被拷贝 3 次;
- 1 个元素:在容量 1 时加入,经历 4 次扩容,被拷贝 4 次。
一句话翻译这句话的核心
“越早期加入的元素,被拷贝的次数越多;每往回追溯一次扩容,元素数量就减半,拷贝次数就加 1”。
最后补一句:为什么总拷贝次数加起来约等于 n?
还是以 n=8 为例,总拷贝次数是:
2个×1次 + 1个×2次 + 1个×3次 = 2+2+3=7 ≈8(n)
以 n=16 为例,总拷贝次数是:
4个×1次 + 2个×2次 + 1个×3次 + 1个×4次 =4+4+3+4=15≈16(n)
这就是原文里 “所有元素被拷贝的次数加起来等于 n” 的原因,最终平摊到每个元素上,拷贝的平均次数就是常数级(O (1))。
#include <iostream>
using namespace std;
#define eleType int
//1.define
struct SequentialList {
eleType* elements;
int capacity;
int size;
};
//2.initialize
void initializeList(SequentialList* list, int capacity) {
list->elements = new eleType[capacity];
list->capacity = capacity;
list->size = 0;
}
//3.isEmpty and size
bool isEmpty(SequentialList* list) {
return list->size == 0;
}
int sizeOfList(SequentialList* list) {
return list->size;
}
//4.destroy
void destroyList(SequentialList* list) {
delete[] list->elements;
}
//5.insert
void insertElement(SequentialList* list, int index, eleType element) {
if (index<0 || index>list->size) {
throw std::invalid_argument(“invalid index”);
}
if (list->size == list->capacity) {
int Newcapacity = list->capacity * 2;
eleType* Newlist = new eleType[Newcapacity];
for (int i = 0; i < list->size; i++) {
Newlist[i] = list->elements[i];
}
delete[] list->elements;
list->elements = Newlist;
list->capacity = Newcapacity;
}
list->size++;//size要放上面
for (int i = list->size-1; i > index; i–) {//这里是list->size-1
list->elements[i] = list->elements[i – 1];
}
list->elements[index] = element;
}
//6.delete
void deleteElement(SequentialList* list, int index) {
if (index < 0 || index >= list->size) {
throw std::invalid_argument(“invalid index”);
}
for (int i = index; i < list->size-1; i++) {
list->elements[i] = list->elements[i + 1];
}
list->size–;
}
//7.find index
int findElementIndex(SequentialList* list, eleType element) {
for (int i = 0; i < list->size; i++) {
if (list->elements[i] == element) {
return i;
}
}
return -1;//represent not found
}
//8.getElement with index
eleType getElement(SequentialList* list, int index) {
if (index < 0 || index >= list->size) {
throw std::invalid_argument(“invalid index”);
}
return list->elements[index];
}
//9.update
void updateElement(SequentialList* list, int index, eleType element) {
if (index < 0 || index >= list->size) {
throw std::invalid_argument(“invalid index”);
}
list->elements[index] = element;
}
int main() {
SequentialList Mylist;
int list_capacity = 10;
initializeList(&Mylist, list_capacity);
cout << “IsEmpty:” << isEmpty(&Mylist) << endl;
cout << “Size:” << sizeOfList(&Mylist) << endl;
for (int i = 0; i < list_capacity; i++) {
insertElement(&Mylist, i, i * 10);
}
cout << “IsEmpty:” << isEmpty(&Mylist) << endl;
cout << “Size:” << sizeOfList(&Mylist)<<endl;
for (int i = 0; i < sizeOfList(&Mylist); i++) {
cout << getElement(&Mylist, i) << ” “;
}cout << endl;
deleteElement(&Mylist, 9);
cout << “IsEmpty:” << isEmpty(&Mylist) << endl;
cout << “Size:” << sizeOfList(&Mylist) << endl;
for (int i = 0; i < sizeOfList(&Mylist); i++) {
cout << getElement(&Mylist, i) << ” “;
}cout << endl;
insertElement(&Mylist, sizeOfList(&Mylist), 55);
for (int i = 0; i < sizeOfList(&Mylist); i++) {
cout << getElement(&Mylist, i) << ” “;
}cout << endl;
int idx = findElementIndex(&Mylist, 55);
updateElement(&Mylist, idx, 520);
for (int i = 0; i < sizeOfList(&Mylist); i++) {
cout << getElement(&Mylist, i) << ” “;
}cout << endl;
cout << “IsEmpty:” << isEmpty(&Mylist) << endl;
cout << “Size:” << sizeOfList(&Mylist) << endl;
destroyList(&Mylist);
return 0;
}