算法 2.6 栈 + 贪心:去除重复字母【leetcode 316】

题目描述

给你一个仅包含小写字母的字符串,请你去除字符串中重复的字母,使得每个字母只出现一次。
需保证返回结果的字典序最小(要求不能打乱其他字符的相对位置)

示例:
输入: "bcabc"
输出: "abc"

示例:
输入: "cbacdcbc"
输出: "acdb"

数据结构

  • 数组、栈

算法思维

  • 遍历、LIFO、贪心


解题步骤


一. Comprehend 理解题意
题目主干
  • 去除字符串中重复的字母,使得每个字母只出现一次
    • 如果某个字母在字符串中出现多次,我们需要删去重复的字母
    • 以字符串“bcabc”为例,只有a,b,c三个字母,去除重复字母后的字符串可能为 “abc”, “bca”,“cab”, “bac”, “cba”, “acb”
附加限制
  1. 不能打乱字符的相对位置:“cba”, “acb” 不满足条件(无法通过删除字符得到)
  2. 返回字典序最小的字符串: “abc”< “bac”< “bca”< “cab”,故应返回 “abc”
宽松限制
  • 输入仅包含小写字母a-z的字符串
细节问题
  • 最终返回的字符串可能不以字符串中出现的最小字母开头
    如:“dcbadd” 中最小字符为a,但是符合题意的字符串应为 "cbad"

二. Choose 选择数据结构与算法
数据结构选择
  • 使用栈来存放结果字符串,利用其 LIFO 的特点进行字符串的构建
  • 原字符串可以是字符串结构或数组结构
算法思维选择:贪心算法与栈的结合
  • 维护栈存放当前扫描过字符串的处理结果
  • 贪心思想
    • 若当前字符小于栈顶字符且栈顶字符在之后还会出现
    • 那么使用当前字符替换栈顶字符一定可以得到字典序更小的结果
  • 回顾贪心算法的流程
    • 将求解问题分成若干子问题并求解:给定当前字符及之前字符串的最优结果(栈)
    • 贪心决策当前最优解,即得到字典序更小的字符串
    • 合并子问题的解为原来问题的解:对字符串所有字符决策结束即得到问题的解

三. Code 编码实现基本解法
解题思路剖析
  • 首先浏览一遍字符串,确定每个字符在串中最后一次出现的位置
  • 再浏览一遍字符串,对当前字符:
    • 若栈为空,压栈
    • 若栈不空,且当前字符在栈中出现过,跳过当前字符
    • 否则比较当前字符与栈顶字符
      • 若当前字符大于栈顶字符,压栈
      • 若栈顶字符大于当前字符且最后出现位置在当前位置之后,栈顶出栈,继续比较当前字符与新栈顶字符,直至栈为空或栈顶字符小于当前字符或栈顶字符未在当前位置后出现过,将当前字符压栈
代码实现
class Solution {
    public String removeDuplicateLetters(String s) {
        int length = s.length();
        //创建计数器 lastOccurrence 记录每个字母在字符串中最后一次出现的位置
        int[] lastOccurrence = new int[26];
        for (int i = 0; i < length; i++)
            lastOccurrence[s.charAt(i) - 'a'] = i;
        Stack stack = new Stack<>();
        boolean[] seen = new boolean[26]; //记录当前栈中已经存在的字符
        //再浏览一遍字符串,对当前字符:
        for (int i = 0; i < length; i++) {
            char c = s.charAt(i);
            if (!seen[c - 'a']) {//若栈不空,且当前字符在栈中出现过,跳过当前字符
                while (!stack.isEmpty() && c < stack.peek() && lastOccurrence[stack.peek() - 'a'] > i) {
                    //栈为空、或当前字符大于栈顶字符、或栈顶字符在当前字符后不再出现,当前字符压栈
                    seen[stack.pop() - 'a'] = false;
                }
                stack.push(c);
                seen[c - 'a'] = true;
            }
        }
        String result = "";
        while (!stack.isEmpty()) result = stack.pop() + result; //栈拼接成字符串
        return result;
    }
}

时间复杂度:O(n)
  • 遍历字符串,统计字母在字符串中最后一次出现的位置 O(n)
  • 遍历字符串,计算结果 O(n)

空间复杂度:O(1)
  • 使用栈存放结果字符串(最大长度26)O(1)
  • 其他变量的常数级空间占用 O(1)

执行耗时:4 ms,击败了 91.26% 的Java用户
内存消耗:39.4 MB,击败了 9.72% 的Java用户

四. Consider 思考更优解
剔除无效代码 优化空间消耗
  • 由于栈的后进先出特点,需将栈顶元素拼接到字符串前面
    • 考虑使用字符数组来模拟栈,可以左到右顺序地得到字符串
    • 同时字符数组为静态空间,可以避免栈的动态扩容
    • 考虑使用 Java 中的 StringBuilder 来实现拼接

五. Code 编码实现最优解
class Solution {
    public String removeDuplicateLetters(String s) {
        //此处使用字符数组来模拟栈,top记录栈顶元素的下标(top=-1时栈为空)
        char[] stack = new char[26];
        int top = -1;
        //数组seen记录当前栈中已经存在的字符,如果后续再遇到可以跳过
        boolean[] seen = new boolean[26];
        //last_occurrence 记录字符串中出现过的字符在字符串最后一次出现的位置
        int[] last_occurrence = new int[26];
        char[] cs = s.toCharArray();
        for (int i = 0; i < s.length(); i++)
            last_occurrence[cs[i] - 'a'] = i;
        //从左到右扫描字符串
        for (int i = 0; i < s.length(); i++) {
            char c = cs[i];
            if (!seen[c - 'a']) {
                // 若当前字符已经在栈中,无需处理
                // 如果栈中有元素,且栈顶元素比当前字符小,并且栈顶字符在后续字符串还会出现,
                // 那么我们可以用当前字符替换栈顶字符得到一个字典序更小的字符串
                //(注意此处将一直与栈顶元素相比,直到栈为空或栈顶字符比当前字符小,或栈顶字符在当前位置之后不会再出现)
                while (top != -1 && c < stack[top] && last_occurrence[stack[top] - 'a'] > i)
                    seen[stack[top--] - 'a'] = false;
                seen[c - 'a'] = true;
                stack[++top] = c;
            }
        }
        //将栈中的字母连接起来
        StringBuilder ss = new StringBuilder();
        for (int i = 0; i <= top; i++) ss.append(stack[i]);
        return ss.toString();
    }
}

时间复杂度:O(n)
  • 数组的遍历 O(n)

空间复杂度:O(1)
  • 常数级内存空间 O(1)

执行耗时:1 ms,击败了 100.00% 的Java用户
内存消耗:37.1 MB,击败了 100.00% 的Java用户

六. Change 变形与延伸
题目变形
  • (等价)返回字符串 text 中按字典序排列最小的子序列,该子序列包含 text 中所有不同字符一次
  • (练习)若要求返回字典序最大的字符串应该如何操作

你可能感兴趣的:(算法 2.6 栈 + 贪心:去除重复字母【leetcode 316】)