框架思维

计算机思维就是递归解决重复问题,自顶向下,从抽象到具体。

数据结构的存储方式

  • 数组:顺序存储

  • 链表:链式存储

栈、队列、散列表、堆、树、图都是在上面两种存储方式的基础上的特殊操作。

数据结构

存储方式

说明

栈、队列

数组

链表

用数组实现,要处理扩容缩容问题

用链表实现,没有扩容问题,但需要更多的内存空间存储节点指针

散列表

数组+链表(拉链法)

通过散列函数把键映射到一个大数组里。解决散列冲突的方法:拉链法需要链表特性,操作简单,但需要额外的空间存储指针、线性探查法需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些

数组

堆是一个完全二叉树

链表

不是完全二叉树时,不适合用数组实现,在树的递归结构上有各种巧妙地设计:二叉搜索树、AVL树、红黑树、区间树、B树

二位数组(邻接矩阵)

链表(邻接表)

邻接矩阵判断连通性迅速,可以进行矩阵运算解决一些问题,如果图比较稀疏很耗费空间

邻接表比较节省空间,但是很多操作效率比不过邻接矩阵

虽然数据结构的种类很多,但是底层存储只有数组和链表两种,二者的优缺点如下:

存储方式

说明

数组

紧凑连续存储,可以随机访问,通过元素快速找到对应元素,节约存储空间

内存空间一次性分配,数组扩容需要重新分配更大空间,再把数据全部复制过去,时间复杂度 `O(N)`

数组中插入或删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 `O(N)`

链表

元素不连续,无法根据索引算出对应元素的地址,所以不能随机访问,依赖指针指向下一个元素的位置,不存在扩容问题

知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 `O(1)`

而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间

数据结构的基本操作

对于任何的数据结构,基本操作包括:

遍历和查找分为线性(for/while迭代为代表)和非线性(递归为代表)。

  • 遍历

  • 查找

  • 增加

  • 删除

  • 修改

数据结构种类很多,但它们存在的目的都是在不同的应用场景,尽可能高效地增删查改

数组遍历

线性迭代:

链表遍历

链表节点:

线性迭代:

非线性递归:

二叉树遍历

二叉树节点:

线性迭代:

非线性递归:

多叉树遍历

多叉树节点:

非线性递归:

动态规划框架

计算机解决问题唯一的解决办法就是穷举所有可能性。算法设计就是先思考“如何穷举”,然后再追求“如何聪明地穷举”(备忘录和DP table 就是追求聪明地穷举)。

列出动态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。

动态规划问题的一般形式是求最值,求解动态规划的核心问题是穷举

  • 求最长递增子序列

  • 求最小编辑距离

动态规划的穷举比较特别:

  • 存在重叠子问题:使用备忘录或DP table优化穷举过程,避免重复计算

  • 具备最优子结构:通过子问题最值得到原问题最值

  • 正确的状态转移方程:穷举所以可行解太难,状态状态转移方程才能正确穷举

思维框架:

  1. 明确base case

  2. 确定状态:base case的变化

  3. 确定选择:导致状态发生变化的行为

  4. 确定DP数组/函数的定义

    1. DP数组(自底向上):存储每一个中间状态

    2. DP函数(自顶向下):一个递归的DP函数,函数的参数就是状态转移中会变化的量(状态),函数的返回值就是题目要求计算的量

重叠子问题优化方法

使用递归穷举

递归算法的时间复杂度:用子问题个数乘以解决一个子问题需要的时间

但凡遇到需要递归的问题,最好都画出递归树,这对分析算法的复杂度,寻找算法低效的原因都有巨大帮助。

带备忘录(数组/哈希表)的递归

  1. 递归耗时的原因是重复计算,那么可以造一个备忘录,每次算出某个子问题的答案后别急着返回,先记到备忘录里再返回

  2. 每次遇到一个子问题先去备忘录里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了

实际上,带备忘录的递归算法,把一棵存在巨量冗余的递归树通过剪枝,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。

带备忘录的递归解法的效率和迭代的动态规划解法一样,这里的备忘录完成后就是动态规划中的DP table。

带备忘录的递归叫做「自顶向下」,动态规划叫做「自底向上」。

  • 自顶向下:按照递归树从上向下延伸,从一个规模较大的原问题,向下逐渐分解规模,直到分解到base case,然后逐层返回答案

  • 自底向上:从最底下,最简单,问题规模最小的base case开始往上推,直到推到想要的答案,这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算

DP数组迭代解法

把递归中的备忘录独立出来成为一张表,叫做 DP table,在这张表上完成自底向上的推算。

状态转移方程:实际上就是描述问题结构的数学形式,备忘录或者DP table都是围绕这个方程式的不同表现形式。

其实状态转移方程直接代表着暴力解法,优化方法无非是用备忘录或者 DP table。

状态压缩:根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 O(1)

如果发现每次状态转移只需要 DP table 中的一部分,那么可以尝试用状态压缩来缩小 DP table 的大小,只记录必要的数据,一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)

最优子结构优化方法

要符合最优子结构,子问题间必须互相独立,子问题的最优解之间不会相互影响。如果子问题相互影响,那子问题之间无法同时得到最优解,动态规划的最优子结构条件就被破坏了。

暴力递归

以凑零钱问题为例,给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。

  1. base case:amount=0

  2. 确定状态:硬币变化导致amout变化

  3. 确定选择:硬币的面值就是选择

  4. DP函数定义:输入一个目标金额 n,返回凑出目标金额 n 的最少硬币数量

带备忘录的递归

在暴力递归的基础上增加备忘录:

DP数组迭代解法

自底向上使用DP table来消除重叠子问题,状态,选择和base case与dp函数一样,dp数组的定义目标金额作为变量,体现在dp数组的索引中。

dp数组定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出。

回溯框架

回溯算法不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。

解决一个回溯问题,实际上就是一个决策树(多叉树)的遍历过程,二叉树的话,就是DFS(深度优先搜索)。只需要思考 3 个问题:

  1. 路径:也就是已经做出的选择

  2. 选择列表:也就是当前可以做的选择

  3. 结束条件:也就是到达决策树底层,无法再做选择的条件

将选择列表和路径当做是决策树中,每个节点的属性。

回溯算法的框架,核心就是 for 循环里面的递归,在递归调用之前做选择,在递归调用之后撤销选择

我们定义的 backtrack 函数其实就像一个指针,在这棵树上游走,同时要正确维护每个节点的属性,每当走到树的底层,其「路径」就是一个解。整棵决策树的访问过程就是多叉树遍历的过程。

所谓的前序遍历和后序遍历,只是两个很有用的时间点:前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历代码在离开某个节点之后的那个时间点执行

backtrack函数在决策树上游走的时候,需要正确的维护每个节点的属性(走过的路径和当前的选择列表),那就在前序/后序这两个时间点执行一些操作:做选择撤销选择。只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到每个节点的选择列表和路径,当触发结束条件时,将路径记入结果集中。

BFS(深度优先搜索)框架

BFS 的核心思想是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,写 BFS 算法都是用队列这种数据结构,每次将一个节点周围的所有节点加入队列。

BFS 找到的路径一定是最短的,因为步数没增加一次,队列中的所有节点都向前迈一步,这保证了第一次到达终点的时候,走的步数是最少的,但代价就是空间复杂度比 DFS 大很多。

各种花里胡哨的问题,包装的很高大上,本质都是一幅图,从一个起点走到终点,最短路径问题。

  • 走出迷宫的最短路径

  • 带传送门的迷宫的最短路径

  • 把一个单词变成另一个单词,每次只替换一个字符,最少几次

  • 连连看游戏,图案相同,且两个方块之间的最短连线只有两个拐点

双向BFS

双向 BFS,可以进一步提高算法的效率,不过,双向 BFS 也有局限,就是必须知道终点在哪里。

传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向 BFS 则是从起点和终点同时开始扩散,当两边有交集的时候停止。

双向 BFS 还是遵循 BFS 算法框架的,只是不再使用队列,而是使用 Hash 方便快速判断两个集合是否有交集。

滑动窗口框架

这个算法技巧的思路非常简单,就是维护一个窗口,不断滑动,然后更新答案。

最后更新于