ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

排列组合入门:从数数思维到组合计数实战指南

排列组合入门:从数数思维到组合计数实战指南 排列组合这个东西我在带新人的时候发现一个特别有意思的现象很多人一听到“排列组合”四个字第一反应就是“这不是高中内容吗”然后紧接着第二反应就是“但我当年就没学明白”。这两个反应其实一点都不矛盾因为排列组合的入门门槛确实不高但它的思维方式和大多数人习惯的“套公式”完全不是一回事。它更像是一种“数数的艺术”——你得先想清楚到底在数什么再决定用什么工具去数。这篇文章我想从一线教学和实际应用的角度把排列组合的基础彻底拆开讲一遍。不管你是正在准备考试的学生还是工作中突然需要用到组合计数来估算方案数量的开发者又或者只是想把这块知识重新捡起来的人都能从下面这些内容里找到可以直接用的东西。我会尽量避开那种“定义加例题”的教科书写法而是按照一个正常人理解问题的顺序来展开先搞清楚为什么要学它再搞清楚它到底在解决什么问题最后才是具体怎么算。1. 先搞清楚排列组合到底在解决什么问题1.1 从“数数”说起为什么需要排列组合我们从小到大都在数数但绝大多数时候数的东西都是“一眼能数清”的。比如桌上有几个苹果、停车场有几辆车这种数数的特点是对象是确定的你只需要逐个点过去就行。但现实中还有另一类问题对象本身是“构造出来的”你没法直接点因为你要数的不是已经存在的东西而是“所有可能的情况”。举个最简单的例子你手里有三张不同的牌要从中抽出两张排成一排一共有多少种排法你可以硬数——假设三张牌是A、B、C那么所有排法就是AB、AC、BA、BC、CA、CB一共6种。这个规模小硬数没问题。但如果牌变成10张要抽3张排成一排呢硬数就不现实了因为数量会急剧膨胀。这时候就需要一套系统的方法来“算”出结果而不是“数”出结果。排列组合就是干这个的。我经常跟人说排列组合的本质是“用乘法代替穷举”。你把所有情况按照某种规则拆成几个步骤每个步骤有若干种选择然后把每一步的选择数乘起来就得到了总数。这个“乘法原理”是整个排列组合的地基后面所有的公式都是从它推导出来的。1.2 排列和组合的核心区别顺序到底重不重要排列和组合最容易混淆的地方就是“顺序”这个概念。我用一个生活化的场景来解释假设你要从5个人里选2个人去参加一个活动。如果这个活动是“一个人当正组长一个人当副组长”那么选出来的两个人是有角色分工的谁当正组长谁当副组长是不一样的。这种情况下先选甲再选乙和先选乙再选甲是两种不同的结果这就是排列。如果这个活动只是“选2个人去参加”不区分角色那么选出来的两个人就是一个整体甲乙和乙甲是同一种结果这就是组合。所以判断的标准非常直接交换两个元素的顺序如果结果变了就是排列如果结果没变就是组合。这个判断方法看起来简单但实际做题的时候很多人会在“隐含着顺序”的题目上翻车。比如“从5个人里选2个人分别去两个不同的城市”虽然题目没有明说“排列”但两个城市是不同的所以去A城市和去B城市是两种不同的安排本质上还是排列。注意很多排列组合题目的难点不在于计算而在于判断这到底是排列还是组合。我的经验是读完题目后先问自己一句“把选出来的两个东西换个位置结果还一样吗”如果一样就是组合不一样就是排列。1.3 加法原理和乘法原理所有公式的源头在讲具体公式之前必须把两个基本原理说清楚因为后面所有的排列数公式、组合数公式都是从这两个原理推出来的。加法原理解决的是“分类”问题。如果你完成一件事有若干种不同的方案这些方案之间是“互斥”的——也就是说选了方案一就不能选方案二——那么总的方法数就是把各种方案的方法数加起来。比如从家到公司可以坐地铁3条线路或者坐公交2条线路那你一共有325种选择。关键词是“或者”。乘法原理解决的是“分步”问题。如果你完成一件事需要分成若干步骤每一步都必须完成那么总的方法数就是把每一步的方法数乘起来。比如从家到公司必须先坐地铁再转公交地铁有3条线路可选公交有2条线路可选那你一共有3×26种走法。关键词是“并且”或者“然后”。这两个原理看起来简单到不需要专门讲但我见过太多人在复杂题目里搞混。一个实用的判断方法是如果你能把所有情况“分成几类”每类之间不重叠那就用加法如果你能把一件事“拆成几步”每步必须依次完成那就用乘法。实际题目中往往是加法和乘法混着用先分类再分步或者先分步再分类。2. 排列的核心公式与实操推导2.1 排列数公式是怎么来的排列数记作A(n, m)或者P(n, m)表示从n个不同元素中取出m个元素按照一定顺序排成一列的方法数。注意这里的n和m都是非负整数且m≤n。这个公式的推导其实非常直观。假设你有n个不同的球要从中取出m个排成一排。第一个位置可以从n个球里任选一个所以有n种选择第二个位置从剩下的n-1个球里选有n-1种选择第三个位置有n-2种选择……一直到第m个位置此时前面已经用掉了m-1个球剩下n-(m-1)个球所以有n-m1种选择。根据乘法原理把这m个步骤的选择数乘起来A(n, m) n × (n-1) × (n-2) × ... × (n-m1)这个乘积一共有m个因子从n开始依次递减。比如A(5, 3) 5 × 4 × 3 60。如果m等于n也就是把n个元素全部拿出来排列那么A(n, n) n × (n-1) × ... × 2 × 1 n!这就是阶乘的定义。所以排列数公式也可以用阶乘来表示A(n, m) n! / (n-m)!这个形式在计算和化简的时候更方便。比如A(5, 3) 5! / 2! 120 / 2 60结果一样。2.2 全排列与阶乘n个元素全部排队全排列是排列的一种特殊情况指的是把n个不同的元素全部拿出来排成一排。根据上面的推导全排列的数量就是n!。阶乘的增长速度非常恐怖这一点在实际应用中特别重要。我列几个数你感受一下nn!1122364245120672075040840320936288010362880010个元素的全排列就已经超过360万种了20个元素的全排列大约是2.4×10^18这个数字已经超过了地球上沙子的总数。所以在实际编程或者算法设计中如果一个问题涉及到全排列你几乎不可能用穷举的方式来解决必须找更聪明的办法。实操心得在写代码处理排列问题的时候如果n超过10就要非常警惕了。我见过有人在面试里写了一个全排列的递归面试官问“如果n20呢”直接卡住。正确的回答应该是全排列的时间复杂度是O(n!)n20时计算量不可接受需要考虑其他方案。2.3 有重复元素的排列去重怎么处理前面讲的都是n个元素互不相同的情况。但现实中经常遇到有重复元素的情况比如单词“LEVEL”里的字母L出现了两次E出现了两次。这时候如果直接算全排列会把重复的情况也算进去。处理方法其实很简单先按照所有元素都不相同来算得到n!然后除以每个重复元素内部的全排列数。假设有k个不同的元素第一个元素重复了n1次第二个重复了n2次……第k个重复了nk次那么总的排列数为n! / (n1! × n2! × ... × nk!)以“LEVEL”为例一共5个字母L重复2次E重复2次V出现1次。所以排列数 5! / (2! × 2! × 1!) 120 / 4 30。这个公式的逻辑是在n!的全排列中每一组“实际相同但被当作不同元素排列”的情况被重复计算了n1! × n2! × ... × nk!次。除以这个数就得到了去重后的结果。2.4 排列的典型应用场景排列在实际中出现的频率比很多人想象的要高。我举几个常见的场景场景一密码组合数估算。假设一个密码由4位数字组成每位可以是0-9那么总共有10^4 10000种组合。注意这里每一位都可以重复所以不是排列问题而是“可重复排列”。如果密码要求4位数字互不相同那就是从10个数字里取4个排列A(10, 4) 10 × 9 × 8 × 7 5040种。场景二赛程安排。8个队伍进行单循环赛每两个队伍之间比赛一场问总共需要安排多少场比赛。这是组合问题不是排列问题因为A队对B队和B队对A队是同一场比赛。答案是C(8, 2) 28场。场景三座位排列。5个人坐一排5个座位有多少种坐法这是全排列5! 120种。如果其中有一对情侣必须坐在一起那就把这对情侣“捆绑”成一个整体变成4个“元素”的全排列再乘以情侣内部2个人的排列数即4! × 2! 48种。3. 组合的核心公式与实操推导3.1 组合数公式的推导逻辑组合数记作C(n, m)表示从n个不同元素中取出m个元素不区分顺序的方法数。组合和排列的关系非常直接从n个元素中取出m个元素如果先按排列算得到A(n, m)但每一种组合在排列中被计算了m!次因为选出来的m个元素内部有m!种排列方式。所以C(n, m) A(n, m) / m! n! / (m! × (n-m)!)这个公式是整个组合数学里最重要的公式之一必须记牢。我个人的记忆方法是分子是n的阶乘分母是“取出的m的阶乘”乘以“剩下的(n-m)的阶乘”。3.2 组合数的对称性与实用技巧组合数有一个非常漂亮的对称性质C(n, m) C(n, n-m)。也就是说从n个元素里选m个和从n个元素里选(n-m)个不选方法数是一样的。这个性质在计算的时候特别有用因为当m比较大的时候算C(n, n-m)往往更简单。比如C(10, 8) C(10, 2) 10 × 9 / 2 45。直接算C(10, 8)的话分母是8!计算量大很多。所以我的习惯是拿到组合数先看m是否大于n/2如果是就转换成C(n, n-m)再算。组合数还有几个常用的恒等式在做化简和证明题的时候经常用到C(n, 0) C(n, n) 1C(n, 1) nC(n, m) C(n-1, m-1) C(n-1, m)帕斯卡恒等式最后这个恒等式是杨辉三角的理论基础也是很多组合证明题的核心工具。它的组合意义是从n个元素里选m个可以分成两种情况——选定了某个特定元素那还需要从剩下的n-1个里选m-1个没选那个特定元素那就从剩下的n-1个里选m个。3.3 组合数的计算实操从手算到代码小规模的组合数手算没什么问题但规模一大就需要借助工具了。我分别说一下手算技巧和代码实现。手算的时候关键是“先约分再乘”。比如算C(10, 3)不要傻乎乎地算10! / (3! × 7!)而是写成(10 × 9 × 8) / (3 × 2 × 1)然后约分10和(3×2)约掉6剩……其实更简单的做法是分子从10开始往下写3个数分母从3开始往下写3个数然后逐个约分。10/1109/338/24最后10×3×4120。这样每一步都是整数运算不容易出错。代码实现的话最简单的写法是用阶乘函数但阶乘增长太快n稍微大一点就会溢出。更好的做法是用递推或者边乘边除def comb(n, m): if m n - m: m n - m result 1 for i in range(m): result result * (n - i) // (i 1) return result这段代码的核心是result result * (n - i) // (i 1)每一步都保证整除避免了浮点数误差。而且先把m转换成n-m减少了循环次数。注意在Python里算组合数其实可以直接用math.comb(n, m)这是Python 3.8以后内置的函数底层实现比手写的更高效。但如果面试要求手写上面那个模板可以直接用。3.4 组合的典型应用场景组合在实际中的应用同样非常广泛我挑几个有代表性的说一下。场景一抽奖概率计算。从50个号码里抽6个号码总共有C(50, 6) 15890700种组合。如果你买了一张彩票中头奖的概率就是1/15890700。这个数字直观地告诉你为什么彩票很难中。场景二团队组建。一个项目组需要从10个候选人里选4个人不考虑角色分工那么有C(10, 4) 210种选法。如果这4个人还要分配不同的角色比如项目经理、开发、测试、设计那就变成了排列问题有A(10, 4) 5040种。场景三图的边数计算。在一个有n个顶点的无向图中任意两个顶点之间最多有一条边所以总边数最多是C(n, 2) n(n-1)/2。这个结论在图论和网络分析里经常用到。4. 排列组合的混合应用与解题策略4.1 先分类还是先分步解题顺序的判断实际题目往往不是单纯的排列或单纯的组合而是两者混合甚至需要先分类再分步。这时候解题顺序就很重要了。我的经验是先看题目能不能“分类”。如果题目描述了几种不同的情况每种情况之间是互斥的那就先分类每一类内部再考虑用排列还是组合。如果题目描述的是一个流程需要依次完成几个步骤那就先分步每一步内部再考虑排列还是组合。举个例子从5个男生和4个女生中选3个人参加比赛要求至少有1个女生。这个题目可以分类恰好1个女生、恰好2个女生、恰好3个女生。每一类内部用组合计算然后加起来。也可以反过来想总的选法减去全是男生的选法即C(9, 3) - C(5, 3) 84 - 10 74。两种方法结果一样但第二种更简洁。这就是“正难则反”的策略。4.2 捆绑法与插空法处理特殊要求的利器排列组合里有两个非常经典的技巧几乎每次考试都会用到。捆绑法用于处理“某些元素必须相邻”的问题。做法是把这些必须相邻的元素“捆”成一个整体当作一个元素参与排列然后再乘以这些元素内部的排列数。比如5个人排队甲乙必须相邻那就把甲乙捆成一个人变成4个元素的全排列再乘以甲乙内部的2!结果是4! × 2! 48。插空法用于处理“某些元素必须不相邻”的问题。做法是先排列其他元素然后把这些不相邻的元素插入到已排好元素的空隙中。比如5个人排队甲乙不能相邻那就先排其他3个人有3! 6种排法这3个人形成4个空隙包括两端从4个空隙中选2个放入甲乙有A(4, 2) 12种总共6 × 12 72种。这两个方法的核心思想都是“转化”——把不熟悉的问题转化成熟悉的问题。捆绑法是“合并”插空法是“插入”方向不同但本质一样。4.3 分组分配问题最容易出错的一类分组分配是排列组合里最容易出错的一类问题因为它涉及到“组是否区分”和“人是否区分”两个维度。我把它分成几种情况来说情况一分组不分配组不区分。比如把6个人分成3组每组2人。这时候组和组之间没有区别所以要先算C(6, 2) × C(4, 2) × C(2, 2)然后除以3!因为3个组之间可以互换。结果是15 × 6 × 1 / 6 15。情况二分组不分配组有区分。比如把6个人分成3组分别去A、B、C三个不同的地方每组2人。这时候组是有区别的所以不需要除以3!直接C(6, 2) × C(4, 2) × C(2, 2) 90。情况三分组后分配。比如把6个人分成3组每组2人然后分别去A、B、C三个地方。这其实和情况二是一样的因为组有区分就相当于已经分配了。判断是否需要除以组数的阶乘关键看“组和组之间是否可区分”。如果题目说“分成三组”通常是不区分的如果说“分成甲乙丙三组”或者“分到三个不同的地方”那就是区分的。避坑技巧分组问题里如果每组人数相同一定要检查是否需要除以组数的阶乘。我见过太多人在这里丢分。一个简单的验证方法是用一个小规模的具体例子手动枚举一下看看算出来的数是否合理。4.4 常见错误与排查清单排列组合的题目算错的原因往往不是公式记错了而是“理解错了题意”。我整理了一个常见错误清单做题的时候可以对照检查错误类型典型表现纠正方法排列组合混淆把组合当排列算结果偏大问自己“交换顺序结果变不变”重复计数分类时各类之间有重叠检查分类是否互斥遗漏情况分类不完整漏了某些情况用“正难则反”验证分组未去重相同人数的组没有除以阶乘检查组是否可区分特殊元素未处理忘记捆绑或插空读题时圈出“相邻”“不相邻”等关键词可重复与不可重复混淆把可重复排列当成了普通排列检查元素是否可以重复使用这个清单我建议在做题前扫一眼做完后再扫一眼能避免大部分低级错误。5. 从入门到实战排列组合的学习路径建议5.1 入门阶段应该重点掌握什么如果你是刚开始学排列组合我建议不要一上来就刷难题。先把下面这几件事做扎实第一把加法原理和乘法原理彻底搞清楚。这两个原理看起来简单但它们是所有公式的根基。我见过很多人公式背得滚瓜烂熟但遇到稍微变形的题目就不知道从何下手根本原因就是原理没吃透。第二把排列数和组合数的公式自己推导一遍。不要直接背结论而是按照“第一个位置有几种选择、第二个位置有几种选择”的思路自己推。推导一遍之后你就再也不会把A(n, m)和C(n, m)的公式搞混了。第三把捆绑法和插空法练熟。这两个技巧是解决“特殊要求”问题的万能钥匙考试中出现的频率极高。第四把分组分配问题的几种情况搞清楚。这是最容易出错的地方也是拉开差距的地方。5.2 进阶阶段需要补充的工具基础打牢之后可以开始接触一些进阶内容容斥原理用于处理“至少满足一个条件”的问题。比如“1到100中能被2或3整除的数有多少个”就需要用容斥原理能被2整除的有50个能被3整除的有33个能被6整除的有16个所以答案是50 33 - 16 67个。隔板法用于处理“把n个相同元素分给m个不同对象”的问题。比如“把10个相同的苹果分给3个人每人至少1个”就是在10个苹果之间的9个空隙中选2个放隔板答案是C(9, 2) 36。递推与动态规划用于处理大规模计数问题。当n很大时直接套公式可能不现实需要找递推关系。比如“爬楼梯问题”每次可以爬1级或2级爬到第n级有多少种方法。这个问题的递推关系是f(n) f(n-1) f(n-2)本质上和斐波那契数列是一样的。5.3 编程中的排列组合实现如果你是用编程来解决排列组合问题有几个实用的建议生成全排列可以用递归或者迭代器。Python的itertools.permutations和itertools.combinations可以直接用但要注意它们返回的是迭代器数据量大的时候不会一次性占用大量内存。计算组合数的时候如果n比较大比如n 20直接用阶乘会溢出。这时候可以用递推公式C(n, m) C(n-1, m-1) C(n-1, m)来打表或者用前面提到的边乘边除的方法。如果需要对结果取模比如模10^97那就需要在每一步乘法之后都取模同时用费马小定理求逆元来处理除法。这个在算法竞赛里非常常见。MOD 10**9 7 def comb_mod(n, m): if m n: return 0 if m n - m: m n - m numerator 1 denominator 1 for i in range(m): numerator numerator * (n - i) % MOD denominator denominator * (i 1) % MOD return numerator * pow(denominator, MOD - 2, MOD) % MOD这段代码用费马小定理求逆元适用于MOD是质数的情况。如果你不熟悉这个可以先记住模板用的时候直接套。5.4 我踩过的坑和总结的经验最后分享几个我在学习和使用排列组合过程中踩过的坑希望能帮你少走弯路。第一个坑是“想当然”。看到“从5个人里选2个”就条件反射地写C(5, 2)结果题目问的是“选2个分别担任不同职务”应该是A(5, 2)。这个错误的根源是读题不仔细没有确认“顺序是否重要”。后来我养成了一个习惯读完题目后用一句话把题目翻译成自己的语言确认理解了再动笔。第二个坑是“分类不完整”。有一次做一道题分了三种情况算完觉得没问题结果一对答案发现少了一种。后来我学会了一个验证方法用另一种分类方式再算一遍如果结果一致就说明分类完整。或者用“正难则反”的方法验证。第三个坑是“计算粗心”。排列组合的计算涉及大量乘除法中间任何一步算错都会导致最终结果错误。我的应对方法是每一步都写清楚不要跳步算完之后用估算验证一下数量级是否合理。排列组合这门学问入门的时候觉得就是几个公式但越深入越发现它的思维方式才是最有价值的东西。它训练的是“把复杂问题拆解成简单步骤”的能力这种能力在编程、产品设计、甚至日常决策中都用得上。希望这篇内容能帮你把基础打扎实后面遇到更复杂的问题时也有信心去拆解。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进