PAYATHON 2026

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

收到新的游戏观察,或者确实需要保存一条独立的推理历史时,再追加记录。如果为了审计而保存每一步推理,可以将历史日志与当前状态分开存储。

推理链较长时,也可以把递归改为待处理队列:

  1. 将新事实加入队列。
  2. 取出一个事实,更新受影响的状态。
  3. 只重新检查与该事实有关的约束。
  4. 将新推导出的事实加入队列,直到队列为空。

要避免将同一事实反复入队,只有状态发生实际变化时才继续传播。同时维护“玩家、卡牌”到相关记录的索引,可以减少每次扫描整个日志的开销。

保留矩阵时需要注意什么?

矩阵适合存储这里同类型、规则排列的卡牌 ID 和逻辑状态,但需要注意以下边界:

  • NA 表示未知,不能当作 FALSE。只有某条约束明确表示“至少持有其中一张”,且其他候选全部为 FALSE 时,才能将唯一剩余的未知候选确定为 TRUE。
  • 如果这类约束中的所有候选都变成 FALSE,应报告信息冲突,而不是继续推导。
  • cardset 和 status 必须同步扩容。turn 若与它们逐行对应,也需要维护相同的有效行映射。
  • 为已有矩阵新增类别会改变列结构,同样需要重新组织存储。如果类别在一局开始前已经确定,可以先固定该局列数。

若扩展包只是增加固定类别,矩阵仍然合理;若以后允许一条约束包含同一类别的多张候选牌,当前“一类一列”的结构就无法完整表达,需要考虑候选集合或长表结构。

对于目前描述的需求,先预分配容量,并减少推理过程中不必要的追加,通常是较直接的实现方向。具体耗时仍需根据真实记录规模和更新方式测量。

备注:内容仅供参考。