从 flatMap 到矩阵外积:把“升维 / 降维”变成 JAVA 开发者的直觉
我们学了那么多 API,为什么还是“缺直觉”?
作为 Java 开发,你大概率经历过这些瞬间:
-
写
Stream已经很顺:map / filter / flatMap一路链式调用,看起来很优雅,但心里偶尔还是会冒出一句:
“flatMap 到底在干嘛?为啥一会儿是
T -> Stream<R>,一会儿又变成了扁平的一维流?”
-
大学线代课上学过矩阵、向量、点积、矩阵乘法,甚至知道“外积”这玩意儿,可一走出考场,这些东西就自动归类为:
“只在考试和深度学习论文里存在的生物。”
于是就出现了一个很有意思的反差:
API 会用,题也能做,但 缺一种一眼看穿本质的“直觉”。
这篇文章想做的事情很简单:
-
不跟你讲高深数学,不和你科普
monad定义; -
而是用一个极其朴素的问题,把两个世界串起来 ——
“这个操作,是在 升维 还是在 降维?”
我们会从你每天在用的 map / flatMap 出发,看看它们其实就是在玩“容器维度”;
然后转向矩阵,看看外积、flatten、点积、trace 怎么在“张量维度”上做类似的事情。
目标只一个:
以后你再看到一个新概念、新 API、新公式,脑子里先弹出一句:
“它到底在对哪些维度动手?”
当你能自然地问出这句话,抽象世界会立刻变得好懂很多。
flatMap 世界里的“升维 / 降维” —— 先从 Java 容器开始
先别谈 FP,先看“多一层包裹”是什么意思
我们从最简单的几个类型开始:
String s; // A
List<String> list; // List<A>
List<List<String>> ll; // List<List<A>>
Optional<Integer> x; // Optional<A>
Optional<Optional<Integer>> y; // Optional<Optional<A>>
如果你把 每一层容器 想象成“多了一维的上下文”,那:
-
没有容器:
A→ “0 维的纯值” -
一层容器:
List<A>、Optional<A>→ “1 维” -
两层嵌套:
List<List<A>>、Optional<Optional<A>>→ “2 维”
这里的“维度”跟三维空间没关系,只是一个方便记忆的说法:
容器嵌套的层数 = 你要处理的“上下文维度”。
接下来,map 和 flatMap 做的事情就可以用“维度变化”来描述了。
map:只在当前维度里“涂颜料”,不改维度
先看最熟悉的 map:
Stream<Integer> s = Stream.of(1, 2, 3);
// A -> B
Stream<String> s2 = s.map(i -> "num-" + i);
类型层面是:
map: Stream<A> × (A -> B) -> Stream<B>
你可以这么理解:
-
容器维度:从
Stream<A>到Stream<B>,没有变(都是 1 层Stream)。 -
真正在变的是“里面的元素类型”而已。
所以:
map= 在 固定维度的容器里,换一批内容。像是你拿着一支画笔,在同一张画布上换颜色,而不是换画布的层数。
flatMap:A -> M<B> + 自动“压扁” = 降一维
再看 flatMap:
Stream<Integer> s = Stream.of(1, 2, 3);
// i -> Stream<String>
Stream<String> s3 = s.flatMap(i -> Stream.of("a" + i, "b" + i));
它的类型是:
flatMap: Stream<A> × (A -> Stream<B>) -> Stream<B>
如果只看 map 部分会发生什么?
Stream<Stream<String>> tmp = s.map(i -> Stream.of("a" + i, "b" + i));
这一步之后,你其实得到了一个 Stream<Stream<String>> ,也就是“2 维”的结构:
[
["a1", "b1"],
["a2", "b2"],
["a3", "b3"]
]
然后 flatMap 在这个基础上做了一次“flatten”:
// flatten 之后:
["a1", "b1", "a2", "b2", "a3", "b3"]
所以可以把 flatMap 分成两步:
-
map:升维 一点点(从Stream<A>到Stream<Stream<B>>,加深了嵌套层数); -
flatten:把两层Stream压成一层:Stream<Stream<B>> -> Stream<B>⇒ 降一维。
整体效果是什么?
对 “从 A 到
Stream<B>” 的映射做了一次整体的 降维,避免你手里多出一层“嵌套容器”。
记住这一点就够了:
有嵌套就会想降维;有纯值就可能想升维。
flatMap 是最典型的“降一维”工具。
升维:把值“丢进一个世界里”
既然有降维,肯定也有升维。
在 Java 里很常见:
// 标量 -> 容器(0 维 -> 1 维)
Optional<Integer> o = Optional.of(42);
Stream<String> s = Stream.of("hello");
// 值 -> Future:0 维 -> 异步世界这 1 维
CompletableFuture<String> f = CompletableFuture.completedFuture("ok");
这些“of / just / completedFuture / singletonList”之类的方法,本质上干的是:
把一个裸值放进某个“上下文维度”里。
在 FP 里,他们经常被叫做 unit / pure 之类的名字,你完全可以用更程序员一点的话记它:
升维:
A -> M<A>(把值装进某个世界)降维:
M<M<A>> -> M<A>(把多余的一层世界压掉)
把这套“维度直觉”应用到你的日常 Java 代码
以后你看到类似的类型:
Stream<Stream<T>>
Optional<Optional<T>>
CompletableFuture<CompletableFuture<R>>
Stream<Optional<T>>
可以下意识问自己:
1)这里有多少层“世界”?
-
Stream<Stream<T>>是“流里还有流” -
Optional<Optional<T>>是“可能里还有可能”
2)接下来我要做的事,是升维还是降维?
-
如果你要“拆开一层世界”,就会想到
flatMap/thenCompose/ 自己写的flattenXXX。 -
如果你要“先包一层再交给别的 API”,就会想到
of/completedFuture/just等等。
这就是 flatMap 世界里的升维 / 降维:
只是你以前叫它“链式调用”,现在多了一个更形象的词:维度变化。
接下来,我们去矩阵的世界看一眼:
那里的 升维 / 降维,其实跟你现在理解的这些,非常像。
矩阵世界里的“升阶 / 降阶” —— 外积、flatten、点积、trace
从容器切到矩阵,我们先约定一下要用的“维度词汇”,避免概念混乱。
三种“维度”,先对齐再说话
在矩阵世界,大家一说“维度”,可能指的是三件不同的事:
1)数组维度(轴的个数)
-
double[]:1D 数组 -
double[][]:2D 数组(矩阵) -
double[][][]:3D 数组(高阶张量)
2)张量阶数(索引个数)
-
标量:0 阶(不需要索引)
-
向量:1 阶(1 个索引 i)
-
矩阵:2 阶(2 个索引 i、j)
-
三维数组:3 阶张量(i、j、k)
3)线性空间的维数(ℝⁿ 里的 n)
-
“这个向量有多少个分量” = “所在空间的维数”。
-
比如
v ∈ ℝ⁵,说的是它在 5 维空间里。
这篇文章接下来主要用 第 2 个意义:
把标量 / 向量 / 矩阵 / 张量看作 0 阶 / 1 阶 / 2 阶 / 3 阶, 然后谈 升阶(升维)和降阶(降维)。
外积:两个向量如何“长成”一个矩阵?——典型升维
你大概对“点积”很熟:
v·w = v1*w1 + v2*w2 + ...,结果是一个标量。
而“外积”是另外一件事:
给定两个向量 v 和 w,生成一个矩阵 A,
其中每个元素是
A[i][j] = v[i] * w[j]。
简单画一下:
v = [v1, v2, v3] (3 维向量)
w = [w1, w2] (2 维向量)
外积 A = v ⊗ w =
[
[v1*w1, v1*w2],
[v2*w1, v2*w2],
[v3*w1, v3*w2]
] // 3x2 的矩阵
从“维度/阶数”的角度看:
-
v是 1 阶张量(只需要一个索引 i) -
w是 1 阶张量(索引 j) -
A是 2 阶张量(索引 i、j)
所以外积做的是:
1 阶 + 1 阶 → 2 阶,
非常典型的 升阶 / 升维 操作。
如果用 Java 粗暴实现一下:
double[][] outerProduct(double[] v, double[] w) {
int m = v.length;
int n = w.length;
double[][] A = new double[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
A[i][j] = v[i] * w[j];
}
}
return A;
}
你可以把它想象成:
把一个一维的东西“拉成行/列”,然后在二维平面上铺开。
flatten / vec / reshape:矩阵如何“捋直”? —— 降维 + 换形状
我们再来看跟 flatMap 很像的一个动作:把矩阵“捋直”成向量。
假设有一个 2×3 的矩阵:
A =
[
[1, 2, 3],
[4, 5, 6]
]
如果我们按行展开:
flatten(A) =
[1, 2, 3, 4, 5, 6] // 一个 6 维向量
从张量阶数的角度:
-
A:2 阶(两个索引 i, j) -
flatten(A):1 阶(一个索引 k)
这就是一个 标准的降阶 / 降维:
2D → 1D,把“行 + 列”合成了一个新的线性索引。
反过来,reshape 则是:
reshape(flatten(A), 2, 3) // 1D -> 2D
在数学上,它既可以被看作升维,也可以看作单纯“换形状”(因为元素没变,只是重新组织索引)。
在工程上我们更关心的是直觉:
-
flatten:把一个 2D 世界“压成一条线”(降维)
-
reshape:把一条线重新铺成 2D 网格(升维 / 改坐标)
点积 / 矩阵×向量 / trace:各种“收缩维度”的降阶操作
外积是升维,那哪些是矩阵世界里的降维?
只要你看到“对某个索引求和、把它干掉了”,基本就是降维 / 降阶。
点积:1 阶 + 1 阶 → 0 阶(标量)
点积定义:
v·w = Σ v[i] * w[i]
从维度角度:
-
输入是两个 1 阶张量(向量)
-
输出是 0 阶张量(标量)
你可以说:
对索引 i 做了一次“求和收缩”,把这个维度“合并掉了”。
这就是一个标准的 降一阶 操作。
矩阵 × 向量:2 阶 + 1 阶 → 1 阶
矩阵乘向量:
y[i] = Σ A[i][j] * x[j]
-
A:2 阶(i, j) -
x:1 阶(j) -
y:1 阶(i)
这里发生的事情是:
-
对 j 这个索引做了求和 ⇒ 把 j 这一个维度“收缩掉”
-
于是整体的阶数从 “2 + 1” 变成 “1”
这和 FP 里什么感觉像?
有点像你拿一个“二维容器 + 一维容器”,经过某种规则的“聚合”,最后只剩下一维结果。
矩阵的迹 trace:2 阶 → 0 阶
迹的定义:
tr(A) = Σ A[i][i]
-
A:2 阶(i, j) -
tr(A):0 阶(标量)
这里是不是很像:
“对 i = j 这条对角线上的元素求和,把两个索引一起收缩掉了”
这就是 把 2 阶对象压缩成 0 阶对象的降维操作。
矩阵世界的“维度操作表”
我们可以用一句话把前面的说完:
1)升维 / 升阶:
-
外积:
向量 × 向量 -> 矩阵 -
把一堆向量堆成矩阵:
k 个 1D -> 1 个 2D -
reshape(从 1D 到 2D 的那一侧)
2)降维 / 降阶:
-
flatten:
矩阵 -> 向量 -
点积:
向量 × 向量 -> 标量 -
矩阵×向量:
矩阵 × 向量 -> 向量 -
trace:
矩阵 -> 标量 -
按行 / 按列求和:对某个轴做收缩
如果你把这些操作的“维度变化”内化成直觉,你会发现:
线代书上很多让人眼晕的符号,其实都可以翻译成一句话:
“我现在在对哪几个索引求和,把哪几个维度合并掉?”
而这一点,和你在 Java 容器世界里学到的 升维 / 降维直觉,是可以共用的。
用一小段 Java Cheat Sheet,把两个世界串在一起
前面我们分别站在“Java 容器世界”和“矩阵 / 张量世界”里讲升维 / 降维。
这里,我们就干一件很务实的事:
用一小段 Java 代码,
把这两个世界塞进同一张“维度操作表”里。
你可以把这当成是:
-
一份可以直接复制进项目的
double[] / double[][]工具类 -
再加上一张:
FP ↔ 线代 ↔ Java 实现 ↔ 维度变化对照表。
3.1 面向 Java 的最小线代工具箱:double[] / double[][] 版
先约定一下表示:
-
向量:
double[] -
矩阵:
double[][]
我们写一个特别迷你的工具类,只保留“跟维度变化有关”的几个操作:
public class LinAlg {
// --- 升维 / 升阶 ---
// 标量 -> 向量(0D -> 1D)
public static double[] scalarToVector(double x) {
return new double[]{x};
}
// 向量 -> 单行矩阵(1D -> 2D)
public static double[][] vectorToRowMatrix(double[] v) {
return new double[][]{v.clone()};
}
// 向量 -> 单列矩阵(1D -> 2D)
public static double[][] vectorToColMatrix(double[] v) {
double[][] m = new double[v.length][1];
for (int i = 0; i < v.length; i++) {
m[i][0] = v[i];
}
return m;
}
// 外积:两个向量 -> 矩阵(1阶 + 1阶 -> 2阶)
public static double[][] outer(double[] v, double[] w) {
int m = v.length, n = w.length;
double[][] a = new double[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
a[i][j] = v[i] * w[j];
}
}
return a;
}
// --- 降维 / 降阶 ---
// flatten:矩阵 -> 向量(2D -> 1D),按行展开
public static double[] flatten(double[][] a) {
int rows = a.length;
int cols = a[0].length;
double[] r = new double[rows * cols];
int k = 0;
for (int i = 0; i < rows; i++) {
if (a[i].length != cols) {
throw new IllegalArgumentException("Row length mismatch");
}
for (int j = 0; j < cols; j++) {
r[k++] = a[i][j];
}
}
return r;
}
// 点积:向量 × 向量 -> 标量(1阶 + 1阶 -> 0阶)
public static double dot(double[] x, double[] y) {
if (x.length != y.length) {
throw new IllegalArgumentException("Length mismatch");
}
double s = 0.0;
for (int i = 0; i < x.length; i++) {
s += x[i] * y[i];
}
return s;
}
// 矩阵 × 向量:2阶 + 1阶 -> 1阶
public static double[] matVec(double[][] a, double[] x) {
int rows = a.length;
int cols = a[0].length;
if (x.length != cols) {
throw new IllegalArgumentException("Size mismatch");
}
double[] y = new double[rows];
for (int i = 0; i < rows; i++) {
if (a[i].length != cols) {
throw new IllegalArgumentException("Row length mismatch");
}
double s = 0.0;
for (int j = 0; j < cols; j++) {
s += a[i][j] * x[j];
}
y[i] = s;
}
return y;
}
// 迹:矩阵 -> 标量(2阶 -> 0阶)
public static double trace(double[][] a) {
int rows = a.length;
int cols = a[0].length;
int n = Math.min(rows, cols);
double s = 0.0;
for (int i = 0; i < n; i++) {
s += a[i][i];
}
return s;
}
// --- 改形状 / 对某个轴降维 ---
// reshape:向量 -> 矩阵(1D -> 2D),纯改形状
public static double[][] reshape(double[] flat, int rows, int cols) {
if (flat.length != rows * cols) {
throw new IllegalArgumentException("Size mismatch");
}
double[][] a = new double[rows][cols];
int k = 0;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
a[i][j] = flat[k++];
}
}
return a;
}
// 按行求和:对列维度降维,矩阵 -> 向量
public static double[] sumRows(double[][] a) {
int rows = a.length;
int cols = a[0].length;
double[] r = new double[rows];
for (int i = 0; i < rows; i++) {
if (a[i].length != cols) {
throw new IllegalArgumentException("Row length mismatch");
}
double s = 0.0;
for (int j = 0; j < cols; j++) {
s += a[i][j];
}
r[i] = s;
}
return r;
}
// 按列求和:对行维度降维,矩阵 -> 向量
public static double[] sumCols(double[][] a) {
int rows = a.length;
int cols = a[0].length;
double[] r = new double[cols];
for (int j = 0; j < cols; j++) {
double s = 0.0;
for (int i = 0; i < rows; i++) {
if (a[i].length != cols) {
throw new IllegalArgumentException("Row length mismatch");
}
s += a[i][j];
}
r[j] = s;
}
return r;
}
}
这个类你可以直接扔到项目里玩。
它其实就是用 Java 把我们在前面讲过的那几个“升阶 / 降阶”操作全部具象化了。
接下来,有趣的事情来了:
我们可以把 FP 操作 / 矩阵操作 / Java 实现 / 维度变化 写成一张表。
对照表:FP vs 线代 vs Java 代码里的“维度动作”
下面这张表是这篇文章的“核心记忆点”:
| 领域 | 操作名字 | 类型/公式视角 | Java 例子 | 维度变化 |
|---|---|---|---|---|
| FP(容器) | unit / of / just |
A -> M<A> |
Optional.of(x)、Stream.of(x) |
0D → 1D(升维:把值装进世界) |
| FP(容器) | map |
M<A> -> M<B> |
stream.map(f) |
维度不变,只改内容 |
| FP(容器) | flatMap |
M<A> × (A -> M<B>) -> M<B> |
stream.flatMap(f) |
先升一点(map 变嵌套),再整体降一维(flatten) |
| 线代(张量) | 外积 v ⊗ w |
ℝ^m × ℝ^n -> ℝ^{m×n} |
LinAlg.outer(v, w) |
1阶 + 1阶 → 2阶(升阶 / 升维) |
| 线代(数组) | flatten / vec | ℝ^{m×n} -> ℝ^{mn} |
LinAlg.flatten(A) |
2阶 → 1阶(降阶 / 降维) |
| 线代 | reshape | ℝ^{mn} -> ℝ^{m×n} |
LinAlg.reshape(flat, m, n) |
通常 1D → 2D,可视为升维 / 换形状 |
| 线代 | 点积 v·w |
ℝ^n × ℝ^n -> ℝ |
LinAlg.dot(v, w) |
1阶 + 1阶 → 0阶(对索引 i 收缩) |
| 线代 | 矩阵×向量 A x |
ℝ^{m×n} × ℝ^n -> ℝ^m |
LinAlg.matVec(A, x) |
2阶 + 1阶 → 1阶(对 j 收缩) |
| 线代 | 迹 tr(A) |
ℝ^{n×n} -> ℝ |
LinAlg.trace(A) |
2阶 → 0阶(对 i=j 收缩) |
| 线代 | 按行/列求和 | Σ axis |
sumRows / sumCols |
对某个轴做降维(2D → 1D) |
你可以这么理解这张表:
左边几列,是你已经会用的 API / 数学操作;
右边这一列,是你希望内化的“本质动作”:
-
在玩哪个维度?
-
是在升维 / 降维 / 换形状?
有了这层对照,你以后再看到一个新东西,比如:
-
某个深度学习库里的
conv2d/attention; -
Reactor 里的各种
flatMapMany、buffer,window; -
Stream 里的
collectingAndThen、groupingBy;
你都可以先问自己三件事:
-
输入输出分别在哪些维度 / 容器层数?
-
中间有没有引入“多一层世界”(升维)?
-
最终结果是不是通过某种聚合 / 求和 / flatten 降回去了?
一旦你能用这三个问题“审问”它,这个抽象就已经一半是你自己的了。
在日常 Java 工作里,怎么用这套维度直觉?
说得再具体一点,这套东西怎么落地到你每天写的业务代码里?
可以给你几个很直接的使用场景:
场景 1:设计 DTO / 返回值结构
比如你写一个接口,返回的是:
List<List<OrderItemDto>>
你可以立刻问自己:
-
我是真的需要一个“二维列表世界”吗?
-
还是其实业务上只是“单层列表 + 分组信息”就够了(比如 Map + List)?
有了“维度意识”之后,你会更谨慎地增加嵌套层数,因为你知道:
每多一层嵌套,就意味着未来的某个你,要多写一层
flatMap/ 多绕一圈逻辑来“降维”。
场景 2:Stream / Reactor 链路设计
当你在链式调用里狂写 map / flatMap / buffer / window 时,可以停下来想一眼:
-
这一步是在 把事件流升维 (比如按窗口分组,
Flux<T> -> Flux<List<T>>)? -
还是在 把流降维 (比如
flatMap把Flux<Flux<T>>打平)?
你会发现,很多“看不懂自己写了啥”的链式调用,本质就是你没在脑子里画清楚“维度怎么变”而已。
场景 3:做一点数学/算法实现时
当你需要自己实现个简单的算法(比如最小二乘、线性回归预测),你就可以直接用上面那几个方法:
-
outer:构造某种协方差矩阵 / 特征矩阵; -
flatten / reshape:在矩阵 / 向量之间来回切换; -
matVec / dot / sumRows / sumCols:做各种简单的线代运算。
把它们当作“线代领域的 flatMap / map / unit”,你就不会只停留在“API 调库调用”的层面,而是真正知道:
“我现在是在把数据升到什么维度、再降到什么维度。”
结语:以后遇到新抽象,先问它“你在动哪个维度?”
回到一开始那句话:
我们学了那么多 API,为什么还是“缺直觉”?
很多时候不是你不聪明,也不是 API 太多,而是 缺了一把统一的“观察尺子”。
这里,我们尝试用 “维度变化” 当这把尺子:
在 FP / Java 容器的世界里:
-
unit / of= 升维,把值丢进一个世界; -
flatMap/flatten= 降维,把多余的一层世界压掉。
在线性代数 / 矩阵的世界里:
-
外积 / 堆向量成矩阵 = 升阶 / 升维;
-
flatten / 点积 / trace / 按轴求和 = 各种收缩维度的降阶操作。
前面的小 Java cheat sheet,只是帮你确认一件事:
这些东西不是“哲学问题”,
它们真的可以翻译成你每天写的代码,而且是跑得起来的那种。
如果你愿意,从下一次开始,遇到任意一个新概念 —— 无论是 FP 的、线代的,还是深度学习里的 —— 都可以先在心里默默问一句:
“它在对哪些维度做什么事?”
当这句话变成你的条件反射,你就不再只是“会用 API 的程序员”,
而是开始有了自己的一套 抽象世界的直觉坐标系。
更多推荐
所有评论(0)