数据结构与算法学习之数组

数组

数组用一块连续的内存空间,来存储相同类型的一组数据,最大的特点就是支持随机访问,但插入、删除操作也因此变得比较低效,平均情况时间复杂度为 O(n)。

在平时的业务开发中,我们可以直接使用编程语言提供的容器类,但是,如果是特别底层的开发,直接使用数组可能会更合适。

数组的基本概念

数组是一种线性表,它拥有一组连续的存储空间,用来存储一组相同类型的元素

数组的特点

数组是线性表

线性表顾名思义数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向,像数组,链表,栈,堆等都属于线性表

数组是具有一组连续的存储空间,用来存储一组相同类型的元素

这样的特性就会有存在一个"杀手锏"特性:"随机访问",但是有利也有弊,它操作插入或者删除时就显得非常低效

数组是如何实现根据下标进行随机访问

举个栗子:我们来创建一个长度为10的int类型数组(即:int[] arr = new int[10]),在我们申请完成后,计算机会给我们的数组a[10],分配一组连续存储空间1000~1039,其中,内存块的首地址为 base_address = 1000.如下图

我们现在想要访问a[5]的元素,那么计算机会怎么做呢!计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。那么我们现在只需要知道a[5]在计算机中存储的地址,就能获取到它的元素值了.

这个寻址公式就是:a[i]_address = base_address(即数组的首地址,在本例子中,首地址为:1000) + i(要访问的元素下标) * data_type_size(这个表示的是数据类型的大小,本次例子中的数据类型为int,则他的大小为4)

关于数组相关的面试题

数组与链表的区别

错误的回答: 链表适合插入,删除,它的复杂度为O(1),数组适合查找,它的复杂度为O(1)

正确的回答: 数组是适合查找操作,但是查找的时间复杂度并不为O(1).即便是排好序的数组,你用二分查找,时间复杂度也是O(logn).所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为O(1).

数组的劣势

插入

我们继续接着上面的arr[10]数组例子来讲,现在数组中存储了5个元素,分别是a,b,c,d,e.我先写要插入一个x值,若是插入到数组的尾部,那么时间复杂度为O(1),若是插入到数组头部或者中间时,这个时候需要将数组的所有元素全部
向后或者部分向后移动一位,这个时候,数组需要进行大量的数据迁移操作,时间复杂度为O(n),所以在进行插入操作时平均复杂度为O(n),如下图

还有一种比较简单的方法,在特定场景下,时间复杂度为O(1),就是把原来第K个值移动到队尾,新的K值移动到原来K值所在的地址,这种思想在快排用的比较多,学习到了在说.如下图

删除

我们继续接着上面的arr[10]数组例子来讲,由于我们刚才插入了一个新值,这意味我们的数组里有6个元素,我现在要删除刚才插入的新值,若插入在数组尾部,我们可以直接删除,不影响空间的连续性,时间复杂度为O(1).

若是在首位或者中间,我们在删除的时候,必然使数组的空间成为一个不连续的内存空间.为了防止出现这种情况,我们在删除的时候,会把没有删除的数据迁移到一个新的空间地址中,这样的话最差时间复杂度为O(n),平均时间复杂度为O(n),如下图

实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性.如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?

我们继续来看例子.数组 a[10]中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素.

为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据.

每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除.

当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移.

如果你了解JVM,你会发现,这不就是JVM标记清除垃圾回收算法的核心思想吗?

没错,数据结构和算法的魅力就在于此,很多时候我们并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的.

使用数组时应注意的问题

防止数组下标越界

继续举个栗子

这个是关于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;
}

看出有啥问题了没,你是否以为是只会输出3行hello word,起初我也是这么认为的.但实际上,它会无限打印hello word.

造成无限打印的原因是

数组大小为 3,a[0],a[1],a[2],而我们的代码因为书写错误,导致 for 循环的结束条件错写为了 i<=3 而非 i<3,所以当 i=3 时,数组 a[3]访问越界。

我们知道,在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。

根据我们前面讲的数组寻址公式,a[3]也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环。

数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。

因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。

这种情况下,一般都会出现莫名其妙的逻辑错误,就像我们刚刚举的那个例子,debug 的难度非常的大。

而且,很多计算机病毒也正是利用到了代码中的数组越界可以访问非法地址的漏洞,来攻击系统,所以写代码的时候一定要警惕数组越界。

但并非所有的语言都像 C 一样,把数组越界检查的工作丢给程序员来做,像 Java 本身就会做越界检查,比如下面这几行 Java 代码,就会抛出 java.lang.ArrayIndexOutOfBoundsException。

int[] a = new int[3];
a[3] = 10;

容器能否完全替代数组?

针对数组类,许多编程语言提供了容器类.如:java的ArrayLists,C++,STL类中的vector

ArrayLists的特点

优点

1.支持自动扩容,开发人员无需关注底层代码开发
2.可以将数组操作的细节封装起来,比如前面提到的数组插入,删除数据时需要搬移其他数据等
3.如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。

缺点

1.扩容操作涉及内存申请和数据搬移,是比较耗时的。每次扩容时当前数据大小的1.5倍.所以,如果事先能确定需要存储的数据大小,**最好在创建 ArrayList 的时候事先指定数据大小**
2.Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
3.还有一个是我个人的喜好,当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList<ArrayList<object>> array。
在项目开发中,什么时候适合用数组,什么时候适合用容器呢?

对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

数组的下标为啥是从0开始

最主要的原因可能是历史原因,C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。

实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。

思考题

参考资料

数据结构与算法之美

posted @ 2022-03-01 00:39  努力跟上大神的脚步  阅读(80)  评论(0编辑  收藏  举报