数据结构与算法-----数组

Array 数组

我想,作为学过编程语言的你,对数组肯定不陌生,它在每种编程语言都会出现,是最常用的数据类型,它也是一种最基础的数据结构,尽管是简单且基础,但是你是否了解它的精髓呢?让我来带你去深入了解一波吧!进入学习之前,我们先带着这个问题继续学习下去,
“为什么数组要从 0 开始编号,而不是从 1 开始呢?”

实现随机访问

数组是一种线性表的数据结构,它是用一组连续的内存空间,来存储一组具有相同类型的数据。

  • 线性表
    性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。
    在这里插入图片描述
    非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。
    在这里插入图片描述
  • 连续内存空间和相同类型数据
    这两个特性,让数组可以随机访问元素,同时又让“插入、删除”操作变得低效。

数组是怎么根据下标随机访问数组元素?

假设有一个int 类型数组,计算机会给它分类一块连续的内存空间1000~1039,其中内存的首地址为base_address=1000
在这里插入图片描述
计算机会给每个内存单元分配一个地址,然后通过地址来访问内存中的数据,访问的方式就是通过下面的寻址公式,计算该元素的内存地址:

a[i]_address = base_address + i * data_type_size

其中 data_type_size 表示数组中每个元素的大小。int类型数据的大小为4个字节

数组和链表的区别

链表在插入和删除操作上的时间复杂度为O(1),适合插入删除
数组适合查找,但是查找的时间复杂度并不为 O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。
数组的正确表述数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)

低效的“插入”和“删除”

插入:

如果数组要插入一个数据到第k个位置,那么要把第 k 个位置腾出来,给新来的数据,我们需要将第 k~n 这部分的元素都顺序地往后挪一位。当数据插入数组末尾,那么时间复杂度为O(1),
但如果在数组的开头插入元素,那所有的数据都需要依次往后移动一位,所以最坏时间复杂度是 O(n)。因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 (1+2+…n)/n=O(n)。

如果数组中的元素是有序的,那么必须按照上面的操作,移动k后面的数据。
如果数组的元素无序的,我们可以用一个tricky的方法,就是直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。
在这里插入图片描述
利用这种处理技巧,在特定场景下,在第 k 个位置插入一个元素的时间复杂度就会降为 O(1)。这个处理思想在快排中也会用到。

删除:

如果我们要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了。
如果删除数组末尾的数据,则最好情况时间复杂度为 O(1);如果删除开头的数据,则最坏情况时间复杂度为 O(n);平均情况时间复杂度也为 O(n)。
其实存在某些特殊场景,我们不一定要追求数组的数据的连续性,我们可以试试把多次删除的操作集中处理,这样可以提高效率
在这里插入图片描述
例如我们要依次删除a、b、c,为了避免每次都要搬移数据,我们先记录已经删除的数据,但是并不会真正搬移数据,只会记录这个数据已经被删除,当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。这也是JVM标记清除垃圾回收算法的核心思想

警惕数组访问越界问题

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 world,而是无限输出hello world,你知道为什么吗?

因为 i i i是从零开始的,所以范围应该是 i &lt; 3 i&lt;3 i<3,而不是 i &lt; = 3 i&lt;=3 i<=3,当访问到数组 a[3],那就访问越界了,C语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。根据我们前面讲的数组寻址公式,a[3] 也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环。

数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。很多计算机病毒就是利用这个方法去访问非法地址的漏洞,来攻击系统。

但是呢,有些编程语言,例如JAVA,就会帮忙做越界检查,会抛出异常。

在这里我想很多同学都疑惑为什么会地址刚好在变量i的内存地址上,其实这是编译器的问题,对于不同的编译器,在内存分配时,会按照内存地址递增或递减的方式进行分配。这个程序,如果是内存地址递减的方式,就会造成无限循环。
因为C语言实现数组的保存方式是使用栈,栈是由高到低位增长的,所以,i和数组的数据从高位地址到低位地址依次是:i, a[2], a[1], a[0]。a[3]通过寻址公式,计算得到地址正好是i的存储地址,所以a[3]=0,就相当于i=0.

别的解释:

例子中死循环的问题跟编译器分配内存和字节对齐有关 数组3个元素 加上一个变量a 。4个整数刚好能满足8字节对齐 所以i的地址恰好跟着a2后面 导致死循环。。如果数组本身有4个元素 则这里不会出现死循环。。因为编译器64位操作系统下 默认会进行8字节对齐 变量i的地址就不紧跟着数组后面了

容器能否完全替代数组?

针对数组类型,很多语言都提供了容器类,比如 Java 中的 ArrayList、C++ STL 中的 vector。在项目开发中,什么时候适合用数组,什么时候适合用容器呢?

其实是要看情况使用的,ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容。当数组大小不够用了,它都会将空间自动扩容为 1.5 倍大小,不过,这里需要注意一点,因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小

建议底层开发,对性能有要求,最好用数组,因为arraylist无法存储基本类型,需要封装为Integer、Long 类等,而装箱、拆箱操作需要一定性能的消耗。

前后呼应,回答开篇问题

为啥数组从0开始?
很简单,因为数组的下标,定义应该叫“偏移”,前面也讲到,如果用 a 来表示数组的首地址,a[0] 就是偏移为 0 的位置,也就是首地址,a[k] 就表示偏移 k 个 type_size 的位置,所以计算 a[k] 的内存地址只需要用这个公式:

a[k]_address = base_address + k * type_size

但是,如果数组从 1 开始计数,那我们计算数组元素 a[k]…

a[k]_address = base_address + (k-1) * type_size

从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。

不过其实还有一些历史原因,因为C语言设计出来,就是0开始,然后其他语言就为了方便,就跟风了~实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。

思考题:二维数组的内存寻址公式

一维数组:

a[i]_address=base_address+i*type_size 

二维数组:假设是m*n,

a[i][j]_address=base_address + (i*n+j)*type_size

三维数组:假设是m * n * q,

a[i][j][k]_address=base_address + (i*n*q + j*q + k)*type_size

二维数组解释参考

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值