数组--王争数据结构与算法学习笔记

数组看起来简单基础,但是很多人没有理解这个数据结构的精髓。数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。数组在逻辑上是一种线性表数据结构. 数组在物理上是一种顺序的存储结构。 数组定义的关键词:
1.连续的内存空间
2.相同类型的数据.
线性表: 数据排成像一条线一样的结构.每个线性表上的数据最多只有前和后两个方向.
非线性表: 数据之间并不是简单的前后关系. 线性表数据结构包括: 数组,链表,队列,栈. 非线性表数据结构包括: 二叉树,堆,图 。

1.数组的查找操作时间复杂度并不是 O ( 1 ) O(1) O(1)。即便是排好的数组,用二分查找,时间复杂度也是 O ( l g n ) O(lgn) O(lgn)。正确表述:数组支持随机访问,根据下标随机访问的时间复杂度为 O ( 1 ) O(1) O(1)
2.连续的内存空间和相同类型的数据:正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。

数组是如何实现根据下标随机访问数组元素

拿一个长度为 10 的 int 类型的数组 int[] a = new int[10]来举例。计算机给数组 a[10],分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 b a s e a d d r e s s base_{address} baseaddress = 1000。

【注】此处使用的Java语言定义int类型,Java基本类型的整型分4中:byte、short、int、long,长度分别为1、2、4、8,所以此处int数组的每个值长度为4,所以分配的内存长度是10×4=40,此处内存分配从1000-1039共40个字节长度。

计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:
a [ i ] a d d r e s s = b a s e a d d r e s s + i ∗ d a t a t y p e S i z e a[i]_{address} = base_{address} + i * data_{typeSize} a[i]address=baseaddress+idatatypeSize
对于 m * n 的数组,a[i][j] (i < m,j < n)的地址为:
a [ i ] a d d r e s s = b a s e a d d r e s s + ( i ∗ n + j ) ∗ d a t a t y p e S i z e a[i]_{address} = base_{address} + (i*n+j) * data_{typeSize} a[i]address=baseaddress+(in+j)datatypeSize
【注】数组和链表的区别?很多人都回答说,“链表适合插入、删除,时间复杂度 O ( 1 ) O(1) O(1);数组适合查找,查找时间复杂度为 O ( 1 ) O(1) O(1)”。这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为 O ( 1 ) O(1) O(1)。即便是排好序的数组,用二分查找,时间复杂度也是 O ( l g n ) O(lgn) O(lgn)。所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O ( 1 ) O(1) O(1)

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

  • 插入
    插入的位置不同,会导致针对同一段代码,我们的时间复杂度有量级的差距.
    1.我们插入的位置位于数组的最后一位:不需要移动任何元素,最好时间复杂度为 O ( 1 ) O(1) O(1).
    2.我们插入的位置位于数组的第一位,需要移动n个元素,最坏时间复杂度为 O ( n ) O(n) O(n).
    3.平均时间复杂度.先把概率算清, 插入到任一位置的可能性都是一样的. n n n个位置.所以插入到每一个位置的概率都是 1 n \frac{1}{n} n1. 插入到数组的第一个位置需要移动 n n n个元素. 插入到数组的第二个位置需要移动 n − 1 n-1 n1个元素,以此类推,插入到数组中的最后一个位置,需要移动1个元素.
    ( n + n − 1 + n − 2 + ⋅ ⋅ ⋅ + 1 ) n = n + 1 2 = O ( n ) \frac{(n+n-1+n-2+···+1)}{n}=\frac{n+1}{2}=O(n) n(n+n1+n2++1)=2n+1=O(n)
    所以: 数组插入操作的平均时间复杂度为 O ( n ) O(n) O(n).

    如果数组中的数据是有序的,我们在某个位置插入一个新的元素时,就必须按照刚才的方法搬移 k k k之后的数据。但是,如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数据插入到第 k k k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k k k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k k k 个位置。

  • 删除
    在不一定非得追求数组中数据的连续性的情况下。如果将多次删除操作集中在一起执行,删除的效率会提高很多,即:可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。类似于Java的垃圾回收核心算法。

警惕数组的访问越界问题

看看这段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”,因为数组大小为 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 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。
【注】:关于运行这段C程序会不会出现死循环,结果和编译器的实现有关,gcc有一个编译选项(-fno-stack-protector)用于关闭堆栈保护功能。默认情况下启动了堆栈保护,不管i声明在前还是在后,i都会在数组之后压栈,只会循环4次;如果关闭堆栈保护功能,则会出现死循环。请参考:https://www.ibm.com/developerworks/cn/linux/l-cn-gccstack/index.html
【注】:函数体内的局部变量存在栈上,且是连续压栈。在Linux进程的内存布局中,栈区在高地址空间,从高向低增长。变量i和arr在相邻地址,且i比arr的地址大,所以arr越界正好访问到i。当然,前提是i和arr元素同类型,否则那段代码仍是未决行为。

容器能否完全替代数组?

针对数组类型,很多语言都提供了容器类,比如 Java 中的 ArrayList、C++ STL 中的 vector。

容器的底层都是数组,只不过对于Java中的容器而言,Vector是线程安全的,但是效率太低。ArrayList是非线程安全的,效率高。Vector是java早期版本使用的容器,而ArrayList则是java近期版本使用的容器,ArrayList的出现就是为了取代Vector的。平时使用的list等语言中的数据类型属于对其进行的封装,也称为容器,容器会帮助开发者自动实现一些功能去实现对数组的操作在项目开发中。

对于业务开发,直接使用容器就足够了,省时省力。做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

why大多数编程语言数组下标从0开始?

从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。如果用 a 来表示数组的首地址,a[0]就是偏移为 0 的位置,也就是首地址,a[k]就表示偏移 k 个 type_size 的位置,所以计算 a[k]的内存地址:
a [ i ] a d d r e s s = b a s e a d d r e s s + i ∗ d a t a t y p e S i z e a[i]_{address} = base_{address} + i * data_{typeSize} a[i]address=baseaddress+idatatypeSize
但是如果数组从 1 开始计数:
a [ i ] a d d r e s s = b a s e a d d r e s s + ( i − 1 ) ∗ d a t a t y p e S i z e a[i]_{address} = base_{address} + (i-1) * data_{typeSize} a[i]address=baseaddress+(i1)datatypeSize
从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。

猜你喜欢

转载自blog.csdn.net/weixin_43141320/article/details/112758829