第30章 向量

30.1 问题从哪来#

前面的程序已经能把用户输入拆成命令和参数,数据库接到的还是一个很明确的目标:插入记录、按 id 查找、按 id 删除。按 id 查找的逻辑很直接——给一个 id,从头到尾比对,找到就返回。

但有一类问题,数据库回答不了:

  • 学生 Alice 和谁最像?
  • 这篇文章和哪篇最接近?
  • 这首歌和哪首风格相似?

这些问题不是在找"相等",而是在找"相近"。id = 3 能精确命中一条记录,但"和 Alice 最像"需要比较每条记录和 Alice 之间的差距。

要比较差距,首先得把记录变成一组数字。


30.2 先看一个例子#

假设数据库里有三个学生:

idnamescore
1Alice92
2Bob65
3Carol88

db_find(&db, 1, &out) 能精确找到 Alice。但"谁和 Alice 最像"这个问题,数据库没法回答。score 只有一个数字,Alice 和 Carol 都是高分,可她们的学习习惯可能完全不同。一个数字能比较大小,但描述太粗糙。

如果给每个学生多记几个数字呢?

name每周学习时长出勤率作业完成率
Alice21.00.950.90
Bob5.00.600.50
Carol15.00.850.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 链接数学库(fabsfmath.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 字节。内存里是一排浮点数,程序按下标访问。

三个学生的向量在内存里依次排列,每个向量占 DIM 个 float 的空间

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 的点离得远。向量的几何意义很直观:每个记录是空间中的一个点,“相似"就是"距离近”。

二维向量在坐标系里对应的点,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 把向量放进结构体#

上一节的代码里,alicebobcarol 是三个独立的数组。如果学生多了,一个个数组散落在 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 以前只有 idnamescore。现在多了一个 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_initdb_insertdb_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] 的合法下标是 0DIM - 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 完成率——这些差值放在一起,到底算"很像"还是"不太像"?

两个向量到底像不像,需要一个统一的度量:距离和相似度