前置知识
- 常规数组与JS数组浅析
在大多数计算机语言中,数组都对应着
一段连续的内存
,JS中的常规数组也是这个标准(数组中存储的是同一种类型的数据),而当存储的是不一样类型的数据时,就会出现不是一段连续的内存的问题,此时JS数组就不再具备数组的特征了,其底层使用哈希映射分配内存空间,是由对象链表来实现的 →JS数组未必是真正的数组
常见数据结构
数组
是一种连续的存储结构,通过下标可以快速的直接访问数组中的任意元素,其时间复杂度是:随机访问(O(1)),插入和删除操作的时间复杂度是(O(N)),其空间复杂度是(需要连续的存储空间):空间复杂度是(O(N))
- 数组的创建方式
const arr = [1, 2, 3]
- 构造函数方式👍🏻:
const arr = new Array()
const arr = new Array(7)
//长度为7的空数组- 长度确定且元素值也确定的数组:
const arr = (new Array(7)).fill(1)
- 数组的遍历和访问
- 访问直接通过索引进行访问即可
- 遍历的三种常用方法
- for循环
let arr = [1, 2, 3, 4];
let i = 0,
length = arr.length;
for (i; i < length; i++) { ... }
- forEach循环 → 从性能上来看 for循环遍历是最快的
let arr = [1, 2, 3, 4];
arr.forEach((item, index) => {
console.log(item, index, "item,index ======== ");
});
- map循环 → 对数组进行再加工
let arr = [1, 2, 3, 4];
arr.map((item,index) => {
return item+index
})
- 二维数组
- 简单说就是数组嵌套数组,即内部的元素也是数组
- 例如
let arr = [[1, 2, 3], [3, 4, 5], [6, 7, 8]]
- 二维数组的初始化与访问
- 不能使用fill进行填充,会有「引用指向」的问题,如
const arr = (new Array(7)).fill([])
- 拓展:fill工作原理
- 当给fill传入参数时,如果该入参是引用类型,那么fill在填充时填入的就是入参的引用,所有的填充都指向了同一块内存空间,本质上是同一个引用类型的数据,当更改二维数组中的某一个数据时会对其他数据也产生影响
- for循环的方式进行初始化
let arr = new Array(6)
const len = arr.length;
let i = 0;
for (i; i < len; i++) {
// 将数组的每一个坑位初始化为数组
arr[i] = [];
}
arr[1][0] = 123
arr[4][1] = 234
- 访问和一维数组一致,通过for循环进行访问,几维数组就嵌套几层for循环
- 数组增加元素的方式
- unshift → 添加元素到数组头部
arr.unshift(123)
- push → 添加元素到数组的尾部
arr.push(321)
- splice → 添加元素到数组的任意位置
arr.splice(1,0,3) // 在索引为1的位置处添加值为3的数据
arr.splice(1,1) // 删除索引为1位置的数据
- 数组删除元素的方式
- shift → 删除数组头部的元素
arr.shift()
- pop → 删除数组尾部的元素
arr.pop()
- splice → 删除任意位置的数据
arr.splice(1,1) // 删除索引为1位置的数据
- 常用数组API对原始数组的影响汇总
- 不改变原始数组:
concat / filter / join / slice / reduce / findIndex / forEach / map
- 改变原始数组:
pop / push / shift / unshift / splice / reverse / sort
栈和队列可以看作是特别的数组
,一般都依赖于数组,是运算受限的线性表,当然也可以用链表来实现
,只是相当于是山鸡用了宰牛刀,两者的区别在于各自对数组的增删操作有着不一样的限制
栈结构
栈是一种遵从后进先出(LIFO)原则的有序集合,是限定仅在表尾(栈顶)进行插入和删除操作的线性表。新添加或待删除的元素都保存在栈的同一端,称为栈顶,另一端称为栈底,如现实中的「叠放盘子」
- 注意点
- 栈是一种线性表,即栈元素具有线性关系,即前驱后继关系
栈的实现
- 拓展
- 当一个需求有多种实现方式,每个实现方式都有公共的内部逻辑或统一的要求,此时就可以定义一个接口,然后让其他的类去继承
implements
这个接口(interface)
- 继承
implements
:当有些方法可以在父类里进行实现,此时就可以使用继承来便捷实现 - 当有些许不一样的地方时,就可以用接口
interface
的方式进行实现 - 接口需要具有「通用性」,可以在不同场景下都适应,例如可以整合后续实例调用时所传递的不同接口类型的数据,这些数据都同时满足定义方法时规定的接口类型即可
- 接口之间通过
extend
来互相继承 - 类可以通过
implements
来实现继承接口
- 继承和接口都有一个特点就是:都是「多态」的前提
实现栈结构
接口定义
- 基类接口
// types/IList.ts
interface IList<T> {
// peek 返回第一个元素
peek(): T | undefined
// 是否为空
isEmpty(): boolean
// 元素个数
// 添加 get 后就可以直接以属性的方式进行调用该方法 是class的一个语法
// 同步在继承中也需要改为get
get size(): number
// size(): number
}
export default IList
- 栈的接口定义 → 继承自基类接口
// 栈结构Stack/IStack.ts
import IList from "../types/IList";
interface IStack<T> extends IList<T> {
push(element: T): void;
// 出对操作 不需要传参数 当队列为空时就应该返回undefined 因此用| undefined
pop(): T | undefined;
// 下列方法通过`extends IList`来实现 不再需要单独定义
// // peek 返回第一个元素
// peek(): T | undefined
// // 是否为空
// isEmpty(): boolean
// // 元素个数
// // get size(): number
// size(): number
}
- 实现栈结构(Stack) → (数组方式)
// 栈结构Stack/实现栈结构(Stack).ts
// 封装一个栈
// T 即是一个泛型 可以在创建类时告知需要创建的数据类型
import IStack from "./IStack";
// class ArrayStack<T = any> {
class ArrayStack<T = string> implements IStack<T> {
// 属性修饰:protected 和 private 都是用来保护内部属性的 private只能自己访问不能外界访问 protected是可以让子类和自身都访问不能让外界访问
private data: T[] = [];
push(element: T): void {
this.data.push(element);
}
// 将栈顶元素弹出栈 返回出该值 且从栈顶移除掉
// 加「 | undefined」是因为pop的时候栈结构为空时 pop返回的是undefined 因此需要「联合类型」进行兼容这种情况
pop(): T | undefined {
return this.data.pop();
}
// peek 栈顶元素 不操作栈
peek(): T | undefined {
return this.data[this.data.length - 1];
}
isEmpty(): boolean {
return this.data.length === 0;
}
get size(): number {
return this.data.length;
}
}
export default ArrayStack;
- 实现栈结构(Stack) → (链表方式)
- 测试栈结构
// 栈结构Stack/测试栈结构.ts
import ArrayStack from "./实现栈结构(Stack)";
// 栈:先进后出 开口端是栈顶 封口端是栈底
const stack = new ArrayStack<number>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.push(4);
console.log(stack.peek(), "stack.peek()========="); // 4
console.log(stack.pop(), "stack.pop()========="); // 4
console.log(stack.pop(), "stack.pop()========="); // 3
队列结构
队列是一种遵循先进先出(FIFO)原则的一组有序的项,队列在尾部添加新元素,在顶部删除元素,最新添加的元素必须排在队列的末尾,现实中的例子如「排队」
实现队列结构
- 队列的接口定义 → 继承自基类接口
// 队列结构Queue/IQueue.ts
import IList from "../types/IList";
interface IQueue<T> extends IList<T> {
// 入队操作 不需要返回值 因此使用「void」
enqueue(element: T): void;
// 出对操作 不需要传参数 当队列为空时就应该返回undefined 因此用| undefined
dequeue(): T | undefined;
// // peek 返回第一个元素
// peek(): T | undefined
// // 是否为空
// isEmpty(): boolean
// // 元素个数
// // 添加 get 后就可以直接以属性的方式进行调用该方法 是class的一个语法
// // 同步在继承中也需要改为get
// get size(): number
// // size(): number
}
export default IQueue;
- 实现队列结构Queue
// 队列结构Queue/实现队列结构Queue.ts
import IQueue from "./IQueue";
class ArrayQueue<T> implements IQueue<T> {
// 内部是通过数组(链表)进行保存的
private data: T[] = [];
enqueue(element: T): void {
this.data.push(element);
}
dequeue(): T | undefined {
return this.data.shift();
}
peek(): T | undefined {
return this.data[0];
}
isEmpty(): boolean {
return this.data.length === 0;
}
// 添加 get 后就可以直接以属性的方式进行调用该方法 是class的一个语法
get size(): number {
return this.data.length;
}
}
export default ArrayQueue;
- 测试队列结构
// 队列结构Queue/测试队列结构.ts
import ArrayQueue from "./实现队列结构Queue";
// 队列:先进先出 队列两端开口 一端入队 一端出对
// 队列里准备放 String 格式的数据
const queue = new ArrayQueue<string>();
queue.enqueue("a");
queue.enqueue("b");
queue.enqueue("c");
queue.enqueue("d");
console.log(queue.dequeue(), "queue.dequeue()========="); // a
console.log(queue.dequeue(), "queue.dequeue()========="); // b
console.log(queue.peek(), "queue.peek()========="); // c
console.log(queue.isEmpty(), "queue.isEmpty()========="); // false
console.log(queue.size, "queue.size()========="); // 2
- 面试题一:击鼓传花
import ArrayQueue from "./实现队列结构Queue";
function hotPotato(names: string[], num: number): number | object {
if (names.length === 0) return -1;
// 创建队列 用于存储数据 方便后续的条件化操作
let queue = new ArrayQueue<string>();
for (const name of names) {
queue.enqueue(name);
}
// 完成队列的初始化
// 淘汰规则
while (queue.size > 1) {
// 在指定num下的数据不需要进行删除 需要将其先出队 然后再入队
for (let i = 0; i < num; i++) {
const name = queue.dequeue();
name && queue.enqueue(name);
}
// 不满足条件的需要出对 删除掉
queue.dequeue();
}
// 找到满足条件的数据 进行格式化输出
const remainResult = queue.dequeue();
// const index = remainResult?(names.indexOf(remainResult)):-1
const index = names.indexOf(remainResult!); //加「!」是断言的作用 效果类似上面注释掉的语句
return { name: remainResult, index };
}
const remainIndex = hotPotato(["why", "james", "kobe", "curry", "lbxin"], 2);
console.log(remainIndex, "remainIndex=========");
// { name: 'curry', index: 3 } remainIndex=========
链表结构
- 前置知识 前端数据及结构 - 链表 - 单向链表👍🏻
- 链表的特点
- 优点
- 内存空间不必是连续的,可以实现较为灵活的内存动态管理
- 链表不必在创建的时候就确定大小,而且可以无限的延伸下去
- 链表在插入和删除数据时,时间复杂度可以达到O(1)
- 缺点
- 链表在访问任何一个数据时,都需要从头开始访问,无法跳过其中某个元素进行访问
- 无法通过下标直接访问元素,需要从头开始一个一个进行访问
- 具体内部实现原理
- 链表中每个节点的构造需要包含
数据域
和指针域
,在JS中是通过嵌套对象来实现的
{
// 数据域
val: 1,
// 指针域
next: {
val: 1,
next: ...
}
}
- 在实现链表时需要封装两个类
// 需要封装两个类
class Node {
value: any
next: Node | null = null //最后一个node的next为空
}
class LinkedList {
head: Node | null = null
append: (value:T) => {}
......
}
实现链表
- 封装Node类 用于创建符合规则的Node节点
class Node<T> {
value: T;
next: Node<T> | null = null; //联合类型 防止有空值的存在
constructor(value: T) {
this.value = value;
}
// constructor(public value: T) {} // 等同于 25行和27~29行的简写 TS语法
}
- 实现基类接口
// types/IList.ts
interface IList<T> {
// peek 返回第一个元素
peek(): T | undefined;
// 是否为空
isEmpty(): boolean;
// 元素个数
// 添加 get 后就可以直接以属性的方式进行调用该方法 是class的一个语法
// 同步在继承中也需要改为get
get size(): number;
// size(): number
}
export default IList;
- 链表接口的定义 → 继承自基类接口
// 链表结构LinkedList/ILinkList.ts
import IList from "../types/IList";
interface ILinkedList<T> extends IList<T> {
append(value: T): void;
traverse(): void;
insert(value: T, position: number): boolean;
removeAt(position: number): T | null;
get(position: number): T | null;
update(value: T, position: number): boolean;
indexOf(value: T): number;
remove(value: T): T | null;
// peek 返回第一个元素
// peek(): T | undefined
// 是否为空
// isEmpty(): boolean
// 元素个数
// get size(): number
// size(): number
get length(): number;
}
export default ILinkedList;
- 实现链表
import ILinkedList from "./ILinkList";
class Node<T> {
value: T;
next: Node<T> | null = null; //联合类型 防止有空值的存在
constructor(value: T) {
this.value = value;
}
// constructor(public value: T) {} // 等同于 25行和27~29行的简写 TS语法
}
// 创建LinkedList类
class LinkedList<T> implements ILinkedList<T> {
head: Node<T> | null = null; //头结点
length: number = 0;
// length为了实现数组和链表转化时的共有属性方法的兼容问题
get size(): number {
return this.length;
}
peek(): T | undefined {
return this.head?.value;
}
// 封装私有方法
// 根据position获取到当前节点
private getNode(position: number): Node<T> | null {
let index = 0;
let current = this.head;
while (index++ < position && current) {
current = current.next;
}
return current;
}
// 追加节点 需要区分链表是否为空
append(value: T) {
// 根据value创建新节点
const newNode = new Node(value);
if (this.head === null) {
this.head = newNode;
} else {
// 不为空 需要找到最后一个节点 然后将最后一个节点的next指向该新节点
// 解决办法是创建一个临时节点 用于找到最后一个节点后将其指向最后一个节点
let current = this.head;
while (current.next) {
current = current.next;
}
// current肯定是指向最后一个节点的
current.next = newNode;
}
this.length++;
}
// 遍历列表
traverse() {
// 现将当前的节点设置为链表的头结点
let current = this.head;
// 定义最终输出数据的格式缓冲区
let values: T[] = [];
// 遍历链表 打印出当前节点的数据
while (current) {
// console.log(current.value,'current.value=========')
values.push(current.value);
// 操作完当前节点后 将当前节点设置为「下一个节点」 知道遍历完整个链表
current = current.next;
}
console.log(values.join(" -> "), "values.join()=========");
}
// 插入方法
insert(value: T, position: number) {
if (position < 0 || position > this.length) {
// throw new Error(`插入的${position}越界了!`)
return false; //上面的方式会影响项目的运行
}
// 根据value创建新节点
let newNode = new Node(value);
// 是否是插入到头部
if (position === 0) {
// 注意顺序问题 反了后会导致旧的所有节点被删除
newNode.next = this.head;
this.head = newNode;
} else {
const previous = this.getNode(position - 1);
newNode.next = previous!.next;
previous!.next = newNode;
}
this.length++;
return true;
}
// 删除方法
// 在涉及链表删除节点的方法中,重点不是定位目标节点,而是定位目标节点的前驱节点
removeAt(position: number): T | null {
// 越界判断
if (position < 0 || position >= this.length) return null;
let current = this.head;
if (position === 0) {
this.head = current?.next ?? null; //可选链
} else {
let previous = this.getNode(position - 1);
current = previous?.next ?? null;
previous!.next = previous?.next?.next ?? null;
}
this.length--;
console.log(current?.value, "current=========");
return current?.value ?? null;
}
get(position: number): T | null {
if (position < 0 || position >= this.length) return null;
return this.getNode(position)?.value ?? null;
}
update(value: T, position: number): boolean {
if (position < 0 || position >= this.length) return false;
const currentNode = this.getNode(position);
currentNode!.value = value;
return true;
}
indexOf(value: T): number {
// 遍历节点
let current = this.head;
let index = 0;
while (current) {
if (current.value === value) {
return index;
}
current = current.next;
index++;
}
return -1;
}
remove(value: T): T | null {
let posrtion = this.indexOf(value);
return this.removeAt(posrtion);
}
isEmpty(): boolean {
return this.size === 0;
}
}
export { LinkedList }; //为了解决名称冲突 可以放入到一个模块里
- 面试题:反转链表
// 链表结构LinkedList/utils.ts
// 面试题基类Node封装
class ListNode {
val: number;
next: ListNode | null;
constructor(val?: number, next?: ListNode | null) {
this.val = val === undefined ? 0 : val;
this.next = next === undefined ? null : next;
}
}
export { ListNode };
- 栈结构方式反转
import { ListNode } from "./utils";
// 栈结构的缺点是会创建了一个内存单元去存储数据
function reverseList(head: ListNode | null): ListNode | null {
// 不需要处理的情况 - head为null
if (head == null) return null;
// 不需要处理的情况 - 只有一个节点 即head.next为null
if (head.next === null) return head;
// 栈的方式 - 数组模拟栈结构
const stack: ListNode[] = [];
let current: ListNode = head!;
while (current) {
stack.push(current);
current = current.next!;
}
console.log(stack, "stack=========");
// 依次从栈结构中取出数据 放到一个链表中
const newHead: ListNode = stack.pop()!;
let newHeadCurrent = newHead;
while (stack.length) {
const node = stack.pop()!;
newHeadCurrent.next = node;
newHeadCurrent = newHeadCurrent.next;
}
newHeadCurrent.next = null;
return newHead;
}
// 测试
const node1 = new ListNode(1);
node1.next = new ListNode(2);
node1.next.next = new ListNode(3);
console.log(node1, "node1=========");
const newHead = reverseList(node1);
let current = newHead;
while (current) {
current = current.next;
}
console.log(newHead, "newHead=========");
// ListNode {
// val: 1,
// next: ListNode { val: 2, next: ListNode { val: 3, next: null } }
// } node1=========
// [
// ListNode { val: 1, next: ListNode { val: 2, next: [ListNode] } },
// ListNode { val: 2, next: ListNode { val: 3, next: null } },
// ListNode { val: 3, next: null }
// ] stack=========
// ListNode {
// val: 3,
// next: ListNode { val: 2, next: ListNode { val: 1, next: null } }
// } newHead=========
export {};
- 递归方式反转
import { ListNode } from "./utils";
let count = 1;
function reverseList(head: ListNode | null): ListNode | null {
// 当使用递归时 需要有明确的结束条件 不然会陷入死循环的问题
// if(head === null) return null
// if(head.next === null) return head
if (head === null || head.next === null) return head;
// 创建newNode时最后一次返回的是最后一个节点 每次循环都会返回最后一个节点
const newNode = reverseList(head.next);
// 完成了递归需要处理的操作 首次来到这个位置的时候是倒数第二个节点
// 这里打印newNode时 每次都是最后一个节点 不同之处在于最后一个节点在不断的反转后的next指向不同 第一次是null 第二次是倒数第二个
console.log(newNode, `第${count++}次的节点是:`, head);
head.next.next = head;
head.next = null;
return newNode;
}
// =========================== 测试代码 start ===========================
const node1 = new ListNode(1);
node1.next = new ListNode(2);
node1.next.next = new ListNode(3);
console.log(node1, "node1=========");
const newHead = reverseList(node1);
let current = newHead;
while (current) {
console.log(current.val, "current.val=========");
current = current.next;
}
console.log(newHead, "newHead=========");
// ListNode {
// val: 1,
// next: ListNode { val: 2, next: ListNode { val: 3, next: null } }
// } node1=========
// ListNode { val: 3, next: null } 第1次的节点是: ListNode { val: 2, next: ListNode { val: 3, next: null } }
// ListNode { val: 3, next: ListNode { val: 2, next: null } } 第2次的节点是: ListNode { val: 1, next: ListNode { val: 2, next: null } }
// 3 current.val=========
// 2 current.val=========
// 1 current.val=========
// ListNode {
// val: 3,
// next: ListNode { val: 2, next: ListNode { val: 1, next: null } }
// } newHead=========
export {};