跳至主要内容

面試官:我們真的了解 Array 嗎

· 閱讀時間約 11 分鐘
SD0

無論學習任何語言,我們一定都使用過 陣列 Array。他是程式語言中最常見的存在之一。也是一個最基礎的數據結構。但我們真的了解過他的設計精髓嗎?讓我們一起來看看。

如果你有任何問題,可以透過 Email 聯絡我 support@sd0.tech

如果你想了解更多關於技術、面試與生活,歡迎到 SD0 Tech 參觀!

本文章同步更新至 Medium

最近開始審視自己所學的基礎,把這些想法分享給大家。

定義

首先,我們需要先了解陣列 Array 的定義。

在計算機科學中,陣列資料結構(英語:array data structure),簡稱數組(英語:Array),是由相同類型的元素(element)的集合所組成的資料結構,分配一塊連續的內存來存儲。利用元素的索引(index)可以計算出該元素對應的儲存地址。— Wiki 維基百科

從 Wiki 維基百科我們可以了解陣列有幾個特性:

  1. 相同類型的元素,比如 Int String
  2. 分配一塊連續的內存
  3. 利用索引(index)進行訪問

相同類型的元素,我想大家應該沒什麼特別的問題。聰明的你可能想到了,那泛型(Generic)算是相同的元素嗎?這題很有趣,我們先留到之後探討。

我們先來聊聊分配一塊連續的內存與利用索引(index)進行訪問

一塊連續的內存與利用索引(index)進行訪問

不知道你有沒有好奇過,為什麼陣列總是以 0 作為起始?這樣不是很反直覺嗎。你可能會想,電腦本來就是從 0 開始的工程。不過,今天我想以另外一個角度的思考分享給你。

當分配一塊連續的內存時,我們到底在指什麼?舉個例子。

假設我們宣告 var digitCounts = Array(repeating: 0, count: 3) ,於是我們可以得到一組陣列 [0, 0, 0] 我們假設電腦分配的內存空間是 1000 - 1015 ,代表陣列的開始位置為 1000 ,即 base_address = 1000

那問題來了,如果我們要訪問陣列中的某個元素,電腦會如何處理他呢?我們理解,電腦會有一套尋址公式,來尋找到我們要訪問的對象。

我們要訪問的對象[i] 的內存地址 = 陣列起始位置內存地址 + i x 資料型別大小

再公式化一點就是:

a[i]’s address = base’s address + i * data type’s size

在我們的例子中,IntSize4byte(這邊假設是 Int32 ),所以如果 i = 2 他在內存中的位置就是:

1000 + 2 x 4 = 1008 - 1011

陣列地址-1.jpeg

所以對電腦而言,一塊連續的內存與利用索引(index)進行訪問的基礎原理來自於內存記憶體的尋址方式。或者說是一個相輔相成的設計原理。

題外話,如果我們希望陣列從 1 開始,那我們的尋址方式會有什麼改變?應該會變成:

a[i]’s address = base’s address + (i - 1) * data type’s size

最直觀上的說,對於電腦 CPU 來說,就是多了一個減法指令,對於底層核心性能來說,可能是無法被接受的。

陣列 Array訪問的時間複雜度?

通過前面的描述,我們可以理解到陣列可以透過 a[2] 這種方式進行隨機訪問。訪問的時間複雜度為 O(1) 。這邊要特別注意一點,千萬別把訪問與搜索混為一談,如果要對於陣列進行搜索指定數值,最快的時間複雜度也是 O(logn) (因為基於二分查找)。所以如果在面試中,最好表達清楚,陣列 Array 支持根據 index 隨機訪問,時間複雜度為 O(1)

接下來我們來聊聊陣列 Array 的新增與刪除元素。

陣列 Array 的新增與刪除元素

我們知道陣列 Array 通常支持新增與刪除元素,在 Swift 中我們會使用 arr.append(item) 操作新增一個元素,arr.remove(at: 2) 表示刪除 index = 2 的元素。

那我們使用新增的時間複雜度為何?很直觀地想,我們將一個元素新增到陣列末尾,那他的時間複雜度應該是 O(1) 吧?沒錯,在陣列 Array 容量足夠的情況下,加入到陣列 Array 末尾,時間複雜度為 O(1) ,但如果陣列空間已經滿了呢?

這邊要牽涉到另外一個問題,陣列 Array 的擴容。

陣列 Array 的擴容

Swift 中,需要進行擴容時,會以原有的基礎進行兩倍擴容。比如原本陣列 Array 數量為 2 當要加入第 3 個元素時,Swift 會開啟新的陣列,長度為 4 ,並且將舊資料複製過去,再加入新元素。雖然這麼做會浪費一點空間,但是會節省一些擴容的性能消耗。

綜上所述,我們知道 陣列 Array 的新增元素並不是所有時刻都是 O(1) 的,取決於是否需要擴容。了解了這一點,對於我們撰寫程式一定會更加有概念以及注意。

IMG_0DFB4A761F87-1.jpeg

陣列 Array 的刪除元素

如果我們要刪除陣列 Array 中的元素,電腦需要將刪除指定 index 之後的數據進行搬移。簡單來說,要刪除 index = 2 的元素時,電腦需要將 2 之後的數據都進行搬移,以確保內存記憶體的連續性。所以刪除數據的時間複雜度是 O(n) 的。我們可以試想最好的情況,就是後面沒有數據,那在這個情況下,就不用進行搬移,時間複雜度就是 O(1)

不過,在實際上,因為每次進行搬移會消耗太多性能。當遇到一些連續刪除操作時,為了避免連續搬移帶來的性能損耗,通常會先進行標記刪除,當真的沒有更多的空間時,再一次做刪除並且搬移。

比如現在有一個陣列 [a, b, c, d, e] ,我們今天連續執行刪除 index = 0index = 1 ,此時在內存上不一定真的已經刪除,有可能是標記刪除,當我們進行 append 操作時,因為容量已滿,才會將標記刪除的項目刪除。

IMG_F68CCF571B0A-1.jpeg

陣列的插入元素

試著想一個情況,當我們要插入一個元素時,電腦會如何運行?當然,如果這個插入操作是插入到陣列 Array 的末尾,那就相當於新增操作,時間複雜度為 O(1)

如果要將元素插入到第 K 個位置呢?

此時,電腦需要將 K 個位置之後的元素都往後挪,然後再把元素放置到這個位置。那時間複雜度就是 O(n) 的。

那我們可以避免這種搬移帶來的性能損失嗎?答案是可以的。

如果陣列 Array 不需要排序,我們可以將第 K 位原有的數據直接移動到陣列 Array 的末尾。然後將第 K 位的數據直接取代成要插入的數據,在這種情況下,時間複雜度就是 O(1)

Out of Index

剛開始接觸程式的朋友,一定多多少少都看過這個錯誤 Out of Index ,他的意思是你的訪問越界了。我們在撰寫程式,甚至在生活中,一定要注意邊界。邊界可以幫助我們避免很多試錯的成本。在計算機科學中,也有很多系統漏洞因為越界而產生的。

總結

在準備新工作面試的這段時間裡,我重新認識了陣列 Array。我理解到了解原理是很重要的,他不但會讓你知道這些東西的工作方式,也了解到邊界在哪裡,我們就可以在邊界裡發揮創意與成果。今天我們就聊到這裡,感謝你的閱讀,我們下次見!