Python - 100天从新手到大师

Python - 100天从新手到大师
Penry引言
这里主要是依托于 jackfrued
仓库 Python-100-Days 进行学习,记录自己的学习过程和心得体会。
Day1
1 Python 简介
Python(英式发音:/ˈpaɪθən/;美式发音:/ˈpaɪθɑːn/)是由荷兰人吉多·范罗苏姆(Guido von Rossum)发明的一种编程语言,是目前世界上最受欢迎和拥有最多用户的编程语言。Python 强调代码的可读性和语法的简洁性,相较于 C、C++、Java 这些同样影响深远的编程语言,Python 让使用者能够用更少的代码表达自己的意图。下面是几个权威的编程语言排行榜给出的 Python 语言的排名,其中第1张图由 TIOBE Index 提供,第3张图由 IEEE Spectrum 提供。值得一提的是第2张图,它展示了编程语言在全球最大代码托管平台 GitHub 上受欢迎的程度,最近的四年时间 Python 语言都占据了冠军的宝座。
1.1 Python 编年史
-
1989年
- 荷兰程序员吉多·范罗苏姆在圣诞假期开始开发新编程语言
- 名称源自喜剧《Monty Python’s Flying Circus》
-
1991年
- 2月20日发布Python 0.9.0
- 首次具备类继承、异常处理等现代特性
- 确立"Batteries included"哲学
-
1994年
- Python 1.0发布
- 引入函数式编程工具
lambda
,map
,filter
- 形成开源社区雏形
-
2000年
- Python 2.0发布
- 新增垃圾回收机制
- 标准库突破2万个函数
- Zope成为首个企业级Python产品
-
2003年
- NumPy库发布
- 科学计算领域开始大规模采用
- NASA、劳伦斯国家实验室成为早期用户
-
2008年
- Python 3.0(代号Py3K)发布
- 核心革新:统一Unicode编码,print改为函数
- 向后不兼容引发社区长期讨论
-
2010年
- Flask框架诞生
- PyPI仓库突破1万个软件包
- Instagram全面转向Python技术栈
-
2013年
- Pandas 0.12重塑数据处理标准
- Jupyter Notebook成为科研标配
- Netflix推荐系统Python化
-
2016年
- TensorFlow 1.0确立AI框架标准
- Python占据91%机器学习框架市场
- RedMonk语言排行榜首次登顶
-
2019年
- Python 2.7正式退役
- 结束长达10年的2.x/3.x并行期
-
2020年
- Python 3.9发布
- 引入模式匹配(match/case)
- 官方支持Mypy静态类型检查
-
2021年
- Python 3.10推出结构化模式匹配
- 语言创建者吉多·范罗苏姆加入微软
- 社区启动解释器性能优化计划
-
2022年
- Python 3.11发布
- 解释器速度平均提升25%
- 引入Exception Groups语法
- TIOBE年度语言冠军
-
2024年
- Python 3.12引入JIT编译器雏形
- 全球用户突破2500万
- 教育领域采用率达87%
- 持续主导AI大模型开发
1.2 Python优缺点
Python 语言的优点很多,简单为大家列出几点。
- 简单优雅,跟其他很多编程语言相比,Python 更容易上手。
- 能用更少的代码做更多的事情,提升开发效率。
- 开放源代码,拥有强大的社区和生态圈。
- 能够做的事情非常多,有极强的适应性。
- 胶水语言,能够黏合其他语言开发的东西。
- 解释型语言,更容易跨平台,能够在多种操作系统上运行。
Python 最主要的缺点是执行效率低(解释型语言的通病),如果更看重代码的执行效率,C、C++ 或 Go 可能是你更好的选择。
1.2 安装 Python 环境
建议直接使用 Anaconda 配置环境,Anaconda 是一个开源的 Python 发行版,它包含了大量的科学计算库,同时它还提供了一个包管理器和环境管理器,可以方便地安装和管理 Python 包和环境。
这里我给出一些参考链接:
2 Python 语言中的变量
对于想学习编程的新手来说,有两个问题可能是他们很想知道的,其一是"什么是(计算机)程序",其二是"写(计算机)程序能做什么"。先说说我对这两个问题的理解:程序是数据和指令的有序集合,写程序就是用数据和指令控制计算机做我们想让它做的事情。今时今日,为什么有那么多人选择用 Python 语言来写程序,因为 Python 语言足够简单和强大。相较于 C、C++、Java 这样的编程语言,Python 对初学者和非专业人士更加友好,很多问题在 Python 语言中都能找到简单优雅的解决方案。接下来,我们就从最基础的语言元素开始,带大家认识和使用 Python 语言。
2.1 一些常识
在开始系统的学习 Python 编程之前,我们先来科普一些计算机的基础知识。计算机的硬件系统通常由五大部件构成,包括:运算器、控制器、存储器、输入设备和输出设备。其中,运算器和控制器放在一起就是我们常说的中央处理器(CPU),它的功能是执行各种运算和控制指令。刚才我们提到过,程序是指令的集合,写程序就是将一系列的指令按照某种方式组织到一起,然后通过这些指令去控制计算机做我们想让它做的事情。存储器可以分为内部存储器和外部存储器,前者就是我们常说的内存,它是中央处理器可以直接寻址的存储空间,程序在执行的过程中,对应的数据和指令需要加载到内存中。输入设备和输出设备经常被统称为 I/O 设备,键盘、鼠标、麦克风、摄像头是典型的输入设备,而显示器、打印机、扬声器等则是典型的输出设备。目前,我们使用的计算机基本大多是遵循"冯·诺依曼体系结构"的计算机,这种计算机有两个关键点:一是将存储器与中央处理器分开;二是将数据以二进制方式编码。
二进制是一种"逢二进一"的计数法,跟人类使用的"逢十进一"的计数法本质是一样的。人类因为有十根手指,所以使用了十进制计数法,在计数时十根手指用完之后,就只能用进位的方式来表示更大的数值。当然凡事都有例外,玛雅人可能是因为长年光着脚的原因,把脚趾头也都用上了,于是他们使用了二十进制的计数法。基于这样的计数方式,玛雅人使用的历法跟我们平常使用的历法就产生了差异。按照玛雅人的历法,2012 年是上一个所谓的"太阳纪"的最后一年,而 2013 年则是新的"太阳纪"的开始。后来这件事情还被以讹传讹的方式误传为"2012 年是玛雅人预言的世界末日"的荒诞说法。今天有很多人猜测,玛雅文明之所以发展缓慢跟使用了二十进制是有关系的。对于计算机来说,二进制在物理器件上最容易实现的,因为可以用高电压表示 1,用低电压表示 0。不是所有写程序的人都需要熟悉二进制,熟悉十进制与二进制、八进制、十六进制的转换,大多数时候我们即便不了解这些知识也能写程序。但是,我们必须知道,计算机是使用二进制计数的,不管什么样的数据,到了计算机内存中都是以二进制形态存在的。
说明:关于二进制计数法以及它与其他进制如何相互转换,大家可以翻翻名为《计算机导论》或《计算机文化》的书,都能找到相应的知识,此处就不再进行赘述了,不清楚的读者可以自行研究。
2.2 变量和类型
要想在计算机的内存中保存数据,首先得说一说变量这个概念。在编程语言中,变量是数据的载体,简单的说就是一块用来保存数据的内存空间,变量的值可以被读取和修改,这是所有运算和控制的基础。计算机能处理的数据有很多种类型,最常见的就是数值,除了数值之外还有文本、图像、音频、视频等各种各样的数据类型。虽然数据在计算机中都是以二进制形态存在的,但是我们可以用不同类型的变量来表示数据类型的差异。Python 语言中预设了多种数据类型,也允许我们自定义新的数据类型,这一点在后面会讲到。我们首先来了解几种 Python 中最为常用的数据类型。
- 整型(
int
):Python 中可以处理任意大小的整数,而且支持二进制(如0b100
,换算成十进制是4)、八进制(如0o100
,换算成十进制是64)、十进制(100)和十六进制(0x100
,换算成十进制是256)的表示法。运行下面的代码,看看会输出什么。
1 | print(0b100) # 二进制整数 |
- 浮点型(
float
):浮点数也就是小数,之所以称为浮点数,是因为按照科学记数法表示时,一个浮点数的小数点位置是可变的,浮点数除了数学写法(如123.456)之外还支持科学计数法(如1.23456e2,表示)。运行下面的代码,看看会输出什么。
1 | print(123.456) |
- 字符串型(
str
):字符串是以单引号或双引号包裹起来的任意文本,比如’hello’和"hello"。
1 | print('hello') |
- 布尔型(
bool
):布尔型只有True、False两种值,要么是True,要么是False,可以用来表示现实世界中的"是"和"否",命题的"真"和"假",状况的"好"与"坏",水平的"高"与"低"等等。如果一个变量的值只有两种状态,我们就可以使用布尔型。
1 | print(int(True)) |
2.3 变量命名
对于每个变量,我们都需要给它取一个名字,就如同我们每个人都有自己的名字一样。在 Python 中,变量命名需要遵循以下的规则和惯例。
- 规则部分:
- 规则1:变量名由字母、数字和下划线构成,数字不能开头。需要说明的是,这里说的字母指的是 Unicode 字符,Unicode 称为万国码,囊括了世界上大部分的文字系统,这也就意味着中文、日文、希腊字母等都可以作为变量名中的字符,但是一些特殊字符(如:!、@、#等)是不能出现在变量名中的。我们强烈建议大家把这里说的字母理解为尽可能只使用英文字母。
- 规则2:Python 是大小写敏感的编程语言,简单的说就是大写的A和小写的a是两个不同的变量,这一条其实并不算规则,而是需要大家注意的地方。
- 规则3:变量名不要跟 Python 的关键字重名,尽可能避开 Python 的保留字。这里的关键字是指在 Python 程序中有特殊含义的单词(如:
is
、if
、else
、for
、while
、True
、False
等),保留字主要指 Python 语言内置函数、内置模块等的名字(如:int
、print
、input
、str
、math
、os
等)。
- 惯例部分:
- 惯例1:变量名应该具有可读性,能够反映出变量的含义,比如一个表示年龄的变量,我们就可以命名为
age
,而不是a
或者x
。 - 惯例2:变量名应该避免使用缩写,除非缩写非常常见且易于理解,比如
name
比nm
要好,age
比a
要好。 - 惯例3:变量名通常使用小写英文字母,多个单词用下划线进行连接。
- 惯例4:受保护的变量用单个下划线开头。
- 惯例5:私有的变量用两个下划线开头。
- 惯例1:变量名应该具有可读性,能够反映出变量的含义,比如一个表示年龄的变量,我们就可以命名为
2.4 变量的使用
下面通过例子来说明变量的类型和变量的使用。
1 | """ |
在 Python 中可以使用type
函数对变量的类型进行检查。程序设计中函数的概念跟数学上函数的概念非常类似,数学上的函数相信大家并不陌生,它包括了函数名、自变量和因变量。如果暂时不理解函数这个概念也不要紧,我们会在后续的内容中专门讲解函数的定义和使用。
1 | """ |
可以通过 Python 内置的函数来改变变量的类型,下面是一些常用的和变量类型相关的函数。
int()
:将一个数值或字符串转换成整数,可以指定进制。float()
:将一个字符串(在可能的情况下)转换成浮点数。str()
:将指定的对象转换成字符串形式,可以指定编码方式。chr()
:将整数(字符编码)转换成对应的(一个字符的)字符串。ord()
:将(一个字符的)字符串转换成对应的整数(字符编码)。
下面的例子为大家演示了 Python 中类型转换的操作。
1 | """ |
说明:
str
类型转int
类型时可以通过base
参数来指定进制,可以将字符串视为对应进制的整数进行转换。str
类型转成bool
类型时,只要字符串有内容,不是’'或"",对应的布尔值都是True
。bool
类型转int
类型时,True
会变成1,False
会变成0。在 ASCII 字符集和 Unicode 字符集中, 字符'd'
对应的编码都是100
。
3 Python语言中的运算符
Python 语言支持很多种运算符,下面的表格按照运算符的优先级从高到低,对 Python 中的运算符进行了罗列。有了变量和运算符,我们就可以构造各种各样的表达式来解决实际问题。在计算机科学中,表达式是计算机程序中的句法实体,它由一个或多个常量、变量、函数和运算符组合而成,编程语言可以对其进行解释和计算以得到另一个值。不理解这句话没有关系,但是一定要知道,不管使用什么样的编程语言,构造表达式都是非常重要的。
运算符 | 描述 |
---|---|
[] 、[:] |
索引、切片 |
** |
幂 |
~ 、+ 、- |
按位取反、正号、负号 |
* 、/ 、% 、// |
乘、除、模、整除 |
+ 、- |
加、减 |
>> 、<< |
右移、左移 |
& |
按位与 |
^ 、` |
` |
<= 、< 、> 、>= |
小于等于、小于、大于、大于等于 |
== 、!= |
等于、不等于 |
is 、is not |
身份运算符 |
in 、not in |
成员运算符 |
not 、or 、and |
逻辑运算符 |
= 、+= 、-= 、*= 、/= 、%= 、//= 、**= 、&= 、|= 、^= 、>>= 、<<= |
赋值运算符 |
说明: 所谓优先级就是在一个运算的表达式中,如果出现了多个运算符,应该先执行什么再执行什么的顺序。编写代码的时候,如果搞不清楚一个表达式中运算符的优先级,可以使用圆括号(小括号)来确保运算的执行顺序。
3.1 算术运算符
Python 中的算术运算符非常丰富,除了大家最为熟悉的加、减、乘、除之外,还有整除运算符、求模(求余数)运算符和求幂运算符。下面的例子为大家展示了算术运算符的使用。
1 | """ |
算术运算需要先乘除后加减,这一点跟数学课本中讲的知识没有区别,也就是说乘除法的运算优先级是高于加减法的。如果还有求幂运算,求幂运算的优先级是高于乘除法的。如果想改变算术运算的执行顺序,可以使用英文输入法状态下的圆括号(小括号),写在圆括号中的表达式会被优先执行,如下面的例子所示。
1 | """ |
3.2 赋值运算符
赋值运算符应该是最为常见的运算符,它的作用是将右边的值赋给左边的变量。赋值运算符还可以跟上面的算术运算符放在一起,组合成复合赋值运算符,例如:a += b
相当于a = a + b
,a *= a + 2
相当于a = a * (a + 2)
。下面的例子演示了赋值运算符和复合赋值运算符的使用。
1 | """ |
赋值运算构成的表达式本身不产生任何值,也就是说,如果你把一个赋值表达式放到print
函数中试图输出表达式的值,将会产生语法错误。为了解决这个问题,Python 3.8 中引入了一个新的赋值运算符:=
,我们称之为海象运算符,大家可以猜一猜它为什么叫这个名字。海象运算符也是将运算符右侧的值赋值给左边的变量,与赋值运算符不同的是,运算符右侧的值也是整个表达式的值,看看下面的代码大家就明白了。
- 这里给出一个海象运算符的详细介绍文档:Python 海象运算符
1 | """ |
提示:上面第 8 行代码如果不注释掉,运行代码会看到
SyntaxError: invalid syntax
错误信息,注意,这行代码中我们给a = 10
加上了圆括号,如果不小心写成了print(a = 10)
,会看到TypeError: 'a' is an invalid keyword argument for print()
错误信息,后面讲到函数的时候,大家就会明白这个错误提示是什么意思了。
这里给出一个普通赋值运算符和海象运算符实现密码输入检验程序的对比:
1 | """ |
1 | """ |
3.3 比较运算符和逻辑运算符
比较运算符也称为关系运算符,包括==
、!=
、<
、>
、<=
、>=
,我相信大家一看就能懂。需要提醒的是比较相等用的是==
,请注意这里是两个等号,因为=
是赋值运算符,我们在上面刚刚讲到过。比较不相等用的是!=
,跟数学课本中使用的并不相同,Python 2 中曾经使用过<>
来表示不等于,在 Python 3 中使用<>
会引发SyntaxError
(语法错误)。比较运算符会产生布尔值,要么是True
,要么是False
。
逻辑运算符有三个,分别是and
、or
和not
。and
字面意思是"而且",所以and
运算符会连接两个布尔值或者产生布尔值的表达式,如果两边的布尔值都是True
,那么运算的结果就是True
;左右两边的布尔值有一个是False
,最终的运算结果就是False
。注意,如果and
运算符左边的布尔值是False
,不管右边的布尔值是什么,最终的结果都是False
,这时运算符右边的布尔值会被跳过(专业的说法叫短路处理,如果and
右边是一个表达式,那么这个表达式不会执行)。or
字面意思是"或者",所以or
运算符也会连接两个布尔值或产生布尔值的表达式,如果两边的布尔值有任意一个是True
,那么最终的结果就是True
。注意,or
运算符也是有短路功能的,当它左边的布尔值为True
的情况下,右边的布尔值会被短路(如果or
右边是一个表达式,那么这个表达式不会执行)。not
运算符的后面可以跟一个布尔值,如果not
后面的布尔值或表达式是True
,那么运算的结果就是False
;如果not
后面的布尔值或表达式是False
,那么运算的结果就是True
。
1 | """ |
说明:比较运算符的优先级高于赋值运算符,所以上面的
flag0 = 1 == 1
先做1 == 1
产生布尔值True
,再将这个值赋值给变量flag0
。,
进行分隔,输出的内容默认以空格分开。
思考以下问题并与结果对比:
1 | """ |
1 | """ |
这里在测试过程中发现我们还需要详细学习一下 and
和 or
返回值规则:
and
返回值规则:- 如果 左操作数为假值 → 直接返回左操作数
- 如果 左操作数为真值 → 直接返回右操作数(无论右操作数的真假)
or
返回值规则:- 如果 左操作数为真值 → 直接返回左操作数
- 如果 左操作数为假值 → 直接返回右操作数(无论右操作数的真假)
1 | """ |
3.4 运算符和表达式应用
例子1:华氏温度转摄氏温度
要求:输入华氏温度将其转换为摄氏温度,华氏温度到摄氏温度的转换公式为: 。
1 | """ |
说明:上面代码中的
input
函数用于从键盘接收用户输入,由于输入的都是字符串,如果想处理成浮点小数来做后续的运算,可以用我们上一课讲解的类型转换的方法,用float
函数将str
类型处理成float
类型。
上面的代码中,我们对print
函数输出的内容进行了格式化处理,print
输出的字符串中有两个%.1f
占位符,这两个占位符会被%
之后的(f, c)
中的两个float
类型的变量值给替换掉,浮点数小数点后保留1位有效数字。如果字符串中有%d
占位符,那么我们会用int
类型的值替换掉它,如果字符串中有%s
占位符,那么它会被str
类型的值替换掉。
除了上面格式化输出的方式外,Python 中还可以用下面的办法来格式化输出,我们给出一个带占位符的字符串,字符串前面的f
表示这个字符串是需要格式化处理的,其中的{f:.1f}
和{c:.1f}
可以先看成是{f}
和{c}
,表示输出时会用变量f
和变量c
的值替换掉这两个占位符,后面的:.1f
表示这是一个浮点数,小数点后保留1位有效数字。
1 | """ |
例子2:计算圆的周长和面积
要求:输入一个圆的半径(),计算出它的周长( )和面积( )。
1 | """ |
Python 中有一个名为math
的内置模块,该模块中定义了名为pi
的变量,它的值就是圆周率。如果要使用 Python 内置的这个pi
,我们可以对上面的代码稍作修改。
1 | """ |
说明:上面代码中的
import math
表示导入math
模块,导入该模块以后,才能用math.pi
得到圆周率的值。
这里其实还有一种格式化输出的方式,是 Python 3.8 中增加的新特性,大家直接看下面的代码就明白了。
1 | """ |
说明:假如变量
a
的值是9.87
,那么字符串f'{a = }'
的值是a = 9.87
;而字符串f'{a = :.1f}'
的值是a = 9.9
。这种格式化输出的方式会同时输出变量名和变量值。
例子3:判断闰年
要求:输入一个 1582 年以后的年份,判断该年份是不是闰年。
1 | """ |
说明:对于格里历(Gregorian calendar),即今天我们使用的公历,判断闰年的规则是:1. 公元年份非 4 的倍数是平年;2. 公元年份为 4 的倍数但非 100 的倍数是闰年;3. 公元年份为 400 的倍数是闰年。格里历是由教皇格里高利十三世在 1582 年 10 月引入的,作为对儒略历(Julian calendar)的修改和替代,我们在输入年份时要注意这一点。上面的代码通过
%
来判断year
是不是4
的倍数、100
的倍数、400
的倍数,然后用and
和or
运算符将三个条件组装在一起,前两个条件要同时满足,第三个条件跟前两个条件的组合只需满足其中之一。
Day2
1 分支结构
迄今为止,我们写的 Python 程序都是一条一条语句按顺序向下执行的,这种代码结构叫做顺序结构。然而仅有顺序结构并不能解决所有的问题,比如我们设计一个游戏,游戏第一关的过关条件是玩家获得 1000 分,那么在第一关完成后,我们要根据玩家得到的分数来决定是进入第二关,还是告诉玩家"Game Over"(游戏结束)。在这种场景下,我们的代码就会产生两个分支,而且只有一个会被执行。类似的场景还有很多,我们将这种结构称之为"分支结构"或"选择结构"。给大家一分钟的时间,你应该可以想到至少 5 个以上类似的例子,赶紧试一试吧!
1.1 使用 if
和 else
构造分支结构
在 Python 中,构造分支结构最常用的是if
、elif
和else
三个关键字。所谓关键字就是编程语言中有特殊含义的单词,很显然你不能够使用它作为变量名。当然,我们并不是每次构造分支结构都会把三个关键字全部用上,我们通过例子加以说明。例如我们要写一个身体质量指数(BMI)的计算器。身体质量质数也叫体质指数,是国际上常用的衡量人体胖瘦程度以及是否健康的一个指标,计算公式如下所示。通常认为 BMI 值低于 18.5 为偏瘦,18.5~24.9 为正常,25~29.9 为偏胖,30 以上为肥胖。
说明:上面公式中的体重以千克(kg)为单位,身高以米(m)为单位。
1 | """ |
提示:
if
语句的最后面有一个:
,它是用英文输入法输入的冒号;程序中输入的'
、"
、=
、(
、)
等特殊字符,都是在英文输入法状态下输入的,这一点之前已经提醒过大家了。很多初学者经常会忽略这一点,等到执行代码时,就会看到一大堆错误提示。当然,认真读一下错误提示还是很容易发现哪里出了问题,但是强烈建议大家在写代码的时候切换到英文输入法,这样可以避免很多不必要的麻烦。
上面的代码中,if
后面的条件判断语句就是bmi < 18.5
,如果条件成立,那么就执行if
和else
之间的代码,否则就执行else
和if
之间的代码。if
和else
之间的代码又称为分支体,它也是由一条或多条语句组成的代码块。if
和else
之间的代码块需要用缩进的方式表示出来,缩进方式有两种,一种是空格,一种是Tab。空格和 Tab 之间是有区别的,空格是等宽字符,Tab 是等高字符,所以它们之间不能混用。在 Python 中,缩进是语法的一部分,如果缩进不正确,那么程序就会报错。
1.2 使用 match
和 case
构造分支结构
Python 3.10 中增加了一种新的构造分支结构的方式,通过使用match
和case
关键字,我们可以轻松的构造出多分支结构。Python 的官方文档在介绍这个新语法时,举了一个 HTTP 响应状态码识别的例子(根据 HTTP 响应状态输出对应的描述),非常有意思。如果不知道什么是 HTTP 响应状态吗,可以看看 MDN 上面的下面我们对官方文档上的示例稍作修改,为大家讲解这个语法,先看看下面用if-else
结构实现的代码。
1 | """ |
下面是使用match-case
语法实现的代码,虽然作用完全相同,但是代码显得更加简单优雅。
1 | """ |
说明:带有
_
的case
语句在代码中起到通配符的作用,如果前面的分支都没有匹配上,代码就会来到case _
。case _
的是可选的,并非每种分支结构都要给出通配符选项。如果分支中出现了case _
,它只能放在分支结构的最后面,如果它的后面还有其他的分支,那么这些分支将是不可达的。
当然,match-case
语法还有很多高级玩法,比如可以匹配多个值,或者匹配一个范围,或者匹配一个模式,或者匹配一个类型,或者匹配一个函数,或者匹配一个对象,或者匹配一个表达式,等等。下面是一个匹配多个值的例子。
1 | """ |
1.3 分支结构的应用
1.3.1 示例1:分段函数求值
要求:编写一个程序,输入一个数,输出这个数在分段函数中的值。分段函数如下:
1 | """ |
根据实际开发的需要,分支结构是可以嵌套的,也就是说在分支结构的if
、elif
或else
代码块中还可以再次引入分支结构。例如if
条件成立表示玩家过关,但过关以后还要根据你获得宝物或者道具的数量对你的表现给出评价(比如点亮一颗、两颗或三颗星星),那么我们就需要在if
的内部再构造一个新的分支结构。同理,我们在elif
和else
中也可以构造新的分支,我们称之为嵌套的分支结构。按照这样的思路,上面的分段函数求值也可以用下面的代码来实现。
1 | """ |
1.3.2 示例2:百分制成绩转等级制成绩
要求:编写一个程序,根据输入的百分制成绩输出对应的等级。等级划分标准如下:
成绩 | 等级 |
---|---|
90-100 | A |
80-89 | B |
70-79 | C |
60-69 | D |
0-59 | E |
1 | """ |
1.3.3 示例3:输入三条边长,如果能构成三角形就计算周长和面积
要求:输入三条边长,如果能构成三角形就计算周长和面积。
1 | """ |
2 循环结构
我们在写程序的时候,极有可能遇到需要重复执行某条或某些指令的场景,例如我们需要每隔1秒钟在屏幕上输出一次"hello, world"并持续输出一个小时。如下所示的代码可以完成一次这样的操作,如果要持续输出一个小时,我们就需要把这段代码写3600遍,你愿意这么做吗?
1 | """ |
说明:上面的代码中,我们使用了
time
模块的sleep
函数,它可以让程序暂停1秒。
为了应对上述场景中的问题,我们可以在 Python 程序中使用循环结构。所谓循环结构,就是程序中控制某条或某些指令重复执行的结构。有了这样的结构,刚才的代码就不需要写 3600 遍,而是写一遍然后放到循环结构中重复 3600 次。在 Python 语言中构造循环结构有两种做法,一种是for-in
循环,另一种是while
循环。
2.1 使用 for-in
循环
如果明确知道循环执行的次数,我们推荐使用for-in循环,例如上面说的那个重复 3600 次的场景,我们可以用下面的代码来实现。 注意,被for-in循环控制的代码块也是通过缩进的方式来构造,这一点跟分支结构中构造代码块的做法是一样的。我们被for-in循环控制的代码块称为循环体,通常循环体中的语句会根据循环的设定被重复执行。
1 | """ |
需要说明的是,上面代码中的range(3600)
可以构造出一个从0到3599的范围,当我们把这样一个范围放到for-in
循环中,就可以通过前面的循环变量i
依次取出从0到3599的整数,这就会让for-in
代码块中的语句重复 3600 次。当然,range
的用法非常灵活,下面的清单给出了使用range
函数的例子:
range(101)
:可以用来产生0到100范围的整数,需要注意的是取不到101。range(1, 101)
:可以用来产生1到100范围的整数,相当于是左闭右开的设定,即[1, 101)。range(1, 101, 2)
:可以用来产生1到100的奇数,其中2是步长(跨度),即每次递增的值,101取不到。range(100, 0, -2)
:可以用来产生100到1的偶数,其中-2是步长(跨度),即每次递减的值,0取不到。
大家可能已经注意到了,上面代码的输出操作和休眠操作都没有用到循环变量i
,对于不需要用到循环变量的for-in
循环结构,按照 Python 的编程惯例,我们通常把循环变量命名为_
,修改后的代码如下所示。虽然结果没什么变化,但是这样写显得你更加专业,逼格瞬间拉满。
1 | """ |
下面,我们用for-in
循环实现从 1 到 100 的整数求和,即。
1 | """ |
我们再来写一个求取从 1 到 100 的偶数之和的程序。
1 | """ |
当然, 更为简单的办法是使用 Python 内置的sum
函数求和,这样我们连循环结构都省掉了。
1 | """ |
2.2 使用 while
循环
如果要构造循环结构但是又不能确定循环重复的次数,我们推荐使用while
循环。while
循环通过布尔值或能产生布尔值的表达式来控制循环,当布尔值或表达式的值为True
时,循环体(while
语句下方保持相同缩进的代码块)中的语句就会被重复执行,当表达式的值为False
时,结束循环。
下面我们用while
循环来实现从 1 到 100 的偶数求和,代码如下所示。
1 | """ |
这里需要注意,我们根据 num_lst
列表来观察中间变量 i
的值,它从 0 开始,每次循环都增加 2,当 i
的值大于 100 时,循环结束。这里也能发现,在循环外围我们设置 i
的初始值为 0,而在 num_lst
列表中,第一个元素也为0,说明 while
循环是先将起始值赋值给中间变量,再进行条件判断。
相较于for-in
循环,上面的代码我们在循环开始前增加了一个变量i
,我们使用这个变量来控制循环,所以while
后面给出了i <= 100
的条件。在while
的循环体中,我们除了做累加,还需要让变量i
的值递增,所以我们添加了i += 2
这条语句,这样i
的值就会依次取到0、2、4、……,直到 102。当i
变成 102 时,while
循环的条件不再成立,代码会离开while
循环,此时我们输出变量sum_even
的值,它就是从 1 到 100 偶数求和的结果 2550。
2.3 break
和 continue
语句
在循环结构中,我们还可以使用break
和continue
两个关键字来控制循环的流程。其中,break
用于终止循环,而continue
用于跳过当前循环的剩余语句,直接进入下一次循环。
如果把while
循环的条件设置为True
,即让条件恒成立会怎么样呢?我们看看下面的代码,还是使用while
构造循环结构,计算 1 到 100 的偶数和。
1 | """ |
上面的代码中使用while True
构造了一个条件恒成立的循环,也就意味着如果不做特殊处理,循环是不会结束的,这就是我们常说的"死循环"。为了在i
的值超过 100 后让循环停下来,我们使用了break
关键字,它的作用是终止循环结构的执行。需要注意的是,break
只能终止它所在的那个循环,这一点在使用嵌套循环结构时需要引起注意,后面我们会讲到什么是嵌套的循环结构。除了break
之外,还有另一个在循环结构中可以使用的关键字continue
,它可以用来放弃本次循环后续的代码直接让循环进入下一轮,代码如下所示。
1 | """ |
说明:上面的代码中,我们使用
continue
关键字来跳过奇数的累加。
2.4 嵌套的循环结构
和分支结构一样,循环结构也是可以嵌套的,也就是说在循环结构中还可以构造循环结构。下面的例子演示了如何通过嵌套的循环来输出一个乘法口诀表(九九表)。
1 | """ |
上面的代码中,for-in
循环的循环体中又用到了for-in
循环,外面的循环用来控制产生i
行的输出,而里面的循环则用来控制在一行中输出j
列。显然,里面的for-in
循环的输出就是乘法口诀表中的一整行。所以在里面的循环完成时,我们用了一个print()
来实现换行的效果,让下面的输出重新另起一行。
2.5 循环结构的应用
2.5.1 练习题1:判断素数
要求:输入一个正整数判断是不是素数。
提示:素数指的是只能被 1 和自身整除的大于 1 的整数。例如对于正整数n
,我们可以通过在2
到n-1
之间寻找有没有n
的因子,来判断它到底是不是一个素数。当然,循环不用从2
开始到n-1
结束,因为对于大于 1 的正整数,因子应该都是成对出现的,所以循环到 就可以结束了。
1 | """ |
说明:上面的代码中我们用了布尔型的变量
is_prime
,我们先将它赋值为True
,假设num
是一个素数;接下来,我们在2
到num ** 0.5
的范围寻找num
的因子,如果找到了num
的因子,那么它一定不是素数,此时我们将is_prime
赋值为False
,同时使用break
关键字终止循环结构;最后,我们根据is_prime
的值是True
还是False
来给出不同的输出。
2.5.2 练习题2:最大公约数
要求:输入两个正整数,计算它们的最大公约数。
提示:两个数的最大公约数是两个数的公共因子中最大的那个。例如,12
和18
的最大公约数是6
,因为12
和18
的公共因子有1
、2
、3
、6
,其中最大的因子是6
。
1 | """ |
用上面代码的找最大公约数在执行效率是有问题的。假如a
的值是999999999998
,b
的值是999999999999
,很显然两个数是互质的,最大公约数为 1。但是我们使用上面的代码,循环会重复999999999998
次,这通常是难以接受的。我们可以使用欧几里得算法来找最大公约数,它能帮我们更快的得到想要的结果,整体数学原理见补充材料。代码如下所示:
1 | """ |
2.5.3 补充材料:欧几里得算法最大公约数原理的严格证明
欧几里得算法(也称辗转相除法)用于求解两个正整数 和 的最大公约数(),其核心原理基于以下恒等式:
其中 表示 除以 的余数(记作 ),满足 且 ( 为商)。下面提供一个完整的数学证明,分为两部分:正确性证明(即该恒等式成立)和终止性证明(即算法在有限步内结束)。
正确性证明
设 和 是正整数,且 (如果 ,交换两者不影响结果)。定义 ,则有:
需要证明:
证明:
-
设 。
则 ( 整除 )) 且 。
由 (因为 ),可得:(因为如果 和 ,则 整除它们的线性组合 )。
因此, 且 ,所以 是 和 的一个公约数。 -
设 。
则 且 。
由 ,可得:(因为如果 和 ,则 整除它们的线性组合 )。
因此, 且 ,所以 是 和 的一个公约数。 -
比较 和 :
- 因为 是 和 的最大公约数,且 是 和 的公约数,故 。
- 因为 是 和 的最大公约数,且 是 和 的公约数(由步骤 1),故 。
结合两者,有 ,即:
结论: 对任意正整数 和 ,$$\gcd(a, b) = \gcd(b, a \mod b)$$ 成立。
终止性证明
欧几里得算法通过反复应用恒等式 $$\gcd(a, b) = \gcd(b, r)$$ 逐步减小问题规模,直到余数为 0。算法过程如下:
- 初始化:设 , (假设 )。
- 迭代:对于第 步(),计算:
- 当某步余数 时停止,此时 $$\gcd(a, b) = r_{n-1}$$。
证明算法在有限步内终止:
- 在每一步迭代中,定义余数序列 ,其中:
- (初始余数),
- 后续 。
- 由余数定义,满足 (当 时)。
- 因此,序列 是非负整数序列,并严格递减:
- 因为序列递减且非负(所有 )),所以余数每次至少减少 1,序列必定在有限步内达到 0。
- 设迭代次数为 ,则当 时,有:
(因为 对于正整数 成立)。
说明: 最坏情况下,算法步数由斐波那契数列控制,但余数序列长度上限为 ,确保效率。
2.5.3 练习题3:猜数字游戏
要求:计算机出一个1到100之间的随机数,玩家输入自己猜的数字,计算机给出对应的提示信息(大一点、小一点或猜对了),如果玩家猜中了数字,计算机提示"恭喜你,猜对了!"并结束游戏;如果玩家没有猜中数字,计算机提示"游戏结束,游戏结束!"并结束游戏。
提示:计算机随机生成一个1到100之间的整数,玩家输入自己猜的数字,计算机给出对应的提示信息(大一点、小一点或猜对了),如果玩家猜中了数字,计算机提示"恭喜你,猜对了!"并结束游戏;如果玩家没有猜中数字,计算机提示"游戏结束,游戏结束!"并结束游戏。
1 | """ |
3 分支和循环结构实战
3.1 实战1:100以内的素数
要求:输出100以内的素数。
说明:素数指的是只能被 1 和自身整除的正整数(不包括 1),之前我们写过判断素数的代码,这里相当于是一个升级版本。
1 | """ |
3.2 实战2:斐波那契数列
要求:输出斐波那契数列中的前 20 个数。
说明:斐波那契数列(Fibonacci sequence),通常也被称作黄金分割数列,是意大利数学家莱昂纳多·斐波那契(Leonardoda Fibonacci)在《计算之书》中研究理想假设条件下兔子成长率问题而引入的数列,因此这个数列也常被戏称为“兔子数列”。斐波那契数列的特点是数列的前两个数都是 1,从第三个数开始,每个数都是它前面两个数的和。按照这个规律,斐波那契数列的前 10 个数是:1, 1, 2, 3, 5, 8, 13, 21, 34, 55
。斐波那契数列在现代物理、准晶体结构、化学等领域都有直接的应用。
1 | """ |
3.3 实战3:寻找水仙花数
要求:找出 100 到 999 范围内的所有水仙花数。
提示:在数论中,水仙花数(narcissistic number)也被称为超完全数字不变数、自恋数、自幂数、阿姆斯特朗数,它是一个 位非负整数,其各位数字的 次方和刚好等于该数本身,例如:,所以 153 是一个水仙花数;,所以 1634 也是一个水仙花数。对于三位数,解题的关键是将它拆分为个位、十位、百位,再判断是否满足水仙花数的要求,这一点利用 Python 中的//
和%
运算符其实很容易做到。
1 | """ |
3.4 实战4:不知位数的正整数反转
要求:将一个正整数反转。
提示:将一个正整数反转,例如:123456变成654321。
1 | """ |
3.5 实战5:百钱百鸡问题
要求:百钱百鸡是我国古代数学家张丘建在《算经》一书中提出的数学问题:鸡翁一值钱五,鸡母一值钱三,鸡雏三值钱一。百钱买百鸡,问鸡翁、鸡母、鸡雏各几何?
提示:设鸡翁、鸡母、鸡雏的个数分别为 、、,则 、、 满足以下条件:
- 、、 都是正整数
1 | """ |
上面使用的方法叫做穷举法,也称为暴力搜索法,这种方法通过一项一项的列举备选解决方案中所有可能的候选项,并检查每个候选项是否符合问题的描述,最终得到问题的解。上面的代码中,我们使用了嵌套的循环结构,假设公鸡有x
只,显然x
的取值范围是 0 到 20,假设母鸡有y
只,它的取值范围是 0 到 33,假设小鸡有z
只,它的取值范围是 0 到 99 且取值是 3 的倍数。这样,我们设置好 100 只鸡的条件x + y + z == 100
,设置好 100 块钱的条件5 * x + 3 * y + z // 3 == 100
,当两个条件同时满足时,就是问题的正确答案,我们用print
函数输出它。这种方法看起来比较笨拙,但对于运算能力非常强大的计算机来说,通常都是一个可行的甚至是不错的选择,只要问题的解存在就能够找到它。
事实上,上面的代码还有更好的写法,既然我们已经假设公鸡有x
只,母鸡有y
只,那么小鸡的数量就应该是100 - x - y
,这样减少一个条件,我们就可以把上面三层嵌套的for-in
循环改写为两层嵌套的for-in
循环。循环次数减少了,代码的执行效率就有了显著的提升,如下所示。
1 | """ |
3.6 实战6:CRAPS赌博游戏
说明:CRAPS又称花旗骰,是美国拉斯维加斯非常受欢迎的一种的桌上赌博游戏。该游戏使用两粒骰子,玩家通过摇两粒骰子获得点数进行游戏。简化后的规则是:玩家第一次摇骰子如果摇出了 7 点或 11 点,玩家胜;玩家第一次如果摇出 2 点、3 点或 12 点,庄家胜;玩家如果摇出其他点数则游戏继续,玩家重新摇骰子,如果玩家摇出了 7 点,庄家胜;如果玩家摇出了第一次摇的点数,玩家胜;其他点数玩家继续摇骰子,直到分出胜负。为了增加代码的趣味性,我们设定游戏开始时玩家有 1000 元的赌注,每局游戏开始之前,玩家先下注,如果玩家获胜就可以获得对应下注金额的奖励,如果庄家获胜,玩家就会输掉自己下注的金额。游戏结束的条件是玩家破产(输光所有的赌注)。
1 | """ |
3.7 实战7:完美数
要求:找出 10000 以内的完美数。
说明:完美数(Perfect number),又称完全数或完备数,是一些特殊的自然数。它所有的真因子(即除了自身以外的因子)的和(即因子函数),恰好等于它本身。例如:6()和 28()就是完美数。
1 | """ |
Day3
0 常用数据结构之列表-1
在开始本节课的内容之前,我们先给大家一个编程任务,将一颗色子掷 6000 次,统计每种点数出现的次数。这个任务对大家来说应该是非常简单的,我们可以用 1 到 6 均匀分布的随机数来模拟掷色子,然后用 6 个变量分别记录每个点数出现的次数,相信通过前面的学习,大家都能比较顺利的写出下面的代码。
1 | """ |
说明:
eval()
是 Python 的一个内置函数,作用是将字符串解析为 Python 代码并执行
上面的代码非常有多么“丑陋”相信就不用我多说了。当然,更为可怕的是,如果我们要掷两颗或者掷更多的色子,然后统计每种点数出现的次数,那就需要定义更多的变量,写更多的分支结构,大家想想都会感到恶心。讲到这里,相信大家心中已经有一个疑问了:有没有办法用一个变量来保存多个数据,有没有办法用统一的代码对多个数据进行操作?答案是肯定的,在 Python 语言中我们可以通过容器型变量来保存和操作多个数据,我们首先为大家介绍列表(list
)这种新的数据类型。
0.1 创建列表
在 Python 中,列表是由一系元素按特定顺序构成的数据序列,这就意味着如果我们定义一个列表类型的变量,可以用它来保存多个数据。在 Python 中,可以使用[]字面量语法来定义列表,列表中的多个元素用逗号进行分隔,代码如下所示。
1 | items1 = [35, 12, 99, 68, 55, 35, 87] |
说明:列表中可以有重复元素,例如
items1
中的35
;列表中可以有不同类型的元素,例如items3
中有int
类型、float
类型、str
类型和bool
类型的元素,但是我们通常并不建议将不同类型的元素放在同一个列表中,主要是操作起来极为不便。
我们可以使用type
函数来查看变量的类型,代码如下所示。
1 | items1 = [35, 12, 99, 68, 55, 35, 87] |
因为列表可以保存多个元素,它是一种容器型的数据类型,所以我们在给列表类型的变量起名字时,变量名通常用复数形式的单词。
除此以外,还可以通过 Python 内置的list
函数将其他序列变成列表。准确的说,list
并不是一个普通的函数,它是创建列表对象的构造器,后面的课程会为大家介绍对象和构造器这些概念。
1 | items1 = list(range(1, 10)) |
说明:
range(1, 10)
会产生1到9的整数序列,给到list
构造器中,会创建出由1到9的整数构成的列表。字符串是字符构成的序列,上面的list('hello')
用字符串hello的字符作为列表元素,创建了列表对象。
0.2 列表的运算
我们可以使用+
运算符实现两个列表的拼接,拼接运算会将两个列表中的元素连接起来放到一个列表中,代码如下所示。
1 | items5 = [35, 12, 99, 45, 66] |
我们可以使用*
运算符实现列表的重复运算,*
运算符会将列表元素重复指定的次数,如下所示。
1 | items8 = ['hello'] * 3 |
我们可以使用in
或not in
运算符判断一个元素在不在列表中,如下所示。
1 | items9 = ['Python', 'Java', 'Go', 'Kotlin'] |
由于列表中有多个元素,而且元素是按照特定顺序放在列表中的,所以当我们想操作列表中的某个元素时,可以使用[]
运算符,通过在[]
中指定元素的位置来访问该元素,这种运算称为索引运算。需要说明的是,[]
的元素位置可以是0到N - 1的整数,也可以是-1到-N的整数,分别称为正向索引和反向索引,其中N代表列表元素的个数。对于正向索引,[0]
可以访问列表中的第一个元素,[N - 1]
可以访问最后一个元素;对于反向索引,[-1]
可以访问列表中的最后一个元素,[-N]
可以访问第一个元素,代码如下所示。
1 | items8 = ['apple', 'waxberry', 'pitaya', 'peach', 'watermelon'] |
在使用索引运算的时候要避免出现索引越界的情况,对于上面的items8
,如果我们访问items8[5]
或items8[-6]
,就会引发IndexError
错误,导致程序崩溃,对应的错误信息是:list index out of range
,翻译成中文就是“数组索引超出范围”。因为对于只有五个元素的列表items8
,有效的正向索引是0到4,有效的反向索引是-1到-5。
如果希望一次性访问列表中的多个元素,我们可以使用切片运算。切片运算是形如[start:end:stride]
的运算符,其中start
代表访问列表元素的起始位置,end
代表访问列表元素的终止位置(终止位置的元素无法访问),而stride
则代表了跨度,简单的说就是位置的增量,比如我们访问的第一个元素在start
位置,那么第二个元素就在start + stride
位置,当然start + stride
要小于end
。如下代码展示了如何使用切片运算符访问列表元素。
1 | items8 = ['apple', 'waxberry', 'pitaya', 'peach', 'watermelon'] |
如果start
值等于0,那么在使用切片运算符时可以将其省略;如果end
值等于N
,N
代表列表元素的个数,那么在使用切片运算符时可以将其省略;如果stride
值等于1,那么在使用切片运算符时也可以将其省略。
事实上,我们还可以通过切片操作修改列表中的元素,例如如下代码:
1 | items8 = ['apple', 'waxberry', 'pitaya', 'peach', 'watermelon'] |
两个列表还可以做关系运算,我们可以比较两个列表是否相等,也可以给两个列表比大小,代码如下所示。
1 | nums1 = [1, 2, 3, 4] |
说明:上面的
nums1
和nums2
对应元素完全相同,所以==
运算的结果是True
。nums2
和nums3
的比较,由于nums2
的第一个元素1
小于nums3
的第一个元素3
,所以nums2 >= nums3
比较的结果是False
。两个列表的关系运算在实际工作并不那么常用,如果实在不理解就跳过吧,不用纠结。
0.3 列表的遍历
如果想逐个取出列表中的元素,可以使用for-in
循环的遍历,有以下两种做法。
方法一:在循环结构中通过索引运算,遍历列表元素。
1 | languages = ['Python', 'Java', 'C++', 'Kotlin'] |
方法二:直接遍历列表元素。
1 | languages = ['Python', 'Java', 'C++', 'Kotlin'] |
0.4 总结
讲到这里,我们可以用列表的知识来重构上面“掷色子统计每种点数出现次数”的代码。
1 | """ |
1 常用数据结构之列表-2
1.1 列表的方法
列表类型的变量拥有很多方法可以帮助我们操作一个列表,假设我们有名为foos
的列表,列表有名为bar
的方法,那么使用列表方法的语法是:foos.bar()
,这是一种通过对象引用调用对象方法的语法。后面我们讲面向对象编程的时候,还会对这种语法进行详细的讲解,这种语法也称为给对象发消息。
列表的常用方法有:
append
:在列表的末尾添加一个元素。insert
:在列表的指定位置插入一个元素。remove
:从列表中删除一个元素。clear
:清空列表。reverse
:反转列表。sort
:对列表进行排序。
1.1.1 添加和删除元素
列表是一种可变容器,可变容器指的是我们可以向容器中添加元素、可以从容器移除元素,也可以修改现有容器中的元素。我们可以使用列表的append
方法向列表中追加元素,使用insert
方法向列表中插入元素。追加指的是将元素添加到列表的末尾,而插入则是在指定的位置添加新元素,大家可以看看下面的代码。
1 | languages = ['Python', 'C++', 'Ruby', 'Go'] |
我们可以用列表的remove
方法从列表中删除指定元素,需要注意的是,如果要删除的元素并不在列表中,会引发ValueError
错误导致程序崩溃,所以建议大家在删除元素时,先用之前讲过的成员运算做一个判断。我们还可以使用pop
方法从列表中删除元素,pop
方法默认删除列表中的最后一个元素,当然也可以给一个位置,删除指定位置的元素。在使用pop
方法删除元素时,如果索引的值超出了范围,会引发IndexError
异常,导致程序崩溃。除此之外,列表还有一个clear
方法,可以清空列表中的元素,代码如下所示。
1 | languages = ["Python", "Java", "C++", "C#", "JavaScript", "PHP", "Go", "Rust"] |
说明:
remove
方法在删除元素时,如果列表中存在多个相同的元素,那么只会删除第一个元素;pop
方法在删除元素时,如果给定了索引,那么会删除指定位置的元素,并且返回被删除的元素,如果给定的索引不存在,会引发IndexError
异常;clear
方法会清空列表中的元素,但是不会删除列表对象,所以列表对象仍然存在。
1.1.2 元素位置和频次
列表的index
方法可以查找某个元素在列表中的索引位置,如果找不到指定的元素,index
方法会引发ValueError
错误;列表的count
方法可以统计一个元素在列表中出现的次数,代码如下所示。
1 | languages = ['Python', 'Java', 'C++', 'Kotlin', 'Python', 'Go'] |
1.1.3 元素排序和反转
列表的sort
操作可以实现列表元素的排序,而reverse
操作可以实现元素的反转,代码如下所示。
1 | languages = ['Python', 'Java', 'C++', 'Kotlin', 'Python', 'Go'] |
说明:
sort
方法会直接修改列表中的元素,而reverse
方法会反转列表中的元素,这两个方法都是原地操作,不会返回新的列表,即列表的地址不会发生变化。
强调:sort
方法的排序规则是按照元素的大小进行排序,如果元素是字符串,那么会按照字符串的ASCII码进行排序。如果是数字,那么会按照数字的大小进行排序。如果列表中既有字符串又有数字,那么会引发TypeError
异常。
1.2 列表生成式
在 Python 中,列表还可以通过一种特殊的字面量语法来创建,这种语法叫做生成式。下面,我们通过例子来说明使用列表生成式创建列表到底有什么好处。
场景一:创建一个取值范围在 1 到 99 且能被 3 或者 5 整除的数字构成的列表。
1 | """ |
使用列表生成式做同样的事情,代码如下所示。
1 | """ |
场景二:有一个整数列表nums1
,创建一个新的列表nums2
,nums2
中的元素是nums1
中对应元素的平方。
1 | """ |
使用列表生成式做同样的事情,代码如下所示。
1 | """ |
场景三: 有一个整数列表nums1
,创建一个新的列表nums2
,将nums1
中大于50的元素放到nums2
中。
1 | """ |
使用列表生成式做同样的事情,代码如下所示。
1 | """ |
使用列表生成式创建列表不仅代码简单优雅,而且性能上也优于使用for-in
循环和append
方法向空列表中追加元素的方式。为什么说生成式有更好的性能呢,那是因为 Python 解释器的字节码指令中有专门针对生成式的指令(LIST_APPEND
指令);而for
循环是通过方法调用(LOAD_METHOD
和CALL_METHOD
指令)的方式为列表添加元素,方法调用本身就是一个相对比较耗时的操作。对这一点不理解也没有关系,记住“强烈建议用生成式语法来创建列表”这个结论就可以了。
1.3 嵌套列表
Python 语言没有限定列表中的元素必须是相同的数据类型,也就是说一个列表中的元素可以任意的数据类型,当然也包括列表本身。如果列表中的元素也是列表,那么我们可以称之为嵌套的列表。嵌套的列表可以用来表示表格或数学上的矩阵,例如:我们想保存5个学生3门课程的成绩,可以用如下所示的列表。
1 | scores = [[95, 83, 92], [80, 75, 82], [92, 97, 90], [80, 78, 69], [65, 66, 89]] |
对于上面的嵌套列表,每个元素相当于就是一个学生3门课程的成绩,例如[95, 83, 92],而这个列表中的83代表了这个学生某一门课的成绩,如果想访问这个值,可以使用两次索引运算scores[0][1]
,其中scores[0]
可以得到[95, 83, 92]
这个列表,再次使用索引运算[1]
就可以获得该列表中的第二个元素。
如果想通过键盘输入的方式来录入5个学生3门课程的成绩并保存在列表中,可以使用如下所示的代码。
1 | """ |
如果想通过产生随机数的方式来生成5个学生3门课程的成绩并保存在列表中,我们可以使用列表生成式,代码如下所示。
1 | """ |
说明:上面的代码
[random.randrange(60, 101) for _ in range(3)]
可以产生由3个随机整数构成的列表,我们把这段代码又放在了另一个列表生成式中作为列表的元素,这样的元素一共生成5个,最终得到了一个嵌套列表。
1.4 列表的应用
下面我们通过一个双色球随机选号的例子为大家讲解列表的应用。双色球是由中国福利彩票发行管理中心发售的乐透型彩票,每注投注号码由6个红色球和1个蓝色球组成。红色球号码从1到33中选择,蓝色球号码从1到16中选择。每注需要选择6个红色球号码和1个蓝色球号码,如下所示。
提示:知乎上有一段对国内各种形式的彩票本质的论述相当精彩,这里分享给大家:“虚构一个不劳而获的人,去忽悠一群想不劳而获的人,最终养活一批真正不劳而获的人”。很多对概率没有概念的人,甚至认为彩票中与不中的概率都是 50%;还有很多人认为如果中奖的概率是 1%,那么买 100 次就一定可以中奖,这些都是非常荒唐的想法。所以,珍爱生命,远离赌博,尤其是你对概率一无所知的情况下。
下面,我们用 Python 来模拟双色球随机选号的过程。
1 | """ |
说明:上面代码中
print(f'\033[0m...\033[0m')
是为了控制输出内容的颜色,红色球输出成红色,蓝色球输出成蓝色。其中省略号代表我们要输出的内容,\033[0m
是一个控制码,表示关闭所有属性,也就是说之前的控制码将会失效,你也可以将其简单的理解为一个定界符,m
前面的0
表示控制台的显示方式为默认值,0
可以省略,1
表示高亮,5
表示闪烁,7
表示反显等。在0
和m
的中间,我们可以写上代表颜色的数字,比如30
代表黑色,31
代表红色,32
代表绿色,33
代表黄色,34
代表蓝色等。
我们还可以利用random
模块提供的sample
和choice
函数来简化上面的代码,前者可以实现无放回随机抽样,后者可以实现随机抽取一个元素,修改后的代码如下所示。
1 | """ |
如果要实现随机生成N注号码,我们只需要将上面的代码放到一个N次的循环中,如下所示。
1 | """ |
这里顺便给大家介绍一个名为 rich
的 Python 三方库,它可以帮助我们用最简单的方式产生最漂亮的输出,你可以在终端中使用 Python 包管理工具 pip
来安装这个三方库。
1 | pip install rich |
安装好 rich
库之后,我们就可以使用它来美化我们的输出,如下所示。
1 | """ |
说明:上面代码中
[red]...[/red]
和[blue]...[/blue]
是用来控制输出内容的颜色,rich
库中还提供了其他很多控制输出内容样式的功能,大家可以参考官方文档。
1.5 总结
Python 中的列表底层是一个可以动态扩容的数组,列表元素在计算机内存中是连续存储的,所以可以实现随机访问(通过一个有效的索引获取对应的元素且操作时间与列表元素个数无关)。我们可以暂时不去触碰这些底层的存储细节,也不需要大家理解列表每个方法的渐近时间复杂度(执行方法耗费的时间跟列表元素个数之间的关系),大家先学会用列表解决工作中的问题,我想这一点更为重要。
2 常用数据结构之元组
2.1 元组的定义和运算
在 Python 语言中,元组也是多个元素按照一定顺序构成的序列。元组和列表的不同之处在于,元组是不可变类型,这就意味着元组类型的变量一旦定义,其中的元素不能再添加或删除,而且元素的值也不能修改。如果试图修改元组中的元素,将引发TypeError
错误,导致程序崩溃。定义元组通常使用形如(x, y, z)
的字面量语法,元组类型支持的运算符跟列表是一样的,我们可以看看下面的代码。
1 | # 定义一个三元组 |
一个元组中如果有两个元素,我们就称之为二元组;一个元组中如果五个元素,我们就称之为五元组。需要提醒大家注意的是,()
表示空元组,但是如果元组中只有一个元素,需要加上一个逗号,否则()
就不是代表元组的字面量语法,而是改变运算优先级的圆括号,所以('hello', )
和(100, )
才是一元组,而('hello')
和(100)
只是字符串和整数。我们可以通过下面的代码来加以验证。
1 | a = () |
2.2 打包和解包操作
当我们把多个用逗号分隔的值赋给一个变量时,多个值会打包成一个元组类型;当我们把一个元组赋值给多个变量时,元组会解包成多个值然后分别赋给对应的变量,如下面的代码所示。
1 | # 打包操作 |
在解包时,如果解包出来的元素个数和变量个数不对应,会引发ValueError
异常,错误信息为:too many values to unpack
(解包的值太多)或not enough values to unpack
(解包的值不足)。
1 | a = 1, 10, 100, 1000 |
有一种解决变量个数少于元素的个数方法,就是使用星号表达式。通过星号表达式,我们可以让一个变量接收多个值,代码如下所示。需要注意两点:首先,用星号表达式修饰的变量会变成一个列表,列表中有0个或多个元素;其次,在解包语法中,星号表达式只能出现一次。
1 | a = 1, 10, 100, 1000 |
需要说明一点,解包语法对所有的序列都成立,这就意味着我们之前讲的列表、range函数构造的范围序列甚至字符串都可以使用解包语法。大家可以尝试运行下面的代码,看看会出现怎样的结果。
1 | a, b, *c = range(1, 10) |
2.3 交换变量的值
交换变量的值是写代码时经常用到的一个操作,但是在很多编程语言中,交换两个变量的值都需要借助一个中间变量才能做到,如果不用中间变量就需要使用比较晦涩的位运算来实现。在 Python 中,交换两个变量a
和b
的值只需要使用如下所示的代码。
1 | a, b = b, a |
同理,如果要将三个变量a
、b
、c
的值互换,即b
的值赋给a
,c
的值赋给b
,a
的值赋给c
,也可以如法炮制。
1 | a, b, c = b, c, a |
需要说明的是,上面的操作并没有用到打包和解包语法,Python 的字节码指令中有ROT_TWO
和ROT_THREE
这样的指令可以直接实现这个操作,效率是非常高的。但是如果有多于三个变量的值要依次互换,这个时候是没有直接可用的字节码指令的,需要通过打包解包的方式来完成变量之间值的交换。
2.4 元组和列表的比较
这里还有一个非常值得探讨的问题,Python 中已经有了列表类型,为什么还需要元组这样的类型呢?这个问题对于初学者来说似乎有点困难,不过没有关系,我们先抛出观点,大家可以一边学习一边慢慢体会。
- 元组是不可变类型,不可变类型更适合多线程环境,因为它降低了并发访问变量的同步化开销。关于这一点,我们会在后面讲解并发编程的时候跟大家一起探讨。
- 元组是不可变类型,通常不可变类型在创建时间上优于对应的可变类型。我们可以使用
timeit
模块的timeit
函数来看看创建保存相同元素的元组和列表各自花费的时间,timeit
函数的number
参数表示代码执行的次数。下面的代码中,我们分别创建了保存1到9的整数的列表和元组,每个操作执行10000000次,统计运行时间。
1 | import timeit |
说明:上面代码的执行结果因软硬件系统而异,在我目前使用的电脑上,执行10000000次创建列表的操作时间是0.436秒,而执行10000000次创建元组的操作时间是0.089秒,显然创建元组更快且二者时间上有数量级的差别。
当然,Python 中的元组和列表类型是可以相互转换的,我们可以通过下面的代码来完成该操作。
1 | infos = ('骆昊', 43, True, '四川成都') |
2.5 总结
列表和元组都是容器型的数据类型,即一个变量可以保存多个数据,而且它们都是按一定顺序组织元素的有序容器。列表是可变数据类型,元组是不可变数据类型,所以列表可以添加元素、删除元素、清空元素、排序反转,但这些操作对元组来说是不成立的。列表和元组都可以支持拼接运算、成员运算、索引运算、切片运算等操作,后面我们要讲到的字符串类型也支持这些运算,因为字符串就是字符按一定顺序构成的序列,在这一点上三者并没有什么区别。我们推荐大家使用列表的生成式语法来创建列表,它不仅好用而且效率很高,是 Python 语言中非常有特色的语法。
3 常用数据结构之字符串
第二次世界大战促使了现代电子计算机的诞生,世界上的第一台通用电子计算机名叫 ENIAC
(电子数值积分计算机),诞生于美国的宾夕法尼亚大学,占地167平米,重量约27吨,每秒钟大约能够完成约5000次浮点运算,如下图所示。ENIAC
诞生之后被应用于导弹弹道的计算,而数值计算也是现代电子计算机最为重要的一项功能。
随着时间的推移,虽然数值运算仍然是计算机日常工作中最为重要的组成部分,但是今天的计算机还要处理大量的以文本形式存在的信息。如果我们希望通过 Python 程序来操作本这些文本信息,就必须要先了解字符串这种数据类型以及与它相关的运算和方法。
3.1 字符串的定义
所谓字符串,就是由零个或多个字符组成的有限序列,一般记为:
在 Python 程序中,我们把单个或多个字符用单引号或者双引号包围起来,就可以表示一个字符串。字符串中的字符可以是特殊符号、英文字母、中文字符、日文的平假名或片假名、希腊字母、Emoji 字符(如:💩、🐷、🀄️)等。
1 | s1 = 'hello, world!' |
3.1.1 转义字符
我们可以在字符串中使用\
(反斜杠)来表示转义,也就是说\
后面的字符不再是它原来的意义,例如:\n
不是代表字符\
和字符n
,而是表示换行;\t
也不是代表字符\
和字符t
,而是表示制表符。所以如果字符串本身又包含了'
、"
、\
这些特殊的字符,必须要通过\
进行转义处理。例如要输出一个带单引号或反斜杠的字符串,需要用如下所示的方法。
1 | s1 = '\'hello, world!\'' |
3.1.2 原始字符串
原始字符串是字符串类型前加r
,它不会对字符串中的转义符号进行转义,例如:\n
不会被转义成换行符,而是保留原来的样子。
1 | s1 = '\it \is \time \to \read \now' |
说明:上面的变量
s1
中,\t
、\r
和\n
都是转义字符。\t
是制表符(table),\n
是换行符(new line),\r
是回车符(carriage return)相当于让输出回到了行首。对比一下两个
注意:这里由于s1
中的\i
、\t
、\r
、\n
等都被解释为转义序列,但这些转义字符大部分是非法或不可见字符,其中的\it
由于 \i
是非法的转义序列,所以\it
字符被吞掉。
3.1.3 字符的特殊表示
Python 中还允许在\
后面还可以跟一个八进制或者十六进制数来表示字符,例如\141
和\x61
都代表小写字母a
,前者是八进制的表示法,后者是十六进制的表示法。另外一种表示字符的方式是在\u
后面跟 Unicode 字符编码,例如\u9e4f\u8fdc
代表的是中文“鹏远”。运行下面的代码,看看输出了什么。
1 | print('\141') |
这里给出一个在线的在线 Unicode 编码转换,大家可以看看一些常见的中文、日文、韩文、拉丁字母等字符的 Unicode 编码。
3.2 字符串的运算
Python 语言为字符串类型提供了非常丰富的运算符,有很多运算符跟列表类型的运算符作用类似。例如,我们可以使用+
运算符来实现字符串的拼接,可以使用*
运算符来重复一个字符串的内容,可以使用in
和not in
来判断一个字符串是否包含另外一个字符串,我们也可以用[]
和[:]
运算符从字符串取出某个字符或某些字符。
3.2.1 拼接和重复
下面的例子演示了使用+
和*
运算符来实现字符串的拼接和重复操作。
1 | s1 = 'hello' * 3 |
用*
实现字符串的重复是非常有意思的一个运算符,在很多编程语言中,要表示一个有10个a
的字符串,你只能写成aaaaaaaaaa
,但是在 Python 中,你可以写成'a' * 10
。你可能觉得aaaaaaaaaa
这种写法也没有什么不方便的,但是请想一想,如果字符a
要重复100次或者1000次又会如何呢?
3.2.2 比较运算
对于两个字符串类型的变量,可以直接使用比较运算符来判断两个字符串的相等性或比较大小。需要说明的是,因为字符串在计算机内存中也是以二进制形式存在的,那么字符串的大小比较比的是每个字符对应的编码的大小。例如A
的编码是65, 而a
的编码是97,所以'A' < 'a'
的结果相当于就是65 < 97
的结果,这里很显然是True
;而'boy' < 'bad'
,因为第一个字符都是'b'
比不出大小,所以实际比较的是第二个字符的大小,显然'o' < 'a'
的结果是False
,所以'boy' < 'bad'
的结果是False
。如果不清楚两个字符对应的编码到底是多少,可以使用ord
函数来获得,之前我们有提到过这个函数。例如ord('A')
的值是65,而ord('鹏')
的值是40527。下面的代码展示了字符串的比较运算,请大家仔细看看。
1 | s1 = 'a whole new world' |
3.2.3 成员运算
Python 中可以用in
和not in
判断一个字符串中是否包含另外一个字符或字符串,跟列表类型一样,in
和not in
称为成员运算符,会产生布尔值True
或False
,代码如下所示。
1 | s1 = 'hello, world!' |
3.2.4 获取字符串长度
获取字符串长度跟获取列表元素个数一样,使用内置函数len
,代码如下所示。
1 | s1 = 'hello, world!' |
说明:这里逗号和空格也是字符,所以字符串的长度是13。
3.2.5 索引和切片
字符串的索引和切片操作跟列表、元组几乎没有区别,因为字符串也是一种有序序列,可以通过正向或反向的整数索引访问其中的元素。但是有一点需要注意,因为字符串是不可变类型,所以不能通过索引运算修改字符串中的字符。
1 | s = 'abc123456' |
注意:需要再次提醒大家注意的是,在进行索引运算时,如果索引越界,会引发
IndexError
异常,错误提示信息为:string index out of range
(字符串索引超出范围)。
3.3 字符的遍历
如果希望遍历字符串中的每个字符,可以使用for-in
循环,有如下所示的两种方式。
方式一:
1 | s = 'hello' |
1 | s = 'hello' |
3.4 字符串的常用方法
在 Python 中,我们可以通过字符串类型自带的方法对字符串进行操作和处理,假设我们有名为foo
的字符串,字符串有名为bar
的方法,那么使用字符串方法的语法是:foo.bar()
,这是一种通过对象引用调用对象方法的语法,跟前面使用列表方法的语法是一样的。
3.4.1 大小写相关操作
下面的代码演示了和字符串大小写变换相关的方法。
1 | s1 = 'hello, world!' |
说明:由于字符串是不可变类型,使用字符串的方法对字符串进行操作会产生新的字符串,但是原来变量的值并没有发生变化。所以上面的代码中,当我们最后检查
s1
和s2
两个变量的值时,s1
和s2
的值并没有发生变化。
3.4.2 查找操作
如果想在一个字符串中从前向后查找有没有另外一个字符串,可以使用字符串的find
或index
方法。在使用find
和index
方法时还可以通过方法的参数来指定查找的范围,也就是查找不必从索引为0的位置开始。
1 | s = 'hello, world!9' |
说明:
find
返回值是字符串中要查找的字符串的第一个字符的索引,如果字符串中不存在要查找的字符串,则返回-1
,index
方法在字符串中找不到要查找的字符串时会引发ValueError
异常。
注意:find(sub, start)
中的start
表示从哪个索引位置开始查找(包括这个位置)。如果从这个位置之后找不到sub
,就会返回-1
,而index(sub, start)
中的start
表示从哪个索引位置开始查找(包括这个位置)。如果从这个位置之后找不到sub
,就会引发ValueError
异常。
这里给出两张图,帮助大家理解find
和index
方法的参数和返回值。
find
和index
方法还有逆向查找(从后向前查找)的版本,分别是rfind
和rindex
,代码如下所示。
1 | s = 'hello, world!' |
说明:
rfind
和rindex
都支持三个参数(sub, start, end)
,表示在s[start:end]
中从右向左查找,但结果仍然是原始字符串中的索引位置。
注意:这里比如s.rfind('or',0,9)
,表示在s[0:9]
中从右向左查找or
,这里的索引范围是0
到8
, 对应范围是开区间。
这里给出两张图,帮助大家理解rfind
和rindex
方法的参数和返回值。
3.4.3 性质判断
可以通过字符串的startswith
、endswith
来判断字符串是否以某个字符串开头和结尾;还可以用is
开头的方法判断字符串的特征,这些方法都返回布尔值,代码如下所示。
1 | s1 = 'hello, world!' |
说明:上面的
isdigit
用来判断字符串是不是完全由数字构成的,isalpha
用来判断字符串是不是完全由字母构成的,这里的字母指的是 Unicode 字符但不包含 Emoji 字符,isalnum
用来判断字符串是不是由字母和数字构成的。
3.4.4 格式化
在 Python 中,字符串类型可以通过center
、ljust
、rjust
方法做居中、左对齐和右对齐的处理。如果要在字符串的左侧补零,也可以使用zfill
方法。
1 | s1 = 'hello, world!' |
我们之前讲过,在用print
函数输出字符串时,可以用下面的方式对字符串进行格式化。
1 | a = 321 |
当然,我们也可以用字符串的format
方法来完成字符串的格式化,代码如下所示。
1 | a = 321 |
从 Python 3.6 开始,格式化字符串还有更为简洁的书写方式,就是在字符串前加上f
来格式化字符串,在这种以f
打头的字符串中,{变量名}
是一个占位符,会被变量对应的值将其替换掉,代码如下所示。
1 | a = 321 |
如果需要进一步控制格式化语法中变量值的形式,可以参照下面的表格来进行字符串格式化操作。
变量值 | 占位符 | 格式化结果 | 说明 |
---|---|---|---|
3.1415926 |
{:.2f} |
'3.14' |
保留小数点后两位 |
3.1415926 |
{:+.2f} |
'+3.14' |
带符号保留小数点后两位 |
-1 |
{:+.2f} |
'-1.00' |
带符号保留小数点后两位 |
3.1415926 |
{:.0f} |
'3' |
不带小数 |
123 |
{:0>10d} |
'0000000123' |
左边补0 ,补够10位 |
123 |
{:x<10d} |
'123xxxxxxx' |
右边补x ,补够10位 |
123 |
{:>10d} |
' 123' |
左边补空格,补够10位 |
123 |
{:<10d} |
'123 ' |
右边补空格,补够10位 |
123456789 |
{:,} |
'123,456,789' |
逗号分隔格式 |
0.123 |
{:.2%} |
'12.30%' |
百分比格式 |
123456789 |
{:.2e} |
'1.23e+08' |
科学计数法格式 |
3.4.5 修剪操作
字符串的strip
方法可以帮我们获得将原字符串修剪掉左右两端指定字符之后的字符串,默认是修剪空格字符。这个方法非常有实用价值,可以用来将用户输入时不小心键入的头尾空格等去掉,strip
方法还有lstrip
和rstrip
两个版本,相信从名字大家已经猜出来这两个方法是做什么用的。
1 | s1 = ' hello, world! ' |
注意:这里面
id
函数的作用是获取对象的内存地址(十六进制表示),输出不同也表明strip
方法不会修改字符串本身,而是返回一个新的字符串。
3.4.6 替换操作
如果希望用新的内容替换字符串中指定的内容,可以使用replace
方法,代码如下所示。replace
方法的第一个参数是被替换的内容,第二个参数是替换后的内容,还可以通过第三个参数指定替换的次数。
1 | s = 'hello, world!' |
注意:
replace
方法不会修改字符串本身,而是返回一个新的字符串,所以需要用变量接收返回值。
3.4.7 拆分与合并
可以使用字符串的split
方法将一个字符串拆分为多个字符串(放在一个列表中),也可以使用字符串的join
方法将列表中的多个字符串连接成一个字符串,代码如下所示。
1 | s = 'I#love#you#so#much' |
注意:
split
方法的第一个参数是分隔符,第二个参数是最大拆分次数,如果第二个参数为2
,则表示最多拆分为两个子字符串,第三个子字符串会作为剩余部分。
3.4.8 编码和解码
Python 中除了字符串str
类型外,还有一种表示二进制数据的字节串类型(bytes
)。所谓字节串,就是由零个或多个字节组成的有限序列。通过字符串的encode
方法,我们可以按照某种编码方式将字符串编码为字节串,我们也可以使用字节串的decode
方法,将字节串解码为字符串,代码如下所示。
1 | a = '鹏远' |
注意,如果编码和解码的方式不一致,会导致乱码问题(无法再现原始的内容)或引发
UnicodeDecodeError
错误,导致程序崩溃。
这里给出常见编码方式的对应关系。
编码方式 | 描述 |
---|---|
utf-8 |
万国码,支持所有语言 |
gbk |
国标码,支持中文 |
gb2312 |
国标码,支持中文 |
3.4.9 其他方法
对于字符串类型来说,还有一个常用的操作是对字符串进行匹配检查,即检查字符串是否满足某种特定的模式。例如,一个网站对用户注册信息中用户名和邮箱的检查,就属于模式匹配检查。实现模式匹配检查的工具叫做正则表达式,Python 语言通过标准库中的re
模块提供了对正则表达式的支持。
4 常用数据结构之集合
在学习了列表和元组之后,我们再来学习一种容器型的数据类型,它的名字叫集合(set)。说到集合这个词大家一定不会陌生,在数学课本上就有这个概念。如果我们把一定范围的、确定的、可以区别的事物当作一个整体来看待,那么这个整体就是集合,集合中的各个事物称为集合的元素。通常,集合需要满足以下要求:
- 无序性:一个集合中,每个元素的地位都是相同的,元素之间是无序的。
- 互异性:一个集合中,任何两个元素都是不相同的,即元素在集合中只能出现一次。
- 确定性:给定一个集合和一个任意元素,该元素要么属这个集合,要么不属于这个集合,二者必居其一,不允许有模棱两可的情况出现。
Python 程序中的集合跟数学上的集合没有什么本质区别,需要强调的是上面所说的无序性和互异性。无序性说明集合中的元素并不像列中的元素那样存在某种次序,可以通过索引运算就能访问任意元素,集合并不支持索引运算。另外,集合的互异性决定了集合中不能有重复元素,这一点也是集合区别于列表的地方,我们无法将重复的元素添加到一个集合中。集合类型必然是支持in
和not in
成员运算的,这样就可以确定一个元素是否属于集合,也就是上面所说的集合的确定性。集合的成员运算在性能上要优于列表的成员运算,这是集合的底层存储特性决定的,此处我们暂时不做讨论,大家记住这个结论即可。
说明:集合底层使用了哈希存储(散列存储),对哈希存储不了解的读者可以先看看“Hello 算法”网站对哈希表的讲解,感谢作者的开源精神。
4.1 创建集合
在 Python 中,创建集合可以使用{}
字面量语法,{}
中需要至少有一个元素,因为没有元素的{}
并不是空集合而是一个空字典,字典类型我们会在下一节课中为大家介绍。当然,也可以使用 Python 内置函数set
来创建一个集合,准确的说set
并不是一个函数,而是创建集合对象的构造器,这个知识点会在后面讲解面向对象编程的地方为大家介绍。我们可以使用set
函数创建一个空集合,也可以用它将其他序列转换成集合,例如:set('hello')
会得到一个包含了4个字符的集合(重复的字符l
只会在集合中出现一次)。除了这两种方式,还可以使用生成式语法来创建集合,就像我们之前用生成式语法创建列表那样。
1 | set1 = {1, 2, 3, 4, 5} |
需要提醒大家,集合中的元素必须是hashable
类型,所谓hashable
类型指的是能够计算出哈希码的数据类型,通常不可变类型都是hashable
类型,如整数(int
)、浮点小数(float
)、布尔值(bool
)、字符串(str
)、元组(tuple
)等。可变类型都不是hashable
类型,因为可变类型无法计算出确定的哈希码,所以它们不能放到集合中。例如:我们不能将列表作为集合中的元素;同理,由于集合本身也是可变类型,所以集合也不能作为集合中的元素。我们可以创建出嵌套列表(列表的元素也是列表),但是我们不能创建出嵌套的集合,这一点在使用集合的时候一定要引起注意。
4.2 集合的遍历
我们可以通过len
函数来获得集合中有多少个元素,但是我们不能通过索引运算来遍历集合中的元素,因为集合元素并没有特定的顺序。当然,要实现对集合元素的遍历,我们仍然可以使用for-in
循环,代码如下所示。
1 | set1 = {'Python', 'C++', 'Java', 'Kotlin', 'Swift'} |
提示:大家看看上面代码的运行结果,通过单词输出的顺序体会一下集合的无序性。
4.3 集合的运算
Python 为集合类型提供了非常丰富的运算,主要包括:成员运算、交集运算、并集运算、差集运算、比较运算(相等性、子集、超集)等。
4.3.1 成员运算
可以通过成员运算in
和not in
检查元素是否在集合中,代码如下所示。
1 | set1 = {11, 12, 13, 14, 15} |
4.3.2 二元运算
集合的二元运算主要指集合的交集、并集、差集、对称差等运算,这些运算可以通过运算符来实现,也可以通过集合类型的方法来实现,代码如下所示。
1 | set1 = {1, 2, 3, 4, 5, 6, 7} |
通过上面的代码可以看出,对两个集合求交集,&
运算符和intersection
方法的作用是完全相同的,使用运算符的方式显然更直观且代码也更简短。需要说明的是,集合的二元运算还可以跟赋值运算一起构成复合赋值运算,例如:set1 |= set2
相当于set1 = set1 | set2
,跟|=
作用相同的方法是update
;set1 &= set2
相当于set1 = set1 & set2
,跟&=
作用相同的方法是intersection_update
,代码如下所示。
1 | set1 = {1, 3, 5, 7} |
4.3.3 比较运算
两个集合可以用==
和!=
进行相等性判断,如果两个集合中的元素完全相同,那么==
比较的结果就是True
,否则就是False
。如果集合A
的任意一个元素都是集合B
的元素,那么集合A
称为集合B
的子集,即对于∀a∈A
,均有a∈B
,则A⊆B
,A
是B
的子集,反过来也可以称B
是A
的超集。如果A
是B
的子集且A
不等于B
,那么A
就是B
的真子集。Python 为集合类型提供了判断子集和超集的运算符,其实就是我们非常熟悉的<
、<=
、>
、>=
这些运算符。当然,我们也可以通过集合类型的方法issubset
和issuperset
来判断集合之间的关系,代码如下所示。
1 | set1 = {1, 3, 5} |
说明:上面的代码中,
set1 < set2
判断set1
是不是set2
的真子集,set1 <= set2
判断set1
是不是set2
的子集,set2 > set1
判断set2
是不是set1
的超集。当然,我们也可以通过set1.issubset(set2)
判断set1
是不是set2
的子集;通过set2.issuperset(set1)
判断set2
是不是set1
的超集。
4.4 集合的常用方法
刚才我们说过,Python 中的集合是可变类型,我们可以通过集合的方法向集合添加元素或从集合中删除元素。
1 | set1 = {1, 10, 100} |
这里我们用到了discard
方法,这个方法跟remove
方法的区别在于,如果删除的元素不存在,discard
方法不会引发KeyError
错误,而remove
方法会引发KeyError
错误。
集合中还有一个pop
方法,这个方法会随机删除集合中的一个元素并返回该元素,如果集合为空,则引发KeyError
错误。
1 | set1 = {1, 10, 100} |
集合类型还有一个名为isdisjoint
的方法可以判断两个集合有没有相同的元素,如果没有相同元素,该方法返回True
,否则该方法返回False
,代码如下所示。
1 | set1 = {1, 3, 5} |
4.5 不可变集合
Python 中还有一种不可变类型的集合,名字叫frozenset
。set
跟frozenset
的区别就如同list
跟tuple
的区别,frozenset
由于是不可变类型,能够计算出哈希码,因此它可以作为set
中的元素。除了不能添加和删除元素,frozenset
在其他方面跟set
是一样的,下面的代码简单的展示了frozenset
的用法。
1 | fset1 = frozenset({1, 3, 5, 7}) |
4.6 总结
Python 中的集合类型是一种无序容器,不允许有重复运算,由于底层使用了哈希存储,集合中的元素必须是hashable
类型。集合与列表最大的区别在于集合中的元素没有顺序、所以不能够通过索引运算访问元素、但是集合可以执行交集、并集、差集等二元运算,也可以通过关系运算符检查两个集合是否存在超集、子集等关系。
5 常用数据结构之字典
迄今为止,我们已经为大家介绍了 Python 中的三种容器型数据类型(列表、元组、集合),但是这些数据类型仍然不足以帮助我们解决所有的问题。例如,我们需要一个变量来保存一个人的多项信息,包括:姓名、年龄、身高、体重、家庭住址、本人手机号、紧急联系人手机号,此时你会发现,我们之前学过的列表、元组和集合类型都不够好使。
集合肯定是最不合适的,因为集合中不能有重复元素,如果一个人的年龄和体重刚好相同,那么集合中就会少一项信息;同理,如果这个人的手机号和紧急联系人手机号是相同的,那么集合中又会少一项信息。另一方面,虽然列表和元组可以把一个人的所有信息都保存下来,但是当你想要获取这个人的手机号或家庭住址时,你得先知道他的手机号是列表或元组中的第几个元素。总之,在遇到上述的场景时,列表、元组、集合都不是最合适的选择,此时我们需要字典(dictionary)类型,这种数据类型最适合把相关联的信息组装到一起,可以帮助我们解决 Python 程序中为真实事物建模的问题。
Python 程序中的字典跟现实生活中的字典很像,它以键值对(键和值的组合)的方式把数据组织到一起,我们可以通过键找到与之对应的值并进行操作。就像《新华字典》中,每个字(键)都有与它对应的解释(值)一样,每个字和它的解释合在一起就是字典中的一个条目,而字典中通常包含了很多个这样的条目。
5.1 创建字典
Python 中创建字典可以使用{}
字面量语法,这一点跟上一节课讲的集合是一样的。但是字典的{}
中的元素是以键值对的形式存在的,每个元素由:
分隔的两个值构成,:
前面是键,:
后面是值,代码如下所示。
1 | xinhua = { |
通过上面的代码,相信大家已经看出来了,用字典来保存一个人的信息远远优于使用列表或元组,因为我们可以用:
前面的键来表示条目的含义,而:
后面就是这个条目所对应的值。
当然,如果愿意,我们也可以使用内置函数dict
或者是字典的生成式语法来创建字典,代码如下所示。
1 | # 用 dict 函数(构造器)生成,其每一组参数就是字典中的一组键值对 |
想知道字典中一共有多少组键值对,仍然是使用len
函数;如果想对字典进行遍历,可以用for-in
循环,但是需要注意,for-in
循环只是对字典的键进行了遍历,不过没关系,在学习了字典的索引运算后,我们可以通过字典的键访问它对应的值。
1 | person = { |
5.2 字典的运算
对于字典类型来说,成员运算和索引运算肯定是很重要的,前者可以判定指定的键在不在字典中,后者可以通过键访问对应的值或者向字典中添加新的键值对。值得注意的是,字典的索引不同于列表的索引,列表中的元素因为有属于自己有序号,所以列表的索引是一个整数;字典中因为保存的是键值对,所以字典需要用键去索引对应的值。需要特别提醒大家注意的是,字典中的键必须是不可变类型,例如整数(int
)、浮点数(float
)、字符串(str
)、元组(tuple
)等类型,这一点跟集合类型对元素的要求是一样的;很显然,之前我们讲的列表(list
)和集合(set
)不能作为字典中的键,字典类型本身也不能再作为字典中的键,因为字典也是可变类型,但是列表、集合、字典都可以作为字典中的值,例如:
1 | person = { |
大家可以看看下面的代码,了解一下字典的成员运算和索引运算。
1 | person = { |
说明:字典的索引运算跟列表的索引运算一样,如果键不存在,会引发
KeyError
错误。
5.3 字典的方法
字典类型的方法基本上都跟字典的键值对操作相关,其中get
方法可以通过键来获取对应的值。跟索引运算不同的是,get
方法在字典中没有指定的键时不会产生异常,而是返回None
或指定的默认值,代码如下所示。
1 | person = { |
get
方法的第一个参数是键,第二个参数是默认值,如果字典中存在指定的键,则返回对应的值,否则返回第二个参数对应的值。
字典的update
方法实现两个字典的合并操作。例如,有两个字典x
和y
,当执行x.update(y)
操作时,x
跟y
相同的键对应的值会被y
中的值更新,而y
中有但x
中没有的键值对会直接添加到x
中,代码如下所示。
1 | x = {'a': 1, 'b': 2} |
如果使用 Python 3.9 及以上的版本,也可以使用|
运算符来完成同样的操作,代码如下所示。
1 | x = {'a': 1, 'b': 2} |
可以通过pop
或popitem
方法从字典中删除元素,前者会返回(获得)键对应的值,但是如果字典中不存在指定的键,会引发KeyError
错误;后者在删除元素时,会返回(获得)键和值组成的二元组。字典的clear
方法会清空字典中所有的键值对,代码如下所示。
1 | person = { |
5.4 字典的应用
我们通过几个简单的例子来看看如何使用字典类型解决一些实际的问题。
例子1:输入一段话,统计每个英文字母出现的次数,按出现次数从高到低输出。
1 | """ |
例子2:在一个字典中保存了股票的代码和价格,找出股价大于100元的股票并创建一个新的字典。
说明:可以用字典的生成式语法来创建这个新字典。
1 | """ |
5.5 总结
Python 程序中的字典跟现实生活中字典非常像,允许我们以键值对的形式保存数据,再通过键访问对应的值。字典是一种非常有利于数据检索的数据类型,但是需要再次提醒大家,字典中的键必须是不可变类型,列表、集合、字典等类型的数据都不能作为字典的键。
6 常用数据结构总结
数据结构 | 是否可变 | 是否有序 | 是否允许重复 | 索引支持 | 典型符号 | 适用场景举例 |
---|---|---|---|---|---|---|
列表 list |
✅ 可变 | ✅ 有序 | ✅ 允许 | ✅ 支持 | [1, 2, 3] |
多元素集合,可增删改查 |
字符串 str |
❌ 不可变 | ✅ 有序 | ✅ 允许 | ✅ 支持 | 'abc' 或 "abc" |
文本处理、编码转换 |
元组 tuple |
❌ 不可变 | ✅ 有序 | ✅ 允许 | ✅ 支持 | (1, 2, 3) |
不可变序列、函数多返回值 |
集合 set |
✅ 可变 | ❌ 无序 | ❌ 不允许重复 | ❌ 不支持 | {1, 2, 3} |
去重、集合运算(交并差) |
字典 dict |
✅ 可变 | ✅ 有序* | ❌ 键不重复 | ✅ 支持键索引 | {'a': 1} |
键值映射、快速查找 |
✅ 有序:指插入元素的顺序是否保留;Python 3.7+ 中
dict
默认有序
✅ 可变:是否支持增删改;不可变结构不能原地修改
✅ 支持索引:是否可以使用结构[i]
或结构[key]
访问元素
Day4
1 函数和模块
1.1 函数引入
在讲解本节课的内容之前,我们先来研究一道数学题,请说出下面的方程有多少组正整数解。
你可能已经想到了,这个问题其实等同于将 8 个苹果分成四组且每组至少一个苹果有多少种方案,也等价于在 8 个苹果之间的 7 个间隙中放入 3 个隔板,将苹果分成四组。因此,答案为:
其中, 表示从 7 个间隙中选出 3 个放隔板的组合数,其计算公式如下:
根据之前我们所学的知识,我们可以用循环做累乘的方式分别计算出 、 和 ,最后将它们相除即可得到 。
1 | """ |
不知大家是否注意到,上面的代码中我们做了三次求阶乘的操作,虽然 、 、 的值各不相同,但是三段代码并没有实质性的区别,属于重复代码。世界级的编程大师Martin Fowler曾经说过:“代码有很多种坏味道,重复是最坏的一种!”。要写出高质量的代码,首先就要解决重复代码的问题。对于上面的代码来说,我们可以将求阶乘的功能封装到一个称为“函数”的代码块中,在需要计算阶乘的地方,我们只需“调用函数”即可实现对求阶乘功能的复用。
1.2 定义函数
数学上的函数通常形如 或者 这样的形式,在 中, 是函数的名字, 是函数的自变量, 是函数的因变量;而在 中, 是函数名, 和 是函数的自变量, 是函数的因变量。Python 中的函数跟这个结构是一致的,每个函数都有自己的名字、自变量和因变量。我们通常把 Python 函数的自变量称为函数的参数,而因变量称为函数的返回值。
Python 中可以使用 def
关键字来定义函数,和变量一样每个函数也应该有一个漂亮的名字,命名规则跟变量的命名规则是一样的(大家赶紧想想我们之前讲过的变量的命名规则)。在函数名后面的圆括号中可以设置函数的参数,也就是我们刚才说的函数的自变量,而函数执行完成后,我们会通过 return
关键字来返回函数的执行结果,这就是我们刚才说的函数的因变量。如果函数中没有 return
语句,那么函数会返回代表空值的 None
。另外,函数也可以没有自变量(参数),但是函数名后面的圆括号是必须有的。一个函数要做的事情(要执行的代码),是通过代码缩进的方式放到函数定义行之后,跟之前分支和循环结构的代码块类似,如下图所示。
下面,我们将之前代码中求阶乘的操作放到一个函数中,通过这种方式来重构上面的代码。所谓重构,是在不影响代码执行结果的前提下对代码的结构进行调整,重构之后的代码如下所示。
1 | """ |
大家可以感受下,上面的代码是不是比之前的版本更加简单优雅。更为重要的是,我们定义的求阶乘函数fac还可以在其他需要求阶乘的代码中重复使用。所以,使用函数可以帮助我们将功能上相对独立且会被重复使用的代码封装起来,当我们需要这些的代码,不是把重复的代码再编写一遍,而是通过调用函数实现对既有代码的复用。事实上,Python 标准库的math
模块中,已经有一个名为factorial
的函数实现了求阶乘的功能,我们可以直接用import math
导入math
模块,然后使用math.factorial
来调用求阶乘的函数;我们也可以通过from math import factorial
直接导入factorial
函数来使用它,代码如下所示:
1 | """ |
将来我们使用的函数,要么是自定义的函数,要么是 Python 标准库或者三方库中提供的函数,如果已经有现成的可用的函数,我们就没有必要自己去定义,“重复发明轮子”是一件非常糟糕的事情。对于上面的代码,如果你觉得factorial
这个名字太长,书写代码的时候不是特别方便,我们在导入函数的时候还可以通过as
关键字为其别名。在调用函数的时候,我们可以用函数的别名,而不再使用它之前的名字,代码如下所示:
1 | """ |
1.3 函数的参数
1.3.1 位置参数和关键字参数
我们再来写一个函数,根据给出的三条边的长度判断是否可以构成三角形,如果可以构成三角形则返回True
,否则返回False
,代码如下所示。
1 | """ |
上面is_triangle
函数有三个参数,这种参数叫做位置参数,在调用函数时通常按照从左到右的顺序依次传入,而且传入参数的数量必须和定义函数时参数的数量相同,如下所示:
1 | print(is_triangle(1, 2, 3)) |
如果不想按照从左到右的顺序依次给出 a, b, c
,可以在传入参数的时候指定参数名,这样传入参数的顺序就可以和定义函数时的参数顺序不同,代码如下所示:
1 | print(is_triangle(a=3, b=4, c=5)) |
在定义函数的时候,可以在参数列表中用 /
设置强制位置参数(positional-only arguments
),用 \*
设置可变参数(*args
)和关键字参数(**kwargs
)。
- 强制位置参数:就是调用函数时只能按照参数位置来接收参数值的参数,
/
前面的参数是强制位置参数; - 命名关键字参数:只能通过“参数名=参数值”的方式来传递和接收参数,
*
后面的参数是命名关键字参数。
可以看下面这个例子:
1 | def func(a, /, b, *, c): |
在上面的代码中,a
是一个位置参数,b
是一个强制位置参数,c
是一个命名关键字参数。在调用函数的时候,a
只能按照位置参数的方式来传入,而 b
和 c
必须通过“参数名=参数值”的方式来传入。
说明:强制位置参数是 Python 3.8 引入的新特性,在使用低版本的 Python 解释器时需要注意。
1.3.2 默认参数
函数的参数可以设置默认值,这样在调用函数的时候,如果没有传入对应的参数值,就会使用默认值。我们可以把之前讲过的一个例子“CRAPS赌博游戏”中摇色子获得点数的功能封装到函数中,代码如下所示:
1 | """ |
在上面的代码中,我们给 n
设置了默认值为 2
,也就是说,如果不传入 n
的值,那么默认摇两颗色子。
我们再来看一个更为简单的例子:
1 | """ |
需要注意的是,带默认值的参数必须放在不带默认值的参数之后,否则将产生SyntaxError错误,错误消息是:non-default argument follows default argument,翻译成中文的意思是“没有默认值的参数放在了带默认值的参数后面”。
1 | """ |
报错如下:
1.3.3 可变参数
Python 语言中可以通过星号表达式语法让函数支持可变参数。所谓可变参数指的是在调用函数时,可以向函数传入0
个或任意多个参数。将来我们以团队协作的方式开发商业项目时,很有可能要设计函数给其他人使用,但有的时候我们并不知道函数的调用者会向该函数传入多少个参数,这个时候可变参数就能派上用场。
下面的代码演示了如何使用可变位置参数实现对任意多个数求和的add函数,调用函数时传入的参数会保存到一个元组,通过对该元组的遍历,可以获取传入函数的参数。
1 | """ |
如果我们希望通过 “参数名 = 变量名” 的形式传入若干个参数,具体有多少个参数也是不确定的,我们就可以给函数添加可变关键字参数,把传入的关键字参数组装到一个字典中,代码如下所示:
1 | """ |
1.4 用模块管理函数
不管用什么样的编程语言来写代码,给变量、函数起名字都是一个让人头疼的问题,因为我们会遇到命名冲突这种尴尬的情况。最简单的场景就是在同一个 .py
文件中定义了两个同名的函数,如下所示:
1 | def foo(): |
当然上面的这种情况我们很容易就能避免,但是如果项目是团队协作多人开发的时候,团队中可能有多个程序员都定义了名为 foo
的函数,这种情况下怎么解决命名冲突呢?答案其实很简单,Python 中每个文件就代表了一个模块(module
),我们在不同的模块中可以有同名的函数,在使用函数的时候,我们通过 import
关键字导入指定的模块再使用完全限定名(模块名.函数名
)的调用方式,就可以区分到底要使用的是哪个模块中的foo函数,代码如下所示:
model1.py
:
1 | def foo(): |
model2.py
:
1 | def foo(): |
test.py
:
1 | import model1 |
输出:
1 | hello, world! |
需要说明的是,如果我们导入的模块除了定义函数之外还有其他代码,那么 Python 解释器在导入这个模块时就会执行这些代码。实际上,我们编写的每个 .py
文件都是一个模块,模块的名字就是文件名去掉 .py
后缀。
上面两段代码,我们导入的是定义函数的模块,我们也可以使用 from...import...
语法从模块中直接导入需要使用的函数,代码如下所示:
1 | from model1 import foo |
输出:
1 | hello, world! |
但是,如果我们如果从两个不同的模块中导入了同名的函数,后面导入的函数会替换掉之前的导入,就像下面的代码,调用 foo
会输出 goodbye, world!
,因为我们先导入了module1
的foo
,后导入了module2
的foo
。如果两个from...import...
反过来写,那就是另外一种情况了,调用foo
会输出hello, world!
,因为我们先导入了module2
的foo
,后导入了module1
的foo
。
1 | from model2 import foo |
输出:
1 | goodbye, world! |
1.5 标准库中的模块和函数
Python 标准库中提供了大量的模块和函数来简化我们的开发工作,我们之前用过的random
模块就为我们提供了生成随机数和进行随机抽样的函数;而time
模块则提供了和时间操作相关的函数;我们之前用到过的math
模块中还包括了计算正弦、余弦、指数、对数等一系列的数学函数。随着我们深入学习 Python 语言,我们还会用到更多的模块和函数。
Python 标准库中还有一类函数是不需要import
就能够直接使用的,我们将其称之为内置函数,这些内置函数不仅有用而且还很常用,下面的表格列出了一部分的内置函数:
函数 | 说明 |
---|---|
abs |
返回一个数的绝对值,例如:abs(-1.3) 会返回1.3 。 |
bin |
把一个整数转换成以'0b' 开头的二进制字符串,例如:bin(123) 会返回'0b1111011' 。 |
chr |
将Unicode编码转换成对应的字符,例如:chr(8364) 会返回'€' 。 |
hex |
将一个整数转换成以'0x' 开头的十六进制字符串,例如:hex(123) 会返回'0x7b' 。 |
input |
从输入中读取一行,返回读到的字符串。 |
len |
获取字符串、列表等的长度。 |
max |
返回多个参数或一个可迭代对象中的最大值,例如:max(12, 95, 37) 会返回95 。 |
min |
返回多个参数或一个可迭代对象中的最小值,例如:min(12, 95, 37) 会返回12 。 |
oct |
把一个整数转换成以'0o' 开头的八进制字符串,例如:oct(123) 会返回'0o173' 。 |
open |
打开一个文件并返回文件对象。 |
ord |
将字符转换成对应的Unicode编码,例如:ord('€') 会返回8364 。 |
pow |
求幂运算,例如:pow(2, 3) 会返回8 ;pow(2, 0.5) 会返回1.4142135623730951 。 |
print |
打印输出。 |
range |
构造一个范围序列,例如:range(100) 会产生0 到99 的整数序列。 |
round |
按照指定的精度对数值进行四舍五入,例如:round(1.23456, 4) 会返回1.2346 。 |
sum |
对一个序列中的项从左到右进行求和运算,例如:sum(range(1, 101)) 会返回5050 。 |
type |
返回对象的类型,例如:type(10) 会返回int ;而 type('hello') 会返回str 。 |
1.6 总结
函数是对功能相对独立且会重复使用的代码的封装。学会使用定义和使用函数,就能够写出更为优质的代码。当然,Python 语言的标准库中已经为我们提供了大量的模块和常用的函数,用好这些模块和函数就能够用更少的代码做更多的事情;如果这些模块和函数不能满足我们的要求,可能就需要自定义函数,然后再通过模块的概念来管理这些自定义函数。
2 函数应用实战
2.1 随机验证码
设计一个生成随机验证码的函数,验证码由数字和英文大小写字母构成,长度可以通过参数设置。
1 | """ |
说明1:
代码 ALL_CHARS = string.digits + string.ascii_letters
的作用是:
string.digits
表示所有的数字字符,即字符串 “0123456789”。string.ascii_letters
表示所有的英文字母,包括大写和小写,即字符串 “abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ”。- 两者相加,得到一个包含所有数字和英文字母(共62个字符)的字符串。
最终,ALL_CHARS
变量就包含了所有数字和英文字母,常用于生成验证码、随机密码等需要用到这些字符的场景。
示例:
1 | import string |
说明2:
random
模块的sample
和choices
函数都可以实现随机抽样,sample
实现无放回抽样,这意味着抽样取出的元素是不重复的;choices
实现有放回抽样,这意味着可能会重复选中某些元素。这两个函数的第一个参数代表抽样的总体,而参数k
代表样本容量,需要说明的是choices
函数的参数k
是一个命名关键字参数,在传参时必须指定参数名。
说明3:
我们设计的generate_code
函数的参数code_len
是命名关键字参数,由于它有默认值,可以不给它传值,使用默认值4。如果需要给函数传入参数,必须指定参数名code_len
。
2.2 判断素数
设计一个判断给定的大于1的正整数是不是质数的函数。质数是只能被1和自身整除的正整数(大于1)。如果一个大于1的正整数N
是质数,那就意味着在2到N-1
之间都没有它的因子。
1 | """ |
说明1:上面
is_prime
函数的参数num
后面的:int
用来标注参数的类型,虽然它对代码的执行结果不产生任何影响,但是很好的增强了代码的可读性。同理,参数列表后面的-> bool
用来标注函数返回值的类型,它也不会对代码的执行结果产生影响,但是却让我们清楚的知道,调用函数会得到一个布尔值,要么是True
,要么是False
。
说明2:上面的循环并不需要从 2 循环到 ,因为如果循环到 时,还没有找到 的因子,那么之后肯定也不会出现了。
2.3 最大公约数和最小公倍数
题目描述:输入两个正整数,计算它们的最大公约数和最小公倍数。
两个数的最大公约数是能够同时整除这两个数的最大正整数。例如,12和18的最大公约数是6。
两个数的最小公倍数是能够被这两个数同时整除的最小正整数。例如,12和18的最小公倍数是36。
这个问题可以通过多种算法来解决:
- 欧几里得算法(辗转相除法)求最大公约数
- 根据最大公约数计算最小公倍数
- 穷举法(不推荐,效率较低)
2.3.1 欧几里得算法
欧几里得算法的基本思想是:用较大的数去除以较小的数,再用得到的余数去除以除数,如此反复,直到余数为0时,被除数就是这两个数的最大公约数。
例如,计算12和18的最大公约数的过程如下:
- 12 ÷ 18 = 0(余12)
- 18 ÷ 12 = 1(余6)
- 12 ÷ 6 = 2(余0)
所以,12和18的最大公约数是6。
1 | """ |
2.3.2 穷举法
穷举法的基本思想是:从1开始逐个尝试,直到找到同时能整除这两个数的最小正整数,这个数就是它们的最大公约数。
例如,计算12和18的最大公约数的过程如下:
- 从1开始逐个尝试,直到找到同时能整除12和18的最小正整数
1 | """ |
2.3.3 数据统计
假设样本数据保存一个列表中,设计计算样本数据描述性统计信息的函数。描述性统计信息通常包括:算术平均值、中位数、极差(最大值和最小值的差)、方差、标准差、变异系数等,计算公式如下所示:
- 算术平均值:$$\bar{x} = \frac{1}{n}\sum_{i=1}^{n}x_i$$
- 中位数:$$\text{中位数} = \begin{cases} x_{\frac{n+1}{2}} & \text{如果 } n \text{ 是奇数} \ \frac{1}{2}(x_{\frac{n}{2}} + x_{\frac{n}{2}+1}) & \text{如果 } n \text{ 是偶数} \end{cases}$$
- 极差:$$\text{极差} = x_{\text{最大值}} - x_{\text{最小值}}$$
- 方差:$$s^2 = \frac{1}{n-1}\sum_{i=1}^{n}(x_i - \bar{x})^2$$
- 标准差:$$s = \sqrt{s^2}$$
- 变异系数:$$CV = \frac{s}{\bar{x}}$$
1 | """ |
说明:
describe
函数将上面封装好的统计函数组装到一起,用于输出数据的描述性统计信息。事实上,Python
标准库中有一个名为statistics
的模块,它已经把获取描述性统计信息的函数封装好了,有兴趣的读者可以自行了解。
2.3.4 双色球随机选号
我们用函数重构之前讲过的双色球随机选号的例子,将生成随机号码和输出一组号码的功能分别封装到两个函数中,然后通过调用函数实现机选N注号码的功能。
1 | """ |
2.4 总结
在写代码尤其是开发商业项目的时候,一定要有意识的将相对独立且重复使用的功能封装成函数,这样不管是自己还是团队的其他成员都可以通过调用函数的方式来使用这些功能,减少工作中那些重复且乏味的劳动。
3 函数使用进阶
我们继续探索定义和使用函数的相关知识。通过前面的学习,我们知道了函数有自变量(参数)和因变量(返回值),自变量可以是任意的数据类型,因变量也可以是任意的数据类型,那么这里就有一个小问题,我们能不能用函数作为函数的参数,用函数作为函数的返回值?这里我们先说结论:Python 中的函数是一等函数,所谓“一等函数”指的就是函数可以赋值给变量,函数可以作为函数的参数,函数也可以作为函数的返回值。把一个函数作为其他函数的参数或返回值的用法,我们通常称之为高阶函数。
3.1 高阶函数
我们回到之前讲过的一个例子,设计一个函数,传入任意多个参数,对其中int
类型或float
类型的元素实现求和操作。我们对之前的代码稍作调整,让整个代码更加紧凑一些,如下所示:
1 | """ |
如果我们希望上面的calc
函数不仅仅可以做多个参数的求和,还可以实现更多的甚至是自定义的二元运算,我们该怎么做呢?上面的代码只能求和是因为函数中使用了+=
运算符,这使得函数跟加法运算形成了耦合关系,如果能解除这种耦合关系,函数的通用性和灵活性就会更好。解除耦合的办法就是将+
运算符变成函数调用,并将其设计为函数的参数,代码如下所示:
1 | """ |
上面的calc
函数通过将运算符变成函数的参数,实现了跟加法运算去耦合,这是一种非常高明和实用的编程技巧,但对于最初学者来说可能会觉得难以理解,建议大家细品一下。需要注意上面的代码中,将函数作为参数传入其他函数和直接调用函数是有显著的区别的,调用函数需要在函数名后面跟上圆括号,而把函数作为参数时只需要函数名即可。
如果我们没有提前定义好add
和mul
函数,也可以使用 Python 标准库中的operator
模块提供的add
和mul
函数,它们分别代表了做加法和做乘法的二元运算,我们拿过来直接使用即可。
Python 内置函数中有不少高阶函数,我们前面提到过的filter
和map
函数就是高阶函数,前者可以实现对序列中元素的过滤,后者可以实现对序列中元素的映射,例如我们要去掉一个整数列表中的奇数,并对所有的偶数求平方得到一个新的列表,就可以直接使用这两个函数来做到,具体的做法是如下所示:
1 | """ |
我们再来讨论一个内置函数sorted
,它可以实现对容器型数据类型(如:列表、字典等)元素的排序。我们之前讲过list
类型的sort
方法,它实现了对列表元素的排序,sorted
函数从功能上来讲跟列表的sort
方法没有区别,但它会返回排序后的列表对象,而不是直接修改原来的列表,这一点我们称为函数的无副作用设计,也就是说调用函数除了产生返回值以外,不会对程序的状态或外部环境产生任何其他的影响。使用sorted
函数排序时,可以通过高阶函数的形式自定义排序的规则,我们通过下面的例子加以说明:
1 | """ |
3.2 Lambda 函数
在使用高阶函数的时候,如果作为参数或者返回值的函数本身非常简单,一行代码就能够完成,也不需要考虑对函数的复用,那么我们可以使用 lambda
函数。Python 中的 lambda
函数是没有的名字函数,所以很多人也把它叫做匿名函数,lambda
函数只能有一行代码,代码中的表达式产生的运算结果就是这个匿名函数的返回值。之前的代码中,我们写的is_even
和square
函数都只有一行代码,我们可以考虑用 lambda 函数来替换掉它们,代码如下所示:
1 | """ |
通过上面的代码可以看出,定义 lambda
函数的关键字是lambda
,后面跟函数的参数,如果有多个参数用逗号进行分隔;冒号后面的部分就是函数的执行体,通常是一个表达式,表达式的运算结果就是 lambda
函数的返回值,不需要写 return
关键字。
前面我们说过,Python 中的函数是一等函数,函数是可以直接赋值给变量的。在学习了 lambda
函数之后,前面我们写过的一些函数就可以用一行代码来实现它们了,大家可以看看能否理解下面的求阶乘和判断素数的函数:
1 | """ |
解读1:上面使用的
reduce
函数是 Python 标准库functools
模块中的函数,它可以实现对一组数据的归约操作,类似于我们之前定义的calc
函数,第一个参数是代表运算的函数,第二个参数是运算的数据,第三个参数是运算的初始值。很显然,reduce
函数也是高阶函数,它和filter
函数、map
函数一起构成了处理数据中非常关键的三个动作:过滤、映射和归约。
解读2:上面判断素数的lambda
函数通过range
函数构造了从 2 到 的范围,检查这个范围有没有 的因子。all
函数也是 Python 内置函数,如果传入的序列中所有的布尔值都是True
,all
函数返回True
,否则all
函数返回False。
3.3 偏函数
偏函数(Partial Function)是 Python 中一个非常有用的工具,它允许我们创建一个新的函数,这个新函数的某些参数已经被预先设置好。偏函数通常用于简化函数调用,减少重复代码,或者在需要固定某些参数的情况下使用。
在 Python 中,偏函数是通过 functools.partial
实现的。
3.3.1 偏函数的概念
偏函数是一个函数的“部分应用”,即我们预先设置好某些参数的值,生成一个新的函数。当我们调用这个新函数时,只需要传入剩余的参数即可。
例如,我们有一个函数 power(base, exponent)
,它返回 base
的 exponent
次幂。如果我们经常需要计算平方或立方,可以创建两个偏函数,分别对应 exponent=2
和 exponent=3
。
3.3.2 偏函数的语法
偏函数通过 functools.partial
创建,其语法如下:
1 | from functools import partial |
func
:原始函数。*args
:要预先设置的位置参数。**keywords
:要预先设置的关键字参数。
返回的 new_func
是一个新函数,它在调用时会将预先设置的参数传递给原始函数。
3.3.3 示例 1:基本用法
1 | """ |
3.3.4 示例 2:在排序中的应用
偏函数经常用于排序操作中,特别是当我们需要自定义排序键时。
1 | """ |
3.3.5 示例 3:与 map 函数结合
偏函数可以与 map
函数结合使用,简化代码。
1 | """ |
3.3.6 示例 4:条件判断中的应用
偏函数也可以用于条件判断,简化判断逻辑。
1 | """ |
3.3.7 偏函数的优势
- 减少重复代码:通过固定某些参数,避免重复编写类似的函数调用。
- 提高代码可读性:偏函数可以清晰地表达意图,使代码更易读。
- 灵活性:偏函数可以在不同场景下复用,适应不同的需求。
3.3.8 偏函数与 lambda 表达式的对比
特性/维度 | 偏函数(functools.partial ) |
lambda 表达式 |
---|---|---|
定义方式 | partial(函数, 固定参数=值) |
lambda 参数: 表达式 |
是否返回函数对象 | ✅ 是 | ✅ 是 |
是否固定参数 | ✅ 是(提前绑定部分参数) | ✅ 可以(通过表达式绑定) |
可读性 | ✅ 更清晰(尤其用于已有函数封装) | ⚠️ 复杂时较难读 |
是否支持复杂逻辑 | ❌ 否(只传参,不可写逻辑) | ✅ 是(可写任意表达式) |
调用开销 | 较低(C实现) | 较低 |
使用场景 | 回调函数、简化函数调用、API适配等 | 简单匿名函数、排序键、map/filter等 |
是否依赖模块 | ✅ 是(需 from functools import partial ) |
❌ 否(内建支持) |
- 使用
partial
:当你已有一个函数,只想固定部分参数,生成更简洁的调用方式。 - 使用
lambda
:当你需要动态生成匿名函数,或者函数逻辑不能直接用原函数表示时。
3.4 总结
Python 中的函数是一等函数,可以赋值给变量,也可以作为函数的参数和返回值,这也就意味着我们可以在 Python 中使用高阶函数。高阶函数的概念对新手并不友好,但它却带来了函数设计上的灵活性。如果我们要定义的函数非常简单,只有一行代码,而且不需要函数名来复用它,我们可以使用 lambda 函数。
4 函数高级应用
在上一个章节中,我们探索了 Python 中的高阶函数,相信大家对函数的定义和应用有了更深刻的认知。本章我们继续为大家讲解函数相关的知识,一个是 Python 中的特色语法装饰器,一个是函数的递归调用。
4.1 装饰器
4.1.1 装饰器引入
Python 语言中,装饰器就是“用一个函数装饰另外一个函数,并为其提供额外的能力”的语法现象。装饰器本身就是一个函数,它的参数是另一个函数,返回值是一个带有装饰功能的函数,装饰器的作用是在不改变原函数的代码的前提下,给原函数添加新的功能。装饰器的语法是 @装饰器名
,它放在函数定义的上方,装饰器的定义和使用都需要遵循一定的规则,装饰器的使用场景有很多,比如:日志记录、性能分析、权限验证、缓存等。
假设有名为downlaod
和upload
的两个函数,分别用于文件的上传和下载,如下所示:
1 | import random |
说明:上面的代码用休眠一段随机时间的方式模拟了下载和上传文件需要花费一定的时间,并没有真正的联网上传下载文件。用 Python 语言实现联网上传下载文件也非常简单,后面我们会讲到相关的知识。
现在有一个新的需求,我们希望知道调用download
和upload
函数上传下载文件到底用了多少时间,这应该如何实现呢?相信很多小伙伴已经想到了,我们可以在函数开始执行的时候记录一个时间,在函数调用结束后记录一个时间,两个时间相减就可以计算出下载或上传的时间,代码如下所示:
1 | start = time.time() |
通过上面的代码,我们可以在下载和上传文件的时候记录下耗费的时间,但不知道大家是否注意到,上面记录时间、计算和显示执行时间的代码都是重复代码。有编程经验的人都知道,重复的代码是万恶之源,我们应该避免编写重复的代码。那么有没有办法在不写重复代码的前提下,用一种简单优雅的方式记录下函数的执行时间呢?在 Python 语言中,装饰器就是解决这类问题的最佳选择。通过装饰器语法,我们可以把跟原来的业务(上传和下载)没有关系计时功能的代码封装到一个函数中,如果 upload
和 download
函数需要记录时间,我们直接把装饰器作用到这两个函数上即可。既然上面提到了,装饰器就是一个高阶函数,它的参数和返回值都是函数,我们将记录时间的装饰器命名为 record_time
,那么它的整体结构应该如下所示:
1 | def record_time(func): |
相信大家注意到了,record_time
函数的参数func
代表了一个被装饰的函数,函数里面定义的wrapper
函数是带有装饰功能的函数,它会执行被装饰的函数func
,它还需要返回在最后产生函数执行的返回值。不知大家是否留意到,上面的代码我在第4行和第6行留下了两个空行,这意味着我们可以这些地方添加代码来实现额外的功能。record_time
函数最终会返回这个带有装饰功能的函数wrapper
并通过它替代原函数func
,当原函数func
被record_time
函数装饰后,我们调用它时其实调用的是wrapper
函数,所以才获得了额外的能力。wrapper
函数的参数比较特殊,由于我们要用wrapper
替代原函数func
,但是我们又不清楚原函数func
会接受哪些参数,所以我们就通过可变参数和关键字参数照单全收,然后在调用func
的时候,原封不动的全部给它。这里还要强调一下,Python 语言支持函数的嵌套定义,就像上面,我们可以在record_time
函数中定义wrappe
r函数,这个操作在很多编程语言中并不被支持。
看懂这个结构后,我们就可以把记录时间的功能写到这个装饰器中,代码如下所示:
1 | def record_time(func): |
写装饰器虽然颇费周折,但是这是个一劳永逸的骚操作,将来再有记录函数执行时间的需求时,我们只需要添加上面的装饰器即可。使用上面的装饰器函数有两种方式:
4.1.2 装饰器使用方法一
第一种方式就是直接调用装饰器函数,传入被装饰的函数并获得返回值,我们可以用这个返回值直接替代原来的函数,那么在调用时就已经获得了装饰器提供的额外的能力(记录执行时间),大家试试下面的代码就明白了。
1 | download = record_time(download) |
4.1.3 装饰器使用方法2
在 Python 中,使用装饰器有更为便捷的语法糖(编程语言中添加的某种语法,这种语法对语言的功能没有影响,但是使用更加方法,代码的可读性也更强,我们将其称之为“语法糖”或“糖衣语法”),可以用@装饰器函数
将装饰器函数直接放在被装饰的函数上,效果跟上面的代码相同。我们把完整的代码为大家罗列出来,大家可以再看看我们是如何定义和使用装饰器的:
1 | """ |
上面的代码,我们通过装饰器语法糖为download
和upload
函数添加了装饰器,被装饰后的download
和upload
函数其实就是我们在装饰器中返回的wrapper
函数,调用它们其实就是在调用wrapper
函数,所以才有了记录函数执行时间的功能。
如果在代码的某些地方,我们想去掉装饰器的作用执行原函数,那么在定义装饰器函数的时候,需要做一点点额外的工作,常见有两种方法:
4.1.4 ✅ 方法 1:保留原函数引用,手动调用
1 | def my_decorator(func): |
4.1.5 ✅ 方法 2:functools.wraps
保留原函数引用
通过 functools.wraps
保留原函数引用,然后通过 .__wrapped__
属性调用原函数,从而绕过装饰器的执行逻辑。
这属于**装饰器解包(unwrapping)**的一种方式,常用于:
- 调试时跳过装饰逻辑;
- 保留原函数以便测试;
- 在某些特殊场合(比如单元测试、文档生成)只希望使用未被包装的函数。
Python 标准库functools
模块的wraps
函数也是一个装饰器,我们将它放在wrapper
函数上,这个装饰器可以帮我们保留被装饰之前的函数,这样在需要取消装饰器时,可以通过被装饰函数的__wrapped__
属性获得被装饰之前的函数。
1 | import random |
装饰器函数本身也可以参数化,简单的说就是装饰器也是可以通过调用者传入的参数来进行定制的,这个知识点我们在后面用到的时候再为大家讲解。
4.2 递归调用
Python 中允许函数嵌套定义,也允许函数之间相互调用,而且一个函数还可以直接或间接的调用自身。函数自己调用自己称为递归调用,那么递归调用有什么用处呢?现实中,有很多问题的定义本身就是一个递归定义,例如我们之前讲到的阶乘,非负整数N
的阶乘是N
乘以N-1
的阶乘,即 ,定义的左边和右边都出现了阶乘的概念,所以这是一个递归定义。既然如此,我们可以使用递归调用的方式来写一个求阶乘的函数,代码如下所示。
1 | """ |
上面的代码中,fac
函数中又调用了fac
函数,这就是所谓的递归调用。代码第2行的if
条件叫做递归的收敛条件,简单的说就是什么时候要结束函数的递归调用,在计算阶乘时,如果计算到0
或1
的阶乘,就停止递归调用,直接返回1
;代码第4行的num * fac(num - 1)
是递归公式,也就是阶乘的递归定义。下面,我们简单的分析下,如果用fac(5)
计算5
的阶乘,整个过程会是怎样的。
1 | # 递归调用函数入栈 |
注意,函数调用会通过内存中称为栈
(stack)的数据结构来保存当前代码的执行现场,函数调用结束后会通过这个栈结构恢复之前的执行现场。栈是一种先进后出的数据结构,这也就意味着最早入栈的函数最后才会返回,而最后入栈的函数会最先返回。例如调用一个名为a
的函数,函数a
的执行体中又调用了函数b
,函数b
的执行体中又调用了函数c
,那么最先入栈的函数是a
,最先出栈的函数是c
。每进入一个函数调用,栈就会增加一层栈帧(stack frame),栈帧就是我们刚才提到的保存当前代码执行现场的结构;每当函数调用结束后,栈就会减少一层栈帧。通常,内存中的栈空间很小,因此递归调用的次数如果太多,会导致栈溢出(stack overflow),所以递归调用一定要确保能够快速收敛。我们可以尝试执行fac(5000)
,看看是不是会提示RecursionError
错误,错误消息为:maximum recursion depth exceeded in comparison
(超出最大递归深度),其实就是发生了栈溢出。
如果我们使用官方的 Python 解释器(CPython),默认将函数调用的栈结构最大深度设置为1000
层。如果超出这个深度,就会发生上面说的RecursionError
。当然,我们可以使用sys
模块的setrecursionlimit
函数来改变递归调用的最大深度,但是我们不建议这样做,因为让递归快速收敛才是我们应该做的事情,否则就应该考虑使用循环递推而不是递归。
再举一个之前讲过的生成斐波那契数列的例子,因为斐波那契数列前两个数都是1
,从第三个数开始,每个数是前两个数相加的和,可以记为f(n) = f(n - 1) + f(n - 2)
,很显然这又是一个递归的定义,所以我们可以用下面的递归调用函数来计算第n
个斐波那契数。
1 | """ |
需要提醒大家,上面计算斐波那契数的代码虽然看起来非常简单明了,但执行性能是比较糟糕的。大家可以试一试,把上面代码for
循环中range
函数的第二个参数修改为51
,即输出前50个斐波那契数,看看需要多长时间。至于为什么这么慢,大家可以自己思考一下原因。很显然,直接使用循环递推的方式获得斐波那契数列是更好的选择,代码如下所示。
1 | """ |
除此以外,我们还可以使用 Python 标准库中functools
模块的lru_cache
函数来优化上面的递归代码。lru_cache
函数是一个装饰器函数,我们将其置于上面的函数fib1
之上,它可以缓存该函数的执行结果从而避免在递归调用的过程中产生大量的重复运算,这样代码的执行性能就有“飞一般”的提升。大家可以尝试输出前50个斐波那契数,看看加上装饰器以后代码需要执行多长时间?
1 | """ |
lru_cache
是 Python 内置模块 functools
提供的一个装饰器,用于给函数添加缓存机制(memoization),即:自动记住函数以前的调用结果,如果下次用到相同的参数,直接返回结果,不再重复计算。
提示:
lru_cache
函数是一个带参数的装饰器,所以上面第4行代码使用装饰器语法糖时,lru_cache
后面要跟上圆括号。lru_cache
函数有一个非常重要的参数叫maxsize
,它可以用来定义缓存空间的大小,默认值是128。
而不使用 lru_cache
修饰器的代码如下所示:
1 | """ |
上述图片可以看出,运行了5min才仅仅运算到第43个数,因此非常耗时,使用functools
中的lru_cache
修饰器以后,运算第50个数只用了0.001s,这是一个数量级的提升,这就是装饰器的强大之处。
4.3 总结
装饰器是 Python 语言中的特色语法,可以通过装饰器来增强现有的函数,这是一种非常有用的编程技巧。另一方面,通过函数递归调用,可以在代码层面将一些复杂的问题简单化,但是递归调用一定要注意收敛条件和递归公式,找到递归公式才有机会使用递归调用,而收敛条件则确保了递归调用能停下来。函数调用通过内存中的栈空间来保存现场和恢复现场,栈空间通常都很小,所以递归如果不能迅速收敛,很可能会引发栈溢出错误,从而导致程序的崩溃。