算法:2.數組

算法:2.數組

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

關鍵詞:線性表,連續內存空間

線性表:

每個線性表上的數據最多隻有前和後兩個方向。除了數組,鏈表、隊列、棧等也是線性表結構。

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

連續的內存空間和相同類型的數據

這兩個限制,使數組具有很牛的特性:“隨機訪問”。但有利就有弊,這兩個限制也讓數組的刪除、插入變得很低效。爲了保證連續性,刪除和插入時,就需要做大量的數據搬移工作。

如何實現隨機訪問?

長度爲10的int數組 int a[ ] =new int [10] 。假如在計算機內存分配連續空間1000~1039,如下圖:

其中,內存塊的首地址爲 base_address = 1000。已知,計算機會給每個內存單元分配一個地址,計算機通過地址來訪問內存中的數據。當計算機需要隨機訪問數組中的某個元素時,它會首先通過下面的尋址公式,計算出該元素存儲的內存地址:
a[i]_address=base_address+idata_type_size a[ i ]\_address = base\_address + i * data\_type\_size
其中 data_type_size 表示數組中每個元素的大小。int是 4 個字節。

特別糾正一個“錯誤”,面試經典話術“鏈表適合插入、刪除,時間複雜度 O(1);數組適合查找,查找時間複雜度爲 O(1)”。數組是適合查找操作,但是即便是排好序的數組,你用二分查找,時間複雜度也是 O(logn)。所以,正確的表述應該是,數組支持隨機訪問,根據下標隨機訪問的時間複雜度爲 O(1)。

低效的插入和刪除:

插入操作: 長度爲 n的數組,在 第k 位置插入元素。爲了騰出第K位置,意味着需要將第 k~n 這部分的元素都順序地往後挪一位。末位插入O(1),首位插入O(n),平均(1+2+3+…+n)/n=O(n)。

數組元素值有序時,只能逐個挪位置;數組元素無序時,插入K位置,爲了避免k~n的數據搬移,一個簡單的辦法:直接將第 k 位的數據搬移到數組元素的最後,把新的元素直接放入第 k 個位置。複雜度立降爲O(1),快排算法就用到該技巧。

刪除操作:爲了保證內存連續,不留空洞,刪除第K位置元素,k~n元素統統前移。複雜度同插入,也爲O(n)。

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

如下圖,要刪除前三個元素abc

數組刪除

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

這正是 JVM 標記清除垃圾回收算法的核心思想。

容器能否完全替代數組?

以Java中ArrayList舉例, ArrayList可以將很多數組操作的細節封裝起來。如數組增刪元素時的搬移等。另外支持動態擴容。數組本身在定義的時必須預先指定大小,方便分配連續的內存空間。如申請了大小爲 10 的數組,當存儲第 11 個數據時,就需重新分配一塊更大的空間,將原來的數據複製過去,然後再將新的數據插入。

而ArrayList 已經幫我們實現好了擴容邏輯。每次存儲空間不夠的時候,自動1.5x擴容。原始容量爲10,其擴容公式:oldCapacity + (oldCapacity >> 1)

擴容操作涉及內存申請和數據搬移,是比較耗時的。如果事先能確定需要存儲的數據大小,最好在創建 ArrayList 的時候事先指定數據大小

比如要從數據庫中取出 10000 條數據放入 ArrayList。事先指定list大小可以省掉很多次內存申請和數據搬移的耗時操作。

List<UserInfo> list = new ArrayList(10000);
for (int i = 0; i < 10000; ++i) {
  list.add(xxx);
}

何時用到數組?

  1. Java ArrayList 無法存儲基本類型,比如 int、long,需要封裝爲 Integer、Long 類,而 Autoboxing、Unboxing 則有一定的性能消耗,所以如果特別關注性能,或者希望使用基本類型,就可以選用數組。

  2. 如果數據大小事先已知,並且對數據的操作非常簡單,用不到 ArrayList 提供的大部分方法,也可以直接使用數組。

  3. 當要表示多維數組時,用數組往往會更加直觀。比如 Object[][][][];而用容器的話則需要這樣定義:ArrayList<ArrayList>array

數組爲何從0開始?

從數組存儲的內存模型上來看,“下標”最確切的定義應該是“偏移(offset)”。如果用 a 來表示數組的首地址,a[0] 就是偏移爲 0 的位置,也就是首地址,a[k] 就表示偏移 k 個 type_size 的位置,所以計算 a[k] 的內存地址只需要用這個公式:
a[k]_address=base_address+kdata_type_size a[k ]\_address = base\_address + k * data\_type\_size
但是,如果數組從 1 開始計數, a[k] 的內存地址就會變爲:base_address + (k-1)*data_type_size。(k-1)意味着每次隨機訪問數組元素都多了一次減法運算,對於 CPU 來說,就是多了一次減法指令。數組作爲非常基礎的數據結構,通過下標隨機訪問數組元素又是其非常基礎的編程操作,效率的優化就要儘可能做到極致。所以爲了減少一次減法操作,數組選擇了從 0 開始編號,而不是從 1 開始。

當然這都不是壓倒性原因,主要是歷史原因。C採用了0開始,很多語言沿用了,也有例外,Matlab,甚至有負下標的 Python。

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

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