[{"categories":[{"title":"数据结构","url":"/categories/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"content":" Summary 前面的数组、链表、栈、队列都是\u0026quot;一条线\u0026quot;——每个元素最多一个前驱、一个后继。但现实世界大量是层次关系:文件系统的目录树、公司的组织架构、HTML 的 DOM、区块链的 Merkle 树。树就是描述这种\u0026quot;一对多\u0026quot;层次关系的结构。更重要的是,树把查找从线性结构的 O(n) 压到了 O(log n)——这是它存在的核心价值。这个结构应用得很广泛，需要重点学习。\n树是什么 树是 n 个节点的有限集合,它满足:\n有且仅有一个根节点(root),没有父节点。 除根外,每个节点有且仅有一个父节点;一个节点可以有 0 个或多个子节点(child)。 没有环——从根出发不会绕回自己。 换句话说,树是一种特殊的图:连通、无环、n 个节点恰好 n-1 条边。它的\u0026quot;非线性\u0026quot;体现在:一个节点可以有多个后继(孩子),所以数据是分叉展开的,不再是一条线。\n根 root / | \\ A B C ← A、B、C 是根的孩子,根是它们的父 / \\ | D E F ← D、E、F 是叶子(no child) 树的关键词 用上面这棵树对照:\n节点的度(degree):该节点的孩子个数。根的度是 3,A 的度是 2,D 的度是 0。 叶子节点(leaf):度为 0 的节点(B、D、E、F)。 深度(depth):从根到该节点的边数(根深度 0)。 高度(height):从该节点到最深叶子的边数;树的高度=根的高度。上图高度为 2。 层(level):通常根为第 1 层,往下递增。 子树(subtree):任意节点连同它下面所有节点,自成一棵树。 树上大多数操作(查找、插入)的代价正比于高度 h。树越\u0026quot;矮胖平衡\u0026quot;,h 越接近 log n,操作越快;越\u0026quot;高瘦\u0026quot;(退化成链),h 越接近 n,越慢。\u0026ldquo;如何让树保持矮\u0026quot;是二叉树的精髓\n二叉树(Binary Tree)——最重要的一类树 每个节点最多两个孩子,分别叫左孩子和右孩子,且左右有序(不能交换)。\n两种特殊形态 满二叉树(full/perfect):每一层都填满,叶子全在最底层。高度 h 的满二叉树有 2^(h+1) - 1 个节点。 完全二叉树(complete):除最后一层外都填满,且最后一层的节点靠左连续排列。这个\u0026quot;靠左连续\u0026quot;的性质极其重要——它让树可以用数组紧凑存储。 满二叉树: 完全二叉树(最后一层靠左): 1 1 / \\ / \\ 2 3 2 3 / \\ / \\ / \\ / 4 5 6 7 4 5 6 ← 7 的位置空着也没关系,只要靠左连续 代码实现 使用链表的方式实现是最高效快捷的，代码如下 ：\ntype TreeNode struct { Val int Left *TreeNode Right *TreeNode } 完全二叉树使用数组的形式也是可以实现的，每个下标对应的节点是\n对下标 i(从 0 开始)的节点: 左孩子 = 2i + 1 右孩子 = 2i + 2 父亲 = (i - 1) / 2 遍历 树的遍历是按照某种特定的顺序进行访问，通常分为两大类：深度遍历、广度遍历\n深度遍历 有三种遍历顺序\n前序遍历：根 -\u0026gt; 左 -\u0026gt; 右 中序遍历：左 -\u0026gt; 根 -\u0026gt; 右 对于二叉搜索树，中序遍历就是升序遍历 后序遍历：右 -\u0026gt; 左 -\u0026gt; 根 使用场景：计算目录大小、释放子树、表达式求值。 使用递归可以很方便的写出上述遍历，代码如下：\nfunc Traversal(root *TreeNode) { if root == nil { return } fmt.Println(root.Val) // 根 —— 把这行移到下面一行是中序, 移到最后是后序 Traversal(root.Left) // 左 Traversal(root.Right) // 右 } 广度遍历 一层一层从上到下、从左到右访问。\nfunc levelOrder(root *TreeNode) [][]int { if root == nil { return nil } var res [][]int queue := []*TreeNode{root} for len(queue) \u0026gt; 0 { n := len(queue) // 获取当层节点数量 level := make([]int, 0, n) for i := 0; i \u0026lt; n; i++ { node := queue[0] queue = queue[1:] level = append(level, node.Val) if node.Left != nil { queue = append(queue, node.Left) } if node.Right != nil { queue = append(queue, node.Right) } } res = append(res, level) // 每一层单独成组 } return res } 二叉搜索树（BST） 普通的树，节点之间是没有任何规律的，进行查找还需要遍历，效率接近O(n)。\n二叉搜索树是一个特殊的树，它的节点遵循，左节点的值小于 \u0026lt; 父节点，右节点的值 \u0026gt; 父节点的值，这使得查询的效率提成到了O(h)树高 | log(n)，类似二分查找。\nfunc (t *TreeNode) Search(target int) *TreeNode { cur := t for cur != nil { switch { case target == cur.Val: return cur case target \u0026lt; cur.Val: cur = cur.Left // 目标更小,去左子树 default: cur = cur.Right // 目标更大,去右子树 } } return nil } 删除操作 删除是此数最麻烦的操作，为了保证遵循的规则，删除分三种情况：\n叶子节点，直接删除就可以了 有一个子节点，直接让叶子节点变成自己 有两个字节点，删除时，需要找到右子树里最小的节点(中序后继)或左子树里最大的(中序前驱),用它的值替换自己,再去删那个后继/前驱的节点(它必然最多一个孩子,转化为情况 1 或 2)。 代码如下 ：\nfunc (node *TreeNode) Delete(value int) *TreeNode { if node == nil { return nil } if value \u0026lt; node.Value { node.Left = node.Left.Delete(value) } else if value \u0026gt; node.Value { node.Right = node.Right.Delete(value) } else { if node.Left == nil { return node.Right } else if node.Right == nil { return node.Left } minRight := node.Right.findMin() // 找到右节点中最小的 // 中序前继，找到左节点中最大的子节点 maxLeft := node.Left.findMax() node.Value = minRight.Value // node.Value = maxLeft.Value node.Right = node.Right.Delete(minRight.Value) // node.Left = node.Left.LeftDelete(maxLeft.Value) } return node } func (node *TreeNode) findMin() *TreeNode { current := node for current.Left != nil { current = current.Left } return current } func (node *TreeNode) findMax() *TreeNode { current := node for current.Right != nil { current = current.Right } return current } 测试时，可以使用下面的结构构建树：\n50 / \\ 30 70 / \\ 20 40 删除50这个node，最终40会提升上去。30的这个node的右节点是nil。\n极端情况 二叉搜索树的搜索复杂度 O(log n) 是有前提的——树必须是平衡的。如果按已经有序的数据依次插入(1,2,3,4,5……)，每次都插到右边，树就退化成一根\u0026quot;只有右孩子的链\u0026rdquo;,高度 h=n,查找退回 O(n),和链表没区别，如下：\n1 \\ 2 \\ 3 \\ 4 \\ 5 所以针对涂上情况，出现了平衡树\n平衡二叉树 针对上述情况导致数长歪了，那就在每次删除插入之后，自动调整，保证子树的高度不超过某个上限，把高度牢牢控制在O（log n），调整的核心手段是旋转-通过局部的左旋转和右旋转，在保证BST的性质下降低高度。常用的结构有：\nAVL树：属于规则最严格的树，任意节点的左右子树高度差\u0026lt;=1。查找最快，但每次插入和删除旋转调整频繁。 红黑树：对平衡的规则要求稍微放宽了一些，插入删除调整的最少，也是最常用的平衡树（用红/黑规则保证最长路径≤ 2×最短路径）。 B 树/ B+ 树 ：一个节点可以储存多个子节点，让树更矮胖，极少磁盘IO，这种树在数据库中经常使用。 B 树 / B+ 树 之前演示的树都是按照放在内存里面进行的测试，但如果把树放到磁盘上，这些树就会很吃力了，因为磁盘IO是有上限的。总所周知，内存随机访问是很快的，可能也就几纳秒，磁盘一次随机访问的时间可能是几毫秒，差了很多倍。访问树时，没往下走一层，可能就需要一次磁盘IO，一颗存了10 万条记录的二叉树/平衡树，访问叶子节点，需要的IO次数可能超过你的想象。\n而针对这类情况，解决方法也是有的，那就是让树变得矮胖，把一个节点只有两个子节点改成可以拥有多个节点，树的高度就直接塌下来了，这个就是B 树。\n一个节点存一批有序的键 + 多个孩子指针,不再是二叉树。一个节点的大小正好对齐一个磁盘页(通常 4KB/16KB),这样\u0026quot;读一个节点 = 一次磁盘 IO\u0026quot;最划算——一次 IO 就把几百个键捞进内存，并且数据全部下沉到叶子节点，叶子节点使用双向链表串起来，这个就是B+ 树。 所有叶子在同一层(严格平衡),查找/插入/删除都是 O(logₘ n),这里的底数 m(路数/扇出)是几百上千,所以树高极矮。 算一笔账:扇出 1170 的 B+ 树,3 层就能索引约 2000 万条记录——查任意一条,最多 3 （计算： log₁₁₇₀(2000万) ≈ 3）次磁盘 IO。对比二叉树的 25（计算：log₂(2000万) ≈ 25） 次磁盘IO,这就是数据库索引选它的根本原因。 扇出 1170 这个数字不是大风刮来的，是计算出来的，一个磁盘页，按照16KB进行计算，假设主键是bigint，8字节，子节点指针6字节，一条索引项是14字节， 16384 / 14 = 1170，三层树的结构就可以包含\n1 层:1170 2 层:1170² ≈ 137 万 3 层:1170³ ≈ 16 亿 B+ 树(3 阶示意,真实场景每节点几百个键): [ 10 | 20 ] ← 内部节点:只放\u0026#34;路标\u0026#34;键 + 孩子指针 / | \\ [1|4|8] [11|15] [22|30] ← 叶子:放全部真实数据 ↕ ↕ ↕ 叶子之间用链表串起来 → 范围查询顺着链走,不用回树上跳 B 树和 B+ 树的区别大致如下：\nB 树: [key|data|key|data|...] 一页塞不下几条 B+树: [key|ptr|key|ptr|...] 一页塞 1170 条 并不是所有的搜索引擎都用B+ 树。像LevelDB这种使用的是LSM-Tree（日志结构合并树）- 把随机写变成顺序写（先写内存memtable，再批量写到磁盘中的有序文件中）。使用： 读多写少用 B+ 树, 写多读少用 LSM-Tree。\nTrie(前缀树 / 字典树) Trie 是一种多叉树,专门用来高效存储和检索字符串集合,尤其擅长前缀匹配。\n思路:把字符串的每个字符作为一条边,从根到某节点的路径就拼成一个前缀。 公共前缀的字符串共享同一段路径。\n存入 {\u0026#34;cat\u0026#34;, \u0026#34;car\u0026#34;, \u0026#34;card\u0026#34;, \u0026#34;dog\u0026#34;}: (root) / \\ c d | | a o / \\ | t r g (end)| (end) | d (end) 查一个词是否存在、或以某前缀开头,时间只和词的长度 L 有关,即 O(L),与词库大小无关。 代价是空间：每个节点要为可能的下一个字符存指针(如 26 个字母就是 [26]*Node 或一个 map)。 用途：搜索引擎/输入法的自动补全、拼写检查、IP 路由表的最长前缀匹配、敏感词过滤。以太坊的 MPT(Merkle Patricia Trie) 就是 Trie 的密码学增强变体。\n","date":"August 26, 2026","img":"/datastruct/binarytree/cover.png","lang":"zh-cn","langName":"","largeImg":"/datastruct/binarytree/cover_hu_cea0f0cf6fa95a.png","permalink":"/datastruct/binarytree/","series":[],"smallImg":"/datastruct/binarytree/cover_hu_399b6f49994fca6c.png","tags":[{"title":"数据结构","url":"/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"timestamp":1787745457,"title":"数据结构-二叉树"},{"categories":[{"title":"Webthree","url":"/categories/webthree/"}],"content":" 导读 链多了以后，钱包里的资产是碎的:USDC 在 Polygon、ETH 在 Arbitrum、稳定币又躺在 Base。想把 A 链的某个币变成 B 链的另一个币，你得亲手干三件麻烦事——选一座靠谱的桥(几十座桥各有价格、速度、安全性)、在两头各做一次 DEX 兑换(桥往往只认少数几种中转币)、再把这一长串调用拼成交易发出去。LI.FI 就是把这三件事打包掉的跨链聚合器:你告诉它\u0026quot;从哪条链的什么币，到哪条链的什么币\u0026quot;，它负责比价选路、拼好交易、一把执行完。\n但 LI.FI 其实有两条产品线、两套范式,这也是这篇文章的分界:\n前半篇(一~二)讲聚合器——链下给你算路线，你自己发交易、自己付 gas、自己扛桥的风险。 后半篇(四~五)讲 LI.FI Intents——你只表达意图，solver 用自己的钱先把币给你,风险和选路都转移出去。 中间第三节用 UniswapX 做参照，把这两种范式的分界线说清楚。 按目的挑着看:\n你想干什么 直接跳到 快速给产品接一个跨链换币 一 · 三种接入姿势 搞懂 LI.FI 合约是怎么设计的 二 · 内部架构 分清\u0026quot;聚合器\u0026quot;和\u0026quot;意图\u0026quot;到底差在哪 三 · 对标 UniswapX、六 · 三者对比 理解 Intents / solver 的运作原理 四 上手写 Intents 的接入代码 五 一、聚合器:它解决什么问题、怎么接入 多链流动性割裂 单看一条链，Uniswap 这类 DEX 已经把\u0026quot;同链换币\u0026quot;解决得很好了。但一旦跨链，事情立刻变复杂:\n桥太多太杂:Stargate、Across、Hop、CCTP、Connext…… 每座桥支持的链、收的币、手续费、到账速度、安全模型都不一样，用户根本挑不过来。 两头都要兑换:桥通常只搬运少数几种\u0026quot;标准中转币\u0026quot;(比如 USDC、原生 ETH)。你手里的币桥不收，就得先在源链换成它认的币;到了目标链，你要的币桥又不给，还得再换一次。 一趟跨链 = 一串动作:源链 swap → 跨桥 → 目标链 swap，中间任何一步失败都可能卡住资金。 LI.FI 干的就是跨链世界里的\u0026quot;聚合 + 比价 + 执行\u0026quot;:把几十座桥、多家 DEX 聚合器接进来，帮你算出一条最优路线，再把整串动作打包成一次可执行的交易。你可以把它理解成跨链版的 1inch/0x——只不过它聚合的不只是 DEX，还有一堆桥。\n三种接入姿势:Widget / SDK / 直接调合约 你想在自己的产品里加\u0026quot;跨链换币\u0026quot;，LI.FI 给了三个由浅入深的入口:\n① Widget:一段代码嵌一个完整 UI。 最省事，连界面都不用写。装个 @lifi/widget，丢进页面就有一个能选链、选币、比价、发交易的完整组件。\n② SDK / API:只要数据和执行，UI 自己做。 最常用的接入方式。核心就两步——问路线(getRoutes)、执行(executeRoute)，中间的选桥、编 calldata、授权、发交易、跨链状态轮询，SDK 全包了。\n③ 直接调合约:自己拼交易、自己发。 不走 SDK，链下拿到路线数据后，自己把参数发给 LI.FI 的入口合约 LiFiDiamond。适合需要极致控制、或要把跨链塞进自己合约逻辑里的场景。走这条路就得懂它的内部结构了——下面正式进入内部架构。\n三种姿势的分界很清楚:要快用 Widget，要灵活用 SDK，要极致控制直接调合约。绝大多数团队停在 SDK 这一层就够了。\n二、聚合器的内部架构 一次跨链到底分几步 要看懂内部，先建立心智模型。“USDC on Polygon → ETH on Arbitrum” 这件事，拆开其实是三段:\n①源链 swap ②跨桥 ③目标链 swap USDC ──DEX──▶ 桥认识的中转币 ──bridge──▶ 到账 ──DEX──▶ ETH (Polygon) (Arbitrum) 第①段:桥通常只认少数几种币。你手里的币桥不收，先在源链用一个 DEX 换成桥认识的中转币。 第②段:调具体某座桥(Stargate / Across / …)把中转币跨过去。 第③段:到了目标链，你要的最终币桥不给，再用目标链的 DEX 换一次。 第①③段不一定都有——桥直接收你的币、你要的就是到账的币，那这两段就省了。LI.FI 的合约要做的，就是把这条\u0026quot;可长可短\u0026quot;的链路，用一套统一的接口表达并执行。\nDiamond 模式:一个地址装下所有桥 桥有几十座，每座桥接入逻辑都不一样。最笨的做法是每座桥一个合约、用户面对一堆地址——没法维护。LI.FI 用的是 EIP-2535，也就是 Diamond(钻石)代理模式。\n核心结构叫 LiFiDiamond。它本身几乎没有业务逻辑，只做一件事:根据你调用的函数选择器(前 4 字节)，delegatecall 到对应的 facet(切面)合约去执行。\n┌───────────────────────────┐ 用户只面对 ──▶ │ LiFiDiamond (代理) │ ← 唯一入口地址 │ 按 selector 分发 delegatecall│ └──────────────┬────────────┘ ┌────────────┬────────┼─────────┬────────────┐ ▼ ▼ ▼ ▼ ▼ StargateFacet HopFacet AcrossFacet ... GenericSwapFacet (每座桥 = 一个 facet，只放这座桥的接入逻辑) delegatecall 是关键:它是\u0026quot;借你的代码，在我的存储上跑\u0026quot;。facet 里的代码执行时，读写的是 LiFiDiamond 的 storage、msg.sender 还是原始用户。所以对外永远是一个地址、一套状态，逻辑却散在几十个 facet 里。这套设计正好戳中跨链聚合器的痛点:\n可插拔升级:加一座新桥，只要部署一个新 facet，再通过 DiamondCut 把它的函数选择器挂上去，地址不变、用户不用改集成;下架同理，摘掉选择器。 绕开合约体积上限:以太坊单合约有 24KB 字节码上限，几十座桥塞一个合约早爆了，切成 facet 就没这问题。 配套工具 facet:DiamondCutFacet(增删改切面)、DiamondLoupeFacet(查有哪些 facet/函数)、OwnershipFacet、WithdrawFacet。 一个要留神的坑:所有 facet 共用钻石同一片 storage，两个 facet 若用同一个 slot 存不同东西就会存储冲突互相踩。Diamond 的解法是 \u0026ldquo;Diamond Storage\u0026rdquo;——每个 facet 用一个哈希算出的、几乎不可能撞的固定 slot 起点各存各的。\n两个核心结构体:BridgeData 与 SwapData 不管走哪座桥，LI.FI 都用两个结构体描述一次操作。吃透这俩，就懂了它接口设计的全部精髓。\nBridgeData——描述\u0026quot;这一趟跨链的意图\u0026quot;(桥无关的公共部分):\nstruct BridgeData { bytes32 transactionId; // 全局唯一 ID,串起源链和目标链的两笔交易 string bridge; // 走哪座桥,如 \u0026#34;stargate\u0026#34; string integrator; // 集成方(谁家 App 发起的),用于分成/统计 address referrer; // 推荐人 address sendingAssetId; // 源链上要发出去的币(address(0) 表示原生币) address receiver; // 目标链上的收款地址 uint256 minAmount; // 交给桥的最小数量(滑点保护下限) uint256 destinationChainId; // 目标链 ID bool hasSourceSwaps; // 源链上是否要先做 swap(对应第①段) bool hasDestinationCall; // 目标链上是否要再做一步调用(对应第③段) } 最后两个 bool 是路由\u0026quot;开关\u0026quot;:告诉合约这趟是三段全跑，还是只跨桥。hasSourceSwaps 为真就先在源链兑换再上桥;hasDestinationCall 为真就得带着目标链要执行的动作一起跨过去。\nSwapData——描述\u0026quot;一步 DEX 兑换\u0026quot;(源链、目标链的每一次 swap 都用它):\nstruct SwapData { address callTo; // 去调哪个合约做兑换(某个 DEX / 聚合器) address approveTo; // 把授权给谁(有时和 callTo 不是同一个) address sendingAssetId; // 换出的币 address receivingAssetId; // 换入的币 uint256 fromAmount; // 换出多少 bytes callData; // 喂给那个 DEX 的完整调用数据 bool requiresDeposit; // 是否需要先把币拉进来 } 注意 callData 这个 bytes 字段——它是一整段预先编码好的、要发给某个 DEX 的调用数据。也就是说 LI.FI 合约自己不懂任何一个 DEX 的接口，它只是拿着这段现成的 calldata 去 call。这一点是理解整个架构的关键，下面专门说。\nLibSwap:怎么执行一次\"不认识\"的兑换 上面说合约不懂任何 DEX，那 swap 到底怎么跑?逻辑集中在 LibSwap.swap()，动作朴素得很:\n校验 callTo 是个合约、fromAmount 不为 0; 记下兑换前目标币的余额(快照); 换出的是 ERC20 就给 approveTo 授权;是原生币就把数量作为 msg.value 带上; 对 callTo 发一个 low-level call，把 callData 原样打过去;失败就把底层错误冒泡出来; 再读一次目标币余额，用前后差值算出这次换回来多少，emit AssetSwapped。 看懂这个就明白:它是用\u0026quot;转账 + 授权 + 裸调用 + 余额做差\u0026quot;这套通用动作，去适配任意 DEX。今天接 0x、明天接 1inch、后天接 Paraswap，合约一行都不用改——因为具体怎么换，全写在链下拼好的那段 callData 里。\n精髓:寻路在链下，执行在链上 这是最容易被忽略、但最重要的一点:LI.FI 的合约本身不做\u0026quot;寻路\u0026quot;。\n\u0026ldquo;从哪座桥走最便宜、源链用哪个 DEX、滑点怎么设、calldata 怎么编\u0026rdquo;——这些是 LI.FI 的链下 API / SDK 算出来的。链下把最优路线规划好，编码成 BridgeData + SwapData[]，让你把这坨参数发给链上合约。合约的职责只有一个:忠实地、原子地把链下拼好的这套动作执行掉，中间哪一步失败就整笔回滚。\n链下(API/SDK):比价、选桥、算滑点、拼 calldata ──▶ 链上(LiFiDiamond):照单执行、失败回滚 “聪明”的部分 “可信”的部分 这套分工是几乎所有 DEX / 跨链聚合器的通用范式:易变、算力重的策略放链下，要求可信、原子的结算放链上。真正在源链 / 目标链执行兑换的，可以是 0x、1inch、Paraswap 这些外部聚合器，也可以是 LI.FI 自己的链上路由 LiFiDEXAggregator(fork 自 SushiSwap 的 RouteProcessor)。\n目标链的另一半:Receiver 还有第③段——目标链的 swap——它其实发生在另一条链、另一笔交易里，源链交易管不着。桥能保证的只是\u0026quot;把中转币送到目标链的某个地址\u0026quot;。\n所以 LI.FI 在目标链部署了 Receiver 类合约作为收款落点:桥把币打给它，它带着当初 hasDestinationCall 一起跨过来的指令，在目标链上替你把最后一步兑换做掉，再把成品打给真正的 receiver。这就是为什么 BridgeData 里要有 hasDestinationCall 这个开关——它决定了目标链到账后是\u0026quot;直接给你\u0026quot;还是\u0026quot;还得再折腾一下\u0026quot;。\n三、对比:它对标 UniswapX 吗? 会问这个问题很自然:UniswapX 也在做\u0026quot;跨链、找最优执行\u0026quot;，那 LI.FI 是不是就是跨链版的它?答案是:目标有重叠，但架构范式完全不同，算不上严格一对一对标。 差别在于\u0026quot;谁来执行、怎么定价\u0026quot;。\nLI.FI 是\u0026quot;聚合器/路由\u0026quot;范式:链下帮你算好路线，但交易是你自己发的、gas 是你自己付的、执行风险(滑点、桥出问题)也是你承担。它的价值在于把几十座桥和多个 DEX 聚合到一个接口，帮你选路——正是上面内部架构讲的那套。\nUniswapX 是\u0026quot;意图(intent)/拍卖\u0026quot;范式:你不发交易，而是签一个离线订单——只声明\u0026quot;我出多少 A、最少要拿回多少 B\u0026quot;，剩下的不管。然后一批叫 filler(solver) 的专业做市商，在一个荷兰式拍卖里竞争:报价从高往低降，谁先愿意用最好的价格成交谁就接单，由 filler 替你上链、替你垫 gas。跨链时，filler 先在目标链把币打给你，再通过一个结算预言机(消息桥)把你锁在源链的输入放给 filler(这套正是 ERC-7683 跨链意图标准的落地)。\n一个对比就清楚了:\nLI.FI UniswapX 范式 聚合器 / 路由 意图 + 荷兰拍 用户做什么 发一笔交易 签一个离线订单 谁执行、谁付 gas 你自己 filler 替你做、替你垫 定价 链下比价选路 filler 竞价(RFQ 定起拍价) 聚合的是 一堆桥 + 多个 DEX filler 背后的任意流动性 跨链方式 直接调桥(桥选择透明) filler 垫付 + 结算预言机回收 用户的桥风险 直接暴露(你走哪座桥你知道) 转嫁给 filler，用户少接触桥 长尾/薄流动性币 有桥有 DEX 就能拼 没 filler 有货就可能流拍 一句话总结这场对比:LI.FI 把\u0026quot;复杂性\u0026quot;透明地摊在你面前让你选路、你执行;UniswapX 把\u0026quot;复杂性\u0026quot;整个甩给 filler，你只签个意图、坐等收货。两者都在解决\u0026quot;跨链最优执行\u0026quot;，方向也在收敛(UniswapX 往跨链走，LI.FI 也在做 intent 能力)，但出发点一个是**\u0026ldquo;聚合执行\u0026rdquo;、一个是\u0026ldquo;意图 + 竞价\u0026rdquo;**——殊途同归的两条路线，而不是同一套东西。\n四、LI.FI Intents:意图市场怎么运作 定位:和聚合器是两套东西 上面留了个尾巴——\u0026ldquo;LI.FI 也在做 intent 能力\u0026rdquo;。这不是 roadmap 上的空话，而是一条已经上线的独立产品线:LI.FI Intents,一个 solver(做市商)报价市场。\n要先钉死一件事:它和上半篇讲的聚合器是两套东西，不是同一个接口换了个名字。\n聚合器(LiFiDiamond):链下给你算路线，你自己发交易、自己付 gas、自己承担桥的风险，桥是谁你一清二楚。 Intents:你只表达\u0026quot;我出 10 USDC on Base，要 Arbitrum 上的 USDC\u0026quot;，一批 solver 用自己的钱先在目标链把币给你，之后再回源链把你锁住的输入拿走。选路、桥的风险、跨链等待，全转移到 solver 那一侧。 它还有个身份值得单独提:LI.FI Intents 是 OIF(Open Intents Framework) 的官方实现。OIF 是以太坊基金会牵头的开源意图框架，合约在 openintentsframework/oif-contracts。这跟上文提到的 ERC-7683 是同一条脉络——7683 定义\u0026quot;跨链订单长什么样\u0026quot;(统一订单结构 + 结算接口)，OIF 往前又走一步:把锁资金、验证交付、跨链传消息这三件事彻底拆成可自由组合的模块。所以这一章学到的东西不只对 LI.FI 有用，它正在变成行业公共标准。\n角色与组件:六个名字先认清 名字 在链上/链下 干什么 Intent 用户表达 只描述\u0026quot;想要的结果\u0026quot;,不指定执行路径 Solver 链下 + 双链发交易 挂报价、用自有资金垫付填单、事后回源链收钱 Input Settler 源链合约 管源链的钱:锁进来、验证通过后放给 solver ├ InputSettlerEscrow 源链 逐笔托管:一单一锁 └ InputSettlerCompact 源链 一次充值:基于 Uniswap 的 The Compact 资源锁，之后多次下单免 gas Output Settler 目标链合约 接收 solver 的交付、记录 fill、产出可验证的凭证 Oracle 跨链消息层 把\u0026quot;目标链确实交付了\u0026quot;这个事实搬回源链 Order Server 链下(LI.FI 运营) 把用户 intent 撮合到 solver 已挂的报价上，并分发订单流 最容易混的是 Input Settler / Output Settler / Oracle 的分工,一句话记法:Output Settler 负责\u0026quot;证明发生了什么\u0026quot;，Oracle 负责\u0026quot;把这个证明搬过去\u0026quot;，Input Settler 负责\u0026quot;看到证明就付钱\u0026quot;。\n一次 intent 的完整生命周期 源链 (Base) 目标链 (Arbitrum) ───────────────────────────────────────────────────────────────────────── ① 用户锁钱 + 声明想要什么 InputSettlerEscrow.open(order) ──emit Open──┐ │ ② 订单流分发 ▼ Order Server ◀── WebSocket/链上事件 ──▶ Solver 集群 │ ③ 撮合:拿 intent 去匹配 solver 早就挂好的报价 │ ▼ ④ Solver 用自己的钱交付 OutputSettler.fill(...) ← 谁先 fill 谁赢 │ ⑤ 验证 + 结算 Oracle 打包凭证并跨链提交 InputSettler.finalise() ◀────── efficientRequireProven ────┘ → 托管的 10 USDC 放给 solver 几个细节决定了这套东西的手感:\n步骤①之后用户就没事了。不用等、不用再签、不用管走哪座桥。到账速度取决于 solver 的垫资意愿，通常是秒级。 步骤④的胜负由速度决定:\u0026ldquo;第一个调用 fill(...) 并写下自己 identifier 的 solver\u0026quot;拿走这一单。多输出订单里，谁填掉第一个 output 谁就拿走全部 inputs,但所有 output 填完之前 inputs 一直锁着。 步骤⑤是 solver 承担的风险窗口:钱已经垫出去了，凭证还没回到源链。这段时间的跨链消息延迟、gas 波动、甚至 oracle 出问题，都算在 solver 头上——这也是为什么用户端体验能那么快。 两个 deadline 别搞混:fillDeadline 是 solver 必须填完的截止时间;expires 之后用户才能领退款。fillDeadline 一定要留足余量早于 expires,否则会出现\u0026quot;solver 刚填完、用户已经能退款\u0026quot;的尴尬窗口。官方 quickstart 给的是 fill 30 分钟、expire 60 分钟。 关键设计:standing quotes,不是逐笔 RFQ 这是 LI.FI Intents 和 UniswapX / 传统 RFQ 最不一样的一点，也是它敢说自己延迟低的原因。\n传统 RFQ 是\u0026quot;问价\u0026rdquo;:每来一笔单子，广播给所有 solver，等它们各自报价，再挑最优。往返一圈，几百毫秒到几秒就没了，而且 solver 不报你就没价。\nLI.FI Intents 是\u0026quot;挂单簿\u0026quot;:solver 提前把自己的报价曲线批量上传——哪些路由、什么价格曲线、支持多大金额区间,一次能推最多 20 万条报价。用户的 intent 进来时，order server 直接在这份已有库存里查表撮合。\nRFQ: intent ──▶ 广播 ──▶ 等 N 个 solver 回价 ──▶ 选最优 (每笔都要等一轮) Standing: solver ──▶ 预先上传 20 万条报价曲线 ↓ intent ──▶ 直接查表撮合 (零往返) 代价是报价的时效性:挂出去的曲线不是实时算的，市场剧烈波动时 solver 得自己勤快地刷新库存，否则要么亏、要么撤单。这也解释了为什么它的可用路由是动态的——覆盖范围随 solver 的实时库存一直在变，官方文档里的链和路由列表甚至是前端实时拉 GET /chains/supported 和 GET /routes 渲染出来的，而不是写死的。\n三段解耦:OIF 真正的技术贡献 上文说过聚合器的精髓是\u0026quot;寻路在链下、执行在链上\u0026quot;。Intents 这一侧的精髓是另一句:把锁资金、验证交付、跨链传消息拆开,各自可替换。\n以前的意图/跨链协议这三件事是焊死在一起的——换个消息桥就得重写结算合约。OIF 的做法是只规定极窄的接口。\nOutput Settler 只需要暴露一个校验函数:\ninterface IPayloadCreator { function arePayloadsValid(bytes[] calldata payloads) external view returns (bool); } 注意 payloads 是 不透明的 bytes[]。这意味着一次 fill 只要能被表达成一串字节，什么订单类型、什么虚拟机都能塞进来——这就是为什么同一套框架能同时挂上 EVM、Tron，甚至有个 BitcoinOracle.sol。\nOracle 提交前先自证:\nfunction submit(address proofSource, bytes[] calldata payloads) external payable { if (!IPayloadCreator(proofSource).arePayloadsValid(payloads)) revert NotAllPayloadsValid(); _submit(proofSource, payloads); } Input Settler 只问一句\u0026quot;证过了吗\u0026quot;:\ninterface IValidationLayer { /// @param proofSeries remoteOracle、remoteChainId、dataHash 按 32*4=128 字节分块编码 function efficientRequireProven(bytes calldata proofSeries) external view; } 这个函数不返回 bool,而是不满足就 revert——省掉调用方的分支判断，也让\u0026quot;空证明序列直接通过\u0026quot;这种同链场景自然成立。跨链只搬 payload 的哈希而不是完整数据，gas 便宜，但代价是:因为 payload 本身不标准，输入侧和输出侧的编码有可能互不兼容,官方文档自己也提示了这个坑。\n现成的 oracle 实现有三个:PolymerOracleMapped.sol(默认，走 Polymer)、WormholeOracle.sol、BitcoinOracle.sol。\n同链交易是这套架构的一个漂亮特例:订单格式完全不变,但跨链消息那一段直接塌缩掉——把 output settler 同时配成 input oracle 和 output oracle,它自己给自己作证,不需要任何外部验证层。所以\u0026quot;同链 swap\u0026quot;和\u0026quot;跨链 swap\u0026quot;在这套 API 里是同一个接口,这也是 LI.FI Intents 敢同时卖同链和跨链两种流量的原因。\n两种锁资金方式:Escrow 还是 Compact 这是接入时的第一个真正的架构选择。\nEscrow Compact(资源锁) 锁定方式 逐笔,每单一次链上锁 一次充值,余额反复用 提单方式 链上 open / openFor 链下 POST /orders/submit Gas 每单都要付 首次充值后免 gas 用户签什么 一笔交易 一个 EIP-712 BatchCompact 签名 结算函数 finalise(solver 自己调) finaliseWithSignature(任何人可调) 订单类型标识 链上检测 CatalystCompactOrder 适合谁 绝大多数集成方 高频下单、要 gasless、或本身就是资源锁原生应用 Compact 用的是 Uniswap 的 The Compact 资源锁。它的信任模型多了两个角色,值得单独理解:\nsponsor = 用户,锁里的钱是他的; arbiter = InputSettlerCompact 合约,负责最终裁定放款; allocator = 提供 nonce 域的角色,职责是不给超出用户余额的重叠锁重复签名。 安全边界因此是:用户信任 arbiter 不会欺诈性地 finalise，arbiter 和 solver 信任 allocator 不会超额共签。没有任何单一角色能独立动用资金——这是资源锁相对\u0026quot;直接托管\u0026quot;的核心卖点:钱在你自己的锁里,不在别人的合约里躺着。\n注意\u0026quot;gasless\u0026quot;是个有条件的说法:提单确实零 gas,但首次充值(Escrow 是每笔的 approve/deposit)仍然是实打实的链上交易。文档也直说了:多数集成方先用 Escrow,有 gasless 刚需再上 Compact。\n合约地址(EVM 各链同址,靠 keyless CREATE2 工厂部署,新 EVM 链可以无许可加):\n合约 地址 InputSettlerEscrow 0x00fC00edbe7C003b006f870068c548940000223e InputSettlerCompact 0x0000000000cd5f7fDEc90a03a31F79E5Fbc6A9Cf The Compact 0x00000000000000171ede64904551eeDF3C6C9788 OutputSettler 0x75220B7600c300005038432a0000f308e0000068 Polymer Oracle(主网) 0x008C3800F3Ad9b3B662d002E90Cc00000000eE17 Polymer Oracle(测试网) 0xC401b53377b8A71A7cEB820e6a4dC53832343a90 Tron 另有一套:InputSettlerEscrow = TXmVLCXzrhzmeCfchDPTmFF6Qe7rg3H7Kk、OutputSettler = THWDD3umarircbqo8jXxVazbpJnE25VjhN、Polymer Oracle = TCeNWukZUoTSrgWZEMpn9X8C5NtV8Rsy6c。\n订单类型:一个 context 字段撑起四种拍卖 目前只有一个输出结算器 OutputSettlerSimple.sol,但它靠 MandateOutput.context 的首字节区分出四种订单:\n类型 首字节 语义 限价单 Limit 0x00 或空 0x 固定价,谁先填谁得 独占限价单 Exclusive Limit 0xe0 指定 solver 在窗口内独占,LI.FI Widget 的默认类型 荷兰拍 Dutch 0x01 价格随时间衰减 独占荷兰拍 Exclusive Dutch 0xe1 独占窗口 + 衰减曲线 MandateOutput 结构体本身很规整:\nstruct MandateOutput { bytes32 oracle; // 目标链用哪个 oracle 作证 bytes32 settler; // 目标链的 output settler uint256 chainId; // 目标链 bytes32 token; // 要交付的币 uint256 amount; // 要交付的量(荷兰拍里这是\u0026#34;地板价\u0026#34;) bytes32 recipient; // 收款人 bytes call; // 交付后要执行的自定义 calldata bytes context; // ← 拍卖类型全靠它 } 地址字段全是 bytes32(左填充),这样非 EVM 链的地址也塞得下——又是\u0026quot;为多虚拟机留门\u0026quot;的设计。\ncontext 的编码:\n限价单: context = \u0026#34;0x\u0026#34; 或 \u0026#34;0x00\u0026#34; 独占限价单: bytes1(0xe0) | bytes32(exclusiveFor) | bytes4(startTime) 荷兰拍: bytes1(0x01) | uint32(startTime) | uint32(stopTime) | uint256(slope) 独占荷兰拍: bytes1(0xe1) | bytes32(exclusiveFor) | uint32(startTime) | uint32(stopTime) | uint256(slope) 荷兰拍的价格公式(合约里就这么几行):\nuint32 currentTime = max(block.timestamp, startTime); if (stopTime \u0026lt; currentTime) return amount; uint256 timeDiff = stopTime - currentTime; return amount + slope * timeDiff; 读法:solver 需要交付的数量 = 地板价 amount + 剩余时间 × 斜率。所以\n天花板是 amount + (stopTime - startTime) * slope,出现在 startTime; 越早填,solver 交付得越多(solver 越亏、用户越赚); 时间推向 stopTime,线性衰减到地板价 amount; 过了 stopTime 就是平的限价单。 于是形成一场博弈:solver 想等价格衰减到自己有利润再动手，但等着就有被别人抢单的风险。这正是荷兰拍在替用户压价——和 UniswapX 的荷兰拍是同一个机理,只不过 LI.FI 这套是在链上按 block.timestamp 结算的,不依赖链下拍卖服务。\n独占窗口的用处是省 gas:在 startTime 之前只有指定的 exclusiveFor 能填,startTime 之后放开给所有人。配合 order server 的 Reputation(声誉)系统,可以把单子先给历史表现好的 solver,避免一堆 solver 同时抢单、大部分交易 revert 掉白烧 gas。官方建议的窗口是 30 秒到 1 分钟。\n两个实现时要留神的点:\n多输出的荷兰拍只有第一个 output 是拍卖,其余全按最差价格结算。原因很直白:solver 只在第一个 output 上竞争(赢下它就等于赢下整单)。 官方文档里独占荷兰拍的类型标识写的是 0xe1,但编码示例里的首字节写成了 0x01——这两处对不上,实现前请以 oif-contracts 里 OutputSettlerSimple.sol 的源码为准。 同一个 orderId 下每个 output 只能被填一次,合约把 output 哈希后记在按 orderId 索引的 mapping 里去重。 参考 LI.FI 合约仓库(lifinance/contracts) LI.FI Intents 官方文档 · Introduction LI.FI Intents Quickstart(Escrow 完整代码) LI.FI Intents 架构总览 · 拍卖机制 · Solver API Order Server 交互式 API 文档 Open Intents Framework(OIF) · oif-contracts 源码 The Compact(Uniswap 资源锁) UniswapX Overview | Uniswap Developers How Dutch Auctions Deliver Better Swaps | Uniswap Blog ERC-7683 跨链意图标准解读 | BuildBear ","date":"August 20, 2026","img":"/webthree/lifi/cover.png","lang":"zh-cn","langName":"","largeImg":"/webthree/lifi/cover_hu_b8039087b533904d.png","permalink":"/webthree/lifi/","series":[],"smallImg":"/webthree/lifi/cover_hu_5a0c291b01ae04c.png","tags":[{"title":"Web3","url":"/tags/web3/"}],"timestamp":1787234936,"title":"路由协议-lifi"},{"categories":[{"title":"数据结构","url":"/categories/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"content":" Summary 用一个函数直接\u0026quot;算\u0026quot;出元素在哪，一步到位 O(1)。它的灵魂就一句话:哈希表 = 数组 + 一个把 key 变成下标的哈希函数，用\u0026quot;算地址\u0026quot;代替\u0026quot;找元素\u0026quot;。代价是天下没有免费的午餐:key 无限、桶有限，冲突必然发生，于是又牵出拉链法/开放寻址两套解决方案，以及装载因子 + 扩容 rehash 这套动态维稳机制。\n核心思想:把 key 变成数组下标 数组的超能力是 base + i * size 一步算出地址。但我们手里的 key 是字符串、地址、结构体，不是下标。哈希表就架一座桥:拿一个哈希函数把任意 key 砸成一个整数，再对桶数组长度取模，得到下标:\nkey ──hash()──▶ 一个大整数 ──% len──▶ 下标 ──▶ 桶(bucket) \u0026#34;apple\u0026#34; ─hash→ 3208616981 % 8 = 5 ─▶ 桶[5] \u0026#34;banana\u0026#34; ─hash→ 773291043 % 8 = 3 ─▶ 桶[3] 哈希函数长什么样?不神秘，就是一堆位运算把字节搅匀。工程里常用 FNV-1a，十来行就能写出一个把字符串打散得很均匀的版本:\n// FNV-1a:把任意字符串搅成一个 uint32 func hash(key string) uint32 { var h uint32 = 2166136261 // FNV offset basis for i := 0; i \u0026lt; len(key); i++ { h ^= uint32(key[i]) // 异或吃进一个字节 h *= 16777619 // 再乘一个大质数,把影响扩散到所有位 } return h } 一个能用的哈希函数要满足两点:一是确定性，同一个 key 每次算出同一个值(不然存进去就找不回来);二是均匀，把 key 尽量摊平到各个桶，别都挤一个格子——做不到均匀，哈希表就退化成链表了。\n上面取模我写的是 % len，但你翻 Go、Java 的源码会发现它们用的是 hash \u0026amp; (len-1)。这不是炫技:当桶数组长度是 2 的幂时，hash \u0026amp; (n-1) 完全等价于 hash % n，而位与比取模快得多。 这也是为什么它们的桶数永远是 2 的幂。\n哈希冲突 key 是无限的(任意长的字符串)，桶是有限的(就那么长的数组)，迟早两个不同的 key 会算到同一个下标——这就是哈希冲突。上面 banana 和别的词都可能落到桶 3。冲突不是\u0026quot;能不能避免\u0026quot;，是必然发生、只能想办法收拾。两条主流路子:\n拉链法(链地址法) 桶里不直接存值，而是存一条链表的头。撞到同一个桶的元素，串成一条链挂后面。查找时先定位桶，再沿链比对 key。冲突少时链很短、接近 O(1);冲突多时链变长、退化到 O(n)。这就是经典的\u0026quot;数组 + 链表\u0026ldquo;组合，Go、Java 8 之前的 HashMap 都是这一类。\n桶[3] ─▶ [banana|7] ─▶ [cherry|9] ─▶ nil 开放寻址法 不用链表，所有元素都直接住数组里。撞了就往后找下一个空位(最简单的叫线性探测):\n桶[3] 被 banana 占了 → cherry 来了看桶[4] → 空,cherry 住桶[4] 好处是全部连续存储、缓存友好，没有链表指针到处跳。坏处是删除麻烦:不能直接清空格子，否则探测链断了(后面的元素就找不到)，得打个\u0026rdquo;墓碑(tombstone)\u0026ldquo;标记;而且越满探测越慢，对装载因子更敏感。Python 的 dict 走这条，Go 1.24+ 的 map 也从拉链法换到了这条路——这个反转后面会细说。\n拉链法 开放寻址 存储 数组 + 链表 纯数组 缓存友好 一般(链表跳来跳去) 好(连续) 删除 简单,断链即可 麻烦,要墓碑 高装载因子 抗压(链变长而已) 脆(探测急剧变慢) 额外内存 每个元素多一个指针 省 手写一个拉链法哈希表 光说不练没用，我们把上面这套亲手撸出来。一个桶是一条冲突链，节点带 next:\ntype entry struct { key string value int next *entry // 同一个桶里的冲突链 } type HashMap struct { buckets []*entry // 桶数组:每个元素是一条冲突链的头 size int // 已存元素个数(算装载因子用) } func NewHashMap() *HashMap { return \u0026amp;HashMap{buckets: make([]*entry, 8)} // 初始 8 个桶(2 的幂) } // 算下标:长度是 2 的幂,用位与代替取模 func (m *HashMap) index(key string) int { return int(hash(key) \u0026amp; uint32(len(m.buckets)-1)) } Put——先看装载因子要不要扩容，再沿链找:key 已存在就更新，否则头插一个新节点:\nfunc (m *HashMap) Put(key string, value int) { // 装载因子 = size/桶数,超过 0.75 先扩容再插 if float64(m.size)/float64(len(m.buckets)) \u0026gt; 0.75 { m.resize() } i := m.index(key) for e := m.buckets[i]; e != nil; e = e.next { if e.key == key { // key 已存在:更新值,不新增 e.value = value return } } // 不存在:头插(O(1),新节点接到链头) m.buckets[i] = \u0026amp;entry{key: key, value: value, next: m.buckets[i]} m.size++ } Get——定位桶，沿链比对 key。用 (int, bool) 双返回值区分\u0026quot;值是 0\u0026quot;和\u0026quot;根本没这个 key\u0026rdquo;:\nfunc (m *HashMap) Get(key string) (int, bool) { for e := m.buckets[m.index(key)]; e != nil; e = e.next { if e.key == key { return e.value, true } } return 0, false } Delete——删链表节点要处理\u0026quot;删头\u0026quot;的边界。这里用二级指针 **entry，让删头和删中间走同一套逻辑，省掉 if 是头节点 的特判:\nfunc (m *HashMap) Delete(key string) { pp := \u0026amp;m.buckets[m.index(key)] // 指向\u0026#34;当前指针\u0026#34;的指针 for *pp != nil { if (*pp).key == key { *pp = (*pp).next // 直接改上一级的指向,绕过被删节点 m.size-- return } pp = \u0026amp;(*pp).next } } resize——最关键的一步:桶数翻倍，把每个旧元素按新长度重新落桶。注意不能照搬旧下标——桶数变了，hash \u0026amp; (n-1) 的结果全变了:\nfunc (m *HashMap) resize() { old := m.buckets m.buckets = make([]*entry, len(old)*2) // 桶数翻倍 m.size = 0 for _, head := range old { // 遍历旧桶的每条链 for e := head; e != nil; e = e.next { m.Put(e.key, e.value) // 用新长度重新算下标、重新落桶 } } } 一百多行，一个能用的哈希表就齐了。跑一下:\nm := NewHashMap() m.Put(\u0026#34;apple\u0026#34;, 7) m.Put(\u0026#34;banana\u0026#34;, 9) m.Put(\u0026#34;apple\u0026#34;, 42) // 覆盖 fmt.Println(m.Get(\u0026#34;apple\u0026#34;)) // 42 true fmt.Println(m.Get(\u0026#34;cherry\u0026#34;)) // 0 false m.Delete(\u0026#34;banana\u0026#34;) fmt.Println(m.Get(\u0026#34;banana\u0026#34;)) // 0 false 装载因子与扩容:均摊 O(1) 的来由 上面 Put 里那行 \u0026gt; 0.75 就是装载因子阈值。装载因子 = 元素数 / 桶数，衡量\u0026quot;挤不挤\u0026quot;:太高冲突多、链变长、退化 O(n);太低一堆空桶、浪费内存。所以到阈值就翻倍扩容 + 全量 rehash。\nrehash 要遍历所有元素，这一次是 O(n)。但它不是每次插入都发生，而是攒够了才来一趟，摊到每一次插入上——均摊仍是 O(1)，和数组 append 翻倍扩容是完全一样的均摊思想。为什么翻倍能把冲突打散:\n旧数组 n=4,元素挤在桶 3 → 扩容到 n=8 后重新取模 下标 3: 3 \u0026amp; 3 = 3, 3 \u0026amp; 7 = 3 → 还在桶 3 下标 7: 7 \u0026amp; 3 = 3, 7 \u0026amp; 7 = 7 → 挪到桶 7 原来同挤桶 3 的两个,扩容后被拆到 3 和 7,冲突缓解 我这个手写版 resize 是一次性搬完所有元素——几百万个 key 时这一下会明显卡顿。生产级实现都不会这么干,思路是把这次 O(n) 的开销摊平到后续操作里,避免单次长停顿。\nRedis 用的是经典的渐进式 rehash:新旧两个哈希表并存,之后每次读写顺手搬一小撮过去,搬完才丢掉旧表。Go 1.24+ 换了个更巧的办法——它把数据分成多张小表,哪张满了就单独分裂哪张,一次只动一小部分,\u0026ldquo;渐进\u0026quot;就成了结构自带的性质(后面细说)。思想是一样的:别一次搬完,改成分期付款。\nGo 的 map 到底长什么样 手写版懂了，本来想说\u0026quot;Go 的 map 就是同一套思想的工业级加强版\u0026rdquo;——但这话现在不能说了。Go 1.24 把 map 整个重写了，换的不是参数，是算法。\nGo ≤ 1.23 拉链法的加强版 runtime/map.go hmap / bmap，靠 overflow 挂链 Go ≥ 1.24 Swiss Tables internal/runtime/maps/ Map / table / group，开放寻址，没有链 方向变了:从前面对比表里的\u0026quot;拉链法\u0026quot;那一栏，跳到了\u0026quot;开放寻址\u0026quot;那一栏。这套东西来自 Google Abseil 的 flat_hash_map，业界一般叫 Swiss Tables。\n下面讲 1.26.3 的现行实现，源码在 internal/runtime/maps/ 下的 map.go、table.go、group.go。\n核心:8 个槽 + 一个控制字 Map └─ dirPtr ─▶ [ table0 │ table1 │ ... ] 目录，存多张 table │ └─▶ [ group0 │ group1 │ ... ] 一个 group: ┌───────────────────────────────────────────────┐ │ ctrl(8 字节) 每槽 1 字节: 空 / 墓碑 / h2 │ ← 精华在这 ├───────────────────────────────────────────────┤ │ slots[8] 8 个 key/elem 对 │ └───────────────────────────────────────────────┘ 查找就三步，关键全在那个控制字上:\nkey 的哈希拆成 h1(选哪个 group)和 h2(高 7 位，存进控制字) 定位到 group 之后，把 8 个控制字节一次性和 h2 比对——amd64 上是真的 SIMD 指令(pcmpeqb + pmovmskb)，非 x86 平台用 uint64 位运算模拟同样的效果 拿到一个\u0026quot;哪几个槽可能匹配\u0026quot;的位掩码，只对这几个槽真正去比完整 key 这就是它甩开拉链法的地方:一条指令筛掉 8 个槽。 而我们手写的那个链表，每次都要一个个跳指针，每跳一下都可能是一次 cache miss。\n槽满了怎么办?不挂溢出桶，而是开放寻址——按二次探测的顺序去下一个 group 找空位。所以 1.24+ 的 map 里压根没有\u0026quot;链\u0026quot;这个东西了。\n跟我们手写版比，差别在这几点 一次比 8 个，而不是一个个跳。 手写版查一个 key 要顺着链表一路 node = node.next，Go 一条 SIMD 指令就把 8 个候选筛完。 冲突不挂链，改成往后找。 手写版桶满了就往链表尾巴上挂;Go 直接跳到下一个 group 探测。少了指针跳转，全是连续内存访问。 删除要留\u0026quot;墓碑\u0026quot;。 这是开放寻址绕不过去的坑:不能直接把槽清空，否则会截断后面的探测链——后面的元素就永远找不到了。所以标记成 tombstone。手写版的拉链法没这个问题，直接摘链表节点就行。 每个 group 至少留一个空槽(最大装载因子 7/8 = 87.5%)，保证探测一定能停下来。 小 map 有专门快路径。 元素 ≤ 8 时，dirPtr 直接指向一个 group，连 table 那层都不要——因为现实里绝大多数 map 都很小，这条快路径很值。 扩容不再是\u0026quot;重建整个 map\u0026quot;。 目录里哪张 table 装满了，就单独把那一张分裂成两张(可扩展哈希)。所以\u0026quot;渐进式搬迁\u0026quot;变成了结构自带的性质，不用像旧实现那样手工维护\u0026quot;新旧两个桶数组 + 每次操作搬 1~2 个桶\u0026quot;。 写到这我自己的一点感慨:开放寻址在教科书里通常被讲成\u0026quot;拉链法的备选方案\u0026quot;，实践中好像总是拉链法赢。 但 Swiss Tables 说明胜负是会翻的——一旦硬件给了你\u0026quot;一条指令并行比 8 个字节\u0026quot;这种能力，算法的账就得重算一遍。\u0026ldquo;哪个方案更好\u0026quot;从来不是纯理论问题，它取决于你跑在什么硬件上。\n三个必踩的坑 坑 1:遍历顺序是随机的。 Go 故意在遍历时加随机起点，就是逼你别依赖顺序:\nm := map[string]int{\u0026#34;a\u0026#34;: 1, \u0026#34;b\u0026#34;: 2, \u0026#34;c\u0026#34;: 3} for k := range m { fmt.Print(k, \u0026#34; \u0026#34;) // 每次运行顺序都可能不同:c a b / b c a ... } // 要有序,自己捞出来排: keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) // a b c 坑 2:并发读写直接 fatal，recover 都拦不住。 不是普通 panic，是整个进程挂掉:\nm := map[int]int{} go func() { for { m[1] = 1 } }() // 一个协程写 go func() { for { _ = m[1] } }() // 另一个协程读 // fatal error: concurrent map read and map write 并发场景老实加锁(sync.RWMutex)，或读多写少用 sync.Map。\n坑 3:不能对 map 元素取地址。 因为扩容会搬动元素、地址随时失效，编译器干脆禁止:\ntype Point struct{ X, Y int } mp := map[string]Point{\u0026#34;a\u0026#34;: {1, 2}} // mp[\u0026#34;a\u0026#34;].X = 10 // 编译错误:cannot assign to struct field mp[\u0026#34;a\u0026#34;].X p := mp[\u0026#34;a\u0026#34;] // 正确:整体取出 p.X = 10 // 改 mp[\u0026#34;a\u0026#34;] = p // 放回 // 或者干脆存指针:map[string]*Point,直接 mp[\u0026#34;a\u0026#34;].X = 10 ","date":"August 14, 2026","img":"/datastruct/hashtable/featured.png","lang":"zh-cn","langName":"","largeImg":"/datastruct/hashtable/featured_hu_10327d5c4e56b901.png","permalink":"/datastruct/hashtable/","series":[],"smallImg":"/datastruct/hashtable/featured_hu_a57777f05b9601c3.png","tags":[{"title":"数据结构","url":"/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"timestamp":1786693049,"title":"数据结构-Hashtable"},{"categories":[{"title":"数据结构","url":"/categories/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"content":" Summary 堆栈和队列是一种特殊的线性结构，同时也是一种受限的数据结构，堆栈的处理顺序是“最近发生的，最先处理”，也就是我们常说的先进后出 LIFO（Last In First Out）这种顺序处理通常适合回退、DFS、函数调用栈；队列的处理顺序是“先来的先服务”，也就是先进先出 FIFO（First In First Out），这种顺序处理通常放到任务调度、消息中间件、BFS（广度优先搜索）。\n堆栈 这里说的堆栈其实就是栈这个数据结构，这里有必要解释一下，因为堆这个术语有很多地方在使用，容易造成术语撞车。\n\u0026ldquo;堆栈\u0026rdquo;:它就等于\u0026quot;栈(stack)\u0026quot; 中文里的 \u0026ldquo;堆栈\u0026quot;是 stack 的历史直译，是一个双字词，整体指的就是栈(后进先出，LIFO)。里面那个\u0026quot;堆\u0026quot;字没有独立含义，只是凑成词，它既不是队列，也不是\u0026quot;堆数据结构\u0026rdquo;。\n所以讲\u0026quot;函数调用堆栈\u0026quot; \u0026ldquo;堆栈溢出(stack overflow)\u0026ldquo;时，说的都是栈。\u0026ldquo;堆栈里的堆\u0026quot;本身不是一个单独的结构\n\u0026ldquo;堆(heap)\u0026rdquo; 单独出现时，有两个完全不同的意思 内存的堆(heap memory):程序运行时动态分配内存的区域(Go 里 new/逃逸到堆上的对象)。它和\u0026quot;栈内存(存局部变量、函数帧)\u0026ldquo;相对。这个\u0026quot;堆\u0026quot;和数据结构的堆没有任何关系，纯粹是术语撞车。 数据结构的堆(heap，二叉堆):一棵完全二叉树。 数据结构的堆 ≈ 优先队列，但不是普通队列 普通队列(queue):先进先出(FIFO)，谁先来谁先走。 堆:它实现的是 优先队列(priority queue) 这种抽象——出队的不是最早进来的，而是优先级最高/最低的那个(最大堆出最大值，最小堆出最小值)。 核心操作 先进后出，就像是在摞盘子，你只能往最上面放，拿的时候也只能从最上面拿。\n两个核心操作:\n入栈 push:把元素放到栈顶。 出栈 pop:取出并移除栈顶元素。 辅助操作:peek(只看栈顶不取出)、isEmpty(是否为空)、size(元素个数)。\npush ↓ ↑ pop ┌─────┐ 栈顶 top ───▶ │ 30 │ ← 最后进，最先出 ├─────┤ │ 20 │ ├─────┤ 栈底 bottom ─▶ │ 10 │ ← 最先进，最后出 └─────┘ 所有核心操作都是 O(1)——只操作栈顶，不涉及其他元素。\n两种实现:数组栈 vs 链表栈 数组栈 type ArrayStack struct { data []any } func NewArrayStack() *ArrayStack { return \u0026amp;ArrayStack{data: make([]any， 0)} } func (s *ArrayStack) Push(v any) { s.data = append(s.data， v) } func (s *ArrayStack) Pop() any { if s.IsEmpty() { return nil } v := s.data[len(s.data)-1] s.data = s.data[:len(s.data)-1] return v } func (s *ArrayStack) Peek() any { if len(s.data) == 0 { return nil } return s.data[len(s.data)-1] } func (s *ArrayStack) IsEmpty() bool { return len(s.data) == 0 } 链表栈 type ListStack struct { Val any Next *ListStack } func NewListStack() *ListStack { return \u0026amp;ListStack{Next: nil} } func (s *ListStack) Push(v any) { s.Next = \u0026amp;ListStack{Next: s.Next， Val: v} } func (s *ListStack) Pop() any { if s.IsEmpty() { return nil } v := s.Next.Val s.Next = s.Next.Next return v } func (s *ListStack) Peek() any { if s.IsEmpty() { return nil } return s.Next.Val } func (s *ListStack) IsEmpty() bool { return s.Next == nil }type ListStack struct { Val any Next *ListStack } func NewListStack() *ListStack { return \u0026amp;ListStack{Next: nil} } func (s *ListStack) Push(v any) { s.Next = \u0026amp;ListStack{Next: s.Next， Val: v} } func (s *ListStack) Pop() any { if s.IsEmpty() { return nil } v := s.Next.Val s.Next = s.Next.Next return v } func (s *ListStack) Peek() any { if s.IsEmpty() { return nil } return s.Next.Val } func (s *ListStack) IsEmpty() bool { return s.Next == nil } 其实感觉用数组实现栈还挺好的\n重要应用:函数调用栈 这是栈最深刻的应用，也是我们每天都在用的但是却没意识到的。\n程序运行时，函数调用是用栈管理的 每调用一个函数，就把它的局部变量、参数、返回地址打包成一个\u0026quot;栈帧(stack frame)\u0026quot;，push 到调用栈上;函数返回时，栈帧 pop 出去。这完美契合 LIFO:最后被调用的函数，最先返回。\nmain() 调用 a()，a() 调用 b(): 调用栈(从底往上生长): ┌─────────┐ ← 栈顶，当前正在执行 b │ b 的帧 │ ├─────────┤ │ a 的帧 │ ├─────────┤ │ main 帧 │ ← 栈底 └─────────┘ b 返回 → 弹出 b 的帧 → 回到 a 如果函数调用链写的过长，比如递归，调用栈会变得很大，导致最后被压爆，就是 stack overflow。\n动手练习(LeetCode) 20.有效的括号(必做) 155.最小栈(设计，O(1) 取最小值) 150.逆波兰表达式求值(栈求值) 232.用栈实现队列(理解栈和队列的转换) 739.每日温度(单调栈入门) 队列 核心操作 先进先出(FIFO)\n入队 enqueue 出队 dequeue ↓ ↓ 队尾 rear 队头 front ┌────┬────┬────┬────┐ ──▶ │ 40 │ 30 │ 20 │ 10 │ ──▶ 10 先出(它最先进来) └────┴────┴────┴────┘ 核心操作 enqueue / dequeue 都应是 O(1)。辅助操作:peek(看队头)、isEmpty、size。\n实现 使用数组实现 需要两个下标，一个记录front的位置，一个记录rear的位置，入队rear++，出队front++，当rear到达数组的尾端时，说明这个队列满了，但是如果出队的话，这个队列就会有空位出现，逻辑上没满，但是下标却到头了。\n例如下图 ：\n如果每次出队都把后面元素整体前移一格来复用空间，那出队就变成 O(n)，违背了队列 O(1) 的初衷。\n使用循环数组实现 让rear这个下标绕回来，形成循环数组\nrear = (rear + 1) % capacity front = (front + 1) % capacity type CycleQueue struct { data []any front int rear int size int } func NewCycleQueue(capacity int) *CycleQueue { return \u0026amp;CycleQueue{ data: make([]any, capacity), front: 0, rear: 0, size: 0, } } func (q *CycleQueue) Enqueue(v any) bool { if q.size == len(q.data) { return false } q.data[q.rear] = v q.rear = (q.rear + 1) % len(q.data) // 下一次入列的位置 q.size++ return true } func (q *CycleQueue) Dequeue() any { if q.IsEmpty() { return nil } v := q.data[q.front] q.front = (q.front + 1) % len(q.data) // 下一次出列的位置 q.size-- return v } func (q *CycleQueue) Peek() any { if q.IsEmpty() { return nil } return q.data[q.front] } func (q *CycleQueue) IsEmpty() bool { return q.size == 0 } func (q *CycleQueue) Size() int { return q.size } 重要应用 广度优先搜索 func bfs(start int, graph map[int][]int) { visited := map[int]bool{start: true} queue := []int{start} for len(queue) \u0026gt; 0 { node := queue[0] // 取队头 queue = queue[1:] // 出队 // 处理 node ... for _, nb := range graph[node] { if !visited[nb] { visited[nb] = true queue = append(queue, nb) // 邻居入队 } } } } 像消息队列、go的chan、限流器这类的功能和组件，底层使用的数据结构基本都是队列，交易池 mempool 这个使用的也是队列，不过更像是优先队列，按照gas price进行排序。\n动手练习(LeetCode) 232.用栈实现队列 / 225. 用队列实现栈(理解两者互转) 622.设计循环队列(必做,巩固环形缓冲) 102.二叉树的层序遍历(BFS 模板题) 200.岛屿数量(BFS/DFS 都可) 239.滑动窗口最大值(单调队列 / 双端队列) 347.前 K 个高频元素(优先队列 / 堆) ","date":"August 10, 2026","img":"/datastruct/stack/featured.png","lang":"zh-cn","langName":"","largeImg":"/datastruct/stack/featured_hu_84e1ee405f743730.png","permalink":"/datastruct/stack/","series":[],"smallImg":"/datastruct/stack/featured_hu_fe97b8d20d0738e6.png","tags":[{"title":"数据结构","url":"/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"timestamp":1786353778,"title":"数据结构-Stack"},{"categories":[{"title":"数据结构","url":"/categories/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"content":" Summary 如果说数组的灵魂是连续存储,那链表的灵魂就是它的反面——用一根\u0026quot;指针\u0026quot;把散落在内存各处的节点串起来。这一个设计上的取舍,换来了数组梦寐以求的能力:在任意位置插入、删除只要改几个指针,O(1) 就能完成,不用像数组那样搬动后面一大片元素。代价也同样直接:失去了 O(1) 随机访问,想找第 i 个节点只能从头一步步走;而且节点散落、对缓存不友好,遍历常常比数组慢好几倍。\n链表本身简单,但它是栈、队列、哈希表拉链、LRU、时间轮、内核/运行时的侵入式链表的地基,也是\u0026quot;快慢指针、反转、判环\u0026quot;这一整套指针技巧的练兵场。这篇按 单链表 → 双链表 → 循环链表 展开,最后落到 Go 标准库的 container/list 和几个必会的算法范式。\n链表 vs 数组:内存模型的对立 数组在内存里是一整块连续区域,靠 base + i * size 一步算出地址;链表恰恰相反,每个节点是独立 malloc 出来的一小块内存,彼此物理上不相邻,只靠节点里存的一个**指针(地址)**知道\u0026quot;下一个在哪\u0026quot;:\n数组: 一整块连续内存,下标即地址偏移 ┌────┬────┬────┬────┐ │ A │ B │ C │ D │ → a[i] = base + i*size,O(1) └────┴────┴────┴────┘ 链表: 节点散落各处,靠指针串联 [A|·]───→[B|·]───→[C|·]───→ ∅ 0x1a0 0x3f8 0x0c0 (地址无规律) 这个区别直接决定了两者的性能对立面:\n数组 链表 随机访问第 i 个 O(1) O(n),从头走 头部插入/删除 O(n),要搬后面所有元素 O(1),改指针即可 已知位置插入/删除 O(n) O(1)(双链表) 内存开销 紧凑 每个节点额外存 1~2 个指针 缓存友好度 高(空间局部性) 低,几乎每跳一次都可能 cache miss 扩容 可能触发整体拷贝 天然按需增长,不搬家 节点长什么样 链表的一切都从\u0026quot;节点\u0026quot;这个结构体开始。一个节点 = 数据 + 指向下一个节点的指针:\ntype Node struct { Val int Next *Node // 指针:存的是下一个节点的内存地址,nil 表示到头了 } Next 是一个指针,不是\u0026quot;下一个节点本身\u0026quot;。正因为存的是地址,插入/删除时我们改的只是这几个 8 字节的地址值,而不用动节点里的数据——这就是链表 O(1) 增删的物理原因。\n单链表(Singly Linked List) 最基础的形态:每个节点只有一个 Next 指针,只能从头往尾单向走。一个 head 指针记住入口,尾节点的 Next 指向 nil(下图用 ∅ 表示)。\n增删为什么是 O(1) 链表增删快,快在\u0026quot;只改指针、不搬数据\u0026quot;。在节点 p 后面插入一个新节点 x,只有两行:\nx.Next = p.Next // ① 新节点接上 p 的后继 p.Next = x // ② p 改为指向新节点 插入前: p ───→ q 插入后: p ──→ x ──→ q 只动了两根指针,和链表多长无关 → O(1) 删除 p 的后继同理,一行绕过去即可,被删节点等着被 GC 回收:\np.Next = p.Next.Next 但有个隐藏前提:你得先\u0026quot;站在\u0026quot;要操作的位置的前一个节点上。 单链表只能从头找,所以\u0026quot;找到第 i 个位置\u0026quot;本身是 O(n)——这也是单链表最大的软肋:知道位置后操作 O(1),但找到位置要 O(n)。\n头节点难处理?用哨兵(dummy head) 单链表所有增删都需要\u0026quot;前驱节点\u0026quot;,可头节点没有前驱,于是\u0026quot;删头\u0026quot;\u0026ldquo;在头前插入\u0026quot;都要写特殊分支。工程上的通用解法是加一个哨兵节点(dummy / sentinel):一个不存数据、永远在最前面的假头,让真正的第一个节点也有前驱,所有操作就统一了,代码里再也没有 if 是头节点 的分支。\nfunc removeElements(head *Node, val int) *Node { dummy := \u0026amp;Node{Next: head} // 哨兵:统一处理\u0026#34;删头\u0026#34;这种边界 prev := dummy for prev.Next != nil { if prev.Next.Val == val { prev.Next = prev.Next.Next // 删除,不用管是不是头节点 } else { prev = prev.Next } } return dummy.Next // 哨兵的下一个才是真正的新头 } 哨兵节点是链表题的第一反射:只要涉及可能删/插头节点,先 dummy := \u0026amp;Node{Next: head}。 后面 LRU、合并链表也都靠它省掉边界判断。\n双链表(Doubly Linked List) 单链表只能往前走,想删掉\u0026quot;当前节点\u0026quot;却拿不到它的前驱(必须从头再找一遍)。双链表给每个节点再加一个 Prev 指针,前后都能走:\ntype DNode struct { Val int Prev *DNode // 指向前一个 Next *DNode // 指向后一个 } 每两个相邻节点之间有两根线:上面一根是 next(向右),下面一根是 prev(向左)。首节点的 prev 和尾节点的 next 都指向 nil。\n双链表的价值:任意节点 O(1) 删除 单链表删除一个\u0026quot;手里已经拿到的节点 p\u0026ldquo;是 O(1) 吗?不是——你还得从头找到 p 的前驱才能改它的 Next,所以是 O(n)。双链表因为能 p.Prev 直接拿到前驱,真正做到 O(1) 删除任意已知节点:\nfunc remove(p *DNode) { p.Prev.Next = p.Next // 前驱越过 p 指向后继 p.Next.Prev = p.Prev // 后继的前驱指回前驱 } 删除前: ... ⇄ prev ⇄ [p] ⇄ next ⇄ ... 删除后: ... ⇄ prev ⇄ next ⇄ ... p 被两边同时\u0026#34;绕过\u0026#34; 这正是 LRU 缓存的核心。 LRU 需要:命中时把某个节点挪到最前(O(1) 删 + O(1) 插),满了淘汰最后一个(O(1))。哈希表负责\u0026quot;O(1) 定位到节点\u0026rdquo;,双链表负责\u0026quot;O(1) 调整顺序\u0026rdquo;,二者配合就是标准 LRU:\ntype entry struct { key, val int prev, next *entry } type LRUCache struct { cap int m map[int]*entry // key → 节点,O(1) 定位 head, tail *entry // 两个哨兵:head 侧最新,tail 侧最旧 } func Constructor(capacity int) LRUCache { h, t := \u0026amp;entry{}, \u0026amp;entry{} h.next, t.prev = t, h // 两个哨兵首尾相接,链表永不为\u0026#34;空\u0026#34; return LRUCache{cap: capacity, m: map[int]*entry{}, head: h, tail: t} } func (c *LRUCache) remove(e *entry) { e.prev.next, e.next.prev = e.next, e.prev } func (c *LRUCache) pushFront(e *entry) { e.prev, e.next = c.head, c.head.next c.head.next.prev, c.head.next = e, e } func (c *LRUCache) Get(key int) int { if e, ok := c.m[key]; ok { c.remove(e) // 命中:先摘下 c.pushFront(e) // 再挪到最前,标记为\u0026#34;最近使用\u0026#34; return e.val } return -1 } func (c *LRUCache) Put(key, val int) { if e, ok := c.m[key]; ok { e.val = val c.remove(e) c.pushFront(e) return } if len(c.m) == c.cap { // 满了:淘汰 tail 前面那个(最旧) oldest := c.tail.prev c.remove(oldest) delete(c.m, oldest.key) } e := \u0026amp;entry{key: key, val: val} c.m[key] = e c.pushFront(e) } 代价是每个节点多存一个指针(内存 +8 字节),以及每次增删要多维护一根 Prev。用空间和一点点常数换\u0026quot;双向可走\u0026quot;,几乎所有需要频繁在中间增删的场景都值。\n循环链表(Circular Linked List) 把尾节点的 Next 从 nil 改成指回头节点,链表就首尾相接成了一个环——循环链表。从任意节点出发,一直走 Next 永远不会遇到 nil,而是会绕回起点。\n它可以是单向环(尾 next → 头),也可以是双向环(再让头 prev → 尾)。判断\u0026quot;走完一圈\u0026quot;的方法不再是 p == nil,而是 p == head(或回到出发节点):\np := head for { // 处理 p p = p.Next if p == head { // 回到起点 = 走完一圈 break } } 它解决什么问题 循环链表天生适合环形、轮转、周而复始的场景:\n约瑟夫环(Josephus):N 人围成圈报数,每数到 M 出局,问最后剩谁。用循环链表直接建模,数到就删节点,删到只剩一个: func josephus(n, m int) int { // 建环:1..n head := \u0026amp;Node{Val: 1} cur := head for i := 2; i \u0026lt;= n; i++ { cur.Next = \u0026amp;Node{Val: i} cur = cur.Next } cur.Next = head // 尾接头,成环 prev := cur // prev 始终是 cur 的前驱,方便删除 for prev.Next != prev { // 还剩 \u0026gt;1 个节点 for i := 1; i \u0026lt; m; i++ { // 数 m-1 步,prev 停在第 m 个的前驱 prev = prev.Next } prev.Next = prev.Next.Next // 删掉第 m 个 } return prev.Val // 只剩它自己 } 循环队列 / 环形缓冲(ring buffer):生产者写、消费者读,空间循环利用——这在数组篇也见过(数组 + 取模),链表版就是循环链表。 时间轮(timing wheel):Kafka、Netty、Linux 内核的定时器调度,把时间槽排成一个环,指针一格格转,到点触发该槽里的任务,是海量定时任务的经典结构。 轮询调度(round-robin):负载均衡里轮流把请求发给一组后端,转一圈回到第一个。 复杂度速查 操作 单链表 双链表 数组(对照) 按下标访问第 i 个 O(n) O(n) O(1) 头部插入 / 删除 O(1) O(1) O(n) 尾部插入(有 tail 指针) O(1) O(1) O(1) 均摊 删除\u0026quot;已拿到的节点 p\u0026quot; O(n)(要找前驱) O(1) O(n) 查找某个值 O(n) O(n) O(n) 额外空间/节点 1 个指针 2 个指针 无 一句话:链表牺牲随机访问,换取任意位置的高效增删;双链表再花一个指针,换\u0026quot;任意已知节点 O(1) 删除\u0026quot;。\n必会的链表算法范式 链表题的套路高度集中,核心就是指针的腾挪。下面几个是面试和工程里反复出现的。\n快慢指针 · 找中点 一个指针一次走一步,另一个一次走两步。快指针到头时,慢指针正好在中点。找中点、判断回文链表都靠它:\nfunc middleNode(head *Node) *Node { slow, fast := head, head for fast != nil \u0026amp;\u0026amp; fast.Next != nil { slow = slow.Next // 走一步 fast = fast.Next.Next // 走两步 } return slow // fast 到头,slow 在中间 } 快慢指针 · Floyd 判环(龟兔赛跑) 如何判断一个链表里有没有环?快慢指针在环里一定会相遇(快的追上慢的);若无环,快指针先到 nil。这就是著名的 Floyd 判圈算法:\nfunc hasCycle(head *Node) bool { slow, fast := head, head for fast != nil \u0026amp;\u0026amp; fast.Next != nil { slow = slow.Next fast = fast.Next.Next if slow == fast { // 相遇 = 有环 return true } } return false } 进阶:相遇后,把其中一个指针放回 head,两个指针都改成一次一步同速前进,再次相遇处就是环的入口(可用数学证明)。\n反转链表 链表题的\u0026quot;必考基本功\u0026quot;。三个指针 prev / cur / next 依次把每根 Next 掉头:\nfunc reverseList(head *Node) *Node { var prev *Node cur := head for cur != nil { next := cur.Next // 先存住后继,不然掉头后就找不到了 cur.Next = prev // 掉头:指向前一个 prev = cur // prev、cur 各前进一步 cur = next } return prev // 原来的尾,现在是新头 } 反转前: A → B → C → ∅ 反转后: ∅ ← A ← B ← C 返回 C 作为新 head 合并两个有序链表 归并排序在链表上的一步。用哨兵起头,谁小接谁:\nfunc mergeTwoLists(l1, l2 *Node) *Node { dummy := \u0026amp;Node{} tail := dummy for l1 != nil \u0026amp;\u0026amp; l2 != nil { if l1.Val \u0026lt;= l2.Val { tail.Next, l1 = l1, l1.Next } else { tail.Next, l2 = l2, l2.Next } tail = tail.Next } if l1 != nil { // 把剩下的一整段直接接上 tail.Next = l1 } else { tail.Next = l2 } return dummy.Next } 链表天生适合归并排序:合并两段是 O(1) 空间的指针拼接,不像数组归并要开辅助数组。这也是为什么链表排序首选归并而不是快排。\nGo 标准库:container/list Go 其实自带了一个开箱即用的链表——container/list,而且它的实现恰好把本文三个概念合到了一起:它是一个带哨兵的「双向循环链表」。理解了前面,再看它的设计会非常顺:\n它的内部结构印证了本文所有要点:\ntype List struct { root Element len int } type Element struct { next, prev *Element // 双向:两个指针 list *List Value any } func (l *List) Init() *List { l.root.next = \u0026amp;l.root l.root.prev = \u0026amp;l.root l.len = 0 return l } func (l *List) Remove(e *Element) any { if e.list == l { // if e.list == l, l must have been initialized when e was inserted // in l or l == nil (e is a zero Element) and l.remove will crash l.remove(e) } return e.Value } func (l *List) remove(e *Element) { //这段正是之前双向链表的删除代码实现 e.prev.next = e.next e.next.prev = e.prev e.next = nil // avoid memory leaks e.prev = nil // avoid memory leaks e.list = nil l.len-- } 设计上的三个巧思,全是前面讲过的东西:\n哨兵 root:空链表时 root.next 和 root.prev 都指向 root 自己,永远不为 nil,所有增删不用判空、不用管边界。 循环:首尾都连到 root,Front() 就是 root.next,Back() 就是 root.prev,O(1) 拿到两端。 双向:Remove(e) 能直接靠 e.prev、e.next 把自己摘掉,O(1)——正是双链表的看家本领。 ","date":"August 8, 2026","img":"/datastruct/list/featured.png","lang":"zh-cn","langName":"","largeImg":"/datastruct/list/featured_hu_7f46608be3108933.png","permalink":"/datastruct/list/","series":[],"smallImg":"/datastruct/list/featured_hu_df44661de3965ec1.png","tags":[{"title":"数据结构","url":"/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"timestamp":1786198830,"title":"数据结构-List"},{"categories":[{"title":"数据结构","url":"/categories/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"content":" Summary 数组是最基础、也最被低估的数据结构。它的两个标签——连续存储和 O(1) 随机访问——不仅决定了它自己的性能，还撑起了几乎所有更复杂的结构(栈、队列、堆、哈希表的桶、邻接矩阵)，以及一整套只有数组才玩得转的算法套路(双指针、前缀和、差分、滑动窗口)。真正吃透数组，是吃透后面一切的前提。\n数组在内存里到底长什么样 假设有一个 [5]int32，每个 int32 占 4 字节，它在内存里是这样的:\n下标: 0 1 2 3 4 ┌────────┬────────┬────────┬────────┬────────┐ 内存: │ 4 字节 │ 4 字节 │ 4 字节 │ 4 字节 │ 4 字节 │ └────────┴────────┴────────┴────────┴────────┘ 地址: 1000 1004 1008 1012 1016 ↑ 首地址 base 因为元素等大、内存连续，访问第 i 个元素时，CPU 不需要一个个数过去，而是直接用一个乘加算出地址:\nelement_address = base_address + i * element_size 访问下标 2:1000 + 2 * 4 = 1008，一步到位。这个\u0026quot;一步算出地址\u0026quot;就是 O(1) 随机访问的本质，也是数组区别于链表最核心的优势——链表的节点散落各处，想找第 i 个只能从头走 i 步。\nCPU 缓存行(Cache Line) 内存比 CPU 慢两个数量级，所以 CPU 有多级缓存(L1/L2/L3)。CPU 从内存加载数据时，不是只加载你要的那几个字节，而是一次加载一整条\u0026quot;缓存行\u0026quot;(cache line，通常 64 字节)。\n数组是连续存储，所以访问 a[0] 时，a[1]、a[2]…… 很可能已经被顺带加载进缓存了，接下来遍历它们直接命中，极快。这叫空间局部性。链表则相反:节点散落各处，访问下一个大概率是一次缓存未命中(cache miss)，要重新去慢速内存取。这就是为什么即使链表和数组遍历都是理论 O(n)，数组在真实机器上往往快好几倍。\n数组是所有数据结构的基石 很多\u0026quot;高级\u0026quot;结构，底层其实就是一个数组 + 规则:\n栈 / 队列:用数组 + 下标即可实现(顺序栈、循环队列)。 堆(二叉堆):一棵完全二叉树直接压进数组，下标 i 的左右孩子是 2i+1、2i+2，父亲是 (i-1)/2 哈希表的桶数组:哈希函数把 key 映射成下标，底层就是一个数组。 字符串:本质是字符/字节的数组(Go中 的 string 底层是只读 []byte)。 位图 / 位集合(bitset):用数组的每一个 bit 表示一个元素在不在，极省空间，uniswap的Tick系统就是使用这个数据结构。判重、布隆过滤器都靠它: 图的邻接矩阵:matrix[i][j] 表示 i、j 之间有没有边，就是个二维数组。 环形缓冲(ring buffer):数组 + 取模下标，循环利用空间，是循环队列和很多 IO 缓冲的原型。 稀疏数组(sparse array):当绝大多数元素是同一个默认值(如 0)时，只记录\u0026quot;非默认\u0026quot;的少数位置，省内存。 数组上的经典算法范式 原地操作(LeetCode 189. Rotate Array） 利用 O(1) 随机访问，直接在原数组上交换/覆盖，不开辅助数组，做到 O(1) 额外空间。最基本的是\u0026quot;交换\u0026quot;和\u0026quot;反转\u0026quot;:\nfunc rotate(nums []int， k int) { if k == 0 { return } length := len(nums) if length == 0 { return } if k%length == 0 { return } if k \u0026gt; length { k = k % length } tmp := append([]int{}， nums[len(nums)-k:]...) copy(nums[k:]， nums[:length-k]) copy(nums[:k]， tmp) } 我这实现空间上不是O(1)\n双指针 · 对撞指针(167. Two Sum II - Input Array Is Sorted) 两个指针从两端向中间逼近。适合有序数组求一对元素、判断回文等:\nfunc twoSumSorted(a []int， target int) (int， int) { i， j := 0， len(a)-1 for i \u0026lt; j { s := a[i] + a[j] switch { case s == target: return i， j case s \u0026lt; target: i++ // 和太小，左指针右移变大 default: j-- // 和太大，右指针左移变小 } } return -1， -1 } 6.3 双指针 · 快慢指针 (26. Remove Duplicates from Sorted Array、80. Remove Duplicates from Sorted Array II) 一快一慢同向移动，慢指针维护\u0026quot;已处理好的边界\u0026quot;。适合原地删除/去重/移动:\nfunc removeDuplicates(nums []int) int { if len(nums) \u0026lt;= 2 { return len(nums) } slow := 2 // 第 0 个元素天然保留，从 1 开始写 for fast := 2; fast \u0026lt; len(nums); fast++ { if nums[fast] != nums[slow-2] { // 和已保留的最后一个不同 → 是新元素 nums[slow] = nums[fast] slow++ } } return slow } 6.4 前缀和（303. Range Sum Query - Immutable） 预先算出\u0026quot;从头到每个位置的累加和\u0026quot;，之后任意区间和都能 O(1) 查询。用 O(n) 预处理换来无数次 O(1) 查询:\ntype NumArray struct { sums []int } func Constructor(nums []int) NumArray { sum := make([]int， len(nums)+1) for i， v := range nums { sum[i+1] = sum[i] + v } return NumArray{ sums : sum } } func (this *NumArray) SumRange(left int， right int) int { return this.sums[right+1] - this.sums[left] } 6.5 差分数组（1094. Car Pooling） 前缀和的\u0026quot;逆操作\u0026quot;。当需要对某个区间整体加同一个值、且这种区间更新很多次时，用差分数组能把每次更新降到 O(1)，最后一次前缀和还原:\nfunc carPooling(trips [][]int， capacity int) bool { diff := make([]int， 1001) for _， t := range trips { num， from， to := t[0]， t[1]， t[2] diff[from] += num diff[to] -= num } cur := 0 for _， d := range diff { cur += d if cur \u0026gt; capacity { return false } } return true } 6.6 滑动窗口（3. Longest Substring Without Repeating Characters） 用两个指针维护一个\u0026quot;窗口\u0026quot;，随着右指针扩张、左指针收缩，避免重复计算。定长窗口最直观——进一个、出一个:（这个用法在TCP流量控制里面也有使用到）\nfunc lengthOfLongestSubstring(s string) int { record := make(map[byte]int) // 记录字符最近一次出现的下标 left， ret := 0， 0 for right := 0; right \u0026lt; len(s); right++ { one := s[right] if idx， ok := record[one]; ok \u0026amp;\u0026amp; idx \u0026gt;= left { left = idx + 1 } record[one] = right if right+1-left \u0026gt; ret { ret = right + 1 - left } } return ret } 某语言的具体实现 前面讲的都是\u0026quot;数组\u0026quot;这个数据结构的通用道理。这一节看 Go 是怎么落地的——它把\u0026quot;静态数组\u0026quot;和\u0026quot;动态数组\u0026quot;分成 array 和 slice 两个东西，搞混它们是 Go 新手最常见的 bug 来源。\n数组 [N]T:值类型 var a [3]int = [3]int{1， 2， 3} b := a // 整个数组被【拷贝】!b 和 a 是两份独立数据 b[0] = 99 // a 仍然是 [1 2 3] fmt.Println(a == b) // 数组支持 == 比较(元素逐个比)，这里 false Go 的数组是值类型，赋值、传参都整体拷贝;长度是类型的一部分，[3]int 和 [4]int 是不同类型。一般也不用数组，都用切片。\n切片 []T:动态数组，本质是个\"视图\" 切片不是数组，它是一个三元组结构体，指向某个底层数组:\ntype slice struct { array unsafe.Pointer len int cap int } s := make([]int， 3， 5) // len=3， cap=5，底层数组能放 5 个 fmt.Println(len(s)， cap(s)) // 3 5 append 与扩容(当前版本 go1.26.3) 动态数据 append 多数操作均为O(1), 偶尔扩容O(n)，均摊下来就是O(1) —— 这是数组最重要的复杂度结论。\nappend 时，编译器先计算 newLen = oldLen + num，若 newLen \u0026gt; cap(oldSlice)，就会触发扩容，调用 growslice（runtime/slice.go）函数。\nfunc growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice { oldLen := newLen - num // 切片当前的长度 // ... if et.Size_ == 0 { return slice{unsafe.Pointer(\u0026amp;zerobase), newLen, newLen} // 这一行就是为什么创建 []struct{}，不会产生新的内存的原因 } newcap := nextslicecap(newLen, oldCap) // 第一步:算“理论新容量” noscan := !et.Pointers() // 第二步:把 newcap*元素大小 向上对齐到内存 size class var overflow bool var lenmem, newlenmem, capmem uintptr switch { case et.Size_ == 1: // 元素 1 字节,免 lenmem = uintptr(oldLen) // 旧的长度 newlenmem = uintptr(newLen) // 新的长度 capmem = roundupsize(uintptr(newcap), noscan) // 字节对齐，内存对齐可以运行下，最下面的一段代码 overflow = uintptr(newcap) \u0026gt; maxAlloc // 溢出检查 newcap = int(capmem) // 获得新容量， 以下case大同小异， case et.Size_ == goarch.PtrSize: // 元素=一个指针宽(例如：[]int、[]*T),乘除被优化成移位 case isPowerOfTwo(et.Size_): // 使用位移进行计算 default: // 默认情况，直接乘除 lenmem = uintptr(oldLen) * et.Size_ // 旧长度*元素大小 } if overflow || capmem \u0026gt; maxAlloc { panic(errorString(\u0026#34;growslice: len out of range\u0026#34;)) } // 分配 + 拷贝 var p unsafe.Pointer if noscan { p = mallocgc(capmem, nil, false) // append 马上会覆盖 [oldLen, newLen),只需清零“不会被覆盖”的尾部 memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem) } else { p = mallocgc(capmem, et, true) // 含指针,必须清零好让 GC 能扫描 if lenmem \u0026gt; 0 \u0026amp;\u0026amp; writeBarrier.enabled { bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(oldPtr), lenmem-et.Size_+et.PtrBytes, et) } } memmove(p, oldPtr, lenmem) // 旧数据整体拷到新数组 return slice{p, newLen, newcap} } nextslicecap 函数\nfunc nextslicecap(newLen, oldCap int) int { newcap := oldCap doublecap := newcap + newcap if newLen \u0026gt; doublecap { return newLen // 翻倍都不够 → 直接使用最新长度newLen=oldLen+num（新添加的元素数量） } const threshold = 256 if oldCap \u0026lt; threshold { return doublecap // \u0026lt; threshold :直接翻倍 } for { newcap += (newcap + 3*threshold) \u0026gt;\u0026gt; 2//大切片:从 2x 平滑过渡到 1.25x if uint(newcap) \u0026gt;= uint(newLen) { break } } if newcap \u0026lt;= 0 { return newLen // 溢出兜底 } return newcap } 为什么扩容要成倍增长,而不是每次 +1? 若每次只加 1,插入 n 个元素要拷贝 1+2+…+n = O(n²) 次,灾难。成倍增长时,虽然单次扩容 O(n),但扩容越来越稀疏,把总代价均摊到每次 append 上,均摊复杂度仍是 O(1)——这是\u0026quot;均摊分析\u0026quot;最经典的例子。\n共享底层数组——最经典的坑 切片类似是\u0026quot;视图\u0026quot;，多个切片可指向同一个底层数组，这是 Go 数组类知识里最容易踩的雷:\ns := []int{1， 2， 3， 4， 5} sub := s[1:3] // sub = [2 3]，和 s 共享同一底层数组 sub[0] = 99 // s 也变了! s 变成 [1 99 3 4 5] 更隐蔽的是 append 引发的意外覆盖:\na := []int{1， 2， 3， 4， 5} b := a[:2] // b=[1 2]， len=2， cap=5(和 a 共享) b = append(b， 100) // cap 够，直接写进底层数组下标 2 → a 变成 [1 2 100 4 5]! 防御:用三索引切片 s[low:high:max] 限制 cap，或用 copy 深拷贝:\nb := a[0:2:2] c := make([]int， len(a)); copy(c， a) // 或干脆拷一份 nil 切片 vs 空切片 var s1 []int // nil 切片:s1 == nil 为 true，len/cap 都是 0，可直接 append s2 := []int{} // 空切片:s2 != nil 为 false，len/cap 为 0 两者都能安全 append 和 range，日常用 var s []int 声明 nil 切片即可。序列化 JSON 时 nil → null、空切片 → []，写 API 时要注意。\n内存对齐测试代码 type One struct { a bool b int64 c bool } fmt.Println(\u0026#34;unsafe.Sizeof(One{}) \u0026#34;, unsafe.Sizeof(One{})) type Two struct { b int64 a bool c bool } fmt.Println(\u0026#34;unsafe.Sizeof(Two{}) \u0026#34;, unsafe.Sizeof(Two{})) ","date":"August 4, 2026","img":"/datastruct/array/featured.png","lang":"zh-cn","langName":"","largeImg":"/datastruct/array/featured_hu_e9f594b93d26ea4f.png","permalink":"/datastruct/array/","series":[],"smallImg":"/datastruct/array/featured_hu_73a13783dc7c002f.png","tags":[{"title":"数据结构","url":"/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/"}],"timestamp":1785830186,"title":"数据结构-Array"},{"categories":[],"content":" Summary 最近在部署一个api的服务，想起来以前一位组长关于https做的分享相当不错，现在学习一下，希望还来得及。\n故事呢，还要先从一个明信片开始讲起。\n第一章：明信片 Alice 在国外旅行，想给 Bob 汇款，于是写信告诉 Bob 自己的银行卡号和密码。\n问题是，她只有明信片可用。\n明信片会经过邮递员、分拣站、Bob 家楼下的门房——每一双手都能翻过来读一遍。窃听者 Eve 就在分拣站工作，她甚至不需要\u0026quot;偷\u0026quot;，只需要\u0026quot;看\u0026quot;。\n这就是 HTTP：你以为在寄信，其实全程在寄明信片。你在咖啡馆 WiFi 上输入的密码、浏览的页面，对整条链路上的任何设备都是敞开的。\n更糟的还在后面。分拣站还有个叫 Mallory 的员工，他不满足于看——他把明信片上的收款账号涂掉，改成自己的。Bob 收到的还是\u0026quot;Alice 的来信\u0026quot;，钱却进了 Mallory 的口袋。\n这时候 Alice 需要三样东西：别人看不懂（防窃听）、别人改不了（防篡改）、确认对方真是 Bob（防冒充）。整个故事就是凑齐这三样。\n第二章：带锁的盒子 Alice 想了个办法：买一个带锁的铁盒，把信锁进去寄出。Eve 在分拣站拿到盒子，摇一摇，什么也看不到。\n这就是对称加密（AES）：一把钥匙，锁和开都靠它。而且这种锁又快又结实——现代 CPU 有专门的硬件指令，加密一部电影只要一眨眼。\n但 Alice 立刻发现自己犯了个傻：Bob 没有钥匙，他收到盒子也打不开。\n怎么把钥匙给 Bob？\n随信寄过去——Eve 连盒带钥匙一起收下，等于没锁 提前见面交给 Bob——可 Alice 和 Bob 素不相识，她昨天才第一次访问 Bob 的网站 这就是困扰了密码学几千年的密钥分发问题：锁再好，钥匙的运送本身就是最脆弱的一环。对称加密自己救不了自己。\n第三章：Bob 的挂锁 Bob 想出一个漂亮的反转：钥匙不动，锁动。\nBob 打造了一种特殊的挂锁，配一把钥匙。他把打开的挂成箱地寄给所有想给他写信的人——分拣站随便拿，Eve 想要也送她一个。而钥匙只有一把，从不离开 Bob 的口袋。\nAlice 收到挂锁，把信放进盒子，\u0026ldquo;咔哒\u0026quot;一声扣上。从这一刻起，全世界只有一个人能打开这个盒子——Bob。扣上挂锁不需要钥匙，打开才需要。Alice 自己都打不开自己刚锁上的盒子。\n这就是非对称加密：挂锁是公钥（public key，公开发放，人人可取），口袋里的钥匙是私钥（private key，永不示人）。公钥加密的东西，只有私钥能解。\n密钥分发问题解决了：需要在不安全线路上传递的东西（挂锁），偷走也没用；有用的东西（钥匙），从不上路。\n只是这种魔法挂锁有个缺点：锁一次的工序比普通铁锁慢上百倍。所以 Alice 和 Bob 商定了一个聪明的用法——用挂锁只寄一样东西：一把普通铁锁的钥匙。之后的大量信件全用快的铁锁。魔法挂锁只在开头用一次。\n这就是 HTTPS 的混合加密：非对称加密负责安全地送出对称密钥，之后全程对称加密。记住这个结构，最后一章会原样出现。\n第四章：Mallory 的调包计 Eve 因为看不到信件所以被out了，但 Mallory 比她狡猾得多。他在分拣站干了一票大的。\nAlice 写信给 Bob 说：\u0026ldquo;把你的挂锁寄给我。\u0026rdquo; Mallory 截下了 Bob 寄回的挂锁，换成了自己的挂锁，继续寄给 Alice。\nMallory 的挂锁和 Bob 的看起来一模一样。Alice 毫无察觉，把银行卡密码锁进盒子寄出。Mallory 截下盒子，用自己的钥匙打开，抄下密码，再把信放进Bob 的挂锁里锁好，寄给 Bob。\nBob 收到信，正常打开，正常回信。Alice 和 Bob 都觉得一切正常，而 Mallory 坐在中间读完了每一个字。\n这就是中间人攻击（MITM）。注意它可怕的地方：挂锁的工艺（加密算法）毫无破绽，破的是更早的一环——Alice 从头到尾没有办法确认\u0026quot;手里这把挂锁真的是 Bob 的\u0026rdquo;。挂锁上又没刻名字；就算刻了，Mallory 也会刻个一样的。\n加密解决不了这个问题。因为这不是加密问题，是公钥本身无法自证归属的问题，这时就需要一个双方都信任的第三方来担保\u0026quot;这把公钥确实属于 Bob\u0026quot;。\n第五章：Trent 的火漆印章 镇上有位德高望重的公证人 Trent，他的火漆印章全镇人都认得，而且无法伪造——只有 Trent 手里那枚印章能压出那个纹路（这就是数字签名：Trent 用自己的私钥签名，任何人都能用 Trent 的公钥验证，但没人能伪造）。\nBob 带着挂锁和身份文件去找 Trent。Trent 验明正身后，开出一张证明书：\n兹证明：这把挂锁（此处附挂锁的完整描述）属于 Bob 本人。 —— 公证人 Trent（火漆印章）\n从此 Bob 寄挂锁时都附上这张证书。Alice 收到后先验火漆印：印章是真的 → 证明书没被改过 → 证明书说这把挂锁是 Bob 的 → 放心使用。\nMallory 再想调包就难了。他可以换挂锁，但证明书上描述的还是 Bob 的挂锁，对不上；他想改证明书，火漆印就碎了；他想伪造火漆印——做不到，印章在 Trent 手里。\nTrent 就是 CA（证书颁发机构），火漆印章就是 CA 的私钥签名，那张证明书就是 HTTPS 证书——它的本质自始至终只有一句话：把\u0026quot;某把公钥\u0026quot;和\u0026quot;某个身份（域名）\u0026ldquo;绑在一起，由可信第三方担保。\n还剩最后一个递归问题：Alice 凭什么认得 Trent 的印章？答案朴素得出奇——Alice 从小就见过。她出生时家里长辈就教她认了镇上几位公证人的印。对应到现实：操作系统和浏览器出厂时，就内置了一百多个根 CA 的公钥。信任必须有一个不证自明的起点，这个起点是\u0026quot;预装\u0026rdquo;。\n下面是某个网站的证书示例：\n（顺带一提：如果有人骗 Alice\u0026quot;再认一枚新印章\u0026quot;，那么这个人从此能给任何假挂锁开证明——这就是\u0026quot;别乱装来路不明的根证书\u0026quot;的原因，也是抓包工具能解密 HTTPS 的全部秘密，Charles这个工具就是让你信任它自己的root证书，从而获取https的抓包功能）\n第六章：烧掉的钥匙 故事本可以在这里结束，但 Bob 睡不着。\n他想到一件后怕的事：Mallory 这些年把所有截到的盒子都原样复制、存在地下室里——虽然打不开。而全部信件共同的软肋，是 Bob 口袋里那一把私钥。哪天钥匙被偷、被抢、被法院强制交出，Mallory 地下室里历年的信件会在同一天全部洞开。\n一把钥匙，陪葬所有历史。\n所以 Bob 改了规矩。从此每次通信都现场打一对新挂锁：Alice 也现场打一对。两人隔空各自比划一番（迪菲-赫尔曼密钥交换的数学魔法：各自用自己的新私钥和对方的新公钥，能算出同一个秘密数字，而旁观的 Mallory 拿着两把公钥也算不出来），用这个只存在于两人脑中的数字当作本次的铁锁钥匙。通信一结束，双方把临时钥匙扔进火炉。\n那 Bob 祖传的那把私钥干什么用？只干一件事：在证明书体系里签名，证明\u0026quot;我是 Bob\u0026quot;。它再也不参与任何加密（数字签名）。\n于是就算多年后 Bob 的私钥失窃，Mallory 也只能拿它冒充未来的 Bob（很快会被吊销证书阻止），而地下室里的历史信件永远打不开——因为打开它们的钥匙早已烧掉，且从未写在任何纸上。\n这叫前向保密（Forward Secrecy）：今天的泄露，不殃及昨天。TLS 1.3 已经强制要求这种做法——第三章里\u0026quot;用挂锁直接寄铁锁钥匙\u0026quot;的老办法（RSA 密钥交换），因为做不到这一点，被整个废除了。\n第七章：三分钟的仪式 现在，把六章的道具全部摆上桌，看 Alice（浏览器）和 Bob（服务器）如何在三次传话内完成所有事——这就是 TLS 1.3 握手：\nAlice 喊话（ClientHello）： \u0026ldquo;Bob！我要和你说话。我会用这几种铁锁『密码套件』；这是我现场新打的临时挂锁『key_share』；另外我抛了个硬币，结果是 3182『random_c』。\u0026rdquo;\n（她不等确认就把临时挂锁直接带上了——赌 Bob 认识这种锁。赌中，就省了一个来回。）\nBob 回话（ServerHello + 证书 + 签名 + Finished）： \u0026ldquo;好，就用你说的第二种铁锁。这是我现场新打的临时挂锁和我的硬币结果 7549『random_s』。——到这里，Bob 已经能用双方的临时锁算出今天的铁锁钥匙了，所以从下一句起他压低了声音（加密）——这是 Trent 给我的证明书『证书链』；为了证明我不是拿着别人证明书的骗子，我用祖传私钥给咱俩刚才说过的每一句话签了名『CertificateVerify』；最后，这是刚才全部对话的摘要『Finished』，你核对一下，确认没人偷偷改过咱们的话。\u0026rdquo;\nAlice 回话（Finished + 数据）： \u0026ldquo;摘要核对无误。『Finished』——顺便，我的第一句正事也一起说了：请把我的账户页面给我。\u0026rdquo;\n仪式结束。没有任何一句话里出现过铁锁钥匙本身——它是在两人各自的脑中\u0026quot;长\u0026quot;出来的。此后所有对话都锁在铁盒里，Eve 听到的是噪音，Mallory 改一个字校验就会碎，而且他连今天这场对话是和\u0026quot;真 Bob\u0026quot;进行的这件事都无法破坏。\n第八章：真实握手 当前正在运行的tls版本有两个，1.2和1.3\n1.2 握手过程如下：\n抓包 https://www.baidu.com 的实际过程，如图所示，握手过程中的消息都是明文可以查看的。\n1.3 握手过程如下：\n抓包 https://www.google.com.hk 的实际过程，如图所示，Server Hello之后的消息都是加密的，和上图是一致的。\n密码学积木 2.1 对称加密：快，但密钥怎么送过去 加密和解密用同一把钥匙（如 AES-256）。\n优点：极快（CPU 有硬件指令，加密 1GB 数据毫秒级） 死穴：密钥分发——钥匙本身怎么安全地告诉对方？明文发钥匙等于没加密 结论：HTTPS 传输数据全程用对称加密，但握手阶段必须先解决\u0026quot;怎么安全地商量出这把钥匙\u0026quot;。\n2.2 非对称加密：解决密钥分发，但太慢 一对钥匙：公钥加密的内容，只有私钥能解（如 RSA、椭圆曲线）。公钥可以随便公开——贴在网站上都行，因为它只能加密不能解密。\n优点：不需要事先共享秘密。你用我的公钥加密，全世界只有持有私钥的我能解 缺点：比对称加密慢 100-1000 倍，不可能用它加密所有流量 结论：混合使用——用非对称加密解决\u0026quot;密钥分发\u0026quot;（只在握手时用一次，保护那把对称密钥），之后全程对称加密。这是理解整个 TLS 的钥匙。\n2.3 数字签名：非对称加密反着用 把非对称钥匙对反过来用：私钥签名，公钥验证。\n服务器：对内容算哈希 → 用私钥加密这个哈希 → 得到\u0026#34;签名\u0026#34;，附在内容后 验证方：用公钥解开签名得到哈希 A → 自己对内容算哈希 B → A == B？ 验证通过则同时证明两件事：内容出自私钥持有者之手（身份），且没被改过一个字（完整性）。证书体系整个建立在这上面。\n2.4 哈希：内容的指纹 任意长度内容 → 固定长度摘要（如 SHA-256 → 32 字节）。改动一个比特，哈希面目全非；无法从哈希反推内容。用途：给签名\u0026quot;瘦身\u0026quot;（签 32 字节的哈希而不是签整个内容），以及校验完整性。\n2.5 ECDHE（迪菲-赫尔曼密钥交换） 在握手过程中，让client和server算出一样的东西，不是那两个随机数，而是各自分享的key_share。\n会话密钥 = HKDF( ECDHE共享秘密, random_c, random_s ) ↑ ↑ ↑ 唯一的秘密成分 公开的 公开的 HKDF 是一个确定性的搅拌函数：输入相同，输出必然相同。双方的 random_c、random_s 本来就一样，所以真正的问题只剩一个——ECDHE 共享秘密凭什么双方算出来一样，而窃听者算不出。这才是密码学的魔法所在\nECDHE 的规则是\u0026quot;己方私钥 × 对方公钥 = 共享秘密\u0026quot;\n举个🌰：\n用小一点的数字实际算一遍（真实场景数字有几百位甚至几千位）。公开约定两个数：g = 5，p = 23（模数，算完取余数）。\n各自扔骰子，产生私密数字：\nAlice 的私密数字 a = 6（这个数字两边是不公开的，是临时私钥）\nBob 的私密数字 b = 15\n各自算出公开数字，明文发给对方（窃听者 Eve 全程看到）：\nAlice 发出：A = 5^6 mod 23 = 8（这是通讯中的key_share，临时公钥）\nBob 发出：B = 5^15 mod 23 = 19\n各自用\u0026quot;自己的私密数 + 对方的公开数\u0026quot;计算：\nAlice 算：B^a mod 23 = 19^6 mod 23 = 2\nBob 算：A^b mod 23 = 8^15 mod 23 = 2 ← 一样！\n为什么必然一样？因为两人算的其实是同一个东西，只是顺序不同：\nAlice：(5^b)^a = 5^(b·a)\nBob： (5^a)^b = 5^(a·b) ← 乘法交换律：a·b = b·a\n这就是全部秘密：指数运算的交换律，双方从不同的路径爬上了同一个山头（这个ECDHE里面的DH，DH也可以表示 Diffie-Hellman，TLS协议创始人）。\n为什么 Eve 算不出来\nEve 看到了 g=5、p=23、A=8、B=19，四个数全知道。她缺的是 a 或 b 中的任何一个。想求 a，她得解\u0026quot;5 的多少次方 mod 23 等于 8\u0026quot;——这叫离散对数问题。数字小的时候可以穷举，但真实场景的数字有 2048 位（或椭圆曲线上的等价物，就是 ECDHE 里的 EC），穷举需要的时间超过宇宙年龄。正向计算一瞬间，反向求解不可行——这种\u0026quot;单向门\u0026quot;是整个现代密码学的地基。\nrandom_c、random_s代表的是ECDHE最后的一个E，表示\u0026quot;每次换新钥匙\u0026quot;的纪律\n","date":"July 24, 2026","img":"/posts/https/featured.png","lang":"zh-cn","langName":"","largeImg":"/posts/https/featured_hu_fb4386f54efadfc9.png","permalink":"/posts/https/","series":[],"smallImg":"/posts/https/featured_hu_6954a36ffef02e79.png","tags":[{"title":"网络","url":"/tags/%E7%BD%91%E7%BB%9C/"},{"title":"Http","url":"/tags/http/"}],"timestamp":1784873370,"title":"Https解析"},{"categories":[{"title":"区块链","url":"/categories/%E5%8C%BA%E5%9D%97%E9%93%BE/"}],"content":" Summary 目前Uniswap是Defi现货交易的一个重要基础，只要你想接触区块链就会碰到它，并且它的设计范式是被整个行业参考和借鉴。从2018年开始，截止到目前为止一共更新了四个版本，每个版本都有自己的特性，接下来我们逐一讲解\nV1 V1版本是Hayden Adams工程师用Vyper（pyhton版的evm语言）实现的一版，主要证明了\n恒定乘积AMM是能跑的 引入了 LP token概念 不需要对手方也可以成交的 核心公式就一个 x * y = k\n这个版本也有很强烈的限制，比如\n仅支持ETH\u0026lt;-\u0026gt; ERC20池，任意一个token必须和ETH做对 手续费固定0.3% 没有价格预言机 这些问题也催生出了V2\nV2 V2版本于2020年，它保留了V1的 x * y = k公式，在生态层面作出了几个关键升级。\n任意ERC20/ERC20池 放弃了V1必须走ETH的路线，允许任意两种ERC20直接组池。\n这个变化的连锁反应超出预期：\n稳定币直兑池爆发——USDC/USDT、DAI/USDC 池深度很快超过 CEX 新代币发行更灵活——项目方发币时可以直接和自己的稳定币建池，不必先兑 ETH 长尾市场繁荣——SUSHI/YAM、YFI/YFV 这些 DeFi meme 时代的核心资产都在 V2 上诞生 TWAP预言机 在每一个pair合约（一个交易对，USDC/WETH）中记录一个累积价格，每次swap时进行更新，\npriceCumulativeLast += 当前价格 * 时间间隔 例如：在T1时刻记录一次priceCumulativeLast,T2时刻再记一次，获取时，者着相减/时间差，得到T1到T2之间的时间加权平均价格。\n举个例子：\n时间 事件 价格 该价格持续时长 0s 开始观察 $2000 600s 600s 有人买入 ETH 涨到 $2100 300s 900s 有人卖出 ETH 跌到 $1900 900s 1800s 结束观察 — — 累积价格的变化过程（假设 0 秒时读到的值是 0）：\n600s 时刻的 swap 触发更新：\n累积价格 += 2000 × 600 = 1,200,000 900s 时刻的 swap 触发更新：\n累积价格 += 2100 × 300 = 630,000 → 累计 1,830,000 1800s 时刻我读取（触发更新）：\n累积价格 += 1900 × 900 = 1,710,000 → 累计 3,540,000 套入公式：\nTWAP = (结束时累积价格 - 开始时累积价格) / 时间差 = (3,540,000 - 0) / 1800 = $1966.67\n这种机制可以有效的防止被闪电贷在单块内操纵价格\n手续费开关（Fee switch） V1中所有的手续费都给了LP，在V2中引入了一个开关，这个开关通过治理打开，一打开，0.3%手续费中的1/6就会被切到协议金库，这个开关，好像从上线开始到现在一直没有打开过，直到2024年，才在部分池子中开启。\n合约Core/Periphery分离 V2 将整体合约分为Core/Periphery两部分，Core包含\nUniswapV2Factory（所有的pair都有此创建） UniswapV2Pair （所有的钱都在这里） UniswapV2ERC20（Pair继承的LP Token的实现，支持permit离线签名操作） 作为核心，极简、不可升级、并且管钱。\nPeriphery（外围合约）包括\nUniswapV2Router02 （用户的实际入口，多跳路径规划、滑点保护等功能都在这里） UniswapV2Library （工具库） 这种设计让核心合约（pair）保持极简，业务逻辑放在 Router。Router 可以升级、可以被别的协议替换。这个\u0026quot;Core / Periphery\u0026quot;分离思想被后续所有 Uniswap 版本沿用。\nV3 V3是在2021年上线的，这次升级让pool的资本效率提升了100倍以上，同时对散户的门槛也提高了，是个对散户不是很友好的版本。\nCLMM（集中流动性） 在一个 USDC/USDT 池子中。这两个稳定币的价格几乎永远在 $0.999 ~ $1.001 之间波动。但 V2 的公式是 x * y = k——流动性均匀分布在 0 到 ∞ 的所有价格区间，例如下图：\n流动性密度 │ │ ━━━━━━━━━━━━━━━━━━━━━━━ ← V2:每个价格上摆的流动性都一样(均匀分布) │ └──────────────────────── 价格 0 1 ∞ 在以前情况，池子都要保持流动性\n价格 = 0.5 时：需要有钱（万一价格砸到那） 价格 = 2 时：需要有钱（万一价格涨到那） 价格 = 10、100、10000 时：也都得有钱 结果就是：99.9% 的流动性趴在永远不会被用到的价格区间。你放 100 万美元进 USDC/USDT V2 池，真正在 $0.999-$1.001 之间被使用的可能只有 1000 美元。资本效率 = 0.1%，证明过程如下，包含挺多的数学公式，不想看的可以跳过：\n证明： 第一步:把池子状态用公式表示 V2 公式:x * y = k(x = USDC 数量,y = USDT 数量) 价格定义:p = y/x(1 个 USDC 值多少 USDT) 由这两条可以解出,任意价格 p 时池子里的资产量: x = √(k/p) y = √(k·p) 第二步:代入\u0026#34;100 万美元、价格 = 1\u0026#34; 价格 p = 1 时,x = y = √k,池子总价值: 总价值 = x + y = 2√k = $1,000,000 所以 √k = 500,000 即池子里:50 万 USDC + 50 万 USDT 第三步:算\u0026#34;价格从 1.001 走到 0.999,池子里实际动了多少钱\u0026#34; \u0026#34;被用到的资金\u0026#34; = 价格在这条缝里来回时,真正参与成交(被换进换出)的资产量。 价格 p = 1.001 时: y = √k × √1.001 = 500,000 × 1.00049988 ≈ 500,250 USDT 价格 p = 0.999 时: y = √k × √0.999 = 500,000 × 0.99949987 ≈ 499,750 USDT USDT 的变动量: Δy = 500,250 − 499,750 ≈ 500 USDT 同样算 USDC(x = √(k/p)): p=0.999 时 x ≈ 500,250, p=1.001 时 x ≈ 499,750 Δx ≈ 500 USDC 第四步:得出结论 价格在 $0.999 ↔ $1.001 之间扫完整个区间,池子里真正被换手的资产: Δx + Δy ≈ 500 + 500 = $1,000 资本效率: 1,000 / 1,000,000 = 0.1% 其余 99.9 万美元,无论价格在这条缝里怎么震,一动不动 —— 它们只有在价格跑出这个区间时才会被动用。 V2的资金利用率就好像你开了一家只做午市的餐厅,但你雇了 100 个服务员 24 小时三班倒站岗 —— 半夜 3 点也有很多服务员站在空餐厅里，做服务的永远只有午市的那几个服务员，剩下的服务员工资都白给了。\nV3出来之后，会让提供流动性之前让你自己选择一个价格区间，同样 100 万美元产生的有效流动性相当于 V2 的 ~500 倍（因为流动性密度提升了）。但是同时也增加了LP的工作，你需要主动管理LP，当LP中只有单边时，会停止赚手续费，需要你手动调整，每次调整还需要付gas，还可能会重新开仓，所以散户可以直接放弃这个版本了。\nFee Tier 以前V2中，手续费固定0.3%，V3则提供4档\nFee Tier 目标资产类型 0.01% 稳定币互换（USDC/USDT、DAI/USDC） 0.05% 稳定币和挂钩资产（stETH/ETH）、蓝筹（USDC/ETH） 0.30% 中等波动资产（ETH/大部分 ERC20） 1.00% 高波动 / 长尾资产 Tick系统 Tick 这个是为了解决LP任意边界而产生的，先看没有Tick的世界有多糟\n假设允许任意边界,池子里有三个 LP:\n张三: [0.99871, 1.00133] 放了 10 万 李四: [0.99902, 1.00087] 放了 10 万 王五: [0.99913, 1.00121] 放了 10 万 现在价格从 1.0008 涨到 1.0009。合约必须回答一个问题:\u0026ldquo;这一步有没有跨过谁家的边界?\u0026rdquo;\n怎么回答？只能把所有仓位挨个查一遍:张三的上边界 1.00133 过了吗?李四的 1.00087 过了吗?王五的呢……池子里有 1 万个 LP,就查 1 万次。每笔 swap 都这么查,gas 天文数字。\n问题的根源:每个人的边界都是\u0026quot;私有的\u0026quot;,合约必须认识每一个仓位（这个问题是不是很像开发过程中，你需要遍历所有的数组的情况），在EVM中每次读取一次数据，会有2100的gas，如果每一个仓位都要读取的话，那gas消耗就不是一般的大了。\n解决：价格的边界只能选在预设刻度上(tick)。于是:\n张三: [tick -13, tick +13] （计算公式： tick = ln(price) / ln(1.0001)， 为什么用1.0001，是因为 log_1.0001(x) 让每个 tick 之间的价格差 ≈ 0.01%（1 basis point）——这是 CEX 交易员熟悉的最小 tick size） 李四: [tick -10, tick +9] 王五: [tick -9, tick +12]\n关键变化发生了 —— 边界的\u0026quot;种类\u0026quot;变得有限了。张三、李四、王五各自的钱不同、区间不同,但他们的边界都落在 -13, -10, -9, +9, +12, +13 这六根钉子上。\n于是合约可以换一种记账方式:不给\u0026quot;人\u0026quot;记账,给\u0026quot;钉子\u0026quot;记账。\n每根被使用的钉子上挂一个数字,叫 liquidityNet(流动性净变化):\n钉子 -13: +10万 ← 价格从下往上跨过这里时,张三的钱上岗 钉子 -10: +10万 ← 李四的钱上岗 钉子 -9: +10万 ← 王五的钱上岗 钉子 +9: -10万 ← 李四的钱下岗 钉子 +12: -10万 ← 王五的钱下岗 钉子 +13: -10万 ← 张三的钱下岗\n现在价格上涨跨过钉子 +9 时,合约根本不需要知道李四是谁,它只做一个动作:\n当前在岗流动性 = 30万 + (-10万) = 20万\n一次加法,完事。1 万个 LP 和 3 个 LP,跨钉子的成本一模一样 —— 因为所有人的边界都被\u0026quot;合并同类项\u0026quot;到了有限的钉子上。\n这就是不允许边界，预设刻度的全部含义，让合约按tick记账，而不是按照人来记账（你的明细存放在你自己的仓位NFT上）。\n新问题：\n按照tick来记账，怎么才能快速的找到下一个tick呢，V3的tick范围是 -887272 到 +887272,约 177 万根钉子。但刚才的池子里只有 6 根钉子有流动性，其余177多万根全是空的，那要怎么才能快速找到呢。\nV3使用Bitmap:用 1 个比特标记 1 根钉子\n解法是给全部钉子建一张位图(bitmap) —— 每根钉子对应 1 个比特:\n该钉子挂了账(被初始化) → 1 该钉子是空的 → 0\n以太坊的一个存储槽是 256 位,正好可以打包 256 根钉子的状态:\n槽 0 管 tick 0~255: 0000000001000000\u0026hellip;0010000000\u0026hellip; ↑ ↑ tick 9 = 1 tick 12 = 1\n现在\u0026quot;找下一根有账的钉子\u0026quot;变成了:\n① 读 1 个存储槽(拿到 256 根钉子的 0/1 状态) ← 1 次读,2100 gas ② 用位运算在这 256 位里找下一个 1 ← 纯计算,几乎免费\n一次读取覆盖 256 根钉子。就算下一根有账的钉子在几千格外,也只需要读十几个槽,而不是几千次。这就是 bitmap 的全部作用:把\u0026quot;大海捞针式的逐格扫描\u0026quot;压缩成\u0026quot;一页页翻目录\u0026quot;。\nTick spacing(间距) tick一共有177多万根钉子，如果每一根都使用，那算下来也是一笔很大的gas消耗，所以针对不同的代币，使用不用的spacing，例如：\n主流币池(0.30% 费率, spacing = 60)，每 60 根钉子才有一根能用 长尾币池(1.00% 费率, spacing = 200)，每 200 根钉子才有一根能用 V4 V4于2025年1月上线，代号Hooks + Singleton。这一版主要是让Uniswap从\u0026quot;一个协议\u0026quot;变成了\u0026quot;一个可编程 DEX 引擎\u0026quot;。\nHooks - 核心 Hooks 让每个池子可以挂一个外部智能合约作为\u0026quot;回调\u0026quot;，在特定 swap 生命周期节点被自动调用：\nHook 时点 触发时机 beforeInitialize 池子创建前 afterInitialize 池子创建后 beforeAddLiquidity LP 添加流动性前 afterAddLiquidity LP 添加流动性后 beforeRemoveLiquidity LP 移除流动性前 afterRemoveLiquidity LP 移除流动性后 beforeSwap swap 前 afterSwap swap 后 beforeDonate 捐赠前 afterDonate 捐赠后 有了 Hook，你可以在同一个 Uniswap V4 引擎上实现：\n动态手续费：beforeSwap 里根据市场波动率调整 fee（这个也是路由服务里面一个重要的工程） 限价单：afterSwap 里检查当前价格，达到用户预设条件时自动执行 KYC 池：beforeSwap 里验证调用者是否在白名单 自定义预言机：afterSwap 里更新自己的 TWAP 存储 MEV 保护：beforeSwap 里检测 sandwich 并拒绝 激励分发：afterAddLiquidity 里给 LP 空投别的代币 这就是 \u0026ldquo;DEX 变平台\u0026quot;的意思——之前想做这些事得部署一个新的 DEX（Bancor 特殊设计、Balancer 加权池、Curve StableSwap）。现在都可以是V4 上的一个 Hook 合约。\nSingleton架构 V2/V3 里，每个 pair 都是独立部署的合约。以太坊主网上有数万个 V2/V3 pair 合约。这意味着：\n每次跨多个池子路由，都要跨合约调用（贵） 部署新池子要花 gas 数据是碎片化的（要查 100 个池子的状态得读 100 个合约） V4 把所有池子塞进一个合约（PoolManager），池子只是这个合约里的一个数据结构（storage）。\n好处：\n创建新池子 gas 降低 99%（从部署合约变成写几个 storage slot） 多跳 swap 只需要一次合约调用 支持\u0026quot;闪电记账\u0026rdquo;（flash accounting）——一次交易内的多个 swap 可以只做净结算，中间状态不落地 Uniswap X 在跨链场景中，如果你想swap 10000USDC -\u0026gt; ETH，传统流程是，调用Router计算最优路径，签一笔交易，支付gas，承担滑点，并且自己防范MEV（三明治）攻击。\n而Uniswap X的思路是，用户不再签\u0026quot;我要在池子 X 用路径 Y swap\u0026quot;，而是签\u0026quot;我要用最多 10000 USDC 换到不少于 5.5 ETH\u0026quot;（intent）。\n然后Filler 网络，会去竞争这个intnet，流程如下：\n你签一个 EIP-712 结构化订单，广播到 Uniswap X orderflow 网络 各个 filler 在链下算怎么最便宜地填充你（他们可能用自己的库存、可能路由到 Uniswap V4、可能路由到 CEX 库存桥回来） 出价最高的 filler 中标，链上执行，并且无MEV。 你付 0 gas（filler 付），拿到承诺的最低金额 底层机制： filler(做单人)在目标链先垫付你要的币,再回源链领走你付的币 —— 由 The Compact(资源锁)做安全结算\n目前这个版本已经上线，最新的动态是已经在Robinhood Chain上线。\nPS：这个感觉就很像广告中的RTB。\n最后说一句 感觉区块链就是数字的游戏，里面的包含太多的数学知识了\n","date":"July 12, 2026","img":"/posts/uniswap/featured.png","lang":"zh-cn","langName":"","largeImg":"/posts/uniswap/featured_hu_c97c16c1250cc35f.png","permalink":"/posts/uniswap/","series":[],"smallImg":"/posts/uniswap/featured_hu_ee42792c4df690f.png","tags":[{"title":"DeFi","url":"/tags/defi/"},{"title":"区块链","url":"/tags/%E5%8C%BA%E5%9D%97%E9%93%BE/"},{"title":"Uniswap","url":"/tags/uniswap/"}],"timestamp":1783814400,"title":"DeFi 基础 - Uniswap"},{"categories":[],"content":"我是 lbtsm,这里记录我在 Go、区块链、DeFi 等方向的学习笔记。\nGitHub: github.com/lbtsm ","date":"July 12, 2026","img":"","lang":"zh-cn","langName":"","largeImg":"","permalink":"/about/","series":[],"smallImg":"","tags":[],"timestamp":1783814400,"title":"关于"},{"categories":[],"content":"","date":"January 1, 1","img":"","lang":"zh-cn","langName":"","largeImg":"","permalink":"/archives/","series":[],"smallImg":"","tags":[],"timestamp":-62135596800,"title":"归档"}]
