不要小瞧陣列

超級小小黑發表於2019-08-05

一、簡介

  本文開始梳理資料結構的內容,從陣列開始,逐層深入。

二、java中的陣列

  在java中,陣列是一種效率最高的儲存和隨機訪問物件引用序列的方式。陣列是一種線性序列,這使得元素訪問非常快速。但是為了這種快速所付出的代價是陣列物件的大小被固定,並且是在其整個生命週期中不可被改變,簡單的來說可以理解為陣列一旦被初始化,則其長度不可被改變。

  從上面一段話中我們不難發現幾個關鍵詞:效率最高,隨機訪問,線性序列,長度固定。

  從而我們對陣列的優缺點就可見一斑:

優點:
  隨機訪問。陣列的隨機訪問速度是O(1)的時間複雜度。效率極高。 缺點:
  長度固定。一旦初始化完成,陣列的大小被固定。靈活性不足。

  上面我們說陣列是一種線性序列,如何理解這句話呢?簡單來說就是將資料碼成一排進行存放。

三、陣列的記憶體分配

int[] a = new int[5];//陣列的靜態初始化

 

執行上面這行程式碼,JVM的記憶體是如何分佈的呢?

 

如圖所示根據程式碼的定義,該陣列的長度為5,則在棧記憶體中開闢長度為5的連續記憶體空間。並且JVM會自動根據型別分配初始值。int 型別的初始值為0。如果型別為Integer,初始值為null(這是java基礎內容)。

1 a[0] = 0;
2 a[1] = 1;
3 a[2] = 2;
4 a[3] = 3;
5 a[4] = 4;

 

如果再執行如上程式碼,記憶體分配如下:

正如以上程式碼所示,陣列的儲存效率也是極高的,可根據下標直接將目標元素存放至指定的位置。所以新增元素的時間複雜度也是O(1)級別的。

 

四、陣列的二次封裝。

  本章我們的重點是封裝一個屬於自己的陣列。對於二次封裝的陣列我們想要達到的效果如下所示:

1 使用java中的陣列作為底層資料結構
2 陣列的基本操作:增刪改查等
3 使用泛型-增加靈活性
4 動態陣列-解決陣列最大的痛點

 

4.1、定義我們的動態陣列類

 1 /**
 2  * 描述:動態陣列類
 3  *
 4  * @Author shf
 5  * @Date 2019/7/18 10:48
 6  * @Version V1.0
 7  **/
 8 public class Array<E> {// 使用泛型
 9     private final static int DEFAULT_SIZE = 10;// 預設的陣列容量
10 
11     private E[] data;// 動態陣列的底層容器
12     private int size;// 陣列的長度
13 
14     /**
15      * 根據傳入的 capacity 定義一個指定容量的陣列
16      * @param capacity
17      */
18     public Array(int capacity){
19         this.data = (E[])new Object[capacity];
20         this.size = 0;
21     }
22 
23     /**
24      * 無參構造方法 - 預設容量為 DEFAULT_SIZE = 10;
25      */
26     public Array(){
27         this(DEFAULT_SIZE);
28     }
29 }

 

TIPS:
java中泛型不能直接 new 出來。需要new Object,然後強轉為我們的泛型。
如下所示:
this.data = (E[])new Object[capacity];

 

4.2,新增元素

  對於我們的陣列,我們需要規定陣列中的元素都存放在 size - 1的位置。這樣做首先我們能根據size引數知道,開闢的陣列空間哪些被用了,哪些還沒被用。另外一個重要作用就是判斷我們的陣列是不是已經滿了,為後面的動態擴容奠定基礎。

 

4.2.1、 向陣列尾部新增元素

  最初我們的陣列如下圖所示:

  我們在陣列的尾部新增一個元素也就是在size處新增一個元素。

  程式碼實現一下:

 1     /**
 2      * 向陣列的尾部 新增 元素
 3      * @param e
 4      */
 5     public void addLast(int e){
 6         if(size == data.length){
 7             throw new IllegalArgumentException("AddLast failed. Array is full.");
 8         }
 9         data[size] = e;
10         size ++;
11     }

 

4.2.2 、向索引 index 處新增元素

  如下圖所示,如果我們想在 index 為2的位置新增一個元素66。

  如圖中所示,我們想在 index = 2 的位置新增元素,我們需要將 index為2 到尾部的所有元素移動往後移動一個位置。然後將66方法 2索引位置。

  接下來我們用程式碼實現一下這個過程。

 1     /**
 2      * 在 index 的位置插入一個新元素e
 3      * @param index
 4      * @param e
 5      */
 6     public void add(int index, int e){
 7 
 8         if(size == data.length)
 9             throw new IllegalArgumentException("Add failed. Array is full.");
10 
11         if(index < 0 || index > size)
12             throw new IllegalArgumentException("Add failed. Require index >= 0 and index <= size.");
13 
14         for(int i = size - 1; i >= index ; i --)
15             data[i + 1] = data[i];
16 
17         data[index] = e;
18 
19         size ++;
20     }

  我們發現有了這個方法,4.2.1中的向陣列尾部新增元素就可以直接呼叫該方法,並且對於向陣列頭新增元素也是顯而易見了。

 1     /**
 2      * 向陣列 尾部 新增元素
 3      * @param e
 4      */
 5     public void addLast(E e){
 6         this.add(this.size, e);
 7     }
 8 
 9     /**
10      * 向陣列 頭部 新增元素
11      * @param e
12      */
13     public void addFirst(E e){
14         this.add(0, e);
15     }

 

4.3、刪除

  刪除指定位置的元素。假設我們刪除 index = 2位置的元素66。

  如上圖所示,我只需要將索引 2 以後的元素向前移動一個位置,並重新維護一下size即可。

  程式碼實現一下上面過程:

 1     /**
 2      * 刪除指定位置上的元素
 3      * @param index
 4      * @return 返回刪除的元素
 5      */
 6     public int remove(int index){
 7         if(index < 0 || index >= size)
 8             throw new IllegalArgumentException("Remove failed. Index is illegal.");
 9 
10         int ret = data[index];
11         for(int i = index + 1 ; i < size ; i ++)
12             data[i - 1] = data[i];
13         size --;
14         return ret;
15     }

   有了上面的方法,對於刪除陣列 頭 或者 尾 部的元素就好辦了

 1     /**
 2      * 刪除第一個元素
 3      * @return
 4      */
 5     public E removeFirst(){
 6         return this.remove(0);
 7     }
 8 
 9     /**
10      * 從陣列中刪除最後一個元素
11      * @return
12      */
13     public E removeLast(){
14         return this.remove(this.size - 1);
15     }

 

4.4、查詢,修改,搜尋等操作

  這些操作都是不改變陣列長度的操作,邏輯相對來說就很簡單了。

 1     /**
 2      * 獲取 index 索引位置的元素
 3      * @param index
 4      * @return
 5      */
 6     public E get(int index){
 7         if(index < 0 || index >= size){
 8             throw new IllegalArgumentException("獲取失敗,Index 引數不合法");
 9         }
10         return this.data[index];
11     }
12 
13     /**
14      * 獲取第一個
15      * @return
16      */
17     public E getFirst(){
18         return get(0);
19     }
20 
21     /**
22      * 獲取最後一個
23      * @return
24      */
25     public E getLast(){
26         return get(this.size - 1);
27     }
28 
29     /**
30      * 修改 index 元素位置的元素為e
31      * @param index
32      * @param e
33      */
34     public void set(int index, E e){
35         if(index < 0 || index >= size){
36             throw new IllegalArgumentException("獲取失敗,Index 引數不合法");
37         }
38         this.data[index] = e;
39     }
40 
41     /**
42      * 查詢陣列中是否有元素 e
43      * @param e
44      * @return
45      */
46     public Boolean contains(E e){
47         for (int i = 0; i< size; i++){
48             if(this.data[i].equals(e)){
49                 return true;
50             }
51         }
52         return false;
53     }
54 
55     /**
56      * 查詢陣列中元素e所在的索引,如果不存在元素e,則返回-1
57      * @param e
58      * @return
59      */
60     public int find(E e){
61         for(int i=0; i< this.size; i++){
62             if(this.data[i].equals(e)){
63                 return i;
64             }
65         }
66         return -1;
67     }

 

4.5、resize操作

  既然是動態陣列,resize操作就是我們的重中之重了。

 

4.5.1、擴容

  擴容是新增操作觸發的。

  如圖所示,如果我們繼續往陣列中新增元素100,這時我們就需要進行擴容了。我們將原來的容量 capacity 擴充為原來的兩倍,然後再進行新增。即:capacity * 2 = 20;(以capacity預設為10為例)

  擴容的臨界值:size == capacity時繼續新增。

  首先將容量擴充為原來的2倍:

  然後新增元素100

  程式碼上,對於add方法我們要做如下改變:

 1     /**
 2      * 在 index 的位置插入一個新元素e
 3      * @param index
 4      * @param e
 5      */
 6     public void add(int index, E e){
 7         if(index < 0 || this.size < index){
 8             throw new IllegalArgumentException("新增失敗,要求引數 index >= 0 並且 index <= size");
 9         }
10         if(size == data.length){
11             this.resize(2 * data.length);//擴容
12         }
13         for (int i = size - 1; i >= index; i--) {
14             data[i + 1] = data[i];
15         }
16         data[index] = e;
17         size ++;
18     }

 

  在新增元素之前,我們進行判斷size == data.length(n*capacity,n代表擴容次數,如果我們用capacity,需要維護一個n,或者每次操作都要維護capacity,我們直接用data.length判斷)

  對於resize方法,邏輯就很簡單了。新建立一個容量為newCapacity的陣列,將原陣列中的元素拷貝到新陣列即可。從這可以發現,每次resize操作由於需要有一個copy操作,時間複雜度為O(n)。

 1     /**
 2      * 將陣列容量調整為 newCapacity 大小
 3      * @param newCapacity
 4      */
 5     public void resize(int newCapacity){
 6         E[] newData = (E[]) new Object[newCapacity];
 7         for (int i = 0; i< this.size; i++){
 8             newData[i] = this.data[i];
 9         }
10         this.data = newData;
11     }

 

4.5.2、縮容

  縮容在刪除操作中觸發。

  接著上面的步驟,如果我們想刪除元素100,該怎麼做?

  刪除100元素後才達到resize的臨界值 size == 1/2*capacity。所以縮容的時機為刪除元素後當 size == 1/2的capacity時。

  進行縮容操作:

  如上圖所示,這時size == 1/2*capacity,已經到了我們縮容的時機。

  我們考慮一個問題,假如刪除了元素100後,將容量縮為原來的1/2 = 10,如果這時,我又新增元素,是不是又得進行擴容,再刪除一個元素,又得縮容。。。

  這樣頻繁的進行擴容,縮容是不是很耗時?這種頻繁的進行縮容和擴容會引起復雜度震盪。那我們該如何防止複雜度的震盪呢?很簡單,假如我們為擴容--縮容取一個過渡帶,即當容量為原來的1/4時再進行縮容是不是就可以避免這種問題了?答案,是的。

  程式碼實現的兩個重點:1,防止複雜度震盪。2,縮容發生在 刪除一個元素後size == 當前容量的1/4時。

 1     /**
 2      * 刪除指定位置上的元素
 3      * @param index
 4      * @return
 5      */
 6     public E remove(int index){
 7         if(index < 0 || this.size <= index){
 8             throw new IllegalArgumentException("刪除失敗,Index 引數不合法");
 9         }
10         E ret = this.data[index];
11         for(int i=index+1; i< this.size; i++){
12             data[i-1] = data[i];
13         }
14         size --;
15         this.data[this.size] = null;
16         if(size == this.data.length / 4 && this.data.length / 2 != 0){//防止複雜度的震盪,當size == 1/4capacity時。
17             this.resize(this.data.length / 2);
18         }
19         return ret;
20     }

 

五、動態陣列的時間複雜度分析

 5.1、增

  addFirst(e)    O(n)

  addLast(e)    O(1)

  add(index, e)   O(1)-O(n) = O(n)

  所以add整體的複雜度最壞情況為O(n)。

 

5.2、刪

  removeLast(e)    O(1)

  removeFirst(e)    O(n)

  remove(index, e)   O(1)-O(n) = O(n)

  所以remove整體的複雜度最壞情況為O(n)。

 

 5.3、resize的均攤複雜度

  對於resize來說,每次進行一次resize,時間複雜度是O(n)。但是對於resize我們僅僅通過resize操作來界定其時間複雜度合理嗎?考慮一個問題,resize操作是每次add或者remove操作都會觸發的嗎?答案肯定不是的。因為假設當前陣列的容量為10,每次使用addLast新增一個元素,需要進行11次的新增操作,才會發生一次resize,一次resize對應10次的元素移動過程。也就是直到resize完成,一共進行了21次操作。假設capacity=n,addLast = n+1,觸發resize共進行了2n+1次操作,所以對於addLast操作來說每一次操作,需要進行2次基本操作。

  這樣均攤計算,addLast的均攤複雜度就是O(1)級別的。均攤複雜度有時比計算最壞的情況更有意義,因為對壞的情況不是每次都發生的。

  同理對於removeLast操作來說,均攤複雜度也是O(1)級別的。

 

5.4、resize操作的複雜度震盪

  對於addLast和removeLast操作而言,時間複雜度都是O(1)級別的,但是當我們對這兩個操作整體來看,在極端情況下可能會發生的有趣的案例

  假設對於新增操作當陣列size == capacity 擴容為當前容量的2倍。對於removeLast,達到當前陣列容量的1/2,進行縮容,縮為當前容量的1/2。

  當前陣列的容量為10,這時反覆進行addLast和removeLast操作。我們會發現有意思的情況就是對於兩個複雜度為O(1)級別的操作,由於每次都觸發resize操作,時間複雜度每次都是最壞的情況O(n)。這種由於某種操作造成的複雜度不斷變化的情況稱為-複雜度的震盪。

  如何解決複雜度的震盪呢?上面我們也提到過,就是新增一個緩衝帶,減少這種情況的發生。那就是當容量變為原來的1/4時進行縮容。所以對於addLast和removeLast的操作,中間間隔1/4容量的操作才會發生複雜度的震盪。這樣我們就有效的減少了複雜度的震盪。

 

  看到這裡如果你發現我們手寫的動態陣列跟java中的ArrayList很相似的話,說明你對ArrayList的瞭解還是很不錯的。

 

  參考文獻:

  《玩轉資料結構-從入門到進階-劉宇波》

  《資料結構與演算法分析-Java語言描述》

 

 

   

  如有錯誤的地方還請留言指正。

  原創不易,轉載請註明原文地址:https://www.cnblogs.com/hello-shf/p/11299383.html

 

 

  

 

  

 

相關文章