05 | 数组:为什么很多编程语言中数组都从0开始编号?
下载APP
关闭
渠道合作
推荐作者
05 | 数组:为什么很多编程语言中数组都从0开始编号?
2018-10-01 王争 来自北京
《数据结构与算法之美》
课程介绍
讲述:冯永吉
时长14:59大小13.72M
提到数组,我想你肯定不陌生,甚至还会自信地说,它很简单啊。
是的,在每一种编程语言中,基本都会有数组这种数据类型。不过,它不仅仅是一种编程语言中的数据类型,还是一种最基础的数据结构。尽管数组看起来非常基础、简单,但是我估计很多人都并没有理解这个基础数据结构的精髓。
在大部分编程语言中,数组都是从 0 开始编号的,但你是否下意识地想过,为什么数组要从 0 开始编号,而不是从 1 开始呢? 从 1 开始不是更符合人类的思维习惯吗?
你可以带着这个问题来学习接下来的内容。
如何实现随机访问?
什么是数组?我估计你心中已经有了答案。不过,我还是想用专业的话来给你做下解释。数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
这个定义里有几个关键词,理解了这几个关键词,我想你就能彻底掌握数组的概念了。下面就从我的角度分别给你“点拨”一下。
第一是线性表(Linear List)。顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。
而与它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。
第二个是连续的内存空间和相同类型的数据。正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。
说到数据的访问,那你知道数组是如何实现根据下标随机访问数组元素的吗?
我们拿一个长度为 10 的 int 类型的数组 int[] a = new int[10]来举例。在我画的这个图中,计算机给数组 a[10],分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000。
我们知道,计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:
其中 data_type_size 表示数组中每个元素的大小。我们举的这个例子里,数组中存储的是 int 类型数据,所以 data_type_size 就为 4 个字节。这个公式非常简单,我就不多做解释了。
这里我要特别纠正一个“错误”。我在面试的时候,常常会问数组和链表的区别,很多人都回答说,“链表适合插入、删除,时间复杂度 O(1);数组适合查找,查找时间复杂度为 O(1)”。
实际上,这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。
低效的“插入”和“删除”
前面概念部分我们提到,数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。现在我们就来详细说一下,究竟为什么会导致低效?又有哪些改进方法呢?
我们先来看插入操作。
假设数组的长度为 n,现在,如果我们需要将一个数据插入到数组中的第 k 个位置。为了把第 k 个位置腾出来,给新来的数据,我们需要将第 k~n 这部分的元素都顺序地往后挪一位。那插入操作的时间复杂度是多少呢?你可以自己先试着分析一下。
如果在数组的末尾插入元素,那就不需要移动数据了,这时的时间复杂度为 O(1)。但如果在数组的开头插入元素,那所有的数据都需要依次往后移动一位,所以最坏时间复杂度是 O(n)。 因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 (1+2+...n)/n=O(n)。
如果数组中的数据是有序的,我们在某个位置插入一个新的元素时,就必须按照刚才的方法搬移 k 之后的数据。但是,如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数据插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。
为了更好地理解,我们举一个例子。假设数组 a[10]中存储了如下 5 个元素:a,b,c,d,e。
我们现在需要将元素 x 插入到第 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 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。
如果你了解 JVM,你会发现,这不就是 JVM 标记清除垃圾回收算法的核心思想吗?没错,数据结构和算法的魅力就在于此,很多时候我们并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的。如果你细心留意,不管是在软件开发还是架构设计中,总能找到某些算法和数据结构的影子。
警惕数组的访问越界问题
了解了数组的几个基本操作后,我们来聊聊数组访问越界的问题。
首先,我请你来分析一下这段 C 语言代码的运行结果:
你发现问题了吗?这段代码的运行结果并非是打印三行“hello word”,而是会无限打印“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 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。
这种情况下,一般都会出现莫名其妙的逻辑错误,就像我们刚刚举的那个例子,debug 的难度非常的大。而且,很多计算机病毒也正是利用到了代码中的数组越界可以访问非法地址的漏洞,来攻击系统,所以写代码的时候一定要警惕数组越界。
但并非所有的语言都像 C 一样,把数组越界检查的工作丢给程序员来做,像 Java 本身就会做越界检查,比如下面这几行 Java 代码,就会抛出 java.lang.ArrayIndexOutOfBoundsException。
容器能否完全替代数组?
针对数组类型,很多语言都提供了容器类,比如 Java 中的 ArrayList、C++ STL 中的 vector。在项目开发中,什么时候适合用数组,什么时候适合用容器呢?
这里我拿 Java 语言来举例。如果你是 Java 工程师,几乎天天都在用 ArrayList,对它应该非常熟悉。那它与数组相比,到底有哪些优势呢?
我个人觉得,ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容。
数组本身在定义的时候需要预先指定大小,因为需要分配连续的内存空间。如果我们申请了大小为 10 的数组,当第 11 个数据需要存储到数组中时,我们就需要重新分配一块更大的空间,将原来的数据复制过去,然后再将新的数据插入。
如果使用 ArrayList,我们就完全不需要关心底层的扩容逻辑,ArrayList 已经帮我们实现好了。每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍大小。
不过,这里需要注意一点,因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小。
比如我们要从数据库中取出 10000 条数据放入 ArrayList。我们看下面这几行代码,你会发现,相比之下,事先指定数据大小可以省掉很多次内存申请和数据搬移操作。
作为高级语言编程者,是不是数组就无用武之地了呢?当然不是,有些时候,用数组会更合适些,我总结了几点自己的经验。
1.Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。
3. 还有一个是我个人的喜好,当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList<ArrayList<object> > array。
我总结一下,对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。
解答开篇
现在我们来思考开篇的问题:为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始呢?
从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。前面也讲到,如果用 a 来表示数组的首地址,a[0]就是偏移为 0 的位置,也就是首地址,a[k]就表示偏移 k 个 type_size 的位置,所以计算 a[k]的内存地址只需要用这个公式:
但是,如果数组从 1 开始计数,那我们计算数组元素 a[k]的内存地址就会变为:
对比两个公式,我们不难发现,从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。
数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。
不过我认为,上面解释得再多其实都算不上压倒性的证明,说数组起始编号非 0 开始不可。所以我觉得最主要的原因可能是历史原因。
C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。
内容小结
我们今天学习了数组。它可以说是最基础、最简单的数据结构了。数组用一块连续的内存空间,来存储相同类型的一组数据,最大的特点就是支持随机访问,但插入、删除操作也因此变得比较低效,平均情况时间复杂度为 O(n)。在平时的业务开发中,我们可以直接使用编程语言提供的容器类,但是,如果是特别底层的开发,直接使用数组可能会更合适。
课后思考
前面我基于数组的原理引出 JVM 的标记清除垃圾回收算法的核心理念。我不知道你是否使用 Java 语言,理解 JVM,如果你熟悉,可以在评论区回顾下你理解的标记清除垃圾回收算法。
前面我们讲到一维数组的内存寻址公式,那你可以思考一下,类比一下,二维数组的内存寻址公式是怎样的呢?
欢迎留言和我分享,我会第一时间给你反馈。
分享给需要的人,Ta购买本课程,你将得20元
生成海报并分享
赞 518
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
04 | 复杂度分析(下):浅析最好、最坏、平均、均摊时间复杂度
下一篇
06 | 链表(上):如何实现LRU缓存淘汰算法?
精选留言(1059)
- 杰杰置顶2018-10-01JVM标记清除算法: 大多数主流虚拟机采用可达性分析算法来判断对象是否存活,在标记阶段,会遍历所有 GC ROOTS,将所有 GC ROOTS 可达的对象标记为存活。只有当标记工作完成后,清理工作才会开始。 不足:1.效率问题。标记和清理效率都不高,但是当知道只有少量垃圾产生时会很高效。2.空间问题。会产生不连续的内存空间碎片。 二维数组内存寻址: 对于 m * n 的数组,a [ i ][ j ] (i < m,j < n)的地址为: address = base_address + ( i * n + j) * type_size 另外,对于数组访问越界造成无限循环,我理解是编译器的问题,对于不同的编译器,在内存分配时,会按照内存地址递增或递减的方式进行分配。老师的程序,如果是内存地址递减的方式,就会造成无限循环。 不知我的解答和理解是否正确,望老师解答?展开
作者回复: 完全正确✅
共 66 条评论1615 - slvher2018-10-01对文中示例的无限循环有疑问的同学,建议去查函数调用的栈桢结构细节(操作系统或计算机体系结构的教材应该会讲到)。 函数体内的局部变量存在栈上,且是连续压栈。在Linux进程的内存布局中,栈区在高地址空间,从高向低增长。变量i和arr在相邻地址,且i比arr的地址大,所以arr越界正好访问到i。当然,前提是i和arr元素同类型,否则那段代码仍是未决行为。展开
作者回复: 👍 高手!
共 38 条评论1837 - 夜下凝月2018-10-07突然想到了垃圾桶。 生活中,我们扔进屋里垃圾桶的垃圾, 并没有消失,只是被 ''标记'' 成了垃圾, 只有垃圾桶塞满时,才会清理垃圾桶。 再次存放垃圾共 43 条评论1174
- 不诉离殇2018-10-01例子中死循环的问题跟编译器分配内存和字节对齐有关 数组3个元素 加上一个变量a 。4个整数刚好能满足8字节对齐 所以i的地址恰好跟着a2后面 导致死循环。。如果数组本身有4个元素 则这里不会出现死循环。。因为编译器64位操作系统下 默认会进行8字节对齐 变量i的地址就不紧跟着数组后面了。
作者回复: 高手!
共 22 条评论698 - zyzheng2018-10-03关于数组越界访问导致死循环的问题,我也动手实践了一下,发现结果和编译器的实现有关,gcc有一个编译选项(-fno-stack-protector)用于关闭堆栈保护功能。默认情况下启动了堆栈保护,不管i声明在前还是在后,i都会在数组之后压栈,只会循环4次;如果关闭堆栈保护功能,则会出现死循环。请参考:https://www.ibm.com/developerworks/cn/linux/l-cn-gccstack/index.html
作者回复: 就喜欢你这种自己动手研究的同学
共 25 条评论453 - Nirvanaliu2018-10-01文章结构: 数组看起来简单基础,但是很多人没有理解这个数据结构的精髓。带着为什么数组要从0开始编号,而不是从1开始的问题,进入主题。 1. 数组如何实现随机访问 1) 数组是一种线性数据结构,用连续的存储空间存储相同类型数据 I) 线性表:数组、链表、队列、栈 非线性表:树 图 II) 连续的内存空间、相同的数据,所以数组可以随机访问,但对数组进行删除插入,为了保证数组的连续性,就要做大量的数据搬移工作 a) 数组如何实现下标随机访问。 引入数组再内存种的分配图,得出寻址公式 b) 纠正数组和链表的错误认识。数组的查找操作时间复杂度并不是O(1)。即便是排好的数组,用二分查找,时间复杂度也是O(logn)。 正确表述:数组支持随机访问,根据下标随机访问的时间复杂度为O(1) 2. 低效的插入和删除 1) 插入:从最好O(1) 最坏O(n) 平均O(n) 2) 插入:数组若无序,插入新的元素时,可以将第K个位置元素移动到数组末尾,把心的元素,插入到第k个位置,此处复杂度为O(1)。作者举例说明 3) 删除:从最好O(1) 最坏O(n) 平均O(n) 4) 多次删除集中在一起,提高删除效率 记录下已经被删除的数据,每次的删除操作并不是搬移数据,只是记录数据已经被删除,当数组没有更多的存储空间时,再触发一次真正的删除操作。即JVM标记清除垃圾回收算法。 3. 警惕数组的访问越界问题 用C语言循环越界访问的例子说明访问越界的bug。此例在《C陷阱与缺陷》出现过,很惭愧,看过但是现在也只有一丢丢印象。翻了下书,替作者加上一句话:如果用来编译这段程序的编译器按照内存地址递减的方式给变量分配内存,那么内存中的i将会被置为0,则为死循环永远出不去。 4. 容器能否完全替代数组 相比于数字,java中的ArrayList封装了数组的很多操作,并支持动态扩容。一旦超过村塾容量,扩容时比较耗内存,因为涉及到内存申请和数据搬移。 数组适合的场景: 1) Java ArrayList 的使用涉及装箱拆箱,有一定的性能损耗,如果特别管柱性能,可以考虑数组 2) 若数据大小事先已知,并且涉及的数据操作非常简单,可以使用数组 3) 表示多维数组时,数组往往更加直观。 4) 业务开发容器即可,底层开发,如网络框架,性能优化。选择数组。 5. 解答开篇问题 1) 从偏移角度理解a[0] 0为偏移量,如果从1计数,会多出K-1。增加cpu负担。为什么循环要写成for(int i = 0;i<3;i++) 而不是for(int i = 0 ;i<=2;i++)。第一个直接就可以算出3-0 = 3 有三个数据,而后者 2-0+1个数据,多出1个加法运算,很恼火。 2) 也有一定的历史原因展开共 8 条评论368
- Rain2018-10-01根据我们前面讲的数组寻址公式,a[3] 也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环。 *而这个地址正好是存储变量 i 的内存地址*这个地方没看太懂,为什么正好就是i的内存地址呢? 谢谢老师。展开共 16 条评论182
- Zzzzz2018-10-01对于死循环那个问题,要了解栈这个东西。栈是向下增长的,首先压栈的i,a[2],a[1],a[0],这是我在我vc上调试查看汇编的时候看到的压栈顺序。相当于访问a[3]的时候,是在访问i变量,而此时i变量的地址是数组当前进程的,所以进行修改的时候,操作系统并不会终止进程。
作者回复: 👍
共 13 条评论174 - 何江2018-10-02有个小问题,我觉得 随机访问Ramdom Acess 更应该翻译成 任意访问,更能表达数组的特性。不过国内书籍都是翻译成随机。新手朋友刚看到时会有一些理解问题,如数组怎么会是随机访问的呢(当初我就是这么想的)共 7 条评论145
- Neuject2019-05-09花时间研究了一下多维数组的寻址公式,写完感觉豁然开朗: 虽然我们可以把二维数组中成员看作行和列两个方向构成的格子中的数据,三维数组成员看作长宽高构成的立体格子中的数据,但这只是有助于理解的形象表示,实质上数组在内存中是连续线性的。 那么对于二维数组 x[][](长度为a1*a2)来说,求x[i][j]的时候(不会考虑i j越界的情况),要到i的时候,一定走完了i*a2的长度,在x[i][0]往后找j个长度就是x[i][j],所以会从初始地址增加 (i*a2+j)个单位长度。 对于三维数组, x[][][](长度为a1*a2*a3)来说,求x[i][j][k]的时候,要到i的时候,一定走完了i*a2*a3的长度,在x[i][0][0]往后找j*a3个长度就是x[i][j][0],再往后找k个长度就是x[i][j][k],所以会从初始地址增加 (i*a2*a3+j*a3+k)个单位长度。以此类推如下: 数组 为x ,an为某一维度长度 一维数组:(a1)x[i]_address = base_address + i * type_size 二维数组:(a1*a2)x[i][j]_address = base_address + ( i * a2 + j ) * type_size 三位数组:(a1*a2*a3)x[i][j][k]_address = base_address + ( i * a2*a3 + j * a3 + k ) * type_size 四位数组:(a1*a2*a3*a4)x[i][j][k][l]_address = base_address + ( i * a2*a3*a4 + j *a3*a4 + k *a4 + l ) * type_size 。。。。。。 n维数组:(a1*a2*a3*...an)x[i1][i2][i3]...[in] = base_address + ( i1 * a2*a3*...*an + i2 * a3*a4*...*an + i3 * a4*a5*...*an +......i(n-1) * an + in) * type_size展开共 13 条评论121
- shane2018-10-01无限循环的问题,我认为内存分配是从后往前分配的。例如,在Excel中从上往下拉4个格子,变量i会先被分配到第4个格子的内存,然后变量arr往上数分配3个格子的内存,但arr的数据是从分配3个格子的第一个格子从上往下存储数据的,当访问第3索引时,这时刚好访问到第4个格子变量i的内存。 不知道对不对,望指正!
作者回复: 形象👍
共 7 条评论98 - 李朋远2018-10-03老师,您好,个人觉得您举例的内存越界的循环应该限制在x86架构的小端模式,在别的架构平台上的大端模式应该不是这样的!
作者回复: 说的没错 👍
共 6 条评论86 - hope2018-10-01根据我们前面讲的数组寻址公式,a[3] 也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环。 这块不是十分清晰,希望老师详细解答一下,谢谢! 看完了 ,之前说总结但是没总结,这次前连天的总结也补上了,打卡展开
作者回复: 1. 不同的语言对数组访问越界的处理方式不同,即便是同一种语言,不同的编译器处理的方式也不同。至于你熟悉的语言是怎么处理的,请行百度。 2. C语言中,数组访问越界的处理是未决。并不一定是错,有同学做实验说没问题,那并不代表就是正确的。 3. 我觉得那个例子,栈是由高到低位增长的,所以,i和数组的数据从高位地址到低位地址依次是:i, a[2], a[1], a[0]。a[3]通过寻址公式,计算得到地址正好是i的存储地址,所以a[3]=0,就相当于i=0. 4. 大家有不懂的多看看留言,留言区还是有很多大牛的!我可能有时候回复的不及时,或者同样的问题只回复一个同学!
共 6 条评论70 - 港2018-10-031. 我认为文中更准确的说法可能是标记-整理垃圾回收算法。标记-清除算法在垃圾收集时会先标记出需要回收的对象,标记完成后统一回收所有被标记的对象。清除之后会产生大量不连续的内存碎片。标记-整理垃圾回收算法在标记完成之后让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。 2. 假设二维数组大小为m*n,那么寻址公式为 a[i][j]_address = base_address + (i * n+j)*data_type_size 3. C语言变量的内存申请可以看做是地址按照从大到小连续申请的,因为i在arr前面申请,所以arr[3]的地址和i的地址相同。 例如对于如下代码: int i = 0;int j = 1;int k = 2; int arr[3] = {0}; cout<<"i-"<<&i<<endl; cout<<"j-"<<&j<<endl; cout<<"k-"<<&k<<endl; cout<<"arr-"<<&arr<<endl; cout<<"arr3-"<<&arr[3]<<endl; 运行结果: i-0x28ff0c j-0x28ff08 k-0x28ff04 arr-0x28fef8 arr3-0x28ff04展开
作者回复: 👍
64 - 小帅b2018-10-02————总结一下———— 什么是数组 数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。 1.线性表 线性表就是数据排成像一条线一样的结构。 常见的线性表结构:数组,链表、队列、栈等。 2. 连续的内存空间和相同类型的数据 优点:两限制使得具有随机访问的特性 缺点:删除,插入数据效率低 数组怎么根据下标随机访问的? 通过寻址公式,计算出该元素存储的内存地址: a[i]_address = base_address + i * data_type_size 为何数组插入和删除低效 插入: 若有一元素想往int[n]的第k个位置插入数据,需要在k-n的位置往后移。 最好情况时间复杂度 O(1) 最坏情况复杂度为O(n) 平均负责度为O(n) 如果数组中的数据不是有序的,也就是无规律的情况下,可以直接把第k个位置上的数据移到最后,然后将插入的数据直接放在第k个位置上。 这样时间复杂度就将为 O(1)了。 删除: 与插入类似,为了保持内存的连续性。 最好情况时间复杂度 O(1) 最坏情况复杂度为O(n) 平均负责度为O(n) 提高效率:将多次删除操作中集中在一起执行,可以先记录已经删除的数据,但是不进行数据迁移,而仅仅是记录,当发现没有更多空间存储时,再执行真正的删除操作。这也是 JVM 标记清除垃圾回收算法的核心思想。 数组访问越界问题 C语言中的数据越界是一种未决行为,一般比较难发现的逻辑错误。相比之下,Java会有越界检查。 用数组还是容器? 数组先指定了空间大小 容器如ArrayList可以动态扩容。 1.希望存储基本类型数据,可以用数组 2.事先知道数据大小,并且操作简单,可以用数组 3.直观表示多维,可以用数组 4.业务开发,使用容器足够,开发框架,追求性能,首先数组。 为什么数组要从 0 开始编号? 由于数组是通过寻址公式,计算出该元素存储的内存地址: a[i]_address = base_address + i * data_type_size 如果数组是从 1 开始计数,那么就会变成: a[i]_address = base_address + (i-1)* data_type_size 对于CPU来说,多了一次减法的指令。 当然,还有一定的历史原因。 ————课后思考———— 1.我理解的JVM标记清除垃圾回收算法:在标记阶段会标记所有的可访问的对象,在清除阶段会遍历堆,回收那些没有被标记的对象。现在想想,和「如果数组中的数据不是有序的,也就是无规律的情况下,可以直接把第k个位置上的数据移到最后,然后将插入的数据直接放在第k个位置上。」思想类似。 2. 对于一维数组:a[i]_address = base_address + (i)* data_type_size 二维数组如果是m*n,那么a[i][j]== base_address + (i*n+j)* data_type_size。 2.展开56
- Teanmy2019-04-07汇总一下各位大神关于那段代码无限循环的总结: 这段代码无限循环原因有2,以及一个附加条件: 1.栈空间从高往低依次分配,i占4字节,接着arr占12字节,内存从高往低是这样:存i的4字节|arr[2]|arr[1]|arr[0],数组访问是通过“baseAddr+index乘typeSize”得到,算下来当index=3时,刚好是i的地址 2.这里刚好满足字节对齐,系统为64位系统,字长64,那么字节对齐必须是8字节的倍数,刚好i变量和arr变量占了16字节,对齐了 如果这里将arr[3]改为arr[4],为了对齐,内存从高往低是这样:存i的4字节|空4字节|arr[3]|arr[2]|arr[1]|arr[0],那么arr[4]刚好是空的4字节,无法影响到i的值,则并不会无限循环 附加条件:编译时gcc默认会自动添加越界保护,此处要达到无限循环效果,编译时需加上-fno-stack-protector去除该保护展开共 4 条评论51
- Monday2018-10-10以问题为思路学习本节(国庆10天假,加来完成当初立下的flag,求支持鼓励) 一、引子:为什么很多编程语言的数组都是从0开始编号的? 1、从数组存储的内存模型上来看,“下标”确切的说法就是一种“偏移”,相比从1开始编号,从0开始编号会少一次减法运算,数组作为非常基础的数组结构,通过下标随机访问元素又是非常基础的操作,效率的优化就要尽可能的做到极致。 2、主要的原因是历史原因,C语言的设计者是从0开始计数数组下标的,之后的Java、JS等语言都进行了效仿,或者说是为了减少从C转向Java、JS等的学习成本。 二、什么是数组? 数组是一个线性数据结构,用一组连续的内存空间存储一组具有相同类型的数据。 其实数组、链表、栈、队列都是线性表结构;树、图则是非线性表结构。 三、数组和链表的面试纠错? 1、数组中的元素存在一个连续的内存空间中,而链表中的元素可以不存在于连续的内存空间。 2、数组支持随机访问,根据下标随机访问的时间复杂度是O(1);链表适合插入、删除操作,时间复杂度为O(1)。 四、容器是否完全替代数组? 容器的优势:对于Java语言,容器封装了数组插入、删除等操作的细节,并且支持动态扩容。 对于Java,一些更适合用数组的场景: 1、Java的ArrayList无法存储基本类型,需要进行装箱操作,而装箱与拆箱操作都会有一定的性能消耗,如果特别注意性能,或者希望使用基本类型,就可以选用数组。 2、若数组大小事先已知,并且对数组只有非常简单的操作,不需要使用到ArrayList提供的大部分方法,则可以直接使用数组。 3、多维数组时,使用数组会更加直观。 五、JVM标记清除算法? GC最基础的收集算法就是标记-清除算法,如同他们的名字一样,此算法分为“标记”、“清除”两个阶段,先标记出需要回收的对象,再统一回收标记的对象。不足有二,一是效率不高,二是产生碎片内存空间。 六、数组的内存寻址公式? 一维数组: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 若理解有误,欢迎指正,谢谢!展开
作者回复: 👍
29 - qx2018-10-011.老师您好,二维数组存储也是连续的吧。 2.对于数组删除abc,还没太理解?申请的是三个地址空间,a(3)越界了,那么它会去找哪个地址的数据呢?而且for循环就是三次啊,如何无限打印? 3.老师时候每讲完一节数据结构可以对应到一些编程题目给大家思考啊例如leetcode或其他的?共 1 条评论27
- CathyLin2018-10-02看完 & 写完笔记来打卡,发现评论区好多大牛!光是翻看了评论区就收获了好多!共 2 条评论26
- 执__生2018-10-01我也是js开发者,前面的那位js开发者同学的问题其实不难解决。 如果不知道老师的“数组”究竟是什么,只要查一下数据结构里的“数组”和“链表”的定义,然后搜一些关于js引擎对js定义下“数组”的底层实现的文章,比如“深究 JavaScript 数组 —— 演进&性能”。就知道老师在说什么了。 互联网从业者更要善用互联网,加油!
作者回复: 说得好!
21