LC 图-汇总

133. 克隆图


给你无向连通图中一个节点的引用,请你返回该图的深拷贝(克隆)

图中的每个节点都包含它的值 val(int) 和其邻居的列表(list[Node])

class Node {
public int val;
public List<Node> neighbors;
}

测试用例格式:

  • 简单起见,每个节点的值都和它的索引相同。例如,第一个节点值为 1(val = 1),第二个节点值为 2(val = 2),以此类推。该图在测试用例中使用邻接列表表示。

  • 邻接列表 是用于表示有限图的无序列表的集合。每个列表都描述了图中节点的邻居集。

  • 给定节点将始终是图中的第一个节点(值为 1)。你必须将给定节点的拷贝作为对克隆图的引用返回。

示例 1:

输入:adjList = [[2,4],[1,3],[2,4],[1,3]]
输出:[[2,4],[1,3],[2,4],[1,3]]
解释:
图中有 4 个节点。
节点 1 的值是 1,它有两个邻居:节点 24
节点 2 的值是 2,它有两个邻居:节点 13
节点 3 的值是 3,它有两个邻居:节点 24
节点 4 的值是 4,它有两个邻居:节点 13

示例 2:

输入:adjList = [[]]
输出:[[]]
解释:输入包含一个空列表。该图仅仅只有一个值为 1 的节点,它没有任何邻居。

示例 3:

输入:adjList = []
输出:[]
解释:这个图是空的,它不含任何节点。

提示:

  • 节点数不超过 100 。
  • 每个节点值 Node.val 都是唯一的,1 <= Node.val <= 100。
  • 无向图是一个简单图,这意味着图中没有重复的边,也没有自环
  • 由于图是无向的,如果节点 p 是节点 q 的邻居,那么节点 q 也必须是节点 p 的邻居。
  • 图是连通图,你可以从给定节点访问到所有节点。

1) 深度优先搜索

从给定的节点出发,进行「图的遍历」,并在遍历的过程中完成图的深拷贝。

为了避免在深拷贝时陷入死循环,需要理解图的结构。对于一张无向图,任何给定的无向边都可以表示为两个有向边,即如果节点 A 和节点 B 之间存在无向边,则表示该图具有从节点 A 到节点 B 的有向边和从节点 B 到节点 A 的有向边。为了防止多次遍历同一个节点,陷入死循环,需要用一种数据结构记录已经被克隆过的节点

算法

  1. 使用一个哈希表存储所有已被访问和克隆的节点。哈希表中的 key 是原始图中的节点,value 是克隆图中的对应节点。

  2. 从给定节点开始遍历图。如果某个节点已经被访问过,则返回其克隆图中的对应节点。

  3. 如果当前访问的节点不在哈希表中,则创建它的克隆节点并存储在哈希表中。注意:在进入递归之前,必须先创建克隆节点并保存在哈希表中。如果不保证这种顺序,可能会在递归中再次遇到同一个节点,再次遍历该节点时,陷入死循环。

  4. 递归调用每个节点的邻接点。每个节点递归调用的次数等于邻接点的数量,每一次调用返回其对应邻接点的克隆节点,最终返回这些克隆邻接点的列表,将其放入对应克隆节点的邻接表中。这样就可以克隆给定的节点和其邻接点。

Java-点击查看代码
class Solution {
private HashMap<Node, Node> visited = new HashMap<>();
public Node cloneGraph(Node node) {
if(node == null){
return node;
}
if(visited.containsKey(node)){ //containsKey!
return visited.get(node);
}
//克隆 Node,但不会克隆它的邻居的列表
Node cloneNode = new Node(node.val, new ArrayList());
//哈希表记录!
visited.put(node, cloneNode);
//复制其邻接 Node
for(Node neighbor: node.neighbors){
cloneNode.neighbors.add(cloneGraph(neighbor));
}
return cloneNode;
}
}
C++-点击查看代码
class Solution {
public:
unordered_map<Node*, Node*> visited;
Node* cloneGraph(Node* node) {
if(node == nullptr){
return node;
}
if(visited.find(node)!=visited.end()){
return visited.at(node);
}
// 克隆节点,注意到为了深拷贝我们不会克隆它的邻居的列表
Node *cloneNode = new Node(node->val);
//Node *cloneNode = new Node(node->val, vector<Node*>);
visited[node] = cloneNode;
for(Node* neighbor: node->neighbors){
cloneNode->neighbors.push_back(cloneGraph(neighbor));
}
return cloneNode;
}
};

复杂度分析

  • 时间复杂度:O(N),其中 N 表示节点数量。深度优先搜索遍历图的过程中每个节点只会被访问一次。

  • 空间复杂度:O(N)。存储克隆节点和原节点的哈希表需要 O(N) 的空间,递归调用栈需要 O(H) 的空间,其中 H 是图的深度,经过放缩可以得到 O(H)=O(N),因此总体空间复杂度为 O(N)。

2) 广度优先遍历

用广度优先搜索来进行「图的遍历」。

方法一与方法二的区别仅在于搜索的方式。深度优先搜索以深度优先,广度优先搜索以广度优先。这两种方法都需要借助哈希表记录被克隆过的节点来避免陷入死循环。

算法

  1. 使用一个哈希表 visited 存储所有已被访问和克隆的节点。哈希表中的 key 是原始图中的节点,value 是克隆图中的对应节点。

  2. 将题目给定的节点添加到队列。克隆该节点并存储到哈希表中。

  3. 每次从队列首部取出一个节点,遍历该节点的所有邻接点。如果某个邻接点已被访问,则该邻接点一定在 visited 中,那么从 visited 获得该邻接点,否则创建一个新的节点存储在 visited 中,并将邻接点添加到队列。将克隆的邻接点添加到克隆图对应节点的邻接表中。重复上述操作直到队列为空,则整个图遍历结束。

  • Java
点击查看代码
class Solution {
public Node cloneGraph(Node node) {
if (node == null) {
return node;
}
HashMap<Node, Node> visited = new HashMap();
LinkedList<Node> queue = new LinkedList<Node>();
visited.put(node, new Node(node.val, new ArrayList()));
queue.add(node);
while(!queue.isEmpty()){
Node n = queue.remove();
for (Node neighbor: n.neighbors) {
if(!visited.containsKey(neighbor)){
visited.put(neighbor, new Node(neighbor.val, new ArrayList()));
queue.add(neighbor);
}
visited.get(n).neighbors.add(visited.get(neighbor));
}
}
return visited.get(node);
}
}
  • C++
点击查看代码
class Solution {
public:
Node* cloneGraph(Node* node) {
if (node == nullptr) {
return node;
}
unordered_map<Node*, Node*> visited;
queue<Node*> Q;
Q.push(node);
visited[node] = new Node(node->val);
// 广度优先搜索
while (!Q.empty()) {
auto n = Q.front(); // 取出队列的头节点
Q.pop();
for (auto& neighbor: n->neighbors) {
if (visited.find(neighbor) == visited.end()) {
// 如果没有被访问过,就克隆并存储在哈希表中
visited[neighbor] = new Node(neighbor->val);
// 将邻居节点加入队列中
Q.push(neighbor);
}
// 更新当前节点的邻居列表
visited[n]->neighbors.emplace_back(visited[neighbor]);
}
}
return visited[node];
}
};

push_back 和 emplace_back 区别

  • push_back():向容器尾部添加一个右值元素,然后调用构造函数构造出这个临时对象,最后调用移动构造函数将这个临时对象放入容器中并释放这个临时对象。注:最后调用的不是拷贝构造函数,而是移动构造函数。因为需要释放临时对象,所以通过 std::move 进行移动构造,可以避免不必要的拷贝操作
  • emplace_back():在容器尾部添加一个元素,调用构造函数原地构造,不需要触发拷贝构造和移动构造。因此比 push_back() 更加高效。

复杂度分析

  • 时间复杂度:O(N),其中 N 表示节点数量。广度优先搜索遍历图的过程中每个节点只会被访问一次。

  • 空间复杂度:O(N)。哈希表使用 O(N) 的空间。广度优先搜索中的队列在最坏情况下会达到 O(N) 的空间复杂度,因此总体空间复杂度为 O(N)。

207. 课程表


你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。

在选修某些课程之前需要一些先修课程。先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则必须先学习课程 bi 。

  • 例如,先修课程对 [0, 1] 表示:想要学习课程 0,你需要先完成课程 1 。

请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。

示例 1
输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。
示例 2
输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:总共有 2 门课程。学习课程 1 之前,你需要先完成​课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的。

前言

本题是一道经典的「拓扑排序」问题。

给定一个包含 n 个节点的有向图 G,给出它的节点编号的一种排列,如果满足:对于图 G 中的任意一条有向边 (u,v),u 在排列中都出现在 v 的前面。那么称该排列是图 G 的「拓扑排序」。

可以得出两个结论:

  • 如果图 G 中存在环(即图 G 不是「有向无环图」),那么图 G 不存在拓扑排序。

  • 如果图 G 是有向无环图,那么它的拓扑排序可能不止一种。举一个最极端的例子,如果图 G 值包含 n 个节点却没有任何边,那么任意一种编号的排列都可以作为拓扑排序。

因此,可以将本题建模成一个求拓扑排序的问题:

  • 将每一门课看成一个节点;

  • 如果想要学习课程 A 之前必须完成课程 B,那么从 B 到 A 连接一条有向边。这样以来,在拓扑排序中,B 一定出现在 A 的前面。

求出该图是否存在拓扑排序,就可以判断是否有一种符合要求的课程学习顺序。

事实上,由于求出一种拓扑排序方法的最优时间复杂度为 O(n+m),其中 n 和 m 分别是有向图 G 的节点数和边数。而判断图 G 是否存在拓扑排序,至少也要对其进行一次完整的遍历,时间复杂度也为 O(n+m)。因此不可能存在一种仅判断图是否存在拓扑排序的方法,它的时间复杂度在渐进意义上严格优于 O(n+m)。

1) 深度优先搜索

将深度优先搜索的流程与拓扑排序的求解联系起来,用一个栈来存储所有已经搜索完成的节点。

对于一个节点 u,如果它的所有相邻节点都已经搜索完成,那么在搜索回溯到 u 的时候,u 本身也会变成一个已经搜索完成的节点。这里的「相邻节点」指的是从 u 出发通过一条有向边可以到达的所有节点。

假设当前搜索到了节点 u,如果它的所有相邻节点都已经搜索完成,那么这些节点都已经在栈中了,此时就可以把 u 入栈。可以发现,如果从栈顶往栈底的顺序看,由于 u 处于栈顶的位置,那么 u 出现在所有 u 的相邻节点的前面。因此对于 u 这个节点而言,它是满足拓扑排序的要求的。

这样以来,对图进行一遍深度优先搜索。当每个节点进行回溯的时候,把该节点放入栈中。最终从栈顶到栈底的序列就是一种拓扑排序。

算法

对于图中的任意一个节点,它在搜索的过程中有三种状态,即:

  • 「未搜索」:我们还没有搜索到这个节点;

  • 「搜索中」:我们搜索过这个节点,但还没有回溯到该节点,即该节点还没有入栈,还有相邻的节点没有搜索完成);

  • 「已完成」:我们搜索过并且回溯过这个节点,即该节点已经入栈,并且所有该节点的相邻节点都出现在栈的更底部的位置,满足拓扑排序的要求。

在每一轮的搜索搜索开始时,任取一个「未搜索」的节点开始进行深度优先搜索。

  • 将当前搜索的节点 u 标记为「搜索中」,遍历该节点的每一个相邻节点 v:

    • 如果 v 为「未搜索」,那么开始搜索 v,待搜索完成回溯到 u;

    • 如果 v 为「搜索中」,那么就找到了图中的一个环,因此是不存在拓扑排序的;

    • 如果 v 为「已完成」,那么说明 v 已经在栈中了,而 u 还不在栈中,因此 u 无论何时入栈都不会影响到 (u, v) 之前的拓扑关系,以及不用进行任何操作。

  • 当 u 的所有相邻节点都为「已完成」时,将 u 放入栈中,并将其标记为「已完成」。

在整个深度优先搜索的过程结束后,如果没有找到图中的环,那么栈中存储这所有的 n 个节点,从栈顶到栈底的顺序即为一种拓扑排序。

优化:由于只需要判断是否存在一种拓扑排序,而栈的作用仅仅是存放最终的拓扑排序结果,因此可以只记录每个节点的状态,而省去对应的栈。

  • Java
点击查看代码
class Solution {
List<List<Integer>> edges;
int[] visited;
boolean valid = true;
public boolean canFinish(int numCourses, int[][] prerequisites) {
//初始化 edges
edges = new ArrayList<List<Integer>>();
for(int i=0; i<numCourses; i++){
edges.add(new ArrayList<Integer>());
}
//建立边
for(int[] info: prerequisites){
edges.get(info[1]).add(info[0]);
}
//初始化 visited
visited = new int[numCourses];
//遍历
for(int i = 0; i<numCourses && valid; i++){
if(visited[i]==0){
dfs(i);
}
}
return valid;
}
void dfs(int u){
visited[u]=1;
for(int v: edges.get(u)){
if(visited[v]==1){
valid = false;
return;
}else if(visited[v]==0){
dfs(v);
//if(!valid) return; //此语句可以当问题出现提前结束,也可以不加
}
}
visited[u]=2;
}
}
  • C++
点击查看代码
class Solution {
private:
vector<vector<int>> edges;
vector<int> visited;
bool valid = true;
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
//使用 resize 分配大小
edges.resize(numCourses);
visited.resize(numCourses);
for (const auto& info: prerequisites) {
edges[info[1]].push_back(info[0]); //info[0]与info[1]的顺序
}
for(int i=0; i<numCourses && valid; i++){
if(visited[i]==0){
dfs(i);
}
}
return valid;
}
void dfs(int u){
visited[u] = 1;
for(int v: edges[u]){
if(visited[v]==0){
dfs(v);
if(!valid) return;
}else if(visited[v]==1){
valid = false;
return;
}
}
visited[u] = 2;
}
};

一个问题:
edges[info[1]].push_back(info[0]);edges[info[0]].push_back(info[1]);的区别?
都可以通过所有测试用例。
题目要求:先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1。因此 1 应该再栈顶,其出栈后,0才可以出栈。=》等价与存在从 1 指向 0 的有向边。
edges[info[1]].push_back(info[0]);:符合题意。学 info[0] 之前要先学 info[1],所以 info[1] 指向 info[0],在 info[1] 的 ArrayList 中存储它指向哪个科目。
再深度递归搜索时,如下图流程。
edges[info[0]].push_back(info[1]);:同理,此不符合题意。

复杂度分析

  • 时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行深度优先搜索的时间复杂度。

  • 空间复杂度: O(n+m)。题目中是以列表形式给出的先修课程关系,为了对图进行深度优先搜索,需要存储成邻接表的形式,空间复杂度为 O(n+m)。在深度优先搜索的过程中,需要最多 O(n) 的栈空间(递归)进行深度优先搜索,因此总空间复杂度为 O(n+m)。

2) 广度优先搜索🍓

思路

方法一的深度优先搜索是一种「逆向思维」:最先被放入栈中的节点是在拓扑排序中最后面的节点。也可以使用正向思维,顺序地生成拓扑排序,这种方法也更加直观。

考虑拓扑排序中最前面的节点(类似树的叶子节点),该节点一定不会有任何入边,也就是它没有任何的先修课程要求。当将一个节点加入答案中后,就可以移除它的所有出边,代表着它的相邻节点少了一门先修课程的要求。如果某个相邻节点变成了「没有任何入边的节点」,那么就代表着这门课可以开始学习了。按照这样的流程,不断地将没有入边的节点加入答案,直到答案中包含所有的节点(得到了一种拓扑排序)或者不存在没有入边的节点(图中包含环)。

上面的想法类似于广度优先搜索,因此可以将广度优先搜索的流程与拓扑排序的求解联系起来。

算法

使用一个队列来进行广度优先搜索。初始时,所有入度为 0 的节点都被放入队列中,它们就是可以作为拓扑排序最前面的节点,并且它们之间的相对顺序是无关紧要的。

在广度优先搜索的每一步中,取出队首的节点 u:

  • 将 u 放入答案中;

  • 移除 u 的所有出边,也就是将 u 的所有相邻节点的入度减少 1。如果某个相邻节点 v 的入度变为 0,那么就将 v 放入队列中。

在广度优先搜索的过程结束后。如果答案中包含了这 n 个节点,那么就找到了一种拓扑排序,否则说明图中存在环,也就不存在拓扑排序了。

优化

由于只需要判断是否存在一种拓扑排序,因此省去存放答案数组,而是只用一个变量记录被放入答案数组的节点个数。在广度优先搜索结束之后,判断该变量的值是否等于课程数,就能知道是否存在一种拓扑排序。

  • Java
点击查看代码
class Solution {
List<List<Integer>> edges;
int[] indeg;
public boolean canFinish(int numCourses, int[][] prerequisites) {
//初始化
indeg = new int[numCourses];
edges = new ArrayList<List<Integer>>();
//构建有向边
for(int i=0; i<numCourses; i++){
edges.add(new ArrayList<Integer>());
}
for(int[] info : prerequisites){
edges.get(info[1]).add(info[0]);
indeg[info[0]]++;
}
LinkedList<Integer> queue = new LinkedList<>();
for(int i=0; i<numCourses; i++){
if(indeg[i]==0){
queue.offer(i);
}
}
int visited = 0;
while(!queue.isEmpty()){
visited++;
int u = queue.poll();
for(int v : edges.get(u)){
indeg[v]--;
if(indeg[v]==0){
queue.offer(v);
}
}
}
return visited==numCourses;
}
}
  • C++
点击查看代码
class Solution {
private:
vector<vector<int>> edges;
vector<int> indeg;
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
//使用 resize 分配大小
edges.resize(numCourses);
indeg.resize(numCourses);
for (const auto& info: prerequisites) {
edges[info[1]].push_back(info[0]); //info[0]与info[1]的顺序
indeg[info[0]]++;
}
queue<int> q; //不用new 关键字,且queue小写!
for(int i=0; i<numCourses; i++){
if(indeg[i]==0) q.push(i);
}
int visited = 0;
while(!q.empty()){
visited++;
int u = q.front();
q.pop();
for(int v: edges[u]){
indeg[v]--;
if(indeg[v]==0) q.push(v);
}
}
return visited==numCourses;
}
};

为什么最后要判断:visited==numCourses
因为有环存在时,visited<numCourses,如下图

复杂度分析

  • 时间复杂度: O(n+m),其中 n 为课程数,m 为先修课程的要求数。这其实就是对图进行广度优先搜索的时间复杂度。

  • 空间复杂度: O(n+m)。题目中是以列表形式给出的先修课程关系,为了对图进行广度优先搜索,需要存储成邻接表的形式,空间复杂度为 O(n+m)。在广度优先搜索的过程中,需要最多 O(n) 的队列空间(迭代)进行广度优先搜索。因此总空间复杂度为 O(n+m)。

210. 课程表 II


310. 最小高度树


树是一个无向图,其中任何两个顶点只通过一条路径连接。 换句话说,一个任何没有简单环路的连通图都是一棵树。

给你一棵包含 n 个节点的树,标记为 0 到 n - 1 。给定数字 n 和一个有 n - 1 条无向边的 edges 列表(每一个边都是一对标签),其中 edges[i] = [ai, bi] 表示树中节点 ai 和 bi 之间存在一条无向边。

可选择树中任何一个节点作为根。当选择节点 x 作为根节点时,设结果树的高度为 h 。在所有可能的树中,具有最小高度的树(即,min(h))被称为 最小高度树 。

请你找到所有的 最小高度树 并按 任意顺序 返回它们的根节点标签列表。

树的 高度 是指根节点和叶子节点之间最长向下路径上边的数量。

示例 1:

输入:n = 4, edges = [[1,0],[1,2],[1,3]]
输出:[1]
解释:如图所示,当根是标签为 1 的节点时,树的高度是 1 ,这是唯一的最小高度树。

示例 2:

输入:n = 6, edges = [[3,0],[3,1],[3,2],[3,4],[5,4]]
输出:[3,4]

提示:

  • 1 <= n <= 2 * 104
  • edges.length == n - 1
  • 0 <= ai, bi < n
  • ai != bi
  • 所有 (ai, bi) 互不相同
  • 给定的输入保证是一棵树,并且不会有重复的边

解题思路

dist[x][y] 表示从节点 x 到节点 y 的距离,假设树中距离最长的两个节点为 (x,y),它们之间的距离为 maxdist=dist[x][y],则可以推出以任意节点构成的树最小高度一定为 minheight=⌈ maxdist/2 ⌉,且最小高度的树根节点一定在节点 x 到节点 y 的路径上。

假设最长的路径的 m 个节点依次为 p1p2 → ⋯ → pm,最长路径的长度为 m-1,可以得到以下结论:

  • 如果 m 为偶数,此时最小高度树的根节点为 pm2 或者 pm2+1,且此时最小的高度为 m2

  • 如果 m 为奇数,此时最小高度树的根节点为 pm+12,且此时最小的高度为 m12

🍒🍒🍒因此只需要求出路径最长的两个叶子节点即可,并求出其路径的最中间的节点即为最小高度树的根节点。可以利用以下算法找到图中距离最远的两个节点与它们之间的路径

  • 任意节点 p 出现,利用广度优先搜索或者深度优先搜索找到以 p 为起点的最长路径的终点 x;

  • 以节点 x 出发,找到以 x 为起点的最长路径的终点 y;

x 到 y 之间的路径即为图中的最长路径,找到路径的中间节点即为根节点。此算法证明可以参考「算法导论习题解答 9-1」。

1) 广度优先搜索🍓

此利用广度优先搜索来找到节点的最长路径,首先找到距离节点 0 的最远节点 x,然后找到距离节点 x 的最远节点 y,然后找到节点 x 与节点 y 的路径,然后找到根节点。

  • Java
点击查看代码
class Solution {
public List<Integer> findMinHeightTrees(int n, int[][] edges) {
List<Integer> ans = new ArrayList<Integer>();
//判断边界条件
if(n==1){
ans.add(0);
return ans;
}
//整理边结构
List<List<Integer>> adj = new ArrayList<List<Integer>>();
for(int i=0; i<n; i++){
adj.add(new ArrayList<Integer>());
}
for(int[] info : edges){
adj.get(info[0]).add(info[1]); //用get获取索引值
adj.get(info[1]).add(info[0]);
}
int[] parent = new int[n];
Arrays.fill(parent, -1);
//找路径
int x = findLongestPath(0, parent, adj);
int y = findLongestPath(x, parent, adj);
parent[x] = -1;
List<Integer> path = new ArrayList<Integer>();
while(y!=-1){
path.add(y);
y = parent[y];
}
int num = path.size();
if(num%2 == 0){
ans.add(path.get(num/2-1));
}
ans.add(path.get(num/2));
return ans;
}
public int findLongestPath(int u, int[] parent, List<List<Integer>> adj){
int n = adj.size();
LinkedList<Integer> queue = new LinkedList<>();
queue.offer(u);
boolean visited[] = new boolean[n];
visited[u] = true;
int res = -1;
while(!queue.isEmpty()){
int cur = queue.poll();
res = cur;
for(int v : adj.get(cur)){
if(!visited[v]){
parent[v] = cur;
visited[v] = true;
queue.offer(v);
}
}
}
return res;
}
}
  • C++
点击查看代码
class Solution {
public:
vector<int> findMinHeightTrees(int n, vector<vector<int>>& edges) {
vector<int> ans;
//处理边界
if(n==1){
ans.push_back(0);
return ans;
}
//处理边结构
vector<vector<int>> adj(n);
for(const auto info: edges){
adj[info[0]].push_back(info[1]);
adj[info[1]].push_back(info[0]);
}
vector<int> parent(n,-1);
int x = findLongestPath(0, parent, adj);
int y = findLongestPath(x, parent, adj);
parent[x] = -1;
vector<int> path;
while(y!=-1){
path.push_back(y);
y = parent[y];
}
int num = path.size();
if(num % 2 == 0){
ans.push_back(path[num/2-1]);
}
ans.push_back(path[num/2]);
return ans;
}
int findLongestPath(int u, vector<int>& parent, vector<vector<int>>& adj){
int n = adj.size();
queue<int> q;
q.push(u);
vector<bool> visited(n,false);
visited[u] = true;
int res = -1;
while(!q.empty()){
int cur = q.front();
q.pop();
res = cur;
for(int v: adj[cur]){
if(!visited[v]){
visited[v] = true;
q.push(v);
parent[v] = cur;
}
}
}
return res;
}
};

复杂度分析

  • 时间复杂度:O(n),其中 n 是为节点的个数。图中边的个数为 n−1,因此建立图的关系需要的时间复杂度为 O(n),通过广度优先搜索需要的时间复杂度为 O(n+n−1),求最长路径的时间复杂度为 O(n),因此总的时间复杂度为 O(n)。

  • 空间复杂度:O(n),其中 n 是节点的个数。由于题目给定的图中任何两个顶点都只有一条路径连接,因此图中边的数目刚好等于 n−1,用邻接表构造图所需的空间刚好为 O(2×n),存储每个节点的距离和父节点均为 O(n),使用广度优先搜索时,队列中最多有 n 个元素,所需的空间也为 O(n),因此空间复杂度为 O(n)。

2) 深度优先搜索

使用深度优先搜索来实现。首先找到距离节点 0 的最远节点 x,然后找到距离节点 x 的最远节点 y,然后找到节点 x 与节点 y 的路径,然后找到根节点。

  • Java
点击查看代码
class Solution {
public List<Integer> findMinHeightTrees(int n, int[][] edges) {
List<Integer> ans = new ArrayList<Integer>();
if (n == 1) {
ans.add(0);
return ans;
}
List<Integer>[] adj = new List[n];
for (int i = 0; i < n; i++) {
adj[i] = new ArrayList<Integer>();
}
for (int[] edge : edges) {
adj[edge[0]].add(edge[1]);
adj[edge[1]].add(edge[0]);
}
int[] parent = new int[n];
Arrays.fill(parent, -1);
/* 找到与节点 0 最远的节点 x */
int x = findLongestNode(0, parent, adj);
/* 找到与节点 x 最远的节点 y */
int y = findLongestNode(x, parent, adj);
/* 求出节点 x 到节点 y 的路径 */
List<Integer> path = new ArrayList<Integer>();
parent[x] = -1;
while (y != -1) {
path.add(y);
y = parent[y];
}
int m = path.size();
if (m % 2 == 0) {
ans.add(path.get(m / 2 - 1));
}
ans.add(path.get(m / 2));
return ans;
}
public int findLongestNode(int u, int[] parent, List<Integer>[] adj) {
int n = adj.length;
int[] dist = new int[n];
Arrays.fill(dist, -1);
dist[u] = 0;
dfs(u, dist, parent, adj);
int maxdist = 0;
int node = -1;
for (int i = 0; i < n; i++) {
if (dist[i] > maxdist) {
maxdist = dist[i];
node = i;
}
}
return node;
}
public void dfs(int u, int[] dist, int[] parent, List<Integer>[] adj) {
for (int v : adj[u]) {
if (dist[v] < 0) {
dist[v] = dist[u] + 1;
parent[v] = u;
dfs(v, dist, parent, adj);
}
}
}
}
  • C++
点击查看代码
class Solution {
public:
void dfs(int u, vector<int> & dist, vector<int> & parent, const vector<vector<int>> & adj) {
for (auto & v : adj[u]) {
if (dist[v] < 0) {
dist[v] = dist[u] + 1;
parent[v] = u;
dfs(v, dist, parent, adj);
}
}
}
int findLongestNode(int u, vector<int> & parent, const vector<vector<int>> & adj) {
int n = adj.size();
vector<int> dist(n, -1);
dist[u] = 0;
dfs(u, dist, parent, adj);
int maxdist = 0;
int node = -1;
for (int i = 0; i < n; i++) {
if (dist[i] > maxdist) {
maxdist = dist[i];
node = i;
}
}
return node;
}
vector<int> findMinHeightTrees(int n, vector<vector<int>>& edges) {
if (n == 1) {
return {0};
}
vector<vector<int>> adj(n);
for (auto & edge : edges) {
adj[edge[0]].emplace_back(edge[1]);
adj[edge[1]].emplace_back(edge[0]);
}
vector<int> parent(n, -1);
/* 找到距离节点 0 最远的节点 x */
int x = findLongestNode(0, parent, adj);
/* 找到距离节点 x 最远的节点 y */
int y = findLongestNode(x, parent, adj);
/* 找到节点 x 到节点 y 的路径 */
vector<int> path;
parent[x] = -1;
while (y != -1) {
path.emplace_back(y);
y = parent[y];
}
int m = path.size();
if (m % 2 == 0) {
return {path[m / 2 - 1], path[m / 2]};
} else {
return {path[m / 2]};
}
}
};

复杂度分析

  • 时间复杂度:O(n),其中 n 是为节点的个数。图中边的个数为 n−1,因此建立图的关系需要的时间复杂度为 O(n),通过深度优先搜索需要的时间复杂度为 O(n+n−1),求最长路径的时间复杂度为 O(n),因此总的时间复杂度为 O(n)。

  • 空间复杂度:O(n),其中 n 是节点的个数。由于题目给定的图中任何两个顶点都只有一条路径连接,因此图中边的数目刚好等于 n−1,用邻接表构造图所需的空间刚好为 O(2×n),存储每个节点的距离和父节点均为 O(n),使用深度优先搜索时,递归的最大深度为 O(n),所需的空间也为 O(n),因此总的空间复杂度为 O(n)。

3) 拓扑排序🍓

思路与算法

由于树的高度由根节点到叶子节点之间的最大距离构成,假设树中距离最长的两个节点为 (x,y),它们之间的距离为 maxdist=dist[x][y],假设 x 到 y 的路径为 p1p2 → ⋯ → pk1pk → y,根据方法一的证明已知最小树的根节点一定为该路径中的中间节点,尝试删除最外层的度为 1 的节点 x,y 后,则可以知道路径中与 x,y 相邻的节点 p1pk 此时也变为度为 1 的节点,此时再次删除最外层度为 1 的节点直到剩下根节点为止。

实际做法如下:

  • 首先找到所有度为 1 的节点压入队列,此时令节点剩余计数 remainNodes=n

  • 同时将当前 remainNodes 计数减去出度为 1 的节点数目,将最外层的度为 1 的叶子节点取出,并将与之相邻的节点的度减少,重复上述步骤将当前节点中度为 1 的节点压入队列中;

重复上述步骤,直到剩余的节点数组 remainNodes≤2 时,此时剩余的节点即为当前高度最小树的根节点。

  • Java
点击查看代码
class Solution {
public List<Integer> findMinHeightTrees(int n, int[][] edges) {
List<Integer> ans = new ArrayList<Integer>();
if (n == 1) {
ans.add(0);
return ans;
}
int[] degree = new int[n];
List<Integer>[] adj = new List[n];
for (int i = 0; i < n; i++) {
adj[i] = new ArrayList<Integer>();
}
for (int[] edge : edges) {
adj[edge[0]].add(edge[1]);
adj[edge[1]].add(edge[0]);
degree[edge[0]]++;
degree[edge[1]]++;
}
Queue<Integer> queue = new ArrayDeque<Integer>();
for (int i = 0; i < n; i++) {
if (degree[i] == 1) {
queue.offer(i);
}
}
int remainNodes = n;
while (remainNodes > 2) {
int sz = queue.size();
remainNodes -= sz;
for (int i = 0; i < sz; i++) {
int curr = queue.poll();
for (int v : adj[curr]) {
degree[v]--;
if (degree[v] == 1) {
queue.offer(v);
}
}
}
}
while (!queue.isEmpty()) {
ans.add(queue.poll());
}
return ans;
}
}
  • C++
点击查看代码
class Solution {
public:
vector<int> findMinHeightTrees(int n, vector<vector<int>>& edges) {
if (n == 1) {
return {0};
}
vector<int> degree(n);
vector<vector<int>> adj(n);
for (auto & edge : edges){
adj[edge[0]].emplace_back(edge[1]);
adj[edge[1]].emplace_back(edge[0]);
degree[edge[0]]++;
degree[edge[1]]++;
}
queue<int> qu;
vector<int> ans;
for (int i = 0; i < n; i++) {
if (degree[i] == 1) {
qu.emplace(i);
}
}
int remainNodes = n;
while (remainNodes > 2) {
int sz = qu.size();
remainNodes -= sz;
for (int i = 0; i < sz; i++) {
int curr = qu.front();
qu.pop();
for (auto & v : adj[curr]) {
if (--degree[v] == 1) {
qu.emplace(v);
}
}
}
}
while (!qu.empty()) {
ans.emplace_back(qu.front());
qu.pop();
}
return ans;
}
};

复杂度分析

  • 时间复杂度:O(n),其中 n 是为节点的个数。图中边的个数为 n−1,因此建立图的关系需要的时间复杂度为 O(n),通过广度优先搜索需要的时间复杂度为 O(n+n−1),求最长路径的时间复杂度为 O(n),因此总的时间复杂度为 O(n)。

  • 空间复杂度:O(n),其中 n 是节点的个数。由于题目给定的图中任何两个顶点都只有一条路径连接,因此图中边的数目刚好等于 n−1,用邻接表构造图所需的空间刚好为 O(2×n),存储每个节点的距离和父节点均为 O(n),使用广度优先搜索时,队列中最多有 n 个元素,所需的空间也为 O(n),因此空间复杂度为 O(n)。

4) 换根动态规划

ref

  • Java
点击查看代码
class Solution {
private List<Integer>[] graph;
private int[] height;
private int[] dp;
private static final int VISITED = 23333;
public List<Integer> findMinHeightTrees(int n, int[][] edges) {
// graph[i] -> 节点 i 的所有邻节点 (无向图)
this.graph = new ArrayList[n];
for (int[] edge : edges) {
int a = edge[0], b = edge[1];
if (graph[a] == null) {
graph[a] = new ArrayList<>();
}
graph[a].add(b);
if (graph[b] == null) {
graph[b] = new ArrayList<>();
}
graph[b].add(a);
}
this.height = new int[n];
this.dp = new int[n];
getHeight(0);
dfs(0);
List<Integer> ansList = new ArrayList<>(2);
int min = n;
for (int i = 0; i < n; i++) {
if (min > dp[i]) {
min = dp[i];
ansList.clear();
}
if (min == dp[i]) {
ansList.add(i);
}
}
return ansList;
}
private void getHeight(int root) {
height[root] = VISITED;
if (graph[root] != null) {
int h = 0;
for (int neigh : graph[root]) {
if (height[neigh] == 0) {
getHeight(neigh);
h = Math.max(h, height[neigh]);
}
}
height[root] = h + 1;
}
}
private void dfs(int root) {
dp[root] = VISITED;
if (graph[root] != null) {
// 维护记录子树高度的最大值和次大值
int max = 0, subMax = 0;
for (int neigh : graph[root]) {
if (max < height[neigh]) {
subMax = max;
max = height[neigh];
} else if (subMax < height[neigh]) {
subMax = height[neigh];
}
}
dp[root] = max + 1;
for (int neigh : graph[root]) {
if (dp[neigh] == 0) {
// 更新以 root 为根节点的子树高度,换根到它的邻节点 neigh
height[root] = (height[neigh] == max ? subMax : max) + 1;
// 计算以节点 neigh 为根节点的子树高度,只需要拼上 “以节点 root 为根节点的子树” 即可
dp[neigh] = Math.max(height[neigh], height[root] + 1);
dfs(neigh);
}
}
}
}
}
  • C++
点击查看代码
// height0 表示子树高
// height 表示树高
class Solution {
public:
// dfs1 计算以 0 号节点为根的树中,以各个节点为根的子树高
void dfs1(vector<vector<int>>& graph, vector<int>& height0, int u) {
height0[u] = 1;
int h = 0;
for (int v : graph[u]) {
if (height0[v] != 0) continue;
dfs1(graph, height0, v);
h = max(h, height0[v]);
}
height0[u] = h + 1;
}
// dfs2 进行换根动态规划,计算出所有的树高
void dfs2(vector<vector<int>>& graph, vector<int>& height0, vector<int>& height, int u) {
// 计算子树高的最大值和次大值
int first = 0;
int second = 0;
for (int v : graph[u]) {
if (height0[v] > first) {
second = first;
first = height0[v];
} else if (height0[v] > second)
second = height0[v];
}
height[u] = first + 1;
for (int v : graph[u]) {
// 树高已计算,跳过这个节点
if (height[v] != 0) continue;
// 更新以当前节点为根的子树高,换根到 v
height0[u] = (height0[v] != first ? first : second) + 1;
// 这句代码和前面的 height[u] = first + 1 保留一个即可
// height[v] = max(height0[v], height0[u] + 1);
// 递归进行换根动态规划
dfs2(graph, height0, height, v);
}
}
vector<int> findMinHeightTrees(int n, vector<vector<int>>& edges) {
vector<vector<int>> graph(n);
for (const auto& e : edges) {
graph[e[0]].push_back(e[1]);
graph[e[1]].push_back(e[0]);
}
vector<int> height0(n, 0);
vector<int> height(n, 0);
dfs1(graph, height0, 0);
dfs2(graph, height0, height, 0);
vector<int> ans;
int h = n;
for (int i = 0;i < n;++i) {
if (height[i] < h) {
h = height[i];
ans.clear();
}
if (height[i] == h)
ans.push_back(i);
}
return ans;
}
};

329. 矩阵中的最长递增路径


给定一个 m x n 整数矩阵 matrix ,找出其中最长递增路径的长度。

对于每个单元格,你可以往上,下,左,右四个方向移动。 你不能对角线方向上移动或移动到边界外(即不允许环绕)。

示例 1:

输入:matrix = [[9,9,4],[6,6,8],[2,1,1]]
输出:4
解释:最长递增路径为 [1, 2, 6, 9]。

示例 2:

输入:matrix = [[3,4,5],[3,2,6],[2,2,1]]
输出:4
解释:最长递增路径是 [3, 4, 5, 6]。注意不允许在对角线方向上移动。

提示:

  • m == matrix.length
  • n == matrix[i].length
  • 1 <= m, n <= 200
  • 0 <= matrix[i][j] <= 231 - 1

1) 记忆化深度优先搜索

将矩阵看成一个有向图,每个单元格对应图中的一个节点,如果相邻的两个单元格的值不相等,则在相邻的两个单元格之间存在一条从较小值指向较大值的有向边。问题转化成在有向图中寻找最长路径。

深度优先搜索是非常直观的方法。从一个单元格开始进行深度优先搜索,即可找到从该单元格开始的最长递增路径。对每个单元格分别进行深度优先搜索之后,即可得到矩阵中的最长递增路径的长度。

但是如果使用朴素深度优先搜索,时间复杂度是指数级,会超出时间限制,因此必须加以优化。

朴素深度优先搜索的时间复杂度过高的原因是进行了大量的重复计算,同一个单元格会被访问多次,每次访问都要重新计算。由于同一个单元格对应的最长递增路径的长度是固定不变的,因此可以使用记忆化的方法进行优化。用矩阵 memo 作为缓存矩阵,已经计算过的单元格的结果存储到缓存矩阵中。

使用记忆化深度优先搜索,当访问到一个单元格 (i,j) 时,如果 memo[i][j]!=0,说明该单元格的结果已经计算过,则直接从缓存中读取结果,如果 memo[i][j]=0,说明该单元格的结果尚未被计算过,则进行搜索,并将计算得到的结果存入缓存中。(由于是有由小值指向大值的有向边,所以使用记忆法不会存在回路,即不会存在重复的节点被访问!)

遍历完矩阵中的所有单元格之后,即可得到矩阵中的最长递增路径的长度。

  • Java
点击查看代码
class Solution {
public int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
public int rows, columns;
public int longestIncreasingPath(int[][] matrix) {
if(matrix==null || matrix.length==0 || matrix[0].length==0){
return 0;
}
rows = matrix.length;
columns = matrix[0].length;
int[][] memo = new int[rows][columns];
int ans = 0;
for(int i=0; i<rows; i++){
for(int j=0; j<columns; j++){
ans = Math.max(ans, dfs(matrix, i, j, memo));
}
}
return ans;
}
public int dfs(int[][] matrix, int row, int column, int[][] memo){
if(memo[row][column]!=0){
return memo[row][column];
}
memo[row][column] = 1;
for(int[] dir :dirs){
int newRow = row + dir[0], newColumn = column + dir[1];
if(newRow>=0 && newRow<rows && newColumn>=0 && newColumn<columns && matrix[newRow][newColumn]>matrix[row][column]){
memo[row][column] = Math.max(memo[row][column], dfs(matrix, newRow, newColumn, memo)+1);
//memo[row][column]:存储以当前为起点,最长增长路径
//因此,需要dfs(matrix, newRow, newColumn, memo)+1,加的1指的是当前点
}
}
return memo[row][column];
}
}
  • C++
点击查看代码
class Solution {
public:
vector<vector<int>> dirs = {{-1,0},{1,0},{0,-1},{0,1}};
int rows, columns;
int longestIncreasingPath(vector<vector<int>>& matrix) {
if(matrix.size()==0 || matrix[0].size()==0){
return 0;
}
rows = matrix.size();
columns = matrix[0].size();
auto memo = vector< vector<int> > (rows, vector <int> (columns)); //!
int ans = 0;
for(int i=0; i<rows; i++){
for(int j=0; j<columns; j++){
ans = max(ans, dfs(matrix, i, j, memo));
}
}
return ans;
}
int dfs(vector<vector<int>>& matrix, int row, int column, vector< vector<int> > &memo){
if(memo[row][column]!=0){
return memo[row][column];
}
memo[row][column] = 1;
for(auto dir :dirs){
int newRow = row + dir[0], newColumn = column + dir[1];
if(newRow>=0 && newRow<rows && newColumn>=0 && newColumn<columns && matrix[newRow][newColumn]>matrix[row][column]){
memo[row][column] = max(memo[row][column], dfs(matrix, newRow, newColumn, memo)+1);
}
}
return memo[row][column];
}
};

复杂度分析

  • 时间复杂度:O(mn),其中 m 和 n 分别是矩阵的行数和列数。深度优先搜索的时间复杂度是 O(V+E),其中 V 是节点数,E 是边数。在矩阵中,O(V)=O(mn),O(E)≈O(4mn)=O(mn)。

  • 空间复杂度:O(mn),其中 m 和 n 分别是矩阵的行数和列数。空间复杂度主要取决于缓存和递归调用深度,缓存的空间复杂度是 O(mn),递归调用深度不会超过 mn。

2) 拓扑排序

从方法一可以看到,每个单元格对应的最长递增路径的结果只和相邻单元格的结果有关,那么是否可以使用动态规划求解?

根据方法一的分析,动态规划的状态定义和状态转移方程都很容易得到。方法一中使用的缓存矩阵 memo 即为状态值,状态转移方程如下:

memo[i][j]=maxmemo[x][y]+1

其中 (x,y) 与 (i,j) 在矩阵中相邻,并且 matrix[x][y]>matrix[i][j]

动态规划除了状态定义和状态转移方程,还需要考虑边界情况。这里的边界情况是什么呢?

如果一个单元格的值比它的所有相邻单元格的值都要大,那么这个单元格对应的最长递增路径是 1,这就是边界条件。这个边界条件并不直观,而是需要根据矩阵中的每个单元格的值找到作为边界条件的单元格。

使用方法一的思想,将矩阵看成一个有向图,计算每个单元格对应的出度,即有多少条边从该单元格出发。对于作为边界条件的单元格,该单元格的值比所有的相邻单元格的值都要大,因此作为边界条件的单元格的出度都是 0

基于出度的概念,可以使用拓扑排序求解。从所有出度为 0 的单元格开始广度优先搜索,每一轮搜索都会遍历当前层的所有单元格,更新其余单元格的出度,并将出度变为 0 的单元格加入下一层搜索。当搜索结束时,搜索的总层数即为矩阵中的最长递增路径的长度。

  • Java
点击查看代码
class Solution {
public int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
public int rows, columns;
public int longestIncreasingPath(int[][] matrix) {
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return 0;
}
rows = matrix.length;
columns = matrix[0].length;
int[][] outdegrees = new int[rows][columns];
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < columns; ++j) {
for (int[] dir : dirs) {
int newRow = i + dir[0], newColumn = j + dir[1];
if (newRow >= 0 && newRow < rows && newColumn >= 0 && newColumn < columns && matrix[newRow][newColumn] > matrix[i][j]) {
++outdegrees[i][j];
}
}
}
}
Queue<int[]> queue = new LinkedList<int[]>();
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < columns; ++j) {
if (outdegrees[i][j] == 0) {
queue.offer(new int[]{i, j});
}
}
}
int ans = 0;
while (!queue.isEmpty()) {
++ans;
int size = queue.size();
for (int i = 0; i < size; ++i) {
int[] cell = queue.poll();
int row = cell[0], column = cell[1];
for (int[] dir : dirs) {
int newRow = row + dir[0], newColumn = column + dir[1];
if (newRow >= 0 && newRow < rows && newColumn >= 0 && newColumn < columns && matrix[newRow][newColumn] < matrix[row][column]) {
--outdegrees[newRow][newColumn]; //!?
if (outdegrees[newRow][newColumn] == 0) {
queue.offer(new int[]{newRow, newColumn});
}
}
}
}
}
return ans;
}
}
  • C++
点击查看代码
class Solution {
public:
static constexpr int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
int rows, columns;
int longestIncreasingPath(vector< vector<int> > &matrix) {
if (matrix.size() == 0 || matrix[0].size() == 0) {
return 0;
}
rows = matrix.size();
columns = matrix[0].size();
auto outdegrees = vector< vector<int> > (rows, vector <int> (columns));
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < columns; ++j) {
for (int k = 0; k < 4; ++k) {
int newRow = i + dirs[k][0], newColumn = j + dirs[k][1];
if (newRow >= 0 && newRow < rows && newColumn >= 0 && newColumn < columns && matrix[newRow][newColumn] > matrix[i][j]) {
++outdegrees[i][j];
}
}
}
}
queue < pair<int, int> > q;
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < columns; ++j) {
if (outdegrees[i][j] == 0) {
q.push({i, j});
}
}
}
int ans = 0;
while (!q.empty()) {
++ans;
int size = q.size();
for (int i = 0; i < size; ++i) {
auto cell = q.front(); q.pop();
int row = cell.first, column = cell.second;
for (int k = 0; k < 4; ++k) {
int newRow = row + dirs[k][0], newColumn = column + dirs[k][1];
if (newRow >= 0 && newRow < rows && newColumn >= 0 && newColumn < columns && matrix[newRow][newColumn] < matrix[row][column]) {
--outdegrees[newRow][newColumn];
if (outdegrees[newRow][newColumn] == 0) {
q.push({newRow, newColumn});
}
}
}
}
}
return ans;
}
};

复杂度分析

  • 时间复杂度:O(mn),其中 m 和 n 分别是矩阵的行数和列数。拓扑排序的时间复杂度是 O(V+E),其中 V 是节点数,E 是边数。在矩阵中,O(V)=O(mn),O(E)≈O(4mn)=O(mn)。

  • 空间复杂度:O(mn),其中 m 和 n 分别是矩阵的行数和列数。空间复杂度主要取决于队列,队列中的元素个数不会超过 mn。

332. 重新安排行程


给你一份航线列表 tickets ,其中 tickets[i] = [fromi, toi] 表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。

所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。

例如,行程 ["JFK", "LGA"]["JFK", "LGB"] 相比就更小,排序更靠前。

假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次只能用一次

示例 1:

输入:tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
输出:["JFK","MUC","LHR","SFO","SJC"]

示例 2:

输入:tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]
输出:["JFK","ATL","JFK","SFO","ATL","SFO"]
解释:另一种有效的行程是 ["JFK","SFO","ATL","JFK","ATL","SFO"] ,但是它字典排序更大更靠后。

提示:

  • 1 <= tickets.length <= 300
  • tickets[i].length == 2
  • fromi.length == 3
  • toi.length == 3
  • fromitoi 由大写英文字母组成
  • fromi != toi

「753. 破解保险箱」类似,是力扣平台上为数不多的求解欧拉回路 / 欧拉通路的题目。

前言

化简本题题意:给定一个 n 个点 m 条边的图,要求从指定的顶点出发,经过所有的边恰好一次(可以理解为给定起点的「一笔画」问题),使得路径的字典序最小。

这种「一笔画」问题与欧拉图或者半欧拉图有着紧密的联系,下面给出定义:

  • 通过图中所有边恰好一次且行遍所有顶点的通路称为欧拉通路;(图连通;除2个端点外其余节点入度=出度;1个端点入度比出度大 1;一个端点入度比出度小 1 或 所有节点入度等于出度 ==> 不回到原点)
  • 通过图中所有边恰好一次且行遍所有顶点的回路称为欧拉回路;(图连通;所有节点入度等于出度 ==> 回到原点)
  • 具有欧拉回路的无向图称为欧拉图;
  • 具有欧拉通路但不具有欧拉回路的无向图称为半欧拉图。

因为本题保证至少存在一种合理的路径,即,这张图是一个欧拉图或者半欧拉图。只需要输出这条欧拉通路的路径即可。

考虑下面的这张图:

从起点 JFK 出发,合法路径有两条:

  • JFK→AAA→JFK→BBB→JFK

  • JFK→BBB→JFK→AAA→JFK

既然要求字典序最小,那么每次应该贪心地选择当前节点所连的节点中字典序最小的那一个,并将其入栈。最后栈中就保存了遍历的顺序。

为了保证能够快速找到当前节点所连的节点中字典序最小的那一个,可以使用优先队列存储当前节点所连到的点,每次 O(1) 地找到最小字典序的节点,并 O(logm) 地删除它。

然后考虑一种特殊情况:

当先访问 AAA 时,无法回到 JFK,这样就无法访问剩余的边了。

也就是说,当贪心地选择字典序最小的节点前进时,可能先走入「死胡同」,从而导致无法遍历到其他还未访问的边。于是希望能够遍历完当前节点所连接的其他节点后再进入「死胡同」。

注意对于每一个节点,它只有最多一个「死胡同」分支。依据前言中对于半欧拉图的描述,只有那个入度与出度差为 1 的节点会导致死胡同。

Hierholzer 算法

Hierholzer 算法用于在连通图中寻找欧拉路径,其流程如下:

  • 从起点出发,进行深度优先搜索。

  • 每次沿着某条边从某个顶点移动到另外一个顶点的时候,都需要删除这条边。

  • 如果没有可移动的路径,则将所在节点加入到栈中,并返回。(怎么理解这句话)

当顺序地考虑该问题时,也许很难解决该问题,因为无法判断当前节点的哪一个分支是「死胡同」分支。

不妨倒过来思考。注意到只有那个入度与出度差为 1 的节点会导致死胡同。而该节点必然是最后一个遍历到的节点。可以改变入栈的规则,当遍历完一个节点所连的所有节点后,才将该节点入栈(即逆序入栈)。

对于当前节点而言,从它的每一个非「死胡同」分支出发进行深度优先搜索,都将会搜回到当前节点。而从它的「死胡同」分支出发进行深度优先搜索将不会搜回到当前节点。也就是说当前节点的死胡同分支将会优先于其他非「死胡同」分支入栈

这样就能保证可以「一笔画」地走完所有边,最终的栈中逆序地保存了「一笔画」的结果。只要将栈中的内容反转,即可得到答案。

Java-代码🍀
class Solution {
// 构造图,key是始发地,value是目的地,目的地用PriorityQueue存放,就可以自动按升序排序
Map<String, PriorityQueue<String>> map = new HashMap<>();
List<String> path = new LinkedList<>();
public List<String> findItinerary(List<List<String>> tickets) {
for(List<String> tic : tickets){
String src = tic.get(0), dst = tic.get(1);
if(!map.containsKey(src)){
map.put(src, new PriorityQueue<String>());
}
map.get(src).offer(dst);
}
dfs_Euler("JFK"); // 从起点开始深度优先搜索
Collections.reverse(path); // 反转链表,最先找到的是最深的不能再走的目的地,所以要反转过来
return path;
}
//注意与回溯区别
public void dfs_Euler(String src){
while(map.containsKey(src) && map.get(src).size()>0){
dfs_Euler(map.get(src).poll());
}
path.add(src);
}
}

C++-代码🍀
class Solution {
public:
unordered_map<string, priority_queue<string, vector<string>, std::greater<string>>> vec;
vector<string> stk;
void dfs(const string& curr) {
while (vec.count(curr) && vec[curr].size() > 0) {
string tmp = vec[curr].top();
vec[curr].pop();
dfs(move(tmp));
}
stk.emplace_back(curr);
}
vector<string> findItinerary(vector<vector<string>>& tickets) {
for (auto& it : tickets) {
vec[it[0]].emplace(it[1]);
}
dfs("JFK");
reverse(stk.begin(), stk.end());
return stk;
}
};

C++ move

  1. std::move函数可以以非常简单的方式将左值引用转换为右值引用。

  2. 通过std::move,可以避免不必要的拷贝操作。

  3. std::move是为性能而生。

  4. std::move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝。两个地址是独立的,move操作实际上是系统将这一块地址属于哪一个地址的登记改一下,实际上这一块内存根本没有发生任何变化。

复杂度分析

  • 时间复杂度:O(mlogm),其中 m 是边的数量。对于每一条边需要 O(logm) 地删除它,最终的答案序列长度为 m+1,而与 n 无关。

  • 空间复杂度:O(m),其中 m 是边的数量。需要存储每一条边。

399. 除法求值


给你一个变量对数组 equations 和一个实数值数组 values 作为已知条件,其中 equations[i] = [Ai, Bi]values[i] 共同表示等式 Ai / Bi = values[i]。每个 Ai 或 Bi 是一个表示单个变量的字符串。

另有一些以数组 queries 表示的问题,其中 queries[j] = [Cj, Dj] 表示第 j 个问题,请你根据已知条件找出 Cj / Dj = ? 的结果作为答案。

返回 所有问题的答案 。如果存在某个无法确定的答案,则用 -1.0 替代这个答案。如果问题中出现了给定的已知条件中没有出现的字符串,也需要用 -1.0 替代这个答案。

注意:输入总是有效的。你可以假设除法运算中不会出现除数为 0 的情况,且不存在任何矛盾的结果。

示例 1
输入:equations = [["a","b"],["b","c"]], values = [2.0,3.0], queries = [["a","c"],["b","a"],["a","e"],["a","a"],["x","x"]]
输出:[6.00000,0.50000,-1.00000,1.00000,-1.00000]
解释:
条件:a / b = 2.0, b / c = 3.0
问题:a / c = ?, b / a = ?, a / e = ?, a / a = ?, x / x = ?
结果:[6.0, 0.5, -1.0, 1.0, -1.0 ]
示例 2
输入:equations = [["a","b"],["b","c"],["bc","cd"]], values = [1.5,2.5,5.0], queries = [["a","c"],["c","b"],["bc","cd"],["cd","bc"]]
输出:[3.75000,0.40000,5.00000,0.20000]
示例 3
输入:equations = [["a","b"]], values = [0.5], queries = [["a","b"],["b","a"],["a","c"],["x","y"]]
输出:[0.50000,2.00000,-1.00000,-1.00000]

提示:

  • 1 <= equations.length <= 20
  • equations[i].length == 2
  • 1 <= Ai.length, Bi.length <= 5
  • values.length == equations.length
  • 0.0 < values[i] <= 20.0
  • 1 <= queries.length <= 20
  • queries[i].length == 2
  • 1 <= Cj.length, Dj.length <= 5
  • Ai, Bi, Cj, Dj小写英文字母与数字组成

说明:题目中的「注意」和「数据范围」,如:每个 AiBi 是一个表示单个变量的字符串。所以用例 equation = ["ab", "cd"],这里的 ab 视为一个变量,不表示 a * b

并查集

由于变量之间的倍数关系具有传递性,处理有传递性关系的问题,可以使用「并查集」,需要在并查集的「合并」与「查询」操作中维护这些变量之间的倍数关系。

分析示例 1

a/b=2.0 说明 a=2bab 在同一个集合中;

b/c=3.0 说明 b=3cbc 在同一个集合中。

ac,可以把 a=2bb=3c 依次代入,得到 ac=2bc=23cc=6.0;求 ba,很显然根据 a=2b,知道 ba=0.5 ,也可以把 ba 都转换成为 c 的倍数,ba=b2b=3c6c=12=0.5

因此,可以将题目给出的 equation 中的两个变量所在的集合进行「合并」,同在一个集合中的两个变量就可以通过某种方式计算出它们的比值。具体来说,可以把不同的变量的比值转换成为相同的变量的比值,这样在做除法的时候就可以消去相同的变量,然后再计算转换成相同变量以后的系数的比值,就是题目要求的结果。统一了比较的标准,可以以 O(1) 的时间复杂度完成计算。

如果两个变量不在同一个集合中, 返回 −1.0。并且根据题目的意思,如果两个变量中至少有一个变量没有出现在所有 equations 出现的字符集合中,也返回 −1.0。

构建有向图

通过例 1 的分析,可以知道,题目给出的 equations 和 values 可以表示成一个图,equations 中出现的变量就是图的顶点,「分子」于「分母」的比值可以表示成一个有向关系(因为「分子」和「分母」是有序的,不可以对换),并且这个图是一个带权图,values 就是对应的有向边的权值。例 1 中给出的 equations 和 values 表示的「图形表示」、「数学表示」和「代码表示」如下表所示。其中 parent[a] = b 表示:结点 a 的(直接)父亲结点是 b,与之对应的有向边的权重,记为 weight[a] = 2.0,即 weight[a] 表示结点 a 到它的直接父亲结点的有向边的权重。

「统一变量」与「路径压缩」的关系

刚刚在分析例 1 的过程中,提到了:可以把一个一个 query 中的不同变量转换成同一个变量,这样在计算 query 的时候就可以以 O(1) 的时间复杂度计算出结果,在「并查集」的一个优化技巧中,「路径压缩」就恰好符合了这样的应用场景。

为了避免并查集所表示的树形结构高度过高,影响查询性能。「路径压缩」就是针对树的高度的优化。「路径压缩」的效果是:在查询一个结点 a 的根结点同时,把结点 a 到根结点的沿途所有结点的父亲结点都指向根结点。如下图所示,除了根结点以外,所有的结点的父亲结点都指向了根结点。特别地,也可以认为根结点的父亲结点就是根结点自己。如下国所示:路径压缩前后,并查集所表示的两棵树形结构等价,路径压缩以后的树的高度为 2,查询性能最好。

由于有「路径压缩」的优化,两个同在一个连通分量中的不同的变量,它们分别到根结点(父亲结点)的权值的比值,就是题目的要求的结果。

如何在「查询」操作的「路径压缩」优化中维护权值变化

如下图所示,在结点 a 执行一次「查询」操作。路径压缩会先一层一层向上先找到根结点 d,然后依次把 c、b 、a 的父亲结点指向根结点 d。

  • c 的父亲结点已经是根结点了,它的权值不用更改;
  • b 的父亲结点要修改成根结点,它的权值就是从当前结点到根结点经过的所有有向边的权值的乘积,因此是 3.0 乘以 4.0 也就是 12.0;
  • a 的父亲结点要修改成根结点,它的权值就是依然是从当前结点到根结点经过的所有有向边的权值的乘积,但是我们 没有必要把这三条有向边的权值乘起来,这是因为 b 到 c,c 到 d 这两条有向边的权值的乘积,我们在把 b 指向 d 的时候已经计算出来了。因此,a 到根结点的权值就等于 b 到根结点 d 的新的权值乘以 a 到 b 的原来的有向边的权值。

如何在「合并」操作中维护权值的变化

「合并」操作基于这样一个很重要的前提:将要合并的两棵树的高度最多为 2,换句话说两棵树都必需是「路径压缩」以后的效果,两棵树的叶子结点到根结点最多只需要经过一条有向边。

例如已知 ab=3.0dc=4.0,又已知 ad=6.0,现在合并结点 a 和 d 所在的集合,其实就是把 a 的根结点 b 指向 d 的根结 c,那么如何计算 b 指向 c 的这条有向边的权重呢?

根据 a 经过 b 可以到达 c,a 经过 d 也可以到达 c,因此两条路径上的有向边的权值的乘积是一定相等的。设 b 到 c 的权值为 x,那么 3.0x=6.04.0,得 x=8.0

一个容易忽略的细节

接下来还有一个小的细节问题:在合并以后,产生了一棵高度为 3 的树,那么在执行查询的时候,例如下图展示的绿色结点和黄色结点,绿色结点并不直接指向根结点,在计算这两个变量的比值的时候,计算边的权值的比值得到的结果是不对的。

但其实不用担心这个问题,并查集的「查询」操作会执行「路径压缩」,所以真正在计算两个变量的权值的时候,绿色结点已经指向了根结点,和黄色结点的根结点相同。因此可以用它们指向根结点的有向边的权值的比值作为两个变量的比值。

通过这个细节向大家强调:一边查询一边修改结点指向是并查集的特色

Java-代码🍀
C++-代码🍀

复杂度分析:

  • 时间复杂度:O((N+Q)logA)
    • 构建并查集 O(NlogA),这里 N 为输入方程 equations 的长度,每一次执行合并操作的时间复杂度是 O(logA),这里 A 是 equations 里不同字符的个数;
    • 查询并查集 O(QlogA),这里 Q 为查询数组 queries 的长度,每一次查询时执行「路径压缩」的时间复杂度是 O(logA)
  • 空间复杂度:O(A):创建字符与 id 的对应关系 hashMap 长度为 A,并查集底层使用的两个数组 parent 和 weight 存储每个变量的连通分量信息,parent 和 weight 的长度均为 A。

547. 省份数量


有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。

省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。

给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。

返回矩阵中 省份 的数量。

示例 1:

输入:isConnected = [[1,1,0],[1,1,0],[0,0,1]]
输出:2

示例 2:

输入:isConnected = [[1,0,0],[0,1,0],[0,0,1]]
输出:3

提示:

  • 1 <= n <= 200
  • n == isConnected.length
  • n == isConnected[i].length
  • isConnected[i][j] 为 1 或 0
  • isConnected[i][i] == 1
  • isConnected[i][j] == isConnected[j][i]

前言

可以把 n 个城市和它们之间的相连关系看成图,城市是图中的节点,相连关系是图中的边,给定的矩阵 isConnected 即为图的邻接矩阵,省份即为图中的连通分量。

计算省份总数,等价于计算图中的连通分量数,可以通过深度优先搜索或广度优先搜索实现,也可以通过并查集实现。

1) 深度优先搜索

深度优先搜索:遍历所有城市,对于每个城市,如果该城市尚未被访问过,则从该城市开始深度优先搜索,通过矩阵 isConnected 得到与该城市直接相连的城市有哪些,这些城市和该城市属于同一个连通分量,然后对这些城市继续深度优先搜索,直到同一个连通分量的所有城市都被访问到,即可得到一个省份。遍历完全部城市以后,即可得到连通分量的总数,即省份的总数。

Java-代码🍀
class Solution {
public int findCircleNum(int[][] isConnected) {
int cities = isConnected.length;
boolean[] visited = new boolean[cities];
int provinces = 0;
for(int i=0; i<cities; i++){
if(!visited[i]){
dfs(isConnected, visited, cities, i);
provinces++;
}
}
return provinces;
}
public void dfs(int[][] isConnected, boolean[] visited, int cities, int i){
for(int j=0; j<cities; j++){
if(isConnected[i][j]==1 && !visited[j]){
visited[j] = true; //放在 dfs 的语句前!
dfs(isConnected, visited, cities, j);
}
}
}
}
C++-代码🍀
class Solution {
public:
void dfs(vector<vector<int>>& isConnected, vector<bool>& visited, int cities, int i) {
for (int j = 0; j < cities; j++) {
if (isConnected[i][j] == 1 && !visited[j]) {
visited[j] = true;
dfs(isConnected, visited, cities, j);
}
}
}
int findCircleNum(vector<vector<int>>& isConnected) {
int cities = isConnected.size();
vector<bool> visited(cities);
int provinces = 0;
for (int i = 0; i < cities; i++) {
if (!visited[i]) {
dfs(isConnected, visited, cities, i);
provinces++;
}
}
return provinces;
}
};

复杂度分析

时间复杂度:O(n2),其中 n 是城市的数量。需要遍历矩阵 n 中的每个元素。

空间复杂度:O(n),其中 n 是城市的数量。需要使用数组 visited 记录每个城市是否被访问过,数组长度是 n,递归调用栈的深度不会超过 n

2) 广度优先搜索

通过广度优先搜索的方法得到省份的总数。对于每个城市,如果该城市尚未被访问过,则从该城市开始广度优先搜索,直到同一个连通分量中的所有城市都被访问到,即可得到一个省份。

Java-代码🍀
class Solution {
public int findCircleNum(int[][] isConnected) {
int cities = isConnected.length;
boolean[] visited = new boolean[cities];
int provinces = 0;
Queue<Integer> queue = new LinkedList<Integer>();
for (int i = 0; i < cities; i++) {
if (!visited[i]) {
queue.offer(i);
while (!queue.isEmpty()) {
int j = queue.poll();
visited[j] = true;
for (int k = 0; k < cities; k++) {
if (isConnected[j][k] == 1 && !visited[k]) {
queue.offer(k);
}
}
}
provinces++;
}
}
return provinces;
}
}
C++-代码🍀
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
int cities = isConnected.size();
vector<int> visited(cities);
int provinces = 0;
queue<int> Q;
for (int i = 0; i < cities; i++) {
if (!visited[i]) {
Q.push(i);
while (!Q.empty()) {
int j = Q.front(); Q.pop();
visited[j] = 1;
for (int k = 0; k < cities; k++) {
if (isConnected[j][k] == 1 && !visited[k]) {
Q.push(k);
}
}
}
provinces++;
}
}
return provinces;
}
};

复杂度分析

时间复杂度:O(n2),其中 n 是城市的数量。需要遍历矩阵 isConnected 中的每个元素。

空间复杂度:O(n),其中 n 是城市的数量。需要使用数组 visited 记录每个城市是否被访问过,数组长度是 n,广度优先搜索使用的队列的元素个数不会超过 n

3) 并查集

计算连通分量数的另一个方法是使用并查集。初始时,每个城市都属于不同的连通分量。遍历矩阵 isConnected,如果两个城市之间有相连关系,则它们属于同一个连通分量,对它们进行合并。

遍历矩阵 isConnected 的全部元素之后,计算连通分量的总数,即为省份的总数。

处理过程

  • 一开始所有令城市为单元素集合。
  • 遍历矩阵,将值为1时的两个城市合并在一起得到一个2元素的集合。之后当某个单元素集合或者2元素集合要再与一个2元素集合合并时,我们需要判断这两个元素是否不在一个集合中,这需要一个查询元素所属集合的操作。
  • 反复进行这样的查询合并操作,当结束矩阵遍历时,得到的集合都是不相交的的省份。

输入矩阵:一共有 6 个城市。

// 输入矩阵M
{
{1, 0, 0, 0, 1, 0}, // 0-0, 0-4
{0, 1, 0, 0, 0, 1}, // 1-1, 1-5
{0, 0, 1, 1, 0, 1}, // 2-2, 2-3, 2-5
{0, 0, 1, 1, 0, 0}, // 3-2, 3-3
{1, 0, 0, 0, 1, 0}, // 4-0, 4-4
{0, 1, 1, 0, 0, 1}, // 5-1, 5-2, 5-5
}

初始化:开始时每个节点都是以自己为根的单节点树,即 parent[x] = x

合并:接着遍历矩阵,执行合并。合并的依据是查询,对于 x 和 y,如果 find(x) = find(y),说明代表元相同,属于同一集合,不必合并。若 find(x) != find(y),将其合并,令parent[find(y)] = find(x)( 或 parent[find(x)] = find(y))。从树的角度来看相当于把 y 挂在了 x 上(或把 x 挂在 y 上)。现在先假设查询方法 find(x) 能够正常工作,该方法返回 x 所在树的的树根(所在集合的代表元)。代表元可以是集合中的任意一个元素,所以一开始选谁做根(代表元)是无关紧要的。

※ 省份数量可以在union方法内添加一行代码(发生合并时令unionCount++)实现累计,最后以元素总数减去合并次数得到不相交集数量。也可以在合并完成后对所有元素执行一次find(x),统计不同结果的个数得到。

查询:在合并前需要查询元素所在树的根节点,由于不相交集都是一棵树,每一个节点 x 都以 parent[x] 指向其父节点,根的父节点为其自身,故可以用递归的方式查询一个节点的根节点。

// 查询:没有路径压缩
public int find(int x) {
if(parent[x] == x){ // 只有根节点满足parent[x] = x
return x;
} else{
return find(parent[x]);
}
}
Java-代码🍀
class Solution {
public int findCircleNum(int[][] isConnected) {
int cities = isConnected.length;
int[] parent = new int[cities];
for (int i = 0; i < cities; i++) {
// 开始时每个节点都是以自己为根的单节点树
parent[i] = i;
}
for (int i = 0; i < cities; i++) {
for (int j = i + 1; j < cities; j++) {
if (isConnected[i][j] == 1) {
union(parent, i, j);
}
}
}
int provinces = 0;
for (int i = 0; i < cities; i++) {
if (parent[i] == i) {
provinces++;
}
}
return provinces;
}
// 合并
public void union(int[] parent, int index1, int index2) {
parent[find(parent, index1)] = find(parent, index2);
}
// 查询:带路径压缩的查找
public int find(int[] parent, int index) {
if (parent[index] != index) {
parent[index] = find(parent, parent[index]);
}
// 只有根节点满足parent[x] = x
return parent[index];
}
}
C++-代码🍀
class Solution {
public:
int Find(vector<int>& parent, int index) {
if (parent[index] != index) {
parent[index] = Find(parent, parent[index]);
}
return parent[index];
}
void Union(vector<int>& parent, int index1, int index2) {
parent[Find(parent, index1)] = Find(parent, index2);
}
int findCircleNum(vector<vector<int>>& isConnected) {
int cities = isConnected.size();
vector<int> parent(cities);
for (int i = 0; i < cities; i++) {
parent[i] = i;
}
for (int i = 0; i < cities; i++) {
for (int j = i + 1; j < cities; j++) {
if (isConnected[i][j] == 1) {
Union(parent, i, j);
}
}
}
int provinces = 0;
for (int i = 0; i < cities; i++) {
if (parent[i] == i) {
provinces++;
}
}
return provinces;
}
};

复杂度分析

  • 时间复杂度:O(n2logn),其中 n 是城市的数量。需要遍历矩阵 isConnected 中的所有元素,时间复杂度是 O(n2),如果遇到相连关系,则需要进行 2 次查找和最多 1 次合并,一共需要进行 2n2 次查找和最多 n2 次合并,因此总时间复杂度是 O(2n2logn2)=O(n2logn)。这里的并查集使用了路径压缩,但是没有使用按秩合并,最坏情况下的时间复杂度是 O(n2logn),平均情况下的时间复杂度依然是 O(n2α(n)),其中 \alphaα 为阿克曼函数的反函数,α(n) 可以认为是一个很小的常数。

  • 空间复杂度:O(n),其中 n 是城市的数量。需要使用数组 parent 记录每个城市所属的连通分量的祖先。

并查集参考文章

684. 冗余连接


树可以看成是一个连通且 无环 的 无向 图。

给定往一棵 n 个节点 (节点值 1~n) 的树中添加一条边后的图。添加的边的两个顶点包含在 1 到 n 中间,且这条附加的边不属于树中已存在的边。图的信息记录于长度为 n 的二维数组 edgesedges[i] = [ai, bi] 表示图中在 ai 和 bi 之间存在一条边。

请找出一条可以删去的边,删除后可使得剩余部分是一个有着 n 个节点的树。如果有多个答案,则返回数组 edges 中最后出现的边。

示例 1:

输入: edges = [[1,2], [1,3], [2,3]]
输出: [2,3]

示例 2:

输入: edges = [[1,2], [2,3], [3,4], [1,4], [1,5]]
输出: [1,4]

提示:

  • n == edges.length
  • 3 <= n <= 1000
  • edges[i].length == 2
  • 1 <= ai < bi <= edges.length
  • ai != bi
  • edges 中无重复元素
  • 给定的图是连通的

并查集

在一棵树中,边的数量比节点的数量少 1。如果一棵树有 n 个节点,则这棵树有 n−1 条边。这道题中的图在树的基础上多了一条附加的边,因此边的数量也是 n。

树是一个连通且无环的无向图,在树中多了一条附加的边之后就会出现环,因此附加的边即为导致环出现的边。

可以通过并查集寻找附加的边。初始时,每个节点都属于不同的连通分量。遍历每一条边,判断这条边连接的两个顶点是否属于相同的连通分量。

  • 如果两个顶点属于不同的连通分量,则说明在遍历到当前的边之前,这两个顶点之间不连通,因此当前的边不会导致环出现,合并这两个顶点的连通分量。

  • 如果两个顶点属于相同的连通分量,则说明在遍历到当前的边之前,这两个顶点之间已经连通,因此当前的边导致环出现,为附加的边,将当前的边作为答案返回。

Java-代码🍀
class Solution {
public int[] findRedundantConnection(int[][] edges) {
int n = edges.length;
int[] parent = new int[n + 1]; //因为树的节点从1开始编号,所以第0位没有使用
for (int i = 1; i <= n; i++) {
parent[i] = i;
}
for (int i = 0; i < n; i++) {
int[] edge = edges[i];
int node1 = edge[0], node2 = edge[1];
if (find(parent, node1) != find(parent, node2)) {
union(parent, node1, node2);
} else {
return edge;
}
}
return new int[0];
}
public void union(int[] parent, int index1, int index2) {
parent[find(parent, index1)] = find(parent, index2);
}
public int find(int[] parent, int index) {
if (parent[index] != index) {
parent[index] = find(parent, parent[index]);
}
return parent[index];
}
}
C++-代码🍀
class Solution {
public:
int Find(vector<int>& parent, int index) {
if (parent[index] != index) {
parent[index] = Find(parent, parent[index]);
}
return parent[index];
}
void Union(vector<int>& parent, int index1, int index2) {
parent[Find(parent, index1)] = Find(parent, index2);
}
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
int n = edges.size();
vector<int> parent(n + 1);
for (int i = 1; i <= n; ++i) {
parent[i] = i;
}
for (auto& edge: edges) {
int node1 = edge[0], node2 = edge[1];
if (Find(parent, node1) != Find(parent, node2)) {
Union(parent, node1, node2);
} else {
return edge;
}
}
return vector<int>{};
}
};

复杂度分析

  • 时间复杂度:O(nlogn),其中 n 是图中的节点个数。需要遍历图中的 n 条边,对于每条边,需要对两个节点查找祖先,如果两个节点的祖先不同则需要进行合并,需要进行 2 次查找和最多 1 次合并。一共需要进行 2n 次查找和最多 n 次合并,因此总时间复杂度是 O(2nlogn)=O(nlogn)。这里的并查集使用了路径压缩,但是没有使用按秩合并,最坏情况下的时间复杂度是 O(nlogn),平均情况下的时间复杂度依然是 O(nα(n)),其中 α 为阿克曼函数的反函数,α(n) 可以认为是一个很小的常数。

  • 空间复杂度:O(n),其中 n 是图中的节点个数。使用数组 parent 记录每个节点的祖先。

685. 冗余连接 II


在本问题中,有根树指满足以下条件的 有向 图。该树只有一个根节点,所有其他节点都是该根节点的后继。该树除了根节点之外的每一个节点都有且只有一个父节点,而根节点没有父节点。

输入一个有向图,该图由一个有着 n 个节点(节点值不重复,从 1 到 n)的树及一条附加的有向边构成。附加的边包含在 1 到 n 中的两个不同顶点间,这条附加的边不属于树中已存在的边。

结果图是一个以边组成的二维数组 edges 。 每个元素是一对 [ui, vi],用以表示 有向 图中连接顶点 ui 和顶点 vi 的边,其中 ui 是 vi 的一个父节点。

返回一条能删除的边,使得剩下的图是有 n 个节点的有根树。若有多个答案,返回最后出现在给定二维数组的答案。

示例 1:

输入:edges = [[1,2],[1,3],[2,3]]
输出:[2,3]

示例 2:

输入:edges = [[1,2],[2,3],[3,4],[4,1],[1,5]]
输出:[4,1]

提示:

  • n == edges.length
  • 3 <= n <= 1000
  • edges[i].length == 2
  • 1 <= ui, vi <= n

并查集

在一棵树中,边的数量比节点的数量少 1。如果一棵树有 n 个节点,则这棵树有 n−1 条边。这道题中的图在树的基础上多了一条附加的边,因此边的数量也是 n。

树中的每个节点都有一个父节点,除了根节点没有父节点。在多了一条附加的边之后,可能有以下两种情况:

  • 附加的边指向根节点,则包括根节点在内的每个节点都有一个父节点,此时图中一定有环路;

  • 附加的边指向非根节点,则恰好有一个节点(即被附加的边指向的节点)有两个父节点,此时图中可能有环路也可能没有环路。

要找到附加的边,需要遍历图中的所有的边构建出一棵树,在构建树的过程中寻找导致冲突(即导致一个节点有两个父节点)的边以及导致环路出现的边。

具体做法是,使用数组 parent 记录每个节点的父节点,初始时对于任何 1≤i≤n 都有 parent[i]=i,另外创建并查集,初始时并查集中的每个节点都是一个连通分支,该连通分支的根节点就是该节点本身。遍历每条边的过程中,维护导致冲突的边和导致环路出现的边,由于只有一条附加的边,因此最多有一条导致冲突的边和一条导致环路出现的边。

当访问到边 [u,v] 时,进行如下操作:

  • 如果此时已经有 parent[v]≠v,说明 v 有两个父节点,将当前的边 [u,v] 记为导致冲突的边;

  • 否则,令 parent[v]=u,然后在并查集中分别找到 uv 的祖先(即各自的连通分支中的根节点),如果祖先相同,说明这条边导致环路出现,将当前的边 [u,v] 记为导致环路出现的边,如果祖先不同,则在并查集中将 uv 进行合并。

根据上述操作,同一条边不可能同时被记为导致冲突的边和导致环路出现的边。如果访问到的边确实同时导致冲突和环路出现,则这条边被记为导致冲突的边。

在遍历图中的所有边之后,根据是否存在导致冲突的边和导致环路出现的边,得到附加的边。

如果没有导致冲突的边(指向根节点),说明附加的边一定导致环路出现,而且是在环路中的最后一条被访问到的边,因此附加的边即为导致环路出现的边。

🍒如果导致冲突的边,记这条边为 [u,v],则有两条边指向 v,另一条边为 [parent[v],v],需要通过判断是否有导致环路的边决定哪条边是附加的边。

  • 如果有导致环路的边,则附加的边不可能是 [u,v](因为 [u,v] 已经被记为导致冲突的边,不可能被记为导致环路出现的边),因此附加的边是 [parent[v],v]

  • 如果没有导致环路的边,则附加的边是后被访问到的指向 v 的边,因此附加的边是 [u,v]

注:根据上述操作,同一条边不可能同时被记为导致冲突的边和导致环路出现的边。如果访问到的边确实同时导致冲突和环路出现,则这条边被记为导致冲突的边。

Java-代码🍀
public class Main {
public int[] findRedundantDirectedConnection(int[][] edges) {
int nodes = edges.length;
//edges 的节点编号是从1开始的,所以要传入 nodes + 1
UnionFind unionFind = new UnionFind(nodes + 1);
int[] parent = new int[nodes + 1];
for (int i = 1; i <= nodes; i++) {
parent[i] = i;
}
//表示冲突和有环的两种情况,因为这是有向图
//无冲突有环,节点循环指向,如 1-->2,2-->3,3-->1
//有冲突无环,某节点入度大于1,如 1-->2,1-->3,2-->3
//有冲突有环,如 1-->2,2-->3,3-->1,4-->2
int conflict = -1;
int cycle = -1;
for (int i = 0; i < nodes; i++) {
//当前边
int[] edge = edges[i];
//node1 --> node2
int node1 = edge[0], node2 = edge[1];
if (parent[node2] != node2) {
conflict = i; //同时出现冲突与环,只记冲突
} else {
parent[node2] = node1;
//如果 node1 和 node2 已经在同一个集合里,那么 node1 --> node2 这条边将即可构成环
if (unionFind.find(node1) == unionFind.find(node2)) {
cycle = i;
} else {
//如果 node1 和 node2 不在同一集合中,那么 node1 --> node2 这条边将使得它俩在一个集合中
unionFind.union(node1, node2);
}
}
}
//情况1:无冲突有环,冗余边为使图成环的那条边
if (conflict < 0) {
int[] redundant = {edges[cycle][0], edges[cycle][1]};
return redundant;
} else {
//情况2:有冲突
int[] conflictEdge = edges[conflict];
if (cycle >= 0) {
//情况2.1:有冲突有环,冗余边为使冲突且成环的那条边
//conflictEdge[1] 即冲突点,其入度大于1
//由于每个节点的 parent 只有一次赋予机会,故 parent[conflictEdge[1]] 为环内点。
int[] redundant = {parent[conflictEdge[1]], conflictEdge[1]};
return redundant;
} else {
//情况2.2:有冲突无环,冗余边为使图冲突的那条边
int[] redundant = {conflictEdge[0], conflictEdge[1]};
return redundant;
}
}
}
}
//实现并查集的实例,用数组表示 (集)
//下标 i 表示元素 i,ancestor[i] 表示所属集合,所属集合用代表元素 j 表示
//有合并 元素 i1 和 元素 i2 的 union 功能 (并)
//和查找 元素 i 属于哪个集合 的 find 功能 (查)
class UnionFind {
int[] ancestor;
public UnionFind(int n) {
ancestor = new int[n];
//初始化时,每个元素都为独立的节点集合
for (int i = 0; i < n; i++) {
ancestor[i] = i;
}
}
/**
* 并
* 合并 idx1 所在集合,及 idx2 所在集合
* 将前者所在集合的代表元素的值,定义为后者所在集合的代表元素
* @param idx1
* @param idx2
*/
public void union(int idx1, int idx2) {
ancestor[find(idx1)] = find(idx2);
}
/**
* 查
* 查找元素 idx 所在的集合,以集合代表元素表示
* @param idx
* @return 元素 idx 所在集合的代表元素
*/
public int find(int idx){
//层层递推直到集合的代表元素(或者说根)
if (ancestor[idx] != idx){
ancestor[idx] = find(ancestor[idx]);
}
return ancestor[idx];
}
}
C++-代码🍀
struct UnionFind {
vector <int> ancestor;
UnionFind(int n) {
ancestor.resize(n);
for (int i = 0; i < n; ++i) {
ancestor[i] = i;
}
}
int find(int index) {
return index == ancestor[index] ? index : ancestor[index] = find(ancestor[index]);
}
void merge(int u, int v) {
ancestor[find(u)] = find(v);
}
};
class Solution {
public:
vector<int> findRedundantDirectedConnection(vector<vector<int>>& edges) {
int n = edges.size();
UnionFind uf = UnionFind(n + 1);
auto parent = vector<int>(n + 1);
for (int i = 1; i <= n; ++i) {
parent[i] = i;
}
int conflict = -1;
int cycle = -1;
for (int i = 0; i < n; ++i) {
auto edge = edges[i];
int node1 = edge[0], node2 = edge[1];
if (parent[node2] != node2) {
conflict = i;
} else {
parent[node2] = node1;
if (uf.find(node1) == uf.find(node2)) {
cycle = i;
} else {
uf.merge(node1, node2);
}
}
}
if (conflict < 0) {
auto redundant = vector<int> {edges[cycle][0], edges[cycle][1]};
return redundant;
} else {
auto conflictEdge = edges[conflict];
if (cycle >= 0) {
auto redundant = vector<int> {parent[conflictEdge[1]], conflictEdge[1]};
return redundant;
} else {
auto redundant = vector<int> {conflictEdge[0], conflictEdge[1]};
return redundant;
}
}
}
};

复杂度分析

  • 时间复杂度:O(nlogn),其中 n 是图中的节点个数。需要遍历图中的 n 条边,对于每条边,需要对两个节点查找祖先,如果两个节点的祖先不同则需要进行合并,需要进行 2 次查找和最多 1 次合并。一共需要进行 2n 次查找和最多 n 次合并,因此总时间复杂度是 O(2nlogn)=O(nlogn)。这里的并查集使用了路径压缩,但是没有使用按秩合并,最坏情况下的时间复杂度是 O(nlogn),平均情况下的时间复杂度依然是 O(nα(n)),其中 α 为阿克曼函数的反函数,α(n) 可以认为是一个很小的常数。

  • 空间复杂度:O(n),其中 n 是图中的节点个数。使用数组 parent 记录每个节点的父节点,并查集使用数组记录每个节点的祖先。

743. 网络延迟时间


有 n 个网络节点,标记为 1 到 n。

给你一个列表 times,表示信号经过 有向 边的传递时间。times[i] = (ui, vi, wi),其中 ui 是源节点,vi 是目标节点, wi 是一个信号从源节点传递到目标节点的时间。

现在,从某个节点 K 发出一个信号。需要多久才能使所有节点都收到信号?如果不能使所有节点收到信号,返回 -1 。

示例 1:

输入:times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2
输出:2

示例 2:

输入:times = [[1,2,1]], n = 2, k = 1
输出:1

示例 3:

输入:times = [[1,2,1]], n = 2, k = 2
输出:-1

提示:

  • 1 <= k <= n <= 100
  • 1 <= times.length <= 6000
  • times[i].length == 3
  • 1 <= ui, vi <= n
  • ui != vi
  • 0 <= wi <= 100
  • 所有 (ui, vi) 对都 互不相同(即,不含重复边)

前言

本题需要用到单源最短路径算法 Dijkstra,其主要思想是贪心。

将所有节点分成两类:已确定从起点到当前点的最短路长度的节点,以及未确定从起点到当前点的最短路长度的节点(简称「未确定节点」和「已确定节点」)。

每次从「未确定节点」中取一个与起点距离最短的点,将它归类为「已确定节点」,并用它「更新」从起点到其他所有「未确定节点」的距离。直到所有点都被归类为「已确定节点」。

用节点 A「更新」节点 B 的意思是,用起点到节点 A 的最短路长度加上从节点 A 到节点 B 的边的长度,去比较起点到节点 B 的最短路长度,如果前者小于后者,就用前者更新后者。这种操作也被叫做「松弛」。

这里暗含的信息是:每次选择「未确定节点」时,起点到它的最短路径的长度可以被确定。

可以这样理解,因为已经用了每一个「已确定节点」更新过了当前节点,无需再次更新(因为一个点不能多次到达)。而当前节点已经是所有「未确定节点」中与起点距离最短的点,不可能被其它「未确定节点」更新。所以当前节点可以被归类为「已确定节点」。

Dijkstra 算法

根据题意,从节点 k 发出的信号,到达节点 x 的时间就是节点 k 到节点 x 的最短路的长度。因此需要求出节点 k 到其余所有点的最短路,其中的最大值就是答案。若存在从 k 出发无法到达的点,则返回 −1。

实现 1:枚举

代码中将节点编号减小了 1,从而使节点编号位于 [0,n−1] 范围。

Java-代码🍀
class Solution {
public int networkDelayTime(int[][] times, int n, int k) {
final int INF = Integer.MAX_VALUE/2; //定义无穷大
int[][] g = new int[n][n];
for(int i=0; i<n;i++){
Arrays.fill(g[i], INF); //初始化为无穷大
}
for (int[] t : times){
//将节点编号减小了 1,从而使节点编号位于 `[0,n−1]` 范围
int x = t[0] - 1, y = t[1] - 1;
g[x][y] = t[2];
}
int[] dist = new int[n]; //记录各节点到根节点的距离
Arrays.fill(dist, INF);
dist[k - 1] = 0; //根节点到自己距离为0
boolean[] used = new boolean[n]; //记录是否已确定
for(int i=0; i<n; i++){ //执行n次循环
int x = -1;
for(int y=0; y<n; y++){
//找到未确定的距离根节点最近的节点
if(!used[y] && (x==-1 || dist[y] < dist[x])){
x = y;
}
}
used[x] = true; //标记为已确定
for(int y=0; y<n; y++){
//用已确定的点更新未确定的点的距离
if(!used[y]){
dist[y] = Math.min(dist[y], dist[x]+g[x][y]);
}
}
}
int res = Arrays.stream(dist).max().getAsInt();
return res == INF? -1: res; //不能直接返回res,有可能不存在
}
}
C++-代码🍀
class Solution {
public:
int networkDelayTime(vector<vector<int>> &times, int n, int k) {
const int inf = INT_MAX / 2;
vector<vector<int>> g(n, vector<int>(n, inf));
for (auto &t : times) {
int x = t[0] - 1, y = t[1] - 1;
g[x][y] = t[2];
}
vector<int> dist(n, inf);
dist[k - 1] = 0;
vector<int> used(n);
for (int i = 0; i < n; ++i) {
int x = -1;
for (int y = 0; y < n; ++y) {
if (!used[y] && (x == -1 || dist[y] < dist[x])) {
x = y;
}
}
used[x] = true;
for (int y = 0; y < n; ++y) {
dist[y] = min(dist[y], dist[x] + g[x][y]);
}
}
int ans = *max_element(dist.begin(), dist.end());
return ans == inf ? -1 : ans;
}
};

实现 2:小根堆

(待)

Java-代码🍀
class Solution {
public int networkDelayTime(int[][] times, int n, int k) {
final int INF = Integer.MAX_VALUE / 2;
List<int[]>[] g = new List[n];
for (int i = 0; i < n; ++i) {
g[i] = new ArrayList<int[]>();
}
for (int[] t : times) {
int x = t[0] - 1, y = t[1] - 1;
g[x].add(new int[]{y, t[2]});
}
int[] dist = new int[n];
Arrays.fill(dist, INF);
dist[k - 1] = 0;
PriorityQueue<int[]> pq = new PriorityQueue<int[]>((a, b) -> a[0] != b[0] ? a[0] - b[0] : a[1] - b[1]);
pq.offer(new int[]{0, k - 1});
while (!pq.isEmpty()) {
int[] p = pq.poll();
int time = p[0], x = p[1];
if (dist[x] < time) {
continue;
}
for (int[] e : g[x]) {
int y = e[0], d = dist[x] + e[1];
if (d < dist[y]) {
dist[y] = d;
pq.offer(new int[]{d, y});
}
}
}
int ans = Arrays.stream(dist).max().getAsInt();
return ans == INF ? -1 : ans;
}
}
C++-代码🍀
class Solution {
public:
int networkDelayTime(vector<vector<int>> &times, int n, int k) {
const int inf = INT_MAX / 2;
vector<vector<pair<int, int>>> g(n);
for (auto &t : times) {
int x = t[0] - 1, y = t[1] - 1;
g[x].emplace_back(y, t[2]);
}
vector<int> dist(n, inf);
dist[k - 1] = 0;
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> q;
q.emplace(0, k - 1);
while (!q.empty()) {
auto p = q.top();
q.pop();
int time = p.first, x = p.second;
if (dist[x] < time) {
continue;
}
for (auto &e : g[x]) {
int y = e.first, d = dist[x] + e.second;
if (d < dist[y]) {
dist[y] = d;
q.emplace(d, y);
}
}
}
int ans = *max_element(dist.begin(), dist.end());
return ans == inf ? -1 : ans;
}
};

复杂度分析

枚举写法的复杂度如下:

  • 时间复杂度:O(n2+m),其中 m 是数组 times 的长度。

  • 空间复杂度:O(n2)。邻接矩阵需占用 O(n2) 的空间。

堆的写法复杂度如下:

  • 时间复杂度:O(mlogm),其中 m 是数组 times 的长度。

  • 空间复杂度:O(n+m)

值得注意的是,由于本题边数远大于点数,是一张稠密图,因此在运行时间上,枚举写法要略快于堆的写法。

【关于无穷大 0x3f3f3f 的一些知识】

0x3f3f3f3f 的十进制是 1061109567,也就是 10^9 级别的(和 0x7fffffff 一个数量级),而一般场合下的数据都是小于 10^9 的,所以它可以作为无穷大使用而不致出现数据大于无穷大的情形。 另一方面,由于一般的数据都不会大于 10^9,所以当把无穷大加上一个数据时,它并不会溢出(这就满足了“无穷大加一个有穷的数依然是无穷大”),事实上 0x3f3f3f3f + 0x3f3f3f3f = 2122219134,这非常大但却没有超过 32-bit int 的表示范围,所以 0x3f3f3f3f还满足了“无穷大加无穷大还是无穷大”的需求。

最后,0x3f3f3f3f 还能带来一个意想不到的额外好处:如果想要将某个数组清零,通常会使用 memset(a,0,sizeof(a)) 这样的代码来实现(方便而高效),但是当想将某个数组全部赋值为无穷大时(例如解决图论问题时邻接矩阵的初始化),就不能使用 memset 函数而得自己写循环了,这是因为 memset 是按字节操作的,它能够对数组清零是因为 0 的每个字节都是 0,现在如果将无穷大设为 0x3f3f3f3f,起每个字节都是 0x3f!所以要把一段内存全部置为无穷大,只需要 memset(a,0x3f,sizeof(a))。 所以在通常的场合下,const int INF = 0x3f3f3f3f; 是一个非常棒的选择。

753. 破解保险箱


有一个需要密码才能打开的保险箱。密码是 n 位数, 密码的每一位是 k 位序列 0, 1, ..., k-1 中的一个 。

你可以随意输入密码,保险箱会自动记住最后 n 位输入,如果匹配,则能够打开保险箱。

举个例子,假设密码是 "345",你可以输入 "012345" 来打开它,只是你输入了 6 个字符.

请返回一个能打开保险箱的最短字符串

示例1:
输入: n = 1, k = 2
输出: "01"
说明: "10"也可以打开保险箱。
 
示例2:
输入: n = 2, k = 2
输出: "00110"
说明: "01100", "10011", "11001" 也能打开保险箱。

提示:

  • n 的范围是 [1, 4]
  • k 的范围是 [1, 10]
  • k^n 最大可能为 4096

本题和 332. 重新安排行程 类似,是力扣平台上为数不多的求解欧拉回路 / 欧拉通路的题目。

Hierholzer 算法

Java-代码🍀
C++-代码🍀

复杂度分析

  • 时间复杂度:O(n×kn)

  • 空间复杂度:O(n×kn)

765. 情侣牵手


n 对情侣坐在连续排列的 2n 个座位上,想要牵到对方的手。

人和座位由一个整数数组 row 表示,其中 row[i] 是坐在第 i 个座位上的人的 ID。情侣们按顺序编号,第一对是 (0, 1),第二对是 (2, 3),以此类推,最后一对是 (2n-2, 2n-1)

返回 最少交换座位的次数,以便每对情侣可以并肩坐在一起。 每次交换可选择任意两人,让他们站起来交换座位。

示例 1:
输入: row = [0,2,1,3]
输出: 1
解释: 只需要交换row[1]和row[2]的位置即可。
示例 2:
输入: row = [3,2,0,1]
输出: 0
解释: 无需交换座位,所有的情侣都已经可以手牵手了。

提示:

  • 2n == row.length
  • 2 <= n <= 30
  • n 是偶数
  • 0 <= row[i] < 2n
  • row 中所有元素均无重复

1) 并查集

Java-代码🍀
C++-代码🍀

2) 广度优先搜索

Java-代码🍀
C++-代码🍀

785. 判断二分图


存在一个 无向图,图中有 n 个节点。其中每个节点都有一个介于 0 到 n - 1 之间的唯一编号。给你一个二维数组 graph,其中 graph[u] 是一个节点数组,由节点 u 的邻接节点组成。形式上,对于 graph[u] 中的每个 v ,都存在一条位于节点 u 和节点 v 之间的无向边。该无向图同时具有以下属性:

  • 不存在自环(graph[u] 不包含 u)。
  • 不存在平行边(graph[u] 不包含重复值)。
  • 如果 v 在 graph[u] 内,那么 u 也应该在 graph[v] 内(该图是无向图)
  • 这个图可能不是连通图,也就是说两个节点 u 和 v 之间可能不存在一条连通彼此的路径。

二分图 定义:如果能将一个图的节点集合分割成两个独立的子集 A 和 B ,并使图中的每一条边的两个节点一个来自 A 集合,一个来自 B 集合,就将这个图称为 二分图 。

如果图是二分图,返回 true;否则,返回 false 。

示例 1:

输入:graph = [[1,2,3],[0,2],[0,1,3],[0,2]]
输出:false
解释:不能将节点分割成两个独立的子集,以使每条边都连通一个子集中的一个节点与另一个子集中的一个节点。

示例 2:

输入:graph = [[1,3],[0,2],[1,3],[0,2]]
输出:true
解释:可以将节点分成两组: {0, 2} 和 {1, 3} 。

提示:

  • graph.length == n
  • 1 <= n <= 100
  • 0 <= graph[u].length < n
  • 0 <= graph[u][i] <= n - 1
  • graph[u] 不会包含 u
  • graph[u] 的所有值 互不相同
  • 如果 graph[u] 包含 v,那么 graph[v] 也会包含 u

前言

对于图中的任意两个节点 u 和 v,如果它们之间有一条边直接相连,那么 u 和 v 必须属于不同的集合。

如果给定的无向图连通,那么就可以任选一个节点开始,给它染成红色。随后对整个图进行遍历,将该节点直接相连的所有节点染成绿色,表示这些节点不能与起始节点属于同一个集合。再将这些绿色节点直接相连的所有节点染成红色,以此类推,直到无向图中的每个节点均被染色。

如果能够成功染色,那么红色和绿色的节点各属于一个集合,这个无向图就是一个二分图;如果未能成功染色,即在染色的过程中,某一时刻访问到了一个已经染色的节点,并且它的颜色与将要给它染上的颜色不相同,也就说明这个无向图不是一个二分图。

算法的流程如下:

  • 任选一个节点开始,将其染成红色,并从该节点开始对整个无向图进行遍历;

  • 在遍历的过程中,如果通过节点 u 遍历到了节点 v(即 u 和 v 在图中有一条边直接相连),那么会有两种情况:

    • 如果 v 未被染色,那么将其染成与 u 不同的颜色,并对 v 直接相连的节点进行遍历;

    • 如果 v 被染色,并且颜色与 u 相同,那么说明给定的无向图不是二分图。可以直接退出遍历并返回 false 作为答案。

  • 当遍历结束时,说明给定的无向图是二分图,返回 true 作为答案。

可以使用「深度优先搜索」或「广度优先搜索」对无向图进行遍历。

注意:题目中给定的无向图不一定保证连通,因此需要进行多次遍历,直到每一个节点都被染色,或确定答案为 false 为止。每次遍历开始时,任选一个未被染色的节点,将所有与该节点直接或间接相连的节点进行染色。

1) 深度优先搜索

Java-代码🍀
class Solution {
private static final int UNCOLORED = 0;
private static final int RED = 1;
private static final int GREEN = 2;
private int[] color;
private boolean valid;
public boolean isBipartite(int[][] graph) {
int n = graph.length;
valid = true;//初始化
color = new int[n];
Arrays.fill(color, UNCOLORED); //初始化
for(int i=0; i<n && valid; i++){
if(color[i] == UNCOLORED){
dfs(i, RED, graph);
}
}
return valid;
}
public void dfs(int node, int c, int[][] graph){
//染色
color[node] = c;
int cNext = (c== RED ? GREEN : RED);
for(int neighbor : graph[node]){
if(color[neighbor] == UNCOLORED){
dfs(neighbor, cNext, graph);
if(!valid){
return;
}
}else if(color[neighbor] != cNext){
valid = false;
return;
}
}
}
}
C++-代码🍀
class Solution {
private:
static constexpr int UNCOLORED = 0;
static constexpr int RED = 1;
static constexpr int GREEN = 2;
vector<int> color;
bool valid;
public:
void dfs(int node, int c, const vector<vector<int>>& graph) {
color[node] = c;
int cNei = (c == RED ? GREEN : RED);
for (int neighbor: graph[node]) {
if (color[neighbor] == UNCOLORED) {
dfs(neighbor, cNei, graph);
if (!valid) {
return;
}
}
else if (color[neighbor] != cNei) {
valid = false;
return;
}
}
}
bool isBipartite(vector<vector<int>>& graph) {
int n = graph.size();
valid = true;
color.assign(n, UNCOLORED); //数组初始化
for (int i = 0; i < n && valid; ++i) {
if (color[i] == UNCOLORED) {
dfs(i, RED, graph);
}
}
return valid;
}
};

复杂度分析

  • 时间复杂度:O(n+m),其中 nm 分别是无向图中的点数和边数。

  • 空间复杂度:O(n),存储节点颜色的数组需要 O(n) 的空间,并且在深度优先搜索的过程中,栈的深度最大为 n,需要 O(n) 的空间。

vector 的 assign() 函数

函数原型是:

1: void assign(const_iterator first,const_iterator last); 相当于个拷贝函数,把 first 到 last 的值赋值给调用者;(注意区间的闭合)

2: void assign(size_type n,const T& x = T()); 把 n 个 x 赋值给调用者;

示例:

ls1.assign(ls.begin(),ls.begin()+9);

ls1.assign(10,7);

2) 广度优先搜索

Java-代码🍀
class Solution {
private static final int UNCOLORED = 0;
private static final int RED = 1;
private static final int GREEN = 2;
private int[] color;
public boolean isBipartite(int[][] graph) {
int n = graph.length;
color = new int[n];
Arrays.fill(color, UNCOLORED);
for (int i = 0; i < n; ++i) {
if (color[i] == UNCOLORED) {
Queue<Integer> queue = new LinkedList<Integer>();
queue.offer(i);
color[i] = RED;
while (!queue.isEmpty()) {
int node = queue.poll();
int cNei = color[node] == RED ? GREEN : RED;
for (int neighbor : graph[node]) {
if (color[neighbor] == UNCOLORED) {
queue.offer(neighbor);
color[neighbor] = cNei;
} else if (color[neighbor] != cNei) {
return false;
}
}
}
}
}
return true;
}
}
C++-代码🍀
class Solution {
private:
static constexpr int UNCOLORED = 0;
static constexpr int RED = 1;
static constexpr int GREEN = 2;
vector<int> color;
public:
bool isBipartite(vector<vector<int>>& graph) {
int n = graph.size();
vector<int> color(n, UNCOLORED);
for (int i = 0; i < n; ++i) {
if (color[i] == UNCOLORED) {
queue<int> q;
q.push(i);
color[i] = RED;
while (!q.empty()) {
int node = q.front();
int cNei = (color[node] == RED ? GREEN : RED);
q.pop();
for (int neighbor: graph[node]) {
if (color[neighbor] == UNCOLORED) {
q.push(neighbor);
color[neighbor] = cNei;
}
else if (color[neighbor] != cNei) {
return false;
}
}
}
}
}
return true;
}
};

复杂度分析

  • 时间复杂度:O(n+m),其中 nm 分别是无向图中的点数和边数。

  • 空间复杂度:O(n),存储节点颜色的数组需要 O(n) 的空间,并且在广度优先搜索的过程中,队列中最多有 n1 个节点,需要 O(n) 的空间。

787. K 站中转内最便宜的航班


有 n 个城市通过一些航班连接。给你一个数组 flights ,其中 flights[i] = [fromi, toi, pricei] ,表示该航班都从城市 fromi 开始,以价格 pricei 抵达 toi。

现在给定所有的城市和航班,以及出发城市 src 和目的地 dst,你的任务是找到出一条最多经过 k 站中转的路线,使得从 src 到 dst 的 价格最便宜,并返回该价格。 如果不存在这样的路线,则输出 -1。

示例 1:

输入:
n = 3, edges = [[0,1,100],[1,2,100],[0,2,500]]
src = 0, dst = 2, k = 1
输出: 200

解释: 城市航班图如下,从城市 0 到城市 2 在 1 站中转以内的最便宜价格是 200,如图中红色所示。

示例 2:

输入:
n = 3, edges = [[0,1,100],[1,2,100],[0,2,500]]
src = 0, dst = 2, k = 0
输出: 500

解释: 城市航班图如下,从城市 0 到城市 2 在 0 站中转以内的最便宜价格是 500,如图中蓝色所示。

提示:

  • 1 <= n <= 100
  • 0 <= flights.length <= (n * (n - 1) / 2)
  • flights[i].length == 3
  • 0 <= fromi, toi < n
  • fromi != toi
  • 1 <= pricei <= 104
  • 航班没有重复,且不存在自环
  • 0 <= src, dst, k < n
  • src != dst

动态规划

f[t][i] 表示通过恰好 t 次航班,从出发城市 src 到达城市 i 需要的最小花费。在进行状态转移时,可以枚举最后一次航班的起点 j,即:

f[t][i]=min(j,i)flights{f[t1][j]+cost(j,i)}

其中 (j,i)flights 表示在给定的航班数组 flights 中存在从城市 j 出发到达城市 i 的航班,cost(j,i) 表示该航班的花费。该状态转移方程的意义在于,枚举最后一次航班的起点 j,那么前 t−1 次航班的最小花费为 f[t1][j] 加上最后一次航班的花费 cost(j,i) 中的最小值,即为 f[t][i]

由于最多只能中转 k 次,也就是最多搭乘 k+1 次航班,最终的答案即为

f[1][dst],f[2][dst],,f[k+1][dst]

中的最小值。

当 t=0 时,状态 f[t][i] 表示不搭乘航班到达城市 i 的最小花费,因此有:

f[t][i]={0,i=src,isrc


也就是说,如果 i 是出发城市 src,那么花费为 0;否则 f[0][i] 不是一个合法的状态,由于在状态转移方程中需要求出的是最小值,因此可以将不合法的状态置为极大值 。根据题目中给出的数据范围,航班的花费不超过 104,最多搭乘航班的次数 k+1 不超过 101,那么在实际的代码编写中,只要使得极大值大于 104×101,就可以将表示不合法状态的极大值与合法状态的花费进行区分。

在状态转移中,需要使用二重循环枚举 t 和 i,随后枚举所有满足 (j,i)flights 的 j,这样做的劣势在于没有很好地利用数组 flights,为了保证时间复杂度较优,需要将 flights 中的所有航班存储在一个新的邻接表中。一种可行的解决方法是,只需要使用一重循环枚举 t,随后枚举 flights 中的每一个航班 (j,i,cost),并用 f[t1][j]+cost 更新 f[t][i],这样就免去了邻接表的使用。

注意到 f[t][i] 只会从 f[t1][..] 转移而来,因此也可以使用两个长度为 n 的一维数组进行状态转移,减少空间的使用。

二维数组状态转移

Java-代码🍀
class Solution {
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
final int INF = 10000 * 101 + 1; //设置最大值
int[][] f = new int[k+2][n];
//初始化动态数组
for(int i = 0; i<=k+1; i++){
Arrays.fill(f[i], INF);
}
f[0][src] = 0;
//分别计算t次中转
for(int t = 1; t <= k + 1; ++t){
for(int[] flight : flights){
int j = flight[0], i = flight[1], cost = flight[2];
f[t][i] = Math.min(f[t][i], f[t-1][j]+cost);
}
}
//取最小值
int res = INF;
for(int t = 1; t <= k + 1; ++t){
res = Math.min(res, f[t][dst]);
}
return res==INF? -1: res;
}
}
C++-代码🍀
class Solution {
private:
static constexpr int INF = 10000 * 101 + 1;
public:
int findCheapestPrice(int n, vector<vector<int>>& flights, int src, int dst, int k) {
vector<vector<int>> f(k + 2, vector<int>(n, INF));
f[0][src] = 0;
for (int t = 1; t <= k + 1; ++t) {
for (auto&& flight: flights) {
int j = flight[0], i = flight[1], cost = flight[2];
f[t][i] = min(f[t][i], f[t - 1][j] + cost);
}
}
int ans = INF;
for (int t = 1; t <= k + 1; ++t) {
ans = min(ans, f[t][dst]);
}
return (ans == INF ? -1 : ans);
}
};

一维数组状态转移

Java-代码🍀
class Solution {
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
final int INF = 10000 * 101 + 1;
int[] f = new int[n];
Arrays.fill(f, INF);
f[src] = 0;
int ans = INF;
for (int t = 1; t <= k + 1; ++t) {
int[] g = new int[n];
Arrays.fill(g, INF);
for (int[] flight : flights) {
int j = flight[0], i = flight[1], cost = flight[2];
g[i] = Math.min(g[i], f[j] + cost);
}
f = g;
ans = Math.min(ans, f[dst]);
}
return ans == INF ? -1 : ans;
}
}
C++-代码🍀
class Solution {
private:
static constexpr int INF = 10000 * 101 + 1;
public:
int findCheapestPrice(int n, vector<vector<int>>& flights, int src, int dst, int k) {
vector<int> f(n, INF);
f[src] = 0;
int ans = INF;
for (int t = 1; t <= k + 1; ++t) {
vector<int> g(n, INF);
for (auto&& flight: flights) {
int j = flight[0], i = flight[1], cost = flight[2];
g[i] = min(g[i], f[j] + cost);
}
f = move(g);
ans = min(ans, f[dst]);
}
return (ans == INF ? -1 : ans);
}
};

复杂度分析

  • 时间复杂度:O((m+n)k),其中 m 是数组 flights 的长度。状态的数量为 O(nk),对于固定的 t,需要 O(m) 的时间计算出所有 f[t][..] 的值,因此总时间复杂度为 O((m+n)k)

  • 空间复杂度:O(nk)O(n),即为存储状态需要的空间。

797. 所有可能的路径


给你一个有 n 个节点的 有向无环图(DAG),请你找出所有从节点 0 到节点 n-1 的路径并输出(不要求按特定顺序

graph[i] 是一个从节点 i 可以访问的所有节点的列表(即从节点 i 到节点 graph[i][j]存在一条有向边)。

示例 1:

输入:graph = [[1,2],[3],[3],[]]
输出:[[0,1,3],[0,2,3]]
解释:有两条路径 0 -> 1 -> 30 -> 2 -> 3

示例 2:

输入:graph = [[4,3,1],[3,2,4],[3],[4],[]]
输出:[[0,4],[0,3,4],[0,1,3,4],[0,1,2,3,4],[0,1,4]]

提示:

  • n == graph.length
  • 2 <= n <= 15
  • 0 <= graph[i][j] < n
  • graph[i][j] != i(即不存在自环)
  • graph[i] 中的所有元素 互不相同
  • 保证输入为 有向无环图(DAG)

动态规划

Java-代码🍀
C++-代码🍀

802. 找到最终的安全状态


有一个有 n 个节点的有向图,节点按 0 到 n - 1 编号。图由一个 索引从 0 开始 的 2D 整数数组 graph表示, graph[i]是与节点 i 相邻的节点的整数数组,这意味着从节点 i 到 graph[i]中的每个节点都有一条边。

如果一个节点没有连出的有向边,则它是 终端节点。如果没有出边,则节点为终端节点。如果从该节点开始的所有可能路径都通向 终端节点,则该节点为 安全节点

返回一个由图中所有 安全节点 组成的数组作为答案。答案数组中的元素应当按 升序 排列。

示例 1:

输入:graph = [[1,2],[2,3],[5],[0],[5],[],[]]
输出:[2,4,5,6]
解释:示意图如上。
节点 5 和节点 6 是终端节点,因为它们都没有出边。
从节点 2456 开始的所有路径都指向节点 56

示例 2:

输入:graph = [[1,2,3,4],[1,2],[3,4],[0,4],[]]
输出:[4]
解释:
只有节点 4 是终端节点,从节点 4 开始的所有路径都通向节点 4

提示:

  • n == graph.length
  • 1 <= n <= 104
  • 0 <= graph[i].length <= n
  • 0 <= graph[i][j] <= n - 1
  • graph[i] 按严格递增顺序排列。
  • 图中可能包含自环。
  • 图中边的数目在范围 [1, 4 * 104] 内。

动态规划

Java-代码🍀
C++-代码🍀

834. 树中距离之和


给定一个无向、连通的树。树中有 n 个标记为 0...n-1 的节点以及 n-1 条边 。

给定整数 n 和数组 edges , edges[i] = [ai, bi]表示树中的节点 ai 和 bi 之间有一条边。

返回长度为 n 的数组 answer ,其中 answer[i] 是树中第 i 个节点与所有其他节点之间的距离之和。

示例 1:

输入: n = 6, edges = [[0,1],[0,2],[2,3],[2,4],[2,5]]
输出: [8,12,6,10,10,10]
解释: 树如图所示。
我们可以计算出 dist(0,1) + dist(0,2) + dist(0,3) + dist(0,4) + dist(0,5)
也就是 1 + 1 + 2 + 2 + 2 = 8。 因此,answer[0] = 8,以此类推。

示例 2:

输入: n = 1, edges = []
输出: [0]

示例 3:

输入: n = 2, edges = [[1,0]]
输出: [1,1]

提示:

  • 1 <= n <= 3 * 104
  • edges.length == n - 1
  • edges[i].length == 2
  • 0 <= ai, bi < n
  • ai != bi
  • 给定的输入保证为有效的树

动态规划

Java-代码🍀
C++-代码🍀

841. 钥匙和房间


有 n 个房间,房间按从 0 到 n - 1 编号。最初,除 0 号房间外的其余所有房间都被锁住。你的目标是进入所有的房间。然而,你不能在没有获得钥匙的时候进入锁住的房间。

当你进入一个房间,你可能会在里面找到一套不同的钥匙,每把钥匙上都有对应的房间号,即表示钥匙可以打开的房间。你可以拿上所有钥匙去解锁其他房间。

给你一个数组 rooms 其中 rooms[i] 是你进入 i 号房间可以获得的钥匙集合。如果能进入 所有 房间返回 true,否则返回 false。

示例 1
输入:rooms = [[1],[2],[3],[]]
输出:true
解释:
0 号房间开始,拿到钥匙 1
之后去 1 号房间,拿到钥匙 2
然后去 2 号房间,拿到钥匙 3
最后去了 3 号房间。
由于能够进入每个房间,返回 true
示例 2
输入:rooms = [[1,3],[3,0,1],[2],[0]]
输出:false
解释:不能进入 2 号房间。

提示:

  • n == rooms.length
  • 2 <= n <= 1000
  • 0 <= rooms[i].length <= 1000
  • 1 <= sum(rooms[i].length) <= 3000
  • 0 <= rooms[i][j] < n
  • 所有 rooms[i] 的值互不相同

动态规划

Java-代码🍀
C++-代码🍀

847. 访问所有节点的最短路径


存在一个由 n 个节点组成的无向连通图,图中的节点按从 0 到 n - 1 编号。

给你一个数组 graph 表示这个图。其中,graph[i] 是一个列表,由所有与节点 i 直接相连的节点组成。

返回能够访问所有节点的最短路径的长度。你可以在任一节点开始和停止,也可以多次重访节点,并且可以重用边。

示例 1:

输入:graph = [[1,2,3],[0],[0],[0]]
输出:4
解释:一种可能的路径为 [1,0,2,0,3]

示例 2:

输入:graph = [[1],[0,2,4],[1,3,4],[2],[1,2]]
输出:4
解释:一种可能的路径为 [0,1,4,2,3]

提示:

  • n == graph.length
  • 1 <= n <= 12
  • 0 <= graph[i].length < n
  • graph[i] 不包含 i
  • 如果 graph[a] 包含 b ,那么 graph[b] 也包含 a
  • 输入的图总是连通图

动态规划

Java-代码🍀
C++-代码🍀

851. 喧闹和富有


有一组 n 个人作为实验对象,从 0 到 n - 1 编号,其中每个人都有不同数目的钱,以及不同程度的安静值(quietness)。为了方便起见,将编号为 x 的人简称为 "person x "。

给你一个数组 richer ,其中 richer[i] = [ai, bi] 表示 person ai 比 person bi 更有钱。另给你一个整数数组 quiet ,其中 quiet[i] 是 person i 的安静值。richer 中所给出的数据 逻辑自洽(也就是说,在 person x 比 person y 更有钱的同时,不会出现 person y 比 person x 更有钱的情况 )。

现在,返回一个整数数组 answer 作为答案,其中 answer[x] = y 的前提是,在所有拥有的钱肯定不少于 person x 的人中,person y 是最安静的人(也就是安静值 quiet[y] 最小的人)。

示例 1
输入:richer = [[1,0],[2,1],[3,1],[3,7],[4,3],[5,3],[6,3]], quiet = [3,2,5,4,6,1,7,0]
输出:[5,5,2,5,4,5,6,7]
解释:
answer[0] = 5
person 5 比 person 3 有更多的钱,person 3 比 person 1 有更多的钱,person 1 比 person 0 有更多的钱。
唯一较为安静(有较低的安静值 quiet[x])的人是 person 7
但是目前还不清楚他是否比 person 0 更有钱。
answer[7] = 7
在所有拥有的钱肯定不少于 person 7 的人中(这可能包括 person 3456 以及 7),
最安静(有较低安静值 quiet[x])的人是 person 7
其他的答案也可以用类似的推理来解释。
示例 2
输入:richer = [], quiet = [0]
输出:[0]

提示:

  • n == quiet.length
  • 1 <= n <= 500
  • 0 <= quiet[i] < n
  • quiet 的所有值 互不相同
  • 0 <= richer.length <= n * (n - 1) / 2
  • 0 <= ai, bi < n
  • ai != bi
  • richer 中的所有数对 互不相同
  • 对 richer 的观察在逻辑上是一致的

882. 细分图中的可到达结点


给你一个无向图(原始图),图中有 n 个节点,编号从 0 到 n - 1 。你决定将图中的每条边 细分 为一条节点链,每条边之间的新节点数各不相同。

图用由边组成的二维数组 edges 表示,其中 edges[i] = [ui, vi, cnti] 表示原始图中节点 ui 和 vi 之间存在一条边,cnti 是将边 细分 后的新节点总数。注意,cnti == 0 表示边不可细分。

要 细分 边 [ui, vi] ,需要将其替换为 (cnti + 1) 条新边,和 cnti 个新节点。新节点为 x1, x2, ..., xcnti ,新边为 [ui, x1], [x1, x2], [x2, x3], ..., [xcnti+1, xcnti], [xcnti, vi] 。

现在得到一个 新的细分图 ,请你计算从节点 0 出发,可以到达多少个节点?如果节点间距离是 maxMoves 或更少,则视为 可以到达 。

给你原始图和 maxMoves ,返回 新的细分图中从节点 0 出发 可到达的节点数 。

示例 1:

输入:edges = [[0,1,10],[0,2,1],[1,2,2]], maxMoves = 6, n = 3
输出:13
解释:边的细分情况如上图所示。
可以到达的节点已经用黄色标注出来。
示例 2
输入:edges = [[0,1,4],[1,2,6],[0,2,8],[1,3,1]], maxMoves = 10, n = 4
输出:23
示例 3
输入:edges = [[1,2,4],[1,4,5],[1,3,1],[2,3,4],[3,4,5]], maxMoves = 17, n = 5
输出:1
解释:节点 0 与图的其余部分没有连通,所以只有节点 0 可以到达。

提示:

  • 0 <= edges.length <= min(n * (n - 1) / 2, 104)
  • edges[i].length == 3
  • 0 <= ui < vi < n
  • 图中 不存在平行边
  • 0 <= cnti <= 104
  • 0 <= maxMoves <= 109
  • 1 <= n <= 3000

886. 可能的二分法


给定一组 n 人(编号为 1, 2, ..., n), 我们想把每个人分进任意大小的两组。每个人都可能不喜欢其他人,那么他们不应该属于同一组。

给定整数 n 和数组 dislikes ,其中 dislikes[i] = [ai, bi] ,表示不允许将编号为 ai 和  bi的人归入同一组。当可以用这种方法将所有人分进两组时,返回 true;否则返回 false。

示例 1
输入:n = 4, dislikes = [[1,2],[1,3],[2,4]]
输出:true
解释:group1 [1,4], group2 [2,3]
示例 2
输入:n = 3, dislikes = [[1,2],[1,3],[2,3]]
输出:false
示例 3
输入:n = 5, dislikes = [[1,2],[2,3],[3,4],[4,5],[1,5]]
输出:false

提示:

  • 1 <= n <= 2000
  • 0 <= dislikes.length <= 104
  • dislikes[i].length == 2
  • 1 <= dislikes[i][j] <= n
  • ai < bi
  • dislikes 中每一组都 不同

913. 猫和老鼠


947. 移除最多的同行或同列石头


959. 由斜杠划分区域


990. 等式方程的可满足性


997. 找到小镇的法官


1042. 不邻接植花


1129. 颜色交替的最短路径


1192. 查找集群内的「关键连接」


1203. 项目管理


1298. 你能从盒子里获得的最大糖果


1311. 获取你好友已观看的视频


1319. 连通网络的操作次数


1334. 阈值距离内邻居最少的城市


posted @   guo-nix  阅读(82)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示