FreeCodeCamp 高级算法题 - 字符串排列
字符串排列 (No repeats please) 题目链接
问题解释
这个 function
接收一个字符串参数 str
。返回值为参数 str
没有连续重复字符串的排列个数
如果 str
为 "aab"
,则返回值应为 2
,因为全排列后,会出现两个 "aba"
,不含连续重复字符串 (排除 "aab"
和 "baa"
)
解题思路
这道题应该是高级算法题目中难度稍大的一道题。题目的难点在于获取字符串的全排列。我觉得这里有必要先说一下,如何获取全排列
只要我们可以获取字符串的全排列,那就至少有两种方式判断字符串是否含有连续重复的字符。可以遍历,也可以用正则
排列 (也叫置换),Permutation 与 组合,Combination ,高中数学就已经涉及到。比如,对于 123
,从中取出两个数有三种组合,分别是 12
、13
和 23
。同样是取出两个数,有六种排列,分别是 12
、21
、13
、31
、23
和 32
再说一下什么是全排列 (Full Permutation),全排列的意思是,从 n
个中取出 n
个的排列。对于 123
,取出三个数的排列,就是 123
的全排列。123
的全排列总共有六种,分别是 123
、132
、213
、231
、312
和 321
。计算数量方式很简单,就是 n!
,n
的阶乘。对于 123
来说,也就是 3!
,得 6
全排列的实现 - 封装,循环
我们可以先根据这个实际的例子想想,怎样才能无遗漏的输出全排列
两个数就不用说了,对于 12
,只有 12
和 21
两种
三个数,比如 123
,我们先分为三种情况,就是 1
开头,2
开头和 3
开头
对于 1
开头的情况,剩下 2
和 3
,这就回到了两个数的排列
对于 2
开头的情况,剩下 1
和 3
,这也回到了两个数的排列
3
开头的情况同理
四个数,先按照开头分为四种情况,然后按照三个数的排列去处理
……
以此类推
你可能已经看出来了,这就是一个递归。就好像求斐波那契数列的某一个元素,我们要先求出前面的;要想求出前面的,我们就要求出更前面的。记 “斐波那契数列的第 n
位” 这件事为 F(n)
,则有 F(n) = F(n - 1) + F(n - 2)
类似地,记 “求出 n
个字符串的全排列” 这件事为 P(n)
,则有 P(n) = 分别以这n个字符之一开头 + P(n - 1)
。其中,P(n - 1)
表示去掉那个开头字符的剩余字符串的全排列。哪怕只有两个字符,比如对于上面例子中的 12
,同样符合这一条结论
以 'abc'
为例,执行步骤如下:
1 2 3 4 5 给出 abc 1. a 作为开头 -> 求 bc 全排列 -> 得到 bc 和 cb -> 与 a 合并 -> 得到 abc 和 acb 2. b 作为开头 -> 求 ac 全排列 -> 得到 ac 和 ca -> 与 b 合并 -> 得到 bac 和 bca 3. c 作为开头 -> 求 ab 全排列 -> 得到 ab 和 ba -> 与 c 合并 -> 得到 cab 和 cba
注意,这只是其中一种实现方式。后面我们还会看到另一种实现
首先我们来想一下公共逻辑是什么。对于一个字符串,我们取出一个字符作为开头,然后对去掉这个开头字符的剩余字符串继续求全排列。求出来之后,与取到的字符合并起来就行
对于 P(n)
来说,我们要取出一个字符作为开头,而且原始的字符串可能本身就含有重复的字符。在代码中,我们可以通过开头字符在原字符串中的索引来区分
对于我们封装的函数,可以直接使用字符串作为参数。这是因为,在获取剩余字符串全排列,即 P(n - 1)
时,我们并不关心去掉的那个,用作开头的字符是什么,只需要关心现在我们要生成谁的全排列就好
因此,我们需要在递归调用时,传入去掉那个用作开头的字符之后的,剩余字符串。这个很容易实现,如果我们知道了去掉的那个字符的索引,那我们就可以用 str.slice(0, i)
来获取这个字符之前的字符串,用 str.slice(i + 1, str.length)
来获取这个字符之后的字符串 (注意,slice
方法的第一个参数是包含的,第二个不包含。如果 i
本身就是 0
,那么取到的是 空字符串 ),拼接在一起就可以作为递归调用的参数
跳出条件也不难想,只要传入的参数长度为 1
或 0
,直接返回即可
另外,每次调用,我们都需要一个数组来保存根据当前参数生成的全排列。代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function permAlone (string ) { function _perm (str ) { if (str.length < 2 ) return str; var permutations = []; for (var i = 0 ; i < str.length; i++) { var start = str[i]; var remaining = str.slice(0 , i) + str.slice(i + 1 , str.length); for (var permutation of _perm(remaining)) { permutations.push(start + permutation); } } return permutations; } }
判断连续重复字符 遍历
判断是否有连续重复的字符,最简单的方式是遍历。只需要在外面用一个变量记录上一个字符就可以
只要当前的和上一个相同,直接跳出就可以,不需要继续判断。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 function hasRepeatChar (str ) { var previous = '' ; for (var i = 0 ; i < str.length; i++) { if (previous === str[i]) { return true ; } else { previous = str[i]; } } return false ; }
递归
递归也是很容易写的。跟上面的思路一样,调用的时候传入两个参数,分别是上一个字符,和剩余字符串。其中,剩余字符串可以通过 str.slice(1)
获取
为避免 str
本身就是空字符串,需要多一次判断,即如果 prevChar
不是空的 (这说明 prevChar
被赋过值,而并非初始的空值),我们才可以认为 str
不含连续重复字符,则返回 false
。因此,跳出条件是 str
为空且 prevChar
有值。如果这时候 prevChar
也是空的,那就证明传入的 str
本身就是空的。为了防止混淆,我们直接给它返回 "Empty string"
。事实上,这种 corner case 在这道题目中不会遇到。代码如下:
1 2 3 4 5 6 function hasRepeatChar (str, prevChar ) { if (str.length === 0 ) return prevChar ? false : 'Empty string' ; if (prevChar === str[0 ]) return true ; return hasRepeatChar(str.slice(1 ), str[0 ]); }
正则表达式
正则是个好东西。在正则里,有一中写法叫做 back reference
,就是 \\
后面加一个正整数。请参考 文档 中 \\n
的那一行
简单来说,\\x
就是匹配之前,正数第 x
个 matched group (匹配组,也叫捕获组,其实就是小括号包含的内容)
对于判断一个字符串是否含有连续重复字符,我们并不关注它重复了几次,也不需要关注它有几组重复的。因此,这里不需要 global
flag /g
那么,对于字符串中的任意字符,只要这个字符右边的字符和它相同,那就匹配到,并且返回 false
。听起来像是句废话,只是,如果你看不懂后面的正则,记得回来再读读这句话。代码如下:
1 2 3 function hasRepeatChar (str ) { return !/(\w)\1/ .test(str); }
基本解法 思路提示
思路上面已经说得很清楚。通过上面的递归调用,我们可以得到了一个包含字符串全排列的数组,只需要通过上面的正则过滤一下,保留不含连续重复字符的字符串,并返回它的 length
代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function permAlone (string ) { function _perm (str ) { if (str.length < 2 ) return str; var permutations = []; for (var i = 0 ; i < str.length; i++) { var start = str[i]; var remaining = str.slice(0 , i) + str.slice(i + 1 , str.length); for (var permutation of _perm(remaining)) { permutations.push(start + permutation); } } return permutations; } return _perm(string).filter(function (str ) { return !/(\w)\1/ .test(str); }).length; }
数组方法 - 思路的优化 思路提示
上面的方式是把子问题 (剩余字符串的全排列) 添加到之前取出的开头字符后面,这也就意味着,对于长度为 n
的字符串 string
,开头的字符我们要获取 n
次。每一次取了开头,我们又要再对子问题进行 n - 1
次取开头的操作,因此这时候的时间复杂度会是 n!
。效率很低
如果我们换一个思路,采用 “插值” 的方法,会让整体操作变少一些。注意,这个思路并不一定需要用数组去实现。确切的说,如果不用数组去实现,效率会更高。只是个人觉得,用数组会比较容易写,也比较容易理解
之前的方式,如果我们说它是 “从前往后” 实现的,那现在我们来试试从后往前实现
对于字符串 'abc'
,给出子串 'bc'
,剩余 'a'
。我们可以通过把 'a'
放到 'bc'
里面,不同的位置来实现排列。注意到 'bc'
有三个位置可以插入 'a'
,分别是:
如果把 'a'
分别插入上面说的位置 1
、2
和 3
,我们就可以得到 'abc'
、'bac'
和 'bca
'bc'
排列还有一种情况 'cb'
。再把 'a'
插入到 'cb'
的三个位置,我们就可以得到另外三种排列
注意到,'bc'
和 'cb'
,其实就是在子串 'c'
中插入 'b'
产生的。因为 'c'
只有两个位置可以插入 'b'
:
这样,我们就得到了一个新的递归思路,如下 (左边的竖线只是为了方便看清递归弹出的时候对应上面的哪一步,弹出步骤中的插入值与上面取出的第一个字符相对应):
1 2 3 4 5 6 7 8 给出 'abc' |- 取出第一个字符 a,剩余 bc | |- 取出第一个字符 b,剩余 c | | |- 取出第一个字符 c,剩余空字符串 (划重点,这个就是弹出的条件) | | |- 在上次剩余值中插入 c,只能得到一种情况 c | |- 在上次剩余值 (c) 中插入 b,得到 bc 和 cb |- 在上次剩余值 (bc 和 cb) 中插入 a,得到 abc, bac, bca 和 acb, cab, cba
这个思路很像是,先一直走到底 (即长度为 0 的时候),弹出的过程中,我们再来生成需要的结果
这段代码加注释不太方便,详细解释还是写到代码之后吧
如果你还是不知道如何写代码,不要怕麻烦,试着写出来 'abcd'
的详细过程,写完你就能理解了
代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function permAlone (string ) { return _perm(string.split('' )).filter(function (str ) { return !/(\w)\1/ .test(str); }).length; function _perm (arr ) { return arr.length === 0 ? [[]] : _perm(arr.slice(1 )).reduce(function (accum, curr ) { for (var i = 0 ; i < arr.length; i++) { accum.push([curr.slice(0 , i), arr[0 ], curr.slice(i)].join('' )); } return accum; }, []); } }
解释
先说一句,上面的代码,尽管思路优化了,但速度理论上会比之前的慢,因为咱们用了数组
外面那层应该没啥疑问,既然决定了用数组去处理,那就干脆直接传入数组,一个 split
的事儿而已
封装的 _perm
,其实还是要进行递归调用的。当外面的 string
是空字符串时,返回值是 [[]]
,而不可以是 []
。原因很简单,如果是 []
,那么 reduce
就不会执行了,因为没有元素。你可以试试以下的代码片段,就理解了:
1 2 3 4 5 6 7 8 9 10 11 [].reduce((accum, curr ) => { console .log('执行了' ); return accum; }, []); [[]].reduce((accum, curr ) => { console .log('执行了' ); return accum; }, []);
只要 arr
长度不为 0
,那我们就递归调用 _perm(arr.slice(1))
,直到遇到传入的 arr
长度为 0
,才开始执行 reduce
弹出的过程。详情请看上面的思路分析
里面的 for
循环很重要,”插值” 这个核心步骤就是在这里实现的。如果看不懂这个过程,请去了解一下 slice
方法是怎么回事,然后举几个例子带进去试一试就明白了
算法优化 - Heap’s algorithm 思路提示
注意,这里的 Heap 不是指数据结构的 “堆”,而是发明者的名字。如果你从来没听说过这个算法,那只靠自己想可能有些困难
给出字符串 'abc'
,我们可以按照先确定结尾字符的思路来这样推一下:
'c'
作为结尾:
我们得到第一个排列 'abc'
通过交换 'a'
和 'b'
,我们可以得到另一种排列 'bac'
交换开头结尾,这时候 'b'
作为结尾:
得到 'cab'
通过交换 'c'
和 'a'
,得到 'acb'
交换开头结尾,这时候 'a'
作为结尾:
得到 'bca'
通过交换 'b'
和 'c'
,得到 'cba'
思路大致上是这样,通过交换去实现。交换的好处在于,我们不需要额外的空间去存储。值得注意的是,在 JavaScript 中,字符串可以通过 index 访问某一个位置的字符,但不可以修改它的值。因此,想要换位,我们还是要通过数组来实现的
事实上,真正实现起来还与上面的例子有区别。在 Heap's Algorithm
的 维基百科页面 有详细的解释,也有四个元素的详细步骤示例。其实,并不是 “交换开头结尾” 这么简单的
实现数组中元素交换的方式也非常多。有朋友可能首先想到的是用 splice
,但这样做效率会很低,我会比较推荐用变量缓存一个值的做法,很容易写。比如,我们需要交换 index 为 2
和 4
的元素:
1 2 3 4 5 6 7 var arr = [1 , 2 , 3 , 4 , 5 , 6 ];var temp = arr[2 ];arr[2 ] = arr[4 ]; arr[4 ] = temp; console .log(arr);
1 2 3 4 var arr = [1 , 2 , 3 , 4 , 5 , 6 ];arr[2 ] = [arr[4 ], arr[4 ] = arr[2 ]][0 ] console .log(arr);
如果你听说过 ES6 的解构赋值,也可以这么写。注意这个在不支持 ES6 的浏览器里是不行的:
1 2 3 4 var arr = [1 , 2 , 3 , 4 , 5 , 6 ];[arr[2 ], arr[4 ]] = [arr[4 ], arr[2 ]] console .log(arr);
维基百科页面也提供了伪代码,而且提供了递归和非递归的两个版本。两个版本都用到了两个参数,但我们只需要一个 n
就够了,不需要第二个 A
,因为我们的数组可以通过 var arr = str.split('');
将它定义到函数 generate
外面。这样,伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 函数 generate 参数 num 如果 num 等于 1 : arr.join('' ),并添加到结果数组 否则: 循环,0 至 num: 递归调用 generate(num - 1 ) 如果 num 为偶数: 交换 strArr[i] 与 strArr[num - 1 ] 如果 num 为奇数: 交换 strArr[0 ] 与 strArr[num - 1 ]
代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 function permAlone (str ) { var arr = str.split('' ); var result = []; var tempIndex; function generate (num ) { if (num === 1 ) { result.push(arr.join('' )); } else { for (var i = 0 ; i < num; i++) { generate(num - 1 ); tempIndex = num % 2 ? 0 : i; arr[tempIndex] = [arr[num - 1 ], arr[num - 1 ] = arr[tempIndex]][0 ]; } } } generate(arr.length); return result.filter(function (str ) { return !/(\w)\1/ .test(str); }).length; }
<
FreeCodeCamp 高级算法题 - 构造对象
FreeCodeCamp 高级算法题 - 更新库存
>