4+ 图论高级算法

强连通分量

基础概念

强连通:在有向图 GGG 中,如果两个点 uuu 和 vvv 是互相可达的,即从 uuu 出发可以到达 vvv , 从 vvv 也可以到达 uuu , 则称 uuu 和 vvv 是强连通的。如果 GGG 中任意两个点都是互相可达的,则称 GGG 是强连通图。

强连通分量:如果一个有向图 GGG 不是强连通图,那么可以把它分成多个子图, 其中每个子图的内部是强连通的, 而且这些子图已经扩展到最大,不能与子图外的任意点强连通, 称这样的一个 "极大强连通" 子图是 GGG 的一个强连通分量。

DFS生成树

SCC(强连通分量) 中的 tarjan 算法主要是靠 DFS生成树 实现的, 所以接下来介绍DFS生成树:

有向图的 DFS 生成树主要有 4 种边:

  1. 树边 :示意图中以黑色边表示,每次搜索找到一个还没有访问过的结点的时候就形成了一条树边。
  2. 反祖边 :示意图中以红色边表示(即 7 → 1),也被叫做回边,即指向祖先结点的边。
  3. 横叉边 :示意图中以蓝色边表示(即 9 → 7),它主要是在搜索的时候遇到了一个已经访问过的结点,但是这个结点 并不是 当前结点的祖先。
  4. 前向边 :示意图中以绿色边表示(即 3 → 6),它是在搜索的时候遇到子树中的结点的时候形成的。

接下来我们考虑 DFS 生成树与强连通分量之间的关系。

如果结点 uuu 是某个强连通分量在搜索树中遇到的第一个结点,那么这个强连通分量的其余结点肯定是在搜索树中以 uuu 为根的子树中。结点 uuu 被称为这个强连通分量的根。

反证法 :假设有个结点 vvv 在该强连通分量中但是不在以 uuu 为根的子树中,那么 uuu 到 vvv 的路径中肯定有一条离开子树的边。但是这样的边只可能是横叉边或者反祖边,然而这两条边都要求指向的结点已经被访问过了 ,这就和 vvv 不在以 uuu 为根的子树中矛盾了。得证。

为什么这两种边都要求"指向的结点已经被访问过了"?

  • 反祖边

    • 定义 :指向祖先结点的边。
    • 为什么要求已访问? 祖先节点的定义就是在DFS过程中比当前节点更早 被访问的节点。你要指回你的祖先,那个祖先肯定在你之前就被发现了。所以,当DFS走到当前节点,准备沿着反祖边走去时,它指向的那个祖先节点肯定已经被访问过了
  • 横叉边

    • 定义 :指向一个既不是祖先也不是后代 ,但已经被访问过的节点的边。
    • 为什么要求已访问? 这是横叉边的核心定义!横叉边连接的是DFS树中两个不存在祖先-后代关系的分支。当你从一个分支走到另一个分支时,另一个分支的节点必然是在之前某次DFS过程中已经被探索过的。如果那个节点还没被访问,DFS就会把它作为当前节点的"后代"(形成一条树边),而不是一条横叉边。

tarjan相关概念

dfn[u]: 时间戳,DFS遍历时结点 uuu 被搜索的次序。
low[u]: uuu 和 uuu 的子树里返祖边横插边 能连到还没出栈的 dfndfndfn 最小的点。

按照DFS遍历次序对图中所有的结点进行搜索,维护每个结点的 dfndfndfn与 lowlowlow 变量,且让搜索到的结点入栈。每当找到一个强连通元素,就按照该元素包含结点数目让栈中元素出栈。在搜索过程中,对于结点 u 和与其相邻的结点 vv 不是 u 的父节点)考虑 3 种情况:

1. vvv 未被访问:继续对 vvv 进行深度搜索。在回溯过程中,用 lowvlow_vlowv 更新 lowulow_ulowu 。因为存在从 uuu 到 vvv 的直接路径,所以 vvv 能够回溯到的已经在栈中的结点,uuu 也一定能够回溯到。
2. vvv 被访问过,已经在栈中:根据 low 值的定义,用 dfnvdfn_vdfnv 更新 lowulow_ulowu。
3. vvv 被访问过,已不在栈中:说明 vvv 已搜索完毕,其所在连通分量已被处理,所以不用对其做操作。

对于一个连通分量图,我们很容易想到,在该连通图中有且仅有一个 uuu 使得 dfnu=lowu\textit{dfn}_u=\textit{low}_udfnu=lowu 。该结点一定是在深度遍历的过程中,该连通分量中第一个被访问过的结点,因为它的 dfndfndfn 和 lowlowlow 值最小,不会被该连通分量中的其他结点所影响。

因此,在回溯的过程中,判定 dfnu=lowu\textit{dfn}_u=\textit{low}_udfnu=lowu 是否成立,如果成立,则栈中 uuu 及其上方的结点构成一个 SCC。

cpp 复制代码
void tarjan(int u) {
  	dfn[u] = low[u] = ++tim;
  	stk.push_back(u), in_stk[u] = true;

  	for (auto y : e[u]) {
  		if (dfn[y] == -1) {
  			tarjan(y);
  			low[u] = std::min(low[u], low[y]);
  		} else if (in_stk[y]) {
  			low[u] = std::min(low[u], dfn[y]);
  		}
  	}


  	if (low[u] == dfn[u]) {
  		++scc_cnt;
  		int y;
  		do {
  			y = stk.back();
  			stk.pop_back();
  			in_stk[y] = false;
  			id[y] = scc_cnt;
  			siz[scc_cnt]++;
  		}while(y != u);
  	}
  }

缩点之后,我们的图就变成了一个DAG(有向无环图),我们就可以利用有向无环图来做很多的事情,比如拓扑排序、DP等。

Template

cpp 复制代码
struct SCC {
	int n, tim, scc_cnt;
	std::vector<std::vector<int>> e; 
	std::vector<int> stk;
	std::vector<int> dfn, low, id;//各个点的时间戳dfn, 各个点能走到的最小的时间戳low, 对应的强连通分量id值
	std::vector<bool> in_stk;
	std::vector<int> siz;

	SCC() {}
	SCC(int _n) {
		init(_n);
	}

	void init(int _n) {
		n = _n;
		e.assign(n, {});
		dfn.assign(n, -1);
		in_stk.assign(n, false);
		low.resize(n);
		id.assign(n, -1);
		stk.clear();
		siz.assign(n, 0);
		tim = scc_cnt = 0;
	}

   void addEdge(int u, int v) { 
  	 e[u].push_back(v);
   }

   void tarjan(int u) {
  	dfn[u] = low[u] = ++tim;
  	stk.push_back(u), in_stk[u] = true;

  	for (auto y : e[u]) {
  		if (dfn[y] == -1) {
  			tarjan(y);
  			low[u] = std::min(low[u], low[y]);
  		} else if (in_stk[y]) {
  			low[u] = std::min(low[u], dfn[y]);
  		}
  	}


  	if (low[u] == dfn[u]) {
  		++scc_cnt;
  		int y;
  		do {
  			y = stk.back();
  			stk.pop_back();
  			in_stk[y] = false;
  			id[y] = scc_cnt;
  			siz[scc_cnt]++;
  		}while(y != u);
  	}
  }

  std::vector<int> work() {
  	for (int i = 1;i <= n; i++) {
  		if (dfn[i] == -1) {
  			tarjan(i);
  		}
  	}

  	return id;
  }

};

2-SAT

背景

假设有 nnn 对夫妻被邀请参加一个聚会,每对夫妻中只有一人可以列席。在 2×n2 \times n2×n 个人中,某些人(不包括夫妻)之间有着很大的矛盾,有矛盾的两个人不会同时出现在聚会上,有没有可能让 nnn 个人同时列席 ?

现在让我们思考这个问题,如果我们没有学过相关算法,我们大概会这样做:每对夫妻都枚举一个人然后一个一个试,时间复杂度高达 O(2n)O\left( 2^{n}\right)O(2n) 。

定义

2-SAT,简单的说就是给出 nnn 个集合,每个集合有两个元素,已知若干个 ⟨a,b⟩\langle a,b \rangle⟨a,b⟩ ,表示 aaa 与 bbb 矛盾(其中 aaa 与 bbb 属于不同的集合)。然后从每个集合选择一个元素,判断能否一共选 nnn 个两两不矛盾的元素。显然可能有多种选择方案,一般题中只需要求出一种即可。

解决方法

针对2-SAT问题,我们可以选择使用SCC + 拓扑排序来解决。

第一步,我们要开始建图:

我们利用矛盾关系来建图,比如 AAA 和 BBB 矛盾,A‾\overline{A}A 和 B‾\overline{B}B 矛盾,这就意味着 AAA 出现的时候, B‾\overline{B}B 就一定要出现, 同理,BBB 出现的时候 A‾\overline{A}A 也一定要出现, 这个时候我们就可以用 AAA 指向 B‾\overline{B}B , 然后 BBB 指向 A‾\overline{A}A 。

第二步,我们应该如何判断是否有解呢 ?

当我们建成一个有向图之后,根据SCC的定义,每个SCC里面各个点都是相互依赖的(一个点出现,其他点都要出现),如果一个人和他的对立面(比如 AAA 和 A‾\overline{A}A )同时出现在一个SCC,则证明无解。

第三步,证明有解之后我们该怎么办呢?

我们先缩点,缩完点之后整张图就变成了一张DAG, 然后我们思考,我们建图时指向的就是被依赖的,那么我们肯定就是优先选择被依赖的更多的,那么其实就是一个逆向的拓扑排序, 不过实际上我们并不需要真正的做拓扑排序,因为SCC编号就是逆向的拓扑排序,SCC编号越小,出现优先级越高。

时间复杂度为 O(n+m)O(n + m)O(n+m) 。

例题

基础思路

假设 xxx 为 0, yyy 为 1 为 一个条件,那么 xxx 为 1 时,yyy 就必然需要为 1,yyy 为 0 时,xxx 就必然需要为 0,依赖关系已经形成。

代码模板

cpp 复制代码
#include <bits/stdc++.h>

using ll = long long;

#ifndef DEBUG
#define debug(x)
#endif

struct TwoSat {
  int n;
  std::vector<std::vector<int>> e;
  std::vector<bool> ans;
  
  TwoSat() {}
  TwoSat(int _n) {
    init(_n);
  }

  void init(int _n) {
    n = _n;
    e.resize(2 * n);
    ans.resize(n);
  }

  void addEdge(int u, int a, int v, int b) {
    int na = (a ^ 1), nb = (b ^ 1);
    e[u + na * n].push_back(v + b * n);
    e[v + nb * n].push_back(u + a * n); 
  }

  bool satisfiable() {
    std::vector<int> id(2 * n, -1), dfn(2 * n, -1), low(2 * n, -1);
    std::vector<int> stk;
    std::vector<bool> in_stk(2 * n, false);
    int tim = 0, scc_cnt = 0;
    std::function<void(int)> tarjan = [&](int u) {
      dfn[u] = low[u] = ++tim;
      stk.push_back(u), in_stk[u] = true;

      for (auto v: e[u]) {
        if (dfn[v] == -1) {
          tarjan(v);
          low[u] = std::min(low[u], low[v]);
        } else if (in_stk[v]) {
          low[u] = std::min(low[u], dfn[v]);
        }
      }

      if (dfn[u] == low[u]) {
        scc_cnt++;
        int y;
        do {
          y = stk.back();
          stk.pop_back();
          in_stk[y] = false;
          id[y] = scc_cnt;
        } while (y != u);
      }
    }; 
    
    for (int i = 0;i < 2 * n; i++) {
      if (dfn[i] == -1) {
        tarjan(i);
      }
    }

    for (int i = 0;i < n;i++) {
      if (id[i] == id[i + n]) {
        return false;
      }
      ans[i] = id[i] > id[i + n];
    }
    return true;
  }

  std::vector<bool> answer() {
    return ans;
  }
};


int main() {
  std::ios::sync_with_stdio(false);
  std::cin.tie(nullptr);

  int n, m;
  std::cin >> n >> m;

  TwoSat g(n);
  for (int i = 1;i <= m; i++) {
    int x, a, y, b;
    std::cin >> x >> a >> y >> b;
    x--;
    y--;
    g.addEdge(x, a, y, b);
  } 

  if (g.satisfiable()) {
    std::vector<bool> ans = g.answer();
    std::cout << "POSSIBLE\n";
    for (int i = 0;i < n; i++) {
      if (ans[i]) {
        std::cout << 1 << " ";
      } else {
        std::cout << 0 << " ";
      }
    }
  } else {
    std::cout << "IMPOSSIBLE\n";
  }
  
  return 0;
}

差分约束

差分约束系统 是一种特殊的 nnn 元一次不等式组,它包含 nnn 个变量 x1,x2,...,xnx_1,x_2,\dots,x_nx1,x2,...,xn 以及 mmm 个约束条件,每个约束条件是由两个其中的变量做差构成的,形如 xi−xj≤ckx_i-x_j\leq c_kxi−xj≤ck,其中 1≤i,j≤n,i≠j,1≤k≤m1 \leq i, j \leq n, i \neq j, 1 \leq k \leq m1≤i,j≤n,i=j,1≤k≤m 并且 ckc_kck 是常数(可以是非负数,也可以是负数)。我们要解决的问题是:求一组解 x1=a1,x2=a2,...,xn=anx_1=a_1,x_2=a_2,\dots,x_n=a_nx1=a1,x2=a2,...,xn=an,使得所有的约束条件得到满足,否则判断出无解。

差分约束系统中的每个约束条件 xi−xj≤ckx_i-x_j\leq c_kxi−xj≤ck 都可以变形成 xi≤xj+ckx_i\leq x_j+c_kxi≤xj+ck,这与单源最短路中的三角形不等式 dist[y]≤dist[x]+z\mathit{dist}[y]\leq \mathit{dist}[x]+zdist[y]≤dist[x]+z 非常相似。因此,我们可以把每个变量 xix_ixi 看做图中的一个结点,对于每个约束条件 xi−xj≤ckx_i-x_j\leq c_kxi−xj≤ck,从结点 jjj 向结点 iii 连一条长度为 ckc_kck 的有向边。

注意到,如果 {a1,a2,...,an}\{a_1,a_2,\dots,a_n\}{a1,a2,...,an} 是该差分约束系统的一组解,那么对于任意的常数 ddd,{a1+d,a2+d,...,an+d}\{a_1+d,a_2+d,\dots,a_n+d\}{a1+d,a2+d,...,an+d} 显然也是该差分约束系统的一组解,因为这样做差后 ddd 刚好被消掉。

算法流程

设 dist[0]=0\mathit{dist}[0]=0dist[0]=0 并向每一个点连一条权重为 000 的边,跑单源最短路,若图中存在负环,则给定的差分约束系统无解,否则,xi=dist[i]x_i=\mathit{dist}[i]xi=dist[i] 为该差分约束系统的一组解。

例题

cpp 复制代码
#include <bits/stdc++.h>

using ll = long long;

#ifndef DEBUG
#define debug(x)
#endif

constexpr int inf = 2e9;

int main() {
  std::ios::sync_with_stdio(false);
  std::cin.tie(nullptr);

  int n, m;
  std::cin >> n >> m;

  std::vector<std::vector<std::pair<int, int>>> adj(n + 1);

  for (int i = 0;i < m; i++) {
    int u, v, c;
    std::cin >> u >> v >> c;
    adj[v].push_back({u, c});
  }

  for (int i = 1;i <= n; i++) {
    adj[0].push_back({i, 0});
  }

  std::queue<int> q;
  std::vector<int> cnt(n + 1, 0), w(n + 1, inf);
  std::vector<bool> st(n + 1, false);

  q.push(0);
  w[0] = 0;
  while (!q.empty()) {
    auto u = q.front();
    q.pop();

    st[u] = false;

    for (auto [v, c]: adj[u]) {
      if (w[v] > w[u] + c) {
        w[v] = w[u] + c;
        if (!st[v]) {
          q.push(v);
          cnt[v]++;
            if (cnt[v] > n) {
              std::cout << "NO\n";
              return 0;
            }
          st[v] = true;
        }
      }
    }
  }

  for (int i = 1;i <= n; i++) {
    std::cout << w[i] << " ";
  }

  return 0;
}
相关推荐
艾醒25 分钟前
大模型面试题剖析:微调与 RAG 技术的选用逻辑
算法
NAGNIP1 小时前
一文弄懂MOE
算法
NAGNIP1 小时前
一文搞懂微调技术的发展与演进
算法
古译汉书1 小时前
蓝桥杯算法之基础知识(2)——Python赛道
数据结构·python·算法·蓝桥杯
地平线开发者1 小时前
LLM 中增量解码与模型推理解读
算法·自动驾驶
.Vcoistnt2 小时前
Codeforces Round 1043 (Div. 3)(A-E)
数据结构·算法
野犬寒鸦2 小时前
力扣hot100:搜索二维矩阵与在排序数组中查找元素的第一个和最后一个位置(74,34)
java·数据结构·算法·leetcode·list
艾醒3 小时前
huggingface入门:如何使用国内镜像下载huggingface中的模型
算法
艾醒3 小时前
huggingface入门:Tokenizer 核心参数与实战指南
算法
啊我不会诶3 小时前
【图论】拓扑排序
算法·深度优先·图论