Planning an Approach to a TopCoder Problem
Section 1
【原文见: http://www.topcoder.com/tc?module=Static&d1=tutorials&d2=planApproach1】
作者: By leadhyena_inran
Topcoder Member
翻译: 农夫三拳@seu(drizzlecrj@gmail.com)
深入某个东西是一门很讲究的艺术;它会像难住一个新手一样难住一个资深的程序员,并且它还不易言表。这可能包含许多考虑和回退,也包含着预见,直觉,创造力,甚至运气。当这些因素不能够很好的协调时,任何一个程序员都会感到无助。有的时候就是这种无助的感觉使得很多程序员泄气而放弃尝试Div1中的难题。有的程序员甚至放弃了比赛,因为他们很不喜欢那些问题给他们带来的不快感觉。尽管如此,如果一个人能够持之以恒,解决方法通常并不是超出人们所能理解的范围。这篇教程将试图澄清一些事实,来让你能够使用一个稳定的计划去做这些问题。
Pattern Mining and the Wrong Mindset
人们很容易落入这样一个怪圈,认为算法比赛是一系列不同的可分类的剧情问题的集合。对于那些做过很多剧情问题的人来说,他可能知道这种类型的题目只有很少的一部分(尤其是上课时教授反复强调的部分)。当你读到一个特定形式的问题时,你的思维是,“哦,这个是X问题,然后就对号入座”。可能有很多次这种思维定势是可行的,但是当你做过一定数量的Topcoder SRM之后,大多数程序员会意识到一类的题目并且不断的进行练习,这种做题目的方法在很多比赛中都会成功。
尽管如此,这个方法是有害的。你可能很多次阅读完题目描述之后,假定它是类型Q的题目并且开始进行编码,但最后发现你的代码一个样例都过不了。你会回头重读问题并发现这个问题和你经验中的不一样。这个时候,你对你的练习感到了麻痹,你不能指出适用于这个问题的类型。这种情况你会经常看到,当问题是从原有问题派生出的时候,很多富有经验的程序员都不能完成这个问题,因为他们被他们的经验蒙蔽了。
思维定势导致了这样的思考。只有通过抛掉你以前的想法,并且重新学习必要的关键技巧才能使得你的rating稳步的上升。
Coding Kata
这里是你的第一个练习:在Practice Room中挑选任何一道你没有做过的问题。努力去搞定它,不管花多久(把比赛的解题报告看作最后一招)。让它通过系统测试,然后计算一下你花了多久完成它。接着,把你的解决方案清楚,然后试着再打一遍(剪切,粘贴显然达不到效果)。再次的让它通过系统测试。记下你第二次花了多久。接着,清除它然后再做一次,并且再次让它通过系统测试,然后记录下最终所用的时间。
你第一次通过的所花的时间是你在对题目一无所知的情况下记录的。第二次通过的时间通常是第一次的时间减去你花费在理解题意上的时间。(不要感到惊讶,当你在第二次过程中重复犯一些错误)。最后一次的记录时间是你的潜能,如果你在读完题后能立刻思考出正确的做法,那么你可以在比赛中很快的解决它。让这些数字给你些启发;迅速的解决此类问题还是很有可能的,而不需要很快的打字速度。从第三次中你可以感受到你已经知道了策略,代码将会是什么样子,哪里你会犯错误等等。这就是有着正确解法时候的感觉,这种感觉就是未来比赛中的你的目标。
在武术文化中,有种叫做空手道。武术家按照一系列的手法进行练习,通常用来抵挡对方的攻击(或者说有的时候是防卫),或者预测性的来防护。首先这种类型的练习没有任何意义,因为它看起来和打斗时没有章法的现实不符。更进一步的,它似乎在鼓励前面一张所提出的思维套路。只有在每个题目经过三次编码之后,你才能真正理解kata的好处。kata使得它的参与者能够在大脑中有一个计划,并且鼓励他们仔细思考问题。进攻就是最好的方法,它可以在编码,调试,提交的整个过程中使用。
Approach Tactics
现在你知道方法是什么样了,也知道它们的具体内容了,你会意识到你学会了许多不同类型的方法。你能给出它们的名字吗?“哦,那题我用的是DP(动态规划 dynamic programming)”,“是吗,我本来可以用贪心做的”“不要告诉我暴力就能过了”。实际上,你给每个问题取的名字都不太恰当,因为你不能把每个问题归结为仅仅是贪心或者仅仅是暴力解法。问题的类型有无数多个,而解决方案的类型甚至更多,而且在每个解决方案中有用无穷多种类型的变化。这里的名字仅仅是完成解决方案的一个高度的概括。
在一些较好的比赛报告中,都有一个解决问题的详尽描述。下次当你看比赛的总结时,并且遇到一个比较好的问题时,从中寻找方法的路数和构造方法。你会注意到在路数之间有一定的颗粒性,这就暗示了思考的一个方向。将观察到的策略和方法来规划你的方法,转换,引导并将其融入代码中,使得接近正解或者至少远离错解。当你准备方法的时候,思路就是想尽所有你会的策略方法来决定方法,所有你以前写过的代码都会在你的脑海中。这相当于你说服你自己那些将要写的代码能够工作。
有一定数学背景的程序员会重新审视这个方法,因为许多方法策略和证明的技巧相似。玩象棋的人能够从当前一步中看到好多步。做应用开发的设计者可能在用到设计模式的时候就认识到这个方法了。在许多其它的问题中,都存在一个和它类似的类型。
练习这种类型的思考方式并且在众多方法中选择你钟爱的,记录下你的问题的解决方案并且在SRM之后写下你自己的表现的一点分析是非常有益的。详细的描述你的解决方案每一步是怎样工作的,使得其他人能够理解并且写出方法如果他们看了你的解释之后。写下你的方法不仅可以帮助你理解你在编程时自己的思路,还能发现你在做的时候时一些易错之处。记住,学会你不懂的知识是很困难的。
Breaking Down a Problem
让我们谈谈最常见的一个方法:问题分解。这在有的时候也叫做自顶向下编程:大意是你的代码必须按顺序执行一系列的步数,从简单的决策开始考虑。因此刚开始应当计划你的主函数需要什么,然后在看子函数要做些什么。这不仅可以让你很快的编写出原型(因为你仅仅编写你所需要的代码而没有其他任何东西),还能把问题划分成更小的部分,更多切实可行的部分。
用到这个方法的一个很好的例子是 2002 TopCoder Invitational第二轮中的MatArith。这个问题需要你计算一个包含矩阵的表达式。你知道为了得到数字,你需要对它们进行解析(因为它们是字符串数组)并且将这些值传入计算器中,然后将他们转变为一个字符串数组,就完成了。因此你需要一个打印的函数,一个解析的函数和一个计算函数。不需要更加多的考虑,加入你已经写好了这三个函数,那么问题可以在一行之内被解决:
public String[] calculate(String[] A, String[] B, String[] C, String eval)
{
return print(calc(parse(A),parse(B),parse(C),eval));
}
这个简单方法的好处在于它将你的思维引导至函数的层次中。你现在可以把工作分为三步了:构建一个解析函数,一个打印函数和一个计算函数,将代码段切割成小段。如果你切割的足够好的话,你根部不用考虑最简单的步数,因为它们将会是原子的(这个下面将会谈到更多)
。事实上这个问题将会被很快的分割直到矩阵的乘法和加法函数,需要注意的仅
仅是读清题意并且调用恰当的函数。这种策略带来了一种编程方法名为函数式编程。网络上有许多关于这个的文章,甚至TopCoder中有一篇radeye写的文章,很深入的讲解了这个概念,但是概念的意思是,如果能够合理的分割,那么代码将在函数之间传递所有的参数信息,并且步数之间不需要存储任何数据,这就避免了很难调试的副作用的可能性(代码中不期望在步数间改变变量)。
这个策略在递归问题上也表现的相当好。递归代码的背后的整个思想就是将问题分成更小的和原问题相似的子问题,由于你正在写的函数是原问题,因此你几乎完成了工作。
Plan to Debug
无论何时你使用一个方法的时候,你都应该有一个调试代码的计划。这个是每一个方法薄弱的一个部分。总有一个使得解决方案失败的方法,通过事先考虑它会不能工作的多种情况,你可以在编码之前避免这些bug。更进一步的,如果你不能过样例,你知道应该在哪里找出问题。最后,通过查找代码的压力值,向自己证明所用方法是个好方法很会容易。
在自顶向下的方法中,分解问题能够让你分离那些可能出现问题的部分,并且它能够让你组合测试那些看起来用的最多的子函数片段。并且还有一个好处,当你修复了一个bug,你把代码分解成了函数。这是因为bug在每个使用的地方都要修复。另外一个方法是程序员将代码段粘贴/复制到任何一个需要的地方,这将使得很难修复并且导致更多的bug。同样,当你在从顶向下的方法中找bug时,你应该首先查找函数内部的错误,而后再查找函数调用间的错误。这些部分组成了一个调试的策略:先看哪里,怎样测试你的思路是错误的,怎样验证代码片段并且继续。只有在足够的练习之下,你才能找到一个好的调试策略。
Atomic Code
当你碰到一段你不能再分解的代码片段时,这就是原子代码。希望你知道怎样去编写这些部分,并且这些部分组成了最基本的原子代码的形式。但是,当你碰到一个问题不知如何下手的时候,不要泄气;这些难以解决的核心正是使得问题有趣的地方所在,有的时候是否能够提前看到将导致能否尽早的找到正确的方法并且放弃错误的方法而浪费大量时间的巨大差异。
你写的最常见的原子代码的类型就是原语了。对于了解语言的库我一直很支持。这就是知识的用处。为你节省时间的最好的方法是在你的方法和你知道代码可能复杂的部分的情况下使用原子代码或者自带的库还是类呢?
你写到第二种类型的原子代码我称它为语言技巧。这些通常是该语言中用来之下特定操作的,如找出数组中最小元素的索引,或者将一个用空格分隔的字符串分割成序列。这些技巧和准备一个方法一样至关重要,因为如果你知道了怎样做这些基础的操作的话,将会使得自顶向下的方法中更多的任务原子化,从而将正确的解法变得简短。除此之外,这使得这些代码中的原子操作出错的可能性减少。更进一步的,如果你要执行一个你所知道的语言技巧所能做的任务时,你可以很轻松的改变代码来进行适应(例如:根据启发式在一个数组中找到最大元素的索引将会变得很简单如果你知道怎样对类似的任务进行编码)。找出这些公共的语言技巧将成为你日常练习的元素之一,任何原子代码都应该随着你的思考从你的指尖流淌出来。
另外一个方面,我必须强调使用代码库。我知道这是一个竞赛话题,许多成功的程序员在编码前使用一个库作为预先插入的代码段。这是合法的(尽管2004 TopCoder Open后的规则可能会改变以后的合法性),并且使用库有着很明显的优点,主要是声明了一些你的自顶向下方法的原语,并且有助于快速构建自底向上的方法(下面将会谈到)。这是我的观点。尽管如此,使用代码库的缺点要超过它的优点。库函数之间的执行有的时候会减慢你的编码,因为你需要使得你的输入满足你所要使用的代码的原型。库代码大多是不易改变的,因此,如果你的库要求做的事情并没有很明白的定义的话,你会发现你在代码技巧和本应该实现的算法之间进行摸索。并且也有可能你的库代码并不是没有bug的,并且在比赛中图调试你的库是非常可怕的,因为你需要把你已经交的代码进行修改,还要在你打开新的问题前去修改模板。此外,库在在线比赛中是不允许使用的。最后,使用库代码(或者宏)将使得你习惯于理解你的库而不是语言的本身,这将会使得你不能够在challenge阶段彻底的理解其他程序员的代码。如果用的得当,你的库很强大,但不是对任何东西都适合的终极武器。
有可能会出现你无法将一个原子代码再进行分段。这时你应当对代码进行分析,并思考当前的方法。我是否应该将任务分割成另外一种形式呢?我应该将中间值进行另外一种形式的存储吗?或者也许这正是问题很难解决的关键点?在你找到关键点之前任何事情都要考虑到。甚至当你意识到你无法解决这些点时,用手算来看怎样可以更快的处理,这些方法构成了剩下的方法策略。