一.冒泡排序
冒泡排序是入门级排序算法,因为其时间复杂度的问题,尽管它在业务开发中不是经常使用,但是因为其算法思路简单易想到,并且在大学程序设计中是同学们的入坑级案例,成为程序爱好者们心中最易联想到的排序算法之一。
算法思路
排序算法的过程非常简单,通过比较相邻的两个数字,每次将较大的数字放在后面,重复上面操作,一直到所有的数字全部排序完成位置。具体过程看图:
代码一
常规的冒泡排序算法,代码没用标志位,导致此时的冒泡排序情况是这样的:
最优时间复杂度:O(n2)
最坏时间复杂度:O(n2)
平均时间复杂度:O(n2)
空间复杂度:O(1)
稳定性:稳定
public void bubble(int[] nums){
if (nums == null || nums.length < 2) return;
//外层循环:可看作已完成排序的数字的个数
for (int i = 0; i < nums.length - 1; i++) {
//内层循环:排序一个数字需要比较的次数(随着较大数字排序的完成,比较次数会越来越少)
for (int j = 0; j < nums.length - i - 1; j++){
if (nums[j] > nums[j+1]){
int temp = nums[j];
nums[j] = nums[j+1];
nums[j+1] = temp;
}
}
}
}
代码二
加了标志位,就会避免如下情况:当你循环一次后,发现并没有数字之间的交换,此时数组已经排序完成。当数组开始就是一个有序数组时,有了标志位,代码就会直接输出。
最优时间复杂度:O(n)
最坏时间复杂度:O(n2)
平均时间复杂度:O(n2)
空间复杂度:O(1)
稳定性:稳定
public void bubble(int[] nums){
if (nums == null || nums.length < 2) return;
boolean flag; //设置标志位
for (int i = 0; i < nums.length - 1; i++) {
flag = false;
for (int j = 0; j < nums.length - i - 1; j++){
if (nums[j] > nums[j+1]){
flag = true;
int temp = nums[j];
nums[j] = nums[j+1];
nums[j+1] = temp;
}
}
if (!flag) return;
}
}
二.选择排序
算法思路
超级超级简单的排序,更符合一般人的思维方式,我感觉比冒泡排序更好想到一种排序方式。即:第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。
代码
最优时间复杂度:O(n2)
最坏时间复杂度:O(n2)
平均时间复杂度:O(n2)
空间复杂度:O(1)
稳定性:不稳定
如何理解选择排序的不稳定性?(针对数组,并且不借助额外空间的前提)
我们举出一个实例{5,8, 5, 2, 9},开始a[0] = 5,然后会发生 5 和 2 的置换,导致两个 5 发生了变化,于是就产生不稳定性。
public static void selectSort(int[] nums){
if (nums == null || nums.length < 2) return;
for (int i = 0; i < nums.length - 1; i++){
for (int j = i + 1; j < nums.length; j++){
if (nums[i] > nums[j]) {
int tmp = nums[j];
nums[j] = nums[i];
nums[i] = tmp;
}
}
}
}
三.插入排序
算法思路
插入排序的思路如果明白了其思想将会变得非常清晰。首先,我们要联想到生活中你打扑克牌时是怎么理牌的。是不是接一张牌,就将其插入你手中牌的某个位置(你手中的牌是已经排好了的)。如果你联想起来了,那么事情就好解决了:我们将数组看作两个部分,你手中排好的牌就是数组中的已经完成局部排序的部分,剩下的待排数字即你剩下要接的牌这部分,将会逐个插入前面局部排序的那部分。至此,插入排序完成。
代码一
常规插入排序算法,即按照上面算法思路完成代码。
最优时间复杂度:O(n)
最坏时间复杂度:O(n2)
平均时间复杂度:O(n2)
稳定性: 稳定
public static void insertSort(int[] nums){
if (nums == null || nums.length < 2) return;
for (int i = 1; i < nums.length; i++) {
int val = nums[i];//排序nums[i]
int j = i - 1;//j为局部排序数组的最后一位的索引
//while循环用来确定nums[i]插入的位置
while (j >= 0 && val < nums[j]) {
nums[j+1] = nums[j];
j--;
}
j++;
nums[j] = val;
}
}
代码二
基于二分查找的插入排序算法,判断数字要插入的位置时,由于要插入的数组是已经排序的数组,所以我们可以使用二分查找优化算法。
最优时间复杂度:O(n)
最坏时间复杂度:O(n2)
平均时间复杂度:O(n2)
稳定性: 稳定
public static void binaryInsertSort(int[] nums){
if (nums == null || nums.length < 2) return;
for (int i = 1; i < nums.length; i++) {
int val = nums[i];
int end = i - 1;//end为局部排序数组的最后一位的索引
int start = 0;//start为局部排序数组的最后一位的索引
//while循环用来确定nums[i]插入的位置
while (start <= end){
int mid = (end - start) / 2 + start;
if (val < nums[mid]){
end = mid - 1;
} else {
start = mid + 1;
}
}
//移动数字
for (int j = i; j > start; j--) {
nums[j] = nums[j-1];
}
//将值插入
nums[start] = val;
}
}
四.归并排序
归并排序是一种典型的使用分治思想的高效的排序算法。如果你对分治法很有了解的话,那么掌握归并排序信手拈来了,如果不了解分治法也没关系,跟着我一块捋一捋思路,算法的思想就出来了。(ps:面试官很喜欢考的一个排序算法,另一个是快排,并且许多语言封装的排序api底层都是使用归并排序)
分治法的核心思想就是先分再治,即将问题的求解先分割成多个相同子问题,再将子问题分割成更小的问题直至最后的子问题可以简单的求解出来,而原问题的求解就是子问题的合并。
如上图所示,归并排序的核心也是分为两步:先分再治。分的阶段,我们每次将数组分割成相等的两部分,一直到最后每个子数组只有一个元素,当然这些只包含一个元素子数组是有序的。治的阶段,我们再将有序的子数组合并成一个更大的有序数组,一直到最终的有序数组为原数组为止。至此,归并排序结束。
代码
最优时间复杂度:O(nlogn)
最坏时间复杂度:O(nlogn)
平均时间复杂度:O(nlogn)
稳定性: 稳定
public void mergeSort(int[] nums, int start, int end){
if (start < end) {
int mid = (end - start) / 2 + start;
mergeSort(nums,start,mid);
mergeSort(nums,mid+1,end);
merge(nums,start,mid,end);
}
}
public void merge(int[] nums, int start, int mid, int end){
int[] c = new int[nums.length];//辅助数组
int i = start, j = mid + 1; //两个子数组的头
int k = start;//放入辅助数组的索引
//合并两个数组
while (i <= mid && j <= end){
if (nums[i] < nums[j]) {
c[k++] = nums[i++];
} else {
c[k++] = nums[j++];
}
}
//如果其中还有一个数组还有元素
while (i <= mid) c[k++] = nums[i++];
while (j <= end) c[k++] = nums[j++];
//将已经排好序的数组c复制到nums中
for (int l = start; l <= end; l++) {
nums[l] = c[l];
}
}
五.快速排序
在说快速排序之前,我们来捋捋之前几种排序的思路。冒泡排序和选择排序有点像,每次遍历,都是将一个元素找到它最终排序时所在的位置,就这样一直遍历所有为止。归并排序是采用分治法将所求的数组划分成子数组,对子数组排完序后,再求最终数组的排序。那么,我们不妨大胆的融合一下这两种算法:先确定一个元素的位置,然后通过这个元素的位置将数组分割成左右两个子数组,再对左右两个数组使用上面相同的方法,最终是否能够完成数组的排序呢?答案是可以的,这就是快速排序的思想。那么请思考一下这个算法是否有极端情况下缺陷呢?是有的,当你每次确定元素的位置都在数组头部或者尾部,那么我们就无法将数组分成我们原本希望的两个数组,转而算法的思想结构由二叉树结构退化成了链表结构,时间复杂度提高。
代码
最优时间复杂度:O(nlogn)
最坏时间复杂度:O(n2)
平均时间复杂度:O(nlogn)
稳定性: 不稳定
public void quickSort(int[] nums, int start, int end) {
if (start < end) {
int partition = findPartition(nums,start,end);
quickSort(nums,start,partition-1);
quickSort(nums,partition+1,end);
}
}
public int findPartition(int[] nums, int start, int end){
int val = nums[start];//取数组的第一个数为基准值
int i = start, j = end;
while (i < j) {
//先移动j,找到比val小的值
while (i < j && nums[j] >= val) {
j--;
}
//将nums[j]放在nums[i]上
if (i < j) {
nums[i++] = nums[j];
}
//移动i,找到比val大的值
while (i < j && nums[i] <= val) {
i++;
}
//将nums[i]放在nums[j]上
if (i < j) {
nums[j--] = nums[i];
}
}
//别忘了将val放在nums[i]上
nums[i] = val;
return i;
}
六.堆排序
我们其实可以借助堆这种数据结构完成排序功能。
不懂堆结构的朋友请移步: 数据结构:堆
我们可以通过构建大顶堆或者小顶堆的方式,每次交换堆顶和堆的最后一个元素,再重新调整堆,从而完成堆的排序。
代码
最优时间复杂度:O(nlogn)
最坏时间复杂度:O(nlogn)
平均时间复杂度:O(nlogn)
稳定性: 不稳定
public void heapSort(int[] nums) {
//构建大顶堆
for (int i = nums.length / 2 - 1; i >= 0; i--) {
adjustHeap(nums, i, nums.length);
}
//交换堆顶元素和堆尾元素,然后调整
for (int i = nums.length - 1; i > 0; i--) {
int tmp = nums[i];
nums[i] = nums[0];
nums[0] = tmp;
adjustHeap(nums, 0, i);
}
}
public void adjustHeap(int[] nums, int i, int length) {
int value = nums[i];
int m = i,k = 2*m+1;
while (k < length) {
if (k + 1 < length && nums[k] < nums[k+1]){
k = k + 1;
}
if (value < nums[k]){
nums[m] = nums[k];
m = k;
k = 2 * m + 1;
} else {
break;
}
}
nums[m] = value;
}