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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite ,注册后在模型对话里选一个代码能力强的模型就行。
如果你要批量跑测试用例,可以用 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, json=payload).json()实际接入时按 TaoToken 的 API 文档构造请求,把每个操作的返回值和本地实现比对。跑通之后,这道题的下标细节基本就没什么问题了。