二分查找
注意:为了防止相加之后越界所以先 right-left,这里必须整体用括号包裹,因为前面的 + 号优先级大于 >>
1、左闭右闭区间写法(最常用)
var search = function(nums, target) {
let left = 0,right = nums.length-1
while(left<=right){
let mid = left + ((right-left)>>1)
if(nums[mid] === target){
return mid
}else if(nums[mid] > target ){
right = mid-1
}else{
left = mid + 1
}
}
return -1
};
2、左闭右开区间(可以这么写,但一般直接用上面的更简洁点,注意边界导致的条件修改即可)
var search = function(nums, target) {
let left = 0,right = nums.length // 1、这里的右边界就是越界的序号了,因为是右开
while(left<right){ // 2、这里也不能等于了,因为right是无效值,终止条件是 left === right,我们拿不到这个right,等于每次是在[left,right)这个区间进行搜索
let mid = left + ((right-left)>>1)
if(nums[mid] === target){
return mid
}else if(nums[mid] > target ){
right = mid // 3、同理right也不需要-1了,下一次区间是[left,right)
}else{
left = mid + 1
}
}
return -1
};
3、寻找左侧边界的二分搜索
首先思考:返回值代表啥?
nums = [2,3,5,7],target = 1,算法会返回 0,含义是:nums中小于 1 的元素有 0 个
nums = [2,3,5,7], target = 8,算法会返回 4,含义是:nums中小于 8 的元素有 4 个
也代表着我们的模板值应该插入到数组的位置
于是返回条件就不是直接返回-1了,需要判断下返回值与数组长度的关系
注意点:在查找时,比如 [1,2,3,4,5] 找 -1,那么 left 返回的是 0 ,但是找6的话返回的 left 就是 5,右侧超出是会等于数组长度 arr.length 的,这里要手动减一才行。反之思考寻找右侧边界时,应该左侧最终返回 -1 ,右侧是 arr.length-1
找到 K 个最接近的元素
1)左闭右开写法(比较常见)
var search = function(nums, target) {
if (nums.length === 0) return -1; // 直接返回
let left = 0,right = nums.length
while(left<right){
let mid = left + ((right-left)>>1)
if(nums[mid] === target){
right = mid // 1、这里对比上面的模板,不需要返回了,因为还要继续搜索确定边界
}else if(nums[mid] > target ){
right = mid
}else{
left = mid + 1
}
}
// 2、检查出界情况(这一步应该可以省略不写,因为正常返回left的话也可以帮我们找到小于target的值有多少个,写这个的话是找最左边那个target的左边有多少个,差不多意思)
if (left >= nums.length || nums[left] != target) return -1;
return left // 3、这里返回我们找到的边界序号
};
2)左闭右闭写法(统一)
var search = function(nums, target) {
if (nums.length === 0) return -1; // 直接返回
let left = 0,right = nums.length-1
while(left<=right){ // 3、等于
let mid = left + ((right-left)>>1)
if(nums[mid] === target){
right = mid - 1 // 1、只要是闭合,这里就要-1(相等时必须要-1缩小边界,不然[1,1]这种情况会死循环)
}else if(nums[mid] > target ){
right = mid - 1 // 2、只要是闭合,这里就要-1
}else{
left = mid + 1
}
}
return left
};
4、寻找右侧边界的二分搜索
返回最后一个小于等于目标值的元素的序号
1)左闭右开写法(了解即可)
var search = function(nums, target) {
if (nums.length === 0) return -1; // 直接返回
let left = 0,right = nums.length // 1.注意
while(left<right){
let mid = left + ((right-left)>>1)
if(nums[mid] === target){
left = mid + 1 // 2、只要是闭合,这里就要+1
}else if(nums[mid] < target ){
left = mid + 1 // 3、只要是闭合,这里就要+1
}else{
right = mid
}
}
if (left>nums.length || nums[left-1] != target) return -1; // 5、这里的边界条件如果要返回right-1的话,就要改成right-1>0 || nums[right-1]!==target。建议用left,因为上面left一直+1在缩减边界,更清晰
return left - 1 // 4、这里要减一,右侧的左闭右开写法专属,因为相等时left会加一,所以结束的时候肯定nums[left]和target不相等。这里的left和right在所有左闭右开都是一样的,因为终止条件就是相等
};
2)左闭右闭写法(统一)
var search = function(nums, target) {
if (nums.length === 0) return -1; // 直接返回
let left = 0,right = nums.length-1
while(left<=right){
let mid = left + ((right-left)>>1)
if(nums[mid] === target){
left = mid + 1 // 1、只要是闭合,这里就要+1
}else if(nums[mid] < target ){
left = mid + 1 // 2、只要是闭合,这里就要+1
}else{
right = mid - 1
}
}
if (right<0 || nums[right] != target) return -1;
return right
};
注意
search( [2,3,5,7],6) 返回 2
search( [2,3,5,7],1) 返回 - 1
search( [2,3,5,7],8) 返回 3
相同的数会返回最右边的一个值的索引,没有相等的数就返回边界最后一个比目标值小的值的索引
因为返回的 right 所以结果会比 left 小一位
上述算法找 mid 值都是找的左边,因为向下取整,如果要拿右边的值需要+1,这里遇见过题目的答案是专门找右边的,但是应该也可以用左边做出来,到时候遇见再看
数组螺旋遍历
从左到右存取链表中的值,默认-1。
let arr = Array(m).fill(0).map(item=> Array(n).fill(-1))
let dx = [-1,0,1,0],dy = [0,1,0,-1] // 这里的值可以选择遍历方向,index 从 1 开始,选择首先遍历的方向,比如这里是优先从左到右,再从上到下
let d = 1,x = 0 ,y = 0 // d === 1 是为了方便取模重复赋值
while(head){
arr[x][y] = head.val
let a = x+dx[d],b=y+dy[d] // 下一个点的 x y
if(a<0 || a>=m || b<0 || b>=n || arr[a][b] !== -1){ // 如果不符合要求,就改变方向,重置正确的下个点的坐标
d = (d+1) % 4
a = x+dx[d], b = y +dy[d]
}
x = a , y = b // 下一个点没问题就赋值给 x,y
head = head.next
}
return arr
n x n的数组顺时针旋转90,180,270度通用思路
顺时针旋转90:先沿对角线反转矩阵,再沿竖中轴线反转矩阵;
顺时针旋转180:先沿横中轴线反转矩阵,再沿竖中轴线反转矩阵;
顺时针旋转270:先沿对角线反转矩阵,再沿横中轴线反转矩阵;
let len = matrix.length-1, halfLen = len / 2 // 记录最后一个元素的下标和边长的一半
// 按对角线反转矩阵
for(let i=0;i<=len;i++){
for(let j=0;j<i;j++){
[matrix[i][j], matrix[j][i]] = [matrix[j][i], matrix[i][j]]
}
}
// 按竖中轴线反转矩阵
for(let i=0;i<=len;i++){
for(let j=0;j<halfLen;j++){
[matrix[i][j], matrix[i][len-j]] = [matrix[i][len-j], matrix[i][j]]
}
}
// 按横中轴线反转矩阵
for(let i=0;i<=len;i++){
for(let j=0;j<halfLen;j++){
[matrix[len-i][j], matrix[i][j]] = [matrix[i][j], matrix[len-i][j]]
}
}
tips:如果是 dfs 中使用,可以对 dx 和 dy 取 for (let i=0;i<4;i++){}
滑动窗口
数据是左闭右开格式,因为循环终止的条件是right === s.length
注意点:一般需要相加时有负数也用不了,无法判断左指针是否应该移动
var minWindow = function(s, t) {
let left = 0,right = 0
while(right < s.length){ // 这样写就成了右开区间,右区间最后终止时right === s.length,s[right]是没有意义的
let rval = s[right]
right ++
// 进行窗口内数据的一系列更新,比如根据条件对哈希的更新
// 判断左侧窗口是否要收缩
while(左窗口需要收缩){
// 如果需要使用到left指针的值,应该在这里,因为下面要+1了
let lval = s[left]
left++
// 进行窗口内数据的一系列更新,比如根据条件对哈希的更新
}
}
// 返回结果
};
注意点:
在找重复子串题中,一般定义一个 window 哈希记录窗口中的字符出现次数,一个 map 哈希存放我们的子串中字符出现次数,这个是固定的,我们要做的就是哈希动态存入 right 新入窗口字符和删除 left 退出的字符(如果存在的话)
大顶堆、小顶堆(优先队列)
定义:大顶堆的每个父节点都大于子节点,根节点是最大的值。小顶堆相反
如何创建堆:
完全二叉树可以使用数组存储,堆又是完全二叉树,所以堆也可以用一个数组表示。
给定一个节点的下标 i (i从0开始) ,那么它的父节点位置为(i-1)/2 ,左子节点为 2i + 1 ,右子节点为 2i+2
于是我们可以创建类
注意点:
1、堆化和出堆操作都要进行down
2、入堆则要进行up
3、主要就是熟悉up、down的操作和原理,其他都是辅助函数写法很多,注意边界即可
class Heap{
constructor(){
this.queue = []
this.size = 0
this.compare = (a,b) => this.queue[a] > this.queue[b]
}
swap(a,b){
[this.queue[a],this.queue[b]] = [this.queue[b],this.queue[a]]
}
shiftDown(index){
let child = (index<<1) + 1
while(child < this.size){
// 右子节点与左子节点对比,满足函数要求就替换成右子节点,下沉就多了一个这个判断
if(child+1<this.size && this.compare(child+1,child)){
child = child+1
}
// 如果父元素对比满足要求,不用替换了
if(this.compare(index,child)){
break
}
this.swap(child,index)
index = child
child = (index<<1)+1 // 必须放下面,放最上面会死循环
}
}
shiftUp(index){
let parent = (index - 1) >> 1// 先找到第一个父节点,判断是否可执行
while(parent>=0){ // 如果存在 并且 没有break 就一直循环替换
if(this.compare(parent,index)){ // 父节点 对比 子节点满足 函数的要求就不替换了,跟之前的不一样,这里是正向的
break
}
this.swap(parent,index)
index = parent
parent = (index - 1) >> 1
}
}
push(val){
this.queue.push(val)
this.shiftUp(this.size++)// 当前的size就是push之后的最后一个元素的下标,然后给size+1就是数组长度
}
pop(){
this.swap(0,--this.size)
this.shiftDown(0)
return this.queue.pop() // 这里可以直接替换、下沉、弹出,不用担心最后一个值再次被往上提,因为上面把size减一了,所以下面down中不会对比最后一个数值,如果用length就是length-1和length-2
}
}
前中后序遍历迭代
前
function preorderTraverse(pRoot){
if(!root){ return [] }
let res = [], stack = []
while(root || stack.length) {
while(root){
res.push(root.val) // 只有保存结果的位置不同,流程和中序一样
stack.push(root)
root= root.left
}
root= stack.pop()
root= root.right
}
return res
}
中
function inorderTraverse(root){
if(!root){return [] }
let res = [], stack = []
while(root || stack.length) {
while(root){
stack.push(root)
root= root.left
}
root= stack.pop()
res.push(root.val)
root= root.right
}
return res
}
后: 先存根右左,再reverse。可以用上面的前序遍历改一下左右的顺序,也可以用下面这种
function fn(){
let stack = [], res = []
root && stack.push(root)
while(stack.length > 0) {
let cur = stack.pop()
res.push(cur.val)
cur.left && stack.push(cur.left)
cur.right && stack.push(cur.right) // 因为上面是pop,所以先存的后出来
}
return res.reverse()
}
层序遍历
var bfs = function(root) {
let q = [root] // 队列
let level = 0 // 初始化层数
while(q.length>0){
let len = q.length
for(let i=0;i<len;i++){
let root = q.shift()
if(root.left){
q.push(root.left)
}
if(root.right){
q.push(root.right)
}
}
level++
}
};
根据层序数组构建二叉树,arr = [2,3,4,5,6] 只有一维
const buildTree = (arr) => {
let i = 0; //i每次用完都需要自增1,因为层序构造依赖于数组的索引
let root = new TreeNode(arr[i++]);
let NodeList = [root];
while (NodeList.length) {
let node = NodeList.shift();
if (arr[i] !== '0') { //如果是空的就不创建节点
node.left = new TreeNode(arr[i]); //创建左节点
NodeList.push(node.left);
}
i++; //不管是不是空的,i都需要自增
if (i == arr.length)
return root; //如果长度已经够了就返回,免得数组索引溢出
if (arr[i] !== '0') { //如果是空的就不创建节点
node.right = new TreeNode(arr[i]); //创建右节点
NodeList.push(node.right);
}
i++; //不管是不是空的,i都需要自增
if (i == arr.length)
return root; //如果长度已经够了就返回,免得数组索引溢出
}
return root;
}
排序算法
归并排序
先二分(logn) 再合并(n) 所以是nlogn
// 从上至下:递归分成两部分数组,通过队列把两个数组合并成有序的数组返回
function erfen(arr){
if(arr.length < 2) return arr // base case,只有一个就返回给上层开始合并(测试了这样分不会分成0个)
let mid = arr.length >> 1
let left = arr.slice(0,mid)
let right = arr.slice(mid)
return merge(erfen(left),erfen(right))
}
function merge(left,right){
let res = []
while(left.length && right.length){
if(left[0]<right[0]){
res.push(left.shift())
}else {
res.push(right.shift())
}
}
left.length ? res.push(...left) :res.push(...right)
return res
}
let arr = erfen(nums)
快排
复杂度:O(nlogn),最坏O(n2)
function quickSort(nums, left, right) {
if (left >= right) return
let l = left,r = right,base = l
while (l < r) {
while (nums[r] >= nums[base] && l < r) r--
while (nums[l] <= nums[base] && l < r) l++
[nums[r],nums[l]] = [nums[l],nums[r]] // 循环着左右互换,保证基准值左边的都小于右边的
}
[nums[l],nums[base]] = [nums[base],nums[l]] // 基准值与最后一个小于基准值的交换,到了中间
quickSort(nums, left, l - 1)
quickSort(nums, l + 1, right)
}
动态规划
「base case」:一般确立了状态转移方程,才能知道对应的base case
「最优子结构」:子问题间必须互相独立,不会影响牵制
「重叠子问题」:动态规划之所以比暴力算法快,是因为动态规划技巧消除了重叠子问题
如何发现重叠子问题? 看是否可能出现重复的「状态」。对于递归函数来说,函数参数中会变的参数就是「状态」
backtrack(i + 1, rest - nums[i]);
backtrack(i + 1, rest + nums[i]);
这里的 rest 和 i 是可以变的,假设 nums[i] === 0,那么两次遍历其实是干了同一件事,所以我们需要把状态 (i, rest)
存起来,也就是递归终止时返回 0 或者 1,父层把子层的结果相加然后存储。
「状态转移方程」
解题套路:
看有多少个可变的状态,一般就是几维
1、第一种思路模板是一个一维的 dp 数组
let len = array.length;
let dp = new Array(arr.length).fill(xxxx); // data base看情况定
for (let i = 1; i < len ; i++) {
for (let j = 0; j < i; j++) {
dp[i] = 最值(dp[i], dp[j] + ...)
}
}
典型:「最长递增子序列」——在子数组 array[0…i] 中(更清晰一点叫以 i 结尾的子数组中),我们要求的子序列(最长递增子序列)的长度是 dp[i]
2、第二种思路模板是一个二维的 dp 数组:
注意点:字符对比时一般要 -1 的,因为如果 dp 数组填充了空字符串的位置,构造的空间是 len+1,所以遍历时 -1 才是我们当前两个字符串的字符位置
let len = array.length;
let dp = new Array(arr.length).fill(0).map(item=> new Array(arr.length).fill(xxxx)); // data base看情况定
for (let i = 1; i < len ; i++) {
for (let j = 0; j < i; j++) {
if (arr[i] === arr[j])
dp[i][j] = dp[i][j] + ... // 具体看情况
else
dp[i][j] = 最值(...)
}
}
一般有两个状态就是这种,这种思路运用相对更多一些,尤其是涉及两个字符串/数组的子序列
2.1 涉及两个字符串/数组时(比如最长公共子序列),dp 定义
在子数组 arr1[0…i] 和子数组 arr2[0…j] 中,我们要求的子序列(最长公共子序列)长度为 dp[i][j]
2.2 只涉及一个字符串/数组时(比如最长回文子序列),dp 定义
在子数组 array[i…j] 中,我们要求的子序列(最长回文子序列)的长度为 dp[i][j]。
一般来说,处理两个字符串的动态规划问题,都是建立 DP table。
为什么呢,因为易于找出状态转移的关系,比如编辑距离的 DP table 如下图
判断回文
暴力法,注意必须从0开始。我会犯的错是在第三个循环里使用当前的循环变量开始判断回文,这样是不对的。因为我们每次找的子串是单独的,所以必须从0开始
function find(str){
for(let a=0;a<str.length;a++){
if(str[a] !== str[str.length-1-a]){
return false
}
}
return true
}
双指针法:有两种base case,单个字符自己可以作为开始的子串,单个字符和它的下一个一起可以做子串。find 函数就判断他俩是否相等,然后 start 往左,end 往右从中心往两边散开
var countSubstrings = function(s) {
let count = 0
for(let i=0;i<s.length;i++){
let a = find(i,i,s)
let b = find(i,i+1,s)
count+= a+b
}
return count
function find(start,end,str){
let count = 0
while(str[start] === str[end] && start>=0 && end<str.length){
count++
start --
end ++
}
return count
}
};
链表相关
反转 [start,end) 之间的链表
function reverse(start,end){
let cur = start, pre = end
while(cur!==end){
let next = cur.next
cur.next = pre
pre = cur
cur = next
}
return pre
}
图
1、遍历有向无权图(有权就数组多push存一个权值),并拓扑排序
var findOrder = function(num, arr) {
let map = new Array(num).fill(0).map(item=>new Array()) // 构建邻接表
// 把对应关系放入邻接表,对应关系的顺序决定了拓扑排序是否需要反转
for(let item of arr){
let zero = item[0]
let one = item[1]
map[zero].push(one)
}
let hasCycle = false // 判断有无环
let visited = new Array(num).fill(false) // 存储走过的点
let path = new Array(num).fill(false) // 存储当前路径数组
let res = [] // 存储拓扑排序结果
// 深度遍历
function dfs(i){
if(path[i]){
return hasCycle = true
}
if(visited[i]){
return
}
path[i] = true
visited[i] = true
for(let item of map[i]){
dfs(item)
}
path[i] = false // 类似回溯,复原当前路径
res.push(i) // 拓扑排序只需要在这里后序遍历这里记录经过的点
}
// 所有的点都可以作为起始点
for(let i=0;i<num;i++){
dfs(i)
}
if(hasCycle){
return []
}
return res
};
2、二分图
定义:一个图中任何一条边两端的节点颜色可被涂成不一样
var isBipartite = function(graph) {
let vis = new Array(graph.length).fill(false)
let color = new Array(graph.length).fill(false)
let ok = true
// 每一个节点都可以作为开始节点
for(let i=0;i<graph.length;i++){
if(!vis[i]){
dfs(i)
}
}
return ok
function dfs(i){
if(!ok) return
vis[i] = true // vis 在下面不需要恢复
for(let item of graph[i]){
// 如果相邻节点没有被访问过,就把它的颜色与当前节点取反,再递归相邻节点
if(!vis[item]){
color[item] = !color[i]
dfs(item)
}else {
// 相邻节点访问过了,就判断颜色是否相等,相等就不可能是二分图
if(color[item] === color[i]){
return ok = false
}
}
}
}
};
3、构造按字典序排序的邻接表套路
const map = {};
for (const ticket of tickets) { // 建表
const [from, to] = ticket;
if (!map[from]) {
map[from] = [];
}
map[from].push(to);
}
for (const city in map) {
map[city].sort();
}
4、标准的欧拉通路(一笔画)
const res = [];
// 构造按字典序排序的邻接表套路,上面第三点
const map = {};
for (const ticket of tickets) { // 建表
const [from, to] = ticket;
if (!map[from]) {
map[from] = [];
}
map[from].push(to);
}
for (const city in map) {
map[city].sort();
}
// 套路结束
const dfs = (node) => { // 当前城市
const nextNodes = map[node]; // 当前城市的邻接城市
while (nextNodes && nextNodes.length) { // 遍历,一次迭代设置一个递归分支
const next = nextNodes.shift(); // 获取并移除第一项,字母小的城市
dfs(next); // 向下递归
}
// 当前城市没有下一站,就把他加到res里,递归开始向上返回,选过的城市一个个推入res
res.unshift(node);
};
dfs('JFK'); // 起点城市
return res;
LRU(最近最少使用) 缓存淘汰算法
本质是利用哈希和双向链表实现,借助链表的有序性使得链表元素维持插入顺序,同时借助哈希映射的快速访问能力使得我们可以在 O(1) 时间访问链表的任意元素。
Map 会把老的数据放前面,Map本质是哈希表,表中存储的是指向数据的指针,如果发生哈希碰撞,就会使用一条双向链表存储两条数据,最遭的情况是都碰撞退化成一条链表,查询插入删除就退化到O(n)
var LRUCache = function(capacity) {
this.capacity = capacity
this.map = new Map()
};
LRUCache.prototype.get = function(key) {
if(this.map.has(key)){ // 拿值的时候,先保存这个值,再删除并重新添加这个值
const temp = this.map.get(key)
this.map.delete(key)
this.map.set(key,temp)
return temp
}else {
return -1
}
};
LRUCache.prototype.put = function(key, value) {
if(this.map.has(key)) this.map.delete(key) // 已经有了就先删掉再添加
this.map.set(key,value)
if(this.map.size > this.capacity){ // 超过容量了就用迭代器把最老的删掉
this.map.delete(this.map.keys().next().value)
}
};
自己构造双向链表+对象
注意点:1、链表需要有删除尾部、添加到头部、删除某一个节点、更新使用的节点到头部的方法
2、LRU需要查询元素get、添加元素put(添加到哈希+链表)、删除(哈希+链表都删除)
3、每次都先去哈希找节点,在把节点传给链表的函数处理就行,很简单
4、对象中如果没有,返回的是 undefined 而不是 null
class ListNode {
constructor(key,value){
this.key = key
this.value = value
this.next = null
this.prev = null
}
}
class LRUCache{
constructor(capacity){
this.capacity = capacity
this.hashTable = {}
this.count = 0
this.dummyHead = new ListNode()
this.dummyTail = new ListNode()
this.dummyHead.next = this.dummyTail
this.dummyTail.prev = this.dummyHead
}
get(key){
let node = this.hashTable[key]
if(!node) return -1
this.moveToHead(node)
return node.value
}
put(key,value){
let node = this.hashTable[key]
if(!node){
let newNode = new ListNode(key,value)
this.hashTable[key] = newNode
this.addToHead(newNode)
this.count++
if(this.count > this.capacity){
this.delete()
}
}else {
node.value = value
this.moveToHead(node)
}
}
delete(){
delete this.hashTable[this.dummyTail.prev.key] // 删掉哈希表
this.removeFromList(this.dummyTail.prev) // 删掉链表
this.count-- // 减少数量
}
// 链表的添加
addToHead(node){
node.prev = this.dummyHead
node.next = this.dummyHead.next
this.dummyHead.next.prev = node
this.dummyHead.next = node
}
// 触发get之后链表移动触发的节点
moveToHead(node){
this.removeFromList(node)
this.addToHead(node)
}
// 删除链表的某一个节点
removeFromList(node){
node.prev.next = node.next
node.next.prev = node.prev
}
}
LFU(最不经常使用) 缓存淘汰算法
原理:2个哈希表+双向链表。节点哈希表中保存节点,方便查找。链表哈希表存储每个频率对应的双向链表,链表中存放出现这个频率的节点,这样方便添加和删除。还需要定义一个最少频率,
// 定义节点,多了频率
class Node{
constructor(key,value){
this.key = key
this.value = value
this.prev = null
this.next = null
this.freq = 1
}
}
// 双向链表,添加节点和删除节点和 LRU 是一样的,对比 LRU 没有移到头部这个操作,而是移动节点到另一条链表
class doublyLinkedList {
constructor(){
this.head = new Node()
this.tail = new Node()
this.head.next = this.tail
this.tail.prev = this.head
}
removeNode(node){
node.prev.next = node.next
node.next.prev = node.prev
}
addNode(node){
node.next = this.head.next
node.prev = this.head
this.head.next.prev = node
this.head.next = node
}
}
// LFU 类:查找、添加、增加某个节点的频率并移动到另一条链表
class LFUCache {
constructor(capacity){
this.capacity = capacity
this.size = 0
this.minFreq = 0 // 最小使用频率
this.cacheMap = new Map() // 找节点用
this.freqMap = new Map() // 记录频率
}
get(key){
if(!this.cacheMap.has(key)) return -1
const node = this.cacheMap.get(key)
this.addFreq(node)
return node.value
}
put(key,value){
if(this.capacity === 0) return // 不能少
const node = this.cacheMap.get(key)
if(node){
node.value = value
this.addFreq(node)
}else {
// 容量已经用完,删除最小频率链表中最后一个节点
if(this.capacity === this.size){
const minFreqLinkedList = this.freqMap.get(this.minFreq)
this.cacheMap.delete(minFreqLinkedList.tail.prev.key) // 尾部的节点是旧的
minFreqLinkedList.removeNode(minFreqLinkedList.tail.prev)
this.size --
}
const newNode = new Node(key,value)
this.cacheMap.set(key,newNode)
let linkedList = this.freqMap.get(1)
if(!linkedList){
linkedList = new doublyLinkedList()
this.freqMap.set(1,linkedList)
}
linkedList.addNode(newNode)
this.size++
this.minFreq = 1
}
}
addFreq(node){
let freq = node.freq
let linkedList = this.freqMap.get(freq)
linkedList.removeNode(node)
// 当前节点频率在最小的链表上并且删除后链表空了,最小频率应增加
if(freq === this.minFreq && linkedList.head.next === linkedList.tail){
this.minFreq = freq+1
}
node.freq++
linkedList = this.freqMap.get(node.freq)
if(!linkedList){
linkedList = new doublyLinkedList()
this.freqMap.set(node.freq,linkedList)
}
linkedList.addNode(node)
}
}
前缀树
原理:每一个节点代表一个对象。对象中的值也是很多个对象,表示下一个可以到达的字符有哪些
注意点:返回值时记得二次取反,以免属性为 undefined 或者节点为 null
class Trie {
constructor(){
this.children = {}
}
insert(word){
let nodes = this.children
// 循环给每个字符创建一个字符对象
for(const char of word){
if(!nodes[char]) nodes[char] = {}
nodes = nodes[char]
}
nodes.isEnd = true // 最后一个字符对象有一个 isEnd 属性
}
// 搜索某个前缀
searchPrefix(prefix){
let nodes = this.children
for(let char of prefix){
// 若没有当前字母的属性,表明树上没这个前缀,返回false
if(!nodes[char]) return false
nodes = nodes[char]
}
return nodes
}
// 当需要匹配通配符时,dfs搜索某个前缀,递归搜索每一个存在的子树。
searchPrefixAll(word){
function dfs(index, node){
if (index === word.length) {
return node.isEnd;
}
const ch = word[index];
if (ch !== '.') {
const child = node[ch]
if (child && dfs(index + 1, child)) {
return true;
}
} else {
for (const key in node) {
if (key && dfs(index + 1, node[key])) {
return true;
}
}
}
return false;
}
return dfs(0, this.children);
}
// 搜索某个单词
search(word){
const nodes = this.searchPrefix(word)
// 树上有这个单词的条件:存在这个单词的前缀,且标记了end结束
return !!nodes && !!nodes.isEnd
}
// 搜索某个前缀是否存在于树
startsWith(word){
return !!this.searchPrefix(word)
}
// 获取拥有某个前缀的单词所有权值,前提是插入时isEnd改为统计val值
getAllSum(node){
let base = 97,sum = 0
for(let i=0;i<26;i++){
if(node[String.fromCharCode(base+i)]){
sum+=this.getAllSum(node[String.fromCharCode(base+i)])
}
}
return sum+=node.val || 0
}
}
区间和问题
区间和问题通常分为以下几种:
- 数组不变,求区间和:「前缀和」、「树状数组」、「线段树」
- 多次修改某个数(单点),求区间和:「树状数组」、「线段树」
- 多次修改某个区间,输出最终结果:「差分」
- 多次修改某个区间,求区间和:「线段树」、「树状数组」(看修改区间范围大小)
- 多次将某个区间变成同一个数,求区间和:「线段树」、「树状数组」(看修改区间范围大小)
6
差分数组
第一步: 求出 diff 差分数组
总结:对于改变区间 [i, j] 的值,只需要进行如下操作 diff[i] += val; diff[j + 1] -= val
差分数组的前缀和就是原数组 这一性质
const n = s.length;
const diff = new Array(n).fill(0);
for (let i = 0; i < arr.length; ++i) {
const start = arr[i][0];
const end = arr[i][1];
const diffVal = 1 // 假设每次变化值是1
diff[start] += diffVal ; // 开始
if (end + 1 < n) {
diff[end + 1] -= diffVal ; // 注意这里是减号!!!!!!
}
}
第二步: 根据 diff 差分数组,还原出原数组中每项值总的变化值
let preSum = [diff[0]];
for (let i = 1; i < n; ++i) {
preSum[i] = preSum[i - 1] + diff[i];
}
第三步:在原数组中应用 preSum 的值即可,这样只用了2次遍历即可统计变化
树状数组
因为线段树很长很费劲,所以总结优先级:
- 简单求区间和,用「前缀和」,适用于更新操作不多的情况
- 多次将某个区间变成同一个数,用「线段树」(第四类不得不写的时候才写)
- 其他情况,用「树状数组」
a1也就是c1=[a1,a1] c3=[a3,a3],把一个大区间,分成了若干个小区间
举例:
等式左侧的数 x 看做是区间 [1, x],等式右边看做从 x 开始每个区间的长度
[1, 11] = [11, 11] + [10, 9] + [8, 1]
[11, 11] 、[10, 9]、[8, 1] 长度分别是 1、2、8
F[1,11] = V[11] + V[10] + V[8]
看黑色图可以知道二进制最右边一个 1 的幂次值,就是数组长度,所以需要 lowbit 函数去找区间长度。如果 len = i & -i ,那么 V[i] = F[i,i-1,i-2, … i-len+1]
注意:因为数组的下标是从 0 开始的,上边的区间范围是从 1 开始的(2的幂次最少为1),所以我们在原数组开头补一个 0 ,这样区间就是从 1 开始了
class Bit {
constructor(arr) {
this.bit= new Array(arr.length + 1).fill(0);
for (let i = 0; i < arr.length; i++) {
this.update(i + 1, arr[i]);
}
}
// 找出二进制最右边一个1所在位置代表的大小,1010 返回 4 ,1011返回1。也就是i这个区间的长度
lowbit(x) {
return x & -x;
}
// 序号从小区间往大区间更新区间和(如下解释),注意这里的val应该是更新前后的差值
update(index, value) {
for (let i = index; i < this.bit.length; i += this.lowbit(i)) {
this.bit[i] += value;
}
}
// 序号从大到小求区间和
sum(index) {
let ans = 0;
// 例如11,先求出 11&-11的子区间和长度1,再11-1求出10&-10的长度2的区间和,再10-2求出8&-8的长度8
// 把这些区间的和相加就是index===11时候的区间和了
for (let i = index; i > 0; i -= this.lowbit(i)) {
ans += this.bit[i];
}
return ans;
}
}
更新值需要看图,当我们改变序号3的值时,由于序号4~1的大区间使用了序号3的值,所以需更新,8 ~1的大区间又使用的区间4 ~1值,所以也需要更新,而通过换算二进制可以看到每次都是加上当前数最右边的 1 表示的幂次值(也就是lowbit)可以得到上层区间,所以函数循环的 i 就可以知道如何增加了
线段树
动态开点:此模板表示为 区间和 和 加减 更新操作
「区间和」:更新节点值的时候『需要✖️左右孩子区间叶子节点的数量 (注意是叶子节点的数量)』
「区间最值」:更新节点值的时候『不需要✖️左右孩子区间叶子节点的数量 (注意是叶子节点的数量)』,并且 pushup 是求左右节点的最大值
对区间进行覆盖:下推懒惰标记的时候『不需要累加』
对区间进行加减:下推懒惰标记的时候『需要累加』
区间和查询:就是现在的模板中的,+= 符号
区间最大值查询,求左右两节点的最大值返回即可
const N = 1e9
class SegNode {
constructor() {
this.left = this.right = null // 左右孩子节点
this.val = this.add = 0 // 懒惰标记 add,当前节点值val
}
}
class SegmentTree {
constructor() {
this.root = new SegNode()
}
// 更新
update(node, start, end, l, r, val) {
// 找到满足要求的区间
if (l <= start && end <= r) {
// 区间节点加上更新值
node.val += val * (end - start + 1) //
node.add += val
return
}
const mid = start + end >> 1
// 下推标记
// mid - start + 1:表示左孩子区间叶子节点数量
// end - mid:表示右孩子区间叶子节点数量
this.pushDown(node,mid - start + 1, end - mid)
// [start, mid] 和 [l, r] 可能有交集,遍历左孩子区间
if (l <= mid) { this.update(node.left, start, mid, l, r, val) }
// [mid + 1, end] 和 [l, r] 可能有交集,遍历右孩.子区间
if (r > mid) { this.update(node.right, mid + 1, end, l, r, val) }
// 向上更新
this.pushUp(node)
}
// 查询
query(node, start, end, l, r) {
if (l <= start && end <= r) { return node.val }
let ans = 0, mid = start + end >> 1
this.pushDown(node, mid - start + 1, end - mid)
if (l <= mid) { ans += this.query(node.left, start, mid, l, r) }
if (r > mid) { ans += this.query(node.right, mid + 1, end, l, r) }
return ans
}
pushUp(node) {
node.val = node.left.val + node.right.val;
}
pushDown(node, leftNum, rightNum) {
// 动态开点
if (node.left === null) {node.left = new SegNode()}
if (node.right === null) {node.right = new SegNode()}
if (node.add === 0) {return}
// 注意:当前节点加上 标记值✖该子树所有叶子节点的数量
node.left.val += node.add * leftNum
node.right.val += node.add * rightNum
// 对区间进行「加减」的更新操作,下推懒惰标记时需要累加起来,不能直接覆盖
node.left.add += node.add
node.right.add += node.add
// 取消当前节点标记,已经用过了
node.add = 0
}
}
位运算
去除最后一个1
x & (x−1)
判断是不是2的整数次幂
x & (x−1) === 0
lowbit 算法
主要用于树状数组中,本质是可以快速求出二进制中最后一位1所代表的值是多少
x & -x
从这个也可以计算有多少个1,每次找到最后一个1的值,然后减掉就变成0了
let count = 0
while(x){
x-=x&-x;
count++;
}
常见技巧
1) 可以通过循环32位右移,来让每一位进行一些运算,比如下面判断哪一位是1,数组保留离右边最远的1的位置
for(let j=0;j<32;j++){
if((nums[i] >> j) & 1 === 1 ){
dp[j] = i
}
}
背包问题
分类解题模板
背包问题大体的解题模板是两层循环,分别遍历物品nums和背包容量target,然后写转移方程
根据背包的分类我们确定物品和容量遍历的先后顺序
根据问题的分类我们确定状态转移方程的写法
首先是背包分类的模板:
1、0/1背包:外循环nums,内循环target,target倒序且target>=nums[i];
2、完全背包:外循环nums,内循环target,target正序且target>=nums[i];
3、组合背包:外循环target,内循环nums,target正序且target>=nums[i];
4、分组背包:这个比较特殊,需要三重循环:外循环背包bags,内部两层循环根据题目的要求转化为1,2,3三种背包类型的模板
然后是问题分类的模板:.
69
1、最值问题: dp[i] = max/min(dp[i], dp[i-nums]+1)或dp[i] = max/min(dp[i], dp[i-num]+num);
2、存在问题(bool):dp[i]=dp[i]||dp[i-num];
3、组合问题:dp[i]+=dp[i-num];
完全背包中:
如果求组合数就是外层for遍历物品,内层for遍历背包。都是正序
如果求排列数就是外层for遍历背包,内层for遍历物品。都是正序
求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[k]]; i是背包,k是物品
最长xxxxx
42是41的简单版,只需要一个循环即可
43是44的简单版,因为数组连续,所以两个数组不相等的时候不需要管,如果是序列就要管,因为需要从dp[i-1][j],dp[i][j-1]转移过来配合后面的值
单调栈
栈底到栈顶:
递增栈找第一个比当前小的
递减栈找第一个比当前大的
为什么要递增栈呢,因为这样可以保证栈顶元素是栈中最大的,后面的元素如果比栈顶大就一定栈顶是第一个小的,比栈顶小的话,就弹栈同时进行比较,这样弹出去的肯定不会是后面的值的第一个最小值
for(let i=0;i<heights.length;i++){
// 为0直接入栈
if(stackLeft.length===0){
stackLeft.push(i)
continue
}
//核心,找左右两边第一个比当前小的,那么[left+1,right-1]的元素都是大于等于当前高度的,都是可使用的。递增栈(栈底到栈顶)
// 比栈顶大,放进去
if(heights[i] > heights[stackLeft[stackLeft.length-1]]){
left[i] = stackLeft[stackLeft.length-1]
stackLeft.push(i)
}else {
// 弹栈的时候循环
while(stackLeft.length && heights[i] <= heights[stackLeft[stackLeft.length-1]]){
if(stackLeft.length){
left[i] = stackLeft[stackLeft.length-1]
}
stackLeft.pop()
}
// 弹完了当前元素入栈
stackLeft.push(i)
}
}
最大公约数gcd
function gcd(a, b) {
if (!b) {
return a;
}
return gcd(b, a % b);
}
组合排列
used[i - 1] == true,说明同⼀树⽀nums[i - 1]使⽤过
used[i - 1] == false,说明同⼀树层nums[i - 1]使⽤过
// 例子:如果同⼀树层nums[i - 1]使⽤过则直接跳过
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
continue;
}
代码输出
async function async1() {
console.log(1);
await async2(); // async2立即执行,1之后立刻打印3
console.log(2);
}
async function async2() {
console.log(3);
}
async1();
setTimeout(() => console.log(4), 0);
new Promise(resolve => {
resolve();
console.log(5);
}).then(() => {
console.log(6);
Promise.resolve().then(()=>{
console.log(7);
});
});
console.log(8);
// 1 3 5 8 2 6 7 4