最基礎的數據結構-左輕侯

做者:左輕侯  程序員

建立時間:2007-03-04 22:29:06   最後修改時間:2008-01-18 22:07:52  算法

本文發表於《程序員》2007年第3期 
   
  最基礎的數據結構 
  左輕侯 
  2007.2.3 
   
  引言 
   
   任何一個受過專業訓練的程序員,對「數據結構」這門課程中涉及到的各類數據結構都不會感到陌生。可是,在實際的編程工做中,大部分的數據結構都不會用到,並且也許永遠都不會用到。形成這種現象的緣由有二:一是根據80/20法則,經常使用的數據結構只會佔到少部分;二是計算機語言每每已經對經常使用的數據結構進行了良好的封裝,程序員不須要關心內部的實現。 
   雖然如此,深刻地理解基本數據結構的概念和實現細節,仍然是每個程序員的任務。這不只是由於,掌握這些知識,將有利於更加正確和靈活地應用它們,並且也是由於,對於語言背後的實現細節的求知慾,是一個優秀的程序員的素質。 
   本文將討論實際編程最常用的三種數據結構:字符串、數組和Hash表,比較它們在不一樣語言中的實現思路,並涉及它們的使用技巧。 
   
  字符串 
   嚴格地說,字符串(string)甚至不能算做一種單獨的數據結構,至少在C語言中,它僅僅是某種特定類型的數組而已。可是,字符串在實際使用中是如此重要,在不一樣語言中的實現又差別頗大,所以,它值得被做爲一種抽象數據類型單獨進行討論,而且在咱們討論的三種結構中排名第一。 
   最經典的字符串實現,應該是C語言中的零終結(null-terminated)字符串。如上所述,C風格的字符串實質上是一個字符數組,它依次存放字符串中的每一個字符,最後以零字符(’\\0’,表示爲常量null)做爲結束。所以,字符串佔據的空間比它實際的長度要多1個單元。在實際應用中,它常以數組或字符指針的形式被定義,以下例: 
   
  char[] message = 「this is a message」; 
  char* pmessage = 「an other message」; 
   
   C語言中,字符串並非一種獨立的數據類型,也沒有提供將字符串做爲一個總體進行處理的運算符。對字符串的全部操做,實際上都是經過對字符數組的操做來完成。 
   試想一個函數,功能是求C風格字符串的長度。實現的思路是:設置一個計數器,而後用一個指針遍歷整個字符數組,同時對計數器進行累加,直到字符串結束(指針指向了null)。實際上,C語言中的strlen函數也是這麼實現的。這種方式看上去很是合理,可是在處理一個很是大的字符數組時,會遭遇到嚴重的性能問題。若是一個字符串長達數M甚至更大,那麼求其長度的操做,須要執行數百萬次甚至更長的循環。更糟糕的是,因爲這個結果沒有被緩存,因此每次求長度的操做都會重複執行這些循環。 
   C風格字符串的另外一個缺陷是,它不會自動管理內存。這意味着,若是字符串的長度超出了數組可以容納的範圍,程序員必須手動申請新的內存空間,並將原來的內容複製過去。這種方式不但產生了大量無謂的工做,並且是無數臭名昭著的溢出漏洞的緣由。一個最簡單的例子是,當一個程序要求用戶輸入一個字符串時,若是用戶輸入的字符串的長度大於程序設定的緩衝區的長度,將會致使溢出,最終程序會崩潰。 
   針對C風格字符串的這些缺陷,新的語言進行了相應的改進。做爲C的直接繼承者,C++語言在標準庫中提供了一個基礎字符串的實現:std :: basic_string。它封裝了大量常見的操做,例如取長度、比較、插入、拼接、查找、替換等等,而且可以自動管理內存。例如,因爲C++支持運算符重載,所以C++字符串可使用運算符直接進行運算,而不須要調用strcpy函數。另外,C++字符串也提供了與C風格字符串進行轉換的功能。基於強大的模板機制,C++字符串將字符串的實現和具體的字符類型分離開來了。下面是兩種最多見的字符串類型: 
   
  typedef basic_string<char> string; // 定義了ansi類型的字符串 
  typedef basic_string<wchar_t> wstring; // 定義了寬字符類型的字符串 
   
   不幸的是,因爲複雜的歷史緣由,許多C++方言(例如Visual C++和Borland C++Builder)都提供了與標準字符串不一樣的字符串實現。這些字符串實現各有長處,可是將它們和C++標準字符串以及C風格字符串進行轉換,又成爲了一項使人頭疼的工做。 
   Delphi對字符串的改進基於另一種思路。在Delphi中,字符串仍然是一種基本類型,而不是類。它的實現方式也是字符數組,不一樣於C風格字符串的是,在數組的頭部增長了兩個32位整數存儲空間,分別用於存放字符串的長度和引用計數。經過前者能夠方便地得到字符串的長度,而不須要進行無謂的遍歷操做。後者實現了COW(Copy on Write)技術,這種技術的效果是:當字符串被複制時,並不會複製其內容,而只是創建一個新的指針,指向原有的字符串,並在引用計數上加一。當字符串被刪除時,引用計數減一,當引用計數爲0時,字符串的內存將被釋放。只有當對字符串進行寫入操做時,纔會創建一個新的字符串並複製內容。這些工做是由編譯器自動完成的,程序員徹底能夠象使用C風格字符串同樣使用Delphi風格的字符串,只是效率大大地提升了。 
   Java和C#中的字符串,是一個封裝了常見操做的類,這一點和C++相似。一個特殊之處(每每致使經典的性能問題)是,不管是在Java仍是在C#中,String類都是不變(immutable)的。也就是說,String的內容不可以被改變,若是代碼試圖改變一個String對象的內容,實際的結果是創建了一個新的String對象,並拋棄舊的對象。以下例: 
   
  String s = \"\"; 
  for (int i = 0;i < 10000;i++) { 
   s += i + \", \"; 
  } 
   
   結果是創建並拋棄了10000個String對象,這在性能上的開銷是驚人的。爲了不這種狀況,應該使用StringBuilder對象,它能夠改變其內容。(C#一直使用StringBuilder。Java從1.5開始引入StringBuilder以部分替代StringBuffer,它們的主要區別在於線程安全性。)以下例: 
   
  StringBuilder sb = new StringBuilder(); 
  for (int i = 0; i < 10000; i++) { 
  sb.append(i + \",\"); 
  } 
   
  數組 
   從抽象數據類型的意義上來講,一維數組(array)的定義是:具備相同數據類型的若干個元素的有限序列。 
   在C語言中,數組意味着一塊連續的內存空間,按順序存放着若干個相同數據類型的元素。能夠經過下標來訪問數組中的元素。以下例: 
   
  int a[10]; // 定義一個int型的數組 
  for (int i = 0;i < 10;i++) { 
   a[i] = i; // 賦值 
  } 
   
   在C語言中,數組名事實上是一個指針(指向該數組的第一個元素),所以全部經過數組下標完成的操做,均可以經過指針來完成。經過指針來訪問數組,效率上比數組下標要高,並且更加靈活,例如,指針能夠進行偏移量的運算,甚至能夠進行絕對地址的存取。 
   C語言中的數組沒有越界檢查,這意味着,程序員能夠訪問數組最後一個元素之後的地址,或者第一個元素以前的地址(例如,a[-1]、a[-2]這種形式是合法的)。在某些狀況下,這是一種有用的技巧,但大多數狀況下是一場災難。C語言的數組也不支持自動增加,若是數組的長度發生了變化,程序員必須手動處理全部關於申請和釋放內存的工做。 
   C++提供了C風格的數組,一樣不支持越界檢查和自動增加。可是,C++(至少是Stroustrup博士本人)建議,應該儘可能使用STL中的容器做爲替代品,通常是vector。Vector基於面向對象和模板技術,構建了一個強大而複雜的類,實現了以下特性:高效率的自動內存管理;按任何順序訪問、插入和刪除元素;越界檢查,但同時也提供了不進行檢查的訪問方式,以照顧性能上的考慮;基於運算符重載技術的運算符支持;基於迭代器的漫遊機制;與數據類型無關的算法支持;等等。相對於C風格的數組,vector是一種更高抽象層次上的序列概念。它對大量經常使用的功能進行了封裝(例如,對內存的直接操做),同時又儘量地照顧了效率和可移植性(例如,在自動擴充時經過緩存機制來提升效率)。這也正是C++語言對C語言進行改進時的指導思想。 
   Delphi也支持C風格的數組,但提供了越界檢查。另外,Delphi還提供了一種動態數組(Dynamic Array),能夠在運行時經過SetLength函數動態地改變它的大小。事實上,SetLength函數就是對內存管理操做的一種封裝。相似於C++中的vector,Delphi也提供了兩個能夠自動增加的容器:TList和TObjectList,前者用於存放無類型的指針,後者用於存放對象。因爲Delphi不支持模板機制,因此TList不會自動釋放指針所指向的內存,它只會維護指針自身佔用的內存(TObjectList可以在銷燬時自動釋放元素所佔用的空間,若是它的OwnsObjects屬性被設置爲True的話)。一種經常使用的解決方法是,編寫一個針對具體類型的包裹類,使用一個做爲私有數據成員的TList對象來管理指針,並手動編寫申請和釋放內存的那部分代碼。這樣總比C語言中的狀況要好得多。 
   Java也支持加上了越界檢查的C風格數組,但它提供的相似容器更爲引人注目。Java將序列(List)做爲一個單獨的接口提取出來,並提供了兩個實現:ArrayList和LinkedList。從名字就能夠看出來,前者是經過數組來實現的,後者則經過鏈表。因爲都實現了List接口,兩者能夠支持一樣的基本操做方式,不一樣的是,ArrayList在頻繁進行隨機訪問時有效率上的優點,而LinkedList在頻繁進行插入和刪除操做時效率較優。實現了List接口的類還有Vector和Stack,可是它們在Java 1.1之後就被廢棄了。因爲LinkedList能夠在序列的頭尾插入和刪除元素,它能夠很好地實現Stack和Queue的功能。 
   Java在1.5之前的版本中也不支持模板,所以List(以及其餘的容器)接受Object類型做爲元素。因爲在Java中全部的類都派生自Object,因此這些容器可以支持任何對象。對於不是對象的基本類型,Java提供了一種包裹類(wrapped class),它可以將基本類型轉換成常規的類,從而得到容器的支持。這和Delphi的解決思路殊途同歸。 
   
  Hash表 
   做爲一種抽象數據結構,詞典(Dictionary)被定義爲鍵-值(Key-Value)對的集合。舉例來講,在電話號碼簿中,經過查找姓名,來找到電話號碼,這個例子中姓名是key,電話號碼是value。又好比,在學生花名冊中,經過查找學號,來找到學生的姓名,這個例子中學號是key,學生的姓名是value。詞典最多見的實現方式是Hash表。 
   Hash表的實現思路以下:經過某種算法,在鍵-值對的存儲地址和鍵-值對中的key之間,創建一種映射,使得每個key,都有一個肯定的存儲地址與之對應。這種算法被封裝在Hash函數中。在查找時,經過Hash函數,算出和key對應的存儲地址,從而找到相應的鍵-值對。相對於經過遍歷整個鍵-值對列表來進行查找,Hash表的查找效率要高得多,理想的狀況下算法複雜度僅爲O(1)(遍歷查找的複雜度爲O(n))。 
  可是,因爲一般狀況下key的集合比鍵-值對存儲地址的集合要大得多,因此有可能把不一樣的key映射到同一個存儲地址上。這種狀況稱爲衝突(collision)。一個好的Hash函數應該儘量地把key映射到均勻的地址空間中,以減小衝突。Hash表的實現也應該提供解決衝突的方案。 
  Hash表是一種相對複雜得多的數據結構,從底層完整地實現一個Hash表,也許超出了對一個普通程序員的要求。可是,因爲它是如此重要,瞭解Hash表的概念和掌握使用它的接口,仍然是一項必不可少的技能。 
   C語言中沒有提供現成的Hash表,可是C++提供了優秀的Hash表實現容器hash_map。象STL中的其餘容器同樣,hash_map支持任何數據類型,支持內存自動管理,可以自動增加。特別地,hash_map經過模板機制,實現了和hash函數的剝離,也就是說,程序員能夠定義本身的hash函數,交給hash_map去進行相應的工做。以下例: 
   
  hash_map <string, int> hml; // 使用默認的Hash<string>函數 
  hash_map <string, int, hfct> hml; // 使用自定義的hfct()做爲hash函數 
  hash_map <string, int, hfct, eql> hml; // 使用自定義的hfct()做爲hash函數,而且使用自定義的eql()函數比較對象是否相等 
   
   Java定義了Map接口,抽象了關於Map的各類操做。在實現了Map接口的類中,有兩種是Hash表:HashMap和WeakHashMap(HashTable在Java 1.1之後已被廢棄)。後者用於實現所謂「標準映射」(canonicalizing mappings),和本文討論的內容關係不大。HashMap接受任何類型的對象做爲鍵-值對的元素,支持快速的查找。以下例: 
   
  HashMap hm = new HashMap(); 
  hm.put(\"akey\", \"this is a word\"); // 使用兩個字符串做爲鍵-值對 
  String str = (String) hm.get(\"akey\"); 
  System.out.println(str); 
   
  HashMap和hash函數也是剝離的,但使用了另外一種思路。在Java中,根類型Object定義了hashCode()和equals()方法,因爲任何類型的對象都派生自Object,因此它們都自動繼承了這兩個方法。用戶自定義的類應該重載這兩個方法,以實現本身的hash函數和比較函數。若是這兩個函數沒有被重載,Java會使用Object的hashCode()和equals()方法,它們的默認實現分別是返回對象的地址,以及比較兩個對象的地址是否相等。 
  在PHP中,數組和Hash表合而爲一了。從語法上看,PHP中並無Hash表這樣的容器,而只支持數組。不一樣的是,PHP中的數組不但支持使用數字下標進行索引,並且支持使用字符串下標進行索引。換句話說,PHP中的數組支持使用鍵-值對做爲數組的元素,而且可使用鍵來進行索引(鍵必須爲integer類型或string類型)。並且,PHP中的數組支持自動增加和嵌套。以下例: 
   
  $arr = array(1 => 12, \"akey\" => \"this is a word\"); 
  echo $arr[1]; // 獲得12 
  echo $arr[\"akey\"]; // 獲得\"this is a word\" 
   
   PHP沒有提供自定義hash函數的接口。因爲它不接受integer和string之外的類型做爲鍵,這一點事實上也沒有必要。 
   
  結束語 
   當接受這篇文章的約稿時,我認爲這是一項比較簡單的工做。由於這三種數據結構實在是太基礎了,因此我甚至懷疑是否可以寫出足夠長的篇幅。很快我就發現了本身的錯誤。光是字符串就夠寫一本書的。 
   在撰寫本文的過程,我回顧了學習過的大部分編程語言,重溫了許多經典書籍中的相關章節,啓動了各類IDE編寫測試用例。我接觸到了大量未知的領域,至今我仍然在猜想許多問題的實現細節。這從另一個方面說明了基本數據結構的重要性:即便在咱們最熟悉的事物中,也隱藏着極爲深入的原理。 
   
  參考文獻: 
  K&R,C程序設計語言,第二版 
  Bjarne Stroustrup,C++程序設計語言,第三版 
  Koenig & Moo,C++沉思錄 
  Delphi Language Guide 
  Bruce Eckel,Thinking in Java,第二版 
  McLaughlin & Flanagan,Java 5.0 Tiger程序高手祕笈 
  Jesse Liberty,Programming C# 
  W. Gilmore,PHP與MySQL 5程序設計 
  Lutz & David Ascher,Learning Python,第二版 
  Alex Martelli,Python in a Nutshell,第二版 
  Introduction to Algorithms,第二版 
  殷人昆等,數據結構(用面向對象和C++描述) 
  Joel Spolsky,Joel說軟件 shell

相關文章
相關標籤/搜索