最短编辑距离:动态规划原理、Java实现与性能优化
1. 最短编辑距离到底在解决什么问题第一次接触“最短编辑距离”这个概念是在做一个文本比对工具的时候。当时的需求很朴素用户输入两个字符串系统要判断它们有多像。我一开始想的是逐字符比较统计不同字符的数量但很快就发现这个思路根本行不通——因为“abc”和“abxc”只差一个字符但“abc”和“xabc”也只差一个字符可它们在语义上的相似度感受完全不同。更麻烦的是当两个字符串长度不一致时简单的逐位比较会直接错位后面全乱套。最短编辑距离Minimum Edit Distance也叫Levenshtein距离解决的正是这个问题把字符串A变成字符串B最少需要多少次单字符编辑操作。这里的编辑操作有三种——插入一个字符、删除一个字符、替换一个字符。每次操作计数为1最终的最小操作次数就是两个字符串之间的编辑距离。这个数字越小说明两个字符串越相似。距离为0意味着完全相同距离等于较长字符串的长度意味着它们几乎没有共同之处。听起来简单但真正把它想透、写对、用好在实际项目里中间有不少坑。它适合谁我认为三类人最需要掌握一是做搜索或推荐系统的开发者模糊匹配、拼写纠错、query改写都离不开它二是做自然语言处理相关工作的比如文本对齐、语音识别结果评估三是任何需要做数据清洗和相似度判断的场景比如用户输入标准化、重复数据检测。哪怕你只是刷算法题它也是动态规划的经典入门案例理解了它很多二维DP问题都能触类旁通。这篇文章我会从原理推导开始把状态定义、转移方程、初始化、边界条件全部拆开讲清楚然后给出完整的Java实现包括二维数组版本和空间优化版本。最后会分享我在实际使用中踩过的坑和排查技巧这些是你在教科书里不太容易看到的。2. 原理解构为什么动态规划是唯一正解2.1 从暴力递归到动态规划的思维转变要理解最短编辑距离先得理解为什么不能用贪心。假设我们要把“kitten”变成“sitting”直觉上你会想从左到右逐位比较不同就替换多了就删除少了就插入。但问题是每一步的选择会影响后续的所有决策。比如在某一位上你选择替换可能比先删除再插入更优但在另一个位置情况可能反过来。这种“当前最优不等于全局最优”的特性直接排除了贪心策略。暴力递归的思路是对于两个字符串的当前位置i和j我们有三种操作可以选择每种操作都会导向一个子问题我们取三种选择中的最小值。递归的终止条件是某一个字符串已经处理完毕剩下的操作数就是另一个字符串剩余的长度。这个思路是对的但时间复杂度是指数级的因为大量子问题被重复计算。动态规划的核心洞察在于子问题具有重叠性。当我们计算“kitten”的前3个字符和“sitting”的前4个字符的编辑距离时这个结果会被后续多个状态复用。所以我们可以用一个二维表把所有子问题的答案存下来从最小的子问题开始逐步推导到最终答案。这就是自底向上的动态规划。2.2 状态定义与转移方程的推导过程定义dp[i][j]为将字符串A的前i个字符转换成字符串B的前j个字符所需的最少编辑操作次数。注意这里的“前i个字符”是指A[0..i-1]下标从0开始。现在考虑dp[i][j]怎么从已知状态推导出来。我们关注A的第i个字符即A[i-1]和B的第j个字符即B[j-1]如果A[i-1] B[j-1]这两个字符已经匹配不需要任何操作所以dp[i][j] dp[i-1][j-1]。如果A[i-1] ! B[j-1]我们有三种操作可选替换把A[i-1]替换成B[j-1]然后处理前面的部分代价是dp[i-1][j-1] 1。删除删掉A[i-1]然后处理A的前i-1个字符和B的前j个字符代价是dp[i-1][j] 1。插入在A中插入一个等于B[j-1]的字符然后处理A的前i个字符和B的前j-1个字符代价是dp[i][j-1] 1。取三者最小值就是dp[i][j]的值。这里有一个容易混淆的点为什么删除对应的是dp[i-1][j]插入对应的是dp[i][j-1]你可以这样理解——删除操作消耗了A的一个字符所以A的指针前移B的指针不动插入操作相当于在A中“补”了一个B的字符所以B的指针前移A的指针不动。替换操作同时消耗了A和B各一个字符所以两个指针都前移。2.3 初始化与边界条件的深层逻辑dp数组的第一行和第一列需要单独初始化这是很多初学者容易忽略的地方。dp[0][j]表示把空字符串变成B的前j个字符显然需要j次插入操作所以dp[0][j] j。dp[i][0]表示把A的前i个字符变成空字符串显然需要i次删除操作所以dp[i][0] i。这两个初始化看似简单但它们保证了后续所有状态转移都有正确的基准。如果初始化错了整个表都会错。我见过有人在初始化时把dp[0][0]设成1结果所有结果都偏大排查了半天才发现是这里的问题。最终答案就是dp[m][n]其中m和n分别是两个字符串的长度。2.4 时间复杂度与空间复杂度的权衡二维DP的时间复杂度是O(mn)因为我们需要填充整个二维表每个格子的计算是O(1)的。空间复杂度也是O(mn)因为需要存储整个表。对于大多数实际场景O(mn)的空间是可以接受的。但如果两个字符串都很长比如比较两篇几千字的文章m和n可能达到几千甚至上万这时候O(mn)的空间就会成为问题。比如mn10000时二维数组需要一亿个int大约400MB内存这显然不现实。所以我们需要空间优化版本。观察状态转移方程dp[i][j]只依赖于dp[i-1][j-1]、dp[i-1][j]和dp[i][j-1]。也就是说计算第i行时只需要第i-1行的数据和当前行已经计算过的数据。所以我们可以用两个一维数组滚动更新把空间复杂度降到O(min(m,n))。更进一步如果只需要最终的编辑距离值而不需要回溯操作路径我们甚至可以用一个一维数组加一个临时变量来实现。这个技巧在面试中经常被问到后面我会给出具体代码。3. Java代码实现从二维数组到空间优化3.1 二维数组版本最直观的实现先给出最基础、最容易理解的二维数组版本。这个版本适合初学者理解算法流程也适合需要回溯操作路径的场景。public class EditDistance { public static int minDistance(String word1, String word2) { int m word1.length(); int n word2.length(); // dp[i][j] 表示 word1 前 i 个字符转换成 word2 前 j 个字符的最少操作数 int[][] dp new int[m 1][n 1]; // 初始化第一列word1 前 i 个字符变成空串需要 i 次删除 for (int i 0; i m; i) { dp[i][0] i; } // 初始化第一行空串变成 word2 前 j 个字符需要 j 次插入 for (int j 0; j n; j) { dp[0][j] j; } // 填充剩余格子 for (int i 1; i m; i) { for (int j 1; j n; j) { if (word1.charAt(i - 1) word2.charAt(j - 1)) { // 字符相同不需要额外操作 dp[i][j] dp[i - 1][j - 1]; } else { // 三种操作取最小替换、删除、插入 dp[i][j] 1 Math.min( dp[i - 1][j - 1], // 替换 Math.min( dp[i - 1][j], // 删除 dp[i][j - 1] // 插入 ) ); } } } return dp[m][n]; } public static void main(String[] args) { System.out.println(minDistance(kitten, sitting)); // 输出 3 System.out.println(minDistance(abc, abc)); // 输出 0 System.out.println(minDistance(, hello)); // 输出 5 } }这段代码的逻辑非常清晰先初始化边界然后双重循环填充每个格子。注意charAt(i-1)和charAt(j-1)的下标转换因为dp数组比原字符串多了一行一列用于表示空串。运行“kitten”到“sitting”的结果是3具体操作是k→s替换、e→i替换、插入g。你可以手动在纸上画一下这个二维表会对手动推导有极大帮助。3.2 空间优化版本一维数组滚动更新理解了二维版本之后空间优化就很容易了。核心思路是我们只需要上一行的数据来计算当前行所以可以用一个一维数组prev保存上一行用另一个一维数组curr保存当前行计算完当前行后交换它们。public static int minDistanceOptimized(String word1, String word2) { int m word1.length(); int n word2.length(); // 让 n 是较小的那个减少空间 if (m n) { return minDistanceOptimized(word2, word1); } int[] prev new int[n 1]; int[] curr new int[n 1]; // 初始化第一行 for (int j 0; j n; j) { prev[j] j; } for (int i 1; i m; i) { curr[0] i; // 第一列初始化 for (int j 1; j n; j) { if (word1.charAt(i - 1) word2.charAt(j - 1)) { curr[j] prev[j - 1]; } else { curr[j] 1 Math.min(prev[j - 1], Math.min(prev[j], curr[j - 1])); } } // 交换 prev 和 curr int[] temp prev; prev curr; curr temp; } return prev[n]; }这里有一个细节在交换之前curr数组会被下一轮覆盖所以不需要清空。但要注意curr[0] i这行必须在每轮开始时设置因为它对应的是dp[i][0]。3.3 单数组版本面试中的加分项如果面试官要求进一步优化空间可以用一个数组加一个变量来实现。核心思路是在计算curr[j]时prev[j-1]会被覆盖所以我们需要用一个变量diag提前保存它。public static int minDistanceOneArray(String word1, String word2) { int m word1.length(); int n word2.length(); if (m n) { return minDistanceOneArray(word2, word1); } int[] dp new int[n 1]; for (int j 0; j n; j) { dp[j] j; } for (int i 1; i m; i) { int diag dp[0]; // 保存 dp[i-1][j-1] dp[0] i; // dp[i][0] for (int j 1; j n; j) { int temp dp[j]; // 保存当前 dp[j]即下一轮的 diag if (word1.charAt(i - 1) word2.charAt(j - 1)) { dp[j] diag; } else { dp[j] 1 Math.min(diag, Math.min(dp[j], dp[j - 1])); } diag temp; } } return dp[n]; }这个版本的技巧在于diag变量的使用。在进入内层循环前diag保存的是dp[i-1][0]在每次计算完dp[j]后把原来的dp[j]即dp[i-1][j]保存到diag中供下一次迭代使用。这样就把空间压缩到了一个数组。3.4 三种实现方式的对比与选型建议实现方式时间复杂度空间复杂度适用场景代码复杂度二维数组O(m*n)O(m*n)需要回溯操作路径、教学演示低双一维数组O(m*n)O(min(m,n))一般生产环境、长字符串比较中单数组变量O(m*n)O(min(m,n))面试、内存极度受限场景高选型建议很直接如果你需要知道具体做了哪些编辑操作比如要展示diff结果必须用二维数组因为回溯需要完整的表。如果只需要距离值用双一维数组版本就够了代码可读性和性能平衡得最好。单数组版本更多是面试时展示功底用的实际项目中除非有极端内存限制否则没必要为了省一个数组而牺牲可读性。4. 实操中的常见问题与排查技巧4.1 下标越界与初始化错误的排查最常见的bug就是数组下标越界。dp数组的大小是(m1) x (n1)但字符串的下标是0到m-1。所以在访问word1.charAt(i-1)时i的范围是1到m不会越界。但如果你在初始化循环里写成了for (int i 0; i m; i)就会漏掉dp[m][0]的初始化导致后续计算出错。另一个常见错误是初始化顺序。有些人先初始化第一行再初始化第一列结果dp[0][0]被覆盖了两次。虽然在这个问题里dp[0][0]都是0不会出错但养成好习惯明确dp[0][0] 0的含义是空串到空串不需要任何操作。排查技巧在代码里加一个打印二维表的方法把每一步的dp值输出出来和手动推导的结果对比。我当初就是靠这个方法发现自己在处理字符相同时忘记取dp[i-1][j-1]而是取了最小值加0导致结果偏大。4.2 字符相同情况下的常见误区很多人会写成这样if (word1.charAt(i-1) word2.charAt(j-1)) { dp[i][j] Math.min(dp[i-1][j-1], Math.min(dp[i-1][j]1, dp[i][j-1]1)); } else { dp[i][j] 1 Math.min(dp[i-1][j-1], Math.min(dp[i-1][j], dp[i][j-1])); }这样写逻辑上不是完全错误但效率会略低而且不符合最优子结构的直觉。当字符相同时最优策略一定是直接继承dp[i-1][j-1]不需要考虑删除或插入。因为如果字符已经匹配额外做删除或插入只会增加操作数不可能更优。所以直接取dp[i-1][j-1]即可。4.3 长字符串比较时的性能优化思路当两个字符串长度达到几千时O(m*n)的时间复杂度可能成为瓶颈。比如mn5000需要计算2500万个格子在Java中大约需要几百毫秒到一秒。如果是在高并发场景下频繁调用这个开销不可忽视。几个优化思路提前终止如果两个字符串长度差超过某个阈值可以直接返回一个较大的距离值不需要完整计算。比如在拼写纠错中如果长度差超过2基本可以判定不相似。带宽限制如果只需要判断编辑距离是否小于某个阈值k可以只计算对角线附近宽度为2k1的区域把时间复杂度降到O(k*min(m,n))。使用int数组而非Integer避免自动装箱拆箱的开销。并行计算对于超长字符串可以把二维表按行分块用多线程并行填充。但要注意行与行之间的依赖关系需要按对角线方向并行。我在实际项目中用过带宽限制的思路把拼写纠错的响应时间从平均80ms降到了5ms以内效果非常明显。4.4 编辑距离的变体与扩展场景标准Levenshtein距离假设三种操作的代价都是1。但在实际应用中不同操作的代价可能不同。比如在DNA序列比对中插入和删除的代价通常高于替换在拼写纠错中键盘上相邻字母的替换代价应该低于不相邻字母。这时候只需要修改转移方程中的代价即可dp[i][j] Math.min( dp[i-1][j-1] (word1.charAt(i-1) word2.charAt(j-1) ? 0 : replaceCost), Math.min( dp[i-1][j] deleteCost, dp[i][j-1] insertCost ) );另一个常见变体是Damerau-Levenshtein距离它额外允许相邻字符的交换操作。这在处理用户输入错误时很有用因为“teh”到“the”只需要一次交换标准Levenshtein距离会算出2替换替换而Damerau版本会算出1。实现上只需要在转移方程中增加一个判断如果word1[i-1] word2[j-2] word1[i-2] word2[j-1]则可以考虑dp[i-2][j-2] 1。4.5 常见问题速查表问题现象可能原因排查方法解决方案结果比预期大1初始化时dp[0][0]设错检查dp[0][0]是否为0确保空串到空串代价为0结果比预期大很多字符相同时未直接继承打印dp表观察相同字符取dp[i-1][j-1]数组越界循环边界写错检查im和jn注意dp数组比字符串多一行一列长字符串内存溢出使用二维数组计算m*n大小改用滚动数组优化结果不稳定多线程共享dp表检查是否有并发写每个线程独立dp表或加锁性能差频繁创建大数组用profiler分析复用数组或使用带宽限制5. 从编辑距离到实际应用我的几点经验编辑距离这个算法看起来简单但真正用好需要理解它的适用边界。它衡量的是字符级别的差异不考虑语义。比如“我喜欢猫”和“我喜欢狗”的编辑距离是1但“我喜欢猫”和“我讨厌猫”的编辑距离也是1可语义上差异巨大。所以在做语义相似度判断时编辑距离只能作为一个粗筛手段不能作为最终决策依据。我在做搜索query纠错时通常会把编辑距离和拼音相似度、键盘距离、词频统计结合起来。编辑距离负责召回候选其他特征负责排序。这样既保证了召回率又提高了准确率。另一个经验是不要自己从头实现编辑距离除非你有特殊需求。Apache Commons Text和Lucene都提供了经过充分测试的实现性能优化也做得很好。自己实现主要用于学习理解或者需要定制化代价函数的场景。最后分享一个调试技巧当你怀疑编辑距离计算结果不对时把两个字符串缩短到3-5个字符手动在纸上画出dp表然后和程序输出的dp表逐格对比。我保证只要你能手动画对一次以后写这个算法基本不会再出错。这个方法比看十遍代码都管用。

相关新闻

私人云盘源码Cloudreve搭建教程:从部署到文件共享传输的避坑指南

私人云盘源码Cloudreve搭建教程:从部署到文件共享传输的避坑指南

简介:这份资源是Cloudreve云盘系统源码,面向希望自建私有网盘的个人用户、单位与公司运维人员,用于搭建简洁美观的文件存储、共享与传输平台,支持各类文件与视频,可部署在本地服务器供员工随时备份数据。压缩包共约200…

2026/10/13 22:34:44 阅读更多 →
PLM零部件管理蓝图:分类编码查重与生命周期落地避坑指南

PLM零部件管理蓝图:分类编码查重与生命周期落地避坑指南

简介:这份PPT资料聚焦PLM项目蓝图设计中的零部件管理模块,面向制造业信息化顾问、研发与主数据管理人员,以及正在推进GPLM一期落地的项目团队。内容围绕零部件主数据管理展开,涵盖需求描述、方案总揽、功能设计与业务流程模板&…

2026/10/12 21:49:51 阅读更多 →
海外仓WMS盘点功能设计:账实一致与库存差异的解决之道

海外仓WMS盘点功能设计:账实一致与库存差异的解决之道

简介:面向海外仓WMS盘点模块的产品设计文档,适合仓储系统产品经理、设计师及开发人员阅读。文档从盘点业务本质出发,系统梳理明盘与暗盘、静态与动态盘点、循环盘点、动碰盘点及指定盘点等概念,并结合盘亏、盘盈、盘平三类结果说明…

2026/10/12 21:49:51 阅读更多 →

最新新闻

第6章:Spring AI 2.0 ChatClient 门面

第6章:Spring AI 2.0 ChatClient 门面

上章思考题答案 用户原话里的 {orderId} 若放进 SystemPromptTemplate 再渲染,StringTemplate 会把它当成变量。缺这个键时,ValidationMode.THROW 抛 IllegalStateException,正常咨询被拒绝。用户原话应走 UserMessage 或 ChatClient.user&a…

2026/10/14 0:03:43 阅读更多 →
【车载定位】基于扩展卡尔曼滤波 EKF,分别利用 TDOA、AOA 以及 TDOA+AOA 融合观测,搭配近恒速 NCV / 近恒位置 NCP运动模型对车辆轨迹估计附Matlab代码

【车载定位】基于扩展卡尔曼滤波 EKF,分别利用 TDOA、AOA 以及 TDOA+AOA 融合观测,搭配近恒速 NCV / 近恒位置 NCP运动模型对车辆轨迹估计附Matlab代码

✅作者简介:热爱科研的Matlab仿真开发者,擅长数学建模、数据处理、建模仿真、程序设计、完整代码获取、论文复现及科研仿真。🍎 往期回顾关注个人主页:Matlab科研工作室👇 关注我领取海量matlab电子书和数学建模资料 &…

2026/10/14 0:03:43 阅读更多 →
人工智能期末复习:用习题集构建考点地图,高效备考不挂科

人工智能期末复习:用习题集构建考点地图,高效备考不挂科

简介:这份资源汇集人工智能课程各章小结与经典习题解答,适合正在系统学习人工智能基础、准备期末考试的本科生或自学者使用。内容覆盖人工智能的概念与发展阶段、研究学派与研究领域、知识与知识表示(谓词逻辑、产生式规则、框架、语义网络&a…

2026/10/14 0:03:43 阅读更多 →
wandb-core 中的 Wire 依赖注入:从贡献指南到 go:generate 自动装配实战

wandb-core 中的 Wire 依赖注入:从贡献指南到 go:generate 自动装配实战

机器学习深度学习数据可视化可观测性 【免费下载链接】wandb The AI developer platform. Use Weights & Biases to train and fine-tune models, and manage models from experimentation to production. 项目地址: https://gitcode.com/gh_mirrors/wa/wandb 点…

2026/10/14 0:03:43 阅读更多 →
微博评论文本情感分析:从数据清洗到三模型对比的完整实践

微博评论文本情感分析:从数据清洗到三模型对比的完整实践

简介:面向毕业设计场景,资源围绕微博评论文本情感分析,完整覆盖支持向量机、朴素贝叶斯与AdaBoost三种机器学习算法的建模流程,适合计算机相关专业本科生用于课程设计或毕业设计参考。压缩包共70个文件,约6.05MB&#…

2026/10/14 0:03:43 阅读更多 →
小型超市仓库管理系统

小型超市仓库管理系统

小型超市仓库管理系统选题背景与意义 随着我国城镇化进程的不断推进以及居民消费水平的持续提升,小型超市作为连接城市社区与消费者的重要商业节点,在日常生活中的作用日益凸显。尤其是在一二线城市周边的居民区、城乡结合部及中小城镇,小型超…

2026/10/14 0:02:42 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →