枚举
引入
定义水仙花数为一个三位数,它的各位数字的立方和等于其本身,比如:153=1^3+5^3+3^3
。那我们怎么找到在100~999
范围内的水仙花数呢?
- 看完这个题,大家可能一下就想到:我们把这个范围内所有的数检查一遍,输出满足水仙花数条件的数就可以了!
- 但是,如果我们手动检查的话,100到999范围内的数字有900个。这样的数据量需要我们颇费一番功夫。
- 但是,因为计算机的存在,1秒钟之内可以进行百万千万甚至更多的操作,所以,我们可以通过写一段代码来进行这个过程。
- 虽然这个思想很简单,但是在计算机科学中,它仍然是一个算法,叫做枚举法。
枚举法是利用计算机运算速度快、精确度高的特点,对要解决问题的所有可能情况,一个不漏地进行检验,从中找出符合要求的答案的方法。
- 虽然枚举法看起来像是一种“笨办法”,但正是因为拥有强大计算能力的计算机的存在,枚举法也变成一种解决问题的有力工具。
详细算法描述和代码实现
回顾水仙花数,假设我们现在要解决这个问题,最直接的想法就是:
- 对于所有三位数,检查是否满足水仙花数的条件,如果满足就输出。
- 现在,我们把算法更严谨地写出来:
- 枚举100到999之间所有的数。
- 检查当前的枚举的数字是否满足水仙花数的条件。
- 我们检查水仙花数的条件,其中一种方法就是将枚举到的整数i的每一位拆分出来(也叫十进制拆分),百位十位个位分别用
a
、b
、c
表示, - 然后检查是否满足
a * a * a + b * b * b + c * c * c == i
。 - 这就是枚举法的思路了。
- 我们检查水仙花数的条件,其中一种方法就是将枚举到的整数i的每一位拆分出来(也叫十进制拆分),百位十位个位分别用
详细算法描述:
- 枚举法的过程:
- 确定枚举对象、枚举范围和判定条件;
- 枚举可能的解,验证是否是问题的解。
引入中水仙花数的例子,对象就是整数,范围在100到999之间。
- 假设枚举的数字百位、十位和个位分别为a、b、和c,那么判定条件就是:
a^3 +b^3 +c^3 是否等于 a∗100+b∗10+c
所以,我们用C++中的for
循环语句来实现这个过程,并用if语句来检查判定条件是否成立。
代码实现
#include <iostream>
using namespace std;
int main() {
int n, m;
for (int i = 100; i <= 999; ++i) { // 枚举所有100到999的数
// 十进制拆分
int a = i / 100; // 拆解i的百位
int b = i / 10 % 10; // 拆解i的十位
int c = i % 10; // 拆解i的个位
// 判断是否满足水仙花数条件
// TODO 请补全代码
if ( i == a*a*a + b*b*b + c*c*c ) {
printf("%d ", i);
}
}
return 0;
}
其他枚举对象举例 —— 枚举矩形
举例 : 统计矩形
给定n×m
的网格图,求该网格图有多少长方形(长和宽不等),以及多少正方形。
要想枚举所有情况的矩形,常见的思路就是枚举左上角和右下角,或者很多情况只需枚举左上角。
思路
- 枚举所有矩形,左上角和右下角分别为
(i, j)
和(k, l)
。 - 检查该矩形是长方形还是正方形,分别累加到对应的计数器里。
// 伪代码
rec_cnt <- 0
sqr_cnt <- 0
for i <- 1 to n do
for j <- 1 to m do // 左上角
for k <- i to n do
for l <- j to m do // 右下角
if k - i == l - j // 正方形
sqr_cnt <- sqr_cnt + 1
else
rec_cnt <- rec_cnt + 1
输出 rec_cnt 和 sqr_cnt
复杂度
根据上面的伪代码,一共有4重循环,每次循环只进行了常数个操作,所以整个算法的复杂度是:T(n) = O(n^4)
但是上面的算法属于一种“无脑”枚举,其实只要我们稍微加入一些数学计算,就能极大地优化该算法的复杂度。
其他枚举对象举例 —— 枚举日期
举例: 统计日期
输入两个日期符合格式YYYY
年MM
月DD
日,统计这两个日期之间所有日期所对应的8位数里,有多少3的倍数。
要想枚举一年里的所有日期,其不方便的地方或许在于每个月份的天数不一样,而且2月份的天数还会随着闰年平年变化。所以通常的策略是将每个月有多少天存在一个数组里:
int days[27] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; // 0号元素无意义
这样枚举月份时,日期只需要枚举到该月份所对应数组元素的天数即可。
思路
- 枚举两个日期之间的所有日期。
- 将该日期转化为8位数,并检查是否为3的倍数。
// 伪代码
ans <- 0
for y <- Y_1 to Y_2 do // 枚举两个日期之间的年份,年份差不大于30
if y是闰年 then days[2] = 29 else days[2] = 28 // 调整2月份的天数
for m <- 1 to 12 do // 枚举月份
for d <- 1 to days[m] do // 根据月份确定枚举日期范围
if y年m月d日在Y_1年M_1月D_1日到Y_2年M_2月D_2日之间 then
num <= y * 10000 + m * 100 + d
if num % 3 == 0 then
ans <- ans + 1
输出 ans
其他枚举对象举例 —— 枚举区间
要想枚举满足条件的所有区间,最常见的枚举方法就是分别枚举区间的左右端点。
举例: 序列染色
有连续N个格子。起初每个格子分别被染成了R(红色)G(绿色)B(蓝色)三种不同颜色,问最少改变多少个格子的颜色,使得这N个格子可以被分成R、G、B的三段,且每一段长度不为空。
如下图的例子,第一行的格子通过将第三个涂成红色,第六个涂成蓝色,变成了一行RGB的形式。
思路
因为满足RGB条件的格子染色方案之间,区别在于位于中间的绿色区间的位置。
我们可以设计如下算法:
- 枚举所有可能的绿色区间的位置
[i, j]
。 - 计算从原序列到目标序列需要重新涂色的格子个数
Count_i,j
。 - 输出所有
Count_i,j
中最小的一个。
在上述思路中,我们枚举的对象是“绿色区间的位置”,需要检查的条件是“需要修改的格子数是否为目前最少的”。
// 伪代码
ans <- n; // 最多修改不会超过n个格子
for i <- 2 to n - 1 do
for j <- i to n - 1 do
cnt <- 绿色区间为[i, j]时需要重新涂颜色的格子数
if cnt < ans then ans <- cnt
输出 ans
复杂度
因为“cnt <- 绿色区间为[i, j]时需要重新涂颜色的格子数
”是一个子过程,并且该子过程被运行了O(n^2)次,所以,整个算法的复杂度为
T(n)=O(n^2 ×T(统计需要修改的格子数))
所以,一个高效的统计方法会降低整个算法的运行时间。目前,我们可以用最简单的方法:
- 将整个序列扫描一遍,如果当前格子的颜色和当前枚举的答案序列不一样,就让统计数值+1。
那么该子过程的复杂度是O(n)
,而整个算法的复杂度是O(n^3)
后面,我们会介绍用前缀和数组优化该算法的做法。
优化
在上面的做法中,我们给出的都是最简单直接的算法。但是当复杂度为O(n^2)
时,个人电脑在1s之内可能只能处理最多10^4
的数据规模,而当算法复杂度到O(n^4)
时,可能数据规模最多只能是10^2
,所以非常低效。所以,下面我们给出一些枚举算法的优化思路:
- 能算则算
可以通过必要的计算规避一些不必要的枚举。
-
比如在上面的统计矩形的例子中,我们枚举左上角之后,长方形和正方形满足条件的右下角个数可以通过计算得出。
-
所以,我们可以通过以下计算来统计(i, j)(i,j)左上角对应对长方形和正方形的贡献
矩形个数 =(n−i+1)∗(m−j+1) 正方形个数 =min(n−i+1,m−j+1) 长方形个数 = 矩形个数 − 正方形个数
- 能存则存
可以通过储存更多的信息来避免重复计算。
在上面的序列染色的例子中,算法的瓶颈在于对于一个确定的绿颜色区间,如何快速计算需要修改的颜色个数。考虑到该步骤是在询问一个区间上的信息。
使用前缀和数组,区间和可以通过两个前缀和相减快速求出。这里我们可以拓展这个思路,预处理出三个前缀和数组:
int a[N]; // 原序列
int not_R[N]; // 前i个格子里不是红色的格子个数
int not_G[N]; // 前i个格子里不是绿色的格子个数
int not_B[N]; // 前i个格子里不是蓝色的格子个数
for (int i = 1; i <= n; ++i) {
not_R[i] = not_R[i - 1] + (a[i] != 'R');
not_G[i] = not_G[i - 1] + (a[i] != 'G');
not_B[i] = not_B[i - 1] + (a[i] != 'B');
}
这样,对于一个绿色区间[i, j]
,总需要修改的格子数为三个颜色区间里,不等于各自颜色的格子数量求和:
n_change = not_R[i - 1] + (not_G[j] - not_G[i - 1]) + (not_B[n] - not_B[j]);
练习
-
纸币支付方案
小明钱包里装着各种纸币。纸币有4种(纸币的类型有1元、3元、5元和10元),每一种分别有a、b、c、d张。现在小明要出门买东西,他需要支付N元,在不找零的情况下,请问能支付成功吗?如果能成功支付,那么请计算出有多少种支付方式;如果不能成功支付,就输出no。
输入描述:
输入共 1 行,包含 5个整数 a、b、c、d、n,之间用一个空格隔开。abcd小于11
输出描述:
如果不能成功支付,则输出:no
如果可以成功支付,则输出:一个整数,表示支付方式的总数
示例 1:
输入: 1 1 1 1 1 输出: 1
代码:
#include <iostream> using namespace std; int main() { // 1,3,5,10的张数,以及总价,枚举时的支付价,支付 方式数量 int a, b, c, d, n, sum, num=0; cin >> a >> b >> c >> d >> n; for (int i=0; i<=d; i++) { if (i*10 > n) break; for (int j=0; j<=c; j++) { if ((i*10 + j*5) > n) break; for (int k=0; k<=b; k++) { if ((i*10 + j*5 + k*3) > n) break; for (int p=0; p<=a; p++) { sum = i*10 + j*5 + k*3 + p; if (sum == n) num += 1; else if (sum > n) break; } } } } if (num) cout << num << endl; else cout << "no" << endl; return 0; }
子集枚举
引入
子集枚举,顾名思义就是在枚举所有子集。为了解释这句话,我们需要先明确几个概念:
- 集合: 集合就是包含一些对象的整体。
- 比如一个学校里,“全体同学”就是包含所有同学的集合;“全体男生”就是包含所有男同学的集合,“全体女生”就是包含所有女同学的集合。
- 子集: 子集就是只包含某集合中一部分对象的集合。
- 比如在学校中的“全体同学”里,“经管系的同学”就是“全体同学”的一个子集。另外,每个非空集合都有两个最特殊的子集。一个是空集,不包含该集合中的任何元素。另一个是该集合本身,包含该集合中的所有元素。
- 举例
假设我们有个集合{1, 2, 3, ... , n}
,输出所有满足集合中所有数求和是3的倍数的子集的个数。- 思路
- 确定枚举对象、枚举范围和判定条件;
- 枚举可能的解,验证是否是问题的解。
- 我们也可以用这个思路解决该问题,那么该题的算法就是:
-
步骤1:枚举所有子集S。
-
步骤2:如果枚举到的子集S满足条件,则将其计入答案。
对于步骤2,算法设计是比较直接的。给定集合S,要想检查它是否满足“所有数求和是3的倍数”这个条件,只需要将所有的数加起来,模3,检查结果是否为0即可。
但是对于步骤1,我们如何枚举一个集合的所有子集呢?这就要涉及到我们的子集枚举算法。
-
- 思路
子集的表示方式 —— 数组表示法
最直接能够想到的一种表示方式(或存储方式),就是数组。因为对于原来集合是{1, 2, 3, 4, 5, 6}
,我们通常会按照如下形式存储:
int a[10] = {1, 2, 3, 4, 5, 6};
int n = 6; // 表示集合的大小
这样,如果我们存储子集时,可以用同样的方法:
int a_1[10] = {1, 2}; // a_1表示的是子集{1, 2}
int n_1 = 2; // a_1子集的元素个数
int a_2[10] = {3, 5, 6}; // a_2表示的是子集{3, 5, 6}
int n_2 = 3; // a_2子集的元素个数
int a_3[10] = {1}; // a_3表示的是子集{1}
int n_3 = 1; // a_3子集的元素个数
上面的代码显示了原集合中的三个子集。
但是目前为止,我们可能想不到什么好方法去枚举它。那么,子集有什么其他表示方法吗?
子集的表示方式 —— 01比特串法
- 什么是01比特串?
- 我们可以类比“字符串”,而将“比特串”类比为一串01数字。我们通过如下方式用长度为n的01比特串表示一个大小为n的集合{a_1, …, a_n}的子集:
- 如果01比特串的第i个元素是0,表示在该子集中没有包含第i个元素,相反如果第i个元素是1,则表示该子集中包含了第i个元素。
- 举例
- 对于集合
{1, 2, 3, 4, 5, 6}
- 010001表示子集{2, 6}。
{1,2,3,5}
表示成111010。
- 对于集合
- 我们可以类比“字符串”,而将“比特串”类比为一串01数字。我们通过如下方式用长度为n的01比特串表示一个大小为n的集合{a_1, …, a_n}的子集:
我们选择01比特串法,是因为它是一种对枚举很友好的表示方法。
- 对于一个大小为n的集合,它的所有子集对应着所有00…0到11…1的长度为n的比特串。
- 而长度为n的比特串又对应着范围从0到2^n-1的整数。
- 所以,如果我们想枚举所有子集的话,就是在枚举该子集的比特串表示,就是在枚举该比特串的整数。
下面,我们先解决两个比较关键的问题,然后再给出完整代码:
- 枚举整数的范围:从0(空集)枚举到
2^n - 1
(也就是原集合本身)- 因为对我们有用的只有长度为n的01比特串,超过n以后就没有意义了。所以如果转化成整数的话,最大的n位二进制整数是
2^n - 1
。所以枚举整数的时候,应该从0(空集)枚举到2^n - 1
(也就是原集合本身)。
- 因为对我们有用的只有长度为n的01比特串,超过n以后就没有意义了。所以如果转化成整数的话,最大的n位二进制整数是
- 如何知道第i个元素是否在集合里。
- 因为我们在枚举的时候枚举的是整数而不是直接枚举01比特串。所以怎样知道一个整数
num
在二进制表示下第i位是0还是1呢?(这里第i位为从最低位开始数的第i位)
- 因为我们在枚举的时候枚举的是整数而不是直接枚举01比特串。所以怎样知道一个整数
这里我们用一些位运算的技巧。
首先考虑一个比较简单的问题:怎样知道最低位是0还是1呢?我们只需要计算num & 1即可(&为与操作),根据一个例子感受一下这么做的原因:
101011 & 1 = 101011 & 000001 = 000001 = 1
101010 & 1 = 101010 & 000001 = 000000 = 0
另外,num & 1
也可用于检查数字num
的奇偶性。
- 这是因为当数字num是奇数时,二进制表示下的最低位就是1,而如果它是偶数,那么二进制表示下最低位就是0。
- 所以检查奇偶性和检查二进制位最低位是等价的。
现在我们知道如何检查最低位,也就是第1位了。那么如果想检查第2位怎么办呢?
我们可以想办法把“检查第2位”转换成“检查第1位”这个问题。具体转换方法,就是将整个二进制数字,向右平移一位。
- 对于向右平移一位,位运算中有对应的右移运算符
>>
; - 右移运算符在计算结果上,相当于“除以2下取整”;
- 在视觉效果上,就相当于将二进制表示下的数字丢掉最低位,并向右移一位;
- 这里,我们可以拿十进制做类比:在十进制下,“除以10下取整“就相当于在十进制下去掉最低位,向右平移1位,例如:
⌊12345÷10⌋=1234
所以,检查num
的第2位就相当于,先将数字向右平移1位,再检查最低位,代码写出来就是:
int second_bit = (num >> 1) & 1;
由第2位类比任意第i位,我们可以得到如下代码。假如我们要枚举长度为n的01比特串的每一位,如果习惯从1枚举到n的话,可以用上面一行;如果习惯从0枚举到n-1,可以用下面一行。
int i_th_bit = (num >> (i - 1)) & 1; // 如果i的范围是1到n
int i_th_bit = (num >> i) & 1; // 如果i的范围是0到n-1
代码实现 —— 集合中所有数求和是3的倍数的子集的个数
#include <iostream>
using namespace std;
int n;
int main() {
scanf("%d", &n); // 集合大小,也就是01比特串的长度
int tot = 1 << n; // 枚举数字代替01比特串,范围为0到2^n - 1
int ans = 0;
for (int num = 0; num < tot; ++num) { // 枚举每个代表01比特串的数字
long long sum = 0;
for (int i = 0; i < n; ++i) // 枚举01比特串的每一位
if ((num >> i) & 1) { // 检查第j位是否为1,注意这里是从0开始枚举
sum += (i + 1); // 如果该位是1,就把对应的数字加到求和的变量里
}
if (sum % 3 == 0) ++ans; // 如果满足题目要求(3的倍数),计入答案
}
printf("%d\n", ans);
}
递归枚举子集
之前我们给出了用数组表示集合的方法。那么有没有一种枚举方式可以基于这种表示方法来枚举集合的所有子集呢?
答案是有的,在以后学习递归的过程中,我们可以写一个递归函数,生成表示每个子集的对应数组。
这里我们简单提一下,其本质思想就是分情况讨论:
在这个结构中,第i层的结点表示的是基于上面的结点对应的子集再加入一个元素的可能情况。
所以,我们就按照这个树形结构枚举了所有的子集。
以在实现上,我们需要用一种叫做“递归”的方式将这个树形结构在代码中生成出来。
使用这种基于数组表示法和递归方法枚举子集的好处,是因为它的复杂度是O(2^n),要好于01比特串的枚举方法。
练习
-
珠心算测验
珠心算是一种通过在脑中模拟算盘变化来完成快速运算的一种计算技术。珠心算训练,既能够开发智力,又能够为日常生活带来很多便利,因而在很多学校得到普及。某学校的珠心算老师采用一种快速考察珠心算加法能力的测验方法。他随机生成一个正整数集合,集合中的数各不相同,然后要求学生回答:其中有多少个数,恰好等于集合中另外两个(不同的)数之和?
最近老师出了一些测验题,请你帮忙求出答案。
输入描述:
共两行,第一行包含一个整数𝑛,表示测试题中给出的正整数个数。
第二行有𝑛个正整数,每两个正整数之间用一个空格隔开,表示测试题中给出的正整数。
输出描述:
一个整数,表示测验题答案。
示例 1:
输入: 4 1 2 3 4 输出: 2
代码:
#include <iostream> #define N 105 using namespace std; int a[N], n; bool check(int i) { for (int j = 0; j < n; ++j) for (int k = j + 1; k < n; ++k) if (a[j] + a[k] == a[i]) return true; return false; } int main() { scanf("%d", &n); for (int i = 0; i < n; ++i) scanf("%d", &a[i]); int sum = 0; for (int i = 0; i < n; ++i) if (check(i)) ++sum; // 也可以是 sum += check(i) printf("%d\n", sum); return 0; }
排列枚举
引入
举例 :取宝石问题
假设在一个大房间有n个宝石,每一处宝石用一个坐标(x, y)表示。如果你从任意一处宝石的地方出发,依次经过每个放宝石的地方并取走宝石,最终要求回到出发地点,问最短需要走的距离是多少。
在这个情境里,经过不同地点的顺序会改变最终的行走距离。所以,我们要枚举的就是经过1~n一共n个位置的顺序。
举例 :八皇后问题
著名的八皇后问题,在8x8
的棋盘上放8个皇后,要求每个皇后不能在同一行,不能再同一列,也不能在同一条对角线上。如下面两种摆法就是不允许的:
如下图就是一个合法的解:
在这个例子中,由题意可以推出,每一行只能放一个皇后,且每一列只能放一个皇后。如果我们把列看成数组的下标,行看成数组里的值的话,如下图所示
可以看到,该实例用数组表示就是:
int a[10] = {0, 5, 7, 1, 4, 2, 8, 6, 3}; // 这里我们从1开始存储,0号无意义。
所以,我们就把八皇后问题转换成如下问题:
- 寻找一个
1~n
的排列,使得它满足八皇后问题中的对角线限制。
因为我们会在讲述递归和深度优先搜索的时候着重解释“八皇后问题”,并不会在这里对该问题做过多展开,感兴趣的同学可以自行思考一下“对角线限制”应该如何用数学形式规范表示。
在上面的“取宝石问题”和“八皇后问题”中,我们都想寻找满足某个条件的排列。这里,我们试图用最简单的枚举法来解决这个问题。回顾一下枚举的基本思路:
- 确定枚举对象、枚举范围和判定条件;
- 枚举可能的解,验证是否是问题的解。
代入到题目中的情景,就是
- 枚举所有排列;
- 检查每一个排列是否满足要求;
下面,我们要介绍一下如何枚举所有1~n的排列。
排列的表示方式
上面我们用“1~n
”的顺序来描述的n个对象中的某种排序关系,其实有个专业术语叫排列。
- 其中,最简单的对排列的解释就是:将n个元素按照一定的顺序排成一列,即为n个数的排列。
上面列举了几个需要找到合法排列的例子之后,下面我们将介绍如何枚举所有的排列。
和子集枚举一样,在设计枚举算法之前,我们也要首先确定排列在计算机里的表示方式。
这里,我们似乎只能想到用数组来存储排列,如下方代码所示:
int a[10] = {1, 2, 3, 4, 5, 6}; // 原序列
int b[10] = {2, 3, 1, 5, 4, 6}; // 原序列的一种排列
这意味着,我们将对一个数组的对象进行枚举。在这里,我们需要明确两个问题:
- 我们按什么顺序枚举?
- 这里,我们引入字典序的概念,并且最终按照字典序的顺序枚举排列。字典序,又叫字母序,是规定两个序列比较大小的一种方式。其规则是对于两个序列a和b:
- 从第一个字母开始比较,如果在第i个位置满足,i没有超过两个序列的长度,小于i处两个序列对应位置的元素都相等,而第i位两个序列对应位置元素不等的话,则若
a[i] < b[i]
,那么序列a小于序列b,否则序列b小于序列a。 - 若从第一个位置直到其中一个序列的最后一个位置都相等的话,则比较a和b的长度,若a的长度小于b,则序列a小于序列b(此时a是b的前缀),而如果b序列的长度小于a,那么序列b小于序列a。
- 若两个序列长度相等,并且所有元素相等,则序列a等于序列b。
- 从第一个字母开始比较,如果在第i个位置满足,i没有超过两个序列的长度,小于i处两个序列对应位置的元素都相等,而第i位两个序列对应位置元素不等的话,则若
- 举例:
abc < bbc // 因为第一个字母a < b ab < abc // 因为两个串前面所有对应位置字母相同,但 第一个串长度小于第二个串 ac > abb // 因为第二个字母c > b
- 这里,我们引入字典序的概念,并且最终按照字典序的顺序枚举排列。字典序,又叫字母序,是规定两个序列比较大小的一种方式。其规则是对于两个序列a和b:
- 如何生成下一个排列的数组?
- C++标准模板库(STL)里面已经有现成的实现了,我们将在下节介绍该内容。
标准模板库和next_permutation
标准模板库(Standard Template Library, STL) 是惠普实验室开发的一系列软件.它分为算法(algorithm)、容器(container)和迭代器(iterator)三个部分,实现了代码开发中常用的算法(如求最小值最大值、排序、二分查找等)和数据结构(如向量vector、集合set、映射map等)。
- 之所以叫做“模板库”,是因为在STL中几乎所有代码都是用模板类或者模板函数的方式实现的。
- 比如说我们常用的函数
min(a, b)
、max(a, b)
以及swap(a, b)
就是在算法部分实现的。 - 可以发现对于不同的数据类型,包括整数(
int
)、浮点数(double
),甚至自己定义的类对象,都可以调用swap
函数。这就是模板的好处。
next_permutation
函数是STL的算法部分实现的一个函数,其功能是将数组中存储的元素重新排列到字典序更大的排列。
- 在我们考虑排列的字典序时,因为所有排列长度相同,所以只需要比较对应位置元素大小即可。
举例: 所有1~3的排列按字典序从小到大排序
1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1
参数
next_permutation
函数有3个参数,分别代表头指针,尾指针和比较函数。- 头指针和尾指针:表示该函数需要重新排列的范围是头指针到尾指针之间的所有元素,包括头指针指向的元素,不包括尾指针指向的元素。
int a[10] = {1, 2, 3}; next_permutation(a, a + 3); // 调用完该函数之后,数组a中的元素会重排 // 此时a数组的元素为{1, 3, 2},因为next_permutation会将a数组中的元素重排成 // 按照字典序顺序的下一个排列
- 比较函数:这是一个可选参数,用于指定数组中存储对象的大小关系。
- 之所以需要比较函数,是因为只有存在单个元素的大小关系,才可以定义字典序。
- 对于整数、浮点数以及字符数组,因为整数、浮点数和字符的大小关系已经在C++里面定义过了,所以不需要传比较函数。当需要对自定义的类对象数组进行重排时,可能需要传入比较函数。
- 头指针和尾指针:表示该函数需要重新排列的范围是头指针到尾指针之间的所有元素,包括头指针指向的元素,不包括尾指针指向的元素。
返回值
next_permutation
的返回值表示是否存在字典序更大的排列。- 如果存在,返回
true
,否则返回false
。但是即便不存在字典序最大的排列,调用该函数也会导致数组a中的元素被重排成字典序最小的一个,例如:
这段代码的输出结果是int a[10] = {4, 3, 2, 1}; if (next_permutation(a, a + 4)) { cout << "Yes" << endl; } else { cout << "No" << endl; } for (int i = 0; i < 4; ++i) cout << a[i] << ' '; cout << endl;
No 1 2 3 4
- 如果存在,返回
“取宝石问题”的代码实现
假设在一个大房间有n个宝石,每一处宝石用一个坐标(x, y)表示。如果你从任意一处宝石的地方出发,依次经过每个放宝石的地方并取走宝石,最终要求回到出发地点,问最短需要走的距离是多少。
在这个情境里,经过不同地点的顺序会改变最终的行走距离。所以,我们要枚举的就是经过1~n一共n个位置的顺序。
用next_permutation
函数解决“取宝石问题”
因为要用枚举法解决第一个问题,所以,代入到题目的情境中,我们可以设计如下算法:
- 枚举所有n个点的排列
- 维护最短距离。检查新枚举的排列产生的行走距离是否比之前的最短距离还短。如果短,就更新答案。
下面是解决这个问题的完整代码:
#include <iostream>
#define N 15
using namespace std;
int n, id[N];
double x[N], y[N];
// 求两个点(x_1, y_1)和(x_2, y_2)之间的直线距离
double dis(double x_1, double y_1, double x_2, double y_2) {
double dx = x_1 - x_2;
double dy = y_1 - y_2;
return sqrt(dx * dx + dy * dy);
}
int main() {
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> x[i] >> y[i];
id[i] = i; // 因为我们枚举标号的排列,所以要将标号存进数组里
}
double ans = -1; // 因为最开始ans中没有值,所以我们可以将其设置为一个不合法的值
// 用do...while循环是为了防止第一次调用时数组id中的值已经被重排
// 所以会导致标号为1, 2, ..., n的排列没有被计算。
do {
// 求解按照id[1], id[2], ..., id[n], id[1]作为行走路线的总距离。
double cur = dis(x[id[1]], y[id[1]], x[id[n]], y[id[n]]);
for (int i = 1; i < n; ++i)
cur += dis(x[id[i]], y[id[i]], x[id[i + 1]], y[id[i + 1]]);
// 如果当前路线的总距离小于之前最优解,就更新。
if (ans < 0 || cur < ans) ans = cur;
} while (next_permutation(id + 1, id + n + 1));
// 输出答案,这里因为是浮点数,所以我们设置精度为4。
cout << setprecision(4) << ans << endl;
return 0;
}
复杂度分析
单纯枚举排列的复杂度是O(n!)。
但如果是针对上面解决“取宝石问题”的代码,do while
循环中还是有一个for
循环枚举,该for
循环会循环n次,所以对于“取宝石问题”,复杂度仍是O(n!×n)。
递归枚举排列
我们也可以用递归实现对所有排列的枚举,其本质是分情况讨论。
下图是关于排列的分情况讨论树形图,最下面一层就是所有可能的排列,可以看到,每一条向下延伸的边表示的是下一个可能放置的数字。
并且在所有生成的排列里,靠左边的位置字典序更小,靠右边的位置字典序更大。
练习
-
三连击
将 1,2,…,9 共 9 个数分成三组,分别组成三个三位数,且使这三个三位数的比例是a : b : c,试求出所有满足条件的三个三位数,若无解,输出 No!!!。输入描述:
三个数,a, b, c。
输出描述:
若干行,每行 3 个数字。按照每行第一个数字升序排列。
示例 1:
输入: 1 2 3 输出: 192 384 576 219 438 657 273 546 819 327 654 981
代码:
#include <iostream> #include <vector> #include <algorithm> using namespace std ; int main(){ int a , b , c ; cin >> a >> b >> c ; int arr[10] = {1,2,3,4,5,6,7,8,9} ; bool check = false ; do{ int num1 = arr[0] * 100 + arr[1] * 10 + arr [2] ; int num2 = arr[3] * 100 + arr[4] * 10 + arr [5] ; int num3 = arr[6] * 100 + arr[7] * 10 + arr [8] ; if ( a * num2 == b * num1 && c * num2 == b * num3 ){ cout << num1 << " " << num2 << " " << num3 << endl ; check = true ; } } while(next_permutation(arr , arr + 9)); // 利 用STL中的 next_permutation 全排列算法按照字典序 产生排列 if ( !check ){ cout << "No!!!" << endl ; } return 0 ; } int arr[10] = {1,2,3,4,5,6,7,8,9} ; bool check = false ; do{ int num1 = arr[0] * 100 + arr[1] * 10 + arr [2] ; int num2 = arr[3] * 100 + arr[4] * 10 + arr [5] ; int num3 = arr[6] * 100 + arr[7] * 10 + arr [8] ; if ( a * num2 == b * num1 && c * num2 == b * num3 ){ cout << num1 << " " << num2 << " " << num3 << endl ; check = true ; } } while(next_permutation(arr , arr + 9)); // 利 用STL中的 next_permutation 全排列算法按照字典序 产生排列 if ( !check ){ cout << "No!!!" << endl ; } return 0 ; }