上周四去了一家单位面试,据同学说,这家单位技术很牛,班子都某大学出来的,他还要我提前预习一下数据结构和算法,否则肯定被鄙视。结果过去后,做完笔试题,就发现自己还真真的要被鄙视了。题并不难在算法和数据结构,而是五花八门各个领域的题都有,还带很多关键字。做完后发现实在是有点惨不忍睹。同去的一海龟博士同学也有同感,休息的时候还和我感叹了一番。
不过本文想说的是其中一道编程题。我虽然略有眉目,却没能即时做出来。我当时告诉面试官说我并非在ACM中摸爬滚打出来的人,这种题我需要一天的时间才能找到正确的算法,半小时之内我没法给出答案。虽然这次面试似乎希望不大,但我还是把说过的一天解出这道题的话记在了心里。这两天也在闲时琢磨了一番,也基本上确定了一种正确的解法,记录于此。
题:给定两个整数a和b,这两个数的取值均在【-2000,2000】区间中。把对a进行一系列操作得到b的过程称为a到b的路径,可选操作包括:加12、减12、加7、减7、加5、减5。例如-5至19的的一条路径为(-5,7,19)。现要求编写程序,对任何a和b,求a至b的最短路径。
首先,这道题要做一些变化,显然,题目的关键在于得到一个合理的操作序列,使所需的操作次数最少;另外,这个操作序列取决于a与b的值差,而与a和b的数值无关。因此,我们可以把题变化为:对于给定整数x,求从0得到x的一个最短操作序列。前面的示例等价于x为24(19-(-5)),路径为(12,12)。
对这道题,我的第一思路是:先手工得出x为0至11时的最短路径,并将其保存到代码中,然后对任意x,求其除以12的商数Quo与余数Rem,然后将Rem的最短路径与Quo个“加(减)12“操作拼接起来,这样即可得到与x对应的一条路径。
这是一种非常简单的实现方式,而且我猜测在半个小时内,能够实现这个方案就应该可以得到面试官的认可了。不过我没有兴趣写这么一东西,因为这个方法只能得到部分正确的结果,而对于算法而言,部分正确就意味着失败。
随便就可以找到几个结果不正确的例子:
1. x为13时,此算法求出的路径为(12,5,5,5,-7,-7),但正确的结果为(5,5,5,5,-7)。
2. x为35时,此算法求出的路径为(12,12,5,5,5,5,5,-7,-7),但正确的结果为(7,7,7,7,7)。
如果当时去分析一下这些路径之间的关系的话,我很快就能够找到我后来才得到的正确解法。但很可惜,当我发现这些问题后,我很快就把注意力集中到寻求其它解法上去了。
接下来我的第一个猜想是:有这样一个阀值(12的整数倍),当x小于等于这个阀值时,需要手工得到最短路径,当x大于阀值时,可以用加减12操作与(x%阀值)对应的最短路径拼接而成。例如,阀值可能是24,则需要先手工得到x为0~23时的最短路径,然后对于任意值x,最短路径则为(x/24)*2个12与x%24的最短路径的并集。这个想法实际上是最初的想法的泛化,最初的思路相当于此猜想的阀值为12的版本。根据一些数学上的经验,如果确实如此,那这个阀值很可能是24(12+5+7)或420(12*5*7),不过阀值为24的可能性很快就被我推翻了,因为它同样无法处理35这个数,而阀值为420则显然不大靠谱。在这个猜想上纠结了一阵子之后,我决定放弃。
第三个想法则是使用一种蛮力算法来求解:将x分解为两个数x1与x2,使x1+x2 = x,则x的一种路径是x1的最短路径与x2的最短路径的并集。考虑x1与x2的不同取值,计算其最短路径的并集,选择最短的一个路径作为x的最短路径:
f(x)=min( merge(f(x-1),f(1)), merge(f(x-2),f(2)), ...) 这其中还需要考虑的是路径合并时的消去问题,例如,假如x1的最短路径中包括一个减7操作,则x2的最短路径中包括一个加7操作,则合并路径中显然可以将这两个操作消去。另外,使用动态规划中的备忘录方法,可以避免重复求解子问题。这样的话,相当于从1至x的所有数,需要顺序求取其最短路径。更严重的问题则是,由于对于任意数,其解(最短路径)都是由多个数构成的一个列表,随着x的增大,整个求解过程需要的存储空间会迅速膨胀。
当我考虑完这些之后,半小时时间基本上已经用完了。于是只得作罢。
昨天晚上,又回想了一下之前考虑过的方法,发现很难找到什么突破,于是开始寻找新的思路。最后,我注意到第三个方案中的路径合并中的对于消去的一些考虑,我开始想象是否有一种路径优化的算法,使得对于一条任意路径,可以通过一些变换,使其变为一条最短路径。
这确实是一个全新的思路,我很兴奋,没工夫从数学角度去证明其可行性,但还是值得尝试一下。我花了一些时间,统计出了一些优化策略:
1. (v,-v) =>()。当路径中同时存在加某个数和减个数的操作时,显然这两个操作可以抵消。
2. (12,-5) =>(7)。同理,还有(-12,5) =>(-7)
3. (12,-7) =>(5)。同理,还有(-12,7) =>(-5)
4. (5,7) =>(12)。同理,还有(-5,-7) =>(-12)
5. => 。本策略实际上与策略4是相对应的,策略4用于优化路径中符号相同的5,7操作,而这条策略则用于优化路径中符号相反的5,7操作。这条优化规则基于等式:5+5+5+5+5+5+5=7+7+7+7+7,也就是说,等式左边表示的路径与等式右边表示的路径是等效的,将等式中的任意个数的5或7移到等号另一侧即可得到多种等效路径。例如数4的路径(5,5,5,5,5,-7,-7,-7)与(7,7,-5,-5)等价,而后者为最短路径。当一条路径中符号相反的5,7操作的数量和(a+b)大于6时,使用此策略可以得到一条更短的等效路径。
现在尝试一下这些策略:
1. 数1的最短路径为(5,5,5,-7,-7),因此数2的一条路径为(5,5,5,-7,-7,5,5,5,-7,-7)。应用策略5,可得数2的最短路径(7,-5)。
2. 数13的一条路径为(12,5,5,5,-7,-7),应用策略3,可得13的最短路径(5,5,5,5,-7)。
3. 数35的一条路径为(12,12,5,5,5,5,5,-7,-7),运用策略3,可得路径(5,5,5,5,5,5,5),再运用策略5,可得最短路径(7,7,7,7,7)。
OK,迄今为止所能想到的诡异数字都能处理了。虽然我还是对从数学角度来证明一条不可再优化的路径就是最短路径这个问题感到无从下嘴(数学的神奇,就是我从未打算过以算法谋生的原因啊!),但我基本上可以断定答案是肯定的。
还有一个小问题,如何得到一条与x对应的初始路径?显然,最初的那个思路就能够帮助我们得到一条靠谱的路径,虽然它无法得到最短的路径,但起码是正确的路径。
到这里,基本上,我们就可以写出程序了,不过如果我们对最初的路径做一些分析的话,就可以发现,其实程序可以更简化一点:由于我们会手工得到0~11的数对应的最短路径,因此,对于任意数x,它的初始路径应该是一组加减12操作与一组最短路径的并集。由于最短路径是不可优化的,因此,向最短路径中加入12操作形成的路径只可能使用策略2和3。这意味着策略1和4是不需要用到的。另外,策略2和3不会使路径中5、7操作的数量和增大,因此,策略5也不会有使用到的机会。
分析到这里,可以看到,解法变得出奇的简单:只需按最初的思路得到一个路径,然后将加(减)12操作与符号相反的加(减)5或加(减)7操作进行合并就可以得到最短路径了。
我相信肯定有不少人能够很快就得到这个解法,实际上我对算法也颇有兴趣,也琢磨过不少,不过似乎总是很难在第一时间找到“正确”的解法,或许我唯一可以欣慰的就是我很久以前就有这个自知之明了吧。呵呵!
代码在此:
#include <iostream> using namespace std; #define RECTIFY(x) ((iEnd > iBegin) ? x : (0-(x))) void CalAndPrintPath(int iBegin,int iEnd) { static int IniPath[12][7] = { { 0, 0, 0, 0, 0, 0, 0}, {-7,-7, 5, 5, 5, 0, 0}, {-5, 7, 0, 0, 0, 0, 0}, {-7, 5, 5, 0, 0, 0, 0}, {-5,-5, 7, 7, 0, 0, 0}, { 5, 0, 0, 0, 0, 0, 0}, {-7,-7, 5, 5, 5, 5, 0}, { 7, 0, 0, 0, 0, 0, 0}, {-7, 5, 5, 5, 0, 0, 0}, {-5, 7, 7, 0, 0, 0, 0}, { 5, 5, 0, 0, 0, 0, 0}, {-5,-5, 7, 7, 7, 0, 0} }; int temp = iEnd - iBegin; if(temp < 0) temp = 0 - temp; int iDiv = temp / 12; int iRem = temp % 12; //cout<<iBegin<<'\t'<<endl; int *iArray = &IniPath[iRem][0]; while(iDiv > 0 && *iArray < 0) { iBegin += RECTIFY(12 + *iArray); cout<<iBegin<<'\t'; --iDiv; ++iArray; } while(*iArray != 0) { iBegin += RECTIFY(*iArray); cout<<iBegin<<'\t'; ++iArray; } while(iDiv > 0) { iBegin += RECTIFY(12); cout<<iBegin<<'\t'; --iDiv; } cout<<endl; } int mainNumPath() { int iBegin,iEnd; do { cout<<"Input : "<<endl; cin>>iBegin>>iEnd; if(iBegin == iEnd) break; cout<<"Output: "<<endl; CalAndPrintPath(iBegin,iEnd); } while (true); return 1; }