算法小抄

230 阅读12分钟

数据结构的基本存储方式就是链式和顺序两种,基本操作就是增删查改,遍历方式无非迭代和递归。

写递归算法的关键是要明确函数的「定义」是什么,然后相信这个定义,利用这个定义推导最终结果,绝不要试图跳入递归。递归一定要有base case
PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。

数据结构

链表

双指针
链表的算法题中是很常见的「虚拟头节点」技巧,也就是dummy节点
快慢指针
左右指针

数组

解题技巧

  • 双指针
  • 滑动窗口
  • 前缀和数组
  • 二分搜索
  • hashmap 对于数组相关的算法问题,有一个通用的技巧:要尽量避免在中间删除元素,那我就 想办法把这个元素换到最后去
    在尾部插入、删除元素是比较高效的,时间复杂度是 O(1),但是如果在中间或者开头插入、删除元素,就会涉及数据的搬移,时间复杂度为 O(N),效率较低。
    主要使用 双指针技巧 中的快慢指针技巧,也可以避免直接删除数组中的元素,降低算法的复杂度。
    通常排序+双指针解决,nSum和题目

字符串

  • 双指针
  • 滑动窗口
  • 递归/栈
  • hashmap

  • 括号类问题

队列

  • 层序遍历(BFS)
  • 优先级队列(堆的实现) 第K大的数或者前K大的数,用长度=K的小顶堆

  • 写树相关的算法,简单说就是,先搞清楚当前root节点该做什么,然后根据函数定义递归调用子节点,递归调用会让孩子节点做相同的事情。 如果需要关注前面节点,则可以在递归是带入额外参数。

二叉树题目的一个难点就是,如何把题目的要求细化成每个节点需要做的事情

  • 树的遍历方式前序 中序 后序 层序,把题目的要求细化,搞清楚根节点应该做什么,然后剩下的事情抛给前/中/后/层序的遍历框架就行了。那怎么确定用那种遍历方式呢?思考一个二叉树节点需要做什么,到底用什么遍历顺序就清楚了。比如说:
    如果当前节点要做的事情需要通过左右子树的计算结果推导出来,就要用到后序遍历

  • 数学归纳法的思想:假设子节点成立,然后根据这个成立的条件推导父节点成立,最终得到答案

  • 遇到任何递归型的问题,无非就是灵魂三问

    1、这个函数是干嘛的

    2、这个函数参数中的变量是什么

    3、得到函数的递归结果,你应该干什么

BST的解题套路

  • 从做算法题的角度来看 BST,除了它的定义,还有一个重要的性质:BST 的中序遍历结果是有序的(升序)
  • 针对 BST 的遍历框架
void BST(TreeNode root, int target) {
    if (root.val == target)
        // 找到目标,做点什么
    if (root.val < target) 
        BST(root.right, target);
    if (root.val > target)
        BST(root.left, target);
}

这个代码框架其实和二叉树的遍历框架差不多,无非就是利用了 BST 左小右大的特性而已。

  • 一旦涉及「改」,函数就要返回TreeNode类型,并且对递归调用的返回值进行接收
  • 如果当前节点会对下面的子节点有整体影响,可以通过辅助函数增长参数列表,借助参数传递信息。

详细的套路在这里

二叉树的分类

满二叉树:二叉树中所有非叶子结点的度都是2,且叶子结点都在同一层次上
完全二叉树:如果把满二叉树从右至左、从下往上删除一些节点,剩余的结构就构成完全二叉树
二叉查找树(BST):左子树上所有结点的值均小于它的根结点的值; 右子树上所有结点的值均大于它的根结点的值; 它的左、右子树也分别为二叉排序树。
平衡二叉树:一种特殊的BST,它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。

字符串

子串、子数组问题一般使用双指针

动态规划(DP)

重叠子问题最优子结构状态转移方程就是动态规划三要素

最优子结构: 要符合「最优子结构」,子问题间必须互相独立,互不影响,且子问题最优能推导出主问题最优。比如:你的、、原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高...

动态规划题目问法

  • 最值问题(比如,最长公共子序列、最小编辑距离、背包能装的最大价值…)
  • 所有方案的个数(比如,两点之间路径个数、爬楼梯问题…)
  • 是否类问题(比如,两个字符串是否按一定方式匹配、背包是否能被填满…)

当然,还有些题目基本上不大可能使用动态规划求解:

  • 找出所有的方案(比如,N 皇后问题、求解一个集合的所有子集、排列组合类问题…)
  • 排序相关的问题(比如,滑动窗口类问题、双指针类问题…)
  • 极值类问题(比如,求数组当中的峰值…)

解法框架

暴力穷举->备忘录->动态规划

解题步骤:

1.先确定「状态」,也就是原问题和子问题中变化的变量,也可以理解为函数的参数,dp[x]中的x

2.然后确定dp函数的定义,确定dp[x]

记住如何解释dp含义,一旦你觉得哪里不好理解,把它翻译成自然语言就容易理解了。

3.然后确定「选择」并择优,选择是写出推导方程的关键

4.写出推导方程(数学归纳法)(base case要先明确), 即函数表达式

动态规划的核心设计思想是数学归纳法。

数学归纳法:

证明当n等于任意一个自然数时某命题成立。证明分下面两步:

  1. 证明当n= 1时命题成立。
  2. 假设n=m时命题成立,那么可以推导出在n=m+1时命题也成立。(m代表任意自然数)

重点

1、明确 dp 数组所存数据的含义。这一步对于任何动态规划问题都很重要,如果不得当或者不够清晰,会阻碍之后的步骤。

2、根据 dp 数组的定义,运用数学归纳法的思想,假设 dp[0...i-1] 都已知,想办法求出 dp[i],一旦这一步完成,整个题目基本就解决了。

但如果无法完成这一步,很可能就是 dp 数组的定义不够恰当,需要重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组

for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 择优(选择1,选择2...)

遍历的方式有三种:

  • 斜着遍历
// 斜着遍历数组
for (int l = 1; l < n; l++) {
    for (int i = 0; i < n - l; i++) {
        int j = l + i;
        // 计算 dp[i][j]
    }
}
  • 正向遍历:
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++)
    for (int j = 0; j < n; j++)
        // 计算 dp[i][j]
  • 反向遍历:
for (int i = m - 1; i >= 0; i--)
    for (int j = n - 1; j >= 0; j--)
        // 计算 dp[i][j]

怎么确定用那种遍历方式呢

1、遍历的过程中,所需的状态必须是已经计算出来的。

2、遍历的终点必须是存储结果的那个位置。

分类设计dp数组

1. 矩阵类问题

画表,找到 dp[i][j] 和 dp[i-1][j], dp[i][j-1], dp[i-1][j-1]的关系,例如路径类问题

dp[i][j] = min(dp[i-1][j],dp[i][j-1])+num[i][j]

可以压缩为:dp[j] = min(dp[j],dp[j-1])+num[i][j]

2. 子序列

相比矩阵类动态规划,序列类动态规划最大的不同在于,对于第 i 个位置的状态分析,它不仅仅需要考虑当前位置的状态,还需要考虑前面 i - 1 个位置的状态

思考的方向其实在于 寻找当前状态和之前所有状态的关系

例如最长递增子序列: dp[i] 表示以位置 i 结尾的子序列的最大长度,nums[i]可以接在任何一个nums[<i]的后面成为一个新的子序列

dp[i] = Math.max(dp[j],...,dp[k]) + 1

打家劫舍股票买卖,粉刷房子 都是这类问题

1、 第一种思路模板是一个一维的 dp 数组

int n = array.length;
int[] dp = new int[n];

for (int i = 1; i < n; i++) {
    for (int j = 0; j < i; j++) {
        dp[i] = 最值(dp[i], dp[j] + ...)
    }
}

举个我们写过的例子 最长递增子序列,在这个思路中 dp 数组的定义是:

在子数组array[0..i]中,以array[i] 结尾的目标子序列(最长递增子序列)的长度是dp[i]

为啥最长递增子序列需要这种思路呢?前文说得很清楚了,因为这样符合归纳法,可以找到状态转移的关系,这里就不具体展开了。

2、第二种思路模板是一个二维的 dp 数组

int n = arr.length;
int[][] dp = new dp[n][n];

for (int i = 0; i < n; i++) {
    for (int j = 1; j < n; j++) {
        if (arr[i] == arr[j]) 
            dp[i][j] = dp[i][j] + ...
        else
            dp[i][j] = 最值(...)
    }
}

这种思路运用相对更多一些,尤其是涉及两个字符串/数组的子序列。本思路中 dp 数组含义又分为「只涉及一个字符串」和「涉及两个字符串」两种情况。

2.1 涉及两个字符串/数组时

dp 数组的含义如下:

在子数组arr1[0..i]和子数组arr2[0..j]中,我们要求的子序列(最长公共子序列)长度为dp[i][j]

解决两个字符串的动态规划问题,一般都是用两个指针i,j分别指向两个字符串的最后,然后一步步往前走,缩小问题的规模。

可以参考这两篇旧文:详解编辑距离 和 最长公共子序列

2.2 只涉及一个字符串/数组时

(比如本文要讲的最长回文子序列),dp 数组的含义如下:

在子数组array[i..j]中,我们要求的子序列(最长回文子序列)的长度为dp[i][j]
最终求的结果就是i=0 j=n-1

3. 字符匹配类问题(双序列类

字符串匹配问题的核心永远是字符之间的比较:
往往这类问题的状态比较好找,你可以先假设状态 dp[i][j] 就是子问题 str1(0...i) str2(0...j)  的状态。
拆解问题主要思考 dp[i][j] 和子问题的状态 dp[i - 1][j],dp[i][j-1] 以及 dp[i - 1][j - 1] 的联系,因为字符串会存在空串的情况,所以动态规划状态数组往往会多开一格。

4.连续类问题

dp[i] = Math.max(dp[i-1]*nums[i],nums[i])

要么i加入前一段,要么自成一段。
最大子数组和最长公共子串

5.背包问题

0-1背包问题

回溯(DFS)

解决一个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题:

1、 路径:也就是已经做出的选择。

2、选择列表:也就是你当前可以做的选择。

3、结束条件:也就是到达决策树底层,无法再做选择的条件。
代码方面,回溯算法的框架:

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return

    for 选择 in 选择列表:
        #剪枝
        合法性判断
        # 做选择
        将该选择从选择列表移除
        路径.add(选择)
        backtrack(路径, 选择列表)
        # 撤销选择
        路径.remove(选择)
        将该选择再加入选择列表

其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。
做选择撤销选择结束条件不同的场景业务逻辑不一样,框架完全套用

!!!一定要注意result.add(路径)时要new一个对象, 如result.add(new ArrayList<>(track)

排列组合子集类问题

子集问题可以利用数学归纳思想,假设已知一个规模较小的问题的结果,思考如何推导出原问题的结果。也可以用回溯算法,要用 start 参数排除已选择的数字。

组合问题利用的是回溯思想,结果可以表示成树结构,我们只要套用回溯算法模板即可,关键点在于要用一个 start 排除已经选择过的数字。

排列问题是回溯思想,也可以表示成树结构套用算法模板,不同之处在于使用 contains 方法排除已经选择的数字,前文有详细分析,这里主要是和组合问题作对比。

对于这三个问题,关键区别在于回溯树的结构,不妨多观察递归树的结构,很自然就可以理解代码的含义了。

BFS

BFS 的核心思想应该不难理解的,就是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,我们写 BFS 算法都是用队列这种数据结构,每次将一个节点周围的所有节点加入队列。

BFS 相对 DFS 的最主要的区别是:BFS 找到的路径一定是最短的,但代价就是空间复杂度比 DFS 大很多

我们先举例一下 BFS 出现的常见场景好吧,问题的本质就是让你在一幅「图」中找到从起点start到终点target的最近距离,这个例子听起来很枯燥,但是 BFS 算法问题其实都是在干这个事儿。

算法解题框架:

// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
    Queue<Node> q// 核心数据结构
    Set<Node> visited; // 避免走回头路

    q.offer(start); // 将起点加入队列
    visited.add(start);
    int step = 0// 记录扩散的步数

    while (q not empty) {
        int sz = q.size();
        /* 将当前队列中的所有节点向四周扩散 */
        for (int i = 0; i < sz; i++) {
            Node cur = q.poll();
            /* 划重点:这里判断是否到达终点 */
            if (cur is target)
                return step;
            /* 将 cur 的相邻节点加入队列 */
            for (Node x : cur.adj())
                if (x not in visited) {
                    q.offer(x);
                    visited.add(x);
                }
        }
        /* 划重点:更新步数在这里 */
        step++;
    }
}

队列q就不说了,BFS 的核心数据结构;cur.adj()泛指cur相邻的节点,比如说二维数组中,cur上下左右四面的位置就是相邻节点;visited的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要visited

传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向 BFS 则是从起点和终点同时开始扩散,当两边有交集的时候停止

贪心

一种特殊情况的动态规划,每一次选择最优->全局最优

分治

双指针

双指针套路
我把双指针技巧再分为两类,一类是「快慢指针」,一类是「左右指针」。前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。

image.png

  • 快慢指针 1.链表
  • 左右指针 1.二分查找
    2.数组反转
    3.两数之和
    4.滑动窗口

滑动窗口

一种特殊的双指针
这也许是双指针技巧的最高境界了,如果掌握了此算法,可以解决一大类子字符串匹配子数组的问题,不过「滑动窗口」稍微比上述的这些算法复杂些
详细看这里
滑动窗口算法的思路是这样

1、 我们在字符串S中使用双指针中的左右指针技巧,初始化left = right = 0把索引左闭右开区间[left, right)称为一个「窗口」

2、 我们先不断地增加right指针扩大窗口[left, right),直到窗口中的字符串符合要求(包含了T中的所有字符)。

3、 此时,我们停止增加right,转而不断增加left指针缩小窗口[left, right),直到窗口中的字符串不再符合要求(不包含T中的所有字符了)。同时,每次增加left,我们都要更新一轮结果。

4、 重复第 2 和第 3 步,直到right到达字符串S的尽头。

这个思路其实也不难,第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解, 也就是最短的覆盖子串。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动,这就是「滑动窗口」这个名字的来历。

现在开始套模板,只需要思考以下四个问题

1、 当移动right扩大窗口,即加入字符时,应该更新哪些数据?

2、 什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?

3、 当移动left缩小窗口,即移出字符时,应该更新哪些数据?

4、 我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?

如果一个字符进入窗口,应该增加window计数器;如果一个字符将移出窗口的时候,应该减少window计数器;当valid满足need时应该收缩窗口;应该在收缩窗口的时候更新最终结果。

二分查找