一、问题描述
给定一个字符串数组words,找到以words中每个字符串作为子字符串的最短字符串。如果有多个有效最短字符串满足题目条件,返回其中任意一个即可。
我们可以假设words中没有字符串是words中另一个字符串的子字符串。
示例1:
输入:words = ["alex","loves","leetcode"]
输出:"alexlovesleetcode"
解释:"alex","loves","leetcode" 的所有排列都会被接受。
示例2:
输入:words = ["catg","ctaagt","gcta","ttca","atgcatc"]
输出:"gctaagttcatgcatc"
二、解题方法
2.1 Bitmask+DP+BFS
照搬了一下别人的想法,最开始自己联想到了之前做到的那个求数组中最长公共子数组的问题,尝试使用dp方法来解题,后面越想越复杂,放弃了。。。。。隔了几天,觉得啥题不能做,现在不会就先看看别人怎么做,不信下次还是一点思路都没有。
推论:
当已经有n个字符串拼接在一起的时候,拼接第n+1个字符串得到的串的长度只和第n个字符串与第n+1个字符串的长度有关,与其它所有串都无关。
状态压缩动态规划:
前提:
0==>0000(二进制表示)
1==>0001
5==>0101
dp[i][j]:表示当前的选取状态为i,同时最后一个被选择的字符串为第j个字符串的结果。(要求最终长度最短,则需要两者之间被覆盖的长度最长)
状态转移方程:
dp[i][j]==>如果第k可字符串还未被选择,dp[i|<2k][k]=dp[i][j]+第j个字符串的后缀与第k个字符串的前缀的重合数量(i|<2k:表示i按位或2**k)
以上面为例,假设上一次选择的是第二个字符串,即dp[5][2]已知晓,下一次选择为第 4个字符串。
dp[5|(2*4)][4]=dp[21][4]=dp[5][2]+第二个字符串的后缀与第4个字符串的前缀的重合数量。
结果:sum(len(s[i]))-MAX[dp[31][k]]:总的字符串的长度减去最大的重合长度,得到的就(dp[31][k]:所有字符串都被选择了,其中最后一个被选上的字符串为k)
初始状态:
dp=0
具体思路:
- 先计算字符串两两之间重叠的长度,存放在一个二维数组中,因为这些值会反复使用,重复计算会严重拖慢执行速度;
- 定义一个二维动态规划数组dp,一个维度是当前已经使用字符串的mask,另一个维度表示最后一个串,其值为拼接后的串(这里没看明白)
- 初始化dp中只使用了一个串的项,即第一个维度的二进制中只有一个1,也就是2的幂的那些项,每个mask对应一个i,其值为strs[i];(到这里看明白了,dp数组第一个维度记录的是字符串在原字符数组中的位置,例如1表示二进制的第0个位为1,则表示strs[0],最开始初始化的时候,字符串都未拼接,所以值就是原来的字符串值strs[i])
- 使用BFS处理更长的串,这要求在上一步初始化时同时初始化状态,这些状态为mask(又看不懂了,果然理解别人的代码是最难的)
- 每添加一个字符串时更新对应的dp项,其中mask中1的个数会增加一个,而增加的这个1的位置正是dp的第二个下标。
- 由于使用BFS,所以dp中的值均使用推的方式得到(????),在某一项还没有值,或新串更短时更新dp
- 取出所有mask位都为1的那些项,在其中找出长度最短的字符串作为返回结果
代码如下:
/**
* @param {string[]} words
* @return {string}
*/
var shortestSuperstring = function(words) {
const n=words.length;
const overlaps=calcOverlaps();
const dp=Array.from({length:2**n},()=>Array.from({length:n}));
let states=new Set();
for(let i=0;i<n;i++){
//初始化dp数组,先将字符串中的值按照顺序传入数组,eg:dp[1][1]=catg,dp[2][2]=ctaagt
dp[1<<i][i]=words[i];
//记录传入的字符串的mask状态,也就是对应的二进制位置
//Set { [ 1 ], [ 2 ], [ 4 ], [ 8 ], [ 16 ]
states.add([1<<i]);
}
while (states.size){
const states2=new Set();
for(const mask of states){
for(let i=0;i<n;i++){
if(mask&(1<<i)){//取出所有mask位都为1的项,找到长度最短的字符串作为返回结果
for(let j=0;j<n;j++) {
if(!(mask&(1<<j))) {
const mask2 = mask^(1<<j);
const overlap = overlaps[i][j];
//要找一个最优的解,所以存在||后面的说法(在某一项还没有值,或新串长度更短时更新dp)
if (!dp[mask2][j] || dp[mask][i].length + words[j].length - overlap < dp[mask2][j].length) {
dp[mask2][j] = dp[mask][i] + words[j].slice(overlap);
states2.add(mask2);
}
}
}
}
}
}
states=states2; //这是个啥啊????为啥要变化
}
return dp[2**n-1].reduce((str1,str2)=>str1.length<str2.length?str1:str2);
function calcOverlaps() {
//[ [ 0, 0, 0 ], [ 0, 0, 0 ], [ 0, 0, 0 ] ]
const overlaps=Array.from({length:n},()=>Array.from({length:n},()=>0));
for(let i=0;i<n;i++){
for(let j=0;j<n;j++){ //这里j需要从0开始,因为存在先后顺序,str1和str2的重叠与str2和str1的重叠数量可能不一样,因为字符串前后顺序会有影响
if(i!=j){
overlaps[i][j]=overlapBetween(words[i],words[j]); //计算得到所有字符串两两之间的重叠长
}
}
}
return overlaps;
function overlapBetween(str1,str2) {
for(let i=0;i<str1.length;i++){
if(str2.startsWith(str1.slice(i))){ //查找str2的字符串是否以str1.slice(i)开题
return str1.length-i; //返回str1中和str2重叠字符的长度
}
}
return 0;
}
}
};
这个题真的太太太太太太恶心了,还是有一部分没看懂,明日再接再厉…
三、知识补充
3.1 Array.from()方法总结
用法:Array.from(arr,mapfn,thisArg),用于将类似数组的对象和可遍历对象转换为真正的数组,是ES6中的新增方法。
- arr:数组参数,必传
- mapfn:函数,对数组元素进行操作后再返回数组,可选
- thisArg:关键字指向,可选
类数组对象即要求对象具有length属性
3.1.1 将类数组对象转换为真正的数组
let arrayLike = {
0: 'tom',
1: '65',
2: '男',
3: ['jane','john','Mary'],
'length': 4
}
let arr = Array.from(arrayLike)
console.log(arr) // ['tom','65','男',['jane','john','Mary']]
要是去除上面代码中的length属性,则只会返回一个长度为0的空数组。
这里将代码再改一下,就是具有length属性,但是对象的属性名不再是数字类型的,而是其他字符串型的,代码如下:
let arrayLike = {
'name': 'tom',
'age': '65',
'sex': '男',
'friends': ['jane','john','Mary'],
length: 4
}
let arr = Array.from(arrayLike)
console.log(arr) // [ undefined, undefined, undefined, undefined ]
返回结果为长度为4,元素均为undefined的数组,由此可知,要将一个类数组对象转换为一个真正的数组,必须具备以下条件:
- 该类数组对象必须具备length属性,用于指定数组的长度。若没有length属性,则转换后的数组是一个空数组;
- 该类数组对象的属性名必须为数值类型或者字符串类型的数字(属性名可加引号,也可以不加引号)
3.1.2 接受第二个参数
Array.from还可以接受第二个参数,作用类似于数组的map方法,用来对每个元素进行处理,将处理后的值放入返回的数组。如下:
let arr = [12,45,97,9797,564,134,45642]
let set = new Set(arr)
console.log(Array.from(set, item => item + 1)) // [ 13, 46, 98, 9798, 565, 135, 45643 ]
3.1.3 使用值填充数组
- 使用相同初始值填充数组
const length = 3;
const init = 0;
const result = Array.from({ length }, () => init);
result; // => [0, 0, 0]
// Array.fill()实现相同功能
const length = 3;
const init = 0;
const result = Array(length).fill(init);
fillArray2(0, 3); // => [0, 0, 0]
- 使用对象填充数组
当初始化数组的每个项都应该是一个新对象时,Array.from()是一个更好的解决方案:
const length = 3;
const resultA = Array.from({ length }, () => ({}));
const resultB = Array(length).fill({});
resultA; // => [{}, {}, {}]
resultB; // => [{}, {}, {}]
resultA[0] === resultA[1]; // => false
resultB[0] === resultB[1]; // => true
3.2 slice()用法
slice(start,end):可从已有数组中返回选定的元素,返回一个新的数组,包含start到end(不包含该元素)的数组元素。
注意:此方法不改变原数组,而是返回一个子数组,如果想删除数组中的一段元素或者改变原数组中的值,应该使用splice()。
- start参数:必选,规定从何处开始选取,如果为负数,规定从数组尾部算起的位置,-1是指最后一个元素。
- end参数:可选(如果该参数没有指定,那么切分的数组包含从start倒数组结束的所有元素,如果这个参数为负数,那么规定是从数组尾部开始算起的元素)。
3.3 startsWith()用法
用于检测字符串是否以指定的子字符串开始。如果以指定的子字符串开始则返回true,否则返回false(对大小写敏感)。
var str = "Hello world";
var n = str.startsWith("Hello");
console.log(n) // true
用法: string.startsWith(searchvalue, start)
- serachvalue:必选,需查找的字符串
- start:可选,查找开始位置,默认为0
var str = "Hello world";
var n = str.startsWith("o", 4);
console.log(n) // true
3.4 JS数组的Reduce()用法
reduce()对累加器和数组中的每个元素(从左到右)应用一个函数,将其简化为单个值。
arr.reduce(callback,[initialValue])
reduce为数组中的每一个元素依次执行回调函数,不包括数组中被删除或从未被赋值的元素,接受四个参数:初始值(或者上一次回调函数的返回值),当前元素值,当前索引,调用reduce的数组。
callback (执行数组中每个值的函数,包含四个参数)
1、previousValue (上一次调用回调返回的值,或者是提供的初始值(initialValue))
2、currentValue (数组中当前被处理的元素)
3、index (当前元素在数组中的索引)
4、array (调用 reduce 的数组)
initialValue (作为第一次调用 callback 的第一个参数。)
3.4.1 实例解析initialValue参数
var arr = [1, 2, 3, 4];
var sum = arr.reduce(function(prev, cur, index, arr) {
console.log(prev, cur, index);
return prev + cur;
})
console.log(arr, sum);
// 1 2 1
// 3 3 2
// 6 4 3
// [1,2,3,4] 10
这里可以看出,上面的例子index是从1开始的,第一次的prev的值是数组的第一个值。数组长度是4,但是reduce函数循环3次。
var arr = [1, 2, 3, 4];
var sum = arr.reduce(function(prev, cur, index, arr) {
console.log(prev, cur, index);
return prev + cur;
},0) //注意这里设置了初始值
console.log(arr, sum);
// 0 1 0
// 1 2 1
// 3 3 2
// 6 4 3
// [1,2,3,4] 10
这个例子index是从0开始的,第一次的prev的值是我们设置的初始值0,数组长度是4,reduce函数循环4次。
结论:如果没有提供initialValue,reduce 会从索引1的地方开始执行 callback 方法,跳过第一个索引。如果提供initialValue,从索引0开始。
注意:如果这个数组为空,运用reduce是什么情况?
var arr = [];
var sum = arr.reduce(function(prev, cur, index, arr) {
console.log(prev, cur, index);
return prev + cur;
})
//报错,"TypeError: Reduce of empty array with no initial value"
但是要是我们设置了初始值就不会报错,如下:
var arr = [];
var sum = arr.reduce(function(prev, cur, index, arr) {
console.log(prev, cur, index);
return prev + cur;
},0)
console.log(arr, sum); // [] 0
所以一般来说提供初始值更安全。
3.4.2 简单用法
var arr = [1, 2, 3, 4];
var sum = arr.reduce((x,y)=>x+y)
var mul = arr.reduce((x,y)=>x*y)
console.log( sum ); //求和,10
console.log( mul ); //求乘积,24