排序
一、排序的概念及意义
1.1排序的概念
(1)排序:排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。
(2)内排序:数据元素全部放在内存中排序
(3)外排序:数据元素过太多不能同时放在内存中,在排序过程中要求不能在内外存之间移动的排序
(4)排序的稳定性:两个相等数字在排序之后相对位置不变(在前面的就在前面,后面的就在后面)称这个排序是稳定的,反之则是不稳定的
1.2排序的应用
(1)购物网站:在电子商务网站上,商品通常需要按照价格、评级或销量等因素进行排序,以便顾客可以更方便地找到他们想要的商品。
(2)社交媒体:在社交媒体平台上,帖子、评论或用户通常会按照时间、热门程度或相关性进行排序,以便用户能够更好地了解最重要或最有趣的内容。
(3)旅行规划:旅行规划应用程序可以使用排序算法来帮助用户确定最佳的旅行路线或行程计划,以最大程度地减少时间和交通成本。
(4)时间管理:在任务管理应用程序中,排序算法可以根据任务的优先级、截止日期或预计完成时间来排序任务列表,以便用户可以更好地管理他们的时间。
(5)音乐播放器:音乐播放器软件可以根据用户的喜好、流行程度或最近播放时间来对歌曲进行排序,并提供个性化的推荐。
(6)照片管理:照片管理应用程序可以根据拍摄日期、地点或标签等信息对照片进行排序,使用户可以更方便地查找和组织他们的照片库。
(7)排队系统:在银行、餐厅或其他服务场所,排序算法可以用于管理顾客的排队顺序,以最大限度地减少等待时间。
1.3常见的排序
注:下面的排序都按照升序来排
二、直接插入和希尔排序的实现和分析
2.1直接插入
void InsertSort(int* a, int size)
{
for (int i = 0; i < size - 1; i++)
{
int end = i;
//要插入的数据,因为有可能被覆盖,所以先存储一下
int tmp = a[end + 1];
//遍历找到比tmp小的位置
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
else
break;
}
//插到该位置的后面
a[end + 1] = tmp;
}
}
接下来我们看看这个排序的思想,其实大多数人都使用过,只是没意识到,就是在我们打牌摸牌的时候排序就是用的插入排序,我们一张一张从牌堆里摸,摸到一张牌的时候,在从自己手里最后一张牌开始倒着找到比这张牌小的那张牌然后插到其后面。插入排序模拟的就是这个过程。
2.2希尔排序
void ShellSort(int* a, int size)
{
//将数组分为gap组,间隔gap的为一组
int gap = size;
while (gap > 1)
{
gap /= 2; //这里除2可以保证最后一次gap一定唯一
for (int i = 0; i < size - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
break;
}
a[end + gap] = tmp;
}
}
}
希尔排序法又称缩小增量法。希尔排序法的基本思想是:把待排序数组中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行直接插入排序。然后重复上述分组和排序的工作。当到达gap=1时,所有记录在统一组内排好序。
结合直接插入的代码来看,其实就是把1换成了gap,而且gap最后一定要为1,也就是希尔排序是直接插入的优化,它先进行预排序,让数组先接近有序,然后在直接插入排序。
三、直接选择和堆排序的实现和分析
3.1直接选择
void SelectSort(int* a, int size)
{
for (int i = 0; i < size - 1; i++)
{
for (int j = i + 1; j < size; j++)
{
if (a[i] > a[j])
{
int t = a[i];
a[i] = a[j];
a[j] = t;
}
}
}
}
相信大家对这个排序已经非常熟悉了,我就不过多赘言,就是遍历一遍选出一个最小。
3.2堆排序(非递归)
//向下调整算法
void AdjustDown(int* p, int size,int root)
{
//前提:左子树和右子树都满足堆
int parten = root;
int child = 2 * parent + 1;
while (child < size)
{
//取两个孩子结点较大的那个
if (a[child] < a[child + 1] && (child + 1 < size))
{
child++;
}
//如果孩子结点大于父亲就交换
if (a[parent] < a[child])
{
int t = a[parent];
a[parent] = a[child];
a[child] = t;
//更新父子结点位置
parent = child;
child = 2 * parent + 1;
}
else
break;
}
//堆排序
void HeapSort(int* a, int size)
{
//先建堆(从下向上)
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, size, i);
}
//排升序建大堆还是小堆?
for (int i = 0; i < size - 1; i++)
{
int t = a[0];
a[0] = a[size - 1];
a[size - 1] = t;
AdjustDown(a, size - 1, 0);
}
}
}
大堆就是任意父节点都大于它的所有子节点,小堆相反。理解完这些概念后我们来解答上面的问题,排升序是建大堆还是小堆,先说答案建大堆,可能很多人会想着建小堆可以直接拿到最小的数了,不是很好吗,确实是这样,但是往后想,你取走第一个数之后这个堆的结构被破坏了,要重新建一个堆再取最小数,但是建堆的时间复杂度很高,所以这样还不如上面的直接选择排序,所以我们选择建大堆,取得最大的数和最后一个位置交换,这样没有破坏堆的整体结构,只需向下调整之后,就可以重新选到最大值,向下调整算法的时间复杂度很低,所以我们选择建大堆。
思想:将一个已知的序列先调整到大堆的形式,然后再将堆顶元素和堆最后的元素进行调换(这样最大的元素就在最后面了),减去最后一个元素将剩余的元素进行堆调整,重复上面的步骤就会生成从小到大的序列。
四、冒泡排序和快速排序的实现和分析
4.1冒泡排序
void BubbleSort(int* a, int size)
{
for (int i = 0; i < size - 1; i++)
{
for (int j = 0; j < size - 1 - i; j++)
{
if (a[j] > a[j + 1])
{
int t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
}
}
}
}
int main()
{
int a[] = { 2,5,7,3,1,4,7,9,10 };
BubbleSort(a, 9);
PrintArray(a, 9);
return 0;
}
我想这个应该是大家最熟悉的排序了,主要思想就是交换,向泡泡一样往外冒。
4.2快速排序(递归版)
//挖坑法
int SortPart1(int* a, int left, int right)
{
int pivot = left;
int key = a[left];
while (right > left)
{
//从右往左找小
while (right > left && a[right] >= key)
right--;
a[pivot] = a[right];
pivot = right;
//从左往右找大
while (right > left && a[left] <= key)
left++;
a[pivot] = a[left];
pivot = left;
}
a[left] = key;
return left;
}
------------------------------------------------------------------------------------
//左右指针法
int SortPart2(int* a, int left, int right)
{
int keyi = left;
while (right > left)
{
//注意:这里的等号不能漏,不然可能造成死循环,找大找小顺序不能变,不然也会出错
//找小
while (right > left && a[right] >= a[keyi])
right--;
//找大
while (right > left && a[left] <= a[keyi])
left++;
int t = a[left];
a[left] = a[right];
a[right] = t;
}
int t = a[left];
a[left] = a[keyi];
a[keyi] = t;
return left;
}
---------------------------------------------------------------------------- -
//前后指针法
int SortPart3(int* a, int left, int right)
{
int keyi = left;
int pre = left;
int cur = pre + 1;
while (cur <= right)
{
if (a[cur] < a[keyi] &&
a[++pre] != a[cur]) //防止两个相同的值无效交换
{
int t = a[pre];
a[pre] = a[cur];
a[cur] = t;
}
cur++;
}
int t = a[pre];
a[pre] = a[keyi];
a[keyi] = t;
return pre;
}
------------------------------------------------------------------------------
void QuickSort(int* a, int left, int right)
{
if (right > left)
return;
int keyi = SortPart1(a, left, right);
QuickSort(a, left, keyi - 1); //递归左区间
QuickSort(a, keyi + 1, right); //递归右区间
}
这个快速排序的部分排序有三种方法,每次排完一趟的结果可能都不太一样,但是这三个方法的主要思想,就是把比key小的数放在key前面,比key大的数放在key后面。这是大家要明白的
4.3快速排序(非递归版)
//利用栈这个数据结构来模拟实现
void QuickSortNor(int* a, int left, int right)
{
ST stack;
StackInit(&stack);
StackPush(&stack, right);
StackPush(&stack, left);
while (!StackEmpty(&stack))
{
int begin = StackTop(&stack);
StackPop(&stack);
int end = StackTop(&stack);
StackPop(&stack);
int pivot = PartSort1(a, begin, end);
if (pivot+1 < right)
{
StackPush(&stack, right);
StackPush(&stack, pivot+1);
}
if (pivot - 1 > left)
{
StackPush(&stack, pivot-1);
StackPush(&stack, left);
}
}
StackDestory(&stack);
}
这个原理就是通过栈这个数据结构模拟了这个递归的过程。
4.4三数取中优化快速排序
//三数取中(快排优化)
int GetMiddleIndex(int* a, int left, int right)
{
int middle = (left + right) >> 1;
if (a[left] < a[middle])
{
if (a[middle] < a[right])
{
return middle;
}
else
{
if (a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
}
else
{
if (a[middle] > a[right])
{
return middle;
}
else
{
if (a[left] > a[right])
{
return right;
}
else
{
return left;
}
}
}
}
//挖坑法
int SortPart1(int* a, int left, int right)
{
//获得三个数中处于中间大小的值然后和左边界的数字交换
int index = GetMiddleIndex(a, left, right);
int t = a[left];
a[left] = a[index];
a[index] = t;
int pivot = left;
int key = a[left];
while (right > left)
{
//从右往左找小
while (right > left && a[right] >= key)
right--;
a[pivot] = a[right];
pivot = right;
//从左往右找大
while (right > left && a[left] <= key)
left++;
a[pivot] = a[left];
pivot = left;
}
a[left] = key;
return left;
}
这里就修改了SortPart1(),其余的大家可以自己修改,做这步的目的是,避免key的值最大或最小,可以防止该算法的最坏的情况发生,详细的复杂度分析在下面,先留个印象。
4.5小区间优化快速排序
void QuickSort(int* a, int left, int right)
{
if (right <= left)
{
return;
}
int pivot = PartSort1(a, left, right);
//小区间优化,区间距离根据要排的数据多少自己把控,这里以10为例
if (pivot - 1 - left > 10)
{
//递归左
QuickSort(a, left, pivot - 1);
}
else
{
//插入排序
InsertSort(a, pivot - left);
}
if (right - pivot - 1 > 10)
{
//递归右
QuickSort(a, pivot + 1, right);
}
else
{
//插入排序
InsertSort(a, right - pivot);
}
}
随着递归层数的增加,要建立的栈是呈指数级增长的,但是快速排序的最后几层,左右区间很短,但又占着大部分的建立的栈,所以我们判断一下,如果区间很小就没有必要快速排序了,用一些常规的排序例如插入、冒泡之类的就行
五、归并排序的实现和分析
5.1归并排序(递归版)
#include <stdio.h>
#include <stdlib.h>
void PrintArray(int* a, int n)
{
for (int i = 0; i < n; ++i)
{
printf("%d ", a[i]);
}
printf("\n");
}
void _mergeSort(int* a, int* tmp, int left, int right)
{
if (right <= left)
return;
int mid = (right + left) / 2;
_mergeSort(a, tmp, left, mid); //使左有序
_mergeSort(a, tmp, mid + 1, right); //使右有序
//开始归并
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
int i = left;
while (end1 >= begin1 && end2 >= begin2)
{
if (a[begin1] < a[begin2]) {
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
while (end1 >= begin1)
{
tmp[i++] = a[begin1++];
}
while (end2 >= begin2)
{
tmp[i++] = a[begin2++];
}
//拷贝回原数组
for (int j = left; j <= right; j++)
{
a[j] = tmp[j];
}
}
void mergeSort(int* a, int size, int left, int right)
{
int* tmp = (int*)malloc(sizeof(a[0])*size);
_mergeSort(a, tmp, left, right);
free(tmp);
}
int main()
{
int a[] = { 2,5,7,3,1,4,7,9,10 };
mergeSort(a, 9, 0, 8);
PrintArray(a, 9);
return 0;
}
归并排序是分治算法思想的一个典型的体现,能归并两个子序列的前提是这两个子序列必须是有序的,然后怎么让子序列有序呢,归并,如此嵌套即可完成归并排序。
这幅图就是上面代码的逻辑,可以帮助大家更好的理解归并排序
5.2归并排序(非递归版)
void MergeSortNor(int* a, int size, int left, int right)
{
int* tmp = (int*)malloc(sizeof(a[0]) * size);
//分组,先是一个和一个一组,然后两个和两个一组……
int gap = 1;
while (gap < size)
{
for (int i = 0; i < size; i += 2 * gap)
{
int index = i;
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
//右区间不存在,不归并
if (begin2 > right)
{
break;
}
//右区间越界,调整一下
if (end2 > right)
{
end2 = right;
}
//开始归并
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
//拷贝回原数组,归一点,拷贝一点,这里可能有一部分没有归并到tmp数组,所以要控制边界,不然会拷贝随机值进原数组
for (int j = i; j <= end2; j++)
{
a[j] = tmp[j];
}
}
gap *= 2;
}
}
int main()
{
int a[] = { 2,5,7,3,1,4,7,9,10 };
MergeSortNor(a, 9, 0, 8);
PrintArray(a, 9);
return 0;
}
非递归版和递归版的思想差不多,难点在与边界的控制,所以大家要格外的注意,一不小心就会越界,大家要重点看上面代码的边界。
六、排序的稳定性和复杂度分析
6.1分析(这里以顺序为例分析)
-
直接插入排序的时间复杂度和稳定性
(1)时间复杂度:最好的情况是已经是顺序的了,只需遍历一遍即可,不用插入,故为O(n),最坏的情况是逆序的,每次都要插到最前面,表达式为1+2+3+…+n-1,是一个等差数列,故为O(n^2)。(2)稳定性:在判断到两个数相同后,我们可以直接插入了,故不会改变相对位置,所以是稳定的。
-
希尔排序的时间复杂度和稳定性
(1)时间复杂度:这个排序因为gap的取值不同,导致很难去计算,在很多书中给出的时间复杂度都不相同,这里我们就来比较一下希尔排序与直接插入排序,最坏的情况直接插入排序是O(n^2),使用希尔预排序之后,当gap = 1,时可以近似的认为时间复杂度为O(n),故从此可以得出希尔排序的时间复杂度大于O(n)小于O(n ^ 2)。(2)稳定性:分组的时候很难控制两个相同数字的相对位置,故是不稳定的。
-
直接选择排序的时间复杂度和稳定性
(1)时间复杂度:无论是顺序还是逆序这个排序的核心思想都是遍历一遍选出一个数,故时间复杂度为O(n^2)。(2)稳定性:会把两个相等的数的前面一个换到后面去,不稳定。
-
堆排序的时间复杂度和稳定性
(1)时间复杂度:堆排序过程分为建堆和排序两个过程,我们先来计算建堆的时间复杂度2^(i-1)(h-i-1),其中i代表的是第几层,h代表的是该堆(二叉树)的高度 ,故这个表达式可以理解为每层的节点需要向下调整的次数,例如第一层:有一个结点,该结点需要向下调整h-1次,所以总和可以写成2 ^(i-1)1 + 2 ^(i-2)2 + …+2 ^0 * (h-1),是不是看着和高中的等比乘等差求和一样,记得当年高中求和用苹果公式那是分分钟的事,毕竟现在是大学生了,就交给大家去算把,最终的结果 n-logn-1,所以建堆的时间复杂度为O(n)。
现在来计算排序,建好堆后,我们只需将头和尾交换一下位置,然后用向下调整算法,该算法的时间复杂度为O(logn),如此循环n-1次就行了,故时间复杂度为
O(nlogn),总时间复杂度为O(n)+ O(nlogn),也就是O(nlogn)。
(2)稳定性:在建好堆排序的时候,将根换到最后一个位置可能会改变相对位置,故这个排序是不稳定的。 -
冒泡排序的时间复杂度和稳定性
(1)时间复杂度:等差数列O(n^2)。(2)稳定性:我们可以在比大小交换的时候在情况相等的时候不交换,故这个排序是稳定的。
-
快速排序的时间复杂度和稳定性
(1)时间复杂度:
最好的情况是,选择的key每次都是排在选定的区间中间如图
这个结构是不是联想到了二叉树,OK现在我们来分析一下,一共有高度层,而高度为logn,每层都是执行排一趟,故时间复杂度为O(n),综上快排最好的时间复杂度为O(logn*n)。
最坏的情况是有序的情况,无论是升序还是降序,来看下图
每次分出去一个,故一共有n层,每层时间复杂度为O(n),总的时间复杂度为O(n^2),而上面的快速排序优化的三数取中就是为了避免这种情况的发生。(2)稳定性:挖坑法选定key值,假如该数列中有多个相等的值,不好控制相对位置不变,故是不稳定的。
6.2练习
- 快速排序算法是基于( )的一个排序算法。
A分治法
B贪心法
C递归法
D动态规划法
解析:分治算法,- 对记录(54,38,96,23,15,72,60,45,83)进行从小到大的直接插入排序时,当把第8个记录45 插入到有序表时,为找到插入位置需比较( )次?(采用从后往前比较)
A 3
B 4
C 5
D 6
解析:根据插入排序的思想,当要插入45的时候,前面7个数字已经是有序的了。故该数组为15,23,38,54,60,72,96,45,83,故要比较到38时结束,所以为5次- 以下排序方式中占用O(n)辅助存储空间的是( )
A 简单排序
B 快速排序
C 堆排序
D 归并排序
解析:归并排序的特殊之处就是借助另一个数组存数据- 下列排序算法中稳定且时间复杂度为O(n^2)的是( )
A 快速排序
B 冒泡排序
C 直接选择排序
D 归并排序
解析:快速排序不稳定且时间复杂度为O(nlogn)冒泡稳定且时间复杂度为O(n^2)直接选择排序不稳定且时间复杂度为O(n ^2)归并排序稳定且时间复杂度为O(nlogn)- 关于排序,下面说法不正确的是 ( )
A 快排时间复杂度为O(N*logN),空间复杂度为O(logN)
B 归并排序是一种稳定的排序,堆排序和快排均不稳定
C 序列基本有序时,快排退化成冒泡排序,直接插入排序最快
D 归并排序空间复杂度为O(N),堆排序空间复杂度的为O(logN)
解析:堆排序不需要借助额外的辅助空间来建堆- 下列排序法中,最坏情况下时间复杂度最小的是( )
A 堆排序
B 快速排序
C 希尔排序
D 冒泡排序
解析:堆排序最坏情况是n*logn,快速排序是O(n^2),希尔排序O(n ^2),冒泡排序O(n ^2)- 设一组初始记录关键字序列为(65,56,72,99,86,25,34,66),则以第一个关键字65为基准而得到 的一趟快速排序结果是()
A 34,56,25,65,86,99,72,66
B 25,34,56,65,99,86,72,66
C34,56,25,65,66,99,86,72
D 34,56,25,65,99,86,72,66
解析:
答案:1.A 2.C 3.D 4.B 5.D 6.A 7.A