算法入门概念
算法的重要性
- 算法工程师是非常有趣且薪资高的岗位。
- 一般程序员则可以通过算法锻炼代码思维,将代码写的更加高效。
- 互联网大厂笔试面试等一般都会涉及算法题目。
算法概念
算法(Algorithm): 一个计算过程,解决问题的方法。
Niklaus Wirth:“程序 = 数据结构 + 算法”。
栗子:
对于python来说, 数据结构是指数字,字符(串),列表,字典等静态的,储存数据的方式;算法则让数据有操作,有输入输出,形成针对某一类问题的通用的解决方法,是动态的。
时间复杂度
栗子:
print('Hello World')
for i in range(n):
print('Hello World')
for i in range(n):
for j in range(n):
print('Hello World')
for i in range(n):
for j in range(n):
for k in range (n):
print('Hello World')
问题:如上四组代码中, 哪组运行时间最短?
回答:如上四组运行时间逐个变长。
问题:用什么方式来体现算法运行的快慢?
回答:由于所使用的计算机性能的不同,单次操作的差异以及外界环境的影响等等, 单单用算法运行时间的长短来体现算法运行的快慢是不好的,不准确的。所以我们从算法本身来分析,进行一定程度的估计,使用所谓“时间复杂度”的概念来体现算法运行的快慢。
时间复杂度:用来评估算法运行效率的一个式子。
我们对如上四组代码或算法来进行如下估计和分析:
- 将加、减、乘、除、打印等等基本操作的时间消耗估计做一个单位,记作O(1),则第一组代码时间复杂度为O(1)。
- 第二组代码进行了n次基本操作,则为O(n)。
- 第三组则为O(n2)。
- 第四组为O(n3)。
栗子:
print('Hello World')
print('Hello Python')
print('Hello Algrithm')
3次基本操作,时间复杂度为O(1)。
for i in range(n):
print('Hello World')
for j in range(n):
print('Hello World')
n(n+1)次基本操作, 时间复杂度为O(n2)。
重要的栗子:
while n>1:
print(n)
n = n // 2
n = 64输出:
64
32
16
8
4
2
这是循环减半过程:26 = 64,log264 = 6,时间复杂度记为O(log2n) 或 O(logn)。
注意:当算法过程出现循环减半的时候,复杂度式子中会出现logn。
时间复杂度小结
- 时间复杂度是用来估计算法运行时间的一个式子(单位)。
- 一般来说, 时间复杂度搞的算法比时间复杂度低的算法慢。
- 常见的时间复杂度(按效率排序):
O(1) < O(logn) < O(nlogn) < O(n2) < O(n2logn) < O(n3)… - 复杂问题的时间复杂度:
O(n!) , O(2n), O(nn)…
简单判断时间复杂度
- 快速判断算法复杂度(适用于绝大多数简单情况):
确定问题规模n;
循环减半过程为logn;
k层关于n的循环为nk; - 复杂情况:根据算法执行过程判断。
空间复杂度
- 空间复杂度:用来评估算法内存占用大小的式子。
- 空间复杂度的表示方式与时间复杂度完全一样。
算法使用了几个变量:O(1)
算法使用了长度为n的一维列表:O(n)
算法使用了m行n列的二维列表:O(mn) - 空间换时间,在实际情况中算法效率远远比算法占用内存重要,例如分布式运算。
递归
条件
递归的两个条件:
- 调用自身
- 结束条件(或者初始值)
栗子:
def func1(n):
print(x)
func1(x-1)
def func2(x):
if x > 0:
print(x)
func(x+1)
def func3(x):
if x > 0:
print(x)
func3(x-1)
def func4(x):
if x > 0:
func(x-1)
print(x)
其中func1,func2不是递归,均无法结束;func3,func4是合法递归。
问题:若对func3,func4输入3,那么两个程序的输出分别是什么?
回答:其中func3依次输出3,2,1;而func4依次输出1,2,3。
汉诺塔问题
大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序裸着64片黄金圆盘。
大梵天命令婆罗门把盘子从下面开始按大小顺序重新摆放在另一根柱子上。
在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。
64根柱子移动完毕之日,就是世界毁灭之时。
- 先来看两个圆盘的时候:
先将1从A移动到B;
再将2从A移动到C;
最后将1从B移动到C。
- 再来看三个圆盘的时候:
将1从A移动到C;
将2从A移动到B;
将1从C移动到B;
将3从A移动到C;
将1从B移动到A;
将2从B移动到C;
将1从A移动到C。
事实上我们可以将上面的步骤做个总结:
前三步就是将前两个圆盘1,2通过C从A移动到B,第四步将最后一个圆盘从A移动到C,后三步将前两个圆盘1,2通过A从B移动到C,即如下步骤:
将1,2从A(通过C)移动到B;
将3从A移动到C;
将1,2从B(通过A)移动到C。
这实际上与两个圆盘时候的步骤有一定的共性了。
- 最后看n个圆盘的时候:
这时候就根据三个盘子时候的情况进行归纳:
将前n-1从A(通过C)移动到B;
将n从A移动到C;
将前n-1从B(通过A)移动到C。
那么如上的步骤是否是合理的?我们用递归的角度来看待这个步骤,并将其转化做算法语言。
- 原问题是将n个盘子从A(通过B)移动到C,第一步和第三步是比原问题规模小1的问题,这时可以调用函数自身来解决,而第二步是平凡的。
- 在n=0时程序终止。
def hanoi(n,A,B,C):
while n>0:
hanoi(n-1,A,C,B)
print('moving from %s to %s' % {A,C})
hanoi(n-1,B,A,C)
hanoi(3,A,B,C)
汉诺塔问题算法分析
假设n个盘子移动完毕需要h(n)步;
根据算法,汉诺塔移动次数的递推式:h(n) = 2h(n-1) +1,简单计算出来h(64) = 18446744073709551615;
假设婆罗门每秒钟搬一个盘子,则总共需要大约5800亿年。
查找
- 查找:在一些数据元素中, 通过一定的方法找出与给定关键字相同的数据元素的过程。
- 列表查找(线性表查找):从列表中查找指定元素。
输入:列表,待查找元素。
输出:元素下标(未找到元素时一般返回None或-1)。 - python内置列表查找函数index()。
顺序查找
顺序查找(Linear Search):也叫线性查找,从列表第一个元素开始,顺序进行搜索,直到找到元素或搜索到列表最后一个元素为止。
def linear_S(li, val):
for ind, v in enunerate(li):
if v == val:
return ind
else:
return None
顺序查找的时间复杂度为O(n)。
二分查找
二分查找(Binary Search):又叫折半查找,从有序列表的初始候选区li[0,n]开始, 通过对待查找的值与候选区中间值的比较,可以使候选区减少一半。
举个栗子来讲,从100人中海选一个身高175cm的模特。先让他们从矮到高排成一队。
先量一下最中间的人([(1+100)/2])的身高,比如他的身高为178cm,那么右边51-100位模特全部淘汰。此时左边1-50位模特成为新的候选区,再计算新的中间的人的身高([(1+50)/2]),比如为165cm,那么左边1-25位全部淘汰,右边26-50位成为新的候选区。
依次将这个过程进行下去,直到找到要求的值或候选区变空(找不到)。
def binary_S(li, val)
left = 0
right = len(li) - 1
while left <= right: #候选区有值
mid = (left + right) // 2
if li[mid] == val: #中间值符合要求
return mid
elif li[mid] > val: # 待查找的值在中间值左侧
right = mid - 1
else li[mid] < val: # 待查找的值在中间值右侧
left = mid + 1
else:
return None
li = [1,2,3,4,5,6,7,8,9]
print(binary_S(li,3))
二分查找是一个循环减半的过程,从而时间复杂度为O(logn)。
算法比较
import time
def cal_time(func):
def wrapper(*args, **kwargs)
t1 = time.time()
result = func(*args, **kwargs)
t2 = time.time()
print('%s running time: %s secs.' % (func._name_, t2 - t1))
return wrapper
@cal_time
def linear_S(li, val):
for ind, v in enunerate(li):
if v == val:
return ind
else:
return None
@cal_time
def binary_S(li, val)
left = 0
right = len(li) - 1
while left <= right:
mid = (left + right) // 2
if li[mid] == val:
return mid
elif li[mid] > val:
right = mid - 1
else li[mid] < val:
left = mid + 1
else:
return None
li = list(range(10000))
linear_S(li, 3890)
binary_S(li, 3890)
最终的运行结果:
linear_S :0.00023412704467773438 secs.
binary_S:5.9604644775390625e-06 secs.
直观的栗子:
对于一个长度为232的列表,二分查找的次数最多是33次,而对于线性查找来说最多达到了42亿次之多。
python内置函数index()的查找方式一定不是二分查找,因为查找的列表不一定被排过序。
注意:
线性查找和二分查找各有优缺点。
若拿到有序列表,那我们一定使用二分查找。
若拿到无序列表,那么我们要去考虑是否要排序。排序需要花时间,若列表过长导致排序运行时间过长,不如考虑线性查找,而若需要多次查找,则不如先排序后使用二分查找,这样对后续多次查找带来极大方便。
实际情况中要权衡使用不同的查找算法。