第一章:排序的概念及其运用
1.1 排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2 排序运用
1.3 常见的排序算法
第二章:常见排序算法的实现
2.1 插入排序
2.1.1 基本思想:
直接插入排序是一种简单的插入排序法,其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想
2.1.2 直接插入排序:
当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移
//思路:
//将第一个元素视为已排序部分,从第二个元素开始,将该元素依次与已排序部分的元素依次进行比较。
void InsertSort(int* a, int n) { //直接插入排序
//i为数组下标,从数组的第一个元素开始(下标为0),依次和后面的元素比较。遍历至倒数第二个元素
for (int i = 0; i < n - 1; i++) {
int end = i; //已排序部分最后元素
int tmp = a[i + 1]; //待排序的元素。暂存tmp中,防止后面排序移动数据时被覆盖
while (end >= 0) { //在已排序部分最后元素从后向前查找合适的插入位置
if (a[end] > tmp) { //如果已排序部分的元素大于当前要插入的元素
a[end + 1] = a[end]; //将已排序元素向后移动一位,为要插入的元素腾出插入位置
end--; //继续直向前遍历,
}
else //直到已排序部分的某个元素小于插入元素时停下
break;
}
//这里说明end指向的已排序元素比插入元素小
a[end + 1] = tmp;// 将新元素插入到该数组元素后面
}
}
直接插入排序的特性总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N ^ 2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
2.1.3 希尔排序( 缩小增量排序 )
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
版本一: 一组一组排序
预排序,未完全完成排序
//思路:
//从下标0开始,每隔gap(3)选数据(这里是对数据分组),然后用插入排序法排序这些数据。
//再从下标1开始,重复上面步骤,直到下标等于gap-1
//这里是一组一组排序
void ShellSort(int* a, int n) { //希尔排序
int gap = 3;
for (int j = 0; j < gap; j++) { //循环gap组
for (int i = j; i < n - gap; i += gap) { //每组数据插入排序
int end = i; //已排序部分最后元素
int tmp = a[end + gap]; //要插入的数据。该组数据最后元素end+gap要小于n,end=i,所以i<n-gap
while (end >= 0) {
if (a[end] > tmp) {
a[end + gap] = a[end];
end -= gap;
}
else
break;
}
a[end + gap] = tmp;
}
}
}
版本二:多组并排
减少一层循环,该版本是每组完成一次排序再进行第二次。预排序,未完全完成排序
void ShellSort(int* a, int n) { //希尔排序
int gap = 3;
//gap=3,被分成了3组数据。
//这种写法相当于每组数据分别排序一次
//即A组第一次,B组第一次,C组第一次。依次类推排完所有数据
for (int i = 0; i < n - gap; i++) {
int end = i; //记录当前要插入元素的前一个位置索引
int tmp = a[end + gap]; //要插入的数据。该组数据最后元素end+gap要小于n,end=i,所以i<n-gap
while (end >= 0) {
if (a[end] > tmp) {
a[end + gap] = a[end];
end -= gap;
}
else
break;
}
a[end + gap] = tmp;
}
}
结论:
gap越大,大的数可以更快到后面,小的数可以更快到前面
gap越小,大的小的挪动越慢,但是越接近有序
gap==1,就是直接插入排序
版本三:优化gap
完全完成排序
//继续优化gap版本 -- 时间复杂度O(N^1.3)
void ShellSort(int* a, int n) { //希尔排序
int gap = n;//gap要根据数据个数来确定
while (gap > 1) { //gap不用等于1,下方商为0时,再+1就能保证最后一次为直接插入排序。且等于1会死循环
//这里每排序一次,gap都会更小。
//当gap小于3时,商为0。+1既保证了程序正确,也能最后一次排序变为直接插入排序,保证数据有序
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++) {
int end = i; //记录当前要插入元素的前一个位置索引
int tmp = a[end + gap]; //要插入的数据。该组数据最后元素end+gap要小于n,end=i,所以i<n-gap
while (end >= 0) {
if (a[end] > tmp) {
a[end + gap] = a[end];
end -= gap;
}
else
break;
}
a[end + gap] = tmp;
}
}
}
希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定:
4. 稳定性:不稳定
2.2 选择排序
2.2.1 基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
2.2.2 直接选择排序:
- 在元素集合array[i]--array[n-1]中选择关键码最大(小)的数据元素
- 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
- 在剩余的array[i]--array[n-2](array[i+1]--array[n-1])集合中,重复上述步骤,直到集合剩余1个元素
void SelectSort(int* a, int n) { //选择排序
//思路:
//1.初始化起始、结束位置下标,该区间内为未排序部分。(初始时,整个数组都为未排序部分)
//2.初始化最小、最大元素下标。每次排序前遍历未排序区间的元素,更新最大、最小下标。
//3.将最小元素交换到起始位置;将最大元素交换到结束位置
//但要注意如果最大(maxi)下标和起始(begin)下标重合,那么交换最小元素(a[mini])和起始元素(a[begin])时,
//那么最大元素(a[mxi])就被换到最小下标(mini)处
//所以要将最大下标移动至最小下标处后,再交换最大元素和结束位置元素
//4.每次排序后,更新起始、结束下标,begin++,end--(即重新指向未排序区间)。然后重复2、3步骤
//当begin>=end时,说明没有待排序区间,排序结束
int begin = 0, end = n - 1; //起始和结束位置的下标
while (begin < end) {
int maxi = begin, mini = begin; //初始化最大值和最小值的下标
for (int i = begin; i <= end; i++) { //遍历数组未排序部分
if (a[i] > a[maxi])
maxi = i;
if (a[i] < a[mini])
mini = i;
}
Swap(&a[begin], &a[mini]);
//如果maxi和begin重叠,修正下即可
//上述情况因为maxi和begin相等,交换mini和begin也是交换mini和maxi,
//即maxi指向最小值,mini指向最大值。但mini的值已经正确移动到begin位置。
//所以要调整maxi指向,即将mini指向给maxi
if (maxi == begin)
maxi = mini;
Swap(&a[end], &a[maxi]);
begin++;
end--;
}
}
直接选择排序的特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
2.2.3 堆排序
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
void AdjustDown(HPDataType* a, int n, int parent)//堆的向下调整
{
//父亲下标找孩子
//leftchild = parent*2 + 1
//rightchild = parent*2 + 2
//如果是小堆,就找2个子节点中较小那个,因为父节点比小的子节点还小,说明父节点比2个子节点都小。
//如果是大堆,就找2个子节点中较大那个,因为父节点比大的子节点还大,说明父节点比2个子节点都大。
//1.先初始化当前父节点的左子节点
//2.以大堆为例,在比较父子节点之前选出左右子节点较大那个,
//3.如果较大子节点比父节点大,那么交换并更新父节点,再找到其子节点
//4.如果较大子节点比父节点小,说明已经是大堆,直接跳出循环。
//重复2、3、4步骤直至子节点不存在
int child = parent * 2 + 1;//假设左子结点小
while (child < n) //子节点下标要在数组内才继续
{
//child为左子结点,child+1为右子节点
//选出左右子节点小的那个(前提是右子节点存在)。如果右子节点小,孩子下标++
if (child + 1 < n && a[child + 1] > a[child]) //大堆
//if (child + 1 < n && a[child + 1] < a[child]) //小堆
child++;
if (a[child] > a[parent]) { //大堆,如果父节点比子节点小,调整。每个父节点>=子节点
//if (a[child] < a[parent]) { //小堆,如果父节点比子节点大,调整。每个父节点<=子节点
Swap(&a[child], &a[parent]);//交换父子节点
parent = child;//父节点下标指向子节点,以该节点作为新的父节点。
child = parent * 2 + 1;//新的子节点
}
else
break;
}
}
void HeapSort(int* a, int n)
{
//升序 建大堆
//降序 建小堆
//建堆 - 向下调整。(因为向下调整需要左右子树是堆,所以倒着向下调整)
//时间复杂度:F(N)=N-log(N+1) O(N)
//从倒数第一个非叶子节点(最后节点的父节点)开始调整
//叶节点(即终端节点,没有子节点的节点)不需要调整
//孩子找父亲 parent = (child-1)/2。n是元素个数,n-1才是最后节点下标
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
AdjustDown(a, n, i);
//堆排序 时间复杂度:N*logN
//1.初始化尾元素下标
//2.交换首尾元素,即堆顶元素放到数组最后。
//3.向下调整排序,不包含当前堆中的尾元素
//4.更新尾元素下标,end--
//5.end=0结束,一个元素视为有序(重复2~4步)。
int end = n - 1;
while (end > 0) //end不用等于0。只剩一个待排序数据时已经有序,不需要在调整。
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);//end是尾元素下标,其数值是n-1,恰好等于去掉最后元素的个数
end--;
}
}
直接选择排序的特性总结:
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
2.3 交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动
2.3.1冒泡排序
void BubbleSort(int* a, int n) {
for (int j = 0; j < n - 1; j++) {
bool exchange = false;
for (int i = 1; i < n - j; i++) { //从第二个数(下标为1)开始排序。第一个数默认有序
if (a[i - 1] > a[i]) {
int tmp = a[i - 1];
a[i - 1] = a[i];
a[i] = tmp;
exchange = true;
}
}
if (exchange == false)
break;
}
}
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
2.3.2 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
1. hoare版本
版本一 :此版本判断条件不完善
void PartSort(int* a, int left, int right) {
int keyi = left;
while (left < right) {
//右边找小
while (a[right] > a[keyi])
right--;
//左边找大
while (a[left] < a[keyi])
left++;
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
}
上面版本有2个问题:
- 当left和right位置的值等于keyi位置的值陷入死循环,即left不比keyi大,left不++;right不比keyi小,right不--。两下标不移动,无限交换两处的值。但当left和right跟keyi比较时,如果仅将条件改为>=或<=,这会引发第二个问题。
- 如果数组初始状态第一个元素最小,此时left和keyi同时指向它,那么right一直--,即使left==right,right还会--造成越界。
综上所述,left和right跟keyi比较时,不仅要将条件改为>=或<=避免死循环,还要在加上left<right的条件避免越界
版本二:
//时间复杂度:O(N*logN)
//空间复杂度:O(logN) 类似二叉树高度
int PartSort(int* a, int left, int right) {
//left必须从keyi位置开始。如果left在keyi后面且keyi是最小值,那么left不会移动,right会在left位置相遇。
//然后错误的交换left和keyi
int keyi = left;
while (left < right) {
//1.左边作key,右边先走;保障了相遇位置的值比key小或key的位置
//2.右边作key,坐边先走;保障了相遇位置的值比key大
//left和right相遇分2种情况:left遇right;right遇left。
//1.left遇right,right先停下,left在走。
//因为right先走,所以right停下的位置一定比key小。(right找比key小的,比key大就移动)
//此时相遇位置就是right停下位置,一定比key小
//2.right遇left,在相遇这轮,left没动,right在移动跟left相遇,相遇位置就是left位置
//情况一:left位置就是key位置(left始终没动过,始终指向key)
//情况二:交换过一些轮次,相遇时left位置一定比key小。
//因为上一轮left和right交换后,left位置比key小;且是right遇left,所以left位置比key小
//右边找小
while (left < right && a[right] >= a[keyi])
right--;
//左边找大
while (left < right && a[left] <= a[keyi])//要先检查越界再访问
left++;
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);//此时left和right相遇的位置就是基准值位置,将left或right的其中一个位置交换给基准值变量
//返回keyi的位置,即left和right相遇的位置。
//此位置的值作为基准值,继续排序它的左右两边
return left;
}
void QuickSort(int* a, int begin, int end) { //快速排序
if (begin >= end) //当待排序区间只有一个元素Or待排区间不存在说明全部排序完毕,递归结束
return;
//1.hoare版本
int keyi = PartSort(a, begin, end);//获取排序后的keyi
//[begin, keyi-1] keyi [keyi+1, end]
//分别递归keyi左右区间的数据,注意keyi已经排序到正确位置。
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
2. 挖坑法
//2.挖坑法 [left, right]
//创建基准值变量及坑变量并初始化。
//右边找小,左边找大。
//当right找到比key小的值,将该值赋值给坑位置。同时将坑位置更新为right位置。
//当left找到比key大的值,将该值赋值给坑位置。同时将坑位置更新为left位置。
//遍历完数组后,将key的值赋值给坑位置。
int PartSort2(int* a, int left, int right) {
int key = a[left];//初始化基准值为left的值
int hole = left;//初始化坑下标为left
while (left < right) {
//右边找小
while (left < right && a[right] >= key)
right--;
a[hole] = a[right];
hole = right;
//左边找大
while (left < right && a[left] <= key)//要先检查越界再访问
left++;
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return hole;
}
3. 前后指针法
//3.前后指针法 [left, right]
//prev初始指向开头,cur指向prev后面。
//若cur指向小于key,则prev++,并将cur与prev交换(若两者相等不交换),然后cur++
//若cur指向大于key,cur继续++
//当cur遍历完,将prev指向的值和key交换
//思路:
//prev指向已排好序部分最后元素。一开始,整个数组可以视为只有第一个元素是已排序的,
//因此 prev 初始化为 left,即数组的起始位置。
//cur负责遍历数组,当他找到比keyi小的值,prev就向后移动一个,然后交换。
int PartSort3(int* a, int left, int right) {
int keyi = left;//不会被覆盖(即交换)就用keyi(即下标)
//用于指向当前已经处理过的小于分区点值的最后一个元素的位置。
//初始时,prev 被设置为 left,即数组的起始位置。这表示在开始时,还没有找到任何小于分区点的元素。
int prev = left;
//用于遍历数组,查找当前位置的元素是否小于分区点元素。
int cur = left + 1;
while (cur <= right) {
if (a[cur] < a[keyi] && ++prev != cur) //这里无论第二个条件是否为真,++prev都会执行
Swap(&a[prev], &a[cur]);
cur++;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
return keyi;
}
2.3.3 快速排序优化
1. 三数取中法选key
当key是最大或最小时,排序的时间复杂度接近O(n^2),例如,如果数组已经有序或逆序。使用三数取中法可以大大降低这种最坏情况的概率。
int GetMidIndex(int* a, int left, int right) {
int mid = (left + right) / 2;
//int mid = left + (rand() % (right - left));//随机数三数取中,避免左右都是最小值
if (a[left] < a[mid]) {
if (a[mid] < a[right]) //a[left] < a[mid] 且 a[mid] < a[right]
return mid;
//a[left] < a[mid] 且 a[right] <= a[mid](因为上方a[mid] < a[right]不成立),又因为a[left] < a[right]
else if (a[left] < a[right])
return right;
else
return left;
}
else { //a[left] > a[mid]
if (a[mid] > a[right])
return mid;
else if (a[left] > a[right]) //a[left] > a[mid] 且 a[left] > a[right];a[mid] < a[right]
return right;
else
return left;
}
}
a. hoare版本(带三数取中)
int PartSort(int* a, int left, int right) {
//三数取中法选key
int midi = GetMidIndex(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
while (left < right) {
//右边找小
while (left < right && a[right] >= a[keyi])
right--;
//左边找大
while (left < right && a[left] <= a[keyi])//要先检查越界再访问
left++;
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
return left;
}
b. 挖坑法(带三数取中)
int PartSort2(int* a, int left, int right) {
//三数取中法选key
int midi = GetMidIndex(a, left, right);
Swap(&a[left], &a[midi]);
int key = a[left];//初始化基准值为left的值
int hole = left;//初始化坑下标为left
while (left < right) {
//右边找小
while (left < right && a[right] >= key)
right--;
a[hole] = a[right];
hole = right;
//左边找大
while (left < right && a[left] <= key)//要先检查越界再访问
left++;
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return hole;
}
c. 前后指针法(带三数取中)
int PartSort3(int* a, int left, int right) {
//三数取中法选key
int midi = GetMidIndex(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;//不会被覆盖(即交换)就用keyi(即下标)
int prev = left;
int cur = left + 1;
while (cur <= right) {
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
cur++;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
return keyi;
}
d. 三路划分版本(针对OJ题优化)
int GetMidIndex(int* a, int left, int right) {
int mid = left + (rand() % (right - left));//随机数三数取中,避免左右都是最小值
if (a[left] < a[mid]) {
if (a[mid] < a[right]) //a[left] < a[mid] 且 a[mid] < a[right]
return mid;
//a[left] < a[mid] 且 a[right] <= a[mid](因为上方a[mid] < a[right]不成立),又因为a[left] < a[right]
else if (a[left] < a[right])
return right;
else
return left;
}
else { //a[left] > a[mid]
if (a[mid] > a[right])
return mid;
else if (a[left] > a[right]) //a[left] > a[mid] 且 a[left] > a[right];a[mid] < a[right]
return right;
else
return left;
}
}
//1.a[cur]<key,交换cur和left位置的值,++left,++cur
//2.a[cur]>key,交换cur和right位置的值,--right
//3.a[cur]==key,++cur
//本质:
//1.小的换到左边,大的换到右边
//2.把key相等的值推到中间
void QuickSort(int* a, int begin, int end) { // 快速排序
if (begin >= end) // 当待排序区间只有一个元素Or待排区间不存在说明全部排序完毕,递归结束
return;
int left = begin;
int right = end;
int cur = left + 1;
int midi = GetMidIndex(a, left, right);
Swap(&a[left], &a[midi]);
int key = a[left];
while (cur <= right) {
if (a[cur] < key) {
Swap(&a[cur], &a[left]);
++left;
++cur;
}
else if (a[cur] > key) {
Swap(&a[cur], &a[right]);
--right;
}
else
++cur;
}
// 小于key 等于key 大于key
//[begin, left-1] [left,right] [right+1, end]
QuickSort(a, begin, left - 1);
QuickSort(a, right + 1, end);
}
2.3.4 快速排序非递归
1. 栈的使用:
栈的主要作用并不是进行排序操作,而是用来管理待排序的子数组范围。
栈被用来存储待排序子数组的起始和结束索引。每次从栈中取出一对索引,表示当前需要处理的子数组范围。
2. 排序过程:
实际的排序操作发生在对子数组进行分区(Partition)的过程中,即调用 PartSort 函数。
这个函数会根据选定的基准元素将子数组分成左右两部分,并返回基准元素的索引。
3. 分区后的处理:
分区完成后,根据基准元素的索引,将未排序的左右子数组范围压入栈中,以便后续继续处理和排序。
4. 栈的管理:
栈的作用类似于递归调用中的函数调用栈,但这里使用循环和栈结构来实现非递归的快速排序。
通过栈,可以有效地管理排序过程中子数组的分割和顺序,确保所有子数组都被正确地排序。
因此,栈的主要目的是帮助确定排序的范围,而不是在栈内直接进行排序操作。
这种非递归的实现方式避免了递归调用可能导致的栈溢出问题,同时保持了快速排序算法的效率和性能优势。
void QuickSortNonR(int* a, int begin, int end) {
ST st;
STInit(&st);
//将初始的排序范围[begin, end] 推入栈中。
//先进后出,end先进begin后进,所以begin先出end后出
STPush(&st, end);
STPush(&st, begin);
while (!STEmpty(&st)) {
//从栈中弹出顶部的两个元素,定义当前的子数组范围[left, right]。
int left = STTop(&st);//begin后进先出
STPop(&st);
int right = STTop(&st);//end先进后出
STPop(&st);
//对子数组 a[left...right] 进行分区操作,将数组围绕一个基准元素(keyi)进行分隔。
int keyi = PartSort(a, left, right);
//[begin, keyi-1] keyi [keyi+1, end]
//将子数组推入栈中:
if (keyi + 1 < right) { //如果keyi+1 < right,表示右侧子数组[keyi+1,right]还有需要排序的元素,将其索引推入栈中。
STPush(&st, right);
STPush(&st, keyi + 1);
}
if (left < keyi - 1) { //如果left < keyi-1,表示左侧子数组[left, keyi-1]还有需要排序的元素,将其索引推入栈中。
STPush(&st, keyi - 1);
STPush(&st, left);
}
}
STDestroy(&st);
}
快速排序的特性总结:
1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)
4. 稳定性:不稳定
2.4 归并排序
基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。归并排序核心步骤:
递归版本
版本一:
//思路:
//通过递归不断将数组平分为两部分,直到每个部分只有一个元素,此时可以认为这个部分是有序的。
//如果部分包含超过一个元素,则会对这两部分进行合并排序操作。
//时间复杂:O(N*logN) logN层,每层N个数
//空间复杂度:O(N) 需要拷贝一个同样大小数组。开辟的栈帧是logN,即空间复杂度函数式:N+logN
void _MergeSort(int* a, int begin, int end, int* tmp) {
if (begin == end) //子数组中只有一个元素,直接返回,因为单个元素视为有序。
return;
int mid = (begin + end) / 2;
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
int begin1 = begin, end1 = mid;//表示第一部分数组的范围
int begin2 = mid + 1, end2 = end;//表示第二部分数组的范围。
int i = begin;//i 表示当前存放位置,开始时 i 初始化为 begin。
//在归并排序中,确实需要保证在合并阶段时,区间内至少有两个元素才能正常进行合并操作。
while (begin1 <= end1 && begin2 <= end2) { //循环直到其中一部分数组被合并完,begin1-end1为左闭右闭区间
if (a[begin1] < a[begin2])
tmp[i++] = a[begin1++];
else
tmp[i++] = a[begin2++];
}
//循环结束后,可能存在某一部分数组还有剩余元素未处理,分别使用两个 while 循环将剩余元素放入 tmp 数组中。
while (begin1 <= end1)
tmp[i++] = a[begin1++];
while (begin2 <= end2)
tmp[i++] = a[begin2++];
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
版本二:
小区间优化 - 当数据个数较小时(例如10个)不在分组,直接调用其他排序
void _MergeSort(int* a, int begin, int end, int* tmp) {
if (begin == end) //子数组中只有一个元素,直接返回,因为单个元素视为有序。
return;
if (end - begin + 1 < 10) {
InsertSort(a + begin, end - begin + 1);//这里是对[begin,end]区间排序,不是a数组排序,所以是a+begin
return;
}
int mid = (begin + end) / 2;
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
int begin1 = begin, end1 = mid;//表示第一部分数组的范围
int begin2 = mid + 1, end2 = end;//表示第二部分数组的范围。
int i = begin;//i 表示当前存放位置,开始时 i 初始化为 begin。
//在归并排序中,确实需要保证在合并阶段时,区间内至少有两个元素才能正常进行合并操作。
while (begin1 <= end1 && begin2 <= end2) { //循环直到其中一部分数组被合并完,begin1-end1为左闭右闭区间
if (a[begin1] <= a[begin2])
tmp[i++] = a[begin1++];
else
tmp[i++] = a[begin2++];
}
//循环结束后,可能存在某一部分数组还有剩余元素未处理,分别使用两个 while 循环将剩余元素放入 tmp 数组中。
while (begin1 <= end1)
tmp[i++] = a[begin1++];
while (begin2 <= end2)
tmp[i++] = a[begin2++];
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n) { //归并排序
int* tmp = (int*)malloc(sizeof(int) * n);
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
非递归版本
版本一:错误版本
void MergeSortNonR(int* a, int n) { //错误版本
int* tmp = (int*)malloc(sizeof(int) * n);
int gap = 1;//每组的数据个数。1 2 4 8....
while (gap < n) { //不能等于,等于说明所有数据是一组,一组说明都排序完了。
int j = 0;
for (int i = 0; i < n; i += 2 * gap) { //遍历整个数组,每次根据gap个数排序2组
int begin1 = i, end1 = i + gap - 1;//表示第一部分数组的范围。(因为是左闭右闭区间,gap是数据个数,end1是下标
int begin2 = i + gap, end2 = i + 2 * gap - 1;//表示第二部分数组的范围。
//在归并排序中,确实需要保证在合并阶段时,区间内至少有两个元素才能正常进行合并操作。
while (begin1 <= end1 && begin2 <= end2) { //begin1-end1为左闭右闭区间
if (a[begin1] < a[begin2])
tmp[j++] = a[begin1++];
else
tmp[j++] = a[begin2++];
}
//循环结束后,可能存在某一部分数组还有剩余元素未处理,分别使用两个 while 循环将剩余元素放入 tmp 数组中。
while (begin1 <= end1)
tmp[j++] = a[begin1++];
while (begin2 <= end2)
tmp[j++] = a[begin2++];
}
memcpy(a, tmp, sizeof(int) * n);
gap *= 2;
}
free(tmp);
}
上方版本没有考虑越界的情况,因为数据是两两归并,只能处理个数为2的次方的数据
1.end1,begin2,end2全部越界
2.begin2,end2越界
3.end2越界
版本二:整体拷贝版本
void MergeSortNonR(int* a, int n) {
int* tmp = (int*)malloc(sizeof(int) * n);
int gap = 1;//每组的数据个数。1 2 4 8....
while (gap < n) { //不能等于,等于说明所有数据是一组,一组说明都排序完了。
int j = 0;
for (int i = 0; i < n; i += 2 * gap) { //遍历整个数组,每次根据gap个数排序2组
int begin1 = i, end1 = i + gap - 1;//表示第一部分数组的范围。(因为是左闭右闭区间,gap是数据个数,end1是下标,所以要-1)
int begin2 = i + gap, end2 = i + 2 * gap - 1;//表示第二部分数组的范围。
//下方前两种情况直接将第二组数据下标改为不存在的区间
//这样下方while循环的归并就不会进去。只会将第一组数据直接拷贝
if (end1 >= n) { //1.如果第一组数据不满足个数
end1 = n - 1;
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n) { //2.如果没有第二组数据
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n) //3.如果第三组数据不满足个数
end2 = n - 1;
while (begin1 <= end1 && begin2 <= end2) { //begin1-end1为左闭右闭区间
if (a[begin1] <= a[begin2])
tmp[j++] = a[begin1++];
else
tmp[j++] = a[begin2++];
}
while (begin1 <= end1)
tmp[j++] = a[begin1++];
while (begin2 <= end2)
tmp[j++] = a[begin2++];
}
memcpy(a, tmp, sizeof(int) * n);
gap *= 2;
}
free(tmp);
}
版本三:归并一组,拷贝一组版本(更推荐此版本)
void MergeSortNonR(int* a, int n) {
int* tmp = (int*)malloc(sizeof(int) * n);
int gap = 1;//每组的数据个数。1 2 4 8....
while (gap < n) { //不能等于,等于说明所有数据是一组,一组说明都排序完了。
int j = 0;
for (int i = 0; i < n; i += 2 * gap) { //遍历整个数组,每次根据gap个数排序2组
int begin1 = i, end1 = i + gap - 1;//表示第一部分数组的范围。(因为是左闭右闭区间,gap是数据个数,end1是下标,所以要-1)
int begin2 = i + gap, end2 = i + 2 * gap - 1;//表示第二部分数组的范围。
//修正越界
//这里的 break; 是为了避免对超出边界的无效子数组进行排序操作,
//这并不意味着未排序的元素不进行排序,而是在当前的迭代中,这两个子数组的剩余元素无法继续进行合并操作,
//因为归并排序要求两个数组必须是相邻且连续的。
//在下一次迭代中,这些剩余的元素会被合并到更大的子数组中,直到最终完成整个数组的排序。
//这么做的主要理由是为了确保归并排序的每一次合并操作都是有效的,具体原因包括:
//1.合并操作要求连续性:在归并排序中,要求进行合并的两个子数组必须是连续的、相邻的。
//如果 end1 超出了数组边界,说明当前的第一组数据已经超过了数组范围,不再与其后面的数据进行合并是合理的。
//2.避免无效操作:如果没有足够的第二组数据(即 begin2 >= n),则无法与第一组数据进行有效的合并排序。
//终止当前迭代可以避免对没有足够数据的情况进行无效的合并尝试。
//3.优化执行效率:通过这种方式,可以确保每次合并操作都是基于有效数据范围内的,
//避免了不必要的操作和可能的数组越界错误
if (end1 >= n || begin2 >= n)
break;
if (end2 >= n)
end2 = n - 1;
while (begin1 <= end1 && begin2 <= end2) { //begin1-end1为左闭右闭区间
if (a[begin1] <= a[begin2])
tmp[j++] = a[begin1++];
else
tmp[j++] = a[begin2++];
}
while (begin1 <= end1)
tmp[j++] = a[begin1++];
while (begin2 <= end2)
tmp[j++] = a[begin2++];
//归并一组,拷贝一组
//如果上方有数组越界,那么就没有归并到tmp中,这时tmp中有部分随机值。
//再将tmp数据拷贝回原数组时就会覆盖有效数据
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
free(tmp);
}
归并排序的特性总结:
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
2.5 非比较排序
思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤:
- 统计相同元素出现次数
- 根据统计的结果将序列回收到原来的序列中
void CountSort(int* a, int n) { //计数排序
int min = a[0];//初始化最大值和最小值
int max = a[0];
for (int i = 0; i < n; i++) { //遍历数组,找最大和最小
if (a[i] < min)
min = a[i];
if (a[i] > max)
max = a[i];
}
int range = max - min + 1;//计数数组个数,因为左闭右闭区间,所以+1
int* countA = (int*)malloc(sizeof(int) * range);
if (!countA) {
perror("malloc int* countA fail");
return;
}
memset(countA, 0, sizeof(int) * range);//计数数组所有数据初始化为0
//相对映射
//开辟一个计数数组countA用于存放原数组(a)各元素出现次数
//最小值min在第一个位置(下标0),最大值max在最后位置(下标rang-1)
//统计次数
for (int i = 0; i < n; i++) //循环遍历原数组元素
countA[a[i] - min]++;
//计算元素在计数数组中的索引:
//a[i] - min:这一步计算出了当前元素 a[i] 在计数数组 countA 中的索引位置。
//由于计数数组是以最小值 min 为基准,所以使用 a[i] - min 可以将元素映射到从0开始的计数数组索引上。
//增加计数:
//countA[a[i] - min]++:这里对应索引位置的计数器加1。
//意味着每当发现原数组中某个元素 a[i],就在 countA 中对应的位置增加其出现次数。
//排序
int k = 0;//初始化一个索引 k,用于逐步填充排序后的数组 a。
for (int j = 0; j < range; j++) //遍历计数数组 countA 的所有索引 j。
while (countA[j]--) //当 countA[j] 的值大于0时执行。j下标处的值是几,说明出现了几次
//索引 j 表示的是相对于最小值 min 的偏移量,即 j 代表的是原始数组中的元素值与 min 的差值。
a[k++] = j + min;//j + min(实际的元素值)
}
计数排序的特性总结:
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
- 时间复杂度:O(MAX(N,范围))
- 空间复杂度:O(范围)
- 稳定性:稳定
第三章:排序算法复杂度及稳定性分析
第四章:选择题练习
1. 快速排序算法是基于( )的一个排序算法。
A 分治法
B 贪心法
C 递归法
D 动态规划法
答案:A
2. 对记录(54,38,96,23,15,72,60,45,83)进行从小到大的直接插入排序时,当把第8个记录45插入到有序表时,为找到插入位置需比较( )次?(采用从后往前比较)
A 3
B 4
C 5
D 6
答案:C
3. 以下排序方式中占用O(n)辅助存储空间的是
A 简单排序
B 快速排序
C 堆排序
D 归并排序
答案:D
4. 下列排序算法中稳定且时间复杂度为O(n2)的是( )
A 快速排序
B 冒泡排序
C 直接选择排序
D 归并排序
答案:B
5. 关于排序,下面说法不正确的是
A 快排时间复杂度为O(N*logN),空间复杂度为O(logN)
B 归并排序是一种稳定的排序,堆排序和快排均不稳定
C 序列基本有序时,快排退化成冒泡排序,直接插入排序最快
D 归并排序空间复杂度为O(N), 堆排序空间复杂度的为O(logN)
答案:D
6. 下列排序法中,最坏情况下时间复杂度最小的是( )
A 堆排序
B 快速排序
C 希尔排序
D 冒泡排序
答案:A
7. 设一组初始记录关键字序列为(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
C 34,56,25,65,66,99,86,72
D 34,56,25,65,99,86,72,66
答案:A(挖坑法)
作业
1. 排序过程中,对尚未确定最终位置的所有元素进行一遍处理称为一趟排序。用冒泡排序对数列4 5 6 3 2 1进行升序排序,则第3趟之后的结果是( )
A.4 3 2 1 5 6
B.4 5 3 2 1 6
C.2 1 3 4 5 6
D.3 2 1 4 5 6
答案:D
解析:
冒泡排序,一趟排序会把未排序元素中的最值移动到未排序元素的最后一个位置,
第一趟:4 5 3 2 1 6
第二趟:4 3 2 1 5 6
第三趟:3 2 1 4 5 6
2. 使用选择排序对长度为100的数组进行排序,则比较的次数为( )
A.5050
B.4950
C.4851
D.2475
答案:B
解析:
选择排序,每次都要在未排序的所有元素中找到最值,
如果有n个元素,则
第一次比较次数: n - 1
第二次比较次数: n - 2
....
第n - 1次比较次数: 1
所有如果n = 100
则比较次数的总和:99 + 98 + ...... + 1
共4950次。
3. 有字符序列 FBJGEAIDCH,现在打算对它按字母的字典顺序用希尔排序进行排序,那么在第一趟后(步长为5)的序列为( )
A.CAEBFDIGJH
B.AIDCHFBJGE
C.ABDCEFIJGH
D.BFJGEAIDCH
答案:C
解析:
希尔排序按照步长把元素进行小组划分,每个小组元素进行插入排序。
所以如果步长为5,则整个数组被会划分成5组数据:
FA BI JD GC EH
所以一趟排序之后的结果为:
ABDCEFIJGH
4. 现有数字序列 5 11 7 2 3 17,目前要通过堆排序进行降序排序,那么由该序列建立的初始堆应为( )
A.2 3 7 11 5 17
B.17 11 7 2 3 5
C.17 11 7 5 3 2
D.2 3 5 7 11 17
答案:A
解析:
要降序排列,所以要建小堆,每次把堆顶元素放在当前堆的最后一个位置
建堆要进行向下调整算法(从最后一个非叶子节点开始进行向下调整算法,直到根元素)
5
11 7
2 3 17
5
2 7
11 3 17
2
3 7
11 5 17
所以初始堆序列为: 2 3 7 11 5 17
5. 下列关于归并排序的说法中正确的是( )
A.归并排序不需要辅助空间
B.归并排序的时间复杂度是O(logn)
C.归并排序是稳定排序
D.归并排序的操作方式类似二叉树的前序遍历
答案:C
解析:
归并排序需要一个辅助空间暂时保存部分区间的排序元素
归并排序是一种二分排序算法,每次都需要给n个元素排序,排序的过程需要logn,即树的高度,所以时间复杂度为nlogn
归并排序中,相同元素的相对位置不会发生变化,所以是稳定排序
6. 对数字序列28 16 32 12 60 2 5 72进行升序的快速排序(以第一个关键码为基准的方法),一次划分后的结果为( )
A.2 5 12 16 28 60 32 72
B.2 16 5 12 28 60 32 72
C.2 16 12 5 28 60 32 72
D.5 16 2 12 28 32 60 72
答案:B
解析:
快速排序以基准值为中心,对元素进行划分,这里以28为基准值,则小于28的和大于28的进行交换,完成一次划分
首先:32和5交换: 28 16 5 12 60 2 32 72
然后60和2交换: 28 16 5 12 2 60 32 72
最后28和最后一个小于28的元素进行交换:2 16 5 12 28 60 32 72
7. 下列选项中,不可能是快速排序第2趟排序后的结果的是( )
A.2 3 5 4 6 7 9
B.2 7 5 6 4 3 9
C.3 2 5 4 7 6 9
D.4 2 3 5 7 6 9
答案:C
解析:
这里说的是快排的第二趟,即在第一趟快排的结果的基础上进行的,如果已经经过了一趟排序,则会通过第一趟选择的基准值划分两个子区间,每个子区间也会以区间内选择的基准值划分成两部分。
A: 第一趟的基准值可以为2, 第二趟的基准值可以为3
B: 第一趟的基准值可以为2, 第二趟的基准值可以为9
C: 第一趟的基准值只能是9,但是第二趟的基准值就找不出来,没有符合要求的值作为基准值,所以不可能是一个中间结果。
D: 第一趟的基准值可以为9, 第二趟的基准值可以为5
8.对n个元素执行快速排序,需要的额外空间的大小为( )
A.O(1)
B.O(n)
C.O(logn)
D.O(nlogn)
答案:C
解析:
如果是递归算法,所递归的深度大概为二叉树的深度,即logn
如果是非递归算法,需要模拟递归的过程,即需要保存子区间的索引,每次都会成对的保存,最多保存的索引也和二叉树的高度有关:2 * logn
所以空间复杂度为logn
9. 下列关于三数取中法快速排序的描述错误的是( )
A.三数取中法可以有效避免快排单链的情况,尤其对已经有序的序列的速度改善尤为明显
B.三数取中法依然无法完全解决针对某种特殊序列复杂度变为O(n)的情况
C.三数取中法一般选取首、尾和正中三个数进行取中
D.三数取中法的快速排序在任何情况下都是速度最快的排序方式
答案:D
解析:
B选项所说的特殊序列比如元素全部相同,此时也无法改善性能。
D选项,三数取中法能过保证快排的平均时间复杂度为nlogn,并不是在所有情况下都是最快的,比如完全有序的序列,冒泡排序可以做到O(n)
10. 下列关于快速排序的非递归算法的说法中错误的是( )
A.快速排序的非递归遍历可以使用栈模拟二叉树的前序遍历的方式实现
B.快速排序的非递归遍历可以使用队列模拟二叉树的层序遍历的方式实现
C.快速排序的非递归遍历可以明显的提升排序的速度
D.快速排序的非递归遍历大大降低了栈空间的开销
答案:C
解析:
快排的非递归是在模拟递归的过程,所以时间复杂度并没有本质的变化,但是没有递归,可以减少栈空间的开销。栈和队列都可以实现。
11. 下列排序方法中,哪一种是不稳定的( )
A.直接插入排序
B.归并排序
C.选择排序
D.冒泡排序
答案:C
解析:
直接插入一般可以从前向后进行元素的插入,相同元素的相对位置可以不发生变化。
归并也可以保证相对位置不变。
冒泡排序在元素相同的情况下也可以不进行交互,也可以保证稳定。
选择排序的思想是每次选出最值,放在已排序序列的末尾,如果最值有多个,而选出的为最后一个最值,会导致相对位置发生变化。当然选择排序也可以变成稳定的,只要保证相同的值选择第一个就可以。
12.下列排序方法中,每一趟排序结束时都至少能够确定一个元素最终位置的方法是( )
① 选择排序
② 归并排序
③ 快速排序
④ 堆排序
A.①④
B.①②④
C.①③④
D.①②③④
答案:C
解析:
选择排序每次选一个最值,放在最终的位置
快速排序每次基准值的位置也可以确定
堆排序每次堆顶元素的位置也可以确定
所以这三种方法都可以每次至少确定一个元素的位置
而归并排序每次都需要对n个元素重新确定位置,所以不能保证每次都能确定一个元素位置,有可能每次排序所有元素的位置都为发生变化。
13. 下列关于排序方法和其平均时间复杂度,配对错误的是( )
A.堆排序——O(nlog2 n)
B.直接插入排序——O(n^2)
C.选择排序——O(n^2)
D.归并排序——O(n^2)
答案:D
解析:归并排序是二分排序,其实际复杂度为nlogn
14. 下列排序算法中,占用辅助空间最多的是( )
A.归并排序
B.快速排序
C.希尔排序
D.堆排序
答案:A
解析:
归并排序空间复杂度:n
快排: logn
希尔,堆排: 1
15. 下列排序算法中,最坏时间复杂度不为O(n^2)的是( )
A.堆排序
B.快速排序
C.选择排序
D.插入排序
答案:A
解析:
堆排: 堆为完全二叉树,每次调整的时间最坏为logn,所以其时间复杂度最坏为nlogn
快排: 如果每次划分只有一半区间,则时间复杂度为n^2
选择排序:时间复杂度始终为n^2
插入排序:如果序列逆序,每次都需要移动元素,时间复杂度n^2
16. 下列排序算法中,最好时间复杂度和最坏时间复杂度不相同的是( )
A.快速排序
B.归并排序
C.选择排序
D.堆排序
答案:A
解析:
快排: n^2 ~ nlogn
归并: nlogn
选择: n^2
堆排: nlogn
17. 以下哪种排序算法对[1, 3, 2, 4, 5, 6, 7, 8, 9]进行排序最快( )
A.直接插入排序
B.快速排序
C.归并排序
D.堆排序
答案:A
解析:
次序列接近有序,所以如果是插入排序,时间复杂度逼近O(n)
快排: 逼近O(n^2)
归并和堆排仍然是nlogn
18. 下列排序算法中,在待排序数据已有序时,花费时间反而最多的是( )排序。
A.堆排序
B.归并排序
C.希尔排序
D.快速排序
答案:D
解析:同上题
19. 下面的排序算法中,初始数据集的排列顺序对算法的性能无影响的有( )
① 快速排序
② 希尔排序
③ 插入排序
④ 堆排序
⑤ 归并排序
⑥ 选择排序
A.①④⑤
B.④⑤⑥
C.②③⑥
D.②③⑤⑥
答案:B
解析:
快排: 初始顺序影响较大,有序是,性能最差
插入: 接近有序,性能最好
希尔:希尔是对插入排序的优化,这种优化是在无序的序列中才有明显的效果,如果序列接近有序,反而是插入最优。
堆排,归并,选择对初始顺序不敏感
20. 用某种排序方法对关键字序列 25 84 21 47 15 27 68 35 20 进行排序,序列的变化情况采样如下:
20 15 21 25 47 27 68 35 84
15 20 21 25 35 27 47 68 84
15 20 21 25 27 35 47 68 84
请问采用的是以下哪种排序算法( )
A.选择排序
B.希尔排序
C.归并排序
D.快速排序
答案:D
解析:此题中的排序是快排二分排序的思想,第一趟的基准值是25,第二趟的基准值分别是20,47,第三趟的基准值分别是15,21,35,68