xiedaxia1hao / leetcode_pattern

1 stars 0 forks source link

Permutations & Combinations (Subsets) #3

Open xiedaxia1hao opened 3 years ago

xiedaxia1hao commented 3 years ago

78. Subsets 90. Subsets II 46. Permutations 784. Letter Case Permutation 22. Generate Parentheses LintCode. Generalized Abbreviation 320. Generalized Abbreviation 241. Different Ways to Add Parentheses 95. Unique Binary Search Trees II 96. Unique Binary Search Trees

刷题的过程中,我们经常会碰见排列组合的题目。

通常情况下,我们可以用BFS或者backtrack来解决这类问题。

xiedaxia1hao commented 3 years ago

模版题: 78. Subsets

Given an integer array nums of unique elements, return all possible subsets (the power set). The solution set must not contain duplicate subsets. Return the solution in any order.

image

这种要求我们求子集的题目,又是return all possible subsets,我第一反应就是要用backtrack来做,因为backtrack的特点就是穷尽所有可能的情况来找到满足条件的解。

这题还有一种思路,就是用BFS来做,在实现上会比backtrack稍微简单点,也更直观一点。这一题我会先用BFS来做一遍,然后再提供backtrack的解法。

那这题如何用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]]

因为输入的集合的数字都是unique的,这种解法不会产生重复解。

我们可以用下图来可视化这个过程:

image

代码如下:

class Solution {
    public List<List<Integer>> subsets(int[] nums) {

        List<List<Integer>> subsets = new ArrayList<>();
        subsets.add(new ArrayList<>());

        for(int num : nums) {
            int size = subsets.size();
            for(int i = 0; i < size; i++) {
                List<Integer> set = new ArrayList<>(subsets.get(i));
                set.add(num);
                subsets.add(new ArrayList<>(set));
            }
        }

        return new ArrayList<>(subsets);
    }
}

要注意的是,这里如果我们把subsets这个变量定义成Queue的话,也就是LinkedList,在subsets.get(i)这个操作会花费O(n)的时间,而ArrayList的话,subsets.get(i)仅花费O(1)的时间。所以我们可以把subsets定义成ArrayList而不是LinkedList。

时间复杂度:O(N 2 ^ N), N代表set里面存的数字个数,我们一共会有2^N的集合,所以复杂度为O(N 2 ^ N)。 空间复杂度:O(N 2 ^ N),我们一共有2^N个subset,每一个subset最多可以占用O(N)的空间,所以复杂度为O(N 2 ^ N)。


Follow-up 90. Subsets II

上面这题的input是没有重复数字的,那如果input里含有重复数字了,并且要求outpu不能有重复的subsets,我们应该怎么做呢?

Given an integer array nums that may contain duplicates, return all possible subsets (the power set). The solution set must not contain duplicate subsets. Return the solution in any order.

image

首先比较直接的能想到的是,我们需要把input set给sort一下,这样我们可以保证所有的重复数字都是sit next to each other。

那如果我们碰见了重复数字以后,该怎么办呢?

让我们用 [1, 5, 3, 3] 来举个例子:

  1. 该数组sort以后,我们得到: [1, 3, 3, 5]
  2. 先创建一个空集合,加入到我们的队列里: [[]]
  3. 把第一个数字(1)加到该队列的所有子集合里:[[], [1]]
  4. 把第二个数字(3)加到该队列的所有子集合里:[[], [1], [3], [1,3]]
  5. 下一个数字(3)是重复的数字,如果我们把它加到所有的existing subsets里,我们会得到:[[], [1], [3], [1,3], [3], [1,3], [1, 3, 3], [3, 3]]
  6. 我们发现我们新加的subsets中,[3]和[1,3]是重复的set,我们只需要[1,3,3]和[3,3]。
  7. 解决的方法很简单,我们只要把这个重复的数字加到上一轮中生成的subsets里就行了:
  8. 上一轮中生成的subsets是[3], [1,3],所以加入我们这个重复的3后,这一轮我们新生成[3,3], [1, 3, 3],再加入到总的集合里就行:[[], [1], [3], [1,3], [3,3], [1, 3, 3]]

下图是以上步骤的可视化操作:

image

所以我们只需要用两个新的变量,startIndexendIndex,来控制我们的循环区间就可以了。代码如下:

class Solution {
    public List<List<Integer>> subsetsWithDup(int[] nums) {

        List<List<Integer>> subsets = new ArrayList<>();
        if(nums == null || nums.length < 1) return subsets;

        Arrays.sort(nums);
        subsets.add(new ArrayList<>());
        int startIndex = 0, endIndex = 0;

        for(int i = 0; i < nums.length; i++) {
            startIndex = 0;

            // if current and the previous elements are same, create new subsets only from the subsets 
            //   added in the previous step
            if(i > 0 && nums[i] == nums[i-1]) {
                startIndex = endIndex+1;
            }
            endIndex = subsets.size()-1;

            for(int j = startIndex; j <= endIndex; j++) {
                // DO NOT DO: List<Integer> set = subsets.get(j)
                //   Java is passed by reference and when we modify this set in the following code, 
                //   the set in the result list will also be affected.
                List<Integer> set = new ArrayList<>(subsets.get(j));
                set.add(nums[i]);
                subsets.add(set);
            }
        }

        return subsets;
    }
}

时间复杂度:O(N * 2 ^ N)*, N代表set里面存的数字个数,我们一共会有2^N的集合,所以复杂度为O(N 2 ^ N)。 空间复杂度:O(N * 2 ^ N)*,我们一共有2^N个subset,每一个subset最多可以占用O(N)的空间,所以复杂度为O(N 2 ^ N)。

xiedaxia1hao commented 3 years ago

模版题: 46. Permutations

Given an array nums of distinct integers, return all the possible permutations. You can return the answer in any order.

image

之前我们的求subsets的时候,每个subset的长度在[0, nums.length]之间都是可以的,但是求permutations的时候,每个permutation一定要包含所有的数字才行,那这种情况下,我们应该怎么做呢?

我们用数组[1, 3, 5]来举个例子:

  1. 首先我们建一个空的数组: []
  2. 我们加入第一个数字(1):[1]
  3. 我们加入第二个数字(3): [3, 1], [1, 3]
  4. 我们加入第三个数字(5):[5, 3, 1], [3, 5, 1], [3, 1, 5], [5, 1, 3], [1, 5, 3], [1, 3, 5]

我们来具体看一下如何从第三步走向第四步的:

我们先看如何把数字5加入到[3, 1]来生成它的全排列。我们把当前数字5,分别加到[3, 1]的最前面,中间,和最后面,即:

  1. 把5插入到3前面:[5, 3, 1]
  2. 把5插入到1和3中间:[3, 5, 1]
  3. 把5插入到最后面: [3, 1, 5]

所以我们每要加一个数字的时候,都对上一轮所有生成的全排列依次循环,在每个排列的所有位置分别加入该数字。等我们加完最后一个数字,就得到了我们要的结果了。

以下为上述过程的示意图:

image

代码实现如下:

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        Queue<List<Integer>> permutations = new LinkedList<>();
        permutations.offer(new ArrayList<>());

        for(int num : nums) {
            int size = permutations.size();
            for(int i = 0; i < size; i++) {
                List<Integer> oldPermutation = permutations.poll();
                for(int j = 0; j <= oldPermutation.size(); j++) {
                    List<Integer> newPermutation = new ArrayList<>(oldPermutation);
                    newPermutation.add(j, num);
                    permutations.offer(newPermutation);
                }
            }
        }

        return new ArrayList<>(permutations);
    }
}

时间复杂度:O(N * N!)*, 其中N代表input里的数字总和。我们一共会有N!个全排列,每个全排列,最多会有N个可以插入数字的节点,所以复杂度为O(N N!)。 空间复杂度:O(N * N!)**,我们一共有N!全排列,每个全排列,最多可以保存N个数字。


字母+数字的Permutation: 784. Letter Case Permutation

Given a string s, we can transform every letter individually to be lowercase or uppercase to create another string. Return a list of all possible strings we could create. You can return the output in any order.

image

这题我第一个想到的是用subsets的bfs的方式来做,代码会附在最后。

Grokking. 上有一个有意思的解法,我们一起来用"ab7c"这个例子来看看:

  1. 我们先从原本的string "ab7c"出发
  2. 第一个字符是('a'),把字母经过大小写转换后,我们可以得到2个新的permutations:"ab7c", "Ab7c"
  3. 第二个字符是('b'),把字母经过大小写转换后,我们可以得到4个新的permutations:"ab7c", "Ab7c", "aB7c", "AB7c"
  4. 第三个字符是数字,直接跳过
  5. 第四个字符是('c'),把字母经过大小写转换后,我们可以得到8个新的permutations: "ab7c", "Ab7c", "aB7c", "AB7c", "ab7C", "Ab7C", "aB7C", "AB7C"

同样的,我们来看看如何从第三步跳到第五步的:

在第五步中,我们先把第三步的所有permutations copy下来,然后把对应的'c'转换成'C',加入到最后的结果里。

下图是上述过程的可视化:

image

代码如下:

class Solution {
    public List<String> letterCasePermutation(String s) {
        List<String> permutations = new ArrayList<>();
        permutations.add(s);

        for(int i = 0; i < s.length(); i++) {
            if(Character.isLetter(s.charAt(i))) {
                int size = permutations.size();
                for(int j = 0; j < size; j++) {
                    char[] chrs = permutations.get(j).toCharArray();
                    if(Character.isUpperCase(chrs[i])) {
                        chrs[i] = Character.toLowerCase(chrs[i]);
                    } else {
                        chrs[i] = Character.toUpperCase(chrs[i]);
                    }
                    permutations.add(new String(chrs));
                }
            }
        }

        return permutations;
    }
}

*时间复杂度:O(N2^N)*, 我们一共有2^N个permutations,对于每个permutation,我们要将其转换成char array,然后进行大小写的更换,这个过程的复杂度是O(N), 所以一共的时间复杂度是O(N2^N)。

*空间复杂度:O(N2^N)**, 我们一共有2^N个permutations,每个permutation所占的space是N。

这题也可以用上题subsets的bfs的思路来做,代码如下:

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        Queue<List<Integer>> permutations = new LinkedList<>();
        permutations.offer(new ArrayList<>());

        for(int num : nums) {
            int size = permutations.size();
            for(int i = 0; i < size; i++) {
                List<Integer> oldPermutation = permutations.poll();
                for(int j = 0; j <= oldPermutation.size(); j++) {
                    List<Integer> newPermutation = new ArrayList<>(oldPermutation);
                    newPermutation.add(j, num);
                    permutations.offer(newPermutation);
                }
            }
        }

        return new ArrayList<>(permutations);
    }
}

Permutation模版题: 22. Generate Parentheses

Given n pairs of parentheses, write a function to generate all combinations of well-formed parentheses.

image

这一题可以可以很直观地用backtrack来做,但是同样也可以采用bfs的方法来做,那应该怎么做呢?

对于bfs来说,我们可以在每一步的时候,给当前的string加 ( 或者 ),但是给string加括号并不是随意加的,有一定的限制:

让我们来用n=3来看看具体的逻辑:

  1. 我们从空的字符串开始: ""
  2. 对于每一步来说,我们要对所有从上一步得到的combinations分别加上 (),加的过程中要注意上面说的两条限制。
  3. 对于空的字符串来说面,我们只能加 (。因为我们没有足够的 ( 来配对 ),所以我们不能加(。这时候,我们的combinations就是: "("
  4. 对下一个循环来说,我们要把之前所有的combinations都看看能不能加上 ()。对于( 来说,因为现在 (的总个数小于3,所以我们可以直接加上。因为这时候 (的数量是大于 )的数量的,所以 ) 也可以加上。这一轮结束后,我们的combinations就是: "((", "()"
  5. 对下一个循环来说,对于第一个combination "((", 加上一个新的 (之后,我们的(个数还是没有超过3,所以我们可以加上一个(,得到"((("。同样的,我们也可以加上 )"(()"。对于第二个combination "()"来说,我们不能加 ),因为没有对于的()匹配。所以这一轮循环过后,我们的combinations就是: "(((", "(()", "()("
  6. 同理,下一轮循环之后,我们的combinations就是: "((()", "(()(", "(())", "()((", "()()"
  7. 然后,我们的combinations就是 "((())", "(()()", "(())(", "()(()", "()()("
  8. 最后,我们的combinations of balanced parentheses就是: "((()))", "(()())", "(())()", "()(())", "()()()"

下图是上述过程的可视化:

image

所以为了方便我们的处理,知道每个parenthesis里面有多少个 (),我们可以建一个新的ParenthesisString对象来保存,整个过程代码如下:

class Solution {
    class ParenthesisString {
        String str;
        int openCount;
        int closeCount;

        public ParenthesisString(String str, int openCount, int closeCount) {
            this.str = str;
            this.openCount = openCount;
            this.closeCount = closeCount;

        }
    }
    public List<String> generateParenthesis(int n) {

        List<String> res = new ArrayList<>();
        Queue<ParenthesisString> queue = new LinkedList<>();
        queue.offer(new ParenthesisString("", 0, 0));

        while(!queue.isEmpty()) {
            ParenthesisString ps = queue.poll();

            if(ps.openCount == n && ps.closeCount == n) {
                res.add(ps.str);
            } else {
                if(ps.openCount < n) {
                    queue.offer(new ParenthesisString(ps.str + "(", ps.openCount+1, ps.closeCount));
                } 

                if(ps.openCount > ps.closeCount) {
                    queue.offer(new ParenthesisString(ps.str + ")", ps.openCount, ps.closeCount+1));
                }
            }
        }

        return res;
    }
}

这道题的时间复杂度比较复杂,一共要生成n pair的括号,那如果不考虑括号的生成限制的话,一共有2^(2N)的可能性,对于每一个括号string的生成,我们又需要给所在的string添加 (或者 ),这样的话,时间复杂度是O(N*2^(2N))。

这个upper bound不是完全准确的,最准确的时间复杂度是O(4^n/\sqrt{n}),面试中应该不要求手动推,了解一下就好。

因为最多有2^(2N)的combinations,每一个combination需要N个space,所以空间复杂度是O(N*2^(2N))。

LintCode. Generalized Abbreviation OR 320. Generalized Abbreviation

Given word ="word", return the following list (order does not matter):
["word", "1ord", "w1rd", "wo1d", "wor1", "2rd", "w2d", "wo2", "1o1d", "1or1", "w1r1", "1o2", "2r1", "3d", "w3", "4"]

Leetcode的一道会员题(lintcode里也有原题)和上面的一题类似,也是通过BFS和构造一个新的对象来帮助我们解题。

那这题我们要怎么想呢?和上面生成有效括号的题目类似,我们在通过bfs来构建我们的string的时候,每一步我们有两种选择:

根据这两种选择,我们来试试看缩写BAT

  1. 我们开始从一个空string出发:""
  2. 每一步的时候,我们要把所有上一步得到的combinations通过上面两条abbreviation rules来组合。
  3. 我们先加第一个字符B,我们可以1)缩写这个B,或者2)直接加到空字符里。这个过程我们得到了两个新的words: _, B
  4. 下一个回合的时候, 我们要开始加A了。
    • 对于_来说,我们根据上面两条abbreviation rules,可以得到:_ _1A
    • 对于B来说,我们可以得到: B_BA
    • 这个回合结束后,我们可以得到:_ _, 1A, B_, BA
  5. 现在我们来开始加T
    • 对于_ _来说,我们可以得到: _ _ _, 2T
    • 对于1A来说,我们可以得到: 1A_, 1AT
    • 对于B_来说,我们可以得到: B_ _, B1T
    • 对于BA来说,我们可以得到: BA_, BAT
    • 这个回合结束后,我们得到了: _ _ _, 2T, 1A_, 1AT, B_ _, B1T, BA_, BAT
  6. 最后一个回合之后,我们把所有的用_代表skip的占位符改成数字,我们得到了: 3, 2T, 1A1, 1AT, B2, B1T, BA1, BAT

以下是上述过程的可视化:

image

具体的代码实现中,因为我们要用到每一个回合要加什么元素,当前有几个字符是被skip掉的, 以及现在这个string的本身。所以我们可以通过构建一个新的AbbreviatedWord对象,来帮助我们解题(其实仔细想想这种构建新的对象,就等于在backtrack的helper函数里加新的参数,对象只是用来帮助我们保存参数的而已)。

代码如下:

public class Solution {
    class AbbreviatedWord { 
        StringBuilder str;
        int start;
        int count;

        public AbbreviatedWord(StringBuilder str, int start, int count) {
            this.str = str;
            this.start = start;
            this.count = count;
        }

    }

    public List<String> generateAbbreviations(String word) {
        int wordLen = word.length();
        List<String> res = new ArrayList<>();
        Queue<AbbreviatedWord> queue = new LinkedList<>();
        queue.add(new AbbreviatedWord(new StringBuilder(), 0, 0));

        while(!queue.isEmpty()) {
            AbbreviatedWord abWord = queue.poll();
            if(abWord.start == wordLen) {
                if(abWord.count != 0) {
                    abWord.str.append(abWord.count);
                }
                res.add(new String(abWord.str));
            } else {

                // continue abbreviating by incrementing the current abbreviation count
                queue.offer(new AbbreviatedWord(new StringBuilder(abWord.str), abWord.start+1, abWord.count+1));

                // restart abbreviating, append the count and the current character to the string
                if(abWord.count != 0) {
                    abWord.str.append(abWord.count);
                }
                queue.offer(new AbbreviatedWord(new StringBuilder(abWord.str).append(word.charAt(abWord.start)), abWord.start+1, 0));
            }
        }
        return res;
    }
}
xiedaxia1hao commented 3 years ago

MISC.

以下是从Grokking. 里找到的比较有意思的题目,可以对上面学到的pattern融会贯通。

241. Different Ways to Add Parentheses

Given a string expression of numbers and operators, return all possible results from computing all the different possible ways to group numbers and operators. You may return the answer in any order.

这一题我们同样可以根据上面的思路来求解此题,具体的思路如下:

  1. 我们对这个expression进行一个字符一个字符的遍历。
  2. 当我们遇见operator *, +, 和 -的时候,把这个expression分成两个half
  3. 这两个half可以分别用recursive的方法算出来最后的值
  4. 最后我们分别对算出来的值,根据这个遇见的operator来进行operate操作。

我们用(2 * 3 - 4 * 5)这个例子来说,把对应的递归可视化以后,就是下图这样:

image

代码如下:

class Solution {
    public List<Integer> diffWaysToCompute(String expression) {
        List<Integer> res = new ArrayList<>();

        // base case: if the input string is a number, parse and add it to output.
        if(!expression.contains("*") && !expression.contains("+") && !expression.contains("-")) {
            res.add(Integer.parseInt(expression));
        } else {
            for(int i = 0; i < expression.length(); i++) {
                char chr = expression.charAt(i);
                if(!Character.isDigit(chr)) {
                    //break the equation into two halves and make recursive calls
                    List<Integer> leftRes = diffWaysToCompute(expression.substring(0, i));
                    List<Integer> rightRes = diffWaysToCompute(expression.substring(i+1));
                    for(int part1 : leftRes) {
                        for(int part2 : rightRes) {
                            if(chr == '*') {
                                res.add(part1 * part2);
                            } else if(chr == '+') {
                                res.add(part1 + part2);
                            } else if(chr == '-') {
                                res.add(part1 - part2);
                            }
                        }
                    }
                }
            }
        }

        return res;
    }
}

其实做到这里,递归我们通常能想到的优化方法是加memorization。对于本题来说,我们同样可以用一个hashmap保存一个expression和对应的List的map,如果传进来的expression是之前见过的,直接return。

优化后的代码如下:

class Solution {

    Map<String, List<Integer>> map = new HashMap<>();
    public List<Integer> diffWaysToCompute(String expression) {

        if(map.containsKey(expression)) {
            return map.get(expression);
        }

        List<Integer> res = new ArrayList<>();

        // base case: if the input string is a number, parse and add it to output.
        if(!expression.contains("*") && !expression.contains("+") && !expression.contains("-")) {
            res.add(Integer.parseInt(expression));
        } else {
            for(int i = 0; i < expression.length(); i++) {
                char chr = expression.charAt(i);
                if(!Character.isDigit(chr)) {
                    //break the equation into two halves and make recursive calls
                    List<Integer> leftRes = diffWaysToCompute(expression.substring(0, i));
                    List<Integer> rightRes = diffWaysToCompute(expression.substring(i+1));
                    for(int part1 : leftRes) {
                        for(int part2 : rightRes) {
                            if(chr == '*') {
                                res.add(part1 * part2);
                            } else if(chr == '+') {
                                res.add(part1 + part2);
                            } else if(chr == '-') {
                                res.add(part1 - part2);
                            }
                        }
                    }
                }
            }
        }

        map.put(expression, res);

        return res;
    }
}

95. Unique Binary Search Trees II

Given an integer n, return all the structurally unique BST's (binary search trees), which has exactly n nodes of unique values from 1 to n. Return the answer in any order.

这题和 241. Different Ways to Add Parentheses 的思路类似。我们可以从1开始遍历到n,把每个数字i都当作root,任何比i小的数就是该root的左子树,任何比i大的数就是该root的右子树。我们可以不断通过递归来得到左边子树和右边子树的集合,代码如下:

class Solution {
    public List<TreeNode> generateTrees(int n) {
        if(n <= 0) {
            return new ArrayList<>();
        }

        return generateTreesRecursive(1, n);
    }

    public List<TreeNode> generateTreesRecursive(int start, int end) {

        List<TreeNode> res = new ArrayList<>();

        // base condition, return 'null' for an empty sub-tree
        // consider n=1, in this case we will have start=end=1, this means we should have only one tree
        // we will have two recursive calls, findUniqueTreesRecursive(1, 0) & (2, 1)
        // both of these should return 'null' for the left and the right child
        if(start > end) {
            res.add(null);
            return res;
        }

        // making "i" the root of the tree
        for(int i = start; i <= end; i++) {
            List<TreeNode> leftPart = generateTreesRecursive(start, i-1);
            List<TreeNode> rightPart = generateTreesRecursive(i+1, end);

            for(TreeNode part1 : leftPart) {
                for(TreeNode part2 : rightPart) {
                    TreeNode root = new TreeNode(i);
                    root.left = part1;
                    root.right = part2;
                    res.add(root);
                }
            }
        }

        return res;
    }
}


96. Unique Binary Search Trees

Given an integer n, return the number of structurally unique BST's (binary search trees) which has exactly n nodes of unique values from 1 to n.

这题和上题完全一样,唯一的区别就是上题返回生成树的root组成的list,这题返回可以生成的个数就好。相同的思路,代码如下:

class Solution {
    public int numTrees(int n) {
        if(n <= 1) {
            return 1;
        }

        int count = 0;

        // making i the root of tree
        for(int i = 1; i <= n; i++) {
            int left = numTrees(i-1);
            int right = numTrees(n-i);
            count += left * right;
        }

        return count;
    }
}

这题同样可以加上memorization进行优化,加了之后的代码:

class Solution {

    Map<Integer, Integer> map = new HashMap<>();

    public int numTrees(int n) {

        if(map.containsKey(n)) {
            return map.get(n);
        }

        if(n <= 1) {
            return 1;
        }

        int count = 0;

        // making i the root of tree
        for(int i = 1; i <= n; i++) {
            int left = numTrees(i-1);
            int right = numTrees(n-i);
            count += left * right;
        }

        map.put(n, count);
        return count;
    }
}

The time complexity of the memoized algorithm will be O(n​2), since we are iterating from ‘1’ to ‘n’ and ensuring that each sub-problem is evaluated only once. The space complexity will be O(n) for the memoization map.