本文開始梳理數據結構的內容,從數組開始,逐層深刻。html
在java中,數組是一種效率最高的存儲和隨機訪問對象引用序列的方式。數組是一種線性序列,這使得元素訪問很是快速。可是爲了這種快速所付出的代價是數組對象的大小被固定,而且是在其整個生命週期中不可被改變,簡單的來講能夠理解爲數組一旦被初始化,則其長度不可被改變。java
從上面一段話中咱們不難發現幾個關鍵詞:效率最高,隨機訪問,線性序列,長度固定。算法
從而咱們對數組的優缺點就可見一斑:數組
優勢:
隨機訪問。數組的隨機訪問速度是O(1)的時間複雜度。效率極高。 缺點:
長度固定。一旦初始化完成,數組的大小被固定。靈活性不足。
上面咱們說數組是一種線性序列,如何理解這句話呢?簡單來講就是將數據碼成一排進行存放。數據結構
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
本章咱們的重點是封裝一個屬於本身的數組。對於二次封裝的數組咱們想要達到的效果以下所示:code
1 使用java中的數組做爲底層數據結構 2 數組的基本操做:增刪改查等 3 使用泛型-增長靈活性 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];
對於咱們的數組,咱們須要規定數組中的元素都存放在 size - 1的位置。這樣作首先咱們能根據size參數知道,開闢的數組空間哪些被用了,哪些還沒被用。另一個重要做用就是判斷咱們的數組是否是已經滿了,爲後面的動態擴容奠基基礎。
最初咱們的數組以下圖所示:
咱們在數組的尾部添加一個元素也就是在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 }
以下圖所示,若是咱們想在 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 }
刪除指定位置的元素。假設咱們刪除 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 }
這些操做都是不改變數組長度的操做,邏輯相對來講就很簡單了。
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 }
既然是動態數組,resize操做就是咱們的重中之重了。
擴容是添加操做觸發的。
如圖所示,若是咱們繼續往數組中添加元素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 }
縮容在刪除操做中觸發。
接着上面的步驟,若是咱們想刪除元素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 }
addFirst(e) O(n)
addLast(e) O(1)
add(index, e) O(1)-O(n) = O(n)
因此add總體的複雜度最壞狀況爲O(n)。
removeLast(e) O(1)
removeFirst(e) O(n)
remove(index, e) O(1)-O(n) = O(n)
因此remove總體的複雜度最壞狀況爲O(n)。
對於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)級別的。
對於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