面试算法题模板总结

数据结构

一、数组

void traverse(int[] arr) {
    for (int i = 0; i < arr.length; i++) {
        // 迭代访问 arr[i]
    }
}

二、链表

方便调试的代码
    // 下面,我们将 LeetCode 中的给出的链表的节点这个类进行一些扩展,方便我们的调试
    // 1、给出一个数字数组,通过数组构建数字链表
    public ListNode(int[] arr) {
        if(arr == null || arr.length == 0){
            throw new IllegalArgumentException("arr can not be empty");
        }
        // 体会这里 this 指代了什么,其实就是 head
        // 因为这是一个构造函数,所以也无须将 head 返回
        this.val = arr[0];
        ListNode cur = this;
        for (int i = 1; i < arr.length; i++) {
            cur.next = new ListNode(arr[i]);
            cur = cur.next;
        }
    }

    // 2、重写 toString() 方法,方便我们查看链表中的元素
    @Override
    public String toString() {
        StringBuilder s = new StringBuilder();
        ListNode cur = this; // 还是要特别注意的是,理解这里 this 的用法
        while (cur!=null){
            s.append(cur.val + "->");
            cur = cur.next;
        }
        s.append("NULL");
        return s.toString();
    }
常见套路

https://leetcode-cn.com/problems/merge-two-sorted-lists/solution/xin-shou-you-hao-xue-hui-tao-lu-bu-fan-cuo-4nian-l/
https://liweiwei1419.gitee.io/leetcode-algo/leetcode-by-tag/linked-list/

穿针引线(画图)
  1. 反转链表(206)
  2. 反转链表 II(92)

因为涉及第 1 个结点的操作,为了避免分类讨论,常见的做法是引入虚拟头结点

比较直观的思路是先断开链表,逆转,拼回去

  1. 删除排序链表中的重复元素(83)
  2. 两数相加(2)
  3. 删除排序链表中的重复元素 II(82)
  4. 合并两个有序链表(21)
  5. 合并 K 个排序链表(23)

https://leetcode-cn.com/problems/merge-k-sorted-lists/solution/4-chong-fang-fa-xiang-jie-bi-xu-miao-dong-by-sweet/
1.K 指针:K 个指针分别指向 K 条链表
2.使用小根堆对 1 进行优化
3.逐一合并两条链表
4.两两合并对 3 进行优化

设置哑节点

需要操作第一个节点的时候,为了避免讨论,设置哑节点

  1. 分隔链表(86)

用两个链表,一个链表放小于x的节点,一个链表放大于等于x的节点
最后,拼接这两个链表.

  1. 移除链表元素(203)
逆序操作使用栈
  1. 两数相加 II(445)

用栈解决逆序处理
头插法保证结果逆序

  1. 两两交换链表中的结点(24)
  2. K 个一组翻转链表(25)

使用了双端队列:Deque,可以在两端操作数据:主要有:ArrayDeque,LinkedList

遍历模板
/* 基本的单链表节点 */
class ListNode {
    int val;
    ListNode next;
}

void traverse(ListNode head) {
    for (ListNode p = head; p != null; p = p.next) {
        // 迭代访问 p.val
    }
}

void traverse(ListNode head) {
    // 递归访问 head.val
    traverse(head.next)
}

三、二叉树

/* 基本的二叉树节点 */
class TreeNode {
    int val;
    TreeNode left, right;
}

void traverse(TreeNode root) {
    // 前序遍历
    traverse(root.left)
    // 中序遍历
    traverse(root.right)
    // 后序遍历
}
多叉树
/* 基本的 N 叉树节点 */
class TreeNode {
    int val;
    TreeNode[] children;
}

void traverse(TreeNode root) {
    for (TreeNode child : root.children)
        traverse(child)
}

算法思想

一. 双指针

https://labuladong.gitbook.io/algo/di-ling-zhang-bi-du-xi-lie/shuang-zhi-zhen-ji-qiao

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

1. 快慢指针

快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。

    ListNode fast, slow;
    fast = slow = head;
    fast = fast.next.next;
    slow = slow.next;

常见问题:

  1. 判定链表中是否含有环
  2. 已知链表中含有环,返回这个环的起始位置
  3. 寻找链表的中点
  4. 寻找链表的倒数第 k 个元素
  5. 快乐数(202)

用快慢指针解决循环问题,指针相遇就是有循环

2. 左右指针

左右指针在数组中实际是指两个索引值,一般初始化为 left = 0, right = nums.length - 1
常见问题:

  1. 二分查找
  2. 两数之和
  3. 反转数组
  4. 滑动窗口算法

二、二分查找

查找问题,并且题目要求算法时间复杂度必须是 O(log n) 级别
一般都是用二分查找

「二分查找」不是只能应用在有序数组里,只要是可以使用「减治思想」的问题,都可以使用二分查找

减治法二分查找:

https://ojeveryday.github.io/AlgoWiki/#/BinarySearch/03-template-2
https://leetcode-cn.com/problems/split-array-largest-sum/solution/pao-ding-jie-niu-dai-ni-yi-bu-bu-shi-yong-er-fen-c/

难点:
1.找出要查找的变量是什么?即我们的left,right 和 mid 表示的是什么?也即我们查找的范围是什么?
2.哪些是这些变量的更新准则?如何缩小范围?

框架:
public int search(int[] nums, int left, int right, int target) {
    while (left < right) {
        // 选择中位数时下取整
        // 需要根据搜索区间只有两个元素时的情况,决定向上取整还是向下取整
        int mid = left + (right - left) / 2;
        if (check(mid)) {
            // 只考虑什么时候不是解
            // 下一轮搜索区间是 [mid + 1, right]
            left = mid + 1
        } else {
            // 不需要考虑为什么是这个区间,只需要简单的取上一个区间的补集
            // 下一轮搜索区间是 [left, mid]
            // 由于设置的是right=mid,所以用向下取整,否则当搜索区间只有两个数的时候mid=right,陷入死循环
            right = mid
        }
    }
    // 退出循环的时候,程序只剩下一个元素没有看到。
    // 视情况,是否需要单独判断 left(或者 right)这个下标的元素是否符合题意
}

常见题目:
题型 1:在半有序(旋转有序或者是山脉)数组里查找元素
  1. 搜索旋转排序数组(33)

利用减治思想可以用一次二分查找就得到结果

  1. 山脉数组中查找目标值(1095)

比较复杂的问题,特别是有序性不好,分隔区间有重叠的问题,最好多用几次二分查找求解,而不是一次性求解

  1. 在排序数组中查找元素的第一个和最后一个位置(34)

这个问题,一次二分查找,分两次二分查找都可以做出来,还是比较推荐分两次二分查找,写起来比较直观,不容易出错

题型 2:确定一个有范围的整数
  1. 寻找重复数(287)

抽屉原理:桌上有十个苹果,要把这十个苹果放到九个抽屉里,无论怎样放,我们会发现至少会有一个抽屉里面放不少于两个苹果
这道题要求我们查找的数是一个整数,并且给出了这个整数的范围(在 11 和 nn 之间,包括 1 和 n),并且给出了一些限制,于是可以使用二分查找法定位在一个区间里的整数,利用抽屉原理判断哪边区间存在重复数

题型 3:需要查找的目标元素满足某个特定的性质
  1. 分割数组的最大值(410)

由题意可知:子数组的最大值是有范围的,根据目标元素的特质,问题转化为确定一个有范围的整数

常规二分查找:
框架:
闭区间统一模板
// 1.基本的二分查找(找一个数)
int binary_search(int[] nums, int target) {
    int left = 0, right = nums.length - 1; 
    while(left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1; 
        } else if(nums[mid] == target) {
            // 直接返回
            return mid;
        }
    }
    // 直接返回
    return -1;
}

// 2.寻找左侧边界的二分搜索
int left_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 别返回,锁定左侧边界
            right = mid - 1;
        }
    }
    // 最后要检查 left 越界的情况
    if (left >= nums.length || nums[left] != target)
        return -1;
    return left;
}

// 3.寻找右侧边界的二分查找
int right_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 别返回,锁定右侧边界
            left = mid + 1;
        }
    }
    // 最后要检查 right 越界的情况
    if (right < 0 || nums[right] != target)
        return -1;
    return right;
}

三、 滑动窗口算法

遇到子串问题,首先想到的就是滑动窗口技巧。

大致逻辑:

int left = 0, right = 0;

while (right < s.size()) {
    // 增大窗口
    window.add(s[right]);
    right++;

    while (window needs shrink) {
        // 缩小窗口
        window.remove(s[left]);
        left++;
    }
}

算法框架:

注意区间是左闭右开的

/* 滑动窗口算法框架 */
    void slidingWindow(string s, string t) {
        /* 滑动窗口算法框架 */
        Map<Character, Integer> need = new HashMap<>();
        Map<Character, Integer> window = new HashMap<>();
        char[] tchars = t.toCharArray();
        for (char c : tchars) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }

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

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

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

套模板,只需要思考以下四个问题:

  1. 当移动 right 扩大窗口,即加入字符时,应该更新哪些数据?
  2. 什么条件下,窗口应该暂停扩大,开始移动 left 缩小窗口?
  3. 当移动 left 缩小窗口,即移出字符时,应该更新哪些数据?
  4. 我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?

常见问题:

  1. 最小覆盖子串

一个坑:Integer类型的数据在-128~127之间时,会使用缓存,用==比较时会比较数值,超出这个范围不会拆箱,比较的是对象,==返回的是false
要使用compareTo,或者equals

上述四个问题:

  1. 更新window,valid,其中 valid 变量表示窗口中满足 need 条件的字符个数
  2. valid==need.size()时,开始缩小
  3. 更新window,valid
  4. 缩小窗口时
  1. 字符串排列(567)
  1. 更新window,valid
  2. right-left==s1.lenth()
  3. 更新window,valid
  4. 缩小时
  1. 找到字符串中所有字母异位词(438)
  2. 无重复字符的最长子串(3)

四、动态规划

首先简单阐述一下递归,分治算法,动态规划,贪心算法这几个东西的区别和联系,心里有个印象就好。

递归是一种编程技巧,一种解决问题的思维方式;分治算法和动态规划很大程度上是递归思想基础上的(虽然实现动态规划大都不是递归了,但是我们要注重过程和思想),解决更具体问题的两类算法思想;贪心算法是动态规划算法的一个子集,可以更高效解决一部分更特殊的问题。

分治算法将在这节讲解,以最经典的归并排序为例,它把待排序数组不断二分为规模更小的子问题处理,这就是“分而治之”这个词的由来。显然,排序问题分解出的子问题是不重复的,如果有的问题分解后的子问题有重复的(重叠子问题性质),那么这就交给动态规划算法去解决!

动态规划的特点:

  • 动态规划问题的一般形式就是求最值。
  • 符合最优子结构:可以从子问题的最优结果推出更大规模问题的最优结果。(那么遇到这种最优子结构失效情况,怎么办?策略是:改造问题。)
  • 求解动态规划的核心问题是穷举。
  • 因为这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

难点在于:
写出状态转移方程是最困难的
明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case
PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助

常见问题:

  1. 斐波那契数列

带备忘录的递归解法的效率已经和迭代的动态规划解法一样了。实际上,这种解法和迭代的动态规划已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」

通过「备忘录」或者「dp table」的方法来优化递归树,并且明确了这两种方法本质上是一样的,只是自顶向下和自底向上的不同而已。

  1. 凑零钱问题

流程化确定「状态转移方程」,只要通过状态转移方程写出暴力递归解,剩下的也就是优化递归树,消除重叠子问题而已。

如何列出正确的状态转移方程?
1.先确定「状态」
2.然后确定 dp 函数的定义
3.然后确定「选择」并择优
4.最后明确 base case

  1. 最大子序和(53)

五、搜索算法DFS&BFS

BFS的特性:第一次遍历到目的节点,其所经过的路径为最短路径
所以BFS通常用来求解最短路径等最优解问题
因此,在搜索的时候常常要操作遍历的层数

DFS特性:从一个节点出发,使用 DFS 对一个图进行遍历时,能够遍历到的节点都是从初始节点可达的
所以DFS 常用来求解这种可达性问题
因此,常常需要将这种可达性标记出来

BFS

https://leetcode-cn.com/problems/n-ary-tree-level-order-traversal/solution/ncha-shu-de-ceng-xu-bian-li-by-leetcode/
https://leetcode-cn.com/problems/perfect-squares/solution/python3zui-ji-chu-de-bfstao-lu-dai-ma-gua-he-ru-me/

BFS 算法组成的 3 元素:队列,入队出队的节点,已访问的集合。
1.队列:先入先出的容器
2.节点:最好写成单独的类,或者pair之类
3.已访问集合:为了避免队列中插入重复的值

BFS套路
1.初始化三元素
2.操作队列 —— 弹出队首节点
3.操作弹出的节点 —— 根据业务生成子节点(一个或多个)
4.判断这些节点 —— 符合业务条件,则return,不符合业务条件,且不在已访问集合,则追加到队尾,并加入已访问集合
5.若以上遍历完成仍未return,下面操作返回未找到代码

        // 结果集合:可能是数组,也可以是别的值
        List<Integer> values = new ArrayList<>();
        int path;
        // 队列
        Queue<Node> queue = new LinkedList<>();
        // 已访问集合
        int[] visited = new int[n + 1];
        queue.add(root);
        int[1] =1;
        int path = 0;
        while (!queue.isEmpty()) {
            // 和层数有关的操作(如果和层数无关,则不需要这一层for)
            path++;
            int size = queue.size();
            // 遍历一层中所有的节点
            for (int i = 0; i < size; i++) {
                // 操作当前节点
                Node nextNode = queue.remove();
                values.add(nextNode.val);
                // 遍历子节点
                for (Node child : nextNode.children) {
                    // 有时候遍历的不是节点,要判断是否符合题目条件和判断是否没有访问过
                    // 添加到队列中
                    queue.add(child);
                }
            }
        }
DFS

DFS可以用递归实现,也可以借用一个栈迭代实现

递归的框架和回溯很像

// 标记节点是否访问过
private boolean[] visited;
public void DFS(顶点) 
{
  if(结束条件){
      return;
  }
  处理当前顶点;
  记录为已访问;
  // 和回溯法不同的是,这里遍历的是状态,而不是选择,所以不需要撤回选择
  遍历与当前顶点相邻的所有未访问顶点
  {
      DFS( 下一子状态);
  }
}

回溯法是求问题的解,使用的是DFS(深度优先搜索)。在DFS的过程中发现不是问题的解,那么就开始回溯到上一层或者上一个节点。DFS是遍历整个搜索空间,而不管是否是问题的解

https://leetcode-cn.com/problems/maximum-depth-of-n-ary-tree/solution/c-san-chong-jing-dian-fang-fa-jie-ti-by-pris_bupt/
https://leetcode-cn.com/problems/path-sum/solution/lu-jing-zong-he-by-leetcode/

int maxDepth(Node* root) {
    if (!root) return 0;
    stack<pair<Node*,int>>stack;
    stack.push(pair<Node*, int>(root,1));
    int max_depth = 0;
    while (!stack.empty()) {
        Node* node = stack.top().first;
        int depth = stack.top().second;
        stack.pop();
        for (Node* it : node->children)
            stack.push(pair<Node*, int>(it, depth + 1));
        max_depth = max(max_depth, depth);
    }
    return max_depth;
}

六、回溯算法

解决一个回溯问题,实际上就是一个决策树的遍历过程.
你只需要思考 3 个问题:
1、 路径:也就是已经做出的选择。
2、 选择列表:也就是你当前可以做的选择。
3、 结束条件:也就是到达决策树底层,无法再做选择的条件。

但是必须说明的是,不管怎么优化,都符合回溯框架,而且时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。

框架:

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

    for 选择 in 选择列表:
        排除不合法选择
        做选择
        backtrack(路径, 选择列表)
        撤销选择

常见问题:

  1. 全排列(46)
  2. N皇后(51)

七、其他

遍历n的每一位
while (n > 0) {
    int d = n % 10;
    处理末位数字;
    n = n / 10;
}
参考资料:

https://labuladong.gitbook.io/algo/di-ling-zhang-bi-du-xi-lie/hui-su-suan-fa-xiang-jie-xiu-ding-ban
https://oi-wiki.org/basic/divide-and-conquer/

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值