代码改变世界

Planning an Approach to a TopCoder Problem Section II[翻译]

2007-05-17 19:02  老博客哈  阅读(1308)  评论(1编辑  收藏  举报

                                     Planning an Approach to a TopCoder Problem
                                                          Section 2

             【原文见: http://www.topcoder.com/tc?module=Static&d1=tutorials&d2=planApproach2
                                                             作者:      By leadhyena_inran
                                                                         Topcoder Member
                                                             翻译:      农夫三拳@seu(drizzlecrj@gmail.com)

Bottom Up Programming
     这个技巧和把一个问题分解的技巧正好相反,它是当你陷入困境时首先应该考虑的。自底向上的编程方法是这样一个过程,将一些初级的函数组合成更多功能的代码直到解决方案和初级函数一样平常。有的时候你知道你需要一些函数来组成解决方案。如果这些函数是原子的或者很容易进行分解时,你就可以首先编写这些函数然后直接编写解决方案而不用将其分解。

    在MatArith的例子中,问题描述中给出的矩阵加法和乘法的模块使得刚开始能够很轻松的编写这两个函数。由上面所得到的,你可以构建一个使用字符串和变量名组成的表达式来编写矩阵乘法运算的较小的函数evalMult,类似的也可以将每一个项看作一小块来进行编写加法evalAdd,这样你就有了一个解决问题的方法了。

    一般来说,在和问题缠在一起之前,将问题描述中的一些具体的模块编好是一个不错的方法。一个这样的例子就是随机函数和任何你要模仿的数据结构或者任何数学对象如矩阵和复数上的操作。在你重新阅读问题描述和完成小的部分之后,你将会知道哪里需要改进。并且有的时候,如果你确实陷入了困境,写下一些你认为需要的代码片段,可以说服你自己将问题分解成那些函数。正如你所看到的,你朝向正解的路线并不是和你的思路一样成线性关系。

    同样的,为了避免出现隐藏bug的情况,时刻记住任何你使用这种方法的代码都应该在你进行自顶向下编程前进行bug查找,因为你趋向于先写好代码,在一定程度上当你完成代码时你会漏掉一些题目信息。这是在你的代码中查错的一个很好的方法;他们通常在代码的较老的部分中存在,尽管老的部分只在分秒之间就决定了。
   
Brute Force
     任何时候当解决方案需要寻找一个最优配置,最大的数量或者是在有限对象中进行选择时,解决问题的最好方法是尝试所有的配置。任何时候当解决方案需要计算一个需要很多步的大量计算时,解决它的最好方法是做完问题中需要的所有计算。任何时候当问题需要你输出一些东西完成的方法数时,解决它的最好方法是尝试所有的方法并做好记录。换句话说,解决任何时间要求宽松的问题的首要方法是最明显的方法,即使它非常的低效。

    这个方法策略叫做暴力方法,之所以这么叫是因为不用思考计算方法的返回值。任何时候当你碰到此类的最优化问题,你应该做的第一件事是在脑中想想最坏情况下的例子,且如果8s足够时间解决每一个例子,那么暴力将会是一个非常快速的解法并且很少出错。为了更好的使用暴力,你应该估计一下编程环境计算所花的时间。估计只是猜测,任何猜测都可能出错。这个时候就是你需要自己做决定的地方了。就是这个特殊的判断使得很多程序员认为不可能用暴力解决,而又调不出更好的方法,类似的情况还有不能很好的估计最坏情况下运行时间。

    一般来说,如果你找不出解决问题的方法,就使用暴力。如果结果你错了,并且有一个测试例子运行了太长的时间,把暴力的解决方案放在一边,并重新编写更优雅的解决方法,使用暴力解决方案来验证你的优雅代码在小数据情况是否正确,用更加直接的方法来验证是一个好的方法(使得代码中的错误更少)。

A Place for Algorithms
    对于许多经典问题都有着很多著名并且高效的算法,就像处理一些数学问题中的基本方法,就像在象棋中的棋子常见移动一样。然而一般来说从你脑海中的标准算法中学习是一个不明智的做法(它将会导致思维定势,使得你只能处理一些原始的题目),而从最常见的算法中学习,尤其是当你能够将它们运用在你的代码中或者让它们来分解你的问题,是一个很好的主意。

    这里不是一个讨论算法的地方(你可以阅读很多著名的算法书或者看看这个系列里的其他教程),这里我们将讨论在决定一个方法时如何去使用算法。仅仅知道算法怎么使用是不够的。例如,你可能碰到CityLink (SRM 170 Div I Med)这样的问题,它使用一个图论中的基础方法的变种可以很好的解决,这里仅仅编写常规算法是不够的。真正理解了算法如何工作才能够在此基础上作出恰当的变化。
   
    因此,当你在学习算法的时候,你需要理解代码是如何工作的,它需要多长时间运行,代码的哪些部分可以改变并且这些改变将会对算法产生哪些影响。同样在你使用方法之前知道如何编写代码也是同等的重要,因为没有实现算法的经历,将很难区分bug来源于错误的实现还是实现中的错误输入。创造性的用算法去解决不同的问题,看看哪些能工作而哪些不能同样是一个很好的习惯。将代码予以实验将不会在比赛中失败。这就是为什么宽泛的算法(像分治,动态规划,贪心)适合于先学,而后面更加注重于具体的算法,因为一旦你理解了概念中的模块,可以很轻松的完成它。

Manipulating the Domain
    情况变得越来越熟悉:你会发现自己一直在一个问题的计划阶段蹒跚而行,因为这个问题的域所包含的工作量。这可能是由于当前域的不精确性,有的时候把问题的域转换一下会使得问题更加容易。这个的一个经典例子是Fifteen游戏(在SRM 172中使用)。在Fifteen中,你有1到9这个几个数字,每次你可以从中取出一个,如果你取出的3个数字的和为15,且在你的对手之前讲和变为15,你就赢了。对于这个问题,有可以把问题域看作一个3*3的魔方阵(每行,每列,每个斜线的和相等,这个例子中是15)。你立刻意识到这个问题是Tic-Tac-Toe的变种,这使得问题很容易的解决并为之编写解决方案,因为你将一个没有与之相关预先知识的问题转变为了一个你知道的。一些数学家将这个看成ABA^(-1)方法,这个代数式暗示了这个过程:首先你转换域,然后你将转换进行倒置(A^(-1))。这个方法在解决像对角矩阵和Rubik's Cube这样的复杂问题时非常普遍。

    更平常一点,这个方法侧率通常用来简化基本计算。这种类型的一个很好的例子是SRM206中的HexagonIntersections 。在这个问题中需要找出碰到一个指定线的平铺六边形的数量。这个问题会变得相当容易如果你通过将包含的数字转换过来使得包含的六边形平行与x和y轴,即把栅格“倾斜”过来,这样同样得到相同的答案,然后简化了计算。

    如果你改变了域之后,在调试的时候必须相当小心。记住正确的过程应当是首先处理域,然后再解决问题,接着将域更正过来。当你测试代码的时候,记住域要么在结果返回之前通过转换将其逆置,或者逆置并不影响答案。同样的,当查看域中的值时,记住这些值都是转变后的值而不是真实的值。在你变换的代码附近加一些注释行来提醒你是一个好习惯。

Unwinding the Definitions
    这个方法策略是数学中的很老的一个技巧,与之相关的是定义之上层叠的定义,它能够阐明一个问题并且得到问题内部想要做的事情。最好的方法是用代码去完成。当你阅读你从来没有遇到过的问题时,试着想想你将会怎样编码。如果代码要你找到整数集合中最简单的grozmojt,首先找出解决方案中什么是grozmojt并指出怎么找到它,不管你是否需要在解决方案中验证grozmojt。这和上面谈到的自底向上的编程非常类似,但是它是定义阶段而不是实现阶段。

    模拟题同样处于类似的策略下。最好的方法去处理模拟问题是创建一个模拟对象,它由主函数的行为控制。如果你向指定函数传递了足够的参数,那你不用担心,因为模拟过程已经有了足够多的信息。方法变得非常方便并且能够很快的接触原子性的代码。这个同样是正确的方法,如果算法需要模拟计数(例如MergeSort)或在另外一个算法中析构的对象的数目(像 immutableTrees)的话。在这些情况下,代码的优雅通常是牺牲了正确性和思考,同样使得方法能够很好的计划。

The Problem is Doable
    有一个很古老的难题:你有一对同心圆并且你所知道的仅仅是外层圆的弦长(弦长长度为x),并且与内圆相切,你现在需要找出两者之间的面积。你回答到:“如果这个问题可行的话,那么内圆的半径与计算无关,因此我假设它为0。由于内圆的面积是0,或者说退化成了外圆的圆心,外圆的弦通过了圆心并且正好是直径,所以面积是Pi(x/2)^2”。注意上面的正确的集合证明很难完成;存在的解决方案使得问题变得很简单。因为作者需要给一个问题写解决方案,你知道它总是可以解决的,这个事实可以用助于你在SRM中。

    这个方法策略显示了一个概念,作者正在试图找出一个特殊的解决方案,有的时候是通过原问题的描述(尤其是当原问题看起来很难)。看看一些约数条件,像数组大小为20(很多经验丰富的程序员会告诉你作者试图寻找一个暴力的解决方案)。或者整数的大小在1到10000之间(保证安全的乘法,而不溢出)。通过约数条件,你正在像上面的情况一样,不会让你的方法的复杂度更高。

    有的时候问题本身的等级将会暗示解决方案。例如,FanFailure (SRM 195 Div I简单题)。这个问题使用子集语言和最大最小值,因此你开始考虑可以使用暴力,然后你会注意到数组的大小是50。2^50不同的子集用暴力是不行的(最好能够在选择方法的时候发现而不是在代码中,对不?),并且你可以寻找更加优雅的算法...但是当你意识到这个是Div1 的简单题并且不太可能像看起来那样难,因此你想到了贪心算法,觉得它可以工作。如果这题不是Div1中的简单题的话,这个选择并不会那么明显。

    记住这些看不见的线索并不是客观的,它们并不能用来解释为什么一个方法能否工作;它们仅仅是用来暗示作者的思路。更进一步的,如果作者非常狡猾,这些自然的线索会使人陷入迷途。只要你事先使用可靠的分析,这个“循环推理”会给你带来很大的好处。

Case Reduction
    有的时候描述起来最简单的问题往往是最难的。对于这种类型的问题,一般不会需要你去将问题进行分解成步数而是例子。通过将一个问题分成不同的输入你可以创建很容易解决的子问题。考虑问题 TeamPhoto(SRM 167 Div1 Medium)。这个问题很容易表述,但是很难解决。如果你将问题换分成不同类型的例子,你会发现这个问题不能单独的由贪心算法解决,每一个不同的例子都将是这样,因此你可以从这些优化配置中选择最优的样例解决问题。

    使用规约的最常见的例子是边缘例子。一个很好的例子是BirthdayOdds (SRM 174 Div I Easy); 许多人硬编码 if(daysInYear == 1) return 2; 为了避免边界例子的可能问题,即使解决方案中没有这个语句但是正确处理了这些情况,通过增加那层,可以很容易的验证这个方法是正确的。

Plans Within Plans
    正如上面举例的,一个方法并不能简单的陈述,并且如果归约成一个单词,通常会很费解。
更进一步的,一个问题有很多不同的等级,每一个都需要在完整的解决方案完成前解决。一个很好的例子是MagicianTour(SRM 191 Div 1 Hard)。这个问题毫无疑问的有两个步骤:第一个步骤需要使用图搜索来搜索所有连通的组件和他们的二色性,第二步需要使用DP背包算法。在这样的情况下,记住有的时候一个解决方案上可能会使用很多策略通常很有帮助。另外一个例子是TopographicalImage (SRM 209 Div I Hard),在一个特定限制下根据最短路径计算出最小角度。为了解决问题,注意可以使用二分查找找出最小值,但是计划中还有计划,内部的计划是使用Floyd-Warshall's 所有点对最短路径算法来决定角度是否满意。

   同样记住一个方法不是仅仅"哦,我知道怎样分解...让我们开始把!"计划一个方法是要有策略的思考代码中的步数,想想算法怎样使用,值将怎样传递和存储,解决方案在最坏情况下如何,哪里最容易产生bug。思路是如果解决方案能够仔细的计划,在challenge或者系统测试中失败的几乎很小。对每一个方法,有的步数包含其他步数的计划。

Tactical Permutation
    对每一个程序员来说并不总是只有一个方法,通常对一个问题都至少有两种解法。让我们看下Division One Easy中叫做OhamaLow(SRM 205 Div 1 Easy)的问题。一个很流行的方法是完成这个问题就是尝试所有组合,看如果组合合法的话,将组合进行排序,然后与目前最好的组合进行比较。这是一个平常不过的暴力搜索策略。但这个不是完整的方法。记住计划中有计划。你需要找出怎样组成每一个组合(这个可以使用递归或者多重循环),怎样存储组合(int数组,字符串,或者甚至一个新的类),并且怎样比较它们。处理每一步有很多不同的方法,并且处理每一步的很多方法都能够工作。正如上面看到的一样,很多时候一件事情可以有很多种方法,并且这些方法都可以改变。事实上,一个方法是改变高层的暴力搜索策略而不是尝试所有构建的组合并且选择最好的一个,你可以按照组合从好到坏的顺序构造并且在找出可构造的时候停止。换句话说,你可以顺序取出"87654321"的5个字符组成的子串,并且看共享的组合和玩家的组合是否可以组成已选择的组合,如果是则返回组合。这个方法同样需要子步骤(如果指针可以组合的话怎样选择,怎样走过可能的组合,等等)但是有的时候(在这个例子中更好)你可以很快的将其分解。

    在两个方法中进行选择的唯一方法是你能够同时想到两者。一个很好的练习方法是在以前的SRM中使用二个不同的方法来解决很多问题。通过这样做,你加强了这些不同解决方案的思路,增加了找到更加优雅的解决方案的机会,或者能够更快写的方案,或者甚至容易调试的方案。

Backtracking from a Flawed Approach
    正如前一节举例的一样,一个问题的方法很可能种。甚至在你编写代码的中途会思考怎样更加优雅的解决问题。在TopCoder SRM中最难的是你能够坚持你选择的方法知道你能毫无疑问的证明你没有在这个方法中犯错并且解决方案会工作。记住你并不是因为代码优雅而得分,或者是聪明,或者是优化代码。你的分数是建立在快速提交正确解决方案上的。如果你在敲代码的中途想到了一个更优雅的解决方案,你需要花几秒钟想想你在现有的方法中花了多少时间。而在大多数情况下,这并不值得。

    第一次就能够计划成正确的方法并不是件轻松的事。如果你编码好了一个解决方案并且你知道它是正确的但是有一些小bug,这修补起来要比突然发现整个方向都错了要简单的多。如果你陷入了这样的处境,不管你怎么做,不要把你的代码给擦掉!重新标记你的主函数和任何子函数,数据结构哪些可能由下面的改变影响的。这是因为你不得不承认,当你重新开始的时候,有些部分可能一模一样,重复的写相同的代码将会很低效。更进一步的是,通过保留旧的代码,你可以在后面challenge其他使用相同方法的程序员。

Conclusion
   深入研究一个东西并不是一门科学,尽管包含了很多严密的思考。然而,它主要是猜测和成功的计划组合在一起的。通过创造性的,经济的和彻底的思考过程,你可以对你的解决方案更加的充满信心,并且你花在思考上的时间将为你在后面的编码和调试中节省时间。在指尖敲击键盘之前计划代码的能力只要在大量的练习之后才能形成,但是只要勤奋,你将会不断的增加解决问题的能力和持续不断增长的rating。

Mentioned in this writeup:
Mentioned in this writeup:
TCI '02 Round 2 Div I Med - MatArith
SRM 170 Div I Med - CityLink
SRM 172 Div I Med - Fifteen
SRM 206 Div I Hard - HexagonIntersections
SRM 195 Div I Easy - FanFailure
SRM 167 Div I Med - TeamPhoto
SRM 174 Div I Easy - BirthdayOdds
SRM 191 Div I Hard - MagicianTour
SRM 210 Div II Hard - TopographicalImage
SRM 206 Div I Easy - OmahaLow