排序【数据结构】【算法】
排序的相关概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序
常见排序算法的实现
直接插入排序
直接插入排序是一种简单的插入排序法,其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
具体算法描述如下:
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2~5。
代码:
// 插入排序(升序)
void InsertSort(int* a, int n)
{
// i迭代n - 1结束,到n结束会越界,因为需要访问未排序的数据(i+1 == n+1),就会越界
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;
}
}
// 插入数据到end+1的下标位置,end < tmp
a[end + 1] = tmp;
}
}
直接插入排序的特性总结:
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1),它是一种稳定的排序算法
4. 稳定性:稳定
希尔排序( 缩小增量排序 )
希尔排序法的基本思想是:
先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
- 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
- 按增量序列个数k,对序列进行k 趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
void ShellSort(int* a, int n)
{
// gap > 1 预排序
// gap == 1 直接插入排序
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[i + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算。本章的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:O() 到O() 来算。
4. 稳定性:不稳定
选择排序
基本思想是:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的 数据元素排完 。
直接选择排序
直接选择排序具体描述:
n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:
- 初始状态:无序区为R[1..n],有序区为空;
- 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1..i-1]和R(i..n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第1个记录R交换,使R[1..i]和R[i+1..n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
- n-1趟结束,数组有序化了。
void SelectSort(int* a, int n)
{
int left = 0, right = n - 1;
while (left < right)
{
int mini = left, maxi = left;
for (int i = left + 1; i <= right; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[left], &a[mini]);
// 如果left和maxi重叠,交换后修正一下
if (left == maxi)
{
maxi = mini;
}
Swap(&a[right], &a[maxi]);
++left;
--right;
}
}
直接选择排序的特性总结:
1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
堆排序
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
需要注意的是排升序要建大堆,排降序建小堆。
堆排序具体描述:
- 建堆,排升序:建大堆,排降序:建小堆
- 通过向下调整,从倒数第一个非叶子节点开始调整
- 堆结构建立完,就排序
- 堆顶的数据和最后一个数据交换,排好的数据排除掉(缩小区间,end–),在重新向下调整把次大的数据放到堆顶,直到堆顶结束(end>0)
// 交换
void Swap(int* pa, int* pb)
{
int tmp = *pa;
*pa = *pb;
*pb = tmp;
}
// 向下调整
// 大堆
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
// 找左右孩子最大的
// 防止越界:右孩子可能不存在
if (child + 1 < n && a[child] < a[child + 1])
{
child++;
}
// 孩子比父亲大就交换
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
// 堆结构正常
break;
}
}
}
// 堆排序
void HeapSort(int* a, int n)
{
// 建大堆,从最后非叶子结点开始到第一个结点
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
// 排序,选出最大的交换到最后,在缩小区间(排除已排好的数据)
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]); // 交换
AdjustDown(a, end, 0); // 找出次大的,放到堆顶
end--; // 已经排好的数据,给排除
}
}
堆排序的特性总结:
1. 堆排序使用堆来选数,效率就高了很多。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(1)
4. 稳定性:不稳定
冒泡排序
冒泡排序是一种简单的排序算法。
基本思想:它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。
冒泡排序具体描述:
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 重复步骤1~3,直到排序完成。
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
// 标识是否交换,默认是没交换
int exchange = 1;
for (int j = 0; j < n - 1 - i; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
exchange = 0; // 数据交换了
}
}
// 数据没有交换,表示有序,就直接跳出
if (exchange)
break;
}
}
冒泡排序的特性总结:
1. 冒泡排序是一种非常容易理解的排序
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:稳定
快速排序
基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
Hoare版(左右指针法)
具体描述:
1.记录key位置,一般最左边或最右边以此作为分区条件,左右两个指针从两端开始往中间走
2.right先走,找到比key小的值停下,left后走,找到比key大的值停下。
3.left停止后,说明left的值比key大和right的值比key小,两个数据交换,大的在右边,小的就在左边
4.left和right相遇时,需要将key和相遇位置的交换,目的达成,key比左边的值要大,比右边的值要小
5.重复以上操作,直到最后左右区间不存在或者只有1个数
// hoare(左右指针法)
void QuickSort(int* a, int begin, int end)
{
// 结束条件:区间不存在或只有一个数,该情况是有序的
if (begin >= end)
return;
int left = begin, right = end;
int keyi = left;
// 相遇就结束
while (left < right)
{
// 右区间找比key小的数
while (left < right && a[right] >= a[keyi])
{
right--;
}
// 左区间找比key大的数
while (left < right && a[left] <= a[keyi])
{
left++;
}
// 交换
Swap(&a[left], &a[right]);
}
// 此时begin和end在同一位置,需要将其中key和其中一个交换
Swap(&a[left], &a[keyi]);
keyi = left; // 更新keyi的位置
// [begin, keyi-1] keyi [keyi+1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
挖坑法
具体实现:
1.记录key,key一般都是最左边,目的就是为了把key排到正确的位置,同时将key设为坑位
2.开始坑在左边,右边开始找值补坑,right找到比key小的值,放入坑位,right形成新的坑
3.现在坑在右边,左边开始找值补坑,left找到比key大的值,放入坑位,left形成新的坑
4.直到left和right相遇就结束,最后把key放到坑位,key就排到正确的位置
5.以排好的key(下标为pivot)开始左右分区,[begin, pivot - 1] pivot [pivot + 1, end],进行分治递归
void QuickSort_pivot(int* a, int begin, int end)
{
if (begin >= end)
return;
int left = begin, right = end;
int key = a[left];
int pivot = left;
while (left < right)
{
// 找小于key的数
while (left < right && a[right] >= key)
{
right--;
}
// 找到小的数放到左边的坑里,自己形成新的坑位
a[pivot] = a[right];
pivot = right;
// 找大于key的数
while (left < right && a[left] <= key)
{
left++;
}
// 找到大的数放到右边的坑里,自己形成新的坑位
a[pivot] = a[left];
pivot = left;
}
// 最后pivot会指向相遇点,相遇点就是key排好的位置
a[pivot] = key;
// [begin, pivot-1] pivot [pivot+1, end]
QuickSort_pivot(a, begin, pivot - 1);
QuickSort_pivot(a, pivot + 1, end);
}
前后指针法
具体实现:
1.记录key,key一般都是最左边。
2.prev记录begin的位置,cur记录begin+1的位置,cur一直递增的 ,如果cur处的值小于key的值,prev++,然后prev处的值和cur处的值交换,直到cur越界就停止
3.cur越界后,说明prev的位置就是key排好的位置,所以key和cur处的值交换后,key就排到正确的位置,开始keyi在最左边,需要更新keyi的位置
4.以排好的key开始左右分区,[begin, keyi - 1] keyi [keyi + 1, end],进行分治递归
void QuickSort_pointer(int* a, int begin, int end)
{
if (begin >= end)
return;
int keyi = begin;
int prev = begin, cur = begin + 1;
while (cur <= end)
{
// cur一直递增,碰到小于key的值并且prev++不等于自己,就交换
// prev++ == cur,会原地交换
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
// key交换到排好的位置,同时更新keyi的位置,原先在最左边
Swap(&a[keyi], &a[prev]);
keyi = prev;
// [begin, keyi-1] keyi [keyi+1, end],进行分治
QuickSort_pointer(a, begin, keyi - 1);
QuickSort_pointer(a, keyi + 1, end);
}
三数取中优化
该优化是针对快排的最坏情况(有序情况),确保在每次在分区里选key的时候,能保证在分区内不会取最或最大的数。
// 三数取中
int GetMidIndex(int* a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
return mid;
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[right] < a[left])
return right;
else
return left;
}
}
非递归版
具体实现:
1.先入右下标,在左下标,因为可以先取到左下标
2.取左右下标组成一个区间,单趟排好一个值(key),在利用key分成左区间[left,key-1],右区间[key+1,right]。
3.检查左右区间数据个数,如果区间个数有二个以上,就需要在入栈排序
4.重复2,3步,直到栈为空,数据就都排好了。
// 快排单趟前后指针法
int SingleQuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
int keyi = begin;
int prev = begin, cur = begin + 1;
while (cur <= end)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
return keyi;
}
void TestQuickSortNonR(int* a, int n)
{
Stack st;
StackInit(&st);
// 先入右,再入左,为了先取左
StackPush(&st, n - 1);
StackPush(&st, 0);
// 栈为空,排序就完成
while (!StackEmpty(&st))
{
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
// 快排单趟,排好一个值key,分割左右区间
int keyi = SingleQuickSort(a, left, right);
// [left, keyi - 1] keyi [ keyi + 1, right]
// key+1小于right,说明至少右区间还有两个数据,还需要入栈排序
if (keyi + 1 < right)
{
//先入右,再入左
StackPush(&st, right);
StackPush(&st, keyi + 1);
}
// left小于keyi - 1,说明至少左区间还有两个数据,还需要入栈排序
if (left < keyi - 1)
{
//先入右,再入左,组成左区间
StackPush(&st, keyi - 1);
StackPush(&st, left);
}
}
StackDestroy(&st);
}
归并排序
基本思想: 归并排序是建立在分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
具体实现:
1.将待排序记录 r[0] 到 r[n-1] 看成是n个长度为1的有序子表,把这些子表依次两两归并,便得到 [n/2] 个有序的子表
2.再把这 [n/2] 个有序的子表两两归并,如此重复,直到最后得到一个长度为 n 的有序表为止。
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
// [begin, mid] [mid+1,end],子区间递归排序
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
// [begin, mid] [mid+1,end]归并
// ...
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\n");
return;
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
非递归版
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(n * sizeof(int));
assert(tmp);
int gap = 1; // 每组的数据个数
// gap < n,就说明有两组数据才能归并,>=就说明只有一组数据
while (gap < n)
{
for (int i = 0; i < n; i += gap * 2)
{
// [i, i + gap - 1] [i + gap, i + gap * 2 - 1]
// 如:[0, 0] 和 [1, 1]......
// 标识需归并两组数据的范围
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + gap * 2 - 1;
// 右区间不存在
if (begin2 >= n)
break;
// 右区间超过了数据长度,修正成数据的长度
if (end2 >= n)
end2 = n - 1;
// 归并
int index = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[index++] = a[begin1++];
}
else
{
tmp[index++] = a[begin2++];
}
}
// 剩余未归并完的数据全部放入tmp里
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
// 拷贝数据,归并了多少,就拷多少
for (int j = i; j <= end2; j++)
{
a[j] = tmp[j];
}
}
gap *= 2; // 下一趟归并的一组数据个数翻倍
}
}
归并排序的特性总结:
1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定
计数排序
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。
操作步骤: 1. 统计相同元素出现次数 2. 根据统计的结果将序列回收到原来的序列中
具体实现
1.找出待排序的数组中最大和最小的元素;
2.统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
3.对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
4.反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
// 计数排序
void CountSort(int* a, int n)
{
// 找到最小值和最大值
int min = a[0], 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;
// 创建计数数组,初始化为全0
// 方法1
int* count = (int*)malloc(range * sizeof(int));
assert(count);
memset(count, 0, range * sizeof(int));
// 方法2,calloc申请的空间默认都是NULL或0(根据指针类型决定)
//int* count = (int*)calloc(range * sizeof(int));
// 统计每个数出现的次数
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
// 把数组写入原数组
// 数据出现了多少次就写入多少个值
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
a[j++] = i + min;
}
}
}
计数排序的特性总结:
1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2. 时间复杂度:O(N+k)
3. 空间复杂度:O(N+k)
4. 稳定性:稳定
基数排序
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。
具体描述:
1.取得数组中的最大数,并取得位数;
2.arr为原始数组,从最低位开始取每个位组成radix数组;
3.对radix进行计数排序(利用计数排序适用于小范围数的特点);
void radixSort(int arr[], int n)
{
int maxVal = arr[0];
// 找出数组中的最大值
for (int i = 1; i < n; i++)
{
if (arr[i] > maxVal)
{
maxVal = arr[i];
}
}
int maxDigit = 0;
// 计算最大值的位数
while (maxVal > 0)
{
maxVal /= 10;
maxDigit++;
}
// 定义一个指针数组,每个指针指向一个整数数组,用于存储不同位数上的数字
int* buckets[10];
for (int i = 0; i < 10; i++)
{
// 为每个桶分配内存空间,每个桶可以存储 n 个整数
buckets[i] = (int*)malloc(n * sizeof(int));
}
// 初始化每个桶的大小为 0
int bucketSizes[10] = {0};
// 对每一位进行排序
for (int digit = 1; digit <= maxDigit; digit++)
{
for (int i = 0; i < n; i++)
{
// 获取当前数字在当前位数上的值
int currentDigit = getDigit(arr[i], digit);
// 将数字放入对应的桶中,并增加桶的大小
buckets[currentDigit][bucketSizes[currentDigit]++] = arr[i];
}
int index = 0;
// 将桶中的数字依次取出,放回原数组
for (int i = 0; i < 10; i++)
{
for (int j = 0; j < bucketSizes[i]; j++)
{
arr[index++] = buckets[i][j];
}
// 重置桶的大小为 0,为下一次排序做准备
bucketSizes[i] = 0;
}
}
// 释放每个桶占用的内存空间
for (int i = 0; i < 10; i++)
{
free(buckets[i]);
}
}
基数排序的特性总结:
1. 基数排序也存在一定的局限性。它需要额外的空间来存储桶或计数数组,这在处理非常大的数据集时可能会导致内存占用过高的问题。此外,基数排序对于位数较多的整数可能需要进行多次遍历和排序操作,这也会增加计算时间。
2. 时间复杂度:O(N*k)
3. 空间复杂度:O(N+k)
4. 稳定性:稳定
桶排序
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。
桶排序的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。
struct node
{
int data;
struct node *next;
};
// 对每个链表(桶)进行插入排序
void insert_node(struct node **bucket, int data)
{
struct node *p = (struct node *)malloc(sizeof(struct node));
p->data = data;
p->next = NULL;
// 桶为空
if(*bucket == NULL)
{
*bucket = p;
}else
{
struct node *pre = NULL;
struct node *cur = *bucket;
while(cur != NULL && cur->data <= data)
{
pre = cur;
cur = cur->next;
}
// 对插入到第一个结点前的情况处理
if(pre == NULL)
{
*bucket = p;
p->next = cur;
}else
{
pre->next = p;
p->next = cur;
}
}
}
// k表示数据位数,3为表示取值范围[000-999]
void bucket_sort(int a[], int length, int k)
{
// 申请桶空间
struct node **b = (struct node **)calloc(10,sizeof(struct node *));
int i,j,m;
// 将待排数据记录分配到桶
for(i=0; i<length; i++)
{
// 获取对应10个桶的标识 0-9
m = a[i];
for(j=k; j>1; j--)
m = m/10;
// 分配到桶链表中
insert_node(&b[m],a[i]);
}
// 方便返回结果,复制到原数组a中
// 复制到数组a中
struct node *p;
for(i=0,j=0; i<10 && j<length; i++)
{
if(b[i] != NULL)
{
p = b[i];
// 遍历每个桶元素
while(p != NULL)
{
a[j] = p->data;
j++;
p = p->next;
}
}
}
// 释放存储空间
for(i=0; i<10; i++)
{
while(b[i] !=NULL)
{
p = b[i];
b[i] = p->next;
free(p);
}
}
free(b);
}
桶排序的特性:
1.桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大
2. 时间复杂度:O(N)
3. 空间复杂度:O(N^2)
4. 稳定性:稳定