03 | 數組:爲何不少編程語言中數組都從0開始編號?

提到數組,我想你確定不陌生,甚至還會自信地說,它很簡單啊。java

 

是的,在每一種編程語言中,基本都會有數組這種數據類型。不過,它不只僅是一種編程語言中的數據類型,仍是一種最基礎的數據結構。儘管數組看起來很是基礎、簡單,可是我估計不少人都並無理解這個基礎數據結構的精髓。python

 

在大部分編程語言中,數組都是從 0 開始編號的,但你是否下意識地想過,爲何數組要從 0 開始編號,而不是從 1 開始呢? 從 1 開始不是更符合人類的思惟習慣嗎?程序員

 

你能夠帶着這個問題來學習接下來的內容。面試

 

如何實現隨機訪問?

 

什麼是數組?算法

 

我估計你心中已經有了答案。不過,我仍是想用專業的話來給你作下解釋。數組(Array)是一種線性表數據結構。它用一組連續的內存空間,來存儲一組具備相同類型的數據。數據庫

 

這個定義裏有幾個關鍵詞,理解了這幾個關鍵詞,我想你就能完全掌握數組的概念了。下面就從個人角度分別給你「點撥」一下。+第一是線性表(Linear+List)。顧名思義,線性表就是數據排成像一條線同樣的結構。每一個線性表上的數據最多隻有前和後兩個方向。其實除了數組,鏈表、隊列、棧等也是線性表結構。編程

 

而與它相對立的概念是非線性表,好比二叉樹、堆、圖等。之因此叫非線性,是由於,在非線性表中,數據之間並非簡單的先後關係。數組

 

 

第二個是連續的內存空間和相同類型的數據。正是由於這兩個限制,它纔有了一個堪稱「殺手鐗」的特性:「隨機訪問」。但有利就有弊,這兩個限制也讓數組的不少操做變得很是低效,好比要想在數組中刪除、插入一個數據,爲了保證連續性,就須要作大量的數據搬移工做。網絡

 

說到數據的訪問,那你知道數組是如何實現根據下標隨機訪問數組元素的嗎?數據結構

 

咱們拿一個長度爲 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 這部分的元素都順序地日後挪一位。那插入操做的時間複雜度是多少呢?你能夠本身先試着分析一下。

 

若是在數組的末尾插入元素,那就不須要移動數據了,這時的時間複雜度爲 O(1)。但若是在數組的開頭插入元素,那全部的數據都須要依次日後移動一位,因此最壞時間複雜度是 O(n)。由於咱們在每一個位置插入元素的機率是同樣的,因此平均狀況時間複雜度爲 (1+2+…n)/n=O(n)。

 

若是數組中的數據是有序的,咱們在某個位置插入一個新的元素時,就必須按照剛纔的方法搬移+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 個位置的數據,爲了內存的連續性,也須要搬移數據,否則中間就會出現空洞,內存就不連續了。

 

和插入相似,若是刪除數組末尾的數據,則最好狀況時間複雜度爲 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,你會發現,這不就是 JVM 標記清除垃圾回收算法的核心思想嗎?沒錯,數據結構和算法的魅力就在於此,不少時候咱們並非要去死記硬背某個數據結構或者算法,而是要學習它背後的思想和處理技巧,這些東西纔是最有價值的。若是你細心留意,無論是在軟件開發仍是架構設計中,總能找到某些算法和數據結構的影子。

 

警戒數組的訪問越界問題

 

瞭解了數組的幾個基本操做後,咱們來聊聊數組訪問越界的問題。

 

首先,我請你來分析一下這段+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] 就至關於 i=0,因此就會致使代碼無限循環。

 

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

 

這種狀況下,通常都會出現莫名其妙的邏輯錯誤,就像咱們剛剛舉的那個例子,debug 的難度很是的大。並且,不少計算機病毒也正是利用到了代碼中的數組越界能夠訪問非法地址的漏洞,來攻擊系統,因此寫代碼的時候必定要警戒數組越界。

 

但並不是全部的語言都像 C 同樣,把數組越界檢查的工做丟給程序員來作,像 Java 自己就會作越界檢查,好比下面這幾行 java 代碼,就會拋出 java.lang.ArrayIndexOutOfBoundsException。 

 

int[] a = new int[3];
a[3] = 10;

  

容器可否徹底替代數組?

 

針對數組類型,不少語言都提供了容器類,好比 Java 中的 ArrayList、C++ 中的 vector。在項目開發中,何時適合用數組,何時適合用容器呢?

 

這裏我拿 Java 語言來舉例。若是你是 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 開始呢?+從數組存儲的內存模型上來看,「下標」最確切的定義應該是「偏移(offset)」。前面也講到,若是用 a 來表示數組的首地址,a[0] 就是偏移爲 0 的位置,也就是首地址,a[k] 就表示偏移 k 個 type_size 的位置,因此計算 a[k] 的內存地址只須要用這個公式:

 

a[k]_address = base_address + k * type_size

  

可是,若是數組從+1+開始計數,那咱們計算數組元素+a%5Bk%5D+的內存地址就會變爲:

 

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

  

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

 

數組做爲很是基礎的數據結構,經過下標隨機訪問數組元素又是其很是基礎的編程操做,效率的優化就要儘量作到極致。因此爲了減小一次減法操做,數組選擇了從 0 開始編號,而不是從 1 開始。

 

不過我認爲,上面解釋得再多其實都算不上壓倒性的證實,說數組起始編號非 0 開始不可。因此我以爲最主要的緣由多是歷史緣由。 C 語言設計者用 0 開始計數數組下標,以後的 Java、JavaScript 等高級語言都效仿了 C 語言,或者說,爲了在必定程度上減小 C 語言程序員學習 Java 的學習成本,所以繼續沿用了從 0 開始計數的習慣。實際上,不少語言中數組也並非從 0 開始計數的,好比 Matlab。甚至還有一些語言支持負數下標,好比 Python。

 

內容小結

 

咱們今天學習了數組。它能夠說是最基礎、最簡單的數據結構了。數組用一塊連續的內存空間,來存儲相同類型的一組數據,最大的特色就是支持隨機訪問,但插入、刪除操做也所以變得比較低效,平均狀況時間複雜度爲+O(n)。在平時的業務開發中,咱們能夠直接使用編程語言提供的容器類,可是,若是是特別底層的開發,直接使用數組可能會更合適。

 

課後思考

 

前面我基於數組的原理引出 JVM 的標記清除垃圾回收算法的核心理念。我不知道你是否使用 Java 語言,理解 JVM,若是你熟悉,能夠在評論區回顧下你理解的標記清除垃圾回收算法。 前面咱們講到一維數組的內存尋址公式,那你能夠思考一下,類比一下,二維數組的內存尋址公式是怎樣的呢? 

 

 

基於python 實現一個簡單地數組實例

from typing import Optional

class MyArray:
    def __init__(self, capacity: int):
        self._data = []
        self._count = 0
        self._capacity = capacity

    def __getitem__(self, position: int) -> int:

        return self._data[position]

    def find(self, index: int) -> Optional[int]:
        if index >= self._count or index <= -self._count: return None
        return self._data[index]

    def delete(self, index: int) -> bool:
        if index >= self._count or index <= -self._count: return False
        self._data[index:-1] = self._data[index + 1:]
        self._count -= 1
        return True

    def insert(self, index: int, value: int) -> bool:
        if index >= self._count or index <= -self._count: return False
        if self._capacity == self._count: return False
        self._data.insert(index, value)
        self._count += 1
        return True

    def insert_to_tail(self, value: int) -> bool:
        if self._count == self._capacity: return False
        if self._count == len(self._data):
            self._data.append(value)
        else:
            self._data[self._count] = value
        self._count += 1
        return True

    def __repr__(self) -> str:
        return " ".join(str(num) for num in self._data[:self._count])

    def print_all(self):
        for num in self._data[:self._count]:
            print(f"{num}", end=" ")
        print("\n", flush=True)


if __name__ == "__main__":
    a = MyArray(6)
    for i in range(7):
        a.insert_to_tail(i)

    a.delete(2)
    print(a)
    a.insert_to_tail(7)
    print(a[2])
View Code
相關文章
相關標籤/搜索