文章 · 2026-02-26

可更新优先级队列:索引追踪与动态调整的权衡

优先级队列是处理任务调度、网络流量整形和图算法(如 Dijkstra)的核心工具。二叉堆通常能很好地处理插入和删除操作,两者都是 $O(\log N)$ 的。但在实际工程中,有两类常见需求会打破这个保证。

其一:需要修改或移除已在堆中的任意元素。若不知道它的位置,查找就需要 $O(N)$ 时间——对数组线性扫描。其二:需要按独立于元素类型的外部准则对元素排序,同时只保留前 N 个候选项,不必将所有输入加载进内存。这两个问题都有针对性的设计方案可以解决。本文依次探讨,索引追踪部分以 Rust 为例,外部优先级变体以 Go 为例。

背景:标准堆的两个局限

标准二叉堆是以数组实现的隐式树结构。元素位置由堆的性质决定,随着插入和删除操作不断变化。这种紧凑布局对内存友好,但元素不知道自己在哪里。

更新某个任务的优先级需要先找到它,线性扫描会把 $O(\log N)$ 的操作变成 $O(N)$。在频繁更新的系统中,这种代价是无法接受的。

模式一:索引追踪

针对任意元素更新问题的解决方案,是让元素拥有自知之明:每个元素必须时刻知道自己在数组中的位置。

机制

  1. 接口约束:堆中的元素必须实现一个特定接口(trait),用于读取和更新自身的索引值。
  2. 同步维护:每当堆执行上浮或下沉操作移动元素时,必须调用此接口更新它们的索引。
  3. 外部句柄:持有元素引用的代码可以在 $O(1)$ 时间内查询它在堆中的索引,然后以 $O(\log N)$ 时间完成优先级更新。

权衡

这种方法并非没有代价,它体现了时间和空间的权衡,并增加了耦合:

Rust 实现

在 Rust 中,定义一个 Trackable trait 来规范这种行为。这种方法比传统面向对象语言更灵活:只要类型有存储索引的字段,就能为其实现这个 trait。

use std::cmp::Ordering;

/// 定义元素必须实现的接口,用于追踪其在堆中的位置
pub trait Trackable {
    type Priority: Ord + Copy;

    fn priority(&self) -> Self::Priority;
    fn set_priority(&mut self, p: Self::Priority);
    fn index(&self) -> usize;
    fn set_index(&mut self, idx: usize);
}

pub struct UpdatablePriorityQueue<T: Trackable> {
    data: Vec<T>,
}

impl<T: Trackable> UpdatablePriorityQueue<T> {
    pub fn new() -> Self {
        Self { data: Vec::new() }
    }

    pub fn push(&mut self, mut item: T) {
        let idx = self.data.len();
        item.set_index(idx);
        self.data.push(item);
        self.bubble_up(idx);
    }

    /// 核心能力:根据已知索引更新优先级
    /// 注意:这里简化了所有权模型,实际使用中可能需要 Rc<RefCell<>> 或类似机制来持有外部引用
    pub fn update_priority(&mut self, idx: usize, new_priority: T::Priority) {
        if idx >= self.data.len() { return; }
        
        let item = &mut self.data[idx];
        let old_priority = item.priority();
        item.set_priority(new_priority);

        // 根据优先级变化方向决定调整策略
        if new_priority < old_priority {
            // 假设是最小堆,优先级变小(更紧急)则上浮
            self.bubble_up(idx);
        } else {
            self.bubble_down(idx);
        }
    }

    fn bubble_up(&mut self, mut idx: usize) {
        while idx > 0 {
            let parent_idx = (idx - 1) / 2;
            if self.data[idx].priority() < self.data[parent_idx].priority() {
                self.swap(idx, parent_idx);
                idx = parent_idx;
            } else {
                break;
            }
        }
    }

    fn bubble_down(&mut self, mut idx: usize) {
        let len = self.data.len();
        loop {
            let left_child = idx * 2 + 1;
            let right_child = idx * 2 + 2;
            let mut smallest = idx;

            if left_child < len && self.data[left_child].priority() < self.data[smallest].priority() {
                smallest = left_child;
            }
            if right_child < len && self.data[right_child].priority() < self.data[smallest].priority() {
                smallest = right_child;
            }

            if smallest != idx {
                self.swap(idx, smallest);
                idx = smallest;
            } else {
                break;
            }
        }
    }

    // 关键辅助函数:交换元素并同步更新索引
    fn swap(&mut self, i: usize, j: usize) {
        self.data.swap(i, j);
        self.data[i].set_index(i);
        self.data[j].set_index(j);
    }
}

// 示例用法
#[derive(Debug)]
struct Task {
    id: String,
    priority: i32,
    index: usize,
}

impl Trackable for Task {
    type Priority = i32;
    fn priority(&self) -> i32 { self.priority }
    fn set_priority(&mut self, p: i32) { self.priority = p; }
    fn index(&self) -> usize { self.index }
    fn set_index(&mut self, idx: usize) { self.index = idx; }
}

update_priority 面临的最大挑战通常不是堆算法本身,而是所有权。如果堆拥有 Task 的所有权,外部代码就很难同时持有对该任务的可变引用来进行更新。常见解决方案包括:

  1. 句柄表:堆中只存储 ID,实际数据保存在外部,通过 ID 查询。
  2. 内部可变性:使用 Rc<RefCell<Task>>,接受运行时开销。
  3. 非安全代码:在极端性能敏感的场景中,可能使用裸指针。

模式二:外部优先级与 Top-N 保留

索引追踪假设优先级存储在元素内部且堆是无界的。另一类问题两个假设都不成立:优先级由调用者在每次插入时提供,且只需要在任意时刻保留前 N 个候选项。

三个场景催生了这种设计:

设计

工业级实现通过简单封装将元素与优先级分离:

// 核心设计:封装标准库的优先级队列
template <class T, class TPriority = int>
class TExtPriorityQueue {
    // 允许外部指定优先级
    void push(const T& data, const TPriority priority);
    const T& ytop() const { return TBase::top().Data; }
};

// Top-N 优化版本
template <class T, class TPriority = int>
class TPriorityTopN {
    size_t N = 0;
    TMaybe<TPriority> LastShifted;
    
    void push(const T& data, const TPriority priority) {
        if (size() < N || priority > top().Pri) {
            TBase::push(data, priority);
            if (size() > N) {
                LastShifted = top().Pri;
                TBase::pop();
            }
        }
    }
};

外部优先级:元素本身不包含优先级字段。调用者在每次 push 时提供优先级,支持多种排序方案而无需修改元素结构。

Top-N 保留:只保留优先级最高的 N 个元素。当大小超过 N 时,优先级最低的元素被移除。无论输入总量如何,内存用量保持 $O(N)$。

边界处理:在边界处插入——新元素的优先级恰好等于当前最小值时——需要明确的策略选择:

void ShrinkToSharpBorder() {
    while (!empty() && top().Pri == LastShifted.GetRef()) {
        pop();
    }
}

权衡

优势:将元素类型与排序逻辑解耦;无论输入量如何,内存始终受限;支持动态、上下文相关的优先级。

劣势:相同优先级的元素可能被任意驱逐,而非按插入顺序。当 N 个元素优先级相等时,队列选择幸存者是不确定的。前 N 名之外的数据被永久丢弃。

Go 实现

// ExtPriorityQueue: 支持外部优先级
type ExtPriorityQueue struct {
    pq PriorityQueue
}

func (q *ExtPriorityQueue) Push(value string, priority int) {
    heap.Push(&q.pq, Item{value, priority})
}

func (q *ExtPriorityQueue) Pop() string {
    return heap.Pop(&q.pq).(Item).value
}

// PriorityTopN: 只维护 Top-N
type PriorityTopN struct {
    pq PriorityQueue
    N  int
}

func (ptn *PriorityTopN) Push(value string, priority int) {
    if ptn.pq.Len() < ptn.N {
        heap.Push(&ptn.pq, Item{value, priority})
    } else if priority > ptn.pq[0].priority {
        heap.Pop(&ptn.pq)
        heap.Push(&ptn.pq, Item{value, priority})
    }
}

输出:

=== Extended Priority Queue Demo (Go) ===

--- Test 1: ExtPriorityQueue ---
Size: 3
Top: task1
Pop: task1
Pop: task3
Pop: task2

--- Test 2: PriorityTopN ---
After push 'a'(10): size=1, top=a
After push 'b'(20): size=2, top=b
After push 'e'(25): size=3, top=e

如何选择两种模式

两种模式都在解决标准堆的局限,但方向不同。

当优先级属于元素本身且会随时间变化时,选择索引追踪——定时器轮、Dijkstra 算法、调度器中的任务重新排序。关键信号是:需要更新已入队的元素。代价是对元素类型的侵入,以及需要仔细处理所有权问题。

当优先级由调用者提供且只关心最优的 N 个结果时,选择外部优先级与 Top-N 保留——搜索排序、限流管道、流式 Top-K 选择。关键信号是:从不需要更新已有条目,只需推入新条目并读取堆顶。代价是相同优先级元素的驱逐是不确定的。

两种模式做出了同一种根本性权衡:都打破了某个封装边界——要么是元素对容器的无感知,要么是容器对调用者排序意图的无感知——以换取标准堆无法提供的性能或容量保证。

© 2026 Yuxu Ge ·