1.排序的概念及其运用
1.1 排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2 常见的排序算法
2. 常见排序算法的实现
2.1 插入排序
2.1.1 基本思想
直接插入排序是一种简单的插入排序法,其基本思想是:
通过构建有序序列,把待排序的记录按其数据的大小逐个插入到一个已经排好序的有序序列中,直到所有的数据插入完为止,得到一个新的有序序列 。
2.1.2 直接插入排序
插入排序步骤如下:
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2~5,直到排序完成
直接插入排序的特性总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
2.1.3 直接插入排序的实现
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end+1];
while (end >= 0)
{
if (tmp < a[end])
{
a[end+1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
void PrintArray(int* a, int n)
{
for (int i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
printf("\n");
}
void TestInsertSort()
{
int a[] = { 3,5,6,2,1,9,8,10 };
InsertSort(a, sizeof(a) / sizeof(int));
PrintArray(a, sizeof(a) / sizeof(int));
}
int main()
{
TestInsertSort();
return 0;
}
结果如下:
2.1.4 希尔排序( 缩小增量排序 )
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
希尔排序的特性总结:
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就 会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
- 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的 希尔排序的时间复杂度都不固定:
- 稳定性:不稳定
2.1.5 希尔排序的实现
void ShellSort(int* a, int n)
{
int gap = n ;
while (gap > 1)
{
gap = gap/ 2;
for (int i = 0; i < n - 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;
}
}
}
void TestShellSort()
{
int a[] = { 3,5,6,2,1,9,8,10 };
ShellSort(a, sizeof(a) / sizeof(int));
PrintArray(a, sizeof(a) / sizeof(int));
}
int main()
{
TestShellSort();
return 0;
}
2.2 选择排序
2.2.1 基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
2.2.2 直接选择排序:
- 首先在未排序的序列中找到最小(或最大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(或最大)元素,然后放到已排序序列的末尾。
步骤如下:
- 从待排序序列中,找到最小(或最大)元素,存放到排序序列的起始位置。
- 再从剩余未排序元素中继续寻找最小(或最大)元素,然后放到已排序序列的末尾。
- 重复第二步,直到所有元素均排序完毕。
直接选择排序的特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
2.2.3 直接选择的实现
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int mini = begin;
int maxi = end;
for (int i = begin+1; i <= end; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[begin], &a[mini]);
if (maxi == begin)//此时begin下标的数字已经改变
{
maxi = mini;
}
Swap(&a[end], &a[maxi]);
begin++;
end--;
}
}
void PrintArray(int* a, int n)
{
for (int i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
printf("\n");
}
void TestSelectSort()
{
//int a[] = { 3,5,6,2,1,9,8,10 };
int a[] = { 3,5,6,2,1,6,9,8,6,10,6 };
SelectSort(a, sizeof(a) / sizeof(int));
PrintArray(a, sizeof(a) / sizeof(int));
}
int main()
{
TestSelectSort();
return 0;
}
2.2.3 堆排序
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是 通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
对于堆排序的详解可以看这篇文章:数据结构学习——二叉树-CSDN博客
直接选择排序的特性总结:
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
2.3 交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排 序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
2.3.1 冒泡排序
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳
2.3.2 快速排序
快速排序(Quick Sort)是从冒泡排序算法演变而来的,实际上是在冒泡排序基础上的递归分治法。快速排序在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成了两个部分。
算法思想:
采用分治的思想,对于一组数据,选择一个基准元素(base),hoare版本通常选择第一个或最后一个元素,通过第一轮扫描,比base小的元素都在base左边,比base大的元素都在base右边,再有同样的方法递归排序这两部分,直到序列中所有数据均有序为止。
快速排序算法通过多次比较和交换来实现排序,快速排序步骤如下:
1、首先设定一个分界值,通过该分界值将数组分成左右两部分。
2、将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边。此时,左边部分中各元素都小于或等于分界值,而右边部分中各元素都大于或等于分界值。
3、然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。
4、重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。
2.3.3 快速排序实现
- hoare版本图示与代码
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
int GetMidi(int* a, int begin, int end)
{
int mid = (end + begin) / 2;
if (a[begin] > a[end])
{
if (a[end] > a[mid])
return end;
else if (a[mid] > a[begin])
return begin;
else
return mid;
}
else
{
if (a[begin] > a[mid])
return begin;
else if (a[mid] > a[end])
return end;
else
return mid;
}
}
int PartSort1(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int left = begin;
int right = end;
int keyi = begin;
while (left < right)
{
while (a[right] >= a[keyi] && left < right)
{
--right;
}
while (a[left] <= a[keyi] && left < right)
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
return left;
}
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
int keyi = PartSort1(a, begin, end);
QuickSort(a, begin, keyi-1);
QuickSort(a, keyi+1, end);
}
2.挖坑法
int PartSort2(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int hole = begin;
int key = a[begin];
while (begin < end)
{
while (a[end] >= key && begin < end)
{
end--;
}
a[hole] = a[end];
hole = end;
while (a[begin] <= key && begin < end)
{
begin++;
}
a[hole] = a[begin];
hole = begin;
}
a[hole] = key;
return hole;
}
3.前后指针版本
int PartSort3(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int keyi = begin;
int prev = begin ;
int cur = prev + 1;
while (cur <= end)
{
//if (a[cur] > a[keyi])
// ++cur;
//else
//{
// ++prev;
// Swap(&a[cur], &a[prev]);
// ++cur;
//}
if(a[cur]< a[keyi]&&++prev!=cur)
Swap(&a[cur], &a[prev]);
++cur;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
return keyi;
}
2.3.3 快速排序非递归
快速排序非递归的思想是通过使用一个数据结构(通常是栈)来模拟递归过程中函数调用栈的行为。在递归版本的快速排序中,我们不断地将子数组作为参数传递给函数本身,这实际上是在调用栈上压入和弹出函数调用的过程。非递归版本则是显式地管理这个调用栈,通过循环来模拟这个过程。
具体来说,非递归快速排序的步骤如下:
- 初始化:首先,我们确定整个数组作为第一个待处理的区间,并将其相关信息(比如区间的左右边界)压入栈中。
- 循环处理:在循环中,我们不断地从栈中取出待处理的区间(即弹出栈顶元素),并对该区间执行快速排序的划分操作。划分操作会选择一个基准元素,并将数组划分为两部分:小于基准的部分和大于基准的部分。然后,我们将这两个部分作为新的待处理区间压入栈中(如果它们非空的话)。
- 栈空判断:在每次从栈中取出区间之前,我们检查栈是否为空。如果栈为空,说明所有的区间都已经处理完毕,此时我们可以结束循环。
- 继续循环:如果栈不为空,我们取出栈顶的区间,重复步骤2和步骤3,直到栈为空。
通过这种方式,我们避免了递归过程中函数调用的开销,并且可以通过控制栈的大小来限制算法的空间复杂度。然而,需要注意的是,虽然非递归版本避免了递归调用,但它仍然需要额外的空间来存储待处理的区间信息(即栈空间)。因此,在空间复杂度上,非递归版本并不总是优于递归版本。
void QuickSortNonR(int* a, int begin, int end)
{
ST s;
STInit(&s);
STPush(&s, end);
STPush(&s, begin);
while (!STEmpty(&s))
{
int left = STTop(&s);
STPop(&s);
int right = STTop(&s);
STPop(&s);
int keyi = PartSort3(a, left, right);
if (left < keyi - 1)
{
STPush(&s, keyi - 1);
STPush(&s, left);
}
if (keyi + 1 < right)
{
STPush(&s, right);
STPush(&s, keyi+1);
}
}
STDestroy(&s);
}
快速排序的特性总结:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
- 时间复杂度:O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
2.4 归并排序
归并排序(Merge sort)是建立在归并操作上的一种有效的[序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。它将待排序的序列划分为若干个子序列,每个子序列都是有序的,然后再将有序子序列合并为整体有序序列。归并排序的过程可以分为以下三个步骤:
-
分解:将当前需要排序的序列平均分成两个子序列。如果当前序列长度为偶数,则平均分成两个等长的子序列;如果当前序列长度为奇数,则分成的两个子序列长度相差为1。对于每个子序列,递归地进行排序。
-
递归进行排序并合并:对于分解得到的两个子序列,递归地进行归并排序。当子序列的长度为1时,可以认为这个子序列已经是有序的,因此递归的基准情形就是子序列长度为1。递归完成后,合并两个已经排序的子序列。
-
合并:合并两个已排序的子序列,得到一个新的有序序列。合并的具体过程是:比较两个子序列的第一个元素,将较小的元素放入一个新的数组中,然后将指针向后移动一位;重复这个过程,直到其中一个子序列的所有元素都被放入新数组中;然后将另一个子序列中剩余的元素依次放入新数组中。
步骤如下:
归并排序的特性总结:
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
2.4.1 归并排序递归代码
void _MergeSort(int* a, int begin,int end,int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
//[0,mid][mid+1,end]
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid+1, end, tmp);
//归并[0,mid][mid+1,end]
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin] < a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
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);
if (tmp == NULL)
{
perror("malloc fail");
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
2.4.2归并排序递归代码
归并排序的非递归版本通常使用迭代的方式来实现,而不是通过递归函数。我们可以使用循环来模拟递归过程。
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1;
while (gap < n - 1)
{
for (int i = 0; i < n; i += 2*gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + gap * 2 - 1;
if (begin2 >= n || end1 >= n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
int j = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
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 + i, tmp + i, sizeof(int) * (end2 - i + 1));//一组有序后拷贝回原数组,继续拷贝下一组。这样确保下次组里面是有序的。
}
gap *= 2;
}
}
2.5 计数排序
计数排序是一个非基于比较的排序算法,元素从未排序状态变为已排序状态的过程,是由额外空间的辅助和元素本身的值决定的。
计数排序的基本步骤如下:
- 根据待排序集合中最大元素和最小元素的差值范围,申请额外空间;
- 遍历待排序集合,将每一个元素出现的次数记录到元素值对应的额外空间内;
- 对额外空间内数据进行计算,得出每一个元素的正确位置;
- 将待排序集合每一个元素移动到计算得出的正确位置上。
代码如下:
void CountSort(int* a, int n)
{
int min = a[0];
int max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] < min)
min = a[i];
if (a[i] > max)
max = a[i];
}
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
if (count == NULL)
{
perror("calloc fail");
return;
}
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int i = 0;
for (int j = 0; j < range; j++)
{
while (count[j]--)
{
a[i++] = j + min;
}
}
}
元素从未排序状态变为已排序状态的过程,是由额外空间的辅助和元素本身的值决定的。
计数排序的基本步骤如下:
- 根据待排序集合中最大元素和最小元素的差值范围,申请额外空间;
- 遍历待排序集合,将每一个元素出现的次数记录到元素值对应的额外空间内;
- 对额外空间内数据进行计算,得出每一个元素的正确位置;
- 将待排序集合每一个元素移动到计算得出的正确位置上。
代码如下:
void CountSort(int* a, int n)
{
int min = a[0];
int max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] < min)
min = a[i];
if (a[i] > max)
max = a[i];
}
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
if (count == NULL)
{
perror("calloc fail");
return;
}
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int i = 0;
for (int j = 0; j < range; j++)
{
while (count[j]--)
{
a[i++] = j + min;
}
}
}