【数据结构】数组

第一章、为什么数组的下标一般从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.ArrayIndexOutOfBoundsExceptionints[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.rhkb.cn/news/267330.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

AI新工具(20240301) Ideogram; Image to Music Generator等

1: Ideogram 全新的多模态生图AI工具&#xff0c;以其优秀的文字渲染能力和生图能力受到业界瞩目 Ideogram是一个创新的AI工具&#xff0c;它通过在生成的图片中自然地整合文字&#xff0c;解决了生图AI领域长期存在的一个难题。这个工具特别擅长将文本以极其自然和协调的方式…

Nginx 隐藏版本信息和logo

1.隐藏版本信息 http {### 隐藏版本号 server_tokens off; } 2.隐藏图标 2.1 cd nginx 安装的路径 cd/XXXX/nginx-1.2.0 2.2 编辑文件 vim src/core/nginx.h 修改define nginx_ver 中的内容 vim src/http/ngx_http_special_response.c 修改 u_char ngx_http_error_tail[]…

腾讯云4核8G的云服务器性能水平?使用场景说明

腾讯云4核8G服务器适合做什么&#xff1f;搭建网站博客、企业官网、小程序、小游戏后端服务器、电商应用、云盘和图床等均可以&#xff0c;腾讯云4核8G服务器可以选择轻量应用服务器4核8G12M或云服务器CVM&#xff0c;轻量服务器和标准型CVM服务器性能是差不多的&#xff0c;轻…

【生成式AI】ChatGPT 原理解析(2/3)- 预训练 Pre-train

Hung-yi Lee 课件整理 预训练得到的模型我们叫自监督学习模型&#xff08;Self-supervised Learning&#xff09;&#xff0c;也叫基石模型&#xff08;foundation modle&#xff09;。 文章目录 机器是怎么学习的ChatGPT里面的监督学习GPT-2GPT-3和GPT-3.5GPTChatGPT支持多语言…

element-plus表格合并

要实现这样的表格&#xff0c; 怎么做呢&#xff1f; 甚至是这种三级的呢&#xff1f; 官网的案例也是通过这个方法进行配置的&#xff0c;也就是说表格长什么样&#xff0c;关键在怎么处理的方法上。 这是官网的方法&#xff0c;可参考拓展&#xff1a; const arraySpanMeth…

【airtest】自动化入门教程(三)Poco操作

目录 一、准备工作 1、创建一个pthon脚本 2、光标位置 2、选择Android 3、选择yes 二、定位元素 三、poco基于设备/屏幕 方式 1、poco.click( (x,y))基于屏幕点击相对坐标为x&#xff0c;y的位置 2、poco.get_screen_size() 3、poco.swipe(v1,v2)基于屏幕从v1位置滑到…

Python爬虫实战第二例【二】

零.前言&#xff1a; 本文章借鉴&#xff1a;Python爬虫实战&#xff08;五&#xff09;&#xff1a;根据关键字爬取某度图片批量下载到本地&#xff08;附上完整源码&#xff09;_python爬虫下载图片-CSDN博客 大佬的文章里面有API的获取&#xff0c;在这里我就不赘述了。 一…

Vue2:路由的两种模式history模式和hash模式

一、情景说明 之前我们写的项目启动后&#xff0c;浏览器访问时&#xff0c;路径中会有个#/&#xff0c;会导致不够美观 因为一般的访问地址都是http://123.123.123.123/aaa/bbb这种形式 这一篇&#xff0c;就来解决这个问题 二、案例 1、hash模式 特点&#xff1a;#/后的…

Docker与虚拟机比较

在对比Docker和虚拟机前&#xff0c;先简单了解下虚拟化&#xff0c;明确Docker和虚拟机分别对应的虚拟化级别&#xff0c;然后对Docker和虚拟机进行比较。需要注意的是&#xff0c;Docker和虚拟机并没有什么可比性&#xff0c;而是Docker使用的容器技术和虚拟机使用的虚拟化技…

微信小程序触屏事件_上划下划事件

一、微信小程序触屏事件 bindtouchstart&#xff1a;手指触摸动作开始 bindtouchmove&#xff1a;手指触摸后移动 bindend&#xff1a;手指触摸动作结束 属性类型说明touchesArray触摸事件&#xff0c;当前停留在屏幕中的触摸点信息的数组 Touch 对象 属性类型说明identi…

《HelloGitHub》第 95 期

兴趣是最好的老师&#xff0c;HelloGitHub 让你对编程感兴趣&#xff01; 简介 HelloGitHub 分享 GitHub 上有趣、入门级的开源项目。 这里有实战项目、入门教程、黑科技、开源书籍、大厂开源项目等&#xff0c;涵盖多种编程语言 Python、Java、Go、C/C、Swift...让你在短时间内…

DDR5内存相比DDR4内存的优势和区别?选择哪一个服务器内存配置能避免丢包和延迟高?

根据幻兽帕鲁服务器的实际案例分析&#xff0c;选择合适的DDR4与DDR5内存大小以避免丢包和延迟高&#xff0c;需要考虑以下几个方面&#xff1a; 性能与延迟&#xff1a;DDR5内存相比DDR4在传输速率、带宽、工作电压等方面都有显著提升&#xff0c;但同时也伴随着更高的延迟。D…

Javaweb之SpringBootWeb案例之自动配置的两种常见方案的详细解析

3.2.2.2 方案一 ComponentScan组件扫描 SpringBootApplication ComponentScan({"com.itheima","com.example"}) //指定要扫描的包 public class SpringbootWebConfig2Application {public static void main(String[] args) {SpringApplication.run(Sprin…

Wireless LAN演进整理以及STA与AP之间的关联行为

一、WLAN standard WLAN标准也是由电机电子工程师学会 (IEEE, Electrical and Electronics Engineers)组织制定的&#xff0c;WLAN标准统称为802.11 二、802.11传输标准 IEEE标准 名称 年份 频道 最高传输速率 调变方式 802.11 WIFI 0 1997 2.4 GHz 2 Mbps DSSS 8…

STM32------分析GPIO寄存器

一、初始LED原理图 共阴极led LED发光二极管&#xff0c;需要有电流通过才能点亮&#xff0c;当有电压差就会产生电流 二极管两端的电压差超过2.7v就会有电流通过 电阻的作用 由于公式IV/R 不加电阻容易造成瞬间电流无穷大 发光二极管工作电流为10-20MA 3.3v / 1kΩ 3.…

如何在群晖Docker运行本地聊天机器人并结合内网穿透发布到公网访问

文章目录 1. 拉取相关的Docker镜像2. 运行Ollama 镜像3. 运行Chatbot Ollama镜像4. 本地访问5. 群晖安装Cpolar6. 配置公网地址7. 公网访问8. 固定公网地址 随着ChatGPT 和open Sora 的热度剧增,大语言模型时代,开启了AI新篇章,大语言模型的应用非常广泛&#xff0c;包括聊天机…

获取linuxIP、内存、cpu、磁盘IO等信息的Shell脚本及其讲解

shell基础知识 1.grep grep是一个在Unix和Unix-like系统上使用的命令行工具&#xff0c;用于在文本文件中搜索匹配指定模式的行。它的名字来自于"global regular expression print"&#xff08;全局正则表达式打印&#xff09;的缩写。grep的基本用法是通过指定一个…

Redis之十:Spring Data Redis --- CrudRepository方式

SpringData Redis CrudRepository方式 Spring Data Redis 的 CrudRepository 是 Spring Data 框架中用于提供基础 CRUD&#xff08;创建、读取、更新和删除&#xff09;操作的一个接口。在与 Redis 集成时&#xff0c;尽管 Redis 是一个键值存储系统&#xff0c;并没有像关系型…

iOS卡顿原因与优化

iOS卡顿原因与优化 1. 卡顿简介 卡顿&#xff1a; 指用户在使用过程中出现了一段时间的阻塞&#xff0c;使得用户在这一段时间内无法进行操作&#xff0c;屏幕上的内容也没有任何的变化。 卡顿作为App的重要性能指标&#xff0c;不仅影响着用户体验&#xff0c;更关系到用户留…

零拷贝技术深入分析

一、零拷贝 在前面的文章“深浅拷贝、COW及零拷贝”中对零拷贝进行过分析&#xff0c;但没有举例子&#xff0c;也没有深入进行展开分析。本文将结合实际的例程对零拷贝进行更深入的分析和说明。 在传统的IO操作中&#xff0c;以文件通过网络传输为例 &#xff0c;一般会经历以…