考察數據結構——第一部分:數據結構簡介[譯]

考察數據結構——第三部分:二叉樹和BSTs

第一部分:數據結構簡介

<?xml:namespace prefix = o ns = "urn:schemas-microsoft-com:office:office" />

 

原文鏈接:Part 1: An Introduction to Data Structures

 

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

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

第一部分:首先介紹數據結構在算法設計中的重要性.決定數據結構的優劣在於其性能.我們將經過嚴格分析數據結構的各種性能.此部分還將介紹.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=4Log2n增長速度比單個的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託管堆的情況。

<?xml:namespace prefix = v ns = "urn:schemas-microsoft-com:vml" /> 1-1.gif
 
圖一:在託管堆中順序存放數組元素

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

1-2.gif
圖二:在託管堆中順序存放數組元素


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

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

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

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

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

注:如果數組元素特別多,索引邊界檢查會對應用程序的執行性能有稍許影響。而對於非託管代碼,這種邊界檢查就被忽略了。要了解更多信息,請參考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的操作是高開銷的,尤其是存儲值類型,其元素個數和每次操作的代價之間的關係與標準數組相同。

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