ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
#### 例题分析二 LeetCode 第 84 题:给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1。求在该柱状图中,能够勾勒出来的矩形的最大面积。 说明:下图是柱状图的示例,其中每个柱子的宽度为 1,给定的高度为 [2,1,5,6,2,3]。图中阴影部分为所能勾勒出的最大矩形面积,其面积为 10 个单位。 示例 ``` 输入: [2,1,5,6,2,3] 输出: 1 ``` * [ ] 解题思路一:暴力法 从暴力法开始寻找思路。既然要找出最大的面积,就把所有可能的面积都找出来,然后从中比较出最大的那个。如何找出所有的面积呢? 1. 从左到右扫描一遍输入的数组。 2. 遇到每根柱子的时候,以它的高度作为当前矩形的高度。 3. 矩形的宽度从当前柱子出发一直延伸到左边和右边。 4. 一旦遇到了低于当前高度的柱子就停止。 5. 计算面积,统计所有面积里的最大值。 具体的实现步骤如下。 1. 第一根柱子高度是 2,当往右边扩展的时候,发现第二根柱子的高度为 1,要低于当前的高度,于是扩展结束,即以第一根柱子高度作为矩形高度,得到矩形面积是 2。 2. 第二根柱子,它的高度为 1,以它作为高度的矩形面积是 6。 3. 以 5 为高度的矩形面积是 10。 4. 以 6 为高度的矩形面积是 6。 5. 以 2 为高度的矩形面积是 8。 6. 以 3 作为高度的矩形面积为 3。 由此,得到最大的面积是 10。 该算法的时间复杂度是 O(n2)。 * [ ] 解题思路二:解法优化  以两个柱子的情况为例进行分析。 1. 不必急于计算以 2 为高度的矩形面积,把 2 暂时保存起来备用,因为一旦从开始就计算矩形面积的话,就是暴力法。 2. 遇到 1 的时候,由于 1 的高度低,造成以 2 为高度的矩形无法延伸到高度为 1 的柱子,即,可以计算高度为 2 的矩形面积。每当遇到一个下降的高度时,就可以开始计算以之前高度作为矩形高度的面积。 3. 遇到更高的高度时,也不急计算以 1 为高度的矩形面积,因为 5 的下一个是 6,面积还能继续扩大。 4. 再次遇到 2 时,按照之前的策略,可以计算以 6 为高度的矩形面积。 5. 是否要计算以 5 作为高度的矩形面积呢?是的,因为 2 比 5 低,以 5 作为高度的矩形无法包含 2 这个点。该宽度如何计算呢?是不是就是 2 的下标减去 5 的下标就可以呢? 6. 当计算完高度为 6 的矩形面积时,立即知道下一个高度是 5,以及 5 所对应的下标,可以利用一个 stack 来帮助记录。(注意:此处在整个算法里都很重要。) 7. 计算完了以 5 作为高度的矩形面积后,还剩下 1,由于 2 比 1 高,表明后面可能还有更高的点,而以 1 为高度的矩形还能扩展。 8. 下一个比 2 还高,于是继续保留它在 stack 里。   到这里,所有的柱子都遍历完了,如何处理剩下的 3 根柱子呢? 以新的柱子高度为 0,由于 0 低于任何一根柱子的高度,那么对剩下的柱子计算,以它们的高度作为边的矩形的面积。 * 指针停留在下标为 6 的地方,堆栈里记录的是三根柱子的下标:5,4,1。 * 跟之前计算其他柱子的情况一样,先将堆栈里的下标弹出,第一个弹出的是 5。 * 然后比它矮的那根柱子的下标一定是堆栈目前顶端的那个,也就是 4。 * 因此以 3 作为高度的矩形的宽度就是:i - 1 - 4 = 6 - 1 - 4 = 1,那么面积就是 3 x 1 = 3。 剩下的 2 根柱子,方法同样,目前 stack 里的值是:4,1。 把下标 4 弹出,得知比这根柱子还要矮的柱子的下标一定是 stack 顶端的值,也就是 1。 那么以高度 2 作为矩形高度的矩形宽度就是:i - 1 - 1 = 6 - 1 - 1 = 4,面积就是 2 x 4 = 8。 最后处理剩下 1 的柱子。   将它弹出,发现此时堆栈为空。那以 1 作为高度的矩形的宽度是多少呢?很简单,就是 i,也就是 6。因为它一定是最矮的那个才会留到最后,那么它的宽度就应该是横跨整个区间。所以求得面积就是 6。 **代码实现** 1. 一旦我们发现当前的高度要比堆栈顶端所记录的高度要矮,就可以开始对堆栈顶端记录的高度计算面积了。在这里,我们巧妙地处理了当 i 等于 n 时的情况。同时在这一步里,我们判断一下当前的面积是不是最大值。 2. 如果当前的高度比堆栈顶端所记录的高度要高,就压入堆栈。 ``` // 将输入数组的长度记为 n,初始化最大面积 max 为 0 int largestRectangleArea(int[] heights) {     int n = heights.length, max = 0;     // 定义一个堆栈 stack 用来辅助计算     Stack<Integer> stack = new Stack<>();         // 从头开始扫描输入数组     for (int i = 0; i <= n; i++) {         while (             !stack.isEmpty() &&              (i == n || heights[i] < heights[stack.peek()])          ) {             int height = heights[stack.pop()];             int width = stack.isEmpty() ? i : i - 1 - stack.peek();             max = Math.max(max, width * height);         }              stack.push(i);     }     // 返回面积最大值     return max; } ``` **复杂度分析** * 时间复杂度是 O(n),因为从头到尾扫描了一遍数组,每个元素都被压入堆栈一次,弹出一次。 * 空间复杂度是 O(n),因为用了一个堆栈来保存各个元素的下标,最坏的情况就是各个高度按照从矮到高的顺序排列,需要将它们都压入堆栈。 #### 例题分析三 LeetCode 第 28 题:实现 strStr() 函数。给定一个 haystack 字符串和一个 needle 字符串,在 haystack 字符串中找出 needle 字符串出现的第一个位置 (从 0 开始)。如果不存在,则返回 -1。 示例 1 ``` 输入: haystack = "hello", needle = "ll" 输出: 2 ``` 解释:"ll"出现在 haystack 第 2 个位置。 示例 2 ``` 输入: haystack = "aaaaa", needle = "bba" 输出: -1 ``` 解释:"bba"并不出现在 "aaaaa"里 * [ ] 解题思路一:暴力法 实现:在一个字符串中找出某个字符串出现的位置,用暴力法来做是非常简单的,从头遍历一遍 haystack 字符串,每遍历到一个位置,就扫描一下,看看是不是等于 needle 字符串。举例说明如下。 输入: ``` haystack = "iloveleetcode" needle   = "leetcode" ``` 不断移动 needle,来对比是否在 haystack 中,一旦找到就返回它的位置。 注意:当 needle 是空字符串时,应当返回什么值呢?这是一个在面试中很好的问题。对于本题而言,当 needle 是空字符串时应当返回 0 。这与 C 语言的 strstr() 以及  Java 的 indexOf() 定义相符。 代码实现 暴力法的代码实现比较简单,如下。 ``` int strStr(String haystack, String needle) {     for (int i = 0; ; i++) {         for (int j = 0; ; j++) {             if (j == needle.length()) return i;             if (i + j == haystack.length()) return -1;             if (needle.charAt(j) != haystack.charAt(i + j)) break;         }     } } ``` **复杂度分析** 假设 haystack 的字符串长度为 m,needle 字符串的长度为 n,那么暴力法的时间复杂度是 O(m×n)。 * [ ] 解题思路二:KMP KMP(Knuth-Morris-Pratt)是由三人联合发表的一个算法,目的就是为了在一个字符串 haystack 中找出另外一个字符串 needle 出现的所有位置。它的核心思想是避免暴力法当中出现的不必要的比较。 用维基百科中的例题说明。 举例: ``` haystack = "ABC ABCDAB ABCDABCDABDE" needle   = "ABCDABD" ``` 解法 1:暴力法,当比较到上图所示位置的时候,发现 D 和空格不一样。接下来,needle 往前挪动一小步,然后继续和 haystack 比较。 解法 2:KMP,直接让 needle 挪动到如上图所示的位置。 此处有两个常见的问题: * 为什么 KMP 无需慢慢移动比较,可以跳跃式比较呢?不会错过一些可能性吗? * 如何能知道 needle 跳跃的位置呢? **LPS** 为了说明这两个问题,必须先讲解 KMP 里的一个重要数据结构——最长的公共前缀和后缀,英文是 Longest Prefix and Suffix,简称 LPS。 LPS 其实是一个数组,记录了字符串从头开始到某个位置结束的一段字符串当中,公共前缀和后缀的最大长度。所谓公共前缀和后缀,就是说字符串的前缀等于后缀,并且,前缀和后缀不能是同一段字符串。 以上题中 needle 字符串,它的 LPS 数组就是:{0, 0, 0, 0, 1, 2, 0}。 ``` needle = "ABCDABD" LPS    = {0000120} ``` LPS[0] = 0,表示字符串"A"的最长公共前缀和后缀的长度为 0。 注意:虽然"A"的前缀和后缀都等于 A,但前缀和后缀不能是同一段字符串,因此,”A”的 LPS 为 0。 LPS[1] = 0,表示字符串”AB”的最长公共前缀和后缀长度为 0。 因为它只有一个前缀 A 和后缀 B,并且它们不相等,因此 LPS 为 0。 LPS[4] = 1,表示字符串 ABCDA 的最长公共前缀和后缀的长度为 1。 该字符串有很多前缀和后缀,前缀有:A,AB,ABC,ABCD,后缀有:BCDA,CDA,DA,A,其中两个相同并且长度最长的就是 A ,所以 LPS 为 1。 LPS[5] = 2,表示字符串 ABCDAB 的最长公共前缀和后缀的长度为 2。 该字符串有很多前缀和后缀,前缀有:A,AB,ABC,ABCD,ABCDA,后缀有:BCDAB,CDAB,DAB,AB,B,其中两个相同并且长度最长的就是 AB,所以 LPS 为 2。 * [ ] LPS 实现跳跃比较 那么,LPS 数组如何实现跳跃比较 haystack 和 needle 字符串呢? haystack 里面的空格和 needle 里的 D 不相等时,在 needle 里,D 前面的字符串 ABCDAB 与 haystack 中对应的字符串是相等的。 ABCDAB 的 LPS 为 2,即,对于 ABCDAB ,它最后两个字符一定与它最前面两个字符相等。 若把最前面的两个字符挪到最后两个字符的位置,可以保证 AB 位置绝对能和 haystack 配对。 那么,为什么不需要去比较前面的位置? 例如: 例如: 因为没有必要。下面通过反证法来证明。将下图所示情况用抽象成为方块图形来表示。 其中红色的方块表示不相同的字符,分别对应 haystack 中的空格以及 needle 当中的 D 字符;而绿色的方块表示相同的最大前缀和后缀,对应字符串里的 AB。  现在,假设向右挪动了,使得 needle 能与 haystack 完美地匹配,如下所示,可以标出 haystack 与 needle 完美匹配时的关系。即,在 haystack 和 needle 里,有一段区间 A,它们是相同的。 那么,needle 里,红色方块前的一段区间其实和 needle 开头的一段区间是相同的,它们都是 A,如下所示。 即,红色方块前的 needle 字符串,A 是共同的前缀和后缀。而它比两个绿色的方块要长得多,这与之前定义的“两个绿色方块是最长的公共前缀和后缀”相互矛盾。 因此,当知道两个绿色的方块就是最大的公共前缀和后缀时,可以放心地进行跳跃操作,而不必担心会错过完全匹配的情况发生。完美匹配不可能在跳跃的区间内发生。 那么,具体在算法上如何进行跳跃操作呢?     j 指针指向红色方块的位置,needle 的字符与 haystack 的字符不一样。 LPS[j - 1] = 2,即 j 指针前一个字符作为结尾时的最长公共前缀和后缀长度是 2,因此,只需要将 j 移动到 2 的位置即可,也就是 j = LPS[j - 1]。 以上就是 KMP 算法的核心思想,下面来看代码如何实现。 代码实现 假如已经求出了 LPS 数组,如何实现上述跳跃策略?代码实现如下。 ``` int strStr(String haystack, String needle) {     int m = haystack.length();     int n = needle.length();        if (n == 0) {         return 0;     }        int[] lps = getLPS(needle);        int i = 0, j = 0;        while (i < m) {         if (haystack.charAt(i) == needle.charAt(j)) {             i++; j++;                    if (j == n) {                 return i - n;             }         } else if (j > 0) {             j = lps[j - 1];         } else {             i++;         }     }        return -1; } ``` 代码解释: 1. 分别用变量 m 和 n 记录 haystack 字符串和 needle 字符串的长度。 2. 若 n=0,返回 0,符合题目要求。 3. 求出 needle 的 LPS,即最长的公共前缀和后缀数组。 4. 分别定义两个指针 i 和 j,i 扫描 haystack,j 扫描 needle。 5. 进入循环体,直到 i 扫描完整个 haystack,若扫描完还没有发现 needle 在里面,就跳出循环。 6. 在循环体里面,当发现 i 指针指向的字符与 j 指针指向的字符相等的时候,两个指针一起向前走一步,i++,j++。 7. 若 j 已经扫描完了 needle 字符串,说明在 haystack 中找到了 needle,立即返回它在 haystack 中的起始位置。 8. 若 i 指针指向的字符和 j 指针指向的字符不相同,进行跳跃操作,j = LPS[j - 1],此处必须要判断 j 是否大于 0。 9. j=0,表明此时 needle 的第一个字符就已经和 haystack 的字符不同,则对比 haystack 的下一个字符,所以 i++。 10. 若没有在 haystack 中找到 needle,返回 -1。 * [ ] 复杂度分析 KMP 算法需要 O(n) 的时间计算 LPS 数组,还需要 O(m) 的时间扫描一遍 haystack 字符串,整体的时间复杂度为 O(m + n)。这比暴力法快了很多。 * [ ] 例题三扩展 如何求出 needle 字符串的最长公共前缀和后缀数组? **解题思路一:暴力法** 解法:检查字符串的每个位置。 举例:若字符串长度为 m,先尝试比较长度为 m−1 的前缀的后缀,如果两者一样,就记录下来;如果不一样,就尝试长度为 m−2 的前缀和后缀。以此类推。 复杂度:O(n2)。 **解题思路二** 解法:对于给定的字符串 needle,用一个 i 指针从头到尾扫描一遍字符串,并且用一个叫 len 的变量来记录当前的最长公共前缀和后缀的长度。举例说明如下。 当 i 扫描到这个位置的时候,len=4,表明在 i 之前的字符串里,最长的前缀和后缀长度是 4,也就是那 4 个绿色的方块。 现在 needle[i] 不等于 needle[4],怎么计算 LPS[i] 呢? 既然无法构成长度为5的最长前缀和后缀,那便尝试构成长度为 4,3,或者 2 的前缀和后缀,但做法并非像暴力法一样逐个尝试比较,而是通过 LPS[len - 1] 得知下一个最长的前缀和后缀的长度是什么。举例说明如下。 * LPS[len - 1] 记录的是橘色字符串的最长的前缀和后缀,假如 LPS[len - 1]=3,那么前面 3 个字符和后面的 3 个字符相等 * 绿色的部分其实和橘色的部分相同。 * LPS[len - 1] 记录的其实是 i 指针之前的字符串里的第二长的公共前缀和后缀(最关键点)。 * 更新 len = LPS[len - 1],继续比较 needle[i] 和  needle[len]。 代码实现 ``` int[] getLPS(String str) {     // 初始化一个 lps 数组用来保存最终的结果     int[] lps = new int[str.length()];        // lps 的第一个值一定是 0,即长度为 1 的字符串的最长公共前缀后缀的长度为 0,直接从第二个位置遍历。并且,初始化当前最长的 lps 长度为 0,用 len 变量记录下     int i = 1, len = 0;     // 指针 i 遍历整个输入字符串     while (i < str.length()) {         // 若 i 指针能延续前缀和后缀,则更新 lps 值为 len+1         if (str.charAt(i) == str.charAt(len)) {             lps[i++] = ++len;         // 否则,判断 len 是否大于 0,尝试第二长的前缀和后缀,是否能继续延续下去/         } else if (len > 0) {             len = lps[len - 1];         // 所有的前缀和后缀都不符合,则当前的 lps 为 0,i++         } else {             i++;         }     }      return lps;    } ``` **复杂度分析** 时间复杂度为 O(n) ,这是一种比较高效的做法。 举例说明 下面通过举例来加深印象。 例题:needle 是 ADCADBADCADC。   1. 一开始,初始化 LPS 数组全部为 0。 规定前缀和后缀不能是同一个字符串,所以从第二个字符开始扫描,此时 len = 0,i = 1。AD 字符串的最长公共前缀和后缀为 0,因为 A 不等于 D,所以 LPS[1] = 0。 2. 移动到 C。同样,对于 ADC ,最长的公共前缀和后缀也是 0,所以 LPS[2] = 0,此时,len 变量一直是 0。 3. 移动到 A,此时 i=3。 对于字符串 ADCA,因为 needle[len] = needle[3],所以执行代码 lps[i++] = ++len,也就是把 len+1 赋给 lps[i],然后 i + 1,len + 1,表明对于字符串 ADCA,最长的公共前缀和后缀的长度为 1。 4. 接下来到 D,此时 i = 4,len = 1。 同样,由于 needle[len] 等于 needle[i],都是字符 D,所以再次执行代码 lps[i++] = ++len,这样一来,lps[4] 就等于 2,表明对于字符串 ADCAD,最长的公共前缀和后缀长度是 2。 5. 接下来是 B,此时 i = 5,len = 2。 needle[len] ='C',而 needle[i] ='B',两者不相等,同时,len 大于 0,将 len 修改为 lps[len - 1],取出字符串 AD 的最长公共前缀和后缀的长度,也就是 0。当循环再次进行,needle[len] 仍不等于 neele[i],因此对于 ADCADB ,最长的公共前缀后缀长度为 0。 建议:以上基本概括了 KMP 的算法思想和精髓,其实 KMP 的代码实现是很精妙的,建议大家不要去死记硬背,通过理解去帮助记忆。 #### 结语 这节课讲解了三道比较难的题目,其中正规表达式以及 KMP 算法是重中之重。