Python在算法竞赛中的常见小技巧

前言

Python作为目前最炙手可热的编程语言,伴随着人工智能及机器学习的发展,吸引了越来越多的专业或非专业人士。它灵活、优雅、易上手,一旦你习惯了它处女座般对格式(缩进)的要求,就很难再回到满屏花括号的年代。然而作为一款胶水一样的、无需编译的、动态的解释型语言,Python的缺点也是显而易见的——慢。通常情况下,运行速度要比编译型语言慢上3到10倍。

使用Python来学习算法也有着它的优点:简洁、清晰、易懂。但目前的环境下似乎并不太适合使用Python参加正规的算法比赛。虽然大多数竞赛平台都允许参与者使用Python提交代码,但在时间及空间上,却并没有像C/C++那样有比较成熟的限制标准,所以往往思路一样、解法一样,使用Python提交就是无法AC。

当然,Python也有第三方库比如NumPy等来极大地优化有关矩阵方面的计算,但并不能保证参赛平台允许这些第三方库的运行。所以,如果你和问哥一样,坚持使用Python来参加算法比赛,一定要对各种各样只针对Python的“坑”做到心中有数。

本文旨在记录一些问哥常用的、也比较常见的Python代码优化方式。其中有一些是问哥的使用习惯,也许不一定真的能提高速度,但至少可以使代码更加简洁,也一并记录在此。


一、输入

1.1 最常见的输入模式,莫过于“N个整数,以空格隔开”。对于确定了是整数的输入,而且大多数情况下需要将这些输入的整数从字符串形式转换成可以计算的整数,所以使用 map() 函数更加方便简洁。此外,map() 函数生成的是一个 map 类可迭代对象,支持解包操作,所以可以直接赋值给数量相等的变量。通常一句代码搞定。

而如果输入的整数需要转化为数组,通常也可以直接将 map 对象使用 list() 函数转为列表。

n, m = map(int, input().split()) # 接收两个整数,以空格隔开
arr = list(map(int, input().split())) # 接收 n 个整数的数组,以空格隔开

此外,还可以使用 sys.stdin.readline() 方法直接将一行数据读入,实测这种方式比 input() 函数接收速度要快。

import sys
s = sys.stdin.readline()

1.2 第二种输入模式,是不定长的数据。测试用例的输入数据不确定有多少行,也不确定以什么字符串终止(通常是以一个空字符串结束输入,但是不全是这样)。这种题目通常算法复杂度在 O(n) 之上,也就是说至少也要逐个检查每个输入数据,所以可以一边输入一边进行计算,然后一边输出,并不需要等到输入结束后再进行计算。

当然,最省事的方法还是使用sys.stdin.readlines() 方法。和上面说到的 readline() 方法类似,只不过是一次性将所有输入读入,然后转化成一个字符串的列表,再进行后续计算。

import sys
p = sys.stdin.readlines()

要注意的是,readlines() 获取到的字符串列表通常会包含一个换行符“\n”,在进行处理的时候,根据需要使用 strip() 或 rstrip() 将最右边的换行符去掉。

1.3 此外,还有一种相对比较少见的输入方式,就是题目明示了数据包含在某个特定的文件中,使用 with open() 的文件读取函数进行操作即可,也比较简单。

with open("filename.txt", "r") as f:
    p = f.readlines()

二、输出

输出比较简单,最常见的是使用 print() 函数,但是也有人喜欢 sys.stdout.write() ,只不过后者需要将输出再手动转化为字符串格式,而 print() 比较省事。至于时间上,问哥并没有感觉有多少区别,所以也还是以 print() 为主。不过在使用 print() 输出的时候,还有一些小技巧:

2.1 要求输出数字以空格分开。其实 print() 函数的参数 sep=" " 的默认值就是空格,所以如果最后的答案是一个列表或其他可迭代对象,可以使用星号 * 对其进行解包,然后再利用print() 将其以空格隔开进行输出。

result = [1, 2, 3, 4, 5]
print(*result)

关于解包,可以理解为就是把列表左右的括号去掉(保留元素间隔的逗号)。比如上面例子里如果 result = [1, 2, 3, 4, 5],那么 print(*result) 等价于 print(1, 2, 3, 4, 5),然后 sep 参数会将逗号变成空格再输出。

2.2 同理,如果要求输出每个数字占一行,则可以在解包列表的同时,将 print() 函数的 sep 参数设置为换行符 “\n”即可。

print(*result, sep = "\n")

三、其他常见技巧

3.1 使用推导式生成列表和字典

使用列表推导式(生成式)生成列表,要比使用 for 循环更快,字典也是一样。

p = [i for i in range(100)]
d = {i: j for i, j in enumerate(range(100))}

上面只是举了简单的例子。对于某些特例,比如并查集,需要初始化元素和下标相等的列表,直接使用 list() 方法将 range 对象转成列表更快。

arr = list(range(100))
# arr = [0, 1, 2, 3, 4,..., 98, 99]

3.2 善用元组、集合与字典

- Python的集合与字典使用哈希表的方式存储,从而可以做到时间复杂度为 O(1) 的常数级读取。稍许美中不足的是Python的集合目前还是无序的,不过瑕不掩瑜,在更多需要频繁读取,且对顺序没有要求的情况下,问哥极力推荐使用集合来代替列表。比如判断某个数字是否在记录里:

nums = {1, 2, 3, 4, 5}
if 2 in nums:

而且集合还可以用来去重,以及自带一些数学上的集合操作,在某些情况下使用更加方便。

- 说到元组,很多初学者可能会觉得这个容器类别比较鸡肋,通常使用列表比元组更加方便,元组的限制——不能修改元素,使得它看起来没什么用。但是,元组是唯一可以被哈希化的容器。也就是说,多元组可以放进集合、字典,从而在保证去重的同时,还可用来常数级读取判断某个多元组存不存在。最常见的情景就是图的深度搜索 DFS、广度搜索 BFS 了。因为在这些搜索算法中,必须要判断某个点是否已经被访问过,而我们常常使用二维矩阵来保存图,这就使得图中的每个点都可以使用 (x, y) 这样的坐标形式来表示,而 (x,y) 就是一个二元组,所以可以将其放进集合,从而以 的时间复杂度来判断某个点是否已经访问过。

- 关于字典的知识这里可以扩展的不多,大多比较基础。但问哥列在这里的原因是字典本身的数据结构十分强大,比如在一个空字典、或字典里没有某个键的时候,可以使用字典的 setdefault() 方法,在创建新键的同时对其值进行操作:

d = dict()
for i in range(10):
    d.setdefault(i, list()).append("a")
    d.setdefault(i+10, set()).add("b")

3.3 善用数据类型

Python 的数据类型非常灵活,而且可以相互转换,最常见的就是布尔型变量与其他类型数据的转换。这种转换常见于以下两种:

3.3.1 其他数据类型转换为布尔型变量进行条件判断。

布尔型变量与其他变量进行转换,通常有两个简单的规则:

  • 空字符串、空的容器(列表、集合、字典等)、数字0 等价于布尔型的 False
  • 非上述情况均等价于 True(负数也是True)

所以,在判断对象非空/零的时候,可以省略等号,或 is_empty() 这样的判断方法,直接进行判断,python会自动转化为布尔型变量。

if i != 0:
if i != "":
if not i.is_empty():
# 均可写成:
if i:
#
# 反之判断相反的情况时:
if not i:

3.3.2 布尔型运算结果直接参与数学计算

这种使用情况比较少,可能也是问哥自己的习惯。因为布尔型变量True等价于整数1,False等价于0,所以可以直接将布尔运算结果(判断或比较)代入计算方程,不用再单独判断,从而省略部分代码。试举例如下:

  • 向上取整:
a = 10
b = 3
c = a//b + bool(a%b)
  • 求满足某条件的元素数量:
p = [25, 50, 120, 90, 150, 85]
s = sum(i < 100 for i in p)

3.4 善用 map 和 zip

zip() 函数可以将多个列表合并起来,生成多元数组,并且位置对应,长度为其中最短的列表之长。使用场景不多,但往往有奇效。比如分开输入的同一对象的 N 个属性,可以创建一个类,用来接收对象。但更简单的是使用 zip() 函数直接将 N 个列表合并起来(通常这种题目里列表都是等长的),然后通过下标进行随机访问。

name = ["Ann", "Billy", "Cathy", "Danny", "Edmond"]
age = [7, 10, 18, 12, 10]
weight = [20, 22, 40, 35, 28]
gender = ["female", "male", "female", "male", "male"]
people = list(zip(name, age, weight, gender))

map() 函数其实非常灵活,使用习惯后,可以将各种其他函数套用其中,并且可以使用 lambda 匿名函数简化代码。

举个例子,分别计算二维矩阵的行列和,可以结合 zip() 函数,两行代码搞定。

arr = [[1, 2, 3], [3, 4, 5], [4, 5, 6]]
row = list(map(sum, arr))
col = list(map(sum, zip(*arr)))

注:上面的例子综合运用了解包、zip、map多种方法。

3.5 善用各种常用函数

3.5.1 sum()

求和函数是相对来说最常用的函数,初学者往往忽略的是,其实 sum() 函数可以直接对可迭代对象求和,而不需要生成可迭代对象再遍历求和。比如计算100内(小于100)的整数之和,可以直接对 range 求和:

result = sum(range(100))
# result = 0+1+2+3+...+98+99

还可以直接对列表生成式(生成器)求和,而不需要实际生成列表(不需要左右两边的中括号):

result = sum(i//2 for i in arr)

3.5.2 max() 与 min()

也是比较常见的函数。容易被忽略的一点是,这两个函数可以通过修改默认参数 key,来得到满足某些特定条件的最大、最小值。key 可以使用其他函数,或者匿名函数 lambda。

比如找最长的字符串:

names = ["Ann", "Billy", "Catherine", "Donald", "Ericson"]
longest_name = max(names, key = len)
shortest_name = min(names, key = len)

3.5.3 sorted()

排序函数,可以说是使用最多的函数了。在所有需要用到排序结果的题目里,可以直接调用 sorted() 函数,不可能比它更快了。

sorted() 函数和列表的 sort() 方法功能基本相同。前者是对目标对象进行排序,返回一个列表,不改变对象;后者是列表自身进行排序,不返回结果,改变自身。

二者都可以使用 key 参数加匿名函数进行某些特定条件的排序,而且可以使用 reverse 参数进行降序排列,比如以二维列表的第二个数字进行降序排列:

arr = [[1,2,3], [3,1,2], [2,3,1]]
new_arr = sorted(arr, key = lambda x: x[1], reverse = True)
arr.sort(key = lambda x: x[1], reverse = True)

而 sorted() 函数因为返回一个列表,所以不需要将对象转成列表再进行排列,可以直接排序生成列表。最常见的有:

  • 对输入的一组数字转化为整数后排序:
arr = sorted(map(int, input().split())
  • 对字典元素根据元素值排序:
scores = {"Ann": 90, "Catherine": 98, "Danny": 85}
result = sorted(scores.items(), key = lambda x: x[1])

3.5.4 字符串方法 find() 和 replace()

这两个使用频率不高,有关字符串的问题才会用到。但是他们的隐藏参数有时候可以节省不少时间和代码。

  • find() 的 start 和 end 参数

可以规定在字符串的什么位置开始和结束查找([start, end)左闭右开)。

s = "abc def cd"
print(s.find(" ", 5)) # 从下标 5 开始查找空格的位置(答案为 7)
print(s.find(" ", 5, 7)) # 在下标 5 和下标 7 之间查找空格(答案为 -1,表示找不到)
  • replace() 的 count 参数

可以规定只替换指定数量的目标值,并不是全部替换。

s = "abc def cd"
print(s.replace(" ", "1", 1)) # 只把第一个空格替换成字符“1”

3.6 善用各种内置库(模块)

众所周知,python 的“方便易用”很大程度上是因为其丰富的拓展库(模块),其内置模块就有不少方便的工具,熟练使用可以省去不少代码。试举例如下:

3.6.1 math 库

顾名思义,数学模块,包含各种和数学相关的运算,比如前面说的向上取整,可以使用 math.ceil() 函数。

import math
result = math.ceil(10/3)

在OJ中用到的函数有 math.gcd(),math.lcm(),math.log()

  • math.gcd() 和 math.lcm()

求最大公约数,与最小公倍数。不过值得注意的是,一些 OJ(比如C站)使用的 Python3 版本较低,并没有 lcm() 函数,而且 gcd() 函数只支持两个数字的计算。这时就需要“曲线救国”:

import math
gcd = math.gcd(4, math.gcd(6, 10)) # 间接求三个数字的最大公约数
lcm = 6*10 // math.gcd(6, 10) # 间接求最小公倍数
  • math.log()

使用的比较少,常用在倍增法动态规划中,比如以2的幂作为DP的长度。

import math
limit = math.ceil(math.log(n, 2))
dp = [[0]*(limit + 1) for _ in range(n)]

还有ST算法中,求区间最值。

import math
for l, r in q:
    length = r - l + 1
    k = int(math.log(length, 2))
    max_k = max(dp_max[l-1][k], dp_max[r-(1<<k)][k])
    min_k = min(dp_min[l-1][k], dp_min[r-(1<<k)][k])

除此之外,还可以使用内置的常数 π 来减少误差。

import math
print(math.pi) # 3.141592653589793

3.6.2 collections 库

最常用的有 Counter 和 deque 类。

  • Counter 类

将对象按照其中元素的出现次数,自动转化为一个类似字典的计数类。与字典大同小异,但是可以进行计算。问哥在不少题解中都介绍过,也算是比较熟悉了。

from collections import Counter
a = "aaabbbccc"
b = "abcdef"
result = Counter(a) - Counter(b)
print(result) # Counter({'a': 2, 'b': 2, 'c': 2})
  • deque类

双端列表,使用方法和普通列表类似,不同的是其可以在列表两端以 O(1) 的复杂度入队、出队,在单调队列、广度搜索 BFS 等题目中使用较多。

比如下面这个求滑动窗口最小值的单调队列模板题:

from collections import deque
q = deque() # 队列中保存的是数组的下标
for i in range(n):
    while q and arr[q[-1]] > arr[i]: q.pop() # 如果新的数字比队尾数字小,则队尾数字出队
    q.append(i) # 新的数字入列
    if i >= k - 1: # 当窗口滑动到k时才开始输出
        while q and q[0] <= i - k: q.popleft() # 如果队首划出窗口,则队首数字出队
        print(arr[q[0]], end=" ") # 打印队首

3.6.3 heapq 库

堆模块。由于堆的数据结构有着 O(logn) 的优秀的算法复杂度,所以在求最值的时候,使用堆往往更加快捷。当然也可以手动实现堆,但Python已经提供了内置堆模块,直接调用即可。

from heapq import *
arr = [2, 5, 1, 3, 9, 8]
heapify(arr) # 将列表进行堆排列
print(arr[0]) # 输出 1
heappush(arr, 0) # 数字 0 入堆,自动更新到堆顶
print(arr[0]) # 输出 0

heapq实现的是小顶堆,也就是堆顶数字保证是最小的。如果要实现大顶堆,使堆顶数字最大,常见的做法是把数字取负再入堆。

3.6.4 itertools 库

问哥比较常用的排列组合函数是 combinations() 和 permutations(),在解决实际问题中用的比较多,反而在 OJ 中比较少用,原因是如果考到排列组合的OJ题目,通常会有更优的解法,因为排列组合相当于穷举,往往会 TLE (Time Limit Exceed)。

还有一些其他的库,比如有序容器库 sortedcontainers 中的有序列表类 SortedList,等等,以后有机会再加以详解。

3.7 递归深度与记忆化搜索

3.7.1 递归深度

Python默认的递归深度是 1000,但是在某些情况下,比如深度搜索DFS时,这个深度可能会不够用,但是题目能够保证不会递归到爆内存的地步,那么可以手动修改这个限制,从而在某些变态的情况下通过测试。

import sys
print(sys.getrecursionlimit()) # 默认递归深度为1000
sys.setrecursionlimit(10000) # 手动修改为10000

3.7.2 记忆化搜索

在某些情况的递归中(比如深度搜索DFS)常常需要不断调用某个函数,如果每次都重新计算,无疑是算力的极大浪费,这时我们可以考虑把计算结果保存在缓存里,这样当下次调用该函数,且使用的参数相同时,就可以直接从缓存中把结果调出来,从而不必再重新计算。

我们可以导入functools库中的装饰器 lru_cache() 来装饰某个函数,使用格式为@functools.lru_cache(maxsize=128, typed=False),其中maxsize为最大缓存数量,默认为128,None则无限制。

from functools import lru_cache
@lru_cache(None)
def dfs(a, b, c):

坦白讲,问哥在OJ中从没用过这个方法,因为凡是需要记忆化的递归算法,基本都可以转化为动态规划的递推。但是话不能说太绝,所以了解这种用法也是必要的。


学无止境,以后如果有更新,或有其他心得,问哥会更新此贴,也欢迎大家留言补充、友好讨论。

  • 15
    点赞
  • 91
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

请叫我问哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值