鏈表的應用——箱子排序和基數排序
單向鏈表的實現
數據結構它描述的是數據和數據之間的關系。數據結構要三大要素:邏輯結構,描述數據和數據之間的關系,分為線性結構和非線性結構兩種,所謂線性結構指的就是這種數據結構描述的數據之間存在這樣的關系,除了首元素和微元素,任何元素都存在一個唯一前驅和唯一後繼(前驅通俗的說就是這個元素的前一個元素,後繼就是這個元素的後一個元素),而非線性結構中的數據元素之間就不存在這種關系,而是用父節點子節點的關系來描述,也就是說前驅後繼不存在唯一性;存儲結構,所謂存儲結構,實際上指的就是怎麽它數據之間的邏輯結構在計算機內存中表示出來,以方便我們對這些數據進行運算,存儲結構分兩種,順序結構和鏈式結構,順序結構指的是使用一塊連續的內存,通過數組索引來表示數據之間的邏輯關系線性或者非線性,鏈式結構能夠有效利用碎片內存,它不需要連續的空間,但是卻不具備順序表的隨機訪問能力,所謂隨機訪問指的是訪問這片內存中任意位置的元素其時間復雜度都是O(1),鏈式存儲結構的描述暗含這樣一個事實,當我們采用鏈式結構來存儲數據結構時,它必然需要一個數據節點來輔助我們實現數據結構,而這個數據節點至少包含數據域(存放我們真正想要的數據)和一個指針域(存放相鄰節點的地址);算法,實際上這就是廣義的算法了,就是指我們能夠在這個數據結構上所采取的操作,例如插入、刪除、清空數據結構等等。
或者你也可以把數據結構看成是一個容器,它裏面容納了某種類型的數據,數據之間存在著線性或者非線性的邏輯關系,存儲結構則昭示著在計算機的世界裏這個容器是以何種形式存在的。
基於順序存儲結構和鏈式存儲結構實現的線性數據結構(線性表),我們把它做個比較:首先,順序表它使用的是連續的內存,它提供了隨機訪問能力,但是它會有內存分配失敗的風險(因為沒有空閑的滿足要求大小的內存可以被分配了,雖然這種情況出現的概率比較低,至少我現在為止還沒遇到過,可能是因為數據量不夠),而鏈表,它可以有效利用碎片內存,但是因為指針域的存在,無疑會多占用一部分空間(32位總線的機器,地址大小就是4字節)。尾部插入,順序表的時間復雜度是O(1),鏈表的話,要看具體實現了。查詢操作上,順序表因為隨機訪問的特性,訪問任意位置上的元素,其時間復雜度都是O(1),而鏈表,訪問任何一個元素都需要從頭結點(一般鏈表的實現會考慮帶一個頭結點,它不是真正意義上的數據節點,只是為了在編碼實現過程中能排除首元素節點的特殊性,首元素節點才是真正意義上的第一個數據節點)開始遍歷,直到對應的索引,所以它的時間復雜度是O(1)。任意位置上的插入,假設在x位置插入,對順序表來說,我們需要將x位置以及其後的所有元素都要逐個移動順序表大小減去x個位置,其時間復雜度是O(n),空出了位置之後的插入操作時間復雜度是O(1),那麽總的插入操作時間復雜度是O(n),對於鏈表來說,插入的時候首先我們要找到這個索引所指示的節點,這個要從頭結點開始遍歷,其時間復雜度是O(1),而插入動作本身的時間復雜度為O(1),因為插入實際上只是改變了鏈表的指向關系,那麽總的時間復雜速度是O(n);盡管如此,鏈表在任意位置的插入效率是要高於順序表的,很簡單,移動操作顯然要比較操作更耗時,尤其是當你需要在中間位置插入一個子數據結構的時候,這種差異就越是明顯。對於刪除操作,它和插入時間復雜度考慮是差不多的,同樣的,任意位置上的刪除操作,鏈式表的性能是優於順序表的。
STL裏順序表的代表就是vector,而鏈式表就是list了。在數據結構選擇時,沒有必要的理由,那麽就使用vector。當你需要使用list的時候,你要問自己,你真的需要操作中間位置的元素嗎?有沒有可能把中間位置的元素“變”到尾部(比如交換)來完成操作,因為通常來說,我們使用數據結構時,尾部的插入和索引訪問時使用頻率最高的操作。
下面是基於鏈式存儲結構實現的單鏈表,歡迎指針,文字描述,可以參考註釋,這裏尤其需要註意的是友元函數和Linux下template的寫法,它和Windows很不一樣。
數據節點的實現:
/**********************************************************************************/ /*鏈表的節點描述 */ /*鏈表就是靠每個節點間的指針指向來表示線性關系中的前驅和後繼關系 */ /*每個鏈表的節點,必須包含指針域(指向下一節點的指針)和數據域(結點包含的數據) */ /*struct和class的區別:C++對C中的struct進行了擴充 */ /*它們的相同點是:struct和class都可以有成員函數(包括構造函數和析構函數) */ /* 都能實現繼承和多態 */ /*區別是:struct在繼承時,它的默認繼承方式是private方式的 */ /* struct的成員和方法,它的默認訪問控制權限是private的 */ /**********************************************************************************/ #ifndef CHAINNODE_H_ #define CHAINNODE_H_ template <typename T> struct ChainNode { ChainNode<T> *pNextNode{nullptr}; //指向鏈表中下一個結點的指針,名稱中的p表示指針,這就是所謂的指針域 T element; //數據域,表示的是節點中存儲的數據 ChainNode() = default; explicit ChainNode(const T& element) { this->element = element; //這裏使用了this->element =element 這樣的語法,這是因為形參名和類的成員有了同樣的名字,使用this能區別誰是形參誰是類的成員 }; //構造函數 ChainNode(const T& element,ChainNode<T> *pNextNode) { this->element = element; this->pNextNode = pNextNode; } }; #endif ~
鏈表的實現
1 root@121.43.188.163‘s password: 2 Last login: Tue Jan 15 23:28:51 2019 from 112.64.61.14 3 4 Welcome to Alibaba Cloud Elastic Compute Service ! 5 6 [root@CentOs64-7 ~]# ls 7 MyProject mysql plantuml Python 8 [root@CentOs64-7 ~]# cd MyProject/ 9 [root@CentOs64-7 MyProject]# ls 10 include library source 11 [root@CentOs64-7 MyProject]# cd source/ 12 [root@CentOs64-7 source]# ls 13 Arithmetic DataStructrue Experiment tcpip 14 C++Primer DesignPattern MultiThread 15 [root@CentOs64-7 source]# cd DataStructrue/ 16 [root@CentOs64-7 DataStructrue]# ls 17 ChainList 18 [root@CentOs64-7 DataStructrue]# cd ChainList/ 19 [root@CentOs64-7 ChainList]# ls 20 ChainList.h ChainNode.h main.cpp main.out 21 [root@CentOs64-7 ChainList]# vim ChainNode.h 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 [root@CentOs64-7 ChainList]# vim ChainList.h 68 69 70 } 71 //找到給定位置的前驅 72 auto pPreElement = findPreElement(iPosition); 73 //用給定的參數構造新的結點 74 ChainNode<T> *pNewNode = new (std::nothrow) ChainNode<T>(element,pPreElement->pNextNode); 75 pPreElement->pNextNode = pNewNode; 76 ++m_iSize; 77 } 78 79 template <typename T> 80 void ChainList<T>::erase(const int iPosition) 81 { 82 //刪除指定位置的元素 83 if( iPosition < 0 || iPosition >= m_iSize) 84 return; 85 //如果給定的索引不是一個合法索引,那麽先找到這個元素,刪除的時候給定的索引是不能等於m_iSize,如果等於的話,意味著刪除的是最後一個元素,這是不合理的 86 auto pPreElement = findPreElement(iPosition); 87 pPreElement->pNextNode->element.~T(); 88 auto pTemp = pPreElement->pNextNode; 89 pPreElement->pNextNode = pPreElement->pNextNode->pNextNode; 90 delete pTemp; 91 pTemp = nullptr; 92 --m_iSize; 93 } 94 95 template <typename T> 96 void ChainList<T>::clear() 97 { 98 //清除數據結構中保存的數據 99 auto pNode = m_pHeaderNode->pNextNode; 100 ChainNode<T> *pNextDelNode {nullptr}; 101 while(pNode != nullptr) 102 { 103 pNextDelNode = pNode->pNextNode; 104 pNode->element.~T(); 105 delete pNode; 106 pNode = pNextDelNode; 107 --m_iSize; 108 } 109 if(nullptr == m_pHeaderNode) 110 { 111 std::cout << "頭節點已經被刪除了" << std::endl; 112 } 113 m_pHeaderNode->pNextNode = nullptr; 114 } 115 116 template <typename T> 117 ChainList<T>::~ChainList() 118 { 119 clear(); 120 delete m_pHeaderNode; 121 m_pHeaderNode = nullptr; 122 } 123 124 template <typename T> 125 std::ostream& operator<<(std::ostream &out,const ChainList<T> &myList) 126 { 127 if(true == myList.empty()) 128 return out; 129 auto pNode = myList.m_pHeaderNode->pNextNode; 130 while(pNode != nullptr) 131 { 132 out << pNode->element << " "; 133 pNode = pNode->pNextNode; 134 } 135 out << std::endl; 136 } 137 #endifChainList
鏈表的應用
1.箱子排序
箱子排序(bin sort),這種排序算法就是將鏈表中數值相同的節點放在同一個箱子裏,然後再將箱子串聯起來形成一個新的有序鏈表。每個箱子都是一個鏈表,它的元素數目介於0-n之間。開始時所有的箱子都是空的。箱子排序需要做的是:(1)逐個刪除輸入鏈表的節點,把刪除的節點分配到相應的箱子裏;(2)把每一個箱子鏈表收集起來,使其成為一個有序鏈表。
我將借助兩個vector<>,其中一個用來存儲每個箱子首元素節點的地址,另外一個用來存儲每個箱子尾元素節點的地址,那對每一個箱子我們有這樣一個認知,即一個箱子有頭就一定有尾,而且在這兩個在這兩個vector中對應的位置上一定是同一個箱子的首和尾,例如head[1]和tail[1]它們對應的就是一號箱子的首節點和尾節點。另外箱子排序的一個前提是,你知道你要排序的數據它的取值範圍是多少,例如我要對0-100內的數進行排序,這樣一來,我可以初始化兩個vector的大小為101,這樣假設有個節點中的數據其大小是1,那麽就把它放到head[1]這個箱子裏,這樣做的話它的空間復雜度是O(n),時間復雜度上,我們操作原來的箱子,實際上是一個遍歷的過程,這一步的操作時間復雜度是O(n) ,收集子箱子(串聯子箱子)的階段是對vector的遍歷,它的時間復雜度也是O(n),所以總的時間復雜度是O(n)。
箱子排序存在的問題:(1)你需要知道你將要排序的數它在什麽範圍內;(2)你要排序的元素必須是一個可以比較的數字,因為按照上述實現方法,實際上只能對int類型排序,因為數據被當做了下標使用;(2)造成了空間上的浪費,因為假設你要排序的數範圍是0-100,可是實際上有99個數字都是90,只有一個數字是10,那麽其實我只需要兩個額外的空間就可以完成排序的功能,但是按照目前這種實現方式,卻需要200個空間。
代碼實現:
template <typename T> void ChainList<T>::binSort(const int iRange) { int iExtraSpaceSize = iRange + 1; //計算出為了保存頭結點和尾節點需要多少額外的空間 ChainNode<T>** pFirstNode = new ChainNode<T>* [iExtraSpaceSize]; //new出的數組,其中每個元素保存的是一個指向鏈表首元素節點的地址 ChainNode<T>** pTailNode = new ChainNode<T>* [iExtraSpaceSize]; //這個數組的作用是保存每個箱子尾元素節點的地址 //初始化這兩個數組,令它們的每個元素都為nullptr,方便後續的算法執行。 for(int i = 0; i < iExtraSpaceSize; ++i) { pFirstNode[i] = nullptr; pTailNode[i] = nullptr; } //拆分鏈表,將鏈表的每一個節點分配到對應的箱子裏去 ChainNode<T>* pNode = m_pHeaderNode->pNextNode; for(; pNode != nullptr; pNode = pNode->pNextNode) { int iBinSeq = pNode->element; //將數據節點中的元素當做箱子的序號 if(pTailNode[iBinSeq] == nullptr) //如果對應箱子的尾節點它保存的地址為空,說明這個箱子裏目前還沒有元素 pTailNode[iBinSeq] = pFirstNode[iBinSeq] = pNode; else { //如果箱子不為空,那麽說明這個箱子裏面已經有元素了,這時候要做的就是把這個箱子串成一個子鏈表 pFirstNode[iBinSeq]->pNextNode = pNode; pFirstNode[iBinSeq] = pNode; //這兩行代碼的巧妙之處在於它保證了在這個箱子裏,排序是穩定排序 } } //接下來要做的事情就是收集子箱子,使得整個數據結構成為一個有序鏈表,要做事情 其實就是讓箱子首尾相連 ChainNode<T> *pTempNode=nullptr; for(int i =0 ; i < iExtraSpaceSize; ++i) { //如果箱子不為空 if(pTailNode[i] != nullptr) { if(pTempNode == nullptr) //排序的結果是得到了一個升序排列 的子鏈表數組,如果pTempNode為空,這意味著這是最小的那個子鏈,因此令頭結點的指針>域指向它 m_pHeaderNode->pNextNode = pTailNode[i]; else pTempNode->pNextNode = pTailNode[i]; pTempNode = pFirstNode[i]; } } if(pTempNode != nullptr) pTempNode->pNextNode = nullptr; //註意咯,算法執行結束之 後,pTempNode指向的應該是最後一個節點,這時候要把它的指針域置為空 if(nullptr != pFirstNode) { delete[] pFirstNode; pFirstNode = nullptr; } if(nullptr != pTailNode) { delete[] pTailNode; pTailNode = nullptr; } }bin sort
2.基數排序
使用箱子排序法,在O(n)時間內只能對0-n之間的數完成一次排序。基數排序法是對箱子排序的改進版,它能在O(n)時間內,對0 ~ (n^c - 1)之間的數進行排序,其中c>=0,是一個整數常量。箱子排序法它直接對元素進行排序,而基數排序法則是把數按照某個基數(radix)分解為數字(digit),然後對分解的結果進行排序。例如用基數排序法,假設基數是10,那麽十進制數子928可以分解為9、2、8。
假定對0~999之間的10個數字進行排序,那麽箱子的個數就是1000個。對保存首尾節點的數組初始化需要1000步,將節點分配到箱子裏需要10步,最後,串聯箱子,需要1000步,總共需要執行2010步。使用基數排序法的話,它的思路是這樣的:
(1)利用箱子排序法,根據最低位數字(即個位數字),對10個數字進行排序。因為個位數字的取值只能是0-9,所以range(就是箱子排序的輸入參數)是10,排完之後又得到一個完整的鏈表。
(2)利用箱子排序法對次低位數字(即十位數字)對(1)的結果進行箱子排序,同樣有range=10,因為箱子排序法是穩定排序法,按最低位數字排序所得到的次序保持不變。所以這一步得到的結果是根據最後兩位排序得到的結果。
(3)利用箱子排序法,對(2)的結果按第三位數字(即百位數字進行排序),小於100的數,最高位是0。因為按第三位數字排序是穩定排序,所以第三位數字相同的節點,按最後兩位排序所得到的次序保持不變。所以現在得到的排序結果就是最終的排序結果了。
上述排序方法,以10為基數,把數分解為十進制數字進行排序,因為每個數字至少有3位數,所需要進行3次排序,每次排序都要使用range=10個箱子排序。每次箱子的初始化需要10個執行步,節點分配需要10個執行步,從箱子中收集節點也需要10個執行步,總的執行步數是90,比直接使用箱子排序的2010次執行少的多了。
箱子排序的一個關鍵之處就是怎麽對待排序的數字進行分解。
假設基數(radix)為r,待排序的數字其範圍為0~n^c-1,那麽在這個範圍內的數字每一個都可以分解出c個數字來。假設數字x是在範圍 0~n^c-1內的數字,依次得到從最低位到最高位數字的分解公式是:
x%r; //分解得到最低位 (x%r^2)/r; //分解得到次低位 (x% r^3)/r^2;
代碼實現:
template <typename T> void ChainList<T>::radixSort(const int iRadix,const int iC) { //箱子排序只需要執行iC次 for(int i=0; i < iC; ++i) { //初始化保存首尾節點地址的數組 ChainNode<T>** bottom = new ChainNode<T>* [iRadix]; ChainNode<T>** top = new ChainNode<T>* [iRadix]; for(int iLoop = 0; iLoop < iRadix; ++iLoop) { bottom[iLoop] = nullptr; top[iLoop] = nullptr; } //將節點按照分解後得到的數組分配到箱子中 ChainNode<T> *pNode = m_pHeaderNode->pNextNode; for(;pNode != nullptr; pNode = pNode->pNextNode) { int iDigit = (pNode->element % static_cast<int>(std::pow(iRadix,(i+1))) ) / static_cast<int>(std::pow(iRadix,i)); if(bottom[iDigit] == nullptr) // 如果對應的箱子為空 bottom[iDigit] = top[iDigit] = pNode; //相當於是把原鏈表中第一個符合條件的元素壓到了箱子的底部 else { //箱子不為空 top[iDigit]->pNextNode = pNode; top[iDigit] = pNode; //保證穩定排序,就是說始終將原鏈表中最後一個符合條件的元素壓到箱子的頂部 } } //箱子分配完了,接下來要做的事情就是從箱子中收集數據 ChainNode<T> *pTempNode = nullptr; for(int j = 0; j < iRadix; ++j) { if(bottom[j] != nullptr) { if(nullptr == pTempNode) m_pHeaderNode->pNextNode = bottom[j]; //令首節點的指針域指向最小元素所在的鏈表頭部節點 else { pTempNode->pNextNode = bottom[j]; //第 n個鏈表和第 n+1個鏈表首尾相連 } pTempNode = top[j]; //這個臨時節點指向了每個箱子最頂部的那個元素,也就是子鏈表中的最後一個元素 } } if(pTempNode != nullptr) pTempNode->pNextNode = nullptr; if(bottom != nullptr) { delete[] bottom; bottom = nullptr; } if(top != nullptr) { delete[] top; top = nullptr; } std::cout << "第:" << i << "次排序:" << *this << std::endl; } }
鏈表的應用——箱子排序和基數排序