本文始發於個人公眾號:TechFlow,原創不易,求個關注
鏈接
難度
Medium
描述
Given n pairs of parentheses, write a function to generate all combinations
of well-formed parentheses.
給定n對括號,要求返回所有這些括號組成的不同的合法的字符串
For example, given n = 3, a solution set is:
[
"((()))",
"(()())",
"(())()",
"()(())",
"()()()"
]
題解
這道題目非常有意思,解法也很多,還是老規矩,我們先由易到難,先從最簡單的方法開始入手。
我們來簡單分析一下題目,n個括號對意味着字符串的長度是2n,我們利用排列組合可以計算出,所有的組合種數一共有\(C_{2n}^n\)種。算一下會知道,這個數是很大的,也就是說我們哪怕一開始就知道答案,把答案遍歷一遍也會有很高的耗時,所以這道題對於時間復雜度的要求應該不會很高。
暴力
能想到最簡單的方法,當然是暴力,不要看不起這個朴素的算法,很多時候靈感都是從暴力當中獲取的。但是這道題暴力不太容易寫,因為會有一種無從入手的感覺,我們知道要暴力,但是並不知道應該怎樣暴力。這道題不存在可以直接枚舉的朴素元素,必須要我們拐個彎才行。
怎么拐彎呢,其實答案我剛才已經說出來了。n個括號對,也就是說一共2n個字符,我們可以枚舉n個'('分別放在什么位置,剩下的自然就是')'了。看起來很有道理,但是有一個問題,就是這個思路並沒有辦法通過循環直接實現。這其實已經進化成了一個搜索問題了,我們要搜索所有可以擺放括號的可能性。
如果你能從暴力方法跳躍到搜索問題,那么說明你離寫出代碼已經很接近了。如果不行,那么我建議你花點時間去學習一下搜索算法專題。
對於搜索問題而言,這已經很簡單了,我們搜索的空間是明確的,2n個位置,搜索的內容,對於每個位置我們可以擺放'('也可以擺放')'。那么代碼自然而然呼之欲出:
def dfs(pos, left, right, n, ret, cur_str):
"""
pos: 當前枚舉的位置
left: 已經放置的左括號的數量
right: 已經放置的右括號的數量
n: 括號的數量
ret: 放置答案的數組
cur_str: 當前的字符串
"""
if pos == 2*n:
ret.append(cur_str)
return
if left < n:
dfs(pos+1, left+1, right, n, ret, cur_str+'(')
if right < n:
dfs(pos+1, left, right+1, n, ret, cur_str+')')
這個程序遍歷運行之后還沒有結束,我們還需要判斷生成出來的括號是否合法,也就是說括號需要匹配上。我們可以用一個棧來判斷括號是否能夠匹配,比如我們遇見左括號就進棧,遇見右括號則判斷棧頂,如果棧頂是左括號,那么棧頂的左括號出棧,否則則入棧,最后判斷棧是否為空。這個算法實現當然不難,但是如果你仔細去想了,你會發現完全沒有必要用棧,因為如果我們遇到右括號的時候,棧頂不為左括號,那么一定最后是無法匹配的。因為后面出現的左括號不能匹配前面出現的右括號,正所謂往者不可追就是這個道理。【狗頭】
優化
我們來思考一個問題:什么情況會出現右括號遇不到左括號呢?只有一種情況,就是當前出現右括號的個數超過了左括號,也就是說我們遍歷一下字符串,如果中途出現右括號數量超過左括號的情況,那么就說明這個字符串是非法的。看起來沒毛病對吧,但是有問題,我們為什么不在枚舉的時候就判斷呢,如果左括號放入的數量已經等於右括號了,那么就不往里防止右括號,這樣不就可以保證搜索到的一定是合法的字符串嗎?
如果你能想到這一層,說明你對搜索的理解已經很不錯了。我們看一下改動之后的代碼:
def dfs(pos, left, right, n, ret, cur_str):
"""
pos: 當前枚舉的位置
left: 已經放置的左括號的數量
right: 已經放置的右括號的數量
n: 括號的數量
ret: 放置答案的數組
cur_str: 當前的字符串
"""
if pos == 2*n:
ret.append(cur_str)
return
if left < n:
dfs(pos+1, left+1, right, n, ret, cur_str+'(')
if right < n and right < left:
dfs(pos+1, left, right+1, n, ret, cur_str+')')
大部分代碼都沒有變化,只是在right < n后面加入了一個right < left這個條件。看似只有一個條件,但是這個條件起到的作用至關重要。整個算法的效率有了質的提升,實際上這也是效率最高的算法。
構造
上面的方案在LeetCode官方當中都有收入,也是比較常規的解法,下面要介紹的方法是我的原創,我個人感覺也比較有意思,分享給大家。
在之前的文章當中我們介紹過分治法,分治法的核心是將一個看似無法求解的大問題,分解成比較容易解決的小問題,最后加以解決。這道題當中,我們直接求n時的解法是比較困難的,沒辦法直接獲得,我們能不能也試着使用分治的方法來解決呢?
我們來觀察一下數據,當n=1的時候,很簡單,結果是(),只有這一種。當n=2呢?有兩種,分別是(())和()(),當n=3呢?有5種:((())), ()(()), ()()(), (()()), (())()。這當中有沒有規律呢?
我們用solution(n)表示n對應的解法,那么我們可以寫出solution(n)對應的公式:
上面這個式子有點像是動態規划的狀態轉移方程,雖然不完全一樣,但是大概是那么回事。也就是說我們可以用比答案規模小的答案組裝成現在的答案。比如n=3時的答案,等於n=2時的答案和n=1時答案的拼接。
比如: solution(1) + solution(2) 可以得到: ()()()和()(()),solution(2) + solution(1)可以得到 ()()()和(())()。但是還有一種答案無法通過拼接得到就是( solution(2) )。也就是說在solution(2)的答案外面包一層括號。那為什么不用考慮solution(1)的答案外面包兩層括號呢?答案很簡單,因為solution(2)已經包括了這樣的情況,所以我們只用往下考慮一層。
不過還沒有結束,還有一點小問題,就是這樣得到的答案可能會有重復,所以我們需要去重,利用set我們可以很簡單做到這點,讓我們一起來看代碼:
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
solutionMap = {}
# 記錄下n=0和1時的答案
solutionMap[0] = set([""])
solutionMap[1] = set(["()"])
# 遍歷小於n的所有長度
for i in range(2, n+1):
cur = set()
# 遍歷小於n的所有長度
for j in range(1, i):
# 構造答案
ans1 = solutionMap[j]
ans2 = solutionMap[i-j]
for s in ans1:
for t in ans2:
cur.add(s + t)
# 構造 ( solution(n-1) )這種答案
for s in solutionMap[i-1]:
cur.add("(" + s + ")")
solutionMap[i] = cur
return list(solutionMap[n])
在C++當中,這兩種方法的效率差不多,但是使用Python的話,構造的方法要更快一些。和搜索這種方法相比,搜索是不知道答案去搜尋答案,而構造法是知道答案大概長什么樣子,依據一定的規則生產答案。可以說是兩種不同思路的解法,也是我本人很喜歡這道題的原因。
這道題的代碼都不長,但是思路挺有意思,希望大家會喜歡。
今天的文章就是這些,如果覺得有所收獲,請順手掃碼點個關注吧,你們的舉手之勞對我來說很重要。