摘要:最近,看了下慕课2周刷完n道面试题,记录下...
1. 求一个二叉搜索树的第k小值
二叉树(Binary Tree)
是一棵树
每个节点最多两个子节点
树节点的数据结构{value, left?, right?}
二叉树的遍历
前序遍历:root→left→right
中序遍历:left→root→right
后序遍历:left→right→root
二叉搜索树BST(Binary Search Tree)
left(包括其后代) value ≤ root value
right (包括其后代) value ≥ root value
可使用二分法进行快速查找
解题思路:BST中序遍历,从小到大的排序
找到排序后的第k个值
/**
* 二叉搜索树
*/
interface ITreeNode {
value: number
left: ITreeNode | null
right: ITreeNode | null
}
const arr: number[] = []
/**
* 二叉树前序遍历
*/
function preOrderTraverse(node: ITreeNode | null) {
if ( node == null) return
//console.log(node.value)
arr.push(node.value)
preOrderTraverse(node.left)
preOrderTraverse(node.right)
}
/**
* 二叉树中序遍历
*/
function inOrderTraverse(node: ITreeNode | null) {
if ( node == null) return
inOrderTraverse(node.left)
// console.log(node.value)
arr.push(node.value)
inOrderTraverse(node.right)
}
/**
* 二叉树后序遍历
*/
function postOrderTraverse(node: ITreeNode | null) {
if ( node == null) return
postOrderTraverse(node.left)
postOrderTraverse(node.right)
// console.log(node.value)
arr.push(node.value)
}
/**
* **寻找BST中的第k小值**
*/
function getKthValue(node: ITreeNode, k: number): number | null {
inOrderTraverse(node)
console.log(arr)
return arr[k-1] || null
}
const bst: ITreeNode = {
value: 5,
left: {
value: 3,
left: {
value: 2,
left: null,
right: null
},
right: {
value: 4,
left: null,
right: null
}
},
right: {
value: 7,
left: {
value: 6,
left: null,
right: null
},
right: {
value: 8,
left: null,
right: null
}
}
}
//preOrderTraverse(tree)
平衡二叉树 | HZFE - 剑指前端 Offer题目描述https://febook.hzfe.org/awesome-interview/book1/algorithm-balanced-binary-trees 扩展:为何二叉树如此重要,而不是三叉树、四叉树?
性能、性能、还是性能!重要的事情说三遍
数组:查找快O(1),增删慢O(n);链表:查找慢O(n),增删快O(1)
二叉搜索树BST:查找快、增删快—"木桶效应"
平衡二叉树
BST如果不平衡,那就又成了链表
所以要尽量平衡:平衡二叉搜索树BBST(其增删查,时间复杂度都是O(logn),即树的高度)
红黑树:本质是一种自平衡二叉树
分为红/黑两种颜色,通过颜色转换来维持输的平衡
相对于普通平衡二叉树,它维持平衡的效率更高
B树
物理上是多叉树,但逻辑上是二叉树
一般用于高效I/O, 关系型数据库常用B树 来组织数据
扩展2:堆有什么特点?和二叉树又什么关系?
堆栈模型
JS执行时,值类型变量,存储在栈中;引用类型变量,存储在堆中
堆是完全二叉树
最大堆:父节点 ≥子节点
最小堆:父节点≤子节点
满二叉树(又叫完美二叉树):所有层的节点都被填满;
完全二叉树:最底层节点靠左填充,其它层节点全被填满
7.1 二叉树 - Hello 算法动画图解、一键运行的数据结构与算法教程https://www.hello-algo.com/chapter_tree/binary_tree/#1_1 逻辑结构 VS 物理结构
堆:逻辑结构是一颗二叉树,但它的物理结构式一个数组
堆的使用场景
特别适合"堆栈模型"
堆的数据,都是在栈中引用的,不需要从root遍历
堆恰巧是数组形式,根据栈的地址,可用O(1)找到目标
2. JS计算斐波那契数列的第n个值
/**
* 斐波那契额数列(递归)
*/
function fibonacci(n:number): number{
if(n <=1 ) return n
return fibonacci(n-1) + fibonacci(n-2)
}
递归有大量重复计算,其时间复杂度是O(2^n)
优化:不用递归用循环,记录中间结果,时间复杂度O(n)
/**
* 斐波那契额数列(循环)
*/
function fibonacci(n:number): number{
if(n <=1 ) return n
let n1 = 1 //记录n-1的结果
let n2 = 0 //记录n-2的结果
let res = 0
for(let i = 2; i <= n; i++) {
res = n1 + n2;
// 记录中间结果
n2 = n1
n1 = res
}
return res
}
动态规划:
把一个大问题,拆解为一个小问题,逐级向下拆解
用递归的思想去分析问题,再改用循环来实现
算法三大思维:贪心、二分、动态规划
扩展:青蛙跳台阶问题,一只青蛙,一次可跳1级,也可跳两级,请问青蛙跳到n级台阶,总共有多少种方式?
第一次跳1级则有f(n-1)种方式,跳2级则有f(n-2)种方式,则结果和斐波那契额数列一样。
3. 将数组的0 移动到末尾
如输入[1,0,3,0,11,0],输出[1,3,11,0,0,0],只移动0,其他顺序不变;必须在原数组进行操作
传统思路
遍历数组,遇到0则push到数组末尾
用splice截取当前元素
时间复杂度O(n^2)—算法不可用
数组是连续存储,要慎用splice unshift 等API
/**
* 移动0到数组末尾(嵌套循环)
*/
function moveZero1(arr:number[]):void {
const length = arr.length
if(length === 0) return
let zeroLength = 0
// **O(n^2)**
for (let i = 0; i < length - zeroLength; i++) {
if (arr[i] === 0) {
arr.push(0)
arr.splice(i,1) // 本身就有O(n)
i-- //数组接去了一个元素,i要递减,否则连续0就会有错误
zeroLength++ // 累加0的长度
}
}
}
双指针思路(解决嵌套循环的有效)
定义j指向第一个0,i指向j后面的第一个非0
交换i和j的值,继续向后移动
只遍历一次,所以时间复杂度是O(n)
/**
* 移动0到数组末尾(双指针)
*/
function moveZero2(arr:number[]):void {
const length = arr.length
if(length === 0) return
let i
let j = -1 // 指向第一个0
for(i=0; i < length; i++) {
if(arr[i] === 0) {
if (j < 0) { // 第一个0
j = i
}
}
if(arr[i] !== 0 && j >=0 ) {
const n = arr[i]
arr[i] = arr[j]
arr[j] = n
j++
}
}
}
4. 获取字符串中连续最多的字符,以及次数
如输入'abbcccddeeee1234',计算得到连输最多的字符是'e',为4次
传统思路
嵌套循环,找出每个字符的连续次数,并记录
看似时间复杂度是O(n^2)
但实际时间复杂度是多少?—O(n),因为有'跳步'
/**
* 求连续最多的字符和次数(嵌套循环)
*/
interface IRes {
char: string
length: number
}
function findContinuousChars1(str:string):IRes {
const res:IRes = {
char: '',
length: 0
}
const length = str.length
if (length === 0) return res
let tempLength = 0 //临时记录当前连续字符串的长度
// 时间复杂度O(n)
for(let i = 0; i < length; i++) {
tempLength = 0 // 重置
for(let j = i; j < length; j++) {
if (str[i] === str[j]) {
tempLength++
}
if(str[i] !== str[j] || j === length-1) {
// 不相等,或者已经到最后一个元素。要去判断最大值
if (tempLength > res.length) {
res.char = str[i]
res.length = tempLength
}
if (i < length - 1) {
i = j -1 // 跳步
}
break
}
}
}
return res
}
双指针思路(适用于解决嵌套循环类问题)
定义指针i和j;j不动,i继续移动
如果i和j的值一直相等,则i继续移动
直到i和j的值不相等,记录处理,让j追上i。继续第一步
/**
* 求连续最多的字符和次数(双指针)
*/
interface IRes {
char: string
length: number
}
function findContinuousChars2(str:string):IRes {
const res:IRes = {
char: '',
length: 0
}
const length = str.length
if (length === 0) return res
let tempLength = 0 //临时记录当前连续字符串的长度
// 时间复杂度O(n)
let i = 0
let j = 0
for(; i < length; i++) {
if(str[i] === str[j]) {
tempLength++
}
if(str[i] !== str[j] || i === length-1) {
// 不相等,或者i到了字符串的末尾
if(tempLength > res.length) {
res.char = str[j]
res.length = tempLength
}
tempLength = 0 //重置长度
if(i < length - 1) {
j = i //让j"追上" i
i--
}
}
}
return res
}
ps:算法题尽量使用低级的代码,慎用语法糖或者高级API
5. 用JS实现快速排序,并说明时间复杂度
固定算法和思路
- 找到中间位置midValue
- 遍历数组,小于midValue放在left,否则放在right
- 继续递归,最后concat拼接,返回
获取midValue的两种方式:
使用splice,会修改原数组
使用slice,不会修改原数组 — 更推荐
/**
* 快速排序(使用splice)
*/
function quickSort1(arr: number[]):number[] {
const length = arr.length
if(length === 0) return arr
const modIndex = Math.floor(length / 2)
const midValue = arr.splice(midIndex, 1)[0] // splice和slice返回的都是数组
const left: number[] = []
const right: number[] = []
// O(n*logn)
for(let i = 0; i < arr.length; i++) { // 细节,不直接使用length是由于splice已经改变数组
const n = arr[i]
// 二分后递归遍历O(logn)
if (n < midValue) {
// 小于midValue, 则放在left
left.push(n)
} else{
// 大于midValue,则放在right
right.push(n)
}
}
return quickSort1(left).concat([midValue], quickSort1(right))
}
/**
* 快速排序(使用slice)
*/
function quickSort2(arr: number[]):number[] {
const length = arr.length
if(length === 0) return arr
const modIndex = Math.floor(length / 2)
const midValue = arr.slice(midIndex, midIndex + 1)[0] // splice和slice返回的都是数组
const left: number[] = []
const right: number[] = []
for(let i = 0; i < length; i++) {
if (i !== midIndex) {
const n = arr[i]
if (n < midValue) {
// 小于midValue, 则放在left
left.push(n)
} else{
// 大于midValue,则放在right
right.push(n)
}
}
}
return quickSort2(left).concat([midValue], quickSort2(right))
}
快速排序,有遍历有二分时间复杂度为O(nlogn); 常规排序,嵌套循环,复杂度是O(n^2)
此处,splice和slice没区分出来
算法本身时间复杂度就够高O(nlogn)
外加,splice是逐步二分后执行的,二分会快速消减数量级
如果单独使用splice和slice,效果会很明显
6. 获取1-10000之前所有的对称数
例如:1, 2, 11, 22, 101, 232, 1221……
思路1 使用数组反转、比较
- 数字转换为字符串,再反转为数组
- 数组reverse,再join为字符串
- 前后字符串进行比较
/**
* 查询1-max的所有对称数(数组反转)
*/
function findPalindromeNumbersa(max: number): number[] {
const res: number[] = []
if (max <= 0) return res
for(let i = 1; i <= max; i++) {
// 转换为字符串,转换为数组,再反转, 比较
const s = i.toString()
if (s === s.split('').reverse().join('')) {
res.push(i)
}
}
return res
}
思路2 字符串头尾比较
数字转换为字符串
字符串头尾字符比较
(也可以用栈,像括号匹配,但需要注意奇偶数 )
/**
* 查询1-max的所有对称数(字符串前后比较)
*/
function findPalindromeNumbersa(max: number): number[] {
const res: number[] = []
if (max <= 0) return res
for(let i = 1; i <= max; i++) {
// 转换为字符串,转换为数组,再反转, 比较
const s = i.toString()
const length = s.length
// 字符串头尾比较
let flag = true
let startIndex = 0 //字符串开始
let endIndex = length -1 //字符串结束
while(startIndex < endIndex) {
if (s[startIndex] != s[endIndex]) {
flag = false
break
} else {
// 继续比较
startIndex++
endIndex--
}
}
if(flag) res.push(i)
}
return res
}
思路3生成反转数
使用%和Math.floor生成反转数
前后数字进行对比(全程操作数字,没有字符串类型)
/**
* 查询1-max的所有对称数(反转数)
*/
function findPalindromeNumbers3(max: number): number[] {
const res: number[] = []
if (max <= 0) return res
for(let i = 1; i <= max; i++) {
let n = i
let rev = 0 //存储反转数
// 生成反转数
while (n > 0) {
rev = rev * 10 + n % 10
n = Math.floor(n/10)
}
if(i === rev) res.push(i)
}
return res
}
总结:思路1 看似是O(n)的复杂度,但是数组转换、操作都需要时间,所以慢;
思路2和思路3,操作数字更快(电脑原型就是计算器);
思路2中要用栈,不合适,因为栈一般也是数组实现的,会慢
尽量不要转换数据结构,尤其是数组这种有序结构;尽量不要内置API,如reverse,不好识别复杂度;数字操作最快,其次是字符串
7. 如何实现高效的英文单词前缀匹配
如有一个英文单词库(数组),里面有几十万格英文单词;输入一个字符串,快速判断是不是某个单词的前缀(说明思路不用写代码)
常规思路
第一,遍历单词库数组
第二,indexOf判断前缀
实际时间复杂度超过了O(n), 因为要考虑indexOf的计算量
优化:
英文字母一共26个,可以提前把单词库数组拆分为26个
既然第一层拆分为26个,第二层、第三层,还可以继续拆分
最后,把单词库拆分为一棵树(对象或者hash表取key时间复杂度为O(1))
性能分析:
如果遍历数组,时间复杂度至少O(n)起步(n是数组长度)
而改为树,时间复杂度降低到O(m)(m为单词的长度)
PS:哈希表(对象)通过key查询,时间复杂度O(1)
总结:
考虑优化原始数据结构(需要和面试官沟通确认)
有明确的数据(如26个英文字母),考虑使用哈希表(对象)
以空间换时间,定义数据结构最重要
8. 用JS实现数字千分位格式
将数组千分位格式化,输出字符串;如数字12050100,输出字符串12,050,100(注意:逆序判断)
常见思路
转换为数组,reverse,每3位拆分
使用正则表达式(慢)
使用字符串拆分
/**
* 千分位格式化(使用数组)
*/
export function format1(n: number): string {
n = Math.floor(n) // 只考虑整数
const s = n.toString()
const arr = s.split('').reverse()
return arr.reduce((prev,val,index) => {
if (index % 3 === 0) {
if (prev) {
return val + ',' + prev
} else {
return val
}
} else {
return val + prev
}
}, '')
}
/**
* 千分位格式化(字符串分析)
*/
export function format2(n: number): string {
n = Math.floor(n) // 只考虑整数
let res = ''
const s = n.toString()
const length = s.length
for (let i = length - 1; i >= 0; i--) {
const j = length -i //
if (j % 3 ==== 0) {
if (i === 0) {
res = s[i] + res
} else {
res = ',' + s[i] + res
}
} else {
res = s[i] + res
}
}
return res
}
性能分析
使用数组,转换影响影响性能
使用正则表达式,性能较差
使用字符串,性能较好(推荐)
9. 用JS切换字母大小写
输入一个字符串,切换其中的字母大小写:如,输入字符串12aBc34,输出字符串12ABC34
常见思路
正则表达式
通过ASCII码判断
/**
* 切换字母大小写(正则表达式)
*/
function switchLetterCase(s: string): string {
let res = ''
const length = s.length
if (length === 0) return res
const reg1 = /[a-z]/
const reg2 = /[A-Z]/
for (let i = 0; i < length; i++) {
const c = s[i]
if (reg1.test(c)) {
res += c.toUpperCas()
} else if (reg2.test(c)) {
res += c.toLowerCas()
} else {
res += c
}
}
return res
}
/**
* 切换字母大小写(ASCII编码)
*/
function switchLetterCase(s: string): string {
let res = ''
const length = s.length
if (length === 0) return res
for (let i = 0; i < length; i++) {
const c = s[i]
const code = c.charCodeAt(0) //ASCII 码值
if (code >= 65 && code <= 90) {
res += c.toLowerCase()
} else if (code >= 97 && code <= 122) {
res += c.toUpperCase()
} else {
res += c
}
}
return res
}
性能分析
使用正则表达式,性能较差
使用ASCII码判断,性能较好(推荐)
10. 为什么0.1+0.2≠0.3
计算机使用二进制存储数据
整数转换二进制没有误差,如9转换为二进制是1001
而小数可能无法使用二进制准确表达,如0.2转换为1.1001100...
不光JS,其他编程语言也这样