線性表結構:數組


什么是數組

數組(Array)是一種線性表數據結構。它用一組連續的內存空間,來存儲一組具有相同類型的數據。對於數組,你要掌握兩個關鍵點。

1. 線性表

線性表就是數據排成像一條線一樣的結構。每個線性表上的數據最多只有前和后兩個方向。其實除了數組,鏈表、隊列、棧等也是線性表結構。

而與它相對立的概念是非線性表,比如二叉樹、堆、圖等。之所以叫非線性,是因為,在非線性表中,數據之間並不是簡單的前后關系。比如說下面樹形結構中的D節點就有三個方向的數據。

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

數組的存儲空間是連續的,而且必須存儲相同類型的數據。正是因為這兩個限制,它才有了一個堪稱“殺手鐧”的特性:“隨機訪問”。但有利就有弊,這兩個限制也讓數組的很多操作變得非常低效,比如要想在數組中刪除、插入一個數據,為了保證連續性,就需要做大量的數據搬移工作。

這邊解釋下隨機訪問的含義。隨機訪問是指通過元素的下標能立馬定位到元素在數組中的位置,隨機查找的時間復雜度為O(1)。
有的人可能把在數組中查找元素和隨機訪問搞混了。在數組中查找元素必須進行數組遍歷,時間復雜度是O(n),即使是排序的數組,通過二分查找,時間復雜度是O(logn)。

低效的“插入”和“刪除”

1. 插入操作

假設數組的長度為 n,現在,如果我們需要將一個數據插入到數組中的第 k 個位置。為了把第 k 個位置騰出來,給新來的數據,我們需要將第 k~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)。(直接將指定位置的元素放到數組最后一位后面array[array.length]=k)

2. 刪除操作
跟插入數據類似,如果我們要刪除第 k 個位置的數據,為了內存的連續性,也需要搬移數據,不然中間就會出現空洞,內存就不連續了。刪除操作的時間復雜度也是O(n)。

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

我們繼續來看例子。數組 a[10]中存儲了 8 個元素:a,b,c,d,e,f,g,h。現在,我們要依次刪除 a,b,c 三個元素。

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

如果你了解 JVM,你會發現,這不就是 JVM 標記清除垃圾回收算法的核心思想

關於數組使用的幾個注意點

  • 小心數組越界訪問(一般在數組的長度范圍內訪問數組元素是沒什么問題的);
  • 小心數組元素為空,數組某個下標位置上的值可能是空的,如果不做判斷的話可能會發生空指針異常。

怎么實現數組這種數據結構

數組是每個編程語言都會直接提供的數據結構。而且很多語言提供了更高級的容器實現,比如Java中的ArrayList。ArrayList 最大的優勢就是可以將很多數組操作的細節封裝起來。比如前面提到的數組插入、刪除數據時需要搬移其他數據等。另外,它還有一個優勢,就是支持動態擴容。

數組本身在定義的時候需要預先指定大小,因為需要分配連續的內存空間。如果我們申請了大小為 10 的數組,當第 11 個數據需要存儲到數組中時,我們就需要重新分配一塊更大的空間,將原來的數據復制過去,然后再將新的數據插入。

如果使用 ArrayList,我們就完全不需要關心底層的擴容邏輯,ArrayList 已經幫我們實現好了。每次存儲空間不夠的時候,它都會將空間自動擴容為 1.5 倍大小。

不過,這里需要注意一點,因為擴容操作涉及內存申請和數據搬移,是比較耗時的。所以,如果事先能確定需要存儲的數據大小,最好在創建 ArrayList 的時候事先指定數據大小。

作為高級語言編程者,是不是數組就無用武之地了呢?當然不是,有些時候,用數組會更合適些,我總結了幾點自己的經驗:

  • Java ArrayList 無法存儲基本類型,比如 int、long,需要封裝為 Integer、Long 類,而 Autoboxing、Unboxing 則有一定的性能消耗,所以如果特別關注性能,或者希望使用基本類型,就可以選用數組。
  • 如果數據大小事先已知,並且對數據的操作非常簡單,用不到 ArrayList 提供的大部分方法,也可以直接使用數組;
  • 還有一個是我個人的喜好,當要表示多維數組時,用數組往往會更加直觀。比如 Object[][] array;而用容器的話則需要這樣定義:ArrayList > array;

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

有趣的知識點

數組的下標為什么從0開始?

其實數組的下標更確切的表述是相對於首地址的偏移量,這樣更容易尋址。

從數組存儲的內存模型上來看,“下標”最確切的定義應該是“偏移(offset)”。前面也講到,如果用 a 來表示數組的首地址,a[0]就是偏移為 0 的位置,也就是首地址,a[k]就表示偏移 k 個 type_size 的位置,所以計算 a[k]的內存地址只需要用這個公式: a[k]_address = base_address + k * type_size 但是,如果數組從 1 開始計數,那我們計算數組元素 a[k]的內存地址就會變為: a[k]_address = base_address + (k-1)*type_size

一些網友留言

1. 數組的一些其他應用

數組的應用真的很多,比如redis的內部實現,壓縮鏈表,快速鏈表,還有后來搞出一個緊湊列表來替代壓縮列表。而且很多自定義協議都是用數組做的,比如rocketmq的協議,前面幾位代表什么,后面幾位代表什么。

2. 標記清除法

標記清除具體步驟如下:

  • 開始標記並程序暫停(stop the world);
  • 找到所有可達對象,並做上標記;
  • 標記完成后開始清除未標記的對象;
  • 清除完成;

其實所有的垃圾收集算法都可以分為:標記階段和收集階段。只是不同的垃圾回收機制在這兩個階段使用的算法不一樣。

標記清除法帶來的問題

  • STW (stop the world) 標記對象的時候程序需要暫停,導致程序出現卡頓,如果經常進行STW操作,程序性能將大幅下降;
  • 標記需要掃描整個堆;
  • 清除對象會產生堆碎片。

STW指的是JVM把所有線程都暫停了,這樣所有的對象都不會被修改,這個時候去掃描是絕對安全的。

3. 畫圖軟件推薦

ipad Paper

4.分代收集法
分代收集算法(針對JDK1.8以下):
根據對象的存活周期分為老年代,新生代,永久代
a、在新生代中,每次GC時都發現有大批對象死去,只有少量存活,使用復制算法。即在垃圾回收時,將正在使用的內存中存活對象復制到另一塊未使用的內存中。之后清理正在使用的內存中所有對象,交換兩塊內存角色。反復進行,完成垃圾回收。
b、在老年代中,因為對象存活率高、沒有額外空間對他進行分配擔保,使用“標記-清理”/“標記-整理”算法。即在標記階段,遍歷所有的GC Roots,然后將所有GC Roots可達的對象標記為存活的對象。清除階段,清除的過程將遍歷堆中所有的對象,將沒有標記的對象全部清除掉。
c、永久代(Permanet Generation)/ 元空間(Metaspace)
永久代用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯后的代碼等數據。是JVM規范中方法區的具體實現。
是Hotspot虛擬機特有的概念,方法區/永久代是非堆內存。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM