这篇文章是看了“左程云”老师在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;
}