diff --git a/README.md b/README.md index f8c6d131700426db9db8989224c23049a78df333..86e1618228738ef577b83e9c838b83966276bdd9 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ GitHub Pages 地址:https://labuladong.github.io/algo/ * [动态规划解题框架](动态规划系列/动态规划详解进阶.md) * [动态规划答疑篇](动态规划系列/最优子结构.md) * [回溯算法解题框架](算法思维系列/回溯算法详解修订版.md) + * [提高刷题幸福感的小技巧](技术/刷题技巧.md) * [为了学会二分查找,我写了首诗](算法思维系列/二分查找详解.md) * [滑动窗口解题框架](算法思维系列/滑动窗口技巧.md) * [双指针技巧解题框架](算法思维系列/双指针技巧.md) @@ -80,6 +81,7 @@ GitHub Pages 地址:https://labuladong.github.io/algo/ * [动态规划答疑篇](动态规划系列/最优子结构.md) * [动态规划设计:最长递增子序列](动态规划系列/动态规划设计:最长递增子序列.md) * [编辑距离](动态规划系列/编辑距离.md) + * [经典动态规划:0-1 背包问题](动态规划系列/背包问题.md) * [经典动态规划问题:高楼扔鸡蛋](动态规划系列/高楼扔鸡蛋问题.md) * [经典动态规划问题:高楼扔鸡蛋(进阶)](动态规划系列/高楼扔鸡蛋进阶.md) * [动态规划之子序列问题解题模板](动态规划系列/子序列问题模板.md) diff --git a/pictures/souyisou.png b/pictures/souyisou.png index 15372ce6f6c5922d136eaf5625f044283b7be4e0..94d9f67c1eca710cb79d7c0650b0a78aec8ee380 100644 Binary files a/pictures/souyisou.png and b/pictures/souyisou.png differ diff --git "a/\345\212\250\346\200\201\350\247\204\345\210\222\347\263\273\345\210\227/\350\203\214\345\214\205\351\227\256\351\242\230.md" "b/\345\212\250\346\200\201\350\247\204\345\210\222\347\263\273\345\210\227/\350\203\214\345\214\205\351\227\256\351\242\230.md" new file mode 100644 index 0000000000000000000000000000000000000000..69de7f90c0c9593e2ddeb0c44a02cb46f4922940 --- /dev/null +++ "b/\345\212\250\346\200\201\350\247\204\345\210\222\347\263\273\345\210\227/\350\203\214\345\214\205\351\227\256\351\242\230.md" @@ -0,0 +1,160 @@ +# 动态规划之背包问题 + + +

+GitHub + + + +

+ +![](../pictures/souyisou.png) + +相关推荐: + * [经典动态规划:最长公共子序列](https://labuladong.gitee.io/algo/) + * [特殊数据结构:单调栈](https://labuladong.gitee.io/algo/) + +**-----------** + +本文有视频版:[0-1背包问题详解](https://www.bilibili.com/video/BV15B4y1P7X7/) + +后台天天有人问背包问题,这个问题其实不难啊,如果我们号动态规划系列的十几篇文章你都看过,借助框架,遇到背包问题可以说是手到擒来好吧。无非就是状态 + 选择,也没啥特别之处嘛。 + +今天就来说一下背包问题吧,就讨论最常说的 0-1 背包问题。描述: + +给你一个可装载重量为 `W` 的背包和 `N` 个物品,每个物品有重量和价值两个属性。其中第 `i` 个物品的重量为 `wt[i]`,价值为 `val[i]`,现在让你用这个背包装物品,最多能装的价值是多少? + +举个简单的例子,输入如下: + +``` +N = 3, W = 4 +wt = [2, 1, 3] +val = [4, 2, 3] +``` + +算法返回 6,选择前两件物品装进背包,总重量 3 小于 `W`,可以获得最大价值 6。 + +题目就是这么简单,一个典型的动态规划问题。这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。这就是 0-1 背包这个名词的来历。 + +解决这个问题没有什么排序之类巧妙的方法,只能穷举所有可能,根据我们 [动态规划详解](https://labuladong.gitee.io/algo/) 中的套路,直接走流程就行了。 + +### 动规标准套路 + +看来我得每篇动态规划文章都得重复一遍套路,历史文章中的动态规划问题都是按照下面的套路来的。 + +**第一步要明确两点,「状态」和「选择」**。 + +先说状态,如何才能描述一个问题局面?只要给几个物品和一个背包的容量限制,就形成了一个背包问题呀。**所以状态有两个,就是「背包的容量」和「可选择的物品」**。 + +再说选择,也很容易想到啊,对于每件物品,你能选择什么?**选择就是「装进背包」或者「不装进背包」嘛**。 + +明白了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完事儿了: + +```python +for 状态1 in 状态1的所有取值: + for 状态2 in 状态2的所有取值: + for ... + dp[状态1][状态2][...] = 择优(选择1,选择2...) +``` + +PS:此框架出自历史文章 [团灭 LeetCode 股票问题](https://labuladong.gitee.io/algo/)。 + +**第二步要明确 `dp` 数组的定义**。 + +首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维 `dp` 数组。 + +`dp[i][w]` 的定义如下:对于前 `i` 个物品,当前背包的容量为 `w`,这种情况下可以装的最大价值是 `dp[i][w]`。 + +比如说,如果 `dp[3][5] = 6`,其含义为:对于给定的一系列物品中,若只对前 3 个物品进行选择,当背包容量为 5 时,最多可以装下的价值为 6。 + +PS:为什么要这么定义?便于状态转移,或者说这就是套路,记下来就行了。建议看一下我们的动态规划系列文章,几种套路都被扒得清清楚楚了。 + +根据这个定义,我们想求的最终答案就是 `dp[N][W]`。base case 就是 `dp[0][..] = dp[..][0] = 0`,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0。 + +细化上面的框架: + +```python +int[][] dp[N+1][W+1] +dp[0][..] = 0 +dp[..][0] = 0 + +for i in [1..N]: + for w in [1..W]: + dp[i][w] = max( + 把物品 i 装进背包, + 不把物品 i 装进背包 + ) +return dp[N][W] +``` + +**第三步,根据「选择」,思考状态转移的逻辑**。 + +简单说就是,上面伪码中「把物品 `i` 装进背包」和「不把物品 `i` 装进背包」怎么用代码体现出来呢? + +这就要结合对 `dp` 数组的定义,看看这两种选择会对状态产生什么影响: + +先重申一下刚才我们的 `dp` 数组的定义: + +`dp[i][w]` 表示:对于前 `i` 个物品,当前背包的容量为 `w` 时,这种情况下可以装下的最大价值是 `dp[i][w]`。 + +**如果你没有把这第 `i` 个物品装入背包**,那么很显然,最大价值 `dp[i][w]` 应该等于 `dp[i-1][w]`,继承之前的结果。 + +**如果你把这第 `i` 个物品装入了背包**,那么 `dp[i][w]` 应该等于 `dp[i-1][w - wt[i-1]] + val[i-1]`。 + +首先,由于 `i` 是从 1 开始的,所以 `val` 和 `wt` 的索引是 `i-1` 时表示第 `i` 个物品的价值和重量。 + +而 `dp[i-1][w - wt[i-1]]` 也很好理解:你如果装了第 `i` 个物品,就要寻求剩余重量 `w - wt[i-1]` 限制下的最大价值,加上第 `i` 个物品的价值 `val[i-1]`。 + +综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码: + +```python +for i in [1..N]: + for w in [1..W]: + dp[i][w] = max( + dp[i-1][w], + dp[i-1][w - wt[i-1]] + val[i-1] + ) +return dp[N][W] +``` + +**最后一步,把伪码翻译成代码,处理一些边界情况**。 + +我用 C++ 写的代码,把上面的思路完全翻译了一遍,并且处理了 `w - wt[i-1]` 可能小于 0 导致数组索引越界的问题: + +```cpp +int knapsack(int W, int N, vector& wt, vector& val) { + // base case 已初始化 + vector> dp(N + 1, vector(W + 1, 0)); + for (int i = 1; i <= N; i++) { + for (int w = 1; w <= W; w++) { + if (w - wt[i-1] < 0) { + // 这种情况下只能选择不装入背包 + dp[i][w] = dp[i - 1][w]; + } else { + // 装入或者不装入背包,择优 + dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1], + dp[i - 1][w]); + } + } + } + + return dp[N][W]; +} +``` + +至此,背包问题就解决了,相比而言,我觉得这是比较简单的动态规划问题,因为状态转移的推导比较自然,基本上你明确了 `dp` 数组的定义,就可以理所当然地确定状态转移了。 + +接下来请阅读: + +* [背包问题变体之子集分割](https://labuladong.gitee.io/algo/) +* [完全背包问题之零钱兑换](https://labuladong.gitee.io/algo/) + +**_____________** + +**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章**。 + +**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode**。 + +

+ +

\ No newline at end of file diff --git "a/\346\212\200\346\234\257/\345\210\267\351\242\230\346\212\200\345\267\247.md" "b/\346\212\200\346\234\257/\345\210\267\351\242\230\346\212\200\345\267\247.md" new file mode 100644 index 0000000000000000000000000000000000000000..f436638691a7f43a88cd17794abbe6b135e63432 --- /dev/null +++ "b/\346\212\200\346\234\257/\345\210\267\351\242\230\346\212\200\345\267\247.md" @@ -0,0 +1,155 @@ +# 刷题小技巧 + + +

+GitHub + + + +

+ +![](../pictures/souyisou.png) + +相关推荐: + * [一文解决三道区间问题](https://labuladong.gitee.io/algo/) + * [Union-Find算法详解](https://labuladong.gitee.io/algo/) + +**-----------** + +相信每个人都有过被代码的小 bug 搞得心态爆炸的经历,本文分享一个我最常用的简单技巧,可以大幅提升刷题的幸福感。 + +在这之前,首先回答一个问题,刷力扣题是直接在网页上刷比较好还是在本地 IDE 上刷比较好? + +如果是牛客网笔试那种自己处理输入输出的判题形式,一定要在 IDE 上写,这个没啥说的,但**像力扣这种判题形式,我个人偏好直接在网页上刷**,原因有二: + +**1、方便** + +因为力扣有的数据结构是自定的,比如说 `TreeNode`,`ListNode` 这种,在本地你还得把这个类 copy 过去。 + +而且在 IDE 上没办法测试,写完代码之后还得粘贴到网页上跑测试数据,那还不如直接网页上写呢。 + +算法又不是工程代码,量都比较小,IDE 的自动补全带来的收益基本可以忽略不计。 + +**2、实用** + +到时候面试的时候,面试官给你出的算法题大都是希望你直接在网页上完成的,最好是边写边讲你的思路。 + +如果平时练习的时候就习惯没有 IDE 的自动补全,习惯手写代码大脑编译,到时候面试的时候写代码就能更快更从容。 + +之前我面快手的时候,有个面试官让我 [实现 LRU 算法](https://labuladong.gitee.io/algo/),我直接把双链表的实现、哈希链表的实现,在网页上全写出来了,而且一次无 bug 跑通,可以看到面试官惊讶的表情😂 + +我秋招能当 offer 收割机,很大程度上就是因为手写算法这一关超出面试官的预期,其实都是因为之前在网页上刷题练出来的。 + +接下来分享我觉得最常实用的干货技巧。 + +### 如何给算法 debug + +代码的错误时无法避免的,有时候可能整个思路都错了,有时候可能是某些细节问题,比如 `i` 和 `j` 写反了,这种问题怎么排查? + +我想一般的算法问题肯定不难排查,肉眼检查应该都没啥问题,再不济 `print` 打印一些关键变量的值,总能发现问题。 + +**比较让人头疼的的应该是递归算法的问题排查**。 + +如果没有一定的经验,函数递归的过程很难被正确理解,所以这里就重点讲讲如何高效 debug 递归算法。 + +有的读者可能会说,把算法 copy 到 IDE 里面,然后打断点一步步跟着走不就行了吗? + +这个方法肯定是可以的,但是之前的文章多次说过,递归函数最好从一个全局的角度理解,而不要跳进具体的细节。 + +如果你对递归还不够熟悉,没有一个全局的视角,这种一步步打断点的方式也容易把人绕进去。 + +**我的建议是直接在递归函数内部打印关键值,配合缩进,直观地观察递归函数执行情况**。 + +最能提升我们 debug 效率的是缩进,除了解法函数,我们新定义一个函数 `printIndent` 和一个全局变量 `count`: + +```cpp +// 全局变量,记录递归函数的递归层数 +int count = 0; + +// 输入 n,打印 n 个 tab 缩进 +void printIndent(int n) { + for (int i = 0; i < n; i++) { + printf(" "); + } +} +``` + +接下来,套路来了: + +**在递归函数的开头,调用 `printIndent(count++)` 并打印关键变量;然后在所有 `return` 语句之前调用 `printIndent(--count)` 并打印返回值**。 + +举个具体的例子,比如说上篇文章 [练琴时悟出的一个动态规划算法](https://labuladong.gitee.io/algo/) 中实现了一个递归的 `dp` 函数,大致的结构如下: + +```cpp +int dp(string& ring, int i, string& key, int j) { + /* base case */ + if (j == key.size()) { + return 0; + } + + /* 状态转移 */ + int res = INT_MAX; + for (int k : charToIndex[key[j]]) { + res = min(res, dp(ring, j, key, i + 1)); + } + + return res; +} +``` + +这个递归的 `dp` 函数在我进行了 debug 之后,变成了这样: + +```cpp +int count = 0; +void printIndent(int n) { + for (int i = 0; i < n; i++) { + printf(" "); + } +} + +int dp(string& ring, int i, string& key, int j) { + // printIndent(count++); + // printf("i = %d, j = %d\n", i, j); + + if (j == key.size()) { + // printIndent(--count); + // printf("return 0\n"); + return 0; + } + + int res = INT_MAX; + for (int k : charToIndex[key[j]]) { + res = min(res, dp(ring, j, key, i + 1)); + } + + // printIndent(--count); + // printf("return %d\n", res); + return res; +} +``` + +**就是在函数开头和所有 `return` 语句对应的地方加上一些打印代码**。 + +如果去掉注释,执行一个测试用例,输出如下: + +![](../pictures/刷题技巧/1.jpg) + +这样,我们通过对比对应的缩进就能知道每次递归时输入的关键参数 `i, j` 的值,以及每次递归调用返回的结果是多少。 + +**最重要的是,这样可以比较直观地看出递归过程,你有没有发现这就是一棵递归树**? + +![](../pictures/刷题技巧/2.jpg) + +前文 [动态规划套路详解](https://labuladong.gitee.io/algo/) 说过,理解递归函数最重要的就是画出递归树,这样打印一下,连递归树都不用自己画了,而且还能清晰地看出每次递归的返回值。 + +**可以说,这是对刷题「幸福感」提升最大的一个小技巧,比 IDE 打断点要高效**。 + +好了,本文分享就到这里,马上快过年了,估计大家都无心学习了,不过刷题还是要坚持的,这就叫弯道超车,顺便实践一下这个技巧。 + +如果本文对你有帮助,点个在看,就会被推荐更多相似文章。 + +**_____________** + +**《labuladong 的算法小抄》已经出版,关注公众号「labuladong」查看详情;后台回复关键词「进群」可加入算法群,回复题号获取对应的文章**: + +![](../pictures/souyisou2.png) \ No newline at end of file diff --git "a/\346\225\260\346\215\256\347\273\223\346\236\204\347\263\273\345\210\227/\344\272\214\345\217\211\346\240\221\347\263\273\345\210\2271.md" "b/\346\225\260\346\215\256\347\273\223\346\236\204\347\263\273\345\210\227/\344\272\214\345\217\211\346\240\221\347\263\273\345\210\2271.md" new file mode 100644 index 0000000000000000000000000000000000000000..721b653ca1c6ed49ca6e2c2c8a4a4a2548e78292 --- /dev/null +++ "b/\346\225\260\346\215\256\347\273\223\346\236\204\347\263\273\345\210\227/\344\272\214\345\217\211\346\240\221\347\263\273\345\210\2271.md" @@ -0,0 +1,329 @@ +# 东哥手把手带你刷二叉树(第一期) + + +

+GitHub + + + +

+ +![](../pictures/souyisou.png) + +相关推荐: + * [特殊数据结构:单调队列](https://labuladong.gitee.io/algo/) + * [一行代码就能解决的算法题](https://labuladong.gitee.io/algo/) + +读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目: + +[226.翻转二叉树(简单)](https://leetcode-cn.com/problems/invert-binary-tree) + +[114.二叉树展开为链表(中等)](https://leetcode-cn.com/problems/flatten-binary-tree-to-linked-list) + +[116.填充每个节点的下一个右侧节点指针(中等)](https://leetcode-cn.com/problems/populating-next-right-pointers-in-each-node) + +**-----------** + +我们公众号的成名之作 [学习数据结构和算法的框架思维](https://labuladong.gitee.io/algo/) 中多次强调,先刷二叉树的题目,先刷二叉树的题目,先刷二叉树的题目,因为很多经典算法,以及我们前文讲过的所有回溯、动归、分治算法,其实都是树的问题,而树的问题就永远逃不开树的递归遍历框架这几行破代码: + +```java +/* 二叉树遍历框架 */ +void traverse(TreeNode root) { + // 前序遍历 + traverse(root.left) + // 中序遍历 + traverse(root.right) + // 后序遍历 +} +``` + +上篇公众号文章让读者留言说说对什么问题还有疑惑,我接下来可以重点写一写相关的文章。结果还有很多读者说觉得「递归」非常难以理解,说实话,递归解法应该是最简单,最容易理解的才对,行云流水地写递归代码是学好算法的基本功,而二叉树相关的题目就是最练习递归基本功,最练习框架思维的。 + +我先花一些篇幅说明二叉树算法的重要性。 + +### 一、二叉树的重要性 + +举个例子,比如说我们的经典算法「快速排序」和「归并排序」,对于这两个算法,你有什么理解?**如果你告诉我,快速排序就是个二叉树的前序遍历,归并排序就是个二叉树的后序遍历,那么我就知道你是个算法高手了**。 + +为什么快速排序和归并排序能和二叉树扯上关系?我们来简单分析一下他们的算法思想和代码框架: + +快速排序的逻辑是,若要对 `nums[lo..hi]` 进行排序,我们先找一个分界点 `p`,通过交换元素使得 `nums[lo..p-1]` 都小于等于 `nums[p]`,且 `nums[p+1..hi]` 都大于 `nums[p]`,然后递归地去 `nums[lo..p-1]` 和 `nums[p+1..hi]` 中寻找新的分界点,最后整个数组就被排序了。 + +快速排序的代码框架如下: + +```java +void sort(int[] nums, int lo, int hi) { + /****** 前序遍历位置 ******/ + // 通过交换元素构建分界点 p + int p = partition(nums, lo, hi); + /************************/ + + sort(nums, lo, p - 1); + sort(nums, p + 1, hi); +} +``` + +先构造分界点,然后去左右子数组构造分界点,你看这不就是一个二叉树的前序遍历吗? + +再说说归并排序的逻辑,若要对 `nums[lo..hi]` 进行排序,我们先对 `nums[lo..mid]` 排序,再对 `nums[mid+1..hi]` 排序,最后把这两个有序的子数组合并,整个数组就排好序了。 + +归并排序的代码框架如下: + +```java +void sort(int[] nums, int lo, int hi) { + int mid = (lo + hi) / 2; + sort(nums, lo, mid); + sort(nums, mid + 1, hi); + + /****** 后序遍历位置 ******/ + // 合并两个排好序的子数组 + merge(nums, lo, mid, hi); + /************************/ +} +``` + +先对左右子数组排序,然后合并(类似合并有序链表的逻辑),你看这是不是二叉树的后序遍历框架?另外,这不就是传说中的分治算法嘛,不过如此呀。 + +如果你一眼就识破这些排序算法的底细,还需要背这些算法代码吗?这不是手到擒来,从框架慢慢扩展就能写出算法了。 + +说了这么多,旨在说明,二叉树的算法思想的运用广泛,甚至可以说,只要涉及递归,都可以抽象成二叉树的问题,**所以本文和后续的 [手把手带你刷二叉树(第二期)](https://labuladong.gitee.io/algo/) 以及 [手把手刷二叉树(第三期)](https://labuladong.gitee.io/algo/),我们直接上几道比较有意思,且能体现出递归算法精妙的二叉树题目,东哥手把手教你怎么用算法框架搞定它们**。 + +### 二、写递归算法的秘诀 + +我们前文 [二叉树的最近公共祖先](https://labuladong.gitee.io/algo/) 写过,**写递归算法的关键是要明确函数的「定义」是什么,然后相信这个定义,利用这个定义推导最终结果,绝不要跳入递归的细节**。 + +怎么理解呢,我们用一个具体的例子来说,比如说让你计算一棵二叉树共有几个节点: + +```java +// 定义:count(root) 返回以 root 为根的树有多少节点 +int count(TreeNode root) { + // base case + if (root == null) return 0; + // 自己加上子树的节点数就是整棵树的节点数 + return 1 + count(root.left) + count(root.right); +} +``` + +这个问题非常简单,大家应该都会写这段代码,`root` 本身就是一个节点,加上左右子树的节点数就是以 `root` 为根的树的节点总数。 + +左右子树的节点数怎么算?其实就是计算根为 `root.left` 和 `root.right` 两棵树的节点数呗,按照定义,递归调用 `count` 函数即可算出来。 + +**写树相关的算法,简单说就是,先搞清楚当前 `root` 节点该做什么,然后根据函数定义递归调用子节点**,递归调用会让孩子节点做相同的事情。 + +我们接下来看几道算法题目实操一下。 + +### 三、算法实践 + +**第一题、翻转二叉树** + +我们先从简单的题开始,看看力扣第 226 题「翻转二叉树」,输入一个二叉树根节点 `root`,让你把整棵树镜像翻转,比如输入的二叉树如下: + +```python + 4 + / \ + 2 7 + / \ / \ +1 3 6 9 +``` + +算法原地翻转二叉树,使得以 `root` 为根的树变成: + +```python + 4 + / \ + 7 2 + / \ / \ +9 6 3 1 +``` + +通过观察,**我们发现只要把二叉树上的每一个节点的左右子节点进行交换,最后的结果就是完全翻转之后的二叉树**。 + +可以直接写出解法代码: + +```java +// 将整棵树的节点翻转 +TreeNode invertTree(TreeNode root) { + // base case + if (root == null) { + return null; + } + + /**** 前序遍历位置 ****/ + // root 节点需要交换它的左右子节点 + TreeNode tmp = root.left; + root.left = root.right; + root.right = tmp; + + // 让左右子节点继续翻转它们的子节点 + invertTree(root.left); + invertTree(root.right); + + return root; +} +``` + +这道题目比较简单,关键思路在于我们发现翻转整棵树就是交换每个节点的左右子节点,于是我们把交换左右子节点的代码放在了前序遍历的位置。 + +值得一提的是,如果把交换左右子节点的代码放在后序遍历的位置也是可以的,但是放在中序遍历的位置是不行的,请你想一想为什么?这个应该不难想到,我会把答案置顶在公众号留言区。 + +首先讲这道题目是想告诉你,**二叉树题目的一个难点就是,如何把题目的要求细化成每个节点需要做的事情**。 + +这种洞察力需要多刷题训练,我们看下一道题。 + +**第二题、填充二叉树节点的右侧指针** + +这是力扣第 116 题,看下题目: + +![](../pictures/二叉树系列/title1.png) + +```java +Node connect(Node root); +``` + +题目的意思就是把二叉树的每一层节点都用 `next` 指针连接起来: + +![](../pictures/二叉树系列/1.png) + +而且题目说了,输入是一棵「完美二叉树」,形象地说整棵二叉树是一个正三角形,除了最右侧的节点 `next` 指针会指向 `null`,其他节点的右侧一定有相邻的节点。 + +这道题怎么做呢?把每一层的节点穿起来,是不是只要把每个节点的左右子节点都穿起来就行了? + +我们可以模仿上一道题,写出如下代码: + +```java +Node connect(Node root) { + if (root == null || root.left == null) { + return root; + } + + root.left.next = root.right; + + connect(root.left); + connect(root.right); + + return root; +} +``` + +这样其实有很大问题,再看看这张图: + +![](../pictures/二叉树系列/1.png) + +节点 5 和节点 6 不属于同一个父节点,那么按照这段代码的逻辑,它俩就没办法被穿起来,这是不符合题意的。 + +回想刚才说的,**二叉树的问题难点在于,如何把题目的要求细化成每个节点需要做的事情**,但是如果只依赖一个节点的话,肯定是没办法连接「跨父节点」的两个相邻节点的。 + +那么,我们的做法就是增加函数参数,一个节点做不到,我们就给他安排两个节点,「将每一层二叉树节点连接起来」可以细化成「将每两个相邻节点都连接起来」: + +```java +// 主函数 +Node connect(Node root) { + if (root == null) return null; + connectTwoNode(root.left, root.right); + return root; +} + +// 辅助函数 +void connectTwoNode(Node node1, Node node2) { + if (node1 == null || node2 == null) { + return; + } + /**** 前序遍历位置 ****/ + // 将传入的两个节点连接 + node1.next = node2; + + // 连接相同父节点的两个子节点 + connectTwoNode(node1.left, node1.right); + connectTwoNode(node2.left, node2.right); + // 连接跨越父节点的两个子节点 + connectTwoNode(node1.right, node2.left); +} +``` + +这样,`connectTwoNode` 函数不断递归,可以无死角覆盖整棵二叉树,将所有相邻节点都连接起来,也就避免了我们之前出现的问题,这道题就解决了。 + +**第三题、将二叉树展开为链表** + +这是力扣第 114 题,看下题目: + +![](../pictures/二叉树系列/title2.png) + +函数签名如下: + +```java +void flatten(TreeNode root); +``` + +我们尝试给出这个函数的定义: + +**给 `flatten` 函数输入一个节点 `root`,那么以 `root` 为根的二叉树就会被拉平为一条链表**。 + +我们再梳理一下,如何按题目要求把一棵树拉平成一条链表?很简单,以下流程: + +1、将 `root` 的左子树和右子树拉平。 + +2、将 `root` 的右子树接到左子树下方,然后将整个左子树作为右子树。 + +![](../pictures/二叉树系列/2.jpeg) + +上面三步看起来最难的应该是第一步对吧,如何把 `root` 的左右子树拉平?其实很简单,按照 `flatten` 函数的定义,对 `root` 的左右子树递归调用 `flatten` 函数即可: + +```java +// 定义:将以 root 为根的树拉平为链表 +void flatten(TreeNode root) { + // base case + if (root == null) return; + + flatten(root.left); + flatten(root.right); + + /**** 后序遍历位置 ****/ + // 1、左右子树已经被拉平成一条链表 + TreeNode left = root.left; + TreeNode right = root.right; + + // 2、将左子树作为右子树 + root.left = null; + root.right = left; + + // 3、将原先的右子树接到当前右子树的末端 + TreeNode p = root; + while (p.right != null) { + p = p.right; + } + p.right = right; +} +``` + +你看,这就是递归的魅力,你说 `flatten` 函数是怎么把左右子树拉平的?说不清楚,但是只要知道 `flatten` 的定义如此,相信这个定义,让 `root` 做它该做的事情,然后 `flatten` 函数就会按照定义工作。另外注意递归框架是后序遍历,因为我们要先拉平左右子树才能进行后续操作。 + +至此,这道题也解决了,我们旧文 [k个一组翻转链表](https://labuladong.gitee.io/algo/) 的递归思路和本题也有一些类似。 + +### 四、最后总结 + +递归算法的关键要明确函数的定义,相信这个定义,而不要跳进递归细节。 + +写二叉树的算法题,都是基于递归框架的,我们先要搞清楚 `root` 节点它自己要做什么,然后根据题目要求选择使用前序,中序,后续的递归框架。 + +二叉树题目的难点在于如何通过题目的要求思考出每一个节点需要做什么,这个只能通过多刷题进行练习了。 + +如果本文讲的三道题对你有一些启发,请三连,数据好的话东哥下次再来一波手把手刷题文,你会发现二叉树的题真的是越刷越顺手,欲罢不能,恨不得一口气把二叉树的题刷通。 + +接下来请阅读: + +* [手把手刷二叉树(第二期)](https://labuladong.gitee.io/algo/) +* [手把手刷二叉树(第三期)](https://labuladong.gitee.io/algo/) + + +**_____________** + +**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章**。 + +**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode**。 + +

+ +

+ + +======其他语言代码====== \ No newline at end of file