《iOS面試之道》演算法基礎學習(上)

Tioks0發表於2019-03-03

前言

道長和唐巧的面試之道,剛出來第一時間就入手了,也是趁著公司目前不是很忙,能好好靜下心來細讀這本書.這本書的一大亮點就在第二章的演算法基礎了,所以想通過筆記的形式來記錄演算法的學習過程,同時在忘記的時候也能第一時間翻閱檢視.

這部分程式碼都是通過Swift來講解的,所以對於想學習Swift的初學者來說也是不錯的.在本章演算法題筆者簡單的分析了下,需要看詳細的大家可以選擇購買原書學習.

1 字典和集合:給出一個整型陣列和一個目標值,判斷陣列中是否有兩個數之和等於目標值.

這種題在LeetCode上其實挺多的,書上關於這題swift的程式碼也是比較簡單.

func twoSum (nums: [Int], _ target: Int) -> Bool {
      //初始化集合
      var set = Set<Int>()
      //遍歷整型陣列
      for num in nums {
          //判斷集合中是否包含目標值減去當前值的結果
          if set.contains(target - num) {
              //包含 返回true
              return true
          }
          //不包含 將當前值存進集合 用作下次判斷
          set.insert(num)
      }
      //都不包含 返回fasle
      return false
  }
複製程式碼

這種方法的時間複雜度為O(n),較選中一個數然後遍歷整個陣列這種複雜度為 O(n^2) 的方法要好很多.

1.2:在這道題的基礎上做修改:給定一個整型陣列中有且僅有 兩個數之和等於目標值,求這兩個數在陣列中的序號.

書中的方法為了方便得到序列號,使用了字典,看程式碼

 func twoSum (nums: [Int], _ target: Int) -> [Int] {
    //初始化字典
    var dict = [Int: Int]()
    //通過索引i 和對應的num進行判斷
    for (i,num) in nums.enumerated() {
        //從dict字典中取出之前儲存的索引,判斷是否存在索引值
        if let lastIndex = dict[target - num] {
            //返回之前存的索引和當前索引
            return [lastIndex, i]
        }else {
            //儲存當前索引,用於後續判斷
            dict[num] = i
        }
    }
    //致命錯誤來中止程式
    fatalError("No valid output!")
}
複製程式碼

巧妙的用到了字典的特性,用key表示陣列的值,通過判斷字典中是否含有目標值的key來取出索引.

2 字串:給出一個字串,要求將其按照單詞順序進行反轉.

eg:如果是”hello world”,那麼反轉後的結果就是”world hello”,這個題比較常規了,但是要注意空格的特殊處理.看程式碼:

fileprivate func _reverse<T>(_ chars: inout[T], _ start: Int, _ end: Int) {
    //接收字串反轉起始,結束位置
    var start = start, end = end
    //判斷反轉字串的位置
    while start < end {
        //start end位置 字元互換
        swap(&chars, start, end)
        //往中間位置靠攏
        start += 1
        end -= 1
    }
}

fileprivate func swap<T>(_ chars: inout[T], _ p: Int, _ q: Int){
    //將p,q字元 位置進行互換  這種寫法也是swift裡的一大亮點
    (chars[p], chars[q]) = (chars[q], chars[p])
}
複製程式碼

上面方法是實現了字串的反轉,再來看下每個單詞的單獨反轉.需要注意的是單詞的反轉需要對空格進行判斷,來看完整實現程式碼.

func reverseWords(s: String?) -> String? {
    //判斷入參s是否有值
    guard let s = s else {
        return nil
    }
    //將字串s分割成字元陣列
    var chars = Array(s.characters), start = 0
    //反轉chars字元陣列
    _reverse(&chars, start, chars.count - 1)
   
    for i in 0 ..< chars.count {
        //當i等於 陣列最後一位 或者 遇到空格時反轉字串
        if i == chars.count - 1 || chars[i + 1] == " " {
            //將每個單獨的單詞進行反轉
            _reverse(&chars, start, i)
            //更新start位置
            start = i + 2
        }
    }
    return String(chars)
}
複製程式碼

註解:

1:以輸入字串為”Hello World”為例,首先將該字串分割成一個個的小字元陣列,然後反轉成 “dlroW olleH”.

2“接著我們通過判斷字元陣列中的空格位和最後一位字元,對單一的單詞進行分段反轉,更新start位置.
3:最後輸出我們需要的結果”World Hello”

其實字串反轉蘋果已經為我們提供了方法來實現這個操作.

func reverseWords(s: String) -> String? {
    //用空格劃分字串
    let chars = s.components(separatedBy:" ")
    //將字串陣列 反轉 並通過空格重新組合
    let reverseString = chars.reversed().joined(separator:" ")
    return reverseString
}
複製程式碼

寫法非常的簡單,就不贅述了.但是components和joined的時間複雜度是高於上面的寫法的,需要注意.

3 連結串列

筆者理解的連結串列是一串在儲存空間上非連續性、順序的儲存結構.由節點進行頭尾依次連線而形成連結串列.每個結點有包括兩個部分:資料域和下個節點的指標域.

接下來看下書中在swift裡如何實現一個連結串列

1:生成節點

class ListNode {
    //資料域
    var val: Int
    //指標域
    var next: ListNode?
    
    init(_ val: Int) {
        self.val = val
        self.next = nil
    }     
}
複製程式碼

2:實現頭插法和尾插法
頭插法:當前節點插到第一個節點之前,
尾插法:當前節點插入到連結串列最後一個節點之後。

class List {
    
    var head: ListNode?
    var tail: ListNode?
    
    //頭插法
    func appendToHead(_ val: Int) {
        
        if head == nil {
           //建立頭節點 
            head = ListNode(val)
            tail = head
        } else {
            let temp = ListNode(val)
           //把當前head地址賦給temp的指標域
            temp.next = head
            head = temp
        }
        
    }
    
    //尾插法實現同上
    func appendToTail(_ val: Int) {
        if tail == nil {
            tail = ListNode(val)
            head = tail
        } else {
            tail!.next = ListNode(val)
            tail = tail!.next
        }
    }
}
複製程式碼

例題: 1 ->3 ->5 ->2 ->4 ->2,當給定x=3時,要求返回 1 ->2 ->2 ->3 ->5 ->4.
書上給出的解題思路,將完整的連結串列通過條件判斷(入參x)分割成2個新的連結串列,然後再將2個新連結串列拼接成完整的連結串列,看下程式碼.

func partition(_ head: ListNode?, _ x: Int) -> ListNode? {
    //建立2個Dummy節點
    let prevDummy = ListNode(0), postDummy = ListNode(0)
    var prev = prevDummy, post = postDummy
    
    var node = head
    //判斷是否存在node
    while node != nil {
        //判斷資料是否小於x
        if node!.val < x {
            //小於x prev.next指標域指向node
            prev.next = node
            prev = node!
        } else
        {
            //同上 大於x 構建新的連結串列
            post.next = node
            post = node!
        }
        //判斷下個節點
        node = node!.next
    }
    //將尾節點next置為nil,防止構成環
    post.next = nil
    //左右拼接(左邊尾節點指向右邊頭節點)
    prev.next = postDummy.next

    return prevDummy.next
}
複製程式碼

關於Dummy節點,書中給出了詳細介紹.

上面程式碼引入了Dummy節點,它的作用就是作為一個虛擬的頭前結點。我們引入它的原因是我們不知道要返回的新連結串列的頭結點是哪一個,它有可能是原連結串列的第一個節點,可能在原連結串列的中間,也可能在最後,甚至可能不存在(nil)。而Dummy節點的引入可以巧妙的涵蓋所有以上情況,我們可以用dummy.next方便得返回最終需要的頭結點。

註解:

1:首先建立左右兩個Dummy節點,然後通過while判斷node是否存在.

2:若存在再判斷節點的資料val和判斷條件x的關係,小於x則被鏈道左邊連結串列上,大於x被鏈到右邊連結串列.

3:執行完while,此時我們已經拿到左右兩個新的連結串列.最後把左邊的尾節點指向右邊的頭節點就完成了完整連結串列的拼接.

注意:需要將右連結串列的尾節點置nil,防止構成環.
關於檢測連結串列中是否有環,可以通過快行指標來檢測,若快指標和慢指標變成相等的了,就代表該連結串列有環,具體的就不在這裡介紹了,比較簡單.

4 棧和佇列

先來了解下棧和佇列的基本概念
棧:先進後出(First In Last Out)的結構,又叫FILO.可以理解為我們小時候完的疊羅漢,最下面的人是第一躺下而最後一個起來的.
佇列:先進先出(First In First Out)的結構,又叫(FIFO).這個字面上理解就行,就像排隊買票一樣,先來的人先買到票.

瞭解了棧和堆的概念,來看下Swift怎麼來寫出個棧和佇列.

protocol Stack {
   //associatedtype:關聯型別 相當於範型 在呼叫的時候可以根據associatedtype指定的Element來設定型別
   associatedtype Element
   //判斷棧是否為空
   var isEmpty: Bool {get}
   //棧的大小
   var size: Int {get}
   //棧頂元素
   var peek: Element? {get}
  //mutating:當需要在協議(結構體,列舉)的例項方法中,修改協議(結構體,列舉)的例項屬性,需要用到mutating對例項方法進行修飾,不然會報錯.
  //進棧
  mutating func push(_ newElement: Element)
  //出棧
  mutating func pop() -> Element?
}
複製程式碼

實現協議方法

struct IntegerStack: Stack {
//typealias:型別別名 指定協議關聯型別的具體型別 和associatedtype成對出現的
typealias Element  = Int

var isEmpty: Bool {return stack.isEmpty}

var size: Int {return stack.count}
//取出stack棧頂元素
var peek: Element? {return stack.last}

private var stack = [Element]()
//push 加入stack陣列中
mutating func push(_ newElement: Element) {
   return stack.append(newElement)
}
//pop 刪除stack中最後一個元素
mutating func pop() -> Element? {
   return stack.popLast()
  }
}
複製程式碼

註解:利用協議申明瞭棧的屬性和方法,並在結構體中宣告陣列stack,對陣列資料進行append和poplLast操作,完成入棧出棧操作,比較簡單.

再來看下佇列的實現:

protocol Queue {

associatedtype Element
//佇列是否為空
var isEmpty: Bool {get}
//佇列大小
var size: Int {get}
//佇列首元素
var peek: Element? {get}
//入列
mutating func enqueue(_ newElement: Element)
//出列
mutating func dequeue() -> Element?

}
複製程式碼

實現協議方法

struct IntegerQueue: Queue {

  typealias Element = Int

  var isEmpty: Bool {return left.isEmpty && right.isEmpty}
  var size: Int {return left.count + right.count}
  var peek: Element? {return left.isEmpty ? right.first : left.last}

  //宣告左右2個陣列
  private var left = [Element]()
  private var right = [Element]()

 //在right陣列中新增新元素
  mutating func enqueue(_ newElement: Int) {
      right.append(newElement)
  }

 //出佇列時 首先判斷left是否為空 
  mutating func dequeue() -> Element? {
      if left.isEmpty {
       //reversed: 倒序遍歷陣列
          left = right.reversed()
       //刪除right陣列
          right.removeAll()
      }
      //刪除left陣列的最後一個元素
      return left.popLast()
  }
}
複製程式碼

註解:上面程式碼裡面分別用兩個陣列分別完成入佇列和出佇列的操作,用right儲存入佇列的元素.當出佇列時會先判斷left是否為空,如果為空left陣列指向倒序後的right陣列,這時執行popLast,即實現了出佇列的操作.size peek isEmpty屬性也是充分通過左右兩個陣列來進行判斷.
關於書上用left right兩個陣列來形成一個佇列的寫法筆者自己也不是很清楚,希望有知道的同學可以給小弟指點一下.

之後書上提到了棧和佇列互相轉換,這塊部分因為篇幅原因就不介紹了,不過大致思路是通過兩個棧/佇列,配合生成所對應的結構,是一種用空間複雜度換時間複雜度的寫法.有興趣的同學可以購買原書細讀.

例題:給出路徑 /a/./b/../b/c/,簡化成/c
首先分析一下這道題,輸入字串x,輸出字串y.然後我們通過**`/`將輸入的字串進行分割,分割成字串陣列,然後遍歷陣列中字元的具體情況(”.“表示當前目錄,”..**”表示上一級目錄)就可以了,看下程式碼.

func simplifyPath(path: String) -> String {
    //初始化儲存路徑的陣列
    var pathStack = [String]()
    //通過"/"分割入參path
    let paths = path.components(separatedBy: "/")
    //遍歷paths
    for path in paths {
       // 當path為"."時,表示當前目錄,所以不做處理
        guard path != "." else {
           //跳過迴圈
            continue
        }
        //當path等於".."時,表示返回上級目錄
        if path == ".." {
            //這裡邊界情況一定要考慮,這也是個容易出錯的地方
            if (pathStack.count > 0){
               //pathStack有資料,刪除last元素
                pathStack.removeLast()
            }
        } else if path != "" {
           //判斷path不等於""的邊界情況,新增路徑
            pathStack.append(path)
        }
    }
    //將pathStack進行遍歷拼接,獲得最終結果res
    let res = pathStack.reduce(""){
        total, dir in "(total)/(dir)"
    }
    //如果是空路徑 返回 "/"
    return res.isEmpty ? "/" : res
}
複製程式碼

上面的例題並不是很難,但是想完整寫好卻不是十分容易.需要注意的邊界條件非常的多,如果在面試的時候只是順著解決問題的方向走而忽視了這些邊界問題,那麼給面試官的印象也會大打折扣.

5 二叉樹

概念:二叉樹中,每個節點最多有兩個子節點,一般稱為左子節點和右子節點,並且節點的順序不能任意顛倒.第一層的節點稱之為根節點,我們從根節點開始計算,到節點的最大層次為二叉樹的深度.
首先看下書中節點實現的程式碼:

//public:不同於OC的public swift中不能在override和繼承的extension中訪問
public class TreeNode {
    public var val: Int
    public var left: TreeNode?
    public var right: TreeNode?
    public init(_ val: Int){
        self.val = val
    }
}
複製程式碼

一個完整的二叉樹節點包含資料val,左子節點和右子節點.

有了節點我們再看下如何求二叉樹的深度

func treeMaxDepth(root: TreeNode?) -> Int {
    
    guard let root = root else {
        return 0;//root不存在返回0
    }
    //max函式:比較兩個入參的大小取最大值
    return max(treeMaxDepth(root:root.left), treeMaxDepth(root: root.right)) + 1 
}
複製程式碼

註解:

1:首先判斷入參二叉樹節點是否為nil,若不存在的話返回0,若存在遞迴子節點取最大值.

2:+1表示每遞迴一層,二叉樹深度+1.

3:max函式作用:左右子節點最大深度比較,取較大值.

二叉查詢樹

在二叉樹中有類特別的二叉樹叫做二叉查詢樹(Binary Sort Tree),滿足所有左子節點的值都小於它的根節點的值,所有右位元組點都大於它的根子節點的值,那麼問題來了我們怎麼判斷一顆二叉樹是否為二叉查詢樹呢.來看程式碼:

func isValidBST(root: TreeNode?) -> Bool {
    return _helper(node: root, nil, nil)
}

private func _helper(node: TreeNode?, _ min: Int?, _ max: Int?) -> Bool {
    //node不存在 則到了最底層 遞迴結束.這個要注意的是第一次傳入的node不能為nil
    guard let node = node else {
        return true
    }
    //右子樹的值都必須大於它的根節點值
    if let min = min, node.val <= min {
        return false
    }
    //左子樹的值都必須小於它的根節點值
    if let max = max, node.val >= max {
        return false
    }
    //左右子樹同時遞迴
    return _helper(node: node.left, min, node.val) && _helper(node: node.right, node.val, max)
}
複製程式碼

註解:

1:這個地方會首先判斷當前節點是否存在,若不存在即代表已經遞迴到最後一層,返回true.

2:然後判斷右子樹和左子樹的val是否滿足判斷條件,若都滿足條件進行下一層的遞迴.

3:左右同時進行遞迴操作.

知道了二叉樹的基本概念和swift的寫法外,再來看下如何實現二叉樹的遍歷操作.常見的二叉樹遍歷有前序、中序、後序和層級遍歷(廣度優先遍歷).

給出幾種遍歷方法的規則:

前序遍歷規則:
1:訪問根節點
2:前序遍歷左子樹
3:前序遍歷右子樹

中序遍歷規則:
1:中序遍歷左子樹
2:訪問根節點
3:中序遍歷右子樹

後序遍歷規則:
1:後序遍歷左子樹
2:後序遍歷右子樹
3:訪問根節點

**層級遍歷規則:**以層為順序,將某一層上的節點全部遍歷完成後,才向下一層進行遍歷,依次類推,至到最後一層.

如果還是不理解可以看下這篇關於二叉樹遍歷的介紹,講的比較直白易懂.

關於遍歷部分內容因為比較多,筆者摘錄書上佇列實現樹來介紹下實現邏輯,看以下程式碼:

func levelOrder(root: TreeNode?) -> [[Int]] {
    //初始化 遍歷後返回的res陣列
    var res = [[Int]]()
    //佇列陣列
    var queue = [TreeNode]()
    
    if let root = root {
        //儲存入參root節點
        queue.append(root)
    }
    
    while queue.count > 0 {
        //獲取層級個數
        let size = queue.count
        //每一層 資料儲存陣列
        var level = [Int]()
        
        for _ in 0 ..< size {
            //removeFirst:刪除第一個元素 並返回被刪除的元素
            let node = queue.removeFirst()
            //新增資料
            level.append(node.val)
            //判斷是否存在 左右子節點 並新增到佇列 順序從左至右
            if let left = node.left {
                queue.append(left)
            }
            if let right = node.right {
                queue.append(right)
            }  
        }
        //儲存level資料陣列
        res.append(level)
    }
    return res
}
複製程式碼

註解:

1:首先我們宣告陣列res,用來儲存每一層的val陣列,並宣告佇列陣列,方便後面判斷.

2:判斷入參root是否存在,若存在儲存root節點到queue陣列中.

3:while判斷queue是否有資料,若有資料則取到queue的第一個資料,並在queue中刪除.然後判斷下一層的資料,並再次迴圈遍歷.

4:最後把節點的val資料儲存到level中,新增到res.

總結

關於本書中演算法部分的基本資料結構就介紹的差不多了,後面會對具體的演算法實戰部分進行介紹.筆者總體感覺這本書更適合初級->中級的同學,對於想要提高自己的初中級開發人員這本書絕對是不錯的選擇.

相關文章