Open anjia opened 2 years ago
复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度。
asymptotic time complexity \~ 执行时间 \~ 更快
asymptotic space complexity \~ 存储空间 \~ 更省
大 O 复杂度表示方法只是表示一种变化趋势。
T(n) = O(f(n))
,n 表示数据规模的大小
T(n)
所有代码的执行时间f(n)
每行代码的执行次数之和T(n)
和 f(n)
成正比
假设每行代码的执行时间都一样,为 unit_time。eg.
T(n) = (2n+2) * unit_time = O(2n+2) = O(n)
T(n) = (2n2 + 2n + 3) * unit_time = O(2n2 + 2n + 3) = O(n2)
大 O 时间复杂度实际上并不表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势。通常我们会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了。
当 n 很大时,公式中的低阶/常量/系数并不左右增长趋势,故都可忽略。
几种常见的复杂度量级(从低阶到高阶)如下:
分类 | 复杂度量级 | 大 O 表示 |
---|---|---|
多项式量级 | 常量阶 | O(1) |
对数阶 | O(logn) |
|
线性阶 | O(n) |
|
线性对数阶 | O(nlogn) |
|
平方阶 立方阶 k 次方阶 |
O(n2) O(n3) O(nk) |
|
非多项式量级 | 指数阶 | O(2n) |
阶乘阶 | O(n!) |
说明:
O(1)
只是常量级时间复杂度的一种表示方法,并不是表示只执行了一行代码
Ο(1)
O(logn)
和线性对数阶 O(nlogn)
O(logn)
,因为换底公式可以提取任意常量O(nlogn)
即一段时间复杂度为 O(logn)
的代码循环了 n 遍常见的空间复杂度有 O(1)
, O(n)
, O(n2)
像
O(logn)
,O(nlogn)
这样的对数阶复杂度平时都用不到
对于时间复杂度:
有的代码的复杂度是由多个数据规模来决定的
eg. O(m+n), O(m*n)
function add(n) {
let sum = 0;
for (let i = 1; i <= n; i++) {
sum += i;
}
return sum;
}
// 时间复杂度 T(n) = (1+2n) * unit_time = O(1+2n) = O(n)
// 空间复杂度 O(1)
function add(n) {
let sum = 0;
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= n; j++)
sum += i * j;
}
return sum;
}
// 时间复杂度 T(n) = (1+n+2n^2) * unit_time = O(1+n+2n^2) = O(n^2)
// 空间复杂度 O(1)
let count = 0;
let i = 1;
while (i < n) {
count++;
i = i * 2;
}
// 时间复杂度 T(n) = O(log(n))
20, 21, 22, 23 ... 2k ... 2x = n
x = log2n
代码在不同情况下的不同时间复杂度:
1 和 2 都是极端情况下的代码复杂度,发生的概率其实并不大。3 能更好地表示平均情况下的复杂度。
大部分情况下,我们并不需要区分最好、最坏、平均三种复杂度。只有当同一块代码在不同的情况下,时间复杂度有量级的差距时,我们才会使用这三种复杂度表示法来区分。
best case time complexity worst case time complexity average case time complexity
平均复杂度只在某些特殊情况下才会用到,而均摊时间复杂度应用的场景比它更加特殊和有限。
amortized time complexity, 均摊时间复杂度
均摊时间复杂度就是一种特殊的平均时间复杂度,我们没必要花太多精力去区分它们。最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要。
数组是一种基本的线性表数据结构,它用一段连续的内存空间来存储一组具有相同类型的数据。
线性表, Linear List, 即数据排成一条线似的的结构,比如数组/链表/栈/队列。与之相反的是非线性表,比如二叉树/堆/图,其数据之间并不是简单的前后关系。
正是因为“内存连续+类型相同”的内存模型,数组的基本特点就是支持随机访问。它可以 O(1) 地随机访问一个 a[i]
,关键就在于索引和寻址,下标 i
确切地说是偏移(offset)。
a[i]_address = base_address + i * type_size
如果下标是从 1 开始的,即 a[i]_address = base_address + (i-1) * type_size
,那就意味着每次随机访问数组元素时,CPU 都要多执行一条减法指令。
大多数高级语言的数组下标都是从 0 开始的。当然也有例外,比如 Matlab 的数组下标是从 1 开始的、Python 的下标支持负数。
在 C 语言中,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的(即便不是数组的合法内存),程序就有可能不报任何错误。
在有些语言中,语言本身是会做越界检查的,比如 Java。
为了保持内存的连续性,数组的插入和删除因此而变得比较低效。
操作 | 时间复杂度 | 不同情况 |
---|---|---|
访问 | O(1) | |
插入 | O(n) | 最好 O(1) 最坏 O(n) 平均 O(n) |
删除 | O(n) | 同上 |
平均情况时间复杂度:(1+2+...n)/n = ((1+n)/2) * n / n = (1+n)/2 = O(n)
等差数列求和公式:Sn = (a1+an)/2 * n
在特定场景下,可以对插入和删除的时间复杂度进行优化。比如:
JVM 标记清除算法:会先标记出需要回收的对象,然后再统一回收标记对象。不足是效率不高,且会产生不连续的内存空间碎片。
所以,在学习数据结构和算法的时候,重点是理解其背后的思想和处理技巧,切忌照本宣科。
从严格意义上讲,数组本身在定义的时候是需要预先指定大小的,因为需要分配连续的内存空间。而变长数组在创建的时候,是可以不指定长度的,它支持动态扩容。
很多高级语言都提供了变长数组(如下),严格说它们不是一种数据结构,而更像是个容器或接口,它们封装了很多数组的操作细节(比如插入和删除元素/在头部插入元素)。
vector
ArrayList
list
在平时的业务开发中,我们可以直接使用编程语言提供的容器类,因为方便。但是,如果是特别底层的开发,直接使用数组可能会更合适,因为变长数组会涉及到动态扩容,而扩容操作又涉及到内存申请和数据搬移,比较耗时。
数组的适用场景,一是事先知道数据的大小、且对数据的操作比较简单,二是性能。
即便使用的是容器类,如果事先就能确定所需数据的存储大小,最好在创建的时候就能指定数据大小。
Resizable Array, 场景是编译器或库
变长数组是个系统设计问题,它涉及到基本的数据结构(数组)、接口和覆盖场景。
均摊 O(1)
在空数组中连续插入 n 个元素,总插入/拷贝的次数为 n+n/2+n/4+... < 2n
从一次扩容到下次释放,至少需要再删除 n - 2n*0.25 = 0.5n 次
数组是一种基本的线性表数据结构,它用一段连续的内存空间来存储一组具有相同类型的数据。数组的基本特点是支持随机访问,关键就在于索引和寻址。
a[i]_address = base_address + i * type_size
。a[i][j]_address = base_address + (i * n + j) * type_size
注意访问越界
为了保持内存的连续性,数组可以做到 O(1) 地随机访问一个数组元素,但也因此插入和删除操作比较低效。它们的时间复杂度分别是随机访问 O(1)、插入操作 O(n)、删除操作 O(n)。
插入和删除的特定场景,可以特殊处理。
变长数组不是严格意义上的数组,它更像是个容器或接口,我们需要理解动态扩容的原理和思路。结合具体的编程语言,感受下数组和变长数组的异同。
数据结构 - 数据类型 - 容器/接口
今天介绍第二个线性表数据结构:链表。
与数组的连续内存空间相比,链表中的每个元素是可以存储在内存中的任意位置的,它通过指针将一组零散的内存块串联起来使用。
Next
是指针或引用类型,它存储的是所指对象的内存地址。正因为要存储下一个结点的内存地址,所以链表所需的整体内存空间要比数组的大。
链表的这种灵活的内存存储结构,让它的插入和删除变得比较高效。
当要在特定位置插入新结点时,需要 2 次赋值操作,所以时间复杂度是 O(1)。
当要删除给定结点时,需要 1 次赋值操作,所以时间复杂度是 O(1)。
我们知道,当要插入和删除结点的时候,需要知道它的上一个结点,所以单链表在执行真正的插入和删除操作之前,得先花 O(n) 的时间复杂度来进行遍历查找。为了更快地得到上一个结点,我们可以把其内存地址也存起来,这就是双向链表了。
上一个结点 ~ 前驱结点
下一个结点 ~ 后继结点
双向链表可以 O(1) 地找到前驱结点,正是这样的特点使得双向链表在某些情况下的插入和删除操作都要比单链表高效,比如在指定结点前插入一个新结点或删除特定结点。所以双向链表的应用也挺广泛的,比如 Java 里的 LinkedHashMap 容器,就是用双向链表这种数据结构实现的。
不过,无论是单链表还是双向链表,当要随机访问第 i 个元素时,都没有数组那么高效,由于内存空间的不连续致使它没法靠索引来直接寻址,只能从头开始遍历,所以时间复杂度是 O(n)。
链表的操作 | 时间复杂度 |
---|---|
随机访问 | O(n) |
查询 | O(n) |
插入 | O(1) |
删除 | O(1) |
注意:这里是借时间复杂度来直观地理解特定数据结构的基本操作,重点是“特定数据结构”和“基本操作”。比如我们说链表可以 O(1) 地删除一个结点是指“删除”这一原子操作,倘若是删除单链表中的指定结点或是删除“值为X”的特定结点那链表还需要先 O(n) 地查询才能再 O(1) 地删除。
linked list
double linked list
除了上面介绍的单链表和双向链表之外,还有一种非常常见的链表结构,那就是循环链表。
循环链表的特点是从链尾又重新指回了链头,这非常适用于具有环型结构特点的数据,比如著名的约瑟夫问题。
链表的理论虽然不难,但要写好链表的代码也是不容易的,因为这里到处都是指针操作和边界处理,稍不留神就容易出 bug。
在实际开发中,需要根据具体情况来权衡,究竟是选数组还是链表。
本文重点介绍了链表的原理,包括其内存模型和基本操作的时间复杂度(随机访问/查询/插入/删除),当然这些都是基于特定的链表结构展开的(单链表/双向链表/循环链表)。最后简要说明了下写链表代码的注意事项,以及在实际开发中选择数组还是链表的几个参考点。
本文介绍两个非常基本的线性表数据结构:栈和队列。
数据结构里的“栈/堆”和内存里的“堆栈”是完全不一样的,数据结构里的一般是指栈和二叉堆。
栈的特点是后进先出,它只允许在栈顶插入一个元素、在栈顶删除一个元素。如下图所示:
First in - Last out, 先进后出
Last in - First out, 后进先出
队列的特点是先进先出,它只允许在队尾插入一个元素、在队头删除一个元素。如下图所示:
First in - First out, 先进先出
Last in - Last out, 后进后出
严格来说,栈和队列更像是个“容器”。它们都是线性存储的,且只在头和尾进行操作,所以底层实现要么是数组,要么是链表。
用数组实现的栈/队列, 叫顺序栈/队列
用链表实现的栈/队列, 叫链式栈/队列
虽然从定义上看,栈和队列都是一种操作受限的线性“容器”,但因为它们的实用性,大部分语言都提供了栈和队列的数据类型或者库,以便我们直接使用。所以,一般情况下我们都会说栈和队列是一种非常基础的“数据结构”。
普通队列是一端进另一端出,而双端队列是头和尾都是可进可出的。如下图所示:
双端队列也可以用数组或者链表来实现。如果是用数组实现的,通常会选择循环数组,因为这样可以更好地利用存储空间。
普通队列是以“时间”为顺序的(先进先出),而优先队列是按照元素的“优先级”取出的,比如它保证出队的元素总是优先级最高的。“优先级”可以是自己定义的一个元素属性,一般是个整数。
优先队列是个容器/接口/方法,它可以用很多种数据结构来实现,比如二叉堆/二叉平衡树(它们都能动态维护一个集合里的最大值/最小值)。
数据结构/容器 | 时间复杂度 | 说明 |
---|---|---|
栈, 队列 | Push O(1) Pop O(1) Access O(1) |
入栈/入队 出栈/出队 访问栈顶/队头 |
双端队列 | 插入 O(1) 删除 O(1) 访问 O(1) |
队头和队尾 |
优先队列 | 访问最值 O(1) | |
插入 O(logN) | 一些高级数据结构可以做到 O(1) 比如配对堆, 斐波那契堆 |
|
取最值 O(logN) | 取完了还需动态调整下剩余的 |
stack
queue, deque, priority queue
栈可以解决“最近相关性”的问题。最近相关性就蕴含着此序列只在一个口操作(后进先出),比如:
三种表达式的基本定义
- 前缀表达式(波兰式)
- 形如
op A B
, eg.* 3 + 1 2
- 类似写程序-调函数 ie.
op(A, B)
, eg.mult(3, plus(1,2))
- 后缀表达式(逆波兰式)
- 形如
A B op
, eg.3 1 2 + *
- 中缀表达式
- 形如
A op B
, eg.3 * (1 + 2)
“前/中/后”指的是操作符的位置。
前缀和后缀表达式写出来之后,操作顺序是固定的,所以不需要括号。
队列这种数据结构很适合用来存储排队请求,比如线程池/数据库连接池/请求池/分布式消息池。
阻塞队列是在队列的基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据的操作会被阻塞(因为此时没数据可取),直到队列中有了数据之后才能返回;在队列满了的时候,从队尾插入数据的操作会被阻塞,直到队列中有了空闲位置之后才能插入数据然后再返回。
当条件不满足时,非阻塞的处理方式是直接拒绝任务请求
而阻塞的处理方式是先将请求排队,然后等条件满足了再处理并返回
我们可以用阻塞队列轻松地实现“生产者-消费者模型”,进而有效地协调生产和消费的速度。
此外还有并发队列、循环队列,它们在很多偏底层的系统/框架/中间件中起着关键性的作用。比如高性能队列 Disruptor 和 Linux 环形缓存都用到了循环并发队列、Java concurrent 并发包利用了 ArrayBlockingQueue 来实现公平锁等。
线程安全的队列叫并发队列
队列大小的设置也很关键。队列太大容易导致等待的请求过多,队列太小容易导致没法充分利用系统的资源。
严格来说,单调栈和单调队列其实不是一种数据结构,而是一种对程序和算法的优化。即利用单调性排除一些不可能的冗余选项,只不过是借用了栈和队列这两个容器而已。
单调栈的思想是先考虑“前面不能影响到后面”的条件(是递增还是递减),然后就可以单独计算前面的了。思路就是一个一个遍历,然后把数据修剪成单调栈,符合单调性的直接进栈,不符合的就先修剪成符合的然后再进栈(此时会更新一次答案)。
- 柱状图中最大的矩形(H)
单调队列的思想是利用队列来维护一个候选集合。过程是利用单调性来尽快地排除掉在这个集合里永远不可能成为答案的一些选项,只保留那些在某些时刻可能成为答案的选项,进而简化候选集合。
- 滑动窗口最大值(H)
本文重点介绍了栈和队列的基本原理及其时间复杂度,同时介绍了栈和队列的基本应用。
栈和队列作为两个基本容器,在很多算法里都会用到 (eg. DFS, BFS),所以一定要把它们的原理掌握好。
此外,栈和队列的原理又相对比较简单,所以重点还是在于使用。首先熟悉下自己使用的编程语言里自带的与栈和队列相关的数据类型和接口,以便在实战中灵活运用。再就是多练习,感受下这两种数据结构的解题思路。
哈希表,又称散列表,它能通过关键码 key 来进行直接访问 hash_table[key]
。
Hash Table, 哈希表/散列表/Hash表
key, 关键码/关键字/key
哈希表由两部分构成:
Hash Function, 哈希函数/散列函数/Hash函数
hash(key)
计算出来的值,通常叫做哈希值/散列值/Hash值。
举个最简单的例子,关键码 key 是整数,哈希函数是 hash(key) = key
。此时,哈希表就退化成了一个数组,key 即数组下标。
其实,哈希表就是数组的一种扩展,它很好地利用了数组支持下标随机访问数据的特性。
一般情况下,关键码 key 是一个比较复杂的信息,比如字符串或很大的数。此时,就需要设计一个哈希函数 hash(key)
,将复杂信息 key 映射到一个较小的值域内,然后再以此作为索引去访问与之对应的数据结构。
哈希表对外表现为一个更强大的数组,其核心就在于哈希函数让“索引”变得更强大了。
hash_table[key] = value
,实际上数据是存储在“数据结构”的 hash(key)
位置上,即 data_structure[hash(key)] = value
。
哈希表 hash_table["lies"] = 233
,实际上包含了两个步骤:
hash("lies") = 9
array[9] = 233
Hash Table 访问一个值经过了两步
- 将 key 通过 Hash Function 映射到一个索引
- 再通过索引去访问真实的值
Hash Collisions, 哈希/散列-碰撞/冲突
哈希碰撞是指两个不同的 key 计算出了相同的 hash 结果。把复杂信息映射到小的值域,发生碰撞是不可避免的。
一个好的哈希函数,可以减少碰撞发生的几率,让数据尽可能地均衡分布。碰撞少了,均匀分布了,容器的效率也会越高。此外,哈希函数通常不会太复杂,因为复杂了会消耗较多的计算时间,进而影响哈希表的性能。
哈希函数多种多样
eg.直接寻址法/平方取中法/折叠法/随机数法等
eg.将每个字母的 ASCll 码值"进位"相加, 再取模
ie.(("n"-"a")*26^3 + ("i"-"a")*26^2 + ("c"-"a")*26 + ("e"-"a")) / 78978
但是,无论多好的哈希函数,发生碰撞都是不可避免的,所以,我们还是需要一种终极的解决方案。
常用的解决哈希碰撞的方法有两类:
开散列是最常见的哈希碰撞解决方案,其 hash 函数依然用于计算数组下标,然后数组的每个位置存储一个链表的表头指针,每个链表保存着具有同样 hash 值的数据(同时也会存 key)。它也称“挂链法”,即表头数组的每个位置“挂”着一个链表。如下:
开散列法将数组和链表相结合,可以解决哈希碰撞的问题,从而实现一个哈希表。在工程上也有广泛的应用,比如电话号码簙(姓名-电话)、用户信息表(用户名-信息)、缓存(LRU Cache)、键值对存储(Redis 数据库)、Java 的 LinkedHashMap 容器等。
我们知道,数组的优点是支持随机访问,缺点是需要连续内存。链表的优点是内存可以不连续,缺点是访问/查找是线性的。这里的哈希表将数组和链表混合使用,便同时利用了二者的优势,也规避了它们的不足。
开散列法(数组挂链法)的效率取决于 hash 函数写得怎么样。
时间复杂度 | 操作 | 说明 | |
---|---|---|---|
期望 | O(1) | 插入 查询 删除 |
数据分布比较均匀 |
最坏 | O(n) | 插入 查询 删除 |
数据全被映射到了 相同的 hash 值 |
当然,我们一般不会针对字符串写一个 hash 函数,因为编程语言已经帮我们写好了一些非常好的字符串的哈希函数,我们可以直接使用,比如集合和映射这样的库/数据类型。
它们都分别分为两类:
对于语言内置的类型(int, string)都已经有默认的非常好的 hash 函数,不需要我们自己写,我们可以直接在集合和映射里使用。
eg1. C++
eg2. Java 里的 Set, Map 不重复
eg3. Python
{'aa', 'bb', 'cc'}
, set(['aa', 'bb', 'cc'])
本文重点介绍了哈希表,它的两个核心就是哈希函数和解决哈希碰撞。哈希碰撞有两种常用的解决方法,即开放寻址法和链表法。哈希函数的好坏决定了哈希冲突的概率大小,也直接决定了哈希表的性能。
哈希表来源于数组,并借助哈希函数对下标索引进行了扩展,以很好地利用了数组支持按照下标随机访问元素的特性。无序集合和无序映射一般都是用哈希表来实现的,大部分编程语言都提供了这两种数据类型。
严格来说,无序集合和无序映射是一个容器/接口,它们可以用哈希表来实现,而哈希表是一个实现方法/数据结构。然后,哈希表的底层实现又是数组或者链表。
video. p0, hw, daily audio.
题目 | 思路 |
---|---|
26. 删除有序数组中的重复项(E) 283. 移动零(E) 88. 合并两个有序数组(E) 15. 三数之和(M) 11. 盛最多水的容器(M) |
双指针 双指针 双指针 排序+双指针 双指针 |
141. 环形链表-是否有环(E) 142. 环形链表 II-入环节点(M) 19. 删除链表倒数第 n 个结点(M) 876. 求链表的中间结点(E) |
Hash, 快慢指针 Hash, 快慢指针 迭代长度, 栈, 快慢指针 迭代, 迭代+数组, 快慢指针 |
206. 反转链表(E) 21. 合并两个有序链表(E) 25. K个一组反转链表(H) 66. 加一(E) |
迭代, 递归 迭代, 递归 迭代 迭代+数学 |
20. 有效的括号(E) 155. 最小栈-前缀最小值(E) |
迭代+栈 迭代+辅助栈 |
641. 设计循环双端队列(M) | 循环数组, 双向循环链表 |
1. 两数之和(E) 811. 子域名访问计数(M) 697. 数组的度(E) 49. 字母异位词分组(M) 146. LRU 缓存(M) |
Hash, 排序+双指针 Hash Hash hash(key)=计数/排序/质数乘积 Hash+双向循环链表 |
304. 二维区域和检索 - 矩阵不可变(M) 560. 和为 K 的子数组(M) |
前缀和 前缀和+Hash |
53. 最大子数组和(E) | 贪心, 动态规划, 分治 |
169. 多数元素(E) | Hash, 排序, 投票, 分治 |
1523. 在区间范围内统计奇数数目(E) | 数学 |
recursion, 递归
递归和递推都是程序的一种基本实现形式,它们可以用来实现一系列算法,比如搜索/分治/树的遍历/深搜等。
我们知道,递推可以通过 for 循环来重复一个从小到大的过程,比如前缀和的递推公式 S[i] = S[i-1] + A[i]
、阶乘的递推公式 f(n) = n * f(n-1)
。
eg1. 用 for 循环实现阶乘
// 用 for 循环实现阶乘
function factorial(n) {
let ans = 1;
for (let i = 1; i <= n; i++) ans *= i; // 递推公式 f(n) = f(n-1) * n
return ans;
}
console.log(factorial(6)); // 720
递归,本质上也是重复,只是它不是基于循环语句,而是基于函数。我们通过用函数自己调自己的方式,来实现一个以函数体进行循环的运算,函数体的每层是自相似的(参数不同)。递归的本质就是把函数体作为一个重复(循环)的过程。
eg2. 用递归实现阶乘
// 函数自己调自己,以实现循环
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1); // 递推公式 f(n) = n * f(n-1)
}
console.log(factorial(6)); // 720
为了更直观地了解 for 循环和递归循环这两种执行过程之间的异同,我们对上面的两个代码加上 log,输出见代码底部的注释。
eg1. 用 for 循环实现阶乘
// 用 for 循环来实现阶乘 n!
function factorial(n) {
let ans = 1;
for (let i = 1; i <= n; i++) {
ans *= i;
console.log(`for *${i} = ${ans}`); // 打 log 方便查看“循环过程”
}
return ans;
}
console.log(factorial(6)); // 720 = 6*5*4*3*2*1
// for *1 = 1
// for *2 = 2
// for *3 = 6
// for *4 = 24
// for *5 = 120
// for *6 = 720
eg2. 用递归实现阶乘
function factorial(n) {
console.log(`factorial(${n})`);
if (n === 1) {
console.log('\t返回计算结果:1');
return 1;
}
let recurAns = factorial(n - 1);
let ans = n * recurAns;
console.log(`\t返回计算结果:${ans} = ${n} * ${recurAns}(~${n - 1}...1)`);
return ans;
}
factorial(6);
// factorial(6)
// factorial(5)
// factorial(4)
// factorial(3)
// factorial(2)
// factorial(1)
// 返回计算结果:1
// 返回计算结果:2 = 2 * 1(~1..1)
// 返回计算结果:6 = 3 * 2(~2..1)
// 返回计算结果:24 = 4 * 6(~3..1)
// 返回计算结果:120 = 5 * 24(~4..1)
// 返回计算结果:720 = 6 * 120(~5..1)
通过对比,我们可以看出:for 循环是直接从小到大进行递推的,而递归是先往下层层探下去,再向上层层推上来(递推)。递归多了一个“往下探”的过程,如下图所示:
虽然乍看觉得递归反而更繁琐了,但是对于某些推导路径不那么直观的问题来说,递归的这种实现方式还是更直观些的,比如用分治的思想合并 k 个有序链表、计算 x 的 n 次方,比如和树/图相关的问题。
此时,如果要将递归展开成 for 循环,可重点关注递归下探到底之后,再“层层向上递推”的那部分逻辑。
这里我们就重点理解,递归是如何利用函数本身来实现循环的。
结合递归的本质,我们再来看看它的实现形式。
首先,需要明确要递归的问题,即定义重叠/相似的子问题(数学归纳法的思维)。
其次,为了保证程序可以正常停止,需要确定递归边界。有时是需要在叶子结点处显式地写 return
语句,有时是不需要的(因为此时递归树本身的生长是有边界的)。
第三点就是保护与还原现场。对于需要维护全局变量(状态空间)的情况,每次一层一层向上推的时候(回溯)都要还原现场,以防不同分支间相互影响。对于有些情况,是不需要考虑这点的(因为没有全局变量)。
- 函数体 + 传参调自己:循环的主体,即相似子问题
- 递归边界:防止死循环
- 还原现场:如果之前改了一些值的话
递归的三种基本模板,分别是子集、组合和排列,对它们进行各种变形和组合就能扩展出各种各样的递归问题。
题目:给定一个整数数组 nums(元素各不相同),返回该数组所有可能的子集(幂集)。
思路:依次判定每个元素选还是不选。比如 [1,2,3]
,递归树如下:
var subsets = function(nums) {
const n = nums.length;
let ans = [];
// 递归
let sub = []; // 子集列表(共享变量)
const recur = function(i){
if(i===n) {
ans.push([...sub]); // 浅拷贝
return;
}
// 不选择当前元素
recur(i+1);
// 选择当前元素
sub.push(nums[i]);
recur(i+1);
sub.pop(); // 还原现场
};
// 从第0个元素开始
recur(0);
return ans;
};
打上日志,以 [1,2,3]
为例,其执行过程如下图所示,和上面画的递归树是一一对应的。
打日志的代码就不单独放了,有些繁琐。原计划把 log 的代码也放在上面的示例上,但是太丑了,可读性不好,还影响对主逻辑的理解。感兴趣的朋友可以自己边 run 边 log。
题目:给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
思路:和子集的一样,只是多了一个长度限制。有一个小技巧就是剪枝,它可以更早地发现不合法的情况,以便尽早退出,以此来提高搜索效率。
var combine = function (n, k) {
const ans = [];
const sub = []; // 子集(共享变量-状态空间)
const recur = function (i) {
// 剪枝1:长度>k了
// 剪枝2:即便后面的都选上,也不可能成为答案(提前下探)
if (sub.length > k || sub.length + (n - i + 1) < k) return;
// 此时到达叶子结点的,就是答案了,长度=k
if (i > n) {
ans.push([...sub]);
return;
}
// 每层的相似逻辑:i不选或者选
recur(i + 1);
sub.push(i);
recur(i + 1);
sub.pop(); // 恢复现场
};
recur(1);
return ans;
};
题目:给定一个不含重复数字的数组 nums,返回其所有可能的全排列。
思路:一共有 n 个位置,考虑每个位置放哪个数字,从还没有选过的数里选一个。
var permute = function (nums) {
const n = nums.length;
const ans = [];
// 共享变量(状态空间)
let used = []; // 已经被选过了的元素下标
let permutation = []; // 排列
// 递归:在第i个位置放一个数
const recur = function (i) {
if (i === n) {
ans.push([...permutation]);
return;
}
// N叉树,确保[0,n-1]上的元素都有可能出现在位置i上
for (let j = 0; j < n; j++) {
if (!used[j]) {
permutation.push(nums[j]);
used[j] = true;
recur(i + 1);
used[j] = false; // 恢复现场
permutation.pop();
}
}
}
recur(0);
return ans;
};
以上三个问题都是用递归实现的暴力搜索(或者枚举回溯),它们都是去尝试试探每一个分支,然后推导出所有路径。
子集的思想是对于每个数决定选还是不选(子问题)。然后再一个一个来,直到把数组全部扫完(重复),它是个指数型的问题。
组合是从元素里选定 k 个,它依然是选数,只是限制了个数。在具体实现上,可以通过剪枝来提高搜索效率。
全排列的思想是考虑每个位置放哪个数(子问题)。第一个位置有 n 种选法,第二位置有 n-1 种选法,以此类推(重复),它是个 n 阶乘的问题。全排列的方案数是 n!。
当然,递归并不局限于这三种形式,但它们是最基础最经典的,与之对应的还有很多类似的系列问题。子集对应一系列时间复杂度是指数的问题,比如大体积背包。有些问题可以抽象成全排列的问题,比如 N 皇后;找顺序的都是全排列的问题,比如旅行商。组合型问题就是在一个集合里选一部分。
递归形式 | 时间复杂度规模 | 问题举例 |
---|---|---|
指数型 | kn | 子集、大体积背包 |
排列型 | n! | 全排列、旅行商、N 皇后 |
组合型 | n! / m!(n-m)! | 组合选数 |
递归的本质是重复,只是它不是基于循环语句,而是基于函数。与熟悉的 for 循环相比,递归多了一个先往下层层下探的过程,即先向下探寻、再向上递推。
递归的三个关键是明确递归问题、确定递归边界、保护与还原现场。虽然并不是所有的递归问题都同时具备这三个要素,但还是非常鼓励大家在每次写完一个递归代码的时候,都有意识地确认下这三个关键点,看看它们是如何在递归代码中扮演自己的重要角色的,以此积累解决问题的通识。
子集、组合和排列是三种最经典的递归实现形式,从它们可以扩展和组合出一系列的类似问题,所以最好能熟练掌握。
贪心理论和常见的证明方法
Greedy Algorithm
贪心算法在每一步的选择中都会采取在当前状态下的最优决策,并希望由此导致的最终结果也是全局最优。
决策:局部最优(以希望能全局最优)
与一般的搜索(也称暴力搜索/蛮力搜索/枚举回溯)和动态规划相比,贪心算法的不同之处在于:它不对整个状态空间进行遍历或计算,而是始终按照局部最优的选择执行下去,不再回头。
搜索和动态规划会遍历整个状态空间。搜索有回溯,是蛮力遍历;动态规划是把状态分阶段+按顺序,然后选取代表+提炼最优子结构,从而更高效地处理所有状态。它两都是基于全局考量的,所以求解一定是对的。
正因为这个特性(不遍历整个状态空间),贪心算法不一定能得到正确的结果,除非可以证明。即证明:按照特定方法做出的局部最优选择,依然可以得到全局最优结果。
贪心,一定要证明。
举个例子,零钱兑换。
题目:给定一个整数数组 coins(表示不同面额的硬币)和一个整数 amount(表示总金额),计算并返回可以凑成总金额的最少硬币个数。如果没有任何一种硬币组合能组成总金额,就返回 -1。假设每种硬币的数量是无限的。
我们平时兑换零钱,是会尽量选择面额大的(因为这样可以更快地兑完)直到它不能兑换了为止,然后剩余的再用小面额的凑齐(策略一致)。
“每次都选尽量大的面值”就是一个贪心思想。
那我们思考,它对吗?不一定对。
比如用 [10,9,1]
兑换 18(状态图如下),如果用贪心策略兑换,就会走图中的红色路径,即需要 9 个硬币(1 个 10 和 8 个 1),而最优解是绿色路径,即需要 2 个硬币(2 个 9)。
如果是暴力搜索,它会遍历所有状态
[剩余金额, 已用硬币数]
,然后取最小的硬币数。虽然搜索也会考虑贪心选的那条路径,但它会遍历所有边所有点。
贪心算法不一定能得到正确的解,在大部分题目上不能随便使用。如果要使用贪心算法,就必须先证明它的正确性。
除了数学归纳法和反证法之外,还有三种常用的证明方法。
决策包容性是从“状态空间”这一本质出发的一个证明方法。包容,即包含,指子集包含。子集,是可达边界构成的子集。
贪心算法实际上是在状态空间中按照局部最优策略找了一条路径。如果我们能证明:从一个点出发,它还能到达最优解,即并不会丢失最优解的可达性。那么,这样的贪心就是对的。
比如用 [5,10,20]
来兑换零钱,贪心算法是成立的,因为它们的面值互成倍数,此时能用 1 张 10 块兑换的必然也能用 2 张 5 块的来兑换。在这种情况下,“每次都选尽量大的面值”的局部最优策略是有包容性的。
再来看两个例子。
题目1:柠檬水找零。每杯柠檬水售价为 5,给定一个整数数组 bills,bills[i] 里存着第 i 位顾客付的账,值只会是 5、10、20,判断能否给每位顾客正确找零。注意:一开始手头并没有任何零钱。
代码如下:
var lemonadeChange = function(bills) {
const moneys = {
5: 0,
10: 0,
20: 0
};
// 找零:用贪心策略 “在可选的面额里取最大的”
const exchange = function(amount) {
for (let x of [20, 10, 5]) {
// 用循环减法替代除法和%
while (amount && x <= amount && moneys[x] > 0) {
amount -= x;
moneys[x]--;
}
}
return amount === 0;
};
// 遍历账单
for (let x of bills) {
if (!exchange(x - 5)) return false;
moneys[x]++;
}
return true;
};
题目2:分发饼干。给定一个孩子的胃口数组 g 和一个饼干的尺寸数组 s,求能满足最多孩子的个数。孩子 i 的胃口值 g[i],饼干 j 的尺寸值 s[j],如果 s[j] >= g[i],那就可以将这个饼干 j 分配给孩子 i ,此时孩子 i 会得到满足。
代码如下:
var findContentChildren = function(g, s) {
// 原地排序:小-大
g.sort((a, b) => a - b);
s.sort((a, b) => a - b);
let ans = 0;
let j = 0, sn = s.length;
// 贪心策略:对于孩子 child,选择一个能满足其胃口的“最小”饼干
for (let child of g) {
while (j < sn && child > s[j]) j++;
if (j >= sn) break;
ans++;
j++;
}
return ans;
};
以上两个例子,能都用决策包容性来证明贪心理论的正确性,所以可以用贪心算法求解。
决策包容性,只看一条边。
在思考贪心算法时,有时候不容易直接证明局部最优决策的正确性,此时可以往后多扩展一步,进而对当前的决策进行验证。扩展决策范围,即往后多看一步。
决策扩展范围,是看两条边。
来看两个例子。
题目1:跳跃游戏。给定一个非负整数数组 nums,其中 nums[i] 表示在该位置可以跳跃的最大长度。起初是位于数组的第一个位置,求跳到数组最后一个位置的最少跳跃次数。
代码如下:
var jump = function(nums) {
// 目的地:最后一个位置的下标
let target = nums.length - 1;
let ans = 0;
let cur = 0;
while (cur < target) {
let jumping = cur + nums[cur];
// 如果本位置跳一下就能到达目的地,则直接返回+1
if (jumping >= target) return ans+1;
// 否则,判断下一步跳哪里
// 贪心策略:在当前位置的可跳范围内,选一个能跳“最远”的那个位置
let farest = -1;
let next = -1; // 下个位置
for (let j = cur + 1; j <= jumping; j++) {
if (j + nums[j] > farest) {
farest = j + nums[j];
next = j;
}
}
// 卡这里了,走不动了,也到不了目的地了
if (next === -1) return -1;
// 否则,跳至下个位置,步数+1
cur = next;
ans++;
}
return ans;
};
题目2:买卖股票的最佳时机。给定一个数组 prices,其中 prices[i] 表示股票第 i 天的价格,求能获得的最大利润。说明:任何时候最多只能持有一股股票,在每一天中可以购买或出售,不限交易次数。
代码如下:
var maxProfit = function(prices) {
let ans = 0;
for (let i = 0; i < prices.length - 1; i++){
// 贪心策略:如果明天涨,那就买入(以此获得所有上涨区间的全部收益)呃~预言家式炒股
ans += Math.max(prices[i + 1] - prices[i], 0);
}
return ans;
};
以上两个例子,都能用“扩展决策范围+决策包容性”来证明贪心理论的正确性,所以可以用贪心算法求解。
多用于求顺序的题目,即按照某个顺序来完成一个任务。贪心理论认为按照某个顺序排列是比较优的,此时要证明它,就可以用邻项交换法。
证明:无论什么情况下,只要沿着逆序方向走,就会让答案变差。
这样就能证明按有序的方向走,必然能得到最优解。贪心算法的实现就是碰到逆序的就给它局部邻项交换下,此时得到的序列就是最优序列。
至于“逆序/有序”的定义,就得看具体场景了。举个例子。
题目1:完成所有任务的最少初始能量。给定一个任务数组 tasks,tasks[i] = [actuali, minimumi],其中,第一个元素表示完成第 i 个任务需要耗费的实际能量,第二个元素表示开始第 i 个任务前需要达到的最低能量。求完成所有任务的最少初始能量。
代码如下:
var minimumEffort = function(tasks) {
// 贪心策略:按元素的 actual - minimum 升序排列,以此顺序完成任务
tasks.sort((a, b) => a[0] - a[1] - (b[0] - b[1]));
// 倒着计算初始能量
let ans = 0;
for (let i = tasks.length - 1; i >= 0; i--) {
ans = Math.max(ans + tasks[i][0], tasks[i][1]);
}
return ans;
};
贪心算法在每一步的决策中都采取局部最优策略,并希望由此导致的最终结果也是全局最优的。它不会遍历整个状态空间,因此贪心算法不一定能得到正确的结果,除非可以证明。
如果要使用贪心算法,就必须先证明它的正确性。除去熟知的数学归纳法和反证法之外,还有三种重要的证明方法,分别是决策包容性、扩展决策范围和邻项交换法。它们也可以结合使用,以证明做什么决策是最好的。
搜索、动态规划和贪心是一脉相承的,都基于状态。不同之处在于搜索和动态规划会遍历整个状态空间,而贪心不会。正因为如此,贪心算法得到的结果不一定正确(除非可以证明),而基于全局状态的算法求出的最优解一定是正确的。
用贪心能做的题目,一般也都可以用搜索或者动态规划来求解,只是贪心一般是最高效的。所以,遇到问题,先想想搜索和动态规划,先对整体的状态空间有个了解。如果时间复杂度太高或是运行太慢,再考虑贪心(此时,也可能会更容易一些)。搜索→动态规划→贪心,是一个比较好的思路。
还是要格外强调:任何时候做题不要先想贪心。因为在不考虑时间复杂度的情况下,用搜索和动态规划总是可以求解的。同时,贪心一定要证明。此外,贪心也是最容易在面试里想很久,但没有一个结果和思路的,然后也不会证,就很容易浪费时间(而没有成果)。所以还是从分析状态空间出发,先理解题目、提炼状态。
题目:爬楼梯。每次可以爬 1 或 2 个台阶,现在有 n 个台阶,请问有多少种不同的走法?
两个思路:
思路一是依次确定每一步该怎么走:
直到走的台阶数达到 n 个。以 n=7 为例,方案如下:
其中,蓝框里是具体的走法,红色数字表示当前走过的台阶总数,绿框是达到 n=7 这一目标了,是叶子结点。
递归树都画出来了,代码实现就很简单了。如下:
var climbStairs = function(n) {
let ans = 0;
// 递归:第i步怎么走
let total = 0; // 当前走过的台阶总数(状态空间)
const recur = function() {
// 递归边界
if (total > n) return; // 剪枝(跳过n的情况)
if (total === n) { // 若==n,则答案+1
ans++;
return;
}
// 本层逻辑
// N叉树:可以走1,也可以走2
for (let i = 1; i <= 2; i++) {
total = total + i; // 走
recur();
total = total - i; // 回溯
}
};
// 开始递归
recur();
return ans;
};
运行结果:超出时间限制
最后执行的输入:44
很不幸,超时了。说明代码运行太慢了,来看看怎么优化。
这个思路是依次确定第 i 步怎么走,类似于“排列”。虽然是排列的思路,但代码里没记录具体的走法,只记录了当前走过的台阶总数,所以真实的状态图如下,其中蓝框和绿框里的数字就是“当前走过的台阶总数”:
我们可以很直观地看到有很多重复的状态。将相同的状态合并了之后,状态图就从一棵树“变”成了一个有向无环图,如下:
说“变”其实是不合适的,因为树本来就是(特殊的)图。
现在,我们试着遍历下这个有向无环图,思路依然是依次确定第 i 步怎么走。
广度优先遍历,代码如下:
var climbStairs = function(n) {
// i个台阶,共有 steps[i] 种走法
let steps = new Array(n + 1).fill(0);
// 广度优先遍历:队列+入口
const queue = [];
queue.push(0);
// 开始遍历,直到队列为空
while (queue.length) {
const front = queue.shift(); // 取队头
steps[front]++; // 相应的走法+1
// 如果台阶数==n,则找到一种
if (front === n) continue;
// 如果台阶数<n,则继续走
else if (front < n) {
queue.push(front + 1); // 再走1步(一条边让新结点入队)
queue.push(front + 2); // 再走2步(一条边让新结点入队)
}
}
return steps[n];
};
执行结果:超出时间限制
最后执行的输入:35
呃...依然超时,而且效果似乎更差了。为什么?
虽然相同的状态是被合并了,但是代码还是遍历了每条边每个点,因为该思路的本质是“排列”,排列的每一步都是独立且彼此依赖的,其整体才是最终答案。这就意味着即便是同一个结点,但因为达到它或是从它出发的边不同,致使它们依然是彼此不重叠的。
也就是说,这段广度优先遍历图的代码,和上面的深度优先遍历树的代码是一样的。代码之所以要遍历从 0-n 的每条路径,而没有使用布尔数组 visited[]
来避免相同结点的重复访问,就是因为它是通过统计具体的走法来计算总数的。
提问:题目里只要求计算有多少种走法,并没有要求列出每种走法的具体步骤,那么能否通过记录每个结点的“走法总数”,从而避免重复遍历图中的每个结点呢?
答案是肯定的,这其实就是我们的思路二。
思路二是通过先走一步,从而将 n 阶的问题,划分成
这两个子问题。其本质是分治。继续以 n=7 为例,方案如下:
同样,将树中的相同状态合并下,就变成了一张图,如下:
欸,合并后的图看起来和思路一的好像啊。这里我们对照着看下,下图中的左侧是思路二、右侧是思路一。它俩之所以看起来相似(一个倒着长一个正着长),是因为图里都隐藏了第二个状态,它们完整的状态都是个二元组,分别是 [台阶数, 走法总数]
、[台阶数, 具体走法]
。现在再从状态组的视角看它们,还是有本质不同的。
好,我们继续回到思路二(上图左一)。它是把 n 阶的问题划分成了 n-1 和 n-2 这两个子问题,如果用函数的形式 f(n)
表示 n 个台阶的走法总数,那么图中每个结点所对应的状态就是 [n, f(n)]
,由它出发的两条边的含义就是 f(n) = f(n-1) + f(n-2)
。
啊哈,这不就是斐波那契数列吗?那简单,递归实现,代码如下:
var climbStairs = function(n) {
if (n === 1) return 1;
if (n === 2) return 2; // [1,1], [2]
return climbStairs(n - 1) + climbStairs(n - 2);
};
运行结果:超出时间限制
最后执行的输入:45
囧,还是超时。
通过打印 log,可以看到代码都递归了谁。继续以 n=7 为例,如下:
log: climbStairs(7)
log: climbStairs(6)
log: climbStairs(5)
log: climbStairs(4)
log: climbStairs(3)
log: climbStairs(2)
log: climbStairs(1)
log: climbStairs(2)
log: climbStairs(3)
log: climbStairs(2)
log: climbStairs(1)
log: climbStairs(4)
log: climbStairs(3)
log: climbStairs(2)
log: climbStairs(1)
log: climbStairs(2)
log: climbStairs(5)
log: climbStairs(4)
log: climbStairs(3)
log: climbStairs(2)
log: climbStairs(1)
log: climbStairs(2)
log: climbStairs(3)
log: climbStairs(2)
log: climbStairs(1)
我们发现递归了很多重复的 n,比如 n=5 两次,n=4 三次,n=3 五次,n=2 八次,n=1 五次。
思考:能否把计算过的结果给存起来,等下次再递归到此 n 时就不用再继续向下探寻(递归)了?
当然可以。改造后的代码,如下:
var climbStairs = function(n) {
// 存起来:当有 i 个台阶时,共有 cache[i] 种不同的走法
const cache = [0, 1, 2];
// 递归:深度优先遍历
const dfs = function(i) {
// 优先取缓存
if (cache[i]) return cache[i]; // 1 <= n
// 若没有,再向下探寻
let p1 = cache[i - 1] ? cache[i - 1] : dfs(i - 1); // 同理,先取缓存
let p2 = cache[i - 2] ? cache[i - 2] : dfs(i - 2); // 同理,先取缓存
// 把结果存起来,再返回
cache[i] = p1 + p2;
return cache[i];
};
return dfs(n);
};
执行结果:通过
此时再运行 climbStairs(7)
,递归的日志如下。计算的状态数明显少了,从指数阶 O(2n) 降到了线性阶 O(n)。
log: dfs(7)
log: dfs(6)
log: dfs(5)
log: dfs(4)
log: dfs(3)
这种将之前计算过的状态 [n, f(n)]
先存起来的解决方法,就是记忆化搜索,它能有效避免递归的重复下探,从而提高执行效率。
大家是否有注意到,上述代码中用来存储状态的数据结构
cache[]
和我们遍历图时为了防止结点的重复访问而使用的布尔数组visited[]
有异曲同工之妙,都是为了避免不必要的遍历(广度优先遍历的代码详见文末的附录2)。
还有一种可以有效避免递归重复下探的方法,那就是消除递归。对,就是不用递归实现了,改用 for 循环,代码如下:
var climbStairs = function(n) {
// 存起来:当有 i 个台阶时,一共有 cache[i] 种不同的走法
const cache = [0, 1, 2];
// 开始迭代:从小到大
for (let i = 3; i <= n; i++) {
cache[i] = cache[i - 1] + cache[i - 2];
}
return cache[n];
};
从小到大的迭代,就天然地避免了状态的重复,因为它是纯纯的递推,而不像递归那样,先层层向下探寻(这步有可能出现“重复”),再层层向上递推。
你是否能想象的到,刚才写的 for 循环代码就是动态规划算法。
我们可以说把带记忆的递归搜索展开成 for 循环就是动态规划,也可以说记忆化搜索就是动态规划的递归实现,但它们都是表象。最根本的还是在于状态空间,就拿爬楼梯的例子来说,状态是个二元组 [n, f(n)]
,状态转移方程是 f(n) = f(n-1) + f(n-2)
。
记忆化搜索依托的是图,图中的结点对应的完整状态是 [n, f(n)]
,图中的边的含义是 f(n) = f(n-1) + f(n-2)
。而动态规划依托的是迭代——从小到大的迭代,至于如何确定迭代的递推公式,我们后面的文章会详细介绍。
本文作为动态规划的入门,并没有对它进行过多的介绍,而是仅在末尾引出了动态规划。虽说如此,其实文章里的很多内容都和动态规划有着千丝万缕的联系。
比如我们常说,先暴力搜索,如果太慢就动态规划,如果还慢就贪心。那暴力搜索为什么慢?还记得上面的树形状态图吗?如下,树每长高一层就会多 2k 个状态,如果是 3 叉树或 4 叉树就会多 3k 或 4k 个状态。总之,状态空间是呈指数级增长的,诸如 2n, 3n ... 10n 等,这就是暴力搜索慢的根本原因。
即便是形态从树变成了图,依然可能是指数级增长的。还记得思路一(右)和思路二(左)的对比吗?只不过指数级增长从眼花缭乱的树结点个数,变成了图中边的个数的累乘。
状态图的“图”形结构也只是个表象,重点还是在于图中的边是如何迁移的、图中的结点是如何诠释该问题的。
暴力搜索想要升级成记忆化搜索,关键还是在于分治,即将原问题划分成“规模更小但解题思路一致”的子问题。只有在这样的状态空间里,才有可能(自然而然地)出现重叠的(子)状态,而它就是我们要记忆化的对象。
记忆化搜索本质上就是动态规划的递归实现。而我们常说的动态规划算法,大多数时候都是用 for 循环来实现的,因为它的代码更短小更简洁。正是因为它的简洁,以至于我们在代码里往往只看到了几层 for 循环和一个递推公式,就莫名其妙地把一个看起来还挺复杂的问题给解决了,其实它背后蕴藏着对状态空间的分析和改造。而本文的主要目的,就是学会借助状态图来分析和求解问题。
附录1. 最后的动态规划代码还可以进一步优化,将空间复杂度从 O(n) 降为 O(1)。代码如下:
var climbStairs = function(n) {
let p1 = 0, p2 = 1;
let ans = 0;
for (let i = 1; i <= n; i++) {
ans = p1 + p2;
p1 = p2;
p2 = ans;
}
return ans;
};
附录2. 记忆化搜索也可以用广度优先遍历实现,代码如下:
var climbStairs = function(n) {
// 存起来:当有 i 个台阶时,共有 cache[i] 种不同的走法
const cache = [0, 1, 2];
// 广度优先遍历:队列+入口
const queue = [];
queue.push(3); // 从3个台阶开始
// 开始遍历,直到队列为空
while (queue.length) {
const front = queue.shift();
if (front > n) continue; // 如果台阶数>n,则忽略
if (cache[front]) continue; // 如果该节点已访问,则忽略
// 处理本层逻辑
// 计算本台阶数的走法总数:要么从1步的台阶上来,要么从2步的台阶上来
cache[front] = cache[front - 1] + cache[front - 2];
// 将下个结点入队
queue.push(front + 1);
queue.push(front + 2);
}
return cache[n];
};
本文将从一个例子出发,介绍暴力搜索是怎么一步一步很自然地进化到动态规划的,并以此角度来理解动态规划。
题目:零钱兑换。给定一个整数数组 coins(表示不同面额的硬币)和一个整数 amount(表示总金额),计算并返回可以凑成总金额的最少硬币个数。如果没有任何一种硬币组合能组成总金额,就返回 -1。假设每种硬币的数量是无限的。
这道题之前用贪心做过——“每次尽量找最大面额”,但是不对。代码如下:
var coinChange = function (coins, amount) {
if (amount === 0) return 0; // 提前退出(省得排序)
let ans = 0;
// 依次遍历硬币,面额从大到小
coins.sort((a, b) => b - a);
for (let x of coins) {
if (amount === 0) break;
if (x > amount) continue; // 面额太大,忽略
// 贪心策略:在可选的硬币中,挑面值最大的,兑换到底
ans += parseInt(amount / x);
amount = amount % x;
}
return amount ? -1 : ans;
};
执行结果:解答错误
输入:[186,419,83,408] 6249
今天我们试试暴力搜索。思路是分治,即把“兑换总金额 amount” 的问题划分为 “先兑换 coins[i],再兑换剩余的 amount - coins[i]” 这几个子问题。然后针对子问题,再次划分,直到剩余金额为 0。
比如 coins = [10,9,1],amount = 18,兑换方案如下:
上图就是搜索要遍历的整个状态空间,图中的每个结点都有个状态 [剩余金额, 已用硬币枚数]
,起始点是 [18, 0]
。蓝框里的数字就是“剩余金额”,而“已用硬币枚数”会因到达结点的路径的不同而不同,比如结点 8,从起点到它有三条路径:
[8, 1]
[8, 2]
[8, 2]
而到结点 0 的路径就更多了,我们在它的一系列状态 [0, x]
中选一个最小的 x
便是这个问题的答案。
代码实现如下,从 [amount, 0]
到 [0, x]
的深度优先遍历:
var coinChange = function (coins, amount) {
let ans = Infinity;
// 深度优先遍历:当前金额amount,已用硬币枚数x
const dfs = function (amount, x) {
if (amount === 0) {
ans = Math.min(ans, x); // 到结点0了,挑个最小值
return;
}
// 本层逻辑:N个分支
for (let coin of coins) {
if (coin <= amount) {
dfs(amount - coin, x + 1); // 兑换剩余的,x+1
}
}
}
// 从 [amount, 0] 开始
dfs(amount, 0);
return ans === Infinity ? -1 : ans;
};
执行结果:超出时间限制
最后执行的输入:[1,2,5] 100
超时了,说明代码运行太慢了。
继续以 coins = [10,9,1],amount = 18 为例来说明。
我们从 [18, 0]
出发,每次有 3 个分支,要么找 10 块、要么找 9 块、要么找 1 块,每多一层就会多出现 *3 条路径,也就是说状态空间是呈 3 的指数级增长的,所以搜索会慢一些。虽然慢,但它算出来的结果一定是对的(相比贪心而言),因为它会遍历整个状态空间,最终发现 [0, 2]
用两枚是最优的。
思考:如果能避免分支的累乘,是否就能让搜索快点?
为了方便讨论,我们看个简单点的例子,coins = [9,1],amount = 18。它的状态图如下:
为了进一步简化问题,我们先忽略图中灰色的边,这样一来,从 18 到 0 就分成了两大步:
所以,从 18 到 0 就一共有 2*2=4 条路径,分别是:A1B1、A1B2 和 A2B1、A2B2。
现在我们考虑:从 9 到 0 的那两个分支,哪个更好?显然是 B1——1 张 9 块的,因为它用的硬币枚数更少。那么从 18 到 0 的这 2*2=4 条路径都有用吗?显然不是。因为只要我们到了 9,不论是从 A1 来还是从 A2 来,后面必然是从 9 到 0,而从 9 到 0 的那个 B2 分支完全没用,因为与 B1 相比它必然不够好。也就是说,搜索蛮力遍历的那 2*2=4 条路径,其中与 B2 相关的那两条路径是完全没用的。砍掉了 B2 之后,就成了 2*1=2 条路径了,这样就能避免分支的累乘。
那么,对于特定结点,如何砍掉不必要的分支而只留唯一的那一个呢?试想我们砍掉 B2 分支的逻辑——因为与 B1 相比它不够好,它用了更多的硬币枚数。
思考:对于每个结点,我们能否只要知道“兑换该金额所需要的最少硬币枚数”这个最优的兑换值就足够了呢?答案是肯定的。
在原始的搜索中,状态是个二元组 [剩余金额,已用硬币枚数]
,目标是状态 [0, x]
是否可达,是个 true 或者 false 的一个可行问题。我们可以将状态优化为 [剩余金额]
,而对于每个状态只求它的 最少硬币枚数
这一最优化目标的值。现在,问题就从一个可行问题变成了一个“对于每种金额去求最少硬币枚数”的最优化问题,状态也从二元变成了一元。
我们来对比下新旧状态所对应的状态空间,如下图:
虽然它们的分支看起来是一样的,但是新状态下是不会全部遍历的,因为它只取值最小的那一个。除了状态从二元的 [剩余金额,已用硬币枚数]
变成了一元 [剩余金额]
之外,状态的迁移(即图中的边)逻辑也是不同的,具体表现为:
[剩余金额 amount,已用硬币枚数 x]
,它的三条边分别代表了三个子问题,即 [amount-10, x+1]
、[amount-9, x+1]
、[amount-1, x+1]
。[剩余金额]
,最优化目标是 最少硬币枚数
,它的三条边代表了状态 n
和它的三个子状态 n-10
、n-9
、n-1
,且它们的最优化目标之间存在着递推关系 opt(n) = 1 + min(opt(n-10), opt(n-9), opt(n-1))
。opt, optimization, 最优化目标 \~ 题目要求解的最优化问题
状态 + 最优化目标 + 最优化目标之间具有递推关系 = 最优子结构
最优子结构是一个整块。比如找 9 块有两个方案(B1 和 B2)、找 18 块有四个方案(A1B1、A1B2 和 A2B1、A2B2),但是我们就在这诸多方案中选一个最好的作为“找 9 块或者找 18 块”的一个代表,此时这些方案们一起就是个整块。
我们把很多种方案的这个大集合缩成了一个最优化目标——“最少硬币枚数”的这一个值。这样一来,状态空间就从一个很大的规模变成了各个最优化目标之间的线性递推,这就是动态规划的思路。
- 状态
9
,最优化目标是 1- 状态
18
,最优化目标是 2- 递推关系:
opt(n) = 1 + min(opt(n-1), opt(n-9), opt(n-10))
动态规划的递推实现,代码如下:
var coinChange = function (coins, amount) {
// 最优化目标:金额 i 最少需要 opt[i] 枚硬币
let opt = (new Array(amount + 1)).fill(Infinity);
opt[0] = 0; // 递推边界
// 动态规划:从小到大,开始递推最优化目标
for (let i = 1; i <= amount; i++) {
// 最优化目标的递推公式:opt(n) = 1 + min(opt(n-coin))
for (let coin of coins)
if (i - coin >= 0) {
opt[i] = Math.min(opt[i], 1 + opt[i - coin]);
}
}
return opt[amount] === Infinity ? -1 : opt[amount];
};
执行结果:通过
代码通过了,时间复杂度是 O(amount*coins.length)。
动态规划其实就是对一般搜索的一个优化,它之所以比搜索快,就是因为它不是盲目地遍历所有方案,而是只遍历那一个最优解的方案,用于递推。
Dynamic Programming,简称 DP
动态规划是一种对问题的整个状态空间去分阶段、有顺序、不重复、决策性遍历的算法。即按照递推公式(决策)从小到大依次计算最优解(分阶段+有顺序),而且只算一次(不重复)。
结合具体代码,理解“阶段+状态+决策”这三要素
动态规划的三个关键分别是重叠子问题(递推的基础)、最优子结构(本质/核心)和无后效性。无后效性是指问题的状态空间是一张有向无环图(即可以按照一定的顺序来遍历求解),无环是递推的必要条件,否则从小到大进行递推的时候会出现死循环。
核心:最优子结构
本文通过“零钱兑换”的例子,介绍了动态规划是怎么一步一步很自然地从搜索变过来的。动态规划其实就是对一般搜索的一个优化,它之所以比搜索快,就是因为它不是盲目地遍历所有方案,而是只遍历那一个最优解的方案,用于递推。
先考虑搜索也方便我们梳理问题的整个状态空间,进而有助于我们去提炼状态和最优子结构。最优子结构是动态规划的核心,只要能把递推公式写出来,工作也就完成了一大半。剩下的任务就是照着方程写几个循环来递推实现了,外层循环是状态,内存循环是对最优化目标的决策。
状态 + 最优化目标 + 最优化目标之间具有递推关系 = 最优子结构
eg. 最优子结构
opt[i]
表示凑成金额 i
所需要的最少硬币数” 这句话便暗含了状态和最优化目标
i
opt[i]
opt[i] = min { opt[i-coin] } + 1
,即递推公式
多实战,熟练运用以上思路分析问题
https://u.geekbang.org/lesson/270?article=430105
附1. 动态规划的递归实现(也称记忆化搜索),代码如下:
var coinChange = function (coins, amount) {
// 带记忆的搜索:总金额 i, 最少需要 cache[i] 个硬币数
const cache = (new Array(amount + 1)).fill(-1);
cache[0] = 0;
// 兑换总金额 n
const dfs = function (n) {
if (n === 0) return 0;
// 优先取缓存里的
if (cache[n] !== -1) return cache[n];
// 若缓存里没,再去递归计算
let min = Infinity;
for (let c of coins) {
if (c <= n) {
min = Math.min(min, 1 + dfs(n - c));
}
}
cache[n] = min; // 即便值是 Infinity 也存,因为是“计算过的”
return min;
}
let ans = dfs(amount);
return ans === Infinity ? -1 : ans;
};
还是更推荐用递推来写动态规划,因为代码更短小更简洁,for 循环一目了然。所以就把递归实现的代码放在文末了,供参考。
题目:最长公共子序列。给定两个字符串 text1 和 text2,返回它们的最长公共子序列的长度。如果不存在公共子序列,就返回 0。“子序列”里的字符可以不连续,但在原字符串中的相对顺序不能变。
假设字符串 text1 = "abcabcdbdxe",text2 = "xabcde"。
先用朴素的算法,梳理下问题的状态和状态空间。
思路:依次扫描 text2,在 text1 中找以 text2[i] 开头的最长公共子序列。
先看 text2[0]="x" 字符。在 text1 中找到了一处 x,位于下标 9。如下图:
然后从 text1 的下标 10 开始(含),依次匹配 text2 里的剩余字符。我们发现 text2[1]="a", text2[2]="b", text2[3]="c", text2[4]="d" 均没有找到相同的,只有 text2[5]="e" 在下标 10 处匹配到了。如下图:
所以,以 text2[0]="x" 开头的最长公共子序列就是字符串 "xe",长度为 2。
再来看 text2[1]="a" 字符。在 text1 中找到了两处字符 a,分别位于下标 0 和下标 3。如下图:
同理,再从相应的位置开始,继续匹配 text2 里的剩余字符 b, c, d, e。
第一处的 a 位于 text1 的下标 0,所以从 text1 的下标 1 开始(含)找字符 b,找到了三处。如下图:
再继续找下一个字符 c。在第一处的 b 后面找到了两个 c,在第二处的 b 后面找到了一个 c,在第三处的 b 后面没有找到字符 c。如下图:
再继续找下一个字符 d,结果如下图所示:
再继续找最后一个字符 e,结果如下图所示:
至此,以 text2[1]=text1[0]="a" 开头的最长公共子序列找到了 7 个,分别是 6 个 "abcde" 和 1 个 "abde",显然最长长度是 5。
第二个 a 位于 text1[3]。
同理,继续找下一个字符 b,结果如下图:
再依次找剩余的字符 c, d, e,最终结果为下图:
至此,以 text2[1]=text1[3]="a" 开头的最长公共子序列找到了 3 个,分别是 2 个 "abcde" 和 1 个 "abde",显然最长长度也是 5。
再用相同的逻辑,依次寻找以 text2 中的剩余字符 b, c, d, e 开头的最长公共子序列(每次都会从头到尾地扫描字符串 text1)。大家可以自己手动画下,这里就不再贴类似逻辑的图了。
代码实现,如下:
var longestCommonSubsequence = function(text1, text2) {
let ans = 0;
// 递归:在 text1[pos~End] 里找以 text2[start] 开头的最长公共子序列
const selected = []; // 匹配到的公共子序列(状态空间)
const recur = function(pos, start) {
// 到结尾了,更新答案
if (pos === text1.length || start === text2.length) {
ans = Math.max(ans, selected.length);
return;
}
// 本层逻辑:N叉,取决于匹配到了几个 text2[start] 字符
for (let i = pos; i < text1.length; i++) {
if (text1[i] === text2[start]) {
selected.push(text2[start]);
recur(i + 1, start + 1); // 从i+1开始找, 找第start+1个字符
selected.pop();
}
}
// 继续从pos开始找, 找以 text2[start+1] 开头的
recur(pos, start + 1);
};
// 开始递归,从 0,0 开始
recur(0, 0);
return ans;
};
执行结果:超出时间限制
超时了。大约在预期之内,接下来看看为什么超时以及如何优化。
在上面手动寻找最长公共子序列的过程中,我们也能发现有重复的逻辑。
比如当找完以 text2[1]="a" 开头的最长公共子序列之后,再找以 text2[i]="b","c","d","e" 开头的时候,就和第一处 a 的后续逻辑是完全重叠的,因为它匹配到了 text1[0]——即第一个位置。
而在找 text2[0]="x" 时之所以没有和后续逻辑有大量重叠(只和最后的 e 重复了),是因为它匹配到的位置是在 text1 的倒数第二个。
那么,该如何优化呢?老规矩,画状态图,因为相互重叠的逻辑理论上是会落到图中的同一个结点上的。
继续用上面的例子,字符串 text1 = "abcabcdbdxe", text2 = "xabcde"。
结合暴力搜索的分析和解题思路,我们画的状态图如下——带权重的有向无环图。其中,S 表示 Start 开始结点,E 表示 End 结束结点,两个字符串的相同字符间用双向边相连且权重为 1,其余边的权重均为 0。那么求两个字符串的最长公共子序列,就是求从起点 S 到终点 E 的最大权重路径,其中要求路径中“边 1”的两个端点不能同时是另一条“边 1”的端点。
严格来说,“暴力搜索”应该是对上面这个状态图的完全遍历?
单从状态图来看,似乎不容易直接想到能避免重复计算的方法,那我们就再思考下动态规划的几个原则。
动态规划的原则:
回想用暴力搜索解决此问题的过程,手动模拟时找到的阶段性的解是“以 text2[1]=text1[0]="a" 开头的最长公共子序列找到了 7 个”,在代码中递归求解的也是“在 text1[i~End] 里找以 text2[j] 开头的最长公共子序列,且末尾会继续递归 recur(i, j+1)”。所以,“变化的信息”就是两个字符串的下标 text1[i]
和 text2[j]
,即状态。
动态规划的思路是“从小到大的递推”,所以参数的含义大概率不会和递归的原始含义完全一致,但参数的“形”可以借鉴。
之前是将找出的最长公共子序列存储在共享变量 selected[]
数组中,现在我们不关心具体序列的样子,只关心那个最优化的代表——这些最长公共子序列的最大长度 opt[i][j]
,即 text1 的前 i 个字符和 text2 的前 j 个字符能组成的最长公共子序列的长度。
因为是“从小到大的递推”,所以
opt[i][j]
所代表的集合不是递归中的以text1[i]
和text2[j]
“开始”的两个字符串的最长公共子序列,而是以它两为“结尾”的两个字符串的最长公共子序列(否则就得倒着推,这里我们选择正着推,因为更直观些)。
至于决策,尚不清楚。那我们就按照已确定的状态和最优化目标,尝试人工模拟下,看能否找到最优化目标之间的递推公式。
结合上面的分析,我们画个二维表格,手动模拟下 opt[i][j] 的求解过程(如下),其中 opt[i][j] 表示 text1 的前 i 个字符和 text2 的前 j 个字符能组成的最长公共子序列的长度。
可以得到递推公式:
text1[i] == text2[j]
,那么 opt[i][j] = opt[i-1][j-1] + 1
text1[i] != text2[j]
,那么 opt[i][j] = max(opt[i][j-1], opt[i-1][j])
动态规划的代码实现,如下:
var longestCommonSubsequence = function(text1, text2) {
const m = text1.length;
const n = text2.length;
// 最优化目标:以 text1[i], text2[j] 结尾的两个字符串,最长公共子序列的长度是 opt[i+1][j+1]
let opt = new Array(m + 1);
for (let i = 0; i <= m; i++) {
opt[i] = new Array(n + 1).fill(0); // 填充0(含边界初始化)
}
// 开始递推
let ans = 0;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
// 若相等时,从对角线来+1
if (text1[i - 1] === text2[j - 1]) {
opt[i][j] = opt[i - 1][j - 1] + 1;
// 否则,取最大值(左侧,上侧)
} else {
opt[i][j] = Math.max(opt[i][j - 1], opt[i - 1][j]);
}
ans = Math.max(ans, opt[i][j]); // 可删,多余了
}
}
return opt[m][n];
};
执行结果:通过
通常情况,在看到“求最长公共子序列”的问题时,都会很自然地想到用动态规划(求最优解的问题)。本文之所以选择先从暴力搜索开始,就是想更进一步探索“为什么会想到用画表格这么巧妙的方式来进行递推”。
其实,最优化目标是可以直接取题目中的。但是“状态”有时候不怎么直观,不论是先从朴素的思路开始,还是直接从画状态图开始,都是想梳理清楚状态的来龙去脉。一旦最优化目标和状态都确定好了,递推公式往往也就呼之欲出了。
动态规划最难的大约就是“得先有个思路”,哪怕是最朴素的那种。
从暴力搜索到动态规划的那个“优化过程”,个人感觉文中的思路还有些跳跃不够流畅。如果你有更好/巧妙的方法,诚邀留言讨论。
动态规划打印方案的原则就是:记录转移路径,然后递归输出。
不记录每一步的具体方案,只记录它“从哪来”。
用两个例子感受下。说明:一般情况,“子段/子串”是连续的一段,“子序列”可以不连续。
题目:最长递增子序列。给定一个整数数组 nums,找出最长严格递增子序列的长度。
首先,给重复子问题选代表。这可以直接从题目中来,即“以 nums[i] 结尾的数组,最长严格递增子序列的长度是 opt[i]”,这句话里就包含了状态 [i]
和最优化目标 opt[i]
。
“状态”的定义,可以从暴力搜索中提取(递归的参数+全局变量),也可以人力模拟求解过程进而提取变化的信息,也可以通过“轮廓的变化”来确定变化的信息(该例子就是,直接看重复子问题的变化趋势)。
然后,确定状态转移方程,即最优化目标的递推公式。这就需要自己手动模拟下了。对于每个 [i]
它的最优化目标 opt[i]
的初始值都是 1(即它自己一个),再在它前面的数字 nums[j]
里,找到满足 nums[i] > nums[j]
的数(即严格递增),然后取 opt[j] + 1
里的最大值,就是 opt[i]
的值。
状态 + 最优化目标 + 递推公式 = 最优子结构
动态规划的代码实现,如下:
var lengthOfLIS = function (nums) {
const n = nums.length;
// 最优化目标:以 nums[i] 结尾的最长严格递增子序列的长度是 opt[i]
let opt = (new Array(n)).fill(1);
// 动态规划:从小到大进行递推
for (let i = 1; i < n; i++) {
for (let j = i - 1; j >= 0; j--) {
if (nums[i] > nums[j]) {
opt[i] = Math.max(opt[i], opt[j] + 1);
}
}
}
return Math.max(...opt);
};
记录 opt[i]
对应的最长严格递增子序列的末尾数字来源于谁(即它的前一个是谁),然后再沿着最大的 [max]
倒着找回去就可以了。
代码实现,如下:
var lengthOfLIS = function (nums) {
const n = nums.length;
let opt = (new Array(n)).fill(1);
let pre = (new Array(n)).fill(-1); // 转移路径,初始值是-1(没有来源)
for (let i = 1; i < n; i++) {
for (let j = i - 1; j >= 0; j--) {
if (nums[i] > nums[j] && opt[j] + 1 > opt[i]) {
opt[i] = opt[j] + 1;
pre[i] = j; // 从 j 来
}
}
}
// 找最大值的下标
let ans = opt[0];
let ansIndex = 0;
for (let i = 1; i < n; i++) {
if (opt[i] > ans) {
ans = opt[i];
ansIndex = i;
}
}
// 递归输出:因为是倒序,所以在回溯时输出
let ansList = [];
const log = function (i) {
if (pre[i] !== -1) log(pre[i]); // 若不是-1,则继续下探
ansList.push(nums[i]); // 回溯时,再输出
}
log(ansIndex); // 从 ansIndex 开始递归输出
console.log(ansList.join());
return ans;
};
nums = [10,9,2,5,3,7,101,18]
会输出:2,3,7,101
打印逻辑的空间复杂度是 O(n)。
题目:最长公共子序列。给定两个字符串 text1 和 text2,返回它们的最长公共子序列的长度。如果不存在公共子序列,就返回 0。“子序列”里的字符可以不连续,但在原字符串中的相对顺序不能变。
这个题目在之前的文章里已经详细讨论过了,这里就直接写打印方案的代码了。
路径的来源有三种:对角线、左侧、正上方。代码实现如下:
var longestCommonSubsequence = function (text1, text2) {
const m = text1.length;
const n = text2.length;
let opt = new Array(m + 1);
let pre = new Array(m + 1); // 转移路径的类型,1左侧,2正上方,3对角线
for (let i = 0; i <= m; i++) {
opt[i] = new Array(n + 1).fill(0);
pre[i] = new Array(n + 1).fill(0); // 初始值是0
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (text1[i-1] === text2[j-1]) {
opt[i][j] = opt[i-1][j-1] + 1;
pre[i][j] = 3; // 从对角线来
} else {
opt[i][j] = opt[i][j-1];
pre[i][j] = 1; // 从左侧来
if (opt[i - 1][j] > opt[i][j]) {
opt[i][j] = opt[i-1][j];
pre[i][j] = 2; // 从正上方来
}
}
}
}
// 打印方案
let ansList = [];
const log = function (i, j) {
const type = pre[i][j];
if (type === 0 || opt[i][j] === 0) return; // 递归边界
// 本层逻辑
if (type === 1) log(i, j-1);
else if (type === 2) log(i-1, j);
else if (type === 3) {
log(i-1, j-1);
ansList.push(text1[i-1]); // 当从对角线来时,再记录
}
}
log(m, n); // 从 [m, n] 开始
console.log(ansList.join(''));
return opt[m][n];
};
text1 = "abcabcdbdxe", text2 = "xabcde"
会输出:abcde
打印逻辑的空间复杂度是 O(m*n),存了转移类型一个值。
本文内容比较简单,就讨论了一件事,那就是:当要在动态规划里打印方案时,只要记录“转移路径”就可以了——它从哪来。
在具体应用时,有两个小点需要关注:一是转移路径如何记录(需要分析具体问题),二是因为是倒序记录的,所以递归输出的时机是在“回溯”时。
背包有两个模型:0/1 背包和完全背包。
给定 N 个物品,其中第 i 个物品的体积是 Vi,价值是 Wi。有一个容积为 M 的背包,要求选择一些物品放入背包,使得在总体积不超过 M 的前提下,物品的价值总和最大。
依次考虑每个物品选还是不选(即子集问题),每个结点有 2 个分支,是个 2n 指数级别的搜索。同时关注已经放入背包里的物品的总重量和总价值。
“子集问题”详见《递归的本质及其基本实现形式》小节
opt[i][j]
表示从前 i 个物品中选出了总体积为 j 的物品放入背包,物品的最大价值总和
[i]
和总体积 [j]
opt[i][j]
最大价值总和opt[i][j] = opt[i-1][j]
opt[i][j] = opt[i-1][j-Vi] + Wi
opt[i][j]
的值就取以上两个中值最大的那个
opt[i][j] = max(opt[i-1][j], opt[i-1][j-Vi] + Wi)
opt[i][j] = -Infinity
opt[0][0] = 0
max(...opt[N])
动态规划的核心代码:
// 最优化目标:opt[i][j] 表示从前 i 个物品中选出了总体积为 j 的物品放入背包,物品的最大价值总和
let opt = new Array(N + 1); // (N+1)*(M+1),多申请一个-当保护结点-以方便递推
for (let i = 0; i <= N; i++) opt[i] = (new Array(M + 1)).fill(-Infinity);
opt[0][0] = 0; // 初始值
// 从小到大开始递推,先 for 状态,再 for 决策
for (let i = 1; i <= N; i++) { // [i] 物品
for (let j = 0; j <= M; j++) { // [j] 总体积
// 决策:选or不选
opt[i][j] = opt[i-1][j]; // 不选
if (j >= V[i]) {
opt[i][j] = Math.max(opt[i][j], opt[i-1][j-V[i]] + W[i]); // 选
}
}
}
return Math.max(...opt[N]);
我们发现,状态 opt[i]
是先从 opt[i-1]
完全转移过来(当不选第 i 个物品时),然后是当 j >= V[i]
时,走“选择第 i 个物品”的决策。所以,在空间上,我们可以省略掉第一维的物品 [i]
存储,只留总体积 [j]
的维度。优化后的代码,如下:
// 最优化目标:opt[i][j] 表示从前 i 个物品中选出了总体积为 j 的物品放入背包,物品的最大价值总和
let opt = (new Array(M + 1)).fill(-Infinity); // 省略了第一维,从 (N+1)*(M+1) 降低为 (M+1)
opt[0] = 0; // 初始值
// 从小到大开始递推,先 for 状态,再 for 决策
for (let i = 1; i <= N; i++) { // [i] 物品
// for (let j = V[i]; j <= M; j++) { // [j] 总体积
for (let j = M; j >= V[i]; j--) { // 须倒序遍历 [j],才能拿到之前二维时纯粹的 opt[i-1][j]
opt[j] = Math.max(opt[j], opt[j-V[i]] + W[i]); // max(不选, 选)
}
}
return Math.max(...opt);
代码的空间复杂度从 O(N*M) 降到了 O(M)。这就是 0/1 背包算法的通用代码,背包问题是一类特殊的动态规划问题。
来看个例子感受下。
题目:分割等和子集。给定一个只包含正整数的非空数组 nums,请判断是否可以将该数组分割成两个子集,使得两个子集的元素和相等。
这是一个子集问题,即选一个子集,让它的元素之和等于原数组和的一半。
暴力搜素(枚举回溯)的代码实现,如下:
var canPartition = function (nums) {
const n = nums.length;
const sum = nums.reduce((a, b) => a + b);
if (sum % 2 === 1) return false; // 若和是奇数,则直接返回false
// 选择一个子集,使其元素之和等于target
const target = sum / 2;
let ans = false;
// 子集问题,递归搜索:判断第pos个位置选or不选
let seletedSum = 0; // 已选元素之和(状态空间-共享变量)
const dfs = function (pos) {
if (ans || seletedSum > target) return; // 剪枝
if (seletedSum === target) { // 找到答案了
ans = true;
return;
};
if (pos === n) return; // 叶子结点(正常边界)
// 本层逻辑:选or不选当前位置的数
dfs(pos + 1); // 不选
// 选
seletedSum += nums[pos]; // 下探时+本层
dfs(pos + 1);
seletedSum -= nums[pos]; // 回溯时恢复现场
}
dfs(0); // 从位置 0 开始
return ans;
};
执行结果:超出时间限制
子集问题在有和限制的前提下,选数就和 0/1 背包一样。每个数就是一个物品,数的值就是物品的体积,此问题就是求:能否选出一些总体积等于 sum/2 的物品。
此时不用考虑“价值”(最优解问题),而是考虑“体积 M”是否可行(可行性问题)。
按照动态规划的思路进行分析,如下:
opt[i][j]
表示从前 i 个数中选出一些数,总和是 j,是否可行?
[i]
和总和 [j]
opt[i][j]
是否可行opt[i][j] = opt[i-1][j]
opt[i][j] = opt[i-1][j-nums[i]]
opt[i][j]
的值就是这两个分支的逻辑或,即有一个分支是 true 则值就是 true
opt[i][j] = opt[i-1][j] || opt[i-1][j-nums[i]]
opt[i][j] = false
opt[0][0] = true
opt[N][M]
是否可行代码实现,如下:
var canPartition = function(nums) {
const n = nums.length;
const sum = nums.reduce((a,b) => a+b);
if(sum%2===1) return false; // 若和是奇数,则直接返回false
// 选择一个子集,使其元素之和等于target
const target = sum/2;
// opt[i][j] 表示从前 i 个数中选出一些数,总和是 j,是否可行?
let opt = new Array(n + 1); // (N+1)*(M+1),多申请一个-当保护结点-以方便递推
for (let i = 0; i <= n; i++) opt[i] = (new Array(target + 1)).fill(false); // 初始值均为 false(不可达)
opt[0][0] = true; // 初始值
// 从小到大开始递推,先 for 状态,再 for 决策
for (let i = 1; i <= n; i++) { // [i] 物品-数字
for (let j = 0; j <= target; j++) { // [j] 总体积-总和
// 决策:选or不选,有一个分支是 true 就可以
opt[i][j] = opt[i-1][j]; // 不选
if (j >= nums[i-1]) {
opt[i][j] = opt[i][j] || opt[i-1][j-nums[i-1]]; // 选
}
}
}
return opt[n][target]; // 直接返回目标状态是否可行
};
执行结果:通过
优化下代码的空间复杂度,将其从 O(nums.length*target) 降为 O(target)。如下:
var canPartition = function (nums) {
const n = nums.length;
const sum = nums.reduce((a, b) => a + b);
if (sum % 2 === 1) return false; // 若和是奇数,则直接返回false
// 选择一个子集,使其元素之和等于target
const target = sum / 2;
// opt[i][j] 表示从前 i 个数中选出一些数,总和是 j,是否可行?
let opt = (new Array(target + 1)).fill(false);
opt[0] = true;
// 从小到大开始递推,先 for 状态,再 for 决策
for (let num of nums) { // [i] 物品-数
for (let j = target; j >= num; j--) { // [j] 总体积-总和,须倒序遍历,才能拿到之前二维时纯粹的 opt[i-1][j]
opt[j] = opt[j] || opt[j-num]; // (不选, 选) 两个分支有一个是 true 就可以
}
}
return opt[target];
};
执行结果:通过
与 0/1 背包相比,完全背包的物品不限个数。
完全背包:给定 N “种”物品,其中第 i 种物品的体积是 Vi,价值是 Wi,并且每种物品都有“无数个”。有一个容积为 M 的背包,要求选择一些物品放入背包,使得在总体积不超过 M 的前提下,物品的价值总和最大。
完全背包的递推公式,和 0/1 背包的唯一不同就是:当从第 i 种物品中选择的时候是包含 i 的。
背包模型 | 重复子问题 | 递推公式 opt[i][j] |
---|---|---|
0/1 背包 | opt[i][j] 表示从前 i 个物品中选出了总体积为 j 的物品放入背包, 物品的最大价值总和 |
max(opt[i-1][j], opt[i-1][j-Vi] + Wi) (不选第i个物品, 选择第i个物品) |
完全背包 | opt[i][j] 表示从前 i 种物品中选出了总体积为 j 的物品放入背包, 物品的最大价值总和 |
max(opt[i-1][j], opt[i][j-Vi] + Wi) (尚未选过第i种物品, 从第i种物品中选一个) |
直接按照递推公式写,完全背包的代码如下:
// 最优化目标:opt[i][j] 表示从前 i 种物品中选出了总体积为 j 的物品放入背包,物品的最大价值总和
let opt = new Array(N + 1); // (N+1)*(M+1),多申请一个-当保护结点-以方便递推
for (let i = 0; i <= N; i++) opt[i] = (new Array(M + 1)).fill(-Infinity);
opt[0][0] = 0; // 初始值
// 从小到大开始递推,先 for 状态,再 for 决策
for (let i = 1; i <= N; i++) { // [i] 物品
for (let j = 0; j <= M; j++) { // [j] 总体积
// 决策:选or不选
opt[i][j] = opt[i-1][j]; // 不选
if (j >= V[i]) {
opt[i][j] = Math.max(opt[i][j], opt[i][j-V[i]] + W[i]); // 选
}
}
}
return Math.max(...opt[N]);
优化下代码的空间复杂度,将其变为一维,如下:
// 最优化目标:opt[i][j] 表示从前 i 种物品中选出了总体积为 j 的物品放入背包,物品的最大价值总和
let opt = new Array(M + 1); // 省略了第一维,从 (N+1)*(M+1) 降低为 (M+1)
opt[0] = 0; // 初始值
// 从小到大开始递推,先 for 状态,再 for 决策
for (let i = 1; i <= N; i++) { // [i] 物品
for (let j = V[i]; j <= M; j++) { // [j] 总体积,正序循环即可(便能重复选第 [i] 种物品了)
opt[j] = Math.max(opt[j], opt[j-V[i]] + W[i]); // max(尚未选过, 选一个)
}
}
return Math.max(...opt);
我们发现,把 0/1 背包算法中的状态 [j]
正序循环了之后,就变成完全背包算法了,因为这样就能很方便地实现重复选择第 i 种物品的逻辑了。
来看两个例子感受下。
题目:零钱兑换的最少硬币个数。给定一个整数数组 coins(表示不同面额的硬币)和一个整数 amount(表示总金额),计算并返回可以凑成总金额的最少硬币个数。如果没有任何一种硬币组合能组成总金额,就返回 -1。假设每种硬币的数量是无限的。
零钱兑换的问题其实就符合完全背包模型。零钱=物品,面值=体积,价值=1,求最少兑换个数就是求价值的最小值。
用背包的思想来分析这个动态规划的问题,步骤如下:
opt[i][j]
表示从前 i 种硬币中凑出总面额为 j 的最少硬币个数
[i]
和总面额 [j]
opt[i][j]
最少硬币数opt[i][j] = opt[i-1][j]
opt[i][j] = opt[i][j-coins[i]] + 1
opt[i][j]
的值就取这两个分支的最小值
opt[i][j] = min(opt[i-1][j], opt[i][j-coins[i]] + 1)
opt[i][j] = Infinity
opt[0][0] = 0
opt[N][amount]
直接套用递推公式,代码如下:
var coinChange = function (coins, amount) {
const n = coins.length;
// opt[i][j] 表示从前 i 种硬币中凑出总面额为 j 的最少硬币个数
let opt = new Array(n + 1);
for (let i = 0; i <= n; i++) opt[i] = (new Array(amount + 1)).fill(Infinity); // 默认值 Infinity
opt[0][0] = 0; // 递推起点
// 开始递推
for (let i = 1; i <= n; i++) {
const c = coins[i-1];
for (let j = 0; j <= amount; j++) {
opt[i][j] = opt[i-1][j];
if (j >= c && opt[i][j-c] + 1 < opt[i][j]) {
opt[i][j] = opt[i][j-c] + 1;
}
}
}
return opt[n][amount] === Infinity ? -1 : opt[n][amount];
};
执行结果:通过
优化下代码的空间复杂度,如下:
var coinChange = function (coins, amount) {
// opt[i][j] 表示从前 i 种硬币中凑出总面额为 j 的最少硬币个数
let opt = (new Array(amount + 1)).fill(Infinity);
opt[0] = 0;
for (let c of coins)
for (let j = c; j <= amount; j++)
opt[j] = Math.min(opt[j], opt[j - c] + 1);
// 返回
return opt[amount] === Infinity ? -1 : opt[amount];
};
执行结果:通过
题目:零钱兑换的硬币组合数。给定一个整数数组 coins(表示不同面额的硬币)和一个整数 amount(表示总金额),计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0。假设每种面额的硬币有无限个。
这个问题是完全背包+计数。
opt[i][j]
表示从前 i 种硬币中凑出总面额为 j 的硬币组合数
[i]
和总面额 [j]
opt[i][j]
硬币组合数opt[i][j] = opt[i-1][j]
opt[i][j] = opt[i][j-coins[i]]
opt[i][j]
的值等于它们之和
opt[i][j] = opt[i-1][j] + opt[i][j-coins[i]]
opt[i][j] = 0
opt[i][0] = 1
opt[N][amount]
直接按递推公式写,代码如下:
var change = function (amount, coins) {
const n = coins.length;
// opt[i][j] 表示从前i种硬币中,能凑出总面额为j的硬币组合数
let opt = new Array(n + 1);
for (let i = 0; i <= n; i++) {
opt[i] = (new Array(amount + 1)).fill(0); // 默认是 0
opt[i][0] = 1; // 递推起点:[i][0] = 1
}
// 开始递推:不选+选
for (let i = 1; i <= n; i++) {
for (let j = 0; j <= amount; j++) {
opt[i][j] = opt[i-1][j]; // 不选时
}
let c = coins[i-1];
for (let j = c; j <= amount; j++) {
opt[i][j] += opt[i][j-c];
}
}
return opt[n][amount];
};
执行结果:通过
优化下代码的空间复杂度,如下:
var change = function (amount, coins) {
let opt = (new Array(amount + 1)).fill(0);
opt[0] = 1;
for (let c of coins)
for (let j = c; j <= amount; j++)
opt[j] += opt[j - c];
return opt[amount];
};
执行结果:通过
背包问题是一类特殊的动态规划问题,它就是在一定的体积限制下取物品,让价值最大化。背包有两个模型,分别是 0/1 背包和完全背包,它们的唯一区别就在于每种物品的个数是 1 个还是无限个。正因如此,它们的递推公式和通用代码之间的区别也不大。
递推公式的区别就在于:当从第 i 种物品中选择的时候是包含 i。
背包模型 | 重复子问题 | 递推公式 opt[i][j] |
---|---|---|
0/1 背包 | opt[i][j] 表示从前 i 个物品中选出了总体积为 j 的物品放入背包, 物品的最大价值总和 |
max(opt[i-1][j], opt[i-1][j-Vi] + Wi) (不选第i个物品, 选择第i个物品) |
完全背包 | opt[i][j] 表示从前 i 种物品中选出了总体积为 j 的物品放入背包, 物品的最大价值总和 |
max(opt[i-1][j], opt[i][j-Vi] + Wi) (尚未选过第i种物品, 从第i种物品中选一个) |
通用代码中,倒序循环 [j]
时是 0/1 背包,正序循环时是完全背包。
// opt[i][j] 表示从前 i 种物品中选出了总体积为 j 的物品放入背包,物品的最大价值总和
let opt = new Array(M + 1).fill(-Infinity);
opt[0] = 0;
// 开始递推:先 for 状态,再 for 决策
for (let i = 1; i <= N; i++) {
for (let j = M; j >= V[i]; j--) { // 倒序循环-0/1背包
// for (let j = V[i]; j <= M; j++) { // 正序循环-完全背包
opt[j] = Math.max(opt[j], opt[j - V[i]] + W[i]); // max(不选, 选)
}
}
return Math.max(...opt);
背包问题就是选或者不选——即子集问题,而子集问题适用于所有指数级别的搜索,所以背包的应用非常广泛,比如各种取物品且有体积限制的。它能求解最优解问题 min, max
、可行性问题 |= boolean
以及计数问题 +=
。
好用功 orz,膜拜
好用功 orz,膜拜
捡一些重要的基础知识 💪
按频次 | 子分类 | 内容 | 要求 | |
---|---|---|---|---|
高频 | 计算机基础 | 数组+链表 栈+队列 |
必须熟练掌握 四种最基本的一维容器 |
|
Hash | 熟练应用 掌握基本的建立方法 |
|||
树+图 | 重点 | |||
DFS/BFS | 重点 | |||
二叉堆 二叉搜索树 字典树 并查集 |
熟练掌握 四种基本的树形结构 应用非常广泛, 在库/框架里很常用 都不难写, 代码也比较简洁 |
|||
字符串 | 重点 平时应用的最多 |
|||
子问题划分 | 递归 | 子问题是不同规模如何处理 | ||
分治 | ||||
动态规划 | 子问题是当前阶段如何决策 | |||
搜索 | ||||
贪心 | ||||
基本技巧 | 双指针 滑动窗 |
候选集合优化 | ||
前缀和 二分答案 |
重点 比较好写, 代码也不难 |
|||
相对低频 | 高端技巧 | 最短路 最小生成树 |
图论算法 一般 Hard 题较多 |
|
离线批处理 关键事件思想 |
【计算机基础】部分是重中之重,必须学扎实。因为除了算法题,它们在平时也会用到。主要包括数据结构(四种最基本的一维容器+Hash+四种基本的树形结构)、树和图(树是特殊的图, 树和递归也有着密切关系)以及深度优先搜索和广度优先搜索(搜索是解决一切问题的万金油算法)。
【子问题划分与状态空间】部分更进阶一些,是有决策地整理和理解,会考虑整个状态空间。不论是递归还是动规,都考虑的是本层逻辑。「递归」要想的是本层逻辑,而不要往太深的地方想。要把子问题看作是一个写好的函数,去直接调用;然后在本层考虑怎样把问题的规模进一步缩小,只要一缩小,子问题就会遇到边界,然后就能层层返回。「动规」是分阶段的,考虑“当前阶段”这个子问题,然后通过不同决策再变到之后的一个问题,直到目标阶段。
【基本技巧】部分属于优化类。其中「决策与候选集合优化」是提高效率的关键,本质是去除冗余。“滑动窗口”系列的多种维护方法,思想是固定一端+移动一端+消除冗余。比如在动规里要考虑的决策很多时,如何维护候选集合。「前缀和」属于预处理,用空间换时间。「二分答案」是把一个最优解问题变成判定问题(验证要比求解简单些)。
多项式量级和非多项式量级的区别?
多项式量级和非多项式量级的区别?
分两块理解:
所以,多项式量级就是指数据规模是多项式的。
结合上面的,再看《复杂度分析》里提到的这个应该会更清晰些,核心是在数学里的“多项式”的概念。
分类 | 复杂度量级 | 大 O 表示 |
---|---|---|
多项式量级 | 常量阶 | O(1) |
对数阶 | O(logn) |
|
线性阶 | O(n) |
|
线性对数阶 | O(nlogn) |
|
平方阶 立方阶 k 次方阶 |
O(n2) O(n3) O(nk) |
|
非多项式量级 | 指数阶 | O(2n) |
阶乘阶 | O(n!) |
指数阶和阶乘阶之所以不是多项式量级的,是因为多项式里没有指数和阶乘这两种操作。对于形如 xy 的,如果 x 是变量,y 是常量,那 xy 就是个一元 y 次多项式(未知数在底数位置——幂函数);如果 y 是变量,x 是常量,那 xy 就不是个多项式(未知数在指数位置——指数函数)。
非多项式级别的复杂度,比如指数阶 O(2n)、阶乘阶 O(n!),当数据规模很大时,算法执行需要太多的时间,计算机往往不能承受(除非数据规模较小)。
目录
O(1)
,O(logn)
,O(n)
,O(nlogn)
,O(n2)
/O(n3)
/O(nk)
O(2n)
,O(n!)