「笔记」点分治

写在前面

尝试使用随机选择根节点的方法草过模板失败了= =

点分治

引入

P3806 【模板】点分治1

给定一棵 n 个节点的树,边有边权,给定 m 次询问,每次询问树上距离为 k 的点对是否存在。
1n1041m1001k1071 边权 104
200ms,512MB。

对于单次询问,暴力枚举点对并统计信息的复杂度为 O(n2) 级别。而点分治算法通过适当地选取根节点,将路径问题转化为到根的链的问题。通过记忆化链信息减小了统计的复杂度,可以在 O(nlogn) 的时间复杂度内完成单次询问。

分析

先随意钦定一个根节点,此时树上的路径分为两种:过根节点的和不过根节点的,考虑分别处理它们。

  • 第一步,对于过根节点的路径,它们可以由至多两条以根节点为端点的链拼接而成。考虑 dfs 预处理以根节点为端点的链的权值和,问题变为它们中是否存在至多两个数的权值和为 k,开个桶即可解决。

  • 第二步,对于不过根节点的路径,将其子树中其它节点作为新的根节点递归处理即可。在完成第一步后,所有过旧根的路径都已被统计过,更新根节点后枚举链时可以不统计经过旧根的链。此时旧根的各子树是相互独立的,需要在每棵子树中都选取一个新的根节点并重复上述过程。此时可以将每棵子树的情况看做规模更小的子问题。

下图给出了一个上述过程的示例:

[图片]

考虑复杂度。设上述过程中选择的第 1 个根构成「1 级根集」,所有「r 级根集」中的点在第二步里各子树中选择的新根共同构成 「r+1 级根集」。可以发现对于「任意级根集」,它们作为新根在第一步中进行统计时,遍历到的所有节点的数量是 O(n) 级别的。则上述算法的总时间复杂度是 O(nrmax) 的。

为最小化 rmax,显然应每次选择子树的重心作为新的根节点。正确性显然,以重心为根时,每棵子树的大小都不超过 n2,则子树的大小将会在至多 logn 次新根的选择后变为 1,保证 rmax 稳定在 logn 级别。


注意每次重新选择根节点前都需要重新计算子树大小,并通过新子树大小选择新根。

总复杂度 O(nmlogn) 级别。

复制复制
//知识点:点分治
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <vector>
#include <queue>
#define LL long long
const int kN = 1e4 + 10;
const int kM = 110;
const int kMaxDis = 1e7 + 10;
//=============================================================
int n, m, e_num, k[kM], head[kN], v[kN << 1], w[kN << 1], ne[kN << 1];
int root, sumsz, sz[kN], maxsz[kN], dis[kN];
bool ans[kM], vis[kN], exist[kMaxDis];
std::vector <int> tmp;
std::queue <int> tag;
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Chkmax(int &fir, int sec) {
if (sec > fir) fir = sec;
}
void Chkmin(int &fir, int sec) {
if (sec < fir) fir = sec;
}
void Add(int u_, int v_, int w_) {
v[++ e_num] = v_, w[e_num] = w_;
ne[e_num] = head[u_], head[u_] = e_num;
}
void CalcSize(int u_, int fa_) { //求重心
sz[u_] = 1, maxsz[u_] = 0;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
CalcSize(v_, u_);
Chkmax(maxsz[u_], sz[v_]);
sz[u_] += sz[v_];
}
Chkmax(maxsz[u_], sumsz - sz[u_]);
if (maxsz[u_] < maxsz[root]) root = u_;
}
void CalcDis(int u_, int fa_) { //求得各点到当前根的距离
tmp.push_back(dis[u_]);
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i], w_ = w[i];
if (v_ == fa_ || vis[v_]) continue;
dis[v_] = dis[u_] + w_;
CalcDis(v_, u_);
}
}
void Dfs(int u_, int fa_) {
exist[0] = true;
tag.push(0);
vis[u_] = true; //标记已处理
for (int i = head[u_]; i; i = ne[i]) { //处理链信息
int v_ = v[i], w_ = w[i];
if (v_ == fa_ || vis[v_]) continue;
dis[v_] = w_;
CalcDis(v_, u_);
for (int j = 0, lim = tmp.size(); j < lim; ++ j) {
for (int l = 1; l <= m; ++ l) {
if (k[l] >= tmp[j]) ans[l] |= exist[k[l] - tmp[j]];
}
}
for (int j = 0, lim = tmp.size(); j < lim; ++ j) {
if (tmp[j] < kMaxDis) {
tag.push(tmp[j]);
exist[tmp[j]] = true;
}
}
tmp.clear();
}
while (!tag.empty()) {
exist[tag.front()] = false;
tag.pop();
}
for (int i = head[u_]; i; i = ne[i]) { //分治求解
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
sumsz = sz[v_];
root = 0, maxsz[root] = kN;
CalcSize(v_, u_), Clac(root, 0), Dfs(root, 0);
}
}
//=============================================================
int main() {
n = read(), m = read();
for (int i = 1; i < n; ++ i) {
int u_ = read(), v_ = read(), w_ = read();
Add(u_, v_, w_), Add(v_, u_, w_);
}
for (int i = 1; i <= m; ++ i) k[i] = read();
sumsz = n;
root = 0, maxsz[root] = kN;
CalcSize(1, 0), Clac(root, 0), Dfs(root, 0);
for (int i = 1; i <= m; ++ i) printf("%s\n", ans[i] ? "AYE" : "NAY");
return 0;
}

点分树

简介

点分树是一种基于点分治的、通过更改原树形态使树高变为稳定 logn 的重构树,常用于解决与树原形态无关的问题(包括树上路径、关键点问题等),嵌套数据结构后可以维护某些动态修改问题。

点分树的建立非常容易理解。在点分治的过程中,令每次找到的重心的父亲变为上一层的重心即可。由于点分治的递归层数为 logn 级别,重构树的树高也是稳定 logn 的。自顶向下遍历点分树与点分治过程一致。点分树上某点的子树信息,对应点分治过程中该点所在分治块的信息。
预处理点分树上的子树信息后,可以实现带修的点分治,即“动态点分治”。
由于点分树树高是稳定 logn 的,一些基于树高的暴力算法在点分树上有着正确的复杂度,比如用对每个节点都开一个 vector 维护其点分树上的子树节点之类。

点分树上任意两点 u,vlca(u,v) 一定在两点在原树上的路径上。因为在原树上进行点分治时,对 lca(u,v) 后点 u,v 位于不同的分治块中,说明删去 lca(u,v) 后两点便不连通。则一定有 dis(u,v)=dis(u,lca(u,v))+dis(v,lca(u,v)),由此可以在点分树上处理一些路径权值类问题。

以下给出了一个对给定树建立点分树的图示:

如果你看到这行字说明我的牛逼图床爆掉啦 如果你看到这行字说明我的牛逼图床爆掉啦

根据上述性质,一种使用点分树处理带修路径问题的套路是在点分树上模拟点分治的过程:
预处理点分树上每个节点子树内的点与该点组成的路径的信息 f1,以及与该点的父亲组成的路径的信息 f2。经过某节点的路径在点分治中,只会在以点分树上该点及其祖先为分治块的重心时被统计到。由此在点分树上统计它们的信息时,可以先统计预处理的该点与其子树节点构成路径的贡献,再暴力跳父亲统计不在子树内节点的贡献(过父亲的路径的贡献)。每次累计不在指定点所在分治块内的点到达父亲的路径的贡献(通过预处理的 f1,f2 容斥得到),再加上指定点到达父亲的路径的贡献。

模板题

P6329 【模板】点分树 | 震波

点分树,动态开点权值线段树

模板 ×
恶臭嵌套数据结构

给定一棵 n 个节点的树,点有点权。两点间的距离定义为两点树上简单路径边的数量。给定 m 次操作:

  1. 单点点权修改。
  2. 查询到达给定点 u 距离不大于给定值 w 的点权值之和。

1n,m1051 点权104
2S,256MB。

可以先随意钦定一个根节点预处理深度 dep 与欧拉序,树上两点距离即可通过 depu+depv2×deplca(u,v) 快速求得,使得距离信息与原树形态无关。

先考虑如何使用点分治解决不带修的情况。考虑枚举所有以给定点 u 为一端点的权值为给定值的路径。在点分树上有 dis(u,v)=dis(u,lca(u,v))+dis(v,lca(u,v)),考虑每次点分治时都枚举以该分治块的根 root 为一端点的链 rootv,检查它们与链 rootu 拼接后权值是否不大于给定值并累计合法路径的权值。注意不考虑与 uroot 的同一子树中的 v,因为此时 root 不一定在 uv 上,这样统计出的路径是非法的。
递归处理分值块时仅处理 u 所在的分治块,不包含 u 的分治块对答案并无贡献。处理完以 u 为根的分治块时回溯即可,此时已经处理完所有的指定路径了。

可以发现上述过程仅处理了 logn 个分治块,其时间复杂度为 O(nlogn) 级别。


对于上述过程,考虑在点分树上预处理分治块的信息来加速。一种显然的想法是对每个节点都维护一个桶 t1,储存其点分树子树中与其不同距离的点的权值之和。为处理与 uroot 的同一子树中的 v,还应维护一个桶 t2,储存其点分树子树中其点分树上的父亲不同距离的点的权值之和(点分树的根节点不存在 t2)。即有:

t1(u,x)=vsubtree(u)vali[dis(u,v)=x]t2(u,x)=vsubtree(u)vali[dis(fau,v)=x]

由于点分树的树高为 logn 级别,上述信息可以直接在点分树上暴力处理,时间复杂度 O(nlogn) 级别。

每层点分治统计的是过该分治块根的路径的贡献,该过程可以在预处理信息后的点分树上进行。考虑在点分树上从给定节点 u 不断上移,每次统计与当前节点 now 距离不超过 wdis(now,u) 的点的点权和,即 iwdis(now,u)t1(now,i),并减去另一端点与 uroot 的同一子树中的路径的贡献,即 iwdis(now,u)t2(last,i)lastnow 上移前的节点编号)。
可以发现该自下向上统计的过程与点分治自上向下统计的过程是完全一致的。跳父亲访问到的节点信息与点分治访问的分治块信息一一对应,从桶中取出与当前节点 now 距离不大于 wdis(now,u) 的点的点权和的过程与点分治中拼接路径的过程对应。通过 t2 容斥掉重复路径的过程与点分治中避免访问到非法子树的过程对应。
对每个节点预处理 t1t2 的前缀和后,单次询问的时间复杂度被优化到了 O(logn) 级别。

再考虑单点权值修改。权值修改不会影响距离信息,且被影响的节点仅有从指定节点到根的 logn 个节点,直接用树状数组/动态开点线段树维护 t1t2 即可。修改时直接暴力跳父亲并修改,时空复杂度均多乘一个 logn

总复杂度为 O((n+m)log2n) 级别。

代码中使用了动态开点线段树,为减小复杂度每棵线段树的下标范围设为了点分树子树大小而非 n

//知识点:点分树,线段树
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 2e5 + 10;
const int kM = kN << 1;
//=============================================================
int n, m, ans, e_num, head[kN], v[kM], ne[kM], dep[kN], val[kN];
int root, sumsz, sz[kN], maxsz[kN], dis[kN];
int newfa[kN], newsz[kN];
bool vis[kN];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Chkmax(int &fir_, int sec_) {
if (sec_ > fir_) fir_ = sec_;
}
void Chkmin(int &fir_, int sec_) {
if (sec_ < fir_) fir_ = sec_;
}
void AddEdge(int u_, int v_) {
v[++ e_num] = v_;
ne[e_num] = head[u_];
head[u_] = e_num;
}
namespace ST { //用于求树上两点距离
int num, Log2[kN << 1], f[kN << 1][22], fir[kN];
void Dfs(int u_, int fa_) {
dep[u_] = dep[fa_] + 1;
fir[u_] = ++ num;
f[num][0] = u_;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_) continue ;
Dfs(v_, u_);
f[++ num][0] = u_;
}
}
void Prepare() {
Dfs(1, 0);
Log2[1] = 0;
for (int i = 2; i <= num; ++ i) Log2[i] = Log2[i >> 1] + 1;
for (int i = 1; i <= 21; ++ i) {
for (int j = 1; j + (1 << i) - 1 <= num; ++ j) {
if (dep[f[j][i - 1]] < dep[f[j + (1 << (i - 1))][i - 1]]) {
f[j][i] = f[j][i - 1];
} else {
f[j][i] = f[j + (1 << (i - 1))][i - 1];
}
}
}
}
int Lca(int u_, int v_) {
int l = fir[u_], r = fir[v_];
if (l > r) std::swap(l, r);
int lth = Log2[r - l + 1];
if (dep[f[l][lth]] < dep[f[r - (1 << lth) + 1][lth]]) return f[l][lth];
return f[r - (1 << lth) + 1][lth];
}
int Dis(int u_, int v_) {
return dep[u_] + dep[v_] - 2 * dep[Lca(u_, v_)];
}
}
#define ls lson[now_]
#define rs rson[now_]
#define mid ((L_+R_)>>1)
int node_num, lson[kN << 5], rson[kN << 5], sum[kN << 5];
struct Seg {
int rt;
void Modify(int &now_, int L_, int R_, int pos_, int val_) {
if (!now_) now_ = ++ node_num;
sum[now_] += val_;
if (L_ == R_) return;
if (pos_ <= mid) Modify(ls, L_, mid, pos_, val_);
else Modify(rs, mid + 1, R_, pos_, val_);
}
int Query(int now_, int L_, int R_, int pos_) {
if (!now_) return 0;
if (R_ <= pos_) return sum[now_];
if (pos_ > mid) {
return Query(ls, L_, mid, pos_) + Query(rs, mid + 1, R_, pos_);
}
return Query(ls, L_, mid, pos_);
}
} t1[kN], t2[kN];
//t1、t2 均为权值线段树。
//每个节点的 t1 维护该点 点分树子树内距离 **该点** 各距离的点的权值之和。
//每个节点的 t2 维护该点 点分树的子树内距离 **该点的父亲** 各距离的点的权值之和。
void CalcSize(int u_, int fa_) { //找重心
sz[u_] = 1, maxsz[u_] = 0;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;shu
CalcSize(v_, u_);
Chkmax(maxsz[u_], sz[v_]);
sz[u_] += sz[v_];
}
Chkmax(maxsz[u_], sumsz - sz[u_]);
if (maxsz[u_] < maxsz[root]) root = u_;
}
void Dfs(int u_, int fa_) { //建立点分树并维护点分树的 size
vis[u_] = true;
newsz[u_] = 1;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
sumsz = sz[v_], root = 0, maxsz[root] = kN;
CalcSize(v_, u_);
int tmp = root;
CalcSize(root, 0), Dfs(root, 0);
newfa[tmp] = u_;
newsz[u_] += newsz[tmp];
}
}
void Modify(int pos_, int val_) { //单点修改,更新点分树上该点到根路径上各点的 子树信息
for (int now_ = pos_; now_; now_ = newfa[now_]) {
t1[now_].Modify(t1[now_].rt, 0, newsz[now_], ST::Dis(pos_, now_), val_);
if (newfa[now_]) { //更新父亲信息
t2[now_].Modify(t2[now_].rt, 0, newsz[newfa[now_]],
ST::Dis(pos_, newfa[now_]), val_);
}
}
}
int Query(int pos_, int k_) { //查询操作,查询点分树上该点到根路径上各点
int ret = 0;
for (int now_ = pos_, last_ = 0; now_; now_ = newfa[now_]) {
int dis = ST::Dis(pos_, now_); //当前点到指定点的距离
if (dis <= k_) { //查询该点分树的子树内到指定点距离不大于 k 的点的点权和
ret += t1[now_].Query(t1[now_].rt, 0, newsz[now_], k_ - dis);
if (last_) ret -= t2[last_].Query(t2[last_].rt, 0, newsz[now_], k_ - dis);
//last 内的点到指定点的路径 已经在上一次循环中被统计过,减去其贡献
}
last_ = now_;
}
return ret;
//为什么上述做法是对的?
//类比点分治自顶向下的处理过程,上述的每层循环实际上是在统计经过 now_ 的路径的贡献。
//从 pos_ 开始一路向上,一定可以统计到所有有贡献的路径,去重后的正确性也可以保证。
}
void Init() {
n = read(), m = read();
for (int i = 1; i <= n; ++ i) val[i] = read();
for (int i = 1; i < n; ++ i) {
int u_ = read(), v_ = read();
AddEdge(u_, v_), AddEdge(v_, u_);
}
ST::Prepare();
sumsz = n, root = 0, maxsz[root] = kN;
CalcSize(1, 0), CalcSize(root, 0), Dfs(root, 0);
for (int i = 1; i <= n; ++ i) Modify(i, val[i]);
}
//=============================================================
int main() {
Init();
for (int i = 1; i <= m; ++ i) {
int opt = read(), x = read() ^ ans, y = read() ^ ans;
if (!opt) {
printf("%d\n", ans = Query(x, y));
} else {
Modify(x, y - val[x]);
val[x] = y;
}
}
return 0;
}

例题

P4178 Tree

静态点分治,树状数组

给定一棵 n 个节点的树,边有边权,给定参数 k,求树上两点距离小于等于 k 的无序点对数量。
1n4×1040 边权 1030k2×104
1S,512MB。

同上,先钦定一个根节点,仅考虑处理过根节点的路径,并递归处理不过根节点的路径。处理出以根为一端点的链的长度后,问题变为求得 和不大于 k 的数对的数量。显然可以双指针求解,k 较小也可以考虑权值树状数组简单维护。
若使用双指针总时间复杂度 O(nlogn) 级别,树状数组的复杂度多一个 logk

代码中使用了树状数组。

//知识点:点分治,树状数组
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <vector>
#include <queue>
#define LL long long
const int kN = 4e4 + 10;
const int kMaxDis = 2e4 + 10;
//=============================================================
int n, k, e_num, head[kN], v[kN << 1], w[kN << 1], ne[kN << 1];
int ans, root, sumsz, sz[kN], maxsz[kN], dis[kN];
bool vis[kN], exist[kMaxDis];
std::vector <int> tmp;
std::queue <int> tag;
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Chkmax(int &fir, int sec) {
if (sec > fir) fir = sec;
}
void Chkmin(int &fir, int sec) {
if (sec < fir) fir = sec;
}
void Add(int u_, int v_, int w_) {
v[++ e_num] = v_, w[e_num] = w_;
ne[e_num] = head[u_], head[u_] = e_num;
}
namespace Bit {
#define low(x) (x&-x)
int sum[kMaxDis];
void Add(int pos_, int val_) {
pos_ ++;
for (int i = pos_; i < kMaxDis; i += low(i)) sum[i] += val_;
}
int Sum(int pos_) {
pos_ ++; //偏移量
int ret = 0;
for (int i = pos_; i; i -= low(i)) ret += sum[i];
return ret;
}
}
void CalcSize(int u_, int fa_) {
sz[u_] = 1, maxsz[u_] = 0;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
CalcSize(v_, u_);
Chkmax(maxsz[u_], sz[v_]);
sz[u_] += sz[v_];
}
Chkmax(maxsz[u_], sumsz - sz[u_]);
if (maxsz[u_] < maxsz[root]) root = u_;
}
void CalcDis(int u_, int fa_) {
tmp.push_back(dis[u_]);
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i], w_ = w[i];
if (v_ == fa_ || vis[v_]) continue;
dis[v_] = dis[u_] + w_;
CalcDis(v_, u_);
}
}
void Dfs(int u_, int fa_) {
exist[0] = true;
tag.push(0);
Bit::Add(0, 1);
vis[u_] = true;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i], w_ = w[i];
if (v_ == fa_ || vis[v_]) continue;
dis[v_] = w_;
CalcDis(v_, u_);
for (int j = 0, lim = tmp.size(); j < lim; ++ j) {
if (k >= tmp[j]) ans += Bit::Sum(k - tmp[j]);
}
for (int j = 0, lim = tmp.size(); j < lim; ++ j) {
if (tmp[j] <= k) {
tag.push(tmp[j]);
Bit::Add(tmp[j], 1);
}
}
tmp.clear();
}
while (!tag.empty()) {
Bit::Add(tag.front(), -1);
tag.pop();
}
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
sumsz = sz[v_];
root = 0, maxsz[root] = kN;
CalcSize(v_, u_), Clac(root, 0), Dfs(root, 0);
}
}
//=============================================================
int main() {
n = read();
for (int i = 1; i < n; ++ i) {
int u_ = read(), v_ = read(), w_ = read();
Add(u_, v_, w_), Add(v_, u_, w_);
}
k = read();
sumsz = n;
root = 0, maxsz[root] = kN;
CalcSize(1, 0), Clac(root, 0), Dfs(root, 0);
printf("%d\n", ans);
return 0;
}

P2664 树上游戏

静态点分治

给定一棵 n 个节点的树,节点 i 的颜色为 ci
定义 s(i,j) 表示节点 i 到节点 j 简单路径上的颜色种类数,定义 sumi=1jns(i,j),求:sum1sumn
1n,ci105
1S,128MB。

套路地考虑点分治,先钦定一个根节点 root,仅考虑处理过根节点的路径,并递归处理不过根节点的路径。

考虑先处理出以根为端点的链的颜色种类数,并更新 sumroot。从路径的角度统计贡献并不容易,考虑从颜色的角度统计。从 root 开始 dfs,并维护一个 cnt 数组储存当前节点到根路径上各颜色出现的次数。若 dfs 到节点 u 时有 cntcu=1,说明以 u 的子树节点为一端点,根为一端点的路径上都会出现颜色 cusumroot 应增加 sizeu

再考虑对非根节点的贡献。考虑在上一步中预处理一些值:所有链的贡献量 (即sumroot 的增量) S,各颜色有贡献的路径数量 f。若在上一步中 dfs 到节点 u 时有 cntcu=1,则令 SS+sizeufcufcu+sizeu

之后考虑 dfs 处理非根节点的答案。对于当前 dfs 到的节点 u,找到 root 的一个儿子 son,满足 uson 的子树中。则过根节点且过节点 u 的路径显然有 sizerootsizeson 条。将这样的路径 uv 拆成链 rootu 与链 rootv 分别考虑它们对 sumu 的贡献。

对于链 rootu,它在每一条有贡献的路径中都被包含。考虑在 dfs 枚举 u 时通过维护当前节点到根路径上各颜色出现的次数,处理出其中的颜色种类数,乘上路径数量 sizerootsizeson 即为其贡献。

对于所有链 rootv,先考虑从所有链的贡献 S 与各颜色的贡献 f 中减去子树 son 的贡献。具体地,考虑枚举子树 son 的节点 x。若某颜色在 rootx 的路径上是第一次出现,则令 SSsizeufcufcusizeu。处理完成后 Sf 仅包含其他子树的信息。之后进行 dfs 枚举节点 u 并更新 sumu,若某颜色在路径 rootu 上出现,说明该颜色的贡献已经在上一步统计 rootu 时已经统计过,的则令 SSfcu。此时所有链 rootvsumu 的贡献即为 S。统计两种链的贡献可以在一个 dfs 中完成。

注意统计完成后需要还原子树 son 的贡献。

上述过程均可在 O(n) 的时间复杂度内完成,总时间复杂度是常数巨大的 O(nlogn)

//知识点:点分治
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e5 + 10;
//=============================================================
int n, m, e_num, col[kN], head[kN], v[kN << 1], ne[kN << 1];
int root, sumsz, sz[kN], maxsz[kN];
LL deltasz, num, sum, cnt[kN], colsz[kN], ans[kN];
bool vis[kN];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Chkmax(int &fir, int sec) {
if (sec > fir) fir = sec;
}
void Chkmin(int &fir, int sec) {
if (sec < fir) fir = sec;
}
void Add(int u_, int v_) {
v[++ e_num] = v_, ne[e_num] = head[u_], head[u_] = e_num;
}
void CalcSize(int u_, int fa_) {
sz[u_] = 1, maxsz[u_] = 0;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
CalcSize(v_, u_);
Chkmax(maxsz[u_], sz[v_]);
sz[u_] += sz[v_];
}
Chkmax(maxsz[u_], sumsz - sz[u_]);
if (maxsz[u_] < maxsz[root]) root = u_;
}
void Dfs1(int u_, int fa_) { //求得所有链的贡献
sz[u_] = 1;
cnt[col[u_]] ++;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
Dfs1(v_, u_);
sz[u_] += sz[v_];
}
if (cnt[col[u_]] == 1) {
sum += sz[u_];
colsz[col[u_]] += sz[u_];
}
-- cnt[col[u_]];
}
void Modify(int u_, int fa_, int val_) { //更改子树的贡献
cnt[col[u_]] ++;
if (cnt[col[u_]] == 1) { //
sum += val_ * sz[u_];
colsz[col[u_]] += val_ * sz[u_];
}
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
Modify(v_, u_, val_);
}
cnt[col[u_]] --;
}
void Dfs2(int u_, int fa_) { //统计链 root -> u 与 链 root -> v 的贡献
cnt[col[u_]] ++;
if (cnt[col[u_]] == 1) { //颜色 col[u] 在链 root -> u 中出现过,需要去除该颜色在其他链 root -> v 中的贡献
sum -= colsz[col[u_]];
++ num; //num 为链 root -> u 上的颜色种类数
}
ans[u_] += sum + num * deltasz; //更新
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
Dfs2(v_, u_);
}
if (cnt[col[u_]] == 1) { //回溯时还原
sum += colsz[col[u_]];
-- num;
}
-- cnt[col[u_]];
}
void Clear(int u_, int fa_) {
cnt[col[u_]] = colsz[col[u_]] = 0;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
Clear(v_, u_);
}
}
void Dfs(int u_, int fa_) {
vis[u_] = true;
Dfs1(u_, fa_);
ans[u_] += sum; //对 root 的贡献
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
cnt[col[u_]] ++; //清除子树 v 的贡献。根 u 为必经点,需要初始化其数量
sum -= sz[v_], colsz[col[u_]] -= sz[v_];
Modify(v_, u_, -1);
cnt[col[u_]] --;
deltasz = sz[u_] - sz[v_]; //路径总数
Dfs2(v_, u_);
cnt[col[u_]] ++; //还原子树 v 的贡献
sum += sz[v_], colsz[col[u_]] += sz[v_];
Modify(v_, u_, 1);
cnt[col[u_]] --;
}
sum = num = 0; //注意清空
Clear(u_, fa_);
for (int i = head[u_]; i; i = ne[i]) { //分治
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
sumsz = sz[v_];
root = 0, maxsz[root] = kN;
CalcSize(v_, u_), Clac(root, 0), Dfs(root, 0);
}
}
//=============================================================
int main() {
n = read();
for (int i = 1; i <= n; ++ i) col[i] = read();
for (int i = 1; i < n; ++ i) {
int u_ = read(), v_ = read();
Add(u_, v_), Add(v_, u_);
}
sumsz = n;
root = 0, maxsz[root] = kN;
CalcSize(1, 0), CalcSize(root, 0), Dfs(root, 0);
for (int i = 1; i <= n; ++ i) printf("%lld\n", ans[i]);
return 0;
}

P4115 QTREE4

点分树,堆

给定一棵 n 个节点的的树,边有边权。初始树上所有节点都是白色。给定 m 次操作:

  1. 反转给定点的颜色。
  2. 询问树上最远的两个白色节点的距离,两点可以重合(距离为 0)。

1n1051m2×105103 边权 103
1S,512MB。

预处理 lca 后即可快速求得两点距离,先考虑如何处理静态问题。可以考虑在点分治的过程中对每个节点都维护一个堆,储存分治块内该点子树节点中的所有白点到重心的距离,使用其中的分属不同分治块的最大值与次大值拼接路径求最大值即可,单次询问时间复杂度 O(nlog2n) 级别。

考虑动态问题,把上述过程放到点分树上自底向下进行。对于每个节点预处理两个堆:t1 维护该点子树内所有白点到父亲的距离(对应点分治中求得各点到重心最大值),t2 维护所有儿子t1 的最大值(对应维护分属不同分治块的最值过程)。特别地,若 u 为白点,则 0t1(u)。即有:

t1(u)={dis(u,v)vsubtree(u),colorvis white}t2(u)={{max(t1(v))vson(u)}{0}color(u) is white{max(t1(v))vson(u)}otherwise

同时处理一个堆 all,储存所有节点路径拼接后的值,即有 all={max(t2(u))+secmax(t2(u))},每次询问的答案即 max(all)。初始时所有节点均为白点,在建立点分树时对每个节点初始化上述三个堆即可,复杂度 O(nlog2n) 级别。
对于修改操作,自指定节点向上跳父亲,在 t1 中添加/删除父亲到指定节点的链,并更新父亲的 t2all 即可,单次修改复杂度 O(log2n) 级别。注意添加/删除指定节点 t2 中的 0。上述过程中的堆需要支持插入、删除、查询最大值、次大值,可以使用 mulitiset 或堆+懒惰删除实现。

特别地,此题的树上可能有负权边且允许白点重合。答案要对 0 取最大值。

总时间复杂度是常数极大的 O((n+m)log2n)
然后是一些卡常技巧:树剖求 lca 常数较小,实际表现比 RMQ 更优;multiset 常数过大不如堆 + 懒惰删除。但还是被色批 OJ 卡常了呜呜,下面的代码只能在 Luogu 上过。

//知识点:点分树
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <queue>
#define LL long long
const int kN = 1e5 + 10;
const int kM = kN << 1;
const int kInf = 1e9 + 2077;
//=============================================================
int n, m, e_num, head[kN], v[kM], w[kM], ne[kM], dep[kN];
int cnt, root, sumsz, sz[kN], maxsz[kN], newfa[kN];
bool vis[kN], val[kN];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Chkmax(int &fir_, int sec_) {
if (sec_ > fir_) fir_ = sec_;
}
void Chkmin(int &fir_, int sec_) {
if (sec_ < fir_) fir_ = sec_;
}
void AddEdge(int u_, int v_, int w_) {
v[++ e_num] = v_, w[e_num] = w_;
ne[e_num] = head[u_];
head[u_] = e_num;
}
namespace Cut {
int fa[kN], son[kN], dep[kN], dis[kN], size[kN], top[kN];
void Dfs1(int u_, int fa_) {
fa[u_] = fa_;
size[u_] = 1;
dep[u_] = dep[fa_] + 1;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i], w_ = w[i];
if (v_ == fa_) continue ;
dis[v_] = dis[u_] + w_;
Dfs1(v_, u_);
if (size[v_] > size[son[u_]]) son[u_] = v_;
size[u_] += size[v_];
}
}
void Dfs2(int u_, int top_) {
top[u_] = top_;
if (son[u_]) Dfs2(son[u_], top_);
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa[u_] or v_ == son[u_]) continue ;
Dfs2(v_, v_);
}
}
int Lca(int u_, int v_) {
for (; top[u_] != top[v_]; u_ = fa[top[u_]]) {
if (dep[top[u_]] < dep[top[v_]]) {
std::swap(u_, v_);
}
}
return dep[u_] < dep[v_] ? u_ : v_;
}
int Dis(int u_, int v_) {
return dis[u_] + dis[v_] - 2 * dis[Lca(u_, v_)];
}
void Prepare() {
Dfs1(1, 0), Dfs2(1, 1);
}
}
struct Heap { //懒惰删除堆
std::priority_queue <int> heap, delt;
void Insert(int val_) {
heap.push(val_);
}
void Erase(int val_) {
delt.push(val_);
}
int Size() {
return heap.size() - delt.size();
}
void Update() {
while (delt.size() && heap.top() == delt.top()) {
heap.pop();
delt.pop();
}
}
int Top() {
Update();
return Size() ? heap.top() : -kInf;
}
int Sectop() {
int t1 = Top(), t2;
Update(), heap.pop();
t2 = Top();
Insert(t1);
return t2;
}
int Get() {
if (Size() >= 2) return Top() + Sectop();
if (Size() == 1) return std::max(Top(), 0);
return -kInf;
}
} all, dis[kN], disfa[kN];
void AddAll(int pos_) {
if (dis[pos_].Size() >= 2) all.Insert(dis[pos_].Get());
}
void DeleteAll(int pos_) {
if (dis[pos_].Size() >= 2) all.Erase(dis[pos_].Get());
}
void CalcSize(int u_, int fa_) { //找重心
sz[u_] = 1, maxsz[u_] = 0;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
CalcSize(v_, u_);
Chkmax(maxsz[u_], sz[v_]);
sz[u_] += sz[v_];
}
Chkmax(maxsz[u_], sumsz - sz[u_]);
if (maxsz[u_] < maxsz[root]) root = u_;
}
void CalcDis(int u_, int fa_, int dis_, int pos_) {
disfa[pos_].Insert(dis_); //预处理
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i], w_ = w[i];
if (v_ == fa_ || vis[v_]) continue;
CalcDis(v_, u_, dis_ + w_, pos_);
}
}
void Dfs(int u_, int fa_) { //建立点分树
vis[u_] = true;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i], w_ = w[i];
if (v_ == fa_ || vis[v_]) continue;
sumsz = sz[v_], root = 0, maxsz[root] = kN;
CalcSize(v_, u_);
CalcDis(v_, u_, w_, root);
newfa[root] = u_;
dis[u_].Insert(disfa[root].Top()); //预处理
CalcSize(root, 0), Dfs(root, 0);
}
dis[u_].Insert(0);
AddAll(u_);
}
void Add(int pos_) { //添加白点
++ cnt;
DeleteAll(pos_); //以当前节点为一端点的情况
dis[pos_].Insert(0);
AddAll(pos_);
for (int now_ = pos_; newfa[now_]; now_ = newfa[now_]) {
int f = newfa[now_], d = Cut::Dis(pos_, f);
DeleteAll(f); //削除更新前的贡献
if (disfa[now_].Size()) dis[f].Erase(disfa[now_].Top());
disfa[now_].Insert(d); //插入父亲到指定节点的链
if (disfa[now_].Size()) dis[f].Insert(disfa[now_].Top());
AddAll(f); //更新贡献
}
}
void Delete(int pos_) { //删除白点
-- cnt;
DeleteAll(pos_);
dis[pos_].Erase(0);
AddAll(pos_);
for (int now_ = pos_; newfa[now_]; now_ = newfa[now_]) {
int f = newfa[now_], d = Cut::Dis(pos_, f);
DeleteAll(f); //削除更新前的贡献
if (disfa[now_].Size()) dis[f].Erase(disfa[now_].Top());
disfa[now_].Erase(d); //插入父亲到指定节点的链
if (disfa[now_].Size()) dis[f].Insert(disfa[now_].Top());
AddAll(f); //更新贡献
}
}
void Modify(int pos_) { //单点修改,更新点分树上该点到根路径上各点的 子树信息
val[pos_] ? Add(pos_) : Delete(pos_);
val[pos_] ^= 1;
}
int Query() {
return std::max(all.Top(), 0);
}
//=============================================================
int main() {
cnt = n = read();
for (int i = 1; i < n; ++ i) {
int u_ = read(), v_ = read(), w_ = read();
AddEdge(u_, v_, w_), AddEdge(v_, u_, w_);
}
Cut::Prepare();
sumsz = n, root = 0, maxsz[root] = kN;
CalcSize(1, 0), CalcSize(root, 0), Dfs(root, 0);
m = read();
for (int i = 1; i <= m; ++ i) {
char opt[5]; scanf("%s", opt + 1);
if (opt[1] == 'C') {
Modify(read());
} else {
if (!cnt) {
printf("They have disappeared.\n");
} else {
printf("%d\n", cnt == 1 ? 0 : Query());
}
}
}
return 0;
}

「ZJOI2015」幻想乡战略游戏

结论,点分树

给定一棵 n 个节点的树,点有点权,边有边权,初始时各点点权为 0。定义树上一点 u 作为决策点的代价为:

vTdis(u,v)×valv

其中 dis(u,v) 表示树上两点距离,val 表示点权。
定义作为决策点代价最小的点为带权重心。给定 m 次操作,每次将给定节点的点权增加给定值 w,并查询当前树上带权重心的代价。
所有节点的度数不超过 20
1n,m105|w|1031 边权 103
6S,256MB。

先随意钦定一个点 u 为树根。若带权重心在 u 的儿子 v 的子树中,则显然 v 作为决策点的代价小于 u 作为决策点的代价。记 sx 表示此时树上 x 的子树中各点的权值之和,则决策点从 u 移动到 v 代价的增量为:

dis(u,v)×((susv)sv)

由于 dis(u,v)>0,由上式可知当 sv>2×suv 作为决策点优于 u 作为决策点。且显然满足该式的 v 至多只有一个点分治总结 - Sshwy's Notes 中给出了一个对该结论的证明。

注意到节点度数不超过 20,我们可以进行一些基于枚举儿子的算法。
根据上述性质可以得到一个基于换根的做法:先随机一个点作为根并求得其作为决策点的代价,之后枚举它的儿子 v,检查带权重心是否位于儿子 v 中,若位于儿子中则换根,更新 s 并得到 v 作为决策点的代价。依次进行直到不存在更优的儿子即可。暴力实现复杂度依赖于树高,是个极其不稳定的算法,且无法高效率处理修改操作。


上述带权重心问题,实质上是一类路径统计问题。为减小树高并处理修改,可以考虑将上述过程放到点分树上进行。

从根开始向下查询。对于每个分治块,先计算重心作为决策点的代价,再枚举重心的儿子 v 并计算它们分别作为决策点的代价。若儿子 v 作为决策点时优于重心,则钦定 v 所在分治块的重心作为新的决策点。不断递归进行,直至不存在更优的儿子。

考虑在上述过程中如何快速维护某点作为决策点的代价。
根据点分树上 dis(u,v)=dis(u,lca(u,v))+dis(v,lca(u,v)) 的性质,考虑维护每个点作为决策点时点分树子树内各点的贡献,并通过暴力枚举 lca 统计不在其子树内的节点与指定节点构成的路径的贡献。记 su 表示点分树中 u 的子树内各点的点权之和,fu 表示点分树中 u 的子树内各点对决策点 u 的贡献之和,gu 表示点分树中 u 的子树内各点对决策点 u 的父亲的贡献之和,即有:

su=vsubtree(u)valvfu=vsubtree(u)valv×dis(u,v)gu=vsubtree(u)valv×dis(fau,v)

求指定点 x 作为决策点的代价时,在点分树上模拟点分治的过程,先统计子树贡献 fx,再暴力跳父亲统计不在子树内节点的贡献(即过父亲的路径的贡献),每次累计不在指定点所在分治块内的点到达父亲的路径的贡献 ffaugu,再加上指定点到达父亲的路径的贡献 dis(fau,x)。单次查询复杂度 O(logn) 级别,详见代码。

又点分树的高为 logn 级别,且每个节点度数不超过 20。使用 RMQ O(1) 求得两点间距离的前提下,总复杂度为 O(mlog2n) 再乘上一个 20 的大常数(
但是一般跑不满,实际表现比较出色。

//知识点:点分树
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <vector>
#define LL long long
const int kN = 1e5 + 10;
const int kM = kN << 1;
//=============================================================
int n, m, e_num, head[kN], v[kM], w[kM], ne[kM], newroot[kM];
int allroot, root, sumsz, sz[kN], maxsz[kN], newfa[kN];
LL sum[kN], sumval[kN], sumfaval[kN];
//sum[u]:u 点分树子树内各点点权之和
//sumval[u]:u 点分树子树内各点到达 u 的代价 dis(u,v)*val[v] 之和
//sumfaval[u]:u 点分树子树内各点到达 u 点分树上的父亲 fa[u] 的代价之和
bool vis[kN];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Chkmax(int &fir, int sec) {
if (sec > fir) fir = sec;
}
void Chkmin(int &fir, int sec) {
if (sec < fir) fir = sec;
}
void Add(int u_, int v_, int w_) {
v[++ e_num] = v_, w[e_num] = w_;
ne[e_num] = head[u_], head[u_] = e_num;
}
namespace ST { //用于求树上两点距离
int num, Log2[kN << 1], f[kN << 1][22], fir[kN], dep[kN];
LL dis[kN];
void Dfs(int u_, int fa_) {
dep[u_] = dep[fa_] + 1;
fir[u_] = ++ num;
f[num][0] = u_;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i], w_ = w[i];
if (v_ == fa_) continue ;
dis[v_] = dis[u_] + w_;
Dfs(v_, u_);
f[++ num][0] = u_;
}
}
void Prepare() {
Dfs(1, 0);
Log2[1] = 0;
for (int i = 2; i <= num; ++ i) Log2[i] = Log2[i >> 1] + 1;
for (int i = 1; i <= 21; ++ i) {
for (int j = 1; j + (1 << i) - 1 <= num; ++ j) {
if (dep[f[j][i - 1]] < dep[f[j + (1 << (i - 1))][i - 1]]) {
f[j][i] = f[j][i - 1];
} else {
f[j][i] = f[j + (1 << (i - 1))][i - 1];
}
}
}
}
int Lca(int u_, int v_) {
int l = fir[u_], r = fir[v_];
if (l > r) std::swap(l, r);
int lth = Log2[r - l + 1];
if (dep[f[l][lth]] < dep[f[r - (1 << lth) + 1][lth]]) return f[l][lth];
return f[r - (1 << lth) + 1][lth];
}
int Dis(int u_, int v_) {
return dis[u_] + dis[v_] - 2 * dis[Lca(u_, v_)];
}
}
void CalcSize(int u_, int fa_) {
sz[u_] = 1, maxsz[u_] = 0;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
CalcSize(v_, u_);
Chkmax(maxsz[u_], sz[v_]);
sz[u_] += sz[v_];
}
Chkmax(maxsz[u_], sumsz - sz[u_]);
if (maxsz[u_] < maxsz[root]) root = u_;
}
void Dfs(int u_, int fa_) {
vis[u_] = true;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_ || vis[v_]) continue;
sumsz = sz[v_], root = 0, maxsz[root] = kN;
CalcSize(v_, u_);
newroot[i] = root; //处理 v 所在分治块的重心
newfa[root] = u_;
CalcSize(root, 0), Dfs(root, 0);
}
}
void Modify(int pos_, int val_) { //单点修改操作
sum[pos_] += val_; //按照定义跳父亲更新
for (int u_ = pos_; newfa[u_]; u_ = newfa[u_]) {
int f = newfa[u_], dis = ST::Dis(pos_, newfa[u_]);
sumval[f] += 1ll * dis * val_;
sumfaval[u_] += 1ll * dis * val_;
sum[f] += val_;
}
}
LL Calc(int pos_) { //模拟点分治过程,计算以 pos_ 为带权重心时的代价之和
LL ret = sumval[pos_]; //以 pos_ 为重心的分治块的贡献
for (int u_ = pos_; newfa[u_]; u_ = newfa[u_]) {
int f = newfa[u_]; //统计当前分治块内过 f 的路径的贡献
ret += sumval[f] - sumfaval[u_] + //所有节点到 f 的代价,并去除不合法的在下一层的节点的代价
ST::Dis(f, pos_) * (sum[f] - sum[u_]); //从 f 到 pos_ 的路径代价
}
return ret;
}
LL Query(int u_) { //自根向下不断寻找更优解
LL ret = Calc(u_);
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i], newroot_ = newroot[i];
if (Calc(v_) < ret) return Query(newroot_);
}
return ret;
}
void Init() {
n = read(), m = read();
for (int i = 1; i < n; ++ i) {
int u_ = read(), v_ = read(), w_ = read();
Add(u_, v_, w_), Add(v_, u_, w_);
}
ST::Prepare();
sumsz = n, root = 0; maxsz[root] = kN;
CalcSize(1, 0), CalcSize(root, 0);
allroot = root;
Dfs(root, 0);
}
//=============================================================
int main() {
Init();
for (int i = 1; i <= m; ++ i) {
int pos_ = read(), val_ = read();
Modify(pos_, val_);
printf("%lld\n", Query(allroot));
}
return 0;
}

写在最后

鸣谢:

树分治 - OI Wiki
题解 P6329 【【模板】点分树 _ 震波】 - Irelia
点分治总结 - Sshwy's Notes

posted @   Luckyblock  阅读(243)  评论(3编辑  收藏  举报
编辑推荐:
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示