learn-tech/专栏/重学数据结构与算法-完/19真题案例(四):大厂真题实战演练.md
2024-10-16 11:12:24 +08:00

196 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
19 真题案例(四):大厂真题实战演练
这个课时,我们找一些大厂的真题进行分析和演练。在看真题前,我们依然是再重复一遍通用的解题方法论,它可以分为以下 4 个步骤:
复杂度分析。估算问题中复杂度的上限和下限。
定位问题。根据问题类型,确定采用何种算法思维。
数据操作分析。根据增、删、查和数据顺序关系去选择合适的数据结构,利用空间换取时间。
编码实现。
大厂真题实战演练
例题 1判断数组中所有的数字是否只出现一次
【题目】 判断数组中所有的数字是否只出现一次。给定一个个数字 arr判断数组 arr 中是否所有的数字都只出现过一次。约束时间复杂度为 O(n)。例如arr = {1, 2, 3},输出 YES。又如arr = {1, 2, 1},输出 NO。
【解析】 这个题目相当于一道开胃菜,也是一道送分题。我们还是严格围绕解题方法论,去拆解这个问题。
我们先来看一下复杂度。判断是否所有数字都只出现一次,很显然我们需要对每个数字进行遍历,因此时间复杂度为 O(n)。而每次的遍历,都要判断当前元素在先前已经扫描过的区间内是否出现过。由于此时并没有额外信息(例如数组有序)输入,因此,还需要 O(n) 的时间进行判断。综合起来看就是 O(n²) 的时间复杂度。这显然与题目的要求不符合。
然后我们来定位问题。根据题目来看,你可以理解这是一个数据去重的问题。但是由于我们并没有学过太多解决这类问题的算法思维,因此我们不妨再从数据操作的视角看一下。
按照解题步骤,接下来我们需要做数据操作分析。 每轮迭代需要去判断当前元素在先前已经扫描过的区间内是否出现过,这就是一个查找的动作。也就是说,每次迭代需要对数据进行数值特征方面的查找。这个题目只需要判断是否有重复,并不需要新增、删除的动作。
在优化数值特性的查找时,我们应该立马想到哈希表。因为它能在 O(1) 的时间内完成查找动作。这样,整体的时间复杂度就可以被降低为 O(n) 了。与此同时,空间复杂度也提高到了 O(n)。
根据上面的思路进行编码开发,具体代码如下:
public static void main(String[] args) {
int[] arr = { 1, 2, 3 };
boolean isUniquel = isUniquel(arr);
if (isUniquel) {
System.out.println("YES");
} else {
System.out.println("NO");
}
}
public static boolean isUniquel(int[] arr) {
Map<Integer, Integer> d = new HashMap<>();
for (int i = 0; i < arr.length; i++) {
if (d.containsKey(arr[i])) {
return false;
}
d.put(arr[i], 1);
}
return true;
}
我们对代码进行解读在主函数第 19 行中调用 isUniquel() 函数进行判断并根据结果打印 YES 或者 NO在函数 isUniquel() 12 行定义了一个 k-v 结构的 map
接着 13 行开始 arr 的每个元素进行循环如果 d 中已经存在 arr[i] 那么就返回 false 1416 否则就把 arr[i],1 k,v 关系放进 d 17 )。
这道题目比较简单属于数据结构的应用范畴
例题 2找出数组中出现次数超过数组长度一半的元素
题目 假设在一个数组中有一个数字出现的次数超过数组长度的一半现在要求你找出这个数字
你可以假设一定存在这个出现次数超过数组长度的一半的数字即不用考虑输入不合法的情况要求时间复杂度是 O(n)空间复杂度是 O(1)。例如输入 a = {1,2,1,1,2,4,1,5,1},输出 1
解析先来看一下时间复杂度的分析一个直观想法是一边扫描一边记录每个元素出现的次数并利用 k-v 结构的哈希表存储例如一次扫描后得到元素-次数1-52-24-15-1的字典接着再在这个字典里去找到次数最多的元素这样的时间复杂度和空间复杂度都是 O(n)。不过可惜这并不满足题目的要求
接着我们需要定位问题 从问题出发这并不是某个特定类型的问题而且既然空间复杂度限定是 O(1)也就意味着不允许使用任何复杂的数据结构也就是说数据结构的优化不可以用算法思维的优化也不可以用
面对这类问题我们只能从问题出发看还有哪些信息我们没有使用上题目中有一个重要的信息是这个出现超过半数的数字一定存在回想我们上边的解法它可以找到出现次数最多的数字但没有使用到必然超过半数这个重要的信息
分析到这里我们不妨想一下这个场景假设现在三国交战其中 A 国的兵力比 B 国和 C 国的总和还多那么人们就常常会说哪怕是 A 国士兵一个碰一个地和另外两国打消耗战都能取得最后的胜利
说到这里不知道你有没有一些发现。“一个碰一个的思想那就是如果相等则加 1如果不等则减 1这样只需要记录一个当前的缓存元素变量和一个次数统计变量就可以了
根据上面的思路进行编码开发具体代码为
public static void main(String[] args) {
int[] a = {1,2,2,1,1,4,1,5,1};
int result = a[0];
int times = 1;
for (int i = 1; i < a.length; i++) {
if (a[i] != result) {
times--;
}
else {
times++;
}
if (times == -1) {
times = 1;
result = a[i];
}
}
System.out.println(result);
}
我们对代码进行解读 34 初始化变量结果 result 赋值为 a[0]次数 times 1
接着进入循环体执行一个碰一个”,即第 611
如果当前元素与 a[i] 不相等次数减 1
如果当前元素与 a[i] 相等次数加 1
当次数降低为 -1 则发生了结果跳转此时result 更新为 a[i]次数重新置为 1最终我们就在 O(n) 的时间复杂度下O(1 )的空间复杂度下找到了结果
例题 3给定一个方格棋盘从左上角出发到右下角有多少种方法
题目 在一个方格棋盘里左上角是起点右下角是终点每次只能向右或向下移向相邻的格子同时棋盘中有若干个格子是陷阱不可经过必须绕开行走
要求用动态规划的方法求出从起点到终点总共有多少种不同的路径例如输入二维矩阵 m 代表棋盘其中1 表示格子可达-1 表示陷阱输出可行的路径数量为 2
解析 题目要求使用动态规划的方法这是我们解题的一个难点也正是因为这一点限制才让这道题目区别于常见的题目
对于 O2O 领域的公司尤其对于经常要遇到有限资源下去最优化某个目标的岗位时动态规划应该是高频考察的内容我们依然是围绕动态规划的解题方法从寻找最优子结构的视角去解决问题
千万别忘了动态规划的解题方法是分阶段找状态做决策状态转移方程定目标寻找终止条件
我们先看一下这个问题的阶段很显然从起点开始每一个移动动作就是一个阶段的决策动作移动后到达的新的格子就是一个状态
状态的转移和先前的最短路径问题非常相似假定棋盘的维度是例子中的 3 x 6那么起点标记为 m[0,0]终点标记为 m[2,5]。利用 V(m[i,j]) 表示从起点到 m[i,j] 的可行路径总数那么则有
V(m[i,j]) = V(m[i-1,j]) + V(m[i,j-1])。
也就是说到达某个格子的路径数等于到达它左边格子的路径数加上到达它上边格子的路径数我们的目标也就是根据 m 矩阵求解出 V(m[2,5])。
最后再来看一下终止条件起点到起点只有一种走法因此V(m[0,0]) = 1同时所有棋盘外的区域也是不可抵达的因此 V(m[-, ]) = 0V(m[ , - ]) = 0需要注意的是根据题目的信息标记为 -1 的格子是不得到达的也就是说如果 m[i,j] -1 V(m[i,j]) = 0
分析到了这里我们可以得出了一个可行的解决方案根据状态转移方程就能寻找到最优子结构 V(m[i,j]) = V(m[i-1,j]) + V(m[i,j-1])。
很显然我们可以用递归来实现其他需要注意的地方例如终止条件棋盘外区域以及棋盘内不可抵达的格子我们都已经定义好接下来就可以进入开发阶段了具体代码如下
public static void main(String[] args) {
int[][] m = {{1,1, 1, 1, 1,1}, {1,1,-1,-1,1,1}, {1,1,-1, 1,-1,1}};
int path = getpath(m,2,5);
System.out.println(path);
}
public static int getpath(int[][] m, int i, int j) {
if (m[i][j] == -1) {
return 0;
}
if ((i > 0) && (j > 0)) {
return getpath(m, i-1, j) + getpath(m, i, j-1);
}
else if ((i == 0) && (j > 0)) {
return getpath(m, i, j-1);
}
else if ((i > 0) && (j == 0)){
return getpath(m, i-1, j);
}
else {
return 1;
}
}
我们对代码进行解读。第 15 行为主函数。在主函数中,定义了 m 数组,就是输入的棋盘。在其中,数值为 -1 表示不可抵达。随后第 3 行代码调用 getpath 函数来计算从顶点到 m[2,5] 位置的路径数量。
接着进入第 723 行的getpath()函数,用来计算到达 m[i,j] 的路径数。在第 810 行进行判断:如果 m[i][j ]== -1也就是当前格子不可抵达则无须任何计算直接返回 0 即可。如果 m[i][j] 不等于 -1则继续往下判断。
如果 i 和 j 都是正数,也就是说,它们不在边界上。那么根据状态转移方程,就能得到第 12 行的递归执行动作,即到达 m[i,j] 的路径数,等于到达 m[i-1,j] 的路径数,加上到 达 m[i,j-1] 的路径数。
如果 i 为 0而 j 还是大于 0 的,也就是说此时已经到了最左边的格子了,则直接返回 getpath(m, i, j-1) 就可以了。
如果 i 为正,而 j 已经变为 0 了,同理直接返回 getpath(m, i-1, j) 就可以了。
剩下的 else 判断是,如果 i 和 j 都变成了 0则说明在起点。此时起点到起点的路径数是 1这就是终止条件。
根据这个例子不难发现,动态规划的代码往往并不复杂。关键在于你能否把阶段、状态、决策、状态转移方程和终止条件定义清楚。
总结
在备战大厂面试时,一定要加强问题解决方法论的沉淀。绝大多数一线的互联网公司讲究的是解决问题的规范性,这就决定了其更关注的是问题解决过程的步骤、方法或体系,而不仅仅是解决后的结果。
练习题
下面我们给出一个练习题,帮助你巩固本课时讲解的解题思路和方法。
【题目】 小明从小就喜欢数学,喜欢在笔记里记录很多表达式。他觉得现在的表达式写法很麻烦,为了提高运算符优先级,不得不添加很多括号。如果不小心漏了一个右括号的话,就差之毫厘,谬之千里了。
因此他改用前缀表达式,例如把 (2 + 3) * 4写成* + 2 3 4这样就能避免使用括号了。这样的表达式虽然书写简单但计算却不够直观。请你写一个程序帮他计算这些前缀表达式。
在这个题目中输入就是前缀表达式输出就是计算的结果。你可以假设除法为整除即“5/3=1”。例如输入字符串为 + 2 3输出 5输入字符串为 * + 2 2 3输出为 12输入字符串为 * 2 + 2 3输出为 10。
我们给出一些提示。假设输入字符串为 * 2 + 2 3即 2*(2+3)。第一个字符为运算符号 *,它将对两个数字进行乘法。如果后面紧接着的字符不全是数字字符,那就需要暂存下来,先计算后面的算式。一旦后面的计算完成,就需要接着从后往前去继续计算。
因为从后往前是一种逆序动作,我们能够很自然地想到可以用栈的数据结构进行存储。你可以尝试利用栈,去解决这个问题。