数据结构中的排序算法真是多呀,本节先将插入排序、堆排序、归并排序、快速排序这几种算法。下一篇讲PAT中这些算法的相关题目。
插入排序
- 几种排序算法中最简单的一种,代码也很容易书写,从数组第二位元素起,插入其到前面有序的序列中,算法如下:
void InsertSort(int a[], int n){ //n为数组元素的大小,以数组下标为0为例
for(int i = 1; i < n; ++i){
int temp = a[i]; //先保存该位元素,之后向前查找
int j = i; //从第i位向前找,前一位元素若大于temp,前一位元素后移,继续向前查找,直到找到temp应该在的位置
while(j > 0 && a[j - 1] > temp){
a[j] = a[j - 1];
--j;
}
//将temp填入j位
a[j] = temp;
//循环体中的代码,若在运行时间允许的情况下,可以用STL库sort函数代替
//sort(a, a + i + 1);
//第二参数为右开区间
}
return;
}
上述算法,一定记得在while循环时加上条件 j > 0,防止访问越界。还有,上述循环体中的代码在运行时间允许的情况下可以用注释部分的一句代码替代。
堆排序
-
先来介绍一下什么是堆吧,堆实际是将元素组织成完全二叉树。完全二叉树是由满二叉树而引出来的。
-
满二叉树:是指各层结点达到各层最多能容纳的结点数。
-
完全二叉树:对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。
完全二叉树存放一般按照数组进行存储,因为其结点比较密集,根节点为存在数组的1号位。根节点若为i,左子树和右子树则分别为2 * i,2 * i + 1。如下:数字代表在数组中的存储下标。
- 堆有两种分类:大顶堆和小顶堆。大顶堆是每个结点的值都不小于左右孩子的值,小顶堆是每个节点的值都不大于左右孩子的值。
- 进行堆排序的第一步是先建好堆。这里以建立大顶堆为例,堆排序之后的元素为升序排列。以下开始书写算法。
堆排序中的向下调整算法
先介绍堆排序中的向下调整算法:从最后一个元素,从下向上,从右向左。假设当前节点为 i,让他与他的孩子节点比较,找出孩子节点中较大的节点,交换,然后让交换所在的那个孩子节点再与其孩子节点比较,直到当前父节点大于左右孩子或者左右孩子为空。
//对数组在[low, high]范围进行向下调整,时间复杂度为 O(logn)
//low为欲调整结点的数组下标,high一般为堆的最后一个元素的下标
//heap数组作为全局变量
void DownAdjust(int low, int high){
int i = low; //i为欲调整结点
int j = 2 * i; //j为其左孩子
while(j <= high){ //存在左孩子
if(j + 1 <= high && heap[j + 1] > heap[j]){ //存在右孩子且右孩子值大于左孩子值
j = j + 1; //j存右孩子下标
}
//如果孩子中最大权值大于欲调整节点值,进行交换,然后继续向下调整
if(heap[j] > heap[i]){
swap(heap[i], heap[j]);
i = j;
j = 2 * i;
}
else{
break; //孩子节点值均小于节点 i 的值,调整结束
}
}
return;
}
建立堆的算法
- 向下调整算法介绍之后,我们在建立堆的时候实际应当从底层向上一点一点的选出较大的元素,直至堆顶。
- 我们在建堆时候从第一个非叶子节点开始,进行向下调整。由完全二叉树的性质,假设序列中元素个数为 n ,由于完全二叉树的叶子节点个数为【(n/2)向上取整】,因此下标在[1, 【(n/2)向下取整】]范围内的节点均为叶子结点。
//代码很简单,只要理解了堆调整的过程,就很容易书写
void CreateHeap(int n){
for(int i = n / 2; i >= 1; --i){
DownAdjust(i, n);
}
return;
}
堆排序算法
建好堆之后,每次将堆顶元素与最后一个元素交换,然后对堆顶元素进行向下调整。
void HeapSort(int n){
CreateHeap(n);
for(int i = n; i > 1; --i){
swap(heap[i], heap[1]);
DownAdjust(1, i - 1);
}
return;
}
至此,堆排序结束,排序后的序列是升序的。特别注意:堆排序元素的下标从1开始。
下面谈论堆排序中的一些其他操作
删除堆顶元素
void DeleteTop(int n){
heap[1] = heap[n--]; //用最后一个元素覆盖堆顶元素,并让元素个数减 1
DownAdjust(1, n);
return;
}
向上调整
将添加的元素放在序列最后,然后进行向上调整操作。向上调整总是把欲调整结点与父节点比较,如果权值比父结点大,交换其与父亲结点,这样反复比较,直到达到堆顶或者父亲结点的权值最大为止。向上调整的代码:
void UpAdjust(int low, int high){
int i = high; //i为欲调整结点
int j = i / 2; // j为其父亲
while(j >= low){
if(heap[j] < heap[i]){
swap(heap[i], heap[j]);
i = j; //保持i为欲调整结点,j为i的父亲
j = i / 2;
}
else{
break; //父亲结点比欲调整结点i的权值大,调整结束
}
}
return;
}
添加元素
void Insert(int x){
heap[++n] = x;
UpAdjust(1, n);
return;
}
堆排序基本的一些算法结束,其实只要理解了堆排序的一个操作过程,重点算法是DownAdjust函数的书写,其他代码理解原理后很容易书写。
归并排序
下面来看归并排序,先说一下简单的原理:将序列两两分组,组内单独排序,然后将这些组两两归并,直到最后只剩下一个组,时间复杂度为O(nlogn),下面算法只介绍最简单的 2-路归并排序。
- 先来看一个例子,我们用手工进行归并排序的操作。
- 序列 {66,12,33,57,64,27,18} 进行 2-路归并排序。
- 第一趟:两两分组,得到四组:{66,12}、{33,57}、{64,27}、{18},组内单独排序,得到序列{{12,66}、{33,57}、{27,64}、{18}}。
- 第二趟:将四组继续两两分组,得到两组:{12,66,33,57}、{27,64,18},组内单独排序得到新序列{{12,33,57,66}、{18,27,64}}。
- 第三趟:两个组继续两两分组,得到一组:{12,33,57,66,18,27,64},组内单独排序,得到新序列{12,18,27,33,57,64,66}。算法结束。
由上面过程可以发现,2-路归并的核心在于两个有序序列的合并。接下来 2-路归并的具体实现算法我们分为递归和非递归来讲解。其实二者差别只在分组的时候,一个分组采用递归,一个用循环控制,在合并上没有不同。
两个有序序列的合并
先从两个有序序列的合并来开始讲解。先说合并两个序列我们肯定需要一个辅助的空间来进行中间结果的存放。算法具体如下:
const int MAX_N = 100; //最大值根据题目要求来设置
//将数组A的 [L1, R1]和数组A的 [L2, R2]合并为有序区间
void Merge(int a[], int L1, int R1, int L2, int R2){
int i = L1;
int j = L2;
int temp[MAX_N], index = 0; //设置临时数组
while(i <= R1 && j <= R2){
if(A[i] <= A[j]{
temp[index++] = A[i++];
}
else{
temp[index++] = A[j++];
}
}
while(i <= R1){
temp[index++] = A[i++];
}
while(j <= R2){
temp[index++] = A[j++];
}
//最后将中间结果再存入原数组
for(int i = 0; i < index; ++i){
A[L1 + i] = temp[i];
}
return;
}
归并排序的递归代码
下面是归并函数,参数有三个:数组、left、right。每次把序列分为两半,只要left还小于right就继续向下分,分到最后只有一个元素时,递归退层,回到上一层进行合并
void MergeSort(int A[], int left, int right){
if(left < right){
int mid = left + (right - left) / 2;
MergeSort(left, mid);
MergeSort(mid + 1, right);
Merge(A, left, mid, mid + 1, right);
}
return;
}
归并排序的非递归代码
人工手动分组,从两两一组分起,然后进行组内排序。
void MergeSort(int A[], int n){
//设置变量step为组内元素个数,从组内元素为2开始
for(int step = 2; step / 2 <= n; step *= 2){
//对每一组,左右半边元素分别进行排序,然后合并
for(int i = 0; i < n; i += step){
int mid = i + step / 2 - 1; //左区间元素个数为step / 2
//右区间存在元素的话,进行合并
if(mid + 1 < n){
//最后一组的右区间元素有时可能不足分组元素的一半,故写为min(i + step - 1, n - 1)
Merge(A, i, mid, mid + 1, min(i + step - 1, n - 1));
//可以用sort函数替代
//sort(a + i, a + min(i + step, n));
//用时一定记得sort函数的第二个参数为右开区间,而上述代码的Merge函数为闭区间
}
}
}
}
以上就是归并排序的相关算法,当然在运行时间允许的条件下,merge函数可以直接用STL库函数sort来代替。上述代码注释的部分写到了。
快速排序
先来了解下原理:选择数组中的一个元素为基准,来对数组中其他的元素以该元素为标准,小于它的放在它的左边,大于的放在它的右边。然后进行递归再对其左边和右边的元素分别进行快速排序。
- 第一个问题:基准元素怎么选?一般来说,可以用待排序序列的 left 的元素当作基准元素。或者可以采用随机数来在序列中选择一个元素来作为基准元素。
- 第二个问题:具体的比基准元素小的元素和比基准元素大的元素怎么移动呢?我们采用的一个方法叫做挖坑法。
下面由一个例子说明,以下图序列为例:
- 首先,我们选定基准元素Pivot,每次让基准元素处于 left 位置,快排序列的第一个元素,记下基准元素的值,这个位置相当于一个“坑”。并且设置两个指针left和right,指向数列的最左和最右两个元素。
- 接下来,从right指针开始,把指针所指向的元素和基准元素做比较。如果比pivot大,则right指针向左移动;如果比pivot小,则把right所指向的元素填入坑中。
- 接下来,我们切换到left指针进行比较。如果left指向的元素小于pivot,则left指针向右移动;如果元素大于pivot,则把left指向的元素填入坑中。
在当前数列中,7 > 4,所以把7填入index的位置。这时候元素7本来的位置成为了新的坑。同时,right向左移动一位。
- 下面按照刚才的思路继续排序:
- 8 > 4,元素位置不变,right左移
-
相信看到这,大家对快速排序具体怎么怎么操作已经有了一些理解。下面我们来看算法。
区间划分代码
//返回值:基准元素在数组中的位置。用于在快速排序中递归使用元素区间
int RandPartition(int a[], int left, int right){
//生成一个随机下标
int p = (round(1.0 * rand()) / RAND_MAX * (right - left) + left);
swap(a[p], a[left]); //保持第一个元素为基准元素
//先从后面找
int temp = a[left];
while(left < right){
while(left < right && a[right] > temp){
right--; //右边指针不断前移,直到找到一个比temp小的数
}
a[left] = a[right];
while(left < right && a[left] <= temp){
left++; //左边指针不断后移,直到找到一个比temp大的数
}
a[right] = a[left];
}
a[left] = temp;
return left; //返回相遇的下标
}
快速排序代码
void QuickSort(int a[], int left, int right){
if(left < right){ //当前区间长度不超过1
int pos = RandPartition(a, left, right); //将区间一分为二,返回划分处的下标
QuickSort(a, left, pos - 1); //对左区间进行快速排序
QuickSort(a, pos + 1, right); //同样右区间
}
return;
}
ok,这次的分享就到这,一般的机试题目没有特殊要求时,使用STL库中的sort函数就足够了。下节我们来看PAT中几道对于以上几种排序的题目。