🟡 数据结构角度
数据结构可以从两个角度来分类:
- 逻辑结构:描述数据间的逻辑关系,决定了数据之间的关系。
- 物理结构 :描述数据在计内存中的物理存储方式,存储方式进而影响我们如何设计算法。
-
- 从计算机内存的物理特性来看,无论数据结构多么复杂,其底层存储最终都归结为顺序存储或链式存储。
- 顺序存储:数据在内存中连续排列,直接映射到线性地址空间(数组就是顺序存储的具象化)。
- 链式存储:数据通过指针分散存储,逻辑上通过链接形成关系(链表就是跳跃存储的具象化)。
逻辑: 线性
物理: 顺序"] Linear --> LinkedList["链表
逻辑: 线性
物理: 链式"] Linear --> Stack["栈
逻辑: 线性
物理: 顺序或链式"] Linear --> Queue["队列
逻辑: 线性
物理: 顺序或链式"] NonLinear --> Tree["树
逻辑: 非线性
物理: 链式(常见)"] NonLinear --> Graph["图
逻辑: 非线性
物理: 链式(邻接表)或顺序(邻接矩阵)"] %% 样式 classDef default fill:#f9f9f9,stroke:#333,stroke-width:1px; classDef main fill:#4285f4,color:white,stroke:#333,stroke-width:1px; classDef logical fill:#0f9d58,color:white,stroke:#333,stroke-width:1px; classDef physical fill:#db4437,color:white,stroke:#333,stroke-width:1px; classDef type fill:#f4b400,stroke:#333,stroke-width:1px; class DS main; class LS,PS logical; class Linear,NonLinear,Sequential,Linked physical; class Array,LinkedList,Stack,Queue,Tree,Graph type;
顺序存储就不用说了,位置隐含了逻辑关系。而链式存储的本质是通过指针(或引用)将分散在内存中的数据节点连接起来。因此不同数据结构之间的差异就是体现在指针的设计上:最基础的链式结构就是链表,每个节点只有一个指针指向下一个节点。然后复杂一点的比如二叉树 ,它每个节点有两个指针,分别指向左子节点和右子节点。再复杂一些比如图,它每个节点可能有多个指针,指向它的邻居节点(如邻接表)。还有跳表,每个节点有多个指针,指向不同层级的后续节点。
数组和链表是两种最原始的数据结构,所有其他数据结构都可以基于它们实现。
]:::titleClass BaseStructure --> Array BaseStructure --> LinkedList %% 数组的扩展 Array --> StackQueueArray["栈/队列
(顺序实现)"]:::arrayExtClass Array --> CompleteBinaryTree["完全二叉树
(数组实现)"]:::arrayExtClass Array --> AdjacencyMatrix["邻接矩阵
(图的表示)"]:::arrayExtClass Array --> HashTable["哈希表
(数组 + 链表)"]:::arrayExtClass %% 链表的扩展 LinkedList --> StackQueueList["栈/队列
(链式实现)"]:::listExtClass LinkedList --> BinaryTree["二叉树
(链式实现)"]:::listExtClass LinkedList --> AdjacencyList["邻接表
(图的表示)"]:::listExtClass LinkedList --> SkipList["跳表
(多级索引链表)"]:::listExtClass %% 样式定义 classDef arrayClass fill:#ffcccb,stroke:#ff6b6b,stroke-width:2px,color:black classDef listClass fill:#c2e0ff,stroke:#4a90e2,stroke-width:2px,color:black classDef arrayExtClass fill:#ffecb3,stroke:#ffa726,stroke-width:1px,color:black classDef listExtClass fill:#b2dfdb,stroke:#00897b,stroke-width:1px,color:black classDef titleClass fill:#f9f9f9,stroke:#666,stroke-width:1px,color:black,font-weight:bold
🔘逻辑结构:线性 or 非线性
plaintext
线性结构 非线性结构
A -> B -> C -> D 树 图
A A -- B
/ \ | |
B C C -- D
/ \
D E
🔘物理结构:链式 or 顺序
plaintext
顺序结构(数组)
内存地址: 0x1000 | 值: 10
内存地址: 0x1004 | 值: 20
内存地址: 0x1008 | 值: 30
内存地址: 0x100C | 值: 40
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
链式结构(链表)
节点1: 地址: 0x2000 | 值: 10 | 指针: 0x3000
节点2: 地址: 0x3000 | 值: 20 | 指针: 0x4000
节点3: 地址: 0x4000 | 值: 30 | 指针: NULL
链式结构(二叉树)
节点A: 地址: 0x5000 | 值: A | 左指针: 0x6000 | 右指针: 0x7000
节点B: 地址: 0x6000 | 值: B | 左指针: NULL | 右指针: NULL
节点C: 地址: 0x7000 | 值: C | 左指针: NULL | 右指针: NULL
链式结构(图)
节点A: 地址: 0x8000 | 值: A | 邻居: [0x9000, 0xA000]
节点B: 地址: 0x9000 | 值: B | 邻居: [0x8000]
节点C: 地址: 0xA000 | 值: C | 邻居: [0x8000, 0xB000]
节点D: 地址: 0xB000 | 值: D | 邻居: [0xA000]
🔘数据结构与算法的关系
不同的数据结构对应不同的操作需求,因此需要不同的算法来优化这些操作:
- 数组:适合随机访问,常用二分查找。
- 链表:适合动态插入/删除,常用遍历和反转算法。
- 栈和队列:适合特定的顺序操作,分别用于 DFS 和 BFS。
- 树和图 :适合表示复杂关系,常用遍历和路径规划算法。
🟡 访问方式
🔘顺序访问 ☆
从头到尾逐一访问数据元素 的方式。它的时间复杂度通常是 O(n)。
- 逐一遍历:无法直接跳转到任意位置,必须按照顺序逐步移动。
- 灵活性:适合动态插入和删除操作。
- 依赖非连续存储:数据可以分散存储(如链表)。
典型场景
- 链表:从头节点开始逐一访问每个节点。
- 文件流:按顺序读取文件内容。
- 磁带存储:只能按顺序访问数据。
🔘索引访问
通过索引快速定位数据,但索引本身可能需要额外维护。
- 类似于随机访问,但索引可能是间接的。索引可以加速访问,但需要额外的空间和维护成本。
典型场景:
- 数据库中的索引(如 B+ 树)。
- 跳表(Skip List):通过多级索引加速查找。
🔘分块访问
将数据分成若干块,每块内部可以顺序访问,块之间可以通过索引随机访问。
- 结合了随机访问和顺序访问的优点。
- 适合大规模数据集。
典型场景:
- 分块存储的文件系统。
- 数据库中的分页查询。
🔘缓存访问
利用缓存机制加速访问,优先访问最近使用过的数据。
- 访问速度取决于数据是否在缓存中。
- 时间复杂度介于 O(1) 和 O(n) 之间。
典型场景:
- CPU 缓存。
- 数据库查询缓存。
🔘并行访问
同时访问多个数据元素,通常用于分布式系统或并行计算。
- 可以显著提高访问效率。
- 需要复杂的同步机制。
典型场景:
- 分布式数据库。
- 并行计算中的共享内存模型。
🔘随机访问 ☆
直接访问任意位置的数据 ,而不需要从头开始逐一查找。它的时间复杂度通常是 O(1)。
- 直接定位:通过索引或地址直接定位目标,无论数据量多大访问任意位置的时间固定。
- 前提是连续存储:通常需要数据在内存中连续存储(如数组)。
典型场景
- 数组:
arr[3]
直接访问第 4 个元素。 - 哈希表:通过键直接映射到值的位置。
- 数据库索引:B+ 树等结构优化了随机访问性能。
举个例子:如果你在一本书中查找第 100 页的内容,你可以直接翻到第 100 页,而不需要从第 1 页开始一页一页地翻过去。这就是随机访问。在计算机内存中,随机访问之所以可能,是因为内存的存储方式支持按地址直接访问:内存被划分为一个个连续的存储单元,每个单元都有一个唯一的地址,当你需要访问某个数据时,只需要提供它的地址,CPU 就可以直接读取该地址的内容。
python
arr = [10, 20, 30, 40, 50]
print(arr[3]) # 输出 40
在这个例子中,arr[3]
的访问是随机的,因为数组在内存中是连续存储 的,程序可以通过计算偏移量 直接找到第 3 个元素的位置,而不需要遍历前面的元素。数组结构就支持随机访问?原因在于它的物理存储方式 ,数组在内存中是连续存储的,每个元素占用固定大小的空间。通过公式 地址 = 起始地址 + 索引 × 元素大小
,可以直接计算出任意元素的内存地址。地址 = 0x1000 + 2 × 4 = 0x1008
,直接读取地址 0x1008
的内容。
plaintext
内存布局:
地址 0x1000: 10 (4 字节)
地址 0x1004: 20 (4 字节)
地址 0x1008: 30 (4 字节)
地址 0x100C: 40 (4 字节)
地址 0x1010: 50 (4 字节)
很显然,如果元素大小不固定(比如一个元素是4字节,另一个是8字节),计算机就无法用简单的公式计算地址,必须逐个检查每个元素的位置,随机访问效率会降到O(n),失去数组的优势。所以下面趁热来点语言效率差异的拓展:
在C、C++等静态语言中:你必须在声明变量时严格指定类型(int
、float
),因为类型决定了内存大小,所以编译器在编译时就确定数组的内存布局,确保所有元素连续存储,且大小固定。这些严格的规定让数组的随机访问成为可能,因为计算机总能通过公式直接计算任何元素的地址,无需额外检查。
而Python是一种动态语言,类型和内存布局不像C、C++那样严格。Python中常见的"数组"实际上是list
,它不是C/C++那样的传统数组,而是基于动态数组(有时也用链表或混合结构)实现的。允许列表中的元素类型不同(比如[1, "hello", 3.14]
),元素大小不固定(整数可能是4字节,字符串可能是几十字节)。为了支持动态类型和大小调整,Python会在内存中额外存储元数据(比如每个元素的类型和大小) ,导致无法直接用地址 = 起始地址 + 索引 × 元素大小
计算地址。
C/C++的数组像一个整齐的书架,所有书大小相同,找书直接算位置。Python的list
像一个书架,但书大小不固定(有些是厚书,有些是薄书),虽然你也能快速找到第3本书,但背后可能需要多看几眼标签(元数据),效率略低。
Python中真正的"数组"是array
模块,array
模块,可以创建类似C的固定类型数组(比如array('i', [1, 2, 3])
表示整数数组)。这种数组元素大小固定(比如每个整数4字节),支持真正的随机访问,效率接近C/C++。 但是也有局限性,array
只能存储相同类型的数据,不支持动态类型(如Python的list
),使用范围较小,Python开发者更常使用灵活的list
。
链表不支持随机访问的本质也在于它的物理存储方式:链表的节点分散存储在内存中,每个节点包含数据和指向下一个节点的指针。要访问某个节点,必须从头节点开始,沿着指针逐一跳转,直到找到目标节点。
plaintext
链表: 10 -> 20 -> 30 -> 40 -> 50
内存布局:
地址 0x1A3F: 数据 10,指针 0x7B8C
地址 0x7B8C: 数据 20,指针 0x4D2E
地址 0x4D2E: 数据 30,指针 0x9F1A
地址 0x9F1A: 数据 40,指针 0x2C6B
地址 0x2C6B: 数据 50,指针 NULL
每个节点的地址(如 0x1A3F, 0x7B8C)是随机的,没有固定的间隔。要访问第 3 个节点(值为 30),必须从头节点开始,依次访问 0x1A3F -> 0x7B8C -> 0x4D2E,无法直接跳转到目标节点。
链表的节点分散存储在内存中,地址随机分配,因此无法通过索引直接访问,只能通过逐一遍历实现顺序访问。 链表通过调整指针实现高效的插入/删除,而数组需要整体移动元素,因此在动态场景下链表更具优势。
🟡 LangChian vs LangGraph
LangChain最初设计用于简单的LLM应用,比如问答系统或RAG。这些任务通常是线性的:输入→处理→输出,或者几个步骤按顺序执行。当任务复杂到需要多代理协作、动态决策或循环逻辑时,链的if-else和手动循环会变得很复杂 ,也难以修改业务逻辑,难以维护。LangGraph针对更复杂的AI场景,比如多代理协作(多个AI角色实时互动)、循环任务(不断优化直到满足条件)或动态工作流(根据状态选择路径)。这些场景需要非线性的、灵活的结构。图结构天生适合表示复杂关系和动态逻辑,开发者可以用节点和边定义任务,系统自动根据状态选择路径。
🔘链结构就不能路径规划了?
"我用链结构(比如通过if-else、循环等逻辑)也能实现路径规划或复杂逻辑,为什么说图结构'天生自带路径规划'呢?"首先,用链结构(比如LangChain中的Chains)确实可以通过条件判断(如if-else)、循环(while/for)等实现路径规划或复杂逻辑。就像你在超市购物清单上写"如果没牛奶就买豆奶",或者在代码中写:
python
if condition1:
step1()
elif condition2:
step2()
else:
step3()
甚至可以用循环实现反复检查或动态调整,比如:
python
while not inventory_enough:
adjust_order()
所以,你完全可以通过链结构实现路径规划或复杂的决策逻辑,比如一个AI代理根据输入选择不同路径("如果用户问天气就查天气API,如果问餐厅就查地图API")。但尽管链结构能实现路径规划,但图结构在路径规划和复杂逻辑上的"天生优势"体现在以下几个关键点上。
🔘链结构的路径------人工驱动
链的路径规划是"程序员驱动"的,手动定义每条路径
在链结构中,路径规划需要你(程序员)手动写出所有可能的条件和分支。比如在咖啡店订单例子中,你需要明确写出:如果点咖啡→调用咖啡代理、如果点甜点→调用甜点代理、如果点两者→先咖啡后甜点、如果库存不足→调整订单(可能需要嵌套if-else或循环)。一旦路径变得复杂,if-else和循环会变得非常繁琐,代码可能长到难以维护。而且链的路径是预定义的,运行时只能按照你写好的逻辑走,无法根据实时状态灵活调整,你只能手动添加更多条件。而且一旦业务需求变化(比如新增一个代理或新路径),你需要修改代码,重新设计整个链,工作量大。
🔘图结构的路径------数据驱动
图的路径规划是"数据驱动"的,动态选择路径
路径规划是通过节点(Nodes)和边(Edges)定义的,系统会根据数据的状态(state)或条件动态选择路径。比如在咖啡店订单例子中:
- 你定义节点:接收输入、检查库存、制作咖啡、制作甜点、调整订单。
- 你定义边:从"检查库存"节点到"制作咖啡"或"调整订单",条件是"如果有库存就制作,如果没库存就调整"。
运行时,图会根据实时状态自动选择路径,比如"如果库存不足,跳到调整订单节点;如果库存够,跳到制作节点",甚至可以设置循环(反复检查直到库存满足)。你不需要手动写出所有if-else逻辑,只需要定义节点和条件,系统自动根据状态选择路径。如果业务需求变化(新增代理或路径),只需添加新节点和边,无需重构整个结构,扩展非常灵活。当然图结构在某些情况下也可以被视为"预定义的规则",但与基于 if-else
或链式逻辑的方式有本质区别。
图是一种非线性数据结构,用于表示节点之间的复杂关系,可以随时添加或删除节点和边。
- 节点(Node):表示实体(如状态、任务、代理等)。
- 边(Edge):表示节点之间的关系(如依赖、路径、权重等)。
🔘为什么链结构无法取代图结构
为了简化,我们选定5个具体的条件,基于咖啡店订单系统:
- condition1: order_coffee - 客户是否点了咖啡(True/False)。
- condition2: order_dessert - 客户是否点了甜点(True/False)。
- condition3: stock_available - 咖啡和甜点的库存是否充足(True/False)。
- condition4: payment_success - 支付是否成功(True/False)。
- condition5: is_vip - 客户是否是VIP(True/False,影响折扣)。
这些条件组合理论上有2^5 = 32种可能,但我们会通过不同方法处理,展示代码量和灵活性的差异。
🔘链结构------平铺
手动写出所有可能的if-else分支,5个条件有32种组合,需写32个分支,代码冗长。增加1个条件(6个)就到64种,维护困难。
python
# 未优化的链结构实现咖啡店订单 ,手动枚举所有32种组合(2^5),代码冗长。
def process_order_unoptimized(order_coffee, order_dessert, stock_available, payment_success, is_vip):
# 组合1:点咖啡+甜点,库存充足,支付成功,VIP
if order_coffee and order_dessert and stock_available and payment_success and is_vip:
print("客户点了咖啡和甜点,库存充足,支付成功,是VIP...")
make_coffee()
make_dessert()
apply_vip_discount()
print("订单完成!")
# 组合2:点咖啡,库存不足,支付成功,非VIP
elif order_coffee and not order_dessert and not stock_available and payment_success and not is_vip:
print("客户点了咖啡,库存不足,支付成功,非VIP...")
adjust_order("咖啡")
print("订单调整完成!")
# 组合3:点甜点,库存充足,支付失败,VIP
# 组合4:...(省略其余28种组合,每种都需要类似分支)
# 测试
process_order_unoptimized(True, True, True, True, True)
🔘链结构------嵌套
你要说,我又不傻,实际设计肯定不会这样平铺,是的,if 嵌套确实比平铺的 if-else 更紧凑,但它的致命弱点在于:一旦需要修改某个条件或逻辑,代码会迅速陷入"嵌套地狱" 。因为所有条件和逻辑紧密耦合在一起,修改一个条件可能会影响其他条件的执行。未来需要支持 "奶茶",需要在多个嵌套层级中插入新的逻辑。再比如顺序一旦想改,比如支付检查需要优先于库存检查,必须重新组织整个嵌套结构。
🔘链结构------分而治之
通过分步骤和循环处理,减少if-else嵌套,分3步(订单收集、库存检查、支付处理),用循环动态处理每个项。5个条件生成约4-8种路径(取决于订单项和状态),远少于32种,代码约10行。但是仍需手动定义步骤和条件,灵活性有限。其实链结构优化和神经网络分层的核心思想非常相似:传统链结构直接枚举所有条件组合,假设我们要构建一个神经网络,总权重数为10000。可以3层结构(10×10×10×10)总共40个节点。可以1000×10结构总共1010个节点。所以优化链结构不是一个特定的"算法"(像排序算法或搜索算法),而是一种设计模式 或编程思想 。它通过结构化代码和动态逻辑,改进传统链结构的局限。想象你在规划一天的行程(链结构),最初你可能为每种天气(雨/晴)和每种活动(工作/娱乐)写出所有组合(未优化)。优化后,你按步骤规划(先查天气,再选活动),减少重复决策。核心思想是:分而治之 + 动态处理 + 模块化
python
def process_order_optimized(order_coffee, order_dessert, stock_available, payment_success, is_vip):
# 步骤1:收集订单项 - 就像先问客户要点啥,记下来
order_items = [] # 空列表,记录客户点的东西
if order_coffee:
order_items.append("咖啡") # 如果点咖啡,加入列表
if order_dessert:
order_items.append("甜点") # 如果点甜点,加入列表
print(f"客户点了:{order_items}") # 显示订单项
# 步骤2:逐个检查库存并制作 - 像一个一个处理订单
for item in order_items: # 循环每个订单项
if item == "咖啡" and stock_available: # 如果是咖啡且有库存
print("库存够,制作咖啡...")
make_coffee() # 做咖啡
elif item == "咖啡" and not stock_available: # 如果是咖啡但没库存
print("咖啡没货,调整订单...")
adjust_order("咖啡") # 调整(比如换产品)
elif item == "甜点" and stock_available: # 如果是甜点且有库存
print("库存够,制作甜点...")
make_dessert() # 做甜点
elif item == "甜点" and not stock_available: # 如果是甜点但没库存
print("甜点没货,调整订单...")
adjust_order("甜点") # 调整
# 步骤3:处理支付和VIP - 最后结账和加优惠
if payment_success: # 如果支付成功
print("支付成功!")
if is_vip: # 如果是VIP
print("VIP客户,给你折扣...")
apply_vip_discount() # 给折扣
print("订单处理完成!")
else: # 如果支付失败
print("支付失败,取消订单...")
cancel_order() # 取消
思想/方式 | 代码量 | 路径数量 | 可读性 | 灵活性 | 适用场景 |
---|---|---|---|---|---|
平铺设计if | 几十行(32+) | 32种(2^5) | 差(冗长) | 低(静态) | 简单场景,条件少 |
if嵌套 | 15-20行 | 4-8种 | 中(嵌套多) | 中(层次调整) | 条件有优先级场景 |
分而治之优化 | 10-15行 | 4-8种 | 好(分步清晰) | 中(需步骤) | 顺序任务,动态调整 |
平铺设计if 像单层神经网络(1000×10,10000权重),枚举所有输入组合。if嵌套 :像浅层网络(10×100,1000权重),按层次处理。分而治之优化:像深层网络(10×10×10,300权重),分层动态优化。
🔘图结构
用节点和边动态建模,运行时根据状态选择路径,无需预定义组合。
python
# 图结构实现咖啡店订单
def process_order_graph(order_coffee, order_dessert, stock_available, payment_success, is_vip):
# 定义图
graph = {
"起点": ["接收订单"],
"接收订单": ["检查咖啡" if order_coffee else "检查甜点" if order_dessert else "无订单"],
"检查咖啡": ["制作咖啡" if stock_available else "调整订单"],
"制作咖啡": ["完成" if payment_success else "支付失败"],
"检查甜点": ["制作甜点" if stock_available else "调整订单"],
"制作甜点": ["完成" if payment_success else "支付失败"],
"调整订单": ["完成"],
"支付失败": ["取消订单"],
"取消订单": [],
"无订单": ["完成"],
"完成": []
}
# 动态遍历
def traverse(current):
"""
遍历图,根据状态选择下一节点。
用简单队列模拟动态路径。
"""
if current in graph:
print(f"状态: {current}")
for next_node in graph[current]:
if isinstance(next_node, str):
traverse(next_node)
elif callable(next_node):
next_node = next_node()
if next_node:
traverse(next_node)
# 从起点开始
traverse("起点")
# 测试
process_order_graph(True, True, True, True, True)
再比如用一个大家熟悉的交通导航例子对比链和图:假设你用链结构设计一个导航系统,从家(A)到公司(D),中间可能经过B(公园)或C(商场),但路径取决于交通状况::
python
if traffic_to_B < traffic_to_C:
drive_to_B()
if traffic_from_B_to_D < 30_minutes:
drive_to_D()
else:
drive_back_to_A()
else:
drive_to_C()
if traffic_from_C_to_D < 30_minutes:
drive_to_D()
else:
drive_back_to_A()
- 如果交通状况有10种可能(堵车、施工、事故等),你需要写出所有if-else组合,代码会变得非常复杂。
- 如果中途需要动态调整(比如发现B点堵车后直接跳到C点),你需要手动修改逻辑,运行时缺乏灵活性。
- 如果需要循环(比如反复检查交通状况直到找到最佳路径),你得手动写while循环,代码更复杂。
假设你用图结构(类似LangGraph)设计导航系统:你定义:
- 节点:A(家)、B(公园)、C(商场)、D(公司)。
- 边:从A到B、A到C、B到D、C到D,每条边带条件(如"如果交通畅通就走这条路")。
- 状态:实时交通数据(堵车、畅通等)。
运行时系统从A开始,根据交通状态自动选择路径,比如"如果B点堵车,就走A→C→D;如果C点也堵车,跳回A重新规划"。如果需要循环(反复检查交通直到找到最佳路径),只需在图中设置"回到A检查"的边,无需手动写代码。可见根本不需要手动写if-else,系统根据状态动态选择路径,代码简洁,而且扩展简单,如果新增E点(新商场),只需添加节点和边,不用重构整个系统。
图结构是路径规划经典算法的基础。例如:
- 广度优先搜索(BFS):用于寻找最短路径(无权图)。
- Dijkstra 算法:用于寻找加权图中的最短路径。
- A 算法*:结合启发式信息,进一步优化路径搜索。
🟡图结构与强化学习
图结构与强化学习之间存在一种深刻而有趣的联系,这种联系源于它们在处理复杂问题时的天然契合,但强化学习并不完全依赖图结构,二者的关系是强关联而非唯一。
图结构是一种非常强大的工具,尤其在需要表示离散状态空间和状态转移关系时表现得淋漓尽致。想象一个导航问题:地图上的每个格子是一个节点,移动方向是连接节点的边,这种结构不仅直观,还天然适合路径规划任务。许多经典的强化学习场景本质上都可以看作路径规划问题 。图结构在这里的优势在于它能够清晰地表达状态之间的"真实路径 ",即从一个节点到另一个节点的实际连接,比如社交网络中的用户互动、知识图谱中的实体关联,或者交通路网中的道路布局。不仅如此,图结构还具备动态调整的能力,可以随时添加或删除节点和边,非常适合实时变化的环境。
然而,强化学习的本质却不仅仅是路径规划。强化学习的真正目标是在一个高维空间中找到"最佳路径" ,但这里的"路径"并不是图结构中的实体路径,而是一个更抽象的概念------最优策略 。这种策略是将状态映射到动作的规则,它可能是通过图结构的路径规划来实现,但也可能完全不依赖图。比如在一个连续动作空间中,比如自动驾驶中的油门控制,最优策略可能由一个神经网络直接输出,而不是依赖节点间的显式连接。这正是图结构与强化学习关系微妙的地方:强化学习的灵活性在于,它可以通过多种数据结构和方法来逼近这个抽象的"最优路径",图结构也只是实现强化学习的一种方式,但绝不是唯一的方式。比如说,深度强化学习中常用的数组和矩阵是不可或缺的。Q-learning 中的 Q 表可以用二维数组存储状态-动作对的价值,而深度 Q 网络(DQN)则依赖矩阵运算来表示神经网络的权重和输入输出。树结构也在某些场景下大放异彩,比如蒙特卡洛树搜索(MCTS)在 AlphaGo 中通过构建博弈树来探索策略。哈希表则在状态空间较大时派上用场,帮助快速查找和存储稀疏的状态价值。甚至在实现细节上,像经验回放中的队列,或优先级采样中的堆结构,都为强化学习的效率提供了支持。这些数据结构各有千秋,它们在特定任务中的作用是图结构无法完全替代的。
近年来,图神经网络GNN的兴起进一步深化了图结构与强化学习的结合。GNN 是一种专门处理图数据的深度学习技术,它通过聚合邻居节点的信息,能够捕捉图中的局部和全局特征。交通流量优化的例子就很贴切:路网可以用图表示,交叉口是节点,道路是边,GNN 可以提取拥堵模式等特征,而强化学习则通过优化信号灯的切换策略来最大化交通效率。这种组合不仅限于单智能体任务,在多智能体强化学习中也展现出强大潜力,比如在社交网络中预测用户行为,或在多人游戏中协调多个智能体的行动。所以,图结构在强化学习中的地位是毋庸置疑的,它擅长建模复杂的依赖关系,支持动态变化,。但强化学习的灵魂在于寻找最优策略,这个过程可以借助图结构,也可以跳出图结构的框架,通过其他工具实现。
🟡数据结构与算法
- 顺序存储:连续内存布局让随机访问很快(O(1)),但插入删除很慢(O(n))。
- 链式存储:动态指针连接让插入删除很快(O(1)),但随机访问很慢(O(n))。
数据结构是理解算法的本质,每种数据结构都有其擅长的操作,算法就是根据数据结构的特性解决问题
- 分治法:把大问题拆成小问题,递归解决(快速排序、归并排序)。
- 贪心法:每步最优,期望全局最优(Dijkstra、Kruskal)。
- 动态规划:记录子问题结果,优化重复计算(背包、LCS)。
- 回溯法:尝试所有可能,找到解(DFS、全排列)。
- 迭代法:逐步逼近答案(二分查找、链表反转)。
🔘 分治思想
将大问题分解为小问题,分别解决后再合并结果。
💠二分查找
- 数据结构:数组(要求有序)。每次将搜索范围缩小一半,利用数组的随机访问特性。
- 适应场景:可快速查找有序数组中的目标值。不适用无序数组或链表(无法随机访问)。
开发一个电商网站,用户输入了一个商品的价格范围(如 500------1000 元),找到所有符合条件的商品。如果采用暴力解法那就遍历整个数组,逐一检查每个商品的价格是否在范围内。时间复杂度为(O(n)),100 万条记录那就判断100万次。而数组本身又支持随机访问 ,可以通过索引直接定位元素。每次比较中间值,判断目标值在哪一半,逐步缩小范围。时间复杂度:(O(\log n)),比暴力解法快得多。
再次强调:二分查找的前提就是"有序"! 如果数据是无序的,那二分查找根本没法用,因为它是基于"排除一半"的策略。如果没有顺序,就无法判断下一步该往哪边缩小范围。
- 数据有序 → 二分查找 O(log n)
- 数据无序 → 先排序 O(n log n),再二分查找
- 频繁查找 → 用平衡搜索树 (BST) 或 B+ 树
- 单点查询快 → 用哈希表 O(1)
💠归并排序
- 数据结构:数组或链表。先将数组分成两部分,分别排序后合并。
- 适应场景:需要稳定排序且时间复杂度为 (O(n \log n)) 的情况。不适用内存有限时(归并排序需要额外空间)。
假设你在开发一个社交平台,需要对用户的活跃度(如点赞数)进行排序,以便展示"最活跃用户排行榜"。首先用户数据量可能非常大(如 100 万用户),而且数据可能是动态生成的(如实时统计的点赞数)。暴力解法即使用简单的冒泡排序或选择排序,时间复杂度:(O(n^2))。我们可以将数据分成小块分别排序,再合并结果,即利用分治思想减少比较次数。
🔘 贪心思想
每一步都要局部最优解,希望最终得到全局最优解。
💠Dijkstra最短路径
- 数据结构:图:邻接表或邻接矩阵。每次选择当前距离最短的节点扩展,逐步逼近目标。
- 适应场景:加权图中求单源最短路径。但不适用存在负权边的图(需用 Bellman-Ford 算法)。
开发一个导航应用,用户输入起点和终点,计算出从起点到终点的最短路径。地图可以用图表示,节点是地点,边是道路,权重是距离或时间。 暴力解法枚举所有可能的路径,计算每条路径的总距离,这个时间复杂度是指数级。我们可以每次基于当前距离最短的节点进行扩展,逐步逼近目标,可以避免重复计算已经确定的最短路径。时间复杂度:(O((V + E) \log V)),适合加权图。
💠霍夫曼编码
- 数据结构:树、队列。每次合并频率最小的两个节点,构建最优编码树。
- 适应场景:数据压缩。但不适合动态更新的场景。
开发一个文件压缩工具,用户希望将一个大文件压缩成更小的文件以便存储或传输。文件中的数据通常包含重复的字符,压缩的核心目标是用更少的比特表示高频字符,同时用更多的比特表示低频字符。 暴力解法使用固定长度编码(ASCII ),每个字符占用相同的比特数,这样低频字符浪费空间,高频字符没有优化。可以使用变长编码,高频字符用短编码,低频字符用长编码,时间复杂度:(O(n \log n))(n 是字符种类数)。
🔘动态规划
将问题分解为子问题,通过保存中间结果避免重复计算。
💠最长公共子序列(LCS)
- 数据结构:二维数组。通过递推公式逐步填表,记录子问题的解。
- 适用场景:字符串匹配、生物信息学。不适用输入规模过大时(空间复杂度过高)。
💠背包问题
- 数据结构:一维数组。通过状态转移方程逐步计算最优解。
- 适用场景:资源分配、组合优化。不适用非线性约束问题。
假设你要去旅行,但你的行李箱容量有限(最多只能装 10kg)。你有多个物品,每个都有重量和价值(比如重要性、实用性)。你的目标是在不超重的情况下,装入价值最高的物品。 直觉方法 (贪心算法)直接选价值最高的物品(💻 笔记本 10 分,先拿!)但剩下 7kg 空间,能否搭配更好的方案?暴力解法 穷举所有物品组合,计算总重量和总价值。但组合太多,计算量太大。所以这时候就需要动态规划解法,我们用一个二维表 dp[i][w]
,表示前 i 个物品,在重量限制 w 下,能获得的最大价值 。引入状态转移公式 保证价值最大化!。在电商购物优惠 (选最优商品组合,最大化折扣) 投资选择 (有限预算下买最优资产) 服务器资源分配(最大化计算力,优化成本)下都好使!
🔘回溯与递归
通过试探所有可能的解,找到满足条件的解。
💠深度优先搜索(DFS):
- 数据结构:栈(显式或隐式递归)。沿着一条路径尽可能深地搜索,直到找到解或穷尽可能性。
- 适用场景:迷宫求解、组合问题这种找到任意解,但不适用需要找到最优解(如最短路径问题)。
假设你在开发一个游戏,玩家需要在一个迷宫中找到出口。你需要编写一个算法,帮助玩家找到从起点到终点的路径。 迷宫可以用图表示,每个格子是一个节点,相邻格子之间有边连接, 需要找到从起点到终点的一条路径(不一定是最短路径)。 暴力解法枚举所有可能的路径,逐一检查是否到达终点。时间复杂度:指数级。可以使用 DFS 沿着一条路径尽可能深地搜索,如果当前路径无法继续,回溯到上一步,尝试其他路径。
💠八皇后问题:
- 数据结构:递归调用栈。尝试放置皇后,回溯到上一步重新尝试。
- 适用场景:排列组合、约束满足问题。不适合解空间过大或约束过于复杂时。
🔘广度优先搜索(BFS)
逐层扩展搜索,确保最先找到的解是最优解。
💠广度优先搜索(BFS)
- 数据结构:队列。从起点开始,逐层扩展相邻节点。
- 适用场景:无权下的最短路径、连通性问题。不适用有权图(需用 Dijkstra 或其他算法)。
假设你在开发一个社交平台,用户 A 想知道他和用户 B 是否有共同好友,或者他们之间的"社交距离"是多少。 社交网络可以用图表示,每个用户是一个节点,好友关系是边。需要找到用户 A 到用户 B 的最短路径(即最少经过的好友数)。 暴力解法枚举所有可能的路径,计算每条路径的长度,时间复杂度:指数级。采用用 BFS 逐层扩展,从用户 A 开始,逐层访问其好友,确保最先找到的路径是最短路径。
深度优先搜索是一种基于试探性探索 的算法,沿着一条路径尽可能深地搜索,直到找到解或穷尽可能性。如果当前路径无法继续,就回溯到上一步,尝试其他路径。属于回溯与递归思想?回溯体现在DFS 的过程本质上是通过试探所有可能的路径来寻找解,当某条路径不可行时,就回溯到上一步重新尝试。地柜体现在DFS 的实现通常依赖递归调用栈 ,隐式地保存当前路径的状态。适用需要找到任意解(如迷宫求解、组合问题),但不需要保证最优解(如八皇后问题)。广度优先搜索是一种基于逐层扩展的算法,从起点开始,逐层访问相邻节点,它确保最先找到的解是最优解(如最短路径问题)。它不需要回溯,也不依赖递归,而是通过队列显式地管理待访问节点。适用需要找到最优解(如无权图中的最短路径),解空间较大但解在较浅层时(如连通性问题)。