排序:
排序操作是算法学习中的经典部分。尽管很多语言中都已经提前实现了排序功能,直接提供了排序函数,对于初学者来说,学习各种排序算法的思想仍是十分必要的。
今天主要介绍三种算法:经典的冒泡排序、插入排序和选择排序。不过在此之前,先来看一下评估排序算法优劣的几个标准。
分析算法的方向:
- 排序算法的执行效率:
注意,这里的执行效率并不能简单的等同于分析时间复杂度。
(1)要考虑低阶、常数还有系数。通常情况下,我们分析的时间复杂度都是针对在输入数据非常大的情况,所以低阶、系数和常数都被忽略了。然而在实际的开发当中,对于较小的数据量的排序也是经常需要的,所以在评估使用哪种算法是也需要把这些考虑进来。
(2)最好情况、最坏情况和平均时间复杂度。由于原始数据的秩序不定,所以需要了解算法在各种情况下的执行情况。
(3)比较次数和交换次数。
排序算法的内存消耗,就是通过算法的空间复杂度来衡量。特别说一下原地排序:就是空间复杂度为O(1)的排序算法。
排序算法的稳定性:经过排序之后,相等的数据原有的先后顺序不变。尽管进行了排序,原数据序列被“打乱”,但是稳定性较高的算法仍然保存了原本序列生成时的一些信息(排序时比较的常常是数据的主键,而非主键信息之间的关系有时也具有使用价值,比如输入时间的的先后关系)。
冒泡排序法
所谓冒泡就是从序列的第一个元素开始,与序列中其他的元素一一比较(可能遍历整个数组),每次只比较相邻的两个元素,如果满足大小条件,则交换位置,然后再进行下一个数的比较。我们来看一下具体的实现。
public static void main(String args[]) {
//等待被排序的数组
int[] nums = {4,6,1,5,3,2};
for(int i = 0; i<nums.length;++i) {
//循环遍历的总轮次
boolean flag = false;
for(int j = 0;j<nums.length-i-1;++j) {
/*j<nums.length-i-1
* -1表示防止数组越界,因为当前数总是在与“下一个数”比较
* -i表示去除已经比较完毕的较大的数*/
if(nums[j]>nums[j+1]) {
//交换位置
int temp = nums[j];
nums[j] = nums[j+1];
nums[j+1] = temp;
}
}
if(!=flag) break; //有关flag的两行等下会解释
}
}
以第一遍历比较为例,解释一下这个操作究竟干了些什么:
容易得出一轮遍历比较只能得出一个“最大数”,让它“冒”到队伍末尾。第二层循环的意义在于能够一直紧盯着当前的较大值,让它找到合适的位置,找到之后在开始下一轮大循环。
(比如上例中nums [ j ] = 6,nums [ j + 1 ] = 1,交换位置后( + + j ),新的nums [ j ] = 6,再与nums [ j + 1 ] 进行比较,较大的值能被一直关注,直到找到最终位置)
让我们来分析一下这种算法:
1. 算法的内存消耗:
如果发生交换行为,程序只需要申请一个(常量级)变量,空间复杂度为 O(1),属于原地排序。
2. 算法的稳定性:
示例中,数值相等的情况下不进行交换,原有的顺序不受干扰。
3. 算法的执行效率:
最好情况当然就是初始序列就是顺序的,也就“不用排序了”。然而上述结论是由我们的眼睛观察得出的。对于计算机来说,还是要遍历数组。一开始我觉得flag两行是多余的,因为在绝大多数情况下没什么用。但是在最好情况下,它却能降低在最好的情况下时间复杂度。在这里简单解释一下两层循环的意义:内循环是为了"跟随",每次比较的都是针对一个数。外循环是为了找到下一个大数。如果没有发生交换"小循环",就说明没有"逆序"存在,也就是不需要交换,"跟随"没有发生,序列无需再次寻找所谓"下一个大数"。
如果没有这样的判断,在一切情况下两层循环都会执行,复杂度为O(n^2);现在添加了判断,在最好情况下就只有里层循环执行,复杂度为O(n)。