/arithmetic

基础算法

Primary LanguageJava

Readme

跟着 labuladong 学习算法

常用数据结构相关的算法,主要是数组、链表相关的算法,比如 双指针算法, 滑动窗口算法 等,不需要什么前置知识,技巧虽然精妙,但你理解起来也不会很困难。但稍微进阶一些的算法技巧,主要是递归相关的算法,比如 回溯算法, 动态规划,或者高级数据结构相关算法 Dijkstra 算法, 字典树算法 等,你不要上来就学它们,否则很容易劝退。这些递归算法需要二叉树算法作为铺垫,你应该先学习我的二叉树专题文章,再去学习这些高级算法,就能融会贯通了。

框架思维

整体到细节,自顶向下 ,从抽象到具体的框架思维是通用的,不只是学习数据结构和算法,学习其他任何知识都是高效的

数据结构的存储方式

数据结构的存储方式只有两种: 数组 (顺序存储)和 链表 (链式存储)。

刷题指南

建议的刷题建议

  1. 先学习像数组、链表这种基本数据结构的常用算法
  2. 学会基础算法之后,应该先刷二叉树,先刷二叉树,先刷二叉树,最容易培养框架思维的,而且大部分算法技巧,本质上都是树的遍历问题

试着从框架上看问题,而不要纠结于细节问题

计算机思维

算法的本质就是「穷举」;能够站在计算机的视角,抽象、化简实际问题,然后用编程的方式去解决问题

  1. 如何穷举?即无遗漏地穷举所有可能解。
  2. 如何聪明地穷举?即避免所有冗余的计算,消耗尽可能少的资源求出答案。
  3. 在做题的时候要思考,联想,进而培养举⼀反三的能力

数组链表代表着计算机最基本的两种存储形式:顺序存储和链式存储,所以他俩可以算是最基本的数据结构;

数组

前缀和

前缀和技巧适用于 快速、频繁地计算⼀个索引区间内的元素之和 。前缀和的核心内容就是新建一个数组,数组内的元素 preNum[i] 为原数组 [0…i] 的和;

class PrefixSum {
    // 前缀和数组
    private int[] prefix;
    /* 输入一个数组,构造前缀和 */
    public PrefixSum(int[] nums) {
        prefix = new int[nums.length + 1];
        // 计算 nums 的累加和
        for (int i = 1; i < prefix.length; i++) {
            prefix[i] = prefix[i - 1] + nums[i - 1];
        }
    }
    /* 查询闭区间 [i, j] 的累加和 */
    public int query(int i, int j) {
        return prefix[j + 1] - prefix[i];
    }
}

一维数组中的前缀和

303. Range Sum Query - Immutable Org Doc

这个技巧在生活中运用也挺广泛的,比方说,你们班上有若干同学,每个同学有⼀个期末考试的成绩(满 100 分),那么请你实现⼀个 API,输⼊任意⼀个分数段,返回有多少同学的成绩在这个分数段内。

二维数组中的前缀和

304. Range Sum Query 2D - Immutable

差分数组

  • 差分数组和前缀和比较类似,不一样的是,差分数组适合频繁对原始数组的某个区间的元素进行增减。
  • 构造类似前缀和的 prefix 数组,对原始 nums 数组构造一个 diff 数组,diff 数组的内容就是 nums 数组前后元素的差值, diff[i] = nums[i] - nums[i-1];

    现在我们把差分数组抽象成⼀个类,包含 increment 方法和 result 方法:

class Difference{
    private int[] diff;

    // 构造函数,构造 diff 数组
    public Difference(int[] nums){
        if(nums.length <=0){
            return;
        }
        diff = new int[nums.length+1];
        diff[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
           diff[i] = nums[i] - nums[i-1];
        }
    }

    // 修改 diff 数组
    public void increment(int start,int end,int incr){
        // 更新 diff 数组, diff[start...end] + incr diff[end...] - incr
        for (int i = start; i <  end; i++) {
            diff[i] += incr;
        }
        for (int j = end; j < diff.length; j++) {
            diff[j] -= incr;
        }
    }

    // 根据 diff 和原始的 nums 数组,计算结果
    public int[] result(){
        int[] res = new int[diff.length];
        res[0] = diff[0];
        for (int i = 1; i < diff.length; i++) {
            res[i] = diff[i] + res[i-1];
        }
        return res;
    }

}

1109. Corporate Flight Bookings

还有一个很类似的题目:

1094. Car Pooling

双指针

1. 删除数组中的重复项

26. Remove Duplicates from Sorted Array

2. 移除元素

27. Remove Element

3. 两数之和

167. Two Sum II - Input Array Is Sorted

4. 反转数组

344. Reverse String

5. 最长回访子串

使用双指针由内向外移动,相当于暴力解决,可以使用动态规化的方式,这个需要等到动态规化的时候再说 TODO: 5. 最长回文子串

链表

双指针 - 七道链表题

对于单链表的题目,双指针的应用还是非常广泛的。。

1. 合并两个有序链表

基本规律:

  1. 首先使用到了 虚拟头节点 的技巧,也就是里面的 dump 节点
  2. 其次,使用到了 p 节点,作为 dump 列表上的一个可移到的头节点,就像拉链的拉头;
  3. 最后,使用两个分别作为 p1,p2 两个节点的可移动的节点,就像拉链两边的锯齿;

21. Merge Two Sorted Lists

2. 合并 K 个有序链表

  1. 这里使用了优先级队列的数据结构,将每个链表的头结点都加到最小堆中,就可以每次获得 k 个节点中的最小结点;
  2. 还有一个方法就是使用二分和递归的方式,两两进行组合,就是跟上面的合并两个有序链表一个思路了,具体实现可以看下面的程序;

23. Merge k Sorted Lists

3. 环形链表

怎么判断是否是环形链表?快慢指针,如果相遇,则是环形链表 141. Linked List Cycle 判断环的起点 142. Linked List Cycle II

4. 单链表的倒数第 K 个节点

  • 怎么获得倒数第 K 个节点?如果想获得第 K 个节点,可以 for 循环直接遍历;
  • 求倒数 K 个节点 -> 那就是求 N - K 位置的节点
  • 怎么知道 N 是多少呢?需要循环一次才能知道,那怎么循环一次就能知道 N-K 呢?
  • 设置快慢指针,快指针到 K 的时候,慢指针从 Head 节点出发,两个指针同速前进,快指针到末尾,慢指针的位置就是 N-K 了
ListNode findFromEnd(ListNode head, int k) {
  ListNode fast = head;
  ListNode slow = head;
  int point = 0;
  while (fast != null) {
    point += 1;
    fast = fast.next;
    if (point >= k) {
      slow = solw.next;
    }
  }
  return solw;
}

19. Remove Nth Node From End of List

5. 链表中的中间节点

通过快慢指针来避免第一次获取长度 N,第二次循环到 N/2 的问题,可以一次循环解决问题 876. Middle of the Linked List

6. 相交链表

因为两个链表的长度不一致,导致使用双指针的时候不知道两个指针什么时候出发合适; HeadA 路程是 a -> c -> b 总数是 A HeadB 路程是 x -> c -> b 总数是 B 可以让 point1 先从 HeadA 出发,走完 HeadA 之后,从 HeadB 出发,此时已经走了路程 A 让 pint2 从 HeadB 出发,走完 HeadB 之后,从 HeadA 出发,此时已经走了路程 B Point1 - Point2 = a - x; 因此: Point1 + x = Point2 + a 从而可以看到,两个再分别通过均速走就可以在交点相遇

160. Intersection of Two Linked Lists ****

滑动窗口算法

滑动窗口算法框架

/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
    unordered_map<char, int> need, window;
    # 将字符map 到数组中,数组的含义就是字符出现的次数
    for (char c : t) need[c]++;

    int left = 0, right = 0;
    int valid = 0;
    while (right < s.size()) {
        // c 是将移入窗口的字符
        char c = s[right];
        // 增大窗口
        right++;
        // 进行窗口内数据的一系列更新
        ...

        /*** debug 输出的位置 ***/
        printf("window: [%d, %d)\n", left, right);
        /********************/

        // 判断左侧窗口是否要收缩
        while (window needs shrink) {
            // d 是将移出窗口的字符
            char d = s[left];
            // 缩小窗口
            left++;
            // 进行窗口内数据的一系列更新
            ...
        }
    }
}

套路实战

76. 最小覆盖子串

76. 最小覆盖子串

567. 字符串的排列

一个根据套路完成的。。成了默写了哈哈 567. 字符串的排列

[#A] 找到字符串的所有字母异位词

这个有问题,需要后面再看下 438. 找到字符串中所有字母异位词

最长无重复子串

3. 无重复字符的最长子串

二分查找算法

二分查找框架

int binarySearch(int[] nums, int target) {
    int left = 0, right = ...;

    while(...) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            ...
        } else if (nums[mid] < target) {
            left = ...
        } else if (nums[mid] > target) {
            right = ...
        }
    }
    return ...;
}

二分查找实战

704. 二分查找

704. 二分查找

寻找左侧边界的十分查找

什么是左侧边界?

二叉树

  • 二叉树的思维方式: a. 从上自下的进行遍历 b. 从下自上的递归
  • TODO 哪些问题可以用二叉树的思维解决?

理解前中后序

二叉树的遍历框架:

void traverse(TreeNode root) {
    if (root == null) {
        return;
    }
    // 前序位置
    traverse(root.left);
    // 中序位置
    traverse(root.right);
    // 后序位置
}

A 在递归调用链表的时候,如何去用递归的方式完成?

/* 递归遍历单链表 */
void traverse(ListNode head) {
    if (head == null) {
        return;
    }
    // 前序位置
    traverse(head.next);
    // 后序位置
}

可以看到,实际上对树的遍历就是对链表遍历的扩展,所谓的前序和后序只不过是在递归调用的不同的位置,后序主要就是利用了调用的堆栈,实现了倒序弹出

[#A] 理解处理树节点的序列实际上是处理节点的时间点

要记住这个图:

img/跟着_labuladong_学习算法/2022-09-17_17-23-06_2.jpeg

前中后序是遍历二叉树过程中处理每一个节点的三个特殊 时间点 ;二叉树的所有问题,就是让你在前中后序位置注入巧妙的代码逻辑,去达到自己的目的,你只需要单独思考 每一个节点应该做什么 ,其他的不用你管,抛给二叉树遍历框架,递归会在所有节点上做相同的操作。

综上,遇到一道二叉树的题目时的通用思考过程是:

1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现。

2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。

3、无论使用哪一种思维模式,你都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做。

后序遍历

  • 可以利用后序遍历将层数+1,取 Max 值如这个题:No104 树的最大深度
  • 后序遍历计算最大值,为啥是后序遍历?-> No543 二叉树的直经
  • 将二叉树展开为链表 114. 二叉树展开为链表[fn:3] 大致思路可以整理为节点的左侧节点,拼接到 root 的右节点上,原本的右节点拼接到原左节点的下面;从子节点向根节点分析,那需要是后序遍历,先遍历到子节点,然后拆分子节点,从而构造根节点。
  • 一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了 ->652. 寻找重复的子树 [fn:4] 这里的大致思路就是需要从下至上看子树是否和其他子树一致,如果一致,则向上遍历再比较

[#A] 前序遍历

  • 看当前值是否 <min 或者 >max, 确定是否要裁剪还是继续向下遍历 -> No669 树的裁剪 如何做裁剪?裁剪无非就是把当前节点这只为 null 说明这个接口以及后面的节点都被裁减掉了
  • 实战 1:simple 226. 翻转二叉树 [fn:1]

层序遍历

当遇到将树按照层的维度连接或查询,实际就是树的广度优先算法,比如这道题 : 116. 填充每个节点的下一个右侧节点指针[fn:2] 这道题实际上有两种解决方式

  • 一种是上面的解决方式,利用栈的特性,将每层数据先放到栈中,再依次弹出栈元素,关键在于如何处置层与层之间的关系,这里使用的是固定推到栈中的一层数据的 size,下次只弹出 size 个元素,这些元素就是在一层内的所有元素;
  • 另一种是二叉树的相邻节点抽象成一个「三叉树节点」,这样二叉树就变成了一棵「三叉树」,然后你去遍历这棵三叉树,把每个「三叉树节点」中的两个节点连接就行了: 116. 填充每个节点的下一个右侧节点指针

Footnotes

[fn:4] https://leetcode.cn/problems/find-duplicate-subtrees/

[fn:3] https://leetcode.cn/problems/flatten-binary-tree-to-linked-list/ [fn:2] https://leetcode.cn/problems/populating-next-right-pointers-in-each-node/ [fn:1] https://leetcode.cn/problems/invert-binary-tree/