1. 程式人生 > >純資料結構Java實現(1/11)(動態陣列)

純資料結構Java實現(1/11)(動態陣列)

我怕說這部分內容太簡單後,突然蹦出來一個大佬把我虐到哭,還是悠著點,踏實寫

大致內容有: 增刪改查,泛型支援,擴容支援,複雜度分析。(鋪墊: Java語言中的陣列)


基礎鋪墊

其實沒啥好介紹的,順序儲存,(非受限的)線性結構。查詢O(1),因為支援隨機存取。

Java中的陣列

Java 中的陣列操作,大致如下:

int[] arr = new int[10]

String[] strArr = new String[9]

// 定義的時候就有初始值 --- 自動感知陣列長度
int[] scores = new int[]{100, 99, 88}

//獲取陣列的長度
arr.length

//for each 迴圈 (陣列是可迭代物件)
for(int s in arr){
    System.out.println(s)
}

但不同於 Python 這類動態指令碼語言,Java中的陣列只能儲存相同型別的元素。

  • Java比較奇葩,陣列長度 一會兒是 length, 一會兒是 length(),一會兒是 size()
  • 可以大致按照陣列-屬性,字串-方法,其他容器-size() 來記憶

自己實現

先別管動態擴容的部分,現在就看陣列這個容器,如何實現增刪改查

大體思路

其實就是內部封裝一個 int[] 陣列,為了方便,順便需要一個長度屬性。

package array;

public class MyArray {
    //先宣告一下相應的變數, 不暴露給外部 (內部維護保持兩者一致)
    private int[] data; //capacity 就是 data 的 length
    private int size;

    // 建構函式,傳入陣列容量
    public MyArray(int capacity) {
        data = new int[capacity];
        size = 0; //初始情況下,實際有 0 個元素
    }

    //提供一個預設的吧, 不需要使用者手動提供 capacity, 無參
    public MyArray() {
        this(10);
    }


    //獲取陣列元素個數
    public int getSize() {
        return size;
    }

    //返回動態陣列的容量
    public int getCapacity() {
        return data.length;
    }

    //陣列此刻是否為空
    public boolean isEmpty() {
        return size == 0;
    }

}
  • capacity 容量,陣列實際裝了多少,用 size 表示
  • capacity 可以用內部陣列的長度表示,及 data.length

上面就是整體的框架。(還沒有涉及動態擴容)

增刪改查

因為趕時間,所以增刪改查一起來。

新增元素

此時 size 應該指向的是,要新增元素的下一個位置。即尾部新增時 size++

簡單想: 一開始沒有元素,size 在 0 位置,即第一個元素的位置。

尾部新增:

public void append(int elem) {
    //TODO: 考慮一下是否超過容量
    if (size == data.length) {
      throw new IllegalArgumentException("append failed");
    }
    this.data[size++] = elem;
}

//如果實現了 insert(key, elem) 方法,可以直接寫成
//末尾新增
public void append(int elem) {
     insert(size, elem);
}

指定位置新增:

//指定位置插入
public void insert(int index, int elem) {
  //TODO: 考慮一下是否超過容量
  if (size == data.length) {
    throw new IllegalArgumentException("append failed");
  }
  //檢查 index 是合法的
  if(index < 0 || index > size){
    throw new IllegalArgumentException("insert failed, require: index >=0 and index <= size ");
  }

  //先移動元素,從後面開始 (size-1 移動到 size --> index 移動到 index+1;騰出 index)
  for(int i = size-1; i>=index; i--){
    data[i+1] = data[i];
  }
  data[index] = elem; //覆蓋原來的 index 處的內容
  size++;
}

注意一下,要維護 size 。

頭部新增:

此時就可以複用 insert 方法了:

//頭部新增
public void presert(int elem){
  insert(0, elem);
}

遍歷元素

其實就是遍歷內部封裝的陣列,找到相應的元素。

獲取整體: (覆寫 toString 這個方法)

    //獲取陣列整體,即列印時需要顯示的資訊
    @Override
    public String toString() {
        StringBuilder res = new StringBuilder();
        res.append(String.format("MyArray: size=%d, capacity=%d\n", size, data.length));

        res.append("[");
        //只遍歷現有元素,而不是容量
        for (int i = 0; i < size; i++) {
            res.append(data[i]);
            if(i != size-1){
                res.append(", ");
            }
        }
        res.append("]");
        return res.toString();
    }

測試看看:

import array.MyArray;

public class Main {

    public static void main(String[] args) {
        MyArray arr = new MyArray(20); //容量20
        //放入 10 個元素
        for(int i=0; i< 10; i++){
            arr.append(i);
        }
        System.out.println(arr);
      
        arr.insert(1, 100);
        System.out.println(arr);      
    }
}

//輸入結果如下:
MyArray: size=10, capacity=20
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
MyArray: size=11, capacity=20
[0, 100, 1, 2, 3, 4, 5, 6, 7, 8, 9]

取出具體元素 (getter)

按照索引取。

//獲取某個具體元素: 通過封裝,加入自定義 index 檢查(保證獲取資料安全)
public int get(int index) {
  if (index < 0 || index >= size) {
    throw new IllegalArgumentException("Get failed, Index is illegal");
  }
  return data[index];
}

修改元素 (setter)

按照索引修改。

//更新元素
public void set(int index, int elem) {
  if (index < 0 || index >= size) {
    throw new IllegalArgumentException("Get failed, Index is illegal");
  }
  data[index] = elem;
}

是否包含

線性遍歷一遍,看看是否存在

//是否包含
public boolean contains(int elem) {
  for (int i = 0; i < size; i++) {
    if (data[i] == elem) {
      return true;
    }
  }
  return false;
}

搜尋元素

還是線性搜尋一下,找到就返回下標

public int find(int elem){
  for(int i=0; i<size; i++){
    if(data[i] == elem){
      return i;
    }
  }
  return -1; //沒有找到返回 -1
}

刪除元素

刪除就是覆蓋,大致分為兩類: 按照索引刪除,按照元素刪除。

從 index 開始到 size-1,不斷往前賦值,最後維護一下整體的 size-—

(size的元素使用者是拿不到的,所以data[size]值不必擔心)

//刪除元素 (通常要返回相應的元素)
public int remove(int index) {
  if (index < 0 || index >= size) {
    throw new IllegalArgumentException("Remove failed, Index is illegal");
  }
  //先把要返回的值儲存起來
  int ret = data[index];
  //覆蓋刪除: 把 index+1 的值移動到 index --> 把 size-1 的值移動到 size-2
  for (int i = index + 1; i < size; i++) {
    data[i - 1] = data[i];
  }
  size--;
  //data[size] 不必清空,因為使用者取不到 data[size]

  return ret;
}

當然也可以補充一些快捷方法:

//快捷刪除尾部元素
public int pop(){
  return remove(size-1);
}
//快捷刪除頭部元素
public int popLeft(){
  return remove(0);
}

按照元素刪除: 先查詢,然後刪除

//刪除指定元素 (不必返回,因為使用者已經知道 elem)
public void removeElem(int elem){
  int index = find(elem);
  if(-1 == index) {
    throw new IllegalArgumentException("Remove failed, cannot find this elem");
  }
  //成功了什麼都不提示,出錯才提示
  remove(index);
}
  • 預設都是查詢,刪除第一個找到的元素

簡單測試一下,刪除:

//[0, 100, 1, 2, 3, 4, 5, 6, 7, 8, 9]
//刪除試試
arr.remove(1);
System.out.println(arr); //[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

//刪除末尾試試
arr.pop();
System.out.println(arr); //[0, 1, 2, 3, 4, 5, 6, 7, 8]

//刪除頭部試試
arr.popLeft();
System.out.println(arr); //[1, 2, 3, 4, 5, 6, 7, 8]

//刪除 4 這個數字
arr.removeElem(4);
System.out.println(arr); //[1, 2, 3, 5, 6, 7, 8]

泛型支援

語法限制

由於泛型的限制(不能放入基本型別,只能放入其包裝類),內部封裝的陣列初始化的時候需要強制型別轉換一下。

也就是說,可以用於宣告,但不能用於定義。

data = new E[capacity]; //報錯,歷史遺留問題,不支援該語法

//正確的寫法 (藉助強制型別轉換)
data = (E[])new Object[capacity];

完整程式碼

特別注意一下:

  • 物件值的比較,需要改成 equals
  • 刪除的時候,因為儲存的是物件的引用,所以應該把移動後的 data[size]位置設定為 NULL
    • 但是這一條是非必須,因為 閒逛物件 != memory leak

還是貼一下,支援泛型的完整程式碼吧:

package array;

public class AdvanceArray<E> {

    //先宣告一下相應的變數, 不暴露給外部 (內部維護保持兩者一致)
    private E[] data; //capacity 就是 data 的 length
    private int size;

    // 建構函式,傳入陣列容量
    public AdvanceArray(int capacity) {
        data = (E[])new Object[capacity];
        size = 0; //初始情況下,實際有 0 個元素
    }

    //提供一個預設的吧, 不需要使用者手動提供 capacity, 無參
    public AdvanceArray() {
        this(10);
    }


    //獲取陣列元素個數
    public int getSize() {
        return size;
    }

    //返回動態陣列的容量
    public int getCapacity() {
        return data.length;
    }

    //陣列此刻是否為空
    public boolean isEmpty() {
        return size == 0;
    }

    //末尾新增
    public void append(E elem) {
        insert(size, elem);
    }

    //頭部新增
    public void presert(E elem) {
        insert(0, elem);
    }

    //指定位置插入
    public void insert(int index, E elem) {
        //TODO: 考慮一下是否超過容量
        if (size == data.length) {
            throw new IllegalArgumentException("append failed");
        }
        //檢查 index 是合法的
        if (index < 0 || index > size) {
            throw new IllegalArgumentException("insert failed, require: index >=0 and index <= size ");
        }

        //先移動元素,從後面開始 (size-1 移動到 size --> index 移動到 index+1;騰出 index)
        for (int i = size - 1; i >= index; i--) {
            data[i + 1] = data[i];
        }
        data[index] = elem; //覆蓋原來的 index 處的內容
        size++;
    }

    //獲取陣列整體,即列印時需要顯示的資訊
    @Override
    public String toString() {
        StringBuilder res = new StringBuilder();
        res.append(String.format("MyArray: size=%d, capacity=%d\n", size, data.length));

        res.append("[");
        //只遍歷現有元素,而不是容量
        for (int i = 0; i < size; i++) {
            res.append(data[i]);
            if (i != size - 1) {
                res.append(", ");
            }
        }
        res.append("]");
        return res.toString();
    }

    //獲取某個具體元素: 通過封裝,加入自定義 index 檢查(保證獲取資料安全)
    public E get(int index) {
        if (index < 0 || index >= size) {
            throw new IllegalArgumentException("Get failed, Index is illegal");
        }
        return data[index];
    }

    //更新元素
    public void set(int index, E elem) {
        if (index < 0 || index >= size) {
            throw new IllegalArgumentException("Get failed, Index is illegal");
        }
        data[index] = elem;
    }

    //是否包含
    public boolean contains(E elem) {
        for (int i = 0; i < size; i++) {
            if (data[i].equals(elem)) {
                return true;
            }
        }
        return false;
    }

    //搜尋元素
    public int find(E elem) {
        for (int i = 0; i < size; i++) {
            if (data[i].equals(elem)) {
                return i;
            }
        }
        return -1; //沒有找到返回 -1
    }

    //刪除元素 (通常要返回相應的元素)
    public E remove(int index) {
        if (index < 0 || index >= size) {
            throw new IllegalArgumentException("Remove failed, Index is illegal");
        }
        //先把要返回的值儲存起來
        E ret = data[index];
        //覆蓋刪除: 把 index+1 的值移動到 index --> 把 size-1 的值移動到 size-2
        for (int i = index + 1; i < size; i++) {
            data[i - 1] = data[i];
        }

        size--;
        //data[size] 不必清空,因為使用者取不到 data[size]
        //當使用泛型,對物件元素支援時
        data[size] = null; //方便 java 回收 loitering objects (非必須,閒逛物件 != memory leak)
        return ret;
    }

    //快捷刪除尾部元素
    public E pop() {
        return remove(size - 1);
    }

    //快捷刪除頭部元素
    public E popLeft() {
        return remove(0);
    }

    //刪除指定元素 (不必返回,因為使用者已經知道 elem)
    public void removeElem(E elem) {
        int index = find(elem);
        if (-1 == index) {
            throw new IllegalArgumentException("Remove failed, cannot find this elem");
        }
        //成功了什麼都不提示,出錯才提示
        remove(index);
    }
}

測試程式碼如下:

//這個類主要用於 AdvanceArray 做泛型測試
public class Student {
    private String name;
    private int score;

    public Student(String studentName, int studentScore){
        name = studentName;
        score = studentScore;
    }

    @Override
    public String toString() {
        return String.format("Student(name: %s, score: %d)", name, score);
    }
}


// main.java 中:
public class Main {
    private static void test_2(){
        //泛型不支援基本型別,所以這裡寫 Integer;使用時會自動box,unbox
        AdvanceArray<Integer> arr = new AdvanceArray<>(20); //容量20
        //放入 10 個元素
        for (int i = 0; i < 10; i++) {
            arr.append(i);
        }
        System.out.println(arr);

        //插入試試
        arr.insert(1, 100);
        System.out.println(arr);

        //[0, 100, 1, 2, 3, 4, 5, 6, 7, 8, 9]
        //刪除試試
        arr.remove(1);
        System.out.println(arr); //[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

        //刪除末尾試試
        arr.pop();
        System.out.println(arr); //[0, 1, 2, 3, 4, 5, 6, 7, 8]

        //刪除頭部試試
        arr.popLeft();
        System.out.println(arr); //[1, 2, 3, 4, 5, 6, 7, 8]

        //刪除 4 這個數字
        arr.removeElem(4);
        System.out.println(arr); //[1, 2, 3, 5, 6, 7, 8]
    }

    private static void test_3(){
        AdvanceArray<Student> arr = new AdvanceArray<>(); //預設容量是 10
        //新增測試資料
        arr.append(new Student("AAA", 100));
        arr.append(new Student("BBB", 90));
        arr.append(new Student("CCC", 60));

        System.out.println(arr);
    }
}

// 測試結果
MyArray: size=3, capacity=10
[Student(name: AAA, score: 100), Student(name: BBB, score: 90), Student(name: CCC, score: 60)]

動態支援

上面其實沒有支援動態擴容,也就不能稱為一個 動態陣列

上面的做法是,一旦檢測到索引異常,基本都拋異常結束流程了;正常索引就插入,但沒有考慮插入之後超過容量了怎麼辦?(在容量滿的時候就應該檢測到了)

解決方案,最基本的就是:

  • 1.開闢新空間
  • 2.把元素複製過去

但這只是擴容支援,如果元素縮減到一定程度,那麼也應該減少容量。

增加擴容

//主要就是修改 insert 函式
public void insert(int index, E elem) {
  //檢查 index 是合法的
  if (index < 0 || index > size) {
    throw new IllegalArgumentException("insert failed, need: index >=0 and index <= size ");
  }

  //滿了,那麼就擴容吧 -- 這裡不再是拋異常物件了
  if (size == data.length) {
    resize(2 * data.length); //java arraylist 選擇的是 1.5 倍
  }

  //先移動元素,從後面開始 (size-1 移動到 size --> index 移動到 index+1;騰出 index)
  for (int i = size - 1; i >= index; i--) {
    data[i + 1] = data[i];
  }
  data[index] = elem; //覆蓋原來的 index 處的內容
  size++;
}

private void resize(int newCapacity) {
  E[] newData = (E[]) new Object[newCapacity];
  //複製舊元素過去
  for (int i = 0; i < size; i++) {
    newData[i] = data[i];
  }
  data = newData;
}

簡單測試一下:

//初始容量為 10,先初始化 10 個元素
AdvanceDynamicArray<Integer> arr = new AdvanceDynamicArray<>(); //初始容量預設為 10
for (int i = 0; i < 10; i++) {
  arr.append(i);
}
System.out.println(arr);

//再新增一個元素,試試
arr.append(100);
System.out.println(arr);

//輸出結果:
MyArray: size=10, capacity=10
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
MyArray: size=11, capacity=20
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 100]

刪除縮減

刪除元素的之後,如果空間小到一定程度,自動縮減空間

  • 只要就是在刪除後,做一下 size 和 capacity 的判斷,然後 resize

(即刪除後,size 為 capacity 的一半就可以縮容了)

//初始容量為 10,先初始化 10 個元素
AdvanceDynamicArray<Integer> arr = new AdvanceDynamicArray<>(); //初始容量預設為 10
for (int i = 0; i < 10; i++) {
  arr.append(i);
}
System.out.println(arr);

//再新增一個元素,試試 -- 此時容量變為 20,實際佔用 11
arr.presert(100);
System.out.println(arr);

//pop一個,容量立馬縮減為 10
arr.pop();
System.out.println(arr);

//執行結果為:
MyArray: size=10, capacity=10
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
MyArray: size=11, capacity=20
[100, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
MyArray: size=10, capacity=10
[100, 0, 1, 2, 3, 4, 5, 6, 7, 8]

最後,擴容或者刪除容量,對於 client 而言,它並不知道。(擴容策略也可以在細緻些)

複雜度分析

O(1) -> O(logn)優良 —> O(n) 一般 —> O(nlogn) 一般般,其實和O(n)差不太多 —> O(n^2) 差 —>O(C^n) 超差

一般談起來,會說和執行規模成: 常數關係,對數關係,線性關係,nlogn,平方關係,指數關係。簡單理解就是,級大一級壓死人。

詳細分析

新增操作:

  • 末尾插入 append: O(1)

  • 頭新增 presert: O(n)

  • 任意位置插入 insert(index): 在 O(1) 和 O(n) 之間,假設 index 的取值概率一樣,index 靠前的時候越慢,越靠後越快,整體需要靠具體需要移動多少個元素,n + (n-1) + … + 0,n 種情況,平均需要移動的元素 n/2,所以複雜度是 O(n/2),即 O(n)

resize 的操作也是 O(n)。
(尾部操作也可能是 O(n),但機率比較小,不可能每次都觸發 resize)

綜上所述,以最差來考慮,即 O(n)。 (一般考慮的是概率最高的情況)

刪除操作: (其實分析方法類似,核心因素還是元素的移動)

  • 末尾刪除 pop(): O(1)
  • 頭刪除 popLeft(): O(n) 因為元素要往前面移動
  • 任意位置,平均 remove(index): O(n/2),即 O(n)

resize 的操作也是 O(n)。
(頭部操作也可能是 O(n),但機率比較小,不可能每次都觸發 resize)

綜上所述,以最差來考慮,即 O(n)。

修改操作: 因為可以支援隨機訪問(按地址,索引),所以複雜度 O(1)

查詢操作:

  • get(index): O(1)
  • contains(e): O(n) 線性遍歷
  • find(e): O(n) 線性遍歷

從上面也可以看出,時間複雜度和順序儲存情況一致。

均攤複雜度

也就是仔細考慮一下包含這個 擴容&縮容 耗時操作的 remove/insert 到底應該算作 O(1) 還是 O(n)。

比如 addLast() 本來是 O(1),加入動態支援後,此時還是 O(1) 麼。

顯而易見,容易得證:

假設 capacity = n,那麼進行 n+1 次 addLast,觸發擴容,此時包括移動元素,總共操作 2n+1 次,也就是說平均每次 addLast 觸發 (2n+1)/(n+1) 即 2 次操作,即常量次操作,所以 addLast() 算均攤,也是 O(1)。

震盪優化

剛新增一個元素,擴容;立馬刪除一個元素,又縮容

連環的 addLast 和 removeLast 導致最近的操作都是 O(n)。(常規而言是O(1)的)

此時就要延遲處理(lazy)縮容,即真實元素為 1/4 capacity時才容量減半。

程式碼如下:

    //刪除元素 (通常要返回相應的元素)
    public E remove(int index) {
        if (index < 0 || index >= size) {
            throw new IllegalArgumentException("Remove failed, Index is illegal");
        }
        //先把要返回的值儲存起來
        E ret = data[index];
        //覆蓋刪除: 把 index+1 的值移動到 index --> 把 size-1 的值移動到 size-2
        for (int i = index + 1; i < size; i++) {
            data[i - 1] = data[i];
        }

        size--;
        //data[size] 不必清空,因為使用者取不到 data[size]
        //當使用泛型,對物件元素支援時
        data[size] = null; //方便 java 回收 loitering objects (非必須,閒逛物件 != memory leak)

        //縮減 (只在一個特定的時機縮減,不能用 < 或者 >,可能會存在多次縮減)
        //注意一下,延遲縮減,即 1/4 時,才縮減為一半 (還有一半是空的)
        if(size == data.length/4 && data.length/2 != 0) {
            resize(data.length/2); //那麼就縮減為一半
        }
        return ret;
    }

上面有個技巧或者程式語言問題, data.length/2 != 0,因為 java 的除法預設是截斷的,即可能出現 capacity 為 0 的情況。比如,當前capacity = 1 即 data.length 為 1,即便 size=0,也不能再縮減了(否則執行縮減, capacity 就為 0了)。

總結

順序儲存的結構,不管是不是線性的,基本都有這些操作特徵,只不過上層介面隱藏了實現細節,比如 順序棧 這類受限的結構,底層就是封裝了動態陣列(而非原始陣列了)。

實現的時候注意移動操作從哪裡開始,索引合法性判斷以及記得維護 size。


深怕有人需要這些粗鄙的程式碼,我還是把它放在了 gayhub 上,供您參考。

如有不足之處,萬望批評指正,蝦蝦儂