常见算法梳理

循序渐进,不急不躁 / 2023-05-09 / 原文

前言: 

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、选择列表:也就是你当前可以做的选择。[ ]  
  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- 回溯树的深度? 宽度? 

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 思否           

 

 

 

 

 

 

 




七:单调栈(数据结构)







八:前缀和数组、差分数组(缓存)





九:并查集(树)
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、自己买的书:算法导论、图解算法