405 小Q的非素数和排列问题
点击跳转到题目:405 小Q的非素数和排列问题
问题描述
小C对排列很感兴趣,她想知道有多少个长度为n的排列满足任意两个相邻元素之和都不是素数。排列定义为一个长度为n的数组,其中包含从1到n的所有整数,每个数字恰好出现一次。
测试样例
样例1:
输入:
n = 5输出:4
样例2:
输入:
n = 3输出:0
样例3:
输入:
n = 6输出:24
问题解析
这是一个排列问题,目的是计算满足特定条件的排列的数量。条件是任意两个相邻元素的和不能是素数。我们可以通过回溯法或动态规划解决这个问题。
- 关键点:需要判断两个相邻数字之和是否是素数。
- 素数范围:因为排列是从1到n的所有整数,当两个数的最大可能和为
2n时,需要判断从2到2n的数字是否是素数。 - 排列条件:每次选择一个未使用的数字,且与前一个数字之和不能是素数。
解法
方法1:回溯法
通过回溯法生成所有排列,逐个验证是否符合条件。虽然时间复杂度较高,但实现简单。
代码说明
-
isPrime函数:判断一个数是否是素数,用于生成素数表。 -
solution函数:- 通过回溯法生成所有排列。
- 利用
primes数组快速判断两个数之和是否为素数。
-
回溯过程:
used数组记录数字是否已使用。path数组保存当前的排列。- 在每一步递归中,检查当前数是否满足条件(与前一个数的和不为素数)。
复杂度分析
- 时间复杂度:理论上回溯的复杂度为
O(n!),但素数条件减少了搜索空间。 - 空间复杂度:使用
O(n)的数组来记录状态和路径。
此实现适合小规模的n(如n <= 10),对于更大的n,可以考虑其他优化方法如动态规划或启发式搜索。
实现代码
import java.util.*;
public class Main {
// 判断是否是素数
private static boolean isPrime(int x) {
if (x < 2) return false;
for (int i = 2; i * i <= x; i++) {
if (x % i == 0) return false;
}
return true;
}
// 计算满足条件的排列数量
public static int solution(int n) {
// 预计算素数表
int maxSum = 2 * n;
boolean[] primes = new boolean[maxSum + 1];
for (int i = 0; i <= maxSum; i++) {
primes[i] = isPrime(i);
}
boolean[] used = new boolean[n + 1];//used数组记录数字是否已使用
int[] path = new int[n];//path数组保存当前的排列
int depth = 0;
// 回溯函数
return backtrack(n,used, path, depth, primes);
}
private static int backtrack(int n, boolean[] used, int[] path, int depth, boolean[] primes) {
// 如果排列完成
if (depth == n) {
return 1;
}
int count = 0;
for (int i = 1; i <= n; i++) {
//path[depth-1]得到上一个数字
if (!used[i] && (depth == 0 || !primes[path[depth - 1] + i])) {
used[i] = true;
path[depth] = i;
count += backtrack(n, used, path, depth + 1, primes);
used[i] = false;
}
}
return count;
}
// 测试样例
public static void main(String[] args) {
System.out.println(solution(5)); // 输出:4
System.out.println(solution(3)); // 输出:0
System.out.println(solution(6)); // 输出:24
}
}
方法2:优化回溯法
可以通过减少递归深度和优化状态存储的方式改写算法,例如使用状态压缩 + 动态规划,这是一种较为高效的方法,特别是在n较大的情况下。
-
状态压缩:使用一个整数的位表示哪些数字已被使用,例如一个
int的第i位为1表示数字i已经被选过。 -
动态规划:
- 定义
dp[mask][last]表示当前状态为mask(已选数字的集合),且最后一个选择的数字是last时的有效排列数量。 - 状态转移:尝试选取一个未使用的数字
i,如果last + i不是素数,则更新dp。
- 定义
代码说明
-
isPrime:与之前相同,用于生成素数表。 -
countValidPermutations:- 使用
dp[mask][last]存储子问题的解。 - 初始化
dp为-1,表示还未计算。
- 使用
-
dfs函数:mask表示当前的状态,用二进制存储哪些数字已使用。last是上一个选中的数字。- 遍历所有可能的下一个数字
i,如果i未被使用且符合条件,则递归求解。 - 通过记忆化
dp避免重复计算。
-
递归终止条件:当
mask == 0时,表示所有数字都已使用,返回1。
复杂度分析
-
时间复杂度:
- 状态总数为
2^n,每种状态最多遍历n个数字。 - 转移复杂度为
O(2^n * n)。
- 状态总数为
-
空间复杂度:
dp数组占用O(2^n * n)空间。
相比回溯法,这种方法避免了重复计算,在n较大的情况下效率更高。
实现代码
import java.util.*;
public class Main {
// 判断一个数是否是素数
private static boolean isPrime(int x) {
if (x < 2) return false;
for (int i = 2; i * i <= x; i++) {
if (x % i == 0) return false;
}
return true;
}
// 计算满足条件的排列数量
public static int countValidPermutations(int n) {
// 预计算素数表
int maxSum = 2 * n;
boolean[] primes = new boolean[maxSum + 1];
for (int i = 0; i <= maxSum; i++) {
primes[i] = isPrime(i);
}
// 初始化 dp 表
int[][] dp = new int[1 << n][n + 1];
for (int[] row : dp) Arrays.fill(row, -1);
// 开始递归动态规划
return dfs((1 << n) - 1, 0, n, primes, dp);
}
// 深度优先搜索(状态压缩 + 记忆化)
private static int dfs(int mask, int last, int n, boolean[] primes, int[][] dp) {
if (mask == 0) return 1; // 所有数字都被使用,找到一个有效排列
if (dp[mask][last] != -1) return dp[mask][last];
int count = 0;
for (int i = 1; i <= n; i++) {
int bit = 1 << (i - 1);
if ((mask & bit) != 0 && (last == 0 || !primes[last + i])) {
count += dfs(mask ^ bit, i, n, primes, dp);
}
}
dp[mask][last] = count;
return count;
}
// 测试样例
public static void main(String[] args) {
System.out.println(countValidPermutations(5)); // 输出:4
System.out.println(countValidPermutations(3)); // 输出:0
System.out.println(countValidPermutations(6)); // 输出:24
}
}
总结与启发
总结
- 问题拆解:本题通过回溯法枚举所有可能的排列,同时结合素数判断对排列进行约束。通过逐步拆解问题(素数判断、排列生成、条件验证),将复杂问题化解为多个子任务,确保代码逻辑清晰且易于实现。
- 剪枝优化:在回溯中加入剪枝条件(即当前相邻元素之和为素数时立即停止递归),极大减少了不必要的计算,优化了性能。这种动态验证和剪枝策略是解决排列类问题的常用技巧。
- 素数预处理:素数的预处理使得条件检查可以在 O(1) 时间内完成,这种以空间换时间的思想在本题中体现得尤为重要。
- 递归思想:本题通过递归构建排列,每一步根据约束条件决定下一步的可行性,这种递归回溯的思想为解决排列、组合问题提供了通用框架。
启发
- 分而治之的解题思路:复杂问题往往需要分解成若干简单子问题,比如本题分解为素数判断与排列生成。将子问题分别求解后再整合,能够显著降低问题的难度。
- 剪枝的重要性:当问题涉及大量枚举时,剪枝是一种高效策略。通过排除无意义的搜索路径,避免了冗余计算。在日常编程中,可以通过条件过滤、缓存计算结果等方式实现剪枝。
- 灵活运用预处理:素数判断这一任务通过预处理大幅优化了运行效率。在解决类似问题时,预先计算或缓存常用数据(如素数、因数、排列等)能够提高整体性能。
- 递归与回溯的广泛应用:回溯法不仅适用于排列、组合问题,还广泛应用于搜索、路径规划等领域。其核心思想是尝试每一种可能性并在不满足条件时回退。掌握回溯法对提高算法能力大有裨益。
- 算法的可拓展性:本题中,素数判断和排列生成是独立的模块,具有通用性。如果未来问题变为“长度为 n 的排列满足相邻两数的平方和不是素数”,仅需修改素数判断部分即可。这种模块化设计使代码更具复用性。
通过解决这道题,我们不仅加深了对回溯法和剪枝优化的理解,还学习了如何将预处理与动态验证相结合。在实际问题中,灵活运用这些技巧可以帮助我们高效解决复杂的算法挑战。使用豆包 MarsCode AI 后刷题工具,无论是效率提升、知识沉淀还是错题复盘,都能让学习更高效、更体系化。对于初学者来说,豆包是起步阶段不可多得的好助手;而对于进阶学习者,它则是攻克难题和优化学习路径的强力工具。希望更多人能借助智能工具与经典资源,享受算法学习的乐趣!