Swift 算法实战之路:二分搜索

紧接上文,排序之后我们来谈谈搜索。一般最直接的搜索就是遍历集合,然后找到满足条件的元素。这种直接的暴力搜索法实现起来简单,但是当输入数据十分巨大的时候,搜索起来就会很慢(复杂度O(n))。本文将探讨算法复杂度更低、效果更好的搜索方法 - 二分搜索。本文的主要内容为:

  • 基本概念
  • 实战演练
  • iOS中搜索与排序的配合使用

基本概念

二分搜索,即在有序数组中,查找某一特定元素的搜索。它从中间的元素开始,如果中间的元素是要找的元素,则返回;若中间元素小于要找的元素,则要找的元素一定在大于中间元素的那一部分,那只需搜索那部分即可;反之搜索小于中间元素的部分即可。重复以上步骤,直到找到或确认该元素不存在为止。这样每一次搜索,就能减小一办的搜索范围,所以它的算法复杂度为O(logn)

Swift 实现二分搜索

// 假设nums是一个升序数组
func binarySearch(_ nums: [Int], _ target: Int) -> Bool {
  var left = 0, mid = 0, right = nums.count - 1
  
  while left <= right {
    mid = (right - left) / 2 + left
    
    if nums[mid] == target {
      return true
    } else if nums[mid] < target {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }
  
  return false
}

这里要注意两个细节:

第一,mid 定义在 while 循环外面,如果定义在里面,则每次循环都要重新给 mid 分配内存空间,造成不必要的浪费;定义在循环之外,每次循环只是重新赋值;
第二,每次重新给 mid 赋值不能写成mid = (right + left) / 2。这种写法表面上看没有问题,但当数组的长度非常大、算法又已经搜索到了最右边部分的时候,那么right + left就会非常之大,造成溢出导致程序崩溃。所以解决问题的办法是写成 mid = (right - left) / 2 + left

当然,我们可以用递归来实现二分搜索:

func binarySearch(nums: [Int], target: Int) -> Bool {
  return binarySearch(nums: nums, target: target, left: 0, right: nums.count - 1)
}

func binarySearch(nums: [Int], target: Int, left: Int, right: Int) -> Bool {
  guard left <= right else {
    return false
  }
  
  let mid = (right - left) / 2 + left
  
  if nums[mid] == target {
    return true
  } else if nums[mid] < target {
    return binarySearch(nums: nums, target: target, left: mid + 1, right: right)
  } else {
    return binarySearch(nums: nums, target: target, left: left, right: mid - 1)
  }
}

实战演练

第一题:版本崩溃

上面的二分搜索基本上稍微有点基本功的都能写出来。所以,真正面试的时候,最多也就是问问概念,不会真正让你实现的。真正的面试题,长下面这个样子:

有一个产品发布了n个版本。它遵循以下规律:假如某个版本崩溃了,后面的所有版本都会崩溃。
举个例子:一个产品假如有5个版本,1,2,3版都是好的,但是第4版崩溃了,那么第5个版本(最新版本)一定也崩溃。第4版则被称为第一个崩溃的版本。
现在已知一个产品有n个版本,而且有一个检测算法 func isBadVersion(version: Int) -> Bool 可以判断一个版本是否崩溃。假设这个产品的最新版本崩溃了,求第一个崩溃的版本。

分析这种题目,同样还是先抽象化。我们可以认为所有版本为一个数组[1, 2, 3, ..., n],现在我们就是要在这个数组中检测出满足 isBadVersion(version) == true中version的最小值。
很容易就想到二分搜索,假如中间的版本是好的,第一个崩溃的版本就在右边,否则就在左边。我们来看一下如何实现:

func findFirstBadVersion(version: n) -> Int {
  // 处理特殊情况
  guard n >= 1 else {
    return -1
  }

  var left = 1, right = n, mid = 0

  while left < right {
    mid = (right - left) / 2 + left
    if isBadVersion(mid) {
      right = mid
    } else {
      left = mid + 1
    }
  }

  return left    // return right 同样正确
}

这个实现方法要注意两点

  1. 当发现中间版本(mid)是崩溃版本的时候,只能说明第一个崩溃的版本小于等于中间版本。所以只能写成 right = mid
  2. 当检测到剩下一个版本的时候,我们已经无需在检测直接返回即可,因为它肯定是崩溃的版本。所以while循环不用写成left <= right

第二题:搜索旋转有序数组

上面的题目是一个简单的二分搜索变种。我们来看一个复杂版本的:

一个有序数组可能在某个位置被旋转。给定一个目标值,查找并返回这个元素在数组中的位置,如果不存在,返回-1。假设数组中没有重复值。
举个例子:[0, 1, 2, 4, 5, 6, 7]在4这个数字位置上被旋转后变为[4, 5, 6, 7, 0, 1, 2]。搜索4返回0。搜索8则返回-1。

假如这个有序数组没有被旋转,那很简单,我们直接采用二分搜索就可以解决。现在被旋转了,还可以采用二分搜索吗?
我们先来想一下旋转之后的情况。第一种情况是旋转点选的特别前,这样旋转之后左半部分就是有序的;第二种情况是旋转点选的特别后,这样旋转之后右半部分就是有序的。如下图:

那么怎么判断是结果1还是结果2呢?我们可以选取整个数组中间元素(mid) ,与数组的第1个元素(left)进行比较 -- 如果 mid > left,则是旋转结果1,那么数组的左半部分就是有序数组,我们可以在左半部分进行正常的二分搜索;反之则是结果二,数组的右半部分为有序数组,我们可以在右半部分进行二分搜索。

这里要注意一点,即使我们一开始清楚了旋转结果,也要判断一下目标值所落的区间。对于旋转结果1,数组左边最大的值是mid,最小值是left。假如要找的值target落在这个区间内,则使用二分搜索。否则就要在右边的范围内搜索,这个时候相当于回到了一开始的状态,有一个旋转的有序数组,只不过我们已经剔除了一半的搜索范围。对于旋转结果2,也类似处理。全部代码如下:

func search(nums: [Int], target: Int) -> Int {
  var (left, mid, right) = (0, 0, nums.count - 1)
        
  while left <= right {
    mid = (right - left) / 2 + left
            
    if nums[mid] == target {
      return mid
    }
            
    if nums[mid] >= nums[left] {
      if nums[mid] > target && target >= nums[left] {
        right = mid - 1
      } else {
        left = mid + 1
      }
    } else {
      if nums[mid] < target && target <= nums[right] {
        left = mid + 1
      } else {
        right = mid - 1
      }
    }
  }
        
  return -1
}

大家可以想一下假如旋转后的数组中有重复值比如[3,4,5,3,3,3]该怎么处理?

iOS中搜索与排序的配合使用

RSS Reader

上图是iOS中开发的一个经典案例:新闻聚合阅读器(RSS Reader)。因为新闻内容经常会更新,所以每次下拉刷新这个UITableView或是点击右上角刷新按钮,经常会有新的内容加入到原来的dataSource中。刷新后合理插入新闻,就要运用到搜索和排列。

笔者在这里提供一个方法。首先,写一个ArrayExtensions.swift;

extension Array {
  func indexForInsertingObject(object: AnyObject, compare: ((a: AnyObject, b: AnyObject) -> Int)) -> Int {
    var left = 0
    var right = self.count
    var mid = 0
    
    while left < right {
      mid = (right - left) / 2 + left
      
      if compare(a: self[mid] as! AnyObject, b: object) < 0 {
        left = mid + 1
      } else {
        right = mid
      }
    }
    
    return left
  }
}

然后在FeedsViewController(就是显示所有新闻的tableView的controller)里面使用这个方法。一般情况下,FeedsViewController里面会有一个dataSource,比如一个装新闻的array。这个时候,我们调用这个方法,并且让它每次都按照新闻的时间进行排序

let insertIdx = news.indexForInsertingObject(object: singleNews) { (a, b) -> Int in
  let newsA = a as! News
  let newsB = b as! News
  return newsA.compareDate(newsB)
}

news.insert(singleNews, at: insertIdx)

这里你需要在News这个model里实现compareDate这个函数。

总结

二分搜索是一种十分巧妙的搜索方法,它的复杂度是主流算法中最低的。正以为其十分高效,它会经常配合排序出现在各种日常coding和iOS开发中。当然,二分搜索也会出现各种各样的变种,其实万变不离其宗,关键是想方法每次减小一半的搜索范围即可。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容