经典算法题总结:回溯,动态规划篇

回溯

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

    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择

46. 全排列(⭐️⭐️)

思路

代码

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

public class Permutations {
    private List<List<Integer>> res = new ArrayList<>();

    public List<List<Integer>> permute(int[] nums) {
        backtrack(nums, new LinkedList<Integer>(), new boolean[nums.length]);
        return res;
    }
    
    private void backtrack(int[] nums, LinkedList<Integer> track, boolean[] used) {
        if (track.size() == nums.length) {
            res.add(new LinkedList<>(track));
        }
        for (int i = 0; i < nums.length; i++) {
            if (used[i]) {
                continue;
            }
            used[i] = true;
            track.add(nums[i]);
            backtrack(nums, track, used);
            track.removeLast();
            used[i] = false;
        }
    }

}

复杂度

93. 复原IP地址(⭐️⭐️)

思路

代码

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

public class RestoreIPAddresses {
    private List<String> res = new ArrayList<>(); // 所有可能的有效 IP 地址
    private List<String> path = new ArrayList<>(); // 临时 IP 地址

    public List<String> restoreIPAddresses(String s) {
        if (s.length() < 4 || s.length() > 12) {
            return res;
        }
        backtrack(s, 0, 0);
        return res;
    }

    /**
     * 回溯复原 IP 地址
     * @param s: 只包含数字的字符串
     * @param splitIndex: 上一段 IP 分割点
     * @param level: 回溯的第几段 IP 地址
     */
    private void backtrack(String s, int splitIndex, int level) {
        if (level > 3) {
            res.add(String.join(".", path));
            return;
        }
        for (int i = splitIndex; i < s.length(); i++) {
            if ((s.length() - i - 1) > 3 * (3 - level)) {
                continue;
            }
            if (!isValidIP(s.substring(splitIndex, i + 1))) {
                continue;
            }
            path.add(s.substring(splitIndex, i + 1));
            backtrack(s, i + 1, level + 1);
            path.remove(path.size() - 1);
        }
    }

    private boolean isValidIP(String s) {
        if (s.charAt(0) == '0' && s.length() > 1) {
            return false;
        }
        if (s.length() > 3) {
            return false;
        }
        if (Integer.parseInt(s) > 255) {
            return false;
        }
        return true;
    }

}

复杂度

22. 括号生成(⭐️⭐️)

思路

代码

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

public class GenerateParentheses {

    public List<String> generateParentheses(int n) {
        List<String> res = new ArrayList<>();
        StringBuilder track = new StringBuilder();
        if (n == 0) {
            return res;
        }
        backtrack(n, n, track, res);
        return res;
    }

    private void backtrack(int left, int right, StringBuilder track, List<String> res) {
        if (right < left) {
            return;
        }
        if (left < 0 || right < 0) {
            return;
        }
        if (left == 0 && right == 0) {
            res.add(track.toString());
            return;
        }
        track.append('(');
        backtrack(left - 1, right, track, res);
        track.deleteCharAt(track.length() - 1);

        track.append(')');
        backtrack(left, right - 1, track, res);
        track.deleteCharAt(track.length() - 1);
    }

}

复杂度

78. 子集(⭐️⭐️)

思路

代码

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

public class Subsets {
    private List<List<Integer>> res = new LinkedList<>();

    public List<List<Integer>> subsets(int[] nums) {
        List<Integer> track = new ArrayList<>();
        backtrack(nums, 0, track);
        return res;
    }

    private void backtrack(int[] nums, int start, List<Integer> track) {
        res.add(new ArrayList<>(track));
        for (int i = start; i < nums.length; i++) {
            track.add(nums[i]); // 做出选择
            backtrack(nums, i + 1, track); // 回溯
            track.remove(track.size() - 1); // 撤销选择
        }
    }

}

复杂度

动态规划

动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解为更小的子问题来解决问题的算法设计方法。与分治法类似,动态规划也将问题分解,但不同的是,动态规划适用于那些子问题重叠的情况,即同一子问题会被多次计算。通过记忆化技术(Memoization)或自底向上(Bottom-Up)的方式,动态规划可以显著减少计算量。

动态规划的两个特征:最优子结构,原问题的最优解是从子问题的最优解构建得来的。无后效性,给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关。

动态规划的基本思想是:

  1. 将问题分解为子问题:将原问题分解成一系列子问题,并且子问题之间具有重叠性质。
  2. 记忆化或表格法:保存每个子问题的结果,以避免重复计算。这可以通过递归加上记忆化(记忆已经计算过的结果)或通过自底向上填表来实现。
  3. 构造最优解:从已知的子问题结果构造出原问题的解。

动态规划的步骤:

  1. 定义状态:定义一个数组来保存子问题的结果,数组的每个元素表示一个子问题的解。
  2. 设定初始状态:初始化数组的初始状态,即最基本的子问题的解。
  3. 状态转移方程:找到子问题与原问题之间的关系,建立状态转移方程。
  4. 计算最终结果:利用状态转移方程,从初始状态逐步计算,直到得到原问题的解。

首先,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,需要你熟练掌握递归思维,只有列出正确的「状态转移方程」,才能正确地穷举。而且,你需要判断算法问题是否具备「最优子结构」,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题存在「重叠子问题」,如果暴力穷举的话效率会很低,所以需要你使用「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义

按上面的套路走,最后的解法代码就会是如下的框架:

# 自顶向下递归的动态规划
def dp(状态1, 状态2, ...):
    for 选择 in 所有可能的选择:
        # 此时的状态已经因为做了选择而改变
        result = 求最值(result, dp(状态1, 状态2, ...))
    return result


# 自底向上迭代的动态规划
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 求最值(选择1,选择2...)

53. 最大子数组和(⭐️⭐️)

思路

dp 数组的含义:nums[i] 为结尾的「最大子数组和」为 dp[i]dp[i] 有两种「选择」,要么与前面的相邻子数组连接,形成一个和更大的子数组;要么不与前面的子数组连接,自成一派,自己作为一个子数组。

代码

public class MaxSubArray {

    public int maxSubArray(int[] nums) {
        int n = nums.length;
        if (n == 0) {
            return 0;
        }
        int[] dp = new int[n];
        dp[0] = nums[0];
        for (int i = 1; i < n; i++) {
            dp[i] = Math.max(nums[i], nums[i] + dp[i - 1]);
        }
        int res = Integer.MIN_VALUE;
        for (int i = 0; i < n; i++) {
            res = Math.max(res, dp[i]);
        }
        return res;
    }

}

复杂度

  • 时间复杂度:O(N)
  • 空间复杂度:O(N)

70. 爬楼梯

思路

递归 -> 带备忘录的递归 -> 迭代 -> 矩阵快速幂 -> 通项公式。

代码

/**
 * 普通递归解斐波那契数列
 */
public class Fib1 {

    public int fib(int n) {
        if (n == 1 || n == 2) {
            return 1;
        }
        return fib(n - 1) + fib(n - 2);
    }

}


/**
 * 带备忘录的递归解斐波那契数列
 */
public class Fib2 {

    public int fib(int n) {
        int[] memo = new int[n + 1];
        return dp(memo, n);
    }

    private int dp(int[] memo, int n) {
        if (n == 0 || n == 1) {
            return n;
        }
        if (memo[0] != 0) {
            return memo[n];
        }
        memo[n] = dp(memo, n - 1) + dp(memo, n - 2);
        return memo[n];
    }

}


/**
 * dp 数组的迭代(递推)解斐波那契数列
 */
public class Fib3 {

    public int fib(int n) {
        if (n == 0) {
            return 0;
        }
        int[] dp = new int[n + 1];
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }

}


/**
 * dp 数组的迭代(递推)解斐波那契数列
 */
public class Fib4 {

    public int fib(int n) {
        if (n == 0 || n == 1) {
            return n;
        }
        int dp_i_1 = 1;
        int dp_i_2 = 0;
        for (int i = 2; i <= n; i++) {
            int dp_i = dp_i_1 + dp_i_2;
            dp_i_2 = dp_i_1;
            dp_i_1 = dp_i;
        }
        return dp_i_1;
    }

}

复杂度

  • 时间复杂度:O(N)
  • 空间复杂度:O(N) -> O(1)

322. 零钱兑换

思路

1、确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount

2、确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。

3、明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。

代码

/**
 * 自顶向下进行递归解凑零钱问题
 */
public class CoinChange1 {

    public int coinChange(int[] coins, int amount) {
        return dp(coins, amount);
    }

    private int dp(int[] coins, int amount) {
        if (amount == 0) {
            return 0;
        }
        if (amount < 0) {
            return -1;
        }
        int res = Integer.MIN_VALUE;
        for (int coin : coins) {
            int subProblem = dp(coins, amount - coin); // 子问题最少需要多少硬币
            if (subProblem == -1) {
                continue;
            }
            res = Math.min(res, subProblem + 1);
        }
        return res == Integer.MAX_VALUE ? -1 : res;
    }

}


import java.util.Arrays;

/**
 * 带备忘录的递归解凑零钱问题
 */
public class CoinChange2 {
    private int[] memo;

    public int coinChange(int[] coins, int amount) {
        memo = new int[amount + 1];
        Arrays.fill(memo, Integer.MIN_VALUE);
        return dp(coins, amount);
    }

    private int dp(int[] coins, int amount) {
        if (amount == 0) {
            return 0;
        }
        if (amount < 0) {
            return -1;
        }
        if (memo[amount] != Integer.MIN_VALUE) {
            return memo[amount];
        }
        int res = Integer.MAX_VALUE;
        for (int coin : coins) {
            int subProblem = dp(coins, amount - coin);
            if (subProblem == -1) {
                continue;
            }
            res = Math.min(res, subProblem + 1);
        }
        memo[amount] = (res == Integer.MAX_VALUE) ? -1 : res;
        return memo[amount];
    }

}


/**
 * dp 数组的迭代解凑零钱问题
 */
import java.util.Arrays;

public class CoinChange3 {

    public int coinChange(int[] coins, int amount) {
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, Integer.MAX_VALUE);
        dp[0] = 0;
        // i 为目标金额
        for (int i = 0; i < dp.length; i++) {
            for (int coin : coins) {
                if (i - coin < 0) {
                    continue;
                }
                dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
            }
        }
        return dp[amount] == Integer.MIN_VALUE ? -1 : dp[amount];
    }

}

复杂度

  • 时间复杂度:O(N)
  • 空间复杂度:O(N)
  • 28
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值