链表的应用——箱子排序和基数排序
Posted tobeexpert
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了链表的应用——箱子排序和基数排序相关的知识,希望对你有一定的参考价值。
单向链表的实现
数据结构它描述的是数据和数据之间的关系。数据结构要三大要素:逻辑结构,描述数据和数据之间的关系,分为线性结构和非线性结构两种,所谓线性结构指的就是这种数据结构描述的数据之间存在这样的关系,除了首元素和微元素,任何元素都存在一个唯一前驱和唯一后继(前驱通俗的说就是这个元素的前一个元素,后继就是这个元素的后一个元素),而非线性结构中的数据元素之间就不存在这种关系,而是用父节点子节点的关系来描述,也就是说前驱后继不存在唯一性;存储结构,所谓存储结构,实际上指的就是怎么它数据之间的逻辑结构在计算机内存中表示出来,以方便我们对这些数据进行运算,存储结构分两种,顺序结构和链式结构,顺序结构指的是使用一块连续的内存,通过数组索引来表示数据之间的逻辑关系线性或者非线性,链式结构能够有效利用碎片内存,它不需要连续的空间,但是却不具备顺序表的随机访问能力,所谓随机访问指的是访问这片内存中任意位置的元素其时间复杂度都是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 [email protected]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 [[email protected]7 ~]# ls 7 MyProject mysql plantuml Python 8 [[email protected]7 ~]# cd MyProject/ 9 [[email protected]7 MyProject]# ls 10 include library source 11 [[email protected]7 MyProject]# cd source/ 12 [[email protected]7 source]# ls 13 Arithmetic DataStructrue Experiment tcpip 14 C++Primer DesignPattern MultiThread 15 [[email protected]7 source]# cd DataStructrue/ 16 [[email protected]7 DataStructrue]# ls 17 ChainList 18 [[email protected]7 DataStructrue]# cd ChainList/ 19 [[email protected]7 ChainList]# ls 20 ChainList.h ChainNode.h main.cpp main.out 21 [[email protected]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 [[email protected]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 #endif
链表的应用
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; } }
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; } }
以上是关于链表的应用——箱子排序和基数排序的主要内容,如果未能解决你的问题,请参考以下文章