如何判断一个排序算法是否稳定?
两个相等的数据,如果经过排序后,排序算法能保证其相对位置不发生变化,则称该算法是具备稳定性的排序算法。
一个排序是否发生跳跃式的交换也是判断是否稳定的一个技巧。
直接插入排序是一种最简单的排序方法,它的基本操作是将一个记录插入到已排好的有序列表中,从而得到一个新的有序列表。
把待排序的区间分为
每次选择无序区间的第一个元素,从有序区间的后面向前比较,在有序区间内选择符合要求的位置将元素插入。
直接插入排序的基本思想就是默认下标为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
// 直接插入排序
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 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
这里的代码是通过一次就把所有元素排序完成。
// 希尔排序
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)
空间复杂度
稳定性
每次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的元素排序完。
每次从无序区间中选取一个最大(或最小)的一个元素,存放在无序区间的最后(或最前),直到全部待排序的元素排序完。
直接选择排序比较简单,就是从待排序区间找最大或者最小的数放到排序完成的区间,每一次都排序都能确定一个排序好的元素。
每一次遍历待排序区间都记录最小元素的下标然后和排序完成的区间的后一个位置的元素进行交换。
// 选择排序
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),如果要用选择排序,数据规模越小是越好的。
堆排序是利用数据结构堆的特性来进行排序,它也是一种选择排序,它通过堆来选取数据。排升序建大堆,排降序建小堆。
堆的详细介绍可以看这一篇文章数据结构堆的详解
每次将堆顶元素和最后一个元素进行交换,再进行向下调整,然后缩小待排序区间,直到数据有序,因为堆顶的元素一定是一组数据中的最大或者最小值。
注意:向下调整的前提是,这个根节点的左右子树一定要是一个堆(大堆或小堆)
// 向下调整(建大堆)
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)
冒泡排序也是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。
每一趟冒泡排序都能确认一个元素最终的位置,这是一趟冒泡排序的过程
当遍历了一遍待排序区间没有发生交换时,说明数组已经有序无需再排序了。
// 冒泡排序
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;
}
}
}
快速排序的基本思想是,通过选取一个关键元素key为一趟排序,将待排序元素分割成独立的两个部分,其中一部分记录的元素大小要比另外一部分的元素小。
Hoare法是快速排序的一种实现方法,它的实现思路为
代码实现
// 快速排序
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方法类似,它的思想是:
代码实现
// 快速排序
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);
}
前后指针法稍微和前面两有一点不同,它的基本思想是:
手绘一趟排序的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,空间复杂度也是同理。
归并排序又是一类不同与前面的排序,归并的意思就是将两个或者两个以上的数组,将它们合并成一个新的有序数组,这也是分治算法的典型应用,将有序的子序列合并,得到完全有序的序列,先让每个子序列有序,再让子序列有序,在使子序列段间有序。若将两个有序表合成一个有序表,称为二路归并。
递归把一个数组所有元素不断对半拆分,直到拆分成一个元素,然后在两两开始合并,直到所有元素最后合并成一个有序数组。
// 归并排序
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个,直到整体合并成一个有序数组。
如果元素个数不是那么均匀
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);
// 如果右半部分已经没有元素就无需合并
if (start2 >= n) {
break;
}
// 合并有序数组
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;
}
}
归并排序使用递归进行分解,是一棵树型结构,而每一层都有 n n n个元素,归并排序还使用了额外的数组辅助。
计数排序是一种非比较的排序,也就是它不需要拿一组数据中的元素进行比较排序,它是一种类似于哈希表的变形,记录一组数据中每个数据出现的次数,然后直接放到原数组中。
假设有一组数据 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);
}
基数排序也不是基于比较的排序,基数排序是一种借助多关键字排序的思想。假设要排序一组数字,它一次排序都是先比较每个数字的个位数进行比较排序,再拿十位比较排序、依次类推。
// 获取位
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个队列,额外的空间