小肥柴慢慢手写数据结构(C篇)(2.1.1 动态数组(ArrayList))
- 目录
-
- [2.1.1.1 问题的出现](#2.1.1.1 问题的出现)
- [2.1.1.2 列出ADT](#2.1.1.2 列出ADT)
- [2.1.1.3 具体实现](#2.1.1.3 具体实现)
- [2.1.1.4 性能讨论](#2.1.1.4 性能讨论)
目录
【注】升级版本教程内容较多,因此对一些逻辑链较长的问题描述,我们往往会采用"step_x"的形式提醒读者当前讨论问题到了哪一步。
2.1.1.1 问题的出现
step_0 回忆数组的优缺点
- 优点:依靠index(索引),本质上依靠内存中的连续存储结构快速获取对应元素。
- 缺点:一旦声明就不能再更改大小。 ==> 可能存在空间的浪费 or 空间不足的情况。
step_1 于是,人们开始设想
如果能够动态把数据存储在数组中,制造这样一种工具:
- 不用顾忌元素个数超出存储空间上限(即:无限加入新的元素,自动扩容);
- 能够及时释放不使用的空间(即:删除元素后数组长度会适时缩减);
- 能够提供一些方便的函数操作(即:元素的增、删、改、查)。
那,岂不是一件美事?==> 于是乎,动态数组(ArrayList)诞生了。
(1)上述瞎想的过程其实蛮重要的,它将贯穿于整个教程笔记中,因为:任何一个数据结构的产生是有应用背景的!
(2)个人建议学习理工技术/知识前,一定要问自己三遍,问别人N遍:为什么要做这件事?想明白自己要做什么、在做什么?其优先级往往高于"怎么做",不然很容易陷入死板的学习旋涡,无限虚空内耗。
【注】在其他编程语言中,动态数组(ArrayList)还有另一个名字------向量(Vector),都是语法糖。
2.1.1.2 列出ADT
step_0 在实现一个具体的数据结构前,我们需要定义好这个数据结构,想清楚两个基础问题。
【Q1】如何存储数据?
【Q2】提供哪些操作?
上述描述有一个专门的术语:抽象数据类型(Abstract Data Type,ADT)。
【习惯】数据结构的具体实现方式是根据ADT展开的,实现形式比较自由;可以将ADT理解为一套接口标准,举个例子:
无论是哪一家生产厂家出品的DDR4内容,都可以插入到支持DDR4的主板上。
step_1 先画一张图,思考应该做些什么,不会写的代码先用中文代替。

c
struct ArrayList {
(1)自定义类型的数组 data[]; //Q1
(2)当前元素个数 len或者size;//Q2
(3)数组最大的容量capacity;//Q3
}
step_2 才开始我们就遇到了几个问题。
【Q1】这里的数组是否要用动态的?
【A1】用,直接用指针动态分配内存;在具体实现中会给出相关学习/讨论链接。
【Q2】当前元素个数len是十分重要的一个标记量,相比于当前最后一个元素的数组索引位置last,哪一个更好呢?
【A2】其实都差不多,len = last + 1,先用len,很多场合都希望得到当前元素个数。
【Q3】容量,要不要放在struct中作为标配?
【A3】都用到动态分配了,那必然是标配了;只不过是给定一个默认值,可拓展成更多的使用模式,例如:
(1)用户可指定生成的ArrayList的原始大小。
(2)用户可以不指定生成的ArrayList的原始大小,直接使用默认值。
【Q4】如何合理设计操作呢?
【A4】 先列一些基本的功能,做自己有把握很快能实现的,那些高大上的功能后面慢慢磨;细想数组元素的操作,无外乎"增、删、改、查 "(CRUD),所以基本功能应该围绕这4个点来讨论。
(1)生成一个ArrayList,最好是带*的,方便函数传参修改内容。
(2)("增" ,add)向当前list中尝试添加一个元素,分为默认在末尾添加和指定位置添加。
(3)("查" ,find/search)在当前list中尝试寻找给定元素item,找到了就返回该元素的位置(实际上是第一次出现的位置)。
(4)("删" ,delete)在当前list尝试删除元素,分为尝试删除给定元素和尝试删除给定位置的元素。
(5)("改" ,update)尝试修改当前list中指定位置的元素。
(6)获取给定位置元素:利用数组的优势,根据索引(index)找到对应的数据。
...
以上就是咱们的ADT了,注释贴在头文件ArrayList.h中,对照着写代码。
c
typedef int ElementType;
#ifndef _Array_List_h
#define _Array_List_h
#define DEFAULT_CAPACITY (20)
#define OK (0)
#define ERROR (-1)
struct ArrayList{
ElementType *data; // 自定义类型的数组 data[]
int len; // 当前元素个数 len 或者 size
int capacity; // 数组最大的容量 capacity
};
typedef struct ArrayList *PtrArrayList;
typedef PtrArrayList List;
//(1)生成一个ArrayList,最好是带*的,方便函数传参修改内容。
List createList();
//(2)向当前list中尝试添加一个元素item,分为默认在末尾添加和指定位置pos添加
int addItem(List list, ElementType item, int pos);
int addItemTail(List list, ElementType item);
//(3)在当前list中尝试寻找给定元素item,找到了就返回该元素的位置(实际上是第一次出现的位置)
int findItem(const List list, ElementType item);
//(4)在当前list尝试删除元素,分为尝试删除给定元素item和尝试删除给定位置pos的元素
int removeItem(List list, ElementType item);
int removeByIndex(List list, int pos);
//(5)尝试修改当前list中指定位置pos的元素item
int setItem(List list, ElementType item, int pos);
//(6)尝试获取给定位置pos的元素
ElementType getItem(List list, int pos);
//(7)检测动态数组内是否没有元素
int isEmpty(List list);
// 可能还有别的功能。。。
#endif
需要注意几个点,代码规范些:
(1)为了防止后头文件被重复引用,用了
c
#ifndef _Array_List_h
#define _Array_List_h
#endif
的常规处理方式
(2)typedef用于变量名称替换,翻车点(编译问和理解知识点),可以自行百度;通过
c
typedef struct ArrayList *PtrArrayList;
typedef PtrArrayList List;
操作可隐去部分指针操作,typedef只是为了封装指针,隐藏细节,帮助大家更好的理解核心知识;typedef的写法有很多,这里仅仅是拆开写帮助基础薄弱的同学理解,我更喜欢使用匿名结构体。
(3)定义宏一定要记得用"()"护体,用" / "折行。
(4)先定两个状态量方便后面用,后续章节就不会那么死板了。
c
#define OK (0)
#define ERROR (-1)
(5)很多函数把List作为传参,实际上将对应的内存喂给函数,既是入参也是出参!
【e.g.】initList(&list),我个人不建议这样写,参考流行编程语言的做法更好,都是语法糖。
(6)易读性强的代码就是最好的注释,强迫自己尽快适应,别傻乎乎地每一句都写注释,那样做你永远学不会编程!
【e.g.】积累函数命名方式:
增:insertXXX / addXXX
删:removeXXX/delXXX
改:setXXX/updateXXX
查:findXXX/searchXXX/contains
(7)传统的的C模块封装,都是将ADT声明放在.h头文件,具体实现放在.c文件中的;可能有读者会嘲笑这段话,但这两年我是亲眼目睹很多初学者被AI带歪了。复杂系统的结构设计和文件布局是有讲究的,模块化设计思想是基础;无脑用AI比不会用AI更可怕:
a.h 头文件对外部暴露模块能力;.c 文件对外隐藏模块具体实现。
b.用户在使用模块的时候,需要include .h头文件,但一般情况下并不关心其具体实现,这就是"封装"。
c.测试模块功能时才会写main()。 ⇒ 原谅一个心态被搞崩过的人的啰嗦。
具体工程示例:

2.1.1.3 具体实现
ArrayList.c 对照.h头文件实现每个功能, 挨个实现:写一段、编一段,测一段。
【注】很多时候数据结构的实现就像搭积木一样简单,不要以讹传讹认为它很难。
step_1 createList( ),返回一个可用的,没有元素的(即空的、全新的)动态数组。
【注】为了方便C语言乱学的朋友,加入了插入头文件的代码片段,后续贴中会省略。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ArrayList.h"
List createList(){
List L = (PtrArrayList)malloc(sizeof(struct ArrayList));
if(L == NULL){
printf("Out of memery, create List fail\n");
return NULL;
}
L->data = malloc(sizeof(ElementType) * DEFAULT_CAPACITY);
if(L->data == NULL){
printf( "Out of memery, create list array fail\n" );
free(L);
return NULL;
}
memset(L->data, 0, sizeof(ElementType) * DEFAULT_CAPACITY);
L->capacity = DEFAULT_CAPACITY;
L->len = 0;
return L;
}
此处两个讨论点:
【Q1】malloc前面到底要不要加(xxx *)的强制转换?
【A1】看编译环境,有的编译器很厉害自动会帮转化,有的比较严格,视情况而定。
【Q2】memset到底要不要做?
【A2】同上;我建议还是要做的,毕竟不希望出现"烫烫烫",C/C++变量声明一定要初始化,这是好多企业的血泪教训。
若不考虑分配内存失败,可使用如下代码替代:
c
L->data = (ElementType*)calloc(DEFAULT_CAPACITY, sizeof(ElementType));
【Q3】struct中的"."与"->"两种操作如何抉择?
【A3】同上,一般来讲 struct/*struct 是有区别的,看编译器。
【Q4】使用完free(ptr)之后,是否需要将ptr = NULL?
【A4】个人认为要看具体的场景和语言,给出几篇参考:
a.【C语言】5. 指针free后为什么要刻意指向NULL、野指针(原因、解决)、悬垂指针
b. 为什么 C 语言的 free 函数最后不自己把指针指向 NULL? ⇒ 经典话语:"在有经验的程序员看来,你这个蠢操作把错误隐藏的更深、导致程序逻辑更复杂、更难排除错误了"
c. 内存管理:C语言中的Malloc/free是如何分配内存的
【建议】别着急往下编,先来个main()试一把createList(),及时排错,main.c:
c
int main(int argc, char *argv[]) {
int i;
List list = createList();
printf("\nlist is empty? %d\n", isEmpty(list));
return 0;
}
⇒ 即便你跟着我们的教程抄代码,作为初学者我们建议一段一段的抄写和测试,这样能够快速提升你对代码的理解、编写能力和编译排错能力。
step_2 int isEmpty(List list),用于检测当前list是否完成初始化(指针不为NULL,data[]没有数据,len=0)。
c
int isEmpty(List list){
if (list == NULL)
return ERROR;
return (list->len == 0);
}
step_3 int addItem(List list, ElementType item, int pos),在指定位置添加元素。
画个图一切明了:

a.在指定位置插入元素,需将所有元素向后挪一位;
b.注意最后一个元素的角标处理和元素个数增加(len++);
c. 还要考虑3种无效/非法操作
1)list为空;
2)数组满了 len == capacity;
3)插入位置越界(pos<0 or pos>=capacity)。
c
int addItem(List list, ElementType item, int pos){
int i;
if (list == NULL) {
printf("\nlist is null\n");
return ERROR;
} else if (pos < 0 || pos > list->capacity ) {
printf("\npos out of range!\n");
return ERROR;
}
if (list->len == list->capacity && growArray(list) == ERROR) {
printf("\ngrow list err\n");
return ERROR;
}
for (i = list->len-1; i >= pos; i--)
list->data[i+1] = list->data[i];
list->data[pos] = item;
list->len++;
return OK;
}
此处有几个讨论点:
【Q1】非法判断那么多条件,要不要写成一个函数呢?
【A1】我认为是可行的,最好是看已经成型的开源库中他人的写法,适度即可。
【Q2】位置的循环操作中,到底是从最后一位last索引开始迭代,还是从pos索引开始迭代呢?
【A2】选择从后面开始比较妥当,处理起来方便很多,不相信可以尝试从pos迭代。
【注1】如下代码使用了逻辑短路技巧,此处的 growArray(list) 指的就是具体的扩容实现
c
if (list->len == list->capacity && growArray(list) == ERROR)
growArray参考实现如下:
c
int growArray(List list){
int i;
int resize = list->capacity << 1; //左移一位操作,相当于*2
ElementType *newData = malloc(sizeof(ElementType) * resize); // 理解resize的含义
if(newData == NULL){
printf("\ngrow array fail!\n");
return ERROR;
}
memset(newData, 0, sizeof(ElementType) * resize);
ElementType *tmp = list->data;
for(i = 0; i < list->len; i++)
newData[i] = list->data[i];
list->data = newData;
free(tmp);
list->capacity = resize;
return OK;
}
对于 默认添加元素方法addItemTail(),直接使用现有addItem()
c
int addItemTail(List list, ElementType item){
return addItem(list, item, list->len);
}
step_4 find操作,有的ADT里也称为:包含(contains)
c
int findItem(const List list, ElementType item){
int i = 0;
if(list == NULL) return ERROR;
// for(i = 0; i < list->len; i++){
// if(list->data[i] == item)
// return i;
// }
// return ERROR;
while(i < list->len && list->data[i] != item)
i++;
return i > (list->len - 1) ? ERROR : i;
}
(1)注意const的用法,不希望list内容被修改。
(2)此处循环使用while/for皆可。
step_5 remove操作,同样需要挪位置。
【注】当前动态数组是允许有重复元素的。

c
int removeItem(List list, ElementType item){
int i;
if(list == NULL){
printf("\nlist is null\n");
return ERROR;
}
int pos = findItem(list, item);
if(pos != ERROR){
for(i = pos; i < list->len-1; i++)
list->data[i] = list->data[i+1];
list->data[list->len-1] = 0;
list->len--;
return pos;
}
return ERROR;
}
同理,可以删除指定位置的元素。
c
int removeByIndex(List list, int pos){
int i;
if(list == NULL){
printf("\nlist is null\n");
return ERROR;
} else if(pos < 0 || pos >= list->len){
printf("\npos out of range!\n");
return ERROR;
}
for(i = pos; i < list->len-1; i++)
list->data[i] = list->data[i+1];
list->data[list->len-1] = 0;
list->len--;
return OK;
}
【注】
a. 如果不考虑输入异常,这两个功能是可以部分代码复用的;
b. 特别对于尝试删除元素的操作,其实是删除了list中第一个出现的该元素,我们的list是允许有重复元素存在的,这个操作的第一步就是通过find函数找到目标元素的索引位置。
step_7 set操作,替换掉指定位置(pos--position,index)的元素。
c
int setItem(List list, ElementType item, int pos){
if (list == NULL) {
printf("\nlist is null\n");
return ERROR;
} else if (pos < 0 || pos >= list->len) {
printf("\npos out of range!\n");
return ERROR;
}
list->data[pos] = item;
return OK;
}
step_8 get操作,获取指定位置的元素
c
ElementType getItem(List list, int pos){
if(list == NULL){
printf("\nlist is null\n");
return ERROR;
} else if (pos < 0 || pos >= list->len) {
printf("\npos out of range!\n");
return ERROR;
}
return list->data[pos];
}
step_9 其他操作,例如遍历打印整个动态数组中的所有元素。
c
void printList(const List list){
int i;
if (list != NULL) {
printf("\n[ ");
for (i = 0; i < list->len; i++)
printf("%d ", list->data[i]);
printf("]\n");
}
}
【ArrayList.c完整实现】
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ArrayList.h"
List createList(){
List L = (PtrArrayList)malloc(sizeof(struct ArrayList));
if (L == NULL) {
printf("Out of memery, create List fail\n");
return NULL;
}
L->data = malloc(sizeof(ElementType) * DEFAULT_CAPACITY);
if (L->data == NULL) {
printf( "Out of memery, create list array fail\n" );
free(L);
return NULL;
}
memset(L->data, 0, sizeof(ElementType) * DEFAULT_CAPACITY);
L->capacity = DEFAULT_CAPACITY;
L->len = 0;
return L;
}
int growArray(List list){
int i;
int resize = list->capacity << 1;
ElementType *newData = malloc(sizeof(ElementType) * resize);
if (newData == NULL) {
printf("\ngrow array fail!\n");
return ERROR;
}
memset(newData, 0, sizeof(ElementType) * resize);
ElementType *tmp = list->data;
for (i = 0; i < list->len; i++)
newData[i] = list->data[i];
list->data = newData;
free(tmp);
list->capacity = resize;
return OK;
}
int addItem(List list, ElementType item, int pos){
int i;
if (list == NULL) {
printf("\nlist is null\n");
return ERROR;
} else if (pos < 0 || pos > list->capacity ) {
printf("\npos out of range!\n");
return ERROR;
}
if (list->len == list->capacity && growArray(list) == ERROR) {
printf("\ngrow list err\n");
return ERROR;
}
for (i = list->len-1; i >= pos; i--)
list->data[i+1] = list->data[i];
list->data[pos] = item;
list->len++;
return OK;
}
int addItemTail(List list, ElementType item){
return addItem(list, item, list->len);
}
int findItem(const List list, ElementType item){
int i = 0;
if (list == NULL) return ERROR;
while (i < list->len && list->data[i] != item)
i++;
return i > (list->len - 1) ? ERROR : i;
}
int removeItem(List list, ElementType item){
int i;
if (list == NULL) {
printf("\nlist is null\n");
return ERROR;
}
int pos = findItem(list, item);
if (pos != ERROR) {
for(i = pos; i < list->len-1; i++)
list->data[i] = list->data[i+1];
list->data[list->len-1] = 0;
list->len--;
return pos;
}
return ERROR;
}
int removeByIndex(List list, int pos){
int i;
if (list == NULL) {
printf("\nlist is null\n");
return ERROR;
} else if (pos < 0 || pos >= list->len) {
printf("\npos out of range!\n");
return ERROR;
}
for (i = pos; i < list->len-1; i++)
list->data[i] = list->data[i+1];
list->data[list->len-1] = 0;
list->len--;
return OK;
}
int setItem(List list, ElementType item, int pos){
if (list == NULL) {
printf("\nlist is null\n");
return ERROR;
} else if(pos < 0 || pos >= list->len) {
printf("\npos out of range!\n");
return ERROR;
}
list->data[pos] = item;
return OK;
}
ElementType getItem(List list, int pos){
if(list == NULL){
printf("\nlist is null\n");
return ERROR;
} else if(pos < 0 || pos >= list->len) {
printf("\npos out of range!\n");
return ERROR;
}
return list->data[pos];
}
int isEmpty(List list){
if(list == NULL) return ERROR;
return (list->len==0);
}
void printList(const List list){
int i;
if (list != NULL) {
printf("\n[ ");
for (i = 0; i < list->len; i++)
printf("%d ", list->data[i]);
printf("]\n");
}
}
step_10 测试代码(main.c),建议使用AI辅助完成
c
#include <stdio.h>
#include <stdlib.h>
#include "ArrayList.h"
int main(int argc, char *argv[]) {
int i = 0;
printf("\n==============test create list && add item===================\n");
List list = createList();
printf("\nlist is empty? %d\n", isEmpty(list));
for(i = 0; i < 10; i++)
addItemTail(list, i);
printf("\ninit data: len=%d\n", list->len);
printList(list);
for(i = 20; i < 40; i++)
addItemTail(list, i);
printf("\ngrow data: len=%d\n", list->len);
printList(list);
addItemTail(list, 100);
printList(list);
addItem(list, 200, 3);
printList(list);
addItem(list, 77, 0);
printList(list);
addItem(list, 99, list->len);
printList(list);
addItem(list, 99, -1);
printList(list);
printf("\n==============test find item===================\n");
printf("\nfind 77'pos=%d\n", findItem(list, 77));
printf("\nfind 100'pos=%d\n", findItem(list, 100));
printf("\nfind 20'pos=%d\n", findItem(list, 20));
printf("\nfind 99'pos=%d\n", findItem(list, 99));
printf("\nfind 500'pos=%d\n", findItem(list, 500));
printf("\n==============test remove item===================\n");
printf("\nremove 10 =>pos=%d\n", removeItem(list, 10));
printList(list);
printf("\nremove 10 again =>pos=%d\n", removeItem(list, 10));
printList(list);
printf("\nremove 77 =>pos=%d\n", removeItem(list, 77));
printList(list);
printf("\nremove 100 =>pos=%d\n", removeItem(list, 100));
printList(list);
printf("\nremove pos=0, ret=%d\n", removeByIndex(list, 0));
printList(list);
printf("\nremove pos=10, ret=%d\n", removeByIndex(list, 10));
printList(list);
printf("\nremove pos=%d, ret=%d\n", list->len-1, removeByIndex(list, list->len-1));
printList(list);
printf("\nremove pos=%d, ret=%d\n", list->len, removeByIndex(list, list->len));
printList(list);
printf("\n==============test set item===================\n");
printf("\nset -2 pos=%d ~~~ %d \n", 0, setItem(list, 0, -2));
printList(list);
printf("\nset -10 pos=%d ~~~ %d \n", list->len-1, setItem(list, -10, list->len-1));
printList(list);
printf("\nset -100 pos=%d ~~~ %d \n", 7, setItem(list, -100, 7));
printList(list);
printf("\nset -50 pos=%d ~~~ %d \n", -1, setItem(list, -50, -1));
printList(list);
printf("\nset -60 pos=%d ~~~ %d \n", list->len, setItem(list, -60, list->len));
printList(list);
printf("\n==============test get item===================\n");
printf("get pos=>%d = %d\n", 0, getItem(list, 0));
printf("get pos=>%d = %d\n", -1, getItem(list, -1));
printf("get pos=>%d = %d\n", list->len-1, getItem(list, list->len-1));
printf("get pos=>%d = %d\n", list->len, getItem(list, list->len));
printf("\nlist is empty? %d\n", isEmpty(list));
return 0;
}
step_11 一串追加问题
思考并尝试编码实现/求证下列问题:
【Q1】既然扩容问题可以解决了,当闲置空间过多时是否考虑缩容?
【A1】remove()操作视情况执行缩容。
【Q2】缩容沿用:"发现当前使用空间不足原始空间一半,则直接空间缩减一半"的策略,可行吗?
【A2】不好吧,见过《九品芝麻官》中方唐镜在作死边缘反复横跳吗?
【Q3】能否将扩容和缩容两种操作整合成一个函数:resize(List list, int newSize)?
【A3】审视自己最newSize的理解,用resize替换growArray,尽量依靠自己从零写出来,不要总依靠AI!
2.1.1.4 性能讨论
step_0 感性的讨论
(1)依靠数组索引,ArrayList在获取元素方面(也就是查的能力)速度是有保障的。
(2)但缺点也很明显:插入和删除成本高。
step_1 理性的讨论(借助数学期望分析)
(1)插入问题,假设插入点为i,则
E i = ∑ i = 1 n + 1 p i ( n − i + 1 ) E_{i}=\sum_{i=1}^{n+1}p_{i}(n-i+1) Ei=i=1∑n+1pi(n−i+1)
随机插入,等概率 p i = 1 n + 1 p_{i}=\frac{1}{n+1} pi=n+11
代入有(求和号内等差数列)
E i = 1 n + 1 ∑ k = 1 n + 1 ( n − i + 1 ) = n 2 E_{i}=\frac{1}{n+1}\sum_{k=1}^{n+1}(n-i+1)=\frac{n}{2} Ei=n+11k=1∑n+1(n−i+1)=2n
(2)删除问题,假设插入点为i,则
E d = ∑ i = 1 n q i ( n − i ) E_{d}=\sum_{i=1}^{n}q_{i}(n-i) Ed=i=1∑nqi(n−i)
随机删除,等概率
q i = 1 n q_{i}=\frac{1}{n} qi=n1
代入有(求和号内等差数列)
E d = 1 n ∑ i = 1 n ( n − i ) = n − 1 2 E_{d}=\frac{1}{n}\sum_{i=1}^{n}(n-i)=\frac{n-1}{2} Ed=n1i=1∑n(n−i)=2n−1
上述两种操作均有时间复杂度 O ( n ) O(n) O(n)
【后记】还可以翻看老版的相关贴,对比严版教材:
1-1 线性表 ArrayList 原始版本
1-2 线性表 ArrayList 升级版本
1-3 线性表 ArrayList 严版教材实现浅析