目录
【复杂度分析】
【降低时间复杂度】
降低时间复杂度的必要性
【每日一练】
不管是使用什么编程语言或者哪种数据库,不管是解决项目中的什么问题,都离不开数据结构与算法。所谓数据结构就是指某一种数据的存储结构,所谓算法就是操作这种数据的方式方法。举个生活中的例子,比如查字典,字典中的所有内容就可以理解为是一种数据结构,而如何找到想要的字就需要算法来处理。比如软件开发中常用的MySQL、Redis以及各类编程语言中都用到了大量的数据结构和算法。熟悉各类数据结构存储和算法能够编写出高效的程序代码。
作为开发人员,一般比较常用的数据结构有:数组、链表、堆、栈、队列、树、跳表、图;常用的算法有:递归、排序、查找、贪心算法、哈希算法、分治算法、深度优先、广度优先、动态规划等。
线性表:如果一组数据结构中的数据排成像一条线一样,那么就是线性表,每个线性表上的数据最多只有前和后两个方向(指针)。数组、链表、队列、栈 都是线性表结构。
非线性表:数据之间并不是简单的前后关系,二叉树、堆、图 都是非线性表结构。
【复杂度分析】
复杂度分为“时间复杂度”和“空间复杂度”,主要用来估算某种数据结构或者算法的执行效率,表示的是一个算法执行效率与数据规模增长的变化趋势,一般使用大O来表示。常见的复杂度量级如下(按照数量级递增):
复杂度表示 | 说明 |
O(1) | 常数级别 |
O(log(n)) | 对数级别 |
O(n) | 线性级别 |
O(n * log(n)) | 线程对数级别 |
O(n^2)、O(n^3)、O(n^k) | 平方级别、立方级别、k次方级别 |
O(2^n) | 指数级别 |
O(n!) | 阶乘级别 |
复杂度的细节说明:
- O(1) :是常量级时间复杂度的表示方法,并不是只执行了1行代码;如果某段代码执行了4行,那么它的时间复杂度也是 O(1),而不是 O(4)。只要程序算法中没有循环语句和递归语句,即使有上万行代码,那么时间复杂度也是Ο(1)。只要是顺序结构的代码,时间复杂度基本都是O(1)。
- O(logn) 和 O(n * logn):对数级别,一般会忽略对数的底数,不管是O(log2n) 还是 O(log3n) 都统一表示为O(log(n))。如果对O(log(n))的循环执行 n 遍,时间复杂度就是 O(n * log(n)) ,归并排序 和 快速排序 的时间复杂度就是 O(n * log(n)),一般简写为 O(nlogn)。一般出现二分查找或者分而治之策略的时候,时间复杂度基本都是O(logn)。
- O(m+n) 和 O(m*n):这种情况的复杂度由m和n两个数据的规模来决定。
- 关于循环:只关注循环执行次数最多的一段代码,一般会忽略掉常量、低阶、系数,只需要记录一个最大阶的量级就可以了,比如O(n * 2)和O(n)是一样的。如果是k层for循环,那么时间复杂度就是O(n^k)。
- 关于加法:总复杂度等于量级最大的那段代码的复杂度,因为当变量n无限大的时候,基本上只需要关注最大量级的代码的计算了,比如O(n^2) + O(n) 也就相当于O(n^2)了。
- 关于乘法:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积,比如嵌套循环了3次,那么复杂度就需要对3次嵌套的复杂度相乘。
package main
import "fmt"
func test1() {
// O(1) 常量级别复杂度
var a = 5
var b = 3
var c = 1
fmt.Println(a + b + c)
// O(logn) 和 O(n * logn) 对数级别
var d = 1
for d <= 10 {
// 变量d的值从1开始取,每循环一次就乘以2;当大于10时循环结束,变量d的取值是一个等比数列。
// 所以这段代码的时间复杂度是 O(log2n),注意这个2是下标2,输入法打不出来.
d = d * 2
}
fmt.Println(d)
// O(m+n) :这种情况的复杂度由m和n两个数据的规模来决定
fmt.Println(test2(100, 300))
}
func test2(m, n int) int {
// 无法事先评估 m 和 n 谁的量级大,所以不能利用加法法则省略掉其中一个;
// 所以这段代码的时间复杂度就是 O(m+n)
var sum1 = 0
for i := 1; i < m; i++ {
sum1 = sum1 + i
}
var sum2 = 0
for j := 1; j < n; j++ {
sum2 = sum2 + j
}
return sum1 + sum2
}
func main() {
test1()
}
时间复杂度与代码的运算方式有关系,空间复杂度与数据结构的设计有关系。比如需要对一个数组的元素逆序输出,可以有下面两种方式:
// 逆序输出数组的元素,方法1:逐个遍历并逆序赋值
// 时间复杂度是O(n),空间复杂度是O(n)
func reverseArray1(arr [5]int) [5]int {
var newArr = [5]int{}
for i := 0; i < len(arr); i++ {
newArr[len(arr)-i-1] = arr[i]
}
return newArr
}
// 逆序输出数组的元素,方法2:前后互相调换
// 时间复杂度是O(n/2),也就是O(n),空间复杂度是O(1)
func reverseArray2(arr [5]int) [5]int {
var tmp = 0
for i := 0; i < len(arr)/2; i++ {
tmp = arr[i]
arr[i] = arr[len(arr)-i-1]
arr[len(arr)-i-1] = tmp
}
return arr
}
func main() {
fmt.Println(reverseArray1([5]int{1, 2, 3, 4, 5})) //[5 4 3 2 1]
fmt.Println(reverseArray2([5]int{1, 2, 3, 4, 5})) //[5 4 3 2 1]
}
上面两种方法输出的结果都是正确的,但是第二种的空间复杂度是常数1,因此效率更高。
【降低时间复杂度】
降低时间复杂度的必要性
实际的生产环境中,用户的访问请求可以看作一个流式数据,假设这个数据流中每个访问的平均时间间隔是t,如果代码无法在 t 时间内处理完单次的访问请求,那么这个系统最终被大量积压的任务给压垮,这就要求开发人员必须通过优化代码来降低时间复杂度。
数据量小的时候,不管怎么写程序,运行的结果差别都不会太大;但是如果数据量特别大的情况下,不同的时间复杂度运行的结果可能千差万别。假设某个计算任务需要处理10万条数据,使用不同的时间复杂度的结果如下:
- 如果是O(n^2)的时间复杂度,那么计算的次数就是 10万*10万 = 100亿次;
- 如果是O(n)的时间复杂度,那么计算的次数就是10万次;
- 如果是O(logn)的时间复杂度下,那么计算的次数就是17次左右(log 100000 = 16.61,这里的对数以2为底去估计)。
降低时间复杂度的方法:
(1)空间换时间:假设一段程序在比较低配置的计算机上运行可能需要很长时间,那么就可以花钱购买高配置或者更多的服务器来缩短运行时间,也就是俗话说的“堆机器”。这样的操作降低了时间复杂度,但增加了空间复杂度。但实际上空间(云服务器)是比较廉价的,而时间是很宝贵的,你总不能让用户打开一个页面等待好几分钟吧?
(2)通过程序算法降低时间复杂度,常用的算法有:递归、二分法、排序算法、动态规划等等。
程序优化的核心思路:
- 暴力解法:在没有任何时间、空间约束下,完成代码任务的开发;
- 无效操作处理:将代码中的无效计算、无效存储剔除,降低时间或空间复杂度;
- 时空转换:设计合理数据结构,完成时间复杂度向空间复杂度的转移;
【每日一练】
力扣:两数之和(https://leetcode.cn/problems/two-sum/)
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
示例:输入:nums = [2,7,11,15], target = 9,输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
思路1:暴力解法,双重循环遍历。时间复杂度: O(n^2),空间复杂度: O(1)
func twoSum1(nums []int, target int) []int {
// 第一轮遍历
for i := 0; i < len(nums); i++ {
// 第二轮遍历不能重复计算了
for j := i + 1; j < len(nums); j++ {
if nums[i]+nums[j] == target {
// 注意 leetcode 中要求返回的是索引位置
return []int{i, j}
}
}
}
return []int{}
}
func main() {
fmt.Println(twoSum1([]int{2, 7, 11, 15}, 22)) //[1 3]
}
思路2:空间换时间,使用map(键值对)存储。时间复杂度: O(n),空间复杂度: O(n)
func twoSum2(nums []int, target int) []int {
find := map[int]int{}
for j, num := range nums {
if i, ok := find[target-num]; ok {
return []int{i, j}
}
// 每一轮都存下当前num和对应的index到map中
find[num] = j
}
return []int{}
}
func main() {
fmt.Println(twoSum2([]int{2, 7, 11, 15}, 22)) //[1 3]
}
源代码:https://gitee.com/rxbook/go-algo-demo/blob/master/algo01/demo1.go