代码调优实践

690 阅读5分钟

[TOC]

代码调优实践

2020-08-04

刚刚看完《编程珠玑》,这是一本介绍算法设计和代码调优的程序员宝典,非常值得精读。在本篇文章中,则以一道概率题作为练习,涉及到书中介绍的随机抽样和代码调优的技巧。本文讨论的题目如下:

问题:A、B、C、D、E 共 5 张卡,每次从中抽出 2 张(这两张卡的字母一定不同),一共抽 5 次,求 A、B、C、D、E 全部出现在抽中的卡中的概率。

一、数学方式解题

首先尝试以概率论的方式解决该问题。如果直接通过计算正向概率 P{P} 解决,则过程十分复杂。如果通过计算反向事件 P\overline{P} 的概率,再通过 P=1P{P = 1 - \overline{P}} ,则过程会简单很多。记正向事件A、B、C、D、E 全部出现在抽中的卡中为事件A{A},则反向事件 A\overline{A}A、B、C、D、E 至少有一张卡未出现在抽中的卡中。其中 P(A)=1P(A){P(A) = 1 - P(\overline{A})}

首先五次抽取是五个独立事件,可能出现的所有事件数为 C52×C52×C52×C52×C52=105{C_5^2 \times C_5^2 \times C_5^2 \times C_5^2 \times C_5^2=10^5}

然后考虑事件 A\overline{A} 的事件数。分五种情况考虑:

  • 情况一:抽中的卡片仅包含 A、B、C、D(缺失E);
  • 情况二:抽中的卡片仅包含 A、B、C、E(缺失D);
  • 情况三:抽中的卡片仅包含 A、B、D、E(缺失C);
  • 情况四:抽中的卡片仅包含 A、C、D、E(缺失B);
  • 情况五:抽中的卡片仅包含 B、C、D、E(缺失A);

情况一事件数为 C42×C42×C42×C42×C42{C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2} ,因为每次只能从 A、B、C、D 四张卡牌中抽取两张。

情况二事件数乍一看像是 C42×C42×C42×C42×C42{C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2} ,但是必须要考虑到这里包含了情况一中的一些事件——抽中卡牌只包含 A、B、C 三种,因此还需要减去这些重复的事件,对于这种事件因为每次只能从 A、B、C 三张卡牌中抽取两张,因此这种事件的数量为 C32×C32×C32×C32×C32{C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2} 。因此情况二的事件数为 C42×C42×C42×C42×C42C32×C32×C32×C32×C32{C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2}

情况三事件数同理也要考虑重复事件数,抽中卡牌只包含三种可能会出现四种情况

  • 情况 3.1:只抽中 A、B、D;
  • 情况 3.2:只抽中 A、B、E;
  • 情况 3.3:只抽中 A、D、E;
  • 情况 3.4:只抽中 B、D、E;

其中情况 3.1 和情况 3.2 已经包含在情况一、情况二的事件中,因此也要减去这两部分事件数。乍一看貌似就是 C42×C42×C42×C42×C422×C32×C32×C32×C32×C32{C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-2\times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2} 。但是还需要考虑情况 3.1 和情况 3.2 实际还存在重复事件——抽中卡牌只包含 A、B,也就是说这个事件被减了两次,必须加回来一次,还好这种事件数只有 1。因此情况三的事件数为 C42×C42×C42×C42×C422×C32×C32×C32×C32×C32+1{C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-2\times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2+1}

情况四事件数同理也要考虑重复事件数,抽中卡牌只包含三种可能会出现四种情况

  • 情况 4.1:只抽中 A、C、D;
  • 情况 4.2:只抽中 A、C、E;
  • 情况 4.3:只抽中 A、D、E;
  • 情况 4.4:只抽中 C、D、E;

其中情况 4.1、情况 4.2、情况 4.3 已经包含在情况一、情况二、情况三的事件中,因此也要减去这三部分事件数,同样要考虑多减去的事件。情况 4.1 和情况 4.2 存在一个重复事件——抽中卡牌只包含 A、C。注意情况 4.3 与情况 4.1 、情况 4.2 存在两个重复事件——抽中卡牌只包含 A、D 以及 抽中卡牌只包含 A、E。因此情况四的事件数为 C42×C42×C42×C42×C423×C32×C32×C32×C32×C32+1+2{C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-3\times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2+1+2}

情况五事件数同理也要考虑重复事件数,抽中卡牌只包含三种可能会出现四种情况

  • 情况 5.1:只抽中 B、C、D;
  • 情况 5.2:只抽中 B、C、E;
  • 情况 5.3:只抽中 B、D、E;
  • 情况 5.4:只抽中 C、D、E;

其中情况 5.1、情况 5.2、情况 5.3、情况 5.4 均包含在情况一、情况二、情况三、情况四的事件中,因此也要减去这四部分事件数,同样要考虑多减去的事件。情况 5.1 和情况 5.2 存在一个重复事件——抽中卡牌只包含 B、C。情况 5.3 与情况 5.1 、情况 5.2 存在两个重复事件——抽中卡牌只包含 B、D 以及 抽中卡牌只包含 B、E。情况 5.4 与情况 5.1 、情况 5.2、情况 5.3 存在三个重复事件——抽中卡牌只包含 C、D 以及 抽中卡牌只包含 C、E 以及抽中卡牌只包含 D、E。。因此情况五的事件数为 C42×C42×C42×C42×C424×C32×C32×C32×C32×C32+1+2+3{C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-4\times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2+1+2+3}

最后计算 A\overline{A} 事件数为情况一、二、三、四、五的事件数总和:

C42×C42×C42×C42×C42{C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2} +C42×C42×C42×C42×C42C32×C32×C32×C32×C32{+C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2} +C42×C42×C42×C42×C422×C32×C32×C32×C32×C32+1{+C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-2\times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2+1} +C42×C42×C42×C42×C423×C32×C32×C32×C32×C32+1+2{+C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-3\times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2+1+2} +C42×C42×C42×C42×C424×C32×C32×C32×C32×C32+1+2+3{+C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-4\times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2+1+2+3} =5×C42×C42×C42×C42×C4210×C32×C32×C32×C32×C32+10{=5\times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2 \times C_4^2-10\times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2 \times C_3^2+10} =5×6510×35+10{=5\times 6^5 - 10\times 3^5 + 10} =36460{=36460}

因此反向事件A、B、C、D、E 至少有一张卡未出现在抽中的卡中的概率为 P(A)=36460÷105=0.36460{P(\overline{A})=36460\div 10^5=0.36460}。最终正向事件A、B、C、D、E 全部出现在抽中的卡中的概率为 P(A)=10.36460=0.63540{P(A) = 1 - 0.36460 = 0.63540}

二、编程方式模拟验证

结果是算出来了,但是概率论的东西其实已经很久没有涉足,所以还不能确定其正确性。如何验证上面计算的结果呢?可以通过过程序模拟抽样事件,当重复事件达到一定量级时,自然会越来越趋近于正确答案。所以本章用程序验证上一章计算结果的正确性。

-(void)testSampling{
    int n = 1e6;
    NSMutableSet* selected = [NSMutableSet new];
    int allFive = 0;
    static NSDate *start, *end;

    start = [NSDate date];
    for(int i = 0; i < n; i++){
        for(int j = 0; j < 5; j++){
            int select1 = arc4random()%5 + 1;
            [selected addObject:@(select1)];

            int select2;
            do {
                select2 = arc4random()%5 + 1;
            }while (select2 == select1);
            [selected addObject:@(select2)];
        }

        if(selected.count == 5){
            allFive++;
        }
        selected = [NSMutableSet new];
    }
    end = [NSDate date];
    NSTimeInterval seconds = [end timeIntervalSinceDate:start];

    double result = 1.0 * allFive / n;
    NSLog(@"模拟次数:%d; 结果:%f; 耗时:%f", n, result, seconds);
}

以上程序的平均运行时间为 4.30 秒,结果为 0.635485。结果虽然接近第一章计算所得到的 0.635485,但是还存在偏差,不能确定其准确性。通过增加模拟次数来得到更精确的结果,将n的值设置为 10 亿,模拟的最终结果为 0.635400989732,已基本无限趋近第一章计算结果。

三、算法调优

考虑到testSampling方法构建NSMutableSet实例存在一定的内存花销,因此使用 5 bit 长度的位图的方式记录抽中样本值。例如,二进制数1<<0也就是 1,表示 A 卡被选中;二进制数1<<1也就是 2,表示 B 卡被选中,依次类推。如果最终位图为二进制数00011111也就是 31,则表示 A、B、C、D、E 卡均被选中。以下testSampling1算法可以使平均运行时间缩减为 3.08 秒。

-(NSTimeInterval)testSampling1{
    int n = 1e6;
    char selected = 0;
    int allFive = 0;
    static NSDate *start, *end;

    start = [NSDate date];
    for(int i = 0; i < n; i++){
        for(int j = 0; j < 5; j++){
            int select1 = arc4random()%5;
            selected |= 1<<select1;

            int select2;
            do {
                select2 = arc4random()%5;
            }while (select2 == select1);
            selected |= 1<<select2;
        }

        if(selected == 31){
            allFive++;
        }
        selected = 0;
    }
    end = [NSDate date];
    NSTimeInterval seconds = [end timeIntervalSinceDate:start];

    double result = 1.0 * allFive / n;
    NSLog(@"模拟次数:%d; 结果:%f; 耗时:%f", n, result, seconds);

    *onceResult = result;
    return seconds;
}

上述testSample方法中,之所以使用arc4random是因为该方法生成随机数更加安全,但是其速度比random函数要慢几乎 90%,因此不妨尝试将arc4random替换为效率更高的random函数,得到的testSampling2的平均运行时长压缩到 0.29 秒,到达毫秒级。此时,即使模拟 10 亿规模的抽取过程也仅需 290 秒。将内部循环拆解开,平均运行时长可以进一步压缩到 0.26 秒,但是会大量增加程序代码量所以不实现这个优化。

-(NSTimeInterval)testSampling2{
    int n = 1e6;
    char selected = 0;
    int allFive = 0;
    static NSDate *start, *end;

    start = [NSDate date];
    for(int i = 0; i < n; i++){
        for(int j = 0; j < 5; j++){
            int select1 = random()%5;
            selected |= 1<<select1;

            int select2;
            do {
                select2 = random()%5;
            }while (select2 == select1);
            selected |= 1<<select2;
        }

        if(selected == 31){
            allFive++;
        }
        selected = 0;
    }
    end = [NSDate date];
    NSTimeInterval seconds = [end timeIntervalSinceDate:start];

    double result = 1.0 * allFive / n;
    NSLog(@"模拟次数:%d; 结果:%f; 耗时:%f", n, result, seconds);

    return seconds;
}

注意到上面do-while循环包围的random函数存在执行多次的可能,random函数比较耗时间,因此调整一下抽样的方式。将第一次抽中某个样本后,第二次也抽中该样本的概率均摊到其他未抽中样本,从而消除do-while循环。然而这个优化效果并没有达到预期的强度,仅仅能提高几毫秒的性能,因此可以断定random函数本身的执行效率还是相对较高的。如果是使用arcrandom,这个优化效果算是比较显著的,可以提升大约 10% 的效率。

-(NSTimeInterval)testSampling3{
    int n = 1e6;
    char selected = 0;
    int allFive = 0;
    static NSDate *start, *end;

    start = [NSDate date];
    for(int i = 0; i < n; i++){
        for(int j = 0; j < 5; j++){
            int select1 = random()%5;
            selected |= 1<<select1;

            // 按 4 等分均摊剩余样本抽中概率
            int select2 = random()%4;
            if(select1 < 4 && select2 >= select1){
                select2++;
            }
            selected |= 1<<select2;
        }

        if(selected == 31){
            allFive++;
        }
        selected = 0;
    }
    end = [NSDate date];
    NSTimeInterval seconds = [end timeIntervalSinceDate:start];

    double result = 1.0 * allFive / n;
    NSLog(@"模拟次数:%d; 结果:%f; 耗时:%f", n, result, seconds);

    return seconds;
}

四、总结

  • 随机抽样算法,可以用来验证概率论题目的正确性;
  • 位图通常用来表示数字集合,是非常有用而且高性能的数据结构;
  • arc4random函数产生的随机数不可预测更加安全但是速度比random慢很多;
  • 随机抽样算法必须严格保证每个样本被抽中的概率相同。