前言
接上文:又是一年轰轰烈烈金三银四------让算法和数据结构不再是你的软肋(中) - 掘金 (juejin.cn)
因为算法考察的知识点特别的广,一篇文章难以涵盖(所以将其分为上中下三篇文章进行阐述),在(上)文中已经阐述数组(二分,双指针)、链表、队列、栈相关的算法考察点。
在(中)文章中我们继续阐述树和堆相关的算法考察点,以及深度优先遍历,广度优先遍历和图的一些算法及排序算法的应用。
本文是最后一篇,主要阐述一些大学课堂上不曾讲过的(不同学校侧重点不一样,从我看过的不同出版社的《数据结构和算法》教材综合来说)但是面试会考察的一些算法。本文的主要内容包括递归 ,分治 ,回溯 ,动态规划 ,贪心方面的知识点。
递归
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐️
递归,在之前我写过一篇怎么样深入理解递归的文章,有兴趣的同学可以查看:成为高级工程师的必经之路------你必须掌握JS递归的那些事儿 - 掘金 (juejin.cn)
面试如果单纯的考递归,那就是考察你的逻辑思维能力,比如用循环能写的代码,如果让你用递归来写,你会不会想到。
本小节主要单独聊递归,不聊递归与其它知识点的结合。在做这方面的题的时候,我们仅仅需要做到的就是理解递归的定义,我们该思考怎么样去将问题的规模减小,怎么样去找退出条件,只要朝着这方面去想,基本上是没问题的。
本文就举两个递归常见的考题,各位读者体会一下。
使用递归进行字符串反转
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️
原文链接:【1 月最新】前端 100 问:能搞懂 80% 的请把简历给我 - 掘金 (juejin.cn)
这道题,用循环大家直接秒了,但是用递归乍一看可能会比较懵逼,而且题目给出的要求还挺多的,要求函数必须只有一个参数传入。还是抓关键,首先我们看看怎么样去找退出条件,字符串如果是空的话,肯定就不用递归了嘛,好,这一步已经明确了;接着看,怎么减少问题的规模,我们相当于是把已经反转的字符串和待反转的字符串进行拼接,得到的就是最终的结果,而这个待反转的字符串来源于递归调用,分析到这儿,就可以写的出来代码了。
以下就是根据上面的思考方式的代码实现:
js
function reverse(s) {
if(s.length === 0) {
return "";
}
// 取出第一个字符
const char = s[0];
// 将剩余的字符进行反转
const nextSubString = s.substring(1);
// char肯定是要再最后面的,前面拼接的是已经反转的部分
return reverse(nextSubString) + char;
}
使用递归进行链表的反转
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐
这题在脉脉上见到过好几次,楼主们纷纷对不会如此简单的题目感到懊悔,哈哈哈。
链表反转,大家应该都清楚吧,比如一个链表1->2->3->4,反转之后就是4->3->2->1,同样还是和之前的题目一样的要求,不能使用全局变量,函数只能是一个参数。
这道题,如果用循环,大家还是能直接秒(如果用循环你还有问题的话,这篇文章可能不太适合你,你需要先学习一些基础的解题技巧),关键就是用递归来做的话,也可能有点儿懵逼。我们还是像之前反转字符串那样的思考方式,先找递归的退出条件,如果链表是空表(即null
),啥事都不用做,如果链表不是空表,我们从未反转的链表上取出一个节点来,接在头的位置,接着把剩余的链表再次进行反转,将反转的结果接到刚才的头结点上。但是,此刻就会有个问题,正常链表都是返回的是头结点,而再将两个节点进行接入的时候,我们需要那个串的尾节点,所以,我们就需要找到尾节点进行拼接,这是比反转字符串复杂的一点,也是容易出错的一个点。
以下就是根据上面的思考方式的代码实现:
js
function reverse(head: LinkedNode | null): LinkedNode | null {
if (!head) {
return null;
}
// 取下头结点
const node = head;
// 将剩余的串记下来,因为马上就要把原来的头结点拿来拼接了
const remainHead = head.next;
head.next = null;
// 递归调用得到的反转结果,此刻得到的是一个头结点,而实际上我们需要的是尾节点,因此需要在这个上面找到尾节点
const reversedHead = reverse(remainHead);
// 如果当前链表只有一个节点,就没必要再继续进行了,把一个null指向一个头结点?
// 没意义啊,因此后面的操作就可以不用做了
if (!remainHead) {
return node;
}
let reverseNode = reversedHead;
let reversedPrev: LinkedNode | null = null;
// 找反转之后的尾节点
while (reverseNode) {
reversedPrev = reverseNode;
reverseNode = reverseNode.next;
}
// 如果链表只有一个节点的时候,reversedPrev就是null,因此需要进行判断
if (reversedPrev) {
reversedPrev.next = node;
}
return reversedHead;
}
好了,关于纯递归的考点就和大家分享这么多了,后面的分治和回溯仍然还会聊到递归。
分治
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐️⭐️⭐️
关于分治,其算法思想非常好理解的,就是分而治之,分治根据我个人的学习经验,分治分为两种情况,一种是直接分而治之,对于一个问题,分成两个部分,然后分别把左右安顿好之后,就解决了问题。比如像快速排序就是属于这种场景。
我们再重新描述一下快速排序的处理流程(我们以升序为例),选取一个主元,从数组两端分别开始遍历,比主元小的拿到主元的前边去,比主元大的拿到后边去,完成当两个指针相遇的时候,这个位置就是主元该存放的位置,这个位置的左边全部都比主元小,右边全部比主元大,分别递归的对这两个部分进行上述操作就完成了排序。
另外一个场景就是,我们虽然把左右两边分别安顿好了之后,但是还需要额外进行一步合并结果集的操作,比如归并排序就是这样的一个场景。
我们再重新描述一下归并排序的处理流程,首先划分问题,不断的递归调用,让数组片段的规模变小,当递归到头了,即此刻排序的数组片段只有1一个元素了,此刻就开始退栈了,我们需要将左右两边的子结果就行合并有序数组的操作。
关于归并排序和快速排序,这节出于篇幅考虑,就不再给出代码了,有兴趣的同学可以查看上一篇文章。
在这节中,我们还是举几个实用的例子:
最大子数组和
重要指数:⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐️⭐️⭐️
这题有个很浪漫的说法叫做:如果我们不能一起变得更加优秀,那就让我们分道扬镳吧~
js
export function maxSubArray(arr: number[]) {
return findMaxPartition(arr, 0, arr.length - 1);
}
function findMaxPartition(arr: number[], left: number, right: number) {
if (left < right) {
let mid = Math.floor((left + right) / 2);
// 这儿不能写出mid-1和mid啊,因为mid-1有可能比right小,程序就出问题了,
// 所以说在分治算法的时候都要写成mid和mid+1,这样才能保证递归过程中right永远比left大
// 比如left 1 right 2 mid就是1,mid+1就是2,但是如果是mid-1 就是0,这就是问题
const leftMax = findMaxPartition(arr, left, mid);
const rightMax = findMaxPartition(arr, mid + 1, right);
const crossMidMax = findCrossMidMax(arr, left, mid, right);
return Math.max(leftMax, rightMax, crossMidMax);
} else {
return arr[left];
}
}
function findCrossMidMax(
arr: number[],
left: number,
mid: number,
right: number
) {
let leftMidMax = -Infinity;
let preLeftSum = 0;
for (let i = mid; i >= left; i--) {
const num = arr[i];
preLeftSum += num;
if (preLeftSum > leftMidMax) {
leftMidMax = preLeftSum;
}
}
let rightMidMax = -Infinity;
let preRightSum = 0;
for (let i = mid + 1; i <= right; i++) {
const num = arr[i];
preRightSum += num;
if (preRightSum > rightMidMax) {
rightMidMax = preRightSum;
}
}
const crossMidMax = leftMidMax + rightMidMax;
return crossMidMax;
}
求Top K
重要指数:⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐️⭐️⭐️
在之前我们聊堆的时候,就已经提到过了这道题,当时我们说了应用堆该怎么解决这道题,当时还提到了一个算法叫做快速选择,这小节我们将会聊到它。
快速选择的思路如下:
-
选择一个"枢纽"(Pivot) :
- 从数组中随机选择一个元素作为枢纽。这个选择可以是随机的,或者使用一些策略来尝试找到一个好的枢纽。
-
分区(Partitioning) :
- 将数组分成两部分,使得所有小于枢纽的元素都在枢纽的左边,所有大于枢纽的元素都在枢纽的右边。枢纽元素本身位于最终位置。
- 这个步骤和快速排序中的分区步骤是一样的。
-
递归选择:
- 确定枢纽元素在数组中的位置
p
,这个位置是已经排定的。 - 如果
p
正好是我们要找的第 k 小的元素的位置,那么直接返回这个元素。 - 如果
k
小于p
,则在枢纽的左侧数组中递归查找第 k 小的元素。 - 如果
k
大于p
,则在枢纽的右侧数组中递归查找第k - p - 1
小的元素(因为左侧有p
个更小的元素)。
- 确定枢纽元素在数组中的位置
-
结束条件:
- 当数组的大小减少到一定程度时(例如,只剩下一个元素),直接返回该元素。
利用快速选择求Top K:
js
/**
* 求一个数组中排名为K的元素
* @param arr 元素中
* @param k 排名,即索引
* @returns
*/
export function quickSelect(arr: number[], k: number): number {
return _quickSelect(arr, 0, arr.length - 1, k);
}
/**
* 快速选择
* @param arr
* @param left
* @param right
* @param k
* @returns
*/
function _quickSelect(arr: number[], left: number, right: number, k: number) {
if (left === right) {
return arr[left];
}
let pivotIdx = partition(arr, left, right);
const distance = pivotIdx - left + 1;
if (distance === k) {
return arr[pivotIdx];
} else if (distance > k) {
return _quickSelect(arr, left, pivotIdx - 1, k);
} else if (distance < k) {
return _quickSelect(arr, pivotIdx + 1, right, k - distance);
}
}
/**
* 分区,并返回分界线的下标
* @param arr 原数组
* @param left 数组的左边下标
* @param right 数组的右边下标
* @returns
*/
function partition(arr: number[], left: number, right: number): number {
let idx = left;
let pivot = arr[right];
for (let i = left; i < right; i++) {
if (arr[i] < pivot) {
[arr[i], arr[idx]] = [arr[idx], arr[i]];
idx++;
}
}
[arr[idx], arr[right]] = [arr[right], arr[idx]];
return idx;
}
另外,关于二叉树利用前序+中序或中序+后序序列的构建也是分治算法的实践,上文中有详细的介绍,本小节就不再赘述了,有兴趣的同学可以查看上一篇文章。
动态规划
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐️⭐️⭐️⭐️
动态规划是多少同学的梦魇,我也是不例外的,动态规划是面试的高频考点,也是最容易考倒人的题目,动态规划题目的特点就是算法代码实现简单,但思考过程复杂的让人抓狂,不过,一定要静下心来分析,才能真正的理解到它。
动态规划常用于求解具有以下特征的问题:
- 最优子结构性质(Optimal Substructure) :问题可以分解为若干个相互独立且相似的子问题,每个子问题都有一个最优解。这意味着问题的整体最优解可以通过合并子问题的最优解来获得。
- 重叠子问题(Overlapping Subproblems) :在解决问题的过程中,同一个子问题可能会被多次重复求解。DP算法通过记忆化存储已经解决过的子问题的解来避免重复计算,从而提高效率。
- 状态转移方程(State Transition Equation) :问题的最优解可以通过子问题的最优解以某种方式组合得到。这种组合关系通常通过状态转移方程来表示,描述了问题的各个状态之间如何相互关联。
我目前就在专题学习动态规划的知识点,关于动态规划如果想提高的同学,可以查看代码随想录
的讲解:代码随想录 (programmercarl.com)。我已经连续学习卡哥的视频好几天了,其内容深入浅出,能够真正的帮助我们专项突破动态规划(我没有打广告,我是真心推荐良心免费学习资料)
就像视频里面卡哥提到的,学习动态规划,并不是一定最重要的就是推导出状态转移方程式,理解初始状态的初始化也同样重要。
斐波那契数列
重要指数:⭐️⭐️
难度指数:⭐️⭐
动态规划入门题,很多同学只知道使用递归(或者追加记忆化的能力进行优化),状态转移方程式就是斐波那契数列的定义,即:dp[n]=dp[n-1] + dp[n-1],dp[1] = 1, dp[2] = 2
js
/**
* @param {number} n
* @return {number}
*/
var fib = function (n) {
if (n == 0) {
return 0;
}
let fn1 = 0;
let fn2 = 1;
let fn = fn1 + fn2;
for (let i = 2; i <= n; i++) {
fn = fn1 + fn2;
fn1 = fn2;
fn2 = fn;
}
return fn;
};
爬楼梯
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐
也是动态规划入门题,不过相比求斐波拉契数列,爬楼梯需要我们自己去推导状态转移方程,所以很多同学不一定搞的明白其中的道理。
这道题是一道高频的面试题,在掘金、脉脉、或者其它一些自媒体平台上经常看到这道题的身影,我也曾经使用这道题来考察一些求职者。
对于这题,我们需要首先明确dp数组的含义,dp数组表示的是跳到当前台阶可能的方案,接着,我们需要确定状态转移方程,来到i阶台阶,它可能是从i-2台阶跳上来的,也有可能是从i-1台阶跳上来的,也就是说它第i阶台阶的跳法,由前面两阶跳法确定的,即状态转移方程式为dp[i]=dp[i-2] + dp[i-1],最后,我们需要明确初始值怎么确定,假设只有一阶台阶的时候,我们只有一种方式就跳到了终点,如果有两阶台阶,我们可以先跳一阶,再跳一阶,也可以直接跳两阶,有两种方案。
js
function climbStairs(n: number): number {
let A = 0;
let B = 1;
let C = 1;
for(let i =0;i<n;i++) {
C = A + B;
A = B
B = C
}
return C;
};
打家劫舍
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐
这题的状态转移方程比较好推导,dp数组表示能够得到的最大财富,dp[i]表示当前到房间的最大财富,要么就是偷当前房间的财富+dp[i-2]的财富,要么就是不偷,直接用dp[i-1]的财富,所以,状态转移方程式为:dp[i]=max{ dp[i-2] + value[i], dp[i-1] }
这题的难点就在于,dp数组的初始化了,dp[0]好初始化,这一点好说,关键在于,dp[1]初始化为value[1]还是max{value[0], value[1]},这个就值得探究了,我们来想一个这样的case,[2,1,1,2]
,如果我们将dp[1]初始化为1,那么实际上,我们是可以偷value[0]和value[3]的,这显然是不对的,因此,我们要将dp[1]初始化成max{value[0], value[1]},我们再思考一下,这样会不会有问题,假设现在有3个数字,我们看有没有可能取到value[1]和value[2]的场景呢,假设value[1]是这里面最大的,那么dp[1]实际上是value[1],目前dp[0]还是指代的value[0],只有可能取到dp[0] + value[2]和dp[1]进行比较,所以是无论如何都不可能取到value[1]+value[2]这种错误解的,因此我们就明确了dp[0]和dp[1]的初始化,剩下的代码就好些了。
js
function rob(nums: number[]) {
if (nums.length === 1) {
return nums[0]
}
let A = nums[0];
let B = Math.max(nums[0], nums[1]);
for (let i = 2; i < nums.length; i++) {
let cur = nums[i];
let sum = Math.max(A + cur, B);
A = B;
B = sum;
}
return B;
}
关于动态规划,还有很多经典的问题LeetCode 热题 100: 这里面的题,大家可以参考卡哥的代码随想录进行学习,掌握母题,举一反三,进而让我们在面对动态规划的题目时,不至于那么手足无措。
回溯算法
重要指数:⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐⭐️⭐️
回溯算法是一种通过探索所有可能的候选解来找出所有解的解题策略。通常,它被认为是一种暴力搜索法,说白了就是目前还没有其他的优化方案,只能依次的进行尝试,我们不过是利用了计算机处理的快的特点,才有可能在短时间内得到答案。
因为它尝试从所有可能的解中找出符合条件的解。回溯算法的核心是"深度优先搜索"(DFS)加上一些剪枝操作。在解决问题的过程中,当它发现已不满足求解条件时,会"回溯"到上一个步骤,尝试其他可能的路径。
回溯算法的基本步骤:
-
选择:从候选解中选择一个可能的选项。
-
约束:根据问题的限制条件,对选择的选项进行约束。如果当前选择导致无法满足限制条件,就放弃这个选项,尝试其他选项。
-
目标:检查当前的解是否满足最终的目标。如果是,将其添加到解集中。
-
回溯:当当前分支的选择不再可能导致解时,回溯到上一步,撤销上一步的选择,并尝试其他选项。
回溯算法的经典应用:
-
组合问题:如求解子集、排列、组合。
-
分割问题:如分割字符串使得分割出的子串满足特定条件。
-
棋盘问题:如八皇后问题,其中需要在棋盘上放置皇后,同时满足一定的约束条件。
-
图相关的问题:如图的着色问题,其中要求相邻的节点不能染相同的颜色。
-
路径问题:在网格或图中寻找从起点到终点的路径,例如迷宫问题。
回溯算法的特点:
-
时间复杂度:通常比较高,因为它需要遍历所有可能的解。在最坏情况下,可能需要指数级时间。
-
空间复杂度:由于使用了递归调用,其空间复杂度通常与递归的深度成正比。
-
剪枝操作 :在搜索过程中,合理的剪枝可以大幅度减少搜索空间,提高效率,因为回溯实际上我们在求解时递归的过程如果我们展开来看,递归操作其实就是在构建解空间的树,如果我们直接去掉这个树的某一个子树,将会极大的缩小解空间的范围,进而提高算法的效率。
回溯算法是解决复杂问题和优化问题的有力工具,尤其适用于问题的解集较大或问题规模较小的情况。在实际应用中,合理设计回溯条件和剪枝策略是提高效率的关键。
例题
回溯这一节考察的是求职者综合解题的能力,我在之前就聊过了一句话,"链表无难题,回溯无Easy",所以,想要较好的掌握回溯问题,需要多看多练多想,这个章节没有捷径可言。
全排列
重要指数:⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐⭐️⭐️
复原IP地址
重要指数:⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐⭐️⭐️
N皇后问题
重要指数:⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐⭐️⭐️
贪心算法
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐⭐️⭐️
最后,最简单,也是最难的一个小节,就是贪心算法了,这个部分,我也跟很多的同事沟通过,有个后端同事甚至能够面过字节跳动(定级2-2),他也表示贪心算法无感。
贪心算法说起来简单也简单,在对问题求解时,总是做出在当前看来是最好的选择,就能得到问题的答案。贪心算法难就难在需要充分挖掘题目中条件,没有固定的模式,解决贪心算法需要一定的直觉和经验。
对于这方面的题目,我们也只能多看,多积累,多练习,我目前也没有什么好的经验,不过,贪心算法确实也是面试中考察的重点之一,为了进一个好的公司,大家都努力吧,😭。
贪心算法和动态规划的区别
贪心算法和动态规划都有最优结构的需求,它们在某些问题上可能会看起来容易混淆,但它们的策略、应用范围和性能各有不同。以下是它们之间的主要区别:
贪心算法:
-
策略:贪心算法在每一步都做出一个局部最优的选择,希望这些局部最优能导致全局最优解。换句话说,它只考虑当前的情况,不会考虑决策的长远影响。
-
回溯:一旦做出了选择,就不会撤销。
-
应用范围:贪心算法适用于那些局部最优选择能够确保找到全局最优解的问题。如霍夫曼编码、最小生成树问题(如 Prim 和 Kruskal 算法)。
-
性能:通常来说,贪心算法的时间复杂度较低,但它并不总能得到最优解。
-
简单性:编写起来通常比动态规划简单。
动态规划:
-
策略:动态规划通过将问题分解为相互重叠的子问题,逐步解决小问题,再合并结果来解决大问题。它考虑了每个决策阶段的所有可能选择,并选择总体上最优的那个。
-
回溯:它会保存以前的结果,并根据以前的结果对当前进行选择。可以回溯到以前的状态。
-
应用范围:动态规划适用于有重叠子问题和最优子结构的问题。例如,斐波那契数列、背包问题、最长公共子序列。
-
性能:尽管动态规划能确保找到最优解,但它可能会因为考虑过多的可能性而导致较高的时间和空间复杂度。
-
复杂性:动态规划的实现通常比贪心算法更复杂,尤其是状态转移方程和边界条件的确定。
总结来说,贪心算法在每一步都做出最优选择,并且不回头看;而动态规划则通过解决子问题来构建最终解,并能回溯到以前的状态。贪心算法更简单、更快,但不总是能得到最优解;动态规划更复杂、更慢,但能确保找到最优解。
例题
分发饼干
重要指数:⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐
买卖股票
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐⭐
122. 买卖股票的最佳时机 II - 力扣(LeetCode)
跳跃游戏
重要指数:⭐️⭐️⭐️⭐️⭐️
难度指数:⭐️⭐⭐⭐️⭐️
总结
通过3篇文章,为大家梳理了一些前端面试编程题常见的知识点,这些题需要因人而异,对于大多数人来说,掌握数据结构方面的知识更加的容易,因为它存在定式,而算法方面的考察就比较宽泛了,这更多的取决于运气,即你和面试官的知识重叠程度。
需要注意一点儿的就是,算法只是你的一方面,在面试的准备环节中,核心还是项目亮点(经常看到某些论坛上的朋友说面某某公司上来就是一道困难题,多半就是不匹配,面试官只想提前结束面试而已,😭),这也说明了投机取巧是不如踏实苦干的,在做项目的时候要多思考,有没有什么优化的点,优化之后能够得到什么收益,坚持好了这种习惯,总有一天量变会引发质变的,那么恭喜你,你又迈上了新的台阶。我们在平时较为空闲的时间可以花时间来学习算法和数据结构,尤其是觉得自己"瓶颈了",看什么东西都静不下心的时候,不妨学习算法,算是扩展我们的工具技能箱里面的工具了。
对于这3篇文章的重要程度的来排序的话,分别是由高到低的顺序,但是难度反而又是由低到高的顺序,各位读者在实际准备的过程中学会取舍,如果你要面试的是国内一线公司的话(比如字节跳动),那这一篇文章阐述的内容则需要重视起来,如果面试的是国内二三线公司的话,那可以将精力重点放在前面两篇文章。
最后,祝大家都能在今年的金三银四的时间周期内找到自己心仪的公司,祝愿大家一切顺利,加油,各位同行。
本文的很多题目我是没有给出示例算法的(有些题我也不会做,但是经常在各大论坛看到,所以贴出来),有兴趣的同学可以在评论区一起讨论,对于这些题目的实现有困难的也可以在评论区留言。
对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。
如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。