4.堆排序
在介绍堆排序之前,还得说一说堆的概念。简单来说,最大堆就是一个从上而下的每一层都满足从大到小顺序的完全二叉树。
那么最简单的堆排序就是每次取出堆顶元素,并且保持剩下的元素依旧构成一个最大堆,就可以实现堆排序了。具体的相关操作参考连接的博文内容。
所以堆排序的排序方法非常简单,因为重点在堆结构的操作上。源码如下:
- //优先队列:堆MaxHeap的定义与使用
- #include <iostream>
- using namespace std;
- void OutOfBounds(){
- cout<<"Out Of Bounds!"<<endl;
- }
- void BadInput(){
- cout<<"Bad Input!"<<endl;
- }
- void NoMem(){
- cout<<"No Memory!"<<endl;
- }
- template<class T>
- class MaxHeap{
- public:
- MaxHeap(int MaxHeapSize = 10);
- int Size()const{ return CurrentSize;}
- T Max(){
- if (CurrentSize == 0)
- throw OutOfBounds();
- return heap[1];
- }
- MaxHeap<T>& Insert(const T&x);
- MaxHeap<T>& DeleteMax(T&x);
- void Initialize(T a[],int size,int ArraySize);
- void Output(ostream& out)const;
- int CurrentSize;
- int MaxSize;
- T *heap;//元素数组
- };
- //输出链表
- template<class T>
- void MaxHeap<T>::Output(ostream& out)const{
- for (int i= 0;i<CurrentSize;i++)
- {
- cout<<heap[i+1]<<" ";
- }
- cout<<endl;
- }
- //重载操作符
- template<class T>
- ostream& operator<<(ostream& out,const MaxHeap<T>&x){
- x.Output(out);
- return out;
- }
- template<class T>
- MaxHeap<T>::MaxHeap(int MaxHeapSize /* = 10 */){
- MaxSize = MaxHeapSize;
- heap = new T[MaxSize+1];
- CurrentSize = 0;
- }
- //将x插入到最大堆中
- template<class T>
- MaxHeap<T>& MaxHeap<T>::Insert(const T&x){
- if(CurrentSize==MaxSize)
- throw NoMem();
- //为x寻找插入位置
- //i从新的叶结点开始,并沿着树慢慢上升
- int i = ++CurrentSize;
- while(i!=1&&x>heap[i/2]){
- //不能把x放到heap[i]
- heap[i] = heap[i/2];//将元素下移
- i/=2;
- }
- heap[i] = x;
- return *this;
- }
- //将最大的元素放到x并从堆中删除
- template<class T>
- MaxHeap<T>& MaxHeap<T>::DeleteMax(T&x){
- //检查堆是否为空
- if(CurrentSize==0)
- throw OutOfBounds();
- x = heap[1]; //取出最大元素并放入x中
- T y = heap[CurrentSize]; //y为最后一个元素
- CurrentSize--;
- //从根开始为y寻找合适的位置
- int i = 1; //堆的当前节点
- int ci = 2; //i的孩子
- while(ci<=CurrentSize){
- //heap[ci]应该是较大的孩子
- if(ci<CurrentSize&&heap[ci]<heap[ci+1])
- ci++;
- //能否把y放入heap[i]
- if(y>=heap[ci])
- break;
- heap[i]=heap[ci];
- i = ci;
- ci = 2*ci;
- }
- heap[i]=y;
- return *this;
- }
- //把最大堆初始化为数组a
- template<class T>
- void MaxHeap<T>::Initialize(T a[],int size,int ArraySize){
- delete []heap;
- heap = a;
- CurrentSize = size;
- MaxSize = ArraySize;//数组空间大小
- //产生一个最大堆
- for (int i = CurrentSize/2;i>=1;i--){
- T y = heap[i]; //子树的根
- //寻找放置y的位置
- int c = 2*i; //c的父节点是y的目标位置
- while(c<=CurrentSize){
- //heap[c]应该是较大的同胞节点
- if(c<CurrentSize&&heap[c]<heap[c+1])
- c++;
- //能否把y放入heap[c/2]
- if(y>=heap[c]) //能把y放入heap[c/2]
- break;
- //不能把y放入heap[c/2]
- heap[c/2]=heap[c]; //将孩子上移
- c=2*c; //下移一层
- }
- heap[c/2] = y;
- }
- }
- template<class T>
- void HeapSort(T a[],int n)
- {
- MaxHeap<T>H;
- H.Initialize(a,n-1,20);
- T x;
- for (int i=n-1;i>=1;i--)
- {
- show(a,10);
- H.DeleteMax(x);
- a[i]=x;
- }
- }
- template <class T>
- void show(T arr,int n){
- for(int i =1;i<n-1;i++){
- cout<<arr[i]<<",";
- }
- cout<<arr[n-1]<<endl;
- }
- int main(){
- MaxHeap<int>myHeap;
- const int number = 10;
- int myArray[number+1] = {-1,2,7,5,9,1,4,6,3,8};
- cout<<"原始数组:"<<endl;
- show(myArray,number);
- cout<<"排序过程:"<<endl;
- // myHeap.Initialize(myArray,number,20);
- HeapSort(myArray,number);
- cout<<"排序结果:"<<endl;
- show(myArray,number);
- return 0;
- }
代码执行的过程图如下:
下面来分析一下堆排序的复杂度。
它的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。
在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,
将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)。
在正式排序时,第i次取堆顶记录重建堆需要用O(log i)的时间。完全二叉树的某个结点到根结点的距离为log(i+1),并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)。
所以总体来说,堆排序的时间复杂度为O(nlogn)。
由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、直接插入的O(n2)的时间复杂度了。
空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。
另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。
下面来分析一下堆排序的稳定性:
我们知道堆的结构是节点i的孩子为2*i和2*i+1节点,大顶堆要求父节点大于等于其2个子节点,小顶堆要求父节点小于等于其2个子节点。在一个长为n的序列,堆排序的过程是从第n/2开始和其子节点共3个值选择最大(大顶堆)或者最小(小顶堆),这3个元素之间的选择当然不会破坏稳定性。但当为n/2-1, n/2-2,...1这些个父节点选择元素时,就会破坏稳定性。有可能第n/2个父节点交换把后面一个元素交换过去了,而第n/2-1个父节点把后面一个相同的元素没有交换,那么这2个相同的元素之间的稳定性就被破坏了。所以,堆排序不是稳定的排序算法。
下面来总结一下堆排序的要点:
原理:利用大根堆或小根堆思想,首先建立堆,然后将堆首与堆尾交换,堆尾之后为有序区。
要点:建堆、交换、调整堆。
5.快速排序:
快速排序的核心思想是分而治之算法。所谓的分而治之,简单来说就是把复杂问题分成几个子问题,然后分别解决小问题,最后再将解组合起来,得到原问题的解。
那么分而治之如何应用到排序算法中呢?
在快速排序中,n个元素被分成了了三段。左端left,右端right,和中段middle。中段仅包含一个元素,作为基准元素,左段的各元素都小于等于中段元素,右段元素都大于等于中段元素。middle元素被称为支点。
基本的操作流程大致如下:在待排序的n个元素中任意选择一个作为基准元素(通常取第一个),把该元素放入最终的位置上,数据序列被此元素划分成两部分,所有关键字比该元素关键字小的元素放置在前一部分,所有比它大的元素放在后一部分,这个过程称为一趟快速排序。对分成的两部分重复上述过程,直到每部分只有一个元素或空为止。
快排的源码如下:
- #include <iostream>
- using namespace std;
- template <class T>
- void show(T arr,int n){
- for(int i =0;i<n-1;i++){
- cout<<arr[i]<<",";
- }
- cout<<arr[n-1]<<endl;
- }
- void QuickSort( int a[], int l, int r )
- {
- show(a,9);
- if (l>=r) return;
- int i, j, temp;
- temp = a[l];
- i = l; j = r;
- while (i<j) {
- while(i<j&&temp<a[j])
- j--;
- a[i] = a[j];
- while(i<j&&temp>a[i])
- i++;
- a[j] = a[i];
- }
- a[i] = temp;
- QuickSort( a, l, i-1 );
- QuickSort( a, i+1, r );
- }
- void main()
- {
- int inputNumber[]={2,7,5,9,1,4,6,3,8};
- int count = 9;
- cout<<"原始数组:"<<endl;
- show(inputNumber,count);
- cout<<"排序过程:"<<endl;
- QuickSort(inputNumber,0,count);
- cout<<"排序结果:"<<endl;
- show(inputNumber,count);
- }
程序运行结果的截图:
下面来谈一下快排的复杂度问题。
快速排序的时间性能取决于快速排序递归的深度,可以用递归树来描述递归算法的执行情况。
比如{50,10,90,30, 70,40,80,60,20}在快速排序过程中的递归过程。由于我们的第一个关键字是50,正好是待排序的序列的中间值,因此递归树是平衡的,此时性能也比较好。
在最优情况下,Partition每次都划分得很均匀,如果排序n个关键字,其递归树的深度就为.log2n.+1(.x.表示不大于x的最大整数),即仅需递归log2n次,需要时间为T(n)的话,第一次Partiation应该是需要对整个数组扫描一遍,做n次比较。然后,获得的枢轴将数组一分为二,那么各自还需要T(n/2)的时间(注意是最好情况,所以平分两半)。于是不断地划分下去,我们就有了下面的不等式推断。
T(n)≤2T(n/2) +n,T(1)=0
T(n)≤2(2T(n/4)+n/2) +n=4T(n/4)+2n
T(n)≤4(2T(n/8)+n/4) +2n=8T(n/8)+3n
……
T(n)≤nT(1)+(log2n)×n= O(nlogn)
也就是说,在最优的情况下,快速排序算法的时间复杂度为O(nlogn)。
在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。如果递归树画出来,它就是一棵斜树。此时需要执行n‐1次递归调用,且第i次划分需要经过n‐i次关键字的比较才能找到第i个记录,也就是枢轴的位置,因此比较次数为
最终其时间复杂度为O(n2)。
平均的情况,设枢轴的关键字应该在第k的位置(1≤k≤n),数学归纳法可证明,其数量级为O(nlogn)。
再来看下快排的稳定性:
快速排序有两个方向,左边的i下标一直往右走,当a[i] <=a[center_index],其中center_index是中枢元素的数组下标,一般取为数组第0个元素。而右边的j下标一直往左走,当a[j]> a[center_index]。如果i和j都走不动了,i <= j,交换a[i]和a[j],重复上面的过程,直到i>j。交换a[j]和a[center_index],完成一趟快速排序。在中枢元素和a[j]交换的时候,很有可能把前面的元素的稳定性打乱,比如序列为5 3 3 4 3 8 9 10 11,现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱,所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j]交换的时刻。
下面来总结一下快速排序:
原理:不断寻找一个序列的中点,然后对中点左右的序列递归的进行排序,直至全部序列排序完成,使用了分治的思想。
要点:递归思想,分而治之。
6.归并排序:
下面来看一个和快速排序相似的算法,它们都用到了分而治之的思想,但是细节操作却不一样。
归并排序的核心思想是,合并两个已排序的表。两个已排序的表a、b, 另一个表c用来存放结果,第一次取出a表和b表的最顶端元素进行比较,把较小(较大)的取出放到c表中,第二趟,继续取出a,b表中的最顶端元素比较,把较小(较大)的取出放到c表的下一个位置,重复上述步骤,直到a,b表中有一个表的元素已经取完,接着把另一张表的剩余元素按顺序加到c表中,排序结束。
就像是AB两队小孩儿合成一个队C,每次都从AB两个队伍中比较选择个子最矮的小孩放到队伍C里。
归并排序的源码如下:
- #include <iostream>
- using namespace std;
- template <class T>
- void show(T arr,int n){
- for(int i =0;i<n-1;i++){
- cout<<arr[i]<<",";
- }
- cout<<arr[n-1]<<endl;
- }
- /* 将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n] */
- void Merge(int SR[],int TR[],int i,int m,int n)
- {
- int j,k,l;
- for(j=m+1,k=i;i<=m && j<=n;k++) /* 将SR中记录由小到大归并入TR */
- {
- if(SR[i]<SR[j])
- TR[k]=SR[i++];
- else
- TR[k]=SR[j++];
- }
- if(i<=m)
- {
- for(l=0;l<=m-i;l++)
- TR[k+l]=SR[i+l]; /* 将剩余的SR[i..m]复制到TR */
- }
- if(j<=n)
- {
- for(l=0;l<=n-j;l++)
- TR[k+l]=SR[j+l]; /* 将剩余的SR[j..n]复制到TR */
- }
- }
- void MSort(int SR[],int TR1[],int s, int t)
- {
- int m;
- int TR2[10];
- if(s==t)
- TR1[s]=SR[s];
- else
- {
- m=(s+t)/2; /* 将SR[s..t]平分为SR[s..m]和SR[m+1..t] */
- MSort(SR,TR2,s,m);/*递归将SR[s..m]归并为有序的TR2[s..m]*/
- MSort(SR,TR2,m+1,t);/*递归将SR[m+1..t]归并为有序TR2[m+1..t]*/
- Merge(TR2,TR1,s,m,t);/*将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t]*/
- }
- show(TR1,9);
- }
- void main()
- {
- int inputNumber[]={2,7,5,9,1,4,6,3,8};
- int inputNumber2[]={0,0,0,0,0,0,0,0,0};
- int count = 9;
- cout<<"原始数组:"<<endl;
- show(inputNumber,10);
- cout<<"排序过程:"<<endl;
- MSort(inputNumber,inputNumber2,0,9);
- cout<<"排序结果:"<<endl;
- show(inputNumber2,9);
- }
代码运行的结果显示:
我们来分析一下归并排序的时间复杂度,一趟归并需要将SR[1]~SR[n]中相邻的长度为h的有序序列进行两两归并。并将结果放到TR1[1]~TR1[n]中,这需要将待排序序列中的所有记录扫描一遍,因此耗费O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行log2n.次,因此,总的时间复杂度为O(nlogn),而且这是归并排序算法中最好、最坏、平均的时间性能。
由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为log2n的栈空间,因此空间复杂度为O(n+logn)。
另外,对代码进行仔细研究,发现Merge函数中有if (SR[i]<SR[j])语句,这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。
也就是说,归并排序是一种比较占用内存,但却效率高且稳定的算法。
再来看下归并算法的稳定性:
归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中,稳定是是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法。
下面我们可以来总结一下分而治之实现排序的算法思想:若n为1,算法中止,否则,将这一元素集合分割成两个或更多个子集合,对每个子集合分别排序,然后将排好序的子集集合归并为一个集合。
下面来总结一下归并排序:
原理:将原序列划分为有序的两个序列,然后利用归并算法进行合并,合并之后即为有序序列。
要点:归并、分而治之