ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

暴力枚举入门:Java四重循环求解PTA“四个数字”组合问题

暴力枚举入门:Java四重循环求解PTA“四个数字”组合问题 刚带一个学弟刷PTA他卡在“暴力小学(二年级篇)-求出4个数字”这道10分题上前前后后交了七八次都是答案错误心态差点崩了。我让他把代码发过来一看循环范围写错了——应该是1到4他写成0到4结果多出来一堆不合法的组合。说实话这道题本身不难考的就是最基础的暴力枚举思想用Java写也就二三十行。但对刚学完循环、还没建立起“枚举-判断”这套解题框架的同学来说它恰好是一道非常值得吃透的入门题。这道题的题面在不同版本里略有差异我以最经典的版本来讲给定1、2、3、4四个数字能组成多少个互不相同且无重复数字的四位数请输出所有满足条件的四位数并统计个数。如果你拿到的版本是“求四个数字满足某个等式”或者“从0到9中选取四个数字”也不用慌解题框架完全一样只是循环范围和if条件不同。下面我把这道题从读题到AC的完整过程拆开讲顺手把我在刷题过程中踩过的坑也一并复盘。1. 先别急着敲代码读懂“求出4个数字”到底在问什么1.1 考题背后是“枚举-判断”这套解题框架很多同学看到“求出4个数字”这个标题会懵不知道从哪下手。其实题目想考的就一件事暴力枚举也就是把四位数字所有可能的组合都列举出来然后根据题目条件筛掉不满足的剩下的就是答案。这个过程用生活中的场景来类比就是找钥匙你忘了钥匙放在哪个抽屉里与其站在原地猜不如把每个抽屉都拉开看一眼。枚举就是这个“挨个抽屉翻”的动作判断条件就是“钥匙是不是在这个抽屉里”。具体到这道题四位数每一位的候选数字都是1、2、3、4千位有4种选法百位有4种选法十位有4种选法个位有4种选法。全部组合是 (4 \times 4 \times 4 \times 4 256) 种。题目要求“互不相同且无重复数字”翻译成代码就是千位、百位、十位、个位这四个位置上的数字两两不相等。我见过不少同学一上来就试图用数学排列组合公式直接算出答案这当然也能做但这个阶段刷题的重点不是省那几毫秒的运行时间而是先学会用代码把思路完整地表达出来。暴力枚举的代码逻辑直白、不容易错是帮你建立“我能独立写出AC代码”信心最好的起点。1.2 为什么这道题敢用“暴力”的写法“暴力”这个词听起来好像不太高级但在算法世界里暴力枚举是一种极其重要的基础思想。它成立的前提是枚举总量在可控范围内。我们看一下这道题的计算量枚举范围四重循环总次数是否可行1到4每位4种选择(4^4 256)完全无压力0到9每位10种选择(10^4 10000)完全无压力1到100每位100种选择(100^4 100000000)开始吃力需优化一个现代CPU每秒可以执行上亿次基本运算所以这道题里1万次甚至10万次的枚举量运行时间都可以忽略不计。这也是为什么它被放在“暴力小学”系列里——题目设计者就是想让你放心大胆地用多重循环去穷举而不是一上来就绞尽脑汁想数学公式。我在刷题时养成的一个习惯是拿到一道题先在心里估算一下枚举总量。如果在100万以内暴力基本都能过超过这个量级才需要考虑剪枝或者换用更高效的算法。这道题明显在安全区间内所以放心写循环就完事了。2. 四重循环搞定题目最直白的Java实现2.1 把“手工排列”翻译成“循环嵌套”想清楚枚举思路之后下一步就是把思路转成代码。手工排列的时候我们的做法是千位先放1然后百位依次试1、2、3、4固定千位和百位之后十位再依次试最后个位再试。代码里的做法一模一样只不过把“依次试”变成了for循环。千位一个循环百位一个循环十位一个循环个位一个循环四个循环嵌套起来就能遍历所有256种组合。注意这里每次循环的起点和终点都是1到4不能从0开始因为题目给出的可选用数字只有1、2、3、4。写循环的时候还有个小细节循环变量尽量用有意义的字母。这道题一般用a、b、c、d分别表示千位、百位、十位、个位这样后面写判断条件和拼接数字时一眼就能看懂。我见过有人用i、j、k、l虽然也能跑但代码可读性差很多一旦出Bug排查起来也费劲。2.2 第一版代码四层for循环暴力枚举直接给出能AC的版本public class Main { public static void main(String[] args) { int count 0; for (int a 1; a 4; a) { for (int b 1; b 4; b) { for (int c 1; c 4; c) { for (int d 1; d 4; d) { if (a ! b a ! c a ! d b ! c b ! d c ! d) { System.out.println( a b c d); count; } } } } } System.out.println(count); } }逐行解释一下。最外层的for (int a 1; a 4; a)枚举千位数字第二层枚举百位第三层枚举十位第四层枚举个位。当四层循环都执行到某一轮时我们就有了一个完整的四位数组合a b c d。接下来是判断条件。a ! b a ! c a ! d保证了千位和另外三个位置不重复b ! c b ! d保证百位和十位、个位不重复c ! d保证十位和个位不重复。六个条件全部满足才是符合题目要求的组合。这里写成一行也可以但分成两行可读性更好PTA也允许这种写法。输出用的是System.out.println( a b c d)。这里有一个新手很容易踩的坑如果直接写System.out.println(a b c d)Java会把四个数字当成整数相加输出的是它们的和而不是拼接后的四位数。加上一个空字符串把后面的数字全部转成字符串拼接才会得到类似“1234”的输出。每次输出完一行计数器count加一。所有循环结束后输出最终个数。2.3 进阶写法用DFS回溯也能解四重循环是这道题最直接的解法但如果你已经学过了递归也可以试试用DFS回溯来写。这段代码在思路上比四重循环更抽象一点但它是后面学习搜索算法的重要铺垫public class Main { static int[] nums {1, 2, 3, 4}; static boolean[] used new boolean[4]; static int[] path new int[4]; static int count 0; public static void main(String[] args) { dfs(0); System.out.println(count); } static void dfs(int level) { if (level 4) { for (int i 0; i 4; i) { System.out.print(path[i]); } System.out.println(); count; return; } for (int i 0; i 4; i) { if (!used[i]) { used[i] true; path[level] nums[i]; dfs(level 1); used[i] false; } } } }这里的核心是用一个used数组记录哪个数字已经被用过用path数组存放当前已确定的数字。递归到第4层时说明四个位置都填完了输出并计数。used[i] false这一步是回溯的关键——把当前数字放回去让它在下一轮可以重新被使用。不过我个人建议如果你刚接触这道题还是先把四重循环版本吃透。DFS回溯的代码更短但理解成本更高容易在递归调用和回溯撤销这两个环节把自己绕晕。先把循环版本写熟再回头看DFS版本会顺畅很多。3. 从跑通到满分输出格式、边界条件和效率取舍3.1 输出格式是PTA判题的红线这道题真正让很多人翻车的地方往往不是枚举逻辑而是输出格式。PTA的判题系统对输出格式非常严格多一个空格、少一个换行都可能被判定为“答案错误”或“格式错误”。常见的要求有两种。第一种是“每行输出一个四位数最后一行输出个数”这种情况下用println一行一个数字就对了不需要在两个数字之间加空格。第二种是“数字之间用空格分隔最后输出个数”那么你在循环里就要用print而不是println同时还要小心最后一个数字后面不能有多余空格。我的建议是提交之前逐字读一遍题面的输出样例数清楚样例里每个换行、每个空格的位置。真的不夸张我有一次做PTA题目因为最后一行少换行被卡了半小时检查了半天才发现是输出细节的问题。还有一种情况是题目没有输入所以代码里不需要写 Scanner。我看到不少同学不管三七二十一先写Scanner sc new Scanner(System.in)哪怕下面根本没用到。虽然这不影响判题但会养成不好的习惯——代码里不应该出现无用的变量。3.2 三个经典变体允许重复、含0、条件求和“求出4个数字”这个题目在不同题库里可能有不同问法。我总结几种最常见的变体你把这一节的判断条件换到刚才的代码里基本就能应付绝大多数版本变体类型题面关键词循环范围判断条件无重复数字互不相同、无重复1到4(a \neq b \neq c \neq d)允许重复可重复、数字可以相同1到4无需判断直接输出含0组四位数0到9中选4个a从1到9b/c/d从0到9千位不能为0求和带输入数字之和等于N1到9(abcd N)举一个含0的例子。如果题目改成“从0到9这10个数字中任选4个数字组成四位数输出所有可能的四位数”那你的循环必须写成for (int a 1; a 9; a) { for (int b 0; b 9; b) { for (int c 0; c 9; c) { for (int d 0; d 9; d) { System.out.println( a b c d); } } } }注意千位的循环从1开始不能从0开始因为“0123”不算四位数。这是边界条件里最容易出错的地方一定要看清楚题目到底说的是“四个数字”还是“四位数”。再举一个带输入的求和变体。如果题目要求“输入一个正整数n输出所有各位数字之和等于n的四位数并统计个数”那么代码框架就会变成import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc new Scanner(System.in); int n sc.nextInt(); int count 0; for (int a 1; a 9; a) { for (int b 0; b 9; b) { for (int c 0; c 9; c) { for (int d 0; d 9; d) { if (a b c d n) { System.out.println( a b c d); count; } } } } } System.out.println(count); } }这段代码里n是读者输入的目标和四个循环把从1000到9999的所有四位数都枚举了一遍用if判断各位数字之和是否等于n。这里循环总量是 (9 \times 10 \times 10 \times 10 9000) 次同样是非常安全的暴力范围。3.3 枚举范围的确定与“剪枝”萌芽暴力枚举不等于盲目枚举。写循环之前一定要先想清楚每一位数字的取值范围这能让你少跑很多无用的循环。拿这道题来说“四位数”三个字就决定了千位不能为0“四个数字1、2、3、4”决定了每一位的取值范围是1到4“互不相同”决定了当选完千位之后百位的候选数字实际上只剩3个。虽然代码里我们依然让百位从1到4挨个试再通过if条件去筛选但如果你已经理解了这一点其实可以在百位循环内部加一个跳过判断比如for (int b 1; b 4; b) { if (b a) continue; for (int c 1; c 4; c) { if (c a || c b) continue; // 后续循环同理 } }这种“在循环内部提前跳过不合法情况”的做法就叫剪枝。这道题里剪枝的效果不明显因为总共只有256种组合怎么跑都很快但它的思想非常重要——不去枚举那些明知道不可能成立的选项。等你以后遇到枚举总量突破百万、千万的题目时一句continue就能省下大量时间。我见过不少同学写代码时从不考虑枚举范围不管什么题目都从0循环到1000000虽然很多题也能过但这属于“瞎暴力”。真正的暴力枚举是经过思考的你知道自己在枚举什么为什么要枚举这个范围以及这个范围为什么是可控的。4. 我在“暴力小学”上踩过的坑排查链路复盘4.1 坑点一循环范围手滑写成0到4回到开头那个学弟的案例。他的代码逻辑基本没问题唯一的问题是把所有循环都写成了for (int a 0; a 4; a)结果枚举出了千位为0的“伪四位数”。我们一起来复盘他的排查过程。他第一次提交PTA返回“答案错误”他以为是判断条件写错了把六个不相等条件反复检查确认没问题第二次提交还是错误他开始怀疑是不是输出格式不对又去调整换行和空格依然不对直到他把代码里输出的前几行打印出来才发现第一位出现了0。其实这道题只要把输出的前几个组合列出来看一眼就能立刻发现问题。我教他一个笨办法写代码的时候临时加一句System.out.println(a a , b b , c c , d d);放在枚举里看前几次循环的输出马上就能定位到是哪一层循环的范围写错了。排查完再把调试语句删掉就行。这个案例告诉我们当PTA报“答案错误”而不是“编译错误”时问题几乎一定出在代码逻辑或输出格式上。这时候不要反复提交浪费时间而是回到本地用打印中间结果的方式一步步确认枚举过程是否符合预期。4.2 坑点二判重条件漏掉一个六组不相等条件看起来很简单但一不小心就会漏。比如有人会写成if (a ! b a ! c a ! d b ! c c ! d)这里漏掉了b ! d。这个错误很隐蔽因为多数情况下你随手列的几组数字都不涉及百位和个位相等的场景试错很难试出来。但只要枚举范围一变bug就冒出来了。我自己验证代码时有一个习惯用数学方法先算出正确答案再用程序的结果去比对。这道题四个数字的全排列是 (4! 24)所以程序输出的个数必须是24。如果输出不是24说明代码一定哪里错了。这个方法简单有效能帮你把“程序看起来能跑”和“程序真的正确”区分开。另外PTA上这道题如果你输出的数量不对判题系统是会明确报错的。所以我强烈建议做这类题目时先在本地把最终输出结果的数量、首尾几行都确认一遍再提交。4.3 一个普遍的排查技巧小范围打印中间结果很多初学者遇到“答案错误”就慌了开始瞎改代码结果越改越乱。我分享一个我从实际调试中总结出来的思路按这个顺序排查基本都能解决先检查枚举范围打印每一层循环变量的边界值确认没有多枚举或漏枚举。再检查判断条件找一个手工能算出来的小样例比如四位数字改成1、2、3手算排列总数是6用代码跑一遍看结果是否和手算一致。最后检查输出格式用文本编辑器打开输出结果逐行对照题面的输出样例重点看空格和换行。这三个步骤里第一步和第二步能发现90%以上的问题。第三步虽然看着简单但千万不能跳过因为格式错误同样会让整道题白做。4.4 把这道题变成自己的“套路模板”刷题的意义不是背答案而是积累可以复用的“套路”。这道题给我的最大收获是让我建立了一个非常通用的暴力枚举模板for (int a minA; a maxA; a) { for (int b minB; b maxB; b) { for (int c minC; c maxC; c) { for (int d minD; d maxD; d) { if (/* 题目要求的判断条件 */) { // 计数或输出 } } } } }以后不管是“求两个数字”、“求三个数字”还是“求四个数字”本质上都是这个模板的变体改循环范围改判断条件改输出方式。甚至当你学到数组、学到字符串处理之后遇到匹配子串、统计词频之类的题目脑子里第一时间浮现的还是这个“枚举所有可能筛掉不合要求”的框架。最后说点题外的。我在实际刷题中见过太多人一看到“暴力”两个字就觉得低级非要想着用数学公式一步算出结果。但暴力枚举本身是一种极其重要的算法思维——它保证了正确性是后面学回溯、学动态规划的起点。这道10分题你把它吃透了等于把“多重循环 枚举 筛选条件”这条主线摸清了以后遇到排列组合、搜索类的题都会轻松很多。至少我到现在还保持着一个习惯拿到题目先想暴力怎么做再想怎么优化。暴力能跑通永远比精妙但写错强。
返回列表