【数据结构与算法 - Swift实现】09 - 数据结构 堆 (Heap)

堆,其实是一个完整的二叉树(除了叶节点外,其他节点都有左右子节点)。堆有以下两种形式:

  • 最大堆:根部元素的值最大,越往下,值越小。例如
         10 
       /   \     
      7     9  
     / \   / \  
    6   4 5   3   
  • 最小堆:根部元素的值最小,越往下,值越大。例如:
         1 
       /   \     
      2     3  
     / \   / \  
    6   8 9   7   

实现

虽然堆本质上是属于二叉树,但是我们不实用节点来实现,而是使用数组。因为堆中的元素有一定的优先级顺序,可以用数组来存储元素;并且在元素的添加移除等过程中需要频繁访问特定的位置的元素,数组正适合我们的需求。

根据以上分析,我们首先定义 Heap 如下:

struct Heap<Element: Equatable> {
    private(set) var elements: [Element] = []
    private let order: (Element, Element) -> Bool
    
    init(order: @escaping (Element, Element) -> Bool) {
        self.order = order
    }
    
    var isEmpty: Bool {
        return elements.isEmpty
    }
    
    var count: Int {
        return elements.count
    }
    
    var peek: Element? {
        return elements.first
    }
}

extension Heap: CustomStringConvertible {
    var description: String {
        return elements.description
    }
}

考虑到堆有两种形式,所以定义了一个 order 闭包,后续的相关操作可以根据它来判断堆的类型。另外还添加了一些常用属性和实现了 CustomStringConvertible 协议。

数组如何表示堆

假设我们有以下堆数据结构:

        10       第一层
-----------------------
       /   \     
      7     9    第二层
-----------------------
     / \   / \  
    6   4 5   3  第三层
-----------------------
  /
 2               第四层
-----------------------

用数组可以表示如下:

索引     0      1     2     3     4     5     6     7
        +-------------------------------------------------+
        |  10  |  7  |  9  |  6  |  4  |  5  |  3  |  2   |
        +-------------------------------------------------+
        
        |第一层 |   第二层   |         第三层         |第四层 |
        |------|-----------|-----------------------|------|      

从上图我们可以总结出,如果给定一个元素的索引 i,那么:

  • 这个元素的左子节点索引为 2 * i + 1,这个元素的右子节点索引为 2 * i + 2
  • 这个的父节点索引为 (i - 1) / 2

所以,添加获取左右子节点和父节点索引的方法,如下:

func leftChildIndex(ofParentAt index: Int) -> Int {
    return 2 * index + 1
}

func rightChildIndex(ofParentAt index: Int) -> Int {
    return 2 * index + 2
}

func parentIndex(ofChildAt index: Int) -> Int {
    return (index - 1) / 2
}

移除元素

元素的移除分两种情况:1)移除根部元素;2)移除任意位置的元素。

移除根部元素

以下面的最大堆为例,移除10:

     +------+
     |  10  |  
     +------+    
       /   \     
      7     9   
     / \   / \  
    6   4 5   3
  /
 2               

为了移除这个根部元素,我们首先将根部元素与最后一个元素的位置调换,变成:

        +-----+
        |  2  |  
        +-----+    
         /   \     
        7     9   
       / \   / \  
      6   4 5   3
    /
+------+
|  10  |  
+------+    

调换成功后,就把最后一个元素移除,变成:

           2  
         /   \     
        7     9   
       / \   / \  
      6   4 5   3

这时需要检查当前的结构是否符合堆的特性。很明显,现在的根部元素是 2,小于它的子节点,所以我们把根部元素和它的左右子节点的较大者 9 位置调换,变成:

           9  
         /   \     
        7     2   
       / \   / \  
      6   4 5   3

再继续往下比较,这时2又比它的左右子节点小,继续把 2 和它的左右子节点的较大者位置 5 调换,变成:

           9  
         /   \     
        7     5   
       / \   / \  
      6   4 2   3

2 下面没有子节点了,所以这棵树最终调整完成,符合最大堆的要求。

实现代码如下:

extension Heap {
    mutating func removePeek() -> Element? {
        guard !isEmpty else { return nil }
        elements.swapAt(0, count - 1)
        defer {
            validate(from: 0)
        }
        return elements.removeLast()
    }
    
    private mutating func validateDown(from index: Int) {
        var parentIndex = index
        while true {
            let leftIndex = leftChildIndex(ofParentAt: parentIndex)
            let rightIndex = rightChildIndex(ofParentAt: parentIndex)
            var targetParentIndex = parentIndex
            
            if leftIndex < count &&
                order(elements[leftIndex], elements[targetParentIndex]) {
                targetParentIndex = leftIndex
            }
            
            if rightIndex < count &&
                order(elements[rightIndex], elements[targetParentIndex]) {
                targetParentIndex = rightIndex
            }
            
            if targetParentIndex == parentIndex {
                return
            }
            
            elements.swapAt(parentIndex, targetParentIndex)
            parentIndex = targetParentIndex
        }
    }
}

removePeek()

  • 首先判断堆是否为空,如果是直接返回 nil
  • 调换根元素和最后一个元素
  • 移除最后一个元素
  • validate(from: 0) 验证堆是否符合要求

validate(from index: Int),while 循环里面:

  • 首先得到左右子节点的索引
  • targetParentIndex 用于记录最终需要跟父节点索引调换的索引
  • 如果左子节点存在,并且左节点的值大于当前父节点的值,则更新 targetParentIndexleftIndex
  • 如果右子节点存在,并且右子节点的值大于当前父节点的值,则更新 targetParentIndexrightIndex
  • 如果 targetParentIndex 还是 parentIndex,意味着此时已经符合堆的特性,直接返回退出循环,否则替换 parentIndextargetParentIndex 对应的值,并且把 parentIndex 替换为 targetParentIndex,继续循环
移除任意元素

移除任意元素与移除根元素的思路类似,同样是把要移除的元素跟最后一个元素调换,让后在验证是否符合堆的特性。

实现代码如下:

mutating func remove(at index: Int) -> Element? {
    guard index < elements.count else { return nil }
    
    if index == elements.count - 1 {
        return elements.removeLast()
    } else {
        elements.swapAt(index, elements.count - 1)
        defer {
            validateDown(from: index)
            validateUp(from: index)
        }
        return elements.removeLast()
    }
}

private mutating func validateUp(from index: Int) {
    var childIndex = index
    var parentIndex = self.parentIndex(ofChildAt: childIndex)
    
    while childIndex > 0 &&
        order(elements[childIndex], elements[parentIndex]) {
            elements.swapAt(childIndex, parentIndex)
            childIndex = parentIndex
            parentIndex = self.parentIndex(ofChildAt: childIndex)
    }
}

remove(at index: Int)

  • 首先判断要移除的索引是否在数组范围内
  • 如果要移除的是最后一个元素,则直接移除
  • 如果是其他位置的元素,首先跟最后一个元素调换位置,接着移除最后一个元素
  • 最后从上下两个方向进行验证是否符合堆的特性

validateUp(from index: Int)

  • 因为是向上验证,所以传入的 index 是一个子节点的索引,首先获得 parentIndex
  • 在 while 循环的条件中,只有当子节点的索引存在,并且子节点的优先级高于父节点时,才需要调换子节点和父节点的值;在循环里面,先调换子节点和父节点的值,然后更新 childIndexparentIndex 的值,进入下一次循环

插入元素

有了上面的代码基础,插入元素就非常简单了,实现代码如下:

mutating func insert(_ element: Element) {
    elements.append(element)
    validateUp(from: elements.count - 1)
}

直接把插入的元素拼接到最后面,然后再向上验证堆的合法性就可以了。

搜索元素

实现代码如下:

extension Heap {
    func index(of element: Element,
               searchingFrom index: Int = 0) -> Int? {
        if index >= count {
            return nil
        }
        if order(element, elements[index]) {
            return nil
        }
        if element == elements[index] {
            return index
        }
        
        let leftIndex = leftChildIndex(ofParentAt: index)
        if let i = self.index(of: element,
                              searchingFrom: leftIndex) {
            return i
        }
        
        let rightIndex = rightChildIndex(ofParentAt: index)
        if let i = self.index(of: element,
                              searchingFrom: rightIndex) {
            return i
        }
        
        return nil
    }
}
  • 首先判断要查找的 index 是否在数组范围内,如果不在,直接返回 nil
  • 因为搜索的实现要用到递归,所以方法中有一个 searchingFrom 参数,这个意思是告诉方法从哪个位置开始往后搜索。所以我们用 order(element, elements[index]) 判断要查找的元素是否高于当前 index 对应元素的优先级,如果是,那么要查找的元素肯定不在后面,直接返回 nil
  • 如果当前 index 对应的值等于要查找的值,则返回 index
  • 从左分支用递归继续查找,如果找到则返回对饮的索引
  • 从右分支用递归继续查找,如果找到则返回对饮的索引
  • 以上步骤都没有找到,说明要查找的元素不存在,返回 nil

测试

堆的代码实现就完成了,我们来测试一下:

插入
var heap = Heap<Int>(order: >)
for i in 1...7 {
    heap.insert(i)
}
print(heap)

// 结果
[7, 4, 6, 1, 3, 2, 5]

对应的树结构为:

           7  
         /   \     
        4     6   
       / \   / \  
      1   3 2   5

符合堆的特性。

移除根部元素
while !heap.isEmpty {
    print(String(describing: heap.removePeek()))
}

// 结果
Optional(7)
Optional(6)
Optional(5)
Optional(4)
Optional(3)
Optional(2)
Optional(1)

从最大的元素删除到最小的元素,符合堆的特性。

移除任意元素
let index = heap.index(of: 3) ?? 0
print(String(describing: heap.remove(at: index)))
print(heap)

// 结果
Optional(3)
[7, 5, 6, 1, 4, 2]

移除元素3,并且移除后,依然符合堆的特性。

总结

堆数据结构非常适合用来跟踪最大值或者最小值,因为获取 peek 的时间复杂度为 O(1)。在搜索性能方面,时间复杂度为 O(n), 比二叉搜索树的 O(log n) 要差很多。另外堆的插入和移除的时间复杂度都为 O(log n)

完整代码 >>

参考资料

Data Structures and Algorithms in Swift --- raywenderlich.com,如果想看原版书籍,请点击链接购买。

欢迎加入我管理的Swift开发群:536353151

下一篇文章:【数据结构与算法 - Swift实现】10 - 优先队列 (Priority Queue)

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