十二月

十二月,应该是最简单的单人自娱扑克牌游戏。

事实上,我只见过我妈玩,而且通常是在大年三十的晚上,主要是算算新的一年各月的运势,哈哈。 我也玩,主要是打发时间。

十二月的玩法极其简单,而且完全不需要动脑子,整个过程可以自动完成,人只是在翻牌打发时间罢了。 也正是因为如此,这里才可以讨论十二月中的概率问题

十二月的玩法

一副标准的扑克牌中去掉大小王和K,只用到点数大小对应于各个月份的A,2,3,...,J,Q,共48张牌。 当然,如果闰月的话,也可以带上K,十三月。

摆牌

充分洗牌后,背面朝上摆成十二堆,依次代表十二个月份,每堆4张牌,叠起来如下图所示。摆牌的过程并不重要。

翻牌

从一月开始翻牌,也就是第1堆最底层那张牌开始。

翻出的牌按点数放到对应的月份那堆牌上,这里图中翻出来的是方片2,代表二月,所以就把方片2放到第2堆牌上面,正面朝上。

接着翻二月那堆(即上一次翻出牌的点数对应那堆)最底层那张牌。

图中翻出来的是梅花8,放到代表八月的第8堆上面;

依次类推...

结果判定

按照上面规则翻牌,最后会停在4张A全部翻出的时候,因为是从一月开始翻的,当最后一张A翻出来放到一月上面,一月最下面就没牌可翻了。此时,如果其它各月的牌都已经被翻出来了,就视为拣开了;如果还有对应月份的牌没翻出来,则视为没拣开。 开头的 这张动图 就是十二月拣开的例子。 下面这张图是十二月没拣开的例子。

特殊情形判定,当4张A全部翻出的时候,仍有其它月份的牌没被翻出,此时,如果各堆中剩下没翻的牌点数大小正好是对应月份数字,那么十二月也视为拣开的。 下图就属于这种特殊拣开情形,当最后一张A被翻出时,还有一张其它月份的牌没被翻出,那这唯一一张没被翻出的牌肯定位于相应的月份下面,这种情形一定是拣开的。

成功拣开的概率

可以看出,十二月的规则特别简单,结果不是拣开就是没拣开。现在就来算一下在洗牌充分的情况下,拣开十二月的概率。

如果不考虑特殊拣开情形,拣开十二月的概率计算很简单,一共48张牌,全排列为 ,而从翻牌规则可以看出,最后一张牌为A视为拣开,有4张A,所以总的拣开的可能有 种,拣开的概率为

为了考虑特殊拣开情形,增加一些无关本质的规则,即当4张A都被翻出后,接着从二月(第2堆)开始翻牌,规则一样,翻出的牌按照点数放到相应月份那堆上面,直到4张2都被翻出;然后接着翻第3堆... 依次下去,直到所有牌都翻过来。这个翻牌序列是唯一的,意思是,任给一个48张牌的排列顺序,都可以构造出相应唯一的摆牌,使得翻牌顺序就是给定的顺序;也就是说翻牌顺序总共有 种可能。而拣开十二月的情形是最后一张A被翻出后,其余未被翻出的牌都在其相应点数的月份那堆下面,仔细理解增加的规则后,可以总结拣开十二月的情形为:48张牌的排列,这个排列中最后一张A之后的牌按序列点数递增(可以相等或者说不减小、已经排好序的)。分情况讨论。

  1. 最后一张A之后没有其它牌了
    此时翻牌序列最后一张就是A,概率前面已经给出了,即

  2. 最后一张A之后还有1张牌
    只剩一张牌没翻,肯定是在自己点数对应那堆底下了,因为其它牌都翻出了,所以此时肯定是拣开了;这也符合前面总结的最后一张A之后的牌已经排序了。此时概率为

  3. 最后一张A之后还有2张牌
    也就是从4张A中选1张放在倒数第三的位置,从其余不是A的44张牌中选出2张按照递增(点数可重复)顺序放在最后两个位置的所有排列。此时概率为

  4. 最后一张A之后还有3张牌
    也就是从4张A中选1张放在倒数第四的位置,从其余不是A的44张牌中选出3张按照递增(点数可重复)顺序放在最后三个位置的所有排列。此时概率为

  5. 最后一张A之后还有4张牌
    也就是从4张A中选1张放在倒数第五的位置,从其余不是A的44张牌中选出4张按照递增(点数可重复)顺序放在最后四个位置的所有排列。此时概率为

翻出最后一张A之后,还有更多张牌未翻出且正好点数都位于相应月份下面的情况概率很小,就忽略了,所以十二月拣开的概率约为

前面用到的排列组合部分知识只记得大概,现补了一下, 不知道用得是否正确, 所以,用蒙特卡洛模拟估一下结果, 首先是简单估一下前面排列组合计算是否靠谱, 就是模拟 “48张牌的排列,最后一张A之后的牌按序列点数递增” 的概率,C++程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <algorithm>
#include <vector>
#include <iostream>
#include <random>
#include <iterator>
using namespace std;

int main()
{
int A = 1;

vector<int> CardVec;
for(int i=0;i<4;i++) //4种花色
for(int j=0;j<12;j++) //12个月
CardVec.push_back(j+1);

int totalSimCount = 500000; //总的拣牌次数
int successCount = 0; //拣开次数

std::random_device rng;
std::mt19937 urng(rng());
for( int i=0; i<totalSimCount; i++ ) {

//经典洗牌算法,Knuth shuffle 或 Fisher-Yates shuffle
shuffle( CardVec.begin(), CardVec.end(), urng);

//找到第一个A(文中描述最后一个A,没差别)
vector<int>::iterator it = find(CardVec.begin(), CardVec.end(), A);

//第一个A之前是否排好顺序(同理最后一个A之后)
if( is_sorted( CardVec.begin(), it ) )
successCount++;

// if( is_sorted( CardVec.begin(), it ) &&
// (distance( CardVec.begin(), it ) == 3))
// successCount++;
}

cout << "p ≈ " << double (successCount) / totalSimCount << endl;
}

下面程序是模拟整个十二月的翻牌过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#include <iostream>
#include <random>
#include <array>
#include <deque>
#include <algorithm>
#include <functional>
using namespace std;

struct Card
{
int face;//0~11, A,2,3,...,J,Q
int suit;//0~3, spade,heart,diamond,club
};

int main() {
const int faces = 12;
const int suits = 4;
std::array<Card, faces*suits> deck;

// initialize
for(size_t i{0}; i<deck.size(); ++i){
deck[i].face = i%faces;
deck[i].suit = i/faces;
}

//12-month containers
std::array<std::deque<Card>, faces> monthArr;
fill(monthArr.begin(),monthArr.end(),std::deque<Card>(suits));


Card card;
int simTotalCount = 500000;
int succCount = 0;


std::random_device rng;
std::mt19937 urng(rng());

//MonteCarlo仿真循环
for(size_t iSim{0}; iSim<simTotalCount; ++iSim){

//是否拣开标志
bool isSucc = true;

//洗牌 shuffle deck
std::shuffle(deck.begin(), deck.end(), urng);

//摆牌 deal
for(size_t i{0}; i<monthArr.size(); ++i){
copy_n(deck.begin()+i*suits, suits, monthArr[i].begin());
}

//翻牌模拟,从第一堆(一月)开始
card = monthArr[0].front();
monthArr[0].pop_front();
//翻牌过程
while(true){
//因为翻牌终止条件是第一堆(一月)全部是A,所以每翻出一个A,就进行验证是否终止
if(card.face == 0){//翻出A
monthArr[0].push_back(card);
//定义lambda函数,验证某一堆(月份)的牌的点数是否全部等于对应的月份
std::function<bool(int)> checkMonSame = [&monthArr](int monNo){
for(auto & cardt : monthArr[monNo]){
if(cardt.face != monNo)
return false;
}
return true;
};
//判断第一堆(一月)是否全部是A
if(checkMonSame(0)){ //如果全部是A,则break本次仿真
//则先检查其它堆(月份)的牌是否都是对应月份的点数
for(size_t i{1}; i<monthArr.size(); ++i){
if(!checkMonSame(i)){//有一堆不是的话,就未成功拣开
isSucc = false;
break;
}
}
//如果运行到这,那就成功拣开,isSucc保持true不变
break;
}
else{ //如果第一堆(一月)并不全是A,则接着翻牌,继续仿真
card = monthArr[0].front();
monthArr[0].pop_front();
continue;
}
}
else{//翻出不是A,则把牌放到相应堆上push_back(),再从该堆下面接着翻pop_front()
int i = card.face;
monthArr[i].push_back(card);
card = monthArr[i].front();
monthArr[i].pop_front();
}
}

//拣开次数统计
if(isSucc)
succCount++;
} //MonteCarlo仿真循环 结束

//计算输出概率
cout<< (double) succCount / simTotalCount <<endl;
}

这两个程序运行输出的仿真结果都是在 0.218、0.219 附近,仿真结果与计算的差不多,所以前面计算得到的十二月拣开的概率还是靠谱的。

可以看出,拣开十二月的概率还是很高的,拣一次就拣开的概率约为 ,拣5次至少有一次拣开的概率则超过70%了: 以拣开次数作为随机变量,拣5次平均就有1次拣开。

最后

  1. 一月肯定会拣开,因为从一月开始翻牌的哈哈;
  2. 从其它月开始翻牌,拣开的概率是一样的;
  3. 不存在只开了11个月的情形;
  4. 同样的摆牌,从一月开始翻牌可以拣开,从其它月份开始翻牌未必能拣开?
  5. 任给一个摆牌,存在一个月份,从这个月份开始翻牌一定可以拣开?
  6. 存在特殊的摆牌,从任意月份开始翻牌都可以拣开,例如每个月下面都是4张同样的其它月份点数的牌;
  7. 以翻出最后一张A为准,玩一把十二月平均要翻多少张牌,玩一把十二月的平均耗时?

每次我妈拣开十二月,总是很高兴:你看,一把就开了,一点差头也没有。 要是没开,就会惋惜:唉,就差两个月。这时候我就会让我妈多拣几次。


本文主要内容整理自htallone.com


关注微信公众号,订阅更新