算法解题模式

421 阅读33分钟

14 种常见的解题模式:

1.滑动窗口
2.二指针或迭代器
3.快速和慢速指针或迭代器
4.合并区间
5.循环排序
6.原地反转链表
7.树的宽度优先搜索(Tree BFS)
8.树的深度优先搜索(Tree DFS)
9.Two Heaps
10.子集
11.经过修改的二叉搜索
12. 前 K 个元素
13. K 路合并
14.拓扑排序

十大排序算法

juejin.cn/post/694175…

分治

“分解问题→解决子问题→合并结果” 三步法解决复杂问题的算法思想。

// 分治入口:在 arr[left..right] 中查找 target
   private static int binarySearch(int[] arr, int left, int right, int target) {
       // 基线条件:查找范围无效(未找到)
       if (left > right) {
           return -1;
       }

       // 1. 分解:取中间位置,将数组分为左右两部分
       int mid = left + (right - left) / 2;

       // 2. 解决:比较中间值与目标值,递归查找左/右分区
       if (arr[mid] == target) {
           return mid; // 找到目标,直接返回索引
       } else if (arr[mid] > target) {
           // 目标在左分区:递归查找 arr[left..mid-1]
           return binarySearch(arr, left, mid - 1, target);
       } else {
           // 目标在右分区:递归查找 arr[mid+1..right]
           return binarySearch(arr, mid + 1, right, target);
       }
       // 3. 合并:无需合并(找到直接返回,未找到返回-1)
   }
// 移动一个圆盘
void move(List<Integer> src, List<Integer> tar) {
    // 从 src 顶部拿出一个圆盘
    Integer pan = src.remove(src.size() - 1);
    // 将圆盘放入 tar 顶部
    tar.add(pan);
}

//求解汉诺塔问题 f(i) 
void dfs(int i, List<Integer> src, List<Integer> buf, List<Integer> tar) {
    // 若 src 只剩下一个圆盘,则直接将其移到 tar
    if (i == 1) {
        move(src, tar);
        return;
    }
    // 子问题 f(i-1) :将 src 顶部 i-1 个圆盘借助 tar 移到 buf
    dfs(i - 1, src, tar, buf);
    // 子问题 f(1) :将 src 剩余一个圆盘移到 tar
    move(src, tar);
    // 子问题 f(i-1) :将 buf 顶部 i-1 个圆盘借助 src 移到 tar
    dfs(i - 1, buf, src, tar);
}

/* 求解汉诺塔问题 */
void solveHanota(List<Integer> A, List<Integer> B, List<Integer> C) {
    int n = A.size();
    // 将 A 顶部 n 个圆盘借助 B 移到 C
    dfs(n, A, B, C);
}

动态规划

高效解决具有重叠子问题和最优子结构的问题,通过存储子问题的解避免重复计算,将指数级时间复杂度优化为多项式级。其难点在于状态定义状态转移方程的推导,需要通过大量练习(如上述典型问题)培养 “拆解问题” 的思维。

通俗讲:子结构的解是知道的,问题的解可以通过子解和当前数据推导。

  • 大规模问题的解依赖小规模问题的解
  • 从最简单最小的规模问题开始解,问题的规模逐渐增大
  • 自底向上求解

学习动态规划的关键:

  1. 识别问题是否具有 “重叠子问题” 和 “最优子结构”;
  2. 设计清晰的状态定义(dp数组的含义);
  3. 推导正确的状态转移方程(子问题与原问题的关系)。

连续子序列的最大和

public int maxSubArray(int[] nums) {
        int len = nums.length;
        // dp[i] 表示:以 nums[i] 结尾的连续子数组的最大和
        int[] dp = new int[len];
        dp[0] = nums[0];

        for (int i = 1; i < len; i++) {
            if (dp[i - 1] > 0) {
                dp[i] = dp[i - 1] + nums[i];
            } else {
                dp[i] = nums[i];
            }
        }

        // 也可以在上面遍历的同时求出 res 的最大值,这里我们为了语义清晰分开写,大家可以自行选择
        int res = dp[0];
        for (int i = 1; i < len; i++) {
            res = Math.max(res, dp[i]);
        }
        return res;
    }
public int maxSubArray(int[] nums) {
        // 边界处理:空数组或长度为0
        if (nums == null || nums.length == 0) {
            return 0; // 实际场景可根据需求调整(如抛异常)
        }

        // currentMax:以当前元素结尾的最大子数组和(替代dp[i])
        // globalMax:全局最大子数组和
        int currentMax = nums[0];
        int globalMax = nums[0];

        // 从第二个元素开始遍历
        for (int i = 1; i < nums.length; i++) {
            // 状态转移:要么延续前序子数组,要么从当前元素重新开始
            currentMax = Math.max(nums[i], currentMax + nums[i]);
            // 更新全局最大值
            globalMax = Math.max(globalMax, currentMax);
        }

        return globalMax;
    }

n阶台阶,每次爬 1 或 2 阶,求登顶的不同方法数

private static int climbStairs(int n) {
        if (n <= 2) return n;
        // 状态定义:dp[i]表示第i阶的方法数
        int[] dp = new int[n + 1];
        // 边界条件
        dp[1] = 1; // 1阶:1种(1)
        dp[2] = 2; // 2阶:2种(1+1 或 2)
        // 状态转移:第i阶 = 第i-1阶(+1) + 第i-2阶(+2)
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }

动态规划求解最少硬币数

/**
     * 动态规划求解最少硬币数 
     * @param coins 硬币面额数组
     * @param target 目标金额
     * @return 最少硬币数
     */
    private static int getMinCoins(int[] coins, int target) {
        // 1. 定义dp数组:dp[i]表示凑成金额i所需的最少硬币数
        int[] dp = new int[target + 1];

        // 2. 初始化dp数组
        // - dp[0] = 0(凑0元需要0个硬币,基础条件)
        // - 其他位置初始化为"无穷大"(用target + 1表示,因为最多需要target个1元硬币)
        dp[0] = 0;
        for (int i = 1; i <= target; i++) {
            dp[i] = target + 1; // 初始值设为比最大可能值大1,代表"暂时无法凑成"
        }

        // 3. 填充dp数组:遍历每个金额,计算最少硬币数
        for (int i = 1; i <= target; i++) {
            // 对每种硬币面额尝试"凑数"
            for (int coin : coins) {
                // 只有当当前金额 >= 硬币面额时,才能用该硬币
                if (i >= coin) {
                    // 状态转移方程:用1个当前硬币 + 凑成"i - coin"金额的最少硬币数
                    // 取当前dp[i]和新可能解的最小值(保证是最少硬币数)
                    dp[i] = Math.min(dp[i], dp[i - coin] + 1);
                }
            }
        }

        // 4. 返回结果(因存在1元硬币,一定能凑成,所以直接返回dp[target])
        return dp[target];
    }

最小路径和

private static int minPathSum(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;
        // 状态定义:dp[i][j] = 到(i,j)的最小路径和
        int[][] dp = new int[m][n];

        // 边界条件:第一行(只能从左来)
        dp[0][0] = grid[0][0];
        for (int j = 1; j < n; j++) {
            dp[0][j] = dp[0][j - 1] + grid[0][j];
        }

        // 边界条件:第一列(只能从上来)
        for (int i = 1; i < m; i++) {
            dp[i][0] = dp[i - 1][0] + grid[i][0];
        }

        // 状态转移:到(i,j)的路径和 = 当前格子值 + min(上方路径和, 左方路径和)
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                dp[i][j] = grid[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
            }
        }

        return dp[m - 1][n - 1];
    }

1.滑动窗口

滑动窗口模式是用于在给定数组或链表的特定窗口大小上执行所需的操作,比如寻找包含所有 1 的最长子数组。从第一个元素开始滑动窗口并逐个元素地向右滑,并根据你所求解的问题调整窗口的长度。在某些情况下窗口大小会保持恒定,在其它情况下窗口大小会增大或减小。

Image

下面是一些你可以用来确定给定问题可能需要滑动窗口的方法:

  • 问题的输入是一种线性数据结构,比如链表、数组或字符串
  • 你被要求查找最长/最短的子字符串、子数组或所需的值

你可以使用滑动窗口模式处理的常见问题:

  • 大小为 K 的子数组的最大和(简单) ``
  • 带有 K 个不同字符的最长子字符串(中等)
import java.util.HashSet;

public class LongestSubstringWithoutRepeatingCharacters {
    public static int lengthOfLongestSubstring(String s) {
        // 创建一个HashSet用于存储当前窗口内的字符
        HashSet<Character> window = new HashSet<>();
        // 用于记录最长无重复字符子串的长度
        int res = 0;
        // 定义滑动窗口的左右边界,初始都为0
        int left = 0, right = 0;

        while (right < s.length()) {
            // c 是将移入窗口的字符
            char c = s.charAt(right);
            // 当窗口中已经包含该字符时,需要收缩窗口
            while (window.contains(c)) {
                // 移除左边界字符
                window.remove(s.charAt(left));
                // 左移窗口
                left++;
            }
            // 将当前字符加入窗口
            window.add(c);
            // 右移窗口
            right++;
            // 更新最长无重复字符子串的长度
            res = Math.max(res, right - left);
        }
        // 返回最长无重复子串的长度
        return res;
    }

    public static void main(String[] args) {
        String s = "abcabcbb";
        System.out.println("最长无重复字符子串的长度: " lengthOfLongestSubstring(s));
    }
}
  • 寻找字符相同但排序不一样的字符串(困难) ``

2.二指针或迭代器

二指针(Two Pointers)是这样一种模式:两个指针以一前一后的模式在数据结构中迭代,直到一个或两个指针达到某种特定条件。二指针通常在排序数组或链表中搜索配对时很有用;比如当你必须将一个数组的每个元素与其它元素做比较时。

二指针是很有用的,因为如果只有一个指针,你必须继续在数组中循环回来才能找到答案。这种使用单个迭代器进行来回在时间和空间复杂度上都很低效——这个概念被称为「渐进分析(asymptotic analysis)」。尽管使用 1 个指针进行暴力搜索或简单普通的解决方案也有效果,但这会沿 O(n²) 线得到一些东西。在很多情况中,二指针有助于你寻找有更好空间或运行时间复杂度的解决方案。

Image

用于识别使用二指针的时机的方法:

  • 可用于你要处理排序数组(或链接列表)并需要查找满足某些约束的一组元素的问题
  • 数组中的元素集是配对、三元组甚至子数组

下面是一些满足二指针模式的问题:

  • 求一个排序数组的平方(简单)
//力扣997   有序数组的平方
class Solution {
    public int[] sortedSquares(int[] A) {
        // 获取数组的长度
        int n = A.length;
        // 用于存储排序后的平方结果的数组
        int[] ans = new int[n];
        // 左指针,初始指向数组的起始位置
        int left = 0;
        // 右指针,初始指向数组的末尾位置
        int right = n - 1;
        // 从结果数组的末尾开始填充元素
        for (int i = n - 1; i >= 0; i--) {
            // 比较左指针和右指针所指元素的平方大小
            if (A[left] * A[left] > A[right] * A[right]) {
                // 左指针元素的平方较大,将其放入结果数组的当前位置
                ans[i] = A[left] * A[left];
                // 左指针右移
                left++;
            } else {
                // 右指针元素的平方较大,将其放入结果数组的当前位置
                ans[i] = A[right] * A[right];
                // 右指针左移
                right--;
            }
        }
        // 返回排序后的平方结果数组
        return ans;
    }

    public static void main(String[] args) {
        // 测试数组
        int[] A = {-4, -1, 0, 3, 10};
        // 创建 Solution 类的实例
        Solution solution = new Solution();
        // 调用 sortedSquares 方法得到排序后的平方结果数组
        int[] result = solution.sortedSquares(A);
        // 输出提示信息
        System.out.println("排序后的结果:");
        // 遍历结果数组并输出每个元素
        for (int num : result) {
            System.out.println(num);
        }
    }
}
  • 求总和为零的三元组(中等)
  • 比较包含回退(backspace)的字符串(中等)

3.快速和慢速指针

快速和慢速指针方法也被称为 Hare & Tortoise 算法,该算法会使用两个在数组(或序列/链表)中以不同速度移动的指针。该方法在处理循环链表或数组时非常有用。

通过以不同的速度进行移动(比如在一个循环链表中),该算法证明这两个指针注定会相遇。只要这两个指针在同一个循环中,快速指针就会追赶上慢速指针。

Image

如何判别使用快速和慢速模式的时机?

  • 处理链表或数组中的循环的问题
  • 当你需要知道特定元素的位置或链表的总长度时

何时应该优先选择这种方法,而不是上面提到的二指针方法?

  • 有些情况不适合使用二指针方法,比如在不能反向移动的单链接链表中。使用快速和慢速模式的一个案例是当你想要确定一个链表是否为回文(palindrome)时。

下面是一些满足快速和慢速指针模式的问题:

  • 链表循环(简单)
    • "快慢指针" 算法来解决,时间复杂度为 O (n),空间复杂度为 O (1)。
class ListNode { 
    int val; 
    ListNode next; 
    ListNode(int x) 
    { 
        val = x; 
        next = null; 
    } 
}

public class LinkedListCycle {
  public boolean hasCycle(ListNode head) {
    // 边界条件处理:空链表或只有一个节点且无环
    if (head == null || head.next == null) {
      return false;
    }

    // 定义快慢指针
    ListNode slow = head;
    ListNode fast = head.next;

    // 如果有环,快慢指针最终会相遇
    while (slow != fast) {
      // 快指针到达尾部,说明无环
      if (fast == null || fast.next == null) {
        return false;
      }

      // 慢指针走一步,快指针走两步
      slow = slow.next;
      fast = fast.next.next;
    }

    // 快慢指针相遇,说明有环
    return true;
  }

  // 测试方法
  public static void main(String[] args) {
    LinkedListCycle solution = new LinkedListCycle();

    // 创建有环的链表
    ListNode head1 = new ListNode(3);
    head1.next = new ListNode(2);
    head1.next.next = new ListNode(0);
    head1.next.next.next = new ListNode(-4);
    head1.next.next.next.next = head1.next; // 形成环

    System.out.println("链表1是否有环: " + solution.hasCycle(head1)); // 应该输出true

    // 创建无环的链表
    ListNode head2 = new ListNode(1);
    head2.next = new ListNode(2);

    System.out.println("链表2是否有环: " + solution.hasCycle(head2)); // 应该输出false
  }
}
  • 回文链表(中等)
    • O (n) 时间复杂度,O (1) 空间复杂度, 找到链表中点,反转后半部分,然后比较两部分是否相同。
public class OptimizedPalindromeLinkedList {
    /**
     * 判断链表是否为回文结构
     * 时间复杂度:O(n),空间复杂度:O(1)
     */
    public boolean isPalindrome(ListNode head) {
        // 边界条件:空链表或单节点链表都是回文
        if (head == null || head.next == null) {
            return true;
        }

        // 1. 找到链表中点(前半部分的尾节点)
        ListNode firstHalfEnd = findMiddle(head);
        // 2. 反转后半部分链表
        ListNode secondHalfStart = reverseList(firstHalfEnd.next);

        // 3. 比较两部分是否相同
        ListNode p1 = head;
        ListNode p2 = secondHalfStart;
        boolean isPalindrome = true;

        // 只需比较到后半部分结束即可
        while (isPalindrome && p2 != null) {
            if (p1.val != p2.val) {
                isPalindrome = false;
            }
            p1 = p1.next;
            p2 = p2.next;
        }

        // 4. 可选:恢复链表结构(如果需要保持原链表不变)
        // 生产环境中如果不需要恢复,可以删除这一步以提高性能
        // firstHalfEnd.next = reverseList(secondHalfStart);

        return isPalindrome;
    }

    /**
     * 查找链表中点(前半部分的尾节点)
     * 快慢指针法:快指针速度是慢指针的2倍
     */
    private ListNode findMiddle(ListNode head) {
        ListNode slow = head;
        ListNode fast = head.next;

        // 当快指针到达末尾时,慢指针正好在中点
        while (fast != null && fast.next != null) {
            slow = slow.next;         // 慢指针走一步
            fast = fast.next.next;    // 快指针走两步
        }
        return slow;
    }

    /**
     * 反转链表并返回新的头节点
     */
    private ListNode reverseList(ListNode head) {
        ListNode prev = null;
        ListNode current = head;
        ListNode nextTemp = null;

        while (current != null) {
            nextTemp = current.next;  // 保存下一个节点
            current.next = prev;                // 反转指针
            prev = current;                     // 移动prev指针
            current = nextTemp;                 // 移动current指针
        }
        return prev;  // prev成为新的头节点
    }
}

4.合并区间

合并区间模式是一种处理重叠区间的有效技术。在很多涉及区间的问题中,你既需要找到重叠的区间,也需要在这些区间重叠时合并它们。该模式的工作方式为:

给定两个区间(a 和 b),这两个区间有 6 种不同的互相关联的方式:

Image

理解并识别这六种情况有助于你求解范围广泛的问题,从插入区间到优化区间合并等。

那么如何确定何时该使用合并区间模式呢?

  • 如果你被要求得到一个仅含互斥区间的列表
  • 如果你听到了术语「重叠区间(overlapping intervals)」

合并区间模式的问题:

  • 区间交叉合并(中等)
public class Interval {
    int start;
    int end;
    Interval() {
        start = 0;
        end = 0;
    }
    Interval(int s, int e) {
        start = s;
        end = e;
    }
}
public class Solution {
    public ArrayList<Interval> merge(ArrayList<Interval> intervals) {
        ArrayList<Interval> res = new ArrayList<>();//定义返回的结果集
        int len = intervals.size();//子区间个数
        //判空
        if(len == 0){
            return res;
        }
        //非空,先排序
        Collections.sort(intervals,(x,y) -> x.start - y.start);
        for(int i = 1 ; i < len ; i++){
            //可以合并,更新start和end
            if(intervals.get(i).start <= intervals.get(i-1).end){
                intervals.get(i).start = Math.min(intervals.get(i-1).start,intervals.get(i).start);
                intervals.get(i).end = Math.max(intervals.get(i-1).end,intervals.get(i).end);
            }else{
                //不能合并,则返回
                res.add(intervals.get(i-1));
            }
        }
        //添加最后一个
        res.add(intervals.get(len-1));
        return res;
    }
}

5. 循环排序

这一模式描述了一种有趣的方法,处理的是涉及包含给定范围内数值的数组的问题。循环排序模式一次会在数组上迭代一个数值,如果所迭代的当前数值不在正确的索引处,就将其与其正确索引处的数值交换。你可以尝试替换其正确索引处的数值,但这会带来 O(n^2) 的复杂度,这不是最优的,因此要用循环排序模式。

Image

如何识别这种模式?

  • 涉及数值在给定范围内的排序数组的问题
  • 如果问题要求你在一个排序/旋转的数组中找到缺失值/重复值/最小值

循环排序模式的问题:

  • 找到缺失值(简单)
  • 找到最小的缺失的正数值(中等)
// 关键 insight 是:**最小的缺失正整数一定在 [1, n+1] 范围内**(n 是数组长度)
// 最优解法是利用数组本身作为哈希表,通过「原地置换」将每个正整数放到它应该在的位置
// 时间复杂度为 O (n),空间复杂度为 O (1)
public class FirstMissingPositive {
  public int firstMissingPositive(int[] nums) {
    int n = nums.length;

    // 第一步:将每个正整数放到它应该在的位置
    for (int i = 0; i < n; i++) {
      // 条件:当前数字是正整数,且在有效范围内,且目标位置的数字不是当前数字(避免死循环)
      while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
        // 交换 nums[i] 和 nums[nums[i]-1]
        int temp = nums[nums[i] - 1];
        nums[nums[i] - 1] = nums[i];
        nums[i] = temp;
      }
    }

    // 第二步:遍历数组,找到第一个不匹配的位置
    for (int i = 0; i < n; i++) {
      if (nums[i] != i + 1) {
        return i + 1;
      }
    }

    // 所有位置都匹配,说明缺失的是 n+1
    return n + 1;
  }
}

6.原地反转链表

在很多问题中,你可能会被要求反转一个链表中一组节点之间的链接。通常而言,你需要原地完成这一任务,即使用已有的节点对象且不占用额外的内存。这就是这个模式的用武之地。该模式会从一个指向链表头的变量(current)开始一次反转一个节点,然后一个变量(previous)将指向已经处理过的前一个节点。以锁步的方式,在移动到下一个节点之前将其指向前一个节点,可实现对当前节点的反转。另外,也将更新变量「previous」,使其总是指向已经处理过的前一个节点。

Image

如何识别使用该模式的时机:

  • 如果你被要求在不使用额外内存的前提下反转一个链表

原地反转链表模式的问题:

  • 反转一个子列表(中等)
//时间复杂度为 O(n), 空间复杂度为 O (1)
class ListNode {
    int val;
    ListNode next;
    ListNode(int x) { 
        val = x; 
    }
}

/**
 * 反转单链表中从第m个到第n个节点的子列表
 * @param head 链表头节点
 * @param m 起始位置(从1开始计数)
 * @param n 结束位置(从1开始计数)
 * @return 反转后的链表头节点
 */
public static ListNode reverseSublist(ListNode head, int m, int n) {
    // 处理边界情况
    if (head == null || m >= n || m < 1) {
        return head;
    }
    
    // 创建虚拟头节点,简化边界情况处理
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    
    // 找到子列表的前一个节点
    ListNode prev = dummy;
    for (int i = 1; i < m; i++) {
        prev = prev.next;
    }
    
    // 反转子列表
    ListNode current = prev.next;
    for (int i = m; i < n; i++) {
        ListNode next = current.next;
        current.next = next.next;
        next.next = prev.next;
        prev.next = next;
    }
    
    return dummy.next;
}
  • 反转每个 K 个元素的子列表(中等)
/*
### 方法思路
**虚拟头节点**:创建一个虚拟头节点(dummy node)简化边界条件处理。
**分段处理**:将链表分为若干长度为 k 的子链表,对每个子链表进行反转。
**反转子链表**:对每个子链表使用迭代方法反转,同时记录反转后的头尾节点。
**连接链表**:将反转后的子链表与前后部分连接,更新指针进行下一段处理。
时间复杂度为 O(n),空间复杂度为 O(1)
*/
class ListNode {
    int val;
    ListNode next;
    ListNode(int x) { val = x; }
}

class Solution {
    public ListNode reverseKGroup(ListNode head, int k) {
        ListNode dummy = new ListNode(0);
        dummy.next = head;
        ListNode pre = dummy;
        ListNode end = dummy;

        while (end.next != null) {
            for (int i = 0; i < k; i++) {
                end = end.next;
                if (end == null) {
                    return dummy.next;
                }
            }
            ListNode start = pre.next;
            ListNode nextStart = end.next;
            end.next = null;
            ListNode reversedHead = reverse(start);
            pre.next = reversedHead;
            start.next = nextStart;
            pre = start;
            end = pre;
        }
        return dummy.next;
    }

    private ListNode reverse(ListNode head) {
        ListNode pre = null;
        ListNode cur = head;
        while (cur != null) {
            ListNode next = cur.next;
            cur.next = pre;
            pre = cur;
            cur = next;
        }
        return pre;
    }
}

深度优先和宽度优先的区别 深度优先搜索用栈(stack)来实现 广度优先搜索使用队列(queue)来实现

深度优先遍历:对任何一个分支都深入到不能再深入为止,每个节点只能访问一次。

二叉树的深度优先分为:先序遍历(根左右)、中序遍历(左根右)、后序遍历(左右根)。

宽度优先遍历:层次遍历,从上往下对每一层进行遍历,在每一层中,从左往右(也可以从右往左)访问结点,一层一层的进行点,直到没有结点为止

深度优先搜素算法:不全部保留结点,占用空间少;有入栈、出栈操作,运行速度慢。

广度优先搜索算法:保留全部结点,占用空间大; 无入栈、出栈操作,运行速度快。

7.树的宽度优先搜索(BFS)

该模式基于宽度优先搜索(BFS)技术,可遍历一个树并使用一个队列来跟踪一个层级的所有节点,之后再跳转到下一个层级。任何涉及到以逐层级方式遍历树的问题都可以使用这种方法有效解决。

BFS 模式的工作方式是:将根节点推至队列,然后连续迭代知道队列为空。在每次迭代中,我们移除队列头部的节点并「访问」该节点。在移除了队列中的每个节点之后,我们还将其所有子节点插入到队列中。

如何识别 BFS 模式:

  • 如果你被要求以逐层级方式遍历(或按层级顺序遍历)一个树

BFS 模式的问题:

  • 二叉树层级顺序遍历(简单): 时间复杂度为 O(n),空间复杂度为 O(n)
public List<List<Integer>> levelOrder(TreeNode root) {
        if(root == null)
            return new ArrayList<>();
        List<List<Integer>> res = new ArrayList<>();
        Queue<TreeNode> queue = new LinkedList<TreeNode>();
        queue.add(root);
        while(!queue.isEmpty()){
            int count = queue.size();
            List<Integer> list = new ArrayList<Integer>();
            while(count > 0){
                TreeNode node = queue.poll();
                list.add(node.val);
                if(node.left != null)
                    queue.add(node.left);
                if(node.right != null)
                    queue.add(node.right);
                count--;
            }
            res.add(list);
        }
        return res;
    }

8.树的深度优先搜索(DFS)

DFS 是基于深度优先搜索(DFS)技术来遍历树。

你可以使用递归(或该迭代方法的技术栈)来在遍历期间保持对所有之前的(父)节点的跟踪。

Tree DFS 模式的工作方式是从树的根部开始,如果这个节点不是一个叶节点,则需要做三件事:

1.决定现在是处理当前的节点(pre-order),或是在处理两个子节点之间(in-order),还是在处理两个子节点之后(post-order)

  1. 为当前节点的两个子节点执行两次递归调用以处理它们

如何识别 Tree DFS 模式:

  • 如果你被要求用 in-order、pre-order 或 post-order DFS 来遍历一个树
    /** —————————— 中序遍历:递归 —————————— */
    public static void recursionMiddleorderTraversal(TreeNode root) {
        if (root != null) {
            recursionMiddleorderTraversal(root.left);
            //在前是前序,在中是中序,在后是后续,demo是中序遍历
            System.out.print("输出节点:" + root.val);
            recursionMiddleorderTraversal(root.right);
        }
    }
  • 如果问题需要搜索其中节点更接近叶节点的东西

DFS 模式的问题:

  • 重建二叉树
// 空间&时间复杂度Q(n)
class Solution {
    int[] preorder;
    HashMap<Integer, Integer> hmap = new HashMap<>();
    public TreeNode deduceTree(int[] preorder, int[] inorder) {
        this.preorder = preorder;
        for(int i = 0; i < inorder.length; i++)
            hmap.put(inorder[i], i);
        return recur(0, 0, inorder.length - 1);
    }
    TreeNode recur(int root, int left, int right) {
        if(left > right) return null;                          // 递归终止
        TreeNode node = new TreeNode(preorder[root]);          // 建立根节点
        int i = hmap.get(preorder[root]);                      // 划分根节点、左子树、右子树
        // -左子树的根节点在前序遍历中的位置是 `root + 1`(因为前序遍历的下一个元素就是左子树的根)。
        // -左子树在中序遍历中的范围是 `[left, i - 1]`。
        node.left = recur(root + 1, left, i - 1);              // 开启左子树递归
        // -右子树的根节点在前序遍历中的位置是 `root + i - left + 1`:
        // -`i - left` 表示左子树的长度。
        // -所以右子树的根节点在前序遍历中的位置是当前根节点位置加上左子树长度再加一。
        // -右子树在中序遍历中的范围是 `[i + 1, right]`
        node.right = recur(root + i - left + 1, i + 1, right); // 开启右子树递归
        return node;                                           // 回溯返回根节点
    }
}
  • 路径数量之和(中等)
  • 二叉树的所有路径(中等)
// 如果当前节点不是叶子节点,则在当前的路径末尾添加该节点,并继续递归遍历该节点的每一个孩子节点。
// 如果当前节点是叶子节点,则在当前路径末尾添加该节点后我们就得到了一条从根节点到叶子节点的路径,将该路径加入到答案即可。
class Solution {
    public List<String> binaryTreePaths(TreeNode root) {
        List<String> paths = new ArrayList<String>();
        constructPaths(root, "", paths);
        return paths;
    }

    public void constructPaths(TreeNode root, String path, List<String> paths) {
        if (root != null) {
            StringBuffer pathSB = new StringBuffer(path);
            pathSB.append(Integer.toString(root.val));
            if (root.left == null && root.right == null) {  // 当前节点是叶子节点
                paths.add(pathSB.toString());  // 把路径加入到答案中
            } else {
                pathSB.append("->");  // 当前节点不是叶子节点,继续递归遍历
                constructPaths(root.left, pathSB.toString(), paths);
                constructPaths(root.right, pathSB.toString(), paths);
            }
        }
    }
}

9. Two Heaps

在很多问题中,我们要将给定的一组元素分为两部分。为了求解这个问题,我们感兴趣的是了解一部分的最小元素以及另一部分的最大元素。这一模式是求解这类问题的一种有效方法。该模式要使用两个堆(heap):一个用于寻找最小元素的 Min Heap 和一个用于寻找最大元素的 Max Heap。该模式的工作方式是:先将前一半的数值存储到 Max Heap,这是由于你要寻找前一半中的最大数值。然后再将另一半存储到 Min Heap,因为你要寻找第二半的最小数值。在任何时候,当前数值列表的中间值都可以根据这两个 heap 的顶部元素计算得到。

识别 Two Heaps 模式的方法:

  • 在优先级队列、调度等场景中有用
  • 如果问题说你需要找到一个集合的最小/最大/中间元素
  • 有时候可用于具有二叉树数据结构的问题

Two Heaps 模式的问题:

  • 查找一个数值流的中间值(中等) 中位数 是有序整数列表中的中间值。如果列表的大小是偶数,则没有中间值,中位数是两个中间值的平均值。

例如,
[2,3,4] 的中位数是 3
[2,3] 的中位数是 (2 + 3) / 2 = 2.5
设计一个支持以下两种操作的数据结构:

  • void addNum(int num) - 从数据流中添加一个整数到数据结构中。
  • double findMedian() - 返回目前所有元素的中位数。

示例 1:

输入: ["MedianFinder","addNum","addNum","findMedian","addNum","findMedian"]
[[],[1],[2],[],[3],[]]
输出: [null,null,null,1.50000,null,2.00000]

示例 2:

输入: ["MedianFinder","addNum","findMedian","addNum","findMedian"]
[[],[2],[],[3],[]]
输出: [null,null,2.00000,null,2.50000]

 

提示:

  • 最多会对 addNum、findMedian 进行 50000 次调用。

算法流程: 设元素总数为 N=m+n ,其中 m 和 n 分别为 A 和 B 中的元素个数。

addNum(num) 函数:

当 m=n(即 N 为 偶数):需向 A 添加一个元素。实现方法:将新元素 num 插入至 B ,再将 B 堆顶元素插入至 A ; 当 m  =n(即 N 为 奇数):需向 B 添加一个元素。实现方法:将新元素 num 插入至 A ,再将 A 堆顶元素插入至 B ; 假设插入数字 num 遇到情况 1. 。由于 num 可能属于 “较小的一半” (即属于 B ),因此不能将 nums 直接插入至 A 。而应先将 num 插入至 B ,再将 B 堆顶元素插入至 A 。这样就可以始终保持 A 保存较大一半、 B 保存较小一半。

findMedian() 函数:

当 m=n( N 为 偶数):则中位数为 ( A 的堆顶元素 + B 的堆顶元素 )/2。 当 m  =n( N 为 奇数):则中位数为 A 的堆顶元素。

//时间O(N),空间O(1)
class MedianFinder {
  Queue<Integer> A, B;
  public MedianFinder() {
    A = new PriorityQueue<>(); // 小顶堆,保存较大的一半
    B = new PriorityQueue<>((x, y) -> (y - x)); // 大顶堆,保存较小的一半
  }
  public void addNum(int num) {
    if(A.size() != B.size()) {
      A.add(num);
      B.add(A.poll());
    } else {
      B.add(num);
      A.add(B.poll());
    }
  }
  public double findMedian() {
    return A.size() != B.size() ? A.peek() : (A.peek() + B.peek()) / 2.0;
  }
}

10. 子集

很多编程面试问题都涉及到处理给定元素集合的排列和组合。子集(Subsets)模式描述了一种用于有效处理所有这些问题的宽度优先搜索(BFS)方法。

该模式看起来是这样:

给定一个集合 [1, 5, 3]

1. 从一个空集开始:[[]]
2.向所有已有子集添加第一个数 (1),从而创造新的子集:[[], [1]]
3.向所有已有子集添加第二个数 (5):[[], [1], [5], [1,5]]
4.向所有已有子集添加第三个数 (3):[[], [1], [5], [1,5], [3], [1,3], [5,3], [1,5,3]]

下面是这种子集模式的一种视觉表示:

Image

如何识别子集模式:

  • 你需要找到给定集合的组合或排列的问题

子集模式的问题:

  • 带有重复项的子集(简单)

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的 子集(幂集)。

解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。

 

示例 1:

输入: nums = [1,2,2]
输出: [[],[1],[1,2],[1,2,2],[2],[2,2]]

示例 2:

输入: nums = [0]
输出: [[],[0]]

递归法实现子集枚举

思路

与方法一类似,在递归时,若发现没有选择上一个数,且当前数字与上一个数相同,则可以跳过当前生成的子集。

时间复杂度:O(n×2n)
空间复杂度:O(n)

class Solution {
  public List<List<Integer>> subsetsWithDup(int[] nums) {
    Arrays.sort(nums);
    List<List<Integer>> ans = new ArrayList<>();
    dfs(0, nums, new ArrayList<>(), ans);
    return ans;
  }

  public void dfs(int i, int[] nums, List<Integer> path, List<List<Integer>> ans) {
    if (i == nums.length) {
      ans.add(new ArrayList<>(path));
      return;
    }

    path.add(nums[i]);
    dfs(i + 1, nums, path, ans);
    path.remove(path.size() - 1);

    // 不选就跳过后面一样的数,只需要用【78. 子集】的代码加这个 while 就搞定了!
    while (i + 1 < nums.length && nums[i + 1] == nums[i]) {
      i++;
    }

    dfs(i + 1, nums, path, ans);
  }
}
  • 通过改变大小写的字符串排列(中等) 给定一个字符串 s ,通过将字符串 s 中的每个字母转变大小写,我们可以获得一个新的字符串。

返回 所有可能得到的字符串集合 。以 任意顺序 返回输出。

 

示例 1:

输入: s = "a1b2"
输出: ["a1b2", "a1B2", "A1b2", "A1B2"]

示例 2:

输入: s = "3z4"
输出: ["3z4","3Z4"]

 

提示:

  • 1 <= s.length <= 12
  • s 由小写英文字母、大写英文字母和数字组成

思路分析:

这一类搜索问题是在一个隐式的树上进行的搜索问题,即「树形问题」。解决这一类问题, 先画出递归树是十分重要的,可以帮助打开思路 ,然后看着图形把代码写出来; 这个问题所求的解,是这棵树的叶子结点上的值。因此,可以使用深度优先遍历,收集 所有 叶子结点的值,深度优先遍历用于搜索也叫回溯算法; 回溯算法因为有回头的过程,因此其显著特征是 状态重置。回溯算法的入门问题是「力扣」第 46 题:全排列)。

import java.util.ArrayList;
import java.util.List;

public class Solution {

  public List<String> letterCasePermutation(String S) {
    List<String> res = new ArrayList<>();
    char[] charArray = S.toCharArray();
    dfs(charArray, 0, res);
    return res;
  }

  private void dfs(char[] charArray, int index, List<String> res) {
    if (index == charArray.length) {
      res.add(new String(charArray));
      return;
    }

    dfs(charArray, index + 1, res);
    if (Character.isLetter(charArray[index])) {
      // 小写转大写 即 charArray[index] ^= 1 << 5 = charArray[index] ^= 32
      charArray[index] ^= 32;
      dfs(charArray, index + 1, res);
    }
  }
}

11. 经过修改的二叉搜索

只要给定了排序数组、链表或矩阵,并要求寻找一个特定元素,你可以使用的最佳算法就是二叉搜索。这一模式描述了一种用于处理所有涉及二叉搜索的问题的有效方法。

对于一个升序的集合,该模式看起来是这样的:

1.首先,找到起点和终点的中间位置。寻找中间位置的一种简单方法是:middle = (start + end) / 2。但这很有可能造成整数溢出,所以推荐你这样表示中间位置:middle = start + (end — start) / 2。
2.如果键值(key)等于中间索引处的值,那么返回这个中间位置。
3.如果键值不等于中间索引处的值:
4.检查 key < arr[middle] 是否成立。如果成立,将搜索约简到 end = middle — 15.检查 key > arr[middle] 是否成立。如果成立,将搜索约简到 end = middle + 1

下面给出了这种经过修改的二叉搜索模式的视觉表示:

Image

经过修改的二叉搜索模式的问题:

  • 与顺序无关的二叉搜索(简单)
  • 在经过排序的无限数组中搜索(中等)
/*
*/

\

12. 前 K 个元素

任何要求我们找到一个给定集合中前面的/最小的/最常出现的 K 的元素的问题都在这一模式的范围内。

跟踪 K 个元素的最佳的数据结构是 Heap。这一模式会使用 Heap 来求解多个一次性处理一个给定元素集中 K 个元素的问题。该模式是这样工作的:

1. 根据问题的不同,将 K 个元素插入到 min-heap 或 max-heap 中
2.迭代处理剩余的数,如果你找到一个比 heap 中数更大的数,那么就移除那个数并插入这个更大的数

Image

这里无需排序算法,因为 heap 将为你跟踪这些元素。

如何识别前 K 个元素模式:

  • 如果你被要求寻找一个给定集合中前面的/最小的/最常出现的 K 的元素
  • 如果你被要求对一个数值进行排序以找到一个确定元素

前 K 个元素模式的问题:

  • 前面的 K 个数(简单)
/*
     * 用最大堆找出数组中最小的 K 个元素
     * @param arr 输入的整数数组
     * @param k 需要返回的元素个数
     * @return 包含最小 K 个元素的数组
     */
    public int[] getKSmallestNumbers(int[] arr, int k) {
        // 边界条件处理
        if (arr == null || arr.length == 0 || k <= 0) {
            return new int[0];
        }
        if (k >= arr.length) {
            return Arrays.copyOf(arr, arr.length);
        }

        // 初始化最大堆(容量为 K,堆顶是当前 K 个元素中的最大值)
        PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);

        // 遍历数组,维护堆的大小为 K
        for (int num : arr) {
            if (maxHeap.size() < k) {
                // 堆未满,直接加入
                maxHeap.offer(num);
            } else {
                // 堆已满,若当前元素比堆顶小,则替换堆顶(保证堆中是更小的元素)
                if (num < maxHeap.peek()) {
                    maxHeap.poll(); // 移除堆顶(最大元素)
                    maxHeap.offer(num); // 加入当前更小的元素
                }
            }
        }

        // 将堆中元素转换为结果数组
        int[] result = new int[k];
        for (int i = 0; i < k; i++) {
            result[i] = maxHeap.poll();
        }
        return result;
    }
  • 最常出现的 K 个数(中等)

13. K 路合并

K 路合并能帮助你求解涉及一组经过排序的数组的问题。

当你被给出了 K 个经过排序的数组时,你可以使用 Heap 来有效地执行所有数组的所有元素的排序遍历。你可以将每个数组的最小元素推送至 Min Heap 以获得整体最小值。在获得了整体最小值后,将来自同一个数组的下一个元素推送至 heap。然后,重复这一过程以得到所有元素的排序遍历结果。

Image

该模式看起来像这样:

1.将每个数组的第一个元素插入 Min Heap
2.之后,从该 Heap 取出最小(顶部的)元素,将其加入到合并的列表。
3.在从 Heap 移除了最小的元素之后,将同一列表的下一个元素插入该 Heap
4.重复步骤 2 和 3,以排序的顺序填充合并的列表

如何识别 K 路合并模式:

  • 具有排序数组、列表或矩阵的问题
  • 如果问题要求你合并排序的列表,找到一个排序列表中的最小元素

K 路合并模式的问题:

  • 合并 K 个排序的列表(中等)
import java.util.PriorityQueue;

public class MergeKLists {
    // 链表节点定义
    public static class ListNode {
        int val;
        ListNode next;
        ListNode() {}
        ListNode(int val) { this.val = val; }
        ListNode(int val, ListNode next) { this.val = val; this.next = next; }
    }

    public ListNode mergeKLists(ListNode[] lists) {
        // 边界条件:空输入直接返回 null
        if (lists == null || lists.length == 0) {
            return null;
        }

        // 初始化最小堆,比较器按节点值从小到大排序
        PriorityQueue<ListNode> minHeap = new PriorityQueue<>((a, b) -> a.val - b.val);

        // 将所有非空链表的头节点加入堆
        for (ListNode node : lists) {
            if (node != null) {
                minHeap.add(node);
            }
        }

        // 构建结果链表(dummy 节点简化边界处理)
        ListNode dummy = new ListNode(0);
        ListNode current = dummy;

        // 循环取出堆中最小节点,构建结果
        while (!minHeap.isEmpty()) {
            ListNode minNode = minHeap.poll(); // 取出当前最小节点
            current.next = minNode; // 接入结果链表
            current = current.next; // 移动指针

            // 将当前节点的下一个节点加入堆(若存在)
            if (minNode.next != null) {
                minHeap.add(minNode.next);
            }
        }

        return dummy.next; // dummy 节点的 next 即为合并后的头节点
    }
}
  • 找到和最大的 K 个配对(困难)

\

\

14. 拓扑排序

拓扑排序可用于寻找互相依赖的元素的线性顺序。比如,如果事件 B 依赖于事件 A,那么 A 在拓扑排序时位于 B 之前。

这个模式定义了一种简单方法来理解执行一组元素的拓扑排序的技术。

该模式看起来是这样的:

1.初始化。a)使用 HashMap 将图(graph)存储到邻接的列表中;b)为了查找所有源,使用 HashMap 记录 in-degree 的数量
2.构建图并找到所有顶点的 in-degree。a)根据输入构建图并填充 in-degree HashMap
3.寻找所有的源。a)所有 in-degree 为 0 的顶点都是源,并会被存入一个队列
4.排序。a)对于每个源,执行以下操作:i)将其加入到排序的列表;ii)根据图获取其所有子节点;iii)将每个子节点的 in-degree 减少 1;iv)如果一个子节点的 in-degree 变为 0,将其加入到源队列。b)重复 (a),直到源队列为空。

Image

如何识别拓扑排序模式:

  • 处理无向有环图的问题
  • 如果你被要求以排序顺序更新所有对象
  • 如果你有一类遵循特定顺序的对象

拓扑排序模式的问题:

  • 任务调度(中等)
  • 一个树的最小高度

Kahn算法实现拓扑排序

算法步骤

  1. 初始化:计算每个顶点的入度,并将所有入度为0的顶点加入队列。
  2. 处理队列:从队列中取出顶点,加入拓扑排序结果,并减少其邻接顶点的入度。若邻接顶点的入度变为0,则将其加入队列。
  3. 检测环:若拓扑排序结果中的顶点数不等于图中的顶点数,则图中存在环。

Java代码实现

import java.util.*;

public class TopologicalSortKahn {
    public static List<Integer> topologicalSort(int numVertices, List<List<Integer>> edges) {
        List<Integer> result = new ArrayList<>();
        int[] inDegree = new int[numVertices];
        Map<Integer, List<Integer>> graph = new HashMap<>();

        // 初始化图和入度
        for (int i = 0; i < numVertices; i++) {
            graph.put(i, new ArrayList<>());
        }
        for (List<Integer> edge : edges) {
            int from = edge.get(0);
            int to = edge.get(1);
            graph.get(from).add(to);
            inDegree[to]++;
        }

        // 将所有入度为0的顶点加入队列
        Queue<Integer> queue = new LinkedList<>();
        for (int i = 0; i < numVertices; i++) {
            if (inDegree[i] == 0) {
                queue.add(i);
            }
        }

        // 处理队列中的顶点
        while (!queue.isEmpty()) {
            int current = queue.poll();
            result.add(current);
            for (int neighbor : graph.get(current)) {
                inDegree[neighbor]--;
                if (inDegree[neighbor] == 0) {
                    queue.add(neighbor);
                }
            }
        }

        // 如果处理的顶点数不等于图中的顶点数,则图中存在环
        if (result.size() != numVertices) {
            throw new RuntimeException("The graph has a cycle!");
        }

        return result;
    }

    public static void main(String[] args) {
        int numVertices = 6;
        List<List<Integer>> edges = Arrays.asList(
            Arrays.asList(5, 2),
            Arrays.asList(5, 0),
            Arrays.asList(4, 0),
            Arrays.asList(4, 1),
            Arrays.asList(2, 3),
            Arrays.asList(3, 1)
        );
        System.out.println("Topological Sort (Kahn's Algorithm): " + topologicalSort(numVertices, edges));
    }
}