数据结构与算法之美【二】 - 基础篇 - 数组
数组 Array
数组是一种线性表结构。数组的内存空间连续,且存储的数据类型相同。因为内存连续和数据相同的特性,数组可以随机访问。
数组的随机访问、插入与删除
数组的随机访问是通过寻址公式计算而来的。如下,数组a分配了一块连续空间1000-1039,首地址为1000。那么访问第i个元素时,它的内存地址为\(a[i].address = base + i *data\_size\) ,即首地址加上偏移量。data_size表示不同类型的数据的字节大小,如int就是4个字节。
数组和链表的区别: 链表适合插入和删除操作,时间复杂度是O(1),数组支持随机访问,根据下标访问的时间复杂度是O(1)。而 数组查找的复杂度是O(1)的说法是错误的。
数组的插入和删除都是比较低效的。
如果要在某个位置插入新的元素,那么就要把该位置腾出来,即把后面的元素全部往后挪,最好的情况是在末尾插入元素,复杂度是O(1),最差的情况是在开头插入元素,复杂度是O(n)。
在有些情况下,如果不考虑插入前后的数组元素顺序发生变化,可以直接替换掉该位置的元素,将其移到最后,这样时间复杂度也是O(1)。
对删除操作,可以不每次删除就移动数据,而是记录要删除的数据后,最后一次性删除,移动全部数据。如下图,如果要删除abc三个元素,删除a,移动后面的元素,删除b再移动后面的元素,这样不受影响的defgh元素就被重复移动了三次,我们先记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作。这样defgh只需要移动一次,减少了操作时间。
数组的越界问题
int main(int argc, char* argv[]){
int i = 0;
int arr[3] = {0};
printf("i的内存地址 : %p \n",&i);
printf("arr的内存地址: %p \n",&arr);
for(; i<=3; i++){
arr[i] = 0;
printf("hello world\n");
}
return 0;
}
事实上 ,这段代码的输出结果如下。因为arr先入栈,i的地址比arr低,不会出现死循环。
按照课程中的说法,这段代码应该是无限循环hello world这一句,但是为什么只输出了四次,程序正常结束了。
经过评论各位网友的讨论和 百度,解决历程如下。
这段代码我在原始的基础了增加了输出\(i\)和数组地址的语句,课程中的原始代码是下面这个。
int main(int argc, char* argv[]){
int i = 0;
int arr[3] = {0};
for(; i<=3; i++){
arr[i] = 0;
printf("hello world\n");
}
return 0;
}
好吧,就按照这个代码,我运行的结果仍然是4次,但是报错了???不是,就只是减少了输出语句,怎么就报错了。不过报错也是必然,按理,数组arr的大小定义是3,但是在循环中我们去访问了第四个位置,因此产生了越界问题。但是结果现象和课程里王争说的现象不一样啊。
然后经过评论提醒,是gcc的编译问题。
王争解释这段代码会无限循环的原因是:在C语言中,只要不是访问受限制的内存区域,都可以正常访问,虽然数组arr只分配了三个区域,但是根据数组的寻址方式\(a[i].address = base + i *data\_size\),当i超出了索引范围,实际上它定位到了一块不属于数组的内存区域,但是可以正常访问,而这个不属于arr的区域,恰好是常量i的内存区域, 因此循环到i=3的时候,进行了赋值运算 arr[3]=0 ,实际上则是 i= 0,因此就不断的循环下去。
而数组arr[3]的区域为什么刚好就是常量i的内存区域呢?首先,要明确C语言变量的入栈顺序等,栈顶为低地址,而栈底为高地址。程序中定义了两个变量:arr和i。 变量的入栈顺序是,先申请的变量先入栈,按照上面的代码,i应该在栈底,不妨设它的地址为1000,然后跟着是数组入栈,而数组入栈的顺序是:a[0]在低地址,然后再依次跟着后面的元素。因此,上面的代码在编译过程中申请的内存空间如下图所示。但是,这种是在没有栈溢出保护机制的条件下才成立。在没有栈溢出保护机制时,变量空间的申请是先定义先申请,即跟代码里的变量顺序有关。
但是编译器大多是默认开启堆栈保护机制的,这时候的变量入栈顺序是先按照类型划分,再按照定义变量的先后顺序划分,即:char型先申请,int类型后申请(这与编译器溢出保护时的规定相关),然后栈空间的申请顺序与代码中变量定义顺序相反(后定义的先入栈)。因此最开始的时候,arr先入栈,不会死循环。如果在这种情况下,那么下表中i的内存地址应该是984。(更多顺序关系参考博客[1]。)
因此,如果按照正常的编译 gcc test.c
,输出结果如下所示。在提示越界之后,程序结束。如果关闭了堆栈保护机制,那么程序会无限循环,编译命令为gcc -fno-stack-protector test.c
,测试代码,确实如此。更多关于gcc的堆栈保护机制可以参考博客[2]。
数组越界的情况要尽可能避免(如果是C语言),大部分语言如Java本身会进行越界检查,因此上述情况在Java中不易遇到。
容器和数组
如Java的ArrayList,c++的vector,它们可以把很多数组操作的细节封装,如插入、删除等操作。另外是这些容器支持动态扩容,在适宜的情况下按需选择使用容器还是数组。
参考:
[1] 局部变量入栈顺序与输出关系
[2] GCC中编译器的堆栈保护技术