10.2 计数与概率基础
排列与组合是最基本的计数技巧。本节介绍一些基本的相关知识和方法,供读者参考。
加法原理。做一件事情有n个办法,第i个办法有pi种方案,则一共有p1+p2+…+pn种方案。
乘法原理。做一件事情有n个步骤,第i个步骤有pi种方案,则一共有p1p2…pn种方案。
乘法原理是加法原理的特殊情况(按照第一步骤进行分类),二者都可用于递推。注意应用加法原理的关键是分类:各类别之间必须没有重复、没有遗漏。如果有重复,可以使用容斥原理。
容斥原理。假设班里有10个学生喜欢数学,15个学生喜欢语文,21个学生喜欢编程,一共有多少个学生呢?是10+15+21=46个吗?不是的,因为有些学生可能同时喜欢数学和语文,或者语文和编程,甚至还可能有三者都喜欢的。为了叙述方便,将喜欢语文、数学、编程的学生集合分别用A, B, C表示,则学生总数等于|A∪B∪C|。刚才已经说了,如果把这3个集合的元素个数|A|、|B|、|C|直接加起来,会有一些元素重复统计了,因此需要扣掉|A∩B|、|B∩C|、|C∩A|,但这样一来,又有一小部分多扣了,需要加回来:|A∩B∩C|。这样,就得到了一个公式:
|A∪B∪C|=|A|+|B|+|C|-|A∩B|-|B∩C|-|C∩A|+|A∩B∩C|
一般地,对于任意多个集合,都可以列出这样一个等式,其中左边是所有集合的并的元素个数,右边是这些集合的“各种搭配”。每个“搭配”都是若干个集合的交集,且每一项前面的正负号取决于集合的个数——奇数个集合为正,偶数个集合为负。
有重复元素的全排列。有k个元素,其中第i个元素有ni个,求全排列个数。
【分析】
令所有ni之和为n,再设答案为x。首先做全排列,然后把所有元素编号,其中第s种元素编号为1~ns(例如,有3个a,两个b,先排列成aabba,然后可以编号为a1a3b2b1a2)。这样做以后,由于编号后所有元素均不相同,方案总数为n的全排列数n!。根据乘法原理,得到了一个方程:n1!n2!n3!…nkx!=n!,移项即可。
可重复选择的组合。有n个不同元素,每个元素可以选多次,一共选k个元素,有多少种方法?例如,n=3,k=2时有6种:(1,1),(1,2),(1,3),(2,2),(2,3),(3,3)。
【分析】
设第i个元素选xi个,问题转化为求方程x1+x2+…+xn=k的非负整数解的个数。令yi=xi+1,则答案为y1+y2+…+yn=k+n的正整数解的个数。想象有k+1个数字“1”排成一排,则问题等价于:把这些“1”分成n个部分,有多少种方法?这相当于在k+n-1个“候选分隔线”中选n-1个,即C(k+n-1,n-1)=C(n+k-1,k)。
10.2.1 杨辉三角与二项式定理
组合数
在组合数学中占有重要地位。与组合数相关的最重要的两个内容是杨辉三角和二项式定理。如图10-1所示就是一个杨辉三角
图10-1 杨辉三角
另一方面,把(a+b)n展开,将得到一个关于x的多项式:
系数正好和杨辉三角一致。一般地,有二项式定理:
这不难理解:(a+b)n是n个括号连乘,每个括号里任选一项乘起来都会对最后的结果有一个贡献。如果选了k个a,就一定会选n-k个b,最后的项自然就是an-kbk。而从n个a里选k个(同时也相当于n个b里选n-k个)有
种方法,这也是组合数的定义。
给定n,如何求出(a+b)n中所有项的系数呢?一个方法是用递推,根据杨辉三角中不难发现的规律,可以写出如下程序:
memset(C, 0, sizeof(C));
for(int i = 0; i <= n; i++) {
C[i][0] = 1;
for(int j = 1; j <= i; j++) C[i][j] = C[i-1][j-1] + C[i-1][j];
}
但遗憾的是,这个算法的时间复杂度是O(n2)——尽管只用了杨辉三角的第n行的n+1个元素,却把全部n行的O(n2)个元素都计算了一遍。
另一个方法是利用等式
,从
开始从左到右递推,例如:
C[0] = 1;
for(int i = 1; i <= n; i++) C[i] = C[i-1]*(n-i+1)/i;
注意,应该先乘后除,因为C[i-1]/i可能不是整数。但这样一来增加了溢出的可能 性——即使最后结果在int或long long范围之内,乘法也可能溢出。如果担心这样的情况出现,可以先约分,不过一般来说是不必要的。
尽管等式
的“实际意义”不是很明显,却很容易用组合数公式
证明,读者不妨一试。
例题10-6 无关的元素(Irrelevant Elements, ACM/ICPC NEERC 2004, UVa1635)
对于给定的n个数a1, a2,…, an,依次求出相邻两数之和,将得到一个新数列。重复上述操作,最后结果将变成一个数。问这个数除以m的余数与哪些数无关?例如n=3,m=2时,第一次求和得到a1+a2,a2+a3,再求和得到a1+2a2+a3,它除以2的余数和a2无关。1≤n≤105,2≤m≤109。
【分析】
显然最后的求和式是a1,a2,…, an的线性组合。设ai的系数为f(i),则和式除以m的余数与ai无关,当且仅当f(i)是i的倍数。不妨看一个简单的例子:
看到最后的结果,你想到了什么?没错,“1 4 6 4 1”正是杨辉三角的第5行!不难证明,在一般情况下,最后ai的系数是
。这样,问题就变成了
中有哪些是m的倍数。
还记得二项式展开的方法吗?理论上,利用此方法可以递推出所有
,但它们太大了,必须用高精度才能存得下。但此问题中所关心的只是“哪些是m的倍数”,受到数论部分中的启发,只需要依次计算m的唯一分解式中各个素因子在
中的指数即可完成判断。这些指数仍然可以用
递推,并且不会涉及高精度。有的读者可能会尝试直接递推每个系数除以m的余数,但遗憾的是,递推式中有除法,而模m意义下的逆并不一定存在。
10.2.2 数论中的计数问题
约数的个数。给出正整数n的唯一分解式
,求n的正约数的个数。
【分析】
不难看出,n的任意正约数也只能包含p1, p2, p3等素因子,而不能有新的素因子出现。对于n的某个素因子pi,它在所求约数中的指数可以是0, 1, 2,…, ai共ai+1种情况,而且不同的素因子之间相互独立。根据乘法原理,n的正约数个数为:

小于n且与n互素的整数个数。给出正整数n的唯一分解式
,求1, 2, 3,…, n中与n互素的数的个数。
【分析】
用容斥原理。首先从总数n中分别减去是p1, p2,…, pk的倍数的个数(对于素数p来说,“与p互素”和“不是p的倍数”等价),即
,然后加上“同时是两个素因子的倍数”的个数
,再减去“同时是3个素因子的倍数”——写成一个“学术味比较浓”的公式就是:

这里引入的新记号φ(n)就是题目中所求的结果,称为欧拉函数。强烈建议初学者花一些时间理解这个公式。对于{p1, p2, …, pk}的任意子集S,“不与其中任何一个互素”的元素个数是
。不过这一项的前面是加号还是减号呢?这取决于S中的元素个数——奇数个就是“减号”,偶数个就是“加号”。
公式已得出,可计算起来很不方便。如果直接根据公式,需要计算多达2k项的代数和,甚至可能比“暴力枚举(依次判断1~n中每个数是否与n互素)”还要慢。
下一步并不显然。上述公式可以变形成如下的形式:

从而只需要O(k)的计算时间,在刚才的基础上大大提高了效率。为什么这个式子和上一个等价呢?直接考虑新公式的“展开方式”即可。展开式的每一项是从每个括号各选一个(选1或者
),全部乘起来以后再乘以n得到。这不正是最初的推导过程吗?
如果没有给出唯一分解式,需要用试除法依次判断
内的所有素数是否是n的因子。这样,则需要先生成
内的素数表。但其实并不用这么麻烦:只需要每次找到一个素因子之后把它“除干净”,即可保证找到的因子都是素数(想一想,为什么)。
int euler_phi(int n) {
int m = (int)sqrt(n+0.5);
int ans = n;
for(int i = 2; i <= m; i++) if(n % i == 0) {
ans = ans / i * (i-1);
while(n % i == 0) n /= i;
}
if(n > 1) ans = ans / n * (n-1);
return ans;
}
1~n中所有数的欧拉phi函数值。并不需要依次计算。可以用与筛法求素数非常类似的方法,在O(nloglogn)时间内计算完毕,例如(原理请读者体会):
void phi_table(int n, int* phi) {
for(int i = 2; i <= n; i++) phi[i] = 0;
phi[1] = 1;
for(int i = 2; i <= n; i++) if(!phi[i])
for(int j = i; j <= n; j += i) {
if(!phi[j]) phi[j] = j;
phi[j] = phi[j] / i * (i-1);
}
}
例题10-7 交表(Send a Table, UVa10820)
有一道比赛题目,输入两个整数x、y(1≤x,y≤n),输出某个函数f(x,y)。有位选手想交表(即事先计算出所有的f(x,y),写在源代码里),但是表太大了,源代码超过了比赛的限制,需要精简。
好在那道题目有一个性质,使得很容易根据f(x,y)算出f(x*k, y*k)(其中k是任意正整数),这样有一些f(x,y)就不需要存在表里了。
输入n(n≤50000),你的任务是统计最简的表里有多少个元素。例如,n=2时有3个:(1,1), (1,2), (2,1)。
【分析】
本题的本质是:输入n,有多少个二元组(x,y)满足:1≤x,y≤n,且x和y互素。不难发现除了(1,1)之外,其他二元组(x,y)中的x和y都不相等。设满足x<y的二元组有f(n)个,那么答案就是2f(n)+1。
对照欧拉函数的定义,可以得到f(n)=phi(2)+phi(3)+…+phi(n),时间复杂度为O(nloglogn)。
10.2.3 编码与解码
两个a、一个b和一个c组成的所有串可以按照字典序编号为:
aabc(1)、aacb(2)、abac(3)、…、cbaa(12)
任给一个字符串,能否方便地求出它的编号呢?例如,输入acab,则应输出5。
下面直接求解一般情况的问题(并不限定字母的种类和个数)。设输入串为S,记d(S)为S的各个排列中,字典序比S小的串的个数,则可以用递推法求解d(S),如图10-2所示。
其中边上的字母表示“下一个字母”,f(x)表示多重集x的全排列个数。例如,根据第一个字母,可以把字典序小于caba的字符串分为3种:以a开头的,以b开头的,以c开头的,分别对应d(caba)的3棵子树。以a开头的所有串的字典序都小于caba,所以剩下的字符可以任意排列,个数为f(cba);同理,以b开头的所有串的字典序也都小于caba,个数为f(caa);以c开头的串字典序不一定小于caba,关键要看后3个字符,因此这部分的个数为d(aba),还需要继续往下分。
至于f函数的求解,大部分组合数学书籍中均有介绍:设字符一共有k类,个数分别为n1, n2,…, nk,则这个多重集的全排列个数为
。
不难算出,
,其他f值分别为f(cba)=6,f(b)=1,故d(caba)=f(cba)+ f(caa)+f(b)=3+6+1=10。既然“比它小”的个数是10,序号自然就是11了。
“给物体一个编号”称为编码,同理也有“解码”,即根据序号构造出这个物体。这个过程和刚才的很接近:依次确定各个位置上的字母即可。例如,要求出序号为8(因此有7个比它小)的字符串,推理过程如图10-3所示。
| ![]() |
| 图10-2 字符串编码的递推过程 | 图10-3 字符串解码的递推过程 |
例题10-8 密码(Password, ACM/ICPC Daejon 2010, UVa1262)
给两个6行5列的字母矩阵,找出满足如下条件的“密码”:密码中的每个字母在两个矩阵的对应列中均出现。例如,左数第2个字母必须在两个矩阵中的左数第2列中均出现。例如,图10-4中,COMPU和DPMAG都满足条件。
| ![]() |
图10-4 满足条件的密码
字典序最小的5个满足条件的密码分别是:ABGAG、ABGAS、ABGAU、ABGPG和ABGPS。给定k(1≤k≤7777),你的任务是找出字典序第k小的密码。如果不存在,输出NO。
【分析】
本题是一个经典的解码问题。首先把不可能出现在答案中的字母排除。例如在上面的例子中,第1个字母只能是{A,C,D,W},第2个字母只能是{B,O,P},第3个字母只能是{G,M,O,X},第4个字母只能是{A,P},第5个字母只能是{G,S,U}。
不管第1个字母是多少,后4个字母都有3*4*2*3=72种可能,因此当k≤72时,第1个字母是A,当72<k≤144时第1个字母是C,如此等等。再用同样的方法确定第2,3,4,5个字母即可。
由于k≤7777,本题还有一个取巧的方法:直接按照字典序从小到大的顺序递归一个一个的枚举。虽然代码比递推法要长,但是由于思维难度小,往往能在更短的时间内写完、写对。
10.2.4 离散概率初步
关于概率有一套很深的理论,不过很多和概率相关的问题并不需要特别的知识,熟悉排列组合就够了。
第1个例子是:连续抛3次硬币,恰好有两次正面的概率是多少?用H和T来表示正面和背面(取自英文单词head和tail),则一共有8种可能的情况:HHH、HHT、HTH、HTT、THH、THT、TTH、TTT。根据我们对硬币的认识,这8种情况出现的可能性相同,概率各为 1/8。用概率论的专业术语说,这里的{HHH、HHT、HTH、HTT、THH、THT、TTH、TTT}称为样本空间(Sample Space)。所求的是“恰好有两次正面”这个事件(Event)的概率。借助于集合的记号,这个事件可以表示为{HHT, HTH, THH},其概率为3/8。
提示10-5:如果样本空间由有限个等概率的简单事件组成,事件E的概率可以用组合计数的方法得到:
。
第2个例子是:如果一间屋子里有23个人,那么“至少有两个人的生日相同”的概率超过50%。为了简单起见,假定已知每个人的生日都不是2月29日。
尽管看上去复杂了许多,其实这个例子和抛硬币是类似的。每个人的生日是365天中等概率随机选择的,因此样本空间大小|S|=36523。接下来需要计算“至少有两个人生日相同”的情况有多少种。这个数目不太好直接统计,所以统计“任何两个人的生日都不相同”的数目,然后用总数减去它即可。公式不难得到:

不管是
还是36523都无法储存在int或者long long中,但概率是实数,并且此处并不需要太高的精度,所以可以直接计算,例如:
double P(int n, int m) {
double ans = 1.0;
for(int i = 0; i < m; i++) ans *= (n-i);
return ans;
}
double birthday(int n, int m) {
double ans = P(n, m);
for(int i = 0; i < m; i++) ans /= n;
return 1 - ans;
}
函数birthday(365,23)的返回值为0.5073,即50.73%。别高兴得太早,我们来算一算birthday(365,365)。直观上,365个人中几乎肯定会有两个人的生日相同,因此birthday(365,365)应该返回一个很接近1的值。可结果呢?很不幸,返回值为-1.#INF0000——连double都溢出了。
解决方案是边乘边除,而不是连着乘m次,然后再连着除m次。例如:
double birthday(int n, int m) {
double ans = 1.0;
for(int i = 0; i < m; i++) ans *= (double)(n-i) / n;
return 1 - ans;
}
本例说明:正如数论和组合计数中要注意int和long long溢出一样,在概率计算中要注意double溢出。顺便说一句,这个“改进版”程序其实有个直接的概率意义:
其中,Ei表示“第i个人的生日不和前面的人重复”这个事件。上面的公式用到了这样一个结论:如果有n个相互独立的事件,则它们同时发生的概率是每个事件单独发生的概率的乘积,像计数中的乘法原理一样。看上去很直观吧?但严格的定义需要用到“条件概率”的知识。
条件概率。在概率计算中,条件概率扮演了重要的作用。公式如下:
P(A|B) = P(AB) | P(B)
这里,P(A|B)是指,在事件B发生的前提下,事件A发生的概率,而P(AB)是指两个事件A和B同时发生的概率。前面所说的两个事件AB独立就是指P(AB)=P(A)P(B)。
条件概率中还有一个重要的公式,即贝叶斯公式:P(A|B)=P(B|A) * P(A)/P(B)
全概率公式。计算概率的一种常用方法是:样本空间S分成若干个不相交的部分B1, B2,…, Bn,则P(A)=P(A|B1)*P(B1) + P(A|B2)*P(B2)+…+P(A|Bn)*P(Bn)。
公式看上去复杂,但其实思路很简单。例如,参加比赛,得一等奖、二等奖、三等奖和优胜奖的概率分别为0.1、0.2、0.3和0.4,这4种情况下,你会被妈妈表扬的概率分别为1.0、0.8、0.5、0.1,则你被妈妈表扬的总概率为0.1*1.0+0.2*0.8+0.3*0.5+0.4*0.1=0.45。使用全概率公式的关键是“划分样本空间”,只有把所有可能情况不重复、不遗漏地进行分类,并算出每个分类下事件发生的概率,才能得出该事件发生的总概率。
例题10-9 决斗(Headshot, ACM/ICPC NEERC 2009, UVa1636)
首先在手枪里随机装一些子弹,然后抠了一枪,发现没有子弹。你希望下一枪也没有子弹,是应该直接再抠一枪(输出SHOOT)呢,还是随机转一下再抠(输出ROTATE)?如果两种策略下没有子弹的概率相等,输出EQUAL。
手枪里的子弹可以看成一个环形序列,开枪一次以后对准下一个位置。例如,子弹序列为0011时,第一次开枪前一定在位置1或2(因为第一枪没有子弹),因此开枪之后位于位置2或3。如果此时开枪,有一半的概率没有子弹。序列长度为2~100。
【分析】
直接抠一枪没子弹的概率是一个条件概率,等于子串00的个数除以00和01总数(也就是0的个数)。转一下再抠没子弹的概率等于0的比率。
设子串00的个数为a,0的个数为b,则两个概率分别是a/b和b/n。问题就是比较an和b2。前者大就是SHOOT,后者大就是ROTATE。
例题10-10 奶牛和轿车(Cows and Cars, UVa10491)
有这么一个电视节目:你的面前有3个门,其中两扇门里是奶牛,另外一扇门里则藏着奖品——一辆豪华小轿车。在你选择一扇门之后,门并不会立即打开。这时,主持人会给你个提示,具体方法是打开其中一扇有奶牛的门(不会打开你已经选择的那个门,即使里面是牛)。接下来你有两种可能的决策:保持先前的选择,或者换成另外一扇未开的门。当然,你最终选择打开的那扇门后面的东西就归你了。
在这个例子里面,你能得到轿车的概率是2/3(难以置信吧!),方法是总是改变自己的选择。2/3这个数是这样得到的:如果选择了两个牛之一,你肯定能换到车前面的门,因为主持人已经让你看了另外一个牛;而如果你开始选择的就是车,就会换成剩下的牛并且输掉奖品。由于你的最初选择是任意的,因此选错的概率是2/3。也正是这2/3的情况让你能换到那辆车(另外1/3的情况你会从车切换到牛)。
现在把问题推广一下,假设有a头牛,b辆车(门的总数为a+b),在最终选择前主持人会替你打开c个有牛的门(1≤a≤10000,1≤b≤10000,0≤c<a),输出“总是换门”的策略下,赢得车的概率。
【分析】
使用全概率公式。打开c个牛门后,还剩a-c头牛,未开的门总数是a+b-c,其中有a+b-c-1个门可以换(称为“可选门”),换到门的概率就是“可选门”的总数除以“可选门中车门的个数”。
情况1:一开始选了牛(概率a / (a+b)),则可选门中车门有b个。这种情况的总概率为a/(a+b) * b/(a+b-c-1)。
情况2:一开始选了车(概率为b / (a+b)),则可选门中车门只有b-1个,概率为b/(a+b) * (b-1)/(a+b-c-1)。
加起来得(ab+b(b-1)) / ((a+b)(a+b-c-1))。
例题10-11 条件概率(Probability|Given, UVa11181)
有n个人准备去超市逛,其中第i个人买东西的概率是Pi。逛完以后你得知有r个人买了东西。根据这一信息,请计算每个人实际买了东西的概率。输入n(1≤n≤20)和r(0≤r≤n),输出每个人实际买了东西的概率。
【分析】
“r个人买了东西”这个事件叫E,“第i个人买东西”这个事件为Ei,则要求的是条件概率P(Ei|E)。根据条件概率公式,P(Ei|E) = P(EiE) / P(E)。
P(E)依然可以用全概率公式。例如,n=4,r=2,有6种可能:1100, 1010, 1001, 0110, 0101, 0011,其中1100的概率为P1*P2*(1-P3)*(1-P4),其他类似,设置A[k]表示第k个人是否买东西(1表示买,0表示不买),则可以用递归的方法枚举恰好有r个A[k]=1的情况。
如何计算P(EiE)呢?方法一样,只是枚举的时候要保证第A[i]=1。不难发现,其实可以用一次枚举就计算出所有的值。用tot表示上述概率之和,sum[i]表示A[i]=1的概率之和,则答案为P(Ei)/P(E)=sum[i]/tot。
例题10-12 纸牌游戏(Double Patience, NEERC 2005, UVa1637)
36张牌分成9堆,每堆4张牌。每次可以拿走某两堆顶部的牌,但需要点数相同。如果有多种拿法则等概率的随机拿。例如,9堆顶部的牌分别为KS, KH, KD, 9H, 8S, 8D, 7C, 7D, 6H,则有5种拿法(KS,KH), (KS,KD), (KH,KD), (8S,8D), (7C,7D),每种拿法的概率均为1/5。如果最后拿完所有牌则游戏成功。按顺序给出每堆牌的4张牌,求成功概率。
【分析】
用9元组表示当前状态,即每堆牌剩的张数,状态总数为59=1953125。设d[i]表示状态i对应的成功概率,则根据全概率公式,d[i]为后继状态的成功概率的平均值,按照动态规划的写法计算即可。