之前談到了最簡單的搜尋法:二分搜尋。雖然它的演算法複雜度非常低只有O(logn),但使用起來也有侷限:只有在輸入是排序的情況下才能使用。這次講解兩個更復雜的搜尋演算法 — 深度優先搜尋(Depth-First-Search,以下簡稱DFS)和廣度優先搜尋(Breadth-First-Search,以下簡稱BFS)。
基本概念
DFS和BFS的具體定義這裡不做贅述。筆者談談自己對此的形象理解:假如你在家中發現鑰匙不見了,為了找到鑰匙,你有兩種選擇:
- 從當前角落開始,順著一個方向不停的找。假如這個方向全部搜尋完畢依然沒有找到鑰匙,就回到起始角落,從另一個方向尋找,直到找到鑰匙或所有方向都搜尋完畢為止。這種方法就是DFS。
- 從當前角落開始,每次把最近所有方向的角落全部搜尋一遍,直到找到鑰匙或所有方向都搜尋完畢為止。這種方法就是BFS。
我們假設共有10個角落,起始角落為1,它的周圍有4個方向,如下圖:
DFS的搜尋步驟為
- 1
- 2 -> 3 -> 4
- 5
- 6 ->7 -> 8
- 9 -> 10
即每次把一個方向徹底搜尋完全後,才返回搜尋下一個方向。
BFS的搜尋步驟為
- 1
- 2 -> 5 -> 6 -> 9
- 3 -> 4
- 7
- 10
- 8
即每次訪問上一步周圍所有方向上的角落。
細心的朋友會記得,我之前在講二叉樹的時候,講到了前序遍歷和層級遍歷,而這兩者本質上就是DFS和BFS。
DFS的Swift實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func dfs(root: Node?) { guard let root = root else { return } visit(root) root.visited = true for node in root.neighbors { if !node.visited { dfs(node) } } } |
BFS的Swift實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
func bfs(root: Node?) { var queue = [Node]() if let root = root { queue.append(root) } while !queue.isEmpty { let current = queue.removeFirst() visit(current) current.visited = true for node in current.neighbors { if !node.visited { queue.append(node) } } } } |
永遠記住:DFS的實現用遞迴,BFS的實現用迴圈(配合佇列)。
iOS實戰演練
矽谷面試iOS工程師,有這樣一個環節,給你1 ~ 1.5小時,從頭開始實現一個小App。我們來看這樣一個題目:
實現一個找單詞App: 給定一個初始的字母矩陣,你可以從任一字母開始,上下左右,任意方向、任意長度,選出其中所有單詞。
很多人拿到這道題目就懵了。。。完全不是我們熟悉的UITableView或者UICollectionView啊,這要咋整。我們來一步步分析。
第一步:實現字母矩陣
首先,我們肯定有個字元二階矩陣作為輸入,姑且記做:matrix: [[Character]]
。現在要把它展現在手機上,那麼可行的方法,就是建立一個UILabel二維矩陣,記做labels: [[UILabel]]
,矩陣中每一個UILabel對應的內容就是相應的字母。同時,我們可以維護2個全域性變數,xOffset和yOffset。然後在for迴圈中建立相應的UILabel同時將其新增進lables中便於以後使用,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
var xOffset = 0 var yOffset = 0 let cellWidth = UIScreen.mainScreen().bounds.size.width / matrix[0].count let cellHeight = UIScreen.mainScreen().bounds.size.height / matrix.count for i in 0 ..< matrix.count { for j in 0 ..< matrix[0].count { let label = UILabel(frame: CGRect(x: xOffset, y: yOffset, width: cellWidth, height: cellHeight)) label.text = String(matrix[i][j]) view.addSubView(label) labels.append(label) xOffset += cellWidth } xOffset = 0 yOffset += cellHeight } |
第二步:用DFS實現搜尋單詞
現在要實現搜尋單詞的核心演算法了。我們先簡化要求,假如只在字母矩陣中搜尋單詞”crowd”該怎麼做?
首先我們要找到 “c” 這個字母所在的位置,然後再上下左右找第二個字母 “r” ,接著再找字母 “o” 。。。以此類推,直到找到最後一個字母 “d” 。如果沒有找到相應的字母,我們就回頭去首字母 “c” 所在的另一個位置,重新搜尋。
這裡要注意一個細節,就是我們不能回頭搜尋字母。比如我們已經從 “c” 開始向上走搜尋到了 “r” ,這個時候就不能從 “r” 向下回頭 — 因為 “c” 已經訪問過了。所以這裡需要一個var visited: [[Bool]]
來記錄訪問記錄。程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
func searchWord(board: [[Character]]) -> Bool { guard board.count > 0 && board[0].count > 0 else { return false } let m = board.count let n = board[0].count var visited = Array(count: m, repeatedValue: Array(count: n, repeatedValue: false)) var wordContent = [Character]("crowd".characters) for i in 0 ..< m { for j in 0 ..< n { if board[i][j] == wordContent[0] && dfs(board, wordContent, m, n, i, j, &visited, 0) { return true } } } return false } func dfs(board: [[Character]], _ wordContent: [Character], _ m: Int, _ n: Int, _ i: Int, _ j: Int, inout _ visited: [[Bool]], _ index: Int) -> Bool { if index == wordContent.count { return true } guard i >= 0 && i < m && j >= 0 && j < n else { return false } guard !visited[i][j] && board[i][j] == wordContent[index] else { return false } visited[i][j] = true if dfs(board, wordContent, m, n, i + 1, j, &visited, index + 1) || dfs(board, wordContent, m, n, i - 1, j, &visited, index + 1) || dfs(board, wordContent, m, n, i, j + 1, &visited, index + 1) || dfs(board, wordContent, m, n, i, j - 1, &visited, index + 1) { return true } visited[i][j] = false return false } |
第三步:優化演算法,進階
好了現在我們已經知道了怎麼搜尋一個單詞了,那麼多個單詞怎麼搜尋?首先題目是要求找出所有的單詞,那麼肯定事先有個字典,根據這個字典,我們可以知道所選字母是不是可以構成一個單詞。所以題目就變成了:
已知一個字母構成的二維矩陣,並給定一個字典。選出二維矩陣中所有橫向或者縱向的單詞。
也就是實現以下函式:
1 |
func findWords(board: [[Character]], _ dict: Set) -> [String] {} |
我們剛才已經知道如何在矩陣中搜尋一個單詞了。所以最暴力的做法,就是在矩陣中,搜尋所有字典中的單詞,如果存在就新增在輸出中。
這個做法顯然複雜度極高:首先,每次DFS的複雜度就是O(n^2),字母矩陣越大,搜尋時間就越長;其次,字典可能會非常大,如果每個單詞都搜尋一遍,開銷太大。這種做法的總複雜度為O(m·n^2),其中m為字典中單詞的數量,n為矩陣的邊長。
這個時候就要引入Trie樹(字首樹)。首先我們把字典轉化為字首樹,這樣的好處在於它可以檢測矩陣中字母構成的字首是不是一個單詞的字首,如果不是就沒必要繼續DFS下去了。這樣我們就把搜尋字典中的每一個單詞,轉化為了只搜字母矩陣。程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
func findWords(board: [[Character]], _ dict: Set<String>) -> [String] { var res = [String]() let m = board.count let n = board[0].count let trie = _convertSetToTrie(dict) var visited = Array(count: m, repeatedValue: Array(count: n, repeatedValue: false)) for i in 0 ..< m { for j in 0 ..< n { _dfs(board, m, n, i, j, &visited, &res, trie, "") } } return res } private func _dfs(board: [[Character]], _ m: Int, _ n: Int, _ i: Int, _ j: Int, inout _ visited: [[Bool]], inout _ res: [String], _ trie: Trie, _ str: String) { // 越界 guard i >= 0 && i < m && j >= 0 && j < n else { return } // 已經訪問過了 guard !visited[i][j] else { return } // 搜尋目前字母組合是否是單詞字首 var str = str + "\(board[i][j])" guard trie.prefixWith(str) else { return } // 確認當前字母組合是否為單詞 if trie.isWord(str) && !res.contains(str) { res.append(str) } // 繼續搜尋上下左右四個方向 visited[i][j] = true _dfs(board, m, n, i + 1, j, &visited, &res, trie, str) _dfs(board, m, n, i - 1, j, &visited, &res, trie, str) _dfs(board, m, n, i, j + 1, &visited, &res, trie, str) _dfs(board, m, n, i, j - 1, &visited, &res, trie, str) visited[i][j] = true } |
這裡對Trie不做深入展開,有興趣的朋友自行研究。
總結
深度優先遍歷和廣度優先遍歷是演算法中略微高階的部分,實際開發中,它也多與地圖路徑、棋盤遊戲相關。雖然不是很常見,但是理解其基本原理並能熟練運用,相信可以使大家的開發功力更上一層樓。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式