基礎資料結構之陣列

程序员波特發表於2024-09-23

陣列

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 函式則能充分利用區域性性原理載入到的快取資料

舉一反三

  1. I/O 讀寫時同樣可以體現區域性性原理

  2. 陣列可以充分利用區域性性原理,那麼連結串列呢?

    答:連結串列不行,因為連結串列的元素並非相鄰儲存

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,有大廠完整面經,工作技術,架構師成長之路,等經驗分享!

相關文章