阅读视图

发现新文章,点击刷新页面。

每日一题-二指输入的的最小距离🔴

二指输入法定制键盘在 X-Y 平面上的布局如上图所示,其中每个大写英文字母都位于某个坐标处。

  • 例如字母 A 位于坐标 (0,0),字母 B 位于坐标 (0,1),字母 P 位于坐标 (2,3) 且字母 Z 位于坐标 (4,1)

给你一个待输入字符串 word,请你计算并返回在仅使用两根手指的情况下,键入该字符串需要的最小移动总距离。

坐标 (x1,y1) (x2,y2) 之间的 距离 是 |x1 - x2| + |y1 - y2|。 

注意,两根手指的起始位置是零代价的,不计入移动总距离。你的两根手指的起始位置也不必从首字母或者前两个字母开始。

 

示例 1:

输入:word = "CAKE"
输出:3
解释: 
使用两根手指输入 "CAKE" 的最佳方案之一是: 
手指 1 在字母 'C' 上 -> 移动距离 = 0 
手指 1 在字母 'A' 上 -> 移动距离 = 从字母 'C' 到字母 'A' 的距离 = 2 
手指 2 在字母 'K' 上 -> 移动距离 = 0 
手指 2 在字母 'E' 上 -> 移动距离 = 从字母 'K' 到字母 'E' 的距离  = 1 
总距离 = 3

示例 2:

输入:word = "HAPPY"
输出:6
解释: 
使用两根手指输入 "HAPPY" 的最佳方案之一是:
手指 1 在字母 'H' 上 -> 移动距离 = 0
手指 1 在字母 'A' 上 -> 移动距离 = 从字母 'H' 到字母 'A' 的距离 = 2
手指 2 在字母 'P' 上 -> 移动距离 = 0
手指 2 在字母 'P' 上 -> 移动距离 = 从字母 'P' 到字母 'P' 的距离 = 0
手指 1 在字母 'Y' 上 -> 移动距离 = 从字母 'A' 到字母 'Y' 的距离 = 4
总距离 = 6

 

提示:

  • 2 <= word.length <= 300
  • 每个 word[i] 都是一个大写英文字母。

写给设计师:如何设计一份 AI 友好的设计规范

你有没有这种体验:让 AI 帮你写个页面,它生成的代码颜色全是瞎编的、间距全靠猜、按钮样式跟你们产品完全不搭?

然后你甩给它一份设计规范的 PDF,指望它能“学会”你们的设计体系。

结果呢?AI 看 PDF 基本等于盲人摸象——它看到的是一堆碎片化的文字和完全无法理解的截图。那些精心排版的视觉示例,在 AI 眼里跟噪音差不多。

问题不是 AI 不行,而是我们给 AI 的“学习资料”不对。

传统设计规范的问题

传统设计规范长这样:一份精美的 PDF,里面有品牌色卡、组件截图、do/don’t 的对比图、各种排版示例。

这东西给人看,完美。给 AI 看,灾难。

原因很简单:

第一,PDF 是视觉媒介,AI 是文本动物。 PDF 里那些色卡截图,AI 根本“看”不出来里面的色值是什么。它需要的是 #1A73E8 这个字符串,不是一个蓝色方块的图片。

第二,设计规范的“规则”通常是散文式的。 比如“不要在一个页面里放太多主按钮”——这句话人类一看就懂,但 AI 很难把它转化成一个可执行的判断。太多是多少?什么算主按钮?什么算一个页面?

第三,知识是碎片化的。 颜色写在第 3 页,间距写在第 7 页,按钮的规范在第 12 页,而按钮用到的颜色和间距需要 AI 自己去关联。这种跨页面的信息拼装,AI 做起来很吃力。

核心思路:把设计决策变成结构化数据

一句话总结:视觉示例给人看,结构化数据给 AI 读。

具体来说,就是把传统设计规范里的每一个设计决策,都翻译成 AI 能精确解析的格式。

那用什么格式呢?我让 Claude Opus 帮我调研了一下,它推荐的方案是:Markdown + JSON + YAML 的组合。其中:

  • Markdown 负责描述性的内容:设计原则、使用场景、什么时候该用什么不该用
  • JSON 负责精确的数值定义:颜色值、字号、间距、阴影
  • YAML 负责组件级的结构化规范:组件的变体、状态、约束规则

为什么不统一用一种格式?因为各有所长。JSON 适合定义纯数据(Design Token),YAML 适合描述有层次的组件规范(因为可读性更好),Markdown 适合写需要段落和叙事的内容(设计原则、模式指引)。

具体分五步来做

1. Design Token 化:把所有“魔法数字”抽出来

传统规范里,设计师说“主色调是品牌蓝”,然后在 PDF 里放一个色块。

AI 友好的方式是把它变成一个 Token:

1
2
3
4
5
6
7
8
9
10
11
12
{
"color": {
"brand": {
"primary": {
"value": "#1A73E8",
"usage": "主操作按钮、重要链接、选中态",
"contrast_on_white": "4.6:1",
"wcag_aa": true
}
}
}
}

注意这里不只有色值,还有 usage(什么场景用)和 wcag_aa(是否满足无障碍标准)。这些上下文信息对 AI 来说极其重要——它不只要知道“是什么颜色”,还要知道“什么时候用”和“为什么选这个颜色”。

同理,字号、间距、圆角、阴影、动画时长……所有数值类的设计决策,都应该 Token 化。

2. 组件规范用结构化 Schema 描述

传统规范里,一个按钮组件的描述可能是一页截图加几段说明文字。

AI 友好的方式是用 YAML 写一个完整的结构化定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
component: Button

variants:
- name: primary
description: "主操作按钮,页面中最重要的行动号召"
styles:
background: "{color.brand.primary}"
text_color: "#FFFFFF"
border_radius: "{border_radius.md}"

sizes:
- name: md
height: "40px"
padding: "0 {spacing.md}"
font_size: "{typography.scale.body-md.size}"

states: [default, hover, active, focus, disabled, loading]

这里面有几个关键设计:

用花括号引用 Token,比如 {color.brand.primary}。这样 AI 在生成代码时,会自动去 Token 文件里查对应的值,而不是硬编码一个色值。整个系统是关联的。

明确列出所有状态。人类设计师可能觉得“hover 状态不用说大家都知道”,但 AI 需要你把它列出来。缺什么它就不做什么。

有变体(variants)和尺寸(sizes)的穷举。 AI 最擅长在有限集合里做选择,而不是在模糊描述里做推断。

3. 把 do/don’t 改写成可执行的规则

这是最关键的一步。

传统规范里的“Don’t”通常配一张错误示例截图,AI 完全看不懂。

AI 友好的方式是把它写成带 ID、有严重等级、能机器检查的规则:

1
2
3
4
5
6
7
8
9
10
11
12
rules:
- id: btn-001
rule: "同一视图中最多一个 primary 按钮"
severity: error
rationale: "多个 primary 按钮导致用户无法识别主操作"

- id: btn-003
rule: "按钮文案不超过 6 个中文字符"
severity: warning
examples:
correct: ["提交订单", "确认删除", "开始学习"]
incorrect: ["好的", "订单信息", "下一步操作确认提交"]

这种格式有几个好处:

  • 有唯一 ID:AI 审查代码时可以引用“违反了规则 btn-001”
  • 有严重等级:error 是必须修的,warning 是建议修的,AI 可以区分优先级
  • 有原因:rationale 告诉 AI“为什么”,当遇到边缘情况需要取舍时,AI 能做更合理的判断
  • 有正反例:而且是文字形式的,不是截图

4. 提供“AI 入口文件”

你的设计规范可能有几十个文件,AI 不知道该先看哪个。你需要一个 README.md 作为入口,就像给 AI 画一张地图:

1
2
3
4
5
6
7
8
9
10
11
12
## AI 使用指引

### 生成 UI 代码时
1. 先读取 tokens/ 中的变量,禁止硬编码颜色/字号/间距值
2. 查找对应 components/*.yaml 获取组件结构和约束规则
3. 查阅 patterns/*.md 确认页面级布局要求
4. 检查 accessibility.md 确保符合无障碍标准

### 审查 UI 代码时
1. 逐条检查组件 YAML 中的 rules 字段
2. 验证 Token 引用是否正确
3. 检查 severity: error 的规则是否被违反

这个入口文件告诉 AI 三件事:有哪些文件、每个文件是干嘛的、不同任务应该按什么顺序查阅哪些文件。

5. 设计原则要可操作化

传统规范里的设计原则通常很抽象:“我们追求简洁”。

AI 友好的方式是让原则可操作——不只说“是什么”,还说“怎么用”和“冲突时怎么办”:

1
2
3
4
### 清晰优先于美观
- **含义**: 用户能否在 3 秒内理解界面意图,比视觉精致更重要
- **实践**: 信息层次分明,操作路径可预期,文案直白无歧义
- **权衡**: 当装饰性元素影响信息传达时,移除装饰性元素

特别是要提供一个 原则冲突解决矩阵。比如“清晰”和“包容性”冲突时谁优先?“性能”和“一致性”冲突时呢?人类设计师靠直觉判断,AI 需要明确的规则。

推荐的文件结构

说了这么多,最终的目录结构长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
design-system/
├── README.md ← AI 入口,索引整个规范
├── principles.md ← 设计原则 + 冲突解决矩阵
├── accessibility.md ← 无障碍要求 + AI 审查清单
├── tokens/
│ ├── colors.json ← 品牌色、功能色、中性色
│ ├── typography.json ← 字体、字号阶梯、行高
│ ├── spacing.json ← 间距、栅格、断点
│ ├── elevation.json ← 阴影、层级
│ └── motion.json ← 动画时长、缓动函数
├── components/
│ ├── button.yaml ← 按钮规范
│ ├── input.yaml ← 输入框规范
│ ├── modal.yaml ← 弹窗规范
│ └── card.yaml ← 卡片规范
└── patterns/
├── form-layout.md ← 表单布局模式
├── error-handling.md ← 错误处理策略
├── responsive.md ← 响应式断点规则
└── dark-mode.md ← 深色模式适配

每层的分工很清晰:

  • tokens/ 是最底层的原子变量,纯数据,JSON 格式
  • components/ 是组件级规范,结构化描述,YAML 格式
  • patterns/ 是页面级模式,需要叙事和流程说明,Markdown 格式

一些实操建议

不要一步到位。 你不需要一次把整个设计规范都改造完。可以先从 Design Token 开始——把颜色和字号从 PDF 里抽出来做成 JSON 文件,这一步投入产出比最高。

保持两个版本同源。 理想情况下,JSON/YAML 是“源文件”,PDF 版本从源文件自动生成。这样改一处,两边都更新。如果做不到自动生成,至少保证人工同步。

给每个决策加上“为什么”。 这是很多人最容易忽略的。AI 在遇到边缘情况时,rationale 字段就是它做判断的依据。没有 rationale,它只会机械执行规则;有了 rationale,它能理解意图,做出更灵活的判断。

把规范放到代码仓库里。 设计规范不应该是一个飞书文档或者 Figma 链接,而是一个 Git 仓库里的文件夹。这样 AI 工具可以直接读取,开发者可以在 CI/CD 里做自动检查,版本变更有迹可循。

实际测试。 改造完之后,拿你的 AI 工具(Claude、Cursor、Copilot 等)实际跑一遍:让它基于你的设计规范生成一个页面,看看它是不是真的引用了 Token、遵守了规则。不好使就迭代。

最后

AI 时代的设计规范,本质上是一个 API——它不再只是给人“阅读”的文档,而是给机器“调用”的接口。

格式变了,但设计的本质没变。你仍然需要好的设计判断来决定什么颜色、什么间距、什么交互模式。只是表达方式要变一变:从“让人看懂”升级为“人机双读”。

如果你的设计师不知道如何输出上面的文件,没关系,把这篇文章发给你的 AI Agent(推荐使用 Claude Opus 4.6),然后说:我需要按照文章中的方案来产生一套面向 AI 的设计规范,你来帮我完成,现在你告诉我需要哪些文件和资料,我来负责提供。

放心,AI 会一步一步带着你完成这份规范。

希望对你有用。

教你一步步思考 DP:记忆化搜索 -> 递推 -> 空间优化(Python/Java/C++/Go)

一、分析

示例 1 的 $\textit{word} = \texttt{CAKE}$,敲完 $\texttt{E}$ 就结束了,所以最后一定有一根手指停在 $\texttt{E}$。

另一根手指呢?最后一定停在 $\texttt{K}$ 吗?不一定,如果第一根手指输入 $\texttt{CA}$,第二根手指输入 $\texttt{KE}$,那么第一根手指最后会停在 $\texttt{A}$,第二根手指最后会停在 $\texttt{E}$。

只要我们能实时跟踪两根手指的位置(在哪个字母),就能暴力搜索所有输入的过程。

二、状态定义与状态转移方程

根据上面的讨论,定义 $\textit{dfs}(i, \textit{finger}_1, \textit{finger}_2)$ 表示在手指 1 位于字母 $\textit{finger}_1$,手指 2 位于字母 $\textit{finger}_2$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i]$ 的最小移动总距离。

:从右往左递归,主要是方便把递归翻译成从左往右的递推。从左往右递归也是可以的。

讨论用哪根手指输入 $\textit{word}[i]$:

  • 用手指 1,那么接下来要解决的问题是,在手指 1 位于字母 $\textit{word}[i]$,手指 2 位于字母 $\textit{finger}_2$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i-1]$ 的最小移动总距离,即 $\textit{dfs}(i-1, \textit{word}[i],\textit{finger}_2)$,加上从 $\textit{finger}_1$ 到 $\textit{word}[i]$ 的距离。
  • 用手指 2,那么接下来要解决的问题是,在手指 1 位于字母 $\textit{finger}_1$,手指 2 位于字母 $\textit{word}[i]$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i-1]$ 的最小移动总距离,即 $\textit{dfs}(i-1, \textit{finger}_1,\textit{word}[i])$,加上从 $\textit{finger}_2$ 到 $\textit{word}[i]$ 的距离。

这两种情况取最小值,就得到了 $\textit{dfs}(i, \textit{finger}_1, \textit{finger}_2)$,即

$$
\textit{dfs}(i, \textit{finger}_1, \textit{finger}_2) = \min
\begin{cases}
\textit{dfs}(i-1, \textit{word}[i],\textit{finger}_2) + \textit{dis}[\textit{finger}_1][\textit{word}[i]] \
\textit{dfs}(i-1, \textit{finger}_1,\textit{word}[i]) + \textit{dis}[\textit{finger}_2][\textit{word}[i]] \
\end{cases}
$$

其中 $\textit{dis}[x][y]$ 表示从字母 $x$ 到字母 $y$ 的距离。这个二维数组可以在跑 $\textit{dfs}$ 之前预处理出来。

递归边界:$\textit{dfs}(-1, \textit{finger}_1, \textit{finger}_2)=0$。没有字母需要输入,无需移动。

递归入口:$\textit{dfs}(n-2, \textit{word}[n-1], \textit{finger}_2)$。最后一定有一根手指在 $\textit{word}[n-1]$,另一根手指的位置不确定,枚举 $\textit{finger}_2$。

三、递归搜索 + 保存递归返回值 = 记忆化搜索

考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:

  • 如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 $\textit{memo}$ 数组中。
  • 如果一个状态不是第一次遇到($\textit{memo}$ 中保存的结果不等于 $\textit{memo}$ 的初始值),那么可以直接返回 $\textit{memo}$ 中保存的结果。

注意:$\textit{memo}$ 数组的初始值一定不能等于要记忆化的值!例如初始值设置为 $0$,并且要记忆化的 $\textit{dfs}(i, \textit{finger}_1, \textit{finger}_2)$ 也等于 $0$,那就没法判断 $0$ 到底表示第一次遇到这个状态,还是表示之前遇到过了,从而导致记忆化失效。一般把初始值设置为 $-1$。

Python 用户可以无视上面这段,直接用 @cache 装饰器。

具体请看视频讲解 动态规划入门:从记忆化搜索到递推【基础算法精讲 17】,其中包含把记忆化搜索 1:1 翻译成递推的技巧。

###py

# 预处理两个字母的距离
COLUMN = 6
get_dis = lambda a, b: abs(a // COLUMN - b // COLUMN) + abs(a % COLUMN - b % COLUMN)
dis = [[get_dis(i, j) for j in range(26)] for i in range(26)]

class Solution:
    def minimumDistance(self, word: str) -> int:
        word = [ord(ch) - ord('A') for ch in word]  # 避免在 dfs 中频繁调用 ord

        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(i: int, finger1: int, finger2: int) -> int:
            if i < 0:
                return 0

            # 手指 1 移到 word[i]
            res1 = dfs(i - 1, word[i], finger2) + dis[finger1][word[i]]

            # 手指 2 移到 word[i]
            res2 = dfs(i - 1, finger1, word[i]) + dis[finger2][word[i]]

            return min(res1, res2)

        n = len(word)
        # 最后一定有一根手指在 word[-1],另一根手指的位置不确定,枚举
        return min(dfs(n - 2, word[-1], finger2) for finger2 in range(26))

###java

class Solution {
    private static final int[][] dis = new int[26][26];

    static {
        // 预处理两个字母的距离
        final int COLUMN = 6;
        for (int i = 0; i < 26; i++) {
            for (int j = 0; j < 26; j++) {
                dis[i][j] = Math.abs(i / COLUMN - j / COLUMN) + Math.abs(i % COLUMN - j % COLUMN);
            }
        }
    }

    public int minimumDistance(String word) {
        char[] s = word.toCharArray();
        int n = s.length;

        int[][][] memo = new int[n][26][26];
        for (int[][] mat : memo) {
            for (int[] row : mat) {
                Arrays.fill(row, -1); // -1 表示没有计算过
            }
        }

        int ans = Integer.MAX_VALUE;
        // 最后一定有一根手指在 s[n-1],另一根手指的位置不确定,枚举
        for (int finger2 = 0; finger2 < 26; finger2++) {
            ans = Math.min(ans, dfs(n - 2, s[n - 1] - 'A', finger2, s, memo));
        }
        return ans;
    }

    private int dfs(int i, int finger1, int finger2, char[] word, int[][][] memo) {
        if (i < 0) {
            return 0;
        }

        if (memo[i][finger1][finger2] != -1) { // 之前计算过
            return memo[i][finger1][finger2];
        }

        // 手指 1 移到 word[i]
        int w = word[i] - 'A';
        int res1 = dfs(i - 1, w, finger2, word, memo) + dis[finger1][w];

        // 手指 2 移到 word[i]
        int res2 = dfs(i - 1, finger1, w, word, memo) + dis[finger2][w];

        int res = Math.min(res1, res2);
        memo[i][finger1][finger2] = res; // 记忆化
        return res;
    }
}

###cpp

int dis[26][26];

auto init = [] {
    // 预处理两个字母的距离
    constexpr int COLUMN = 6;
    for (int i = 0; i < 26; i++) {
        for (int j = 0; j < 26; j++) {
            dis[i][j] = abs(i / COLUMN - j / COLUMN) + abs(i % COLUMN - j % COLUMN);
        }
    }
    return 0;
}();

class Solution {
public:
    int minimumDistance(string word) {
        int n = word.size();
        vector memo(n, vector(26, vector<int>(26, -1))); // -1 表示没有计算过

        auto dfs = [&](this auto&& dfs, int i, int finger1, int finger2) -> int {
            if (i < 0) {
                return 0;
            }

            int& res = memo[i][finger1][finger2]; // 注意这里是引用
            if (res != -1) { // 之前计算过
                return res;
            }

            // 手指 1 移到 word[i]
            int w = word[i] - 'A';
            int res1 = dfs(i - 1, w, finger2) + dis[finger1][w];

            // 手指 2 移到 word[i]
            int res2 = dfs(i - 1, finger1, w) + dis[finger2][w];

            res = min(res1, res2);
            return res;
        };

        int ans = INT_MAX;
        // 最后一定有一根手指在 word[n-1],另一根手指的位置不确定,枚举
        for (int finger2 = 0; finger2 < 26; finger2++) {
            ans = min(ans, dfs(n - 2, word[n - 1] - 'A', finger2));
        }
        return ans;
    }
};

###go

var dis [26][26]int

func init() {
// 预处理两个字母的距离
const column = 6
for i := range 26 {
for j := range 26 {
dis[i][j] = abs(i/column-j/column) + abs(i%column-j%column)
}
}
}

func minimumDistance(word string) int {
n := len(word)
memo := make([][26][26]int, n)

var dfs func(int, byte, byte) int
dfs = func(i int, finger1, finger2 byte) (res int) {
if i < 0 {
return 0
}

p := &memo[i][finger1][finger2]
if *p != 0 { // 之前计算过
return *p - 1
}
defer func() { *p = res + 1 }() // 记忆化的时候加一,这样就无需初始化 memo 为 -1 了

// 手指 1 移到 word[i]
w := word[i] - 'A'
res1 := dfs(i-1, w, finger2) + dis[finger1][w]

// 手指 2 移到 word[i]
res2 := dfs(i-1, finger1, w) + dis[finger2][w]

return min(res1, res2)
}

ans := math.MaxInt
// 最后一定有一根手指在 word[n-1],另一根手指的位置不确定,枚举
for finger2 := range byte(26) {
ans = min(ans, dfs(n-2, word[n-1]-'A', finger2))
}
return ans
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

不计入预处理的时间和空间。

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$ 或 $\mathcal{O}(n|\Sigma|^2)$,其中 $n$ 是 $\textit{word}$ 的长度,$|\Sigma|=26$ 是字符集合的大小。如果用哈希表保存状态,则时间复杂度为 $\mathcal{O}(n|\Sigma|)$,否则瓶颈在创建 $\textit{memo}$ 数组上。理由见下一章。
  • 空间复杂度:$\mathcal{O}(n|\Sigma|)$ 或 $\mathcal{O}(n|\Sigma|^2)$。理由见下一章。

四、状态优化

回顾上面的代码,在输入 $\textit{word}[i]$ 之前,一定有一根手指在 $\textit{word}[i+1]$。这意味着,知道了 $i$,就知道了其中一根手指的位置。所以 $\textit{finger}_1$ 和 $\textit{finger}_2$ 中的一个是多余的。

定义 $\textit{dfs}(i, \textit{anotherFinger})$ 表示在一根手指位于字母 $\textit{word}[i+1]$,另一根手指位于字母 $\textit{anotherFinger}$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i]$ 的最小移动总距离。

讨论用哪根手指输入 $\textit{word}[i]$:

  • 用位于 $\textit{word}[i+1]$ 的手指输入 $\textit{word}[i]$,那么另一根手指仍然位于字母 $\textit{anotherFinger}$,所以接下来要解决的问题是,在一根手指位于字母 $\textit{word}[i]$,另一根手指位于字母 $\textit{anotherFinger}$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i-1]$ 的最小移动总距离,即 $\textit{dfs}(i-1, \textit{anotherFinger})$,加上从 $\textit{word}[i+1]$ 到 $\textit{word}[i]$ 的距离。
  • 用位于 $\textit{anotherFinger}$ 的手指输入 $\textit{word}[i]$,那么位于 $\textit{word}[i+1]$ 的手指就变成了「另一根手指」,所以接下来要解决的问题是,在一根手指位于字母 $\textit{word}[i]$,另一根手指位于字母 $\textit{word}[i+1]$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i-1]$ 的最小移动总距离,即 $\textit{dfs}(i-1, \textit{word}[i+1])$,加上从 $\textit{anotherFinger}$ 到 $\textit{word}[i]$ 的距离。

这两种情况取最小值,就得到了 $\textit{dfs}(i, \textit{anotherFinger})$,即

$$
\textit{dfs}(i, \textit{anotherFinger}) = \min
\begin{cases}
\textit{dfs}(i-1, \textit{anotherFinger}) + \textit{dis}[\textit{word}[i+1]][\textit{word}[i]] \
\textit{dfs}(i-1, \textit{word}[i+1]) + \textit{dis}[\textit{anotherFinger}][\textit{word}[i]] \
\end{cases}
$$

递归边界:$\textit{dfs}(-1, \textit{anotherFinger})=0$。没有字母需要输入,无需移动。

递归入口:$\textit{dfs}(n-2, \textit{anotherFinger})$。最后一定有一根手指在 $\textit{word}[n-1]$,另一根手指的位置不确定,枚举 $\textit{anotherFinger}$。

###py

# 预处理两个字母的距离
COLUMN = 6
get_dis = lambda a, b: abs(a // COLUMN - b // COLUMN) + abs(a % COLUMN - b % COLUMN)
dis = [[get_dis(i, j) for j in range(26)] for i in range(26)]

class Solution:
    def minimumDistance(self, word: str) -> int:
        word = [ord(ch) - ord('A') for ch in word]  # 避免在 dfs 中频繁调用 ord

        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(i: int, another_finger: int) -> int:
            if i < 0:
                return 0

            # 在 word[i+1] 的手指移到 word[i]
            res1 = dfs(i - 1, another_finger) + dis[word[i + 1]][word[i]]

            # 另一根手指移到 word[i],原来在 word[i+1] 的手指变成 another_finger
            res2 = dfs(i - 1, word[i + 1]) + dis[another_finger][word[i]]

            return min(res1, res2)

        n = len(word)
        # 最后一定有一根手指在 word[-1],另一根手指的位置不确定,枚举
        return min(dfs(n - 2, another_finger) for another_finger in range(26))

###java

class Solution {
    private static final int[][] dis = new int[26][26];

    static {
        // 预处理两个字母的距离
        final int COLUMN = 6;
        for (int i = 0; i < 26; i++) {
            for (int j = 0; j < 26; j++) {
                dis[i][j] = Math.abs(i / COLUMN - j / COLUMN) + Math.abs(i % COLUMN - j % COLUMN);
            }
        }
    }

    public int minimumDistance(String word) {
        char[] s = word.toCharArray();
        int n = s.length;

        int[][] memo = new int[n][26];
        for (int[] row : memo) {
            Arrays.fill(row, -1); // -1 表示没有计算过
        }

        int ans = Integer.MAX_VALUE;
        // 最后一定有一根手指在 s[n-1],另一根手指的位置不确定,枚举
        for (int anotherFinger = 0; anotherFinger < 26; anotherFinger++) {
            ans = Math.min(ans, dfs(n - 2, anotherFinger, s, memo));
        }
        return ans;
    }

    private int dfs(int i, int anotherFinger, char[] word, int[][] memo) {
        if (i < 0) {
            return 0;
        }

        if (memo[i][anotherFinger] != -1) { // 之前计算过
            return memo[i][anotherFinger];
        }

        // 在 word[i+1] 的手指移到 word[i]
        int w = word[i] - 'A';
        int res1 = dfs(i - 1, anotherFinger, word, memo) + dis[word[i + 1] - 'A'][w];

        // 另一根手指移到 word[i],原来在 word[i+1] 的手指变成 anotherFinger
        int res2 = dfs(i - 1, word[i + 1] - 'A', word, memo) + dis[anotherFinger][w];

        int res = Math.min(res1, res2);
        memo[i][anotherFinger] = res; // 记忆化
        return res;
    }
}

###cpp

int dis[26][26];

auto init = [] {
    // 预处理两个字母的距离
    constexpr int COLUMN = 6;
    for (int i = 0; i < 26; i++) {
        for (int j = 0; j < 26; j++) {
            dis[i][j] = abs(i / COLUMN - j / COLUMN) + abs(i % COLUMN - j % COLUMN);
        }
    }
    return 0;
}();

class Solution {
public:
    int minimumDistance(string word) {
        int n = word.size();
        vector memo(n, vector<int>(26, -1)); // -1 表示没有计算过

        auto dfs = [&](this auto&& dfs, int i, int another_finger) -> int {
            if (i < 0) {
                return 0;
            }

            int& res = memo[i][another_finger]; // 注意这里是引用
            if (res != -1) { // 之前计算过
                return res;
            }

            // 在 word[i+1] 的手指移到 word[i]
            int w = word[i] - 'A';
            int res1 = dfs(i - 1, another_finger) + dis[word[i + 1] - 'A'][w];

            // 另一根手指移到 word[i],原来在 word[i+1] 的手指变成 another_finger
            int res2 = dfs(i - 1, word[i + 1] - 'A') + dis[another_finger][w];

            res = min(res1, res2);
            return res;
        };

        int ans = INT_MAX;
        // 最后一定有一根手指在 word[n-1],另一根手指的位置不确定,枚举
        for (int another_finger = 0; another_finger < 26; another_finger++) {
            ans = min(ans, dfs(n - 2, another_finger));
        }
        return ans;
    }
};

###go

var dis [26][26]int

func init() {
// 预处理两个字母的距离
const column = 6
for i := range 26 {
for j := range 26 {
dis[i][j] = abs(i/column-j/column) + abs(i%column-j%column)
}
}
}

func minimumDistance(word string) int {
n := len(word)
memo := make([][26]int, n)

var dfs func(int, byte) int
dfs = func(i int, anotherFinger byte) (res int) {
if i < 0 {
return 0
}

p := &memo[i][anotherFinger]
if *p != 0 { // 之前计算过
return *p - 1
}
defer func() { *p = res + 1 }() // 记忆化的时候加一,这样就无需初始化 memo 为 -1 了

// 手指 1 移到 word[i]
w := word[i] - 'A'
res1 := dfs(i-1, anotherFinger) + dis[word[i+1]-'A'][w]

// 手指 2 移到 word[i]
res2 := dfs(i-1, word[i+1]-'A') + dis[anotherFinger][w]

return min(res1, res2)
}

ans := math.MaxInt
// 最后一定有一根手指在 word[n-1],另一根手指的位置不确定,枚举
for anotherFinger := range byte(26) {
ans = min(ans, dfs(n-2, anotherFinger))
}
return ans
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

不计入预处理的时间和空间。

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $\textit{word}$ 的长度,$|\Sigma|=26$ 是字符集合的大小。由于每个状态只会计算一次,动态规划的时间复杂度 $=$ 状态个数 $\times$ 单个状态的计算时间。本题状态个数等于 $\mathcal{O}(n|\Sigma|)$,单个状态的计算时间为 $\mathcal{O}(1)$,所以总的时间复杂度为 $\mathcal{O}(n|\Sigma|)$。
  • 空间复杂度:$\mathcal{O}(n|\Sigma|)$。保存多少状态,就需要多少空间。

五、1:1 翻译成递推

我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。

具体来说,$f[i+1][\textit{anotherFinger}]$ 的定义和 $\textit{dfs}(i,\textit{anotherFinger})$ 的定义是一样的,都表示在一根手指位于字母 $\textit{word}[i+1]$,另一根手指位于字母 $\textit{anotherFinger}$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i]$ 的最小移动总距离。这里 $+1$ 是为了把 $\textit{dfs}(-1,\textit{anotherFinger})$ 这个状态也翻译过来,这样我们可以把 $f[0]$ 作为初始值。

相应的递推式(状态转移方程)也和 $\textit{dfs}$ 一样:

$$
f[i+1][\textit{anotherFinger}] = \min
\begin{cases}
f[i][\textit{anotherFinger}] + \textit{dis}[\textit{word}[i+1]][\textit{word}[i]] \
f[i][\textit{word}[i+1]] + \textit{dis}[\textit{anotherFinger}][\textit{word}[i]] \
\end{cases}
$$

初始值 $f[0][\textit{anotherFinger}]=0$,翻译自递归边界 $\textit{dfs}(-1, \textit{anotherFinger})$。

答案为 $f[n-1][\textit{anotherFinger}]$,翻译自递归入口 $\textit{dfs}(n-2, \textit{anotherFinger})$。

###py

# 预处理两个字母的距离
COLUMN = 6
get_dis = lambda a, b: abs(a // COLUMN - b // COLUMN) + abs(a % COLUMN - b % COLUMN)
dis = [[get_dis(i, j) for j in range(26)] for i in range(26)]

class Solution:
    def minimumDistance(self, word: str) -> int:
        f = [[0] * 26 for _ in word]
        for i, (x, y) in enumerate(pairwise(word)):
            x = ord(x) - ord('A')
            y = ord(y) - ord('A')
            for another_finger in range(26):
                f[i + 1][another_finger] = min(f[i][another_finger] + dis[y][x], f[i][y] + dis[another_finger][x])
        return min(f[-1])

###java

class Solution {
    private static final int[][] dis = new int[26][26];

    static {
        // 预处理两个字母的距离
        final int COLUMN = 6;
        for (int i = 0; i < 26; i++) {
            for (int j = 0; j < 26; j++) {
                dis[i][j] = Math.abs(i / COLUMN - j / COLUMN) + Math.abs(i % COLUMN - j % COLUMN);
            }
        }
    }

    public int minimumDistance(String word) {
        char[] s = word.toCharArray();
        int n = s.length;

        int[][] f = new int[n][26];
        for (int i = 0; i < n - 1; i++) {
            int x = s[i] - 'A';
            int y = s[i + 1] - 'A';
            for (int anotherFinger = 0; anotherFinger < 26; anotherFinger++) {
                f[i + 1][anotherFinger] = Math.min(f[i][anotherFinger] + dis[y][x], f[i][y] + dis[anotherFinger][x]);
            }
        }

        int ans = Integer.MAX_VALUE;
        for (int res : f[n - 1]) {
            ans = Math.min(ans, res);
        }
        return ans;
    }
}

###cpp

int dis[26][26];

auto init = [] {
    // 预处理两个字母的距离
    constexpr int COLUMN = 6;
    for (int i = 0; i < 26; i++) {
        for (int j = 0; j < 26; j++) {
            dis[i][j] = abs(i / COLUMN - j / COLUMN) + abs(i % COLUMN - j % COLUMN);
        }
    }
    return 0;
}();

class Solution {
public:
    int minimumDistance(string word) {
        int n = word.size();
        vector<array<int, 26>> f(n);
        for (int i = 0; i < n - 1; i++) {
            int x = word[i] - 'A', y = word[i + 1] - 'A';
            for (int another_finger = 0; another_finger < 26; another_finger++) {
                f[i + 1][another_finger] = min(f[i][another_finger] + dis[y][x], f[i][y] + dis[another_finger][x]);
            }
        }
        return ranges::min(f[n - 1]);
    }
};

###go

var dis [26][26]int

func init() {
// 预处理两个字母的距离
const column = 6
for i := range 26 {
for j := range 26 {
dis[i][j] = abs(i/column-j/column) + abs(i%column-j%column)
}
}
}

func minimumDistance(word string) int {
n := len(word)
f := make([][26]int, n)

for i := range n - 1 {
x, y := word[i]-'A', word[i+1]-'A'
for anotherFinger := range 26 {
f[i+1][anotherFinger] = min(f[i][anotherFinger]+dis[y][x], f[i][y]+dis[anotherFinger][x])
}
}

return slices.Min(f[n-1][:])
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

不计入预处理的时间和空间。

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $\textit{word}$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(n|\Sigma|)$。

六、空间优化

由于 $f[i+1]$ 只依赖 $f[i]$,那么 $f[i-1]$ 及其之前的数据就没用了。

例如计算 $f[2]$ 的时候,数组 $f[0]$ 不再使用了。

那么干脆把 $f[2]$ 填到 $f[0]$ 中,$f[3]$ 填到 $f[1]$ 中,$f[4]$ 填到 $f[0]$ 中,$f[5]$ 填到 $f[1]$ 中 …… 只用两个长为 $26$ 的数组滚动计算

此外,由于 $\textit{dis}[x][y] = \textit{dis}[y][x]$,我们可以交换转移方程中的 $\textit{dis}$ 的两个维度,这样在同一个内层循环中只会访问 $\textit{dis}[x]$ 这一个数组。

###py

COLUMN = 6
get_dis = lambda a, b: abs(a // COLUMN - b // COLUMN) + abs(a % COLUMN - b % COLUMN)
dis = [[get_dis(i, j) for j in range(26)] for i in range(26)]

class Solution:
    def minimumDistance(self, word: str) -> int:
        f = [0] * 26
        nf = [0] * 26
        for x, y in pairwise(word):
            x = ord(x) - ord('A')
            y = ord(y) - ord('A')
            dis_x = dis[x]
            for another_finger in range(26):
                nf[another_finger] = min(f[another_finger] + dis_x[y], f[y] + dis_x[another_finger])
            f, nf = nf, f
        return min(f)

###java

class Solution {
    private static final int[][] dis = new int[26][26];

    static {
        final int COLUMN = 6;
        for (int i = 0; i < 26; i++) {
            for (int j = 0; j < 26; j++) {
                dis[i][j] = Math.abs(i / COLUMN - j / COLUMN) + Math.abs(i % COLUMN - j % COLUMN);
            }
        }
    }

    public int minimumDistance(String word) {
        char[] s = word.toCharArray();

        int[] f = new int[26];
        int[] nf = new int[26];

        for (int i = 0; i < s.length - 1; i++) {
            int x = s[i] - 'A';
            int y = s[i + 1] - 'A';
            for (int anotherFinger = 0; anotherFinger < 26; anotherFinger++) {
                nf[anotherFinger] = Math.min(f[anotherFinger] + dis[x][y], f[y] + dis[x][anotherFinger]);
            }
            int[] tmp = f;
            f = nf;
            nf = tmp;
        }

        int ans = Integer.MAX_VALUE;
        for (int res : f) {
            ans = Math.min(ans, res);
        }
        return ans;
    }
}

###cpp

int dis[26][26];

auto init = [] {
    constexpr int COLUMN = 6;
    for (int i = 0; i < 26; i++) {
        for (int j = 0; j < 26; j++) {
            dis[i][j] = abs(i / COLUMN - j / COLUMN) + abs(i % COLUMN - j % COLUMN);
        }
    }
    return 0;
}();

class Solution {
public:
    int minimumDistance(string word) {
        int n = word.size();
        int f[26]{}, nf[26];

        for (int i = 0; i < n - 1; i++) {
            int x = word[i] - 'A', y = word[i + 1] - 'A';
            for (int another_finger = 0; another_finger < 26; another_finger++) {
                nf[another_finger] = min(f[another_finger] + dis[x][y], f[y] + dis[x][another_finger]);
            }
            swap(f, nf);
        }

        return ranges::min(f);
    }
};

###go

var dis [26][26]int

func init() {
const column = 6
for i := range 26 {
for j := range 26 {
dis[i][j] = abs(i/column-j/column) + abs(i%column-j%column)
}
}
}

func minimumDistance(word string) int {
var f, nf [26]int

for i := range len(word) - 1 {
x, y := word[i]-'A', word[i+1]-'A'
for anotherFinger := range 26 {
nf[anotherFinger] = min(f[anotherFinger]+dis[x][y], f[y]+dis[x][anotherFinger])
}
f, nf = nf, f
}

return slices.Min(f[:])
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

不计入预处理的时间和空间。

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $\textit{word}$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(|\Sigma|)$。

专题训练

见下面动态规划题单的「§7.6 多维 DP」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

二指输入的的最小距离

方法一:动态规划

我们用 dp[i][l][r] 表示在输入了字符串 word 的第 i 个字母后,左手的位置为 l,右手的位置为 r,达到该状态的最小移动距离。这里的位置为指向的字母编号,例如 A 对应 0B 对应 1,以此类推,而非字母在键盘上的位置。这样做的好处是将字母的位置映射成一个整数而非二维的坐标,使得我们更加方便地进行状态转移。

那么如何进行状态转移呢?我们首先需要看出一个非常重要的性质:对于状态 dp[i][l][r],要么 word[i] == l,要么 word[i] == r,即在输入了第 i 个字母后,左手和右手中至少有一个在 word[i] 的位置。我们可以根据这两种情况,分别进行状态转移:

  • word[i] == l 时,左手在 word[i] 的位置。我们需要考虑在输入字符串 word 的第 i - 1 个字母时,是左手还是右手在 word[i - 1] 的位置:

    • 如果左手在 word[i - 1] 的位置,那么在输入第 i 个字母时,左手从 word[i - 1] 移动至 word[i],状态转移方程为:

      dp[i][l = word[i]][r] = dp[i - 1][l0 = word[i - 1]][r] + dist(word[i - 1], word[i])
      
    • 如果右手在 word[i - 1] 的位置,那么由于第 i 个字母使用了左手,右手就没有移动,即 word[i - 1] == r。同时,在输入 word[i1] 之前的左手位置也无关紧要,可以为任意的 l0,状态转移方程为:

      dp[i][l = word[i]][r = word[i - 1]] = dp[i - 1][l0][r = word[i - 1]] + dist(l0, word[i])
      
  • word[i] == r 时,右手在 word[i] 的位置。我们需要考虑在输入字符串 word 的第 i - 1 个字母时,是右手还是左手在 word[i - 1] 的位置:

    • 如果右手在 word[i - 1] 的位置,那么在输入第 i 个字母时,右手从 word[i - 1] 移动至 word[i],状态转移方程为:

      dp[i][l][r = word[i]] = dp[i - 1][l][r0 = word[i - 1]] + dist(word[i - 1], word[i])
      
    • 如果左手在 word[i - 1] 的位置,那么由于第 i 个字母使用了右手,左手就没有移动,即 word[i - 1] == l。同时,在输入 word[i] 之前的右手位置也无关紧要,可以为任意的 r0,状态转移方程为:

      dp[i][l = word[i - 1]][r = word[i]] = dp[i - 1][l = word[i - 1]][r0] + dist(r0, word[i])
      

对于每一个状态 dp[i][l][r],我们取它所有转移中的最小值,即为输入了字符串 word 的第 i 个字母后,左手的位置为 l,右手的位置为 r,达到该状态的最小移动距离。

在这个动态规划中,我们还需要考虑不合法的状态以及边界状态。对于某一个不合法的状态,如果用它来进行状态转移,可能会使得 dp[i][l][r] 取到一个更小且不合法的值。因此,我们一般会给所有不合法的状态赋予一个非常大的值(例如 C++ 中的整数最大值 INT_MAX),这样即使用它来进行状态转移,也会因为本身值非常大的缘故,对最优解没有任何影响。在考虑边界状态时,由于题目中规定两根手指的起始位置是零代价的,因此对于字符串中的第 0 个字母 word[0],输入它的最小移动距离为 0。此时要么左手的位置为 word[0],要么右手的位置为 word[0],因此我们可以将所有的 dp[0][l = word[0]][r] 以及 dp[0][l][r = word[0]] 作为边界状态,它们的值为 0

###C++

class Solution {
public:
    int getDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return abs(x1 - x2) + abs(y1 - y2);
    }

    int minimumDistance(string word) {
        int n = word.size();
        int dp[n][26][26];
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < 26; ++j) {
                fill(dp[i][j], dp[i][j] + 26, INT_MAX >> 1);
            }
        }
        for (int i = 0; i < 26; ++i) {
            dp[0][i][word[0] - 'A'] = dp[0][word[0] - 'A'][i] = 0;
        }
        
        for (int i = 1; i < n; ++i) {
            int cur = word[i] - 'A';
            int prev = word[i - 1] - 'A';
            int d = getDistance(prev, cur);
            for (int j = 0; j < 26; ++j) {
                dp[i][cur][j] = min(dp[i][cur][j], dp[i - 1][prev][j] + d);
                dp[i][j][cur] = min(dp[i][j][cur], dp[i - 1][j][prev] + d);
                if (prev == j) {
                    for (int k = 0; k < 26; ++k) {
                        int d0 = getDistance(k, cur);
                        dp[i][cur][j] = min(dp[i][cur][j], dp[i - 1][k][j] + d0);
                        dp[i][j][cur] = min(dp[i][j][cur], dp[i - 1][j][k] + d0);
                    }
                }
            }
        }

        int ans = INT_MAX >> 1;
        for (int i = 0; i < 26; ++i) {
            for (int j = 0; j < 26; ++j) {
                ans = min(ans, dp[n - 1][i][j]);
            }
        }
        return ans;
    }
};

###Python

class Solution:
    def minimumDistance(self, word: str) -> int:
        n = len(word)
        BIG = 2**30
        dp = [[[BIG] * 26 for x in range(26)] for y in range(n)]
        for i in range(26):
            dp[0][i][ord(word[0]) - 65] = 0
            dp[0][ord(word[0]) - 65][i] = 0
    
        def getDistance(p, q):
            x1, y1 = p // 6, p % 6
            x2, y2 = q // 6, q % 6
            return abs(x1 - x2) + abs(y1 - y2)

        for i in range(1, n):
            cur, prev = ord(word[i]) - 65, ord(word[i - 1]) - 65
            d = getDistance(prev, cur)
            for j in range(26):
                dp[i][cur][j] = min(dp[i][cur][j], dp[i - 1][prev][j] + d)
                dp[i][j][cur] = min(dp[i][j][cur], dp[i - 1][j][prev] + d)
                if prev == j:
                    for k in range(26):
                        d0 = getDistance(k, cur)
                        dp[i][cur][j] = min(dp[i][cur][j], dp[i - 1][k][j] + d0)
                        dp[i][j][cur] = min(dp[i][j][cur], dp[i - 1][j][k] + d0)
        
        ans = min(min(dp[n - 1][x]) for x in range(26))
        return ans

###Java

class Solution {
    private int getDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    }

    public int minimumDistance(String word) {
        int n = word.length();
        int[][][] dp = new int[n][26][26];
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < 26; ++j) {
                for (int k = 0; k < 26; ++k) {
                    dp[i][j][k] = Integer.MAX_VALUE / 2;
                }
            }
        }
        
        for (int i = 0; i < 26; ++i) {
            dp[0][i][word.charAt(0) - 'A'] = 0;
            dp[0][word.charAt(0) - 'A'][i] = 0;
        }
        
        for (int i = 1; i < n; ++i) {
            int cur = word.charAt(i) - 'A';
            int prev = word.charAt(i - 1) - 'A';
            int d = getDistance(prev, cur);
            
            for (int j = 0; j < 26; ++j) {
                dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][prev][j] + d);
                dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][prev] + d);
                
                if (prev == j) {
                    for (int k = 0; k < 26; ++k) {
                        int d0 = getDistance(k, cur);
                        dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][k][j] + d0);
                        dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][k] + d0);
                    }
                }
            }
        }
        
        int ans = Integer.MAX_VALUE / 2;
        for (int i = 0; i < 26; ++i) {
            for (int j = 0; j < 26; ++j) {
                ans = Math.min(ans, dp[n - 1][i][j]);
            }
        }
        return ans;
    }
}

###C#

public class Solution {
    private int GetDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return Math.Abs(x1 - x2) + Math.Abs(y1 - y2);
    }

    public int MinimumDistance(string word) {
        int n = word.Length;
        int[,,] dp = new int[n, 26, 26];
        
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < 26; ++j) {
                for (int k = 0; k < 26; ++k) {
                    dp[i, j, k] = int.MaxValue / 2;
                }
            }
        }
        
        for (int i = 0; i < 26; ++i) {
            dp[0, i, word[0] - 'A'] = 0;
            dp[0, word[0] - 'A', i] = 0;
        }
        for (int i = 1; i < n; ++i) {
            int cur = word[i] - 'A';
            int prev = word[i - 1] - 'A';
            int d = GetDistance(prev, cur);
            
            for (int j = 0; j < 26; ++j) {
                dp[i, cur, j] = Math.Min(dp[i, cur, j], dp[i - 1, prev, j] + d);
                dp[i, j, cur] = Math.Min(dp[i, j, cur], dp[i - 1, j, prev] + d);
                
                if (prev == j) {
                    for (int k = 0; k < 26; ++k) {
                        int d0 = GetDistance(k, cur);
                        dp[i, cur, j] = Math.Min(dp[i, cur, j], dp[i - 1, k, j] + d0);
                        dp[i, j, cur] = Math.Min(dp[i, j, cur], dp[i - 1, j, k] + d0);
                    }
                }
            }
        }
        
        int ans = int.MaxValue / 2;
        for (int i = 0; i < 26; ++i) {
            for (int j = 0; j < 26; ++j) {
                ans = Math.Min(ans, dp[n - 1, i, j]);
            }
        }
        return ans;
    }
}

###Go

func getDistance(p, q int) int {
    x1, y1 := p/6, p%6
    x2, y2 := q/6, q%6
    return abs(x1 - x2) + abs(y1 - y2)
}

func abs(x int) int {
    if x < 0 {
        return -x
    }
    return x
}

func minimumDistance(word string) int {
    n := len(word)
    dp := make([][26][26]int, n)
    
    for i := 0; i < n; i++ {
        for j := 0; j < 26; j++ {
            for k := 0; k < 26; k++ {
                dp[i][j][k] = 1 << 30
            }
        }
    }
    
    firstChar := int(word[0] - 'A')
    for i := 0; i < 26; i++ {
        dp[0][i][firstChar] = 0
        dp[0][firstChar][i] = 0
    }
    
    for i := 1; i < n; i++ {
        cur := int(word[i] - 'A')
        prev := int(word[i-1] - 'A')
        d := getDistance(prev, cur)
        
        for j := 0; j < 26; j++ {
            dp[i][cur][j] = min(dp[i][cur][j], dp[i-1][prev][j]+d)
            dp[i][j][cur] = min(dp[i][j][cur], dp[i-1][j][prev]+d)
            
            if prev == j {
                for k := 0; k < 26; k++ {
                    d0 := getDistance(k, cur)
                    dp[i][cur][j] = min(dp[i][cur][j], dp[i-1][k][j]+d0)
                    dp[i][j][cur] = min(dp[i][j][cur], dp[i-1][j][k]+d0)
                }
            }
        }
    }
    
    ans := 1 << 30
    for i := 0; i < 26; i++ {
        for j := 0; j < 26; j++ {
            ans = min(ans, dp[n-1][i][j])
        }
    }
    return ans
}

###C

int getDistance(int p, int q) {
    int x1 = p / 6, y1 = p % 6;
    int x2 = q / 6, y2 = q % 6;
    return abs(x1 - x2) + abs(y1 - y2);
}

int minimumDistance(char* word) {
    int n = strlen(word);
    int*** dp = (int***)malloc(n * sizeof(int**));
    for (int i = 0; i < n; ++i) {
        dp[i] = (int**)malloc(26 * sizeof(int*));
        for (int j = 0; j < 26; ++j) {
            dp[i][j] = (int*)malloc(26 * sizeof(int));
            for (int k = 0; k < 26; ++k) {
                dp[i][j][k] = INT_MAX / 2;
            }
        }
    }
    
    for (int i = 0; i < 26; ++i) {
        dp[0][i][word[0] - 'A'] = 0;
        dp[0][word[0] - 'A'][i] = 0;
    }
    for (int i = 1; i < n; ++i) {
        int cur = word[i] - 'A';
        int prev = word[i - 1] - 'A';
        int d = getDistance(prev, cur);
        
        for (int j = 0; j < 26; ++j) {
            dp[i][cur][j] = fmin(dp[i][cur][j], dp[i - 1][prev][j] + d);
            dp[i][j][cur] = fmin(dp[i][j][cur], dp[i - 1][j][prev] + d);
            
            if (prev == j) {
                for (int k = 0; k < 26; ++k) {
                    int d0 = getDistance(k, cur);
                    dp[i][cur][j] = fmin(dp[i][cur][j], dp[i - 1][k][j] + d0);
                    dp[i][j][cur] = fmin(dp[i][j][cur], dp[i - 1][j][k] + d0);
                }
            }
        }
    }
    
    int ans = INT_MAX / 2;
    for (int i = 0; i < 26; ++i) {
        for (int j = 0; j < 26; ++j) {
            if (ans > dp[n - 1][i][j]) {
                ans = dp[n - 1][i][j];
            }
        }
    }
    
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < 26; ++j) {
            free(dp[i][j]);
        }
        free(dp[i]);
    }
    free(dp);
    
    return ans;
}

###JavaScript

var minimumDistance = function(word) {
    const n = word.length;
    const getDistance = (p, q) => {
        const x1 = Math.floor(p / 6), y1 = p % 6;
        const x2 = Math.floor(q / 6), y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    };
    
    const dp = new Array(n);
    for (let i = 0; i < n; i++) {
        dp[i] = new Array(26);
        for (let j = 0; j < 26; j++) {
            dp[i][j] = new Array(26).fill(Math.floor(Number.MAX_SAFE_INTEGER / 2));
        }
    }
    
    const firstChar = word.charCodeAt(0) - 65;
    for (let i = 0; i < 26; i++) {
        dp[0][i][firstChar] = 0;
        dp[0][firstChar][i] = 0;
    }
    
    for (let i = 1; i < n; i++) {
        const cur = word.charCodeAt(i) - 65;
        const prev = word.charCodeAt(i - 1) - 65;
        const d = getDistance(prev, cur);
        
        for (let j = 0; j < 26; j++) {
            dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][prev][j] + d);
            dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][prev] + d);
            
            if (prev === j) {
                for (let k = 0; k < 26; k++) {
                    const d0 = getDistance(k, cur);
                    dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][k][j] + d0);
                    dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][k] + d0);
                }
            }
        }
    }
    
    let ans = Number.MAX_SAFE_INTEGER;
    for (let i = 0; i < 26; i++) {
        for (let j = 0; j < 26; j++) {
            ans = Math.min(ans, dp[n - 1][i][j]);
        }
    }
    return ans;
};

###TypeScript

function minimumDistance(word: string): number {
    const n = word.length;
    const getDistance = (p: number, q: number): number => {
        const x1 = Math.floor(p / 6), y1 = p % 6;
        const x2 = Math.floor(q / 6), y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    };
    
    const dp: number[][][] = new Array(n);
    for (let i = 0; i < n; i++) {
        dp[i] = new Array(26);
        for (let j = 0; j < 26; j++) {
            dp[i][j] = new Array(26).fill(Math.floor(Number.MAX_SAFE_INTEGER / 2));
        }
    }
    const firstChar = word.charCodeAt(0) - 65;
    for (let i = 0; i < 26; i++) {
        dp[0][i][firstChar] = 0;
        dp[0][firstChar][i] = 0;
    }
    
    for (let i = 1; i < n; i++) {
        const cur = word.charCodeAt(i) - 65;
        const prev = word.charCodeAt(i - 1) - 65;
        const d = getDistance(prev, cur);
        
        for (let j = 0; j < 26; j++) {
            dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][prev][j] + d);
            dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][prev] + d);
            
            if (prev === j) {
                for (let k = 0; k < 26; k++) {
                    const d0 = getDistance(k, cur);
                    dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][k][j] + d0);
                    dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][k] + d0);
                }
            }
        }
    }
    
    let ans = Number.MAX_SAFE_INTEGER;
    for (let i = 0; i < 26; i++) {
        for (let j = 0; j < 26; j++) {
            ans = Math.min(ans, dp[n - 1][i][j]);
        }
    }
    return ans;
}

###Rust

impl Solution {
    fn get_distance(p: i32, q: i32) -> i32 {
        let x1 = p / 6;
        let y1 = p % 6;
        let x2 = q / 6;
        let y2 = q % 6;
        (x1 - x2).abs() + (y1 - y2).abs()
    }

    pub fn minimum_distance(word: String) -> i32 {
        let n = word.len();
        let word_chars: Vec<char> = word.chars().collect();
        let mut dp = vec![vec![vec![i32::MAX >> 1; 26]; 26]; n];
        let first_char = (word_chars[0] as u8 - b'A') as usize;
        for i in 0..26 {
            dp[0][i][first_char] = 0;
            dp[0][first_char][i] = 0;
        }
        
        for i in 1..n {
            let cur = (word_chars[i] as u8 - b'A') as i32;
            let prev = (word_chars[i - 1] as u8 - b'A') as i32;
            let d = Self::get_distance(prev, cur);
            
            for j in 0..26 {
                let j_i32 = j as i32;
                dp[i][cur as usize][j] = dp[i][cur as usize][j].min(
                    dp[i - 1][prev as usize][j].saturating_add(d)
                );
                dp[i][j][cur as usize] = dp[i][j][cur as usize].min(
                    dp[i - 1][j][prev as usize].saturating_add(d)
                );
                
                if prev == j_i32 {
                    for k in 0..26 {
                        let d0 = Self::get_distance(k as i32, cur);
                        dp[i][cur as usize][j] = dp[i][cur as usize][j].min(
                            dp[i - 1][k][j].saturating_add(d0)
                        );
                        dp[i][j][cur as usize] = dp[i][j][cur as usize].min(
                            dp[i - 1][j][k].saturating_add(d0)
                        );
                    }
                }
            }
        }
        
        let mut ans = i32::MAX >> 1;
        for i in 0..26 {
            for j in 0..26 {
                ans = ans.min(dp[n - 1][i][j]);
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$O(|\Sigma|N)$,其中 $N$ 是字符串 word 的长度,$|\Sigma|$ 是可能出现的字母数量,在本题中 $|\Sigma| = 26$。对于状态 dp[i][l][r],枚举 i 需要的时间复杂度为 $O(N)$,在此之后,如果 word[i] == l,根据上面的状态转移方程:

    • 如果左手在 word[i - 1] 的位置,那么单次状态转移的时间复杂度为 $O(1)$,需要对所有的 r 都进行转移,总时间复杂度为 $O(|\Sigma|)$;

    • 如果右手在 word[i - 1] 的位置,那么 r == word[i - 1]。虽然我们要枚举 l0,但是合法的 r 只有一个,因此总时间复杂度也为 $O(|\Sigma|)$。

    如果 word[i] == r,分析的过程相同,在此不再赘述。这样总时间复杂度即为 $O(|\Sigma|N)$。

  • 空间复杂度:$O(|\Sigma|^2 N)$。

方法二:动态规划 + 空间优化

在方法一中,我们提到了一条重要的性质:对于状态 dp[i][l][r],要么 word[i] == l,要么 word[i] == r,即在输入了第 i 个字母后,左手和右手中至少有一个在 word[i] 的位置。那么对于每一个 i,我们其实只需要存储 $2|\Sigma|$ 而不是 $|\Sigma|^2$ 个状态。例如我们可以用 dp[i][op][rest] 表示状态,其中 op 的值只能为 01op == 0 表示左手在 word[i] 的位置,op == 1 表示右手在 word[i] 的位置,而 rest 表示不在 word[i] 位置的另一只手的位置。这样我们在状态转移方程几乎不变的前提下,减少了动态规划需要的空间。

那么我们是否还可以继续进行优化呢?我们可以发现,在方法一中,状态转移方程具有高度对称性,那么我们可以断定,dp[i][op = 0][rest]dp[i][op = 1][rest] 的值一定是相等的。这是因为 dp[i][op = 0][rest] 表示左手在 word[i] 的位置且右手在 rest 的位置,如果我们将左右手互换,那么我们同样可以使用 dp[i][op = 0][rest] 的移动距离使得右手在 word[i] 的位置且左手在 rest 的位置,而这恰好就是 dp[i][op = 1][rest]

因此我们可以直接使用 dp[i][rest] 进行状态转移,其表示一只手在 word[i] 的位置,另一只手在 rest 的位置的最小移动距离。我们并不需要关心具体哪只手在 word[i] 的位置,因为两只手是完全对称的。这样以来,我们将三维的动态规划优化至了二维,大大减少了空间的使用。

###C++

class Solution {
public:
    int getDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return abs(x1 - x2) + abs(y1 - y2);
    }

    int minimumDistance(string word) {
        int n = word.size();
        vector<vector<int>> dp(n, vector<int>(26, INT_MAX >> 1));
        fill(dp[0].begin(), dp[0].end(), 0);
        
        for (int i = 1; i < n; ++i) {
            int cur = word[i] - 'A';
            int prev = word[i - 1] - 'A';
            int d = getDistance(prev, cur);
            for (int j = 0; j < 26; ++j) {
                dp[i][j] = min(dp[i][j], dp[i - 1][j] + d);
                if (prev == j) {
                    for (int k = 0; k < 26; ++k) {
                        int d0 = getDistance(k, cur);
                        dp[i][j] = min(dp[i][j], dp[i - 1][k] + d0);
                    }
                }
            }
        }

        int ans = *min_element(dp[n - 1].begin(), dp[n - 1].end());
        return ans;
    }
};

###Python

class Solution:
    def minimumDistance(self, word: str) -> int:
        n = len(word)
        BIG = 2**30
        dp = [[0] * 26] + [[BIG] * 26 for _ in range(n - 1)]
        
        def getDistance(p, q):
            x1, y1 = p // 6, p % 6
            x2, y2 = q // 6, q % 6
            return abs(x1 - x2) + abs(y1 - y2)

        for i in range(1, n):
            cur, prev = ord(word[i]) - 65, ord(word[i - 1]) - 65
            d = getDistance(prev, cur)
            for j in range(26):
                dp[i][j] = min(dp[i][j], dp[i - 1][j] + d)
                if prev == j:
                    for k in range(26):
                        d0 = getDistance(k, cur)
                        dp[i][j] = min(dp[i][j], dp[i - 1][k] + d0)

        ans = min(dp[n - 1])
        return ans

###Java

class Solution {
    private int getDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    }

    public int minimumDistance(String word) {
        int n = word.length();
        int[][] dp = new int[n][26];
        for (int i = 0; i < n; i++) {
            Arrays.fill(dp[i], Integer.MAX_VALUE / 2);
        }
        Arrays.fill(dp[0], 0);
        
        for (int i = 1; i < n; i++) {
            int cur = word.charAt(i) - 'A';
            int prev = word.charAt(i - 1) - 'A';
            int d = getDistance(prev, cur);
            
            for (int j = 0; j < 26; j++) {
                dp[i][j] = Math.min(dp[i][j], dp[i - 1][j] + d);
                if (prev == j) {
                    for (int k = 0; k < 26; k++) {
                        int d0 = getDistance(k, cur);
                        dp[i][j] = Math.min(dp[i][j], dp[i - 1][k] + d0);
                    }
                }
            }
        }
        
        int ans = Integer.MAX_VALUE / 2;
        for (int value : dp[n - 1]) {
            ans = Math.min(ans, value);
        }
        return ans;
    }
}

###C#

public class Solution {
    private int GetDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return Math.Abs(x1 - x2) + Math.Abs(y1 - y2);
    }

    public int MinimumDistance(string word) {
        int n = word.Length;
        int[,] dp = new int[n, 26];
        
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < 26; j++) {
                dp[i, j] = int.MaxValue / 2;
            }
        }
        
        for (int j = 0; j < 26; j++) {
            dp[0, j] = 0;
        }
        for (int i = 1; i < n; i++) {
            int cur = word[i] - 'A';
            int prev = word[i - 1] - 'A';
            int d = GetDistance(prev, cur);
            
            for (int j = 0; j < 26; j++) {
                dp[i, j] = Math.Min(dp[i, j], dp[i - 1, j] + d);
                if (prev == j) {
                    for (int k = 0; k < 26; k++) {
                        int d0 = GetDistance(k, cur);
                        dp[i, j] = Math.Min(dp[i, j], dp[i - 1, k] + d0);
                    }
                }
            }
        }
        
        int ans = int.MaxValue / 2;
        for (int j = 0; j < 26; j++) {
            ans = Math.Min(ans, dp[n - 1, j]);
        }
        return ans;
    }
}

###Go

func getDistance(p, q int) int {
    x1, y1 := p / 6, p % 6
    x2, y2 := q / 6, q % 6
    return abs(x1 - x2) + abs(y1 - y2)
}

func abs(x int) int {
    if x < 0 {
        return -x
    }
    return x
}

func minimumDistance(word string) int {
    n := len(word)
    dp := make([][]int, n)
    for i := range dp {
        dp[i] = make([]int, 26)
        for j := range dp[i] {
            dp[i][j] = 1 << 30
        }
    }
    for j := 0; j < 26; j++ {
        dp[0][j] = 0
    }
    
    for i := 1; i < n; i++ {
        cur := int(word[i] - 'A')
        prev := int(word[i-1] - 'A')
        d := getDistance(prev, cur)
        
        for j := 0; j < 26; j++ {
            dp[i][j] = min(dp[i][j], dp[i-1][j]+d)
            if prev == j {
                for k := 0; k < 26; k++ {
                    d0 := getDistance(k, cur)
                    dp[i][j] = min(dp[i][j], dp[i-1][k]+d0)
                }
            }
        }
    }
    
    ans := 1 << 30
    for j := 0; j < 26; j++ {
        ans = min(ans, dp[n-1][j])
    }
    return ans
}

###C

int getDistance(int p, int q) {
    int x1 = p / 6, y1 = p % 6;
    int x2 = q / 6, y2 = q % 6;
    return abs(x1 - x2) + abs(y1 - y2);
}

int minimumDistance(char* word) {
    int n = strlen(word);
    int** dp = (int**)malloc(n * sizeof(int*));
    for (int i = 0; i < n; i++) {
        dp[i] = (int*)malloc(26 * sizeof(int));
        for (int j = 0; j < 26; j++) {
            dp[i][j] = INT_MAX / 2;
        }
    }
    for (int j = 0; j < 26; j++) {
        dp[0][j] = 0;
    }
    
    for (int i = 1; i < n; i++) {
        int cur = word[i] - 'A';
        int prev = word[i - 1] - 'A';
        int d = getDistance(prev, cur);
        
        for (int j = 0; j < 26; j++) {
            dp[i][j] = fmin(dp[i][j], dp[i - 1][j] + d);
            if (prev == j) {
                for (int k = 0; k < 26; k++) {
                    int d0 = getDistance(k, cur);
                    dp[i][j] = fmin(dp[i][j], dp[i - 1][k] + d0);
                }
            }
        }
    }
    
    int ans = INT_MAX / 2;
    for (int j = 0; j < 26; j++) {
        if (ans > dp[n - 1][j]) {
            ans = dp[n - 1][j];
        }
    }
    for (int i = 0; i < n; i++) {
        free(dp[i]);
    }
    free(dp);
    
    return ans;
}

###JavaScript

var minimumDistance = function(word) {
    const n = word.length;
    const getDistance = (p, q) => {
        const x1 = Math.floor(p / 6), y1 = p % 6;
        const x2 = Math.floor(q / 6), y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    };
    
    const dp = new Array(n);
    for (let i = 0; i < n; i++) {
        dp[i] = new Array(26).fill(Math.floor(Number.MAX_SAFE_INTEGER / 2));
    }
    
    for (let j = 0; j < 26; j++) {
        dp[0][j] = 0;
    }
    
    for (let i = 1; i < n; i++) {
        const cur = word.charCodeAt(i) - 65;
        const prev = word.charCodeAt(i - 1) - 65;
        const d = getDistance(prev, cur);
        
        for (let j = 0; j < 26; j++) {
            dp[i][j] = Math.min(dp[i][j], dp[i - 1][j] + d);
            
            if (prev === j) {
                for (let k = 0; k < 26; k++) {
                    const d0 = getDistance(k, cur);
                    dp[i][j] = Math.min(dp[i][j], dp[i - 1][k] + d0);
                }
            }
        }
    }
    
    let ans = Math.floor(Number.MAX_SAFE_INTEGER / 2);
    for (let j = 0; j < 26; j++) {
        ans = Math.min(ans, dp[n - 1][j]);
    }
    return ans;
};

###TypeScript

function minimumDistance(word: string): number {
    const n = word.length;
    const getDistance = (p: number, q: number): number => {
        const x1 = Math.floor(p / 6), y1 = p % 6;
        const x2 = Math.floor(q / 6), y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    };
    
    const dp: number[][] = new Array(n);
    for (let i = 0; i < n; i++) {
        dp[i] = new Array(26).fill(Math.floor(Number.MAX_SAFE_INTEGER / 2));
    }
    for (let j = 0; j < 26; j++) {
        dp[0][j] = 0;
    }
    
    for (let i = 1; i < n; i++) {
        const cur = word.charCodeAt(i) - 65;
        const prev = word.charCodeAt(i - 1) - 65;
        const d = getDistance(prev, cur);
        
        for (let j = 0; j < 26; j++) {
            dp[i][j] = Math.min(dp[i][j], dp[i - 1][j] + d);
            
            if (prev === j) {
                for (let k = 0; k < 26; k++) {
                    const d0 = getDistance(k, cur);
                    dp[i][j] = Math.min(dp[i][j], dp[i - 1][k] + d0);
                }
            }
        }
    }
    
    let ans = Math.floor(Number.MAX_SAFE_INTEGER / 2);
    for (let j = 0; j < 26; j++) {
        ans = Math.min(ans, dp[n - 1][j]);
    }
    return ans;
}

###Rust

impl Solution {
    fn get_distance(p: i32, q: i32) -> i32 {
        let x1 = p / 6;
        let y1 = p % 6;
        let x2 = q / 6;
        let y2 = q % 6;
        (x1 - x2).abs() + (y1 - y2).abs()
    }

    pub fn minimum_distance(word: String) -> i32 {
        let n = word.len();
        let word_bytes = word.as_bytes();
        let mut dp = vec![vec![i32::MAX >> 1; 26]; n];
        
        for j in 0..26 {
            dp[0][j] = 0;
        }
        
        for i in 1..n {
            let cur = (word_bytes[i] - b'A') as i32;
            let prev = (word_bytes[i - 1] - b'A') as i32;
            let d = Self::get_distance(prev, cur);
            
            for j in 0..26 {
                let j_i32 = j as i32;
                dp[i][j] = dp[i][j].min(dp[i - 1][j].saturating_add(d));
                
                if prev == j_i32 {
                    for k in 0..26 {
                        let d0 = Self::get_distance(k as i32, cur);
                        dp[i][j] = dp[i][j].min(dp[i - 1][k].saturating_add(d0));
                    }
                }
            }
        }
        
        let mut ans = i32::MAX >> 1;
        for j in 0..26 {
            ans = ans.min(dp[n - 1][j]);
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$O(|\Sigma|N)$。

  • 空间复杂度:$O(|\Sigma|N)$。

「清晰&图解」巧妙的动态规划

常规做法

思路

我们将左指和右指所在的键位组成,看成一个状态。每次输入一个字母时,则其中一个手指会进行移动,移动的过程即是状态转移的过程。并且由于字母输入的顺序是固定的,每一个字母都可以看成一个阶段,字母不断输入的过程即是阶段的递增,例如第一个字母为第一个阶段,第二个字母为第二个阶段,后面以此类推。

因此,我们需要一个三维的状态来表示整个动态规划的过程,包括当前考虑的字母下标左指的键位右指的键位

二指组成形成的状态:

image.png

三维状态:

image.png

接下来,让我们思考状态如何进行转移。假设字符串为 CAKE,并且此时阶段为 1,即当前考虑字母是 A。在这个阶段下,左右指会存在一种现象,要么左指为 A ,要么右指为 A,此时才能输入字母 A

对于左指为 A,表示我们通过移动左指来到达这个阶段,而右指是没有移动的。总结来说,这个阶段下,左指会A,右指不变。因此,我们需要遍历上一个阶段左指和右指的所有情况,并且转移到下一个阶段时,只移动左指(dp[1][A][R] = Math.min(dp[1][A][R], dp[0][L][R] + move(L, A)))。

注意观察,如果上一个阶段右指为 R,此时这个阶段右指也必须保持不变,同样为 R

image.png

  • 阶段 1 的右指和阶段 0 的右指键位相同。
  • 阶段 1 的左指键位为 A。

对于右指为 A 的情况同理。

代码

###java

class Solution {
    public int minimumDistance(String word) {
        // 初始化
        int[][][] dp = new int[301][26][26];
        for (int i = 1; i <= 300; i++) {
            for (int j = 0; j < 26; j++) {
                Arrays.fill(dp[i][j], Integer.MAX_VALUE);
            }
        }
        int ans = Integer.MAX_VALUE;
        char[] ca = word.toCharArray();
        // 遍历每个字母
        for (int i = 1; i <= word.length(); i++) {
            int v = ca[i - 1] - 'A';
            // 遍历上一个阶段左指键位
            for (int l = 0; l < 26; l++) {
                // 遍历上一个阶段右指键位
                for (int r = 0; r < 26; r++) {
                    // 判断上一个阶段的状态是否存在
                    if (dp[i - 1][l][r] != Integer.MAX_VALUE) {
                        // 移动左指
                        dp[i][v][r] = Math.min(dp[i][v][r], dp[i - 1][l][r] + help(l, v));
                        // 移动右指
                        dp[i][l][v] = Math.min(dp[i][l][v], dp[i - 1][l][r] + help(r, v));
                    }
                    if (i == word.length()) {
                        ans = Math.min(ans, dp[i][v][r]);
                        ans = Math.min(ans, dp[i][l][v]);
                    }
                }
            }
        }
        return ans;
    }
    // 计算距离
    public int help(int a, int b) {
        int x = a / 6, y = a % 6;
        int x2 = b / 6, y2 = b % 6;
        return (int)(Math.abs(x - x2)) + (int)(Math.abs(y - y2));
    }
}

复杂度分析

  • 时间复杂度:$O(26 * 26 * N)$,其中 N 为字符串 word 的长度。
  • 空间复杂度:$O(26 * 26 * N)$,其中 N 为字符串 word 的长度。

空间优化

思路

由于每个阶段只和上个阶段相关,我们可以使用滚动数组思想,循环利用数组,例如 i % 2 代表当前阶段,(i - 1) % 2 代表上一个阶段

值得注意的是,每次我们计算出新数组后dp[i % 2],需要重新初始化另外一个数组dp[(i - 1) % 2],读者可尝试注释相关代码, 观察结果。

代码

###java

class Solution {
    public int minimumDistance(String word) {
        // 初始化
        int[][][] dp = new int[2][26][26];
        for (int i = 0; i < 26; i++) {
            Arrays.fill(dp[1][i], Integer.MAX_VALUE);
        }
        int ans = Integer.MAX_VALUE;
        char[] ca = word.toCharArray();
        // 遍历每个字母
        for (int i = 1; i <= word.length(); i++) {
            int v = ca[i - 1] - 'A';
            // 遍历上一个阶段左指键位
            for (int l = 0; l < 26; l++) {
                // 遍历上一个阶段右指键位
                for (int r = 0; r < 26; r++) {
                    // 判断上一个阶段的状态是否存在
                    if (dp[(i - 1) % 2][l][r] == Integer.MAX_VALUE) {
                        continue;
                    }
                    if (dp[(i - 1) % 2][l][r] != Integer.MAX_VALUE) {
                        // 移动左指
                        dp[i % 2][v][r] = Math.min(dp[i % 2][v][r], dp[(i - 1) % 2][l][r] + help(l, v));
                        // 移动右指
                        dp[i % 2][l][v] = Math.min(dp[i % 2][l][v], dp[(i - 1) % 2][l][r] + help(r, v));
                    }
                    if (i == word.length()) {
                        ans = Math.min(ans, dp[i % 2][v][r]);
                        ans = Math.min(ans, dp[i % 2][l][v]);
                    }
                }
            }
            // 重新初始化另外一个数组
            for (int l = 0; l < 26; l++) {
                for (int r = 0; r < 26; r++) {
                    dp[(i - 1) % 2][l][r] = Integer.MAX_VALUE;
                }
            }

        }
        return ans;
    }
    // 计算距离
    public int help(int a, int b) {
        int x = a / 6, y = a % 6;
        int x2 = b / 6, y2 = b % 6;
        return (int)(Math.abs(x - x2)) + (int)(Math.abs(y - y2));
    }
}

复杂度分析

  • 时间复杂度:$O(26 * 26 * N)$,其中 N 为字符串 word 的长度。
  • 空间复杂度:$O(26 * 26 * 2)$

时间优化

思路

我们再重新观察一下这三个维度信息,分别是:字母下标左指的键位右指的键位。由于每次需要按下一个字母,左指键位或者右指键位必然有一个是这个字母的键位,因此字母下标也隐含着一个指头的键位信息,使用三个维度显然会有冗余,我们可以重新设计一种新的状态:字母下标(可以代表第一个指头键位),另外一个指头的键位

每次按下一个字母时,要么是字母下标所在的指头(第一个指头)移动,要么是另外一个指头移动。

第一个指头移动的状态转移图如下:

image.png

  • 状态 1 的另外一个指头键位等于状态 0 另外一个指头键位
  • dp[1][r] = Math.min(dp[1][r], dp[0][r] + move(word[0], word[1]))

另外一个指头移动的状态转移图如下:

image.png

  • 注意两个指头顺序交换,第一个指头变成另外一个指头,另外一个指头变成第一个指头。
  • 状态 1 的另外一个指头键位等于状态 0 第一个指头键位
  • dp[1][word[0]] = Math.min(dp[1][word[0]], dp[0][r] + move(r, word[1]))

代码

###java

class Solution {
    public int minimumDistance(String word) {
        // 初始化
        int len = word.length();
        int ans = Integer.MAX_VALUE;
        char[] ca = word.toCharArray();
        // 第一个字母的初始值为 0,从第二个字母开始考虑。
        int[][] dp = new int[2][26];
        Arrays.fill(dp[1], Integer.MAX_VALUE);
        
        // 遍历每个字母
        for (int i = 2; i <= word.length(); i++) {
            int v = ca[i - 1] - 'A';
            // 遍历上一个阶段键位
            for (int j = 0; j < 26; j++) {
                if (dp[i % 2][j] == Integer.MAX_VALUE) {
                    continue;
                }
                int preV = ca[i - 2] - 'A';
                dp[(i + 1) % 2][j] = Math.min(dp[(i + 1) % 2][j], dp[i % 2][j] + help(preV, v));
                dp[(i + 1) % 2][preV] = Math.min(dp[(i + 1) % 2][preV], dp[i % 2][j] + help(j, v));
                if (i == word.length()) {
                    ans = Math.min(ans, dp[(i + 1) % 2][j]);
                    ans = Math.min(ans, dp[(i + 1) % 2][preV]);
                }
            }
            Arrays.fill(dp[i % 2], Integer.MAX_VALUE);
        }
        return ans;
    }
    // 计算距离
    public int help(int a, int b) {
        int x = a / 6, y = a % 6;
        int x2 = b / 6, y2 = b % 6;
        return (int)(Math.abs(x - x2)) + (int)(Math.abs(y - y2));
    }
}

复杂度分析

  • 时间复杂度:$O(26 * N)$,其中 N 为字符串 word 的长度。
  • 空间复杂度:$O(26 * 2)$

 


如果该题解对你有帮助,点个赞再走呗~

构建无障碍组件之Meter Pattern

Meter Pattern 详解:构建无障碍计量器组件

Meter(计量器)是一种图形化显示数值的组件,用于展示在特定范围内变化的数值。本文基于 W3C WAI-ARIA Meter Pattern 规范,详解如何构建无障碍的 Meter 组件。

一、Meter 的定义与核心概念

1.1 什么是 Meter

Meter 是一种图形化显示数值的组件,具有以下特征:

  • 显示一个在定义范围内变化的数值
  • 通常以视觉形式呈现(如进度条、仪表盘、电池图标等)
  • 数值有明确的最小值最大值限制

1.2 Meter vs Progressbar

Meter 和 Progressbar 容易混淆,但它们有明确的区别:

特性 Meter Progressbar
用途 显示当前状态值(如电量、油量) 显示任务进度(如加载中、完成百分比)
数值变化 随时间自然变化 随任务推进单向增长
典型场景 电池电量、磁盘使用率、温度 文件上传、表单提交、安装进度

重要提示

  • Meter 不应用于表示进度(如加载或任务完成百分比)
  • Meter 不适用于没有明确最大值的情况(如世界人口数量)

1.3 核心术语

术语 说明
Value 计量器当前显示的数值
Minimum Value 计量器的最小值(aria-valuemin
Maximum Value 计量器的最大值(aria-valuemax
Current Value 当前值(aria-valuenow
┌─────────────────────────────────────────────────────────────┐
│                      Meter Container                        │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │████████████████████████████████░░░░░░░░░░░░░░░░░░░░░│    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│   0%          25%          50%          75%        100%     │
│   ↑                                                  ↑      │
│  Minimum                                        Maximum     │
│(aria-valuemin)                              (aria-valuemax) │
│                                                             │
│                         Current: 60%                        │
│                       (aria-valuenow)                       │
└─────────────────────────────────────────────────────────────┘

二、HTML <meter> 标签 vs ARIA role="meter"

在实现 Meter 组件时,我们有两种选择:原生 HTML <meter> 标签ARIA role="meter"

2.1 两种方式对比

特性 HTML <meter> ARIA role="meter"
本质 原生 HTML5 语义化标签 ARIA 角色属性
浏览器支持 现代浏览器原生支持 所有支持 ARIA 的浏览器
可定制性 样式受限(浏览器控制) 完全可定制
代码简洁度 简洁,内置语义 需要显式声明 ARIA 属性
辅助技术识别 自动识别为 meter 通过 role 识别为 meter

2.2 使用 HTML <meter> 标签(推荐)

HTML5 提供了原生的 <meter> 标签,它自动具有 role="meter" 的语义,无需额外声明:

<meter value="60" min="0" max="100">60%</meter>

<meter> 标签的属性:

属性 说明 示例值
value 当前值 "60"
min 最小值 "0"
max 最大值 "100"
low 低值阈值 "25"
high 高值阈值 "75"
optimum 最佳值 "90"

示例:带颜色区间的电池电量

<meter 
  value="45" 
  min="0" 
  max="100" 
  low="20" 
  high="80" 
  optimum="90"
  aria-label="电池电量">
  45%
</meter>
  • low 以下:浏览器通常显示为红色(危险)
  • lowhigh 之间:黄色(警告)
  • high 以上:绿色(正常)

2.3 使用 ARIA role="meter"

当需要完全自定义样式(如电池图标、仪表盘、信号格等)时,使用 ARIA 方式:

<div role="meter" aria-label="电池电量" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100">
  <!-- 自定义视觉表现 -->
</div>

2.4 如何选择?

优先使用 <meter> 标签:

  • 简单的进度条场景
  • 不需要复杂自定义样式
  • 追求代码简洁性

使用 ARIA role="meter"

  • 需要自定义视觉样式(电池图标、仪表盘等)
  • 特殊形状或动画效果
  • 需要兼容旧浏览器

2.5 两种方式的等价关系

以下两种实现在辅助技术眼中是等价的

<!-- 方式1:HTML 原生标签 -->
<meter value="60" min="0" max="100" aria-label="电池电量">60%</meter>

<!-- 方式2:ARIA 实现 -->
<div role="meter" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100" aria-label="电池电量">
  60%
</div>

注意<meter> 标签已经内置了 role="meter" 语义,不需要额外添加 role 属性。

三、WAI-ARIA 角色与属性(ARIA 方式)

当使用 <meter> 标签时,以下 ARIA 属性自动处理,无需手动声明:

HTML 属性 对应的 ARIA 属性 说明
value aria-valuenow 自动映射
min aria-valuemin 自动映射
max aria-valuemax 自动映射

当使用 role="meter" 时,需要手动声明以下属性:

3.1 必需属性

Meter 组件需要以下 ARIA 属性:

属性 说明 示例值
aria-valuenow 当前值(必须在 min 和 max 之间) "60"
aria-valuemin 最小值 "0"
aria-valuemax 最大值 "100"
aria-labelaria-labelledby 计量器的可访问标签 "电池电量"
<div
  role="meter"
  aria-label="电池电量"
  aria-valuenow="60"
  aria-valuemin="0"
  aria-valuemax="100">
  <!-- 计量器视觉表现 -->
</div>

3.2 可选属性

aria-valuetext

当仅显示百分比不够友好时,使用 aria-valuetext 提供更友好的值描述:

<div
  role="meter"
  aria-label="电池电量"
  aria-valuenow="50"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="50% (6小时) 剩余">
  <!-- 计量器视觉表现 -->
</div>

辅助技术会读取 aria-valuetext 而不是简单的百分比数值。

3.3 属性关系

aria-valuemin < aria-valuenow < aria-valuemax
      ↑              ↑               ↑
    最小值         当前值          最大值
      0             60              100

约束条件

三、键盘交互规范

Meter 组件没有特定的键盘交互,因为它通常是一个只读组件,用户不能直接操作它。

如果 Meter 是可交互的(如可调节的范围选择器),应该使用 role="slider" 而不是 role="meter"

四、实现方式

4.1 基础 Meter 结构

<div
  class="meter"
  role="meter"
  aria-label="电池电量"
  aria-valuenow="75"
  aria-valuemin="0"
  aria-valuemax="100">
  <div class="meter-bar">
    <div 
      class="meter-fill" 
      style="width: 75%;">
    </div>
  </div>
  <span class="meter-value">75%</span>
</div>

4.2 使用 aria-valuetext 的示例

<div
  class="meter"
  role="meter"
  aria-label="剩余存储空间"
  aria-valuenow="45.5"
  aria-valuemin="0"
  aria-valuemax="128"
  aria-valuetext="45.5 GB 已使用,共 128 GB">
  <div class="meter-bar">
    <div 
      class="meter-fill" 
      style="width: 35.5%;">
    </div>
  </div>
  <span class="meter-value">45.5 GB / 128 GB</span>
</div>

4.3 带颜色状态的 Meter

根据数值范围显示不同颜色(如危险、警告、正常):

<div
  class="meter meter-danger"
  role="meter"
  aria-label="CPU 使用率"
  aria-valuenow="95"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="95%,危险">
  <div class="meter-bar">
    <div 
      class="meter-fill" 
      style="width: 95%;">
    </div>
  </div>
  <span class="meter-value">95%</span>
</div>

五、常见应用场景

5.1 电池电量显示

<div
  role="meter"
  aria-label="电池电量"
  aria-valuenow="45"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="45% (约3小时) 剩余">
  <!-- 电池图标:外框 + 电量填充 -->
  <div class="battery-icon">
    <div class="battery-body">
      <div class="battery-level" style="width: 45%;"></div>
    </div>
    <div class="battery-cap"></div>
  </div>
  <span>45%</span>
</div>

5.2 磁盘使用率

<div
  role="meter"
  aria-label="磁盘使用率"
  aria-valuenow="72"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="72% 已使用 (360 GB / 500 GB)">
  <div class="meter-bar">
    <div style="width: 72%"></div>
  </div>
  <span>72% 已使用</span>
</div>

5.3 温度显示

<div
  role="meter"
  aria-label="CPU 温度"
  aria-valuenow="65"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="65°C">
  <div class="meter-bar">
    <div style="width: 65%"></div>
  </div>
  <span>65°C</span>
</div>

5.4 信号强度

<div
  role="meter"
  aria-label="WiFi 信号强度"
  aria-valuenow="3"
  aria-valuemin="0"
  aria-valuemax="4"
  aria-valuetext="3格信号 (良好)">
  <div class="signal-bars">
    <span class="bar active"></span>
    <span class="bar active"></span>
    <span class="bar active"></span>
    <span class="bar"></span>
  </div>
</div>

六、最佳实践

6.1 正确选择使用场景

使用 Meter

  • 电池电量
  • 磁盘/存储使用率
  • 温度、压力等物理量
  • 信号强度
  • 任何有明确范围的数值

不使用 Meter(改用 Progressbar):

  • 文件上传进度
  • 安装进度
  • 任务完成百分比
  • 任何表示"进度"的场景

不使用 Meter(改用其他组件):

  • 世界人口(无最大值)
  • 可调节的数值(使用 Slider)

6.2 提供清晰的标签

始终为 Meter 提供描述性的标签:

<!-- 好的示例 -->
<div role="meter" aria-label="电池电量">...</div>

<!-- 不好的示例 -->
<div role="meter">...</div>

6.3 使用 aria-valuetext 增强可读性

当纯百分比不够直观时,使用 aria-valuetext

<!-- 好的示例 -->
<div
  role="meter"
  aria-label="电池"
  aria-valuenow="50"
  aria-valuetext="50% (6小时) 剩余">
  ...
</div>

<!-- 不好的示例 -->
<div
  role="meter"
  aria-label="电池"
  aria-valuenow="50">
  ...
</div>

6.4 确保数值在有效范围内

// 确保 aria-valuenow 在有效范围内
function updateMeter(element, value) {
  const min = parseFloat(element.getAttribute('aria-valuemin'));
  const max = parseFloat(element.getAttribute('aria-valuemax'));
  
  // 限制值在范围内
  const clampedValue = Math.max(min, Math.min(max, value));
  
  element.setAttribute('aria-valuenow', clampedValue);
}

6.5 视觉与 ARIA 值保持一致

确保视觉表现和 ARIA 属性值同步更新:

function setMeterValue(element, value) {
  const min = parseFloat(element.getAttribute('aria-valuemin'));
  const max = parseFloat(element.getAttribute('aria-valuemax'));
  
  // 更新 ARIA 值
  element.setAttribute('aria-valuenow', value);
  
  // 更新视觉表现
  const percentage = ((value - min) / (max - min)) * 100;
  const fillElement = element.querySelector('.meter-fill');
  fillElement.style.width = percentage + '%';
  
  // 更新文本
  const valueElement = element.querySelector('.meter-value');
  valueElement.textContent = Math.round(percentage) + '%';
}

6.6 考虑颜色对比度

确保 Meter 的不同状态颜色具有足够的对比度:

.meter-fill {
  background-color: #3b82f6; /* 蓝色 - 正常 */
}

.meter-warning .meter-fill {
  background-color: #f59e0b; /* 黄色 - 警告 */
}

.meter-danger .meter-fill {
  background-color: #ef4444; /* 红色 - 危险 */
}

七、总结

Meter 组件虽然简单,但正确使用 ARIA 属性对于无障碍体验至关重要:

  1. 使用正确的 rolerole="meter" 用于显示范围内的数值
  2. 设置必需的属性aria-valuenow aria-valuemin aria-valuemax
  3. 提供清晰的标签:使用 aria-labelaria-labelledby
  4. 增强可读性:使用 aria-valuetext 提供更友好的值描述
  5. 区分使用场景:Meter vs Progressbar vs Slider

遵循 W3C Meter Pattern 规范,我们能够创建既美观又无障碍的计量器组件,为所有用户提供清晰的状态信息。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

【节点】[Divide节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

Divide节点的核心地位

在Unity URP(通用渲染管线)的ShaderGraph系统中,Divide节点作为数学运算的核心模块,其功能远不止于简单的数值除法。它采用逐元素运算机制,能够处理标量、向量和矩阵等多种数据类型,在材质动态控制、特效实现与性能优化中发挥关键作用。例如,在昼夜交替系统中,Divide节点可通过时间参数驱动场景光照的平滑过渡;在角色受伤特效中,它能精确控制屏幕红色渐变的强度。

Divide节点的功能特性与数据兼容性

基础运算机制

Divide节点执行逐元素除法运算,其输入输出遵循以下规则:

  • 标量运算:当输入为标量时,节点执行数值除法。例如,将基础纹理颜色值除以0.5可提升整体亮度,常用于动态调整材质的明暗表现。
  • 向量运算:支持二维(UV坐标)、三维(RGB颜色)和四维(RGBA颜色)向量运算。例如,UV坐标与旋转矩阵的除法可实现纹理扭曲效果,无需依赖复杂的顶点着色器操作。
  • 矩阵运算:适用于复杂空间变换,如摄像机投影矩阵的除法可优化移动端渲染性能。

输入输出类型与数据兼容性

Divide节点的输入输出类型需严格匹配,以避免运行时错误:

  • 输入类型:支持标量(单值)、向量(多通道)和矩阵(变换数据)。实际应用中,标量常用于控制效果强度(如雾效浓度),向量则处理空间坐标与色彩信息。
  • 输出类型:根据输入自动推断。例如,两个RGB向量相除后,输出仍为RGB向量,但需注意避免除零错误导致的数值溢出。

与其他节点的协同作用

Divide节点常与Multiply、Add等节点配合,构建复杂运算链:

  • 亮度调节:通过标量除法控制材质明暗,再结合Multiply节点实现对比度增强。
  • 纹理混合:将基础纹理与遮罩纹理相除,生成基于像素值的混合效果,适用于UI元素的淡入淡出。
  • 空间变换:UV坐标与旋转矩阵的除法可替代传统顶点着色器操作,显著提升渲染效率。

Divide节点的应用场景与实战案例

场景1:动态材质控制

在昼夜交替系统中,Divide节点通过时间参数驱动场景光照变化:

  1. 时间参数生成:使用Time节点获取游戏时间,并将其转换为0-1范围的标量值。
  2. 光照强度计算:将基础光照颜色除以时间参数,实现从白天到黑夜的平滑过渡。
  3. 材质应用:将计算结果连接至PBR Master节点的BaseColor输入,完成动态光照调整。

场景2:角色受伤特效

当角色生命值低于阈值时,Divide节点可控制屏幕红色渐变的强度:

  1. 生命值映射:将角色当前生命值除以最大生命值,生成0-1范围的标量值。
  2. 颜色混合:将标准红色向量除以生命值标量,实现强度随生命值降低而增强的效果。
  3. 屏幕叠加:使用Screen节点将混合颜色与场景颜色叠加,生成受伤视觉反馈。

场景3:性能优化技巧

在移动端开发中,Divide节点可通过以下方式优化性能:

  • 参数缓存:将重复计算的标量值(如时间参数)存储为变量,避免每帧重新计算。
  • 节点嵌套:将复杂运算链封装为自定义节点,减少图形编辑器中的节点数量。
  • 数据类型匹配:确保输入输出类型一致,避免运行时类型转换开销。

常见问题与解决方案

问题1:除零错误

当除数为零时,Divide节点会返回极大值或NaN,导致材质显示异常。解决方案:

  • 输入验证:在除法前添加条件判断,确保除数不为零。
  • 默认值设置:使用Lerp节点在除数为零时返回默认值,避免数值溢出。

问题2:性能瓶颈

复杂运算链可能导致渲染帧率下降。优化方案:

  • 简化运算:将多级除法合并为单次运算,减少节点连接数。
  • 动态卸载:在非关键帧(如角色静止时)暂停复杂运算,降低CPU负载。

问题3:数据类型不匹配

输入输出类型不一致会导致编译错误。调试方法:

  • 类型检查:在节点属性面板中查看输入输出类型,确保兼容性。
  • 中间转换:使用Vector3ToVector4等节点进行类型转换,避免直接连接不匹配数据。

进阶技巧:Divide节点的高级应用

技巧1:动态纹理扭曲

通过UV坐标与噪声图的除法,实现动态扭曲效果:

  1. 噪声生成:使用Noise节点生成随机噪声图。
  2. 坐标修正:将UV坐标除以噪声图的缩放因子,生成扭曲后的坐标。
  3. 纹理采样:使用SampleTexture2D节点采样扭曲后的坐标,输出最终纹理。

技巧2:法线贴图增强

将法线贴图的RGB值与标量相除,可增强表面细节:

  1. 法线采样:使用SampleTexture2D节点采样法线贴图。
  2. 强度控制:将法线向量除以标量值(如0.5),提升凹凸感。
  3. 光照计算:将增强后的法线连接至PBR Master节点的Normal输入,优化光照效果。

技巧3:粒子系统优化

在粒子特效中,Divide节点可控制粒子大小与速度:

  1. 生命周期映射:将粒子当前生命周期除以最大生命周期,生成0-1范围的标量。
  2. 大小调整:将基础粒子大小除以生命周期标量,实现粒子随年龄缩小。
  3. 速度控制:将粒子速度向量除以生命周期标量,模拟重力衰减效果。

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Markdown 里写公式,别只知道 LaTeX!试试 HTML 标签,简单到飞起

家人们,谁懂啊!每次在 Markdown 笔记里遇到数学公式,虽然知道 LaTeX 语法很强大,但就是写个小小的下标或者上标,都要去查 _{} 或者 ^{},写完还要前后加 $,效率低到离谱。😫

直到今天,我突然发现——HTML 的 <sub><sup> 标签,在 Markdown 里竟然可以直接用!

今天就来跟家人们分享一下这个超简单的小技巧,让你的公式输入速度快到飞起!🚀

一、痛点场景:我只是想写个 H₂O 而已

举个栗子,如果你在 Markdown 里用 LaTeX 写水分子式:

$$H_{2}O$

是不是觉得有点麻烦?既要记忆语法,又要多敲好几个字符。万一公式多了,整个文档的阅读体验在源码模式下简直是噩梦……

二、救星登场:<sub><sup> 标签

1. <sub> 下标标签

<sub> 标签定义下标文本。比如你想写水的化学式 H₂O,只需:

H<sub>2</sub>O

渲染效果:H2O

2. <sup> 上标标签

<sup> 标签定义上标文本。比如你想写勾股定理 a² + b² = c²

a<sup>2</sup> + b<sup>2</sup> = c<sup>2</sup>

渲染效果:a2 + b2 = c2

是不是简单粗暴?根本不需要记忆任何 LaTeX 指令!并且所有markdown 编译器均支持

三、更多实用场景展示

场景 写法 显示效果
摄氏度 35<sup>。</sup>C 35。C
版权符号 Copyright<sup>©</sup> 2025 Copyright© 2025
数学指数 2<sup>n</sup> 2n
同位素 <sup>14</sup>C 14C
脚注参考 这是一句话<sup>[1]</sup> 这是一句话[1]

四、优点总结

  1. 零学习成本:只要你懂一点点 HTML,立刻上手。
  2. 跨平台兼容:几乎所有支持 Markdown 的编辑器(Typora、VS Code、Obsidian、Notion)都完美支持内嵌 HTML 标签。
  3. 代码可读性高:相比 LaTeX 的花括号,标签语义更清晰。
  4. 适合轻量场景:当你不需要复杂的矩阵、积分时,用标签快得多。

五、什么时候还是要用 LaTeX?

虽然 <sub><sup> 很好用,但如果遇到以下情况,还是得老老实实写 LaTeX

  • 复杂的分数:$\frac{1}{2}$
  • 根号:$\sqrt{2}$
  • 求和、积分符号
  • 矩阵排版

结论: 简单上下标用 HTML 标签,复杂公式再用 LaTeX,两者结合,效率最高!

写在最后

有时候我们总想找复杂的插件、学复杂的语法来解决一个问题,殊不知最简单的 HTML 原生能力就藏在 Markdown 的底层支持里。

希望这个小分享能让家人们的笔记更清爽、打字更快!如果你也有类似的 Markdown 偷懒小技巧,欢迎在评论区分享哦~ 👇

Markdown 虽小,技巧不少,我们一起探索! 🎉

nslookup Cheatsheet

Basic Syntax

Core nslookup command forms.

Command Description
nslookup example.com Look up a domain using the default resolver
nslookup example.com 8.8.8.8 Query a specific DNS server
nslookup -type=mx example.com Query a specific record type
nslookup 192.0.2.1 Run a reverse DNS lookup
nslookup Start interactive mode

Common Lookups

Quick checks for hostnames and addresses.

Command Description
nslookup example.com Look up A and AAAA records using the default resolver
nslookup www.example.com Check a hostname or subdomain
nslookup localhost Verify local name resolution
nslookup 127.0.0.1 Reverse lookup for the local loopback address
nslookup 192.0.2.1 Reverse lookup for a public IP address

Record Types

Use -type to query specific DNS records.

Command Description
nslookup -type=a example.com Query IPv4 address records
nslookup -type=aaaa example.com Query IPv6 address records
nslookup -type=mx example.com Query mail exchanger records
nslookup -type=ns example.com Query authoritative name servers
nslookup -type=txt example.com Query TXT records
nslookup -type=soa example.com Query the SOA record
nslookup -type=cname www.example.com Check whether a hostname is an alias
nslookup -type=any example.com Run an ANY query

Specific DNS Servers

Compare answers from different resolvers.

Command Description
nslookup example.com 8.8.8.8 Query Google Public DNS
nslookup example.com 1.1.1.1 Query Cloudflare DNS
nslookup example.com 9.9.9.9 Query Quad9
nslookup -type=mx example.com 8.8.8.8 Query MX records from a specific resolver
nslookup -type=txt example.com 1.1.1.1 Compare TXT answers between resolvers

Interactive Mode

Run multiple queries in one session.

Command Description
nslookup Open interactive mode
set type=mx Switch the active query type to MX
set type=txt Switch the active query type to TXT
server 8.8.8.8 Change the active DNS server
example.com Query a domain after entering interactive mode
exit Leave the interactive session

Troubleshooting

Quick checks for common nslookup errors.

Issue Check
NXDOMAIN Verify the domain name and make sure it exists
SERVFAIL Try another resolver such as 8.8.8.8 or 1.1.1.1
Connection timed out; no servers could be reached Check network access and verify /etc/resolv.conf
Non-authoritative answer Normal cached response from a resolver
No answer The queried record type is not set for that name

Related Guides

Use these guides for fuller DNS troubleshooting workflows.

Guide Description
nslookup Command in Linux Full nslookup guide with practical examples
How to Use the dig Command to Query DNS in Linux Detailed dig guide for deeper DNS debugging
ping Cheatsheet Quick connectivity checks before DNS troubleshooting
IP command cheatsheet Inspect interfaces, addresses, and routes
curl Cheatsheet Test HTTP reachability after DNS resolution succeeds

nslookup Command in Linux: Query DNS Records

When a website does not load or email stops arriving, the first thing to check is whether the domain resolves to the correct address. The nslookup command is a quick way to query DNS servers and inspect the records behind a domain name.

nslookup ships with most Linux distributions and works on macOS and Windows as well. It supports both one-off queries from the command line and an interactive mode for running multiple lookups in a row.

This guide explains how to use nslookup with practical examples covering record types, reverse lookups, and troubleshooting.

Syntax

txt
nslookup [OPTIONS] [NAME] [SERVER]
  • NAME — The domain name or IP address to look up.
  • SERVER — The DNS server to query. If omitted, nslookup uses the server configured in /etc/resolv.conf.
  • OPTIONS — Query options such as -type=MX or -debug.

When called without arguments, nslookup starts in interactive mode.

Installing nslookup

On most distributions nslookup is already installed. To check, run:

Terminal
nslookup -version

If the command is not found, install it using your distribution’s package manager.

Install nslookup on Ubuntu, Debian, and Derivatives

Terminal
sudo apt update && sudo apt install dnsutils

Install nslookup on Fedora, RHEL, and Derivatives

Terminal
sudo dnf install bind-utils

Install nslookup on Arch Linux

Terminal
sudo pacman -S bind

The nslookup command is bundled with the same packages that provide dig .

Look Up a Domain Name

The simplest use is passing a domain name as an argument:

Terminal
nslookup linux.org
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: linux.org
Address: 104.26.14.72
Name: linux.org
Address: 104.26.15.72
Name: linux.org
Address: 172.67.73.26

The first two lines show the DNS server that answered the query. Everything under “Non-authoritative answer” is the actual result. In this case, linux.org resolves to three IPv4 addresses.

“Non-authoritative” means the answer came from a resolver’s cache rather than directly from the domain’s authoritative name server.

Query a Specific DNS Server

By default, nslookup queries the resolver configured in /etc/resolv.conf. To query a different server, add it as the last argument.

For example, to query Google’s public DNS:

Terminal
nslookup linux.org 8.8.8.8
output
Server: 8.8.8.8
Address: 8.8.8.8#53
Non-authoritative answer:
Name: linux.org
Address: 104.26.14.72
Name: linux.org
Address: 104.26.15.72
Name: linux.org
Address: 172.67.73.26

This is useful when you want to compare results across different resolvers or verify whether a DNS change has propagated to public servers.

Query Record Types

By default, nslookup returns A (IPv4 address) records. Use the -type option to query other record types.

MX Records (Mail Servers)

MX records identify the mail servers responsible for receiving email for a domain:

Terminal
nslookup -type=mx google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com mail exchanger = 10 smtp.google.com.

The number before the mail server hostname is the priority. A lower number means higher priority.

NS Records (Name Servers)

NS records show which name servers are authoritative for a domain:

Terminal
nslookup -type=ns google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com nameserver = ns1.google.com.
google.com nameserver = ns2.google.com.
google.com nameserver = ns3.google.com.
google.com nameserver = ns4.google.com.

TXT Records

TXT records store arbitrary text data, commonly used for SPF, DKIM, and domain ownership verification:

Terminal
nslookup -type=txt google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com text = "v=spf1 include:_spf.google.com ~all"
google.com text = "facebook-domain-verification=22rm551cu4k0ab0bxsw536tlds4h95"
google.com text = "docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e"

The output may include many entries. The example above shows a subset of the TXT records returned for google.com.

AAAA Records (IPv6)

AAAA records return the IPv6 address of a domain:

Terminal
nslookup -type=aaaa google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: google.com
Address: 2a00:1450:4017:818::200e

SOA Record (Start of Authority)

The SOA record contains administrative information about the domain, including the primary name server, the responsible email address, and timing parameters for zone transfers:

Terminal
nslookup -type=soa google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com
origin = ns1.google.com
mail addr = dns-admin.google.com
serial = 897592583
refresh = 900
retry = 900
expire = 1800
minimum = 60

The serial number increments each time the zone is updated. DNS secondaries use it to decide whether they need a zone transfer.

CNAME Records

CNAME records point one domain name to another:

Terminal
nslookup -type=cname www.github.com

If a CNAME record exists, the output shows the canonical name the alias points to. If the domain does not have a CNAME record, nslookup returns No answer.

Run an ANY Query

To ask the DNS server for an ANY response, use -type=any:

Terminal
nslookup -type=any google.com

ANY queries do not reliably return every record type for a domain. Many DNS servers return only a subset of records or refuse the query entirely.

Reverse DNS Lookup

A reverse lookup finds the hostname associated with an IP address. Pass an IP address instead of a domain name:

Terminal
nslookup 208.118.235.148
output
148.235.118.208.in-addr.arpa name = ip-208-118-235-148.twdx.net.

Reverse lookups query PTR records. They are useful for verifying that an IP address maps back to the expected hostname, which matters for mail server configuration and security checks.

Interactive Mode

Running nslookup without arguments starts an interactive session where you can run multiple queries without retyping the command:

Terminal
nslookup
output
>

At the > prompt, type a domain name to look it up:

output
> linux.org
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: linux.org
Address: 104.26.14.72
Name: linux.org
Address: 104.26.15.72

You can change query settings during the session with the set command. For example, to switch to MX record lookups and then query a domain:

output
> set type=mx
> google.com
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com mail exchanger = 10 smtp.google.com.

To change the DNS server:

output
> server 8.8.8.8
Default server: 8.8.8.8
Address: 8.8.8.8#53

Type exit to leave interactive mode.

Interactive mode is convenient when you need to test several domains or record types in a row without running separate commands each time.

Debugging DNS Issues

The -debug option shows the full query and response details, including TTL values and additional sections that nslookup normally hides:

Terminal
nslookup -debug linux.org

The debug output is verbose, but it is helpful when you need to see TTL values, check whether answers are authoritative, or trace unexpected behavior.

nslookup vs dig

Both nslookup and dig query DNS servers, but they differ in output and capabilities:

  • nslookup produces simpler, more readable output. It also has an interactive mode that is convenient for quick checks.
  • dig provides detailed, structured output with sections (QUESTION, ANSWER, AUTHORITY, ADDITIONAL) and supports advanced options like +trace for tracing the full resolution path and +dnssec for verifying DNSSEC signatures.

For quick lookups and basic troubleshooting, nslookup is often faster to type and read. For in-depth DNS debugging, dig gives you more control and detail.

Troubleshooting

nslookup returns NXDOMAIN
The domain does not exist or is misspelled. Verify the domain name and check that it is registered.

nslookup returns SERVFAIL
The DNS server could not process the query. Try a different resolver to isolate the problem:

Terminal
nslookup linux.org 1.1.1.1

If public resolvers return the correct answer, the issue is with your configured resolver.

Connection timed out; no servers could be reached
This means nslookup could not contact the DNS server. Check your network connection and verify that /etc/resolv.conf contains a reachable name server. A firewall may also be blocking outbound DNS traffic on port 53.

Non-authoritative answer appears on every query
This is normal. It means the answer came from a resolver’s cache, not directly from the domain’s authoritative server. The result is still valid.

Quick Reference

For a printable quick reference, see the nslookup cheatsheet .

Task Command
Look up a domain nslookup example.com
Query a specific DNS server nslookup example.com 8.8.8.8
Query MX records nslookup -type=mx example.com
Query NS records nslookup -type=ns example.com
Query TXT records nslookup -type=txt example.com
Query AAAA (IPv6) records nslookup -type=aaaa example.com
Query SOA record nslookup -type=soa example.com
Query CNAME record nslookup -type=cname example.com
Run an ANY query nslookup -type=any example.com
Reverse DNS lookup nslookup 192.0.2.1
Start interactive mode nslookup
Enable debug output nslookup -debug example.com

FAQ

Can I use nslookup to check DNS propagation?
Yes. Query the same domain against several public DNS servers and compare the results. For example, run nslookup example.com 8.8.8.8, nslookup example.com 1.1.1.1, and nslookup example.com 9.9.9.9. If the answers differ, the change has not fully propagated.

Is nslookup deprecated?
The ISC (the organization behind BIND) once marked nslookup as deprecated in favor of dig, but later reversed that decision. nslookup is actively maintained and included in current BIND releases. It remains a practical tool for quick DNS lookups.

What does “Non-authoritative answer” mean?
It means the response came from a caching resolver, not from one of the domain’s authoritative name servers. The data is still accurate, but it may be slightly behind if a DNS change was made very recently and the cache has not expired yet.

Conclusion

The nslookup command is a quick way to query DNS records from the command line. Use -type to look up MX, NS, TXT, AAAA, and other record types, and pass a server argument to test against a specific resolver. For deeper DNS debugging, pair it with dig .

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

背景

上个月,我接手了一个DeFi收益聚合器项目的前端开发。产品经理提了一个需求:要在仪表盘首页展示用户可能感兴趣的几个热门Uniswap V3流动性池的实时数据,包括24小时交易量、总流动性和当前手续费率。

我的第一反应是:“简单,直接用 ethers.jsviem 去读合约的 public 变量和事件不就行了?” 于是,我吭哧吭哧写了段代码,通过 useEffect 轮询调用池子合约的 slot0 函数获取当前价格,再通过 provider.getLogs 拉取最近24小时的 Swap 事件来计算交易量。本地测试时,面对一个池子还好。一上线,用户钱包里要是多几个池子,页面直接卡死,RPC调用次数爆炸,速度慢得让人想砸键盘。我意识到,对于这种需要聚合和分析历史链上数据的场景,直接与节点交互是条死路。这时,我想起了那个听过很多次但一直没亲手用过的工具——The Graph。

问题分析

The Graph 的核心是一个去中心化的索引协议,它监听区块链事件,将数据按照定义好的模式(Subgraph)处理后存入可高效查询的数据库。对于前端来说,我们不用再关心如何从海量事件日志里筛选和计算,只需要像调用API一样,用GraphQL查询语句去获取已经处理好的结构化数据。

我的需求很明确:查询Uniswap V3在以太坊主网上特定池子的聚合数据。理论上,我不需要自己部署Subgraph,因为Uniswap官方已经维护了一个非常完善的 Uniswap V3 Subgraph。我的任务就是在前端React应用中,学会如何与这个已部署的Subgraph进行交互。

最初的尝试是直接用 fetchaxios 向Subgraph的GraphQL端点发送POST请求。这确实能跑通,但很快遇到了问题:1. 需要手动管理查询字符串和变量,容易出错;2. 缺乏类型安全,返回的数据结构全靠猜;3. 没有内置的请求状态(loading, error)管理,需要自己用useState和useEffect封装,很繁琐。我需要一个更“React”的、类型友好的解决方案。

核心实现

第一步:环境搭建与GraphQL客户端选择

首先,我创建了一个新的React + TypeScript项目(或者在你的现有项目中操作)。关键的依赖是 @apollo/clientgraphql。Apollo Client 是一个强大的GraphQL状态管理库,它提供了React Hook(如 useQuery)、缓存、错误处理等开箱即用的功能,能极大简化前端与The Graph的交互。

npm install @apollo/client graphql

接下来,我需要初始化Apollo Client,并配置其连接到Uniswap V3的Hosted Service端点。

这里有个坑:The Graph的Hosted Service端点URL结构是 https://api.thegraph.com/subgraphs/name/<用户名>/<子图名称>。对于Uniswap V3以太坊主网,用户名是 uniswap,子图名称是 uniswap-v3。千万别去官方文档里找“API Key”,Hosted Service在查询限额内是免费的,直接使用即可。

我创建了一个文件 lib/apolloClient.ts 来配置客户端:

// lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

// Uniswap V3 以太坊主网 Subgraph 端点
const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: UNISWAP_V3_GRAPH_ENDPOINT,
});

// 创建 Apollo Client 实例
// 注意:默认缓存策略可能不适合实时性极高的数据,对于交易量等数据可以考虑调整fetchPolicy
export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network', // 优先返回缓存,同时在后台更新
    },
    query: {
      fetchPolicy: 'network-only', // 对于主动查询,总是从网络获取
    },
  },
});

第二步:编写GraphQL查询并生成类型

这是核心步骤。我需要去 The Graph Explorer 找到 uniswap/uniswap-v3 子图,研究其数据模式(Schema)。我需要的池子(Pool)数据,在Schema中对应 Pool 实体,里面包含了 id(合约地址)、totalValueLockedUSDvolumeUSDfeesUSDtoken0token1 等字段。

为了获取24小时数据,子图通常会有类似 poolDayData 的时间序列实体。经过探索,我发现查询最近24小时数据的最佳方式是:先查询 Pool 实体本身获取当前快照数据(如TVL),再关联查询其最新的 poolDayData(按日期排序取第一条)来获取过去24小时的交易量和手续费。

我创建了一个GraphQL查询文件 queries/poolData.graphql

# queries/poolData.graphql
query PoolData($poolId: String!) {
  # 查询池子基础信息
  pool(id: $poolId) {
    id
    totalValueLockedUSD
    feeTier
    token0 {
      id
      symbol
      decimals
    }
    token1 {
      id
      symbol
      decimals
    }
    # 关联查询最近的日数据(过去24小时)
    poolDayData(first: 1, orderBy: date, orderDirection: desc) {
      volumeUSD
      feesUSD
      date
    }
  }
}

注意这个细节$poolId 是池子的合约地址,但在The Graph中,id 字段通常是全小写的地址字符串。所以从链上获取的地址,在传入查询变量前最好先 .toLowerCase() 处理一下,避免查不到数据。

接下来,为了让TypeScript认识查询返回的数据结构,我使用GraphQL Code Generator来自动生成类型。这需要额外配置,但一劳永逸。简单起见,我也可以手动定义类型,但对于复杂查询,自动生成更可靠。这里我展示手动定义的方式,更贴近快速上手的场景:

// types/poolData.ts
export interface Token {
  id: string;
  symbol: string;
  decimals: string;
}

export interface PoolDayData {
  volumeUSD: string;
  feesUSD: string;
  date: number;
}

export interface PoolData {
  id: string;
  totalValueLockedUSD: string;
  feeTier: string;
  token0: Token;
  token1: Token;
  poolDayData: PoolDayData[];
}

export interface GraphQLPoolResponse {
  pool: PoolData | null;
}

第三步:创建自定义React Hook

为了让数据获取逻辑可以在组件中优雅复用,我决定将其封装成一个自定义Hook:usePoolData

// hooks/usePoolData.ts
import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';

// 直接在Hook中定义GraphQL查询,避免额外文件
// 注意:gql`...` 是Apollo Client的模板标签函数,用于解析GraphQL查询字符串
const POOL_DATA_QUERY = gql`
  query PoolData($poolId: String!) {
    pool(id: $poolId) {
      id
      totalValueLockedUSD
      feeTier
      token0 {
        id
        symbol
        decimals
      }
      token1 {
        id
        symbol
        decimals
      }
      poolDayData(first: 1, orderBy: date, orderDirection: desc) {
        volumeUSD
        feesUSD
        date
      }
    }
  }
`;

interface UsePoolDataProps {
  poolId: string | undefined; // 池子合约地址
  skip?: boolean; // 是否跳过查询
}

export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
  // 使用 useQuery Hook
  // 它自动处理 loading, error 状态,并返回 data
  const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
    POOL_DATA_QUERY,
    {
      variables: {
        poolId: poolId?.toLowerCase(), // 关键:地址转小写
      },
      skip: !poolId || skip, // 如果没有poolId或主动跳过,则不执行查询
      // fetchPolicy: 'network-only' // 可以根据需要覆盖默认策略
    }
  );

  // 对返回的数据进行简单处理和类型断言
  const poolData = data?.pool;

  return {
    loading,
    error,
    poolData,
    refetch, // 用于手动刷新数据
  };
};

这个Hook的设计非常“React”:它接收依赖项(poolId),管理内部状态,并返回一个清晰的状态对象。在组件中使用时,我可以轻松地根据 loading 显示加载框,根据 error 显示错误信息,用 poolData 渲染UI。

第四步:在组件中集成并使用

最后,我在一个React组件中使用这个Hook。假设我要显示USDC/ETH 0.05%费率的池子(一个非常常见的池)。

// components/PoolCard.tsx
import React from 'react';
import { usePoolData } from '../hooks/usePoolData';

// 已知的 Uniswap V3 USDC/ETH 0.05% 池地址(以太坊主网)
const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';

const PoolCard: React.FC = () => {
  const { loading, error, poolData } = usePoolData({
    poolId: USDC_ETH_POOL_ADDRESS,
  });

  if (loading) {
    return <div className="p-4 border rounded-lg">加载池数据中...</div>;
  }

  if (error) {
    return (
      <div className="p-4 border rounded-lg bg-red-50 text-red-700">
        查询失败: {error.message}
      </div>
    );
  }

  if (!poolData) {
    return <div className="p-4 border rounded-lg">未找到池子数据</div>;
  }

  const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
  const tvl = poolData.totalValueLockedUSD;

  return (
    <div className="p-4 border rounded-lg shadow-sm bg-white">
      <h3 className="font-bold text-lg">
        {poolData.token0.symbol} / {poolData.token1.symbol} Pool
      </h3>
      <p className="text-sm text-gray-500">费率: {Number(poolData.feeTier) / 10000}%</p>
      <div className="mt-3 space-y-2">
        <div>
          <span className="text-gray-600">总锁定价值 (TVL): </span>
          <span className="font-semibold">
            ${Number(tvl).toLocaleString(undefined, { maximumFractionDigits: 0 })}
          </span>
        </div>
        <div>
          <span className="text-gray-600">24小时交易量: </span>
          <span className="font-semibold">
            ${Number(dailyVolume).toLocaleString(undefined, { maximumFractionDigits: 0 })}
          </span>
        </div>
        <div className="text-xs text-gray-400">
          池地址: {poolData.id}
        </div>
      </div>
    </div>
  );
};

export default PoolCard;

至此,一个完整的、从The Graph获取Uniswap V3池数据并展示的前端功能就实现了。代码清晰、类型安全、且易于维护和扩展。

完整代码

以下是关键文件的完整代码汇总,你可以复制到一个新的React + TypeScript项目中运行测试:

1. 安装依赖:

npx create-react-app my-graph-demo --template typescript
cd my-graph-demo
npm install @apollo/client graphql

2. 配置 Apollo Client (src/lib/apolloClient.ts):

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: UNISWAP_V3_GRAPH_ENDPOINT,
});

export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
    query: {
      fetchPolicy: 'network-only',
    },
  },
});

3. 定义类型 (src/types/poolData.ts):

export interface Token {
  id: string;
  symbol: string;
  decimals: string;
}

export interface PoolDayData {
  volumeUSD: string;
  feesUSD: string;
  date: number;
}

export interface PoolData {
  id: string;
  totalValueLockedUSD: string;
  feeTier: string;
  token0: Token;
  token1: Token;
  poolDayData: PoolDayData[];
}

export interface GraphQLPoolResponse {
  pool: PoolData | null;
}

4. 创建自定义Hook (src/hooks/usePoolData.ts):

import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';

const POOL_DATA_QUERY = gql`
  query PoolData($poolId: String!) {
    pool(id: $poolId) {
      id
      totalValueLockedUSD
      feeTier
      token0 {
        id
        symbol
        decimals
      }
      token1 {
        id
        symbol
        decimals
      }
      poolDayData(first: 1, orderBy: date, orderDirection: desc) {
        volumeUSD
        feesUSD
        date
      }
    }
  }
`;

interface UsePoolDataProps {
  poolId: string | undefined;
  skip?: boolean;
}

export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
  const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
    POOL_DATA_QUERY,
    {
      variables: {
        poolId: poolId?.toLowerCase(),
      },
      skip: !poolId || skip,
    }
  );

  const poolData = data?.pool;

  return {
    loading,
    error,
    poolData,
    refetch,
  };
};

5. 创建展示组件 (src/components/PoolCard.tsx):

import React from 'react';
import { usePoolData } from '../hooks/usePoolData';

const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';

const PoolCard: React.FC = () => {
  const { loading, error, poolData } = usePoolData({
    poolId: USDC_ETH_POOL_ADDRESS,
  });

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;
  if (!poolData) return <div>无数据</div>;

  const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
  const tvl = poolData.totalValueLockedUSD;

  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: '8px' }}>
      <h3>{poolData.token0.symbol} / {poolData.token1.symbol} Pool</h3>
      <p>费率: {Number(poolData.feeTier) / 10000}%</p>
      <div>
        <div>TVL: ${Number(tvl).toLocaleString()}</div>
        <div>24h Volume: ${Number(dailyVolume).toLocaleString()}</div>
      </div>
      <small>地址: {poolData.id}</small>
    </div>
  );
};

export default PoolCard;

6. 在应用入口集成 (src/App.tsx):

import React from 'react';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './lib/apolloClient';
import PoolCard from './components/PoolCard';
import './App.css';

function App() {
  return (
    <ApolloProvider client={apolloClient}>
      <div className="App">
        <h1>Uniswap V3 池数据看板 (The Graph)</h1>
        <PoolCard />
        {/* 可以在这里添加更多 PoolCard,传入不同的 poolId */}
      </div>
    </ApolloProvider>
  );
}

export default App;

运行 npm start,你应该能看到一个显示USDC/ETH池数据的卡片。

踩坑记录

  1. “池子找不到 (Pool not found)”:这是我遇到的第一个也是最多人踩的坑。我确认地址没错,但查询返回 null。后来在The Graph的Discord社区提问才知道,Subgraph中存储的地址 id 字段全是小写。而我从链上或Etherscan复制的地址可能是大小写混合的校验和格式。解决方法:在将地址作为变量传入查询前,务必执行 .toLowerCase()

  2. 查询超时或响应慢:第一次查询一个不常被查询的冷门池子时,可能会遇到响应时间较长的情况。这是因为The Graph的索引器需要为这次查询执行索引工作。解决方法:对于用户体验要求高的场景,前端要做好加载状态提示。另外,可以检查Subgraph的健康状态,有时是公共端点负载问题。

  3. 数据类型不匹配:GraphQL查询返回的数字,即使是 BigInt 在Subgraph中,通过API返回时也是字符串格式。直接用于计算会出错。解决方法:在前端使用前,用 Number()parseFloat() 或更适合大数的库如 BigNumber.js (ethers.js自带) 进行转换。我的示例中用了 Number(),对于TVL和交易量这种可能很大的数,在生产环境中建议使用 ethers.BigNumberBigInt 来处理。

  4. “Cannot read property ‘symbol’ of null”:在测试时,我传了一个非Uniswap V3池的地址,查询返回的 pool 不为 null,但内部的 token0token1 可能为 null(如果子图索引不完整)。解决方法:在组件渲染中使用可选链操作符 ?. 或进行严格的空值检查,就像我在示例中处理 poolDayData[0] 一样。

小结

这次实战让我彻底把The Graph从“听说过”变成了“上手用过”。它的核心价值在于将复杂的链上数据索引、聚合工作从前端剥离,让开发者能像查询普通API一样高效获取结构化数据。对于构建数据驱动的DeFi、NFT应用前端,它几乎是必备工具。下一步,我可以探索更复杂的查询(如分页获取多个池子、历史时间序列分析),甚至尝试为自己项目的合约部署一个专属的Subgraph。

工业和信息化部将发布一批“人工智能+”高价值场景

日前在武汉举行的中国人工智能产业发展联盟第十七次全体会议上,工业和信息化部科技司副司长杜广达表示,工业和信息化部将以制造业为主战场、应用牵引为主线,发布一批“人工智能+”高价值场景,探索一批典型应用,建设一批特色智能体,提供一批新型智能终端,研制一批新标准,培育一批产业应用人才,打造一批优质企业,全面推动人工智能与制造业深度融合。(新华社)

为什么有钱人也不买GUCCI了?

曾经一只酒神包便能掀起排队热潮的GUCCI(古驰),如今似乎正通过关店、降价来重新找回市场。

3月底,GUCCI上海环贸iapm旗舰店关闭。此前,该品牌已撤掉上海芮欧百货和新世界大丸百货两家标志性门店——仅两年内一个城市内就关了三家店,关店数量占了总数的三分之一。

但另一边,开进奥莱的GUCCI热闹非凡,成了排队王中的“当红炸子鸡”。只要去奥莱,大部分消费者首奔的店都是GUCCI,因为在那里可以淘到5折甚至2.5折的货品。

GUCCI奥莱门店 | 图 王涵艺摄

正价店卖不动,奥莱店“抢疯”。曾经的顶奢品牌GUCCI,到底怎么了?

GUCCI为何卖不动?

开云集团2025年财报显示,GUCCI营收同比暴跌22%至59.92亿欧元,营业利润腰斩至9.66亿欧元。

这样的下滑势头,已持续近3年。2024年,GUCCI营收已经同比上一年下降了23%;2023年,GUCCI营收也同比单位数下跌。

可供对比的是,在2022年,GUCCI的年销售额曾突破100亿欧元大关。

有人说,这是奢侈品行业的寒流,所有品牌都不好过。

但事实是,同属顶奢阵营的爱马仕、LV、香奈儿虽有业绩波动,却未陷入连续下滑的泥潭,唯独GUCCI一路“跌跌不休”。在四大奢侈品牌中,GUCCI也是唯一开了奥莱折扣店的品牌,仅在国内就曾开过8家。

所以抛开大环境,为什么偏偏GUCCI“命最苦”?

奢侈品专家、要客研究院院长周婷博士指出,风格频繁变动,没有形成稳定、可传承的品牌美学是其一。“古驰在不同设计师带领下风格变化较大,从Alessandro Michele的‘极繁主义’到Sabato De Sarno的‘简约优雅’,缺乏稳定的品牌核心记忆点,导致消费者难以形成持续的品牌认同。”

GUCCI门店陈列的商品 | 图 王涵艺摄

其二是缺乏爆款吸引力。“过去依靠酒神包、老爹鞋等爆款带动增长,但近年新产品缺乏足够创新,难以激发消费者购买欲望,老客户流失,新客户吸引力不足。”周婷称。

时间倒回十年前,GUCCI在2015秋冬秀场上的Dionysus酒神包,2016春夏系列的Sylvie,2016年秋冬女装秀场上的GG Marmont,每一款包几乎都是超级爆款。

GUCCI 2015秋冬Dionysus酒神包 | 图源:GUCCI微信公众号

GUCCI 2016年秋冬GG Marmont | 图源:GUCCI微信公众号

GUCCI的典型消费者云姐就是酒神包的购买者之一,但在那之后,她未曾再购入过GUCCI的包,她认为: “近年来的GUCCI没有话题度,也没有爆品。”

“同时,爆款生命周期短,新品乏力,过度依赖logo与印花,缺乏低调高级的经典款,与顶奢客群‘去logo化’需求相悖也是GUCCI存在的问题。”周婷补充称。

去logo化、极简风的消费趋势,是否真实存在?GUCCI的竞争对手LV用“黑武士”给出了答案。LV于2025年夏季上市的“黑武士”就属于走低调奢华风格路线的单品,它是LV Carryall系列中的全黑款,虽不是官方限量款,却长期处于官网缺货、门店需预订的状态,足以说明它踩中了风口。

此外,服装行业资深顾问、前品牌操盘手顾川告诉有意思报告,GUCCI的定价混乱,也亲手摧毁了品牌溢价。“奥莱渠道的过度使用,导致产品以折扣价大量流通,削弱了专柜产品的稀缺性和高端感,消费者更倾向于在折扣渠道购买,影响品牌整体销售。”

在小红书平台,除了有网友惊叹GUCCI的2.5折高折扣,还有人求助:“专柜买回来的GUCCI外套,还没拆吊牌就进奥莱,损失的8500元该怎么办?”

图源:小红书

“这种渠道管控和定价策略,就是在告诉消费者专柜价是虚高的,帮助他们养成‘非折扣不消费’的习惯。”顾川认为,“频繁折扣又让品牌的高端形象一落千丈,陷入恶性循环。”

当品牌调性被稀释,消费者对GUCCI的“高端”属性就会产生怀疑。在一篇GUCCI打折吐槽帖下方,“买GUCCI只要跨出店门,就立马5折,你敢背一下,再对折”“GUCCI只适合在奥莱蹲”等等都是评论区的高赞留言。

图源:小红书

GUCCI不是没有意识到这些,为实现业绩“止血”,GUCCI采取了一系列自救措施。

GUCCI自救,能行吗?

首先是换人。2025年2月,开云集团宣布结束与创意总监Sabato De Sarno的合作。同年,任命前Balenciaga创意总监德姆纳(Demna Gvasalia)为新任艺术总监。

与此同时,开云集团还宣布了弗朗西丝卡·贝尔莱蒂尼(Francesca Bellettini)为新任GUCCI总裁兼CEO,标志着GUCCI新时代的开启。

其次,除了人事洗牌,GUCCI也在“断臂求生”。2025年,开云集团全球净关店75家,2026年计划再关100家。卢卡·德·梅奥透露:“在75家关闭的门店中,可能有40%都属于GUCCI,奥莱折扣店为主,但这并非全部。”

在业绩交流会上,开云集团CEO卢卡·德·梅奥还表示,亚洲地区GUCCI门店数量已趋于饱和,因此关店计划将重点集中在韩国、日本还有部分中国地区,“如果特别考虑到2026年,我预计40%的关店会发生在亚洲市场”。

截至2月10日,GUCCI的全球门店数是497家。其中,亚太地区(不含日本)共有166家,占比超3成,是GUCCI全球门店数最多的区域。

图源:开云集团官网

此外,降价也成为GUCCI提振业绩的另一重要举措。事实上,自2025年以来,开云集团管理层已多次表态,承认GUCCI此前的定价策略与市场需求存在偏差。“过去几年行业的‘涨价力’(inflationary power)已不复存在,‘奢侈品疲劳’(luxury fatigue)现象已成为行业共识,我们已正视这一市场现实。”

开云集团管理层在业绩交流会上明确表示,品牌部分产品此前的定价确实存在失控问题,经过对产品结构的重新梳理与优化,GUCCI新推出的“La Famiglia”系列已调整定价策略,使其更具市场竞争力,且该系列已获得市场层面的认可。

“La Famiglia”,意大利语意为“家庭”,该系列由新任艺术总监Demna操刀。在这个“家庭”里,除了一个印满logo的行李箱,还有37款造型分别对应37个有着不同个性和态度的角色。从GUCCI释出的2026春夏系列大片中能发现,这一系列更加强调GUCCI旧元素的运用。

GUCCI“La Famiglia”系列 | 图源:小红书@GUCCI

例如,马衔扣不只是点缀式地出现在一双乐福鞋上,更是以极高的出现频率,横跨成衣、裤装、包袋与鞋履多个品类。此外,该系列还重新审视了竹子在GUCCI设计遗产中的角色、聚焦GG字母组合等等。

对此,Demna上任后曾公开表示:“我们正在构思一个愿景来告诉公众,GUCCI的本质是什么?”

GUCCI的马衔扣出现在各种产品上 | 图源:小红书@GUCCI

也就是说,GUCCI正试图通过这些视觉特色来唤醒大家对于品牌的原始记忆。

但这样做,是否有用?

开云集团 CFO Armelle Poulou 透露称,中国客群(Chinese cluster)在第四季度的表现略有改善,同比跌幅收窄至“中双位数”(mid-teens)。 在第三季度业绩交流会上,Armelle Poulou也提到:“手袋品类表现出复苏迹象,在手袋领域,截至2025年9月底,超过60%的手袋销售额来自新品。”

但管理层也承认,尽管新系列备受赞誉,但新创意的商业转化需要时间,GUCCI正处于一个“过渡期”。

“客观来说,GUCCI的自救动作是有效果的,但长期来看,GUCCI要翻身还很难。”周婷指出,“GUCCI此前的一系列操作,影响了高净值人群对其品牌高端性的认知,这种消费偏见短期内已基本形成不可逆态势。”

顾川也认为降价是“饮鸩止渴”,“就像维多利亚的秘密、Alexander Wang、Charles & Keith一样,一旦靠降价走下神坛,就再也回不去了”。

作者:王涵艺

编辑:田纳西

值班编辑:贾诗卉

头图来源:小红书@GUCCI

本文来自微信公众号“有意思报告”,作者:王涵艺,36氪经授权发布。

Vue3 日历组件选型指南:五大主流方案深度解析

在 Vue3 项目开发中,日历组件是日程管理、预约系统、数据可视化等场景的核心组件。不同项目对日历的功能需求差异极大——有的只需基础日期选择,有的需要支持多日程展示、自定义节假日、拖拽调整等复杂功能。本文从「易用性、扩展性、性能」三个维度,深入分析 5 款主流 Vue3 日历组件,并提供选型建议,帮助开发者快速找到适配场景的最佳方案。

一、Vue3 Datepicker:轻量无依赖的基础款

Vue3 Datepicker 是一款纯 Vue3+TypeScript 开发的日历组件,主打轻量与无依赖特性。该组件体积仅约 5KB,却提供了日期范围选择、禁用日期、自定义格式等实用功能。其样式简洁,开发者可通过 CSS 轻松覆盖默认样式,同时完美适配移动端与 PC 端。得益于纯 Vue3 的实现方式,该组件对 Composition API 和 Options API 都有良好的兼容性。

安装命令:

npm install vue3-datepicker --save

使用示例:

<template>
  <div class="basic-calendar">
    <Datepicker
      v-model="selectedDate"
      :disabled-dates="disabledDates"
      format="YYYY-MM-DD"
      placeholder="选择日期"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import Datepicker from 'vue3-datepicker';
import 'vue3-datepicker/dist/index.css';

const selectedDate = ref<Date | null>(null);

const disabledDates = (date: Date) => {
  const day = date.getDay();
  return day === 0 || day === 6;
};
</script>

<style scoped>
.basic-calendar {
  width: 300px;
  margin: 20px;
}
</style>

适用场景:表单中的生日选择、订单日期筛选等轻量级日期选择需求,以及追求极小打包体积的项目。

二、Element Plus Calendar:生态集成的标准化选择

Element Plus Calendar 是饿了么团队出品的企业级日历组件,与 Element Plus 组件库深度集成,视觉风格统一。该组件支持月视图、周视图、日视图三种模式切换,提供日程数据绑定能力,开发者可自定义单元格内容展示。内置国际化功能、日期范围选择、禁用日期等基础能力,并提供完整的 TypeScript 类型定义,可与 Vue3+Vite 开发环境无缝配合。

安装命令:

npm install element-plus --save

使用示例:

<template>
  <div class="el-calendar-demo">
    <el-calendar v-model="currentDate">
      <template #date-cell="{ data }">
        <p :class="data.isSelected ? 'is-selected' : ''">
          {{ data.day.split('-').pop() }}
        </p>
        <span v-if="scheduleMap[data.day]" class="schedule-count">
          {{ scheduleMap[data.day] }}条日程
        </span>
      </template>
    </el-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElCalendar } from 'element-plus';
import 'element-plus/dist/index.css';

const currentDate = ref<Date>(new Date());

const scheduleMap = ref({
  '2026-02-06': 3,
  '2026-02-08': 1,
  '2026-02-10': 2,
});
</script>

<style scoped>
.is-selected {
  color: #409eff;
  font-weight: bold;
}
.schedule-count {
  font-size: 12px;
  color: #f56c6c;
}
</style>

适用场景:使用 Element Plus 组件库的中后台管理系统,需要快速实现标准化日历和日程功能的项目。

三、FullCalendar Vue3:复杂场景的全功能方案

FullCalendar Vue3 是基于业界知名的 FullCalendar 核心库封装的 Vue3 组件,专为复杂日程管理场景设计。该组件支持月视图、周视图、日视图、列表视图、时间轴视图等 10 余种视图类型,提供了日程拖拽、调整时长、重复日程设置、自定义事件渲染等丰富功能。组件兼容 Vue3 的组合式 API,可与 Pinia 或 Vuex 状态管理库无缝集成。此外,还支持 Google 日历和 iCal 导入,具备国际化与时区切换能力。

安装命令:

npm install @fullcalendar/vue3 @fullcalendar/core @fullcalendar/daygrid @fullcalendar/interaction

使用示例:

<template>
  <div class="full-calendar-demo">
    <FullCalendar
      :plugins="calendarPlugins"
      initialView="dayGridMonth"
      :events="calendarEvents"
      editable="true"
      selectable="true"
      @dateClick="handleDateClick"
      @eventClick="handleEventClick"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';

const calendarPlugins = ref([dayGridPlugin, interactionPlugin]);

const calendarEvents = ref([
  { title: '产品评审会', start: '2026-02-06', end: '2026-02-07', color: '#409eff' },
  { title: '版本发布', start: '2026-02-09', color: '#67c23a' },
]);

const handleDateClick = (info: any) => {
  alert(`选择了日期: ${info.dateStr}`);
};

const handleEventClick = (info: any) => {
  alert(`点击了日程: ${info.event.title}`);
};
</script>

<style scoped>
.full-calendar-demo {
  width: 90%;
  margin: 20px auto;
}
</style>

适用场景:企业 OA 系统、会议室预约、课程表管理等复杂日程管理场景,需支持拖拽操作、多视图切换、复杂事件配置的项目。

四、Vant4 Calendar:移动端友好的轻量选择

Vant4 Calendar 是有赞团队出品的移动端日历组件,专为移动端 H5 和小程序场景优化。该组件在交互设计上充分考虑移动端特性,支持滑动切换月份、手势操作等移动端常见交互方式。功能方面支持日期范围选择、快捷日期选择(如近 7 天、近 30 天)、自定义弹窗样式等实用能力。组件体积仅约 8KB,性能表现优异,支持按需引入,与 Vant4 组件库整体风格保持一致。

安装命令:

npm install vant --save

使用示例:

<template>
  <div class="vant-calendar-demo">
    <van-button @click="showCalendar = true">选择日期</van-button>
    <van-calendar
      v-model:show="showCalendar"
      v-model="selectedDate"
      type="range"
      :min-date="minDate"
      :max-date="maxDate"
      @confirm="handleConfirm"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { VanCalendar, VanButton } from 'vant';
import 'vant/lib/index.css';

const showCalendar = ref(false);
const selectedDate = ref<[Date, Date]>([new Date(), new Date()]);
const minDate = ref(new Date('2026-01-01'));
const maxDate = ref(new Date('2026-12-31'));

const handleConfirm = (dates: [Date, Date]) => {
  console.log('选择的日期范围:', dates);
  showCalendar.value = false;
};
</script>

适用场景:移动端 H5 页面、小程序项目,需轻量级、交互友好的日期选择功能。

五、Vue3 Simple Calendar:极简逻辑的定制基石

Vue3 Simple Calendar 是一款独特的日历组件,它不包含任何样式封装,仅提供核心日历逻辑。该组件基于 Vue3 Composition API 开发,体积仅 3KB,没有任何第三方依赖。开发者可以完全自定义 UI 和交互方式,组件只负责处理日历的基本逻辑,如月份切换、日期选中、日期渲染回调等。

安装命令:

npm install vue3-simple-calendar --save

使用示例:

<template>
  <div class="custom-calendar">
    <simple-calendar
      v-model="currentMonth"
      @date-click="handleDateClick"
    >
      <template #header="{ year, month, prevMonth, nextMonth }">
        <div class="calendar-header">
          <button @click="prevMonth">上一月</button>
          <h3>{{ year }}年{{ month }}月</h3>
          <button @click="nextMonth">下一月</button>
        </div>
      </template>
      <template #day="{ date, isToday, isWeekend }">
        <div
          class="day-cell"
          :class="{ today: isToday, weekend: isWeekend, selected: selectedDate === date }"
        >
          {{ date.getDate() }}
        </div>
      </template>
    </simple-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import SimpleCalendar from 'vue3-simple-calendar';

const currentMonth = ref<Date>(new Date());
const selectedDate = ref<Date | null>(null);

const handleDateClick = (date: Date) => {
  selectedDate.value = date;
  console.log('选中日期:', date);
};
</script>

<style scoped>
.custom-calendar {
  width: 350px;
  margin: 20px;
}
.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
}
.day-cell {
  width: 50px;
  height: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.today {
  background-color: #409eff;
  color: white;
  border-radius: 50%;
}
.weekend {
  color: #f56c6c;
}
.selected {
  border: 2px solid #67c23a;
  border-radius: 50%;
}
</style>

适用场景:需要品牌化设计日历 UI、特殊交互效果的项目,或仅需复用核心日历逻辑的定制化开发场景。

六、选型指南与核心原则

面对多种日历组件选择,开发者需要根据项目实际情况做出判断。以下是各组件的核心对比:

组件类型 核心优势 适用场景 打包体积
Vue3 Datepicker 轻量无依赖、易定制 基础日期选择、表单场景 ~5KB
Element Plus Calendar 大厂背书、生态集成 中后台标准化日程功能 ~15KB
FullCalendar Vue3 全功能、复杂交互 企业 OA、预约系统 ~100KB
Vant4 Calendar 移动端适配、交互友好 移动端 H5、小程序 ~8KB
Vue3 Simple Calendar 完全自定义、极简逻辑 个性化 UI、定制化交互 ~3KB

美国土安全部召回员工,下笔薪资无着落

美国国土安全部10日要求所有因“停摆”而无薪休假的员工返岗。尽管美国总统特朗普已签署行政令,从10日开始为国土安全部超过3.5万名员工补发工资,但这可能是他们短期内最后一笔收入,下笔薪资尚无着落。(新华社)

多家快递公司宣布涨价

受地区局势和油价波动影响,不仅航空燃油费在涨,快递费也要涨了。日前,UPS、DHL、联邦快递、顺丰国际等多家国际快递物流企业调整了燃油附加费收取标准,部分企业进行了上调。值得注意的是,不仅是国际快递件,受国际油价飙升与行业政策调整双重影响,国内多地的快递企业也于近期集体增设或者上调了燃油附加费。 (新华日报)

千亿诉讼临近 OpenAI指控马斯克搞“突袭”

在美东时间周五晚间提交的法庭文件中,OpenAI表示,马斯克本周早些时候提出的新诉求,显然意在“刁难被告、扰乱庭审程序,同时试图重塑其就本案对外塑造的舆论形象”。马斯克于2024年起诉OpenAI及微软公司,指控这家ChatGPT开发者在从这家软件巨头获得数十亿美元投资、并计划重组为营利性企业后,背弃了其作为研究机构的创立初衷。该案定于4月27日开庭审理。OpenAI与微软均否认存在不当行为。(新浪财经)

连载05-Claude Skill 不是抄模板:真正管用的 Skill,都是从实战里提炼出来的

别再直接 Fork 别人的 Claude Skill:真正有用的 Skill,都是从项目里长出来的

AI Coding 系列第 05 篇 · 核心工具

封面

我第一次批量导入公开 Skill 模板的时候,是真的以为自己走了捷径。

GitHub 上一堆 star 很高的仓库,code review、需求分析、文档编写、调研、拆任务,看起来什么都有。我当时的想法很直接:既然别人已经把常见工作流整理好了,我直接 fork 一份,全量导入,不就能让 Claude 立刻更稳、更懂项目吗?

结果用了几天,我反而越来越不放心。

不是因为它“明显做错了什么”,而是因为它总在看起来没问题的地方出问题。格式完整,措辞专业,检查项也不少,可真正让我在项目里反复吃亏的那几件事,它一次都没替我盯住。异步链里是不是又漏了 await,这次 migration 有没有回滚方案,新同学是不是又顺手写了 throw new Error(),数据库 schema 改了之后 Prisma 类型是不是也一起更新了。

它会提醒一堆“大家普遍都应该注意”的东西,却不知道“我们团队到底最怕什么”。

后来我才明白,问题不是 Skill 机制不好,而是我导入的根本不是“自己的 Skill”,只是别人整理好的经验。

这些经验当然有价值,但它们解决的是共性问题,不会天然长成你项目里的“肌肉记忆”。

真正有用的 Skill,恰恰应该做一件事:

把那些你本来总要重复提醒、总会漏掉、总会在项目里反复踩坑的动作,固化成默认动作。

也就是一句话:

Skill 的本质,不是收藏经验,而是固化默认动作。

这篇文章会从最基本的边界讲起,一路走到 SKILL.md、源码机制、任务类型和可执行能力。内容不少,但我尽量只保留真正有助于你在项目里把 Skill 用起来的部分。


先说结论

如果你只记住这几条,这篇文章就已经值回时间:

  • 对所有任务都生效的规则,写进 CLAUDE.md;只对某类重复任务生效的,做成 Skill;只对这一次有效的,写进 Prompt
  • 只有“输入相对稳定、输出有模式、而且容易漏步骤”的任务,才值得沉淀为 Skill
  • 通用 Skill 模板只能当原材料,项目级 Skill 必须自己裁剪、自己维护
  • description 不是装饰字段,它承担了触发场景的职责,最好把 Use when... 直接写进去,关键词前置、长度克制
  • Claude 启动时主要只看 frontmatter,Skill 正文在真正触发时才按需载入
  • allowed-tools 是权限边界,不是行为建议;paths 是条件激活,不是说明文字
  • 第一个 Skill 不要挑最关键的任务,先拿中等风险任务练手

一、公开 Skill 模板为什么一开始很香,后来却越用越别扭

我现在反而会对“看起来很全”的公开 Skill 模板保持一点警惕。

不是因为它们没用,而是因为它们太容易制造一种错觉:好像什么都覆盖到了,但真正最重要的东西其实没进去。

公开模板最常见的问题,不是方向错,而是下面这三种。

1. 太宽泛

它什么都管一点,但什么都不够深。

它会告诉你“注意异常处理”“注意性能”“注意安全”,这些当然没错。但这些话本身不构成你项目里的工作流。它不知道你们统一用的是 AppError,不知道你们数据库变更必须检查回滚,也不知道你们哪几个目录历史包袱最重。

2. 太嘈杂

50 行模板里,真正有价值的可能只有 5 行。

剩下的 45 行不是完全没用,而是在和那 5 行争夺 Claude 的注意力。对于 agent 来说,规则不是越多越强。很多时候,8 行写透项目约束的 Skill,比 50 行“样样都提一点”的模板更有用。

3. 太不像你的项目

这点最致命。

公开模板知道“大家普遍应该注意什么”,但不知道“你们团队反复死在哪些地方”。而真正有价值的 Skill,恰恰应该把那些项目特有、团队高频踩坑的东西固化下来。

说得更直白一点:你把一个新同事扔进团队,给他一份行业通用培训材料,当然比什么都不给强;但如果你不告诉他“我们团队最容易出错的是哪三件事”,他依然干不好你最在意的活。

所以正确姿势不是“找一个最全的模板直接用”,而是:

先借鉴,再裁剪,最后只留下真正属于你项目的那几条。

公开模板到项目 Skill 的提炼路径


二、先把 Prompt、CLAUDE.md、Skill 这三件事彻底分清楚

很多人不是不会写 Skill,而是一开始就把这三件事混在一起了。

判断方法其实很简单,只问一个问题:

这个要求的作用范围到底有多大?

  • 这个要求对所有任务都成立吗?如果是,放 CLAUDE.md
  • 这个要求只对某一类任务成立吗?如果是,做成 Skill
  • 这个要求只对这一次成立吗?如果是,写进 Prompt

举几个特别典型的例子:

“所有 throw 必须是 AppError
这是全局规则。不管你是在写新功能、修 bug,还是做重构,都要遵守。它应该进 CLAUDE.md

“代码审查时按固定顺序检查数据库、异步和错误处理”
这只在 code review 这种任务里才触发,它不是全局规则,而是任务模板,所以应该做成 Skill

“这次先只分析原因,不要动代码”
这只对当前这次任务有效,应该写进 Prompt

最容易搞混的是 CLAUDE.mdSkill。它们都能约束 Claude 的行为,但本质完全不同:

  • CLAUDE.md 是永远生效的规则
  • Skill 是遇到对应任务才触发的模板

如果要打个比方:

  • CLAUDE.md 是交通规则
  • Skill 是导航路线
  • Prompt 是你这次上车前临时交代的一句话

这三层一旦分清楚,后面 80% 的混乱都会自动消失。

Prompt、CLAUDE.md、Skill 的边界图

一个常见误判:很多问题根本不需要写 Skill

我后来发现,很多人想写 Skill,并不是因为真的存在一个稳定、重复、值得沉淀的任务,而是因为这一次和 Claude 协作得不顺

比如目标没说清,边界没收紧,上下文没给够,或者你真正缺的是一条全局规则,却误以为自己需要一份任务模板。这个时候你如果急着把它沉淀成 Skill,本质上只是把一次性的混乱模板化。

几个很常见的误判场景是:

  • 这次需求本身还在摇摆,连你自己都没想清楚要什么
  • 这个问题只发生过一次,下次未必还会以同样的形状出现
  • 你真正缺的是全局约定,比如错误处理、目录规范、命名规则
  • 你只是想表达“这次先别改代码”“这次先只分析原因”这种一次性约束

写 Skill 之前,先问自己一句话:

这个问题下次还会以差不多的形状再来一次吗?如果不会,先别急着写 Skill。


三、什么时候一个任务真的值得被沉淀成 Skill

不是所有重复任务都值得沉淀。

我现在给自己的标准其实很克制,就一句话:

同一类任务做了三次以上,而且每次都要重新给 Claude 解释背景。

反过来说,如果某个任务每次背景和目的都完全不同,就不值得沉淀。比如“写文档”这个动作本身很常见,但公司文档、API 文档、用户手册的写法完全不同,它们应该是三个不同的 Skill,而不是一个叫“写文档”的通用模板。

在真正开始写之前,我会先做三个检查。

1. 输入是否稳定

“根据 Figma 设计稿生成 React 组件”这种任务,输入格式相对稳定,比较适合沉淀。

“根据 SQL 查询结果生成图表”这种任务,每次数据格式和图表类型都可能差很多,Skill 会很难写得稳。

2. 输出是否有共同模式

“写 Pull Request 描述”很适合,因为它天然就有固定框架:改了什么、为什么改、怎么测试。

但“和 AI 讨论技术方案”这种任务,每次深度、重点、结论都不同,就不太适合硬沉淀成一个模板。

3. 有没有容易漏掉的关键步骤

最值得沉淀成 Skill 的任务,通常不是“最复杂”的任务,而是那些不特别提醒就容易漏一步的任务。

Skill 最有价值的地方,不是让 Claude 变得更聪明,而是把你每次最容易忘的检查项,固化成默认动作。

所以一个任务如果同时满足下面三点:

  • 输入相对稳定
  • 输出有共同模式
  • 总有一两步容易漏

它就很值得沉淀成 Skill。

什么任务值得沉淀成 Skill


四、从一个真实痛点开始,走完 Skill 的提炼过程

光讲判断标准还是有点抽象,不如走一遍完整例子。

代码审查,几乎每个后端工程师每周都在做,也是最容易进入“重复解释”困境的任务。用它来走一遍完整的 Skill 提炼过程会很清楚。

你反复踩的坑

假设你们团队每周都做代码审查,而且总在重复盯这几件事:

  • 有人改一个功能,顺手动了三个不相关模块
  • 新同学不知道项目里统一用 AppError,直接 throw new Error()
  • Promise 链里漏了 await
  • 数据库查询没有索引,或者潜在 N+1 没被看出来

这就是非常典型的“该沉淀 Skill 的信号”。

先设计内容,再去想格式

一个好 Skill,先别急着写文件。先把内容层想清楚,只要回答四个问题:

1. 什么时候用

不是写“代码审查”四个字,而是写清楚触发场景。

❌ 代码审查
✅ 当我提交 PR 前,检查我的 TypeScript 后端代码是否符合项目约定

差别在于:模糊的描述会让 Claude 在不该用的时候乱触发,而具体的场景描述更容易精准命中。

2. 按什么顺序做

步骤尽量不要超过五步。

你从公开模板里借灵感,但通用模板有 50 行,而你真正关心的可能只有四件事:改动范围、错误处理、异步操作、数据库查询。

1. 读完整个改动的 diff,确认改动是否只涉及这个 PR 的范围
2. 检查错误处理:所有 throw 都必须是 throw new AppError()
3. 检查异步操作:Promise 链是否有遗漏的 await
4. 检查数据库查询:是否有 N+1 问题,关键查询是否 explain 过

3. 输出长什么样

不要写“请清晰输出”。这种话几乎没有约束力。直接给格式。

🔴 Critical: ...
🟡 Warning: ...
✅ Suggestion: ...
Summary: X critical issues to fix before merge.

4. 什么时候不适用

写清楚边界比写清楚功能更重要。

比如:

  • 不审查 UI 层代码
  • 不关注代码风格
  • 改动超过 500 行先拆 PR

这些“我不做什么”的声明,往往比“我会做什么”更能防止 Claude 越界。

到这里,你脑子里其实已经有一个能用的 Skill 了。下一步只是把它放进 Claude Code 认识的格式里。


五、真正落到 SKILL.md 文件层,哪些字段值得你认真写

一个完整的 SKILL.md,通常会长这样:

---
name: code-review
description: Review TypeScript backend code before merging. Use when asked to review code, check a PR, or verify implementation before committing.
allowed-tools:
  - Read
  - Grep
  - Glob
  - Bash(git diff *)
argument-hint: "[PR 分支名或文件路径]"
arguments:
  - target
---

# Code Review

## 步骤
1. 读取 ${target} 的改动 diff
2. 检查错误处理:所有 throw 必须是 throw new AppError()
3. 检查异步操作:Promise 链是否有遗漏的 await
4. 检查数据库查询:是否有 N+1 问题

## 输出格式
🔴 Critical: ...
🟡 Warning: ...
✅ 通过: ...

这里最值得你认真写的,其实是下面几个字段。

name
名字别太抽象。要让人一眼知道它是做什么的。像 helperutilstools 这种名字几乎没有路由价值,远不如 code-reviewpr-summaryapi-conventions 这种具体命名。

description
这是现在最关键的字段。它不只是“简介”,还承担了“触发场景”的职责。你最好直接把 Use when... 写进去,而不是写一句空话。更重要的是,别把它写成长段说明文。关键词尽量前置,长度最好控制在 250 字符左右,太长往往只会稀释命中信号。官方还特别提醒,description 最好用第三人称去写,像 “Analyzes pull requests...” 这种句式,比 “I can help...” 或 “You can use this...” 更稳。

allowed-tools
它决定这个 Skill 具备哪些能力。这个字段后面我会在源码部分展开讲,因为它比很多人想象的更“硬”。

arguments
让 Skill 接受参数,比如目标文件、目录、分支名。${target} 会在正文里被替换成你传进去的实际值;如果你喜欢按位置拿参数,也可以用 $0$1 这类方式。

还有几个很好用,但不是每次都要上的字段。

argument-hint
告诉调用者这个 Skill 期待什么参数。

model: haiku
简单任务可以指定更轻量的模型,直接省成本。像格式化、重命名、简单改写这类工作,很多时候没必要上更重的模型。

paths
让 Skill 只在某些路径下激活。适合模块边界明确的项目。

context: fork
高风险操作放进独立上下文,避免污染主会话。

disable-model-invocation: true
禁止 Claude 自动触发,只允许你手动 /skill-name 调用。部署、发版、发邮件这类有副作用的 Skill,应该优先考虑加上。

大多数 Skill 根本不需要把字段填满。真正实用的思路不是“功能全”,而是“正好够用”。

如果一个 Skill 只是做常规代码审查,namedescriptionallowed-toolsarguments 往往就够了。只有当你真的遇到参数化、模块隔离、上下文隔离这些需求时,再往上加。

SKILL.md 不是整个 Skill,它只是入口

很多人以为一个 Skill 就是一份 SKILL.md。其实不是。

更实用的做法通常是:把 SKILL.md 控制在足够短、足够清楚的范围里,让它承担“入口”和“调度”职责;真正长的规范、示例、脚本都拆出去。

一个 Skill 目录完全可以长这样:

my-skill/
├── SKILL.md
├── reference.md
├── examples/
│   └── sample.md
└── scripts/
    └── helper.py

这里的关键点不是“可以放很多文件”,而是:这些文件不会自动加载,必须在 SKILL.md 里显式引用。

比如:

## 参考资料
- 完整的 API 规范见 [reference.md](reference.md),需要查接口细节时读它
- 期望的输出格式见 [examples/sample.md](examples/sample.md)

这个设计和前面说的懒加载是同一套思路:不是 Skill 触发时把所有材料都灌进上下文,而是只在真正需要的时候再去读。

所以:

  • reference.md 适合放项目特有知识,比如内部 API 规范、禁用库、架构约定
  • examples/ 适合放期望输出样例,帮助 Claude 对齐格式
  • scripts/ 适合放真正可执行的辅助脚本,让 Skill 不只是“描述怎么做”,还能“先把上下文准备好”

这点很重要,因为它决定了 Skill 的上限不是“几行 prompt”,而是“一个有入口、有知识、有执行能力的局部工作流”。

Skill 放在哪,决定它是谁的能力

这点很容易被忽略,但工程上很重要。

同样是一个 Skill,放在不同位置,意义完全不一样:

  • ~/.claude/skills:你个人所有项目都能用,适合个人长期习惯
  • .claude/skills:只在当前项目生效,适合团队项目约定
  • <plugin>/skills:跟着插件走,适合做模块化分发

如果不同层级里恰好有同名 Skill,优先级也不是平均的。官方规则更接近:企业级配置优先于个人级,个人级优先于项目级;插件 Skill 因为带命名空间,通常不会和前面这些直接撞名。

官方文档里甚至把这件事讲得很直接:Skill 存放的位置,本身就是它的作用域设计。

这背后的工程含义非常大。

如果你把一个强项目耦合的 Skill 放进个人目录,它就会带着这个项目的假设跑到别的仓库里;反过来,如果你把一个本该跨项目复用的通用 Skill 只塞在项目目录里,它的复用价值又被锁死了。

在 monorepo 里,这件事更有意思。Claude Code 会自动发现子目录下的 .claude/skills/。也就是说,你完全可以让 packages/frontend/.claude/skills/ 只服务前端包,让 packages/backend/.claude/skills/ 只服务后端包,而不是把所有知识都堆在仓库根目录。

这时 Skill 就不只是“提示词文件”,而是团队知识的分发机制:

  • 个人层的 Skill,固化的是你的工作习惯
  • 项目层的 Skill,固化的是团队约定
  • 包级 Skill,固化的是模块边界里的局部知识

如果你能把这层想清楚,很多“这个规则到底该放哪”的问题,答案会比只看内容本身更清楚。


六、如果只停在经验层,这篇还差半口气:我后来去翻了源码

前面这些判断,靠经验其实也能总结出来。

但我后来还是不太满足。因为有几个问题如果不看实现,心里总会悬着:

  • description 到底是不是自动触发的关键?
  • allowed-tools 到底只是提示,还是硬限制?
  • paths 到底是真过滤,还是只是写给人看的说明?

我后来去翻了一遍源码,结论是:这些字段比我一开始以为的更“硬”。

1. 为什么触发逻辑主要看 frontmatter,而不是正文

loadSkillsDir.ts 里有一个函数 estimateSkillFrontmatterTokens,注释写得非常直接:

/**
 * Estimates token count for a skill based on frontmatter only
 * (name, description, whenToUse) since full content is only loaded on invocation.
 */
export function estimateSkillFrontmatterTokens(skill: Command): number {
  const frontmatterText = [skill.name, skill.description, skill.whenToUse]
    .filter(Boolean)
    .join(' ')
  return roughTokenCountEstimation(frontmatterText)
}

这段代码背后的意思非常重要。

Claude Code 启动时,主要只把每个 Skill 的 frontmatter 信息算进上下文。Skill 正文不是一开始就全量塞进去,而是在你真正触发它的时候才加载。

这直接解释了两件事。

第一,Claude 不是先把你整篇 Skill 读完再判断要不要触发,它先看的就是前面这几行。换句话说,触发效果主要取决于 frontmatter,不取决于正文写得多漂亮。

第二,Skill 多不等于上下文立刻爆炸,因为启动时压进去的不是全文,而是 frontmatter。

源码里保留了 whenToUse 这个概念,但从现在的文档实践看,推荐做法已经更偏向把触发描述直接写进 description。所以对大多数人来说,最稳的策略不是纠结“要不要额外写一个触发字段”,而是把 description 写得具体、可命中、带触发场景。

比如:

description: Review TypeScript backend code before merging. Use when asked to review code, check a PR, or verify implementation before committing.

比“代码审查 Skill”这种描述强太多了。

源码注释里其实还暗含了一个很实用的提醒:触发描述不是越长越稳。冗长的 whenToUsedescription 不会线性提高命中率,很多时候只是在白白消耗首轮缓存和注意力。所以对这个字段最好的优化,不是“多写一点”,而是“把真正会命中的词放到前面”。

2. 为什么 allowed-tools 不是建议,而是权限边界

这一点是我看源码之后感受最强的一处。

Skill 执行时,getPromptForCommand 会在返回内容之前把 allowedTools 写进工具权限上下文:

getAppState() {
  const appState = toolUseContext.getAppState()
  return {
    ...appState,
    toolPermissionContext: {
      ...appState.toolPermissionContext,
      alwaysAllowRules: {
        ...appState.toolPermissionContext.alwaysAllowRules,
        command: allowedTools,
      },
    },
  }
}

这说明 allowed-tools 不是“提醒 Claude 尽量这样做”,而是权限层的强制限制。

比如一个 code review Skill 只开放 ReadGrepGlobBash(git diff *),那它就不是“理论上不该写文件”,而是从架构上根本没有写文件的能力Bash(git diff *) 这种写法也不是装饰,它真的只允许 git diff 开头的命令,其他 Bash 调用会被挡住。

这让我对 allowed-tools 的理解完全变了。它不是“不信任模型”,而是最小权限设计。就像你给数据库只读账号只开 SELECT 权限,不是因为你怀疑这账号会作恶,而是因为这个任务本来就不该拥有写权限。

3. 为什么 paths 不是说明文字,而是条件激活机制

源码里,带 paths 的 Skill 在加载时会被单独分流到一个 conditionalSkills Map:

// Separate conditional skills (with paths frontmatter) from unconditional ones
for (const skill of deduplicatedSkills) {
  if (skill.type === 'prompt' && skill.paths && skill.paths.length > 0
      && !activatedConditionalSkillNames.has(skill.name)) {
    newConditionalSkills.push(skill)
  } else {
    unconditionalSkills.push(skill)
  }
}

// Store conditional skills for later activation when matching files are touched
for (const skill of newConditionalSkills) {
  conditionalSkills.set(skill.name, skill)
}

// 最后只返回无条件的 Skill
return unconditionalSkills

这段逻辑的含义是:带 paths 的 Skill,根本不会像普通 Skill 一样直接进入启动时上下文。它会先待在一个“条件激活区”里,只有当你在会话里碰到了匹配路径的文件,它才会被真正激活。

这点对复杂项目非常有价值。

比如你给支付模块写一个 paths: src/payment/** 的 Skill,在你处理用户系统、文章系统、管理后台时,这个 Skill 对 Claude 几乎是隐身的。只有当你真的进入 src/payment/ 相关文件,它才“出现”。

这也是我现在很认同的一种团队实践:不要在根目录堆一个什么都想管的大 Skill 集合,而是让复杂模块在自己的目录附近维护自己的 Skill。

4. 为什么大型项目不该只在根目录维护一套总 Skill

还有一个很容易被忽略,但工程上非常实用的机制:Claude Code 会从当前文件所在目录一路向上寻找 .claude/skills

源码大概是这样:

// Walk up to cwd but NOT including cwd itself
while (currentDir.startsWith(resolvedCwd + pathSep)) {
  const skillDir = join(currentDir, '.claude', 'skills')
  // ...check if exists, then load
  currentDir = dirname(currentDir)
}

// Sort by path depth (deepest first) so skills closer to the file take precedence
return newDirs.sort((a, b) => b.split(pathSep).length - a.split(pathSep).length)

这里最关键的是最后一行:deepest first。也就是说,越靠近当前文件的 Skill,优先级越高。

这意味着你放在 src/auth/.claude/skills/ 里的 Skill,可以自然覆盖根目录下更通用的同名 Skill。对 monorepo 或大仓库来说,这个机制非常好用:

  • packages/api/.claude/skills/ 可以放 API 专属 Skill
  • packages/web/.claude/skills/ 可以放前端专属 Skill
  • 根目录只保留真正的全局规则

如果把上面四点放在一起看,设计 Skill 的顺序其实会变得很清楚:

  • 先把 frontmatter 写准,再去打磨正文步骤
  • 先按最小权限收紧 allowed-tools,再考虑要不要给更多能力
  • 只有模块边界明确时再上 paths,不要为了“高级”硬加
  • 多目录项目优先做“离代码更近”的局部 Skill,而不是维护一个大而全的总模板

5. 为什么长对话里,Skill 不会轻易“失忆”

还有一个很多人会担心的问题:会话一长、上下文一压缩,前面调过的 Skill 会不会就悄悄失效了?

从实现思路看,Claude Code 不是简单把它们扔掉,而是会把最近调用过的 Skill 重新注入压缩后的上下文。工程上你可以把它理解成:Skill 不是“一次触发完就全靠模型自己记住”,而是一个可以被系统再次带回来的工作单元。

当然,这也不是说你可以无限制地把 Skill 写成超长文档。实现上会有保留预算,比如最近调用的 Skill 只会保留前一段核心内容,而不是把所有正文永久塞在上下文里。所以前面那条原则依然成立:把 frontmatter 写准,把正文写短,把真正长的材料拆到 reference 或脚本里。

Skill 运行机制与条件激活


七、不是所有 Skill 都应该让 Claude 自动触发

到这里,其实已经够你写出一个基础可用的 Skill 了。

但如果你真的准备在项目里长期用,接下来有一个问题迟早会遇到:

这个 Skill 到底应该让 Claude 自动触发,还是只能我手动触发?

这背后其实对应两种完全不同的 Skill。

1. 参考型 Skill:给 Claude 补充背景知识

这类 Skill 的作用不是“执行一个任务”,而是“把某个项目知识注入到当前工作里”。

比如 API 设计规范、错误处理约定、数据库命名规则。这些东西你希望 Claude 在写代码、改代码、review 代码时,只要场景合适就自动想起来。

这类 Skill 的特点是:

  • 倾向自动触发
  • 内容会留在主对话上下文里
  • 更像“局部规范”而不是“独立任务”

比如:

---
name: api-conventions
description: API design patterns and conventions for this codebase. Use when writing or reviewing API endpoints.
---

响应格式统一用 { success, data, timestamp }
禁止在 controller 层直接写 SQL,通过 service 层操作
所有异步函数必须有 try-catch,错误统一 throw new AppError()

2. 任务型 Skill:给 Claude 一个要完成的动作

这类 Skill 有明确边界,通常还可能带副作用。

比如 /deploy/send-release-email/prepare-release-notes/migrate-db。这类 Skill 更像一段可执行流程,而不是知识注入。

这类 Skill 的特点是:

  • 通常应该手动触发
  • 有副作用时最好加 disable-model-invocation: true
  • 有风险时再配 context: fork

比如:

---
name: deploy
description: Deploy the application to production. Manual trigger only.
context: fork
disable-model-invocation: true
allowed-tools:
  - Bash(npm run test)
  - Bash(npm run build)
  - Bash(git push *)
---

1. 跑完整测试:npm run test
2. 确认测试全绿后构建:npm run build
3. 推送到部署分支
4. 等待 CI 完成并检查健康状态

这里的关键不是字段多了,而是触发权变了。

你真正要想清楚的问题是:

这件事我愿不愿意让 Claude 自己判断“现在该触发了”?

如果答案是“不愿意”,那就不要让它自动触发。

3. disable-model-invocation: trueuser-invocable: false 不是一回事

这是一个很容易混淆,但又非常关键的区别。

默认情况下,你和 Claude 都可以调用一个 Skill。你可以手动 /skill-name,Claude 也可以在觉得合适时自动加载它。

但很多人会把下面两个字段混为一谈:

  • disable-model-invocation: true
  • user-invocable: false

它们看起来都像“限制调用”,其实限制的是两件完全不同的事。

disable-model-invocation: true 的意思是:Claude 不能自己触发,只有你能手动触发。 这类 Skill 适合部署、发版、发邮件、推送消息这类你必须自己掌握时机的动作。更重要的是,它还会把这个 Skill 的描述从 Claude 的常驻上下文里拿掉,平时的上下文成本直接归零,只有你手动调用时才完整加载。

user-invocable: false 的意思则是:你不能从 / 菜单里把它当命令来点,但 Claude 仍然可以在合适时自动用它。 这类 Skill 更适合背景知识,比如老系统架构、内部缩写、遗留约定。这些东西你希望 Claude 在相关任务里自动想起来,但你并不需要一个显眼的 /legacy-context 命令天天挂在菜单里。

所以更准确的判断应该是:

  • 有副作用、要你亲自控制时机:disable-model-invocation: true
  • 只是背景知识、不适合被人手动当命令点:user-invocable: false
  • 想限制 Claude 到底能不能调用某些 Skill:去配权限规则,而不是只盯菜单显示

这组区别值得写进脑子里,因为它直接决定了 Skill 的“触发权”到底属于谁。

参考型 Skill 与任务型 Skill


八、Skill 的天花板:从静态模板到可执行能力

前面几节讲的,主要还是“怎么把经验写成一个好模板”。但如果 Skill 只能放静态文字,它的上限其实并不高。

真实项目里,很多任务依赖的是实时信息:当前 PR 的 diff、评论区讨论、今天的测试结果、最新 schema、CI 状态。你当然可以在 Skill 里写“先去看这些东西”,但这样一来,最关键的一步又变回 Claude 自己先兜一圈去找。

Claude Code 里真正更有意思的地方是:Skill 不只是 prompt 模板,它还可以在触发瞬间先准备上下文。

1. 动态注入:先拿真实数据,再交给 Claude

比如你要做一个 PR 总结 Skill,不一定要让 Claude 先自己去猜该看哪些信息,你可以直接让 Skill 触发时先把它们准备好:

---
name: pr-summary
description: Summarize the current pull request. Use when asked to review or summarize a PR.
context: fork
allowed-tools:
  - Bash(gh *)
---

## 当前 PR 信息
- diff:!`gh pr diff`
- 评论区讨论:!`gh pr view --comments`
- 涉及文件:!`gh pr diff --name-only`

## 你的任务
基于以上真实数据,总结这个 PR 做了什么、为什么改、有哪些潜在风险。

这个 Skill 真正触发时,前面的命令会先执行,输出直接注入到 prompt 里。Claude 拿到的不是“请你去看看 PR”这种模糊要求,而是已经准备好的真实上下文。

这也是为什么前面说 scripts/ 不是装饰。如果一个 Skill 需要先查状态、先取数、先做一轮预处理,那它就不再只是“告诉 Claude 怎么做”,而是在执行前把材料也一起备好了。更复杂一点时,你完全可以把逻辑放进 scripts/,再通过 CLAUDE_SKILL_DIR 去调用目录里的脚本,让 Skill 触发时先跑一轮取数或整理。

2. 这件事为什么重要:它决定了 Skill 的上限

一旦你理解了动态注入这层能力,就会发现 Skill 的上限根本不只是“几行 prompt”。

它可以同时承担三件事:

  • 定义触发条件
  • 限制工具权限
  • 在执行前准备实时上下文

换句话说,Skill 不是只能做静态模板,它完全可以长成一个带入口、带约束、还能主动取数的局部能力单元。

不要只把 Skill 当成“写给模型的一段话”,而要把它当成“一个局部工作流的入口”。

3. 从架构位置看,Skill 不是 Tool,也不是 Agent

如果再往上抽一层,我现在对 Skill 的理解是:

  • Tool 是原子能力
  • Skill 是任务知识和操作规约
  • Agent 是执行与编排单元

这个分层不一定是官方唯一表述,但作为工程心智模型非常有用。因为一旦把 Skill 放到 Tool 和 Agent 中间去理解,很多判断都会顺下来:

  • 该写成脚本的,别硬写进 Skill 正文
  • 该放进 CLAUDE.md 的全局规则,别误沉淀成任务 Skill
  • 该拆给 SubAgent 的复杂协作,别硬让一个 Skill 扛完

Skill 真正做的事,不是替代 Tool,也不是替代 Agent,而是把“什么场景下、按什么步骤、调用哪些能力”组织成可复用的任务单元。


九、真正让 Skill 变靠谱的,不是写出来,而是验证出来

这是我觉得官方 best practices 里最容易被忽略、但最能拉开水平差距的一点。

很多人写 Skill 的方式是:先凭感觉写一版,再上项目里试试。这样当然也能跑,但它很容易陷进一种错觉里: 你以为自己在“优化 Skill”,其实只是一直在补想象中的问题。

官方更推荐的思路其实更工程化:

先准备评测样例,再写 Skill。

最轻量、也最实用的做法,是先找出三个真实场景:

  • 一个 Claude 原本就能做得不错的
  • 一个 Claude 容易漏步骤的
  • 一个 Claude 容易理解偏、触发错或者输出不稳的

先不用 Skill 跑一遍,记录基线。看它具体错在哪:

  • 是没想到要用这个 Skill
  • 是触发了,但没读对参考资料
  • 是步骤顺序不稳
  • 是输出格式飘了
  • 是该脚本处理的确定性工作,被它自己“猜”过去了

然后再写最小版本的 Skill,只补刚才暴露出来的那几个缺口,而不是一上来把所有可能性都写满。

把这个过程压缩成一句话,其实就是:

  1. 先找失败样例
  2. 再写最小 Skill
  3. 再看它是不是真的修掉了失败样例

如果三轮下来都没有明显提升,那大概率不是 Skill 写得不够多,而是这个问题根本不该沉淀成 Skill。

不要只看结果,还要看 Claude 是怎么“导航”这个 Skill 的

只看最终结果对不对还不够,更重要的是看 Claude 在过程中到底是怎么用这个 Skill 的。

官方专门建议观察 Claude 实际怎么使用 Skill,而不是只看最终结果对不对。比如:

  • 它是不是总在错误顺序里读文件
  • 它是不是老是忽略某个引用文件
  • 它是不是每次都反复读同一个 reference,那这部分也许该往 SKILL.md 主体里提
  • 某个 examples 文件是不是从来没被读过,那它可能根本没价值,或者信号太弱

这些观察特别重要,因为它能直接反推出信息架构是不是合理。

我现在会把一个 Skill 是否成熟,简单看成四个问题:

  • 会不会在该触发的时候触发
  • 触发后会不会按预期去读材料
  • 执行过程会不会漏掉关键步骤
  • 输出结果能不能稳定复现

如果你团队里会混用不同模型,官方还建议至少跨你计划使用的模型测一遍。不是因为所有模型都得兼容,而是因为有些 Skill 对提示强度和结构依赖更高,换模型后会暴露出你原来没看到的问题。

说到底,Skill 的成熟度,不是靠“我觉得写得挺全”来判断,而是靠一组真实任务能不能稳定跑通来判断。


十、写 Skill 时,最常见的四种设计模式,以及最容易踩的反模式

这里先说清楚:下面这四种名字,不是官方文档逐字给出的固定术语,而是我结合 Anthropic 官方 best practices 做的工程化归纳。

但它们确实能覆盖大多数项目里真正会遇到的 Skill 设计问题。

1. 模板驱动模式:解决“输出不稳定”

这类 Skill 的核心不是让 Claude 更会想,而是让它更稳定地按结构输出。

适合:

  • 周报
  • PR 描述
  • 事故复盘
  • 评审报告

它的关键不是“给一个模板”,而是把模板当成输出接口。模板负责格式,SKILL.md 负责路由和规则。模板里不要塞判断逻辑,也不要把一个模板写成一套小程序。

如果一个模板已经长到一百多行,而且开始出现大量条件分支,通常不是你模板写得认真,而是职责已经混了。

2. 脚本增强模式:解决“结果不稳定”

这类 Skill 的核心是:确定性的事交给脚本,不要交给模型猜。

适合:

  • 指标统计
  • CSV 解析
  • 正则匹配
  • Git / PR / CI 状态抓取
  • 需要预处理的上下文准备

官方对这件事的说法很直白:solve, don't punt。能脚本算出来的,就别只写一句“请 Claude 自行分析”。

这类 Skill 真正提高的,不是文风,而是可靠性。你把概率型推理替换成确定性执行,整个 Skill 的下限会明显抬高。

3. 知识分层模式:解决“上下文过载”

这是官方反复强调的 progressive disclosure 思路。

也就是:SKILL.md 只做入口和导航,把大块知识拆进 reference/examples/forms/ 这类文件里,按需读取,而不是一次灌满。

这类模式适合:

  • 领域知识很多的 Skill
  • 不同子领域差异很大的 Skill
  • 需要高级用法、边界情况、案例库的 Skill

它的关键不是“拆文件”,而是“拆得有层次”。官方明确不建议深层嵌套引用。最稳的做法是所有重要材料都从 SKILL.md 一层直达,别让 Claude 从 advanced.md 再跳 details.md 才看到真正关键信息。

4. 工具隔离模式:解决“能力边界失控”

这类模式最容易被低估。

很多人写 Skill 时只关注“让它能做事”,但真正稳定的 Skill 往往同样重视“让它不能乱做事”。

适合:

  • 部署
  • 发版
  • 写数据库迁移
  • 调外部系统
  • 会改文件、发消息、推远端的任务

这一类 Skill 的核心组合通常是:

  • allowed-tools 收到最小
  • 必要时配 context: fork
  • 不希望自动触发时加 disable-model-invocation: true

它不是在限制 Claude 的创造力,而是在设计这个能力包的安全边界。

最常见的反模式

如果把前面四种模式反过来看,最常见的坑基本也就集中在下面这些地方:

  • 一个 Skill 管太多事,什么都想覆盖,最后什么都不够准
  • description 写得很空,只写“处理文档”“帮助分析”这种谁都能套上的话
  • 一上来给一堆方案,不给默认路径,让 Claude 自己从五六种做法里摇摆
  • 模板里写判断逻辑,或者把本该脚本做的事丢给模型推理
  • reference 层级太深,真正关键信息藏在第二跳、第三跳文件里
  • 把会过期的信息硬写进 Skill,比如时间敏感规则、旧接口切换说明
  • 默认假设工具和依赖都已经装好,结果一跑就断
  • 在路径里写 Windows 反斜杠,跨环境直接出问题

你会发现,所谓反模式,本质上就是一句话:

该约束的地方没约束,该拆开的地方没拆开,该交给脚本的地方还在让模型猜。


十一、完整案例:把一个通用 code review 模板,提炼成你项目真正需要的 Skill

上面说了这么多抽象原则,不如走一遍完整例子。

假设你们团队每周都做后端代码审查,而且总在重复盯这几件事:

  • 有人改一个功能,顺手动了三个不相关模块
  • 新同学不知道项目里统一用 AppError,直接 throw new Error()
  • Promise 链里漏了 await
  • 数据库查询没有索引,或者潜在 N+1 没被看出来

这就是非常典型的“该沉淀 Skill 的信号”。

第一步:先确认痛点到底是什么

这一步别着急写模板,先把“你们到底在反复出什么问题”说清楚。

很多团队的问题不是“没有 code review”,而是每次 review 的注意力都被分散了。真正高频出错的点,永远是那几类项目特有的约束。

所以要沉淀的不是“代码审查”这四个字,而是你们团队在代码审查里最容易漏掉的那几类检查。

第二步:从公开模板里提取真正有用的部分

这时候公开模板就有用了,但它的用途不是直接上生产,而是当素材库。

假设你找到一个 50 行的通用 code review 模板。你真正该提取的,可能只有下面这几类东西:

  • 逻辑正确性,尤其是异步操作
  • 项目约定的遵守,比如 AppError、错误处理模式
  • 数据库相关的风险,比如 N+1、索引、查询范围
  • 改动范围是否聚焦,不要顺手改不相干文件

剩下那些跟你们项目关系不大的部分,就应该果断删掉。

第三步:把它压缩成一个真正能用的 Skill

最后落地出来的 Skill,应该更像这样:

---
name: code-review
description: Review TypeScript backend code before merging. Use when asked to review code, check a PR, or verify implementation before committing.
allowed-tools:
  - Read
  - Grep
  - Glob
  - Bash(git diff *)
argument-hint: "[PR 分支名或文件路径]"
---

# Code Review Skill

## Steps
1. 读 $ARGUMENTS 的改动 diff,确认改动是否只涉及这个 PR 的范围(不要顺手改无关文件)
2. 检查错误处理:所有 throw 都必须是 throw new AppError(),不能 throw new Error()
3. 检查异步操作:Promise 链是否有遗漏的 await,错误是否被正确 catch
4. 检查数据库查询:是否有 SELECT * 的懒惰写法,是否明显的 N+1 查询,关键查询是否 explain 过

## Output Format
Issues found (Critical → Warning → Info):

🔴 **Line 45**: Missing `.select()` in Prisma query - this will fetch unnecessary columns
🟡 **Line 67**: Potential N+1: loop inside `posts.map()` should use `Promise.all()`**No AppError violations** — all errors properly handled

Summary: 1 critical issue to fix before merge.

## Caveats
- 不审查 UI 层代码(只关心后端逻辑)
- 不关注代码风格(那是 prettier 的事)
- 如果一个改动涉及多个不相干功能,分别提交 PR 再 review

你会发现,到这一步之后,Skill 就不再是“通用模板的中文版”了,而是你们项目真正有用的一个局部工作流。

50 行公开模板,最后可能只剩下 4 个真正属于你项目的核心关注点。但恰恰是这 4 个点,才决定它到底值不值得用。

第四步:在真实使用里继续迭代

Skill 从来不是一次写完的。

比如你用了两周之后,又发现一个常见问题:改了数据库 schema,但忘记更新 Prisma 类型。那就把它加进去:

4.5. 检查 Prisma 类型:如果改了数据库,Prisma schema 和生成的 types 是否都已更新

这时候你会发现,Skill 真正的价值不是“第一次写出来”,而是在真实工作里被持续打磨。


十二、Skill 的维护节奏,比第一次写出来更重要

Skill 不是写好就扔。

如果你写完之后三个月不看,它很快就会从“项目经验”重新退化成“历史遗留文档”。

我更推荐一个更贴近实际的轻量节奏:

前一两周
高频使用,快速迭代。每次用完就问自己三个问题:步骤是不是太复杂?输出是不是太啰嗦?有没有漏掉今天刚踩到的新坑?

稳定之后每周一次
回顾一次。看看最近有没有经常被遗漏的步骤,有没有新的痛点需要加入。对 Skill 这种高频小迭代的东西来说,按周看通常比按月看更合适。

每个月做一次清理
把已经不再是问题的注意事项删掉,把那些已经变成全局共识的规则移进 CLAUDE.md。这一步的重点不是加内容,而是防止 Skill 越写越胖。

Skill 应该越用越精炼,而不是越写越臃肿。


十三、一个特别反直觉,但很重要的经验:第一个 Skill,故意别写最重要的任务

这条我非常想单独拿出来讲。

因为很多人第一次沉淀 Skill,会本能地想挑一个最关键的任务,比如“生产环境发布前检查”“数据库迁移前审查”“支付流程改动 review”。

但工程上更稳的做法,其实正好相反。

大多数人写的第一个 Skill,质量都不会太高。触发条件偏模糊,步骤偏啰嗦,输出格式也不够稳定。这很正常,因为你第一次做这件事时,对“什么是好的 Skill”还没有直觉。

如果你一开始就把它用在最关键的任务上,一旦写得不够好,伤害会非常直接:要么关键场合出问题,要么你从此对这套机制失去信心。

更好的策略是:先拿一个重要程度中等、容错率比较高的任务练手。

比如:

  • 写周报
  • 生成 PR 描述
  • 做一次常规 code review

先跑两周,迭代两三次,等你对 Skill 的节奏有感觉了,再去沉淀真正关键的流程。

第一个 Skill 的目的,不是直接解决最大的问题,而是让你学会怎么写 Skill。


十四、如果你今天就想开始,可以直接做这三个动作

别先想着搭一整套系统,直接从最小动作开始:

任务一: 列出你最近一个月里重复做过三次以上的任务。用第一节的判断法(对所有任务成立?→ CLAUDE.md;只对某类任务成立?→ Skill;只对这次成立?→ Prompt),确认你要处理的是 Skill 还是 CLAUDE.md 的事。

任务二: 为这个任务写一个 Skill。先想清楚四个问题(什么时候用、按什么顺序、输出长什么样、什么时候不适用),然后包装成 SKILL.md。目标是精炼——大多数好 Skill 的有效指令不超过 10 行。记住:第一次的目的是练手,不是写出完美的 Skill。

任务三: 选三个你最近真的遇到过的场景,先不用 Skill 跑一遍,再用 Skill 跑一遍,对比它到底有没有少漏步骤、少返工、少补充解释。然后在 SKILL.md 里补一行今天发现的问题(哪个步骤漏了,或者哪个注意事项需要加)。这就是 Skill 的维护节奏,也是最轻量的评测方法。

真正好的 Skill,几乎都不是第一次就写对的,而是在实际使用里慢慢长出来的。


下篇预告

第 06 篇:Sub-agents 实战——什么时候应该拆任务,怎么设计子任务边界

单个 Claude 实例有上下文上限,复杂任务拆成多个子任务让 Sub-agents 并行处理,理论上能大幅提速。但什么时候值得拆?拆错了会有什么代价?下一篇会拆开 Sub-agents 的真实适用场景,以及最常见的过度设计陷阱。


写在最后

公开 Skill 模板当然有用,但它的价值更像脚手架,而不是成品。

你不需要一个很复杂的 Skill 系统。你需要的,往往只是把团队最容易反复犯错的那几件事提前写下来,让 Claude 每次都替你盯住。

如果你现在想动手,就直接做这三步:列出最近一个月里重复做过三次以上的任务;用"三问法"确认它该放 CLAUDE.mdSkill 还是 Prompt;先写一个只有 5 到 10 行的版本,实际用一次,再立刻改第一轮。不要追求第一版完美——Skill 的价值,从来不是"写出来的那一刻",而是"它开始帮你减少重复错误的那一天"。

评论区想聊一个问题:你现在最卡的,到底是"写不出规则",还是"没分清哪些该放 CLAUDE.md、哪些该做成 Skill"? 这两个问题看起来很像,但解法完全不同。

觉得这篇有帮助,点个赞让更多工程师看到 👍


AI Coding 系列持续更新。用别人的 Skill 模板是起点,不是终点。真正管用的 Skill,只有你自己的项目才能提炼出来。

❌