三大排序是基础算法的第一课,它指的是10种排序算法中的选择、插入和冒泡排序,他们是算法学习中的基石。在面试中,面试官基本不会让候选人直接写这三种算法,但如果你是一个初学者,要想直接理解算法思想并写出正确的代码还是有些难度的。此外,我们也可以借助这三种算法充分理解如何估算代码的时间复杂度。话不多说,让我们开始吧!
选择排序
想一下我们在打牌时,抓完牌是如何将手中的纸牌排好序的呢?是不是先找出手中最小的牌放到最边上,然后再找出次小的牌放到刚才牌的旁边,循环往复直到手中的牌排好序,这其实就是典型的选择排序。用一句话概括选择排序的算法思想:在要排序的一组数中,选出最小的数与第一个数交换,然后再在剩下的数中选出最小的数与第二个数交换,直到第n-1个数与第n个数比较为止。以数组[3, 2, 5, 1, 4]
为例,设置一个指针i
,arr[0...i-1]
部分就是我们认为的数组的有序部分。arr[i...n-1]
部分是无序部分。初始状态i
指向数组的0
位置,有序部分为空,再设置一个指针j
在数组的arr[i...n-1]
位置寻找无序部分的最小值,然后将找到的最小值与arr[i]
交换。在这个例子中,3
位置的1
是这一轮找到的最小值,所以将3
位置的1
和0
位置的3
交换位置,第一轮选择排序过后数组就变成了[1, 2, 5, 3, 4]
。
接下来,i
指针指向1
位置的2
,j
指针查找1
位置到4
位置中元素的最小值,发现最小值就是i
指针指向的1
位置的2
,自己和自己交换位置,数组不变。
i
指针继续右移,指向2
位置的5
,j
指针在2
, 3
, 4
三个位置查找最小值,发现是3
位置的3
,所以将它和2
位置的5
交换位置,数组变成[1, 2, 3, 5, 4]
i
指针再右移,指向3
位置的5
,本轮数组中无序部分的最小值是4
位置4
,所以将这两个元素做交换,数组变成[1, 2, 3, 4, 5]
。
i
指针继续右移,此时i
已经来到了数组中的最后一个位置,意味着数组中的其他元素都已经来到了正确的位置,那么最后一个元素肯定也在正确的位置了,整个排序流程到这里就可以结束了。
选择排序的代码如下
java
public static void selectionSort(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
int n = nums.length;
for (int i=0; i<n-1; i++) {
int minIndex = i;
for (int j=i+1; j<n; j++) {
if (nums[j] < nums[minIndex]) {
minIndex = j;
}
}
swap(nums, i, minIndex);
}
}
/**
* 交换nums数组中的nums[i]和nums[j]
*/
public static void swap(int[] nums, int i, int j) {
if (i == j) {
return;
}
nums[i] = nums[i] ^ nums[j];
nums[j] = nums[i] ^ nums[j];
nums[i] = nums[i] ^ nums[j];
}
分析一下代码的时间复杂度,代码的主体是两层for
循环,外层循环执行了n-1
次,当i = 0
时,内层循环j
从1
到n-1
,执行了n-1
次;当i = 1
时,内层循环j
从2
到n-1
,执行了n-2
次;以此类推,当i = n - 2
时,内层循环执行了1
次。所以整段代码一共执行的次数就是(n-1) + (n-2) + (n-3) + ... + 1
,根据等差数列的求和公式,上面这个公式的结果就应该是
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ ( n − 1 ) + 1 ] × ( n − 1 ) 2 = 1 2 n 2 − 1 2 n \frac {[(n-1) + 1] \times (n-1)}{2} = \frac 1 2 n^2 - \frac 1 2 n </math>2[(n−1)+1]×(n−1)=21n2−21n
时间复杂度一般是用于评估当数据量趋近于无穷大时代码执行次数的数量级,所以在计算的时候一般取公式中的最高阶,并忽略掉常数项,所以选择排序的时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)
插入排序
明白了选择排序的流程,接下来再来看一下插入排序。用一句话描述插入排序的算法流程:先将数组的第0个元素看成是一个有序的子数组,然后从第1个元素开始将元素逐个插入有序部分,直到整个数组全部有序为止。还是以上面的数组为例,[3, 2, 5, 1, 4]
,首先将数组的第0
个元素看成是有序部分,设置一个指针i
指向数组的第1
个位置,意思是现在想要将第1
个元素2
插入到前面数组的有序部分。
将1
位置的2
和前面的3
做比较,发现2 < 3
,所以将两个元素交换位置,然后2
就来到了数组的第0
个位置,前面没有元素了,所以本次循环到此结束,数组的有序部分变成了nums[0...1]
。
接下来将i
指针右移一位,指向2
位置的5
,意味着现在要将2
位置的5
插入到前面的有序部分中。5
和前面的3
做比较,发现5 > 3
,这次循环到这里就可以直接结束了,数组的有序部分变成了nums[0...2]
i
指针继续右移,指向3
位置的1
,将这个元素插入到数组的有序部分。所以将1
和左侧的5
做比较,发现1 < 5
,所以交换位置,数组变成[2, 3, 1, 5, 4]
;继续和前面的位置比较,发现1 < 3
,所以继续交换位置,数组变成[2, 1, 3, 5, 4]
,继续比较,1 < 2
,交换位置数组变成[1, 2, 3, 5, 4]
,此时这个1
已经来到了数组的0
位置,左侧没有元素了,所以循环结束。
i
指针继续右移,指向4
位置的4
,4
小于左侧的5
,所以交换位置,再和左侧的3
比较,发现4 > 3
,本次循环直接结束。此时i
已经指向元素中最后一个位置了,整个数组已经有序,流程结束。
插入排序的代码贴在下面
java
public static void insertionSort(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
// nums[0]看成是数组的有序部分,将nums[1...n-1]向数组的有序部分插入
int n = nums.length;
for (int i=1; i<n; i++) {
// 把nums[i]插入有序部分
int j = i;
while (j > 0 && nums[j] < nums[j-1]) {
swap(nums, j, j-1);
j--;
}
}
}
public static void swap(int[] nums, int i, int j) {
if (i == j) {
return;
}
nums[i] = nums[i] ^ nums[j];
nums[j] = nums[i] ^ nums[j];
nums[i] = nums[i] ^ nums[j];
}
看一下插入排序的时间复杂度。这段代码的主体也是这个双层循环,外层的for
循环执行n-1
次,里面的while
循环执行了多少次呢?这个取决于你的数据情况。最好的情况是数组原本就是有序的,每一次for
循环中nums[i]
都是直接大于nums[i-1]
的,这意味着每次while
循环都判断循环条件不通过就直接结束了,这种情况下算法的时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n);
最差的情况下,数组原本是倒序的,每一次for
循环中,nums[i]
都要一步一步地移动到数组的最前面才能结束,while
循环要执行i
次。也就是说当i = 1
时,while
循环执行1
次;i = 2
时,while
循环执行2
次;i = n - 1
时,while
循环执行n - 1
次,把这些执行次数加和之后依然形成上面的等差数列的求和,所以插入排序的最差时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)。评价一个算法的时间复杂度一般都是以最差情况来估算的,所以我们说插入排序的时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)
冒泡排序
最后来看一下冒泡排序。冒泡排序是通过数组中无序部分相邻元素的比较和位置的交换,使得最大的记录像气泡一般逐渐向上"漂浮",一直漂浮到无序部分的最后位置;还是以数组[3, 2, 5, 1, 4]
为例,首先数组没有有序部分,全都是无序的,0
位置的3
和1
位置的2
比较,3 > 2
,所以交换位置数组变成[2, 3, 5, 1, 4]
;接下来1
位置的3
和2
位置的5
做比较,不交换位置;2
位置的5
和3
位置的1
做比较,交换位置,数组变成[2, 3, 1, 5, 4]
;3
位置的5
和4
位置的4
做比较,交换位置,数组变成[2, 3, 1, 4, 5]
,本次循环结束,数组的有序部分是nums[4]
,无序部分是nums[0...3]
。
接下来,在数组的无序部分nums[0...3]
继续冒泡,0
位置的2
和1
位置的3
比较,不交换位置;1
位置的3
和2
位置的1
做比较,交换位置,数组变成[2, 1, 3, 4, 5]
;2
位置的3
和3
位置的4
做比较,不交换位置,循环到此结束,数组的有序部分是nums[3...4]
,无序部分是nums[0...2]
。
接下来在数组的无序部分nums[0...2]
继续冒泡,0
位置的2
和1
位置的1
做比较,交换位置,数组变成[1, 2, 3, 4, 5]
,1
位置的2
和2
位置的3
做比较,不交换位置,循环结束。
接下来数组的无序部分只剩下nums[0...1]
了,对这两个元素做比较,不交换位置,循环结束,由于数组后n-1
个元素都已经来到正确的位置了,所以第一个元素位置也是正确的,整个排序流程就结束了。
冒泡排序的代码如下
java
public static void bubbleSort(int[] nums) {
if (nums == null || nums.length <= 1) {
return;
}
int n = nums.length;
boolean changed = false;
for (int i=n-1; i>0; i--) {
// 要把最大的数冒到i位置
for (int j=0; j<i; j++) {
if (nums[j] > nums[j+1]) {
changed = true;
swap(nums, j, j+1);
}
}
if (!changed) {
// 如果一次循环中没有元素的交换,说明数组已经有序,可以直接跳出循环
break;
}
}
}
public static void swap(int[] nums, int i, int j) {
if (i == j) {
return;
}
nums[i] = nums[i] ^ nums[j];
nums[j] = nums[i] ^ nums[j];
nums[i] = nums[i] ^ nums[j];
}
上面的代码中,如果数组原本就是有序,那么会在首次执行for
循环时直接break
跳出循环,这样算法的时间复杂度就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n);最差情况下,外层for
循环执行了n-1
次,i = n - 1
时,内层for
执行了n - 1
次;i = n - 2
时,内层for
执行了n - 2
次,以此类推,把这些执行次数加和之后仍然是一个等差数列的求和,所以算法的最差时间复杂度仍然是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)