一文讲透 AI 九大核心概念

AI 圈每天都在冒新词。但你真的能准确说出每一个概念的确切含义吗?本文从最底层的工程视角出发,逐层拆解,帮你建立完整的 AI 技术认知体系。


LLM,全称 Large Language Model,中文译作大语言模型,简称大模型

当前几乎所有主流大模型,底层都基于同一套架构——Transformer。这个架构最早由 Google 团队在 2017 年提出,对应的论文标题叫做《Attention Is All You Need》(注意力机制就是全部所需)。

虽然 Google 发明了这把火,但真正点燃全世界的是 OpenAI。

  • 2022 年底,ChatGPT(GPT-3.5)横空出世,成为第一个真正达到"可用级别"的大模型;
  • 2023 年 3 月,GPT-4 发布,把 AI 的能力天花板拉到了新高度。

GPT 系列是这轮 AI 浪潮的绝对引路人。时至今日,GPT 系列依然非常强大,如 GPT-4.5 仍是行业标杆之一。但如今 AI 赛道早已不是 OpenAI 一家独大,Claude、Gemini 等后起之秀都在各自擅长的领域与之同台竞技。

大模型本质上是一个文字接龙游戏

举个例子,你向大模型提问:「今天天气怎么样」

  1. 模型接收这句话,经过内部运算,预测下一个概率最高的词:「今」
  2. 模型把「今」追加到输入后面,再预测下一个词:「天」
  3. 如此循环,直到预测出特殊的结束标记

最终输出:「今天天气晴朗」

这就是大模型最底层的生成原理——一个词一个词地输出答案,因为它就是这么运作的

LLM 文字接龙流程图
LLM 文字接龙流程图


大模型本质上是一个庞大的数学系统,接收的是数字,输出的也是数字,根本不认识人类书写的文字

因此,在人类和大模型之间必须有一个中间人来做翻译,这个中间人就叫做 Tokenizer。它负责两件事:

  • 编码(Encode):把文字变成数字
  • 解码(Decode):把数字还原成文字

编码分两步:

BloomFilter中的数学推导

mm: 表示 Bloom Filter bit array 的长度;
kk: 表示 hash 函数个数;
nn: 表示插入元素的个数;

假设 hash 函数以等概率选择 bit array 的下标,那么某次 hash 后,某个特定 bit 位未被设置为 1 的概率为 11m1 - \frac{1}{m}。经过 kk 个 hash 函数之后,该 bit 位仍未被设置为 1 的概率为:

(11m)k \left(1 - \frac{1}{m}\right)^k

在插入 nn 个元素之后,某个 bit 位仍然没有被设置为 1 的概率为:

使用LLVM的libFuzzer进行fuzzy test

LLVM libFuzzer 是 LLVM 生态系统中的一个fuzzy test工具,用于自动化地发现软件程序中的漏洞和错误。它通过生成大量的随机输入数据并观察程序的行为来进行fuzzy test。 libFuzzer 是一个基于内存的fuzzy test引擎,使用 LLVM 的插桩技术和代码优化功能来提高测试效率和覆盖率。

以下是 libFuzzer 的一些功能特点:

  1. 自动化fuzzy test:libFuzzer 提供了一种自动化的fuzzy test方法,可以生成大量的随机输入数据,并在每个输入上运行目标函数进行测试。它通过观察程序的崩溃、断言失败、未定义行为等反馈来发现潜在的问题。
  2. 内存安全性:libFuzzer 通过使用 AddressSanitizer (ASan) 和 UndefinedBehaviorSanitizer (UBSan) 等工具来确保fuzzy test过程中的内存安全性。这有助于检测和报告内存错误、缓冲区溢出、使用已释放内存等问题。
  3. 代码覆盖率分析:libFuzzer 使用 LLVM 提供的代码覆盖率分析技术,帮助确定已经执行过的代码路径和未执行的代码区域。这有助于评估测试的质量和覆盖范围,并帮助发现潜在的漏洞。
  4. 快速收敛:libFuzzer 使用一种称为 “回退”(Backoff)的策略,以更快地收敛到程序中的漏洞。它会根据测试结果调整输入数据的变异程度,使得能够更快地发现问题并生成更有潜力的测试用例。
  5. 灵活性和可定制性:libFuzzer 提供了多种选项和配置参数,使用户能够根据自己的需求进行定制。例如,可以设置最大测试时间、内存消耗限制、覆盖率报告等。
  6. 多线程支持:libFuzzer 支持多线程执行,可以利用多核处理器并行进行fuzzy test,加快测试速度。

下面是一个使用 libFuzzer 的简单示例

首先我们有一个 test_fuzzy.cpp:

#include <cstddef>
#include <cstdint>

void DoSomethingWithData(const uint8_t* data, std::size_t size) {
  int* p = nullptr;
  if (size < 10) return;
  if (data[0] == 'h' && data[1] == 'e' && data[2] == 'l' && data[3] == 'l' && data[4] == '0') {
    *p = 42;
  }
  return;
}

extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, std::size_t size) {
  DoSomethingWithData(data, size);
  return 0;
}

使用 clang++进行编译:

322.零钱兑换

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。 计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。 你可以认为每种硬币的数量是无限的。

  1. 定义状态:

dp[i]dp[i]表示用所给的面值的硬币凑成金额ii所需的最少的硬币个数。

  1. 设计状态转移方程:
\[ \forall coin \in coins, 当 i \geqslant coin,且 dp[i - coin] \neq -1 时, dp[i] = std::min(dp[i], dp[i - coin] + 1) \]
  1. 初始化:

对于 amount 为 0 的情况,所需的硬币数也为 0,因此:dp[0]=0dp[0] = 0

  1. 递推求解:

这里我们使用了一个小技巧,默认将dpdp的值都填充为INT_MAX,这样就可以避免对-1这个负数做特殊的判断和处理,相当于我们用INT_MAX 来代理了-1

#include <vector>
#include <climits>

class Solution {
public:
    int coinChange(const std::vector<int>& coins, int amount) {
        std::vector<int> dp(amount + 1, INT_MAX);
        dp[0] = 0;
        for (int i = 1; i <= amount; ++i) {
            for (int coin : coins) {
                if (coin <= i && dp[i - coin] != INT_MAX) {
                    dp[i] = std::min(dp[i], dp[i - coin] + 1);
                }
            }
        }
        return dp[amount] == INT_MAX ? -1 : dp[amount];
    }
};

70.爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

这道题是一个非常典型而且很简单的动态规划题目。我们可以根据动态规划题目解题的一般思路来分析:

  1. 定义状态:

dp[i]dp[i]表示爬到第ii级楼梯的不同方法数。由于每次可以选择爬 11 级或者 22 级楼梯, 所以爬到第 ii 级楼梯的方法数等于爬到第 i1i-1 级楼梯和第 i2i-2 级楼梯的方法数之和。 根据这个关系,我们可以使用动态规划的方式从 11 级楼梯开始逐步计算到第 nn 级楼梯的方法数,最终返回 dp[n]dp[n]即为结果。

  1. 设计状态转移方程:
dp[i]=dp[i1]+dp[i2]dp[i] = dp[i - 1] + dp[i - 2]
  1. 初始化:

由题目可知,dp[0]=0dp[0] = 0; dp[1]=1dp[1] = 1,这里需要特别注意的是,dp[2]dp[0]+dp[1]dp[2] \ne dp[0] + dp[1],而是dp[2]=2dp[2] = 2,所以dp[2]dp[2]也应该作为初始值

416.分割等和子集

给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

由题可知,数组nums非空,所以分割后的两个子集也必然非空,由于都是正整数,所以nums中元素之和必然为偶数。

这道题是典型的 01 背包问题,假设dp[i][j]dp[i][j]表示nums中前ii个元素是否包含和为jj的子集,那么:

  1. nums[i] = j的时候,dp[i][j] = true
  2. nums[i] > j的时候,dp[i][j] = dp[i - 1][j]
  3. nums[i] < j的时候,dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]]
#include <vector>
#include <numeric>

class Solution {
public:
    bool canPartition(const std::vector<int>& nums) {
        int size = nums.size();
        int sum = std::accumulate(nums.begin(), nums.end(), 0);
        if (size == 1 || (sum & 1) == 1) return false;
        int target = sum / 2;
        std::vector<std::vector<bool>> dp(size + 1, std::vector<bool>(target + 1));
        for (int i = 1; i <= size; ++i) {
            for (int j = 1; j <= target; ++j) {
                int num = nums[i - 1];
                if (num == j) dp[i][j] = true;
                else if (num > j) dp[i][j] = dp[i - 1][j];
                else dp[i][j] = dp[i - 1][j] | dp[i - 1][j - num];
            }
        }
        return dp[size][target];
    }
};

84. 柱状图中最大的矩形

给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。 求在该柱状图中,能够勾勒出来的矩形的最大面积。

主要思路是,遍历每个柱子,然后往柱子左右两边寻找比当前柱子矮的位置,从而计算出,以当前柱子为高度,所能围成的最大面积。 然后将这些面积中最大的值返回即可。暴力求解的时间复杂度为O(n^2)

不过我尝试过各种暴力求解,在 leetcode 中提交后都会超时。

class Solution {
public:
    int largestRectangleInHistogram(const std::vector<int>& inputs) {
        std::size_t n = inputs.size();
        int max_area = 0;
        for (int i = 0; i < n; ++i) {
            int min_height = INT_MAX;
            for (int j = i; j < n; ++j) {
                min_height = std::min(min_height, inputs[j]);
                max_area = std::max(max_area, min_height * (j - i + 1));
            }
        }
        return max_area;
    }
};
class Solution {
public:
    int largestRectangleInHistogram(const std::vector<int>& inputs) {
        int n = inputs.size();
        std::stack<int> stk;
        int ret = 0;
        for (int i = 0; i < n; ++i) {
            while (!stk.empty() && inputs[stk.top()] > inputs[i]) {
                int w = i;
                int h = inputs[stk.top()];
                stk.pop();
                if (!stk.empty()) {
                    w = i - stk.top() - 1;
                }
                ret = std::max(ret, w * h);
            }
            stk.push(i);
        }
        while (!stk.empty()) {
            int w = n;
            int h = inputs[stk.top()];
            stk.pop();
            if (!stk.empty()) {
                w = n - stk.top() - 1;
            }
            ret = std::max(ret, w * h);
        }

        return ret;
    }
};

155.最小栈

设计一个支持 pushpoptop 操作,并能在常数时间内检索到最小元素的栈。 实现 MinStack 类: MinStack() 初始化 void push(int val) 将元素推入堆栈 void pop() 删除堆栈顶部的元素 int pop() 获取堆栈顶部的元素 int getMin() 获取堆栈中的最小元素

这道题首先要满足堆栈的特性 LIFO,其次是能够在常数时间内获取当前栈中最小的元素,因此我们可以用堆栈保存 个二元组,二元组的第一个元素是存入栈中的值,第二个元素是当前元素作为栈顶元素的时候,栈中的最小值。有 了这个思路,代码实现起来就很简单了。

class MinStack {
public:
    MinStack() = default;

    void push(int val) {
        stack_.emplace(val, std::min(val, getMin()));
    }

    void pop() {
        stack_.pop();
    }

    void top() {
        return stack_.top().first;
    }

    int getMin() {
        if (stack_.empty()) return INT_MAX;
        return stack_.top().second;
    }
private:
    std::stack<std::pair<int, int>> stack_;
};

24.两两交换链表中的节点

给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。

这道题,最最简洁的方法应该就是使用递归了。主要思路是,每两个一组,进行交换,然后递归执行。

class Solution {
public:
    LinkedList* swapPairs(LinkedList* head) {
        if (!head || !head->next) return head;
        // 当前组下的新的head
        LinkedList* newHead = head->next;
        head->next = swapPairs(newHead->next);
        newHead->next = head;
        return newHead;
    }
};

232.用栈实现队列

请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(push、pop、peek、empty): 实现 MyQueue 类:

  • void push(int x) 将元素 x 推到队列的末尾
  • int pop() 从队列的开头移除并返回元素
  • int peek() 返回队列开头的元素
  • boolean empty() 如果队列为空,返回 true ;否则,返回 false

说明: 你 只能 使用标准的栈操作 —— 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。 你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。

堆栈的特性是 LIFO,而队列则是 FIFO,因此想要用堆栈实现队列,需要用两个堆栈的 LIFO 叠加效果,来实现 FIFO。

class MyQueue {
public:
    MyQueue() = default;

    void push(int x) {
        while (!output_.empty()) {
            input_.push(output_.top());
            output_.pop();
        }
        input_.push(x);
    }

    int pop() {
        while (!input_.empty()) {
            output_.push(input_.top());
            input_.pop();
        }
        int ret = output_.top();
        output_.pop();
        return ret;
    }

    int peek() {
        while (!input_.empty()) {
            output_.push(input_.top());
            input_.pop();
        }
        return output_.top();
    }

    bool empty() {
        return input_.empty() && output_.empty();
    }
private:
    std::stack<int> input_;
    std::stack<int> output_;
};