数组不仅是编程语言中的一种数据类型,也是最基础的数据结构。
1. 数组的基本概念
数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
1.1 线性表
线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前、后两个方向。
除了数组之外,队列、栈、链表等都是线性表结构。
1.2 非线性表
非线性表中的数据不是简单的前后关系。二叉树、堆、图是非线性表。
1.3 数组的特性
数组除了是线性表数据结构的特性外,还有一个特性是:连续的内存空间和相同类型的数据。因此数组具有随机访问速度快,但插入、删除数据慢的特点。
如何通过下标访问数组中的元素?
举例:长度为10的int类型的数组 int[] a = new int[10],计算机会给数组分配一块连续内存空间,如下图所示:
其中,连续分配的内存空间是1000——1039,首地址base_address=1000。
计算机会给每个内存单元分配一个地址,计算机通过地址来访问地址中的数据。当计算机需要随机访问数组中的某个元素时,它会通过寻址公式就算出该元素存储的内存地址:
a[i]_address = base_address + i * data_type_size
其中,data_type_size 表示数组中每个元素的大小,例如上面例子中的,数组存储的是int类型数据,所以data_type_size就是4个字节。
2. 数组与链表的区别
-
数组支持随机访问,根据下标随机访问的时间复杂度是O(1),查找的时间复杂度是O(n)
-
链表适合插入、删除操作。时间复杂度是O(1)
3. 数组“插入”和“删除”低效的原因
由于数组中需要保持内存数据的连续性,所以就会导致数据的“插入”和“删除”操作很低效。下面分析下导致低效的原因和一些改进的方法。
3.1 插入操作
假设数组的长度为n,如果需要将一个数据插入到数组中的第k个位置。
为了将第k个位置腾出来,让给新插入的数据,那么就需要将k~n这部分的数据都按顺序的往后移一位。那么插入数据的时间复杂度是:
-
如果在数组的第一位插入数据,那么原来所有的数据都需要往后移一位,此时的时间复杂度是O(n)。
-
如果在数组的最后一位插入数据,那么就不需要移动原来的数据,此时的时间复杂度是O(1)。
-
如果是在数组的中间插入数据,由于每个位置插入数据的概率是一样的,所以此时的平均情况时间复杂度就是(1+2+…+n)/n=O(n)。
如果数组中的数据是有序的,当我们需要在某个位置插入一个新数据时,就必须按照刚才的方法移动k之后的数据。但是如果数组中存储的数据没有任何规律,数组只是被当作一个存储数据的集合,这时如果需要将某个数据插入到第k个位置,为了避免大规模的数据移动,我们可以之间将第k个位置的数据直接移到数组的最后,然后把新的元素插入到第k个位置。
为了方便理解,我们举例说明:
假设数组a[10]中存储了a、b、c、d、e这5个元素,先需要将元素x插入到第三个位置。我们只需要将c直接放到a[5]的位置,然后将a[2]直接赋值为x即可。插入后数组中的元素就变成了a、b、x、d、e、c。如下图所示:
使用这种处理方法,在特定情况下,在第k个位置插入一个新的元素的时间复杂度就会降为O(1)。这种处理思想在快速排序中会用到,后续详说。
3.2 删除操作
当我们需要删除第k个位置的数据,为了保证内存的连续性,也需要移动数据,不然中间就会出现空洞,使得内存不连续。
那么删除数据的时间复杂度是:
-
如果删除数组最后一位的数据,则这是最好情况时间复杂度O(1)。
-
如果删除数组第一位的数据,则这是最坏情况时间复杂度O(n)。
-
如果删除的是数组中间的数据,由于每个位置删除数据的概率都是一样的,所以此时的平均情况时间复杂度是(1+2+…+n)/n=O(n)。
但在有些特殊情况下,我们可以将多次删除操作集中在一起执行,这样删除的效率就会高很多。
为了方便理解,我们举例说明:
假设数组a[10]中存储了8个元素:a、b、c、d、e、f、g、h,先需要删除a、b、c这三个元素。
为了避免d、e、f、g、h被移动三次,我们可以先记录下已经删除的元素。每次的删除操作并不真正的移动数据,只是记录下数据已经被删除。当数组中没有更多的空间存储数据时,再触发一次真正的删除数据操作,这样就减少了因删除操作而要移动其他数据的次数。
上面的这种思想就是JVM标记清除垃圾回收算法的核心思想。
4. 数组中的越界问题
在写数组相关的代码中,我们经常会遇到数组越界的问题。
先看一段C语言代码的运行结果:
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;
}
运行后,会发现不是打印三次"hello world",而是会无限的打印"hello world"。因为在代码中当i=3时,数据a[3]访问越界。
在C语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。根据前面的数组寻址公式,a[3]会被定位到一块不属于数组的内存地址上。而在函数体内的局部变量是存在栈上,并且连续压栈。在Linux进程的内存布局中,栈区在高地址空间,从高向下增长。变量i和arr在相邻地址,且i比arr的地址要大,所以当arr越界时正好访问到i(当然前提要i和arr元素是相同类型),那么a[3]=0就相当于i=0,从而导致代码无限循环。栈地址如下图所示:
数组越界在C语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。
但在java中,就会做数组越界的检查,如果数组越界了,就会报java.lang.ArrayIndexOutOfBoundsException 异常。
5. 数组与容器之间如何选择?
在很多语言中都提供了容器类,例如java中的ArrayList。那么在平时开发的过程中,我们该如何在数组和容器之间做出选择呢?
容器相比于数组有如下两个优势:
-
可以将很多数组操作的细节封装起来。例如前面说的数组插入、删除数据时需要移动其他数据。
-
支持动态扩容,每次存储空间不够时,会自动扩容1.5倍大小。
数组在定义的时候就需要事先指定大小,因为需要分配连续的内存空间。当需要重新分配一块更大的内存空间时,需要将原来的数据复制过去,然后将新的数据插入。
但容器的扩容操作涉及到了内存申请和数据迁移,也是比较耗时的,所以,如果事先能确定好存储数据的大小,最好在创建容器的时候事先指定数据大小。
容器是很好,但一般在如下情况下,我们还是会选择使用数组:
-
java 的ArrayList不能存储基本数据类型,如int、long,需要封装为Integer、Long类,这里就涉及到了Autoboxing和Unboxing的操作,而这些操作是比较耗性能的。如果特别关注性能,此时可以选用数组。
-
如果事先知道数据的大小,且对数据的操作比较简单,用不到ArrayList中提供的大部分方法,这时候可以直接使用数组。
-
当表示多维数组时,用数组会显得更加直观。例如Object[][]array;而容器需要这样定义:ArrayListarray。
标签:删除,int,复杂度,插入,数组,数据结构,数据 来源: https://blog.csdn.net/salmon_zhang/article/details/88583287
本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享; 2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关; 3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关; 4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除; 5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。