一些算法题解析,持续更新中......
1.将一个数组旋转k步
输入一个数组[1,2,3,4,5,6,7], k = 3, 即旋转k步, 输出[5,6,7,1,2,3,4]
- 如:旋转第一步,输出[7,1,2,3,4,5,6]
- 旋转第二步,输出[6,7,1,2,3,4,5]
- 旋转第三步,输出[5,6,7,1,2,3,4]
- 思路一,把末尾的元素挨个pop,然后unshift到数组前面,这个算法的时间复杂度是O(n^2),空间复杂度是O(1)
这里时间复杂度为什么会是O(n ^ 2)呢?
- 因为数组是一个有序结构,对数组进行unshift操作时,结构中的所有数据都需要往后挪一个位置,相当于对数组又进行了一次遍历,再加上外层的for循环,所以时间复杂度就是O(n ^ 2)
/**
* 旋转数组k步 - 思路一
* @param arr
* @param k
* @returns arr
*/
const rotate = (arr: number[], k: number): number[] => {
const length = arr.length;
// 做一个异常判断
if (!k || length === 0) return arr;
const step = Math.abs(k % length);
for (let i = 0; i < step; i++) {
const n = arr.pop();
if (n) {
arr.unshift(n);
}
}
return arr;
};
- 思路二,把数组拆分,最后concat拼接到一起,这个算法的时间复杂度O(1),空间复杂度O(n)
/**
* 旋转数组k步 - 思路二
* @param arr
* @param k
* @returns arr
*/
export const rotate = (arr: number[], k: number): number[] => {
const length = arr.length;
if (!k || length === 0) return arr;
const step = Math.abs(k % length);
const part1 = arr.slice(-step);
const part2 = arr.slice(0, length - step);
const part3 = part1.concat(part2);
return part3;
};
因为前端重时间轻空间,所以思路二更适合
2.判断字符串是否括号匹配
一个字符串S可能包含 {} () [] 三种括号
判断S是否是括号匹配的
- 如:(a{b}c)就是匹配的,(a{b}c就是不匹配的,少了一个)括号
解决这个问题之前首先了解下栈的概念
- 先进后出,如下图,先入栈的在栈底,后入栈的在栈顶。出栈时则是后入栈的先出,先入栈的后出。
- API: push pop length
栈和数组的区别
- 栈是逻辑结构,理论模型,不管如何实现,不受任何语言的限制,在任何语言中都可以通过语法去实现栈。
- 数组,物理结构,真实的功能实现,受限于编程语言。
了解完栈的概念,然后再聊聊解题思路,这个算法的时间复杂度和空间复杂度都是O(n),按理说includes其实也是遍历了一次字符串,它的时间复杂度应该也是O(n),但它遍历的是只有3个字符的常量,计算量可以忽略不计,所以在这个算法中可以忽略掉includes的时间复杂度。
- 遇到左括号 ( { [ 就压栈(入栈)
- 遇到右括号 ) } ] 就判断栈顶,匹配则出栈
- 最后判断length是否为0
/**
* 判断字符串是否括号匹配
* @param str
*/
const matchBracket = (str: string): boolean => {
if (str.length === 0) return false;
const stack = [];
const leftBracket = "([{";
const rightBracket = ")]}";
for (let i = 0; i < str.length; i++) {
// 碰到左括号,就入栈
if (leftBracket.includes(str[i])) {
stack.push(str[i]);
}
// 碰到右括号,就判断栈顶,匹配则出栈
if (rightBracket.includes(str[i])) {
// 左括号
const top = stack[stack.length - 1];
// str[i]右括号,左右如果匹配,就出栈
if (isMatch(top, str[i])) {
stack.pop();
} else {
return false;
}
}
}
return stack.length === 0;
};
/**
* 判断左右括号是否匹配
* @param left
* @param right
* @returns
*/
const isMatch = (left: string, right: string): boolean => {
if (left === "(" && right === ")") return true;
if (left === "{" && right === "}") return true;
if (left === "[" && right === "]") return true;
return false;
};
// 功能测试
const str = "(a){b}[c]";
const str1 = "(a{b[c]})";
const str2 = "(a}";
const str3 = "(a{b]}c)";
console.log("str: ", matchBracket(str)); // true
console.log("str1: ", matchBracket(str1)); // true
console.log("str2: ", matchBracket(str2)); // false
console.log("str3: ", matchBracket(str3)); // false
3.用两个栈实现一个队列
栈的概念在算法2中已经了解了,这里又出现一个队列,其实队列和栈差不多,栈是先进后出,队列是先进先出,比如有一群人在排队,那么先排队的人肯定先办事。
- 先进先出
- API: add delete length
解题思路:
1.准备两个栈,栈1和栈2
2.用pop将栈1中的数据全部压入栈2
3.这时栈2顶部的数据,就是栈1中栈底的数据
4.取出栈2顶部的数据,将剩下的数据再压入栈1
5.就用两个栈实现了先入先出的队列
class myQueue {
// 栈1和栈2
private stack1: number[] = [];
private stack2: number[] = [];
/**
* 新增数据
* @param n
*/
add(n: number) {
this.stack1.push(n);
}
delete(): number | null {
const stack1 = this.stack1;
const stack2 = this.stack2;
// 把stack1中的数据从栈顶开始全部压入stack2中
while (stack1.length) {
const n = stack1.pop();
if (n != null) {
stack2.push(n);
}
}
// stack2的栈顶数据就相当于队列中先出的数据
let res = stack2.pop();
// 将stack2中剩余的数据再放入stack1中,就用两个栈实现了先入先出的队列
while (stack2.length) {
const n = stack2.pop();
if (n != null) {
stack1.push(n);
}
}
return res || null;
}
get length(): number {
// stack2只是临时使用,最终返回的还是stack1的长度
return this.stack1.length;
}
}
// 功能测试,q实例现在就是一个用两个栈实现的队列
const q = new myQueue();
q.add(100);
q.add(200);
q.add(300);
console.log(q.length); // 3 符合预期
console.log(q.delete()); // 100 因为100先入,所以100先出,符合预期
console.log(q.length); // 2 符合预期
这个算法中的add方法时间复杂度是O(1),delete方法的时间复杂度是O(n),整体的空间复杂度是O(n)
4.使用js反转单向链表
链表的概念
- 链表是一种物理结构(非逻辑结构),类似于数组
- 数组需要一段连续的内存空间,而链表是零散的
- 链表节点的数据结构:{ value, next?, prev? },下图就是单向链表,netx和prev后的 ?代表这个属性可有可无
既然要反转单向链表,首先需要根据数组来创建一个单向链表
// 创建一个链表节点类型
interface ILinkListNode {
value: number;
next?: ILinkListNode;
}
/**
* 根据数组创建一个单向链表
*/
const createLinkList = (arr: number[]): ILinkListNode => {
if (arr.length === 0) throw new Error("arr is empty");
// 当前节点的value等于数组的最后一个元素,因为链表中要用next指向下一个元素,所以在数组中要倒着来。
let curNode: ILinkListNode = {
value: arr[arr.length - 1],
};
// 如果数组的长度正好等于1,那么直接返回这个节点
if (arr.length === 1) return curNode;
// 因为数组倒着来,并且第一个节点已经取出来了,所以从倒数第二个开始
for (let i = arr.length - 2; i >= 0; i--) {
curNode = {
value: arr[i],
next: curNode,
};
}
return curNode;
};
const arr = [100, 200, 300, 400, 500];
const list = createLinkList(arr);
console.log(list);
链表和数组的异同
- 都是有序结构
- 链表:查询慢O(n),新增和删除快O(1)
- 数组:查询快O(1),新增和删除慢O(n)
解题思路:
- 反转,即节点next指向前一个节点
- 但这样很容易造成nextNode的丢失
- 所以需要三个指针 prevNode curNode nextNode
// 创建一个链表节点类型
interface ILinkListNode {
value: number;
next?: ILinkListNode;
}
/**
* 反转列表
* @param listNode
* @returns
*/
const reverseLinkList = (listNode: ILinkListNode): ILinkListNode => {
// 定义三个指针
let prevNode: ILinkListNode | undefined = undefined;
let curNode: ILinkListNode | undefined = undefined;
let nextNode: ILinkListNode | undefined = listNode;
// 以nextNode为主,遍历链表
while (nextNode) {
// 如果curNode有值,并且prevNode没值,那么说明这是第一个元素,就要删掉nextNode,防止循环引用
if (curNode && !prevNode) {
delete curNode.next;
}
// 如果curNode和prevNode都有值,那么就将指针反转
if (curNode && prevNode) {
curNode.next = prevNode;
}
// 指针整体向后移动
prevNode = curNode;
curNode = nextNode;
nextNode = nextNode?.next;
}
// 当nextNode为空时,就会退出循环,但这个时候curNode还没有设置next
curNode!.next = prevNode;
return curNode!;
};
/**
* 根据数组创建一个单向链表
*/
const createLinkList = (arr: number[]): ILinkListNode => {
if (arr.length === 0) throw new Error("arr is empty");
// 当前节点的value等于数组的最后一个元素,因为链表中要用next指向下一个元素,所以在数组中要倒着来。
let curNode: ILinkListNode = {
value: arr[arr.length - 1],
};
// 如果数组的长度正好等于1,那么直接返回这个节点
if (arr.length === 1) return curNode;
// 因为数组倒着来,并且第一个节点已经取出来了,所以从倒数第二个开始
for (let i = arr.length - 2; i >= 0; i--) {
curNode = {
value: arr[i],
next: curNode,
};
}
return curNode;
};
const arr = [100, 200, 300, 400, 500];
const list = createLinkList(arr);
const list1 = reverseLinkList(list);
console.log(list);
console.log(list1);
5.链表和数组哪个实现队列更快?
- 数组是连续存储,push很快,shift很慢
- 链表是非连续存储,add和delete都很快(但查找很慢)
- 结论:链表实现队列更快
链表实现队列思路:
- 单向链表,但要同时记录head和tail
- 要从tail入队,从head出队,否则出队时tail不好定位
- length要实时记录,不可遍历链表获取
interface ILinkListNode {
value: number;
// @ts-ignore
next: ILinkListNode | null;
}
class myQueue {
private head: ILinkListNode | null = null;
private tail: ILinkListNode | null = null;
private length = 0;
/**
* 从tail入队,如果从tail出队,因为是单向链表,没法指向tail的上一个元素,所以需要从tail入队
* @params n number
*/
add(n: number) {
const newNode = {
value: n,
next: null,
};
// 如果head节点是null,那就说明链表中只有即将要入队的元素,那么就让head指向newListNode
if (this.head == null) {
this.head = newNode;
}
// 因为是从队尾入队,那么如果tail有值,就让它指向新增的元素
if (this.tail) {
this.tail.next = newNode;
}
// 不管tail有值没值,都需要让tail指向队尾的元素,根据队列先进先出的原则,tail就是新增的元素
this.tail = newNode;
// 记录长度
this.length++;
}
delete(): number | null {
// 处理异常
if (this.head == null) return null;
if (this.length <= 0) return null;
// 取值
const val = this.head.value;
// 取值之后需要让head指向下一个元素
this.head = this.head.next;
// 记录长度
this.length--;
// @ts-ignore
return val | null;
}
get len(): number {
return this.length;
}
}
const q = new myQueue();
q.add(1);
q.add(2);
q.add(3);
console.log(q.len);
q.delete();
console.log(q.len);
q.add(4);
console.log(q.len);
性能分析:
- 空间复杂度都是O(n)
- add时间复杂度都是O(1)
- delete时间复杂度:数组是O(n),数组删除时需要挪动所有元素,相当于遍历了一遍,所以是O(n)。链表是O(1)
6.用js实现二分查找
思路:
- 用递归实现,代码逻辑更加清晰
- 非递归,性能更好
- 时间复杂度:O(logn)
使用循环实现(非递归):
const binarySearch = (arr: number[], target: number): number => {
// 如果arr为空,就返回-1
if (arr.length === 0) return -1;
// 声明一个开始和结束的index
let startIndex = 0;
let endIndex = arr.length - 1;
// 当startIndex小于等于endIndex的时候,证明还没有查完,就继续去查找。否则就是查完了,但没找到
while (startIndex <= endIndex) {
// 因为要从中间开始查找,所以需要拿到中间的index
const midIndex = Math.floor((startIndex + endIndex) / 2);
// 如果要查找的值比中间的值小,那么目标值就在左侧,让endIndex移到中间,因为中间的值已经查找过了,所以可以减一,不再去查找中间的值
// 如果大于,说明在右侧,就让startIndex移动,+1同上
// 再最后就是目标值等于中间值,那么就是找到了,直接返回midIndex
if (target < arr[midIndex]) {
endIndex = midIndex - 1;
} else if (target > arr[midIndex]) {
startIndex = midIndex + 1;
} else {
return midIndex;
}
}
return -1;
};
使用递归实现
const binarySearch1 = (
arr: number[],
target: number,
startIndex?: number,
endIndex?: number
): number => {
if (arr.length <= 0) return -1;
if (startIndex == null) startIndex = 0;
if (endIndex == null) endIndex = arr.length - 1;
// 递归结束条件
if (startIndex > endIndex) return -1;
const midIndex = Math.floor((startIndex + endIndex) / 2);
if (target < arr[midIndex]) {
return binarySearch1(arr, target, startIndex, midIndex - 1);
} else if (target > arr[midIndex]) {
return binarySearch1(arr, target, midIndex + 1, endIndex);
} else {
return midIndex;
}
};
7.给一个数组,找出其中和为N的两个元素
- 有一个数组[1,2,4,7,11,15]
- 数组中有两个数,和是n,即4 + 11 = 15
常规思路
- 嵌套循环,找到一个数,然后去遍历下一个数,求和,判断
- 时间复杂度是O(n ^ 2),不可用
const findTwoNumbers = (arr: number[], n: number): number[] => {
const res: number[] = [];
if (arr.length === 0) return res;
for (let i = 0; i < arr.length; i++) {
const n1 = arr[i];
let flag = false;
for (let j = i + 1; j < arr.length; j++) {
const n2 = arr[j];
if (n1 + n2 === n) {
res.push(n1);
res.push(n2);
flag = true;
break;
}
}
if (flag) break;
}
return res;
};
利用递增(有序)的特性
- 随便找两个数
- 如果和大于n,则需要向前寻找
- 如果和小于n, 则需要向后寻找 ---- 二分法
双指针,时间复杂度降低到O(n)
- 定义i指向头,j指向尾,求arr[i] + arr[j]
- 如果大于n,则j需要向前移动
- 如果小于n,则i需要向后移动
const findTwoNumbers1 = (arr: number[], n: number): number[] => {
const res: number[] = [];
if (arr.length === 0) return res;
let i = 0;
let j = arr.length - 1;
while (i < j) {
let sum = arr[i] + arr[j];
if (sum > n) {
j--;
} else if (sum < n) {
i++;
} else {
res.push(arr[i]);
res.push(arr[j]);
break;
}
}
return res;
};
8.求一个二叉树的第k小值
二叉树概念
- 是一棵树
- 每个节点,最多只能有2个子节点
- 树节点的数据结构{ value , left? , right? }
// 树节点的数据结构
interface ITreeNode {
value: number;
left: ITreeNode | null;
right: ITreeNode | null
}
二叉树的遍历
- 前序遍历: root → left → right
- 中序遍历: left → root → right
- 后序遍历: left → right → root
interface ITreeNode {
value: number;
left: ITreeNode | null;
right: ITreeNode | null;
}
/**
* 前序遍历
* @param node
*/
const prevOrderTraverse = (node: ITreeNode | null) => {
if (node?.value == null) return;
// root
console.log(node?.value);
// left
prevOrderTraverse(node?.left);
// right
prevOrderTraverse(node?.right);
};
/**
* 中序遍历
* @param node
*/
const inOrderTraverse = (node: ITreeNode | null) => {
if (node?.value == null) return;
// left
inOrderTraverse(node?.left);
// root
console.log(node?.value);
// right
inOrderTraverse(node?.right);
};
/**
* 后序遍历
* @param node
*/
const postOrderTraverse = (node: ITreeNode | null) => {
if (node?.value == null) return;
// left
postOrderTraverse(node?.left);
// right
postOrderTraverse(node?.right);
// root
console.log(node?.value);
};
const tree: 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,
},
},
};
// prevOrderTraverse(tree);
// inOrderTraverse(tree);
postOrderTraverse(tree);
二叉搜索树BST(Binary Search Tree)
- left(包括其后代) value <= root value
- right(包括其后代) value >= root value
- 这样可以使用二分查找快速查找值
了解了一些二叉树的概念后,现在看一看题目:求一个二叉树的第k小值,解题思路:
- BST中序遍历,即从小到大的排序
- 找到排序后的第k值即可
interface ITreeNode {
value: number;
left: ITreeNode | null;
right: ITreeNode | null;
}
const res: number[] = [];
/**
* 中序遍历
* @param node
*/
const inOrderTraverse = (node: ITreeNode | null) => {
if (node?.value == null) return;
// left
inOrderTraverse(node?.left);
// root
res.push(node.value);
// right
inOrderTraverse(node?.right);
};
/**
* 获取二叉搜索树的第k小值
* @param k
* @returns value
*/
const getKthValue = (k: number): number | null => {
inOrderTraverse(tree);
console.log(res);
return res[k];
};
const tree: 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,
},
},
};
// prevOrderTraverse(tree);
// inOrderTraverse(tree);
// postOrderTraverse(tree);
console.log(getKthValue(2));
9.堆有什么特点,和二叉树有什么关系
堆栈模型
- js代码执行时
- 值类型变量存储在栈
- 引用类型变量存储在堆
堆
- 完全二叉树
- 最大堆: 父节点 >= 子节点
- 最小堆: 父节点 <= 子节点
和二叉树的关系
- 查询比BST慢
- 增删比BST快,维持平衡更快
- 但整体的时间复杂度都在O(logn)级别,即树的高度
10.求斐波那契数列的第n值
- 用js计算斐波那契数列的第n个值
- 注意时间复杂度
思路1,递归:
直接使用公式: f(n) = f(n - 1) + f(n + 1)
const fibonacci = (n: number): number => {
if (n <= 0) return 0;
if (n === 1) return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
};
console.log(fibonacci(9)); // 34
但如果使用递归,会出现大量的重复计算,导致时间复杂度达到了O(2 ^ n),基本不可用,所以需要对时间复杂度进行优化:
-不用递归,用循环,记录中间结果,时间复杂度为O(n)
const fibonacci = (n: number): number => {
if (n <= 0) return 0;
if (n === 1) return 1;
// 记录 n - 1的结果
let n1 = 1;
// 记录 n - 2的结果
let n2 = 0;
let res = 0;
for (let i = 2; i <= n; i++) {
res = n1 + n2;
// 记录中间结果
n2 = n1;
n1 = res;
}
return res;
};
动态规划
- 把一个大问题,拆解为多个小问题,逐级向下拆解
- 用递归的思路去分析问题,再改为循环来实现
- 算法三大思维:贪心,二分,动态规划
11.将数组中的0移动到末尾
- 如输入[1, 0, 3, 0, 11, 0],输出[1, 3, 11, 0, 0, 0]
- 只移动0,其它顺序不变
- 必须在原数组进行操作
传统思路:
- 遍历数组,遇到0则push到数组末尾
- 用splice截取掉当前元素
- 时间复杂度是O(n ^ 2),算法不可用
const arr: number[] = [1, 0, 0, 0, 3, 0, 11, 0];
const moveZero = (arr: number[]): void => {
if (arr.length === 0) return;
let zeroLength = 0;
for (let i = 0; i < arr.length - zeroLength; i++) {
if (arr[i] === 0) {
arr.push(arr.splice(i, 1)[0]);
i--;
zeroLength++;
}
}
};
moveZero(arr);
console.log(arr);
使用双指针进行优化:
- 定义j指向第一个0, i指向j后面的第一个非0
- 交换i和j的值,继续向后移动
- 只遍历一次,所以时间复杂度是O(n)
const arr: number[] = [1, 0, 0, 12, 0, 3, 0, 11, 0];
const moveZero = (arr: number[]): void => {
if (arr.length === 0) return;
let i: number;
let j: number = -1;
for (i = 0; i < arr.length; i++) {
if (arr[i] === 0) {
// 让j指向第一个0
if (j < 0) {
j = i;
}
}
if (arr[i] !== 0 && j >= 0) {
const n = arr[i];
arr[i] = arr[j];
arr[j] = n;
j++;
}
}
};
moveZero(arr);
console.log(arr);
12.字符串中连续最多的字符以及次数
- 如输入“abbcccddeeee1234”计算后得到连续最多的字符是e,连续出现4次
传统思路:
- 嵌套循环,找出每个字符的连接次数,并记录
- 看似时间复杂度是O(n ^ 2),但实际时间复杂度是O(n),因为有"跳步"