
浏览器前进后退这个功能大家天天用但有多少人想过它在数据结构里其实就是一道经典训练题我在早期的程序设计训练中遇到那道编号为3-21的“Web导航”题时第一反应也是“这不就模拟一下浏览器嘛”真正动手才发现里面藏着栈这个数据结构的核心思想以及一堆容易踩的边界坑。这篇文章就把这道题从头到尾拆开讲讲双栈模型为什么是标准答案、代码怎么写才不出错、真实浏览器和题目里的逻辑又差在哪。1. 题目到底在考什么很多入门级程序设计训练题都爱包装成“模拟某某系统”3-21这道“Web导航”就是典型。它给你一个简化版浏览器支持访问新页面、后退、前进、退出这几种操作要求你模拟整个浏览过程并输出当前页面地址。题目需要支持的命令很清晰VISIT url访问一个新网址BACK后退一步FORWARD前进一步QUIT退出程序输入是一连串指令遇到QUIT结束。每次执行完一条指令如果操作合法就输出当前页面的完整URL如果操作非法比如已经在最开头还后退或者已经是最新页面还前进就输出“Ignored”。这个题看起来是“模拟”本质却是考你两个问题第一你清不清楚浏览器的前进/后退到底是个什么数据结构行为第二你能不能把状态边界处理干净。第一个问题的标准答案是双栈第二个问题则是这道题80%的得分点所在。1.1 浏览器的导航史本质上是一条“栈轨迹”想理解解法得先抛开浏览器这个外壳回到数据结构本身。你在浏览器里的每一次跳转都可以看成是在一条历史时间线上移动。假设你现在停留在页面P按后退你希望回到P之前那页按前进你又希望回到P之后那页。这个“之前”和“之后”是严格对称的。换句话说后退能退多少取决于你往前访问过多少页前进能进多少取决于你往后撤退过多远。这种“对称的撤销与重做”结构现实中对应的就是两个栈一个栈存“后退历史”一个栈存“前进历史”。你可以把当前页面想象成一条分界线后退栈里存的是分界线左边的页面前进栈里存的是分界线右边的页面。每次访问新页面原来的当前页就沉入后退栈而前进栈直接清空——因为一旦你跳到了一个新地方之前“前方”的页面就已经不存在了这是浏览器历史记录的真实规则。这个理解一旦建立代码就只是套路问题了。但很多初学者卡住不是不会用栈而是没想明白“当前页到底放哪个栈”以及“新访问要不要压栈”这两个细节。1.2 为什么不是单栈也不是数组有的同学第一反应是“我用一个数组存历史维护一个指针不就行了”。确实可以而且这个思路并不错数组加下标本质上也能模拟两个方向。但问题在于数组方案在“访问新页面时清空未来的历史”这一步下标挪动和逻辑分支得自己写容易在指针边界上出错。用双栈的好处是每个栈的操作都是天然封装的push和pop就是前进/后退本身完全不需要额外的下标变量去维护“当前在哪”。而数组的劣势在题目要求动态读入指令时会放大——你根本不知道总共有多少条指令开多大的数组就成了一个需要掂量的事情。那为什么不是单栈因为单栈只能解决“后退”没法解决“后退之后又前进”的场景。你要么用两个栈要么用一个可以双向走的结构比如双向链表而双向链表在“回到过去某页”这种场景下还需要维护每个节点复杂度远高于两个栈直接互相倒腾。双栈是这道题最自然、代码量最少、边界最清晰的做法。2. 双栈模型的完整设计设计双栈模型前先把状态定义清楚。我习惯用三个变量来描述浏览器状态当前页面 current后退栈 backStack前进栈 forwardStack初始状态是current等于浏览器的默认首页两个栈都为空。这个默认首页在本题里通常是“http://www.acm.org/”但也可能因题目而异所以读题时一定注意初始URL。2.1 操作语义每个指令到底动哪个栈把四条指令逐条拆开语义是这样的。VISIT url先把current压入backStack当前页退役成为历史再把current改成url最后清空forwardStack。这最后一步特别重要——新访问意味着你不再是“从某页撤退”而是开启了全新分支旧的前进历史必须作废。BACK如果backStack非空先把current压入forwardStack当前页成了“前进历史”再让current等于backStack栈顶并弹出。如果backStack为空说明当前已经在最最开始那页没有可退的输出Ignored其他状态一律不动。FORWARD和BACK严格对称。如果forwardStack非空先把current压入backStack再让current等于forwardStack栈顶并弹出。如果为空输出Ignored。QUIT直接结束程序不需要输出任何东西。这四条规则里最容易出问题的是“空栈判断”。题目里坑人的点就在于很多选手在BACK或FORWARD失败时直接把栈弹空了或者忘了输出Ignored又或者弹栈了却不更新current。这几个错误我在后面调试实录里会展开讲。2.2 边界条件四个最容易翻车的场景我梳理了一份边界清单刷题时按这个自查基本能把分数拿全。第一初始状态执行BACK和FORWARD都应该输出Ignored因为两个栈都是空的current是默认首页且从未移动过。第二连续执行两次BACK第二次的结果取决于第一次BACK之后forwardStack里有没有东西。比如从A走到B再走到CBACK一次是BBACK第二次是A此时forwardStack里按顺序存着C和BFORWARD一次能回到B。这个“栈内顺序”很容易搞反很多人写出了FORWARD却回到了错误页面就是因为没搞清楚栈顶是谁。第三VISIT之后前进栈必须清空。假设你访问了A、B、C然后BACK回B此时前进栈里有C。如果你这时候执行VISIT DD变成当前页C应该彻底被遗忘。不清空前进栈的话程序后续会把你带回C但真实的浏览器不会这么做。第四URL本身是个字符串不是单个字符。读入时不能只用cin一个char否则首字母之外的字符全丢了。这种低级错误在训练场里其实很常见因为样例URL都比较长一截断就会连环出错。2.3 数据结构选型STL栈为什么够用用C的话直接引入stack头文件就行。stack提供push、pop、top、empty四个核心方法恰好覆盖题目所有需求。别去手写栈结构除非你是在训练“数组模拟栈”——这道题没必要STL栈不仅正确而且push和pop的复杂度都是O(1)。Windows上有些在线评测系统兼容的是老编译器记得包含iostream、string和stack不要用bits/stdc.h这种万能头文件因为不少训练环境和老版本编译器不认它编译失败会白扔不少时间。但如果想用Java写就用ArrayDeque或LinkedList当栈用别用Stack类——后者是同步的性能差点而且它的迭代顺序、遗留API风格在算法训练里并不讨喜。这个选择虽然不影响正确性但能体现一个从业者的工程审美。3. 代码实现与实操细节理论说清楚了就该动手了。我给出一份完整的C实现这段代码我实测能过常规评测数据注释写得很全适合拿去做对比参考。3.1 完整参考代码#include iostream #include string #include stack using namespace std; int main() { string cmd; string current http://www.acm.org/; stackstring backStack; stackstring forwardStack; while (cin cmd) { if (cmd QUIT) { break; } if (cmd VISIT) { string url; cin url; // 当前页成为历史压入后退栈 backStack.push(current); current url; // 新访问产生新分支清空前进栈 while (!forwardStack.empty()) { forwardStack.pop(); } cout current endl; } else if (cmd BACK) { if (backStack.empty()) { cout Ignored endl; continue; } // 当前页归入前进路线 forwardStack.push(current); // 后退栈栈顶成为当前页 current backStack.top(); backStack.pop(); cout current endl; } else if (cmd FORWARD) { if (forwardStack.empty()) { cout Ignored endl; continue; } // 当前页归入后退路线 backStack.push(current); // 前进栈栈顶成为当前页 current forwardStack.top(); forwardStack.pop(); cout current endl; } } return 0; }这段代码的核心逻辑只有三个动作压栈、换当前页、弹栈。你仔细观察会发现BACK和FORWARD的代码主体几乎是对称的只是操作对象从backStack换成forwardStack方向反过来。对称性本身就是检验逻辑是否正确的一个好信号如果写着写着两边代码不对称了那大概率就是哪里漏了或多了。3.2 输入处理的细节输入处理有个隐蔽坑指令和URL之间是用空格分隔的URL本身不含空格。所以用cin cmd读指令再用cin url读地址没有任何问题。但如果题目改成URL里可能出现空格实际浏览器URL不会但训练题偶尔乱来那就得用getline了按行读再解析空格。另外注意一点每行指令执行完都要换行输出QUIT之后不需要输出任何内容。有些题目会让你在QUIT之后还输出一个“exit”之类的东西这题没有不要画蛇添足。3.3 为什么栈清空要用while而不是直接赋空很多人清空forwardStack时第一反应是写forwardStack stackstring();。这在某些编译器里能过但会引入一个问题赋值操作会拷贝一个新的临时栈对象虽然能清空但开销稍大且语义不够明确。用while循环逐个pop虽然代码长一点但胜在稳定清晰也方便理解栈的真实清空过程。如果你非常在意性能也可以写一个clearStack函数或者用stackstring().swap(forwardStack)这个技巧。但说实话这道题的数据量根本不会让清空操作成为性能瓶颈怎么顺手怎么来。4. 测试与调试实录代码写完不等于拿分。程序设计训练题核心指标之一就是“在各种边界条件下输出正确”。我用标准样例和几个自造用例走一遍把容易踩的坑全部摊开。4.1 官方样例推演假设初始页是http://www.acm.org/依次输入VISIT http://acm.ashland.edu/ VISIT http://acm.baylor.edu/acmicpc/ BACK BACK BACK FORWARD VISIT http://www.ibm.com/ BACK BACK FORWARD FORWARD FORWARD QUIT推演过程如下初始current http://www.acm.org/VISIT ashlandback压入默认页current变为ashland输出它VISIT baylorback压入ashlandcurrent变为baylor输出它BACKforward压入baylorcurrent回到ashland输出ashlandBACKforward压入ashlandcurrent回到默认页输出默认页BACKbackStack为空输出IgnoredFORWARDback压入默认页current回到ashland输出ashlandVISIT ibmback压入ashlandforward清空current变为ibm输出ibmBACKforward压入ibmcurrent回到ashland输出ashlandBACKforward压入ashlandcurrent回到默认页输出默认页FORWARDback压入默认页current回到ashland输出ashlandFORWARDback压入ashlandcurrent回到ibm输出ibmFORWARDforward栈已空输出Ignored最后输出结果http://acm.ashland.edu/ http://acm.baylor.edu/acmicpc/ http://acm.ashland.edu/ http://www.acm.org/ Ignored http://acm.baylor.edu/acmicpc/ http://www.ibm.com/ http://acm.baylor.edu/acmicpc/ http://acm.ashland.edu/ http://www.acm.org/ Ignored这里有一个容易看走眼的点第一次FORWARD后输出的是http://acm.baylor.edu/acmicpc/而不是ashland。因为FORWARD会把current压进back栈再弹forward栈栈顶。而forward栈栈顶正是baylor。所以FORWARD一次直接越过ashland回到了baylor看起来有点“跳步”实际完全符合真实浏览器的行为——后退到默认页时前进按钮应该能直接回到你后退前停留的位置。4.2 我踩过的三个坑第一个坑BACK失败时没有continue。在BACK分支里如果backStack为空就输出Ignored注意这里必须有continue否则程序会继续往下执行压栈逻辑。我当时写着写着发现输出正确但栈状态错乱了排查半天才意识到是漏了continue。一旦漏了continue空栈时不仅输出Ignored还会把当前页压进forwardStack、尝试访问backStack.top()——这个top()在空栈上调用是未定义行为不会立刻报错但会在后续操作里产生诡异结果。第二个坑VISIT后忘了清空forwardStack。这个坑更隐蔽——简单样例里可能测不出来。比如访问A、B、CBACK回B这时候VISIT D如果不清空forwardStack里面存着C后续FORWARD会让你回到C。而真实浏览器和题目期望的行为都是新访问D后C就该消失在历史里FORWARD应该输出Ignored。这个用例必须自己构造训练题样例通常不会专门设计这种分支全是等你考场踩雷。第三个坑把当前页和栈混在一起存。有的写法是“每次访问都把页面压进一个总栈用指针标记当前项”。这么做也不是不行但遇到BACK再VISIT再FORWARD这种组合操作时“指针”和“栈顶”谁先动、谁后动非常容易乱。双栈之所以干净是因为每个状态转移都只涉及一次压栈、一次弹栈、一次更新current没有第三条路。4.3 常见问题速查表症状可能原因修复方式连续BACK后FORWARD回错页面前进栈压入/弹出顺序反了BACK时当前页压forwardFORWARD时当前页压back再弹forwardVISIT后FORWARD还能回到旧页面忘记清空forwardStack在VISIT分支中主动pop到空空栈时程序崩溃空栈调用top()在pop和top之前先判断empty输出的URL缺字符把URL当char读改用string读入所有输出后多了一行空白QUIT后继续处理了输入break后直接结束循环5. 从训练题到真实浏览器这题的含金量在哪做完这道题如果不做一点延伸思考其实挺亏的。因为它表面是训练栈实际是在为一个更大的问题打底你怎么用抽象的数据结构去模拟一个“有状态交互系统”。5.1 真实浏览器和历史记录的距离真实浏览器的标签页机制比这题复杂得多。每个标签页有自己的历史列表前进后退不能跨标签页同时浏览器还提供“关闭标签页后恢复”这种全局历史。但不管怎么复杂核心历史导航依然可以用双栈模型去理解——只是每个标签页一个双栈再加一个会话级的历史索引。另外真实浏览器还有一些题目里没有体现的规则。比如你在访问一个新页面时不是所有浏览器都会清空前进历史有些浏览器会保留未来历史比如通过“在新标签页打开链接”生成的访问需要用更细粒度的历史树去管理。Chrome和Firefox的“历史记录”页面展示的就是这种树状结构而不是简单的双栈。这题其实只做了一维时间线上的模拟二维历史树就交给后续的数据结构去解决。5.2 如果题目升级你要怎么改顺着这个思路我建议你做几个变体练习巩固这一套状态机的理解。第一个变体给每条指令加一个数字参数表示后退或前进的步数。要求能连续移动多步。这个变体考验的是“循环内重复单步操作”你可以在BACK和FORWARD分支里包一层while循环每次移动都要判断栈是否为空为空就提前结束并输出Ignored还是如何定义这个规则题目会写清楚。第二个变体支持“当前页被重新访问时如果它已经在历史中是否刷新前进栈”。这题是直接清空前进栈但有些浏览器只清除当前页之后的历史当前页之前也就是后退栈不动。这其实考的仍是双栈但多了一层“如何从栈里恢复状态”的思考。第三个变体增加一个“显示历史记录”指令要求按时间顺序打印所有访问过的页面。这用双栈就不够了因为双栈只能看两头不能遍历全部。这时你需要用数组指针来维护完整的浏览轨迹或者用一个双向链表。做一遍这个变体你就能反过来理解为什么原题选双栈而非数组了——栈能给的抽象层次最贴合前进后退的对称语义。继续扩展浏览器还有刷新功能、默认首页、崩溃恢复、多窗口会话每一个都能变成一个独立的模拟题。但底子还是那个朴素的“状态转移”思想每次操作是在改变当前状态数据结构负责保存那些“被跳过去的状态”。6. 个人心得这类题该怎么练出一眼写出状态机的功力做完3-21这道题我最深的体会是模拟题不是靠“照着题目打代码”而是靠“先想清楚状态在怎么流动”。你能把题目的每一条指令变成一次明确的状态转移写代码就是填表根本不会乱。给正在刷这类题的读者几个建议。第一任何模拟题先花五分钟回答三个问题状态是什么、状态之间怎么转移、哪些转移是非法的。3-21的状态就是“当前页两个栈”转移就是那四条指令非法转移就是空栈操作。想清楚这三点再动键盘代码一般都不会废。第二自己造边界样例。标准样例只覆盖正路径不会帮你打补丁。我后来每次做模拟题都会强迫自己造一个“连续非法操作穿插合法操作”的样例专门用来测边界逻辑。比如BACK到最开头马上FORWARD再VISIT再BACK再BACK这种序列才能逼出问题。第三把对称的操作对照着写。BACK和FORWARD是天然对称的写的时候尽量让两个分支结构保持一致这样不但好检查而且不容易漏掉某一半的逻辑。如果写着写着发现两边逻辑差异很大大概率某个方向处理错了。数据结构训练的价值就在这种地方它不教你背API而是教你用最合适的结构去表达真实世界中某个行为背后的规律。浏览器的前进后退、文档编辑器的撤销重做、编译器里的括号匹配、函数调用的调用栈都是一套逻辑在不同场景下的投影。把这套逻辑在3-21这种小题目里练扎实后面遇到类似问题时大脑会自己往正确的结构上靠。