动画图解:九大经典排序算法详解(算法宝App)

重新整理了一遍排序算法,结合自己开发的算法宝App的录屏,转成webp动画一起分享给大家,适合新手入门。

概述

时间复杂度(time complexity)
用来描述算法的运行时间。常用大O符号表述。比如:O(n),O(1),O(logn),O(n2)等。举例:
O(n)表示线性级复杂度,表示时间复杂度和元素element数量n成正比。比如数组的线性查找的复杂度随着元素数量增加而增加。
O(1)表示常数级复杂度,表示时间复杂度不随元素element数量变化而变化。比如链表的插入的复杂度不随链表节点数量变化而变化。
其他常见的复杂度如下图所示:
在这里插入图片描述
空间复杂度(Space Complexity)
对一个算法在运行过程中临时占用存储空间大小的量度。也可用大O符号表述。举例:
O(n)表示线性级复杂度,表示算法所需空间大小和元素数量成正比,比如归并排序,需要额外的临时空间来保存两个数组的合并结果,元素越多所需空间越大。
O(n)表示常数级复杂度,表示算法所需空间大小和元素数量无关。

排序的稳定性

  • 稳定:相等的两个数,排序前后两数的顺序保持不变。
  • 不稳定:相等的两个数,排序前后两数的顺序发生变化。

排序的复杂度一览

算法时间复杂度:最好时间复杂度:平均时间复杂度:最坏空间复杂度:最坏稳定性
冒泡排序O(n)O(n2)O(n2)O(1)稳定
选择排序O(n2)O(n2)O(n2)O(1)不稳定
插入排序O(n)O(n2)O(n2)O(1)稳定
希尔排序O(n)O(n3/2)O(n2)O(1)不稳定
快速排序O(nlogn)O(nlogn)O(n2)O(logn)不稳定
归并排序O(nlogn)O(nlogn)O(nlogn)O(n)稳定
计数排序O(n+k)O(n+k)O(n+k)O(k)稳定
基数排序O(nk)O(nk)O(nk)O(n+k)稳定
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)不稳定

冒泡排序(Bubble Sort)

动图
在这里插入图片描述
核心思路
将数组分为左右两部分,左为无序部分,右为有序部分。无序部分中的最大数在每次遍历结束后被交换到无序部分的最右侧,继而成为有序部分的最左侧元素,就像冒泡一样。
代码

func sort(_ a:inout Array<Int>){
    let n = a.count
    for i in 0..<n-1 {
        var isSorted = true
        for j in 0..<n-1-i {
            if a[j] > a[j+1] {
                a.swapAt(j, j+1)
                isSorted = false
            }
        }
        if isSorted {
            break
        }
    }
}

代码讲解
使用双重循来遍历,把双重循环分为内循环和外循环。
内循环:
从左往右处理无序部分,使用下标 j 遍历,比较相邻元素大小,交换位置成为左小右大,一次遍历后,无序部分中最大数被交换到无序部分的最右处,继而加入有序部分的最左侧。
外循环:
i可理解为有序部分的数量,每次内循环结束,有序部分数量增加一个,无序部分数量减少一个。
特点
两两比较,不存在跳跃,所以稳定
每次遍历都能检查数组是否有序,可提前退出排序,但是冒泡的交换在内循环,交换次数多。实际效率比选择排序低。
复杂度分析
最好的情况可以达到O(n),最坏的情况是O(n2),平均O(n2)

选择排序(Selection Sort)

动图
在这里插入图片描述
核心思路
将数组分为左右两部分,左为有序部分,右为无序部分。无序部分每次遍历选择出一个最小的元素,交换到无序部分的最左侧,继而成为有序部分的最右侧元素。
代码

func sort(_ a:inout Array<Int>){
    let n = a.count
    for i in 0..<n-1 {
        var iMin = i
        for j in i+1..<n {
            if a[iMin] > a[j] {
                iMin = j
            }
        }
        a.swapAt(i, iMin)
    }
}

代码讲解
使用双重循来遍历,把双重循环分为内循环和外循环。
内循环:
从左往右处理无序部分,使用下标 j 遍历,每次遍历后保存下无序部分中最小数的位置iMin。
外循环:
i为无序部分的首元素位置,其左侧为有序部分,将有序部分排除在内循环外。每次内循环结束,将内循环保存的iMin和i位置的连个元素交换,使有序部分数量增加一个,无序部分数量减少一个。
特点
由于交换动作放在外循环,交换次数少于冒泡,实际效率优于冒泡。除非本来就是有序的数组的最好情况,选择排序还是要进行比较和交换,而冒泡排序一次n的遍历就能提前退出。
不稳定,举个例子,序列5 8 5 2 9, 我们知道第一遍选择第1个元素5会和2交换,那么原序列中2个5的相对前后顺序就被破坏了)
复杂度分析
最好情况O(n2),最坏情况O(n2),平均O(n^2)

插入排序(Insertion Sort)

动图
在这里插入图片描述
核心思路
将数组分为左右两部分,左为有序部分,右为无序部分。遍历有序部分,将无序部分首元素,根据其大小在有序部分寻找合适的位置并插入。
代码

func sort(_ a:inout Array<Int>){
    let n = a.count
    for i in 1..<n {
        print(" i=\(i)")
        let value = a[i]
        var hole = i
        while hole > 0 && a[hole-1] > value {
            a[hole] = a[hole-1]
            hole -= 1
        }
        a[hole] = value
    }
}

代码讲解
使用双重循来遍历,把双重循环分为内循环和外循环。
内循环:
每次从无序部分的首元素value的位置开始,从右向左,在有序部分中遍历,比较每一个元素,凡是比value大的元素,都向右移动一位,遍历结束后空出来的位置hole就是value的插入位置。
外循环:
i可理解为有序部分的数量,同时也是无序部分的首元素的位置。每次外循环value都作为无序部分首元素,需通过内循环在有序部分寻找一个合适的位置hole并将其插入。
特点
选择排序的比较开销是固定的n(n-1)/2,而插入排序平均下来是n(n-1)/4。
选择排序最多只需要执行2(n-1)次交换,而插入排序平均的交换开销也是n(n-1)/4。这就取决于单次比较和交换的开销之比。如果是一个量级的,则插入排序优于选择排序,如果交换开销远大于插入开销,则插入排序可能比选择排序慢。
两两比较,不存在跳跃,所以稳定
复杂度分析
最好情况O(n),最坏情况O(n2),平均O(n2)

希尔排序(Shell Sort)

动图
在这里插入图片描述
核心思路
在插入排序基础上引入增量gap概念,是插入排序的改进。
代码

func sort(_ a:inout Array<Int>){
    let n = a.count
    var gap = n / 2
    while gap > 0 {
        for i in gap..<n {
            let value = a[i]
            var hole = i
            while hole > 0 && a[hole-1] > value {
                a[hole] = a[hole-1]
                hole -= 1
            }
            a[hole] = value
        }
        gap /= 2
    }
}

代码讲解
当增量gap为半数时,在整个数组中选取右边1/2部分进行插入排序,在此结果上在整个数组中选取右边3/4部分进行插入排序,反复这个过程,直到最后一次对整个数组做插入排序,最终成为有序数组。
每次做插入排序的部分从gap开始,每次扩大做插入排序的范围。
特点
希尔排序算法1959年提出,是直接插入排序算法的一种改进,减少了移动次数,平均时间复杂度比插入快。是第一批时间复杂度低于O(n2)的排序算法
插入排序是稳定的,而shell排序会分组,相同的数分在不同的组内各自进行移动打破稳定性。所以不稳定
复杂度分析
gap /= 2表示折半的方式选取增量。究竟应该选取什么样的增量才是最好,目前还有数学上的定论。最坏的情况是O(n2),在使用了增量后,平均时间复杂度O(n(3/2))

快速排序(Quick Sort)

动图
在这里插入图片描述
核心思路
将数组最右侧的元素作为一个参照值pivot,以参照值为标准,将数组拆分patition为3个部分:比参照值小的部分,参照值,比参照值大的部分。
将这个过程再分别应用到较小部分和较大部分中继续拆分,直到所有部分被拆分成1个元素无法再拆分,就完成了排序。
这是一个分而治之的方法,也是一个递归的过程。
代码

func sort(_ a:inout Array<Int>, start: Int, end: Int){
    if start < end {
        let partitionIndex = partition(&a, start: start, end: end)
        sort(&a, start: start, end: partitionIndex-1 )
        sort(&a, start: partitionIndex+1, end: end )
    }
}
    
func partition(_ a:inout Array<Int>, start: Int, end: Int) -> Int {
    let pivot = a[end]
    var partitionIndex = start
    for i in start..<end {
        if a[i] <= pivot {
            a.swapAt(i, partitionIndex)
            partitionIndex += 1
        }
    }
    a.swapAt(partitionIndex, end)
    return partitionIndex
}

代码讲解
每次递归将数组拆分成前部,基准值,后部3部分,前部比后部小。按此方法再递归调用前,后部分,最终达到从小到大的排序。
partition()拆分
将数组a的start到end区间拆分为三部分。区间内选择一个参照值,通常可选start或者end的值为参照。因为区间拆分前无序,任何一个值都可作为参照,这里我们选择end的值作为参照值pivot。
因为pivot作为end,所以区间遍历使用i从start到end-1,并假设参照值位置为pt初始为start。将比参照值小的元素交换到pt,pt的右侧位置就是参照值的位置,所以pt+=1,遍历结束,将参照值交换到pt,并返回参照值的位置。
sort()递归分治
调用partition()拆分数组,返回拆分后参照值位置pt,那么数组前部分的区间是start到pt-1,后部分的区间为pt+1到end。对着两部分继续递归调用sort()。当数组被拆分为1个元素,即start等于end的时候,则停止分治,退出递归。
特点
快速排序是一种交换类的排序,它同样是分治法的经典体现。
如果pivot的值有重复,pivot作为参照放在前后两部中间有可能打破稳定性,所以不稳定
复杂度分析
待排序数组最终被拆分成深度为logn+1的二叉树。拆分次数为logn。第一次拆分时,总共的执行时间是Cn(C为固定的单位时间常数);第二次拆分时,每个子序列的执行时间为Cn/2,总的执行时间是Cn/2+Cn/2=Cn;第三次拆分时,总时间时Cn/4+Cn/4+Cn/4+Cn/4+=Cn,所以每次执行复杂度都是Cn。
每次执行复杂度Cn * 拆分次数log(n) = 快排复杂度O(nlogn)。最坏情况O(n2)

归并排序(Merge Sort)

动图
在这里插入图片描述
核心思路
将两个有序数组,合并为一个新的有序数组就是做归并。
归并排序中,将数组从中间分解为左右两部分,将这个过程再应用到左右部分中继续分解。最后把n个元素的数组分解为只有1个元素的n个数组。这种情况下,满足两个有序数组进行的归并条件,开始再两两归并,直到合并出一个新的有序数组就完成了排序。
这是一个分而治之的方法,同时也是一个递归的过程。
代码

func sort(a: [Int]) -> [Int] {
    let n = a.count
    if n == 1 {
        return a
    }
    var left = Array(a[0..<n/2])
    var right = Array(a[n/2..<n])
    left = sort(a: left)
    right = sort(a: right)
    let m = merge(left: left, right: right)
    return m
}

func merge(left: [Int], right: [Int]) -> [Int] {
    var mergedList = [Int]()
    while left.count > 0 && right.count > 0 {
        if left.first! < right.first! {
            mergedList.append(left.first!)
            left.removeFirst()
        } else {
            mergedList.append(right.first!)
            r.removeFirst()
        }
    }
    return mergedList + left + right
}

代码讲解
sort()将数组a从中间分为left和right两部分。再将left和right再递归调用sort继续分解。将分解的结果传入merge()进行归并。
merge()合并left和right两个有序数组,返回合并后的有序数组。将left和right两个数组同时开始遍历,对比两个数组的首元素,将较小的元素填入mergedList,并在原数组中删除。当遍历结束后,left或right还不为空,说明该数组元素都大于mergedList,加入在mergedList尾部即可。
特点
该算法采用经典的分治(divide-and-conquer)策略。分治法将问题分(divide)成一些小的问题然后递归求解。两两比较,不存在跳跃,所以稳定
复杂度分析
归并排序的效率是比较高的,设数列长为n,将数列分开成小数列一共要logn步,每步都是一个合并有序数列的过程,时间复杂度可以记为O(n),故一共为O(nlogn)。对空间有要求,空间复杂度O(n)。所以,归并排序是一种比较占用内存,但却效率高且稳定的算法。

计数排序(Counting Sort)

动图
在这里插入图片描述
核心思路
通过前面元素出现的累计次数确定当前元素的位置,比如第一个元素1出现3次,那么元素2的位置从第4个开始,元素2出现4次,那么元素3的位置从第8个开始。
代码

func sort(_ a:inout Array<Int>) {
    let max = a.max() ?? 0
    let min = a.min() ?? 0
    let k = max - min + 1
    var counts = [Int](repeating: 0, count: k)
    for item in a {
        let i =  item - min
        counts[i] += 1
    }
    var prefixSums: [Int] = counts
    for i in 1..<counts.count {
        prefixSums[i] = prefixSums[i - 1] + counts[i]
    }
    var sorted = [Int](repeating: 0, count: a.count)
    for i in 0..<a.count {
        let key = a[i]
        let index = key - min
        let prefixSum = prefixSums[index]
        sorted[prefixSum - 1] = key
        prefixSums[index] -= 1
    }
    a = sorted
}

代码讲解
在无序数组中,先找出最大值max和最小值min,从而确定需要开辟max-min+1大小空间的计数数组和累计数组。
计数数组:
保存每个元素出现的次数。比如1出现2次,2出现3次。
累计数组:
保存前面元素累计出现的次数。比如,1出现3次,累计3次;2出现4次,累计3+4=7次;3出现1次,累计7+1=8次。累计次数-1就是元素有序的位置。
特点
计数排序是一个稳定的非比较排序算法。它的优势在于在对一定范围内的整数排序,比如对1万名学生的考试分数做排序。计数排序是一种以空间换时间的排序算法,整数大小差异越大,所需要的空间越大。
复杂度分析
计数数组的大小为k,无序数组大小为n,复杂度为Ο(n+k),所以时间复杂度是O(n);由于申请了大小为k的桶来放计数,所以空间复杂度是O(k)

基数排序(Radix Sort)

动图
在这里插入图片描述
核心思路
是一种非比较的整数排序算法。其原理是将整数按位数切割成不同的数字,然后对每个位数上的数字进行分别比较。第一轮先按个位数大小排序,第二轮按十位数大小排序,一直进行到最高位。实际上是也一种桶排序,从0到9一共分10个桶。
代码

func sort(_ a:inout Array<Int>){
    let maxValue = a.max() ?? 0
    var buckets = [[Int]](repeating: [], count: 10)
    var powerOfTen = 1
    while maxValue / powerOfTen > 0 {
        a = a.compactMap { value in
            buckets[value / powerOfTen % 10].append(value)
            return nil
        }
        buckets = buckets.map { bucket in
            bucket.forEach { value in
                a.append(value)
            }
            return []
        }
        powerOfTen *= 10
    }
}

代码讲解
先找到最大数,算出最大数有几位,则进行几次桶排序。
powerOfTen初始为1,每次while循环则乘以10倍,这样while循环的次数就是最大数的位数。
10个桶,当进行个位数比较的时候,桶n保存个位为n的元素,当进行十位数比较的时候,桶n保存十位为n的元素。
特点
是一种非比较的稳定的整数排序算法。
不适用于数字的位数k多,但是排序的数少的情况;
适用于数字小但是排序的数字多的情况。
复杂度分析
复杂度是O(n*k)。其中n是排序元素个数,每轮处理的操作数目;k是数字位数,决定了进行多少轮处理。
并不一定优于O(n·logn),当k>logn,就没有归并、堆排序快。

堆排序(Heap Sort)

相关概念:
完全二叉树:其每个结点的编号跟满二叉树都能一一对应。
堆:是一个完全二叉树,其每个结点的值都大于等于其子结点的值称为大顶堆。
满二叉树:所有分支结点都有左右子结点,所有子结点都在同一层上。
动图
在这里插入图片描述
核心思路
大顶堆的顶是最大的,所以堆排序的过程就是反复的构造堆
第一次构建的堆顶最大,和堆尾交换放在数组最后一位,第二次构建的堆的堆顶第二大,放在倒数第二位,以此类推进行排序。
代码

func sort(_ a:inout Array<Int>){
	// 构建初始堆
    createHeap(a: &a)
    // 重建堆。逆序遍历反复构建大顶堆,遍历一次,顶被摘掉放入结果数组a尾部,直到最后无法构建堆,结果就是有序数组a
    for index in a.indices.reversed() {
        siftDown(from: 0, upTo: index, a: &a)
    }
}

func createHeap(a:inout Array<Int>) {
    if !a.isEmpty {
        //从a.count/2 - 1开始到0结束,逆序遍历。a.count/2 - 1是第一个非叶子节点,向根部遍历
        for i in stride(from: a.count/2 - 1, through: 0, by: -1) {
            siftDown(from: i, upTo: a.count, a: &a)
        }
    }
}

func siftDown(from index: Int, upTo size: Int, a:inout Array<Int>) {
    var parent = index
    while true {
        let left = a[leftChildIndex(ofParentAt: parent)]
        let right = a[rightChildIndex(ofParentAt: parent)]
        var candidate = parent
        if left < size && a[left] > a[candidate] {
            candidate = left
        }
        if right < size && a[right] > a[candidate] {
            candidate = right
        }
        if candidate == parent {
            return
        }
        a.swapAt(parent, candidate)
        parent = candidate
    }
}
// 在树的顺序存储中,返回i的左子节点的下标
func leftChildIndex(ofParentAt i: Int) -> Int {
    return (2 * i) + 1
}
// 在树的顺序存储中,返回i的右子节点的下标
func rightChildIndex(ofParentAt i: Int) -> Int {
    return (2 * i) + 2
}

代码讲解
先构建堆,可以是大顶堆(也可以是小顶堆)。交换堆顶[0]元素(堆中最大)与末尾[index]元素,再将[0,–index]的堆调整为大顶堆。重复到index为0。
特点
堆尾如果有重复,被交换到数组首再构建堆,会打破稳定性,所以不稳定(记录的比较和交换是跳跃式进行的)。
复杂度分析
整体主要由构建初始堆重建堆两部分组成。其中构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次;而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)…1]逐步递减,近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)级。,空间复杂度是O(1)

  • 33
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
朴素贝叶斯算法是一种基于贝叶斯定理的分类算法,它假设特征之间相互独立。这个假设使得算法具有较快的训练和预测速度,并且在处理大规模数据集时表现良好。 下面我将用图解的方式详细介绍朴素贝叶斯算法的原理和步骤。 首先,我们需要准备一个分类任务的数据集。假设我们要构建一个垃圾邮件分类器,数据集包含了一些已经标记好的邮件样本,每个邮件样本都有一些特征(如邮件内容、发件人等)和对应的标签(垃圾邮件/非垃圾邮件)。 第一步是计算先验概率。先验概率指的是在没有任何特征信息的情况下,某个样本属于某个类别的概率。在我们的例子中,就是计算垃圾邮件和非垃圾邮件出现的概率。 第二步是计算条件概率。条件概率指的是在已知某个特征条件下,某个样本属于某个类别的概率。对于朴素贝叶斯算法来说,我们假设所有特征之间相互独立,因此可以将条件概率拆分为各个特征的概率乘积。我们需要计算每个特征在每个类别下的概率。 第三步是应用贝叶斯定理。贝叶斯定理可以根据已知的特征计算某个样本属于某个类别的后验概率。后验概率越大,就说明该样本属于该类别的可能性越大。 最后,我们可以根据后验概率进行分类预测。选择具有最大后验概率的类别作为预测结果。 总结一下,朴素贝叶斯算法通过计算先验概率、条件概率和应用贝叶斯定理,实现了对样本的分类预测。它的优势在于简单、快速,并且在一些特定的数据集上表现出色。然而,它的假设可能不符合实际情况,所以在实际应用中需要考虑和验证数据的特性和假设的合理性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值