图论:LCA、树的直径、树的重心、二分图与 Tarjan 缩点

图论:LCA、树的直径、树的重心、二分图与 Tarjan 缩点


目录


一、最近公共祖先(LCA)

给定一棵有根树,若节点 \(z\) 既是节点 \(x\) 的祖先,也是节点 \(y\) 的祖先,则称 \(z\) 是 \(x, y\) 的公共祖先。在 \(x, y\) 的所有公共祖先中,深度最大的一个称为 \(x, y\) 的最近公共祖先 ,记为 \(\text{LCA}(x, y)\)。

\(\text{LCA}(x, y)\) 是 \(x\) 到根的路径与 \(y\) 到根的路径的交会点,它也是 \(x\) 与 \(y\) 之间路径上深度最小的节点。

1. 树上倍增法

树上倍增法是 LCA 最经典、最实用的算法,也是竞赛中最常用的写法。

核心思想

设 \(dpx, k\) 表示 \(x\) 的 \(2^k\) 辈祖先,即从 \(x\) 向根节点走 \(2^k\) 步到达的节点。

  • \(dpx, 0\) 就是 \(x\) 的父节点(\(2^0 = 1\) 辈祖先)
  • 递推关系:\(dpx, k = dp\\ dp\[x, k-1\ ,\ k-1\ ]\)
    • 即:\(x\) 的 \(2^k\) 辈祖先 = \(x\) 的 \(2^{k-1}\) 辈祖先的 \(2^{k-1}\) 辈祖先

预处理时,对树做 DFS(或 BFS),在遍历过程中计算每个节点的 \(dp\) 数组。预处理时间复杂度 \(O(n \log n)\)。

查询时,用二进制拆分的思想,把两个点"跳"到同一深度,再同时往上跳,每次跳 \(2^k\) 步。每次查询时间复杂度 \(O(\log n)\)。

算法步骤

查询 \(\text{LCA}(x, y)\):

  1. 设 \(dx\) 表示 \(x\) 的深度。不妨设 \(dx \ge dy\),否则交换 \(x, y\)。
  2. 把 \(x\) 向上调整到与 \(y\) 同一深度(从大到小枚举 \(k\),能跳就跳)。
  3. 若此时 \(x = y\),说明已经找到了 LCA,返回 \(x\)。
  4. 否则,把 \(x, y\) 同时向上调整,保持深度一致且二者不相会。
  5. 最终 \(x, y\) 只差一步就相会,它们的父节点 \(dpx, 0\) 就是 LCA。

代码实现

cpp 复制代码
const int N = 500010;

int head[N], ver[N * 2], nxt[N * 2], tot;
int d[N];       // 节点深度
int dp[N][23];  // dp[x][k] 表示 x 的 2^k 辈祖先
int n, m, root;

// 加边(无向图)
void add(int x, int y) {
    ver[++tot] = y;
    nxt[tot] = head[x];
    head[x] = tot;
}

// DFS 预处理
void dfs(int x, int fa) {
    d[x] = d[fa] + 1;
    dp[x][0] = fa;

    // 预处理倍增数组
    for (int i = 1; (1 << i) <= d[x]; i++)
        dp[x][i] = dp[dp[x][i - 1]][i - 1];

    // 遍历子节点
    for (int i = head[x]; i; i = nxt[i])
        if (ver[i] != fa)
            dfs(ver[i], x);
}

// 查询 LCA
int LCA(int x, int y) {
    // 保证 x 比 y 深
    if (d[x] < d[y]) swap(x, y);

    // 第一步:把 x 跳到和 y 同一深度
    for (int i = 22; i >= 0; i--)
        if (d[y] + (1 << i) <= d[x])
            x = dp[x][i];

    // 已经在同一个点上
    if (x == y) return x;

    // 第二步:同时往上跳,直到 LCA 的子节点
    for (int i = 22; i >= 0; i--) {
        if (dp[x][i] != dp[y][i]) {
            x = dp[x][i];
            y = dp[y][i];
        }
    }

    return dp[x][0];
}

使用说明

cpp 复制代码
// 主程序中
dfs(root, 0);      // 预处理,root 为根节点编号
int ans = LCA(x, y); // 查询 x 和 y 的最近公共祖先

注意 :数组第二维的大小要根据节点数确定。一般 \(2^{20} \approx 10^6\),所以开 23 足够(下标 0~22)。


2. DFS 序求 LCA(科技)(O(1) 查询)

该技巧最早由 skip2004 提出。DFS 序求 LCA 无论是在时间常数、空间常数还是好写程度上,都优于欧拉序的做法。

概念区分

先明确两个概念:

  • DFS 序 :对一棵树进行深度优先搜索得到的节点序列(每个节点只在进入时记录一次)
  • 时间戳 dfn[x]:每个节点在 DFS 序中的位置

DFS 序的长度为 \(n\)(每个节点恰好出现一次),而欧拉序长度为 \(2n-1\)。这是 DFS 序做法的核心优势之一------空间减半。

核心定理

定理 :设 \(u, v\) 是树上两点,不妨设 \(dfnu < dfnv\):

  • 若 \(u\) 是 \(v\) 的祖先(即 \(v\) 在 \(u\) 的子树内),则 \(\text{LCA}(u, v) = u\)
  • 否则,\(\text{LCA}(u, v)\) = DFS 序上位置在 \(dfn\[u+1, dfnv]\) 中深度最小的节点的父亲

证明与理解

DFS 序的基本性质 :祖先先于后代遍历。即若 \(u\) 是 \(v\) 的祖先,则 \(dfnu < dfnv\)。

现在考虑 \(dfnu < dfnv\) 且 \(u\) 不是 \(v\) 的祖先的情况:

DFS 的顺序是:从根下降到 \(u\),遍历完 \(u\) 的子树后回溯,再下降到 \(v\)。因此,在 DFS 序中 \(dfnu\) 到 \(dfnv\) 之间的节点,包含了:

  1. \(u\) 子树中的部分节点
  2. 回溯过程中经过的一些节点
  3. \(v\) 所在路径上的节点

关键观察:考察 \(u\) 在 LCA 方向上的第一个儿子(即 LCA 的儿子中,子树包含 \(u\) 的那个)。根据 DFS 的顺序,这个儿子一定出现在 \(dfn\[u+1, dfnv]\) 这个区间内。

因此,在 DFS 序的 \(dfn\[u+1, dfnv]\) 区间中,深度最小的节点一定是 LCA 的某个儿子。它的父亲就是 LCA。

对于祖先-后代关系 :如果 \(u\) 是 \(v\) 的祖先,查询 \(dfn\[u, dfnv]\) 区间,\(u\) 本身就是深度最小的。如果我们查询的是 \(dfn\[u+1, dfnv]\),那深度最小的节点是 \(u\) 的某个后代,它的父亲可能不是 LCA。

不过,有一个更统一的处理方式:令查询区间为 \(dfn\[u+1, dfnv]\),当 \(u = v\) 时直接特判返回 \(u\)

  • 情况 1(祖先-后代):\(u\) 是 \(v\) 的祖先,区间 \(dfn\[u+1, dfnv]\) 中深度最小的节点是 \(u\) 的某个儿子,它的父亲是 \(u\),即 LCA ✓
  • 情况 2(一般情况):区间中深度最小的节点的父亲是 LCA ✓

这样就用统一的方式处理了两种情况,唯一需要特判的是 \(u = v\)。

极简实现

基于上述结论,可以写出非常简洁的代码。ST 表中直接存储节点的父亲 ,比较时取 dfn 较小的节点(因为深度越小的节点,在对应位置的 dfn 也越小------其实比较的是节点对应的深度,这里用一个技巧:直接比较 dfn 大小)。

实际上更直接的做法:ST 表存储节点编号,比较时按深度比较取最小值。但下面这个模板更为经典,代码极短:

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

constexpr int N = 5e5 + 5;

int n, m, R, dn;
int dfn[N];          // dfn[x]:节点 x 的时间戳
int mi[19][N];       // ST 表,mi[k][i] 存节点编号
vector<int> e[N];

// 取 dfn 较小的节点
int get(int x, int y) {
    return dfn[x] < dfn[y] ? x : y;
}

// DFS 求 dfn 和 mi[0](ST 表第一层)
void dfs(int id, int f) {
    mi[0][dfn[id] = ++dn] = f;  // 记录时间戳,ST 表底层存父亲
    for (int it : e[id])
        if (it != f) dfs(it, id);
}

// 求 LCA
int lca(int u, int v) {
    if (u == v) return u;
    if ((u = dfn[u]) > (v = dfn[v])) swap(u, v);
    // 查询区间 [u+1, v] 的最小值对应的节点
    int d = __lg(v - u++);
    return get(mi[d][u], mi[d][v - (1 << d) + 1]);
}

int main() {
    scanf("%d %d %d", &n, &m, &R);
    for (int i = 2, u, v; i <= n; i++) {
        scanf("%d %d", &u, &v);
        e[u].push_back(v), e[v].push_back(u);
    }
    dfs(R, 0);
    // 预处理 ST 表
    for (int i = 1; i <= __lg(n); i++)
        for (int j = 1; j + (1 << i) - 1 <= n; j++)
            mi[i][j] = get(mi[i-1][j], mi[i-1][j + (1 << i-1)]);
    // 处理询问
    for (int i = 1, u, v; i <= m; i++) {
        scanf("%d %d", &u, &v);
        printf("%d\n", lca(u, v));
    }
    return 0;
}

注:上面的 get 函数比较的是 dfn 而不是深度。为什么 dfn 小等价于深度小?因为在 ST 表的每一层中,我们存储的是"该区间内对应答案的节点",而根据之前的结论,答案是深度最小的节点。用 dfn 比较是一个等价的技巧------具体来说,ST 表中维护的是区间内「作为答案更优」的节点,而比较 dfn 恰好能选出正确的那个。如果你想更直观,也可以用 dep[x] < dep[y] 来比较,效果是一样的。

复杂度分析

操作 复杂度
DFS 求 dfn \(O(n)\)
建立 ST 表 \(O(n \log n)\)
单次查询 \(O(1)\)

各种 LCA 算法对比

算法 预处理 查询 空间 好写程度 备注
倍增法 \(O(n \log n)\) \(O(\log n)\) \(O(n \log n)\) ★★★★☆ 最常用,代码短
DFS 序 + ST 表 \(O(n \log n)\) \(O(1)\) \(O(n \log n)\) ★★★★★ 推荐,常数小、空间优
欧拉序 + ST 表 \(O(n \log n)\) \(O(1)\) \(O(2n \log n)\) ★★★☆☆ 空间大、预处理慢
树剖 \(O(n)\) \(O(\log n)\) \(O(n)\) ★★☆☆☆ 需要树剖,代码长

为什么 DFS 序做法更优

  1. 最大优点:\(O(1)\) 处理每次查询
  2. 空间减半 :DFS 序长度为 \(n\),欧拉序为 \(2n-1\),ST 表空间也相应减半
  3. 预处理更快 :DFS 是线性的,而且 ST 表只有 \(n\) 个元素,预处理时间常数减半
  4. 更好写 :代码非常简洁,不需要额外记录 seqpos 等数组
  5. 无需担心空间 :不需要开两倍空间,对于 \(n = 5 \times 10^5\) 也毫无压力

总结:DFS 序求 LCA 是一个被低估的技巧。它代码短、常数小、空间优,在绝大多数情况下都优于欧拉序的做法。推荐掌握。


二、树的直径

给定一棵树,树中两点之间的距离定义为连接两点的路径上的边权之和。树中最远的两个节点之间的距离被称为树的直径 ,连接这两个点的路径被称为树的最长链

1. 两次 DFS 法

算法步骤

  1. 从任意节点 \(p\) 出发,通过 DFS 找到距离 \(p\) 最远的点 \(u\)。
  2. 从点 \(u\) 出发,再次通过 DFS 找到距离 \(u\) 最远的点 \(v\)。
  3. \(u\) 到 \(v\) 的路径即为树的直径,长度为 \(u\) 到 \(v\) 的距离。

正确性证明(反证法)

假设树的直径为 \(s \to t\),而从 \(p\) 出发找到的最远点是 \(u\)。

  • 若 \(p\) 在直径 \(s \to t\) 上,则 \(u\) 一定是 \(s\) 或 \(t\) 之一,否则与直径定义矛盾。
  • 若 \(p\) 不在直径上,设从 \(p\) 到直径的路径交直径于 \(q\),则 \(u\) 一定是 \(s\) 或 \(t\) 中离 \(q\) 更远的那个点,否则可以构造出比直径更长的路径,矛盾。

因此,从 \(u\) 出发找到的最远点 \(v\) 就是直径的另一个端点。

优缺点

  • 优点:思路简单、代码短,且容易求出直径上的具体节点。
  • 缺点:只能处理边权非负的树(实际上绝大多数题都是非负权)。

2. 树形 DP 法

核心思想

设 \(dx\) 表示从节点 \(x\) 出发,往以 \(x\) 为根的子树中走,能够到达的最远节点的距离。

对于节点 \(x\),枚举它的所有子节点 \(y\):

  • 在进入子树 \(y\) 之前,\(dx\) 保存的是 \(x\) 到已经遍历过的子树中的最远距离
  • 此时可以用 \(dx + \text{边权}(x,y) + dy\) 来更新直径(经过 \(x\) 的最长链)
  • 然后用 \(dy + \text{边权}(x,y)\) 更新 \(dx\)
cpp 复制代码
const int N = 100010;

int head[N], ver[N * 2], edge[N * 2], nxt[N * 2], tot;
int d[N];   // d[x] 表示从 x 出发往子树走的最远距离
int ans;    // 直径长度

void dp(int x, int fa) {
    for (int i = head[x]; i; i = nxt[i]) {
        int y = ver[i];
        if (y == fa) continue;
        dp(y, x);
        // 用"已遍历子树的最远距离 + 当前子树的最远距离 + 边权"更新答案
        ans = max(ans, d[x] + d[y] + edge[i]);
        // 更新 d[x]
        d[x] = max(d[x], d[y] + edge[i]);
    }
}

优缺点

  • 优点:可以处理带负权边的树,扩展性强(很多树形 DP 都基于这个框架)。
  • 缺点:不太方便记录直径上的具体节点。

三、树的重心

定义

对于一棵树的某个节点,如果把这个节点删除后,树会分成若干个连通块。若最大连通块的节点数最小,则称这个节点为树的重心

换句话说,设 \(sizex\) 表示以 \(x\) 为根的子树大小。删除节点 \(x\) 后,产生的若干个连通块的大小为:

  • 每个子树的大小:\(sizey_1, sizey_2, \dots, sizey_k\)
  • 上方部分:\(n - sizex\)

取这些值的最大值,这个最大值最小的节点就是重心。

性质

树的重心有很多优美的性质,是树形 DP 和点分治的重要基础:

性质 1:树的重心如果不唯一,则至多有两个,且这两个重心相邻。

性质 2:以树的重心为根时,所有子树的大小都不超过整棵树大小的一半。

这是重心最重要的性质,也是点分治的理论基础------每次选重心作为分治中心,可以保证递归深度不超过 \(O(\log n)\)。

性质 3:树中所有点到某个点的距离和中,到重心的距离和是最小的。

性质 4:把两棵树通过一条边相连,新的树的重心在原来两棵树重心的连线上。

性质 5:一棵树添加或者删除一个叶子,树的重心最多只移动一条边的位置。

求法

两次 DFS 即可求出。

cpp 复制代码
int siz[N], maxS[N], root, maxPart;

// 第一次 DFS:计算子树大小
void dfs1(int u, int f) {
    siz[u] = 1;
    for (int i = head[u]; i; i = nxt[i]) {
        int v = to[i];
        if (v == f || vis[v]) continue;
        dfs1(v, u);
        siz[u] += siz[v];
    }
}

// 第二次 DFS:计算最大子树大小并找重心
void dfs2(int u, int f, int total) {
    maxS[u] = total - siz[u];          // 向上的子树
    for (int i = head[u]; i; i = nxt[i]) {
        int v = to[i];
        if (v == f || vis[v]) continue;
        maxS[u] = max(maxS[u], siz[v]);
        dfs2(v, u, total);
    }
    if (maxS[u] < maxPart) {
        maxPart = maxS[u];
        root = u;
    }
}

// 使用示例(以 1 为根,整棵树大小 n)
maxPart = INF;
dfs1(1, 0);
dfs2(1, 0, n);
// root 即为重心

四、二分图

定义与判定

如果一张无向图的 \(N\) 个节点可以分成 \(A, B\) 两个非空集合,其中 \(A \cap B = \emptyset\),并且在同一集合内的点之间都没有边相连,则称这张无向图为二分图

定理:一张无向图是二分图,当且仅当图中不存在奇环(长度为奇数的环)。

染色法判定

用 DFS 或 BFS 对图进行黑白染色:

  • 从任意未染色的点开始,染成黑色
  • 遍历它的邻居,染成白色
  • 再遍历邻居的邻居,染成黑色
  • 依此类推,如果发现相邻两点颜色相同,则存在奇环,不是二分图

二分图匹配

给定二分图 \(G = (V, E)\),\(A, B\) 是它的两个部。我们称一组两两没有公共端点的边集 \(M \subseteq E\) 为二分图的一组匹配 ,边数最多的匹配被称为最大匹配

匈牙利算法(增广路算法)

增广路 :若 \(P\) 是图中一条连通两个未匹配点的路径,并且属于 \(M\) 的边和不属于 \(M\) 的边在 \(P\) 上交替出现,则称 \(P\) 为一条增广路。

沿着增广路把匹配状态取反(匹配边变非匹配,非匹配边变匹配),匹配数就会加一。

定理:一个匹配是最大匹配,当且仅当图中不存在增广路。

匈牙利算法就是不断寻找增广路,直到找不到为止。


五、Tarjan 求强连通分量与缩点

基本概念

给定一张有向图,若对于图中任意两个节点 \(x, y\),既存在从 \(x\) 到 \(y\) 的路径,也存在从 \(y\) 到 \(x\) 的路径,则称该有向图是强连通图

有向图的极大强连通子图 被称为强连通分量 (Strongly Connected Component,SCC)。

缩点 :把每个强连通分量缩成一个点,原图就转化为一个有向无环图(DAG)。缩点后可以在 DAG 上做拓扑排序、DP 等操作。

Tarjan 算法原理

Tarjan 算法基于深度优先遍历,在 \(O(n + m)\) 的时间内求出有向图的所有强连通分量。

维护两个关键数组:

  • dfn[x]:节点 \(x\) 的时间戳(第一次被访问的顺序)
  • low[x]:节点 \(x\) 能够追溯到的最早的栈中节点的时间戳

并用一个栈维护当前搜索路径上的节点。

核心步骤

  1. 访问节点 \(x\) 时,记录 dfn[x] = low[x] = ++timestamp,并将 \(x\) 入栈。
  2. 遍历 \(x\) 的每条出边 \((x, y)\):
    • 若 \(y\) 未被访问过:递归访问 \(y\),回溯时用 low[y] 更新 low[x]
    • 若 \(y\) 已被访问过且在栈中:用 dfn[y] 更新 low[x]
  3. 遍历完所有出边后,若 dfn[x] == low[x],说明 \(x\) 是某个强连通分量的根。此时从栈顶到 \(x\) 的所有节点构成一个强连通分量。

代码实现

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100010;

vector<int> e[N];       // 原图邻接表
vector<int> ne[N];      // 缩点后的新图邻接表

int a[N];               // 节点权值
int dfn[N], low[N];     // Tarjan:dfn 时间戳,low 追溯值
int stk[N];             // 栈,存储当前搜索路径上的节点
int S[N];               // 节点所属强连通分量编号
int sum[N];             // 每个强连通分量的权值和

bool ins[N];            // 节点是否在栈中

int T, C, top;          // T:时间戳计数器, C:SCC 数量, top:栈顶指针

// Tarjan 求强连通分量
void dfs(int u) {
    dfn[u] = low[u] = ++T;
    stk[++top] = u;
    ins[u] = true;

    for (auto v : e[u]) {
        if (dfn[v] == 0) {
            dfs(v);
            low[u] = min(low[u], low[v]);
        }
        else if (ins[v])
            low[u] = min(low[u], dfn[v]);
    }

    // 如果当前节点 u 是某个强连通分量的根节点
    if (dfn[u] == low[u]) {
        ++C;

        // 弹出栈中元素,直到遇到 u
        while (stk[top] != u) {
            int p = stk[top--];
            ins[p] = false;
            S[p] = C;
            sum[C] += a[p];
        }

        int p = stk[top--];
        ins[p] = false;
        S[p] = C;
        sum[C] += a[p];
    }
}

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

    int n, m;
    cin >> n >> m;

    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

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

    // Tarjan 求所有 SCC
    for (int i = 1; i <= n; i++)
        if (!dfn[i]) dfs(i);

    // 缩点:建新图
    set<pair<int, int>> edges;  // 去重,避免重边
    for (int u = 1; u <= n; u++) {
        for (int v : e[u]) {
            int x = S[u], y = S[v];
            // 如果 u 和 v 不在同一个 SCC 中,且这条边尚未添加
            if (x != y && edges.find({x, y}) == edges.end()) {
                edges.insert({x, y});
                ne[x].push_back(y);    // 在缩点后的图中添加 x -> y
            }
        }
    }

    // 此时 ne[1..C] 就是缩点后的 DAG,可以在上面做拓扑排序、DP 等
    // ...

    return 0;
}

缩点与建图

缩点后的图是一个 DAG,常见的后续操作有:

  1. 拓扑排序:按拓扑序 DP。
  2. 最长路 / 最短路:DAG 上的最长路可以拓扑序 DP 求。
  3. 统计入度出度:比如求至少加多少条边使图强连通 = max(入度为0的点数, 出度为0的点数)。

技巧 :用 set<pair<int,int>> 去重是一种简单的写法,但复杂度略高(\(O(m \log m)\))。如果对复杂度要求高,可以先存所有边再排序去重,或者用 unordered_set 等方法。


六、图论小 trick:时光倒流法(删边改加边)

这是一个非常经典的离线处理技巧,常用于并查集、连通性、最小生成树等问题中。

核心思想

并查集(DSU)有一个特点:加边(合并)很容易,删边(分裂)很难做

所以当题目要求你"动态删除边,并查询连通性/连通块大小"时,正着做非常困难。这时可以考虑反过来做

  1. 先读入所有操作(离线)
  2. 从最终状态开始:把最后保留下来的边全部加进并查集
  3. 倒着处理询问:把"删除边"变成"加边",把"加边"变成"删边"
  4. 最后把答案反过来输出

就像时光倒流一样------删除操作变成了添加操作,而添加操作正是并查集擅长的。

适用场景

  • 动态删边 + 查询两点连通性
  • 动态删边 + 查询连通块大小
  • 动态删边 + 最小生成树维护
  • 一切"正着做困难,倒着做简单"的问题

例题:P10238 yLCPC2024 F. PANDORA PARADOXXX

经验总结

  1. 看到"删边"想"时光倒流":只要题目可以离线处理,优先考虑倒着做
  2. 先算最终状态:想清楚最后剩下什么,从最终状态出发
  3. 答案倒过来存:因为是倒着处理的,答案要倒过来输出
  4. 不只是并查集:这个思想很通用,凡是"删除难、添加易"的问题都可以考虑

这个技巧的本质是离线 + 逆序处理,是竞赛中非常高频的思维方式。不止图论,在数据结构、DP 中也经常用到。


部分由 AI 润色

相关推荐
302wanger2 小时前
干与湿:AI 拿走脑力之后,人剩下什么
算法
计算机编程-吉哥3 小时前
脑肿瘤MRI智能识别系统:基于深度学习的像素级脑肿瘤语义分割平台【计算机毕业设计选题推荐】
人工智能·python·深度学习·算法·毕业设计·课程设计·大数据毕业设计选题推荐
用户204937554953 小时前
端侧语音部署踩坑:模型能跑不等于终端真的能用
后端·算法
shehuiyuelaiyuehao3 小时前
算法39,位运算,消失的两个数字
java·数据结构·算法
ZiLing3 小时前
2026 ROS 2 Lyrical 踩坑实录(一):编译与依赖——rosdep、现代 CMake 与 CMake 4.x
算法
Murphy_lx3 小时前
1124. 表现良好的最长时间段
c++·算法
aramae4 小时前
模拟实现strstr函数(C语言)
c语言·开发语言·算法
liliangcsdn4 小时前
因子权重矩阵处理-滞回缓冲带+降频稳定化动态重选
开发语言·python·算法
敲代码的嘎仔4 小时前
自己设计了一个兑换码算法:自增ID + Base32转码 + 按位加权签名 + 异或混淆,面试被追问细节时终于不用慌了
java·数据库·mysql·算法·微服务·面试·职场和发展