DAG与拓扑排序
现实生活中我们经常要做一连串事情,这些事情之间有顺序关系或依赖关系,做一件事情之前必须先做另一件事,如安排客人的座位、穿衣服的先后、课程学习的先后等。这些事情可以抽象为图论中的拓扑排序(Topological Sorting)问题。
给定一张有向无环图(在有向图中,从一个节点出发,最终回到它自身的路径被称为“环”,不存在环的有向图即为有向无环图),若一个由图中所有点构成的序列
显然,一个图的拓扑序不一定唯一。
另外,只有在有向无环图(DAG)中才有拓扑序,如果图中有环,则没有合法的拓扑序。
例题:P4017 最大食物链计数
给出一个食物网,要求出这个食物网中最大食物链的数量。这里的“最大食物链”,指的是生物学意义上的食物链,即开头是不会捕食其他生物的生产者,结尾是不会被其他生物捕食的消费者。答案可能很大,所以要对
取模。
分析: 考虑把这个食物网转换为一个图,食物网有什么样的特点呢?
- 食物网中的捕食关系一定是单向的(比如猫吃鱼,而不是鱼吃猫)。
- 食物网中的捕食关系一定是无环的,不存在 A 捕食 B,B 捕食 C,C 捕食 A 这种情况。
所以可以发现食物网其实就是一个 DAG(有向无环图)。在这道题目中“最大食物链”的定义就是一条从入度为
用一个数组 f[x] 表示从任意一个入度为 0 的点到点 x 的食物链计数。那么对于任意一个入度为 0 的点 y,它的 f[y]=1。对于一个入度非 0 的点 z,它的 f[z] 等于能到达点 z 的点 u 的 f[u] 之和。
如点 3,它的食物链计数等于点 1 的食物链计数加上点 10 的,即 f[3]=f[1]+f[10]=1+1=2。而对于点 6,它的食物链计数等于点 2 的食物链计数加上点 3 的,即 f[6]=f[2]+f[3]=1+2=3。这样最后只要对所有出度为 0 的点的事物链计数求和就能求出题目所求的答案了。
在计算 f[x] 的过程中,需要保证对于点 x,所有能到达点 x 的点 y 的 f[y] 已经被计算过了,这样就需要确定一个合适的计算顺序。因此使用 拓扑排序 来控制计算顺序。拓扑排序并不是对一个数列进行排序,而是在 DAG 上对点进行排序,使得在搜到点 x 时所有能到达点 x 的点 y 的结果已经计算完成了。具体流程如下:
- 统计每个点的入度,将所有入度为 0 的点加入处理队列。
- 将处于队头的点 x 取出,遍历点 x 能到达的所有点 y。
- 对于每一个 y,删去从点 x 到点 y 的边。在具体的实现中,只需要让 y 的入度减一即可。在这一步中,顺便可以对点 y 的数据进行维护,在这题中是
f[y]=(f[y]+f[x])%MOD
。 - 如果点 y 的入度减到 0 了,说明所有能到 y 的点都被计算过了,这时将点 y 加入处理队列。
- 重复步骤 2 直到处理队列为空。
这样,就保证了在食物链计数这题中求 f[x] 的顺序正确。时间复杂度为
参考代码
#include <cstdio> #include <vector> #include <queue> using namespace std; const int N = 5005; const int MOD = 80112002; vector<int> graph[N]; int ind[N], f[N]; int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= m; i++) { int a, b; scanf("%d%d", &a, &b); graph[a].push_back(b); // 存边 ind[b]++; // 点b的入度+1 } queue<int> q; for (int i = 1; i <= n; i++) if (ind[i] == 0) { q.push(i); f[i] = 1; // 将入度为0的点加入队列 } while (!q.empty()) { int u = q.front(); q.pop(); for (int v : graph[u]) { f[v] = (f[v] + f[u]) % MOD; ind[v]--; if (ind[v] == 0) q.push(v); // 此时点y的依赖都解除了,将点y加入队列 } } int ans = 0; for (int i = 1; i <= n; i++) if (graph[i].size() == 0) ans = (ans + f[i]) % MOD; printf("%d\n", ans); return 0; }
答案需要对 80112002 取模,在计算 f 时一边加一边取模,以及在对出度为 0 的点的食物链计数求和时一边加一边取模。如果只在输出答案时取模,那么可能在累加的过程中答案超出了数据类型存储的范围而导致答案的错误。
如果原图中有环,则拓扑排序过程中取出的点的数量会小于整个图的点数,有一些点的入度没有降到
。
例题:P6145 [USACO20FEB] Timeline G
分析:
在拓扑排序的过程中,设目前考虑的是点
参考代码
#include <cstdio> #include <utility> #include <vector> #include <queue> #include <algorithm> using std::vector; using std::pair; using std::queue; using std::max; using ll = long long; using edge = pair<int, int>; const int N = 100005; ll s[N]; int ind[N]; vector<edge> graph[N]; int main() { int n, m, c; scanf("%d%d%d", &n, &m, &c); for (int i = 1; i <= n; i++) scanf("%lld", &s[i]); for (int i = 1; i <= c; i++) { int a, b, x; scanf("%d%d%d", &a, &b, &x); graph[a].push_back({b, x}); ind[b]++; } queue<int> q; for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i); while (!q.empty()) { int u = q.front(); q.pop(); for (edge e : graph[u]) { int v = e.first, w = e.second; s[v] = max(s[v], s[u] + w); ind[v]--; if (ind[v] == 0) q.push(v); } } for (int i = 1; i <= n; i++) printf("%lld\n", s[i]); return 0; }
例题:P10480 可达性统计
给定一个
个点 条边的有向无环图,分别统计从每个点出发能够到达的点的数量。 。
分析:设从点
也就是说,从
这里涉及到集合运算,结合
参考代码
#include <cstdio> #include <vector> #include <queue> #include <bitset> using std::vector; using std::queue; using std::bitset; const int N = 30005; vector<int> graph[N]; int ind[N]; bitset<N> ans[N]; int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= m; i++) { int x, y; scanf("%d%d", &x, &y); graph[y].push_back(x); ind[x]++; } queue<int> q; for (int i = 1; i <= n; i++) { if (ind[i] == 0) q.push(i); ans[i][i] = 1; } while (!q.empty()) { int u = q.front(); q.pop(); for (int v : graph[u]) { ans[v] |= ans[u]; ind[v]--; if (ind[v] == 0) q.push(v); } } for (int i = 1; i <= n; i++) printf("%d\n", (int)ans[i].count()); return 0; }
例题:P1038 [NOIP2003 提高组] 神经网络
解题思路
按拓扑序计算,在向后传递的过程中注意,只有当前的
最后要统计有多少没有出度且
参考代码
#include <cstdio> #include <vector> #include <queue> #include <utility> using std::pair; using std::vector; using std::queue; using edge = pair<int, int>; const int N = 105; int u[N], c[N], ind[N]; vector<edge> graph[N]; int main() { int n, p; scanf("%d%d", &n, &p); for (int i = 1; i <= n; i++) { scanf("%d%d", &c[i], &u[i]); if (c[i] == 0) c[i] -= u[i]; } for (int i = 1; i <= p; i++) { int u, v, w; scanf("%d%d%d", &u, &v, &w); graph[u].push_back({v, w}); ind[v]++; } queue<int> q; for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i); while (!q.empty()) { int cur = q.front(); q.pop(); for (edge p : graph[cur]) { int nxt = p.first, w = p.second; ind[nxt]--; if (c[cur] > 0) c[nxt] += w * c[cur]; if (ind[nxt] == 0) q.push(nxt); } } bool null = true; for (int i = 1; i <= n; i++) if (graph[i].size() == 0 && c[i] > 0) { null = false; printf("%d %d\n", i, c[i]); } if (null) printf("NULL\n"); return 0; }
例题:P1983 [NOIP2013 普及组] 车站分级
解题思路
希望分的级别尽量少,也就是最高级别尽量低,如果可以让每个车站取到它的最低等级,那最终的结果就是这些等级的最大值。
这就可以建图了,点显然就是每个车站,边要根据车次来定,没停的站的等级一定低于停的站,所以可以由没停的站向停的站连边,注意不要添加重边,否则边数太多。
设
最后结果为
参考代码
#include <cstdio> #include <vector> #include <queue> #include <algorithm> using std::queue; using std::vector; using std::max; const int N = 1005; vector<int> graph[N]; int ind[N], level[N]; bool vis[N][N]; int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= m; i++) { int s; scanf("%d", &s); vector<int> stop(s); for (int j = 0; j < s; j++) scanf("%d", &stop[j]); for (int j = 1; j < s; j++) { for (int k1 = stop[j - 1] + 1; k1 < stop[j]; k1++) { for (int k2 : stop) { if (vis[k1][k2]) continue; graph[k1].push_back(k2); ind[k2]++; vis[k1][k2] = true; } } } } queue<int> q; int ans = 0; for (int i = 1; i <= n; i++) if (ind[i] == 0) { q.push(i); level[i] = 1; } while (!q.empty()) { int u = q.front(); q.pop(); for (int v : graph[u]) { level[v] = max(level[v], level[u] + 1); ans = max(ans, level[v]); ind[v]--; if (ind[v] == 0) q.push(v); } } printf("%d\n", ans); return 0; }
瓶颈在于连边的次数过多,需要减少边数。
这里的建图还可以进一步优化,对于每一趟车次可以增加一个虚拟结点,所有不停靠的车站指向这个虚拟结点,而虚拟结点又指向所有停靠的车站,这样可以将建图的边数由
参考代码
#include <cstdio> #include <vector> #include <queue> #include <algorithm> using std::queue; using std::vector; using std::max; const int N = 2005; vector<int> graph[N]; int ind[N], level[N]; int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= m; i++) { int s; scanf("%d", &s); // virtual node (n+i) int pre = 0; for (int j = 1; j <= s; j++) { int stop; scanf("%d", &stop); if (j == 1) pre = stop; graph[stop].push_back(n + i); ind[n + i]++; for (int k = pre + 1; k < stop; k++) { graph[n + i].push_back(k); ind[k]++; } pre = stop; } } queue<int> q; int ans = 1; for (int i = 1; i <= n + m; i++) if (ind[i] == 0) q.push(i); while (!q.empty()) { int u = q.front(); q.pop(); for (int v : graph[u]) { level[v] = max(level[v], level[u] + 1); ans = max(ans, level[v]); ind[v]--; if (ind[v] == 0) q.push(v); } } printf("%d\n", ans / 2 + 1); return 0; }
例题:P7113 [NOIP2020] 排水系统
解题思路
按拓扑序计算,
最后输出出度为
注意结果用分数形式保存,即分子、分母分别存储。
本题的坑点:分母最大可达 long long
得分为 __int128
。
参考代码
#include <cstdio> #include <vector> #include <queue> #include <utility> using std::vector; using std::queue; using std::pair; using frac = pair<__int128, __int128>; // 分子,分母 const int N = 100005; vector<int> graph[N]; int ind[N]; frac ans[N]; __int128 gcd(__int128 x, __int128 y) { return y == 0 ? x : gcd(y, x % y); } frac add(frac f1, frac f2) { frac res; __int128 lcm = f1.second / gcd(f1.second, f2.second) * f2.second; res.first = lcm / f1.second * f1.first + lcm / f2.second * f2.first; res.second = lcm; __int128 g = gcd(res.first, res.second); res.first /= g; res.second /= g; return res; } void output(__int128 n) { if (n > 9) output(n / 10); putchar(n % 10 + '0'); } int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { ans[i] = {0, 1}; int d; scanf("%d", &d); for (int j = 1; j <= d; j++) { int a; scanf("%d", &a); graph[i].push_back(a); ind[a]++; } } queue<int> q; for (int i = 1; i <= n; i++) if (ind[i] == 0) { q.push(i); ans[i] = {1, 1}; } while (!q.empty()) { int u = q.front(); q.pop(); for (int v : graph[u]) { frac f = {ans[u].first, ans[u].second * (__int128)graph[u].size()}; ans[v] = add(ans[v], f); ind[v]--; if (ind[v] == 0) q.push(v); } } for (int i = 1; i <= n; i++) if (graph[i].size() == 0) { output(ans[i].first); printf(" "); output(ans[i].second); printf("\n"); } return 0; }
例题:P1347 排序
解题思路
矛盾就是有环,也就是拓扑排序过程中取出的元素数不够
唯一解就是自始至终队列里都只有不超过
如果队列里有多个备选元素,就是多解。
参考代码
#include <cstdio> #include <vector> #include <queue> using std::vector; using std::queue; int main() { int n, m; scanf("%d%d", &n, &m); vector<vector<int>> g(n); vector<int> ind(n); for (int i = 1; i <= m; i++) { char buf[4]; scanf("%s", buf); int u = buf[0] - 'A', v = buf[2] - 'A'; g[u].push_back(v); ind[v]++; queue<int> q; vector<int> tmp = ind; for (int j = 0; j < n; j++) if (tmp[j] == 0) q.push(j); bool mul = false; vector<int> order; while (!q.empty()) { if (q.size() > 1) mul = true; u = q.front(); q.pop(); order.push_back(u); for (int v : g[u]) { tmp[v]--; if (tmp[v] == 0) q.push(v); } } if (order.size() < n) { printf("Inconsistency found after %d relations.\n", i); return 0; } else if (!mul) { printf("Sorted sequence determined after %d relations: ", i); for (int x : order) printf("%c", x + 'A'); printf(".\n"); return 0; } } printf("Sorted sequence cannot be determined.\n"); return 0; }
例题:P4376 [USACO18OPEN] Milking Order G
分析:分两步,第一步求最大的符合条件的
先看第一步,最朴素的想法是暴力枚举
想要进一步优化需要观察到
第二步,求字典序最小的方案。
首先需要理解拓扑排序过程中队列里的节点的含义,这些点是目前入度为
现在要求字典序最小的方案,也就是每次要取出的点是可选的点中编号最小的,因此可以把队列换成优先队列,让编号小的先出队。
这一步时间复杂度为
参考代码
#include <cstdio> #include <vector> #include <queue> using std::vector; using std::queue; using std::priority_queue; using std::greater; const int N = 200005; vector<int> order[N], graph[N]; int ind[N], res[N]; void init(int n) { for (int i = 1; i <= n; i++) { graph[i].clear(); ind[i] = 0; } } bool check(int n, int x) { init(n); for (int i = 1; i <= x; i++) { for (int j = 1; j < order[i].size(); j++) { int pre = order[i][j - 1], cur = order[i][j]; graph[pre].push_back(cur); ind[cur]++; } } queue<int> q; for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i); while (!q.empty()) { int u = q.front(); q.pop(); for (int v : graph[u]) { ind[v]--; if (ind[v] == 0) q.push(v); } } for (int i = 1; i <= n; i++) if (ind[i] != 0) return false; return true; } int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= m; i++) { int cnt; scanf("%d", &cnt); for (int j = 1; j <= cnt; j++) { int x; scanf("%d", &x); order[i].push_back(x); } } int ans = 0, l = 1, r = m; while (l <= r) { int mid = (l + r) / 2; if (check(n, mid)) { l = mid + 1; ans = mid; } else { r = mid - 1; } } priority_queue<int, vector<int>, greater<int>> q; init(n); for (int i = 1; i <= ans; i++) { for (int j = 1; j < order[i].size(); j++) { int pre = order[i][j - 1], cur = order[i][j]; graph[pre].push_back(cur); ind[cur]++; } } for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i); int cnt = 0; while (!q.empty()) { cnt++; res[cnt] = q.top(); q.pop(); for (int v : graph[res[cnt]]) { ind[v]--; if (ind[v] == 0) q.push(v); } } for (int i = 1; i <= n; i++) printf("%d ", res[i]); return 0; }
习题:P3243 [HNOI2015] 菜肴制作
解题思路
注意,本题并不是求字典序最小的拓扑序。
例如,
则如果求字典序最小的拓扑序会求出
考虑制作的最后一道菜肴(其出度必为
最后一道菜定了之后,可以将它移出考虑范畴了。此时问题演变为剩下
因此本题等价于按反向边建图之后求字典序最大的拓扑序,这个结果倒过来输出就是本题要求的答案。
参考代码
#include <cstdio> #include <vector> #include <queue> using std::vector; using std::priority_queue; const int N = 100005; vector<int> graph[N]; int ind[N], ans[N]; void solve() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { graph[i].clear(); ind[i] = 0; } for (int i = 1; i <= m; i++) { int x, y; scanf("%d%d", &x, &y); graph[y].push_back(x); ind[x]++; } priority_queue<int> q; for (int i = 1; i <= n; i++) if (ind[i] == 0) q.push(i); int idx = 0; while (!q.empty()) { int u = q.top(); q.pop(); ans[++idx] = u; for (int v : graph[u]) { ind[v]--; if (ind[v] == 0) q.push(v); } } if (idx < n) printf("Impossible!"); else for (int i = n; i >= 1; i--) printf("%d ", ans[i]); printf("\n"); } int main() { int t; scanf("%d", &t); for (int i = 1; i <= t; i++) { solve(); } return 0; }
例题:P7077 [CSP-S2020] 函数调用
分析:先从简单的操作入手。
如果只有操作
如果只有操作
如果只有操作
如果只有操作
如果只有操作
参考代码
#include <cstdio> #include <vector> #include <queue> using std::vector; using std::queue; const int N = 100005; const int MOD = 998244353; int n, m, q, a[N], t[N], p[N], add[N], mul[N], f[N]; int ind[N], rev_ind[N], cnt[N]; vector<int> graph[N], rev_graph[N]; void func(int u) { if (t[u] == 1) { int idx = p[u]; a[idx] = (a[idx] + add[u]) % MOD; } else if (t[u] == 2) { for (int i = 1; i <= n; i++) { a[i] = 1ll * a[i] * mul[u] % MOD; } } else { for (int g : graph[u]) { func(g); } } } bool check_no_3() { // 没有操作3,只有1和2 for (int i = 1; i <= m; i++) { if (graph[i].size()) return false; } return true; } void solve_no_3() { vector<int> add_num(n + 1); int global_mul = 1; for (int i = q; i >= 1; i--) { int u = f[i]; if (t[u] == 1) { int idx = p[u]; add_num[idx] = (add_num[idx] + 1ll * global_mul * add[u] % MOD) % MOD; } else { global_mul = 1ll * global_mul * mul[u] % MOD; } } for (int i = 1; i <= n; i++) { a[i] = 1ll * a[i] * global_mul % MOD; a[i] = (a[i] + add_num[i]) % MOD; } } int quickpow(int x, int y) { int res = 1; while (y > 0) { if (y % 2 == 1) res = 1ll * res * x % MOD; x = 1ll * x * x % MOD; y /= 2; } return res; } bool check_no_2() { // 没有操作2,只有1和3 for (int i = 1; i <= m; i++) if (t[i] == 2) return false; return true; } void solve_no_2() { for (int i = 1; i <= q; i++) cnt[f[i]]++; queue<int> que; for (int i = 1; i <= m; i++) if (ind[i] == 0) que.push(i); while (!que.empty()) { int u = que.front(); que.pop(); for (int v : graph[u]) { cnt[v] = (cnt[v] + cnt[u]) % MOD; ind[v]--; if (ind[v] == 0) que.push(v); } } for (int i = 1; i <= m; i++) { if (t[i] == 1) { a[p[i]] = (a[p[i]] + 1ll * cnt[i] * add[i] % MOD) % MOD; } } } bool check_no_1() { // 没有操作1,只有2和3 for (int i = 1; i <= m; i++) if (t[i] == 1) return false; return true; } void solve_no_1() { queue<int> que; for (int i = 1; i <= m; i++) if (rev_ind[i] == 0) que.push(i); while (!que.empty()) { int u = que.front(); que.pop(); for (int v : rev_graph[u]) { mul[v] = 1ll * mul[u] * mul[v] % MOD; rev_ind[v]--; if (rev_ind[v] == 0) que.push(v); } } int global_mul = 1; for (int i = 1; i <= q; i++) global_mul = 1ll * global_mul * mul[f[i]] % MOD; for (int i = 1; i <= n; i++) a[i] = 1ll * a[i] * global_mul % MOD; } int main() { scanf("%d", &n); for (int i = 1; i <= n; i++) scanf("%d", &a[i]); scanf("%d", &m); for (int i = 1; i <= m; i++) { scanf("%d", &t[i]); if (t[i] == 1) { scanf("%d%d", &p[i], &add[i]); mul[i] = 1; } else if (t[i] == 2) { scanf("%d", &mul[i]); } else { int c; scanf("%d", &c); mul[i] = 1; for (int j = 1; j <= c; j++) { int g; scanf("%d", &g); graph[i].push_back(g); ind[g]++; rev_graph[g].push_back(i); rev_ind[i]++; } } } scanf("%d", &q); for (int i = 1; i <= q; i++) { scanf("%d", &f[i]); } if (check_no_3()) { solve_no_3(); } else if (check_no_2()) { solve_no_2(); } else if (check_no_1()) { solve_no_1(); } else { for (int i = 1; i <= q; i++) func(f[i]); } for (int i = 1; i <= n; i++) printf("%d ", a[i]); return 0; }
为了方便,可以建立一个虚点
在操作
对于拓扑排序过程中遇到的每个节点
对于初始化,只需要将
注意遍历
参考代码
#include <cstdio> #include <vector> #include <queue> using std::vector; using std::queue; const int N = 100005; const int MOD = 998244353; vector<int> g[N], rev_g[N]; int ind[N], rev_ind[N], a[N], t[N], p[N], add[N], mul[N], cnt[N]; int main() { int n; scanf("%d", &n); for (int i = 1; i <= n; i++) scanf("%d", &a[i]); int m; scanf("%d", &m); for (int i = 1; i <= m; i++) { scanf("%d", &t[i]); if (t[i] == 1) { scanf("%d%d", &p[i], &add[i]); mul[i] = 1; } else if (t[i] == 2) { scanf("%d", &mul[i]); } else { mul[i] = 1; int c; scanf("%d", &c); for (int j = 1; j <= c; j++) { int gid; scanf("%d", &gid); g[i].push_back(gid); ind[gid]++; rev_g[gid].push_back(i); rev_ind[i]++; } } } int q; scanf("%d", &q); for (int i = 1; i <= q; i++) { int f; scanf("%d", &f); g[0].push_back(f); ind[f]++; rev_g[f].push_back(0); rev_ind[0]++; } mul[0] = 1; cnt[0] = 1; queue<int> que; for (int i = 0; i <= m; i++) { if (rev_ind[i] == 0) que.push(i); } while (!que.empty()) { int u = que.front(); que.pop(); for (int v : rev_g[u]) { mul[v] = 1ll * mul[v] * mul[u] % MOD; if (--rev_ind[v] == 0) que.push(v); } } for (int i = 1; i <= n; i++) a[i] = 1ll * a[i] * mul[0] % MOD; for (int i = 0; i <= m; i++) if (ind[i] == 0) que.push(i); while (!que.empty()) { int u = que.front(); que.pop(); int nowmul = 1; if (g[u].size() > 0) { for (int i = (int)g[u].size() - 1; i >= 0; i--) { int v = g[u][i]; cnt[v] = (cnt[v] + 1ll * cnt[u] * nowmul % MOD) % MOD; if (--ind[v] == 0) que.push(v); nowmul = 1ll * nowmul * mul[v] % MOD; } } } for (int i = 1; i <= m; i++) { if (t[i] == 1) { a[p[i]] = (a[p[i]] + 1ll * add[i] * cnt[i] % MOD) % MOD; } } for (int i = 1; i <= n; i++) printf("%d ", a[i]); return 0; }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?