正值数据结构期末复习之际,遂将排序相关的复习内容记录在此,也算是对知识的一种整理归纳和复习。
常见的排序方法:插入排序、交换排序、选择排序、归并排序、基数排序
其中插入排序又包含直接插入排序、折半插入排序、希尔排序。
交换排序有包括冒泡排序、快速排序。
选择排序又包括简单选择排序、树形选择排序、堆排序。
我们本篇介绍常见的排序方法,默认按升序排列实现。
目录
一、插入排序
1、直接插入排序
(1)、排序思想
首先,对于一个数,其肯定是有序的。那么我们的排序思想是将一个待排序的关键字插入已经排序好的表中,从而得到一个新的,关键字数量加1的有序表。将第二个关键字插入只有1个数的有序表中,将第三个关键字有两个数的有序表中,以此类推。每次将待排序(即有序表最后一个的下一个位置)的和前面的有序表的最后一个比较,如果比其小,则将该位置的元素往后移,空出该位置来,如果大于等于,那么就在该位置的下一个位置插入。
(2)、代码实现
void InsertSort(int* arr, int n) //默认按升序实现
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = arr[end + 1]; //arr[end+1]是待排序的关键字
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
arr[end + 1] = tmp;
}
}
(3)、复杂度分析
时间复杂度:需要比较n-1趟,最好情况时,总的比较次数达最小值n-1.最坏情况下,总的比较次数和移动次数均达到最大值,都大约为n方/2。因此总体时间复杂度为O(N*2)
空间复杂的:显而易见为O(1),几乎没有借助额外的辅助空间。‘
(4)、算法特点
稳定排序,适用于顺序结构和链式结构,当初始状态越有序时算法效率越好。
2、折半插入排序
(1)、排序思想
基本思想与上述直接插入排序相同,均为将一个待排序的元素插入有序表中,不同的是在进行比较的时候不是从前往后比,而是采用二分思想比较,不断缩小区间,平均情况下可以减少关键字的比较次数,但移动次数不变。
(2)、代码实现
void BinaryInsertSort(int* arr, int n)
{
for (int i = 0; i < n - 1; i++)
{
int tmp = arr[i + 1];
int left = 0, right = i;
while (left <= right)
{
int m = (left + right) / 2;
if (tmp < arr[m])
{
right = m - 1;
}
else
{
left = m + 1;
//无论插入位置在数组中间还是末尾,left 始终指向第一个大于 tmp 的元素位置
}
}
// 将插入位置之后的元素后移
for (int j = i; j >= left; j--)
{
arr[j + 1] = arr[j];
}
arr[left] = tmp;
}
}
(3)、复杂度分析
时间复杂度:在平均情况下,折半插入排序仅减少了关键字的比较次数,而记录的移动次数不变,因此,折半插入排序的时间复杂度仍为O(N*2)]。
空间复杂度:几乎没有使用额外的辅助空间,空间复杂度为O(N)。
(4)、算法特点
稳定排序、只能用于顺序结构。
3、希尔排序
(1)、排序思想
希尔排序又称缩小增量排序。其基本思想是分组。先选定一个整数gap,把待排序的所有元素分成各组,所有距离相等的在一组内,并对每一组内的记录进行排序,然后gap=gap/3+1得到下一个整数,(这里gap的迭代方式自己定,只需要确保最后一次是1即分为1组即可。不过通常认为/3+1或/2+1比较好),再继续分组,进行插入排序,当gap=1时,就相当与直接插入排序。
我们用接下来这个图演示。
初始时gap=5,将距离每隔5的元素分成一组,然后对每组进行插入排序,然后就得到了稍微那么有点序的第二趟数组,此时gap迭代更新为2,距离每隔2的元素分组,然后继续插入排序得到了更有序一点的第三趟数组,然后此时gap迭代为1,对整个数组进行直接插入排序。注意对于直接插入排序越接近有序排序效率越高。
(2)、代码实现
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = arr[end + gap]; //取同一组内有序表最后一个元素的下一个位置的元素
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
(3)、复杂度分析
时间复杂度:希尔排序的时间复杂度是一个难题,对于增量序列的不同,起时间复杂度也不同,当n在某个特定范围内,希尔排序所需的比较和移动次数约为n的1.3次方。
空间复杂度:代码中几乎没有引入额外的辅助空间,因此空间复杂度为O(1)。
(4)、算法特点
不稳定排序、只能用于顺序结构、增量序列中的值不能有除1之外的公因子,且最后一个增量值必须为1.
二、交换排序
1、冒泡排序
(1)、排序思想
冒泡排序可以说是大部分人接触的第一种排序算法。其排序思想也很简单,即每次将一个元素根据其自身大小向数组一侧不断移动。经过一趟排序之后可以确定一个元素的位置。于是对于n个元素的数组,只需要排序n-1趟。
(2)、代码实现
void BubbleSort(int* arr, int n)
{
for (int i = 0; i < n - 1; i++) //排序n-1趟
{
int flag = 0;
for (int j = 0; j < n - i - 1; j++) //每趟排序要比较n-i-1次来确定一个数
{
if (arr[j] > arr[j + 1])
{
flag = 1;
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
//经过一趟排序后检验是否有序
if (flag == 0)
{
break;
}
}
}
(3)、复杂度分析
时间复杂度:O(N*2)。
空间复杂度:O(1)。
(4)、算法特点
稳定排序、可用于链式结构、平均性能较差。
2、快速排序
(1)、排序思想
取待排序元素序列中的某个元素作为基准值,按照该基准值将待排序集合分为两个待排序的子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,重复该过程。
(2)、代码实现
int _QuickSort(int* arr, int left, int right)
{
int keyi = left; //默认选最左边为基准值
++left;
while (left <= right)
{
//左边找大的
while (left <= right && arr[left] < arr[keyi])
{
left++;
}
//右边找小的
while (left <= right && arr[right] > arr[keyi])
{
right--;
}
if (left <= right)
{
int tmp = arr[left];
arr[left] = arr[right];
arr[right] = tmp;
left++;
right--;
}
}
int tmp = arr[keyi];
arr[keyi] = arr[right];
arr[right] = tmp;
return right;//返回基准值的位置
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int mid = _QuickSort(a, left, right);
QuickSort(a, left, mid - 1);
QuickSort(a, mid + 1, right);
}
这里可能会有两个问题:
为什么跳出循环后right位置的值一定不大于key也就是为什么此时right指向的位置就是基准值的位置?
因为我们left从左往右找比key大的,left扫描过的地方都是比key小的所以左边都比key小,循环结束后right<left因此right此时指向位置的左边都是小于基准值的,右边同理。
在进行left和right所指向位置的数据交换时,为什么相等也要交换?
相等时也交互会多造成一些损耗。但是在实际复杂场景中,当数组中的数据大量重复时,就不能有效地分割子序列了。
当然也有非递归版本的快速排序,可以借助栈来实现,这里不再详细介绍。
(3)、复杂度分析
时间复杂度:快速排序的趟数取决于递归树的深度。我们可以大致这样看,找基准值正确位置的复杂度为O(N),趟数即划分子序列的次数,即logN,于是大致认为其时间复杂度为O(NlogN),更为精确的计算可以查阅其他资料。当然,如果待排序的序列接近有序,那么快速排序的时间复杂度将接近O(N*2),因为假如是升序的,每次都划分出左子序列为一个值,右子序列为其余全部值。在这种情况下,快速排序的性能会退化。
空间复杂度:对于这种递归版本的快速排序,执行时需要有一个栈来存放相应的数据。最大递归调用次数与递归树深度一致。则最好情况下空间复杂度为O(logN),最坏情况下位O(N)。
(4)、算法特点
不稳定排序、当初始状态接近有序时,平均性能会下降、不适合链式结构。
三、选择排序
1、直接选择排序
(1)、排序思想
(2)、代码实现
void SelectSort(int* arr, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin;
for (int i = begin; i <= end; i++)
{
if (arr[i] > arr[maxi])
{
maxi = i;
}
if (arr[i] < arr[mini])
{
mini = i;
}
}
//注意处理特殊情况,因为交换有顺序
if (begin == maxi)
{
maxi = mini;
}
swap(&arr[mini], &arr[begin]);
swap(&arr[maxi], &arr[end]);
++begin;
--end;
}
}
(3)、复杂度分析
时间复杂度:O(N*2)
空间复杂度:O(1)
(4)、算法特点
稳定排序、可用于链式结构。
2、堆排序
(1)、排序思想
堆排序是一种树形选择排序,将待排序的序列看成顺序存储的完全二叉树结构,利用顺序存储结构,可以很快得到父节点与子节点的对应关系并找到对应结点。所以对于一个建好的堆,在其内部的顺序存储结果中是有序的。所以我们实现堆排序的任务无非两个:
建初堆:如何将一个无序序列建成一个堆
调整堆:去掉堆顶元素,在堆顶元素改变之后,如何调整剩余元素成为一个新的堆。
对于建初堆,要用到调整堆的操作。
调整堆,可以从上向下调整,也可以从下向上调整,我们这里用向下调整实现,以大根堆为例。
//向下调整算法
void AdjustDown(int* arr, int parent, int n)
{
int child = parent * 2 + 1;
while (child < n)
{
//这里建立大根堆,找最小的孩子然后跟父亲比
if (child + 1 < n && arr[child] < arr[child + 1])
{
child++;
}
if (arr[child] > arr[parent])
{
swap(&arr[child], &arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;//调整结束
}
}
}
我们堆排序的整个过程是:不断取出堆顶元素,将其放到合适的位置,然后重新调整堆。
这里我们是大根堆,堆顶元素是整个堆中最大的,那么就是每次和最后一位交换,然后缩小范围重新调整堆。
(2)、代码实现
每次将堆顶元素放到最后一个位置,然后重新调整堆,最终不断变成升序的样子。
void HeapSort(int* arr, int n)
{
//child:n-1 parent:(n-1-1)/2
for (int i = (n - 1 - 1) / 2; i >= 0; i--) //从最后一个父节点开始逆序向下调整建堆
{
AdjustDown(arr, i, n);
}
//此时已经建好大堆
int end = n - 1;
while (end > 0)
{
swap(&arr[0], &arr[end]); //每次交换将堆顶元素放到合适的位置。
AdjustDown(arr, 0, end);
end--;
}
}
(3)、复杂度分析
时间复杂度:堆排序的时间主要消耗在建初堆和不断的调整堆过程上。在最坏情况下,堆排序的时间复杂度为O(NlogN)。
空间复杂度:空间复杂度为O(1)。
(4)、算法特点
不稳定排序、只能用于顺序结构、建堆所需次数比较多、在元素较少时不宜使用。
四、归并排序
1、归并排序
(1)、排序思想
(2)、代码实现
void MergeSort(int* arr, int left, int right, int* tmp)
{
if (left >= right)
{
return;
}
int mid = (left + right) / 2;
//根据mid划分为两个子序列[left,mid] [mid+1,right]
MergeSort(arr, left, mid, tmp);
MergeSort(arr, mid + 1, right, tmp);
//合并[left,mid]和[mid+1,right]
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else {
tmp[index++] = arr[begin2++];
}
}
//循环结束后可能存在某个个序列中的数据没有全部存放到tmp中
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
//将数据挪回到arr中
for (int i = left; i <= right; i++)
{
arr[i] = tmp[i];
}
}
(3)、复杂度分析
时间复杂度:对于n个元素,需进行logn趟归并排序,每趟归并排序关键字比较不超过n,元素移动次数都是n,因此归并排序时间复杂度为O(NlogN)。
空间复杂度:借助了一个额外的辅助存储空间,大小为n,因此空间复杂度为O(N)。
(4)、算法特点
稳定排序、可用于链式结构。
五、基数排序
前面各类排序方法都是建立在关键字比较的基础上,而分配类排序不需要比较关键字的大小,它是根据关键字中各位的值,通过对待排序记录进行若干趟分配与收集来实现排序的,是一种借助于多关键字排序的思想对单关键字进行排序的方法。基数排序就是一种典型的分配类排序。
这里斗胆借用老师上课的课件来展示基数排序的过程。
对这个例子中的十个链式结构的关键码进行排序。
首先创建一个链式结构队列用来进行分配。
第一趟分配首先按每个数的个位来分配,将其个位对10取余后得到余数,然后将其挂在对应的位置上。
接着按挂在上面的顺序将其重新收集为一个关键码链表。
接着进行第二趟分配,这次按十位来看,取每个数的十位对10取余后,按照余数挂在对应的队列位置上,然后进行第二次收集。
可以看到,经过第2趟收集后得到了正确的排序序列。
这种方法正确的原因在于每次分配都针对每一位,得到了不同元素同一位上的相对位置,然后每个位都分配收集完就得到了正确的序列。
对于其时间复杂度:
对于这个例子,基数是10,即那个队列中共有十个选项,排序码位数是2,因为这道题最大的数的位数是2,排序码个数就是元素个数。
每次分配时需要遍历链表,于是分配的时间复杂度是O(N),收集时遍历队列,收集的时间复杂度是O(r)。总共需要分配收集d趟。因此时间复杂度为O(d×(n+r))。
对于空间复杂度:
创建了n个结点的队列,再加上排序码个数,因此空间复杂度是O(n+r)。
基数排序是稳定的,可用于链式结构和顺序结构。