文章目录
文章内容:该篇文章主要是介绍排序的一些常用算法,这些算法无论时间复杂度高还是低都非常的有代表性,学习排序初阶算法不仅能帮助我们加深对算法好坏的分析能力,更能帮助我们去综合运用算法解决问题的能力,该篇文章的算法代码是用c语言实现的(除了非递归版本的快速排序,用了容器栈),用C++也是极为相似的。另外排序进阶的外排序之文件归并排序以及三路划分,自省排序也会总结阐述
思维导图
1. 插入排序
1.1 直接插入排序
思路
当i = 0时,可以把每一次的for循环区间[0,i]的数组看成有序的,而需要将 i + 1 处的元素插入到区间[0,i]的合适位置,插入结束后每次i++去保存下一个数据,一共有n个数据,除却第一个数据,则需要将后面n - 1个数据依次插入到区间[0,i]中,因此要循环n-1次
代码
void InsertSort(int* arr, int n)
{
// 这里i <= n - 2, i = n - 1没有下一个值给tmp
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = arr[end + 1];// 把下一个值保存下来
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + 1] = arr[end];
end--;
}
else
break;
}
arr[end + 1] = tmp;
}
}
1. 2 希尔排序
思路
希尔排序它是以直接插入排序为基础,并对它进行优化,最为核心的思路就是,
先通过预排序,也就是在每个小组中进行排序,最终让大部分小的往左边靠,让大部分大的往右边靠,最后再直接插入排序
,大的往右边靠后,那最后直接插入排序中arr[end] < tmp的次数会增加,break的次数也增加,一定程度上减少了内层while循环的次数
其实本质还是在于直接插入排序,比如按高和矮去排队,先站成一排,分成M组,反正这些组的队员,前面,中间,后面的都有,那组队员先内部排序,高的站后面去,矮的站前面,这M组站好后,有一些高的去了后面,有一些矮的去了前面,但只来一次效果不大,再来N次。那只要保障最后一次为直接插入排序即可
优化的思路
- 这里一般采用的是
gap / 3 + 1
,当然也能 gap / 2 + 1;最后的1是为了保障最后的一组为直接插入排序
- 其实到这里会发现一个疑问,再写插入排序代码的时候,很容易得把每个小组先排行序,比如其中一组为
[9,7,3]
,那先排成[3,7,9]
,接着去排下一组,但我们上述思路是先排前面两个数字,等i
走到7后再排3的顺序,那没优化的思路和代码就在下面展示,我觉得应该根据优化的思路去看未优化的思路,才能算完全理解了希尔排序
代码
// 希尔排序-优化的思路
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;
}
}
}
不优化的思路
不优化的思路根据代码能看到,每组与每组的排序是分割来排序的,相比较优化的思路又多套了一层for循环,用来从每组首元素处开始进行直接插入排序,排序的次数我认为是一样的,比如[9,7,3]
,一次性排完进行三次while循环,优化的思路也是三次,只不过优化的思路代码简洁,但不太好理解
代码
// 希尔排序-不优化版本
void ShellSort(int* arr, int n)
{
// 没有优化的
int gap = n; // 分组进行排序
while (gap > 1)
{
gap = gap / 3 + 1;
for (int j = 0; j < gap; j++) // 对每个组进行预排序
{
for (int i = j; i < n - gap; i += gap) // 对每个组中的小组进行预排序
{
int end = i;
int tmp = arr[end + gap]; // 这里 i < n - gap; 就是因为arr[end + gap]不越界
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
break;
}
arr[end + gap] = tmp;
}
}
}
}
2. 选择排序
2. 1 直接选择排序
上面的动图是没优化的直接选择排序,直接选择排序的思路是比较简单的,用双指针
int left = 0, right = n -1;
就是遍历一遍数组找出最小值与最大值的索引,然后与数组的 left 与 right 的元素交换,left++,right--;
在到[left,right]
区间找最小值与最大值进行交换。但需要注意最大值在left处的特殊情况,此时如果交换,那maxi此处指向的就不是最大值了
// 直接选择排序
void SelectSort(int* arr, int n)
{
int left = 0, right = n - 1;
while (left < right)
{
int maxi = left, mini = left;
// 遍历一遍数组,找出最大值,最小值的索引
// 分别与left,right处的元素交换
for (int i = left + 1; i <= right; i++)
{
if (arr[i] > arr[maxi])
maxi = i;
if (arr[i] < arr[mini])
mini = i;
}
// [9,1,2,5] 最初maxi = mini = left = 0;遍历一遍后mini = 1,maxi = left = 0;
// 如果swap(arr[left], arr[mini]);直接交换了maxi处的最大值就飘走了
if (left == maxi)
maxi = mini;
swap(arr[left++], arr[mini]);
swap(arr[right--], arr[maxi]);
}
}
2. 2 堆排序
堆排序需要借助向下调整算法建大堆,
排升序建大堆,排降序建小堆
,当将该数组建成一个大堆后,那大堆的堆顶元素一定是该数组的最大值,此时只需要将堆顶元素与数组末尾元素交换(end--
),再从堆顶处向下调整,重新建成大堆,再与数组end处的元素交换,直到end = 0
,至于如何用向下调整算法建成大堆,以及向下调整算法的讲解不在此处进行阐述,后续会在初阶数据结构进行补充
// 堆排序--需借助向上调整算法建造堆
// 排升序建大堆,排降序建小堆
void AdjustDown(int* arr, int n, int parent)
{
// 些找到孩子结点
int child = parent * 2 + 1;
while (child < n)
{
// 如果右孩子结点存在,那就找两孩子的最大值与父结点交换
if (child + 1 < n && arr[child + 1] > arr[child])
{
child++;
}
if (arr[child] > arr[parent])
{
swap(arr[child], arr[parent]);
parent = child;
child = 2 * parent + 1; // 继续判断下一父结点与孩子结点
}
else
break;
}
}
void HeapSort(int* arr, int n)
{
// 将数组里的值建成大堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, n, i);
}
// 大堆的堆顶元素一定是数组的最大值
// 将堆顶元素与 n - 1处的元素交换,再向下调整为大堆
int end = n - 1;
while (end > 0)
{
swap(arr[end], arr[0]);
AdjustDown(arr, end, 0);
end--;
}
}
3. 交换排序
3.1 冒泡排序
冒泡排序就是使用两层for循环,一层for循环用来冒出一个最大值,另外一层for循环就是开始冒除了上一个最大值的区间中的最大值,了解一下即可,时间复杂度太高了,效率太低了用处不大,不过当初最学最先接触的算法就是冒泡排序了,还是很有意义的。
不过这重点是放在交换排序
// 冒泡排序
void BubbleSort(int* arr, int n)
{
// 可以判断下特殊情况,如果已经是升序了就没必要再冒泡了
int stdim = 1;
for (int i = 0; i < n; i++)
{
// 这里得是 j < n - i - 1 当 i = 0时,j < n ,j = n - 1时,arr[n - 1 + 1]越界访问
for (int j = 0; j < n - i - 1; j++)
{
if (arr[j] > arr[j + 1])
{
// swap(arr[j], arr[j + 1]);
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
stdim = 0;
}
if (stdim == 1) break; // 证明已经是有序的了
}
}
}
3.2 快速排序
快速排序就是在区间中去找到一个基准值,该基准值右边的元素一定大于或等于基准值处的元素,左边的元素一定小于基准值处的元素,并将该基准值返回,并以此去递归左区间,以及右区间。递归的结束条件是
left >= right,那说明了两个元素的区间也在找基准值,返回基准值之前,这两个元素会被排成有序的,因为基准值要么在第一个元素要么在第二个元素,而该区间要么大于之前递归的基准值,要么小于
,那最终整个数组都会被排成有序的
// 快速排序
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
// 走到这里证明区间有效
int keyi = _QuickSort(arr, left, right);
QuickSort(arr, left, keyi - 1); // 递归左区间
QuickSort(arr, keyi + 1, right); // 递归右区间
}
3.2.1 hoare版本
// hoare版本的找基准值
int _QuickSort(int* arr, int begin, int end)
{
int key = begin;
begin++;
while (begin <= end)
{
// 从右往左找比基准值小的元素
while (begin <= end && arr[end] > arr[key]) end--;
// 从左往右找比基准值大的元素
while (begin <= end && arr[begin] < arr[key]) begin++;
// 找到后,两个元素开始交换,但得begin <= end的前提下
if (begin <= end) swap(arr[begin++], arr[end--]);
}
// 结束循环后,将key与right处元素交换,并将right作为基准值返回
// 以便主体去递归它的左区间与右区间
swap(arr[key], arr[end]);
return end;
}
3.2.2 挖坑法
// 挖坑法去找基准值
int _QuickSort(int* arr, int begin, int end)
{
int hole = begin;
int tmp = arr[hole];
while (begin < end)
{
// 从右往左找比基准值小的元素
while (begin < end && arr[end] >= tmp) end--;
arr[hole] = arr[end];
hole = end;
// 从左往右找比基准值大的元素
while (begin < end && arr[begin] <= tmp) begin++;
arr[hole] = arr[begin];
hole = begin;
}
arr[hole] = tmp;
return hole;
}
3.2.3 lumuto前后指针
定义两个变量
int prev = left, cur = left + 1; 并且将 int key = arr[left]; 保存下来,后面要比较
,若arr[cur] >= key cur++
,那就能知道在[prev + 1, cur - 1]
区间的值是一定大于key的,如果arr[cur] < key && ++prev != cur
,++prev后它所指向的元素就大于key了(看上面的区间
)就把小的值给搞到左边,大的值给搞到右边,其实就是交换arr[cur]与arr[prev],那此刻prev所指向的元素是不是小于key,那它循环结束之后,arr[left] = key > arr[prev],将它两交换并让prev作为基准值返回
// lumuto前后指针法
int _QuickSort(int* arr, int left, int right)
{
int prev = left, cur = left + 1;
int key = arr[left];
while (cur <= right)
{
if (arr[cur] < key && ++prev != cur)
swap(arr[cur], arr[prev]);
cur++;
}
swap(arr[prev], arr[left]);
return prev;
}
3.3 非递归版本的快速排序
// 非递归版本的快速排序C++
void QuickSortNonR(int* arr, int left, int right)
{
stack<int> st; // 先创建一个栈
st.push(left);
st.push(right);
while (!st.empty())
{
// 取栈顶元素,构成左右区间
int end = st.top();
st.pop();
int begin = st.top();
st.pop();
// 开始lumuto前后指针法去找基准值
int prev = begin, cur = begin + 1;
int key = arr[begin];
while (cur <= end)
{
if (arr[cur] < key && ++prev != cur)
swap(arr[cur], arr[prev]);
cur++;
}
swap(arr[prev], arr[left]);
// [begin, prev - 1] [prev + 1, end]
if (begin < prev - 1)
{
// 先入左区间
st.push(begin);
st.push(prev - 1);
}
if (prev + 1 < end)
{
st.push(prev + 1);
st.push(end);
}
}
}
如果用c语言去写的话还是很麻烦的,你得自己去创建一个容器栈,得手动初始化,销毁。这里就只截取代码了,思路是一样的。后续也会在初阶数据结构实现栈的模拟实现
//非递归版本的,需借助数据结构栈
void QuickSortNonR(int* arr, int left, int right)
{
ST st;
STInit(&st);
StackPush(&st, right);
StackPush(&st, left);
while (!StackEmpty(&st))
{
//取栈顶元素
int begin = StackTop(&st);
StackPop(&st);
int end = StackTop(&st);
StackPop(&st);
//开始模拟递归
int prev = begin, cur = begin + 1;
int key = arr[begin];
while (cur <= end)
{
if (arr[cur] < key && ++prev != cur)
swap(&arr[cur], &arr[prev]);
cur++;
}
swap(&arr[prev], &arr[begin]);
//[begin , prev - 1]
//[prev + 1 , end]
if (prev + 1 < end)
{
StackPush(&st, end);
StackPush(&st, prev + 1);
}
if (begin < prev - 1)
{
StackPush(&st, prev - 1);
StackPush(&st, begin);
}
}
}
4. 非比较排序(计数排序)
非比较排序借助哈希数组的下标来充当键(
arr[i] - min
),把arr数组的相同元素个数(count[arr[i] - min]]++
)作为值统计到相应的下标上
// 非比较排序--计数排序
void CountSort(int* arr, int n)
{
// 先得去找出这个数组中的最大值与最小值
int max = arr[0], min = arr[0];
for (int i = 1; i < n; i++)
{
if (arr[i] > max)
max = arr[i];
if (arr[i] < min)
min = arr[i];
}
// 找到后开始开辟空间
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("malloc fail");
exit(-1);
}
memset(count, 0, sizeof(int) * range); // 重置成零
// 先遍历一遍arr,将其中的值映射到count中
for (int i = 0; i < n; i++)
{
count[arr[i] - min]++;
}
// 遍历count,只要count[i] != 0,就把 i + min 赋值给arr[index++]
int index = 0;
for (int i = 0; i < range; i++)
{
while (count[i] != 0)
{
arr[index++] = min + i;
count[i]--;
}
}
free(count); // 别忘记释放
}
5. 归并排序
这个归并排序主要是采用递归的思路将一个大数组的排序划分成一小块块区间排序的子问题,其中两有序数组合并成一个升序数组的思路值得去认真学习
void _MergeSort(int* arr, int left, int right, int* tmp)
{
// 先递归到底
if (left >= right)
{
return;
}
int mid = (left + right) / 2; // 二分递归
_MergeSort(arr, left, mid, tmp);
_MergeSort(arr, mid + 1, right, tmp);
// 走到这里算是递归到底了
// 要将一小块块区间排序成大区间了
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = begin1;
// 开始排序放值到tmp中
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
tmp[index++] = arr[begin1++];
else
tmp[index++] = arr[begin2++];
}
// [left, mid] & [mid + 1, right],其中有个区间部分的值没放入
while (begin1 <= end1) tmp[index++] = arr[begin1++];
while (begin2 <= end2) tmp[index++] = arr[begin2++];
// 再将tmp中的值重新拷贝回arr中
for (int i = left; i <= right; i++)
{
arr[i] = tmp[i];
}
}
// 归并排序
void MergeSort(int* arr, int n)
{
// 先为tmp开辟一块空间
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
// 递归可不能直接在这个函数递归
_MergeSort(arr, 0, n - 1, tmp);
// 记得释放,否则内存泄漏
free(tmp);
}
6.测试程序
6.1测试结果
通过图片可以看出,直接插入排序,直接选择排序,冒泡排序是很拉跨的
6.2测试代码
// 测试排序的性能对⽐
void TestOP()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
int* a8 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
a8[i] = a1[i];
}
int begin7 = clock();
BubbleSort(a7, N);
int end7 = clock();
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
//QuickSortNonR(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
int begin8 = clock();
CountSort(a8, N);
int end8 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
printf("MergeSort:%d\n", end6 - begin6);
printf("BubbleSort:%d\n", end7 - begin7);
printf("CountSort:%d\n", end8 - begin8);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
free(a8);
}
7.排序算法复杂度及稳定性分析
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,⽽在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
直接选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
直接插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
希尔排序 | O(n log n) ~ O(n²) | O(n¹.³) | O(n²) | O(1) | 不稳定 |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) ~ O(n) | 不稳定 |
总结
无论算法时间复杂度高还是低,其都是非常有价值的,我认为这些算法真的像是天马行空,值得我们细心研究与探索,而我只是站在巨人的肩膀上做一个知识的搬运空,我认为对前人知识的学习和总结便是对他们思想做出最好的回应,如果你能看到这里,希望你能开开心心的学习与生活,无论学习质量高低,学习时间多少,只要往前走,我相信终有一天能找到属于自己想去探索的领域,以前人的肩膀为基,走上自己的康庄大道