认识“分治”思想
本节我们要学习的两个排序算法都是对“分治”思想的应用。
“分治”,分而治之。其思想就是将一个大问题分解为若干个子问题,针对子问题分别求解后,再将子问题的解整合为大问题的解。
利用分治思想解决问题,我们一般分三步走:
- 分解子问题
- 求解每个子问题
- 合并子问题的解,得出大问题的解
下面我们一起来看看分治思想是如何帮助我们提升排序算法效率的。
归并排序
思路分析
归并排序是对分治思想的典型应用,它按照如下的思路对分治思想“三步走”的框架进行了填充:
- 分解子问题:将需要被排序的数组从中间分割为两半,然后再将分割出来的每个子数组各分割为两半,重复以上操作,直到单个子数组只有一个元素为止。
- 求解每个子问题:从粒度最小的子数组开始,两两合并、确保每次合并出来的数组都是有序的。(这里的“子问题”指的就是对每个子数组进行排序)。
- 合并子问题的解,得出大问题的解:当数组被合并至原有的规模时,就得到了一个完全排序的数组
真实排序过程演示
下面我们基于归并排序的思路,尝试对以下数组进行排序:
[8, 7, 6, 5, 4, 3, 2, 1]
首先重复地分割数组,整个分割过程如下:
首次分割,将数组整个对半分:
[8, 7, 6, 5,| 4, 3, 2, 1]
二次分割,将分割出的左右两个子数组各自对半分:
[8, 7,| 6, 5,| 4, 3,| 2, 1]
三次分割,四个子数组各自对半分后,每个子数组内都只有一个元素了:
[8,| 7,| 6,| 5,| 4,| 3,| 2,| 1]
接下来开始尝试解决每个子问题。将规模为1的子数组两两合并为规模为2的子数组,合并时确保有序,我们会得到这样的结果:
[7, 8,| 5, 6,| 3, 4,| 1, 2]
继续将规模为2的按照有序原则合并为规模为4的子数组:
[5, 6, 7, 8,| 1, 2, 3, 4]
最后将规模为4的子数组合并为规模为8的数组:
[1, 2, 3, 4, 5, 6, 7, 8]
整个数组就完全有序了。
编码实现
通过上面的讲解,我们可以总结出归并排序中的两个主要动作:
- 分割
- 合并
这两个动作是紧密关联的,分割是将大数组反复分解为一个一个的原子项,合并是将原子项反复地组装回原有的大数组。整个过程符合两个特征:
- 重复(令人想到递归或迭代)
- 有去有回(令人想到回溯,进而明确递归这条路)
因此,归并排序在实现上依托的就是递归思想。
除此之外,这里还涉及到另一个小小的知识点——两个有序数组的合并。合并有序数组是咱们在第 7 节讲过的一道真题,涉及到双指针法。此处强烈建议印象模糊的同学回头复习一下完整的解题思路。
编码实现
function mergeSort(arr) {
const len = arr.length
// 处理边界情况
if(len <= 1) {
return arr
}
// 计算分割点
const mid = Math.floor(len / 2)
// 递归分割左子数组,然后合并为有序数组
const leftArr = mergeSort(arr.slice(0, mid))
// 递归分割右子数组,然后合并为有序数组
const rightArr = mergeSort(arr.slice(mid,len))
// 合并左右两个有序数组
arr = mergeArr(leftArr, rightArr)
// 返回合并后的结果
return arr
}
function mergeArr(arr1, arr2) {
// 初始化两个指针,分别指向 arr1 和 arr2
let i = 0, j = 0
// 初始化结果数组
const res = []
// 缓存arr1的长度
const len1 = arr1.length
// 缓存arr2的长度
const len2 = arr2.length
// 合并两个子数组
while(i < len1 && j < len2) {
if(arr1[i] < arr2[j]) {
res.push(arr1[i])
i++
} else {
res.push(arr2[j])
j++
}
}
// 若其中一个子数组首先被合并完全,则直接拼接另一个子数组的剩余部分
if(i<len1) {
return res.concat(arr1.slice(i))
} else {
return res.concat(arr2.slice(j))
}
}
编码复盘——归并排序的时间复杂度分析
归并排序的时间复杂度的分析,同样是基于分治法。
基于数学计算的分析
我们假设规模为 n 的数组对应的排序的时间复杂度是一个关于 n 的函数 F(n)。那么它和自己的两个子数组之间就有如下关系:
F(n) = F(n/2) + F(n/2) + 合并两个数组的时间
合并两个数组的过程一共要对 n 个元素进行一轮循环,因此时间复杂度可以目测出来是 O(n),代入上面公式:
F(n) = F(n/2) + F(n/2) + O(n) = 2^1*T(n/2)+2^0*O(n)
继续细分,两个子数组被划分为四个子数组,仍然遵循上面公式所描述的关系。代入 n/4 后可以得到四个子数组和大数组之间的关系:
F(n/2) = 2*F(n/4)+O(n)
F(n) = 2*(2*F(n/4)+O(n))+O(n) = 2^2*F(n/4)+2^1*O(n)
这样不断划分下去,直到每个序列里只有一个数位置。对于规模为 n 的数组来说,需要划分的次数为 log(n),用 log(n) 替换掉上述公式中的2的次数,我们就可以得到归并排序的时间复杂度:
F(n) = nF(1) + O(nlog(n)) = O(nlog(n))
综上所述, 归并排序的时间复杂度是 O(nlog(n))。
基于逻辑的分析
如果上面的数学公式让你感到不友好,那么我们通过简单的逻辑估算,也可以得出归并排序的时间复杂度:
逻辑估算的核心思想是“抓主要矛盾”。我们可以回顾一下归并排序的代码:
function mergeSort(arr) {
const len = arr.length
// 处理边界情况
if(len <= 1) {
return arr
}
// 计算分割点
const mid = Math.floor(len / 2)
// 递归分割左子数组,然后合并为有序数组
const leftArr = mergeSort(arr.slice(0, mid))
// 递归分割右子数组,然后合并为有序数组
const rightArr = mergeSort(arr.slice(mid,len))
// 合并左右两个有序数组
arr = mergeArr(leftArr, rightArr)
// 返回合并后的结果
return arr
}
function mergeArr(arr1, arr2) {
// 初始化两个指针,分别指向 arr1 和 arr2
let i = 0, j = 0
// 初始化结果数组
const res = []
// 缓存arr1的长度
const len1 = arr1.length
// 缓存arr2的长度
const len2 = arr2.length
// 合并两个子数组
while(i < len1 && j < len2) {
if(arr1[i] < arr2[j]) {
res.push(arr1[i])
i++
} else {
res.push(arr2[j])
j++
}
}
// 若其中一个子数组首先被合并完全,则直接拼接另一个子数组的剩余部分
if(i<len1) {
return res.concat(arr1.slice(i))
} else {
return res.concat(arr2.slice(j))
}
}
我们把每一次切分+归并看做是一轮。对于规模为 n 的数组来说,需要切分 log(n) 次,因此就有 log(n) 轮。
每一轮中,切分动作都是小事情,只需要固定的几步:
// 计算分割点
const mid = Math.floor(len / 2)
// 递归分割左子数组,然后合并为有序数组
const leftArr = mergeSort(arr.slice(0, mid))
// 递归分割右子数组,然后合并为有序数组
const rightArr = mergeSort(arr.slice(mid,len))
因此单次切分对应的是常数级别的时间复杂度 O(1)。
再看合并,单次合并的时间复杂度为 O(n)。O(n) 和 O(1) 完全不在一个复杂度量级上,因此本着“抓主要矛盾”的原则,我们可以认为:决定归并排序时间复杂度的操作就是合并操作。
log(n) 轮对应 log(n) 次合并操作,因此归并排序的时间复杂度就是 O(nlog(n))。
以上两种时间复杂度的计算思路,大家理解其中一种即可,不必死磕。
快速排序
快速排序在基本思想上和归并排序是一致的,仍然坚持“分而治之”的原则不动摇。区别在于,快速排序并不会把真的数组分割开来再合并到一个新数组中去,而是直接在原有的数组内部进行排序。
思路分析
快速排序会将原始的数组筛选成较小和较大的两个子数组,然后递归地排序两个子数组。
这个描述对初学者来说可能会比较抽象,我们直接通过真实排序的过程来理解它:
真实排序过程演示
首先要做的事情就选取一个基准值。基准值的选择有很多方式,这里我们选取数组中间的值:
[5, 1, 3, 6, 2, 0, 7]
↑ 基准 ↑
左右指针分别指向数组的两端。接下来我们要做的,就是先移动左指针,直到找到一个不小于基准值的值为止;然后再移动右指针,直到找到一个不大于基准值的值为止。
首先我们来看左指针,5比6小,故左指针右移一位:
[5, 1, 3, 6, 2, 0, 7]
↑ 基准 ↑
继续对比,1比6小,继续右移左指针:
[5, 1, 3, 6, 2, 0, 7]
↑ 基准 ↑
继续对比,3比6小,继续右移左指针,左指针最终指向了基准值:
[5, 1, 3, 6, 2, 0, 7]
基准 ↑
↑
此时由于 6===6,左指针停止移动。开始看右指针:
右指针指向7,7>6,故左移右指针:
[5, 1, 3, 6, 2, 0, 7]
基准 ↑
↑
发现 0 比 6 小,停下来,交换 6 和 0,同时两个指针共同向中间走一步:
[5, 1, 3, 0, 2, 6, 7]
↑ 基准
↑
此时 2 比 6 小,故右指针不动,左指针继续前进:
[5, 1, 3, 0, 2, 6, 7]
↑ 基准
right↑
left
此时右指针所指的值不大于 6,左指针所指的值不小于 6,故两个指针都不再移动。此时我们会发现,对于左指针所指的数字来说,它左边的所有数字都比它小,右边的所有数字都比它大(这里注意也可能存在相等的情况)。由此我们就能够以左指针为轴心,划分出一左一右、一小一大两个子数组:
[5, 1, 3, 0, 2]
[6, 7]
针对两个子数组,重复执行以上操作,直到数组完全排序为止。这就是快速排序的整个过程。
编码实现
// 快速排序入口
function quickSort(arr, left = 0, right = arr.length - 1) {
// 定义递归边界,若数组只有一个元素,则没有排序必要
if(arr.length > 1) {
// lineIndex表示下一次划分左右子数组的索引位
const lineIndex = partition(arr, left, right)
// 如果左边子数组的长度不小于1,则递归快排这个子数组
if(left < lineIndex-1) {
// 左子数组以 lineIndex-1 为右边界
quickSort(arr, left, lineIndex-1)
}
// 如果右边子数组的长度不小于1,则递归快排这个子数组
if(lineIndex<right) {
// 右子数组以 lineIndex 为左边界
quickSort(arr, lineIndex, right)
}
}
return arr
}
// 以基准值为轴心,划分左右子数组的过程
function partition(arr, left, right) {
// 基准值默认取中间位置的元素
let pivotValue = arr[Math.floor(left + (right-left)/2)]
// 初始化左右指针
let i = left
let j = right
// 当左右指针不越界时,循环执行以下逻辑
while(i<=j) {
// 左指针所指元素若小于基准值,则右移左指针
while(arr[i] < pivotValue) {
i++
}
// 右指针所指元素大于基准值,则左移右指针
while(arr[j] > pivotValue) {
j--
}
// 若i<=j,则意味着基准值左边存在较大元素或右边存在较小元素,交换两个元素确保左右两侧有序
if(i<=j) {
swap(arr, i, j)
i++
j--
}
}
// 返回左指针索引作为下一次划分左右子数组的依据
return i
}
// 快速排序中使用 swap 的地方比较多,我们提取成一个独立的函数
function swap(arr, i, j) {
[arr[i], arr[j]] = [arr[j], arr[i]]
}
编码复盘——快速排序的时间复杂度分析
快速排序的时间复杂度的好坏,是由基准值来决定的。
- 最好时间复杂度:它对应的是这种情况——我们每次选择基准值,都刚好是当前子数组的中间数。这时,可以确保每一次分割都能将数组分为两半,进而只需要递归 log(n) 次。这时,快速排序的时间复杂度分析思路和归并排序相似,最后结果也是
O(nlog(n))。 - 最坏时间复杂度:每次划分取到的都是当前数组中的最大值/最小值。大家可以尝试把这种情况代入快排的思路中,你会发现此时快排已经退化为了冒泡排序,对应的时间复杂度是
O(n^2)。 - 平均时间复杂度:
O(nlog(n))
小结
经过两节的学习,大家已经掌握了前端算法面试中最常考、最关键的5种排序算法。对于已经学过的这些知识,希望大家课下多消化多反思,以“默写”为目标去反复熟悉每一个算法。
排序算法的学习,对于培养大家的时间效率敏感度、提升算法优化思维等方面是大有裨益的。在整个算法知识体系中,还有一些虽然不常考察,但同样有趣的排序算法,比如基数排序、桶排序、堆排序等等,在这里推荐学有余力、时间充裕的同学课下多读多看,在排序算法这个专题下更进一步。
大家加油!
动态规划方法论
动态规划是算法面试中的一个“大IP”,同时也是很多同学的心头痛。本节致力于用舒服的姿势帮助大家克服这块心病,因此开篇不能急于怼知识点,要先讲讲方法。
在笔者看来,对于动态规划的学习,最重要的是找到一个正确的学习切入点:如果你是一个对相关理论一无所知的初学者,自然不能急于一上来就生吞“模型”、“状态转移方程”等高端概念——大家谨记,动态规划是一种思想,所谓思想,就是非常好用,好用到爆的套路。我们学习一种思想,重要的是建立起对它的感性认知,而不是反复咀嚼那些对现在的你来说还非常生硬的文字概念——从抽象去理解抽象是意淫,从具体去理解抽象才是学习。
本节将会延续小册一贯的讲解风格:首先带大家一起解决一个实际的问题,然后逐步复盘问题的解决方案,最后从解决方案中提取出动态规划相关的概念、模型和技巧,实现对号入座。
从前面一系列章节的学习反馈中,笔者观察到一部分同学的阅读习惯非常“薄情”——打开小册只为做题,做完就溜,讲解部分基本是不看的。
这里想要提醒大家的是,题目本身不仅仅是命题点,更是素材、是教具,大家最终要关注到的还是题目背后的思想和方法。因此希望同学们能多给自己一点时间、多一些耐心去反刍和吸收知识。
从“爬楼梯”问题说起
题目描述:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
示例 2:
输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
- 1 阶 + 1 阶 + 1 阶
- 1 阶 + 2 阶
- 2 阶 + 1 阶
思路分析与编码实现
这道题目有两个关键的特征:
- 要求你给出达成某个目的的解法个数
- 不要求你给出每一种解法对应的具体路径
这样的问题,往往可以用动态规划进行求解(这个结论大家先记下来,后面我们会有很多验证它的机会)。
Step1:递归思想分析问题
基于动态规划的思想来做题,我们首先要想到的思维工具就是“倒着分析问题”。“倒着分析问题”分两步走:
- 定位到问题的终点
- 站在终点这个视角,思考后退的可能性
在这道题里,“问题的终点”指的就是走到第 n 阶楼梯这个目标对应的路径数,我们把它记为 f(n)。
那么站在第 n 阶楼梯这个视角, 有哪些后退的可能性呢?按照题目中的要求,一次只能后退 1 步或者 2 步。因此可以定位到从第 n 阶楼梯只能后退到第 n-1 或者第 n-2 阶。我们把抵达第 n-1 阶楼梯对应的路径数记为f(n-1),把抵达第 n-2 阶楼梯对应的路径数记为 f(n-2),不难得出以下关系:
f(n) = f(n-1) + f(n-2)
这个关系用树形结构表示会更加形象
现在我们不难看出,要想求出 f(n),必须求出f(n-1) 和f(n-2)(我们假设 n 是一个大于 5 的数字)。
接下来站在第 n-1 阶台阶上,思考后退的姿势,也无非只能是退到 n-1-1层台阶 或 n-1-2层台阶上,所以f(n-1) 和 f(n-2)、f(n-3)间同样具有以下关系:
f(n-1) = f(n-2) + f(n-3)
同理, f(n-2)也可以按照同样的规则进行拆分:
f(n-2) = f(n-3) + f(n-4)
现在我们的树结构渐渐丰满起来了:
随着拆分的进行,一定会有一个时刻,求解到了
f(1) 或 f(2)。按照题设规则,第 1 阶楼梯只能走 1 步抵达,第 2 阶楼梯可以走 1 步或者走 2 步抵达,因此我们不难得出 f(1) 和 f(2) 的值:
f(1) = 1
f(2) = 2
我们在学习递归与回溯思想的时候,曾经给大家强调过,遇到“树形思维模型”,就要想办法往递归上靠。这道题明显用到了树形思维模型,有着明确的重复内容(不断地按照 f(n) = f(n-1) + f(n-2)的规则拆分),同时有着明确的边界条件(遇到f(1)或f(2)就可以返回了),因此我们不难写出其对应的递归解法代码:
/**
* @param {number} n
* @return {number}
*/
const climbStairs = function(n) {
// 处理递归边界
if(n === 1) {
return 1
}
if(n === 2){
return 2
}
// 递归计算
return climbStairs(n-1) + climbStairs(n-2)
};
但是这个解法问题比较大,丢进 OJ 会直接超时。我们一起来看看原因,回到我们上面这张树形结构图上来:
这次我把
f(n-2) 和f(n-3)给标红了。大家不难看出,我们在图中对 f(n-2)和f(n-3) 进行了重复的计算。事实上,随着我们递归层级的加深,这个重复的问题会越来越严重:
(图上标红的均为发生过重复计算的结点)
Step2:记忆化搜索来提效
重复计算带来了时间效率上的问题,要想解决这类问题,最直接的思路就是用空间换时间,也就是想办法记住之前已经求解过的结果。这里我们只需要定义一个数组:
const f = []
每计算出一个 f(n) 的值,都把它塞进 f 数组里。下次要用到这个值的时候,直接取出来就行了:
/**
* @param {number} n
* @return {number}
*/
// 定义记忆数组 f
const f = []
const climbStairs = function(n) {
if(n==1) {
return 1
}
if(n==2) {
return 2
}
// 若f[n]不存在,则进行计算
if(f[n]===undefined) f[n] = climbStairs(n-1) + climbStairs(n-2)
// 若f[n]已经求解过,直接返回
return f[n]
};
以上这种在递归的过程中,不断保存已经计算出的结果,从而避免重复计算的手法,叫做记忆化搜索。
对于一些实用派的面试官来说,“记忆化搜索”和“动态规划”没有区别,它们都能够以不错的效率帮我们达到同样的目的。这种情况下,上面这个答案就足够了。
但是还有一部分面试官,比较讲究,善于咀嚼理论概念。他会告诉你记忆化搜索和动态规划是两个东西,别想糊弄哥,哥要的是动态规划的解法。
行吧,就给你动态规划的解法。
Step3:记忆化搜索转化为动态规划
要想完成记忆化搜索与动态规划之间的转化,首先要清楚两者间的区别。
先说记忆化搜索,记忆化搜索可以理解为优化过后的递归。递归往往可以基于树形思维模型来做,以这道题为例:
我们基于树形思维模型来解题时,实际上是站在了一个比较大的未知数量级(也就是最终的那个n),来不断进行拆分,最终拆回较小的已知数量级(f(1)、f(2))。这个过程是一个明显的自顶向下的过程。
动态规划则恰恰相反,是一个自底向上的过程。它要求我们站在已知的角度,通过定位已知和未知之间的关系,一步一步向前推导,进而求解出未知的值。
在这道题中,已知 f(1) 和 f(2) 的值,要求解未知的 f(n),我们唯一的抓手就是这个等价关系:
f(n) = f(n-1) + f(n-2)
以 f(1) 和 f(2) 为起点,不断求和,循环递增 n 的值,我们就能够求出f(n)了:
/**
* @param {number} n
* @return {number}
*/
const climbStairs = function(n) {
// 初始化状态数组
const f = [];
// 初始化已知值
f[1] = 1;
f[2] = 2;
// 动态更新每一层楼梯对应的结果
for(let i = 3;i <= n;i++){
f[i] = f[i-2] + f[i-1];
}
// 返回目标值
return f[n];
};
以上便是这道题的动态规划解法。
从题解思路看动态规划
下面我们基于这个题解的过程,站在专业的角度来重新认识一下动态规划。
前面咱们在排序专题学过“分治”思想,提到了“子问题”这个概念。分治问题的核心思想是:把一个问题分解为相互独立的子问题,逐个解决子问题后,再组合子问题的答案,就得到了问题的最终解。
动态规划的思想和“分治”有点相似。不同之处在于,“分治”思想中,各个子问题之间是独立的:比如说归并排序中,子数组之间的排序并不互相影响。而动态规划划分出的子问题,往往是相互依赖、相互影响的。
什么样的题应该用动态规划来做?我们要抓以下两个关键特征:
- 最优子结构
- 重叠子问题
拿这道题的分析过程来说:
最优子结构,它指的是问题的最优解包含着子问题的最优解——不管前面的决策如何,此后的状态必须是基于当前状态(由上次决策产生)的最优决策。就这道题来说,f(n)和f(n-1)、f(n-2)之间的关系印证了这一点(这玩意儿叫状态转移方程,大家记一下)。
重叠子问题,它指的是在递归的过程中,出现了反复计算的情况。就这道题来说,图上标红的一系列重复计算的结点印证了这一点。
因此,这道题适合用动态规划来做。
动态规划问题的分析技巧
现在,大家理解了动态规划的概念,明确了其“自底向上”的脑回路特征。但在实际做题过程中,“自底向上”分析问题往往不是最舒服的解题姿势,按照这个脑回路去想问题,容易拧巴。
什么姿势不拧巴?
递归!
你现在回过头去看看咱们前面递归+记忆化搜索那一通操作,你觉得拧巴吗?不拧巴!舒服不?相当舒服了——只要你掌握了递归与回溯,就不难分析出图上的树形思维模型和递归边界条件,树形思维模型将帮助我们更迅速地定位到状态转移关系,边界条件往往对应的就是已知子问题的解;基于树形思维模型,结合一下记忆化搜索,难么?不难,谁还不会初始化个记忆数组了呢;最后再把递归往迭代那么一转,答案不就有了么!
当然,咱们上面一通吹牛逼都只是为了衬托递归思路分析下来有多么爽,并不是说动态规划有多么简单。实际上,动态规划可复杂了,递归+记忆化搜索的思想只是帮助我们简化问题,但并不能送佛送到西。说到底,还是得靠我们自己。
动态规划到底复杂在什么地方,这里我先预告一下:
- 状态转移方程不好确定
- 已知的状态可能不明显
- 递归转迭代,一部分同学可能不知道怎么转(这个就是纯粹的编程基础问题了,多写多练哈)
多的也没法说了,大家后面慢慢体会吧:)。
总结一下,对于动态规划,笔者建议大家优先选择这样的分析路径:
- 递归思想明确树形思维模型:找到问题终点,思考倒退的姿势,往往可以帮助你更快速地明确状态间的关系
- 结合记忆化搜索,明确状态转移方程
- 递归代码转化为迭代表达(这一步不一定是必要的,1、2本身为思维路径,而并非代码实现。若你成长为熟手,2中分析出来的状态转移方程可以直接往循环里塞,根本不需要转换)。
“最值”型问题典范:如何优雅地找硬币
题目描述:给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例2:
输入: coins = [2], amount = 3
输出: -1
提示:最值问题是动态规划的常见对口题型,见到最值问题,应该想到动态规划
思路分析
现在思维工具已经给到大家了,详细的步骤我就不啰嗦了。我直接讲难点:这道题对于初学者来说,难的是状态转移方程的明确。
要明确状态转移关系,我们依然是借助“倒推”的思想:解决爬楼梯问题时,我们首先思考的是站在第 n 阶楼梯上的后退姿势。这道题也一样,我们需要思考的是站在 amount 这个组合结果上的“后退姿势”——
我们可以假装此时手里已经有了 36 美分,只是不清楚硬币的个数,把“如何凑到36”的问题转化为“如何从36减到0”的问题。
硬币的英文是 coin,因此我们这里用 c1、c2、c3......cn 分别来表示题目中给到我们的第 1-n 个硬币。现在我如果从 36 美分的总额中拿走一个硬币,那么有以下几种可能:
拿走 c1
拿走 c2
拿走 c3
......
拿走 cn
重复往前推导这个“拿走”的过程,我们可以得到以下的树形思维模型:
假如用 f(x)表示每一个总额数字对应的最少硬币数,那么我们可以得到以下的对应关系:
f(36) = Math.min(f(36-c1)+1,f(36-c2)+1,f(36-c3)+1......f(36-cn)+1)
这套对应关系,就是本题的状态转移方程。
找出了状态转移方程,我们接下来需要思考的是递归的边界条件:在什么情况下,我的“后退”(实际是做减法)可以停下来?这里需要考虑的是硬币总额为 0 的情况,这种情况对应的硬币个数毫无疑问也会是 0,因而不需要任何的回溯计算。由此我们就得到了一个已知的最基本的子问题的结果:
f[0] = 0
现在,明确了状态转移方程,明确了已知子问题的解,我们来写代码:
编码实现
const coinChange = function(coins, amount) {
// 用于保存每个目标总额对应的最小硬币个数
const f = []
// 提前定义已知情况
f[0] = 0
// 遍历 [1, amount] 这个区间的硬币总额
for(let i=1;i<=amount;i++) {
// 求的是最小值,因此我们预设为无穷大,确保它一定会被更小的数更新
f[i] = Infinity
// 循环遍历每个可用硬币的面额
for(let j=0;j<coins.length;j++) {
// 若硬币面额小于目标总额,则问题成立
if(i-coins[j]>=0) {
// 状态转移方程
f[i] = Math.min(f[i],f[i-coins[j]]+1)
}
}
}
// 若目标总额对应的解为无穷大,则意味着没有一个符合条件的硬币总数来更新它,本题无解,返回-1
if(f[amount]===Infinity) {
return -1
}
// 若有解,直接返回解的内容
return f[amount]
};
小结
经过本节的讲解,相信大家已经对动态规划的概念和通用解题模板有了掌握。但仅仅依靠这些,可能还不足以支撑起你全部的底气——动态规划问题千姿百态,有着繁多的题型分支。在下一节,我们就将围绕这些分支中考察频率最高的一部分,提取出通用的解题模型,帮助大家更进一步。
在上一节,我们掌握了求解动态规划问题的通用套路。但正如我们前面所说,通用思路存在一定的局限性——它提供给大家的毕竟是一种方向性的引导,至于能不能实实在在地用自己的双手解决掉一道具体的题目,更多还是看同学们自己的“造化”。
所谓“造化”,其实并不神秘,它就是指我们自己对题目和知识点的思考深度和吸收程度。“造化”是否到位,并不取决于天赋,而是取决于每一位同学自身对题目的量的积累和对解题技术的总结。
作为对通用解题套路的补充,本节笔者基于自己接触过的海量动态规划真题,结合个人对面试命题倾向的观察和思考,提取了两个学习性价比极高的重点解题模型。
模型可以帮助我们迅速地识别并解决掉一类问题,通过对重点模型进行学习,大家可以在实战中做到举一反N,大大提升我们做题的效率和专业程度。
0-1背包模型
0-1背包问题是一个基本问题,基于这个基本问题,可以衍生出千姿百态的变种问题,这种题目就非常适合拿来构造解题模型。
0-1背包问题说的是这么回事儿:
有 n 件物品,物品体积用一个名为 w 的数组存起来,物品的价值用一个名为 value 的数组存起来;每件物品的体积用 w[i] 来表示,每件物品的价值用 value[i] 来表示。现在有一个容量为 c 的背包,问你如何选取物品放入背包,才能使得背包内的物品总价值最大?
注意:每种物品都只有1件
思路分析
这道题如果全靠本能来做,相信不少同学会联想到“暴力枚举法”:暴力枚举每一件物品放或者不放进背包的情况。考虑到每一种物品都面临“放”和“不放”两种选择,因此 n 个物品就对应 2^n 种情况,进而会带来高达 O(2^n)的时间复杂度。这个时间复杂度是众多复杂度中相对来说比较恐怖的“指数量级”,我们是万万不能让这种东西出现在面试题解中的,因此果断放弃它。
现在我们放弃本能,回归理智,开始调度自己的智慧来做题:这道题最后问了“如何才能使背包内的物品总价值最大?”,我们前面讲过,遇到最值问题,一定要在可能的解题方案中给动态优化留下一席之地。事实上,背包系列问题,正是动态规划的标准对口问题。
下面我们基于通用解题思路来梳理一下这道题:
“倒推”法明确状态间关系
现在,假设背包已满,容量已经达到了 c。站在c这个容量终点往后退,考虑从中取出一样物品,那么可能被取出的物品就有 i 种可能性。我们现在尝试表达“取出一件”这个动作对应的变化,我用 f(i, c) 来表示前 i 件物品恰好装入容量为 c 的背包中所能获得的最大价值。现在假设我试图取出的物品是 i,那么只有两种可能:
- 第
i件物品在背包里 - 第
i件物品不在背包里
如果说本来这个背包中就没有 i 这个东西,那么尝试取之前和尝试取之后,背包中的价值总量是不会发生变化的。:
f(i, c) = f(i-1, c)
但如果背包中是有 i 的,那么取出这个动作就会带来价值量和体积量的减少:
f(i, c) - value[i] = f(i-1, c-w[i])
把这个减法关系稍微转化一下,变为加法关系:
f(i, c) = f(i-1, c-w[i]) + value[i]
可以看出,想要求出 f(i, c),我们只要定位到正确的 f(i-1, c) 和 f(i-1, c-w[i]) + value[i] 的值,并且取出两者中较大的值就可以了。如此,我们便明确出了这道题的状态转移关系。现在我们需要思考的是如何把这种关系用代码的形式表达出来。
首先,基于上面的分析,我们抽取出自变量和因变量:自变量是物品的索引(假设为i)和当前背包内物品的总体积(假设为 v),因变量是总价值。我们仍然是用一个数组来记忆不同状态下的总价值,考虑到这道题中存在两个自变量,我们需要开辟的是一个二维数组。现在我利用二维数组来将上述的状态关系编码化:
dp[i][v] = Math.max(dp[i-1][v], dp[i-1][v-w[i]] + c[i])
以上便是这道题对应的状态转移方程。你会发现我前面真没忽悠你——只要能够利用“倒推”法明确出状态转移关系,我们根本没有必要去构造一个完整而复杂的树形思维模型,直接把状态转移方程往循环里塞就行。
为什么我这么笃定?别忘了,动态规划的关键特性就是“最优子结构”(对这个概念感到模糊的同学,赶紧回到上一节去复习一下)。这道题符合最优子结构的特征——dp[i]只和它之前的状态dp[i-1]有关。最优子结构允许我们推导一次就知晓全局,就是这么爽。
现在我们来瞅瞅这个状态转移方程怎么往循环里塞才合适。仍然是从变量入手:变量是 i 和 v,但本质上来说 v 其实也是随着 i 的变化而变化的,因此我们可以在外层遍历i、在内层遍历 v。明白了这一点,我们就可以编码如下:
for(let i=1;i<=n;i++) {
for(let v=w[i]; v<=c;v++) {
dp[i][v] = Math.max(dp[i-1][v], dp[i-1][v-w[i]]+value[i])
}
}
现在,时间复杂度已经被我们优化到了 O(n)的水平,相当不错。但是空间复杂度其实还可以抢救一下。不过不着急,初学背包问题,我们先站在巩固思路的角度,重现一下这个二维数组的填充过程:
从图中我们可以看出,计算 dp[i][v] 的时候,其实只需要图中标红位置的数据就可以了(这与我们前面讲解过的最优子结构特性不谋而合),也就是说未标红的地方对于 dp[i][v] 的计算来说都属于冗余数据。实际上,对于第 i 行的计算来说,只有第 i-1 行的数据是有意义的,更早的数据它都不关心。也就是说我们其实根本不需要记录所有的数据,理论上只要保留当前行和上一行的数据就足够了。
一些教材或许会教你通过优化二位数组来节省空间上的开销,但这种手段在笔者看来无异于隔靴搔痒——要优化就优化到底,我们干脆把二维数组干掉,用一维数组来做。
插播小知识——滚动数组
这里要给大家介绍的是一种叫做“滚动数组”的编码思想——所谓“滚动数组”,顾名思义,就是让数组“滚动”起来:固定一块存储空间,滚动更新这块存储空间的内容,确保每个时刻空间内的数据都是当前真正会用到的最新数据,从而达到节约内存的效果,这种手段就叫做滚动数组。
用滚动数组来优化状态转移方程
我可以只定义一个一维数组,通过倒着遍历v的方法来实现数组的滚动更新:
for(let i=1;i<=n;i++) {
for(let v=c;v>=w[i];v--) {
dp[v] = Math.max(dp[v], d[v-w[i]]+value[i])
}
}
这个方法到底骚在哪里?它为什么可以实现数组的滚动更新?接下来咱们就一起来瞅瞅这个数组是怎么“滚”的:
拿第 i-1行和第i行来举例,首先我肯定是刷刷刷地用第 i-1 行的数据把一维数组给填满了(这里我保留了对关键计算线索的高亮):
接下来我尝试用第
i 行的数据更新它。当数据更新走到 dp[i][v] 这里的时候,dp[i-1][v] 和 dp[i-1][v-w[i]] 都是存在的状态(分别对应一维数组中现在的 dp[v]和dp[v-w[i]]的值,完全可以满足我们的计算需要:
当我们计算出
dp[i][v] 的值以后,dp[i-1][v-w[i]]可能还会在以后的计算中用到,但dp[i-1][v]其实已经完全用不到了(这个点对初学的同学来说可能会有点绕,不要慌,你品,你细品。注意这里dp[i][v]已经求解出来了,对于 i 这个索引来说只需要求解 dp[i][v-1]到dp[i][w[i]]之间的值,仔细想想,求解这些值是不是完全用不到dp[i-1][v]?)。
此时我们刚好用 dp[i][v] 去更新了 dp[v] 的值,把用不到的数据给及时地替换掉了,岂不美滋滋?
基于上面的分析,我们可以写出背包问题的完整求解代码:
// 入参是物品的个数和背包的容量上限,以及物品的重量和价值数组
function knapsack(n, c, w, value) {
// dp是动态规划的状态保存数组
const dp = (new Array(c+1)).fill(0)
// res 用来记录所有组合方案中的最大值
let res = -Infinity
for(let i=1;i<=n;i++) {
for(let v=c;v>=w[i];v--) {
// 写出状态转移方程
dp[v] = Math.max(dp[v], dp[v-w[i]] + value[i])
// 即时更新最大值
if(dp[v] > res) {
res = dp[v]
}
}
}
return res
}
最长上升子序列模型
该模型对口的其实是动态规划中一类非常经典的问题——“序列型动态规划”。在形态各异的序列型动态规划问题中,“最长上升子序列”问题可以说是相当热门的,其解法也是比较具有代表性的。接下来我们就以这个问题为抓手,提取其对应的的解题模型。
题目描述:给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
说明:
可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。 你算法的时间复杂度应该为 O(n^2) 。
进阶: 你能将算法的时间复杂度降低到 O(n log n) 吗?
思路分析
在做一道题之前,首先要知道题目到底在说啥,这里可能会对初学者构成理解障碍的关键字有两个:“子序列”和“上升”。
啥是“子序列”?它指的是在原有序列的基础上,删除0个或者多个数,其他数的顺序保持不变得到的结果。拿示例的这个序列来说:
[10,9,2,5,3,7,101,18]
随便拿掉0个数或多个数:比如说我去掉第一个数字10,去掉最后一个数18,但是不打乱这个序列的顺序,那么我得到的新的序列就是该序列的一个子序列:
[9,2,5,3,7,101]
啥是“上升”?这个就比较好理解了,它指的是排在后面的元素总是要大于排在前面的元素。
在上一节,我们曾经学习过一种“通用解题思路”。通用解题思路的核心,是利用递归思想,以“倒推”为抓手,快速地明确状态与状态间的关系。当我们面对一道题目,无从下手的时候,这个思维工具往往能够帮到我们很大的忙。
然而,事事无绝对,学习动态规划并不是掌握一个套路就可以高枕无忧的“一锤子买卖”。对于最长上升子序列问题来说,通用解题思路所描述的这种“自顶向下”的思维方式,并不好使。
事实上,做这道题,大家要把握住一个关键的特征,那就是“序列”。
这道题比较直白,直接把“序列”二字写在了题干里。后面我们会做到一些更有趣(同时也更拧巴)的题目,这些题目不会直接跟你说它是序列类型题目,但是通过对题干进行分析,你会发现它实际上仍然会涉及到对一个序列的遍历,并且序列中的每一个元素都能够对应到一个有利于求解出最终结果的状态值,这类题目也符合“序列”的特征。
对于序列类题目,我们并没有一套固定的解题模型可以直接套,一般来说只能见招拆招,针对不同的题型分支套不同的解法(所以确实需要大家有一定程度的题量上的积累)。解法虽有不同,背后的思想却是一而贯之的,那就是关注到序列中元素的索引,尝试寻找不同索引对应的元素之间的关系、并以索引为线索去构造一维或二维的状态数组。
拿“最长上升子序列”这个问题分支来说,这里我们关注到的就是“以序列中第 i 个元素为结尾的前i个元素的状态”。
我们用f(i)来表示前i个元素中最长上升子序列的长度。若想基于 f(i) 求解出 f(i+1),我们需要关注到的是第 i+1 个元素和前 i 个元素范围内的最长上升子序列的关系,它们之间的关系有两种可能:
- 若第
i+1个元素比前i个元素中某一个元素要大,此时我们就可以在这个元素所在的上升子序列的末尾追加第i+1个元素(延长原有的子序列),得到一个新的上升子序列。 - 若第
i+1个元素并不比前i个元素中所涵盖的最长上升子序列中的某一个元素大,则维持原状,子序列不延长。
这个过程形容起来可能比较抽象,下面我们用一个示例来理解它。
拿我们题目示例中的数组[10,9,2,5,3,7,101,18] 来举例。在算法的初始态,我们还没有进行任何的遍历和计算,此时对于每一个索引位来说,它都只与一个长度为1的子序列有关——那就是只有它自己存在的子序列。因此每一个索引位对应的状态初始值都是1:
同时对于索引位为0的元素来说,由于以它为结尾的子序列有且仅有 [10] 这一个,因此它的状态值时一开始就明确的,那就是1:
f(0) = 1
下面基于 f(0) 对 f(1) 求解,比较两个索引位上元素的大小:
发现9比10小,没办法延长原有的子序列,因此啥也不干。继续往下遍历,遇到了2,发现2比前两个数都小,仍然没法延长任何一个子序列,继续啥也不干。
再往下遍历,遇到了5,对比5和前面三个元素,发现它比2大,可以延长2所在的那个最长上升子序列,延长后,以 5 为结尾的最长上升子序列的长度就得到了更新:
重复上面这个“遍历新元素+回头看”的逻辑,直到整个数组被完全遍历,我们就能拿到以每一个索引位元素为结尾的最长上升子序列的长度值。从这些长度值中筛选出最大值,我们也就得到了问题的解。
我们基于这个思路进行编码:
编码实现
/**
* @param {number[]} nums
* @return {number}
*/
// 入参是一个数字序列
const lengthOfLIS = function(nums) {
// 缓存序列的长度
const len = nums.length
// 处理边界条件
if(!len) {
return 0
}
// 初始化数组里面每一个索引位的状态值
const dp = (new Array(len)).fill(1)
// 初始化最大上升子序列的长度为1
let maxLen = 1
// 从第2个元素开始,遍历整个数组
for(let i=1;i<len;i++) {
// 每遍历一个新元素,都要“回头看”,看看能不能延长原有的上升子序列
for(let j=0;j<i;j++) {
// 若遇到了一个比当前元素小的值,则意味着遇到了一个可以延长的上升子序列,故更新当前元素索引位对应的状态
if(nums[j]<nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1)
}
}
// 及时更新上升子序列长度的最大值
if(dp[i] > maxLen) {
maxLen = dp[i]
}
}
// 遍历完毕,最后到手的就是最大上升子序列的长度
return maxLen
};
问题复盘
对于这道题,其实也可以用最原始的办法来枚举每种情况:对于每个元素,考虑“取”和“不取”两种选择,得到对应的序列,进而判断这个序列是否是一个上升序列。若得到的是上升序列,则计算并更新最大长度;若不是,则啥也不干。
基于这个枚举的思路,我们不难想出递归的解法,然后再结合记忆化搜索,这道题似乎也能用“自顶向下”的求解思路做出来。
这里不推荐大家延续“自顶向下”的思维方式,原因有两个:
- 这类题目有稳定的解题模型,可以帮助我们更好更快地解决问题,大可不必舍近求远
- 帮助大家完成从递归思想向动态规划思想的过渡,避免思维定式。
在初学动态规划时,为了避免架空式地理解一个全新思想所带来的不适,我们需要借助已经学过的递归思想作为“垫脚石”来辅助我们的理解,从而跨过最难的第一道门。在基于递归思想的解题模板的帮助下,相信大家都对“倒推”法明确状态转移关系这个套路有了深刻的理解,同时也难免会有这样的困惑——难道动态规划就是记忆化搜索的迭代实现吗?这跟脱裤子放屁有什么区别?
如果你曾经有过这样的困惑,相信本节的学习已经为你解开心结——动态规划并不是任何一种其他算法思想的“替代品”,它有着自己独特的思路和作用。我们之所以在前面的几道题中会用到递归,是因为递归能够帮助我们又好又快地定位状态转移关系——递归是手段,而不是目的。在最大上升子序列这个问题中,我们可以看出,状态转移关系虽然总是涉及到对前后两个状态的分析,却并不总是依赖递归。
在后续的“大厂真题训练”环节中,大家会见识到更多千姿百态的动态规划题目。其中有一部分,可以完全借助我们这两节所讨论过的经验来解决;还有一部分,需要你“见招拆招”。不过不要怕,只要你把握住了“重叠子问题”和“最优子结构”两个关键特征,把握住了动态规划的核心解题逻辑,那么再新的题目也只不过是对你已经掌握的动态规划之“道”的验证,是对解题之“术”的拓展。
在开始之前
从本节开始,我们进入“大厂真题解读与训练”环节。在进入正题之前,笔者想要先帮大家捋清楚两件事情:
如何正确看待你已经做过的那些题
在2-23节的漫长的知识讲解过程中,我们学过的所有题目,都是实打实的大厂真题。不要因为是例题,就心不在焉。要知道,能够选入例题、作为“教具”出现的题目,一定都是经典中的经典,是需要反复咀嚼的。
如何正确看待你即将要做的这些题
“真题解读”!==“猜题”。
一些同学早期在各种营销号、培训机构广告的蛊惑下,潜意识里会觉得大公司总会有一套一成不变的面试套路,认为有类似于“面试题库”这样的稳定题源存在,因此对面试猜题这种性质的行为抱有强烈的幻想。
我们刷题之旅的第一步,就是要打破这种幻想——算法面试几乎没有什么因公司而异的套路,就算有(比如Google),它的更新频率也是非常高的。唯一的“套路”只能是你扎实的算法基本功和丰富的解题思路方面的积累(这也是小册从开篇到现在一直在引导大家做的事情)——这些东西是需要你真刀真枪地花时间和算法面对面搏斗才能沉淀下来的“内力”,唯有它能够以不变应万变。
本环节在整本小册中的作用,是对前述知识体系的补充,意在帮助同学们扩展解题思路、强化做题手感。所谓“大厂真题”,只不过是用来试炼学习效果、提升综合能力的“教具”,它们的任务是帮你快速建立起实战场景下的解题自信,而不是为了劝退或者炫技。
在接下来几节的学习过程中,最要紧的是保持住学习的平常心——不要被标题中高大上的公司 Title 给吓到了,要知道这些题对你来说终究会是小菜一碟。你需要做的仅仅是专注于题目和题目背后的思路,将题目对自己的价值最大化,扎扎实实地跑完这一场算法马拉松的最后一公里。
大家加油!
最长回文子串问题
题目描述:给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。
示例 2:
输入: "cbbd"
输出: "bb"
命题关键字:字符串、动态规划
思路分析
这道题最直接的思路仍然是暴力解法:
定义两个指针 i 和 j,用这两个指针嵌套两层循环,尝试枚举出给定字符串对应的所有可能的子序列,判断每一个子序列是否回文:若回文且长度已经超越当前的长度最大值,则更新长度最大值,并记录该子串的两个端点。当所有的子串枚举结束时,我们也就得到了最大长度的回文子串。
枚举子串需要两层循环,对应的复杂度是 O(n^2);判断是否回文,又额外需要 O(n)的开销。因此,这个暴力解法的时间复杂度就是 O(n^3)。
由于这个复杂度过于辣鸡,我们看看就行了。下面抛弃本能,恢复理智,我们结合前面做题的经验,重新来看这道题。
题干中的“最长”二字,表明了这是一道“求最值”型问题。前面我们说过,看到最值,就要把动态规划调度进可用解题工具里。
继续往下分析,发现这道题中,较长回文子串中可能包含较短的回文子串(最优子结构);若按照暴力解法来做,多次遍历的过程中不可避免地会涉及到对同一个回文子串的重复判断(重叠子问题),因此,这道题用动态规划求解是比较合理的。
这道题中,我们拿到的原始素材是一个字符串序列,符合“序列型”动态规划的特征。大家现在已经知道,对于序列型动态规划,我们总是需要以它的索引为线索去构造一维或二维的状态数组。对于这道题来说,由于定位任意子串需要的是两个索引,因此我们的状态数组应该是一个二维数组:
// 初始化一个二维数组
let dp = [];
const len = s.length
for (let i = 0; i < len; i ++) {
dp[i] = [];
};
由于i和j分别表示子串的两个端点,只要我们明确了这两个值,就能间接地求出子串的长度。因此dp[i][j]不必额外记录长度这个状态,只需要记录该区间内的字符串是否回文。这里我们把回文记为 1(或true),不回文记为0(或false)。
按照这个思路走下去,我们需要关注到的无疑就是字符串的两个端点 s[i]和s[j]了。当遍历到一对新的端点的时候,有以下两种可能的状态转移情况:
s[i] === s[j]。这种情况下,只要以s[i+1]和s[j-1]为端点的字符串是回文字符串,那么dp[i][j] = 1就成立,否则dp[i][j] = 0。s[i] !== s[j]。这种情况下,一定有dp[i][j]=0。
到这里,我们也就明确到了这道题的状态转移方程,这里我用编码表达如下:
if(s[i] === s[j]) {
dp[i][j] = dp[i+1][j-1]
} else {
dp[i][j] = 0
}
找出了状态转移方程,现在来找边界值。这里大家需要注意的是:如果在一个序列中,涉及到了 i、j两个索引,那么一定要关注到 i===j 这种特殊情况。在这道题中,由于 i===j时,dp[i][i]对应的是一个单独的字母,单独的字母必然回文(长度为1),因此dp[i][i] = 1 就是这道题的边界值(或者说初始值)。
现在,明确了初始值,明确了状态转移方程,我们来写代码(注意看注释):
编码实现
/**
* @param {string} s
* @return {string}
*/
const longestPalindrome = function(s) {
const dp = [];
// 缓存字符串长度
const len = s.length
// 初始化状态二维数组
for (let i = 0; i < len; i ++) {
dp[i] = [];
};
// 初始化最长回文子串的两个端点值
let st = 0, end=0
// 初始化最长回文子串的初始值为1
for(let i=0;i<len;i++) {
dp[i][i] = 1
}
// 这里为了降低题目的复杂度,我们预先对悬念比较小的 s[i][i+1] 也做了处理
for(let i=0;i<len-1;i++){
if(s[i]===s[i+1]) {
dp[i][i+1] = 1
st = i
end = i+1
}
}
// n 代表子串的长度,从3开始递增
for(let n=3;n<=len;n++) {
// 下面的两层循环,用来实现状态转移方程
for(let i=0;i<=len-n;i++) {
let j = i+n-1
if(dp[i+1][j-1]) {
if(s[i]===s[j]){
// 若定位到更长的回文子串,则更新目标子串端点的索引值
dp[i][j] = 1
st = i
end = j
}
}
}
}
// 最后依据端点值把子串截取出来即可
return s.substring(st,end+1);
}
从前序(先序)与中序遍历序列构造二叉树
题目描述:根据一棵树的前序遍历与中序遍历构造二叉树。
注意: 你可以假设树中没有重复的元素。
例如,给出
前序遍历 preorder = [3,9,20,15,7]
中序遍历 inorder = [9,3,15,20,7]
返回如下的二叉树:
3
/ \
9 20
/ \
15 7
命题关键字:二叉树、前序、中序、遍历序列特征、递归
思路分析
这道题非常非常非常非常经典。
第一次见到它,没思路,是正常的;第二次见到它,写不顺,也是正常的——对于经典的题目,我们未必一定要完全靠自己的聪明才智去解决它(直接看答案一点也不丢人),但一定要追求一个熟练度(你得对答案有充分的理解和把握,能靠条件反射来做题)。
这道题解题的一个切入点,就是前序遍历序列和中序遍历之间的关系。
我们假设前序遍历序列中的元素分别为 p1、p2......pn,中序遍历序列中的元素分别为 i1、i2......in。那么两个序列之间就有以下关系:
root |<-左子树->| |<- 右子树 ->|
↓
前序序列 p1 p2......pk p(k+1)......pn
|<- 左子树 ->| root |<- 右子树 ->|
↓
中序序列 i1 i2......i(k-1) ik i(k+1)......in
它们之间的关系蕴含着两个重要的规律:
- 前序序列头部的元素
p1,一定是当前二叉树的根结点(想一想,为什么?)。 - 中序遍历序列中,以二叉树的根结点为界划分出的两个子序列,分别对应着二叉树的左子树和二叉树的右子树。
基于以上两个规律,我们不难明确这道题的解题思路:在中序序列中定位到根结点(p1)对应的坐标,然后基于这个坐标划分出左右子树对应的两个子序列,进而明确到左右子树各自在前序、中序遍历序列中对应的索引区间,由此构造左右子树。
以上面的示意简图为例,根结点(p1)在中序序列中的坐标索引为 k,于是左子树的结点个数就可以通过计算得出:
numLeft = k - 1
这里为了确保逻辑的通用性,我们把前序序列当前范围的头部索引记为 preL,尾部索引记为 preR;把中序序列当前范围的头部索引记为 inL,尾部索引记为 inR。
那么左子树在前序序列中的索引区间就是 [preL+1,preL+numLeft],在中序序列中的索引区间是 [inL, k-1];右子树在前序序列的索引区间是 [preL+numLeft+1, preR],在中序序列中的索引区间是 [k+1, inR]。
此时我们会发现,基于左子树和右子树各自对应的前序、中序子序列,我们完全可以直接重复执行上面的逻辑来定位到左右子树各自的根结点和子树的序列区间。通过反复重复这套定位+构造的逻辑,我们就能够完成整个二叉树的构建。
二叉树类题目中的重复逻辑,90%都是用递归来完成的。下面我就基于递归思想来完成这道题的编码示范(注意看注释里的解析):
编码实现
/**
* 预定义树的结点结构.
* function TreeNode(val) {
* this.val = val;
* this.left = this.right = null;
* }
*/
/**
* @param {number[]} preorder
* @param {number[]} inorder
* @return {TreeNode}
*/
const buildTree = function(preorder, inorder) {
// 缓存结点总个数(遍历序列的长度)
const len = preorder.length
// 定义构造二叉树结点的递归函数
function build(preL, preR, inL, inR) {
// 处理越界情况
if(preL > preR) {
return null
}
// 初始化目标结点
const root = new TreeNode()
// 目标结点映射的是当前前序遍历序列的头部结点(也就是当前范围的根结点)
root.val = preorder[preL]
// 定位到根结点在中序遍历序列中的位置
const k = inorder.indexOf(root.val)
// 计算出左子树中结点的个数
const numLeft = k - inL
// 构造左子树
root.left = build(preL+1, preL+numLeft, inL, k-1)
// 构造右子树
root.right = build(preL+numLeft+1, preR, k+1, inR)
// 返回当前结点
return root
}
// 递归构造二叉树
return build(0, len-1, 0, len-1)
};
请思考:如果把题目中的“前序”改成“后序”,这道题应该怎么做?
提示:不妨先写出题示二叉树对应的后序遍历序列,然后比猫画虎,寻找它和中序遍历之间的关系。答案其实就藏在我们的题解中,相信你一定能挖掘出新的规律,加油呀~~
复制带随机指针的链表
题目描述:给定一个链表,每个节点包含一个额外增加的随机指针,该指针可以指向链表中的任何节点或空节点。要求返回这个链表的 深拷贝。
我们用一个由 n 个节点组成的链表来表示输入/输出中的链表。每个节点用一个 [val, random_index] 表示:
val:一个表示 Node.val 的整数。
random_index:随机指针指向的节点索引(范围从 0 到 n-1);如果不指向任何节点,则为 null 。
示例1:
输入:head = [[7,null],[13,0],[11,4],[10,2],[1,0]]
输出:[[7,null],[13,0],[11,4],[10,2],[1,0]]
示例2:
输入:head = [[1,1],[2,1]]
输出:[[1,1],[2,1]]
示例3:
输入:head = [[3,null],[3,0],[3,null]]
输出:[[3,null],[3,0],[3,null]]
示例4:
输入:head = []
输出:[]
解释:给定的链表为空(空指针),因此返回 null。
命题关键字:数据结构、链表、哈希表
思路分析
这道题考的是数据结构相关的基础知识和进阶操作。
关于基础知识,我们前面已经叨叨了不少了,这里不再赘述。这道题我着重要讲的是面向实战场景的几个解题突破口:
- 啥是深拷贝:在这道题里,深拷贝是相对于引用拷贝来说的,对于
JS中的对象a和对象b,假如我们单纯赋值:
a = b
那么 a 和 b 其实是指向了同一个引用,这就是引用拷贝。深拷贝的意思是说a和b的内容相同,但是占据两块不同的内存空间,也就是拥有两个不同的引用。对于链表中的 Node 对象(假设对象中的属性分别是数据域val和指针域next)来说,我们可以这样做:
// 先开辟一块新的内存空间
const copyNode = new Node()
// copy旧结点的值
copyNode.val = curr.val
// copy旧结点的next指针
copyNode.next = curr.next ? new Node() : null
- 如何处理深拷贝过程中的结点关系:笔者在这里最推荐的一种做法是用
Map结构:在这道题中,除了next指针还有random指针,结点关系相对复杂,这就意味着我们在处理结点关系的过程中必然会遇到“根据原结点定位它对应的copy结点”这样的需求。Map结构可以帮我们做到这一点。 next指针和random指针各自应该如何处理:我们可以先走一遍普通链表(也就是没有random指针)的复制流程。在这个过程中,一方面是完成对结点的复制+存储工作,另一方面也用next指针把新链表串了起来。这一步做完之后,新链表和老链表之间唯一的区别就在于random指针了。此时我们只需要同步遍历新旧两个链表,把random的指向映射到新链表上去即可。
基于对以上三个问题的探讨,我们可以有以下编码(注意注释里的解析):
编码实现
/**
* // Definition for a Node.
* function Node(val, next, random) {
* this.val = val;
* this.next = next;
* this.random = random;
* };
*/
/**
* @param {Node} head
* @return {Node}
*/
const copyRandomList = (head) => {
// 处理边界条件
if (!head) return null
// 初始化copy的头部结点
let copyHead = new Node()
// 初始化copy的游标结点
let copyNode = copyHead
// 初始化hashMap
const hashMap = new Map()
let curr = head
// 首次循环,正常处理链表的复制
while (curr) {
copyNode.val = curr.val
copyNode.next = curr.next ? new Node() : null
hashMap.set(curr, copyNode)
curr = curr.next
copyNode = copyNode.next
}
// 将游标复位到head
curr = head
// 将copy链表的游标也复位到copyHead
copyNode = copyHead
// 再搞个循环,特殊处理random关系
while (curr) {
// 处理random的指向
copyNode.random = curr.random ? hashMap.get(curr.random) : null
// copyNode 和 curr 两个游标一起前进
copyNode = copyNode.next
curr = curr.next
}
// 注意这里返回的是copyHead而不是head
return copyHead
};
在这一小节,大家需要关注到的是“从具体问题中抽象算法模型”这个能力。
直白点说,有一类题目,它们(看上去)来者不善:题干不仅天马行空,有时候还又臭又长,导致你读了五分钟很可能也只读出了一个屁——这类题目其实就是在考察你把具体问题抽象为算法模型的能力。
遇到它,你除了不要慌、不要怕之外,最重要的是不要被题目牵着鼻子走。你得拉拢它,收买它,把它往你已经掌握的那些知识点上靠——很多时候,同学们缺少的并不是知识储备,而是【建立题目与知识点之间的关联】的能力。
岛屿数量问题
题目描述:给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
示例 1:
输入:
11110
11010
11000
00000
输出: 1
示例 2:
输入:
11000
11000
00100
00011
输出: 3
解释: 每座岛屿只能由水平和/或竖直方向上相邻的陆地连接而成。
命题关键字:模拟、DFS
思路分析
这道题好就好在它题目不长,但是题干这通描述有可能会让一部分同学直接失去耐心——岛屿?水?“网格”???
啥啥啥?这都是啥?
其实,只要同学能够耐住性子读下去,就会发现所谓“网格”不过是二维数组,而“岛屿”和“水”这样的具体概念,题目也已经贴心地帮我们抽象为了“1”和“0”这样简单的数字。因此,我们拿到这道题,首先要做的就是把题目中这些干扰性的概念“翻译”成简单直接的算法语言:
已知一个二维数组,定义“相互连接的1”为一个块(这里的相互连接,意思就是1和1之间可以不经过0就相互抵达),求符合条件的块的数量。
翻译到这个程度之后,我们不难找出“相互连接”这个关键词作为我们做题的抓手,进而去形成一个初步的思路——若当前所在位置是1,从1出发,可以抵达的所有1都和它算作同一个岛屿。注意这里我把“所有”这个词标了粗体,已经读了25节算法小册的你,请和我一起大声喊出下面这句话:
看到“所有”,必须想到“枚举”!看到“枚举”,必须回忆起
DFS和BFS!
喜欢递归的我,选择用 DFS 来做~~~
在明确了 DFS 的大方向之后,结合题意,我们可以提取出以下关键问题:
- 如何实现对不同岛屿的统计?
- 已经计算过的岛屿如何排除?
下面我一一回答这两个问题:
- 岛屿的统计思路:从起点出发,遵循“不撞水面(也就是0)不回头”的原则,枚举当前可以触及的所有1。当枚举无法继续进行时,说明当前这座岛屿被遍历完毕,记为一个。也就是说每完成一次
DFS,就累加一个岛屿。 - 避免重复计算的方法:每遍历过一个1,就把它置为0,后续再次路过时就会自动忽略它啦~~
回答完这俩问题,代码也算基本写完了(如果以上描述仍然无法帮你建立清晰的思路,不妨去代码注释里找一下答案~):
编码实现
/**
* @param {character[][]} grid
* @return {number}
*/
// 入参是二维数组
const numIslands = function(grid) {
const moveX = [0, 1, 0, -1]
const moveY = [1, 0, -1, 0]
// 处理二维数组的边界情况
if(!grid || grid.length === 0 || grid[0].length === 0) {
return 0
}
// 初始化岛屿数量
let count = 0
// 缓存二维数组的行数和列数
let row = grid.length, column = grid[0].length
// 以行和列为线索,尝试“逐个”遍历二位数组中的坑位
for(let i=0; i<row; i++) {
for(let j=0; j<column; j++) {
if(grid[i][j] === '1') {
// 每遇到1,就进入dfs,探索岛屿边界
dfs(grid, i, j)
// 每完成一个 dfs,就累加一个岛屿
count++
}
}
}
return count
// 编写探索岛屿边界的逻辑
function dfs(grid, i, j) {
// 如果试图探索的范围已经越界,则return
if(i<0 || i>=grid.length || j<0 || j>=grid[0].length || grid[i][j] === '0'){
return
}
// 遍历过的坑位都置0,防止反复遍历
grid[i][j] = '0'
// 遍历完当前的1,继续去寻找下一个1
for(let k=0; k<4; k++) {
dfs(grid, i+moveX[k], j+moveY[k])
}
}
}
编码复盘
对初学此类问题的同学来说,这道题里有一个值得关注的做题技巧,就是对 moveX 和 moveY 两个数组的设定:
const moveX = [0, 1, 0, -1]
const moveY = [1, 0, -1, 0]
结合代码的上下文可以看出,我们借助这两个数组,可以完成对当前格子的“垂直”和“水平”两个方向上的相邻格子的检查:
for(let k=0; k<4; k++) {
dfs(grid, i+moveX[k], j+moveY[k])
}
后续我们遇到的一些题目,一旦和这道题一样,强调了“水平”、“垂直”方向上的相邻关系,我们就可以无脑复用这个套路啦~
“扫地机器人”问题
题目描述:房间(用格栅表示)中有一个扫地机器人。格栅中的每一个格子有空和障碍物两种可能。
扫地机器人提供4个API,可以向前进,向左转或者向右转。每次转弯90度。
当扫地机器人试图进入障碍物格子时,它的碰撞传感器会探测出障碍物,使它停留在原地。
请利用提供的4个API编写让机器人清理整个房间的算法。
interface Robot {
// 若下一个方格为空,则返回true,并移动至该方格
// 若下一个方格为障碍物,则返回false,并停留在原地
boolean move();
// 在调用turnLeft/turnRight后机器人会停留在原位置
// 每次转弯90度
void turnLeft();
void turnRight();
// 清理所在方格
void clean();
}
示例:
输入:
room = [
[1,1,1,1,1,0,1,1],
[1,1,1,1,1,0,1,1],
[1,0,1,1,1,1,1,1],
[0,0,0,1,0,0,0,0],
[1,1,1,1,1,1,1,1]
],
row = 1,
col = 3
解析: 房间格栅用0或1填充。0表示障碍物,1表示可以通过。 机器人从row=1,col=3的初始位置出发。在左上角的一行以下,三列以右。
注意:
输入只用于初始化房间和机器人的位置。你需要“盲解”这个问题。换而言之,你必须在对房间和机器人位置一无所知的情况下,只使用4个给出的API解决问题。
扫地机器人的初始位置一定是空地。
扫地机器人的初始方向向上。
所有可抵达的格子都是相连的,亦即所有标记为1的格子机器人都可以抵达。
可以假定格栅的四周都被墙包围。
命题关键字:模拟、DFS
思路分析
这道题很明显属于我们开篇提到过的“又臭又长”系列。但笔者相信,每一个做过“岛屿数量”的同学,在面对这道题的时候,至少心里是不会怂的。 毕竟,它们也长得太像了:
room = [
[1,1,1,1,1,0,1,1],
[1,1,1,1,1,0,1,1],
[1,0,1,1,1,1,1,1],
[0,0,0,1,0,0,0,0],
[1,1,1,1,1,1,1,1]
]
变化的题干,不变的1&0二维数组,嘿嘿嘿。
现在我们回头研究一下题干。
前面咱们说过,对于这种场景感比较强的具体问题,最要紧的是从冗长的描述中去提取出确切的算法问题。因此大家最好先尝试自己先翻译一下题干,想想它到底想让你干嘛。
这里我先给出自己做这道题时的脑回路,大家不妨观察一下这个思考的过程,你会发现这些思路其实都不是从天而降的,而是来源于对已经做过的题目的吸收和反思:
整体思路
这道题涉及到对二维数组网格的枚举,可能与岛屿数量问题的基本思路一致(将DFS作为优先方法来考虑)。虽然我不知道对不对,但是沿着这个思路往下多分析几步总是好的:
- 机器人从初始位置出发,检查上下左右四个方向是否有障碍物,进而决定是否进入对应方向的格子完成清扫。
- 因为题目强调了“所有可抵达的格子都是相连的,亦即所有标记为1的格子机器人都可以抵达”,所以我们借助
DFS尝试枚举所有可抵达的格子是完全没有问题的。DFS的主要递归逻辑其实就是步骤1。 - 当某一个方向已经“撞到南墙”后,机器人应该逐渐回溯到上一个位置,尝试新的方向。
- 最后,由于递归边界其实就是障碍物/已经清扫过的格子。所以别忘了对已经清扫过的格子做个标记。
整个思路捋下来,没有逻辑上的硬伤。下面我试着对具体问题进行分析,看看实现起来有没有什么困难。
机器人的前进规则分析
题目的复杂之处在于强调了“上下左右”的概念,它要求我们先旋转、再判断、最后根据判断结果决定是否需要前进。也就是说,我们不仅需要考虑机器人的前进坐标,还需要考虑机器人的旋转角度。其实无论旋转也好、前进也罢,本质上都是让它“自己动”。大家记住,“自己动”往往和循环有关。比如说上一道题里,我们就是用一个固定 k=4 的循环来完成了上下左右四个方向的前进:
for(let k=0; k<4; k++) {
dfs(grid, i+moveX[k], j+moveY[k])
}
在这道题里,我们同样可以用类似的循环来实现四个方向的旋转+前进。
明确了循环结构的设计,现在继续来分析循环体。
既然题目已经把步骤拆成了旋转和前进两步,那么我们就有必要把旋转角度和前进坐标之间的关系对应起来。假设机器人现在所在的格子坐标是 (i, j),它的旋转角度以及对应的前进坐标之间就有以下关系(结合题意我们把“上”这个初始方向记为0度):
(定义逻辑)
// 初始化角度为 0 度
let dir = 0
...
(判断逻辑)
// 将角度和前进坐标对应起来
switch(dir) {
// 0度的前进坐标,是当前坐标向上走一步
case 0:
x = i - 1
break
// 90度(顺时针)的前进坐标,是当前坐标向右走一步
case 90:
y = j + 1
break
// 180度(顺时针)的前进坐标,是当前坐标向下走一步
case 180:
x = i + 1
break
// 270度(顺时针)的前进坐标,是当前坐标向左走一步
case 270:
y = j - 1
break
default:
break
}
...
(叠加逻辑)
// 注意这里我给机器人的规则就是每次顺时针转一个方向,所以是 turnRight
robot.turnRight()
// turnRight的同时,dir需要跟着旋转90度
dir += 90
// 这里取模是为了保证dir在[0, 360]的范围内变化
dir %= 360
如何优雅地对已处理过的格子做标记
请思考一下,在这道题里,是否还可以沿用“岛屿数量”问题中直接修改二维数组的思路?说实话,没试过,也不建议——就这道题来说,题给的四个API都不是我们自己实现的,一旦改了全局的 room 变量,谁知道会影响哪个API呢。保险起见,我们应该优先考虑不污染room变量的实现方法,这里我借助的是 Set 数据结构:
(以下是定义逻辑)
//初始化一个 set 结构来存储清扫过的坐标
const boxSet = new Set()
...
(以下是判断逻辑)
// 标识当前格子的坐标
let box = i + '+' + j
// 如果已经打扫过,那么跳过
if(boxSet.has(box)) return
// 打扫当前这个格子
robot.clean()
// 记住这个格子
boxSet.add(box)
OK,分析完这三个大问题,我们的代码也基本写完了:
编码实现
/**
* @param {Robot} robot
* @return {void}
*/
const cleanRoom = function(robot) {
// 初始化一个 set 结构来存储清扫过的坐标
const boxSet = new Set()
// 初始化机器人的朝向
let dir = 0
// 进入 dfs
dfs(robot, boxSet, 0, 0, 0)
// 定义 dfs
function dfs(robot, boxSet, i, j, dir) {
// 记录当前格子的坐标
let box = i + '+' + j
// 如果已经打扫过,那么跳过
if(boxSet.has(box)) return
// 打扫当前这个格子
robot.clean()
// 记住这个格子
boxSet.add(box)
// 四个方向试探
for(let k=0;k<4;k++) {
// 如果接下来前进的目标方向不是障碍物(也就意味着可以打扫)
if(robot.move()) {
// 从当前格子出发,试探上右左下
let x = i, y = j
// 处理角度和坐标的对应关系
switch(dir) {
case 0:
x = i - 1
break
case 90:
y = j + 1
break
case 180:
x = i + 1
break
case 270:
y = j - 1
break
default:
break
}
dfs(robot, boxSet, x, y, dir)
// 一个方向的dfs结束了,意味着撞到了南墙,此时我们需要回溯到上一个格子
robot.turnLeft()
robot.turnLeft()
robot.move()
robot.turnRight()
robot.turnRight()
}
// 转向
robot.turnRight()
dir += 90
dir %= 360
}
}
}
编码复盘
这里有一段逻辑可能会让初学题目的同学蒙圈:
dfs(robot, boxSet, x, y, dir)
robot.turnLeft()
robot.turnLeft()
robot.move()
robot.turnRight()
robot.turnRight()
这是在干啥?
结合一下代码的上下文,这里我给机器人的设定是:
你在进入每一个格子后,都需要基于当前方向顺时针旋转四次
在这个前提下,机器人在 (x,y) 这个格子工作完之后,它的朝向一定是和刚进入 (x,y)时的朝向是一样的,区别在于在原来的基础上多走了一个格子:
此时后一个网格的机器人要想退回“事前”的状态,它必须先旋转 180 度,然后前进一步,再旋转 180 度。而“旋转 180 度”这个动作,可以通过连续两次 turnLeft或者turnRight来完成。这里我为了写代码好看,各用了一次(羞)。
“合并区间”问题
题目描述:给出一个区间的集合,请合并所有重叠的区间。
示例 1:
输入: [[1,3],[2,6],[8,10],[15,18]]
输出: [[1,6],[8,10],[15,18]]
解释: 区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].
示例 2:
输入: [[1,4],[4,5]]
输出: [[1,5]]
解释: 区间 [1,4] 和 [4,5] 可被视为重叠区间。
命题关键字:数学问题、数组
思路分析
做完两道应用题,大家放松一下,换换口味,现在我们来一起解决一个并没有许多套路的数学问题。
这个题里,你什么都可以忽略,但是请一定抓住“区间”二字,并记住下面这样一个规律:
对于区间类问题,尝试以区间内的第一个元素为索引进行排序,往往可以帮助我们找到问题的突破点
不信我们来看看这道题,题中给了我们这样一个例子:
[[1,3],[2,6],[8,10],[15,18]]
这个例子就是一个排序过的区间,当区间排序后,区间与区间之间的重叠关系会变得非常有迹可循:
[1, 3]
[2, 6]
[8, 10]
[15, 18]
可以看出,对于有序区间,我们其实可以从头开始,逐个合并首尾有交集的区间——比如上面区间关系图中的 [1, 3] 和 [2, 6],由于前一个区间的尾部(3)和下一个区间的头部(2)是有交错关系的(这个交错关系用数学语言表达出来就是前一个的尾部 >= 下一个的头部),因此我们可以毫不犹豫地把它们合并为一个区间:
[1, 3] + [2, 6] ==> [1, 6]
遵循这个合并规则,我们可以编码如下:
编码实现
/**
* @param {number[][]} intervals
* @return {number[][]}
*/
const merge = function(intervals) {
// 定义结果数组
const res = []
// 缓存区间个数
const len = intervals.length
// 将所有区间按照第一个元素大小排序
intervals.sort(function(a, b) {
return a[0] - b[0]
})
// 处理区间的边界情况
if(!intervals || !intervals.length) {
return []
}
// 将第一个区间(起始元素最小的区间)推入结果数组(初始化)
res.push(intervals[0])
// 按照顺序,逐个遍历所有区间
for(let i=1; i<len; i++) {
// 取结果数组中的最后一个元素,作为当前对比的参考
prev = res[res.length-1]
// 若满足交错关系(前一个的尾部 >= 下一个的头部)
if(prev[1] >= intervals[i][0]) {
prev[1] = Math.max(prev[1], intervals[i][1])
} else {
res.push(intervals[i])
}
}
return res
}
本节的命题风格是“大杂烩”:文中涉及到的题目本身并不难,但题目与题目之间的知识点跨度会比较大,目的是考验大家对知识点的熟练度和整合知识点的能力。
注:此处的“命题风格”仅出于笔者个人对课程设计的考虑,并非对腾讯公司命题思路的预测/总结。准备背题目的同学都醒醒。
寻找二叉树的最近公共祖先
题目描述: 给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
百度百科中最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”
例如,给定如下二叉树: root = [3,5,1,6,2,0,8,null,null,7,4]
示例 1:
输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出: 3
解释: 节点 5 和节点 1 的最近公共祖先是节点 3。
示例 2:
输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出: 5
解释: 节点 5 和节点 4 的最近公共祖先是节点 5。因为根据定义最近公共祖先节点可以为节点本身。
命题关键字:二叉树、递归
思路分析
这道题非常经典。很多人(包括我)第一次读完题目的时候,脑子里都是一片空白——确实,这道题的题干并不能够给我们提供什么有效的启发性信息。不过不要慌,当题干都是屁话时,我们不妨试试从“示例”中寻找答案:
题干中一直在强调“祖先结点”、“树的深度”等概念,这可能会误导一部分同学情不自禁地代入“爹找儿子”这种思维模式,然后陷入僵局。如果你不幸中招,别忘了:
虽然编码的时候我们实现的确实是“爹找儿子”,但是在规则摸索阶段,“儿子找爹”这种思维模式会更加人性化。
不管是爹找儿子,还是儿子找爹,我们都必须首先明确儿子和爹之间的关系有哪些,从而尝试去将不同的关系和“公共祖先”这个概念建立关联。这些信息,我们都可以从题目的示例中挖掘出来。
现在我按照“儿子向爹汇报”这个思路,一层层往上溯源,尝试枚举不同的父子关系形式。
注:下文所提及的“有效汇报”指的就是“爸爸我这里有p或者q”这样式儿的汇报哈
假如说我要寻找的是 6和2的最近公共祖先,那么这中间出现的儿子和爹之间的关系就有以下几种:
-
对于
5这个结点来说,它的左边和右边各有一个目标儿子给他作有效汇报,5也确实就是这俩目标儿子的最近公共祖先。 -
对于
3这个结点来说,由于6和2只存在于它的左孩子上,所以它得到的有效汇报只有1个。同时3本身又并不等同于6或者2,因此3不是最近公共祖先。
这里我强调了“不等同”,那么相应地一定会有“等同”的情况——假如我们要寻找的目标结点是5和6,那么对于5来说,即使只有一侧的孩子结点给它作了有效的汇报,也不影响它作为两个结点的最近公共祖先而存在(因为它自己既是儿子也是爸爸)。 -
对于
1这个结点来说,它的左孩子和右孩子上都没有目标结点,这意味着它拿到的所有“汇报”就都是无效的,因此1不是最近公共祖先。
分析至此,我们发现了一个明显的规律:最近公共祖先和有效汇报个数之间,有着非常强烈的关联。
那么“有效汇报个数”就成了我们做题的抓手。由于一个结点最多有两个孩子,它拿到的有效汇报个数也无非只有0、1、2这三种可能性,我们逐个来看:
-
若有效汇报个数为0,则
p和q完全不存在与当前结点的后代中,当前结点一定不是最近公共祖先(对应示例二叉树中p=6, q=2时,6、2、1之间的关系)。 -
若有效汇报个数为2,则意味着
p和q所在的两个分支刚好在当前结点交错了,当前结点就是p和q的最近公共祖先(对应示例二叉树中p=6, q=2时,6、2、5之间的关系)。 -
若有效汇报个数为1,这里面蕴含着三种情况:
a. 当前结点的左子树/右子树中,包含了p或者q中的一个。此时我们需要将p或者q所在的那棵子树的根结点作为有效结点上报,继续向上去寻找p和q所在分支的交错点。b. 当前结点的左子树/右子树中,同时包含了
p和q。在有效汇报数为1的前提下,这种假设只可能对应一种情况,那就是p和q之间互为父子关系。此时我们仍然是需要将p和q所在的那个子树的根结点(其实就是p或者q中作为爸爸存在那个)作为有效结点给上报上去。
结合上面三种情况,我们可以进一步分析出以下结论:
- 若有效汇报个数为2,直接返回当前结点
- 若有效汇报个数为1,返回1所在的子树的根结点
- 若有效汇报个数为0,则返回空(空就是无效汇报)
我们把这个判定规则,揉进二叉树递归的层层上报的逻辑里去,就得到了这道题的答案:
编码实现
/**
* 二叉树结点的结构定义如下
* function TreeNode(val) {
* this.val = val;
* this.left = this.right = null;
* }
*/
/**
* @param {TreeNode} root
* @param {TreeNode} p
* @param {TreeNode} q
* @return {TreeNode}
*/
const lowestCommonAncestor = function(root, p, q) {
// 编写 dfs 逻辑
function dfs(root) {
// 若当前结点不存在(意味着无效)或者等于p/q(意味着找到目标),则直接返回
if(!root || root === p || root === q) {
return root
}
// 向左子树去寻找p和q
const leftNode = dfs(root.left)
// 向右子树去寻找p和q
const rightNode = dfs(root.right)
// 如果左子树和右子树同时包含了p和q,那么这个结点一定是最近公共祖先
if(leftNode && rightNode) {
return root
}
// 如果左子树和右子树其中一个包含了p或者q,则把对应的有效子树汇报上去,等待进一步的判断;否则返回空
return leftNode || rightNode
}
// 调用 dfs 方法
return dfs(root)
};
寻找两个正序数组的中位数
题目描述:给定两个大小为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。
请你找出这两个正序数组的中位数,并且要求算法的时间复杂度为 O(log(m + n))。
你可以假设 nums1 和 nums2 不会同时为空。
示例 1: nums1 = [1, 3]
nums2 = [2]
则中位数是 2.0
示例 2:
nums1 = [1, 2]
nums2 = [3, 4]
则中位数是 (2 + 3)/2 = 2.5
命题关键字:二分思想、数学问题
思路分析
在做这道题之前,大家先记住一个规律:
题目中若要求
log级别的时间复杂度,则优先使用二分法解题
回到这道题上来,既然题目要求log级别的时间复杂度,我们首要的解题思路就不应该再是“遍历”,而应该是“切割”。
理解中位数的取值思路
接下来就需要思考切割的手法了。大家想想,如果只允许你用切割的方式来定位两个正序数组的中位数,你会怎么办?是不是应该首先想到从元素的数量上入手?
具体来说,假如我这里需要求解的是这样两个数组:
nums1 = [1, 3, 5, 7, 9]
nums2 = [2, 4, 6, 8, 10]
我要求解的中位数的范围是10个数,那么假如我在某个合适的位置分别切割了nums1和nums2:
[1, 3, 5,| 7, 9]
|<- s1 ->|
[2, 4, |6, 8, 10]
|<-s2->|
使得 s1+s2,刚好就是10个数里面按正序排布的前5个数。这样我其实只需要关心切割边界的这些值就可以了:
L1 R1
[1, 3, 5,| 7, 9]
|<- s1 ->|
L2 R2
[2, 4, |6, 8, 10]
|<-s2->|
这个例子中,数组总长度是10,10是偶数。偶数个数字的中位数,按照定义需要取中间两个元素的平均值。而“中间两个元素”,一定分别是 L1和L2中的较大值,以及R1和R2中的最小值(这个结论无需多言,你品品就出来了):
// 取 L1 和 L2 中的较大值
const L = L1 > L2 ? L1 : L2
// 取 R1 和 R2 中的较小值
const R = R1 < R2 ? R1 : R2
// 计算平均值
return (L + R)/2
此时假如给其中一个数组增加一个元素,让两个数组的长度和变为奇数:
L1 R1
[1, 3, 5,| 7, 9, 11]
|<- s1 ->|
L2 R2
[2, 4, |6, 8, 10]
|<-s2->|
那么中位数的取值就更简单了,我们只需要取 R1 和 R2 中的较小值即可:
const median = (R1 < R2) ? R1 : R2
到此为止,大家就对“切割法”下的中位数取值思路有了基本的了解。
以上我们所有的讨论,都是建立在 nums1 和 nums2 的分割点已知的前提下。实际上,对这道题来说,分割点的计算才是它真正的难点。
要解决这个问题,就需要请出二分思想了。
二分思想确定分割点
我们回头看这个数组
nums1 = [1, 3, 5, 7, 9]
nums2 = [2, 4, 6, 8, 10]
在不口算的情况下,没有人会知道 R1、R2到底取在哪个位置是比较合理的,你只知道一件事——我需要让nums1切割后左侧的元素个数+nums2切割后左侧元素的个数===两个数组长度和的一半。
我们先用编码语言来表达一下这个关系:
// slice1和slice2分别表示R1的索引和R2的索引
slice1 + slice2 === Math.floor((nums1.length + nums2.length)/2)
nums1、nums2的长度是已知的,这也就意味着只要求出 slice1 和 slice2 中的一个,另一个值就能求出来了。
因此我们的大方向先明确如下:
用二分法定位出其中一个数组的slice1,然后通过做减法求出另一个数组的slice2
“其中一个数组”到底以nums1为准还是以nums2为准?答案是以长度较短的数组为准,这样做可以减小二分计算的范围,从而提高我们算法的效率,所以我们代码开局就是要校验两个数组的长度大小关系:
const findMedianSortedArrays = function(nums1, nums2) {
const len1 = nums1.length
const len2 = nums2.length
// 确保直接处理的数组(第一个数组)总是较短的数组
if(len1 > len2) {
return findMedianSortedArrays(nums2, nums1)
}
...
}
从而确保较短的数组始终占据nums1的位置,后续我们就拿nums1开刀做二分。
这里我们假设 nums1 和 nums2 分别是以下两个数组:
nums1 = [5, 6, 7]
nums2 = [1, 2, 4, 12]
用二分法做题,首先需要明确二分的两个端点。在没有任何多余线索的情况下,我们只能把二分的端点定义为 nums1 的起点和终点:
// 初始化第一个数组二分范围的左端点
let slice1L = 0
// 初始化第一个数组二分范围的右端点
let slice1R = len1
基于此去计算 slice1 的值:
slice1 = Math.floor((slice1R - slice1L)/2) + slice1L
然后通过做减法求出 slice2:
slice2 = Math.floor(len/2) - slice1
第一次二分,两个数组分别被分割为如下形状:
L1 R1
nums1 = [5, |6, 7]
L2 R2
nums2 = [1, 2, |4, 12]
如何确认你的二分是否合理?标准只有一个——分割后,需要确保左侧的元素都比右侧的元素小,也就是说你的两个分割线要间接地把两个数组按照正序分为两半。这个标准用变量关系可以表示如下:
L1 <= R1
L1 <= R2
L2 <= R1
L2 <= R2
由于数组本身是正序的,所以L1 <= R1、L2 <= R2是必然的,我们需要判断的是剩下两个不等关系:
若发现 L1 > R2,则说明slice1取大了,需要用二分法将slice1适当左移;若发现L2 > R1,则说明slice1取小了,需要用二分法将slice1适当右移:
// 处理L1>R2的错误情况
if(L1 > R2) {
// 将slice1R左移,进而使slice1对应的值变小
slice1R = slice1 - 1
} else if(L2 > R1) {
// 反之将slice1L右移,进而使slice1对应的值变大
slice1L = slice1 + 1
}
只有当以上两种偏差情况都不发生时,我们的分割线才算定位得恰到好处,此时就可以执行取中位数的逻辑了:
// len表示两个数组的总长度
if(len % 2 === 0) {
// 偶数长度对应逻辑(取平均值)
const L = L1 > L2 ? L1 : L2
const R = R1 < R2 ? R1 : R2
return (L + R)/2
} else {
// 奇数长度对应逻辑(取中间值)
const median = (R1 < R2) ? R1 : R2
return median
}
我们把以上的整个分析用代码串起来,就有了这道题的答案:
编码实现
/**
* @param {number[]} nums1
* @param {number[]} nums2
* @return {number}
*/
const findMedianSortedArrays = function(nums1, nums2) {
const len1 = nums1.length
const len2 = nums2.length
// 确保直接处理的数组(第一个数组)总是较短的数组
if(len1 > len2) {
return findMedianSortedArrays(nums2, nums1)
}
// 计算两个数组的总长度
const len = len1 + len2
// 初始化第一个数组“下刀”的位置
let slice1 = 0
// 初始化第二个数组“下刀”的位置
let slice2 = 0
// 初始化第一个数组二分范围的左端点
let slice1L = 0
// 初始化第一个数组二分范围的右端点
let slice1R = len1
let L1, L2, R1, R2
// 当slice1没有越界时
while(slice1 <= len1) {
// 以二分原则更新slice1
slice1 = Math.floor((slice1R - slice1L)/2) + slice1L
// 用总长度的1/2减去slice1,确定slice2
slice2 = Math.floor(len/2) - slice1 // 计算L1、L2、R1、R2
const L1 = (slice1===0)? -Infinity : nums1[slice1-1]
const L2 = (slice2===0)? -Infinity : nums2[slice2-1]
const R1 = (slice1===len1)? Infinity : nums1[slice1]
const R2 = (slice2===len2)? Infinity: nums2[slice2]
// 处理L1>R2的错误情况
if(L1 > R2) {
// 将slice1R左移,进而使slice1对应的值变小
slice1R = slice1 - 1
} else if(L2 > R1) {
// 反之将slice1L右移,进而使slice1对应的值变大
slice1L = slice1 + 1
} else {
// 如果已经符合取中位数的条件(L1<R2&&L2<R1),则直接取中位数
if(len % 2 === 0) {
const L = L1 > L2 ? L1 : L2
const R = R1 < R2 ? R1 : R2
return (L + R)/2
} else {
const median = (R1 < R2) ? R1 : R2
return median
}
}
}
return -1
};
拓展
假如把题目中的 O(log(m+n)) 改为 O(m+n),你会怎样做?
“粉刷房子”问题
题目描述: 假如有一排房子,共 n 个,每个房子可以被粉刷成红色、蓝色或者绿色这三种颜色中的一种,你需要粉刷所有的房子并且使其相邻的两个房子颜色不能相同。
当然,因为市场上不同颜色油漆的价格不同,所以房子粉刷成不同颜色的花费成本也是不同的。每个房子粉刷成不同颜色的花费是以一个 n x 3 的矩阵来表示的。
例如,costs[0][0] 表示第 0 号房子粉刷成红色的成本花费;costs[1][2] 表示第 1 号房子粉刷成绿色的花费,以此类推。请你计算出粉刷完所有房子最少的花费成本。
注意: 所有花费均为正整数。
示例: 输入: [[17,2,17],[16,16,5],[14,3,19]]
输出: 10
解释: 将 0 号房子粉刷成蓝色,1 号房子粉刷成绿色,2 号房子粉刷成蓝色。
最少花费: 2 + 5 + 3 = 10。
命题关键字:动态规划、滚动数组
思路分析
这道题的特征非常肤浅,从概念的角度来说,动态规划的两个特征全部命中(如果你不知道我在说啥,建议复习小册第22、23节);从技巧的角度来说,“求最值”这个信号也在疯狂暗示你用动态规划来解决它。
对于最值型动态规划,我们最常用的思路仍然是动态规划专题中首推的“倒推”法。由于这个方法笔者已经重复地讲过太多次了,我们就不再在真题训练环节予以过多的表述(这道题的重点也不在这里)。结合“倒推”法,我们可以得出题目对应的状态转移方程是:
f[i][x] = Math.min(f[i-1][x以外的索引1号], f[i-1][x以外的索引2号]) + costs[i][x]
其中f[i][x]对应的是当粉刷到第i个房子时,使用第x(x=0、1、2)号油漆对应的总花费成本的最小值。
状态的初始值,就是当 i=0 时对应的三个值:
f[0][0] = costs[0][0]
f[0][1] = costs[0][1]
f[0][2] = costs[0][2]
f[0][0]、f[0][1]、f[0][2]分别表示当粉刷到第0个房子时,对它使用0号、1号、2号油漆对应的总花费成本。此时由于只粉刷了一个房子,所以总花费成本就等于房子本身的花费成本。
基于以上两个结论,我们可以有如下的初步编码:
编码实现-基础版
/**
* @param {number[][]} costs
* @return {number}
*/
const minCost = function(costs) {
// 处理边界情况
if(!costs || !costs.length) return 0
// 缓存房子的个数
const len = costs.length
// 初始化状态数组(二维)
const f = new Array(len)
for(let i=0;i<len;i++) {
f[i] = new Array(3)
}
// 初始化状态值
f[0][0] = costs[0][0]
f[0][1] = costs[0][1]
f[0][2] = costs[0][2]
// 开始更新刷到每一个房子时的状态值
for(let i=1;i<len;i++) {
// 更新刷到当前房子时,给当前房子选用第0种油漆对应的最小总价
f[i][0] = Math.min(f[i-1][1], f[i-1][2]) + costs[i][0]
// 更新刷到当前房子时,给当前房子选用第1种油漆对应的最小总价
f[i][1] = Math.min(f[i-1][2], f[i-1][0]) + costs[i][1]
// 更新刷到当前房子时,给当前房子选用第2种油漆对应的最小总价
f[i][2] = Math.min(f[i-1][1], f[i-1][0]) + costs[i][2]
}
// 返回刷到最后一个房子时,所有可能出现的总价中的最小值
return Math.min(f[len-1][0], f[len-1][1], f[len-1][2])
};
如果你写出了以上答案,而你的面试官又是一个在算法方面稍有见识的人,他就会问你:这道题的空间复杂度能否进一步优化?
此时,没有读过算法小册的同学,他以为自己做完了整道题,其实好戏才刚刚开始。
而认真研读过小册第23节的同学,他认为这样的追问合情合理,甚至在一开始准备好了思路,就等面试官把舞台交给自己。只见他三下五除二,就变出了一个叫“滚动数组”的东西,把这道题的空间复杂度碾了个稀碎:
编码实现-优化版
/**
* @param {number[][]} costs
* @return {number}
*/
const minCost = function(costs) {
// 处理边界情况
if(!costs || !costs.length) return 0
// 缓存房子的个数
const len = costs.length
// 开始更新状态
for(let i=1;i<len;i++) {
// now表示粉刷到当前房子时对应的价格状态
const now = costs[i]
// prev表示粉刷到上一个房子时的价格状态
const prev = costs[i-1]
// 更新当前状态下,刷三种油漆对应的三种最优价格
now[0] += Math.min(prev[1], prev[2])
now[1] += Math.min(prev[0], prev[2])
now[2] += Math.min(prev[1], prev[0])
}
// 返回粉刷到最后一个房子时,总价格的最小值
return Math.min(costs[len-1][0], costs[len-1][1], costs[len-1][2])
};
倘若对“基础版”代码稍作分析,你就会发现,其实我们每次更新f[i]时,需要的仅仅是 f[i-1]对应的状态而已,因此我们只需要确保一个数组中总是能保持着有效的f[i-1]即可。
这样的特征,符合“滚动数组”的使用场景。在这道题中,我们直接滚动了题目中原有的costs变量,将空间复杂度缩减了一个量级。
“滚动数组”是什么、怎么用?如果你对此心怀疑惑,请耐下心来,复习一下小册的第23节吧~^_^ 延续综合性训练小节的一贯风格,本节所涉及题目仍然存在较大的知识点跨度。这些题目之间,要真说有什么共性,大概就是它们的难度评级都是 Hard 吧。。。(逃。。。。
不过没关系,能坚持到宇宙条这一节的你想必也是个狠人。难者不会,会者不难,让我们一起来挨打做题吧~!^_^
本节题目不要求所有同学挑战。
如果你急于面试,时间有限,本节可以选择性跳过。策略要灵活,切勿死磕。
“接雨水”问题
题目描述:给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
示例:
输入: [0,1,0,2,1,0,1,3,2,1,2,1]
输出: 6
命题关键字:双指针法、数组、模拟
思路分析
这道题的解法有很多,这里我为大家介绍接受度相对比较高的双指针法。
对于这道题来说,想明白为什么用双指针法,比用双指针法把它做出来,要难得多得多。所以我们第一个要解决的问题是:这道题凭什么用双指针?
鲁迅说过:没有认真分析过题意的人,是不配讨论解题方向的。本题是一道与现实生活结合得比较紧密的应用题,大家拿到手要做的第一件事就是要结合题意/题中示例抽离解题模型。
开局一张图,剩下全靠猜——这道题的题干很短,我们分析的主要素材是图片和示例。
示例比较简单,它给到我们的一个关键信息是:这道题的入参是一个数组。
你想啊,这题想让你分析数组,同时又没提logN级别的复杂度(要提这个就得往二分上想了),那么遍历肯定是跑不了的吧?所以说这个时候,你心里就应该默默地种下了一个遍历数组的指针了。
接着看图:
图中黑色的部分是柱子,蓝色的部分是接到的雨水。这个图给到我们的一个最直观的体验是:雨水是由柱子“围起来”的——每坨雨水的两侧都有两根柱子,雨水能不能接住、能接多少,涉及到对两根柱子的综合分析。这时候你就应该产生这样的预感——这题估计一个指针搞不定,得往双指针上靠靠!
看到没同学们?对于数组问题来说,双指针未必总是作为单指针解法的改进技巧存在,人家也是有对口解题场景的。所以说,在解决数组问题(尤其是比较复杂的数组问题)时,双指针法必须在你的备选大招列表里拥有姓名~
对于这道题来说,双指针的作用就是帮助我们更加直接地处理【柱子高度和雨水量】之间的关系,实现对现实问题的模拟。所以说要想捋清楚双指针怎么用,首先得捋清楚【柱子高度和雨水量】之间的关系是啥。
找关系的这个过程很关键,它考验的是你的观察能力和归纳总结能力。
如果你对“【柱子高度和雨水量】之间的关系”这个大问题感到懵逼,那么不妨把它拆解成更加具体的小问题。我们的终极目标是统计雨水量,要想做到这点,有两个前提:
- 要能接到雨水
- 要知道接到了多少雨水
拆解出来的问题就可以是这样的两个:
- 什么情况下能接到雨水 ?
- 接到的雨水的量的多少是由谁来决定的?
带着这两个问题,我们重新审视一下题给的图片。不必做特别细致的分析,仅凭直观感受和生活经验,我相信各位不难得出这样的结论:
- 两个柱子之间有“凹槽”时,可以接到雨水
- 雨水的量由左右两边较矮的柱子的高度决定,类似大家以前做数学题常常见到的“木桶原理”
那么现在问题就具体到了这种程度:
我应该如何结合双指针法,判断出“凹槽”的存在,并且完成雨水总量的累加计算?
此时你需要做的,就是带上你脑内的双指针,尝试去走一遍这个数组的遍历,看看这个过程中能不能发现点什么有趣的东西。
这里问题又来了:我该用快慢指针、还是对撞指针呢?
答案是对撞指针,因为“凹槽”是在对撞的过程中“夹”出来的——这个决策没有用到我们前面专题文章总结过的任何快慢指针和对撞指针的选型规律,它完全依靠你自身对题目的感知和分析。
能想到对撞指针,这道题已经做对了一半。下面我们结合图中的两种情况,一起来寻思一下这个对撞指针应该怎么用:
首先讨论下索引[1,3]区间和索引[8,10]区间覆盖到的这种情况:两个柱子中间有一个凹槽,这个凹槽比较简单,它的宽度是1,高度就是由两个柱子中较矮的那一个决定的(第二个区间左右柱子的高度是相等的,所以取其中一个,和凹陷处的柱子高度做减法就可以了)。
接着再看索引[3,7]区间覆盖到的这种情况:索引为3的柱子和索引为7的柱子之间有一个凹槽,这个凹槽比较复杂,它左右两边高度为1,中间的高度为2。可以看出,对于左右两边来说,凹槽的高度就是相邻两根柱子之间的高度差。但是对于中间那个高度为2的凹槽来说,它的高度是【当前柱子和它左侧最高的那个柱子】之间的高度差:
喔,原来凹槽的深度不是由与它相邻的柱子来决定的,而是由某一侧的最高的柱子决定的。
那么为什么是左侧最高的柱子,而不是右侧最高的柱子?
因为左侧最高的柱子,比右侧最高的柱子要矮。在蓄水量这个问题上,矮的柱子说了算。
由此我们可以得到一个这样的结论:对于凹槽来说,决定它高度的不是与它相邻的那个柱子,而是左侧最高柱子和右侧最高柱子中,较矮的那个柱子。
因此我们在指针对撞的过程中,主要任务有两个:
- 维护一对
leftCur(左指针)和rightCur(右指针,以对撞的形式从两边向中间遍历所有的柱子 - 在遍历的过程中,维护一对
leftMax和rightMax,时刻记录当前两侧柱子高度的最大值。以便在遇到“凹槽”时,结合leftCur与rightCur各自指向的柱子高度,完成凹槽深度(也就是蓄水量)的计算。
将以上两个任务以编码的语言表达出来,就可以得到这道题的答案了。
谈谈“真题训练”
讲到这里,不知道大家的思路现在是否清晰一些了。如果仍然对其中的一些点想不明白,我建议你也先别急着撤退。写算法小册这段日子,我个人最深刻的一种感觉就是,读者对【讲解】这个事情的依赖性是越来越强的。但其实到了真题训练这个环节,每位同学都不应该再只关注题目本身,而应该关注自己对题目的思考。
拿这道题来说,以笔者的脑回路来看,我会坚定地认为它就是一个应该用对撞指针求解的数组问题。这份“坚定”来源于笔者与海量真题搏斗过后,沉淀下来的一种叫做【题感】的东西。我相信大部分同学跟着上面的题解,一步一步走下来,也能够把这道题的解法理解个大概。但这就是学习的全部吗?当然不是!你还需要想:如果这道题是交给我来做,我会怎么搞?
有的同学会问了:答案都在上面了,你都“坚定认为这题就用对撞指针”了,我还能怎么搞?
别说,不同的熟手玩家来做这个题,就是会坚定不同的解法。比如很多同学在分析完示例之后就会坚定地认为,这道题必须用【栈】来做,其它解法都靠边站。
巧了,这道题就算用栈来做,也完全不超纲——用到的都是我们在第12、13节讲过的知识,就看你怎么把知识和题目建立关联。
现在,仔细想想,如果回过头重做这道题,你是否也会一开始就给自己定下【对撞指针】的基调?还是说你更喜欢先逐个分析题给示例中柱子和雨水之间的种种关系、最后再敲定你的解法?
如果你跟着笔者给出的思路往下走,觉得别扭,那么能不能把阅读顺序反转一下,先从分析示例做起,逐步推导出双指针的存在,或者干脆另辟蹊径?
别忘了,你的目的是【靠自己搞懂这道题】,而不是【完全复刻某人的思路】。在真题训练环节,舞台属于你自己,题解只是个辅助。
编码实现
/**
* @param {number[]} height
* @return {number}
*/
const trap = function(height) {
// 初始化左指针
let leftCur = 0
// 初始化右指针
let rightCur = height.length - 1
// 初始化最终结果
let res = 0
// 初始化左侧最高的柱子
let leftMax = 0
// 初始化右侧最高的柱子
let rightMax = 0
// 对撞指针开始走路
while(leftCur < rightCur) {
// 缓存左指针所指的柱子的高度
const left = height[leftCur]
// 缓存右指针所指的柱子的高度
const right = height[rightCur]
// 以左右两边较矮的柱子为准,选定计算目标
if(left < right) {
// 更新leftMax
leftMax = Math.max(left, leftMax)
// 累加蓄水量
res += leftMax - left
// 移动左指针
leftCur++
} else {
// 更新rightMax
rightMax = Math.max(right, rightMax)
// 累加蓄水量
res += rightMax - right
// 移动右指针
rightCur--
}
}
// 返回计算结果
return res
};
思路拓展
我们前面说过,数组问题往往可以转化为栈问题或队列问题。
这道题就可以用栈的思路来解。
想一想,为什么?怎么做?
K个一组翻转链表
题目描述:给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。
k 是一个正整数,它的值小于或等于链表的长度。
如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。
示例: 给你这个链表:1->2->3->4->5
当 k = 2 时,应当返回: 2->1->4->3->5
当 k = 3 时,应当返回: 3->2->1->4->5
命题关键字:链表、链表的翻转、复杂数据处理
思路分析
同学们,这道题摆在这里,是为了兑现我在第10节留给大家的一个承诺。说真的,对现在的你来说,这个题真不能算是 hard 题,它最多是个medium。
经过第10节的洗礼,现在的你已经掌握了局部翻转指定范围链表结点的能力。这道题要你做的,就是记一个count 变量,每次累加到k个结点,就表演一次“局部链表翻转”这个节目。
这道题的难点在第10节其实已经拆完了,现在就是看各位对学过的知识有没有真正地吃透嚼烂。
屏幕前的你,不要再往下翻了,赶紧去打开这道题的力扣链接,验证一下自己对链表翻转类题目的掌握程度。如果你能靠自己的力量做对,那么你完全可以直接跳过下面的题解;如果不能,请你带着愉悦的心情复习一下第10节,然后再次向它发起挑战。
如果还是不能,也没关系。毕竟,我还是会给你写注释的orz:
编码实现
/**
* @param {ListNode} head
* @param {number} k
* @return {ListNode}
*/
const reverseKGroup = function(head, k) {
// 这个方法专门用来翻转指定范围(以head为起点)内的k个结点
function reverse(head) {
// 初始化 pre、cur、next三剑客
let pre = null, cur = head, next = null
// 遍历当前范围结点
while(cur) {
// 缓存next
next = cur.next
// 翻转当前结点的next指针
cur.next = pre
// pre、cur各前进一步,为下一个指针的翻转做准备
pre = cur
cur = next
}
// 翻转到最后,pre会指向最末尾的结点,也就是翻转后的第一个结点
return pre
}
// 有dummy指针好办事
let dummy = new ListNode()
dummy.next = head
// pre用来缓存当前这一截k个结点的链表前驱的那个结点(不丢头)
let pre = dummy
// start指向k个一组的局部链表中的第一个
let start = head
// end指向k个一组的局部链表中的最后一个
let end = head
// next用来缓存当前这一截k个结点的链表后继的那个结点(不丢尾)
let next = head
// 当后继结点存在时,持续遍历
while(next) {
// 找到k个结点中的最后一个
for(let i=1;i<k&&end;i++) {
end = end.next
}
// 如果不满k个,直接返回
if(!end) {
break
}
// 缓存这k个结点的后继结点
next = end.next
// 这一步把end.next置为null,是为了配合reverse方法
end.next = null
// 手动把end指向start(因为下面reverse完start就会改变)
end = start
// 以start为起点翻转k个结点
start = reverse(start)
// 接上尾巴
end.next = next
// 接上头
pre.next = start
// pre、start、end一起前进,为下一次翻转做准备
pre = end
start = next
end = start
}
// dummy.next指向的永远是链表的第一个结点
return dummy.next
};
思路拓展
这道题还可以用递归来做。 想一想,怎么实现?
柱状图中的最大矩形
题目描述:给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
以上是柱状图的示例,其中每个柱子的宽度为 1,给定的高度为 [2,1,5,6,2,3]。
图中阴影部分为所能勾勒出的最大矩形面积,其面积为 10 个单位。
示例:
输入: [2,1,5,6,2,3]
输出: 10
命题关键字:数学问题、模拟、单调栈
思路分析
首先,矩形面积如何计算?长X宽,对吧?这道题给到我们的是一个高度数组,对于每个高度来说,以它为高度的矩形的宽度是未知的。因此我们最直观的一个思路,就是固定一个高度,去探索宽度的上限。举个例子,假如说我固定的是图中的第一个柱子的高度:
从第一个柱子出发,往前遍历,发现下一个柱子的高度是1。1<2,很明显以第一个柱子为高的矩形宽度没办法再扩散了,它的宽度只能是1。
再看第二个柱子:
这个柱子高度是1,我们往下遍历,发现它后面的所有柱子
都比它高,这就意味着以1为高度的矩形面积是可以向下扩散的:
扩散到不能再扩散为止时,已经跨越了5个柱子。现在再回头看,发现它左边的柱子也比自己高,那么矩形的宽度还可以再向左扩散:
如此一来,以第二个柱子为高的矩形,最大面积可以达到1X6=6。
由此我们也可以总结出矩形宽度最大值的计算规则:若下一个柱子比当前柱子高,则持续扩散以当前柱子为高度的矩形宽度(扩展矩形的右边界);否则停止扩散,“回头看”寻找左边界,进而计算总宽度。
秉持上述的计算规则,对每一个柱子都重复此操作,我们就能得到每一个柱子所支撑的最大矩形的面积。从这些面积中对比出一个最大值,就算是把这道题做出来了。
基于这个思路,我们来写代码:
编码实现
/**
* @param {number[]} heights
* @return {number}
*/
const largestRectangleArea = function(heights) {
// 判断边界条件
if(!heights || !heights.length) return 0
// 初始化最大值
let max = -1
// 缓存柱子长度
const len = heights.length
// 遍历每根柱子
for(let i=0;i<len;i++) {
// 如果遍历完了所有柱子,或者遇到了比前一个矮的柱子,则停止遍历,开始回头计算
if(i == len-1 || heights[i]>heights[i+1]) {
// 初始化前i个柱子中最矮的柱子
let minHeight = heights[i]
// “回头看”
for(let j=i;j>=0;j--) {
// 若遇到比当前柱子更矮的柱子,则以更矮的柱子为高进行计算
minHeight = Math.min(minHeight, heights[j])
// 计算当前柱子对应的最大宽度的矩形面积,并及时更新最大值
max = Math.max(max, minHeight*(i-j+1))
}
}
}
// 返回结果
return max
};
思路拓展
这道题还可以用栈来做,但是解法相对比较难推导,这里我先给大家做一遍。
站在高度为6的柱子这里,向后遍历,发现2比6小,矩形宽度不能再扩散了。此时我们回头看,首先计算出来的是高度为6的矩形的面积,然后才计算出来高度为5的矩形的面积——后面的柱子比前面的柱子先出结果。这叫啥?这叫后进先出!后进先出的数据结构是啥?是栈!由此,这道题的大方向就有了个脉络——借助栈来模拟矩形宽度的探索过程。
具体需要一个什么样的栈呢?回到上一个解法中去看,当柱子高度递增时,我们不做特殊处理(此时只需要入栈)。只有当发现柱子的高度回落时,才会开始“弹出”前面柱子对应的结果(出栈)。所以我们在编码层面的一个基本思路,就是去维护一个单调递增栈。
多说无益,都在注释里了:
编码实现
/**
* @param {number[]} heights
* @return {number}
*/
const largestRectangleArea = function(heights) {
// 判断边界条件
if(!heights || !heights.length) return 0
// 初始化最大值
let max = -1
// 初始化栈
const stack = []
// 缓存柱子高度的数量
const len = heights.length
// 开始遍历
for(let i=0;i<len;i++) {
// 如果栈已经为空或当前柱子大于等于前一个柱子的高度
if(!stack.length || heights[i] >= heights[stack[stack.length-1]]) {
// 执行入栈操作
stack.push(i)
} else {
// 矩形的右边界
let right = i
// pop出作为计算目标存在的那个柱子
let target = stack.pop()
// 处理柱子高度相等的特殊情况
while(stack.length&&heights[target]===heights[stack[stack.length-1]]) {
// 若柱子高度相等,则反复pop
target = stack.pop()
}
// 矩形的左边界
let left = (!stack.length)? -1: stack[stack.length-1]
// 左右边界定宽,柱子定高,计算矩形面积
max = Math.max(max, (right-left-1)*heights[target])
// 这一步保证下一次循环从当前柱子往下走(因为当前柱子还没作为计算目标计算出结果)
i--
}
}
// rightAdd是我们针对右边界为空这种情况,补上的一个假的右边界
let rightAdd = stack[stack.length-1]+1
// 此时栈里是高度单调递增(不减)的柱子索引,这些柱子还没有参与计算,需要针对它们计算一遍
while(stack.length) {
// 取出栈顶元素作为计算目标
let target = stack.pop()
// 找到左边界
let left = (!stack.length)? -1 : stack[stack.length-1]
// 注意这里的右边界一定是rightAdd,想一想,为什么?
max = Math.max(max, (rightAdd-left-1)*heights[target])
}
// 返回计算出的最大值
return max
};
算法面试的评价逻辑
在和读者同学保持紧密互动的这两年里,我发现不少同学对面试都是有畏惧心理的,似乎面试官的心,就好像那镜中花、水中月,是非常虚无而不可捉摸的东西。所以在小册的最后,我想和大家聊聊算法面试的“评价逻辑”。
评价逻辑这东西,稍有不慎就会变成某一个面试官完全主观的个人脱口秀。为了使样本尽可能丰富、逻辑尽可能客观,笔者充分利用了每次面试结尾面试官抛出的“你还有什么想问我的吗?”这个问题,对超过10个考察算法的前端/全栈团队的不同角度的算法面试评价逻辑进行了梳理和抽象,这里作为彩蛋性质的知识点放送给大家。
涉及到软实力的内容,多说无益,贵在好记好执行。这里我就说两个点:1.应该做什么;2.不应该做什么
应该做什么
- 面试前:大量做题,及时总结——给自己囤一个尽可能巨大的知识方法库。
- 面试中:积极思考自己的知识储备和题目间的关系。没有关系,创造关系也要讲。充分相信“我是一个算法扎实的牛人”——面试官可以不信,但你必须信。
- 面试后:保持思考,趁热打铁。面试已经结束,但你的职业生涯还在继续。一要珍惜向面试官提问的机会,尽可能从对方嘴里套话,理解他的命题意图和评价逻辑;二要抓住那些面试过程中不会做的题,把它当成你对象,就算再难搞也不要生气(你会随便跟你对象生气么?:)),抓紧它,搞懂它,下一次面试它就会从你的软肋变成你的底气。
不应该做什么
这里我着重说说面试中常见的两个大忌:
-
畏于表达:面试官面你,不是为了难为你,他也是在给自己找未来的同事;面试官不是神,他可能技术还不如你。所以说遇到脸再臭的面试官,都不要怂。题目描述得不清楚,直接问;面试官点评的内容有问题,直接提;就算自己现有的解题思路并不能真的把题目做出来,也要试着用“虽然我没有找到最终解,但我想先说下我现在的想法xxxxx”这样的话术争取一个表达的机会。
答案的对错很多时候并不重要,重要的是你在回答的过程中展现了多少你的思考。 -
过早地对新题目投降:我们前期大量做题、反复总结,不是为了在面试的时候能够遇到原题上去默写(当然这种情况也是有的),而是为了自己在面对新题目的时候能够有充分的的知识储备和自信心去应对。
首先从态度上来说,大家千万不要畏惧新题目,他很可能只是一个化了妆的旧题目。然后就是不要急于回答,让这个问题在你脑子里多兜几圈,多试几条路,看看能不能和自己已知的解题技巧关联起来(就像我在前面20多个小节给大家示范的那样)。
如果你觉得这个过程可能会比较久,可以用“我来想一下哈”这样的话术来争取时间。在充分的思考之后,你做出这道题的概率将会大大提升;就算你还是不会做,但你至少已经经历了一个思考的过程,有了可以表达的东西。还是那句话,不怕做不对,就怕不表达。
前方的路
拓展阅读
如果你在读完这本小册后,感到意犹未尽,想要在算法世界里更进一步的话,我除了想给你双击666,还想推荐你读读下面这几本书:
《剑指offer》
非常经典的刷题书,适合考前一个月突击复习找手感。
这本书现在已经出到第二版了,笔者从第一版发行开始学习,书中的题目如今看来仍不过时。难度方面,相信对学完算法小册的你来说,已经无法构成威胁,哈哈~
《算法》
内容翔实、绝佳的入门教材 。
这本书虽然不是用 JS 语言描述的,但在表达上对新手非常友好。我第一次读这本书是在大学时,至今仍然庆幸自己没有选错启蒙读物。
《学习JavaScript数据结构与算法》
如果你对《算法》的篇幅和非 JS 语言的表达感到望而生畏,我建议你读读这本对新手更加友好的入门书。
这本书很薄,更加侧重于对数据结构的讲解,适合非科班的同学光速入门。
《程序员代码面试指南:IT名企算法与数据结构题目最优解》
这是一本中国人写的书。
目前来看这本书的评价比较两极化,笔者个人的感觉是大家可以在刷题后期用这本书来练手。书中会讲解一些力扣上没有覆盖的题目,作者解题的思路也时不时会让人觉得耳目一新。
工作/生活上的一些教训和建议
很多读我小册的同学喜欢叫我“老铁”,既然是“老铁”,这里就和大家唠唠嗑,分享一下自己过去这一年里学到的最重要的几个知识点。如果大家也能从中受益,那才真是我的福报了。
-
身体健康应该是每个程序员优先级为
P0的需求。每半年或一年做一次全面体检,非常有必要。
从这个角度看,学好算法相当于间接续命,因为当你修不动福报的时候,还可以在算法能力的支持下去外企休息两年。
——病假休到第二周的修言如是说 -
如果你没有做出过一份好的技术 PPT,那么不应该盲目地排斥做 PPT 这件事情。
过去我很反感 PPT 侠这号人物,觉得别一天到晚瞎比比,有种就 “show me the code”。但在过去的一年里,因为各种各样的契机,我自己也不得不频繁使用 PPT 来表达想法、完成宣讲。这才渐渐发现, 做 PPT 其实是一个非常好的抽象和提炼自己思路的方式。具体到执行层面,我们更多是钻一个点。而 PPT 却可以帮助我们去把点串成线、进而去看一整个面的问题。
我个人在制作和表达 PPT 的过程中,发现了许多执行阶段所难以感知的问题,也挖掘出了执行阶段无暇思考的一些非常好的项目演进方向,最后收获了大量的实实在在可落地可执行的东西,可以说是受益良多。
看来,要做一个好的工程师,只有code可以show是远远不够的,我们还需要刻意训练自己的抽象能力和归纳能力。
PS:插播一个有趣的事情。前两年我看过一篇文章,标题叫做《累死累活干不过做 PPT 的!》。两年前的我深以为然(这帮 PPT 侠真讨厌!),两年后的我再次深以为然(做 PPT 牛逼!)。哈哈~
如果你读到这里,仍然对 PPT 侠抱有偏见,那么也没关系,可以试试从写各种文档开始,养成记录和总结工作的好习惯。毕竟, PPT 也只是我们做总结的一种形式嘛~ -
关注他人,做利他的事情,往往就是在做对的事情。
这话是写给和曾经的我一样讨厌做业务开发的同学们的。我相信不少同学讨厌做业务,无非是出于以下两方面的想法:
a. “这玩意儿不过是在堆代码,对我的技术提升一点卵用也没有”
b. “我不想和不懂技术的产品/运营/品牌商/各路业务方打交道”
无论是 a 还是 b,本质上都是因为我们不想去关注与技术无关的人和事,想做纯粹的程序员。
过去一年里,我因为秉持对“纯粹”的坚持,吃了不少工作上的苦头。后来在对3.75客户价值的渴求下,尝试转变思路,把一部分研究技术的时间拿出来做业务输入,发现很多事情都变得豁然开朗。
其实作为一个好的工程师,我们的任务确实不是只有coding这么简单,更多地还要去思考如何用技术去创造产品上的价值,进而去实现业务上的目标。工程师与科学家的区别,就在于他关注的不是技术本身,而是如何使用技术去创造实际的价值。这就要求我们去关注到更多的人,更多的事情,同时思考自己的技能和这些人、这些事之间的关联。这些思考,往往能为我们带来技术上的新思路、新方法。
致谢
正事说完了,接下来最最不能省略的,仍然是致谢。
过去我写致谢,喜欢说“感谢大家读完了这本小册”。确实,在职程序员主动扩展自己的知识边界本身就是一件不易的事情,能够学完28节算法的大家都是狠人,修言给你跪下了点个赞!
但算法小册更特别,在这里,我还要谢谢最初那一波每日一push、坚持要看算法小册的读者。算法小册诞生于掘站的一个比较特殊的(没人管的)时期,命题、试读、上线,整个过程中很大程度上仰仗了读者老铁们的参与、支持和鼓励。总之,掘友牛逼!
尾声
前前后后说了这么多,最后还是要回到算法面试上来。
现在的你,有了硬基础,有了软实力。但这些都还不够,你需要做的,是在时间允许的前提下,是刷更多的题、做更多的总结。
当你不知道该用什么姿势刷题,不知道该从什么角度出去去总结的时候,欢迎你随时回到算法小册这里,再看看自己走过的路,相信一定会有别样的收获。
希望大家都能身体健康,学有所成。也欢迎大家添加我的微信 xyalinode,和我聊聊工作与生活。
大家加油!
修言 2020.07
各位老铁,从本节开始,我们进入排序算法的世界。
对于前端来说,排序算法在应用方面似乎始终不是什么瓶颈——JS 天生地提供了对排序能力的支持,很多时候,我们实现排序只需要这样寥寥数行的代码:
arr.sort((a,b) => {
return a - b
})
以某一个排序算法为“引子”,顺藤摸瓜式地盘问,可以问出非常多的东西,这也是排序算法始终热门的一个重要原因——面试官可以通过这种方式在较短的时间里试探出候选人算法能力的扎实程度和知识链路的完整性。因此排序算法在面试中的权重不容小觑。
以面试为导向来看,需要大家着重掌握的排序算法,主要是以下5种:
- 基础排序算法:
- 冒泡排序
- 插入排序
- 选择排序
- 进阶排序算法
- 归并排序
- 快速排序
我们的学习安排就按照这个从基础到进阶的次序来。
和以往不同的是,本专题的讲解线索不再是“题目”,而是排序算法本身:针对每一种算法,我都会首先介绍其思想,然后为大家逐步示范一遍真实的排序过程,接着为大家做编码教学。最后,别忘了,排序算法的时间复杂度也是一个不能忽视的考点,“编码复盘”部分我们不见不散。
注意:考虑到排序类题目在未经特别声明的情况下,都默认以“从小到大排列”为有序标准。因此下文中所有”有序“的描述指代的都是“从小到大排列”。
冒泡排序
基本思路分析
冒泡排序的过程,就是从第一个元素开始,重复比较相邻的两个项,若第一项比第二项更大,则交换两者的位置;反之不动。
每一轮操作,都会将这一轮中最大的元素放置到数组的末尾。假如数组的长度是 n,那么当我们重复完 n 轮的时候,整个数组就有序了。
真实排序过程演示
下面我们基于冒泡排序的思路,尝试对以下数组进行排序:
[5, 3, 2, 4, 1]
首先,将第一个元素 5 和它相邻的元素 3 作比较,发现5 比 3 大,故将 5 和 3 交换:
[3, 5, 2, 4, 1]
↑ ↑
将第二个元素 5 和第三个元素 2 作比较,发现 5 比 2大,故将 5 和 2 交换:
[3, 2, 5, 4, 1]
↑ ↑
将第三个元素 5 和第四个元素 4 作比较,发现 5 比 4 大,故将 5 和 4 交换:
[3, 2, 4, 5, 1]
↑ ↑
将第四个元素 5 和第五个元素 1 作比较,发现 5 比 1 大,故将 5 和 1 交换:
[3, 2, 4, 1, 5]
↑ ↑
至此我们就完成了一轮排序,此时,五个数中最大的数字 5 仿佛气泡浮出水面一样,被”冒“到了数组顶部。这也是冒泡排序得名的原因。
重复上面的操作,我们继续从第一个元素开始看起。比较 3 和 2,发现 3 比 2 大,交换两者:
[2, 3, 4, 1, 5]
↑ ↑
比较 3 和 4,发现 3 比 4 小,符合从小到大排列的原则,故保持不动:
[2, 3, 4, 1, 5]
↑ ↑
比较 4 和 1,发现 4 比 1 大,交换两者:
[2, 3, 1, 4, 5]
↑ ↑
比较 4 和 5,发现 4 比 5 小,符合从小到大排列的原则,故保持不动:
[2, 3, 1, 4, 5]
↑ ↑
以上我们完成了第二轮排序,至此,五个数中第二大的数字 4 也被”冒“到了数组相对靠后的位置。
沿着这个思路往下走,仍然是从第一个元素开始,比较 2 和 3。发现 2 比 3 小,符合排序原则,故保持不动:
[2, 3, 1, 4, 5]
↑ ↑
接着走下去,比较 3 和 1,发现 3 比 1 大,交换两者:
[2, 1, 3, 4, 5]
↑ ↑
比较 3 和 4,发现 3 比 4 小,符合排序原则,故保持不动:
[2, 1, 3, 4, 5]
↑ ↑
比较 4 和 5,发现 4 比 5 小,符合排序原则,故保持不动:
[2, 1, 3, 4, 5]
↑ ↑
以上我们完成了第二轮排序,至此,五个数中第三大的数字 3 被”冒“到了倒数第三个的位置。
继续我们的循环,从当前的第一个元素 2 开始,比较 2 和相邻元素 1,发现 2 比 1 大,交换两者:
[1, 2, 3, 4, 5]
↑ ↑
接下来仍然会对剩余的元素进行相邻元素比较,但由于不再发生交换,所以我们这里简写一下每一步对应的相邻元素关系:
[1, 2, 3, 4, 5]
↑ ↑
[1, 2, 3, 4, 5]
↑ ↑
[1, 2, 3, 4, 5]
↑ ↑
[1, 2, 3, 4, 5]
↑ ↑
经过第四轮冒泡,整个数组已经完全达到了有序状态。不过,冒泡排序的逻辑并不会因为数组有序就立刻停下来——”从头到尾遍历数组,对比+交换每两个相邻元素“这套逻辑到底要执行多少次,按照我们目前的基本思路来看,是完全由数组中元素的个数来决定的:每一次从头到尾的遍历都只能定位到一个元素的位置,因此元素有多少个,总的循环就要执行多少轮。
在这个例子中,总的元素有 5 个,因此理论上来说还有一轮从头到尾的循环要走。
相信大家已经隐约感觉到了哪里不对,不过没关系,掌握了基本思路,优化啥的都好说。我们先按照这个思路来编码:
基本冒泡思路编码实现
function bubbleSort(arr) {
// 缓存数组长度
const len = arr.length
// 外层循环用于控制从头到尾的比较+交换到底有多少轮
for(let i=0;i<len;i++) {
// 内层循环用于完成每一轮遍历过程中的重复比较+交换
for(let j=0;j<len-1;j++) {
// 若相邻元素前面的数比后面的大
if(arr[j] > arr[j+1]) {
// 交换两者
[arr[j], arr[j+1]] = [arr[j+1], arr[j]]
}
}
}
// 返回数组
return arr
}
基本冒泡思路的改进
在上面的示例中,我们已经初步分析出了这样一个结论:在冒泡排序的过程中,有一些”动作“是不太必要的。比如数组在已经有序的情况下,为什么还要强行再从头到尾再对数组做一次遍历?
这背后的根本原因是,我们忽略了这样一个事实:随着外层循环的进行,数组尾部的元素会渐渐变得有序——当我们走完第1轮循环的时候,最大的元素被排到了数组末尾;走完第2轮循环的时候,第2大的元素被排到了数组倒数第2位;走完第3轮循环的时候,第3大的元素被排到了数组倒数第3位......以此类推,走完第 n 轮循环的时候,数组的后 n 个元素就已经是有序的。
楼上基本冒泡思路的问题在于,没有区别处理这一部分已经有序的元素,而是把它和未排序的部分做了无差别的处理,进而造成了许多不必要的比较。
为了避免这些冗余的比较动作,我们需要规避掉数组中的后 n 个元素,对应的代码可以这样写:
改进版冒泡排序的编码实现
function betterBubbleSort(arr) {
const len = arr.length
for(let i=0;i<len;i++) {
// 注意差别在这行,我们对内层循环的范围作了限制
for(let j=0;j<len-1-i;j++) {
if(arr[j] > arr[j+1]) {
[arr[j], arr[j+1]] = [arr[j+1], arr[j]]
}
}
}
return arr
}
面向“最好情况”的进一步改进
很多同学反映说,在不少教材里都看到了“冒泡排序时间复杂度在最好情况下是 O(n)”这种说法,但是横看竖看,包括楼上示例在内的各种冒泡排序主流模板似乎都无法帮助我们推导出 O(n) 这个结果。
实际上,你的想法是对的,冒泡排序最常见的写法(也就是楼上的编码示例)在最好情况下对应的时间复杂度确实不是O(n),而是O(n^2)。
那么是O(n)这个说法错了吗?其实也不错,因为冒泡排序通过进一步的改进,确实是可以做到最好情况下 O(n)复杂度的,这里我先把代码给大家写出来(注意解析在注释里):
function betterBubbleSort(arr) {
const len = arr.length
for(let i=0;i<len;i++) {
// 区别在这里,我们加了一个标志位
let flag = false
for(let j=0;j<len-1-i;j++) {
if(arr[j] > arr[j+1]) {
[arr[j], arr[j+1]] = [arr[j+1], arr[j]]
// 只要发生了一次交换,就修改标志位
flag = true
}
}
// 若一次交换也没发生,则说明数组有序,直接放过
if(flag == false) return arr;
}
return arr
}
标志位可以帮助我们在第一次冒泡的时候就定位到数组是否完全有序,进而节省掉不必要的判断逻辑,将最好情况下的时间复杂度定向优化为 O(n)。
注意,以上几种写法中,改进后的版本可以视为标准的冒泡排序。但笔者更推荐大家把两个思路都记住,尤其是要理解从基本思路到改进思路的演进过程和优化依据。
编码复盘——冒泡排序的时间复杂度
我们分最好、最坏和平均来看:
- 最好时间复杂度:它对应的是数组本身有序这种情况。在这种情况下,我们只需要作比较(n-1 次),而不需要做交换。时间复杂度为 O(n)
- 最坏时间复杂度: 它对应的是数组完全逆序这种情况。在这种情况下,每一轮内层循环都要执行,重复的总次数是 n(n-1)/2 次,因此时间复杂度是 O(n^2)
- 平均时间复杂度:这个东西比较难搞,它涉及到一些概率论的知识。实际面试的时候也不会有面试官摁着你让你算这个,这里记住平均时间复杂度是 O(n^2) 即可。
对于每一种排序算法的时间复杂度,大家对计算依据有了解即可,重点在于记忆。面试的时候不要靠现场推导,要靠直觉+条件反射。
选择排序
思路分析
选择排序的关键字是“最小值”:循环遍历数组,每次都找出当前范围内的最小值,把它放在当前范围的头部;然后缩小排序范围,继续重复以上操作,直至数组完全有序为止。
真实排序过程演示
下面我们尝试基于选择排序的思路,对如下数组进行排序:
[5, 3, 2, 4, 1]
首先,索引范围为 [0, n-1] 也即 [0,4] 之间的元素进行的遍历(两个箭头分别对应当前范围的起点和终点):
[5, 3, 2, 4, 1]
↑ ↑
得出整个数组的最小值为 1。因此把1锁定在当前范围的头部,也就是和 5 进行交换:
[1, 3, 2, 4, 5]
交换后,数组的第一个元素值就明确了。接下来需要排序的是 [1, 4] 这个索引区间:
[1, 3, 2, 4, 5]
↑ ↑
遍历这个区间,找出区间内最小值为 2。因此区间头部的元素锁定为 2,也就是把 2 和 3 交换。相应地,将需要排序的区间范围的起点再次后移一位,此时区间为 [2, 4]:
[1, 2, 3, 4, 5]
↑ ↑
遍历 [2,4] 区间,得到最小值为 3。3 本来就在当前区间的头部,因此不需要做额外的交换。
以此类推,4会被定位为索引区间 [3,4] 上的最小值,仍然是不需要额外交换的。
基于这个思路,我们来写代码:
编码示范
function selectSort(arr) {
// 缓存数组长度
const len = arr.length
// 定义 minIndex,缓存当前区间最小值的索引,注意是索引
let minIndex
// i 是当前排序区间的起点
for(let i = 0; i < len - 1; i++) {
// 初始化 minIndex 为当前区间第一个元素
minIndex = i
// i、j分别定义当前区间的上下界,i是左边界,j是右边界
for(let j = i; j < len; j++) {
// 若 j 处的数据项比当前最小值还要小,则更新最小值索引为 j
if(arr[j] < arr[minIndex]) {
minIndex = j
}
}
// 如果 minIndex 对应元素不是目前的头部元素,则交换两者
if(minIndex !== i) {
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]
}
}
return arr
}
编码复盘——选择排序的时间复杂度
在时间复杂度这方面,选择排序没有那么多弯弯绕绕:最好情况也好,最坏情况也罢,两者之间的区别仅仅在于元素交换的次数不同,但都是要走内层循环作比较的。因此选择排序的三个时间复杂度都对应两层循环消耗的时间量级: O(n^2)。
插入排序
思路分析
插入排序的核心思想是“找到元素在它前面那个序列中的正确位置”。
具体来说,插入排序所有的操作都基于一个这样的前提:当前元素前面的序列是有序的。基于这个前提,从后往前去寻找当前元素在前面那个序列里的正确位置。
真实排序过程演示
下面我们尝试基于插入排序的思路,对如下数组进行排序:
[5, 3, 2, 4, 1]
首先,单个数字一定有序,因此数组首位的这个 5 可以看做是一个有序序列。在这样的前提下, 我们就可以选中第二个元素 3 作为当前元素,思考它和前面那个序列 [5] 之间的关系。很明显, 3 比 5 小,注意这里按照插入排序的原则,靠前的较大数字要为靠后的较小数字腾出位置:
[暂时空出, 5, 2, 4, 1]
当前元素 3
再往前看,发现没有更小的元素可以作比较了。那么现在空出的这个位置就是当前元素 3 应该待的地方:
[3, 5, 2, 4, 1]
以上我们就完成了一轮插入。这一轮插入结束后,大家会发现,有序数组 [5] 现在变成了有序数组 [3, 5]——这正是插入排序的用意所在,通过正确地定位当前元素在有序序列里的位置、不断扩大有序数组的范围,最终达到完全排序的目的。
沿着这个思路,继续往下走,当前元素变成了紧跟[3, 5] 这个有序序列的 2。对比 2 和 5 的大小,发现 2 比 5 小。按照插入排序的原则,5要往后挪,给较小元素空出一个位置:
[3, 暂时空出, 5, 4, 1]
当前元素 2
接着继续向前对比,遇到了 3。对比 3 和 2 的大小,发现 3 比 2 大。按照插入排序的原则,3要往后挪,给较小元素空出一个位置:
[暂时空出, 3, 5, 4, 1]
当前元素 2
此时 2 前面的有序序列已经被对比完毕了。我们把 2 放到最终空出来的那个属于它的空位里去:
[2, 3, 5, 4, 1]
以上我们完成了第二轮插入。这一轮插入结束后,有序数组 [3, 5] 现在变成了有序数组 [2, 3, 5]。
继续往下走,紧跟有序数组 [2, 3, 5] 的元素是 4。仍然是从后往前,首先对比 4 和 5 的大小,发现 4 比 5 小,那么 5 就要为更小的元素空出一个位置:
[2, 3, 暂时空出, 5, 1]
当前元素 4
向前对比,遇到了 3。因为 4 比 3 大,符合从小到大的排序原则;同时已知当前这个序列是有序的,3 前面的数字一定都比 3 小,再继续向前查找就没有意义了。因此当前空出的这个坑就是 4 应该待的地方:
[2, 3, 4, 5, 1]
以此类推,最后一个元素 1 会被拱到 [2, 3, 4, 5] 这个序列的头部去,最终数组得以完全排序:
[1, 2, 3, 4, 5]
分析至此,再来帮大家复习一遍插入排序里的几个关键点:
- 当前元素前面的那个序列是有序的
- “正确的位置”如何定义——所有在当前元素前面的数都不大于它,所有在当前元素后面的数都不小于它
- 在有序序列里定位元素位置的时候,是从后往前定位的。只要发现一个比当前元素大的值,就需要为当前元素腾出一个新的坑位。
基于这个思路,我们来写代码:
编码实现
function insertSort(arr) {
// 缓存数组长度
const len = arr.length
// temp 用来保存当前需要插入的元素
let temp
// i用于标识每次被插入的元素的索引
for(let i = 1;i < len; i++) {
// j用于帮助 temp 寻找自己应该有的定位
let j = i
temp = arr[i]
// 判断 j 前面一个元素是否比 temp 大
while(j > 0 && arr[j-1] > temp) {
// 如果是,则将 j 前面的一个元素后移一位,为 temp 让出位置
arr[j] = arr[j-1]
j--
}
// 循环让位,最后得到的 j 就是 temp 的正确索引
arr[j] = temp
}
return arr
}
编码复盘——插入排序的时间复杂度
-
最好时间复杂度:它对应的数组本身就有序这种情况。此时内层循环只走一次,整体复杂度取决于外层循环,时间复杂度就是一层循环对应的 O(n)。
-
最坏时间复杂度:它对应的是数组完全逆序这种情况。此时内层循环每次都要移动有序序列里的所有元素,因此时间复杂度对应的就是两层循环的 O(n^2)
-
平均时间复杂度:O(n^2)
小结
所谓基础排序算法,普遍符合两个特征:
- 易于理解,上手迅速
- 时间效率差
楼上的三个算法完美地诠释了这两个特征。对于基础排序算法,大家不要胡思乱想,你的目标就是默写,面试的时候考的最多的也是默写。
那么在此基础上,排序效率如何提升、排序算法如何与进阶的算法思想相结合?这就是我们下一节要讨论的问题了。