排序就是对一组对象按某种逻辑顺序排列的过程,而经过排序后,这组对象就有了非常好的性质。例如,在一组有序的集合中查找元素会比在无序的集合中查找要快的多,有时候为了节省时间,在有些情况下甚至不得不对集合先排序再查找。 那么怎么进行排序,如何排得更快呢?这个问题的答案就是设计排序算法并进行改进。那么排序算法有好有坏,有简单的实现,有复杂的实现,有运行时间很慢的算法,也有运行时间极快的算法。而快的算法一般就是人们想要的,毕竟谁也不想浪费大把的时间去等待程序来排序数据。
那为啥不直接介绍那些快的算法呢?因为从初级到高级是研究算法的原理,尤其是当算法变得越来越复杂时,我们不得不从简单的部分开始。而且通过比较初级算法与高级算法的性能后,我们就能明白改进算法的必要性。所以我们从较为初级的排序算法开始吧。 但是为了 能够对接下来的要介绍的算法的性能有一个较为精确的分析,我们要先介绍一下关于算法增长的知识。 对于一个算法而言,一次运行时间往往跟向这个算法输入的对象的输入规模有关,一般来说运行时间随着输入规模n的增大而增大,但是算法设计的不同,这个增大的速度也就不一样。我们通过增长量级来判断算法的增长速度,而增长量级有几种不同的表示形式,现在来说明一种。 把一个算法的运行时间随输入规模变化的情况用一个函数f(n)来表示,那么虽然不能对f(n)有一个准确的数学描述,但是却可以用最坏情况来确定f(n)的上界,f(n)的上界用一个符号来表述。例如以下情况: 表示f(n)的上界为 ,即算法运行时间的增长量级小于。 但是确定了上界后,算法的性能仍不能很好的描述,我们还需要另一个概念来判断性能的好坏。输入规模 n 一定时,输入数据的分布不同,算法运行时间也可能不同,所以对于这些不同的分布,我们给定一个平均情况来描述此时的增长量级,用符号说明。则表示在平均情况时算法有着的增长量级。 那么对于好的排序算法,我们希望它的增长量级在最坏情况下不要超过级别,而在平均情况下能达到 的级别。
初级排序算法
这篇文章中,我会介绍三种初级排序算法以及其中一种算法的变体。
选择排序
最简单,也是最自然的一个想法是,我们选择出最小的元素,与最小位置的元素进行交换,然后在剩下的元素中继续找出最小的元素,放到合适的位置,直到整个数组排序完毕。所以在每一次排序中我们需要不断选择出剩余元素中最小的元素,所以这个算法被称作选择排序。而为了实现这一点,我们需要知道最小元素的索引,这需要比较后才能得出,也就是我们要在比较中更新索引值。代码实现如下。
public static void sort(int[] es) {
for(int i = 0;i<es.length;i++) {
int min = i;
for (int j = i;j<es.length;j++) {
if (less(es[j],es[min])) {
min = j;
}
}
swap(es,min,i);
}
}
public static boolean less(int v,int w) {
if (v < w ){
return true;
}
return false;
}
public static void swap(int[] es,int i,int j) {
int tmp = es[i];
es[i] = es[j];
es[j] = tmp;
}
对于一个算法,我们主要看的是它的性能,而性能主要取决于算法的时间复杂度和稳定性,那么这个选择算法的时间复杂度又是多少呢?我们来看看吧,对于规模为n的输入,最坏情况就是输入数据随逆序排列,这样选择算法的增长量级为,既次比较和 n 次交换;而平均情况也是,因为无论输入数据如何分布它都进行了次比较。 所以,选择排序算法的渐进增长量级就是 。那么这样来看,数据的分布并不能影响选择排序算法的增长量级,而该算法所需的交换次数对于任何情况而言仅需要 n 次,这是后面的算法都无法做到的。
冒泡排序
接下来介绍另一种算法,冒泡排序算法,可以说是非常有名的算法,虽然不是很高效,但因为其简单的实现,也许是很多人第一次接触的排序算法。那我们来看看冒泡排序怎么具体实现的吧,冒泡排序在每一趟排序中做的事就是把最大的那个元素通过比较和交换放到最后一位,可以这么说,元素在向后移动时,元素的大小增大,就像从湖里冒出来的泡泡一样,但是它的比较只两两比较,而这也导致它的效率很低。代码实现如下:
public static void sort(int[] es) {
for(int i = 0;i<es.length;i++) {
for (int j = 0;j<es.length-i;j++) {
if (less(es[j+1],es[j])) {
swap(es,j,j+1);
}
}
}
}
public static boolean less(int v,int w) {
if (v < w ){
return true;
}
return false;
}
public static void swap(int[] es,int i,int j) {
int tmp = es[i];
es[i] = es[j];
es[j] = tmp;
}
冒泡排序算法的最坏情况下的增长量级显然是而且由于內循环中涉及到比较和交换两个操作,其运行时间还会大于选择排序的最坏情况的运行时间。在平均情况下冒泡排序的增长量级也是.
插入排序
第三种初级排序算法是插入排序,插入排序的核心就是利用前面一段已排序的子数组的性质,将索引键值向前插入到合适位置,就好比我们在打扑克牌,手上拿的就是已排好的牌,每从牌堆中拿一张牌,就把它插到手上合适的位置,到最后牌堆拿完了,牌也排好了。通过这个比喻我们就知道插入排序算法怎么实现了,代码实现如下:
public static void sort(int[] es) {
for(int i = 1;i<es.length;i++) {
int key = es[i];
int j = i;
for(;j>0 && key < es[j-1];j--)
{
es[j] = es[j-1];
}
es[j] = key;
}
}
现在对插排算法的性能进行分析,在最坏情况下,也就是逆序排列情况下插入排序需要次比较,次交换,也就是 的增长量级,在最坏情况下选择排序与插入排序的增长量级相同;那么在一些数据部分有序的情况下,插入排序的比较次数会远小于选择排序的比较次数,所以平均来看,插入排序的运行时间会小于选择排序的运行时间;不过二者在增长量级上仅仅相差一个常数因子。 综上来看,三个初级排序算法的平均情况增长量级均为;但是插入排序的运行时间最快;而级别的排序算法在输入规模达到10^4以上便不能很快完成了,所以需要更快的算法。
希尔排序
那么接下来介绍一种由插入排序改造的快速算法——希尔排序,它仅仅对插入排序做出了小小的改进,就使得算法的性能产生飞跃式的增进。插入排序效率之所以低,是因为它每次只把元素移动一位,而在一些情况下元素需要移动很远。所以为了改进插入排序算法的性能,我们先对数组进行一个 h 大小排序的插入排序,然后对 h 进行降序到 1。而在这个过程中就实现了将数组h有序化,而这个h在等于一时就是插入排序,但是有了前面的步骤,整个数组就处于比较有序的状态下了,它被分为几个较小的有序的子数组。e而因为插入排序对于较小且有序的数组排得非常快,所以最终的排序速度就提升了一大截。 代码实现如下:
public static void sort(int[] es) {
int N = es.length;
int h = 1;
while(h < N/3) {
h = 3*h+1;
}
while(h>=1) {
for(int i = h;i<N;i++) {
int key = es[i];
int j = i;
for(;j >= h&&(key<es[j-h]);j-= h) {
es[j] = es[j-h];
}
es[j] = key;
}
h = h / 3;
}
}
对于希尔排序的准确性能分析现在还无法做到,但是我们知道在上述的h序列下,它在最坏情况的时间复杂度也就是增长量级达到了 级别,在较好情况下甚至能达到 nlogn 级别。而这显然是一个性能的大飞跃。而且其不用消耗额外空间,实现简单。 希尔排序的实现告诉我们,对于算法而言,我们只需添加几行代码,就可以使算法的速度快上几百倍,对于上文的希尔排序与插入排序的比较就是这样,更大规模的问题也可以解决了。而在接下来这个系列会对更好的排序算法进行介绍以及说明。