MIT-6-01SC-EECS-导论笔记-全-
MIT 6.01SC EECS 导论笔记(全)
001:课程概述与软件工程基础


在本节课中,我们将要学习MIT 6.01SC课程的整体框架、教学理念,并深入探讨软件工程模块中最基础的概念:组合性、抽象与模块化。
课程目标与教学理念
本课程的核心是关于工程思维的模式。我们希望你们能从中学习如何设计、构建、调试复杂的系统。工程师擅长此道,我们也希望你们能变得同样出色。
为了达到这个目标,我们选择了“实践-理论-实践”的教学方法。教育研究表明,通过动手实践来学习效果最好。因此,本课程将围绕动手构建系统展开,在过程中让你们领悟高质量工程背后的核心思想。
课程模块概览
本课程内容广泛,我们将其组织为四个核心模块,每个模块约占课程的四分之一。以下是这四个模块的简要介绍:
- 软件工程:我们将聚焦于抽象和模块化这两个在构建大型系统时至关重要的思想。我们将从代码层面开始,逐步扩展到更高级的概念,如状态机。
- 信号与系统:我们将重点学习离散时间反馈。通过为系统行为建立数学模型并进行分析,我们可以预测系统性能并设计出更好的控制器。
- 电路:我们将学习如何为复杂系统(如机器人)增添新的传感能力。例如,你们将动手搭建一个电路,让机器人能够追踪光源。
- 概率与规划:我们将探讨如何设计能够应对不确定性并执行复杂计划的鲁棒系统。例如,让机器人在未知环境中构建地图、定位自身并规划路径。
课程组织形式
我们的教学理念直接体现在课程的组织形式上。以下是课程的主要组成部分:
- 每周讲座:共有13次讲座,用于介绍核心理论。
- 阅读材料:针对每个主题都有详尽的阅读材料,强烈建议课前阅读。
- 在线辅导练习:通过计算机练习来准备和巩固知识。
- 实验课:这是“实践”部分的核心,分为两种:
- 软件实验:时长为1.5小时,个人完成,主要编写和测试小程序。
- 设计实验:时长为3小时,与伙伴合作完成,解决更具开放性的设计问题。
- 小测验:每次设计实验开始时有15分钟的在线小测验,用于确保学习进度。
- 考核:包括两次期中考试、一次期末考试以及针对实验情况的一对一访谈。
软件工程模块入门

我们以软件工程模块开启课程,这既是EECS领域的重要组成部分,也为我们思考后续所有工程问题提供了一种便捷的“语言”。
今天,我们将从最微观的层面——单行代码的尺度——来探讨抽象与模块化。后续我们会逐步扩展到更大的尺度。

关于编程的特别说明
课程的前两周旨在让所有人达到一定的编程熟练度。我们不假设你有丰富的编程经验。
如果你编程经验较少或感到不自信,请务必优先完成Python在线辅导练习。我们甚至会为此提供专门的帮助课程和延期许可。我们的目标是让你在情人节(2月14日)前对编程感到得心应手。如果届时仍感困难,可以考虑转入专门的Python编程课程(6.00)。
Python基础:解释器与组合性
我们选择Python是因为它简单,并能很好地展示软件工程的重要思想。Python是一个解释器,其基本行为模式是:询问用户输入、读取输入、解释执行、打印结果,如此循环。
这种交互特性使得“通过动手来学习”成为可能。例如,在解释器中直接输入 2 + 3,Python会计算并输出结果 5。
这揭示了一个核心思想:组合性。表达式 3 * 8 可以被视为与单个整数 24 完全等价。在任何后续操作中,你都可以用 24 来替换 3 * 8。这种用简单事物替代复杂表达式的特性,是构建层次化思维系统的基础。
命名与抽象:函数与变量
为了让常用的操作序列能被方便地复用,我们需要为其命名。在Python中,使用 def 关键字来定义函数。
def square(x):
return x * x
定义后,square(6) 的结果是 36。现在,square 可以像 * 这样的原始操作符一样被使用。我们可以利用它来构建更高级的操作。
def sum_of_squares(x, y):
return square(x) + square(y)
sum_of_squares 函数不需要知道如何计算平方,它只需相信 square 函数能完成这项工作。这就是分解问题的威力:将任务拆分成“如何平方”和“如何求和”两个更简单的部分。
同样,我们可以组合数据。Python中最基本的数据结构是列表,它可以包含各种类型的元素,甚至包含其他列表,从而形成复杂、层次化的数据结构。
my_list = [1, 2, [‘a‘, ‘b‘], 3]
我们可以使用变量为数据命名,例如 y = [1, 2, 3],然后通过 y[0] 或 y[-1] 来访问其元素,这带来了与命名函数相同的好处。
聚合数据与操作:类
更高阶的概念是将数据和操作聚合到一个数据结构中。Python通过类来实现这一点。类可以定义属性(数据)和方法(操作)。
class Student:
school = ‘MIT‘
def calculate_final_grade(self):
# ... 计算逻辑 ...
pass
定义类之后,可以创建它的实例。实例继承类的所有结构,但也可以拥有自己特有的数据。
mary = Student()
john = Student()
mary.section = 3
john.section = 4
你还可以创建子类,它继承父类的一切,并可以添加新的属性和方法。
class Student_601(Student):
lecture_day = ‘Tuesday‘
def calculate_tutor_score(self):
# ... 计算逻辑 ...
pass
命名绑定的实现细节:环境
理解Python如何管理名称与实体(值、函数、类)的关联至关重要,其规则简单而一致。
Python在称为环境的列表中维护名称与值的绑定。当执行 b = 3 时,它就在当前环境中添加了名称 b 与整数 3 的绑定。当查询 b 时,Python就在当前环境中查找 b 并返回其值。
对于函数,当定义 def square(x): ... 时,Python会在当前环境中绑定名称 square 到一个过程对象,该对象记录了形式参数 x、函数体以及函数定义时的环境。
当调用 square(a + 2) 时,Python会:
- 查找
square,发现它是一个过程。 - 为这次调用创建一个新环境(E2),用于绑定形式参数
x。 - 在调用时的环境(E1)中计算实际参数
a + 2的值(例如得到5)。 - 在E2中将
x绑定到该值(5)。 - 在E2中执行函数体
return x * x。当需要查找x时,在E2中找到其值为5。 - 计算并返回结果25,然后销毁这个临时环境E2。
如果函数体内使用的变量不在其局部环境E2中,Python会到其父环境(即函数定义时的环境)中去查找,从而形成一条环境链。类的实现也基于环境模型,类名.属性 或 实例.方法 中的点操作符,本质就是在相应的环境链中进行查找。

本节课中我们一起学习了MIT 6.01SC课程的整体架构与教学法,并深入探讨了软件工程基础。我们理解了通过组合性构建复杂表达式,使用函数和变量进行命名与抽象,以及利用类来聚合数据与操作。最后,我们揭示了Python通过环境模型来实现这些命名绑定的核心机制,这是写出可预测、模块化代码的关键。在接下来的实践中,我们将反复运用这些基础概念。
002:Python面向对象编程入门教程


概述
在本节课中,我们将学习Python中的面向对象编程。面向对象编程是一种编程范式,它允许我们以与现实世界对象交互相似的方式来组织和管理代码。我们将从基本概念开始,逐步介绍如何在Python中定义类、创建对象以及使用方法和属性。
面向对象编程基础
面向对象编程的核心思想是“一切皆对象”。这意味着我们可以将代码组织成类似于现实世界中对象的单元,每个对象都有自己的属性和行为。
在面向对象编程中,我们使用类来描述一类对象的共同特征和行为。类定义了对象可以执行的操作(方法)和对象具有的属性(变量)。一旦定义了类,我们就可以创建该类的实例,即具体的对象。
上一节我们介绍了面向对象编程的基本概念,本节中我们来看看如何在Python中实现这些概念。
定义第一个类
以下是一个简单的Python类定义示例,它描述了一个6.01课程工作人员的类:
class Staff601:
room = "34-501"
def sayHi(self):
print("Hello")
在这个例子中,Staff601是一个类。它有一个类属性room,所有该类的实例都将共享这个属性。同时,它定义了一个方法sayHi,当调用时会在屏幕上打印“Hello”。
创建和使用对象
定义了类之后,我们可以创建该类的实例(对象)。以下是创建和使用对象的步骤:
- 实例化对象:使用类名后跟括号来创建对象。
- 访问属性:使用点号(
.)访问对象的属性。 - 调用方法:同样使用点号来调用对象的方法。
以下是具体操作的示例:
# 创建Staff601类的一个实例
kpu = Staff601()
# 访问实例的属性
print(kpu.room) # 输出: 34-501
# 调用实例的方法
kpu.sayHi() # 输出: Hello
理解self参数
在类的方法定义中,第一个参数通常是self。self代表调用该方法的对象实例本身。它允许方法访问和修改该特定实例的属性。
以下是self参数的使用示例:
class Staff601:
room = "34-501"
def __init__(self, greeting):
self.greeting = greeting
def sayHi(self):
print(self.greeting)
在这个修订版的类定义中,我们添加了一个__init__方法,它用于初始化每个实例的特定属性。self.greeting将存储每个实例独有的问候语。
实例属性与类属性
在Python中,属性可以分为实例属性和类属性:
- 实例属性:属于特定实例的属性,每个实例可以有不同的值。它们通常在
__init__方法中定义。 - 类属性:属于类本身的属性,所有实例共享相同的值。它们在类内部但在任何方法之外定义。
以下是区分实例属性和类属性的示例:
# 创建两个不同的实例
hearts = Staff601("Hi")
kpu = Staff601("Hello")
# 访问实例属性(每个实例不同)
hearts.sayHi() # 输出: Hi
kpu.sayHi() # 输出: Hello
# 访问类属性(所有实例相同)
print(hearts.room) # 输出: 34-501
print(kpu.room) # 输出: 34-501
总结
本节课中我们一起学习了Python面向对象编程的基础知识。我们了解了类和对象的概念,学习了如何定义类、创建对象实例,以及如何使用方法和属性。我们还探讨了self参数的作用,并区分了实例属性和类属性。掌握这些基本概念是进一步学习面向对象编程高级主题(如继承)的重要基础。
003:Python中的继承概念与实践 🐍


在本节课中,我们将学习面向对象编程中的一个核心概念——继承。我们将探讨继承的基本思想,如何在Python中实现它,并了解在6.01课程中使用继承时的一些技巧和常见注意事项。
概述:什么是继承? 🧬
上一节我们介绍了面向对象编程的基础,本节中我们来看看继承。继承是一种将对象按层次结构排列的思想。这样,适用于整个对象组的、非常通用或基本的属性可以在更高的层级指定,然后可以逐步向下深入到更具体的层级。
你可能在生物学分类法中最正式地接触过这种方法:界、门、纲、目、科、属、种。每个物种都具有其所属属的所有属性,而一个科的所有属又具有该科的所有属性,依此类推。
为了更生动地说明,我们以犬种为例。金毛寻回犬是寻回犬的一种,而寻回犬是狗的一个特定种类。你可以根据对狗的了解来推断金毛的普遍特性。例如,所有的狗都会叫,所有的狗都有四条腿。金毛也具有寻回犬的所有属性,它们能够去叼回猎物。金毛还有其特有的属性,这些属性定义了金毛与一般寻回犬的区别。
当我们想要创建具有特定属性,同时又与其他对象共享通用属性的对象时,我们会创建一个新的对象类别,并将具体特性放入这个特定类别中。然后,将我们可以概括的通用部分放入更通用的类别中。这样做可以避免重复编写大量代码,或者避免到处复制粘贴代码以实现代码复用。
使用继承的另一个主要优势是代码更直观。你可以到处引用同一段代码,但反复这样做并不那么直观。将金毛视为寻回犬的子类或子类型,而寻回犬又是狗的子类或子类型,这种思考方式非常方便。
在面向对象编程中讨论这种关系时,金毛是寻回犬的子类或子类,而寻回犬是金毛的父类或超类。同样,狗是寻回犬的父类。
Python中的继承实践 💻
现在,让我们转向Python中的具体实现。这里有一个非常简短的狗类定义。
class Dog:
cry = "Bark"
def __init__(self, name):
self.name = name
def greeting(self):
return "I'm " + self.name + " " + self.cry
每个狗都有类属性 cry。每个狗都有一个初始化方法,为每只狗赋予一个在初始化时传入的特定名字。每只狗都可以访问类方法 greeting,该方法返回一个字符串,内容是“我是[狗的名字]”以及特定的叫声(本例中实际上是类的叫声)。如果你不熟悉字符串中使用加号,它只是一个连接符。
如果创建一个实例 Lassie,调用 Lassie.name 会指向初始化对象时指定的 self.name,所以 Lassie 的名字是 Lassie。同样,如果你输入 print(Lassie.greeting()) 并回车,应该会返回一个字符串:“I‘m Lassie Bark”。
创建子类
现在,我们看看当你想建立一个子类时会发生什么。如果我建立 Retriever 类,并想从超类 Dog 继承,我会传入 Dog。其语法与我想向函数传递参数时使用的语法相同。
class Retriever(Dog):
pass
注意,我这里没有写任何代码。这明确指明了 Retriever 实际上不会为 Dog 引入任何新属性。它们的类型会不同。如果我创建一个 Retriever 对象,它的对象类型将是 Retriever,而如果我创建一个 Dog 对象,它的类型将是 Dog。
当我创建一个 Retriever 对象时(比如叫 Benji),它首先会在 Retriever 类定义中寻找初始化方法或其他方法或属性。运行这里的所有代码,然后转到父类,运行那里的所有代码。因此,即使 Retriever 下面没有任何显式代码,我仍然可以以与 Lassie 对象相同的方式与 Benji 对象交互。它拥有所有相同的方法和属性。
这就是基本的继承。需要说明的是,如果你这样做,可能一开始就不需要创建子类。如果你在设计自己的代码,并思考组织事物的最佳方式,如果你必须创建一个子类型或子类,而又没有新的方法或属性,或者没有处理这些方法或属性的不同方式,那么这个类别可能实际上并不需要单独存在。你可能为了进行有趣的类型检查而区分它们,但这是我能想到的唯一理由。
多级继承
我们已经完成了继承的第一部分,我们将再继承一次,为金毛寻回犬创建一个类。
class Golden(Retriever):
def greeting(self):
return "Oh, hi " + Retriever.greeting(self)
我再次有了类定义,并指明我将从 Retriever 继承。我没有任何初始化或属性赋值,只有一个 greeting 的定义。
那么这里会发生什么?我们首先总是寻找初始化方法。Golden 没有,所以它会检查 Retriever 类。Retriever 也没有,所以它会检查 Dog 类。初始化方法在这里,所以当它运行初始化方法时,会运行这里的代码。
这里任何代码或属性赋值或方法定义都将被视为任何金毛对象首先要引用的规范。因此,greeting 方法将在任何其他地方使用 greeting 之前被执行。你注意到这个 greeting 和狗的 greeting 之间的唯一区别是,在短语前添加了“Oh, hi”。我们实现这一点的方式是,我们先连接字符串,然后引用超类。同样,当我们在类定义中讨论时,必须传入显式参数 self。稍后当你实际实例化一个对象并使用它时,你不需要将 self 作为参数放入,否则会引起混淆。
假设我创建一只金毛寻回犬 Sydney,我将传入一个参数,即名字。我们将首先考虑这里的所有定义,这意味着金毛将拥有一个在这里指定的 greeting 方法。它将使用来自 Retriever 的 greeting 方法。我们也可以在这里放入任何东西,比如 Dog.greeting,或者与 Golden 在同一环境中的其他函数。但在这里,我们可以显式地访问我们在这里定义的超类。
我们将转到 Retriever,查看是否有任何作为 Retriever 子类的结果需要添加到我们定义中的额外方法或属性。这里我们只遇到了 pass。另一方面,Retriever 继承自 Dog,所以我们必须再次跳转到超类,并获取那里定义的任何属性或方法。
回到 Sydney,当我调用 Sydney.greeting() 时,发生的第一件事是我在最具体的子类(或我的对象类型)中查找该方法是否有定义。因为这里有定义,所以我不会使用 Dog.greeting,而是使用 Golden.greeting。Golden.greeting 说:返回一个字符串“Oh, hi”,并将其附加到 Retriever.greeting 返回的内容上。我转到 Retriever,这里没有,但我仍然有对 Dog 的引用。我转到 Dog,它有一个 greeting 方法,它说:“I‘m Sydney Bark”。所以最终的返回类型应该是:“Oh, hi I‘m Sydney Bark”。
总结 📚
本节课中我们一起学习了面向对象编程中的继承概念。我们了解到继承允许我们创建层次化的类结构,将通用属性放在父类中,特定属性放在子类中,从而实现代码复用和更直观的设计。在Python中,通过在类定义时在括号内指定父类来实现继承。子类会自动获得父类的方法和属性,并可以重写或扩展它们。我们还通过犬种的例子,演示了从 Dog 到 Retriever 再到 Golden 的多级继承过程,以及方法解析的顺序。理解继承对于阅读和编写6.01课程中使用面向对象范式的代码至关重要。
004:编程范式与状态机 🚀

概述
在本节课中,我们将学习如何通过不同的编程范式来组织和管理复杂系统的构建。我们将首先回顾PCAP(原语、组合、抽象、模式)的核心思想,然后探讨三种不同的编程风格:命令式、函数式和面向对象编程。最后,我们将引入一个更高级别的概念——状态机,作为构建动态过程(如机器人控制)的模块化工具。
编程范式:管理复杂性的不同视角
上一节我们介绍了PCAP作为管理复杂性的核心思想。本节中,我们来看看如何通过不同的编程范式来实现这一目标。编程的基本结构会显著影响你构建抽象的能力。


我们将探讨三种构建程序的方法论:


- 命令式(过程式)编程:像遵循食谱一样,专注于“下一步做什么”的逐步指令。
- 函数式编程:关注数学意义上的函数,它们接收输入、产生输出,并且没有副作用(如修改变量)。
- 面向对象编程:围绕数据和相关过程的集合(对象)来组织解决方案,并构建这些对象的层次结构。
为了理解这些范式的区别,我们将通过一个具体问题来展示每种方法。
示例问题:寻找操作序列
问题:找到一系列操作(“递增”或“平方”),将一个初始整数 I 转换成一个目标整数 G。
- 操作:
increment(加1) 或square(平方)。 - 示例:序列
increment, increment, increment, square作用于1会得到16。1 -> 2 -> 3 -> 4 -> 16
命令式(过程式)方法 🧾
命令式方法通过预先设计好的步骤来解决问题,就像一份详细的食谱。
解决上述问题的一个合理步骤是:按长度枚举所有可能的操作序列。先检查所有长度为1的序列,然后长度2,依此类推,直到找到能实现目标的序列。
以下是该思路的关键实现点:
- 使用元组
(操作描述字符串, 执行后的结果值)来表示一个序列及其结果。 - 程序结构包含三层嵌套循环来生成和测试不同长度的序列。
核心挑战:保持多层循环中索引的正确性很容易出错,代码的模块化程度较低。

函数式方法 🔄
函数式方法将问题分解为多个纯函数(无副作用)的计算模块。
我们定义两个核心函数:
apply函数:接收一个函数列表和一个初始值,按顺序应用这些函数,返回最终结果。这体现了“函数是一等公民”的思想,即函数可以像数据一样被存储在列表中传递。# 示例 apply([], 7) -> 7 apply([increment], 7) -> 8 apply([square], 7) -> 49 apply([increment, square], 7) -> 64add_level函数:接收一个“序列列表”(每个序列是一个函数列表),并通过为每个现有序列添加increment或square操作,生成一个包含所有可能更长序列的新列表。


优势:
- 模块化与易调试:每个函数都是独立的模块,可以单独测试。
- 表达力强:自然地支持递归思想,使算法描述更清晰。



递归:强大的表达工具
递归是一种函数调用自身的技术,它将复杂问题逐步简化为已知的基本情况。



示例1:线性递归求幂
def exponent(b, n):
if n == 0:
return 1
else:
return b * exponent(b, n-1)
计算 b^n 需要大约 n 次递归调用。


示例2:快速求幂(利用数学知识)
通过增加一个判断条件(如果n是偶数),可以大幅减少递归步骤,这体现了函数式方法易于融入新规则的优点。
def fast_exponent(b, n):
if n == 0:
return 1
elif n % 2 == 1: # n 是奇数
return b * fast_exponent(b, n-1)
else: # n 是偶数
t = fast_exponent(b, n//2)
return t * t
计算 b^10 只需约5次调用,而非10次。


示例3:汉诺塔问题
汉诺塔的解决方案用循环描述非常晦涩,但用递归描述则异常简洁:
要将
n个盘子从A柱移到B柱:
- 将上面
n-1个盘子从A移到C(借助B)。- 将最大的盘子从A移到B。
- 将
n-1个盘子从C移到B(借助A)。

递归的核心优势在于其表达力,它能将复杂算法用简洁、符合直觉的方式描述出来。

面向对象方法 🧱


面向对象方法使用对象(数据与方法的集合)来构建解决方案。
对于操作序列问题,我们可以将所有可能的序列想象成一棵树:
- 每个节点代表一个状态。
- 节点包含:父节点引用、导致此状态的操作、当前状态的结果值。
- 定义一个
Node类来封装这些信息。 - 程序通过创建和连接
Node对象来构建这棵树,并搜索目标值。
优势:将问题的表示(树形结构)与解决方案逻辑紧密结合,循环结构更简单,因为核心信息都封装在对象内部。
状态机:对动态过程的高级抽象 ⚙️

前面我们探讨了在代码层面的模块化。现在,我们将抽象级别再提高一层,思考如何为随时间演化的过程(如银行账户、图形用户界面、机器人控制器)建模。我们仍要遵循PCAP原则,但使用新的编程模块——状态机。

什么是状态机?
状态机是描述过程行为的模型,它在每个步骤中:
- 接收一个输入。
- 根据当前状态和输入,计算一个输出。
- 更新为下一个状态。
状态机通过其状态来记忆过去发生的一切必要信息。
核心要素:(输入, 状态, 输出)
示例:十字转门
十字转门是一个经典的状态机例子。
- 状态:
锁定或解锁。 - 输入:
投币、推动、无。 - 输出:
请付款、请通行。

其行为可以用状态图清晰描述:
[投币 / 请通行]
----------------
| |
锁定 解锁
| |
----------------
[推动 / 请付款]
(初始状态为锁定,无输入时保持当前状态并输出相应信息)。
状态机表示法将时间步进的循环逻辑与每一步的状态转换逻辑分离开,提高了模块性。

状态机在机器人控制中的应用
考虑一个机器人从A点移动到B点的问题,它不知道中途的障碍物。每一步,它:
- 用传感器(如声纳)探测环境,更新地图。
- 根据最新地图,规划一条新路径。
- 根据规划出的路径,控制轮子移动。


我们可以用三个模块化的状态机来建模这个复杂过程:
Mapper(地图构建器):输入传感器数据,输出更新后的地图。Planner(路径规划器):输入地图,输出规划路径。Mover(运动控制器):输入路径,输出轮子控制指令。

这些状态机可以独立开发、测试和调试,最后组合起来形成复杂的机器人行为。这正是模块化的威力所在。


在Python中实现状态机 🐍
我们将使用面向对象技术,在三个层次上实现状态机:
-
通用
StateMachine类:定义所有状态机的共同接口。start(): 初始化状态。step(输入): 执行一步,返回输出。其内部会调用...get_next_values(状态, 输入): 纯函数,根据当前状态和输入,计算下一个状态和输出。这是子类必须定义的核心。transduce(输入列表): 对一系列输入,返回对应的输出列表。
-
特定状态机子类(如
Accumulator累加器,Turnstile十字转门)。- 定义
start_state。 - 实现
get_next_values方法。
- 定义
-
状态机实例:子类的具体对象(如“中央地铁站的第一个转门”)。
示例:Accumulator 累加器
class Accumulator(StateMachine):
start_state = 0
def get_next_values(self, state, inp):
next_state = state + inp
return (next_state, next_state)
这个状态机将输入值不断累加到状态上,并将新状态作为输出。
状态机的组合
我们可以像组合电路一样组合状态机,这是PCAP中“组合”的体现:
- 级联:一个状态机的输出作为另一个的输入。
- 并行:多个状态机处理相同的输入。
- 反馈:将输出作为输入的一部分反馈回去。
示例:两个累加器级联
如果 A 和 B 都是累加器,C 是 A 和 B 的级联。
输入 [7, 3, 4] 到 C:
A处理:[7, 10, 14](输出序列)。B接收A的输出作为输入:[7, 17, 31]。
因此,C.transduce([7,3,4])的结果是[7, 17, 31]。


通过这种方式,我们可以用简单的状态机构建出复杂的大脑。

总结 🎯

本节课中我们一起学习了:
- 三种编程范式:命令式、函数式和面向对象编程,它们以不同的方式组织代码,影响模块化和抽象能力。
- 递归的强大表达力:它使某些算法(如汉诺塔)的描述变得异常简洁。
- 状态机:作为对动态过程进行高级抽象和模块化设计的强大工具。状态机通过
(输入,状态,输出)模型,将时间逻辑与业务逻辑分离。 - 状态机的实现与组合:在Python中通过类层次结构实现,并可以通过级联、并行等方式组合,从而构建复杂的系统(如机器人控制系统)。





掌握这些概念和工具,将帮助你更好地管理复杂性,构建更清晰、更健壮、更易维护的软件系统。
005:函数式编程与列表操作


在本节课中,我们将学习Python中一些重要的概念,特别是函数式编程相关的特性,包括函数作为一等公民、Lambda表达式、列表推导式以及可变对象的注意事项。这些知识对于理解和使用Python进行高效编程至关重要。
函数作为一等公民
上一节我们提到了面向对象编程,本节中我们来看看函数式编程。在Python中,函数被视为“一等公民”。这意味着函数可以像其他数据类型(如整数、字符串)一样被处理。
具体来说,函数作为一等公民包含以下含义:
- 函数可以作为另一个函数的返回值。
- 函数可以作为参数传递给另一个函数。
- 函数可以赋值给变量,并像其他数据结构一样进行操作。
这一点非常重要,因为它允许我们创建高阶函数,即那些操作或使用其他函数的函数。
让我们看一个例子。假设我们有一个基础函数 square,用于计算一个数的平方:
def square(x):
return x * x
基于函数是一等公民的概念,我们可以编写一个函数,它接收一个函数作为参数,并返回一个新的函数。这个新函数会将传入的函数应用两次:
def twice(some_function):
def return_function(*args):
return some_function(some_function(*args))
return return_function
twice 函数做了以下事情:
- 它接受一个函数
some_function作为参数。 - 在内部定义了一个新函数
return_function。 return_function接受任意参数*args,先调用some_function(*args),然后将结果再次传给some_function并返回最终结果。- 最后,
twice返回这个新函数return_function。
请注意,这个返回值的类型就是一个函数。
现在,我们可以使用 twice 来创建一个将 square 应用两次的新函数:
F = twice(square)
当我们调用 F(2) 时,会发生:
args被赋值为2。- 首先调用
square(2),得到4。 - 然后调用
square(4),得到16。 - 最终返回
16。
你可以尝试在IDLE中输入这些代码,并修改参数来验证其功能。
Lambda表达式
既然函数可以像普通值一样传递和返回,那么我们是否可以直接使用一个“匿名”的函数值,而不必先使用 def 给它命名呢?这就是Lambda表达式的作用。
lambda 是Python的一个关键字,用于创建匿名函数。它的语法是:lambda 参数: 表达式。它直接返回一个函数对象。
例如,我们之前定义的 square 函数,可以用Lambda表达式简洁地表示为:
lambda x: x * x
Lambda表达式源于计算机科学历史上的Lambda演算。它的常见用途包括:
- 需要快速定义一个简单的函数,不想花费额外的代码行数去写
def。 - 需要一个匿名函数,不想为其分配一个名字或占用额外的命名空间。
使用Lambda,之前的 twice(square) 可以写成:
F = twice(lambda x: x * x)
这样,我们节省了定义 square 函数的两行代码,并且在这一行内就能清晰地表达意图。
列表操作:map、filter与列表推导式
在Python中,你会经常进行列表操作。匿名函数结合高阶函数,可以非常优雅地在一行内处理列表。
假设我们有一个简单的列表:
demo_list = [1, 2, 3, 4]
以下是几个强大的工具:
1. map函数
map 函数接受一个函数和一个可迭代对象(如列表),将该函数应用到可迭代对象的每个元素上,并返回一个包含结果的新迭代器(在Python 3中,如需列表请使用 list() 转换)。
list(map(lambda x: x * 2, demo_list)) # 返回 [2, 4, 6, 8]
这行代码完成了将列表中每个元素乘以2的操作,无需编写循环。重要的是,map 返回一个新的数据对象,原始的 demo_list 并没有被改变。
2. 列表推导式
列表推导式提供了一种更优雅、更数学化的方式来创建和操作列表。它的语法类似于数学中的集合表示法。
例如,要创建一个包含1到4的平方的列表:
squared_list = [x**2 for x in range(1, 5)] # 返回 [1, 4, 9, 16]
列表推导式非常简洁,可读性强。它也可以结合条件语句(实现类似 filter 的功能)和多个循环,功能强大。
列表推导式、map、filter 和匿名函数这些工具结合在一起,让你能用极少的代码实现复杂的功能。熟悉这些写法对于阅读许多函数式风格或人工智能领域的代码也很有帮助。
可变对象与别名
在讨论了高效操作列表的工具后,我们需要了解一个关于Python列表的重要特性:可变性。这对于避免程序中的错误至关重要。
如果你是编程新手,可能已经接触过一些数据类型:数字、字符串、元组都是不可变的。这意味着一旦创建,它们的值就不能改变。Python会进行优化,让指向相同不可变值的变量共享内存地址。
g = “hello”
h = “hello”
# g 和 h 可能指向内存中同一个字符串 “hello”
x = 5
y = 5
# x 和 y 可能指向内存中同一个整数 5
x = 6 # x 现在指向了新的整数 6 的内存地址,y 仍然指向 5
然而,列表是可变对象。问题在于,当你修改一个可变对象时,它是在原地被修改的,内存地址不变。这可能导致“别名”问题。
A = [1, 2, 3]
B = A # B 和 A 现在指向内存中的同一个列表对象
B.append(4) # 通过 B 修改了这个列表
print(A) # 输出 [1, 2, 3, 4]!A 也被改变了,因为 A 和 B 是同一个对象的别名。
这种行为有时很有用,但你必须时刻意识到它,否则容易引发难以察觉的错误。
为了避免这种情况,当你需要修改一个列表但又想保留原列表时,应该创建它的一个副本,然后修改副本。
创建浅拷贝最简单的方法是使用切片:
A = [1, 2, 3]
C = A[:] # 创建 A 的一个副本赋值给 C
C.append(4)
print(A) # 输出 [1, 2, 3],保持不变
print(C) # 输出 [1, 2, 3, 4]
此时,C 和 A 指向内存中两个不同的列表对象。对于嵌套的复杂数据结构,可以使用 copy 模块的 deepcopy 函数进行深拷贝。
总结
本节课中我们一起学习了Python函数式编程的核心概念。我们了解了函数作为一等公民的含义,学会了使用Lambda表达式创建匿名函数。接着,我们掌握了使用 map 函数和列表推导式来高效、简洁地操作列表。最后,我们探讨了可变对象(如列表)的特性以及由“别名”可能引发的陷阱,并学习了通过创建副本来安全修改数据的方法。这些知识为你进行更复杂的Python编程,尤其是处理数据和状态,奠定了坚实的基础。
006:状态机 🧠

在本节课中,我们将学习状态机这一核心概念。状态机在控制理论、人工智能和可计算性理论等领域都至关重要。我们将回顾已学的编程范式,解释为何需要状态机来为模型增加复杂性,并探讨状态机在不同领域的表示方法,最后介绍如何在课程软件中使用状态机。
回顾已学编程范式
上一节我们介绍了不同的编程范式。到目前为止,我们讨论了函数式、命令式和面向对象编程范式。
- 在函数式编程中,一切皆是函数。
- 在命令式编程中,我们可以使用函数,但也允许函数产生副作用。
- 在面向对象编程中,一切皆是对象。
我们可以用前两种范式来实现最后一种,也可以用第一种范式加上变量赋值等概念来实现第二种。这体现了计算机科学语言范式的演进脉络。
然而,这些范式本身都无法让我们维护内部状态。所谓内部状态,是指我们希望建模的系统能够随时间推移而演化,或能追踪系统中随时间累积的数据。函数式编程无法做到这一点,因为它接受一个输入,产生一个输出。虽然可以编写一个函数来处理所有可能的情况并产生逻辑输出,但这将导致代码量巨大。命令式和面向对象编程同样无法单独解决这个问题。我们需要的是能够审视随时间发生的一切事件和所有数据,对其进行综合处理,然后生成相应输出的能力。这就是内部状态的概念。
状态机的基本概念
状态机,也称为离散有限自动机,已经存在很长时间。本课程主要讨论离散状态机。在数学文献中,状态机或离散有限自动机通常由五个要素定义:
- 状态集合:状态机可能处于的所有状态的集合。
- 输入集合:状态机可能接收的所有输入的集合。
- 输出集合:状态机可能产生的所有输出的集合。
- 转移函数:该函数查看当前状态和当前输入,确定因此次转移将进入的下一个状态以及将产生的输出。
- 起始状态:状态机开始运行时的初始状态。
为了更具体地理解,我们来看一个状态转移图。
状态转移图示例
假设有一个地铁旋转闸门(MBTA turnstile)。它有四个状态:
- 关闭:闸门关闭,等待交互。
- 开放且愉快:因投入钱币而开放。
- 开放且安静:通常因前一个人的交互而开放。
- 开放且愤怒:因不当交互而开放。
在这个有向图中,顶点就是我们的状态集合。假设起始状态为“关闭”(通常用一个从无处指向某个状态的箭头表示)。
现在,我们需要输入和输出。向闸门投币构成输入。转移函数会查看当前状态和当前输入,生成输出和下一个状态。图中的所有箭头(除了包含目标顶点信息外)就指定了我们的转移函数。任何未指定的转移都不在函数考虑范围内。
以下是状态转移过程示例:
- 起始状态为“关闭”。
- 有人通过闸门退出(输入:“退出”)。输出:“无”。新状态:“开放且安静”。
- 此时,我尝试进入(输入:“进入”)。输出:“发出噪音”。新状态:“开放且愤怒”。
- 随后,闸门会自动关闭(输入:无或任何输入)。输出:“关闭”。新状态:“关闭”。
总结一下映射关系:
- 状态集合:图中的顶点。
- 输入集合:转移边上标注的第一部分。
- 输出集合:转移边上标注的第二部分(作为转移的结果)。
- 转移函数:由有向边及其标注表示。
- 起始状态:指定的开始顶点。
在软件中实现状态机
一旦理清了所有这些集合,就可以讨论如何在软件中实现状态机了。课程库已经对此进行了抽象,但你需要知道如何与之交互。
让我们看一个状态机类的例子。假设我们要构建一个累加器,它在每个时间步查看输入,将其与此前所有输入值相加,然后输出新值并将其保留为新状态。
首先,需要用初始值初始化累加器,这就是我们的起始状态。我们还需要一个 getNextValues 方法,它在功能上等同于转移函数。
getNextValues 方法会查看当前状态和当前输入,进行一些内部数据处理(例如乘以2,或与先前输入比较并根据条件判断等)。对于前几周的内容,这通常是一个简单的函数。然后,它返回一个包含新状态和输出的元组。
对于这个累加器,新状态和输出都是当前状态与当前输入的线性组合。
如果用状态转移图表示这个累加器,会是这样的:起始状态是初始值。传入一个新输入(称为 input0)后,输出和新状态都是这两个值的线性组合。进行下一次转移时,会取下一个输入,将其与当前状态值相加并作为输出返回,依此类推。
鼓励你在 Python(或课程指定的 IDE)中尝试此操作。你可能需要输入 import lib601 来获取状态机类。除此之外,这些内容足以让你开始学习状态机。
总结
本节课我们一起学习了状态机。我们回顾了函数式、命令式和面向对象编程范式的局限性,引出了对内部状态的需求。我们详细介绍了状态机的五个核心组成部分:状态集合、输入集合、输出集合、转移函数和起始状态,并通过地铁闸门的例子,用状态转移图直观地展示了这些概念。最后,我们探讨了如何在软件中实现一个简单的累加器状态机,并鼓励你通过实践来加深理解。如果你遇到困难,强烈建议阅读课程材料中的所有示例,它们非常全面,也包含了累加器的例子。
下次课,我们将讨论线性时不变系统。
007:信号与系统入门 🚀



在本节课中,我们将要学习如何从“构建系统”转向“分析系统行为”。我们将引入一种强大的方法——信号与系统方法,它通过观察系统如何将输入信号转换为输出信号来表征系统。我们将重点学习离散时间系统的几种表示方法:差分方程、框图以及最重要的——算子表示法。



从编程到建模物理系统



上一节我们介绍了如何通过编程(PAP原则:基本元素、组合方式、抽象和模式识别)来构建复杂系统。本节中,我们来看看如何分析和控制物理系统的行为。


我们将通过一个机器人找墙的例子来引入。目标是编写程序,让机器人从当前位置平滑移动到距离墙壁半米的目标位置。一种直观的方法是使用比例控制器,其核心思想是让控制指令(如速度)与误差成比例。
在代码中,这可以表示为:
forward_velocity = Kp * (current_distance - desired_distance)
其中,Kp 是一个比例常数。当 current_distance 等于 desired_distance 时,速度应为零;当距离过远时,速度应为正;当距离过近时,速度应为负。
然而,在实际系统中,由于传感器延迟、计算时间和机器人惯性等因素,简单的比例控制可能导致超调现象,即机器人会越过目标点,然后来回振荡。


信号与系统方法




为了分析和预测这类行为,我们需要一种新的视角。我们不再仅仅关注系统内部如何构建,而是关注其行为,即输入与输出之间的关系。这就是信号与系统方法。
以下是该方法的核心思想:
- 将任何系统(机械、电气、计算等)视为一个“黑盒”。
- 这个黑盒接收一个输入信号,并产生一个输出信号。
- 系统的特性完全由它从输入到输出的变换规则所定义。
这种方法具有普适性和模块化的优点。例如,我们可以将手机系统视为将声音信号转换为电磁波信号,再转换回声音信号的几个模块的串联。

离散时间系统表示法
由于我们的机器人工作在离散的“步进”时间中,我们将专注于离散时间系统的分析。以下是三种主要的表示方法:
1. 差分方程
差分方程是离散时间系统最紧凑的数学描述,类似于连续时间系统的微分方程。
一个简单的例子是:
y[n] = x[n] - x[n-1]
这个方程表示,在时间步 n 的输出 y[n],等于当前输入 x[n] 减去前一个时间步的输入 x[n-1]。

2. 框图
框图以图形化的方式展示了信号在系统中的流动路径。它包含代表加法、乘法和延迟(用 R 表示)的模块。


以下是表示 y[n] = x[n] - x[n-1] 的框图:
x[n] --->(+)---> y[n]
^
|
(-1)|
|
[R]--'
框图是命令式的,它明确指出了计算每一步输出的流程,并且清晰地标明了输入和输出。

3. 算子表示法
算子表示法结合了差分方程的简洁性和框图的丰富信息。它通过将整个信号(而非单个样本)作为操作对象,实现了更高层次的抽象。
我们引入右移算子 R。对信号 X 应用 R 算子,即得到整个信号向右移动一步的新信号 RX。
利用算子,上面的系统可以表示为:
Y = (1 - R) X
这里,1 表示恒等算子(直接通过),-R 表示将信号右移后取反。整个表达式 (1 - R) 就是一个作用于输入信号 X 的算子。


算子表示法的强大之处在于:
- 简洁且包含方向信息:像差分方程一样紧凑,同时像框图一样明确了输入
X和输出Y。 - 易于操作:算子遵循多项式代数规则(如交换律、分配律)。例如,两个系统
(1-R)和(1+R)的级联可以简单地表示为(1-R)(1+R) = 1 - R^2。 - 便于证明等价性:通过比较算子表达式,可以轻松判断两个不同的框图是否在初始静止条件下等价。
处理反馈系统
当系统中存在反馈回路时,情况变得更有趣。在反馈系统中,输出会反过来影响输入。


考虑一个简单的反馈系统:当前输出 y[n] 等于当前输入 x[n] 加上前一步的输出 y[n-1]。其框图包含一个回路。

在算子域中,这表示为:
Y = X + RY => (1 - R)Y = X => Y = (1 / (1 - R)) X
这里,算子出现在分母上。为了理解其含义,我们可以利用多项式知识对其进行级数展开:
1 / (1 - R) = 1 + R + R^2 + R^3 + ...
这意味着,反馈系统 Y = (1/(1-R)) X 等价于一个具有无穷多条前向路径的馈通系统。当输入是单位样本信号(仅在 n=0 时为1)时,该系统的输出会是一个持续为1的无限长信号(阶跃响应)。这展示了反馈可以产生持久的影响。



总结与应用
本节课中我们一起学习了:
- 行为分析的重要性:从构建系统转向分析其输入/输出行为。
- 信号与系统框架:将系统视为输入信号到输出信号的变换器。
- 三种系统表示法:
- 差分方程:数学上紧凑。
- 框图:图形化、命令式,显示了信号流。
- 算子表示法:结合了前两者的优点,利用
R算子和多项式代数,成为分析和操作离散时间系统最有力的工具。
- 反馈的概念:反馈回路能用算子分式表示,并通过级数展开理解其无限脉冲响应。


通过掌握算子表示法,我们可以用熟悉的多项式运算来解决复杂的系统互联问题。在接下来的课程和实验中,我们将运用这些工具来设计性能更好的控制器,并预测复杂系统的行为。
008:离散线性时不变系统简介与表示方法

在本节课中,我们将学习一种新的物理世界建模方法——离散线性时不变系统。我们将了解为何需要学习它,并掌握描述这类系统的几种不同表示方法。
课程概述
上一节我们介绍了面向对象编程和状态机作为物理世界的建模方法。本节中,我们将探讨一种新的建模方式:离散线性时不变系统。你可能会问,既然状态机似乎能模拟一切,为何还需要学习新方法?简单答案是,新方法能帮助我们预测未来。但要理解如何预测未来,还需等待后续课程。本节课我们先介绍离散线性时不变系统,并学习其不同表示方法,以便你能识别、操作并与他人交流。
为何需要离散线性时不变系统?
状态机虽然功能强大,能通过记录所有先前的输入、输出和状态来模拟任何系统随时间的演化,但它存在一个根本问题:与大多数编程一样,它会遇到“停机问题”。当系统复杂度达到一定程度时,若不实际运行程序,就无法确定其行为。这对于需要发现新事物的研究是好事,但若想向他人做出承诺或预测未来,就需要一种能抽象掉复杂性、并基于特定特征预测其行为的方法。
因此,若想设计一个实现特定功能的控制系统,或分析现有系统(例如,预测一个发电厂五年后是否会因某种原因爆炸),就需要理解特定系统类别及其产生的长期行为。这就是我们学习离散线性时不变系统的原因。在6.01课程中,我们专注于离散线性时不变系统,因为它相对简单。掌握了处理这类系统的方法后,你就能运用这些技能去解决更复杂的反馈或控制问题。
线性时不变系统简要回顾
在讨论线性时不变系统时,输入和输出都是实数,我们不涉及复数域。
如果用状态机来建模一个线性时不变系统,该状态机将依赖于固定数量的先前输入、输出或状态。在达到代表当前状态的固定数据结构之前,你可以为起始状态或固定数量的先前状态留出一些余地。但对于一个线性时不变系统,从长期来看,确定其状态所需的信息量总是有限且固定的。
在函数表达式方面,你可能见过与线性时不变系统相关的形式。这意味着你可以将线性时不变系统表示为现有函数的加法或标量乘法。
以上是线性时不变系统的特性。我们将专注于离散线性时不变系统,原因有二:一是我们可以用离散状态机为其建模;二是在计算机中表示连续函数较为困难,而一旦有了数字抽象,就无需担心处理真正的连续函数。
系统的不同表示方法
现在你知道了我们要讨论什么,那么如何与他人交流呢?以下是描述离散线性时不变系统时可能遇到的几种表示方法。
差分方程
其中一种表示方法称为差分方程,你可能在数学课中遇到过。它表示从一个信号 Y 中在某个时间步(通常是 n)进行采样。在写出代表整个系统的差分方程时,通常会在左边看到 Y[n],右边则是代表系统功能组件的部分,它决定了每个时间步的输出。
以下是一个累加器的具体例子:
Y[n] = X[n] + Y[n-1]
在这个特定情况下,我们讨论的是一个累加器。这意味着在每个时间步,输出由当前输入加上前一个输出决定。所以,在每个时间步,我们取输入值以及之前的输出值,将它们相加。
如果有人用文字向你描述一个差分方程,你很可能能将其转化为这样的等式。请注意,尽管这些样本有关联的变量,但它们仍然是特定的样本。你真正感兴趣的甚至可能不是样本本身,而是样本之间的相对采样关系,以及这种关系如何影响给定时间步的输出。
算子方程
如果你想讨论整个信号,可以使用算子方程。在研究控制理论或反馈时,你很可能会看到这些方程。
我们不再表示信号 Y 在给定时间 n 的样本,而是讨论整个信号 Y。同样,我们讨论信号 X,而不是 X 在给定时间 n 的值。
之前提到,这个方程最重要的部分是这两个信号之间存在差异关系,并且与采样时间有关。当我们想表示这种相对延迟时,我们使用 R 来表示。特别要注意,R 的指数代表了与特定信号样本相关的延迟量。
例如,如果我想表示 Y[n-2],我可以通过改变 R 的指数来反映算子方程中的这一变化。因为我们处理的是线性时不变系统,如果我们想将其缩放两倍,也等同于将另一边缩放两倍。
框图
如果我想讨论这些信号之间关系的物理表现,或者想使用类似电路图的东西来讨论将要进行的信号处理操作呢?这时就需要框图。
框图非常直观,意味着每个人都能理解,因为你只需要追踪箭头。框图表示你将通过增益、延迟器和加法器的组合,来直观地表示输出信号与任何输入信号之间的关系。
下图我实际上画了一个累加器,并用一个方框将其框起来,这部分内容两分钟后会变得相关,但现在你可以忽略它。我的输入信号通常在左侧,框图中增益、延迟器和加法器的排列表示了输入信号与输出信号之间的关系。然后,我的输出信号在右侧。大多数人会用箭头表示流向,部分原因是为了便于阅读,但通常你只需要箭头来指示从哪里采样以及输出是什么。
在这个例子中,为了得到 Y,我可以从感兴趣的信号开始,沿着框图反向推导,找出我真正感兴趣的值。在这个特定情况下,Y 是 X 和 RY 的线性组合。
如果我想在这里放一个增益系数2,我一直在谈论增益、延迟器和加法器,但这个图还没有包含增益。让我展示如何包含增益。
增益通常用箭头内的增益值表示,看起来像原理图中的运算放大器(我们将在后续电路课程中讲到)。箭头不一定必须指向流向,但如果不指向流向,人们可能会觉得奇怪。另外我想指出的是,如果你在这里看到一个减号,意味着 X - 2RY,这等同于在你的增益上放一个负值。所以现在,我们回到 X + 2RY。
框图的抽象价值
此时你可能会问,框图除了用于PPT演示或与朋友争论发生了什么之外,还有什么用?我认为它们对于抽象非常有用。我可以在这个框图周围画一个方框,将其抽象为一个函数。并说,如果我把某个东西放进这个方框,并想得到输出,而这里面发生的动作与此原理图相同,那么我就能找到一个方程来实际表示那个操作。
我这样做的方法是,取我的算子方程,并专门求解 Y/X。在这个带系数2的例子中,Y/X 将是 1 / (1 - 2R)。
请注意,如果 H 等于表达式 Y/X,并且我将 H 乘以 X,我就会得到 Y。
总结
本节课我们一起学习了学习离散线性时不变系统的动机,以及在与他人交流离散线性时不变系统时希望使用的不同表示方法:差分方程、算子方程和框图。至此,我们也可以开始讨论如何达到能够预测未来的阶段,在接下来的视频中,我将实际开始讨论极点等相关概念。
009:系统等价性与未来预测 🧩


在本节课中,我们将学习如何通过系统等价性来简化和分析复杂的离散线性时不变系统。我们将探讨如何将复杂的系统框图转化为数学表达式,并利用这些表达式预测系统的长期行为。
上一节我们介绍了系统函数的概念,本节中我们来看看如何将多个系统组合起来进行分析。
处理复杂系统的一种简单方法是识别并标记每个新信号点。然后,从最终输出开始,反向求解出与这些信号相关的表达式。
以下是一个具体例子:
- Y 是 Y2 与 Y3 的和。
- Y2 等于 Y1 乘以系统函数 H2。
- Y1 等于输入 X 乘以系统函数 H1。
- Y3 等于输入 X 乘以系统函数 H3。
通过代入上述等式并提取公因子 X,我们可以得到输出 Y 的表达式。要得到整个复合系统的系统函数 H,只需计算 Y/X。
由此,我们可以得出两个重要的等价关系:
- 级联等价:两个系统函数 H1 和 H2 级联(串联),等价于将它们相乘。
H_cascade = H1 * H2
- 并联等价:两个系统函数 H1 和 H2 并联(它们的输出相加),等价于将它们相加。
H_parallel = H1 + H2
这些关系可以扩展到任意复杂度的系统。利用这些等价性,我们可以重新排列和简化系统框图,类似于数字逻辑电路中的“气泡推动”技术。
接下来,我们探讨一种更重要的等价关系:反馈等价。
考虑一个标准的累加器(反馈系统)。如果我们想将其表示为一个前馈系统,就需要考虑所有过去时刻的输入对当前输出的影响。
这会导致一个无限项的求和:y[n] = x[n] + x[n-1] + x[n-2] + ...
虽然难以在纸上直接画出无限项,但我们可以利用几何序列的知识来处理。当我们求解反馈系统的系统函数时,实际上就得到了这个无限求和的封闭形式表达式。
在本课程中,我们将重点观察系统的单位采样响应。这意味着我们只在时间0输入一个值为1的信号,然后观察输出。
选择单位采样响应的原因有两个:
- 它是分析离散线性时不变系统长期行为的最简单方式。
- 一旦得到它,我们就可以用它来预测系统对更复杂输入信号(如阶跃响应)的长期行为。
以累加器为例,如果在时间0输入1,其输出将永远保持为1。这个特性反映在其系统函数的几何序列系数中。
通过观察系统函数中关键系数 R 的性质,我们可以判断系统长期行为是发散、收敛还是保持稳定。利用对 P0(系统函数的极点)的分析,我们可以对系统的长期行为做出有效预测。
本节课中我们一起学习了系统等价性的概念,包括级联、并联和反馈等价。我们了解了如何通过分析系统函数和单位采样响应,来预测离散线性时不变系统的长期行为。下一节,我们将更系统地分类这些行为,并学习如何处理如二阶系统等更复杂的情况。
010:反馈系统与极点分析 🧠

以下内容基于知识共享许可协议提供。您的支持将帮助麻省理工学院开放式课程网站继续免费提供高质量教育资源。如需捐款或查看来自数百门麻省理工学院课程的更多材料,请访问 MIT OpenCourseWare 网站。



概述
在本节课中,我们将学习如何运用信号与系统的方法来分析反馈系统。我们将探讨反馈的普遍性,学习如何使用算子方法简化系统分析,并最终引入“极点”这一核心概念来定量表征系统的性能。通过极点,我们可以预测系统对瞬态信号的响应是收敛、发散还是振荡。
反馈无处不在
反馈在我们的生活中无处不在。例如,当你驾驶汽车并试图保持在车道中央时,你就在进行反馈控制:你不断比较当前位置与期望位置,并据此做出微小调整。


另一个简单例子是房屋内的恒温器。当温度下降时,恒温器会启动加热系统进行补偿,以维持设定温度。
生物学中也充满了精妙的反馈调节。例如,人体通过胰岛素等激素的反馈系统,将血糖浓度精确维持在每升2到10毫摩尔的狭窄范围内。考虑到饮食和运动的间歇性,这种调节的精度令人惊叹。
即使是拧灯泡这样简单的任务,也依赖于来自触觉、本体感觉和肌肉应力传感器的反馈,以防止用力过猛而损坏灯泡。

在信号与系统框架中分析反馈
我们希望将反馈系统纳入信号与系统的框架中进行分析。以上周设计实验室中的“寻墙”问题为例,该系统试图让机器人与墙壁保持固定距离。我们将其建模为一个由控制器、被控对象和传感器三部分组成的反馈系统,并用差分方程描述各部分。
为了让大家跟上思路,这里有一个关于“寻墙”问题方程组的问题。


以下是描述“寻墙”问题的方程:
v[n] = K * e[n]d_o[n] = d_o[n-1] + T * v[n-1]d_s[n] = p * d_o[n]e[n] = d_i[n] - d_s[n]

其中 T 和 K 是已知参数。这个方程组有多少个方程和未知数?
如果采用逐样本的代数方程视角,我们需要为每个 n 值列出方程,因此会得到无限多个方程和无限多个未知数。这种方法非常复杂。

算子方法:简化复杂性
相比之下,如果我们采用算子方法,将整个信号视为一个整体,情况会大大简化。


在算子视角下,已知量是参数 K、T 以及输入信号 D_i。未知量是三个完整的信号:速度 V、机器人输出 D_o 和传感器信号 D_s。这样,我们就得到了三个方程和三个未知数。

算子方法的核心价值在于降低复杂性。它让我们能够专注于信号之间的关系,这种关系现在由一个称为系统函数的算子 H 来表示。我们通常将 H 视为 R 的多项式之比,即 H = Y / X。
计算系统函数
现在,让我们对“寻墙”系统应用算子方法。思考以算子形式表示的各组件方程,并计算出该系统的系统函数 H,即用 R 的多项式之比表示。

通过代数运算(将算子视为代数符号处理),我们可以求解出 H。从第二个方程开始,逐步代入,最终得到 D_o 和 D_i 的关系,从而解出比值。






计算过程如下:
- 由
v[n] = K * e[n]得V = K * E - 由
e[n] = d_i[n] - d_s[n]且d_s[n] = p * d_o[n]得E = D_i - p * D_o - 由
d_o[n] = d_o[n-1] + T * v[n-1]得D_o = R * D_o + T * R * V(这里R是延迟算子) - 代入
V和E,最终得到H = D_o / D_i = (K T R) / (1 - R + p K T R^2)


因此,正确答案是选项三。这表明我们可以像处理代数一样处理算子,从而带来巨大的简化。

从简单系统入手:一阶反馈系统
我们最终希望理解系统函数 H 与系统实际行为(如单调、振荡等)之间的关系。为了分析更复杂的系统,我们采用自底向上的方法:先从最简单的行为(原语)开始,然后组合它们以理解更复杂的事物。

考虑一个非常简单的反馈系统,它包含一个延迟环节和一个增益为 P 的放大器。

我们假设系统初始处于静止状态(所有延迟框的输出为零),并输入一个单位采样信号 δ[n]。通过逐样本推导或算子分析,可以求出输出 y[n]。


逐样本推导:
n=0: 输入为1,延迟输出为0,所以y[0] = 1n=1: 之前的输出1经过延迟和增益P变为P,当前输入为0,所以y[1] = Pn=2: 之前的输出P经过延迟和增益P变为P^2,所以y[2] = P^2- ...
- 规律:
y[n] = P^n(当n>=0)


算子分析:
系统方程可写为:Y = X + P R Y
解得系统函数:H = Y / X = 1 / (1 - P R)
将 1/(1-PR) 展开为幂级数:1 + P R + P^2 R^2 + P^3 R^3 + ...
这对应于输出信号:y[n] = P^n (当 n>=0),与逐样本结果一致。




框图与反馈的本质
从框图的信号流角度,可以可视化这个结果。系统函数展开式的每一项对应一条从输入到输出的路径:
1对应直接路径(无延迟)。P R对应经过一次反馈循环的路径(一次延迟和一次乘以P)。P^2 R^2对应经过两次反馈循环的路径,以此类推。


这个流程图揭示了一个关键点:循环流路径的存在。反馈意味着信号可以回流,形成循环。这使得即使像单位采样这样的瞬态输入,也能产生持续不断的输出。这是反馈系统的一个基本特性。


系统可以分为两类:
- 前馈系统:信号流图中没有循环路径。瞬态输入产生瞬态输出。
- 反馈系统:信号流图中存在循环路径。瞬态输入可能产生持续(甚至无限)的输出。







极点的概念
对于只有一个反馈环路的简单系统,其行为完全由环路增益 P 决定。每次循环,信号幅度乘以 P。这个 P 值我们称之为系统的极点。


极点决定了系统对单位采样信号的响应模式:
|P| > 1:响应幅度发散(增长)。|P| < 1:响应幅度收敛(衰减至零)。P > 0:响应符号不变(单调)。P < 0:响应符号交替(振荡)。


因此,对于一个单极点系统,所有可能的行为都包含在下图中。这是一个非常强大的结论:我们完全刻画了这个简单系统的所有动态特性。


扩展到高阶系统:分解与部分分式
上一节我们分析了一阶系统,那么更复杂的系统呢?考虑一个二阶系统,其差分方程为:y[n] = 1.6 y[n-1] - 0.63 y[n-2] + x[n]。
如果计算其单位采样响应,会发现它先增长后衰减,并不像简单的几何序列。这是否意味着先前的理论失效了?
并非如此。关键在于,我们可以利用算子与多项式同构这一强大工具。对于上述系统,其系统函数为:
H = 1 / (1 - 1.6 R + 0.63 R^2)
根据代数中的因式定理,我们可以对分母进行因式分解:
H = 1 / ((1 - 0.9 R)(1 - 0.7 R))
这意味着,这个复杂的二阶系统可以看作是两个一阶系统的级联。更进一步,我们可以使用部分分式展开法将其分解为两个简单分式的和:
H = 4.5/(1 - 0.9 R) - 3.5/(1 - 0.7 R)




这个结果的系统意义非常深刻:该复杂系统的单位采样响应,可以表示为两个几何序列(分别以0.9和0.7为底)的加权和。尽管响应看起来复杂,但其本质仍是简单几何序列的组合。





一般性结论:极点与模态
这个结论可以推广到一般情况。任何由加法器、增益器和延迟器构成的线性常系数差分方程系统,其系统函数都可以表示为 R 的两个多项式之比:
H = (多项式_B(R)) / (多项式_A(R))
通过因式分解和部分分式展开,系统的单位采样响应总可以写成若干几何序列之和的形式:
y[n] = C1 * p1^n + C2 * p2^n + ... + Ck * pk^n
其中 p1, p2, ..., pk 是分母多项式 多项式_A(R) 的根,也就是系统的极点。每个极点 p_i 对应的几何序列 p_i^n 称为一个模态。
因此,知道了一个系统的极点,我们就知道了其响应模态的基本形状(增长/衰减,振荡频率)。常数 C_i 可以通过初始条件确定。
求极点的一个实用技巧是进行变量代换 R = 1/z,将系统函数转化为 z 的多项式之比,然后求分母多项式的根。这些根就是极点。

复极点与振荡
在代数中,多项式的根可能是复数。在系统分析中,这意味着极点可以是复数值。例如,系统函数 H = 1 / (1 - R + R^2) 的极点就是复数:0.5 ± j*(√3/2)。
这会产生复数值的模态 p^n。对于实系数系统,如果有一个复极点,则其共轭复数也必然是极点。这两个共轭复极点对应的模态会相互“合谋”,使得总输出的虚部抵消,最终产生实数的正弦振荡输出。

将复极点 p 用极坐标表示非常有用:p = r * e^(jω)。那么模态 p^n = r^n * e^(jωn)。这里:
r是极点的幅度,决定模态的衰减(r<1)或增长(r>1)。ω是极点的角度,决定模态的振荡频率(周期T = 2π/ω)。
这种表示法将幅度和角度的影响分离开,便于分析。例如,观察一个衰减振荡的响应曲线,我们可以估算出 r 略小于1,并根据振荡周期估算出 ω。



应用实例:斐波那契数列
让我们用极点分析法重新审视著名的斐波那契数列。斐波那契数列的差分方程为:y[n] = y[n-1] + y[n-2] + x[n],其中 x[n] 是单位采样信号,用于提供初始条件 y[0]=1。
按照标准步骤:
- 写出算子方程:
Y = R Y + R^2 Y + X - 得到系统函数:
H = Y / X = 1 / (1 - R - R^2) - 代换
R=1/z:H = z^2 / (z^2 - z - 1) - 求分母多项式的根(极点):
z = (1 ± √5)/2


这两个极点是:
p1 = (1 + √5)/2 ≈ 1.618(黄金比例)p2 = (1 - √5)/2 ≈ -0.618
因此,斐波那契数列的通项公式可以写成:y[n] = C1 * p1^n + C2 * p2^n。通过初始条件可以解出 C1 和 C2,最终得到我们熟知的闭式解。这个例子展示了极点分析法如何为经典问题提供一个全新而强大的视角。






总结
本节课我们一起深入学习了信号与系统框架下的反馈分析。
- 我们认识到反馈的普遍性和重要性。
- 我们引入了算子方法,将系统视为输入到输出的变换
H,这极大地简化了复杂系统的描述和分析。 - 我们从最简单的单极点反馈系统入手,引入了极点的概念,并看到极点完全决定了系统的响应模式(收敛/发散,单调/振荡)。
- 我们将分析推广到高阶系统,利用因式分解和部分分式展开,证明了任何此类系统的响应都可以分解为多个简单几何序列(模态)的加权和,每个模态对应一个极点。
- 我们探讨了复极点的情况,并解释了它们如何产生实数的正弦振荡,以及如何用极坐标(幅度
r和角度ω)来直观理解振荡的衰减和频率。 - 最后,我们以斐波那契数列为例,展示了极点分析法如何为传统问题提供简洁而深刻的洞察。



通过引入极点这一核心概念,我们获得了一种定量分析和预测线性反馈系统行为的强大工具。
011:极点分析 🧮


在本节课中,我们将要学习系统分析中的一个核心概念——极点。我们将探讨如何求解极点,以及如何利用极点的信息来预测系统的长期行为。
上一节我们介绍了线性时不变系统的表示与操作,特别是前馈系统与反馈系统之间的关系。本节中,我们将基于这种关系,深入探讨如何通过极点来分析系统的长期响应。
系统回顾:前馈与反馈
首先,快速回顾一下。上次我们讨论了前馈系统。需要强调的是,如果你给前馈系统一个瞬态输入,你只会得到一个瞬态响应。前馈系统无法将信息保留超过你输入信息的时间步长。
反馈系统则不同,它能对瞬态输入产生持续响应。因为反馈系统的工作原理,你输入的信息可以反映在不止一个时间步长上,具体取决于系统中延迟单元的数量。
上次我们还画出了前馈系统与反馈系统之间的关系。实际上,你可以将一个反馈系统,描述为一个接收无限个输入样本,并通过一个包含无限延迟的求和过程的前馈系统。这种转换可以用一个几何序列来表示。
该几何序列的基底,正是我们将用来预测未来的对象,也就是我们所说的“极点”。
什么是极点?
在确定系统的长期行为时,可能会涉及多个几何序列。如果只有一个,事情就很简单:找到你的系统函数,然后找出表达式中与 p0 相关的值。在这个表达式中,外部可能存在一个标量系数。由于我们处理的是线性时不变系统,这个标量会影响系统的初始响应,但对于长期行为而言,其影响不大,目前无需过分担心。
对于二阶或更高阶的系统,求解这些表达式最终需要进行部分分式分解。你可以这样做,部分原因是为了在讨论瞬态输入等情况的短期响应时,能够提取出那些标量系数。不过在本课程中,我们主要关注长期响应,因此不会过多涉及这些。
我们可以通过引入一个称为 Z 的表达式来绕过处理高阶系统而不进行部分分式分解的问题,Z 实际上代表了 R 的负幂。然后求解该方程的根。如果你将 Z 代入分母中的 1/R,然后求解该表达式的根,你会得到相同的结果,最终得到 p0。
如何利用极点分析长期行为?
现在我们知道如何找到一个或多个极点了。接下来,我们如何确定系统的长期行为呢?
以下是分析步骤:
首先,查看你求解出的所有极点的幅度,并选择幅度最大的极点。如果存在多个幅度相同的极点,则需要同时考虑它们。如果遇到此处未提及的复杂情况,不必过于担心,或可咨询教授或助教。
幅度分析:
- 若主导极点的幅度大于1:系统将长期发散。这很直观:如果每个时间步,单位采样响应都乘以一个大于1的值,那么它就会增长。主导极点幅度超过1的程度决定了增长率,也决定了系统响应“包络线”膨胀的速度。
- 若主导极点的幅度小于1:在单位采样输入或δ函数的作用下,系统将收敛。这同样直观:如果你持续用小于1的标量乘以系统中的值,最终将收敛到零。
- 若主导极点的幅度等于1:系统既不会收敛也不会发散。在这种情况下,之前提到的外部标量系数可能会变得相关。我们不会过多关注这种情况,但了解主导极点幅度为一时会发生什么是有益的。
极点的角度与振荡行为
当我们观察系统的主导极点时,另一个感兴趣的特征是,如果我们将主导极点用极坐标形式表示,那么与该极点相关联的角度是多少?如果你在复平面上用极坐标绘制该极点:
角度分析:
- 若极点位于实轴上(无虚部):你会看到两种情况之一。
- 若主导极点为实数且为正:你会看到绝对非交替的行为。系统响应保持在X轴的一侧,根据输入收敛、发散或保持恒定,不会出现任何交替或振荡行为。
- 若主导极点为实数且为负:这意味着它仍在实轴上,但其值为负。在极坐标中,它将与角度
π相关联。这会导致交替行为,即你的单位采样响应在每个时间步都会跨越坐标轴跳变,这等效于周期为2。
- 若该角度既不是0也不是π:此时你将讨论振荡行为或正弦响应,其“包络线”决定了函数的边界。
为了找到周期,即你的单位采样响应完成一个周期所需的时间,你需要取主导极点相关联的角度,然后用 2π 除以它。这是计算周期的一般公式:
周期 = 2π / 极点角度
总结
本节课中,我们一起学习了系统分析中的极点概念。我们回顾了前馈与反馈系统的区别,了解了极点如何从系统函数中求解,并掌握了如何通过分析主导极点的幅度来判断系统长期是发散、收敛还是保持稳定。同时,我们也学习了如何通过分析主导极点的角度来预测系统响应是单调、交替还是振荡,并能计算振荡的周期。

这些是利用极点信息预测系统未来行为的基础。下次课程,我将通过解决一个具体的极点问题,向你展示长期响应是什么样的,并讨论一些本节课略过的关于极点的细节。到那时,你应该能够自己求解并分析极点了。
012:极点分析

在本节课中,我们将学习如何分析系统的极点,并利用极点预测系统的长期行为。我们将介绍极点的几个重要概念,并通过具体问题演示如何求解极点以及如何根据极点判断系统响应。

上一节我们介绍了极点,特别是如何从处理前馈和反馈系统以及由此产生的几何序列,转向使用该几何序列的基数来预测系统的长期行为。
当我们在求解极点且只关心长期行为时,最简单的方法之一是求解 Z 的根,其中 Z 是系统函数分母中 1/R 的替代变量。
完成这一步后,我们就得到了一个极点列表。我们需要从这个列表中选择主导极点,即具有最大幅值的极点。然后,根据该极点的幅值和周期,我们可以确定系统长期行为的表现。
本节中,我们将介绍关于极点的一些重要注意事项。如果你对这类信息或一般的反馈与控制感兴趣,强烈推荐学习课程 6.003。但作为 6.01 的后续,以下是你至少应该了解的一些信息。
此外,我们将通过几个极点问题来练习,帮助你更熟悉求解系统函数的极点,或者观察系统函数的单位样本响应并绘制对应的极点图。
首先,我们来讨论零极点相消。这是什么意思呢?如果分子和分母具有相同的代数项,那么你将同时得到一个零点和一个极点。如果这个零点和极点具有相同的值,你可能会想将它们约去。
除非零点和极点都等于零,否则不要这样做。原因在于,当你实现一个真实系统时,零点和极点被实现的精度很难高到足以让两者完全相消。唯一的例外是当极点和零点都等于零时,即 (R-0)/(R-0),你可以放心地将其转换为 1。在几乎所有其他情况下,不要进行因式相消。
接下来,我们谈谈重根。如果你有一个重根,就会得到重复的极点。在讨论如何将这些极点的单位响应相加时,这会变得有些棘手。但系统的长期行为看起来是相同的。因此,如果这两个极点都是主导极点,那么它们相同的特性将决定你的长期行为。如果它们不是主导极点,那么主导极点将决定长期行为。
最后要提到的是叠加原理。到目前为止,我们只讨论了系统函数的单位样本响应,以及如何使用极点来确定系统的长期行为。我们可以观察系统对更复杂输入(而不仅仅是单位样本响应或δ函数)的响应。事实上,我们可能最终会研究阶跃函数。
要从讨论单位样本响应过渡到讨论任何其他类型的响应,你需要知道我们处理的仍然是线性时不变系统。这意味着,如果你对输入求和后再应用系统函数,其结果与先对每个输入应用系统函数再将输出求和的结果相同。用公式表示,对于系统函数 H,有:
H(Σ x[n]) = Σ H(x[n])
如果 H 是系统函数,同样的性质也适用。
现在,让我们通过一个具体问题来练习极点求解。
这里有一个二阶系统设置,我们有两个 R 的阶次,并且存在反馈。我们可以求解出用 x 表示 y 的表达式。
让我们现在就开始做。W 是求和的结果,或者是 x 与 y 的延迟信号(乘以 1.6)以及 y 的延迟信号的延迟(乘以 -0.163)的线性组合。这是我的第一个阶次。为了保持一致,这是我的第二个阶次。
首先求解系统函数。如果你感到困惑,我建议你从这里开始进行代数运算,直到得到这个表达式。你应该得到这个分数形式。
我们的第二步是求解 Z 的根。记住,Z 等于系统函数分母中的 1/R。在这种情况下,我们将处理:
分母: 1 - 1.6*(1/Z) + 0.163*(1/Z^2)
我所做的只是将每个 R 的阶次替换为 1/Z,然后乘开,这样我就不再处理分母中的 Z 了,实际上只是在处理分子中的所有项。如果我将其重新展开,会得到这个表达式。我的极点将是 0.7 和 0.9。
根据我的极点,单位样本响应的长期特性是什么?
我要做的第一件事是在找到的极点中寻找主导极点。在这种情况下,我甚至不需要担心在复平面上计算极点离原点的距离,只需要担心实轴上极点的幅值。0.9 是我的主导极点,因为它是最大的极点。0.9 小于 1,所以我最终会得到收敛。最终,我的系统将收敛到 0。
我的系统的另一个有趣特性是它的周期是什么,以及这与我的函数外观有何关系。在这种情况下,我们只处理正实轴,因此在复平面上绘制该极点相关的角度为零,所以我们的系统没有周期。这意味着我们的系统将单调收敛。
现在,让我们看一些单位样本响应图,然后绘制出产生这些响应的极点。
在复平面上的单位圆内,让我们先看第一张图。
我注意到的第一点是,与前面的例子一样,这是单调收敛。我们趋向于零,并且没有围绕 X 轴交替或振荡。
所以我知道我将在这条线上、单位圆内的某处工作,因为在单位圆边缘,离原点的距离等于一。如果让我猜,我会看这里的距离,并与下一个时间步的距离进行比较。我意识到这是黑板,比例并不完全准确,但为了演示的目的,我想说这个时间步的信号是前一个时间步信号的 0.5 倍。同样,在下一个时间步,我想说这个信号是前一个时间步信号的 0.5 倍,依此类推。因此,我将把我的极点绘制在这里。
让我们看第二张图。我画了这些轮子,是为了表示单位样本响应超出了我为这个图留出的空间边界,所以请假设这些值比我画的大得多。
我注意到的第一点是,我不仅在以一种似乎没有改变的方式增加(我们最终会发散),而且我实际上在围绕 X 轴交替。特别是,如果我要称此为振荡,我会说它是周期为 2 的振荡。
这意味着我正在处理一个负实极点。发散的事实意味着我正在处理一个大于一或幅值大于一的负实极点。如果你必须猜测,我会看这个时间步相关的距离,并与前一个时间步的距离进行比较。如果你问我,我会说这大约是前一个时间步值的 1.3 倍。同样,如果我看下一个时间步,我会说这个增长大约是前一个值的 30%。我甚至不打算尝试计算那个,但我想说的是,如果你在处理一阶系统,你可以使用前后时间步的比较来尝试确定主导极点的幅值。如果你在处理二阶系统,那么你可能会看到一些非常有趣的初始化效应,你应该问我们中的一个人。但对于这个例子,我们将把极点放在这里。
这是我要讨论的最后一张图。我注意到的第一点是,它似乎没有发散,但也没有真正收敛。如果是这种情况,那么我将把它放在单位圆上。我注意到的第二点是,它既不是单调的,也不是交替的。它是在振荡。
因此,为了确定我将为我的单位样本响应分配什么角度,我将数出完成一个完整周期所需的时间步数,然后从那里计算出需要什么角度才能确定该长度的周期。
让我从这里开始。我数一、二、三、四、五、六、七、八,完成一次完整的振荡。这意味着我的周期是 8。如果我必须用 2π 除以一个特定的角度来得到 8,那么我想除以 π/4。所以此时,我处理的幅值大约为 1,我希望这个角度大约是 π/4。
本节课中,我们一起学习了如何求解系统的极点,包括零极点相消的注意事项、重根的影响以及叠加原理的应用。我们通过具体示例练习了从系统函数求解极点,并根据极点在复平面上的位置(幅值和角度)解读了系统单位样本响应的长期行为(如收敛、发散、单调性、振荡周期等)。这些技能是分析和设计线性时不变系统的基础。下次课,我们将开始讨论电路。
013:控制系统设计

在本节课中,我们将学习如何设计控制系统。这将完成我们对信号与系统的讨论。
概述
首先,让我们简要回顾一下我们目前所处的位置。这也有助于你为今晚的考试理清思路。
我们研究了离散时间系统的多种表示方法。
- 差分方程是最简单、最简洁的数学表示方法。但它没有明确指出谁是输入、谁是输出,以及从输入到输出的所有可能路径。
- 框图是图形化的表示,可以清晰地展示信号流路径,例如是否存在循环路径。但它不如差分方程简洁。
- 算子表示法同样简洁,并且包含了额外信息,因为算子有隐含的参数,可以区分输入和输出。它结合了差分方程和框图的优点,提供了包含完整信号流路径信息的简洁表示。
此外,我们可以使用多项式数学来分析算子,这引出了系统函数的概念。系统函数是一个很好的抽象,让我们可以将整个系统视为一个单一的算子。
我们利用所有这些表示方法来理解反馈。从框图中可以很容易看出,只要有反馈,就会存在循环。循环意味着即使瞬态输入也能产生持续的输出,这种行为是我们希望理解的。
我们通过将复杂系统的响应分解为基于极点的多个相加分量来表征这种行为。每个极点对应的响应称为模态。
上一节我们介绍了如何通过极点分解系统响应。本节中,我们将利用这个框架来思考设计问题。
设计优化:如何选择控制器参数
回顾我们在实验四中的内容,我们研究了如何编程让机器人接近墙壁。我们发现,根据系统设置的不同,会得到非常不同的性能表现。我们希望有一种方法,可以在不实际构建系统的情况下设计性能。
在实验四中,构建系统并测试其行为并不困难。但一般来说,如果你要设计一个像波音777这样具有多个极点的复杂系统,你肯定不希望测试所有的不良配置。因此,我们希望有能力从分析的角度理解这类问题。
从差分方程到框图



使用不同的表示方法,你可以生成非常简洁的表示。就像你们在实验四中所做的那样,可以从差分方程开始。
以下是一个简单控制器的差分方程示例:
y[n] = y[n-1] + K * (d[n] - y[n-1])
其中 y[n] 是输出(例如机器人的位置),d[n] 是期望输入,K 是增益参数。
这个差分方程原则上包含了一切信息,但不易于分析。如果将其转化为框图,情况会好一些。

在框图中,你可以看到这个方程组实际上包含两个反馈回路(两个循环)。这两个循环都可能对瞬态信号产生持续的响应,从而可能降低性能。例如,如果瞬态响应持续很长时间,或者小的扰动随时间增大,那将是非常糟糕的。
我们希望理解这个由差分方程描述的简单控制器何时会出现这种情况。
分析内环:累加器
分析这个问题最简单的方法是首先关注内环。我们需要找出那个被称为累加器的方框(它在其输出端累积所有曾经输入的总和)的函数表示。
我们使用多项式数学来推导。从框图中,我们可以看出信号 Y 可以通过将算子 R 应用于 W 得到:Y = R * W。同时,W 是 X 和 Y 的和:W = X + Y。
结合这两个方程,我们得到一个只涉及 X 和 Y 的表达式,可以求解 Y/X 的比值:
Y = R * (X + Y)
Y = R*X + R*Y
Y - R*Y = R*X
Y*(1 - R) = R*X
Y/X = R / (1 - R)
因此,累加器的函数表示是 R / (1 - R)。
这个结果在控制系统设计中经常出现,我们称之为布莱克公式。它对于避免繁琐的代数步骤直接得到答案非常有用。
为了确保大家理解,我们来看一个练习。

练习:推导布莱克公式
对于下图所示的反馈系统,其中前向路径增益为 F,反馈路径增益为 G,求从输入 x 到输出 y 的系统函数形式。
(图示:一个标准负反馈系统框图,前向路径为 F,反馈路径为 G,求和点为 x - G*y 输出到 F)

以下是选项:
F / (1 - F*G)F / (1 + F*G)1 / (1 - F*G)G / (1 - F*G)- 以上都不是
答案分析
通过简单代数推导:设求和点输出为 e = x - G*y,则 y = F * e = F*(x - G*y)。整理得 y = F*x - F*G*y,进而 y + F*G*y = F*x,所以 y*(1 + F*G) = F*x,最终 y/x = F / (1 + F*G)。因此正确答案是 2。
设计师们会这样理解这个形式:F 是前向增益(从输入直接到输出的增益),F*G 是环路增益(绕环路一周的增益乘积)。闭环系统的响应就是前向增益 F 除以 1 加上环路增益 F*G。

通常,我们会看到这种系统的两种表示形式:一种是求和点为加法(如之前的累加器例子),另一种是求和点为减法(如本练习)。减法形式在控制问题中更常见,因为我们希望控制器驱动误差信号趋于零。这两种形式本质相同,只是差一个负号,可以认为是将 -G 代入加法形式的公式中。
应用布莱克公式分析完整系统

现在,我们使用这个思想来分析完整的机器人控制器系统。首先,用等效系统 R/(1-R) 替换内环(累加器)部分。
然后,对外层反馈环路再次应用布莱克公式。此时,前向增益是 K * (-T) * [R/(1-R)],环路增益(因为下方的反馈路径增益为1)也是 K * (-T) * [R/(1-R)]。
应用公式 前向增益 / (1 + 环路增益),我们得到:
H = [ -K T R / (1-R) ] / [ 1 + (-K T R / (1-R)) ]
简化后得到:
H = (-K T R) / (1 - R + K T R) = (-K T R) / (1 - (1 - K T) R)
这个结果有两点需要注意:
- 尽管直接代入会得到一个分式除以分式的形式,但它简化为一个单一的比值。对于仅由加法器、增益和延迟构成的系统,其系统函数总是可以表示为
R的多项式之比。 - 这种表示形式有助于我们直观理解系统行为。我们可以将其解释为一个更简单的系统。
具体来说,这个系统函数 H = (-K T R) / (1 - (1 - K T) R) 可以看作三个部分的级联:一个延迟 R、一个增益 -K T,以及一个极点位于 p = 1 - K T 的一阶系统。极点 p 是 R 的系数。

例如,如果选择 K T = -0.2,那么极点 p = 1 - (-0.2) = 1.2?等等,这里需要检查公式。根据分母 1 - (1 - K T) R,极点是 (1 - K T) 的倒数吗?实际上,将 R 替换为 1/z 更易求极点。H = (-K T / z) / (1 - (1 - K T)/z) = (-K T) / (z - (1 - K T))。所以极点位于 z = 1 - K T。
因此,若 K T = -0.2,则极点 p = 1 - (-0.2) = 1.2。这似乎不在单位圆内,会导致发散。让我们重新审视原差分方程和推导。典型的稳定控制器增益 K 应在一定范围内。假设 T 为正,为了使系统稳定,可能需要 K 为负且幅度适中。例如,若 K T = -0.2,则 p = 1.2,在单位圆外,不稳定。若 K T = 0.2,则 p = 0.8,在单位圆内,稳定。这说明我们需要仔细选择 K 的值。

与极点 p 相关的模态响应具有 p^n 的形式(几何序列)。通过将系统操作视为算子,我们可以识别并简化行为形式,从而直观地掌握如何最佳选择 K T 参数。
单位采样响应与阶跃响应
我们关注的行为并不总是单位采样响应。我们使用单位采样响应是因为它是最简单的信号:仅在 n=0 处值为1,其余处为0。

但在实践中,我们经常考虑阶跃响应。阶跃响应描述的是系统初始静止(输出为零)时,突然施加一个恒为1的信号后系统的输出。
在机器人例子中,如果机器人从靠近墙壁的位置(输出接近0)开始,而期望输入在其后方1米处,那么输入信号就是从 n=0 开始恒为1的阶跃信号。系统的响应就是阶跃响应。
阶跃响应通常在实验室中比单位采样响应更容易测量。因此,我们在做理论分析计算时使用单位采样响应,在实验室测量时使用阶跃响应。
如果这两种响应之间没有密切关系,整个理论就不会非常有用。下图说明了它们之间的关系。

(图示:系统 H 的阶跃响应等于将单位阶跃 U[n] 输入 H 的输出。而单位阶跃 U[n] 是单位采样 δ[n] 的累加。因此,H 的阶跃响应等于 H 的单位采样响应 h[n] 再经过一个累加器的输出。)

由于多项式的性质和框图遵循多项式规则,当系统都从静止开始时,我们可以交换级联系统的顺序。这意味着,如果你用单位采样激励 H 得到单位采样响应 h[n],那么将 h[n] 通过一个累加器就可以得到阶跃响应。
因此,单位采样响应和单位阶跃响应之间存在紧密联系:阶跃响应是单位采样响应的累加和。
这意味着,在前面的例子中,如果我们设置 K T = -0.2(假设修正后是稳定值)得到了某个单位采样响应,那么对应的阶跃响应就是对该单位采样响应进行累加求和。累加和会从0开始,逐渐逼近1,这符合直觉:如果机器人从墙壁开始,期望位置在后方1米,它会单调地接近1。

如果改变极点的值,例如将 K T 从 -0.2 改为 -0.8(假设对应稳定的极点),单位采样响应会变快,阶跃响应也会相应变快。
关键是,我们可能使用不同种类的性能指标(单位采样响应、阶跃响应等),但从单位采样响应可以了解所有响应的特性。这就是我们如此关注单位采样信号的原因:并非因为它在实验室最常用,而是因为它最容易计算,并能为我们提供在实验室想要测量的 insights。
系统行为分类与极点关系
对于这个非常简单的单极点系统,其行为只有几种可能的类别。

- 如果选择
K T在0到1之间(假设T>0),那么极点p = 1 - K T将在0到1之间。响应将是单调收敛的。因为单位采样响应始终为正,并衰减趋于零,这使得阶跃响应单调收敛至终值。 - 如果
K T在-1到0之间,那么极点p将在1到2之间?实际上,p = 1 - K T,若K T为负,p > 1,会导致发散。我们需要重新界定稳定范围。稳定要求极点幅度小于1,即|1 - K T| < 1。这等价于-1 < 1 - K T < 1=>-2 < -K T < 0=>0 < K T < 2。但通常K可能为负以实现负反馈。设K T = -k,则p = 1 + k,稳定要求|1+k| < 1=>-2 < k < 0=>-2 < K T < 0。这样更合理。- 若
-1 < K T < 0,则0 < p < 1,响应单调收敛。 - 若
-2 < K T < -1,则-1 < p < 0,响应交替收敛(符号交替,幅度衰减)。 - 若
K T < -2,则p < -1,响应交替发散。 - 若
K T > 0,则p > 1,响应单调发散。
- 若

因此,通过思考系统的极点,可以推断控制系统的特性。上面是针对单极点系统的图示。
练习:选择最佳增益

对于前述单极点系统,哪个 K T 值能使系统对单位采样信号的收敛速度最快?
选项:1) K T = -2, 2) K T = -1, 3) K T = 0, 4) K T = 1
答案分析
极点 p = 1 - K T。收敛速度取决于 |p| 的大小,|p| 越小,几何衰减越快。稳定区域内 (|p|<1),|p| 的最小值出现在 p=0 处。令 p = 1 - K T = 0,得 K T = 1。但 K T=1 是否在稳定区域内?p=0 在单位圆内,是稳定的。然而,我们需要考虑原系统是负反馈吗?从公式 H = (-K T R) / (1 - (1 - K T) R) 看,似乎 K T=1 使分子为 -R,分母为 1,系统变为 -R,即一个简单的延迟和反相。其单位采样响应为 -δ[n-1],确实在一个时间步后即结束,是最快响应。但在实际机器人例子中,K T=1 可能对应很大的增益,可能导致其他问题如饱和。但在理想线性模型下,K T=1 能一步到位,是最快的。因此答案可能是 4) K T = 1。但需注意上下文,有时默认 K 为负。若 K 为负,则 K T = -1 时 p=0。根据之前推导,p = 1 - K T,若 K T = -1,则 p=2,发散。所以必须明确参数符号。根据原始差分方程 y[n] = y[n-1] + K*(d[n] - y[n-1]),可重写为 y[n] = (1-K)*y[n-1] + K*d[n]。系统极点为 (1-K)。稳定要求 |1-K| < 1。最快收敛发生在 1-K = 0,即 K=1。此时 K T = 1 若 T=1。因此答案应为 4) K T = 1。
在机器人实例中,假设采样周期 T=0.1 秒,最佳 K 则为 10。这意味着如果距离期望位置1米,我们会将速度设为10米/秒。这样,经过0.1秒后,我们恰好移动1米,到达目标位置。下一步,误差为零,速度为零,保持在该位置。理论上,一步到位,性能极佳。
延迟的影响
问题在于,实验室中并未看到这种良好性能。原因是机器人的传感器并非瞬时工作,它们会引入延迟。作为对延迟的理想化,我们考虑相同的问题,但假设传感器将其输入(系统输出)延迟一个时间单位再报告。
现在,如果机器人从目标位置开始(但由于传感器延迟,控制器认为它还在1米外),它会命令高速运动,导致超调。随后,传感器报告到达目标(实际上是过冲后的位置),控制器命令停止或反向,从而产生振荡。延迟对控制器性能产生了破坏性影响。

我们希望能够在无需实际测量的情况下预测这种行为。



含延迟系统的分析
以下是包含传感器延迟的方程和框图。现在,传感器路径中有一个延迟算子 R。
问题:这个新控制系统的函数表示是什么?
选项:
(-K T R) / (1 - R + K T R)(-K T R) / (1 - R - K T R^2)(-K T R^2) / (1 - R + K T R^2)(-K T R^2) / (1 - R - K T R^2)

答案分析

我们可以通过简化内环,然后应用布莱克公式来分析。内环(累加器加延迟)的函数是 R/(1-R) 吗?注意,现在从 Y 到求和点的反馈路径上有一个 R。设内环输出为 S,输入为 X_in,则 S = R*(X_in + R*S)?需要仔细推导。或者直接对整体应用梅森公式或逐步代数推导。
最终推导出的系统函数形式为 H = (-K T R^2) / (1 - R + K T R^2)。因此正确答案是 3。

这个形式与无延迟时的区别在于,分子和分母中 R 的阶次发生了变化。关键点是分母中出现了 R^2 项,这意味着分母是 R 的二次多项式。因此,系统将有两个极点。
二阶系统的行为与根轨迹
一阶系统(单极点)可能的行为有四种:单调发散、交替发散、单调收敛、交替收敛。现在有了两个极点,我们需要思考二阶系统所有可能的行为。
为了找出极点,我们将系统函数表达式中的 R 替换为 1/z,得到 z 域的函数。对于 H = (-K T R^2) / (1 - R + K T R^2),代入 R=1/z,分子为 -K T / z^2,分母为 1 - 1/z + K T / z^2。通分乘以 z^2 得:H(z) = (-K T) / (z^2 - z + K T)。极点就是分母二次方程 z^2 - z + K T = 0 的根。
通过求解二次方程,我们可以绘制极点随参数 K T 变化的轨迹,即根轨迹。
- 当
K T幅度很小时,极点靠近z=0和z=1。靠近z=1的极点响应衰减很慢,是主导极点,导致系统性能不佳。 - 当
K T = -0.25时,根号下为零,两个极点重合于z=0.5。两者都快速衰减,性能较好。 - 当
K T = -1时,极点为一对共轭复数0.5 ± j√3/2,位于单位圆上,导致等幅振荡。 - 当
K T < -1且幅度更大时,极点移到单位圆外,导致发散振荡。
因此,通过改变增益 K T,我们可以得到根轨迹图上的所有行为。根轨迹显示了该系统所有可能的行为。
练习:选择最快收敛的增益

根据根轨迹,为了获得尽可能快的系统响应,应选择哪个 K T 值?
选项:1) K T = -2, 2) K T = -0.25, 3) K T = 0, 4) K T = 1
答案分析
对于二阶系统,其响应可以分解为两个一阶模态的加权和。响应速度由最慢的那个模态(即主导极点)决定。最慢的极点是离单位圆最近的那个。因此,为了获得最快响应,我们希望最慢的极点尽可能快,即所有极点的幅度都尽可能小。当两个极点重合于实轴上离原点最近的位置时,通常能达到最快收敛。从根轨迹看,当 K T = -0.25 时,两个极点重合于 z=0.5,这是稳定前提下能使极点幅度最小(0.5)的配置。因此,答案应为 2) K T = -0.25。
延迟的普遍影响
我们首先分析了传感器无延迟的墙循迹系统,发现其由单极点表征,我们可以通过选择增益将该极点放在实轴上任一位置,甚至放在原点以获得极佳性能。
有趣的是,当在传感器中仅增加一个延迟,使系统多出一个极点后,系统变得更复杂,性能也远不如前。事实上,如果分析在传感器中增加更多延迟的情况,会发现性能更差。
由此可以推广出一个一般性结论:在反馈环路内增加延迟是破坏稳定性的因素。通常,随着延迟数量的增加,你不得不降低所能使用的最大增益,因为系统变得更难稳定。总的来说,延迟是有害的。虽然可以构思一些特殊方案使其不成立,但在几乎所有实际物理系统中,增加延迟都会使系统更难稳定。这就是我们从实验室墙循迹系统中得到的重要启示:由于传感器、微处理器、模数转换等多个环节都存在延迟,延迟数量很多,因此系统难以稳定。

高阶系统特性练习
最后,我们通过一个练习来思考如何表征高阶系统的性能。
考虑下图所示系统:
(图示:一个三阶系统,包含三个延迟单元和反馈,具体结构略)
关于此系统,判断以下五个陈述中有几个是正确的:
- 系统有三个极点。
- 单位采样响应可以写成三个几何序列之和。
- 单位采样响应是
..., 0, 0, 0, 1, 0, 0, 0, 1, ...周期序列。 - 单位采样响应是
..., 0, 0, 0, 1, 1, 1, 1, 1, ...序列。 - 其中一个极点在
z=1。
答案分析
- 正确。将系统函数写成
R的多项式之比,然后替换R=1/z,分母是z的三次多项式,故有三个极点。 - 正确。通过部分分式展开,任何高阶系统响应都可以表示为多个一阶项(几何序列)的加权和。
- 正确?可以通过差分方程或直接模拟来求单位采样响应。假设系统初始静止(所有延迟单元输出为0),在
n=0时输入δ[0]=1。通过逐步计算,可以发现输出序列是周期为3的序列:y[0]=0, y[1]=0, y[2]=1, y[3]=0, y[4]=0, y[5]=1, ...。所以陈述3基本正确(除了初始可能不同,但周期正确)。 - 错误。显然不是全1序列。
- 正确?极点由分母多项式决定。对于这个具体系统,其系统函数可能为
H = R^3 / (1 - R^3)?如果是这样,那么极点是z^3 = 1的解,即三个单位根:z=1,z=e^(j2π/3),z=e^(-j2π/3)。所以确实有一个极点在z=1。

因此,五个陈述中有四个正确(1,2,3,5)。陈述3需要确认初始值,但周期性是正确的。

这个练习说明了两个要点:
- 我们通过观察单极点来推断一阶系统的特性,其行为只有四种简单方式。
- 二阶系统引入了新的行为(如振荡)。振荡源于复数极点。对于更高阶系统,代数上并无新事物出现,但当我们问及高阶系统的特性时,我们需要根据各个部分来思考,这需要一些推理。例如,主导极点的概念(幅度最大的极点决定长期行为),但短期行为可能由其他极点共同影响。此外,周期性等概念对于高阶系统也是有意义的。
总结
本节课中,我们一起学习了如何利用系统表示(差分方程、框图、算子、系统函数)来设计和分析控制系统。我们重点探讨了:
- 反馈与极点:反馈引入循环,导致极点,极点决定系统模态(自然响应模式)。
- 性能分析:通过极点位置可以预测系统行为(收敛性、单调性、振荡性)。单位采样响应和阶跃响应密切相关。
- 控制器设计:通过调整增益
K,可以改变极点位置,从而优化系统性能(如收敛速度)。 - 延迟的影响:反馈环路中的延迟会引入额外的极点,使系统变得更复杂,通常会对稳定性产生不利影响,限制可用的增益范围,并可能引发振荡。这是实际控制系统设计中需要谨慎处理的关键因素。
通过理解这些基本原理,我们可以在不实际构建系统的情况下,分析和设计出性能更好的控制系统。
014:电路理论入门 🧠

在本节课中,我们将要学习电路理论的基础知识。我们将从最简单的电路元件开始,了解它们如何通过基本规则相互连接,并学习如何系统地分析复杂电路。课程的核心是理解基尔霍夫定律以及如何运用节点法和回路法简化电路分析。
课程回顾与引入
上一节我们结束了关于系统建模的模块。为了设计复杂系统,我们需要能够预测其行为,建模是关键。本节中,我们来看看如何将物理系统与计算相结合,这正是电路模块的起点。


我们将从最基础的层面开始,思考如何构建一个实际的设备。具体来说,我们将为机器人设计一个光追踪系统,这需要用到电路知识。接下来三周的计划是:今天介绍电路理论,本周的两次实验课则专注于电路的实际搭建。


电路的基本概念
电路理论的核心是将物理系统视为元件的互连,并遵循特定的规则。这些规则主要涉及两个变量:

- 电流:流经元件的电量。
- 电压:元件两端产生的电势差。

我们需要一种方法来整合这三部分:元件本身的工作原理、流经元件的电流以及元件两端产生的电压。
让我们从两个简单的例子开始理解这个概念。

示例一:手电筒电路
手电筒是一个最简单的电路。闭合开关,电流流通。

我们为其建立的模型如下:将电池视为电压源,将灯泡视为电阻。我们需要知道这两个元件的电流-电压关系,以及将它们连接在一起时,这些电流和电压会产生什么影响。
示例二:泄漏水箱模型



这个例子旨在说明,电路理论并不仅限于电子领域。对于一个有进水管和出水管的泄漏水箱,我们可以用电路来建模。

- 在电子电路中,“通过变量”是电流。
- 在流体系统中,“通过变量”是流速(水的流量)。
- 在电子电路中,“跨变量”是电压。
- 在流体系统中,“跨变量”是压力(由水位高度产生)。
因此,我们将基于电子学发展电路理论,但请记住,这是一个更通用的概念。

为什么学习电路?
学习电路主要有两个重要原因:
- 物理系统设计:设计电网、手机等电子设备时,必须理解电路如何工作。
- 行为建模:电路模型是描述复杂行为的强大工具。例如,在生物物理学中成功的霍奇金-赫胥黎模型,就是用电路来理解和模拟神经细胞的动作电位传导。
电路的基本元件与组合规则
电路分析的基础层面,类似于我们学习Python时的“原始组合-抽象-模式”框架。我们从最基本的原始元件和组合规则开始。
基本原始元件
我们将从最简单的电子元件开始,进行一定程度的简化:


- 电阻:遵循欧姆定律
V = I * R。 - 电压源:无论流经电流如何,始终保持两端电压恒定。
- 电流源:无论两端电压如何,始终保持流经电流恒定。
这些元件类似于系统函数中的基本单元(如延迟、增益、加法器)。

最简单的互连

最简单的互连是显而易见的。例如,将电压源连接到电阻,电压源设定电阻两端电压,根据欧姆定律即可求出电流。将电流源连接到电阻,电流源设定流经电阻的电流,同样可根据欧姆定律求出电压。


系统化电路分析:基尔霍夫定律
当电路变得稍微复杂时,我们需要一个更结构化的分析方法。这就是基尔霍夫定律。
基尔霍夫电压定律
基尔霍夫电压定律指出:在电路中,沿任何闭合回路的所有电压代数和为零。

数学表达:对于任意闭合回路,∑V = 0。

应用要点:
- 必须为每个元件预先设定一个电压参考方向(正负极)。
- 沿回路行进时,经过元件从正极到负极,则电压取正;从负极到正极,则电压取负。
- 另一种直观理解:回路中任意两点间的电压,无论沿哪条路径计算,结果都相同。

对于一个平面电路,所有最小的内部回路(网孔)的KVL方程是线性独立的。

基尔霍夫电流定律
基尔霍夫电流定律指出:流入电路中任一节点的电流代数和为零。
数学表达:对于任意节点,∑I = 0。
物理意义:这类似于不可压缩流体的流动。在理想导线中,电荷不会堆积。因此,流入一个节点的电荷必须全部流出。

推广:KCL不仅适用于节点,也适用于任何闭合区域(超节点)。流入任一闭合区域的总电流也为零。

一个重要结论是:对于具有 n 个节点的电路,只有 n-1 个独立的KCL方程。

电路求解方法
理论上,只要为每个元件设定电压和电流变量,然后列出所有元件的约束方程、所有独立回路的KVL方程和所有独立节点的KCL方程,就能求解电路。但这会导致方程数量非常多。
以下是两种更高效的简化方法:
1. 节点电压法
此方法不直接求解每个元件的电压,而是求解每个节点相对于参考点(通常称为“地”)的电压。
步骤:
- 选择一个节点作为接地点(电压为0)。
- 为其他每个节点设定一个未知电压变量。
- 对除接地点外的每一个节点,列出KCL方程。方程中的电流用欧姆定律表示为相邻节点电压之差除以电阻。
- 求解这些方程,得到各节点电压,进而可求出所有元件电压和电流。
优点:未知数数量等于独立节点数,通常远少于元件数。
2. 回路电流法
此方法是节点法的对偶方法。它不直接求解每个元件的电流,而是假设有虚拟的回路电流在每个独立回路中流动。
步骤:
- 确定电路中的所有独立回路,并为每个回路设定一个回路电流变量。
- 每个元件的实际电流是流经它的各回路电流的代数和。
- 对每一个独立回路,列出KVL方程。方程中的电压用欧姆定律表示为回路电流的函数。
- 求解这些方程,得到各回路电流,进而可求出所有元件电流和电压。

优点:未知数数量等于独立回路数。
电路的抽象:等效与常用模式
为了管理复杂性,我们需要对电路进行抽象,即用简单的等效元件来代表复杂的子电路。
串并联电阻的等效

- 串联等效:
R_eq = R1 + R2 + ... - 并联等效:
1/R_eq = 1/R1 + 1/R2 + ...

通过反复应用串并联等效,可以简化许多电阻网络。
常用模式:分压与分流

以下是两种极其常见的电路模式,其结论可作为公式直接使用:


分压器:
两个电阻 R1 和 R2 串联,总电压 V 加在它们两端。则 R1 两端的电压 V1 为:
V1 = V * [R1 / (R1 + R2)]

分流器:
两个电阻 R1 和 R2 并联,总电流 I 流入它们。则流经 R1 的电流 I1 为:
I1 = I * [R2 / (R1 + R2)]
(注意:大部分电流流经较小的电阻。)


总结


本节课中我们一起学习了电路理论的基础。我们从原始元件(电阻、电压源、电流源)和组合规则(基尔霍夫电压定律和电流定律)出发。为了高效分析,我们介绍了两种抽象方法:节点电压法和回路电流法。最后,我们探讨了通过等效变换(串并联)和识别常用模式(分压器、分流器)来简化电路。这些概念为我们接下来在实验室中实际搭建和分析电路奠定了坚实的理论基础。
015:电路基础


在本节课中,我们将学习电路的基本概念。我们将探讨如何表示电路,并介绍一些分析电路的基本方法。电路是我们设计物理世界系统的第一步,也是使用物理组件构建系统的开端。
电路表示法
上一节我们介绍了系统建模,本节中我们来看看如何表示电路。电路图在广义上由许多元件和它们之间的连接组成。这些连接形成回路和节点。如果不指定具体元件,电路图看起来很像方框图。实际上,方框图与电路图密切相关,因为方框图常用于建模反馈系统,而这些系统通常由电路实现。
在本课程中,我们将主要关注两种元件:独立源和电阻。我们也会使用电位器(可调电阻)和运算放大器。运算放大器将在后续视频中详细讨论,但这里先画出它的符号以便识别。请注意,它的符号很像方框图中增益模块的符号,这是有意为之的。
我们将使用独立电流源和电压源。我们将使用电阻来调节电路中的电压和电流,然后在电路特定点采样电流或电压,以获得我们期望的数值。
在电路图中,若对某个元件两端的电压降感兴趣,通常用“+”和“-”号标注,这同时也指明了电压降的方向。同样,若对流过某个元件的电流感兴趣,通常会标注电流“I”(可能带下标),并用箭头指明电流流过该元件的方向,以避免与读图或绘图者产生符号错误。
注意:这就是电气工程师使用“J”来表示复平面数值的原因,因为“I”已被专门用于表示电流。
基尔霍夫定律
现在我们来回顾基尔霍夫电压定律和基尔霍夫电流定律。你可能在物理课上学过,但我们现在快速复习一下。
基尔霍夫电压定律指出,沿电路中任一闭合回路的电压降之和为零。即,如果取电路中某个回路的电压降,它们的总和将为0。
基尔霍夫电流定律指出,流入电路中任一节点的电流之和为零。即,如果取流入和流出某个节点的所有电流,它们的代数和应为零。
电路分析实践
让我们在这个具体电路上练习。需要注意的一点是,在一般情况下求解电路时,无论是寻求助教帮助还是在考试中希望获得部分分数,你都应该标注所有节点、所有元件以及所有你要求解的电流。
以下是分析此电路的第一步:
- 简化电路:首先尝试将电路简化为更简单的形式。例如,将并联的两个电阻合并为一个等效电阻。
- 应用定律:对于并联电阻,总电阻的倒数等于各电阻倒数之和。当只有两个电阻时,可以使用公式
R_eq = (R1 * R2) / (R1 + R2)来简化计算。 - 分析分压与分流:简化后,电路可能变为一个分压器或分流器。在分压器中,各电阻上的电压降与其电阻值成正比。在分流器中,各支路电流的分配与支路电阻值成反比。
- 求解具体值:利用基尔霍夫定律和欧姆定律,求解特定的电流和电压值。
通过应用这些步骤,我们可以求解出电路中的各个电流(如 I1、I2、I3)和电压(如 V1、V2、V3)。例如,通过计算等效电阻和应用分压原理,可以求得 V1 = (5/8) * V。
本节课中,我们一起学习了电路的基本表示方法,回顾了基尔霍夫电压定律和电流定律,并通过一个实例练习了分析简单电路的步骤。我们了解到,电路图是连接抽象系统设计与物理实现的重要工具。下次课我们将讨论分析此电路的其他方法,并最终总结运算放大器的相关知识。
016:节点电压与元件电流法(NVCC)教程


概述
在本节课中,我们将学习一种新的电路分析方法——节点电压与元件电流法。上一节我们回顾了使用基尔霍夫电压定律、基尔霍夫电流定律和欧姆定律来求解电路的一般方法,但该方法通常会产生大量冗余或非独立的方程。本节介绍的NVCC方法能够更简洁、更有效地表达电路中元件之间的关系,并可能更快地求解方程,这在考试或日常工作中节省时间尤为重要。
节点电压与元件电流法(NVCC)简介
NVCC代表节点电压与元件电流法。它与节点分析法非常相似,如果你听到其中一种说法,可以理解为大致相同的内容。我们将在稍后介绍两者之间的细微差别。一旦掌握了使用NVCC方法求解电路,我们就能更简洁、更有效地表达电路中元件之间的关系。
NVCC方法的核心步骤
以下是应用NVCC方法分析电路的具体步骤。
第一步:标记节点与电流
首先,标记电路中的所有节点。节点是元件相互连接的任何点。同时,标记与电路中每个元件相关联的电流。在NVCC方法中,我们将关注流经特定元件的电流,而不是流入或流出特定节点的电流。从某种意义上说,NVCC方法与KVL和KCL的思路相反,尽管最终仍会使用相同的关系式。
第二步:设定参考地并建立电压关系
接下来,指定一个节点作为“地”或参考点,即设定其相对电压为零。然后,写出特定元件两端的电压降、流经该元件的电流以及该元件本身所要求的电压与电流关系(例如欧姆定律 V = I * R)之间的方程式。
第三步:应用基尔霍夫电流定律(KCL)
对相关节点应用基尔霍夫电流定律。在NVCC框架下,我们通常关注流经元件的电流在节点处的代数和为零。
第四步:联立求解
将前几步得到的方程组合并,形成一个方程组,然后求解未知的节点电压和元件电流。
NVCC与节点分析法的区别
节点分析法与NVCC方法非常相似。主要区别在于处理电压源时:当元件是电压源且有多个电流流入该电压源时,在节点分析法中,可以将电压源及其连接点视为一个单一的电压节点(其电压值已知),并直接为此节点写出KCL方程,仿佛这些连接点被合并了。这是两者之间的一个主要差异。
实例分析
让我们通过一个例子来具体说明。你可以在课程阅读材料6.4.3节找到这个例子。
电路描述与第一步:标记
假设我们有一个简单电路,包含一个电压源和几个电阻。我们首先标记所有节点(例如N0, N1, N2)和每个元件上的电流(例如I0, I1, I2, I3)。
第二步:设定参考地与电压关系
设定节点N0为地,即其电压 V_N0 = 0。根据电路,我们知道电压源使节点N1的电压 V_N1 = 15V。
接着,根据标记的电流方向,写出每个元件两端的电压降表达式。通常约定电压降的方向与假设的电流方向一致。例如,对于一个电阻R,其电压降 V_drop = I * R,其中I是流经它的电流。
第三步:应用KCL
对关键节点应用KCL:
- 对于节点N1:流入电流
I0等于流出电流I1。因此,I0 = I1。 - 对于节点N2:流入电流
I1和I3等于流出电流I2。因此,I1 + I3 = I2。 - 对于节点N0:其KCL方程通常依赖于前两个方程,可能不是独立的,因此可以不必单独列出。
- 此外,已知
I3 = 10A。
第四步:联立求解
利用第二步的电压关系(例如,对于电阻,V_N1 - V_N2 = I1 * R1,V_N2 - V_N0 = I2 * R2)和第三步的KCL方程,将电流用节点电压表示。
例如,将 I1 = (V_N1 - V_N2) / R1 和 I2 = (V_N2 - V_N0) / R2 代入节点N2的KCL方程 I1 + I3 = I2。
代入已知值 V_N1 = 15V,V_N0 = 0V,I3 = 10A,以及电阻值,可以得到一个仅含未知数 V_N2 的方程:
(15 - V_N2) / 3 + 10 = V_N2 / 6
解这个方程:
两边乘以6以消去分母:2*(15 - V_N2) + 60 = V_N2
计算:30 - 2*V_N2 + 60 = V_N2 => 90 = 3*V_N2 => V_N2 = 30V
求得 V_N2 后,可以回代计算所有电流:
I2 = V_N2 / 6 = 30 / 6 = 5A
I1 = (15 - 30) / 3 = -5A (负号表示实际电流方向与假设相反)
I0 = I1 = -5A
最后,可以计算各电阻的电压降:
3Ω电阻的电压降:V_N1 - V_N2 = 15 - 30 = -15V
2Ω电阻的电压降:V_N2 - V_N0 = 30 - 0 = 30V
总结
本节课我们一起学习了节点电压与元件电流法。这种方法通过关注节点电压和流经元件的电流,能够系统化地建立电路方程,通常比直接应用KVL和KCL更简洁。其核心步骤包括标记节点与电流、设定参考地、建立元件电压-电流关系、对节点应用KCL,最后联立求解。我们还简要比较了NVCC与节点分析法的异同,并通过一个实例完整演示了分析过程。掌握此法将有助于你更高效地分析和求解复杂电路。
017:运算放大器与电路模块化 🎛️

在本节课中,我们将要学习运算放大器,并探讨如何在电路设计中实现模块化。我们将看到,电路模块化面临特殊挑战,而运算放大器是解决这些挑战的关键工具之一。
回顾:电路分析基础
在开始新内容之前,我们先简要回顾一下电路分析的基础知识。上一讲我们首次接触了电路。电路与我们之前学习的编程和线性系统理论不同,其各个部分是相互连接的,电压和电流在整个网络中共享。
电路分析可以归结为三个核心方面:
- 电压:遵循基尔霍夫电压定律,即沿任何闭合回路的电压代数和为零。公式表示为:
∑V = 0。 - 电流:遵循基尔霍夫电流定律,即流入任何节点的电流代数和为零。公式表示为:
∑I = 0。 - 元件特性:取决于具体元件。例如,线性电阻遵循欧姆定律
V = IR;电压源提供固定电压Vs = V0;电流源提供固定电流Is = I0。
结合这些定律和关系,我们就能求解电路。为了简化求解过程,我们介绍了三种方法:
- 原始变量法:为每个元件设定电压和电流变量。变量多,方程也多。
- 节点电压法:设定最少数量的节点电压,足以确定所有元件电压。通常变量更少。
- 回路电流法:设定最少数量的回路电流,足以确定所有元件电流。通常变量也更少。
这三种方法本质上是等价的,但节点电压法和回路电流法通常能减少未知数数量,更便于求解。在编写程序自动求解电路时,节点电压法(或其变体)通常是更易于自动化的选择。
电路模块化的挑战


本节中,我们来看看是什么让电路设计变得困难,特别是模块化设计。与之前讨论的线性时不变系统不同,在电路中,每个元件的存在都会影响电路中所有其他元件的电压和电流。改变一个元件,就可能改变全局。


一个例子:灯泡亮度控制
设想一个用分压器控制灯泡亮度的电路。初始电路有一个电压源和两个电阻。当我们闭合开关,接入灯泡(可视为一个电阻)后,情况发生了变化。
以下是分析步骤:
- 开关断开时:电路是简单的分压器。
V_not为 8V,I_not为 4A。 - 开关闭合后:灯泡电阻
R与R2并联,改变了整个电路的等效电阻。通过计算可以发现,V_not下降,I_not上升。
这个例子说明,添加一个新元件会改变远处其他元件的电压和电流。如果我们希望分压器稳定地提供8V电压给灯泡,这个简单的电路无法实现,因为接入负载(灯泡)后,分压关系被破坏了(电流被分流)。
我们真正需要的是一种“魔法”电路,能够隔离负载对前级电路的影响。这就是运算放大器的作用。

运算放大器简介


运算放大器是一种特殊的电路元件,它可以帮助我们实现电路模块化。一个理想的缓冲器可以测量一侧的电压,并在另一侧“神奇地”生成相同的电压,而不影响前一级。

运算放大器看起来与普通二端元件(如电阻)不同,它通常有多个引脚(至少五个)。理解它的关键在于受控源的概念。
受控源

受控源是其电压-电流关系依赖于电路中其他地方电压或电流的元件。例如:
- 电流控制电流源:其输出电流
I_out受另一个电流I_B控制,例如I_out = β * I_B。 - 电压控制电压源:其输出电压
V_out受另一个电压差(V+ - V-)控制,例如V_out = A * (V+ - V-)。
运算放大器的一个基本模型就是电压控制电压源。其符号有一个同相输入端(+)、一个反相输入端(-)和一个输出端。其关系为:
V_out = K * (V_plus - V_minus)
其中 K 是一个非常大的数(通常 > 10^5)。
理想运算放大器模型



由于 K 非常大,为了得到合理的有限输出电压 V_out,输入端的电压差 (V_plus - V_minus) 必须非常小,趋近于零。这引出了理想运算放大器模型的两个关键规则:
- 虚短:
V_plus ≈ V_minus。 - 虚断:流入运算放大器两个输入端的电流为零。

使用这个模型,可以极大地简化电路分析。


应用示例1:电压跟随器


考虑一个将输出直接反馈到反相输入端的电路(电压跟随器)。根据理想模型:
- 由于
V_plus = V_in且V_plus ≈ V_minus,所以V_minus ≈ V_in。 - 又因为
V_out直接连接到V_minus,所以V_out = V_in。
这个电路实现了完美的缓冲隔离,输出跟随输入,但不会像简单分压器那样因负载接入而改变。


应用示例2:反相加法器
考虑一个更复杂的电路,有两个输入电压 V1 和 V2 通过电阻连接到反相输入端。
- 根据虚短,反相输入端电压
V_minus ≈ V_plus = 0V(接地)。 - 根据虚断,流入该节点的电流和为零:
(V1/R) + (V2/R) + (V_out/R_f) = 0。 - 解得:
V_out = -R_f/R * (V1 + V2)。当R_f = R时,V_out = -(V1 + V2)。


这个电路实现了对输入电压求反相和的功能。


深入理解:为什么反馈极性很重要
理想运算放大器模型似乎暗示,无论将输入接在同相端还是反相端,只要构成反馈,结果都一样(V_out = V_in)。但这与实际情况不符。我们需要更深入的模型来理解其工作机制。
动态模型:考虑电荷流动


运算放大器内部通过感知输入电压差,并向输出端的寄生电容充电或放电来调整输出电压。这可以用一个包含受控电流源的动态模型来描述。

- 负反馈(稳定):输入接同相端,输出反馈到反相端。若
V_in上升,V_out也会上升,使(V+ - V-)减小,充电电流减弱,最终稳定在V_out ≈ V_in。这像一个位于山谷的小球,扰动后会回到平衡点。 - 正反馈(不稳定):输入接反相端。若
V_in上升,V_out反而下降,使(V+ - V-)变得更大,导致V_out进一步下降,形成恶性循环。这像一个位于山顶的小球,稍有扰动就会滚落。
因此,必须将运算放大器连接成负反馈形式,电路才能稳定工作。只要确保是负反馈配置,就可以安全地使用理想运算放大器模型进行分析。

实际考量:电源轨
最后需要指出,运算放大器要实现其功能,必须从外部获取能量。因此,实际的运算放大器除了输入、输出引脚外,还有正、负电源引脚(如 +V_s 和 -V_s)。这意味着运算放大器的输出电压范围受限于其电源电压,无法输出超过电源轨的电压。在设计电路(如机器人头部控制电路)时,这是一个重要的约束条件。


总结
本节课中我们一起学习了:
- 电路模块化的挑战:由于元件间相互影响,直接连接电路会破坏前级的工作状态。
- 运算放大器的引入:作为一种解决方案,它能提供缓冲隔离。
- 运算放大器的模型:从电压控制电压源模型,到简化的理想模型(虚短、虚断)。
- 理想模型的应用:利用该模型可以轻松分析如电压跟随器、反相加法器等电路。
- 稳定性的关键:通过动态模型理解了负反馈的重要性,这是运算放大器电路稳定工作的前提。
- 实际限制:运算放大器的输出电压范围受其电源电压限制。





总之,运算放大器通过提供高输入阻抗和低输出阻抗,有效地隔离了电路的不同部分,使我们能够设计出模块化、可预测的复杂电路系统。
018:运算放大器


概述
在本节课中,我们将要学习运算放大器。运算放大器是一种强大的电路元件,它允许我们从电路的一个部分采样电压,而不影响该部分电路,并且能够对采样的电压信号进行修改。
上一节我们介绍了节点电压电流守恒方法,以及如何减少求解特定电路所需的方程数量。目前我们已经具备了从一般意义上求解电路的能力。但我们尚未讨论如何利用这些信息,或者如何以特定方式使用电路。
在深入探讨这些内容以及电路的抽象化之前,我们需要先了解运算放大器。
什么是运算放大器?🔌
运算放大器,简称Op Amp,是一种由大量晶体管组成的电路网络。它的功能是作为一个电压控制型电压源。运算放大器可以有效地从现有电路中采样电压,然后利用这些电压为其他元件供电。
例如,如果你想用一个5伏的电源点亮一个灯泡,但灯泡本身是一个电阻,那么灯泡两端的实际电压降将不等于5伏。运算放大器可以解决这个问题。
运算放大器的符号与基本规则
在电路原理图中,运算放大器通常如下图所示。它有一个正输入端、一个负输入端、电源轨和一个输出端。

实际上,输出电压与输入电压之间的关系近似为:
V_out = A * (V_plus - V_minus)
其中,A 是一个非常大的数值。
这个关系式带来的效果是,V_out 会调整到任何必要的值,以确保 V_plus 等于 V_minus。V_plus ≈ V_minus 是你在分析包含运算放大器的电路时需要用到的基本规则。
运算放大器应用实例 💡
回到点亮灯泡的例子。我们可以构建如下电路:

我们有一个10伏的电压源和一个分压器,在中间点采样得到5伏电压。运算放大器将这个采样点与负输入端相连。根据基本规则,运算放大器会调整其输出 V_out,使得负输入端电压与正输入端采样到的5伏电压相等。因此,V_out 被驱动至5伏,从而成功用5伏电压为灯泡供电,并且隔离了电源电路与负载。
分析运算放大器电路
你可能需要分析一个给定的运算放大器电路图,判断它对输入信号做了什么,或者求解 V_out 的表达式。
以下是一个分析练习。对于给定的电路,我们可以按步骤求解:
- 计算正输入端
V_plus的电压(通常是一个分压器)。 - 用
V_out表示负输入端V_minus的电压(通常是另一个分压器或反馈网络)。 - 根据
V_plus = V_minus的规则,将两个表达式相等。 - 解方程,得到
V_out关于输入电压的表达式。
例如,如果输入电压 V_in 为10伏,通过计算可以得到一个具体的 V_out 值。如果 V_in 是未指定的变量 V_in,那么我们将得到一个 V_out = f(V_in) 的表达式。
重要注意事项 ⚠️
在使用运算放大器时,有几个关键点需要注意:
以下是运算放大器使用中的三个核心注意事项:
- 稳定性问题:目前我们接触的运算放大器电路,输入信号通常接入正端,负端接地。你也可以反接,但这可能带来不稳定的影响。你需要关注
V_out = A*(V_plus - V_minus)这个关系。系统可能在一个点稳定,但任何微小的扰动都可能导致输出发散(饱和)。在这种情况下,运算放大器可能会烧毁。以错误方式连接运算放大器代价高昂且危险。 - 输出范围限制:运算放大器的电源轨限制了其输出范围。如果一个运算放大器仅由±10伏电源供电,它无法将信号放大到超过±10伏。同样,如果你的输入是负电压,而你的参考地是真正的“地”或相对于输入电压更高,那么运算放大器实际上无法输出负电压。
- 常见电路术语:有一些与运算放大器相关的术语你可能听到,例如“缓冲器”和“电压跟随器”,它们指的是同一种电路:当你想采样一个电压信号,但不想对其进行放大、加法或其他线性时不变操作时使用的电路。我们也可以使用运算放大器构建放大器(增益小于1或大于1)和加法器(在互联网上搜索“电压求和放大器”可以找到相关信息)。
总结
本节课中,我们一起学习了运算放大器。运算放大器功能强大,它使我们能够:
- 从电路的特定部分采样电压值,而不影响该部分电路。
- 在将采样电压用于整个电路的其他部分之前,对其进行修改。
因此,我们能够设计更复杂、更强大的电路系统。下一讲,我们将讨论叠加原理、戴维南与诺顿等效,这些方法将进一步增强我们电路设计的模块化和抽象能力。
019:电路抽象化方法


在本节课中,我们将学习如何利用线性特性来简化电路分析。我们将重点介绍戴维南等效、诺顿等效以及叠加定理这三种强大的抽象化方法,它们能帮助我们理解复杂电路中各部分的相互作用,而无需每次都进行繁琐的全电路计算。

上一节我们讨论了电路中各部分的相互影响给设计带来的挑战。本节中,我们来看看如何利用线性特性来建立有效的电路抽象模型。

线性电路的伏安特性
首先,我们回顾一个基本概念:由线性元件(如电阻、独立电压源、独立电流源)组成的任意电路,从其任意一对端口看进去,端口电压 V 和端口电流 I 之间的关系是一条直线。


公式:V = m * I + b,其中 m 和 b 是常数。
这个结论源于电路方程(KVL、KCL和元件约束方程)都是线性方程。求解线性方程组,最终得到的端口 V-I 关系必然是线性的。这意味着,无论内部电路多复杂,我们只需要两个参数就能完全描述其外部特性。
戴维南等效与诺顿等效


由于任意线性单口网络的 V-I 特性是一条直线,我们可以用非常简单的电路来等效它。以下是两种最常用的等效模型。
戴维南等效电路
戴维南等效电路由一个电压源 V_th 和一个串联电阻 R_th 组成。
公式:
V_th= 端口的开路电压(即I = 0时的V)。R_th=V_th / I_sc,其中I_sc是端口的短路电流(即V = 0时的I,需注意电流参考方向)。
诺顿等效电路
诺顿等效电路由一个电流源 I_n 和一个并联电阻 R_n 组成。
公式:
I_n= 端口的短路电流(即V = 0时的I,需注意电流参考方向)。R_n=V_oc / I_n,其中V_oc是端口的开路电压(即I = 0时的V)。可以证明R_n = R_th。
戴维南和诺顿等效是相互转换的,它们描述了同一个 V-I 直线,只是表现形式不同。使用等效电路后,分析外部负载的变化将变得极其简单。
如何求解等效电路参数
求解戴维南或诺顿等效参数的核心是计算两个特殊工作点。
以下是关键步骤:
- 求开路电压 (
V_oc):将端口断开,计算该处的电压。 - 求短路电流 (
I_sc):用导线将端口短接,计算流经该导线的电流。 - 计算等效电阻 (
R_eq):R_eq = V_oc / I_sc。
示例:对于下图电路,求其戴维南等效。
1Ω
+---/\/\/---+
| |
(10V) +--- a
| |
+---/\/\/---+
3Ω
V_oc(a点对地电压): 使用分压公式,V_oc = 3/(1+3) * 10V = 7.5V。I_sc: 将a点短接到地,则3Ω电阻被短路,电流I_sc = 10V / 1Ω = 10A。R_eq = V_oc / I_sc = 7.5V / 10A = 0.75Ω。

因此,该复杂电路可以等效为一个 7.5V 电压源串联一个 0.75Ω 的电阻。
叠加定理
叠加定理是线性系统的另一个直接结果。它指出,在含有多个独立源的线性电路中,任一支路的电压或电流,等于每个独立源单独作用时(其他独立源置零),在该支路产生的响应的代数和。
应用规则:
- 电压源置零:视为短路。
- 电流源置零:视为开路。
示例:求下图中电阻 R 两端的电压 V。
+---/\/\/\/---+
| R |
(1V) (1A)
| |
+-------------+
- 电压源单独作用:电流源开路。
V1 = 1V(全部加在R上)。 - 电流源单独作用:电压源短路。R被短路,故
V2 = 0V。 - 总电压:
V = V1 + V2 = 1V + 0V = 1V。
叠加定理将多源问题分解为多个单源问题,常常能简化计算。


等效电路的应用价值
理解这些抽象化方法具有双重意义:



- 实用价值:这是电子元器件制造商描述产品特性的标准方式。例如,运算放大器的数据手册会给出其输出端的戴维南等效电阻。
- 概念简化:它让电路设计师能够快速洞察电路行为。例如,判断一个开关闭合后某支路电流是增大还是减小,通过将电路其余部分进行戴维南等效,可以立即得出结论,而无需求解整个网络。



本节课中我们一起学习了三种基于线性代数核心思想的电路抽象化方法:戴维南等效、诺顿等效和叠加定理。这些工具使我们能够模块化地分析电路,预测部件间的相互影响,从而显著简化复杂电路的设计与分析过程。掌握这些方法,是成为高效电路设计者的关键一步。
020:戴维南/诺顿等效与叠加原理 📚


在本节课中,我们将学习两个重要的电路分析概念:戴维南/诺顿等效,以及叠加原理。这两个概念都源于我们所处理的系统是线性时不变系统这一事实,它们能帮助我们简化复杂电路的分析过程。
上一节我们讨论了运算放大器,它允许我们抽象化电路的某些部分,并以线性时不变的方式对电压进行采样和修改。本节中,我们将探讨由LTI系统特性衍生出的其他有趣概念。
戴维南与诺顿等效 ⚡
戴维南与诺顿等效的核心思想是:对于一个复杂的LTI电路,你可以将其在特定两个端点处的特性,用一个简单的线性关系(一条直线)来表示。这在你只想关注电路中某特定位置的电压或电流,而不想分析整个复杂电路时非常有用。
因为处理的是LTI系统,我们可以将特定采样点的特性,用其电压(V)和电流(I)之间的关系来表达,这个关系通常与一个等效电阻有关。
我们通过以下步骤来确定这条关系曲线(即等效电路):
- 求开路电压:在我们采样的两个端点处开路(即不连接任何东西),测量两点之间的电压。这就是戴维南等效电压
V_th。 - 求短路电流:将我们采样的两个端点用导线短接,测量流经该导线的电流。这就是诺顿等效电流
I_n。 - 求等效电阻:关系曲线的斜率代表了等效电阻
R_th。它可以通过公式R_th = V_th / I_n计算得出(注意电流方向,通常取绝对值)。
一旦求出这些值,我们可以用两种等效电路来表示原电路在该端口处的特性:
- 戴维南等效电路:一个电压源
V_th与一个电阻R_th串联。 - 诺顿等效电路:一个电流源
I_n与一个电阻R_th并联。
这两种等效电路可以相互转换。
让我们通过一个例子来实践。假设我们有一个简单电路,并希望找到跨越图中某个电阻两端的戴维南等效电路。
首先,我们求解开路电压 V_th。在我们采样的两点之间开路,这形成了一个分压器。根据分压原理,采样点间的电压占总电压的特定比例。通过计算,我们得到 V_th = 24V。
接下来,求解短路电流 I_n。将采样两点短接。此时,被短接的电阻被旁路,电流将全部流经短路线。根据欧姆定律,短路电流为总电压除以短路线之外路径上的电阻。计算得到 I_n = -6A(负号表示电流方向与参考方向相反)。
最后,计算等效电阻 R_th。利用公式 R_th = V_th / |I_n| = 24V / 6A = 4Ω。
这样,我们就得到了该端口的戴维南等效电路:一个 24V 的电压源与一个 4Ω 的电阻串联。同样可以转换为诺顿等效电路。
叠加原理 ➕
叠加原理是另一种电路求解策略,它同样是LTI系统特性的直接结果。其含义是:为了求解某个元件上的电流或电压,你可以通过计算每个独立电源单独作用时,在该元件上产生的贡献,然后将这些贡献线性叠加起来。
具体操作步骤如下:
- 保留一个独立电源(电压源或电流源),将其他所有独立电源“置零”。
- 电压源置零:相当于用一根导线代替(短路),使其两端电压为0。
- 电流源置零:相当于将其移开,使该支路断开(开路),因为零电流意味着没有连接。
- 分析简化后的电路,计算目标元件上的电流或电压。
- 对每个独立电源重复步骤1和2。
- 将所有独立电源单独作用时得到的结果代数相加,即为原电路中目标元件上的总电流或电压。
让我们看一个例子。假设我们要求解下图中电阻R1上的电流 I1。
根据叠加原理,I1 等于仅电压源作用时的 I1_v 加上仅电流源作用时的 I1_i。
- 在仅电压源作用的电路中(电流源开路),电路简化。使用欧姆定律
V=IR,可以计算出I1_v = 3A(假设方向向下)。 - 在仅电流源作用的电路中(电压源短路),电路形成一个分流器。根据分流原理,流经R1的电流占总电流的比例由电阻值决定。计算可得
I1_i = -4A(负号表示方向与参考方向相反,即向上)。
因此,原电路中的总电流 I1 = I1_v + I1_i = 3A + (-4A) = -1A。这个结果与使用其他方法(如节点电压法)求解的结果一致。
本节课中,我们一起学习了两个基于LTI系统特性的重要电路分析工具:戴维南/诺顿等效和叠加原理。戴维南/诺顿等效允许我们将复杂电路在特定端口简化为一个简单的电压源/电阻串联或电流源/电阻并联模型,极大地方便了分析。叠加原理则提供了一种化整为零的求解思路,通过分别计算各独立电源的贡献并叠加,来求解复杂电路中的响应。掌握这两个概念,将为分析更复杂的电子系统奠定坚实基础。
下一讲,我们将开始一个全新的模块。
021:概率论导论 🎲

在本节课中,我们将开始学习一个全新的主题——概率论。我们将探讨如何用精确的数学语言来描述和处理不确定性,这是设计复杂、鲁棒系统的关键。
到目前为止,我们已经学习了如何通过抽象、层次化和模块化来设计复杂系统,以及如何通过建模来预测和控制物理系统。今天,我们将面对一个更现实的问题:当系统面临不确定性和未知环境时,我们该如何应对?例如,一个机器人在未知的迷宫中如何定位自己并规划路径?这正是概率论可以大显身手的地方。


概率论基础:从集合论到贝叶斯定理
为了理解不确定性,我们需要一个理论框架,这就是概率论。它的核心规则非常简单,但其应用和直觉却可能非常微妙。
样本空间与事件
我们首先需要理解如何描述一个实验的所有可能结果。这涉及到集合论的思想。
- 实验:任何可能产生不同结果的过程,例如掷骰子。
- 事件:实验的任何可描述的结果。例如,掷三次硬币,“结果为正正正”是一个事件,“第一次为正”也是一个事件。
- 原子事件:最细粒度、不可再分的事件。对于掷三次硬币的实验,原子事件包括“正正正”、“正正反”等8种组合。原子事件有两个关键属性:
- 互斥性:如果一个原子事件发生,其他原子事件必然不发生。
- 完备性:所有原子事件的集合覆盖了实验的所有可能结果。
- 样本空间:所有原子事件的集合,通常用符号 Ω 或 U 表示。
概率三大公理
概率论建立在三个简单的基本规则之上:
- 非负性:任何事件 A 的概率都是一个非负实数。
P(A) ≥ 0 - 归一性:整个样本空间的概率为1。
P(Ω) = 1 - 可加性:如果两个事件 A 和 B 互斥(即
A ∩ B = ∅),那么它们并集的概率等于各自概率之和。P(A ∪ B) = P(A) + P(B)
基于这些简单公理,可以推导出概率论的所有结论。例如,对于任意两个事件(不一定互斥),其并集的概率公式为:
P(A ∪ B) = P(A) + P(B) - P(A ∩ B)
贝叶斯定理与条件概率
最有趣且最具挑战性的规则是贝叶斯定理,它处理的是条件概率。
条件概率回答的问题是:在已知事件 B 发生的情况下,事件 A 发生的概率是多少?记作 P(A | B)。
贝叶斯定理的公式是:
P(A | B) = P(A ∩ B) / P(B)
直观理解:已知 B 发生,相当于我们的“世界”缩小到了 B 这个范围内。在这个新世界里,原来 A 与 B 的交集部分所占的比例,就是条件概率。这个过程也称为“归一化”——将交集部分的概率除以 P(B),使得在新样本空间 B 中,所有概率之和重新变为1。
示例:掷一个公平的六面骰子。
- 问:已知点数为奇数,求点数大于3的概率。
- 解:原始样本空间为 {1,2,3,4,5,6}。条件“点数为奇数”将样本空间缩小为 {1,3,5}。在这个新空间中,“大于3”的事件只有 {5}。因此,
P(>3 | 奇数) = 1/3。注意,无条件时P(>3) = 1/2,条件信息改变了概率。
随机变量:更便捷的数学工具
虽然用集合和事件足以描述概率,但为了更简洁地处理复杂问题(尤其是多维问题),我们引入随机变量。

- 随机变量:一个变量,其值取决于随机实验的结果。通常用大写字母表示,如 X。
- 取值:随机变量的具体观测值用小写字母表示,如 x。
- 概率表示:
P(X = x)表示随机变量 X 取值为 x 的概率。


使用随机变量的好处是能方便地处理多维问题。例如,描述两次掷骰子的结果,可以定义一个二维随机变量 (X, Y),其中 X 是第一次结果,Y 是第二次结果。

边缘化与条件化
对于多维随机变量,我们常需要从联合分布中获取部分信息,主要操作有两种:
- 边缘化:忽略我们不关心的维度。例如,从两次掷骰子的联合分布
P(X, Y)中,求第一次掷出某点数的概率P(X),需要对 Y 的所有可能取值求和:P(X=x) = Σ_y P(X=x, Y=y) - 条件化:在已知某些维度信息的条件下,求其他维度的分布。这正是贝叶斯定理的应用。例如,已知第一次掷出3点
(X=3),求第二次点数的分布P(Y | X=3),需要用到公式:P(Y=y | X=3) = P(X=3, Y=y) / P(X=3)
实例分析:艾滋病检测
假设有一个艾滋病检测,已知以下联合概率数据(单位:%):
| 检测阳性 | 检测阴性 | 边缘概率 | |
|---|---|---|---|
| 患病 | 0.3648 | 0.0052 | 0.3700 |
| 未患病 | 4.9552 | 94.6748 | 99.6300 |
| 边缘概率 | 5.3200 | 94.6800 | 100.00 |
- 问题一:已知一个人患病,检测为阳性的概率是多少?
P(阳性 | 患病) = 0.3648 / 0.3700 ≈ 0.986。这是一个非常准确的检测。 - 问题二:已知一个人检测为阳性,他实际患病的概率是多少?
P(患病 | 阳性) = 0.3648 / 5.3200 ≈ 0.0686。这个概率很低,因为患病的基础发病率(边缘概率)很低。这说明了先验概率的重要性。
Python中的概率表示 🐍
为了在计算中方便地使用概率,我们定义了一套Python表示方法。
离散分布类 (DDist)
我们用一个类来表示离散概率分布,其核心是一个Python字典,将原子事件(键)映射到其概率(值)。原子事件可以是字符串、元组等任何可哈希的Python对象。


# 示例:创建一个公平硬币的分布
from lib601.dist import DDist
coin = DDist({'Head': 0.5, 'Tail': 0.5})
print(coin.prob('Head')) # 输出 0.5
print(coin.prob('Heads')) # 输出 0.0 (未定义的键返回0)
条件概率分布
条件概率分布被表示为一个函数。输入是条件,输出是在该条件下的概率分布(一个DDist对象)。
# 示例:定义检测结果在已知患病情况下的条件分布
def testGivenAIDS(hasAIDS):
if hasAIDS:
return DDist({'Positive': 0.986, 'Negative': 0.014})
else:
return DDist({'Positive': 0.0497, 'Negative': 0.9503})
联合概率分布
联合分布通常用元组作为原子事件的键。可以通过边缘分布和条件分布来构建。
# 示例:构建患病状态与检测结果的联合分布
# 首先需要患病的边缘分布
pHasAIDS = DDist({True: 0.0037, False: 0.9963})
# 然后利用条件分布函数构建联合分布
# (这里示意概念,实际有特定类如JDist来处理)
joint_event = (True, 'Positive')
# 其概率为 P(True) * P('Positive' | True)
应用:理性决策与期望值 💰

让我们回到课堂开始的乐高积木游戏,应用概率论进行理性决策分析。
游戏规则:袋中有4块乐高,颜色非红即白,比例未知。你付钱给我,然后抽一块。如果抽到红色,我给你20美元。

问题:你愿意付多少钱玩这个游戏?


分析步骤:
- 建立先验模型:在不知道装袋人偏好时,我们假设袋中红色积木数量 S 的所有可能性(0, 1, 2, 3, 4)是等可能的。即
P(S=s) = 1/5。 - 计算条件期望收益:如果已知袋中有 s 个红块,抽到红块的几率是
s/4,期望收益是(s/4) * $20 = $5s。 - 计算总体期望收益:根据全期望公式,总体期望收益是各情况下的期望收益按其先验概率的加权和。
E[收益] = Σ_{s=0}^4 P(S=s) * ($5s) = (1/5)*(0+5+10+15+20) = $10 - 决策:一个理性的决策是,愿意支付的金额应不高于期望收益10美元。如果你想盈利,应支付低于10美元;如果你愿意承担风险博取更高回报,则可能支付接近10美元。
更新信息:如果抽出一块后发现是红色,我们就有了新信息。利用贝叶斯定理更新红色积木数量的后验概率,再重新计算期望收益,会发现期望值升高了(计算后约为15美元)。因此,在获得“至少有一红块”的信息后,游戏对你更有利,你应愿意支付更多。
总结与展望 🚀
本节课我们一起学习了概率论的基础知识。我们从样本空间和事件出发,理解了概率的三大公理。然后,我们掌握了强大的贝叶斯定理,它教会我们如何用新证据更新对世界的认知。为了方便计算,我们引入了随机变量的概念,并学会了边缘化和条件化两种基本操作。最后,我们还探讨了如何在Python中表示概率分布,并用期望值理论解决了一个简单的决策问题。
概率论为我们提供了量化不确定性的语言。在接下来的课程中,我们将把这些工具应用到机器人技术中,例如:
- 状态估计:机器人如何结合不可靠的运动传感器(里程计)和噪声的感知传感器(声纳)来更准确地判断自己的位置?
- 定位与建图:在未知环境中,机器人如何通过移动和感知来逐步构建环境地图并确定自身位姿?

我们将建立运动模型(描述状态如何随时间概率性演化)和观测模型(描述在某个状态下得到特定观测值的概率),并利用贝叶斯框架将它们融合起来,从而设计出能够应对现实世界不确定性的鲁棒系统。
022:概率论基础


概述
在本节课中,我们将学习概率论的基础知识。概率论是我们在现实世界中建模和处理不确定性的核心数学工具。我们将从基本概念开始,逐步理解如何用概率来描述随机事件,并介绍几种关键的概率运算规则。
概率建模:样本空间与事件
上一节我们介绍了课程的整体目标,本节中我们来看看如何为不确定性建立数学模型。当我们谈论概率时,首先需要定义样本空间。样本空间代表了我们所关心的“整个宇宙”,即所有可能发生的结果的集合。
这些结果,或者说“状态”,可以用两种方式来描述:
- 原子事件:直接、详尽地列举出每一个可能的结果。
- 分解状态空间:使用多个随机变量来描述系统的不同方面。
例如,考虑抛四次硬币这个实验。
- 以原子事件的方式描述,我们需要列出所有可能的序列,如“正正正正”、“正正正反”等。
- 以分解状态空间的方式描述,我们可以定义四个随机变量:
C1,C2,C3,C4,每个变量代表一次抛掷的结果(正或反)。所有变量取值的组合,就对应了原子事件所描述的状态。
使用随机变量的好处在于,它让我们无需穷举所有可能事件,就能高效地描述和分析复杂的系统。
随机变量与概率分布
既然引入了随机变量,我们需要明确其含义。随机变量是一个其值由随机现象决定的变量。当我们谈论一个随机变量A的所有可能取值及其可能性时,我们指的是它的概率分布。
概率分布是一个函数,对于随机变量A的每一个可能取值a,该函数给出A等于a的概率P(A=a)。所有概率值都在0到1之间,且所有可能取值的概率之和为1。
例如,设随机变量A表示“我当天所穿衬衫的颜色”。P(A="粉色")就表示我穿粉色衬衫的概率,这个概率可以通过统计我过去一年穿粉色衬衫的天数比例来估计。
联合概率与条件概率
在包含多个随机变量的分解状态空间中,我们有两种主要的方式来讨论它们之间的关系。
以下是描述多个随机变量的两种核心方法:
- 联合概率:同时指定所有随机变量取特定值的概率,记为
P(A=a, B=b)。它表示事件“A等于a且B等于b”同时发生的概率。 - 条件概率:在已知某些随机变量取值的前提下,讨论其他随机变量的概率,记为
P(A=a | B=b)。它表示在“B等于b”已经发生的条件下,事件“A等于a”发生的概率。
为了直观理解,可以将样本空间想象成一个矩形,其中每个随机变量的不同取值划分出不同的区域。概率则对应这些区域的面积(相对于整个矩形的面积)。
P(A=1)是A=1区域面积与整个矩形面积之比。P(A=1, B=1)是A=1与B=1区域重叠部分的面积与整个矩形面积之比。P(A=1 | B=1)是在仅限于B=1区域的范围内,A=1区域所占的面积比例。因此,它的计算方式是重叠部分面积除以B=1区域的面积。
理解联合概率与条件概率的区别至关重要,后者是在一个缩小了的范围(条件)内考虑概率。
概率的解释:频率主义与贝叶斯主义
在深入应用之前,需要了解概率的两种哲学解释,因为人们常会用到这些术语。
以下是两种主要的概率解释:
- 频率主义解释:适用于可重复大量试验的场景。概率被解释为长期频率的极限。例如,“周三下雨的概率是30%”可以理解为在所有周三中,有30%的日子会下雨。
- 贝叶斯主义解释:适用于一次性或高度特定的事件。概率被解释为对某命题为真的信念度或似然度。例如,“2011年8月24日下午下雨的可能性很大”,这个事件过于特定,无法谈频率,但可以基于现有知识(如天气预报)评估其发生的可能性。
概率论的基本公理
任何概率论的介绍都离不开其三条基本公理,它们构成了所有概率运算的基石。
以下是概率论必须满足的三个基本公理:
- 非负性:任何事件的概率都是一个介于0和1之间的实数。
0 ≤ P(E) ≤ 1。 - 归一性:整个样本空间(即必然事件)的概率为1。
P(Ω) = 1。 - 可加性:对于互斥(不能同时发生)的事件集合,它们中至少一个发生的概率等于各自概率之和。如果事件A和B互斥(
A ∩ B = ∅),则P(A ∪ B) = P(A) + P(B)。
第三条公理在图形上可以理解为:事件A或B发生的概率P(A ∪ B),等于A的面积加B的面积,再减去两者重叠部分(因为被计算了两次)的面积,即 P(A ∪ B) = P(A) + P(B) - P(A ∩ B)。当A和B互斥时,重叠面积为0。
边缘化与全概率公式
在实际应用中,我们常常需要从已知的复杂概率分布中提取出我们关心的部分信息。
为了从联合分布中得到单个变量的分布,我们使用边缘化操作。例如,已知衬衫颜色S和裤子颜色P的联合分布P(S, P),若只想得到衬衫颜色的分布P(S),可以对裤子颜色的所有可能取值求和:
P(S=s) = Σ_{所有p} P(S=s, P=p)
全概率公式是边缘化思想在条件概率中的体现。它允许我们通过一组互斥且完备的条件事件B_i来计算一个事件A的总概率:
P(A) = Σ_i P(A | B_i) * P(B_i)
这个公式将A的概率“分解”到各个条件B_i下进行考虑,然后再汇总。
贝叶斯规则:推理的基石
最后,我们介绍概率论中一个极其强大且重要的工具——贝叶斯规则(贝叶斯定理)。
贝叶斯规则建立了两种条件概率之间的关系:
P(B | A) = [P(A | B) * P(B)] / P(A)
它的推导基于联合概率的对称性:P(A, B) = P(A|B)P(B) = P(B|A)P(A)。通过简单的代数变换即可得到上述公式。
贝叶斯规则是统计推断的基础。它允许我们在观察到新证据A之后,更新我们对某个假设B的信念(即从先验概率P(B)更新到后验概率P(B|A))。这正是我们下一讲“状态估计”的核心方法,系统将利用传感器数据(证据)来更新其对世界状态(假设)的估计。
总结
本节课中我们一起学习了概率论的基础知识。我们首先了解了如何用样本空间和随机变量来建模不确定性。接着,我们区分了联合概率与条件概率,并介绍了概率的频率主义与贝叶斯主义解释。然后,我们回顾了概率论的三条基本公理。最后,我们掌握了边缘化、全概率公式以及最重要的贝叶斯规则。这些工具为我们处理现实世界中的不确定性,以及进行状态估计和推理奠定了坚实的数学基础。下一讲,我们将应用这些概率知识,开始学习状态估计。
023:状态估计教程 🧠


在本节课中,我们将学习状态估计。状态估计是一种处理系统不确定性的方法。我们将通过一个“生病诊断”的例子,学习如何对一个不完全理解的系统进行建模,并基于可观察的信息来推断其内部状态。
上一节我们介绍了概率论作为建模不确定性的基础。本节中我们来看看如何应用概率进行状态估计。
概述:什么是状态估计?
状态估计是我们处理系统不确定性的一种方式。我们可以对一个不完全理解的模型,尝试基于对该系统可观察的事物来推断其信息。
具体来说,我们将观察一系列系统采取的动作或我们对系统采取的动作。如果我们持续多个时间步进行这个过程,就可以不断尝试了解这个特定系统。这种在多个时间步中持续进行推断的过程,就是我们所说的状态估计。
首先,状态估计是一个过程,它是我们想要理解一个随机状态机的产物。状态估计本身不是一个随机状态机。状态估计试图获取一个随机状态机,为其建立模型,然后迭代地(或递归地)运行状态估计,以试图弄清楚该随机状态机内部发生了什么。
构建随机状态机模型 🏗️
当你构建一个随机状态机模型时,需要指定三个组成部分。
以下是构建模型所需的三个核心部分:
-
初始状态分布:这是你对系统初始状态的信念。例如,假设我生病了,试图弄清楚病因。我认为可能患有三种疾病:链球菌性喉炎(Strep)、普通病毒性感冒,或单核细胞增多症(Mono)。初始分布就是指我对这些可能性的初始信念。一个常见的假设是初始分布是均匀的,即每种可能性概率相等。
- 公式表示:
P(S0),其中S0代表初始状态。
- 公式表示:
-
观测分布:这是在给定当前状态下,做出特定观测的可能性。例如,如果我患有单核细胞增多症,观察到喉咙后部有白色斑块的可能性有多大?如果我患有链球菌性喉炎,观察到这种症状的可能性又有多大?通常,观测变量可以分解为几个不同的现象。在生病的例子中,最好的观测就是症状,例如:是否嗜睡、喉咙是否有白斑、是否发烧等。
- 公式表示:
P(O | S),即在状态S下观测到O的概率。
- 公式表示:
-
转移分布:你假设状态机会随时间变化。例如,我病情加重或减轻的可能性。你可以采取一些行动来促使这种变化,或者有些行动可以模拟时间的流逝。在随机状态机模型中,你的动作可以是模型自身采取的动作(你只进行观测),也可以是你对模型采取的动作。在生病的例子中,我可以采取一些行动让自己感觉好些,或者更好地了解病情,例如:服用抗生素、多休息多喝橙汁、或正常生活。
- 公式表示:
P(S' | S, A),即在状态S下采取动作A后,转移到新状态S'的概率。
- 公式表示:
状态估计的步骤 🔄
现在,我将逐步讲解状态估计的一个步骤。每个状态估计步骤都是相同的。事实上,如果你基于前一步获得的信息执行多个步骤,这就被称为递归状态估计。我将继续使用生病的例子。
当你进行状态估计时,你试图弄清楚一个你无法完美建模的系统的某些信息。你拥有随机状态机模型的所有组件。由于时间的流逝,或者由于进行了观测并观察到了随机状态机采取的动作(或你对它执行了动作),你将对该不可知系统(或对你来说不完全可观测的系统)的当前状态做出一个新的估计。简而言之,你将求解 S 在时间 T+1 的概率分布 P(S_{T+1})。
状态估计分为两个步骤:
第一步:贝叶斯推理
第一步被称为贝叶斯推理步骤,它涉及对当前状态分布应用贝叶斯规则,给定一个特定的观测。
此时,我已经对自己进行了一些观察。如果以生病模型为例,我可能观察到自己整天咳嗽、发烧、喉咙痛或感到极度嗜睡。给定这个观测 O,我可以从观测分布中取出 P(O|S),将其乘以我当前对状态分布的理解 P(S),然后除以 P(O) 进行归一化。
完成此操作最慢的方法是构建联合分布,然后以特定列为条件。这是非常规范的做法,但你可以通过以下方式节省计算量:
假设我从均匀分布开始,即我患有链球菌性喉炎、普通病毒或单核细胞增多症的可能性相等。作为进行观测的结果,例如,我观察到喉咙后部没有白斑,我可以说,我处于普通病毒状态的可能性更高,而患有链球菌性喉炎或单核细胞增多症的可能性更低。
这个步骤计算 P(S) * P(O|S)。一旦我有了这些值,我必须将它们归一化,使其总和为1。这样我就得到了 P(S_T | O)。至此,我已经考虑了我所做的观测,但尚未考虑对系统采取的动作。
第二步:转移更新
我们将以贝叶斯推理的结果(有时称为 B'(S_T))作为输入,结合采取的动作,找出经过一个时间步或一次状态估计迭代后的状态分布。
第二步被称为转移更新。我们有了更新后的信念 P(S_T | O)。我们将使用转移分布,即给定当前状态和已采取的动作,系统会如何变化的规范。此时,我们将得到新状态的概率分布。
以下是我第一步得到的值作为示例。假设我采取的行动是“多休息多喝橙汁”。作为这个行动的结果,我继续患有链球菌性喉炎有一定的可能性,或者我实际上只是患有普通病毒的可能性也存在。
如果我患有普通病毒并且多休息多喝橙汁,那么我鼓励自己进入的状态将是“患有病毒”。如果我患有单核细胞增多症并且多休息多喝橙汁,那么第二天我仍处于类似单核细胞增多症的状态有一定的可能性,但也存在一些可能性使我进入类似链球菌性喉炎的状态。
这就是运行转移更新时发生的情况。当你运行转移更新时,最终会累积所有与进入一个特定新状态相关的概率,这些概率是基于处于特定先前状态并根据转移分布进入该新状态而产生的。一旦你累积了所有这些值,你就得到了新状态的新分布 P(S_{T+1})。
递归状态估计 ♾️
这代表了一步状态估计。如果我想运行多步,我会将这里得到的 S_{T+1} 的值,替换为 S_T 的值,然后再次运行相同的贝叶斯推理和转移更新过程。
总结
本节课中我们一起学习了状态估计。我们了解到状态估计是处理系统不确定性的关键方法,通过为随机状态机建立模型(包括初始分布、观测分布和转移分布),并迭代应用贝叶斯推理和转移更新两个步骤,我们可以基于不完全的观测来持续推断系统的内部状态。递归地进行这一过程,使我们能够随着时间推移不断更新对系统的认知。
024:搜索算法 🧭

在本节课中,我们将要学习搜索算法。搜索是计算机科学中的一个核心概念,它允许我们系统地探索所有可能的选项,以找到解决问题的最佳路径。我们将从简单的网格路径问题入手,逐步构建一个通用的搜索框架,并探讨不同搜索策略的优缺点。
概述

上一节我们介绍了概率和贝叶斯定理,用于状态估计。本节中,我们来看看另一个重要主题:搜索。搜索与规划相关,我们不再仅仅对给定情况做出反应,而是尝试找出未来应该采取的最佳行动。为此,我们需要考虑所有可能采取的行动,在这个空间中搜索,并找出“最佳”的那个。我们需要定义“最佳”的含义。
为了开始,我们先看一个简单的搜索问题:八数码问题。


这个问题的目标是制定一个计划,从我们称之为“状态”的初始配置,转换到我们称之为“目标状态”的配置。每次移动,你可以将其中一个方块移动到空格中。在初始状态下,我可以将8向右移动或将6向下移动,这是我唯一能做的两件事。我必须决定我想做哪一个,并且相信经过一系列移动后,我最终能够将这个状态转变为目标状态。

我们可以想象猜测。但我们需要估计猜测空间有多大。如果猜测空间只有四个元素,猜测是个好策略。但如果猜测空间大得多,猜测可能就不是一个好主意了。我之前在我们的搜索算法上运行了这个问题,以下是算法得出的解决方案。

如果你在数的话,我移动了22步。问题是,这个问题有多难?这个解决方案是好是坏?有更好的解决方案吗?为了计算这个解决方案,我必须做多少工作?为了理解这一点,让我们从一个简单的问题开始:有多少种配置?我用了22步到达目标。我必须查看的空间有多大?存在多少种不同的棋盘配置?思考20秒,和你的邻居讨论一下,看看是8的平方、9的平方、8的阶乘、9的阶乘,还是都不是。

那么,你看到了多少种棋盘配置?答案是9的阶乘。你可以这样想:如果你把所有方块拿出来扔在地上,然后一个一个放回去。放第一个方块时,你有9个可能的位置。放第二个方块时,有8个可能的位置。放第三个方块时,有7个可能的位置,依此类推。所以最终结果是9的阶乘。关键是,9的阶乘是一个很大的数字:362,880。所以,仅仅靠猜测可能效果不会太好。即使你不混淆自己,也有大约三分之一百万种不同的配置需要查看。如果混淆了,那就更多了。以计算机科学的标准来看,这不是一个巨大的问题,但肯定不是一个微不足道的问题。这不是一个仅靠猜测就能正确解决的问题。
因此,我们今天的目标是找出一种算法来进行这样的搜索。我们想要找出算法,分析其效果,优化它,并尝试找到最佳解决方案的方法。对于这个特定问题,“最佳”意味着最短路径长度。所以,通过考虑尽可能少的情况来找出最佳解决方案。显然,如果你枚举所有情况,那应该可行。问题在于,我们感兴趣的是解决那些枚举量非常大的问题,即使在这里,枚举量也相当大。
让我们思考算法,并通过一个更简单、更有限的问题来思考算法。如果我考虑一个可能位置的网格呢?也许这是曼哈顿街道的交汇点。我想从A点走到I点。哪条是最短路径?你可能都能算出来。但我想做的是编写一个能找出答案的算法。如果我们写得好,就能将其用于方块问题,而方块问题就不那么容易了。
我们思考这个问题的方式是,将穿过迷宫的所有可能路径组织成一棵树。

如果我从A开始,我需要做一个决定:我可以去B或D。然后,如果我去了B,我可以再去A、C或E。我按字母顺序组织它们,没有特别好的理由,只是我需要某种顺序。所以,如果我从A到B再到A,那么我可以从A再去B或D,如图所示。这个想法是有效的。
现在,原始问题是找到穿过某个网格的最短路径。我想从A到I,并考虑所有可能路径的树。问题是,对于我们即将研究的那类问题,这棵树在长度上可能是无限的。这意味着构建树并搜索它的策略可能不是一个好策略。因此,我们将尝试以这样一种方式编写算法:我们构建树并一次性搜索最佳解决方案。希望如果我们能在有限的步骤内找到解决方案,我们只构建了包含答案的那部分树。
这个想法是,通过思考所有可能路径的树来考虑我们想要采取的路径。但我们想做的是编写代码,在动态考虑所有不同节点的优劣时,即时构建这棵树。那么,我们该怎么做呢?
我们将在Python中工作。我们将所有可能的位置称为“状态”。问题将有状态A、B、C、D,我们用字符串表示它们,这使其灵活且任意。然后,我们通过程序来思考状态转移,而不是枚举它们。记住,我们不想枚举,因为可能有无限多个。我们将编写一个名为 successor 的过程。给定当前状态和动作,它将计算出下一个状态。这是一种我们可以增量构建树的方式。想象一下,如果我告诉你状态A,我采取动作0或1。如果我从A开始并执行动作0或1,我将分别到达B或D。所以我告诉你当前状态和当前动作,successor 程序将返回给你新的状态。这就是我们即时构建树所需的全部。
然后,为了指定感兴趣的具体问题,我必须告诉你从哪里开始。所以我必须定义初始状态。我还必须告诉你到哪里结束。我可以直接告诉你最终状态。但在我们想要处理的某些类型的问题中,可能有多个可接受的答案。所以我不想只给你最终状态,我会给你一个测试。我会给你另一个名为 goalTest 的过程。这个目标测试,当传入一个状态作为输入时,会告诉你是否达到了目标。这样,例如,所有偶数编号的方格都可以满足目标,如果那是感兴趣的问题的话;或者所有右边的状态都可以满足目标。这只是一点灵活性。
因此,为了表示那棵树,我们将通过指定一个名为 successor 的过程,以及指定起始状态和目标测试来实现。以下是我如何为开始时提到的简单曼哈顿问题设置这些。
我最终想要一个名为 successor 的东西,它接收一个状态和一个动作。我将曼哈顿的结构构建到一个字典中。该字典为每个状态(A、B、C、D、E、F、G、H、I)列出可能的下一个状态列表。所以如果我在A,我接下来可以去B或D。我任意地按字母顺序组织这些,以便记住发生了什么。所以下一个状态都按字母顺序排列。下一个状态的数量取决于状态。我不太担心这个。我只是将动作指定为一个整数:0、1、2、3,无论我需要多少个。所以可能的动作取自该列表,可能的动作可能是执行动作0、执行动作1、执行动作2。所以如果我从状态E开始执行动作2(动作从0开始:0、1、2),我将到达状态F。都清楚了吗?初始状态是A,目标状态是当S等于I时返回True,否则返回False。
这足以完全指定树了,但我还需要在Python中构建这棵树。不出所料,根据我们过去的面向对象经验,我们将使用面向对象的表示来表示那棵树。所以我们将指定树中的每个节点作为 SearchNode 类的一个实例。
SearchNode 很简单,它只知道是什么动作让我到达这里,我的父节点是谁,以及我当前的状态是什么。所以当你创建一个新节点时,你必须告诉构造函数这三件事:是什么动作让我到达这里,我当前的状态是什么,以及我的父节点是谁。知道节点,你就应该知道带你到这里整个路径。所以我们还会添加一个报告路径的方法。所以如果我恰好在节点E,我的路径应该是:我从A开始,采取动作0到达B,然后采取动作2到达E。这个子程序就是为了做到这一点。如果我的父节点是None(这将在初始状态发生),只需报告到我这里的路径是None和A。然而,如果我不是初始节点,那么找出到我父节点的路径描述,并添加让我到达这里的动作和我的状态。这就是它的作用。

我们通过告诉你 successor 函数、起始状态和目标测试来指定一个问题。然后我们提供一个类,你可以用它来动态构建搜索树的节点。现在,我们准备好编写算法了。

以下是算法的伪代码。我们做什么?我们初始化。我们将进行搜索。为了解决搜索问题,我将搜索树。所以,我将通过我们称之为“议程”的东西来思考我搜索树的状态。这是一个非常专业的词,我为此完全道歉。我没有发明它,但每个人都这么叫它。
议程是我当前正在考虑的节点集合。我将其初始化为包含起始节点。然后,我将系统地一遍又一遍地重复相同的事情:从议程中取出一个节点,思考它,用它的子节点替换那个节点。在我这样做的时候,两件事应该发生:我应该构建搜索树,但同时我也会留意是否刚刚构建了一个作为答案的子节点。因为如果我刚刚构建了答案,我就完成了。
初始化议程以仅包含起始节点,然后重复以下步骤:从议程中移除一个节点,将该节点的子节点添加到议程中,并继续直到发生以下两种情况之一:要么你找到了它(目标测试返回True),要么议程变空(在这种情况下一定没有解决方案)。如果我移除了所有选项仍未找到任何东西,那么就没有解决方案。
程序看起来如何?实际上非常简单,尤其是当你考虑到问题有多难时。想象一下,如果你想用非常简单的思维方式(如果这样,就那样;如果那样,就那样)来做那个方块问题,我们谈论的是大约三分之一百万个不同的“如果”,那可能不是正确的方法。所以这个程序最终大约有这么长,它将能处理这种情况甚至更困难的情况。
定义搜索过程。搜索过程将接收初始状态、目标测试、可能的动作以及 successor 过程。这是指定问题所需的一切。它将返回给我最优路径。
第一步:初始化议程以包含起始节点。我想放入起始节点。有可能起始节点就是答案。所以先处理这种情况。如果你已经在那里了,返回答案。到答案的路径就是我。我正在尝试创建议程。我正在尝试将第一个节点放入议程。有可能第一个节点就是答案。如果第一个节点是答案,返回到我的路径,即不采取任何动作,你就在这里。但这可能不是我们问的那类问题的情况。在这种情况下,我们将创建一个包含一个节点的列表,该节点代表起始节点。

然后重复:从议程中移除一个节点(我们称之为父节点),并用其子节点替换它。当议程不为空时(empty 是某种我将在一分钟内填写的伪程序),从议程中获取一个元素,我们称之为父节点。然后,我想考虑所有子节点。有一个可能的动作列表。所以对于动作列表中的每个动作A,执行以下操作。每个父节点可以有多个子节点,每个可能动作对应一个。所以对于动作列表中的每个A,计算出新状态是什么。新状态就是父节点状态在给定动作A下的后继。记住,父节点是一个节点。父节点是搜索树中的一个节点,但节点知道它们的状态。所以计算出新状态,即我的父节点(我从议程中取出的那个)的后继。创建一个对应于这个子节点的新节点。然后问一个问题:新状态是否满足目标测试?如果满足,答案就是到新节点的路径。所以创建一个新状态,即父节点在给定动作A下的后继。创建一个新节点,检查它是否是终点。如果是,直接返回,我完成了。否则,添加(同样,这是一个伪过程,我将在一分钟内填写)。将新节点添加到议程中。
当我运行这个循环时,可能会发生几件事。如果节点没有子节点,那么我取出父节点后不会放回任何东西。如果节点有多个子节点,我可能取出一个节点并放回比取出时更多的节点,所以议程可能会变长。因此,议程的长度可能因循环而增加或减少。同时,我们可能识别出一个目标,也可能未能识别出目标。所以随着议程的增减,我们可能会也可能不会找到答案。
现在,技巧在于,唯一使这变得复杂的是顺序很重要。那些伪操作,无论它们是什么。具体如何获取元素以及如何将其添加到议程中,会影响我进行搜索的方式。
让我们想一些非常简单的东西。总是移除第一个项目,即从议程中移除第一个节点,并用其子节点替换它。所以取出第一个节点,并将其子节点放回议程的开头。那么,在步骤0中,我将起始节点放入议程。现在议程中有一个元素,即起始节点。然后,在循环的第一次遍历中,我将取出议程中的第一个节点(议程中只有一个节点,所以那就是第一个)。取出第一个节点,并用其子节点替换它。它的子节点是A B和A D。我通过路径来表示这些节点。因为相同的状态可能在树中出现多次。注意,我可以走A B A B,这对应于树中重复出现的相同状态,所以当我在这里写下来时,不能用状态来表示节点,但我可以用路径来表示节点。
在循环的第一次遍历中,取出议程中的第一项,即A,并将A的子节点放回。A的子节点是A B和A D。现在,在第二次遍历中,规则是取出第一个节点并用其子节点替换它。现在第一个节点是A B。所以我在这里。取出那个节点,并用其子节点替换它,它的子节点是A B A、A B C和A B E。A D被留下来了。议程中的元素数量变多了。下一步,取出议程中的第一项,并用其子节点替换它。议程中的第一项是A B A。A B A的子节点是A B A B和A B A D。
注意正在发生的情况的结构。忽略底部的内容,只看顶部的图片。我从将A放入议程开始,然后是它的子节点,然后是子节点的子节点。所以当我实现算法时,取出第一个并用其子节点替换它,我是在沿着深度优先搜索。我越来越深入树中,而没有完全探索所有横向空间。所以我正在沿着那条线向下追踪。想象一下这棵树(这里我只表示了前三层节点),这棵树是无限的,因为你可以在那个曼哈顿网格中永远走下去。所以尽管我只列出了前三层,但原则上树是无限的。这个算法将具有沿着左边缘行走的特性。我们称之为深度优先搜索,因为我们首先探索深度,而不是广度。这是因为我们的规则:用其子节点替换第一个节点。

让我们思考一个不同的规则:用其子节点替换最后一个节点。现在,我们通过将代表起始状态的节点初始化为议程来开始。所以那是A,路径A。然后重复:取出议程中的最后一个节点。那是A,并用其子节点替换它。它的子节点仍然是A B和A D,和之前一样。现在,答案与之前的答案不同,因为当我取出最后一个节点时,我现在取出的是A D。现在我用其子节点替换A D,即A D A、A D E、A D G。重复。我得到的是一个不同的,但仍然是深度优先的搜索。所以我看了两种不同的排序:从议程中取出第一个节点并用其子节点替换它;取出最后一个节点并用其子节点替换它。这两种算法都给出了决策树的深度优先探索。所以它会尝试在尝试探索宽度之前,穷尽整个深度。

作为替代方案,考虑一个稍微复杂一点的规则:从议程中移除第一个元素,并将其子节点添加到议程的末尾。所以用起始状态初始化它:A。从议程中移除第一个元素,那是A,并用其子节点替换它,即A B、A D。现在取出第一个节点,第一个节点是A B。并将其子节点放在末尾。它的子节点是A B A、A B C、A B E。现在它们被放在末尾。所以在下一步,我将取出A D(开头的节点),并将A D的子节点放在末尾,等等。想法是暂时不要注意底部,只考虑你在顶部看到的模式。
在这种顺序中,我们移除第一个节点并将其子节点放在议程末尾,这具有探索广度的效果。所以我们称之为广度优先搜索。想法是,我们拥有这套通用工具,可以让我们构建搜索树,但我们操作议程的顺序在如何进行搜索方面起着关键作用。体现两种极端情况的是:如果我替换最后一个节点及其子节点会发生什么,或者如果我移除第一个节点并将其子节点放在末尾会发生什么。这两种结构有名称,因为它们经常发生。我们将第一种称为栈,第二种称为队列。


基于栈的将给我们深度优先。基于队列的将给我们广度优先。那么,如何思考栈?你可以把栈想象成一叠盘子。这是我的桌子,我要把盘子叠起来。我要把它们放在一个栈上。所以我创建一个栈。我推入1,推入9,推入3,推入1,推入9,推入3。这就是我操作栈的方式。然后弹出。当我弹出时,3出来。然后弹出,9出来。然后推入-2。然后弹出,现在-2出来。这是基于栈的。所以后进先出。这就是我们想要用于深度优先搜索的规则。实现这个非常容易,我们可以将其实现为一个列表。我们需要做的就是小心如何实现推入和弹出操作。
如果我们设置推入操作简单地追加到末尾,然后弹出操作通常从末尾弹出,我们将获得栈的行为。这给了我用于获取元素和添加的那些过程的规则。我将使用这些基于栈的操作符。另一种选择是队列。队列是不同的。队列就像你在商店排队一样。队列是:我这里有一个队列,服务器在那边。第一个进入队列的人,我推入1,所以现在1进入队列。然后当第二个人排在第一个人后面时,另一个人走过来。然后我推入3。但队列的工作方式是,当我从队列中弹出下一个人时,我取队伍最前面的人。所以1出来。如果我再次弹出,9出来。如果我然后推入-2并弹出,那么队列中的下一个人出来,就像那样。
所以是基于队列还是基于栈。基于队列的是我们想要用于广度优先组织的。队列的实现与栈的实现只有非常微小的不同。唯一的区别是,我将通过从队列头部弹出来操作列表。所以弹出接受一个可选参数,当指定为0时,它告诉你要弹出哪个元素。所以当你指定0或1时,它从队列头部取出。
这使得现在用实际过程替换伪过程变得非常容易。如果我想实现深度优先搜索,我将用创建一个包含议程的栈来替换“创建一个包含议程的列表”。所以创建一个新栈。议程是一个栈。然后,我不是简单地将起始节点放入列表,而是将其推入栈中。所以议程是一个栈。议程.推入初始节点。然后每次我想取出一个新元素时,议程.弹出它。每次我想放入东西时,议程.推入它。除此之外,它看起来和我之前展示的伪代码一模一样。
这就是深度优先搜索的实现。如果我想做广度优先搜索,很简单,把“栈”这个词改成“队列”。现在创建一个作为队列的议程。但是队列具有与栈相同的操作:推入、弹出和判空。所以程序中的其他部分都没有改变。我需要做的就是改变容纳议程的东西的结构,其他一切都会随之而来。
你可能已经注意到了。这就是我们需要的全部。现在,我想做的是通过例子来思考,并思考不同类型搜索的优缺点。我想继续我在第一张幻灯片中提到的第二步:我想思考如何优化搜索。正如我所说,即使是那个简单的小方块问题,即使是八数码问题,也有三分之一百万个不同的状态。我不一定想查看所有状态。我现在想思考这些不同的搜索策略,它们彼此之间的最优性如何,以及是否有改进的方法。
现在,你们中的一些人可能已经注意到,所有这些路径似乎并不都一样好。所以花一分钟思考一下。记住问题,问题是这个在曼哈顿行走的问题。我想从A到I。这是所有可能从A到I路径的树。我想让你思考的是,是否所有这些路径都很重要?我们能否去掉一些?问题是,我能否丢弃一些终端节点?注意,我在这里使用“终端”这个词有点奇怪,树会继续生长。树实际上是无限长的。所以“终端”我指的是这第三行。那么,我能丢弃第三行中的一些节点吗?具体来说,我能丢弃多少个:0、2、4、6还是8?
如果你在曼哈顿从A走到I,如果你在这个循环中转了一圈又回到A,那可能是一条糟糕的路径。所以重新访问一个你已经去过的地方可能是个坏主意,如果你的目标是以最短的可能距离从A到I的话。所以我想识别出我回到起点的情况。例如,那个A是坏的,那意味着我回到了起点,我只是重新开始了。所以如果我考虑那些,我可以用红色标出所有我重复的地方。所以A B A,之后发生什么我并不关心。A B C B,那是B再次出现,所以那只是愚蠢的,对吧?所以实际上,我可以通过简单地去掉愚蠢的部分来移除相当一部分树。不要重新开始路径。这意味着如果你来到一个以前去过的地方,就停止在那里寻找。这不是正确的答案。你可以看到,我实际上删除了一半的树。所以第三行上的节点数是16,其中8个具有重复的属性。
所以这是一个非常简单的移除规则。你在想一个更高级的规则。所以如果你看到,如果有一条到特定地方的更短路径,就不要看更长的路径。你完全正确。所以实际上,可能有更严格的剪枝可以做。所以可能有一个比8更大的答案。你完全正确。但让我暂时忽略这一点,大约四张幻灯片后再回来讨论,你完全正确。
所以我们现在想做的是,将丢弃愚蠢路径的想法形式化,以便我们可以将其放入算法中。我们将把这视为剪枝规则。第一条剪枝规则是简单的:不要考虑任何访问同一状态两次的路径。这没有涵盖你的情况,但它确实涵盖了这里的8种情况。这很容易实现。我们需要做的就是在我们思考这是否是一个要添加的好状态时,问一下:它是否已经在路径中?所以如果我即将放入路径的状态已经在路径中,就不要放进去。如果你不把它推回议程,它就会被遗忘。所以在把它推入议程之前,问自己一个问题:它是否已经在路径中?
我在这里这样做:记住,我弹出了一个称为父节点的元素。我正在查看子节点。子节点的状态称为新状态,所以我问:新状态是否在父节点的路径中?所以父节点获取新状态的路径。这意味着我必须编写 inPath。inPath 很容易,特别是如果我们使用递归。inPath 说:如果我的状态是状态,则返回True。如果那不是真的,并且我没有父节点,那么这意味着我是起始状态。这意味着它不在路径中。如果这两者都不成立,问我的父节点同样的问题。所以这使其递归,对吧?
考虑两种情况,可能使其为真或假。如果当前所在的节点恰好是相同的状态,则为真;如果我递归回起始状态,并且尚未找到它,则为假。所以有两个终止状态:我落在路径中与新状态相同的状态上,或者我一路递归回起始状态但没有找到它。这两种情况通过返回True和返回False来终止。另一种选择是我不知道答案,问我的父节点。所以在 inPath 上递归,并要求我的父节点做同样的事情。这就是我可以实现剪枝规则1的方式。
现在剪枝规则2:如果多个动作导致同一状态,只考虑其中一个。这在曼哈顿网格问题上不会发生,但你可以想象存在三种不同操作可以到达同一状态的搜索情况。事实上,你上周编写机器人代码时看到过一些这样的情况。你可以通过在那里并向左移动(撞到墙)到达大厅尽头的状态,或者通过在这里并向左移动到达那里。两者都让你在同一个地方。所以如果你在计划搜索,你不需要区分它们,因为它们需要相同的步数。由于它们需要相同的步数,我们不需要进一步搜索,所以我们可以合并它们。这被称为剪枝规则2。这也容易实现。我们做的是跟踪每个父节点的所有子节点。如果父节点已经有一个在那个位置的子节点,丢弃多余的子节点。这听起来不太好。
所以跟踪我有多少个子状态。创建一个列表。如果新状态不满足目标,检查它是否已经在子节点列表中。如果已经存在,跳过,什么都不做。否则,执行剪枝规则1,然后在将其推入议程之前,也将其推入新子节点列表。这是一种确保如果有多条路径到达同一状态,你只跟踪其中一条的方法。所以这是一个额外的剪枝规则。
现在让我们思考如何实现这些。让我们思考一个我们想要应用深度优先搜索的问题的解决方案。在这个曼哈顿问题上,从A到I。让我们思考一下。所以让我们上去。
我想思考如何将深度优先搜索应用于那个问题。思考议程。议程,我将其初始化为对应于起始状态的节点。所以那是A。我正在做深度优先。深度优先的规则是什么?弹出最后一个节点,用其子节点替换它。所以弹出最后一个节点。最后一个节点是什么?最后一个节点是A,用其子节点替换它。A的子节点是什么?有两个:A B和A D。所以我完成了第一层的循环。然后弹出最后一个节点。那是A D。用其子节点替换它,A D的子节点是什么?D能做什么?D可以去A、E或G。A是愚蠢的,所以我不想要那个。所以我考虑E和G。所以A D E,A D G。顺便说一句,如果我错了请阻止我,这真的很尴尬。弹出末尾。A D G,A D G的可能子节点是什么?A D G,可以回到D,但那是愚蠢的。所以A D G H似乎是唯一好的。弹出最后一个,A D G H。它的子节点是什么?A D G H有子节点E和I。H、E、G和I,但我不想要G,因为那是愚蠢的,所以A D G H E或I。那个赢了,因为我到达了I。每个人都看到我做了什么吗?我试图手动推演算法。
所以想法是,我访问了1、2、3、4、5、6、7,然后我找到了它,所以我做了7次访问。我得到了正确答案。这两者都很好。7是一个小数字,并且得到了正确答案。这两者都是好事。一般来说,如果你思考深度优先搜索的工作方式,它不会适用于每个问题。它恰好适用于这个问题。事实上,它对这个问题非常好,但它不会适用于每个问题,因为它可能会卡住。它可能会在无限域的问题中永远运行。这个问题有无限域。所以如果我明智地选择起始和结束状态,我可以让它陷入无限循环。这是深度优先搜索的一个特性。即使它找到了一条路径,它也不一定找到最短路径。但它在内存使用上非常高效。所以它不是一个完全愚蠢的搜索策略,但它通常是愚蠢的。
让我们思考广度优先搜索作为替代方案。同样,我们需要做的就是切换思考栈与队列的想法。从开头取出,添加到末尾。这就是队列的工作方式。所以现在让我们用广度优先搜索做同样的问题。我从议程开始。我放入A。我从队列头部弹出,并将子节点放在末尾,所以我从开头弹出并将子节点A B、A D放入。那看起来是对的。那是第一次遍历的结束。现在我从开头弹出并放入子节点。A B的子节点是什么?A B可以去A、C、E,A是愚蠢的,所以A B C或A B E。A B C或A B E,那看起来是对的。现在弹出这个节点A D,并将其子节点放在末尾,A D,那是A D E和A D G。我想我还没出错。弹出第一个节点A B C,放入其子节点。A B C,A B C可以去B或F。看起来F是唯一有意义的。A B C,那看起来是对的。A B E放入其子节点。A B E,E可以去B(愚蠢的)、D、F或H。D、F,谢谢。哦,我应该做A B E后面跟着什么。A B E后面跟着什么?我不想要B。D可以。F可以,H可以。D、F和H。到目前为止,哦,不对,错了,为什么错了?哦,这里,在上面那个?谢谢。下一个。弹出A D。这就是为什么我们有电脑,我们通常不手动做这个。所以A D E。如果我有A D E,我可以去B,那似乎可以。D似乎不好,F或H。所以看起来是B、F、H。A D G。A D G,看起来H是我唯一的选择。A B C F。A B C F,看起来我可以去E或I。最后。现在唯一的问题是我是否得到了正确数量的状态。假设我得到了1、2、3、4、5、6、7、8、9、10、11、12、14、15、16。这恰好是正确答案,至少是我今天早上吃早餐时得到的答案。
所以我刚刚做了什么?我刚刚做了广度优先搜索。这是一个记录,16次匹配,很好。广度优先搜索具有不同的属性。注意它花了我更长的时间。但因为它是广度优先,并且每一行对应一条递增的路径,它总是保证给你最短的答案。这很好。所以它总是给你最短的答案。它需要更多空间,你可以在黑板上看到,对吧?而且,它仍然没有处理你的问题。所以这似乎仍然有太多工作。我查看了16个不同的地方,做了16次访问。这完全不对,因为只有9个状态。怎么可能访问次数比状态数还多?这听起来不对,正是因为你的观点。所以我们可以使用另一个想法,称为动态规划。
动态规划中的思想原则是:如果你思考一条从X经过Y到Z的路径,那么从X经过Y到Z的最佳路径是两条路径的和:从X到Y的最佳路径和从Y到Z的最佳路径。如果你思考一下,情况一定是这样。并且,如果我们进一步假设我们将进行广度优先搜索,那么我们第一次看到一个状态时,就是到达那里的最佳方式。所以为了处理你的情况,我们可以做的是跟踪我们已经访问过的状态。如果我们已经访问过一个状态,它出现在树中更早的位置,就没有必要进一步考虑它。这就是动态规划的思想。
这也容易实现。我们需要做的就是跟踪所有我们已经访问过的地方。所以我们创建一个名为 visited 的字典。在初始化之后,在我开始查看子节点之前,就在我设置议程的初始内容之后,我创建这个名为 visited 的字典。每次我访问一个新节点,我将该状态放入访问列表。然后,在我将子节点添加到议程之前,我问:子节点是否已经在访问列表中?如果子节点已经在那里,那么忘记它,我不需要它。否则,就在你推入新状态之前,记住现在这是一个已被访问过的元素。
所以想法是,通过跟踪你已经查看过谁,你可以避免重复查看。所以如果有一条深度为3的路径到达D,和一条深度为2的路径,我不需要担心前一条,因为它已经在访问列表中了。为什么我们仍然需要新的子节点修复?为什么我们仍然必须处理子状态?我想我需要思考一下,我认为你是对的,我认为我修改不同地方的代码时可能可以移除那一行。我认为你是对的。我必须思考一下,但我认为你是对的。所以如果那一行神奇地从在线版本中消失,他是对的。
现在最后一个问题,我想看看是否能弄清楚动态规划会发生什么。我想做广度优先搜索。作为警告,我现在血糖低,所以可能会有比平时更多的错误,所以我需要跟踪两件事:访问列表和议程。有两个列表我需要跟踪。
让我们从议程包含起始元素A开始。这意味着我们访问了A。这是广度优先。所以我想从队列中取出第一个节点,并将其子节点添加到队列末尾。从队列中取出第一个节点。添加其子节点。A的子节点是B和D。这意味着我现在访问了B和D。现在,我想从队列中取出第一个节点A B,并将其子节点放在队列末尾。A B的子节点是A(已访问)、C(未访问)和E(未访问)。所以A B C、A B E。但这访问了C和E。现在我想取出A D,并将其子节点放在末尾。A D是A、E、G。A已访问,E已访问,所以只有G,所以A D G。这访问了G。然后我想取出A B C,放入其子节点。A B C,A B C可以是B或F。B或F,B不好,只剩下F,但这访问了F。所以现在A B E。A B E,可以是B、D、F、H。B已访问,D已访问,F已访问,H。这访问了H。现在取出A D G。A D G的子节点,A D G,两个子节点:D和H。D和H,它们都在那里。那没用。A D G没有子节点。A B C F。A B C F,三个子节点:C、E、I。C已访问,E已访问,I。找到正确答案。1、2、3、4、5、6、7、8次访问。所以我得到了相同的答案。它是最优的。我做了少于9次访问。9是状态数。所以这个算法将始终具有这些属性。所以动态规划,好吧,我忘了那一行。所以带有广度优先搜索的动态规划将始终找到最佳路径。它永远不会超过状态数,所以在这个有有限状态数的问题中,即使它有无限条路径(因为你可以绕圈子),它也永远不会超过状态数。实现它只需要维护两个列表而不是一个。
所以今天的要点是,我们看了两种真正不同类型的搜索算法:深度优先搜索和广度优先搜索,并且我们看了几种不同的剪枝规则:剪枝规则1(不要去你已经访问过的地方)、剪枝规则2(如果你有两个子节点去同一个地方,只考虑其中一个)。你可以将动态规划视为第三条剪枝规则,因为这就是它的效果。

总结
本节课中我们一起学习了搜索算法。我们从简单的八数码和网格路径问题入手,构建了一个通用的搜索框架。我们定义了状态、动作、后继函数和目标测试来形式化问题。我们引入了 SearchNode 类来表示搜索树中的节点。核心的搜索算法通过维护一个“议程”来工作,并重复从中取出节点、扩展其子节点、并检查是否达到目标。
我们重点探讨了操作议程的顺序如何决定了搜索策略:
- 深度优先搜索 🧗♂️:使用栈(后进先出)。它沿着一条路径深入探索,内存效率高,但可能找不到最短路径,甚至陷入无限循环。
# 栈操作示例 agenda = [] # 用列表模拟栈 agenda.append(node) # 推入 parent = agenda.pop() # 弹出(从末尾) - 广度优先搜索 ↔️:使用队列(先进先出)。它逐层探索,总是找到最短路径(如果存在),但可能需要更多内存。
# 队列操作示例 from collections import deque agenda = deque() # 使用双端队列 agenda.append(node) # 推入(到末尾) parent = agenda.popleft() # 弹出(从开头)
为了优化搜索,避免无效工作,我们介绍了剪枝规则:
- 避免循环:不扩展那些会导致状态在路径中重复出现的节点。通过检查新状态是否已在从当前节点回溯到根节点的路径中来实现。
- 合并等价路径:如果同一个父节点的多个动作导致相同的子状态,只保留其中一个进行扩展。
- 动态规划/图搜索:维护一个全局的“已访问”状态集合。一旦一个状态被以某种路径访问过,就不再考虑其他更晚或更长的路径到达同一状态。这在广度优先搜索中能保证找到最优解,且探索的节点数不超过总状态数。

最后,我们通过手动推演,比较了不同策略在简单网格问题上的表现,直观理解了它们的特性和适用场景。搜索是解决许多规划、优化和人工智能问题的基础工具。
025:搜索


概述
在本节课中,我们将要学习搜索的概念。搜索是一种使自主系统能够评估一系列决策或行动的方法,特别是在每个步骤都存在多种选择时。通过学习搜索,我们的自主系统将能够完成一系列复杂的动作。
状态空间搜索
上一节我们介绍了自主系统需要做出复杂决策。本节中我们来看看如何通过搜索状态空间来实现这一点。在6.01课程中,我们将搜索状态空间,这借鉴了状态转移图或状态机的许多思想。
搜索状态空间时,我们需要知道:
- 状态:我们正在搜索哪些状态。
- 转移:状态之间的转移关系,或者如何从一个给定状态访问其所有相邻状态。
- 起始状态:需要指定起始状态,以便知道从哪里开始。
- 目标测试:用于指定搜索的目标,或者判断是否到达了目标状态。它可以检查状态的输出或状态名称本身。
此外,在搜索过程中,我们还需要一个合法行动列表。虽然我们今天可以一次性看到整个状态转移图,但机器人无法做到这一点。因此,我们为系统提供一个在给定状态下应该尝试的所有行动的列表。并非每个行动在每个状态下都能成功转移,但拥有一个详尽的列表有助于尝试所有可能性并观察结果。
搜索树与路径
能够进行搜索固然很好,但我们还需要记录搜索的过程和路径,以便在搜索完成后能够执行最佳方案。我们将使用搜索树来跟踪我们到过的地方以及如何到达那里。
搜索树由节点组成,它类似于一个有向无环图,与我们搜索的特定状态转移图有很多相似之处。但节点与状态不同。节点代表了你访问过的状态,以及你如何到达那里:它包含了状态本身、父节点(你来自哪个节点)以及使你从父节点到达该节点的转移或行动。
跟踪节点列表被称为路径,它指定了你到过的地方以及如何到达的。如果你在一个给定的节点,你可以利用对父节点的引用和行动信息,从当前节点回溯到其父节点,再回溯到父节点的父节点,最终回到起始状态。这样你就知道了该采取什么路径。
议程与搜索策略
剩下的问题就是:如何确定首先探索哪些路径?这就是议程的作用。议程是所有部分路径的集合,这些路径是你在扩展路径和节点时创建的,用于未来的扩展。
你向议程添加和移除项目的顺序将决定你的搜索树的样子,也就是你的搜索策略。接下来,我们将通过一个例子来探讨两种基本的搜索策略。
示例:两种基本搜索策略
假设我们要搜索以下状态转移图。我们从状态 A 开始,目标测试是判断状态是否等于 E。
今天我们将尝试两种不同的基本搜索:
- 广度优先搜索:在搜索树中,在进入下一深度之前,会彻底扩展给定深度的所有节点。这种方法非常彻底,并且保证能找到从起始节点到目标的最短路径(如果存在的话)。
- 深度优先搜索:与广度优先搜索相反,它会尽可能深入地扩展给定分支中的所有节点,然后再转向下一个分支。它占用的空间比广度优先搜索少得多,但不保证能找到最优路径。
另一种理解这两种搜索的方式是:
- 在广度优先搜索中,议程充当队列。最先发现的项(部分路径)最先被取出扩展。
- 在深度优先搜索中,议程充当栈。最后添加的项(最近访问的部分路径)最先被取出扩展。
让我们通过状态转移图进行几次迭代,希望能清楚地展示这个过程。
第一次迭代:
首先访问并扩展起始节点 A。路径 [A] 将被添加到两个议程中。节点 A 将在两个搜索树中首先被访问。
后续迭代(假设按字母顺序访问新节点):
从 A 可以到达 B 和 C。我们将把部分路径 [A, B] 和 [A, C] 添加到议程中。
以下是两种搜索策略的区别:
- 广度优先搜索:遵循先进先出规则。
[A, B]先入队,所以先扩展 B。从 B 可以到达 C 和 D。我们将部分路径[A, B, C]和[A, B, D]添加到议程末尾。然后,我们从议程前端取出[A, C]来扩展 C,并将[A, C, B]和[A, C, D]添加到议程末尾。 - 深度优先搜索:从议程的另一端(栈顶)获取。首先取出
[A, C]来扩展 C。从 C 可以到达 B 和 D。我们将[A, C, B]和[A, C, D]添加到议程顶端(栈顶)。注意,我们的搜索树已经看起来不同了。
找到目标:
在深度优先搜索的下一次迭代中,我们从议程顶端取出 [A, C, D] 来扩展 D。D 有一个到 E 的转移。在经典的广度优先和深度优先搜索中,我们在访问节点时运行目标测试。此时,我们会测试 E 是否为目标,发现它是,于是搜索成功返回找到的路径 [A, C, D, E]。
避免常见错误
如果我们继续广度优先搜索的下一轮迭代,从议程中取出 [A, B, C] 并扩展 C,C 的子节点是 B 和 D。这样我们会将 [A, B, C, B] 添加到议程,这将创建一个无限循环。
因此,需要强调两条基本搜索规则来避免此类问题(在课本中被称为“如何不犯傻”):
- 避免循环:如果你要访问的节点已经存在于当前部分路径中,不要将其添加到该路径。这将防止你创建循环,因为访问同一个节点多次意味着你做了不必要的工作。
- 处理多路径:如果状态转移图允许从一个状态到另一个状态有多个基于不同行动的转移,那么你需要制定某种规则来选择其中一个行动。第二条规则就是:如果你在搜索中发现从一个状态到另一个状态有多个转移,请选择一个,并制定规则来决定选哪一个。
总结
本节课中我们一起学习了搜索的基础知识。我们了解了如何通过定义状态、转移、起始状态和目标测试来搜索状态空间。我们引入了搜索树和节点的概念来记录路径,并使用议程来控制搜索顺序。我们重点探讨了两种基本策略:广度优先搜索和深度优先搜索,并分析了它们的特点(广度优先保证找到最短路径,深度优先占用空间少)。最后,我们学习了两个重要规则来避免无限循环和冗余路径。下周,我们将讨论动态规划、成本和启发式方法。
026:搜索算法(二)


在本节课中,我们将继续学习搜索算法。我们将扩展上一节介绍的框架,引入路径成本的概念,并探讨如何通过启发式信息来显著提高搜索效率。
上一节我们介绍了通过树状结构系统化组织搜索过程的方法,并强调了节点处理顺序的重要性。本节中,我们将看看如何将搜索框架推广到处理路径成本不同的情况。


统一代价搜索
到目前为止,我们只考虑了每个“动作”成本相同的问题,即只计算从起点到目标需要经过多少步。这是一个重要的类别,但并非唯一。例如,在曼哈顿网格寻路问题中,如果某个节点(如C)距离非常远,那么最小化步数就不再是最优解,因为从B到C的距离可能远大于从D到G的距离。
首先,让我们思考广度优先搜索(结合动态规划)会如何处理这个问题,并找出其不足之处。
广度优先搜索的局限性
广度优先搜索(结合动态规划)会按照“跳数”(hops)递增的顺序访问状态。它保证能找到步数最少的路径,但不一定是距离最短的路径。当我们将度量标准从“跳数”改为“距离”(英里数)时,该算法访问状态的顺序就不再是路径长度递增的顺序了。这就是问题所在。
我们的目标是修改算法,使其能按照路径成本(长度)从短到长的顺序探索路径。

引入优先级队列


实现这一目标的关键是引入优先级队列。在之前的搜索中,我们使用队列(先进先出)或栈(后进先出)来管理议程(agenda)。现在,我们将使用优先级队列。
优先级队列类似于队列,但每个元素都有一个关联的“优先级”或“成本”。当我们从队列中弹出(pop)元素时,总是弹出成本最低的那个。这样,我们就能按照路径成本递增的顺序来探索搜索树。

代码示例:优先级队列的简化实现
class PriorityQueue:
def __init__(self):
self.items = []
def push(self, item, cost):
# 将元素及其成本加入列表
self.items.append((item, cost))
def pop(self):
# 找到成本最小的元素
min_index = 0
for i in range(1, len(self.items)):
if self.items[i][1] < self.items[min_index][1]:
min_index = i
# 弹出并返回该元素
return self.items.pop(min_index)[0]
这个实现为了清晰起见,在每次弹出时都遍历整个列表来寻找最小成本项,效率不高。在实际应用中(如谷歌的搜索引擎),会使用更高效的数据结构(如堆)。但在这里,我们关注的是抽象概念,将实现细节封装在队列内部。
修改节点与搜索算法
我们需要对节点和搜索算法进行小幅修改,以纳入成本信息。
节点结构:现在,节点不仅需要记录状态(当前位置)、动作(如何到达此处)和父节点,还需要记录从起点到该节点的累计路径成本。在创建新节点时,我们需要传入执行该动作所产生的成本。
搜索算法:算法框架与广度优先搜索类似,但将议程从普通队列替换为优先级队列。此外,由于子节点的成本可能不同,我们无法在将子节点加入议程时立即判断哪个路径最短。因此,我们需要将目标测试从“扩展子节点”的循环中移出,推迟到下一次从议程中弹出节点时再进行。


动态规划:在统一代价搜索中,我们同样可以应用动态规划来避免重复探索状态。但这里的关键是,我们只记录那些已经被扩展的状态(即已从其出发探索过所有可能动作的状态),而不是所有被访问过的状态。因为只有当一个状态被扩展后,我们才能确定当前找到的到达该状态的路径是否是最优(成本最低)的。
通过引入路径成本和优先级队列,我们得到了统一代价搜索算法。它能够处理动作成本不同的问题,并找到总成本最低的路径,其核心思想仍然是系统性地探索由状态和动作构成的树。

启发式搜索
我们目前讨论的搜索算法都是“以起点为中心”的。它们从起点开始,一圈一圈地向外探索,直到偶然碰到目标。例如,从堪萨斯州搜索到波士顿,算法可能会先探索怀俄明州,然后才是马萨诸塞州,这显然不够智能。
我们希望让搜索过程对目标“有感觉”,从而偏向于朝目标方向探索。这可以通过启发式来实现。

启发式的概念
启发式是一个函数,用于估计从当前考虑的点到目标点的剩余成本。在决定下一步探索哪个节点时,我们不再只考虑从起点到该节点的已知成本 g,而是考虑 f = g + h,其中 h 就是启发式函数给出的估计剩余成本。
关键在于,计算精确的剩余成本 h 本身就是一个和原问题同样复杂的搜索问题。因此,启发式函数必须是一个易于计算的估计值,同时不能高估实际剩余成本(即必须是可采纳的)。如果启发式函数高估了成本,可能会导致算法错误地剪掉实际上的最优路径。
启发式搜索示例
以曼哈顿网格寻路为例,一个简单可采纳的启发式函数是当前点到目标点的曼哈顿距离(横向加纵向格数)。
假设从E点出发前往I点。在普通统一代价搜索中,E的初始成本为0。在启发式搜索(如A*算法)中,E的初始成本 f = g + h = 0 + 曼哈顿距离(E, I) = 0 + 2 = 2。
扩展E后,得到子节点B、D、F、H。计算每个子节点的 f 值:
- B:
f = g + h = 1 + 曼哈顿距离(B, I)=1+3=4 - D:
f = 1 + 3 = 4 - F:
f = 1 + 1 = 2 - H:
f = 1 + 1 = 2
此时,议程中 f 值最小的是F和H(均为2),算法会优先探索它们,从而让搜索方向明显偏向目标I,避免了向反方向(如B)做过多无用的探索。

启发式函数的设计
设计一个好的启发式函数是一门艺术。它必须是:
- 可采纳的:永远不会高估到达目标的实际成本。
- 易于计算的:计算开销不应太大。
- 尽可能接近真实成本:在满足可采纳性的前提下,估计值越接近真实成本,搜索效率通常越高。
以下是一个八数码游戏(8-puzzle)的启发式函数对比思考题:
- 启发式A:总是返回0。
- 启发式B:返回“错位方块的数量”。
- 启发式C:返回“每个方块当前位置到目标位置的曼哈顿距离之和”。
可以证明,这三个启发式函数都是可采纳的。因此,使用它们都能找到步数最少的解决方案(但具体路径可能不同)。然而,它们在搜索效率上差异巨大:
- 启发式A(零启发式)等价于不使用启发式,搜索范围最广。
- 启发式B提供了一些引导,减少了搜索范围。
- 启发式C(曼哈顿距离和)更接近真实剩余步数,能最有效地引导搜索,大幅减少需要扩展的状态数。
数据表明,在解决某个八数码问题时,使用启发式A需要访问约17万个状态,而使用启发式C仅需访问很少一部分状态。这充分说明了在复杂的高维搜索中,一个精心设计的启发式函数至关重要。
总结
本节课我们一起学习了搜索算法的两个重要扩展:
- 统一代价搜索:通过引入路径成本和优先级队列,我们扩展了搜索框架,使其能够处理动作成本不同的问题,并找到总成本最低的路径。其核心结构与广度优先搜索一脉相承。
- 启发式搜索:通过添加一个可采纳的启发式函数来估计剩余成本,我们可以显著提高搜索效率,使搜索过程偏向目标方向。A*算法是这一思想的经典实现。启发式函数的设计需要在可采纳性、计算复杂度和准确性之间取得平衡。

理解搜索的关键在于将其视为在状态空间树上进行的系统性探索,而通过改变节点探索的顺序(如使用优先级队列、加入启发式信息),我们可以控制搜索的方向和效率,以解决更复杂的问题。
027:智能搜索技术 🧠


在本节课中,我们将学习如何改进基础搜索技术,使系统能做出更智能的决策并节省计算时间。我们将探讨动态编程、成本考量以及启发式方法,并了解如何将它们结合使用。
概述
上一节我们介绍了基础搜索及其基本思想,即如何对搜索进行编码,以便系统在遇到未知领域或状态空间时能够使用搜索。本节中,我们将探讨如何利用已知信息以及对未知信息的估计,来提高我们最快发现目标路径的机会。
动态编程
为了改进搜索,我们可以做的第一件事是使用动态编程。
动态编程指的是这样一种思想:一旦你为某一特定类型的问题完成了计算,你可以保存该计算结果并在以后使用,而无需进行第二次计算。
在搜索中,这表现为:一旦你访问了某个特定状态,就无需再次访问它。因为根据你的议程设置,你已经找到了到达该状态的最快路径。
核心思想:一旦访问了某个特定状态,就无需再次访问。
如果你正在构建一个普通的搜索树,可能很难跟踪你去过的地方,尤其是在进行深度优先搜索时。为了克服这一点并启用动态编程,最简单的方法是保留一个列表,记录本次搜索运行中访问过的所有状态。
我将通过在我们的状态转移图上运行深度优先搜索来演示动态编程。前两步是相同的,除了我们会记录在搜索的前两次迭代中访问了A、B和C。如果我运行深度优先搜索,我的议程就像一个栈,这意味着我将取出最近添加到议程中的部分路径,弹出它,并扩展该部分路径中的最后一个节点。
当我扩展C时,我将访问B和D。然而,B已经在我访问过的状态列表中,因为它已经在我议程的某个部分路径中。因此,我不会再次访问B。相反,我只向我的议程添加一条新的部分路径:A->D。我也会将D添加到我的已访问状态列表中。
如果我运行深度优先搜索的另一次迭代,我找到了目标。为了完整性,将目标添加到已访问状态列表。这比原始的深度优先搜索花费了更少的迭代次数。我们没有浪费时间扩展不同的节点,通常也使用了更少的空间。
动态编程的概念在任何可能的情况下都值得使用,在搜索中可以节省大量时间和精力。
考虑成本
另一种智能改进搜索技术的方法是,考虑与状态转移图中特定转移相关的已知成本。
在下图中,我标注了特定状态之间转移的相关成本。

如果我们知道与转移相关的成本,那么我们可以利用遍历特定部分路径所累积的权重,来优先探索哪些路径,以减少最终行动的总成本。为此,我们可以跟踪与该累积相关的值,并在每次迭代中根据该成本对议程进行排序。
如果我们使用动态编程,同时考虑成本和启发式,并且在运行目标测试时,我们实际上会在扩展节点时进行考量,而不是在访问节点时。这个区别非常重要,因为它为我们提供了最优解。如果我们有可能访问目标节点,但距离有100个成本单位,那么寻找能提供更短路径的替代方案可能是值得的。这就是为什么我们从在访问时考量切换到在扩展时考量。
让我们看看结合动态编程运行的一致代价搜索。第一步,我扩展A,并向我的队列添加两条部分路径。我也会跟踪与该部分路径相关的累积成本。当我扩展A时,我将其添加到议程。请注意,我还没有讨论栈、队列、深度优先或广度优先。我们正在使用一个优先队列,这意味着优先级高的项(或成本最低的项)会浮到顶部,我们将首先考虑它们。
这意味着我将首先扩展部分路径A->B。我会将其添加到我的列表中。当我扩展B时,B有两个子节点D和C。路径A->B->C的相关部分路径成本为3,而路径A->B->D的相关部分路径成本为11。这意味着,当我重新排序我的队列时,最终会将所有项排序,使得A->C排第一,A->B->C排第二,A->B->D排第三。
之前,在我们的动态编程策略中,C不会被添加到这个部分路径中,因为我们已经访问过它(通过路径A->C)。在这一步,我们将扩展路径A->C,将C添加到列表。以后任何访问C的情况,我们都不会将其添加到我们的路径中。
如果我扩展C,我有一个到B的转移(B已在列表中),以及一个到D的转移(成本为7)。路径A->B->C将浮到我的优先队列顶部。路径A->B不会被考虑,因为B已在我的列表中。路径A->B->D将保持成本11。
此时你可能会说,Kendra,为什么我还要考虑部分路径A->B->C,既然C已经在我的列表中?即使我们扩展了部分路径A->C,如果我们没有在每次迭代中采取措施来清理我们的列表,A->B->C仍然会浮到顶部,我们仍然需要处理它。既然我们已经扩展了C,我们将忽略这个部分路径,只将A->D(成本7)和A->B->D(成本11)移到前面。
D不在我们的扩展列表中。当我们扩展D时,我们有一个子节点E。因为我们正在处理成本和启发式,我们实际上不是在访问节点时评估目标测试,而是在扩展节点时评估。所以我要将A->C->D->E添加到我的议程,它的成本将是8。而A->D(成本11)仍然会留在优先队列的后面。
此时,我扩展E。我在扩展D时跳过了将D添加到扩展列表(从A->C->D到A->C->D->E)。此时,我将扩展E,我要做的第一件事是测试它是否通过目标测试。在那一刻,我停止搜索,返回搜索成功完成,并且我的部分路径是A->C->D->E。
这就涵盖了一致代价搜索。
启发式方法
此时你可能会说,Kendra,这跟地图之类的东西很相似,我真的很希望能够利用我对欧几里得距离等知识的了解,来做出更智能的关于去向的决策。我会说,是的,你应该能够,事实上,人们确实一直在这样做。他们会说,嗯,某个地方直线距离有这么远,所以我知道如果我在任何一点走得更远,那我就浪费了一些时间,但它代表了对我将要覆盖距离的一个很好的低估。
这就是启发式的基本概念。如果你试图找到一个目标,并且对剩余成本有一个估计,但你知道它并不完全准确,你仍然可以使用这些信息来尝试节省一些计算量或搜索量。
特别是,如果你知道启发式是完美的,你可能不应该使用它,因为如果你知道启发式是完美的,那么你应该直接用启发式来解决问题,而不是首先进行搜索。或者,如果启发式已经告诉你找到某物需要多长时间,那么它可能也包含了代表那个成本的路径。
如果你想有效地使用启发式,你必须确保你的启发式代表了对剩余成本量的非严格低估。我的意思是,如果你有一个启发式,并且你用它来判断你是否在浪费时间,如果你的启发式代表的信息是错误的,或者说某条特定路径的成本比实际高,那么它会误导你。或者,你不想使用一个会阻止你使用实际成本低于启发式所宣传的路径的启发式。
这就是所谓的可采纳启发式。一个可采纳的启发式总是在估计你到目标的总距离时,如果出错,会低估。
A* 搜索
至此,我已经涵盖了动态编程、成本和启发式。事实证明,你可以同时使用所有这些技术。当你在评估优先队列时,结合使用成本和启发式,这被称为A搜索。你会看到相当多关于A的文献。
这就涵盖了本课程中我将讨论的所有对基础搜索的智能改进。
总结
本节课我们一起学习了如何通过动态编程避免重复访问状态来节省时间,如何利用转移成本来优先探索更经济的路径,以及如何使用可采纳的启发式来指导搜索方向。最后,我们了解到结合成本和启发式的A*搜索是一种强大的智能搜索技术。希望你们喜欢6.01的课程。

浙公网安备 33010602011771号