写在前面:从2020年的3月疫情在家,闲着无聊开始研究研究算法导论和刷题。一下子一年过来了,也到了准备春招的时候。最开始的文章都是python写的,因为都还是按照算法岗进行准备的。后来发现似乎还是开发岗更符合实际,因此也就开始用java进行刷题。在换了一种语言以后,更加觉着其实算法是与编程语言完全无关的,更多的是一种思想。近期会重新整理我写过的算法笔记。对于古老的版本会尝试补上java版本。
排序方法比较
首先,在面试中,排序问题最常被问到的就是排序的复杂度和排序的稳定性。进一步深化的话还有针对快排和归并的分治思想。
其中快排是核心,归并同样思路重要。堆排序是一个更加复杂的数据结构,如果希望自己手撕堆是很复杂的,可以参考【专题讲解】手撕堆。
在java中你使用 Collections.sort 的时候是TimSort。你在IDE 里点进去 Arrays.sort 这个方法,你会发现是 DualPivotQuicksort。它混合了快速排序,插入排序,归并排序,桶排序,TimSort。
快排
思想比较简单,就是寻找一个哨兵,小于pivot的放到左侧,大于pivot的放在右侧。如果为了针对面试,我们会采用交换指针的方法,并且随机化pivot的方法实现。
class Solution {
public int[] sortArray(int[] nums) {
quickSort(nums, 0, nums.length-1);
return nums;
}
public void swap(int[] arr, int i, int j ){
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
public void quickSort(int[] arr, int l, int r){
if (l>r) return; // 这里是一个判断,也就是base情况的处理
int index = partition(arr, l, r);
quickSort(arr, index+1, r);
quickSort(arr, l, index-1);
}
public int partition(int[] arr , int l, int r){
int pivot = arr[l];
//int pivot = new Random().nextInt(r - l + 1) + l;
int index = l;
for(int i = l+1; i<=r;i++){
if (arr[i]<pivot){
index++;
swap(arr, index, i);
}
}
swap(arr, index, l); // 这里记着最后交换回来
return index;
}
}
归并算法
归并算法的思想是,首先把数组拆分为两个数组,分别排序成有序数组以后再合并一起成为新的有序数组。有点递归的意思了。
class Solution {
public int[] sortArray(int[] nums) {
int n = nums.length;
if (n == 1 || n == 0) return nums;
int[] left = Arrays.copyOfRange(nums, 0, n/2);
int[] right = Arrays.copyOfRange(nums, n/2,n);
int[] ans = merge(sortArray(left), sortArray(right));
return ans;
}
public int[] merge(int[] a, int[] b){
int n = a.length;
int m = b.length;
int[] ans = new int[n+m];
int i = 0;
int j = 0;
while (i<n && j<m){
int nums1 = a[i];
int nums2 = b[j];
if (nums1<=nums2){
ans[i+j] = nums1;
i++;
}else{
ans[i+j] = nums2;
j++;
}
}
while (i != n){
ans[i+j] = a[i];
i++;
}
while (j != m){
ans[i+j] = b[j];
j++;
}
return ans;
}
}
TopK问题
这个类问题可以被认为是排序问题的一个变种问题,主要就是计算最小的第K个数或者前K个数字。比较好的思路一般是两个,一个是采用堆的方法,构造一个小顶堆,动态的维护这个堆。二是采用快排的优化策略。
堆排序
建堆的时间复杂度是 O ( K ) O(K) O(K), 后续的插入操作是 O ( N l o g K ) O(NlogK) O(NlogK)。
另外这里需要补充些对于堆的内容,
建堆有2种方法
第一种方法:HeapInsert(本题就是这种方法),它可以假定我们事先不知道有多少个元素,通过不断往堆里面插入元素进行调整来构建堆。这种插入建堆的时间复杂度是O(NlogN)
第二种方法:Heapify
从最后一个非叶子节点一直到根结点进行堆化的调整。如果当前节点小于某个自己的孩子节点(大根堆中),那么当前节点和这个孩子交换。这种建堆的时间复杂度是O(N)
Heapify是一种类似下沉的操作,HeapInsert是一种类似上浮的操作。
// 保持堆的大小为K,然后遍历数组中的数字,遍历的时候做如下判断:
// 1. 若目前堆的大小小于K,将当前数字放入堆中。
// 2. 否则判断当前数字与大根堆堆顶元素的大小关系,如果当前数字比大根堆堆顶还大,这个数就直接跳过;
// 反之如果当前数字比大根堆堆顶小,先poll掉堆顶,再将该数字放入堆中。
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0 || arr.length == 0) {
return new int[0];
}
// 默认是小根堆,实现大根堆需要重写一下比较器。
Queue<Integer> pq = new PriorityQueue<>((v1, v2) -> v2 - v1);
for (int num: arr) {
if (pq.size() < k) {
pq.offer(num);
} else if (num < pq.peek()) {
pq.poll();
pq.offer(num);
}
}
// 返回堆中的元素
int[] res = new int[pq.size()];
int idx = 0;
for(int num: pq) {
res[idx++] = num;
}
return res;
}
}
快排优化
快排已经是我们前面介绍得了,这里的核心是,我们在partition函数返回index以后,我们知道左侧和右侧的大小,对于超出K的范围的部分我们已经不用进行额外的排序了。
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
// if (k == 0 || arr.length == 0) {
// return new int[0];
// }
return quickSort(arr, 0, arr.length-1, k-1);
}
public void swap(int[] arr, int i, int j ){
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
public int[] quickSort(int[] arr, int l, int r, int k){
if (l>r)return new int[0];
int index = partition(arr, l, r);
if(index == k){
return Arrays.copyOfRange(arr, 0, k+1);
}
else if (index<k){
return quickSort(arr, index+1, r,k);
}else{
return quickSort(arr, l, index-1, k);
}
}
public int partition(int[] arr , int l, int r){
int pivot = arr[l];
int index = l;
for(int i = l+1; i<=r;i++){
if (arr[i]<pivot){
index++;
swap(arr, index, i);
}
}
swap(arr, index, l);
return index;
}
}
时间复杂度的计算
首先我们对于一些常用的时间复杂度应该是需要很熟悉的,比如dp的时间复杂度,状态压缩dp的次方级别,已经二分等的对数级别的。除此以外,最长用于计算时间复杂度的就是主定理。
主定理
假如有式子 T ( n ) = a T ( n b ) + f ( n ) T(n) = aT(\frac{n}{b})+f(n) T(n)=aT(bn)+f(n),其中 n n n为问题规模, a a a为递推的子问题数量, n b \frac{n}{b} bn为每个子问题的规模(假设每个子问题的规模基本一样), f ( n ) f(n) f(n)为递推以外进行的计算工作。