数据结构与算法(Ⅵ):分治、回溯

分治(divide and conquer)

分治的核心思想是分而治之,即将原问题分解成 n 个规模较小,并且结构与原问题相似的子问题,递归的解决这些子问题,然后合并结果就得到原问题的解。

从定义看,分治有些类似递归,但区别在于,分治算法是一种处理问题的思想,而递归是一种编程技巧。实际上,分治算法一般都适合用递归实现,分治算法中的递归实现中,每一层递归都会涉及以下三个操作:

  1. 分解:将原问题分解成一系列子问题
  2. 解决:递归的求解各个子问题,若问题足够小则直接求解
  3. 合并:将子问题的结果合并成原问题的结果

分治算法能解决的问题一般需要满足以下几个条件:

  • 原问题与分解成的小问题具有相同的模式
  • 原问题分解成的子问题可以独立求解,子问题之间没有相关性
  • 具有分解终止条件,也就是说,当问题足够小时,可以直接求解
  • 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果

分治代码模板

  • terminator
  • process(split your big problem)
  • drill down (subproblmes)
  • merge(subresult)
  • reverse states
public void divide_conquer(Problem problem, String... params) {
    // 终止条件 recursion terminator
    if (problem == null) {
        // process_result
        return;
    } 

    // 准备数据、问题拆分 prepare data
    data = prepare_data(problem) ;
    subproblems = split_problem(problem, data);
    
    // 下探下一层,分别处理子问题 conquer subproblems
    subresult1 = divide_conquer(subproblem[0], p1);
    subresult2 = divide_conquer(subproblem[1], p1);
    subresult3 = divide_conquer(subproblem[2], p1);

    // 结果合并 process and generate the final result
    result = process_result(subresult1, subresult2, subresult3, …);

    // 清理当前层 revert the current level status
}

回溯(backtracking)

回溯的处理思想,有点类似枚举搜索。枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。

回溯法通常采用最简单的递归方法实现,在反复重复以上步骤枚举解后可能出现两种情况:

  • 找到一个可能存在的正确答案
  • 在尝试了所有可能的分步方法后宣告该问题没有解

在最坏情况下,回溯法会导致一次复杂度为指数时间的计算。

典型应用:

  • 八皇后问题
  • 0-1 背包
  • 正则表达式

LeetCode实战

50. Pow(x, n)

实现 pow(x, n) ,即计算 x 的 n 次幂函数。

示例 1:

输入: 2.00000, 10
输出: 1024.00000

示例 2:

输入: 2.10000, 3
输出: 9.26100

示例 3:

输入: 2.00000, -2
输出: 0.25000
解释: 2-2 = 1/22 = 1/4 = 0.25

说明:

  • -100.0 < x < 100.0
  • n 是 32 位有符号整数,其数值范围是 [−2^31, 2^31 − 1] 。

题解:

「快速幂算法」的本质是分治算法。举个例子,要计算 x^64,我们可以按照以下顺序:

x -> x^2 -> x^4 -> x^8 -> x^16 -> x^32 -> x^64

// 1. 快速幂 + 递归 
// 时间复杂度:O(log n),即为递归的层数; 
// 空间复杂度:O(logn)
class Solution {
    public double quickMul(double x, long N) {
        if (N == 0) {
            return 1.0;
        }
        double y = quickMul(x, N / 2);
        return N % 2 == 0 ? y * y : y * y * x;
    }

    public double myPow(double x, int n) {
        long N = n;
        return N >= 0 ? quickMul(x, N) : 1.0 / quickMul(x, -N);
    }
}

// 2. 快速幂 + 迭代
// 时间复杂度:O(logn),即为对 n 进行二进制拆分的时间复杂度。
// 空间复杂度:O(1)。
class Solution {
    double quickMul(double x, long N) {
        double ans = 1.0;
        // 贡献的初始值为 x
        double x_contribute = x;
        // 在对 N 进行二进制拆分的同时计算答案
        while (N > 0) {
            if (N % 2 == 1) {
                // 如果 N 二进制表示的最低位为 1,那么需要计入贡献
                ans *= x_contribute;
            }
            // 将贡献不断地平方
            x_contribute *= x_contribute;
            // 舍弃 N 二进制表示的最低位,这样我们每次只要判断最低位即可
            N /= 2;
        }
        return ans;
    }

    public double myPow(double x, int n) {
        long N = n;
        return N >= 0 ? quickMul(x, N) : 1.0 / quickMul(x, -N);
    }
}

78. 子集

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

说明:解集不能包含重复的子集。

示例:

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

递归:

开始假设输出子集为空,每一步都向子集添加新的整数,并生成新的子集。

image

回溯:

根据定义,该问题可以看作是从序列中生成幂集。遍历 子集长度,通过 回溯 生成所有给定长度的子集。

image

image

// 1. 递归
// 时间复杂度:O(N * 2^N)
// 空间复杂度:O(N * 2^N)
class Solution {
  public List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> output = new ArrayList();
    output.add(new ArrayList<Integer>());

    for (int num : nums) {
      List<List<Integer>> newSubsets = new ArrayList();
      for (List<Integer> curr : output) {
        newSubsets.add(new ArrayList<Integer>(curr){{add(num);}});
      }
      for (List<Integer> curr : newSubsets) {
        output.add(curr);
      }
    }
    return output;
  }
}

// 2. 回溯
// 时间复杂度:O(N * 2^N)
// 空间复杂度:O(N * 2^N)
class Solution {
  List<List<Integer>> output = new ArrayList();
  int n, k;

  public void backtrack(int first, ArrayList<Integer> curr, int[] nums) {
    // if the combination is done
    if (curr.size() == k)
      output.add(new ArrayList(curr));

    for (int i = first; i < n; ++i) {
      // add i into the current combination
      curr.add(nums[i]);
      // use next integers to complete the combination
      backtrack(i + 1, curr, nums);
      // backtrack
      curr.remove(curr.size() - 1);
    }
  }

  public List<List<Integer>> subsets(int[] nums) {
    n = nums.length;
    for (k = 0; k < n + 1; ++k) {
      backtrack(0, new ArrayList<Integer>(), nums);
    }
    return output;
  }
}

参考文献:

  • 数据结构与算法之美-王争
  • 算法训练营-覃超
  • LeetCode
posted @ 2020-09-19 01:01  天上的白云贼白了  阅读(267)  评论(0编辑  收藏  举报