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