01.数组深入浅出分析
目录介绍
01.数组的设计介绍
- 1.1 数组内存设计
- 1.2 一维和二维数组
- 1.3 数组复杂度思考
02.数组的优缺点
- 2.1 数组的优点
- 2.2 数组的缺点
03.数组使用场景
- 3.1 那些使用场景
- 3.2 那些集合用数组
04.线性表和非线性表
- 4.1 什么是线性表
- 4.2 什么是非线性表
05.数组的访问
- 5.1 设计访问元素
- 5.2 数组地址值设计
- 5.3 用例子来理解
06.数组低效插入
- 6.1 插入操作复杂度
- 6.2 分析插入效率低
- 6.3 如何提高插入效率
07.数组低效删除
- 7.1 普通删除元素
- 7.2 集中删除多个元素
08.容器和数组
- 8.1 问题思考分析
- 8.2 集合的优势
- 8.3 两者特性比较
- 8.4 数组和集合总结
- 8.5 使用场景
09.为何数组从0开始
- 9.1 下标的设计
- 9.2 从0开始访问
- 9.3 历史原因导致
10.总结和概括
- 10.1 整体总结一下
- 10.2 更多内容推荐
01.什么是数组
数组是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
数组的初始化。Java中的数组必须先初始化,然后才能使用。所谓初始化:就是为数组中的数组元素分配内存空间,并为每个数组元素赋值。
1.1 数组内存设计
数据存储中的连续空间是指数据在物理存储介质(如硬盘、内存等)上被连续地存储。这意味着数据在存储介质上的存储位置是相邻的,没有间隔或间隔很小。
连续空间的使用有以下几个原因:
- 读写效率:在连续空间中存储数据可以提高读写操作的效率。
- 内存分配:在内存中,连续空间的使用可以简化内存分配和管理。当数据在内存中连续存储时,可以通过指针和偏移量等方式轻松访问和操作数据。
- 缓存利用:现代计算机系统通常具有缓存层次结构,连续存储可以提高缓存的利用率,因为连续的数据通常会更好地利用缓存行。
1.2 一维和二维数组
一维数组表示代码如下
//数组一定要确定长度,连续内存空间
int[] arr1 = new int[5];
int[] arr2 = {1,2,3,4,5};
二维数组定义格式
- 数据类型[][] 变量名 = new 数据类型m;
- m表示这个二维数组有多少个一维数组
- n表示每一个一维数组的元素个数
举例:
int[][] arr = new int[3][2];
定义了一个二维数组arr
这个二维数组有3个一维数组,名称是arr[0],arr[1],arr[2]
每个一维数组有2个元素,可以通过arr[m][n]来获取,表示获取第m+1个一维数组的第n+1个元素
1.3 数组复杂度思考
数组和链表的复杂度,很多人都回答说,“链表适合插入、删除,时间复杂度O(1);数组适合查找,查找时间复杂度为 O(1)”。
实际上,这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是O(logn)。
所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。
02.数组的优缺点
2.1 数组的优点
- 随机访问:数组通过索引可以快速访问和修改元素,时间复杂度为O(1)。这使得数组在需要频繁访问元素的场景中非常高效。
- 连续存储:数组的元素在内存中是连续存储的,这有利于缓存的利用,提高访问效率。
- 简单:数组的操作相对简单,可以快速进行插入、删除和搜索等操作。
- 有序性:数组中的元素按照索引顺序排列,这使得数组在需要有序数据的场景中非常有用。
2.2 数组的缺点
- 固定大小:数组的大小在创建时就确定,无法动态调整。如果需要存储的元素数量超过数组的初始大小,就需要重新分配更大的数组并进行数据复制,这可能会导致性能损失。
- 插入和删除效率低:在数组中插入或删除元素时,需要移动其他元素来保持连续性,这可能导致较高的时间复杂度,尤其是在大规模数据的情况下。
- 内存浪费:如果数组的大小远远超过实际存储的元素数量,会造成内存的浪费。
- 不适用于关联性数据:数组适用于按索引访问元素的场景,但对于需要关联性数据的情况,使用其他数据结构(如字典或哈希表)可能更合适。
详细一点来说:数组作为数据存储结构有一定的缺陷。在无序数组中,搜索性能差,在有序数组中,插入效率又很低,而且这两种数组的删除效率都很低,并且数组在创建后,其大小是固定了,设置的过大会造成内存的浪费,过小又不能满足数据量的存储。
连续的内存空间和相同类型的数据。正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。
03.数组使用场景
3.1 那些使用场景
1.数组适用于需要按照索引顺序存储和访问元素的场景。例如,存储学生成绩、员工工资等有序数据时,可以使用数组。
2.由于数组的元素在内存中是连续存储的,这有利于缓存的利用。在需要频繁访问数组元素的场景中,数组可以提供更好的性能。
3.数组是一种通用的数据结构,能用来实现栈、队列等很多数据结构。
3.2 那些集合用数组
许多集合类的底层实现都使用了数组,例如 ArrayList、Vector、ArrayDeque、PriorityQueue 等。数组提供了高效的随机访问能力,而集合类在此基础上增加了动态扩容、线程安全、排序等功能。
- ArrayList。底层实现:动态数组。需要频繁访问元素,但插入和删除操作较少的场景。
- Stack。底层实现:基于 数组 实现。需要实现栈操作的场景。
- ArrayDeque。底层实现:循环数组。需要高效的双端队列操作。
- CopyOnWriteArrayList。底层实现:动态数组。
- String 的字符数组。底层实现:char[] 数组。字符串操作。
04.线性表和非线性表
4.1 什么是线性表
用于存储一组有序的元素。在线性表中,元素之间存在顺序关系,每个元素都有唯一的前驱和后继元素(除了第一个元素和最后一个元素)。线性表中的元素通常按照其插入顺序排列。
数据排成像一条线一样的结构。每个线性表上的数据最多只有前后两个方向。除了数组,链表、队列、栈也是线性表结构。
4.2 什么是非线性表
在非线性表中,元素之间的关系不是简单的前后顺序,而是以其他方式相互连接或组织的。非线性表中的元素之间可以存在多种关系,如父子关系、兄弟关系等,不受严格的顺序限制。
非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。
05.数组的访问
5.1 设计访问元素
数组是如何实现根据下标随机访问元素的吗?
- 使用方括号[]来访问数组元素,后面跟上要访问的元素的下标。例如,array[5]表示访问数组array中的第六个元素。
- 数组会根据给定的下标计算出元素在内存中的地址,这个是元素的地址值。
- 最后通过元素地址值直接精确找到元素值。
通过下标访问数组元素的时间复杂度是O(1),你可以通过简单的地址计算来直接访问。
5.2 数组地址值设计
在数组中,元素的地址值是根据数组的起始地址和元素的下标计算得出的。数组的起始地址是数组在内存中的首地址,而元素的下标表示元素在数组中的位置。
当访问数组元素时,计算元素的地址值的一般公式如下:元素地址 = 数组起始地址 + (元素下标 * 单个元素的大小)
单个元素的大小是指数组中每个元素所占用的字节数。通过将元素的下标乘以单个元素的大小,可以得到元素在数组中的偏移量。然后,将数组的起始地址与偏移量相加,即可得到元素的地址值。
这种设计使得数组中的元素在内存中是连续存储的,因为每个元素的地址都是根据前一个元素的地址计算得出的。
5.3 用例子来理解
我们拿一个长度为 10 的 int 类型的数组 int[ ] a = new int[10] 来举例。在我画的这个图中,计算机给数组 a[10],分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000。
计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址,最后找到元素。
int 类型的数据,字节的大小是4
a[0] = 4,那么,地址值 = 1000 + 1 * 4
a[1] = 5,那么,地址值 = 1000 + 2 * 4
a[2] = 7,那么,地址值 = 1000 + 3 * 4
06.数组低效插入
6.1 插入操作复杂度
假设数组的长度为 n,现在,如果我们需要将一个数据插入到数组中的第 k 个位置。为了把第 k 个位置腾出来,给新来的数据,我们需要将第 k~n 这部分的元素都顺序地往后挪一位。那插入操作的时间复杂度是多少呢?
- 如果在数组的末尾插入元素,那就不需要移动数据了,这时的时间复杂度为 O(1)。
- 如果在数组的开头插入元素,那所有的数据都需要依次往后移动一位,所以最坏时间复杂度是 O(n)。
- 如果在数组中间插入元素也需要将插入位置后的元素向后移动,以便为新元素腾出空间。在这种情况下,时间复杂度也是 O(n),因为需要移动 n/2 个元素(平均情况)。
因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 (1+2+…n)/n=O(n)。建议:因此,在频繁执行插入操作的情况下,可能需要考虑使用其他数据结构,如链表,以避免频繁的元素移动。
6.2 分析插入效率低
数组插入数据效率低的原因主要是由于数组的内部实现方式。在大多数编程语言中,数组是一种连续的内存块,插入数据需要做:
- 移动元素:由于数组是连续的内存块,插入数据会导致插入位置之后的元素都需要向后移动,以腾出空间给新插入的元素。
- 空间开销:如果数组已经满了,无法容纳更多的元素,那么需要重新分配更大的内存空间,并将原有的元素复制到新的内存空间中。
- 内存拷贝:元素移动实际上是一种内存拷贝操作,需要将一部分元素复制到新的位置。这种内存拷贝操作会消耗额外的时间,尤其是在数组规模较大时,影响插入操作的效率。
这三个操作都需要耗费时间,特别是在大型数组中插入数据时,效率会更低。因此,频繁地在数组中插入数据可能会导致性能下降。如果需要频繁地进行插入操作,可以考虑使用其他数据结构来提高性能。
6.3 如何提高插入效率
如果数组中的数据是有序的,我们在某个位置插入一个新的元素时,就必须按照刚才的方法搬移+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)。这个处理思想在快排中也会用到。
07.数组低效删除
7.1 普通删除元素
跟插入数据类似,如果我们要删除第k个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了。
和插入类似,如果删除数组末尾的数据,则最好情况时间复杂度为 O(1);如果删除开头的数据,则最坏情况时间复杂度为 O(n);平均情况时间复杂度也为 O(n)。
7.2 集中删除多个元素
实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?
我们继续来看例子。数组 a[10]中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。
为了避免 d,e,f,g,h+这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。
核心思想是,使用缓冲区:在删除操作频繁的情况下,可以考虑使用缓冲区,将多个删除操作合并为一次性的批量删除,减少元素移动次数,提高效率。
如果你了解 JVM,你会发现,这不就是JVM标记清除垃圾回收算法的核心思想吗?没错,数据结构和算法的魅力就在于此,很多时候我们并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的。
如果你细心留意,不管是在软件开发还是架构设计中,总能找到某些算法和数据结构的影子。
08.容器和数组
8.1 问题思考分析
针对数组类型,很多语言都提供了容器类,比如 Java 中的 ArrayList、C++ 中的 vector。在项目开发中,什么时候适合用数组,什么时候适合用容器呢?
这里我拿 Java 语言来举例。如果你是 Java 工程师,几乎天天都在用 ArrayList,对它应该非常熟悉。那它与数组相比,到底有哪些优势呢?
8.2 集合的优势
个人觉得,ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容。
数组本身在定义的时候需要预先指定大小,因为需要分配连续的内存空间。如果我们申请了大小为 10 的数组,当第 11 个数据需要存储到数组中时,我们就需要重新分配一块更大的空间,将原来的数据复制过去,然后再将新的数据插入。
如果使用 ArrayList,我们就完全不需要关心底层的扩容逻辑,ArrayList 已经帮我们实现好了。每次存储空间不够的时候,它都会将空间自动扩容为 1.5+倍大小。
不过,这里需要注意一点,因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小。比如我们要从数据库中取出 10000 条数据放入 ArrayList。
8.3 两者特性比较
特性 | 数组 | 容器 |
---|---|---|
大小 | 固定大小,声明时确定 | 动态大小,可以自动调整 |
内存管理 | 手动管理(如 C/C++) | 自动管理(如 C++ 的 std::vector ) |
元素类型 | 必须相同 | 可以相同,也可以不同(如 std::any ) |
访问方式 | 通过索引直接访问 | 通过迭代器或索引访问 |
插入/删除效率 | 低效,需要移动元素 | 高效,容器内部优化了插入和删除操作 |
功能 | 基本功能(存储和访问) | 提供丰富功能(如排序、查找、遍历等) |
内存连续性 | 元素在内存中连续存储 | 元素在内存中可能不连续存储 |
性能 | 访问速度快,适合固定大小的场景 | 灵活性高,适合动态大小的场景 |
8.4 数组和集合总结
数组:
- 数据量固定且已知。
- 需要高效的随机访问。
- 对内存占用有严格要求(如嵌入式系统)。
- 示例:存储一周的天数、图像像素数据等。
容器:
- 数据量动态变化。
- 需要频繁插入、删除或修改数据。
- 需要高级功能(如排序、查找、遍历等)。
- 示例:存储用户输入、管理动态列表、实现复杂数据结构等。
作为高级语言编程者,是不是数组就无用武之地了呢?当然不是,有些时候,用数组会更合适些,我总结了几点自己的经验。
- 1.Java+ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
- 2.如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。
- 3.还有一个是个人的喜好,当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList<ArrayList >+array。
对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。
09.为何数组从0开始
9.1 下标的设计
数组下标是编程中非常重要的概念,它们可以用于访问和修改数组中的元素。通过操作数组下标,可以对数组进行排序、搜索、删除和插入等操作,使得我们可以灵活地处理数据。
9.2 从0开始访问
为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始呢?+从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。
前面也讲到,如果用 a 来表示数组的首地址,a[0] 就是偏移为 0 的位置,也就是首地址,a[k] 就表示偏移 k 个 type_size 的位置,所以计算 a[k] 的内存地址只需要用这个公式:a[k]_address = base_address + k * type_size
但是,如果数组从+1+开始计数,那我们计算数组元素+a%5Bk%5D+的内存地址就会变为:a[k]_address = base_address + (k-1)*type_size
对比两个公式,我们不难发现,从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。
数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。
9.3 历史原因导致
上面解释得再多其实都算不上压倒性的证明,说数组起始编号非 0 开始不可。
所以我觉得最主要的原因可能是历史原因。 C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。甚至还有一些语言支持负数下标,比如 Python。
在编程中,容器(Container) 和 数组(Array) 都是用于存储和管理数据的结构,但它们在功能、灵活性、性能和使用场景上有显著的区别。
10.总结和概括
10.1 整体总结一下
- 1.0 什么是数组:数组是线性表结构,存储相同类型的数据。然后数据必须先初始化,存储数组是一段连续的内存空间。
- 1.1 为何数组存储要设计连续内存空间:1.连续内存空间可以提高读写效率;2.连续存储可以通过指针和偏移量轻松访问和操作数据;3.连续数据可以提高缓存的利用率。
- 1.3 数组和链表的区别:数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。
- 2.1 数组的优点:1.可以随机访问,通过索引访问元素时间复杂度O(1);2.连续存储有利于缓存利用;3.操作简单;4.元素按照索引顺序排序
- 2.2 数组的缺点:1.数组创建是大小就确定无法动态调整;2.插入和删除效率低;3.数组很大存储实际元素少会导致内存浪费;4.无法存储关联性数据
- 3.1 数组有那些使用场景:适用于需要按照索引顺序存储和访问元素的场景。
- 3.2 哪些集合用到了数组:例如 ArrayList、Vector、ArrayDeque、PriorityQueue 等。数组提供了高效的随机访问能力,而集合类在此基础上增加了动态扩容、线程安全、排序等功能。
- 4.1 什么是线性表:在线性表中,元素之间存在顺序关系,每个元素都有唯一的前驱和后继元素。线性表中的元素通常按照其插入顺序排列。
- 4.2 什么是非线性表:元素之间的关系不是简单的前后顺序,而是以其他方式相互连接或组织的。可以存在多种关系,如父子关系、兄弟关系等,不受严格的顺序限制。
- 5.1 数组是如何实现根据下标随机访问元素:数组会根据给定的下标计算出元素在内存中的地址,这个是元素的地址值,最后通过元素地址值直接精确找到元素值。
- 5.2 地址值是如何设计的:当访问数组元素时,计算元素的地址值的一般公式如下:元素地址 = 数组起始地址 + (元素下标 * 单个元素的大小)
- 6.1 如何理解数组插入数据时间复杂度:如果在末尾插入数据则时间复杂度O(1),如果在首位插入数据则时间复杂度O(n),在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 (1+2+…n)/n=O(n)。
- 6.2 数组插入效率低的原因:1.插入元素后,插入位置之后的元素都要挨个往后挪动;2.如果数组满了则需要创建新的空间,并且复制数据到新的存储空间。这两点是效率低原因
- 7.1 删除元素效率分析:要删除第k个位置的数据,为了内存的连续性,也需要搬移数据。
- 7.2 如何提高数组删除元素效率:使用缓冲区,在删除操作频繁的情况下,可以考虑使用缓冲区,将多个删除操作合并为一次性的批量删除,减少元素移动次数,提高效率。
- 8.1 思考一下数组和集合:在项目开发中,什么时候适合用数组,什么时候适合用容器呢?
- 8.2 集合相比数组的优势:最大的优势就是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容。
- 9.1 数组下标的设计:它们可以用于访问和修改数组中的元素。通过操作数组下标,可以对数组进行排序、搜索、删除和插入等操作,使得我们可以灵活地处理数据。
- 9.2 如何理解数组从0开始编号:数组从0开始编号的概念最早出现在C语言中。在C语言中,数组的元素是通过偏移量来访问的,数组名本身就是数组首元素的地址。
10.2 更多内容推荐
模块 | 描述 | 备注 |
---|---|---|
GitHub | 多个YC系列开源项目,包含Android组件库,以及多个案例 | GitHub |
博客汇总 | 汇聚Java,Android,C/C++,网络协议,算法,编程总结等 | YCBlogs |
设计模式 | 六大设计原则,23种设计模式,设计模式案例,面向对象思想 | 设计模式 |
Java进阶 | 数据设计和原理,面向对象核心思想,IO,异常,线程和并发,JVM | Java高级 |
网络协议 | 网络实际案例,网络原理和分层,Https,网络请求,故障排查 | 网络协议 |
计算机原理 | 计算机组成结构,框架,存储器,CPU设计,内存设计,指令编程原理,异常处理机制,IO操作和原理 | 计算机基础 |
学习C编程 | C语言入门级别系统全面的学习教程,学习三到四个综合案例 | C编程 |
C++编程 | C++语言入门级别系统全面的教学教程,并发编程,核心原理 | C++编程 |
算法实践 | 专栏,数组,链表,栈,队列,树,哈希,递归,查找,排序等 | Leetcode |
Android | 基础入门,开源库解读,性能优化,Framework,方案设计 | Android |
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。