【数据结构】数组

news2024/11/15 23:33:51

第一章、为什么数组的下标一般从0开始编号

        提到数组,读者肯定不陌生,甚至还会很自信地说,数组很简单。编程语言中一般会有数组这种数据类型。不过,它不仅是编程语言中的一种数据类型,还是基础的数据结构。尽管数组看起来非常基础,简单,但是深究起来,数组还有很多值得思考的地方。

        例如,在大部分编程语言中,数组的下标是从0开始编号的。读者是否想过,为什么数组的下标要从0开始编号,而不是从1开始尼?从1开始编号不是更符合人类的思维习惯吗?读者可以带着这些问题学习本章的内容。

一、数组的定义

什么是数组?

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

        数组的定义中的第一个关键词是“线性表”(linear list)。顾名思义,线性表指的是数据排列成一条线一样的结构。线性表中的数据只有前、后两个方向。其实,除数组之外,本章要讲到的链表,栈和队列都是线性表结构,如图所示:

        与线性表相对立的概念是非线性表,如树,图等,如图所示。之所以称为非线性表,是因为数据之间并不是简单的前后关系。

        数组的定义中的第二个关键词和第三个关键词是“连续的内存空间”和“相同类型的数据”。正是因为这两个限制,数组才有了一个重要的特性:随机访问。不过,有利就有弊,这两个限制也让数组的很多操作变得非常低效。例如,要想在数组中插入或者删除一个数据,为了保证数组中存储数据的连续性,我们需要做大量的数据搬移工作。

二、寻址公式和随机访问特性

        “随机访问”具体指的是:支持在O(1)时间复杂度内按照下标快速访问数组中的元素。我们用一个长度为10,int类型的数组a[10](代码实现为 int[] a = new int[10];)来举例。假设计算机给数组a[10]分配了一块连续内存空间,其中,内存空间的首地址 base_address = 1000。数组的内存存储模型如图所示:

        我们知道,计算机会给每个内存单元分配一个地址,目的是方便计算机通过地址来访问内存中的数据。当计算机想要访问下标为 i 的数组元素时,它首先通过下面的寻址公式:

a[i]_address = base_address + i x data_type_size

        其中,data_type_size表示数组中每个元素的大小。由于数组中存储的是int类型的数据(int类型占4字节的存储空间),因此 data_type_size就等于4。

        在这里,作者要纠正一个“错误”。作者在面试应聘者的时候,常常会向应聘者询问数组和链表的区别,很多应聘者回答:“链表适合插入,删除,对应的时间复杂度为O(1);数组适合查找,查找的时间复杂度为O(1)”。

        实际上,这种表述是不准确的,因为在数组中查找数据的时间复杂度并不为O(1)。即便是排好序的数组,用二分查找,时间复杂度也只能达到O(logn)。因此,正确的表述应该是:数组支持随机访问,根据下标访问元素的时间复杂度为O(1)。

三、低效的插入和删除操作

        在上文中,我们提到,为了保持内存数据的连续性,数组的插入,删除操作会比较低效。现在我们就来解释一下为什么这两种操作会低效,同时探讨一下有哪些改进方法。

        我们先来看插入操作。

        假设数组的长度为n。现在,假设我们需要将一个数据插入到数组中的第k个位置。为了把第k个位置腾出来给新来的数据,我们需要将第k~n这部分元素顺序地往后移动一位。在这种情况下,插入操作的时间复杂度是多少尼?读者可以自己先试着分析一下。

        如果在数组的末尾插入元素,那么不需要移动数据,最好情况时间复杂度为O(1)。但如果在数组的开头插入元素,那么所有的数据都需要依次往后移动一位,最坏情况时间复杂度为O(n)。

        因为在每个位置插入元素的概率是相同的,一共有 n + 1种情况,所以平均情况时间复杂度为 (1 + 2 +...+n)/(n + 1) = O(n)。

        如果数组中的数据是有序的,在某个位置(假设下标为k的位置)插入一个新的数据时。就必须按照刚才的方法,搬移下标k之后的数据。但是,如果数组中存储的数据并没有任何规律,那么数组只是被当成一个存储数据的集合。在这种情况下,为了避免大规模的数据搬移,我们可以将第k位的数据搬移到数组的最后,然后把新数据直接放到第k个位置即可。为了更好地理解这段描述,我们通过一个例子来进一步解释一下。

        假设数组a中存储了5个元素:a、b、c、d、e。现在,需要将元素x插入到第3个位置。按照上面的处理思路,只需要将原来在第3个位置的c放入到a[5] 这个位置,然后将 a[2] 赋值为x。最后,数组中的元素为 a、b、x、d、e、c,如下图所示:

        利用这种处理技巧,在特定场景下,在第k个位置插入数据的时间复杂度就变成了O(1)。这种处理思路在快速排序中也会用到。

        下面再看一下删除操作。

        与插入操作类似,如果我们要删除第k个位置的数据,为了存储数据的连续性,那么也需要搬移数据,不然中间就会存在已经删除的数据,数组中的数据就不连续了。因此,如果删除数组末尾的数据,则最好时间复杂度为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这几个元素被搬移3次,每次的删除操作并不真正地搬移数据,而只是标记数据已被删除。当数组中没有更多的存储空间时,我们再集中触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移次数。

        如果读者了解JVM(java虚拟机),就会发现,这不就是JVM标记清除“垃圾”回收算法的核心思想吗?没错。数据结构和算法的魅力就在于此。很多时候我们并不需要“死记硬背”某个数据结构或算法,而是要学习其背后的思想和处理技巧。这些东西才是最有价值的。如果读者细心留意,就会发现,无论是在软件开发还是架构设计中,总能找到数据结构和算法的影子。

四、警惕数组访问越界问题

          在C语言中,数组访问越界是一种未决行为,换句话说,C语言规范并没有规定数组访问越界时编译器应该如何处理。访问数组的本质就是访问一段连续内存,只要通过偏移计算得到的内存地址是可用的,即便数组访问越界,程序就有可能不会报出任何错误。

        数组访问越界一般会导致程序出现莫名其妙的运行错误,调试的难度非常大。除此之外,很多计算机病毒也正是利用了数组越界可以访问非法地址的漏洞来攻击系统的。因此,在写代码的时候,我们一定要警惕数组访问越界问题。

        但并非所有的语言都像C语言一样,把数组越界检查的工作“交”给程序员来做。例如Java语言,它本身就会进行越界检查,如下面这两行Java代码,数组访问越界,运行时就会抛出java.lang.ArrayIndexOutOfBoundsException 异常。

public static void main(String[] args) {
        int[] ints = new int[3];
        System.out.println(ints[1]); //0
//        System.out.println(ints[3]); //java.lang.ArrayIndexOutOfBoundsException
        ints[3] = 10; //java.lang.ArrayIndexOutOfBoundsException
}

数组访问越界异常,python会抛出IndexError异常

my_list = [i for i in range(3)]
print(my_list[3]) # IndexError: list index out of range

五、容器能否完全替代数组

        针对数组类型,很多编程语言提供了容器类,如Java中的ArrayList,Python中的list集合。在项目开发中,什么时候适合用高数组?什么时候适合用容器?

        这里作者用Java语言来举例。如果读者是Java工程师,应该很熟悉ArrayList,那么它与数组相比,到底有哪些优势尼?

        ArrayList最大优势是:可以将很多数组操作的细节封装起来,如上文提到的数组插入,删除数据时的搬移操作。除此之外,他还有一个优势,就是支持动态扩容。

        因为数组需要连续的内存存储空间,所以在定义的时候,需要预先指定内存空间大小。如果我们申请了一个大小为10的数组,当第11个数组需要存储到数组中,就需要重新分配一块更大的内存空间,将原来的数据复制过去,然后将新的数据插入。

        如果使用ArrayList,我们就完全不需要关系底层的扩容逻辑,刚才提到的这些细节会封装在ArrayList中。

        这里需要注意一点,由于扩容操作涉及内存申请和数据搬移,是比较耗时的,因此,如果事先能确定需要存储的数据的大小,最好在创建ArrayList的时候,事先指定容器的大小,这样就能避免在插入数据的过程中出现频繁的扩容操作。举例如下:

ArrayList<Object> objects = new ArrayList<>(10000); //实现指定容器大小

        对于使用高级语言编程的读者,有了容器,数组是不是就无用武之地了尼?当然不是,有些时候,用数组会更加合适,如下面集中情况。

  • Java ArrayList无法存储基本类型,如int,long,需要封装为Integer类和Long类,而自动装箱(autoboxing),拆箱(unboxing)有一定的性能消耗,因此,如果特别关注性能,或者希望使用基本类型,就可以选用数组。
  • 如果数据大小事先已知,并且对数据的操作非常简单,用不到ArrayList提供的大部分方法,那么可以直接使用数组。
  • 还有一个算是作者的个人喜好:当需要表示多维数组时,使用数组往往会更加直观,如Object[] array。而如果使用容器的话,那么需要这样定义:ArrayList<ArrayList<Object>> array。这样编写比较麻烦,可读性也不如Object[][] array强。

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

六、解答本章开篇问题

        现在我们来看一下开篇的问题:为什么在大多数编程语言中,数组的下标从0开始编号,而不是从1开始编号尼?

        从数组存储的内存模型来看,“下标”确切的定义应该是“偏移”(offset)。a[0]就是相对于首地址偏移为0的内存地址,a[k]就是相对于首地址偏移 k 个type_size的内存地址。从0开始编号,计算a[k] 的内存地址只需要用下式:

a[k]_address = base_address + k x data_type_size

但是,如果从1开始编号,计算a[k]的内存地址的公式就会变成如下:

a[k]_address = base_address + (k-1) x data_type_size

        对比上面的两个公式,我们不难发现,如果数组下标从1开始编号,每次按照下标访问数组元素,会多一次减法运算。数组是基础的数据结构,通过下标访问数组元素又是其基础的操作,效率的优化就要尽可能做到极致。因此,为了减少一次减法操作,数组的下标选择了从0开始编号,而不是从1开始编号。

        不过,这个理由可能还不够充分。作者认为,数组的下标从0开始编号还是有其历史原因的。最初,C语言设计者用0作为数组的起始下标,目的是在一定程度上减少C语言程序员学习其他编程语言的成本,之后的Java,JavaScript等效仿了C语言,继续沿用了数组下标从0开始编号的方式。

        当然,也并不是所有的编程语言中的数组下标都是从0开始编号,如MATLAB。甚至,一些语言支持负数下标,如Python。

七、内容小结

        数组是简单的数据结构,是很多数据结构的实现基础。数组用一块连续的内存空间存储相同类型的一组数据。数组最大的特点是:

支持在O(1)的时间复杂度内按照下标快速访问元素,但插入操作,删除操作也因此变得比较低效,平均时间复杂度为O(n)。

在平时的业务开发中,我们可以直接使用编程语言提供的数组类容器,如Java ArrayList,但是,对于偏底层的开发,考虑到性能,直接使用数组可能会更加合适。

八、思考题

本章讲到了一维数组的内存寻址公式,类比一下,二维数组的内存寻址公式是怎么的?

类比一维数组的寻址公式,对于m x n的二维数组,其寻址公式分下列两种情况。

第一种情况:如果数组是按行存储的(先存储第一行,在存储第二行,依次类推),那么二维数组的寻址公式为:

a[i][j]_address = base_address + (n x i + j) x data_type_size

第二种情况:如果数组是按列存储的(先存储第一列,在存储第二列,依次类推),那么二维数组的寻址公式为:

a[i][j]_address = base_address + (m x j + i) x data_type_size

        除此之外,有很多编程语言对数组重新进行了定义和改造,它们的二维数组的寻址公式无法满足前面给出的标准形式,如Java中的多维数组的内存空间是不连续的。

第二章、数据结构中的数组和编程语言中的数组的区别

        在上一章中,我们提到了数组是存储相同数据类型的一块连续的存储空间。解读一下:数组中的数据必须是相同类型的,数组中的数据必须是连续存储的。只有这样,数组才能实现根据下标快速地访问数据。

        有些读者可能会发现,在有些编程语言中,“数组”这种数据类型并不一定完全符合上面的定义。例如,在JavaScript中,数组中的数据不一定是连续存储的,也不一定是相同类型的,甚至,数组还可以是变长的。

var arr = new array(4, "hello", new Date());

        在大部分关于数据结构和算法的图书中,在提到二维数组或者多维数组中数据的存储方法的时候,一般会这样介绍:二维数组中的数据已“先按行,再按列”(或者“先按列,再按行”)的方式依次存储在连续的存储空间中。如果二维数组定义为 a[n][m],那么a[i][j]的寻址公式如式所示:(以“先按行,再按列”的方式存储)。

address_a[i][j] = base_address + (i x m + j) x data_type_size

        但是,在有些编程语言中,二维数组并不满足上面的定义,寻址公式也并非如此。例如Java中的二维数组的第二维可以是不同长度的,如下所示,而且第二维的3个数组(arr[0],arr[1],arr[2])在内存中也并非连续存储。

public static void main(String[] args) {
        int arr[][] = new int[3][];
        arr[0] = new int[1];
        arr[1] = new int[2];
        arr[2] = new int[3];
        System.out.println(Arrays.toString(arr)); // [[I@1b6d3586, [I@4554617c, [I@74a14482]
}

        难道有些关于数据结构和算法的图书里的讲解脱离实践?难道编程语言中的数组没有完全按照数组的定义来设计?哪个对尼?

        实际上,这两种讲法都没错。编程语言中的“数组”并不完全等同于我们在介绍数据结构和算法的时候提到的“数组”。编程语言在实现自己的“数组”类型的时候,并不是完全遵循数据结构中的“数组”的定义,而是针对编程语言自身的特点,进行了相应的调整。

        在不同的编程语言中,数组这种数据类型的实现方法不大相同。本章利用几种比较经典的编程语言,如C语言,Java,JavaScript,向读者介绍一下几种比较有代表性的数组实现方式。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1483178.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

加密与安全_探索对称加密算法

文章目录 概述常用的对称加密算法AESECB模式CBC模式 (推荐)ECB VS CBC 附&#xff1a;AES工具类总结 概述 对称加密算法是一种加密技术&#xff0c;使用相同的密钥来进行加密和解密数据。在这种算法中&#xff0c;发送方使用密钥将明文&#xff08;未加密的数据&#xff09;转…

腾讯云幻兽帕鲁服务器中,如何检查并确保所有必要的配置文件(如PalWorldSettings.ini和WorldOption.sav)正确配置?

腾讯云幻兽帕鲁服务器中&#xff0c;如何检查并确保所有必要的配置文件&#xff08;如PalWorldSettings.ini和WorldOption.sav&#xff09;正确配置&#xff1f; 登录腾讯云控制台&#xff1a;登录轻量云控制台&#xff0c;找到部署了幻兽帕鲁的服务器&#xff0c;单击实例卡片…

二维码门楼牌管理系统技术服务的深度解析

文章目录 前言一、标准地址名称的定义与重要性二、二维码门楼牌管理系统的核心技术三、标准地址名称在二维码门楼牌管理中的应用四、二维码门楼牌管理系统的优势与挑战五、展望未来 前言 在数字化浪潮中&#xff0c;二维码门楼牌管理系统以其高效、便捷的特性&#xff0c;正逐…

46、WEB攻防——通用漏洞PHP反序列化原生类漏洞绕过公私有属性

文章目录 几种常用的魔术方法1、__destruct()2、__tostring()3、__call()4、__get()5、__set()6、__sleep()7、__wakeup()8、__isset()9、__unset()9、__invoke() 三种变量属性极客2019 PHPphp原生类 几种常用的魔术方法 1、__destruct() 当删除一个对象或对象操作终止时被调…

Android13 Audio框架

一、Android 13音频代码结构 1、framework: android/frameworks/base 1.AudioManager.java &#xff1a;音频管理器&#xff0c;音量调节、音量UI、设置和获取参数等控制流的对外API 2.AudioService.java &#xff1a;音频系统服务&#xff08;java层&#xff09;&#xff0c…

Tuning Language Models by Proxy

1、写作动机&#xff1a; 调整大语言模型已经变得越来越耗资源&#xff0c;或者在模型权重是私有的情况下是不可能的。作者引入了代理微调&#xff0c;这是一种轻量级的解码时算法&#xff0c;它在黑盒 大语言模型 之上运行&#xff0c;以达到直接微调模型的结果&#xff0c;但…

【数据结构】之优先级队列(堆)

文章目录 一、优先级队列的概念二、优先级队列的模拟实现1.堆的存储2.堆的创建3.代码的实现 一、优先级队列的概念 队列是一种先进先出(FIFO)的数据结构&#xff0c;但有些情况下&#xff0c;操作的数据可能带有优先级&#xff0c;一般出队列时&#xff0c;可能需要优先级高的…

wireshark抓取localhost(127.0.0.1)数据包

打开wireshark中&#xff0c;在"capture"菜单中&#xff0c;选择"interfaces"子菜单&#xff0c;在列出的接口中选中"Adapter for loopback traffic capture"即可。 必须安装了Npcap才有此选项&#xff0c;否则需要重新安装wireshark。 抓包截图…

Windows 10 合并磁盘分区 (G and H)

Windows 10 合并磁盘分区 [G and H] 1. 设备和驱动器2. 计算机 -> 管理 -> 存储 -> 磁盘管理3. 删除卷4. 新建简单卷5. 设备和驱动器References 1. 设备和驱动器 2. 计算机 -> 管理 -> 存储 -> 磁盘管理 3. 删除卷 H: -> right-click -> 删除卷 H: 变…

c语言经典测试题10

1.题1 int fun( int x) {int n 0;while (x 1){n;x x | (x 1);}return n; } int main() {int ret fun(2014);printf("%d", ret);return 0; } 上述代码运行结果是什么呢&#xff1f; 我们来分析一下&#xff1a;这里的fun函数有一个while循环&#xff0c;其判断…

uniApp 调整小程序 单个/全部界面横屏展示效果

我们打开uni项目 小程序端运行 默认是竖着的一个效果 我们打开项目的 pages.json 给需要横屏的界面 的 style 属性 加上 "mp-weixin": {"pageOrientation": "landscape" }界面就横屏了 如果是要所有界面都横屏的话 就直接在pages.json 的 gl…

14-Linux部署Hadoop集群

Linux部署Hadoop集群 简介 1&#xff09;Hadoop是一个由Apache基金会所开发的分布式系统基础架构。 2&#xff09;主要解决&#xff0c;海量数据的存储和海量数据的分析计算问题。 Hadoop HDFS 提供分布式海量数据存储能力 Hadoop YARN 提供分布式集群资源管理能力 Hadoop…

Django后端开发——cookies和session

文章目录 参考资料会话保持Cookiesviews.pyurls.py Sessionviews.pyurls.py Cookies和session对比 参考资料 B站网课&#xff1a;点击蓝色字体跳转 或复制链接至浏览器&#xff1a;https://www.bilibili.com/video/BV1vK4y1o7jH/?p29&spm_id_from333.1007.top_right_bar_…

Android Gradle开发与应用 (四) : Gradle构建与生命周期

1. 前言 前几篇文章&#xff0c;我们对Gradle中的基本知识&#xff0c;包括Gradle项目结构、Gradle Wrapper、GradleUserHome、Groovy基础语法、Groovy语法概念、Groovy闭包等知识点&#xff0c;这篇文章我们接着来介绍Gradle构建过程中的知识点。 2. Project : Gradle中构建…

Linux - 基本开发工具

1、软件包管理器 yum 1.1、什么是软件包 在Linux下安装软件, 一个通常的办法是下载到程序的源代码, 并进行编译, 得到可执行程序但是这样太麻烦了, 于是有些人把一些常用的软件提前编译好, 做成软件包(可以理解成windows上的安装程序)放在一个服务器上, 通过包管理器可以很方…

接口测试(全)

&#x1f345; 视频学习&#xff1a;文末有免费的配套视频可观看 &#x1f345; 关注公众号【互联网杂货铺】&#xff0c;回复 1 &#xff0c;免费获取软件测试全套资料&#xff0c;资料在手&#xff0c;涨薪更快 大多数人对于接口测试都觉得是一种高大上的测试&#xff0c;觉得…

Flutter开发之CupertinoApp

Flutter开发之CupertinoApp 最近由于使用Flutter编程更多&#xff0c;使用Flutter更顺手&#xff0c;相对于其他前端框架来说&#xff0c;Flutter在跨平台、响应式UI、自绘引擎、即插即用的组件和庞大的社区生态支持方面有更大的优势&#xff1b;Flutter拥有更低的学习成本&am…

前端monorepo大仓共享复杂业务组件最佳实践

一、背景 在 Monorepo 大仓模式中&#xff0c;我们把组件放在共享目录下&#xff0c;就能通过源码引入的方式实现组件共享。越来越多的应用愿意走进大仓&#xff0c;正是为了享受这种组件复用模式带来的开发便利。这种方式可以满足大部分代码复用的诉求&#xff0c;但对于复杂…

AutoEncoder和 Denoising AutoEncoder学习笔记

参考&#xff1a; 【1】 https://lilianweng.github.io/posts/2018-08-12-vae/ 写在前面&#xff1a; 只是直觉上的认识&#xff0c;并没有数学推导。后面会写一篇&#xff08;抄&#xff09;大一统文章&#xff08;概率角度理解为什么AE要选择MSE Loss&#xff09; TOC 1 Au…

Java进阶-IO(1)

进入java IO部分的学习&#xff0c;首先学习IO基础&#xff0c;内容如下。需要了解流的概念、分类还有其他一些如集合与文件的转换&#xff0c;字符编码问题等&#xff0c;这次先学到字节流的读写数据&#xff0c;剩余下次学完。 一、IO基础 1、背景 1.1 数据存储问题 变量…