鏈表vector


根據邏輯次序的復雜程度,大致可以將各種數據結構划分為線性結構半線性結構非線性結構三大類。

在線性結構中,各數據項按照一個線性次序構成一個整體。最為基本的線性結構統稱為序列(sequence),根據其中數據項的邏輯次序與其物理存儲地址的對應關系不同,又可進一步地將序列區分為向量vector)和列表list)。在向量中,所有數據項的物理存放位置與其邏輯次序完全吻合,此時的邏輯次序也稱作rank);而在列表中,邏輯上相鄰的數據項在物理上未必相鄰,而是采用間接定址的方式通過封裝后的位置(position)相互引用。

  • 向量結構的高效實現,包括其作為抽象數據類型的接口規范以及對應的算法,尤其是高效維護動態向量的技巧。
  • 還將針對有序向量,系統介紹經典的查找與排序算法,並就其性能做一分析對比,這也是本章的重點與難點所在。
  • 引入復雜度下界的概念,並通過建立比較樹模型,針對基於比較式算法給出復雜度下界的統一界定方法。

一、數組:

  若集合S由n個元素組成,且各元素之間具有一個線性次序,則可將它們存放於起始於地址A、物理位置連續的一段存儲空間,並統稱作數組(array),通常以A作為該數組的標識。對於任何0 < i < j < n,A[i]都是A[j]的前驅(predecessor),A[j]都是A[i]的后繼(successor).,對於任何i >1,A[i - 1]稱作A[i]的直接前驅(intermediate predecessor);對於任何i < n - 2,A[i + 1]稱作A[i]的直接后繼(intermediate successor)。任一元素的所有前驅構成其前綴(prefix),所有后繼構成其后綴(suffix)。

  若數組A[]存放空間的起始地址為A,且每個元素占用s個單位的空間,則元素A[i]對應的物理地址為:A + i * s因其中元素的物理地址與其下標之間滿足這種線性關系,故亦稱作線性數組(linear array).


二、向量  

  按照面向對象思想中的數據抽象原則,可對以上的數組結構做一般性推廣,使得其以上特性更具普遍性。向量(vector)就是線性數組的一種抽象與泛化,它也是由具有線性次序的一組元素構成的集合V = { v0, v1, ..., vn-1 },其中的元素分別由相互區分。

  各元素的秩(rank)互異,且均為[0, n)內的整數。具體地,若元素e的前驅元素共計r個,則其秩就是r。經如此抽象之后,我們不再限定同一向量中的各元素都屬於同一基本類型,它們本身可以是來自於更具一般性的某一類的對象。另外,各元素也不見得同時具有某一數值屬性,故而並不保證它們之間能夠相互比較大小。

  • 以下首先從向量最基本的接口出發,設計並實現與之對應的向量模板類
  • 然后在元素之間具有大小可比性的假設前提下,通過引入通用比較器重載對應的操作符明確定義元素之間的大小判斷依據,並強制要求它們按此次序排列,從而得到所謂有序向量
  • 介紹和分析此類向量的相關算法及其針對不同要求的各種實現版本。

2.1   ADT接口:

  

在引入的概念並將外部接口與內部實現分離之后,無論采用何種具體的方式,符合統一外部接口規范的任一實現均可直接地相互調用和集成。下表給出了一個整數向量從被創建開始,通過ADT接口依次實施一系列操作的過程。請留意觀察,向量內部各元素秩的逐步變化過程。

typedef int Rank; //
#define DEFAULT_CAPACITY 3 //默訃癿刜始容量(實際應用中可謳置為更大)

template <typename T> class Vector { //向量模板類
protected:
  Rank _size; int _capacity; T* _elem; //規模、容量、數據區
  void copyFrom(T const* A, Rank lo, Rank hi); //復制數據區間A[lo,hi]
  void expand(); //空間時擴容
  void shrink(); //裝填因子過小時壓縮
  bool bubble(Rank lo, Rank hi); //掃描交換
  void bubbleSort(Rank lo, Rank hi); //起泡排序算法
  Rank max(Rank lo, Rank hi); //選取最大元素
  void selectionSort(Rank lo, Rank hi); //選擇排序算法
  void merge(Rank lo, Rank mi, Rank hi); //歸並算法
  void mergeSort(Rank lo, Rank hi); //歸並排序算法
  Rank partition(Rank lo, Rank hi); //軸點構造算法
  void quickSort(Rank lo, Rank hi); //快速排序算法
  void heapSort(Rank lo, Rank hi); //堆排序(稍后結合完全堆講解)
public:
//構造函數
Vector(int c = DEFAULT_CAPACITY, int s = 0, T v = 0) //容量為c、規模為s、所有元素初始為v
  { _elem = new T[_capacity = c]; for (_size = 0; _size < s; _elem[_size++] = v); } //s <= c 大小小於等於容量
Vector(T const* A, Rank lo, Rank hi) { copyFrom(A, lo, hi); } //數組區間復制
Vector(T const* A, Rank n) { copyFrom(A, 0, n); } //數組整體復制
Vector(Vector<T> const& V, Rank lo, Rank hi) { copyFrom(V._elem, lo, hi); } //向量區間復制
Vector(Vector<T> const& V) { copyFrom(V._elem, 0, V._size); } //向量整體復制
// 析構函數
~Vector() { delete [] _elem; } //釋放內部空間
// 只讀訪問接口
Rank size() const { return _size; } //規模
bool empty() const { return !_size; } //判空
int disordered() const; //判斷向量是否已經排序
Rank find(T const& e) const { return find(e, 0, _size); } //無序向量整體查找
Rank find(T const& e, Rank lo, Rank hi) const; //無序向量區間查找
Rank search(T const& e) const //有序向量整體查找
  { return (0 >= _size) ? -1 : search(e, 0, _size); }
Rank search(T const& e, Rank lo, Rank hi) const; //有序向量區間查找
// 可寫訪問接口
T& operator[](Rank r) const; //重載下標操作符,可以類似亍數組形式引用各元素
Vector<T> & operator=(Vector<T> const&); //重載賦值操作符,以便直接克隆向量
T remove(Rank r); //初除秩為r的元素
int remove(Rank lo, Rank hi); //刪除秩在區間[lo, hi)之內的元素
Rank insert(Rank r, T const& e); //插入元素
Rank insert(T const& e) { return insert(_size, e); } //默認作為末元素插入
void sort(Rank lo, Rank hi); //對[lo, hi)排序
void sort() { sort(0, _size); } //整體排序
void unsort(Rank lo, Rank hi); //對[lo, hi)置亂
void unsort() { unsort(0, _size); } //整體置亂 int deduplicate(); //無序去重 int uniquify(); //有序去重 // 遍歷 void traverse(void (*)(T&)); //遍歷(使用函數指針,只讀或局部性修改) template <typename VST> void traverse(VST&); //遍歷(使用函數對象,可全局性修改) }; //Vector

 

  這里通過模板參數T,指定向量元素的類型。於是,以Vector<int>Vector<float>之類的形式,可便捷地引入存放整數或浮點數的向量;而以Vector<Vector<char>>之類的形式,則可直接定義存放字符的二維向量等。這一技巧有利於提高數據結構選用的靈活性和運行效率,並減少出錯,因此在本書中頻繁使用。

 

2.2  構造與析構:

  

  因此向量對象的構造與析構,將主要圍繞這些私有變量和數據區的初始化與銷毀展開。

2.2.1 默認構造方法
  其中默認的構造方法是,首先根據創建者指定的初始容量,向系統申請空間,以創建內部私有數組_elem[];若容量未明確指定,則使用默認值DEFAULT_CAPACITY。接下來,鑒於初生的向量尚不包含任何元素,故將指示規模的變量_size初始化為0。
  整個過程順序進行,沒有任何迭代,故若忽略用於分配數組空間的時間,共需常數時間

2.2.2 基於復制的構造方法:

template <typename T> //元素類型
void Vector<T>::copyFrom(T const* A, Rank lo, Rank hi) { //以數組區間A[lo, hi)為藍本復制向量   _elem = new T[_capacity = 2 * (hi - lo)]; _size = 0; //分配空間,規模清零   while (lo < hi) //A[lo, hi)內的元素逐一   _elem[_size++] = A[lo++]; //復制到_elem[0, hi - lo) }

  copyFrom()首先根據待復制區間的邊界,換算出新向量的初始規模;再以雙倍的容量,為內部數組_elem[]申請空間。最后通過一趟迭代,完成區間A[lo, hi)內各元素的順次復制。若忽略開辟新空間所需的時間,運行時間應正比於區間寬度,即O(hi - lo) = O(_size)

2.2.3重載的賦值運算符:

  重載向量的賦值運算符如下:

template <typename T> Vector<T>& Vector<T>::operator=(Vector<T> const& V ) { //重載賦值操作符
  if (_elem)  delete [] _elem; //釋放原有內容
  copyFrom(V._elem, 0, V.size()); //整體復刢
  return *this; //返回當前對象的引用,以便於鏈式賦值
}

2.2.4   析構函數:

  與所有對象一樣,不再需要的向量,應借助析構函數(destructor)及時清理(cleanup),以釋放其占用的系統資源。與構造函數不同,同一對象只能有一個析構函數,不得重載。向量對象的析構過程,如代碼2.1中的方法~Vector()所示:只需釋放用於存放元素的內部數組_elem[],將其占用的空間交還操作系統。_capacity和_size之類的內部變量無需做任何處理,它們將作為向量對象自身的一部分被系統回收,此后既無需也無法被引用。
  若不計系統用於空間回收的時間,整個析構過程只需O(1)時間。

  同樣地,向量中的元素可能不是程序語言直接支持的基本類型。比如,可能是指向動態分配對象的指針或引用,故在向量析構之前應該提前釋放對應的空間。出於簡化的考慮,這里約定並遵照“誰申請誰釋放”的原則。究竟應釋放掉向量各元素所指的對象,還是需要保留這些對象,以便通過其它指針繼續引用它們,應由上層調用者負責確定。 

 


 

2.3   動態空間管理:

  向量實際規模與其內部數組容量的比值(即_size/_capacity),亦稱作裝填因子(loadfactor),它是衡量空間利用率的重要指標。如何保證向量的裝填因子既不至於超過1,也不至於太接近0.需要改用動態空間管理策略。其中一種有效的方法,即使用所謂的可擴充向量

2.3.1   可擴充向量:

  擴充的原理如同金蟬脫殼,就動態地擴大內部數組的容量。這里的難點及關鍵在於:如何實現擴容?新的容量選取多少合適?一種可行的方法,如圖2.1(c~e)所示。我們需要另行申請一個容量更大的數組B[](圖(c)),並將原數組中的成員集體搬遷至新的空間(圖(d)),此后方可順利地插入新元素e而不致溢出(圖(e))。當然,原數組所占的空間,需要及時釋放並歸還操作系統

  

2.3.2   擴容:

  基於以上策略的擴容算法expand(),可實現如下所示:

template <typename T> void Vector<T>::expand() { //向量空間丌足時擴容
  if (_size < _capacity) return; //尚未滿員時,不必擴容
  if (_capacity < DEFAULT_CAPACITY) _capacity = DEFAULT_CAPACITY; //不低於最小容量
  T* oldElem = _elem; _elem = new T[_capacity <<= 1]; //容量加倍 elem是一個指向新堆內存的指針了
  for (int i = 0; i < _size; i++)
    _elem[i] = oldElem[i]; //復制原向量內容(T為基本類型,戒已重載賦值操作符'=')
  delete [] oldElem; //釋放原空間
}

 實際上,在調用insert()接口插入新元素之前,都要先調用該算法,檢查內部數組的可用容量。一旦當前數據區已滿(_size == _capacity),則將原數組替換為一個更大的數組。  

 這種情況下,若直接引用數組,往往會導致共同指向原數組的其它指針失效,成為野指針(wild pointer);而經封裝為向量之后,即可繼續准確地引用各元素,從而有效地避免野指針的風險。 

2.3.2   分攤時間

  雖然每次擴容,就要把向量元素從當前內存復制到另一內存處,其時間復雜度為O(n),但這只是最壞情況下。

 假定數組的初始容量為某一常數N。既然是估計復雜度的上界,故不妨設向量的初始規模也為N——即將溢出。另外不難看出,除插入操作外,向量其余的接口操作既不會直接導致溢出,也不會增加此后溢出的可能性,因此不妨考查最壞的情況,假設在此后需要連續地進行n次insert()操作,n >> N。首先定義如下函數:

size(n) = 連續插入n個元素后向量的規模
capacity(n) = 連續插入n個元素后數組的容量
T(n) = 為連續插入n個元素而花費於擴容的時間

 

其中,向量規模從N開始隨着操作的進程逐步遞增,故有:size(n) = N + n。既然不致溢出,故裝填因子絕不會超過100%。同時,這里的擴容采用了“懶惰”策略——只有在的確即將發生溢出時,才不得不將容量加倍——因此裝填因子也始終不低於50%。

   概括起來始終有:

 

  容量以2為比例按指數速度增長,在容量達到capacity(n)之前,共做過(log2n)次擴容,每次擴容所需時間線性正比於當時的容量(或規模),且同樣以2為比例按指數速度增長。因此,消耗於擴容的時間累計不過:  

 

將其分攤到其間的連續n次操作,單次操作所需的分攤運行時間應為O(1)。而其他追加固定數目單元的擴容方案,無論采用的固定常數是多大,在最壞情況下,此類數組單詞操作的分攤時間復雜度都是O(n).

 2.3.3 縮小容量

   當裝填因子低於某一閾值時,我們稱數組發生了下溢(underflow)。盡管下溢不屬於必須解決的問題,但是在格外關注空間利用率的場合,發生下溢也有要必要的適當縮減內部數組容量。如下給出了一個動態縮容shrink()算法

template <typename T> void Vector<T>::shrink() { //裝填因子過小時壓縮向量所占空間
  if (_capacity < DEFAULT_CAPACITY << 1) return; //不至於收縮到DEFAULT_CAPACITY以下
  if (_size << 2 > _capacity) return; //以25%為界 說明size不是很小 左移2位就是乘以4,4倍size是大於容量的
  T* oldElem = _elem; _elem = new T[_capacity >>= 1]; //容量減半
  for (int i = 0; i < _size; i++) _elem[i] = oldElem[i]; //復制原向量內容
  delete [] oldElem; //釋放原空間
}

 就單次擴容或縮容操作而言,所需時間的確會高達O(n),因此在對單次操作的執行速度極其敏感的應用場合以上策略並不適用,其中縮容操作甚至可以完全不予考慮。

 


 

 2.4   常規向量:

2.4.1 直接引用元素(循秩訪問)

  在經過封裝之后,對向量元素的訪問可以沿用數組的方式,方法就是重載操作符"[]":T&代表是類型T的引用,使用它是因為返回值可以作為左值。

template <typename T> T& Vector<T>::operator[](Rank r) const //重載下標操作符
{ return _elem[r]; } // assert: 0 <= r < _size

 2.4.2 置亂器:

  經重載后操作符“[]”返回的是對數組元素的引用,這就意味着它既可以取代get()操作(通常作為賦值表達式的右值),也可以取代set()操作(通常作為左值)。例如,采用這
種形式,可以簡明清晰地描述和實現如代碼2.7所示的向量置亂算法。

template <typename T> void permute(Vector<T>& V) { //隨機置亂向量,使各元素等概率出現亍殏一位置
  for (int i = V.size(); i > 0; i--) //自后向前
  swap(V[i - 1], V[rand() % i]); //V[i - 1]不V[0, i)中某一隨機元素交換
}

   該算法從待置亂區間的末元素開始,逆序地向前逐一處理各元素。對每一個當前元素V[i - 1],先通過調用rand()函數在[0, i)之間等概率地隨機選取一個元素,再令二者互換位置。注意,這里的交換操作swap(),隱含了三次基於重載操作符“[]”的賦值。每經過一步這樣的迭代,置亂區間都會向前拓展一個單元。因此經過O(n)步迭代之后,即實現了整個向量的置亂。

  在軟件測試、仿真模擬等應用中,隨機向量的生成都是一項至關重要的基本操作,直接影響到測試的覆蓋面或仿真的真實性。從理論上說,使用這里的算法permute(),不僅可以枚舉出同一向量所有可能的排列,而且能夠保證生成各種排列的概率均等。

  將以上permute()算法封裝至向量ADT中,對外提供向量的置亂操作接口Vector::unsort()。

template <typename T> void Vector<T>::unsort(Rank lo, Rank hi) { //等概率隨機置亂向量匙間[lo, hi)
  T* V = _elem + lo; //將子向量_elem[lo, hi)規作另一向量V[0, hi - lo)
  for (Rank i = hi - lo; i > 0; i--) //自后向前
  swap(V[i - 1], V[rand() % i]); //將V[i - 1]不V[0, i)中某一元素隨機交換
}

 

   通過該接口,可以均勻地置亂任一向量區間[lo, hi)內的元素,故通用性有所提高。可見,只要將該區間等效地視作另一向量V,即可從形式上完整地套用以上permute()算法的流程。盡管如此,還請特別留意代碼2.7與代碼2.8的細微差異:后者是通過下標,直接訪問內部數組的元素;而前者則是借助重載的操作符“[]”,通過秩間接地訪問向量的元素。

  從算法的角度來看,“判斷兩個對象是否相等”與“判斷兩個對象的相對大小”都是至關重要的操作,它們直接控制着算法執行的分支方向,因此也是算法的“靈魂”所在。在本書中為了以示區別,前者多稱作“比對”操作,后者多稱作“比較”操作。
  算法實現的簡潔性與通用性,在很大程度上體現於:針對整數等特定數據類型的某種實現,可否推廣至可比較或可比對的任何數據類型,而不必關心如何定義以及判定其大小或相等關系
若能如此,我們就可以將比對和比較操作的具體實現剝離出來,直接討論算法流程本身。為此,通常可以采用兩種方法。其一,將比對操作和比較操作分別封裝成通用的判等器和比較器。其二,在定義對應的數據類型時,通過重載"<"和"=="之類的操作符,給出大小和相等關系的具體定義及其判別方法

  

1 template <typename T> static bool lt(T* a, T* b) { return lt(*a, *b); } //less than
2 template <typename T> static bool lt(T& a, T& b) { return a < b; } //less than
3 template <typename T> static bool eq(T* a, T* b) { return eq(*a, *b); } //equal
4 template <typename T> static bool eq(T& a, T& b) { return a == b; } //equal

2.4.3 無序查找

   在無序向量中查找任意指定元素e時,因為沒有更多的信息可以借助,故在最壞情況下,比如向量中並不包含e時,只有在訪遍所有元素之后,才能得出查找結論。

   因此不妨如圖2.3所示,從末元素起自后向前逐一取出各個元素並與目標元素e進行比對,直至發現與之相等者(查找成功),或者直至檢查過所有元素之后仍未找到相等者(查找失敗)。這種依次逐個比對的查找方式,稱作順序查找(sequential search)。

1 template <typename T>
2 Rank Vector<T>::find (T const &,Rank lo,Rank hi) const {
3     while((lo < hi--))&&(e != _elem[hi]); //從后往前順序查找
4     return hi; //若hi < lo,則意味着失敗,否則hi即命中元素的秩
5 }

 

 

   其中若干細微之處,需要體會。比如,當同時有多個命中元素時,本書統一約定返回其中秩最大者,稍后介紹的查找接口find()亦是如此故這里采用了自后向前的查找次序。如此,一旦命中即可立即返回,從而省略掉不必要的比對。另外,查找失敗時約定統一返回-1。這不僅簡化了對查找失敗情況的判別,同時也使此時的返回結果更加易於理解,只要假想着在秩為-1處植入一個與任何對象都相等的哨兵元素,則返回該元素的秩當且僅當查找失敗。最后還有一處需要留意。while循環的控制邏輯由兩部分組成,首先判斷是否已抵達通配符,再判斷當前元素與目標元素是否相等。得益於C/C++語言中邏輯表達式的短路求值特性,在前一判斷非真后循環會立即終止,而不致因試圖引用已越界的秩(-1)而出錯。 

  最壞情況下,查找終止於首元素_elem[lo],運行時間為O(hi - lo) = O(n)。最好情況下,查找命中於末元素_elem[hi - 1],僅需O(1)時間。對於規模相同、內部組成不同的輸入,漸進運行時間卻有本質區別,故此類算法也稱作輸入敏感的(input sensitive)算法。

 


 

2.4.4  插入

  插入操作insert(r, e)負責將任意給定的元素e插到任意指定的秩為r的單元。代碼如下:

template <typename T> //將e作為秩為r元素插入
  Rank Vector<T>::insert(Rank r, T const& e) { //assert: 0 <= r <= size
  expand(); //若有必要,擴容
  for (int i = _size; i > r; i--) _elem[i] = _elem[i-1]; //自后向前,后繼元素依次后移一個單元
  _elem[r] = e; _size++; //置入新元素, 更新容量
  return r; //返回
}

 

如果采用從r處移動,原向量中秩大於r的元素的都會被覆蓋。就是只能從后往前移動

 插入之前必須首先調用expand()算法,核對是否即將溢出;若有必要,則加倍擴容。為保證數組元素物理地址連續的特性,隨后需要將后綴_elem[r, _size)(如果非空)整體后移一個單元(圖(c))。這些后繼元素自后向前的搬遷次序不能顛倒,否則會因元素被覆蓋
而造成數據丟失。在單元_elem[r]騰出之后,方可將待插入對象e置入其中。

  復雜度:

  時間主要消耗於后繼元素的后移,線性正比於后綴的長度,故總體為O(_size - r + 1)。可見,新插入元素越靠后(前)所需時間越短(長)。特別地,r取最大值_size時為最好情況,只需O(1)時間;r取最小值0時為最壞情況,需要O(_size)時間。一般地,若插入位置等概率分布,則平均運行時間為O(_size) = O(n)(習題[2-9]),線性正比於向量的實際規模。


 

2.4.5  刪除

  應將單元素刪除視作區間刪除的特例,並基於后者來實現前者。

 區間刪除:romove(lo,hi)

template <typename T> int Vector<T>::remove(Rank lo, Rank hi) { //刪除區間[lo, hi)
if (lo == hi) return 0; //出校效率考慮,單獨處理退化情況,比如remove(0, 0)  
  
while (hi < _size) _elem[lo++] = _elem[hi++]; //[hi, _size)依次前移hi - lo個單元   _size = lo; //更新規模,直接丟棄尾部[lo, _size = hi)   shrink(); //若有必要,則縮容   return hi - lo; //返回被刪除元素的數目
}

 

  單個元素刪除:romove(r)

  將[r] = [ r , r+1)

1 template <typename T> T Vector<T>::remove(Rank r) { //刪除向量中秩為r的元素,0 <= r < size
2 T e = _elem[r]; //備份被刪除元素
3 remove(r, r + 1); //調用區間刪除算法,等效亍對匙間[r, r + 1)的刪除
4 return e; //返回被刪除元素
5 }

復雜度:

   remove(lo, hi)的計算成本,主要消耗於后續元素的前移,線性正比於后綴的長度,總體不過O(m + 1) = O(_size - hi + 1)。這與此前的預期完全吻合:區間刪除操作所需的時間,應該僅取決於后繼元素的數目,而與被刪除區間本身的寬度無關。特別地,基於該接口實現的單元素刪除接口remove(r)需耗時O(_size - r)。也就是說,被刪除元素在向量中的位置越靠后(前)所需時間越短(長),最好為O(1),最壞為O(n) = O(_size)。

 如果調用單元素刪除來實現區間刪除:那么就會實現  

 

   每刪除一個,r的后繼將整體前移一次,耗時為O(n),總共要刪除n次。所以復雜度是O(n方)


 

 2.4.6 唯一化(去除重復元素)

    所謂向量的唯一化處理,就是剔除其中的重復元素,即表2.1所列deduplicate()接口的功能。

1 template <typename T> int Vector<T>::deduplicate() { //剔除無序向量中重復元素(高效版)
2 int oldSize = _size; //記錄原規模
3 Rank i = 1; //從_elem[1]開始 從1號元素開始
4 while (i < _size) //自前向后逐一考查各元素_elem[i]
5   (find(_elem[i], 0, i) < 0) ? i++ : remove(i); //在前綴中查找與i雷同的元素,若無雷同則繼續查找其后繼,否則刪除雷同者
7 return oldSize - _size;//向量規模變化,即被刪除元素的綜述
}

 

 挖掘算法的單調性和不變性:
  不變性:

   在當前V[i]的前綴v[0,i]中,各元素彼此互異。

 

針對該元素的一步迭代之后,無非兩種結果:
  1)若元素e的前綴_elem[0, i)中不含與之雷同的元素,則如圖(b),在做過i++之后,新的前綴_elem[0, i)將繼續滿足不變性,而且其規模增加一個單位。
  2)反之,若含存在與e雷同的元素,則由此前一直滿足的不變性可知,這樣的雷同元素不超過一個。因此如圖(c),在刪除e之后,前綴_elem[0, i)依然保持不變性。

 復雜度:

 這里所需的時間,主要消耗於find()和remove()兩個接口。根據2.5.4節的結論,前一部分時間應線性正比於查找區間的寬度,即前驅的總數;根據2.5.6節的結論,后一部分時間應線性正比於后繼的總數。因此,每步迭代所需時間為O(n),總體復雜度應為O(n2)。 


 

 2.4.6 遍歷

  在很多算法中,往往需要將向量作為一個整體,對其中所有元素實施某種統一的操作,比如輸出向量中的所有元素,或者按照某種運算流程統一修改所有元素的數值(習題[2-13])。針對此類操作,可為向量專門設置一個遍歷接口traverse()

1 template <typename T> void Vector<T>::traverse(void (*visit)(T&)) //利用函數指針機制的遍歷
2 { for (int i = 0; i < _size; i++) visit(_elem[i]); }
3
4 template <typename T> template <typename VST> //元素類型、操作器
5 void Vector<T>::traverse(VST& visit) /利用函數對象機制遍歷
6 { for (int i = 0; i < _size; i++) visit(_elem[i]); }
  •  前一種方式借助函數指針*visit()指定某一函數,該函數只有一個參數,其類型為對向量元素的引用,故通過該函數即可直接訪問或修改向量元素。
  •  另外有可以以函數對象的形式,指定具體的遍歷操作,。這類對象的操作符“()”經重載之后,在形式上等效於一個函數接口,故此得名。

 

 


 

三、有序向量:

   有序向量各元素之間必須能夠比較大小。這一條件構成了有序向量中“次序”概念的基礎,否則所謂的“有序”將無從談起

3.1 甄別有序算法(判斷是不是有序向量)

1 template <typename T> int Vector<T>::disordered() const { //返回向量中逆序相鄰元素對的總數
2   int n = 0; //計數器
3   for (int i = 1; i < _size; i++) //逐一檢查_size -1 對相鄰元素
4   if (_elem[i - 1] > _elem[i]) n++; //逆序則計數
5 return n; //當且僅當n = 0,向量有序
6 }

 


 

 3.2、唯一化(去重)

1 template <typename T> int Vector<T>::uniquify() { //有序向量重復元素剔除算法(高效版)
2   Rank i = 0, j = 0; //各對互異“相鄰”元素的秩
3   while (++j < _size) //逐一掃描,直至末元素
4     if (_elem[i] != _elem[j]) //跳過雷同者
5       _elem[++i] = _elem[j]; //發現不同元素時,向前移至緊鄰於前者右側
6   _size = ++i; shrink(); //直接截除尾部多余元素
7   return j - i; //向量規模化變量,即被刪除元素總數
8 }

 

  復雜度:

  while循環的每一步迭代,僅需對元素數值做一次比較,向后移動一到兩個位置指針,並至多向前復制一個元素,故只需常數時間。而在整個算法過程中,每經過一步迭代秩j都必然加一,鑒於j不能超過向量的規模n,故共需迭代n次。由此可知,uniquify()算法的時間復雜度應為O(n),較之uniquifySlow()的O(n2),效率整整提高了一個線性因子。反過來,在遍歷所有元素之前不可能確定是否有重復元素,故就漸進復雜度而言,能在O(n)時間內完成向量的唯一化已屬最優。當然,之所以能夠做到這一點,關鍵在於向量已經排序。


  

  3.3、查找

  為區別於無序向量的查找接口find(),有序向量的查找接口將統一命名為search()。

1 template <typename T> //在有序向量的區間[lo, hi)內,確定不大於e的最后一個節點的秩
2   Rank Vector<T>::search(T const& e, Rank lo, Rank hi) const { //assert: 0 <= lo < hi <= _size
3   return (rand() % 2) ? //按%50的概率隨機使用
4   binSearch(_elem, e, lo, hi) : fibSearch(_elem, e, lo, hi); //二分查找或Fibonacci查找
5 }

 

 3.3.1.二分查找(版本A)  

 

1 // 二分查找算法(版本A):在有序向量區間[lo, hi)內查找元素e,0 <= lo <= hi <= _size
2 template <typename T> static Rank binSearch(T* A, T const& e, Rank lo, Rank hi) {
3   while (lo < hi) { //每步迭代要做兩次判斷,有三個分支
4     Rank mi = (lo + hi) >> 1; //以中點為軸點
5     if (e < A[mi]) hi = mi; //深入前半段[lo, mi)繼續查找
6     else if (A[mi] < e) lo = mi + 1; //深入后半段(mi, hi)繼續查找
7     else return mi; //在mi處命中
8   } //成功查找可以提前終止
9 return -1; //查找失敗
10} //如有多個命中元素時,不能保證返回秩最大者;查找失敗時,簡單的返回-1,而不能只是失敗的位置。

 

 以上算法采取的策略可概括為,以“當前區間內居中的元素”作為目標元素的試探對象。從應對最壞情況的保守角度來看,這一策略是最優的每一步迭代之后無論沿着哪個方向深入,新問題的規模都將縮小一半。因此,這一策略亦稱作二分查找(binary search)。也就是說,隨着迭代的不斷深入,有效的查找區間寬度將按1/2的比例以幾何級數的速度遞減。於是,經過至多log2(hi - lo)步迭代后,算法必然終止。鑒於每步迭代僅需常數時間,故總體時間復雜度不超過:O(log2(hi - lo)) = O(logn)與代碼2.10中順序查找算法的O(n)復雜度相比,O(logn)幾乎改進了一個線性因子。

  查找長度:

  以上迭代過程所涉及的計算,主要分為兩類:元素的大小比較、秩的算術運算及其賦值。雖然二者均屬於O(1)復雜度的基本操作,但元素的秩無非是(無符號)整數,而向量元素的類型則通常更為復雜,甚至復雜到未必能夠保證在常數時間內完成(習題[2-17])。因此就時間復雜度的常系數而言,前一類計算的權重遠遠高於后者,而查找算法的整體效率也更主要地取決於其中所執行的元素大小比較操作的次數,即所謂查找長度(search length)。通常,可針對查找成功或失敗等情況,從最好、最壞和平均情況等角度,分別測算查找長度,並憑此對查找算法的總體性能做一評估。

 二分查找的不足:

比較是向左一次,向右兩次。

成功查找長度和失敗查找長度如下所示:

  

 

  盡管二分查找算法(版本A)即便在最壞情況下也可保證O(logn)的漸進時間復雜度,但就其常系數1.5而言仍有改進余地。以成功查找為例,即便是迭代次數相同的情況,對應的查找長度也不盡相等。究其根源在於,在每一步迭代中為確定左、右分支方向,分別需要做1次或2次元素比較從而造成不同情況所對應查找長度的不均衡。盡管該版本從表面上看完全均衡,但我們通過以上細致的分析已經看出,最短和最長分支所對應的查找長度相差約兩倍。

 與遞歸版本的二分查找相比:由於每次遞歸的次數和深度都是log2 n,所以每次需要的輔助空間都是常數級別的:時間復雜度是log2n,空間復雜度也是log2n。而迭代版本的二分查找其時間復雜度是O(log2n) ,由於不需要開辟新的空間,其空間復雜度是O(1).

3.3.2. Fibonacci(斐波那契)查找 

  按照二分查找存在均衡性方面的缺陷,根源在於這兩項的大小不相匹配(向左側成本低,右側成本高)解決思路如下:

 

做成左側是更深的,右側是更淺的,這樣看似的不平衡,但是由於和成本低做一個合適的補償,可以在整體上達到最優。使得整體查找平均成本最短。

 fibsearch()查找算法的實現如上所述:binsearch()與fibsearch()的區別在於中間點mi的選取,如果按照黃金分割來取,就是fibsearch(),如果是按照中點來取就是binsearch()。中點是fib(k- 1) -1;

  

 斐波那契查找舉例:

  V = [2,3,7,14,22,33,55,75,89,123]

  • v.size() = 10;
  • 下標區間[lo,hi]為lo = 0,hi = 9;
  • 初始化斐波那契數列要滿足,斐波那契數列的最后一位要比size-1大。所以Fib = [1,1,2,3,5,8,13];
  • 如果查找值比mid處的值大,則向右查找並減小2個斐波那契區間。
  • 如果查找值比mid處的值小,則向左查找並減小1個斐波那契區間。
  • block0 = 6; Fib(6)  -1 > size,此時選擇的6剛好是Fib序列的最后一位的序列值6.
  • mid0 = lo0 + Fib(block0 - 1) -1 = 7; 對應的mid0值是75;
  • 此時查找值比mid0處的值小,則向左查找並減小一個斐波那契區間。此時block1  = block0  - 1 = 5;
  • 區間范圍變成:lo1 = lo0 = 0;hi1 = mid0 - 1= 6;
  • mid1 = lo1 + Fib(block1 -1 ) -1 = 4; 對應的mid1值是22;此時查找值44 >22,所以向右查找並減小兩個斐波那契區間。block2  = block1 -2 = 3;
  • lo2 = mid1 +1 = 5; hi2 = hi1 = 6;
  • mid3 = lo2 + Fib(block2 -1 ) -1=  6; 對應的mid3值是55,此時查找值44<55;向左查找,並減小一個斐波那契數列 block3 = block2 -1 = 2;
  • 依次往下

 

 


 

 3.3.3.二分查找(版本B)

  版本A是因為左右分支的轉向代價不平衡導致的,在版本B中我們將其改成平衡的。

  同樣的軸點mi取做中點,所有分支只有2個方向,原來是3個方向(在軸點處要判斷是否等於目標值),現在是將軸點歸到右側區間上。

 

  具體實現如下:

  

 

 A版本和B版本的最優時間復雜度不一樣:A是O(1),B是O(log2n)。

  滿足語義的代碼:

  

  最終得到版本C:

  


 

  3.3.4.二分查找(版本C)

  

 


 

3.4  插值查找:

 

 

     分析性能:平均情況,每經一次比較,插值查找算法就可以將查找范圍n縮短至根號下n.

  

  插值是對n的二進制寬度作二分查找。

 

 

所以每次需要的次數是O(loglogn)

 


 

 四、綜合比對:     大規模:插值查找 中規模:折半查找 小規模:順序查找

  

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM