1.排序的概念及其运用
排序:
所谓排序,就是使一串记录,按照其中的某个或某些关键字的小,递增或递减的排列起来的操作。
稳定性:
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:
数据元素全部放在内存中的排序。
外部排序:
数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序
2.常见排序算法的实现
插入排序
1、从图中观察的现象是如果后一个数不比前一个数小,那就不需要插入,不插入的动作就是break出循环 2、如果前面的数都比pos值大,那么就将前n个数都往后挪动,直到比pos值小或者相等就停止,可以用循环控制,这里防止越界需要再加判断
插入排序的基本思想:其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
代码
void InsertSort(int* arr, int n)
{
int i = 0;
while (i < n - 1)
{
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;
i++;
}
}
直接插入排序的特性总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高,反之越低
- 时间复杂度:O(N^2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
希尔排序
希尔排序法又称缩小增量法。
希尔排序法的基本思想是: 先选定一个整数,把待排序数组中所有元素分成个组,所有距离为gap的元素分在同一组内,并对每一组内的元素进行排序。然后,重复上面分组和排序的工作。当到达=1时,所有元素在统一组内排好序。 从图中观察到的现象: 1、gap越大越不接近有序,但是挪动的更快 2、gap越小越接近有序,挪动的越慢 3、gap为1时,已经很接近有序了,直接插入排序,gap不为1时就是预排序的过程,让数组接近于有序,接近有序后直接插入排序的效率会更高
代码
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;
}
}
}
时间复杂度分析: 最坏的情况,逆序,gap很大的时候–》 O(N),当gap很小时本来应该是O(N * N),但是经过前面的预排序,数组已经已经很接近有序的,所以间隔为gap的插入排序可以理解为很接近O(N),而再看外循环影响循环次数的语句,gap = (gap / 3 + 1);, 当 gap / 3 / 3 / 3 … == 1,展开之后是 3 ^ x = gap,所以外层while循环执行的次数就是x次, 那么算法的整体时间复杂度就是O(log 3 (N) * N), log 3 (N),以3为底N的对数
选择排序
选择排序的基本思想: 在每次遍历数组的过程中一次循环选两个下标找出最大和最小值的下,将大的交换到右边,小的交换到左边
void selectSort(int* arr, int n)
{
int left = 0;
int right = n - 1;
while (left < right)
{
int MaxIndex = left, MinIndex = left;
for (int i = left; i <= right; i++)
{
if (arr[MaxIndex] < arr[i])
MaxIndex = i;
else
MinIndex = i;
}
Swap(&arr[left],&arr[MinIndex]);
if (MaxIndex == left)
{
MaxIndex = MinIndex;
}
Swap(&arr[right], &arr[MaxIndex]);
left++;
right--;
}
}
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
堆排
博主之前已经在堆实现这一章讲解过了,如果需要细致学习的请点击链接,这里就不再叙说了,需要注意的是排升序要建大堆,排降序建小堆
堆排教程.
代码:
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++;
}
else if (arr[child] > arr[parent])
{
Swap(&arr[child],&arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* arr, int n)
{
for(int i = (n - 1 - 1) / 2 ; i >= 0; i--)
{
AdjustDown(arr, n, i);
}
int end = n - 1;
while (end >= 0)
{
Swap(&arr[end--],&arr[0]);
AdjustDown(arr,end,0);
}
}
冒泡排序
冒泡排序的基本思想: 就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。 两两相比较,将小的交换到前面大的交换到后面,排序n个元素只需要比较n - 1趟,每冒泡一趟少比较一个元素
void bubblesort(int* arr, int n)
{
int end = 0;
for (end = n; end > 0; end--)
{
int flag = 0;
int j = 0;
for (j = 1; j < end; j++)
{
if (arr[j - 1] > arr[j])
{
Swap(&arr[j - 1] ,&arr[j]);
flag = 1;
}
else
{
break;
}
}
if (!flag)
break;
}
}
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
快排
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
hoare版本
过程: 1、单趟排序选出key,通常情况下key的位置都是选择在数组下标为0的位置,最左边,最右边都可以 2、将小的值交换到左边,将大的值交换到右边,最终把key放到正确的位置,保证左边的值要比key小,右边的值要比key大
左右指针法: 左边的哨兵找比key大的值,右边的哨兵找比key小的值 观察到的现象是一次单趟排序后,key的左边值都比key要小,key右边的值都要比key大,这样就已经达成了初步有序的目的了
if (begin >= end)
{
return;
}
int left = begin, right = end;
int key = left;
while (left < right)
{
while (left < right && arr[right] >= arr[key])
{
right--;
}
while (left < right && arr[left] <= arr[key])
{
left++;
}
Swap(&arr[left], &arr[right]);
}
int meet = left;
Swap(&arr[left], &arr[key]);
QuickSort(arr,begin, meet - 1);
QuickSort(arr, meet + 1, end);
挖坑法
基本思想: 将第一个位置选做key,这样就形成了天然的坑位,右边去找比key小的值,找到后将值填充到坑位中,自己成为新坑,左去找大,找到后将值放到右边的坑中,自己成为新的坑,反复直到相遇,相遇点也是一个坑位,将key的值放到坑中,这样单趟排就已经确定key值的位置了
初步单趟排就已经确定了,并且key也放到它正确的位置了,key的左边比他小,右边比他大
void QuickSort(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int left = begin, 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];
}
arr[left] = key;
int meet = left;
QuickSort(arr, begin,meet - 1);
QuickSort(arr, meet + 1, end);
}
前后指针法
基本思想: 双指针,定义prev和cur开始一前一后,cur去找比key小的值,找到后++prev,再交换cur和prev它们的值,直到数组遍历完,最后一下交换key位置的值和prev位置的值,这样就确定了key值的位置
void __QuickSort(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int prev = begin - 1;
int cur = begin;
int key = begin;
while (cur <= end)
{
while (arr[cur] < arr[key] && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(&arr[prev],&arr[key]);
_QuickSort(arr, begin, prev - 1);
_QuickSort(arr, prev + 1, end);
}
快排时间复杂度分析:
先考虑理想情况: 从图中我们可以看出快排的单趟排不管是右遇左,还是左遇右,合计起来也只能走数组长度N次,每选一个key值划分左右区间,单趟排确定key值的位置,不断递归划分左右区间,递归的深度是以2^N递增的,所以它的时间复杂度是O(N * log N)
考虑最坏情况: 如果数组已经是有序的了,那么不管是右遇左,还是左遇右每次都得走n-1步才能选出key的位置,那么它的执行次数就是一个等差数列了,所以时间复杂度就是O(N^2),怎么优化呢?
快排的优化:
针对快排得优化: 思考:对快排影响最大的是选的key,如果key越接近中位数越接近二分,效率越高
1、三数取中
找出这个区间中的中位数,使每次选key都为中位数,那么不用考虑有序这种恶劣的情况出现了
int GetMidIndex(int* arr, int left, int right)
{
int mid = (left + right) >> 1;
if (arr[left] < arr[mid])
{
if (arr[mid] < arr[right])
{
return mid;
}
else if (arr[left] > arr[right])
{
return left;
}
else
{
return right;
}
}
else
{
if (arr[mid] > arr[right])
{
return mid;
}
else if (arr[left] < arr[right])
{
return left;
}
else
{
return right;
}
}
}
2、小区间优化
当每一个区间递归下去的时候只剩20个数了(官方参考),就可以考虑不再递归换用插入排序,接近于有序插入排序的效果会更好一些,而且递归 也是有消耗的,能节省就节省一些
if (end - begin > 10)
{
QuickSort(arr, begin, meet - 1);
QuickSort(arr, meet + 1, end);
}
else
{
InsertSort(arr + begin, end - begin + 1);
}
完整的代码:
void QuickSort(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int MidIndex = GetMidIndex(arr, begin, end);
int left = begin, right = end;
Swap(&arr[MidIndex], &arr[left]);
int key = left;
while (left < right)
{
while (left < right && arr[right] >= arr[key])
{
right--;
}
while (left < right && arr[left] <= arr[key])
{
left++;
}
Swap(&arr[left], &arr[right]);
}
int meet = left;
Swap(&arr[left], &arr[key]);
if (end - begin > 20)
{
QuickSort(arr, begin, meet - 1);
QuickSort(arr, meet + 1, end);
}
else
{
InsertSort(arr + begin, end - begin + 1);
}
}
快排非递归实现
为什么会有非递归的版本呢,有些场景递归解决不了的问题于是就需要非递归登场了 非递归实现思想:由于C语言库并没有栈,所以需要自己动手实现一个栈,如果需要相关代码的可以点击这个链接: 栈实现.要想实现非递归并不难,只需要理解好快排的递归原理,快排的递归思想是,对一段区间单趟排,选key,确定好key的位置,再对key的左区间递归递归单趟排,key的右区间递归单趟排,不断地分裂,确定key的位置,只剩一个值了,这样一来数组就有序了,读者有没有发现,其中描述的两个步骤无非就是选key和对一段区间进行单趟排序,直到只剩一个值了,就可以是有序的,所以只需要用栈模拟递归的过程,将一段区间用栈保存起来,取区间出来单趟排,再选key的位置,不断划分左右区间,最终只剩一个值了,数组就是有序的了
int parsort(int* arr, int begin, int end)
{
int MidIndex = GetMidIndex(arr, begin, end);
int left = begin, right = end;
Swap(&arr[MidIndex], &arr[left]);
int key = left;
while (left < right)
{
while (left < right && arr[right] >= arr[key])
{
right--;
}
while (left < right && arr[left] <= arr[key])
{
left++;
}
Swap(&arr[left], &arr[right]);
}
int meet = left;
Swap(&arr[left], &arr[key]);
return meet;
}
void QuickSortNonR(int* arr, int begin, int end)
{
Stack st;
StackInit(&st);
StackPushBack(&st, begin);
StackPushBack(&st, end);
while (!StackEmpty(&st))
{
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
int keyi = parsort(arr, left, right);
if (left < keyi - 1)
{
StackPushBack(&st, left);
StackPushBack(&st, keyi - 1);
}
if (keyi + 1 < right)
{
StackPushBack(&st, keyi + 1);
StackPushBack(&st, right);
}
}
}
快排特性总结:
- 时间复杂度:O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
归并排序
基本思想: 归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤: 选出中间的位置,去递归左区间和右区间当它们只剩一个值的时候就不需要再递归,将左右区间中较小的值拷贝到临时数组,再将临时数组的值拷贝会原数组,这样子区间就有序了。 简单点来说就是想让整个区间有序就必须不断地拆分子区间,让子区间先有序了,再来归并子区间让整个区间就有序了。下图用不同的颜色标识每一段区间,当小区间归并后又会替换成另一种新的颜色
归并和快排的区别:同属于O(N * log N)时间复杂度的算法,但是归并的空间复杂度却是O(N),因为需要开辟一个额外的数组用来归并使其有序,当然快排是选key再分治递归左右区间,可见他是一种前序遍历思想,根–>左–>右,而归并不同于快排的是他是不断地拆分左右区间,使左右区间只剩一个值了,看成有序,再来对左右区间归并,让这段子区间有序,左–>右–>根所以归并是一种后序思想。
实现代码:
void MergerSort(int* arr, int begin, int end,int *tmp)
{
if (begin >= end)
{
return;
}
int mid = (begin + end) >> 1;
MergerSort(arr,begin, mid,tmp);
MergerSort(arr, mid + 1, end, tmp);
int begin1 = begin, begin2 = mid + 1;
int i = begin;
while (begin1 <= mid && begin2 <= end)
tmp[i++] = arr[begin1] < arr[begin2] ? arr[begin1++] : arr[begin2++];
while (begin1 <= mid)
tmp[i++] = arr[begin1++];
while (begin2 <= end)
tmp[i++] = arr[begin2++];
int dest = i;
for (i = 0; i < dest; i++)
{
arr[i] = tmp[i];
}
}
归并复杂度分析
可以从图中看到的对N个元素会被分解出N次,每一层都有N个元素,递归的深度是呈logN增长的,所以归并的时间复杂度是O(N * log N),但是归并唯一遗憾的是它的空间复杂度却是O(N),因为他要开辟一个额外的临时数组。
归并的非递归
再使用递归的时候由于是后序遍历思想,所以需要将左右区间分解成一个值的时候才开始往回退归并,而现在换用循环就不需要考虑这个区间是不是只剩一个值了,可以通过调整gap来控制排序过程,gap表示的是区间中有几个元素个数,当然这里画的是满了,后面还是一些其他情况也会一 一讲解
控制gap的大小,对这两段区间进行归并,i从0开始 左区间【i ,i + gap - 1】,右区间【i + gap,i + 2 * gap - 1】 考虑恶劣的情况 1、第二个区间并不存在 当gap == 1的时候,但是右区间如果没有的话直接不归跳出循环防止访问越界 如果右区间并不存在 右区间存在但不够gap个,结束位置可能存在越界,需要修正 左区间不够gap个
总结: 1、最后一个小组归并时,第二个小区间不存在,不需要再归并 2、最后一个小组归并时,第二个小区间存在,第二个区间不够gap个 3、最后一个小组归并时,第一个小区间不够gap个,不需要归并。
void _Merger(int* arr, int begin1, int end1,int begin2, int end2, int* tmp)
{
int i = begin1;
while (begin1 <= end1 && begin2 <= end2)
tmp[i++] = arr[begin1] < arr[begin2] ? arr[begin1++] : arr[begin2++];
while (begin1 <= end1)
tmp[i++] = arr[begin1++];
while (begin2 <= end2)
tmp[i++] = arr[begin2++];
int dest = i;
for (i = 0; i < dest; i++)
{
arr[i] = tmp[i];
}
}
void MergerSortNonR(int* arr, int *tmp,int n)
{
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1,
begin2 = i + gap, end2 = i + 2 * gap - 1;
if (begin2 >= n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
_Merger(arr,begin1, end1,begin2, end2,tmp);
}
gap *= 2;
}
}
归并排序特新总结:
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
非比较排序
计数排序
观察到的现象: 1、统计相同元素出现次数,时间复杂度O(N) 2、根据统计的结果将序列回收到原来的序列中 3、需要开辟一个临时数组用来存放数据,空间复杂度O(N)
当然如果数据量大了,需要开辟的临时数组可能就会更大了,这些都是有消耗的,怎么解决呢? 先来了解两个概念
绝对映射和相对映射
绝对映射:统计出每个数出现的次数,A[i]的值对应count数组位置的值++
for(int i = 0; i < n; i++)
count[A[i]]++
相对映射:
void CountSort(int* arr, int n)
{
int max = arr[0], min = arr[0];
for (int i = 0; i < n; i++)
{
if (arr[i] > max)
max = arr[i];
else if (arr[i] < min)
min = arr[i];
}
int range = max - min + 1;
int* tmp = (int *)malloc(sizeof(int) * range);
assert(tmp);
memset(tmp, 0, sizeof(int) * range);
for (int i = 0; i < n; i++)
{
tmp[arr[i] - min]++;
}
int i = 0;
for (int j = 0; j < range; j++)
{
while (tmp[j]--)
{
arr[i++] = j + min;
}
}
}
总结: 计数排序不管是用相对映射还是绝对映射都行,只不过用绝对映射会存在一定的浪费,而相对不会 1、时间复杂度O(N + range) 2、空间复杂度O(range) 3、计数排序只适合一组数据,数据的范围比较集中,如果范围集中,效率是很高的,但是局限性也在这里,并且只适合整数
3.排序算法复杂度及稳定性分析
|