数组

314 阅读9分钟

数组的特点

我们知道数组具有如下二个特点:

  1. 数组是线性数据结构,使用数组存储的数据会排成像一条线一样的结构。

图片

  1. 数组用一组连续的内存空间,来存储一组具有相同类型的数据

图片

如果我们想访问上图数组中索引为 2 的元素的值,我们应该按照下面两步来达到目的:

  1. 先计算索引为 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,我们应该:

  1. 通过寻址公式,计算得到 index = 3 的元素所在的内存地址等于 1000 + 3 * 4 = 1012
  2. 将内存地址为 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)

总结

  1. 数组的随机读写是非常高效的,时间复杂度是 O(1)
  2. 数组的随机删除和插入操作相对来说则比较低效,不管是时间复杂度还是空间复杂度,都是 O(n)

二次封装静态数组

前面我们讲解了 Java 的数组的特性,现在我们来看看 Java 数组提供的方法:

图片

从上图可以看出,Java 数组除了提供一个属性 lenght 外,没有提供任何的方法,上面的这些方法实际上都是从 java.lang.Object 父类中继承的。

然而在实际的数组使用中,我们会经常对数组进行如下的操作:

  1. 计算一个数组中实际上存储了多少个元素,注意:数组的属性 length 只能表达这个数组的长度,不能表达这个数组中真实存储了多少个元素
  2. 计算一个数组中是否包含某个元素
  3. 对数组进行删除某个元素
  4. 对数组进行新增某个元素
  5. 判断数组是否为空 (也就是数组没有存储任何的数据)
  6. ........

如果每个人都需要对数组进行上面的操作,那么可能就会导致每个人都会写一份类似的代码,那么就导致了相同的代码散乱在各个不同的地方,这样会降低代码的可维护性了。

针对这个问题,我们可以写一个名为 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 包含了数组的增删改查操作,这里需要重点强调的是数组的扩容缩容操作

我们知道,静态数组一旦初始化,它的长度就是固定不变的,但是在实际工作中,我们会碰到不断的往数组中添加元素,一旦达到了数组的长度后,我们这个时候就需要给数组进行扩容了

当数组每次装满元素的时候,我们都可以将数组的容量自动的扩容两倍,扩容的步骤为:

  1. 创建一个容量为 2 倍于之前容量的临时新数组
  2. 将原始数组中的数据拷贝到新数组中
  3. 将新数组覆盖老数组

代码请看 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 的时候,就又要扩容了。

解决方案就是:

  1. addLast 的逻辑不变
  2. 对于 removeLast 方法,当元素的个数等于数组容量的一半的时候,不着急进行缩容,而是等元素的个数等于数组容量的 1/4 的时候,再对数组进行缩容,缩容的话也只是将容量缩为原来容量的 1/2

对于 removeLast 的方法实现,你可以看上面的 ArrayList 源码

动态数组 vs 静态数组

前面我们通过二次封装 Java 的数组而得到了一个动态数组类,为什么叫做动态数组呢?更多的是因为它具有动态扩容缩容的功能,而 Java 自带的数组是没有这个功能的,所以 Java 自带的数组我们一般称为静态数组。

是不是有了动态数组,我们就可以抛弃静态数组呢?不是这样的,静态数组和动态数组各有各的优缺点吧,接下来我们分别来讨论。

动态数组有两个很大的优势:

  1. 将很多数组操作的细节封装起来,比如我们前面讲到的数组插入、数组删除时需要搬移数据的动作,都是封装在动态数组中的,用户使用动态数组的时候,完全没必要关心这些细节,直接使用插入、删除等方法即可

  2. 动态数组支持动态的扩容和缩容,所以当你使用动态数组的时候,你完全没必要去关心数组的容量是不是不够,你只需要关心数组容量的初始容量

对于动态数组的使用,我们需要注意一个事情,那就是扩容缩容操作会涉及内存申请和数据的移动。一般是比较耗时的,所以说,如果你知道在动态数组中需要存储多大的数据的时候,那么你在初始化数组的时候,就需要指定数组的容量,这样的话,就不会因为数组的扩容而影响性能了。

如果你事先知道数组中会存储多少数据的话,那么初始化数组的时候最好指定容量。如果这个时候你又不需要使用动态数组中的其他的方法的话,我建议你使用静态数组,因为这样性能更好,如下:

int[] arr = new int[32];

for (int i = 0; i < 32; i++) {
    arr[i] = i;
}

对于封装了数组的动态数组而言,相比于原生静态数组,会多一些额外开销的,会损失一点性能的,所以说如果你是做底层开发,对性能的要求非常的严格,那么我建议你是用静态数组

如果你只是做一般的业务开发的话,直接使用动态数组就可以了,因为动态数组使用灵活方便,消耗一点点性能不会影响到整个系统的性能。

还有一个场景必须要使用静态数组,那就是存储基本类型的数据,比如 int、long、double 等类型数据,动态数组 Array 虽说支持泛型,但是它不能存储基本类型的数据,如果你的业务场景要求必须要存储基本类型的数据的话,那么就得使用静态数组了。

一个程序员 5 年内需要的数据结构与算法知识都在这里,系统学习:数据结构与算法