class 027 堆结构常见题目

这篇文章是看了“左程云”老师在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. 按照上面图片的例子:将 1, 3, 5, null(放不放都一样), 全部都放到小根堆中,
  2. 然后将小根堆中最小的节点弹出(肯定是根节点 1 了), 因为这是一个链表, 可以通过 next 指针找到 1 的下一个节点(是 8). 此时设置一个变量 head 指向最开始弹出来的 1.
  3. 然后将 8 放到小根堆中, 利用 JDK 中自带的 PriorityQueue类, 这样可以在实现加入和弹出的操作的同时进行自动的调整. 此时小根堆中有 3, 5, 8 .然后设置一个变量 pre, 指向当前弹出的节点, 因为需要直到当前走到了哪里, 这样好进行下一个节点的连接.
  4. 然后继续弹出堆中最小的节点 3, 所以将 pre.next 指针连接到 3, 然后将 pre 指向这个 3 节点,
  5. 然后继续将 3 节点的下一个节点放入小根堆中.
  6. 以后重复上述操作, 直到小根堆中没有任何东西位为止.

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. 比如现在我们有:[1, 5], [3, 7], [2, 4], [1, 3], [2, 6], [5, 9],
  2. 然后将其按照左边界的大小(小的在前)排好序:[1, 5], [1, 3], [2, 4], [2, 6], [3, 7], [5, 9],
  3. 然后设置一个小根堆, 用来放置所有的线段的右边界.
  4. [1, 5] 的右边界放到堆中, 现在只有 [1, 5] 自己冲到 1 的左边界之后, 所以此时记为 1.
  5. 然后判断 [1, 3], 先判断小根堆中有没有小于 [1, 3]左边界 1 的数字, 要是有, 直接将其全部弹出, 因为要是堆中的右边界都到不了 1 这个左边界, 那就肯定是冲不进来. 肯定是不用考虑的. 但是这个 [1, 3] 没有上述情况, 所以将 3 放到堆中, 堆中现在有 3, 5. 此时代表有两条线段能到 1 这个左边界的位置:[1, 5] 和 [1, 3]. 所以此时记为 2.
  6. [2, 4] 加入到其中, 继续重复上述操作, 堆中没有比 2 小的, 堆中有:3, 4, 5 所以此时记为 3.
  7. 然后将 [2, 6] 加入到其中, 继续重复上述操作, 堆中没有比 2 小的, 堆中有:3, 4, 5, 6 所以此时记为 4.
  8. 然后将 [3, 7] 加入到其中, 继续重复上述操作, 堆中的 3, 4, 5, 63 <= 左边界3, 所以弹出堆中的 3, 因为此时 1, 3 已经对所有的线段没有意义了, 因为无论是以后的还是以前的线段, 都没有任何一个能和 [1, 3] 重合了, 因为我们最开始的时候已经排好序了, 不存在后续有好几个 [1, 2] 这样的情况. 此时现在堆中有:4, 5, 6, 7. 此时记为:4.
  9. 然后将 [5, 9] 放入其中, 堆中的 4, 5 <= 5, 所以将其弹出, 此时堆中有 6, 7, 9, 此时记为 3.
  10. 最后的答案是 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需要留着).

利用大根堆, 将所有的数字都放到大根堆中,

  1. 比如:arr[] = {20, 10, 100, 50}. 将这四个数字放入到大根堆中, 这样可以保证所有弹出来的数字肯定是数组中最大的.
  2. 然后将 100 拿出来, 减去 50,
  3. 然后将剩下的 50 放回到大根堆中, 现在大根堆中有 50, 50, 20, 10.
  4. 继续将大根堆中的根节点弹出, 还是 50, 减去一半, 将剩下的 25 放回到大根堆中, 现在大根堆中有 50, 25, 20, 10.
  5. 继续弹出, 这样周而复始, 最后直到累加和减少到了一半以下就停止.

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;  
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值