数据结构_1:自己动手造轮子之动态数组

一、数组

关于数组,指的就是一组有限的相关类型的变量集合。在Java语言中,简单数组并没有像Collections集合的相关操作接口,本文将对简单数组封装相应的操作接口从而形成类ArrayList的集合类为目标,并进行代码时间复杂度分析以及代码优化

在开始之前,我们先对构造的Array类进行成员变量的说明:

  • data:核心操作数组成员变量,为适应多种数据类型数据存储,将data设置为泛型数组。
  • size:当前数组的元素个数控制指针(手动操作作用于数组上的元素个数,在Java语言中开辟数组会将数组元素进行初始化,这点我们忽略),也就是说size指针会永远指向当前已存在元素的下一个索引位置。例如:数组容量为10,当前已存在数组元素为a[0],a[1],a[2],则size指针便指向 a[3] 元素空间上,并表示当前数组存在元素为 3 (这里排除随机存储数据元素的情况,以上情况会很大程度上浪费存储空间,不建议使用)

相应图解如下:
在这里插入图片描述

Part 1 基于泛型的数组类的实现

/**
 * @Author: Jiangyanfei
 * @Date: 2019/4/26 11:35
 * @Version 1.0
 */
public class Array<E> {

    /**
     * 泛型数组
     */
    private E[] data;

    /**
     * 数组实际元素个数
     */
    private int size;

    public Array(int capacity) {
        data = (E[]) new Object [capacity];
        size = 0;
    }

    public Array() {
        this(10);
    }

    /**
     * 获取数组元素的个数
     */
    public int getSize() {
        return size;
    }

    /**
     * 获取数组容量
     */
    public int getCapacity() {
        return data.length;
    }

    /**
     * 判断数组是否为空
     */
    public boolean isEmpty() {
        return size == 0;
    }
    
}

基础的属性方法之后,要开始对常规的操作接口 [CURD] 的设计。先对数组元素的查询和修改进行方法设计,对于存在数组容量变更的添加和删除操作在后面会说明。

  • 查询
    • 获取index索引处的元素

        /**
         * 获取index索引处的元素
         */
        public E get(int index) {
            if (index < 0 || index > size) {
                throw new IllegalArgumentException("Index is incorrect");
            }
            return data[index];
        }
      
    • 查询数组中是否有元素 e

        /**
         * 查询数组是否存在元素e
         */
        public boolean contains(E e) {
            for (E element : data) {
                if (element.equals(e)) {
                    return true;
                }
            }
            return false;
        }
      
    • 查询数组中元素e是否存在,返回索引,不存在返回 -1

        /**
         * 查询数组中元素e是否存在,返回索引,不存在返回 -1
         */
        public int find(E e) {
            for (int i = 0; i < size; i++) {
                if (data[i].equals(e)) {
                    return i;
                }
            }
            return -1;
        }
      
  • 修改
    • 改变index索引出的元素值

        /**
         * 改变index索引处的元素值
         */
        public void set(int index, E e) {
            if (index < 0 || index > size) {
                throw new IllegalArgumentException("Index is incorrect");
            }
            data[index] = e;
        }
      

Part 2 动态数组实现

在开始正式内容之前,需要先提及一个问题,数组扩容问题?
为什么要数组扩容,在数组使用期间,尤其针对添加和删除操作,定长的数组长度会导致数组空间的浪费,这里提出一种数组扩容的方法:采用新开数组进行数组元素转移实现容量扩充或缩减。

采用数组转移的方式进行扩容方案需要考虑一个问题:扩容的幅度?

假设存在一个容量为10的数组空间(已满),这时候新添加数组元素,扩容的幅度为1?那么每次元素新添加都要进行扩容操作?反之,删除元素同理。频繁的调用扩容方法、大幅度扩大数组容量会造成剩余空间浪费,我们需要从这两种极端情况中寻找 折中策略

私有数组扩容方法:resize()

/**
 * 数组扩容方法
 */
private void resize(int newCapacity) {
    E[] newData = ((E[]) new Object[newCapacity]);
    for (int i = 0; i < size; i++) {
        newData[i] = data[i];
    }
    data = newData;
}

resize() 图示:
在这里插入图片描述
数组扩容或缩容的实质就是:数组元素移动到新数组中,并将数组地址指向新数组。
这里我们默认扩容幅度与缩容幅度倍数为:2 (仅举例,可自定义)

结合上述 resize() 方法,继续完成添加和删除操作方法的设计:

  • 添加
    • 指定索引添加元素

        /**
         * 指定索引处添加元素
         */
        public void add(int index, E e) {
            if (index < 0 || index > size) {
                throw new IllegalArgumentException("Index is incorrect");
            }
            // 数组扩容判断
            if (size == data.length) {
                resize(data.length * 2);
            }
            // 插入元素核心代码,指定索引后数组元素整体后移
            for (int i = size -1; i >= index; i--) {
                data[i+1] = data[i];
            }
            data[index] = e;
            size ++;
        }
      
  • 删除
    • 指定索引删除元素

        /**
         * 指定索引处删除元素
         */
        public E remove(int index) {
            if (index < 0 || index > size) {
                throw new IllegalArgumentException("Index is incorrect");
            }
            // 保存待删除数组元素
            E res = data[index];
            // 删除元素核心代码,指定索引后数组整体前移
            for (int i = index + 1; i < size; i++) {
                data[i-1] = data[i];
            }
            size --;
            // 数组缩容判断
            if (size == data.length / 2 && data.length /2 != 0) {
                resize(data.length / 2);
            }
            return res;
        }
      

Part 3 均摊复杂度浅析以及防止复杂度震动

在提及均摊复杂度之前,首先来分析数组容量变动所引发的时间复杂度变化:

以添加操作为例:假设数组当前 size 为 n

  • 数组首部插入元素,索引直接定位到 0,索引后数组整体移动范围为 n
  • 数组尾部插入元素,索引直接定位到 size,元素插入,原数组元素移动范围 0
  • 数组指定索引插入元素,结合上述两种临界情况,原数组元素移动范围 [0, n]

综合来讲,添加操作的时间复杂度为 O(n),只有在尾部插入元素时,时间复杂度为 O(1)

那么我们假设一种情景,数组容量为 N 的空数组状态下,依次插入 N+1 个数组元素,并且触发数组容量扩容操作的流程。

  • 首先,在空数组状态下依次插入 N 个元素,此时操作数:N
  • 当数组插入第 N 个元素完成后,准备插入第 N+1 个元素时,触发resize()进行数组容量扩容。完成数组扩容操作后,需要将原数组元素进行转移,此时操作数变为了:N + N
  • 当完成数组转移之后,插入第 N+1 个元素。此时操作数为:2N + 1

我们插入了 N+1 个数组元素,总计操作数为:2N + 1,粗略计算平均每插入一个元素需要耗费的操作数为:2

那么这个平均的操作数消耗量我们可以作为均摊复杂度分析的依据,那么添加操作的均摊复杂度为:O(1)

再考虑一种情景,数组容量为 N 的空数组依次插入 N 个元素,当插入第 N+1 个元素后,便进行删除第 N+1 个元素的操作,完成之后继续插入第 N+1 个元素,删除第 N+1 个元素…如此反复的进行该流程。

上面说的场景的本质是,扩容和缩容的高频操作,每次扩容/缩容都需要将数组元素向新数组进行转移,且转移的大小与数组长度有关,这一数组转移操作的时间复杂度稳定在 O(n),那么反复的进行该类时间复杂度稳定的操作,会导致复杂度的震动现象。

针对上述复杂度震荡情况,改进下remove()方法,在数组长度低于 data.length / 2 时触发resize()方法。 将触发的条件 data.length / 2 进行 Lazy 延迟处理 resize() 方法的执行。

if (size == data.length / 4 && data.length /2 != 0) {
	resize(data.length / 2);
}

在上述场景的缩容部分中,数组容量为 2N,resize() 触发条件为当数组长度变为:N/2 时(容量四分点)触发。

注:data.length /2 != 0 是确保不构造容量为 0 的新数组


猜你喜欢

转载自blog.csdn.net/Nerver_77/article/details/89608856