数组的特点
我们知道数组具有如下二个特点:
- 数组是线性数据结构,使用数组存储的数据会排成像一条线一样的结构。
- 数组用一组连续的内存空间,来存储一组具有相同类型的数据
如果我们想访问上图数组中索引为 2 的元素的值,我们应该按照下面两步来达到目的:
- 先计算索引为 2 的元素所在的内存地址
- 然后可以通过内存地址访问元素的值
在数组中,我们可以通过下面的公式来计算指定索引 (index) 对应的内存地址:
这个公式我们也可以称为数组元素的寻址公式
通过上面的公式,我们可以得到索引为 2 的元素所在的内存地址等于 1000 + 2 * 4 = 1008,所以 data[2] 就等于内存地址为 1008 所对应的元素值。
data_type_size 等于 4 的原因是 int 类型的数据需要占用 4 个字节的内存
从上面可以看出,随机访问数组中的某个元素的时候只需要做一次寻址计算,它的时间复杂度和数据规模 n 没有关系,所以时间复杂度是 O(1) 所以说对数组进行随机读的时间复杂度是 O(1),性能是很棒的。
我们现在来看看对数组进行随机写的情况,假设,我们现在需要将 index = 3 的元素的值设置为 4,我们应该:
- 通过寻址公式,计算得到 index = 3 的元素所在的内存地址等于 1000 + 3 * 4 = 1012
- 将内存地址为 1012 指向的内存值赋值为 4
可以看出,对数组的随机写包含了寻址计算和赋值运算两个步骤,这两个步骤和数据规模都没有关系,所以对数组随机写的时间复杂度是 O(1)
综上可以看出:随机读写数组的性能是很好的,时间复杂度都是 O(1)
我们前面谈到数组是连续的内存空间,存储的是相同类型的数据。正是因为这两个限制,使得数组的随机访问非常的高效
但是有利也有弊,这两个限制也让数组的很多操作变的非常低效,比如要想在数组中删除、插入一个数据,为了保证内存连续性,就需要做大量的数据搬移工作。
接下来我们就分别来谈一下数组的插入和删除操作。
下面代码实现的就是在一个数组中的指定索引中插入新元素:
* 将指定的元素插入到指定数组的指定位置上
* @param src 需要插入元素的数组
* @param index 插入数组的位置
* @param element 需要插入的元素值
* @return 包含了插入元素的数组
*/
public static int[] insertElement(int[] src,
int index,
int element) {
int length = src.length;
int[] dest = new int[length + 1];
// 1. 将原始数组中 [0, index) 的元素拷贝到目标数组
for (int i = 0; i < index; i++) {
dest[i] = src[i];
}
// 2. 将 index 对应的元素值设置为指定的值
dest[index] = element;
// 3. 将原始数组中 [index, src.length) 的元素拷贝到目标数组
for (int i = index; i < length; i++) {
dest[i + 1] = src[i];
}
return dest;
}
上面的代码的复杂度分析:
- 在方法中申请了一个和输入数据规模正正比的数组空间,所以空间复杂度是 O(n)
- 数组拷贝的时候,需要遍历每个元素,所以时间复杂度是 O(n)
下面代码实现就是随机删除一个数组中指定的索引的元素:
/**
* 从数组中删除指定位置的元素
* @param src 数组
* @param index 指定的位置
* @return 删除元素之后的数组
*/
public static int[] removeElement(int[] src, int index) {
// 1. 创建一个新的数组,数组的长度是原始数组的长度减 1
int[] dest = new int[src.length - 1];
// 2. 将要删除的元素之前的元素拷贝到新数组中
for (int i = 0; i < index; i++) {
dest[i] = src[i];
}
// 3. 将原始数组中除了需要删除的元素之外的元素拷贝到新数组中
for (int i = index + 1; i < src.length - index - 1; i++) {
dest[i] = src[i];
}
return dest;
}
上面的代码的复杂度分析:
- 在方法中申请了一个和输入数据规模正正比的数组空间,所以空间复杂度是 O(n)
- 数组拷贝的时候,需要遍历每个元素,所以时间复杂度是 O(n)
总结
- 数组的随机读写是非常高效的,时间复杂度是 O(1)
- 数组的随机删除和插入操作相对来说则比较低效,不管是时间复杂度还是空间复杂度,都是 O(n)
二次封装静态数组
前面我们讲解了 Java 的数组的特性,现在我们来看看 Java 数组提供的方法:
从上图可以看出,Java 数组除了提供一个属性 lenght 外,没有提供任何的方法,上面的这些方法实际上都是从 java.lang.Object 父类中继承的。
然而在实际的数组使用中,我们会经常对数组进行如下的操作:
- 计算一个数组中实际上存储了多少个元素,注意:数组的属性 length 只能表达这个数组的长度,不能表达这个数组中真实存储了多少个元素
- 计算一个数组中是否包含某个元素
- 对数组进行删除某个元素
- 对数组进行新增某个元素
- 判断数组是否为空 (也就是数组没有存储任何的数据)
- ........
如果每个人都需要对数组进行上面的操作,那么可能就会导致每个人都会写一份类似的代码,那么就导致了相同的代码散乱在各个不同的地方,这样会降低代码的可维护性了。
针对这个问题,我们可以写一个名为 ArrayList 的类,这个类是对 Java 静态数组的二次封装,可以把对数组的常见操作都放在这个类中
然后将这个类打成一个 jar 包,那么每个人就可以通过 jar 包的方式来使用这个类,从而实现了代码的复用,而且也可以提高代码的可维护性。
这个 ArrayList 类的源码如下:
package com.douma.line.array;
/**
* @微信公众号 : 抖码课堂
* @官方微信号 : bigdatatang01
* @作者 : 老汤
*/
public class ArrayList<E> {
private E[] data;
private int capacity;
private int size;
public ArrayList(int capacity) {
this.data = (E[])new Object[capacity];
this.capacity = capacity;
this.size = 0;
}
public ArrayList(E[] arr) {
this.data = (E[])new Object[arr.length];
for (int i = 0; i < arr.length; i++) {
data[i] = arr[i];
}
size = arr.length;
}
public ArrayList() {
this(15);
}
public boolean isEmpty() {
return size == 0;
}
public int getSize() {
return size;
}
public int getCapacity() {
return capacity;
}
/**** 新增操作 ****/
/**
* C(Create)-R(Retrieve)-U(Update)-D(Delete)
* 向指定索引位置添加一个新元素
* @param index 指定索引
* @param e 新元素
*/
// 时间复杂度:O(n)
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("add failed, require index >= 0 && index <= size");
}
// 扩容
if (size == data.length) {
resize(data.length * 2);
}
// 最差时间复杂度,循环代码运行最大的次数
// size = data.length && index = 0
// 时间复杂度 O(n)
for (int i = size - 1; i >= index; i--) {
data[i + 1] = data[i];
}
data[index] = e;
size++;
}
private void resize(int newCapacity) {
// 1. 创建一个容量为 newCapacity 的临时数组
E [] newData = (E[])new Object[newCapacity];
// 2. 将原来数组中的元素拷贝到新数组中
for (int i = 0; i < size; i++) {
newData[i] = data[i];
}
// 3. 将新数组覆盖老数组
data = newData;
// bug 修复:将容量设置位新容量值
capacity = newCapacity;
}
// 时间复杂度 O(n)
public void addFirst(E e) {
add(0, e);
}
// 时间复杂度 O(1)
public void addLast(E e) {
add(size, e);
}
/**** 查询操作 ****/
/**
* 获取 index 索引位置的元素
* @param index 指定索引
* @return 返回指定索引对应的元素值
*/
// 时间复杂度 O(1)
public E get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("get failed, require index >= 0 && index < size");
}
return data[index];
}
public E getLast() {
return get(size - 1);
}
public E getFirst() {
return get(0);
}
// 时间复杂度 O(n)
public boolean contains(E target) {
for (E num : data) {
if (target.equals(num)) return true;
}
return false;
}
/**
* 查找数组元素 e 所在的索引,如果不存在的元素 e,则返回 -1
* @param e 指定元素
* @return 元素 e 所在的索引
*/
// 时间复杂度 O(n)
public int find(E e) {
for (int i = 0; i < size; i++) {
if (data[i].equals(e)) {
return i;
}
}
return -1;
}
/**** 修改操作 ****/
/**
* 将 index 索引位置的元素修改为新元素 e
* @param index 需要修改的索引位置
* @param e 新设置的元素值
*/
// 时间复杂度 O(1)
public void set(int index, E e) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("set failed, require index >= 0 && index < size");
}
data[index] = e;
}
/**** 删除操作 ****/
/**
* 删除指定索引位置的元素
* @param index 指定索引
* @return 返回删除的元素
*/
// 时间复杂度:O(n)
public E remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("remove failed, require index >= 0 && index < size");
}
E res = data[index];
// index = 0 && size = data.length
// 时间复杂度 O(n)
for (int i = index + 1; i < size; i++) {
data[i - 1] = data[i];
}
size--;
// GC 清除不用的对象
data[size] = null;
// 如果 size 等于总容量的一半的话,则进行缩容
// // 因为 data.length 有可能不断的减少,所以有可能小于 2 了,所以需要判断下
if (size == data.length / 2 && data.length / 2 != 0) {
resize(data.length / 2);
}
return res;
}
/**
* 删除第一个元素
* @return 第一个元素值
*/
// 时间复杂度 O(n)
public E removeFirst() {
return remove(0);
}
/**
* 删除最后一个元素
* @return 最后一个元素的值
*/
// 时间复杂度 O(1)
public E removeLast() {
return remove(size - 1);
}
/**
* 删除指定元素
* @param e 需要删除的元素
*/
// 时间复杂度 O(n)
public void removeElement(E e) {
int index = find(e);
if (index != -1) {
remove(index);
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(String.format(
"Array: size = %d, capacity = %d\n", size, data.length));
sb.append("[");
for (int i = 0; i < size; i++) {
sb.append(data[i]);
if (i != size - 1) {
sb.append(",");
}
}
sb.append("]");
return sb.toString();
}
}
以上 ArrayList 包含了数组的增删改查操作,这里需要重点强调的是数组的扩容缩容操作
我们知道,静态数组一旦初始化,它的长度就是固定不变的,但是在实际工作中,我们会碰到不断的往数组中添加元素,一旦达到了数组的长度后,我们这个时候就需要给数组进行扩容了
当数组每次装满元素的时候,我们都可以将数组的容量自动的扩容两倍,扩容的步骤为:
- 创建一个容量为 2 倍于之前容量的临时新数组
- 将原始数组中的数据拷贝到新数组中
- 将新数组覆盖老数组
代码请看 ArrayList 类中的 resize() 方法。
当我们不断的删除数组中的元素的时候,数组中存储元素的个数会不断的变少,如果我们不对数组进行缩容的话,那么数组会有很多没有存储元素的位置占着内存,浪费空间。
所以说,当数组中元素的个数达到整个数组容量的一半后,我们实际上是可以对数组进行缩容,将容量缩小到原来数组的一半的。
实际上,缩容的逻辑和扩容的逻辑是一样的,只是扩容将容量设置为 2 倍,而缩容就是将容量设置为一半
因为 ArrayList 支持对数组进行动态扩容缩容,所以 ArrayList 我们也称为动态数组
均摊时间复杂度
在 ArrayList 中的每个方法我都标注了它的时间复杂度
接下来就看下 addLast 和 removeLast 两个方法的时间复杂度,因为这两个方法牵涉到了扩容和缩容,所以时间复杂度分析起来有点复杂
addLast 和 removeLast,这两个操作的时间复杂度真的是 O(1) 吗?
因为我们知道,当到达一定的条件下,这两个操作是需要进行扩容和缩容的,然而,扩容和缩容方法 resize 因为需要拷贝数据,所以时间复杂度是 O(n)
在不需要扩容缩容的时候,addLast 和 removeLast 的时间复杂度确实是 O(1),那么加入了扩容和缩容的时间复杂度是多少呢?这个时候,我们需要借助摊还分析法来分析这个场景的时间复杂度了。
我们仔细分析下 addLast 方法,假设现在初始化了一个长度为 7 的数组对象,然后我们调用 7 次 addLast 方法,这个时候实际上每次调用 addLast 都是执行了 1 次赋值操作,也就是时间复杂度是 O(1)
当我们再次调用 addLast 的时候,需要扩容,扩容的话需要循环 7 次,也就是说这个时候的时间复杂度是 O(n)。如下图:
上面的 7 + 1 中的 7 表示的是扩容的时候遍历了数组 7 次,1 表示一次 addLast 操作
基本操作是指对数组的一次随机访问,它的时间复杂度是 O(1) 的
可以看出,我们执行了 8 次 addLast 操作,然后触发了 1 次 resize 操作,总共进行了 15 次基本操作,如果我们把这 15 次基本操作平均到 8 次 addLast 操作上的话,那么每次 addLast 方法平均进行 2 次基本操作。
更一般的来说,假设我们初始化了一个容量为 n 的数组,然后我们不断的调用 addLast 方法。当调用到第 n + 1 次的时候,需要进行 1 次 resize,resize 操作需要遍历数组 n 次,所以总共的基本操作次数是 2n + 1。
我们把这 2n + 1 次基本操作均摊到 n + 1 次 addLast 方法调用上,那么每次 addLast 方法就执行 2 次基本操作了
那么,从这个角度来看,addLast 方法的时间复杂度就是 O(1)。这种时间复杂度我们称为均摊时间复杂度,这种分析时间复杂度的方法我们称为摊还分析。
所以说像这种 addLast、removeLast 方法,n 次 O(1) 的操作后,跟着一个 O(n) 的操作,它们的均摊时间复杂度就是 O(1),我们也可以说,它们的时间复杂度就是 O(1)
复杂度的震荡问题
现在我们同时来看看 addLast 和 removeLast 操作
当我们的数组中存储的元素的个数等于数组的容量的时候,我们再调用 addLast 方法的时候,数组会进行扩容到 2 倍于之前的容量,然后再在最后添加一个元素。这个时候的 addLaste 的时间复杂度是 O(n)
这个时候,我们调用 removeLast 方法,将数组的最后一个元素删除掉,这个时候数组元素的个数等于数组容量的一半了,所以需要将数组的容量缩容到原来容量的一半,这个时候的 removeLast 的时间复杂度是 O(n)
如果这个时候,我们再一次调用 addLast 往数组中添加元素,那么又需要扩容,时间复杂度又是 O(n)
如果,我们又调用 removeLast 删除最后一个元素,这个时候又需要缩容,时间复杂度又是 O(n)
如此往复的话,导致 addLast 和 removeLast 的时间复杂度一直是 O(n)。这样就是出现了复杂度的震荡,导致性能变差。
出现复杂度震荡的原因其实是 removeLast 的 resize 过于着急,当元素的个数变为容量的一半的时候,我们立马将数组的容量缩小为之前容量的一半了,这个时候的数组是满的,也就是说数组中元素的个数等于数组的容量,这个时候如果再一次调用 addLast 的时候,就又要扩容了。
解决方案就是:
- addLast 的逻辑不变
- 对于 removeLast 方法,当元素的个数等于数组容量的一半的时候,不着急进行缩容,而是等元素的个数等于数组容量的 1/4 的时候,再对数组进行缩容,缩容的话也只是将容量缩为原来容量的 1/2
对于 removeLast 的方法实现,你可以看上面的 ArrayList 源码
动态数组 vs 静态数组
前面我们通过二次封装 Java 的数组而得到了一个动态数组类,为什么叫做动态数组呢?更多的是因为它具有动态扩容缩容的功能,而 Java 自带的数组是没有这个功能的,所以 Java 自带的数组我们一般称为静态数组。
是不是有了动态数组,我们就可以抛弃静态数组呢?不是这样的,静态数组和动态数组各有各的优缺点吧,接下来我们分别来讨论。
动态数组有两个很大的优势:
-
将很多数组操作的细节封装起来,比如我们前面讲到的数组插入、数组删除时需要搬移数据的动作,都是封装在动态数组中的,用户使用动态数组的时候,完全没必要关心这些细节,直接使用插入、删除等方法即可
-
动态数组支持动态的扩容和缩容,所以当你使用动态数组的时候,你完全没必要去关心数组的容量是不是不够,你只需要关心数组容量的初始容量
对于动态数组的使用,我们需要注意一个事情,那就是扩容缩容操作会涉及内存申请和数据的移动。一般是比较耗时的,所以说,如果你知道在动态数组中需要存储多大的数据的时候,那么你在初始化数组的时候,就需要指定数组的容量,这样的话,就不会因为数组的扩容而影响性能了。
如果你事先知道数组中会存储多少数据的话,那么初始化数组的时候最好指定容量。如果这个时候你又不需要使用动态数组中的其他的方法的话,我建议你使用静态数组,因为这样性能更好,如下:
int[] arr = new int[32];
for (int i = 0; i < 32; i++) {
arr[i] = i;
}
对于封装了数组的动态数组而言,相比于原生静态数组,会多一些额外开销的,会损失一点性能的,所以说如果你是做底层开发,对性能的要求非常的严格,那么我建议你是用静态数组
如果你只是做一般的业务开发的话,直接使用动态数组就可以了,因为动态数组使用灵活方便,消耗一点点性能不会影响到整个系统的性能。
还有一个场景必须要使用静态数组,那就是存储基本类型的数据,比如 int、long、double 等类型数据,动态数组 Array 虽说支持泛型,但是它不能存储基本类型的数据,如果你的业务场景要求必须要存储基本类型的数据的话,那么就得使用静态数组了。
一个程序员 5 年内需要的数据结构与算法知识都在这里,系统学习:数据结构与算法