程序员的数学(五)排列组合:编程中解决计数问题的核心思维

文章目录
欢迎回到 “程序员的数学” 系列专栏。前面我们陆续掌握了 “余数的分组简化”“归纳法的无穷验证”“逻辑的无歧义计数”—— 今天要讲的排列组合,是这些思维的综合应用,专门解决 “计数问题”。比如编程中 “密码有多少种可能”“任务有多少种调度方式”“从列表中选元素有多少种组合”,本质都是排列组合问题。
你可能会觉得 “排列组合不就是背公式吗?C (n,k) 等于多少,P (n,k) 等于多少”—— 但实际编程中,死记公式很容易出错,比如 “到底要不要考虑顺序”“是否允许重复选择”,这些才是计数的关键。今天我们从 “不遗漏、不重复” 的核心原则出发,用生活例子 + 编程场景,把排列组合的逻辑讲透,让你下次遇到计数问题时,能先理清 “计数对象的性质”,再选择合适的方法,而不是硬套公式。
一、为什么程序员需要排列组合?因为 “计数” 是编程的高频需求
先看几个编程中常见的计数场景,感受排列组合的实用性:
- 密码破解:6 位数字密码有多少种可能?(10^6=1000000 种,乘法法则);
- 接口测试:一个接口有 3 个参数,每个参数有 2 种取值,要测所有组合需要多少次?(2×2×2=8 次,乘法法则);
- 团队组建:从 10 个候选人中选 3 人组成开发团队,有多少种选法?(C (10,3)=120 种,组合);
- 任务排序:5 个任务按优先级排序,有多少种排列方式?(5!=120 种,置换)。
这些问题的核心都是 “不重复、不遗漏地计数”—— 如果靠手数,数字大了根本数不过来;如果硬写循环遍历,遇到复杂场景(比如选 3 人且不考虑顺序)很容易出错。排列组合就是帮我们 “抽象计数逻辑”,用数学方法快速算出结果,再落地到代码的工具。
二、计数的基础:不遗漏、不重复(从植树问题说起)
在讲排列组合之前,先回顾一个前面提到的 “植树问题”—— 它揭示了计数的核心陷阱:边界处理。比如 “10 米路,每隔 1 米种一棵树,共种多少棵?” 答案是 11 棵(0 到 10 米共 11 个点),因为我们容易遗漏 “起点 0” 或 “终点 10”。
编程中的计数问题也常遇到类似陷阱,比如 “从数组索引 0 到 9 选 2 个不同的索引,有多少种组合?” 如果直接算 10×9=90,就错了(因为 (0,1) 和 (1,0) 是同一组合),正确是 C (10,2)=45 种。
所以计数前,先明确两个问题:
- 是否考虑顺序?(比如 “选索引 (0,1)” 和 “(1,0)” 是否算同一种?)
- 是否允许重复选择?(比如 “选索引 (0,0)” 是否合法?)
这两个问题的答案,决定了用 “排列” 还是 “组合”,以及是否用 “重复排列”“重复组合”。
三、加法法则与乘法法则:计数的两大基石
排列组合的所有公式,本质都是 “加法法则” 和 “乘法法则” 的延伸。先掌握这两个基础法则,再复杂的计数问题都能拆解。
1. 加法法则:分类计数(“要么… 要么…”)
定义:如果完成一件事有 A、B 两类方法,A 类有 m 种,B 类有 n 种,且 A、B 没有重叠,那么总方法数是 m+n 种。核心是 “分类”—— 每类方法都能独立完成任务,且不重复。
例子 1:选水果
你去水果店,要么选苹果(有 3 种品种),要么选香蕉(有 2 种品种),总共有 3+2=5 种选法。
例子 2:编程中的功能选择
一个工具类有两种排序功能:冒泡排序(1 种实现)、快速排序(2 种实现,递归 / 迭代),总共有 1+2=3 种排序方式。
代码示例:统计合法的输入类型
判断用户输入是否为 “数字” 或 “字母”,统计合法输入的类型数:
python
def count_valid_input_types(input_str):
# 分类:数字(1种类型)、字母(1种类型),无重叠
has_digit = any(c.isdigit() for c in input_str)
has_alpha = any(c.isalpha() for c in input_str)
count = 0
if has_digit:
count += 1 # 数字类
if has_alpha:
count += 1 # 字母类
return count
# 测试:"123a"有数字和字母→2种;"123"只有数字→1种
print(count_valid_input_types("123a")) # 输出2
print(count_valid_input_types("123")) # 输出1
2. 乘法法则:分步计数(“先… 再…”)
定义:如果完成一件事需要分两步,第一步有 m 种方法,第二步有 n 种方法,且第一步的结果不影响第二步,那么总方法数是 m×n 种。核心是 “分步”—— 两步都完成才算完成任务,步骤间独立。
例子 1:搭配衣服
上衣有 2 件(T 恤、衬衫),裤子有 3 件(牛仔裤、运动裤、西裤),总搭配数是 2×3=6 种(T 恤 + 牛仔裤、T 恤 + 运动裤、T 恤 + 西裤、衬衫 + 牛仔裤、衬衫 + 运动裤、衬衫 + 西裤)。
例子 2:编程中的参数组合
一个接口有两个参数:size(可选 “small”、“medium”、“large”,3 种)、color(可选 “red”、“blue”,2 种),总参数组合数是 3×2=6 种,需要测试 6 次才能覆盖所有情况。
代码示例:生成参数组合
生成所有参数组合的列表,方便测试:
python
def generate_param_combinations(sizes, colors):
combinations = []
# 分步:先选size,再选color
for size in sizes:
for color in colors:
combinations.append((size, color))
return combinations
# 测试:size=["small","medium"], color=["red","blue"]→4种组合
sizes = ["small", "medium"]
colors = ["red", "blue"]
print(generate_param_combinations(sizes, colors))
# 输出[("small","red"), ("small","blue"), ("medium","red"), ("medium","blue")]
加法与乘法的区别:一句话分清
- 加法法则:“或” 关系(要么 A 类,要么 B 类);
- 乘法法则:“与” 关系(先 A 步,再 B 步)。
比如 “选上衣或裤子” 是加法(但实际搭配是乘法,因为要 “上衣与裤子”),“选红色上衣或蓝色上衣” 是加法。
四、置换:n 个元素的全排列(有序、无重复)
定义:将 n 个不同的元素按顺序排列,总共有多少种排法?这就是置换(Permutation of n elements),记为 n!(n 的阶乘)。
1. 置换的推导:从乘法法则出发
以 “3 张牌 A、B、C 的全排列” 为例:
-
第 1 张牌:有 3 种选法(A、B、C);
-
第 2 张牌:选了第 1 张后,剩 2 种选法;
-
第 3 张牌:选了前 2 张后,剩 1 种选法;
总排法 = 3×2×1=6 种(ABC、ACB、BAC、BCA、CAB、CBA)。
推广到 n 个元素:n! = n×(n-1)×(n-2)×…×1,其中 0! 定义为 1(基底条件,对应 “0 个元素的排列只有 1 种:空排列”)。
2. 编程实现:递归生成全排列
利用递归(联系前面的递归思维),每次固定第 k 个元素,递归排列剩下的 n-k 个元素:
python
def permute(arr):
"""生成数组的全排列(递归实现)"""
n = len(arr)
if n == 0:
return [[]] # 基底:空数组的排列是[[]]
if n == 1:
return [arr] # 基底:1个元素的排列是自身
# 递归:固定第0个元素,排列剩下的元素,再将第0个元素插入所有位置
result = []
for i in range(n):
# 固定arr[i]作为第一个元素
fixed = arr[i]
# 剩下的元素(排除arr[i])
remaining = arr[:i] + arr[i+1:]
# 递归排列剩下的元素
for p in permute(remaining):
result.append([fixed] + p)
return result
# 测试:[1,2,3]的全排列→6种
print(permute([1,2,3]))
# 输出[[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]
3. 置换与归纳法:证明 n! 的正确性
用前面学的数学归纳法,证明 “n 个元素的全排列数是 n!”:
-
基底条件:n=1 时,1! =1,正确;
-
归纳条件:假设 k 个元素的全排列数是 k!,证明 k+1 个元素的全排列数是 (k+1)!。
推导:k+1 个元素中,选 1 个作为第一个元素(k+1 种选法),剩下 k 个元素的排列数是 k!(归纳假设),总排法 =(k+1)×k!=(k+1)!,正确。
五、排列:从 n 个元素中选 k 个排列(有序、无重复)
定义:从 n 个不同元素中,选 k 个按顺序排列,总排法数记为 P (n,k)(或 A (n,k))。
1. 排列的推导:乘法法则的延伸
以 “从 5 张牌中选 3 张排列” 为例:
-
第 1 张牌:5 种选法;
-
第 2 张牌:剩 4 种选法;
-
第 3 张牌:剩 3 种选法;
总排法 = 5×4×3=60 种,即 P (5,3)=5×4×3。
推广到一般情况:P(n,k) = n×(n-1)×(n-2)×…×(n-k+1) = n! / (n-k)!(因为 n! = n×(n-1)×…×(n-k+1)×(n-k)!,约分后得到 P (n,k))。
2. 编程实现:生成选 k 个元素的排列
在全排列的基础上,只保留长度为 k 的结果:
python
def permute_k(arr, k):
"""从数组中选k个元素生成排列"""
n = len(arr)
if k == 0:
return [[]]
if k > n:
return [] # 选的数量超过元素总数,无排列
result = []
for i in range(n):
fixed = arr[i]
remaining = arr[:i] + arr[i+1:]
# 递归选k-1个元素排列
for p in permute_k(remaining, k-1):
result.append([fixed] + p)
return result
# 测试:从[1,2,3,4,5]选3个排列→60种(此处只展示前5种)
arr = [1,2,3,4,5]
k = 3
perms = permute_k(arr, k)
print(f"总排列数:{len(perms)}") # 输出60
print("前5种排列:", perms[:5])
# 输出前5种:[[1,2,3], [1,2,4], [1,2,5], [1,3,2], [1,3,4]]
六、组合:从 n 个元素中选 k 个组合(无序、无重复)
定义:从 n 个不同元素中,选 k 个不考虑顺序,总选法数记为 C (n,k)(或 C (n,k))。组合与排列的核心区别是 “无顺序”—— 比如 (1,2) 和 (2,1) 是同一组合,但不同排列。
1. 组合的推导:排列去重
因为 “选 k 个元素的排列数”=“选 k 个元素的组合数”דk 个元素的全排列数”(先选组合,再排序),所以:C(n,k) = P(n,k) / k! = [n! / (n-k)!] / k! = n! / [k!×(n-k)!]
以 “从 5 张牌中选 3 张组合” 为例:C (5,3) = P (5,3) / 3! = 60 / 6 = 10 种(ABC、ABD、ABE、ACD、ACE、ADE、BCD、BCE、BDE、CDE)。
2. 组合的递归公式:用归纳法证明
组合数有一个重要的递归公式:C (n,k) = C (n-1,k-1) + C (n-1,k),这个公式可以用 “是否选第 n 个元素” 来理解:
-
选第 n 个元素:需要从剩下的 n-1 个中选 k-1 个,即 C (n-1,k-1);
-
不选第 n 个元素:需要从剩下的 n-1 个中选 k 个,即 C (n-1,k);
总选法 = 两者之和(加法法则)。
用归纳法证明这个公式:
- 基底条件:k=0 或 k=n 时,C (n,k)=1(选 0 个元素只有 1 种方法,选 n 个元素也只有 1 种方法);
- 归纳条件:假设 C (n-1,k-1) 和 C (n-1,k) 成立,证明 C (n,k) 成立(由加法法则直接推导,正确)。
3. 编程实现:生成选 k 个元素的组合
用递归实现组合生成,基于 “选或不选” 的逻辑:
python
def combine(arr, k):
"""从数组中选k个元素生成组合(递归实现)"""
n = len(arr)
if k == 0:
return [[]]
if k > n:
return []
# 情况1:选第一个元素,再从剩下的n-1个中选k-1个
take_first = [[arr[0]] + c for c in combine(arr[1:], k-1)]
# 情况2:不选第一个元素,再从剩下的n-1个中选k个
skip_first = combine(arr[1:], k)
# 总组合=两种情况之和(加法法则)
return take_first + skip_first
# 测试:从[1,2,3,4,5]选3个组合→10种
arr = [1,2,3,4,5]
k = 3
combs = combine(arr, k)
print(f"总组合数:{len(combs)}") # 输出10
print("所有组合:", combs)
# 输出:[[1,2,3], [1,2,4], [1,2,5], [1,3,4], [1,3,5], [1,4,5], [2,3,4], [2,3,5], [2,4,5], [3,4,5]]
七、实战:排列组合的复杂场景
前面讲的都是 “无重复、有序 / 无序” 的基础场景,实际编程中还会遇到 “允许重复”“带约束条件” 的情况,我们用两个实战例子巩固。
实战 1:重复组合(允许重复选择)
问题:有 A、B、C3 种药品,选 100 粒调剂,每种至少 1 粒,不考虑顺序,有多少种方法?这是 “重复组合” 问题,公式为 C (n+k-1,k-1)(n 种元素,选 k 个,允许重复,每种至少 1 个)。推导:先给每种药品分 1 粒(满足至少 1 粒),剩下 97 粒自由分配,相当于 “3 种元素选 97 粒,允许重复”,公式为 C (3+97-1,97)=C (99,2)=4851 种。
代码实现:重复组合的生成(以 “3 种选 5 粒,每种至少 1 粒” 为例):
python
def combine_with_repetition(elements, k):
"""重复组合:从elements选k个,允许重复,每种至少1个"""
n = len(elements)
if k < n:
return [] # 每种至少1个,k不能小于n
if n == 1:
return [[elements[0]] * k]
# 情况1:选至少1个elements[0],再从所有元素选k-1个(允许重复)
take = [[elements[0]] + c for c in combine_with_repetition(elements, k-1)]
# 情况2:不选elements[0],再从剩下的n-1个选k个(每种至少1个)
skip = combine_with_repetition(elements[1:], k)
return take + skip
# 测试:3种药品选5粒,每种至少1粒→C(3+5-1,5-3)=C(7,2)=21种
drugs = ["A", "B", "C"]
count = 5
combs = combine_with_repetition(drugs, count)
print(f"总组合数:{len(combs)}") # 输出21
print("前5种组合:", combs[:5])
# 输出前5种:[["A","A","A","B","C"], ["A","A","A","C","B"], ["A","A","B","A","C"], ["A","A","B","B","C"], ["A","A","B","C","A"]]
实战 2:带约束的排列(容斥原理)
问题:5 张牌(2 张王牌、J、Q、K)排成一排,至少一端是王牌的排法有多少种?(不区分大小王牌)用 “容斥原理”(联系前面的逻辑章节):总排列数 - 两端都不是王牌的排列数。
- 总排列数:不区分大小王牌,先算区分的情况再除以 2(王牌重复度),即 P (5,5)/2=120/2=60 种;
- 两端都不是王牌:从 J、Q、K 选 2 个排两端(P (3,2)=6),中间 3 张(2 张王牌 + 剩下 1 张)排列(P (3,3)/2=3),总排法 = 6×3=18 种;
- 至少一端是王牌:60-18=42 种。
代码验证:
python
def count_ace_at_ends(cards):
"""统计至少一端是王牌的排列数(不区分大小王牌)"""
# 生成所有全排列
all_perms = permute(cards)
# 去重(不区分大小王牌,假设王牌是"J1"和"J2")
unique_perms = []
seen = set()
for p in all_perms:
# 将"J1"和"J2"统一为"J",再转tuple去重
normalized = tuple("J" if c in ["J1", "J2"] else c for c in p)
if normalized not in seen:
seen.add(normalized)
unique_perms.append(normalized)
# 统计至少一端是"J"的排列
count = 0
for p in unique_perms:
if p[0] == "J" or p[-1] == "J":
count += 1
return count, unique_perms
# 测试:5张牌["J1","J2","J","Q","K"](J1、J2是王牌)
cards = ["J1", "J2", "J", "Q", "K"]
count, perms = count_ace_at_ends(cards)
print(f"至少一端是王牌的排法数:{count}") # 输出42
八、小结:排列组合的核心是 “认清计数对象”
今天我们从加法 / 乘法法则出发,逐步讲解了置换、排列、组合,核心收获是:
-
计数前先明确两个问题:是否考虑顺序?是否允许重复?
- 置换:有序、无重复、全选(n 选 n);
- 排列:有序、无重复、选 k 个(n 选 k);
- 组合:无序、无重复、选 k 个(n 选 k);
- 重复组合:无序、允许重复、选 k 个。
-
公式不是死记的:所有公式都源于 “加法法则” 和 “乘法法则”,比如组合数的递归公式是 “选或不选” 的分类(加法),排列数是 “分步选元素” 的分步(乘法)。
-
编程实现的关键:递归是生成排列组合的常用方法,本质是 “拆解问题为小问题”(联系前面的递归和归纳法),去重则需要根据 “是否重复”(如王牌不区分)做特殊处理。
排列组合是后面 “概率统计” 章节的基础 —— 比如 “抽到王牌的概率” 就是 “王牌的组合数 / 总组合数”。下一篇我们将学习 “递归” 的深入应用,比如斐波那契数列、汉诺塔,而递归生成排列组合正是递归思维的典型实践。
如果你在编程中遇到过计数问题,比如生成测试用例、计算任务调度方案,欢迎在评论区分享你的经历!
- 点赞
- 收藏
- 关注作者
评论(0)