第30章 向量
30.1 问题从哪来#
前面的程序已经能把用户输入拆成命令和参数,数据库接到的还是一个很明确的目标:插入记录、按 id 查找、按 id 删除。按 id 查找的逻辑很直接——给一个 id,从头到尾比对,找到就返回。
但有一类问题,数据库回答不了:
- 学生 Alice 和谁最像?
- 这篇文章和哪篇最接近?
- 这首歌和哪首风格相似?
这些问题不是在找"相等",而是在找"相近"。id = 3 能精确命中一条记录,但"和 Alice 最像"需要比较每条记录和 Alice 之间的差距。
要比较差距,首先得把记录变成一组数字。
30.2 先看一个例子#
假设数据库里有三个学生:
| id | name | score |
|---|---|---|
| 1 | Alice | 92 |
| 2 | Bob | 65 |
| 3 | Carol | 88 |
用 db_find(&db, 1, &out) 能精确找到 Alice。但"谁和 Alice 最像"这个问题,数据库没法回答。score 只有一个数字,Alice 和 Carol 都是高分,可她们的学习习惯可能完全不同。一个数字能比较大小,但描述太粗糙。
如果给每个学生多记几个数字呢?
| name | 每周学习时长 | 出勤率 | 作业完成率 |
|---|---|---|---|
| Alice | 21.0 | 0.95 | 0.90 |
| Bob | 5.0 | 0.60 | 0.50 |
| Carol | 15.0 | 0.85 | 0.80 |
三个数字排成一行,就构成了一个向量(vector):
- Alice 的向量:
[21.0, 0.95, 0.90] - Bob 的向量:
[5.0, 0.60, 0.50] - Carol 的向量:
[15.0, 0.85, 0.80]
每一列代表一个特征(feature),每一行就是一个向量。三个数字就是三个维度(dimension)。
有了向量,“谁和谁更像"就变成了"谁的数字和谁更接近”。Alice 和 Carol 三个数字都比较接近,Alice 和 Bob 三个数字都差很远。
30.3 最小实验#
在 C 语言里,向量就是一个浮点数数组:
#define DIM 3 // 维度:每个学生用 3 个数字描述
float vec[DIM]; // 一个向量,能装 3 个 float
DIM 是维度的数量。vec[0] 存第一个特征,vec[1] 存第二个,vec[2] 存第三个。
定义好维度和数组,就能做三件事:存进去、打印出来、算差值。下面这个小实验把这三件事串起来,读的时候盯住数组下标和循环。
#include <stdio.h>
#include <math.h>
#define DIM 3 // 三个维度:学习时长、出勤率、作业完成率
void init_student_vectors(float alice[DIM], float bob[DIM], float carol[DIM])
{
// Alice: 学习时长 21h,出勤率 95%,作业完成率 90%
alice[0] = 21.0f; alice[1] = 0.95f; alice[2] = 0.90f;
// Bob: 学习时长 5h,出勤率 60%,作业完成率 50%
bob[0] = 5.0f; bob[1] = 0.60f; bob[2] = 0.50f;
// Carol: 学习时长 15h,出勤率 85%,作业完成率 80%
carol[0] = 15.0f; carol[1] = 0.85f; carol[2] = 0.80f;
}
void print_vector(const char *name, const float vec[DIM])
{
printf("%-8s [", name); // 打印向量名,左对齐占 8 位
for (int i = 0; i < DIM; i++) { // 遍历每个维度
if (i > 0) printf(", "); // 维度之间加逗号分隔
printf("%6.2f", vec[i]); // 打印当前维度的值
}
printf(" ]\n");
}
void print_diff(const char *a_name, const float a[DIM],
const char *b_name, const float b[DIM])
{
printf("%s vs %s: diff [", a_name, b_name); // 打印"谁 vs 谁"
for (int i = 0; i < DIM; i++) { // 逐维度计算差值
if (i > 0) printf(", "); // 维度间加逗号
printf("%6.2f", fabsf(a[i] - b[i])); // 每个维度取绝对值
}
printf(" ]\n");
}
int main(void)
{
float alice[DIM], bob[DIM], carol[DIM];
init_student_vectors(alice, bob, carol);
printf("=== Student Vectors ===\n");
print_vector("Alice", alice);
print_vector("Bob", bob);
print_vector("Carol", carol);
printf("\n=== Pairwise Differences ===\n");
print_diff("Alice", alice, "Bob", bob);
print_diff("Alice", alice, "Carol", carol);
print_diff("Bob", bob, "Carol", carol);
return 0;
}30.4 编译运行#
保存为 vector_demo.c,编译时需要加 -lm 链接数学库(fabsf 在 math.h 里):
$ gcc vector_demo.c -o vector_demo -lm
$ ./vector_demo
运行结果:
=== Student Vectors ===
Alice [ 21.00, 0.95, 0.90 ]
Bob [ 5.00, 0.60, 0.50 ]
Carol [ 15.00, 0.85, 0.80 ]
=== Pairwise Differences ===
Alice vs Bob: diff [ 16.00, 0.35, 0.40 ]
Alice vs Carol: diff [ 6.00, 0.10, 0.10 ]
Bob vs Carol: diff [ 10.00, 0.25, 0.30 ]
Alice 和 Carol 每个维度的差值都比较小(6.00, 0.10, 0.10),Alice 和 Bob 每个维度都差很多(16.00, 0.35, 0.40)。数字告诉我们的结论和直觉一致:Alice 和 Carol 更像。
30.5 数据/内存/流程里发生了什么#
30.5.1 内存布局#
float vec[DIM] 在内存里是一段连续的空间。DIM 是 3,float 占 4 字节,所以一个向量占 12 字节:
地址 内容
0x1000 vec[0] = 21.0f (4 字节)
0x1004 vec[1] = 0.95f (4 字节)
0x1008 vec[2] = 0.90f (4 字节)三个学生就是三段这样的空间,每段 12 字节,总共 36 字节。内存里是一排浮点数,程序按下标访问。
30.5.2 维度的含义#
维度不是随便选的数字,每个维度代表一个具体的特征。这个对应关系是人定的,程序不关心维度的含义,它只管存和算。
| 下标 | 含义 | 取值范围 |
|---|---|---|
| 0 | 每周学习时长 | 0 ~ 40 |
| 1 | 出勤率 | 0.0 ~ 1.0 |
| 2 | 作业完成率 | 0.0 ~ 1.0 |
注意每个维度的量纲不同:学习时长是小时,出勤率是比例。这种量纲差异会影响"差值"的含义:学习时长差 5 小时和出勤率差 0.05,并不在同一个尺度上。程序可以直接相减,但相减以后怎么解释,需要单独处理。
30.5.3 二维的情形#
三个维度没法在纸上画。如果只取两个维度,比如学习时长和出勤率,每个学生就变成平面上的一个点:
- Alice:(21.0, 0.95)
- Bob:(5.0, 0.60)
- Carol:(15.0, 0.85)
在坐标系里画出来,Alice 和 Carol 的点离得近,Bob 的点离得远。向量的几何意义很直观:每个记录是空间中的一个点,“相似"就是"距离近”。
只看图,“近"和"远"已经很明显。数学上可以把横向差值和纵向差值合成一个数,平面上常见的做法是用勾股定理计算距离:
$$d = \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2}$$Alice 和 Bob 的距离:$\sqrt{(21-5)^2 + (0.95-0.60)^2} = \sqrt{256 + 0.1225} \approx 16.00$。
Alice 和 Carol 的距离:$\sqrt{(21-15)^2 + (0.95-0.85)^2} = \sqrt{36 + 0.01} \approx 6.00$。
Alice 和 Carol 的距离(6.00)远小于 Alice 和 Bob 的距离(16.00)。这个结论和逐维度看差值的结论一致。
这里借二维图说明向量的几何看法:一条记录可以看成空间中的一个点,点和点之间有远近。真实程序用三个维度甚至更多维度时,还要决定用哪种计算规则、要不要先调整量纲。当前程序仍然打印逐维差值,没有把"像不像"封装成一个稳定的度量函数。
30.5.4 差值怎么算#
print_diff 函数做的事情很简单:逐维度相减,取绝对值。
fabsf(a[i] - b[i])Alice 的学习时长是 21.0,Bob 是 5.0,差值是 |21.0 - 5.0| = 16.0。三个维度各算一次,得到三个差值。
这个差值数组本身也是一个向量——它描述了两个人在每个特征上的差距。
30.5.5 为什么用 float 而不是 int#
学习时长可以用 int(小时数),但出勤率 0.95 和作业完成率 0.90 是小数,必须用浮点。既然有的维度需要浮点,不如统一用 float,省得混着来。
float 有精度限制——大约 7 位有效数字。对于特征描述来说,这个精度足够。
30.6 把向量放进结构体#
上一节的代码里,alice、bob、carol 是三个独立的数组。如果学生多了,一个个数组散落在 main 里会很难管理。更好的做法是把向量放进结构体,让它成为记录的一部分:
#define DIM 3
#define NAME_LEN 32
#define MAX_STUDENTS 100
struct Student {
int id;
char name[NAME_LEN];
float vec[DIM]; // 向量是结构体的一个字段
};
struct StudentDB {
struct Student rows[MAX_STUDENTS];
int count;
};struct Student 以前只有 id、name、score。现在多了一个 float vec[DIM]。向量和 id、name 放在一起,属于同一条记录。查找、删除按 id 的思路不用变;插入记录时,需要把这一组特征数字一起存进去。
void db_init(struct StudentDB *db)
{
db->count = 0; // 初始记录数为 0
}
int db_insert(struct StudentDB *db, int id, const char *name, const float vec[DIM])
{
if (db->count >= MAX_STUDENTS) return 0; // 数据库已满,插入失败
struct Student *s = &db->rows[db->count]; // 指向下一个可用位置
s->id = id; // 写入 id
snprintf(s->name, sizeof(s->name), "%s", name); // 安全复制名字
for (int i = 0; i < DIM; i++) { // 逐维度复制向量
s->vec[i] = vec[i];
}
db->count++; // 记录数加一
return 1;
}
void db_list(const struct StudentDB *db)
{
for (int i = 0; i < db->count; i++) { // 遍历所有记录
const struct Student *s = &db->rows[i]; // 取出当前记录指针
printf("id=%d %-8s vec=[", s->id, s->name); // 打印 id 和名字
for (int j = 0; j < DIM; j++) { // 遍历向量的每个维度
if (j > 0) printf(", "); // 维度间加逗号
printf("%.2f", s->vec[j]); // 打印当前维度值
}
printf("]\n");
}
}调用方代码如下。它需要和上面的结构体定义、db_init、db_insert、db_list 放在同一个文件里编译。
// 片段:需要和上面的结构体定义、db_init、db_insert、db_list 放在同一个文件里编译
int main(void)
{
struct StudentDB db;
db_init(&db); // 初始化数据库
float a_vec[] = {21.0f, 0.95f, 0.90f}; // 三个学生的特征向量
float b_vec[] = {5.0f, 0.60f, 0.50f};
float c_vec[] = {15.0f, 0.85f, 0.80f};
db_insert(&db, 1, "Alice", a_vec); // 插入 Alice 的完整记录
db_insert(&db, 2, "Bob", b_vec); // 插入 Bob 的完整记录
db_insert(&db, 3, "Carol", c_vec); // 插入 Carol 的完整记录
printf("=== All Database Records ===\n");
db_list(&db); // 列出全部记录
return 0;
}运行结果:
=== All Database Records ===
id=1 Alice vec=[21.00, 0.95, 0.90]
id=2 Bob vec=[5.00, 0.60, 0.50]
id=3 Carol vec=[15.00, 0.85, 0.80]
记录还是那些记录,但现在每条记录多了一个向量字段。数据库从"只能查 id"变成了"每条记录带一组特征数字”。
30.7 常见坑#
坑 1:维度写错。 DIM 定义成 3,但初始化时只写了 2 个值,第三个维度是未初始化的垃圾值。数组有多少维,就要写多少个值。
float vec[DIM];
vec[0] = 21.0f;
vec[1] = 0.95f;
// vec[2] 没赋值,里面是垃圾!
安全的做法是初始化时全部写 0:
float vec[DIM] = {0}; // 全部清零,再按需覆盖
坑 2:下标越界。 vec[DIM] 的合法下标是 0 到 DIM - 1。写 vec[DIM] = 1.0f 不会报编译错误,但会写到数组后面的内存,可能破坏别的变量。
坑 3:printf 格式符不对。 float 传给 printf 时会提升成 double,要用 %f。用 %d 去打印浮点数属于未定义行为,输出不能当成有规律的结果。
坑 4:混淆维度和记录数。 DIM 是每条记录的特征数量,不是记录总数。记录总数是 db->count。两个数字含义完全不同。
坑 5:量纲不统一就直接比较。 学习时长的单位是小时(040),出勤率是比例(01)。差值 5 小时和差值 0.05 不在一个尺度上。直接把它们放进同一个总分时,学习时长会天然占更大的权重。
30.8 自己试试看#
Q1:加一个维度。 把 DIM 改成 4,给每个学生加一个"考试成绩"维度(0.0 ~ 1.0),重新运行程序,观察差值有什么变化。
提示:把 DIM 改成 4,让 vec[3] 存考试成绩。初始化时给每个学生的第 4 个分量赋值。现有的差值打印循环已经按 DIM 控制维度数,不用改。
Q2:找最大维度差值。 写一个函数 max_diff(a, b),返回两个向量在所有维度上差值最大的那个值。用它来比较三对学生。
提示:遍历 0 到 DIM - 1,用 fabsf(a[i] - b[i]) 取差值的绝对值,维护一个 max 变量,遇到更大的就更新。
Q3:批量打印差值表。 用两层循环遍历数据库里所有学生,打印一张 $n \times n$ 的差值表。
提示:外层 for (int i = 0; i < count; i++),内层 for (int j = 0; j < count; j++),对每对 (i, j) 调用差值计算函数。对角线上的值应该是 0(自己和自己的差值)。
Q4:从键盘输入向量。 改 init_student_vectors,用 scanf 让用户自己输入每个维度的值,而不是写死在代码里。
提示:用 for (int d = 0; d < DIM; d++) 循环 scanf("%f", &vec[d]),替换原来逐个赋值的写法。输入前用 printf 提示当前是第几个维度。
下一章的问题#
有了向量,每条记录从一堆文字和单个分数,变成了一组有意义的数字。这些数字排成一排,就是它在"特征空间"里的坐标。
但到目前为止,程序只打印了每个维度的差值。Alice 和 Carol 差了 6 小时、0.10 出勤率、0.10 完成率——这些差值放在一起,到底算"很像"还是"不太像"?
两个向量到底像不像,需要一个统一的度量:距离和相似度。