Java 数据结构 - 数组

目录

  • Java 数据结构 - 数组
  • 1. 什么是数组
  • 2. 数组寻址公式
  • 3. ArrayList
  • 4. 复杂度分析
  • 4.1 插入
  • 4.2 删除
  • 4.3 查找
  • 4.4 批量处理

数据结构与算法目录(javascript:void(0))

1. 什么是数组

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

  1. 线性表(Linear List)。顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向(即前驱和后继节点)。其实除了数组,链表、队列、栈等也是线性表结构。
  2. 连续的内存空间和相同类型的数据。正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。

思考:对于 Object[] 数组而言,可以存放任意类型的对象,为什么说也是相同类型的类型?

Java 对象存储都是引用地址,一个引用地址都是 4 byte。对于数组寻址时,不管什么类型的 Object 地址都是占用 4 byte。

2. 数组寻址公式

计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:

(1)一维数组寻址公式

a[i]_address = base_address + i * data_type_size

(2)二维数组寻址公式

// 对于 m * n 的数组,a [i][j] (i < m, j < n) 寻址地址
address = base_address + (i * n + j) * type_size

思考1:为什么大多数语言,数据都是从 0 开始编号?

原因可为分为两个:首先,如果从 1 开始编号,寻址公式则变为 a[i]_address = base_address + (i - 1) * data_type_size,从而多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。最重要的是,C 语言的设计者,将数组设计为从 0 开始编号,之后的高级语言如 Java、Python 纷纷效仿 C 语言。

3. ArrayList

针对数组类型,Java 提供了 ArrayList。在项目开发中,什么时候适合用数组,什么时候适合用容器呢?

ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容。

  1. ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
  2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。
  3. 当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array,而用容器的话则需要这样定义:ArrayList<ArrayList<Object>> array

总结:

4. 复杂度分析

4.1 插入

插入元素的时间复杂度是 O(n)。如果插入数组末尾的数据,则最好情况时间复杂度为 O(1);如果插入开头的数据,则最坏情况时间复杂度为 O(n);平均情况时间复杂度也为 O(n)。

当然,如果元素不用关心数组顺序,我们可以将原 arr[k] 元素直接挪到最后一位 arr[count],这样时间复杂度就变成 O(1)。

4.2 删除

同插入类似,删除元素的时间复杂度也是 O(n)。同样,如果如果不关心数组顺序,时间复杂度就变成 O(1)。

4.3 查找

如果是随机查找(根据索引寻址),时间复杂度是 O(1)。如果根据元素的 value 查找数据,则时间复杂度变为 O(n)。

4.4 批量处理

在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?

我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

如果你了解 JVM,你会发现,这不就是 JVM 标记清除垃圾回收算法的核心思想吗?大多数主流虚拟机采用可达性分析算法来判断对象是否存活,在标记阶段,会遍历所有 GC ROOTS,将所有 GC ROOTS 可达的对象标记为存活。只有当标记工作完成后,清理工作才会开始。当然也会带来问题:一是效率问题,标记和清理效率都不高,但是当知道只有少量垃圾产生时会很高效。二是空间问题,会产生不连续的内存空间碎片。所以 JVM 新生代采用了另一种垃圾回收算法:复制算法。


每天用心记录一点点。内容也许不重要,但习惯很重要!