Dart algorithms
Skill Python51888/StudyDart-Skills/.opencode/skills/dart-algorithms
《Hello 算法》Dart 版完整技能手册——从算法思维基础、复杂度分析、数据结构选择、排序搜索、算法范式到常见陷阱的全体系方法论From its SKILL.md
npx -y skills add Python51888/StudyDart-Skills --skill dart-algorithmsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
138.4 KB, ~53.8k tokens by cl100k_base, as published. Nobody here has run it
Hello 算法 Dart 版 · 完整技能手册
来源:靳宇栋《Hello 算法》Dart 语言版 Release 1.3.0,2026 蒸馏日期:2026-05-14 | 合并日期:2026-05-15
总目录
第一部分:算法思维基础
dart-algorithms-foundations
来源:靳宇栋《Hello 算法》Dart 语言版 Release 1.3.0,2026 覆盖章节:第 0 章(前言/学习路线)、第 1 章(初识算法)、第 3 章(数据结构分类)
Contents
一、为什么要学算法
R: 原文引用
算法是问题的解决方案。在计算机科学中,算法是一系列用于解决特定问题的明确指令。它是计算机程序的灵魂——没有算法的程序只是一堆毫无意义的代码。学习算法不仅是准备技术面试的需要,更是提升工程效率和逻辑思维能力的根本途径。
I: 重述
算法的本质是把"怎么解决问题"变成精确的、可重复执行的步骤序列。学算法有三层价值:第一层是面试通行证——大厂技术面试必考数据结构与算法;第二层是工程效率——知道什么场景用什么结构、算法瓶颈在哪里,写出性能合格的代码;第三层是思维训练——算法教会你的不是死记硬背的模板,而是一种"遇问题 -> 拆解 -> 建模 -> 逐步解决"的思维习惯。这种思维可迁移到任何工程领域。
A1: 书中案例
| 场景 | 算法思维体现 |
|---|---|
| 微信红包分配 | 二倍均值算法在 O(n) 时间内随机拆分金额 |
| 导航路径规划 | Dijkstra 最短路径算法在路网图中搜索最优路线 |
| 搜索引擎排序 | PageRank 算法用图结构 + 迭代收敛对网页排名 |
| 推荐系统 | 协同过滤算法基于用户行为相似性做推荐 |
| LeetCode 刷题 | 每道题背后都对应一种或多种算法范式的应用 |
A2: 触发场景
- 刚入门编程,面对算法一词感到困惑和畏惧
- 刷 LeetCode 时不知从何下手,题目和数据结构对不上号
- 面试准备中需要系统了解算法学习的大方向
- 日常开发中遇到性能问题,需要判断"是不是算法选错了"
- 阅读开源项目源码时看到陌生的数据结构想理解其用途
E: 可执行步骤
- 建立心理锚点:记住一个核心类比——"算法是乐谱,程序是演奏;数据结构是积木块,算法是拼装图纸"
- 分类你的问题:遇到一个新问题,先判断它属于哪类算法问题——查找、排序、最优化、遍历、规划?
- 选对数据结构:用什么结构承载数据决定了你后续能做什么操作、能多快完成
- 动手写代码:看懂不等同于会写。每个算法至少手写 3 遍,能盲打出核心模板
- 复盘总结:做完一道题后问三个问题——为什么选这个数据结构?复杂度是多少?还有更优解吗?
B: 边界
- 算法不是银弹:很多实际工程问题的瓶颈不在算法而在 I/O、网络、数据库
- 简单场景不用过度设计:20 条数据的列表,线性查找足够,不需要把二分查找包装成服务
- 不是所有问题都有多项式解:NP 难问题(如旅行商问题)只能求近似解
- 面试算法和工程算法有差距:面试追求最优复杂度,工程追求可读、可维护、够用就好
二、算法生活类比
2.1 拼装积木——数据结构与算法的共生关系
R:数据结构就像积木的"形状"——有长方形、正方形、三角形;算法就像"拼装步骤"——先拼底座、再搭墙壁、最后盖屋顶。单独看每块积木没什么意义,只有按照一定的顺序和方法把它们组合起来,才能搭出一个完整的作品。
I:数据结构和算法是一体两面。数据结构是数据的组织方式(存储形式),算法是操作数据的方法(执行逻辑)。你无法脱离数据结构讨论算法——二分查找必须在有序数组上运行;你也无法脱离算法讨论数据结构——栈的价值体现在 push/pop 操作的 LIFO 语义上。正确的思维是:先确定需要什么样的数据组织方式,再设计对应的操作方法。
A:以手机通讯录为例——联系人数据用数组(有序存储)存放 -> 支持按姓名二分查找 O(log n);如果要按标签(如"同事""家人")分组 -> 改用哈希表(key=标签,value=联系人列表)-> 支持 O(1) 按标签查找。
E:拿到一个需求后,用两个问题启动设计——1. 数据长什么样?(决定结构)2. 我要对它做什么操作?(决定算法)
B:没有"最好的"数据结构,只有"最适合当前场景的"数据结构。
2.2 扑克牌排序——插入排序的直觉理解
R:打扑克时拿到一手牌,大多数人会一边摸牌一边整理——从右往左一张张比较,找到合适的位置把新牌插入进去,保持手中的牌始终有序。这个过程就是插入排序的核心思想。
I:插入排序的每个步骤都在维护一个"已排序区间"——初始时是手中的第一张牌(天然有序),每次摸到一张新牌,就在已排序区间中从右往左扫描,找到第一个小于等于新牌的位置,把新牌塞进去。扫描过程中需要把比新牌大的牌整体右移一位。
A:手牌 [5, 2, 4, 6, 1] -> 摸 5(已排序 [5])-> 摸 2 插入到 5 前([2,5])-> 摸 4 插入到 2 和 5 之间([2,4,5])-> 摸 6 插到末尾([2,4,5,6])-> 摸 1 插到最前([1,2,4,5,6])。时间复杂度 O(n^2),但当牌近乎有序时只需 O(n)——因为每次比较一下就结束,不需要大量右移。
E:
void insertionSort(List<int> nums) {
final n = nums.length;
for (var i = 1; i < n; i++) {
final base = nums[i];
var j = i - 1;
while (j >= 0 && nums[j] > base) {
nums[j + 1] = nums[j];
j--;
}
nums[j + 1] = base;
}
}
B:数据量超过 10^4 时 O(n^2) 不可接受;反序排列是最坏情况,每次都要移动到最左端。
2.3 查字典——二分查找的直觉理解
R:查一本按拼音排序的字典时,你不会从第一页开始逐页翻。你会先翻到中间,看当前页的首字拼音在你目标拼音之前还是之后——如果在之前就翻后半本,在之后就翻前半本。每翻一次查找范围减半,最多翻 log2(n) 次。
I:二分查找的四个前提:1. 数据有序(字典按拼音排序)2. 支持随机访问(能直接翻到任意页)3. 静态或低频更新(字典不会每天重排)4. 不是链表(链表翻到中间需要 O(n) 走一遍)。满足前提时,O(log n) 的效率在 n=10^6 时只需约 20 次比较——这是指数级差距。
A:在 26 个字母的字典中找 "K" 开头的词 -> 翻到中间 (M) -> K<M,翻前半本 -> 翻到中间 (F) -> K>F,翻后半本 -> 翻到中间 (I) -> K>I,翻后 -> 翻到 (K),命中。共 4 次翻页(log2 26 ~~ 4.7)。
E:
int binarySearch(List<int> arr, int target) {
var left = 0, right = arr.length - 1;
while (left <= right) {
final mid = left + (right - left) ~/ 2;
if (arr[mid] == target) return mid;
if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
B:数据必须有序——无序数据要先排序(O(n log n) 的前置成本);不适合频繁增删的场景(维护有序性需 O(n) 移动元素);不适合链表(取中点需要遍历)。
2.4 整理钞票——计数排序的直觉理解
R:假设你有一堆面值分别为 1 元、5 元、10 元、20 元、50 元、100 元的纸币,要按面值从小到大整理。你不会一张张比较面值大小,而是按面值分堆——所有 1 元的放一堆,所有 5 元的放一堆……最后把各堆按面值顺序合并,瞬间完成"排序"。这就是计数排序。
I:计数排序绕过了比较排序的 O(n log n) 下界——它不比较元素大小,而是利用数据本身的值作为索引,统计每个值出现了多少次,然后按值的顺序"铺回"结果。代价是需要 O(n+m) 额外空间(m 为数据取值范围)。当 m 远小于 n 时(如考试成绩 0~100 分),效率极高。
A:学生成绩排序——100 万考生,成绩范围 0~750 分。不需要 O(n log n) 的比较排序,统计每个分数的人数,按 0->750 的顺序输出即可,O(n+750) ~~ O(n)。
E:
void countingSort(List<int> nums) {
if (nums.isEmpty) return;
final m = nums.reduce((a, b) => a > b ? a : b);
final counter = List<int>.filled(m + 1, 0);
for (final num in nums) counter[num]++;
var i = 0;
for (var val = 0; val <= m; val++) {
for (var c = 0; c < counter[val]; c++) {
nums[i++] = val;
}
}
}
B:数据必须是非负整数,且取值范围 m 不能远大于数据量 n(否则大量空间浪费在空计数位上);不适用于浮点数或字符串排序;稳定版计数排序需要前缀和技巧。
三、数据结构分类体系
R: 原文引用
数据结构的分类可以从两个维度展开。第一个维度是逻辑结构——反映数据元素之间的逻辑关系,分为线性和非线性两大类。第二个维度是物理结构——反映数据在计算机内存中的存储方式,分为连续存储(数组)和分散存储(链表)。所有复杂数据结构都是数组或链表(或二者组合)在逻辑结构维度上的设计。
I: 重述
数据结构分类体系的精髓在于:逻辑结构描述的是"看起来怎么样"(元素之间的抽象关系),物理结构描述的是"放在哪"(内存中的真实布局)。比如说,栈在逻辑上是 LIFO 的线性结构,但在物理上可以用数组(连续空间)或链表(分散空间)两种方式实现。再比如,二叉树在逻辑上是树形结构,但在物理上可以用数组(完全二叉树按层编号存储)或链表节点(左右指针)两种方式实现。理解这两个维度,你就理解了数据结构设计的全部可能性。
逻辑结构分类
数据结构(逻辑维度)
|-- 线性结构(元素之间是一对一关系)
| |-- 数组 —— 索引访问,定长/动态扩容
| |-- 链表 —— 指针串联,增删 O(1)
| |-- 栈 —— LIFO,后进先出
| +-- 队列 —— FIFO,先进先出
|
+-- 非线性结构(元素之间是一对多/多对多关系)
|-- 树 —— 层级关系,一对多(二叉树、堆、AVL)
|-- 堆 —— 特殊完全二叉树,用于优先队列
|-- 哈希表 —— 键值映射,O(1) 平均查找
+-- 图 —— 多对多,顶点+边构成网络
物理结构对比表
| 维度 | 连续空间(数组) | 分散空间(链表) |
|---|---|---|
| 存储方式 | 一块连续内存,按索引顺序排列 | 节点散落在内存各处,通过指针串联 |
| 随机访问 | O(1) — 通过索引直接定位 | O(n) — 必须从头遍历到目标位置 |
| 插入/删除 | O(n) — 需要移动后续所有元素 | O(1) — 仅修改相邻节点指针(已知位置时) |
| 缓存友好度 | 高 — 连续内存,CPU 缓存预取命中率高 | 低 — 跳转访问,频繁 cache miss |
| 空间开销 | 低 — 仅存数据本身 | 高 — 每节点额外存指针(Dart 对象头更大) |
| 扩容机制 | 动态数组扩容需搬移 O(n),但均摊 O(1) | 天然动态,无扩容问题 |
| Dart 对应 | List<T>(基于数组) | 无内置链表,需自建或 dart:collection 的 LinkedList |
A1: 物理结构选择案例
| 场景 | 选择物理结构 | 原因 |
|---|---|---|
| 算法题中的栈 | 基于数组实现 | 缓存命中率高,操作效率优于链表 |
| 浏览器前进后退 | 基于双向链表 | 频繁在中间删除(关闭标签页),O(1) |
| 大型游戏的场景管理 | 链表 | 频繁增删实体,随机访问不是主力操作 |
| Twitter 时间线 | 数组(动态列表) | 主要操作是按时间顺序追加和遍历显示 |
| LRU 缓存 | 哈希表 + 双向链表 | 哈希 O(1) 查找 + 链表 O(1) 调整顺序 |
数据结构设计的三要素
R:任何一种数据结构的设计都围绕三个要素展开——空间占用(占多少内存)、操作速度(各种操作有多快)、信息表示(能否准确表达数据之间的关系)。
I:三要素是"不可能三角"——你通常只能在三个维度中取其二。数组取"速度快 + 空间小",牺牲了"增删灵活";链表取"增删快 + 灵活扩容",牺牲了"随机访问"和"空间紧致";哈希表取"查找快 + 表示灵活",牺牲了"有序性"和"额外空间";红黑树取"有序 + 平衡",牺牲了"常数项大 + 额外指针空间"。选择数据结构时,你是在回答:我愿意牺牲哪个指标来换取哪个指标?
E:设计或选择数据结构时的评估清单:
- 画操作贴图:列出你的代码中每种操作(查/增/删/改/遍历)的频率比例
- 算空间上限:估算数据量 n 及上限,确定 O(n) 空间是否可接受
- 标有序需求:数据是否需要保持插入顺序、排序顺序、或任意顺序均可
- 查复杂度正交表:看哪种结构在最高频操作上有最优复杂度
- 考虑 Dart 现实:Dart 内置
List是数组、Map/Set是哈希表、Queue基于List——大多数场景不需要手写结构
B: 数据结构分类边界
- 分类是人为的,不是绝对的:哈希表既可以是"线性结构"(拉链法本质是数组+链表),也可以是"非线性"(键值映射不算线性关系)
- 物理结构不止数组和链表两种:内存池、B+ 树的页式存储等在操作系统/数据库层面才是真实的物理结构
- Dart 层面的"物理结构"被 VM 封装:我们只能控制逻辑组织,无法精确控制内存布局(GC 移动对象对程序员透明)
四、三段式学习路线
R: 原文引用
本书建议的学习路径分为三个阶段。阶段一:算法入门——先熟悉各种数据结构的特点和用法,能够用代码实现基本操作。阶段二:刷算法题——先刷热门题目,积累 100 道以上的刷题量,在实战中理解算法的应用。阶段三:构建体系——在大量练习后,知识开始融会贯通,此时回头整理知识图谱,形成自己的算法思维体系。
I: 重述
三段式的核心逻辑是先广度、后深度、再融通。阶段一不求精,只求"见过"——你知道数组、链表、栈、队列、哈希、树、堆、图各长什么样、各有什么特点。阶段二是"练内功"——LeetCode 刷题不是目的,是通过大量题的交叉印证,把"冒泡排序 O(n^2)"从背的变成肌肉记忆。阶段三时你不再需要死记模板——看到一个题目,脑子里自动浮现出"这用哈希表 + 双指针就行"的方案。三个阶段缺一不可:不经历阶段一的广度,阶段二就是瞎撞;不经历阶段二的量变,阶段三就永远是别人的经验。
A1: 书中案例
| 阶段 | 学习内容 | 对应章节 |
|---|---|---|
| 阶段一 | 复杂度分析、数据结构遍历、基本排序 | 第 1-11 章 |
| 阶段二 | LeetCode Hot 100、剑指 Offer 等 | 各章配套练习 |
| 阶段三 | 重新整理复杂度速查表、画出个人知识图谱 | 全书回顾 |
A2: 触发场景
- 不知道从哪本书/哪个章节开始学算法
- 刷了 30 道题感觉没有进步,开始怀疑方法
- 看到 Hard 题就开始发怵,不知道该用什么数据结构
- 面试前几天突击复习,想系统性地过一遍
- 学完一段时间后想检验自己是否真正掌握了
E: 阶段一可执行清单
- 跑通环境:克隆 Hello 算法 Dart 版仓库,运行每个示例代码
- 画结构图:用手画出 8 种核心数据结构的逻辑结构和物理存储示意图
- 默写基本操作:List 的增删查改、Map/Set 的增删查、链表节点的创建/删除/遍历(手写 3 遍)
- 理解复杂度:看到一种数据结构,能立刻说出它的查/增/删复杂度(至少 80% 准确率)
- 完成阶段性自测:不看答案实现插入排序、二分查找,验证正确性
E: 阶段二可执行清单
- 定计划:每天 1-3 道题,优先做 LeetCode Hot 100 中你已经认识数据结构的题
- 记录模板:每学一个新算法范式,写入个人模板库(分治、回溯、DP、贪心各一个核心模板)
- 交叉练习:同一道题尝试用不同数据结构和算法范式解决
- 复盘瓶颈:做不出来的题,标注"是数据结构选错了"还是"算法范式不熟",针对性补
- 积累 100+:到达 100 道题时,你应该能对 70% 的 Medium 题在 5 分钟内给出大致思路
E: 阶段三可执行清单
- 画知识图谱:用思维导图或流程图把学过的所有数据结构和算法范式画出来,标出相互引用关系
- 写 Skill 文档:参考本书蒸馏技能的格式,把你自己总结的心得写成结构化的 SKILL.md
- 教别人:给另一个刚学算法的人讲一遍——如果你能讲清楚,才算真的理解了
- 写总结文章:把"数组 vs 链表"、"BFS vs DFS"、"DP vs 贪心"等核心对比写出来
B: 学习路线边界
- 阶段不可跳跃:没有阶段一的广度就想跳到阶段三的融通 = 空中楼阁
- 刷题量不是唯一指标:100 道题是参考值,有些人需要 200 道,关键是每道题都复盘总结
- 书不是唯一的资源:本书以基础数据结构为主,高级算法(字符串算法、数论、计算几何)需另寻资源
- 算法学习是长期过程:不能指望两周"突击"掌握——把它当成健身,每周保持训练节奏
五、算法不是万能钥匙
R: 原文引用
虽然算法在计算机科学中占据核心地位,但我们不应神化算法。算法是解决问题的工具,而不是目的。在实际工程中,代码的可读性、可维护性、团队协作效率往往比极致的算法优化更重要。
I: 重述
算法思维和工程思维有本质区别。算法追求极端效率——哪怕常数因子大一倍都要优化;工程追求恰到好处——代码要人看得懂、改得动、测得到。你不需要在每个 for 循环前面分析时间复杂度,也不需要把每个缓存都实现成 LRU。真正的能力是"知道什么时候该用算法思维"——当数据量从 1000 变成 1000 万时,当响应延迟从 100ms 飙升到 5s 时,你能迅速定位瓶颈并选择正确的优化策略。
A: 过度优化的反面案例
| 场景 | 过度优化 | 正确做法 |
|---|---|---|
| 配置列表排序 | 10 条配置用自平衡 BST 存储 | List.sort() 足够,O(1) 完全可以忽略 |
| 聊天消息列表 | 为找最新消息实现跳表 | 直接在末尾 append,用线性倒查 |
| 单次数据库查询 | 在内存里写复杂的图算法去重 | 让数据库用 DISTINCT 或 GROUP BY |
| 微服务 API | 为省 2ms 延迟把同步改异步 | 除非吞吐量到瓶颈,否则同步更简单、更好调试 |
E: 判断是否需要算法优化的决策流程
- 测量,不要猜:用 profiler 或 benchmark 找出真正的瓶颈,不要凭直觉
- 算一笔账:优化带来的性能提升 vs 增加的代码复杂度和维护成本
- 问一个问题:这个操作会执行多少次?如果
n永远不会超过 100,O(n^2) 完全 OK - 检查已有轮子:Dart SDK 和常用包里的方法(
sort()、where()、fold())已经高度优化,不要重复发明
B: 终极边界
- 算法优化有极限:比较排序不能突破 O(n log n) 的理论下界
- 有些问题是 NP 难的:你必须接受近似解,而不是追求最优解
- 代码腐烂速度 > 性能退化速度:三个月后没人能维护的"极致优化"代码,比多跑 500ms 的"朴素实现"更危险
- 过早优化是万恶之源(Donald Knuth):先让代码正确运行,再让代码跑得快
六、Dart 基础语法在算法中的角色
6.1 变量与类型推断
// var 类型推断——省去冗长类型声明,保持代码清爽
var count = 0; // int
var ratio = 3.14; // double
var flag = true; // bool
var items = <int>[]; // List<int>
// final vs const——算法中的常量定义
final n = nums.length; // 运行时确定
const mod = 1000000007; // 编译时常量,取模用
6.2 集合操作链(Collection If/For)
// 集合字面量中的控制流——比传统 for 循环更声明式
List<int> evenSquares(List<int> nums) {
return [
for (final n in nums)
if (n % 2 == 0) n * n, // 偶数的平方
];
}
// 等价传统写法
List<int> evenSquaresOld(List<int> nums) {
final result = <int>[];
for (final n in nums) {
if (n % 2 == 0) result.add(n * n);
}
return result;
}
6.3 可空类型在算法中的安全处理
// null 用于"未找到"的语义
int? findIndex(List<int> nums, int target) {
for (var i = 0; i < nums.length; i++) {
if (nums[i] == target) return i;
}
return null; // 明确表示"不存在",比返回 -1 更安全
}
// null-aware 操作符简化边界处理
void process(List<int>? nums) {
final n = nums?.length ?? 0; // 如果 nums 为 null,用 0
final safe = nums ?? []; // 给默认空列表
safe.forEach(print);
}
6.4 Map 和 Set 在算法题中的高频用法
// 两数之和——Map 的经典 O(n) 解法
List<int> twoSum(List<int> nums, int target) {
final seen = <int, int>{}; // value -> index
for (var i = 0; i < nums.length; i++) {
final complement = target - nums[i];
if (seen.containsKey(complement)) {
return [seen[complement]!, i];
}
seen[nums[i]] = i;
}
return [];
}
// 去重——Set 的 O(1) 判断存在性
bool hasDuplicate(List<int> nums) {
final seen = <int>{};
for (final n in nums) {
if (!seen.add(n)) return true; // Set.add 返回 false 表示已存在
}
return false;
}
// 字符频率统计——Map 计数模式
Map<String, int> charFrequency(String s) {
final freq = <String, int>{};
for (final ch in s.split('')) {
freq[ch] = (freq[ch] ?? 0) + 1;
}
return freq;
}
6.5 级联操作符(Cascade)
// 级联操作符构建复杂数据结构——链式操作
final graph = <int, List<int>>{}
..[0] = [1, 2]
..[1] = [0, 3]
..[2] = [0, 3]
..[3] = [1, 2];
// 等价于
final graph2 = <int, List<int>>{};
graph2[0] = [1, 2];
graph2[1] = [0, 3];
graph2[2] = [0, 3];
graph2[3] = [1, 2];
E: Dart 语法速查在算法中的应用
| 语法特性 | 算法场景 | 示例 |
|---|---|---|
~/ 整除 | 二分查找 mid 计算、取一半 | final mid = (left + right) ~/ 2; |
?? 空值合并 | 统计计数时初始化 | freq[ch] = (freq[ch] ?? 0) + 1; |
... 展开 | 合并两个列表(归并排序) | result.addAll(left.sublist(i)); |
collection if/for | 条件过滤、列表推导 | [for (final n in nums) if (n > 0) n] |
Set.add 返回值 | 去重、检测重复 | if (!seen.add(n)) return true; |
reduce | 求和、找最大值 | final max = nums.reduce((a, b) => a > b ? a : b); |
B: Dart 语法边界
~/是截断除法,不是 round——3 ~/ 2 == 1,不是 2List.filled(length, fill)填充的是同一个对象引用——对 mutable 对象的 filled 要小心- 集合字面量中的
for/if会立即执行并构建完整集合——不是惰性的 Set.add对已存在元素返回 false,但不会抛出异常——可以作为去重哨兵- Dart 无内置对 tuple 的语法支持——算法中需要返回多个值时,用
Record((int, int))或自定义类
Workflow: 建立算法思维
Task Progress
- Step 1: 理解问题域。 面对一个新问题时,先用自然语言描述清楚——输入是什么?输出是什么?约束条件是什么?(数据范围、时间限制、空间限制)
- Step 2: 判断算法类型。 是查找(二分/哈希/遍历)?排序(比较/非比较)?最优化(DP/贪心)?遍历(BFS/DFS)?排列组合(回溯)?先贴一个标签。
- Step 3: 选择数据结构。 根据操作模式(查/增/删哪个多)和有序性需求,从 8 种核心结构中选出候选。对照第 3 章的"不可能三角"做权衡。
- Step 4: 画流程图 / 状态图。 在纸上画出算法的执行流程——每一步数据如何变化、指针如何移动。不要跳过这一步直接写代码。
- Step 5: 分析复杂度。 标注时间复杂度和空间复杂度,确认在给定数据范围内是否可行。如 n=10^6 则 O(n^2) 不可用。
- Step 6: 编写 Dart 代码。 遵循 Effective Dart 风格,使用恰当的 Dart 语法特性(非空类型、集合操作符、final 优先)。
- Step 7: 测试边界。 测试空输入、单元素、全部相同元素、反序排列等极端情况。用
assert编写自测用例。 - Step 8: 复盘 + 找更优解。 做完后问:这是最优复杂度吗?有没有常数项更小的实现?Dart 有没有内置方法可以替代?
条件逻辑
- 如果输入规模 n <= 10^2 -> O(n^2) 算法可接受,优先选择代码简洁的实现
- 如果输入规模 n >= 10^6 -> 必须 O(n) 或 O(n log n),禁止 O(n^2)
- 如果数据已经有序 -> 优先考虑二分查找 O(log n),而不是从头遍历
- 如果需要反复查找 -> 构建哈希表(空间换时间),而不是每次线性查找
- 如果问题涉及"全部组合/排列" -> 这是回溯算法的信号,复杂度通常是 O(2^n) 或 O(n!)
- 如果问题求"最大/最小/最优"且有重叠子问题 -> 这是动态规划的信号,先找状态转移方程
- 如果数据范围固定且很小(如 26 个字母) -> 可以用固定大小的数组替代 Map 做计数,O(1) 空间 + O(1) 访问
- 如果 Dart 内置方法能满足需求 -> 不要重复发明轮子。
nums.sort()、nums.where()、nums.fold()已经高度优化
Examples
示例 1: 如何判断一个问题属于哪种算法类型
/// 问题:给定一个整数数组,找出其中任意一个重复的数字。
/// 返回重复的数字,如果没有重复则返回 null。
// 判断流程:
// Step 1: 输入 int[],输出 int?,约束:无(数据范围未指定)
// Step 2: 算法类型标签——"查找重复"
// Step 3: 数据结构选型——
// 方案A: 遍历 + Set 去重 -> O(n) 时间,O(n) 空间
// 方案B: 排序 + 相邻比较 -> O(n log n) 时间,O(1) 空间
// Step 4: 无需复杂流程图,简单的一趟遍历
// Step 5: 方案A 适合 n 不大时;方案B 适合内存紧张时
int? findDuplicate(List<int> nums) {
final seen = <int>{};
for (final n in nums) {
if (!seen.add(n)) return n; // Set.add 返回 false = 已存在
}
return null;
}
// 自测用例
void main() {
assert(findDuplicate([1, 3, 4, 2, 2]) == 2);
assert(findDuplicate([3, 1, 3, 4, 2]) == 3);
assert(findDuplicate([1, 2, 3, 4]) == null);
assert(findDuplicate([1]) == null);
assert(findDuplicate(<int>[]) == null);
}
示例 2: 数据结构选择决策——同一个问题,三种方案
/// 问题:设计一个支持"插入"和"获取中位数"的数据结构
/// 展示了数据结构选择如何改变算法策略
// 方案A: 无序数组 + 每次查询时排序
// 插入 O(1),查询 O(n log n) —— 适合写多读少的场景
class MedianFinderA {
final List<int> _data = [];
void addNum(int num) => _data.add(num); // O(1)
double findMedian() {
_data.sort(); // O(n log n)
final mid = _data.length ~/ 2;
if (_data.length % 2 == 1) return _data[mid].toDouble();
return (_data[mid - 1] + _data[mid]) / 2.0;
}
}
// 方案B: 两个堆(大顶堆 + 小顶堆)
// 插入 O(log n),查询 O(1) —— 适合读多写多
import 'dart:collection';
class MedianFinderB {
final _lo = HeapPriorityQueue<int>((a, b) => b.compareTo(a)); // 大顶堆
final _hi = HeapPriorityQueue<int>(); // 小顶堆(默认)
void addNum(int num) {
_lo.add(num);
_hi.add(_lo.removeFirst());
if (_lo.length < _hi.length) {
_lo.add(_hi.removeFirst());
}
}
double findMedian() {
if (_lo.length > _hi.length) return _lo.first.toDouble();
return (_lo.first + _hi.first) / 2.0;
}
}
示例 3: 递归思维入门——从迭代到递归的思维转换
/// 计算 1+2+...+n,用两种方式实现,体会递归的递与归
// 迭代版——显式循环,状态在迭代中累加
int sumIterative(int n) {
var result = 0;
for (var i = 1; i <= n; i++) result += i;
return result;
}
// 递归版——隐式循环,状态通过参数传递和返回值回溯
int sumRecursive(int n) {
if (n == 1) return 1; // 终止条件(归的起点)
return n + sumRecursive(n - 1); // 递:把 n 入栈,自己处理 n-1
}
// 递归树分析(n=5):
// sumRecursive(5) = 5 + sumRecursive(4)
// 4 + sumRecursive(3)
// 3 + sumRecursive(2)
// 2 + sumRecursive(1)
// 1 <- 触底,开始归
// = 3
// = 6
// = 10
// = 15
// Dart 注意:无尾递归优化,n 过大时用迭代版或 fold
int sumDartWay(int n) => List.generate(n, (i) => i + 1).fold(0, (a, b) => a + b);
示例 4: 复杂度分析的实战流程
/// 问题:判断一个整数 n 是否为质数
/// 不同实现有不同的复杂度——展示从 O(n) 到 O(sqrt(n)) 的优化
// 版本1: 暴力试除法 —— O(n) 时间,O(1) 空间
bool isPrimeV1(int n) {
if (n < 2) return false;
for (var i = 2; i < n; i++) { // n-2 次循环
if (n % i == 0) return false;
}
return true;
}
// 版本2: sqrt 优化 —— O(sqrt(n)) 时间,O(1) 空间
// 关键洞察:如果 n 是合数,必有一个因子 <= sqrt(n)
bool isPrimeV2(int n) {
if (n < 2) return false;
for (var i = 2; i * i <= n; i++) { // sqrt(n) 次循环
if (n % i == 0) return false;
}
return true;
}
// 复杂度对比:
// n = 10^12 时,V1 需要 ~10^12 次循环(不可能完成)
// V2 只需 ~10^6 次循环(瞬间完成)
// 这就是"算法选择"的威力——从不可用到可用
示例 5: 边界条件检查——Dart 中的防御性编程
/// 二分查找的完整边界检查版本
/// 展示算法实现中需要考虑的所有边界情况
int? binarySearchSafe(List<int>? nums, int target) {
// 边界1: null 或空列表
if (nums == null || nums.isEmpty) return null;
var left = 0;
var right = nums.length - 1;
// 边界2: 单元素列表
if (left == right) return nums[left] == target ? left : null;
while (left <= right) {
// 边界3: 防溢出(虽然 Dart int 是 BigInt,但保持好习惯)
final mid = left + (right - left) ~/ 2;
if (nums[mid] == target) return mid;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 边界4: 未找到
return null;
}
// 自测
void test() {
assert(binarySearchSafe(null, 5) == null); // null
assert(binarySearchSafe([], 5) == null); // 空
assert(binarySearchSafe([3], 3) == 0); // 单元素命中
assert(binarySearchSafe([3], 5) == null); // 单元素未命中
assert(binarySearchSafe([1, 3, 5, 7, 9], 1) == 0); // 最左
assert(binarySearchSafe([1, 3, 5, 7, 9], 9) == 4); // 最右
assert(binarySearchSafe([1, 3, 5, 7, 9], 6) == null); // 不存在
}
第二部分:复杂度分析
复杂度分析方法论
Contents
- R: 核心原文引用
- I: 方法论重述
- A1: 书中经典案例
- A2: 未来触发场景
- E: 可执行步骤
- B: 边界与不适用场景
- Workflow: 分析算法复杂度
- 常见时间复杂度速查
- 常见空间复杂度速查
- Examples
R: 核心原文引用
时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势。"时间增长趋势"这个概念很抽象,我们通过一个例子来理解。假设输入数据大小为 n,给定三个算法 A、B、C,它们分别执行 1 次、n 次、n² 次操作。从增长趋势来看,足够大的 n 会使得不同阶数之间的差距变得巨大,因此我们通常只关注最高阶的项。
—— 靳宇栋《Hello 算法》Dart 版,第 2 章
I: 方法论重述
复杂度分析的本质是忽略常数因子、只关注增长趋势的渐进性思维。大 O 表示法给出了一个函数在 n→∞ 时的上界。这个方法论的三个支柱:
- 以操作为单位:不直接计时(受硬件影响),而是统计基本操作(赋值、比较、算术、函数调用)的执行次数。
- 推算渐近上界:从统计到的操作数 f(n) 出发,找到增长阶最高的项,忽略常数系数和低阶项,得到 O(g(n))。
- 最差情况优先:通常取最坏输入下的操作数作为复杂度,因为它给出了算法的性能底线。
两个关键技巧:乘法法则(嵌套循环各层相乘)和加法法则(顺序执行各步相加再取最高阶)。递归的复杂度分析需要额外的手段——递归树法或主定理。
A1: 书中经典案例
| 案例 | 复杂度 | 关键特征 |
|---|---|---|
| 数组随机访问 | O(1) | 通过索引直接定位,操作次数不随 n 增大 |
| 二分查找 | O(log n) | 每次比较将问题规模减半 |
| 线性查找 | O(n) | 最坏情况下遍历整个数组 |
| 冒泡排序 | O(n²) | 双层嵌套循环,每层遍历约 n 次 |
| 递归斐波那契 | O(2ⁿ) | 每次调用分裂为两个子问题,呈指数增长 |
| 全排列 | O(n!) | 第一个位置有 n 种选择,第二个有 n−1 种,以此类推 |
| 归并排序(递归树) | O(n log n) | log n 层递归,每层合并操作 O(n) |
| 递归求和(尾递归) | O(n) | 线性递归,每层 O(1),共 n 层 |
A2: 未来触发场景
用户有以下需求时应加载本技能:
- 编写算法后不确定其性能瓶颈在哪里
- 比较多个解决方案时无法量化判断谁更优
- 设计递归函数需要评估栈深度和总操作量
- 面试准备、Code Review 中需要论证代码效率
- 数据规模从几百增长到百万级别,需要预估是否有性能风险
- 权衡"时间换空间"或"空间换时间"的架构决策
E: 可执行步骤
Step 1: 确定输入规模 n
明确 n 代表什么——数组长度、节点数、字符串长度、递归深度等。
Step 2: 统计基本操作数
逐行遍历代码,为每行标注执行次数(用 n 表示),累加得到 f(n):
- 简单语句(赋值、return):计数 1
- 循环体:循环次数 × 循环体内操作数
- 条件分支:取分支中最大操作数(最差情况)
- 函数调用:计入被调用函数的操作数
Step 3: 应用大 O 化简规则
从 f(n) 提取渐近上界:
- 保留最高阶项(忽略低阶项)
- 忽略常数系数
- 嵌套循环内层操作数与外层相乘
- 顺序块取各块复杂度的最大值
Step 4: 分类并标注
将结果归入常见复杂度类型(见下方速查表)。
Step 5: 分析空间复杂度
按照三类空间分别统计:
- 输入空间:存储输入数据的空间(通常不计入复杂度)
- 暂存空间:算法执行中临时分配的变量、栈帧
- 输出空间:存储返回结果的空间
判定是否为原地算法(O(1) 额外空间)。
Step 6: 递归场景专用分析
使用递归树法:
- 画出递归展开的树形结构
- 标注每层的操作数
- 统计树的深度和每层节点数
- 层数 × 每层操作数 = 总复杂度
B: 边界与不适用场景
| 场景 | 说明 |
|---|---|
| 小规模数据(n < 100) | 常数因子可能主导,O(n²) 可能比 O(n log n) 更快 |
| 已排序/近乎有序的输入 | 最差复杂度可能高估(如插入排序在有序时退化为 O(n)) |
| 硬件异构环境 | 复杂度不反映缓存命中率、分支预测、SIMD 等硬件特性 |
| 分布式系统 | 单机算法复杂度不适用网络延迟、数据分片等场景 |
| 实时系统 | 关注的是 worst-case execution time (WCET),需更精确的分析 |
| 递归深度受限 | Dart 无尾递归优化,深度递归可能先爆栈而非受复杂度限制 |
| 常数因子巨大的算法 | O(n) 操作如涉及数据库查询,实际耗时远超 O(n²) 纯内存操作 |
Workflow: 分析算法复杂度
Task Progress
- Step 1: 识别输入规模 n。 确定哪个参数代表数据量——数组长度
list.length、递归深度depth、字符串长度s.length。如有多个输入维度,分别标注 m、n。 - Step 2: 标注每行执行次数。 遍历代码,在心理上为每行标注执行频次(1, n, n² 等)。循环体标注循环次数,递归标注调用次数。
- Step 3: 累加并提取最高阶。 将所有行的操作数相加得 f(n),忽略常数系数和低阶项,提取 O(g(n))。
- Step 4: 匹配复杂度类型。 将结果与下方速查表对照,确认类型名称和中文描述。
- Step 5: 分析空间占用。 区分输入空间、暂存空间、输出空间。统计额外分配的变量数量和递归调用栈深度。
- Step 6: 如果是递归,画递归树。 确认树深度和每层操作数,验证递归树法的复杂度推算与步骤 3 一致。
- Step 7: 写出分析结论。 格式:
T(n) = O(g(n)), S(n) = O(h(n)),附一句话说明瓶颈所在。 - Step 8: Review + 复核。 检查是否遗漏隐藏的操作(如
List.contains是 O(n))、Dart 内置方法的真实复杂度(sort是 O(n log n)),确认常数系数不是实际瓶颈。
条件逻辑
- 如果算法有多层嵌套循环: 使用乘法法则——各层循环次数相乘。
for (i) { for (j) {} }中内层次数 × 外层次数。 - 如果算法有多个顺序步骤: 使用加法法则——取各步骤中最高阶。step1 O(n) + step2 O(n²) = O(n²)。
- 如果输入有两个独立维度(如矩阵的行 m 和列 n): 分别保留,不能化简为一个 n。如 O(m × n)。
- 如果递归函数每次分裂为 k 个子问题: 检查子问题规模是否等比缩小。若无重叠子问题,复杂度通常为 O(kᵈ) 或 O(nˡᵒᵍᵏ)。
- 如果 Dart 代码使用了集合字面量操作符(spread
...、collection if/for): 展开为等价的循环再分析——每个元素都被遍历一次。 - 如果算法使用了
dart:collection中的数据结构(如LinkedList、SplayTreeMap): 查阅对应数据结构的操作复杂度,纳入分析。
常见时间复杂度速查
| 复杂度 | 名称 | 示例操作 | Dart 代码线索 |
|---|---|---|---|
| O(1) | 常数阶 | 数组索引访问、哈希表查找 | list[i]、map[key](平均) |
| O(log n) | 对数阶 | 二分查找、平衡树操作 | 每轮循环 n 减半:while (low <= high) { mid = ... } |
| O(n) | 线性阶 | 单层遍历、线性查找 | for (final e in list)、list.where() |
| O(n log n) | 线性对数阶 | 归并排序、堆排序 | list.sort() 默认实现 |
| O(n²) | 平方阶 | 双层嵌套遍历、冒泡排序 | for (i) { for (j) {} } 两层都依赖 n |
| O(2ⁿ) | 指数阶 | 递归斐波那契、子集枚举 | 每步分支为 2 个子问题,无记忆化 |
| O(n!) | 阶乘阶 | 全排列 | 逐位选择,选项数递减:n × (n−1) × … × 1 |
常见空间复杂度速查
| 复杂度 | 名称 | 触发条件 |
|---|---|---|
| O(1) | 常数空间(原地算法) | 仅使用有限个临时变量,无额外数组分配 |
| O(log n) | 对数空间 | 递归深度为 log n(如二分递归、合并排序非原地版) |
| O(n) | 线性空间 | 分配与输入等长的辅助数组、或递归深度为 n |
| O(n²) | 平方空间 | 分配 n × n 的二维数组(如动态规划表) |
Examples
示例 1: 分析线性查找的复杂度
/// 在列表中查找目标值,返回索引;未找到返回 -1。
/// 时间复杂度:O(n),空间复杂度:O(1)
int linearSearch(List<int> nums, int target) {
for (var i = 0; i < nums.length; i++) {
// 第 i 次迭代:1 次比较 + 1 次返回(命中时)
if (nums[i] == target) return i;
}
return -1; // 1 次操作
}
// 分析:循环最多执行 n 次 → T(n) = O(n)
// 仅使用 i 一个额外变量 → S(n) = O(1)
示例 2: 分析冒泡排序的复杂度
/// 冒泡排序。时间复杂度 O(n²),空间复杂度 O(1)(原地排序)。
void bubbleSort(List<int> nums) {
final n = nums.length;
// 外层:n−1 轮
for (var i = 0; i < n - 1; i++) {
// 内层:每轮比较 n−1−i 次(仍在 O(n) 级别)
for (var j = 0; j < n - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
// swap 是 O(1)
final tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
}
}
}
}
// 分析:外层 n 次 × 内层 n 次 → T(n) = O(n²)
// 仅使用 tmp 一个临时变量 → S(n) = O(1)(原地算法)
示例 3: 递归斐波那契——指数爆炸
/// 递归斐波那契。时间复杂度 O(2ⁿ),空间复杂度 O(n)(递归栈深度)。
int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// 递归树分析:
// - 每层问题规模减 1,共 n 层
// - 第 k 层约 2ᵏ 个节点
// - 总节点数 ≈ 2ⁿ → T(n) = O(2ⁿ)
// - 栈深度 = n → S(n) = O(n)
//
// 优化建议:使用记忆化递归或迭代法可将时间降至 O(n)。
示例 4: 归并排序——递归树法实战
/// 归并排序。时间复杂度 O(n log n),空间复杂度 O(n)。
List<int> mergeSort(List<int> nums) {
if (nums.length <= 1) return nums;
final mid = nums.length ~/ 2;
final left = mergeSort(nums.sublist(0, mid));
final right = mergeSort(nums.sublist(mid));
return _merge(left, right);
}
List<int> _merge(List<int> left, List<int> right) {
final result = <int>[];
var i = 0, j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result.add(left[i++]);
} else {
result.add(right[j++]);
}
}
result.addAll(left.sublist(i));
result.addAll(right.sublist(j));
return result;
}
// 递归树分析:
// 深度:每次对半切分 → log₂ n 层
// 每层操作:所有节点合并操作合计 O(n)
// 总复杂度:log₂ n 层 × 每层 O(n) → T(n) = O(n log n)
// 空间:辅助数组 result 总计 O(n)(非原地)
示例 5: 分析 Dart 内置方法链的复杂度
/// 计算正数的平方和。需要识别链中每一步的隐藏复杂度。
int sumOfPositiveSquares(List<int> nums) {
return nums
.where((n) => n > 0) // O(n):遍历一次
.map((n) => n * n) // O(n):遍历一次
.fold(0, (a, b) => a + b); // O(n):归约一次
}
// 总时间复杂度:O(n) + O(n) + O(n) = O(n)(加法法则,最高阶为 O(n))
// 中间集合:where 和 map 返回惰性 Iterable,不产生额外 O(n) 空间
// 空间复杂度:O(1)(仅 fold 累加器)
示例 6: 权衡思维——时间换空间 vs 空间换时间
/// 版本 A:哈希表缓存 → O(n) 时间,O(n) 空间
int firstDuplicateA(List<int> nums) {
final seen = <int>{};
for (final n in nums) {
if (seen.contains(n)) return n; // Set.contains 平均 O(1)
seen.add(n);
}
return -1;
}
/// 版本 B:暴力双重循环 → O(n²) 时间,O(1) 空间
int firstDuplicateB(List<int> nums) {
for (var i = 0; i < nums.length; i++) {
for (var j = 0; j < i; j++) {
if (nums[i] == nums[j]) return nums[i];
}
}
return -1;
}
// 权衡:版本 A 用 O(n) 额外空间换取 O(n) 时间
// 版本 B 用 O(1) 额外空间但付出 O(n²) 时间
// 选择取决于:内存约束 vs 数据规模。n ≥ 10⁵ 时 B 几乎不可用。
示例 7: 递归深度警告——Dart 无尾递归优化
/// 求和递归版。看似 O(n) 时间,但实际风险在栈空间。
int sumRecursive(List<int> nums, int i) {
if (i == nums.length) return 0;
return nums[i] + sumRecursive(nums, i + 1);
}
// 时间复杂度:O(n)(n 次调用,每次 O(1))
// 空间复杂度:O(n)(递归调用栈,每帧保存局部变量)
// Dart 风险:Dart 不支持尾递归优化(TCO),n ≈ 10⁵ 时可能栈溢出。
// 对大 n 建议改用迭代:
// int sumIterative(List<int> nums) => nums.fold(0, (a, b) => a + b);
第三部分:数据结构选择决策
数据结构选择决策
来源:靳宇栋《Hello 算法》Dart 语言版(Release 1.3.0,2026),第 4-9 章
Contents
RIA++ 核心分析
R (Reading) —— 书中原文
数组和链表是两种基本的数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和分散空间存储。两者的特点呈现出互补的特性。所有数据结构都是基于数组、链表或二者的组合实现的。
— 《Hello 算法》4.5 小结
I (Interpretation) —— 方法论提炼
数据结构选择的本质是在时间效率与空间效率之间做权衡。连续存储(数组)以空间换时间——内存紧凑、缓存友好、随机访问 O(1),但增删 O(n)。分散存储(链表)以时间换空间——增删 O(1)、灵活扩容,但访问 O(n)、指针开销大。所有高级数据结构都是这两种基础结构的选择或组合:哈希表是数组+链表;树是链表节点的层级组织;堆是数组实现的完全二叉树;图则需要在邻接矩阵(空间换时间)与邻接表(时间换空间)之间二选一。
决策核心公式:选择 = 根据访问模式(随机/顺序/两端) × 操作频率(查/增/删哪个多) × 空间约束(内存是否紧张) × 有序性要求。
A1 (Past Application) —— 书中案例
| 场景 | 选择 | 原因 |
|---|---|---|
| 算法题中的栈 | 基于数组实现 | 缓存命中率高,操作效率优 |
| 数据量大、动态性高的栈 | 基于链表实现 | 避免数组扩容开销,分散存储 |
| 图的稠密图场景(电路网络) | 邻接矩阵 | 边数接近 n²,需要快速判断连通性 |
| 图的稀疏图场景(社交网络) | 邻接表 | 节省 O(n²) 空间为 O(n+m) |
| 软件的"撤销"功能 | 双向队列替代栈 | 需要支持从栈底删除超限历史 |
| Top-K 热搜排行 | 小顶堆 | O(1) 取堆顶 + O(log k) 维护 |
A2 (Future Trigger) —— 何时需要
你在以下任一场景中遇到选择困难时,加载本 skill:
- 需要一个集合存储数据,但不确定用
List、Set还是Map - 需要频繁在头部/尾部插入删除,考虑是否该用
Queue - 需要按键查找且希望 O(1),但担心内存占用
- 需要维护数据的排序状态,在 BST 和排序数组之间犹豫
- 需要找出"最大/最小的 K 个"元素
- 需要建模一个关系网络(社交、地图、依赖)
- 面试算法题中对数据结构选型没有把握
E (Execution) —— 可执行步骤
- 画出访问模式:你的代码主要做什么操作?读取(索引/按键/遍历)、写入(追加/插入/删除)各占多少比例?
- 查阅决策矩阵:对照下方决策矩阵,找到匹配你操作模式的行
- 确认空间约束:内存是否紧张?数据量是否可能大幅增长?
- 选择实现方式:确定数据结构后,选择基于数组还是链表的实现(参考复杂度速查表)
- 编写并测试:用 Dart 原生集合类实现,运行验证性能
B (Boundary) —— 不适用场景
- 纯函数式计算或不可变数据:本 skill 关注 Dart 原生可变集合;如需不可变集合,应使用
built_collection等包 - 并发环境下的线程安全集合:Dart Isolate 隔离模型通常不需要;若跨 Isolate 共享,需用
SendPort/ReceivePort传递副本 - 数据库级别的数据管理:本 skill 不涉及 SQLite、Hive、Drift 等持久化存储的选择
- 极端性能优化场景:复杂度分析提供趋势判断,但常数因子在极端场景下可能逆转结论——需实测验证
数据结构选择决策矩阵
从四个维度评估每种数据结构:访问速度、增删效率、空间开销、有序性。按你的操作模式匹配最佳选择。
| 数据结构 | 随机访问 | 增删(已知位置) | 空间效率 | 有序性 | 典型 Dart 类型 |
|---|---|---|---|---|---|
| 数组 | O(1) | O(n) | 高(无额外开销) | 有序/无序 | List<T> |
| 链表 | O(n) | O(1) | 低(指针开销) | 无序 | 自定义 ListNode |
| 栈 | O(1)(顶) | O(1)(顶) | 取决于实现 | LIFO | List<T>(当栈用) |
| 队列 | O(1)(两端) | O(1)(两端) | 取决于实现 | FIFO | Queue<T> |
| 哈希表 | O(1)* | O(1)* | 低(空位浪费) | 无序 | Map<K,V> / Set<T> |
| 二叉搜索树 | O(log n)* | O(log n)* | 中(指针开销) | 有序 | 自定义 TreeNode |
| 堆(优先队列) | O(1)(顶) | O(log n) | 高(数组实现) | 部分有序 | 自定义 MinHeap |
| 图 | — | O(1)/O(n) | O(n²) 或 O(n+m) | — | Map<V, List<V>> |
* 哈希表为平均情况,最坏 O(n);BST 平均 O(log n),退化时 O(n)
按需求快速定位
| 你的需求 | 首选结构 | 备选方案 |
|---|---|---|
| "我需要快速按索引取值" | List | — |
| "我需要频繁在中间增删" | 链表 | Queue(仅两端) |
| "我需要先进先出的处理顺序" | Queue | List(手动管理索引) |
| "我需要后进先出的处理顺序" | List(栈) | Queue(双向) |
| "我需要按 Key 快速查找" | Map | BST(需有序时) |
| "我需要数据不重复" | Set | Map<K, bool> |
| "我需要数据始终保持有序" | BST / SplayTreeMap | 排序 List + 二分查找 |
| "我需要实时获取最大/最小值" | 堆 | 每次排序(不推荐) |
| "我需要建模节点间关系" | 图(邻接表) | 邻接矩阵(稠密图) |
Workflow: 选择合适的数据结构
Task Progress
- 列出你的核心操作(查找、插入、删除、遍历)及其频率
- 判断是否有顺序要求(FIFO / LIFO / 排序 / 无序)
- 判断是否需要唯一性约束(去重)
- 判断是否需要 Key-Value 映射
- 估算数据规模:<1000 → 低成本结构即可;>10^6 → 优先 O(1) / O(log n)
- 确认内存限制:嵌入式/移动端 → 优先数组实现
- 对照决策矩阵选择数据结构
- 选择具体实现变体(基于数组 vs 基于链表)
- 在 Dart 中用原生类型或自定义类实现
- 编写基准测试验证性能假设
条件逻辑
你主要的操作模式是什么?
├─ 索引访问 + 尾部追加
│ → List<T>(Dart 原生)
│
├─ 头部 + 尾部插入/删除
│ → Queue<T>(dart:collection)
│
├─ Key → Value 查找(无需排序)
│ → Map<K,V> / HashMap<K,V>
│ └─ 数据量大 + 内存敏感?→ 链式哈希 vs 开放寻址
│
├─ Key → Value 查找(需要排序遍历)
│ → SplayTreeMap<K,V>(dart:collection)
│
├─ 只要值,不重复(去重)
│ → Set<T> / HashSet<T>
│
├─ 经常取最大/最小 K 个
│ → 自定义堆(用 List 实现)
│
├─ 建模网络/地图/依赖关系
│ ├─ 边数 < 顶点数²/10(稀疏)→ Map<V, List<V>>(邻接表)
│ └─ 边数接近顶点数²(稠密)→ List<List<int>>(邻接矩阵)
│
└─ 所有以上需求的组合
→ 分层设计:外层用 Map 索引,内层用 List 存储
Decision Tree
需要随机访问(按索引)?
├─ YES → 需要增删在中间?
│ ├─ YES → 数据量如何?
│ │ ├─ < 1000 → List(O(n) 可接受)
│ │ └─ > 10^6 → 链表 + 辅助索引结构
│ └─ NO → List<T>
│
└─ NO → 需要按 Key 查找?
├─ YES → 需要有序遍历?
│ ├─ YES → SplayTreeMap / 自定义 BST
│ └─ NO → Map / HashMap
│
└─ NO → 需要特定顺序处理?
├─ LIFO → List 当做栈
├─ FIFO → Queue
├─ 最值优先 → 堆
└─ 无所谓 → 需要去重?
├─ YES → Set
└─ NO → List(通用)
各结构关键操作复杂度速查
| 操作 | List (数组) | 链表 | Queue | Map (哈希) | BST (平衡) | 堆 |
|---|---|---|---|---|---|---|
| 访问 | O(1) | O(n) | O(1)(两端) | O(1)* | O(log n) | O(1)(顶) |
| 搜索 | O(n) | O(n) | O(n) | O(1)* | O(log n) | O(n) |
| 插入 | O(n) | O(1) | O(1) | O(1)* | O(log n) | O(log n) |
| 删除 | O(n) | O(1) | O(1) | O(1)* | O(log n) | O(log n) |
| 遍历 | O(n) | O(n) | O(n) | O(n) | O(n) | O(n) |
| 空间 | O(n) | O(n) | O(n) | O(n) | O(n) | O(n) |
* 哈希表为平均复杂度;最坏(大量冲突)退化为 O(n)。 BST 未平衡时最坏退化为 O(n)。
图:邻接表 vs 邻接矩阵
| 操作 | 邻接表 | 邻接矩阵 |
|---|---|---|
| 空间 | O(|V|+|E|) | O(|V|²) |
| 添加边 | O(1) | O(1) |
| 删除边 | O(|E|) | O(1) |
| 添加顶点 | O(1) | O(|V|²) |
| 删除顶点 | O(|V|+|E|) | O(|V|²) |
| 查询边 | O(|V|) | O(1) |
选择规则:边数 > |V|²/10 → 邻接矩阵;边数 < |V|²/10 → 邻接表。
Examples (Dart 代码示例)
1. 数组(List)—— 随机访问王者
void demoList() {
// 初始化
List<int> nums = [1, 3, 2, 5, 4];
// 随机访问 O(1)
int item = nums[2]; // 2
// 尾部追加 O(1)
nums.add(6); // [1, 3, 2, 5, 4, 6]
// 中间插入 O(n) —— 后续元素后移
nums.insert(2, 10); // [1, 3, 10, 2, 5, 4, 6]
// 删除 O(n) —— 后续元素前移
nums.removeAt(2); // [1, 3, 2, 5, 4, 6]
// 遍历 O(n)
for (var n in nums) { print(n); }
}
Dart List(列表)— 动态数组封装
R: "在 Dart 中,List 是一个动态数组,底层基于数组实现,自动管理扩容。"(《Hello 算法》第 4 章)
I: Dart 的 List 本质上是一个会自动扩容的数组。它提供了随机访问 O(1) 和尾部增删 O(1),但中间插入删除为 O(n)。扩容时会分配新内存并拷贝,触发时 O(n)。
A1: 书中所有排序算法均以 List<int> 作为输入,利用其随机访问特性实现高效排序。
A2: 需要随机访问且增删集中在尾部时(如日志收集、打点记录),List 是首选。
E:
- 确认主要操作是随机读取还是频繁增删
- 主操作为随机读取且增删在尾部 →
List - 中间频繁插入删除 → 考虑
Queue或自定义链表
B: 中间大量插入删除操作 O(n),此时应改用 Queue 或链表。
// Dart List 示例
final nums = <int>[];
nums.add(1); // 尾部添加 O(1) 均摊
nums.addAll([2,3]); // 批量添加
nums.insert(1, 99); // 中间插入 O(n)
nums.removeAt(0); // 删除 O(n)
nums[2]; // 随机访问 O(1)
// 固定长度 List
final fixed = List.filled(5, 0); // 长度不可变
2. 链表 —— 频繁增删的首选
class ListNode {
int val;
ListNode? next;
ListNode(this.val, [this.next]);
}
void demoLinkedList() {
// 构建链表: 1 → 3 → 2
ListNode n0 = ListNode(1);
ListNode n1 = ListNode(3);
ListNode n2 = ListNode(2);
n0.next = n1;
n1.next = n2;
// 在 n0 后插入节点 O(1)
void insertAfter(ListNode target, ListNode newNode) {
newNode.next = target.next;
target.next = newNode;
}
insertAfter(n0, ListNode(4)); // 1 → 4 → 3 → 2
// 删除 n0 的后继节点 O(1)
void removeAfter(ListNode target) {
if (target.next == null) return;
target.next = target.next?.next;
}
removeAfter(n0); // 1 → 3 → 2
// 查找 O(n)
int indexOf(ListNode? head, int target) {
int index = 0;
while (head != null) {
if (head.val == target) return index;
head = head.next;
index++;
}
return -1;
}
}
3. 栈 —— LIFO 后进先出
import 'dart:collection';
void demoStack() {
// Dart List 天然支持栈操作
List<int> stack = [];
// 入栈 O(1)
stack.add(1);
stack.add(3);
stack.add(2);
// 访问栈顶 O(1)
int top = stack.last; // 2
// 出栈 O(1)
int popped = stack.removeLast(); // 2
// stack 现在是 [1, 3]
// 判空
bool empty = stack.isEmpty;
}
// 栈的典型应用:括号匹配
bool isValid(String s) {
List<String> stack = [];
Map<String, String> pairs = {')': '(', ']': '[', '}': '{'};
for (var ch in s.split('')) {
if (pairs.containsValue(ch)) {
stack.add(ch);
} else if (pairs.containsKey(ch)) {
if (stack.isEmpty || stack.removeLast() != pairs[ch]) return false;
}
}
return stack.isEmpty;
}
4. 队列 —— FIFO 先进先出
import 'dart:collection';
void demoQueue() {
// Queue 提供 O(1) 的两端操作
Queue<int> queue = Queue<int>();
// 入队(队尾) O(1)
queue.addLast(1);
queue.addLast(3);
queue.addLast(2);
// 访问队首 O(1)
int front = queue.first; // 1
// 出队(队首) O(1)
int dequeued = queue.removeFirst(); // 1
// queue 现在是 [3, 2]
int size = queue.length; // 2
}
// 队列的典型应用:BFS 层序遍历
List<int> levelOrder(Map<int, List<int>> graph, int start) {
List<int> result = [];
Set<int> visited = {start};
Queue<int> queue = Queue<int>()..add(start);
while (queue.isNotEmpty) {
int node = queue.removeFirst();
result.add(node);
for (var neighbor in (graph[node] ?? [])) {
if (!visited.contains(neighbor)) {
visited.add(neighbor);
queue.addLast(neighbor);
}
}
}
return result;
}
5. 哈希表 —— O(1) 键值查找
void demoHashMap() {
// Dart Map 即哈希表
Map<String, int> map = {};
// 插入/更新 O(1)*
map['apple'] = 5;
map['banana'] = 3;
map['cherry'] = 8;
// 查找 O(1)*
int? price = map['apple']; // 5
bool has = map.containsKey('grape'); // false
// 删除 O(1)*
map.remove('banana');
// 遍历 O(n) —— 无序!
map.forEach((key, value) => print('$key: $value'));
// 去重利器
List<int> nums = [1, 2, 2, 3, 3, 3];
Set<int> unique = nums.toSet(); // {1, 2, 3}
// 计数统计
Map<int, int> freq = {};
for (var n in nums) {
freq[n] = (freq[n] ?? 0) + 1;
} // {1: 1, 2: 2, 3: 3}
}
6. 堆(优先队列)—— O(1) 取最值
// 小顶堆 —— 用于 Top-K 问题
class MinHeap {
final List<int> _heap = [];
int peek() => _heap[0];
int size() => _heap.length;
bool isEmpty() => _heap.isEmpty;
void push(int val) {
_heap.add(val);
_siftUp(size() - 1);
}
int pop() {
int root = _heap[0];
_heap[0] = _heap.removeLast();
if (_heap.isNotEmpty) _siftDown(0);
return root;
}
void _siftUp(int i) {
int p = (i - 1) ~/ 2;
while (p >= 0 && _heap[p] > _heap[i]) {
_swap(p, i);
i = p;
p = (i - 1) ~/ 2;
}
}
void _siftDown(int i) {
while (true) {
int l = 2 * i + 1, r = 2 * i + 2, min = i;
if (l < _heap.length && _heap[l] < _heap[min]) min = l;
if (r < _heap.length && _heap[r] < _heap[min]) min = r;
if (min == i) break;
_swap(i, min);
i = min;
}
}
void _swap(int i, int j) {
int tmp = _heap[i]; _heap[i] = _heap[j]; _heap[j] = tmp;
}
}
// Top-K 问题:从海量数据找最大的 K 个元素
List<int> topKLargest(List<int> nums, int k) {
MinHeap heap = MinHeap();
// 先用前 K 个元素建堆
for (int i = 0; i < k; i++) heap.push(nums[i]);
// 维护小顶堆:大于堆顶则替换
for (int i = k; i < nums.length; i++) {
if (nums[i] > heap.peek()) {
heap.pop();
heap.push(nums[i]);
}
}
// 导出结果
List<int> result = [];
while (!heap.isEmpty()) result.add(heap.pop());
return result; // 升序输出
}
void demoTopK() {
List<int> data = [3, 1, 5, 12, 2, 11, 7, 8, 9, 4];
List<int> top3 = topKLargest(data, 3);
print(top3); // [9, 11, 12]
}
7. 图 —— 关系网络建模
// 邻接表实现(适用于稀疏图)
class GraphAdjList {
final Map<int, List<int>> adjList = {};
void addVertex(int v) => adjList.putIfAbsent(v, () => []);
void addEdge(int a, int b) {
addVertex(a);
addVertex(b);
adjList[a]!.add(b);
// 无向图需双向添加
// adjList[b]!.add(a);
}
void removeEdge(int a, int b) {
adjList[a]?.remove(b);
}
void removeVertex(int v) {
adjList.remove(v);
for (var list in adjList.values) list.remove(v);
}
bool hasEdge(int a, int b) => adjList[a]?.contains(b) ?? false;
}
// 邻接矩阵实现(适用于稠密图)
class GraphAdjMat {
List<int> vertices = [];
List<List<int>> adjMat = [];
void addVertex(int v) {
int n = vertices.length;
vertices.add(v);
// 扩充矩阵
for (var row in adjMat) row.add(0);
adjMat.add(List.filled(n + 1, 0));
}
void addEdge(int i, int j) => adjMat[i][j] = 1;
void removeEdge(int i, int j) => adjMat[i][j] = 0;
bool hasEdge(int i, int j) => adjMat[i][j] == 1;
}
void demoGraph() {
// 社交网络示例
GraphAdjList social = GraphAdjList();
social.addEdge(1, 2);
social.addEdge(1, 3);
social.addEdge(2, 4);
print(social.adjList); // {1: [2, 3], 2: [4], 3: [], 4: []}
}
8. 综合示例:从需求到数据结构选择
void main() {
// 场景:需要维护一个排行榜,支持:
// 1) 快速获取前 10 名
// 2) 按用户名查找排名
// 3) 更新分数
// 选择组合:
// - 堆 → 维护 Top-10
// - Map → 按用户名 O(1) 查找
Map<String, int> scores = {};
MinHeap topK = MinHeap();
int k = 3; // 演示用 3
void updateScore(String user, int newScore) {
int oldScore = scores[user] ?? 0;
scores[user] = newScore;
// 如果进入 Top-K 则更新堆
if (topK.size() < k || newScore > topK.peek()) {
// 简化处理:重建(生产环境用更精细的维护策略)
topK = MinHeap();
List<int> sorted = scores.values.toList()..sort((a, b) => b.compareTo(a));
for (int i = 0; i < k && i < sorted.length; i++) topK.push(sorted[i]);
}
}
updateScore('Alice', 100);
updateScore('Bob', 200);
updateScore('Charlie', 50);
updateScore('Diana', 300);
print(scores); // {Alice: 100, Bob: 200, Charlie: 50, Diana: 300}
}
双向队列(Deque)—— 两端操作均 O(1)
import 'dart:collection';
void demoDeque() {
// Dart 的 Queue 即双向队列
Queue<int> deque = Queue<int>();
// 两端入队 O(1)
deque.addFirst(1);
deque.addLast(2);
deque.addFirst(3); // 3 → 1 → 2
// 两端出队 O(1)
int first = deque.removeFirst(); // 3
int last = deque.removeLast(); // 2
// 滑动窗口最大值 —— 双向队列经典应用
List<int> maxSlidingWindow(List<int> nums, int k) {
List<int> result = [];
Queue<int> deque = Queue<int>(); // 存储索引
for (int i = 0; i < nums.length; i++) {
// 移除超出窗口的索引
while (deque.isNotEmpty && deque.first <= i - k)
deque.removeFirst();
// 维护递减队列:移除所有小于当前值的索引
while (deque.isNotEmpty && nums[deque.last] < nums[i])
deque.removeLast();
deque.addLast(i);
// 窗口形成后记录最大值
if (i >= k - 1) result.add(nums[deque.first]);
}
return result;
}
List<int> result = maxSlidingWindow([1, 3, -1, -3, 5, 3, 6, 7], 3);
print(result); // [3, 3, 5, 5, 6, 7]
}
总结:一分钟决策速查
数据有索引/位置含义?
├─ YES → List<T>(Dart 默认,90% 场景适用)
└─ NO → 数据有 Key 且需要查找?
├─ YES → Map<K,V>(O(1) 查找)
└─ NO → 需要特定顺序?
├─ FIFO(先进先出)→ Queue<T>
├─ LIFO(后进先出)→ List 当做栈
├─ 最值优先 → 自定义堆(List 实现)
└─ 需要排序 + 范围查询 → SplayTreeMap<K,V>
核心原则:用最简单的结构,能 List 就不自定义类。复杂度只在数据量 > 10^5 时真正重要。
第四部分:排序与搜索
dart-algorithms-sort-search
来源:靳宇栋《Hello 算法》Dart 语言版 Release 1.3.0,2026 覆盖章节:第 10 章(搜索)、第 11 章(排序)
Contents
一、排序算法概览
1.1 对比矩阵
| 算法 | 平均时间 | 最差时间 | 最佳时间 | 空间 | 稳定 | 原地 | 自适应 |
|---|---|---|---|---|---|---|---|
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 否 | 是 | 否 |
| 冒泡排序 | O(n²) | O(n²) | O(n) | O(1) | 是 | 是 | 是(加 flag) |
| 插入排序 | O(n²) | O(n²) | O(n) | O(1) | 是 | 是 | 是 |
| 快速排序 | O(n log n) | O(n²) | O(n log n) | O(log n) | 否 | 是 | 否 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 是 | 否 | 否 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 否 | 是 | 否 |
| 桶排序 | O(n+k) | O(n²) | O(n+k) | O(n+k) | 是 | 否 | 否 |
| 计数排序 | O(n+m) | O(n+m) | O(n+m) | O(n+m) | 是 | 否 | 否 |
| 基数排序 | O(nk) | O(nk) | O(nk) | O(n+d) | 是 | 否 | 否 |
1.2 RIA++ 详解
选择排序
R:开启一个循环,每轮从未排序区间选择最小的元素,将其交换到已排序区间的末尾。设数组长度为 n,共 n-1 轮循环,每轮内部比较 n-i 次。时间复杂度 O(n²),非稳定排序。
I:每次在剩余元素中找最小值的下标,然后把它和当前轮次的起始位置交换。两层循环:外层控制已排序边界,内层扫描最小值。因为选择后直接交换而非插入,可能打乱相等元素的相对顺序,所以不稳定。
A1:对 [4, 1, 3, 1, 5] 排序,两个 1 的相对顺序可能被破坏——第一个 1 被交换到末尾后,顺序反转。
A2:数据量 N ≤ 50 且不关心稳定性时可接受;内存极度受限(原地 O(1))但一般不如插入排序。
E:
void selectionSort(List<int> nums) {
final n = nums.length;
for (var i = 0; i < n - 1; i++) {
int k = i;
for (var j = i + 1; j < n; j++) {
if (nums[j] < nums[k]) k = j;
}
final tmp = nums[i];
nums[i] = nums[k];
nums[k] = tmp;
}
}
B:不稳定,交换可能破坏相等元素顺序。
冒泡排序
R:连续地比较与交换相邻元素实现排序。每轮将未排序区间内的最大元素"冒泡"到末尾。可加 swapped 标志位,若某轮无交换说明已有序,提前终止。
I:从前往后两两比较相邻元素,大的往后走(如气泡上浮)。每次内循环把当前范围的最大值推到右端。加 flag 后最佳情况 O(n)——一轮扫描无交换即退出。
A1:对 [4, 1, 3, 1, 5] 排序,第一轮把 5 推到最后,第二轮把 4 推到倒数第二……相等元素只在 > 时交换,故稳定。
A2:教学场景最常用;小数据量且需要稳定排序时可用,但常数项比插入大。
E:
void bubbleSort(List<int> nums) {
final n = nums.length;
for (var i = n - 1; i > 0; i--) {
var swapped = false;
for (var j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
final tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
swapped = true;
}
}
if (!swapped) break;
}
}
B:无 flag 退化;每轮只冒泡一个元素,大数据量下极慢。
插入排序
R:在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将其插入到正确的位置。时间复杂度 O(n²),但常数项小,在小数据量和几乎有序的数据上表现优异。
I:就像打扑克时整理手牌——每次拿到一张新牌,从右往左和已有的牌比较,找到合适位置插入。左侧已排序区间始终保持有序。因为它只是"挤"出一个位置放入元素,相等元素不会互换顺序,因此稳定。
A1:对 [3, 2, 1, 5, 4] 排序,第一轮 3 已有序,第二轮把 2 插入到 3 之前变成 [2, 3],第三轮 1 插入到最前……
A2:数据库中小批量数据的"最后排序";快速排序递归到子数组 ≤ 15 时切换插入排序(多数语言 sort() 内部策略);在线处理(数据流式到达)。
E:
void insertionSort(List<int> nums) {
final n = nums.length;
for (var i = 1; i < n; i++) {
final base = nums[i];
var j = i - 1;
while (j >= 0 && nums[j] > base) {
nums[j + 1] = nums[j];
j--;
}
nums[j + 1] = base;
}
}
B:大规模乱序数据 O(n²) 不可接受。
快速排序
R:选取一个基准数(pivot),将数组分为"小于基准数"和"大于基准数"两个子数组,再递归地对子数组排序。哨兵划分的核心操作是从两端向中间扫描交换。平均时间复杂度 O(n log n),但若每次选到最值元素退化为 O(n²)。不稳定。
I:分治策略的典范。每轮选一个 pivot,然后用双指针从左右两端向中间扫描:左边找到 ≥ pivot 的元素,右边找到 ≤ pivot 的元素,交换两者,直到指针相遇。递归处理左右子数组。随机选择 pivot 或三数取中法可规避退化。平均跑得最快的通用排序算法。
A1:对 [3, 2, 1, 5, 4],选 3 为 pivot,左指针找到 5,右指针找到 1,交换得 [3, 2, 5, 1, 4],直至指针相遇,最终 pivot 与 1 交换得 [1, 2, 3, 5, 4],左侧 < 3,右侧 > 3。
A2:通用排序首选(Dart List.sort() 内部实现);大数据量、不要求稳定性的场景。
E:
void quickSort(List<int> nums, int left, int right) {
if (left >= right) return;
final pivot = _partition(nums, left, right);
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
int _partition(List<int> nums, int left, int right) {
int i = left, j = right;
final pivot = nums[left];
while (i < j) {
while (i < j && nums[j] >= pivot) j--;
while (i < j && nums[i] <= pivot) i++;
final tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
nums[left] = nums[i];
nums[i] = pivot;
return i;
}
B:不稳定(相等元素可能互换位置);最差 O(n²) 需用随机基准/三数取中规避;递归栈深 O(log n) 需注意栈溢出。
归并排序
R:基于分治策略,划分为"划分阶段"(递归将数组从中点平分为两个子数组)和"合并阶段"(将有序子数组合并为一个有序数组)。时间复杂度稳定 O(n log n),空间 O(n)(合并阶段需辅助数组)。稳定排序。
I:先一刀切到底——不停对半分直到子数组仅含一个元素(天然有序)。再自底向上合并:用两个指针分别指向两个有序子数组的头部,每次取较小者放入临时数组。相等时取左侧元素,因此稳定。O(n) 辅助空间是代价,但对链表排序时可原地合并。
A1:对 [3, 2, 1, 5, 4],分到 [3] [2] [1] [5] [4],合并 [2,3] 和 [1] → [1,2,3],合并 [4,5] → [4,5],最终合并得 [1,2,3,4,5]。
A2:要求稳定排序的场景(多级排序中保留前一轮顺序);链表排序(可原地合并,无 O(n) 空间开销);外部排序(海量数据分块排序后合并)。
E:
void mergeSort(List<int> nums, int left, int right) {
if (left >= right) return;
final mid = (left + right) ~/ 2;
mergeSort(nums, left, mid);
mergeSort(nums, mid + 1, right);
_merge(nums, left, mid, right);
}
void _merge(List<int> nums, int left, int mid, int right) {
final tmp = List<int>.filled(right - left + 1, 0);
int i = left, j = mid + 1, k = 0;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
tmp[k++] = nums[i++];
} else {
tmp[k++] = nums[j++];
}
}
while (i <= mid) tmp[k++] = nums[i++];
while (j <= right) tmp[k++] = nums[j++];
for (var p = 0; p < tmp.length; p++) {
nums[left + p] = tmp[p];
}
}
B:O(n) 辅助空间(数组排序的硬伤);常数项比快排大,实际运行慢于快排。
堆排序
R:利用堆数据结构实现的排序。首先将数组构建为最大堆,然后依次将堆顶元素(最大值)与堆底元素交换,并缩小堆的范围,对新堆顶执行"从顶至底堆化"。时间复杂度严格 O(n log n),原地排序,但不稳定。
I:建堆阶段将数组转化为大顶堆(从最后一个非叶节点开始自底向上堆化)。排序阶段不断取出堆顶最大值放到数组末尾,然后对剩余部分重新下沉堆化。优势是 O(1) 额外空间且性能稳定,适合内存极度受限场景。
A1:数组 [4, 10, 3, 5, 1] → 建堆 → [10, 5, 3, 4, 1] → 交换堆顶与堆底 → [1, 5, 3, 4, 10] → 堆化 → [5, 4, 3, 1, 10] → 循环……
A2:嵌入式/小内存设备;需要 O(n log n) 且不接受快排退化风险但可接受不稳定的场景。
E:
void heapSort(List<int> nums) {
final n = nums.length;
for (var i = (n ~/ 2) - 1; i >= 0; i--) _siftDown(nums, n, i);
for (var i = n - 1; i > 0; i--) {
final tmp = nums[0];
nums[0] = nums[i];
nums[i] = tmp;
_siftDown(nums, i, 0);
}
}
void _siftDown(List<int> nums, int n, int i) {
while (true) {
int l = 2 * i + 1, r = 2 * i + 2, ma = i;
if (l < n && nums[l] > nums[ma]) ma = l;
if (r < n && nums[r] > nums[ma]) ma = r;
if (ma == i) break;
final tmp = nums[i];
nums[i] = nums[ma];
nums[ma] = tmp;
i = ma;
}
}
B:不稳定(堆化过程中交换破坏相对顺序);缓存不友好(跳转访问);实际运行通常慢于快排。
桶排序 / 计数排序 / 基数排序(线性排序)
R:非比较排序,利用数据本身的特性(取值范围、数位长度)绕过比较,达到 O(n) 级别。桶排序将数据分散到多个桶内各自排序后合并;计数排序统计各值的出现次数并通过前缀和确定位置;基数排序从低位到高位对每一位实施稳定计数排序。
I:这三种排序都用"空间换时间"把 O(n log n) 的比较下界突破到 O(n)。桶排序先分桶再桶内排序(桶内通常用插入/快排);计数排序直接统计每个值的出现次数,然后按顺序"铺回"结果数组;基数排序逐位排序(从个位到最高位),每一趟用计数排序保证稳定性。
A1:计数排序处理 [2, 1, 1, 0, 3] → count 数组 [1, 2, 1, 1] → 前缀和 [1, 3, 4, 5] → 将元素按前缀和放到正确位置。
A2:计数排序——成绩排名(0-100 分)、年龄统计等取值范围有限的整数;基数排序——身份证号排序、IP 地址排序等定长多关键字排序;桶排序——均匀分布的浮点数、海量数据预分割。
E(计数排序示例):
void countingSort(List<int> nums) {
final m = nums.reduce((a, b) => a > b ? a : b);
final counter = List<int>.filled(m + 1, 0);
for (final num in nums) counter[num]++;
var i = 0;
for (var val = 0; val <= m; val++) {
for (var c = 0; c < counter[val]; c++) {
nums[i++] = val;
}
}
}
B:计数排序要求数据为非负整数且 取值范围 m 不能太大(空间 O(n+m) 若 m ≫ n 浪费严重);基数排序要求数据可表示为固定位数的关键字;桶排序依赖数据均匀分布,分布不均时某桶承载过多元素退化。
二、排序选择决策
flowchart TD
START[需要排序] --> S1{数据量 N?}
S1 -->|N ≤ 50| IN[插入排序 — 常数项最小]
S1 -->|N > 50| S2{需要稳定性?}
S2 -->|是| S3{内存充裕?}
S3 -->|是| MERGE[归并排序 — 稳定 O n log n]
S3 -->|否| S4{数据范围有限且整数?}
S4 -->|是| CNT[计数排序 — 稳定 O n+m]
S4 -->|否| IN2[插入排序 — 折衷]
S2 -->|否| S5{内存极度紧张?}
S5 -->|是| HEAP[堆排序 — 原地 O 1]
S5 -->|否| S6{数据范围有限?}
S6 -->|整数 + 范围小| CNT2[计数/基数排序]
S6 -->|定长多关键字| RADIX[基数排序]
S6 -->|通用| QUICK[快速排序 — 最常用]
一句话决策:小就用插入,要稳就归并,内存紧用堆,通用选快排,范围有限用计数。
三、二分查找
3.1 标准二分查找
R:在有序数组中,每次取区间中点与目标值比较,根据比较结果将搜索范围缩小一半。时间复杂度 O(log n),仅适用于有序数据。
I:核心是维护一个 [left, right] 闭区间,每次算 mid,若 nums[mid] == target 返回下标;若 target < nums[mid] 则 right = mid - 1;否则 left = mid + 1。循环终止条件是 left > right。在移动端、物联网等计算资源受限环境下极为实用。
A1:在 [1, 3, 5, 7, 9, 11, 13] 中找 7:mid=3(7) 命中返回 3。找 6:mid=3(7)→左缩,mid=1(3)→右缩,mid=2(5)→右缩,left>right,返回 -1。
A2:有序数组中定位特定值(基础用法);搜索建议的自动补全、IP路由表查找;数据库索引 B+ 树的核心逻辑。
E:
int binarySearch(List<int> nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
final mid = left + (right - left) ~/ 2;
if (nums[mid] == target) return mid;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
B:数组必须已排序!无序数据必须先用 O(n log n) 排序;仅适用于随机访问数据结构(数组),不适用于链表;若数组频繁插入/删除,维护有序性的成本很高。
3.2 二分查找变体:查找插入点
R:当目标元素不存在时,返回该元素应插入的位置(保持数组有序)。这是 left 指针的含义——循环结束时 left 指向第一个 ≥ target 的元素位置。
I:与标准二分几乎相同,只是不提前返回(不在 mid 命中时 return),而是在循环结束后返回 left。left 在退出时刚好指向插入位置——0 到 n 之间。
A1:在 [1, 3, 5, 7, 9] 中找 6 的插入点:最终 left=3 → 应插入在 index 3(7 之前)。
A2:实现有序集合的 add() 方法;合并两个有序列表;在排序数组中找第一个 ≥ target 的位置(lower_bound)。
E:
int binarySearchInsertion(List<int> nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
final mid = left + (right - left) ~/ 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
}
B:返回的是第一个 ≥ target 的位置(重复元素时插在最左侧)。
3.3 二分查找变体:查找左/右边界
R:在含重复元素的有序数组中找到目标值的起始和结束位置。左边界二分:遇到 nums[mid]==target 时继续向左搜;右边界二分:遇到相等时继续向右搜。
I:左边界:nums[mid] >= target 时 right = mid - 1,最终 left 停在第一个 target 位置。右边界:nums[mid] <= target 时 left = mid + 1,最终 right 停在最后一个 target 位置。需额外验证——检查 left 是否越界且 nums[left] == target。
A1:在 [1, 2, 2, 2, 3, 4] 中找 2 → 左边界返回 1,右边界返回 3。
A2:搜索一个值在数组中的出现次数(right - left + 1);区间查询(如"价格在 100-200 之间的商品");数据去重统计。
E:
int binarySearchLeftEdge(List<int> nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
final mid = left + (right - left) ~/ 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left == nums.length || nums[left] != target) return -1;
return left;
}
int binarySearchRightEdge(List<int> nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
final mid = left + (right - left) ~/ 2;
if (nums[mid] <= target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right < 0 || nums[right] != target) return -1;
return right;
}
B:返回值需做越界和目标值相等性校验;仅适用于有序数组。
3.4 二分查找前提条件
| 条件 | 说明 |
|---|---|
| 数据有序 | 数组必须按升序(或降序)排列,否则二分逻辑失效 |
| 随机访问 | 必须能在 O(1) 时间内访问任意位置元素 → 数组、列表 |
| 静态/低频更新 | 若频繁增删,维护有序性的 O(n) 代价可能抵消 O(log n) 优势 |
| 非链表 | 链表取中点需 O(n) 遍历,二分退化为 O(n log n),此时不如遍历 |
思考题:有序链表若要支持 O(log n) 查询,用什么数据结构?答案:跳表(Skip List)。
四、哈希查找(空间换时间)
R:使用哈希表建立键值映射,通过哈希函数计算出目标元素的存储位置,实现 O(1) 平均查询效率。但需要 O(n) 额外空间,且无法进行范围查询。
I:当你需要反复查找某个值时,把查找从 O(n) 降到 O(1) 的最简单策略就是——先建一个哈希表。比如"两数之和"题:target - nums[i] 是否出现过?用 HashMap 一次遍历就解决,暴力 O(n²) → 哈希 O(n)。代价是内存。
A1:LeetCode 两数之和:遍历数组 [2, 7, 11, 15],target=9。遍历到 7 时,在 HashMap 中查 9-7=2 存在 → 返回 [0, 1]。
A2:高频查询且无须范围/顺序的场景;去重(Set);缓存层(LRU);统计频率、计数。
E:
List<int> twoSum(List<int> nums, int target) {
final map = <int, int>{};
for (var i = 0; i < nums.length; i++) {
final complement = target - nums[i];
if (map.containsKey(complement)) return [map[complement]!, i];
map[nums[i]] = i;
}
return [];
}
B:O(n) 额外空间;无序——需要范围查询或 topK 时不如堆/树;哈希冲突严重时退化 O(n)。
五、重述算法(盲打模板)
快速排序(核心模板)
选 pivot → 左右指针 → 循环交换 → pivot 归位 → 递归左右
归并排序(核心模板)
递归平分 → 合并两个有序数组 → 辅助数组拷贝回原数组
二分查找(核心模板)
while left <= right:
mid = left + (right-left)/2
相等返回 / 比目标小左移 / 比目标大右移
哈希优化(核心模板)
遍历 → target - current 是否在哈希表中 → 找到返回 / 否则存入
六、搜索方法四象限选择
| 维度 | 线性搜索 | 二分查找 | 哈希查找 | 树查找 |
|---|---|---|---|---|
| 时间复杂度 | O(n) | O(log n) | O(1) 平均 | O(log n) |
| 空间开销 | O(1) | O(1) | O(n) | O(n) |
| 数据有序要求 | 否 | 是 | 否 | 是(部分有序) |
| 范围查询 | 否 | 是(有序) | 否 | 是 |
| 更新友好性 | 好(O(1)增删) | 差(O(n)维护) | 好(O(1)平均) | 中(O(log n)) |
| 适用场景 | 小数据/单次/高频更新 | 有序数组/低频增删 | 高频查询/无范围 | 海量+有序+范围 |
七、Dart 内置排序参考
// Dart 内置 sort——内部使用 TimSort(归并+插入的混合排序),稳定
final nums = [3, 1, 4, 1, 5];
nums.sort(); // 升序,原地修改
nums.sort((a, b) => b.compareTo(a)); // 自定义比较器
// 获取排序后的新列表(不修改原列表)
final sorted = [...nums]..sort();
// 二分查找——需要 package:collection
import 'package:collection/collection.dart';
final idx = binarySearch(nums, 4); // 返回下标,未找到返回 -1
八、边界限制汇总
| 边界 | 详情 |
|---|---|
| 比较排序下界 | 基于比较的排序理论最优为 O(n log n),要突破必须用非比较排序 |
| 快排退化 | 选到最值 pivot 退化为 O(n²),用随机 pivot 或三数取中解决 |
| 二分查找前提 | 数据必须有序且支持随机访问,不适用于链表 |
| 计数排序限制 | 数据须为非负整数,且取值范围 m 不能远大于 n(否则空间浪费) |
| 基数排序限制 | 数据可表示为固定位数的关键字(如数字、定长字符串) |
Workflow: 选择排序与搜索策略
Task Progress
- Step 1: 分析数据特征。 数据量大小?是否有序?需要稳定性吗?内存限制?
- Step 2: 选择排序算法。 参照排序对比矩阵,根据数据特征选最优算法。
- Step 3: 实现排序。 优先使用 Dart 内置
sort()(TimSort),特殊场景才手写。 - Step 4: 选择搜索策略。 数据有序且需多次搜索 → 二分;高频查询 → 哈希;小数据 → 线性。
- Step 5: 处理边界。 数据是否有序(二分前提)?哈希冲突处理?浮点比较用 epsilon?
- Step 6: 运行测试。 验证排序正确性(有序性检查)和搜索准确性。
条件逻辑
- 如果 n < 50 → 用插入排序或内置 sort
- 如果需要稳定性 → 归并排序或 Dart sort()(TimSort 稳定)
- 如果数据范围远小于 n → 计数排序
- 如果数据基本有序 → 插入排序 / 冒泡排序(加 early exit)
- 如果数据可表示为固定位数 → 基数排序
Examples
排序选择实例
// 场景1: 小数据量排序 → Dart 内置
final small = [3, 1, 4, 1, 5];
small.sort(); // TimSort,稳定,原地
// 场景2: 大数据 + 需稳定 → 归并排序
List<int> mergeSort(List<int> arr) {
if (arr.length <= 1) return arr;
final mid = arr.length ~/ 2;
final left = mergeSort(arr.sublist(0, mid));
final right = mergeSort(arr.sublist(mid));
return _merge(left, right);
}
// 场景3: 海量数据 + 范围小 → 计数排序
List<int> countingSort(List<int> arr) {
if (arr.isEmpty) return [];
final max = arr.reduce((a, b) => a > b ? a : b);
final count = List.filled(max + 1, 0);
for (final x in arr) count[x]++;
final result = <int>[];
for (var i = 0; i <= max; i++) {
result.addAll(List.filled(count[i], i));
}
return result;
}
二分查找实例
// 标准二分查找
int binarySearch(List<int> nums, int target) {
var i = 0, j = nums.length; // [i, j)
while (i < j) {
final mid = i + (j - i) ~/ 2;
if (nums[mid] < target) {
i = mid + 1;
} else if (nums[mid] > target) {
j = mid;
} else {
return mid;
}
}
return -1;
}
// 查找插入点(左边界)
int binarySearchInsertion(List<int> nums, int target) {
var i = 0, j = nums.length;
while (i < j) {
final mid = i + (j - i) ~/ 2;
if (nums[mid] < target) { i = mid + 1; }
else { j = mid; }
}
return i; // 返回最左插入位置
}
第五部分:算法范式对比与选择
算法范式对比与选择
来源:靳宇栋《Hello 算法》Dart 语言版,Release 1.3.0,2026
Contents
- 四大范式速览
- 一、分治 Divide & Conquer
- 二、回溯 Backtracking
- 三、动态规划 Dynamic Programming
- 四、贪心 Greedy
- 五、范式选择决策框架
- 六、回溯→记忆化搜索→动态规划渐进路径
- 七、范式横向对比表
- Workflow:拿到新问题如何选范式
- Examples
四大范式速览
| 范式 | 核心思想 | 时间复杂度典型 | 适合场景 | 致命缺陷 |
|---|---|---|---|---|
| 分治 | 分解→独立求解→合并 | O(n log n) | 子问题独立可合并 | 子问题重叠时重复计算 |
| 回溯 | 穷举+剪枝+回退 | O(k^n) / O(n!) | 求所有解/排列组合 | 无剪枝时状态爆炸 |
| 动态规划 | 存储子问题解避免重复 | O(n) / O(n²) | 最优解+重叠子问题 | 不满足最优子结构则失败 |
| 贪心 | 每一步选局部最优 | O(n) / O(n log n) | 贪心选择性质成立 | 不可靠的局部选择导致次优 |
一、分治 Divide & Conquer
R — 原文引用
分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。— §14.2
分:递归地将原数组划分为两个子数组,直到子数组只剩一个元素。治:从底至顶地将有序的子数组合并。— §12.1
I — 用自己的话重写
分治将大问题递归拆分为若干个结构相同、规模更小的子问题。子问题之间完全独立(无重叠),各自求解后再将结果合并为原问题解。核心是"分—治—合"三步,每一层的子问题与父问题形式一致,只是数据规模递减。提升效率的底层逻辑有二:操作数量优化(划分后常数项缩小,递归至 O(n log n));并行计算优化(独立子问题可多核并行)。
A1 — 书中案例
归并排序:将数组从中间分成两半,递归排好左右子数组,再合并两个有序数组。
/// 归并排序 — 分治经典实现
void mergeSort(List<int> nums, int left, int right) {
if (left >= right) return; // 终止条件:子数组长度为 1
int mid = left + (right - left) ~/ 2; // 分:计算中点
mergeSort(nums, left, mid); // 治:递归排序左半
mergeSort(nums, mid + 1, right); // 治:递归排序右半
_merge(nums, left, mid, right); // 合:合并两个有序子数组
}
void _merge(List<int> nums, int left, int mid, int right) {
List<int> tmp = List.filled(right - left + 1, 0);
int i = left, j = mid + 1, k = 0;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) tmp[k++] = nums[i++];
else tmp[k++] = nums[j++];
}
while (i <= mid) tmp[k++] = nums[i++];
while (j <= right) tmp[k++] = nums[j++];
for (int p = 0; p < tmp.length; p++) nums[left + p] = tmp[p];
}
汉诺塔:n 个盘子从 A 移到 C,借助 B。分解为三步:n-1 个盘子 A→B、第 n 个盘子 A→C、n-1 个盘子 B→C。
/// 汉诺塔 — 分治递归
void hanoi(List<int> A, List<int> B, List<int> C) {
void _move(int n, List<int> src, List<int> buf, List<int> tar) {
if (n == 1) { tar.add(src.removeLast()); return; }
_move(n - 1, src, tar, buf); // 子问题1:n-1 移到缓冲柱
tar.add(src.removeLast()); // 最大盘直接移到目标
_move(n - 1, buf, src, tar); // 子问题2:n-1 从缓冲移到目标
}
_move(A.length, A, B, C);
}
A2 — 何时需要分治
- 问题能按相同逻辑分解为更小规模的子问题
- 子问题间无依赖、无重叠(否则应选 DP)
- 子问题的解可以合并还原原问题的解
- 典型信号:归并排序、快速排序、二分查找、构建二叉树、最近点对、大整数乘法
E — 可执行步骤 / Dart 实现模板
/// 分治通用模板
ReturnType divideAndConquer(Params params) {
// 1. 终止条件:问题规模已经足够小,直接求解
if (isBaseCase(params)) return solveBaseCase(params);
// 2. 分(Divide):将原问题拆分为 k 个子问题
var subProblems = divide(params, k);
// 3. 治(Conquer):递归解决每个子问题(可并行)
var subResults = subProblems.map((p) => divideAndConquer(p));
// 4. 合(Combine):合并子问题的解得到原问题的解
return merge(subResults);
}
B — 边界限制
- 三个条件缺一不可:可分解、子问题独立、解可合并(§12.1.1)
- 若子问题重叠(如 Fibonacci),分治会重复计算导致指数级开销——应改用 DP
- 递归深度可能导致栈溢出,部分场景需转为迭代(如归并排序的非递归实现)
- 等分 k 份时,当 n > 4 划分才有操作数量优势(§12.1.2)
二、回溯 Backtracking
R — 原文引用
回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。— §14.2
"剪枝"可避免遍历无意义的搜索空间,从而提升搜索效率。— §13.1
I — 用自己的话重写
回溯是一种试探性穷举搜索:每一步在可选集合中尝试一个选项,递归深入;若当前路径不可能产生解,则剪枝终止该分支;若所有选项穷尽或遇错,则回退到上一步,撤销选择,替换下一个候选。本质是 DFS 在解空间树上的搜素,辅以约束条件剪枝来压缩搜索空间。核心四要素:状态(state)、选择(choice)、剪枝条件(isValid)、回退(undo)。
A1 — 书中案例
全排列:给定不重复数字数组,返回所有排列。
/// 全排列 — 回溯模板
List<List<int>> permute(List<int> nums) {
List<List<int>> res = [];
List<int> state = [];
List<bool> selected = List.filled(nums.length, false);
void backtrack() {
if (state.length == nums.length) { res.add(List.from(state)); return; }
for (int i = 0; i < nums.length; i++) {
if (selected[i]) continue; // 剪枝:跳过已选元素
selected[i] = true;
state.add(nums[i]); // 尝试
backtrack(); // 递归
selected[i] = false;
state.removeLast(); // 回退
}
}
backtrack();
return res;
}
子集和 I:无重复元素数组,求所有和等于 target 的子集。
/// 子集和 — 带 start 剪枝
void backtrack(List<int> state, int target, int start, List<int> choices,
List<List<int>> res) {
if (target == 0) { res.add(List.from(state)); return; }
for (int i = start; i < choices.length; i++) {
if (target - choices[i] < 0) break; // 剪枝一:超出目标
state.add(choices[i]); // 尝试
backtrack(state, target - choices[i], i, choices, res);
state.removeLast(); // 回退
}
}
N 皇后:n×n 棋盘放 n 个皇后,彼此不攻击。
/// N 皇后 — 逐行放置 + 对角线剪枝
List<List<String>> solveNQueens(int n) {
List<List<String>> res = [];
List<int> cols = List.filled(n, 0); // cols[row] = col
List<bool> diag1 = List.filled(2 * n, false);
List<bool> diag2 = List.filled(2 * n, false);
void backtrack(int row) {
if (row == n) { res.add(_buildBoard(cols)); return; }
for (int col = 0; col < n; col++) {
int d1 = row - col + n, d2 = row + col;
if (cols.contains(col) || diag1[d1] || diag2[d2]) continue; // 剪枝
cols[row] = col; diag1[d1] = diag2[d2] = true; // 尝试
backtrack(row + 1);
diag1[d1] = diag2[d2] = false; // 回退
}
}
backtrack(0);
return res;
}
A2 — 何时需要回溯
- 需要穷举所有可能解(全排列、子集、组合)
- 问题可用决策树建模,每个节点代表一次选择
- 搜索空间可通过约束条件剪枝大幅压缩
- 典型信号:排列、组合、子集、棋盘问题、数独、图着色
E — 可执行步骤 / Dart 实现模板
/// 回溯通用模板
void backtrack(
State state, // 当前状态
List<Choice> choices, // 当前可选列表
List<State> res, // 结果集
) {
// 1. 检查是否为解 → 记录
if (isSolution(state)) { res.add(state.copy()); /* 可选 return */ }
// 2. 遍历所有选择
for (final choice in choices) {
// 3. 剪枝:跳过不合法选项
if (!isValid(state, choice)) continue;
// 4. 尝试:做出选择,更新状态
makeChoice(state, choice);
// 5. 递归深入
backtrack(state, nextChoices(choice), res);
// 6. 回退:撤销选择,恢复状态
undoChoice(state, choice);
}
}
B — 边界限制
- 时间复杂度可达 O(k^n) 或 O(n!),剪枝是核心竞争力——无剪枝的回溯在大规模问题中不可行
- 剪枝条件必须正确且尽可能紧:宽松则效率低,过紧则漏解
- 重复选择剪枝 vs 相等元素剪枝目标不同:前者防同一元素被多次选(用
selected数组),后者防等值元素产生重复排列/子集(用duplicated集合或start索引,§13.2-13.3) - 回溯不适合纯"求最优解"问题——那是 DP 的领地
三、动态规划 Dynamic Programming
R — 原文引用
动态规划将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。— §14.1
动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。— §14.2
I — 用自己的话重写
DP 的核心是空间换时间:将分解出的重叠子问题的解存入 dp 表,后续直接查表而不用重新计算,将指数级复杂度降为多项式级。DP 与分治的关键区别:分治的子问题独立(各自求解,无重复),DP 的子问题依赖重叠(同一子问题被反复用到)。三大前提条件:重叠子问题(驱动动力)、最优子结构(原问题最优解由子问题最优解构建)、无后效性(状态未来发展只取决于当前状态,与历史路径无关)。
A1 — 书中案例
爬楼梯:每步可上 1 或 2 阶,n 阶楼梯有几种爬法?dp[i] = dp[i-1] + dp[i-2]
/// 爬楼梯 — DP(自底向上 + 滚动变量优化)
int climbingStairsDP(int n) {
if (n == 1 || n == 2) return n;
int a = 1, b = 2; // a=dp[i-2], b=dp[i-1]
for (int i = 3; i <= n; i++) {
int c = a + b; // dp[i] = dp[i-1] + dp[i-2]
a = b;
b = c;
}
return b;
}
0-1 背包:n 个物品,重量 w[i]、价值 v[i],容量 cap,求最大价值。
/// 0-1 背包 — DP 空间优化版(一维倒序)
int knapsack01(List<int> w, List<int> v, int cap) {
List<int> dp = List.filled(cap + 1, 0);
for (int i = 0; i < w.length; i++) {
for (int c = cap; c >= w[i]; c--) { // 倒序遍历,防覆盖
dp[c] = max(dp[c], dp[c - w[i]] + v[i]);
}
}
return dp[cap];
}
编辑距离:将 s1 变成 s2 的最少操作数(增/删/改)。
/// 编辑距离 — DP
int editDistance(String s1, String s2) {
int m = s1.length, n = s2.length;
List<List<int>> dp = List.generate(m + 1, (_) => List.filled(n + 1, 0));
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s1[i - 1] == s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = min(min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1;
}
}
}
return dp[m][n];
}
A2 — 何时需要动态规划
- 问题可用回溯但出现大量重叠子问题(递归树中有大量相同子树)
- 题目含"最大/最小/最多/最少/最长/最短"等最优化描述(加分项)
- 状态能表示为列表/矩阵/树,且状态间存在递推关系
- 目标是求最优解而非列举所有方案(减分项:需要返回所有具体方案时 DP 不适用,§14.3.1)
E — 可执行步骤 / Dart 实现模板
/// DP 解题五步法(§14.3.2)
///
/// Step 1: 定义状态与 dp 表
/// 将决策变量抽象为状态维度,如 dp[i][j] 表示前 i 个物品容量 j 下的最大价值
///
/// Step 2: 找出最优子结构
/// 原问题最优解如何从子问题最优解构建,如 dp[i][c] = max(dp[i-1][c], dp[i-1][c-w]+v)
///
/// Step 3: 推导状态转移方程
/// 用公式表达递推关系
///
/// Step 4: 确定边界条件与遍历顺序
/// 初始化 dp[0][*] 和 dp[*][0],确保计算 dp[i] 时前序状态已就绪
///
/// Step 5: 可选空间优化
/// 若当前状态仅依赖有限个前序状态,用滚动变量/数组降维
///
/// 模板代码:
ReturnType dynamicProgramming(Params params) {
// 1. 定义 dp 表
var dp = List.generate(n + 1, (_) => List.filled(m + 1, 0));
// 2. 初始化边界条件
for (int i = 0; i <= n; i++) dp[i][0] = baseValue(i);
for (int j = 0; j <= m; j++) dp[0][j] = baseValue(j);
// 3. 按正确顺序递推(确保依赖的状态已计算)
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
// 4. 状态转移
dp[i][j] = transition(dp, i, j, params);
}
}
// 5. 返回目标状态
return dp[n][m];
}
B — 边界限制
- 三道门槛:重叠子问题 + 最优子结构 + 无后效性,缺一不可(§14.2)
- 无后效性被违反时:可通过增加状态维度(如 [i] 扩为 [i, j])来恢复,但代价是 dp 表维度膨胀(§14.2.2 带约束爬楼梯例)。严重有后效性问题(如每次决策改变全局约束)即使扩展状态也无法救回
- 空间优化有坑:0-1 背包一维化时必须倒序遍历 c(因为当前状态依赖上一行正上方和左上方,倒序防止尚未使用的旧值被覆盖);完全背包则正序(因为依赖同一行正上方和正左方,§14.7 小结)
- 目标不是"最优值"而是"所有方案"时:DP 不适用,退回回溯
四、贪心 Greedy
R — 原文引用
贪心算法不会考虑过去的决策,而是一路向前地进行贪心选择。— §15.1
动态规划会根据之前阶段的所有决策来考虑当前决策。— §15.1(对比 DP)
I — 用自己的话重写
贪心在每一步都做出当前看来最好的选择(局部最优),期望一路选下去就得到全局最优解。它"只看眼前"——不回头改之前的决定,也不规划未来。核心挑战在于贪心策略的正确性:并非所有问题都满足贪心选择性质(局部最优→全局最优)。当条件满足时,贪心往往比 DP 快一个数量级;当条件不满足时,贪心会给出次优解甚至错误解。
A1 — 书中案例
分数背包:物品可分割,每次选单位价值最高的物品装满背包。
/// 分数背包 — 贪心(按单位价值降序)
double fractionalKnapsack(List<int> w, List<int> v, int cap) {
List<_Item> items = [];
for (int i = 0; i < w.length; i++) items.add(_Item(w[i], v[i]));
items.sort((a, b) => (b.v / b.w).compareTo(a.v / a.w)); // 贪心策略:按单位价值排序
double res = 0;
for (final item in items) {
if (item.w <= cap) { res += item.v; cap -= item.w; }
else { res += (item.v / item.w) * cap; break; }
}
return res;
}
最大容量问题:给定 n 个隔板高度,选两个隔板使得构成的容器能装最多水。
/// 最大容量 — 贪心双指针
int maxCapacity(List<int> ht) {
int i = 0, j = ht.length - 1, res = 0;
while (i < j) {
int cap = min(ht[i], ht[j]) * (j - i);
res = max(res, cap);
if (ht[i] < ht[j]) i++; else j--; // 贪心策略:移动较短的板
}
return res;
}
零钱兑换(正例与反例):
/// 零钱兑换 — 贪心(仅当硬币面额满足特定条件时才正确)
int coinChangeGreedy(List<int> coins, int amt) {
coins.sort((a, b) => b.compareTo(a)); // 从大到小
int count = 0;
for (final coin in coins) {
count += amt ~/ coin;
amt %= coin;
}
return amt == 0 ? count : -1;
}
// ✅ coins=[1,5,10,20], amt=31 → 20+10+1 = 3 枚,正确
// ❌ coins=[1,5,11], amt=15 → 贪心选 11+1+1+1+1=5 枚,但最优是 5+5+5=3 枚
A2 — 何时需要贪心
- 问题满足贪心选择性质:每一步的局部最优决策不会阻止达到全局最优
- 同时满足最优子结构(与 DP 共享)
- 数据具有某种可排序/可比较的性质,能定义清晰的贪心策略
- 典型信号:分数背包、区间调度、霍夫曼编码、Dijkstra 最短路径、活动选择、最大容量
E — 可执行步骤 / Dart 实现模板
/// 贪心三步法(§15.1.3)
///
/// Step 1: 问题分析 — 梳理状态、优化目标、约束条件
/// Step 2: 确定贪心策略 — 每一步选择什么规则(如"选最大"、"选最轻"、"选最早结束")
/// Step 3: 正确性验证 — 用反证法或数学归纳法证明(实践中可用测试样例迭代验证)
///
/// 模板代码:
ResultType greedy(Input input) {
// 1. 预处理(排序、建堆等),为贪心策略做准备
input.sort((a, b) => greedyComparator(a, b));
ResultType result = initialValue;
// 2. 按贪心策略逐步决策
for (final item in input) {
if (canInclude(item, result)) {
result = applyGreedyChoice(result, item); // 局部最优选择
if (isComplete(result)) break; // 提前终止条件
}
}
return result;
}
B — 边界限制
- 贪心选择性质是硬条件:不满足时贪心结果不可靠(如零钱兑换 coins=[1,5,11] 时,§15.1)
- 正确性必须验证:书中建议用反证法或数学归纳法;不可想当然——很多看似可贪心的问题实则需要 DP(§15.1.2)
- 贪心≠简单:贪心策略设计千变万化,不同问题差异巨大,没有万用模板
- 贪心失败时用 DP:贪心是"健忘的"只向前看,DP 是"记忆的"回顾过去;两者共享最优子结构,前者更高效但条件苛刻
五、范式选择决策框架
┌─ 拿到一个新问题 ─┐
│ │
▼ ▼
能否拆成独立子问题? 需要穷举所有解?
├─ 是 → 分治 ├─ 是 → 回溯(+剪枝)
│ │
▼ ▼
子问题有重叠? 有大量重叠子问题且求最优?
├─ 否 → 分治 ✓ ├─ 是 → 动态规划
│ │
▼ ▼
是 → 动态规划 局部最优 = 全局最优?
├─ 是 → 贪心(更快)
└─ 否 → 动态规划
决策检查清单
| 检查项 | 是 → | 否 → 下一项 |
|---|---|---|
| 1. 子问题可分解且独立? | 分治 | 2 |
| 2. 需要穷举所有解(排列/组合/子集)? | 回溯 + 剪枝 | 3 |
| 3. 求最优解 + 重叠子问题 + 最优子结构? | 动态规划 | 4 |
| 4. 局部最优可保证全局最优? | 贪心 | 5 |
| 5. 回溯可解但有大量重叠子问题? | DP(渐进优化) | 回到回溯 |
动态规划加分/减分项(§14.3.1)
加分项(适合 DP):
- 题目含"最大/最小/最多/最少/最长/最短"
- 状态可用列表/矩阵/树表示且存在递推关系
- 时间复杂度可通过缓存从指数降为多项式
减分项(不适合 DP):
- 目标是找出所有方案而非最优解
- 有明显的排列组合特征需返回多个具体方案
- 严重有后效性,扩展状态维度也无法消除
六、回溯→记忆化搜索→动态规划渐进路径
面对 DP 问题的推荐开发顺序(§14.1, §14.3)
四阶段渐进优化
回溯(暴力 DFS) 复杂度 O(2^n) / O(n!)
│ 加入 mem 数组缓存已解子问题
▼
记忆化搜索(自顶向下 + 缓存) 复杂度 O(n) / O(nm)
│ 转为迭代,从最小值推到目标
▼
动态规划(自底向上迭代) 复杂度 O(n) / O(nm)
│ 观察当前状态仅依赖有限前序状态
▼
滚动变量优化(空间降维) 空间从 O(n) → O(1)
Dart 四阶段代码对比(以爬楼梯为例)
// ── 阶段一:暴力回溯 O(2^n) ──
int dfs(int i) {
if (i == 1 || i == 2) return i;
return dfs(i - 1) + dfs(i - 2); // 大量重复计算
}
// ── 阶段二:记忆化搜索 O(n) 时间, O(n) 空间 ──
int dfsMem(int i, List<int> mem) {
if (i == 1 || i == 2) return i;
if (mem[i] != 0) return mem[i]; // 缓存命中
mem[i] = dfsMem(i - 1, mem) + dfsMem(i - 2, mem);
return mem[i];
}
// ── 阶段三:动态规划(自底向上)O(n) 时间, O(n) 空间 ──
int climbingStairsDP(int n) {
if (n == 1 || n == 2) return n;
List<int> dp = List.filled(n + 1, 0);
dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
// ── 阶段四:滚动变量优化 O(n) 时间, O(1) 空间 ──
int climbingStairsOptimized(int n) {
if (n == 1 || n == 2) return n;
int a = 1, b = 2; // a=dp[i-2], b=dp[i-1]
for (int i = 3; i <= n; i++) {
int c = a + b; a = b; b = c;
}
return b;
}
渐进路径适用条件
| 阶段 | 何时适用 | 何时跳回 |
|---|---|---|
| 回溯 | 任何新 DP 问题的起点,理清决策树 | — |
| 记忆化搜索 | 回溯中发现大量重叠子树 | 递归深度过大导致栈溢出时转迭代 |
| 动态规划 | 所有 DP 问题,编码量比记忆化多但无递归开销 | — |
| 滚动变量 | 状态转移仅依赖 dp[i-1]、dp[i-2] 等紧邻状态 | 依赖跨度大或需保留完整 dp 表回溯路径时不可用 |
七、范式横向对比表
| 维度 | 分治 | 回溯 | 动态规划 | 贪心 |
|---|---|---|---|---|
| 子问题关系 | 独立 | 递进(共享部分前缀) | 重叠依赖 | 不分解 |
| 方向 | 自顶向下递归 | 自顶向下试探 | 自底向上递推 | 自左向右单次扫描 |
| 记忆化 | 不需要 | 可选(状态去重) | 核心必需 | 不需要 |
| 正确性保证 | 数学归纳 | 穷举保证 | 状态转移方程 | 需严格证明 |
| 典型复杂度 | O(n log n) | O(k^n) / O(n!) | O(n) ~ O(n²) | O(n) / O(n log n) |
| 是否求最优 | 不保证 | 不专攻 | 核心目标 | 条件苛刻 |
| Dart 常见模式 | 递归 + 合并 | 递归 + selected/used + undo | List.filled + 双层循环 | 排序 + 单次扫描 |
Workflow:拿到新问题如何选范式
Task Progress
- Step 1: 建模。 将问题抽象为状态 + 决策树,确定输入输出范围。
- Step 2: 穷举判断。 是否要穷举所有解(排列/组合)?→ 是 → 回溯 + 剪枝。
- Step 3: 独立性判断。 问题可分解为独立子问题且无重叠?→ 是 → 分治。
- Step 4: 重叠子问题判断。 递归树中是否有大量重复子树?→ 是 → DP(从记忆化搜索起步)。
- Step 5: 贪心适用判断。 每步局部最优能否保证全局最优?→ 是 → 贪心;否 → DP。
- Step 6: 兜底验证。 不确定时,先写回溯暴力解,观察递归树 → 有重叠→DP;有剪枝空间→回溯优化;独立子问题→分治。
条件逻辑
- 如果需要穷举所有排列/组合 → 回溯(加剪枝优化)
- 如果子问题相互独立、无共享状态 → 分治
- 如果子问题有大量重叠计算 → 动态规划(自底向上或记忆化搜索)
- 如果贪心选择性质可证 + 最优子结构成立 → 贪心
- 如果无法判断范式 → 先写回溯暴力解,分析递归树再决定优化方向
- 如果 DP 空间过大 → 尝试滚动变量优化(如爬楼梯 O(n)→O(1))
Examples
分治示例:归并排序
List<int> mergeSort(List<int> nums) {
if (nums.length <= 1) return nums;
final mid = nums.length ~/ 2;
final left = mergeSort(nums.sublist(0, mid));
final right = mergeSort(nums.sublist(mid));
return _merge(left, right);
}
List<int> _merge(List<int> a, List<int> b) {
final res = <int>[];
var i = 0, j = 0;
while (i < a.length && j < b.length) {
res.add(a[i] < b[j] ? a[i++] : b[j++]);
}
res.addAll(a.sublist(i));
res.addAll(b.sublist(j));
return res;
}
DP 示例:爬楼梯(空间优化)
int climbingStairs(int n) {
if (n == 1 || n == 2) return n;
var a = 1, b = 2;
for (var i = 3; i <= n; i++) {
final c = a + b;
a = b;
b = c;
}
return b; // O(n) 时间, O(1) 空间
}
贪心示例:分数背包
double fractionalKnapsack(List<int> w, List<int> v, int cap) {
final items = List.generate(w.length, (i) => [v[i] / w[i], w[i], v[i]]);
items.sort((a, b) => b[0].compareTo(a[0])); // 按单位价值降序
double res = 0;
for (final item in items) {
if (item[1] <= cap) { res += item[2]; cap -= item[1]; }
else { res += item[0] * cap; break; }
}
return res;
}
第六部分:常见陷阱与反模式
Dart 算法实现中的常见陷阱与反模式
基于《Hello 算法》Dart 版(靳宇栋,2026)。
Contents
一、递归陷阱
1.1 递归栈溢出
R: "递归调用深度过大时,每次递归调用都会在调用栈上分配新的栈帧。当递归深度超过系统栈容量时,会引发 Stack Overflow 错误。"(《Hello 算法》第 2 章)
I: 递归每深入一层就占用一块栈内存,深度过大时直接爆栈。Dart 栈空间有限(通常几千层),递归解决深层问题不可靠。
A1: 递归计算斐波那契第 100 项——即便用了记忆化,第一次线性展开的深度也达 100 层;若不加记忆化,指数爆炸先于栈溢出到来。标准做法是改用迭代。
A2: 当数据规模未知或输入规模 > 1000 时;树/图遍历中子树可能退化为链表时;需要稳定可预测的内存消耗时。
E:
// ❌ 危险:深度不可控
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
// ✅ 安全:显式栈迭代
int factorialSafe(int n) {
int result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
}
// ✅ 树遍历显式栈(避免递归打爆调用栈)
void dfsIterative(Node root) {
final stack = <Node>[root];
while (stack.isNotEmpty) {
final node = stack.removeLast();
_process(node);
if (node.right != null) stack.add(node.right!);
if (node.left != null) stack.add(node.left!);
}
}
B: 尾递归函数 Dart VM 不保证优化为循环;Iterable.generate 等惰性构造不替代栈安全。
1.2 尾递归不被 Dart 优化
R: "尾递归是指递归调用是函数最后一个操作……部分编译器会自动优化尾递归为迭代形式,但 Dart 目前不保证这一优化。"
I: Dart VM 不会将尾递归转成循环,该爆栈还是爆栈。别指望 tail call optimization(TCO)。
A1: 尾递归求和 sum(n, acc) ——在 Scala/Elixir 中安全,在 Dart 中 n=10000 直接 StackOverflow。
A2: 任何看起来是尾递归的函数,尤其处理列表/流时。
E:
// ❌ 看似安全,实则会爆栈
int sumTail(int n, int acc) {
if (n == 0) return acc;
return sumTail(n - 1, acc + n); // Dart 不会优化此尾调用
}
// ✅ 老老实实写循环
int sumLoop(int n) {
int acc = 0;
for (int i = 1; i <= n; i++) {
acc += i;
}
return acc;
}
B: 不依赖任何编译标志或 --optimization-level 来期望 TCO。
1.3 重叠子问题导致指数爆炸
R: "暴力递归通常包含大量重复计算。以斐波那契数列为例,时间复杂从 O(2^n) 降至 O(n) 的关键在于引入记忆化搜索(Memoization)。"
I: 递归树中同一子问题被反复求解,计算量指数增长。这是从不可行到可行的关键一步。
A1: fib(50) 暴力递归需要约 2×10^10 次调用,实际无法在有限时间完成;加 Map<int, int> 缓存后只需 ~99 次调用。
A2: 当递归函数参数空间有限而反复出现同一组参数时;问题具备最优子结构但尚未使用 DP 时;pub get 前不想引入额外依赖但又需要效率时。
E:
// ❌ 指数爆炸
int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// ✅ 记忆化递归
int fibMemo(int n, [Map<int, int>? memo]) {
memo ??= {};
if (memo.containsKey(n)) return memo[n]!;
if (n <= 1) return n;
return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}
// ✅ 自底向上 DP(空间 O(1))
int fibDP(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
final c = a + b;
a = b;
b = c;
}
return b;
}
B: 记忆化适合参数空间稀疏的情况;自底向上 DP 适合参数空间密集、需严格控制内存时。
1.4 BST 退化
R: "二叉搜索树的各项操作时间复杂度与树高成正比。若按有序序列顺序插入节点,BST 退化为链表,查找、插入、删除操作退化为 O(n)。"(第 7 章)
I: 有序插入 BST → 全挂在右子树 → 变成链表。平衡是 BST 高效的前提。
A1: 依次插入 [1,2,3,4,5,6] 构建 BST,搜索 6 需要遍历整棵"树";同一组数据用 AVL 树插入,树高保持 O(log n)。
A2: 数据源可能有序(从数据库 ORDER BY 拿到、从排序后的列表构建);需要构建 BST 做范围查询。
E:
// ❌ 朴素 BST 插入 → 有序数据退化为链表
TreeNode? insert(TreeNode? root, int val) {
if (root == null) return TreeNode(val);
if (val < root.val) {
root.left = insert(root.left, val);
} else {
root.right = insert(root.right, val);
}
return root;
}
// ✅ 使用 AVL 树——Dart 中可以用 SplayTreeSet 作为替代
import 'dart:collection';
final balancedSet = SplayTreeSet<int>();
for (final v in [1, 2, 3, 4, 5, 6]) {
balancedSet.add(v);
}
// SplayTreeSet 自平衡,查找 O(log n)
B: SplayTreeSet/SplayTreeMap 基于伸展树,摊还 O(log n),但不保证单次操作 O(log n);需要严格 O(log n) 可用 package:avl_tree。
二、二分查找陷阱
2.1 区间定义混淆
R: "二分查找的边界条件是最容易出错的环节。必须在实现前明确区间定义:双闭区间
[i, j]还是左闭右开[i, j),并保持循环条件与边界更新一致。"(第 10 章)
I: while (i <= j) 配 [i,j],while (i < j) 配 [i,j)。混用导致死循环或漏元素。
A1: 在 [0, n) 区间搜索,但用了 i <= j 条件 → 越界;在 [0, n-1] 区间却用 j = mid 更新 → 死循环。
A2: 每次写二分查找时;面试手写二分;代码 review 中看到二分查找。
E:
// 双闭区间 [i, j]
int binarySearchClosed(List<int> nums, int target) {
int i = 0, j = nums.length - 1;
while (i <= j) { // 区间为空时 i > j
final mid = i + (j - i) ~/ 2;
if (nums[mid] < target) {
i = mid + 1;
} else if (nums[mid] > target) {
j = mid - 1; // 排除 mid
} else {
return mid;
}
}
return -1;
}
// 左闭右开 [i, j)
int binarySearchHalfOpen(List<int> nums, int target) {
int i = 0, j = nums.length;
while (i < j) { // 区间为空时 i == j
final mid = i + (j - i) ~/ 2;
if (nums[mid] < target) {
i = mid + 1;
} else if (nums[mid] > target) {
j = mid; // mid 不在搜索范围内
} else {
return mid;
}
}
return -1;
}
B: 左闭右开更符合 Dart 的 List.sublist / for (var i = 0; i < list.length; i++) 惯例,推荐首选。
2.2 mid 计算溢出
R: "在 i 和 j 都很大时,
(i + j) / 2可能超出整数范围导致溢出。安全的写法是i + (j - i) / 2。"
I: (i + j) ~/ 2 在 i+j 超过 2^63-1 时溢出。Dart 整数是任意精度(big int),严格来说不会溢出——但 Dart2JS 编译目标下 int 回退到 JS Number(53 位),i+j 超过 2^53 会丢失精度。
A1: Dart2JS 中搜索 2^53 级别的大数组,(i+j)~/2 导致 mid 计算错误,返回值偏差。
A2: 目标平台可能是 Web(Dart2JS);处理超大索引数组;团队编码规范要求防御性写法。
E:
// ❌ Dart2JS 下可能精度丢失
final mid = (i + j) ~/ 2;
// ✅ 安全写法,JIT/AOT/JS 全平台正确
final mid = i + (j - i) ~/ 2;
B: Dart VM(JIT/AOT)int 无精度问题,但统一写成安全形式保证跨平台一致性。
2.3 忘记数据必须有序
R: "二分查找的前提是数据已按关键字有序排列。在无序数组上使用二分查找,结果无意义。"
I: 二分查找依赖有序性。无序数据先排序(O(n log n))再二分(O(log n))——如果只查一次,不如线性扫描 O(n)。
A1: 拿到一个未排序的 List 直接传进二分查找,得到随机结果。
A2: 不确定输入是否有序时;数据源是第三方 API 或用户输入。
E:
int safeBinarySearch(List<int> nums, int target) {
// 防御性检查:只在开发/测试阶段开启
assert(() {
for (int i = 1; i < nums.length; i++) {
if (nums[i - 1] > nums[i]) {
throw StateError('输入数组必须有序,索引 $i 处违反有序性');
}
}
return true;
}(), 'binary search requires sorted input');
// ... 二分查找逻辑
}
B: assert 只在 debug 模式生效,release 模式会被移除。生产环境如需校验,自行实现显式检查。
三、排序陷阱
3.1 快速排序基准选择不良
R: "快速排序的性能高度依赖于基准元素的选择。若每次选择最左或最右元素作为基准,在已有序或接近有序的数据上,快速排序退化为 O(n²)。"(第 11 章)
I: 选最左/最右元素当 pivot → 遇到有序数据直接 O(n²)。随机选或三数取中可避免。
A1: 对一个近乎有序的 10 万元素数组做快排(固定取最左元素为 pivot),递归深度 ~10^5,栈溢出或极慢;随机 pivot 使深度期望 O(log n)。
A2: 数据来源不可控(可能已局部有序);生产环境排序;快速排序是唯一可行的 in-place O(n log n) 方案时。
E:
import 'dart:math';
final _rand = Random();
// ✅ 随机基准
int _partitionRandom(List<int> nums, int left, int right) {
final pivotIdx = left + _rand.nextInt(right - left + 1);
_swap(nums, left, pivotIdx); // 把随机基准换到最左边
return _partition(nums, left, right);
}
// ✅ 三数取中
int _medianOfThree(List<int> nums, int left, int right) {
final mid = left + (right - left) ~/ 2;
// 取 left, mid, right 的中位数作为基准
if (nums[left] > nums[mid]) _swap(nums, left, mid);
if (nums[left] > nums[right]) _swap(nums, left, right);
if (nums[mid] > nums[right]) _swap(nums, mid, right);
_swap(nums, left, mid); // 将中位数换到最左
return _partition(nums, left, right);
}
void _swap(List<int> nums, int i, int j) {
final tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
// _partition 同标准 Lomuto 划分,此处省略
B: 随机 pivot 每次调用 Random.nextInt 有微小开销,三数取中在近乎有序数据上表现更好且无随机开销。工程实践选三数取中。
3.2 相等元素稳定性丢失
R: "排序算法的稳定性指相等元素在排序后是否保持原始相对顺序。快速排序、堆排序不稳定;归并排序、插入排序稳定。Dart 的
List.sort默认使用快速排序(不稳定)。"
I: Dart sort() 不保证稳定。需要稳定排序时,要么用稳定算法,要么把原始索引编码进比较器。
A1: 先按年龄排序再按姓名排序——若 sort 不稳定,第二次排序可能打乱第一次的年龄分组。
A2: 多级排序(先按 A 再按 B);排序对象有主键/次键;UI 列表需要保持用户感知顺序。
E:
// ❌ 不稳定排序可能破坏已有顺序
items.sort((a, b) => a.age.compareTo(b.age));
// ✅ 方案一:使用归并排序(稳定)
List<T> mergeSort<T>(List<T> list, int Function(T, T) compare) {
if (list.length <= 1) return list;
final mid = list.length ~/ 2;
final left = mergeSort(list.sublist(0, mid), compare);
final right = mergeSort(list.sublist(mid), compare);
return _merge(left, right, compare);
}
// ✅ 方案二:把原始索引编码进比较器(Schwartzian transform)
final sorted = items
.asMap()
.entries
.toList()
..sort((a, b) {
final cmp = a.value.age.compareTo(b.value.age);
if (cmp != 0) return cmp;
return a.key.compareTo(b.key); // 索引保序
});
final result = sorted.map((e) => e.value).toList();
B: 归并排序需要 O(n) 额外空间;索引编码法在原 List 所有元素相等时不影响结果正确性。
四、哈希陷阱
4.1 哈希冲突退化
R: "哈希冲突不可避免。当冲突严重时(如所有键映射到同一桶),哈希表操作退化为 O(n)。需要设计良好的哈希函数和冲突处理策略(链式地址、开放寻址)来保证均摊 O(1)。"(第 6 章)
I: 所有 key 哈希到同一个桶 → 退化为链表遍历。常见于恶意构造的输入(哈希碰撞攻击)或糟糕的 hashCode 实现。
A1: 自定义类覆盖了 hashCode 但始终返回 0,存入 10 万元素后 map[key] 退化为 O(n) 线性扫描。
A2: 自定义对象的 hashCode 实现;处理来自不可信源的 key;高频 HashMap 操作。
E:
// ❌ 糟糕的 hashCode:所有对象落入同一桶
class BadKey {
final String id;
const BadKey(this.id);
@override
int get hashCode => 0; // 全部冲突!
@override
bool operator ==(Object other) =>
other is BadKey && other.id == id;
}
// ✅ 使用 Object.hash() 自动生成良好分布的 hashCode
class GoodKey {
final String id;
final int version;
const GoodKey(this.id, this.version);
@override
int get hashCode => Object.hash(id, version);
@override
bool operator ==(Object other) =>
other is GoodKey && other.id == id && other.version == version;
}
B: Object.hash() 从 Dart 2.14 开始可用。确保 == 和 hashCode 一致:a == b → a.hashCode == b.hashCode。
4.2 TOMBSTONE 死标签累积
R: "开放寻址法的删除操作不能简单将桶置空,否则会切断探测链。通常使用 TOMBSTONE 标记已删除元素。但大量删除后,TOMBSTONE 占比过高会导致查找性能下降。"
I: 开放寻址哈希表删除时插"墓碑"占位,防止探测链断裂。墓碑太多 → 表里空位多但探测路径长 → 性能退化。
A1: 开放寻址哈希表经历大量增删交替操作,load factor 只有 0.3 但查找仍需探测 5-8 步。
A2: 自己实现哈希表;增删频繁的场景(如 LRU 淘汰后重建);理解 Dart HashMap 内部行为。
E:
// 开放寻址哈希表删除示意
class OpenAddressingHashMap<K, V> {
static final _TOMBSTONE = Object();
V? remove(K key) {
final idx = _findIndex(key);
if (idx == -1) return null;
final old = _values[idx] as V;
_keys[idx] = _TOMBSTONE; // 墓碑占位,不设 null
_values[idx] = null;
_size--;
// ⚠️ 墓碑累积:当墓碑比例 > 50% 时重新哈希
if (_tombstoneCount > _capacity ~/ 2) {
_rehash();
}
return old;
}
}
B: Dart 标准库 HashMap 使用链式地址法,无需关心墓碑问题;此陷阱仅针对自己实现的开放寻址表。
五、动态规划与贪心陷阱
5.1 贪心不一定全局最优
R: "贪心算法在每一步选择当前看起来最优的选项,但这种局部最优并不必然导向全局最优解。最典型的是零钱兑换问题:当硬币面额为
[1,3,4]、目标金额为 6 时,贪心策略选择 4+1+1 共 3 枚,而最优解是 3+3 共 2 枚。"(第 15 章)
I: 贪心是赌当前最优=全局最优。硬币面额不满足贪心选择性质时赌输。
A1: 面额 [1,3,4] 兑 6 元——贪心输出 3 枚,DP 输出 2 枚。
A2: 每次想用贪心时,先证明"贪心选择性质 + 最优子结构";否则用 DP。
E:
// ❌ 贪心策略:对 [1,3,4] 目标 6 给出错误结果 3
int coinChangeGreedy(List<int> coins, int amount) {
final sorted = coins..sort((a, b) => b.compareTo(a)); // 降序
int count = 0, remaining = amount;
for (final coin in sorted) {
count += remaining ~/ coin;
remaining %= coin;
}
return remaining == 0 ? count : -1;
}
// ✅ DP 保证全局最优
int coinChangeDP(List<int> coins, int amount) {
final dp = List.filled(amount + 1, amount + 1);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (final coin in coins) {
if (coin <= i) {
dp[i] = dp[i] < dp[i - coin] + 1 ? dp[i] : dp[i - coin] + 1;
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
B: 部分硬币系统(如人民币 [1,2,5,10])贪心确实最优,但这需要数学证明,不能假定。
5.2 背包遍历顺序错误
R: "0-1 背包问题中,若使用正序遍历容量会导致物品被重复选用(即退化为完全背包)。0-1 背包的容量维度必须倒序遍历,而完全背包必须正序遍历。"(第 14 章)
I: 0-1 背包正序遍历 → 同一件物品可能被放多次;完全背包倒序遍历 → 物品不能重复使用。遍历方向搞反,结果完全错误。
A1: 0-1 背包 dp[j] = max(dp[j], dp[j-w]+v) 正序遍历 j → 物品 i 被重复计入(因为 dp[j-w] 可能刚被物品 i 更新过)。
A2: 写任何背包变种(0-1、完全、多重);面试中手写 DP。
E:
// 0-1 背包:每个物品最多选一次
int knapsack01(List<int> weights, List<int> values, int capacity) {
final dp = List.filled(capacity + 1, 0);
for (int i = 0; i < weights.length; i++) {
// ✅ 倒序遍历:避免重复使用物品 i
for (int j = capacity; j >= weights[i]; j--) {
final take = dp[j - weights[i]] + values[i];
if (take > dp[j]) dp[j] = take;
}
}
return dp[capacity];
}
// 完全背包:每个物品可选无限次
int knapsackUnbounded(List<int> weights, List<int> values, int capacity) {
final dp = List.filled(capacity + 1, 0);
for (int i = 0; i < weights.length; i++) {
// ✅ 正序遍历:允许重复使用物品 i
for (int j = weights[i]; j <= capacity; j++) {
final take = dp[j - weights[i]] + values[i];
if (take > dp[j]) dp[j] = take;
}
}
return dp[capacity];
}
B: 二维 DP 数组版本不存在遍历方向陷阱(dp[i][j] 隔离了物品维度),但空间占用 O(n×cap) vs 一维 O(cap)。
六、数值陷阱
6.1 浮点精度问题
R: "浮点数遵循 IEEE 754 标准,无法精确表示所有实数。在算法中直接使用
==比较浮点数可能导致逻辑错误。应当使用差值比较法。"(第 3 章)
I: 0.1 + 0.2 == 0.3 为 false。浮点运算有舍入误差,算法中用 == 做浮点等值判断是 bug 来源。
A1: 用 == 比较两个"相等"的 double 结果,条件分支走到错误路径;二分查找中 double mid 比较导致无限循环。
A2: 任何涉及浮点比较的地方——几何计算、物理模拟、金融计算、ML 梯度检查。
E:
import 'dart:math';
// ❌ 直接等值比较——不可靠
bool isZero(double x) => x == 0.0;
// ✅ 差值比较
const eps = 1e-9;
bool isZeroSafe(double x) => x.abs() < eps;
bool approxEqual(double a, double b) => (a - b).abs() < eps;
// 二分查找中的浮点安全写法
int binarySearchFloats(List<double> nums, double target) {
int i = 0, j = nums.length - 1;
while (i <= j) {
final mid = i + (j - i) ~/ 2;
if (nums[mid] < target - eps) {
i = mid + 1;
} else if (nums[mid] > target + eps) {
j = mid - 1;
} else {
return mid; // 在 eps 范围内视为相等
}
}
return -1;
}
B: eps = 1e-9 适合大多数场景;金融计算请用 package:decimal 定点数而非浮点;科学计算中 eps 应根据数据量级动态计算。
七、速查表
| 陷阱 | 症状 | 快速修复 |
|---|---|---|
| 递归爆栈 | StackOverflow | 改显式栈迭代 |
| 尾递归 | 大 n 时爆栈 | 改循环 |
| 重叠子问题 | 运算时间指数增长 | 加 Map 缓存 / 改 DP |
| BST 退化 | 查找变慢 | 用 AVL / SplayTreeSet |
| 二分区间混乱 | 死循环/漏元素 | 统一 [i,j) + i<j |
| mid 溢出(JS) | 查找结果错误 | i + (j-i)~/2 |
| 无序二分 | 结果随机 | 先排序或线性扫描 |
| 快排选基准 | 有序数据 O(n²) | 三数取中 / 随机 pivot |
| 排序不稳定 | 相等元素乱序 | 归并排序 / 编码索引 |
| hashCode=0 | HashMap O(n) | Object.hash(...) |
| 墓碑累积 | 探测路径变长 | 定期 rehash |
| 贪心非最优 | 硬币兑换多给币 | 改用 DP |
| 背包遍历反 | 物品重复/缺失 | 0-1 倒序,完全正序 |
| 浮点 == | 条件分支错误 | abs(a-b) < eps |
Workflow: 算法实现上线前检查清单
Task Progress
- Step 1: 递归深度检查。 数据规模 > 1000?改迭代或显式栈。
- Step 2: 重复计算检查。 子问题是否反复出现?加记忆化或改 DP。
- Step 3: 数据结构假设验证。 BST 有序插入?用自平衡树。
- Step 4: 二分前提确认。 数据是否有序?不放心加 assert。
- Step 5: 区间语义。
[i,j)还是[i,j]?写注释,保持一致。 - Step 6: 基准选择。 快排 pivot 是否固定?随机或三数取中。
- Step 7: 排序稳定性。 相等元素顺序是否重要?选稳定排序。
- Step 8: hashCode 质量。 自定义类 hashCode 是否均匀分布?
- Step 9: 贪心证明。 是否已验证最优子结构?
- Step 10: DP 遍历方向。 0-1 倒序,完全正序。
- Step 11: 浮点比较。 是否用了
==?改为(a-b).abs() < eps。 - Step 12: Feedback Loop。 满足全部检查项,算法实现才具备上线质量。
条件逻辑
- 如果遇到栈溢出 → 改为迭代或显式栈实现
- 如果性能不达标 → 检查是否存在隐藏的 O(n²) 操作
- 如果结果不稳定 → 检查排序算法是否稳定,或编码时加入稳定化处理
在执行任何算法实现前,按此清单逐项检查:
- 递归深度 —— 数据规模 > 1000?改迭代或显式栈。
- 重复计算 —— 子问题是否反复出现?加记忆化或改 DP。
- 数据结构假设 —— BST 有序插入?用自平衡树。
- 二分前提 —— 数据是否有序?不放心加 assert。
- 区间语义 ——
[i,j)还是[i,j]?写注释,保持一致性。 - 基准选择 —— 快排 pivot 是否固定?随机或三数取中。
- 排序稳定性 —— 相等元素顺序是否重要?选稳定排序。
- hashCode 质量 —— 自定义类
hashCode是否声明为 const 且分布均匀? - 贪心证明 —— 是否已验证贪心选择性质和最优子结构?
- DP 遍历方向 —— 0-1 背包容量倒序,完全背包容量正序。
- 浮点比较 —— 是否用了
==?改为(a-b).abs() < eps。
满足全部检查项,算法实现才具备上线质量。
Examples
❌ 错误:未检查有序直接二分
final unsorted = [3, 1, 4, 1, 5];
final idx = binarySearch(unsorted, 3); // ❌ 结果不可预测
✅ 正确:二分前确保有序
final data = [3, 1, 4, 1, 5];
data.sort(); // ✓ 先排序
final idx = binarySearch(data, 3); // ✓ 结果可靠
❌ 错误:0-1 背包正序遍历导致物品复用
for (var i = 0; i < n; i++)
for (var c = 0; c <= cap; c++) // ❌ 正序:同一物品可多次计算
if (c >= w[i]) dp[c] = max(dp[c], dp[c - w[i]] + v[i]);
✅ 正确:0-1 背包倒序遍历
for (var i = 0; i < n; i++)
for (var c = cap; c >= w[i]; c--) // ✓ 倒序:每件物品只用一次
dp[c] = max(dp[c], dp[c - w[i]] + v[i]);
❌ 错误:浮点直接用 == 比较
if (a + b == 1.0) { /* ❌ 精度误差导致误判 */ }
✅ 正确:浮点安全比较
const eps = 1e-9;
if ((a + b - 1.0).abs() < eps) { /* ✓ 安全比较 */ }
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.