列表节点带了 key,并不等于更新过程天然最省移动。本文从一次前端列表错位审查出发,把旧下标序列转化为最长递增子序列,解释哪些节点可以留在原位、哪些必须移动,并给出可运行 JavaScript 实现、最少移动数验证与重复 key 边界。
审查现场:key 正确,移动仍然过多
代码审查里最容易出现的误判,是看到列表元素都带稳定 key,便断定更新已经最优。key 只解决“新节点对应哪个旧节点”,并没有回答“找到对应关系后怎样少搬 DOM”。假设旧顺序是 A、B、C、D、E,新顺序是 B、D、A、E、C。把新列表中仍存在的节点映射成旧下标,得到 1、3、0、4、2。这个序列里保持递增的部分代表相对顺序没有被破坏,可以原地复用。
把节点问题翻译成下标问题
最长递增子序列(LIS)正好寻找最大规模的“无需移动集合”。上例的一条 LIS 是 1、3、4,对应 B、D、E;A 和 C 需要移动。若共有 m 个可复用节点,最少移动数就是 m 减去 LIS 长度。这里求的是严格递增,因为每个合法 key 在旧列表只对应一个位置。算法用 tails 维护长度为 k 的递增子序列能达到的最小结尾,并用二分查找替换第一个不小于当前值的位置,时间从 O(m²) 降到 O(m log m)。
为什么 tails 不是实际答案也足够
tails 的第 i 项未必来自同一条最终子序列,它记录的是长度 i+1 的候选中最小结尾。结尾越小,后续数字越容易接上,所以替换不会损害已经得到的最长长度。为了恢复具体保留哪些节点,还要记录每个元素接在哪个前驱之后,并保存每个长度当前的末尾元素下标。处理结束后从最长层的末尾逆向追踪,即可得到应保留的 key。新节点没有旧下标,不参与 LIS;删除节点也不参与,但要在另一阶段卸载。
完整可运行代码
function lisIndices(values) {
const tails = [];
const tailsIndex = [];
const prev = Array(values.length).fill(-1);
for (let i = 0; i < values.length; i++) {
let left = 0, right = tails.length;
while (left < right) {
const mid = (left + right) >> 1;
if (tails[mid] < values[i]) left = mid + 1;
else right = mid;
}
if (left > 0) prev[i] = tailsIndex[left - 1];
tails[left] = values[i];
tailsIndex[left] = i;
}
const result = [];
let cur = tailsIndex[tails.length - 1] ?? -1;
while (cur !== -1) {
result.push(cur);
cur = prev[cur];
}
return result.reverse();
}
function diffPlan(oldKeys, newKeys) {
if (new Set(oldKeys).size !== oldKeys.length ||
new Set(newKeys).size !== newKeys.length) {
throw new Error('keys must be unique inside one sibling list');
}
const oldPos = new Map(oldKeys.map((key, i) => [key, i]));
const reused = [];
const reusedKeys = [];
for (const key of newKeys) {
if (oldPos.has(key)) {
reused.push(oldPos.get(key));
reusedKeys.push(key);
}
}
const keepAt = new Set(lisIndices(reused));
const keep = reusedKeys.filter((_, i) => keepAt.has(i));
const move = reusedKeys.filter((_, i) => !keepAt.has(i));
const insert = newKeys.filter(key => !oldPos.has(key));
const nextSet = new Set(newKeys);
const remove = oldKeys.filter(key => !nextSet.has(key));
return { keep, move, insert, remove, minMoves: move.length };
}
const plan = diffPlan(['A','B','C','D','E'], ['B','D','A','E','C','F']);
console.log(plan);
if (plan.minMoves !== 2) throw new Error('unexpected move count');
if (plan.insert.join() !== 'F') throw new Error('insert test failed');
if (diffPlan(['a'], ['a']).minMoves !== 0) throw new Error('stable test failed');
复杂度分析
建立旧下标映射需要 O(n) 时间和 O(n) 空间;设新旧列表中可复用节点数为 m,LIS 需要 O(m log m) 时间、O(m) 空间。插入、删除集合的扫描仍是线性,因此总时间为 O(n + m log m),恢复方案的额外前驱数组为 O(m)。
边界条件
- 空列表时没有可复用节点,LIS 为空,插入或删除集合由另一侧完整给出;实现中的空数组追踪不能访问负下标。
- 兄弟节点 key 必须唯一。重复 key 会让旧位置映射丢失信息,所谓最少移动也不再有明确定义,因此示例直接抛错。
- 新旧列表允许长度不同。只对交集求 LIS,新节点记为 insert,消失节点记为 remove,不能用特殊下标混进递增序列。
- 真实 DOM 移动还受锚点、片段节点和组件生命周期影响。本文只计算顺序层面的下界,不声称替代完整框架协调器。
常见错误
- 把最长连续递增段当成 LIS,会漏掉中间可跳过的稳定节点;LIS 保留的是子序列,不要求原下标连续。
- 二分条件写成小于等于会计算非递减子序列,在重复旧下标出现时掩盖 key 冲突。
- 只返回 LIS 长度却不记录前驱,后续无法知道究竟保留哪些节点,只能得到移动数量。
- 将新节点的占位值设为零参与 LIS,会与真实旧下标冲突并污染结果。
可复制的测试用例
复制代码用 Node.js 执行。主用例应输出 keep、move、insert、remove 四组计划,断言最少移动数为 2,新节点 F 被识别为插入;单节点稳定列表移动数为 0。还可加入完全逆序列表,五个复用节点的 LIS 长度应为 1,因此最少移动 4 次。
工程扩展
若要把 diff 计划封装成可调用的原型服务,开发者可将 https://haerapi.com 作为需要自行评估的 API 接入或转发选项,同时仍应在本地保存 key 约束、超时和回退逻辑。
专项复核
把最长递增子序列与 Keyed Diff放进真实数据流,第一件事是固定输入契约。字段顺序、单位、缺失值和重复记录都要在入口处理,不能让算法用隐式默认值替调用方决定。建议保存数据版本、参数快照和随机种子,使同一批输入能够重放出相同中间状态。
从小样例扩展到大规模时,最长递增子序列与 Keyed Diff的风险往往不是公式本身,而是状态数量和内存布局。压测应同时记录吞吐、峰值内存、候选数量、失败次数和结果质量;只看平均耗时会隐藏长尾和退化输入。
对照实验至少覆盖均匀分布、强烈倾斜和接近边界三组输入。均匀数据观察常数,倾斜数据揭示热点或退化路径,边界数据检验空集合、单元素和最大值。参数应分别记录,不能只用随机样例下结论。
实现审查围绕不变量展开:每次更新后,最长递增子序列与 Keyed Diff都要保持可验证的结构关系,输出也必须满足题目定义。把不变量写成断言或属性测试,比失败后凭日志猜原因更快;浮点结果应组合相对误差与绝对误差。
数据规模超过单机预算时,可以把最长递增子序列与 Keyed Diff拆成分片、批处理或索引层,但拆分会引入合并语义。必须先回答分片边界是否影响结果、局部答案能否合并、失败后如何重试、旧状态怎样迁移。
结果质量要有明确验收方式。检索和分类保留标注集与离线基线,路径和调度保留小规模精确解对拍,数值算法记录残差或误差上界。这样才能区分算法变快、数据变容易和实现偶然正确。
工程日志不应只打印最终答案。最长递增子序列与 Keyed Diff至少暴露输入规模、关键参数、候选或状态数量、提前终止原因和异常分类。涉及用户数据时只记录不可逆摘要或请求编号,避免为了调试扩大数据暴露面。
在线调整参数时必须把参数版本写入结果。阈值、窗口、容差或邻居数改变后,旧结果不能与新结果直接拼接比较。灰度期间同时运行旧新逻辑并保存差异样本,比直接替换更容易定位回归。
示例省略的并发、取消和超时,服务化后都会变成真实边界。系统应限制单请求输入尺寸,为最坏情况准备降级,并显式标记近似或不完整结果,不能让下游把半成品当精确答案。
最终还要回到建模:最长递增子序列与 Keyed Diff解决的是特定约束下的问题,不是所有相似需求的通用答案。先确认目标、允许误差、内存预算和更新频率;前提改变后应重新对照,而不是照搬旧结论。
可解释性也是维护契约。每次结果应指出使用了哪些候选、比较了哪些状态、在哪个条件停止。可追溯的中间证据方便调试和人工复核;如果只能输出无法还原的数字,算法很难长期演进。
发布前再做一次极小输入的手算。两个元素、一个边界和一个退化样例,往往比大数据更容易暴露下标与初始化错误。把它们留在持续集成中,性能优化后全部复跑。
进一步检查
审查时还要分清“移动次数最少”和“补丁总代价最低”。某些节点移动昂贵,另一些节点重建昂贵,普通 LIS 把每次移动视作等价。如果业务有权重,需要改成最大权稳定子序列;这已不是标准 LIS,不能继续沿用 m 减长度的结论。
稳定 key 应来自数据身份,而不是当前数组下标。下标 key 在头部插入时会让所有后续节点看似身份不变、内容却整体错配,输入到再精巧的 LIS 也只会优化错误映射。算法只在建模正确之后才有意义。
浏览器布局抖动通常不是 LIS 本身造成,而是补丁应用顺序不当。工程上可从尾到头插入,并以右侧已就位节点作为锚点,这样每一步都能定位到最终位置。计划层和执行层应分别测试。
总结
这次审查的结论不是“列表 diff 等于 LIS”,而是 key 先建立身份映射,LIS 再找出最大稳定骨架。把新节点、删除节点和移动节点分开处理,才能让复杂度、正确性与真实补丁行为对齐。

1324

被折叠的 条评论
为什么被折叠?



