要解决棘手的算法问题,世上没什么不二法门,不过下面介绍的几种方法可能管用。常言道熟能生巧,题目练习得越多,就越容易确定该采用哪种方法来解决问题。、
另外,下面这五种方法可以“混搭”使用。也就是说,施以“简化推广法”后,还可以接着尝试“模式匹配法”。
方法一:举例法
我们先从你可能熟悉的“举例法”开始,也许你从未听过这种方法。“举例法”是先列举一下具体的例子,看看能否发现其中的一般规则。
示例:给定一个具体时间,计算时针和分针之间的角度。
下面以3点27分为例。确定3点的时针位置和27分的分针位置,我们可以画出一个时钟。
在下面的解法中,h表示小时,m表示分钟。同时,我们假定h的范围是0~23.
从这些例子可以得出以下规则:
- 分针的角度(从12点整开始算起):360 × m / 60;
- 时针的角度(从12点整开始算起):360 ×(h%12) /12+360×(m / 60)×(1/12);
- 时针和分针之间的角度:(时针的角度-分针的角度)%360。
简化上述式子可以得到(30h-5.5m)%360。
方法二:模式匹配法
模式匹配法是指将现有问题与相似问题作类比,看看能否通过修改相关问题的解法来解决新问题。
示例:一个有序数组的元素经过循环移动,元素的顺序可能变为“3 4 5 6 7 1 2”。怎样才能找出数组中最小的那个元素?假设数值中的元素各不相同。
这个问题和下面两个问题有点类似:
- 在一个无序数组中找出最小的元素;
- 在一个有序数组中找出某个特定的元素(比如,通过二分查找法)。
处理方法
在无序数组中查找最小元素的算法没多大意思(只要遍历所有元素即可),同时它也没有利用给定信息(即这是一个有序数组),因此这个问题帮不上什么忙。
然而,二分查找法就非常适合。我们知道,这是个有序数组,只是一部分元素循环移动过。因此元素排序肯定是从小到大,在某一位置突然变小,接着又开始从小到大排列。那个“转折点”正是最小的元素。
比较中间元素 与末尾元素(6和2),由于MD > RIGHT,可以确定这个转折点就在这两个元素之间。这不符合从小到大的排列顺序,故而表明转折点就在其中。
如果MID比RIGHT小,说明转折点要么在前半部分,要么根本不存在(此数组严格按照从小到大排序)。不管怎样,将数组逐步二分进行查找,最终找到最小的元素(或是转折点)。
方法三:简化推广法
采用简化推广法,我们会分多步走。首先,我们会修改某个约束条件,比如数据类型或数据量,从而简化这个问题。接着,我们转而处理这个问题的简化版本。最后,一旦找到解决简化版问题的算法,我们就可以基于这个问题进行推广,并试着调整简化版的解决方案,让它适用于这个问题的复杂版本。
示例:从一本杂志里剪下一些单词可以拼凑成一封勒索信。怎样才能断定勒索信(以字符串表示)是否由某本杂志(即另一个字符串)里的单词组成?
我们可以先这样简化问题:暂时不考虑单词,只当它是字符。也就是说,假设我们从杂志里剪下一些字符拼成了这封勒索信。
接着,我们只需新建一个数组并数出字符的数量,即可解决这个简化后的勒索信问题。数组中的每个元素对应一个字母。首先,我们数出每个字符在勒索信中出现的次数,然后再遍历正本杂志,确认它是否包含勒索信上的全部字符。
推广这个算法是,具体做法和上面的差不多。只不过这一回,我们不再创建包含字符计数的数组,而是创建一个散列表,将单词映射到其词频上。
方法四:简单构造法
对于某些类型的问题,简单构造法非常奏效。使用简单构造法,我们会先从最基本的情况(比如n=1)来解决问题,一般只需记下正确的结果。得到n=1的结果后,接着设法解决n=2的情况。接下来,有了n=1和n=2的结果,我们就可以试着解决n=3的情况了。
最后,你会发现这其实就是一种递归算法——知道N-1的正确结果,就能计算出N时的结果。有时,只有等到算出N为3或4时的结果,我们才能从中找到规律,基于前面的结果解决整个问题。
示例:设计一种算法,打印某个字符串所有可能的排列组合。为简单起见,假设字符串中没有重复字符。
以字符串abcdefg为例:
只有“a”的情况,结果为:{“a”}
然后是“ab”,结果为:{“ab”,“ba”}
再然后是“abc”,结果会是什么呢?
此时,问题开始变得“有点意思”了。得到P(“ab”)的答案,怎么才能生成P(“abc”)呢?很简单,新字符是“c”,我们只需在前一种情况的答案也即字符组合的任意位置加一个c就可以了。也就是:
P(“abc”)=将“c”字符插入P(“ab”)得到的所有字符串的任意位置。
亦即:P(“abc”)=merge({“cab”,“acb”,“abc”},{“cba”,“bca”,“bac”})。
最后得出结果:P(“abc”)= {“cab”,“acb”,“abc”},{“cba”,“bca”,“bac”}。
既然掌握了其中的套路,我们就可以设计一个递归算法。要生成字符串S1...Sn的所有排列,我们可以先“砍掉”最后一个字符,首先生成S1...Sn-1的所有排列。得到S1...Sn-1所有排列的结果列表之后,我们会循环遍历这个列表,并在每个字符串任意位置插入Sn。
简单构造法最后往往会演变成递归法。
方法五:数据结构头脑风暴法
这种方法看起来有点笨,不管很管用。我们可以快速过一遍数据结构的列表,然后逐一尝试各种数据结构。这种方法很实用,因为一旦找到合适的数据结构(比如说树),很多问题也就迎刃而解了。
示例:随机生成一些数字,并保存到一个(可扩展的)数组中。如何跟踪数组的中位数?
数据结构头脑风暴法的过程大致如下。
链表?恐怕不行——在数字的存取和排序上,链表往往效果不佳。
数组?也许可以,不过你已经用了一个数组。你有办法让数组保持有序状态吗?这么做开销恐怕比较大。
二叉树?倒也有可能,因为二叉树非常适合处理排序问题。实际上,如果这棵二叉树是完全平衡的,根结点可能就是中位数。不过,你要小心——如果它包含偶数个元素,那么中位数实际上是中间两个元素的平均值。而中间两个元素不可能都是根结点。因此,二叉树也许可行,我们待会儿再说。
堆?堆非常适合基本排序,跟踪最大值和最小值。对其实也很有意思——只用两个堆,就能跟踪较大 那一半元素和较小的那一半元素。较大的一半保存在小顶堆中,其中最小元素位于堆顶。较小的一半则保存在大顶堆中,其中最大元素位于堆顶。现在,有了这些数据结构,整个数组的中位数很可能就是两个堆顶之一。如果这两个堆大小不一样,你可以从元素较多的堆中弹出一个元素并压入另一个堆中,两个堆很快就能“重获平衡”。
切记,问题演练得越多,你就越容易判断该选用哪种数据结构。当然了,你也能更自如地从这五种方法中选出最管用的那种。