【AI】Agent技术原理和应用

本文介绍了 AI Agent 相关内容
原文:

本文介绍了 AI Agent 相关内容
原文:

本文介绍了LLM一般的开发流程和运行环境相关内容
接上文,了解了LLM的发展历程,从简单的线性回归模型到神经网络,再到生成式预训练转换器。
现在更多从实际部署运行的操作上,介绍下LLM在各个平台上的运行。
根据 OpenAI 联合创始人 Andrej Karpathy 在微软 Build 2023 大会上所公开的信息,OpenAI 所使用的大规模语言模型构建流程如下图所示。主要包含四个阶段:预训练、有监督微调、奖励建模、强化学习。这四个阶段都需要不同规模数据集合以及不同类型的算法,会产出不同类型的模型,同时所需要的资源也有非常大的差别。

任何AI模型,要让神经网络输出的结果足够准确,必须以大量的数据来做训练,调节权重参数。数据的准备过程是整个流程的基石,数据的质量和规模直接决定了模型能力的上限。
首先需要构建一个规模庞大(通常是 PB 级别,即数千 TB)且多样化的数据集。来源包括网页数据、书籍、代码、专业文献、对话数据等。 然后要进行数据清洗。原始数据非常“脏”,必须进行严格清洗。例如要进行去重,移除完全相同或高度相似的文本,防止模型过度拟合这些重复内容。还有从质量上过滤,移除低质量文本,有害内容移除,过滤掉色情、暴力、极端言论等内容。最后还有隐私考量,移除个人身份信息。
数据集准备完成之后,要进行分词。计算机不认识“文字”,只认识数字。分词就是将文本转换成模型能理解的数字序列,它能有效地平衡词汇表大小和序列长度。
这是最耗时、最烧钱的阶段,目的是让模型掌握语言的“本质”。
现代 LLM 几乎都基于 Transformer 架构。最主流的是 Decoder-only (仅解码器) 架构(如 GPT 系列、Llama、PaLM),它非常擅长生成任务。
最常见的训练目标目标是 Next-Token Prediction 。简单来说,就是给模型一段文本,让它预测下一个最可能出现的词。
例如,输入 "The quick brown fox jumps over the",模型需要输出 "lazy"。
通过在数万亿个 Token 上反复进行这个任务,模型可以学习语法、语义、事实知识、上下文理解甚至一定的推理能力。
同时,这一步需要极大规模的算力集群,通常由数千甚至上万块高端 GPU 组成,互联成一个超级计算机。训练时间可能长达数周甚至数月,成本高达数千万至数亿美元。
预训练结束后,我们得到一个 “基础模型” (Base Model)。这个模型博学,但“野性难驯”,它只会续写文本,不一定会听从指令(比如你让它“写首诗”,它可能会续写成“……是一个常见的作业要求”)。
这是让模型从“文本续写机”变为“智能助手”的关键一步,目的是让模型的输出符合人类的意图 (Intent) 和价值观 (Values)。
这个阶段主要包含两个步骤:
教会模型理解并执行指令。雇佣大量的数据标注员,编写高质量的“指令-回答”对(Prompt-Response pairs),利用这些少量高质量数据集合。
例如:
提示词(Prompt):复旦大学有几个校区?
理想输出:复旦大学现有 4 个校区,分别是邯郸校区、新江湾校区、枫林校区和张江校区。其中邯郸校区是复旦大学的主校区,邯郸校区与新江湾校区都位于杨浦区,枫林校区位于徐汇区,张江校区位于浦东新区。
利用这些有监督数据,使用与预训练阶段相同的语言模型训练算法,在基础语言模型基础上再进行训练,从而得到有监督微调模型(SFT 模型)。让它学会“对话”和“问答”的模式。
经过训练的 SFT 模型具备了初步的指令理解能力和上下文理解能力,能够完成开放领域问题、阅读理解、翻译、生成代码等能力,也具备了一定的对未知任务的泛化能力。
由于有监督微调阶段的所需的训练语料数量较少,SFT 模型的训练过程并不需要消耗非常大量的计算。根据模型的大小和训练数据量,通常只需要数十块GPU,花费数天时间完成训练。
SFT 模型具备了初步的任务完成能力,可以开放给用户使用,很多类 ChatGPT的模型都属于该类型,包括:Alpaca[38]、Vicuna[39]、MOSS 、 ChatGLM-6B 等。
很多这类模型效果也非常好,甚至在一些评测中达到了 ChatGPT 的 90% 的效果。当前的一些研究表明有监督微调阶段数据选择对 SFT 模型效果有非常大的影响,因此如何构造少量并且高质量的训练数据是本阶段有监督微调阶段的研究重点。
让模型的回答更有用、更诚实、更无害。主流技术是 RLHF (Reinforcement Learning from Human Feedback,基于人类反馈的强化学习)。主要有两步:
奖励建模阶段目标是 构建一个文本质量对比模型 ,对于同一个提示词,SFT模型给出的多个不同输出结果的质量进行排序。
奖励模型(RM 模型)可以通过二分类模型,对输入的两个结果之间的优劣进行判断。RM 模型与基础语言模型和 SFT 模型不同,RM 模型本身并不能单独提供给用户使用。
奖励模型的训练通常和 SFT 模型一样,使用数十块 GPU,通过几天时间完成训练。由于 RM 模型的准确率对于强化学习阶段的效果有着至关重要的影响,因此对于该模型的训练通常需要大规模的训练数据。
Andrej Karpathy 在报告中指出,该部分需要百万量级的对比数据标注,而且其中很多标注需要花费非常长的时间才能完成。

如图,InstructGPT 系统中奖励模型训练样本标注示例。可以看到,示例中文本表达都较为流畅,标注其质量排序需要制定非常详细的规范,标注人员也需要非常认真的对标规范内容进行标注,需要消耗大量的人力,同时如何保持众包标注人员之间的一致性,也是奖励建模阶段需要解决的难点问题之一。
此外 奖励模型的泛化能力边界 也是在本阶段需要重点研究的另一个问题。如果 RM 模型的目标是针对所有提示词系统所生成输出都能够高质量的进行判断,该问题所面临的难度在某种程度上与文本生成等价,因此如何限定 RM 模型应用的泛化边界也是本阶段难点问题。
强化学习阶段根据数十万用户给出的提示词,利用在前一阶段训练的 RM 模型,给出 SFT 模型对用户提示词补全结果的质量评估,并与语言模型建模目标综合得到更好的效果。
该阶段所使用的提示词数量与有监督微调阶段类似,数量在十万量级,并且不需要人工提前给出该提示词所对应的理想回复。使用强化学习,在 SFT 模型基础上调整参数,使得最终生成的文本可以获得更高的奖励(Reward)。该阶段所需要的计算量相较预训练阶段也少很多,通常也仅需要数十块 GPU,经过数天时间的即可完成训练。
强化学习和有监督微调的对比,在模型参数量相同的情况下,强化学习可以得到相较于有监督微调好得多的效果。关于为什么强化学习相比有监督微调可以得到更好结果的问题,截止到 2023 年 9 月也还没有完整和得到普遍共识的解释。
此外,Andrej Karpathy 也指出强化学习也并不是没有问题的,它会使得基础模型的熵降低,从而减少了模型输出的多样性。在经过强化学习方法训练完成后的 RL 模型,就是最终提供给用户使用具有理解用户指令和上下文的类 ChatGPT 系统。
由于强化学习方法稳定性不高,并且超参数众多,使得模型收敛难度大,再叠加 RM 模型的准确率问题,使得在大规模语言模型如何能够有效应用强化学习非常困难。
DPO (Direct Preference Optimization) 是一种更新的、更简单的替代技术,它跳过了训练独立奖励模型的步骤,直接使用偏好数据来优化模型,效果也非常好。
如何知道模型有多好?需要一套严格的“考试”体系。
这是将模型交付给最终用户的阶段。
Hugging Face 是一家在人工智能(AI)领域迅速崛起的开源社区平台和公司,专注于让机器学习的构建和使用变得更加简单和开放。它最初是一家开发聊天机器人应用的公司,但后来转型并成为了全球AI领域,尤其是自然语言处理 (NLP) 领域的关键资源中心。
Hugging Face 的核心理念是 开放科学 和 民主化AI,通过提供一系列工具、库和平台,使得研究人员和开发者能够轻松地共享、发现、训练和部署机器学习模型和数据集。
Hugging Face Hub 是一个类似 GitHub 的平台,但专门用于托管和共享机器学习模型、数据集和演示空间。
Hugging Face 对机器学习社区产生了深远的影响。通过提供开源工具和平台,它极大地促进了AI模型的开放共享和协作。降低AI了门槛,使得非专业人士也能更容易地使用和部署复杂的AI模型,推动了AI的普及。标准化了模型接口和共享机制,加速研究与开发,让研究人员可以更快地构建和迭代新模型,开发者可以更快地将研究成果转化为实际应用。Hugging Face 聚集了全球大量的AI研究者和开发者,共同推动AI技术的发展。
总的来说,Hugging Face 已经成为机器学习领域不可或缺的一部分,无论你是研究人员、开发者还是仅仅对AI感兴趣,它都提供了丰富的资源和工具来帮助你探索和构建AI应用。作为一名安卓开发者,Hugging Face 也能帮助你更便捷地将强大的AI能力集成到你的移动应用中。
魔塔AI社区 是一个聚焦于人工智能(AI)技术交流与实践的线上社区平台,致力于为开发者、研究者、学生及AI爱好者提供技术分享、协作创新和资源整合的空间。
分主题板块(如模型架构设计、算法优化、行业应用案例),支持代码片段分享、论文解读和技术争议探讨。定期举办“技术擂台”或“挑战赛”(如模型压缩竞赛、数据集标注比赛),激发创新思维。
同时,提供代码托管功能(类似GitHub集成),支持成员发布AI项目、工具库或插件,并通过社区协作迭代优化。设立“明星项目”推荐板块,帮助优质项目获得曝光和贡献者。
还有学习与资源中心,整理AI领域的经典教材、课程笔记、工具链指南(如PyTorch/TensorFlow实战手册),并附有社区成员的批注与实战反馈。开设“新手村”板块,提供从环境配置到模型训练的逐步教程,降低入门门槛。
分享AI在医疗、金融、游戏等垂直行业的落地案例,讨论技术适配与商业化挑战。设立“内推直通车”和“技能树自测”功能,帮助成员对接企业需求,提升就业竞争力。
魔塔AI社区不仅是技术交流的场所,更是一个推动AI技术民主化、产业化的生态平台。无论是寻求答案的学习者、寻找协作的开发者,还是希望探索商业机会的创业者,都能在这里找到对应的价值支点。
目前,绝大多数大型 LLM 都基于Transformer (注意力机制) 架构。Transformer 是一种特殊的神经网络,它有两个主要部分:
Transformer 的关键是自注意力机制 (Self-Attention)。它允许模型在处理一个词时,同时考虑输入序列中所有其他词的重要性,从而捕捉词与词之间的长距离依赖关系。
Transformer 架构图示:

大模型之所以对 GPU(图形处理器) 有着“饥渴”般的需求,根本原因在于它们的架构特性和计算模式与 GPU 的设计理念完美契合。
CPU 就像一个拥有几名博士的团队,擅长攻克复杂的独立难题;而 GPU 更像一个拥有成千上万名小学生的大部队,虽然每个小学生只能做简单的加减法,但他们可以同时处理数百万个简单的加减法,效率惊人。大模型的计算任务,恰恰就是这种“数百万个简单加减法”的集合。
大模型,特别是深度神经网络,在训练和推理过程中会进行天文数字般的数学运算,核心就是大量的矩阵乘法(Matrix Multiplication)和向量运算(Vector Operations)。
在神经网络中,每一层神经元从前一层接收输入,然后通过与权重矩阵相乘来计算其输出。这个过程就是矩阵乘法。 矩阵乘法计算:一个 m X n 的 A 行列式,和一个 n X t 的 B 行列式可以进行乘法运算得到一个 C 行列式。拿 A 的第 i 行(n个元素)和 B 的第 j 列(n个元素)元素乘积之和,就是 C 的第 [i,j] 位的元素值。
大模型的“大”体现在其巨量的参数(Parameters)上。这些参数就是模型从训练数据中学习到的“知识”和“权重”。一个大模型可能拥有数十亿、数千亿甚至上万亿个参数。
每次进行前向传播(推理)或反向传播(训练)时,每个输入数据点都必须与模型的所有相关参数进行交互计算。参数量越大,需要进行的乘加运算就越多。这些参数通常以巨大的矩阵形式存储,GPU 高效的并行处理能力是处理如此大规模矩阵运算的关键。
深度学习中的计算,尤其是权重和激活值的计算,通常使用浮点数(Floating-point Numbers)。大模型需要执行大量的浮点数乘法和加法。GPU 在设计时就高度优化了浮点运算单元。例如,NVIDIA 的 Tensor Cores 就是专门为深度学习中的混合精度计算(FP16/BF16)而设计的,能够极大地加速这些浮点运算。
大模型不仅参数量大,每次处理的数据批次(batch size)也可能很大,这意味着在计算过程中需要频繁地读取和写入大量数据。GPU 需要快速地从其显存中读取模型参数和输入数据,并将中间结果写回显存。现代 GPU 配备了高速的 GDDR 显存(如 GDDR6 或 HBM),拥有比 CPU 系统内存高得多的内存带宽,可以快速地吞吐大量数据。同时,高端 GPU 也提供了数十 GB 甚至上百 GB 的显存容量来存储庞大的模型参数。
NVIDIA 不仅仅提供了强大的 GPU 硬件,还开发了 CUDA 这样的并行计算平台,以及专门针对深度学习的 cuDNN 等库。
CUDA(Compute Unified Device Architecture,统一计算设备架构)是 NVIDIA 公司开发的一种并行计算平台和编程模型。简单来说,它是一套让软件能够利用 NVIDIA 图形处理器(GPU) 强大计算能力的工具和接口。CUDA 的核心思想是让你能够编写代码,并将其中适合并行计算的部分“卸载”到 GPU 上执行。
它主要通过以下几个方面实现:
nvcc,用于编译 CUDA C/C++ 代码)、库(如 cuDNN 用于深度学习,cuBLAS 用于线性代数)、调试工具、性能分析工具等,帮助开发者编写、优化和部署 GPU 加速的应用。即使没有高性能的独立 GPU 的设备也可以运行大模型,但这通常会有一些重要的限制和权衡。
CPU 也能执行并行计算 ,现代 CPU 通常有多个核心,并且支持 SIMD (Single Instruction, Multiple Data) 指令集(如 Intel 的 AVX、SSE 指令集),这使得它们能够同时处理少量数据。当没有 GPU 时,机器学习框架(如 TensorFlow 或 PyTorch)会自动回退到使用 CPU 来执行所有的矩阵乘法和其他计算。这些框架的 CPU 版本也会进行高度优化,以尽可能利用 CPU 的并行能力。
量化和剪枝是让大模型在资源受限设备上运行的关键技术。
这些优化技术可以在保持模型大部分性能的同时,显著降低其对计算资源(包括 CPU)的需求。
相比于大模型的训练,推理阶段的计算量相对较小 ,训练大模型需要极其强大的 GPU,因为它涉及数万亿次的参数更新,需要多次迭代和反向传播。而在 推理(Inference)阶段,即模型用于实际预测时,只需要进行前向传播。虽然计算量依然庞大,但比训练时少得多。对于量化后的模型,推理的计算需求会进一步降低。
在没有 GPU 的电脑上运行大模型也会面临一些问题。
首先就是推理速度会比有 GPU 的系统慢很多,尤其是对于较大的模型和批次处理。其次,在这些设备上训练大型模型几乎不可能,因为 CPU 的计算能力和内存带宽远远不足。
在性能受限的设备上通常只能运行经过高度优化、量化甚至剪枝的较小版本的大模型,原始的巨型模型无法加载或运行。由于几乎所有的计算任务都需要 CPU 来完成,CPU 满负荷运行时能耗较高,可能导致设备发热严重。
一个大模型(特指大型语言模型 LLM)在运行时,其进程内部包含多个核心组件协同工作,才能完成从接收输入到生成输出的全过程。这些组件涵盖了数据处理、模型计算和结果输出等多个环节。
一个典型的大模型运行时进程通常会包含以下核心组件:
大模型无法直接理解人类的自然语言文本。分词器是模型处理文本的第一步,它将输入的原始文本(如句子、段落)分解成模型能够理解的最小单位,这些单位称为 “token” 。
Token 可以是单词、子词(例如 “un”、”happy” 组成 “unhappy”),甚至是单个字符。
分词器还将每个 token 映射到一个唯一的整数 ID。
["Hello", ",", " ", "world", "!"],并转换为对应的 ID 序列 [15496, 11, 220, 995, 0]。正确的分词对于模型理解输入和生成连贯的输出至关重要。不同的模型可能使用不同的分词器。
这个组件是大模型的心脏,负责执行实际的推理计算。它是一个由数千亿甚至上万亿个参数组成的深度神经网络(通常基于 Transformer 架构)。
核心接收分词器输出的 token ID 序列作为输入。将这些 ID 转换为词嵌入(Word Embeddings),这是一种高维向量表示,捕捉了 token 的语义信息。通过多层 Transformer 块进行复杂的数学运算(主要是矩阵乘法和激活函数),处理这些嵌入,捕捉文本中的上下文关系、语法结构和语义含义。最终输出每个位置上下一个 token 预测的概率分布。
模型核心输出的是下一个 token 的概率分布,而生成策略则根据这些概率来实际选择下一个 token,并决定何时停止生成。这好比模型给出了所有可能的字词及其概率,而生成策略就是选择最合理的那一个。常见的生成策略有:
不同的生成策略会显著影响模型输出的质量、多样性和流畅性。
大模型在生成文本时需要记住之前的对话或输入内容,这就是上下文(Context)。上下文管理组件负责维护和更新模型当前处理的上下文。将新生成的 token 添加到现有上下文中。在上下文过长时(超过模型的上下文窗口限制),需要执行截断或滑动窗口等策略来管理,确保模型始终在有效范围内工作。
将模型生成的新 token ID 转换回人类可读的文本。通过分词器的逆向操作(detokenization)转换回字符串。同时可能还包括对输出文本的格式化、清理或后处理,例如删除多余的空格、处理特殊字符等。
这个组件不是模型本身的一部分,但它是大模型进程运行的底层基础。它负责将模型核心的计算任务调度到硬件加速器(如 GPU 或 NPU)上执行,并管理数据在内存和显存之间的传输。比如利用 CUDA、cuDNN (NVIDIA GPU) 或其他 AI 加速器 SDK (如 Android 设备的 NPU 驱动)。可以优化数据加载和计算流,以最大化硬件利用率。如果缺乏强大的硬件接口和底层优化,即使有优秀的模型,也无法高效运行。

本文介绍了AI领域的不同范围的分层概念,以及现在强大的LLM是如何进化而来的
手动翻译自:
How Large Language Models work? From zero to ChatGPT
得益于 大型语言模型(Latge Language Models 简称LLM) 的迅速发展,人工智能领域如今几乎吸引了所有人的目光。
ChatGPT,或许是最著名的LLM,由于自然语言本身就是一种非常自然的交互方式,这使得人工智能领域的最新突破变得触手可及,因此其人气迅速飙升。然而,除非您是数据科学家或其他与AI相关的职位,否则对于LLM的工作原理仍然是不太清楚的。在本文中,我将尝试帮您改变这个现状。
要讲清楚很难,毕竟,我们今天拥有的强大的大模型(LLM)是数十年人工智能研究的结晶。遗憾的是,大多数涉及AI的文章都属于以下两种情况:要么技术性很强,需要大量的先验知识;要么内容过于琐碎,最终你并不会比之前获得更多收获。
本文旨在在这两种方法之间取得平衡。或者换个说法,它旨在带你从零开始,一路了解大模型(LLM)的培养方式,以及它们为何如此出色。我们将通过逐步梳理所有相关内容来实现这一点。
本文不会深入探讨所有细节,因此我们将尽可能地依靠直觉而非数学,并尽可能地使用视觉工具。正如你将看到的,虽然LLM在细节上确实非常复杂,但它背后的主要机制非常直观,仅凭这一点就能让我们走得更远。
本文也能帮助你更好地利用像 ChatGPT 这样的大模型 (LLM)。事实上,我们将学习一些巧妙的技巧,帮助你提高获得有用回复的几率。
但首先,让我们尝试了解大模型在人工智能领域中的地位。
人工智能领域通常被形象地分为多个层次:

机器学习的目标是发现数据中的模式。或者更具体地说,是描述 输入和结果之间关系 的模式。最好用一个例子来解释这一点。
假设我们想区分我最喜欢的两种音乐类型:雷鬼音乐和R&B。
雷鬼音乐是一种拉丁都市音乐,以其活泼的节奏和适合跳舞的韵律而闻名;而 R&B(节奏布鲁斯)则是一种根植于非裔美国音乐传统的音乐类型,其特点是深情的唱腔以及轻快和慢节奏歌曲的融合。
假设我们有 20 首歌曲。我们知道每首歌曲的节奏和能量,这两个指标对于任何歌曲来说都可以轻松测量或计算。此外,我们还给每首歌曲贴上了不同的风格标签,例如雷鬼或 R&B。当我们将数据可视化时,我们可以看到,高能量、高节奏的歌曲主要属于雷鬼,而低节奏、低能量的歌曲主要属于 R&B,这很合理。
然而,我们希望避免总是手动标记音乐类型,因为这既耗时又不可扩展。相反,我们可以学习歌曲指标(节奏、能量)与音乐类型之间的关系,然后仅使用现有的指标进行预测。
用机器学习的术语来说,我们称之为分类问题,因为结果变量(音乐类型)只能归类到一组固定的类别/标签之一,这里指的是雷鬼和 R&B。这与回归问题不同,回归问题的结果是连续值(例如温度或距离)。
现在,我们可以使用标记好的数据集(即使用一组我们已知类型的歌曲)来“训练”一个机器学习模型(或“分类器”) 。直观地说,模型的训练就是找到最能区分两个类别的界线。
这有什么用呢?嗯,既然我们知道了这条界线,对于任何一首新歌,我们都可以预测它是雷鬼音乐还是 R&B 歌曲,这取决于这首歌落在界线的哪一边。我们只需要节奏和能量,我们假设这些更容易获得。这比人工为每首歌分配类型要简单得多,也更具可扩展性。
此外,正如你所想象的,距离这条线越远,我们就越能确定预测的正确性。因此,我们通常也可以根据与这条线的距离来判断我们对预测的正确性的信心程度。例如,对于我们新的低能量、低节奏的歌曲,我们可能有 98% 的把握认为这是一首 R&B 歌曲,而只有 2% 的可能性认为它实际上是雷鬼音乐。

但当然,现实往往比这更复杂。
划分类别的最佳边界可能并非线性的。换句话说,输入和结果之间的关系可能更加复杂。它可能呈曲线状,甚至比曲线还复杂很多倍。
现实里,评价的尺度也更多,也更加复杂,可能有数十、数百甚至数千个输入变量,而不是像我们示例中那样只有两个输入。此外,我们通常有两个以上的类别。所有的类别都可能通过一种极其复杂的非线性关系依赖于所有这些输入。
即使以我们的例子来说,我们也知道现实中音乐类型远不止两种,而且除了节奏和能量之外,我们还需要更多其他指标。它们之间的关系可能也没有那么简单。
我主要想让你记住的是:输入和输出之间的关系越复杂,我们用来学习这种关系的机器学习模型就越复杂、越强大。通常,复杂性会随着输入数量和类别数量的增加而增加。
除此之外,我们还需要更多数据。你很快就会明白为什么这很重要。
现在我们来讨论一个略有不同的问题,但我们会尝试运用之前的思维模型。在新问题中,我们输入一张图片,例如这张包里有一只可爱的猫的图片(因为有猫的例子总是最好的)。

至于结果,假设这次我们有三种可能的标签:老虎、猫和狐狸。如果你需要一些动机来完成这项任务,假设我们想要保护一群羊,如果看到老虎就发出警报,但如果看到猫或狐狸就不发出警报。
我们已经知道这又是一个分类任务,因为输出只能属于几个固定的类别之一。因此,就像之前一样,我们可以简单地使用一些可用的标记数据(即带有指定类别标签的图像)来训练机器学习模型。
然而,由于计算机只能处理数字输入,我们究竟如何处理视觉输入呢?
当然,我们的歌曲指标——能量和节奏——都是数字。幸运的是,图像也只是数字输入,因为它们由像素组成。它们有高度、宽度和三个通道(红、绿、蓝)。因此,理论上,我们可以直接将像素输入机器学习模型(暂时忽略这里的空间元素,我们之前没有处理过)。
然而,现在我们面临两个问题。首先,即使是一张小尺寸、低质量的 224x224 图像,其像素也超过 15 万(224x224x3)。记住,我们之前讨论的是输入变量最多只有几百个(很少超过一千个),但现在突然就至少有 15 万个了。
其次,如果你思考原始像素和类别标签之间的关系,就会发现它极其复杂,至少从机器学习的角度来看是如此。我们人类的大脑拥有惊人的能力,通常能够轻松区分老虎、狐狸和猫。然而,如果你逐个查看这 15 万像素,你根本无法知道图像中包含什么。而这正是机器学习模型看待它们的方式,因此它需要从头学习这些原始像素与图像标签之间的映射关系,这并非易事。
让我们考虑另一种极其复杂的输入输出关系——句子与其情感之间的关系。我们所说的情感通常是指句子所传达的情感,可以是积极的,也可以是消极的。

让我们再次形式化地描述问题设置:这里的输入是一系列单词,也就是一个句子,情绪是我们的结果变量。和之前一样,这是一个分类任务,这次的输出目标有两个可能的标签,即正面或负面。
与前面讨论的图像示例一样,作为人类,我们自然地理解这种关系,但是我们能否教会机器学习模型做同样的事情呢?
在回答这个问题之前,我们首先要明确,如何将单词转换为机器学习模型的数字输入。事实上,这比我们在图像中看到的情况要复杂一到两个层次,因为图像本质上已经是数字了,但单词的情况并非如此。我们在这里就不赘述了,但你需要知道的是,每个单词都可以转换成一个词向量。
简而言之,词向量表示单词在特定语境下的语义和句法意义。这些词向量可以在机器学习模型训练过程中获得,也可以通过单独的训练过程获得。通常,每个单词的词向量包含数万个变量。
总而言之,我们可以将一个句子转换成一系列数字输入,即词向量,它包含语义和句法含义。然后,我们可以将其输入到机器学习模型中。
很好,我们把一个句子成功转换为了数字输入,但是仍然面临和视觉输入相同的挑战。可以想象,对于一个长句子(或段落,甚至整个文档),由于词向量的规模很大,我们就会面临大量的输入。
第二个问题是语言与其情感之间的关系,这很复杂——非常复杂。举个例子:
“那真是一次伟大的跌倒”
这样的句子,它可能会有很多种解读方式(更不用说讽刺了)。
我们需要的是极其强大的机器学习模型和海量数据。这正是深度学习的用武之地。
通过了解机器学习的基础知识和使用更强大模型背后的动机,我们已经朝着理解 LLM 迈出了重要一步,现在我们将继续尝试介绍深度学习。
我们讨论过,如果输入和输出之间的关系非常复杂,并且输入或输出变量的数量很大(之前的图像和语言示例就属于这种情况),我们就需要更灵活、更强大的模型。我们之前提到的线性模型或任何类似的模型都无法解决这类视觉或情感分类任务。
这就是 神经网络(Neural networks) 发挥作用的地方。
神经网络是强大的机器学习模型,能够对任意复杂的关系进行建模。它们是大规模学习此类复杂关系的引擎。
事实上,神经网络大致上受到了人类大脑的启发,尽管两者之间的相似之处尚有争议。它们的基本架构相对简单。它们由一系列相互连接的“神经元”层组成,输入信号经过这些神经元层来预测结果变量。你可以将它们想象成 堆叠在一起的多层线性回归,并在其间添加非线性,这使得神经网络能够模拟高度非线性的关系。
神经网络通常具有多层深度(因此称为深度学习),这意味着它们可以非常大。例如,ChatGPT 的背后,是一个由 1760 亿个神经元组成的神经网络,这比人类大脑中大约 1000 亿个神经元还要多。
因此,从这里开始我们将使用神经网络作为我们的机器学习模型,并考虑到我们还学习了如何处理图像和文本。
最后,我们可以开始讨论大型语言模型了,这才是真正有趣的地方。如果你已经读到这里,那么你应该已经具备了理解 LLM 所需的所有知识。
有什么好的开始方式吗?或许可以先解释一下大型语言模型的真正含义。我们已经知道 “大型” 的含义,在这里,它仅仅指的是神经网络中神经元(也称为参数)的数量。大型语言模型的构成并没有明确的数字,但你可以将超过 10 亿个神经元的模型视为大型模型。
既然已经明确了这一点,那么“语言模型”又是什么呢? 接下来我们来讨论一下。并且,我们稍后还会了解 ChatGPT 中的 GPT 代表什么。不过,我们还是一步一步来吧。
让我们将以下想法构建成一个机器学习问题:
给定一个词序列,例如输入一个一个句子或段落,输出下一个可能的词是什么?

换句话说,我们只是想学习如何随时预测下一个词。此前,我们已经学习了如何将一个句子转换陈数字输入,并判断其情绪。事实上,这个任务与我们之前看到的情绪分类并无二致。
就像那个例子一样,神经网络的输入是一系列单词,但现在,输出是预测下一个单词。同样,这只是一个分类任务。唯一的区别在于,我们不再只有两个或几个类别,而是拥有与单词数量一样多的类别——假设大约 50,000 个。这就是语言建模的意义所在——学习预测下一个单词。
好吧,你可以想象,这比二元情绪分类要复杂几个数量级。但既然我们也了解了神经网络及其强大的威力,对这个问题唯一的回应其实就是“为什么不呢?”
快速免责声明:我们在这里简化了很多内容,实际上情况要复杂一些,但这不应该妨碍我们理解主要机制,因此我们简化并省略了一些细节。
我们知道了任务,现在我们需要数据来训练神经网络。实际上,为我们的“下一个单词预测”任务创建大量数据并不难:互联网、书籍、研究论文等等都拥有丰富的文本。我们可以轻松地从所有这些文本中创建一个庞大的数据集。我们甚至不需要标记数据,因为下一个单词本身就是标签,这就是为什么它也被称为自监督学习。

上图展示了这个过程。一个序列可以转换成多个序列进行训练。我们有很多这样的序列。重要的是,我们对许多短序列和长序列(有些序列长达数千个单词)都进行了同样的操作,以便在每个上下文中都能学习到下一个单词应该是什么。
总而言之,我们在这里所做的就是训练一个神经网络(LLM)来预测给定单词序列中的下一个单词,无论该序列是长是短,是德语、英语还是其他任何语言,无论是推文、数学公式、诗歌还是代码片段。所有这些序列都可以在训练数据中找到。
如果我们拥有足够大的神经网络和足够的数据,LLM 就能非常擅长预测下一个单词。它会完美吗?当然不会,因为一个序列后面通常有多个单词。但它会擅长选择一个在句法和语义上都合适的单词。

现在我们的神经网络可以根据一段文本来预测下一个单词了,我们可以将扩展后的文本再次反馈给LLM,预测另一个单词,以此类推。
换句话说,使用我们训练好的LLM,我们现在可以生成文本,而不仅仅是一个单词。这就是为什么LLM是我们所说的生成式人工智能的一个例子。我们刚刚教会了LLM说话,也就是说,一次一个单词。
我认为还有一个细节需要理解。我们不一定总是要预测出最准确的单词。我们可以让模型在给定时间内,从最可能一些单词中选取出5个可能的结果。因此,我们或许可以从大模型(LLM)中获得更多创造力。有些大模型实际上允许你选择输出的确定性或创造性程度(temperature)。这也是为什么在使用这种取样策略的ChatGPT中,你重新生成响应时通常不会得到相同的答案。
说到 ChatGPT,你现在可能会问,为什么它不叫 ChatLLM。事实证明,语言建模(Language Models)并非故事的终点——事实上,它只是一个开始。那么,ChatGPT 中的 GPT 到底代表什么呢?

实际上,我们刚刚了解了 G 的含义,即 “generative(生成式)” ——这意味着它是以语言生成为目的进行训练的,这一点我们之前已经讨论过了。那么 P 和 T 呢?
这里我们先简单聊一下 T ,它代表 Transformer(转换器)。这个可不是说的不是电影里的那个(变形金刚就叫Transformer),而是指目前所使用的一种神经网络架构。
Transformer架构的的主要优势是什么呢? 那么Transformer架构之所以如此高效,是因为它能够随时将注意力集中在输入序列中最相关的部分。 你可能会说这与人类的工作方式类似。我们也需要将注意力集中在与任务最相关的部分,而忽略其他部分。
现在来谈谈 P ,它代表 “pre-training(预训练)” 。接下来,我们将讨论为什么我们突然开始谈论“预训练”,而不是仅仅谈论“训练”。
原因是,像 ChatGPT 这样的大型语言模型实际上是分阶段训练的。

(1)预训练,(2)指令微调,(3)人工反馈强化(RLHF)。
第一阶段是预训练,也就是我们刚才经历的阶段。这个阶段需要大量的数据来学习预测下一个单词。在这个阶段,模型不仅要学习掌握语言的语法和句法,还要获得大量关于世界的知识,甚至一些我们稍后会谈到的新兴能力。
但现在我有几个问题想问你:首先,这种预训练可能存在什么问题?当然存在一些问题,但我在这里想指出的一个问题是LLM到底学到了什么。
也就是说,它主要学会了如何滔滔不绝地谈论某个话题。它甚至可能做得非常好,但它无法很好地响应你通常想给人工智能的输入,比如一个问题或一个指令。问题在于,这个模型还没有学会如何成为一个助手,因此它的行为也并非助手。
例如,如果你问一个预训练的大模型
“你的名字是什么?”
它可能会回答
“你的姓氏是什么?”
仅仅是因为它在预训练过程中见过这种类型的数据,例如在许多空表格中。它只是试图完成输入序列。
它在遵循指令方面表现不佳,仅仅是因为这种一问一答式的语言结构在训练数据中并不常见。 或许 Quora 或 StackOverflow 是最接近这种结构的代表。
在这个阶段,我们认为 LLM 与人类意图不一致。一致性是 LLM 的一个重要课题,我们将学习如何在很大程度上解决这个问题,因为事实证明,这些预先训练好的 LLM 实际上非常易于操控。所以,即使它们最初对指令的反应不太好,它们也可以被训练来做到这一点。
这就是指令调整的用武之地。我们基于预先训练的 LLM 当前的能力,再次让他做同样的输入和输出训练,即学习一次预测一个单词。不一样的是,我们只使用高质量的指令和响应对作为我们的训练数据。
这样,模型就不再只是学习完成文本,而是学习成为一个有用的助手,能够遵循指令并以符合用户意图的方式做出响应。该指令数据集的大小通常比预训练集小得多。这是因为高质量的指令-响应对的创建成本要高得多,因为它们通常来自人工。这与我们在预训练中使用的廉价自监督标签截然不同。这就是为什么这个阶段也称为监督指令微调。
有些大模型(比如 ChatGPT)还会经历第三个阶段,即人类反馈强化学习(RLHF)。我们在此不再赘述,但其目的与指令微调类似。强化学习高频 (RLHF) 也有助于对齐,并确保大模型 (LLM) 的输出能够反映人类的价值观和偏好。一些早期研究表明,这一阶段对于达到或超越人类水平的表现至关重要。事实上,强化学习和语言建模领域的结合已被证明前景广阔,并可能带来比我们现有的大模型 (LLM) 更显著的改进。
现在让我们通过三个问题测试一下我们的理解。
第一个问题,为什么 LLM 可以对较长的文本进行摘要?
如果您还不知道,它确实做得很棒。只需粘贴一份文档并让它进行总结即可。
要理解其中的原因,我们需要思考一下训练数据。事实上,人们经常在互联网、研究论文、书籍等等中进行总结。因此,用这些数据训练的大模型(LLM)也学会了如何做到这一点。它学会了关注要点,并将其压缩成简短的文本。
请注意,在生成摘要这个用例中,待总结的全文不是全部,只是LLM输入序列的一部分。这个输入的结构类似于一篇研究论文,它前面是正文,而后面是全文总结。
因此,该技能可能在预训练阶段就已经习得,尽管指令微调肯定有助于进一步提升该技能。我们可以假设此阶段也包含了一些摘要示例。
第二,为什么LLM可以回答常识性的问题?
如上所述,能够充当助手并做出适当响应的能力归功于指令微调和 RLHF。但所有(或大部分)回答问题的知识本身在预训练期间就已经获得了。
当然,这又引出了我们的第三个问题。
第三,如果大模型(LLM)不知道答案怎么办?不幸的是,在这种情况下,他们可能会编造一个答案。
要理解其中的原因,我们需要重新思考训练数据和训练目标。
问题:LLM 接受的训练是生成人类文字,而不是真实文字。
解决思路:我们需要让它们 “立足 “于现实,这样它们就不会胡编乱造。
措施:将相关知识纳入LLM的上下文中。
LLM 可能会出现幻觉,但可以通过提供额外的背景信息来缓解。“幻觉”一词,它指的是大模型 (LLM) 编造不该编造的事实的现象。
为什么会这样?因为 LLM 学习的只是生成文本,而不是生成符合事实的真实文本。 它的训练过程中,并没有为模型提供任何关于训练数据真实性或可靠性的指标。然而,这也不是主要问题,问题在于互联网和书籍上的文本通常听起来都很有说服力,即使它是错误的,所以 LLM 当然也会学习到这种做法。这样一来,从LLM 的角度出发,几乎看不到任何关于内容不确定性的迹象。
话虽如此,这是一个活跃的研究领域,我们可以预期,随着时间的推移,LLM 的幻觉倾向会降低。例如,在教学调整过程中,我们可以尝试教会 LLM 在一定程度上避免产生幻觉,但能否完全解决这个问题,只有时间才能证明。
你可能会惊讶,我们现在居然可以一起尝试解决这个问题。我们拥有所需的知识,能够找到一个至少能在一定程度上提供帮助、并且目前已被广泛应用的解决方案。

Bing 聊天是基于搜索的 LLM 工作流程的一个示例。
假设你问大模型(LLM)这个问题: 哥伦比亚现任总统是谁? 大模型很可能会答错。这可能有两个原因:
那么,我们该如何解决这两个问题呢?答案在于为模型提供一些相关的上下文。其原理是,在 LLM 输入序列中的内容比它在预训练过程中获得的任何隐性知识都更容易被模型处理,容易检索,且更稳定。 就像让他从一个已知的数组中找出最大的一个数字一样。
假设我们将维基百科上关于哥伦比亚政治史的文章作为大模型课程的背景信息。在这种情况下,它更有可能正确回答问题,因为它可以直接从上下文中提取姓名(前提是上下文是最新的,并且包含现任总统)。
在上图中,你可以看到带有附加上下文的 LLM 典型提示是什么样子的。(顺便说一下,prompt(提示)只是我们给 LLM 的指令的另一种说法,即构成输入序列的指令。)
这个过程被称为将 LLM 置于上下文中,或者如果你愿意的话,置于现实世界中,而不是让它自由生成。
这正是 Bing Chat 和其他基于搜索的 LLM 的工作原理。它们首先使用搜索引擎从网络中提取相关上下文,然后将所有信息连同用户的初始问题一起传递给 LLM。上图直观地展示了这个过程是如何实现的。
现在你基本上了解了最先进的 LLM 的主要机制(无论如何,截至 2023 年下半年)。
你可能会想“这其实没什么神奇的”,这只是一次预测一个单词而已。毕竟,这只是纯粹的统计数据。事实真的如此吗?
让我们回顾一下。这一切的神奇之处在于它的效果如此出色。
事实上,每个人,甚至是 OpenAI 的研究人员,都对这种语言建模的进展感到惊讶。过去几年,其发展如此迅猛的关键因素之一就是神经网络和数据集的大规模扩展,这推动了 LLM 表现的提升。例如,据报道,GPT-4 的参数总数超过一万亿个,它能够以前 10% 的成绩通过律师资格考试或 AP 生物学考试。
令人惊讶的是,这些大模型甚至表现出某些新兴能力,即解决任务和完成一些他们没有被训练过的事情。
在本文的最后一部分,我们将讨论一些新兴的能力,并向您展示一些如何使用它们来解决问题的技巧。

LLM 可以以零样本方式解决全新的任务。正如其名称所示,LLM 正在兴起一种普遍存在的能力:它可以执行训练中从未遇到过的全新任务,这被称为零样本(zero-shot)。它所需要的只是一些关于如何解决任务的指令。
为了用一个愚蠢的例子来说明这种能力,你可以要求大模型将一个句子从德语翻译成英语,同时句子中的每个单子都要以字母 f 开头。
例如,我们测试的其中一个大模型将
“Die Katze schläft gerne in der Box”(德语,字面意思是“猫喜欢睡在盒子里”)
翻译成了
“Feline friend finds fluffy fortress”
我认为这是一个非常酷的翻译。
对于更复杂的任务,您可能很快就会意识到零样本提示通常需要非常详细的说明,即使这样,性能也往往远非完美。
大模型就像人类一样,可以从示例或演示中提取有用的信息。如果有人要求你执行一项全新的任务,你可能会要求提供一些示例或演示来说明如何执行该任务。大模型(LLM)也一样。

举个例子,假设你想要一个模型将不同的货币金额转换为通用格式。你可以详细描述你的目标,或者只给出一个简短的说明和一些示例演示。上图展示了一个示例任务。
使用这个提示,模型应该在最后一个例子上表现良好,即“牛排:24.99 美元”,并用 24.99 美元回答。
注意我们是如何省略掉上一个例子的答案的。记住,LLM 本质上仍然是一个文本补全器,所以想要输出中保持一致的结构,你应该几乎强制模型只给出你想要的结果,就像我们在上面的例子中所做的那样。
总而言之,如果LLM在零样本学习中遇到困难,一个通用的建议是提供一些示例。你会发现,这通常有助于LLM理解任务,从而使其输出的效果更好、更可靠。
思路链为大模型 (LLM) 提供了工作记忆,这可以大大提高他们的表现,特别是在更复杂的任务上。这也让人联想到人类智能,当任务更加复杂,需要多步推理才能解决时,推理思路链尤其有用。

假设我问你
“莱昂内尔·梅西出生前一年谁赢得了世界杯?”
你会怎么做?你可能会一步一步地解决这个问题,写下所有需要的中间解,直到得出正确答案。先找到梅西的生日,再找到对应前一年的这届世界杯冠军是哪个国家。而这正是大模型(LLM)也可以做到的。
研究发现,只要简单地告诉大模型“一步一步思考”,就能在许多任务中显著提高其表现。
为什么这样做有效?我们已经知道了回答这个问题所需的一切。问题在于,这种不寻常的复合知识可能并不直接存在于大模型(LLM)的记忆中。 然而,所有单独的事实,比如梅西的生日,以及历届世界杯的冠军,都可能存在。
让 LLM 逐渐形成最终答案是有帮助的,因为它给了模型时间去大声思考——可以说是工作记忆——并在给出最终答案之前解决更简单的子问题。
这里的关键在于记住,待生成单词左侧的所有内容都是模型可以依赖的上下文。因此,如上图所示,当模型说出答案 “阿根廷” 时,梅西的生日和查询到的世界杯年份 的数据已经存在于 LLM 的工作记忆中,这使得模型更容易正确回答。
在结束之前,我想回答我在文章前面提出的一个问题。LLM 真的只是预测下一个单词,还是它还有其他功能?一些研究人员支持后者,他们认为,LLM 在任何情况下都如此擅长预测下一个单词,实际上一定是在内部获得了对世界的压缩理解。而不是像其他人所说的那样,模型只是学会了记忆和复制在训练中看到的模式,而没有真正理解语言、世界或其他任何东西。
目前来看,这两种观点或许并没有绝对的对错;或许只是看待同一事物的不同视角。显然,这些大模型已被证明非常有用,展现出令人印象深刻的知识和推理能力,甚至可能展现出一些通用智能体的火花。但它们是否或在多大程度上与人类智能相似仍有待确定,语言建模还能在多大程度上提升现有水平也同样有待确定。
我希望本文能帮助你了解大模型(LLM)及其当前的热潮,从而对人工智能的潜力和风险形成自己的见解。如何利用人工智能造福世界,不仅取决于人工智能研究人员和数据科学家;每个人都应该拥有发言权。正因如此,我才想写一篇不需要太多背景知识的文章。
如果您读完了这篇文章,我想您就大致了解一些最先进的 LLM 是如何运作的(截至 2023 年秋季),至少在大体上是这样。

本文介绍了若干种常见的AI领域的算法及其应用场景
简单来说,激活函数的主要作用是 向神经网络中引入非线性因素 。可以将激活函数理解为神经网络中一个至关重要的“开关”和“调节器”。它被 应用在每个神经元的输出 上,用来决定这个神经元应该在多大程度上被“激活”,以及它应该向下一层传递多强的信号。
在人工神经网络中,一个神经元会接收来自上一层的多个输入信号。它会首先将这些输入信号进行 “加权求和” ,并加上一个 偏置项 。
这个加权求和的结果是一个线性的值。 \[z = \sum(weight \cdot input) + bias\]
激活函数 f(z) 就是紧接着应用在这个线性结果 z 上的一个非线性函数。它会产生该神经元的最终输出 a = f(z) ,这个输出 a 随后会作为输入传递给网络的下一层。
这一点至关重要。想象一下,如果没有激活函数会发生什么?
激活函数 被应用到每个神经元的输出上,对加权求和后的结果进行一次 非线性变换 。正是这种非线性变换,使得神经网络能够:
正向传播和反向传播是神经网络训练过程中相辅相成的两个阶段。
正向传播 (Forward Propagation)是模型进行预测的过程。数据从输入层“正向”流到输出层,得出一个预测结果。
反向传播 (Backpropagation)是模型学习和修正错误的过程。根据预测结果和真实答案之间的“误差”,从输出层 “反向”计算每个权重参数(W)应该如何调整 。
一个生动的比喻是学生考试和老师批改:
的意思是 “损失函数(L)”对“权重(W)”的偏导数。
它代表的是 “梯度”(Gradient) ,用来衡量 “当权重 W 发生一个极小的变化时,损失 L 会相应地发生多大变化” 。
这个值是神经网络训练(“学习”)的根本依据。
代表偏导数 (Partial Derivative),在神经网络中,L(损失)的值是 由成千上万个不同的 W(权重)共同决定的 。
偏导数的作用就是,单独衡量 L 是如何受到某一个特定权重 W 影响的。它在问一个问题:“如果我们只把这一个 W 增加一点点,同时保持所有其他权重不变,那么 L 会增加还是减少?变化的幅度有多大?”
反向传播的目标是计算出每个权重 W 到底对最终的“错误”负有多大责任,即计算梯度 \[\frac{\partial L}{\partial W}\]
以便在下一步更新它们。
数据(梯度)流向是从 输出 -> 隐藏层 -> 输入 。
损失函数和权重的偏导数它通常和梯度下降(Gradient Descent) 算法协同工作。
利用“链式法则”反向传播: 这是最关键的一步。算法使用微积分中的 链式法则 ,将“错误梯度” 从后一层反向传播到前一层。它会计算: \[\frac{\partial L}{\partial W_{\text{hidden}}}\]
这个计算会依赖于它后面一层(即 W_last )的梯度。这个过程就像在说:“最后一层对错误负有 X 的责任,而你(当前层)对最后一层的输出负有 Y 的责任,所以你对总错误负有 X · Y 的责任。”
这个过程一直重复,直到计算出网络中 每一个 W 对应的偏导数 。
更新所有权重(梯度下降步骤): 一旦反向传播计算出了所有 W 的梯度,优化器(如梯度下降)就会使用这个信息来更新所有的 W。更新公式: \[W_{\text{new}} = W_{\text{old}} - \text{learning\_rate} \cdot \frac{\partial L}{\partial W}\]
(减去梯度,是因为梯度指向 L 增加最快的方向,所以我们要朝相反方向去最小化 L)。
一句话总结:反向传播是模型在“复盘”或“订正错误”,它找出每个 W 犯了多少错,并告诉 W 应该朝哪个方向改正。
以下是几种最常用和最重要的激活函数,包括它们的公式、特点以及优缺点。激活函数分类图表:

对应的数学模型也叫 逻辑回归 模型,公式: \[f(x) = \frac{1}{1 + e^{-x}}\]
它能将输入值“压缩”到 0 和 1 之间,输出平滑且易于求导。在早期神经网络中很流行,常用于二元分类任务的输出层(输出概率)。
缺点:
由于 x(Sigmoid 的输出)始终为正,导致偏导数的 所有分量的符号(正或负)都完全取决于上游梯度 。
这会导致权重在更新时,要么 所有的权重都一起增加,要么所有的权重都一起减小 。这限制了梯度下降的寻优路径,使其只能呈“Z”字形(ZigZag)下降,收敛效率低下。
相比之下, Tanh (输出为 -1 到 1,零中心)在这个问题上表现更好。而 ReLU ( f(x) = max(0, x) )则极大地解决了另外一个梯度消失问题(在正区间,导数恒为 1),因此成为了现在最主流的激活函数。
公式: \[f(x) = \tanh(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}}\]
它将输入值“压缩”到 -1 和 1 之间。与 Sigmoid 相比,它的 输出是零中心 的(均值为 0),这通常能带来更快的收敛速度。
缺点是仍然存在 梯度消失 的问题,当输入值饱和时(接近 -1 或 1),梯度也会趋近于 0。
公式: \[f(x) = \max(0, x)\]
解决了梯度消失(在正区间):当输入 x > 0 时,导数恒为 1,这极大地缓解了梯度消失问题,使得训练深度网络成为可能。
计算非常简单(只是一个阈值判断),比 Sigmoid 和 Tanh 的指数运算快得多。
当输入 x < 0 时,输出为 0,这能使网络中的一些神经元“关闭”,带来稀疏性,可能 有助于提取特征和防止过拟合 。
缺点是 Dying ReLU(神经元死亡) 如果一个神经元的输入在训练过程中始终为负数,那么它的输出将永远是 0,梯度也永远是 0。这个神经元将停止学习和更新。
公式: \[f(x) = \max(\alpha x, x)\]
(其中 alpha 是一个很小的常数,如 0.01)
为了解决 “Dying ReLU” 神经元死亡问题而设计。当输入 x < 0 时,它不再输出 0,而是 输出一个非常小的正值 (如 0.01x),从而保证了在负区间的梯度不为零。
PReLU(Parametric ReLU) 是 Leaky ReLU 的一个变种,alpha 不是固定的,而是作为一个参数通过网络训练学习得到。
公式: \[f(x) = \begin{cases} x & \text{if } x > 0 \\ \alpha(e^x - 1) & \text{if } x \le 0 \end{cases}\]
融合了 ReLU 和 Leaky ReLU 的优点。它在负区间有输出(避免神经元死亡),且输出均值接近于 0(类似 Tanh),有助于加速学习。在负区间的“软饱和”特性使其对噪声有一定的鲁棒性。
缺点是计算上比 ReLU 复杂(涉及指数运算)。
公式: \[f(x_i) = \frac{e^{x_i}}{\sum_{j} e^{x_j}}\]
严格来说,它更像是一个“归一化”函数,而非隐藏层的激活函数。它 专门用于多分类问题的输出层 。
它能将一个包含任意实数的向量,转换成一个“概率分布”向量。向量中所有元素的和为 1,且每个元素都在 0 和 1 之间,可以被解释为该样本属于各个类别的概率。
Softmax 没有一个统一的函数图,通常以概率分布柱状图来表示其输出结果。
Maxout 可以作为神经网络的隐藏层的激活函数。
隐藏层位于输入层和输出层之间。之所以被称为“隐藏”,是因为它的输入和输出既不是直接来自外部世界,也不是直接输出到外部世界,而是网络内部进行信息处理和转换的地方。一个神经网络可以有零个(例如简单的线性模型)或多个隐藏层。拥有多个隐藏层的网络称为深度神经网络(Deep Neural Network)。
Maxout 不是一个固定的非线性函数(如 ReLU 或 Sigmoid ),而是一个 可学习 的激活函数。它将输入分成若干组,然后输出每组中的最大值。 \[h(\mathbf{x}) = \max_{j \in [1, k]} (\mathbf{w}_{j}^T \mathbf{x} + b_j)\]
其中 x 是输入,w_j 和 b_j 是可学习的参数, k 是组的大小(通常称为 piece 或 unit 的数量)。
Maxout 具有 分段线性 的特性,并且可以 近似任何凸函数 是由多条直线段组成的折线图,形状上是一个凸函数。它在一定程度上避免了 ReLU 的“神经元死亡”问题,但在计算上和参数数量上会更复杂。
在现代深度学习实践中(特别是作为 Android 开发者,你可能接触到的 TFLite 模型中):

本文介绍了若干种常见的AI领域的算法及其应用场景
人工智能在GPT吹响冲锋号之后,突然迎来了关注度大爆发,在AI领域的算法也开始出现了一些新的算法,本文将介绍一些常见的AI算法及其应用场景。
首先,了解下人工智能项目的训练方法,训练方法大模型的训练需要使用深度学习方法。以下是一些常见的深度学习方法:
“拟合”是训练模型的过程,目标是让模型学习到数据中的 内在规律和模式 ,从而能够根据输入数据(特征)准确地预测输出数据(标签)。
如果一个模型成功地捕捉到了数据的主要趋势,同时又忽略了数据中的随机噪声。这样的模型在训练数据上表现良好,在未见过的新数据(测试数据)上也具有很好的泛化能力,即能做出准确的预测。就可以称其为理想拟合 (Good Fit) / 适度拟合。
“欠拟合”指的是模型过于简单,以至于连训练数据中的基本模式都没有学到。
在训练数据上的误差(错误率)很高。在新数据(测试数据)上的误差也很高。
主要是因为模型复杂度太低(例如,用一条直线去拟合曲线分布的数据),或者训练时间不足、特征太少。
“过拟合”指的是模型过于复杂或训练过度,它不仅学习了数据中的基本模式,还把训练数据中的随机噪声和特殊情况也当作了规律。
在训练数据上的误差非常低(表现完美,几乎记住所有训练样本)。在新数据(测试数据)上的误差却很高,预测效果很差。
模型过于复杂(参数过多),或者训练数据量太少、训练时间过长。比如一个学生,死记硬背了所有的习题和答案,包括习题中的错别字和不规范的表达,但在遇到一道稍微变化了的新题时,却不知道如何应用学到的知识。模型过度关注了“训练数据”这个个体,而失去了“泛化”到新数据的能力。
线性回归是一种常见的机器学习算法,用于预测连续型变量的值。它的基本思想是通过建立一个线性模型来预测目标变量的值。
试图确定一个线性方程来拟合数据,通过最小化数据点与回归线之间距离的平方和。预测连续数值目标变量,如:根据房屋特征预测房价。
一元线性回归的过程可以表示为: \[y = \theta_0 + \theta_1x\]
多元线性回归的模型可以表示为: \[y = \theta_0 + \theta_1x_1 + \theta_2x_2 + \cdots + \theta_nx_n\]
其中,$y$ 是目标变量,$x_1, x_2, \cdots, x_n$ 是特征变量,$\theta_0, \theta_1, \theta_2, \cdots, \theta_n$ 是模型参数。线性回归的目标是 找到一组最优的模型参数,使得模型能够最好地拟合训练数据 。
那么,如何找到一组最优的模型参数呢?这就需要用到损失函数。损失函数是用来衡量模型预测值与真实值之间的差异的函数。常用的损失函数为均方误差(Mean Squared Error,MSE)。
均方误差的公式为: \[MSE = \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y_i})^2\]
其中,$y_i$ 是第 $i$ 个样本的真实值,$\hat{y_i}$ 是第 $i$ 个样本的预测值,$n$ 是样本的数量。
图形表达:

误差从图形上看是一条线段,平方后就形成了一个正方形, 将正方形的面积求和再取平均 ,就是均方误差的损失函数。所有的正方形的平均面积越小,损失越小。对于给定数据集,x和y的值是已知的,参数m和b是需要求解的,模型求解的过程就是解公式4的过程。以上就是 最小二乘法 的数学表示,”二乘”表示取平方,”最小”表示损失函数最小。至此我们发现,有了损失函数,机器学习的过程被化解成对损失函数求最优解过程,即求一个最优化问题。
简单线性回归实践:
import numpy as np
import matplotlib.pyplot as plt
x = np.array([1, 2, 3, 4, 5], dtype=np.int8)
y = np.array([1, 3.0, 2, 3, 5])
plt.scatter(x, y)
x_mean = np.mean(x)
y_mean = np.mean(y)
num = 0.0
d = 0.0
for x_i, y_i in zip(x, y):
num += (x_i - x_mean) * (y_i - y_mean)
d += (x_i - x_mean) ** 2
a = num / d
b = y_mean - a * x_mean
y_hat = a * x + b
plt.figure(2)
plt.scatter(x, y)
plt.plot(x, y_hat, c='r')
x_predict = 4.8
y_predict = a * x_predict + b
print(y_predict)
plt.scatter(x_predict, y_predict, c='b', marker='+')
plt.show()
结果:

什么样的数据适合使用线性回归呢?统计学家安斯库姆给出了四个数据集,被称为 安斯库姆四重奏(Anscombe’s quartet) ,分别是:

从这四个数据集的分布可以看出,并不是所有的数据集都可以用一元线性回归来建模。现实世界中的问题往往更复杂,变量几乎不可能非常理想化地符合线性模型的要求。因此使用线性回归,需要遵守下面几个假设:
回归问题与分类问题
与回归相对的是分类问题(classification),分类问题要预测的变量y输出集合是有限的,预测值只能是有限集合内的一个。当要预测的变量y输出集合是无限且连续,我们称之为回归。比如,天气预报预测明天是否下雨,是一个二分类问题;预测明天的降雨量多少,就是一个回归问题。
变量之间是线性关系
线性通常是指变量之间保持等比例的关系,从图形上来看,变量之间的形状为直线,斜率是常数。这是一个非常强的假设,数据点的分布呈现复杂的曲线,则不能使用线性回归来建模。可以看出,四重奏右上角的数据就不太适合用线性回归的方式进行建模。
误差服从正态分布
最小二乘法求解过程已经提到了误差的概念,误差可以表示为误差 = 实际值 - 预测值。 可以这样理解这个假设:线性回归允许预测值与真实值之间存在误差,随着数据量的增多,这些数据的误差平均值为0;从图形上来看,各个真实值可能在直线上方,也可能在直线下方,当数据足够多时,各个数据上上下下相互抵消。如果误差不服从均值为零的正太分布,那么很有可能是出现了一些异常值,数据的分布很可能是安斯库姆四重奏右下角的情况。 这也是一个非常强的假设,如果要使用线性回归模型,那么必须假设数据的误差均值为零的正太分布。
变量 x 的分布要有变异性
线性回归对变量 x也有要求,要有一定变化,不能像安斯库姆四重奏右下角的数据那样,绝大多数数据都分布在一条竖线上。
多元线性回归不同特征之间相互独立
如果不同特征不是相互独立,那么可能导致特征间产生共线性,进而导致模型不准确。举一个比较极端的例子,预测房价时使用多个特征:房间数量,房间数量*2,-房间数量等,特征之间是线性相关的,如果模型只有这些特征,缺少其他有效特征,虽然可以训练出一个模型,但是模型不准确,预测性差。
优点: (1)思想简单,实现容易。建模迅速,对于小数据量、简单的关系很有效; (2)是许多强大的非线性模型的基础。 (3)线性回归模型十分容易理解,结果具有很好的可解释性,有利于决策分析。 (4)蕴含机器学习中的很多重要思想。 (5)能解决回归问题。
缺点: (1)对于非线性数据或者数据特征间具有相关性多项式回归难以建模. (2)难以很好地表达高度复杂的数据。
逻辑回归主要用于分类问题,拟合一个S形函数 (Sigmoid Function),输出结果可表示数据点属于某一类别的概率。预测离散类别目标变量(二分类或多分类),如:根据身高体重预测人的性别。
| 逻辑回归与线性回归都是一种广义线性模型(generalized linear model,GLM)。具体的说,都是从指数分布族导出的线性模型,线性回归假设Y | X服从高斯分布,逻辑回归假设Y | X服从伯努利分布。 |
伯努利分布:伯努利分布又名0-1分布或者两点分布,是一个离散型概率分布。随机变量X只取0和1两个值,比如正面或反面,成功或失败,有缺陷或没有缺陷,病人康复或未康复。为方便起见,记这两个可能的结果为0和1,成功概率为p(0<=p<=1),失败概率为q=1-p。
高斯分布:高斯分布一般指正态分布。
首先我们要先介绍一下 Sigmoid函数,也称为逻辑函数(Logistic function) 。 Sigmoid函数的公式为: \[Sigmoid(x) = \frac{1}{1 + e^{-x}}\]
Sigmoid函数是一个非线性函数,它将输入值映射到0到1之间的输出值。Sigmoid函数的输出值可以被解释为概率。Sigmoid函数的图像如下:

从上图可以看到sigmoid函数是一个s形的曲线,它的取值在[0, 1]之间,在远离0的地方函数的值会很快接近0或者1,它的这个特性对于解决二分类问题十分重要。
逻辑回归与线性回归有很多相同之处,去除 Sigmoid映射函数 的话,逻辑回归算法就是一个线性回归。可以说,逻辑回归是以线性回归为理论支持的,但是逻辑回归通过Sigmoid函数引入了非线性因素,因此可以轻松处理0/1分类问题。
逻辑回归假设函数的形式为: \[h_\theta(x) = \frac{1}{1 + e^{-\theta^Tx}}\]
其中,$h_\theta(x)$ 是逻辑回归模型的预测值,$\theta^Tx$ 是线性回归模型的预测值。逻辑回归模型的预测值是一个概率值,它表示样本属于正类的概率。
一个机器学习的模型,实际上是把决策函数限定在某一组条件下,这组限定条件就决定了模型的假设空间。当然,我们还希望这组限定条件简单而合理。而逻辑回归模型所做的假设是: \[h_\theta(x) = P(y=1|x;\theta)\]
即在给定 x 和 θ 的条件下,y = 1的概率。
我们知道,在二分类问题模型:例如逻辑回归「Logistic Regression」、神经网络「Neural Network」等,真实样本的标签为 [0,1],分别表示负类和正类。模型的最后通常会经过一个 Sigmoid 函数,输出一个概率值,这个概率值反映了预测为正类的可能性:概率越大,可能性越大。
通常提到损失函数,我们不得不提到代价函数(Cost Function)及目标函数(Object Function)。
概况来讲,任何能够衡量模型预测出来的值 h(θ) 与真实值 y 之间的差异的函数都可以叫做代价函数 C(θ) 如果有多个样本,则可以将所有代价函数的取值求均值,记做 J(θ) 。因此很容易就可以得出以下关于代价函数的性质:
经过上面的描述,一个好的代价函数需要满足两个最基本的要求:能够评价模型的准确性,对参数 θ 可微。
在线性回归中,最常用的是均方误差(Mean squared error),即: \[J(\theta) = \frac{1}{2m}\sum_{i=1}^m(h_\theta(x^{(i)}) - y^{(i)})^2\]
而在逻辑回归中,最常用的是代价函数是交叉熵(Cross Entropy),交叉熵是一个常见的代价函数: \[J(\theta) = -\frac{1}{m}\sum_{i=1}^m[y^{(i)}log(h_\theta(x^{(i)})) + (1-y^{(i)})log(1-h_\theta(x^{(i)}))]\]
逻辑回归在确定了模型的形式后,通过最大似然估计法来实现最小散度从而求出模型参数。
为什么逻辑回归不能使用线性回归的损失函数均方差来计算呢?
线性回归的均方差损失函数是一个凸函数,可以很容易求解,而逻辑回归的损失函数是非凸函数,无法直接使用梯度下降法求解。所以逻辑回归使用的是对数损失函数(log loss function)。
LR优点:
LR缺点:
逻辑回归的简单例程如下:
import numpy as np
from sklearn.linear_model import LogisticRegression
import matplotlib.pyplot as plt
# 训练数据
X = np.array([[1, 2], [2, 3], [3, 4], [4, 5]])
y = np.array([0, 0, 1, 1])
# 创建逻辑回归模型
model = LogisticRegression()
# 训练模型
model.fit(X, y)
# 预测新数据
new_data = np.array([[5, 6]])
prediction = model.predict(new_data)
print("预测结果:", prediction)
# 绘制训练数据
plt.scatter(X[y == 0][:, 0], X[y == 0][:, 1], color='blue', label='Class 0')
plt.scatter(X[y == 1][:, 0], X[y == 1][:, 1], color='red', label='Class 1')
# 绘制决策边界
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.1), np.arange(y_min, y_max, 0.1))
Z = model.predict(np.c_[xx.ravel(), yy.ravel()])
Z = Z.reshape(xx.shape)
plt.contourf(xx, yy, Z, alpha=0.4)
# 绘制新数据的预测结果
plt.scatter(new_data[:, 0], new_data[:, 1], color='green', marker='x', label='New Data')
plt.xlabel('Feature 1')
plt.ylabel('Feature 2')
plt.legend()
plt.show()
运行结果:
预测结果: [1]

用来分类和回归,是一种非参数算法,不拟合任何方程。新的数据点被预测为其K个最近邻居的目标值的平均(回归)或多数类别(分类)。
KNN算法的原理相当简单:当需要预测一个新的数据点(查询点)的类别时,算法会 在已有的训练数据集中寻找与该点距离最近的K个邻居 。然后,根据这K个邻居的类别来确定新数据点的类别。
适用于关系复杂,难以用简单方程建模的场景。K近邻算法是一种基本而直观的监督学习算法,既可用于分类任务,也可用于回归任务。 其核心思想是“物以类聚,人以群分”,即一个样本的类别由其最邻近的几个样本的类别来决定。
具体来说,KNN算法的步骤如下:
K近邻(KNN)算法是一种基于实例的学习方法,其数学核心在于两个关键部分:距离度量和决策规则。 下面将对其公式和数学概念进行详细解释。
为了找到一个未知样本点的“邻居”,首先需要定义如何衡量样本之间的“远近”。这是通过在特征空间中计算两点之间的距离来实现的。假设有两个样本点,x 和 y,它们都在一个 n 维的特征空间中,即 x = (x₁, x₂, …, xₙ) 且 y = (y₁, y₂, …, yₙ)。
最常用的距离度量是闵可夫斯基距离(Minkowski Distance),它是一个通用的距离公式。
闵可夫斯基距离公式 为: \[_p(\mathbf{x}, \mathbf{y}) = \left( \sum_{i=1}^{n} |x_i - y_i|^p \right)^{1/p}\]
这个公式中的参数 p 可以调整,从而得到不同的距离计算方式:
曼哈顿距离计算的是两点在各个坐标轴上差值的绝对值之和。 它就像在城市街区中从一个十字路口走到另一个十字路口,只能沿着街道(坐标轴)行走的总距离,因此也被称为“城市街区距离”。
\(L_2(\mathbf{x}, \mathbf{y}) = \sqrt{\sum_{i=1}^{n} (x_i - y_i)^2}\)
这是最直观、最常用的距离度量,代表了 n 维空间中两点之间的直线距离。它的计算基于勾股定理。
\(L_\infty(\mathbf{x}, \mathbf{y}) = \max_{i} |x_i - y_i|\)
切比雪夫距离是两点在各个坐标上差值的最大值。
距离选择的重要性: 距离度量的选择对KNN算法的结果至关重要。欧几里得距离在大多数情况下表现良好,但如果特征的维度和物理含义不同,可能需要先对数据进行标准化(例如,将所有特征缩放到区间或使其均值为0,方差为1),以避免某些维度因数值范围过大而主导距离计算。
在通过距离度量找到距离未知样本点 x 最近的 K 个训练样本(记为集合 Nₖ(x))后,KNN算法根据任务类型(分类或回归)采用不同的决策规则来做出预测。
对于分类任务,KNN采用 多数表决法(Majority Voting) 来决定 x 的类别。
假设类别标签的集合为 {c₁, c₂, …, cⱼ}。算法会统计 Nₖ(x) 中每个类别出现的次数,然后选择出现次数最多的那个类别作为 x 的预测类别。 \[\hat{y} = \arg\max_{c_j} \sum_{\mathbf{x}_i \in N_k(\mathbf{x})} I(y_i = c_j)\]
其中:
argmax 表示取使表达式值最大的那个类别 cⱼ。简单来说,就是对K个邻居进行“投票”,得票最多的类别即为最终预测结果。
对于回归任务,KNN通常采用平均法(Averaging)来预测 x 的数值。
算法会计算 Nₖ(x) 中所有邻居的实际数值的平均值,并将该平均值作为 x 的预测值。 \[\hat{y} = \frac{1}{K} \sum_{\mathbf{x}_i \in N_k(\mathbf{x})} y_i\]
其中 yᵢ 是邻居样本 xᵢ 的真实数值。
加权平均法: 有时为了让更近的邻居有更大的影响力,也可以使用加权平均法。例如,以距离的倒数作为权重: \[\hat{y} = \frac{\sum_{\mathbf{x}_i \in N_k(\mathbf{x})} \frac{1}{d(\mathbf{x}, \mathbf{x}_i)} y_i}{\sum_{\mathbf{x}_i \in N_k(\mathbf{x})} \frac{1}{d(\mathbf{x}, \mathbf{x}_i)}}\]
其中 d(x, xᵢ) 是样本 x 与其邻居 xᵢ 之间的距离。
K值的选择对KNN算法的结果有显著影响:
因此,选择一个合适的K值至关重要,通常需要通过交叉验证等方法来确定一个最优的K值。
算法原理直观,易于实现。其是一种“懒惰学习”算法,它不需要显式的训练过程,只是将训练数据存储起来,所有计算都发生在预测阶段。灵活性高,可以同时应用于分类和回归问题,并且对非线性问题也能适应。作为一种非参数模型,它不对数据的底层分布做任何假设。
缺点有计算量大,在预测阶段,需要计算新数据点与所有训练样本的距离,当训练集很大时,计算成本非常高。内存消耗大,需要存储全部训练数据集。对不平衡样本敏感,如果某些类别的样本数量远多于其他类别,那么新样本更容易被预测为多数类。另外,如果不同特征的数值范围相差很大,数值范围大的特征会在距离计算中占据主导地位。因此,在使用KNN之前,通常需要对数据进行标准化处理。
KNN算法因其简单和有效性,被应用于多个领域:
分类和回归任务。
旨在绘制一个决策边界 (Decision Boundary),以最大化不同类别数据点之间的间隔 (Margin)。支持向量是位于间隔边缘的数据点。
适用于高维数据(特征数量大);利用核函数 (Kernel Function) 可处理高度复杂的非线性决策边界
优点: 在高维空间中表现强大;内存效率高(仅需支持向量进行分类);鲁棒性好,对噪声和异常值不敏感。
用于分类任务。基于 贝叶斯定理 (Bayes’ Theorem) ,但做了 “朴素”假设 ,即所有特征的概率相互独立。
文本分类任务,如:垃圾邮件过滤。
优点: 具有极高的计算效率,是许多用例的良好近似。缺点: “朴素”假设 在现实中通常是错误的。
分类和回归
通过一系列是非问题来分割数据集,目标是创建尽可能纯净 (Pure) 的叶节点(即误分类的数据点最少)
可作为独立模型,更常作为集成算法的基础模型 (Base Model)。
优点: 模型可解释性强,是许多复杂集成算法的基础。缺点: 简单决策树容易过拟合。
分类和回归 一种Bagging集成方法,训练多个决策树在数据的不同子集上,通过多数投票进行分类预测
强大的分类和回归估算器。
优点: 鲁棒性强,通过随机排除特征来防止过拟合,减少了树之间的相关性。
分类和回归
一种Boosting集成方法,顺序训练模型,每个后续模型都专注于修复前一个模型所犯的错误
著名的例子包括AdaBoost、Gradient Boosting和XGBoost。通常能达到比随机森林更高的准确率。
优点:准确度高。缺点:顺序训练使其速度较慢;更容易出现过拟合。
分类和回归
由多个隐藏层 (Hidden Layer) 组成,每一层都学习隐式、复杂的特征(例如,图片中的线条、人脸等),以预测最终目标。单层感知机本质上是多特征回归任务。 图像识别、自然语言处理等复杂的任务。
优点: 能够自动且隐式地设计非常复杂的特征,非常强大。缺点: 模型通常难以解释 (“黑箱”)。
无监督学习用于在没有特定目标变量或标签的情况下,发现数据中潜在的结构。
聚类 (Clustering)
K是超参数,代表要寻找的簇 (Cluster) 数量。算法通过随机选择簇中心,然后迭代地将数据点分配给最近的中心,并重新计算中心,直到中心稳定。
发现数据中未知的、潜在的群组结构,如将邮件分类到几个未指定的类别。优点: 简单、高效。缺点: K值(簇的数量)的选择需要领域知识和试错。
降维 (Dimensionality Reduction) | 通过找到数据集中最大方差 (Most Variance) 的方向(即主成分,PC),将数据投影到较低维度,以减少特征数量并尽可能保留信息 。
作为预处理步骤,用于减少特征数量,使监督学习算法更高效、更健壮;发现现有特征之间的相关性。
优点: 有效减少数据集中的特征数量,同时保留大部分信息,去除冗余维度和噪声

本文介绍了若干种常见的AI领域的算法及其应用场景
AI是一个广泛的概念,指让机器模仿人类智能。ML是实现AI的一种方法,让机器从数据中学习。DL是ML的一个子集,使用深度神经网络(多层)进行学习。
过拟合指模型在训练集上表现很好,但在测试集上表现很差(泛化能力弱)。欠拟合指模型在训练集上表现就不好。
高偏差通常导致欠拟合(模型太简单),高方差通常导致过拟合(模型太复杂)。理想的模型是低偏差且低方差,但两者往往是此消彼长的。
找到一条直线(或超平面)来最好地拟合数据点。关键是定义 损失函数(Loss Function),通常使用均方误差 (MSE),然后通过优化算法(如梯度下降)最小化损失函数来求解参数。
逻辑回归是用来做分类任务的(尤其是二分类),线性回归是用来做回归(预测连续值)任务的。逻辑回归在线性回归的基础上,套用了一个Sigmoid函数,将输出值映射到0到1之间,表示概率。
优点是可解释性强、能处理非线性数据。缺点是容易过拟合。
随机森林是 集成学习 (Ensemble Learning) 中 Bagging 的一种。它通过构建多棵决策树,并让它们投票(分类)或取平均值(回归)来减少单棵决策树过拟合的风险。
“近朱者赤,近墨者黑”。它是一种懒惰学习(Lazy Learning),预测时才计算。对于新数据点,找到训练集中离它最近的K个点,然后根据这K个点的标签来决定新数据点的标签。
无监督学习。目标是将数据分成K个簇(Cluster)。步骤:1. 随机选K个中心点。2. 将每个数据点分配给最近的中心点。3. 重新计算每个簇的中心点(均值)。4. 重复2和3直到中心点不再变化。
解决不同特征(如“年龄”和“收入”)量纲(尺度)不同的问题。这可以加速梯度下降的收敛速度,并提高一些算法(如KNN、SVM)的准确性。
[1,0,0], [0,1,0], [0,0,1]。适用于无序类别。一个表格,用于可视化分类模型的性能。包含四个值:真正 (TP), 假正 (FP), 真负 (TN), 假负 (FN)。
为了更可靠地评估模型的泛化能力,防止数据划分的偶然性。最常用的是K折交叉验证(K-Fold Cross-Validation),将数据分成K份,轮流用其中K-1份训练,1份测试,最后取K次评估结果的平均值。
输入层、隐藏层、输出层。神经元(节点)、权重 (Weights)、偏置 (Biases)、激活函数 (Activation Function)。
为了给模型引入非线性。如果不用激活函数,多层神经网络也只相当于一个线性回归。
一种优化算法,用来最小化损失函数。通过计算损失函数对参数的梯度(导数),然后沿着梯度的反方向小步更新参数。
主要用于图像处理。它的核心是卷积核 (Kernel),通过卷积操作提取图像的局部特征(如边缘、纹理)。
主要用于序列数据(如文本、时间序列)。它的特点是神经元的输出可以作为下一次的输入,使其具有“记忆”功能,能处理上下文依赖关系。
将词语(离散符号)映射到低维连续向量空间的过程。关键思想是让语义相近的词在向量空间中的距离也相近。
Word2Vec 无法处理。”bank”(银行)和 “bank”(河岸)的词向量是相同的。BERT 可以处理,因为它会根据上下文(”I went to the bank to deposit money” vs “I sat on the river bank”)生成不同的向量。
BPE (Byte Pair Encoding) 或 SentencePiece。
这是LLM的基石,必考领域。
解决 RNN/LSTM 的两个主要问题:
这是核心中的核心。简单说,就是让模型在处理一个词时,能“关注”到句子中所有其他词,并计算一个“注意力权重”分布,来决定哪些词对当前词的理解更重要。
$Q, K, V$ 都是从同一个输入向量(词嵌入)通过不同的线性变换(乘以权重矩阵 $W_q, W_k, W_v$)得到的。
计算过程:
放 (Scaling)。防止点积结果过大,导致 $\text{Softmax}$ 函数进入梯度很小的区域,使得梯度消失,训练不稳定。
将 $Q, K, V$ 拆分成 $h$ 个“头”(Head),分别进行注意力计算,最后再把 $h$ 个头的结果拼接起来。允许模型在不同的“表示子空间”中学习信息。好比“从不同角度去审视”这个句子。
因为 Self-Attention 机制本身是位置无关的(它只看词与词之间的关系,不看谁在谁前面)。
必须有一个东西告诉模型词的顺序信息。位置编码就是给每个位置的词嵌入加上一个独特的位置向量(通常用 $\sin$ 和 $\cos$ 函数生成)。
Causal Language Model (CLM, 因果语言模型),也就是“下一个词预测”。模型只能看到当前词以及它之前的所有词,来预测下一个词。
是 GPT-3 带来的一个重要能力。不需要更新模型参数(不需要微调),只在提示词 (Prompt) 中给模型几个例子,模型就能“学会”如何做这个新任务。
RLHF (Reinforcement Learning from Human Feedback, 基于人类反馈的强化学习): 这是让 ChatGPT 如此“听话”和“有用”的关键技术,用于模型对齐 (Alignment)。
步骤:
PEFT (Parameter-Efficient Fine-Tuning, 参数高效微调): 在微调 LLM 时,只训练模型的一小部分参数,而不是全部参数(如 175B 个)。
全参数微调成本太高(需要几十到几百 GB 的显存)。
例如 LoRA (Low-Rank Adaptation) 核心。冻结原始权重 $W$,在旁边加一个低秩矩阵 $W + \Delta W = W + BA$。只训练 $B$ 和 $A$(参数量远小于 $W$)。
这是目前最主流的 LLM 应用范式,非常重要。
门用于存储和高效查询向量 (Embedding) 的数据库。RAG 的核心组件之一。
loc 和 iloc 有什么区别?loc 是基于标签 (label) 的索引。iloc 是基于位置 (integer position) 的索引。model = LogisticRegression())。model.fit(X_train, y_train))。model.score(X_test, y_test))。这是构建和训练 LLM 本身的基础。
Hugging Face (HF) 提供了一套“全家桶”,极大地简化了 LLM 的使用和开发。
transformers:datasets:map 操作),是预训练和微调必备的工具。tokenizers:transformers 库背后的高性能分词器库。它提供了 BPE, WordPiece 等主流分词算法的快速实现。accelerate:evaluate:当你不训练模型,而是调用模型(如 GPT-4 API 或开源模型)来构建 RAG、Agents 等应用时,会用到这些框架。
RAG 是 LLM 最核心的应用之一,而向量数据库是 RAG 的基础。
当你需要将训练好的模型部署为 API 时使用。
bitsandbytes / auto-gptq:
本文介绍了LLM中各个张量的概念和计算
LLM 的推理是一个自回归 (Autoregressive) 的过程,它分两个主要阶段:预填充(Pre-fill)/提示处理阶段 和 解码(Decoding)/生成阶段。简单介绍下 LLM 推理流程 (以 Transformer Decoder-Only 架构为例)
这一阶段的目标是将用户的整个输入文本 (Prompt) 转化为模型可以处理的内部状态。
| 步骤 | 动作 |
|---|---|
| 1. 输入文本 | 用户输入一段文本。 |
| 2. 分词 (Tokenization) | 文本 -> Token (词元)。分词器将连续的文本分割成模型能理解的基本单元(Tokens)。一个 Token 可能是一个单词、一个子词、一个标点符号,或者是一个特殊标记。分词器输出是整数 ID 序列。 |
| 3. 词嵌入 (Embedding) | Token ID -> 向量。模型通过查表操作,将每个 Token ID 转换为一个固定维度的词嵌入向量。嵌入层将离散的 ID 转换为连续的浮点数向量,这些向量包含了词的语义信息。 |
| 4. 位置编码 (Positional Encoding) | 在嵌入向量中加入位置信息,因为 Transformer 结构本身不包含序列顺序的概念。通常是固定的三角函数编码或可学习的相对/绝对位置编码 (如 RoPE)。 |
这一阶段模型开始根据输入状态和前面生成的 Tokens,一次生成一个新 Token。
| 步骤 | 动作 |
|---|---|
| 5. 自注意力计算 (Self-Attention) | 模型计算当前序列中所有 Token 之间的相互关系和重要性(即注意力分数)。每一个token通过 Q (Query), K (Key), V (Value) 三个向量计出三组向量。 |
| 6. 计算注意力分数 | 将 Query 向量 与 Key 向量进行点积,衡量它们之间的相似度。对点积结果进行缩放(稳定梯度)。应用 Softmax 函数,将相似度分数转换为权重(注意力分数),确保权重总和为 1。 |
| 7. 上下文向量生成 | 模型使用注意力分数对 Value 向量(V)进行加权求和,得到一个上下文向量,这个向量包含了序列中所有相关信息。加权求和结果(上下文向量)进入后续的前馈网络。 |
| 8. 前馈网络 (FFN) 与残差连接 | 上下文向量经过多层 Transformer Blocks 中的前馈网络和残差连接,进一步提炼特征。这是Transformer 的核心组成部分,增加模型的非线性表示能力。 |
| 9. 预测 (Logits) 与采样 | 最终的向量经过顶部的线性层和 Softmax 函数,输出一个概率分布Logits,维度等于模型的词汇表大小。Logits 告诉模型下一个 Token 可能是词汇表中的哪个词。根据采样策略的概率分布选择下一个 Token。在线性层,Softmax + 采样器 通常会加入温度参数以控制随机性。 |
| 10. 缓存 Key 和 Value (KV Cache) | 在生成阶段,每生成一个 Token,其计算出的 K 和 V 向量会被缓存。在下一轮迭代时,可以直接使用这些缓存,而无需重新计算前面所有 Token 的 K 和 V,大大提高速度。这是LLM 加速的关键。 |
| 11. 自回归循环 | 新 Token -> Token ID -> 嵌入,将其作为下一轮输入序列的最后一个 Token,重复步骤 5-9。直到循环达到停止条件。 |
LLM 的生成过程不是无限的,它会在满足以下停止条件之一时终止:
<EOS> 或 <end> Token 最高的概率,这是最主要的 自然完结标志 。前面的文章介绍了自注意力机制,这个是现代LLM的核心机制,它允许模型在处理序列数据(如文本)时,能够关注不同位置的信息。而这整个过程中,原始数据,KQV权重,偏差等等,都是通过 张量(Tensor) 的存储和高效计算来实现的。
在神经网络中,权重是模型学习到的、用来决定输入数据重要性的参数。权重的作用是决定一个神经元从上一层接收到的众多信号中,哪些是重要的,哪些是不重要的。
它们本质上是乘数,应用于输入数据 (x)。一个输入值乘以一个权重 (w) 后,就会进入下一层的神经元。
权重的大小决定了该输入特征对下一层神经元的影响程度。权重越大,表示对应的输入特征越重要,对最终输出的影响也越大。权重越小(接近零),表示该特征不太重要。
在 模型训练过程中 ,算法(如梯度下降)会不断调整这些权重的值,直到模型能够准确地完成任务(例如,正确识别图片中的物体)。
权重 是模型在训练过程中学到的,用来对输入特征进行 优先排序 和 量化重要性 的参数。它是模型的核心“知识”。
举一个例子,想象你正在做饭(输出),你需要考虑以下输入:
| 输入 | 含义 |
|---|---|
x_1 | 食材的新鲜度 |
x_2 | 厨师的经验 |
x_3 | 餐具的精致度 |
在模型训练之前,这三项对“味道好不好”的贡献是未知的。模型通过学习,会给它们分配 权重 (w):
x_1 分配一个很高的 权重 w_1 (比如 w_1 = 5.0)。x_3 分配一个很小的 权重 w_3 (比如 w_3 = 0.1)。每个输入值都会被它对应的权重放大或缩小,然后所有结果相加: \[\text{加权输入} = w_1 x_1 + w_2 x_2 + w_3 x_3 + \dots\]
偏差是模型学习到的另一个参数,通常添加到权重乘以输入值的乘积之和上。
它是一个 加数(b) ,用于平移或调整神经元的激活输出。在计算中,它通常位于激活函数之前。偏差的作用是提供一个额外的、独立于输入的恒定值,用于调整神经元的激活阈值,让它更容易或更难被“激活”。
偏差 允许神经元在所有输入都是零的情况下仍能产生一个非零的激活输出。它赋予了模型更大的灵活性,使其能够更好地拟合数据。如果没有偏差,决策边界(Decision Boundary)将必须穿过原点,这会极大地限制模型的表达能力。
即 偏差 允许模型在不改变任何权重的情况下,平移(Shift)激活函数曲线。这使得模型能更好地捕捉和拟合数据中的各种模式,是调整 决策边界 的关键。
像权重一样,偏差的值也是在训练过程中不断学习和调整的。
沿用做饭的例子: \[\text{加权输入} = (w_1 x_1 + w_2 x_2 + w_3 x_3)\]
如果所有的输入 x 都很差(都是 0),那么加权输入就是 0。这意味着无论你使用什么激活函数,输出可能总是很低。
偏差 (b) 的引入打破了这个限制:
偏差 (b) 在加权输入求和之后被简单地 加上 去: \[\text{神经元的净输入} (z) = \underbrace{(w_1x_1 + w_2x_2 + \dots)}_{\text{加权输入}} + \underbrace{b}_{\text{偏差}}\]
这个 净输入z 随后会送入激活函数,以产生该神经元的最终输出。
以 自注意力机制(Self-Attention) 为例,详细说明张量如何组织数据、存储参数和执行计算。
这个过程是 Transformer 模型核心组件 “缩放点积注意力” (Scaled Dot-Product Attention) 的关键步骤。它的目的是让模型在处理一个词(Token)时,能够 动态地衡量输入序列中所有其他词与这个词的相关性 ,然后根据这个相关性(即注意力权重)来构造这个词的新的、包含上下文信息的表示。
整个计算过程可以分解为以下几个步骤:
当用户输入一句话时,模型会先将自然语言划分token,然后输入句子中的每个词或子词的Token都会被转换成一个固定维度的向量,称为词嵌入。
转换后的总输入的张量名称为 X ,张量形状为 (序列长度, 嵌入维度)
如果输入是 4 个词,每个词用 1024 维的向量表示,则 X 的形状是 (4, 1024)。它们的嵌入向量分别是 x_1, x_2, …, x_4 。
对于序列中的每一个词嵌入 x_i,我们都通过乘以三个不同的、可学习的权重矩阵 (W_Q, W_K, W_V) 来生成它对应的 Query (查询)、Key (键)、和 Value (值) 三个向量。
数学上表示为: \[Q_i = x_i \cdot W_Q \\ K_i = x_i \cdot W_K \\ V_i = x_i \cdot W_V\]
这个操作会对序列中的所有 Token 进行,最终我们会得到三组向量矩阵 Q, K, V。如果输出维度也是 1024,则这三个权重张量都是 (1024, 1024) 的 2 维矩阵。它们是模型的永久知识。
传统的编程需要一个 for 循环,遍历 4 个词,分别计算。但使用张量,这 4 个词的计算是一次性并行完成的,这正是深度学习速度飞快的原因。
现在,为了计算某个特定 Token(比如第 i 个 Token)应该对序列中其他所有 Token(包括它自己)投入多少“注意力”,我们需要用它的 Query 向量 (Q_i) 与所有 Token 的 Key 向量 (K_j) 进行点积运算。
Score(i, j) = \[Q_i \cdot K_j\]
这个点积的结果是一个标量(一个数字),它衡量了 Token i 和 Token j 之间的“相关性”或“匹配度”。如果 Q_i 和 K_j 的方向越接近,点积的结果就越大,意味着它们越相关。
点积在数学上的定义为: \[A \cdot B = |A| |B| \cos(\theta)\]
这里每个词向量取模都一样可以互相抵消,所以 结果的大小就代表了两个向量在空间内部的距离远近 。最后的结果越接近1,关联就越大。
这个 4 X 4 的张量矩阵就是注意力矩阵,其中的每个数值 S_ij 代表了 第 i 个词在理解语境时,应该给第 j 个词分配多少注意力 。
将上一步得到的原始分数除以一个缩放因子。这个因子通常是 Key(或 Query)向量维度 d_k 的平方根: \[\sqrt{d_k}\]
缩放后的分数 Scaled Score(i, j) = \[\frac{Q_i \cdot K_j}{\sqrt{d_k}}\]
为什么要缩放? 当向量的维度 d_k 很大时,点积的结果也可能会变得非常大。这会将 softmax 函数(下一步会用到)推向 梯度非常小的区域 ,导致模型在训练时梯度消失,难以学习。通过缩放,可以有效地缓解这个问题,使训练过程更加稳定。
接下来,对所有缩放后的分数应用 softmax 函数。softmax 会将一组任意的实数转换成一个总和为 1 的概率分布。
Attention Weights (alpha_{ij}) = \[softmax(\frac{Q_i \cdot K_j}{\sqrt{d_k}})\]
经过这一步,我们就得到了注意力权重或注意力分数。alpha_{ij} 的值在 0 到 1 之间,表示 Token i 在编码自己时,应该将多少注意力放在 Token j 上。所有 alpha_{i1}, alpha_{i2}, …, alpha_{in} 的总和为 1。
最后,用上一步得到的注意力权重 alpha_{ij} 来对所有 Token 的 Value 向量 (V_j) 进行加权求和,得到 Token i 的最终输出向量 z_i。 \[z_i = \sum_{j=1}^{n} \alpha_{ij} \cdot V_j\]
这个输出向量 z_i 就是 Token i 的新的、融合了全局上下文信息的表示。它不再仅仅是 x_i 本身,而是整个序列中所有 Token 的 Value 的一个加权混合,权重由 Q-K 相关性决定。
加权求和,将这些注意力权重分别乘以每个词的 Value 向量,然后把它们全部加起来。例如输入为: “今天天气很”
output_vector_for_很 = 0.1 * V("今天") + 0.8 * V("天气") + 0.1 * V("很")
这个计算出的 output_vector_for_很 是一个全新的向量。它不仅包含了 “很” 本身的意思,还重点融入了 “天气” 的信息,以及少量 “今天” 的信息。这样,模型就深刻理解了 “很” 在当前上下文中的确切含义。
这个过程会 对输入序列中的每一个词都做一遍 ,最终得到一组包含了丰富上下文信息的新向量。
总结公式 如果将整个序列的 Q, K, V 看作矩阵,上述过程可以用一个简洁的公式来概括: \[\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V\]
理解了上面的计算过程后,我们再来看 KV 缓存。KV 缓存是一种在模型进行自回归生成(autoregressive generation)任务(比如文本续写、对话)时,用来加速推理的关键优化技术。
LLM 生成文本时,是一个 Token 一个 Token 地吐出来的。
问题在哪里? 在生成第 N 个 Token 时,对于前面已经存在的 N-1 个 Token,它们的 Key 和 Value 向量是固定不变的(因为它们的输入 Token 和模型的权重矩阵 W_K, W_V 都没有变)。每次生成新 Token 时都去重复计算这些已经算过的 K 和 V 值,是巨大的计算浪费。随着生成序列的增长,这种浪费会呈平方级增加,导致推理速度急剧下降。
KV 缓存的核心思想非常简单:计算过的东西就存起来,别再算了!
具体做法是:
经常提到的 FP32 (Floating Point 32-bit) 和 INT4 (Integer 4-bit) 都是表示模型权重和激活值的精度单位,它们定义了存储一个数值所需的位数,直接影响了模型的大小、运行速度和精度。
简介:
| 特性 | 描述 |
|---|---|
| 全称 (Full Name) | 单精度浮点数 (Single-Precision Floating-Point Number) |
| 位数 (Bits) | 32 位 (4 字节) |
| 数据结构 (Structure) | 遵循 IEEE 754 标准,由以下部分组成: |
| 符号位 (Sign):1 位 (表示正负) | |
| 指数位 (Exponent):8 位 (表示数量级) | |
| 尾数位 (Mantissa/Significand):23 位 (表示有效数字精度) | |
| 特点 (Characteristics) | 高精度,表示范围广。这是训练模型时最常用的标准精度。 |
| 用途 (Use Case) | 深度学习模型训练、推理阶段的基准精度、对精度要求极高的场景。 |
| 大小对比 (Size Comparison) | 存储 N 个权重需要 4N 字节。 |
FP32 遵循 IEEE 754 标准,共 32 位,分为三个部分: \[\text{FP32} = \text{符号位 (1 位)} + \text{指数位 (8 位)} + \text{尾数位 (23 位)}\]
一个 32 位浮点数的值 V 可以表示为: \[V = (-1)^{\text{符号}} \times (1 + \text{尾数}) \times 2^{(\text{指数} - 127)}\]
其中:
1.0 的 FP32 二进制表示为: \[\underbrace{0}_{\text{S}} \underbrace{01111111}_{\text{E}} \underbrace{00000000000000000000000}_{\text{M}}\]
16 位 (2 字节)。精度和范围都比 FP32 小,但计算速度快,模型体积减半。常用于加速训练和推理。
FP16 (Half-Precision) 也是 IEEE 754 标准,共 16 位,结构类似 FP32,但位数更少: \[\text{FP16} = \text{符号位 (1 位)} + \text{指数位 (5 位)} + \text{尾数位 (10 位)}\]
公式类似: \[V = (-1)^{\text{符号}} \times (1 + \text{尾数}) \times 2^{(\text{指数} - 15)}\]
其中:
由于指数位只有 5 位,FP16 的表示大小范围比 FP32 小得多,这是它 量化损失 的主要来源之一。
位数同样有16 位 (2 字节)。其数据结构是 指数位 8 位(与FP32相同),尾数位 7 位。在训练中,尤其在 TPU 上,比 FP16 更稳定。
BF16 最大的优势在于它的 8 位指数位与 FP32 的 8 位指数位相同。这意味着 BF16 能够表示的数值范围(从极小数到极大数)与 FP32 完全一样。
在训练深度学习模型时,梯度的数值有时会非常大或非常小。如果精度格式的 范围不够 ,就会发生溢出 (Overflow) 或下溢 (Underflow)。BF16 保持了 FP32 的范围,这使得训练过程中的数值稳定性非常好,更容易直接替换 FP32 使用。
它的 尾数位只有 7 位 ,导致其精度(有效数字)比 FP16 和 FP32 低很多。
8 位 (1 字节)。最常用的 量化 目标格式。在模型量化中,通常使用 有符号 INT8 (SInt8) 来表示量化后的权重或激活值。相比 FP32 模型大小减小 4 倍。在精度和速度之间取得了很好的平衡,许多移动端推理引擎(如 TFLite、PyTorch Mobile)都支持高效的 INT8 推理。
整数的二进制表示比浮点数简单得多,它们没有复杂的指数和尾数结构,就是纯粹的二进制整数。
| 类型 | 位数 | 表示范围 (十进制) | 二进制示例 |
|---|---|---|---|
| 无符号 (UInt8) | 8 位 | [0, 255] | 255 = 11111111 |
| 有符号 (SInt8) | 8 位 | [-128, 127] | 1 = 00000001, -1 = 11111111 |
INT4 只有 4 位,是最简化的整数形式:
| 类型 | 位数 | 表示范围 (十进制) | 二进制示例 |
|---|---|---|---|
| 无符号 (UInt4) | 4 位 | [0, 15] | 15 = 1111 |
| 有符号 (SInt4) | 4 位 | [-8, 7] | 7 = 0111, -1 = 1111 |
在模型量化中,INT4 也通常使用有符号形式。
关键在于,INT8 或 INT4 本身只表示一个整数,要表示模型中的浮点数(如 FP32 的权重),需要一个 量化公式 来建立整数到浮点数的映射: \[\text{FP}_{\text{Real}} = \text{Scale} \times (\text{INT}_{\text{Quantized}} - \text{Zero Point})\]
其中:
正是这个 Scale 和 Zero Point,使得 INT8/INT4 能够在极小的二进制位数下,近似地表示 FP32 的大范围数值。这也就是在 Android 设备上部署模型时,能够大幅减小模型体积和提高速度的秘密。

本文记录了使用Ollama拉取deepseek,使用docker运行包含网页的本地容器进行交互,并使用内网穿透工具进行公网的访问,最后验证了使用Python调用deepseek能力的可行性。
Ollama 是一个开源项目,是在本地机器上运行 LLM 的强大而友好的平台。它在 LLM 技术的复杂性与人们对可访问、可定制的人工智能体验的渴望之间架起了一座桥梁。
Ollama 的核心功能是简化下载、安装和与各种 LLM 交互的过程,使用户能够探索这些 LLM 的功能,而无需大量的专业技术知识或依赖基于云的平台。
借助Ollama在本地部署当今主流的开源大模型,包含llama3、gemma、qwen等(其支持的开源大模型可在library (ollama.com)中查看)
在Windows和MAC平台上可以直接下载安装包进行安装,在Linux平台上可使用以下命令安装:
curl -fsSL https://ollama.com/install.sh | sh
完毕之后可以输入验证:
ollama -h
我是直接在Windows平台上安装的,安装过程比较简单,下载安装包,打开之后直接下一步即可。默认安装在C盘,且自动配置了环境变量,可以直接在cmd里使用 ollama 命令。Ollama默认安装路径为
C:\Users%username%\AppData\Local\Programs\Ollama
如果需要转移到其他盘,安装完毕之后,也可以直接把整个文件夹剪切到其他盘。然后找到ollama自己添加的 PATH 环境变量,将其改为新的地址。
Ollama 默认下载的大模型存储位置也在C盘,我们需要让其下载到其他地址,也可以自己新建 OLLAMA_MODELS 环境变量,然后将其指向其他地址。
拉取的时候就会自动存储在这个地址下。
查看本地大模型列表:
C:\Users\stephen>ollama list
NAME ID SIZE MODIFIED
deepseek-coder-v2:16b 63fb193b3a9b 8.9 GB 32 hours ago
deepseek-r1:14b ea35dfe18182 9.0 GB 2 days ago
移除本地大模型:
ollama rm deepseek-r1:14b
创建自定义模型:
ollama create my-model -f my-model.modelfile
启动模型:
ollama run my-model
停止模型:
ollama stop my-model
更新模型
C:\Users\stephen>ollama pull deepseek-r1:14b
pulling manifest
pulling 6e9f90f02bb3... 100% ▕███████████████▏ 9.0 GB
pulling 369ca498f347... 100% ▕███████████████▏ 387 B
pulling 6e4c38e1172f... 100% ▕███████████████▏ 1.1 KB
pulling f4d24e9138dd... 100% ▕███████████████▏ 148 B
pulling 3c24b0c80794... 100% ▕███████████████▏ 488 B
verifying sha256 digest
writing manifest
success
显示模型信息:
C:\Users\stephen>ollama show deepseek-coder-v2:16b
Model
architecture deepseek2
parameters 15.7B
context length 163840
embedding length 2048
quantization Q4_0
Parameters
stop "User:"
stop "Assistant:"
License
DEEPSEEK LICENSE AGREEMENT
Version 1.0, 23 October 2023
拉取模型的过程极致的简单,只需要执行以下命令即可:
ollama run deepseek-r1:14b
末尾是指定的训练参数大小, deepseek 提供了7b,14b,32b等等选项。
14b大小为9G,我体验的话下载应该是无限速,中途下载速度如果变慢了,可以Ctrl C掐断后,立即重新开始,就会重置下载速度,也不会断传。
拉取完整性检验完毕之后,会直接打开deepseek的对话命令行,可以直接跟其进行交互。使用Ctrl D可以退出对话。
C:\Users\stephen>ollama run deepseek-r1:14b
>>> 介绍一下你自己
<think>
</think>
您好!我是由中国的深度求索(DeepSeek)公司开发的智能助手DeepSeek-R1。如您有任何任何问题,我会尽我所能为您提供帮助。
测试一个简单的Python编程需求:
>>> 使用python怎么执行本地命令行
<think>
嗯,用户问我“使用python怎么执行本地命令行”。这看起来是一个关于Python编程的问题,具体涉及到如何在Python中执行本地的
命令行指令。
首先,我需要理解用户的需求。他可能想要在自己的Python脚本中运行一些系统命令,比如ls、cd或者其他任何可以在终端中运行的
命令。这可能是因为他在进行自动化任务,或者想让脚本执行一些依赖于系统命令的操作。
接下来,我要考虑有哪些Python库可以用来执行命令行指令。我知道有几个常用的模块,比如os和subprocess。这两个模块都可以用
来调用外部程序,并且能够获取它们的输出或错误信息。
对于os模块来说,它有一个system()函数,可以直接运行命令,但返回的结果比较有限,主要是用于执行命令本身。而subprocess模
块则更为强大,因为它不仅可以运行命令,还可以捕捉标准输出和错误输出,处理进程的终止等等。
我需要决定推荐哪个库更好。考虑到功能性和灵活性,subprocess模块可能更适合大多数情况,尤其是当用户需要捕获命令的输出或
者处理更复杂的任务时。然而,os.system()在简单的场景下使用起来更为简单直观。
另外,我还需要考虑用户可能的使用场景。如果他只是想运行一个简单的命令,并不需要获取输出或错误信息,那么os.system()可
能足够了。但如果他需要更多的控制和反馈,比如处理返回值或者错误情况,subprocess会是更好的选择。
我应该提供两种方法的例子,让用户可以根据自己的需求来选择。这样既展示了基本的使用方法,也提供了更高级的功能实现。
此外,我还应该强调最佳实践的重要性。例如,在执行外部命令时,使用shell=True可能会带来一定的安全风险,比如命令注射攻击
。因此,如果可能的话,建议用户通过传递参数列表的方式调用命令,而不是直接拼接字符串。这样可以减少潜在的安全漏洞。
最后,我要确保解释清晰易懂,避免过于技术化的术语,让用户能够轻松理解并应用这些方法到自己的项目中。
</think>
在 Python 中,如果你想执行本地的命令行指令(例如 ls, cd, echo 等),可以通过以下几种方式实现。以下是常用的方法
:
1. 使用 os.system()
os 模块提供了一个简单的方法来执行系统命令。
示例代码:
import os
# 执行 "dir" 命令(Windows)或 "ls" 命令(Linux/macOS)
os.system("dir") # Windows
# 或者
os.system("ls") # Linux/macOS
输出:
这会直接在终端中执行命令,并输出结果到标准输出。
2. 使用 subprocess 模块
subprocess 是更强大和灵活的模块,推荐用于复杂的任务。它允许你捕捉输出、处理错误以及管理进程。
示例代码(运行命令并获取输出):
import subprocess
# 执行 "ls" 命令,并将结果存储在变量中
result = subprocess.run("ls", capture_output=True, text=True)
print(result.stdout) # 输出命令的 stdout
更复杂的用法:
import subprocess
# 使用 shell=True 可以执行更复杂的命令(不推荐用于外部输入,有安全风险)
result = subprocess.run(
"echo Hello World",
shell=True,
capture_output=True,
text=True
)
print(result.stdout) # 输出 "Hello World"
3. 使用 os.popen()
os.popen() 可以打开一个管道,方便地读取命令的输出。
示例代码:
import os
# 执行命令并获取输出
output = os.popen("ls").read()
print(output)
注意事项
安全性:如果使用 subprocess.run() 的 shell=True,可能会带来安全风险(例如命令注入攻击)。因此,在处理用户输入时,尽量避免这种方式。
平台兼容性:某些命令可能只在特定平台上可用。例如,ls 是 Unix 系统的命令,而 dir 是 Windows 的命令。
总结
如果需要简单的命令执行,可以使用 os.system()。
如果需要更灵活和强大的功能(比如捕获输出、处理错误),推荐使用 subprocess 模块。
希望这些方法能帮到你!
我的电脑是win10企业版,没有微软商店,也不带wsl子系统,需要手动安装。可以直接参考微软的官方文档。
首先启用两个功能,使用 管理员权限 打开power shell。
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
然后重启电脑。
安装完成后,将wsl2设为默认版本:
wsl --set-default-version 2
使用 Docker 来部署运行 Open WebUI 是一种简单而有效的方法,可以用一个交互友好的界面来使用Deepseek。
docker pull ghcr.io/open-webui/open-webui:main
下载完毕之后,在运行该项目前请确保部署在 Ollama 中的语言模型处于运行状态。
然后在Docker中的Terminal终端中输入并执行以下命令:
docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main
启动成功后,也可以通过Docker Desktop来进行管理:

打开浏览器,转到 http://localhost:3000/ ,进行聊天测试。初次进入需要设置 管理员账户 ,在后台就可以对模型的访问进行一系列的设置。

如果想在公司内部小范围使用,或者不在电脑边,想使用手机访问deepseek,可以使用内网穿透工具,这里推荐使用 cpolar 。
cpolar官网地址: https://www.cpolar.com
注册账号,登录后,选择免费套餐,下载 cpolar 安装包,下载完毕打开,一直点下一步即可。
安装完毕后,浏览器打开本地localhost的 9200 端口:
http://localhost:9200
使用自己刚刚注册的账号登录之后,在cpolar左侧导航栏中,点击 隧道管理 ,然后点击 添加隧道 ,选择 HTTP 协议,本地地址设置为 OpenWebUI 的 3000 端口

配置完成之后,点击 创建 ,等待cpolar分配一个域名。
然后点击 状态 ,里面有一个 在线隧道列表 将显示的域名复制到浏览器中,打开即可。
需要注意的是,刚才创建的是随机地址,24小时会发生变化。另外它的网址是由随机字符生成,不容易记忆。如果想把域名变成固定的二级子域名,并且不想每次都重新创建隧道来访问Open WebUI,我们可以选择创建一个固定的公网地址来解决这个问题。想要创建保留域名,需要开通Cpolar的会员,这个后面再看看。
使用外部的公共链接,首次打开后,发现没有注册新用户账号的地方,只能登录管理员账号使用,这是因为Open WebUI默认是不允许用户注册的。
探索之后发现,可以先使用oi的管理员账户登录,然后在设置里面找到,开启允许新用户注册:

或者直接在管理员后台,像下面这样点加号手动创建几个新账号来使用:

最后将对应模型的可见性设置为public公开,让非管理员用户也可以使用:

配置完成测试,使用普通账号登录之后,可以正常使用模型了,就像下面这样:

对于程序开发来说,如果只是在用户层面使用UI交互,那局限性太大了,能不能使用AI的能力,来优化一些程序运行结果呢?
最近做了一个翻译的小项目,将本地的中文的 strings.xml 翻译成英文的,输出到项目中集成,完成英文界面的显示需要。
目前是用本地词条数据库加上百度,网易,阿里的翻译api来实现的,但是有一个问题,就是机器翻译的结果不是很准确,所以想能不能使用 Deepseek 来优化一下。
github发现 ollama 有官方的 Python 库了.
https://github.com/ollama/ollama-python
导入之后,可以直接使用 chat 接口调用到本地部署的模型的能力。配置好model名称,再输入 message 即可。
from ollama import chat
from ollama import ChatResponse
response: ChatResponse = chat(model='llama3.2', messages=[
{
'role': 'user',
'content': 'Why is the sky blue?',
},
])
print(response['message']['content'])
# or access fields directly from the response object
print(response.message.content)
运行结果:
The sky appears blue because of a phenomenon called Rayleigh scattering. When sunlight reaches Earth’s atmosphere, it is made up of many different colors, each with different wavelengths. The shorter wavelengths, such as blue and violet light, are scattered in all directions by the gases and small particles in the atmosphere. Since our eyes are more sensitive to blue than violet, we perceive the sky as blue. During sunrise or sunset, the sun is closer to the horizon, so the light has to pass through more atmosphere, scattering out the shorter wavelengths and allowing the longer red and orange wavelengths to dominate, creating the colorful skies during these times.
import ollama
def translate_test():
res = ollama.chat(model='deepseek-r1:14b', stream=False,
messages=[{'role': 'user',
'content': '假如你是一个汽车领域的翻译专家,帮我翻译下面的几个词条:驾驶模式,智能驾驶,座椅加热,语音助手'}])
print(res['message']['content'])
translate_test()
结果打印:
当然可以!以下是这几个词条的翻译:
1. 驾驶模式 - Driving Mode
2. 智能驾驶 - Intelligent Driving
3. 座椅加热 - Heated Seats
4. 语音助手 - Voice Assistant
这些翻译在汽车领域中是常见的表达方式,希望对你有帮助!
可以看到使用 deepseek 的翻译质量上比在线的机器翻译要好上不少。
但是还需要将 AI 的回复进行二次处理,才能得到最终的结果,结合到程序流程中去。输出处理后的格式一旦对不上,程序的运行流程就会出错。目前仅仅是验证了一下可行性,还未正式使用。
除了同步返回外,也支持流式输出:
from ollama import chat
stream = chat(
model='llama3.2',
messages=[{'role': 'user', 'content': 'Why is the sky blue?'}],
stream=True,
)
for chunk in stream:
print(chunk['message']['content'], end='', flush=True)
结果打印:
<think>
</think>
The sky appears blue due to a phenomenon known as Rayleigh scattering. When sunlight reaches Earth's atmosphere, it interacts with molecules and small particles in the air. Sunlight is composed of various colors, each corresponding to different wavelengths. Blue light has a shorter wavelength and is scattered more by these tiny particles, whereas other colors like red or orange are scattered less. As a result, blue light is dispersed across the sky, making it appear blue to our eyes. This scattering effect is named after Lord Rayleigh, who explained it in the 19th century.
Process finished with exit code 0
最后介绍下ollama库的异步请求。
AsyncClient 类用于发出异步请求。它可以配置与客户端类相同的字段。
import asyncio
from ollama import AsyncClient
async def chat():
message = {'role': 'user', 'content': 'Why is the sky blue?'}
response = await AsyncClient().chat(model='llama3.2', messages=[message])
asyncio.run(chat())
设置 stream=True 会修改函数,使其返回 Python 异步生成器:
import asyncio
from ollama import AsyncClient
async def chat():
message = {'role': 'user', 'content': 'Why is the sky blue?'}
async for part in await AsyncClient().chat(model='llama3.2', messages=[message], stream=True):
print(part['message']['content'], end='', flush=True)
asyncio.run(chat())
以上就是对 ollama-python 库的简要使用介绍。
在使用Deepseek和Kimi等厂商提供的云端大模型服务时,在api文档中,可以看到请求的参数有很多。这些常见的参数代表什么含义呢?
举例:
curl -L -X POST 'https://api.deepseek.com/chat/completions' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Authorization: Bearer <TOKEN>' \
--data-raw '{
"messages": [
{
"content": "You are a helpful assistant",
"role": "system"
},
{
"content": "Hi",
"role": "user"
}
],
"model": "deepseek-chat",
"frequency_penalty": 0,
"max_tokens": 4096,
"presence_penalty": 0,
"response_format": {
"type": "text"
},
"stop": null,
"stream": false,
"stream_options": null,
"temperature": 1,
"top_p": 1,
"tools": null,
"tool_choice": "none",
"logprobs": false,
"top_logprobs": null
}'
data: [DONE] 结尾。temperature 像一个“软化剂”或“锐化剂”,它直接作用于所有词语的概率分布。
改变 temperature 就像是在调整整个概率曲线的形状,影响了所有词语被选中的相对可能性。
top_p 则是一个“概率预算”,它通过限制可选词语的数量来控制随机性。
模型会按概率从高到低排序,然后选择一个最小的词语集合,使得这些词语的累积概率达到你设置的 top_p 值。模型后续的选择只会从这个被“裁剪”的集合中进行。如果你设置 top_p=0.9,模型会选出那些加起来概率达到 90% 的词语,将完全排除概率很低的词语。
改变 top_p 就像是在概率列表上画一条线,直接淘汰掉那些累积概率达不到预算的词语。
对于需要事实准确、代码或总结的严谨任务,将 temperature 设置为 0.0 到 0.5 左右,或将 top_p 设置为 0.95 或更高(基本上只依赖概率)。
对于需要创意、故事或头脑风暴的任务,将 temperature 设置为 0.7 到 1.0 左右,或将 top_p 设置为 0.8 到 0.9。

本文记录了近期在leetcode上刷到的关于滑动窗口的题目。
滑动窗口其实也可以归类为双指针的一种。滑动窗口的基本思想是维护一个窗口,窗口内的元素满足某些条件。窗口的左右边界分别用两个指针表示,窗口的大小可以变化。
解题通用模板:
//外层循环扩展右边界,内层循环扩展左边界
for (int l = 0, r = 0 ; r < n ; r++) {
//当前考虑的元素
while (l <= r && check()) {//区间[left,right]不符合题意
//扩展左边界
}
//区间[left,right]符合题意,统计相关信息
}
给定一个含有 n 个正整数的数组和一个正整数 target 。
找出该数组中满足其总和大于等于 target 的长度最小的 子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
示例 1:
输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。
示例 2:
输入:target = 4, nums = [1,4,4]
输出:1
示例 3:
输入:target = 11, nums = [1,1,1,1,1,1,1,1]
输出:0
提示:
1 <= target <= 109
1 <= nums.length <= 105
1 <= nums[i] <= 104
进阶:
如果你已经实现 O(n) 时间复杂度的解法, 请尝试设计一个 O(n log(n)) 时间复杂度的解法。
暴力解法的思路是枚举所有可能的子数组,计算子数组的和是否大于等于target。如果是,更新最小长度。
class Solution {
public int minSubArrayLen(int s, int[] nums) {
int n = nums.length;
if (n == 0) {
return 0;
}
int ans = Integer.MAX_VALUE;
// 枚举子数组的左边界
for (int i = 0; i < n; i++) {
int sum = 0;
// 枚举子数组的右边界
for (int j = i; j < n; j++) {
sum += nums[j];
if (sum >= s) {
ans = Math.min(ans, j - i + 1);
break;
}
}
}
return ans == Integer.MAX_VALUE ? 0 : ans;
}
}
复杂度分析
滑动窗口的思路是维护一个窗口,窗口的左右边界分别用两个指针表示,窗口的大小可以变化。
初始时,窗口的左右边界都指向数组的第一个元素,sum和设为0。
class Solution {
public:
int minSubArrayLen(int s, vector<int>& nums) {
int n = nums.size();
if (n == 0) {
return 0;
}
// 因为要找最小值,result设为 INT_MAX 最大值,方便后续比较
int result = INT_MAX;
int left = 0;
int right = 0;
int sum = 0;
// 右指针右移,加入元素
while (right <= n - 1) {
sum += nums[right];
// 可以不用判断左右指针相对位置,重合时,减完sum就为0了
while (sum >= s) {
result = min(right - left + 1, result);
// 左指针右移,提前减去这个位置的元素
sum -= nums[left];
left++;
}
right++;
}
// 如果result没有被更新,说明没有符合条件的子数组
return result == INT_MAX ? 0 : result;
}
};
给定一个字符串 s ,请你找出其中不含有重复字符的 最长 子串 的长度。
示例 1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
提示:
0 <= s.length <= 5 * 104
s 由英文字母、数字、符号和空格组成
我们不妨以示例一中的字符串 abcabcbb 为例,找出从每一个字符开始的,不包含重复字符的最长子串,那么其中最长的那个字符串即为答案。对于示例一中的字符串,我们列举出这些结果,其中括号中表示选中的字符以及最长的字符串:
以 (a)bcabcbb 开始的最长字符串为 (abc)abcbb;
以 a(b)cabcbb 开始的最长字符串为 a(bca)bcbb;
以 ab(c)abcbb 开始的最长字符串为 ab(cab)cbb;
以 abc(a)bcbb 开始的最长字符串为 abc(abc)bb;
以 abca(b)cbb 开始的最长字符串为 abca(bc)bb;
以 abcab(c)bb 开始的最长字符串为 abcab(cb)b;
以 abcabc(b)b 开始的最长字符串为 abcabc(b)b;
以 abcabcb(b) 开始的最长字符串为 abcabcb(b)。
可以看到,随着左指针移动,右边界也是在移动的。
我们就可以使用「滑动窗口」来解决这个问题了:
我们使用两个指针表示字符串中的某个子串(或窗口)的左右边界,其中左指针代表着上文中「枚举子串的起始位置」。
在每一步的操作中,我们会将左指针向右移动一格,表示 我们开始枚举下一个字符作为起始位置,然后我们可以不断地向右移动右指针,但需要保证这两个指针对应的子串中没有重复的字符。在移动结束后,这个子串就对应着 以左指针开始的,不包含重复字符的最长子串。我们记录下这个子串的长度;
在枚举结束后,我们找到的最长的子串的长度即为答案。
class Solution {
public:
int lengthOfLongestSubstring(string s) {
int n = s.size();
if (n <= 1) {
return n;
}
int ans = 0;
unordered_set<char> filter;
int right = 0;
for (int left = 0; left < n; left++) {
// 左指针向右移动一格,移除掉遍历检查过的字符
if (left != 0) {
// 移除掉遍历检查过的字符
filter.erase(s[left - 1]);
}
// 右指针向右移动,加入元素,直到遇到重复元素
while (right <= n - 1 && !filter.count(s[right])) {
filter.insert(s[right]);
right++;
}
// 更新答案,此时右指针指向的元素是重复元素,所以 right - left 是当前窗口的长度
ans = max(ans, right - left);
}
return ans;
}
};
给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
示例 1:
输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
示例 2:
输入: s = "abab", p = "ab"
输出: [0,1,2]
解释:
起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。
起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。
起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。
提示:
1 <= s.length, p.length <= 3 * 104
s 和 p 仅包含小写字母
根据题目要求,我们需要在字符串 s 寻找字符串 p 的异位词。因为字符串 p 的异位词的长度一定与字符串 p 的长度相同,所以我们可以在字符串 s 中构造一个长度为与字符串 p 的长度相同的滑动窗口,并在滑动中维护窗口中每种字母的数量;
不需要费劲去排列组合,只需要看每种字符的数量是否相等即可。
当窗口中每种字母的数量与字符串 p 中每种字母的数量相同时,则说明当前窗口为字符串 p 的异位词。
而且字符的存储形式是一个整形,所以我们可以用一个 vector<int> 来记录窗口中每种字母的数量。
两个 vector<int> 分别记录字符串 p 中每种字母的数量和窗口中每种字母的数量。数组相等可以直接使用 == 运算符。底层已经实现了。
vector的==运算符,内部已实现,先判断长度,若长度相同再逐个比较元素是否相等。
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
int sSize = s.size();
int pSize = p.size();
if (sSize < pSize) {
return {};
}
vector<int> result;
// 承载s和p中每一个字符的数组,大小固定为26
vector<int> sDict(26);
vector<int> pDict(26);
for (int i = 0; i < pSize; i++) {
// 填入字母和字符a的差值作为存储判断的数据
sDict[s[i] - 'a']++;
pDict[p[i] - 'a']++;
}
// 至此,p字符串的结构体已经录入了pDict这个数组
// 先比较一次s的开头字符串
if (sDict == pDict) {
result.emplace_back(0);
}
// 比较剩余的元素
for (int i = 0; i < sSize - pSize; i++) {
// 补上新的剩余字符计入
sDict[s[pSize + i] - 'a']++;
// 删掉之前计算的字符,保持窗口大小不变
sDict[s[i] - 'a']--;
if (sDict == pDict) {
// i 是从减去p长度的后一个位置开始的,在s中的实际下标需要加 1
result.emplace_back(i + 1);
}
}
return result;
}
};
emplace_back是 vector 的一个成员函数,用于在 vector 的末尾直接构造一个元素,而不是先创建一个临时对象再将其复制到 vector 中。适合大对象,复杂对象的构造。这里使用push_back也没差。
这题可以说是438的进阶版,不过一个是字符,一个是字符串版本。
给定一个字符串 s 和一个字符串数组 words。 words 中所有字符串 长度相同。
s 中的 串联子串 是指一个包含 words 中所有字符串以任意顺序排列连接起来的子串。
例如,如果 words = ["ab","cd","ef"], 那么 "abcdef", "abefcd","cdabef", "cdefab","efabcd", 和 "efcdab" 都是串联子串。 "acdbef" 不是串联子串,因为他不是任何 words 排列的连接。
返回所有串联子串在 s 中的开始索引。你可以以 任意顺序 返回答案。
示例 1:
输入:s = "barfoothefoobarman", words = ["foo","bar"]
输出:[0,9]
解释:因为 words.length == 2 同时 words[i].length == 3,连接的子字符串的长度必须为 6。
子串 "barfoo" 开始位置是 0。它是 words 中以 ["bar","foo"] 顺序排列的连接。
子串 "foobar" 开始位置是 9。它是 words 中以 ["foo","bar"] 顺序排列的连接。
输出顺序无关紧要。返回 [9,0] 也是可以的。
示例 2:
输入:s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]
输出:[]
解释:因为 words.length == 4 并且 words[i].length == 4,所以串联子串的长度必须为 16。
s 中没有子串长度为 16 并且等于 words 的任何顺序排列的连接。
所以我们返回一个空数组。
示例 3:
输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]
输出:[6,9,12]
解释:因为 words.length == 3 并且 words[i].length == 3,所以串联子串的长度必须为 9。
子串 "foobarthe" 开始位置是 6。它是 words 中以 ["foo","bar","the"] 顺序排列的连接。
子串 "barthefoo" 开始位置是 9。它是 words 中以 ["bar","the","foo"] 顺序排列的连接。
子串 "thefoobar" 开始位置是 12。它是 words 中以 ["the","foo","bar"] 顺序排列的连接。
提示:
1 <= s.length <= 104
1 <= words.length <= 5000
1 <= words[i].length <= 30
words[i] 和 s 由小写英文字母组成
使用的还是滑动窗口检查异位词这题的思想。只不过检查的对象成了字符串,并且用来比较的数据集合改用hash表,记录每个字符串出现的次数。
重点在于如何划分窗口。记 words 的长度为 m ,words 中每个单词的长度为 n , s 的长度为 ls 。首先需要将 s 划分为单词组,每个单词的大小均为 n (首尾除外)。这样的划分方法有 n 种,即先删去前 i (i=0∼n−1) 个字母后,将剩下的字母进行划分,如果末尾有不到 n 个字母也删去。对这 n 种划分得到的单词数组分别使用滑动窗口对 words 进行类似于字母异位词的搜寻。
检查流程如下:
第一次滑动流程
初始窗口 ['bar', 'foo','foo'] 未命中
入'bar'出'bar' ['foo','foo','bar'] 未命中
入'the'出'foo' ['foo','bar', 'the'] 命中 index 6
入'foo'出'foo' ['bar','the', 'foo'] 命中 index 9
入'bar'出'bar' ['the','foo','bar'] 命中index 12
入'man'出'the' ['foo','bar','man'] 未命中
结束
第二次滑动流程
初始窗口 ['arf','oof','oob'] 未命中
划动过程略
第三次滑动流程
初始窗口 ['rfo','ofo','oba'] 未命中
划动过程略
class Solution {
public:
vector<int> findSubstring(string& s, vector<string>& words) {
vector<int> res; // 存储结果的向量,记录所有符合条件的起始索引
// 获取关键参数:
int m = words.size(); // words中单词的个数
int n = words[0].size(); // 每个单词的长度(题目保证所有单词长度相同)
int ls = s.size(); // 字符串s的长度
// 外层循环:从0到n-1,处理不同的起始偏移量
// 这样可以覆盖所有可能的对齐方式
// 第二个结束条件就是剩余的字符串长度不足以支持分割
for (int i = 0; i < n && i + m * n <= ls; ++i) {
// 使用哈希表记录当前窗口内各单词出现次数与words中次数的差异
unordered_map<string, int> differ;
// 初始化滑动窗口:截取从位置i开始的m个单词
for (int j = 0; j < m; ++j) {
// 从s中截取一个单词并加入differ(增加计数)
++differ[s.substr(i + j * n, n)];
}
// 用words中的单词来平衡differ(减少计数)
for (string& word : words) {
// 如果某个单词在words中,就在differ中减少其计数
if (--differ[word] == 0) {
// 如果计数减到0,就从differ中移除该单词
differ.erase(word);
}
}
// 开始逐词滑动窗口遍历
for (int start = i; start < ls - m * n + 1; start += n) {
// 初始窗口第一次检查已完成,如果不是初始窗口,需要更新differ
if (start != i) {
// 添加新进入窗口的单词(窗口右边界新增的单词)
string word = s.substr(start + (m - 1) * n, n);
if (++differ[word] == 0) {
differ.erase(word); // 如果计数变为0,移除
}
// 移除离开窗口的单词(窗口左边界移出的单词)
word = s.substr(start - n, n);
if (--differ[word] == 0) {
differ.erase(word); // 如果计数变为0,移除
}
}
// 检查当前窗口是否匹配:如果differ为空,说明窗口内的单词与words完全匹配
if (differ.empty()) {
res.emplace_back(start); // 记录当前起始位置
}
}
}
return res; // 返回所有找到的起始索引
}
};
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。
注意:
对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
如果 s 中存在这样的子串,我们保证它是唯一的答案。
示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
示例 2:
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。
示例 3:
输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。
提示:
m == s.length
n == t.length
1 <= m, n <= 105
s 和 t 由英文字母组成
进阶:你能设计一个在 o(m+n) 时间内解决此问题的算法吗?
解题思路,右指针往前遍历,一路扩大检查的范围,直到当前窗口区域内,可以包含 t 字符串的组成字符数量后,再缩小范围,直到不包含,过程中动态更新区间大小,取最短区间。
本地验证OK,问题点是添加了一个hash表来判断对比,超出内存限制了。
class Solution {
public:
bool checkDict(unordered_map<char, int>& dyDict,
unordered_map<char, int>& tDict) {
auto it = tDict.begin();
while (it != tDict.end()) {
if (it->second > dyDict[it->first]) {
return false;
}
it++;
}
return true;
}
string minWindow(string s, string t) {
int n = s.size();
int m = t.size();
if (n < m) {
return "";
}
// 需要动态更新最小值
string result = "";
bool isFound = false;
unordered_map<char, int> tDict;
unordered_map<char, int> dynamicDitct;
// 先填入即将用来比对hash表数据
for (char c : t) {
tDict[c]++;
}
// 思路,一路扩大检查的范围,直到包含的tDict中的字符数量后,再缩小范围到不包含,记录下区间大小
int right = 0;
int left = 0;
while (right < n) {
dynamicDitct[s[right]]++;
// 包含了组成t的字符组合
while (left < n && left + m <= right + 1 &&
checkDict(dynamicDitct, tDict)) {
string temp = s.substr(left, right - left + 1);
if (result == "") {
result = temp;
} else {
result = result.size() > temp.size() ? temp : result;
}
dynamicDitct[s[left]]--;
left++;
}
right++;
}
return result;
}
};
内存问题有以下几点:
dyDict[it->first] 可能会在 dyDict 中创建新键值对优化思路:
required 和 formed 来跟踪需要匹配的字符种类数和当前已匹配的字符种类数,避免每次都遍历整个 tDictclass Solution {
public:
string minWindow(string s, string t) {
if (s.empty() || t.empty() || s.length() < t.length()) return "";
unordered_map<char, int> tCount;
for (char c : t) tCount[c]++;
int required = tCount.size(); // 需要匹配的字符种类数
int formed = 0; // 当前已匹配的字符种类数
unordered_map<char, int> windowCount;
int left = 0, right = 0;
int minLen = INT_MAX;
int start = 0;
while (right < s.length()) {
char c = s[right];
windowCount[c]++;
// 检查当前字符是否达到t中的要求
if (tCount.count(c) && windowCount[c] == tCount[c]) {
formed++;
}
// 尝试收缩窗口
while (left <= right && formed == required) {
c = s[left];
// 更新最小窗口
if (right - left + 1 < minLen) {
minLen = right - left + 1;
start = left;
}
// 移动左指针
windowCount[c]--;
if (tCount.count(c) && windowCount[c] < tCount[c]) {
formed--;
}
left++;
}
right++;
}
return minLen == INT_MAX ? "" : s.substr(start, minLen);
}
};
思路相同,性能更优:
class Solution {
public:
unordered_map<char, int> ori, cnt;
bool check() {
for (const auto& p : ori) {
if (cnt[p.first] < p.second) {
return false;
}
}
return true;
}
string minWindow(string s, string t) {
for (const auto& c : t) {
++ori[c];
}
int l = 0, r = -1;
int len = INT_MAX, ansL = -1, ansR = -1;
while (r < int(s.size())) {
if (ori.find(s[++r]) != ori.end()) {
++cnt[s[r]];
}
while (check() && l <= r) {
if (r - l + 1 < len) {
len = r - l + 1;
ansL = l;
}
if (ori.find(s[l]) != ori.end()) {
--cnt[s[l]];
}
++l;
}
}
return ansL == -1 ? string() : s.substr(ansL, len);
}
};

本文记录了近期在leetcode上刷到的关于双指针的题目。
由于数据特征的有序性(大小或者正负),所以可以证明当前节点一定是优于过往节点,从而可以通过数据的维度数量的指针,逐步的迭代收敛最终找到最优解。
无序数据大多只能通过遍历的方式来解决,因为无法通过指针的移动来证明当前节点是否是最优解。
如果在将所有大写字符转换为小写字符、并移除所有非字母数字字符之后,短语正着读和反着读都一样。则可以认为该短语是一个 回文串 。
字母和数字都属于字母数字字符。
给你一个字符串 s,如果它是 回文串 ,返回 true ;否则,返回 false 。
示例 1:
输入: s = "A man, a plan, a canal: Panama"
输出:true
解释:"amanaplanacanalpanama" 是回文串。
示例 2:
输入:s = "race a car"
输出:false
解释:"raceacar" 不是回文串。
示例 3:
输入:s = " "
输出:true
解释:在移除非字母数字字符之后,s 是一个空字符串 "" 。
由于空字符串正着反着读都一样,所以是回文串。
提示:
1 <= s.length <= 2 * 105
s 仅由可打印的 ASCII 字符组成
class Solution {
fun isPalindrome(s: String): Boolean {
val cleanedString = s.lowercase().replace(Regex("[^a-z0-9]"), "")
return cleanedString == cleanedString.reversed()
}
}
使用方便的扩展函数结合正则表达式替换,可以一两行输出结果。
去除多余字符,转换为小写,翻转,与原来的做比较:
class Solution {
public:
bool isPalindrome(string s) {
// 去除掉所有非字母数字的其他字符
s.erase(remove_if(s.begin(), s.end(),
[](unsigned char c) { return !isalnum(c); }),
s.end());
// 转换为小写
transform(s.begin(), s.end(), s.begin(), ::tolower);
// 反转字符串
string reversed = s;
reverse(reversed.begin(), reversed.end());
// 比较反转后的字符串是否与原字符串相同
return reversed == s;
}
};
将字符的剔除,转换合二为一:
class Solution {
public:
bool isPalindrome(string s) {
string sgood;
for (char ch: s) {
// 剔除非字母数字字符
if (isalnum(ch)) {
// 转换为小写并添加到新字符串
sgood += tolower(ch);
}
}
// 创建翻转的字符串也可以写作下面这样
string sgood_rev(sgood.rbegin(), sgood.rend());
return sgood == sgood_rev;
}
};
isalnum()函数用于检查一个字符是否是字母或数字。类似的还有isdigit()判断是否是数字;isalpha()判断是否是字母。
设置两个指针,一个指向字符串的头,一个指向字符串的尾,向中间移动,比较对应位置的字符是否相同。
需要注意的是,在移动左右指针时, 遇到非字母数字字符时,需要跳过 ,继续往下移动到下一个有效字符。
class Solution {
public:
bool isPalindrome(string s) {
int n = s.size();
int left = 0, right = n - 1;
while (left < right) {
while (left < right && !isalnum(s[left])) {
++left;
}
while (left < right && !isalnum(s[right])) {
--right;
}
if (left < right) {
if (tolower(s[left]) != tolower(s[right])) {
return false;
}
++left;
--right;
}
}
return true;
}
};
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。
进阶:
如果有大量输入的 S,称作 S1, S2, ... , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?
示例 1:
输入:s = "abc", t = "ahbgdc"
输出:true
示例 2:
输入:s = "axc", t = "ahbgdc"
输出:false
提示:
0 <= s.length <= 100
0 <= t.length <= 10^4
个字符串都只由小写字符组成。
这个子字符串的定义不同于之前的连续的子字符串,它可以不连续。思路简单直接写即可,基于长串遍历,子串也从头开始移动,遇到当前长串字符和子串的字符匹配上的,就移动一下子串的指针,比较下一个字符,子串遍历完成,即为成功,如果长串走完了还没有成功,就返回失败。
class Solution {
public:
bool isSubsequence(string s, string t) {
int n = s.size();
if (n == 0) {
return true;
}
int subPtr = 0;
for (int i = 0; i < t.size(); i++) {
// 有和子字符串相同的字符出现
if (s[subPtr] == t[i]) {
// 比较下一个
subPtr++;
// 子字符串遍历完成,结束
if (subPtr == n) {
return true;
}
}
}
return false;
}
};
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案,并且你不能使用两次相同的元素。
你可以按任意顺序返回答案。
示例 1:
输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
示例 2:
输入:nums = [3,2,4], target = 6
输出:[1,2]
示例 3:
输入:nums = [3,3], target = 6
输出:[0,1]
提示:
2 <= nums.length <= 104
-109 <= nums[i] <= 109
-109 <= target <= 109
只会存在一个有效答案
进阶:你可以想出一个时间复杂度小于 O(n2) 的算法吗?
这题非双指针类别,但是是下一题的基础,同时也记录一下。
很容易想到类似冒泡排序的写法,遍历数组,对于每个元素,都遍历数组后面的元素,看是否有和当前元素之和等于目标值的元素。
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
int n = nums.size();
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (nums[i] + nums[j] == target) {
return {i, j};
}
}
}
return {};
}
};
上一个暴力解法的时间复杂度是 O(n2),主要是搜索差值的过程比较耗时。我们可以用 hash 表记录已经遍历过的元素,每次遍历到一个元素时,都去 hash 表中查找是否有和当前元素之和等于目标值的元素。如果有,就返回这两个元素的下标。时间复杂度为O(n).
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
int n = nums.size();
unordered_map<int, int> valueIndex;
for (int i = 0; i < n; i++) {
// 查看 hash 表中是否有和当前元素之和等于目标值的元素
auto it = valueIndex.find(target - nums[i]);
if (it != nullptr) {
// 找到和为目标值的元素,返回这两个元素的下标
return {it->second, i};
}
valueIndex[nums[i]] = i;
}
// 遍历完成,没有找到,返回空
return {};
}
};
给你一个下标从 1 开始的整数数组 numbers ,该数组已按 非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index1] 和 numbers[index2] ,则 1 <= index1 < index2 <= numbers.length 。
以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1 和 index2。
你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。
你所设计的解决方案必须只使用常量级的额外空间。
示例 1:
输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。
示例 2:
输入:numbers = [2,3,4], target = 6
输出:[1,3]
解释:2 与 4 之和等于目标数 6 。因此 index1 = 1, index2 = 3 。返回 [1, 3] 。
示例 3:
输入:numbers = [-1,0], target = -1
输出:[1,2]
解释:-1 与 0 之和等于目标数 -1 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。
提示:
2 <= numbers.length <= 3 * 104
-1000 <= numbers[i] <= 1000
numbers 按 非递减顺序 排列
-1000 <= target <= 1000
仅存在一个有效答案
对比第一题,要求不另外开辟空间,就只能在原数组上操作了。
同时也多了一个条件,即数组是有序的。经测试,这题如果使用暴力解法会超时。即算法时间复杂度要优于 O(n^2) .
因为数组是有序的,所以可以采用一个头一个尾,计算和值sum,与target比较,缩小范围。如果sum太小,就左指针右移,sum太大,就右指针左移。
初始时两个指针分别指向第一个元素位置和最后一个元素的位置。每次计算两个指针指向的两个元素之和,并和目标值比较。如果两个元素之和等于目标值,则发现了唯一解。如果两个元素之和小于目标值,则将左侧指针右移一位。如果两个元素之和大于目标值,则将右侧指针左移一位。移动指针之后,重复上述操作,直到找到答案。
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
int n = numbers.size();
int left = 0;
int right = n - 1;
while (right > left) {
if (numbers[left] + numbers[right] > target) {
right--;
} else if (numbers[left] + numbers[right] < target) {
left++;
} else {
return {left + 1, right + 1};
}
}
return {};
}
};
复杂度分析
时间复杂度:O(n),其中 n 是数组的长度。两个指针移动的总次数最多为 n 次。
空间复杂度:O(1)。
在数组中找到两个数,使得它们的和等于目标值,可以首先固定第一个数,然后寻找第二个数,第二个数等于目标值减去第一个数的差。利用数组的有序性质,可以通过二分查找的方法寻找第二个数。为了避免重复寻找,在寻找第二个数时,只在第一个数的右侧寻找。
low 和 high 的移动类似双指针的解法,这里是划分区间,看target落在哪里,“击中”区间后就缩小范围寻找。
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
for (int i = 0; i < numbers.size(); ++i) {
int low = i + 1, high = numbers.size() - 1;
while (low <= high) {
int mid = (high - low) / 2 + low;
if (numbers[mid] == target - numbers[i]) {
return {i + 1, mid + 1};
} else if (numbers[mid] > target - numbers[i]) {
high = mid - 1;
} else {
low = mid + 1;
}
}
}
return {-1, -1};
}
};
给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。
找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。
说明:你不能倾斜容器。
示例1:
输入:
[1,8,6,2,5,4,8,3,7]输出:49 解释:图中垂直线代表输入数组[1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。

示例 2:
输入:height =
[1,1]输出:1
提示: n == height.length 2 <= n <= 105 0 <= height[i] <= 104
根据图示,可以比较容易想到使用双指针,因为做区间限制计算,需要有两个下标。
设置双指针,共同从两边开始往中间移动,直到相遇,指针移动时,记录下所组合到的最大面积 (right-left)X 短的那个指针的高度 。问题点就是该移动哪一个指针。
比较通俗的想法是,移动高度比较短的那个指针,因为移动长的那个指针,容器的高度不会增加,而宽度会减少,所以容器的面积一定是减少的。数学证明也是如此,省略证明过程。
class Solution {
public:
int maxArea(vector<int>& height) {
int n = height.size();
int left = 0;
int right = n - 1;
int maxArea = 0;
while (left < right) {
// 计算当前面积,更新maxArea的值
int areaHeight = min(height[left], height[right]);
maxArea = max(maxArea, (right - left) * areaHeight);
// 移动对应高度较短的那个指针
height[left] >= height[right] ? right-- : left++;
}
return maxArea;
}
};
复杂度分析
时间复杂度:O(N),双指针总计最多遍历整个数组一次。 空间复杂度:O(1),只需要额外的常数级别的空间。
给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例 1:
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。
注意,输出的顺序和三元组的顺序并不重要。
示例 2:
输入:nums = [0,1,1]
输出:[]
解释:唯一可能的三元组和不为 0 。
示例 3:
输入:nums = [0,0,0]
输出:[[0,0,0]]
解释:唯一可能的三元组和为 0 。
提示:
3 <= nums.length <= 3000
-105 <= nums[i] <= 105
题目要求找元素组合,并未对下标做限制,所以可以先排序,然后使用双指针。可以预料到时间复杂度至少为O(N^2),而sort的时间复杂度为O(NlogN),所以整体时间复杂度为O(N^2)。
求解思路类似两数之和的双指针,只是在三数之和的基础上,增加了一个循环。遍历数组时,先锁定第一个数 numbers[i] ,再设置两个指针,一个指向较小的位置(i的下一个位置),一个指向末尾,求和,如果大于0,就将right左移,如果小于0,就将left右移,知道两个指针遇上。
关于去重:
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
int n = nums.size();
// 先排序
sort(nums.begin(), nums.end());
vector<vector<int>> result;
for (int i = 0; i < n - 2; i++) {
int lock = nums[i];
// 说明遍历到此处,后面的元素已经都大于0了
if (lock > 0) {
return result;
}
// 对第一个元素去重
if (i > 0 && lock == nums[i - 1]) {
continue;
}
int left = i + 1;
int right = n - 1;
while (left < right) {
int sum = lock + nums[left] + nums[right];
if (sum > 0) {
// 结果大于0,缩小right指针
right--;
} else if (sum < 0) {
// 结果小于0,增大left指针
left++;
} else {
// 添加进结果数组中
result.push_back({nums[i], nums[left], nums[right]});
// 去重逻辑应该放在找到一个三元组之后,对left和right进行多移几位的操作
while (right > left && nums[right] == nums[right - 1])
right--;
while (right > left && nums[left] == nums[left + 1])
left++;
// 找到答案时,双指针同时收缩
right--;
left++;
}
}
}
return result;
}
};
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
请注意 ,必须在不复制数组的情况下原地对数组进行操作。
示例 1:
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]
示例 2:
输入: nums = [0]
输出: [0]
提示:
1 <= nums.length <= 104
-231 <= nums[i] <= 231 - 1
进阶:你能尽量减少完成的操作次数吗?
朴素写法,遍历数组,遇到0,就将0后面的元素往前移动,然后将0放到数组的末尾。这是一个O(n^2)的算法。
使用双指针进行优化,一个指针指向当前遍历的元素,一个指针指向当前0的位置。当往前遍历的快指针遇到第一个非0元素时,就将这个非0元素移动到指向0的慢指针的位置。慢指针继续去找0,最后将尾部剩余空间全部写成0即可。
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int n = nums.size();
if (n < 1) {
return;
}
int slow = 0;
for (int fast = 0; fast < n; fast++) {
if (nums[fast] == 0) {
continue;
}
nums[slow] = nums[fast];
slow++;
}
for (int i = slow; i < n; i++) {
nums[i] = 0;
}
}
};