说明
本人从事的岗位是前端开发,考虑到自己未来的发展,因此有意识的去学习各方面的知识,以此来提升自己的技术水平。而本文记录的是本人学习算法题的笔记,也算是时刻督导自己学习的日志。因为属于刚学习阶段,因此算法的难度会很低,题量也会很少,而且看视频学习确实会比较费时间,本人尽量做到学到的学会的就记录上。
本文会持续更新,如有希望一起学习进步的读者可自行收藏,同时如有发现文中描述错误的地方,望大佬不吝指出,在此拜谢!
1.将一个数组旋转K步
案例:
- 输入一个数组,如[1,2,3,4,5,6,7];
- k等于3时,则旋转3步;
- 输出[5,6,7,1,2,3,4];
提醒:读者可先分析该案例的题意,并自行设计解题思路,并进行作答。
分析:
- 题意很明确,从尾部截取元素至头部,K就是截取的次数,或者说个数;
- 为了减少部分边界情况,设计函数建议用ts做类型检测限制;
- 数组为空,或者k不存在应返回原值;
- k如果为数组长度的倍数,旋转k步之后和原来一样,因此实际步数为k对数组长度取余;
- 一种是一个一个尾部移头部,一种是一段一段尾部移头部;
代码:
方法1:pop() + unshift()
/**
* 使用尾部pop移除再追加头部unshift追加方式
* @param arr 数组
* @param k 步数
* @returns
*/
rotate1(arr: number[], k: number): number[] {
// 获取数组长度
const length = arr.length;
// 空数组或旋转0步直接返回
if (!length || !k) 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;
}
方法2:
rotate2(arr: number[], k: number): number[] {
// 获取数组长度
const length = arr.length;
// 空数组或旋转0步直接返回
if (!length || !k) return arr;
// 计算实际旋转步数
const step = Math.abs(k % length);
// 截取尾部需要移的元素
let part1 = arr.slice(-step);
// 截取移去尾部后剩余的元素
let part2 = arr.slice(0, length - step);
// 两部分拼接得到新的旋转后的数组
let part3 = part1.concat(part2);
return part3;
}
代码测试:slice() + concat()
// // 性能测试
const arr1 = []
for (let i = 0; i < 10 * 10000; i++) {
arr1.push(i)
}
// 方法1测试:
console.time('rotate1')
rotate1(arr1, 9 * 10000)
console.timeEnd('rotate1') // 2095.52685546875 ms O(n^2)
// 方法2测试:
const arr2 = []
for (let i = 0; i < 10 * 10000; i++) {
arr2.push(i)
}
console.time('rotate2')
rotate2(arr2, 9 * 10000)
console.timeEnd('rotate2') // 1.400146484375 ms O(1)
结论:方式2性能优于方法1
原因:
- 方法1中unshift方法在数组头部插入元素,数组是有序的,头部插入导致后面元素都要重排,耗费性能,同时遍历k步数也要耗费性能,因此时间复杂度O(n^2);
- 方法2中slice方法是截取后面元素,不改变原有数组排序,concat方法合并两数组,也不会导致数组重排,时间复杂度O(n);
2.两个栈实现一个队列
案例:
- 实现两个栈,并用他们实现一个队列的功能;
- 队列的功能有add length delete;
分析:
- 队列是先进先出的,而栈是先进后出的,这两者不一样;
- 要求提到了使用两个栈,我们可以用两个数组实现栈的需求;
- 我们让其中一个数组为基准栈,另一个作为中转栈;
- 队列add时,把数据压入基准栈中;
- 队列delete时,由于需要删除栈底先进的元素,显然直接对基准栈删除元素 是做不到的,此时我们把基准栈的元素都出栈,并压入中转栈,这样基准栈先进的元素会作为中转栈后进的元素,此时我们对中转栈进行一次出栈,即删除了顶部的元素,也就是原基准栈最底部的元素,这样我们就实现了队列的删除,同时不违背栈的原则。当然,最后我们要把中转栈的剩余元素出栈并压入基准栈,因为我们队列是基于基准栈的;
- length的话就比较简单了,直接返回基准栈的长度即可;
代码:
// 为了方便,假设这个队列的都是数字,用ts限制:
// 定义队列类
export class MyQueue {
// 定义两个私有的数组(用来实现栈):
private stack1: number[] = [];
private stack2: number[] = [];
// 加入队列:
add(n: number) {
this.stack1.push(n);
}
// 移除队列
delete(): number | null {
// res作为删除队列元素时返回值
let res;
while (this.stack1.length) {
// 出基准栈,入中转栈
const n = this.stack1.pop();
if (n) {
this.stack2.push(n);
}
}
// 删除实际需要出队列的元素
res = this.stack2.pop();
while (this.stack2.length) {
// 剩余出中转栈,入基准栈
const n = this.stack2.pop();
if (n) {
this.stack1.push(n);
}
}
// 返回值
return res || null;
}
// 获取长度
get length() {
return this.stack1.length;
}
}
测试:
const myQueue = new MyQueue();
myQueue.add(100);
myQueue.add(200);
myQueue.add(300);
myQueue.add(400);
console.info(myQueue.length); // 4
myQueue.delete();
console.info(myQueue.length); // 3
3.根据数组创建单向链表
案例:根据数组创建一个单向链表
提醒:在此之前,需要对链表有个简单的认知,什么是链表?此处不作介绍,请自行查阅。
分析:
- 数组每一个元素都会被改造成对象,对象属性value值为当前元素值,属性next为指向下一个节点对象;
- 最后一个元素对象无next属性;
- 返回值为第一个节点对象;
代码:
// 定义链表结构接口:
interface ILinkListNode {
value: number;
next?: ILinkListNode;
}
/**
*根据数组创建单向链表
* @param arr number arr
* @return 注意,由于是链表,我们只需要返回头部那一个就行,因为前一个next指向下一个
*/
function createLinkList(arr: number[]): ILinkListNode {
// 获取数组长度
const length = arr.length;
// 为空抛出错误
if (length === 0) throw new Error("arr is empty");
// 由于我们只需要拿到第一个节点,因此从后往前遍历
// 先获取最后一个(最后一个没有next)
let curNode: ILinkListNode = {
value: arr[arr.length - 1],
};
// 从后往前遍历,使得倒数第二个next是最后一个,依次类推
for (let i = length - 2; i >= 0; i--) {
curNode = {
value: arr[i],
next: curNode,
};
}
return curNode;
}
测试:
const arr = [1, 2, 3, 4, 5];
const curNode = createLinkList(arr);
console.info(curNode);
打印结果如下:
4.反转单向链表
案例:对单项链表进行反转
分析:
- 题意很明确,① -> ② -> ③ -> ④ -> ⑤ 反转成 ⑤ -> ④ -> ③ -> ② -> ①;
- 引入变量a,表示第一个,反转的话第一个变最后一个,则需删除next,如此就会失去第二个的引用,因此需要继续引入变量b,表示第二个,此时移除a的next,把b的next指向a,此时第三个的引用就会丢失,所以必须引入第三个变量c,为了方便说明,下面就以preNode、curNode、nextNode来表示三个变量;
- 初始化nextNode表示第一个,preNode和curNode为undefined,由于只有nextNode有值,无法进行切换,因此往右移一位,preNode=undefined、curNode=①、nextNode=②,此时删除①即curNode的next,此时① ② -> ③ -> ④ -> ⑤;
- 本来应该②的next指向①,即nextNode的next指向curNode,但是此时如果设置,③的数据会丢失,因此需要继续往右移一位,保证③被变量指向着,此时preNode=①、curNode=②、nextNode=③,且① ② -> ③ -> ④ -> ⑤,此时把把②即curNode的next指向①,即curNode的next=preNode,此时① <- ② ③ -> ④ -> ⑤;
- 重复4步骤,继续往右移,需要注意,我们右移nextNode是利用next属性移动的,原最后一个节点无next,因此没curNode获取到最后一个节点身上没有next属性,需要手动设置;
提示:为了方便测试,我们此处使用的原链表数据取至【3】中数组生成的链表;
代码:
// 定义链表结构接口:
interface ILinkListNode {
value: number;
next?: ILinkListNode;
}
/**
* 反转单向链表
* @param listNode 需要反正的链表
*/
function reverseLinkList(listNode: ILinkListNode): ILinkListNode {
// 如果链表只有一个元素直接返回
if (!listNode.next) return listNode;
// 设置三个变量节点
let preNode: ILinkListNode | undefined = undefined;
let curNode: ILinkListNode | undefined = undefined;
let nextNode: ILinkListNode | undefined = listNode;
// 循环右移,只要能获取到下一个节点都右移一次
while (nextNode) {
if(curNode && !preNode){
// 【// @ts-ignore;】注释是用来忽略ts编译在写的过程时报错提示
// @ts-ignore;
delete curNode.next; // 删除第一个元素的next
}
// 当前节点的next指向上一节点
if(curNode && preNode){
// @ts-ignore;
curNode.next = preNode;
}
// 右移操作
preNode = curNode;
curNode = nextNode;
nextNode = nextNode?.next; // 最后一个节点没有next,此处用?.可选链防报错
}
// 手动处理最后一个节点的next
curNode!.next = preNode;
return curNode!;
}
测试:
const arr = [1, 2, 3, 4, 5];
const curNode = createLinkList(arr); // 【3】中数组转链接的函数
console.info(curNode); // 获得原链表
const reverseNode = reverseLinkList(curNode);
console.log(reverseNode);
打印的结果:
5.链表实现队列
案例:用链表实现队列,有add、delete、length方法
分析:
- 考虑到队列先进先出,映射到链表时,实现add加入队列时,必须从尾部加一个next指向,因此需要变量记录尾部数据;
- 考虑到出队列是从第一个先出,实现delete得知道第一个数据是什么,因此需要变量记录头部数据;
- 考虑到链表的特性,为了拿到队列长度,因此需要一个变量实时记录队列元素个数;
代码:
// 链表接口
interface IListNode {
value: number | null;
next: IListNode | null;
}
class MyQueue {
// 定义三个私有变量
private head: IListNode | null = null;
private tail: IListNode | null = null;
private len = 0;
// 进队列
add(n: number) {
const newNode = {
value: n,
next: null,
};
//如果头部不存在,则直接赋值给头部
if (this.head == null) {
this.head = newNode;
}
// 如果尾部存在,则原尾部的next指向新元素
if (this.tail) {
this.tail.next = newNode;
}
// 更新尾部为新的元素
this.tail = newNode;
// 更新队列中元素个数
this.len++;
}
// 出队列
delete() {
const headNode = this.head;
// 不存在返回null
if (headNode == null) return null;
if (this.len <= 0) return null;
// 获取头部的value值作为删除的返回值
const value = headNode.value;
// 更新头部元素
this.head = headNode.next;
// 更新元素个数
this.len--;
return value;
}
// 获取长度
get length(): number {
return this.len;
}
}
测试:
const queue = new MyQueue();
queue.add(100);
queue.add(200);
queue.add(300);
queue.add(400);
console.log(queue.length); // 4
queue.delete();
console.log(queue.length); // 3
补充:用链表实现队列的性能跟用数组push和shift实现队列的性能,谁更优?
性能测试:
// 性能测试:
const q1 = new MyQueue();
console.time("queue by list");
for (let i = 0; i < 100000; i++) {
q1.add(i);
}
for (let i = 0; i < 100000; i++) {
q1.delete();
}
console.timeEnd("queue by list"); // 13.22607421875 ms
const q2 = [];
console.time("queue by array");
for (let i = 0; i < 100000; i++) {
q2.push(i);
}
for (let i = 0; i < 100000; i++) {
q2.shift();
}
console.timeEnd("queue by array"); // 1012.818115234375 ms
结论:很显然使用链表实现队列的方式远远优于使用数组实现队列的方式。
原因:链表是无需的,增删很快,只要指定位置的加next或者去掉即可,而数组的头部删会导致数组重排比较费时间。
6.实现二分查找
补充说明:二分查找的数据必须是有序的
分析: 1.开始和结束索引确定中间索引对应值,和目标值比较,偏小则更新开始索引为当前的,偏大则更新结束索引为当前的,循环操作,直到等于; 2.可以使用循环或者递归实现;
代码:
方式一:循环
/**
*二分查找:循环方式
* @param arr 二分查找数组
* @param target 目标值
* @return 目标值索引/-1
*/
function binarySearch1(arr: number[], target: number): number {
const length = arr.length;
if (length === 0) return -1;
let startIndex = 0;
let endIndex = length - 1;
while (startIndex <= endIndex) {
// 获取中间位置索引
const midIndex = Math.floor((startIndex + endIndex) / 2);
if (target < arr[midIndex]) {
// 目标值较小,在左侧继续找
endIndex = midIndex - 1;
} else if (target > arr[midIndex]) {
// 目标值较大,在右侧继续找
startIndex = midIndex + 1;
} else {
// 相对则返回
return midIndex;
}
}
// 没找到返回-1
return -1;
}
方式二:递归
/**
* 二分查找:递归方式
* @param arr 二分查找数组
* @param target 目标值
* @param startIndex 开始索引
* @param endIndex 结束索引
* @returns 目标值索引/-1
*/
function binarySearch2(arr: number[], target: number, startIndex?: number, endIndex?: number): number {
const length = arr.length;
if (length === 0) return -1;
// 开始和结束不传是手动初始化
if (!startIndex) startIndex = 0;
if (!endIndex) endIndex = length - 1;
// 中间位置
const midIndex = Math.floor((startIndex + endIndex) / 2);
const midValue = arr[midIndex];
// 如果相遇,则结束
if (startIndex > endIndex) return -1;
if (target < midValue) {
// 目标值较小,在左侧继续找
return binarySearch2(arr, target, startIndex, midIndex - 1);
} else if (target > midValue) {
// 目标值较大,在右侧继续找
return binarySearch2(arr, target, midIndex + 1, endIndex);
} else {
// 相对则返回
return midIndex;
}
}
测试:
const arr = [1, 7, 13, 45, 47, 98, 234, 456, 561, 899];
const index1 = binarySearch1(arr, 288);
const index2 = binarySearch1(arr, 98);
const index3 = binarySearch2(arr,99);
const index4 = binarySearch2(arr,456);
console.log(index1, index2); // -1 5
console.log(index3, index4); // -1 7