回文子串和回文链表
一、最长回文子串
1.题目描述
2.分析
- 对于这个问题,我们首先应该思考的是,给一个字符串 s,如何在 s 中找到一个回文子串?
- 有一个很有趣的思路:既然回文串是一个正着反着读都一样的字符串,那么如果我们把 s 反转,称为 s’,然后在 s 和 s’ 中寻找最长公共子串,这样应该就能找到最长回文子串。
- 比如说字符串 abacd,反过来是 dcaba,它的最长公共子串是 aba,也就是最长回文子串。
- 但是这个思路是错误的,比如说字符串 aacxycaa,反转之后是 aacyxcaa,最长公共子串是 aac,但是最长回文子串应该是 aa。
- 虽然这个思路不正确,但是这种把问题转化为其他形式的思考方式是非常值得提倡的。
- 下面,就来说一下正确的思路,如何使用
双指针
。 - 寻找回文串的问题核心思想是:
从中间开始向两边扩散来判断回文串
。对于最长回文子串,就是这个意思:
for 0 <= i < len(s):
找到以 s[i] 为中心的回文串
更新答案
但是呢,我们刚才也说了,回文串的长度可能是奇数也可能是偶数
,如果是 abba这种情况,没有一个中心字符,上面的算法就没辙了。所以我们可以修改一下:
for 0 <= i < len(s):
找到以 s[i] 为中心的回文串
找到以 s[i] 和 s[i+1] 为中心的回文串
更新答案
3.代码实现
string palindrome(string& s, int l, int r) {
// 防止索引越界
while (l >= 0 && r < s.size() && s[l] == s[r])
{
// 向两边展开
l--;
r++;
}
// 返回以 s[l] 和 s[r] 为中心的最长回文串
return s.substr(l + 1, r - l - 1);
}
为什么要传入两个指针 l 和 r 呢?因为这样实现可以同时处理回文串长度为奇数和偶数的情况:
for 0 <= i < len(s):
# 找到以 s[i] 为中心的回文串
palindrome(s, i, i)
# 找到以 s[i] 和 s[i+1] 为中心的回文串
palindrome(s, i, i + 1)
更新答案
longestPalindrome完整代码
:
string longestPalindrome(string s)
{
string res;
for (int i = 0; i < s.size(); i++)
{
// 以 s[i] 为中心的最长回文子串
string s1 = palindrome(s, i, i);
// 以 s[i] 和 s[i+1] 为中心的最长回文子串
string s2 = palindrome(s, i, i + 1);
// res = longest(res, s1, s2)
res = res.size() > s1.size() ? res : s1;
res = res.size() > s2.size() ? res : s2;
}
return res;
}
- 至此,这道最长回文子串的问题就解决了,
时间复杂度 O(N^2),空间复杂度 O(1)。
- 值得一提的是,这个问题可以用
动态规划方法解决
,时间复杂度一样,但是空间复杂度至少要 O(N^2) 来存储 DP table
。这道题是少有的动态规划非最优解法的问题。
二、判断回文链表
1.问题描述
2. 分析
普通暴力求解就不说了!!!
-
借助二叉树后序遍历的思路,不需要显式反转原始链表也可以倒序遍历链表
,下面来具体聊聊。 - 对于二叉树的几种遍历方式,我们再熟悉不过了:
void traverse(TreeNode root) {
// 前序遍历代码
traverse(root.left);
// 中序遍历代码
traverse(root.right);
// 后序遍历代码
}
- 链表兼具递归结构,树结构不过是链表的衍生。那么,
链表其实也可以有前序遍历和后序遍历
:
void traverse(ListNode head) {
// 前序遍历代码
traverse(head.next);
// 后序遍历代码
}
- 这个框架有什么指导意义呢?
如果我想正序打印链表中的val值,可以在前序遍历位置写代码;反之,如果想倒序遍历链表,就可以在后序遍历位置操作
:
/* 倒序打印单链表中的元素值 */
void traverse(ListNode head) {
if (head == null)
return;
traverse(head.next);
// 后序遍历代码
print(head.val);
}
- 说到这了,其实可以稍作修改,
模仿双指针实现回文判断的功能
:
3.代码
// 左侧指针
ListNode left;
boolean isPalindrome(ListNode head)
{
left = head;
return traverse(head);
}
boolean traverse(ListNode right)
{
if (right == null)
return true;
boolean res = traverse(right.next);
// 后序遍历代码
res = res && (right.val == left.val);
left = left.next;
return res;
}
- 这么做的核心逻辑是什么呢?
实际上就是把链表节点放入一个栈,然后再拿出来,这时候元素顺序就是反的,只不过我们利用的是递归函数的堆栈而已
。
当然,无论造一条反转链表还是利用后续遍历,算法的时间和空间复杂度都是 O(N)
。下面我们想想,能不能不用额外的空间,解决这个问题呢?
4.优化
-
1、先通过「双指针技巧」中的快慢指针来找到链表的中点:
ListNode slow, fast;
slow = fast = head;
while (fast != null && fast.next != null)
{
slow = slow.next;
fast = fast.next.next;
}
// slow 指针现在指向链表中点
-
2、如果fast指针没有指向null,说明链表长度为奇数,slow还要再前进一步:
if (fast != null)
slow = slow.next;
-
3、从slow开始反转后面的链表,现在就可以开始比较回文串了:
ListNode left = head;
ListNode right = reverse(slow);
while (right != null)
{
if (left.val != right.val)
return false;
left = left.next;
right = right.next;
}
return true;
至此,把上面 3 段代码合在一起就高效地解决这个问题了,其中reverse函数很容易实现:
ListNode reverse(ListNode head) {
ListNode pre = null, cur = head;
while (cur != null)
{
ListNode next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
}
- 算法总体的
时间复杂度 O(N),空间复杂度 O(1)
,已经是最优的了。 - 我知道肯定有读者会问:这种解法虽然高效,但
破坏了输入链表的原始结构
,能不能避免这个瑕疵呢? - 其实这个问题很好解决,关键在于得到p, q这两个指针位置:
这样,只要在函数 return 之前加一段代码即可恢复原先链表顺序:
p.next = reverse(q);
三、最后总结
- 首先,
寻找回文串是从中间向两端扩展,判断回文串是从两端向中间收缩
。 - 对于单链表,无法直接倒序遍历,可以造一条新的反转链表,可以利用链表的后序遍历,也可以用栈结构倒序处理单链表。
- 具体到回文链表的判断问题,由于回文的特殊性,可以不完全反转链表,而是仅仅反转部分链表,将空间复杂度降到 O(1)。