数组简介 Array

189 阅读10分钟

数组本质

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

线性表结构:数据只有前后两个方向,像链表、队列和栈都是线性表结构,而非线性表结构中的数据不只有两个方向直接关联到其他数据,如树和图。

连续的内存空间:数组是存放在连续内存空间上的相同类型数据的集合,数组可以方便的通过下标索引的方式获取到下标下对应的数据。

利用 C语言指针变量可以很清楚得看出数组这个特性:

void arrayAddr(){ int arr[5] = {19, 94, 10, 13, 9}; 
    printf("arr[0]'s address is %p\n", &arr[0]); // arr[0]'s address is 000000000061FE00 
    printf("arr[1]'s address is %p\n", &arr[1]); // arr[1]'s address is 000000000061FE04 
    printf("arr[2]'s address is %p\n", &arr[2]); // arr[2]'s address is 000000000061FE08 
    printf("arr[3]'s address is %p\n", &arr[3]); // arr[3]'s address is 000000000061FE0C 
    printf("arr[4]'s address is %p\n", &arr[4]); // arr[4]'s address is 000000000061FE10 
}

上述代码开辟了五个 int类型 大小的数组,依次打印每个元素的内存地址,因为 int 类型占4个字节:

▪ 第一个元素 19 所在的内存地址是 000000000061FE00 ~ 000000000061FE03;

▪ 第二个元素 94 所在的内存地址是 000000000061FE04 ~ 000000000061FE07,

▪ ...

以此类推,五个 int 类型 大小的数组所在的内存地址范围是连续的 000000000061FE00 ~ 000000000061FE13,共20个字节。

相同类型的数据:连续内存空间和相同类型的数据,可以轻松实现根据下标随机访问数组元素。

随机访问数组中的某个元素,就是通过下标访问,如 arr[1] 是访问数组第二个元素,arr[3] 是访问数组第四个元素。

当计算机需要随机访问数组中的某个元素时,那它会首先通过下面的寻址公式,计算出该元素存储的内存地址:

arr[i]_address = base_address + i * data_type_size

其中 base_address 是数组首地址,data_type_size 表示数组中每个元素的大小。

我们举的C语言例子里,数组首地址是 000000000061FE00,数组中存储的是int类型数据,所以 data_type_size 就为4个字节,所以 arr[2] = 000000000061FE00 + 2*4 = 000000000061FE08 。

这也是为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始,如果数组要从 1 开始编号,那寻址公式就变成了:

arr[i]_address = base_address + (i-1) * data_type_size

每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。

自定义数组容器 IntArray

对于Java开发者来说,ArrayList 应该是天天用了,那 ArrayList 是怎么封装数组操作的呢?

现在就手写一个数组容器来熟悉对数组的操作,数组是用于存放数据的,那增删改查API就必不可少:

⨳ void add(int index, E e) 在 index 索引的位置插入一个新元素 e

⨳ E remove(int index) 从数组中删除index位置的元素, 返回删除的元素

⨳ E get(int index) 获取index索引位置的元素

⨳ void set(int index,E e) 修改index索引位置的元素为e

Java 的 ArrayList 封装的是Object数组,可以使用泛型来限制元素类型,咱就简单一点,存储基本类型 int 即可:

public class IntArray 
{
    private int[] data; 
    private int size;
}

如上,IntArray 有两个成员变量,int类型的数组 和 表示数组中元素个数的 size。

注意这个 size 不仅仅可以表示元素的个数,还表示一个指针,指向下一个要添加元素的位置,即 add(E e) 默认添加的位置就是 size 指向的位置。

数组初始化

因为数组是一组连续的内存空间,所以数组初始化要提前指定数组的容量,来进行内存分配,那 IntArray 的构造函数就来初始化数组吧:


// 构造函数,传入数组的容量 capacity 构造 IntArray
public IntArray(int capacity){
    data = new int[capacity]; 
    size = 0; 
} 

// 无参数的构造函数,默认数组的容量 capacity=10 
public IntArray(){ 
    this(10); 
}

注意数组容量 capacity 和数组大小 size 是不一样的,capacity表示数组最多可以存储多少个元素,size 表示数组存了多少个元素。

capacity 是固定的,size 是不定的,每当在数组中添加一个元素,size 就要加一,当 size 等于 capacity 时,表示数组已经存满了,无法再继续往数组中添加元素了。

在指定位置添加元素

// 在index索引的位置插入一个新元素e
public void add(int index, int e){

    if(size == data.length)
        throw new IllegalArgumentException("Add failed. Array is full.");

    if(index < 0 || index > size)
        throw new IllegalArgumentException("Add failed. Require index >= 0 and index <= size.");

    for(int i = size - 1; i >= index ; i --)
        data[i + 1] = data[i];

    data[index] = e;

    size ++;
}

代码很好理解,这里注意一点,因为数组的内存是连续的,为了更好地根据下标进行随机访问,对数组中元素的插入和删除也不能破坏其连续性,所以以上代码在index索引的位置插入一个新元素e时,要将原index指向的元素,以及其后的元素都要向后位移一位。

后移元素下标集合为 [index,size):

⨳ 在数组头部添加元素,就是将 [0,size) 指向的元素后移一位,如果size为0,则集合为空,空集合指向的元素后移,即没有移动元素。

⨳ 在数组尾部添加元素,就是将 [size,size) 指向的元素后移一位,因集合为空,没有元素被移动,所以仅仅在 size 指向位置插入元素即可。

无论哪种情况,都满足将 [index,size) 指向的元素后移一位的逻辑。

在指定位置删除元素

删除元素是添加元素的反面,删除 index 指定位置的元素时,也要将 index 之后的元素向前移动一位,来保证数组中元素的连续性:

// 从数组中删除index位置的元素, 返回删除的元素
public int remove(int index){
    if(index < 0 || index >= size)
        throw new IllegalArgumentException("Remove failed. Index is illegal.");

    int ret = data[index];
    for(int i = index + 1 ; i < size ; i ++)
        data[i - 1] = data[i];
    size --;
    return ret;
}

注意,前移元素下标集合为 [index+1,size):

⨳ 在数组头部删除元素,是将 [1,size) 前移一位,如 size = 1,则集合为空,没有被元素前移,所以仅仅需要将 size -1 即可;

⨳ 在数组尾部删除元素,是将 [size,size) 前移一位,因集合为空,没有被元素前移,仅仅需要将 size -1 即可;

无论哪种情况,都满足将 [index+1,size) 指向的元素前移一位的逻辑。

在指定位置获取和修改元素

这种情况最简单,元素都不需要移动,直接贴代码即可:

// 获取index索引位置的元素
public int get(int index){
    if(index < 0 || index >= size)
        throw new IllegalArgumentException("Get failed. Index is illegal.");
    return data[index];
}

// 修改index索引位置的元素为e
public void set(int index, int e){
    if(index < 0 || index >= size)
        throw new IllegalArgumentException("Set failed. Index is illegal.");
    data[index] = e;
}

动态扩容

我们自己定义的数组容器 IntArray 是不支持动态扩容的,也就是说数组的容量 capacity 在初始化的时候就确定了,如果 size = capacity 时,再往数组中添加元素就会报错。

如果想让 IntArray 在数组满的时候可以继续添加元素,就得修改 add 方法:

⨳ 去掉数组已满校验

⨳ 添加当数组已满时,将数组扩容的逻辑

数组扩容的基本逻辑就是创建一个更大的数组,将原数组的元素复制到新数组,在将新数组替换原数组。

// 在index索引的位置插入一个新元素e,支持动态扩容
public void addDynamics(int index, int e) {

    if (index < 0 || index > size)
        throw new IllegalArgumentException("Add failed. Require index >= 0 and index <= size.");
    // 数组已满
    if (size == data.length){
        // 创建一个更大的数组
        int[] newData = new int[2*data.length];
        // 将原数组的元素复制到新数组
        for (int i = 0; i < size; i++)
            newData[i] = data[i];
        // 将新数组替换原数组    
        data = newData;
    }
    for(int i = size - 1; i >= index ; i --)
        data[i + 1] = data[i];

    data[index] = e;
    size ++;
}

你可能会想,我直接在数组后面继续申请新空间不行吗,为啥还要创建一个新数组,再费劲巴拉得搬移元素,想法很好,但 Java不支持,C语言也只是选择性的支持,毕竟谁也不知道数组后面的空间有没有被别的数据给占用了。

所以数组的动态扩容是一个很费时的操作。

有动态扩容,那是不是也有动态缩容,以上代码是将新数组的容量扩容到原数组的 2 倍,那我在 remove方法中加一个缩容逻辑行不行,当数组的 size 小于 capacity 的一半时,将数组缩容到原来的一半,行不行。

可以是可以,但要注意,无论扩容还是缩容都是创建新数组,再进行元素搬移,很费时,而且缩容的时机还有再考虑的空间:

⨳ 比如原数组 capacity 为 4,当 size = 4 时,表示数组已满,再往数组中添加元素,就会创建一个 capacity 为 8 的新数组,数据搬移,此时 size = 5;

⨳ 这时,进行删除操作,size 变成了 4 ,为capacity的一半,进行缩容,创建一个 capacity 为 4 的新数组,数据搬移,size =4;

⨳ 如果又数组中添加一个元素,于是再创建一个 capacity 为 8 的新数组,数据搬移,此时 size = 5;

⨳ 如果在从数组中删除一个元素,.....

这就是所谓的复杂度震荡问题,我只是每次添加删除一个元素而已,你这搞的一直进行数组创建,元素搬移,累不累呀,所以缩容的时机很重要,不能和扩容时机保持一致的,要慢一点,Lazy一点,比如当数组元素 size 变成 capacity 的 1/4 ,再进行缩容。

Java之ArrayList面试题

ArrayList默认容量是多少?

private static final int DEFAULT_CAPACITY = 10;

ArrayList的扩容机制了解吗?

ArrayList 是基于数组的集合,数组的容量是在定义的时候确定的,如果数组满了,再插入,就会数组溢出。所以在插入时候,会先检查是否需要扩容,如果当前容量+1超过数组长度,就会进行扩容。

ArrayList的扩容是创建一个1.5倍的新数组,然后把原数组的值拷贝过去。