數據結構之——數組

數據結構:數組java

  • 1:什麼是數組?
  • 2:Java中數組的聲明及數組的遍歷
  • 3:數組天生的優點——索引
  • 4:動態數組
  • 5:封裝本身的數組類——增長元素
  • 6:封裝本身的數組類——刪除元素
  • 7:封裝本身的數組類——修改元素,查詢元素
  • 8:簡單的時間複雜度分析
  • 9:均攤複雜度與複雜度震盪

1:什麼是數組?

數組是咱們在學習任何一種編程語言最先接觸到的數據結構。它是一種相同數據類型的元素存儲的集合;數組中各個元素的存儲是有前後順序的,而且它們在內存中也會按照這樣的順序連續存放在一塊兒。git

2:Java中數組的聲明及數組的遍歷

Java中數組的聲明

Java語言當中,數組常規的聲明方式有三種。github

// 第一種
int [] students = new int [50];
// 第二種
int [] scores = new int [3]{99,88,79};
// 第三種
String [] hobby = {"拳擊","健身","跑步"}
複製代碼

不管是哪種聲明方式,均可以看出數組的聲明直接或間接地指定了數組的大小,只有在聲明數組時,告訴計算機數組的大小,計算機才能夠在指定的內存中爲你聲明的數組開闢一串連續的空間。咱們能夠想象一連串小格子一個挨着一個緊密地拼湊在一塊兒,每個小格子都裝着一個數據,而裝着數據的小格子又對應計算機內存上的一個地址,每一個小格子所對應的地址是連續的......算法

Java中數組的遍歷

除了while循環,for循環等基本的遍歷方式,數組還支持一種特殊的遍歷:foreach.舉一個簡單的例子:編程

// 聲明數組:
int [] scores = new int[50];
// 普通for循環
for(int i=0;i<scores.length;i++){
      System.out.println(score);
}
// foreach遍歷
for(int score:scores){
      System.out.println(score);
}
複製代碼

由於數組在內存中連續排布,因此數組自己就具備可遍歷的能力。數組

3:數組天生的優點——索引

數組最大的優點就是經過索引能夠快速查詢一個元素。由於數組在內存中開闢了一段空間,這一段連續的空間就是用來存儲數組元素的,因此當咱們想獲取某一個數組索引的元素時,計算機只要經過這個索引就能夠在開闢的內存空間中,找到存放這個元素的地址,繼而經過內存地址就能夠快速查詢到這個元素。咱們將索引查詢的時間複雜度定義爲O(1)。在後文有關於時間複雜度的介紹。當數組的索引變得有必定的語意時,數組的應用就更加方便了,例如:int [] students = new int [50]; 若是索引表明的是班級裏學生們的學號,如:students[21] 表明的是學號爲21號的學生,那麼這種索引就變得很是有意義。但並不是全部有語意的數組索引都適用,例如一個公司有10名員工,如今須要將員工信息存儲於一個emp[]數組當中,若是將員工的身份證號做爲索引去建立一個數組,那麼顯然是不合理的。雖然索引變得有意義,可是計算機爲了存儲10名員工的信息就要在內存上開闢身份證號長度的內存去存儲,實在是大大浪費了空間。索引最好創建在有語意的前提下,可是必定要合理。bash

4:動態數組

什麼是動態數組?

瞭解Java的人必定知道,Java Collecion裏面,ArrayList的底層實現原理就是動態數組,那麼動態數組的含義是什麼呢?在上文咱們說過,若是想要聲明定義一個數組,都須要直接或間接地告訴計算機咱們要聲明的數組的大小,只有計算機知道數組的大小後,才能夠爲咱們的數組分配具體的內存空間。可是這樣一來,數組就變得不是那麼靈活了,當數組元素已滿,咱們就沒法繼續添加元素了。若是咱們開闢了1000個元素空間的數組,可是僅僅存儲10個元素,那這種狀況也是不合理的,咱們但願數組可以經過本身的存儲元素的實際數量去調節本身的容量大小,這就須要數組具有動態的能力。Java 提供的數組不具有動態能力,因此,咱們須要本身封裝一個咱們本身的數組,這個數組須要具有動態調節自身容量大小的能力,即:動態數組。數據結構

動態數組的原理

現有一個數組:int [] data = new int[5];jvm

arr.png

該數組已經沒法繼續添加元素了,因此咱們再初始化一個新的數組,其容量爲10,即數組arr容量的2倍:int [] newData = new int [10]; 編程語言

newArr.png

而後將原數組的全部元素所有都賦值給新的數組。

image.png

再將原數組的引用 arr指向 新的數組。

image.png

這個過程轉換爲僞碼爲:

public void resize(int newCapacity){
        E [] newData = (E[]) new Object[newCapacity];
        for(int i=0;i<size;i++){
                newData[i] = data[i];
        } 
        data= newData;
}
複製代碼

動態數組擴容或者縮容的過程封裝成了一個方法:resize.在方法中,使用了泛型,用來表明全部類型的數組。

5:封裝本身的數組類——增長元素

如今咱們要實現添加元素的方法,這個方法能夠在指定的合法的索引位置進行元素的添加。例如:在索引index=1處添加元素88

原數組:

1.png

在索引 index=1處添加元素 88

image.png

過程轉換爲代碼:

public void add(int index,E e){
        if(index<0 || index>size)
            throw new IllegalArgumentException("Index is Illegal");

        if(size == data.length)
            resize(2*data.length);

        for(int i=this.size-1;i>=index;i--){
            data[i+1] = data[i];
        }
        data[index] = e;
        size++;
    }
複製代碼

6:封裝本身的數組類——刪除元素

舉例:刪除索引爲index=1處的元素88.

  • 原數組

    image.png

  • 在索引 index=1處刪除元素 88

    image.png

  • 刪除後的數組

    image.png
    能夠看到,在本例中,刪除元素88後,咱們使用size這樣一個變量去維護實際數組元素的數量,實際元素的數量已經變爲4了,可是本來索引爲 index=4 處的元素仍然還在,以用戶的角度來看,用戶是沒法訪問data[size] 這樣的一個元素的,因此最後的這個元素的存在已經沒有意義了,理應被GC回收。這樣的元素,被稱爲:"Loitering Objects",它們存在並無意義,理應被Java的GC回收機制回收,因此咱們須要手動對其進行回收工做(非必需),data[size] = null 就可讓Java的GC進行回收。刪除元素的代碼爲:

public E remove(int index){
        if(index<0 || index>=this.size)
            throw new IllegalArgumentException("Index is Illegal");

        E ret = data[index];
        for(int i=index+1;i<=this.size-1;i++){
            data[i-1] = data[i];
        }
        size--;
        // data[size] 爲loitering Object 最好將其賦值爲null 讓jvm GC自動進行垃圾回收
        data[size] = null;

        // &&data.length/2!=0的緣由:防止出現當 data.length = 1 且當前無元素即size=0時
        if(this.size == data.length/4 && data.length/2!=0)
            resize(data.length/2);

        return ret;
    }
複製代碼

對於代碼:

if(this.size == data.length/4 && data.length/2!=0)
            resize(data.length/2);
複製代碼

在後面的文章中,有詳細的解釋。

7:封裝本身的數組類——修改元素,查詢元素

  • 修改元素
public void set(int index,E e){
        if(index<0 || index>=this.size)
            throw new IllegalArgumentException("Index is Illegal");

        data[index] = e;
    }
複製代碼
  • 查詢元素
// 查:get
    public E get(int index){
        if(index<0 || index>=this.size)
            throw new IllegalArgumentException("Index is Illegal");

        return data[index];
    }

    // 查:contains
    public boolean contains(E e){
        for(int i=0;i<size;i++){
            if(data[i].equals(e)){
                return true;
            }
        }
        return false;
    }

    // 查:find
    public int find(E e){
        for(int i=0;i<size;i++){
            if(data[i].equals(e)){
                return i;
            }
        }
        return -1;
    }
複製代碼

8:簡單的時間複雜度分析

時間複雜度分析

常見的時間複雜度有:O(1),O(n),O(n logn),O(n^2),等等,其中O( ) 描述的是算法的運行時間和輸入數據的關係。拿數組的索引舉例,數組的索引就是一個O(1) 級別的算法,由於知道索引獲取元素的過程和數據的數量級沒有關係,也就是說不管數組開闢了10的空間仍是開闢了100萬的內存空間,索引任一下標的時間都是一個常量。再例如程序:

public static int sum(int[]nums){
        int sum = 0;
        for(int num:nums){
              sum+=num;
        }
        return sum;
}
複製代碼

上面的程序就是一個O(n) 級別的算法,程序是計算nums數組全部元素的和,計算出結果須要將nums數組從頭到尾遍歷一邊,而遍歷這個過程則與nums元素的數量n呈線性關係,隨着元素個數n愈來愈大,遍歷須要的時間就愈來愈長。固然,這個時間複雜度分析其實也忽略了不少常數及一些細節,包括使用的語言不一樣,程序消耗的時間也是有差別的,因此*O( )*時間複雜度分析分析的只是一種趨勢。

簡單的時間複雜度分析與比對

O(1)O(n)O(n^2) 這三種級別的算法 哪種更優秀呢?首先,*O(1)級別的算法確定是最優的,可是也有必定的弊端。拿數組的索引來講,數組之因此可以快速索引,就是由於它是一種以空間來換取時間的數據結構。若是數據的數量級是千萬級的,那麼數組就要在內存中開闢千萬級的內存空間來支持索引,這顯然是不現實。那麼O(n)級別的算法必定要比O(n^2)級別的算法更優嗎?其實也是不必定的,如T1 = 2000n+10000 這是一個O(n)級別的算法,T2 = n*n 這是一個O(n^2)*級別的算法。當n取值很小如100時,很顯然 T2的時間要小於T1,可是隨着n的取值愈來愈大,*O(n)算法的優點就會愈來愈大。因此從總體上來看O(1)*算法最優,*O(n)算法要優於O(n^2)*級別的算法,實際上也確實如此,O(n)算法不只僅是優於O(n^2),在海量級的數據下,這兩種算法的差別是巨大的。

分析動態數組的時間複雜度
添加操做
除了``add(int index,E e)``方法,爲了方便一些功能 我增長了方法``addLast(E e)``以及 ``addFirst(E e)``。先不考慮resize操做帶來的影響。
複製代碼
  • addLast(E e) O(1)
  • addFirst(E e) O(n)
  • add(int index,E e) 當index=0時,至關於向數組的頭添加一個元素,全部的元素都須要向後挪動一個位置,因此是*O(n)的時間複雜度,當index取值爲size時,則至關於addLast操做,即向數組的末尾添加一個元素,是O(1)的時間複雜度。index的取值在0~size 的機率是相等的,這裏面涉及到機率論的問題,平均而言,add(int index,E e) 的時間複雜度爲:O(n/2) 。也就是說addLast (E e) 直接就能夠將增長的元素添加到數組的末尾,addFirst (E e)操做,數組挪動了n個元素,add(int index,E e) 操做平均來說,數組須要挪動n/2個元素,它消耗的時間也同數組的個數n 呈線性關係,因此能夠將add(int index,E e) 看做一個O(n)*時間複雜度的操做(僅僅表明該方法的時間複雜度同元素個數呈線性關係)。

總體來看 動態數組的添加元素方法:add是一個*O(n)*級別時間複雜度的算法。

刪除操做
除了``remove(int index)``方法,爲了方便一些功能,我也增長了方法``removeFirst()`` 以及``removeLast()``方法。一樣,也不考慮resize()方法對刪除操做帶來的影響。
複製代碼
  • removeFirst() O(n)
  • removeLast() O(1)
  • remove(int index) 對於remove(int index)方法時間複雜度的分析和add(int index,E e)方法的分析過程相似,index的取值在 0~n的機率是相等的,平均上來看,remove(int index)方法會使數組移動n/2個元素,也就是說remove(int index)操做的時間與數組元素的個數n呈線性關係,remove(int index)也是一個*O(n)*級別時間複雜度的算法。

總體上來看,刪除操做remove爲 *O(n)*的時間複雜度。

修改操做
  • set(int index,E e) 修改操做只有一個方法即:set(int index,E e)。由於其利用了數組快速索引的特性,因此修改操做爲*O(1)*的時間複雜度。
查詢操做
  • get(int index) O(1)
  • contains(E e)
public boolean contains(E e){
        for(int i=0;i<size;i++){
            if(data[i].equals(e))
                return true;
        }
        return false;
}
複製代碼

contains方法爲查看數組中是否包含某個元素,由於contains方法須要將數組總體進行一次遍歷,因此contains方法爲*O(n)*的時間複雜度。

  • find(E e)
public int find(E e){
        for(int i=0;i<size;i++){
            if(data[i].equals(e))
                return i;
        }
        return -1;
    }
複製代碼

find方法爲查看是否數組中包含某個元素,若是包含則返回這個元素所在的索引,若是沒有則返回-1。find方法爲*O(n)的時間複雜度。 5. resize操做 resize操做的本質是將一個數組的全部元素依次賦值給一個空數組,它涉及到數組的遍歷,因此resize方法爲O(n)*的時間複雜度。

9:均攤複雜度與複雜度震盪

以上,咱們簡單分析了增刪該查操做的時間複雜度,可是除了改查兩種操做不涉及到resize擴容或縮容操做外,添加元素和刪除元素都有resize這種機制在裏面。

均攤複雜度

以時間複雜度爲*O(1)*的方法addLast(E e)來舉例,若是初始化數組的原始capacity爲10,開始時,數組內沒有任何元素。一直使用addLast(E e)向數組末尾添加元素,在添加10次後,即第十一次再次添加元素則觸發了一次擴容操做,擴容後的capacity爲20即 原來的capacity的2倍。而在第21次添加元素操做時,又再次觸發了擴容的操做。

image.png

也就是說:第n+1次addLast操做會觸發依次resize方法。若是將O(1)的操做稱做1次基本操做的話,從第1次添加元素至第n+1次添加操做共進行了2n+1次基本操做(resize爲O(n),至關於n次基本操做)。n+1次addLast操做,計算機作了2n+1次基本操做即O(1) 的操做,也就是說,平均下來,每1次addLast,計算機就要作(2n+1)/(n+1)次基本的*O(1)操做。也就是說當n這個數字趨近無窮大時,則每1次addLast操做,計算機會進行2次基本的O(1)操做,也就是說——addLast操做和n沒有關係,它仍然是一個O(1)級別的算法。以上分析的思想就是均攤複雜度的分析思想,同理:其餘的方法也能夠用均攤複雜度來進行分析,獲得的結果是一致的,resize雖然會觸發O(n)的操做,可是將resize的O(n)操做平均到每一次O(1)*操做上,對咱們以前分析的時間複雜度並沒有結果上的變化。

複雜度震盪

還記得這段代碼麼?

if(this.size == data.length/4 && data.length/2!=0)
            resize(data.length/2);
複製代碼

這段代碼是remove(int index)方法中,data.length/2!=0是爲了防止出現:數組無元素,capacity已經縮容到1的這種狀況,防止resize縮容到capacity=0,這很顯然是錯誤的。代碼中,當數組元素的個數減小到 數組capacity的四分之一時,觸發了縮容,且縮容爲當前capacity的一半,爲何要這樣寫呢?這種寫法叫作 Lazy 機制,它是爲了解決複雜度震盪的方法。若是咱們將代碼寫成:

if(this.size == data.length/2 && data.length/2!=0)
            resize(data.length/2);
複製代碼

那麼就會出現一個問題:

image.png

當前數組的size爲5,capacity爲5,如今對數組進行這樣的操做:

  1. addLast,觸發擴容擴容成capacity=10
  2. removeLast,觸發縮容,又縮容成capacity=5
  3. addLast,觸發擴容擴容成capacity=10
  4. removeLast,觸發縮容,又縮容成capacity=5 ... ...

想必咱們已經看到問題的所在了,本來爲O(1) 時間複雜度的addLast和removeLast方法硬生生地被玩成了*O(n)*的算法,出現這個問題的緣由就是由於咱們在remove操做時,resize太過於着急了(too Eager),因此形成了複雜度震盪。其實解決方法已經給出,就是Lazy機制。當數組元素的個數到capacity的一半時,不着急去縮容,而是等到size==capacity/4時,將capacity的容積縮容爲capacity/2。這種看似懶惰的機制,卻解決了這樣的一個問題。

最後附上 GitHub的代碼連接:MyArray 測試代碼:Main

相關文章
相關標籤/搜索