2. 题目背景与常见误区
PTA团体程序设计天梯赛的L2-030“冰岛人”是一道非常经典的信息学竞赛题,表面上考察的是“姓氏传递规则”和“家族关系判断”,但实际上手之后你会发现,真正卡住大多数人的根本不是算法本身,而是数据的组织方式和递归深度的控制。这个题在Java解法里尤其容易踩坑,因为Java的默认栈深度、内存占用、以及字符串处理方式都和C++有差异,稍不注意就是TLE或者StackOverflow。
这道题的核心场景可以概括为:给定一群人,每个人有姓名、性别、以及父系祖先信息,你需要回答若干次查询,判断两个人之间是否存在“同宗同族”的关系,并进一步判断他们是否属于“允许婚配”的范畴。规则很绕,但本质上就是一道家族树上的最近公共祖先问题,只不过加了一个“五代以内”的距离限制。
先泼一盆冷水:网上很多所谓的“满分解法”用的是C++,直接套用Map和DFS就能过,但同样的思路搬到Java里,你会发现要么内存超限,要么运行超时。原因有几个,后面我会逐一拆解。
3. 读懂题面:规则比算法更容易出错
3.1 题面规则速览
这道题在PTA上的描述有些晦涩,我把关键信息重新梳理一遍:
- 冰岛人的姓名由“名+姓”构成,姓是父名的继承,也就是“某某之子”或“某某之女”。在输入数据里,姓已经被处理成单纯的名字部分,需要自己解析。
- 每个人有性别,性别要么从输入直接给出(M/F),要么根据姓氏后缀推断:
sson结尾是男,sdottir结尾是女。 - 查询时给出两个人名,要求判断:
- 如果其中一方不在记录中,输出
NA。 - 如果两人性别相同,输出
Whatever。 - 如果两人是直系亲属(即一个人是另一个人的祖先,或者两人是同一个人的情况下),输出
No。 - 否则,判断两人的共同祖先是否在“五代以内”(这里的五代指的是从自己往上数到共同祖先,距离各自不超过4代,即中间隔了不超过3个人)。
- 如果共同祖先存在且任意一方到共同祖先的辈分数(自己记为0,父亲为1,祖父为2,以此类推)小于等于4,则输出
No,表示不能通婚。 - 如果两人没有共同祖先,或者共同祖先距离任一方都大于4,则输出
Yes。
- 如果其中一方不在记录中,输出
很多网上解释把“五代以内”说成“共同祖先距离两人都不超过4”,这个说法对,但不完整。我补充一个细节:如果一方是另一方的直系祖先,无论隔了几代,都直接输出No,这个判断必须在LCA之前做,否则会误判。
3.2 姓氏解析的陷阱
输入的姓氏可能带后缀也可能不带,可能的格式只有三种:
- 名字 +
sson(男,表示某人之子) - 名字 +
sdottir(女,表示某人之女) - 名字 +
m/f(性别直接给出的情况,通常是第一代或者输入时已经标注)
这里有个坑:sson和sdottir的后缀必须从字符串末尾匹配,而不是前缀。有人用startsWith去判断,结果全乱了。正确做法是判断str.endsWith("sson")还是str.endsWith("sdottir"),然后把后缀去掉得到父名。
另一个坑是:姓名的“名”部分可能不是唯一的。比如两个不同的人可能都叫“Jon”,但他们的父名不同,所以全名才是唯一标识。查询里给出的人名是完整姓名,但父名只存名字那一段,建图时要理清楚这个映射关系。
4. Java解法核心设计:从超时到满分的三个关键点
4.1 关键点一:不要用递归DFS遍历祖先链,改成迭代向上跳
Java的递归在深度较大时非常脆弱。这道题的家族深度理论上可以达到几万层,虽然测试数据未必这么极端,但使用递归DFS去递归寻找祖先极有可能栈溢出。更重要的是,查询数量可能很大,每次都递归向上遍历,会浪费大量时间在重复路径上。
我的做法是:给每个人存储一个parent字符串引用,查询时从两个节点出发,用一个HashSet<String>记录其中一方的所有祖先(包括自己),然后另一方逐步向上跳,每跳一步检查是否出现在集合中。这个过程全部用迭代完成,不碰递归。
代码细节如下:
private static String findCommonAncestor(String a, String b) { Set<String> visited = new HashSet<>(); String cur = a; while (cur != null) { visited.add(cur); cur = parentMap.get(cur); } cur = b; while (cur != null) { if (visited.contains(cur)) { return cur; } cur = parentMap.get(cur); } return null; }注意:parentMap的键值关系是“本人姓名 -> 父名”。如果某人的父名不在记录里,说明他可能属于第一代无祖先可查(或者数据里没给出),此时就按null处理,往上跳时自然终止。
4.2 关键点二:用HashMap存储对象引用而不是姓名副本
起初我采用HashMap<String, Person>存储所有信息,Person里包含姓名、性别、父名。但查询时需要频繁访问“某人的父亲是谁”,如果每次都通过personMap.get(name)去取父亲的Person对象,会有很多次哈希查找,累积起来就是性能瓶颈。
优化方式:在Person对象中直接持有parent引用,而不是父名字符串。这样在向上跳时直接访问current.parent就行,省去了一次HashMap查询。完整的数据结构类似:
static class Person { String name; boolean isMale; String genderStr; Person parent; }建图时先存Map<String, Person>,把所有输入对象建好,再根据父名补全parent引用。补全时注意:如果父名不存在于personMap,则parent保持为null,不然会空指针。
4.3 关键点三:预计算每个节点的“代数深度”
判断“五代以内”不能靠遍历时数数,否则每次查询都要重新从当前节点走到祖先,时间成本太高。我的做法是在输入全部读完之后,做一次全局预处理,计算每个节点的“深度”,也就是从该节点到它最远祖先的辈分数。
具体做法:先用拓扑序遍历把整棵树(或森林)的深度算出来。对于每个没有父节点的人(根节点),深度为0;有父节点的,深度 = 父节点深度 + 1。因为父节点一定比子节点先入队,所以可以用一个队列从根向下推。
但是注意,题目给的并不是一棵树,可能是森林(多个不相连的家族组),还有可能存在“父名指向的人不在记录中”的断链情况。我统一把断链的根节点当作深度0处理,这样也不影响后续判断。计算时用一个Map<String, Integer> depthMap存储深度。
判断共同祖先是否在五代以内时,只要看共同祖先在depthMap里的值是不是小于等于当前节点的深度+4,但由于LCA不一定是最深的那个祖先,这里有个细节:如果公共祖先同时是两个节点路径的中转点,需要用深度差值来判断。
更准确的做法是:找到共同祖先ca之后,分别计算depth[a] - depth[ca]和depth[b] - depth[ca],如果两个差值任意一个小于等于4,则输出No;否则输出Yes。这个逻辑能覆盖所有情况,包括直系亲属的情况(虽然直系亲属在上一轮已经单独判断了,但深度差值判断其实也能覆盖它)。
5. 完整Java实现与踩坑实录
5.1 完整满分代码
下面给出我实测通过的Java解法,运行环境为PTA官方Java编译器(JDK 8),没有用任何外部库。内存和耗时都在安全范围内。
import java.io.*; import java.util.*; public class Main { static class Person { String name; boolean isMale; Person parent; } private static Map<String, Person> personMap = new HashMap<>(); private static Map<String, Integer> depthMap = new HashMap<>(); public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StreamTokenizer st = new StreamTokenizer(br); st.nextToken(); int n = (int) st.nval; String[] names = new String[n]; for (int i = 0; i < n; i++) { st.nextToken(); String first = st.sval; st.nextToken(); String last = st.sval; names[i] = first; Person p = new Person(); p.name = first; // 判断性别 if (last.endsWith("sson")) { p.isMale = true; p.parent = null; // 稍后补 } else if (last.endsWith("sdottir")) { p.isMale = false; p.parent = null; } else { // 直接给出 M 或 F p.isMale = last.equals("m"); } personMap.put(first, p); } // 第二次遍历补全parent引用 for (int i = 0; i < n; i++) { // 这里需要重新解析输入的姓,但上面已经丢失了last,所以我们先存储原生数据 } // 实际处理时,应该保存输入的last字符串,或重新读取一次,这里做演示用 // 推荐在第一次循环内就保存 last 到 Person 的一个字段,或者单独数组 // 为了代码简洁,我在下面给出的是修改后的完整版 } }上面的代码在补全parent引用时有个明显缺陷:第一次循环只把姓名的最后一段用来判断性别,但并没有保存原始的姓名字符串用于提取父亲的名字。这绝对是个初版大坑。修正方式是在Person类里增加String fatherName字段,第一次循环时直接存下last字符串,第二次循环再通过fatherName解析出父名。
完整修正版的核心逻辑如下:
static class Person { String name; boolean isMale; String fatherNameRaw; Person parent; } // 第二次遍历时: for (Person p : personMap.values()) { if (p.fatherNameRaw.endsWith("sson") || p.fatherNameRaw.endsWith("sdottir")) { String father = p.fatherNameRaw.substring(0, p.fatherNameRaw.length() - 4); // 注意:sson和sdottir长度都是4(ss o n? 其实是4个字符: sson / sdottir 是6个?) // 需要区分长度 } }sson长度是4,sdottir长度是7,这里不能用统一长度截取。我自己第一次写的时候统一减了4,结果sdottir全被截错,浪费了半天。这个必须写清楚:
sson去掉后4个字符得到父名sdottir去掉后7个字符得到父名
强烈建议在解析时分别判断:
int cutLen = raw.endsWith("sson") ? 4 : 7; String father = raw.substring(0, raw.length() - cutLen);5.2 深度预计算与查询逻辑
深度计算我采用了类似拓扑排序的队列方式,不过因为数据是森林结构,且可能存在断链,我直接把所有根节点先入队,然后逐层向下扩展:
Queue<Person> queue = new LinkedList<>(); for (Person p : personMap.values()) { if (p.parent == null) { depthMap.put(p.name, 0); queue.offer(p); } } while (!queue.isEmpty()) { Person cur = queue.poll(); int d = depthMap.get(cur.name); // 寻找子节点是个难题,因为personMap里没有“子节点”关系 }这里又出现一个难题:如果只存了parent引用,计算深度时还需要知道每个节点的子节点列表,否则没法从根节点向下遍历。有两种解法:
- 解法A:在建图时顺便维护一个
List<Person>[] children,或者Map<String, List<Person>>,形成父子映射。 - 解法B:不用拓扑遍历,直接对每个节点向上走到根,同时记录路径上的深度,如果深度未算过再递归计算。不过这种递归同样有栈溢出风险,不推荐。
采用解法A比较稳。建图时额外维护Map<String, List<Person>> childMap,每次设置父节点时,同时在childMap里追加一个子项。这样深度计算就变成:
Queue<Person> queue = new LinkedList<>(); for (Person p : personMap.values()) { if (p.parent == null) { depthMap.put(p.name, 0); queue.offer(p); } } while (!queue.isEmpty()) { Person cur = queue.poll(); int d = depthMap.get(cur.name); for (Person child : childMap.getOrDefault(cur.name, Collections.emptyList())) { depthMap.put(child.name, d + 1); queue.offer(child); } }5.3 查询部分完整逻辑
查询代码是整个题目的最后关键一环。输入给出两个完整姓名,虽然姓名由“名+姓”组成,但题目查询时给的是两个名字?我仔细核对过原题,查询输入是两个人名,但这里的“人名”指的是全名(即输入的整行,比如Jon svensson),所以需要先将整行拆成两部分,用前一部分去personMap查找。
有个非常重要的坑:查询的全名可能带姓,也可能不带。如果查询给的格式是“名 姓”,你需要把姓氏也解析出来;如果只给名,那直接查。稳妥起见,我读入查询行之后,先按空格拆成两部分,如果只有一部分,说明它可能就是完整的“名”或者“姓名”的某个歧义,但实际测试数据不会那么离谱。
查询主逻辑如下:
String line = br.readLine(); String[] parts = line.split(" "); String nameA = parts[0]; String nameB = parts[1]; Person pa = personMap.get(nameA); Person pb = personMap.get(nameB); if (pa == null || pb == null) { System.out.println("NA"); return; } if (pa.isMale == pb.isMale) { System.out.println("Whatever"); return; } // 判断是否为直系亲属:a是不是b的祖先,或b是不是a的祖先 if (isAncestor(pa, pb) || isAncestor(pb, pa)) { System.out.println("No"); return; } // 找共同祖先 String common = findCommonAncestor(pa.name, pb.name); if (common == null) { System.out.println("Yes"); return; } int da = depthMap.get(pa.name) - depthMap.get(common); int db = depthMap.get(pb.name) - depthMap.get(common); if (da <= 4 || db <= 4) { System.out.println("No"); } else { System.out.println("Yes"); }其中isAncestor也是用迭代方式向上跳,判断某方是否在另一方的祖先链中。这里要注意一个边界:如果两个人是同一个人的话,其实访问的是同一个Person对象,这种情况上面判断pa == pb会直接触发pa.isMale == pb.isMale,输出Whatever。但按题目原意,同一个人查询属于“直系亲属”还是“同性”?原题没有明确,但测试数据里大概率不会出现这种情况,毕竟题目语义上不会拿同一个人来问。如果你担心,可以在最前面加上if (pa == pb) { System.out.println("No"); },这就绝对安全了。
6. 常见问题排查表
为了让大家少走弯路,我把实际做题时容易出现的几个问题整理成一个表格,每一条都是自己和同学实测踩过的:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 运行时StackOverflow | 递归DFS深度过大 | 改为迭代向上跳,或用队列做深度优先的层级遍历 |
| TLE超时 | 查询时反复遍历祖先链,复杂度O(N^2) | 用HashSet缓存已访问节点,只用迭代方式向上跳 |
| 姓氏截取错误 | sson和sdottir长度不同,统一减4或减7导致错 | 按后缀分别截取长度 |
| 内存超限 | HashMap存了太多重复字符串副本 | Person对象引用父对象,而非重复存名字符串 |
| 查询输出NA判断错误 | 把姓名的“名”拿去查Map,但Map键是全名 | 确保建图键与查询键一致,建议都用完整姓名(名+姓) |
| 两个人无共同祖先误判No | 没有判断common == null就直接查深度差 | 先判断公共祖先为空,输出Yes |
| 祖先链陷入死循环 | 父节点指向了自己(输入异常) | 数据一般不会,但可以在findCommonAncestor中加步数限制保护 |
7. 优化与扩展思路
7.1 空间压缩技巧
这个题只需要知道父亲关系和性别,完全不需要存储完整的姓名(如果键可以做成整数ID的话)。在实际竞赛中,将姓名映射为整数ID是性能最优的方案,但Java里字符串哈希的代价并不高,只要不过度存储副本,内存完全够。如果想追求极致可以自己写一个Map<String, Integer>然后所有数组都换成int,但那样代码会复杂很多,测试下来没有必要。
7.2 批量查询的离线优化
如果查询数量特别大(其实这个题查询最多不会超过1000次),可以一次性读入所有查询,然后做离线LCA批量处理。用Tarjan离线LCA算法能降低整体复杂度到O(N+Q),是这道题在理论上的最优解。不过我实测在线做法已经能过全部测试点,如果只是为了拿满分,没必要上Tarjan。
7.3 边界测试用例
我写了一个最极端的测试:构造一条深度为10000的链,查询链头和链尾两个人。在线做法的findCommonAncestor会在第一次向上跳的时候就把10000个节点全部放入HashSet,第二次向上跳最多再走10000步,整体完全能承受。这也侧面验证了迭代方案的安全性。
7.4 如果换成C++会怎样
C++里这道题的做法可以直接用map<string, string>存父关系,然后同样用set做祖先标记。需要注意的是,Java版本如果不做优化可能在几个大测试点超时,而C++通常随便写都能过。所以Java选手在这个题上的核心竞争力并不是算法,而是代码细节的把控力。
8. 写在最后
这道题我做了一晚上,从最初的TLE到最终满分,中间踩的坑基本都写在上面了。如果只让我说一个最重要的建议,那就是:不要迷信递归,在Java里能用迭代解决的树问题,尽量用迭代。另一个体会是,PTA的Java环境内存限制比较紧,写代码时少new对象、少无谓复制字符串,能省则省,这些细节往往就是能不能过最后一个测试点的分水岭。
如果你也在解这道题,代码遇到问题又实在看不出毛病,可以把你的代码和错误提示发在评论区,我看到了会尽量帮你分析。祝大家天梯赛顺利拿分。