【LeetCode选讲·第十三期】「串联所有单词的子串」(滑动窗口)(队列)(哈希表)

150 阅读11分钟

T25 串联所有单词的子串

题目链接:leetcode.cn/problems/su…

朴素解法:逐位遍历+数值统计

这是一种很容易想到的方法:

我们可以逐个选取传入字符串str中的字符为起点,向后对字符串进行遍历,按words中单词的固定长度截取子串。同时我们维护一个记录表record统计遍历过程中遇到的words数组中的单词(这里我们采用更容易编写的减法形式进行记录)。

record中的所有记录恰好归零且中途没有遇到words中没有的单词,则视为找到了一个正确答案。

由于我们逐个选取str中的字符为起点,因此这种解法不可能出现漏解,同时自然而然也消耗大量的执行时间来完成代码的执行

代码如下:

function findSubstring(str, words) {
    let wordLen = words[0].length;
    let wordNum = words.length;
    let ans = [];
    for (let i = 0; i <= str.length - wordLen * wordNum; i++) {
        let record = {};
        words.forEach(word => record[word] ? record[word]++ : (record[word] = 1));
        for (let j = i; j <= i + wordLen * (wordNum - 1); j += wordLen) {
            let ch = str.slice(j, j + wordLen);
            if ((record[ch]--) > 0) {
                //当遇到计数恰好归零的单词,
                //则尝试校验words中的单词是否已全部归零。
                if (record[ch] === 0 && check(record)) {
                    ans.push(i);
                    break;
                }
            } else {
                //当遇到record中没有的单词,或计数已归零的单词,
                //则说明此处一定不是符合题意的「串联子串」,直接退出循环。
                break;
            }
        }
    }
    return ans;
}
function check(record) {
    for (let i in record) {
        if (record[i] !== 0) return false;
    }
    return true;
}

提交结果:

2.png

优化解法:滑动窗口

初次尝试:滑动窗口+队列

首先我们需要引入本文的主角——「滑动窗口」。实际上它就指的是能够左右移动,且一般情况下宽度固定的字符串区间。因为它如同一个像窗子一样的框,可以"框"住字符串中一定范围内的字符,故得此名。

画个重点:「滑动窗口」最大的特色就在于宽度固定,而本题中我们寻找的子串长度恰好就是固定的(即wordLen * wordNum),因此我们可以想到用这种方法来解题。

下面我们通过一个最简单的例子来理解「滑动窗口」的基本用法。

假设str = "barfoothefoobarman"words = ["foo","bar"]

现在str最左端i = 0处建立一个宽度为wordLen * wordNum = 6的「滑动窗口」,

记作 "[barfoo]thefoobarman"

我们注意到,此时被窗口"框"住的区域恰好是一个符合题意的子串!但我们的工作还没有结束,下面让我们每次向右滑动wordLen个单位距离,直至字符串末端,以尝试寻找右端的其他答案

滑动1次,得"bar[foothe]foobarman",不符合。

滑动2次,得"barfoo[thefoo]barman",不符合。

滑动3次,得"barfoothe[foobar]man",我们找到了另一个答案!

仅考虑上面的滑动过程,我们可以得出两个基本结论:

  1. 经过完整的滑动,并不能保证字符串中所有的字符都被"框"过;
  2. 如从最左侧开始,若str中所有单词长度相等,则每滑动一次都会有一个右侧的单词"进入"窗口,同时左侧的一个单词"离开"窗口.

首先,由结论(1)我们可以敏锐地察觉到:「滑动窗口」进出的特性可以用「队列」进行模拟。更幸运的是,JavaScript中「数组」功能基本等同于「队列」,我们可以直接使用其来编写代码。

这对我们来说比较简单,因而也不是本文的重点。

下面让我们将注意力集中于如下的疑问:

  1. 既然无法保证所有字符都被"框"过,是否会存在漏解?
  2. str中的干扰单词与words中目标单词长度不一,窗口不就不能精准地框住目标子串了吗?

事实上这两个问题有一定的关联性。我们先来考虑问题(2)。

很明显,如果我们始终让窗口从最左端开始滑动,当干扰单词长度不一时,的确无法精准框住答案。例如假设words = ['jng'],那么对于"xjng""xxjng"……这些字符串刚才的方法就行不通了。

怎么办呢?其实很简单,我们只要动态调整窗口的起始位置i就可以解决了!

为了便于理解如何"调整",我们继续沿用上面的例子予以说明:

str = "jng"时,令i = 0,即"[jng]",经0次移动找到答案.

str = "xjng"时,令i = 1,即"x[jng]",经0次移动找到答案.

str = "xxjng"时,令i = 2,即"xx[jng]",经0次移动找到答案.

str = "xxxjng"时,令i = 0,即"[xxx]jng",经1次移动找到答案.

str = "xxxxjng"时,令i = 1,即"x[xxx]jng",经1次移动找到答案.

str = "xxxxxjng"时,令i = 2,即"xx[xxx]jng",经1次移动找到答案.

……

通过观察,我们可以容易地归纳出如下的结论:

i = 目标子串左侧字符总数 % wordLen. (实际上i就是一个周期函数)

可遗憾的是,在找到目标子串前,程序压根不可能知道其左方到底有几个字符,因此这条结论是无法直接使用的。

实际上我们在算法实现过程中需要的是它的一条二级结论:

i为区间[0, wordLen - 1](实际上就是周期函数的值域)范围内的所有情况中,必然存在某一种情况能够使得窗口在若干次移动后精准框住目标子串。

根据这条结论,由于wordLen是已知的,因此我们只需要对所有可能的i建立循环进行枚举,再逐一验证能否搜得正确答案就可以解决问题!

显然,我们也可以使用这个结论来处理干扰单词长度不规则的、含多个目标子串的字符串。例如令str = "xjngxxjngxxjng",我们枚举区间[0, 2]内的所有情况,则可解得当i分别为012时,窗口经若干次移动后,恰好能分别框中的不同目标子串。

现在我们也就可以消除问题(1)中的疑惑了:想要搜索字符串中的任意目标子串,只要按上文介绍的结论枚举窗口起始位置即可,与窗口能否"框"过所有字符无关,因此根本无需担心漏解。

到此为止,「滑动窗口」的基本原理也就介绍完毕了。下面让我们过一遍代码:

const findSubstring =(str, words) => {
    const wordLen = words[0].length;
    const wordNum = words.length;
    const ans = [];
    //首先对目标数组words进行排序,便于后面的校验
    words.sort();
    for (let i = 0; i < wordLen; i++) {
        const queue = [];
        for (let j = i; j <= str.length - wordLen; j += wordLen) {
            let ch = str.slice(j, j + wordLen);
            //右边下一个单词进队列
            queue.push(ch);
            //检查窗口是否已经形成,
            //如果创建完就移除区域左侧的单词。
            if(queue.length >= wordNum) {
                //剪枝:当遇到words中存在的单词才尝试进行校验
                words.includes(ch) && check(queue, words) && ans.push(j - wordLen * (wordNum - 1));
                //窗口最左边的单词出队列
                queue.shift();
            }
        }
    }
    return ans;
};
const check = (queue, words) => {
    //为了便于校验,需要先排序数组queue的拷贝
    let tempArr = Array.from(queue).sort();
    for(let i = 0; i < tempArr.length; i++) {
        if(tempArr[i] !== words[i]) return false;
    }
    return true;
};

编码过程中的注意点已通过注释进行提醒,故本文不再赘述!

下面我们来分析一下这种方法的性能情况。别忘了我们学习「滑动窗口」的最初目的就是提示算法效率!

相比先前的「朴素解法」,现在我们得以将遍历的起点范围压缩为[0, wordLen - 1]这个极小的区间。虽然我们将每轮遍历指针j的范围由原先的i + wordLen * (wordNum - 1)扩大到了str.length - wordLen,但在str长度大于目标子串长度,且窗口可以每次移动wordLen这样一个大距离的实际条件下,使用「滑动窗口」明显具有压倒性优势。

但是很明显,借助「队列」实现的「滑动窗口」仍存在一定的性能问题:

为了便于校验用于储存当前窗口信息的数组queue和目标数组words中的内容是否相等,每次校验时我们不得不对queue进行拷贝和排序,造成了时间上的较大开销。

2.png

这时候,有同学会提出如下的解决方案:

const check = (queue, words) => {
    if(queue.length !== words.length) return false;
    let temp = Array.from(queue);
    for(let word of words) {
        let idx = temp.indexOf(word);
        if(idx >= 0) {
            temp.splice(idx, 1);
        } else {
            return false;
        }
    }
    return temp.length === 0;
};

但提交后我们发现优化的幅度微乎其微:

2.png

其原因在于,虽然我们用indexOfsplice等方法代替了数组排序,但这些方法因为基于"索引搜索"实现,仍然占据了可观的性能开销。而基于"键查询"的Set由于要求集合中数据的唯一性,也不适合用于本题的解答。

有没有什么更快的解决方案?

再次改进:滑动窗口+哈希表(数值统计)

实际上,我们可以用「哈希表」来代替上面代码中的数组这一数据结构。

基本思路如下:

  • 在程序运行伊始,创建哈希表targetHash用于储存words中单词及其数量;
  • 在遍历字符串过程中维护哈希表curHash实时记录「移动窗口」中的单词及数量;
  • 当遇到targetHash中存在的单词且窗口已经形成时,尝试比较targetHashcurHash两者信息是否相同.

可见替代后哈希表发挥的作用与数组大致相同,都是对「滑动窗口」中的信息进行记录。只不过相比能够形象地实现窗口进出效果的数组,哈希表更加抽象一些。

那TA为什么就能实现优化呢?

我们来看一下在本题中如何实现哈希表的比较:

当比较targetHashcurHash时,我们首先比较它们记录条数是否相同。若记录条数不相同,则说明记录中的单词肯定存在差异,直接返回结果即可。然后我们再检测targteHash中每条记录的键和值能否在curHash中获取即可。

由此可见,倘若我们采用哈希表进行比较,则只需关注能否查询到对应的键和值,而非像数组一样因为需要考虑顺序而不得不使用排序。

同时,由于JavaScript中的哈希表(也就是Map对象)基于"键值查询"实现,故对其进行查询操作的效率远高于对数组元素的查询,从而帮助我们避免了因使用数组中各种涉及索引搜索的操作而带来的庞大性能开销。

从本质上讲,这就是「朴素解法」中「数值统计」思想的迁移运用。但现在我们已站在了更高的角度来使用这种思想:通过其来较为抽象地实现「滑动窗口」,以求摆脱形象的数组实现方案,提升程序性能。

代码如下:

/* 
   tip: 
   从代码的可维护性和可移植性出发,这里我们用Map对象
   封装一个通用的用于统计的哈希表类,称作「统计哈希表」。
   或许TA在以后的题目中还能继续排上用场!
*/
class StatHash {
    constructor(keys) {
        this.map = new Map();
        //不同于map.size,这里的size用于储存所有key的
        //统计数量之和,在本题中就是窗口中所含的单词数。
        //类似于前面示例代码中的queue.length!
        this.size = 0;  
        keys && keys.forEach(key => this.add(key));
    }
    add(key) {
        //与原生Map的set不同,这里是将某一个key的统计数量+1,
        //除非第一次统计某个key,否则不会创建新记录!
        const map = this.map;
        if(map.has(key)) {
            map.set(key, map.get(key) + 1);
        } else {
            map.set(key, 1);
        }
        this.size++;
        return map.get(key);
    }
    remove(key) {
        //与原生Map的delete不同,这里是将某一个key的统计数量-1,
        //除非某个key的统计数量归0,否则不会删除相应记录!
        const map = this.map;
        const num = map.get(key);
        if(num > 0) {
            num > 1 ? map.set(key, num - 1) : map.delete(key);
            this.size--;
        }
        return map.get(key);
    }
    has(key) {
        return this.map.has(key);
    }
    get(key) {
        return this.map.get(key);
    }
    isEmpty() {
        return !this.map.size;
    }
    static compare(h1, h2) {
        //静态方法,用于比较两个「统计哈希表」中信息是否相同
        if (h1.map.size !== h2.map.size) return false; 
        for(let [key, value] of h1.map) {
            if (h2.get(key) !== value) return false;
        }
        return true;
    }
}
/* findSubstring函数与之前基于数组的解答代码基本相同,就不细说了。 */
const findSubstring = (str, words) => {
    const wordLen = words[0].length;
    const wordNum = words.length;
    const targetHash = new StatHash(words);
    const ans = [];
    for (let i = 0; i < wordLen; i++) {
        const curHash = new StatHash();
        for (let j = i; j <= str.length - wordLen; j += wordLen) {
            let ch = str.slice(j, j + wordLen);
            curHash.add(ch);
            if(curHash.size >= wordNum) {
                let idx = j - wordLen * (wordNum - 1);
                targetHash.has(ch) && StatHash.compare(targetHash, curHash) && ans.push(idx);
                curHash.remove( str.slice(idx, idx + wordLen) );
            }
        }
    }
    return ans;
};

提交结果:

2.png

如果我们还希望继续提升代码的执行速度,可以在代码核心部分执行前,对某些「奇葩」的测试样例直接返回结果,例如当words.length = 0时直接返回一个空数组,从而避免后续代码的执行。

当然了,这并不属于算法学习的重点,就交给大家自行尝试了~


最后顺便贴上用「统计哈希表」实现的「朴素解法」代码:

function findSubstring(str, words) {
    const wordLen = words[0].length;
    const wordNum = words.length;
    const ans = [];
    for (let i = 0; i <= str.length - wordLen * wordNum; i++) {
        const record = new StatHash(words);
        for (let j = i; j <= i + wordLen * (wordNum - 1); j += wordLen) {
            let ch = str.slice(j, j + wordLen);
            if (record.has(ch)) {
                record.remove(ch);
                if (record.isEmpty()) {
                    ans.push(i);
                    break;
                }
            } else {
                break;
            }
        }
    }
    return ans;
}

依赖Map对象自带size属性,我们可以直接实现对记录表record是否为空的检查,而不必像原先那样通过遍历实现。

提交后我们惊喜地发现性能也得到了近一倍的提升:

2.png

写在文末

我是来自在校学生编程兴趣小组江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》,以锻炼我们的前端应用开发能力。

我们诚挚邀请您体验我们的这款优秀作品,如果您喜欢TA的话,欢迎向您的同事和朋友推荐。如果您有技术方面的问题希望与我们探讨,欢迎直接与我联系。您的支持是我们最大的动力!

QQ图片20220701165008.png