说在前面
限于这是个自查手册,回答不那么太详细,如果某个知识点下面有链接,自己又没有深入了解过的,理应点击链接或自行搜索深入了解。
另外两个部分 :
基本算法
排序
稳定性,关键字相同的元素,排序后保持着排序前的前后位置
选择排序
时间复杂度最稳定的算法,固定 O(n²) ,具有稳定性
- 为某一位找到它后面最小的数,然后交换;从前往后循环。
function selectionSort(arr) {
var len = arr.length;
var minIndex, temp;
for (var i = 0; i < len - 1; i++) {
minIndex = i;
for (var j = i + 1; j < len; j++) {
if (arr[j] < arr[minIndex]) { //寻找最小的数
minIndex = j; //将最小数的索引保存
}
}
temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
return arr;
}
插入排序
O(n^2),具有稳定性
- 在前面已完成排序的有序序列中不断向前对比并交换位置,直到找到位置 ;从前往后循环
function insertionSort(arr) {
var len = arr.length;
var preIndex, current;
for (var i = 1; i < len; i++) {
preIndex = i - 1;
current = arr[i];
while(preIndex >= 0 && arr[preIndex] > current) {
arr[preIndex+1] = arr[preIndex];
preIndex--;
}
arr[preIndex+1] = current;
}
return arr;
}
归并排序
O(nlog(n)) ,不具有稳定性
首先需要实现merge函数,之后将数组拆分成两半,分治;最后再merge回来;
function MergeSort(arr) {
const len = arr.length
if (len <= 1) return arr
const middle = Math.floor(len / 2)
const left = MergeSort(arr.slice(0, middle))
const right = MergeSort(arr.slice(middle, len))
return merge(left, right)
// 核心函数
function merge(left, right) {
let l = 0
let r = 0
let result = []
while (l < left.length && r < right.length) {
if (left[l] < right[r]) {
result.push(left[l])
l++
} else {
result.push(right[r])
r++
}
}
result = result.concat(left.slice(l, left.length))
result = result.concat(right.slice(r, right.length))
return result
}
}
快速排序
O(nlog(n)),不具有稳定性
函数式的写法很简单,先取出第一个数,先用 fliter分成小于区和大于区,再对两个区分治;最后合成一个数组
两次filter遍历了两次数组,可以用for循环遍历一次来代替
function QuickSort(arr) {
if (arr.length <= 1) return arr
const flag = arr.shift()
const left = QuickSort(arr.filter(num => num <= flag))
const right = QuickSort(arr.filter(num => num > flag))
return [...left, flag, ...right]
}
树
递归遍历
前中后序遍历
前序节点第一次经过时输出
中序节点第二次经过时输出
后序节点第三次经过时输出
// 前序遍历
function ProOrderTraverse(biTree) {
if (biTree == null) return;
console.log(biTree.data);
ProOrderTraverse(biTree.lChild);
ProOrderTraverse(biTree.rChild);
}
// 中序遍历
function InOrderTraverse(biTree) {
if (biTree == null) return;
InOrderTraverse(biTree.lChild);
console.log(biTree.data);
InOrderTraverse(biTree.rChild);
}
// 后序遍历
function PostOrderTraverse(biTree) {
if (biTree == null) return;
PostOrderTraverse(biTree.lChild);
PostOrderTraverse(biTree.rChild);
console.log(biTree.data);
}
非递归遍历(迭代遍历)
深度优先遍历
迭代法需要掌握 前序 和 中序 两种;一般遍历用到前序,二叉搜索树用到中序
- 前序遍历就是在出栈时加入子树,然后循环;注意是先入栈右子树再入栈左子树;
-
中序遍历就是将左侧连成一条线上的节点先后入栈,出栈后入栈右节点;需要用 p 指针保存遍历的位置
如下图:首先入栈 A-H,出栈 H、D,D 有右节点,入栈 I ,出栈 I,出栈 B,B 有右节点 E,入栈 E-N ;如此循环...
// 深度优先非递归
// 前序遍历
function DepthFirstSearch(biTree) {
let stack = [biTree];
while (stack.length != 0) {
let node = stack.pop();
console.log(node.data);
// 注意rChild先 才能保证先访问到左子树
if (node.right) stack.push(node.right);
if (node.left) stack.push(node.left);
}
}
// 中序遍历
function DepthFirstInorderSearch(biTree){
let stack = [biTree]
let p = biTree
while(stack.length != 0){
// 某一节点的左方先全入栈
while(p.left){
stack.push(p.left)
p = p.left
}
p = stack.pop()
console.log(p.data)
if(p.right){
stack.push(p)
p = p.right
}
}
}
// 后序遍历 https://leetcode-cn.com/problems/binary-tree-postorder-traversal/solution/
var postorderTraversal = function(root) {
let res = [];
if(!root) {
return res;
}
let stack = [];
let cur = root;
do {
if(cur) {
stack.push([cur, true]);
cur = cur.left;
} else {
cur = stack[stack.length-1][0];
if(!cur.right || !stack[stack.length-1][1]) {
res.push(stack.pop()[0].val);
cur = null;
} else {
stack[stack.length-1][1] = false;
cur = stack[stack.length-1][0].right;
}
}
}while(stack.length);
return res;
};
广度优先遍历(层序遍历)
//广度优先非递归 与上面前序迭代遍历结构相同
function BreadthFirstSearch(biTree) {
let queue = [];
queue.push(biTree);
while (queue.length != 0) {
let node = queue.shift();
console.log(node.data);
if (node.left) {
queue.push(node.left);
}
if (node.right) {
queue.push(node.right);
}
}
}
两种优先遍历分析
两种非递归遍历的结构都是相同的,差别主要是一点
- 深度优先(前序)是栈(先入后出),广度优先是队列(先入先出)
- 因为深度优先是栈,所以在push左右子树时,应该先push右子树再左子树,才能保证从左到右的顺序
生成
层序生成树
class Node { // 定义节点
constructor(data){
this.data = data
this.left = null
this.right = null
}
}
// 层序遍历结果的数组,生成层序遍历树
// 输入例 ['a','b','d',null,null,'e',null,null,'c',null,null] # 是 null
// a
// / \
// b d
// / \ / \
// # # e #
// / \
// # c
// / \
// # #
function CreateTree(arr) {
let i = 0
const head = new Node(arr[i++])
let queue = [head]
let next
while (queue.length) {
let node = queue.shift()
next = arr[i++]
if (!(next == null)) queue.push((node.left = new Node(next)))
next = arr[i++]
if (!(next == null)) queue.push((node.right = new Node(next)))
}
return head
}
// 或者用 for of 可以模拟队列
function CreateTree(arr) {
let i = 0
const head = new Node(arr[i++])
let queue = [head]
let next
for (let node of queue) {
next = arr[i++]
if (!(next == null)) queue.push((node.left = new Node(next)))
next = arr[i++]
if (!(next == null)) queue.push((node.right = new Node(next)))
}
return head
}
二叉树搜索树 BST(Binary Search Tree)
定义
- 左子树不为空时,左子树上所有节点的值都小于根节点
- 右子树不为空时,右子树上所有节点的值都大于根节点
- 左右子树也是二叉搜索树
- 没有重复的节点
其他特点
-
中序遍历输出的是有序序列
可以用于输出第 K 大/小 的节点
-
最左边的节点的值是最小的,最右边的节点是值最大的
搜索
如果树是空的,则查找未命中,返回 null ;
如果被查找的键和根节点的键相等,查找命中,返回根节点对应的值;
如果被查找的键较小,则在左子树中继续查找,如果被查找的键较大,则在右子树中继续查找。
性能:最好 O(logn) 一颗平衡二叉树 最差O(n) 退化成链表
// 递归法
function search(head, data){
//在以x为根结点的子树中查找并返回键key所对应的节点
//如果找不到,就返回null
if(head == null) return null;
if(data < head.data) return search(head.left, data);
else if(data > head.data) return search(head.right, data);
else return head;
}
// 迭代法
function search(head, data) {
while (head) {
if (data < head.data) head = head.left
else if (data > head.data) head = head.right
else return head
}
return null
}
插入
- 插入是作为叶子节点插入
- 需要先查找后再插入
function insert(head, data) {
if (head === null) return new Node(data)
while (true) {
if (data < head.data) {
if (head.left) head = head.left
else return (head.left = new Node(data))
} else if (data > head.data) {
if (head.right) head = head.rightleft
else return (head.right = new Node(data))
} else return false
}
}
二叉查找树的这样插入算法一定能保证正确性吗?是的,它这样插入一定符合定义;但是一组无序的数据,它可以有n多种二叉搜索树的形式,其中有一种二叉搜索树,它的左子树和右子树高度差不超过1,可以给查找带来最好的性能,这就是平衡二叉树,平衡二叉树可以通过普通的二叉查找树调整得到。
堆
完全二叉树
完全二叉树有如下性质:
-
结点的编号对应该结点在数组的下标
-
父节点的下标为 i,则左子节点下标为 2i + 1,右子节点下标为 2i + 2
具体视根节点位置而定,根节点序号为 0 时,左子节点下标为 2i + 1,右子节点下标为 2i + 2
根节点序号为 1 时,左子节点下标为 2i,右子节点下标为 2i + 1
因为有如上性质,所以完全二叉树一般用数组储存。
堆是一颗完全二叉树。
定义
以小顶堆为例(大顶堆将小于换成大于)
- 根节点小于或者等于左子树和右子树上的所有结点
- 如果子树存在,那么它也是堆
作用:
-
堆排序
在原数组上进行,大顶堆可以转化为升序序列;小顶堆可以转化为降序序列;
可以获得第 K 大/小 的元素
堆排序:
堆排序一共有三个步骤
1. 建堆
2. 将堆顶元素取出,并将堆底元素放置堆顶
因为堆顶的元素是当前所有元素中最大/最小的,所以按序取出就是有序序列
3. 筛选(即重建堆)
重复 2 和 3步骤
筛选
筛选:当堆的根元素发生了改变,重建堆的过程
或者说满足以下条件才能进行筛选
- 左右子树都是堆,且高度差距不大于1
建堆也需要筛选,筛选步骤贯穿整个堆排序,所以它是堆的重中之重;
具体步骤如下
- 找出左右子节点值最大的节点,与根节点比较,如果值大于根节点,那么将它们值进行交换,之后指针向下移至交换过的子节点;一直循环下去
- 如果某一节点子节点均不大于根节点或为叶子节点,说明当前树符合堆的规范,直接返回;
分析:因为子树一定是堆,所以发生了交换后,又回到了刚刚的问题:堆的根元素发生改变,重建堆,一直到不交换或叶子结点为止
如下图,根节点变为 6,与左子节点进行交换,然后指针往左子节点移
// 父子节点交换
function swap(arr, i, j) {
let temp = arr[i]
arr[i] = arr[j]
arr[j] = temp
}
// 筛选
function shiftDown(A, i, length) {
let temp = A[i] // 当前父节点
// j<length 的目的是对结点 i 以下的结点全部做顺序调整
for (let j = 2 * i + 1; j < length; j = 2 * i + 1) {
temp = A[i] // 将 A[i] 取出,整个过程相当于找到 A[i] 应处于的位置
if (j + 1 < length && A[j] < A[j + 1]) {
j++ // 找到两个孩子中较大的一个,再与父节点比较
}
if (temp < A[j]) {
swap(A, i, j) // 如果父节点小于子节点:交换;否则跳出
i = j // 交换后,temp 的下标变为 j
} else {
break
}
}
}
建立
我们输入一个无序的数组,如何把它建立成堆?只需要从最后一个节点开始,到第一个节点,每个进行筛选,就可以构成一个堆;实际上,建堆是从最后一个非叶子节点开始,因为叶子节点已经是堆了,不需要筛选。
分析:假如有某非叶子节点 n,左右子节点 2n + 1,2n + 2,因为 2n + 1 和 2n + 2 先前已经做过筛选,它们已经是堆了,问题就转化成:当堆的根元素发生了改变,重建堆的过程,这里的根指的是 n;一直筛选到根节点最后整个数组就是一个堆了
function createHeap(A) {
// 从最后一个非叶子节点开始 即 Math.floor(A.length / 2 - 1)
for (let i = Math.floor(A.length / 2 - 1); i >= 0; i--) {
shiftDown(A, i, A.length)
}
}
排序
排序原理我们已经知道了:建堆,取最大,重建堆
// 堆排序
function heapSort(A) {
// 初始化大顶堆,从第一个非叶子结点开始
createHeap(A)
// 排序,每一次for循环找出一个当前最大值,数组长度减一
for(let i = Math.floor(A.length-1); i>0; i--) {
swap(A, 0, i); // 根节点与最后一个节点交换
shiftDown(A, 0, i); // 从根节点开始调整
}
}
其他
二分搜索
逻辑很简单,判断数组[中间下标]与搜索值比较大小后,左边或右边下标移到中间下标,注意点是边界处理问题
// 逻辑很简单,主要是边界处理的问题
function binarySearch(arr, num) {
let left = 0
let right = arr.length - 1
let middle
while (right - left > 1) {
middle = Math.floor((left + right) / 2)
const now = arr[middle]
if (num === now) return middle
if (num > now) left = middle
else right = middle
}
if (arr[right] === num) return right
if (arr[left] === num) return left
return -1
}
// precision是返回根平方与num的差的绝对值小于这个精度
function sqrtInt(num, precision) {
precision = 1 / 10 ** precision // 转换为末尾 如 0.1
let left = 1
let right = (1 + num) / 2
while (true) {
const middle = (left + right) / 2
const middleSquare = middle ** 2
const diff = middleSquare - num
if (Math.abs(diff) < precision) return middle
if (diff > 0) right = middle
else left = middle
}
}
设计模式
一. 结构型模式(Structural Patterns)
通过识别系统中组件间的简单关系来简化系统的设计。
适配器模式
适配器用来解决两个已有接口之间不匹配的问题,它并不需要考虑接口是如何实现,也不用考虑将来该如何修改;适配器不需要修改已有接口,就可以使他们协同工作;
外观模式
外观模式是最常见的设计模式之一,它为子系统中的一组接口提供一个统一的高层接口,使子系统更容易使用。简而言之外观设计模式就是把多个子系统中复杂逻辑进行抽象,从而提供一个更统一、更简洁、更易用的API。很多我们常用的框架和库基本都遵循了外观设计模式,比如JQuery就把复杂的原生DOM操作进行了抽象和封装,并消除了浏览器之间的兼容问题,从而提供了一个更高级更易用的版本。其实在平时工作中我们也会经常用到外观模式进行开发,只是我们不自知而已。
比如,我们可以应用外观模式封装一个统一的DOM元素事件绑定/取消方法,用于兼容不同版本的浏览器和更方便的调用:
// 绑定事件
function addEvent(element, event, handler) {
if (element.addEventListener) {
element.addEventListener(event, handler, false);
} else if (element.attachEvent) {
element.attachEvent('on' + event, handler);
} else {
element['on' + event] = fn;
}
}
// 取消绑定
function removeEvent(element, event, handler) {
if (element.removeEventListener) {
element.removeEventListener(event, handler, false);
} else if (element.detachEvent) {
element.detachEvent('on' + event, handler);
} else {
element['on' + event] = null;
}
}
代理模式
代理模式可以解决以下的问题:
- 增加对一个对象的访问控制
- 当访问一个对象的过程中需要增加额外的逻辑
要实现代理模式需要三部分:
Real Subject:真实对象Proxy:代理对象Subject接口:Real Subject 和 Proxy都需要实现的接口,这样Proxy才能被当成Real Subject的“替身”使用
创建型模式(Creational Patterns)
处理对象的创建,根据实际情况使用合适的方式创建对象。常规的对象创建方式可能会导致设计上的问题,或增加设计的复杂度。创建型模式通过以某种方式控制对象的创建来解决问题。
工厂模式
将构造函数进行二次封装
单例模式
顾名思义,单例模式中Class的实例个数最多为1。当需要一个对象去贯穿整个系统执行某些任务时,单例模式就派上了用场。而除此之外的场景尽量避免单例模式的使用,因为单例模式会引入全局状态,而一个健康的系统应该避免引入过多的全局状态。
行为型模式(Behavioral Patterns)
用于识别对象之间常见的交互模式并加以实现,如此,增加了这些交互的灵活性。
观察者模式
观察者模式又称发布订阅模式(Publish/Subscribe Pattern),是我们经常接触到的设计模式,日常生活中的应用也比比皆是,比如你订阅了某个博主的频道,当有内容更新时会收到推送;又比如JavaScript中的事件订阅响应机制。观察者模式的思想用一句话描述就是:被观察对象(subject)维护一组观察者(observer),当被观察对象状态改变时,通过调用观察者的某个方法将这些变化通知到观察者。
策略模式
策略模式简单描述就是:对象有某个行为,但是在不同的场景中,该行为有不同的实现算法。比如每个人都要“交个人所得税”,但是“在美国交个人所得税”和“在中国交个人所得税”就有不同的算税方法。最常见的使用策略模式的场景如登录鉴权,鉴权算法取决于用户的登录方式是手机、邮箱或者第三方的微信登录等等,而且登录方式也只有在运行时才能获取,获取到登录方式后再动态的配置鉴权策略。所有这些策略应该实现统一的接口,或者说有统一的行为模式。Node 生态里著名的鉴权库 Passport.js API的设计就应用了策略模式。
还是以登录鉴权的例子我们仿照 passport.js 的思路通过代码来理解策略模式:
将if-else中的逻辑抽离成不同方法,更关注不同方法内的逻辑且方便切换。
/**
* 登录控制器
*/
function LoginController() {
this.strategy = undefined;
this.setStrategy = function (strategy) {
this.strategy = strategy;
this.login = this.strategy.login;
}
}
/**
* 用户名、密码登录策略
*/
function LocalStragegy() {
this.login = ({ username, password }) => {
console.log(username, password);
// authenticating with username and password...
}
}
/**
* 手机号、验证码登录策略
*/
function PhoneStragety() {
this.login = ({ phone, verifyCode }) => {
console.log(phone, verifyCode);
// authenticating with hone and verifyCode...
}
}
/**
* 第三方社交登录策略
*/
function SocialStragety() {
this.login = ({ id, secret }) => {
console.log(id, secret);
// authenticating with id and secret...
}
}
const loginController = new LoginController();
// 调用用户名、密码登录接口,使用LocalStrategy
app.use('/login/local', function (req, res) {
loginController.setStrategy(new LocalStragegy());
loginController.login(req.body);
});
// 调用手机、验证码登录接口,使用PhoneStrategy
app.use('/login/phone', function (req, res) {
loginController.setStrategy(new PhoneStragety());
loginController.login(req.body);
});
// 调用社交登录接口,使用SocialStrategy
app.use('/login/social', function (req, res) {
loginController.setStrategy(new SocialStragety());
loginController.login(req.body);
});
从以上示例可以得出使用策略模式有以下优势:
- 方便在运行时切换算法和策略
- 代码更简洁,避免使用大量的条件判断
- 关注分离,每个strategy类控制自己的算法逻辑,strategy和其使用者之间也相互独立
操作系统
进程相关
进程和线程的区别?
- 进程是资源分配的最小单位,线程是CPU调度的最小单位
- 线程在进程下行进,一个进程可以包含多个线程
- 不同进程间数据很难共享,同一进程下不同线程间数据很易共享
- 线程上下文切换比进程上下文切换快的多
- 进程要比线程消耗更多的计算机资源
- 进程间不会相互影响,一个线程挂掉将导致整个进程挂掉
进程间通信 IPC
IPC方式包括:管道、系统IPC(信号量、消息队列、共享内存)和套接字(socket)。
死锁
所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。
进程间互相占用对方所需资源,等待对方释放资源的僵持状态;
避免死锁-银行家算法
避免系统进入不安全的状态
- 系统每种资源的总量
- 系统每种资源已分配的总量
- 系统每种资源未分配的总量
首先有一个请求分配的进程需求列表,它们有的已经被分配了部分资源;
选取一个剩余资源满足所需资源的进程,进程释放资源后系统将之前分配的资源和刚刚分配的资源一同回收(所以剩余资源总量增加了,可以满足更多进程了),再继续选取一个可以满足的进程,如此反复,直到所有进程都被满足了
这一个满足进程的先后顺序被称为 安全队列 ,找出 安全队列 可以避免系统进入一个不安全的状态
首先需要定义状态和安全状态的概念。系统的状态是当前给进程分配的资源情况。因此,状态包含两个向量Resource(系统中每种资源的总量)和Available(未分配给进程的每种资源的总量)及两个矩阵Claim(表示进程对资源的需求)和Allocation(表示当前分配给进程的资源)。安全状态是指至少有一个资源分配序列不会导致死锁。当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程知道同意该请求后系统状态仍然是安全的。
作业调度算法
先来先服务(First Come First Server)
按照作业到达的先后顺序进行服务。缺点是对短作业不利
短作业优先(Short First Server)
最短的作业优先得到服务。缺点是对长作业不利且可能产生饥饿
最短剩余时间优先
短作业优先的 抢占式 版本。
调度程序总是选择剩余运行时间最短的那个进程运行。当一个新作业到达时,其整个时间同当前进程的剩余时间做比较。如果新的进程比当前运行进程需要更少的时间,当前进程就被挂起,而运行新的进程。
时间片轮转法
轮流为各个进程分配服务,让每个服务都可以得到响应。它是一个抢占式的算法
按照各进程到达就绪队列的顺序,轮流让各个进程执行一个时间片(如100ms)。若进程未在一个时间片内执行完,则剥夺处理机,将进程重新放到就绪队列队尾重新排队。