純數據結構Java實現(1/11)(動態數組)
- 2019 年 10 月 3 日
- 筆記
我怕說這部分內容太簡單後,突然蹦出來一個大佬把我虐到哭,還是悠著點,踏實寫
大致內容有: 增刪改查,泛型支援,擴容支援,複雜度分析。(鋪墊: 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=%dn", 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=%dn", 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 上,供您參考。
如有不足之處,萬望批評指正,蝦蝦儂。