Swift的二分法查找实践
在这篇教程中我们会使用计算机科学里一个基础的算法:二分法查找
binary search.我们也会实现一个二分法查找的项目:快速自动匹配.
介绍
在一个数组里查找某个元素,最直接的方法是直接循环这个数组, 然后比较看是否等于我们要查找的元素.这种方法叫做linear search, 实现方法如下:
func linearSearch(array: [Int], key: Int) -> Bool {
for item in array {
if item == key {
return true
}
}
return false
}
在大多数实际的项目中顺序查找速度已经满足需求了. 但是如果数据量很大或者比较操作十分耗时, 那么这时候顺序查找性能就会急剧下降.
另一个方法是使用二分法查找. 二分法查找只适用于有序的数据, 所以我们需要将数组排序或者维护它的有序状态来保证二分法查找可以正确运行.
二分法查找是通过将数组从中间位置分开两半, 查找只会发生在其中的一部分.如果当前的元素比查找元素大, 则查找会在前半部分继续.如果当前元素比查找元素小,则查找在后半部分继续.
如果当前元素等于查找元素,则完成查找.
查找操作只有当找到元素了或者数组长度变成1
.
为了让二分查找更加的直观, 可以看按字母排序的电话簿.假如你现在想找Nash John
的电话号码,比较快捷的方法是从电话簿的中间开始找.你只需要找电话簿的一半,因为Nash John
只会出现在当前页的之前或者之后.二分法查找跟这个方法差不多, 除了一直将数据对半分, 知道找到元素.
下图展示了怎样在已下的有序数组里面找到36
:
let array = [1, 2, 3, 5, 5, 7, 10, 36, 50]
二分查找法比线性查找法高效是因为二分查找法每一次查找都会将搜索范围缩减一半.当然, 在我们长度为9
的数组来说没有很明显的效果, 这里线性查找最多需要9
次找到目标, 而二分查找法最多需要4
次.但是如果一个数组有1000
个元素,线性查找最多需要1000
次查找, 而二分查找法则最多需要10
次.(查找数组)
即使数组大小为10亿
,二分查找法也最多需要30
次查找就能够找到元素.
假设数组大小是K
, 那么线性查找需要K
次查找, 而二分查找需要[Log₂(K)]
次.
如果Log₂(K) = n
, 那么 2^n = K
.
Swift 实现
直到现在为止,我们只从理论的角度来讲二分查找法,让我们来看看怎么用Swift来实现这个算法.这个算法实现为一个方法, 入参是一个数组, 一个目标元素(target
), 根据查找结果返回 一个bool
值.
我们会维护两个index值('left
和right
), 代表着查找数组的范围.初始值是数组的第一个元素和最后一个元素的index.
下一步我们会重复从数组中取得中间的元素与target
作比较.我们会在一个while
循环循环这些操作, 直到数组大小为1
,也就是left <= right
.
我们会从每一步的循环中计算中间的index(left
和right
的均值),然后取得元素的值.
现在我们会遇到3种情况:
- 找到target, 完成查找.返回
true
. - 当前元素的值小于target.我们设置
left
为mid + 1
然后让查找在数组的后半部分继续. - 当前元素的值大于target.我们设置
right
为mid - 1
然后让查找在数据的前半部分继续.
如果在退出循环,那么target肯定不在数组中, 那么这时候返回false
.
func binarySearch(array: [Int], target: Int) -> Bool {
var left = 0
var right = array.count - 1
while (left <= right) {
let mid = (left + right) / 2
let value = array[mid]
if (value == target) {
return true
}
if (value < target) {
left = mid + 1
}
if (value > target) {
right = mid - 1
}
}
return false
}
以上的方法只适用整型数组, 但我们可以很容易地将改方法扩展为Comparable
型的数组.
func binarySearch<T : Comparable>(array: [T], target: T) -> Bool {
var left = 0
var right = array.count - 1
while (left <= right) {
let mid = (left + right) / 2
let value = array[mid]
if (value == target) {
return true
}
if (value < target) {
left = mid + 1
}
if (value > target) {
right = mid - 1
}
}
return false
}
查找前缀
我们泛型版本的二分查找法可以用在搜索字符串的数组.我们可以进一步扩展该方法为在字符串数组中查找前缀等于target字符串的元素.如果一个字符串以target字符串作为前缀,那么我们就说target字符串就是该元素的前缀.
实现这个功能, 我们只需要在比较元素的时候用hasPrefix
方法.
func binarySearchPrefix(array: [String], target: String) -> Bool {
var left = 0
var right = array.count - 1
while (left <= right) {
let mid = (left + right) / 2
let value = array[mid]
if (value.hasPrefix(target)) {
return true
}
if (value < target) {
left = mid + 1
}
if (value > target) {
right = mid - 1
}
}
return false
}
有可能数组里前缀是target字符串的不止一个.我们可以实现一个方法返回数组中第一个前缀为target元素的下标而不是返回true/false
. 相似的, 也可以实现一个返回满足条件的最后一个元素的下标.我们会用这些方法来实现自动匹配
功能.
这里的技巧就是只有当出现已下某一种情况的时候我们才返回下标:
-
left
和right
的值相等, 并且当前值的前缀等于target. - 当前值的前缀等于target并且当前值的前一个元素的前缀不等于target(适用于
binarySearchFirst
方法).我们需要注意不要进行数组的越界操作. - 在
binarySearchLast
方法中,我们使用了相同的方法,只是检测当前值的下一个元素的前缀不等于target.
如果数组中没有符号条件的字符串,返回-1
.
binarySearchFirst :
func binarySearchFirst(array: [String], target: String) -> Int {
var left = 0
var right = array.count - 1
while (left <= right) {
let mid = (left + right) / 2
let value = array[mid]
if (left == right && value.hasPrefix(target)) {
return left
}
if value.hasPrefix(target) {
if mid > 0 {
if !array[mid - 1].hasPrefix(target) {
return mid
}
}
right = mid - 1
} else if (value < target) {
left = mid + 1
} else if (value > target) {
right = mid - 1
}
}
return -1
}
binarySearchLast:
func binarySearchLast(array: [String], target: String) -> Int {
var left = 0
var right = array.count - 1
while (left <= right) {
let mid = (left + right) / 2
let value = array[mid]
if (left == right && value.hasPrefix(target)) {
return left
}
if value.hasPrefix(target) {
if mid < array.count - 1 {
if !array[mid + 1].hasPrefix(target) {
return mid
}
}
left = mid + 1
} else if (value < target) {
left = mid + 1
} else if (value > target) {
right = mid - 1
}
}
return -1
}
自动匹配
假设你现在要建一个浏览器, 当用户输入url的时候需要有自动匹配.一个用户可能会有成千上万的url历史纪录,这时如果用线性查找的话自动匹配会有有延迟导致用户体验不好.
线性查找
二分查找
这个项目的UI代码相当的简单直接: 一个textField
用来让用户输入URL, 下面还有一个tableView
来显示自动匹配
.tabeView
显示的数据是来自于SearchManager
.这个类包含已下的方法:
-
func updateFilter(filter: String)
,用来更新过滤URLs的字符串. -
func filteredURLAtIndex(index: Int) -> String
返回过滤后URLs列表中的第index个URL. -
func filteredURLCount() -> Int
返回满足过滤条件URLs的数量.
UI代码
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var textField: UITextField!
var searchManager = SearchManager()
override func viewDidLoad() {
super.viewDidLoad()
}
@IBAction func textFieldDidChange(sender: AnyObject) {
searchManager.updateFilter(textField.text)
tableView.reloadData()
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel!.text = searchManager.filteredURLAtIndex(indexPath.row)
return cell
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return searchManager.filteredURLCount()
}
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
self.textField.text = searchManager.filteredURLAtIndex(indexPath.row)
}
}
Search manager
Search manager的作用是初始化之后维护一个从文件中读取的URL列表.另外还有两个需要维护的数组下标: filterStart
:第一满足过滤条件URL的下标. filterEnd
:最后一个满足过滤条件URL的下标.
下面是SearchManager
线性查找的实现.
class SearchManager {
private var urls: [String]
private var filterStart: Int
private var filterEnd: Int
init() {
let path = NSBundle.mainBundle().pathForResource("urls", ofType: "txt")!
let content = String(contentsOfFile: path, encoding: NSUTF8StringEncoding, error: nil)!
urls = content.componentsSeparatedByString("\n")
filterStart = 0
filterEnd = urls.count - 1
}
func filteredURLCount() -> Int {
if filterStart == -1 {
return 0
}
return filterEnd - filterStart + 1
}
func filteredURLAtIndex(index: Int) -> String {
return urls[filterStart + index]
}
func updateFilter(filter: String) {
if filter == "" {
filterStart = 0
filterEnd = urls.count - 1
return
}
var foundFirst = false
var index = 0
for url in urls {
if url.hasPrefix(filter) {
if !foundFirst {
filterStart = index
foundFirst = true
}
filterEnd = index
}
index++
}
if !foundFirst {
filterStart = -1
}
}
}
SearchManager
二分查找法是比较容易实现.需要注意的是我们修改SearchManager
的内部实现的时候是不需要修改UI代码的.
class SearchManager {
private var urls: [String]
private var filterStart: Int
private var filterEnd: Int
init() {
let path = NSBundle.mainBundle().pathForResource("urls", ofType: "txt")!
let content = String(contentsOfFile: path, encoding: NSUTF8StringEncoding, error: nil)!
urls = content.componentsSeparatedByString("\n")
filterStart = 0
filterEnd = urls.count - 1
}
func filteredURLCount() -> Int {
if filterStart == -1 {
return 0
}
return filterEnd - filterStart + 1
}
func filteredURLAtIndex(index: Int) -> String {
return urls[filterStart + index]
}
func binarySearchLast(array: [String], target: String) -> Int {
var left = 0
var right = array.count - 1
while (left <= right) {
let mid = (left + right) / 2
let value = array[mid]
if (left == right && value.hasPrefix(target)) {
return left
}
if value.hasPrefix(target) {
if mid < array.count - 1 {
if !array[mid + 1].hasPrefix(target) {
return mid
}
}
left = mid + 1
} else if (value < target) {
left = mid + 1
} else if (value > target) {
right = mid - 1
}
}
return -1
}
func binarySearchFirst(array: [String], target: String) -> Int {
var left = 0
var right = array.count - 1
while (left <= right) {
let mid = (left + right) / 2
let value = array[mid]
if (left == right && value.hasPrefix(target)) {
return left
}
if value.hasPrefix(target) {
if mid > 0 {
if !array[mid - 1].hasPrefix(target) {
return mid
}
}
right = mid - 1
} else if (value < target) {
left = mid + 1
} else if (value > target) {
right = mid - 1
}
}
return -1
}
func updateFilter(filter: String) {
if filter == "" {
filterStart = 0
filterEnd = urls.count - 1
return
}
filterStart = binarySearchFirst(urls, target: filter)
filterEnd = binarySearchLast(urls, target: filter)
}
}
Extra Credits
当自动匹配是根据前缀来实现的时候,二分查找非常适合.当你需要查找数组中是否有满足包含某个子字符串的时候,这种情况下二分查找就不适合了.
如果你对更高级的字符串匹配算法感兴趣, 也可以了解下面的:
如果你想做一个模糊匹配: