一、基本概念
通过学习折半查找,我们知道了有序数列的重要性,所以,这里的排序算法就是学习如何建立有序数列。
排序:就是将任意顺序的数据元素按关键字排列成有序的序列。
排序的稳定性:对于序列中相同的元素,如果排序之前和之后,相同元素之间的位置关系不变,那么这种排序就是稳定排序(假设排序前的序列中,ri在rj之前,且ki=kj,如果排序后ri仍然在rj之前,则认为所用的排序算法是稳定的)。
稳定排序算法的好处在于:如果排序算法是稳定的,根据某个键值排序,然后再用另一个键值排序,第一次排序的结果可以为第二次排序所用。
稳定排序算法包括:插入排序、冒泡排序、归并排序、计数排序、基数排序、桶排序
不稳定排序算法包括:选择排序、快速排序、堆排序
内排序和外排序:排序过程完全在内存中进行的排序叫做内部排序。数据量较大需要借助外部存储设备才能完成的排序叫做外部排序。
内部排序算法:交换排序(冒泡和快速排序);插入排序(希尔排序);选择排序(堆排序)。
外部排序算法:归并排序、计数排序、基数排序、桶排序。
二、典型的排序算法
1、冒泡排序(简单算法):相邻数据先比较大小,如果是升序排列,就将大的放在后面,小的放在前面,总体感觉就是大的向下沉,小的向上浮。
基本思路:n个数据保存在a[0]~a[n-1]的数组中,每一轮比较,只找出一个最大的数据并放在最后,最多只需要n-1轮。
设计两层循环,外层循环控制比较的轮数,内层循环控制比较的数据。每一轮找到最大的数并放在本轮的最后,并且这个数据不再参与下一轮的比较。
第一轮,依次比较a[0]~a[n-1],找到最大的数并放在a[n-1]的位置上。
第二轮,依次比较a[0]~a[n-2],找到最大的数并放在a[n-2]的位置上。
第m轮,依次比较a[0]~a[n-m],找到最大的数并放在a[n-m]的位置上。
第n-1轮,只需要比较a[0]~a[1],找到找到最大的数并放在a[1]的位置上。
算法复杂度
时间复杂度:T(n) = O(n²)。
空间复杂度:S(n) = O(1)。
稳定性:稳定排序。
代码实现
void Print(int a[], int n);
void Swap(int* a, int* b);
void BubbleSort(int a[], int n);
void BubbleSort(int a[], int n)
{
for(int i = 0; i < n-1; i++)
{
for(int j = 0; j < n-i-1; j++)
{
if (a[j] > a[j+1])
{
Swap(&a[j], &a[j+1]);
}
}
cout<<"第 "<<i+1<<"轮:";
Print(a, n);
}
}
int main()
{
int a[] = {9,5,7,1,4,3,2,6,8};
cout<<"初始值:";
Print(a, sizeof(a)/sizeof(int));
BubbleSort(a, sizeof(a)/sizeof(int));
//SelectSort(a, sizeof(a)/sizeof(int));
//InsertSort(a, sizeof(a)/sizeof(int));
//ShellSort(a, sizeof(a)/sizeof(int));
//QuickSort(a, 0, sizeof(a)/sizeof(int)-1);
//HeapSort(a, sizeof(a)/sizeof(int));
//MergeSort(a, sizeof(a)/sizeof(int));
getchar();
return 0;
}
void Swap(int* a, int* b)
{
int temp = *a;
*a = *b;
*b = temp;
}
void Print(int a[], int n)
{
for(int j= 0; j<n; j++)
{
cout<<a[j] <<" ";
}
cout<<endl;
}
对冒泡排序常见的改进方法是在内层循环中加入一标志性变量change,用于标志某一趟排序过程中是否有数据交换,如果进行某一趟排序时并没有进行数据交换,则说明数据已经按要求排列好,可立即结束排序,避免不必要的比较过程。
2、快速排序(改进算法):本质上是二分法
基本思路:
1. 在一组数据集合中选择一个基准元素,一般直接选择第一个元素(或者最后一个元素)。
2. 分区(Partition):将基准元素右侧的数据从右向左,基准元素左侧的数据从左向右,分别与基准元素比较,小的数据调整到左侧,大的调整到右侧,完成一轮比较后,基准元素位置确定(下一轮这个基准元素不再参与比较)。
3. 然后分别对基准元素左、右两个集合分别调用同样的方法继续排序(递归),直到整个序列有序。
算法复杂度
时间复杂度:平均T(n) = O(nlogn),最坏O(n²)。
空间复杂度:S(n) = O(nlogn)。
稳定性:不稳定排序。
代码实现
int Partition(int a[], int low, int high)
{
int privotKey = a[low]; //基准元素
while(low < high) //从表的两端交替地向中间扫描
{
//先从右向左搜索,最多到low+1位置。找到比基准元素小的数据就停止,并交换到左端
while(low < high && a[high] >= privotKey) {high--;}
Swap(&a[low], &a[high]); // 将基准值被放在high的位置上,high可能不是最初的high
//再从左向右搜索,最多到high-1位置。找到比基准元素大的数据就停止,并交换到右端
while(low < high && a[low] <= privotKey ) {low++;}
Swap(&a[low], &a[high]); // 将放在high位置上的基准值,交换到low位置上,low也可能不是最初的low
}
static int x = 0;
cout<<"第 "<<x++<<"轮:";
Print(a, 9);
return low; // 循环结束,high==low,表示基准值的位置已确定
}
void QuickSort(int a[], int low, int high)
{
if(low < high)
{
int privotLoc = Partition(a, low, high); //将表一分为二
QuickSort(a, low, privotLoc -1); //递归对低子表递归排序
QuickSort(a, privotLoc + 1, high); //递归对高子表递归排序
}
}
冒泡排序法和快速排序法都属于交换排序法。冒泡排序属于稳定排序,快速排序的平均实际复杂度可以达到O(nlogn)。在实际的软件工程中很少使用冒泡排序法,主要是因为针对大规模的数据,冒泡排序法太慢了(工作中,运用冒泡排序处理代码中的私有数据,这种场景不算。)
3、简单选择排序(简单算法)
基本思路:每轮比较选出一个最小或最大值,找到以后再通过交换放到最前或最后的位置。所以,整体思路和前面的循环冒泡法基本一致,但是每次相邻节点比较完以后并不交换,所以代码有较大差异。
算法复杂度
时间复杂度:T(n) = O(n²)。
空间复杂度:S(n) = O(1)。
稳定性:不稳定排序。
代码实现
void SelectSort(int a[], int n)
{
for (int i = 0; i < n-1; i++)
{
int max_key = 0;
int last_key = n-1-i;
for(int j = 1; j < n-i; j++)
{
if (a[max_key] < a[j])
{
max_key = j;
}
}
// 完成一轮比较以后再只做交换一次
Swap(&a[max_key], &a[last_key]);
cout<<"第 "<<i+1<<"轮:";
Print(a, n);
}
}
4、堆排序(改进算法):堆排序是一种树形选择排序,是对直接选择排序的有效改进。
问题:直接选择排序每一轮比较完成,都会选出当前序列中最大的元素放在当前序列的末尾,然后启动下一轮比较。在这个过程中,每一轮比较只是让队尾的数据排列有序,队首的数据始终是乱糟糟的。如果,每一轮比较过后,除了队尾的数据排列有序,队首的数据也能大致有序,则可以有效减少后面每轮的比较次数。
基本概念:
堆是一种经过排序的完全二叉树,其中任一非叶子节点的数据值均不大于(或不小于)其左孩子和右孩子节点的值。
大根堆:根结点(亦称为堆顶)的关键字是树上所有结点关键字中最大的。
小根堆:根结点的关键字是所有堆结点关键字中最小的。
如下图所示,a表示一个大根堆,b表示一个小根堆
因为是排序,所以还是使用数组来表示一个堆,数组和堆之间按照二叉树的层序遍历方式相互转换。具体如下:
大根堆(a)对应的数组:a[6] = {96, 83, 27, 38, 11, 9};
小根堆(b)对应的数组:b[8] = {12, 36, 24, 85, 47, 30, 53,91};
整出这么多概念,但是这些玩意和排序有毛关系啊。所以,接下来分析一下堆排序的基本思路。
基本思路:
先回忆一下直接选择排序算法,它的逻辑很简单,就是通过每一轮比较选出当前集合中最小或最大的元素放在队列的首部或尾部。这里的最大值和最小值,其实就是大根堆或小根堆的根节点。所以,堆排序的逻辑就是建堆、取出根节点、重新建堆不断循环,直到只有两个节点的堆,并对它们作交换,最后得到有n个节点的有序序列。
说到这里,可能会产生一些疑问:1. 通过建堆找到最大值,一定比直接选择快吗。2. 重新建堆的成本会不会很高???
有了上面的两个问题,接下来的方向就很明确了:
1. n个元素的集合如何建堆
1)将给定的无序数组a[8] = {1,5,7,9,2,4,3,6},转化成一个满二叉树,如下图所示。
2)以创建大根堆为例,目标是树上所有父结点都比自己的儿子结点大。所以,只能从下往上,从后往前调整。就从最后一个父结点开始检查,不满足要求的,原地调整父亲和儿子的关系。这里最后一个父结点是a[3],显然a[3]作为一个父节点是满足(大根堆)要求的,下一个父结点a[2]显然也是满足要求的,接下来的父点节点是a[1],显然他不满足要求。a[1]作为父结点有两个儿子,分别是a[3]和a[4],其中,左儿子a[3]=9,比a[1]要大,所以,立刻将a[1]中的数据5和和a[3]中的数据9交换位置,得到如下图所示。
3)调整到这里,正常我们应该继续判断下一个父结点a[0],但是,现在出现了一个小问题,当前的a[3]结点是5,而他的儿子结点a[7]是6,显然,a[3]变成了一个不合格的父结点,需要交换a[3]和a[7]。虽然出现了这个小波折,但是,总体感觉可控,反正就是父结点发生一次被动调整(父结点作为儿子和自己的父亲发生交换)后,需要再主动和自己的儿子结点比较一次,发现不满足,立刻调整。直到整个子树都满足大根堆的要求。大概的过程应该是下面这个样子:
所以,总结一下创建堆的过程就是:1.从最后一个父节点开始倒退检查;2.每次检查都是在父节点和左右儿子结点三者中选择最大者跟父节点进行交换;3.当前的儿子结点如果本身向下也是父结点的话,而且这个儿子结点在第二步中发生过交换,那么这个结点向下做父亲的资格可能不满足,需要重新检查。
2. 取出根节点后,剩下的n-1个结点如何通过最低成本重新建堆
1)取出大根堆的根结点,其实就是把根结点直接放到数组的尾部,也就是根结点a[0]中的9和最后一片叶子a[7]中的1进行交换。如下图所示:
至此,第一轮排序正式结束,最大值被放在了数组的尾部。但同时,大根堆也被破坏了。后续的工作就是排除a[7]结点,对a[0]~a[6]重新建堆,这个重新建堆的过程,和第一次建堆是不一样的,第一次建堆是从最后一个父结点a[3]开始遍历所有的父结点。而重新建堆的过程,只需要处理一部分受影响的父结点,其它没受到影响的父结点不需要处理。所以,低成本的重新建堆才是堆排序速度更快的关键。
代码实现
void HeapSort(int a[], int n)
{
CreateMaxHeap(a, n); // 第一次新建一个堆
cout<<"第 "<<1<<"轮:";
Print(a, n);
Swap(&a[0], &a[n-1]); // 取出堆顶的最大值交换到数组的尾部
cout<<"第 "<<1<<"轮:";
Print(a, n);
for (int i = 0; i < n-2; i++) // 剩下n-1个数据,需要n-2轮选择,每轮找一个最大值
{
AdjustRoot(a, n-i-1, 0); // 当前根节点a[0]上的数据破坏了堆原则,需要对a[0]结点重新建堆
cout<<"第 "<<i+2<<"轮:";
Print(a, n);
Swap(&a[0], &a[n-i-2]); // 取出堆顶的最大值交换到数组的尾部
cout<<"第 "<<i+2<<"轮:";
Print(a, n);
}
}
void CreateMaxHeap(int a[], int n)
{
// 从树上最后一个父结点开始检查
// 对于n个结点的二叉树,最后一个父结点的编号是(n-1)/2
int rootKey = (n-1)/2;
for (int i = rootKey; i >= 0; i--)
{
AdjustRoot(a, n, i);
}
}
void AdjustRoot(int a[], int n, int rootKey) // 调整指定的根结点,直到满足堆的要求
{
int lLeafKey = 2*rootKey+1;
int rLeafKey = 2*rootKey+2;
while(lLeafKey < n)
{
int maxKey = lLeafKey;
if ((rLeafKey < n)&&(a[lLeafKey] < a[rLeafKey]))
{
maxKey = rLeafKey;
}
if (a[rootKey] < a[maxKey])
{
Swap(&a[rootKey], &a[maxKey]);
// 如果发生了交换,就需要重新审查叶子结点向下作为父亲的资格
rootKey = maxKey;
lLeafKey = 2*maxKey+1;
rLeafKey = 2*maxKey+2;
}
else
{
break;
}
}
}
简单选择排序和堆排序都属于选择排序法。两者都属于不稳定排序,因为存在建堆的成本,所以堆排序典型适用于数据量非常大,且对排序稳定性无要求的场景。
5、直接插入排序(简单算法)
基本思路:
1. 先将数组头两个数据比较后排序。
2. 第三个数据与已经排序的数据比较后,插入合适的位置。后面的数据以此类推。
3. 待插入的数据每次和有序数列比较,所以比较的次数更少,且插入比交换操作的工作量要少。
时间复杂度:T(n) = O(n²)。
空间复杂度:S(n) = O(1)。
稳定性:稳定排序。
代码实现
void InsertSort(int a[], int n)
{
for(int i= 1; i<n; i++) // 从a[1]开始一直到最后一个数据a[n-1],每一个都和前面已排序的所有数据比较
{
int temp = a[i]; // 这一步操作达到2个目的: 1.将a[i]中的数据临时保存
// 2.将a[i]的位置空出来了,前面的数据如果搬移可直接放入
for (int j= i-1; j>=0; j--) // a[i]依次和前面的a[i-1]]...a[0]比较
{
if (temp < a[j]) // 如果发现a[i]比前面的某个数据小,直接交换交换
{
a[j+1] = a[j];
a[j] = temp;
}
else
{
break;
}
}
cout<<"第 "<<i<<"轮:";
Print(a, n);
}
}
当输入数组本身就已排好序的时候,插入排序的时间复杂度为O(n),而快速排序时间复杂度为O(n²)。
当输入数组为倒序时,插入排序的时间复杂度为O(n²)。
另外,插入排序适用于数据量较少的场景。
6、希尔排序(改进算法)
希尔排序也是一种插入排序算法,但是相比直接插入排序算法,它又是一种优化算法。普通的插入排序算法思路很简单,但是会产生大量的数据搬移,哪怕只是一个数据需要插入,都会导致一连串的数据向后移动。当数据量非常大或数据非常乱需要频繁插入的时候,大量的搬移操作会导致直接插入排序算法效率不足。
希尔排序的基本思路是先将整个待排序数组划分成几个小数组,小数组内部先分别执行插入排序,大致有序以后,再统一执行一次直接插入排序。这里先分小组、大致有序的思路就是针对性的解决数据量大,以及数据非常乱的情况。因为希尔排序原理需要的数学知识比较多,这里不深入分析,只是记录一些指导应用的结论:
首先需要确定如何分组,分组不合适,就无法得到大致有序的数列。可惜现有的数学理论对于应该如何分组也没有明确结论,已有的结论就是:1)不要将相邻的数据分成一组。2)可以简单定义一个分组的间隔序列d = {n/2 ,n/4, n/8 …..1}; n为要排序数的个数。当d=n/2的时候,表示同一小组的数据之间间隔(n/2-1)个数据,d=1的时候,表示同一小组的数据完全相邻,即所有数据合成一个分组做最后一次整体排序。
代码实现
void ShellSort(int a[], int n)
{
static int x = 0;
int dk = n/2; // 同一分组的数据之间初始间隔n/2个数据
while (dk >= 1) // 如果分组间隔小于1,结束排序
{
for (int i= dk; i<n; ++i) // 从a[dk]开始,向前间隔dk个数据后比较,确定a[dk]应该插入的位置
{
int temp = a[i]; // 这一步操作达到2个目的: 1.将a[i]中的数据临时保存
// 2.将a[i]的位置空出来了,前面的数据如果搬移可直接放入
for (int j = i - dk; j >= 0; j=j-dk)
{
if (temp < a[j]) // 如果发现a[i]比前面的某个数据小,直接交换交换
{
a[j+dk] = a[j];
a[j] = temp;
}
else
{
break;
}
}
}
dk = dk/2; // 缩小数据间隔重新分组再排一遍
cout<<"第 "<<x++<<"轮:";
Print(a, n);
}
}
直接插入排序和希尔排序都属于插入排序法。两者的稳定性不一样,但希尔排序效率更高。
7、归并排序(改进算法)
归并排序和前面介绍的交换排序、选择排序、插入排序相比,既有联系,又有较大差异。归并排序的思路是先将一个大的数据表分割成多个小数据表并分别排序,然后将小的有序表合并(也称为归并)成一个大的有序表。这种分组的思想在快速排序和希尔排序中都有体现,但是在归并的过程中,归并排序算法还需要与待排序序列一样多的辅助空间。
将两个有序表合并成一个有序表的过程被称为2路归并。
首先,分析一下,两个有序数列合并成一个有序数列的大致处理过程,以及在这个过程中,以及这个过程在时间上的优势。
如上图所示,要将两个有序数列A[7]、B[7]合并成一个统一的有序数列C[14],先取出a[1]和b[1]进行比较,如果a[1]小于b[1],则将a[1]放入c[1];然后取出a[2]和b[1]比较,同样是将较小的那一个放入c[2];如此循环,直到其中一个有序表取完,然后再将另一个有序表中剩余的所有元素一次性复制到辅助空间C中剩余的位置上。
因为是有序表嘛,所以上面的合并操作果然非常简单。那最后的问题就非常明确了,即:如何构造两个有序表。
其实,构造两个有序表的过程更加简单,如下图所示:
1、第一轮,将前后相邻的两个元素组成一个序列表(如果最后多一个元素,可单独组表),并根据大小调整前后顺序。
2、第二轮,将前后相邻的四个元素组成一个序列表,并根据大小调整前后顺序。
3、依次类推:第logn轮,将前后相邻的(n/2)个数据组成一个序列表,并根据大小关系调整前后顺序。
4、最后一轮,将前面(n/2)个数据的有序表和后面(n/2)个数据的有序表合并。
代码实现
void MergeSort(int a[], int n)
{
static int x = 0;
// 归并排序和上面那些排序算法的最大差异在于需要辅助空间
// 所以函数的第一步就是申请一个和待排序列相同大小的内存作为辅助空间
int* paux = new int[n];
for (int i = 0; i < n; i++)
{
paux[i] = 0;
}
int dk = 1; // 分组的规模从1开始(即每个分组只有1个元素)
while (dk < n) // 最后一轮归并操作的分组规模为n/2
{
for (int i = 0; i < n; i=i+2*dk)
{
// 如果i+dk不超过n,则a[i]和a[i+dk]作为两个相邻分组的首地址,执行归并操作
if (i+dk < n)
{
if (i+dk+dk < n)
{
MergeTowSet(&a[i], &a[i+dk], &paux[i], dk, dk, n);
}
else
{
MergeTowSet(&a[i], &a[i+dk], &paux[i], dk, n-(i+dk), n);
}
}
}
dk = dk * 2; // 完成一轮归并后,将分组的规模扩大一倍,再启动下一轮归并
// 当前有效数据保存在辅助空间中,
// 这里把辅助空间和原有数据空间交换一下,保证下一轮归并操作使用正确的数据
int* temp = paux;
paux = a;
a = temp;
x++;
cout<<"第 "<<x<<"轮:";
Print(a, n);
}
}
void MergeTowSet(int a[], int b[], int aux[], int a_n, int b_n, int n)
{
int a_index = 0;
int b_index = 0;
for(int i = 0; i < n; i++)
{
if (a_index >= a_n)
{
aux[i] = b[b_index];
b_index++;
}
else if (b_index >= b_n)
{
aux[i] = a[a_index];
a_index++;
}
else if (a[a_index] < b[b_index])
{
aux[i] = a[a_index];
a_index++;
}
else
{
aux[i] = b[b_index];
b_index++;
}
}
}
数据量越大,越需要选择时间复杂度为O(nLogn)的排序算法,但如果对算法的稳定性也有要求,那就必须考虑归并算法。