R 中如何高效地为矩阵动态追加行,并尽量减少复制?
结论
对于普通的 base R 矩阵,没有通用的原地追加行操作能保证不复制底层数据。反复使用 rbind() 可以得到正确结果,但矩阵增长时会反复分配和搬运数据。
可以保留矩阵,同时记录已使用行数和已分配容量:先分配一批行,容量不足时再成倍扩容。递归推理时,尽量在已有单元格中更新状态,只在出现新的观察记录时追加行。
R6 可以让多个调用访问同一个对象,但其中的普通矩阵仍遵循 R 的对象修改语义,因此不能保证矩阵赋值完全不发生复制。
为什么向量可以扩展,矩阵却会报错?
矩阵在底层是带有 dim 属性的向量。例如:
m <- matrix(1:50, ncol = 2, byrow = TRUE)
dim(m)
# [1] 25 2
执行下面的代码时,[<- 按矩阵的二维下标规则处理赋值,而第 26 行超出了现有维度:
m[26, ] <- c(51, 52)
这种赋值不会自动改变 dim。原始材料中的错误文本使用了下标 29,与示例代码不一致,但 26 和 29 都超出了这个矩阵的行数。
向量的一维下标替换允许延长长度;data.frame 有自己的 [<-.data.frame 替换方法,支持相应的增加行行为。它们的语法相似,扩展规则却不同,而且能够扩展也不代表不会复制。
矩阵采用按列存储的布局。增加一行时,每一列的长度都会变化,后续列的起始位置也会随之变化。因此,追加一行通常不能简单地理解为在现有数据末尾再放几个值。
用预分配容量减少反复扩容
如果能估计最大行数,可以直接按上限分配。如果无法估计,可以从较小容量开始,每次容量不足时翻倍。
下面用 environment 保存矩阵和已使用行数,矩阵的实际行数就是容量。相同字段也可以放进 R6 对象。
new_log <- function(n_categories = 3L, capacity = 64L) {
stopifnot(
length(n_categories) == 1L,
!is.na(n_categories),
n_categories >= 1L,
n_categories == as.integer(n_categories),
length(capacity) == 1L,
!is.na(capacity),
capacity >= 1L,
capacity == as.integer(capacity)
)
log <- new.env(parent = emptyenv())
log$n <- 0L
log$cardset <- matrix(
NA_integer_, nrow = capacity, ncol = n_categories
)
log$status <- matrix(
NA, nrow = capacity, ncol = n_categories
)
log
}
append_log <- function(log, cards, known = rep(NA, length(cards))) {
stopifnot(
is.integer(cards),
is.logical(known),
length(cards) == ncol(log$cardset),
length(known) == ncol(log$status)
)
next_row <- log$n + 1L
capacity <- nrow(log$cardset)
if (next_row > capacity) {
new_capacity <- max(next_row, 2L * capacity)
n_categories <- ncol(log$cardset)
cards_new <- matrix(
NA_integer_, nrow = new_capacity, ncol = n_categories
)
status_new <- matrix(
NA, nrow = new_capacity, ncol = n_categories
)
used <- seq_len(log$n)
cards_new[used, ] <- log$cardset[used, , drop = FALSE]
status_new[used, ] <- log$status[used, , drop = FALSE]
log$cardset <- cards_new
log$status <- status_new
}
log$cardset[next_row, ] <- cards
log$status[next_row, ] <- known
log$n <- next_row
invisible(next_row)
}
log <- new_log()
row_id <- append_log(
log,
cards = c(3L, 7L, 13L),
known = c(TRUE, FALSE, FALSE)
)
log$status[row_id, 1L]
# [1] TRUE
这里的 64L 只是示例初始容量,可以根据实际记录规模调整。cards 要求使用整数向量,因此示例使用 3L 等整数常量;status 使用逻辑值和逻辑型 NA。
读取有效记录时,只取前 log$n 行:
used <- seq_len(log$n)
cardset <- log$cardset[used, , drop = FALSE]
status <- log$status[used, , drop = FALSE]
drop = FALSE 可以避免结果在只有一行或一列时退化为向量。可以等到展示、导出或确实需要时再提取有效矩阵,避免每次追加后都创建一份完整结果。
在列数固定的前提下,按倍数扩容时,各次扩容需要搬运的数据量形成几何级数,累计与最终行数同阶。这个结论只描述扩容策略本身;R 的对象共享、临时引用等因素仍可能导致赋值时复制,因此不能据此承诺整个实现具有严格的原地修改或运行时间保证。
递归推理时,先区分新记录和状态变化
递归调用时,先判断是否确实需要新增一行。
假设一条观察记录表达“玩家 #2 至少持有这三张牌中的一张”。后来得知其中两张牌不在玩家手中,这通常是对已有约束的进一步求解,可以更新原行:
log$status[row_id, 2L] <- FALSE
log$status[row_id, 3L] <- FALSE
log$status[row_id, 1L] <- TRUE
收到新的游戏观察,或者确实需要保存一条独立的推理历史时,再追加记录。如果为了审计而保存每一步推理,可以将历史日志与当前状态分开存储。
推理链较长时,也可以把递归改为待处理队列:
- 将新事实加入队列。
- 取出一个事实,更新受影响的状态。
- 只重新检查与该事实有关的约束。
- 将新推导出的事实加入队列,直到队列为空。
要避免将同一事实反复入队,只有状态发生实际变化时才继续传播。同时维护“玩家、卡牌”到相关记录的索引,可以减少每次扫描整个日志的开销。
保留矩阵时需要注意什么?
矩阵适合存储这里同类型、规则排列的卡牌 ID 和逻辑状态,但需要注意以下边界:
NA表示未知,不能当作FALSE。只有某条约束明确表示“至少持有其中一张”,且其他候选全部为FALSE时,才能将唯一剩余的未知候选确定为TRUE。- 如果这类约束中的所有候选都变成
FALSE,应报告信息冲突,而不是继续推导。 cardset和status必须同步扩容。turn若与它们逐行对应,也需要维护相同的有效行映射。- 为已有矩阵新增类别会改变列结构,同样需要重新组织存储。如果类别在一局开始前已经确定,可以先固定该局列数。
若扩展包只是增加固定类别,矩阵仍然合理;若以后允许一条约束包含同一类别的多张候选牌,当前“一类一列”的结构就无法完整表达,需要考虑候选集合或长表结构。
对于目前描述的需求,先预分配容量,并减少推理过程中不必要的追加,通常是较直接的实现方向。具体耗时仍需根据真实记录规模和更新方式测量。
备注:内容仅供参考。
