perf(parse): 落单 <p> 的「后面还有没有正文」判据改记忆化,parse 从 O(N²) 回到 O(N)

W61 审查点出的问题:`stripLooseTags` 对**每一个**落单 `<p>` 都要调一次 `hasContentAfter`,
而那函数每次从该位置一路现扫到串尾 ⇒ 正文里有 N 个落单标签整体 O(N²)。`parse` 跑在主线程
(`ui/chat/MessageBubble.kt` 的 `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。

改了什么
1. `hasContentAfter(text, from)` → `ContentAfterQuery`(同文件私有类):判定用的还是原来那段
   扫描本身(`next` 逐分支对应原循环,判据仍是同一组 OPEN_P_TAG_RE / CLOSE_P_TAG_RE,没有另立
   一套「什么算 `<p>` 标签」的定义),只是把走链途经位置的答案落表,之后 O(1) 命中。每个位置
   至多被算一次 ⇒ 整体线性。惰性建表:没遇到落单 `<p>` 开标签的常规消息一个字节都不多花。
2. 顺着同一个输入查下去,发现同一形状下**还有另一处 O(N²)**:`tokenize` 的
   `P_BLOCK_RE.findAll(raw)`——正文里没有 `</p>` 时,每个 `<p` 候选点都会带着懒惰的
   `([\s\S]*?)</p>` 回溯到串尾。完整块必然以 `</p>` 收尾,所以把最后一个 `</p>` 之后切掉再扫:
   每个匹配都整体落在被切下的前缀里、正则的尝试顺序也没变 ⇒ 匹配结果逐字不变,而这一路回溯没了。

实测(本机 JVM 单测内 `System.nanoTime` 计时,修复前后跑的是同一份测速脚手架、同一台机器)

  `parse("<p>".repeat(N))`        修复前       修复后
  N = 2000                         854ms         5ms
  N = 8000                        4254ms        12ms
  N = 20000                      26405ms        17ms
  另一轮完整曲线 N=1000/2000/4000/8000/16000 = 148/238/881/3700/15973ms(每翻一倍涨约 4 倍)
  新增的复杂度回归测试在修复前报:`60000 字符(20000 个落单标签)用了 31226ms`

  两处各自的份额(N=20000,`"<p>".repeat(N)`):落单标签判据约 23s、完整块搜索约 8.2s。

  `parse(" \n<p> ".repeat(20000))`(标签间夹空白):修复前也是 20s 量级,修复后 6ms。

  已知残留(**非本次引入**,改前改后一致,在测试里如实登记):`"<p ".repeat(N) + "</p>"`
  这种「`<p` + 空白一路没有 `>`、最后只有一个 `</p>`」的病态输入仍走完整块搜索的最坏路径
  (N=2000/8000/20000 = 105/1067/7314ms → 108/1242/7201ms)。要治它得把完整块搜索换成手写的
  线性匹配(牵动气泡切分的核心语义),本切片没做。

语义
`parse` 的对外行为一字未改:气泡切分、旁白依附、空块过滤、畸形回落全部照旧。既有 121 个测试
套件 1471 条全部通过、一条期望值都没动,另外新增 2 条测试:1 条复杂度回归
(`hugeRunOfLooseTagsIsDroppedWithinALinearBudget`,20000 个落单标签 + 宽松阈值,注释里写明
它测的是复杂度不是精确耗时)+ 1 条钉住「后面有正文才留字面」的语义
(`aLooseTagIsKeptWheneverThereIsStillContentAfterIt`)。

验收:`gradlew.bat testDebugUnitTest assembleDebug --rerun-tasks` → BUILD SUCCESSFUL
(121 suites / 1473 tests / 0 failures / 0 errors;APK 已产出)。无新依赖。
This commit is contained in:
AskaEth committed 2026-10-07 11:50:53 +08:00
1 parent 541a70f126
commit 0e834fc854
2 files changed
+125 -16

No files matched your search

@@ -172,7 +172,18 @@ private sealed interface Tok {
/** 先切 `<p>` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */
private fun tokenize(raw: String): List<Tok> {
val pMatches = P_BLOCK_RE.findAll(raw).toList()
// 完整块必然以 `</p>` 收尾,所以最后一个 `</p>` 之后不可能再有块:切掉尾巴再让正则去扫。不切的话,
// 每个 `<p` 候选点都会带着懒惰的 `([\s\S]*?)</p>` 一路回溯到串尾——正文里 N 个落单标签时
// 这里就是 O(N²):实测 20000 个要 8.2 秒(同一输入下 `stripLooseTags` 那边还要 23 秒,见
// [ContentAfterQuery])。切掉后同一输入 1 毫秒内出结果,且匹配结果逐字不变(每个匹配都整体落在
// 被切下的前缀里,正则的尝试顺序也没变)。
val lastClose = raw.lastIndexOf("</p>")
val pMatches =
if (lastClose < 0) {
emptyList()
} else {
P_BLOCK_RE.findAll(raw.substring(0, lastClose + "</p>".length)).toList()
}
val out = ArrayList<Tok>()
if (pMatches.isEmpty()) {
// 一条完整块都没有:`<p>` 及其后内容按既有契约整段回落字面文本,`<action>` 这类旁白标签
@@ -224,6 +235,9 @@ private fun tokenize(raw: String): List<Tok> {
private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags: Boolean): String {
if (text.indexOf('<') < 0) return text
val roleRanges = if (stripStrayRoleTags) ROLE_RE.findAll(text).map { it.range }.toList() else emptyList()
// 「这个落单 `<p>` 后面还有没有正文」按位置记表:正文里 N 个落单标签,现扫是 O(N²)(见
// [ContentAfterQuery] 的 KDoc)。常规消息(没有落单 `<p>` 开标签)一次都不建表。
val contentAfter = ContentAfterQuery(text)
val out = StringBuilder(text.length)
var last = 0
var i = 0
@@ -234,7 +248,7 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags
if (gt < 0) break
val tag = text.substring(lt, gt + 1)
val drop = when {
OPEN_P_TAG_RE.matches(tag) -> markupMode || !hasContentAfter(text, gt + 1)
OPEN_P_TAG_RE.matches(tag) -> markupMode || !contentAfter.hasContentAfter(gt + 1)
CLOSE_P_TAG_RE.matches(tag) -> true
!stripStrayRoleTags -> false
roleRanges.any { lt in it } -> false
@@ -253,27 +267,78 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags
return out.toString()
}
/** [from] 之后是否还有正文:跳过空白与后续的 `<p>` 族标签后仍非空即为有。 */
private fun hasContentAfter(text: String, from: Int): Boolean {
var i = from
while (i < text.length) {
if (text[i].isWhitespace()) {
i++
continue
/**
* [stripLooseTags] 判落单 `<p>` 用的「[from] 之后还有没有正文」查询:空白跳过,`<p>` / `</p>`
* 整段跳过,其余字符(别的标签、落单的 `<`)一律算有正文——跳过空白与后续 `<p>` 族标签后仍非空
* 即为有。判据与原实现逐字一致,只多了记忆化。
*
* **为什么要记忆化**:这条判据原本每次查询都一路现扫到串尾,而 [stripLooseTags] 对**每一个**
* 落单 `<p>` 都要问一次,正文里 N 个落单标签就是 O(N²)——20000 个落单 `<p>` 的输入实测这条判据
* 要 23 秒(同一次 `parse` 合计 31 秒),而 `parse` 跑在主线程上(`ui/chat/MessageBubble.kt` 的
* `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。
*
* **为什么可以只算一次**:答案只取决于**位置**——同一个位置再问一次,走的一定是同一条链、到的
* 一定是同一个终点。所以走链时把沿途每个位置的答案落表,之后 O(1) 命中;每个位置至多被算一次,
* N 个落单标签整体就是线性的(同一输入实测这条判据从 23 秒降到毫秒级)。
*
* **为什么不改变语义**:判定用的就是原来那段扫描本身([next] 逐分支对应原循环),没有另立一套
* 「什么算 `<p>` 标签」的判据——仍是同一组 [OPEN_P_TAG_RE] / [CLOSE_P_TAG_RE]。落表的答案是同一条
* 扫描推出来的,因此与逐次现扫逐字相同;连「起点落在标签内部」这种查询也一致(原扫描看到的是
* 标签里的字符、判有正文,[next] 在这里同样返回 null)。
*
* 惰性:没遇到落单 `<p>` 开标签的常规消息一次也不建表,一个字节都不多花。
*/
private class ContentAfterQuery(private val text: String) {
/** 每个位置的答案:`0` = 还没算、[NOISE_ONLY] = 后面只剩空白与 `<p>` 族标签、[HAS_CONTENT] = 有正文。 */
private val answers: ByteArray by lazy(LazyThreadSafetyMode.NONE) {
ByteArray(text.length + 1).also { it[text.length] = NOISE_ONLY }
}
/** 本次走链途经的位置,用来把 [answers] 一次落表。 */
private val path: IntArray by lazy(LazyThreadSafetyMode.NONE) { IntArray(text.length) }
/** 与原先「每次现扫到串尾」的判定逐字等价。 */
fun hasContentAfter(from: Int): Boolean {
var walked = 0
var i = from
while (true) {
val known = answers[i]
if (known != UNKNOWN) return finish(walked, known)
path[walked++] = i
val step = next(i)
if (step == null) return finish(walked, HAS_CONTENT)
i = step
}
if (text[i] == '<') {
}
/** 把这次走链途经的位置一次落表:它们与起点走的是同一条链,答案必然相同。 */
private fun finish(walked: Int, answer: Byte): Boolean {
for (k in 0 until walked) answers[path[k]] = answer
return answer == HAS_CONTENT
}
/**
* 原扫描的一步:空白 → 下一位;`<p>` 族标签 → 标签之后;其余(别的标签、落单的 `<`)→ `null`,
* 即「有正文」,扫描到此为止。走到 `text.length` 时由 [answers] 里预置的答案收口。
*/
private fun next(i: Int): Int? {
val c = text[i]
if (c.isWhitespace()) return i + 1
if (c == '<') {
val gt = text.indexOf('>', i)
if (gt >= 0) {
val tag = text.substring(i, gt + 1)
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) {
i = gt + 1
continue
}
if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) return gt + 1
}
}
return true
return null
}
private companion object {
const val UNKNOWN: Byte = 0
const val NOISE_ONLY: Byte = 1
const val HAS_CONTENT: Byte = 2
}
return false
}
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
@@ -325,6 +325,14 @@ class ChatContentParserTest {
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p>乙").map { it.body })
}
/** 剥不剥只看它**后面**还有没有内容:后面有正文的留字面,后面什么都没有的才剥。 */
@Test
fun aLooseTagIsKeptWheneverThereIsStillContentAfterIt() {
assertEquals(listOf("<p>甲<p>乙"), parse("<p>甲<p>乙").map { it.body })
assertEquals(listOf("<p>甲"), parse("<p>甲<p>").map { it.body })
assertEquals(listOf("<p>甲"), parse("<p>甲<p><p><p>").map { it.body })
}
@Test
fun strayRoleTagOutsideParagraphIsDropped() {
assertEquals(listOf("甲"), parse("<p>甲</p></action>").map { it.body })
@@ -509,6 +517,42 @@ class ChatContentParserTest {
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
}
// ------------------------------------------------------------------
// 复杂度回归:一大段落单标签不许把 parse 拖成 O(N²)
//
// ⚠️ 这条测的是**复杂度**,不是精确耗时:阈值宽到 20000 个落单标签给 2 秒,只用来区分
// 「线性」与「平方」。修复前实测:2000 个 854ms、8000 个 4254ms、20000 个 26405ms(每翻
// 一倍涨约 4 倍,典型平方);修复后 20000 个是十几毫秒。所以阈值别卡紧(CI 机器可能更慢),
// 这里量到的数也别当性能指标读——要盯的是「有没有退回 O(N²)」。
//
// 这个形状同时压住两处(修复前各占一部分,都在 `ChatContentParser` 里):
// - 落单标签的「后面还有没有正文」判据(`ContentAfterQuery`,修复前约 23 秒);
// - 完整块搜索 `P_BLOCK_RE.findAll`(没有 `</p>` 时每个 `<p` 候选点都回溯到串尾,约 8.2 秒)。
// ------------------------------------------------------------------
/** 复杂度回归的宽松上限(毫秒):只区分线性与平方,不是耗时指标。 */
private val looseTagBudgetMs = 2_000L
@Test
fun hugeRunOfLooseTagsIsDroppedWithinALinearBudget() {
val count = 20_000
val inputs = listOf(
"<p>".repeat(count), // 一串纯落单开标签
" \n<p> ".repeat(count), // 标签之间夹空白:跳过空白后仍是「后面什么都没有」
)
for (input in inputs) {
val startedAt = System.nanoTime()
val bubbles = parse(input)
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
assertTrue("落单标签应当被剥净,实际留下:${bubbles.map { it.body }}", bubbles.isEmpty())
assertTrue(
"${input.length} 字符($count 个落单标签)用了 ${elapsedMs}ms," +
"超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?",
elapsedMs < looseTagBudgetMs,
)
}
}
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
for (bubble in bubbles) {