常见算法梳理
前言:
1- 算法的本质就是合理的穷举:无遗漏无冗余; 然后考虑剪枝、空间换时间、空间压缩
2- 回溯算法是在遍历「树枝」,DFS 算法是在遍历「节点」, BFS是从一个点发散,DFS是一个方向深度走下去
一:二分搜索
function binarySearch(arr, target) { let left = 0; let right = arr.length - 1 // 注意 while (left <= right) { let mid = Math.floor((left + right) / 2) // parseInt if (arr[mid] === target) { // 逻辑 return mid } else if(arr[mid] > target) { // 逻辑 left = mid + 1 // 注意 } else if (arr[mid] < target) { // 逻辑 right = mid - 1 // 注意 } } }
1- 找上下边界!边界确定,是否包含边界 2- 然后取pivot 3- 二分逻辑: 大了咋办 小了咋办
4- 细节:
a- 到底要给 mid 加一还是减一: 看区间开闭
b- while 里到底用 大于等于 还是 小于
二:滑动窗口 快慢指针
1- 关联: 节流算法: 固定窗口、滑动窗口、漏桶、令牌桶; 协议:tcp滑动窗口、慢启动
2- 维护一个窗口,窗口内元素满足一定的条件, 通过移动窗口的左右边界来得到满足条件的子序列、子数组、字串
3- 扩张窗口:寻找可行解, 收缩窗口:优化可行解
4- 注意初始窗口
其他双指针技巧:
1- 碰撞指针,相向而行
2- 中心发散
三:贪心
1- np完全问题、近似解,尝试能否通过局部最优,达到整体最优: 举不出明显反例
2- 问题:分饼干、
四:回溯
1- 场景:子集、排列、组合
1.解决一个回溯问题,实际上就是一个决策树的遍历过程,站在回溯树的一个节点上,你只需要思考 3 个问题:
1、路径:也就是已经做出的选择。 path: [ ]
2、选择列表:也就是你当前可以做的选择。[ ] arr
3、结束条件:也就是到达决策树底层,无法再做选择的条件。 收集结果 result.push(path)
4、递归函数的参数:
a:当前数组
b:startIndex: 选择列表的startIndex,前面的选择过了
c:当前路径列表:path []
d:结果集: result []
e:备忘录: 比如元素哪些使用,哪些没有用
result = [] def backtrack(路径, 选择列表): if 满足结束条件: result.add(路径) return for 选择 in 选择列表: 做选择 // -----------------> 选1, 选2, 选3, 选。。。n; 然后递归
backtrack(路径, 选择列表)
撤销选择 ============================================================================================================================== 其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」 回溯算法的「决策树」;每个节点上都在做决策。
1- 回溯算法, 通过递归来控制有多少层for循环
1- 回溯算法是在遍历「树枝」, DFS 算法是在遍历「节点」
1- 回溯树的深度? 宽度?
补充:
1- dfs(total, arr, i, sum + arr[i], path, res); 这种就不需要回溯了, 因为sum没有改变,不会影响后续的循环和递归, 如果sum放到dfs函数调用外边就需要回溯了
2- 排列不需要使用startIndex, 因为1,2和2,1不同, 排列讲究顺序! 所以排列只要求满足:元素不重复选取; if (used[i] == true) continue;
3- 如果要对树层中前一位去重,就用used[i - 1] == false, 如果要对树枝前一位去重用used[i - 1] == true。 对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高!、
4- 去重必须先排序, 让相同的元素挨在一起; 树层去重: 解决集合有重复元素时,要保证组合、排列不会重复; if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false)
5- 如何区分我用的哪一个元素呢, 如果两个元素值都一样,但是索引不一样, 这时候只能用used数组来做标记
1- 排列问题: 元素不能重复使用, 元素使用次序无关
2- 组合问题: 元素不能重复使用, 元素使用次序有关
3- 回溯算法: 递归模拟for循环嵌套, 递归里嵌套for循环,for循环再递归
2- 尝试画决策树:多叉树的遍历/多叉树的遍历: 每一个结点就是一个for循环,从当前选择列表挨个做选择,然后回溯重新选择, n个递归函数向下去递归, 直到path.length === arr.length 叶子节点
3- 树枝去重 树层去重
4- 迭代与递归写法:
// 迭代 private void traversal(int[] nums) { for (int i = 0; i < nums.length; i++) { System.out.println(nums[i]); } } // 递归三部曲: 函数参数、终止条件、单层逻辑 private void traversal(int[] nums, int index) { // 回溯startIndex的理解 if (index == nums.length) return ; //出口 // 处理当前元素 System.out.println(nums[i]); // 递归处理 index + 1 后的元素 traversal(nums, index + 1); // ( 数组,startIndex ) } traversal(nums, 0);
5- 带for循环的递归
6- 全排列
7- N皇后
8- 等和子数组
五:动态规划
1- 应用场景
a:标准的dp:求最值
b:重复子问题
c:最优子结构
2- 深度手动阀手动阀,画递归树,来找出状态转移关系
3- 推导dp公式: 数学归纳法
4- 空间压缩思路: 二维数组投影到一维数组
一: 自顶向下递归的动态规划 def dp(状态1, 状态2, ...): for 选择 in 所有可能的选择: # 此时的状态已经因为做了选择而改变 result = 求最值(result, dp(状态1, 状态2, ...)) return result 二: 自底向上迭代的动态规划 # 初始化 base case dp[0][0][...] = base case # 进行状态转移 for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ... dp[状态1][状态2][...] = 求最值(选择1,选择2...)
经典题目:
1- 背包问题: 01背包 完全背包
2- 斐波那契数列
// O(2^n),指数级别 var fib = function(N) { if (N === 1 || N === 2) return 1; return fib(N - 1) + fib(N - 2); }; =============================================================================== // 一: 递归解法 带memo的递归: 时间复杂度是 O(n) var fib = function(N) { // 备忘录全初始化为 0 let memo = new Array(N + 1).fill(0); // 为啥N+1??? // 进行带备忘录的递归 return dp(memo, N); }; // 带着备忘录进行递归 var dp = function(memo, n) { // 递归三部曲: 递归函数参数, 递归出口, 单层逻辑 // base case if (n == 0 || n == 1) return n; // 已经计算过,不用再计算了 if (memo[n] != 0) return memo[n]; memo[n] = dp(memo, n - 1) + dp(memo, n - 2); return memo[n]; };
// 二: dp 数组的迭代解法 var fib = function(N) { if (N === 0) return 0; let dp = new Array(N + 1).fill(0); // 下边循环从2开始, 所以这里N+1? 因为dp[0]不用, 所以保证索引从1开始? // base case dp[0] = 0; dp[1] = 1; // 状态转移 for (let i = 2; i <= N; i++) { dp[i] = dp[i - 1] + dp[i - 2]; // 循环过程中: 填充dp数组, 后边的循环计算依赖前面的计算结果, 所以从前往后遍历 } return dp[N]; };
// 三:空间压缩 空间压缩: 空间复杂度降为 O(1) var fib = function(n) { if (n === 0 || n === 1) { // base case return n; } // 分别代表 dp[i - 1] 和 dp[i - 2] 上面dp[i]: 靠dp[i-1] dp[i-2]计算出来 let dp_i_1 = 1, dp_i_2 = 0; for (let i = 2; i <= n; i++) { // dp[i] = dp[i - 1] + dp[i - 2]; let dp_i = dp_i_1 + dp_i_2; // 滚动更新 dp_i_2 = dp_i_1; dp_i_1 = dp_i; } return dp_i_1; };
3-
思考:
1- dp与贪心: 无后效性
2- dp与回溯:
六: 优先队列
1- 保证一个元素的插入后数组是有序的, 排序方法:每个元素的优先级来排序! 大顶堆、小顶堆、满二叉树、堆序性、上滤swim、下滤sink, 取优先级最高元素:根节点, 时间复杂度O(1)
2- js简单实现
class PriorityQueue { constructor(compare) { this.queue = []; // 一个数组 this.compare = compare; // 一个排序方法 } offer(element) { // 添加元素: add(E e)和offer(E e)的语义相同,都是向优先队列中插入元素,只是Queue接口规定二者对插入失败时的处理不同,前者在插入失败时抛出异常,后则则会返回false。对于PriorityQueue这两个方法其实没什么差别。 this.queue.push(element); this.queue.sort(this.compare); } poll() { // remove()和poll()方法的语义也完全相同,都是获取并删除队首元素,区别是当方法失败时前者抛出异常,后者返回null。由于删除操作会改变队列的结构,为维护小顶堆的性质,需要进行必要的调整。 return this.queue.shift(); } peek() { // element()和peek()的语义完全相同,都是获取但不删除队首元素,也就是队列中权值最小的那个元素,二者唯一的区别是当方法失败时前者抛出异常,后者返回null return this.queue[0]; } isEmpty() { return this.queue.length === 0; } }
参考: How to use priority queue in javascript (hashnode.dev) javascript - 面试官:请使用JS实现一个优先队列 - 个人文章 - SegmentFault 思否
七:单调栈、单调队列(数据结构)
八:前缀和数组、差分数组(缓存)
前缀和:频繁计算一个区域的元素之和。 所以先算出来做一个缓存,, 以后再用直接O(1)复杂度
// 如何求一个数组的【i,j】区间和: 一般思路: 每次计算时间复杂度都是O(n)
let NumArray1 = function(nums) { this.nums = nums;
} NumArray1.prototype.sumRange = function(left, right) { let res = 0; for (let i = left; i <= right; i++) { res += this.nums[i]; } return res;
}
// 前缀和思路: 第一次计算之后时间复杂度就是O(1)
let NumArray = function(arr) {
this.preSum = new Array(arr.length + 1) // 索引+1
this.preSum[0] = 0
for(let i=1; i<arr.length; i++) {
this.preSum[i] = this.preSum[i-1] + arr[i]
}
}
// [left ,right] 区间累加和
NumArray.prototype.sumRange = function(left, right) {
return this.preSum[right+1] - this.preSum[left]
}
应用
1- 求数组左右乘积
eg:班上有若干同学,每个同学有一个期末考试的成绩(满分 100 分),那么请你实现一个 API,输入任意一个分数段,返回有多少同学的成绩在这个分数段内。
let scores = [8, 9, 6, 10, 5, 5] // 6名同学 let count = new Array(scores.length + 1).fill(0) // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] for(let score of scores) { count[score]++ // 落在每个分数上的学生 // [0, 0, 0, 0, 0, 2, 1, 0, 1, 1, 1] -----------》 接下来计算落在[left, right]之间的人有多少 } // 构造前缀和数组: 方便接下来求和 let preSum = new Array(scores.length + 1).fill(0) for(let i=1; i<count.length; i++) { // i 从1开始 1 2 3 4 5 6 7 8 9 10 preSum[i] = preSum[i-1] + count[i] // [0, 0, 0, 0, 0, 2, 3, 3, 4, 5, 6] } function getScoreSpan(left, right) { // [8 , 9] return count[right+1] - count[left] // 6 - 4 }
九:并查集(树)
1- 求解连通分量
十:DFS BFS(树 图)
十一:二叉树
1- 前中后序
2- 满二叉、平衡二叉
3 红黑树
4 B树
十二: 散列表
1- 底层实现:arryList LinkedList: 数组、链表实现方案
2- 散列函数:
3- 填装因子: 元素个数 / 地址总数 0.7
4- 用:dns解析、缓存
十三:其他todo
1- 分治
3- 减治
3- 分支界定
4- 图: 有向图、无向图
a:狄克斯特拉:加权图,权重为正, 权重为负:贝尔曼福德
5- knn算法、贝叶斯分类
6- 回文串:马拉车
7- 字符串:kmp
8- 散列表下:布隆过滤器
9- 数学知识复习: 方程、线代、概率论、离散数学、高数
10- 反向索引
11- LIS:二分解法
学习资源备忘:
1- labuladong官网 刷题插件 b站
2- 程序员卡尔官网、b站
3- leetcode:按类型来
4- 微信收藏、b站收藏
5- 电脑pdf、自己买的书:算法导论、图解算法
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了