昨天面试被问到了排序,说一种最熟悉的排序算法,说了快排,感觉答的不好,今天来复盘一下。
点击我的leetcode主页跳转:https://leetcode-cn.com/u/zhenglin_li/
问:排序算法分哪几种?
答:外排序和内排序
复盘:感觉他想问的是分为
1、基于插入的排序 :直接插入排序 、希尔排序
2、基于交换到排序 :冒泡排序 、快速排序
3、基于选择的排序 :简单选择排序、堆排序
问:知道哪几种?
冒泡排序
一句话总结:每次比较相邻的两个元素,逆序则交换,每一轮结束后最大的元素就会被换到结尾
-
优化1,除了第一轮之后的每一轮的比较不用到尾,且每轮比较的次数越来越少
-
优化2,记录每一趟是否交换过,如没有交换过,说明已经有序,提前返回
-
复杂度:最好
O(n)
,如果有序,没有交换过,一趟遍历即返回,平均O(n^2)
for (int i = 1; i < arr.length; i++) {
// 设定一个标记,若为true,则表示此次循环没有进行交换,也就是待排序列已经有序,排序已经完成。
boolean flag = true;
for (int j = 0; j < arr.length - i; j++) {
if (arr[j] > arr[j + 1]) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
flag = false;
}
}
if (flag) {
break;
}
}
选择排序
一句话总结:每一轮是选择剩下的最小的,与第一个交换就行
复杂度最好最坏都是O(n^2)
,无论如何每一轮都要遍历来选择剩下的最小的
-
算法思想 1:贪心算法:每一次决策只看当前,当前最优,则全局最优。注意:这种思想不是任何时候都适用。
-
算法思想 2:减治思想:外层循环每一次都能排定一个元素,问题的规模逐渐减少,直到全部解决,即「大而化小,小而化了」。运用「减治思想」很典型的算法就是大名鼎鼎的「二分查找」。
-
优点:交换次数最少。如果在交换成本较高的排序任务中,就可以使用「选择排序」(《算法 4》相关章节课后练习题)
// 总共要经过 N-1 轮比较
for (int i = 0; i < arr.length - 1; i++) {
int min = i;
// 每轮需要比较的次数 N-i
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[min]) {
// 记录目前能找到的最小值元素的下标
min = j;
}
}
// 将找到的最小值和i位置所在的值进行交换
if (i != min) {
int tmp = arr[i];
arr[i] = arr[min];
arr[min] = tmp;
}
插入排序
一句话总结:对于每一个元素a,他之前的元素依次后移一位,直到a在合适的位置上
-
特点:「插入排序」在「几乎有序」的数组上表现良好。在数组「几乎有序」的前提下,「插入排序」可以提前终止内层循环;「短数组」的特点是:每个元素离它最终排定的位置都不会太远
-
最好复杂度:O(n),如果有序,每一个元素只需要和前一位元素比较
for (int i = 1; i < arr.length; i++) {
// 记录要插入的数据 a
int tmp = arr[i];
// 从已经排序的序列最右边的开始比较,找到比其小的数
int j = i;
while (j > 0 && tmp < arr[j - 1]) {
arr[j] = arr[j - 1];
j--;
}
// 存在比其小的数,插入
if (j != i) {
arr[j] = tmp;
}
}
希尔排序
一句话总结:分治思想在简单插入排序上的应用,原序列分组为很多子序列,不断减少步长,直到为1
int length = arr.length;
int temp;
// 步长为step,结束标志为step==1,每次缩短为原来的一半
for (int step = length / 2; step >= 1; step /= 2) {
for (int i = step; i < length; i++) {
temp = arr[i];
int j = i - step;
while (j >= 0 && arr[j] > temp) {
arr[j + step] = arr[j];
j -= step;
}
arr[j + step] = temp;
}
}
归并排序
「归并排序」有「原地 + 迭代」和「借助额外空间 + 递归」,也即「自底向上」和「自顶向下」
自底向上「借助额外空间 + 递归」
自顶向下的归并排序进行的操作主要就是对数组的拆分与合并。通过层层拆分得到单元素数组,天生有序,然后归并两个单元素数组得到一个较大的有序数组,接着再归并两个较大数组得到更大的一个有序数组,重复这个过程,最终归并便得到了一个排好序的数组。
public int[] sortArray(int[] nums) {
return mergeSort(nums, 0, nums.length - 1);
}
public int[] mergeSort(int[] nums, int left, int right) {
if (left >= right)
return new int[]{nums[left]};
int middle = (left + right) >>> 1;
int[] leftPart = mergeSort(nums, left, middle);
int[] rightPart = mergeSort(nums, middle + 1, right);
return merge(leftPart, rightPart);
}
public int[] merge(int[] left, int[] right) {
int[] res = new int[left.length + right.length];
int i = 0, j = 0, index = 0;
while (i < left.length && j < right.length)
res[index++] = left[i] < right[j] ? left[i++] : right[j++];
while (i < left.length)
res[index++] = left[i++];
while (j < right.length)
res[index++] = right[j++];
return res;
}
时间复杂度分析:为什么为O(nlogn)
,且这个时间复杂度是稳定的,不随需要排序的序列不同而产生波动?
我们知道,归并排序的过程中,需要对当前区间进行对半划分,直到区间的长度为1
。也就是说,每一层的子区间,长度都是上一层的1/2
。这也就意味着,当划分到第logn层的时候,子区间的长度就是1了。而归并排序的merge
操作,则是从最底层开始(子区间为1
的层),对相邻的两个子区间进行合并,对于每一层来说,在合并所有子区间的过程中,n
个元素都会被操作一次,所以每一层的时间复杂度都是O(n)
。
每一层的时间复杂度为O(n),共有logn层,所以归并排序的时间复杂度就是O(nlogn)。
自顶向下「原地 + 迭代」
归并两个单元素数组得到一个较大的有序数组,接着再归并两个较大数组得到更大的一个有序数组,重复这个过程,最终归并便得到了一个排好序的数组。
快速排序
一句话总结:快速排序每一次都排定一个元素(这个元素呆在了它最终应该呆的位置),然后递归地去排它左边的部分和右边的部分,依次进行下去,直到数组有序
算法思想:分而治之(分治思想),与「归并排序」不同,「快速排序」在「分」这件事情上不想「归并排序」无脑地一分为二,而是采用了 partition 的方法(每次partition后这个元素呆在了它最终应该呆的位置),因此就没有「合」的过程。
class Solution {
public int[] sortArray(int[] nums) {
quickSort(nums, 0, nums.length - 1);
return nums;
}
public void quickSort(int[] nums, int start, int end) {
if (start >= end)
return;
int partitionIndex = partition(nums, start, end);
// 注意要-1
quickSort(nums, start, partitionIndex - 1);
quickSort(nums, partitionIndex + 1, end);
}
public int partition(int[] nums, int left, int right) {
int pivot = left;
while (left < right) {
// 注意先right--再left++
while (left < right && nums[right] > nums[pivot])
right--;
// 注意要有等号,二选一
while (left < right && nums[left] <= nums[pivot])
left++;
if (left < right)
swap(nums, left, right);
}
// now left == right is true
swap(nums, pivot, left);
return left;
}
public void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
时间复杂度分析:
-
最差情况:选取的轴刚好就是这个区间的最大值或者最小值。需要处理
n
轮,每轮复杂度为O(n)
。 -
最好情况,我们选取的轴刚好就是这个区间的中位数。
也就是说,在操作之后,正好将区间分成了数字个数相等的左右两个子区间。此时就和归并排序基本一致了。
堆排序
一句话总结:先建堆,再从后往前对每一个元素 交换和归位
堆:完全二叉树 + parent > children
注意在堆排序中,建堆堆复杂度为O(n)
,前提是在堆排序中,否则更普适的情况是O(nlogn)
class Solution {
public int[] sortArray(int[] nums) {
heapSort(nums);
return nums;
}
// 堆排序,先建堆,在进行交换和归位
public void heapSort(int[] nums) {
buildHeap(nums);
for (int i = nums.length - 1; i >= 1; i--) {
swap(nums, 0, i);
siftDown(nums, 0, i - 1);
}
}
// 归位函数,用于递归地把某个节点放到该放的位置
public void siftDown(int[] nums, int root_index, int end) {
int max_index = root_index;
int left_index = root_index * 2 + 1;
int right_index = root_index * 2 + 2;
if (left_index <= end && nums[left_index] > nums[max_index])
max_index = left_index;
if (right_index <= end && nums[right_index] > nums[max_index])
max_index = right_index;
if (max_index != root_index) {
swap(nums, max_index, root_index);
siftDown(nums, max_index, end);
}
}
// 建堆,O(n),从倒数第二层有叶子节点的那个节点开始,依次递减
public void buildHeap(int[] nums) {
int index_tail = nums.length - 1;
int index_parent = (index_tail - 1) / 2;
for (int i = index_parent; i >= 0; i--)
siftDown(nums, i, index_tail);
}
public static void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}