不要小瞧數組

1、簡介

  本文開始梳理數據結構的內容,從數組開始,逐層深刻。html

2、java中的數組

  在java中,數組是一種效率最高的存儲和隨機訪問對象引用序列的方式。數組是一種線性序列,這使得元素訪問很是快速。可是爲了這種快速所付出的代價是數組對象的大小被固定,而且是在其整個生命週期中不可被改變,簡單的來講能夠理解爲數組一旦被初始化,則其長度不可被改變。java

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

  從而咱們對數組的優缺點就可見一斑:數組

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

  上面咱們說數組是一種線性序列,如何理解這句話呢?簡單來講就是將數據碼成一排進行存放。數據結構

3、數組的內存分配

int[] a = new int[5];//數組的靜態初始化

 

執行上面這行代碼,JVM的內存是如何分佈的呢?ui

 

如圖所示根據代碼的定義,該數組的長度爲5,則在棧內存中開闢長度爲5的連續內存空間。而且JVM會自動根據類型分配初始值。int 類型的初始值爲0。若是類型爲Integer,初始值爲null(這是java基礎內容)。this

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

 

若是再執行如上代碼,內存分配以下:spa

正如以上代碼所示,數組的存儲效率也是極高的,可根據下標直接將目標元素存放至指定的位置。因此添加元素的時間複雜度也是O(1)級別的。3d

 

4、數組的二次封裝。

  本章咱們的重點是封裝一個屬於本身的數組。對於二次封裝的數組咱們想要達到的效果以下所示:code

1 使用java中的數組做爲底層數據結構
2 數組的基本操做:增刪改查等
3 使用泛型-增長靈活性
4 動態數組-解決數組最大的痛點

 

4.一、定義咱們的動態數組類

 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.一、 向數組尾部添加元素

  最初咱們的數組以下圖所示:

  咱們在數組的尾部添加一個元素也就是在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.三、刪除

  刪除指定位置的元素。假設咱們刪除 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.四、查找,修改,搜索等操做

  這些操做都是不改變數組長度的操做,邏輯相對來講就很簡單了。

 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.五、resize操做

  既然是動態數組,resize操做就是咱們的重中之重了。

 

4.5.一、擴容

  擴容是添加操做觸發的。

  如圖所示,若是咱們繼續往數組中添加元素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.二、縮容

  縮容在刪除操做中觸發。

  接着上面的步驟,若是咱們想刪除元素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、動態數組的時間複雜度分析

 5.一、增

  addFirst(e)    O(n)

  addLast(e)    O(1)

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

  因此add總體的複雜度最壞狀況爲O(n)。

 

5.二、刪

  removeLast(e)    O(1)

  removeFirst(e)    O(n)

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

  因此remove總體的複雜度最壞狀況爲O(n)。

 

 5.三、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.四、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語言描述》

 

 

   

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

  原創不易,轉載請註明原文地址:http://www.javashuo.com/article/p-egowplue-a.html

相關文章
相關標籤/搜索