爲什麼很多編程語言中的數組都從0開始編號?——你真的瞭解數組嗎?

數組是學習數據結構的開端。儘管數組看起來非常基礎、簡單,但是有多少人理解數組的精髓呢?

在大部分編程語言中,數組都是從0開始編號的,回想當年初學java,剛接觸數組的時候,我就想過,爲什麼數組要從0開始編號,而不是從1開始呢?從1開始不是更符合人類的思維習慣嗎?

讓我們帶着疑惑來探索這小小的數組?

一、基本定義:

數組是一種線性表數據結構。它用一組連續的內存空間,來存儲一組具有相同類型的數據

這句定義有三個關鍵詞,線性表、連續的內存空間、相同類型的數據。

線性表就是數據排成像一條線一樣的結構,每個線性表上的數據最多隻有前後兩個方向。像鏈表、隊列、棧等也是線性表結構。

與之相對的是非線性表,比如二叉樹、堆、圖等。之所以叫非線性,是因爲,在非線性表中,數據之間並不是簡單的前後關係。

數組有一個特性是:“隨機訪問”,是因爲其連續的內存空間相同類型的數據。但是有利必有弊,正因爲這兩個限制,使得數組的很多操作變得非常低效,比如在數組中刪除、插入一個數據,爲了保證連續性,就需要做大量的數據搬移工作。

二、數組的隨機訪問

我們都知道數組是根據下標隨機訪問元素的,但是這個底層是如何實現的呢?

我們拿一個長度爲10的int類型的數組 int [] a = new int[10] 來舉例。如圖計算機給數組a[10],分配了一塊連續內存空間1000~1039,其中,內存塊的首地址是base_address = 1000。

我們知道,計算機會給每個內存單元分配一個地址,計算機通過地址來訪問內存中的數據。當計算機需要隨機訪問數組中的某個元素時,它會首先通過下面的尋址公式,計算出該元素存儲的內存地址:

a[i]_address = base_address + i * data_type_size

其中 data_type_size 表示數組中每個元素的大小。我們舉的這個例子裏,數組中存儲的是 int 類型數據,所以 data_type_size 就爲 4 個字節。

三、低效的“插入”和“刪除”

有道面試題,數組和鏈表的區別是什麼?很多人會回答:“鏈表適合插入、刪除,時間複雜度爲O(1);數組適合查找,查找時間複雜度爲O(1)”。

實際上,這種表述是不準確的。數組是適合查找操作,但是查找的時間複雜度並不爲 O(1)。即便是排好序的數組,你用二分查找,時間複雜度也是 O(logn)。所以,正確的表述應該是,數組支持隨機訪問,根據下標隨機訪問的時間複雜度爲 O(1)。

數組爲了保持內存數據的連續性,會導致插入、刪除這兩個操作比較低效。究竟爲什麼會導致低效?又有哪些改進方法呢

我們先來看插入。假設數組的長度爲 n,現在,如果我們需要將一個數據插入到數組中的第 k 個位置。爲了把第 k 個位置騰出來,給新來的數據,我們需要將第 k~n 這部分的元素都順序地往後挪一位。

public class Array<E> {
    private E[] data;
    private int size;//元素個數

    public Array(int capacity){
        this.data = (E[]) new Object[capacity];
        this.size=0;
    }
    public Array(){
        this(10);
    }

    //遍歷查找元素
    public int find(E e){
        for (int i = 0 ;i<size; i++){
            if(data [i].equals(e))
                return i;
        }
        return -1;
    }
}
//在中間添加元素
    public void add(int index,E e){
        if (size == data.length)
            throw new IllegalArgumentException("data is full !");
        if (index<0||index>size)
            throw new IllegalArgumentException("index should be >=0 || <=size !");
        for (int i = size-1; i>=index  ; i--) {
            data[i+1] = data[i];
        }
        data[index] = e;
        size++;
    }

如果在數組的末尾插入元素,那就不需要移動數據了,這時的時間複雜度爲 O(1)。但如果在數組的開頭插入元素,那所有的數據都需要依次往後移動一位,所以最壞時間複雜度是 O(n)。 因爲我們在每個位置插入元素的概率是一樣的,所以平均情況時間複雜度爲 (1+2+…n)/n=O(n)

在首位置插入元素
    public void addFirst(E e){
        add(0,e);
    }
在末位置插入元素
    public void addLast(E e){
        add(size,e);
    }

改進方案:如果數組中的數據是有序的,我們在某個位置插入一個新的元素時,就必須按照剛纔的方法搬移 k 之後的數據。但是,如果數組中存儲的數據並沒有任何規律,數組只是被當作一個存儲數據的集合。在這種情況下,如果要將某個數組插入到第 k 個位置,爲了避免大規模的數據搬移,我們還有一個簡單的辦法就是,直接將第 k 位的數據搬移到數組元素的最後,把新的元素直接放入第 k 個位置。

爲了更好地理解,我們舉一個例子。假設數組 a[10] 中存儲瞭如下 5 個元素:a,b,c,d,e

我們現在需要將元素 x 插入到第 3 個位置。我們只需要將 c 放入到 a[5],將 a[2] 賦值爲 x 即可。最後,數組中的元素如下: a,b,x,d,e,c

利用這種處理技巧,在特定場景下,在第 k 個位置插入一個元素的時間複雜度就會降爲 O(1)。這個處理思想在快排中也會用到。

我們再來看看刪除操作。跟插入數據類似,如果我們要刪除第 k 個位置的數據,爲了內存的連續性,也需要搬移數據,不然中間就會出現空洞,內存就不連續了。

    //在中間位置刪除元素
    public E remove(int index){
        if (index < 0 || index >=size)
            throw new IllegalArgumentException("index should be >=0 || <size !");
        E ret = data[index];
        for (int i= index+1 ; i<size ; i++)
            data[i-1] = data[i];
        size--;
        data[size] = null;
        return ret;
    }
//在首位刪除元素
    public E removeFirst(){
        return remove(0);
    }
//在尾部刪除元素
    public E removeLast(){
        return remove(size-1);
    }
//刪除一個元素(通過查找位置)
    public void removeElement(E e){
        int index = find(e);
        if (index !=-1)
            remove(index);
    }

和插入類似,如果刪除數組末尾的數據,則最好情況時間複雜度爲 O(1);如果刪除開頭的數據,則最壞情況時間複雜度爲 O(n);平均情況時間複雜度也爲 O(n)。

改進方案:實際上,在某些特殊場景下,我們並不一定非得追求數組中數據的連續性。如果我們將多次刪除操作集中在一起執行,刪除的效率是不是會提高很多呢?

我們繼續來看例子。數組 a[10] 中存儲了 8 個元素:a,b,c,d,e,f,g,h。現在,我們要依次刪除 a,b,c 三個元素。

爲了避免 d,e,f,g,h 這幾個數據會被搬移三次,我們可以先記錄下已經刪除的數據。每次的刪除操作並不是真正地搬移數據,只是記錄數據已經被刪除。當數組沒有更多空間存儲數據時,我們再觸發執行一次真正的刪除操作,這樣就大大減少了刪除操作導致的數據搬移。

這種巧妙的方式也是JVM標記清楚垃圾回收算法的核心思想。很多時候我們並不是要去死記硬背某個數據結構或者算法,而是要學習它背後的 思想和處理技巧,這些東西纔是最有價值的。

四、數組訪問越界問題和動態數組

數組越界在 C 語言中是一種未決行爲,並沒有規定數組訪問越界時編譯器應該如何處理。因爲,訪問數組的本質就是訪問一段連續內存,只要數組通過偏移計算得到的內存地址是可用的,那麼程序就可能不會報任何錯誤。 如下c語言代碼:

int main(int argc, char* argv[]){
    int i = 0;
    int arr[3] = {0};
    for(; i<=3; i++){
        arr[i] = 0;
        printf("hello world\n");
    }
    return 0;
}

這段代碼的運行結果並非是打印三行“hello word”,而是會無限打印“hello world”,這是爲什麼呢?

因爲,數組大小爲 3,a[0],a[1],a[2],而我們的代碼因爲書寫錯誤,導致 for 循環的結束條件錯寫爲了 i<=3 而非 i<3,所以當 i=3 時,數組 a[3] 訪問越界。

我們知道,在 C 語言中,只要不是訪問受限的內存,所有的內存空間都是可以自由訪問的。根據我們前面講的數組尋址公式,a[3] 也會被定位到某塊不屬於數組的內存地址上,而這個地址正好是存儲變量 i 的內存地址,那麼 a[3]=0 就相當於 i=0,所以就會導致代碼無限循環。

這種情況下,一般都會出現莫名其妙的邏輯錯誤,就像我們剛剛舉的那個例子,debug 的難度非常的大。而且,很多計算機病毒也正是利用到了代碼中的數組越界可以訪問非法地址的漏洞,來攻擊系統,所以寫代碼的時候一定要警惕數組越界。

但並非所有的語言都像 C 一樣,把數組越界檢查的工作丟給程序員來做,像 Java 本身就會做越界檢查,比如下面這幾行 Java 代碼,就會拋出 java.lang.ArrayIndexOutOfBoundsException

數組越界,是因爲數組不可變。我們是否能創造一個動態數組?插入數據後,發現數組滿了的時候,我們就重新創建一個新數組,容量是原來數組的2倍,並將舊數組裏的數據複製到新數組中。

    private void resize(int newCapacity){
        E[] new_array = (E[]) new Object[newCapacity];
        for (int i = 0 ; i< size; i++)
            new_array[i] = data[i];
        data = new_array;
    }
    public void add(int index,E e){
        if (index<0||index>size)
            throw new IllegalArgumentException("index should be >=0 || <=size !");
        //動態擴大容量
        if (size == data.length)
            resize(data.length*2);
        for (int i = size - 1 ; i >= index ; i--)
            data[i+1] = data[i];
        data[index] = e;
        size++;
    }

插入數據時數組的容量不夠要擴容,刪除數據時,我們也要做相應的縮容。

    public E remove(int index){
        if (index < 0 || index >=size)
            throw new IllegalArgumentException("index should be >=0 || <size !");
        E ret = data[index];
        for (int i= index+1 ; i<size ; i++)
            data[i-1] = data[i];
        size--;
        data[size] = null;
        //動態數組,如果元素的數量是容量的一半,可適當縮小容量
        //lazy 操作
        if (size == data.length * 1/4 && data.length/2 != 0)
            resize(data.length * 1/2);
        return ret;
    }

這裏其實有一個問題。在縮容時,爲什麼不是在元素個數到容量一半時,容量縮小到元素個數大小(就是原容量的一半)?假如這個時候又插入數據不就又要擴容了嗎?擴容和縮容都是數據的遷移,是很耗性能的,這就是複雜度震盪。所以縮容的解決方案就是:Lazy。當元素個數是容量的1/4時,再縮容,這就避免了複雜度震盪。

(因爲篇幅過長,關於數組的插入、刪除、擴、縮容的複雜度分析會單獨寫一篇文章深入研究。)

五、我們是否已經拋棄了數組

java爲我們提供了很多容器,如ArrayList等,在項目開發中,什麼時候適合用數組,什麼時候適合用容器呢?

如果你是java開發人員,一定對ArrayList非常熟悉。那它與數組相比,到底有哪些優勢呢?

我個人覺得,ArrayList 最大的優勢就是可以將很多數組操作的細節封裝起來。比如前面提到的數組插入、刪除數據時需要搬移其他數據等。另外,它還有一個優勢,就是支持動態擴容。

數組本身在定義的時候需要預先指定大小,因爲需要分配連續的內存空間。如果我們申請了大小爲 10 的數組,當第 11 個數據需要存儲到數組中時,我們就需要重新分配一塊更大的空間,將原來的數據複製過去,然後再將新的數據插入。

如果使用 ArrayList,我們就完全不需要關心底層的擴容邏輯,ArrayList 已經幫我們實現好了。每次存儲空間不夠的時候,它都會將空間自動擴容爲 1.5 倍大小。

不過,這裏需要注意一點,因爲擴容操作涉及內存申請和數據搬移,是比較耗時的。所以,如果事先能確定需要存儲的數據大小,最好在創建 ArrayList 的時候事先指定數據大小

比如我們要從數據庫中取出 10000 條數據放入 ArrayList。我們看下面這幾行代碼,你會發現,相比之下,事先指定數據大小可以省掉很多次內存申請和數據搬移操作。

ArrayList<User> users = new ArrayList(10000);
for (int i = 0; i < 10000; ++i) {
  users.add(xxx);
}

比較下來感覺數組無用武之地,太底層了。作爲一個初級程序員,我在實際項目中幾乎沒用到過數組。但是數組並不是那麼不堪。

  1. Java ArrayList 無法存儲基本類型,比如 int、long,需要封裝爲 Integer、Long 類,而 Autoboxing、Unboxing 則有一定的性能消耗,所以如果特別關注性能,或者希望使用基本類型,就可以選用數組。
  2. 如果數據大小事先已知,並且對數據的操作非常簡單,用不到 ArrayList 提供的大部分方法,也可以直接使用數組。
  3. 還有一個是我個人的喜好,當要表示多維數組時,用數組往往會更加直觀。比如 Object[][] array;而用容器的話則需要這樣定義:ArrayList<ArrayList > array。

總結:對於業務開發,直接使用容器就足夠了,省時省力。畢竟損耗一丟丟性能,完全不會影響到系統整體的性能。但如果你是做一些非常底層的開發,比如開發網絡框架,性能的優化需要做到極致,這個時候數組就會優於容器,成爲首選。

六、爲什麼數組要從0開始編號,而不是從1開始

CPU性能考慮:從數組存儲的內存模型上來看,“下標”最確切的定義應該是“偏移(offset)”。前面也講到,如果用 a 來表示數組的首地址,a[0] 就是偏移爲 0 的位置,也就是首地址,a[k] 就表示偏移 k 個 type_size 的位置,所以計算 a[k] 的內存地址只需要用這個公式:

a[k]_address = base_address + k * type_size

但是,如果數組從 1 開始計數,那我們計算數組元素 a[k] 的內存地址就會變爲:

a[k]_address = base_address + (k-1)*type_size

對比兩個公式,我們不難發現,從 1 開始編號,每次隨機訪問數組元素都多了一次減法運算,對於 CPU 來說,就是多了一次減法指令。

數組作爲非常基礎的數據結構,通過下標隨機訪問數組元素又是其非常基礎的編程操作,效率的優化就要儘可能做到極致。所以爲了減少一次減法操作,數組選擇了從 0 開始編號,而不是從 1 開始。

歷史原因:C 語言設計者用 0 開始計數數組下標,之後的 Java、JavaScript 等高級語言都效仿了 C 語言,或者說,爲了在一定程度上減少 C 語言程序員學習 Java 的學習成本,因此繼續沿用了從 0 開始計數的習慣。實際上,很多語言中數組也並不是從 0 開始計數的,比如 Matlab。甚至還有一些語言支持負數下標,比如 Python。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章