資料結構與演算法之美 王爭 學習筆記-2 [MD]
我的GitHub | 我的部落格 | 我的微信 | 我的郵箱 |
---|---|---|---|
baiqiantao | baiqiantao | bqt20094 | [email protected] |
目錄
目錄基礎篇 (38講)
05 | 陣列:為什麼很多程式語言中陣列都從0開始編號?
為什麼陣列要從 0 開始編號,而不是從 1 開始呢?
如何實現隨機訪問?
陣列(Array)是一種線性表
資料結構。它用一組連續
相同型別
的資料。
線性表
顧名思義,線性表就是資料排成像一條線一樣的結構。每個線性表上的資料最多隻有前和後兩個方向
。除了陣列,連結串列
、佇列
、棧
等也是線性表結構。
而與它相對立的概念是非線性表,比如二叉樹
、堆
、圖
等。在非線性表中,資料之間並不是簡單的前後關係。
連續的記憶體空間和相同型別的資料
第二個是連續的記憶體空間和相同型別的資料。正是因為這兩個限制,它才有了一個堪稱殺手鐗
的特性:隨機訪問
。
這兩個限制也讓陣列的很多操作變得非常低效,比如要想在陣列中刪除、插入一個數據,為了保證連續性
,就需要做大量的資料搬移工作。
陣列是如何實現根據下標隨機訪問陣列元素的
例如陣列 int[] a = new int[10]
計算機會給每個記憶體單元分配一個地址
,計算機通過地址
來訪問記憶體中的資料。當計算機需要隨機訪問陣列中的某個元素時,它會首先通過下面的定址
公式,計算出該元素儲存的記憶體地址
:
a[i]_address = base_address + i * data_type_size
這裡我要特別糾正一個錯誤。我在面試的時候,常常會問陣列和連結串列的區別,很多人都回答說,“連結串列適合插入、刪除,時間複雜度 O(1);陣列適合查詢,查詢時間複雜度為 O(1)”。實際上,這種表述是不準確的。陣列是適合查詢操作,但是查詢的時間複雜度並不為
O(1)
。即便是排好序的陣列,你用二分查詢,時間複雜度也是O(logn)
。所以,正確的表述應該是,陣列支援隨機訪問
,根據下標隨機訪問的時間複雜度為O(1)
。
低效的插入和刪除
插入操作的優化
如果陣列中儲存的資料並沒有任何規律
(即:無序),陣列只是被當作一個儲存資料的集合。在這種情況下,如果要將某個資料插入到第 k 個位置,為了避免大規模的資料搬移,我們還有一個簡單的辦法就是,直接將第 k 位的資料搬移到陣列元素的最後,把新的元素直接放入第 k 個位置。
利用這種處理技巧,在特定場景下,在第 k 個位置插入一個元素的時間複雜度就會降為 O(1)。這個處理思想在快排中也會用到。
正常情況,不會使用這種操作,
刪除操作的優化
在某些特殊場景下,我們並不一定非得追求陣列中資料的連續性
。我們可以先記錄
下已經刪除的資料,每次的刪除操作並不是真正地搬移資料
,只是記錄
資料已經被刪除。當陣列沒有更多空間儲存資料時,我們再觸發執行一次真正的刪除操作
,這樣就大大減少了刪除操作導致的資料搬移。
這其實就是 JVM 標記清除
垃圾回收演算法的核心思想。
警惕陣列的訪問越界問題
陣列越界在 C 語言中是一種未決行為,並沒有規定陣列訪問越界時編譯器應該如何處理。因為,訪問陣列的本質就是訪問一段連續記憶體,只要陣列通過偏移計算得到的記憶體地址是可用的(非受限的記憶體),那麼程式就可能不會報任何錯誤。
容器能否完全替代陣列?
ArrayList 最大的優勢就是可以將很多陣列操作的細節封裝
起來。比如前面提到的陣列插入、刪除資料時需要搬移其他資料等。另外,它還有一個優勢,就是支援動態擴容
。
陣列本身在定義的時候需要預先指定大小
,因為需要分配連續的記憶體空間
。
因為擴容操作涉及記憶體申請和資料搬移,是比較耗時的。所以,如果事先能確定需要儲存的資料大小,最好在建立 ArrayList 的時候事先指定容器大小
。
對於業務開發,直接使用容器就足夠了,省時省力。畢竟損耗一丟丟效能,完全不會影響到系統整體的效能。但如果你是做一些非常底層的開發,比如開發網路框架,效能的優化需要做到極致,這個時候陣列就會優於容器,成為首選。
解答開篇
為什麼大多數程式語言中,陣列要從 0 開始編號,而不是從 1 開始呢?
從陣列儲存的記憶體模型上來看,下標最確切的定義應該是偏移(offset)
。如果用 a 來表示陣列的首地址,a[0]
就是偏移為 0 的位置,也就是首地址,a[k]
就表示偏移 k 個 type_size 的位置。
如果從 1 開始編號,每次隨機訪問陣列元素都多了一次減法運算,對於 CPU 來說,就是多了一次減法指令。
陣列作為非常基礎的資料結構,通過下標隨機訪問陣列元素又是其非常基礎的程式設計操作,效率的優化就要儘可能做到極致。所以為了減少一次減法操作
,陣列選擇了從 0 開始編號,而不是從 1 開始。
不過,上面解釋得再多其實都算不上壓倒性的證明,我覺得最主要的原因可能是歷史原因。C 語言設計者用 0 開始計數陣列下標,之後的 Java、JavaScript 等高階語言都效仿了 C 語言,或者說,為了在一定程度上減少 C 語言程式設計師學習 Java 的學習成本,因此繼續沿用了從 0 開始計數的習慣。實際上,很多語言中陣列也並不是從 0 開始計數的,比如 Matlab。甚至還有一些語言支援負數下標,比如 Python。