簡體   English   中英

使用向量類實現堆棧的鏈表與動態數組

[英]Linked list vs dynamic array for implementing a stack using vector class

我正在閱讀實現堆棧的兩種不同方法:鏈表和動態數組。 鏈表相對於動態數組的主要優點是鏈表不必調整大小,而如果插入的元素過多,則必須調整動態數組的大小,從而浪費大量時間和內存。

這讓我想知道這是否適用於 C++(因為有一個向量類,它會在插入新元素時自動調整大小)?

很難比較兩者,因為它們的內存使用模式非常不同。

矢量調整大小

矢量根據需要動態調整自身大小。 它通過分配一個新的內存塊,將數據從舊塊移動(或復制)到新塊,釋放舊塊來實現。 在典型的情況下,新塊的大小是舊塊的 1.5 倍(與流行的看法相反,2 倍在實踐中似乎很不尋常)。 這意味着在重新分配的短時間內,它需要的內存大約是您實際存儲的數據的 2.5 倍。 其余時間,正在使用的“塊”最少為 2/3 rds滿,最多為完全滿。 如果所有尺寸的可能性均等,我們可以預期它的平均填充率約為 5/6。 從另一個角度看,我們可以預計大約1/6,或約17的空間%被“浪費”在任何給定的時間。

當我們按照這樣的常數因子調整大小時(而不是,例如,總是添加特定大小的塊,例如以 4Kb 的增量增長),我們得到了所謂的攤銷常數時間添加。 換句話說,隨着數組的增長,調整大小的頻率呈指數下降。 數組中項目被復制的平均次數趨於恆定(通常約為 3,但取決於您使用的增長因子)。

鏈表分配

使用鏈表,情況就大不相同了。 我們從未看到調整大小,因此我們看不到某些插入的額外時間或內存使用。 與此同時,我們確實看到額外的時間和內存基本上一直在使用。 特別是,鏈表中的每個節點都需要包含一個指向下一個節點的指針。 根據節點中數據的大小與指針的大小相比,這可能會導致顯着的開銷。 例如,假設您需要一堆int int與指針大小相同的典型情況下,這將意味着 50% 的開銷——一直是。 指針大於int的情況越來越常見; 兩倍的大小相當常見(64 位指針,32 位 int)。 在這種情況下,您有大約 67% 的開銷——即,很明顯,每個節點為指針提供的空間是存儲數據的兩倍。

不幸的是,這通常只是冰山一角。 在典型的鏈表中,每個節點都是單獨動態分配的。 至少,如果您要存儲小數據項(例如int ),則為節點分配的內存可能(通常會)甚至大於您實際請求的數量。 所以——你要求 12 字節的內存來保存一個 int 和一個指針——但是你得到的內存塊很可能會被四舍五入到 16 或 32 字節。 現在您看到的開銷至少為 75%,很可能約為 88%。

就速度而言,情況相當相似:動態分配和釋放內存通常很慢。 堆管理器通常具有空閑內存塊,並且必須花時間搜索它們以找到最適合您要求的大小的塊。 然后它(通常)必須將該塊分成兩部分,一個用於滿足您的分配,另一個用於滿足其他分配。 同樣,當您釋放內存時,它通常會返回到相同的空閑塊列表並檢查是否有相鄰的內存塊已經空閑,因此它可以將兩者重新連接在一起。

分配和管理大量內存塊的成本很高。

緩存使用

最后,對於最近的處理器,我們遇到了另一個重要因素:緩存使用。 在向量的情況下,我們擁有彼此相鄰的所有數據。 然后,在使用的向量部分結束后,我們有一些空內存。 這導致了出色的緩存使用——我們使用的數據被緩存; 我們沒有使用的數據對緩存幾乎沒有影響。

使用鏈表,指針(以及每個節點中可能的開銷)分布在整個鏈表中。 即,我們關心的每條數據旁邊都有指針的開銷,以及分配給我們沒有使用的節點的空白空間。 總之,高速緩存的有效尺寸降低了大約為列表中的每個節點的總開銷相同的因素-也就是說,我們可能很容易看到的只有1/8緩存的存儲我們關心的日期, 7/8 ths專門用於存儲指針和/或純垃圾。

概括

當您的節點數量相對較少時,鏈表可以很好地工作,每個節點都非常大。 如果(這是一個堆棧更典型的),你所面對的是相對大量的項目,每一個都是單獨相當小,你就不太可能的看到在時間或內存使用儲蓄。 恰恰相反,對於這種情況,鏈表更有可能基本上浪費大量時間和內存。

是的,您所說的對於 C++ 來說是正確的。 為此, std::stack的默認容器(C++ 中的標准堆棧類)既不是向量也不是鏈表,而是雙端隊列( deque )。 這幾乎具有矢量的所有優點,但它調整大小要好得多。

基本上, std::deque是內部排序數組鏈表 這樣,當它需要調整大小時,它只會添加另一個數組。

首先,鏈表和動態數組之間的性能權衡比這要微妙得多。

根據要求,C++ 中的向量類實現為“動態數組”,這意味着它必須具有向其中插入元素的攤銷常數成本。 如何做到這一點通常是通過以幾何方式增加陣列的“容量”,也就是說,每當您用完(或接近用完)時,您就將容量加倍。 最后,這意味着重新分配操作(分配新的內存塊並將當前內容復制到其中)只會在少數情況下發生。 實際上,這意味着重新分配的開銷僅在性能圖上顯示為對數間隔的小峰值。 這就是具有“攤銷不變”成本的含義,因為一旦您忽略了那些小尖峰,插入操作的成本基本上是不變的(在這種情況下是微不足道的)。

在鏈表實現中,您沒有重新分配的開銷,但是,您確實有在 freestore(動態內存)上分配每個新元素的開銷。 因此,開銷有點規律(不是尖峰,有時可能需要),但可能比使用動態數組更重要,特別是如果元素的復制成本相當低(尺寸小,對象簡單)。 在我看來,鏈表只推薦用於復制(或移動)成本非常高的對象。 但歸根結底,這是您需要在任何給定情況下進行測試的內容。

最后,重要的是要指出,對於任何廣泛使用和遍歷元素的應用程序,引用的位置通常是決定因素。 使用動態數組時,元素一個接一個地打包在內存中,按順序遍歷非常有效,因為 CPU 可以在讀/寫操作之前搶先緩存內存。 在普通的鏈表實現中,從一個元素到下一個元素的跳轉通常涉及在截然不同的內存位置之間相當不穩定的跳轉,這有效地禁用了這種“預取”行為。 因此,除非列表的單個元素非常大並且對它們的操作通常需要很長時間才能執行,否則在使用鏈表時缺少預取將成為主要的性能問題。

你可以猜到,我很少使用鏈表( std::list ),因為有利的應用程序的數量很少。 很多時候,對於大而昂貴的復制對象,通常更可取的做法是簡單地使用指針向量(您獲得與鏈表基本相同的性能優勢(和劣勢),但內存使用較少(用於鏈接指針) ),如果需要,您可以獲得隨機訪問功能)。

我能想到的主要情況是,當您需要經常在中間(而不是兩端)插入元素時,鏈表勝過動態數組(或像std::deque這樣的分段動態數組)。 但是,當您保留一組已排序(或以某種方式排序)的元素時,通常會出現這種情況,在這種情況下,您將使用樹結構來存儲元素(例如,二叉搜索樹 (BST)),不是鏈表。 並且通常,這樣的樹使用動態陣列或分段動態陣列(例如,高速緩存遺忘的動態陣列)內的半連續存儲器布局(例如,廣度優先布局)來存儲它們的節點(元素)。

是的, C++或任何其他語言都是如此。 動態數組是一個概念 C++ 具有vector的事實並沒有改變理論。 C++的向量實際上在內部進行大小調整,因此此任務不是開發人員的責任。 使用vector ,實際成本不會神奇地消失,它只是簡單地卸載到標准庫實現中。

std::vector使用動態數組實現,而std::list實現為鏈表。 使用這兩種數據結構需要權衡。 選擇最適合您需求的一種。

  • 正如您所指出的,如果動態數組已滿,則添加項目可能需要更多時間,因為它必須自行擴展。 但是,由於其所有成員都在內存中組合在一起,因此訪問速度更快。 這種緊密的分組通常也使它對緩存更友好。

  • 鏈表永遠不需要調整大小,但遍歷它們需要更長的時間,因為 CPU 必須在內存中跳轉。

這讓我想知道這是否適用於 C++,因為有一個向量類,它會在插入新元素時自動調整大小。

是的,它仍然成立,因為vector調整大小是一項潛在的昂貴操作。 在內部,如果達到向量的預分配大小並且您嘗試添加新元素,則會發生新分配並將舊數據移動到新內存位置。

C++ 文檔

vector::push_back - 在最后添加元素

在向量的末尾添加一個新元素,在其當前最后一個元素之后。 val 的內容被復制(或移動)到新元素。

這有效地將容器大小增加了 1,當且僅當新向量大小超過當前向量容量時,這會導致自動重新分配已分配的存儲空間。

http://channel9.msdn.com/Events/GoingNative/GoingNative-2012/Keynote-Bjarne-Stroustrup-Cpp11-Style跳至 44:40。 正如 Bjarne 本人在視頻中所解釋的那樣,您應該盡可能選擇std::vector而不是std::list 由於std::vector將所有元素std::vector存儲在內存中,因此它將具有緩存在內存中的優勢。 這適用於從std::vector添加和刪​​除元素以及搜索。 他說std::liststd::vector慢 50-100 倍。

如果你真的想要一個堆棧,你真的應該使用std::stack而不是自己制作。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM