最近在复习算法和数据结构,复习到排序和查找,在这里对常用的几种排序算法做一个总结
先放一张图,这张图包括了十种经典排序算法的一些特点和复杂度,接下来就按照图中的顺序来进行具体的分析
说明:本文中的排序目标均为升序
*定义swap方法:
private void swap(int[]nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
冒泡排序是最简单的排序方法之一,原理十分直接,对于数组中的每一个数,不断将它与右侧的数进行比较,如果右侧的数大于它就将它和右侧的数交换,直到没有比他大的数为止,这个过程类似气泡在水中上浮的过程,故得名冒泡排序
冒泡排序的代码如下
public int[] bubbleSort(int[] nums) {
for(int i = 0; i < nums.length; i++) {
boolean flag = false;
for(int j = 0; j < nums.length - i - 1; j++) {
if(nums[j] > nums[j + 1]) {
swap(nums, j, j + 1);
flag = true;
}
}
if(!flag) {
//若flag为false说明原数组中任意相邻的两个数都是有序的,因此整个数组已经有序,所以不需要进行接下来的比较直接返回true
return true;
}
}
return nums;
}
接下来分析代码的复杂度。很明显能看出,当待排序数组为降序时,最内层代码最多需要执行(1 +…+ n - 1) 次,当待排序数组为升序时,内层代码需要执行n次,。平均时间复杂度为O(n^2),冒泡排序不需要额外的空间,因此空间复杂度为O(1)。当相邻两个数相等时不进行交换,因此冒泡排序是稳定排序。
选择排序,顾名思义,每次循环中选择一个最大或者最小的数将它放到数组的一侧,假设每次选择一个最小的数,首先将该数放到最左侧,然后从该数的下一个位置开始遍历,重复这一过程,每个被放到数组左侧的数都是剩余数组中的最小值,该数的左侧都是比它小的数且已经有序。遍历一遍之后原数组就成为有序的了,选择排序的具体过程可以参考下面这张图
参考代码如下:
public int[] choseSort(int[] nums) {
for(int i = 0; i < nums.length; i++) {
int min = i;
for(int j = i + 1; j <nums.length; j++) {
if(nums[min] > nums[j]) {
min = j;
}
}
swap(nums, i, min);//i所在的位置是有序子数组的末尾
}
return nums;
}
下面分析复杂度。不难看出,无论什么情况,内层代码都要执行(1 + …+ n - 1) = n ^ 2 / 2 次,因此时间复杂度为O(n^2)。选择排序不需要额外空间,因此空间复杂度为O(1)。选择排序中原本排在前面的数字插入时可能被移到后面(如[9,3,6,9,1],第一次扫描后变为[1,3,6,9,9],原本在前面的9跑到了后面)。因此选择排序是不稳定排序。
*当排序对象不是数组而是单链表时,选择排序依然适用,思路同数组的类似,这里设立一个辅助头指针指向原链表的头部方便插入节点
代码如下:
public ListNode linkedChoseSort(ListNode head) {
ListNode headPre = new ListNode();
headPre.next = head;//辅助头节点
ListNode cur = headPre;//计数指针
while(cur.next != null) {
ListNode min = cur.next;//初始化最小值为cur的下一位
ListNode flag = cur.next;
ListNode minPre = flag;//因为插入节点需要修改该节点前一个节点的next,这里用一个指针标记前一个节点
while(flag.next != null) {
if(flag.next.val < min.val) {
//如果当前节点的下一个节点值小于min,则更新
min = flag.next;
minPre = flag;
}
flag = flag.next;
}
if(min != cur.next) {
ListNode target = cur.next;//targer为左侧要进行交换的节点
if(minPre == target) {
//如果target是min的前置节点,直接交换
minPre.next = min.next;
cur.next = min;
min.next = minPre;
} else {
ListNode afterNext = target.next;
minPre.next = target;
cur.next = min;
target.next = min.next;
min.next = afterNext;
}
}
cur = cur.next;
}
return headPre.next;
}
插入排序的总体思想是:首先认为第一个数是有序的,从第二个数开始,对于数组中的一个数m,不断向左寻找直到找到一个比m小的数n,然后将m插入到n之后,若直到数组头部还没找到就将该数放在数组头部,实现局部有序,当整个数组被遍历完之后就成为了一个有序的数组。
插入排序的具体过程可以参考下图:
插入排序的具体代码如下:
public int[] insertSort(int[] nums) {
if(nums.length < 2) {
return nums;
}
for(int i = 1; i < nums.length; i++) {
int j = i - 1;
while(j >= 0 && nums[j] > nums[i]) {
//i - 1;若碰到相等的数就停止,保证了稳定性
j--;
}
int temp = nums[i];
for(int k = i; k > j + 1; k--) {
//i - j - 1;
nums[k] = nums[k - 1];
}
nums[j + 1] = temp;
}
return nums;
}
复杂度分析:
由代码可以看出,最坏情况(数组为逆序时)对于每一个i,内层循环最多执行2 *(i - 1)次,故最坏情况时间复杂度为O(n^2 )最好情况(数组为升序时)只需进行n次比较,此时时间复杂度为O(n),平均时间复杂度为O(n^2),插入排序不需要用到额外的空间,故空间复杂度为O(1)。插入排序向左搜索时遇到相等的数就停止,故原本在前的数排序后还是在前(如[3,9,5,1,9,2]->[3,5,9,1,9,2]->[1,3,5,9,9,2]),故插入排序是稳定的排序。
归并排序中使用到了分治的思想,当一个数组中只有一个元素时,它是有序的,若一个数组有两个以上元素,可以拆成两个子数组,若每个子数组都有序,则将它们合并之后的数组也是有序的,这就是归并排序的核心。
下面是归并排序的演示图:
归并排序的递归代码如下:
public int[] mergeSort(int[] nums) {
if(nums.length < 2) {
return nums;
}
int flag = nums.length / 2;
int[] a = Arrays.copyOfRange(nums, 0, flag);
int[] b = Arrays.copyOfRange(nums, flag, nums.length);
return merge(mergeSort(a), mergeSort(b));
}
private int[] merge(int[] a, int[] b) {
int[] res = new int[a.length + b.length];
int i = 0;
int m = 0;
int n = 0;
while(i < a.length + b.length) {
if(m < a.length && n < b.length) {
if(a[m] <= b[n]) {
res[i] = a[m++];
} else {
res[i] = b[n++];
}
} else if(m >= a.length) {
res[i] = b[n++];
} else if(n >= b.length) {
res[i] = a[m++];
}
i++;
}
return res;
}
非递归代码如下:
public static int[] mergeSort(int[] nums) {
int n = nums.length;
for (int i = 1; i < n; i *= 2) {
int left = 0;
int mid = left + i - 1;
int right = mid + i;
while (right < n) {
merge(nums, left, mid, right);
left = right + 1;
mid = left + i - 1;
right = mid + i;
}
if (left < n && mid < n) {
merge(nums, left, mid, n - 1);
}
}
return nums;
}
private void merge(int[] nums, int left, int mid, int right) {
int[] a = new int[right - left + 1];
int i = left;
int j = mid + 1;
int k = 0;
while (i <= mid && j <= right) {
if (nums[i] < nums[j]) {
a[k++] = nums[i++];
} else {
a[k++] = nums[j++];
}
}
while(i <= mid) a[k++] = nums[i++];
while(j <= right) a[k++] = nums[j++];
// 把临时数组复制到原数组
for (i = 0; i < k; i++) {
nums[left++] = a[i];
}
}
*当目标序列为链表时的归并排序如下:
private void insert(ListNode l1, ListNode l2) {
if(l1 == null || l2 == null) {
return;
}
l1.next = l2;
}
public ListNode linkedMerge(ListNode l1, ListNode l2) {
ListNode p1 = l1;
ListNode p2 = l2;
ListNode head = new ListNode();
ListNode p = head;
while(l1 != null && l2 != null) {
if(l1.val < l2.val) {
insert(p, l1);
p = p.next;
l1 = l1.next;
} else {
insert(p, l2);
p = p.next;
l2 = l2.next;
}
}
if(l1 == null) {
insert(p, l2);
} else {
insert(p, l1);
}
return head.next;
}
public ListNode linkedMergeSort(ListNode head) {
if(head == null || head.next == null) {
return head;
}
ListNode fast = head;
ListNode slow = head;
while(fast != null && fast.next != null && fast.next.next != null) {
//用快慢指针找到链表中点
fast = fast.next.next;
slow = slow.next;
}
ListNode rightHead = slow.next;
slow.next = null;//将链表从中点处断开,然后进行递归sort后merge
return linkedMerge(linkedMergeSort(head), linkedMergeSort(rightHead));
}
归并排序的时间复杂度为O(nlogn)。当对象为数组时,在排序过程中需要用到额外的n份空间,栈空间使用带来的复杂度为O(logn),故总的空间复杂度为O(n)。当排序对象为链表时,不需要额外空间,此时时间复杂度为O(1)。归并排序在合并时保留了原序列顺序,故归并排序是稳定的排序。
快速排序的核心思想是先找一个数作为基准,然后把数组中比它小的数都放到该数左边,比它大的数都放到该数右边,操作结束后该数的位置就是正确位置,接下来递归地对该数左侧和右侧的数进行操作。
快速排序的过程可以参考下图:
代码如下:
public int[] quickSort(int[] nums, int left, int right) {
if(left >= right) {
return nums;
}
int index = getIndex(nums, left, right);//获取基准数在数组中的正确下标
quickSort(nums, left, index - 1);//递归地对基准数左右侧进行排序
quickSort(nums, index + 1, right);
return nums;
}
private int getIndex(int[] nums, int left, int right) {
int l = left;
int r = right;
int flag = nums[left];//以第一个数为基准
while(l < r) {
while(l < r && nums[r] >= flag) {
r--;
}
nums[l] = nums[r];
while(l < r && nums[l] <= flag) {
l++;
}
nums[r] = nums[l];
}
nums[l] = flag;
return l;
}
快速排序在原数组有序的情况下复杂度最高,为O(n^2), 平均的时间复杂度是O(nlogn)。快速排序不需要额外的空间,主要空间复杂度来源于递归时栈空间的使用,平均复杂度为O(logn),由于快速排序遇到相等的数仍然交换位置,(例如:[3,2,1,3,4],选取第一个3为基准,则在排序中若取等号会将第二个3移到第一个的左侧,若不取则会交换第二个3和第一个3的位置)故快速排序不是稳定排序。
希尔排序是插入排序的一个变种,插入排序一次只能将数据移动一位,同时插入排序在数据较为有序的时候复杂度较好,希尔排序通过将数据进行分组,每次使得组内数据有序,并不断减少组数,最好剩下一组时,整个数组就有序了。
具体代码如下:
public int[] shellSort(int[] nums) {
for(int gap = nums.length / 2; gap > 0; gap /= 2){
//从第gap个元素,逐个对其所在组进行直接插入排序操作
for(int i = gap; i < nums.length; i++){
int j = i;
while(j - gap >= 0 && nums[j] < nums[j - gap]){
swap(nums, j, j - gap);
j -= gap;
}
}
}
return nums;
}
希尔排序的时间复杂度为O(nlogn),空间复杂度为O(1),由代码可以看出,希尔排序是由多次插入排序组合而成,虽然每组内的插入排序是稳定的,但对于整个数组来说元素的前后顺序可能发生改变,故希尔排序不是稳定的排序。
计数排序的原理是:先遍历找到原数组中最大值max和最小值min,然后设置偏移量flag为max - min,接着用一个大小为flag的辅助数组保存原数组中数出现的次数,该数组下标加上flag就是原数组中的数。
然后从头开始查找辅助数组,若当前位置值不为0,则输出辅助数组当前下标加上flag的值,同时将当前位置值减1,若当前值为0则前进一步,直到数组末尾。
具体代码如下:
public int[] countSort(int[] nums) {
if(nums.length < 2) {
return nums;
}
int max = nums[0];
int min = max;
for(int i:nums) {
if(i > max) {
max = i;
}
if(i < min) {
min = i;
}
}
int len = max - min + 1;
int[] count = new int[len];
for(int i:nums) {
count[i - min]++;
}
int flag = 0;
//不稳定的做法,直接按照顺序输出
//for(int j = 0;j < count.length; j++) {
//while(count[j] > 0) {
// nums[flag++] = j + min;
// count[j]--;
//}
//}
//return nums;
//稳定的做法,从原数组尾端扫描,依次插入新数组
int[] res = new int[nums.length];
for(int i = 1; i < count.length; i++) {
count[i] += count[i - 1];
}
for(int j = nums.length - 1; j >=0; j--) {
int index = nums[j] - min;
if(count[index] > 0) {
res[count[index] - 1] = nums[j];
count[index]--;
}
}
return res;
}
计数排序的时间复杂度是O(n+k),由于用到了额外长度为n的数组和k = max-min的辅助空间,故空间复杂度是O(n+k)。是稳定排序。如果直接用原数组输出,则空间复杂度为O(k),但此时排序是不稳定的。
时间原因先写到这,堆排序、桶排序和基数排序等面试完再补充吧