线性结构
线性结构由n个元素组成的有序序列。常用的线性结构有:线性表,栈,队列,双队列,数组。
数组的内存是连续的,因此在知道下标的情况下,访问效率是很高的。(早起JavaScript的数组内存实现并不是连续的。)
栈结构
实现一个栈的常用方法如下
在node中运行ts代码:npm i ts-node -g
。安装成功后直接ts-node 文件
即可。
利用数组实现栈结构
使用泛型进行约束,能够在开发的时候进行更好的提示
class ArrayStack<T = any> {
//T代表泛型 默认any
private data: T[] = [];
push(val: T): void {
//完成头插,最新数据放在栈顶,这里利用数组,将最新数据一值保存在最后一个元素
this.data.push(val);
}
pop(): T | undefined {
//当数组为空的时候出栈元素为undefined,需要设置为联合类型
//出栈,将栈顶元素出栈,即数组的最后一个元素取出
return this.data.pop();
}
peek(): T | undefined {
//查看当前栈顶元素,但是不会对栈进行任何修改
return this.data[this.data.length - 1];
}
isEmpty(): boolean {
//判断当前栈是否为空
return this.data.length === 0;
}
size(): number {
//判断当前栈的大小
return this.data.length;
}
}
实现一个基本的栈结构,就不需要重复写上面的几个方法
interface BaseStack<T> {
// 定义一个模版,用来快速实现通用方法
push(val: T): void;
pop(): T | undefined;
peek(): T | undefined;
isEmpty(): boolean;
size(): number;
}
export default BaseStack;
定义一个LinkedStack
类实现该接口,则选择提示默认会生成如下通用方法
队列
队列是受限的线性结构
实现队列有两种方法:数组和链表
。但是使用数组有一个效率缺陷,当进行删除的时候,可能会过多的移动很多元素造成性能问题。
队列中常见的方法如下
实现通用接口
export interface BaseQueue<T> {
enqueue(val: T): void;
dequeue(): T | undefined;
peek(): T | undefined;
isEmpty(): boolean;
getSize(): number;
}
class ArrayQueue<T> implements BaseQueue<T> {
private data: T[] = [];
enqueue(val: T): void {
//保持最新数据在首部
this.data.push(val);
}
dequeue(): T | undefined {
//每次将队首的值取出
return this.data.shift();
}
peek(): T | undefined {
return this.data[0];
}
isEmpty(): boolean {
return this.data.length === 0;
}
getSize(): number {
return this.data.length;
}
}
击鼓传花
// 根据传入的数字,决定喊到多少出局的人
// 比如数到3的人出队
function fun(name: string[], num: number): number {
if (name.length === 0) return -1;
let queue = new ArrayQueue<string>();
//集体入队
name.forEach((item) => {
queue.enqueue(item);
});
//人数只有一个的时候退出循环
while (queue.getSize() > 1) {
//1或2的人出队在入队
for (let i = 1; i < num; i++) {
let val = queue.dequeue();
if (val) queue.enqueue(val);
}
//喊到3的直接出队
queue.dequeue();
}
let res = queue.dequeue()!;
let index = name.indexOf(res);
return index;
}
let res = fun(["a", "b", "c", "d", "e", "f", "g", "h"], 3);
约瑟夫环
和上题属于一个类型
const lastRemaining = (n: number, m: number) => {
let queue = new ArrayQueue<number>();
for (let i = 0; i < n; i++) {
queue.enqueue(i);
}
while (queue.getSize() > 1) {
for (let i = 1; i < m; i++) {
queue.enqueue(queue.dequeue()!);
}
queue.dequeue();
}
return queue.dequeue();
};
const lastRemaining = (n: number, m: number) => {
let position = 0;
//从2开始考虑,当为1的时候无法组成一个环,即没有元素需要移除,该元素即最终需要移除
for (let i = 2; i <= n; i++) {
position = (position + m) % i;
}
return position;
};
链表
链表和数组一样,用于存储一系列元素。但是数组和链表的实现机制完全不同
链表的优势
封装一个Node类,存储每一个节点信息,在封装一个LinkedList类,标识链表结构,链表中存在两个属性一个是head节点指向链表的首部,另一个是链表的长度。
链表中的常用方法
append方法实现
class LinkNode<T> {
value: T;
next: LinkNode<T> | null = null;
constructor(value: T) {
this.value = value;
}
}
class LinkedList<T> {
head: LinkNode<T> | null = null;
private size: number = 0;
get length() {
return this.size;
}
}
都属于LinkedList内部方法
append(value: T) {
let node = new LinkNode<T>(value);
//分两种情况。无节点和有节点的情况
if (!this.head) {
//为空的情况下直接插入即可
this.head = node;
} else {
//链表插入节点需要遍历到最后一个节点进行插入
let curNode = this.head; //默认当前节点指向头部
while (curNode.next) {
//当没有指向null的时候才会执行
curNode = curNode.next;
}
//执行到这里代表为最后一个节点
curNode.next = node;
}
//节点数量自增
this.size++;
}
traverse方法
traverse() {
let res: T[] = [];
let curNode = this.head; //默认当前节点指向头部
while (curNode) {
res.push(curNode.value);
curNode = curNode.next;
}
console.log(res.join("->"));
}
insert方法
// 插入节点
insert(value: T, position: number) {
//越界处理
if (position < 0 || position > this.length) return false;
let newNode = new LinkNode<T>(value);
if (position === 0) {
//插入头部
newNode.next = this.head;
this.head = newNode;
} else {
//这里采用双指针法(也可以只使用一个preNode,通过两次.next也可以实现相同功能)
//包含了中间插入节点和尾部插入节点
// 遍历找到下标位置
let index = 0;
let curNode = this.head; //当前节点
let preNode: LinkNode<T> | null = null; //双指针,指向前一个节点
while (index < position && curNode) {
// 保存前一个位置
preNode = curNode;
//移动当前元素
curNode = curNode.next;
index++;
}
newNode.next = curNode;
preNode!.next = newNode;
}
this.size++;
return true;
}
removeAt
//删除节点
removeAt(position: number): T | null {
//越界处理(包含空节点的情况下删除)
if (position < 0 || position >= this.size) return null;
let curNode = this.head;
if (position === 0) {
//可能一个节点,则头节点指向null
this.head = curNode?.next ?? null; //删除首部,头节点指向下一个位置
} else {
let preNode: LinkNode<T> | null = null;
let index = 0;
while (index < position && curNode) {
preNode = curNode;
curNode = curNode.next;
index++;
}
preNode!.next = curNode?.next ?? null;
}
this.size--;
return curNode!.value;
}
getValue
getValue(position: number): T | null {
if (position < 0 || position >= this.size) return null;
let curNode = this.head;
let index = 0;
while (index < position && curNode) {
curNode = curNode.next;
index++;
}
return curNode?.value ?? null;
}
update
update(value: T, position: number): boolean {
if (position < 0 || position >= this.size) return false;
let curNode = this.head;
let index = 0;
while (index < position && curNode) {
curNode = curNode.next;
index++;
}
curNode!.value = value;
return true;
}
indexOf
indexOf(value: T): number {
let curNode = this.head;
let index = 0;
while (curNode) {
if (value === curNode.value) {
return index;
}
curNode = curNode.next;
index++;
}
return -1;
}
isEmpty
isEmpty(): boolean {
return this.size === 0;
}
removeValue
removeValue(value: T): boolean {
//根据值删除节点
let curNode = this.head;
let preNode: LinkNode<T> | null = null;
while (curNode) {
if (curNode.value === value) {
//可能是首节点的情况
if (preNode) {
preNode.next = curNode.next;
} else {
this.head = curNode.next;
}
return true;
}
preNode = curNode;
curNode = curNode.next;
}
return false;
}
时间复杂的
空间复杂度
数组和链表的复杂度比对
数组在查找方面的效率远高于链表的查找。
单项循环链表
重构部分原列表代码,添加尾节点的相关逻辑。
让循环列表继承单链表的结构,修改append方法,使其成为循环列表
class CircularLinkedList<T> extends LinkedList<T> {
append(value: T) {
//继承父类代码
super.append(value);
//首尾相连
this.tail!.next = this.head;
}
}
修改父类的循环输出列表,避免死循环
traverse() {
let res: T[] = [];
let curNode = this.head; //默认当前节点指向头部
while (curNode) {
res.push(curNode.value);
if (this.isTail(curNode)) {
//如果是尾节点了需要退出循环
break;
}
curNode = curNode.next;
}
//循环链表的时候才需要处理
if (this.head && this.tail!.next === this.head) res.push(this.head!.value);
console.log(res.join("->"));
}
重构插入方法
insert(value: T, position: number): boolean {
let flag = super.insert(value, position); //父类插入,返回布尔值,true代表成功
//插入节点需要使链表保持循环特性
if (flag && (position === this.length - 1 || position === 0)) { //length-1是因为父类插入完成后,长度会增加
//如果插入的节点是尾节点,那么需要将尾节点的下一个指向头结点
//如果插入的节点是头结点,那么尾节点需要指向性的头结点
this.tail!.next = this.head;
}
return flag;
}
重构删除操作
removeAt(position: number): T | null {
let value = super.removeAt(position);
if (value && this.tail && (position === 0 || position === this.length)) {
//判断this.tail是否存在,如只有一个节点的情况
this.tail.next = this.head;
}
return value;
}
修改获取下标方法
indexOf(value: T): number {
let curNode = this.head;
let index = 0;
while (curNode) {
if (value === curNode.value) {
return index;
}
if (this.isTail(curNode)) break;
curNode = curNode.next;
index++;
}
return -1;
}
双向链表
双向链表依旧是继承原封装好的单项链表
class LinkNode<T> {
value: T;
next: LinkNode<T> | null = null;
constructor(value: T) {
this.value = value;
}
}
//双向列表需要一个节点指向前面
export class DoublyLinkedNode<T> extends LinkNode<T> {
// 每一个节点包含了指向下一个节点和前一个节点还有当前值
prev: DoublyLinkedNode<T> | null = null;
//继承的next节点定义了类型,只包含了LinkNode节点的内容,因此这里需要重写
next: DoublyLinkedNode<T> | null = null;
}
搭建双向链表的结构
class DoublyLinkedList<T> extends LinkedList<T> {
// 重写head和tail节点的类型;
protected head: DoublyLinkedNode<T> | null = null;
protected tail: DoublyLinkedNode<T> | null = null;
}
重构append方法
//重写插入方法
append(value: T): void {
let newNode = new DoublyLinkedNode(value);
if (this.head === null && this.tail === null) {
//为空的情况下首节点的插入操作
this.head = newNode;
this.tail = newNode;
} else {
//非头节点插入的情况下,尾节点都是指向前一个位置
this.tail!.next = newNode; //先和新节点建立连接
newNode.prev = this.tail; //新节点和前面的节点建立连接
this.tail = newNode; //将尾节点移动
}
this.size++;
}
prepend头插法封装
prepend(value: T) {
//往头部插入节点
let newNode = new DoublyLinkedNode(value);
if (this.head === null && this.tail === null) {
this.head = newNode;
this.tail = newNode;
} else {
//向头部插入,不需要处理tail位置,且不是双向循环列表,不需要过多考虑
newNode.next = this.head; //新节点和首节点建立连接
this.head!.prev = newNode;
this.head = newNode;
}
this.size++;
}
psotTraverse反向输出链表
psotTraverse() {
//将数据逆向输出
let arr: T[] = [];
let curNode = this.tail;
while (curNode) {
arr.push(curNode.value);
//不断向前移动指针
curNode = curNode.prev;
}
console.log(arr.join("<-"));
}
封装insert方法
insert(value: T, position: number): boolean {
if (position < 0 || position > this.length) return false;
if (position === 0) {
//头部插入直接调用封装好的方法
this.prepend(value); //方法内部包含数据增加操作
} else if (position === this.length) {
//尾节点插入情况
this.append(value); //方法内部包含数据增加操作
} else {
// 找到当前插入下标位置的节点
let curNode = this.head;
let index = 0;
while (index < position && curNode) {
curNode = curNode.next;
index++;
}
// 中间插入节点
let newNode = new DoublyLinkedNode(value);
// 根据当前curNode节点的信息进行连接新节点
curNode!.prev!.next = newNode; //前一个节点和当前新节点建立关系
newNode.next = curNode; //当前新节点和下一个节点建立关系
newNode.prev = curNode!.prev;
curNode!.prev = newNode;
this.size++;
}
return true;
}
removeAt方法
removeAt(position: number): T | null {
if (position < 0 || position >= this.length) return null;
let curNode = this.head; //默认头结点
if (position === 0) {
//头部删除节点情况
//分只有一个节点的情况
curNode = this.head; //保存节点返回
if (this.length === 1) {
this.head = null;
this.tail = null;
} else {
this.head = this.head!.next;
this.head!.prev = null;
}
} else if (position === this.length - 1) {
//删除尾节点情况
curNode = this.tail; //保存节点返回
this.tail = this.tail!.prev;
this.tail!.next = null;
} else {
let index = 0;
while (index < position && curNode) {
curNode = curNode.next;
index++;
}
//删除节点
curNode!.next!.prev = curNode!.prev;
curNode!.prev!.next = curNode!.next;
}
this.size--;
return curNode?.value || null;
}
哈希表
哈希表通常都是基于数组完成的。其在大量数据面前,进行插入删除值都接近常量的时间:O(1)。但是哈希表在没有进过特殊处理的前提下,内部数据是无序的。并且哈希表中的key值不允许重复。
开放地址法
主要是通过查询空白地址区域以此插入新数据完成。但是当根据指定的公式探寻到当前数所对应的地址时候,如果该地址有值的情况下,需要采用不同的探测方法,探寻空白地址区域:线性探测,二次探测,再哈希法。
线性探测的问题
二次探测的问题
再哈希法
装填因子
平均探测长度以及平均存取时间,取决于装填因子,装填因子越大,则探测的长度也会越大。
装填因子表示哈希表中已经包含的数据项和整个哈希表长度的比例值。装填因子=总数据项/哈希表长度。在开发地址法中最大装填因子为1。链地址发的装填因子可以大于1,因此拉链法可以无限延伸。但是会随着装填因子的变大而探测步长变大。
哈希函数
在设计哈希表长度的时候尽量为质数,N次幂的底数也应该尽量使用质数
/**
*
* @param key //计算hash地址的值,要求唯一
* @param max 哈希表的长度
* @returns //存放哈希表的索引
*/
const hashFun = (key: string, max: number): number => {
let hashCode = 0; //初始值为0
let len = key.length;
for (let i = 0; i < len; i++) {
//31是计算使用的质数,charCodeAt函数获取字符串的ascii码
hashCode = hashCode * 31 + key.charCodeAt(i);
}
let index = hashCode % max; //计算出该值位于哈希表中的位置
return index;
};
// 装填因子 :4 / 7 = 0.57
console.log(hashFun("abc", 7));
console.log(hashFun("cba", 7));
console.log(hashFun("nba", 7));
console.log(hashFun("mba", 7));
// 装填因子:6 / 7 = 0.85 预计超出0.75的时候可以开始自动扩容哈希表
console.log(hashFun("aaa", 7));
console.log(hashFun("bbb", 7));
实现哈希表
基于链地址发实现哈希表
封装一个哈希表基本结构
class HashTable<T = any> {
/* storage = [[[string, T]]]这种结构,整体是一个数组,然后数组的每一个元素是一个数组,数组中的每一个元素是一个元组 */
private storage: [string, T][][] = []; //哈希表整体
private length: number = 7; //hash表长度
private count: number = 0; //hash表已填充元素,两个变量用于计算装填因子
}
put新增或修改
因为哈希表中的key不允许重复,因此当一个key已经存在的情况下,则进行更新操作。
put(key: string, value: T) {
//计算哈希地址
let hashCode = this.hashFun(key, this.length);
let buket = this.storage[hashCode]; //取出哈希地址对应的数组
//初始化的时候哈希表整体值为undefined
if (!buket) {
buket = []; //将对应位置的哈希地址初始化为数组
this.storage[hashCode] = buket;
}
//更新标识
let isUpdate: boolean = false;
for (let i = 0; i < buket.length; i++) {
//编程当前数组,查找是否存在相同的key,存在则更新
let tuple = buket[i]; //找出当前地址即数组中存放的每一项元组[string,T]
let tupleKey = tuple[0]; //key值
if (tupleKey === key) {
//存在则更新
tuple[1] = value;
isUpdate = true;
break;
}
}
if (!isUpdate) {
//执行到这里,说明不存在相同的key,执行插入操作
buket.push([key, value]);
this.count++;
}
}
get方法
get(key: string): T | undefined {
let hashCode = this.hashFun(key, this.length);
let buket = this.storage[hashCode];
if (!buket) return undefined; //如果索引为空,代表没有数据位于当下存储空间内
for (let i = 0; i < buket.length; i++) {
let tuple = buket[i];
let tupleKey = tuple[0];
if (tupleKey === key) {
return tuple[1];
}
}
//在对应的索引数据中没有找到该数据
return undefined;
}
delete
delete(key: string): T | undefined {
let hashCode = this.hashFun(key, this.length);
let buket = this.storage[hashCode];
if (!buket) return undefined; //不存在数据
for (let i = 0; i < buket.length; i++) {
let tuple = buket[i];
let tupleKey = tuple[0];
if (tupleKey === key) {
buket.splice(i, 1); //删除元组
this.count--;
return tuple[1];
}
}
return undefined; //未找到情况
}
扩容与缩容
哈希表扩容首先需要将原数组的长度 * 2 (暂时不考虑质数长度)。然后因为hashFun
函数是针对当前数组的长度进行计算的,因此扩容后,所有的原数据需要再次进行计算重新放入哈希表中保存。
private resize(newLength: number) {
this.length = newLength; //新哈希表的长度
let oldStorage = this.storage;
this.storage = [];
this.count = 0;
//将数据再次插入到哈希表中
oldStorage.forEach((buket) => {
if (!buket) return;
for (let i = 0; i < buket.length; i++) {
let tuple = buket[i];
this.put(tuple[0], tuple[1]); // put的时候已经再次hash化了
}
});
}
在原来的put和delete方法中添加判断
buket.push([key, value]);
this.count++;
let loadFactor = this.count / this.length;
if (loadFactor > 0.75) {
//装填因子大于0.75则进行扩容
this.resize(this.length * 2);
}
buket.splice(i, 1); //删除元组
this.count--;
let loadFactor = this.count / this.length;
//设置最小缩容长度
if (loadFactor < 0.25 && this.length > 7) {
//装填因子大于0.75则进行扩容
this.resize(Math.floor(this.length / 2));
}
测试如下
let hashTable = new HashTable<number>();
hashTable.put("aaa", 100);
// 5 / 7 = 0.71
hashTable.put("aaa", 200);
hashTable.put("bbb", 300);
hashTable.put("ccc", 412);
hashTable.put("abc", 222);
hashTable.put("cba", 111);
console.log(hashTable.storage);
//扩容
hashTable.put("nba", 123);
hashTable.put("baw", 1243);
console.log(hashTable.storage);
//缩容
hashTable.delete("nba");
hashTable.delete("baw");
hashTable.delete("aaa");
hashTable.delete("ccc");
hashTable.delete("abc");
console.log(hashTable.storage);
质数问题
采用质数作为哈希表的长度的时候可以很好的均匀分布元素减少冲突。
private isPrime(num: number): boolean {
let sqrt = Math.sqrt(num);
for (let i = 2; i <= sqrt; i++) {
if (num % i === 0) return false;
}
return true;
}
如果当前长度不是质数的前提下,不断向下寻找质数
private getNextPrime(num: number) {
let newPrime = num;
while (!this.isPrime(newPrime)) {
//不是质数的情况下一直寻找质数
newPrime++;
}
if (newPrime < 7) newPrime = 7; //保持最小长度
return newPrime;
}
private resize(newLength: number) {
this.length = this.getNextPrime(newLength); //新哈希表的长度
console.log("长度", this.length);
......
}
树
树的术语
树的表示方法
二叉树概念
二叉搜索的操作
封装二叉树搜索树基本结构
class Node<T> {
value: T;
constructor(val: T) {
this.value = val;
}
}
class TreeNode<T> extends Node<T> {
left: TreeNode<T> | null = null;
right: TreeNode<T> | null = null;
}
class BSTree<T> {
private root: TreeNode<T> | null = null; //根节点
}
insert插入
private insertNode(node: TreeNode<T>, newNode: TreeNode<T>) {
if (newNode.value < node.value) {
//新节点的值比根节点小,左插入
//左插入分为两张情况,空或非空节点,非空节点进行递归比较插入
if (node.left === null) {
node.left = newNode;
} else {
this.insertNode(node.left, newNode);
}
} else {
if (node.right === null) {
node.right = newNode;
} else {
this.insertNode(node.right, newNode);
}
}
}
insert(value: T) {
//新节点
let newNode = new TreeNode(value);
//如果当前根节点为空则新节点直接为根节点
if (this.root === null) {
this.root = newNode;
} else {
//每次插入的数据都是从根节点开始比较
this.insertNode(this.root, newNode);
}
}
插入节点后并借助工具打印如下
插入代码解析如下
preOrderTraverse先序遍历
private preOrderTraverseNode(node: TreeNode<T> | null = null) {
if (node) {
//当前节点不为空的情况下输出
console.log(node.value);
// 然后进行根左右递归
this.preOrderTraverseNode(node.left);
this.preOrderTraverseNode(node.right);
}
}
preOrderTraverse() {
// 先序遍历
this.preOrderTraverseNode(this.root);
}
非递归写法
inOrderTraverse中序遍历
private inOrderTraverseNode(node: TreeNode<T> | null = null) {
if (node) {
this.inOrderTraverseNode(node.left);
console.log(node.value);
this.inOrderTraverseNode(node.right);
}
}
inOrderTraverse() {
//中序遍历
this.inOrderTraverseNode(this.root);
}
非递归写法
postOrderTraverse后序遍历
private postOrderTraverseNode(node: TreeNode<T> | null = null) {
if (node) {
this.postOrderTraverseNode(node.left);
this.postOrderTraverseNode(node.right);
console.log(node.value);
}
}
postOrderTraverse() {
//后序
this.postOrderTraverseNode(this.root);
}
非递归写法
levelOrderTraverse层序遍历
层序遍历借助队列完成,思路首先将当前根节点入队,如果队列不为空就一直出队,读取当前节点的值,然后将当前节点的左右节点分别入队。
levelOrderTraverse() {
//层序遍历
let queue: TreeNode<T>[] = [];
queue.push(this.root!);
while (queue.length) {
let curNode = queue.shift()!; //当前元素
console.log(curNode.value);
if (curNode.left) queue.push(curNode.left);
if (curNode.right) queue.push(curNode.right);
}
}
获取最大值和最小值
在一个二叉搜索树中,最左边和最右边的节点分别代表最小值和最大值
getMaxValue(): T | null {
//获取最大值
let curNode = this.root;
while (curNode && curNode.right) {
curNode = curNode.right;
}
return curNode?.value || null;
}
getMinValue() {
let curNode = this.root;
while (curNode && curNode.left) {
curNode = curNode.left;
}
return curNode?.value || null;
}
search查找
search(val: T): boolean {
let curNode = this.root;
while (curNode) {
if (curNode.value === val) return true;
//如果查找的元素比当前节点大,向右查找,否则反之查找
if (curNode.value > val) {
curNode = curNode.left;
} else {
curNode = curNode.right;
}
}
return false;
}
递归写法
remove删除节点
删除节点需要考虑的情况很多,如叶子节点删除,非叶子节点删除等几种情况。但是无论如何删除,都是需要删除节点的父节点,确保删除后树的完整性。下面这段初始代码负责寻找节点及其父节点
//该方法适用于search查找方法
private searchNode(value: T) {
// 封装的查找节点
let curNode = this.root;
while (curNode) {
if (curNode.value === value) return curNode;
//如果查找的元素比当前节点大,向右查找,否则反之查找
let parNode = curNode; //保存父节点
if (curNode.value > value) {
curNode = curNode.left;
} else {
curNode = curNode.right;
}
// 保留当前节点的父节点
if (curNode) curNode.parent = parNode;
}
return null;
}
remove(value: T): boolean {
let curNode = this.searchNode(value);
console.log(`当前节点${curNode?.value},父节点${curNode?.parent?.value}`);
return true;
}
做节点的删除操作,分多种情况,分别是叶子节点的删除,该节点只有一个节点的删除操作,和该节点有多个节点的删除操作,前面两个比较简单。
if (!curNode) return false; //当前节点不存在直接返回
if (curNode.left === null && curNode.right === null) {
// 处理叶子节点情况
/* 叶子节点分两种情况,一种是只有一个根节点的情况,还有一个是存在节点情况的叶子节点 */
// 左右节点均为空,代表是叶子节点
if (curNode === this.root) {
//如果当前节点是根节点
curNode = null;
} else if (curNode.parent!.left === curNode) {
// 通过父元素删除叶子节点,左节点为当前元素,代表在左边
curNode.parent!.left = null;
} else if (curNode.parent!.right === curNode) {
curNode.parent!.right = null;
}
} else if (curNode.left === null) {
// 当前节点只有一个右节点的情况下删除该节点,徐将其子节点与该节点的父节点进行相连
//那么需要判断当前节点是父节点的左节点还是右节点
if (curNode.parent!.left === curNode) {
//代表当前节点是父节点的左节点,需要将父节点的左节点连接当前删除元素的右节点
curNode.parent!.left = curNode.right;
} else if (curNode.parent!.right === curNode) {
curNode.parent!.right = curNode.right;
}
} else if (curNode.right === null) {
//当前删除节点只有一个左节点情况
if (curNode.parent!.left === curNode) {
curNode.parent!.left = curNode.left;
} else if (curNode.parent!.right === curNode) {
curNode.parent!.right = curNode.left;
}
}
但是删除的节点如果有多个节点,就需要特殊处理。比如上图最后一个实例,删除7节点后还需要保持二叉搜索树的规律,如果删除该节点后,对左子树进行节点提升操作,那么该节点必须是删除节点的左子树中最大的那一个。在删除15节点中,左子树中最大的14进行提示,则可以保持规律。如果要提升右子树,那么该节点节点必须是删除节点的右子树中最小的一个。因此当删除9节点的时候,左右两个叶子节点均可以提升。
下面是最后一种情况
else {
//删除节点具有多个节点
let successor = this.getSuccessor(curNode); //获取删除节点的后继节点
if (curNode === this.root) {
//删除的节点是根节点
this.root = successor;
} else if (curNode.parent?.left === curNode) {
//删除是左节点情况
//但是只将后继节点连接过来还不行,还需要处理节点下面的其他节点情况
curNode.parent!.left = successor;
} else {
curNode.parent!.right = successor;
}
}
private getSuccessor(delNode: TreeNode<T>): TreeNode<T> {
//这里获取删除节点的左子树中最小的那一个后继节点
let curNode = delNode.right; //一定是删除节点的右子树中,然后在右子树中往左边找最小值
let successor: TreeNode<T> | null = null;
while (curNode) {
successor = curNode;
curNode = curNode.left;
if (curNode) {
//保存后继节点的父节点,用于链接后继节点的子节点
curNode.parent = successor;
}
}
//将后继节点属于叶子节点,将该节点继承原删除节点的位置,修改指向,兼容原节点
if (delNode.right !== successor) {
successor!.parent!.left = successor!.right; //一定是往右边取,因为左边是最小值
successor!.right = delNode.right;
}
successor!.left = delNode.left;
// 退出节点的时候,当前curNode为删除节点右子树的最小值
return successor!;
}
删除11,7,15的结果如图
完整的删除代码
remove(value: T): boolean {
let curNode = this.searchNode(value);
if (!curNode) return false; //当前节点不存在直接返回
if (curNode.left === null && curNode.right === null) {
// 处理叶子节点情况
/* 叶子节点分两种情况,一种是只有一个根节点的情况,还有一个是存在节点情况的叶子节点 */
// 左右节点均为空,代表是叶子节点
if (curNode === this.root) {
//如果当前节点是根节点
curNode = null;
} else if (curNode.parent!.left === curNode) {
// 通过父元素删除叶子节点,左节点为当前元素,代表在左边
curNode.parent!.left = null;
} else if (curNode.parent!.right === curNode) {
curNode.parent!.right = null;
}
} else if (curNode.left === null) {
// 当前节点只有一个右节点的情况下删除该节点,徐将其子节点与该节点的父节点进行相连
//那么需要判断当前节点是父节点的左节点还是右节点
if (curNode.parent!.left === curNode) {
//代表当前节点是父节点的左节点,需要将父节点的左节点连接当前删除元素的右节点
curNode.parent!.left = curNode.right;
} else if (curNode.parent!.right === curNode) {
curNode.parent!.right = curNode.right;
}
} else if (curNode.right === null) {
//当前删除节点只有一个左节点情况
if (curNode.parent!.left === curNode) {
curNode.parent!.left = curNode.left;
} else if (curNode.parent!.right === curNode) {
curNode.parent!.right = curNode.left;
}
} else {
//删除节点具有多个节点
let successor = this.getSuccessor(curNode); //获取删除节点的后继节点
if (curNode === this.root) {
//删除的节点是根节点
this.root = successor;
} else if (curNode.parent?.left === curNode) {
//删除是左节点情况
//但是只将后继节点连接过来还不行,还需要处理节点下面的其他节点情况
curNode.parent!.left = successor;
} else {
curNode.parent!.right = successor;
}
}
return true;
}
平衡二叉搜索树
二叉搜索树在进行插入的时候如1,2,3,4,5这些值的时候,会往树的一边进行插入操作,会造成树的高度不平衡。
封装基本的AVL节点结构
class AVLTreeNode<T> {
value: T;
left: AVLTreeNode<T> | null = null;
right: AVLTreeNode<T> | null = null;
parent: AVLTreeNode<T> | null = null;
// 当前节点的高度
height: number = 1;
constructor(val: T) {
this.value = val;
}
get isLeft(): boolean {
return !!(this.parent && this.parent.left === this);
}
get isRight(): boolean {
return !!(this.parent && this.parent.right === this);
}
//获取某一个节点在树中的高度
getHeight(): number {
//左右子树中选高度最高的
let leftHeight = this.left ? this.left.getHeight() : 0;
let rightHeight = this.right ? this.right.getHeight() : 0;
return Math.max(leftHeight, rightHeight) + 1;
}
getBalanceFactor() {
//获取平衡因子,左树高度减去右树高度
let leftHeight = this.left ? this.left.getHeight() : 0; //直接进入当前节点的子树开始比较
let rightHeight = this.right ? this.right.getHeight() : 0;//直接进入当前节点的子树开始比较
return leftHeight - rightHeight; //0,1,-1
}
get isBalanced() {
//直接获取布尔值
return this.getBalanceFactor() >= -1 && this.getBalanceFactor() <= 1;
}
get heigherChild(): AVLTreeNode<T> | null {
//获取更高的子节点
let leftChild = this.left ? this.left.getHeight() : 0;
let rightChild = this.right ? this.right.getHeight() : 0;
//返回左右子树中最高的一个
if (leftChild > rightChild) return this.left;
if (leftChild < rightChild) return this.right;
//尽量不返回null
return this.isLeft ? this.left : this.right;
}
}
封装一个获取最高节点的操作。如图所示,节点5属于不平衡节点,那么就需要获取其左右节点中最高的子节点
左左情况(右旋转)
rightRotation() {
let isLeft = this.isLeft;
let isRight = this.isRight;
//右旋转(左左)
//保存轴心点
let pivot = this.left!; //this代表当前不平衡节点
pivot.parent = this.parent; //保存父节点
//处理轴心点的右子节点情况
this.left = pivot.right;
if (pivot.right) {
//修改其子节点的parent指向
pivot.right.parent = this;
}
//将轴心点的右子树链接不平衡的节点
pivot.right = this;
this.parent = pivot;
//处理轴心点父节点情况
if (!pivot.parent) {
//直接作为根节点
return pivot;
} else if (isLeft) {
//但是执行到这里原节点跟父节点的关系已经断了在一开始保存
//不平衡节点属于左节点,那么旋转后的节点也属于左节点
pivot.parent.left = pivot;
} else if (isRight) {
pivot.parent.right = pivot;
}
return pivot;
}
右右 左旋转
leftRotation() {
let isLeft = this.isLeft;
let isRight = this.isRight;
let pivot = this.right!;
pivot.parent = this.parent;
this.right = pivot.left;
if (pivot.left) pivot.left.parent = this;
pivot.left = this;
this.parent = pivot;
if (!pivot.parent) {
return pivot;
} else if (isLeft) {
pivot.parent.left = pivot;
} else if (isRight) {
pivot.parent.right = pivot;
}
return pivot;
}
树的再平衡
已经封装了左旋和右旋代码,现在针对节点插入进行二次平衡,找到如图所示的红色区域不平衡的节点,还有绿色区域的轴心节点以及粉色区域的节点。先查找不平衡节点的左右子树最高的一边,然后查找轴心节点左右子树中最高的一边,
封装AVL树的结构
class AVLTree<T> extends BSTree<T> {}
reBalance(root: AVLTreeNode<T>) {
//重新平衡
let pivot = root.heigherChild!; //不平衡节点中最高的节点
let current = pivot.heigherChild; //轴心点中最高的节点
let resultNode: AVLTreeNode<T> | null = null; //作为最后存储root节点使用(根节点)
if (pivot.isLeft) {
// L
if (current?.isLeft) {
// L
// 左左情况进行右旋
resultNode = root.rightRotation(); //返回轴心点
} else {
// R
//左右旋,先进行左旋再进行右旋
pivot.leftRotation(); // 返回的是粉红色区域的轴心点
resultNode = root.rightRotation();
}
} else {
//R
if (current?.isLeft) {
// L
//右左情况 先进行右旋然后左旋
pivot.rightRotation();
resultNode = root.leftRotation();
} else {
// R
//右右情况
resultNode = root.leftRotation();
}
}
if (resultNode.parent === null) {
this.root = resultNode; //将旋转后的轴心点设置为根节点
}
}
但是如上代码并没有处理旋转后成为根节点的情况。
插入节点旋转如图
因为当前平衡树继承了二叉搜索树,所以可以使用insert方法插入节点打印测试,但是在insert方法中创建的节点是TreeNode非AVLTreeNode,因此不会有左旋右旋方法
子类中重写
然后调整父类的插入方法,每次插入的节点,连接其父节点。方便选择的时候调整
然后需要在每次插入一个新节点后进行平衡检查,检查的步骤就是从当前节点开始依次检测其父元素是否平衡,直到根节点为止。检查的方法写在父类中,如果写在子类中不方便传值。
然后在子类中重写检查平衡的方法
checkBalance(node: AVLTreeNode<T>) {
/*
新插入的节点是否平衡,需要依次向其父节点进行平衡检查
新插入的节点本身就为叶子节点不需要检测,需要检测其父节点到根节点是否平衡
*/
let curNode = node.parent; //当前新插入节点的父节点
while (curNode) {
// 检测其父节点是否平衡
if (!curNode.isBalanced) {
//不平衡开始调整
this.reBalance(curNode);
}
curNode = curNode.parent;
}
}
插入随机节点测试
平衡二叉树的删除
删除方法复用二次搜索树的删除,但是需要保留删除节点的后继节点和其父节点的关系。
remove(value: T): boolean {
let curNode = this.searchNode(value);
if (!curNode) return false; //当前节点不存在直接返回
let replaceNode: TreeNode<T> | null = null;
let delNode = curNode; //保存被删除的节点(处理AVL情况)
// 抽离代码
if (curNode.left === null && curNode.right === null) {
replaceNode = null;
} else if (curNode.left === null) {
replaceNode = curNode.right;
} else if (curNode.right === null) {
replaceNode = curNode.left;
} else {
//删除节点具有多个节点
let successor = this.getSuccessor(curNode); //获取删除节点的后继节点
replaceNode = successor;
}
if (curNode === this.root) {
//如果当前节点是根节点
curNode = replaceNode; //根节点移动
} else if (curNode.parent!.left === curNode) {
// 通过父元素删除叶子节点,左节点为当前元素,代表在左边
curNode.parent!.left = replaceNode;
} else if (curNode.parent!.right === curNode) {
curNode.parent!.right = replaceNode;
}
if (replaceNode && curNode?.parent) {
// 将replaceNode的父节点链接到删除节点的父节点
replaceNode.parent = curNode.parent;
}
this.checkBalance(delNode);
return true;
}
如图删除25的时候需要将12和50之间建立联系,删除12的时候,树已经不平衡了,需要进行平衡操作。
但是上面的删除逻辑适用于一个节点删除,或删除节点只有一个左右节点的情况,因此下面这段代码主要用于功能实现。但是如果是删除的节点有两个左右子树,那么情况就比较复杂,不断的修改父节点等等。因此在平衡二叉树删除节点具有两个左右子树的时候采用赋值删除发。
if (replaceNode && curNode?.parent) {
// 将replaceNode的父节点链接到删除节点的父节点
replaceNode.parent = curNode.parent;
}
this.checkBalance(delNode);
以下是节点具有左右两个子树的情况
修改remove方法中处理删除节点有两个节点的情况,这里需要再结束的时候返回,否则会往下执行修改指向的部分(这里需要通过值删除节点)
//删除节点具有多个节点
let successor = this.getSuccessor(curNode); //获取删除节点的后继节点
curNode.value = successor.value; //后继节点最小值覆盖原有删除节点的内容,这样子不用修改节点的指向等问题
delNode = successor; //实际被删除的节点是后继节点
this.checkBalance(delNode);
return true;
private getSuccessor(delNode: TreeNode<T>): TreeNode<T> {
//这里获取删除节点的左子树中最小的那一个后继节点
let curNode = delNode.right; //一定是删除节点的右子树中,然后在右子树中往左边找最小值
let successor: TreeNode<T> | null = null;
while (curNode) {
successor = curNode;
curNode = curNode.left;
if (curNode) {
//保存后继节点的父节点,用于链接后继节点的子节点
curNode.parent = successor;
}
}
//将后继节点属于叶子节点,将该节点继承原删除节点的位置,修改指向,兼容原节点
if (delNode.right !== successor) {
successor!.parent!.left = successor!.right; //一定是往右边取,因为左边是最小值
if (successor?.right) {
successor.right.parent = successor.parent;
}
} else {
delNode.right = successor!.right;
if (successor?.right) {
successor.right.parent = delNode;
}
}
// 退出节点的时候,当前curNode为删除节点右子树的最小值
return successor!;
}
堆结构
堆是一种树形结构,使用完全二叉树实现,必须符合完全二叉树的概念。堆基本都是二叉堆:二叉堆分为最大堆和最小堆。
- 最大堆:堆中的每一个节点都大于等于他的子节点
- 最小堆:堆中的每一个节点都小于等于他的子节点
最大堆
class Heap<T> {
data: T[] = [];
private size: number = 0;
get length() {
return this.size;
}
private swap(i: number, j: number) {
const temp = this.data[i];
this.data[i] = this.data[j];
this.data[j] = temp;
}
peek(): T | undefined {
return this.data[0];
}
isEmpty() {
return this.length === 0;
}
insert(value: T) {
}
extract(): T | undefined {
//删除堆中最大值或最小值
return undefined;
}
buildHeep(arr: T[]) {}
}
insert插入方法
insert(value: T) {
// 实现最大堆
//将插入的数据放到数组的最后一个位置
this.data.push(value);
this.size++;
let index = this.length - 1; //起始值最后一个元素的下标
while (index > 0) {
//如果初始化数组为空,则插入的元素就在下标0的位置,不需要进行比较
//循环比较
let parentIndex = Math.floor((index - 1) / 2); //父元素下标位置
if (this.data[index] <= this.data[parentIndex]) {
//新插入的节点小于其父节点不需要交换
break;
}
this.swap(index, parentIndex);
index = parentIndex; //继续往往上寻找依次比较
}
}
extract
删除元素,不能使用shif方法进行删除,因此该方法删除没回自动将删除元素后面的元素自动往前移动,这样子会打断原本就符合大根堆的特性。
extract(): T | undefined {
//删除堆中最大值
if (this.length === 0) {
//如果空堆直接返回
return undefined;
}
if (this.length === 1) {
//如果是一个元素的堆,则直接返回,因为不涉及其他元素的移动,直接使用数组原生方法即可
this.size--;
//返回的元素本身就属于最大值
return this.data.pop();
}
let topValue = this.data[0]; //取出最大堆中的最大值
//将数组中的最后一个元素移动到数组首部
this.data[0] = this.data.pop()!; //pop方法能够自动实现数组长度修改
this.size--;
let index = 0;
while (index * 2 + 1 < this.length) {
//index*2+1条件是判断当前位置的左子节点下标是否存在于数组中,防止越界,
//堆是一个完全二叉树,先有左子节点再有右子节点
//循环负责保持最大堆结构
let LeftChildIndex = 2 * index + 1;
let RightChildIndex = 2 * index + 2;
let largeIndex = LeftChildIndex; //保存最大值下标 默认左边最大值
//这里是判断右节点是否越界(上面判断左节点,下面判断右节点)
if (
RightChildIndex < this.length &&
this.data[RightChildIndex] > this.data[LeftChildIndex]
) {
largeIndex = RightChildIndex;
}
//判断最大值和index位置的值进行比较,决定是否需要改变堆结构
if (this.data[index] >= this.data[largeIndex]) {
break;
}
this.swap(index, largeIndex);
index = largeIndex;
}
return topValue;
}
删除100的情况
原地建堆
给定一个初始化数组[9,11,20,56,23,45],不使用额外空间,在原有的数组上直接进行建堆操作。
步骤:找到第一个非叶子节点。然后进行下虑操作比较大小交换。然后依次比较非叶子节点和其子节点大小。
第一个非叶子节点公式:Math.floor((arr.length / 2) - 1)
buildHeep(arr: T[]) {
this.data = arr;
this.size = this.data.length;
// 找到第一个非叶子节点
let index = Math.floor(this.length / 2 - 1);
for (let start = index; start >= 0; start--) {
this.heapifyDown(start); //给改函数设置形参start并初始化赋值给index
//#region
/* index = start;
while (index * 2 + 1 < this.length) {
// 从非叶子节点开始进行下虑比较操作
let leftChildIndex = index * 2 + 1;
let rightChildInex = index * 2 + 2;
let largeIndex = leftChildIndex;
if (
rightChildInex < this.length &&
this.data[rightChildInex] > this.data[largeIndex]
) {
largeIndex = rightChildInex;
}
if (this.data[index] >= this.data[largeIndex]) break;
this.swap(index, largeIndex);
index = largeIndex;
} */
//#endregion
}
}
最小堆
最小堆只需要修改上面最大堆的比较条件即可
上虑中代码修改
if (this.data[index] >= this.data[parentIndex]) {
//新插入的节点大于其父节点不需要交换
break;
}
下虑中代码修改
let LeftChildIndex = 2 * index + 1;
let RightChildIndex = 2 * index + 2;
let smallIndex = LeftChildIndex; //保存最大值下标 默认左边最小值
//这里是判断右节点是否越界(上面判断左节点,下面判断右节点)
if (
RightChildIndex < this.length &&
this.data[RightChildIndex] < this.data[LeftChildIndex]
) {
smallIndex = RightChildIndex;
}
//判断最小值和index位置的值进行比较,决定是否需要改变堆结构
if (this.data[index] <= this.data[smallIndex]) {
break;
}
双端队列
双端队列允许在队列的两边同时进行插入和删除
class ArrayDeque<T> extends ArrayQueue<T> {
addFront(value: T) {
this.data.unshift(value);
}
removeBack() {
return this.data.pop();
}
}
优先级队列
优先级队列设计为一个节点,每一个节点都具有值和优先级,将其保存在一个最大堆中,每次出队,本质是出队最大堆中的最大值
将原先的堆结构部分修改使用即可
export class PriorityNode<T> {
value: T;
priority: number;
constructor(val: T, priority: number) {
this.value = val;
this.priority = priority;
}
}
class PriorityQueue<T> {
//指定保存在堆中的数据类型结构
private heap = new Heap();
enqueue(value: T, priority: number) {
let node = new PriorityNode(value, priority);
this.heap.insert(node);
}
dequeue() {
return this.heap.extract()?.value;
}
isEmpty() {
return this.heap.isEmpty();
}
peek() {
return this.heap.peek();
}
}