1.题目
Given a string s, find the longest palindromic substring in s. You may assume that the maximum length of s is 1000.
Example 1: Input: "babad" Output: "bab" Note: "aba" is also a valid answer.
Example 2: Input: "cbbd" Output: "bb".
题目的大意是求取给定字符串中最长的回文子串(palindromic substring),顺带限定了字符串的最大长度(1000),简化编码不用malloc了,不过无关紧要.
2.思路
最原始的做法,暴力搜索:找出字符串s的所有可能子串,判断每个子串是否回文串.其思路比较简单,直接贴代码:
int is_palindrome(char *s, int begin, int end) { while (begin <= end && s[begin] == s[end]) ++begin, --end; if (begin < end) return 0; else return 1; } char *longestPalindrome(char *s) { int i, step; int max, begin; if (strlen(s) < 2) return s; max = 0; begin = 0; for (i = 0; i < strlen(s); ++i) { for (step = 1; step <= strlen(s) - i - 1; ++step) { if (is_palindrome(s, i, i + step)) { if (step > max) { begin = i; max = step; } } } } s[begin + max + 1] = 0; return &s[begin]; }
对于一个长度为n的字符串,其子串(不含单个字符的情形)共有n*(n-1)/2,因为一组下标(begin,end)表达了一个子串,从0,1,2,...,n-1个数中选出两个数,就是个排列组合问题.对于每个子串,判断其是否回文串,时间复杂度Ο(n),所以暴力破解的时间复杂度是Ο(n3).
很显然暴力搜索的方法是可能优化的.如果在求解一个问题的时候,所有的已知条件都使用了,那么这个解法应该不太可能优化了,如果还存在未使用的条件,那说明解法还存在优化的可能.有些时候这些条件是很明显的,而有些时候却隐藏的很深,甚至需要复杂的数学推导(个人的一点心得,有不对的,轻拍).回到正题,那么在暴力破解法中有哪些条件是我们还未使用的呢?例如知道"bab"是回文串,在判断"xbabx"时还有必要遍历吗?显然只需要比较两端的字符是否相等即可.
3.空间换时间
为了判断子串是否是回文串,我们可以利用已有的回文串信息简化操作,这意味着我们需要将已经找到的回文串信息记录下来.这是一种典型的空间换时间的做法.思路有了,如何实现呢?
对于一个回文子串,有三个信息:起始下标,结束下标,长度(或者认为是两个信息,长度可以用起止坐标计算).设L(i,j)表示一个回文子串的长度,S[i]表示下标i处的字符,根据我们的分析有以下结论:
L(i,j) = L(i+1,j-1) + 2; if S[i] == S[j]
特别地,L(i,i) = 1, L(i,i+1) = 2 if S[i] == S[i+1]. 有了这个结论,我们就可以用一个二维数组P[i][j]保存已知的回文子串信息来提高效率(你可能习惯称这种方法为动态规划(dynamic programming)).
/* 采用动态规划方式求解最长回文子串. * 设P(i,j)为回文子串的长度,则有如下关系: * P(i,j) = P(i+1,j-1) + 2; if S[i] == S[j] * 特别地, P(i,i) = 1; P(i,i+1) = 2 if S[i] == S[i+1] */ char * longestPalindrome(char *s) { int P[1000][1000] = {0}; int i, j, maxi, maxj, max; size_t len; len = strlen(s); if (len < 2) return s; max = 1; maxi = maxj = 0; for (j = 0; j < len; ++j) { for (i = 0; i <= j; ++i) { if (i == j) P[i][j] = 1; else if (i + 1 == j && s[i] == s[j]) P[i][j] = 2; else if (i + 1 < j && P[i+1][j-1] > 0 && s[i] == s[j]) P[i][j] = P[i + 1][j - 1] + 2; if (P[i][j] > max) { max = P[i][j]; maxi = i; maxj = j; } } } s[maxj + 1] = 0; return &s[maxi]; }
上述代码可以将更新max(最长回文子串的长度)的操作放在if语句分支里面减少不必要的判断,这里为了代码简洁没有这么做.这种解法相比于暴力破解法的优势是提高了判断一个子串是否回文串的效率(Ο(n)变为Ο(1)),故整个算法的时间复杂度是Ο(n2).
是否到此为止了呢?是否还有优化的可能或者说是否还存在未使用的已知条件?当然是有的(不然就不会有这篇博客了XD).所谓回文串是反转后和原字符串相同的字符串(等等,这个条件我们前面两种方法不都用到了吗?确实是用了,但并未有效或者说完全利用.),对于每一个回文子串S(i,j),可以假想其有一个中点下标是c,有一个半径r=L(i,j)/2,中点两边距中点距离相等的字符必然相等,这是一种对称性,利用这个性质我们能简化下标在(c, c+r)范围内的每个点的最大回文串长度的计算吗?答案是YES!
4.Manacher算法
关于这个算法的详细说明我就不班门弄斧了,附上两个链接,一个e文,一个中文,图文并茂,个人认为讲的都比较清楚.
最后附上自己的实现作为结束:
/* Manacher's Algorithm * 关键思想是利用回文字符串的对称性,为了简化处理字符串长度的奇偶问题, * 对原字符串进行了预处理.对于处理后的字符串为了简化边界处理,在起始 * 位置设置了哨兵,结束位置采用'\0'作为哨兵. */ #define min(x, y) ((x) > (y) ? (y) : (x)) char * preProcess(char *s) { size_t len, i; char *ret; len = strlen(s); ret = malloc((2 * len + 3) * sizeof(char)); memset(ret, '#', 2 * len + 3); /* 设置哨兵,简化计算P时的越界检查,设置的哨兵不能等于原字符串中 * 任意一个字符. */ ret[0] = '^'; for (i = 1; i <= len; ++i) ret[2 * i] = s[i - 1]; ret[2 * len + 2] = 0; return ret; } char * longestPalindrome(char *s) { int *P; size_t len, max, center, right, i, mirror; char *ptr; len = strlen(s); if (len < 2) return s; ptr = preProcess(s); P = calloc(2 * len + 2, sizeof(int)); /* 比处理后的字符串少一个结束符'\0' */ center = 0; /* 回文字符串的中点 */ right = 0; /* 回文字符串的右边界 */ for (i = 1; i < 2 * len + 2; ++i) { mirror = 2 * center - i; /* center - (i - center) */ /* 利用回文字符串的对称性快速计算i处的回文字符串长度: * i处关于中点center的对称点mirror处的回文字符串长度P[mirror]小于i到边界right的距离, * 则P[i] = P[mirror],否则P[i] = right - i. */ P[i] = (right > i) ? min(right - i, P[mirror]) : 0; /* 往右搜索扩展回文串,因为ptr[0]设置了哨兵故无需担心数组越界 */ while (ptr[i + P[i] + 1] == ptr[i - P[i] - 1]) ++P[i]; /* 更新回文串的中点和右边界 */ if (i + P[i] > right) { center = i; right = i + P[i]; } } /* 返回最长的回文串 */ max = 0; for (i = 1; i < 2 * len + 2; ++i) { if (P[i] > max) { max = P[i]; center = i; } } free(P); free(ptr); s[(center + max) / 2] = 0; return &s[(center - max) / 2]; }