merge: W63 —— 解析器去二次方(两处 O(N²):松标签重复扫描 + P_BLOCK_RE 回溯;20000 个落单标签 26405ms → 17ms,语义一字不变)
This commit is contained in:
commit
bfa79946d4
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) {
|
||||
|
||||
Reference in new issue
Block a user