可视化的动图可以帮助我们理解排序算法,在了解了排序算法的思想后,观察动图可以加深我们对排序算法的理解。
本文全部代码已上传Gitee。
文章目录
一、插入排序
1.直接插入排序
核心思想:
把一个数插入一个有序区间。
实现方法:假设0—end是已经有序的区间,我们用x存储end后面一个位置的元素,表示要把x存储到0—end的有序区间中。
如果end所指元素比x大,就把end所指的元素赋给后面一个位置的元素(相当于把end所指元素往后移动一个格子),然后end=end-1使end指向前一个元素,继续比较;
如果end所指元素比x大,end后面的这个格子已经被我们空出来了,就把end的下一个位置的元素赋值成x,end从0开始一直循环到n-2就能把所有元素都插入进来了。
可视化
void InsertSort(int* a, int n)
{
assert(a);
for (int i = 0; i <= n - 2; i++)
{
int end = i;
int x = a[end + 1];
//x已经保存了a[end + 1] 所以后面再覆盖也可以
//因此end只能落在n-2
while (end >= 0)
{
//如果end指的元素比x大
//那就往后挪
if (a[end] > x)
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
//插入在最头上和插入在中间都在这里处理
a[end + 1] = x;
}
}
时间复杂度分析
直接插入排序最坏情况是逆序(每插入一个都要移动,从第二个元素开始,第二个元素需要移动1次,第三个元素需要移动2次,…,第n个元素需要移动n-1次)
1
+
2
+
3
+
.
.
.
+
n
−
1
=
n
(
n
−
1
)
2
1+2+3+...+n-1=\frac{n(n-1)}{2}
1+2+3+...+n−1=2n(n−1)
取最大项就是O(N^2).
最好情况是已经有序或者基本有序,就只需要遍历一次数组(有序)或者偶尔几个元素需要移动几次格子再插入其他的直接插入在end所指元素后面就行(基本有序),故最好情况下时间复杂度是O(N)。
2.希尔排序
插入排序面对逆序或不太有序的情况下效率比较低,但是面对基本有序的情况它是非常棒的排序(O(N))。
核心思想:
希尔排序就是在直接插入排序上优化,既然对基本有序的情况直接插入排序很棒,那我先分成gap组进行一个预排序(这个过程可以使数组基本有序),然后再进行一个直接插入排序,那么怎么样进行预排序呢?
预排序步骤:
- 单趟预排序
按gap分组,分成gap组,gap>1,对每个组进行插入排序,使总体数组看起来接近有序
实际上就是把0 0+gap 0+2gap…视为一组,1 1+gap 1+2gap…视为一组…对每一组进行直接插入排序,这样每一组都是有序的了,总体数组就比之前有有序多了。
那么对0,0+gap,0+2gap…这一组预排序的单趟排序代码如下(这里gap取3):
//分组的单趟
//按gap分组进行预排序
int gap = 3;
int end = 0;
int x = a[end + gap];
while (end >= 0)
{
if (a[end] > x)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = x;
对所有组的预排序的代码如下(这里取gap=3):
//排完gap组
int gap = 3;
for (int j = 0; j < gap; ++j)
{
for (int i = j; i < n - gap; i += gap)
{
int end = i;
int x = a[end + gap];
while (end >= 0)
{
if (a[end] > x)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = x;
}
}
上面的代码虽然清楚,但是不够简洁,我们可以对多组同时进行预排序,就好像把多组同时一锅炖了一样。对单趟多组预排序的代码改造如下:
for (int i = 0; i < n - gap; i++)
{
int end = i;
int x = a[end + gap];
while (end >= 0)
{
if (a[end] > x)
{
//如果end所指的元素比x大
//就把end所指元素往后移动,空出一个格子
a[end + gap] = a[end];
end -= gap;
}
else
{
//否则就跳出去,
//这样可以同时处理end小于0的情况(插入在最头上的情况)
break;
}
}
a[end + gap] = x;
}
讨论一下预排序的时间复杂度
与直接插入排序类似,最好情况是已经有序的时候,是O(N)(遍历一遍就行了)
最坏情况:每一组都是逆序的,每一组的元素个数是[N/gap],这样的总共需要的循环次数是:gap*(1+2+3+…+[N/gap]-1)(套用最糟糕情况直接插入排序的循环次数,gap组)。
观察这个总共需要的循环次数的函数,发现:
gap越大 预排越快(gap=N,O(N)) ,但是因为分的组数太多了,排完后越接近无序;
gap越小 预排越慢(gap=1,O(N^2)),分的组数少排完后越接近有序。
- 多趟分组预排序与最后的直接插入排序
为了让最后进行插入排序的时候数组能更接近有序一些,我们可以加一个循环控制gap不断变化进行多趟分组预排序,并且把gap=1时,也就是最终进行直接插入排序耦合到while循环里,代码如下:
void ShellSort(int* a, int n)
{
int gap = n;
//多次预排序(gap > 1)+直接插入排序(gap == 1)
while (gap > 1)//gap进去以后才/ 所以大于1就行
//等于1可能会死循环 一直是1出不去
{
//两种预排序方法:
//gap = gap / 2;//一次跳一半
gap = gap / 3 + 1;
//加一是为了保证最后一次gap小于3的时候
//能够有gap等于1来表示直接插入排序
//多组同时搞:
for (int i = 0; i < n - gap; i++)
{
int end = i;
int x = a[end + gap];
while (end >= 0)
{
if (a[end] > x)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = x;
}
}
}
可视化
3.对比希尔排序和直接插入排序的速度
纸上得来终觉浅,我们这里使用随机数生成10w个数比较希尔排序和直接插入排序的速度。
void TestVel()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
}
int start1 = clock();
InsertSort(a1, N);
int end1 = clock();
int start2 = clock();
ShellSort(a2, N);
int end2 = clock();
printf("InsertSort:%d\n", end1 - start1);
printf("ShellSort:%d\n", end2 - start2);
free(a1);
free(a2);
}
100w个数
4.希尔排序时间复杂度分析
估算
最外层的while循环logn次(每次除2或除3),进去的预排序,一开始n很大的时候,时间复杂度接近O(n),后来n很小的时候,由于前面的预排序已经让它基本有序了,时间复杂度也是是O(n),所以时间复杂度大概是O(nlogn)。
正式数学运算
严格来讲,希尔排序的时间复杂度的计算是一件十分困难的事情,《数据结构—用面向对象方法与C++描述》中的说法如下:
所以从记忆结论的角度上大概是Knuth的
O
(
N
1.25
)
O(N^{1.25})
O(N1.25)
二、选择排序
选择排序的思想是通过某种方法选出最大或最小元素,把他放到正确位置。
1.直接选择排序
思想:遍历一遍选出最大的元素和最小的元素,分别与最后一个位置和第一个位置交换一下,然后从第二个元素到倒数第二个元素重新进行一次选择排序,直到区间长度小于等于1为止。
可视化
代码:
void swap(int* px, int* py)
{
int tmp = *px;
*px = *py;
*py = tmp;
}
//直接选择排序 时间复杂度
//最坏O(n^2)
//最好O(n^2)
//所以是整体而言最差的排序,因为无论什么情况都是N^2
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int mini = begin;
int maxi = end;
for (int i = begin; i <= end; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
swap(&a[begin], &a[mini]);
//如果begin和maxi重合了 maxi就被换走了
//begin的元素换到mini那里去了
//控制一下maxi=mini就行。
if (begin == maxi)
{
maxi = mini;
}
swap(&a[end], &a[maxi]);
++begin;
--end;
}
}
时间复杂度分析
不 管 最 好 最 坏 都 是 O ( n 2 ) 因 为 不 管 怎 么 样 都 会 遍 历 一 遍 选 最 小 最 大 长 度 减 小 2 再 遍 历 一 遍 求 和 求 起 来 最 高 次 项 就 是 O ( N 2 ) . 不管最好最坏都是O(n^{2})\\因为不管怎么样都会遍历一遍选最小最大\\长度减小2再遍历一遍\\求和求起来最高次项就是O(N^2). 不管最好最坏都是O(n2)因为不管怎么样都会遍历一遍选最小最大长度减小2再遍历一遍求和求起来最高次项就是O(N2).
100w个数排序的速度
2.堆排序
这里就不详细介绍了,详情参考我的有关特殊的完全二叉树——堆的文章。
代码
void AdjustDown(int* a, int n, int root)
{
int parent = root;
int child = root * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])
child = child + 1;
if (a[child] > a[parent])
{
swap(&a[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
assert(a);
//向下调整建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
//交换堆顶和最后一个元素,然后向下调整
for (int i = n - 1; i > 0; i--)
{
swap(&a[0], &a[i]);
AdjustDown(a, i, 0);
}
}
void AdjustUp(int* a, int child)
{
assert(a);
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] > a[parent])
{
swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void Heapsort(int* a, int n)
{
assert(a);
//用向上调整算法建堆
//假插入的思想
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}
for (int i = n - 1; i > 0; i--)
{
swap(&a[0], &a[i]);
AdjustDown(a, i, 0);
}
}
可视化
1kw个数堆排序的速度
时间复杂度分析
建堆的时间复杂度是O(N),然后调整,最坏情况下每次堆顶被换了都要调整层数次,所以时间复杂度是O(N*logN)。
用向上调整建堆速度确实比用向下调整建堆慢,这点也可以从这里的测试看出:
三、交换排序
1.冒泡排序
思想:
这个排序可以说是程序员的必修课了,思想就是一次单趟从头开始和自己相邻的数比较,如果比相邻的那个数大(排升序),就交换这两个数;这样的思想下,第一次单趟会把最大的数冒到最后,然后再重新从头开始,这次比较到倒数第二个数停,以此类推。
针对有序和基本有序数组的优化:
在冒泡排序中加入一个flag表示此次单趟交换的次数,如果某次单趟交换次数是0,表明此时已经有序了,就break出去就行,这个对有序数组和基本有序的数组都是有优化作用的,这样冒几次单趟就有序了就break了。
可视化
代码
void BubbleSort(int* a, int n)
{
assert(a);
for (int i = 0; i < n; i++)
{
int flag = 0;
for (int j = 0; j < n - 1 - i; j++)
{
if (a[j] > a[j + 1])
{
swap(&a[j], &a[j + 1]);
flag++;
}
}
if (flag == 0)
break;
}
}
2.横向对比冒泡排序和直接选择排序和直接插入排序
直接选择排序不论什么情况都是O(N^2),无法和另外两个比,另外两个中,冒泡排序和直接插入排序,最坏都是O(N^2),最好都是O(N).
对于已经有序的数组,冒泡排序和直接插入排序,一样好都是O(N)。
对接近有序的数组,插入排序更好,理由如下:
如下面的数组 1 2 3 4 6 5
冒泡排序:N-1+N-2(先遍历一趟把6放到应该放的位置,第二趟遍历确定有序了停下来)。
直接插入排序:2插入,比1大,插入1后面:1次;3插入,比2大,插入2后面:2次;4插入,比3大,插入3后面:3次;6插入,比4大,插入4后面:4次;5插入,和6比一次,比6小,6往后移动,和4比, 比4大,5插在4后面:6次,归纳一下就是N次。
综上所述,直接插入排序更好一些,直接插入排序应对于局部有序是很不错的排序。
3.快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
单趟排序的目标
快速排序单趟的目的是把基准值key放到一个正确的位置,这个位置左边元素小于等于它,右边元素大于等于它。
先选一个基准值key,一般选最左边或者最右边的值做key。
单趟排序的目标是排成:左边的值比key要小,右边的值比key大,一个单趟就把key放到了恰当的位置了。
本文会介绍三种单趟排序的方法。
单趟排序之hoare版本
思想:
以最左边的值为key时,安排左右两个指针L和R,L找比key大的值,R找比key小的值,R先走找到了停下来,然后L再走找到了停下来,两个都找到了就让L和R指的值做一次交换,然后R再走。当L和R相遇的时跟key交换,返回相遇的位置表示这个位置的元素已经排好了。
有小朋友可能会问了,如果相遇遇到的值比key大怎么办呢,这样交换不就把比key大的值放到左边去了嘛。
所以有一个核心原则:
-
选最左边的值做key,右边先走,可以达到左右相遇时比key小;
-
选最右边的值做key,左边先走,可以达到左右相遇时比key大;
以最左边的值做key的单趟可视化
下图中:i是L,j是R。
以左边做key时让左边先走为例,会出问题:
如图,比key=6大的9跑到key左边去了。
原理:以右边做key时需要左边先走原理为例,
我们先说明为什么右边先走不行:如果在没相遇之前,其实哪边先走都一样,会保证L左边的值比key小,R右边的值比key大。
这里相遇有两种情况,L撞R和R撞L。
假设最后一次相遇前是R先走,R要找的是比key小的值,R没找到,R撞见了L停了,但是此时L的值是比R小的,如果和key交换了会导致key右边有比key小的值,这轮就失败了;
假设R先走R找到了比key小的值,L没有找到比key大的值,L撞见R停了,那此时相遇位置的值是比key小的,交换到key所在的右边,比key小的值放到了key右边,交换出问题了,这轮也会失败。
再说明为什么左边先走是可以的:所以如果右边做key左边先走的话,L找比key大的值没找到,撞到R停了,此时R指的值一定是比key大的,并且相遇位置左边是比key小的,右边是比key大的,所以交换key就会成功;
假设L找到了比key大的值,R没找到比key小的值撞到L停了,相遇位置是比key大的值,并且相遇位置左边是比key小的值,右边是比key大的值,交换key和相遇位置的值此轮也是成功的。
有了思想的铺垫,我们的单趟版本可以这样写:
int PartSort1(int* a, int left, int right)
{
int keyi = left;
while (left < right)
{
while (a[right] > a[keyi])
--right;
while (a[left] < a[keyi])
++left;
swap(&a[left], &a[right]);
}
swap(&a[left], &a[keyi]);
return left;
}
但是上面的代码在两种情况会有缺陷。
缺陷1:全部都是相等的情况,right和left会动不了导致死循环。
修改方法,右找小的,那相等的也放在右边把;左找大的,那相等的也放在左边吧。
大于号改大于等于,小于号改小于等于。
修改后右找的是严格比key大的,左找的是严格比key小的.
int PartSort1(int* a, int left, int right)
{
int keyi = left;
while (left < right)
{
while (a[right] >= a[keyi])
--right;
while (a[left] <= a[keyi])
++left;
swap(&a[left], &a[right]);
}
swap(&a[left], &a[keyi]);
return left;
}
但是仍然规避不了越界的情况。
情况2:
因为我们在让right走的时候并没有控制right要大于left,所以可能会导致越界!
所以必须每次比较前必须比较一下left是否小于right,只要left和right没错开,就不会出现越界的情况。
int PartSort1(int* a, int left, int right)
{
int keyi = left;
//左边做key
while (left < right)
{
//右边先走 找小 控制不要错开不要越界
while (left < right && a[right] >= a[keyi])
--right;
//左边再走 找大 控制不要错开不要越界
while (left < right && a[left] <= a[keyi])
++left;
swap(&a[left], &a[right]);
}
swap(&a[left], &a[keyi]);
return left;
}
单趟排完,比key小的都到了左边,比key大的都在右边,如果左边有序,右边有序就完成了。
单趟排序之挖坑法
挖坑法是单趟排序的hoare版本的一个变形,并没有实际上的效率优化,只是思想更好理解了一些。
思想:
以最左边为key,把它取出放到一个临时变量tmp里头,最左边L就形成了一个坑,R先走找小,找到了就把数扔到坑里面,自己就形成了一个坑,然后L再走找大,找到了就把这个数扔到R所在的坑里边,一直到他们相遇,他俩相遇时一定是有一个是一个坑,把tmp放进来就行,最后返回坑的位置表示这个位置的元素已经放好了。
挖坑法单趟排序可视化:
代码:
int PartSort2(int* a, int left, int right)
{
int ipit = left;
int key = a[ipit];
while (left < right)
{
//右边先走 找小 找到了放到左边的坑里面
while (left < right && a[right] >= key)
--right;
a[ipit] = a[right];//放
ipit = right;//自己就变成了坑
//左边再走 找大 找到了放到右边的坑里边
while (left < right && a[left] <= key)
++left;
a[ipit] = a[left];
ipit = left;
}
//把key放到最后相遇的坑里面
a[ipit] = key;
return ipit;
}
单趟排序之前后指针法
前后指针法是快排单趟最优雅的写法,不得不说发明这些算法的人真是大神。
思想:
最左边做key,cur指key后面一个元素,prev指key。
出发,cur先走,cur找小,找到小的停下来,然后prev走一步,++prev,然后交换cur和prev指向的值,然后重复上一轮;直到cur出去为止,最后交换key和prev所指向的值。
这就像是把小的往左边甩,大的往右边甩的意思,prev要么紧跟着cur,要么紧跟着比key大的序列。
可视化:
代码:
//左边做key
//右边做key会遇到意外 可能prev指的值比key小,那就++prev
int PartSort3low(int* a, int left, int right)
{
int key = a[left];
int keyi = left;
int cur = left;
int prev = cur + 1;
while (cur <= right)
{
//cur找小
while (cur <= right && a[cur] >= key)
++cur;
if (cur <= right)//防止越界
{
swap(&a[++prev], &a[cur]);
//交换是prev的值还是比key小的
//这样cur指的值仍然比key小
//在上面的while循环,cur会动不了
//但其实这个点已经不用管了
//这个位置已经放是比key小的了
++cur;
}
}
swap(&a[prev], &a[keyi]);
return prev;
}
可以观察到不管是找到还是没找到,都要++cur,就算找到了交换过后cur的值可能会比key小,这样cur就无法通过最前面的while循环动起来了,并且当prev紧跟着cur的时候,cur和prev总是在自己交换自己,很呆。
因此我们考虑不管找到还是没找到cur都++,如果cur找到了比key小的值并且cur不等于++prev的时候(这一步帮助我们把prev移动了),进行交换。
//更优质的写法
int PartSort3(int* a, int left, int right)
{
assert(a);
int keyi = left;
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
//相同就不交换了 减少消耗
if (a[cur] < a[keyi] && ++prev != cur)
swap(&a[cur], &a[prev]);
//既然不管是交换还是不交换cur都得走
//不如直接拿出来走
++cur;
}
swap(&a[keyi], &a[prev]);
return prev;
}
单趟排序的前后指针法的第二种写法非常简洁,因为它把思路弄得很清楚,并且这个思路是很好的思路,所以在写快速排序的时候,单趟排序我们尽量写第三种。
多趟排序递归写法
我们用二叉树前序遍历的思想,现在key已经放在正确的位置上了,想让左区间有序,就对[left,keyi - 1]进行一次快排,想让右区间有序,就对[keyi + 1, right]进行一次快排,分解为最小问题时区间不存在时或区间长度等于1的时候,认为元素是有序的,就返回。
递归图:
可视化:
代码:
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
return;
int keyi = PartSort1(a, left, right);
//keyi位置已经放了恰当的元素了
//分成了[left, keyi-1] [keyi+1, right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
时间复杂度分析
left=0,right = n-1的一次单趟,就是left、right往中间走,相当于一次遍历,时间复杂度O(N)。
考察总的递归过程:
如果每次都能差不多取到中位数的情况下,如下图:
因此时间复杂度就是O(NlogN)。
性能测试(1千万个数字)
递归写法的快排两个缺陷
最坏情况:如果数组有序或基本有序的情况下,每次取得的左值就会放到最左边,分成的区间就是0和[1,n-1]、0和[2,n-1]…这种情况二叉树的深度会太深了,如下图:
这时的时间复杂度是O(N^2)(1+2+3+…+N),时间复杂度变得非常糟糕,这是第一个缺陷。
测试一下:
让快排和堆排序都去排希尔排序已经排好的数组,
10w个数的速度。
另一个缺陷是递归层数太深甚至可能会导致栈溢出。
这根本上是key选的不合理导致的,key应该尽量选择中间值以保证树的深度不大,这样递归次数少一点。
如何解决快排面对有序的选key的问题:
- 随机选key,命运交给了随机,也不好。
- 三数取中。a[left]、a[right]、a[mid]中取不是最大也不是最小的那个做key,这样就规避了如果是有序的情况每次keyi取到了最左边导致时间复杂度骤升的情况,针对有序的情况下,每次取中就取到了中间,这样就是一下子从最坏变成了最好情况.
int GetMidIndex(int* a, int left, int right)
{
//int mid = (left + right) / 2;
//如果left和right超过int的一半会出问题
//int mid = left + (right - left) / 2;
//进一步修改 /是用减法实现的 减法是用加法实现的 效率比较低
//除2相当于右移一位 右移效率相对高一点
//注意优先级
int mid = left + ((right - left) >> 1);
if (a[mid] < a[left])
{
if (a[left] < a[right])
{
return left;
}
//a[left] > a[right] && a[mid] < a[left]
//a[left]最大
else if (a[mid] < a[right])
{
return right;
}
else
{
return mid;
}
}
else //a[mid] > a[left]
{
if (a[left] > a[right])
{
return left;
}
//a[mid] > a[left] && a[left] < a[right]
//a[left]最小
else if (a[mid] < a[right])
{
return mid;
}
else
{
return right;
}
}
}
为了保持主逻辑不变,我们先取得中的下标,然后把中的值和left的值换一下。
int PartSort1(int* a, int left, int right)
{
int mini = GetMidIndex(a, left, right);
swap(&a[left], &a[mini]);
int keyi = left;
//左边做key
while (left < right)
{
//右边先走 找小 控制不要错开不要越界
while (left < right && a[right] >= a[keyi])
--right;
//左边再走 找大 控制不要错开不要越界
while (left < right && a[left] <= a[keyi])
++left;
swap(&a[left], &a[right]);
}
swap(&a[left], &a[keyi]);
return left;
}
已经用希尔排序排完了,有序的情况下。
10w个数的速度:
1kw个数的速度:
有了三数取中,这样递归的深度都不会很大了,因为是接近完全二叉树的形态,栈溢出问题更难发生了,并且把最坏情况变成了最好情况,把时间复杂度控制在接近O(NlogN)。
快排无法解决的缺陷
如果所有数据都相等,快速排序就会变得很糟糕。
因为你所有数据都相等的时候(或者是23232323232323这种情况),三值取中取到的值还是最小的或次小的,快排就变成了上面提到的最坏情况(每次都是左边区间长度0,右边区间长度n-1个)。
10w个相同的数:
没有很好的办法解决这个问题。
小区间优化
我们的快排写成递归的话,有以下缺陷。
递归程序的缺陷:
- 相比循环程序,性能差。(针对早期编译器成立,因为对于递归调用,建立栈帧优化不大。现代编译器优化都很好,递归相比循环性能差不了多少,已经不是核心矛盾了)
- 递归深度太深时会导致栈溢出(Linux栈只有8M,只够几万层),核心矛盾。
第一种解决方法是搞一个小区间优化,当区间长度很小的时候其实反而占的区间比较多(因为完全二叉树除最后一层外越深的结点越多),我们不如在这个时候使用一个别的排序的就行了。
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left >= right)
return;
if (right - left + 1 < 10)
//闭区间[left,right]有right - left + 1个元素
{
//小区间优化 当分割到小区间的时候 不再用分割让小区间有序
//减少递归次数
InsertSort(a + left, right - left + 1);//
//a + left 起始位置
}
else {
int keyi = PartSort3(a, left, right);
//keyi位置已经放了恰当的元素了
//分成了[left, keyi-1] [keyi+1, right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
1kw个数速度比较
可以看出还是有一定程度的优化的,看起来不明显是因为release版本对递归优化了很多了。
快速排序非递归实现(栈模拟)
另一种方法是实现所谓的非递归。
思路:
把区间的左端点和右端点存到栈里头,出栈以后进行一次单趟排序,然后类似递归版本一样,分成左右两个区间再入栈,直到栈空为止。
如果左区间想先处理,因为栈后进先出,所以先让右区间进去;左端点先进,右端点再进,同理,先出来的是右端点,再出来的是左端点。
这里注意控制当区间长度大于1的时候才有入栈的必要。
void QuickSortNonR(int* a, int left, int right)
{
assert(a);
Stack st;
StackInit(&st);
StackPush(&st, left);
StackPush(&st, right);
while (!StackEmpty(&st))
{
int end = StackTop(&st);
StackPop(&st);
int begin = StackTop(&st);
StackPop(&st);
int keyi = PartSort3(a, begin, end);
//一次单趟让keyi放到正确的位置去了
//[begin,keyi-1] [keyi + 1,end]
if (keyi + 1 < end)
{
StackPush(&st, keyi + 1);
StackPush(&st, end);
}
if (begin < keyi - 1)
{
StackPush(&st, begin);
StackPush(&st, keyi - 1);
}
}
Stackdestroy(&st);
}
四、归并排序
1 基本思想
如果左区间有序,右区间也有序,我们用一个临时数组不断插入左右的最小元素,然后拷贝回原数组就行。
那怎么做到左右有序呢?
与快速排序类似,借助递归的思想。注意到如果只有区间中只有两个元素的时候,我们可以很轻松的做到让区间有序,谁小谁先插入tmp数组,然后插另一个,这样这个区间就有序了。
所以我们不断的把区间划分成一半一半,到最小规模的子问题即只有一个值或者不存在的区间的时候,这个区间可不就有序了吗,然后两个有序区间我们可以往回做归并,所以要把左边弄成有序,然后把右边弄成有序,然后再归并,类似后续遍历。
可视化
2 递归版本
先把左区间归并到有序,再把右区间归并到有序,对两个区间合成的区间进行一个归并,所以是一个类后续遍历。
代码:
void MergeSort(int* a, int n)
{
assert(a);
int* tmp = (int*)malloc(n * sizeof(int));
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
_MergeSort(a, 0, n - 1, tmp);//递归用的子函数
free(tmp);
tmp = NULL;
}
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
//区间长度为0或者不存在这个区间的时候 返回
{
return;
}
int mid = left + (right - left) / 2;
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
//归并[left,mid] [mid+1, right]到临时数组tmp
int begin1 = left;
int end1 = mid;
int begin2 = mid + 1;
int end2 = right;
int i = left;
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++];
}
//拷贝回原数组
for (int j = left; j <= right; j++)
{
a[j] = tmp[j];
}
}
1kw个数效率
3 非递归版本
非递归可以用循环模拟,也可以用栈模拟,也可以用队列模拟。队列模拟类似层序遍历。
总体思路框架:
第一趟以间距为1,进行归并;第二趟以间距为2,进行归并…第i躺以间距为2^(i-1)进行归并,只要间距还小于n,就继续归并,类似一种层序遍历。
有问题的代码
void MergeSortNonR(int* a, int n)
{
assert(a);
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//归并[i,i+gap - 1] [i + gap, i + 2 * gap - 1]
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 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++];
}
}
//把当前tmp拷贝回去以应对下一次归并
for (int k = 0; k < n; k++)
{
a[k] = tmp[k];
}
gap *= 2;
}
}
但是这么写有很大的问题,可能会导致越界问题,如下:
测试用例
int a[] = { 2,56,9,3,5,1,-2,6,14,2,56,9,3,5,1,-2,6,14,2,56,9,3,5,1,-2,6,14,2,56,9,3,5,1,-2,6,14 };
//n = 35
我们知道出现随机值一般都是越界的问题。
加上每次归并的两个区间打印出来方便我们确定问题(我们这里的测试用例的n=35):
通过观察可以分析出原因:begin1不可能越界(for循环的i控制的),但end1、begin2、end2都有可能越界,如上图。
技巧:
快捷键:VS拷贝快捷键ctrl+d 删除快捷键ctrl+shift+l
条件断点:
经过分析这里有三种情况(n=35):
- end1越界了,后面的都越界了
- end1没越界,begin2越界了,end2因此也越界了
- 前面的没越界,end2越界了
既然你的区间会越界,我们的思路就是是修正区间(因为我们归并的时候并不会要求归并的两个区间长度要相等,修正区间别让他越界就是),我们调整end1、begin2、end2。
if (end1 >= n)
{
end1 = n - 1;
}
if (begin2 >= n)
{
begin2 = n - 1;
}
if (end2 >= n)
{
end2 = n - 1;
}
但是这样修正后仍然存在另一个可能越界的地方:
我们的测试用例是:
int a[] = { 10,6,7,1,8,9,4,2,5 };
这个地方begin1,end1和begin2、end2都修正成了8的时候,先在上面的while循环中拷贝[begin2, end2]区间中的5后,j++,下来在begin1仍然小于end1,[begin1, end1]又会再拷贝一次,此时j=9,把tmp[9]改了就已经tmp越界了。
这个地方修正方法思路一个是在下面再加一层条件防止它越界。
while (j < n && begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (j < n && begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
或者这样修正,如果end1越界了,我们就让end2和begin2变成一个不存在的区间,begin2>end2就不会进下面的while循环。
两种修正的思想都是在你要越界tmp的时候修正,让他不要越界。
if (end1 >= n)
{
end1 = n - 1;
}
if (begin2 >= n)
{
begin2 = n;
end2 = n - 1;
}
if (end2 >= n)
{
end2 = n - 1;
}
总代码:
void MergeSortNonR(int* a, int n)
{
assert(a);
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
while (gap < n)
{
//if (gap = 8)
//{
// int m = 0;
//}
for (int i = 0; i < n; i += 2 * gap)
{
//归并[i,i+gap - 1] [i + gap, i + 2 * gap - 1]
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
if (end1 >= n)
{
end1 = n - 1;
}
if (begin2 >= n)
{
begin2 = n;
end2 = n - 1;
}
if (end2 >= n)
{
end2 = n - 1;
}
printf("[%d,%d] [%d,%d] ",
begin1, end1, begin2, end2);
int j = i;
if (j == 8)
{
int m = 3;
}
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++];
}
}
printf("\n");
//把当前tmp拷贝回去以应对下一次归并
for (int k = 0; k < n; k++)
{
a[k] = tmp[k];
}
gap *= 2;
}
}
另一种思路,我们可不可以把拷贝放到while里面来呢,也就是说,像递归那样,归并一部分拷贝一部分。
这时,end1越界或begin2越界不需要处理,因为end1越界相当于第二个区间不存在,并且第一个区间已经经过上次归并且在while里头的拷贝后,已经在a中且有序了,只不过end1越出去了;begin2越界更好说了,第二个区间不存在,上次循环已经拷到原数组里头去了,不用归并了,所里这俩情况直接break就好。
如果end2越界了,那说明其他的第二个区间里是有值的,因此要修正end2为n-1,走归并拷贝的逻辑。
调整后的代码如下:
void MergeSortNonR(int* a, int n)
{
assert(a);
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//归并[i,i+gap - 1] [i + gap, i + 2 * gap - 1]
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++];
}
//归一部分 拷贝一部分
for (int k = i; k <= end2; k++)
{
a[k] = tmp[k];
}
}
gap *= 2;
}
}
4 时间复杂度
与快速排序类似,归并排序也是不断地二分二分,每一层归并回去的效率是O(n),层数有logn层,时间复杂度就是O(nlogn)。
五、计数排序
计数排序是一种非比较排序,它不依赖于比较大小得到顺序。
思想
取当前数组的最大值和最小值,然后开一个最大值减最小值+1个大小的数组,初值赋0,遍历一遍原数组,如果某个元素出现了一次,就在元素-最小值(一种映射的思想)的位置++一次表示次数加1,最后按照开出来的数组的非零值,下标为i,对应值就是i+min,把原数组排序就行。
不过当前的思想有一定的问题,如果最小值是负数,在下标是
时间复杂度
遍历一次取最大最小,时间复杂度O(N),遍历一次记次数,O(N),遍历一次写回去O(max - min + 1)
总时间复杂度O(N + max - min + 1)。
计数排序数据分布表集中的数据,这样时间复杂度可以接近O(N),否则会造成空间和时间的浪费。
可视化
代码
void CountSort(int* a, int n)
{
assert(a);
int min = a[0], max = 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* count = (int*)calloc(range,sizeof(int));
if (count == NULL)
{
printf("malloc fail\n");
exit(-1);
}
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i] > 0)
{
a[j++] = i + min;
count[i]--;
}
}
}
1kw个随机数效率测试
适用范围
适合对范围比较集中的整数数组进行排序。当范围较大,或者数据类型是浮点数和字符串就不合适。
六、各种排序的总结与排序的稳定性
1 各个排序的对比
2 稳定性
以数组中相同的值在排序后位置是否变化来判断排序是否是稳定的,如果排序后位置可能会变化,就是不稳定的,如果排序后位置一定不变,就是稳定的。
意义:对于稳定的排序,如果有以下场景:按成绩发奖金,先交卷的在原数组中排名在前,后交卷的在原数组中排名在后面,我给前三名法奖学金1000、800、600,如果前三名的成绩是100、99、99,如果我的排序算法是稳定的,那先交卷的就会排名在前(和原数组相比位置不变),这样就很好的满足了我们的规则。
直接插入排序是稳定的:控制只有比前面的值小才往前挪动,就可以做到稳定。
希尔排序是不稳定的:因为相同的值可能在预排序的时候分到不同的组,预判可能会让他们的位置变化。
直接选择排序是不稳定的:因为我们在交换值的时候可能把相等的值的顺序给变了
堆排序是不稳定的:如这个反例(堆顶的5原本在前面,换到后面去了)
冒泡排序是稳定的。
快速排序是不稳定的:因为单趟时和key相等的值可以在左边也可以在右边,然后你放key的时候位置就会变化。如5 1 5 5。
计数排序是不稳定的:因为计完数以后我并不知道原来的位置。
归并排序是稳定的:相等的时候下左边的。