排序:将一个记录的无序序列调整成为一个有序序列,使之按关键字递增或递减有序排列的过程。为了提高数据的查找效率,预先排序可以减少获得查找结果的时间。
关键字:对记录进行排序时的依据。
如:可以按学生姓名首字母对学生记录进行排序,也可以按学号对学生记录进行排序。
稳定的排序:如果待排序的记录中存在多个关键字相同的数据,排序后这些具有相同关键字的数据之间的相对次序保持不变。
不稳定的排序:如果待排序的记录中存在多个关键字相同的数据,排序后这些具有相同关键字的数据之间的相对次序发生变化。
内排序:排序过程中,排序数据在内存中处理,不涉及数据的内、外存交换。适用于数据量小的数据。
外排序:排序过程中,涉及数据的内、外存交换。
内排序是外排序的基础。
排序算法的性能由算法的时间和空间确定。
算法时间复杂度:求出算法所有原操作的执行次数T(n),与算法的执行时间成正比,即可以用T(n)来表示算法的执行时间。算法的时间复杂度一般作为问题规模n的函数,使用T(n)的数量级表示,即
T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))
常数阶O(1):一般一个没有循环(或有循环,但循环次数与问题规模n无关)的算法中原操作执行次数与问题规模n无关,执行时间看成O(1)。如定义变量、赋值、输入输出语句。
线性阶O(n):一个只有一重循环的算法中原操作执行次数与问题规模n的增长呈现线性增大的关系,执行时间看成O(n)。
其余常用的还有平方阶O(n2)、立方阶O(n3)、对数阶O(log2n)、指数阶O(2n)等。
不同时间复杂度的大小关系:
O ( 1 ) < O ( l o g 2 n ) < O ( n ) < O ( n l o g 2 n ) < O ( n 2 ) < O ( n 3 ) < O ( 2 n ) < O ( n ! ) Ο(1)<Ο(log_2n)<Ο(n)<Ο(nlog_2n)<Ο(n^2)<Ο(n^3)<Ο(2^n)<Ο(n!) O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n!)
算法空间复杂度:对一个算法在运行过程中临时占用的存储空间大小的量度。一般也作为问题规模n的函数,以数量级的形式给出,即
S ( n ) = O ( g ( n ) ) S(n)=O(g(n)) S(n)=O(g(n))
分类 | 算法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 | |||
最好 | 最差 | 平均 | ||||||
比较排序 | 插入排序 | 直接插入排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 | 简单 |
折半插入排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 | 简单 | ||
希尔排序 | O(n) | O(n1.5) | O(n1.3) | O(1) | 不稳定 | 较复杂 | ||
交换排序 | 冒泡排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 | 简单 | |
快速排序 | O(nlog2n) | O(n2) | O(nlog2n) | O(log2n) | 不稳定 | 较复杂 | ||
选择排序 | 简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 | 简单 | |
堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(1) | 不稳定 | 较复杂 | ||
二路归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 稳定 | 较复杂 | ||
非比较排序 | 基数排序 | O(d*(n+r)) d是位数,r是基数, n是序列中记录的数目 |
O(d*(n+r)) | O(d*(n+r)) | O(r) | 稳定 | 较复杂 | |
计数排序 | O(n+k) k是max-min+1, n是序列中记录的数目 |
O(n+k) | O(n+k) | O(k) | 稳定 | 较复杂 | ||
桶排序 | O(n) n是序列中记录的数目 |
O(n2) | O(n+C) C是常数,等于n+n×log2n-n×log2k, k是桶的个数 |
O(k) | 取决于桶内排序算法 | 较复杂 |
基于比较的排序算法主要有两种基本操作:
插入排序属于减治法的减一技术,即每一趟排序后将问题规模减少1。
基本思想:依次将待排序序列中的每一个记录插入到一个已排好序的序列中。
就像打扑克牌时整理分发下来的扑克。
排序过程:
(1)将整个待排序的记录序列划分成有序区和无序区,初始时有序区为待排序记录序列中的第一个记录,无序区包括所有剩余待排序的记录;
(2)将无序区的第一个记录插入到有序区的合适位置,从而使无序区减少一个记录,有序区增加一个记录。
(3)重复执行步骤(2),直到无序区中没有记录为止。
//直接插入排序
void InsertSort(int r[],int n) //待排序记录序列存储在r[1]~r[n]中
{
int i,j;
for(i = 1; i < n; i++) //从第2个记录开始执行插入操作
{
int temp = r[i]; //temp暂存待插记录,并作为观察哨兵防止寻找插入位置时数组下标越界
for(j = i-1; temp < r[j]; j--) //寻找插入位置
r[j+1] = r[j]; //下一条记录
r[j+1] = temp;
}
}
算法分析:
算法由两层循环嵌套而成,外层循环执行n-1次,内层循环执行次数取决于第i个记录前有多少个记录大于第i个记录。
最好情况下,待排序序列为正序,每趟只需要与有序序列最后一个记录比较一次,移动两次记录,则比较次数为n-1,记录的移动次数为2(n-1)。因此,最好情况下直接插入排序的时间复杂度为O(n)。
最坏情况下,待排序序列为倒序,在第i趟插入时,第i个记录必须与前面i-1个记录以及观察哨兵比较,并且比较一次就要做一次记录的移动,则比较次数为∑ni=2 i = (n+2)(n-1)/2,记录的移动次数为∑ni=2 ( i+1) = (n+4)(n-1)/2。因此,最坏情况下直接插入排序的时间复杂度为O(n2)。
平均情况下,待排序序列中各中可能排序的概率相同,在插入第i个记录时平均需要比较有序区中全部记录的一半。则比较次数为∑ni=2 i/2 = (n+2)(n-1)/4,移动次数为∑ni=2 (i+1)/2 = (n+4)(n-1)/4。因此,平均情况下直接插入排序的时间复杂性为O(n2)。
直接插入排序中将无序区的开头记录r[i]插入到有序区r[0…i-1]是采用顺序比较的方法。而由于有序区的记录是有序的,因此可以采用折半查找的方法在r[0…i-1]中找到插入位置,再通过移动记录进行插入,这样对直接插入排序的优化方法,称为折半插入排序。
折半插入排序在插入时利用了二分法的思想。
排序过程:
(1)将整个待排序的记录序列划分成有序区和无序区,初始时有序区为待排序记录序列中的第一个记录,无序区包括所有剩余待排序的记录;
(2)将无序区的第一个记录r[i]按折半查找的方法插入到有序区中找到的第一个大于r[i]的记录的位置p之后,p及其后记录后移一位,将r[i]插入位置p。如果有序区中没有记录大于r[i],则将r[i]放到有序区末尾从而使无序区减少一个记录,有序区增加一个记录。
(3)重复执行步骤(2),直到无序区中没有记录为止。
C语言代码:
//折半插入排序
void BinInsertSort(int r[],int n) //待排序记录序列存储在r[1]~r[n]中
{
for(int i = 1; i < n; i++) //从第2个记录开始执行插入操作
{
int low = 0;
int high = i-1;
int temp = r[i]; //temp暂存待插记录r[i],并作为观察哨兵防止寻找插入位置时数组下标越界
while(low <= high) //在r[low…high]中查找插入的位置
{
int mid = (low+high)/2; //取中间位置
if(temp < r[mid])
high = mid - 1; //插入点在左半区
else
low = mid + 1; //插入点在右半区
}
for(int j = i-1; j >= high+1; j--) //寻找插入位置high+1
r[j+1] = r[j]; //下一个记录
r[high+1] = temp; //在high+1处插入r[i]
}
}
算法分析:
折半插入排序的记录移动次数与直接插入排序相同,不同的是比较次数。在r[0…i-1]中查找插入r[i]的位置时,折半查找的平均关键字比较次数约为log2(i+1)-1,平均移动记录的次数为i/2+2,因此,平均时间复杂度为:
∑ i = 2 n ( l o g 2 ( i + 1 ) − 1 + i 2 + 2 ) = O ( n 2 ) \sum_{i=2}^n(log_2(i+1)-1+\frac{i}{2}+2) = O(n^2) i=2∑n(log2(i+1)−1+2i+2)=O(n2)
希尔排序又叫减少增量排序,是相较于折半插入排序来说,对直接插入排序的一种更优化的分组插入排序算法。
希尔排序在插入时利用了分治法的思想。
增量:序列的分组数,通常用d表示。
排序过程:
(1)取一个小于序列长度n的第一个增量d1(d1=n/2),将序列中的全部记录分为d1个组,将所有距离为d1的倍数的记录放在同一个组中;(希尔排序中增量的取法是每次/2并向下取整,即第二个增量d2=⌊d1/2⌋,依此类推…)
(2)在各个组内进行直接插入排序;
(3)重复执行步骤(1)(2),直到所取的增量dt=1(dt
笔试常考:希尔排序每趟不产生有序区,在最后一趟排序结束前,所有记录不一定归位了,但是在希尔排序每趟完成后数据越来越接近有序。
示例:
C语言代码:
//希尔排序
void ShellSort(int r[],int n)
{
for(int d = n/2; d > 0; d /= 2) //对增量d赋初值,每次遍历后将其减半
{
for(int i = d; i < n; i++) //对每个分组内的记录使用直接插入排序
{
int temp = r[i]; //暂存待插入记录r[i]的值
int j = i - d; //j在与i相差长度d的同组位置上
while(j >= 0 && temp < r[j]) //如果同组中i处的记录比j处的小
{
r[j+d] = r[j]; //将在r[i]前且比temp值大的记录(在j处)后移d位
j = j-d; //避免数组下标越界
}
r[j+d] = temp; //在j+d处插入r[i]
}
}
}
算法分析:
希尔排序的性能分析取决于增量序列的取法(不同取法最后一个增量必须等于1),因为它的时间是所取增量序列的函数。如果按照上述算法的取法,即d1=n/2,di+1=⌊di/2⌋(i≥1),每次后一个增量是前一个增量的1/2,则经过t=log2(n-1)次后dt=1。
希尔排序的时间复杂度难以分析,一般认为其平均时间复杂度为O(n1.3)。
当最后一趟增量d=1时,希尔排序与直接插入排序基本一致,为什么希尔排序的时间性能通常要优于直接插入排序呢?
①直接插入排序在初始序列为正序时所需时间最少。希尔排序就是利用这一特性,将序列不断地分组排序而趋于有序。
②当序列长度n较小时,n和n2的差别不大,进而直接插入排序的最好时间复杂度O(n)和最坏时间复杂度O(n2)差别不大。希尔排序开始时增量d较大,分组较多,每组中记录的数量就相对少,所以对各组组内排序时较快,而后增量d逐渐减小,分组数逐渐减少,各组记录数逐渐增多,但由于此时序列已经接近有序状态,因此使用直接插入排序也较快。
希尔排序中值使用到了i、j、d和temp这4个辅助变量,均与问题规模n无关,因此希尔排序的空间复杂度为O(1)。
希尔排序时一中不稳定的排序算法。
不稳定的排序算法:一般情况下,一个排序算法在排序过程中需要以较大的间隔交换记录或者把记录移动一个较大的距离时,称该排序方法是不稳定的(反之则为稳定的),因为这可能会把原来排在前面的记录移动到具有相同关键字的另一个记录的后面。
交换排序的基本思想是两两比较待排序记录的关键字,当这两个记录的次序相反时进行交换,直到没有反序的记录为止。
冒泡排序,又叫起泡排序,其基本思想是对无序区中的相邻记录关键字进行两两比较,反序则交换,直到没有反序的记录为止,这就使得关键字最小的记录如气泡一般逐渐往上漂浮直至水面。
冒泡排序使用到的是蛮力法的思想。
每一趟排序后最后的记录都是最大的记录。
排序过程:
(1)将整个待排序序列的记录序列划分为有序区和无序区,初始时有序区为空,无序区包括所有待排序的记录;
(2)对无序区从前向后地依次比较相邻记录,若反序则交换,从而使得值较小的记录向前移动,值较大的记录向后移动。
(3)重复执行步骤(2),直到无序区中没有反序的记录。
示例:
图示:
C语言代码:
//冒泡排序
void BubbleSort(int r[], int n)
{
for (int i = 0; i < n-1; i++) //第一趟冒泡排序的区间是[0,n-1]
for (int j = 0; j < n-1-i; j++) //每一趟比较前n-1-i个,即已排序好的最后i个不用比较
if (r[j] > r[j + 1]) //相邻两个记录反序时,将r[j]和r[j+1]交换
{
int temp = r[j];
r[j] = r[j + 1];
r[j + 1] = temp;
}
}
有些情况下,在第i趟时已经排好了序,但仍执行后面几趟的比较。实际上,一旦算法中某一趟比较时不出现任何记录交换,则可以结束算法。因此,冒泡排序有一种针对此的优化算法:就是立一个 flag,当在一趟序列遍历中记录没有发生交换,则证明该序列已经有序。
具体C语言代码实现如下:
//优化的冒泡排序
void BubbleSortBetter(int r[],int n)
{
for(int i = 0; i < n-1; i++) //第一趟冒泡排序的区间是[0,n-1]
{
bool exchange = false; //置exchange为假
for(int j = 0; j< n-1-i; j++) //每一趟比较前n-1-i个,即已排序好的最后i个不用比较
{
if(r[j] > r[j+1]) //相邻两个记录反序时,将r[j]和r[j+1]交换
{
exchange = true; //一旦有交换,置exchange为真
int temp = r[j];
r[j] = r[j+1];
r[j+1] = temp;
}
}
if(!exchange) break; //如果没有发生交换,说明序列已经排序好了,则结束算法
}
}
优化的冒泡排序算法分析:
算法的基本语句是比较操作,其执行次数取决于排序的趟数。
最好情况下,待排序序列为正序,算法只执行一趟,进行了n-1次比较。因此,最好情况下优化的冒泡排序的时间复杂度为O(n)。
最坏情况下,待排序序列为倒序,每趟排序在无序序列中只有一个最大的记录被交换到最终位置,算法执行n-1趟,第i趟排序执行了n-i次比较,则记录的比较次数为∑n-11(n-i)=n(n-1)/2。因此,最坏情况下冒牌排序的时间复杂度为O(n2)。
平均情况下,需要考虑初始序列中逆序的个数。为了确定相邻的两个记录是否需要交换,必须对这两个记录进行比较,因此,初始序列中逆序的个数,也就是记录比较次数的下界。
设a1,a2,…,an是集合{1,2,…,n}的一个排列,如果i
对于n个记录的所有初始排列,最好情况下,逆序的个数是0;最坏情况下,逆序的个数是n(n-1)/2;其余所有排列,逆序的个数处于最好和最坏之间。Donald Kunth对逆序的分布规律进行了研究,得出了如下的式子:
m e a n ( n ) = 1 n ! ∑ k = 20 n ( n − 1 ) / 2 S ( k ) × k = ∑ k = 1 n k − 1 2 = 1 4 n ( n − 1 ) mean(n)=\frac{1}{n!}\sum_{k=20}^{n(n-1)/2}S(k)×k=\sum_{k=1}^n\frac{k-1}{2}=\frac{1}{4}n(n-1) mean(n)=n!1k=20∑n(n−1)/2S(k)×k=k=1∑n2k−1=41n(n−1)
因此,平均情况下冒泡排序的时间复杂性为O(n2)。
在冒泡排序算法中只使用了i、j和temp这3个辅助变量,与问题规模无关。因此,辅助空间复杂度为O(1)。
快速排序是由冒泡排序改进而成的,其基本思想是在待排序的n个元素中任取一条记录(通常为第一条记录)作为基准,将该记录放入适当位置后,数据序列被该记录划分成了两部分。所有关键字比该记录关键字小的记录放置在前一部分,所有关键字比该记录关键字大的记录放置在后一部分,并把该记录排在这两部分的中间(称为该记录归位),这个过程称为一趟快速排序,也即一趟划分。后续对产生的两个部分分别重复上述过程,直至每部分内只有一个记录或空为止。
快速排序用到了分治法的思想。
快速排序由于排序效率在同为O(nlog2n)的几种排序方法中效率较高,且其思想分治思想也很实用,因此非常常考!
排序过程:
(1)划分:选定一个记录作为轴值,以轴值为基准将整个序列划分为两个子序列r1…ri-1和ri+1…rn,轴值的位置i在划分的过程中确定,并且前一个子序列中的记录均小于或等于轴值,后一个子序列中的记录均大于或等于轴值。
(2)求解子问题:分别对划分后每一个子序列递归处理。
(3)合并:由于对子序列r1…ri-1和ri+1…rn的排序是就地进行的,所以合并不需要执行任何操作。
示例:
//一趟划分
int Partition(int r[],int first,int end)
{
int i = first, j = end;
int temp = r[i]; //以r[i]为基准
while(i < j) //从两端交替向中间扫描,直至i=j为止
{
while(i < j && temp <= r[j])//从右向左扫描,找到一个小于temp的r[j]
j--;
r[i] = r[j]; //将r[j]放入r[i]
while(i < j && temp >= r[j])//从左向右扫描,找到一个大于temp的r[i]
i++;
r[j] = r[i]; //将r[i]放入r[j]
}
r[i] = temp;
return i;
}
//对r[first..end]中的记录进行快速排序
void QuickSort(int r[],int first,int end)
{
if(first < end) //区间内至少存在两个记录的情况
{
int i = Partition(r,first,end); //划分成两个区间
QuickSort(r,first,i-1); //对左区间递归快速排序
QuickSort(r,i+1,end); //对右区间递归快速排序
}
}
上述代码在写法上分了两个函数来写,下面将两个函数整合成一个:
//整合的快速排序
void QuickSort(int r[], int first, int end)
{
if (first < end)
{
int i = first, j = end, x = r[first]; //以第一个数为基准
while (i < j) //从两端交替向中间扫描,直至i=j为止
{
while(i < j && r[j] >= x) //从右向左扫描,找到一个小于基准的r[j]
j--;
if(i < j)
r[i++] = r[j];
while(i < j && r[i] < x) //从左向右扫描,找到一个大于等于基准的r[i]
i++;
if(i < j)
r[j--] = r[i];
}
r[i] = x;
QuickSort(r, first, i-1); //对左区间递归快速排序
QuickSort(r, i+1, end); //对右区间递归快速排序
}
}
算法分析:
最好情况下,每次划分对一个记录定位后,该记录的左侧子序列与右侧子序列的长度相同。在具有n个记录的序列中,一次划分需要对整个待划分序列扫描一遍,则所需时间为O(n)。设T(n)是对记录的序列进行排序的时间,每次划分后,正好把待划分区间划分为长度相等的两个子序列,则有:
T ( n ) = 2 T ( n 2 ) + n = 2 ( 2 T ( 4 n ) + n 2 ) + n = 4 T ( n 4 ) + 2 n = 4 ( 2 T ( n 8 ) + n 4 ) + 2 n = 8 T ( n 8 ) + 3 n = . . . = n T ( 1 ) + n l o g 2 n = O ( n l o g 2 n ) \begin{aligned} T(n)&=2T(\frac{n}{2})+n=2(2T(\frac{4}{n})+\frac{n}{2})+n=4T(\frac{n}{4})+2n\\ &=4(2T(\frac{n}{8})+\frac{n}{4})+2n=8T(\frac{n}{8})+3n\\ &=...\\ &=nT(1)+nlog_2n\\ &=O(nlog_2n) \end{aligned} T(n)=2T(2n)+n=2(2T(n4)+2n)+n=4T(4n)+2n=4(2T(8n)+4n)+2n=8T(8n)+3n=...=nT(1)+nlog2n=O(nlog2n)
因此,最好情况下快速排序的时间复杂度为O(nlog2n)。
最坏情况下,待排序记录序列为正序或为逆序,每次划分只得到一个比上一次划分少一个记录的子序列(另一个子序列为空)。此时,必须经过n-1次递归调用才能把所有记录定位,而且第i趟划分需要经过n-i次比较才能找到第i个记录的位置,则有:
∑ i = 1 n − 1 ( n − i ) = 1 2 n ( n − 1 ) = O ( n 2 ) \sum_{i=1}^{n-1}(n-i)=\frac{1}{2}n(n-1)=O(n^2) i=1∑n−1(n−i)=21n(n−1)=O(n2)
因此,最坏情况下快速排序的时间复杂度为O(n2)。
平均情况下,设轴值记录的关键码第k小(1≤k≤n),则有:
T ( n ) = 1 n ∑ k = 1 n ( T ( n − k ) + T ( k − 1 ) ) + n = 2 n ∑ k = 1 n T ( k ) + n T(n)=\frac{1}{n}\sum_{k=1}^n(T(n-k)+T(k-1))+n=\frac{2}{n}\sum_{k=1}^nT(k)+n T(n)=n1k=1∑n(T(n−k)+T(k−1))+n=n2k=1∑nT(k)+n
因此,平均情况下快速排序的时间复杂度为O(nlog2n)。
由于快速排序是递归执行的,需要用一个栈来存放每一层递归调用的必要信息,其最大容量应与递归调用的深度一致。最好情况下要进行⌊log2n⌋次递归调用,栈的深度为O(log2n);最坏情况下,因为要进行n-1次递归调用,栈的深度为O(n);平均情况下,栈的深度为O(log2n)。
选择排序的基本思想是每一趟从待排序的记录中选择出关键字最小的记录,顺序放在已排好序的子序列的最后,直到全部元素排序完毕。
简单选择排序的基本思想是第i趟排序在无序序列ri~ rn中找到值最小的记录,并和第i个记录交换作为有序序列的第i个记录。
简单选择排序用到了蛮力法的思想。
排序过程:
(1)将整个记录序列划分为有序区和无序区,初始时有序区为空,无序区含有待排序的所有记录;
(2)在无序区查找值最小的记录,将它与无序区的第一个记录交换,使得有序区扩展一个记录,同时无序区减少一个记录。
(3)不断重复步骤(2),直到无序区只剩下一个记录为止。
示例:
图示:
C语言代码:
//简单选择排序
void SelectSort(int r[],int n)
{
for(int i = 0; i < n-1; i++) //对n个记录进行n-1趟选择排序
{
int min = i;
for(int j = i+1; j < n; j++) //在无序区中查找最小的记录
if(r[j] < r[min])
min = j; //min记下当前找到的最小的记录所在位置
if(min != i) //如果i不是最小的记录,则交换r[i]与r[min]
{
int temp = r[i];
r[i] = r[min];
r[min] = temp;
}
}
}
算法分析:
简单选择排序的基本语句是内层循环体中的比较语句(r[j] < r[min]),其执行次数为:
∑ i = 0 n − 2 ∑ j = i + 1 n − 1 1 = ∑ i = 0 n − 2 ( n − i − 1 ) = n ( n − 1 ) 2 = O ( n 2 ) \sum_{i=0}^{n-2}\sum_{j=i+1}^{n-1}1=\sum_{i=0}^{n-2}(n-i-1)=\frac{n(n-1)}{2}=O(n^2) i=0∑n−2j=i+1∑n−11=i=0∑n−2(n−i−1)=2n(n−1)=O(n2)
因此,对于任何待排序记录序列,简单选择排序算法的时间性能都是O(n2)。
由于简单选择排序算法记录交换次数最多为n-1次,因为外层循环每执行一次,交换记录的语句最多只执行一次。因此,从移动记录的角度说,简单选择排序算法优于许多其他排序算法。
堆:满足如下性质的二叉树:
小根堆:每个结点的值都小于或等于其左右孩子结点的值。
大根堆:每个结点的值都大于或等于其左右孩子结点的值。
以结点的层序编号作为下标,将堆用数组存储,则堆对应于一组序列:
堆排序正是利用堆这种数据结构所设计的一种排序算法,其基本思想是首先将待排序的记录序列构造成一个堆,此时,堆顶记录是堆中所有记录的最大值,将它从堆中移走(通常将堆顶记录和堆中最后一个记录交换),然后将剩余记录再调整成堆,这样又找出了次大的记录,依此类推,直到堆中只有一个记录为止。
堆排序的排序过程与简单选择排序类似,只是挑选最大或最小记录时采用的方法不同,这里采用大根堆,每次挑选最大记录归位。挑选最大记录采用的是筛选法。
筛选法:将一个无序序列调整为堆的过程。
堆排序使用了减治法的思想。
排序过程:
(1)将记录序列用筛选法调整为大根堆;
(2)将堆顶和堆中最后一个记录交换;
(3)不断重复步骤(1)(2),直到堆中只有一个记录为止。
筛选法:
(1)将根结点与左右子树的根结点进行比较;
(2)若不满足大根堆的条件,则将根结点与左右子树根结点较大者进行交换;
(3)重复步骤(1)(2),直到所有子树均为大根堆。
示例:
图示:
C语言代码:
/** 筛选法
* k:当前要筛选的结点编号,k的左右子树均是堆
* n:堆中最后一个记录的编号
*/
void SiftHeap(int r[],int k,int n)
{
int i = k; //置i为要筛选的结点
int j = 2*i+1; //置j为i的左孩子
while(j < n) //当筛选还没有进行到叶子
{
if(j < n-1 && r[j] < r[j+1]) //比较i的左右孩子,j为较大者
j++;
if(r[i] > r[j]) //根结点已经大于左右孩子中的较大者
break;
else //根节点小于左右孩子中的较大者,交换r[i]和r[j]
{
int temp = r[i];
r[i] = r[j];
r[j] = temp;
i = j; //被筛结点i位于原来结点j的位置
j = 2*i+1;
}
}
}
//堆排序
void HeapSort(int r[],int n)
{
for(int i=(n-1)/2;i>=0;i--) //初始建堆,最后一个分支的下标是(n-1)/2
SiftHeap(r,i,n);
for(int i = 1;i <= n-1; i++) //不断移走堆顶,并用筛选法调整堆
{
int temp = r[0]; //将堆顶与堆中最后一个记录交换
r[0] = r[n-i];
r[n-i] = temp;
SiftHeap(r,0,n-i); //只需调整根结点
}
}
算法分析:
筛选算法将根结点与左右子树的根结点进行比较,若不满足堆的条件,则将根结点与左右子树根结点中较大者交换。所以,每比较一次,需要调整的完全二叉树的问题规模就减少一半。因此,筛选法的时间性能为O(log2n)。
堆排序的时间主要由建立初始堆和反复重建堆这两部分时间组成,它们都是通过调用筛选算法实现的。
对于高度为k的完全二叉树,调用筛选算法时,其中while循环最多执行k-1次,所以最多进行2(k-1)次关键字比较,最多进行k+1次记录的移动,因此主要以关键字的比较来分析堆排序的时间性能。
n个结点的完全二叉树的高度h=⌊log2n⌋+1。在建立初始堆时,需要筛选调整的层为h-1 ~ 1层,以第i层中某个结点为根的子树的高度为h-i-1,并且第i层中最多有2i-1个结点。设建立初始堆所需要的关键字比较次数最多为T1(n),则有:
T 1 ( n ) = ∑ i = h − 1 1 2 i − 1 × 2 ( h − i + 1 − 1 ) = ∑ i = h − 1 1 2 i × ( h − i ) T_1(n)=\sum_{i=h-1}^{1}2^{i-1}×2(h-i+1-1)=\sum_{i=h-1}^{1}2^i×(h-i) T1(n)=i=h−1∑12i−1×2(h−i+1−1)=i=h−1∑12i×(h−i)
令j=h-i,当i=h-1时,j=1;当i=1时,j=h-1,所以:
T 1 ( n ) = ∑ i = h − 1 1 2 i × ( h − i ) = ∑ j = 1 h − 1 2 h − j × j = 2 h − 1 × 1 + 2 h − 2 × 2 + . . . + 2 1 × ( h − 1 ) = 2 h − 1 − 2 h − 2 < 2 ⌊ l o g 2 n ⌋ + 2 < 4 × 2 l o g 2 n = 4 n \begin{aligned} T_1(n)&=\sum_{i=h-1}^{1}2^i×(h-i)=\sum_{j=1}^{h-1}2^{h-j}×j\\ &=2^{h-1}×1+2^{h-2}×2+...+2^1×(h-1)\\ &=2^{h-1}-2^h-2<2^{⌊log_2n⌋+2}<4×2^{log_2n}=4n \end{aligned} T1(n)=i=h−1∑12i×(h−i)=j=1∑h−12h−j×j=2h−1×1+2h−2×2+...+21×(h−1)=2h−1−2h−2<2⌊log2n⌋+2<4×2log2n=4n
因此,建立初始堆总共进行的关键字比较次数不超过4n。类似地,设重建堆中对筛选算法的n-1次调用所需的比较总次数为T2(n)。其中i从n到2,每次对r[1…i-1]的i-1个结点的完全二叉树进行筛选调整,该树的高度为⌊log2(i-1)⌋+1,所以有:
T 2 ( n ) = ∑ i = 2 n 2 × ( ⌊ l o g 2 ( i − 1 ) ⌋ + 1 − 1 ) = 2 ∑ i = 2 n ⌊ l o g 2 ( i − 1 ) ⌋ < 2 n l o g 2 n T_2(n)=\sum_{i=2}^{n}2×(⌊log_2(i-1)⌋+1-1)=2\sum_{i=2}^{n}⌊log~2~(i-1)⌋<2nlog_2n T2(n)=i=2∑n2×(⌊log2(i−1)⌋+1−1)=2i=2∑n⌊log 2 (i−1)⌋<2nlog2n
所以,堆排序所需关键字的比较总次数为T(n)=T1(n)+T2(n)=4n+2nlog2n=O(nlog2n)。
综上,堆排序的最坏时间复杂度为O(nlog2n)。
堆排序的平均时间性能分析较复杂,实验研究表明,它较接近最坏性能。实际上,堆排序和简单选择排序算法一样,其时间性能与初始序列的顺序无关,所以堆排序的最好、最坏和平均时间复杂度都是O(nlog2n)。
由于堆排序建初始堆所需的比较次数较多,所以堆排序不适合记录数较少的序列排序。
堆排序只是用了i、j、temp等辅助变量,其空间复杂度为O(1)。
堆排序在进行筛选时可能把后面相同关键字的记录调整到前面,所以堆排序是不稳定的排序算法。
归并排序是多次将两个或两个以上的有序序列合并成一个新的有序序列。最简单的归并是直接将两个有序的子序列合并成一个有序的表,即二路归并。
归并排序使用了分治法的思想。
归并排序按照记录在序列中的位置对序列进行划分,而快速排序按照记录的值对序列进行划分。相较而言,快速排序更加巧妙。
二路归并排序的基本思路是将r[0…n-1]看成是n个长度为1的有序序列,然后进行两两归并,得到⌈n/2⌉个长度为2(最后一个有序序列的长度可能为2)的有序序列,再进行两两归并,得到⌈n/4⌉个长度为4(最后一个有序序列的长度可能小于4)的有序序列,依次类推,直到得到一个长度为n的有序序列。
笔试常考:归并排序每趟产生的有序区只是局部有序的,在最后一趟排序结束前,所有记录不一定归位了。
排序过程:
(1)划分:将待排序序列r1,r2,…,rn划分为两个长度相等的子序列r1,…,rn/2和rn/2+1,…,rn;
(2)求解子问题:分别对这两个子序列进行排序,得到两个有序子序列;
(3)合并:将这两个有序序列合并成一个有序序列。
示例:
//合并算法:合并r[low..high]
void Merge(int r[], int r1[], int low, int mid, int high)
{
int i = low, j = mid+1, k = low; //i、j是r中第一个子序列和第二个子序列的下标;k是r1的下标
while(i <= mid & j <= high) //当前半个子序列和后半个子序列都没有处理完时循环
{
if(r[i] <= r[j]) //取r[i]和r[j]中较小者放入r1[k]
r1[k++] = r[i++];
else
r1[k++] = r[j++];
}
while(i <= mid) //当前半子序列没处理完
r1[k++] = r[i++]; //将前半个子序列余下的部分放入r1
while(j <= high) //当后半个子序列没处理完
r1[k++] = r[j++]; //将后半个子序列余下的部分放入r1
}
//对r[low]~r[high]进行归并排序
void MergeSort(int r[], int low, int high)
{
int r1[1000]; //临时数组r1,假设最多1000个记录
if(low == high) //递归的边界条件,只有一个记录,已经有序
return;
else
{
int mid = (low+high)/2; //划分为两个子序列
MergeSort(r,low,mid); //求解子问题1:归并排序前半个子序列
MergeSort(r,mid+1,high); //求解子问题2:归并排序后半个子序列
Merge(r,r1,low,mid,high); //合并两个有序子序列,结果保存在数组r1中
for(int i = low; i <= high; i++)
r[i] = r1[i]; //将有序序列放回数组r中
}
}
此处使用的时自顶向下的递归实现的二路归并算法,还可以使用自底向上的迭代重写。
算法分析:
设待排序记录个数为n,则执行一趟合并算法的时间复杂性为O(n),则归并排序算法存在如下递推式:
T ( n ) = { 1 n = 1 2 T ( n 2 ) + n n > 1 T(n)=\left\{ \begin{array}{lr} 1&n=1\\ 2T(\frac{n}{2})+n & n>1 \end{array} \right. T(n)={12T(2n)+nn=1n>1
该式子满足通用分治递推式(a,b,c和k都是常数):
T ( n ) = { c n = 1 a T ( n b ) + c n k n > 1 T(n)=\left\{ \begin{array}{lr} c&n=1\\ aT(\frac{n}{b})+cn^k & n>1 \end{array} \right. T(n)={caT(bn)+cnkn=1n>1
该递归式描述了大小为n的原问题分解为若干大小为n/b的子问题,其中a个子问题需要求解,cnk是合并子问题的解需要的工作量。
当T(n)是一个非递减函数,且满足通用分治递推式时,则有以下结论成立:
T ( n ) = { O ( n l o g b a ) a > b k O ( n k l o g b n ) a = b k O ( n k ) a < b k T(n)=\left\{ \begin{array}{lr} O(n^{log_ba})&a>b^k\\ O(n^klog_bn)&a=b^k \\ O(n^k)&aT(n)=⎩⎨⎧O(nlogba)O(nklogbn)O(nk)a>bka=bka<bk
因此可知归并排序算法中,a=2,b=2,c=1,k=1。所以,a=bk,对应结论可知:T(n) = O(nlog2n)。因此,归并排序算法的最好、最坏、平均时间复杂性为O(nlog2n)。
归并排序需要与待排序记录序列同样数量的存储空间,以便存放归并结果。因此,归并排序的空间复杂度为O(n)。
非比较排序不同于比较排序,不像上述的比较排序算法,是基于关键字间的比较,而是使用其他的方法得到排序结果。
非比较排序包括:基数排序、计数排序和桶排序,均是通过分配和收集来实现排序。
这三者在排序时都使用了桶的概念。不同的是对桶的使用方法。
桶:一组未排序的记录。
基数排序中桶的使用:根据关键字的每一位数字来分配桶。
计数排序中桶的使用:每个桶只存储单一关键字。
桶排序中桶的使用:每个桶存储一定范围的记录。
基数排序是通过分配和收集过程来实现排序,用到了多关键字排序的思想对单关键字进行排序。
位数d:记录的关键字由d位数字组成。
基数r:关键字可表示成kd-1kd-2…k1k0,其中kd-1为最高位,k0是最低位,每一位都在0≤ki<r的范围内,r就称为是基数。如对二进制数来说r为2,对十进制数来说r为10。
基数排序有两种,分别是最低位优先(LSD)和最高位优先(MSD),其原理相同,这里主要讨论LSD。
在对一个序列排序时采用最低位优先还是最高位优先排序方法是根据序列的特点决定的。如对整数序列递增排序,由于个位数的重要性低于十位数,十位数的重要性低于百位数,一般越重要的位放在越后面排序。因此,个位数属于最低位,所以对于整数序列的递增排序采用最低位优先排序。
最低位优先的排序过程:
按最低位的值对记录进行排序,在此基础上再按次低位进行排序,依次类推,每一趟都是根据关键字的一位并在前一趟的基础上对所有记录进行排序,直至最高位。
分配:从低位开始将待排序的记录按照这一位的值分配至编号0到9的桶中。
收集:将这些桶中已排序的记录依次首位相接,得到新的记录序列。
在执行d趟后,记录序列就有序了。
示例:
排序过程:
(1)获取待排序序列中的最大值,目的是计算出其的最大指数;
(2)从指数1开始,根据位数对待排序序列中的记录进行排序;(排序的时候采用的是桶排序。)
(3)重复步骤(2),直至最高位。
C语言代码:
//获取长度为n的数组r中的最大值
int getMax(int r[], int n)
{
int i, max;
max = r[0];
for (i = 1; i < n; i++)
if (r[i] > max)
max = r[i];
return max;
}
/** 对数组按照"某个位数"进行排序(桶排序)
* exp:指数。对数组r按照该指数进行排序。
* 当exp=1表示按照"个位"对数组r进行排序
* 当exp=10表示按照"十位"对数组r进行排序
* 当exp=100表示按照"百位"对数组r进行排序
* ...
*/
void CountSort(int r[], int n, int exp)
{
int output[n]; //存储"被排序数据"的临时数组
int i, buckets[10] = {0};
//将数据出现的次数存储在buckets[]中
for (i = 0; i < n; i++)
buckets[ (r[i]/exp)%10 ]++;
//更改buckets[i]。目的是让更改后的buckets[i]的值,是该数据在output[]中的位置。
for (i = 1; i < 10; i++)
buckets[i] += buckets[i - 1];
//将数据存储到临时数组output[]中
for (i = n - 1; i >= 0; i--)
{
output[buckets[ (r[i]/exp)%10 ] - 1] = r[i];
buckets[ (r[i]/exp)%10 ]--;
}
//将排序好的数据赋值给r[]
for (i = 0; i < n; i++)
r[i] = output[i];
}
// 基数排序
void RadixSort(int r[], int n)
{
int exp; //指数。当对数组按个位数进行排序时,exp=1;按十位进行排序时,exp=10;...
int max = getMax(r, n); //数组r中的最大值
//从个位开始,对数组r按"指数"进行排序
for (exp = 1; max/exp > 0; exp *= 10)
CountSort(r, n, exp);
}
算法分析:
在基数排序过程中共进行了d趟的分配和排序。每一趟分配过程需要扫描序列中的所有n个记录,而收集过程取决于桶的个数r,所以一趟的执行时间为O(n+r)。因此基数排序的时间复杂度为O(d(n+r))。
在基数排序中第一趟排序需要的辅助存储空间为r(创建了r个桶),但以后的各趟都重复使用这些桶,因此基数排序的空间复杂度为O( r )。
很多博客上面分析的空间复杂度为O(n+r),这是由于考虑了额外的空间复杂度,基数排序也是内部排序的一种,在装桶时会有O(n)的空间复杂度。
基数排序后续的排序基于前面的排序,排在后面的记录只能排在前面相同关键字记录的后面,相对位置不会发生改变,因此,基数排序是一种稳定的排序算法。
计数排序也是一种不基于比较的排序算法,利用牺牲空间换取时间的思想赢得了比所有比较排序算法更快的速度。
计数排序的基本思想是通过对序列中的每个记录进行相应的计数统计,进而通过计数值确定记录的正确位置的排序算法。
计数排序不适用于序列中最大最小值差距过大及序列中记录非整数的情况。
排序过程:
(1)获取待排序序列中的最大值与最小值,目的是计算出其要申请的辅助空间;
(2)申请辅助空间count,大小为待排序序列中的最大值-最小值+1,并将其初始化为0;
(3)对待排序序列中的记录进行出现频次的统计,统计结果对应存入辅助空间count中;
(4)对所有的计数累加(从count中的第一个计数开始,每一项和前一项相加),计算得到每个记录在排序后序列中的结束位置;
(5)反向填充目标序列,将每个记录i放在新序列的第count(i)项,每放一个记录就将count(i)减去1。
示例:
C语言代码:
//找到待排序序列中的最大值
int getMax(int r[],int n)
{
int max = r[0];
for (int i = 0; i < n; i++)
{
if (r[i] > max)
max = r[i];
}
return max;
}
//找到待排序序列中的最小值
int getMin(int r[],int n)
{
int min = r[0];
for (int i = 0; i < n; i++)
{
if (r[i] < min)
min = r[i];
}
return min;
}
//计数排序
void countingSort(int r[],int n)
{
//(1)找到序列中的最大值和最小值
int max = getMax(r,n);
int min = getMin(r,n);
int c = 0;
//(2)申请辅助空间,并初始化为0
int count[max-min+1] = {0};
int output[n-1] = {0};
//(3)统计各个记录的出现频次,并存储在相应的位置上
for (int i = 0; i < n; i++)
count[r[i]-min]++;
//(4)根据 count 中的信息,找到各个记录排序后所在位置,回收元素
for (int i = 0; i < max-min+1; i++)
{
while(count[i]--)
r[c++] = i+min;
}
}
算法分析:
在计数排序中,首先遍历了待排序序列,求得最大最小值,时间复杂度为O(n);接着再次遍历了待排序序列,统计各个记录的出现频次,时间复杂度为O(n);最后再遍历辅助空间数组count,其大小为k=max-min+1,时间复杂度为O(k);因此计数排序总的时间复杂度为O(n+k)。
由于计数排序过程中需要申请一个辅助空间count,用于存放计数结果。因此计数排序的空间复杂度为O(k)。
计数排序是一种稳定的排序算法,因为具有相同值的记录在输出时的相对次序与它们在输入时的相对次序是相同的。
桶排序是基数排序和计数排序的升级版,基数排序和计数排序都是基于桶排序的思想实现的。桶排序的基本思想是将待排序序列通过映射函数分配到有限数量的桶中,再对每个桶分别排序,最后收集各桶中的排序结果得到最终的排序结果。
对于桶中记录的排序,选择哪种比较排序算法对于性能的影响至关重要。
为使桶排序更加高效,做到在额外空间充足的情况下,尽量增大桶的数量;使用的映射函数能够将输入的 n 个记录均匀的分配到 k个桶中。
桶排序使用到了分治的思想。
排序过程:
(1)设置一个定量的数组当作空桶。
(2)遍历待排序序列,将记录一个一个放到对应的桶中。
(3)对每个非空的桶进行排序。
(4)从非空的桶中把排好序的记录再放回到原来的序列中。
示例:
C语言代码:
//获找到待排序序列中的最大值
int getMax(int r[], int n)
{
int max = r[0];
for(int i = 1; i < n; i++)
{
if(r[i] > max)
max = r[i];
}
return max;
}
//桶排序
void BucketSort(int r[] , int n)
{
//找到待排序序列中的最大值
int max = getMax(r,n);
//申请空桶,并初始化为0
int bucket[max+1]={0};
int i, j;
//遍历待排序序列,将记录一个一个放到对应的桶中
for(i = 0; i < n; i++)
bucket[r[i]]++;
for(i = 0, j = 0; i < max+1; i++)
{
//对每个非空的桶进行排序
while(bucket[i] != 0)
{
r[j] = i; //从非空的桶子里把排好序的记录再放回原来的序列中
j++;
bucket[i]--;
}
}
}
算法分析:
在桶排序中,首先遍历了待排序序列,求得最大值,时间复杂度为O(n);接着再次遍历了待排序序列,将各个记录放入对应的桶中,时间复杂度为O(n);然后对各桶中记录进行排序,一般使用快速排序,时间复杂度为O(nlog2n),为使桶排序更加高效,记录能平均得分配到所有桶中,每个桶中有[n/k]个记录;最后再遍历所有有记录的桶,其个数为k=max+1,时间复杂度为O(k);因此桶排序的平均时间复杂度为 O ( n ) + O ( k × ( n k ) × l o g 2 ( n k ) ) = O ( n + n × ( l o g 2 n − l o g 2 k ) ) = O ( n + n × l o g 2 n − n × l o g 2 k ) O(n)+O(k×(\frac{n}{k})×log_2(\frac{n}{k}))=O(n+n×(log_2n-log_2k))=O(n+n×log_2n-n×log_2k) O(n)+O(k×(kn)×log2(kn))=O(n+n×(log2n−log2k))=O(n+n×log2n−n×log2k)即O(n+C)。
当n=k时,即极限情况下,每个桶中只有一个记录,桶排序的最好时间复杂度是O(n)。
当所有的待排序记录都被分配到了同一个桶中,相当于用任意的排序算法都行。因此,桶排序的最坏时间复杂度是O(n2)。(参考其他排序的最坏时间复杂度)
由于桶排序过程中需要申请k个桶,用于存放待排序序列。因此桶排序的空间复杂度为O(k)。待排序记录分布越均匀,也就是说当记录能够非常均匀地填满所有的桶时,这个空间的利用率是最好的。
桶排序的稳定性与桶内记录使用的排序算法是否稳定有关,如果桶内排序算法是稳定的,那么桶排序也是稳定的,反之,则桶排序不是稳定的。