连续存储不规则数组的原地随机洗牌实现方案咨询
嘿,针对你提到的这种大尺寸不规则数组(ragged array)洗牌的问题,我刚好有几个实用的方案,既可以节省内存,又能满足洗牌需求,我按实现难度和内存开销给你梳理一下:
方案1:间接索引表(最低实现复杂度,低内存开销)
这绝对是最实用的方案——完全不用修改原始的values、shape和offsets数组,只需要维护一个行索引数组就行。你对这个索引数组执行Fisher-Yates洗牌,后续访问数组时通过索引映射到原始行即可。
为啥推荐它?
- 内存开销极小:仅需要一个长度等于行数的
Vec<usize>,就拿1亿行来说,8字节/元素的话也就800MB,远小于复制整个values数组的开销(按每行平均10个4字节浮点数算,1亿行就是40GB,根本没法比)。 - 实现超简单,完全不碰原始数据,避免了内存移动带来的性能损耗。
- 还能随时恢复原始顺序,只要保留好初始的索引数组就行。
Rust示例代码:
use rand::Rng; use rand::seq::SliceRandom; // 嫌手动写麻烦的话,用这个库方法也行 // 生成洗牌后的行索引数组 fn shuffle_rows_with_index(rows_count: usize) -> Vec<usize> { // 先创建初始索引:0,1,2,...,rows_count-1 let mut indices: Vec<usize> = (0..rows_count).collect(); // 手动实现Fisher-Yates洗牌(也可以直接用indices.shuffle(&mut rng)) let mut rng = rand::thread_rng(); for i in (1..rows_count).rev() { let j = rng.gen_range(0..=i); indices.swap(i, j); } indices } // 通过索引数组访问洗牌后的行 fn access_shuffled_row( indices: &[usize], row_idx: usize, values: &[f32], offsets: &[usize], shape: &[usize] ) -> &[f32] { let original_row = indices[row_idx]; let start = offsets[original_row]; let len = shape[original_row]; &values[start..start+len] }
方案2:原地行交换(低额外内存,修改原始数据)
如果你必须修改原始数组的存储顺序(比如后续需要直接遍历values就能拿到洗牌后的行),那可以基于Fisher-Yates的思路,直接交换两行的内存块,同时更新shape和offsets数组。这里只需要一个临时缓冲区,大小等于最长行的长度——你说每行最多20个4字节浮点数,也就是80字节,这点内存完全可以忽略不计。
核心步骤:
- 从最后一行往前遍历(Fisher-Yates的逆序遍历逻辑)。
- 随机选一个
j(0≤j≤当前行索引i),交换行i和行j。 - 交换的时候要注意:
- 先把两行的长度存下来,因为后续要调整内存和偏移量。
- 如果两行长度不一样,得先移动中间的内存块腾出空间,再交换数据。
- 最后更新
shape数组,还要调整受影响的后续行的offsets值。
Rust示例代码片段(核心交换逻辑):
use rand::Rng; // 交换两行的核心逻辑 fn swap_rows( values: &mut [f32], shape: &mut [usize], offsets: &mut [usize], i: usize, j: usize, ) { if i == j { return; } // 统一让i大于j,方便后续处理内存块移动 let (i, j) = if i < j { (j, i) } else { (i, j) }; let len_i = shape[i]; let len_j = shape[j]; let start_i = offsets[i]; let start_j = offsets[j]; // 先把行i的数据存到临时缓冲区 let mut temp = vec![0.0; len_i]; temp.copy_from_slice(&values[start_i..start_i+len_i]); // 处理长度不同的情况,移动中间的内存块 if len_i > len_j { // 行i更长,需要把中间的块后移(腾出空间给行j的数据) let shift = len_i - len_j; values.copy_within(start_j+len_j..start_i, start_j+len_j+shift); } else if len_i < len_j { // 行i更短,需要把中间的块前移(填补行j数据移走后的空隙) let shift = len_j - len_i; values.copy_within(start_i..start_j+len_j, start_i-shift); } // 把行j的数据移到行i的位置 values.copy_within(start_j..start_j+len_j, start_i); // 把临时存储的行i数据移到行j的位置 values[start_j..start_j+len_i].copy_from_slice(&temp); // 更新shape数组 shape.swap(i, j); // 更新offsets数组中受影响的部分 let delta = len_i as isize - len_j as isize; // 先更新j+1到i的偏移量 for k in j+1..=i { offsets[k] = (offsets[k] as isize + delta) as usize; } // 如果i不是最后一行,还要更新i之后的所有偏移量 if i < offsets.len() - 1 { for k in i+1..offsets.len() { offsets[k] = (offsets[k] as isize + delta) as usize; } } } // 原地洗牌整个数组 fn shuffle_rows_in_place( values: &mut [f32], shape: &mut [usize], offsets: &mut [usize], ) { let rows_count = shape.len(); let mut rng = rand::thread_rng(); for i in (1..rows_count).rev() { let j = rng.gen_range(0..=i); swap_rows(values, shape, offsets, i, j); } }
方案3:原地重排(零额外内存,极高实现复杂度)
如果你的内存极端受限,连索引数组的几百MB都拿不出来,那可以试试类似原地排序的“循环置换”思路:把每个行的位置看作一个置换环,沿着环把行移动到目标位置,全程只用少量临时空间存当前行的数据。不过这个方案实现起来特别复杂,调试难度也大,除非真的走投无路,不然不推荐用。
核心思路:
- 生成随机置换,但不用存储整个置换数组,而是用一个标记数组记录哪些行已经处理过。
- 对每个未处理的行,沿着置换环依次移动行到目标位置,每次移动都调整
values、shape和offsets。 - 标记数组用
Vec<bool>就行,内存开销是1字节/元素,1亿行也就100MB,比索引数组更小。
内容的提问来源于stack exchange,提问作者MSR
相关产品推荐
相关产品推荐

