数组
概述
定义
在计算机科学中,数组是由一组元素(值或变量)组成的数据结构,每个元素有至少一个索引或键来标识
In computer science, an array is a data structure consisting of a collection of elements (values or variables), each identified by at least one array index or key
因为数组内的元素是连续存储 的,所以数组中元素的地址,可以通过其索引计算出来,例如:
1 int [] array = {1 ,2 ,3 ,4 ,5 }
知道了数组的数据 起始地址 B a s e A d d r e s s BaseAddress B a se A dd ress ,就可以由公式 B a s e A d d r e s s + i ∗ s i z e BaseAddress + i * size B a se A dd ress + i ∗ s i ze 计算出索引 i i i 元素的地址
i i i 即索引,在 Java、C 等语言都是从 0 开始
s i z e size s i ze 是每个元素占用字节,例如 i n t int in t 占 4 4 4 ,d o u b l e double d o u b l e 占 8 8 8
小测试
1 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} 2 32 )
数组元素 + 对齐字节(java 中所有对象大小都是 8 字节的整数倍[^12],不足的要用对齐字节补足)
例如
1 int [] array = {1 , 2 , 3 , 4 , 5 };
的大小为 40 个字节,组成如下
1 8 + 4 + 4 + 5*4 + 4(alignment)
随机访问性能
即根据索引查找元素,时间复杂度是 O ( 1 ) O(1) O ( 1 )
动态数组
java 版本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 public class DynamicArray implements Iterable <Integer> { private int size = 0 ; private int capacity = 8 ; private int [] array = {}; public void addLast (int element) { add(size, element); } public void add (int index, int element) { checkAndGrow(); if (index >= 0 && index < size) { System.arraycopy(array, index, array, index + 1 , size - index); } array[index] = element; size++; } 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; } } public int remove (int index) { int removed = array[index]; if (index < size - 1 ) { System.arraycopy(array, index + 1 , array, index, size - index - 1 ); } size--; return removed; } 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]); } } @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 IntStream stream () { return IntStream.of(Arrays.copyOfRange(array, 0 , size)); } }
这些方法实现,都简化了 index 的有效性判断,假设输入的 index 都是合法的
插入或删除性能
头部位置,时间复杂度是 O ( n ) O(n) O ( n )
中间位置,时间复杂度是 O ( n ) O(n) O ( n )
尾部位置,时间复杂度是 O ( 1 ) O(1) O ( 1 ) (均摊来说)
二维数组
1 2 3 4 5 int [][] array = { {11 , 12 , 13 , 14 , 15 }, {21 , 22 , 23 , 24 , 25 }, {31 , 32 , 33 , 34 , 35 }, };
内存图如下
更一般的,对一个二维数组 A r r a y [ m ] [ n ] Array[m][n] A rr a y [ m ] [ n ]
m m m 是外层数组的长度,可以看作 row 行
n n n 是内层数组的长度,可以看作 column 列
当访问 A r r a y [ i ] [ j ] Array[i][j] A rr a y [ 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 环境下(不考虑类指针和引用压缩,此为默认情况),有下面的二维数组
1 2 3 4 5 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
局部性原理
这里只讨论空间局部性
cpu 读取内存(速度慢)数据后,会将其放入高速缓存(速度快)当中,如果后来的计算再用到此数据,在缓存中能读到的话,就不必读内存了
缓存的最小存储单位是缓存行(cache line),一般是 64 bytes,一次读的数据少了不划算啊,因此最少读 64 bytes 填满一个缓存行,因此读入某个数据时也会读取其临近的数据 ,这就是所谓空间局部性
对效率的影响
比较下面 ij 和 ji 两个方法的执行效率
1 2 3 4 5 6 7 8 9 10 11 12 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 方法
1 2 3 4 5 6 7 8 9 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 方法
1 2 3 4 5 6 7 8 9 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); }
执行结果
1 2 3 4 5 6 7 8 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 ] ,又得到内存去读了
同理可以分析 ij 函数则能充分利用局部性原理加载到的缓存数据
举一反三
I/O 读写时同样可以体现局部性原理
数组可以充分利用局部性原理,那么链表呢?
答:链表不行,因为链表的元素并非相邻存储
越界检查
java 中对数组元素的读写都有越界检查,类似于下面的代码
1 2 3 4 bool is_within_bounds (int index) const { return 0 <= index && index < length (); }
代码位置:openjdk\src\hotspot\share\oops\arrayOop.hpp
只不过此检查代码,不需要由程序员自己来调用,JVM 会帮我们调用
习题
E01. 合并有序数组 - 对应 Leetcode 88
将数组内两个区间内的有序元素合并
例
可以视作两个有序区间
1 [1, 5, 6] 和 [2, 4, 10, 11]
合并后,结果仍存储于原有空间
方法1
递归
1 2 3 4 5 6 7 8 9 10 11 12 13 merge(left=[1 ,5 ,6 ],right=[2 ,4 ,10 ,11 ],a2=[]){ merge(left=[5 ,6 ],right=[2 ,4 ,10 ,11 ],a2=[1 ]){ merge(left=[5 ,6 ],right=[4 ,10 ,11 ],a2=[1 ,2 ]){ merge(left=[5 ,6 ],right=[10 ,11 ],a2=[1 ,2 ,4 ]){ merge(left=[6 ],right=[10 ,11 ],a2=[1 ,2 ,4 ,5 ]){ merge(left=[],right=[10 ,11 ],a2=[1 ,2 ,4 ,5 ,6 ]){ } } } } } }
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public static void merge (int [] a1, int i, int iEnd, int j, int jEnd, int [] a2, int k) { if (i > iEnd) { System.arraycopy(a1, j, a2, k, jEnd - j + 1 ); return ; } if (j > jEnd) { System.arraycopy(a1, i, a2, k, iEnd - i + 1 ); return ; } if (a1[i] < a1[j]) { a2[k] = a1[i]; merge(a1, i + 1 , iEnd, j, jEnd, a2, k + 1 ); } else { a2[k] = a1[j]; merge(a1, i, iEnd, j + 1 , jEnd, a2, k + 1 ); } }
测试
1 2 3 int [] a1 = {1 , 5 , 6 , 2 , 4 , 10 , 11 };int [] a2 = new int [a1.length];merge(a1, 0 , 2 , 3 , 6 , a2, 0 );
方法2
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public static void merge (int [] a1, int i, int iEnd, int j, int jEnd, int [] a2) { int k = i; while (i <= iEnd && j <= jEnd) { if (a1[i] < a1[j]) { a2[k] = a1[i]; i++; } else { a2[k] = a1[j]; j++; } k++; } if (i > iEnd) { System.arraycopy(a1, j, a2, k, jEnd - j + 1 ); } if (j > jEnd) { System.arraycopy(a1, i, a2, k, iEnd - i + 1 ); } }
测试
1 2 3 int [] a1 = {1 , 5 , 6 , 2 , 4 , 10 , 11 };int [] a2 = new int [a3.length];merge(a1, 0 , 2 , 3 , 6 , a2);
再看二分查找
基础数据结构-链表