AskaEth 0e834fc854 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 已产出)。无新依赖。
2026-10-07 11:50:53 +08:00
S
Description
昔涟在安卓设备上的载体。
Apache-2.0
19 MiB
0 Stars 1 Watchers 0 Forks
2026-05-29 22:15:56 +08:00