本文首發於微信公眾號「程序員面試官」
數組幾乎可以是所有軟件工程師最常用到的數據結構,正是因為如此,很多開發者對其不夠重視.
而面試中經常有這樣一類問題: 「100萬個成員的數組取第一個和最后一個有性能差距嗎?為什么?」
除此之外,我們在平時的業務開發中會經常出現數組一把梭的情況,大多數情況下我們都會用數組的形式進行操作,而有讀源碼習慣的開發者可能會發現,在一些底層庫中,我們可能平時用數組的地方,底層庫卻選擇了另外的數據結構,這又是為什么?
希望大家帶着以上的問題我們進行討論.
什么是數組
數組是計算機科學中最基本的數據結構了,絕大多數編程語言都內置了這種數據結構,也是開發者最常見的數據結構.
數組(英語:Array),是由相同類型的元素(element)的集合所組成的數據結構,分配一塊連續的內存來存儲.
當然,在一些動態語言中例如Python的列表或者JavaScript的數組都可能是非連續性的內存,也可以存儲不同類型的元素.
比如我們有如下一個數組:
arr = [1, 2, 3, 4, 5] 復制代碼
其在內存中的表現應該是這樣的:

我們可以看到,這個數組在內存中是以連續線性的形式儲存的,這個連續線性的儲存形式既有其優勢又有其劣勢,只有我們搞清楚優劣才能在以后的開發中更好地使用數組.
數組的特性
一個數據結構通常都有「插入、查找、刪除、讀取」這四種基本的操作,我們會逐一分析這些操作帶來的性能差異.
首先我們要辨析一個概念--性能.
這里的性能並不是絕對意義上速度的快慢,因為不同的設備其硬件基礎就會產生巨大的速度差異,這里的性能是我們在算法分析中的「復雜度」概念.
復雜度的概念可以移步算法分析
插入性能
我們已經知道數組是一段連續儲存的內存,當我們要將新元素插入到數組k的位置時呢?這個時候需要將k索引處之后的所有元素往后挪一位,並將k索引的位置插入新元素.

我們看到這個時候需要進行操作的工作量就大多了,通常情況下,插入操作的時間復雜度是O(n).
刪除性能
刪除操作其實與插入很類似,同樣我要刪除數組之內的k索引位置的元素,我們就需要將其刪除后,為了保持內存的連續性,需要將k之后的元素通通向前移動一位,這個情況的時間復雜度也是O(n).

查找性能
比如我們要查找一個數組中是否存在一個為2
的元素,那么計算機需要如何操作呢?
如果是人的話,在少量數據的情況下我們自然可以一眼找到是否有2
的元素,而計算機不是,計算機需要從索引0開始往下匹配,直到匹配到2
的元素為止.

這個查找的過程其實就是我們常見的線性查找,這個時候需要匹配的平均步數與數組的長度n有關,這個時間復雜度同樣是O(n).
讀取性能
我們已經強調過數組的特點是擁有相同的數據類型和一塊連續的線性內存,那么正是基於以上的特點,數組的讀取性能非常的卓越,時間復雜度為O(1),相比於鏈表、二叉樹等數據結構,它的優勢非常明顯.
那么數組是如何做到這么低的時間復雜度呢?
假設我們的數組內存起始地址為start
,而元素類型的長度為size
,數組索引為i
,那么我們很容易得到這個數組內存地址的尋址公式:
arr[i]_address = start + size * i
復制代碼
比如我們要讀取arr[3]
的值,那么只需要把3
代入尋址公式,計算機就可以一步查詢到對應的元素,因此數組讀取的時間復雜度只有O(1).
性能優化
我們已經知道除了「讀取」這一個操作以外,其他操作的時間復雜度都在O(n),那么有沒有有效的方法進行性能優化呢?
查找性能優化
當數組的元素是無序狀態下,我們只能用相對不太快的線性查找進行查找,當元素是有序狀態(遞增或者遞減),我們可以用另一種更高效的方法--二分查找.
假設我們有一個有int類型組成的數組,以遞增的方式儲存:
arr = [1, 2, 3, 4, 5, 6, 7]
復制代碼
如果我們要查找值為6
元素,按照線性查找的方式需要根據數組索引從0依次比對,直到碰到索引5的元素.
而二分查找的效率則更高,由於我們知道此數組的元素是有序遞增排列的:
- 我們可以取一個索引為3的元素為中間值p
- 將p與目標值6進行對比,發現p的值
4<6
,那么此時由於是遞增數組,目標值一定在索引3之后的元素中 - 此時,再在索引3之后到尾部的元素中取出新的中間值p與目標值比對,再重復下去,直到找到目標值
我們可以發現這樣的操作每一次對比都能排除當前元素數量一半的元素,整體下來的時間復雜度只有O(log n),這表示此方法的效率極高.
這種高效的方法在數據量越大的情況下,越能體現出來,比如目前有一個10億成員的數組是有序遞增,如果按照線性查找,最差的情況下需要10億此查找操作才能找到結果,而二分查找僅僅需要7次.
插入性能優化
比如有以下數組,我們要將一個新成員orange
插入索引1
的位置,通常情況下需要后三位成員后移,orange
占據索引1
的位置.
但是如果我們的需求並不一定需要索引的有序性呢?也就是說,我們可以把數組當成一個集合概念,我們可以在索引1
的位置插入orange
並在數組的尾部開辟一個新內存將原本在1
位置的banana
存入新內存中,這樣雖然索引的亂了,但是整個操作僅僅需要O(1)的時間復雜度.
arr = ['apple', 'banana', 'grape', 'watermelon'] 復制代碼
刪除性能優化
刪除操作需要將產出位置后的元素集體向前移動,這非常消耗性能,尤其是在頻繁的刪除、插入操作中更是如此。
我們可以先記錄下相關的操作,但是並不立即進行刪除,當到一定的節點時我們再一次性依據記錄對數組進行操作,這樣數組成員的反復頻繁移動變成了一次性操作,可以很大程度上提高性能.

這個思想應用非常廣泛:
- 前端框架的虛擬DOM就是將對DOM的大量操作先儲存在差異隊列中,然后再一次性更新,避免了DOM的回流和重繪.
- V8和JVM中的標記清除算法也是基於此思想,標記清除算法分為兩個階段,標記階段對訪問到的對象都打上一個標識,在清除階段發現某個對象沒有標記則進行回收.
小結
回到題目中的問題,我們現在已經可以很清楚地知道「100萬個成員的數組取第一個和最后一個是否有性能差距」,答案顯然是沒有,因為數組是一塊線性連續的內存,我們可以通過尋址公式一步取出對應的成員,這跟成員的位置沒有關系.
最后我們經常在面試或者LeetCode中碰到這樣一類問題,即數組中的子元素問題.
比如: 給定一個整數數組,計算長度為 'k' 的連續子數組的最大總和。

什么方法可以盡可能地降低時間復雜度?說出你的思路即可.
