这篇文章是看了“左程云”老师在b站上的讲解之后写的, 自己感觉已经能理解了, 所以就将整个过程写下来了。
这个是“左程云”老师个人空间的b站的链接, 数据结构与算法讲的很好很好, 希望大家可以多多支持左程云老师, 真心推荐.
https://space.bilibili.com/8888480?spm_id_from=333.999.0.0
1. 题目一:实现 K
个链表的合并
1.1 题目描述
有若干个链表, 然后将所有的链表的头结点都放到一个数组 arr[]
中, 例子:arr[] = {3, 1, null, 5}
.
每一个数组中的头结点后面还连着链表之后的结点, 是一整个链表. 然后将所有的链表都进行合并, 按照顺序串起来.
如下图, null
表示这个链表是空的.
1.2 解法
1.2.1 暴力解法
首先来说暴力解法, 开始先准备一个容器, 需要将所有的节点(假设有 n
个) 都放到容器中, 这个的空间复杂度是:O(n)
然后进行排序, 排序过程的时间复杂度是:O(n * log(n))
. 然后将所有的节点都串起来(遍历一遍), 最后的时间复杂度是:O(n * log(n)) + O(n) -> O(n * log(n))
.
1.2.2 利用堆结构的解法(逻辑实现)
利用堆结构, 将 arr[]
数组中所有的头结点存储的头结点都放到堆结构中, 而且这个堆结构是一个小根堆.
- 按照上面图片的例子:将
1, 3, 5, null(放不放都一样)
, 全部都放到小根堆中, - 然后将小根堆中最小的节点弹出(肯定是根节点
1
了), 因为这是一个链表, 可以通过next
指针找到1
的下一个节点(是8
). 此时设置一个变量head
指向最开始弹出来的1
. - 然后将
8
放到小根堆中, 利用JDK
中自带的PriorityQueue类
, 这样可以在实现加入和弹出的操作的同时进行自动的调整. 此时小根堆中有3, 5, 8
.然后设置一个变量pre
, 指向当前弹出的节点, 因为需要直到当前走到了哪里, 这样好进行下一个节点的连接. - 然后继续弹出堆中最小的节点
3
, 所以将pre.next
指针连接到3
, 然后将pre
指向这个3
节点, - 然后继续将
3
节点的下一个节点放入小根堆中. - 以后重复上述操作, 直到小根堆中没有任何东西位为止.
1.2.3 利用堆结构的解法(代码实例)
所有需要注意的地方都在注释中.
// 测试链接:https://www.nowcoder.com/practice/65cfde9e5b9b4cf2b6bafa5f3ef33fa6
// 不要提交这个类
public static class ListNode { public int val; public ListNode next;
} // 提交以下的方法
public static ListNode mergeKLists(ArrayList<ListNode> arr) { // 小根堆 PriorityQueue<ListNode> heap = new PriorityQueue<>((a, b) -> a.val - b.val); for (ListNode h : arr) { // 遍历所有的头! if (h != null) { heap.add(h); // 将所有的头结点都加入到小根堆中. } } if (heap.isEmpty()) { return null; // 若是为“空”, 直接返回 } // 先弹出一个节点,做总头部 ListNode h = heap.poll(); ListNode pre = h; // 将“pre”指向最开始的“h”指向的节点 if (pre.next != null) { // 只要当前节点的下一个节点不是“空”, 就将其压入到堆中. heap.add(pre.next); } while (!heap.isEmpty()) { // 只要“堆”中还有链表节点.就不会停止 ListNode cur = heap.poll(); // 将“cur”指向从堆中弹出来的节点. pre.next = cur; // 将pre的下一个节点指向cur指向的节点 pre = cur; // 然后将pre指向cur指向的节点. if (cur.next != null) { heap.add(cur.next); // 若是cur的下一个节点不是“空”, 就将其加入到堆中. } } return h; // 最后返回总的头结点
}
1.2.4 复杂度分析
假设这个题目中有 n
个节点, k
条链表
暴力方法:时间复杂度:
先设置一个容器, 然后遍历一遍(时间复杂度:O(n)
),
然后将其进行排序(时间复杂度:(O(n * log(n))
)), 最后将所有链表节点串起来(时间复杂度:O(n)
).
所以按照时间复杂度取最高阶的定义:这个方法的时间复杂度是:O(n *log(n))
.
空间复杂度:因为需要一个能存放所有节点的容器, 所以空间复杂度:O(n)
.
使用堆结构的方法:时间复杂度:
开始的时候我将所有的链表的头结点(k
个)都放到堆中, 所以将来堆中的节点个数一定不会超过 k
个, 因为我永远的弹出一个, 然后立刻进去一个, 假设按照最差的情况来估计, 插入和弹出过程的时间复杂度是:log(k)
. 又因为有 n
个节点, 所以对应的时间复杂度是:O(n * log(k))
.
空间复杂度:O(k)
.(因为我的堆中永远不会超过 k
个元素).
1.2.5 用归并的方式做
这里给出代码, 就不做解释了.(只是将其分解为以前讲过的合并两条有序链表的).
public static class ListNode { int val; ListNode next; ListNode(int x) { val = x; next = null; } } public static ListNode mergeKLists(ArrayList<ListNode> lists) { return digui(lists, 0, lists.size() - 1);
} public static ListNode digui(ArrayList<ListNode> lists, int left, int right) { if (left > right) return null; else if (left == right) return lists.get(left); else { int mid = (left + right) / 2; return mergeTwo(digui(lists, left, mid), digui(lists, mid + 1, right)); } } public static ListNode mergeTwo(ListNode list1, ListNode list2) { if (list1 == null) return list2; if (list2 == null) return list1; ListNode res = new ListNode(0); ListNode cur = res; while (list1 != null && list2 != null) { if (list1.val < list2.val) { cur.next = list1; list1 = list1.next; } else { cur.next = list2; list2 = list2.next; } cur = cur.next; } if (list1 != null) cur.next = list1; if (list2 != null) cur.next = list2; return res.next;
}
2. 线段最多重合问题
2.1 问题描述
下方的图片中, 重合最多的是 [4, 5]
, 这条线段压中了两条线段, 所以返回 2
.
2.2 使用堆的方法做
解题之前我们首先要明确一点:任何一条重合区域的左边界, 都一定是某一条线段的左边界.
举例:比如 [1, 7], [2, 5]
重合区域的左边界一定是:2
,
比如:[1, 7], [5, 13]
, 重合区域的左边界一定是:5
.
2.2.1 逻辑实现
- 比如现在我们有:
[1, 5], [3, 7], [2, 4], [1, 3], [2, 6], [5, 9]
, - 然后将其按照左边界的大小(小的在前)排好序:
[1, 5], [1, 3], [2, 4], [2, 6], [3, 7], [5, 9]
, - 然后设置一个小根堆, 用来放置所有的线段的右边界.
- 将
[1, 5]
的右边界放到堆中, 现在只有[1, 5]
自己冲到1
的左边界之后, 所以此时记为1
. - 然后判断
[1, 3]
, 先判断小根堆中有没有小于[1, 3]左边界 1
的数字, 要是有, 直接将其全部弹出, 因为要是堆中的右边界都到不了1
这个左边界, 那就肯定是冲不进来. 肯定是不用考虑的. 但是这个[1, 3]
没有上述情况, 所以将3
放到堆中, 堆中现在有3, 5
. 此时代表有两条线段能到1
这个左边界的位置:[1, 5] 和 [1, 3]
. 所以此时记为2
. - 将
[2, 4]
加入到其中, 继续重复上述操作, 堆中没有比2
小的, 堆中有:3, 4, 5
所以此时记为3
. - 然后将
[2, 6]
加入到其中, 继续重复上述操作, 堆中没有比2
小的, 堆中有:3, 4, 5, 6
所以此时记为4
. - 然后将
[3, 7]
加入到其中, 继续重复上述操作, 堆中的3, 4, 5, 6
中3 <= 左边界3
, 所以弹出堆中的3
, 因为此时1, 3
已经对所有的线段没有意义了, 因为无论是以后的还是以前的线段, 都没有任何一个能和[1, 3]
重合了, 因为我们最开始的时候已经排好序了, 不存在后续有好几个[1, 2]
这样的情况. 此时现在堆中有:4, 5, 6, 7
. 此时记为:4
. - 然后将
[5, 9]
放入其中, 堆中的4, 5 <= 5
, 所以将其弹出, 此时堆中有6, 7, 9
, 此时记为3
. - 最后的答案是
4
, 因为从开始到结束, 堆的大小经过了很多次变换, 但是我要的是其中最大的值.
总结:将所有的线段按照左边界排好序, 然后将开始的线段右边界的数字放到 堆中
, 然后继续往下判断, 要是后来的线段的左边界比 堆中
含有的数字大, 就将堆较小的数字弹出, 最后遍历完了所有线段之后, 我们每次都对于堆中的数字进行了记录, 哪一次记录的数字最大就说明答案是几.
2.2.2 代码实例
为什么要设置一个 int[][]
, 因为我需要一个 N * 2
阶的矩阵, 此时需要一个对应的位置用来存放我的线段, 比如说: 我需要 3
个线段, 是: [2, 3], [3, 4], [2, 3]
, 所以对应的, 我需要这个矩阵存放, 反正线段是只有两个数字, 只要宽度是 2 就行了, 但是高度不一定, 因为我不知道自己需要几个线段, 所以需要用 n
来表示.
Arrays.sort(line, 0, n, (a, b) -> a[0] - b[0])
其中的 a 和 b
代表对应的数组本身, a[0] 和 b[0]
就代表数组中对应的数字.
ans = Math.max(ans, size)
这样设置的原因是:有可能先进去的线段已经被记为了 4
, 但是后来进去的数组是有可能将堆中的数字弹出去很多, 此时已经被记为了 1
, 所以我当然是要 4
, 最后一定要返回 4
.
// 测试链接 : https://www.nowcoder.com/practice/1ae8d0b6bb4e4bcdbf64ec491f63fc37
// 测试链接 : https://leetcode.cn/problems/meeting-rooms-ii/ 这个要会员才行.
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过 import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.util.Arrays;
import java.util.PriorityQueue; public class Code02_MaxCover { public static int MAXN = 10001; public static int[][] line = new int[MAXN][2]; public static int n; public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StreamTokenizer in = new StreamTokenizer(br); PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out)); while (in.nextToken() != StreamTokenizer.TT_EOF) { n = (int) in.nval; for (int i = 0; i < n; i++) { in.nextToken(); line[i][0] = (int) in.nval; in.nextToken(); line[i][1] = (int) in.nval; } out.println(compute()); } out.flush(); out.close(); br.close(); } public static int compute() { // 堆的清空 size = 0; // 线段一共有n条,line[0...n-1][2] : line[i][0] line[i][1], 左闭右闭 // 所有线段,根据开始位置排序,结束位置无所谓 // 比较器的用法 // line [0...n) 排序 : 所有小数组,开始位置谁小谁在前 Arrays.sort(line, 0, n, (a, b) -> a[0] - b[0]); int ans = 0; for (int i = 0; i < n; i++) { // i : line[i][0] line[i][1] while (size > 0 && heap[0] <= line[i][0]) { pop(); } add(line[i][1]); ans = Math.max(ans, size); } return ans; } // 小根堆,堆顶0位置 public static int[] heap = new int[MAXN]; // 堆的大小 public static int size; public static void add(int x) { heap[size] = x; int i = size++; while (heap[i] < heap[(i - 1) / 2]) { swap(i, (i - 1) / 2); i = (i - 1) / 2; } } public static void pop() { swap(0, --size); int i = 0, l = 1; while (l < size) { int best = l + 1 < size && heap[l + 1] < heap[l] ? l + 1 : l; best = heap[best] < heap[i] ? best : i; if (best == i) { break; } swap(i, best); i = best; l = i * 2 + 1; } } public static void swap(int i, int j) { int tmp = heap[i]; heap[i] = heap[j]; heap[j] = tmp; } // 也找到了leetcode测试链接 // 测试链接 : https://leetcode.cn/problems/meeting-rooms-ii/ // 提交如下代码可以直接通过 public static int minMeetingRooms(int[][] meeting) { int n = meeting.length; Arrays.sort(meeting, (a, b) -> a[0] - b[0]); PriorityQueue<Integer> heap = new PriorityQueue<>(); int ans = 0; for (int i = 0; i < n; i++) { while (!heap.isEmpty() && heap.peek() <= meeting[i][0]) { heap.poll(); } heap.add(meeting[i][1]); ans = Math.max(ans, heap.size()); } return ans; }
}
2.2.3 复杂度分析
一共有 n
个线段.
时间复杂度:每一次将线段的右边界放到堆中, 最多就是进去一次, 然后出去一次, 一共有 n
条线段, 所以时间复杂度是:O(n * log(n))
.
3. 累加和减半的最少操作次数
3.1 题目描述
3.2 逻辑实现
肯定是将所有的数字中最大的数字减少一半, 然后继续选择剩下的里最大的数字减少一半, 看看什么时候能将 sum
减小到 sum/2
以下.(同时需要注意:7 / 2 == 3.5, 这个0.5需要留着
).
利用大根堆, 将所有的数字都放到大根堆中,
- 比如:
arr[] = {20, 10, 100, 50}
. 将这四个数字放入到大根堆中, 这样可以保证所有弹出来的数字肯定是数组中最大的. - 然后将
100
拿出来, 减去50
, - 然后将剩下的
50
放回到大根堆中, 现在大根堆中有50, 50, 20, 10
. - 继续将大根堆中的根节点弹出, 还是
50
, 减去一半, 将剩下的25
放回到大根堆中, 现在大根堆中有50, 25, 20, 10
. - 继续弹出, 这样周而复始, 最后直到累加和减少到了一半以下就停止.
3.3 代码实例
// 提交时把halveArray1改名为halveArray
public static int halveArray1(int[] nums) { // 大根堆 PriorityQueue<Double> heap = new PriorityQueue<>((a, b) -> b.compareTo(a)); double sum = 0; for (int num : nums) { heap.add((double) num); sum += num; } // sum,整体累加和,-> 要减少的目标! sum /= 2; int ans = 0; for (double minus = 0, cur; minus < sum; ans++, minus += cur) { cur = heap.poll() / 2; heap.add(cur); } return ans;
}
3.4 复杂度分析
时间复杂度:O(n * log(n))
.
证明:假设我们直接将所有位置的数字减少一半, 那么我需要遍历所有的数字, 然后进行减少一半的操作, 这样也能达成目标, 而且这个的操作次数是 n
(相当于直接将所有的数字遍历一遍).
现在我将所有的数字放到大根堆中, 每一次都拿出最大的数字, 这样肯定是不会超过 n
个数字的. 每一次取出和放入的操作是:O(log(n))
, 所以最后的时间复杂度是:O(n * log(n))
.
3.5 优化之后的解法
double
类型的数字是有一个精度的, 要是精度太长了也是有可能出错了, 这个肯定都知道.
可以将所有的数字都乘以 2 的 20次方
, 将 double类型的转换为 long类型的
.(注意:这个问题还是不会变化, 和原来一样, 只要将这个新的数组的数字和降到原来的一般就行了).
将 int
类型转为:long
类型的是不会超出的. 因为 int类型是:32位, long是:64位
.
这样的原理是:比如:arr[]数组中有一个数字是:5
, 那我将 5 乘以 2 的 20 次方
, 结果就是:
5 * 2 * 2 * 2 * 2 * 2 * 2 ... * 2
, 所以我以后进行除二的时候, 就直接将 5后面的2
一个一个消除掉就行
相当于是给每一个数字都建立了一个缓冲区(哪怕是我除以十几次 2
也能是一个整数). 而且我自己也能进行控制, 要是 20
不够, 还可以乘以 30
.
剩下的需要注意的地方都在注释中写好了, 主要是利用了自己写的函数, 速度是要比 JDK
自带的快很多的.
public static int MAXN = 100001; public static long[] heap = new long[MAXN]; public static int size; // 提交时把halveArray2改名为halveArray
public static int halveArray2(int[] nums) { size = nums.length; // 设置为数组长度. long sum = 0; for (int i = size - 1; i >= 0; i--) { // 我们这里采用的是从底到顶建堆, 因为这样可以实现时间复杂度:O(n). heap[i] = (long) nums[i] << 20; // 在将其加入的时候给 数字 * 2的20次方, sum += heap[i]; heapify(i); } sum /= 2; int ans = 0; for (long minus = 0; minus < sum; ans++) { heap[0] /= 2; // 这里将顶部的数字 / 2, minus += heap[0]; // 然后将其加起来, heapify(0); // 最后还是将这个数字放回到堆中. 然后利用heapify函数重新建立好大根堆. } // 这个数字有可能已经比较小了, 毕竟减少了一半, 然后利用heapify函数将这个数字进行调整. return ans;
} public static void heapify(int i) { int l = i * 2 + 1; while (l < size) { int best = l + 1 < size && heap[l + 1] > heap[l] ? l + 1 : l; best = heap[best] > heap[i] ? best : i; if (best == i) { break; } swap(best, i); i = best; l = i * 2 + 1; }
} public static void swap(int i, int j) { long tmp = heap[i]; heap[i] = heap[j]; heap[j] = tmp;
}