7. 排序
(1)知识点
① 排序对象
一般意义上的排序可以是任意文件,而文件是由一组记录组成的。记录则由若干数据项/域组成。其中有一项可用来标识其所属的记录,该项称为关键项,而关键项的值称为关键字(Key)。
常见的排序对象有数组、链表、二叉树。
② 排序的稳定性
在待排序的文件中,若存在多个关键字相同的记录,经过排序后这些具有相同关键字的记录之间的相对次序保持不变,则称这种排序方法是稳定的。若具有相同关键字的记录之间的相对次序发生变化,则称这种排序方法是不稳定的。
冒泡排序法、归并排序法是稳定的排序法。而其他的排序法是不稳定的。
③ 排序方法的分类
i O(n2)量级的排序算法
按排序性能好坏依次为:插入排序法(也称直接插入排序法)、选择排序法、交换排序法、冒泡排序法
另外,当文件接近正序时,直接插入排序法和冒泡排序法均最佳。
【规律】直接插入排序法的双层循环为外前内后(内层循环索引的初始值为外层索引变量i);而选择排序法和交换排序法为内外均前(内层循环索引的初始值为i+1),且选择排序法由于smallPos的引入而使得其不用再内层循环中进行交换操作,进而优于交换排序法(除此之外,二者几乎相同);而冒泡排序法为外后内前(内层循环索引的终止值为外层索引变量i-1)。
ii O(n1.25)量级的排序算法
希尔(Shell)排序法
iii O(nlog2n)量级的排序算法
按排序性能好坏依次为:快速排序法、堆排序法、归并排序法、二叉搜索树排序法、竞赛排序法。
(2)插入排序法
//对“插入”的理解:如果有个元素大于temp(新插入元素的值),则将这个元素及其后的元素依次后移,然后将temp
//插入到最后移位的那个元素留下的空缺位处,这便是“插入”之所在。
void insertSort(int a[], int n)
{
int i, j;//用于循环的索引
int temp;//插入排序法所特有,也即为插入排序法的主线
for(i=1; i<n; ++i) //外前内后
{
temp = a[i];
//在未插入新元素之前,原来的元素是已排好序的,故如果出现a[j-1]<temp时,则a[j-1]之前的元素也
//一定小于temp,于是,循环可以就此终止了。
//【注】下面的循环判断条件一定是与temp进行比较,如果比temp大则后移。
for(j=i; j>0 && temp<a[j-1]; --j) //外前内后,且内层循环索引的初始值为外层索引变量i
a[j] = a[j-1];//右移表中元素
a[j] = temp;//这时的a[j]恰好是最后一次右移时的a[j-1],这位每次循环后都要执行“--j”。
}
}
(3)选择排序法
在前n个元素中选择最小的放入第0个位置,在后n-1个元素中选择最小的放入第1个位置,……
void selectSort(int a[], int n)
{
int smallPos;//选择排序法所特有的,也是选择排序法的主线
int i, j;//用于排序
for(i=0; i<n-1; ++i) //内外均前
{
smallPos = i;
for(j=i+1; j<n; ++j) //内外均前,且内层循环索引的初始值为i+1
if(a[j]<a[smallPos])
smallPos = j;
swap(a[i], a[smallPos]); //C++标准库中的swap函数,不需头文件
}
}
(4)交换排序法
//交换排序法是最一般的排序法:
void exchangeSort(int a[], int n)
{
int i, j;
for(i=0; i<n-1; ++i) //内外均前
for(j=i+1; j<n; ++j) //内外均前,且内层循环索引的初始值为i+1
if(a[j] < a[i])
swap(a[j], a[i]); //C++标准库中的swap函数,不需头文件
}
(5)冒泡排序法
事实上,冒泡法更合理地应该叫做“沉底法”,因为经历第i次内层循环后,就会将第n-i+1大的元素沉到相应位置。最终的结果便是最小的元素浮到最前面,……
void bubbleSort(int a[], int n)
{
int lastExchangePos;//冒泡排序法所特有的,也是冒泡排序法的主线
int i, j;//用于排序
for(i=n-1; i>0; i=lastExchangePos) //外后内前
{
lastExchangePos = 0; //防止内层没有发生交换时lastExchangePos不改变以至变为死循环
for(j=0; j<i; ++j) //外后内前,且内层循环索引的终止值为外层索引变量i-1
if(a[j+1]<a[j])
{
swap(a[j+1], a[j]); //C++标准库中的swap函数,不需头文件
lastExchangePos = j;
//助记:如果内层循环完全执行,则最后依次交换后j=i-1,
//而lastExchangePos也变为i-1,即仅前移一步,这是合理的
}
}
}
(6)希尔(Shell)排序法
希尔排序法是插入排序法的一种,因D.L.Shell于1959年提出而得名。希尔排序法的时间性能优于直接插入排序法,它是O(n1.25)量级的排序法,而后者是O(n2)量级的排序法。
希尔排序法的性能优于直接插入排序法的原因:
在希尔排序开始时,增量较大,分组(第二层循环)较多,每组的记录数目少,故各组内直接插入较快。后来增量d逐渐缩小,分组数逐渐减少,而各组的记录数目逐渐增多。但由于已经按d-1的距离排过序,使得文件较接近于有序状态,所以新的一趟排序过程也较快(因为当文件接近正序时,直接插入排序法和冒泡排序法均最佳)。因此,希尔排序在效率上较直接插入排序法有较大的改进。
void shellSort(int *a, int n)
{
int i, j;//用于循环的索引
int temp, d;
//temp为插入排序法所特有,也即插入排序法的主线;而d代表distance,即每次循环时的增量或需要互换的两元素的距离,d为
//希尔排序法的主线
for(d=n/2; d>0; d/=2) //控制增量
{
//这个实际上就是直接插入排序法的扩展版本,因为当d=1时,控制增量循环内的这两层循环完全就是直接插入法的循环,
//这再次印证了希尔排序法属于插入排序法这一结论。
for(i=d; i<n; ++i)
//这里i为逐一递增,只有这样才能保证每次外层循环所有的元素都可能进行互换,从而更快地完成排序
{
temp = a[i];
//【注】循环判断条件一定是与temp进行比较,如果比temp大则后移。
for(j=i; j>0 && temp<a[j-d]; j-=d) //内层循环的步长为d
a[j] = a[j-d];
a[j] = temp;
}
}
}
(7)快速排序法
① 方法一:
【注】方法一是方法二的改进版,因此只需用方法一即可,方法二仅供参考。
方法一的最坏情况:当原数组为升序或降序时,复杂度为O(n2)。
void quickSort(int a[], int low, int high)
//由于要进行递归编程,故要多设一个参数(由n变为low和high)用作递归控制
{
int i, j;//i和j分别是正向扫描和逆向扫描的索引
if(low < high)
{
//先扫描后判断
do
{
//i只需扫描到high-1、j只需扫描到low+1即可,因为如果只有两个元素时,第一个循环就不用执行,而直接执行第二
//个循环中的if语句来决定是否需要互换即可(当前一元素大于后一元素时,执行--j,进而j<i或者说j=low,从而在
//外层循环后的swap语句相当于没执行;反之,不执行--j,于是在循环内的swap执行后变为顺序了,此后,还要进行
//一次外层循环,这时便又回到了第一种情况)。
//【注】将数组的第一个元素a[low]视为中心点(由于数组排列的随机性,将任何元素视为起始中心点其实都一样)
for(i=low+1; i<high; ++i)
if(a[i] > a[low])
break; //如果左端出现大于a[low]的元素则结束该内层循环
for(j=high; j>low; --j)
if(a[j] <= a[low]) //注意这里一定是小于等于,否则前后内层循环不能覆盖所有的情况
break; //如果右端出现小于a[low]的元素则结束该内层循环
if(i < j) //执行此语句之前,前两个内层循环一定都已经遇到终止循环点,即a[i]>a[low] && a[j]<=a[low],由此
//也可以得知i一定不等于j,所以,如果不满足该if语句的话,则i>j。
swap(a[i], a[j]); //保证了比pivot小的存放在左边,比pivot大的存放在右边
}
while(i < j); //当i>j时才终止循环
//将中心点移到正确的位置(即j所在的位置)
swap(a[j], a[low]);
//因为在交换前,a[low+1]~a[j]的元素都小于等于a[low],a[j+1]~a[high]的元素都大于a[low],所以将原数组分为两部
//分,而上一步得到的中心点不再参加递归,故交换后低端子表范围为low~j-1;高端子表为j+1~high,并将上一步未交换
//时的a[j]作为新的中心值a[low]
quickSort(a, low, j-1);
quickSort(a, j+1, high);
}
}
② 方法二:
【注】方法一是方法二的改进版,因此只需用方法一即可,方法二仅供参考。
方法二的最坏情况:当中心值总是落在一个单元素子表中,而其余元素都在另一个子表中,复杂度为
//由于要进行递归编程,故要多设一个参数(由n变为low和high)用作递归控制
void quickSort(int a[], int low, int high)
{
int mid, pivot;//mid:中心元素的索引;pivot:['pivət],中心点,重点,中心元素的值
int scanUp, scanDown;//用于扫描
//特例:最多只有一个元素
if(high-low <= 0)
return;
//终止条件
if(high-low == 1)//只有两个元素
{
if(a[high]<a[low])
swap(a[high], a[low]);//C++标准库中的swap函数,不需头文件
return;
}
//初始中心元素的索引和值
mid = (low+high)/2;
pivot = a[mid];
swap(a[low], a[mid]);//C++标准库中的swap函数,不需头文件
//scanUp与scanUp的初始值
scanUp = low+1;
scanDown = high;
//先扫描后判断
do
{
while(scanUp<=scanDown && a[scanUp]<=pivot)
++scanUp;
while(scanUp<=scanDown && a[scanDown]>pivot)
--scanDown;
//【注】经过前面的两轮扫描,不可能再有scanUp==scanDown的情况出现了
if(scanUp<scanDown)
swap(a[scanUp], a[scanDown]);//C++标准库中的swap函数,不需头文件
}
while(scanUp<scanDown);
//当前面的扫描循环执行完毕后,scanUp刚好到了高端子表中(即高端子表的第一个元素),而
//scanDown处在中心位置,并将中心点移到中心位置处。
a[low] = a[scanDown];
a[scanDown] = pivot;
//上一步得到的中心点不再参加递归,故低端子表范围为low~scanDown-1;高端子表为scanDown+1~high
if(low<scanDown-1)
quickSort(a, low, scanDown-1);
if(scanDown+1<high)
quickSort(a, scanDown+1, high);
}
(8)堆排序法
堆是按由小到大(双亲值小于或等于其孩子值,称为最小堆(minimum heap))或由大到小(双亲值大于或等于其孩子值,称为最大堆(maximum heap))的顺序将一系列数据以完全二叉树的形式存放的一种非线性表。
该方法用到了二叉树的顺序(数组)表示方式(也称为基于数组的二叉树)。堆是结点间具有层次次序关系和抽象表机构的完全二叉树。
对于每次插入和删除操作,堆仅需扫描从根到树的末端的短路径即可恢复其顺序(堆化)。
堆排序原理:将未排序的数组通过数组堆化构造函数生成堆(最小堆)的形式,然后通过deleteHeap()函数将堆化数组排序成由大到小的顺序。
#include <stdafx.h>
#include <iostream>
using namespace std;
class Heap
{
private:
int *hList;//hList指向一个数组,代表heap list
int maxSize, size;//maxSize是堆中可存放的元+素的最大个数,size是当前堆大小
void filterUp(int i);//用在insertHeap函数中
void filterDown(int i);//用在deleteHeap和堆化数组的构造函数中
public:
Heap(int a[], int n);//通过数组来构造堆
//由于在堆排序中不会用到下面的四个成员函数,因此这里就不做定义了
/*Heap(int maxSize);//构建空堆
Heap(const Heap &h);//复制构造函数
~Heap();
Heap & operator=(const Heap &h);*/
// 公有成员函数中最要的是堆插入函数和堆删除函数,且他们分别调用私有成员函数filterUp和filterDown
void insertHeap(const int item); //堆插入函数
int deleteHeap();//堆删除函数,返回最小值以便进行堆排序
};
//向上堆化函数
//用在insertHeap函数中,并非数组堆排序所必须的函数
//【注】向上堆化和向下堆化都是对数组进行堆化,差别仅在于新插入值位于表尾还是表首。
//由于堆插入时是将一个新结点加入到原堆的最后一个位置,于是要想调整顺序使其堆化,
//则必须向上进行位置调整,于是便有了“filterUp”。
//【注】由于是在已经堆排序了的堆尾进行插入新结点的,所以与直接插入排序法很相似,这也是该函数与直接插入排序法实现上相
//似的根本原因。而由于是在堆尾插入,所以循环顺序为从下到上。
void Heap::filterUp(int i)//【注】参数是要进行排序的元素的索引
{
int currPos, parentPos;//注意,这里currPos和parentPos是数组的索引,而非指针
//以下完全是直接插入排序法内层循环的思想
int temp = hList[i];//向上堆化涉及到了直接插入排序法,而temp又是后者所特有的
//向下堆化是用currPos来控制边界的,因为当currPos为0时则不会执行下面的语句从而就不会出现hList[parentPos]以致错误
//在下面的循环控制条件中,当currPos为零时,可以看作在空表中插入,故必须考虑
for(currPos=i,parentPos=(i-1)/2; currPos!=0 && temp<hList[parentPos];
currPos=parentPos,parentPos=(parentPos-1)/2) //直接插入排序法:外前内后,而这里是内层循环的思想,故从后向前
hList[currPos] = hList[parentPos]; //依次下移
hList[currPos] = temp;
}
//堆插入函数,并非数组堆排序所必须的函数
void Heap::insertHeap(const int item)
{
if(size == maxSize)
{
cerr << "heap full" << endl;
exit(1);
}
hList[size] = item;//开始时,将item插入到堆的末尾
filterUp(size++);//对新插入的结点进行堆化,参数是新插入结点的索引
}
//向下堆化函数
//用在deleteHeap和数组堆化构造函数,是数组堆排序所必须的函数
//【注】向上堆化和向下堆化都是对数组进行堆化,差别仅在于新插入值位于表尾还是表首。因此,实现方法也类似于直接插入排序
//法。由于是在堆首插入,所以循环顺序为从上到下。
void Heap::filterDown(int i)//【注】参数是要删除的元素的索引
{
int currPos, childPos;
int temp = hList[i];//向下堆化涉及到了直接插入排序法,而temp又是后者所特有的
//以下除了比较两个孩子大小外,其余完全是直接插入排序法的思想
//由于是在表首插入,因此,这里的循环起始值与前面的直接插入排序法的内循环起始插入值不同,这里是从前向后进行循环
for(currPos=i,childPos=2*i+1; childPos<size; currPos=childPos,childPos=2*childPos+1)
//向下堆化是用childPos来控制边界的,因为循环中涉及到了hList[childPos]
{
//千万不要漏掉“childPos+1<size”这个条件,以保证右孩子不会出界,否则程序会出错
//向下堆化是用孩子结点来控制边界的,但这里还涉及到了右孩子,故以childPos+1(最先接触边界)来控制边界
if(childPos+1<size && hList[childPos]>hList[childPos+1])
++childPos;//置childPos为较小孩子的下标
if(temp > hlist[childPos]) //由于在for语句中childPos还未确定,故不能放在for中作为条件判断
hlist[currPos] = hlist[childPos]; //依次上移
//由于是表首插入,故进行的是前移/上移(与直接插入排序法相区别)
else
break; //由于之前childPos未确定而导致循环控制条件被滞后
}
hList[currPos] = temp;
}
//堆删除函数,是数组堆排序所必须的函数
int Heap::deleteHeap()
{
if(0 == size)
{
cerr << "heap empty" << endl;
exit(1);
}
int item = hList[0];//要返回的值,也即当前堆的根元素
hList[0] = hList[--size];//将堆的末元素来替代根元素,并将size减一
filterDown(0);//对新堆进行堆化
return item;
}
//数组堆化构造函数,是数组堆排序所必须的函数
Heap::Heap(int a[], int n)
{
if(n <= 0)
{
cerr << "bad size" << endl;
exit(1);
}
int currPos;
maxSize = n;
size = n;
hList = a;
//将currPos置为最大双亲结点的下标,然后依次进行向下堆化,直到currPos为0时进行最后一次堆化
for(currPos=((size-1)-1)/2; currPos>=0; --currPos)
filterDown(currPos); //可见,堆排序其实只需filterDown函数而不需insertSort函数
}
//堆排序函数,是数组堆排序所必须的函数
void heapSort(int a[], int n)
{
Heap h(a, n);//先对数组a进行堆化
for(int i=n-1; i>0; --i) //i=0时就不用再排序了,因为最后得到的a[0]一定是最大值
a[i] = h.deleteHeap();//依次删除堆中的根元素,从而达到排序的目的
//这是因为堆每执行依次deleteHeap操作后,就将size减一,于是以后不再对该元素进行操作,
//因此,可以将以删除的元素依次从数组的末端向前逆向存储,这样到最后,数组便被排好序了,
//而且不占用其他数组。只不过数组最后是从大到小的(对最小堆而言)
}
int main()
{
int arr[10] = {5,0,6,9,3,4,7,8,2,1};
heapSort(arr, 10);
int i;
for(i=0; i<10; ++i)
{
cout << arr[i] << " ";
}
cout << endl;
}
(9)归并排序法
归并排序法是一种外部存储设备最常用的排序方法。这种排序法可以分为两个步骤,如下所示:
① 将排序数据先分成几个文件,其中每个文件的大小是可以加载到内存中,然后使用适当的内部排序法运行排序,最后将排序完成的数据写回文件。
② 将第一步中创建的文件两两合并成一个文件,等到全部的文件都归并成一个文件后,文件数据也就排好序了。
一般将归并排序法的函数名设为mergeSort,具体如下:
#include <stdafx.h>
#include <iostream>
using namespace std;
void merge(int a[], int left, int mid, int right)
{
//在递归在里层,right-left=1且mid=left,所以左子数组只有a[left],而右子数组只有a[right],将二者较小的存入tempArr[0],
//将较大的存入tempArr[1];以此类推,将前面已经排好序的左右子数组通过下面的操作可以达到新的数组的排序结果。
//【思想】归并排序法的合并过程就是将两个已经排好序的数组合并并按先前的顺序(从小到大)排列成新的有序数组的方法。
int *tempArr = new int [right-left+1]; //临时数组,用来存放排序的结果,从小到大依次存放
int i=left, j=mid+1, k=0; //分别为左子数组(0~mid)、右子数组(mid+1~right)、临时数组(tempArr)的索引k
while(i<=mid && j<=right) //情况一:两个子数组均未合并完毕
if(a[i] <= a[j])
tempArr[k++] = a[i++];
else
tempArr[k++] = a[j++];
//【注】下面的两种情况不能同时发生
while(i <= mid) //情况二:后一个子数组已经合并完毕
tempArr[k++] = a[i++];
while(j <= right) //情况三:前一个子数组已经合并完毕
tempArr[k++] = a[j++];
for(i=0; i<k; i++) //这时k为合并后的数组的元素个数
a[left++] = tempArr[i];
delete [] tempArr;
}
void mergeSort(int a[], int left, int right)
{
if(right > left)
{
//先分后合
int mid = (left+right)/2;
mergeSort(a, left, mid); //递归,直到left=mid。左子树:left~mid
mergeSort(a, mid+1, right); //递归,直到mid+1=right。右子树:mid+1~right
merge(a, left, mid, right); //将已排好序的子数组进行合并
}
}
void mergeSort(int a[], int len)
{
mergeSort(a, 0, len-1);
return;
}
int main()
{
int a[] = {9,8,7,6,5,4,3,2,1,0};
mergeSort(a, 10);
for(int i=0; i<10; i++)
cout << a[i] << " ";
cout << endl;
return 0;
}
(10)二叉搜索树排序法
详见“6. 二叉搜索树”。
步骤:对一个数组构造其对应的二叉搜索树,然后根据LNR遍历顺序将各结点的数据复制回原数组中,从而达到排序的作用。
#include <stdafx.h>
#include <iostream>
using namespace std;
struct TreeNode
{
int data;
TreeNode *left;
TreeNode *right;
};
//二叉搜索树排序法仅需要BinSTree类的inertNode、_insertNode以及默认构造函数。
class BinSTree
{
public:
TreeNode *root;//设为public是为了方便BSTSort函数的调用
private:
void _insertNode(TreeNode *&node, TreeNode *newNode);
public:
BinSTree():root(NULL){};
//【注】必须自定义默认构造函数,因为BinSTree类的数据成员是TreeNode类型,属于自定义类型,
//编译器是不会也不可能对其初始化的(因为编译器并不了解自定义类型)
void insertNode(const int item);
};
void BinSTree::_insertNode(TreeNode *&node, TreeNode *newNode)
{
if(NULL == node)
node = newNode;//BST的新结点始终作为叶子结点插入到二叉树中的,从而简化了问题
else if(newNode->data < node->data)
return _insertNode(node->left, newNode);
else
return _insertNode(node->right, newNode);
}
void BinSTree::insertNode(const int item)
{
TreeNode *newNode = new TreeNode;
newNode->data = item;
newNode->left = NULL;
newNode->right = NULL;
_insertNode(root, newNode);
}
//以下为二叉搜索树排序法的主程序(非BinSTree类的成员函数):
void _BSTSort(TreeNode *node, int a[], int &k)
{
if(NULL == node)
return;
_BSTSort(node->left, a, k);
a[k++] = node->data;
_BSTSort(node->right, a, k);
}
void BSTSort(int a[], int n)
{
BinSTree bst;
for(int i=0; i<n; ++i)
bst.insertNode(a[i]);
int k = 0;//由于引用是针对变量而言的,故要将赋给变量k,然后再将该变量传递给递归函数用来控制_BSTSort
_BSTSort(bst.root, a, k);
}
int main()
{
int a[10] = {9,6,8,2,4,3,7,5,1,0};
BSTSort(a, 10);
for(int i=0; i<10; ++i)
cout << a[i] << " ";
cout << endl;
}
(11)竞赛排序法
该方法用到了二叉树的顺序(数组)表示方式(也称为基于数组的二叉树),即用数组项存储数据以及标识结点的索引。
【注】结点a[i]的双亲索引为:(i-1)/2(i>0);而结点a[i]的左孩子的索引为2i+1,右孩子的索引为2i+2。
【原理】
二叉树的重要应用之一是作为抉择树(decision tree)。树中每个结点都代表一个具有两个可能去向的分支。这类应用的一个例子是用树存储参加一种单淘汰比赛的运动员的记录,直至有一个运动员赢得决赛。这便是竞赛排序法的“竞赛”之来由。
如果用竞赛排序法对N个表项进行排序,则需建立一棵基于数组的树,该N个表项作为叶子结点位于树的底层(第k层,其中2k>N)。例如,如果要对10个元素进行竞赛排序,则这些元素需放在基于数组的树的第4层。
虽然竞赛排序法的比较次数为O(nlog2n)量级,但是空间复杂度却较高。整棵树需要2n-1(当竞赛树为满树时)个结点以供k-1个轮次的比赛。
【例】通过竞赛排序法,还可以很容易找到一个数组的最大值、最小值以及最大值和最小值,它们分别需要n-1、n-1和(n-1)+(n/2-1)次比较运算。对于前两种情况,可以用一般的遍历方法,也可以用竞赛排序法,最后结果都是n-1次比较运算。但是对于最后一种情况,通过竞赛排序法更易得出结果:在第一轮竞赛排序完毕后,有一半的元素是大的,另一半是小的,其中最大值只需在大的那一半元素中查找即可,而最小值只需在小的那一半元素中查找即可,并且两部分从此互相独立。假设查找最大值的次数包括第一轮竞赛所用的比较次数,于是查找最大值用了n-1次比较运算,那么查找最小值的次数从另外的n/2个元素开始进行竞赛排序,又用了n/2-1次比较运算,因此总共需要(n-1)+(n/2-1)次比较运算。