news 2026/2/2 2:16:37

浅谈 Tarjan 算法 _

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
浅谈 Tarjan 算法 _

最近学了些新算法,过来做下笔记,以免以后忘了。

前置知识

Tarjan 算法的时间复杂度为

(

+

)

O(n+m)。

在除了求最近公共祖先的 Tarjan 算法里,都会用到两个数组和一个概念,在这里写清楚一点,以免后面讲得云里雾里。

对图深搜时,每一个节点只访问一次,被访问过的节点与边构成搜索树。

时间戳

dfn

dfn

x

​:表示节点

x​ 第一次被访问的顺序。

追溯值

low

low

x

​:从节点

x​ 出发,最早能访问到的最早时间戳。

当然,这两个数组的含义在不同的 Tarjan 算法中都会有所变化,到时我会讲清楚的。

最近公共祖先(LCA)

题目:最近公共祖先。

在最近公共祖先里,Tarjan 算法是一个离线算法。

离线算法是指在开始处理数据前,所有输入数据已知且不可更改的算法,例如离散化就是一个经典的代表。‌

众所周知,朴素 LCA 的单次查询在最坏情况下的时间复杂度为

(

)

O(n)(

n 为总节点数),因此就有了改进算法,将单次查询的时间提高至

(

log

)

O(logn)。

但是,能否使我们每一次对树的遍历更有价值一些呢,可不可以一次遍历获得不止一个答案甚至是所有答案呢?Tarjan 算法表示这是可以做到的。

知周所众,在以

u​ 为根的子树下,任意两个不属于同一颗以

u​ 的儿子为根的子树的节点(听起来有点晕),它们的 LCA 就是

u​。简单来说,设

son

,

son

u,i

​ 表示节点

u​ 的第

i​ 个儿子,

x​ 属于

son

,

son

u,i

​ 的子树,

y​ 属于

son

,

son

u,j

​ 的子树。只要

i

=j​,那么

x​ 与

y​ 的 LCA 就是

u​。

利用这一性质,我们可以想到一个做法:使用 DFS 遍历每一个节点

x,将

x 的子树存入以

x 的父亲为首的集合里面。接着枚举与

x 有关的问题(即在 DFS 开始前离线出来的问题)若问题为“求出

,

x,y 的 LCA”,且

y 已经被访问过了,那么这个问题的答案即为

y 的首领。

维护首领需要用到并查集,但是这个并查集其实是有点问题,他只能让节点

x 的集合变成

x 父亲节点的集合,即 fa[x] = father,而其它的操作都正常。这样做的目的是为了维护每个节点的当前最大祖先,以达到回答问题的效果。

不过为什么在问题为“求出

,

x,y​ 的 LCA”中,答案是 find(v) 而不是 find(x) 呢?因为我们已经要求了

y​ 是已经被访问过的节点了,也就是说,

y​ 所在的集合首领已经让

y​ 成为这个集合中的一个了,而

x​ 由于还在访问,所以是无法看到自己的最大祖先的。因此我们这里就选择使用 find(y) 来当答案啦!

cpp

#include <bits/stdc++.h>

using namespace std;

const int N = 500010, M = 2 * N;

int n, m, s, a, b;

vector<int> linker[N];

struct node { int v, w; };

vector<node> query[N];

int fa[N], vis[N], ans[M];

int find(int x) {

if (x == fa[x]) return x;

return fa[x] = find(fa[x]);

}

void tarjan(int x) {

vis[x] = 1; // 做访问标记

for (auto to : linker[x])

if (!vis[to]) { // 不能是自己的父亲

tarjan(to); // 接着向下访问

fa[to] = x; // 并查集将节点指向节点父亲

// 注意上述两行代码不能反过来,不然它们的 fa[to] 就都是 root 了

}

for (auto i : query[x]) {

int v = i.v, w = i.w;

// 一定要访问过,不然无意义

if (vis[v]) ans[w] = find(v);

// find(v) 才是真答案,因为 find(v) 已经将 v 的集合首领变为 find(v) 了,而 x 还没有

// 可以这么想一想,如果 v 和 x 不属于同一颗 LCA 的儿子的子树,那么既然 v 已经访问完毕,答案肯定是 v 的集合首领

}

}

int main() {

cin >> n >> m >> s;

for (int i = 1; i < n; i++) {

scanf("%d%d", &a, &b);

linker[a].push_back(b);

linker[b].push_back(a);

}

// 离线

for (int i = 1; i <= m; i++) {

scanf("%d%d", &a, &b);

query[a].push_back({b, i});

query[b].push_back({a, i});

}

for (int i = 1; i <= N; i++) fa[i] = i; // 并查集初始化

tarjan(s);

for (int i = 1; i <= m; i++)

printf("%d\n", ans[i]);

return 0;

}

强连通分量(SCC)

题目:The Cow Prom S。

强连通分量:如果一张有向图,它的任意两个节点都可以互相到达,我们称之为强连通图。如果是一张图的极大的强连通子图,我们称之为强连通分量。

如图,

1

,

2

,

3

,

4

1,2,3,4 组成了一个强连通分量,但

1

,

2

,

3

,

4

,

5

1,2,3,4,5 组成的就不是:

好了现在要用到前面说到的

dfn

dfn 数组和

low

low 数组了,忘记的回去看。

使用 DFS 遍历每一个节点,当枚举到

x 时,初始化

dfn

dfn

x

low

low

x

,并且将其压入栈内(这个栈是用来存储强连通分量的节点的):

cpp

dfn[x] = low[x] = ++tim; // 这个 tim 是全局变量

stk[++top] = x, vis[x] = 1; // 将 x 放入栈中,并打上标记

接着枚举

x 的邻点

v,分三种情况:

v 尚未访问,那么对

v 深搜,并用

low

low

v

更新

low

low

x

。因为

x 是

v 的父节点,

v 能到达的地方

x 也能到达。

v 已访问且在栈中,说明

v 是左子树节点或者祖先节点。使用

dfn

dfn

v

更新

low

low

x

。此处使用

low

low

v

更新

low

low

x

可能会被其它强连通分量给污染。

v 已访问且不在栈中,说明

v 已经被其所在的强连通分量处理了,不需要管它。

cpp

for (int v : linker[x]) {

if (!dfn[v]) {

tarjan(v);

low[x] = min(low[x], low[v]);

} else if (vis[v])

low[x] = min(low[x], dfn[v]);

}

之后处理强连通分量。

若当前枚举到的

x 满足

dfn

=

low

dfn

x

=low

x

,也就表示

x 能到达的最小时间戳的节点就是自己,也就代表着

x 是一个强连通分量上的一个点,那么在栈中在它后面放入的点且现在还在栈中的点与它就可以组成一个强连通分量。所以接下来只要把栈中比

x 后加入栈的节点取出来就可以了。

cpp

if (dfn[x] == low[x]) {

++cnt;

while (1) {

int y = stk[top--]; // 取出节点

vis[y] = 0; // 取消进栈标记

scc[y] = cnt; // 记录强连通分量中的节点属于哪一个分量

++siz[cnt]; // 记录强连通分量大小

if (x == y) break; // 枚举到 x 出栈就可以了

}

}

为了完成这道题,这里继续给出主函数代码,比较简单,如果前面的看懂了,主函数肯定不成问题:

cpp

int main() {

ios::sync_with_stdio(0);

cin.tie(0), cout.tie(0);

cin >> n >> m;

for (int i = 1; i <= m; i++)

cin >> a >> b, add(a, b);

for (int i = 1; i <= n; i++) // 整个图可能不连通,要一一枚举

if (!dfn[i]) tarjan(i);

for (int i = 1; i <= cnt; i++)

if (siz[i] > 1) ans++;

cout << ans << endl;

return 0;

}

割点

题目:割点。

割点:在一张无向图中,如果去掉一个点可以使图的连通块增加,则这个点被称之为割点。

如图,

4

4​ 就是一个割点:

好吧这个算法也是要用到

dfn

dfn 和

low

low 的,而且我可以提前告诉你:割点算法与强连通分量的代码长得很像,需要好好区分。

还是使用 DFS 枚举

x​,分两种情况:

x​ 不为根节点,当搜索树上存在一个

x​ 的儿子

y​,满足

low

dfn

low

y

≥dfn

x

​,则

x​ 为割点。

x​ 为根节点,当搜索上存在两个

x​ 的儿子满足上述条件时,

x​ 为割点。

那么

low

dfn

low

y

≥dfn

x

是什么意思呢?这个不等式其实代表着

y 最远能到达的最小的时间戳也不会超过

x 的时间戳,也就表示着

x 的父亲节点

y 是碰不着的,那么此时如果断开,必定会增加至少一个连通块。

那么为什么根节点需要拥有两个这样的儿子呢,举个例子你就知道了。

如图,

1

1 作为根节点有着

2

2 这样满足条件的儿子,但是将其去掉后图的连通块仍然是

1

1:

好了,在知道以上判断条件后,代码也不难写出了.

但是,还是要注意一点:数据是有重边的,而重边就可以往回走了。但是我们这个判断就直接把这个机会给“杀死”了。

因此,我们不能根据顶点来判断,而是要根据边来判断,条件是不能走上一次走过的边。我们为了判边,就需要给 vector 多绑上一个 int 保存边的编号以判断。

cpp

// Tarjan算法 O(n + m)

#include<bits/stdc++.h>

using namespace std;

const int N = 20010;

int n, m, a, b, ans;

vector<int> linker[N], ne[N];

int dfn[N], low[N], tim, top;

int iscut[N];

void add(int a, int b) {

linker[a].push_back(b);

}

void tarjan(int x, int isroot) {

dfn[x] = low[x] = ++tim;

int child = 0;

for (int v : linker[x]) {

if (!dfn[v]) {

tarjan(v, 0);

low[x] = min(low[x], low[v]);

if (low[v] >= dfn[x]) child++;

} else low[x] = min(low[x], dfn[v]);

}

if (!isroot && child >= 1 || isroot && child >= 2) iscut[x] = 1;

}

int main() {

ios::sync_with_stdio(0);

cin.tie(0), cout.tie(0);

cin >> n >> m;

for (int i = 1, a, b; i <= m; i++) {

cin >> a >> b;

add(a, b), add(b, a);

}

for (int i = 1; i <= n; i++) // 可能不连通

if (!dfn[i]) tarjan(i, 1);

for (int i = 1; i <= n; i++)

ans += iscut[i];

cout << ans << endl;

for (int i = 1; i <= n; i++)

if (iscut[i]) cout << i << " ";

return 0;

}

割边

题目:炸铁路。

注:本题使用暴力解法也可以通过 ,因此你可以不用学这个算法。

割边:给定一张无向图,若存在一条边,将这条边去掉后整个图的连通块数量增加,则这条边为割边。

如图,边

[

4

,

5

]

[4,5] 就是一条割边:

好吧我就告诉你吧,其实 Tarjan 算法长得都挺像的(除了那个 LCA 以外)

还是使用 DFS 算法,设当前枚举到

x​,若

x​ 存在一个子节点

y​,满足

low

>

dfn

low

y

>dfn

x

​,则

[

,

]

[x,y]​ 这条边就是割边。

low

>

dfn

low

y

>dfn

x

,说明从

y 出发,在不经过

[

,

]

[x,y] 这条边的前提下,不管怎么走,都无法达到

x 或更早访问的节点。故删除

[

,

]

[x,y] 这条边,图的联通块一定能增加。

反之,如果

low

dfn

low

y

≤dfn

x

,则说明

y 能绕行其他边到达

x 或更早访问的节点,那

[

,

]

[x,y] 就不是割边了。我们也可以知道:环内的边割不断。

在这里与割点算法做个对比,割点算法是满足

low

dfn

low

y

≥dfn

x

就可以使

x 成为割点,因为割边算法是判断

[

,

]

[x,y] 是否是条割边,因此在

y 不走

[

,

]

[x,y] 不能到达

x,那么

[

,

]

[x,y] 就是割边,因此不可以带等号。而割点需要判断去掉点

x 后

y 能否到达比

x 更早访问的点,因此可以带等号。

cpp

#include<bits/stdc++.h>

using namespace std;

const int N = 5010;

int n, m, a, b, ans, cnt;

int dfn[N], low[N], tim, top;

struct edge { int u, v; } bri[N];

vector<edge> e;

vector<int> linker[N];

bool cmp(edge x, edge y) {

if (x.u == y.u) return x.v < y.v;

return x.u < y.u;

}

void add(int a, int b) {

e.push_back({a, b});

linker[a].push_back(e.size() - 1);

}

void tarjan(int x, int in_edge) {

dfn[x] = low[x] = ++tim;

for (int j : linker[x]) {

int v = e[j].v;

if (!dfn[v]) {

tarjan(v, j);

low[x] = min(low[x], low[v]);

if (low[v] > dfn[x]) bri[++cnt] = {x, v};

} else if (j != (in_edge ^ 1)) low[x] = min(low[x], dfn[v]);

}

}

int main() {

ios::sync_with_stdio(0);

cin.tie(0), cout.tie(0);

cin >> n >> m;

for (int i = 1, a, b; i <= m; i++) {

cin >> a >> b;

add(a, b), add(b, a);

}

for (int i = 1; i <= n; i++) // 可能不连通

if (!dfn[i]) tarjan(i, 0);

sort(bri + 1, bri + cnt + 1, cmp);

for (int i = 1; i <= cnt; i++)

cout << bri[i].u << " " << bri[i].v << endl;

return 0;

}

边双连通分量(eDCC)

题目:边双连通分量。

边双连通分量:在无向图中,极大的不包含割边的连通块称为边双连通分量。

如图,

[

1

,

2

,

3

]

,

[

4

]

,

[

5

]

,

[

6

]

[1,2,3],[4],[5],[6] 都是边双连通分量。

仍然使用 DFS 枚举节点。当枚举到

x 时,使用邻点更新

low

low

x

dfn

dfn

x

,这个前面已经讲过了。不太明白的可以往前看。

然后进行边双连通分量的判定:若存在

low

=

dfn

low

x

=dfn

x

,那么弹出当前栈中在

x 之后加入的点和

x,这些点就构成了一个边双连通分量。

cpp

#include<bits/stdc++.h>

using namespace std;

const int N = 500010;

int n, m, tim, top;

int dfn[N], low[N], stk[N];

struct edge { int u, v; };

vector<edge> e;

vector<int> linker[N];

vector<vector<int> > ans;

void add(int a, int b) {

e.push_back({a, b});

linker[a].push_back(e.size() - 1);

}

void tarjan(int x, int ine) {

dfn[x] = low[x] = ++tim;

stk[++top] = x;

for (int j : linker[x]) {

int v = e[j].v;

if (!dfn[v]) {

tarjan(v, j);

low[x] = min(low[x], low[v]);

} else if (j != (ine ^ 1))

low[x] = min(low[x], dfn[v]);

}

// 以下记录边双联通分量

if (dfn[x] == low[x]) {

vector<int> vec;

while (1) {

int y = stk[top--];

vec.push_back(y);

if (x == y) break;

}

ans.push_back(vec);

}

}

int main() {

ios::sync_with_stdio(0);

cin.tie(0), cout.tie(0);

cin >> n >> m;

for (int i = 1, a, b; i <= m; i++) {

cin >> a >> b;

add(a, b), add(b, a);

}

for (int i = 1; i <= n; i++)

if (!dfn[i]) tarjan(i, 0);

cout << ans.size() << endl;

for (auto i : ans) {

cout << i.size() << " ";

for (int j : i) cout << j << " ";

cout << endl;

}

return 0;

}

点双连通分量(vDCC)

题目:点双连通分量。

点双连通分量:在无向图中,极大的不包含割点的连通块称为点双连通分量(注:一个点也算点双连通分量)。

如图,

[

1

,

2

,

3

]

,

[

1

,

4

]

,

[

4

,

5

]

,

[

4

,

6

]

[1,2,3],[1,4],[4,5],[4,6] 都是点双连通分量。

仍然使用 DFS 枚举节点。当枚举到

x 时,使用邻点更新

low

low

x

dfn

dfn

x

,这个前面已经讲过了。不太明白的可以往前看。

然后进行点双连通分量的判定:若发现割点判定法

low

dfn

low

y

≥dfn

x

,那么当前栈中在

y 之后加入的点与

x 就构成了一个点双连通分量(注:

x 与

y 相连)。并且在记录完点双连通分量后把前栈中在

y 之后加入的点全部弹出。

当然,除了上述判定外,在遇到单独节点时,也把这个点看作一个边双连通分量。

cpp

#include<bits/stdc++.h>

using namespace std;

const int N = 500010;

int n, m, cnt;

vector<int> linker[N], dcc[N];

int dfn[N], low[N], stk[N], tim, top;

bool cut[N];

void tarjan(int u, int root) {

dfn[u] = low[u] = ++tim;

stk[++top] = u;

if (linker[u].empty()) { // 孤立点

dcc[++cnt].push_back(u);

return;

}

int child = 0;

for (int v : linker[u]) {

if(!dfn[v]) {

tarjan(v, root);

low[u] = min(low[u], low[v]);

if(low[v] >= dfn[u]) {

child++;

if(u != root || child > 1) cut[u] = true;

cnt++;

int x;

do {

x = stk[top--];

dcc[cnt].push_back(x);

} while (x != v);

dcc[cnt].push_back(u);

}

}

else low[u] = min(low[u], dfn[v]);

}

}

int main() {

scanf("%d%d", &n, &m);

for(int i = 1; i <= m; i++) {

int u, v;

scanf("%d%d", &u, &v);

if(u == v) continue; // 处理自环

linker[u].push_back(v);

linker[v].push_back(u);

}

for(int i = 1; i <= n; i++)

if(!dfn[i]) tarjan(i, i);

// 处理孤立点

for(int i = 1; i <= n; i++) {

if(!dfn[i]) {

dcc[++cnt].push_back(i);

}

}

printf("%d\n", cnt);

for(int i = 1; i <= cnt; i++) {

printf("%d", (int)dcc[i].size());

sort(dcc[i].begin(), dcc[i].end());

dcc[i].erase(unique(dcc[i].begin(), dcc[i].end()), dcc[i].end());

for(int x : dcc[i]) printf(" %d", x);

puts("");

}

return 0;

}

SCC 缩点

题目:缩点。

SCC 缩点:将一个有向图中的强连通分量合并成一个点,组成了一张有向无环图。

如图,

[

1

,

2

,

3

]

[1,2,3] 作为一个强连通分量缩成点

1

1,

[

4

,

5

]

[4,5] 作为一个强连通分量缩成点

2

2。

SCC 缩点比较简单,只要在找出所有强连通分量之后,把这些分量看成一个点就可以了。

至于本题的做法就是在缩点完毕之后,算出新图中每个点的点权值,然后跑 DP 就可以了。

cpp

// Tarjan算法 O(n + m)

#include<bits/stdc++.h>

using namespace std;

const int N = 10010;

int n, m, a, b, ans;

vector<int> linker[N], ne[N];

int dfn[N], low[N], tim, stk[N], vis[N], top, scc[N], cnt;

int siz[N], dout[N], w[N], nw[N], f[N];

void add(int a, int b) {

linker[a].push_back(b);

}

void tarjan(int x) {

dfn[x] = low[x] = ++tim;

stk[++top] = x, vis[x] = 1; // 将 x 放入栈中

for (int v : linker[x]) {

if (!dfn[v]) {

tarjan(v);

low[x] = min(low[x], low[v]);

} else if (vis[v])

low[x] = min(low[x], dfn[v]);

}

if (dfn[x] == low[x]) {

++cnt;

while (1) {

int y = stk[top--];

vis[y] = 0;

scc[y] = cnt;

siz[cnt]++;

if (x == y) break;

}

}

}

int main() {

ios::sync_with_stdio(0);

cin.tie(0), cout.tie(0);

cin >> n >> m;

for (int i = 1; i <= n; i++) cin >> w[i];

for (int i = 1, a, b; i <= m; i++) {

cin >> a >> b;

add(a, b);

}

for (int i = 1; i <= n; i++) // 可能不连通

if (!dfn[i]) tarjan(i);

for (int x = 1; x <= n; x++) {

nw[scc[x]] += w[x];

for (int v : linker[x]) {

int a = scc[x], b = scc[v];

if (a != b) ne[a].push_back(b);

}

}

for (int x = cnt; x; x--) { // 记住就好,只要倒着枚举就可以了

if (f[x] == 0) f[x] = nw[x];

for (int v : ne[x])

f[v] = max(f[v], f[x] + nw[v]);

}

for (int i = 1; i <= cnt; i++)

ans = max(ans, f[i]);

cout << ans << endl;

return 0;

}

eDCC 缩点

题目:Redundant Paths G。

eDCC 缩点:将一个无向图中的边双通分量合并成一个点,组成了一棵树。

如图,

[

1

,

2

,

3

]

[1,2,3] 构成点

1

1,

[

4

]

[4] 构成点

2

2​。

把边双连通分量处理出来,之后进行缩点即可。

cpp

#include<bits/stdc++.h>

using namespace std;

const int N = 5010, M = 20010; // 调整数组大小:N为节点数,M为有向边数

int n, m, a, b, cnt, sum;

int dfn[N], low[N], dcc[N], stk[N], tim, top, du[N];

int bri[M]; // 扩大bri数组大小至M

struct edge { int u, v; };

vector<edge> e;

vector<int> linker[N];

void add(int a, int b) {

e.push_back({a, b});

linker[a].push_back(e.size() - 1);

}

void tarjan(int x, int ine) {

dfn[x] = low[x] = ++tim;

stk[++top] = x;

for (int j : linker[x]) {

int v = e[j].v;

if (!dfn[v]) {

tarjan(v, j);

low[x] = min(low[x], low[v]);

if (low[v] > dfn[x])

bri[j] = bri[j ^ 1] = 1; // 标记桥边

} else if (j != (ine ^ 1))

low[x] = min(low[x], dfn[v]);

}

if (dfn[x] == low[x]) {

++cnt;

while (1) {

int y = stk[top--];

dcc[y] = cnt;

if (x == y) break;

}

}

}

int main() {

ios::sync_with_stdio(0);

cin.tie(0), cout.tie(0);

cin >> n >> m;

for (int i = 1, a, b; i <= m; i++) {

cin >> a >> b;

add(a, b), add(b, a);

}

tarjan(1, -1); // 从节点1开始,初始入边编号为-1

// 计算缩点后度数:只遍历偶数索引边(每条无向边处理一次)

for (int i = 0; i < 2 * m; i += 2) {

if (bri[i]) { // 当前无向边是桥

int u = e[i].u, v = e[i].v;

du[dcc[u]]++; // 更新缩点后连通分量的度数

du[dcc[v]]++;

}

}

// 统计叶子节点数(度数为1的连通分量)

for (int i = 1; i <= cnt; i++)

if (du[i] == 1) sum++;

cout << (sum + 1) / 2 << endl; // 最小加边数

return 0;

}

vDCC 缩点

题目:由于占时没有找到题目,此处不给出代码。不过结合前面的代码那么写出这个板子也不难。

vDCC 缩点:将一个无向图中的点双通分量合并成一个点,组成了一棵树。

如图,

[

1

,

2

,

3

]

[1,2,3] 构成点

1

1,

[

4

]

[4] 构成点

2

2。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/2/2 23:51:37

120亿参数改写效率标杆:GLM-4.5-Air重塑智能代理格局

120亿参数改写效率标杆&#xff1a;GLM-4.5-Air重塑智能代理格局 【免费下载链接】GLM-4.5-Air 项目地址: https://ai.gitcode.com/hf_mirrors/unsloth/GLM-4.5-Air 导语 当企业还在为大模型部署成本居高不下而发愁时&#xff0c;智谱AI推出的GLM-4.5-Air以1060亿总参…

作者头像 李华
网站建设 2026/2/3 0:34:37

FTXUI ResizableSplit:打造你的终端自定义布局神器

还在为终端应用界面死板而烦恼吗&#xff1f;FTXUI的ResizableSplit组件为你带来了革命性的解决方案&#xff01;这个强大的C功能终端用户界面库让终端应用也能拥有灵活的拖拽调整功能&#xff0c;让你的用户界面体验提升到全新高度。 【免费下载链接】FTXUI :computer: C Func…

作者头像 李华
网站建设 2026/2/2 23:57:45

解锁Sketchfab宝藏:3步搞定海量3D模型下载

解锁Sketchfab宝藏&#xff1a;3步搞定海量3D模型下载 【免费下载链接】sketchfab sketchfab download userscipt for Tampermonkey by firefox only 项目地址: https://gitcode.com/gh_mirrors/sk/sketchfab 还在为Sketchfab上精美的3D模型无法下载而烦恼吗&#xff1f…

作者头像 李华
网站建设 2026/2/3 0:23:48

抖音去水印下载工具:5分钟学会批量保存无水印视频的终极方法

抖音去水印下载工具&#xff1a;5分钟学会批量保存无水印视频的终极方法 【免费下载链接】TikTokDownload 抖音去水印批量下载用户主页作品、喜欢、收藏、图文、音频 项目地址: https://gitcode.com/gh_mirrors/ti/TikTokDownload 还在为无法保存无水印的抖音视频而烦恼…

作者头像 李华
网站建设 2026/2/3 0:17:21

Mac鼠标滚动终极优化指南:让普通鼠标拥有触控板般的丝滑体验

Mac鼠标滚动终极优化指南&#xff1a;让普通鼠标拥有触控板般的丝滑体验 【免费下载链接】Mos 一个用于在 macOS 上平滑你的鼠标滚动效果或单独设置滚动方向的小工具, 让你的滚轮爽如触控板 | A lightweight tool used to smooth scrolling and set scroll direction independe…

作者头像 李华