Swift 算法實(shí)戰(zhàn)之路:深度和廣度優(yōu)先搜索

之前談到了最簡(jiǎn)單的搜索法:二分搜索。雖然它的算法復(fù)雜度非常低只有O(logn),但使用起來(lái)也有局限:只有在輸入是排序的情況下才能使用。這次講解兩個(gè)更復(fù)雜的搜索算法 -- 深度優(yōu)先搜索(Depth-First-Search,以下簡(jiǎn)稱(chēng)DFS)和廣度優(yōu)先搜索(Breadth-First-Search,以下簡(jiǎn)稱(chēng)BFS)。

基本概念

DFS和BFS的具體定義這里不做贅述。筆者談?wù)勛约簩?duì)此的形象理解:假如你在家中發(fā)現(xiàn)鑰匙不見(jiàn)了,為了找到鑰匙,你有兩種選擇:

  1. 從當(dāng)前角落開(kāi)始,順著一個(gè)方向不停的找。假如這個(gè)方向全部搜索完畢依然沒(méi)有找到鑰匙,就回到起始角落,從另一個(gè)方向?qū)ふ?,直到找到鑰匙或所有方向都搜索完畢為止。這種方法就是DFS。
  2. 從當(dāng)前角落開(kāi)始,每次把最近所有方向的角落全部搜索一遍,直到找到鑰匙或所有方向都搜索完畢為止。這種方法就是BFS。

我們假設(shè)共有10個(gè)角落,起始角落為1,它的周?chē)?個(gè)方向,如下圖:


DFS的搜索步驟為

  • 1
  • 2 -> 3 -> 4
  • 5
  • 6 ->7 -> 8
  • 9 -> 10

即每次把一個(gè)方向徹底搜索完全后,才返回搜索下一個(gè)方向。
BFS的搜索步驟為

  • 1
  • 2 -> 5 -> 6 -> 9
  • 3 -> 4
  • 7
  • 10
  • 8

即每次訪(fǎng)問(wèn)上一步周?chē)蟹较蛏系慕锹?/strong>。
細(xì)心的朋友會(huì)記得,我之前在講二叉樹(shù)的時(shí)候,講到了前序遍歷層級(jí)遍歷,而這兩者本質(zhì)上就是DFS和BFS。

DFS的Swift實(shí)現(xiàn):

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實(shí)現(xiàn):

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)
            }
        }
    }
}

永遠(yuǎn)記?。?strong>DFS的實(shí)現(xiàn)用遞歸,BFS的實(shí)現(xiàn)用循環(huán)(配合隊(duì)列)。

iOS實(shí)戰(zhàn)演練

硅谷面試iOS工程師,有這樣一個(gè)環(huán)節(jié),給你1 ~ 1.5小時(shí),從頭開(kāi)始實(shí)現(xiàn)一個(gè)小App。我們來(lái)看這樣一個(gè)題目:

實(shí)現(xiàn)一個(gè)找單詞App: 給定一個(gè)初始的字母矩陣,你可以從任一字母開(kāi)始,上下左右,任意方向、任意長(zhǎng)度,選出其中所有單詞。

找單詞App

很多人拿到這道題目就懵了。。。完全不是我們熟悉的UITableView或者UICollectionView啊,這要咋整。我們來(lái)一步步分析。

第一步:實(shí)現(xiàn)字母矩陣

首先,我們肯定有個(gè)字符二階矩陣作為輸入,姑且記做:matrix: [[Character]]?,F(xiàn)在要把它展現(xiàn)在手機(jī)上,那么可行的方法,就是創(chuàng)建一個(gè)UILabel二維矩陣,記做labels: [[UILabel]],矩陣中每一個(gè)UILabel對(duì)應(yīng)的內(nèi)容就是相應(yīng)的字母。同時(shí),我們可以維護(hù)2個(gè)全局變量,xOffset和yOffset。然后在for循環(huán)中創(chuàng)建相應(yīng)的UILabel同時(shí)將其添加進(jìn)lables中便于以后使用,代碼如下:

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實(shí)現(xiàn)搜索單詞

現(xiàn)在要實(shí)現(xiàn)搜索單詞的核心算法了。我們先簡(jiǎn)化要求,假如只在字母矩陣中搜索單詞"crowd"該怎么做?
首先我們要找到 "c" 這個(gè)字母所在的位置,然后再上下左右找第二個(gè)字母 "r" ,接著再找字母 "o" 。。。以此類(lèi)推,直到找到最后一個(gè)字母 "d" 。如果沒(méi)有找到相應(yīng)的字母,我們就回頭去首字母 "c" 所在的另一個(gè)位置,重新搜索。
這里要注意一個(gè)細(xì)節(jié),就是我們不能回頭搜索字母。比如我們已經(jīng)從 "c" 開(kāi)始向上走搜索到了 "r" ,這個(gè)時(shí)候就不能從 "r" 向下回頭 -- 因?yàn)?"c" 已經(jīng)訪(fǎng)問(wèn)過(guò)了。所以這里需要一個(gè)var visited: [[Bool]] 來(lái)記錄訪(fǎng)問(wèn)記錄。代碼如下:

func searchWord(_ board: [[Character]]) -> Bool {
    guard board.count > 0 && board[0].count > 0 else {
        return false
    }

    let (m, n) = (board.count, board[0].count)
    var visited = Array(repeating: Array(repeating: false, count: n), count: m)
    var wordContent = [Character]("crowd".characters)

    for i in 0 ..< m {
        for j in 0 ..< n {
            if 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, _ visited: inout [[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
}

第三步:優(yōu)化算法,進(jìn)階

好了現(xiàn)在我們已經(jīng)知道了怎么搜索一個(gè)單詞了,那么多個(gè)單詞怎么搜索?首先題目是要求找出所有的單詞,那么肯定事先有個(gè)字典,根據(jù)這個(gè)字典,我們可以知道所選字母是不是可以構(gòu)成一個(gè)單詞。所以題目就變成了:

已知一個(gè)字母構(gòu)成的二維矩陣,并給定一個(gè)字典。選出二維矩陣中所有橫向或者縱向的單詞。

也就是實(shí)現(xiàn)以下函數(shù):

func findWords(_ board: [[Character]], _ dict: Set<String>) -> [String] {}

我們剛才已經(jīng)知道如何在矩陣中搜索一個(gè)單詞了。所以最暴力的做法,就是在矩陣中,搜索所有字典中的單詞,如果存在就添加在輸出中。
這個(gè)做法顯然復(fù)雜度極高:首先,每次DFS的復(fù)雜度就是O(n2),字母矩陣越大,搜索時(shí)間就越長(zhǎng);其次,字典可能會(huì)非常大,如果每個(gè)單詞都搜索一遍,開(kāi)銷(xiāo)太大。這種做法的總復(fù)雜度為O(m·n2),其中m為字典中單詞的數(shù)量,n為矩陣的邊長(zhǎng)。
這個(gè)時(shí)候就要引入Trie樹(shù)(前綴樹(shù))。首先我們把字典轉(zhuǎn)化為前綴樹(shù),這樣的好處在于它可以檢測(cè)矩陣中字母構(gòu)成的前綴是不是一個(gè)單詞的前綴,如果不是就沒(méi)必要繼續(xù)DFS下去了。這樣我們就把搜索字典中的每一個(gè)單詞,轉(zhuǎn)化為了只搜字母矩陣。代碼如下:

func findWords(_ board: [[Character]], _ dict: Set<String>) -> [String] {
  var res = [String]()
  
  let (m, n) = (board.count, board[0].count)

  let trie = _convertSetToTrie(dict)
  var visited = Array(repeating: Array(repeating: false, count: n), count: m)
  
  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
  }
  
  // 已經(jīng)訪(fǎng)問(wèn)過(guò)了
  guard !visited[i][j] else {
    return
  }
  
  // 搜索目前字母組合是否是單詞前綴
  var str = str + "\(board[i][j])"
  guard trie.prefixWith(str) else {
    return
  }
  
  // 確認(rèn)當(dāng)前字母組合是否為單詞
  if trie.isWord(str) && !res.contains(str) {
    res.append(str)
  }
  
  // 繼續(xù)搜索上下左右四個(gè)方向
  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
}

這里對(duì)Trie不做深入展開(kāi),有興趣的朋友自行研究。

總結(jié)

深度優(yōu)先遍歷和廣度優(yōu)先遍歷是算法中略微高階的部分,實(shí)際開(kāi)發(fā)中,它也多與地圖路徑、棋盤(pán)游戲相關(guān)。雖然不是很常見(jiàn),但是理解其基本原理并能熟練運(yùn)用,相信可以使大家的開(kāi)發(fā)功力更上一層樓。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容