腾讯一面算法题:最长重复子串 1044,讲个比较好理解的思路

1044. 最长重复子串

困难

给你一个字符串 s ,考虑其所有 重复子串 :即 s 的(连续)子串,在 s 中出现 2 次或更多次。这些出现之间可能存在重叠。

返回 任意一个 可能具有最长长度的重复子串。如果 s 不含重复子串,那么答案为 “” 。

示例 1:

输入:s = “banana”
输出:“ana”

示例 2:

输入:s = “abcd”
输出:“”


题目 End…

前言

这个解法我是学习了其他大佬的题解后,我自己琢磨出的一个比较好理解的版本,但是效率并没有那么高,并且这个解法有一定误判的可能(并且官网题解也可能误判:官网使用随机数来取模 + 随机数作为进制,并且保证这两随机数的大小,所以保证了误判的概率会非常非常小,但是如果对这个随机数予以定值,仍然有可能误判,大家可以试试。个人浅见,如果有错误欢迎指出问题

所以我写的这个解法主要有两个意义:

  1. 可以以较低的成本加深大家对字符串哈希算法的理解,并且可以 完成并通过 这个题目
  2. 看了这篇文章之后,再去看官网的或者其他大佬写的更高效的版本可能会有帮助

在这里插入图片描述

思路

Version 1:暴力

首先这个是最容易想到的版本,枚举所有的子字符串,并加入哈希表中,进行判重

class Solution {
public:
    string longestDupSubstring(string s) {
        unordered_map<string, int> mp;
        string res;

        for (int i = 0; i < s.size(); i ++) {
            for (int len = 1; i + len <= s.size(); len ++) {
                string sub = s.substr(i, len);
                if (++ mp[sub] >= 2 && sub.size() > res.size()) {
                    res = sub;
                }
            }
        }

        return res;
    }
};

但是很明显会超时,第 19 个用例就无法通过。这里有几个问题可以尝试优化

  1. 是不是不需要遍历所有长度的子字符串?来优化掉这个 O ( n 2 ) O(n^2) O(n2)
  2. 在数据量非常庞大的情况下,unordered_map 可能存在性能退化的问题,在哈希冲突的情况下,时间复杂度可能会退化为 l o g 2 n log_2n log2n
  3. 其次,如果发生了哈希冲突,又需要针对哈希表红黑树结点内存值来比较 K e y Key Key 是否相等 —— 存在字符串比较,如果一个字符串长度是 3 ∗ 1 0 4 3 * 10^4 3104,那其实性能开销还是不小的

Version 2:引入二分,优化 O ( n 2 ) O(n^2) O(n2)

这个其实不难理解:

  • 如果存在一个字符串长度为 n,并且重复了,那么一定存在重复的、长度为 n - 1 的字符串
  • 如果不存在一个字符串长度为 n 的重复字符串,那么一定不存在重复的、长度为 n + 1 的字符串

举个例子
b a n a n a banana banana,明显最长重复子字符串为 a n a ana ana

  • 存在 a n a ana ana 长度为 3 的重复字符串,那么一定存在 a n an an 长度为 2 的子字符串(或者 n a na na
  • 如果不存在长度为 4 的重复字符串,那么一定不存在长度为 5 的重复子字符串

所以这里就可以通过二分查找的方式 ′ 猜答 案 ′ '猜答案' 猜答,思路有点像二分答案。如果猜大了,并且不存在这个长度的重复字符串,那么说明一定没有更大的重复字符串。

Version 3:引入自定义哈希,优化字符串比较

首先明确一点:如果有两个字符串 a a a b b b,那么就算不讨论哈希带来的消耗,在我们将 a a a 放入哈希表后,再尝试插入 b b b,如果发生哈希冲突了怎么办?

会遍历哈希桶链表(甚至红黑树),逐个结点判断是否存在重复 K e y Key Key,即就是 a a a 会和 b b b 发生逐字节比较

  • 如果 a a a b b b 长度很长呢?
  • 如果发生哈希冲突的不止一个结点呢?还有 c c c d d d e e e 字符串,并且他们长度都很长呢?

所以我们需要针对这里的字符串制定更高效的哈希方式:

假设现在有两个字符串, " a b c " "abc" "abc" (称为 A), " b c d " "bcd" "bcd"(称为 B)

  • 如果我们把他们想象成一个整数?假设 a = 1, b = 2, c = 3, d = 4
  • 那么 A 字符串,就可以是: 1 ∗ 100 + 2 ∗ 10 + 3 = 123 1 * 100 + 2 * 10 + 3 = 123 1100+210+3=123
  • 那么 B 字符串,就可以是: 2 ∗ 100 + 3 ∗ 10 + 4 = 234 2 * 100 + 3 * 10 + 4 = 234 2100+310+4=234
  • 那么这两个字符串的比较是否就变成了整数的比较?成百上千个字节的比较可以直接优化成 4或8 个字节的比较

但是显然小写字母有 26 个,所以这里的进制不能是 10,可以定成 31 进制,32 进制等,但是最好定成 31、67 这些质数(有利于降低哈希冲突,提高性能,具体设为多少更合适那就是专业人员做的事了,我不知道)

那么如果不考虑数据范围,那么我们是不是就根本不用考虑哈希冲突了?没错,因为字符串越长,这个哈希值一直都是递增的,只要数据范围装得下,除非字符串相等,否则永远都不会碰撞,效率嘎嘎高。可惜的是我们需要考虑数据范围,但是这个问题等会再说

Version 4:计算所有字符串的哈希值

先解决一个问题,如果我们要计算长度为 3 的字符串哈希值,比如上面的 a b c abc abc,那还是很好操作的,那么如果现在有字符串 a b c d e f g abcdefg abcdefg,我们怎么高效地计算所有长度为 3 的字符串哈希值呢?

  • 上面说了 a b c abc abc = 1 ∗ 100 + 2 ∗ 10 + 3 = 123 1 * 100 + 2 * 10 + 3 = 123 1100+210+3=123
  • 那么如果我们要计算 b c d bcd bcd 呢?我们是不是要舍弃 a a a 字母,也就是减少 a ∗ 100 a * 100 a100 呢?
  • 好,现在减少了 a ∗ 100 a * 100 a100,但是现在 b b b 变成了百位, c c c 变成了个位,是不是需要 ( 2 ∗ 10 + 3 ) ∗ 10 (2 * 10 + 3) * 10 (210+3)10 呢?也就是 【乘上进制
  • 最后加上 d d d

OK,于是就可以通过这种滑动窗口的方式, O ( n ) O(n) O(n) 复杂度计算所有长度为 3 的子字符串

Version 5:引入无符号长整型,自动取模

为了防止数据溢出,我们就可以引入取模,来防止数据溢出,但是为了简化程序,简化思路,这里可以使用 unsigned long long,无符号长整形,自带取模效果。

Version 6:引入双哈希,大大降低哈希碰撞

但是这样问题又来了,如果取模了,那么原本递增、很大的数据有可能突然特别小,以至于和前面已经哈希的值发生碰撞,那是否重复就不好说了

于是引入二次哈希,我们使用另一个不同的进制来计算该字符串的哈希值。如果字符串 A A A,字符串 B B B 两次哈希值都一样,那么我们认为字符串相等,所以可能存在误判,但是概率极小。

总结

  1. 二分答案,猜最长重复子串的长度,并验证
  2. 用数字表示字符串,高效进行哈希
  3. 防止溢出,自动取模,使用 unsigned long long
  4. 取模后导致哈希冲突,使用双哈希降低冲突概率
  5. (ps:pair<ULL, ULL> 不支持哈希,故使用 set

代码

class Solution {
public:
    constexpr static int P1 = 31;			// 31 进制
    constexpr static int P2 = 67;			// 67 进制


    // 最长长度的重复子串
    string longestDupSubstring(string s) {
        string res ;

		// 二分答案
        int left = 0, right = s.size() - 1;
        while (left <= right) {
            // +1 即向上取整,向上取整可以防止 guess_len = 0,比如 left = 0, right = 1
            int guess_len = left + ((right - left + 1) >> 1);
            string search = find(s, guess_len);	// 开始找有没有这个长度的重复子字符串

            if (search.size() == 0) {       // 没找到捏
                right = guess_len - 1;
            }
            else {                          // 找到了
                left = guess_len + 1;
            }

            if (search.size() > res.size())  res = search;
        }

        return res;
    }

    using ULL = unsigned long long;     
    using PULL = pair<ULL, ULL>;		// pair<ULL, ULL> 用来存储两次哈希值
    string find(const string& s, int len) {
        // 通过滑动窗口的方式来计算所有子串的 哈希值,ULL 会自动帮我们取模
        ULL hash1 = 0, hash2 = 0;
        ULL base1 = 1, base2 = 1;       // 当前 len 的最大进制,用于窗口右移的删减

        set<PULL> vis;	// 判重
        for (int i = 0; i < len; i ++) {
            hash1 = (hash1 * P1) + s[i];
            hash2 = (hash2 * P2) + s[i];

            base1 *= P1;	// 计算最高位,比如上面 abc 中的 100,自己模拟一次就明白了
            base2 *= P2;
        }
        vis.insert({hash1, hash2});	// 插入两次哈希值

        for (int i = len; i < s.size(); i ++) {
            hash1 = (hash1 * P1 + s[i]) - base1 * s[i - len];
            hash2 = (hash2 * P2 + s[i]) - base2 * s[i - len];

            if (vis.count({hash1, hash2})) {
                return s.substr(i - len + 1, len);
            }
            vis.insert({hash1, hash2});
        }

        return "";
    }
};

主要参考资料

【字符串哈希】字符串哈希入门(宫水三叶)
【微扰理论】Rabin-Karp + 二分搜索

  • 23
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

答辣喇叭

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值