摸鱼多日, 来随便更一更. 最近JS写的比较多, 所以以后题解的代码就以JS为主了. 这篇题解写了好久, 主要是后面两题搞得有点复杂, 加上各种学校的事情, 拖着写了个把月QAQ.
21. Merge Two Sorted Lists
合并两个有序链表, 链表中数据是从小到大排序的.
这有点像归并排序中归并的过程, 只是这个要在链表上进行归并, 思路还是一样的.
对两个链表头中的值相互比较, 取值较小的那个作为新链的下一个节点, 并将取出的链表头向后移动一位, 并继续这一个过程直到两个链表都被遍历.
复杂度O(N+M)
, N
和M
为两个链表长度.
代码中为了方便我建了一个空节点作为头节点, 并返回了一个新链表, 而没有破坏原先两个链表. 只需要稍微修改一下就能直接使用原先链表的节点.
1 | var mergeTwoLists = function(l1, l2) { |
22. Generate Parentheses
给N
对小括号, 返回所有合法的括号序列.
典型的分治问题. 要保证一个括号序列合法, 其每个括号内的子序列也要是合法. 我们可以根据第一对匹配括号的大小(其中含有的括号数)来枚举.
如N = 1
有唯一的合法序列()
.
如N = 2
, 则有序列()()
和(())
. 等价于1对括号加所有N = 1
的序列或者1对括号内含N = 1
的序列.
如N = 3
, 我们在1对括号后加上所有N = 2
的序列可以得到()()()
和()(())
;
然后我们在1对括号里放所有N = 1
的序列, 并在其后加上所有N = 1
的序列, 得到(())()
;
我们在1对括号里放所有N = 2
的序列, 则可以得到(()())
和((()))
.
即要得到N
组括号的序列, 我们可以让i
组括号的序列在1对括号内, 再在后面加N - i - 1
组括号序列, 于是得到规模为i
和N - i - 1
的子问题.
我们用数组记录一下子问题的答案便可以从小到大递推得到答案了. 至于复杂度, 由于答案规模是指数级增加的, 所以复杂度也是指数级的, 比较爆炸.
1 | var generateParenthesis = function(n) { |
23. Merge k Sorted Lists
合并k
个有序链表, 链表中数据是从小到大排序的.
和21题如出一辙, 就是多路归并的思路. 从所有的头中取最小的一个作为新链表的头. 为了提高选取效率, 可以使用优先队列去实现. JS里面没有优先队列, 所以我手敲了一个, 具体的算法可以去搜索大/小顶推
, 网上有许多资料, 《算法导论》中关于堆排序的部分也有讲解.
整体复杂度O(Nlog(k))
, 其中N
为链表中点的总个数, k
为链表数.
1 | var mergeKLists = function(lists) { |
24. Swap Nodes in Pairs
将链表中的元素两两交换, 如1->2->3->4->5
, 则交换1
与2
和3
与4
, 多出的5
不管, 返回2->1->4->3->5
.
简单的递归. 要两两交换所有元素, 等价于先交换除去前两个节点之外的其他节点, 然后再交换前两个节点. 交换其他节点便形成子问题.
由于交换后头节点会变化, 最方便的方法就是将新的头结点作为返回值传递.
复杂度O(N)
.
1 | var swapPairs = function(head) { |
25. Reverse Nodes in k-Group
上一题的升级版, 给一个链表和一个长度k
, 将每连续长度为k
链翻转, 尾部长度不足k
的部分保持不变.
可以先用递归实现一个翻转链表前k
个节点的函数.
对于1->2->3->4->5
, k = 3
, 我们可以先找到3
, 并将其前导放到3
后得到1->3->2->4->5
, 然后再将3
新的前导1
放到2
后得到3->2->1->4->5
.
有了这个函数, 可以先从根节点做一次翻转, 如果翻转完成, 则旧的根节点已经成为第k
个节点, 然后继续从其后一个节点开始再做翻转, 直到无需翻转即可.
复杂度O(N)
.
1 | var reverseKGroup = function(head, k) { |
26. Remove Duplicates from Sorted Array
去除有序数组中的重复项, 返回新数组长度, 要求只能使用O(1)
的额外空间.
因为数组有序, 所以相同项一定相邻. 要删除重复项, 只需要判断每一项是否与前一项相同, 相同的话删除即可. 这样对于要保留的项, 我们直接将其移动到合适的位置即可. 因为其一定是往前移动, 所以只需要从前往后枚举, 然后覆盖到原先的数组上即可.
1 | var removeDuplicates = function(nums) { |
27. Remove Element
去除有序数组中的指定值的项, 返回新数组长度, 要求只能使用O(1)
的额外空间.
与上一题类似, 跳过要删除的项, 对于要保留的项直接将其向前覆盖到对应的位置即可.
1 | var removeElement = function(nums, val) { |
28. Implement strStr()
返回一个子串在字符串中的起始角标. 如果不是子串返回-1
.
字符串匹配问题, JS中有内置方法indexOf()
. 就V8引擎来说, 其内部实现比较复杂, 对于不同长度模式串采用不同的算法, 其中对于较长的模式串使用Boyer–Moore–Horspool算法(具体可以查看src/string-search.h).
常见的其他字符串匹配算法有KMP
, BM
和Sunday
等, 具体可以去搜索查看相关算法.
1 | var strStr = function(haystack, needle) { |
29. Divide Two Integers
不使用乘\除\模运算, 实现两个整数(32位整型)的除法, 如果答案溢出则返回MAX_INT
, 即2^31 - 1
.
能够使用的运算只有位运算和加减法, 因此最基础的想法就是用减法, 看被除数中能减去多少个除数. 然而对于2^31 - 1
除以1
这样的输入, 直接减必定会超时, 这就需要在减的时候一次能减去多个.
一种减的思路是利用二进制, 每次尝试减去2^i
个除数. 例如10/2 = 5
, 实际我们可以减去的除数为5
个, 即2^2 + 2^0
个. 在计算除法时, 我们先用10
减去4 * 2
, 即4
个除数, 余下2
, 然后还能减去1
个除数2
, 因此最后答案是4 + 1 = 5
.
对于不能整除的情况, 如16/3 = 5
, 我们可以先减去4
个除数, 余下4
, 再减去1
个除数余下1
, 由于无法再减去任何除数, 所以得到的答案也是5
.
但是由于无法使用乘法, 如何一次性减去2^n
个除数呢? 这里就可以利用位移运算. 我们知道, 位运算中的左移相当于乘2
, 右移相当于除以2
, 因此只需要用位移就可以实现乘法了. 实际我在代码中采用的方法是, 先左移到一个足够大的值, 再每次右移一位, 依次去试减的方式. 在记录结果时, 我采用边加边左移的方式.
对于于正负数问题, 以上的讨论都是针对同号的情况(虽然只讨论了两个正数, 但是两个负数实际上是类似的), 异号的情况可以单独将正负号提出来, 直接处理同号的结果, 最后统一处理符号.
还有一个小问题在于溢出. 由于整型最小值是-2^31
, 而最大值是2^31 - 1
, 在遇到如-2^31/-1
时, 结果就会超过最大值. 简单的做法就是把除数\被除数\相除结果全部处理成负数, 最后统一处理符号和溢出.
整体复杂度O(log(N))
, N
是相除结果.
实际上这种方法可以看做是一种二分的方法, 当然如果可以使用乘法的话, 直接二分最多能够减去多少个除数也是一种解法.
1 | var divide = function(dividend, divisor) { |
30. Substring with Concatenation of All Words
给一组单词和一个字符串, 找到字符串中由这些单词连成的子串的位置. 这些单词的长度相同, 子串可以由这一组单词按一定顺序连成, 且每个单词都只用到一次. 字符串中可能有多个子串满足条件.
我们用N
表示字符串的长度, L
表示每个单词的长度, K
表示单词的个数.
我们可以把原字符串看成是L
个不同的分解串. 例如字符串abcdefgdefabc
, L = 3
, 则可以看成:abc def gde fab
, bcd efg def abc
和 cde fgd efa
. 其中我们将尾部多余的字符舍去. 显然, 满足答案的子串一定是这几个串中的一段. 如果我们原单词是abc
和def
, 分别用A
和B
代表, #
表示不属于给定单词, 则这几个串可以看做AB##
, ##BA
, ###
, 则我们只用找到其中长度为2
的每个单词都出现过的子串.
要找到这样的子串, 我们首先要对所有单词计数, 以处理同一个单词出现多次的情况. 这个计数值表示了每个单词还有多少次可以使用. 例如, 单词A
出现2
次, B
出现1
次(K = 3
), 我们要在AAB#BAABBA
中找到所有满足这个条件长度为3
的子串.
我们用一个队列记录可能合法的子串, 将一个分解串中的单词依次进入队列中. 入队单词不为#
, 我们就在计数中减去对应单词的计数. 当队列长度达到K
, 且都能成功减去计数, 说明对应的计数值都应该为0
了, 此时队列中的元素组成一个合法串, 队首元素的位置就是一个答案.
同时, 为了继续判断下一个子串, 我们要将队首出队, 并将其对应的单词计数加回去, 然后再加入下一个单词继续判定.
当遇到单词#
时, 则当前队列中任何一个单词作为开始位置, 所得到的长度为K
的子串都会包含单词#
, 一定不满足要求. 因此可以将所有元素出队, 并从下一个不为#
的单词开始重新入队.
当一个计数值会被减为负数时, 说明当前队列中此单词的数量超过给出的数量, 因此队列记录的一定不是一个合法子串, 需要通过出队使其合法. 直接依此将元素出队, 出队时将出队单词的计数值加回去, 直到可以减去刚才入队单词的计数值为止.
以上面AAB#BAABBAA
为例子. 开始AAB
依次入队达到长度K = 3
且都能成功减去计数, 因此为一个有效答案. 去除队首得到AB
以后加入#
, 所有包含#
的都不可能是答案, 因此全部出队, 从#
后的BAABBAA
重新开始. 开头的BAA
同理可以满足要求, 是一个有效答案. B
出队后, 又加入一个B
, 再次满足要求, 即AAB
又是一个答案. A
出队得到AB
, 又加入一个B
, 此时B
计数已经为0
了, 无法再减去(加入后队列中是ABB
, 而合法串B
只能出现一次), 因此依次出队直到队列中第一个B
出队, 然后再将后面的B
入队, 此时队列为B
. 接着又能依次加入AA
, 又得到一个答案.
由于每个分解串中的单词只会入队出队一次, 且分解串长度之和为N
, 因此用队列找答案的复杂度为O(N)
. 然而这里面没有考虑匹配单词的耗时. 如果使用暴力的匹配方法, 对于分解串每个单词要去匹配给定的一组单词, 复杂度退化为O(NKL)
. 因此需要一种快速匹配的方法.
要在一个字符串中匹配一组单词可以使用AC自动机之类的数据结构, 但是这里所有单词的长度是一样的, 可以用更简单的hash来做.
我们使用前缀hash, 这样可以更快的处理出整个字符串中所有指定长度子串的hash值. 一个字符串abc
, 则这个字符串的hash值表示为hash('abc') = 'a' * g^2 + 'b' * g^1 + 'c' * g^0 % m
, 其中'a'
,'b'
,'c'
对应字符串中的字母, 实际计算中每个字母对应一个唯一的数字, g
和m
都是常数, 为了减少冲突概率通常取素数.
这种hash有以下性质:1
2hash('abc') = (hash('ab') * g + 'c') % m
hash('bcd') = ((hash('abc') - ('a' * g^2) % m + m) * g + 'd') % m
其中第二个式子中加上m
是为了避免出现负数.
这两个性质意味着我们可以用O(N)
的复杂度计算得到长度为N
的字符串中每个长度为L
的子串的hash值. 利用第一个式子可以用O(L)
的复杂度算得第一个长度为L
的子串的hash, 接着用第二个式子就能用O(1)
的复杂度得到下一个子串的hash, 因此获得所有分解串中单词的hash是O(N)
的复杂度.
另外, 我们还需要对给出的那组单词做hash, 复杂度是O(KL)
, 匹配时复杂度O(1)
, 因此综合下来算法整体的复杂度是O(N + KL)
.
在实际的代码中我为了减少冲突的可能性, 使用了双hash. 另外我用JS里面的对象作为map去计数和匹配, 但实际把M1
和M2
调整小一点, 直接用数组就可以.
这种方法如果遇到特殊情况使得hash冲突的话就会出错(比如给无数多个单词), 不过双hash一般很难卡掉的.
1 | var findSubstring = function(s, words) { |