0. 序言
- 数组是线性表的代表,是很多复杂数据结构的底层实现;对数组的特性认识越深刻,对学习和设计复杂的数据结构大有裨益,总之不要小瞧数组。
- 本文为了处理“索引没有语意”的情况,即当索引没有语意的时候,如何表示剩余的数组空间空元素这种情况,以及如何添加元素和如何删除元素(Java中给出的数组是没有添加元素和删除元素的功能的,为了处理索引没有语意的情况以及更好的理解Java的数组,我们二次封装了自己的数组类,实现增删改查【一些复杂数据结构的背后都有数组的身影,而且通过数组的二次封装实现增删改查】,为后面学习栈、队列等做准备)。
- 这里我们实现一个动态数组,来实现增删改查以及实现动态扩容和缩容,避免内存空间的浪费。而这里所说的“动态数组”,当你看完这篇文章后,你会发现这个动态数组的底层其实是一个静态数组,只不过是通过resize来解决固定容量的问题。
- 如果你觉得我对第二段话不是很理解,那么不妨一点点往下看,终有柳暗花明又一村的那一刻。
1. 数组基础
- 本质:数组是典型的线性表,由N个具有相同类型的数据元素组成的有限序列。
- 特征:元素之间的关系是一对一的关系,即除了第一个和最后一个元素之外,其他元素都是首尾相接的。
- 类型:数组是静态数据结构,在内存中的空间分配是连续的,用于区分各个元素的数字编号称为下标或索引。
2. 数组优缺点
- 优点:
① 设计时相当简单。
② 读取和修改列表中的任一元素的时间都是固定的。 - 缺点:
① 删除和添加数据时需要移动大量的数据。
② 在编译器就要把内存分配给相关变量,所以在创建初期,必须事先声明最大可能需要的固定存储空间,这就可能造成内存的浪费。
3.数组代码展示
- 声明
int[] arr = new int[10];
int[] scores = new int[]{100,99,98};
- 大小
arr.length
- 遍历
for (int i = 0; i <arr.length; i++) {
}
for (int score:scores) {
System.out.println(score);
}
- 增加和修改
arr[i] = i;
4. 数组需注意的地方
- 数组最大的优点:快速查询
因为是静态数据结构,在内存中是连续分配的空间,根据索引就可以区分元素所带来的好处就是根据索引就可以快速查询元素。 - 数组最好应用于“索引有语意"的情况
比如查询学生的成绩,只需要让学号作为索引,根据学号来查成绩。但如果索引没有语意的话,就无法根据索引快速查询元素。 - 并非所有有语意的索引都适用于数组
比如查询学生的成绩,你可以把身份证号作为索引来区分学生的成绩,这明显是有语意的。但如果索引是身份证号,在创建数组的时候就需要开辟超级大的内存空间,显然这是不合理的。
5. 动态数组的设计
public class Array {
private int[] data;
private int size;
// 构造函数,传入数组的容量capacity构造Array
public Array(int capacity) {
data = new int[capacity];
size = 0;
}
// 无参数的构造函数,默认数组的容量capacity = 10
public Array() {
this(10);
}
// 获取数组中的元素个数
public int getSize() {
return size;
}
// 获取数组的容量
public int getCapacity() {
return data.length;
}
// 返回数组是否为空
public boolean isEmpty() {
return size == 0;
}
}
说明:
① capacity是创建数组时设置的数组的最大可容纳的元素数,即数组的length。
② size是当前数组中元素的个数,数组初始化的时候,size = 0 ,指向数组中索引为0的地方。
③ data是我们定义的数组,如果没有给定数组的大小,默认大小是10.
④ isEmpty是判断数组是否为空。
6. 添加元素
当添加一个元素的时候,size的个数是1,此时指向索引是1的地方。注意:我们不允许在索引0没有元素的情况下,往索引1的位置添加元素,也就是添加元素的时候,索引不能大于size。
假设数组中有4个元素,当往索引1的位置添加元素的时候,元素99需要首先先往后移动1位,以此类推,元素88再往后移动1位,元素77再往后移动1位,这个时候索引为1的地方的元素值依然是77,然后把索引为1的位置的元素77替换为我们想要设置的元素值即可,当然最后需要把size往后移动1位。
// 在第index个位置插入一个新元素e
public void add(int index, int e) {
if (size == data.length) // 1
throw new IllegalArgumentException("AddLast failed. Array is full");
if (index < 0 || index > size) { // 2
throw new IllegalArgumentException("Add failed. Require index >=0 and index <= size.");
}
for (int i = size - 1; i >= index; i--) // 3
data[i + 1] = data[i];
data[index] = e; // 4
size++; // 5
}
说明:
① 代码1:当数组中的元素个数达到数组的最大存储空间的时候,不允许添加元素并抛出异常。
② 代码2:不允许向索引小于0以及索引比size大的地方添加数据并抛出异常。
③ 代码3:size-1位置到等于我们指定的添加元素的索引的位置的元素全部往后挪动1位,即赋值给后1位且从后开始执行。
④ 代码4:把index位置的元素替换为我们想要设置的元素e。
⑤ 代码5:size的位置往后移动1位。
既然有了添加元素的方法,那么可以衍生出来往数组第一个位置和最后一个位置添加元素的方法:
// 向第一个索引位置添加一个元素
public void addFirst(int e) {
add(0, e);
}
// 向所有元素后添加一个新元素
public void addLast(int e) {
add(size, e);
}
7. 查询和修改元素
- 查询数组内容
@Override
public String toString() {
StringBuffer res = new StringBuffer();
res.append(String.format("Array: size = %d,capacity = %d\n", size, data.length));
res.append('[');
for (int i = 0; i < size; i++) {
res.append(data[i]);
if (i != size - 1)
res.append(", ");
}
res.append(']');
return res.toString();
}
在这里我们重写了类的toString方法,用来打印data数组中的内容,以便我们进行我们的方法测试。
- 检测
public class TestArray {
public static void main(String[] args){
Array array = new Array(20);
for (int i = 0; i < 10 ; i++) {
array.addLast(i);
}
System.out.println(array);
array.add(1,100);
System.out.println(array);
array.addFirst(-1);
System.out.println(array);
}
}
Array: size = 10,capacity = 20
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Array: size = 11,capacity = 20
[0, 100, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Array: size = 12,capacity = 20
[-1, 0, 100, 1, 2, 3, 4, 5, 6, 7, 8, 9]
通过以上测试:添加元素的三个方法都是正确的。
- 查询元素
// 获取index索引位置的元素
int get(int index) {
if (index < 0 || index >= size)
throw new IllegalArgumentException("Get failed. Index is illegal.");
return data[index];
}
// 获取数组中最后一个元素
public E getLast() {
return get(size - 1);
}
// 获取数组中第一个元素
public E getFirst() {
return get(0);
}
- 修改元素
// 修改index索引位置的元素为e
void set(int index, int e) {
if (index < 0 || index >= size)
throw new IllegalArgumentException("Get failed. Index is illegal.");
data[index] = e;
}
8. 包含、搜索和删除
- 包含和搜索
// 查找数组中是否有元素e
public boolean contains(int e){
for (int i = 0; i < size ; i++) {
if (data[i] == e)
return true;
}
return false;
}
// 查找数组中元素e所在的索引,如果不存在元素e,则返回-1
public int find(int e){
for (int i = 0; i < size ; i++) {
if (data[i] == e)
return i;
}
return -1;
}
当包含元素e的时候返回true,否则返回false。当数组中含有元素e的时候,返回e元素所在的索引,否则返回-1。
-
删除指定索引的元素
当要删除索引为1的元素的时候,索引2、3和4位置的元素都会往前面移动1位,所谓移动1位指的是索引为2的元素值赋值给索引1的元素,索引为3的元素值赋值给索引2的元素,索引为4的元素值赋值给索引3的元素。最后别忘记size往前移动1位。
可能你会说size一般指向的没有元素的索引,这里size指向了100,会不会有问题呢?答案是否定的,因为我们操作索引上的元素的时候,会校验索引的合法性。
// 从数组中删除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;
}
有了删除的方法,可以设计删除数组中第一个元素和最后一个元素的方法:
// 从数组中删除第一个元素,返回删除的元素
public int removeFirst(){
return remove(0);
}
// 从数组中删除最后一个元素,返回删除的元素
public int removeLast(){
return remove(size-1);
}
- 删除指定的元素
// 从数组中删除元素e
public void removeElement(int e){
int index = find(e);
if (index != -1)
remove(index);
}
这里有两点需要注意:
① 这里的删除元素的方法,只能删除一个元素,即可能存在相同的两个或多个元素,而这个方法只会删除其中一个元素。同样,find方法也存在相同的问题,感兴趣的可以对方法重新设计。
② 可能你会说这个方法没有返回成功或者失败。这个你也可以自行设计。
而两项只是设计上的问题,方法并没有问题,暂时先这样,因为这不是重点。
- 检测
// [-1, 0, 100, 1, 2, 3, 4, 5, 6, 7, 8, 9]
array.remove(2);
System.out.println(array);
array.removeElement(4);
System.out.println(array);
array.removeFirst();
System.out.println(array);
array.removeLast();
System.out.println(array);
int i = array.find(0);
System.out.println(i);
boolean contains = array.contains(0);
System.out.println(contains);
int i1 = array.get(0);
System.out.println(i1);
array.set(0,10);
System.out.println(array);
Array: size = 11,capacity = 20
[-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Array: size = 10,capacity = 20
[-1, 0, 1, 2, 3, 5, 6, 7, 8, 9]
Array: size = 9,capacity = 20
[0, 1, 2, 3, 5, 6, 7, 8, 9]
Array: size = 8,capacity = 20
[0, 1, 2, 3, 5, 6, 7, 8]
0
true
Array: size = 8,capacity = 20
[10, 1, 2, 3, 5, 6, 7, 8]
通过检测,会发现以上的方法正确,让我们继续。
9. 泛型
以上我的数组类Array,只能放置int数据类型的数据,为了让Array可以放置“任何”数据类型,我们选择使用泛型,当然“任何”二字之所以加引号,是因为数据类型不可以是基本数据类型,只能是类对象,所以我们定义基本数据类型的包装类即可,这样当遇到基本数据类型的时候,Java会自动装箱为对应的包装类。
public class Array<E> {
private E[] data;
private int size;
// 构造函数,传入数组的容量capacity构造Array
public Array(int capacity) {
data = (E[]) new Object[capacity]; // 4
size = 0;
}
// 无参数的构造函数,默认数组的容量capacity = 10
public Array() {
this(10);
}
// 获取数组中的元素个数
public int getSize() {
return size;
}
// 获取数组的容量
public int getCapacity() {
return data.length;
}
// 返回数组是否为空
public boolean isEmpty() {
return size == 0;
}
// 向第一个索引位置添加一个元素
public void addFirst(E e) {
add(0, e);
}
// 向所有元素后添加一个新元素
public void addLast(E e) {
add(size, e);
}
// 在第index个位置插入一个新元素e
public void add(int index, E e) {
if (size == data.length)
throw new IllegalArgumentException("AddLast 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索引位置的元素
public E 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, E e) {
if (index < 0 || index >= size)
throw new IllegalArgumentException("Set failed. Index is illegal.");
data[index] = e;
}
// 查找数组中是否有元素e
public boolean contains(E e) {
for (int i = 0; i < size; i++) {
if (data[i].equals(e)) // 1
return true;
}
return false;
}
// 查找数组中元素e所在的索引,如果不存在元素e,则返回-1
public int find(E e) {
for (int i = 0; i < size; i++) {
if (data[i] .equals(e) ) // 2
return i;
}
return -1;
}
// 从数组中删除index位置的元素,返回删除的元素
public E remove(int index) {
if (index < 0 || index >= size)
throw new IllegalArgumentException("Remove failed. Index is illegal.");
E ret = data[index];
for (int i = index + 1; i < size; i++)
data[i - 1] = data[i];
size--;
data[size] = null; // 程序优化——实际上是一个闲散的对象!=内存泄露 // 3
return ret;
}
// 从数组中删除第一个元素,返回删除的元素
public E removeFirst() {
return remove(0);
}
// 从数组中删除最后一个元素,返回删除的元素
public E removeLast() {
return remove(size - 1);
}
// 从数组中删除元素e
public void removeElement(E e) {
int index = find(e);
if (index != -1)
remove(index);
}
@Override
public String toString() {
StringBuffer res = new StringBuffer();
res.append(String.format("Array: size = %d,capacity = %d\n", size, data.length));
res.append('[');
for (int i = 0; i < size; i++) {
res.append(data[i]);
if (i != size - 1)
res.append(", ");
}
res.append(']');
return res.toString();
}
}
有两个地方需要注意:
① 代码1和代码2:类对象之间的对比用的是equals而不是“=”
② 代码3:size此时指向的是一个对象的引用,出于程序优化的目的,这里选择把它置为null。这个对象的引用并非是内存泄露,因为我们在数组后面插入数据后,data[size]所指代的对象引用就会发生变化,之前的对象的引用就会引起垃圾回收机制的注意,根据垃圾回收机制会被回收掉。总之它是一个闲散的对象,而非内存泄露,处理它是出于程序优化的目的。
③ 代码4:泛型并不能new出来,而是需要new一个Object对象,然后强转为泛型对象。
Array<Integer> array = new Array<>(20);
之前我们测试数组,就可以修改为Interger类型即可。
public class Student {
private String name;
private int score;
public Student(String name, int score) {
this.name = name;
this.score = score;
}
@Override
public String toString() {
return String.format("Student(name:%s,score:%d",name,score);
}
public static void main(String[] args){
Array<Student> array = new Array<>();
array.addLast(new Student("小明",99));
array.addLast(new Student("小红",100));
System.out.println(array);
}
}
Array: size = 2,capacity = 10
[Student(name:小明,score:99, Student(name:小红,score:100]
证明我们的泛型添加正确,达到了预期效果。
10. 动态数组
现在我们的数组Array依然是一个静态数组,如果开的太大可能造成空间的浪费,如果开的太小可能空间不够,所以这里我们让数组Array容量动态伸缩,即让Array成为一个动态数组。
原来的数组为data,数组中有6个元素,当我们想再添加新的元素的时候,由于数组空间不够,所以我们新创建一个数组newdata,数组容量大小是data数组的2倍,然后遍历data数组中的元素,赋值到newdata中,然后把data数组的引入指向newdata数组,这样以来data数组就拥有了动态扩容的技能。而为什么是之前数组容量的2倍,而不是10或者1000呢?是因为这里不想引入一个常数,因为10可能扩容得太小,1000可能扩容的太大,而2倍可以和之前的数组容量相关,它们处在一个数量级上,所以更加合适,而为什么是2倍,而不是1.5倍等等,后续会详细探讨这部分内容。
// 在第index个位置插入一个新元素e
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed. Require index >=0 and index <= size.");
}
if (size == data.length)
resize(2 * data.length);
for (int i = size - 1; i >= index; i--)
data[i + 1] = data[i];
data[index] = e;
size++;
}
// 数组的扩容
private void resize(int newCapacity) {
E[] newData = (E[]) new Object[newCapacity];
for (int i = 0; i < size ; i++)
newData[i] = data[i];
data = newData;
}
添加元素,如果发现size == data.length,那么就执行扩容函数resize,数组的扩容大小是之前的2倍,然后把之前的数组元素赋值给新的数组,然后让原来的数组引用指向新创建的数组,由于newData是局部变量,在函数执行完以后它就失效了,而data是成员变量,所以data依然有效。
Array<Integer> array = new Array<>();
for (int i = 0; i < 10 ; i++) {
array.addLast(i);
}
System.out.println(array);
array.add(1,100);
System.out.println(array);
array.addFirst(-1);
System.out.println(array);
Array: size = 10,capacity = 10
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Array: size = 11,capacity = 20
[0, 100, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Array: size = 12,capacity = 20
[-1, 0, 100, 1, 2, 3, 4, 5, 6, 7, 8, 9]
从以上示例来看,数组Array自动扩容了,非常酷!那扩容实现了,缩容也很简单,当我们删除元素,发现现有的数组的元素size是容量capacity的一半的时候,我们调用下resize,让数组的容量变为之前的1/2:
// 从数组中删除index位置的元素,返回删除的元素
public E remove(int index) {
if (index < 0 || index >= size)
throw new IllegalArgumentException("Remove failed. Index is illegal.");
E ret = data[index];
for (int i = index + 1; i < size; i++)
data[i - 1] = data[i];
size--;
data[size] = null; // 程序优化——实际上是一个闲散的对象!=内存泄露
if (size == data.length / 2)
resize(data.length/2);
return ret;
}
让我们测试下:
array.remove(2);
System.out.println(array);
array.removeElement(4);
System.out.println(array);
array.removeFirst();
System.out.println(array);
Array: size = 11,capacity = 20
[-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Array: size = 10,capacity = 10
[-1, 0, 1, 2, 3, 5, 6, 7, 8, 9]
Array: size = 9,capacity = 10
[0, 1, 2, 3, 5, 6, 7, 8, 9]
Array: size = 8,capacity = 10
[0, 1, 2, 3, 5, 6, 7, 8]
当数组的size为11的时候,数组的容量并没有发生变化,当数组的size为10的时候,数组容量缩小到了10.非常酷!
11. 复杂度分析
如果对复杂度分析不了解,请跳转阅读:https://www.jianshu.com/p/2d5e5f1bc77e
如果对平均时间复杂度不了解,请跳转阅读:https://www.jianshu.com/p/08d1d509c5db
如果对均摊时间复杂度不了解,请跳转阅读:https://www.jianshu.com/p/59f380c6ffcd
- 添加操作
① addLast(e)---O(1)
② addFirst(e)---O(n)
因为需要进行数据的搬移工作,所以时间复杂度为O(n)
③ add(index,e)---O(n)
④ resize---O(n)
一共是n个索引,索引范围是0~n-1,索引添加数据而造成的搬移数据量分别是1+2+3+...+n-1+n,每个位置被插入数据的概率是1/2,所以平均数据搬移量是1/2乘以(1+2+3+...+n-1+n)/n = (n+1)/4个数据,用大O时间复杂度表示法表示平均时间复杂度为O(n).
综上:根据时间复杂度分析之加法法则,添加操作的时间复杂度为O(n) - 删除操作
① removeLast(e)---O(1)
② removeFirst(e)---O(n)
③ remove(index,e)---O(n)(同添加操作分析)
④ resize---O(n)
综上:根据时间复杂度分析之加法法则,添加操作的时间复杂度为O(n) - 修改操作
set(index,e)---O(1)
数组支持随机访问,这是数组最大的优势。 - 查找操作
① get(index)---O(1)
② contains(e)---O(n)
③ find(e)---O(n)
综上:
- 增:O(n)
- 删:O(n)
- 改:已知索引O(1);未知索引O(n)
- 查:已知索引O(1);未知索引O(n)
12.addLast+resize的时间复杂度
添加操作的addLast的时间复杂度是O(1),resize的时间复杂度是O(n),而resize只有满足条件的时候才会触发,所以不能简单的说addLast+reszie的时间复杂度就是O(n),这个时候就应该用均摊时间复杂度分析,不了解均摊时间复杂度分析的,请跳转阅读:https://www.jianshu.com/p/59f380c6ffcd
假设capacity = 8 ,并且每次添加操作都使用addLast,那9次的addLast操作,才会触发resize,总共进行了17次基本操作。根据均摊复杂度分析,平均每次addLast操作进行2次基本操作。即假设capacity=n,n+1次addLast,触发resize,总共进行2n+1次操作,平均每次addLast操作,进行2次基本操作。
综上:addLast+resize,根据均摊时间复杂度分析,得知时间复杂度为O(1)。同理,removeLast操作,均摊复杂度也为O(1)。
13. 复杂度震荡
看下我们之间的addLast和removeLast操作,会发现有点问题。addLast当size == capacity的时候会进行扩容操作,而此时执行removeLast操作,由于size =capacity/2会执行缩容的操作,循环往复的话,就非常不合理,addLast和removeLast都导致了时间复杂度为O(n)的操作,我们称这种情况为复杂度震荡。
解决方案:每次removeLast时,size = capacity/4的时候再进行缩容操作—称为Lazy解决方案:
// 从数组中删除index位置的元素,返回删除的元素
public E remove(int index) {
if (index < 0 || index >= size)
throw new IllegalArgumentException("Remove failed. Index is illegal.");
E ret = data[index];
for (int i = index + 1; i < size; i++)
data[i - 1] = data[i];
size--;
data[size] = null; // 程序优化——实际上是一个闲散的对象!=内存泄露
if (size == data.length / 4 && data.length/2 !=0) // 1
resize(data.length/2);
return ret;
}
代码1处,需要注意data.length/2可能为0,毕竟数组容量不能初始化为0,所以这里要注意。
14. 后续
如果大家喜欢这篇文章,欢迎点赞!
如果想看更多 数据结构 方面的文章,欢迎关注!