[TOC]
代码调优实践
2020-08-04
刚刚看完《编程珠玑》,这是一本介绍算法设计和代码调优的程序员宝典,非常值得精读。在本篇文章中,则以一道概率题作为练习,涉及到书中介绍的随机抽样和代码调优的技巧。本文讨论的题目如下:
问题:A、B、C、D、E 共 5 张卡,每次从中抽出 2 张(这两张卡的字母一定不同),一共抽 5 次,求 A、B、C、D、E 全部出现在抽中的卡中的概率。
一、数学方式解题
首先尝试以概率论的方式解决该问题。如果直接通过计算正向概率 解决,则过程十分复杂。如果通过计算反向事件 的概率,再通过 ,则过程会简单很多。记正向事件A、B、C、D、E 全部出现在抽中的卡中为事件,则反向事件 为A、B、C、D、E 至少有一张卡未出现在抽中的卡中。其中 。
首先五次抽取是五个独立事件,可能出现的所有事件数为 。
然后考虑事件 的事件数。分五种情况考虑:
- 情况一:抽中的卡片仅包含 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);
情况一事件数为 ,因为每次只能从 A、B、C、D 四张卡牌中抽取两张。
情况二事件数乍一看像是 ,但是必须要考虑到这里包含了情况一中的一些事件——抽中卡牌只包含 A、B、C 三种,因此还需要减去这些重复的事件,对于这种事件因为每次只能从 A、B、C 三张卡牌中抽取两张,因此这种事件的数量为 。因此情况二的事件数为 ;
情况三事件数同理也要考虑重复事件数,抽中卡牌只包含三种可能会出现四种情况
- 情况 3.1:只抽中 A、B、D;
- 情况 3.2:只抽中 A、B、E;
- 情况 3.3:只抽中 A、D、E;
- 情况 3.4:只抽中 B、D、E;
其中情况 3.1 和情况 3.2 已经包含在情况一、情况二的事件中,因此也要减去这两部分事件数。乍一看貌似就是 。但是还需要考虑情况 3.1 和情况 3.2 实际还存在重复事件——抽中卡牌只包含 A、B,也就是说这个事件被减了两次,必须加回来一次,还好这种事件数只有 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。因此情况四的事件数为 ;
情况五事件数同理也要考虑重复事件数,抽中卡牌只包含三种可能会出现四种情况
- 情况 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。。因此情况五的事件数为 ;
最后计算 事件数为情况一、二、三、四、五的事件数总和:
因此反向事件A、B、C、D、E 至少有一张卡未出现在抽中的卡中的概率为 。最终正向事件A、B、C、D、E 全部出现在抽中的卡中的概率为 。
二、编程方式模拟验证
结果是算出来了,但是概率论的东西其实已经很久没有涉足,所以还不能确定其正确性。如何验证上面计算的结果呢?可以通过过程序模拟抽样事件,当重复事件达到一定量级时,自然会越来越趋近于正确答案。所以本章用程序验证上一章计算结果的正确性。
-(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慢很多;- 随机抽样算法必须严格保证每个样本被抽中的概率相同。