介绍
传统的 面试过程 通常以最基本的如何编写 手机屏幕页面 问题为开始,然后通过全天的 现场工作 来检验 编码能力 和 文化契合 度。 几乎无一例外,决定性的因素还是 编码能力。 毕竟,工程师是靠一天结束之时产出可使用的软件来获得报酬的。一般来说,我们会使用 白板 来测试这种编码能力。比获得正确答案更重要的是清晰明了的思考过程。编码和生活一样,正确的答案不总是显而易见的,但是好的论据通常是足够好的。 有效 的 推理 能力标志着学习,适应和发展的潜力。最好的工程师总是在成长,最好的公司总是在不断创新。
算法挑战 是有效的锻炼能力的方法,因为总有不止一种的方法来解决它们。这为决策和演算决策提供了可能性。当解决算法问题的时候,我们应该挑战自我,从多个角度来看 问题的定义 ,然后权衡各种方式的 益处 和 缺陷 。通过足够的联系,我们甚至可以一瞥宇宙的真理; 没有“完美”的解决方案 。
真正掌握 算法 就是去理解 数据 和 结构 之间的关系。数据结构和算法之间的关系,就如同“阴”之于“阳”, 玻璃杯 之于 水 。没有玻璃杯,水就无法被承载。没有数据结构,我们就没有可以用于逻辑的对象。没有水,玻璃杯会因为缺乏物质而变空。没有算法,对象就无法被转化或者“消费”。
引言
应用于代码中,一个算法只是一个把确定的 数据结构 的 输入 转化为一个确定的 数据结构 的 输出 的 function
。算法 内在 的 逻辑 决定了如何转换。首先,输入和输出应该被 明确 定义为 单元测试。这需要完全的理解手头的问题,这是不容小觑的,因为彻底分析问题可以无需编写任何代码,就自然地解决问题。
一旦彻底掌握问题的领域,就可以开始对解决方案进行 头脑风暴 。 需要哪些变量?需要多少循环以及哪些类型的循环?有没有巧妙的内置的方法可以提供帮助?需要考虑哪些边缘情况? 复杂和重复的逻辑只会徒增阅读和理解的难度。 帮助函数可以被抽象或者抽离吗? 算法通常需要是可扩展的。 随着输入规模的增加,函数将如何执行? 是否应该有某种缓存机制? 而性能优化(时间)通常需要牺牲内存空间(增加内存消耗)。
为了使问题更具体,让我们来绘制一个 图表 !
当解决方案中的高级结构开始出现时,我们就可以开始写 伪代码 了。为了给面试官留下真正的印象, 请 优先 考虑代码的重构和 复用 。有时,行为类似的函数可以合并成一个可以接受额外参数的更通用的函数。其他时候,去参数化会更好。保持函数的 纯净 以便于测试和维护也是很有先见之明的。换言之,设计算法时,将 架构 和 设计模式 纳入到你的考虑范围内。
如果有任何不清楚的地方,请 提问 以便说明!
Big O(算法的复杂度)
为了估算算法运行时的复杂度,在计算算法所需的 操作次数 之前,我们通常把 输入大小 外推至无穷来估算算法的可扩展性。在这种最坏情况的运行时上限情况下,我们可以忽略系数以及附加项,只保留主导函数的因子。因此,只需要几种类型就可以描述几乎所有的可扩展算法。
最优最理想的算法,是在时间和空间维度以 常数 速率变化。这就是说它完全不关心输入大小的变化。次优的算法是对时间或空间以 对数 速率变化,再次分别是 线性 , 线性对数 , 二次 和 指数 型。最糟糕的是对时间或空间以 阶乘 速率变化。在 Big-O 表示法中:
- 常数: O(1)
- 对数: O(log n)
- 线性: O(n)
- 线性对数: O(n log n)
- 二次: O(n²)
- 指数: O(2^n)
- 阶乘: O(n!)
当我们考虑算法的时间和空间复杂性之间的权衡时,Big-O 渐近分析 是不可或缺的工具。然而,Big O 忽略了在实际实践中可能有影响的常量因素。此外,优化算法的时间和空间复杂性可能会增加现实的开发时间或对代码可读性产生负面影响。在设计算法的结构和逻辑时,对真正可忽略不计的东西的直觉同样重要。
Arrays(数组)
最干净的算法通常会利用语言中固有的 标准 对象。可以说计算机科学中最重要的是Arrays
。在JavaScript中,没有其他对象比数组拥有更多的实用工具方法。值得记住的数组方法是: sort
, reverse
, slice
, 以及 splice
。数组从 第0个索引 开始插入数组元素。这意味着最后一个数组元素的位置是 array.length — 1
。数组是 索引 (推入) 的最佳选择,但对于 插入, 删除 (不弹出), 和 搜索 等动作非常糟糕。在 JavaScript 中, 数组可以 动态 增长。
对应的 Big O :
- 索引: O(1)
- 插入: O(n)
- 删除: O(n)
- 暴力搜索: O(n)
- 优化搜索: O(log n)
完整的阅读 MDN 有关 Arrays 的文档也是值得的。
类似数组的还有 Sets
和 Maps
. 在 set 中,元素一定是 唯一 的。在 map 中,元素由字典式关系的 键 和 值 组成。当然,Objects
(and their literals) 也可以储存键值对,但键必须是 strings
类型。
Object Object构造函数创建一个对象包装器 developer.mozilla.org
迭代
与 Arrays
密切相关的是使用循环 遍历 它们。在 JavaScript 中,我们可以用 五种 不同的 控制结构 来迭代。可定制化程度最高的是 for
循环,我们几乎可以用它以任何顺序来遍历数组 索引 。如果无法确定 迭代次数 ,我们可以使用 while
和 do while
循环,直到遇到一个满足确定条件的情况。对于任何对象,我们可以使用 for in
和 for of
循环来分别迭代它的“键”和“值”。要同时获取“键”和“值”,我们可以使用它的 entries()
方法。我们可以通过 break
语句随时 中断循环 break
, 或者使用 continue
语句 跳到 。在大多数情况下,通过 generator
函数来控制迭代是最好的选择。
原生的遍历所有数组项的方法是: indexOf
, lastIndexOf
, includes
, fill
和 join
。 另外,我们可以为以下方法提供 回调函数
: findIndex
, find
, filter
, forEach
, map
, some
, every
和 reduce
。
递归
在一篇开创性的论文 Church-Turing Thesis 中,证明了任何迭代函数都可以用递归函数重写,反之亦然。有时,递归方法更简洁,更清晰,更优雅。就用这个 factorial
阶乘迭代函数来举例:
const **factorial** = number => {
let product = 1;
for (let i = 2; i <= number; i++) {
product *= i;
}
return product;
};
复制代码
用 recursive
递归函数来写,只需要 一行 代码!
const **factorial** = number => {
return number < 2 ? 1 : number * factorial(number - 1);
};
复制代码
所有递归函数都有一个 通用模式 。它们总是由一个调用自身的 递归部分 和一个不调用自身的 基本情形 组成。当一个函数调用自己的时候,它就会将一个新的 执行上下文
推送到 执行堆栈
里。这种情况会一直持续进行下去,直到遇到 基本情形 ,然后 堆栈 逐个弹出展开成 各个上下文。因此,草率的依赖递归会导致可怕的运行时 堆栈溢出
错误。
factorial
阶乘函数的代码示例:
终于,我们准备好接受任何算法挑战了!😉
热门的算法问题
在本节中,我们将按照难度顺序浏览22个 经常被问到的 算法问题。我们将讨论不同的方法和它们的利弊以及运行中的时间复杂性。最优雅的解决方案通常会利用特殊的 “技巧” 或者敏锐的洞察力。记住这一点,让我们开始吧!
1. 反转字符串
把一个给定的 一串字符
当作 输入 ,编写一个函数,将传入字符串 反转 字符顺序后返回。
describe("String Reversal", () => {
it("**Should reverse string**", () =\> {
assert.equal(reverse("Hello World!"), "!dlroW olleH");
});
});
复制代码
分析:
如果我们知道“技巧”,那么解决方案就不重要了。技巧就是意识到我们可以使用 数组 的内置方法 reverse
。首先,我们对 字符串 使用 split
方法生成一个 字符数组 ,然后我们可以用 reverse
方法,最后用 join
方法将字符数组重新组合回一个 字符串。这个解决方案可以用一行代码来完成!虽然不那么优雅,但也可以借助最新的语法和帮助函数来解决问题。使用新的 for of
循环迭代字符串中的每一个字符,可以展示出我们对最新语法的熟悉情况。或者,我们可以用数组的 reduce
方法,它使我们不再需要保留临时基元。
对于给定的字符串的每个字符都要被“访问”一次。虽然这中访问会多次发生,但是 时间 可以被归一化为 线性 时间。并且因为没有单独的内部状态需要被保存,因此 空间 是 恒定 的。
2. 回文
回文 是指一个 单词
或 短语
正向和反向