版本说明
当前版本号[20231004]。
版本 | 修改说明 |
---|---|
20231004 | 初版 |
目录
文章目录
- 版本说明
- 目录
- 二. 基础数据结构
- 2.1 数组
- 1) 概述
- 2) 动态数组
- 1)插入
- addlast 方法
- 测试: addlast 方法
- add 方法
- 测试:add方法
- addlast 方法与 add 方法合并版
- get 方法
- 测试:get 方法
- 2) 遍历
- 遍历方法1:consumer 遍历
- 测试:consumer 遍历
- 遍历方法2:迭代器遍历
- 测试:迭代器遍历
- 遍历方法3:stream 遍历
- 测试:stream 遍历
- 插入与遍历方法汇总
- 插入与遍历测试类汇总
- 3)删除
- 测试:删除方法
- 4)扩容
- 5)插入或删除性能
- 3) 二维数组
- **测试**
- 4) 局部性原理
- **对效率的影响**
二. 基础数据结构
2.1 数组
1) 概述
定义
在计算机科学中,数组是由一组元素(值或变量)组成的数据结构,每个元素有至少一个索引或键来标识
因为数组内的元素是连续存储的,所以数组中元素的地址,可以通过其索引计算出来,例如:
int[] array = {1,2,3,4,5}
知道了数组的数据起始地址 B a s e A d d r e s s BaseAddress BaseAddress,就可以由公式 B a s e A d d r e s s + i ∗ s i z e BaseAddress + i * size BaseAddress+i∗size 计算出索引 i i i 元素的地址
- i i i 即索引,在 Java、C 等语言都是从 0 开始
- s i z e size size 是每个元素占用字节,例如 i n t int int 占 4 4 4, d o u b l e double double 占 8 8 8
小测试
byte[] array = {1,2,3,4,5}
已知 array 的数据的起始地址是 0x7138f94c8,那么元素 3 的地址是什么?
答:0x7138f94c8 + 2 * 1 = 0x7138f94ca
空间占用
Java 中数组结构为
- 8 字节 markword
- 4 字节 class 指针(压缩 class 指针的情况)
- 4 字节 数组大小(决定了数组最大容量是 2 32 2^{32} 232)
- 数组元素 + 对齐字节(java 中所有对象大小都是 8 字节的整数倍[^12],不足的要用对齐字节补足)
例如
int[] array = {1, 2, 3, 4, 5};
的大小为 40 个字节,组成如下
8 + 4 + 4 + 5*4 + 4(alignment)
随机访问性能
即根据索引查找元素,时间复杂度是 O ( 1 ) O(1) O(1)
2) 动态数组
1)插入
addlast 方法
意在把新增的元素加入到最后一个到动态数组里。
public class 动态数组
{
private int size = 0;//逻辑大小
private int capacity = 8;//所创建数组的数组容量
private int[] array = new int[capacity];
/**
* 向最后位置 [size] 添加元素
*
* @param element 待添加元素
*/
public void addlast(int element)
{
array[size] = element;
size++;
}
}
这个类具有以下特点:
size
变量表示当前数组中已存储的元素数量。capacity
变量表示数组的容量,即可以容纳的最大元素数量。array
是一个整型数组,用于存储元素。addlast(int element)
方法用于在数组的末尾添加一个元素,并将size
增加1。
测试: addlast 方法
这段代码作为一个JUnit测试类,在测试方法test1中,首先创建了一个动态数组对象,然后向数组中添加了5个元素,最后遍历数组并打印出每个元素的值。
package SuanFa.test;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import SuanFa.初始算法.第一章数组.动态数组;
import java.util.Arrays;
public class 动态数组Test {
@Test
public void test1()
{
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
dynamicarray.addlast(5);
for(int i = 0; i < 5; i++)
{
System.out.println(dynamicarray.get(i));
}
}
}
测试结果如下:
注:接下来的所有方法中将只保留最主要的代码。
add 方法
意在向某一个索引里加入新值。
/**
* 向 [0 .. size] 位置添加元素
*
* @param index 索引位置
* @param element 待添加元素
*/
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++;
}
}
这个类具有以下特点:
size
变量表示当前数组中已存储的元素数量。capacity
变量表示数组的容量,即可以容纳的最大元素数量。array
是一个整型数组,用于存储元素。add(int index, int element)
方法用于在指定索引位置插入到指定索引位置,并确保索引在合法范围内(大于等于0且小于当前数组大小)。- 如果索引合法,使用
System.arraycopy()
方法将原数组中从指定索引位置开始的元素向后移动一个位置,腾出空间来插入新元素。(复制 array 数组中从下标为 index 的元素复制到 array 数组的下标为 index+1 的元素上,并且只复制 size - index 的元素) - 将新元素赋值给指定索引位置。
- 增加
size
变量的值,表示数组中已存储的元素数量增加了1。 - 如果索引不合法,该方法不会执行任何操作。
测试:add方法
这段代码作为一个JUnit测试类,在测试方法test1中,首先创建了一个动态数组对象,然后向数组中添加了4个元素,接着使用add方法在索引为2的位置插入了一个值为9的元素。最后遍历数组并打印出每个元素的值。
@Test
public void test2()
{
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
//dynamicarray.addlast(5);
dynamicarray.add(2, 9);
for(int i = 0; i < 5; i++)
{
System.out.println(dynamicarray.get(i));
}
}
}
测试结果如下:
addlast 方法与 add 方法合并版
由于addlast与add方法中,都有 array[下标] = element; size++;
这两段代码,于是也可以对他们进行优化,放在一起
/**
* 向 [0 .. size] 位置添加元素
*
* @param index 索引位置
* @param element 待添加元素
*/
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++;
}
/**
* 向最后位置 [size] 添加元素
*
* @param element 待添加元素
*/
public void addlast(int element)
{
add(size, element);
}
这个类具有以下特点:
size
变量表示当前数组中已存储的元素数量。capacity
变量表示数组的容量,即可以容纳的最大元素数量。array
是一个整型数组,用于存储元素。add(int index, int element)
方法用于在指定索引位置插入一个元素。addlast(int element)
方法用于在数组的末尾添加一个元素。add(int index, int element)
方法将传入的元素插入到指定索引位置,并确保索引在合法范围内(大于等于0且小于当前数组大小)。- 如果索引合法,使用
System.arraycopy()
方法将原数组中从指定索引位置开始的元素向后移动一个位置,腾出空间来插入新元素。 - 将新元素赋值给指定索引位置。
- 增加
size
变量的值,表示数组中已存储的元素数量增加了1。 - 如果索引不合法,该方法不会执行任何操作。
addlast(int element)
方法调用add(int index, int element)
方法,将元素添加到数组的末尾。- 数组会自动调整大小以适应新元素的添加。
get 方法
意在接收一个整数参数 index ,返回数组中下标为 index 的元素。
/**
* 查询元素
*
* @param index 索引位置, 在 [0..size) 区间内
* @return 该索引位置的元素
*/
public int get(int index)
{
return array[index];
}
这个类具有以下特点:
size
变量表示当前数组中已存储的元素数量。capacity
变量表示数组的容量,即可以容纳的最大元素数量。array
是一个整型数组,用于存储元素。- 查询方法
get
接收一个整数参数 index ,返回数组中下标为 index 的元素。
测试:get 方法
与测试:addlast 方法相同
@Test
public void test1()
{
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
dynamicarray.addlast(5);
for(int i = 0; i < 5; i++)
{
System.out.println(dynamicarray.get(i));
}
}
2) 遍历
遍历方法1:consumer 遍历
这段代码定义了一个名为 foreach 的方法,该方法接收一个 Consumer 类型的参数consumer。在方法内部,使用for循环遍历数组array,**对于每个元素,调用consumer的accept方法执行传入的操作。**最后,forEach方法没有返回值(void)。
/**
* 遍历方法1
*
* @param consumer 遍历要执行的操作, 入参: 每个元素
*/
public void foreach(Consumer<Integer> consumer)
{
for(int i = 0; i<size ; i++)
// 提供 array[i]
// 返回 void
{
consumer.accept(array[i]);
}
}
测试:consumer 遍历
在该测试方法中,首先创建了一个动态数组对象dynamicarray,并向其中添加了5个元素。然后调用forEach方法遍历数组,对于每个元素,使用Lambda表达式打印出该元素的值。
public class 动态数组Test {
@Test
public void test3()
{
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
dynamicarray.forEach((element->
{
System.out.println(element);
}));
}
}
测试结果如下:
遍历方法2:迭代器遍历
该动态数组类实现了Iterable接口,并重写了iterator方法。
在类中重写了Iterable接口的iterator方法,返回一个Iterator对象,该对象实现了Iterator接口的方法hasNext和next。其中,hasNext方法用于判断是否还有下一个元素,next方法用于**返回当前元素并将指针移动到下一个元素。**由于没有给出数组元素的添加和删除操作,因此该类的迭代器只能从头到尾遍历一遍数组。
public class 动态数组 implements Iterable<Integer>
{
private int size = 0;//逻辑大小
private int capacity = 8;//容量
private int[] array = new int[capacity];
/**
* 遍历方法2 - 迭代器遍历
*/
@Override
public Iterator<Integer> iterator(){
return new Iterator<Integer>() {
int i = 0;
@Override
public boolean hasNext() { // 有没有下一个元素
return i < size;
}
@Override
public Integer next() { // 返回当前元素,并移动到下一个元素
return array[i++];
}
};
}
测试:迭代器遍历
使用增强for循环遍历数组,对于每个元素,打印出该元素的值。
@Test
public void test4()
{
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
for(Integer element: dynamicarray)
{
System.out.println(element);
}
}
测试结果如下:
遍历方法3:stream 遍历
这段代码定义了一个名为stream的公共方法,该方法返回一个IntStream类型的对象。在方法内部,使用IntStream.of()方法创建一个包含数组array中前size个元素的IntStream流,并返回该流。
/**
* 遍历方法3 - stream 遍历
*
* @return stream 流
*/
public IntStream stream(){
return IntStream.of(Arrays.copyOfRange(array,0,size));
}
测试:stream 遍历
@Test
public void test5()
{
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(7);
dynamicarray.addlast(2);
dynamicarray.addlast(4);
dynamicarray.addlast(1);
dynamicarray.stream().forEach(element->{
System.out.println(element);
});
}
测试结果如下:
插入与遍历方法汇总
以下这段代码定义了一个名为动态数组的类,汇总了所有方法,实现了Iterable接口。该类方法分别有:
add(int index, int element)
:向指定索引位置添加元素的方法。如果索引有效(即大于等于0且小于当前大小),则将该位置及之后的元素向后移动一位,然后将新元素放置在指定索引位置上,并将大小加1。addlast(int element)
:向数组末尾添加元素的方法。调用add(size, element)
实现。get(int index)
:获取指定索引位置的元素的方法。foreach(Consumer<Integer> consumer)
:遍历数组并对每个元素执行给定的消费者操作。stream()
:返回一个IntStream流,其中包含数组中的所有元素。使用Arrays.copyOfRange(array, 0, size)
创建一个包含数组元素的新数组,并将其转换为IntStream流。
package SuanFa.初始算法.第一章数组;
import java.util.Arrays;
import java.util.Iterator;
import java.util.function.Consumer;
import java.util.stream.IntStream;
public class 动态数组 implements Iterable<Integer> {
private int size = 0;//逻辑大小
private int capacity = 8;//容量
private int[] array = new int[capacity];
@Override
public Iterator<Integer> iterator() {
return new Iterator<Integer>() {
int i = 0;
@Override
public boolean hasNext() { // 有没有下一个元素
return i < size;
}
@Override
public Integer next() { // 返回当前元素,并移动到下一个元素
return array[i++];
}
};
}
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++;
}
public void addlast(int element) {
add(size, element);
}
public int get(int index) {
return array[index];
}
public void foreach(Consumer<Integer> consumer) {
for (int i = 0; i < size; i++) {
consumer.accept(array[i]);
}
}
public IntStream stream() {
return IntStream.of(Arrays.copyOfRange(array, 0, size));
}
}
插入与遍历测试类汇总
该类包含了5个测试方法:
test1()
:测试了向动态数组末尾添加元素的功能。首先创建了一个空的动态数组对象dynamicarray
,然后连续调用addlast()
方法向数组末尾添加了5个元素(1到5)。最后使用循环遍历数组并打印每个元素的值。test2()
:测试了在指定位置插入元素的功能。首先创建了一个空的动态数组对象dynamicarray
,然后连续调用addlast()
方法向数组末尾添加了5个元素(1到5)。接着调用add()
方法在索引为2的位置插入了一个新元素9。最后使用循环遍历数组并打印每个元素的值。test3()
:测试了使用IntStream流遍历动态数组的功能。首先创建了一个空的动态数组对象dynamicarray
,然后连续调用addlast()
方法向数组末尾添加了5个元素(1到5)。接着调用stream()
方法获取一个包含数组元素的IntStream流,并使用forEach()
方法对每个元素执行打印操作。test4()
:测试了使用for-each循环遍历动态数组的功能。首先创建了一个空的动态数组对象dynamicarray
,然后连续调用addlast()
方法向数组末尾添加了5个元素(1到5)。接着使用for-each循环遍历数组,并在每次迭代中打印当前元素的值。test5()
:测试了在动态数组中间插入元素的功能。首先创建了一个空的动态数组对象dynamicarray
,然后连续调用addlast()
方法向数组末尾添加了4个元素(7、2、4、1)。最后使用流
package SuanFa.test;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import SuanFa.初始算法.第一章数组.动态数组;
import java.lang.annotation.ElementType;
import java.util.Arrays;
public class 动态数组Test {
@Test
public void test1() {
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
dynamicarray.addlast(5);
for (int i = 0; i < 5; i++) {
System.out.println(dynamicarray.get(i));
}
}
@Test
public void test2() {
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
dynamicarray.add(2, 9);
for (int i = 0; i < 5; i++) {
System.out.println(dynamicarray.get(i));
}
}
@Test
public void test3() {
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
dynamicarray.stream().forEach(element -> {
System.out.println(element);
});
}
@Test
public void test4() {
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
for (Integer element : dynamicarray) {
System.out.println(element);
}
}
@Test
public void test5() {
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(7);
dynamicarray.addlast(2);
dynamicarray.addlast(4);
dynamicarray.addlast(1);
dynamicarray.stream().forEach(element -> {
System.out.println(element);
});
}
}
3)删除
该方法接受一个整数参数 index
,表示要删除元素的索引位置。
首先,它将要删除的元素保存在变量 removed
中。然后,使用 System.arraycopy()
方法将索引位置后的元素向前移动一位,覆盖掉要删除的元素。
接下来,通过将数组的大小减一来更新数组的大小。
最后,返回被删除的元素。
/**
* 从 [0 .. size) 范围删除元素
*
* @param index 索引位置
* @return 被删除元素
*/
public int remove(int index){
// 保存要删除的元素
int removed = array[index];
// 将索引位置后的元素向前移动一位
System.arraycopy(array, index + 1, array, index, size - index - 1);
// 更新数组的大小
size--;
// 返回被删除的元素
return removed;
}
测试:删除方法
这是一个J用于测试动态数组的测试类。首先创建一个dynamicarray的动态数组对象,然后使用addlast方法向数组中添加元素1、2、3、4和5。接着**调用remove方法移除索引为2的元素,并将其值赋给变量removed。**最后,使用stream方法和forEach遍历打印数组中的所有元素。
@Test
public void test6() {
动态数组 dynamicarray = new 动态数组();
dynamicarray.addlast(1);
dynamicarray.addlast(2);
dynamicarray.addlast(3);
dynamicarray.addlast(4);
dynamicarray.addlast(5);
int removed = dynamicarray.remove(2);
System.out.println(removed);
System.out.println("-----");
dynamicarray.stream().forEach(element -> {
System.out.println(element);
});
}
测试结果如下:
4)扩容
- 该方法首先进行容量检查。
- 如果当前数组的大小为0,即数组为空,那么将创建一个新的数组,其容量为
capacity
。 - 如果当前数组的大小等于容量,即数组已满,那么将通过将容量增加至原来的两倍来扩展数组。
- 然后,创建一个新的数组
newArray
,其容量为新的容量值。 - 接下来,使用
System.arraycopy()
方法将原数组中的元素复制到新数组中。 - 最后,将新数组赋值给原数组,完成数组的扩展操作。
- 这个方法通常用于动态数组的管理,以确保数组在插入元素时能够自动调整其容量大小,以适应不断增长的数据量。
private void checkAndGrow() {
// 容量检查
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;
}
}
5)插入或删除性能
头部位置,时间复杂度是 O ( n ) O(n) O(n)
中间位置,时间复杂度是 O ( n ) O(n) O(n)
尾部位置,时间复杂度是 O ( 1 ) O(1) O(1)(均摊来说)
3) 二维数组
int[][] array = {
{11, 12, 13, 14, 15},
{21, 22, 23, 24, 25},
{31, 32, 33, 34, 35},
};
-
二维数组占 32 个字节,其中 array[0],array[1],array[2] 三个元素分别保存了指向三个一维数组的引用
-
三个一维数组各占 40 个字节
-
它们在内层布局上是连续的
更一般的,对一个二维数组 A r r a y [ m ] [ n ] Array[m][n] Array[m][n]
- m m m 是外层数组的长度,可以看作 row 行
- n n n 是内层数组的长度,可以看作 column 列
- 当访问
A
r
r
a
y
[
i
]
[
j
]
Array[i][j]
Array[i][j],
0
≤
i
<
m
,
0
≤
j
<
n
0\leq i \lt m, 0\leq j \lt n
0≤i<m,0≤j<n时,就相当于
- 先找到第 i i i 个内层数组(行)
- 再找到此内层数组中第 j j j 个元素(列)
测试
Java 环境下(不考虑类指针和引用压缩,此为默认情况),有下面的二维数组
byte[][] array = {
{11, 12, 13, 14, 15},
{21, 22, 23, 24, 25},
{31, 32, 33, 34, 35},
};
已知 array 对象起始地址是 0x1000,那么 23 这个元素的地址是什么?
答:
- 起始地址 0x1000
- 外层数组大小:16字节对象头 + 3元素 * 每个引用4字节 + 4 对齐字节 = 32 = 0x20
- 第一个内层数组大小:16字节对象头 + 5元素 * 每个byte1字节 + 3 对齐字节 = 24 = 0x18
- 第二个内层数组,16字节对象头 = 0x10,待查找元素索引为 2
- 最后结果 = 0x1000 + 0x20 + 0x18 + 0x10 + 2*1 = 0x104a
4) 局部性原理
这里只讨论空间局部性
- cpu 读取内存(速度慢)数据后,会将其放入高速缓存(速度快)当中,如果后来的计算再用到此数据,在缓存中能读到的话,就不必读内存了
- 缓存的最小存储单位是缓存行(cache line),一般是 64 bytes,一次读的数据少了不划算啊,因此最少读 64 bytes 填满一个缓存行,因此读入某个数据时也会读取其临近的数据,这就是所谓空间局部性
对效率的影响
比较下面 ij 和 ji 两个方法的执行效率
int rows = 1000000;
int columns = 14;
int[][] a = new int[rows][columns];
StopWatch sw = new StopWatch();
sw.start("ij");
ij(a, rows, columns);
sw.stop();
sw.start("ji");
ji(a, rows, columns);
sw.stop();
System.out.println(sw.prettyPrint());
ij 方法
public static void ij(int[][] a, int rows, int columns) {
long sum = 0L;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
sum += a[i][j];
}
}
System.out.println(sum);
}
ji 方法
public static void ji(int[][] a, int rows, int columns) {
long sum = 0L;
for (int j = 0; j < columns; j++) {
for (int i = 0; i < rows; i++) {
sum += a[i][j];
}
}
System.out.println(sum);
}
执行结果
0
0
StopWatch '': running time = 96283300 ns
---------------------------------------------
ns % Task name
---------------------------------------------
016196200 017% ij
080087100 083% ji
可以看到 ij 的效率比 ji 快很多,为什么呢?
- 缓存是有限的,当新数据来了后,一些旧的缓存行数据就会被覆盖
- 如果不能充分利用缓存的数据,就会造成效率低下
以 ji 执行为例,第一次内循环要读入 [ 0 , 0 ] [0,0] [0,0] 这条数据,由于局部性原理,读入 [ 0 , 0 ] [0,0] [0,0] 的同时也读入了 [ 0 , 1 ] . . . [ 0 , 13 ] [0,1] ... [0,13] [0,1]...[0,13],如图所示
但很遗憾,第二次内循环要的是 [ 1 , 0 ] [1,0] [1,0] 这条数据,缓存中没有,于是再读入了下图的数据
这显然是一种浪费,因为 [ 0 , 1 ] . . . [ 0 , 13 ] [0,1] ... [0,13] [0,1]...[0,13] 包括 [ 1 , 1 ] . . . [ 1 , 13 ] [1,1] ... [1,13] [1,1]...[1,13] 这些数据虽然读入了缓存,却没有及时用上,而缓存的大小是有限的,等执行到第九次内循环时
缓存的第一行数据已经被新的数据 [ 8 , 0 ] . . . [ 8 , 13 ] [8,0] ... [8,13] [8,0]...[8,13] 覆盖掉了,以后如果再想读,比如 [ 0 , 1 ] [0,1] [0,1],又得到内存去读了