排序算法-选择排序
评价:
最简单: 最符合人的自然思维
最没用: 工程实践中基本不用
时间复杂度: O(n^2)
空间复杂度: O(1)
本节内容:
如何计算时间和空间复杂度
如何对算法进行验证-随机数生成器、对数器
写算法程序的哲学
选择排序算法思路
最符合人思维的一种算法
每次遍历,找到最小的值,将其放在前面(交换),直到找完所有的数值。就排好顺序了,从小到大
此处为什么是“交换”而不是“插入”,我个人的理解:在内存排列中,数组元素的“交换”移动的内存区域更小,而“插入”需要移动的内存区域更多
如何写算法程序?写算法的哲学
- 由简单到复杂
- 验证一步走一步
- 多打印中间结果
- 先局部后整体
- 没思路时先细分
- 先粗糙后精细
- 变量更名
- 语句合并
- 边界处理
具体到选择排序算法,该如何写?
由简单到复杂:先不考虑双层循环,只写最内层的循环,只做第一件核心的事情:循环找到数组中的最小值所在的下标
更近一步拆分:有一个变量专门记录最小值所在的下标,先假设这个下标是0,从1的位置开始选好,到某一个元素的时候,如果对比发现有比下标为0的数值更小的值,则更新最小值下标变量的值为新的下标
粗糙版代码:
private static void sort1(int[] arr) {
int minPos = 0;
for (int i = 1; i < arr.length; i++) {
if (arr[minPos] > arr[i]) {
minPos = i;
}
}
// 此时下标为 minPos 的值就是我们挑选出来的最小值
System.out.println("minPos = " + minPos);
System.out.println("arr = " + Arrays.toString(arr));
// 交换: 将最小值与下标为0的值做交换
int tmp = arr[0];
arr[0] = arr[minPos];
arr[minPos] = tmp;
// 再次打印
System.out.println("after arr1 = " + Arrays.toString(arr));
// 第一轮执行完毕,准备第二轮
minPos = 1;
for (int i = 2; i < arr.length; i++) {
if (arr[minPos] > arr[i]) {
minPos = i;
}
}
// 此时下标为 minPos 的值就是挑选出来的最小值
System.out.println("minPos = " + minPos);
System.out.println("arr = " + Arrays.toString(arr));
// 交换: 将最小值与下标为1的值做交换
tmp = arr[1];
arr[1] = arr[minPos];
arr[minPos] = tmp;
// 交换后,下标为1的位置就是本轮挑选出来的最小值
System.out.println("arr2 = " + Arrays.toString(arr));
// ...
// 发现规律:每一轮的结果就是挑选出一个最小值,并与指定下标的值交换
// 待交换的下标是从0开始递增,直到length - 1
}
精细化
发现规律:每一轮的结果就是挑选出一个最小值,并与指定下标的值交换
待交换的下标是从0开始递增,直到length - 1
private static void sort2(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
int minPos = i;
for (int j = i + 1; j < arr.length; j++) {
if (arr[minPos] > arr[j]) {
minPos = j;
}
}
System.out.println("第 " + i + " 轮外层循环,找到的最小值下标: " + minPos);
int tmp = arr[i];
arr[i] = arr[minPos];
arr[minPos] = tmp;
System.out.println("交换后的数组 = " + Arrays.toString(arr));
}
System.out.println("arr = " + Arrays.toString(arr));
}
计算时间和空间复杂度
先分析时间复杂度,再分析空间复杂度
以sort2方法为例进行时间和空间复杂度的计算,分析每句话的时间复杂度,为了方便表示,一句java代码表示为一个时间复杂度单位
时间复杂度分析
private static void sort2(int[] arr) { // 方法名,不计入复杂度,假设参数重的arr长度为n,以下的代码将以n为基础
for (int i = 0; // 代码 i=0 只会执行一次,计为1
i < arr.length - 1; // 代码 i<arr.length-1 将执行 length-1 次,计为n-1,又因n很大的时候与n-1相差无几,所以可直接计为n
i++) { // 代码 i++ 将执行n次,计为n
int minPos = i; // 执行 n-1 次,计为 n
for (int j = i + 1; // 该语句将执行 (n-1) 次,与外层循环的次数一致
j < arr.length; // 该语句将执行 (n-1)+(n-2)+(n-3)+...+2+1 次,这是个等差数列,换算一下为: n(n-1)/2 = n^2/2 - n/2
j++) { // 该语句是上句话的次数+1,同理当n很大的时候,与n相关的+1相差无几,所以为了方便计算,+1就不在单独加一,即该语句与上句话的时间复杂度相同
if (arr[minPos] > arr[j]) { // 该语句与等差数列的次数相同
minPos = j; // 同上
}
}
System.out.println("第 " + i + " 轮外层循环,找到的最小值下标: " + minPos); // 该语句是调试打印语句,不计入时间复杂度
int tmp = arr[i]; // 该语句将执行 (n-1) 次
arr[i] = arr[minPos]; // 同上
arr[minPos] = tmp; // 同上
System.out.println("交换后的数组 = " + Arrays.toString(arr)); // 调试打印语句,忽略
}
System.out.println("arr = " + Arrays.toString(arr)); // 调试打印语句,忽略
}
从以上的源码注释的时间复杂度分析来看,时间复杂度幂次最高的就是那个等差数列,即 n^2/2 - n/2
,而整个算法的时间复杂度只需要找到幂次最高的那一句话当作整个算法的时间复杂度即可
再将复杂公式化简,忽略常数项,忽略低幂次项——只保留最高幂次——的值为 n^2
,即整个算法的时间复杂度为: O(n^2)
可以看到两层循环的时间复杂度就是n^2
,以后可以直接寻找多层循环,有基层循环基本时间复杂度就是n的几次方
空间复杂度分析
空间复杂度
是指算法需要用到的额外空间。
原则:主要寻找跟数据大小相关的空间,而忽略单个基本类型变量,如for循环中的索引变量i
和内层循环的索引变量j
,也忽略作为交换用的int tmp
变量
选择排序算法中,并没有额外用到跟数据体积相当的空间,所以空间复杂度是最基本的O(1)
选择排序的“不稳定性”
什么叫做不稳定呢?即一个数组中有两个值相当的元素,经过选择排序之后,这两个元素可能发生交换。这两个元素的相对位置是不稳定的
举个例子,假设以下是待排序的数组:
int[] arr = {5, 4, 2, 5, 1, 3};
在方法sort2()
中发生交换之前增加一句调试打印代码:
System.out.printf("即将发生交换arr[%s]=%s <-> arr[%s]=%s\n", i, arr[i], minPos, arr[minPos]);
可以看到有以下输出:
即将发生交换arr[0]=5 <-> arr[4]=1
即将发生交换arr[1]=4 <-> arr[2]=2
即将发生交换arr[2]=4 <-> arr[5]=3
即将发生交换arr[3]=5 <-> arr[5]=4
即将发生交换arr[4]=5 <-> arr[4]=5
arr = [1, 2, 3, 4, 5, 5]
我们看到最后一行的结果显示排序结果确实是按照从小到大的顺序排列,这是正确的顺序,但别急,看看数值为5的元素,是否被来回的交换位置
下标为0的数值5的交换路径: [0] -> [4]
下标为3的数值5的交换路径: [3] -> [5]
有人说,这里两个数值5的相对顺序没变啊,第一个5从0号位置交换到4号位置,第二个5从3号位置交换到5号位置,交换之后它们仍然保持着相对顺序。
不着急,上面的例子是我随手写的,正好没有命中不稳定的特殊场景,下面我们就根据这个例子来稍微改一改原始数组的顺序即可触发不稳定的场景,
我们将末尾两个数字的1和3交换,得到如下的数组:
int[] arr = {5, 4, 2, 5, 3, 1};
我们再次执行选择排序算法得到输出:
即将发生交换arr[0]=5 <-> arr[5]=1
即将发生交换arr[1]=4 <-> arr[2]=2
即将发生交换arr[2]=4 <-> arr[4]=3
即将发生交换arr[3]=5 <-> arr[4]=4
即将发生交换arr[4]=5 <-> arr[4]=5
arr = [1, 2, 3, 4, 5, 5]
下标为0的数值5的交换路径: [0] -> [5]
下标为3的数值5的交换路径: [3] -> [4]
此时第一个5从0号位置交换到了5号位置,第二个5从3号位置交换到了4号位置,于是这两个5在各自的交换后,发生了相对位置的改变,不稳定性的场景被触发。
排序不稳定对实际业务影响的举例
银行的储户的排序,对储户存储金额从大到小排序,但存在大量的储户金额相等,于是金额相等的储户的顺序就不稳定,可能某次活动刚好对前1000个最大的储户送礼品,由于排序不稳定从而第N个人,划在第N个人的金额是50万,而正好50万的储户有100个人,如果将这100个人全部纳入送礼品的范围那么总人数将达到1001人,但业务规则严格要求只能是前1000个人,所以必须有一个存款金额为50万的储户被排出在外。
选择排序算法对这些储户排序不能保证排序前的存款50万的储户的相对顺序不变,这就是选择排序算法的“不稳定性”
如何对算法进行验证?
验证算法–对数器(对比数组的器具)
- 肉眼观察 – 仅用在测试代码,不能用在工程代码上,因为:容易出错;样本大的时候没法肉眼看。所以步骤从第2条开始:
- 产生足够多的随机样本
- 用确定正确的算法计算样本结果
- 对比被验证算法的结果
产生足够多的随机样本
生成一个数组,数量由参数指定,然后创建一个随机对象,再为数组的每一个下标都产生一个随机整数,随机整数的最大值由参数指定,代码如下:
private int[] generateRandomArray(int len, int max) {
int[] arr = new int[len];
Random r = new Random();
for (int i = 0; i < arr.length; i++) {
arr[i] = r.nextInt(max);
}
return arr;
}
比较两个数组是否相等
如果长度不相等,则数组必然不等,否则遍历两个数组,分别取出两个数组对应下标中的值,最对比,如果发现不等,则返回不相等,如果遍历结束都没有发现不相等,则返回相等,代码如下:
private boolean checkArrayEquals(int[] arr1, int[] arr2) {
if (arr1.length != arr2.length) {
return false;
}
for (int i = 0; i < arr1.length; i++) {
if (arr1[i] != arr2[i]) {
return false;
}
}
return true;
}
辅助打印大数组的前N个元素
由于大数组的数值太多,全部打印出来也不方便查看,在排序的场景中,只查看前N个元素是否有序就可以大致反应整个数组的顺序情况,代码如下:
private void printFirstN(int[] arr, int n) {
System.out.print("first-" + n + ": ");
for (int i = 0; i < n; i++) {
System.out.print(arr[i] + " ");
}
System.out.println();
}
验证算法的主要函数
前面完成了辅助函数后,就可以编写主要函数了。先随机生成10000个元素的数组,再拷贝一份,得到两份,一份用作我们自己的排序算法排序,另一份用作标准的排序函数排序,再将两种排序算法的结果作对比,代码如下:
public void test_data_checker() {
for (int i = 0; i < 10; i++) {
int[] arr = generateRandomArray(10000, 10000);
int[] arr2 = new int[arr.length];
System.arraycopy(arr, 0, arr2, 0, arr.length);
printFirstN(arr, 10);
SelectionSort.sort2(arr);
Arrays.sort(arr2);
printFirstN(arr, 10);
printFirstN(arr2, 10);
boolean b = checkArrayEquals(arr, arr2);
if (!b) {
throw new RuntimeException("排序结果与标准排序算法不一致");
}
}
}
将以上逻辑包装在循环中,循环多次,发现某一次的结果不同,则抛出异常信息