排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。分内部排序和外部排序。若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序。反之,若参加排序的记录数量很大,整个序列的排序过程不可能在内存中完成,则称此类排序问题为外部排序。内部排序的过程是一个逐步扩大记录的有序序列长度的过程。
上面是关于排序的一些介绍,排序按是否使用外在存储来分分为内部排序(locally sort)和外部排序(external sort)。下文分析的是内部排序的两种常见的、简单易实现的排序算法,虽然两者的排序算法的时间复杂度在众多内部排序算法中属于下等,但是对于理解内部排序(特别是采用比较排序)的一些机制是十分有用的。
常见的内部排序算法及其时间复杂度主要如下:
O(n*n)
i
个待排元素i
与数组中前i-1
个已经有序的元素j
进行两两比较,如果元素 i
小于 j
,便交换。直到遇到比i
小的元素或者到达数组头。由于每一个元素的插入都会最多比较i - 1
次,因此最坏的时间复杂度为 1 + 2 + 3 + ... + n - 1 = n * (n - 1) / 2
, 因此时间复杂度也为O(n*n)
。 O(n*n)
O(nlgn)
,由于待排数据如果是逆序,其时间复杂度会变为O(n*n)
,因此常用的方法是在排序前先随机化待排数据。O(nlgn)
,需要额外的存储空间O(nlgn)
0 ~ K
,时间复杂度为O(n)
d
,每一位的范围为0~k
。上文中介绍的除了计数排序和基数排序外,其余均为内排序中的比较排序,即通过两元素的比较,来确定元素正确放置的位置。
插入排序算是一种比较经典的比较类排序算法,可以分析其时间复杂度,然后确定其最坏和平均时间复杂度,从而来确定时间复杂度的最低边界。
由于没有比较,就不会有元素的交换,因此元素的交换次数是小于等于
最坏时间复杂度分析:若输入的n
个数据是逆序的,那么对第i
个元素的插入,需要比较i-1
次,因此总的时间复杂度为为n*(n - 1) / 2
平均时间复杂度分析:对于第i
个元素的插入,该元素有i + 1
个位置可以插入,平均来看,插入每一个位置的概率都相同为1 / (i + 1)
;对于每一个位置比较的次数分别为1, 2, 3, 4, 5 ... i, i
,因为当i
元素为最小和次小的时候,比较次数都为i
次,(当比较i
次后,便可以知道该元素是最小还是次小了),所以插入第i
个元素的比较次数为1 / (i + 1) * (1 + 2 +3 + ... + i) + i / (1 + i)
,那么对于n
各元素的n-1
次插入来看,总的比较次数近似于n * n / 4
。所以插入排序的时间复杂度为n * n / 4
对于一个待排序列S,S(i)元素表示位置应该在i的元素。那么如果S(i) < S(j),i > j,称(S(i),S(j))为逆序对(inversion),对于每一个n个元素的输入,最多有n * (n - 1) / 2
个逆序对,如果比较排序算法每一次比较最多消除一个逆序对,那么比较排序算法的最坏时间复杂度至少为 n * (n - 1) / 2
。
考虑待排序列的转置(transpose) 序列T,转置序列T和序列S的元素顺序是颠倒的。显然一个逆序对(inversion)不是在序列S中,就是在其转置序列T中,因此两个序列的逆序对数量之后等于n * (n - 1) / 2
,每个序列逆序对数量平均为n * (n - 1) / 4
约为n*n/4
。
以上分析可以得出下面的理论
对于含有
n
个元素的待排输入,任何通过比较来排序的算法,若其每一次比较至多消除一个逆序对的话,其在最坏情况下至少得比较n(n-1)/2
次,在平均情况下至少得比较n(n-1)/4
由于插入排序的最坏和平均情况下比较的次数都接近上述分析给出的数值,考虑那些只比较交换相邻元素的算法,插入排序可以算是最好的算法了。如果想要显著的改善此类排序算法的性能,我们必须在一次将元素移动超过一个位置。(或是一次消除多个逆序对)。这种想法给性能较好的快速排序和希尔排序等带来新的思路。
下面是插入排序的代码示例:
void insertSort(int* a, int n) {
int i = 0,j = 0;
for(i = 1; i < n; i++)
for(j = i; j > 0; j--) {
if(a[j] < a[j-1])
swap(&a[j], &a[j-1]);
else
break;
}
}
冒泡排序得名于其简单,易用的排序风格。冒泡排序通过每一趟相邻元素的的两两比较确定某一个元素的正确的位置,这样进行n
趟就确定了n
个元素的位置了。冒泡排序的代码示例如下:
void bubbleSort(int* a, int n) {
int i = 0;
int j = 0;
for(i = 1; i <= n; i++)
for(j = 0; j < n - i; j++) {
if(a[j] > a[j+1])
swap(&a[j], &a[j+1]);
}
}
最常见的冒泡排序如上所示。
如果输入的待排序列的尾部是部分有序的,那么内部循环的j
就没有必要增加到n-i
了,直接在上一趟最后发生元素交换的位置lastSwapIndex
,这样就可以减少没有必要的的比较了。例如序列7,8,5,6,9,10,11
,第一趟比较,7,5,6,8,9,10,11
后的结果为最后交换的位置为元素6
的位置(8
与6
交换),因此下一趟比较的时候,j
就没有必要增加到元素10
的位置了,因为上一趟最后发生交换的位置lastSwapIndex
代表了此位置以后的元素都已经完排序了,不需进行没必要的比较了。
因此改进后的冒泡排序如下:
void bubbleSort1(int* a, int n) {
int i = 1;
int j = 0;
int lastSwapIndex = n - i;
int q = n - 1;
//n趟
for(i = 1; i <= n; i++) {
for(j = 0; j < q; j++)
if(a[j] > a[j+1]) {
swap(&a[j], &a[j+1]);
lastSwapIndex = j;//记录每一趟最后交换的位置
}
q = lastSwapIndex;
}
}
通过记录上一趟待排序列尾部中最后一次进行元素交换的位置,为下一趟省去了不必要的比较次数,从而改进了冒泡排序。
同样的思路,如果待排序列的开始已经是部分有序了,那么内部循环的j
也没有必要每一趟都从0
开始了。例如待排序列1,2,3,4,9,7,8,6
第一趟首次发生交换的位置为元素9
的位置,那么下一趟j
就没有必要从0
开始了,而是从元素9
的位置beginSwapIndex
的前一个位置(记为beginSwapIndex - 1
)开始,进行比较。因为上一趟的首次发生交换的位置beginSwapIndex
表明此位置beginSwapIndex - 1
的元素都是有序的了,下一趟的比较就从位置eginSwapIndex - 1
开始,为什么是beginSwapIndex - 1
? 而不是beginSwapIndex
呢?是因为上一趟首次发生交换后,被交换到位置beginSwapIndex
元素无法确定是否大于位置beginSwapIndex - 1
的元素,因此需要从beginSwapIndex - 1
的元素继续开始比较。
下面是改进版本2的代码示例
//改进版1
void bubbleSort1(int* a, int n) {
int i = 1;
int j = 0;
int lastSwapIndex = n - i;
int q = n - 1;
//n趟
for(i = 1; i <= n; i++) {
for(j = 0; j < q; j++)
if(a[j] > a[j+1]) {
swap(&a[j], &a[j+1]);
lastSwapIndex = j;//记录每一趟最后交换的位置
}
q = lastSwapIndex;
}
}
//改进版2
void bubbleSort2(int *a, int n) {
int i = 1;
int j = 0;
int lastSwapIndex = n - i;
int beginSwapIndex = 0;
int q = n - 1;
int p = beginSwapIndex;
int firstFlag = 1;//是否为首次发生交换的flag
//n趟
for(i = 1; i <= n; i++) {
for(j = p; j < q; j++)
if(a[j] > a[j+1]) {
swap(&a[j], &a[j+1]);
lastSwapIndex = j;//记录每一趟最后交换的位置
if(firstFlag) {//记录每一趟开始交换的位置
beginSwapIndex = j == 0 ? 0 : j - 1;
firstFlag = 0;
}
}
q = lastSwapIndex;
firstFlag = 1;
p = beginSwapIndex;
}
}
通过分析比较排序中经典的插入排序,引入了逆序对和转置的概念,对所有比较排序的算法的平均和最坏时间复杂度进行了最坏的边界分析,从而得到了对于每一次比较最多消除一个逆序对的算法平均时间复杂度的边界上边界,和最坏的时间复杂度的上边界。这些分析给如何改良排序算法提供了途径。通过每一次比较,将元素移动超过不止一个位置(这样就可以消除不止一个逆序对),这样就可以将比较类排序算法显著的改善。