整数取模运算在C++与Python中的差异
在实际应用中我突然意识到C++与Python的取模运算是不同的,这体现在被模数为负数时,这篇笔记仅讨论整数取模运算的差异以及产生差异的原因,暂不讨论浮点数
一、问题背景
- 昨天我为了测试DQN算法,在Python中使用pygame搭建了贪吃蛇游戏环境,在游戏的逻辑中蛇的前进方向分为上下左右四个方向,算法所需的简化动作仅需三个即可,即直走、向左转和向右转,其中向右转就是在原来方向(上下左右四个)的基础上顺时针(即
右->下->左->上->右
的循环)旋转递增即可,而向左转则逆时针即可,程序如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 蛇的四个前进方向
class Direction(Enum):
RIGHT = 1
LEFT = 2
UP = 3
DOWN = 4
class SnakeGameAI:
... # 其余函数略
def Move(self, action:np.ndarray[Literal[3], int]) -> None:
# 记录顺时针方向,用于得到[当前方向]叠加[action]操作后得到的具体方向
clockWise = [Direction.RIGHT, Direction.DOWN, Direction.LEFT, Direction.UP]
# 保存蛇原本的方向在clockWise中的索引数,类似STL的vector的find函数
idx = clockWise.index(self.direction)
# 蛇运动的新方向,等待经判断后获得赋值
new_dir = None
# 对比传入的action动作矩阵,[1,0,0]表示直走,[0,1,0]表示右转,[0,0,1]表示左转
if np.array_equal(action, [1,0,0]):
# 若是直走,则蛇保持原有的方向不变
new_dir = clockWise[idx]
if np.array_equal(action, [0,1,0]):
# 若是向右转,则在原方向的基础上顺时针变换一个方向,取模应对的是idx从最大索引3递增到4的情况
new_dir = clockWise[(idx + 1) % 4]
if np.array_equal(action, [0,0,1]):
# 若是向左转,则在原方向的基础上逆时针变换一个方向,(-1%4)的结果是3
new_dir = clockWise[(idx - 1) % 4]
# 赋予当前蛇以新的方向
self.direction = new_dir
... # 其余操作略
列表
clockWise
存储的代表四个方向的数分别为0,1,2,3四个索引,在向左转的new_dir = clockWise[(idx - 1) % 4]
计算中,当idx
为0
时,即从向右的基础方向上左转,得到的新方向应当为向上,即应当得到3
这个数,也即(-1%4)=3
形成了列表clockWise
的首尾相接至此一切正常,但是总感觉貌似有哪里不太对劲,然后当我使用C++进行
std::cout << -1 % 4 << "\n";
输出时,结果赫然为-1
二、除法运算的差异
在讨论为何会导致上述同一算式的结果差异之前,我们先看一看C++和Python的除法上的差异
2.1 C++的除法
- 当除数和被除数都为整型时,
/
为去除小数位的除法(因为C++中整数运算只能输出整数,小数位会被去除掉),所以口算结果只需正常算出带小数的结果然后直接去除小数位部分即可;对于结果为正的除法来说,结果去除小数位是向下取整;而对于结果为负的除法来说,结果去除小数位是向上取整 - 而浮点型之间的
/
既可以输出整数也可以输出小数,可以小数精度做限制 - 注意
/
的右侧操作数(即分母)不可为0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
cout << 10 / 3;
//3
cout << 3 / 5;
//0
cout << -2 / 4;
//0
cout << -5 / 2;
//-2
cout << -5 / -2;
//2
cout << -2 / -5;
//0
2.2 Python的除法
Python有两种除法,分别是
/
与//
Python的
/
(除)不同于C++的/
(整除),这里的除保留小数位,具体保留几位需手动控制,这里不详细展开Python的
//
是整除,但是此整除同样与C++的/
有差异,后者是直接将结果的小数位舍弃,结果为正则向下取整,为负则向上取整;而前者无论结果是正是负,都进行向下取整
1
2
3
4
5
6
7
8
9
10
11
12
13
14
print(11//2)
# 结果为5.5向下取整,即5
print(2//10)
# 结果为0.2向下取整,即0
print(-2//10)
# 结果为-0.2向下取整,即-1(若是C++的`/`运算符,则结果为0)
print(-10//4)
# 结果为-2.5向下取整,即-3
print(-10//-4)
# 2
print(-2//-5)
# 0
三、取模运算的差异
取模运算的差异实际上就产生于两种语言的除法运算的差异
3.1 C++的取模
- C++中
%
代表取余运算,其右边同样不能为0
,C++中的取模运算实际上是A % B = A - A / B * B
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cout << 10 % 3 << "\n";
//1
cout << 3 % 5 << "\n";
//3
//注意负数的取模运算
cout << 5 % -2 << "\n";
//1
cout << 2 % -5 << "\n";
//2
cout << -2 % 4 << "\n";
//-2
cout << -7 % 3 << "\n";
//-1
cout << -5 % -2 << "\n";
//-1
cout << -2 % -5 << "\n";
//-2
3.2 Python的取模
- Python的取模运算实际上是
A % B = A - A // B * B
,这与C++相同,由于Python和C++的整除(C++中为/
)的差异,故而导致了运算结果的不同,这个差异尤其体现在当被模数为负数时 - 以
-1%4
为例,按照正常逻辑,算式a%b
的结果应当与(a+n*b)%b
的结果相同,无论二者正负,所以-1%4=3%4=3
,这是Python的结果;但C++就很奇怪地得到-1
,是由于其-1/4
等于0
的缘故(Python的-1//4
结果为-1
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
print(1%3)
# 1
print(11%3)
# 2
# 注意负数的取模运算
print(-1%4)
# 3
# 浮点数直接套公式
print(0.1%4)
# 0.1-0.1//4*4 = 0.1-0*4 = 0.1
# 至于%右侧为浮点数的情况,这与//右侧为浮点数的情况有关,这个比较复杂
四、底层探究
- 上述二者取余的差异源自除法的差异,联系到我最近学习计算机组成原理的时候了解到除法是会保留余数的(所以取余运算实际上就是进行除法),这其中有什么关联我暂不得而知
- 我还没学过编译原理等,至于不同语言的底层是怎么实现取余,其考量又是什么,等学了就回来补上这一板块的内容(如果没忘记的话)
五、问题解决
了解了二者的差异后,回到原来的背景上,如果用C++的话,贪吃蛇的向左转部分的逻辑使用取模运算就会出问题,问了下信竞的同学说一般会采取将取模运算统一为
(A % B + B) % B
的方法来代替所有的取模运算,使得所有语言、所有编译器的取模运算得到的结果都是一致的C++可以通过自定义类型的运算符重载来实现,此处暂且用
\%
来表示这个新的取模运算,还是以-1 \% 4
为例,在C++中会得到(-1 % 4 + 4) % 4
等于(-1 + 4) % 4
即3
;Python中会得到(-1 % 4 + 4) % 4
等于(3 + 4) % 4
即还是3
;这样就统一运算结果了,且这样的结果更合乎正常逻辑无论被模负数
A
的绝对值有多大,(A % B + B)
最终都是一个正数,如C++中-9 \% 4
即为(-1 + 4) % 4
,与我们正常思维下将所有为负数的被模数先(通过加上n个%
右侧的数)转化为正数后再进行取模运算的逻辑是连贯的