数据结构与算法面试宝典 - 前微软资深软件工程师 - 拉勾教育

数据结构与算法面试宝典 - 前微软资深软件工程师 - 拉勾教育

在本讲,我将带你继续探究 “一题多解”,结合前面学习过且面试常考的知识点,比如区间问题、双指针、动态规划、栈、贪心,从多个角度去求解一个题目,通过逐步分析已知条件、提取题目特点,进而将不熟悉的题目变成我们擅长求解的题目,最终攻克 “最长有效括号长度” 的难题。

在正式介绍前,我有两点说明。

  1. 这类题目的解法非常多,但本讲仅重点介绍其中 5 种具有代表性的解法。目的是让你在算法面试时,能够展开更多的解题思路。
  2. 并不是每种解法都能够利用常量空间,如果用到,我会特别注明。

题目

给你一个只包含('')'的字符串,找出最长有效(格式正确且连续)括号子串的长度。

输入:s = “(()”

输出:2

解释:最长有效括号子串是 “()”

注意,“有效” 需要满足两个条件:

  • 连续子串;
  • 子串有效指的是每个左括号 ‘(‘ 都可以找到相应的右括号 ‘)’ 完成一一配对

在 “01 | 栈:从简单栈到单调栈,解决经典栈问题” 的 “例 1” 中,我们介绍过如何判断一个字符串是否合法(即有效)。只不过,在这个题中,我们要从一个字符串中找到一个子串,需要满足 “最长且合法”。

当我们拿到题目,最容易想到的思路是:

  • 一共有 O(N2) 个连续子串
  • 判断每个连续子串是否合法

如果直接按上述思路开始进行暴力求解,那么时间复杂度就会上升到 O(N3)。因此,在求解前,我们应该根据已知条件,认真分析题目的特点,然后和暴力破解法 Say GoodBye。

合法字符串的特性

首先我们看一下合法字符串长什么样?有什么样的特点?一般而言,括号对匹配成功的合法字符串,有三种情况。

  • 相连:指很多个配对成功的括号连在一起,比如 [“()”, “()()”, “()()()”]。
  • 嵌套:指内外层层相套,但是它们能够合法配对成功,比如 [“()”, “(())”, “((()))”]。
  • 相连 + 嵌套:指相连和嵌套混合的情况,但是它们是合法的字符串,比如 [“()(())”, “(())()”]。

注意,在后文中,我们将用 “相连”“嵌套”“相连 + 嵌套” 特指这三种情况。

特点 1: 区间

我们发现,括号能够配对的时候,总是有一个左括号 + 一个右括号,如果我们将左括号的下标当成一个区间的左端点,右括号的下标当成一个区间的右端点。

那么用区间来表示一个合法字符串,会得到什么有趣的特点呢?下面我们仍然从合法字符串的 3 种情况展开。

  • 相连:比如 “()()()”,就可以得到区间 [[0,1], [2,3], [4,5]]。
  • 嵌套:比如 “(())”,就可以得到区间 [[0,3], [1,2]]。
  • 相连 + 嵌套:比如 “()(())”,就可以得到区间 [[0,1], [2,5], [3,4]]。

我们尝试定义一下区间的连接性。

如果两个区间 , 满足下面 2 个条件之一:

  1. 有公共点,即 not ((b < c) || d < a) );
  2. b < c && b + 1 == c 或者 d < a && d + 1 == a。

我们就称这两个区间具有连接性。

我这里给出三个例子,帮你理清区间的连接性

例 1:区间 [1,2], [3,4] 是否具有连接性?答案是!因为区间 [1,2] 的右端点 2 加上 1 就是区间 [3,4] 的左端点(满足条件 2)。

例 2:区间 [0,5] 和区间 [2,5] 是否具有连接性?答案是!因为它们是有公共点的,因此我们认为它们具有连接性。

例 3:区间 [0, 1] 和区间 [3,4] 则不具有连接性。因为这两个区间不满足条件 1,也不满足条件 2。

那么定义好区间的连接性之后,有什么用途呢?这样操作后,我们就可以把原始的题目分为两步:

  1. 得到所有配对的左括号与右括号的下标;
  2. 给定一系列区间,找到最长的区域,这个区域被具有相连性的区间覆盖。

针对第 2 步,我们进一步举例说明。

例 1:当给定的区间为 [1, 2], [3,4] 的时候,我们可以找到一个最长的区域 [1, 4],这个区域是由具有相连性的两个区间覆盖的。

例 2:当给定的区间集合为 [0, 1], [3,4], [5,6], [6,9] 的时候,最长的区域为 [3, 9]。由 [3,4], [5,6], [6,9] 这三个具有相连性的区间覆盖。

注意,我们不要求区域内的区间两两满足相连性

比如 [3,4] 和 [5,6] 具有连接性,但是 [3,4] 和 [6,9] 不具有连接性。在操作时,我们可以这样操作:

Step 1. [3,4] 和 [5,6] 具有连接性,合并成一个大区间 [3,6];

Step 2. [3,6] 与 [6,9] 具有连接性,合并成一个大区间 [3,9]。

例 3:给定一个区间 [0, 1],那么最长区域就是 [0, 1]。

注意:一个合法的括号配对字符串不会生成一个单独的很长的区间,比如 [0, 10]。如果 s[0]= ‘(‘ 与 s[10]=’)’ 配对成功,那么必然是一种嵌套的合法字符串,内部还会有很多区间必须要给出来。

第一步的处理,可以直接使用栈(参考 “01 | 栈:从简单栈到单调栈,解决经典栈问题” 的 “例 1”)。而第二步的处理需要用到贪心算法(参考 “11 | 贪心:这种思想,没有模板,如何才能掌握它?” 的 “例 2”)。

基于这样的思路,我们就可以写出代码如下(解析在注释里):

  1. class Solution {
  2. public int longestValidParentheses(String s) {
  3. final int N = s == null ? 0 : s.length();
  4. Stack<Integer> st = new Stack<>();
  5. List<int[]> ranges = new ArrayList<>();
  6. for (int i = 0; i < N; i++) {
  7. final char c = s.charAt(i);
  8. if (c == ')') {
  9. if (!st.isEmpty()) {
  10. final int topIdx = st.pop();
  11. ranges.add(new int[]{topIdx, i});
  12. }
  13. } else {
  14. st.push(i);
  15. }
  16. }
  17. Collections.sort(ranges, new Comparator<int[]>() {
  18. public int compare(int[] a, int[] b) {
  19. return a[0] - b[0];
  20. }
  21. });
  22. int start = 0;
  23. int end = -1;
  24. int ans = 0;
  25. for (int i = 0; i < ranges.size(); i++) {
  26. final int from = ranges.get(i)[0], to = ranges.get(i)[1];
  27. if (from <= end + 1) {
  28. end = Math.max(end, to);
  29. } else {
  30. start = from;
  31. end = to;
  32. }
  33. ans = Math.max(ans, end - start + 1);
  34. }
  35. return ans;
  36. }
  37. }

代码:Java/C++/Python

复杂度分析:从字符串中提取区间,只需要 O(N) 的时间复杂度。然后我们需要对区间进行排序,最差情况下有 O(N/2) 个区间,那么排序的时间复杂度会上升到 O(NlgN)。最后的贪心算法时间复杂度为 O(N)。因此,整个问题的时间复杂度为 O(NlgN),空间复杂度为 O(N)。

关于求解最长相连通的区间问题,这里我还给你留了一个练习题。你可以做一做。

练习题 1:给定一系列区间,将重合的区间合并在一起。

输入:A = [[1,2], [2,3], [2,6], [7, 8]]

输出:[[1, 6], [7,8]]

代码:Java/C++/Python

我们发现,实际上整个问题的时间复杂度是由排序带来的,那么,有没有可能优化掉排序呢?我们先来看一下题目中生成区间的代码:

  1. if (c == ')') {
  2. if (!st.isEmpty()) {
  3. final int topIdx = st.pop();
  4. ranges.add(new int[]{topIdx, i});
  5. }
  6. }

不难发现,在右括号找到左括号匹配成功的时候,总是记录下左括号下标 <,右括号下标 >。

题目中,左括号的下标本来就是有序的,那么我们可以使用一个数组 pairPos[] 来记录这个匹配成功的区间信息。采用的原则如下:

pairPos[左括号的下标] = 右括号的下标
pairPos[右括号的下标] = -1

就是下图所示的样子:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图1

也就意味着,我们将区间信息成对放在了 ,其中 i 是一个字符串中配对成功的左括号的下标。

那么,基于这个思想,我们可以优化代码如下(解析在注释里):

  1. class Solution {
  2. public int longestValidParentheses(String s) {
  3. final int N = s == null ? 0 : s.length();
  4. Stack<Integer> st = new Stack<>();
  5. int[] pairPos = new int[N];
  6. Arrays.fill(pairPos, -1);
  7. for (int i = 0; i < N; i++) {
  8. final char c = s.charAt(i);
  9. if (c == ')') {
  10. if (!st.isEmpty()) {
  11. final int topIdx = st.pop();
  12. pairPos[topIdx] = i;
  13. }
  14. } else {
  15. st.push(i);
  16. }
  17. }
  18. int start = 0;
  19. int end = -1;
  20. int ans = 0;
  21. for (int i = 0; i < N; i++) {
  22. if (pairPos[i] == -1) {
  23. continue;
  24. }
  25. final int from = i, to = pairPos[i];
  26. if (from <= end + 1) {
  27. end = Math.max(end, to);
  28. } else {
  29. start = from;
  30. end = to;
  31. }
  32. ans = Math.max(ans, end - start + 1);
  33. }
  34. return ans;
  35. }
  36. }

代码:Java/C++/Python

复杂度分析:时间复杂度为 O(N),空间复杂度为 O(N)。

接下来我们再看题目的另外一个特点,连续性。

特点 2:连续性

通过 “特点 1” 的分析,我们将问题处理成区间的连接性问题。不难发现,所谓相连性,就是要求:

求解出来的最长区间,里面的每个字符都可以配对成功。

那么,这个条件必然等价于,这个区间里面的下标,一旦排序之后,必然由相邻的整数构成。下面我们分别举例进行说明。

  • 相连:比如 “()()()”,就可以得到区间 [[0,1], [2,3], [4,5]]。排序后,得到整数列表 [0,1,2,3,4,5]。
  • 嵌套:比如 “(())”,就可以得到区间 [[0,3], [1,2]]。排序后,得到整数列表 [0,1,2,3]
  • 相连 + 嵌套:比如 “()(())”,就可以得到区间 [[0,1], [2,5], [3,4]]。排序后,得到整数列表 [0,1,2,3,4,5]。

当找到一个不能匹配的位置 NOT 的时候,我们不可能在 NOT 的左边找一个位置 left_pos,在 NOT 的右边找一个位置 right_pos,并且使得 [left_pos, right_pos] 是一个合法的字符串。

上面描述的情况如下图所示:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图2

我们可以使用反证法进行证明。

证明:假设存在一个位置 NOT,不能找到一个左括号与之匹配。但是我们可以找到 left_pos < NOT 和 right_pos > NOT,并且使得 [left_pos, right_pos] 是一个合法括号的字符串。

首先,由于 [left_pos, right_pos] 是一个合法的括号配对的子串,那么可以肯定,这个区间里面的每一个字符应该都有左括号与之匹配。但是这一结论与 “NOT 找不到左括号匹配” 相矛盾。因此,我们就证明了合法字符串的连续性。

那么,我们是不是可以进行如下操作呢?

Step 1. 把所有配对成功的下标,都放到一个数组中,然后再把这个数组排序。

Step 2. 找到最长的相邻整数构成的列表,输出其长度。

为了方便你理解 Step 2 中 “最长的相邻整数构成的列表”,这里我给你举个例子:

当给定的数组为 A[] = {0,1,3,4,5,6,8,9} 时,最长的相邻整数列表为 [3,4,5,6],输出长度 4。

那么,基于这样的思路,我们可以写出代码如下(解析在注释里):

  1. class Solution {
  2. public int longestValidParentheses(String s) {
  3. final int N = s == null ? 0 : s.length();
  4. if (N <= 1) {
  5. return 0;
  6. }
  7. List<Integer> pairs = new ArrayList<>();
  8. Stack<Integer> st = new Stack<>();
  9. for (int i = 0; i < N; i++) {
  10. final char c = s.charAt(i);
  11. if (c == ')') {
  12. if (!st.empty()) {
  13. final int idx = st.pop();
  14. pairs.add(idx);
  15. pairs.add(i);
  16. }
  17. } else {
  18. st.push(i);
  19. }
  20. }
  21. Collections.sort(pairs);
  22. int ans = 0;
  23. int preValue = -1;
  24. int rangeStart = 0;
  25. for (int i = 0; i < pairs.size(); i++) {
  26. final int cur = pairs.get(i);
  27. if (i > 0) {
  28. if (cur != preValue + 1) {
  29. ans = Math.max(ans, i - rangeStart);
  30. rangeStart = i;
  31. }
  32. }
  33. preValue = cur;
  34. }
  35. ans = Math.max(ans, pairs.size() - rangeStart);
  36. return ans;
  37. }
  38. }

代码:Java/C++/Python

复杂度分析:最差情况下,会有 N 个整数放到数组中排序,所以时间复杂度为 O(NlgN),空间复杂度为 O(N)。

我们继续从相连性出发,请你思考,这里是否必须要做排序?既然都相连了,我们就把所有配对成功的字符标记为 1,没有配对成功的字符标记为 -INF,如下图所示:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图3

那么,原问题就成功转化为 “求一个数组的最大子数组” 问题。并且,最大子数组和即是原问题的输出。

基于这个思想,我们可以写出代码如下(解析在注释里):

  1. class Solution {
  2. public int longestValidParentheses(String s) {
  3. final int N = s == null ? 0 : s.length();
  4. if (N <= 1) {
  5. return 0;
  6. }
  7. int[] pairPos = new int[N];
  8. Arrays.fill(pairPos, -1);
  9. Stack<Integer> st = new Stack<>();
  10. for (int i = 0; i < N; i++) {
  11. final char c = s.charAt(i);
  12. if (c == ')') {
  13. if (!st.isEmpty()) {
  14. pairPos[i] = st.peek();
  15. pairPos[st.peek()] = i;
  16. st.pop();
  17. }
  18. } else {
  19. st.push(i);
  20. }
  21. }
  22. int tmp_sum = 0;
  23. int max_sum = 0;
  24. for (int i = 0; i < N; i++) {
  25. final int v = (pairPos[i] == -1) ? Integer.MIN_VALUE / 2 : 1;
  26. tmp_sum = Math.max(tmp_sum, 0) + v;
  27. max_sum = Math.max(max_sum, tmp_sum);
  28. }
  29. return max_sum;
  30. }
  31. }

代码:Java/C++/Python

复杂度分析:时间复杂度为 O(N),空间复杂度为 O(N)。

然后,我们再看一下这个题目的下一个特点与解法。

特点 3:最长

我们从题目中看到了 “最长” 二字,这不禁让我想起一个总结:

如果题目中出现 “最大”“最小”“最长” 等字眼,那么可以尝试往 DP 的方向思考。

因此,这里我们就尝试往 DP 的方向去想一想,首先还是拿出 “DP 分析 6 步法”。

1. 最后一步

DP 问题的关键是最后一步。对于处理一维的字符串而言,最后一步肯定就是处理字符串的最后一个字符。最后一个字符可以分为两种情况:左括号、右括号。

  • 左括号:此时肯定找不到括号与这个字符配对,所以包含最后一个字符的有效子串长长度为 0。
  • 右括号:如果最后一个字符为右括号,那么我们再看它前面的那个字符,这个字符也只有 2 种情况(左括号,右括号)。

1) 左括号

情况如下图所示:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图4

此时,最后一个字符可以和它前面的字符配对成功。但是需要注意:包含最后一个括号的合法子串长度可能是 2,也可能比 2 大。因为可能有如下 2 种情况:

情况 1,前面字符串不是有效的合法子串,此时最后一个括号的合法子串长度是 2。如下图所示:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图5

情况 2,前面的字符串也是有效的合法子串。我们需要加上这部分的合法子串的长度。此时最后一个括号的合法子串长度大于 2。如下图所示:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图6

2)右括号

如果最后一个括号之前的字符是右括号,如下图所示:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图7

那么我们只需要跳过前面那一段有效合法子串,看一下 “问号” 位置(与最后一个字符可能匹配的位置 pairPos)是否可以与最后一个字符匹配。

  • 如果问号位置(s[pairPos])是一个右括号,那么最后一个字符肯定匹配失败;
  • 如果问号位置(s[pairPos])是一个左括号,那么就可以配对成功。

在配对成功的情况下,我们还需要加上之前的合法子串的长度。情况如下:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图8

我们可以将这里分情况的思路用思维导图整理如下:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图9

分情况讨论总是很烦,所以这里我给你总结成两条原则:

  • 配对失败,那么此字符的合法子串长度为 0;
  • 配对成功,还需要加上之前的合法长度。

2. 子问题

我们可以用 f(x) 表示以字符 s[x] 为右端点的,合法子串的长度。

3. 递推关系

有了 “f(x)” 的含义,可以写出递推的伪代码如下(解析在注释里):

  1. if s[i-1] == '(':
  2. f(i) = 2 + f(i-2)
  3. else:
  4. preValidLen = f(i-1)
  5. pairPos = i - preLen - 1
  6. if pairPos >= 0 and s[pairPos] == '(':
  7. f(i) = i + 1 - pairPos
  8. if pairPos - 1 >= 0:
  9. f(i) += f(pairPos-1)

4. f(x) 的表达

我们发现,x 只是字符串 s 的下标,那么只需使用一个一维的数组 dp[] 就可以表示 f(x) 了。

5. 初始条件与边界

在计算的时候,f(i) 会依赖 f(i-1) 以及 f(i-2),还有 f(pairPos-1)。这里提醒我们:

  • 由 f(i) 依赖 f(i-1)、f(i-2),可知初始计算的时候,至少应该先计算出两项的值,也就是计算出 f(0) 和 f(1);
  • 由于我们并不清楚 f(pairPos-1) 具体的值是多少,所以在处理的时候,要注意判断是否越界。

6. 计算顺序

如果我们从左往右遍历字符串,那么计算的时候,需要注意:

  • 处理字符串为空,或者长度为 1 的特殊情况;
  • 优先计算头两个字符的 f() 函数值;
  • 注意 pairPos 的越界的处理。

至此,我们就可以写出动态规划求解的代码了(解析在注释里):

  1. class Solution {
  2. public int longestValidParentheses(String s) {
  3. final int N = s == null ? 0 : s.length();
  4. if (N <= 1) {
  5. return 0;
  6. }
  7. int[] dp = new int[N];
  8. int ans = 0;
  9. if (s.charAt(0) == '(' && s.charAt(1) == ')') {
  10. dp[1] = 2;
  11. ans = 2;
  12. }
  13. for (int i = 2; i < N; i++) {
  14. final char c = s.charAt(i);
  15. if (c == ')') {
  16. final char preChar = s.charAt(i - 1);
  17. if (preChar == '(') {
  18. dp[i] = 2 + dp[i - 2];
  19. } else {
  20. final int preLen = dp[i - 1];
  21. final int start = i - preLen;
  22. final int pairPos = start - 1;
  23. if (pairPos >= 0 && s.charAt(pairPos) == '(') {
  24. int curLen = i + 1 - pairPos;
  25. if (pairPos - 1 >= 0) {
  26. curLen += dp[pairPos - 1];
  27. }
  28. dp[i] = curLen;
  29. }
  30. }
  31. }
  32. ans = Math.max(ans, dp[i]);
  33. }
  34. return ans;
  35. }
  36. }

代码:Java/C++/Python

复杂度分析:时间复杂度为 O(N),空间复杂度为 O(N)。

在使用 DP 的过程中,我们发现,可以浓缩成一个原则:

配对成功,就加上之前的合法长度。

那么,基于这个原则我们还有没有其他解法呢?

特点 4:栈的性质

在特点 1 和特点 2 中,都用到了栈来找到每一个字符的匹配字符的位置(如果存在)。下面我们再看一下,当一个合法的字符串利用栈来进行匹配处理的时候,有什么样的性质。

  1. stack = []
  2. for i in range(len(s)):
  3. if s[i] == ')':
  4. if not stack.empty():
  5. topIdx = stack.pop()
  6. <topIdx, i>匹配成功
  7. else:
  8. else:
  9. stack.push(i)

问题 1:弹栈之后的空栈

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图10

当我们需要为红框中的右括号 s[i] 进行匹配,并且弹栈之后,栈为空。那么我们可以得到一个结论 1

此时,整个字符串从开头当前位置是一个合法有效的字符串。

但是,这个结论 1会遇到下面这种坑。

例 1:给定的字符串是 “)))()”,当我们处理最后一个字符 s[4] 的时候,弹栈之后,栈肯定为空。但是,我们不能认为 [0, 4] 是一个有效的合法子串。

那么,是不是结论 1 不对呢?其实不尽然,只是我们有可能需要重新定义字符串的开头。

问题 2:空栈无法弹

假设,字符串是 s = “))))”。如果按照结论 1 来进行操作,每个位置都会得到空栈,我们可以得到每个位置的最长合法子串,如下图所示:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图11

显然,这样处理是不合适的。那么问题出在哪里呢?

在特点 2 中,我们证明了合法字符串必须满足连续性,那么当我们找到一个右括号,但是找不到任何左括号与之配对的时候,就可以扔掉左边的字符了。

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图12

这也就意味着,在找到一个字符 s[i],找不到匹配的时候,需要切掉 [0,i] 这部分子串。

当然,为了处理方便,我们不会真正去切这个子串,而是重新定义一个 start 变量来表示新的字符串的起始位置。

在切掉 [0, i] 这部分子串的时候,就可以有如下操作:

当 s[i] 为右括号,栈为空的时候,设置新的字符串的起始点 start = i + 1。

打上这个补丁之后,字符串 s = “))))” 就可以正确处理了。到此时,我们发现,并不是结论 1 不对,而是结论 1 中的 “开头 start” 在遇到空栈无法弹的时候,需要重新定义。

问题 3:弹栈后非空

如果弹栈之后,栈非空,那么必然是遇到了 s = “((()()()” 这种字符串。当我们处理最后一个字符(会将 6 弹栈),如下图所示:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图13

但是,弹栈之后,栈顶元素就变成了 1。我们发现 (1, 7] 这段区间都是一个有效的合法区间。因此,弹栈之后,栈非空的情况:区间(存留的栈顶元素,i] 是一个合法的子串。

处理好问题 1、问题 2、问题 3 之后,我们就可以写出如下代码了(解析在注释里):

  1. class Solution {
  2. public int longestValidParentheses(String s) {
  3. final int N = s == null ? 0 : s.length();
  4. if (N <= 1) {
  5. return 0;
  6. }
  7. Stack<Integer> st = new Stack<>();
  8. int ans = 0;
  9. int start = 0;
  10. for (int i = 0; i < N; i++) {
  11. final char c = s.charAt(i);
  12. if (c == ')') {
  13. if (st.isEmpty()) {
  14. start = i + 1;
  15. } else {
  16. st.pop();
  17. final int base =
  18. st.isEmpty() ? start : st.peek() + 1;
  19. ans = Math.max(ans, i - base + 1);
  20. }
  21. } else {
  22. st.push(i);
  23. }
  24. }
  25. return ans;
  26. }
  27. }

代码:Java/C++/Python

复杂度分析:时间复杂度为 O(N),空间复杂度为 O(N)。

前面我们介绍的算法最优情况下,时间复杂度都是 O(N),但是空间复杂度也都为 O(N)。有没有可能优化一下空间复杂度呢?

特点 5:合法字符串的性质

如果优化空间复杂度,我们需要再挖掘一下合法子串的性质。对于任意一个合法子串,都必然满足以下两个性质。

  • 合法性质 1:对于任意一个合法子串,当我们从左往右遍历这个子串的时候,左括号的数目永远 >= 右括号的数目。
  • 合法性质 2:对于任意一个合法子串,当我们从右往左遍历这个子串的时候,右括号的数目永远 >= 左括号的数目。

那么,我们是否可以反推呢?

1) 利用合法性质 1,我们从左往右遍历,找到一个最长的区间,使其总是满足左括号的数目 >= 右括号的数目。那么当左括号数目 == 右括号数目,此时就可以得到一个最长的合法子串,我们记为 maxEquLength1。

2)利用合法性质 2,我们从右往左遍历,找到一个最长的区间,使其总是满足右括号的数目 >= 左括号的数目。那么当左括号数目 == 右括号数目,此时就可以得到一个最长的合法子串,我们记为 maxEquLength2。

最后,再取这两者的最大值:max(maxEquLength1, maxEquLength2)。

那么,有没有可能只从左向右遍历一次呢?答案是不行!

原因是:如果只利用合法性质 1 从左向右遍历,那么无法处理字符串 s = “(()”;如果只利用合法性质 2 从右向左遍历,那么无法处理字符串 s = “())”。

结合以上分析,我们可以写出如下代码(解析在注释里):

  1. class Solution {
  2. public int longestValidParentheses(String s) {
  3. final int N = s == null ? 0 : s.length();
  4. if (N <= 1) {
  5. return 0;
  6. }
  7. int leftBraceNumber = 0;
  8. int rightBraceNumber = 0;
  9. int maxEquLength1 = 0;
  10. for (int i = 0; i < N; i++) {
  11. final char c = s.charAt(i);
  12. leftBraceNumber += c == '(' ? 1 : 0;
  13. rightBraceNumber += c == ')' ? 1 : 0;
  14. if (rightBraceNumber > leftBraceNumber) {
  15. leftBraceNumber = 0;
  16. rightBraceNumber = 0;
  17. }
  18. if (leftBraceNumber == rightBraceNumber) {
  19. maxEquLength1 =
  20. Math.max(maxEquLength1,
  21. leftBraceNumber + rightBraceNumber);
  22. }
  23. }
  24. int maxEquLength2 = 0;
  25. leftBraceNumber = 0;
  26. rightBraceNumber = 0;
  27. for (int i = N - 1; i >= 0; i--) {
  28. final char c = s.charAt(i);
  29. leftBraceNumber += c == '(' ? 1 : 0;
  30. rightBraceNumber += c == ')' ? 1 : 0;
  31. if (leftBraceNumber > rightBraceNumber) {
  32. leftBraceNumber = 0;
  33. rightBraceNumber = 0;
  34. }
  35. if (leftBraceNumber == rightBraceNumber) {
  36. maxEquLength2 =
  37. Math.max(maxEquLength2,
  38. leftBraceNumber + rightBraceNumber);
  39. }
  40. }
  41. return Math.max(maxEquLength1, maxEquLength2);
  42. }
  43. }

代码:Java/C++/Python

复杂度分析:时间复杂度为 O(N),空间复杂度为 O(1)。

在这里,我们抓住了合法字符串的性质 1 和性质 2,然后利用贪心算法在 O(1) 空间内解决了这个题目。在做题的时候,如果挖掘出题目的更多信息,再匹配到合适的算法,就能够帮助我们巧妙地破题

总结

在本讲,我们通过一个括号匹配的题目,深入挖掘了题目的特点,然后再使用上各种算法与数据结构,得到了各种巧妙的解法。我将本讲的内容整理成了思维导图,帮助你总结思路:

19 | 最小体力消耗路径:如何突破经典题型,掌握解题模板? - 图14

思考题

最后,我还给你留了一个思考题。

思考题:数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

示例 1

输入:n = 3

输出:[“((()))”,”(()())”,”(())()”,”()(())”,”()()()”]

代码:Java/C++/Python

你可以自己尝试求解这道题目,把答案写在留言区,我们一起讨论。关于这道括号的题目就介绍到这里。接下来,下一讲介绍 “21 | 安排会议室:如何利用多种方法安排会议室?”,让我们继续前进。