动态数组(集合的底层实现)
前言
这两天学了动态数组,也就是集合,只不过是用底层数组来实现功能,在这其中包含了很多JavaSE的知识点,在这也算是复习一下,相关知识点都整理了一下,之前在学JavaSE时没怎么做过笔记,有的知识点都忘记了,也花了不少时间去重新学习,文章有不足或错误之处,还请见谅。
动态数组简介
在Java中,数组是一种用于存储固定大小的相同类型元素的数据结构。数组是一种引用类型,它存储了元素的引用,而不是元素本身(对于对象类型)或直接存储值(对于基本类型)。以下是Java中数组的一些主要特点:
数组特点:
- 固定大小:数组在创建时必须指定大小,并且之后不能更改。
- 存储同类型元素:数组中的所有元素必须是相同的数据类型。
- 索引访问:可以通过索引(从0开始)访问数组中的元素。
- 内存连续:数组在内存中占据连续的空间。
- 自动初始化:数组在创建时会自动被初始化为其数据类型的默认值(如
int
数组默认为0,boolean
数组默认为false
,对象数组默认为null
)。
很明显,数组的缺点很明显,就是不能改变数组的长度,但在很多时候,我们需要增加新的元素,这时候使用数组存储数据显然不能满足我们的需求,所以我们引入动态数组来解决这个问题。
动态数组
显然Java中也考虑到了这一点,所以Java中有一种名为集合的数据结构,可以完美解决上述问题,但其实集合的底层逻辑也是使用数组来实现的,所以我们这次使用动态数组来解决上述问题。
动态数组的特点主要体现在其灵活性和可扩展性上。与传统的静态数组相比,动态数组能够根据实际需要动态地增加或减少其元素数量。这种灵活性使得动态数组在处理不确定大小的数据集时非常有用。
具体来说,动态数组的特点包括:
- 可变性:动态数组的大小不是固定的,可以根据需要随时调整。当需要添加新元素时,动态数组可以自动扩展其容量以容纳新元素;当需要删除元素时,动态数组可以缩小其容量以释放内存空间。
- 连续性:尽管动态数组的大小可以变化,但在内存中,动态数组的元素仍然是连续存储的。这使得访问动态数组中的元素时,可以使用与静态数组相同的索引方式。
- 高效性:动态数组在添加或删除元素时,通常会尽量保持其内部数据的连续性,以减少内存碎片和提高访问效率。为了实现这一点,动态数组在扩容时通常会分配一块更大的连续内存空间,并将原有数据复制到新空间中;在缩容时,则可能会释放部分内存空间或将数据移动到更紧凑的空间中。
1.定义一个动态数组
首先思考动态数组需要满足什么,需要哪些参数,除了基本的索引,似乎还需要一个代表数组长度的capacity,对于这个capacity,我们先给定它一个初始值,当存储空间不够时,自动扩容。
对于数组的空间占用,有以下特点
Java 中数组结构为:
- 8 字节 markword
- 4字节 class 指针(压缩 class 指针的情况)
- 4字节 数组大小(决定了数组最大容量是 2^32)
- 数组元素+对齐字节(Java 中所有对象大小都是8字节的整数倍 12 ,不足的要用对齐字节补足)
代码实现如下
public class Dynamicarrays{
//逻辑大小
private int size = 0;
//容量
private int capacity = 8;
private int[] array = new int[capacity];
}
2.动态数组的插入
先考虑最基本的,在数组的最后插入数据,代码如下。
public void addLast(int element){
array[size] = element;
size++;
}
接下来就要考虑另一种情况,我们要在任意位置插入元素,如果这个位置本身有元素的话,那么这个元素以及它之后的元素就要向后移动,代码如下
//在数组的指定位置插入元素
public void add(int index,int element){
//首先判断这个数据是合法的,索引位置大于或等于零,且不能超过数组的容量
if(index >= 0 && index < size){
//对于移动元素,我们直接向后复制
System.arraycopy(array,index,array,index+1,size-index);
}
//在原来元素的位置赋新值
array[index] = element;
size++;
}
写完这段代码后,我们发现在指定位置插入,与在最后插入的代码有重复的部分,那我们可以简化一下,直接调用add方法。
//在数组最后添加元素,
public void addLast(int element){
// array[size] = element;
// size++;
add(size,element);
}
3.动态数组的遍历
遍历,对于以前刚学Java的我来说就是用一个for循环,把所有数据元素都打印出来,但在经过后面的学习后,遍历可能不只是要打印元素,我可能不需要打印它,我可能使用遍历去做一些其他的事情,比如把数据元素存储到数据库等等。那么这里就介绍三种不同的遍历方法,在学习这三种遍历方法之前,首先对JavaSE的部分只是进行一个简单的复习。
函数式接口遍历
函数式接口(Functional Interface)就是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口,可以被隐式转换为 lambda 表达式。
函数式接口有很多,这里就先只介绍一个简单的Consumer接口。
Consumer
接口是 Java 8 中引入的一个函数式接口,它位于 java.util.function
包中。Consumer
接口代表了一个接受单一输入参数并且没有返回值的操作。它经常用于对某个对象执行某种操作,而不必关心该操作的具体实现。
Consumer
接口的声明如下:
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
}
这里,T
是一个泛型参数,代表 Consumer
可以接受任何类型的参数。accept
方法就是 Consumer
的唯一抽象方法,它接受一个类型为 T
的参数,并没有返回值。
在使用这个接口时,会自动调用accept接口,那么我们就可以把打印的工作交给方法使用者,具体使用如下。
//函数式接口遍历
public void foreach(Consumer<Integer> consumer){
for (int i = 0; i < size; i++) {
consumer.accept(array[i]);
}
}
编写一个测试类
public void test2(){
Dynamicarrays dynamicarrays = new Dynamicarrays();
dynamicarrays.addLast(1);
dynamicarrays.addLast(2);
dynamicarrays.addLast(3);
dynamicarrays.addLast(4);
dynamicarrays.foreach((element)->{
System.out.println(element);
});
}
迭代器遍历
迭代器在Java中的类是Iterator,迭代器是集合专用的遍历方式。
Iterator中的常用方法:
- boolean hasNext(),这个方法是判断当前位置是否有元素,有元素返回true,没有元素返回false。
- E next(),这个方法是获取当前位置的元素,并且将迭代器对象移向下一个位置。
在这里,我们选择实现一个名为Iterable
的接口,那么它必须提供一个iterator()
方法,该方法返回一个Iterator
对象,用于遍历该类的元素。当你实现Iterable
接口时,你正在告诉其他代码:“我的对象可以被遍历,你可以通过调用我的iterator()
方法来获取一个Iterator
对象,并使用这个对象来遍历我。”
//迭代器遍历
@Override
public Iterator<Integer> iterator() {
return new Iterator<Integer>() {
int i = 0;
//询问有没有下一个元素,有返回true
@Override
public boolean hasNext() {
return i<size;
}
//返回当前元素,并移动到下一个元素
@Override
public Integer next() {
return array[i++];
}
};
}
编写一个测试类来对代码进行测试
@Test
public void test3(){
Dynamicarrays dynamicarrays = new Dynamicarrays();
dynamicarrays.addLast(1);
dynamicarrays.addLast(2);
dynamicarrays.addLast(3);
dynamicarrays.addLast(4);
for (Integer element : dynamicarrays){
System.out.println(element);
}
}
在这里使用了增强for循环的方法来进行遍历,在这里也简单复习一下增强for循环的相关知识
增强for循环(Enhanced for loop),也被称为"for-each"循环,是Java 5及以后版本中引入的一种简化遍历数组或集合元素的方式。这种循环结构用于迭代数组或实现了Iterable
接口的集合对象(如List
, Set
等)。
当你使用增强for循环遍历一个集合或数组时,你不需要担心索引或迭代器的管理。Java编译器会为你处理这些细节。
对于在这个测试代码中使用的增强for循环做一个简单解释:
Integer element
:这是循环变量的声明。在每次迭代中,element
变量都会被赋予dynamicarrays
集合中的下一个元素的值。:
:这个冒号是增强for循环的语法标志,它告诉Java编译器接下来的变量是用于接收集合或数组中每个元素的。dynamicarrays
:这是你要遍历的集合或数组。它必须实现了Iterable
接口(如果是自定义类型)或者是一个数组。System.out.println(element);
:这是循环体中的代码,它会在每次迭代中执行。在这个例子中,它打印出当前element
的值。
当循环开始时,Java会自动调用dynamicarrays
的iterator()
方法,获取一个Iterator
对象。然后,循环会反复调用Iterator
的next()
方法来获取下一个元素,直到没有更多元素为止。每次调用next()
时,都会将返回的元素值赋给element
变量,并执行循环体中的代码。
stream流遍历
在这个动态数组遍历中,我们使用IntStream,因为这个动态数组设置的都是int类型的元素,由于流的知识点过多,就不再一一赘述,只简单复习一下流的定义以及IntStream的方法。
在Java中,流(Stream)是Java 8及以后版本中引入的一个新特性,它允许你以声明性方式处理数据集合(即你可以描述你想要做什么,而不是描述如何去做)。流API主要用于集合的并行或串行处理,并支持链式操作。
Java中的流主要分为两种类型:
- 原始流(Primitive Streams):这些流专门用于处理基本数据类型(如
int
,long
,double
)。Java提供了IntStream
,LongStream
, 和DoubleStream
这三种原始流。 - 对象流(Object Streams):这些流用于处理对象。你可以通过任何实现了
java.util.stream.Stream
接口的对象来创建对象流,最常见的是通过集合类(如List
,Set
)的stream()
方法。
流操作通常分为两类:
- 中间操作(Intermediate Operations):这些操作返回一个新的流,允许进一步的操作链。例如,
filter()
,map()
,sorted()
,limit()
,skip()
等。 - 终端操作(Terminal Operations):这些操作返回一个结果或一个副作用,并且会结束流的处理。例如,
forEach()
,reduce()
,collect()
,count()
,sum()
等。
示例代码如下
//stream流遍历
public IntStream stream(){
return IntStream.of(Arrays.copyOfRange(array,0,size));
}
在编写测试类时,发现有一个小小的问题,如果这个stream()方法,我直接返回return IntStream.of(array);那就会出现一个问题,因为我的有效值只有四个,但我这个数组的容量是八个,因此当我在测试方法中进行循环遍历时,结果把后面的无效元素也打印了出来,所以对代码修改一下,of方法中的参数改为这个数组的有效值部分。
@Test
public void test4(){
Dynamicarrays dynamicarrays = new Dynamicarrays();
dynamicarrays.addLast(1);
dynamicarrays.addLast(2);
dynamicarrays.addLast(3);
dynamicarrays.addLast(4);
dynamicarrays.stream().forEach((element)->{
System.out.println(element);
});
}
4.动态数组的删除
删除代码比较简单,直接上示例代码
//动态数组的删除
public int remove(int index){
int removed = array[index];
if(index<5){
System.arraycopy(array,index+1,array,index,size-index-1);
}
size--;
return removed;
}
但在测试的时候出现了一个问题,我使用Junit框架进行测试,想用assertIterableEquals()这个断言来比较删除后的结果是否相等,代码应该为assertIterableEquals(List.of(1,2,4,5),dynamicarrays);
但由于我使用的jdk版本为1.8,所以不支持List.of()
这个方法,但是当我更换为jdk17后,这个方法仍然不能使用,也不知道是哪里出了问题,如果有大佬知道,请在评论区指点一二。
测试代码
@Test
public void test5(){
Dynamicarrays dynamicarrays = new Dynamicarrays();
dynamicarrays.addLast(1);
dynamicarrays.addLast(2);
dynamicarrays.addLast(3);
dynamicarrays.addLast(4);
dynamicarrays.addLast(5);
int removed = dynamicarrays.remove(2);
assertEquals(3,removed);
assertIterableEquals(List.of(1,2,4,5),dynamicarrays);
}
5.动态数组的扩容
在之前的代码中,都没有考虑超出容量的情况,下面在进行添加元素之前,对容量进行检查,确定是否需要扩容。
在此之前,我们可以对初始化代码进行一个小改动,把array数组先定义为一个空数组,如果需要往里面添加元素,再对数组进行扩容,代码如下
private void ckeckAndGrow() {
if (size == 0){
array = new int [capacity];
}
else if (size == capacity){
//扩容
capacity += capacity>>1;
int [] newArray = new int[capacity];
System.arraycopy(array,0,newArray,0,size);
array = newArray;
}
}
测试代码
@Test
public void test6() {
Dynamicarrays dynamicarrays = new Dynamicarrays();
for (int i = 0; i < 9; i++) {
dynamicarrays.addLast(i + 1);
}
dynamicarrays.foreach((element) -> {
System.out.println(element);
});
}