千手观音题解(C++与Java)与拓扑排序讲解
拓扑排序
什么是拓扑排序
参考链接
在图论中,拓扑排序(Topological Sorting)是一个有向无环图(DAG, Directed Acyclic Graph)的所有顶点的线性序列。且该序列必须满足下面两个条件:
每个顶点出现且只出现一次。
若存在一条从顶点 A 到顶点 B 的路径,那么在序列中顶点 A 出现在顶点 B 的前面。
有向无环图(DAG)才有拓扑排序,非DAG图没有拓扑排序一说。
例如,下面这个图:
它是一个 DAG 图,那么如何写出它的拓扑排序呢?这里说一种比较常用的方法:
从 DAG 图中选择一个 没有前驱(即入度为0)的顶点并输出。
从图中删除该顶点和所有以它为起点的有向边。
重复 1 和 2 直到当前的 DAG 图为空或当前图中不存在无前驱的顶点为止。后一种情况说明有向图中必然存在环。
于是,得到拓扑排序后的结果是 { 1, 2, 4, 3, 5 }。
通常,一个有向无环图可以有一个或多个拓扑排序序列。(出现多个是因为同时出现多个节点入度为0的情况)
拓扑排序思路
现在就要是要先有这样一个思路:
-
先将题目的数据都提取到两个容器中,一个为记入每个节点的入度,另外一个为指向这个节点的有向边
-
从入度为0的开始删除,每删除一个入度为0就将他指向的所有节点入度减一(入度定义,少一个入他的入度就应该减1),删除的元素记入出来作为排序的输出结果
入度为0指的是没有边指向他,所以他是当前图中最大或最小中的元素之一*(最大或最小通过填入map时的判断决定)*
-
**现在有一个问题,就是有些节点在某个节点删除的时候同时入度为0了怎么办?**这就说明在这个图中这两的大小无法进行比较(因为没有相应的参照物,可以想象成物理中的相对速度),题目一般会告诉你会按照字典升序排列,这时就需要使用优先队列了,将多个入度为0的同时进到这个优先队列中,按照优先队列的规则来实现按照字典升序排列
这就是拓扑算法的思路,细节实现可以查看以下配套题目与代码:
拓扑排序的实现(Java,C++)
参考题目:千手观音
人类喜欢用 10 进制,大概是因为人类有一双手 10 根手指用于计数。于是在千手观音的世界里,数字都是 10 000 进制的,因为每位观音有 1 000 双手 ……
千手观音们的每一根手指都对应一个符号(但是观音世界里的符号太难画了,我们暂且用小写英文字母串来代表),就好像人类用自己的 10 根手指对应 0 到 9 这 10 个数字。同样的,就像人类把这 10 个数字排列起来表示更大的数字一样,ta们也把这些名字排列起来表示更大的数字,并且也遵循左边高位右边低位的规则,相邻名字间用一个点 .
分隔,例如 pat.pta.cn
表示千手观音世界里的一个 3 位数。
人类不知道这些符号代表的数字的大小。不过幸运的是,人类发现了千手观音们留下的一串数字,并且有理由相信,这串数字是从小到大有序的!于是你的任务来了:请你根据这串有序的数字,推导出千手观音每只手代表的符号的相对顺序。
注意:有可能无法根据这串数字得到全部的顺序,你只要尽量推出能得到的结果就好了。当若干根手指之间的相对顺序无法确定时,就暂且按它们的英文字典序升序排列。例如给定下面几个数字:
pat
cn
lao.cn
lao.oms
pta.lao
pta.pat
cn.pat
我们首先可以根据前两个数字推断 pat
< cn
;根据左边高位的顺序可以推断 lao
< pta
< cn
;再根据高位相等时低位的顺序,可以推断出 cn
< oms
,lao
< pat
。综上我们得到两种可能的顺序:lao
< pat
< pta
< cn
< oms
;或者 lao
< pta
< pat
< cn
< oms
,即 pat
和 pta
之间的相对顺序无法确定,这时我们按字典序排列,得到 lao
< pat
< pta
< cn
< oms
。
输入格式:
输入第一行给出一个正整数 N (≤105),为千手观音留下的数字的个数。随后 N 行,每行给出一个千手观音留下的数字,不超过 10 位数,每一位的符号用不超过 3 个小写英文字母表示,相邻两符号之间用 .
分隔。
我们假设给出的数字顺序在千手观音的世界里是严格递增的。题目保证数字是 104 进制的,即符号的种类肯定不超过 104 种。
输出格式:
在一行中按大小递增序输出符号。当若干根手指之间的相对顺序无法确定时,按它们的英文字典序升序排列。符号间仍然用 .
分隔。
输入样例:
7
pat
cn
lao.cn
lao.oms
pta.lao
pta.pat
cn.pat
输出样例:
lao.pat.pta.cn.oms
题目分析
这么长的题目就是想叫我们将这个新的字符语言进行从小到大的排序,字符语言是由.
隔开,并且从上往下依次递增(pta.pat可以理解为数字的十位)
代码及配套详细解释:
在有些pta题集中java实现会出现运行超时,使用c++却不会,为此提供两段代码!
Java实现:
import java.util.*;
import java.io.*;
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader read = new BufferedReader(new InputStreamReader(System.in));
// 入度:有多少有向边指向这个节点
Map<String, Integer> rudu = new HashMap<>();
// 记录指向这个节点的有向边(使用Set可以去重,但是要注意需要和入度的数量相匹配)
Map<String, Set<String>> graph = new HashMap<>();
int n = Integer.parseInt(read.readLine());
String[] lastTokens = null;
for (int i = 0; i < n; i++) {
String[] tokens = read.readLine().split("\\.");
//初始化入度map和有向边map
for (String token : tokens) {
if (!rudu.containsKey(token)) {
rudu.put(token, 0);//初始化都为0
}
if (!graph.containsKey(token)) {
graph.put(token, new HashSet<>());//初始化为空
}
}
//开始填入这两个map
//lastTokens.length,tokens.length需要相等的缘故是应为我们无法从不同位中提取出大小关系
if (lastTokens != null && lastTokens.length == tokens.length) {
int p = 0;
//找到第一个不同,这个可以提取出大小关系,但后续的不同无法提取
while (p < tokens.length && lastTokens[p].equals(tokens[p])) {
p++;
}
//添加有向边和入度信息
//注意,有向边和入度的大小必须统一!!!如果使用set进行有向边的记入出现问题大概率就是这里出了问题
if (p < tokens.length) {
String from = lastTokens[p];
String to = tokens[p];
if (!graph.get(from).contains(to)) {
//我这边是小的指向大的,入度为0为最小的
graph.get(from).add(to);
rudu.put(to, rudu.get(to) + 1);
//可以根据题目自行考虑吧是什么方向的有向边,只需要指到的那个节点的入度加1就行
}
}
}
lastTokens = tokens;
}
//创建优先队列,先将入度为0的添加进去作为开始
PriorityQueue<String> queue = new PriorityQueue<>();
for (String key : rudu.keySet()) {
if (rudu.get(key) == 0) {
queue.offer(key);
}
}
List<String> result = new ArrayList<>();
//不断删除最小节点,动态更新入度信息
while (!queue.isEmpty()) {
String token = queue.poll();
result.add(token);
//只需遍历入度信息改变的即可
for (String neighbor : graph.get(token)) {
int newRudu = rudu.get(neighbor) - 1;
rudu.put(neighbor, newRudu);
if (newRudu == 0) {
queue.offer(neighbor);
}
}
}
System.out.println(String.join(".", result));
}
}
C++实现:
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <queue>
#include <functional>
using namespace std;
// 分割字符串:使用 delimiter 作为分隔符,将结果存储到 vector 中
vector<string> split(const string &s, char delimiter) {
vector<string> tokens;
string token;
istringstream tokenStream(s);
while (getline(tokenStream, token, delimiter)) {
tokens.push_back(token);
}
return tokens;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// 入度: 有多少有向边指向这个节点
unordered_map<string, int> rudu;
// 记录指向这个节点的有向边 (使用 set 可以去重,但是要注意需要和入度的数量相匹配)
unordered_map<string, unordered_set<string>> graph;
int n;
cin >> n;
cin.ignore(); // 忽略换行符
vector<string> lastTokens; // 保存上一行拆分后的 token 数组
for (int i = 0; i < n; i++) {
string line;
getline(cin, line);
// 使用 '.' 进行分割
vector<string> tokens = split(line, '.');
// 初始化入度 map 和有向边 map
for (const string &token : tokens) {
if (rudu.find(token) == rudu.end()) {
rudu[token] = 0; // 初始化都为 0
}
if (graph.find(token) == graph.end()) {
graph[token] = unordered_set<string>(); // 初始化为空
}
}
// 开始填入这两个 map
// lastTokens.size() 和 tokens.size() 需要相等的缘故是因为我们无法从不同位中提取出大小关系
if (!lastTokens.empty() && lastTokens.size() == tokens.size()) {
int p = 0;
// 找到第一个不同的 token,这样可以提取出大小关系,但后续的不同无法提取
while (p < tokens.size() && lastTokens[p] == tokens[p]) {
p++;
}
// 添加有向边和入度信息
// 注意, 有向边和入度的数量必须保持一致!!!
// 如果使用 set 进行有向边的记录出现问题,大概率就是这里出了问题
if (p < tokens.size()) {
string from = lastTokens[p];
string to = tokens[p];
if (graph[from].find(to) == graph[from].end()) {
// 这里是小的指向大的, 入度为 0 的为最小的
graph[from].insert(to);
rudu[to]++; // 被指向的节点入度 +1
}
}
}
lastTokens = tokens;
}
// 创建优先队列,先将入度为 0 的节点添加进去作为开始
priority_queue<string, vector<string>, greater<string>> queue;
for (const auto &entry : rudu) {
if (entry.second == 0) {
queue.push(entry.first);
}
}
vector<string> result;
// 不断删除最小节点,动态更新入度信息
while (!queue.empty()) {
string token = queue.top();
queue.pop();
result.push_back(token);
// 只需遍历那些入度信息会改变的邻接点即可
for (const string &neighbor : graph[token]) {
rudu[neighbor]--;
if (rudu[neighbor] == 0) {
queue.push(neighbor);
}
}
}
// 输出:将结果用 '.' 连接
string output;
if (!result.empty()) {
output = result[0];
for (size_t i = 1; i < result.size(); i++) {
output += "." + result[i];
}
}
cout << output;
return 0;
}
注意点
- 有向边和入度的数量需要匹配,比如入度为3的话有向边需要记入3个节点(包括重复的节点)信息,无论是可重复的List还是不可重复的Set都需要匹配,不然在后续的删除过程中就会出现问题