@TOC
数据结构与算法-八大排序
排序的概念及其应用
排序的概念
初看这些概念可能一脸懵,但是没有关系,等下面学完几种排序之后在来看这些概念非常容易理解。
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
- 内部排序:数据元素全部放在内存中的排序
- 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
排序的应用
排序的应用在日常是生活中就很多了,我们生活中很多很多地方都需要排序,例如学校排名,成绩排名等等。
还有我们日常购物使用的京东等等,搜索一个东西也可以按价格销量排序等。所以说我们学习排序还是非常有必要的。
常见的排序算法实现
常见的排序算法
常见的排序算法一般来说有八种,当然还有一些其他的排序,但是我们先来看这最主要的八种。
每一种排序都有其特点,也会有其适合的场景。没有哪一种排序是适用于所有场景的,不然其他排序也就没有存在的必要了。
我们后面会分析它们的时间复杂度,稳定性等一些特质,先来看每一种排序的实现。
插入排序
直接插入排序
直接插入排序是一种简单的插入排序法,其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
直接插入排序也可以叫插入排序,是一种相对来讲比较简单的排序。其思想有点类似于我们玩扑克牌时每抓取一张牌就要插入到有序的位置,下面一个动图可以来帮助理解
可以看出插入排序的思想是很简单的,拿一个数来向前面判断即可,当遇到比它小的就插入到其后面,依次向后走就可以了。
那么代码怎么实现呢?先来看单趟排序,
单趟排序的思想搞清楚了,那么下面就容易了,我们默认第一个数是有序的,将数组中从第二个数开始依次向前插入,就可以完成我们的插入排序了。
代码实现:
void InsertSort(int* arr, int num)
{
for (int i = 1; i < num; i++)
{
int tmp = arr[i];
int end = i - 1;
while (end >= 0)
{
if (arr[end] > tmp)/*升序*/
{
arr[end + 1] = arr[end];
--end;
}
else
break;
}
arr[end + 1] = tmp;
}
}
直接插入排序总结:
1. 元素越接近有序,直接插入排序的效率越高
2. 时间复杂度:O(N^2),最坏的情况,完全逆序的情况下,每个数字都得挪动,1+2+3+..+(n-1)+n=n(n+1)/2。
3. 空间复杂度:O(1)
4. 稳定性:稳定
### 希尔排序(缩小增量排序)
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
理解了上面的直接插入排序的话,希尔排序也就很容易理解了,实际上希尔排序就是对插入排序的优化,我们看插入排序的思想可以知道, 当原数组越接近有序时插入排序的效率就越高,所以希尔就利用这一特点,通过进行 预排序的方式让数据先变的接近有序。
什么意思呢,先用下图来理解一下预排序的思想:
重点就是理解这个gap分组的作用,希尔将数据分成gap组,每距离gap个距离个元素就分成一组,然后对每一组都使用插入排序算法,使它们在各自的组里有序,这样就达到了预排序的效果, 当gap越大时,向后跳的也就越快,当gap越小时,排序后也就越接近有序,当gap为1时,就完成了彻底的排序。
那么一趟预排序的代码就可以写出来了,
有了上面的基础,那么接下来也就很简单了,我们只要讨论清楚gap到底取多少合适,然后弄一个判断条件就可以了。我们知道gap最后一定是要到1的,那么让gap怎么变化最合适呢,我们 第一趟通常是让gap为数据个数的一半,然后不断的取半,一个数除2最后一定是1。这是一种思路,如果你觉得还不够快的话,也可以采取除3再加1的方式,最后的gap一定也是1。
c void ShellSort(int* arr, int num) { int gap = num; while (gap > 1) { gap /= 2; for (int i = 0; i < num - gap; i++) { int end = i; int tmp = arr[i + gap]; while (end >= 0) { if (tmp < arr[end]) { arr[end + gap] = arr[end]; end -= gap; } else break; } arr[end + gap] = tmp; } } }
其实还有一种写法:
c void ShellSort(int* arr, int num) { int gap = num; while (gap > 1) { gap /= 2; for (int j = 0; j < gap; j++) { for (int i = j; i < num - gap; i += gap) { int end = i; int tmp = arr[i + gap]; while (end >= 0) { if (tmp < arr[end]) { arr[end + gap] = arr[end]; end -= gap; } else break; } arr[end + gap] = tmp; } } } }
不过我还是比较喜欢去掉一层循环,能稍微简化一点。
#### 希尔排序的时间复杂度
希尔的思想其实非常的奥妙,我们会发现每一次预排序后会变的有序一些,对下一次排序也会有一定的增益,但是这个增益具体是多少,目前在数学界也是一个未解之谜,不过很多数学家也去研究了这种思想,用统计的方法得出的结论也是一个大概, 我们近似认为O(N^1.3)
希尔排序对插入排序的优化效果
其实从时间复杂度上我们就已经看出来希尔的优化非常强大,O(N^2)和O(N ^1.3)差的可不是一星半点,我们可以来验证一下,
可以看到在10万个数据的时候其实已经拉开很大差距了,插入排序用了3.5秒,希尔排序用了0.02秒,其实当我用100万级别的数去跑的时候,插入排序已经跑不出来了,只有我的电脑风扇在狂转。所以从这里我们可以看出,希尔还是非常厉害的。
希尔排序总结:
希尔排序是对直接插入排序的优化,而且优化的效果非常强大
时间复杂度:O(N^1.3)
- 空间复杂度:O(1)
- 稳定性:不稳定
选择排序
直接选择排序
直接选择排序,也可以叫选择排序,它的基本思想是:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
下面同样是一个动图来帮助理解:
图中是每一轮都找一个最小值换到最前面。
很明显,这样排序其实效率已经很低了,我们如果写的话最好一次找两个,一个最大值一个最小值,虽然一次找两个效率也并没有本质的变化。效率还是很差。
void SelectSort(int* arr, int num)
{
int left = 0, right = num - 1;
while (left < right)
{
int mini = left, maxi = left;
for (int i = left + 1 ; i <= right; i++)
{
if (arr[i] < arr[mini])
mini = i;
if (arr[i] > arr[maxi])
maxi = i;
}
Swap(&arr[mini], &arr[left]);
if (maxi == left)
maxi = mini;
Swap(&arr[maxi], &arr[right]);
left++;
right--;
}
}
其中重点要注意的就一个地方,由于是最小值先交换,在将最小值交换后有可能将最大值给移走,所以需要特别调整一下。
堆排序
堆排序的前提就是理解堆的思想,堆排序第一步是建堆,而建堆又有两种方式,向上建堆和向下建堆,建大堆还是建小堆要取决于具体情况:
通常我们是向下建堆的方式,因为效率要高于向上建堆,
排升序:建大堆
排降序:建小堆
向上调整建堆:
拿一个动图来理解一下,虽然堆排序用不到,但是关于堆的思想还是要会的。
基本思想:
1、顾名思义从孩子依次往上调整
2、保证前面的元素构成堆
3、模拟数据插入的过程向上调整,若插入的元素比它的双亲大,则需要交换他们的位置,再迭代向上调整,直到孩子比双亲小。
4、从头到尾遍历完整个数组便实现了堆的构建
void AdjustUp(int* arr, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (arr[parent] < arr[child])
{
Swap(&arr[parent], &arr[child]);
child = parent;
parent = (child - 1) / 2;
}
else
break;
}
}
for(int i=1;i<size;i++)
{
AdjustUp(arr, i);
}
向下调整建堆
向下调整建堆是效率比较高的方式,也是堆排序要用的方式。
思想:从倒数第一个非叶结点开始向下调整,找到比双亲大的左右孩子,让双亲和左右孩子大的那个孩子交换,循环从该叶子结点遍历到根结点就结束
void AdjustDown(int* arr, int size, int parent)
{
int child = parent * 2 + 1;
while (child < size)
{
/*选出较大的孩子,注意右孩子可能不存在,导致越界*/
if (child + 1 < size && arr[child] < arr[child + 1])
++child;
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else
break;
}
}
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, size, i);
}
堆排序
我们就通常写效率较高的,用向下建堆即可,
思想:每次将堆顶的最大数据交换到堆尾,然后忽略最后数据,将前面数据再调整为大堆,继续交换循环下去。
void AdjustDown(int* arr, int size, int parent)
{
int child = parent * 2 + 1;
while (child < size)
{
/*选出较大的孩子,注意右孩子可能不存在,导致越界*/
if (child + 1 < size && arr[child] < arr[child + 1])
++child;
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else
break;
}
}
void HeapSort(int* arr, int size)
{
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, size, i);
}
int end = size - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
AdjustDown(arr, end, 0);
--end;
}
}
交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
冒泡排序
冒泡排序不管是从思想上还是代码实现上都是最简单的一种排序,所以几乎学习任何一门语言最先接触到的排序都是冒泡排序,虽然它比较简单但是对初学者的编程能力和思想的培养也有着不可忽视的意义。
关于思想就不再过多解释了,非常简单的思路,直接两层循环即可,
void BubbleSort(int* a, int n)
{
for (int j = 0; j < n-1; j++)
{
for (int i = 1; i < n - j; i++)
{
if (a[i - 1] > a[i])
Swap(&a[i - 1], &a[i]);
}
}
}
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
快速排序
快速排序一听名字就很快嘛,当然是一种特别厉害的排序,简称快排。我们的库函数qsort
底层就是使用快排来实现的。
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快排的思想其实是非常巧妙的,并且它有多种方法来实现,可以说是排序中最难的一种了。我们先来看第一种方法。
hoare版本
先来看一下hoare最初的思想,
这个思想画图来看的话是不是有一点二叉树的影子,其实没错,我们的代码确实是用递归来实现的,当然也有非递归的版本,但是通常递归用起来很方便。
看一下这个动图,但我们理解了方法之后,败在我们面前一个难题,此时我们要考虑的就是key怎么选,选多少合适,为了方便我们是确定选最左边那个数,但是试想一下,如果是一个完全逆序的数组,我们要排的话要好好思考一下效率问题,此时效率就会特别低,所以我们通常有两种选key的方式,一种是在数组中随机选数,另一种是数组的最左边,中间和右边三个数选出一个中间数,要说哪个更好的话,当然是后者了,可以非常稳定的选择一个比较合适的数。
int GetMidi(int* a, int left, int right)
{
int mid = (left + right) / 2;
if ((a[left] - a[mid]) * (a[mid] - a[right]) > 0)
return mid;
else if ((a[mid] - a[left]) * (a[left] - a[right]) > 0)
return left;
else
return right;
}
int PartSort1(int* a, int left, int right)
{
int midi = GetMidi(a, left, right);
Swap(&a[midi], &a[left]);
int keyi = left;
int begin = left, end = right;
while (begin < end)
{
//注意左边选keyi右边先走,保证最后的位置正确
//以及要注意等于号,否则会死循环
while(begin < end && a[end] >= a[keyi])
end--;
while (begin < end && a[begin] <= a[keyi])
begin++;
Swap(&a[begin], &a[end]);
}
Swap(&a[keyi], &a[end]);
keyi = end;
return keyi;
}
这样我们就完成了第一趟排序,将一个数放在了它应该存在的位置,接下来我们只需要分别对两边进行递归即可。
void QuickSort(int* a, int left,int right)
{
if (left >= right)
return;
int keyi = PartSort1(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
其中的细节还是蛮多的,如果不熟悉的话比较容易出错,下面看其他方法。
挖坑法
挖坑法其实和上面的思想是类似的,就是将key的值保存起来,就相当于多了一个坑,然后从另一边找大于或者小于key的值来填坑。
下面依旧是动图来演示,我们面对的还是同样的问题,key的取值,上面已经讨论过了,还是三数取中的方法,或者随机选key也可以。
代码实现:
int GetMidi(int* a, int left, int right)
{
int mid = (left + right) / 2;
if ((a[left] - a[mid]) * (a[mid] - a[right]) > 0)
return mid;
else if ((a[mid] - a[left]) * (a[left] - a[right]) > 0)
return left;
else
return right;
}
int PartSort2(int* a, int left, int right)
{
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
int key = a[left];
int hole= left;
int begin = left, end = right;
while (begin < end)
{
while (begin<end && a[end] >= key)//等于key的情况不能漏掉
end--;
a[hole] = a[end];
hole = end;
while (begin < end && a[begin] <= key)
begin++;
a[hole] = a[begin];
hole = begin;
}
a[hole] = key;
return hole;
}
void QuickSort(int* a, int left,int right)
{
if (left >= right)
return;
int keyi = PartSort2(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
前后指针法
前后指针法是我们通常用的方法,相对于上面两种版本会省心一些,因为上面两种版本代码实现起来细节要注意多处地方,关于基本思想呢,其实都差不多,都是选定一个key,将小于key的挪动左边,大于key的挪到右边,最后将key放到该放的位置上,接着两边递归即可,只是在实现上是用前后两个指针来实现,
可以看到两个指针的规律,cur每次都要向后走,而prev是遇到比key小的才走,如果是比key大的就等cur找到比key小的,接着两者交换,当cur越界时为停止条件。
int GetMidi(int* a, int left, int right)
{
int mid = (left + right) / 2;
if ((a[left] - a[mid]) * (a[mid] - a[right]) > 0)
return mid;
else if ((a[mid] - a[left]) * (a[left] - a[right]) > 0)
return left;
else
return right;
}
int PartSort3(int* a, int left, int right)
{
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
int prev = left, cur = left + 1;
while (cur <= right)
{
while (a[cur] < a[keyi] && ++prev != cur)//只有cur找到小于key的值,prev才++
Swap(&a[cur], &a[prev]);
++cur;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
return keyi;
}
void QuickSort(int* a, int left,int right)
{
if (left >= right)
return;
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
快排优化
关于快排的优化主要有两点,
- 基准值(key)的选取问题,这个上面其实已经讨论过了,两种解决办法,一个是随机选,一种是三数取中。
- 和直接插入排序结合使用
由于快排是用递归的方式来进行的,有些类似于二叉树的结构,所以每次递归都是以2倍的速度增长的,如果递归的深度比较深那么效率就会受到一定影响,所以我们可以和直接插入排序结合使用,我们只需要将最后3层或4层递归去掉,那么递归次数就被干掉了60%~70%。
所以优化后的代码就可以这样来写,如果子区间数据小于10的话就使用直接插入排序:
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
if (right - left + 1 > 10)
{
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
else
InsertSort(a + left, right - left + 1);
}
快排非递归
由于快排玩的是递归的方式,那么就势必要考虑一下栈是否会溢出的问题,快速排序其实也可以用非递归来实现,但是要借助一下我们的存储数据的结构栈,当然队列也是可以的,我们这里就用栈来实现一下,
void QuickSortNonR(int* a, int left, int right)
{
stack st;
StackInit(&st);
StackPush(&st, right);
StackPush(&st, left);
while (!StackEmpty(&st))
{
int begin = StackTop(&st);
StackPop(&st);
int end = StackTop(&st);
StackPop(&st);
int keyi = PartSort3(a, begin, end);
//[begin,keyi-1] keyi [keyi+1,end]
if (keyi + 1 < end)
{
StackPush(&st, end);
StackPush(&st, keyi + 1);
}
if (begin < keyi - 1)
{
StackPush(&st, keyi - 1);
StackPush(&st, begin);
}
}
StackDestroy(&st);
}
- 快速排序特性总结:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
- 时间复杂度:O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
递归
归并排序的基本思路其实也是一种递归分治的思想,可以用下图来表示理解一下:
动图演示:
从图中可以看出,开始是两两归并,然后拷贝回来,走完后开始四四归并,然后拷贝回来,依次递归下去即可。
代码实现:
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
//归并
//[begin,mid][mid+1,end]
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;//注意开始位置,不能是0
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
tmp[i++] = a[begin1++];
else
tmp[i++] = a[begin2++];
}
while (begin1 <= end1)
tmp[i++] = a[begin1++];
while (begin2 <= end2)
tmp[i++] = a[begin2++];
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);
}
类似这种分治的方法,在分出子区间进行操作时,尤其要注意起始位置的问题,不要想当然起始位置为0.
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
归并非递归
归并的非递归在于数组边界的控制,和递归的思路上正好是反过来,先1个和1个归并,再2个2个归并,然后依次循环,当然这个相对来说也没那么重要,了解即可。
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail\n");
return;
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
if (end1 >= n || begin2 >= n)
break;
if (end2 >= n)
end2 = n - 1;
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
tmp[j++] = a[begin1++];
else
tmp[j++] = a[begin2++];
}
while (begin1 <= end1)
tmp[j++] = a[begin1++];
while (begin2 <= end2)
tmp[j++] = a[begin2++];
memcpy(a + i, tmp + i, sizeof(int)*(end2 - i + 1));
}
gap *= 2;
}
}
非比较排序
计数排序
思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。
步骤:
- 开辟合适大小的数组
- 统计相同元素出现的次数
- 根据统计的结果将序列回收到原来的序列中
这是一个比较简单的情况,实际上我们是用相对位置才可以完成计数排序,例如一组数据:
所以代码在实现时也要考虑更一般的情况,
void CountSort(int* a, int n)
{
//找最大值,最小值
int max = a[0], min = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] < min)
min = a[i];
if (a[i] > max)
max = a[i];
}
//开辟数组
int range = max - min + 1;
int* countA = (int*)malloc(sizeof(int) * range);
if (countA == NULL)
{
perror("malloc fail\n");
return;
}
memset(countA, 0, sizeof(int) * range);
//计数
for (int i = 0; i < n; i++)
countA[a[i] - min]++;
//排序
int j = 0;
for (int i = 0; i < range; i++)
{
while (countA[i]--)
a[j++] = i + min;
}
free(countA);
}
计数排序特性总结:
1、计数排序适合范围集中,且范围不大的整形数组排序。不适合范围分散或者非整形的排序,如:字符串、浮点数等
2、时间复杂度O(N)
3、空间复杂度O(N)
排序总结
排序还有像基数排序和桶排序可能没有提到,但是我们已经将最重要的几种都提到了,基数排序有时间也可以学一下,桶排序可以说是最没用的排序了,学不学都可以,基本没什么应用场景。
对于排序的了解一定要理解思想,能够很清楚它的时间复杂度和空间复杂度,稳定性等特性。
- 稳定的排序有:直接插入排序、冒泡排序、归并排序
- 不稳定的排序有:希尔排序、选择排序、堆排序、快速排序、计数排序