陣列
1) 概述
定義
在電腦科學中,陣列是由一組元素(值或變數)組成的資料結構,每個元素有至少一個索引或鍵來標識
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
因為陣列內的元素是連續儲存的,所以陣列中元素的地址,可以透過其索引計算出來,例如:
int[] array = {1,2,3,4,5}
知道了陣列的資料起始地址 \(BaseAddress\),就可以由公式 \(BaseAddress + i * size\) 計算出索引 \(i\) 元素的地址
- \(i\) 即索引,在 Java、C 等語言都是從 0 開始
- \(size\) 是每個元素佔用位元組,例如 \(int\) 佔 \(4\),\(double\) 佔 \(8\)
小測試
byte[] array = {1,2,3,4,5}
已知 array 的資料的起始地址是 0x7138f94c8,那麼元素 3 的地址是什麼?
答:0x7138f94c8 + 2 * 1 = 0x7138f94ca
空間佔用
Java 中陣列結構為
- 8 位元組 markword
- 4 位元組 class 指標(壓縮 class 指標的情況)
- 4 位元組 陣列大小(決定了陣列最大容量是 \(2^{32}\))
- 陣列元素 + 對齊位元組(java 中所有物件大小都是 8 位元組的整數倍[^12],不足的要用對齊位元組補足)
例如
int[] array = {1, 2, 3, 4, 5};
的大小為 40 個位元組,組成如下
8 + 4 + 4 + 5*4 + 4(alignment)
隨機訪問效能
即根據索引查詢元素,時間複雜度是 \(O(1)\)
2) 動態陣列
java 版本
public class DynamicArray implements Iterable<Integer> {
private int size = 0; // 邏輯大小
private int capacity = 8; // 容量
private int[] array = {};
/**
* 向最後位置 [size] 新增元素
*
* @param element 待新增元素
*/
public void addLast(int element) {
add(size, element);
}
/**
* 向 [0 .. size] 位置新增元素
*
* @param index 索引位置
* @param 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) {
// 進行擴容, 1.5 1.618 2
capacity += capacity >> 1;
int[] newArray = new int[capacity];
System.arraycopy(array, 0,
newArray, 0, size);
array = newArray;
}
}
/**
* 從 [0 .. size) 範圍刪除元素
*
* @param index 索引位置
* @return 被刪除元素
*/
public int remove(int index) { // [0..size)
int removed = array[index];
if (index < size - 1) {
// 向前挪動
System.arraycopy(array, index + 1,
array, index, size - index - 1);
}
size--;
return removed;
}
/**
* 查詢元素
*
* @param index 索引位置, 在 [0..size) 區間內
* @return 該索引位置的元素
*/
public int get(int index) {
return array[index];
}
/**
* 遍歷方法1
*
* @param consumer 遍歷要執行的操作, 入參: 每個元素
*/
public void foreach(Consumer<Integer> consumer) {
for (int i = 0; i < size; i++) {
// 提供 array[i]
// 返回 void
consumer.accept(array[i]);
}
}
/**
* 遍歷方法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++];
}
};
}
/**
* 遍歷方法3 - stream 遍歷
*
* @return stream 流
*/
public IntStream stream() {
return IntStream.of(Arrays.copyOfRange(array, 0, size));
}
}
- 這些方法實現,都簡化了 index 的有效性判斷,假設輸入的 index 都是合法的
插入或刪除效能
頭部位置,時間複雜度是 \(O(n)\)
中間位置,時間複雜度是 \(O(n)\)
尾部位置,時間複雜度是 \(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 個位元組
-
它們在內層佈局上是連續的
更一般的,對一個二維陣列 \(Array[m][n]\)
- \(m\) 是外層陣列的長度,可以看作 row 行
- \(n\) 是內層陣列的長度,可以看作 column 列
- 當訪問 \(Array[i][j]\),\(0\leq i \lt m, 0\leq j \lt n\)時,就相當於
- 先找到第 \(i\) 個內層陣列(行)
- 再找到此內層陣列中第 \(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,1] ... [0,13]\),如圖所示
但很遺憾,第二次內迴圈要的是 \([1,0]\) 這條資料,快取中沒有,於是再讀入了下圖的資料
這顯然是一種浪費,因為 \([0,1] ... [0,13]\) 包括 \([1,1] ... [1,13]\) 這些資料雖然讀入了快取,卻沒有及時用上,而快取的大小是有限的,等執行到第九次內迴圈時
快取的第一行資料已經被新的資料 \([8,0] ... [8,13]\) 覆蓋掉了,以後如果再想讀,比如 \([0,1]\),又得到記憶體去讀了
同理可以分析 ij 函式則能充分利用區域性性原理載入到的快取資料
舉一反三
-
I/O 讀寫時同樣可以體現區域性性原理
-
陣列可以充分利用區域性性原理,那麼連結串列呢?
答:連結串列不行,因為連結串列的元素並非相鄰儲存
5) 越界檢查
java 中對陣列元素的讀寫都有越界檢查,類似於下面的程式碼
bool is_within_bounds(int index) const
{
return 0 <= index && index < length();
}
- 程式碼位置:
openjdk\src\hotspot\share\oops\arrayOop.hpp
只不過此檢查程式碼,不需要由程式設計師自己來呼叫,JVM 會幫我們呼叫
習題
E01. 合併有序陣列 - 對應 Leetcode 88
將陣列內兩個區間內的有序元素合併
例
[1, 5, 6, 2, 4, 10, 11]
可以視作兩個有序區間
[1, 5, 6] 和 [2, 4, 10, 11]
合併後,結果仍儲存於原有空間
[1, 2, 4, 5, 6, 10, 11]
方法1
遞迴
- 每次遞迴把更小的元素複製到結果陣列
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]){
// 複製10,11
}
}
}
}
}
}
程式碼
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);
}
}
測試
int[] a1 = {1, 5, 6, 2, 4, 10, 11};
int[] a2 = new int[a1.length];
merge(a1, 0, 2, 3, 6, a2, 0);
方法2
程式碼
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);
}
}
測試
int[] a1 = {1, 5, 6, 2, 4, 10, 11};
int[] a2 = new int[a3.length];
merge(a1, 0, 2, 3, 6, a2);
本文,已收錄於,我的技術網站 pottercoding.cn,有大廠完整面經,工作技術,架構師成長之路,等經驗分享!