数据结构与算法 -- 基本排序算法

之前文章的专题,更多的是与Android开发相关的,所以我将其称为【Android进阶宝典】,那么从这一篇开始,我将会开启一个新的专题【数据结构与算法】,将会介绍与数据结构和算法相关的内容,包括leetcode和剑指offer中的经典例题讲解。

1 时间复杂度的认识

对于一个程序来说,时间复杂度代表这个程序的性能好坏,一开始人们将程序执行的时间长短作为时间复杂度的标准,但是由于不同的机器,相对应的性能也有好坏之分,并不能由此决定时间复杂度。后来采用 Big O表达式来,一直沿用至今。

何为Big O表达式,即:

java 复制代码
时间复杂度 = O(f(n))

f(n)为每行代码执行的次数之和,例如有一个数组arr[10],从中取出第5个元素,即arr[4],因为数组是连续的,只需要做一次寻址即可拿到数据,因此时间复杂度为O(1);但如果是从一个链表中取出第5个元素,因为链表不是连续的,需要从头开始遍历,当遍历到第5个元素的时候,才能拿到值,因此时间复杂度为O(5)。

所以什么情况下会导致时间复杂度升高呢?循环、递归。循环我们有时候迫不得已必须要使用,因此在一些算法场景中,我们常常会要求不使用递归,原因就在这儿。

1.1 时间复杂度计算

这里我们拿选择排序算法为例,这里我不写代码,只讲思路,目的还是为了突出算法时间复杂度的计算。

有一组数arr,顺序被打乱了,这个时候如果使用插入排序算法,思想就是:从第0个位置开始,查找从 0 到 arr.length-1中最小的数,与第0个位置的数字做交换,此时

java 复制代码
取出数据:N次
比较数据:N次
交换数据:1次

然后,从第1个位置开始,做同样的操作,拿到最小的数据与第1个位置数字交换,此时

java 复制代码
取出数据:N - 1 次
比较数据:N - 1 次
交换数据:1次

等所有的数字遍历到结尾,总共

java 复制代码
取出数据:N + N -1 + N - 2 + ... 次
比较数据:N + N -1 + N - 2 + ... 次
交换数据:N - 1 次

所以如果按照算法时间复杂度的计算方式,就是将取出数据的次数 + 比较数据的次数 + 交换数据的次数,总共求和肯定是下面这个表达式:

java 复制代码
f(n) = aN² + bN + c

那么对于时间复杂度的定义,低阶次方不需要考虑,最高阶次方的系数不需要考虑,因此选择排序的算法时间复杂度是O(N²)

1.2 评价算法好坏的指标

评价一个算法的好坏,当然时间复杂度是第一要务,但是如果时间复杂度都是N,那么这个时候就需要在不同数据样本的场景下,分析运行时间的长短,以此来决定算法的好坏,例如

kotlin 复制代码
fun testBigO1() {
    val startTime = System.currentTimeMillis()
    var a = 0;
    for (index in 0 until 1000) {
        a = 5 * 7
        a = 2 + 10
        a = 3 * 776
    }
    val endTime = System.currentTimeMillis()
    Log.e("TAG", "testBigO1 cost ${endTime - startTime}")
}

fun testBigO2() {
    val startTime = System.currentTimeMillis()
    var a = 0;
    for (index in 0 until 1000) {
        a = 5 or 7
        a = 2 and 10
        a = 3 and 776
    }
    val endTime = System.currentTimeMillis()
    Log.e("TAG", "testBigO2 cost ${endTime - startTime}")
}

例如这里有两个算法,时间复杂度均为O(1000),但是在循环代码块中,一个方法采用普通的计算方式,另一个方法采用了位运算,这个时候时间复杂度是一样的了,那么就只能看两个方法的实际运算效率。

java 复制代码
testBigO1 cost 74
testBigO2 cost 70

其实位运算的运算效率是要比普通计算效率要高的,尤其是大数据量的场景下优势更能体现。

1.3 空间复杂度

什么是空间复杂度呢?就是算法在运行的时候,不需要开辟任何额外的空间,例如创建一个数组等等,那么此时的空间复杂度就是O(1);但是如果要创建一个新的数组,例如创建一个与原数组等长的新数组,那么空间复杂度就是O(n)。

首先我们先根据1.1小节选择排序的思想,写出这个简单算法。

kotlin 复制代码
object SelectSort {

    /**
     * 选择排序
     * @param intArray 需要进行排序的数组
     */
    fun start(intArray: Array<Int>?): Array<Int>? {
        if (intArray == null || intArray.size < 2) {
            return intArray
        }

        //从第0个位置开始,第一轮选出最小的一个数字,安排在首位
        for (index in 0 until intArray.size - 1) {
            var minIndex = index
            for (nextIndex in index + 1 until intArray.size) {
                minIndex = if (intArray[minIndex] > intArray[nextIndex]) nextIndex else minIndex
            }
            //找到了最小的元素位置
            swapMinIndex(index, minIndex, intArray)
        }
        return intArray
    }

    /**
     * 交互顺序,将最小的元素往前移动
     * @param index 当前数组遍历的起始位置
     * @param minIndex 当前数组中最小元素的位置
     */
    private fun swapMinIndex(index: Int, minIndex: Int, intArray: Array<Int>) {
        val startNum = intArray[index]
        intArray[index] = intArray[minIndex]
        intArray[minIndex] = startNum
    }

}

我们看下这个算法,我们开辟了多少空间,首先在for循环中,创建一个index变量、minIndex变量、nextIndex变量,但是这些都是临时变量,在程序结束之后都会被释放,所以整个算法的空间复杂度就可以看做是O(1)。

具体什么样的算法空间复杂度会是O(n)或者是其他,我们在写算法的过程中,会给伙伴们介绍。

2 简单排序算法思想介绍以及coding

从第二小节开始,我们开始介绍一些简单的排序算法,关注一下他们的时间复杂度以及空间复杂度

2.1 冒泡排序算法

这个算法应该是最常见也是最基本的一个算法了,从我们一开始接触算法这个就是敲门砖,在一些面试中也经常会让我们写了冒泡排序算法。

其实冒泡的思想也很简单,跟选择排序不同的是,冒泡排序会交换相邻两个位置的元素,把最大的元素往后推。

这个图比较简陋,但是思想就是在第一轮的时候,从第0个位置开始跟第1个位置比较,因为 9 > 6,所以需要交换,第1个位置换为6,紧接着跟第2个位置比较,因为 9 > 1,所以也需要交换,以此类推,最终9被放在了最后一个位置。

kotlin 复制代码
object PopSort {

    fun start(array: Array<Int>?): Array<Int>? {
        if (array == null || array.size < 2) {
            return array
        }

        for (index in 0 until array.size - 1) {
            for (nextIndex in index + 1 until array.size) {
                //取出第一个元素,与下一个元素作比较
                if (array[index] > array[nextIndex]) {
                    //需要交换
                    swap(index, nextIndex, array)
                }
            }
        }
        return array
    }

    /**
     * 交换位置
     * @param index 数组当前元素位置
     * @param nextIndex 数组当中下一个元素的位置
     */
    private fun swap(index: Int, nextIndex: Int, array: Array<Int>) {
        val startNum = array[index]
        array[index] = array[nextIndex]
        array[nextIndex] = startNum
    }
}

所以从算法中我们也就知道,时间复杂度为O(N²),空间复杂度为O(1)

2.2 插入排序算法

插入排序算法的思想是从左向右,从最小的颗粒度上保证区间内数字的有序的,从而变为整体有序的数组,见下图

以此类推,只要保证从 0-N 的小区间有序,通过指针不断往前比较,只要指针前没有数字或者指针前有序,那么此次小区间交换就算完成了,从而使得整个区间变得有序。

kotlin 复制代码
object InsertSort {

    fun start(array: Array<Int>?): Array<Int>? {

        if (array == null || array.size < 2) {
            return array
        }

        //外层控制从0-N的值,例如从0-1,0-2,0-3
        for (index in 1 until array.size) {
            //指针位置
            var pointerIndex = index
            //内存控制小区间范围
            for (index2 in index downTo 0) {
                val pointerValue = array[pointerIndex]
                //与前面的数字比较
                if (pointerIndex > 0 && array[pointerIndex - 1] > pointerValue) {
                    //需要交换
                    swap(pointerIndex, pointerIndex - 1, array)
                    pointerIndex -= 1
                } else {
                    break
                }
            }

        }

        return array
    }

    /**
     * 交换位置
     * @param index 数组当前元素位置
     * @param nextIndex 数组当中下一个元素的位置
     */
    private fun swap(index: Int, nextIndex: Int, array: Array<Int>) {
        val startNum = array[index]
        array[index] = array[nextIndex]
        array[nextIndex] = startNum
    }
}

这里我们完全按照图中的思想,每次外层循环都会重置指针的pointerIndex,每次都是小区间的最大Index,然后内层就是控制循环的次数,如果发现指针前面的数字比当前的要大,那么就交换,然后指针位置--,能够进行交换的条件就是:指针前面需要有数字,即pointerIndex > 0,而且需要比前面的数字要小才可以

但是插入排序的时间复杂度跟前面两种算法:冒泡排序和选择排序 不同,前面两种都是需要轮一遍数组,取出某个值进行交换,但是插入排序会因为数据样本的不同而有不同的时间复杂度。

例如数组为:7654321,这种数组属于完全倒序的,那么这种情况下,其实每次都需要轮一遍数组,此时O(n) = N²;但是如果数组为:1234567,那么这种完全顺序的数组,其实只需要取出数据比较一下即可,此时O(n) = N,但是对于时间复杂度来说,需要取最差的,那么插入排序的算法时间复杂度还是O(n) = N²,空间复杂度为O(1)

3 二分查找法

如果想要在一个有序数组中查找某个值是否存在,并获取在数组当中的位置,记住前提是有序数组,那么用二分法是一个明智的选择。当然我们可以使用暴力算法遍历这个数组,那么此时的时间复杂度为O(n)= N,那么用二分法呢,先把代码实现一下。

kotlin 复制代码
object BinarySort {


    fun start(startIndex: Int, endIndex: Int, array: Array<Int>?, num: Int): Int {

        if (array == null || array.isEmpty()) {
            return -1
        }

        //先拿到中间位置的元素
        val centerIndex = (startIndex + endIndex) / 2

        //如果目标值比中间值大,那么就从右侧开始继续二分
        if (num > array[centerIndex]) {
            return start(centerIndex + 1, endIndex, array, num)
        } else if (num < array[centerIndex]) {
            return start(startIndex, centerIndex - 1, array, num)
        } else {
            return centerIndex
        }
    }
}

这是采用递归的形式,从中间开始向两边查找,假设数组长度为8,那么从中间劈一刀,长度为4,再从中间劈一刀,长度为2,在劈一刀,长度为1,那么最多就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> l o g 2 8 log_28 </math>log28,也可以认为是O(n)= <math xmlns="http://www.w3.org/1998/Math/MathML"> l o g N logN </math>logN.

那么如果不使用递归呢?

kotlin 复制代码
fun start2(array: Array<Int>?, num: Int): Int {

    if (array == null || array.isEmpty()) {
        return -1
    }
    var startIndex = 0
    var endIndex = array.size - 1

    while (startIndex <= endIndex) {
        //先拿到中间位置的元素
        val centerIndex = (startIndex + endIndex) / 2
        //如果目标值比中间值大,那么就从右侧开始继续二分
        if (num > array[centerIndex]) {
            startIndex = centerIndex + 1
        } else if (num < array[centerIndex]) {
            endIndex = centerIndex - 1
        } else {
            return centerIndex
        }
    }
    return -1
}

其实跟使用递归的思想基本一致,采用while循环的形式。

相关推荐
是苏浙20 小时前
零基础入门C语言之C语言实现数据结构之单链表经典算法
c语言·开发语言·数据结构·算法
橘颂TA20 小时前
【剑斩OFFER】算法的暴力美学——点名
数据结构·算法·leetcode·c/c++
迷途之人不知返21 小时前
数据结构之,栈与队列
数据结构
MATLAB代码顾问1 天前
多种时间序列预测算法的MATLAB实现
开发语言·算法·matlab
高山上有一只小老虎1 天前
字符串字符匹配
java·算法
愚润求学1 天前
【动态规划】专题完结,题单汇总
算法·leetcode·动态规划
MOONICK1 天前
数据结构——哈希表
数据结构·哈希算法·散列表
林太白1 天前
跟着TRAE SOLO学习两大搜索
前端·算法
ghie90901 天前
图像去雾算法详解与MATLAB实现
开发语言·算法·matlab
云泽8081 天前
从三路快排到内省排序:探索工业级排序算法的演进
算法·排序算法