本文属于「征服LeetCode」系列文章之一,这一系列正式开始于2021/08/12。由于LeetCode上部分题目有锁,本系列将至少持续到刷完所有无锁题之日为止;由于LeetCode还在不断地创建新题,本系列的终止日期可能是永远。在这一系列刷题文章中,我不仅会讲解多种解题思路及其优化,还会用多种编程语言实现题解,涉及到通用解法时更将归纳总结出相应的算法模板。
为了方便在PC上运行调试、分享代码文件,我还建立了相关的仓库:https://github.com/memcpy0/LeetCode-Conquest。在这一仓库中,你不仅可以看到LeetCode原题链接、题解代码、题解文章链接、同类题目归纳、通用解法总结等,还可以看到原题出现频率和相关企业等重要信息。如果有其他优选题解,还可以一同分享给他人。
由于本系列文章的内容随时可能发生更新变动,欢迎关注和收藏征服LeetCode系列文章目录一文以作备忘。
给你一个 有向图 ,它含有 n
个节点和 m
条边。节点编号从 0
到 n - 1
。
给你一个字符串 colors
,其中 colors[i]
是小写英文字母,表示图中第 i
个节点的 颜色 (下标从 0 开始)。同时给你一个二维数组 edges
,其中 edges[j] = [aj, bj]
表示从节点 aj
到节点 bj
有一条 有向边 。
图中一条有效 路径 是一个点序列 x1 -> x2 -> x3 -> ... -> xk
,对于所有 1 <= i < k
,从 xi
到 xi+1
在图中有一条有向边。路径的 颜色值 是路径中 出现次数最多 颜色的节点数目。
请你返回给定图中有效路径里面的 最大颜色值 。 如果图中含有环,请返回 -1
。
示例 1:
输入:colors = "abaca", edges = [[0,1],[0,2],[2,3],[3,4]]
输出:3
解释:路径 0 -> 2 -> 3 -> 4 含有 3 个颜色为 "a" 的节点(上图中的红色节点)。
示例 2:
输入:colors = "a", edges = [[0,0]]
输出:-1
解释:从 0 到 0 有一个环。
提示:
n == colors.length
m == edges.length
1 <= n <= 10^5
0 <= m <= 10^5
colors
只含有小写英文字母。0 <= aj, bj < n
解法 拓扑排序 + 动态规划
我们需要求出的答案等价于:对于一种颜色 c c c ,以及一条路径 path \textit{path} path ,其中颜色为 c c c 的节点有 path c \textit{path}_c pathc 个。我们希望挑选 c c c 以及 p a t h path path ,使得 path c \textit{path}_c pathc 的值最大。
我们可以枚举颜色 c c c ,随后选出可以使得 path c \textit{path}_c pathc 达到最大值的 path \textit{path} path 。这些 path c \textit{path}_c pathc 中的最大值即为答案。
- 如果给定的有向图包含环,那么它不存在拓扑排序。
- 如果给定的有向图不包含环,那么这个有向图是一个「有向无环图」,它一定存在拓扑排序。
根据拓扑排序的性质,如果节点 a a a 有一条有向边指向节点 b b b ,那么 b b b 在拓扑排序中一定出现在 a a a 之后。因此,一条有效路径上点的顺序与它们在拓扑排序中出现的顺序是一致的。
我们可以根据拓扑排序来进行动态规划。设
d
p
[
v
]
[
c
]
dp[v][c]
dp[v][c] 表示以节点
v
v
v 为终点的所有有效路径中,包含颜色
c
c
c 的节点数量的最大值。在进行状态转移时,我们考虑所有
v
v
v 的前驱节点(即有一条有向边指向
v
v
v 的节点)
prev
v
\textit{prev}_v
prevv
d
p
[
v
]
[
c
]
=
(
max
u
∈
prev
j
d
p
[
u
]
[
c
]
)
+
I
(
v
,
c
)
dp[v] [c] = \left( \max_{u \in \textit{prev}_j} dp [u][c] \right) + \mathbb{I}(v, c)
dp[v][c]=(u∈prevjmaxdp[u][c])+I(v,c)
即找出前驱节点中包含颜色
c
c
c 的节点数量最多的那个节点进行转移,并且如果
v
v
v 本身的颜色为
c
c
c ,
d
p
[
v
]
[
c
]
dp[v][c]
dp[v][c] 的值就增加
1
1
1 。这里
I
(
v
,
c
)
\mathbb{I}(v, c)
I(v,c) 为示性函数,当节点
v
v
v 的颜色为
c
c
c 时,函数值为
1
1
1 ,否则为
0
0
0 。那么
path
c
\textit{path}_c
pathc 的最大值即为
d
p
[
v
]
[
c
]
dp[v][ c]
dp[v][c] 中的最大值。
我们可以将状态转移,融入使用广度优先搜索的方法求解拓扑排序的过程中。当遍历到节点 u u u 时:
- 如果 u u u 的颜色为 c c c ,那么将 d p [ u ] [ c ] dp[u] [c] dp[u][c] 的值增加 1 1 1 ;
- 枚举 u u u 的所有后继节点(即从 u u u 出发经过一条有向边可以到达的节点),对于后继节点 v v v ,将 d p [ v ] [ c ] dp[v][c] dp[v][c] 更新为其与 d p [ u ] [ c ] dp[u][c] dp[u][c] 的较大值。
这样的操作与上文描述的状态转移方程是一致的。它的好处在于,如果使用广度优先搜索的方法求解拓扑排序,那么我们需要使用邻接表存储所有的有向边,而上文的动态规划是通过「枚举 v → v → v→ 枚举前驱节点 u u u 」进行状态转移的,这就需要我们额外存储所有边的反向边,才能通过 v v v 找到所有的前驱节点。如果我们通过「枚举 u → u \to u→ 枚举后继节点 v v v 」进行状态转移,这样就与拓扑排序存储的边保持一致了。
class Solution {
public:
int largestPathValue(string colors, vector<vector<int>>& edges) {
int n = colors.size();
vector<int> g[n];
vector<int> ind(n); // 节点的入度统计,用于找出拓扑排序中最开始的节点
for (vector<int>& e : edges) {
g[e[0]].push_back(e[1]);
++ind[e[1]];
}
vector<array<int, 26>> dp(n);
int found = 0, ans = 0;
queue<int> q;
for (int i = 0; i < n; ++i) if (ind[i] == 0) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
++found;
++dp[u][colors[u] - 'a']; // 节点u对应的颜色+1
ans = max(ans, dp[u][colors[u] - 'a']);
for (int v : g[u]) {
for (int c = 0; c < 26; ++c)
dp[v][c] = max(dp[u][c], dp[v][c]);
if (--ind[v] == 0) q.push(v);
}
}
return found != n ? -1 : ans;
}
};
复杂度分析:
- 时间复杂度:
O
(
n
+
m
∣
Σ
∣
)
O(n+m |\Sigma |)
O(n+m∣Σ∣),其中
∣
Σ
∣
|\Sigma |
∣Σ∣ 表示颜色的数量,在本题中
∣
Σ
∣
=
26
|\Sigma |=26
∣Σ∣=26。
一般的拓扑排序需要的时间为 O ( n + m ) O(n+m) O(n+m)。而在本题中在拓扑排序的过程中加入了状态转移,由于一条有向边对应着 ∣ Σ ∣ |\Sigma | ∣Σ∣ 次状态转移,因此拓扑排序的时间复杂度实际为 O ( n + m ∣ Σ ∣ ) O(n + m|\Sigma|) O(n+m∣Σ∣) 。 - 空间复杂度: O ( n ∣ Σ ∣ + m ) O(n|\Sigma| + m) O(n∣Σ∣+m) 。我们需要 O ( n ∣ Σ ∣ ) O(n |\Sigma|) O(n∣Σ∣) 的空间存储对应数量的状态;我们需要 O ( n + m ) O(n+m) O(n+m) 的空间存储邻接表;我们需要 O ( n ) O(n) O(n) 的队列空间进行拓扑排序。将它们相加,即可得到总时间复杂度为 O ( n ∣ Σ ∣ + m ) O(n |\Sigma| + m) O(n∣Σ∣+m) 。