昨天面试,面试官让我写归并排序,我知道逻辑怎么写,但是别人看着我写代码我就是不得劲,被面试官diss惨了,知道怎么写还写不出来不就是不行吗?也确实,是我自己太菜了。是时候该清醒下了,别人面试想看的就是你临场发挥,总是紧张谁爱看你的样子?
总结下自己的问题吧:
- 确实面试爱紧张,多面面呗没啥办法,这里推荐大家先把思路捋清楚,一边想思路一边给面试官说,这样能确定自己接下来的步骤,并且如果有偏差,面试官也会给你一些提示,也是面试破冰的一种方法。建议大家一边写一边给面试官解释代码会好一些。
- 能说出来实现流程不能写出来,我不认为自己业务能力差,我认为是对递归理解不透彻,包括自己本来就对算法感觉抽象,是不是应该做一道题会一道题才对?
- 太轻视基础了,其实面试之前本就应该看看基础的东西,但是有点浮,看不清自己几斤几两。
惩罚自己把所有常见的排序算法全捋清楚。
冒泡、选择、插入、快排、归并、希尔、堆排。
冒泡排序(必会)
原理:每次循环找到数组中最大的元素,放在最右边。
逻辑:
- 外层循环完一次最右边必然是最大值,所以外层循环完一次之后,内层循环会逐渐减少。
- 内层循环需要做的仅是判断相邻元素中的最大值,判断体与外层变量无关。
- 优化:假设数组是
[2,1]
,那么循环一次就可以结束,因为在2
确定为最大值之后剩下的1
就是有序的。所以外层循环可以是arr.length - 1
代码:
public void bubbleSort(int[] nums) {
// -1可以减少一次循环,实际上没什么大用只是向面试官展示你的细节
for(int i = 0; i < nums.length - 1; i ++) {
// -1是防止下标越界,因为是与j + 1做的比较
for(int j = 0; j < nums.length - i - 1; j ++) {
if(nums[j] > nums[j + 1]) {
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
}
}
}
}
总结:因为是双重循环,所以时间复杂度很稳定为O(n^2)
,空间O(1)
。
选择排序(理解)
原理:每次遍历选中一个最大值,与无序序列的最右端交换。
逻辑:
- 用指针
p
选中第一个下标为默认值,在后续遍历的时候选中无序数组中大于p
的下标 - 遍历完成之后与无序数组的最后一个下标比较后交换,可以缓解选择排序的不稳定性。
代码:
public void selectSort(int[] nums) {
// -1可以减少一次循环,实际上没什么大用只是向面试官展示你的细节
for(int i = 0; i < nums.length - 1; i ++) {
// 最大下标指针
int p = 0;
for (int j = 0; j < nums.length - i; j ++) {
// 这里用 < 也行,<=是缓解选择排序的不稳定性
if(nums[p] <= nums[j]) p = j;
}
if (nums[p] > nums[nums.length - 1 - i]) {
int tmp = nums[nums.length - 1 - i];
nums[nums.length - 1 - i] = nums[p];
nums[p] = tmp;
}
}
}
总结:因为是双重循环,所以时间复杂度很稳定为O(n^2)
,空间O(1)
,优于冒泡是减少了交换次数。
插入排序(必会)
原理:从无序数组中选中一个插入到有序数组对应顺序的位置上。
逻辑:
- 每次遍历选中无序数组第一个
- 与有序数组从右到左一一比对,如果需要插入,就右移有序数组。
- 把选中的元素放到对应的位置上。
代码:
public void insertSort(int[] nums) {
// 从下标1开始,因为1个元素的数组一定有序
for(int i = 1; i < nums.length; i ++) {
// 选中待排序的元素
int p = nums[i];
// 有序数组的最右端下标
int j = i - 1;
// 待排序元素需要插入到有序数组中
while(j >= 0 && p < nums[j]) {
nums[j + 1] = nums[j];
j --;
}
// 位置腾出了,插入待排序元素,如果不需要插入,则没动
nums[j + 1] = p;
}
}
总结:因为是双重循环,所以时间复杂度依旧为O(n^2)
,空间O(1)
。
快速排序(必会)
原理:随机选定一个元素,双指针指向左右边,让左边始终小于选定元素,右边始终大于选定元素,如果不符合条件,交换两指针的值即可,直到左边都小于目标值,右边都大于目标值,然后将左右边重复上面操作即可。
画图不易,看到这的点个赞!收个藏!
逻辑:
- 随机选定一元素,我们这里直接选最中间的元素,使用双指针向中间移动,直到选中左边大于选中的元素并且右边小于选中的元素。
- 交换两个元素。这样就保证了左边一定小于该值,右边一定大于该值。
- 对左右两边分治,直到左右两边不可再分。
代码:
public void quickSort(int[] nums, int l, int r) {
// 这里是 <= 因为当arr.length == 1时也不需要排序
if(l >= r) return ;
// 这里做 l - 1 r + 1是因为后面要不管三七二十一先夹一格
int i = l - 1;
int j = r + 1;
// 这里为什么不用指针而是直接取值? 因为数组是变动的
// 如果更新了指针中的内容 也就改变了基准值 违背了快排原则 排序会出现问题
int mid = nums[l + (r - l) / 2];
// 这里不是i <= j是因为当i == j不必再判断了
while(i < j) {
while(nums[++i] < mid);
while(nums[--j] > mid);
if(i < j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
// 2 1 3 5 4
// 这里拿j和i做分割都行
// j分界点是j / j+1
// i分界点是i-1 / i,并且mid的选取需要向上取整
quickSort(nums, l, j);
quickSort(nums, j + 1, r);
}
}
总结:时间复杂度定义的计算方法是这样的。
平均情况下:T(n)=2*T(n/2)+n;
第一次划分 =2*(2*T(n/4)+n/2)+n;
第二次划分 (=2^2*T(n/4)+2*n)
=2*(2*(2*T(n/8)+n/4)+n/2)+n;
第三次划分 (=2*3*T(n/8)+3*n)
=.....................
=2^m+m*n; 第m次划分
因为2^m=n,所以等价于 = n+m*n
所以m=logn,所以T(n)=n+n*logn;
归并排序(必会!!终生之敌!递归必须入门这个!!)
原理:归并归并,就是先把数组归理成有序的多个数组,然后合并。
逻辑:
- 按照原理。先考虑如何把数组拆分成多个有序的数组,把数组拆分成只有一个元素的数组,那么一个元素必然有序,按照这个逻辑就先把数组拆分开。
- 拆分完之后就把数组合并一下,首先取两个数组,我们就取相邻的数组吧。然后我们使用双指针指向两个数组的起始位置。
- 指向起始位置之后就可以开始遍历了,让他们先整合到一个临时的数组中,最后把他们再复制到原数组中就ok了。
我以最简单明了的描述介绍了归并排序,如果还不懂可以到为什么复杂度是O(nlogn)?深入讲解归并排序帖中查看图文详解。下面图片摘自此链接。
代码:
public void mergeSort(int[] nums, int l, int r) {
// 一个元素无序排序,或者下标不符合规范的也直接返回。
if(l >= r) return;
// 分割数组,取中间的下标
int mid = l + (r - l) / 2;
// 分割数组
mergeSort(nums, l, mid);
mergeSort(nums, mid + 1, r);
// 临时指针,指向临时数组的起始位置
int t = 0;
// 双指针,分别指向待排序的两个数组
int i = l;
int j = mid + 1;
// 比较两个数组指向的元素大小,符合条件存在临时数组
while(i <= mid && j <= r) {
if(nums[i] <= nums[j]) tmp[t ++] = nums[i ++];
else tmp[t ++] = nums[j ++];
}
// 数组可能还剩一些元素没遍历到,把这些元素追加在末尾
while(i <= mid) tmp[t ++] = nums[i ++];
while(j <= r) tmp[t ++] = nums[j ++];
// 把有序的数组复制到原数组中
for(i = l, j = 0; j < t; j ++, i ++) nums[i] = tmp[j];
}
总结:时间复杂度与快排推理的类似,不过归并还是需要额外的空间复杂度去存储临时数组。归并比快排的好处在于归并的稳定性比快排好。但总体而言我觉得快排更加简单且高效。
希尔排序(了解)
原理:希尔排序是一种优化版的插入排序,它的思想是:分堆处理。
逻辑:
- 将数组划分成若干组,初始状态是
n / 2
组,从0
开始每隔n / 2
为一组。 - 将同组的进行插入排序。
- 每组排完之后再把数组划分,划分成上一次间隔的一半,也就是
n / 2 / 2
。 - 将同组的进行插入排序。
- 直到划分的间隔为0,表示数组已经有序了。
画图不易,看到这的点个赞!收个藏!!
代码:
public void shellSort(int[] nums) {
int n = nums.length;
// 划分分组,每次为上一次的一半
for(int d = n / 2; d >= 1; d = d / 2) {
// 从d开始是默认前面那个元素已经有序,与插入排序思想相同
for(int i = d; i < n; i ++) {
// 待插入的值
int tmp = nums[i];
// 有序数组的最后一位下标
int j = i - d;
// 与插入排序代码相同,不再解释
while(j >= 0 && tmp < nums[j]) {
nums[j + d] = nums[j];
j -= d;
}
nums[j + d] = tmp;
}
}
}
总结:希尔排序是优化版的插入排序,经其他比较过的博主给出的数据:希尔排序无论在比较次数还是交换次数来说都是远远优于插入排序的。个人测试在时间效率上也确实如此。
堆排序(必会)
原理:堆排序实际上是把待排序的数组当做成一个完全二叉树,如果我们数组排序成从小到大,那么我们需要维护的这个树就是大顶堆树,维护完之后把最大值放在最后面,下次维护的时候size - 1
,维护剩下的堆直到剩下最后一个就结束。
画图不易,看到这的点个赞!收个藏!!
逻辑:
- 因为我们拿到的是个数组,所以先需要建立堆,以形成合法的大顶堆。只有在合法的大顶堆基础上我们才能去维护成有序的数组。
- 怎么维护大顶堆?使用到了递归的特性:我们先拿到一个子树,去维护子树的根节点、左节点、右节点保证根节点的值最大。
- 代码:维护大顶堆
/**
* u: 当前待排序的数组下标
* s: 待排序的数组大小
*/
private void down(int[] nums, int u, int s) {
int t = u;
// 当前节点左孩子的下标是u * 2 + 1;
// 当前节点右孩子的下标是u * 2 + 2;
int l = u * 2 + 1;
int r = u * 2 + 2;
// 如果存在左/右孩子,并且左/右孩子比当前节点大
if(l < s && nums[l] > nums[t]) t = l;
if(r < s && nums[r] > nums[t]) t = r;
// 左/右孩子比当前节点大时交换,并且维护交换后的子树序列
if(u != t) {
int tmp = nums[t];
nums[t] = nums[u];
nums[u] = tmp;
down(nums, t, s);
}
}
- 我们每次维护完之后,就会把最大值放在堆顶,直接把堆顶的值放到无序数组队尾,下次再维护的时候不带上它就实现了整个功能。
代码:
public void heapSort(int[] nums) {
int n = nums.length;
// 建堆,保证构成大顶堆
for(int i = n / 2 - 1; i >= 0; i --) {
down(nums, i, n);
}
// 维护大顶堆,每次把最大值拿走,然后维护剩下的就可以了。
for(int i = n - 1; i > 0; i --) {
int tmp = nums[0];
nums[0] = nums[i];
nums[i] = tmp;
down(nums, 0, i);
}
}
// 大顶堆,上面已经解释过,所以就不再加注解了
private void down(int[] nums, int u, int s) {
int t = u;
int l = u * 2 + 1;
int r = u * 2 + 2;
if(l < s && nums[l] > nums[t]) t = l;
if(r < s && nums[r] > nums[t]) t = r;
if(u != t) {
int tmp = nums[t];
nums[t] = nums[u];
nums[u] = tmp;
down(nums, t, s);
}
}
到这还没理解堆排序可以在这个up主学习下,他讲的很清晰排序算法:堆排序【图解+代码】
总结:堆排序可以看成是加强版的选择排序,每次找到最大值然后与最后一个值交换。堆排序的时间复杂度是O(nlogn)
,空间复杂度不计栈为O(1)
,与归并排序的区别在于堆排序无序开辟额外的空间去存储临时排序好的元素。
附上上述排序算法之间的区别:
最后希望大家都能拿到心仪的offer