我可以有效地从 HashSet 中随机采样吗?

17

我有一个std::collections::HashSet,我想要随机地抽取并删除其中一个元素。

目前,我的做法是使用rand.gen_range随机抽取索引,然后遍历HashSet到该索引以获取元素。然后我删除所选的元素。这种方法可行,但效率不高。是否有一种更有效的方法来随机抽样元素?

这是我代码的简化版本:

use std::collections::HashSet;

extern crate rand;
use rand::thread_rng;
use rand::Rng;

let mut hash_set = HashSet::new();

// ... Fill up hash_set ...

let index = thread_rng().gen_range(0, hash_set.len());
let element = hash_set.iter().nth(index).unwrap().clone();
hash_set.remove(&element);

// ... Use element ...
5个回答

6

考虑 Sven Marnach 的答案,我想使用向量,但也需要在不重复的情况下进行常数时间插入。然后我意识到我可以同时维护一个向量和一个集合,并确保它们始终具有相同的元素。这将允许常数时间插入和去重以及常数时间的随机删除。

这是我最终实现的代码:

struct VecSet<T> {
    set: HashSet<T>,
    vec: Vec<T>,
}

impl<T> VecSet<T>
where
    T: Clone + Eq + std::hash::Hash,
{
    fn new() -> Self {
        Self {
            set: HashSet::new(),
            vec: Vec::new(),
        }
    }
    fn insert(&mut self, elem: T) {
        assert_eq!(self.set.len(), self.vec.len());
        let was_new = self.set.insert(elem.clone());
        if was_new {
            self.vec.push(elem);
        }
    }
    fn remove_random(&mut self) -> T {
        assert_eq!(self.set.len(), self.vec.len());
        let index = thread_rng().gen_range(0, self.vec.len());
        let elem = self.vec.swap_remove(index);
        let was_present = self.set.remove(&elem);
        assert!(was_present);
        elem
    }
    fn is_empty(&self) -> bool {
        assert_eq!(self.set.len(), self.vec.len());
        self.vec.is_empty()
    }
}

6
唯一允许在常数时间内进行统一抽样的数据结构是具有常数时间索引访问的数据结构。`HashSet`不提供索引,因此无法在常数时间内生成随机样本。
建议先将哈希集转换为`Vec`,然后从向量中进行抽样。要删除元素,只需将最后一个元素移动到其位置 - 向量中的元素顺序无关紧要。
如果您想以随机顺序消耗集合中的所有元素,则还可以对向量进行一次洗牌,然后迭代它。
以下是从`Vec`中以恒定时间删除随机元素的示例实现:
use rand::{thread_rng, Rng};

pub trait RemoveRandom {
    type Item;

    fn remove_random<R: Rng>(&mut self, rng: &mut R) -> Option<Self::Item>;
}

impl<T> RemoveRandom for Vec<T> {
    type Item = T;

    fn remove_random<R: Rng>(&mut self, rng: &mut R) -> Option<Self::Item> {
        if self.len() == 0 {
            None
        } else {
            let index = rng.gen_range(0..self.len());
            Some(self.swap_remove(index))
        }
    }
}

(Playground)


2
根据 HashSet::iter 的文档,它返回一个“按任意顺序访问所有元素的迭代器”。
如果对于您的使用情况来说,任意顺序足够接近均匀随机分布,这个方法是 O(1) 的,并且每次都会返回不同的值。
// Build a set of integers 0 - 99
let mut set = HashSet::new();
for i in 0..100 {
    set.insert(i);
}
// Sample
for _ in 0..10 {
    let n = set.iter().next().unwrap().clone();
    println!("{}", n);
    set.remove(&n);
}

和作者一样,我想从HashSet中删除采样后的值。通过这种方式多次采样,而不改变HashSet,似乎每次都会得到相同的结果。


1
注意:这里可以正常运行是由于哈希集已经重新调整了大小。如果哈希集相对稳定,则此方法不适用。 - Ziliang Lin

2
Sven的答案建议将HashSet转换为Vec,以便在O(1)时间内从Vec中随机采样。此转换需要O(n)时间,如果只偶尔需要进行转换,例如从一个不变的哈希集合中取一系列随机样本,则适用。如果需要经常进行转换,例如在取随机样本之间,想要插入一些通过值进行O(1)删除的操作,则较不适用,因为这将涉及在HashSet和Vec之间来回转换,每次转换需要O(n)时间。
isaacg的解决方案是同时保留HashSet和Vec,并对它们进行操作。这允许通过索引进行O(1)查找,O(1)随机删除和O(1)插入,但不能进行O(1)值查找或O(1)值删除(因为Vec无法完成这些操作)。
下面是一种数据结构,它允许按索引或值O(1)查找,O(1)插入和O(1)删除:
它是一个`HashMap`和一个`Vec`的组合,其中`Vec`将索引(即`usizes`)映射到 `T`,而`HashMap`将`T`映射到`usize`。可以将`HashMap`和`Vec`视为彼此的反函数,以便您可以从索引转到其值,并从值返回到其索引。插入和删除操作的定义使得索引恰好是从0到size()-1的整数,不允许存在空缺。我称这种数据结构为BijectiveFiniteSequence。(请注意`take_random_val`方法;它在O(1)时间内运行。)
use std::collections::HashMap;
use rand::{thread_rng, Rng};

#[derive(Clone, Debug)]
struct BijectiveFiniteSequence<T: Eq + Copy + Hash> { 
    idx_to_val: Vec<T>,
    val_to_idx: HashMap<T, usize>,
}
impl<T: Eq + Copy + Hash> BijectiveFiniteSequence<T> {
    fn new () -> BijectiveFiniteSequence<T> {
        BijectiveFiniteSequence {
            idx_to_val: Vec::new(),
            val_to_idx: HashMap::new()
        }
    }
    fn insert(&mut self, val: T) {
        self.idx_to_val.push(val);
        self.val_to_idx.insert(val, self.len()-1);
    }
    fn take_random_val(&mut self) -> Option<T> {
        let mut rng = thread_rng();
        let rand_idx: usize = rng.gen_range(0..self.len());
        self.remove_by_idx(rand_idx)
    }
    fn remove_by_idx(&mut self, idx: usize) -> Option<T> {
        match idx < self.len() {
            true => {
                let val = self.idx_to_val[idx];
                let last_idx = self.len() - 1;
                self.idx_to_val.swap(idx, last_idx);
                self.idx_to_val.pop();
                // update hashmap entry after the swap above
                self.val_to_idx.insert(self.idx_to_val[idx], idx);
                self.val_to_idx.remove(&val);
                Some(val)
            },
            false => None
        }
    }
    fn remove_val(&mut self, val: T) -> Option<T> {
        //nearly identical to the implementation of remove_by_idx,above 
        match self.contains(&val) {
            true => {
                let idx: usize = *self.val_to_idx.get(&val).unwrap();
                let last_idx = self.len() - 1;
                self.idx_to_val.swap(idx, last_idx);
                self.idx_to_val.pop();
                // update hashmap entry after the swap above
                self.val_to_idx.insert(self.idx_to_val[idx], idx);
                self.val_to_idx.remove(&val);
                Some(val)
            }
            false => None
        }
    }
    fn get_idx_of(&mut self, val: &T) -> Option<&usize> {
        self.val_to_idx.get(val)
    }
    fn get_val_at(&mut self, idx: usize) -> Option<T> {
        match idx < self.len() {
            true => Some(self.idx_to_val[idx]),
            false => None
        }
    }
    fn contains(&self, val: &T) -> bool {
        self.val_to_idx.contains_key(val)
    }
    fn len(&self) -> usize {
        self.idx_to_val.len()
    }
    // etc. etc. etc.
}

1
谢谢您的回复!这看起来比我之前给出的答案更符合我的需求。不过,您提供的代码示例无法编译 - 它会产生13个错误,请参见此链接:https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=02890a0cd7991ec865f72aee0ad08122 您能否更新一下代码? - isaacg
不用谢!我刚刚把它修改成可工作的版本了。 - Asker
我相信来自 indexmap crate 的 IndexSet 实现了同样的目的。 - kaya3
Vec实现了swap_remove,与您的swap(last)pop相同。可能会稍微好一点(2次复制而不是3次)。 - undefined

0
2023年,实现Rust标准库HashSet的hashbrown crate引入了RawTable API,该API允许对HashSet的内部状态进行不安全的低级访问。利用这个API,我们可以直接实现HashSet的随机删除操作。
use hashbrown::HashSet;
use rand::prelude::*;
use std::hash::Hash;

fn remove_random<T, R>(set: &mut HashSet<T>, rng: &mut R) -> Option<T>
where
    R: Rng,
    T: Eq + PartialEq + Hash,
{
    if set.is_empty() {
        return None;
    }
    // If load factor is under 25%, shrink to fit.
    // We need a high load factor to ensure that the sampling succeeds in a reasonable time,
    // and the table doesn't rebalance on removals.
    // Insertions can only cause the load factor to reach as low as 50%,
    // so it's safe to shrink at 25%.
    if set.capacity() >= 8 && set.len() < set.capacity() / 4 {
        set.shrink_to_fit();
    }
    let raw_table = set.raw_table_mut();
    let num_buckets = raw_table.buckets();
    // Perform rejection sampling: Pick a random bucket, check if it's full,
    // repeat until a full bucket is found.
    loop {
        let bucket_index = rng.gen_range(0..num_buckets);
        // Safety: bucket_index is less than the number of buckets.
        // Note that we return the first time we modify the table,
        // so raw_table.buckets() never changes.
        // Also, the table has been allocated, because set is a HashSet.
        unsafe {
            if raw_table.is_bucket_full(bucket_index) {
                let bucket = raw_table.bucket(bucket_index);
                let ((element, ()), _insert_slot) = raw_table.remove(bucket);
                return Some(element);
            }
        }
    }
}

这个程序需要启用 hashbrown crate 的 "raw" 功能,需要一个类似以下的 Cargo.toml 文件:
[dependencies]
hashbrown = { version = "^0.14.2", features = ["raw"] }
rand = "^0.8.5"

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接