-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #48 from Jacob953/development/algorithm
docs: --20220111 add Analysis of Complexion
- Loading branch information
Showing
5 changed files
with
290 additions
and
37 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
# 0. Analysis of Complexion - 复杂度分析 | ||
|
||
在学习完数据结构之后,相信你能感受到特定环境下,特定数据结构带来的高效性 | ||
|
||
算法和数据结构也是一样,它们本身就是为了解决代码的运行速度和存储空间消耗的问题 | ||
|
||
这个问题所分析的对象,就是我们常说的时空复杂度,即时间复杂度和空间复杂度 | ||
|
||
## Go - 复杂度分析的意义 | ||
|
||
通常情况下,想要知道一段程序的运行时间,在 Golang 中可以配合以下代码进行计算: | ||
|
||
```go | ||
import "time" | ||
|
||
// get start | ||
start := time.Now() | ||
|
||
// <code-segment> | ||
|
||
// time cost from start | ||
cost := time.Since(start) | ||
``` | ||
|
||
通过类似的统计、监控,可以得到代码执行的时间和内存占用大小,但是,这种基于测算的事后统计法局限性极大,它非常依赖测试环境、数据规模等其他环境因素等影响 | ||
|
||
譬如,对同一段代码进行计算,可以得到如下结果: | ||
|
||
<img algin="center" src="../../../image/Algorithm/basic/00-complexion-analysis/01-result-of-test.go.png" alt="01-result-of-test.go"> | ||
|
||
为此,我们需要一种更加粗粒度的方法来对复杂度进行分析,这就是常被提到的大 O 复杂度表示法 | ||
|
||
> 注:真实世界的复杂度远不止时空复杂度这么简单,但为了方便理解,本章以时间复杂度的分析为主 | ||
## Go - 大 O 复杂度表示法 | ||
|
||
因为只需要做粗略计算,我们假设: | ||
|
||
- 代码的执行时间用 T(n) 来表示 | ||
- 代码的执行次数用 f(n) 来表示 | ||
- 每行语句的执行效率是 unit-time,通常将其假设为单位 1 | ||
|
||
以如下代码段为例: | ||
|
||
```go | ||
func cal(n int) { | ||
//Loop 1 | ||
i := 1 | ||
for ; i <= n; i++ { | ||
//Loop 2 | ||
for j:= 1; j <= n ; j++{ | ||
fmt.Println("i: ", i,"j: ", j) | ||
} | ||
} | ||
} | ||
``` | ||
|
||
在 Loop 2 中,每行语句需要执行 n 遍,即 f(n) = 2 _ n ,在 Loop 1 中,Loop 2 需要执行 n 遍,那么这段代码的执行时间 T(n) = (( 2 _ n<sup>2</sup> ) + 1 ) \* 1( 前 1 是 i 初始化,后 1 是 unit-time )。 | ||
|
||
很容易看出,T(n) 与 f(n) 是成正比的,于是,可以总结:**T(n) = O(f(n))**。 | ||
|
||
因此,类似 T(n) = O(2 \* n<sup>2</sup>) 这样的表达式被称为大 O 时间复杂度表示法。 | ||
|
||
## Go - 时间复杂度分析 | ||
|
||
大 O 时间复杂度只能粗略地描述代码执行时间随数据规模增长的变化趋势,故也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。 | ||
|
||
想要精益求精,优化自己的代码,降低其时间复杂度,首先就要学会如何分析时间复杂度: | ||
|
||
1. 基本法则:只关注 f(n) 最多的代码段。 | ||
|
||
T(n) = O(f(1) + f(1000000) + f(n)) = O(f(n)) | ||
|
||
1. 加法法则:O(n) 是量级最大的 f(n) 的代码段之和。 | ||
|
||
T(n) = T(f(2 \* n)) + T(f(n<sup>2</sup>)) = O(n<sup>2</sup>) | ||
|
||
T(n) = T(g(2 \* n)) + T(f(n<sup>2</sup>)) = O(max(O(g(n)), O(f(n))) | ||
|
||
1. 乘法法则:O(n) 是嵌套代码段的 f(n) 之积。 | ||
|
||
T(n) = T(g(n)) _ T(f(2 _ n)) = O(2 \* n<sup>2</sup>) | ||
|
||
1. 保留法则:保留不同的数据规模 | ||
|
||
加法法则失效:T(n) = T(f(n)) + T(f(m)) = O(m + n) | ||
|
||
乘法法则有效:T(n) = T(f(n)) _ T(f(m)) = O(m _ n) | ||
|
||
## Go - 空间复杂度分析 | ||
|
||
大 O 表示法同样也可以用来表示空间复杂度,当其粗略地描述代码的存储空间与数据规模之间的增长关系时,就被称为渐进空间复杂度(asymptotic space complexity),简称空间复杂度。 | ||
|
||
空间复杂度的分析通过类比时间复杂度的分析即可,只需要记住,现在的关注点在于算法的存储空间,即运行代码所需要使用的内存。 | ||
|
||
如以下片段: | ||
|
||
```go | ||
func cal() { | ||
// ... | ||
//<code-segment> | ||
n := [5]int{1, 2, 3, 4, 5} | ||
// ... | ||
//<code-segment> | ||
} | ||
``` | ||
|
||
无论其他代码段如何,只要不涉及存储空间,那么 f(n) = 5。 | ||
|
||
## Go - 常见的复杂度量级 | ||
|
||
代码段的写法千变万化,但常见的复杂度量级并不多,通常可以分为非多项式量级和多项式量级。 | ||
|
||
非多项式量级:O(2<sup>n</sup>) 和 O(n!) | ||
|
||
非多项式量级的算法问题通常被叫做 NP 问题,即 Non-Deterministic Polynomial,这类问题几乎只有做此类算法研究的同学才会涉及,因此,本章将重心放在多项式量级: | ||
|
||
- 常量级:O(1) | ||
|
||
只要不含循环、递归语句:T(n) = T(2) + T(100000) = O(1) | ||
|
||
- 线性、对数级:O(n), O(logn), O(nlogn) | ||
|
||
大 O 表示法只需要做粗略计算,故忽略线性级的系数,对数级的底数: | ||
|
||
- T(n) = T(2 _ n) + T(5 _ n) = O(n) | ||
- T(n) = T(log<sub>3</sub>n) = O(logn) | ||
|
||
而 O(nlogn) 可以看作线性级和对数级的嵌套,即遵循了乘法法则。 | ||
|
||
- 次方级:O(n<sup>k</sup>) | ||
|
||
多个相同数据代码段的嵌套,如循环、递归。 | ||
|
||
对于空间复杂度而言,只需要掌握 O(1), O(n), O(n<sup>2</sup>) 即可。 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
# 1. Recursion - 递归 | ||
|
||
简单地介绍完了复杂度分析,接下来就要进入算法实战了。 | ||
|
||
递归,可以说是算法学习之路上最难理解的知识点之一,很多复杂的算法实现都需要运用递归的编程技巧。 | ||
|
||
## Go - 什么是递归? | ||
|
||
即使是科班生也很难第一时间理解透递归的思路,但其实只要将其拆开来看,递归并不难。 | ||
|
||
递归实际上只涉及两个操作,“递”和“归”。从实际生活中去理解递归: | ||
|
||
场景一:监考老师分发试卷,由于多印了很多试卷,可以靠手去掂量,对试卷粗略分组。 | ||
|
||
要求:将多余的试卷回收给教务处。 | ||
|
||
具体步骤: | ||
|
||
1. 将试卷发给第一排的同学,让其只能向后传; | ||
|
||
2. 每个人只能拿一张试卷,如果没到场,留在其桌上即可; | ||
|
||
3. 直到最后一排的同学拿到试卷后,多余的试卷将由最后一排向第一排传; | ||
|
||
4. 传到第一排,等待监考老师来收即可。 | ||
|
||
> 这里为了帮助理解算法,我们没有让最后一排的同学直接叫老师,而是一排一排向前传 | ||
分析上述具体步骤,理解如下: | ||
|
||
步骤 1 即递归算法的入口,试卷将一排一排的传递下去,这就是“递”; | ||
|
||
步骤 2 即递归算法的多个子问题,下一排不管是否有人,都要留一张试卷在桌上; | ||
|
||
步骤 3 即递归算法的终止条件,试卷传到最后一排后,则要被传回第一排,这就是“归”; | ||
|
||
步骤 4 即递归算法的出口,在代码实现过程中,这里便回到了调用递归算法的主函数中。 | ||
|
||
以上思路的代码实现如下: | ||
|
||
```go | ||
// LAST :最后一排 | ||
// TOTAL :该组的分发试卷数 | ||
func rest(pos int) int { | ||
if pos == LAST { | ||
return TOTAL - 1 | ||
} | ||
return rest(pos+1) - 1 | ||
} | ||
``` | ||
|
||
## Go - 递归的三个条件 | ||
|
||
1. 可分解成多个子问题 | ||
|
||
实现递归算法的第一件事就是将问题拆分为多个子问题,子问题即数据规模更小的问题,比如,将“试卷还剩多少”,分解为多个“下一排试卷还剩多少”的问题。 | ||
|
||
2. 子问题除数据规模不同,算法思路完全一样 | ||
|
||
场景一中,在传递给下一排时,可以使用同一种算法思路解决子问题,如“无论是否到场,留一张试卷在桌上”。 | ||
|
||
同样,我们可以根据不同场景更改算法思路,如场景二:不需要给没来的同学留试卷,直接传给再下一排。这样的话,算法也会跟着变化: | ||
|
||
```go | ||
// LAST: 最后一排 | ||
// TOTAL: 该组的分发试卷数 | ||
// stu[]: 保存考生的到场状态,0 为未到场 | ||
func rest(pos int) int { | ||
if pos == LAST { | ||
return TOTAL - 1 | ||
} | ||
if stu[pos + 1] == 0 { | ||
if pos + 1 == LAST { | ||
return TOTAL - 1 | ||
} | ||
return rest(pos+2) - 1 | ||
} | ||
return rest(pos+1) - 1 | ||
} | ||
``` | ||
|
||
3. 存在终止条件 | ||
|
||
推导出递归公式后,还需要找到递归的终止条件。递归之于循环一样,如果没有终止条件,算法将会无限递归下去,因此,在实现递归算法时,一定要找到合适的终止条件。 | ||
|
||
## Go - 递归的注意事项 | ||
|
||
1. 警惕内存溢出 | ||
|
||
如果你已经开始实现递归算法了,相信你肯定碰到过这样的问题 `fatal error: stack overflow` ,这便是因为一直进行函数调用,导致栈、堆内存溢出产生的错误。 | ||
|
||
最大允许的递归深度跟当前线程剩余的栈空间有关,不误进行事前计算,因此,这种错误很难控制和预防。 | ||
|
||
2. 警惕重复计算 | ||
|
||
先来看看每每提到递归,就必谈的斐波那契数的实现吧: | ||
|
||
> Fibonacci:由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出,如:1, 1, 2, 3, 5, 8, 13, 21, 34, ... | ||
|
||
```go | ||
func fib(n int) int { | ||
if n == 1 { | ||
return 1 | ||
} | ||
if n == 0 { | ||
return 0 | ||
} | ||
return fib(n-1) + fib(n-2) | ||
} | ||
``` | ||
|
||
不难发现,fib(fib(n-1)-2) 与 fib(fib(n-2)-1) 所计算的值是相同的,以 fib(6) 为例,如 Figure 1. Fibonacci 所示,fib(2) 会被计算 5 次: | ||
|
||
<img algin="center" src="../../../image/Algorithm/basic/01-recursion/01-fibonacci.jpg" alt="Figure 1. fibonacci"> | ||
|
||
如果求 fib(20),难以想象同样一个值会被重复计算多少次,因此,我们可以利用一些数据结构(如数组、散列表等)巧妙地避免这个问题: | ||
|
||
```go | ||
// tmp[]: 存储中间值 | ||
func fib(n int) (res int) { | ||
if n == 0 { | ||
return 0 | ||
} | ||
if n == 1 { | ||
return 1 | ||
} | ||
if tmp[n] != 0 { | ||
return tmp[n] | ||
} | ||
res = fib(n-1) + fib(n-2) | ||
tmp[n] = res | ||
return res | ||
} | ||
``` | ||
|
||
当然,为了避免重复计算,还有更加高级的方法,我提出以上方法,便是在这里抛砖引玉了。 |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.