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

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/320096074 和 http://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乘法很慢的话,我的方法更有优势。

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

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

  我们在最前面加一个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. Fizz      (数字里含有第一个特殊数a的);
  2. FizzBuzzWhizz  (同时是a,b,c倍数的数);
  3. FizzBuzz      (同时是a,b的倍数的数);
  4. FizzWhizz    (同时是a,c的倍数的数);
  5. BuzzWhizz   (同时是b,c的倍数的数);
  6. Fizz             (是a的倍数的数);
  7. Buzz            (是b的倍数的数);
  8. Whizz          (是c的倍数的数);
  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, 十位数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、对于某位数D(设D为从右边数的第P位数字),先搜索某位数上(个位,十位,百位等)的右边,去掉该位数以后,从0开始循环,一直循环到(10^P - 1)为止,再与该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的幂次方数1000000000,此时左边是不用循环的。虽然这种情况出现的可能性非常低,但考虑完整性,还是保留了这个判断。而且分开处理后(拿到外层循环只判断一次,具体可以看GitHub的代码,为什么不贴在这里,因为我觉得这里贴的应该尽量简洁,能说明问题即可)也只需要一次if判断即可,而不必像原来那样在每次循环时都判断一次,耗时比原来略微减少了一点。

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

具体的代码请看src\FizzBuzzWhizz\FizzBuzzWhizz_fast.cpp,里面两种方法都有写。

 

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

 

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

 

下面介绍一下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默认分配的的地址本来就是4字节对齐的(默认情况下,不另外设置),所以_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#版本没弄,如果你有兴趣,可以自己加上去。

 

下面是程序运行的截图:

C\C++版本:

这个是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);
    }

    printf("===============================================\n\n");
}

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