資料結構簡介

來源:互聯網
上載者:User

第一部分:資料結構簡介

 

原文連結:Part 1: An Introduction to Data Structures

 

介紹:
本文是介紹在.Net平台下使用資料結構的系列文章,共分為六部分,這是本文的第一部分.本文試圖考察幾種資料結構,其中有的包含在.Net Framework的基底類別庫中,有的是我們自己建立的.如果你對這些名詞不太熟悉,那麼我們可以把資料結構看作是一種抽象結構或是類,它通常用來組織資料,並提供對資料的操作.最常見並為我們所熟知的資料結構就是數組array,它包含了一組連續的資料,並通過索引進行訪問.

在閱讀本文內容之前,讓我們先看看這六部分的主要內容.如果你有什麼想法,或覺得本文有什麼遺漏之處,希望你通過e-mail(mitchell@4guysfromrolla.com)和我聯絡,共同分享你的思想.假如有時間的話,我很高興將你的建議放到合適的部分,如有必要,可以在這篇系列文章中加上第七部分.

第一部分:首先介紹資料結構在演算法設計中的重要性.決定資料結構的優劣在於其效能.我們將經過嚴格分析資料結構的各種效能.此部分還將介紹.Net Frameword下兩種常用的資料機構:Array 和ArrayList.我們將考察其結構的操作方式及其效率.

第二部分:我們將繼續從更多細節上分析ArrayList結構,同時還將介紹Queue類和Stack類.和ArrayList一樣,Queue和Stack存放的都是一組連續的資料集合,都屬於.Net Framework基底類別庫.與ArrayList不同的是,Stack和Queue只能以預先規定的序列順序讀取其資料(先進先出和先進後出),而ArrayList可以任意擷取資料項目.我們將通過樣本程式來考察Queue,Stack,並通過擴充ArrayList類來實現它們.之後,我們還要分析雜湊表HashTable,它象ArrayList一樣可以直接存取資料,不同的是它以key(字串)為索引.

ArrayList對資料直接讀取和儲存是一種理想的資料結構,同時,它也是支援資料搜尋的候選方案.在第三部分,我們將考察二叉樹結構,對於資料搜尋而言,它比ArrayList更加有效. .Net Framework並不包含此種內建資料結構,因此需要我們自己建立.

二叉樹搜尋的效率受制於插入到樹中的資料的順序.如果我們插入的是有序或近似有序的資料,實際上,它的效率不如ArrayList.為了將這兩種的優勢結合起來,在第四部分,我門將考察一種有趣的隨機資料結構——SkipList. SkipList既保留了二叉樹搜尋的高效率,同時輸入資料的順序對其效率影響甚微.

第五部分我們將注意力轉向通常用來表現圖形的資料結構.圖(graph)是眾多節點以及節點之間邊的集合.舉例來說,地圖就可以圖的形式來表現.城市是節點,公路則是串連節點之間的邊.許多現實問題都可以抽象成圖的形式,因此,圖也是我們經常要用到的資料結構.

最後,第六部分我們將談到reprisent sets(表示集?)和disjoint sets(非關聯集,即交集為空白?)集合是一種無序資料的集中.非關聯集是指它和另外一個集合沒有共同的元素.我們在程式編寫時會經常用到集合和非關聯集.我們將在這一部分中詳細描述它.

資料結構效能分析

當我們在思考一個特別的應用程式或者程式的問題時,多數開發人員(包括我自己)都將興趣集中到演算法上以解決手頭的難題,或者為應用程式加上一個很酷的特色以豐富使用者的經驗.我們似乎很少聽到有人會為他所使用的資料結構而激動不已,嘖嘖讚歎. 然而,用在一個特定演算法中的資料結構能夠很大程度上影響其效能.最常見的例子就是在資料結構中尋找一個元素.在數組中,尋找過程所耗時間是與這個數組中元素的個數是成正比的.採用二叉數或者SkipLists(我找不到合適的翻譯,按前所述,它包含了隨機數的集合,也許看了後面的部分會想到合適的中文),耗時與資料個數比例成線型下降(sub-linear,我又黔驢詞窮了).當我們要搜尋大量的資料時,資料結構的選擇對程式的效能尤其重要,其差別甚至達到數秒,乃至於數分鐘.

既然在演算法中使用的資料結構影響了演算法的效率,因此比較各種資料結構的效率並從中選擇一種更佳的方法就顯得尤為重要.作為開發人員而言,我們首先要關注的是隨著儲存的資料量的增長,資料結構效能是怎樣隨之改變的的?也就是說,每當資料結構中添加一個新元素時,它將怎樣影響資料結構的已耗用時間?

考慮這樣一種情形,我們在程式中使用了System.IO.Directory.GetFiles(路徑)方法以返迴文件的列表,存放到一個特定的字串數組directory中.假設你需要搜尋這個數組以判斷在檔案清單中是否存在XML檔案(即副檔名為.xml的檔案),一種方法是掃描(scan,或者是遍曆)整個數組,當找到XML檔案時,就設定一個標識.代碼可能是這樣:

using System;
using System.Collections;
using System.IO;

public class MyClass
{
   public static void Main()
   {
      string [] fs = Directory.GetFiles(@"C:\Inetpub\wwwroot");
      bool foundXML = false;
      int i = 0;
      for (i = 0; i < fs.Length; i++)
         if (String.Compare(Path.GetExtension(fs[i]), ".xml", true) == 0)
         {
            foundXML = true;
            break;
         }
  
     if (foundXML)
        Console.WriteLine("XML file found - " + fs[i]);
     else
        Console.WriteLine("No XML files found.");
     
   }
}

現在我們來看看最糟糕的一種情況,當這個列表中不存在XML檔案或者XML檔案是在列表的最後,我們將會搜尋完這個數組的所有元素.再來分析一下數組的效率,我們必須問問自己,"假設數組中現有n個元素,如果我添加一個新元素,增長為n+1個元素,那麼新的已耗用時間是多少?(術語"已耗用時間"--running time,不能顧名思義地認為是程式運行所消耗的絕對時間,而指的是程式完成該任務所必須執行的步驟數.以數組而言,已耗用時間特定被認為是訪問數組元素所需執行的步驟數。)要搜尋數組中的一個值,潛在的可能是訪問數組的每一個元素,如果數組中有n+1個元素,就將執行n+1次檢查。那就是說,搜尋數組耗費的時間與數組元素個數成幾何線形比。

當資料結構的長度趨於無窮大時,分析其結構的效率,我們把這種分析方法稱為漸進分析(asymptotic analysis)。漸進分析中常用的符號是大寫的O(big-Oh),以O(n)的形式描述遍曆數組的效能。O是術語學中big-Oh符號的表示,n則代表遍曆數組時隨長度增長而與之線形增長的程式執行步數。

計算代碼塊中演算法的已耗用時間的一種系統方法應遵循以下步驟:

1、判斷組成演算法已耗用時間的步驟。如前所述,對於數組而言,典型的步驟應是對數組進行讀寫訪問的操作。而對於其他資料結構則不盡然。特別地,你應該考慮的是資料結構自身的步驟,而與電腦內部的操作無關。以上面的代碼塊為例,已耗用時間應該只計算訪問數組的次數,而不用考慮建立和初始設定變數以及比較兩個字串是否相等的時間。
2、找到符合計算已耗用時間條件的程式碼。在這些行上面置1。
3、判斷這些置1的行是否包含在迴圈中,如果是,則將1改為1乘上迴圈執行的最大次數。如果嵌套兩重或多重迴圈,繼續對迴圈做相同的乘法。
4、找到對每行寫下的最大值,它就是已耗用時間。

現在我們按照這種步驟來標記上面的代碼塊。首先我們已經能夠確定與計算已耗用時間有關的程式碼,再根據步驟2,在數組fs被訪問的兩行代碼作上標記,一行是數組元素作為String.Compare()方法的參數,一行是在Console.WriteLine()方法中。我們將這兩行標記為1。然後根據步驟3,String.Compare()方法是在迴圈中,最大迴圈次數為n(因為數組長度為n)。因此將該行的標記1改為n。最後,我們得到的已耗用時間就是標記的最大值n,記為O(n)。(譯註:即為資料結構中通常所說的時間複雜度)

O(n),或者說線形時間(linear-time),表示了多種演算法已耗用時間中的一種。其他還有O(log2 n),O(n log 2 n),O(n2),O(2n)等等。我們無須關心這些繁雜的big-Oh記號,只需要知道在括弧中的值越小,則代表資料結構的效能越好。舉例來說,時間複雜度(在這裡我還是覺得用時間複雜度比已耗用時間更能理解)為O(log n)的演算法遠比O(n)更有效率,因為log n<n。

註:

我們需要溫習以下數學知識。在這裡,log a b另外一種表示方法為ay=b。因此,log24=2,因為22=4。Log2n增長速度比單個的n要慢得多,在第三部分我們將考察時間複雜度為O(log2n)的二叉樹結構。(這個注釋沒多大意思啊!)

在這篇系列文章中,我們將計算每一種新的資料結構和它們的漸進操作已耗用時間,並通過相似的操作比較其他資料結構在已耗用時間上的區別。

數組:一種線形的,可以直接存取的,單一資料結構

在程式編寫中,數組是最簡單也是最廣泛使用的資料結構。在所有的程式語言中數組都具備以下共同的屬性:
1.數組的資料存放區在一段連續的記憶體之中;
2.數組的所有元素都必須是同一種資料類型,因此數組又被認為是單一資料結構(homogeneous data structures);
3.數組元素可以直接存取。(在很多資料結構中,這一特點是不必要的。例如,文章第四部分介紹的資料結構SkipList。要訪問SkipList中的特定元素,你必鬚根據搜尋其他元素直到找到搜尋對象為止。然而對於數組而言,如果你知道你要尋找第i個元素,就可以通過arrayName[i]來訪問它。)(譯註:很多語言都規定數組的下標從0開始,因此訪問第i個元素,應為arrayName[i-1])

以下是數組常用的操作:
1.分配空間
2.資料訪問
3.數組空間重分配(Redimensioning)

在C#裡聲明數組時,數組為空白值(null)。下面的代碼建立了一個名為booleanArray的陣列變數,其值為空白(null):

Bool [] boolleanArray;

在使用該數組時,必須用一個特定數字給它分配空間,如下所示:

booleanArray = new bool[10];

通用的表述為:

arrayName = new arrayType[allocationSize];

它將在CLR託管堆裡分配一塊連續的記憶體空間,足以容納資料類型為arrayTypes、個數為allocationSize的數組元素。如果arrayType為實值型別(譯註:如int類型),則有allocationSize個未封箱(unboxed)的arrayType值被建立。如果arrayType為參考型別(譯註:如string類型),則有allocationSize個arrayType參考型別值被建立。(如果你對實值型別和參考型別、託管堆和棧之間的區別不熟悉,請查閱“理解.Net公用類型系統Common Type System”)

為協助理解.Net Framework中數組的內部儲存機制,請看下面的例子:

arrayName = new arrayType[allocationSize];

This allocates a contiguous block of memory in the CLR-managed heap large enough to hold the allocationSize number of arrayTypes. If arrayType is a value type, then allocationSize number of unboxed arrayType values are created. If arrayType is a reference type, then allocationSize number of arrayType references are created. (If you are unfamiliar with the difference between reference and value types and the managed heap versus the stack, check out Understanding .NET's Common Type System.)

To help hammer home how the .NET Framework stores the internals of an array, consider the following example:

bool [] booleanArray;
FileInfo [] files;

booleanArray = new bool[10];
files = new FileInfo[10];

這裡,booleanArray是實值型別System.Boolean數組,而files數組則是參考型別System.IO.FileInfo數組。圖一顯示了執行這四行代碼後CLR託管堆的情況。

 

 
圖一:在託管堆中順序存放數組元素

請記住在files數組中存放的十個元素指向的是FileInfo執行個體。圖二強調了這一點(hammers home this point,有些俚語的感覺,不知道怎麼翻譯),顯示了如果我們為files數組中的FileInfo執行個體分配一些值後記憶體的分布情況。
 


圖二:在託管堆中順序存放數組元素

.Net中所有數組都支援對元素的讀寫操作。訪問數組元素的文法格式如下:

// 讀一個數組元素
bool b = booleanArray[7];

// 寫一個數組元素,即賦值
booleanArray[0] = false;

訪問一個數組元素的已耗用時間表示為O(1),因為對它的訪問時間是不變的。那就是說,不管數組儲存了多少元素,尋找一個元素所花的時間都是相同的。已耗用時間之所以不變,是因為數組元素是連續存放的,尋找定位的時候只需要知道數組在記憶體中的起始位置,每個元素的大小,以及元素的索引值。

在Managed 程式碼中,數組的尋找比實際的實現稍微複雜一些,因為在CLR中訪問每個數組,都要確保索引值在其邊界之內。如果數組索引超出邊界,會拋出IndexOutOfRangeException異常。這種邊界檢查有助於確保我們在訪問數組不至於意外地超出數組邊界而進入另外一塊記憶體區。而且它不會影響數組訪問的時間,因為執行邊界檢查所需時間並不隨數組元素的增加而增加。

註:如果數組元素特別多,索引邊界檢查會對應用程式的執行效能有稍許影響。而對於Unmanaged 程式碼,這種邊界檢查就被忽略了。要瞭解更多資訊,請參考Jeffrey Richter所著的Applied Microsoft .NET Framework Programming第14章。

使用數組時,你也許需要改變數組大小。可以通過根據特定的長度大小建立一個新數組執行個體,並將舊數組的內容拷貝到新數組,來實現該操作。我們稱這一過程為數組空間重分配(redimensioning),如下代碼:

using System;
using System.Collections;

public class MyClass
{
   public static void Main()
   {
      // 建立包含3個元素的int類型數組
      int [] fib = new int[3];
      fib[0] = 1;
      fib[1] = 1;
      fib[2] = 2;
     
      // 重新分配數組,長度為10
      int [] temp = new int[10];

// 將fib數組內容拷貝到臨時數組
      fib.CopyTo(temp, 0);
     
      // 將臨時數組賦給fib
      fib = temp;  
   }
}

在代碼的最後一行,fib指向包含10個元素的Int32類型數組。Fib數組中3到9(譯註:注意下標從0開始)的元素值預設為0(Int32類型)。

當我們要儲存同種類型的資料(原文為heterogeneous types——異質資料類型,我懷疑有誤)並僅需要直接存取資料時,數組是較好的資料結構。搜尋未排序的數組時間複雜度是線形的。當我們對小型數組進行操作,或很少對它進行查詢操作時,數組這種結構是可以接受的。但當你的應用程式需要儲存大量資料,且頻繁進行查詢操作時,有很多其他資料結構更能適應你的工作。我們來看看本文接下來將要介紹的一些資料結構。(如果你要根據某個屬性尋找數組,且數組是根據該屬性進行排序的,你可以使用二叉法(binary search)對其搜尋,它的時間複雜度為O(log n),與在二叉樹中搜尋的時間複雜度相同。事實上,數組類中包含了一個靜態方法BinarySearch()。如要瞭解該方法的更多資訊,請參考我早期的一篇文章“有效地搜尋有序數組”。

註:.Net Framework同樣支援多維陣列。與一維數組一樣,多維陣列對資料元素的訪問已耗用時間仍然是不變的。回想一下我們前面介紹的在n個元素的一維數組中查詢操作的時間複雜度為O(n)。對於一個nxn的二維數組,時間複雜度為O(n2),因為每次搜尋都要檢查n2個元素。以此類推,k維數組搜尋的時間複雜度為O(nk)。

ArrayList:可儲存不同類型資料、自增長的數組

明確地,數組在設計時受到一些限制,因為一維數組只能儲存相同類型的資料,而且在使用數組時,必須為數組定義特定的長度。很多時候,開發人員要求數組更加靈活,它可以儲存不同類型的資料,也不用去關心數組空間的分配。在.Net Framework基底類別庫中提供了滿足這樣條件的資料結構——System.Collections.ArrayList。

如下的一小段代碼是ArrayList的樣本。注意到使用ArrayList時可以添加任意類型的資料,且不需要分配空間。所有的這些都由系統控制。

ArrayList countDown = new ArrayList();
countDown.Add(5);
countDown.Add(4);
countDown.Add(3);
countDown.Add(2);
countDown.Add(1);
countDown.Add("blast off!");
countDown.Add(new ArrayList());

從深層次的含義來講,ArrayList使用的存放類型為object的System.Array對象。既然所有類型都是直接或間接從object派生,自然一個object類型的數組也可以存放任何類型的元素。ArrayList預設建立16個object類型元素的數組,當然我們也可以通過建構函式中的參數或設定Capacity屬性來定製ArrayList大小。通過Add()方法添加新元素,數組內部自動檢查其容量。如果添加新元素導致越界,則容量則自動成倍增加,我們稱為自增長。

ArrayList和Array一樣,也可以通過索引直接存取:

// Read access
int x = (int) countDown[0];
string y = (string) countDown[5];

// Write access
countDown[1] = 5;

// 會產生ArgumentOutOfRange 異常
countDown[7] = 5;

既然ArrayList儲存的是object類型的元素,因此從ArrayList中讀元素時應該顯示的指定類型轉換。同時要注意的是,如果你訪問的數組元素超過ArrayList的長度,系統會拋出System.ArgumentOutOfRange異常。

ArrayList提供了標準數組所不具備的自增長靈活性,但這種靈活性是以犧牲效能為代價的,尤其是當我們儲存的是實值型別——例如System.Int32,System.Double,System.Boolean等。它們在託管堆中是以未封箱形式(unboxed form)連續存放的。然而,ArrayList的內部機制是一個引用的object對象數組;因此,即使ArrayList中只存放了實值型別,這些元素仍然會通過封箱(boxing)轉換為參考型別。三所示:
 

圖三:儲存連續塊的object引用的ArrayList

在ArrayList中使用實值型別,將額外進行封箱(boxing)和撤箱(unboxing)操作,當你的應用程式是一個很大的ArrayList,並頻繁進行讀寫操作時,會很大程度上影響程式效能。3所示,對於參考型別而言,ArrayList和數組的記憶體配置是相同的。

比較數組而言,ArrayList的自增長並不會導致任何效能的下降。如果你知道儲存到ArrayList的元素的準確數量,可以通過ArrayList建構函式初始化容量以關閉其自增長功能。而對於數組,當你不知道具體容量時,不得不在插入的資料元素超過數組長度的時候,手動改變數組的大小。

一個經典的電腦科學問題是:當程式運行時超出了緩衝空間,應該分配多少新的空間為最佳。一種方案是是原來分配空間的基礎上每次加1。例如數組最初分配了5個元素,那麼在插入第6個元素之前,將其長度增加為6。顯然,這種方案最大程度上節約了記憶體空間,但代價太大,因為每插入一個新元素都要進行一次再分配操作。

另一種方案剛好相反,也就是每次分配都在原來大小的基礎上增加100倍。如果數組最初分配了5個元素,那麼在插入第6個元素之前,數組空間增長為500。顯然,該方案大大地減少了再分配操作的次數,但僅當插入極少的資料元素時,就會有上百的元素空間未使用,實在太浪費空間了!

ArrayList的漸近已耗用時間和標準數組一樣。即使對ArrayList的操作是高開銷的,尤其是儲存實值型別,其元素個數和每次操作的代價之間的關係與標準數組相同。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.