跳至主要內容

 

labuladong约 4782 字大约 16 分钟配套学习工具

一定要认真看到最后

在最后一部分,我会教给你使用可视化面板的实用技巧,然后结合具体的算法题场景向你提问,要求你利用这些技巧高效分析题目和算法的执行过程。

一张示意图就可以说明视化面板主要几部分的功能,后文我会具体展示可视化面板使用方法:

我的所有算法代码都添加了动画可视化,且可视化面板全线支持我的配套刷题插件,包括 刷题网站open in new windowChrome 插件vscode 插件Jetbrains 插件

最近录了一个视频介绍可视化面板的主要功能,喜欢看视频的读者可以去 B 站观看:

数据结构的可视化

注意,这可视化面板不是一个简单的视频或者 GIF,而是交互式页面,你可以自己控制动画的播放,一步步查看代码的运行过程。我这里就展示一个简单的示例,单链表成环算法的可视化面板:

环检测算法的核心逻辑就是快慢指针,慢指针 slow 每次只移动 1 步,快指针 fast 每次移动 2 步,如果单链表有环,最终它俩一定会相遇。

请你用鼠标不断点击可视化面板左侧代码中的 if (slow === fast) 这一行,就可以直观地看到快慢指针的追逐过程,直至它们相遇。

面板可以可视化数组、链表、二叉树、哈希表、哈希集合等常见数据结构,这些属于基本的可视化功能展示,下面来介绍点进阶的功能。

递归过程的可视化

递归算法是很多读者头痛的,我之前写过一篇 从树的角度理解一切递归算法,从理论上抽象出了递归算法最基本的思维模型和编写技巧。

现在,我把之前系列文章所阐述的思维模型融入了算法可视化面板,一定可以让你更直观地理解递归算法的执行过程,快速培养 学习数据结构和算法的框架思维

下面,我先简单梳理一下我对递归思维的总结,然后再介绍可视化面板为什么融合了这些思维。

首先,我在 我的算法学习心得 中说过,算法的本质是穷举,而大家普遍认为比较难的算法,比如回溯算法、动态规划、DFS 算法等,它们的本质也是穷举,只不过需要借助递归的形式,或者说是递归的思想,来实现穷举。

其次,关于这些比较抽象的递归算法,我在 DFS/BFS/回溯/动归算法的融会贯通 中用二叉树这种简单的基本结构把它们都穿起来了,我把二叉树系列算法分为两种解题思路:

一种是分解问题的思维模式,这种思路代表着动态规划、分治算法等;另一种是遍历问题的解题思维模式,这种思路代表着回溯算法、DFS 算法等。

反过来,在用动态规划/回溯算法等比较复杂的问题时,我也会教大家用树的视角来理解算法,只要把递归树画出来,就可以很直观地理解这些递归算法:

把递归函数理解成递归树上的一个指针,回溯算法是在遍历一棵多叉树,并收集叶子节点的值;动态规划是在分解问题,用子问题的答案来推导原问题的答案

回溯算法

先讲回溯算法,我在讲解 回溯算法秒杀排列/组合/子集问题 时画出了全排列问题的递归树,每一个叶子节点就是一个合法的全排列:

如何描述算法运行的过程?回溯算法核心函数 backtrack 就好像递归树上的一个指针,它在遍历整棵递归树,收集所有叶子节点上的全排列结果。

结合我的可视化面板,可以很直观地看到这个过程。下面这个面板是全排列算法运行的第 260 步,递归树即将生长:

提示

你可以看到 backtrack 函数上有一个 @visualize status(track) 的注释,这意味着:

1、对这个 backtrack 函数开启递归树可视化功能,backtrack 指针在遍历这棵递归树,处于堆栈路径的树枝会加粗显示。

2、这棵递归树中节点的值是 track 列表的值。

实操

请你尝试点击可视化面板,让算法向后运行几步,理解回溯算法的递归树生长过程。

实操

请你尝试拉动进度条到最后,可以看到这棵递归树和前面那张图是相同的。

动态规划算法

再讲一下动态规划算法,我在讲解 动态规划算法核心框架 时画出了斐波那契问题的递归树,上层规模较大的问题逐渐被分解:

如何描述算法运行的过程?递归函数 fib 就好像递归树上的一个指针,它不断把原问题分解成子问题,当问题分解到 base case(叶子节点)时,开始逐层返回子问题的答案,推导出原问题的答案。

结合我的可视化面板,可以很直观地看到这个过程。下面这个面板是斐波那契算法运行的第 24 步,正在试图计算 fib(3) 的值:

提示

你可以看到 fib 函数上有一个 @visualize status(n) 的注释,这意味着:

1、对这个 fib 函数开启递归树可视化功能,fib 指针在遍历这棵递归树,处于堆栈路径的树枝会加粗显示。

2、fib 函数的「状态」是入参 n,所以递归树上节点的值就是 n 的值。

3、因为 fib 函数存在返回值,当函数结束时,返回值会反映到递归树的节点上。

实操

请你把鼠标移动到节点 (2) 上,可以看到 fib(2) = 1,这说明 fib(2) 的值已经被计算出来了。

而处在递归路径上的 (5),(4),(3) 节点,它们的值还没被计算出来,你把鼠标移动上去也不会显示返回值。

实操

请你尝试点击可视化面板,让算法向后运行几步,理解动态规划算法的递归树生长过程。

实操

待到整棵递归树遍历完成之时,就是原问题 fib(5) 被计算出来之日,那时候整棵递归树上的所有节点都会显示返回值。

请你尝试拉动进度条到最后,看看这棵递归树的样子,并把鼠标移动到各个节点上,看看显示什么。

当然,我在 动态规划算法核心框架 中也说了,斐波那契问题严格来说并不是动态规划问题,但是很适合帮助大家理解分解问题的思路。

前面展示的斐波那契解法代码没有添加备忘录,是一个指数级时间复杂度的算法,现在我们来体验一下添加备忘录之后会对整棵递归树的生长产生什么影响。

实操

👇 请你拉动进度条到算法的最后,看看递归树长什么样子。

实操

👇 这是一个带备忘录优化的版本,请你拉动进度条到算法的最后,看看递归树长什么样子,和不带备忘录优化的递归树进行对比。

这样把整棵树可视化出来,你是不是就能很直观地理解动态规划通过备忘录消除重叠子问题的原理了?

场景练习题

下面我会结合具体的算法题目,向你提问,要求你利用可视化面板高效分析题目和算法的执行过程。我会给出问题的答案,你可以先自己思考,然后再看我的解答。

这部分主要用到的是可视化面板的「执行到指定代码位置」的功能:你可以点击代码的某一部分,然后算法就会执行到那里,并将执行过程可视化。这个功能用得好,可以大幅提升你对算法的理解

热身

首先,我带大家熟悉一下「执行到指定代码位置」的功能,在之前展示的单链表成环算法中,我其实已经带你用过了,我让你不断点击代码中的 if (slow === fast) 这一行,观看快板指针的追逐过程:

为什么点击这部分就可以展示快慢指针的追逐呢?因为执行这部分代码时,fast, slow 指针刚刚移动完毕,所以点击这里,可以看到它们最完整的移动过程。

在下面的练习中,请你着重思考代码执行到某一部分时算法的状态,这样你就能更好地利用可视化面板,精确地控制算法的执行。

场景一:二叉树的前中后序遍历

实操

下面这段代码是二叉树前中后序遍历,相信大家都不陌生。

请你点击播放按钮,查看这段代码的执行流程。播放时,上一步/下一步按钮将变成加速/减速按钮,用来调整播放速度。

提问

你可以看到,二叉树将会被可视化出来,root 变量就好像一个指针,在二叉树上游走。我希望你能够使用「执行到指定代码位置」的功能,点击代码中的合适位置,精确控制 root 指针的移动顺序。

1、把面板复位,让 root 指针按照前序顺序访问整棵二叉树,即 root 指针按照 1,2,4,3,5,6 的顺序遍历二叉树。

2、把面板复位,root 指针按照中序顺序访问整棵二叉树,即 root 指针按照 2,4,1,5,3,6 的顺序遍历二叉树。

3、把面板复位,root 指针按照后序顺序访问整棵二叉树,即 root 指针按照 4,2,5,6,3,1 的顺序遍历二叉树。

答案

这几个问题不难:

1、不断点击 preorderResult.push(root.val) 这部分代码。

2、不断点击 inorderResult.push(root.val) 这部分代码。

3、不断点击 postorderResult.push(root.val) 这部分代码。

场景二:观察递归树的生长

提问

👇 这是刚才讲到的不带备忘录的斐波那契算法,现在请结合你对递归树的理解,点击代码中的合适位置,使得每点击一次,递归树就向下生长一个节点,直到递归树完全长成。

答案

你可以不断点击代码中 if (n < 2) 这一行,观察递归树的生长过程。

为什么点击这部分就可以展示递归树的生长过程呢?因为这部分代码是递归函数的 base case,每次递归必然都会执行这段代码,而递归树的每个节点其实就对应着每次递归,所以点击这个 if 语句就可以看到递归树上节点的生长过程。

这是一个非常常用的技巧,可以用来观察递归过程。

场景三:快速获得一个穷举结果

提问一

👇 这是前文展示的全排列算法的可视化面板,在场景二中你观察了斐波那契问题的递归树生长,现在请你点击代码中的合适位置,观察全排列问题的递归树生长。

答案一

这个很简单,不断点击 if (track.length === nums.length) 这部分代码即可观察递归树的生长。

提问二

将面板复位。

现在,我不想看递归树一个一个节点地生长了,没什么意思,我希望你点击代码中的合适位置,每点击一次,递归树就生长出一条「树枝」。

因为每条「树枝」的末端是叶子节点,而叶子节点就是一个全排列结果,我想快速获得一个全排列结果。

答案二

不断点击 [...track] 这部分代码,每次点击,回溯树都将生长出一条「树枝」。

因为叶子节点其实就是递归树结束生长的节点,即 backtrack 函数的 base case。那么我们只要点击 base case 中的代码,就可以直接跳到递归树的叶子节点,也就相当于生长出了一条树枝。

场景四:理解自顶向下/自底向上两种思维模式

提问

我在 动态规划核心框架 中讲过,自顶向下用 memo 备忘录的递归解法和自底向上用 dp 数组的迭代解法本质上是一样的,dp 数组中的值和 memo 备忘录中的值完全一样,两种解法可以互相转化。

下面我将同时给你自顶向下的递归解法和自底向上的迭代解法,请你完成如下题目:

1、在自顶向下递归解法的面板上,点击代码的合适位置,使得每次点击都在 memo 中更新一个元素。

2、在自底向上迭代解法的面板上,点击代码的合适位置,使得每次点击都在 dp 中更新一个元素。

3、对比两个解法中 dp 数组和 memo 数组的更新,理解自顶向下的递归解法和自底向上的迭代解法本质上是相同的。

答案

1、在自顶向下递归解法的面板上,每次点击 memo[n] = dp(memo, n - 1) + dp(memo, n - 2) 这部分代码,都会在 memo 数组中更新一个元素。

2、在自底向上迭代解法的面板上,每次点击 dp[i] = dp[i - 1] + dp[i - 2] 这部分代码,都会在 dp 数组中更新一个元素。

3、结合可视化面板应该不难理解,不过你会发现 memo[1]dp[1] 的值不一样,那是因为我们把 dp[1] 作为 base case 设置了初始值,而递归解法中的 base case 是直接 return 的,并不需要设置在 memo 中。

共同维护高质量学习环境,评论礼仪见这里,违者直接拉黑不解释