以下所有动图均来源于一像素博客园
以下代码均使用C++编写
完整代码请到这里下载
稳定排序算法:冒泡排序、插入排序、归并排序
时间复杂度不受数据影响:选择排序、归并排序、堆排序
时间复杂度基本小于n2:希尔排序、快速排序、归并排序、堆排序
一、冒泡排序(Bubble Sort)
1.基本原理:
一共n个数,nums=[a1,a2,a3….an]。一共遍历n-1次。
(1)第1次遍历:比较a1和a2,如果a1>a2,则交换两个数的位置,否则不变;再比较a2和a3,如果a2>a3,则交换两个数的位置,否则不变……一直比较到an-1和an。可以看到经过第一次排序之后,an应该是序列中最大的数所在的位置。
(2)第2次遍历:比较a1和a2,如果a1>a2,则交换两个数的位置,否则不变……一直比较到an-2和an-1,经过第二次排序之后,an-1应该是序列中第二大的数所在的位置。(即除去an之后最大的数)。
……
(3)第n-1次遍历:比较a1和a2,交换原理同上,排序结束。
2.动图演示:
3.代码实现:
void BubbleSort(int* nums,int n)//冒泡排序
{
for(int i=0;i<n-1;i++)//i代表排序次数,一共n-1次
{
bool exchange=false;//监督变量,判断一次排序中是否发生了交换事件
for(int j=0;j<n-i-1;j++)//j代表每次拿来比较的数的下标,每次都从0开始,上限是n-i-1
{
if(nums[j]>nums[j+1])
{
exchange=true;
int temp=nums[j];
nums[j]=nums[j+1];
nums[j+1]=temp;
}
}
if(!exchange)//如果没有发生交换说明已经排好序了,直接退出
break;
}
}
4.复杂性分析
冒泡排序的时间复杂度:最好O(n),最坏O(n2),平均O(n2)
(1)最好的情况就是数组事先就是已经排好序的了,第1遍排序过后监督变量没变化就直接退出了,这种情况下只遍历了一遍数组,即循环走了n-1次,所以复杂度是O(n)。
(2)最坏的情况就是数组事先是倒序排好的,n-1次排序要排完排满,第1遍需要循环n-1次,第2遍需要循环n-2次……第n-1遍需要循环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(n2)。
(3)平均情况就是,有可能只排1遍就退出,这时循环了n-1次;有可能只排2遍就退出,这时循环了(n-1)+(n-2)次;也有可能排n-1遍才完成,这时循环了(n-1)+(n-2)+…2+1次。在数据足够多的情况下这些次数发生的概率是相同的,这时求的是离散型数据的期望。即
E
(
X
)
=
∑
k
=
1
N
−
1
x
k
⋅
p
k
E(X)=\sum_{k=1}^{N-1} x_k·p_k
E(X)=∑k=1N−1xk⋅pk,其中pk都是相同的,
p
k
=
p
=
1
n
−
1
p_k=p=\frac{1}{n-1}
pk=p=n−11,所以
E
(
X
)
=
(
n
−
1
)
∗
p
+
[
(
n
−
1
)
+
(
n
−
2
)
]
∗
p
+
[
(
n
−
1
)
+
(
n
−
2
)
+
(
n
−
3
)
]
∗
p
E(X)=(n-1)*p+[(n-1)+(n-2)]*p+[(n-1)+(n-2)+(n-3)]*p
E(X)=(n−1)∗p+[(n−1)+(n−2)]∗p+[(n−1)+(n−2)+(n−3)]∗p
+
…
+
[
(
n
−
1
)
+
(
n
−
2
)
+
.
.
.
+
2
+
1
]
∗
p
+…+[(n-1)+(n-2)+...+2+1]*p
+…+[(n−1)+(n−2)+...+2+1]∗p
=
(
n
−
1
)
2
∗
p
+
(
n
−
2
)
2
∗
p
+
(
n
−
3
)
2
∗
p
+
.
.
.
2
2
∗
p
+
1
2
∗
p
=(n-1)^2*p+(n-2)^2*p+(n-3)^2*p+...2^2*p+1^2*p
=(n−1)2∗p+(n−2)2∗p+(n−3)2∗p+...22∗p+12∗p
=
(
n
−
1
)
∗
n
∗
(
2
n
−
1
)
6
∗
p
=\frac{(n-1)*n*(2n-1)}{6}*p
=6(n−1)∗n∗(2n−1)∗p
=
2
n
2
−
n
6
=\frac{2n^2-n}{6}
=62n2−n
所以平均复杂度为O(n2)。
冒泡排序的空间复杂度为O(1)很好理解,我们只借助了exchange和temp两个变量。
冒泡排序是稳定排序算法,因为我们设置的交换条件不包括等号,所以相同的数是不会发生交换的,只会有比它们大的数放到后面去,而它们之间的相对前后位置是不变的。
二、选择排序(Selection Sort)
1.基本原理
一共n个数,nums=[a1,a2,a3….an]。一共遍历n-1次。
(1)第1次遍历:从a1开始遍历到an,找出最小的数,然后交换最小数的位置和a1的位置。
(2)第2次遍历:从a2开始遍历到an,找出最小的数,然后交换最小数的位置和a2的位置。
……
(3)第n-1次遍历:从an-1开始遍历到an,找出最小的数,然后交换最小数的位置和an-1的位置。排序结束。
2.动图演示
3.代码实现
void SelectionSort(int* nums,int n)//选择排序
{
for(int i=0;i<n-1;i++)//i代表排序次数,一共n-1次
{
int min_id=i;//每次遍历查找最小数的下标
for(int j=i+1;j<n;j++)//j代表遍历下标,每次都从i+1开始,遍历到最后一个数
{
if(nums[j]<nums[min_id])
min_id=j;
}
if(min_id!=i)//如果最小数下标和最初位置不等则交换
{
int temp=nums[i];
nums[i]=nums[min_id];
nums[min_id]=temp;
}
}
}
4.复杂性分析
选择排序的时间复杂度:最好、最坏、平均都是O(n2)。
这个很好理解,因为无论原始数据如何分布,我们都必须把遍历过程全部做完,某一次遍历完发现min_id没变,只能说明min_id是当前最小数,不能保证后面的数也是按顺序排好的。
选择排序的空间复杂度为O(1)也很好理解,我们只借助了min_id和temp两个变量。
选择排序是不稳定排序算法,比如原始数据是[51,52,2],当我们第1次遍历时51会跟2交换,排序后数据变成了[2,52,51],也就是原始数据靠前的5排序后变成了靠后,所以选择排序不能保证稳定性。
三、插入排序(Insertion Sort)
1.基本原理
一共n个数,nums=[a1,a2,a3….an]。一共遍历n-1次。第一个数默认是排好的。
(1)第1次遍历:以a2为选取数
- 将a2与a1比较,
——如果a2不比a1小,则结束本次遍历。
——如果a2比a1小,则交换位置。
(2)第2次遍历:以a3为选取数
- 将a3与a2比较,
——如果a3不比a2小,则结束本次遍历。
——如果a3比a2小,则交换位置,
————然后将a2(交换后的a2,即交换前的a3,也就是选取数)与a1比较,
————如果a2不比a1小,则结束本次遍历。
————如果a2比a1小,则交换位置。
……
(3)第n-1次遍历:从an为选取数
- 将an与an-1比较,
——如果an不比an-1小,则结束本次遍历。
——如果an比an-1小,则交换位置,
————然后将an-1(交换后的an-1,即交换前的an,也就是选取数)与an-2比较,
————如果an-1不比an-2小,则结束本次遍历。
————如果an-1比an-2小,则交换位置,
——————然后将an-2(交换后的an-2,即交换前的an-1,也就是选取数)与an-3比较,
——————如果an-2不比an-3小,则结束本次遍历。
——————如果an-2比an-3小,则交换位置,
……
——————————————————————然后将a2(交换后的a2,即交换前的a3,也就是选取数)与a1比较,
——————————————————————如果a2不比a1小,则结束本次遍历。
——————————————————————如果a2比a1小,则交换位置。
即每次都从k出发,然后逐个与k-1,k-2……2,1这些序列的数比较,发现k的数比较小则交换位置继续遍历,而一旦发现k的数不小于当前数,则立即停止本次遍历,然后从k+1出发再进行一次遍历,重复上述过程直到排序完成。要注意的是,我们每一次比较都是拿选取数和其他数比较。
2.动图演示
3.代码实现
void InsertionSort(int* nums,int n)//插入排序
{
for(int i=1;i<n;i++)//i代表选取数的下标
{
for(int j=i;j>0;j--)//j代表从i-1开始比较,直到找到合适位置
{
if(nums[j]<nums[j-1])//如果选取数比当前数小则交换
{
int temp=nums[j-1];
nums[j-1]=nums[j];
nums[j]=temp;
}
else//如果选取数不比当前数小
break;
}
}
}
4.复杂性分析
插入排序的时间复杂度:最好O(n),最坏O(n2),平均O(n2)。
其实插入排序的复杂度情况分析跟冒泡排序非常类似。
(1)最好的情况依然是数组事先就是已经排好序的了,每次第二层循环一进来就判断选取数不小于当前数,所以就直接跳出循环,这样的话外层循环走了n-1次,里面每次都只有1次,所以复杂度是O(n)。
(2)最坏的情况就是数组事先是倒序排好的,n-1次排序要排完排满,每一次都要从选取数一直比较到第1个数,第1遍需要循环1次,第2遍需要循环2次……第n-1遍需要循环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(n2)。
(3)平均情况就是,我们可以确认无论什么数据都必须遍历n-1次,然而在每次遍历之中的循环次数我们是不能确认的,最好的时候每次只循环1次,最差的时候每次循环要满,所以我们可以知道,插入排序需要的次数区间为【n-1,
n
∗
(
n
−
1
)
2
\frac{n*(n-1)}{2}
2n∗(n−1)】,区间里的每一个数字发生的概率是相同的,求此离散型数据的期望。我们假设这个区间里一共有m个数,即
E
(
X
)
=
∑
k
=
1
m
x
k
⋅
p
k
E(X)=\sum_{k=1}^{m} x_k·p_k
E(X)=∑k=1mxk⋅pk,其中pk都是相同的,
p
k
=
p
=
1
m
p_k=p=\frac{1}{m}
pk=p=m1,所以
E
(
X
)
=
(
n
−
1
)
∗
p
+
[
(
n
−
1
)
+
1
]
∗
p
+
[
(
n
−
1
)
+
2
]
∗
p
+
…
+
n
∗
(
n
−
1
)
2
∗
p
E(X)=(n-1)*p+[(n-1)+1]*p+[(n-1)+2]*p+…+\frac{n*(n-1)}{2}*p
E(X)=(n−1)∗p+[(n−1)+1]∗p+[(n−1)+2]∗p+…+2n∗(n−1)∗p
=
m
∗
[
(
n
−
1
)
+
n
∗
(
n
−
1
)
2
]
2
∗
p
=\frac{m*[(n-1)+\frac{n*(n-1)}{2}]}{2}*p
=2m∗[(n−1)+2n∗(n−1)]∗p
=
n
2
+
n
−
2
4
=\frac{n^2+n-2}{4}
=4n2+n−2
所以复杂度为O(n2)。
插入排序的空间复杂度为O(1),我们只借助了temp这一个变量。
插入排序是稳定排序算法,同样是因为我们没有设置相等的时候交换,当一个数向前遍历到相等的数的时候就会自动停下,从而保证了排序的稳定。
四、希尔排序(Shell Sort)
1.基本原理
例:nums=[0,5,2,3,6,8,9,1]一共8个数
(1)我们首先令增量gap=n/2=4。
这样从a1开始,a1和a5成为一组,a2和a6成为一组,a3和a7成为一组,a4和a8成为一组,对于这四组,每组组内进行插入排序,这样一次排序之后就会变成nums=[0,5,2,1,6,8,9,3]。
(2)我们令gap=gap/2=2。
这样从a1开始,a1、a3、a5、a7成为一组,a2、a4、a6、a8成为一组,每组组内进行插入排序,这样二次排序之后就会变成nums=[0,1,2,3,6,5,9,8]。
(3)我们令gap=gap/2=1。
这样从a1开始,a1、a2、a3、a4、a5、a6、a7、a8成为一组,组内进行插入排序,这样三次排序之后就会变成nums=[0,1,2,3,5,6,8,9]。
这就是希尔排序的过程,首先确定一个增量gap,从a1开始把a1、a1+gap、a1+2*gap等数包含到一个组里,对组内的数进行插入排序;然后不停缩小这个增量序列,直到gap=1时(必须要有)变为全体进行插入排序。
希尔排序gap的选择其实是个比较难的数学问题。上面例子中gap的选择是希尔本人提倡的方式,即每次都对半,然而这样的选择其实是效率很低的,有时甚至会使复杂度降到O(N2)。
有一种广泛接受的gap选择公式是gap=gap*3+1。因为最后一次gap一定等于1,所以我们可以逆向反推前面的gap的值应该为1,4,13,40,121,364,1093……,对于一个数组,只需要从小于数组个数的最大值开始设定gap就可以了。比如一个数组有700个数,那我们就设定初始gap=364,然后下一次排序gap=121,再下一次gap=40,这样一直到gap=1。
2.动图演示
3.代码实现
void ShellSort(int *nums,int n)//希尔排序
{
int gap=1;
while(gap*3+1<n)
gap=gap*3+1;
while(gap>0)
{
for(int i=0;i<gap;i++)//i代表组数,组数其实就是gap
{
//下面是插入排序
for(int j=i+gap;j<n;j=j+gap)//j代表循环从组内第2个数开始,每个数间隔gap
{
for(int k=j;k>=gap;k=k-gap)//k代表组内每个数都向前比较,直到找到合适位置
{
if(nums[k]<nums[k-gap])//如果选取数比当前数小则交换
{
int temp=nums[k-gap];
nums[k-gap]=nums[k];
nums[k]=temp;
}
else
break;
}
}
}
gap=(gap-1)/3;
}
}
4.复杂性分析
希尔排序时间复杂度分析方法太太太麻烦了,都可以写成一篇论文了,所以我就只记住结论就行。最好O(n),最坏O(n2),平均O(n1.3)。
希尔排序的空间复杂度为O(1),我们只借助了gap和temp这两个变量。
希尔排序是不稳定排序算法,比如nums=[21,3,22,1,0],初始gap=4,则[21,0]为一组,其他数字各自为一组,然后第一次排序后为[0,3,22,1,21],即最初开头那个2和0交换了位置,跑到了第二个2的后面。所以希尔排序不是稳定排序算法。
五、快速排序(Quick Sort)
1.原理
可以看到经过第一次排序之后,我们以基准数为标准,把原始数据分成了左右两部分,左边都比基准数小,右边都比基准数大。
然后我们把基准数的左边和右边分别取出来,对取出的部分单独进行排序,这样我们以新的基准数为标准把数又分成了两部分,重复上述过程一直到基准数的左右两边最多只有一个元素为止。
2.动图演示
3.代码实现
代码实现包括两种:递归和非递归。两种方式的实现机制其实是一样的。
我们可以看到一次排序其实就是以基准数为标准把数组分成左右两部分。我们只要知道一次排完序之后基准数的位置,就能知道新的待排序序列的位置。比如说待排的数组给定了left=0,right=9,我们排好序之后发现基准数下标为5。所以新的待排序列就是0-4和6-9两段,然后对这两段再进行排序,以此循环迭代。
我们可以把一次排序过程当成一次划分区间的过程,把它单独写成一个函数,在另外一个函数内只要不停的给定left和right的初始值再调用划分函数即可。
ξ \xi ξ一次快速排序划分过程:
int QuickSortSplit(int *nums,int n,int left,int right)//快速排序一次划分
{
if(left<0 || right<=0 || left>n-1 || right>n-1 || left>=right)
return -1;
int i=left;//左指针
int j=right;//右指针
int base=nums[left];//基准数
while(i!=j)
{
while(nums[j]>=base && j>i)//移动右指针找到小于基准数的数
j--;
nums[i]=nums[j];
while(nums[i]<=base && j>i)//移动左指针找到大于基准数的数
i++;
nums[j]=nums[i];
}
nums[i]=base;//i和j相遇后直接填上基准数
return i;//返回基准数下标
}
(1)递归方式
递归实现就是每次获得基准数id之后,就把新的两段参数的left和right传入函数中,递归调用。
void QuickSortRecursion(int *nums,int n,int left,int right)
{
if(left<0 || right<=0 || left>n-1 || right>n-1 || left>=right)
return;
int baseid=QuickSortSplit(nums,n,left,right);//一次划分并获得基准数下标
if(baseid!=-1)
{
QuickSortRecursion(nums,n,left,baseid-1);//继续排基准数左边的部分
QuickSortRecursion(nums,n,baseid+1,right);//继续排基准数右边的部分
}
}
(2)非递归方式
非递归实现就是用一个栈,每次都将left和right入栈,然后取两次top排一次序,取两次top排一次序,直到栈空为止。
void QuickSortUnRecursion(int *nums,int n,int left,int right)//快速排序非递归调用
{
if(left<0 || right<=0 || left>n-1 || right>n-1 || left>=right)
return;
stack<int> pos;//定义栈来保存指针对的位置
pos.push(right);
pos.push(left);
while(!pos.empty())
{
int i=pos.top();//获取左指针
pos.pop();
int j=pos.top();//获取右指针
pos.pop();
int baseid=QuickSortSplit(nums,n,i,j);//一次划分并获得基准数下标
if(baseid!=-1)
{
//插入新的左区间
pos.push(baseid-1);
pos.push(i);
//插入新的右区间
pos.push(j);
pos.push(baseid+1);
}
}
}
4.复杂性分析
快速排序时间复杂度,最好O(nlog2n),最坏O(n2),平均O(nlog2n)。
最好的情况是基准数每次都能把原始数据平分,这样的话就相当于构造了一棵二叉树。第1层有1个根节点,权值为n,第2层有2个节点,分别都为
n
2
\frac{n}{2}
2n(实际上应该为
n
−
1
2
\frac{n-1}{2}
2n−1,因为要去掉基准数,但我们为了计算方便采用
n
2
\frac{n}{2}
2n,实际上这两种计算出来的结果在数量级上是相同的,大家可以自行验证);第3层有4个节点,分别都为
n
4
\frac{n}{4}
4n……第L层有2L-1个节点,分别都为
n
2
L
−
1
\frac{n}{2^{L-1}}
2L−1n,也就是1。所以可以得出n=2L-1,所以L=
⌊
l
o
g
2
n
+
1
⌋
\left \lfloor log_2~n+1 \right \rfloor
⌊log2 n+1⌋(向下取整)。因为每次都要平分数据,所以i和j指针一定是到中间相遇,就相当于每一次划分都是正好遍历完一整遍数组,所以一共需要的次数就为
1
∗
n
+
2
∗
n
2
+
4
∗
n
4
+
.
.
.
2
L
−
1
∗
n
2
L
−
1
=
n
∗
L
=
n
∗
⌊
l
o
g
2
n
+
1
⌋
1*n+2*\frac{n}{2}+4*\frac{n}{4}+...2^{L-1}*\frac{n}{2^{L-1}}=n*L=n*\left \lfloor log_2~n+1 \right \rfloor
1∗n+2∗2n+4∗4n+...2L−1∗2L−1n=n∗L=n∗⌊log2 n+1⌋,所以最好时间复杂度为最好O(nlog2n)。
最坏的情况其实很好明白,原始的数据已经是有序的,比如nums=[1,2,3,4,5,6]。我们以1为基准数,j指针需要判别n次碰到i指针,然后分割成两个区间,左区间为空,右区间为[2,3,4,5,6],然后再以2为基准数,j指针需要判别n-1次碰到i指针,然后分割成两个区间,左区间为空,右区间为[3,4,5,6],重复这个过程会发现,每次j都要从尾找到头,然而每次都只能分割一个元素(也就是基准数本身)出去。所以就需要
(
n
)
+
(
n
−
1
)
+
(
n
−
2
)
+
…
…
+
2
+
1
=
n
∗
(
n
+
1
)
2
(n)+(n-1)+(n-2)+……+2+1=\frac{n*(n+1)}{2}
(n)+(n−1)+(n−2)+……+2+1=2n∗(n+1)次,所以最坏时间复杂度为O(n2)。快速排序最坏的情况其实就退化成冒泡排序了。
ξ
\xi
ξ以下原理来源于算法导论:
快速排序的平均时间复杂度分析有点麻烦,其实我们只要理解一个前提就行:
差划分的代价可以被吸收到好划分的代价中去,而得到的划分结果也是好的。
这也是快速排序的平均运行时间更接近于其最好情况,而非最坏情况的原因。
例如,假设划分算法总是产生9:1的划分,乍一看,这种划分是很不平衡的。此时,我们得到的快速排序时间复杂度的递归式为:T(n) = T(9n/10) + T(n/10) + cn
这里我们显示地写出了
Θ
\Theta
Θ(n)项中所隐含的常数c,图7-4显示了这 一递归调用所对应的递归树,树中每一层的代价都是cn,直到在深度log10n=
Θ
\Theta
Θ(lgn)处达到递归的边界条件时为止,之后每层代价至多为cn。递归在深度为log10/9n=
Θ
\Theta
Θ(lgn)处终止。因此,快速排序的总代价为O(nlgn)。因此,即使在递归的每一层上都是9: 1的划分,直观上看起来非常不平衡,但快速排序的运行时间是 O(nlgn),与恰好在中间划分的渐近运行时间是一样的。实际上,即使是 99: 1的划分,其时间复杂度仍然是 O(nlgn)。事实上,任何一种常数比例的划分都会产生深度为
Θ
\Theta
Θ(lgn)的递归树,其中每一层的时间代价都是O(n)。因此,只要划分是常数比例的,算法的运行时间总是O(nlgn)。
为了对快速排序的各种随机情况有一个清楚的认识,我们需要对遇到各种输入的出现频率做出假设。快速排序的行为依赖于输入数组中元素的值的相对顺序,而不是某些特定值本身。这里我们假设输入数据的所有排列都是等概率的。
当对一个随机输入的数组运行快速排序时,想要像前面非形式化分析中所假设的那样,在每一层上都有同样的划分是不太可能的。我们预期某些划分会比较平衡,而另一些则会很不平衡。
在平均情况下,PARTITION所产生的划分同时混合有“好”和“ 差 ”的划分。此时,在与 PARTITION平均情况执行过程所对应的递归树中,好和差的划分是随机分布的。基于直觉,假设好和差的划分交替出现在树的各层上,并且好的划分是最好情况划分,而差的划分是最坏情况划分,图7-5(a)显示出了递归树的连续两层上的划分情况。在根结点处,划分的代价为n,划分产生的两个子数组的大小为n-1和0,即最坏情况。在下一层上,大小为n-1的子数组按最好情况划分成大小分别为(n-1)/2-1和(n-1)/2的子数组。在这里,我们假设大小为0的子数组的边界条件代价为1。
在一个差的划分后面接着一个好的划分,这种组合产生出三个子数组,大小分别为0、(n-1)/2-1和(n-1)/2。这一组合的划分代价为
Θ
\Theta
Θ(n)+
Θ
\Theta
Θ(n一1)=
Θ
\Theta
Θ(n)。该代价并不比图7-5(b)更差。在图7-5(b)中,一层划分就产生出大小为(n-1)/2的两个子数组,划分代价为
Θ
\Theta
Θ(n)。但是,后者的划分是平衡的!从直观上看, 差划分的代价
Θ
\Theta
Θ(n一1)可以被吸收到好划分的代价 中去,而得到的划分结果也是好的。因此,当好和差的划分交替出现时,快速排序的时间复杂度与全是好的划分时一样,仍然是O(nlgn)的。区别只是O符号中隐含的常数因子要略大一些。
在空间复杂度上,非递归的快速排序空间复杂度为O(1)这是显然的;递归的时候因为需要栈来保存递归局部变量,在一次递归中空间复杂度是常数级别的,所以递归的总空间复杂度就是二叉树的递归深度。最差的情况下每次只划分一个数出去,二叉树就变成一个链表,空间复杂度就为O(n),最好情况和平均情况下二叉树深度L=
⌊
l
o
g
2
n
+
1
⌋
\left \lfloor log_2~n+1 \right \rfloor
⌊log2 n+1⌋,所以空间复杂度为O(log2 n)。
快速排序是不稳定排序。比如nums=[21,22,5,1]。第一次排序完为[1,22,21,5],这样2的前后位置改变了。
六、归并排序(Merge Sort)
1.基本原理
归并排序的原理其实就是先分解再合并,将原始数组不断折半拆分,直到拆到不能分;然后开始两个块合并成一个块,不断地合并直到把所有块合并好。下图是原理演示,绿色部分就是拆分过程,蓝色部分就是结合过程。
拆分的原理很简单,就是折半拆分。
结合的时候原理如下,两个数组都从头开始比较,每次都挑出较小的数加入到新数组中,被挑选的数字指针右移。这样一直比较,直到某个数组被遍历完,然后把没被遍历完的数组剩下的数字全部补到新数组中,这样新数组就是两个数组的排序组合了。我称下图为归并排序的一次子数组结合过程。
2.动图演示
3.代码实现
归并排序的实现过程其实和快速排序非常类似,它也分成递归和非递归两种,递归的方法就直接切割区间然后组合排序,非递归的方法就用栈来保存需要操作的切割区间然后组合排序。
在用代码实现之前有几个前提我们要清楚:
1.归并排序的切割和结合过程是正好对应的,也就是说我们给定数组下标头和尾分别是5和8,那切割过程就知道要切成5-6、7-8这两个子数组,结合过程就知道要去结合5-6、7-8这两个子数组。所以我的代码中并没有传递mid这个参数,我只传了头和尾。
2.归并排序跟上面的其他排序不同,不是在原数组内不停地进行交换,而是按照图二的方式把两个子数组结合。所以我们需要一个新的数组来保存每次子数组结合的结果,再把这个结果覆盖到原数组去。容易想到每次在子数组结合函数内申请last-first+1这么大的空数组来接受组合结果,即需要结合的两个子数组数据量之和。
这样存在的问题就是,如果我们每次都在组合的时候再新建数组,时间的开销会非常大,尤其是数组数的个数很多时。解决的办法就是事先定义好一个跟原始数据一样大的空数组arr(因为最后一次结合的结果正好是原始数据大小),每次作为参数传递过去。
我们先用代码实现子数组结合过程。
ξ
\xi
ξ一次归并排序子数组结合过程:
void MergeSortCombine(int *nums,int *arr,int first,int last)//归并排序一次结合过程
{
int mid=(last-first)/2+first;
int i=first;//数组一的头部
int j=mid+1;//数组二的头部
int k=0;//新数组的头部
while(i<=mid && j<=last)//实现图二的结合排序过程
arr[k++]=nums[i]<nums[j]?nums[i++]:nums[j++];
while(i<=mid)//如果数组一还没遍历完
arr[k++]=nums[i++];
while(j<=last)//如果数组二还没遍历完
arr[k++]=nums[j++];
for(int m=0;m<k;m++)//将遍历完的结果覆盖到原数组
nums[m+first]=arr[m];
}
(1)递归实现方法
有了结合过程我们只要不停的划分区间,然后把区间传递给结合函数,结合函数就会自动对区间进行排序了。
void MergeSortRecursion(int *nums,int *arr,int first,int last)//归并排序递归实现方法
{
if (first>=last)
return;
int mid=(last-first)/2+first;
MergeSortRecursion(nums,arr,first,mid);//继续拆分左边部分
MergeSortRecursion(nums,arr,mid+1,last);//继续拆分右边部分
MergeSortCombine(nums,arr,first,last);//进行一次结合排序
}
(2)非递归实现方法
非递归方法的想法就是我们把划分出来的区间全部存到栈里,每次都从栈里取一组数进行结合,这样不停的结合下去就是最后的排序结果了。
void MergeSortUnRecursion(int *nums,int *arr,int first,int last)//归并排序非递归实现方法
{
if (first>=last)
return;
stack<int> pos1;//pos1用于不断划分子区间
stack<int> pos2;//pos2用于保存需要排序的子区间
pos1.push(first);pos1.push(last);
pos2.push(first);pos2.push(last);
while(!pos1.empty())//不断划分子区间并保存
{
int j=pos1.top();pos1.pop();
int i=pos1.top();pos1.pop();
int mid=(j-i)/2+i;
if(mid>i)
{
pos1.push(i);pos1.push(mid);
pos2.push(i);pos2.push(mid);
}
if(j>mid+1)
{
pos1.push(mid+1);pos1.push(j);
pos2.push(mid+1);pos2.push(j);
}
}
while(!pos2.empty())//对保存的每个区间进行结合排序
{
int j=pos2.top();pos2.pop();
int i=pos2.top();pos2.pop();
MergeSortCombine(nums,arr,i,j);
}
}
4.复杂性分析
归并排序的时间复杂度:最好、最坏、平均都是O(nlog2n)。
我个人的认识,归并排序分为分割和结合两个部分,分割部分其实就是把数组分割成一个一个,时间为O(n);而结合部分,一个p个数的数组和q个数的数组结合需要O(p+q),所以可以看到在结合树部分,整一层需要的时间是O(n),整个结合树深度是
⌊
l
o
g
2
n
⌋
\left \lfloor log_2~n \right \rfloor
⌊log2 n⌋,所以结合部分时间复杂度是O(nlog2n),整体的时间复杂度是O(n+nlog2n)=O(nlog2n)。
归并排序的空间复杂度:非递归方法是O(1),递归方法是O(n)。
递归部分的空间主要还是在于递归栈需要记住left和right局部变量,而且递归是切割阶段,结合阶段是一个外部函数,所以切割一共需要n次,每次空间消耗是O(1),所以一共需要O(n)。
归并排序是稳定排序算法,因为切割过程不涉及顺序更换,结合过程只要保证大小相等的时候nums1优先进行新数组就能保证稳定。
七、堆排序(Heap Sort)
1.基本原理
堆就是一颗完全二叉树。比如下图就是我们根据原始数据建成的堆。
堆分为大根堆和小根堆。
大根堆:父节点不小于左右子节点;
小根堆:父节点不大于左右子节点。
所以堆排序第一步就是要把原始数组构建成一个大根堆或者小根堆,我们这里以大根堆为例。
这就是从初始数据建立大根堆的过程。可以看到经过一次建堆之后,数组中最大的数来到了堆顶,这时我们把堆顶元素和末尾元素交换,然后把末尾元素输出。(可以理解成跟原始数据斩断关系)
把堆顶元素交换然后输出之后,我们发现堆又不平衡了,不是大根堆了,但是因为我们只交换了堆顶,只有堆顶是不平衡的,所以我们只需要调整堆顶就能再得到平衡的大根堆。
调整完之后输出了当前的最大数8,我们发现堆顶又不平衡了,所以我们再调整堆顶,然后再输出最大数,堆排序的步骤就是重复调整堆、输出到末尾这两个过程。下面是排序的全过程。
2.动图演示
3.代码实现
我们把一个节点的调整过程单独写成一个函数。过程也就是看该节点的左右子节点是否有比该节点大的,如果有,就把子节点中较大数和父节点交换。交换完之后再看子节点树是否平衡,如果不平衡就接着调整子树,直到子树平衡或者到达叶节点。
void SetOneNode(int *nums,int n,int node)//调整堆中某一个节点直到平衡
{
if(2*node+1>n-1)//本身是叶节点
return;
int index=0;
if(2*node+2>n-1)//只有左子节点
index=2*node+1;
else//左右子节点都存在
index=nums[2*node+1]>nums[2*node+2]?2*node+1:2*node+2;//两个子节点中较大数的下标
if(nums[index]>nums[node])
{
int temp=nums[index];
nums[index]=nums[node];
nums[node]=temp;
SetOneNode(nums,n,index);//继续调整子树
}
}
有了调整一个节点直到平衡的代码,我们在初始建堆的时候,需要从最后一个非叶子节点开始调整到堆顶,而后每次只需要调整堆顶一个节点就可以了。
void HeapSort(int *nums,int n)//堆排序过程
{
if(n<=1)
return;
for(int i=n/2-1;i>=0;i--)//初始建堆,从最后一个非叶节点开始调整
SetOneNode(nums,n,i);
swap(nums[0],nums[n-1]);//交换堆顶到堆尾
int qun=n-1;
while(qun)
{
SetOneNode(nums,qun,0);//只调整堆顶平衡
swap(nums[0],nums[qun-1]);//交换堆顶到堆尾
qun--;//参加比较的数不断减少
}
}
4.复杂性分析
堆排序的时间复杂度:最好、最差、平均都是O(nlog2n)。
堆排序的时间复杂度分别两部分,第一是初始建堆,第二是每次输出堆顶之后调整堆顶平衡。
初始建堆的时间复杂度计算如下:
我们假设这是一棵满的完全二叉树(不满的情况下数量级是一样的),树有L层,初始建堆就从第L-1层开始,因为每一层的调整都要保证子树是平衡的,就比如第一层的调整必须要保证第L-1层的子节点都是平衡的,所以:
第L-1层,有2L-2个节点需要比较1次;
第L-2层,有2L-3个节点需要比较2次;
第L-3层,有2L-4个节点需要比较3次;
……
第3层,有22个节点需要比较L-3次;
第2层,有21个节点需要比较L-2次;
第1层,有20个节点需要比较L-1次;
所以初始建堆需要的时间:
S
1
=
2
L
−
2
∗
1
+
2
L
−
3
∗
2
+
2
L
−
4
∗
3
+
.
.
.
2
2
∗
(
L
−
3
)
+
2
1
∗
(
L
−
2
)
+
2
0
∗
(
L
−
1
)
S_1=2^{L-2}*1+2^{L-3}*2+2^{L-4}*3+...2^{2}*(L-3)+2^{1}*(L-2)+2^{0}*(L-1)
S1=2L−2∗1+2L−3∗2+2L−4∗3+...22∗(L−3)+21∗(L−2)+20∗(L−1)
2
∗
S
1
=
2
L
−
1
∗
1
+
2
L
−
2
∗
2
+
2
L
−
3
∗
3
+
.
.
.
2
3
∗
(
L
−
3
)
+
2
2
∗
(
L
−
2
)
+
2
1
∗
(
L
−
1
)
2*S_1=2^{L-1}*1+2^{L-2}*2+2^{L-3}*3+...2^{3}*(L-3)+2^{2}*(L-2)+2^{1}*(L-1)
2∗S1=2L−1∗1+2L−2∗2+2L−3∗3+...23∗(L−3)+22∗(L−2)+21∗(L−1)
2
∗
S
1
−
S
1
=
S
1
=
2
L
−
1
+
2
L
−
2
+
2
L
−
3
+
2
L
−
4
+
…
2
2
+
2
1
−
(
L
−
1
)
2*S_1-S_1=S_1=2^{L-1}+2^{L-2}+2^{L-3}+2^{L-4}+…2^{2}+2^{1}-(L-1)
2∗S1−S1=S1=2L−1+2L−2+2L−3+2L−4+…22+21−(L−1)
=
2
L
−
L
−
1
=
n
−
l
o
g
2
n
−
1
=
O
(
n
)
=2^{L}-L-1=n-log_2 n-1=O(n)
=2L−L−1=n−log2n−1=O(n)。
而后续每输出一次堆顶只需要调整堆顶本身就可以,调整堆顶需要比较L-1次,而输出对堆顶并调整这个动作需要重复n-1次,所以一共时间需要
S
2
=
(
L
−
1
)
∗
(
n
−
1
)
=
O
(
n
l
o
g
2
n
)
S_2=(L-1)*(n-1)=O(nlog_2n)
S2=(L−1)∗(n−1)=O(nlog2n)。
所以总的时间复杂度S=S1+S2=O(n)+O(nlog2n)=O(nlog2n)。
堆排序的空间复杂度是O(1),这是显然的。
堆排序是不稳定排序,比如nums=[51,52,3],排完序之后就是[3,52,51],交换了顺序。
实操比较:
用QueryPerformanceCounter计算各算法的执行时间,我们一起来看一下结果。
我随机生成了200000个数进行排序,可以看到,冒泡排序、选择排序、插入排序的花费时间都很高,而其他排序的时间数量级都很低,在0.05秒左右。其中最快的是递归快速排序,不过与其他相差不大。