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

之前文章的专题,更多的是与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循环的形式。

相关推荐
xiaoshiguang32 小时前
LeetCode:222.完全二叉树节点的数量
算法·leetcode
爱吃西瓜的小菜鸡2 小时前
【C语言】判断回文
c语言·学习·算法
别NULL2 小时前
机试题——疯长的草
数据结构·c++·算法
TT哇2 小时前
*【每日一题 提高题】[蓝桥杯 2022 国 A] 选素数
java·算法·蓝桥杯
ZSYP-S4 小时前
Day 15:Spring 框架基础
java·开发语言·数据结构·后端·spring
yuanbenshidiaos4 小时前
C++----------函数的调用机制
java·c++·算法
唐叔在学习4 小时前
【唐叔学算法】第21天:超越比较-计数排序、桶排序与基数排序的Java实践及性能剖析
数据结构·算法·排序算法
ALISHENGYA4 小时前
全国青少年信息学奥林匹克竞赛(信奥赛)备考实战之分支结构(switch语句)
数据结构·算法
chengooooooo4 小时前
代码随想录训练营第二十七天| 贪心理论基础 455.分发饼干 376. 摆动序列 53. 最大子序和
算法·leetcode·职场和发展
jackiendsc4 小时前
Java的垃圾回收机制介绍、工作原理、算法及分析调优
java·开发语言·算法