1. LeetCode 2296 文本编辑器对顶栈到底怎么拆光标如果你正在搜 LeetCode 2296 设计文本编辑器 的对顶栈解法大概率已经卡在同一个地方光标左边一个栈、右边一个栈听起来很清爽真写起来却总是下标对不上。addText 加错位置deleteText 删多了cursorLeft 和 cursorRight 返回的字符串不是少了字符就是顺序反了。我一开始也在这几个边界上来回改后来发现核心就一句话——光标不是文本里的某个下标而是左栈和右栈之间的那道缝。这道题在 LeetCode 上的完整要求是实现一个带光标的文本编辑器支持在光标处添加文本、删除光标左边 k 个字符、光标左移 k 次、光标右移 k 次后两个操作都要返回光标左边最多 10 个字符。题目保证任意时刻 0 光标位置 文本长度也就是说光标永远不能跑到文本外面去。这个约束直接决定了对顶栈的所有边界处理方式。为什么用两个栈你可以把文本想象成一条被光标切成两半的纸带。左半边倒着放进左栈右半边正着放进右栈栈顶就是紧挨光标的那两个字符。左栈顶是光标左边第一个字符右栈顶是光标右边第一个字符。光标左移就是把左栈顶弹出来压进右栈光标右移就是把右栈顶弹出来压进左栈。添加文本就是把这串字符依次压进左栈。删除就是从左栈弹出。所有操作都只碰栈顶不需要移动数组元素这就是对顶栈比单数组更利落的地方。但这里有个坑cursorLeft 和 cursorRight 返回的是光标左边最多 10 个字符而且必须是从左到右的正常顺序。左栈的弹出顺序是反的所以取完 10 个字符后要反转回来或者干脆用切片取左栈末尾 10 个再拼接。C 里用 stack 的话没法直接切片得先弹出、反转、再压回去顺序不能乱。Python 和 Go 用列表或切片就方便很多直接取末尾 10 个即可。这个差异是很多人换语言重写时最容易翻车的地方。理解了双栈结构和返回顺序剩下的就是逐个操作推导边界。下面我会按四个操作分别拆解给出可复制的结构定义和每一步的下标变化最后用一组 ASCII 演示把整个过程串起来。你跟着走一遍基本就能把这道题的下标细节理清楚。2. 四个操作逐个拆addText、deleteText、cursorLeft、cursorRight 的下标推导2.1 双栈结构定义与光标位置的含义先把结构定下来。左栈left存光标左边的字符栈顶是紧挨光标的那个右栈right存光标右边的字符栈顶也是紧挨光标的那个。光标位置等于len(left)文本总长度等于len(left) len(right)。这个等式是所有边界判断的基准任何时候都不能破坏。用 ASCII 画出来是这样left stack right stack --------- --------- | l e e t | | p r a c | --------- --------- ^ ^ | | 栈顶(左) 栈顶(右) \ / \ / 光标当前文本是leetpractice光标在leet和practice之间。左栈从栈顶到栈底是t e e l右栈从栈顶到栈底是p r a c。注意左栈的存储顺序和显示顺序是相反的这一点在返回字符串时必须处理。2.2 addText把新字符压进左栈addText 最简单直接在光标处插入文本插入后光标停在文本右边。对应到双栈就是把这串字符依次压进左栈右栈完全不动。void addText(string text) { for (char c : text) { left.push(c); } }假设当前左栈是leet栈顶 t右栈是practice栈顶 p执行addText(abc)后左栈变成leetabc栈顶 c右栈不变。光标现在在abc和practice之间。没有越界问题因为添加只会让左栈变长。2.3 deleteText只弹左栈返回实际删除数deleteText(k) 删除光标左边 k 个字符返回实际删除的字符数。如果左栈不够 k 个就全部删掉返回实际删掉的数量。int deleteText(int k) { int ans 0; while (k-- !left.empty()) { left.pop(); ans; } return ans; }边界在于k 可能大于左栈长度。比如左栈只有 4 个字符k 是 10那就只能删 4 个返回 4。右栈不参与删除因为删除键只作用于光标左边。这里用while (k-- !left.empty())就能自然处理不需要额外判断。2.4 cursorLeft左栈弹出压入右栈cursorLeft(k) 把光标左移 k 次每次移动就是把左栈顶弹出、压进右栈。如果左栈空了就不能再移光标停在文本开头。string cursorLeft(int k) { while (k-- !left.empty()) { right.push(left.top()); left.pop(); } return leftMax10(); }leftMax10()负责返回光标左边最多 10 个字符。因为左栈的栈顶是离光标最近的字符所以取出来的顺序是反的需要反转。string leftMax10() { string ans; int cnt min(10, (int)left.size()); for (int i 0; i cnt; i) { ans left.top(); left.pop(); } reverse(ans.begin(), ans.end()); for (char c : ans) { left.push(c); } return ans; }这里有个容易忽略的点取完 10 个字符后必须把弹出的字符原样压回去否则左栈就被破坏了。顺序是弹出、反转、再压回三步都不能少。2.5 cursorRight右栈弹出压回左栈cursorRight(k) 把光标右移 k 次每次移动就是把右栈顶弹出、压回左栈。如果右栈空了光标停在文本末尾。string cursorRight(int k) { while (k-- !right.empty()) { left.push(right.top()); right.pop(); } return leftMax10(); }右移之后同样要返回光标左边最多 10 个字符调用同一个leftMax10()。注意右移时压回左栈的顺序是自然的因为右栈顶本来就是光标右边第一个字符压进左栈后正好成为光标左边最后一个字符。2.6 用一组 ASCII 演示把四个操作串起来从空文本开始依次执行addText(leetcode) - left: leetcode, right: 空 deleteText(4) - 返回 4, left: leet, right: 空 addText(practice) - left: leetpractice, right: 空 cursorRight(3) - right 空无法右移返回 etpractice cursorLeft(8) - left: leet, right: practice, 返回 leet deleteText(10) - 返回 4, left: 空, right: practice cursorLeft(2) - left 空无法左移返回 cursorRight(6) - left: practi, right: ce, 返回 practi每一步都验证len(left)就是光标位置返回的字符串都是左栈末尾最多 10 个字符。把这组用例跑通下标逻辑基本就稳了。3. 用 TaoToken 跑通 LeetCode 2296 的完整代码与测试3.1 为什么用 TaoToken 调模型验证下标逻辑写这道题的时候我习惯先把双栈逻辑用自然语言描述清楚再让模型帮我生成代码骨架然后自己补边界。TaoToken 的模型对话入口可以直接贴题目描述和思路让它输出 C、Python、Go 三个版本对照比自己一个个敲快很多。地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite 注册后在模型对话里选一个代码能力强的模型就行。如果你要批量跑测试用例可以用 API 接入地址是 https://taotoken.net/api 把每个操作的输入输出构造成请求自动比对返回结果。这样能快速发现 cursorLeft 和 cursorRight 返回字符串顺序或长度的问题。3.2 可复制的 Python 完整实现class TextEditor: def __init__(self): self.left [] self.right [] def _left_max_10(self): return .join(self.left[-10:]) def addText(self, text: str) - None: self.left.extend(text) def deleteText(self, k: int) - int: ans min(len(self.left), k) del self.left[-k:] return ans def cursorLeft(self, k: int) - str: while k and self.left: self.right.append(self.left.pop()) k - 1 return self._left_max_10() def cursorRight(self, k: int) - str: while k and self.right: self.left.append(self.right.pop()) k - 1 return self._left_max_10()Python 版本用列表当栈self.left[-10:]直接取末尾 10 个不需要反转因为列表末尾就是光标左边最近的字符顺序天然正确。这是 Python 比 C 用 stack 更顺手的地方。3.3 测试用例与逐步验证obj TextEditor() obj.addText(leetcode) assert obj.deleteText(4) 4 obj.addText(practice) assert obj.cursorRight(3) etpractice assert obj.cursorLeft(8) leet assert obj.deleteText(10) 4 assert obj.cursorLeft(2) assert obj.cursorRight(6) practi每个断言对应一个操作跑通就说明下标和返回顺序都对了。如果某个断言失败先检查左栈末尾 10 个字符的取法再检查左右移时栈的转移方向。3.4 常见报错与排查最常见的错误是 cursorLeft 返回的字符串顺序反了。原因是用 stack 弹出后没有反转或者用列表时取了开头而不是末尾。记住左栈末尾才是光标左边最近的字符。第二个坑是 deleteText 的 k 大于左栈长度时没有取 min导致切片越界。Python 里del self.left[-k:]当 k 大于长度时不会报错但返回的 ans 必须用min(len(self.left), k)算准。第三个坑是 cursorRight 时把右栈元素压回左栈的顺序搞反。右栈顶是光标右边第一个字符压进左栈后应该成为光标左边最后一个字符所以直接 append 就行不需要反转。4. 对顶栈的时间复杂度与进阶优化4.1 每个操作的时间复杂度分析addText 是 O(len(text))每个字符入栈一次。deleteText 是 O(k)最多弹出 k 次。cursorLeft 和 cursorRight 也是 O(k)每次移动只涉及一次栈间转移。返回左栈末尾 10 个字符是 O(10)常数级。整体来看单次调用的时间复杂度是 O(k) 或 O(len(text))满足题目要求。空间复杂度是 O(N)N 是文本总长度所有字符分别存在两个栈里不会重复存储。4.2 进阶每次调用 O(k) 的解决方案题目的进阶要求是每次调用 O(k)。对顶栈天然满足这个要求因为添加是 O(len(text))删除和左右移都是 O(k)取 10 个字符是 O(10)。不需要额外优化只要保证不遍历整个文本就行。如果你用单数组加光标下标实现cursorLeft 和 cursorRight 需要移动光标并截取子串截取最多 10 个字符是 O(10)移动光标是 O(k)也能满足。但 addText 在数组中间插入是 O(N)不如对顶栈。所以对顶栈在添加操作上更有优势。4.3 边界条件清单写代码前先把这几个边界列出来写完逐个对照左栈为空时 cursorLeft 不能继续弹直接返回空字符串。右栈为空时 cursorRight 不能继续弹直接返回左栈末尾 10 个。deleteText 的 k 大于左栈长度时只删左栈全部返回实际删除数。返回的字符串最多 10 个字符不足 10 个就全部返回。左栈末尾 10 个字符的顺序必须是从左到右的正常顺序。5. 语义解析光标位置、栈顶与返回字符串的对应关系5.1 光标位置就是左栈长度任何时候光标位置都等于左栈的元素个数。文本总长度等于左栈加右栈。这个不变式是判断所有操作是否正确的基准。addText 后左栈变长光标右移deleteText 后左栈变短光标左移cursorLeft 把左栈元素转移到右栈光标左移cursorRight 把右栈元素转移回左栈光标右移。5.2 左栈末尾 10 个字符才是返回值cursorLeft 和 cursorRight 返回的都是光标左边最多 10 个字符。光标左边就是左栈的全部内容最近的 10 个就是左栈末尾 10 个。用数组或切片实现时直接取末尾 10 个用 stack 实现时要弹出、反转、压回。这个区别在换语言重写时一定要留意。5.3 右栈的作用只是暂存光标右边的字符右栈不参与返回只负责在光标右移时把字符还回左栈。它的存在是为了让光标移动只涉及栈顶操作不需要移动大量数组元素。理解这一点就能明白为什么右栈的栈顶是光标右边第一个字符而不是最后一个。6. 实测中容易踩的坑与修正方法6.1 返回字符串顺序反了用 C stack 实现时弹出顺序是反的必须反转后再返回。用 Python 列表时直接取末尾 10 个顺序天然正确。如果你从 C 翻译到 Python 时保留了反转逻辑就会把正确的顺序又反回去。检查方法是手动跑一遍cursorLeft看返回的字符串是不是光标左边最近的字符从左到右排列。6.2 deleteText 的 k 越界del self.left[-k:]在 k 大于长度时不会报错但返回的 ans 必须用min(len(self.left), k)算。如果直接返回 k当 k 大于左栈长度时就会返回错误的数量。C 和 Go 里用 while 循环弹出时也要判断栈是否为空。6.3 cursorRight 时右栈为空的处理cursorRight 在右栈为空时不能继续弹直接返回左栈末尾 10 个。如果循环条件写成while k--而不判断!right.empty()就会在右栈为空时继续执行导致越界或死循环。Python 里while k and self.right是安全的C 里要写while (k-- !right.empty())。6.4 用 TaoToken 批量跑测试用例把每个操作的输入输出写成 JSON通过 API 批量请求自动比对返回结果。地址是 https://taotoken.net/api 接入后可以用脚本跑几十组随机用例快速定位边界问题。比如随机生成 addText、deleteText、cursorLeft、cursorRight 的序列每次比对返回值和预期跑几百轮就能覆盖大部分边界。import requests def call_editor(ops, args): url https://taotoken.net/api payload {ops: ops, args: args} # 按实际 API 格式调整 return requests.post(url, jsonpayload).json()实际接入时按 TaoToken 的 API 文档构造请求把每个操作的返回值和本地实现比对。跑通之后这道题的下标细节基本就没什么问题了。