增强堆:任意元素删除与更新

通过反向索引表扩展堆的能力,并用堆解决最大线段重合问题。

约 4 分钟1,674 字#算法#数据结构

本文将深入探讨两个主题:首先,我们将剖析一个在传统堆基础上进行功能扩展的“增强堆”,它如何通过引入辅助结构实现对任意元素的高效操作;其次,我们将应用数据结构的知识,解决一个经典的“最大线段重合”问题。本篇基于上次的堆的基础进行书写,不再赘述已经讲过的堆的基本方法。

1. 增强堆

标准的堆(如Java中的 PriorityQueue)在提供快速访问顶部元素(最大或最小)方面非常高效,但它在某些操作上存在局限性,例如:无法高效地删除或更新堆中任意一个非顶部元素。为了克服这些限制,我们可以通过结合哈希表(HashMap)来构建一个功能更强大的“增强堆”。

1.1. 核心思路:引入反向索引表

增强堆的关键在于引入一个“反向索引表”(indexMap),它是一个哈希表,用于存储堆中每个元素当前在数组(ArrayList)中的具体位置(索引)。

  • 传统堆的困境:在一个标准的堆中,如果要调整某个特定元素,我们首先需要遍历整个数组才能找到它,这个过程的时间复杂度是 O(N)。
  • 增强堆的突破:有了 indexMap,我们可以用 O(1)的时间复杂度定位到任何元素在堆中的位置。这使得后续的调整(上浮或下沉)操作能够精准、高效地进行,整体操作的时间复杂度优化到 O(log N)。

1.2. 基础结构与核心操作

增强堆的数据结构主要由三部分组成:

  1. ArrayList<T> heap:堆的底层存储,使用动态数组。
  2. HashMap<T, Integer> indexMap:反向索引表,记录元素到索引的映射。
  3. Comparator<? super T> comp:比较器,用于定义堆的排序规则(大根堆或小根堆)。
1.2.1. 交换操作(swap)

交换是堆调整的基础。在增强堆中,swap 操作不仅要交换数组中两个元素的位置,还必须同步更新它们在 indexMap 中的索引记录。这是确保索引表始终正确的关键。

java
    public void swap(ArrayList<T> heap, int a, int b){

        T temp = heap.get(a);
        heap.set(a, heap.get(b));
        heap.set(b, temp);
        // 索引表索引同步更新
        indexMap.put(heap.get(b), b);
        indexMap.put(heap.get(a), a);

    }

1.3. 增强功能的实现

基于正确的索引映射,我们可以轻松实现对堆中任意元素的删除和调整。

1.3.1. 任意元素的删除(remove)

删除堆中任意一个元素的标准流程是:

  1. 通过 indexMap 找到目标元素 obj 的索引 idx。
  2. 将堆的最后一个元素 tailNode 取出。
  3. 从 indexMap 和 heap 中移除 obj 和最后一个元素。
  4. 如果被删除的 obj 恰好不是最后一个元素,则将 tailNode 放置在 idx 位置,并更新其在 indexMap 中的索引。
  5. 最后,对 tailNode 执行一次调整(resign),以恢复堆的有序性。
java
    public void remove(T obj) {
        T tailNode = heap.get(heapSize - 1);
        int idx = indexMap.get(obj);
        indexMap.remove(obj);
        heap.remove(--heapSize);
        if (tailNode != obj) {
            heap.set(idx, tailNode);
            indexMap.put(tailNode, idx);
            resign(tailNode);
        }
    }
1.3.2. 元素的调整(resign)

当堆中某个元素的值发生变化后,其在堆中的位置可能不再满足堆的性质。resign 方法就是为了解决这个问题。它通过对指定元素同时尝试执行“上浮”(heapInsert)和“下沉”(heapify)操作,来将其调整到正确的位置。由于一个元素要么需要上浮,要么需要下沉,这两个操作中只有一个会实际执行,另一个会因不满足循环条件而立即终止。

java
    public void resign(T obj) {
        heapInsert(heap, indexMap.get(obj));
        heapify(heap, indexMap.get(obj));
    }

1.4. 完整代码实现

以下是 HeapGreater 的完整实现代码,它封装了上述所有逻辑,提供了一个功能完备且高效的增强堆。

java
package L7;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;

public class HeapGreater<T> {

    private ArrayList<T> heap;
    private Integer heapSize;
    private HashMap<T, Integer> indexMap;
    private Comparator<? super T> comp;

    public HeapGreater(Comparator<? super T> comp) {
        this.heap = new ArrayList<>();
        this.heapSize = 0;
        this.indexMap = new HashMap<>();
        this.comp = comp;
    }

    public int size() {
        return heapSize;
    }

    public boolean isEmpty(){
        return heapSize == 0;
    }

    public T peek() {
        return heap.getFirst();
    }

    public boolean contains(T obj) {
        return indexMap.containsKey(obj);
    }

    public void push(T obj){
        heap.add(obj);
        indexMap.put(obj, heapSize);
        heapInsert(heap, heapSize++);
    }

    public void heapInsert(ArrayList<T> heap, int idx){
        while (comp.compare(heap.get(idx), heap.get((idx - 1) / 2)) < 0) {
            swap(heap, idx, (idx - 1) / 2);
            idx = (idx - 1) / 2;
        }
    }

    public T pop(){
        if (isEmpty()) {
            throw new RuntimeException("堆目前为空!无法弹出");
        }
        T rst = heap.getFirst();
        swap(heap, 0, heapSize - 1);
        indexMap.remove(heap.get(heapSize - 1));
        heap.remove(--heapSize);
        heapify(heap, 0);

        return rst;
    }

    private void heapify(ArrayList<T> heap, int idx) {
        int left = idx * 2 + 1;
        while (left < heapSize) {
            int right = left + 1;
            int childMin = right < heapSize && comp.compare(heap.get(right), heap.get(left)) < 0 ? right : left;
            int finalMin = comp.compare(heap.get(childMin), heap.get(idx)) < 0 ? childMin : idx;
            if (finalMin == idx) {
                break;
            }
            swap(heap, idx, finalMin);
            idx = finalMin;
            left = idx * 2 + 1;
        }
    }

    public void swap(ArrayList<T> heap, int a, int b){

        T temp = heap.get(a);
        heap.set(a, heap.get(b));
        heap.set(b, temp);
        indexMap.put(heap.get(b), b);
        indexMap.put(heap.get(a), a);

    }

    public void resign(T obj) {
        heapInsert(heap, indexMap.get(obj));
        heapify(heap, indexMap.get(obj));
    }

    public void remove(T obj) {
        T tailNode = heap.get(heapSize - 1);
        int idx = indexMap.get(obj);
        indexMap.remove(obj);
        heap.remove(--heapSize);
        if (tailNode != obj) {
            heap.set(idx, tailNode);
            indexMap.put(tailNode, idx);
            resign(tailNode);
        }
    }

    public List<T> getAllElements() {
        List<T> ans = new ArrayList<>();
        for (T c : heap) {
            ans.add(c);
        }
        return ans;
    }

}

2. 算法题解:最大线段重合问题

掌握了高效的数据结构后,我们来看一个能够巧妙利用堆来解决的经典问题。

2.1. 问题描述

给定一个包含多个线段的数组,每个线段由 [start, end] 两个整数表示(闭区间)。我们需要计算在所有线段中,重合区域最多包含了多少条线段。规定线段重合区域的长度必须大于等于1。

2.2. 核心思路:排序 + 最小堆

这个问题的最优解法是“扫描线”思想的体现,可以借助最小堆来实现。

  1. 排序:首先,将所有的线段按照 start 端点从小到大进行排序。这确保我们总是按时间顺序处理线段的开始事件。
  2. 最小堆:我们使用一个最小堆(PriorityQueue)来存放当前“活跃”线段的 end 端点。堆顶始终是所有活跃线段中最早结束的那个。
  3. 遍历与处理:我们依次遍历排序后的线段数组。当处理第 i 条线段 lines[i] 时:
    • 移除已结束的线段:检查堆顶。如果堆顶的 end 值小于或等于当前线段的 start 值(pq.peek() <= lines[i].start),说明堆顶代表的线段在当前线段开始之前就已经结束了,不构成重合。因此,将其从堆中弹出。重复此过程,直到堆为空或堆顶的 end 值大于 lines[i].start。
    • 加入当前线段:将当前线段的 end 值(lines[i].end)加入最小堆。
    • 更新最大值:此时,堆的大小 pq.size() 就代表了在 lines[i].start 这个时间点上,总共有多少条线段是重合的。我们用一个变量 max 来记录这个过程中的最大值。

遍历完所有线段后,max 中存储的就是最终的答案。

图解思路
plaintext
线段按 start 排序: (1,5), (2,4), (3,6), (7,8)

i=0, line=(1,5):
  - 堆中清理: (堆为空)
  - (1,5) 的 end=5 入堆. 堆: [5]
  - 当前重合数: 1. max=1

i=1, line=(2,4):
  - 堆中清理: 堆顶 5 > start 2. 无需清理.
  - (2,4) 的 end=4 入堆. 堆: [4, 5]
  - 当前重合数: 2. max=2

i=2, line=(3,6):
  - 堆中清理: 堆顶 4 > start 3. 无需清理.
  - (3,6) 的 end=6 入堆. 堆: [4, 5, 6]
  - 当前重合数: 3. max=3

i=3, line=(7,8):
  - 堆中清理: 
    - 堆顶 4 <= start 7. 弹出 4. 堆: [5, 6]
    - 堆顶 5 <= start 7. 弹出 5. 堆: [6]
    - 堆顶 6 <= start 7. 弹出 6. 堆: []
  - (7,8) 的 end=8 入堆. 堆: [8]
  - 当前重合数: 1. max 维持 3

遍历结束, 最终答案: 3

2.3. 代码实现

下面是该思路的Java代码实现。代码中定义了 Line 类和用于排序的比较器 LineComp。

java
package L7;

import java.util.Arrays;
import java.util.Comparator;
import java.util.PriorityQueue;
import java.util.Random;

public class MaxCover {

    public static class Line {

        public int start;
        public int end;

        public Line() {
        }

        public Line(int start, int end) {
            this.start = start;
            this.end = end;
        }
    }

    public static class LineComp implements Comparator<Line> {
        @Override
        public int compare(Line o1, Line o2) {
            return o1.start - o2.start;
        }
    }

    public static int getMaxCover(Line[] lines) {

        if (lines == null || lines.length < 2) {
            return 0;
        }

        PriorityQueue<Integer> pq = new PriorityQueue<>();
        int[] count = new int[lines.length];
        Arrays.sort(lines, new LineComp());

        int max = -1;
        for (int i = 0; i < lines.length; i++) {
            pq.add(lines[i].end);
            while (!pq.isEmpty() && pq.peek() <= lines[i].start) {
                pq.poll();
            }
            count[i] = pq.size();
            max = Math.max(max, count[i]);
        }

        return max;
    }

2.4. 算法验证:对数器

为了确保我们算法的正确性,代码中还提供了一个“对数器”——即一个暴力解法(getMaxCoverBruteForce)和一套随机测试框架。

  • 暴力解法:找到所有线段坐标的最大和最小值,然后遍历这个范围内的每一个点(例如 min + 0.5, min + 1.5, ...),统计覆盖每个点的线段数,并取最大值。
  • 随机测试:通过大量生成随机线段数据,分别用我们的解法和暴力解法进行计算,对比结果是否一致。这是一种非常有效的验证算法正确性的工程方法。
java
    public static int getMaxCoverBruteForce(Line[] lines) {
        if (lines == null || lines.length < 2) {
            return 0;
        }
        int min = Integer.MAX_VALUE;
        int max = Integer.MIN_VALUE;
        for (Line line : lines) {
            if (line.start < min) min = line.start;
            if (line.end > max) max = line.end;
        }
        if (min == Integer.MAX_VALUE) {
            return 0;
        }
        int ans = 0;
        for (double p = min + 0.5; p < max; p += 1.0) {
            int cur = 0;
            for (Line line : lines) {
                if (line.start <= p && line.end >= p) {
                    cur++;
                }
            }
            ans = Math.max(ans, cur);
        }
        return ans;
    }

    public static Line[] generateRandomLines(int maxN, int minV, int maxV) {
        Random r = new Random();
        int n = r.nextInt(maxN + 1);
        Line[] arr = new Line[n];
        for (int i = 0; i < n; i++) {
            int a = r.nextInt(maxV - minV + 1) + minV;
            int b = r.nextInt(maxV - minV + 1) + minV;
            if (a == b) {
                if (b < maxV) {
                    b++;
                } else {
                    a--;
                }
            }
            int start = Math.min(a, b);
            int end = Math.max(a, b);
            arr[i] = new Line(start, end);
        }
        return arr;
    }

    public static Line[] copyLines(Line[] src) {
        if (src == null) return null;
        Line[] copy = new Line[src.length];
        for (int i = 0; i < src.length; i++) {
            copy[i] = new Line(src[i].start, src[i].end);
        }
        return copy;
    }

    public static void printLines(Line[] arr) {
        if (arr == null) {
            System.out.println("null");
            return;
        }
        System.out.print("[");
        for (int i = 0; i < arr.length; i++) {
            System.out.print("(" + arr[i].start + "," + arr[i].end + ")");
            if (i != arr.length - 1) System.out.print(", ");
        }
        System.out.println("]");
    }

    public static void main(String[] args) {
        int testTime = 5000;
        int maxN = 8;
        int minV = 0;
        int maxV = 20;
        System.out.println("开始随机测试,共 " + testTime + " 轮 ...");
        for (int i = 1; i <= testTime; i++) {
            Line[] data1 = generateRandomLines(maxN, minV, maxV);
            Line[] data2 = copyLines(data1);
            int ans1 = getMaxCover(data1);
            int ans2 = getMaxCoverBruteForce(data2);
            if (ans1 != ans2) {
                System.out.println("第 " + i + " 轮出现不一致!");
                System.out.println("题解结果: " + ans1 + ", 暴力结果: " + ans2);
                System.out.println("线段数组:");
                printLines(data2);
                return;
            }
        }
        System.out.println("测试完成,所有结果均一致!");
    }

}

3. 总结

本文通过两个案例展示了算法学习的两个重要方面。第一,通过对基础数据结构(堆)的改造,我们创造出功能更强大的工具,能够解决更复杂的问题;第二,通过对具体问题(线段覆盖)的分析,我们学习了如何选择和组合合适的数据结构(排序数组+最小堆)来设计出高效且优雅的解决方案。希望这次的分享能对你有所启发。