文章目录
- 排序算法
- 稳定性
- 1. 插入排序
- 原理
- 排序过程
- 代码实现
- 性能分析
- 2. 希尔排序
- 原理
- 排序过程
- 关于增量取值
- 代码实现
- 性能分析
- 3. 选择排序
- 原理
- 排序过程
- 代码实现
- 性能分析
- 4. 堆排序
- 原理
- 排序过程
- 代码实现
- 性能分析
- 5. 冒泡排序
- 原理
- 排序过程
- 代码实现
- 性能分析
- 6. 快速排序
- 原理
- Hoare法
- 挖坑法
- 前后指针法
- 快排的优化
- 三数取中
- 区间优化
- 非递归实现快速排序
- 性能分析
- 7. 归并排序
- 原理
- 排序过程
- 递归代码实现
- 非递归代码实现
- 性能分析
- 8. 计数排序
- 原理
- 排序过程
- 代码实现
- 性能分析
- 9. 基数排序
- 原理
- 排序过程
- 代码实现
- 性能分析
- 性能分析
排序算法
稳定性
如何判断一个排序算法是否稳定?
两个相等的数据,如果经过排序后,排序算法能保证其相对位置不发生变化,则称该算法是具备稳定性的排序算法。
一个排序是否发生跳跃式的交换也是判断是否稳定的一个技巧。
- 一个稳定的排序算法可以变成一个不稳定的排序算法
- 一个本身就不稳定的排序算法是不可以变成一个稳定的排序算法的
1. 插入排序
直接插入排序是一种最简单的排序方法,它的基本操作是将一个记录插入到已排好的有序列表中,从而得到一个新的有序列表。
原理
把待排序的区间分为
- 有序区间
- 无序区间
每次选择无序区间的第一个元素,从有序区间的后面向前比较,在有序区间内选择符合要求的位置将元素插入。
直接插入排序的基本思想就是默认下标为0的元素是一个有序区间,让后从下标为1的位置开始向前插入元素,每插入一个元素有序区间的大小就加上1,直到把最后一个元素插入到有序区间内。
这就和平时的玩斗地主类似,每摸一张牌就会把牌插到对应的位置。
排序过程
记录无序区间的第一个数,拿它和有序区间的数从后往前逐个比较如果有序区间的数大于这个数,就将有序区间的比它大的数字往后移动一个位置,直到下标到-1或者不小于有序区间的数,然后将无序区间的数字插入到对应位置。假设要排序的数据是 24 , 19 , 32 , 48 , 38 , 6 , 13 , 24 {24,19,32,48,38,6,13,24} 24,19,32,48,38,6,13,24
代码实现
- tmp记录无序区间的第一个数字
- 从有序区间最后一个元素和tmp做比较
- 注意比较完后,此时的下标end是在要插入位置的前一个位置,所以要加一
// 直接插入排序
void InsertSort(int* arr, int n)
{
int i = 0;
for (i = 0; i < n-1; ++i)
{
int end = i;
int tmp = arr[end + 1];//记录无序区间的第一个元素
while (end >= 0)
{
// 拿要插入的元素和有序区间的元素比较
if (tmp < arr[end])
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
//插入到有序区间
arr[end + 1] = tmp;
}
}
性能分析
先然这里是两层循环嵌套,最坏情况就是当数据是逆序(或者接近逆序)的时候,最好情况当然是已经是有序的时候,这里没有用任何额外空间。开头说过判断稳定性就是一组数据里有相同的元素,如果排序前和排序后,这两个相同的元素的前后关系没有发生变化那么这个排序就是稳定的。
- 时间复杂度
- 最好情况: O ( n ) O(n) O(n)
- 最坏情况: O ( n 2 ) O(n^{2}) O(n2)
- 空间复杂度: O ( 1 ) O(1) O(1)
- 稳定性
- 稳定的排序
注意:一组数据的元素越区间于有序,直接插入排序的效率越高。
2. 希尔排序
我们直到直接插入排序的时间复杂度是 O ( n 2 ) O(n^{2}) O(n2),那么当排序数据非常大的时候,就比较慢了。
假设要排序1万个无序的数据,那么直接插入排序的时间复杂度就是 1000 0 2 = 1 亿 10000^{2} = 1亿 100002=1亿,如果采用一种分组的思想,把1万个数据分为100组一组100个,对每组进行直接插入排序,那么每组是有序的整体也就趋近于有序,那么再来排序时间复杂就会大大的降低,这就是希尔排序的思想。
原理
希尔排序又叫做“缩小增量排序”,它也是插入排序的一种。它的基本思想是:先将整个待排序序列通过增量分为若干个子序列分别进行直接插入排序,待整个序列的记录趋近于有序时,再对整体进行一次直接插入排序。
排序过程
假设我们要排序的数据是 24 , 19 , 32 , 48 , 38 , 6 , 13 , 24 , 22 , 2 24,19,32,48,38,6,13,24,22,2 24,19,32,48,38,6,13,24,22,2
我们假设第一次增量为 3 3 3,增量可以理解为每一组元素之间的间隔,也就是通过增量将这组元素分为了3组,对这三组元素分别进行直接插入排序,再把增量设置为2再对这两组进行直接插入排序,经过两轮分组排序之后我们发现,序列中的元素已经整体趋近于有序了,所以再整体进行一次插入排序即可,而越是趋近于有序的数据,直接插入排序的效率也就越高。
关于增量取值
在清华大学严蔚敏的《数据结构C语言版》上右这么一段话,就是关于增量序列的问题,目前尚求得最后的增量序列,但是需要注意的是:应使增量序列中的值没有除1之外的公因子,并且最后一个增量必须是1
- 增量越大:大的和小的数可以更快的移动到对应的方向,且越不接近于有序
- 增量越小:大的和小的数可以更慢移动到对应的方向,且越接近于有序
代码实现
这里的代码是通过一次就把所有元素排序完成。
- gap的取值保证最后为1就可以了
- gap不等于1之前其实都是预排序,让数据趋近于有序。
// 希尔排序
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;//保证最后gap为1就可以了
int i = 0;
for (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;
}
}
}
性能分析
希尔排序的时间复杂是不太好计算的,它取决于增量的取值,在严蔚敏的数据结构书上也有说到涉及到数学上尚未解决的问题。假设gap的取值是3,那么每一次循环的次数就是 n / 3 / 3 / 3... / 3 = 1 n/3/3/3.../3 = 1 n/3/3/3.../3=1,那就是 3 x = n 3^{x}=n 3x=n,然后插入排序本来时间复杂度应该是 O ( n 2 ) O(n^{2}) O(n2),当这里进行了多次预排序,数组已经很接近有序了,所以这里的插入排序可以认为是 O ( n ) O(n) O(n)。
-
时间复杂度
-
最坏的时间复杂度 O ( l o g 3 n ∗ n ) O(log_{3}n*n) O(log3n∗n)(针对我这里的代码)
-
希尔排序的时间复杂为 O ( n 1.3 到 1.5 ) O(n^{1.3到1.5}) O(n1.3到1.5)
-
-
空间复杂度
- O ( 1 ) O(1) O(1)
-
稳定性
- 不稳定的排序(发生跳跃式交换)
3. 选择排序
每次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的元素排序完。
原理
每次从无序区间中选取一个最大(或最小)的一个元素,存放在无序区间的最后(或最前),直到全部待排序的元素排序完。
- 在元素集合中 a r r [ i ] a r r [ n − 1 ] arr[i]~arr[n-1] arr[i] arr[n−1]中选择最大(最小的元素)
- 若它不是这组元素中的最后一个(第一个元素),则将它与这组元素中的最后一个(第一个)元素交换
- 在剩余的 a r r [ i ] a r r [ n − 2 ] arr[i]~arr[n-2] arr[i] arr[n−2]集合中,重复此步骤,直到集合中剩余一个元素为止
排序过程
直接选择排序比较简单,就是从待排序区间找最大或者最小的数放到排序完成的区间,每一次都排序都能确定一个排序好的元素。
代码实现
每一次遍历待排序区间都记录最小元素的下标然后和排序完成的区间的后一个位置的元素进行交换。
// 选择排序
void SelectSort(int* arr, int n)
{
int i = 0;
for (i = 0; i < n - 1; ++i)
{
int minIndex = i;//记录待排序区间最小元素下标
int j = 0;
for (j = i + 1; j < n; ++j)
{
if (arr[minIndex] > arr[j])
{
minIndex = j;
}
}
int tmp = arr[minIndex];
arr[minIndex] = arr[i];
arr[i] = tmp;
}
}
性能分析
选择排序是一种简单直观的排序算法,无论什么数据进行排序都是 O ( n 2 ) O(n^{2}) O(n2),如果要用选择排序,数据规模越小是越好的。
- 时间复杂度
- 最好最坏都是 O ( n 2 ) O(n^{2}) O(n2)
- 空间复杂度
- O ( 1 ) O(1) O(1)
- 稳定性
- 不稳定(发生跳跃式交换)
4. 堆排序
堆排序是利用数据结构堆的特性来进行排序,它也是一种选择排序,它通过堆来选取数据。排升序建大堆,排降序建小堆。
堆的详细介绍可以看这一篇文章数据结构堆的详解
原理
每次将堆顶元素和最后一个元素进行交换,再进行向下调整,然后缩小待排序区间,直到数据有序,因为堆顶的元素一定是一组数据中的最大或者最小值。
注意:向下调整的前提是,这个根节点的左右子树一定要是一个堆(大堆或小堆)
排序过程
代码实现
- 首先要将数组通过向下调整算法建立成逻辑上的堆
- 然后将堆顶元素和待排序区域最后一个元素进行交换,然后从堆顶开始向下调整
- 因为堆顶的元素一定是最大或者最小的,每次交换都会确定一个元素排序成功。
// 向下调整(建大堆)
void AdjustDown(int* arr, int n, int index)
{
int parent = index;
int child = parent*2+1;// 左孩子下标
while (parent < n)
{
// 找出左右孩子中较大的那一个
if (child < n && child + 1 < n && arr[child] < arr[child + 1])
{
++child;
}
//和父亲比较
if (child < n && arr[child] > arr[parent])
{
int tmp = arr[child];
arr[child] = arr[parent];
arr[parent] = tmp;
parent = child;//让调整的位置成为新的父节点
child = parent*2 + 1;
}
else
{
// 说明无需调整
break;
}
}
}
// 堆排序
void HeapSort(int* arr, int n)
{
//建堆
int i = 0;
for (i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(arr, n, i);
}
// 排序
int end = n-1;
while (end > 0)
{
// 堆顶元素和最后一个待排序元素交换
int tmp = arr[0];
arr[0] = arr[end];
arr[end] = tmp;
// 向上调整
AdjustDown(arr, end, 0);
--end;
}
}
性能分析
建堆的时间复杂度为 O ( n ) O(n) O(n),向下调整的时间复杂度为 O ( l o g 2 n ) O(log_{2}n) O(log2n)
- 时间复杂度
- O ( n ∗ l o g 2 n ) O(n*log_{2}n) O(n∗log2n)
- 空间复杂度
- O(1)
- 稳定性
- 不稳定(明显的跳跃式交换)
5. 冒泡排序
原理
冒泡排序也是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。
排序过程
每一趟冒泡排序都能确认一个元素最终的位置,这是一趟冒泡排序的过程
代码实现
当遍历了一遍待排序区间没有发生交换时,说明数组已经有序无需再排序了。
// 冒泡排序
void BubbleSort(int* arr, int n)
{
int i = 0;
for (i = 0; i < n - 1; ++i) // 冒泡排序趟数
{
int j = 0;
int flag = 1;
for (j = 0; j < n - i - 1; ++j) // 待排序区间进行比较交换
{
if (arr[j] > arr[j + 1])
{
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
flag = 0;
}
}
if (flag == 1)
{
// 说明已经有序
break;
}
}
}
性能分析
- 时间复杂度
- O ( n 2 ) O(n^{2}) O(n2)
- 只有当数据时有序的时候才为 O ( n ) O(n) O(n)
- 空间复杂度
- O ( 1 ) O(1) O(1)
- 稳定性
- 稳定的排序
- 很明显这是一个稳定的排序,没有发生跳跃式交换且交换的都是相邻的元素
6. 快速排序
原理
快速排序的基本思想是,通过选取一个关键元素key为一趟排序,将待排序元素分割成独立的两个部分,其中一部分记录的元素大小要比另外一部分的元素小。
- 从待排序区间选一个元素作为基准
- 重新排序数列,比基准小(或者大)的放在基准左边,比基准大(或者小)的放在基准右边(相同的数无所谓放哪边),这一次分区被称为一趟快速排序,此时基准左边的数一定是小于(或大于)基准的,而右边则是大于(或小于基准的)
- 再采用分支思想,对基准的左右两个区间按照同样的方式进行处理,直到区间大小变成1或者0,说明此区间已经有序,就停止递归。
Hoare法
Hoare法是快速排序的一种实现方法,它的实现思路为
- 定义left和right变量记录区间
- 每次取left为基准key(也可以是左边)
- 然后让right从右往左找比key大的值(如果是以left取基准,一定要让right先走)
- 然后让left从左往右找比基准key大的值
- 再让left和right的值进行交换
- 等left和right相遇之后,就把相遇位置的值和基准key的值进行交换
代码实现
// 快速排序
void QuickSort(int* arr, int n)
{
Partition(arr, 0, n - 1);
}
// 快排Hoare法
void Partition(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int keyIndex = begin;
int left = begin;
int right = end;
while (left < right)
{
// 从右往左找比基准小的元素(一定是严格小于)
while (left < right && arr[right] >= arr[keyIndex])
{
--right;
}
// 从左往右找严格大于基准的元素
while (left < right && arr[left] <= arr[keyIndex])
{
++left;
}
Swap(&arr[left], &arr[right]);
}
// 将基准和相遇点的值交换
Swap(&arr[keyIndex], &arr[left]);
// 递归左区间
Partition(arr, begin, left - 1);
// 递归右区间
Partition(arr, left + 1, end);
}
挖坑法
挖坑法其实和Hoare方法类似,它的思想是:
- 定义left和right记录区间开始和结束
- 每次拿key记录区间起始位置的值(left位置的值)叫做挖坑
- 然后right从右往左找一个比key小的元素放到坑位中,此时right产生了一个新的坑位
- 此时让left从左往右找比key大的值放到right位置的坑位
- 就这样不断挖坑填坑,直到left和right相遇就把记录的key值放到相遇的位置
- 接着递归相遇点的左右区间
代码实现
// 快速排序
void QuickSort(int* arr, int n)
{
QuickInternal(arr, 0, n - 1);
}
// 快排挖坑法
void QuickInternal(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
// 记录基准值(挖坑)
int key = arr[begin];
int left = begin;
int right = end;
while (left < right)
{
// 从右往左找比基准值到小的元素去填坑
while (left < right && arr[right] >= key)
{
--right;
}
// 填坑
arr[left] = arr[right];
// 左往右找比基准值大的元素(挖坑)
while (left < right && arr[left] <= key)
{
++left;
}
//填坑
arr[right] = arr[left];
}
// 把记录的基准值放到相遇点
arr[left] = key;
// 递归左区间
QuickInternal(arr, begin, left - 1);
// 递归右区间
QuickInternal(arr, left + 1, end);
}
前后指针法
前后指针法稍微和前面两有一点不同,它的基本思想是:
- 拿一个key记录待排序区间起始位置的值
- 再定义prev指向起始位置,cur指向起始位置的后一个位置
- 然后cur从前向后找比key小的值,找到小于key的值后prev往后走一步
- 然后把cur和prev位置的值进行交换
- 直到cur超出数组长度,然后把prev和key的值进行交换,就完成了一趟快速排序
- 此时prev左边的数要小于prev右边的数
- 然后递归左右区间,最后完成整个数组的排序
手绘一趟排序的gif图
代码实现
循环里的++prev != cur
是当prev和cur相遇是没有必要交换的
// 快排前后指针法
void QuickPtr(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int prev = begin;
int cur = prev + 1;
int keyIndex = prev;
while (cur <= end)
{
// 当找到了小于key的值
if (arr[cur] < arr[keyIndex] && ++prev != cur)
{
//小于key就让prev往后走一步再交换
++prev;
Swap(&arr[prev], &arr[cur]);
}
++cur;
}
// 交换prev和key的值
Swap(&arr[keyIndex], &arr[prev]);
// 递归左边区间
QuickPtr(arr, 0, prev - 1);
// 递归右边区间
QuickPtr(arr, prev + 1, end);
}
快排的优化
前面三种写法的快速排序无论是哪一种,好像排序的时间复杂度是 O ( n ∗ l o g 2 n ) O(n*log_{2}n) O(n∗log2n),当如果是极端情况数据已经是有序的化,那么它的时间复杂度将会上升到 O ( n 2 ) O(n^{2}) O(n2),递归的层次如果太深就可能会出现栈溢出的情况,所以要对其进行优化。优化代码基于挖坑法
三数取中
在选择基准key的时候,我们永远选择的是最左边也就是第一个元素,起始这并不理想,就比如数据已经有序的情况下就会让时间复杂度变成 O ( n 2 ) O(n^{2}) O(n2),所以key的取值是非常关键的,这就可以使用三数取中法,取3个数的中位数,如果key的取值越接近一组数据的中位数,那么快排的效率也就越高。
所以要在left、right和它们的中间下标mid中取一个中位数来做基准,将这个中位数和left的值进行交换。
这里来以挖坑法为例,优化代码:
// 三数取中
int GetMidIndex(int* arr, int left, int right)
{
int mid = (left + right) >> 1;
if (arr[mid] > arr[left])
{
if (arr[mid] > arr[right])
{
if (arr[left] > arr[right])
{
return left;
}
else
{
return right;
}
}
else
{
return mid;
}
}
else
{
if (arr[mid] > arr[right])
{
return mid;
}
else
{
if (arr[left] > arr[right])
{
return right;
}
else
{
return left;
}
}
}
}
// 快排挖坑法
void QuickInternal(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
// 三数取中
int mid = GetMidIndex(arr, begin, end);
int tmp = arr[mid];
arr[mid] = arr[begin];
arr[begin] = tmp;
// 记录基准值(挖坑)
int key = arr[begin];
int left = begin;
int right = end;
while (left < right)
{
// 从右往左找比基准值到小的元素去填坑
while (left < right && arr[right] >= key)
{
--right;
}
// 填坑
arr[left] = arr[right];
// 左往右找比基准值大的元素(挖坑)
while (left < right && arr[left] <= key)
{
++left;
}
//填坑
arr[right] = arr[left];
}
// 把记录的基准值放到相遇点
arr[left] = key;
// 递归左区间
QuickInternal(arr, 0, left - 1);
// 递归右区间
QuickInternal(arr, left + 1, end);
}
区间优化
快排在递归的过程中递归的次数每一层都是上一层的2倍数,如果当递归达到了一个比较深的层次,每继续往下一层都是一个比较大的增加递归次数,此时就可以做一个优化,就是在区间缩小到到一定大小时使用直接插入排序,当然也可以时堆排序,来减少递归次数。虽然优化在有些场景作用微乎其微,但在有些场景比如有千万数据的时候,这个区域的取值越大可能效果就越明显。
// 快排挖坑法
void QuickInternal(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
// 插入排序优化
if (end - begin > 30)
{
// 三数取中
int mid = GetMidIndex(arr, begin, end);
Swap(&arr[mid], &arr[begin]);
// 记录基准值(挖坑)
int key = arr[begin];
int left = begin;
int right = end;
while (left < right)
{
// 从右往左找比基准值到小的元素去填坑
while (left < right && arr[right] >= key)
{
--right;
}
// 填坑
arr[left] = arr[right];
// 左往右找比基准值大的元素(挖坑)
while (left < right && arr[left] <= key)
{
++left;
}
//填坑
arr[right] = arr[left];
}
// 把记录的基准值放到相遇点
arr[left] = key;
// 递归左区间
QuickInternal(arr, begin, left - 1);
// 递归右区间
QuickInternal(arr, left + 1, end);
}
else
{
// 调用直接插入排序
InsertSort(arr + begin, end-begin+1);
}
}
非递归实现快速排序
非递归的快排只能通过栈来模拟递归实现,而且C语言没有栈,所以可以使用自己实现的栈或者使用C++的STL。
这里我使用自己实现的栈来实现非递归挖坑版快排。
- 首先将左边界下标入栈
- 再将右边界入栈
- 然后每次拿出两个边界,进行挖坑填坑
- 一次快排完成后,判断左右区间是否还有元素,有的化就继续入栈
- 当栈为空时就完成了
// 非递归快排(挖坑法)
void NoRecursiveQuick(int* arr, int n)
{
Stack stack;
StackInit(&stack);
StackPush(&stack, 0);
StackPush(&stack, n-1);
while (!StackEmpty(&stack))
{
int end = StackTop(&stack);// 获取栈顶元素
StackPop(&stack);
int begin = StackTop(&stack);
StackPop(&stack);
int left = begin;
int right = end;
int key = arr[left];
while (left < right)
{
// 从右往左找数坑坑
while (left < right && arr[right] >= key)
{
--right;
}
arr[left] = arr[right];
// 从左往右找数填坑
while (left < right && arr[left] <= key)
{
++left;
}
arr[right] = arr[left];
}
// 把key值放到相遇点
arr[left] = key;
// 左区间
if (left - 1 > begin)
{
StackPush(&stack, begin);
StackPush(&stack, left - 1);
}
// 右区间
if (left + 1 < end)
{
StackPush(&stack, left+1);
StackPush(&stack, end);
}
}
}
性能分析
快排的递归的执行其实就是一颗二叉树,每一层也就是执行 n n n次,树的高度为 l o g 2 n log_{2}n log2n,空间复杂度也是同理。
- 时间复杂度
- O ( n l o g 2 n ) O(nlog_{2}n) O(nlog2n)
- O ( n 2 ) O(n^{2}) O(n2)(如果没有优化,数据已经有序的情况)
- 空间复杂度
- O ( l o g 2 n ) O(log_{2}n) O(log2n)
- 最坏情况 O ( n 2 ) O(n^{2}) O(n2)
- 稳定性
- 不稳定(跳跃式交换)
7. 归并排序
归并排序又是一类不同与前面的排序,归并的意思就是将两个或者两个以上的数组,将它们合并成一个新的有序数组,这也是分治算法的典型应用,将有序的子序列合并,得到完全有序的序列,先让每个子序列有序,再让子序列有序,在使子序列段间有序。若将两个有序表合成一个有序表,称为二路归并。
原理
递归把一个数组所有元素不断对半拆分,直到拆分成一个元素,然后在两两开始合并,直到所有元素最后合并成一个有序数组。
排序过程
递归代码实现
- 如果区间元素大于1就需要对半分开递归
- 递归完后开始返回,每次返回就是在合并有序数组
- 合并完后再把临时数组拷贝到原数组里
// 归并排序
void MergeSort(int* arr, int n)
{
int* newArr = (int*)(malloc(sizeof(int)*n));
Merge(arr, 0, n - 1,newArr);
free(newArr);
}
// 归并排序的分解合并
void Merge(int* arr, int left, int right, int* newArr)
{
if (left >= right)// 超过1个元素才分解
{
return;
}
int mid = (left + right) >> 1;
Merge(arr, left, mid, newArr);//递归左边
Merge(arr, mid + 1, right, newArr);//递归右边
int start1 = left;
int end1 = mid;
int start2 = mid + 1;
int end2 = right;
// 合并两个有序数组
int index = left;// 注意开始位置
while (start1 <= end1 && start2 <= end2)
{
if (arr[start1] <= arr[start2])
{
newArr[index] = arr[start1];
++index;
++start1;
}
else
{
newArr[index] = arr[start2];
++index;
++start2;
}
}
// 把剩下的元素放到数组中
while (start1 <= end1)
{
newArr[index] = arr[start1];
++index;
++start1;
}
while (start2 <= end2)
{
newArr[index] = arr[start2];
++index;
++start2;
}
// 把临时数组复制到原数组
while (left <= right)
{
arr[left] = newArr[left];
++left;
}
}
非递归代码实现
非递归的实现也是将元素先将单个元素合并成两个有序的元素,再将两个合并成4个,直到整体合并成一个有序数组。
- 以gap为间隔将元素分组,也可以把gap理解为每个待合并区间元素个数
- gap每次以2倍增长,因为每次都是两组合并,合并之后元素个数就会翻倍
- 需要注意就是后面区间的越界处理。
如果元素个数不是那么均匀
- 最后一组归并时,第二个区间不存在
- 最后一组归并时,第一个小区间不够gap个
- 对与这两种情况,我这里简单进行处理
int end2 = (start2 +gap-1) >= n ? (n-1) : (start2+gap-1);
- 第二个区间的末尾,如果越界就把它赋值成数组的最后一个元素
代码实现
// 归并排序非递归实现
void MergeSortNonR(int* arr, int n)
{
// 临时数组
int* tmp = (int*)(malloc(sizeof(int) * n));
int gap = 1; // 每个待合并的区间元素个数
while (gap < n)
{
int i = 0;
for (i = 0; i < n; i += 2*gap) // 多少组进行合并,每次跳过已经合并的两个区间
{
int start1 = i;
int end1 = i + gap - 1;
int start2 = i + gap;
// 需要考虑特殊情况下的越界
int end2 = (start2 +gap-1) >= n ? (n-1) : (start2+gap-1);
// 合并有序数组
int index = start1;
while (start1 <= end1 && start2 <= end2)
{
if (arr[start1] <= arr[start2])
{
tmp[index] = arr[start1];
++start1;
++index;
}
else
{
tmp[index] = arr[start2];
++start2;
++index;
}
}
// 将剩下的元素放到临时数组中
while (start1 <= end1)
{
tmp[index] = arr[start1];
++start1;
++index;
}
while (start2 <= end2)
{
tmp[index] = arr[start2];
++start2;
++index;
}
// 将临时数组的元素拷贝到原数组
index = i;
while (index <= end2)
{
arr[index] = tmp[index];
++index;
}
}
// 改变gap大小
gap *= 2;
}
free(tmp);
}
性能分析
归并排序使用递归进行分解,是一棵树型结构,而每一层都有 n n n个元素,归并排序还使用了额外的数组辅助。
- 时间复杂度
- O ( m = n l o g 2 n ) O(m=nlog_{2}{n}) O(m=nlog2n)
- 空间复杂度
- O ( n ) O(n) O(n)
- 稳定性
- 稳定的排序
- 没有发生跳跃式的交换,比较的是相邻的元素,所以是稳定的
8. 计数排序
原理
计数排序是一种非比较的排序,也就是它不需要拿一组数据中的元素进行比较排序,它是一种类似于哈希表的变形,记录一组数据中每个数据出现的次数,然后直接放到原数组中。
排序过程
假设有一组数据 5 , 4 , 4 , 3 , 8 , 7 , 8 , 6 , 3 , 1 {5,4,4,3,8,7,8,6,3,1} 5,4,4,3,8,7,8,6,3,1
代码实现
实现计数排序不能使用绝对映射,而要使用相对映射
-
绝对映射
比如有5个数据 900 , 901 , 902 , 903 , 904 , 905 {900,901,902,903,904,905} 900,901,902,903,904,905,如果采用绝对映射就是开一个大小为906的数组,来计数元素的出现次数,显然出现了大量的空间浪费
-
相对映射
而相对映射就不会出现这种情况,同样是数据 900 , 901 , 902 , 903 , 904 , 905 {900,901,902,903,904,905} 900,901,902,903,904,905,我们开的数据大小就是一组元素的最大值减去减去最小值再加上1( m a x − m i n + 1 max-min+1 max−min+1),也就是6。然后记录出现次数就是 a r r [ 元素 − m i n ] arr[元素-min] arr[元素−min],取元素就是 i + m i n i+min i+min就好了,这样大大减少了空间的浪费。
显然计数排序适合数据比较集中的情况。
// 计数排序
void CountSort(int* arr, int n)
{
// 记录最大最小值
int min = arr[0];
int max = arr[0];
// 统计出最大最小值
int i = 0;
for (i = 0; i < n; ++i)
{
if (arr[i] > max)
{
max = arr[i];
}
if (arr[i] < min)
{
min = arr[i];
}
}
// 计算元素范围
int range = max - min + 1;
// 开辟空间记录数字出现次数
int* countArr = (int*)(malloc(sizeof(int) * range));
memset(countArr, 0, sizeof(int) * range);
// 记录元素出现次数
for (i = 0; i < n; ++i)
{
++countArr[arr[i] - min];
}
// 将元素放进原数组
int index = 0;
for (i = 0; i < range; ++i)
{
int count = countArr[i];
while (count)
{
arr[index] = i + min;
--count;
++index;
}
}
free(countArr);
}
性能分析
- 时间复杂度
- O ( n + r a n g e ) O(n+range) O(n+range)
- 空间复杂度
- O ( r a n g e ) O(range) O(range)
- 稳定性
- 显然是稳定的排序
9. 基数排序
原理
基数排序也不是基于比较的排序,基数排序是一种借助多关键字排序的思想。假设要排序一组数字,它一次排序都是先比较每个数字的个位数进行比较排序,再拿十位比较排序、依次类推。
排序过程
代码实现
- 通过队列来先进先出的性质,来用十个队列当做桶
- 循环最大元素的位数次,依次按十位、百位…依次入队列
- 从0号队列开始出队列到数组
- 直到所有循环结束,元素就有序了。
// 获取位
int GetKey(int value, int k)
{
int key = 0;
while (k >= 0)
{
key = value % 10;
value /= 10;
--k;
}
return key;
}
// 按位数入桶
void Distribute(int* arr, int left, int right, int k,Queue* bucket)
{
int i = 0;
for (i = left; i < right; ++i)
{
int key = GetKey(arr[i], k);
QueuePush(&bucket[key], arr[i]);
}
}
// 把桶中的元素放回数组
void Collect(int* arr,Queue* bucket)
{
int k = 0;
int i = 0;
for (i = 0; i < 10; ++i)
{
while (!QueueEmpty(&bucket[i]))
{
arr[k] = QueueFront(&bucket[i]);
QueuePop(&bucket[i]);
++k;
}
}
}
// 基数排序
void RadixSort(int* arr, int n)
{
// 初始化队列
Queue bucket[10];
int i = 0;
for (i = 0; i < 10; ++i)
{
Queue q;
QueueInit(&q);
bucket[i] = q;
}
// 计算最大位数
int maxCount = 0;
for (i = 0; i < n; ++i)
{
int count = 0;
int tmp = arr[i];
while (tmp)
{
++count;
tmp /= 10;
}
maxCount = maxCount >= count ? maxCount : count;
}
// 循环最大位数次
for (i = 0; i < maxCount; ++i)
{
Distribute(arr, 0, n,i,bucket);
Collect(arr, bucket);
}
}
性能分析
这里计算位数是 O ( n ) O(n) O(n),多次基数排序 m a x C o u n t ∗ n maxCount*n maxCount∗n,这里使用了10个队列,额外的空间
- 时间复杂度
- O ( n ∗ m a x C o u n t ∗ n ) O(n*maxCount*n) O(n∗maxCount∗n)
- 空间复杂度
- O ( n ) O(n) O(n)
- 稳定性
- 显然是一个稳定的排序
从0号队列开始出队列到数组
- 直到所有循环结束,元素就有序了。
// 获取位
int GetKey(int value, int k)
{
int key = 0;
while (k >= 0)
{
key = value % 10;
value /= 10;
--k;
}
return key;
}
// 按位数入桶
void Distribute(int* arr, int left, int right, int k,Queue* bucket)
{
int i = 0;
for (i = left; i < right; ++i)
{
int key = GetKey(arr[i], k);
QueuePush(&bucket[key], arr[i]);
}
}
// 把桶中的元素放回数组
void Collect(int* arr,Queue* bucket)
{
int k = 0;
int i = 0;
for (i = 0; i < 10; ++i)
{
while (!QueueEmpty(&bucket[i]))
{
arr[k] = QueueFront(&bucket[i]);
QueuePop(&bucket[i]);
++k;
}
}
}
// 基数排序
void RadixSort(int* arr, int n)
{
// 初始化队列
Queue bucket[10];
int i = 0;
for (i = 0; i < 10; ++i)
{
Queue q;
QueueInit(&q);
bucket[i] = q;
}
// 计算最大位数
int maxCount = 0;
for (i = 0; i < n; ++i)
{
int count = 0;
int tmp = arr[i];
while (tmp)
{
++count;
tmp /= 10;
}
maxCount = maxCount >= count ? maxCount : count;
}
// 循环最大位数次
for (i = 0; i < maxCount; ++i)
{
Distribute(arr, 0, n,i,bucket);
Collect(arr, bucket);
}
}
性能分析
这里计算位数是 O ( n ) O(n) O(n),多次基数排序 m a x C o u n t ∗ n maxCount*n maxCount∗n,这里使用了10个队列,额外的空间
- 时间复杂度
- O ( m a x C o u n t ∗ n ) O(maxCount*n) O(maxCount∗n)
- 空间复杂度
- O ( n ) O(n) O(n)
- 稳定性
- 显然是一个稳定的排序