排序算法对比
排序的稳定性
关于排序的稳定性,我们可以举一个例子,我们计划将公司的员工按照年龄排序,那么具体年龄对照表如下:
小a | 小d | 小w | 小s | 小t | 小e |
---|---|---|---|---|---|
23 | 21 | 21 | 34 | 25 | 26 |
那么我们希望排完序的结果是:
小d | 小w | 小a | 小t | 小e | 小s |
---|---|---|---|---|---|
21 | 21 | 23 | 35 | 26 | 34 |
这就是我们追求的稳定性,相同关键字的记录之间的相对次序不发生变化。
排序的分类
插入类:将无序的子序列中的一个或者几个记录,插入到有序序列中,从而增加记录的有序子序列的长度,如插入排序,shell排序
选择类:从无序子序列中选择关键字最小或者最大的记录,将它加入到有序子序列中,从而增加记录的有序子序列的长度。如直接选择排序,堆排序
交换类:通过交换无序子序列中的记录从而得到其中关键字最小或者最大的记录,并将它加入到有序子序列中,从而增加记录的有序子序列的长度,如冒泡排序,快速排序
归并类:通过“归并”两个或两个以上的记录有序子序列,逐步增加有序子序列的长度,如归并排序
分配类:是惟一一个不需要进行关键字之间的比较的排序算法,它主要利用分配和手机两种基本操作实现整个排序过程。如木桶排序,基数排序,计数排序
1. 插入排序
思想:遍历未排序的数组,将当前便利元素插入到前边已经排好序的合适位置,可以考虑从已经排序的数组的后边向前插入,可以更简便。
最优复杂度:当输入数组就是排好序的时候,复杂度为O(n),而快速排序在这种情况下会产生O(n^2)的复杂度。
最差复杂度:当输入数组为倒序时,复杂度为O(n^2)
具体实现:
/**
* 插入排序
*
* @param data
* @return
*/
static int[] insertSort(int[] data) {
if (data == null || data.length <= 0) {
return null;
}
int[] result = new int[data.length];
result = Arrays.copyOf(data, data.length);
for (int i = 1; i < result.length; i++) {
int j = i;
int key = result[i];
while (j > 0 && result[j - 1] > key) {
result[j] = result[j - 1];
j--;
}
result[j] = key;
}
return result;
}
证明算法正确性:
循环不变式:在每次循环开始前,A[1…i-1]包含了原来的A[1…i-1]的元素,并且已排序。
初始:i=2,A[1…1]已排序,成立。
保持:在迭代开始前,A[1…i-1]已排序,而循环体的目的是将A[i]插入A[1…i-1]中,使得A[1…i]排序,因此在下一轮迭代开 始前,i++,因此现在A[1…i-1]排好序了,因此保持循环不变式。
终止:最后i=n+1,并且A[1…n]已排序,而A[1…n]就是整个数组,因此证毕。
而在算法导论2.3-6中还问是否能将伪代码第6-8行用二分法实现?
实际上是不能的。因为第6-8行并不是单纯的线性查找,而是还要移出一个空位让A[i]插入,因此就算二分查找用O(lgn)查到了插入的位置,但是还是要用O(n)的时间移出一个空位。
问:快速排序(不使用随机化)是否一定比插入排序快?
答:不一定,当输入数组已经排好序时,插入排序需要O(n)时间,而快速排序需要O(n^2)时间。
优化
**折半插入排序:**基于折半查找(二分法查找)有序数组的方法,我们可以通过对前边已经排序的部分进行折半查找,找到记录应该插入的位置。这种方法改良了查找(比较)关键字的代价,但是并没有改良我们对与移动元素的时间代价,所以时间复杂度没有太大变化。已然和原来一样。
优化
希尔排序:先将整个带牌元素序列分割成若干个子序列,分别进行直接插入排序, 然后依次缩减增量再进行排序,待整个序列中的元素基本有序的时候(增量足够小的时候),再对全体元素进行一次直接插入排序,因为直接插入排序在元素基本有序的时候效率是很高的,因此希尔排序,在时间效率上要比前两种方法有较大提高,但是极不稳定。
具体实现:
/**
* 希尔排序
* @param data
* @return
*/
static int[] shellSort(int [] data){
if (data == null || data.length <= 0) {
return null;
}
int[] result = new int[data.length];
result = Arrays.copyOf(data, data.length);
int len = result.length / 2;
while (len >= 1){
for (int i = len; i < result.length ; i++) {
int j = i;
int key = result[i];
while (j >= len && result[j - len] > key) {
result[j] = result[j - len];
j -= len;
count++;
}
result[j] = key;
}
len /= 2;
}
return result;
}
2. 冒泡排序(改进版)
思想:加flag岗哨,每一次遍历,都会把前边未排序部分的最大值冒出来,放到最后
**最佳运行时间:**O(n)
**最坏运行时间:**O(n^2)
具体实现:
/**
* 冒泡排序,从前向后
*
* @param data
* @return
*/
static int[] bubbleSort(int[] data) {
if (data == null || data.length <= 0) {
return null;
}
int[] result = new int[data.length];
result = Arrays.copyOf(data, data.length);
for (int i = result.length; i > 0; i--) {
boolean flag = false;
for (int j = 0; j < i - 1; j++) {
if (result[j] > result[j + 1]) {
flag = true;
swap(result, j, j + 1);
}
}
if (!flag) {
break;
}
}
return result;
}
证明算法正确性:
运用两次循环不变式,先证明第4-6行的内循环,再证明外循环。
内循环不变式:在每次循环开始前,A[j]是A[j…n]中最小的元素。
初始:j=n,因此A[n]是A[n…n]的最小元素。
保持:当循环开始时,已知A[j]是A[j…n]的最小元素,将A[j]与A[j-1]比较,并将较小者放在j-1位置,因此能够说明A[j-1]是A[j-1…n]的最小元素,因此循环不变式保持。
终止:j=i,已知A[i]是A[i…n]中最小的元素,证毕。
接下来证明外循环不变式:在每次循环之前,A[1…i-1]包含了A中最小的i-1个元素,且已排序:A[1]<=A[2]<=…<=A[i-1]。
初始:i=1,因此A[1..0]=空,因此成立。
保持:当循环开始时,已知A[1…i-1]是A中最小的i-1个元素,且A[1]<=A[2]<=…<=A[i-1],根据内循环不变式,终止时A[i]是A[i…n]中最小的元素,因此A[1…i]包含了A中最小的i个元素,且A[1]<=A[2]<=…<=A[i-1]<=A[i]
终止:i=n+1,已知A[1…n]是A中最小的n个元素,且A[1]<=A[2]<=…<=A[n],得证。
在算法导论思考题2-2中又问了”冒泡排序和插入排序哪个更快“呢?
一般的人回答:“差不多吧,因为渐近时间都是O(n^2)”。
但是事实上不是这样的,插入排序的速度直接是逆序对的个数,而冒泡排序中执行“交换“的次数是逆序对的个数,因此冒泡排序执行的时间至少是逆序对的个数,因此插入排序的执行时间至少比冒泡排序快。
3. 快速排序
思想:快速排序是对冒泡排序的一种改进。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一不部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。(默认的分割值为当前数组的最后一个值,可以用随机化改进)
最坏运行时间:当输入数组已排序时,时间为O(n^2),当然可以通过随机化来改进(shuffle array 或者 randomized select pivot),使得期望运行时间为O(nlgn)。
**最佳运行时间:**O(nlgn)
具体实现:
/**
* 快速排序
* @param data
* @return
*/
static int[] quickSort(int[] data){
if (data == null || data.length <= 0) {
return null;
}
int[] result = new int[data.length];
result = Arrays.copyOf(data, data.length);
quickCore(result, 0, data.length - 1);
return result;
}
static void quickCore(int []data, int start, int end){
if (start < end){
int pos = start;
int key = data[end];
for (int i = start; i < end; i++) {
if (data[i] < key){
swap(data, pos, i);
pos ++;
}
}
swap(data, pos, end);
quickCore(data, start, pos - 1);
quickCore(data, pos + 1, end);
}
}
证明算法正确性:
对partition函数证明循环不变式:A[p…i]的所有元素小于等于pivot,A[i+1…j-1]的所有元素大于pivot。
初始:i=p-1,j=p,因此A[p…p-1]=空,A[p…p-1]=空,因此成立。
保持:当循环开始前,已知A[p…i]的所有元素小于等于pivot,A[i+1…j-1]的所有元素大于pivot,在循环体中,
如果A[j]>pivot,那么不动,j++,此时A[p…i]的所有元素小于等于pivot,A[i+1…j-1]的所有元素大于pivot。
如果A[j]<=pivot,则i++,A[i+1]>pivot,将A[i+1]和A[j]交换后,A[P…i]保持所有元素小于等于pivot,而A[i+1…j-1]的所有元素大于pivot。
终止:j=r,因此A[p…i]的所有元素小于等于pivot,A[i+1…r-1]的所有元素大于pivot。
4. 选择排序
思想:每一次从后边未排序的部分中选择最小的放到前边已经排序的部分
**最好情况时间:**O(n^2)。
**最坏情况时间:**O(n^2)。
具体实现:
/**
* 选择排序
*
* @param data
* @return
*/
static int[] selectSort(int[] data) {
if (data == null || data.length <= 0) {
return null;
}
int[] result = new int[data.length];
result = Arrays.copyOf(data, data.length);
for (int i = 0; i < result.length; i++) {
int min = i;
for (int j = i + 1; j < result.length; j++) {
if (result[min] > result[j]) {
min = j;
}
}
swap(result, i, min);
}
return result;
}
证明算法正确性:
循环不变式:A[1…i-1]包含了A中最小的i-1个元素,且已排序。
初始:i=1,A[1…0]=空,因此成立。
保持:在某次迭代开始之前,保持循环不变式,即A[1…i-1]包含了A中最小的i-1个元素,且已排序,则进入循环体后,程序从 A[i…n]中找出最小值放在A[i]处,因此A[1…i]包含了A中最小的i个元素,且已排序,而i++,因此下一次循环之前,保持 循环不变式:A[1..i-1]包含了A中最小的i-1个元素,且已排序。
终止:i=n,已知A[1…n-1]包含了A中最小的i-1个元素,且已排序,因此A[n]中的元素是最大的,因此A[1…n]已排序,证毕。
算法导论2.2-2中问了”为什么伪代码中第3行只有循环n-1次而不是n次”?
在循环不变式证明中也提到了,如果A[1…n-1]已排序,且包含了A中最小的n-1个元素,则A[n]肯定是最大的,因此肯定是已排序的。
优化
树形选择排序:利用树形选择,将整数数组看作二叉树的叶子结点,从下往上,将两个子结点的最小值作为父节点的值,直到最后的根节点就是最小值,然后放入已经排序的数组的末位,同时将原来的该叶子节点值设为无穷大,更新叶子结点所属的祖宗树结点,直到所有的结点已经排序完成。 但是增加了n-1个辅助空间,时间复杂度如下:
5. 堆排序
思想:利用最大堆和最小堆的数据结构,最大堆(堆顶是整个堆的最大值,其子树符合条件),最小堆(堆顶是整个堆的最小值)利用大顶堆(小顶堆)堆顶记录的是最大关键字(最小关键字)这一特性,使得每次从无序中选择最大记录(最小记录)变得简单。
其基本思想为(大顶堆):
1)将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
2)将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
3)由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。
如何创建最大堆:先将初始按照从根节点到子节点的顺序创建完全二叉树,然后慢慢调整子树,使其子树称为最大堆,然后逐渐向上
**特性:**unstable sort、In-place sort。
**最优时间:**O(nlgn)
**最差时间:**O(nlgn)
具体实现:
证明算法正确性:
(1)证明build_max_heap的正确性:
循环不变式:每次循环开始前,A[i+1]、A[i+2]、…、A[n]分别为最大堆的根。
初始:i=floor(n/2),则A[i+1]、…、A[n]都是叶子,因此成立。
保持:每次迭代开始前,已知A[i+1]、A[i+2]、…、A[n]分别为最大堆的根,在循环体中,因为A[i]的孩子的子树都是最大堆,因此执行完MAX_HEAPIFY(A,i)后,A[i]也是最大堆的根,因此保持循环不变式。
终止:i=0,已知A[1]、…、A[n]都是最大堆的根,得到了A[1]是最大堆的根,因此证毕。
(2)证明heapsort的正确性:
循环不变式:每次迭代前,A[i+1]、…、A[n]包含了A中最大的n-i个元素,且A[i+1]<=A[i+2]<=…<=A[n],且A[1]是堆中最大的。
初始:i=n,A[n+1]…A[n]为空,成立。
保持:每次迭代开始前,A[i+1]、…、A[n]包含了A中最大的n-i个元素,且A[i+1]<=A[i+2]<=…<=A[n],循环体内将A[1]与A[i]交换,因为A[1]是堆中最大的,因此A[i]、…、A[n]包含了A中最大的n-i+1个元素且A[i]<=A[i+1]<=A[i+2]<=…<=A[n],因此保持循环不变式。
终止:i=1,已知A[2]、…、A[n]包含了A中最大的n-1个元素,且A[2]<=A[3]<=…<=A[n],因此A[1]<=A[2]<=A[3]<=…<=A[n],证毕。
6. 归并排序
思想:运用分治的思想解决排序问题,将对一个数组的排序分解为将一个个小数组的排序,最后合并, 在合并的时候采用的算法思想是合并两个有序的数组。
**最坏情况运行时间:**O(nlgn)
**最佳运行时间:**O(nlgn)
具体实现:
/**
* 归并排序
* @param data
* @return
*/
static int[] mergeSort(int[] data) {
if (data == null || data.length <= 0) {
return null;
}
int[] result = new int[data.length];
result = Arrays.copyOf(data, data.length);
mergeCore(result, 0, result.length - 1);
return result;
}
static void mergeCore(int[] data, int start, int end) {
if (start < end) {
int middle = (start + end) / 2;
mergeCore(data, start, middle);
mergeCore(data, middle + 1, end);
merge(data, start, middle, end);
}
}
static void merge(int[] data, int start, int middle, int end) {
int i = middle;
int j = end;
int k = end - start;
int[] result = new int[k + 1];
for (int l = 0; l < result.length; l++) {
result[l] = data[start + l];
}
//归并
while (i >= start && j > middle) {
if (data[i] < data[j]) {
result[k] = data[j];
j--;
} else {
result[k] = data[i];
i--;
}
k--;
}
while (i >= start) {
result[k] = data[i];
i--;
k--;
}
while (j > middle) {
result[k] = data[j];
j--;
k--;
}
for (int l = 0; l < result.length; l++) {
data[start + l] = result[l];
}
}
证明算法正确性:
其实我们只要证明merge()函数的正确性即可。
merge函数的主要步骤在第25~31行,可以看出是由一个循环构成。
循环不变式:每次循环之前,A[p…k-1]已排序,且L[i]和R[j]是L和R中剩下的元素中最小的两个元素。
初始:k=p,A[p…p-1]为空,因此已排序,成立。
保持:在第k次迭代之前,A[p…k-1]已经排序,而因为L[i]和R[j]是L和R中剩下的元素中最小的两个元素,因此只需要将L[i]和R[j]中最小的元素放到A[k]即可,在第k+1次迭代之前A[p…k]已排序,且L[i]和R[j]为剩下的最小的两个元素。
终止:k=q+1,且A[p…q]已排序,这就是我们想要的,因此证毕。
归并排序的例子:
问:归并排序的缺点是什么?
答:他是Out-place sort,因此相比快排,需要很多额外的空间。
问:为什么归并排序比快速排序慢?
答:虽然渐近复杂度一样,但是归并排序的系数比快排大。
问:对于归并排序有什么改进?
答:就是在数组长度为k时,用插入排序,因为插入排序适合对小数组排序。在算法导论思考题2-1中介绍了。复杂度为O(nk+nlg(n/k)) ,当k=O(lgn)时,复杂度为O(nlgn)
优化
自然归并排序:第一步合并相邻的长度为1的子序列,这是因为长度为1的子序列已经是排好序的,对序列的一次线性扫描即可找到这些已经排序好的子序列,然后将相邻的排好序的子序列两两合并,构成更大的排好序的子序列。直到整个数组已经排好序。
时间复杂度为O(n)
7. 计数排序
思想:类似桶排,对于给定的输入序列中的每一个元素x,确定该序列中值小于x的元素的个数。一旦有了这个信息,就可以将x直接存放到最终的输出序列的正确位置上。例如,如果输入序列中只有17个元素的值小于x的值,则x可以直接存放在输出序列的第18个位置上。当然,如果有多个元素具有相同的值时,我们不能将这些元素放在输出序列的同一个位置上,因此,上述方案还要作适当的修改。
**特性:**stable sort、out-place sort。
**最坏情况运行时间:**O(n+k)
**最好情况运行时间:**O(n+k)
具体实现:
/**
* 计数排序
* @param data
* @return
*/
static int[] countSort(int[] data){
if (data == null || data.length <= 0) {
return null;
}
int[] result = new int[data.length];
result = Arrays.copyOf(data, data.length);
int max = result[0];
int min = result[0];
for (int i = 0; i < result.length; i++) {
if (max < result[i]){
max = result[i];
}
if (min > result[i]){
min = result[i];
}
}
int[] ballot = new int[max + 1 - min];
for (int i = 0; i < result.length; i++) {
ballot[result[i] - min] ++;
}
for (int i = 1; i < ballot.length; i++) {
ballot[i] += ballot[i - 1];
}
for (int i = data.length - 1; i >= 0 ; i--) {
int value = data[i];
int pos = ballot[value - min];
result[pos - 1] = value;
ballot[value - min] --;
}
return result;
}
8. 基数排序
思想:当d为常数、k=O(n)时,效率为O(n)我们也不一定要一位一位排序,我们可以多位多位排序,比如一共10位,我们可以先对低5位排序,再对高5位排序。
引理:假设n个b位数,将b位数分为多个单元,且每个单元为r位,那么基数排序的效率为O[(b/r)(n+2^r)]。当b=O(nlgn),r=lgn时,基数排序效率O(n)
**特性:**stable sort、Out-place sort。
**最坏情况运行时间:**O((n+k)d)
**最好情况运行时间:**O((n+k)d)
具体实现:
/**
* 基数排序
* @param data
* @return
*/
static int[] radixSort(int [] data){
if (data == null || data.length <= 0) {
return null;
}
int[] result = new int[data.length];
result = Arrays.copyOf(data, data.length);
int pos = 0;
while (radixCore(result, pos)){
pos ++;
}
return result;
}
/**
* 这里采用按照每一位进行排序
* @param data
* @param pos
* @return
*/
static boolean radixCore(int []data, int pos){
int[] ballot = new int[10];
int result[] = new int[data.length];
for (int i = 0; i < data.length; i++) {
int key = (int) ((data[i] / Math.pow(10, pos)) % 10);
ballot[key] ++;
}
for (int i = 1; i < ballot.length; i++) {
ballot[i] += ballot[i - 1];
}
//仍然有位参与了排序
if (ballot[9] == ballot[0]){
return false;
}
for (int i = data.length - 1; i >= 0 ; i--) {
int value = data[i];
int key = (int) ((data[i] / Math.pow(10, pos)) % 10);
int position = ballot[key];
result[position - 1] = value;
ballot[key] --;
}
for (int i = 0; i < data.length; i++) {
data[i] = result[i];
}
return true;
}
9. 桶排
思想:基数排序是先映射到大小为10的计数数组中,然后再映射到大小等于待排序数组长度的临时数组中.而桶排序就是直接整个足够大的临时数组,把待排序的元素全部映射过来.其索引为待排序元素数值.所以为了确定临时数组的大小得先算出数组中最大数.
**特性:**out-place sort、stable sort。
最坏情况运行时间:当分布不均匀时,全部元素都分到一个桶中,则O(n^2),当然[算法导论8.4-2]也可以将插入排序换成堆排序、快速排序等,这样最坏情况就是O(nlgn)。
**最好情况运行时间:**O(n)
具体实现:
/**
* 木桶排序
* @param data
* @return
*/
static int[] bucketSort(int data[]){
if (data == null || data.length < 1){
return null;
}
int [] results = new int[data.length];
results = Arrays.copyOf(data, data.length);
int max = results[0];
int min = results[0];
for (int i = 0; i < results.length; i++) {
if (max < results[i]){
max = results[i];
}
if (min > results[i]){
min = results[i];
}
}
int[] buckets = new int[max - min + 1];
for (int i = 0; i < results.length; i++) {
buckets[results[i] - min] ++;
}
int pos = 0;
for (int i = 0; i < buckets.length; i++) {
count ++;
for (int j = buckets[i]; j > 0; j--) {
results[pos] = i + min;
pos ++;
}
}
return results;
}
证明算法正确性:
对于任意A[i]<=A[j],且A[i]落在B[a],A[j]落在B[b],我们可以看出a<=b,因此得证。