排序
普通排序基本都要O(N^2),复杂点的排序算法基本都要O(NlogN)
插入排序
不停往前面排序好的集合插入新元素并重排序。如果数据事先基本有序,那么只要O(N),否则最坏就要O(N^2)
冒泡排序
不断把更大的数交换到后一位,于是大数经过迭代都冒泡到数组后端,最后排序好。
选择排序
每次找出剩余元素中最大/最小的元素放到最后/最前,然后在剩下的元素里重复流程。
冒泡和选择排序每次外层循环都要d-1次比较,本质差不多,但是选择排序并不需要一直进行交换操作,性能略好,缺点是不是一个稳定算法。
逆序:i<j,然而array[i]<array[j],这就叫做一个逆序对。
任何普通的排序算法,都是通过一次交换相邻两项来减少一个逆序对,根据一个定理
N个互异数的数组的平均逆序数是N(N-1)/4
我们可以知道,一次只减少一个逆序对的算法,其代价显然是O(N^2),上述的三种排序都属于此类。为了改进排序算法,就必须找到一次能减少多个逆序对的算法
希尔排序
希尔排序的想法是一次远距离交换可能隐式地完成多个逆序对的减少。选择排序也是远距离,也是一次交换,但不是一次比较,希尔排序比较大胆,比较一次就交换一次,至于能减少多少个逆序对,就主要取决于增量序列的设计了。希尔排序也许不想归并和快排那样强的普适能力,但是编码却比之简单得多。工程中也常用希尔排序进行中等规模数据的排序.
希尔增量: 其最坏运行情况为O(N^2)
Hibbard增量: 其最坏运行情况为
堆排序
建堆只需O(N)时间,而删除需要O(logN)时间(因为要上下滤),删除k次我们就能得到有序的k个元素。如果用一个新数组存放这k个元素,未免有些浪费空间,一种做法是放到堆尾,堆大小-1。这样操作的结果是获得一个反序列。比如之前堆是总体递增,则序列递减,反之递增。由于要删除N次,最后的复杂度是O(NlogN)
归并排序
考虑将两个子序列合并,从两个序列的第一个元素开始比较,较小者放入新序列,然后较小序列推进一位,一直比较到两个序列元素都用完,由于每次插入一个元素,最多会比较N-1次。这是递归分治思想的典型应用,其复杂度是O(N+NlogN)=O(NlogN),N是并的开销,NlogN 是归的开销
归并排序的主要开销在于并操作时比较和移动元素。在Java中,比较是慢的,而移动却很快(因为只是移动指针),而在C++里面比较是很快的(有内嵌优化),可是移动却很慢(需要拷贝整个对象),所以Java里面归并排序表现的很好,事实上就是java标准库里面泛型排序的实现。
如何权衡比较和移动的开销,需要我们根据实际情况(如语言)进行调整,比如java下面对基本类型排序,可以用快排,但对复杂对象排序时,还是用归并更好。下面描述的快速排序就拥有较少的元素移动和较多的比较操作,更适合在C++/java基本类型比较中实现。
理想中,对于能够适应任何对象比较的一般排序算法来说,归并排序已经触碰到了极限,其最差复杂度也只是O(NlogN)而已。
快速排序
快速排序是一种高度优化的递归分治排序算法,拥有精炼和高效的内部循环,其平均运行时间为O(NlogN),最坏情况是
O(N^2),虽然这一般不会出现。
从表面上来看,快排似乎和归并没什么区别,都是分成两部分去递归地排序。但是,快排能够选择一个恰当的分割点作为比较基准进行分割,而归并只能从中对半分,差别就在这里。快速排序不保证一定能以O(NlogN)运行,但是不需要归并排序那么多空间(需要一个临时数组来一个个放入排序元素)。实践中通过快排和归并的组合能权衡这两者的利弊
选择分割点的策略
如果只想着拿第一个元素作为比较基准,那么很可能几乎所有元素都跑到该元素的某一边去,而另一边几乎没有元素(比如输入集事先基本有序),这会导致O(n^2)的运行时间,而且基本没用。
比较安全的做法是随机选一个位置,这比较平庸,坏运气不可能总光顾我们。然而,随机数生成带来的开销可能有点大。
三数中值分割法
理想情况下我们希望分割点是这些数的中位数,这能让分割点左右两边的集合基本大小相等。但是如果又额外去计算中位数,就会拖慢快排。我们可以抽样:选三个元素,取其中位数。随机抽样并不见得有多好,更普遍的做法是抽样左端、右端、中心三个位置的元素。
知道了如何找分割点,下一步是如何小心地进行分割
分割策略
将分割点放到最后一位,假设是k,现在我们设立两个指针i=0,j=k-1
我们让i往后推进,j往前推进,当i与j交错,我们知道这一趟粗略的排序完成了
i遇到比分割点大的元素停下,j遇到比分割点小的元素停下,如果都停下了,交换这两个元素。直观上来说,这会导致比分割点大的元素都放到右边,比分割点小的元素都放到左边。停下的意思是等待时机更正逆序对。最后把分割点和i指向的元素交换。
一个问题在于,如果有元素和分割点相等怎么办。如果说停下进行交换,这种交换没啥意义。如果说不停下继续推进,极端条件下可能导致分割的极度不平衡。考虑所有数都相等的输入集,i不会停下,从而一直移动到k-1处,这时交换分割点和i,导致分割点右边啥也没有,左边却包含N-1个元素,显然是非常糟糕的。
所以我们选择让指针遇到和分割点相等元素时停下的策略,尽管这会导致一些无意义的交换,但总比产生一个很烂的分割拿去递归要好的多-----交换只是当次循环,递归却涉及多得多的循环。
当某个子递归调用的元素个数少于10个,就没啥必要再去分割递归了,直接在函数内调用插入排序就行了。
如果我们只需要解决选择问题,那么每次只需要一次递归:因为第k大的元素要么在分割点的左边,要么在右边。
桶排序
创建一个数组,数组下标是自然有序的,每个位置看做一个桶,新元素放入对应桶中,最后按序打印数组即完成排序。这种思路更像是一个可扩散列。
基数排序
就是桶排序的一种应用,或者说多趟桶排序。比如说个十百位需要三次桶排序,比如说长为L的字符串集排序需要L次桶排序。在元素长度一样 的时候,先排低位还是高位无关紧要。但元素长度不一样时,我们要先将元素按长度分组(这又可以是一个桶排序),然后先对长的组进行高位桶排序,这样就能兼容了,后面再对短的组进行低位桶排序能正确作用到长的组上,反过来就出错了。
桶排序和基数排序都能保证在常数时间内完成,但是编码复杂的同时,复用性也不高。
外部排序