ThoughtWorks代码挑战——FizzBuzzWhizz游戏 通用高速版(C/C++ & C#)

  最早看到这个题目是从@ 程序媛想事儿(Alexia)最难面试的IT公司之ThoughtWorks代码挑战——FizzBuzzWhizz游戏 开始的,然后这几天陆陆续续有N个小伙伴发表了自己的文章和代码,本来不想做些什么,但是看了这么多代码,总有点想写点什么的欲望。

  我说说我对这个题目的看法,当初看 Alexia 的文章时,也没有看得很仔细,甚至没有看这个题目的原出处,一边在玩英雄联盟,一边看了一下题目,Alexia 并没有贴出相应的代码要求(我是后来看了大家的文章才看到,偶对什么拉勾网不怎么感冒),不过就我的尿性来讲,也不太会往设计模式方向走。就我一贯的思路和作风,还是会往算法和代码优化的方向走(这个题目的确没什么算法深度可言)。所以现在在我看来,这个题目可以有两个方向,一个是走算法以及优化的路线,另一个是设计模式的路线。用设计模式实现必然会损失一些性能。题目里的要求是最好有单元测试,也是很不错的 idea。

  还有一点,看了这么多园子里的代码,没有一个实现可扩展的代码,即 Fizz,Buzz,Whizz 可扩展,特殊数(3,5,7)可扩展(很多人没实现扩展,但实现了改变特殊数的值),最大数(默认为100)可扩展(实现这个难度不大,但基本没人考虑,如果考虑最大数比较大的时候,则还是有一定技巧的,比如一些位操作,只不过看你追求的是什么,在规模比较小的时候,意义不大)。

  我们的思路是:实现高可扩展性尽量简化逻辑避免使用\(除法)、%(取余)(当前各式主流CPU整数除法都是比较慢的指令),避免使用高等函数(浮点),能够使用加法绝不使用乘法用加法代替乘法(其实这个思想跟筛法求素数是一个道理,实现筛法的时候你会用乘法吗?)。唯一思路跟我比较接近的是 @黑耗子FizzBuzzWhizz游戏的高效解法 ,其评论里的代码更是跟我的想法几乎一致。

  后记:后来我发现,编译器在处理被除数是常数的除法上是有优化的,比如/10, /3, /5等,可以转化成一次乘法和一次位移,所有除以固定数或对固定数取余还是可以接受的,但如果是除以一个变量或对一个变量取余,是不可取的。除以10的商可以由 Value / 10 ~= [ (Value * 0x66666667) / 2^32 ] / 2^2 ] 获得,可以参考 http://bbs.csdn.net/topics/320096074http://bbs.emath.ac.cn/thread-521-3-1.html (有原理解释)。

  下面谈谈我的代码的优势,或者说特点:高扩展性,我尽量考虑了扩展的可能性,不过由于内置数据类型的限制,目前最多只能支持64个特殊数,如果还想扩展,也不存在难度。几乎没有使用乘法,全部都是加减法,或者查表法代替,用空间换时间,没有使用高等(数学)函数,只有 left_start_num = special_num * integer_base10[digital]; 这一个地方使用了乘法,当然把数字变成字符串时,fast_itoa_radix_10()函数里还是使用了/10,但是这个是所有方法不可避免的,而且我尝试用减法代替,效率很低。其实现代CPU的整数乘法已经很快了,还是可以值得考虑的。如果你的目标CPU乘法很慢的话,我的方法更有优势。

ThoughtWorks 挑战题之 “FizzBuzzWhizz游戏” 题目如下:

你是一名体育老师,在某次课距离下课还有五分钟时,你决定搞一个游戏。此时有 100 名学生在上课。游戏的规则是:

1. 你首先说出三个不同的特殊数,要求必须是个位数,比如:3、5、7。
2. 让所有学生拍成一队,然后按顺序报数。
3. 学生报数时,如果所报数字是第一个特殊数(3)的倍数,那么不能说该数字,而要说 Fizz;如果所报数字是第二个特殊数(5)的倍数,那么要说 Buzz;
如果所报数字是第三个特殊数(7)的倍数,那么要说 Whizz。 4. 学生报数时,如果所报数字同时是两个特殊数的倍数情况下,也要特殊处理,比如第一个特殊数和第二个特殊数的倍数,那么不能说该数字,而是要说 FizzBuzz,
以此类推。如果同时是三个特殊数的倍数,那么要说 FizzBuzzWhizz。 5. 学生报数时,如果所报数字包含了第一个特殊数,那么也不能说该数字,而是要说相应的单词,比如本例中第一个特殊数是 3,那么要报 13 的同学应该说 Fizz。
如果数字中包含了第一个特殊数,那么忽略 规则3 和 规则4,比如要报 35 的同学只报 Fizz,不报 BuzzWhizz。

现在,我们需要你完成一个程序来模拟这个游戏,它首先接受 3 个特殊数,然后输出 100 名学生应该报数的数或单词。

比如, 输入 3,5,7 。
输出(片段): 1 2 Fizz 4 Buzz Fizz Whizz 8 Fizz Buzz 11 Fizz Fizz Whizz FizzBuzz 16 17 Fizz 19 Buzz ......
一直到 100

 

  好了,下面来谈谈我的思路。

  其实很简单,100个学生报数,从1报到100,根据规则3、4,遇到3个特殊数的倍数的时候需报出该特殊数对应的字符(串),可叠加。那么这些报出来的字符串是这样的(跟黑耗子的很类似,不过后面还是有点区别的):

  1. Fizz
  2. Buzz
  3. FizzBuzz
  4. Whizz
  5. FizzWhizz
  6. BuzzWhizz
  7. FizzBuzzWhizz

  我们在上面的列表最前面插入一个第 0 位 “0. [空]“,并把第 0 位的 [空] 看成 000,

  把 Fizz 看成二进制的第一位(从右边数),把 Buzz 看成是二进制的第二位(同前),把 Whizz 看成是二进制的第三位(同前),则该列表可表示为:

  • 0. 000 [空] 
  • 1. 001
  • 2. 010
  • 3. 011
  • 4. 100
  • 5. 101
  • 6. 110
  • 7. 111

  这不是刚好是三位的二进制吗,如果特殊数有 N 个,则这里列表的长度为 2^N,对于本题默认参数,即为 2 ^ 3 = 8。所以所有的报数除了这 8 种(其中 000 这种是不会被报的)中的7种,再加上不符合规则 3、4、5 的报自己的顺序的数字本身(这个我们认为是同一种规则),归纳起来共 8 种不同的规则,我们对每个要报的数,给它一个这个规则的索引,即完成FizzBuzzWhizz游戏的处理(因0. 000 [空] 这个规则用不到,故我们把它用在表示报数字本身这一规则),当要具体报数的时候,我们再去这 8 种规则里取具体要报的字符串或数字即可。

  我们为什么不直接输出字符串,而输出索引值?这个嘛,是因为我们这样做,可以更好的利用二进制的特性,再者可以实现先预计算,到显示的时候,你要哪个我给你显示哪个(虽然题目本身没有要求我们这么做),因为如果列表所有元素都转换成字符串,还是要花不少时间的。虽然也许理论上是多了一个从索引转换为字符串的步骤,但逻辑更清晰,所以很多人也是这么干的。而且这么做还会增加一定量的内存消耗,如果最大数很大的话,也是要值得考虑的问题。

  构造这个索引字符串列表很简单,把二进制和特殊数列表一一对应即可。具体值,参考以上两个列表。我们需要的是两个参数,特殊数类型的最大种类 max_word_type,即可构造这个 2 ^ max_word_type 的列表。

  然后,我们构造(设置)索引值列表,根据规则 3、4、5,假设 3 个特殊数分别为 a、b、c,得到下列优先级:

  1. 优先级1:Fizz      (数字里含有第一个特殊数a的);
  2. 优先级2:FizzBuzzWhizz  (同时是a,b,c倍数的数);
  3. 优先级3:FizzBuzz      (同时是a,b的倍数的数);
  4. 优先级4:FizzWhizz    (同时是a,c的倍数的数);
  5. 优先级5:BuzzWhizz   (同时是b,c的倍数的数);
  6. 优先级6:Fizz             (是a的倍数的数);
  7. 优先级7:Buzz            (是b的倍数的数);
  8. 优先级8:Whizz          (是c的倍数的数);
  9. 优先级9:报自己的数字

   跟 @黑耗子 的方法相反,由于我使用了二进制合并(也可以用加法来实现),所以我的执行顺序是从下而上的(跟黑耗子文章后面的评论里网友 残蛹 的方法类似,如果你不能理解我说的,可以去看看那个代码),即优先处理优先级 9,最后处理优先级 1(后来我用代码测试了一下,一开始我也认为黑耗子的从上而下的顺序可能更好一点,但后来的实践表明,可能从下而上还稍快一点点,虽然后者重复写入的次数更多,前者每次写入都要检查一遍是否为空,不为空才写入,而后者完全是覆盖式的,不用先查询再写入)。由于我们用二进制合并的机制,所以优先级  2 - 8 可能算得上是同一个步骤处理的,不分先后,而且互相不受优先级影响,具体实现请看下面代码:

    // 所有 sayword_index_list 的默认值均为 NORMAL_NUM_INDEX (0), 即默认是报自己的数字
    for (num = 0; num <= max_number; ++num) {
        sayword_index_list[num] = NORMAL_NUM_INDEX;
    }

    // 规则3, 4: 计算(合并)和设置所有特殊数的 mask 值
    for (index = 0; index < max_word_type; ++index) {
        // 取一个特殊数, 从后往前读
        special_num = special_num_list[index];
        // 如果特殊数不在 [1, 9] 范围内, 则认为是无效特殊数, 跳过
        if (special_num >= 1 && special_num <= 9) {
            // 该特殊数的 mask 值
            mask = 1 << index;
            for (num = special_num; num <= max_number; num += special_num) {
                sayword_index_list[num] |= (index_mask_t)mask;
            }
        }
        else {
            special_num_list[index] = INVALID_SPECIAL_NUM;
        }
    }

  下面是优先级1的处理,这里可能是稍微复杂那么一点的地方,其实也很简单。规则 5:学生报数时,如果所报数字包含了第一个特殊数,那么也不能说该数字,而是要说相应的单词,比如本例中第一个特殊数是 3,那么要报 13 的同学应该说Fizz。 如果数字中包含了第一个特殊数,那么忽略规则 3 和规则 4,比如要报 35 的同学只报 Fizz,不报 BuzzWhizz。也就是说,只要你的数字里面包含第一个特殊数3,那么就只报 Fizz,Fizz 在索引列表里的索引永远都是 001,因为它永远都是第一个特殊数对应的字符串。下面就是怎么把所有含有第一个特殊数3的数都找出来,小伙伴们的代码里都有,而且很简洁,但不是通用的方法,我们来研究一下通用的算法:

  首先,我们先假设要报的最大数 max_number 有 N 位数字,则这 N 位数字里,可能包含个位,十位,百位,千位,万位,…… 等,我们以所有十位数包含 3 的数为例,从十位数 3 的右边来看,分别有 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 共 10 个数字,从十位数 3 的左边来看,分别有 130, 131, 132, 133, , , 230, 231, 232, 233 , ...... , 330, 331, 332, 333,  ...... 等 。我们看到,十位数 3 的右边一共只有 30 到 39 共 10 个数字,它是比十位数低的位数,从 0 开始循环,一直循环到 9,然后跟 3 组合而成;然后,十位数 3 的左边,则是在右边循环(低位循环)所得到的结果 30 - 39 的基础上,在左边加上 “1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,  ...... ”。如此循环,把两者拼接而成,让组合起来的数小于等于 max_number 即可,依此类推到百位,千位,万位 ...... 等等。

我们总结出下面两条规律:

1、对于某个要报的数的第 M 位数字是 D(即 D 为从右边数的第 M 位数字),先搜索某位数上(例如个位,十位,百位等)的右边,去掉该位数以后,从 0 开始循环,一直循环到 (10 ^ M - 1) 为止,再与该第 M 位数字 D 组合起来,记做 “D + 右边循环”(低位循环)。

2、在每一位 “D + 右边循环” 数字的基础上(即前面例子中的 “30, 31, 32, 33, 34, 35, 36, 37, 38, 39”),再从左边循环。左边循环一样简单,从 1 开始循环,把左边循环的数跟 “D + 右边循环” 组合起来,我们得到:“左边循环 + D + 右边循环”,称为 “高位循环”,让这个组合起来的数不超过 max_number 即可,超过的话,则左边循环(高位循环)结束。

 

具体实现,请看下面代码:

    // 规则5: 根据此规则, 设置所有所报数字包含了第一个特殊数 first 的数,
    // 先筛选所有个位数包含 first 的数, 再筛选所有十位数包含 first 的数,
    // 依此类推, 百位, 千位..., 直接达到 max_number
    // FIRST_SPECIAL_NUM_FIXED_INDEX 的值固定为 1, 因为第一个特殊数(仅第一个)的 mask 就是 1

    // 第一个特殊数
    special_num = special_num_list[0];

    // 检查特殊数是否在 [1, 9] 范围内
    if (special_num >= 1 && special_num <= 9) {
        // 筛选所有个,十,百,千,万,十万,百万位数等包含 first_special_num 的数
        for (digital = 0; digital < max_digital; ++digital) {
            right_start_num = special_num * integer_base10[digital];
            right_max_num = MIN(right_start_num + integer_base10[digital] - 1, max_number);
            // 右边的步长恒为 1
            right_num_step = 1;
            // 这里 right_start_num 虽然已经是 first_special_num 的倍数,
            // 但是因为还要进行左边(高位)的循环, 所以不能省略
            // 右边循环(该个,十,百,千,万位的右边, 即低位循环)
            for (right_num = right_start_num; right_num <= right_max_num; right_num += right_num_step) {
                sayword_index_list[right_num] = FIRST_SPECIAL_NUM_FIXED_INDEX;

                if (digital < integer_base10_length) {
                    left_num_step = integer_base10[digital + 1];
                    left_start_num = right_num + left_num_step;
                    // 左边循环(该个,十,百,千,万位的左边, 即高位循环)
                    for (left_num = left_start_num; left_num <= max_number; left_num += left_num_step) {
                        sayword_index_list[left_num] = FIRST_SPECIAL_NUM_FIXED_INDEX;
                    }
                }
            }
        }
    }

 后记

  后来,我改进了一下这个代码,“if (digital < integer_base10_length) { 这一句其实是可以拿出来单独处理的,因为大多数情况下是不会出现这种情况的,即当前位数超过 int32 范围内最大的 10 的 9 幂次方数(1000000000),此时左边是不用循环的。虽然这种情况出现的可能性非常低,但考虑完整性,还是保留了这个判断。而且分开处理后(拿到外层循环只判断一次,具体可以看 GitHub 的代码,为什么不贴在这里,因为我觉得这里贴的应该尽量简洁,能说明问题即可)也只需要一次if判断即可,而不必像原来那样在每次循环时都判断一次,耗时比原来略微减少了一点。

  再后来,我重新研究了一下这段代码,你可以发现右边循环的数都是连续的,左边循环的数的间隔至少是 10 或 10 以上,所有我们何不先循环左边循环,内部再嵌套右边循环,这样写入时,右边循环的地址是连续地址,对写缓存比较有利;而且对于个位数来说,它是没有右边循环的,可以特别处理。遗憾的是,测试结果好像还比先右边循环再循环左边稍慢一点,由于我们测试的仅仅是 max_number=100 的情况,也许 max_number 更大的时候,先左后右的方法才能体现出优势。不过对于为什么会出现这种情况,我也是不太能够理解,也许还是跟缓存或编译器代码优化有关(编译器不是万能的,有时候可能编译出来的代码不一定是最优的),也可能是因为 max_number=100 (太小)的原因,因为即使先循环左边,间隔也不过才 10 而已,由于我们是重复循环 10000 次,数据基本上已经在缓存里了,所有缓存优化已经变得没有差别了,主要差别还是在代码的执行过程上。

具体的代码请看 /FizzBuzzWhizz_vc2008/src/FizzBuzzWhizz/,里面有三种不同的方法。

GitHub 地址:https://github.com/shines77/FizzBuzzWhizz

代码分别有 C/C++ 版本和 C# 版本,C# 版本是和 @黑耗子 的版本一起测试的。

程序运行截图:

下面是各个 C# 版本的运行结果(注:C# 最快的版本比 C++ 最快的版本要慢许多):

那个 “失业青年” 是我写的 C# 版本,解释一下为什么要比 “屌丝青年” 的代码要慢,他们都是固定写死的版本,我写的是通用版本,效率自然会低一些(因为做的工作要多一些),而且这是我比较早的版本写成 C# 的,后来的逻辑改动也不是很大,所以也就懒得改了。如果都写成通用版本,两者估计会更接近(他的代码求最大公约数的时候使用了除法),从逻辑上两者差别不算很大,所以也不会有很大出入。

下面是 C\C++ 版本(第三种方法 FizzBuzzWhizz_fast() 是所有代码里最快的)

下面介绍一下 C\C++ 的三个版本:

1. FizzBuzzWhizz_stl():STL版本,使用 std::string 和 std::vector<>,一开始没有优化的时候,更是用时 500 多 ms(毫秒),比 C# 版本还要慢!后来还做了一些改进,但效率依然不是特别理想;

2. FizzBuzzWhizz_sys():这个版本使用自己分配内存处理字符串数组,并且调用的系统自带的 strcat(), strcpy(), itoa() 函数,效率依然不够高;

3. FizzBuzzWhizz_fast():这个版本重写了 strcat(), strcpy() 以及 itoa() 函数,对这些函数做了一些优化,速度得到一定提升,是目前所有版本里最快的。

 

代码里有用到 _aligned_malloc() 和 _aligned_free(),这个是微软自己写的对齐内存分配函数(其实自己实现也很简单,我有写过,懒得拿出来用了),如果你的系统不支持,可以在 common.h 找到 _aligned_malloc 的宏,默认的处理方式是,如果不是微软的编译器则会重定义到 malloc 和 free 。本来早期是想让内存地址对齐到 16 字节的,因为我考虑可能会用 sse2 来优化,后来发现对齐到 4 字节就够了(如果不用 SSE 的话),而 malloc 默认分配的的地址本来就是 8 字节对齐的(默认情况下,不另外设置),所以 _aligned_malloc 其实是多余的,不过如果是 Visual Studio,默认应该是支持的。如果不支持,也可以修改 common.h 里的代码,让宏生效。

从这个程序得到一个启示,就是不管是 C++ 还是 C#,很有可能在默认的情况,会有一些性能瓶颈,而你不知情,比如以上的 STL 就是,没想到效率差这么多。C# 也没有那么慢,虽然归根结底是要比 C++ 慢的,托管的代码有这样的效率还可以接受,对于这个算法,我没对 Java 做同样的测试。不过之前我做过一些测试,Java 的 HotSpot 大多数情况下要比 C# 优秀,甚至一个简单的递归版 fibonacci() 比 C++ 还要快,如果你想知道详情,可以私下交流。

另外,我还发现一个问题,就是每个算法单独运行比和其他方法一起运行的时候,耗时要多一些。后来想想,可能是 CPU 没有预热的原因,因为这种现象我在 C++ 和 C# 里都发现了,现代的 CPU 和主板都具备在空闲的时间自己把 CPU 频率降下来,以节省能源,而我们的测试代码虽然循环了 10000 次,但耗时还是很短的,所以一开始执行代码的时候 CPU 并没有全速运行,所以我们写个无用的代码,让 CPU 唤醒一下,后面的测试就准确了,从此腿也不酸了,腰也不疼了。CPU 预热我只在 C++ 版本里写了,C# 版本没弄,如果你有兴趣,可以自己加上去。

 

关于怎么实现可扩展,为了方便跟黑耗子的代码做比较,默认只测试了跟他一样的数据,也没有写数据输入部分,暂时不在这上面浪费时间。

那怎么实现扩展性?请看如下代码:

void FizzBuzzWhizz_Test_Wrapper_4(const int max_number, bool display_to_screen)
{
    int index, max_num_list;
    const string say_word_list[] = { "Fizz", "Buzz", "Whizz", "Zoozz" };
    int special_num_list[][4] = {
        { 3, 5, 7, 9 },
        { 2, 4, 8, 9 },
        { 3, 6, 8, 9 },
        { 2, 2, 2, 2 },
    };   

    // 最大say_word类型
    int max_word_type = _countof(say_word_list);

    // say_word组合后的最大字符长度
    int max_word_length = 1;
    for (index = 0; index < max_word_type; ++index)
        max_word_length += say_word_list[index].length();

    // 对齐到STRING_ADDR_ALIGNMENT(4字节)
    max_word_length = (max_word_length + STRING_ADDR_MASK) & (~STRING_ADDR_MASK);

    // 测试参数总组数
    max_num_list = sizeof(special_num_list) / sizeof(special_num_list[0]);

    if (display_to_screen)
        max_num_list = 1;

    for (index = 0; index < max_num_list; ++index) {
        display_special_num_list(max_word_type, special_num_list[index]);

        // 使用stl的std::string, 速度慢
        FizzBuzzWhizz_stl_Test(max_number, max_word_type, max_word_length, say_word_list, special_num_list[index], display_to_screen);

        // 使用自定义的string array, 采用系统自带的字符串处理函数, 中等速度
        FizzBuzzWhizz_sys_Test(max_number, max_word_type, max_word_length, say_word_list, special_num_list[index], display_to_screen);

        // 使用自己编写的字符串处理函数, 较快
        FizzBuzzWhizz_fast_Test(max_number, max_word_type, max_word_length, say_word_list, special_num_list[index], display_to_screen);
    }
}

可以自行修改 say_word_list 和 special_num_list,但要保证 special_num_list 每一行的长度必需跟 say_word_list 的个数相同。当然,say_word_list 的长度在当前的代码里不是无限的,最多支持 32 个。如果需要,可以扩展为 64 个(通过修改 common.h 里的 index_mask_t 类型即可实现),如果超过 64 个,则需要写新的代码以实现 mask 的位操作。

 

此外,我还想了几条扩展的规则,如下:

(下面是 3 条规则笔者自己加的扩展规则,原题是没有的,这些规则可以视为题目的难度扩展,其实也只是增加了一些处理规则和逻辑而已,解决方案基本不变。)

6. 如果所报数字包含了第二个特殊数,也忽略规则 3 和规则 4,直接报 Buzz,比如要报 15 的同学只报 Buzz,不报 FizzBuzz;
7. 如果所报数字包含了第三个特殊数,也忽略规则 3 和规则 4,直接报 Whizz,以此类推。
8. 特殊数越在前面的优先级越高,即,如果满足了规则 5 中的第一个特殊数的条件,则忽略规则 6 中的第二个特殊数的规则,如果满足了规则 6 中的第二个特殊数的条件,则忽略规则 7 中的第三个特殊数的规则,依次类推。

有兴趣的朋友,可以试试。

 

.

posted @ 2014-05-07 22:16  shines77  阅读(3003)  评论(12编辑  收藏  举报