數據結構筆記淺記(九)存儲設備

物理結構在很大程度上決定了程序對內存和緩存的使用效率,進而影響算法程序的整體性能。

由於存儲數據的需要長久保存,並且內存的價格比硬盤貴太多,因此內存無法取代硬盤。

緩存的大容量和高速度難以兼得。隨着 L1、L2、L3 緩存的容量逐步增大,其物理尺寸會變大,與 CPU 核心之間的物理距離會變遠,從而導致數據傳輸時間增加,元素訪問延遲變高。

在程序運行時,數據會從硬盤中被讀取到內存中,供 CPU 計算使用。緩存可以看作 CPU 的 一部分,它通過智能地從內存加載數據,給 CPU 提供高速的數據讀取,從而顯著提升程序的執行效率,減少 對較慢的內存的依賴。

在內存空間利用方面,數組和鏈表各自具有優勢和侷限性

         一方面,內存是有限的,且同一塊內存不能被多個程序共享,因此我們希望數據結構能夠儘可能高效地利用 空間。數組的元素緊密排列,不需要額外的空間來存儲鏈表節點間的引用(指針),因此空間效率更高。然而, 數組需要一次性分配足夠的連續內存空間,這可能導致內存浪費,數組擴容也需要額外的時間和空間成本。 相比之下,鏈表以“節點”爲單位進行動態內存分配和回收,提供了更大的靈活性。

         另一方面,在程序運行時,隨着反覆申請與釋放內存,空閒內存的碎片化程度會越來越高,從而導致內存的 利用效率降低。數組由於其連續的存儲方式,相對不容易導致內存碎片化。相反,鏈表的元素是分散存儲的, 在頻繁的插入與刪除操作中,更容易導致內存碎片化。

 

緩存會採取以下數據加載機制

        ‧ 緩存行:緩存不是單個字節地存儲與加載數據,而是以緩存行爲單位。相比於單個字節的傳輸,緩存行 的傳輸形式更加高效。

        ‧ 預取機制:處理器會嘗試預測數據訪問模式(例如順序訪問、固定步長跳躍訪問等),並根據特定模式 將數據加載至緩存之中,從而提升命中率。 ‧ 空間局部性:如果一個數據被訪問,那麼它附近的數據可能近期也會被訪問。因此,緩存在加載某一數 據時,也會加載其附近的數據,以提高命中率。

         ‧ 時間局部性:如果一個數據被訪問,那麼它在不久的將來很可能再次被訪問。緩存利用這一原理,通過 保留最近訪問過的數據來提高命中率。

實際上,數組和鏈表對緩存的利用效率是不同的,主要體現在以下幾個方面。

        ‧ 佔用空間:鏈表元素比數組元素佔用空間更多,導致緩存中容納的有效數據量更少。

        ‧ 緩存行:鏈表數據分散在內存各處,而緩存是“按行加載”的,因此加載到無效數據的比例更高。

        ‧ 預取機制:數組比鏈表的數據訪問模式更具“可預測性”,即系統更容易猜出即將被加載的數據。

        ‧ 空間局部性:數組被存儲在集中的內存空間中,因此被加載數據附近的數據更有可能即將被訪問。

總體而言,數組具有更高的緩存命中率,因此它在操作效率上通常優於鏈表。這使得在解決算法問題時,基 於數組實現的數據結構往往更受歡迎。 需要注意的是,高緩存效率並不意味着數組在所有情況下都優於鏈表。實際應用中選擇哪種數據結構,應根據具體需求來決定

 

 

Q:數組存儲在棧上和存儲在堆上,對時間效率和空間效率是否有影響?

         存儲在棧上和堆上的數組都被存儲在連續內存空間內,數據操作效率基本一致。然而,棧和堆具有各自的特 點,從而導致以下不同點。 1. 分配和釋放效率:棧是一塊較小的內存,分配由編譯器自動完成;而堆內存相對更大,可以在代碼中動 態分配,更容易碎片化。因此,堆上的分配和釋放操作通常比棧上的慢。 2. 大小限制:棧內存相對較小,堆的大小一般受限於可用內存。因此堆更加適合存儲大型數組。 3. 靈活性:棧上的數組的大小需要在編譯時確定,而堆上的數組的大小可以在運行時動態確定。

Q:爲什麼數組要求相同類型的元素,而在鏈表中卻沒有強調相同類型呢?

        鏈表由節點組成,節點之間通過引用(指針)連接,各個節點可以存儲不同類型的數據,例如 int、double、 string、object 等。 相對地,數組元素則必須是相同類型的,這樣才能通過計算偏移量來獲取對應元素位置。例如,數組同時包 含 int 和 long 兩種類型,單個元素分別佔用 4 字節 和 8 字節,此時就不能用以下公式計算偏移量了,因爲 數組中包含了兩種“元素長度”。 # 元素內存地址 = 數組內存地址(首元素內存地址) + 元素長度 * 元素索引

Q:刪除節點後,是否需要把 P.next 設爲 None 呢?

         不修改 P.next 也可以。從該鏈表的角度看,從頭節點遍歷到尾節點已經不會遇到 P 了。這意味着節點 P 已經 從鏈表中刪除了,此時節點 P 指向哪裏都不會對該鏈表產生影響。 從垃圾回收的角度看,對於 Java、Python、Go 等擁有自動垃圾回收機制的語言來說,節點 P 是否被回收取 決於是否仍存在指向它的引用,而不是 P.next 的值。在 C 和 C++ 等語言中,我們需要手動釋放節點內存。

Q:在鏈表中插入和刪除操作的時間複雜度是 𝑂(1) 。但是增刪之前都需要 𝑂(𝑛) 的時間查找元素,那爲什麼時間複雜度不是 𝑂(𝑛) 呢?

         如果是先查找元素、再刪除元素,時間複雜度確實是 𝑂(𝑛) 。然而,鏈表的 𝑂(1) 增刪的優勢可以在其他應 用上得到體現。例如,雙向隊列適合使用鏈表實現,我們維護一個指針變量始終指向頭節點、尾節點,每次 插入與刪除操作都是 𝑂(1) 。

Q:圖“鏈表定義與存儲方式”中,淺藍色的存儲節點指針是佔用一塊內存地址嗎?還是和節點值各佔一半 呢?

         該示意圖只是定性表示,定量表示需要根據具體情況進行分析。 ‧ 不同類型的節點值佔用的空間是不同的,比如 int、long、double 和實例對象等。 ‧ 指針變量佔用的內存空間大小根據所使用的操作系統及編譯環境而定,大多爲 8 字節或 4 字節。

Q:在列表末尾添加元素是否時時刻刻都爲 𝑂(1) ?

         如果添加元素時超出列表長度,則需要先擴容列表再添加。系統會申請一塊新的內存,並將原列表的所有元素搬運過去,這時候時間複雜度就會是 𝑂(𝑛) 。

Q:“列表的出現極大地提高了數組的實用性,但可能導致部分內存空間浪費”,這裏的空間浪費是指額外增加的變量如容量、長度、擴容倍數所佔的內存嗎?

        這裏的空間浪費主要有兩方面含義:一方面,列表都會設定一個初始長度,我們不一定需要用這麼多;另一 方面,爲了防止頻繁擴容,擴容一般會乘以一個係數,比如 ×1.5 。這樣一來,也會出現很多空位,我們通 常不能完全填滿它們。

Q:在 Python 中初始化 n = [1, 2, 3] 後,這 3 個元素的地址是相連的,但是初始化 m = [2, 1, 3] 會發現 它們每個元素的 id 並不是連續的,而是分別跟 n 中的相同。這些元素的地址不連續,那麼 m 還是數組嗎?

        假如把列表元素換成鏈表節點 n = [n1, n2, n3, n4, n5] ,通常情況下這 5 個節點對象也分散存儲在內存 各處。然而,給定一個列表索引,我們仍然可以在 𝑂(1) 時間內獲取節點內存地址,從而訪問到對應的節點。 這是因爲數組中存儲的是節點的引用,而非節點本身。 與許多語言不同,Python 中的數字也被包裝爲對象,列表中存儲的不是數字本身,而是對數字的引用。因 此,我們會發現兩個數組中的相同數字擁有同一個 id ,並且這些數字的內存地址無須連續。

Q:C++ STL 裏面的 std::list 已經實現了雙向鏈表,但好像一些算法書上不怎麼直接使用它,是不是因爲 有什麼侷限性呢?

         一方面,我們往往更青睞使用數組實現算法,而只在必要時才使用鏈表,主要有兩個原因。 ‧ 空間開銷:由於每個元素需要兩個額外的指針(一個用於前一個元素,一個用於後一個元素),所以 std::list 通常比 std::vector 更佔用空間。 ‧ 緩存不友好:由於數據不是連續存放的,因此 std::list 對緩存的利用率較低。一般情況下,std::vector 的性能會更好。 另一方面,必要使用鏈表的情況主要是二叉樹和圖。棧和隊列往往會使用編程語言提供的 stack 和 queue , 而非鏈表。

Q:初始化列表 res = [0] * self.size() 操作,會導致 res 的每個元素引用相同的地址嗎?

        不會。但二維數組會有這個問題,例如初始化二維列表 res = [[0] * self.size()] ,則多次引用了同一個 列表 [0] 。

Q:在刪除節點中,需要斷開該節點與其後繼節點之間的引用指向嗎?

        從數據結構與算法(做題)的角度看,不斷開沒有關係,只要保證程序的邏輯是正確的就行。從標準庫的角度看,斷開更加安全、邏輯更加清晰。如果不斷開,假設被刪除節點未被正常回收,那麼它那麼它會影響後繼節點的內存回收

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