1 Star 0 Fork 11

coder_lw / wiki

forked from deepinwiki / wiki 
加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
算法.md 23.93 KB
一键复制 编辑 原始数据 按行查看 历史
htqx 提交于 2023-02-21 07:53 . 修改fib从1开始

算法

前言

算法是一个程序员的内功。算法内功是否扎实,视作专业程序员和业余程序员的分水岭。

算法概述

严格意义来说,算法包含一切解题的思路,但是一般而言,计算机算法有它自己的特点。

  1. 数学算法:数学算法的特点就是高效,但是应对的问题往往比较特殊。比如 3*5 =? 这是一个数学算法。但可能真实的问题并不会给出所有参数,可能是 3*?=? ,或者 ?*? = ?。又比如数学问题会问你排列组合有多少种,但是计算机问题可能就是把所有排列组合一一罗列出来。这种问题对人类来说很繁琐,所以也不是数学的研究重点,但是计算机依赖其高速运行的特点,可以完成这类大规模,又繁琐的问题,而往往程序员需要面对的就是这类问题。
  2. 数学算法在计算机中一般是针对性的分支,比如图形化,还有数学建模等等
  3. 一般而言的计算机算法特点是,给出一个问题空间,该空间具有结构特点,让你在其中高效的找寻答案。空间结构,节点的构型,比如直线,树形,图型,是否有序等。针对不同的结构有不同(效率)的搜索算法。

计算机算法设计要点

全面性思维。你必须把问题想清楚,问题的解域在哪里,不能遗漏某个分支。这个是计算机算法的最基础要求。这里面涉及逻辑学的内容,比如最基本的分支结构,怎样才能定义清晰和全面,这都想不清楚,那其他都是空想。只有全面,才能保证正确,只有正确,算法才有意义。有时候想太多(深入,瞎几把乱想)不是好事,但得想全面,把解包裹起来,是基础。

计算机算法的基础是暴力求解。计算机算法是通过重复大量简单的步骤,求解复杂问题。即使是一时间找不到高效解法的问题,只要能够正确的定义解域和判定条件,也可以一个一个尝试,而求出解。这一定是正确的,只是效率低罢了。

往往有一个相对简单的判断该解是否正确的方法。从而让暴力求解得以进行。所以,这个点也许不起眼,但是往往能帮助你找到解题灵感。

剩余的是提高效率的一切方法:

一、数学公式推导。(除非你是做数学研究的)

二、数学归纳法。有效降低存储的规模,因为它只依赖前件推导后件,从而遍历整个问题空间。也就是我们不需要存储整个问题域,只需要存储前后件。

三、模型概括。很多问题虽然看上去不同,但往往都能规整成相同的模型。积累解题经验,是提高算法能力的不二选择。比如树的搜索,几乎能类比大部分现实中的问题。因为计算机很多东西喜欢组织成树的结构,比如 xml,文件夹 等等。如果不用专门的经过专家总结的树算法,面对这类问题自己乱写,可能就写的就比较复杂,也没有复用的可能。所以为什么要学习数据结构,就是希望用一般性的久经考验的算法思路去解决现实中的问题。一定要培养自己看问题,并总结模型构型的能力。人类的能力是有限的,你可能记不清楚算法的细节,但是看问题的方式,习惯,是可以保证你快速建立解题思路的。

总结:

  1. 解域 + 判断条件 ==遍历=> 解
    1. 指导学科:逻辑学和集合论
  2. 结合数学定理可优化特殊样例
  3. 结合数学归纳法可设计精致小巧的算法
    1. 数列的递推公式
  4. 真正创造算法是很难的,程序员层面只需学会适配已有算法

学习方法

  1. 分解知识点(基础)
  2. 针对性训练
    1. 一个知识点重复多次(五次以上)
    2. 多角度处理同一个课题
    3. 跳出舒适区,针对没掌握的课题进行训练
  3. 获得反馈
    1. 尝试自己做题,没思路直接看答案(目的并非创造,目的是学习)
    2. 看别人的优秀解法
    3. 和别人交流

算法复杂度

判断一个算法是否优秀的标准。

当规模达到 N, 解法复杂度(执行步骤次数或空间占用大小)和规模的关系:

  1. O(1): 常数复杂度。常数系数不考虑,都写做 1
  2. O(log(N)):对数复杂度。
  3. O(N):线性复杂度。
  4. O(N*log(N)):线性对数复杂度。
  5. O(N^2):二次方复杂度
  6. O(2^N):指数复杂度
  7. O(N!):阶乘复杂度

比如当 N = 100:

  1. O(1) = 1
  2. O(log(N)) = 2
  3. O(N) = 100
  4. O(N*log(N)) = 200
  5. O(N^2) = 10000
  6. O(2^N) = 1.26765060023e+30
  7. O(N!) = 9.33262154439e+157

可见后两个复杂度基本算作无效的算法,因为计算机算不出来(除非规模很小的时候)。

N 和执行次数相关的叫时间复杂度(一般看重这个),和空间占用相关的叫空间复杂度。

编程技巧

  1. 自顶向下编程,关注顶层逻辑,然后逐层深入。(适合工程性代码)
  2. 想着怎么把问题规模缩小,而不是扩大。
  3. 空间换时间
  4. 升维
  5. 分而治之
  6. 找到重复子问题,用递归或者递推解决
  7. 逻辑片段命名,归纳,代码表达逻辑要尽可能清晰
  8. 先找到问题相似的数据结构,用其来描述问题事半功倍
  9. 算法认知过程是:先掌握正确写法(机械式),然后通过做题训练才能掌握运用的场合(形成思维模式),所以初期的机械化模仿是有必要的。
  10. 使用类比,把问题转化成经典构造
  11. 选择简单的解决思路,复杂意味更难调试和理解,选择简单的解决方法
  12. 复杂问题简单化就是通过包装,使用别人(或自己)设计好的界面,比如库代码有的
  13. 包装的特征就是可移动,尝试把代码移到其他地方(并给另一个对象使用),检查是否可用

方法

  1. 枚举法(暴力法):枚举所有的可能组合
  2. 双指针夹逼法
  3. 堆栈法:最近相关时
  4. 双向队列:处理滚动窗口问题
  5. 状态树:处理包含重复子问题的类型
  6. 贪心法:当下最优 == 全局最优(或可接受)。即存在最优子结构。
  7. 动态规划(动态递推):具有最优子结构,递推公式。
  8. 二分查找:满足单调性,且有上下界的索引结构。

逻辑和集合

  1. 逻辑和集合对应计算机中的分支语句
  2. 递归和递推对应循环语句

本节摘录一些基本的逻辑和集合概念,更多只能自己找相关资料和做思维训练。

布尔逻辑:

  1. 集合代数
    1. 元素(属于、不属于集合)
    2. 全集
    3. 空集
    4. 子集 / 超集(包含)
    5. 真子集 / 真超集(真包含)
    6. 运算符(交集、并集)
  2. 文氏图
  3. 布尔运算(1 真、0 假)
    1. 运算符(非-、与*、或+)
    2. a+b+c = a+(b+c) : 结合律
    3. a+b = b+a : 交换律
    4. a*(a+b) = a = a+(a*b) : 吸收律
    5. a+(b*c) = (a+b)*(a+c): 分配律
    6. a+(-a) = 1; a*(-a) = 0 : 互补律
    7. a+a = a = a*a : 幂等律
    8. a+0 = a; a*0=0; a+1=1; a*1 = a : 有界律
    9. -(a+b) = -a*-b; -(a*b) = -a+(-b) : 德摩根律
    10. -(-a) = a : 对合律
    11. -1 = 0; -0 = 1 : 非运算
    12. 1*1 = 1; 1*0 = 0; 0*0 = 0 : 与运算
    13. 1+1 = 1; 1+0 = 1; 0+0 = 0 : 或运算
// 对应分支语句
if (a  b) => a * b
else if (c  d) => -(a*b)*(c+d)
else => -(a*b)*-(c+b) = -(a*b)*-c*-d
// 非(a且b)且非c且非d

// 当条件比较多的时候,要完全的罗列所有组合其实比较困难
// 只针对某些条件,而对剩余进行统一处理

// 肯定式(多分支)
switch(x){
    ab=>...
    c,d=>...
    default error;
}
// 否定式(优先拒绝,通常只有一个主流程)
if (-a  -b) return;
if (-c  -d) return;
f(a,b)
f(c)
f(d)
...

递归和递推

递归函数的特点:

  1. 参数(规模)逐渐缩小,所以会收敛。
    1. 如果不缩小,可能是想获得中间迭代的值。
  2. 递归是基于当前参数(规模)的整体处理方案,避免局部的过程性思维。
  3. 当前项和次小项之间有分割方法,难以分割的整体,难以做成递归。
    1. 树这种数据结构很擅长作为递归处理,因为拥有简易的分割方法
    2. 所谓分割即次小项函数结果是否可以作为当前解的一部分
    3. 思维理论参考:数学归纳法
  4. 有特定小规模的平凡解(和次小项无关的解法)作为终止条件。

递归函数构成部分:

  1. 平凡解(退出条件)
  2. 计算任务
  3. 进入下一层(缩减规模)

如果严格按照以上顺序编写,那么就属于尾递归,即这层状态和下一层无关(即所有状态都通过参数传递到下一层),优化的时候可以丢弃当前层的调用栈帧。也可以较为简单的转化为递

推模型,只要建立对应参数的变量即可。

但是很多时候,第三步和第二步是相互混合的。

递推和递归的异同

递推和递归思想上非常接近,都是去归纳重复子问题。不同点:递归是函数方法,递推是循环方法。

递推:

  1. 思维理论参考:数列的递推公式
  2. 递推是正向推导,即从基础的已知平凡解,逐步推导到未知的 n
    1. 递归是逆向推导,即从未知的 n,展开到已知的平凡解
    2. 直觉上,递推易于理解,但大多数情况,递归更易于编程。
      1. 递归容易保存当前环境参数,而递推要自己维护。
      2. 当每一层多于一个分支的时候尤其复杂。
      3. 正向推导和递向推导的难易程度,和问题本身有关。
  3. 逆递归:指用正向推导来写递归代码。
  4. 递推公式(例): a[n] = a[n-1] + a[n-2]
    1. 前驱已知: a[n-1],a[n-2]
      1. 平凡解: a[1,2] = 1,1
    2. 后驱可以算出: a[n-1] + a[n-2] => a[n]

示例

// 斐波那契数列:1,1,2,3,5,8,13,21...
// 递归
// 实践中需要用 Map 保存子项的结果值,避免重复计算。
function fib(n){
    if (n == 1) return 1;
    if (n == 2) return 1;
    return fib(n - 1) + fib(n - 2);
}
// 递推
// n = n-1 + n-2
function fibRecursion(n){
    if (n == 1) return 1;
    if (n == 2) return 1;
    let n1 = 1; // n-1
    let n2 = 1; // n-2
    for (let i = 3; i < n; ++i){
        [n1, n2] = [n1 + n2, n1]; //更新 i 位置的正确值
    }
    return n1 + n2;
}
// 逆递推
// 实际等于手动维护调用栈,不如递归清晰
function fibReverseRecursion(n){
    if (n == 1) return 1;
    if (n == 2) return 1;
    let st = [];
    for (let i = n ; i > 2; --i){
        st.push(i); // 保存当前 n 的状态
    }
    st.push(1);
    st.push(1);
    while(1){
        let n2 = st.pop();
        let n1 = st.pop();
        let n = st.pop();//使用当前 n 的状态
        n = n1 + n2; // 这里 n 的状态完全没有价值,但很多时候是有用的
        st.push(n, n1);
        if (st.length == 2){
            st.pop();
            return st.pop();
        }
    }
}

递归模板代码

这套模板代码包含四部分:

  1. 退出条件
  2. 业务逻辑
  3. 进入下一层
  4. 清理状态

特点:

  1. 每一层的环境变量,包括返回值,都可以通过参数传递,根据编程需要灵活添加需要的参数
// 逆递归
function fibReverse(n){
    // level 当前层
    // max 最大层
    // ...param 其余参数,这里保存:
    // 1. param[0] = 上一层结果
    // 2. param[1] = 上两层结果
    let f = (level, max, ...param)=>{
        if (level > max){
            // 1.退出条件(最终结果)
            return param[0];
        }
        // 2.业务逻辑
        let n1 = param[0] + param[1];
        let n2 = param[0];
        // 3.进入下一层
        return f(level + 1, max, n1, n2);
        // 4.清理状态(这里没有)
    }
    if (n == 0) return 0;
    if (n == 1) return 1;
    return f(2, n, 1, 0);
}

递归优化

记忆化搜索状态树。递归之所以效率比较低,在于相同子状态的重复计算。如果能够把某个子状态的解保存起来,下一次直接使用,那么递归的效率将大大提高。

// 用一个缓存保存子状态的解
function fibmemo(n, memo){
    if (n <= 1)
        return n;
    if (memo.get(n-1) === undefined)
        memo.set(n-1, fibmemo(n - 1, memo));
    if (memo.get(n-2) === undefined)
        memo.set(n-2, fibmemo(n - 2, memo));
    return memo.get(n-1) + memo.get(n-2);
}

// 在我的电脑上
// 普通 fib 递归需要 11 秒
// 经过优化后,只需要 0.017 秒
// 速度提升了 600 倍
fibmemo(45, new Map());

分治回溯模板代码

将问题分成若干子问题,分别求解,然后合并所有解得到最终结果。和普通的递归模板没太大分别,它只是强调分割子问题,然后合并结果这两个明确的步骤。

回溯法,就是在最后一步重置状态恢复到上一层。

function fibDivide(n){
    let f = (problem, ...param)=>{
        // 1. 退出条件
        if (problem == 0) return 0;
        if (problem == 1) return 1;
        // 2. 生成子问题
        let data = (pb)=>{return [pb-1, pb-2]}(problem); // 分割成两个子问题
        let subproblems = (pb, data)=>{return data}(problem, data);
        // 3. 处理子问题
        let sub1 = f(subproblems[0]); // n - 1
        let sub2 = f(subproblems[1]); // n - 2
        // 4. 合并结果
        return (...subs)=>{return subs[0] + subs[1]}(sub1,sub2);
        // 5. 清理状态(这里没有)回溯法就在这里返回上一级状态
    }    

    return f(n);
}

遍历

  1. 前序遍历:根左右
  2. 中序遍历:左根右
  3. 后序遍历:左右根
  4. 深度优先:前三种,典型:根左..右
  5. 广度优先:分层迭代
// 前序模板
function preorder(root){
    if (root){
        console.log(root.val);
        preorder(root.left);
        preorder(root.right);
    }
}
// 中序模板
function inorder(root){
    if (root){
        inorder(root.left);
        console.log(root.val);
        inorder(root.right);
    }
}
// 后序模板
function postorder(root){
    if (root){
        postorder(root.left);
        postorder(root.right);
        console.log(root.val);
    }
}
// 深度优先(实际前三种即是深度优先)
function dfs(node, visited){
    if (node && ! visited.includes(node)){
        visited.push(node);
        console.log(node.val);
        for (let next of node.children()){
            if (! visited.includes(next))
                dfs(next, visited);
        }
    }
}
// 广度优先
function bfs(root, visited){
    let queue = [];
    queue.push(root);
    while (queque.length != 0){
        let node = queue[0];
        queue.shift();
        if (node && ! visited.includes(node)){
            visited.add(node);
            console.log(node.val);
            let nodes = node.children();
            queue.push(...nodes);
        }
    }
}

平衡二叉树

有序的二叉树,叫二叉搜索树,它保证左子树小于根,右子树大于根,即中序遍历是有序的。

二叉搜索树的查询复杂度等于树的深度,但是二叉搜索树很容易退化到链表状态,从而导致性能低下。要解决这个问题,就是使用平衡二叉树。平衡二叉树尽量让左子树和右子树的深度一致(在插入和删除的时候)。

平衡二叉树的种类:

  1. 2-3 tree
  2. AA tree
  3. AVL tree
  4. B-tree
  5. Red-black tree(红黑树)
  6. Scapegoat tree(替罪羊树)
  7. Splay tree(伸展树)
  8. Treap (树堆)
  9. Weight-balanced tree(加权平衡树)

AVL (Adelson-Velsky and Landis Tree):

  1. {-1,0,1} : 维持每个子树的状态是这三种,定义:右子树 - 左子树(深度)
  2. 四种旋转操作,让其状态得到保证
    1. 左旋 : 右右子树-> a->b->c --> a<-b->c :即 b 提升为根,a 下降为左节点
    2. 右旋 : 左左子树-> c<-b<-a --> c<-b->a : 即 b 提升为根,a 下降为右节点
    3. 左右旋 : 左右子树-> b<-a,b->c --> b<-c<-a --> b<-c->a : 即先将c 和 b 交换,让 b 成为 c 的左子树,形成一个左左子树,再右旋。
    4. 右左旋 : 右左子树-> a->b,c<-b --> a->c->b --> a<-c->b : 即先将 c 和 b 交换,让 b 成为 c 的右子树,形成一个右右子树,再左旋。
    5. 左旋时,被提升的子树带有左子树,该左子树要挂载到下降的节点的右子树上。
    6. 右旋时,被提升的子树带有右子树,该右子树要挂载到下降的节点的左子树上。

Red-black tree (红黑树): 近似平衡二叉树,保证高度差小于两倍。

  1. root : black
  2. node : {red, black}
  3. 叶子节点是空节点(nil): black
  4. 相邻的节点不能都是红色
  5. 任意节点到叶子的路径包含相同数目的黑色节点

二分查找

// 二分查找模板代码
function binarySearch(target, array, left, right){
    if (left <= right){ // 上界和下界
        let mid = Math.floor((left + right) / 2); // 有序,取中间下标
        if (array[mid] == target) return mid; // 找到目标
        if (target < array[mid]) // 去除下半部分
            return binarySearch(target, array, left, mid - 1);
        if (target > array[mid]) // 去除上半部分
            return binarySearch(target, array, mid + 1, right);
    }
    return -1;// 没找到
}

let index = binarySearch(4, [1,2,3,4,5], 0, 4);

动态规划

动态规划(即动态递推)是解决具备最优子结构的课题。一个问题,可以最优分割成若干子问题,并且这个问题的解可通过求子问题得出。

抽象点来说,所谓最优子结构是指状态树空间中存在最优的子树,而相对的其他状态可以抛弃。

以 fib 状态树为例,n 分为 n-1 和 n-2 的两个子树,只需要分别计算两个子树,必然能得出 n 的值,所以存在最优子结构。

而且 n-2 实际上是 n-1 作为根的左子树。因此实际可以抛弃 n-2 这个子树(的计算)。

观察:

2 1 0
3 1 1
4 2 1
5 3 2

即,左子树会交换到下一级的右子树,下一级新的左子树是上一级的和。可见我们并不需要重复的计算右子树,只需要套用上一级的左子树即可。

动态规划使用递推,由底向上迭代。以 fib 为例:

  1. 存在最优子结构:可以理解为整个状态树中存在重复计算的部分,这部分可以通过某种构造去精简,这是动态规划算法的主要特征。
  2. 中间状态: a(n1), a(n2) 的两个结果值
  3. 递推公式: a(l) = a(n1) + a(n2),并且,可以从最底端开始计算。即 a(1) + a(0) 已知。这是算法的核心内容,找出递推公式。
// 递推公式:a(l) = a(n1) + a(n2)
// 自底向上
function dp(n){
    let fib = (level, n, n1, n2)=>{
        if (level == n){ // 1. 退出条件
            return n1 + n2;
        }
        return fib(level + 1, n, n1 + n2, n1); // 进入上一层
    }
    if (n < 2) return n;
    return fib(2, n, 1, 0);
}

// 动态规划模板
function dp2(n){
    if (n < 2) return n; // 底层平凡解
    
    // 1. 中间状态
    let n1 = 1;
    let n2 = 0;
    for (let i = 2; i < n; ++i){ // 自底向上
        [n1, n2] = [n1 + n2, n1]; // 2. 递推公式(更新状态)
    }
    return n1 + n2; // n 的解
}

递归转换为一般性递推

一般而言,具有递推公式的中间状态,可以为队列 an,虽然不够精巧,但是它都能满足递推公式需要。因为递推公式,都是关于 an 项之间的关系。

  1. 递推公式: a(n) = a(n-1) + a(n-2)
  2. 中间状态: an
function fibRecursion(n){
    let an = [0,1];
    for (let i = 2; i <= n; ++i){
        an.push(an[i-1] + an[i-2]);
    }
    return an[n];
}

搜索

知识点:

  1. 朴素(暴力)搜索
  2. 优化:不重复,剪枝
  3. 搜索方向: bfs、dfs
  4. 高级搜索: 双向搜索(双向bfs)、启发式搜索(使用优先队列)

双向搜索模板

// 单向 bfs
function bfs(root, visited){
    let queue = [];
    queue.push(root);
    while (queque.length != 0){
        let node = queue[0];
        queue.shift();
        if (node && ! visited.includes(node)){
            visited.add(node);
            console.log(node.val); // 业务逻辑
            let nodes = node.children();
            queue.push(...nodes);
        }
    }
}

// 双向 bfs
function dbfs(root1, root2, visited){
    let queue1 = [root1];
    let queue2 = [root2];
    
    let next = (queue){ // 进去下一层
        for (let i = queue.length; i > 0; --i){
            let node = queue.shift();
            if (visited.includes(node))
                return;
            visited.add(node);
            queue.push(...node.children());
        }
    };

    while (queue1.length != 0 || queue2.length != 0){
        // 选择更小规模的来扩散,因而比单向 bfs 搜索更高效
        if (queue1.length > queue2.length)
            [queue1, queue2] = [queue2, queue1];
        next(queue1);

        if (somethings(queue1, queue2)){ // 业务逻辑
            return true;
        }
    }
}

A*搜索

启发式搜索 A*,使用优先队列的 bfs,而优先队列的核心是估价函数 h(n)。它返回一个大于 0 的实数,数值越大表明优先级越高(也可以反过来,只要排序顺序的方向改变一下,就是等价的)。

  1. h(n) 估价函数满足条件:如果有解,必然是最优解。

因此,在每一步根据它选择路径:

  1. 要么无解(回溯到下一条路径),因此是全面无遗漏的;
  2. 要么有解(不用计算其他路径),因而可以缩减搜索规模。

priority queue 优先队列

优先队列有多种底层实现。一般是堆 heap。

位运算

  1. mod 模
  2. | 或
  3. & 与
  4. ~ 反
  5. ^ 异或

常见数据结构复杂度

基本的一维结构

数据结构 prepend append lookup insert delete
array(数组) O(1) O(1) O(1) O(n) O(n)
linkedlist(链表) O(1) O(1) O(n) O(1) O(1)
skiplist(跳表) O(log(n)) O(log(n)) O(log(n)) O(log(n)) O(log(n))

队列和栈

数据结构 prepend append lookup insert delete
stack(栈) O(1) O(n) O(n) O(1) O(1)
queue(队列) O(n) O(1) O(n) O(1) O(1)
deque(双端队列) O(1) O(1) O(n) O(1) O(1)
priority queue(优先队列) O(1) O(1) O(log(n)) O(1) O(1)

哈希集合

数据结构 prepend append lookup insert delete
hash table(哈希) O(1) O(1) O(1) O(1) O(1)
set(集合) O(1) O(1) O(1) O(1) O(1)
map(字典) O(1) O(1) O(1) O(1) O(1)

二维结构

数据结构 prepend append lookup insert delete
binary tree(二叉树)
Binary search tree(二叉搜索树) O(log(n)) O(log(n)) O(log(n)) O(log(n)) O(log(n))
trie tree(字典树)
UnionFind(并查集)

参考

  1. lru cache - lined list :
    1. https://www.jianshu.com/p/b1ab4a170c3c
    2. https://leetcode-cn.com/problems/lru-cache
  2. redis - skip list
    1. https://redisbook.readthedocs.io/en/latest/internal-datastruct/skiplist.html
    2. https://www.zhihu.com/question/20202931
  3. vscode + nodejs,js刷题如虎添翼(WIN平台):https://leetcode-cn.com/circle/article/uyekr8/
  4. 在线方程计算器:https://www.mathepower.com/cn/xian4xing4fang1cheng2.php
  5. 极客大学算法训练营:https://www.youtube.com/watch?v=-LmElEflngQ&list=PLOrexopjVi6YnFF8QVe5gPJBV5Hyne6Ug&index=2
  6. 一个方法团灭 6 道股票问题(动态规划): https://blog.csdn.net/b515833/article/details/93927055
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
1
https://gitee.com/coder_lw/wiki.git
git@gitee.com:coder_lw/wiki.git
coder_lw
wiki
wiki
master

搜索帮助