将整数减少到零需要的最少操作数 图解题解
这道题到底在问什么
- 输入
- n = 39
- 输出
- 3 (加 1 到 40,减 8 到 32,减 32 到 0)
- 输入
- n = 54
- 输出
- 3 (加 2 到 56,加 8 到 64,减 64 到 0)
最优解:为什么这么做
一句话答案:LeetCode 2571 将整数减少到零需要的最少操作数:按位贪心从低位扫二进制,孤立的 1 直接减,连续一串 1 加一个 2 的幂整体进位更省,扫完补结算最高段。时间 O(log n)、空间 O(1)。
每次加减一个 2 的幂,怎样最快把 n 清零
给一个正整数 n,每次操作给它加上或减去一个 2 的幂——1、2、4、8 这些 2 自乘出来的数,问最少几次能把 n 变成 0。题面例子:39 加 1 到 40、减 8 到 32、再减 32 到 0,3 步;54 也是 3 步。
39 的二进制有 4 个 1,答案为什么不是 4
39 的二进制是 100111。二进制就是只用 0 和 1 记数,每一位分量从右往左依次是 1、2、4、8、16、32,最右那头叫低位;100111 里四个 1 的分量 32、4、2、1 正好凑成 39。
看着很对的答案:四个 1 各减自己那份要 4 次,可题面只要 3 次。差在低三位 111:逐个减要 3 次,而加 1 让整串逢二进一、一次归零,这一步叫进位。
一串连续的 1,为什么加一次比逐个减省
给两种手段各记个账。连续的 1 长度为 k:逐个减花 k 次;给最低位加它那份 2 的幂,让整段进位归零只花 1 次,代价是段顶多出一个 1、撑死再花 1 次。长度为 1 直接减最划算;到 2 及以上进位稳赚,5 个连续 1 逐减 5 次,进位加收尾只 2 次。
于是从低位往高位扫:遇 1 先攒着,cnt 记这段连续 1 攒了多长;遇 0 按段长结算。这是贪心(每步选当下更省的处理法),扫一遍出答案。
结算一段 1 时,cnt 为什么有时归 0 有时归 1
参考代码里 n & 1 取出最低位看是不是 1,n >>= 1 把 n 右挪一位、扔掉刚看过的那位。最低位是 1 就 cnt 加一;是 0 且 cnt 大于 0,一段连续 1 到头,ans 加 1 结算。
结算后 cnt 设成几有讲究:孤立的 1(cnt 为 1)直接减掉,cnt 归 0;连续 1(cnt 大于等于 2)走进位,顶出的新 1 落在这个 0 上、成为下段起点,cnt 归 1。
100111 扫六位,39 的 3 步从哪来
39 = 100111,cnt、ans 开局都是 0。第 0 位 1,cnt=1;第 1 位 1,cnt=2;第 2 位 1,cnt=3;第 3 位 0,段长 3 进位,ans=1、cnt 归 1,对应加 1 到 40;第 4 位 0,段长 1 直接减,ans=2、cnt 归 0,对应减 8 到 32;第 5 位 1,cnt=1。
六位扫完,手上还剩 cnt=1:最高段没有更高的 0 触发结算,循环外补 1 次,ans=3,减 32 清零,正是题面写的 3。54 = 110110 同一套:低处 11 进位记 1 次,顶上来的 1 和高处两个 1 汇成段长 3,扫完补 2 次,一共 3 次。
39 扫完就返回 ans,结果为什么只有 2
时间 O(log n)(大 O 是数据每多一位、操作量跟着长多少的记法),只扫 n 的二进制位,n 不超过 10 的 5 次方也就十七位;空间 O(1),只有 cnt 和 ans 两个变量。
循环外那两行补结算最容易被删:扫完直接返回,39 输出 2、54 输出 1;cnt 剩 1 补 1 次、剩 2 个以上补 2 次。cnt 为 2 的段同样走进位、归 1;写成 3 个以上才进位,54 就答成 4。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3记牢这套口诀:连续 1 短就减、长就进位,进位会往高位带一个 1,扫完别忘了结算最高段。下面从 n = 39 开始,每一帧都在套它。
- 4先把 39 写成二进制 100111。约定从最右边这一位(也就是最低位)往左看,一路维护两个数:cnt 记现在攒了几个连续的 1,ans 记一共花了几次操作。开局两个都是 0。
- 5指针移到从右数第 0 位,这一位是 1。1 能接上前面那串连续的 1,把连续段拉长,先攒着不动手。
- 6这一位是 1,连续段又长了一格,cnt 变成 1。绿色就是当前这串连续的 1,攒着等碰到 0 再一次性算账。
- 7指针移到从右数第 1 位,这一位是 1。1 能接上前面那串连续的 1,把连续段拉长,先攒着不动手。
- 8这一位是 1,连续段又长了一格,cnt 变成 2。绿色就是当前这串连续的 1,攒着等碰到 0 再一次性算账。
- 9指针移到从右数第 2 位,这一位是 1。1 能接上前面那串连续的 1,把连续段拉长,先攒着不动手。
- 10这一位是 1,连续段又长了一格,cnt 变成 3。绿色就是当前这串连续的 1,攒着等碰到 0 再一次性算账。
- 11指针移到从右数第 3 位,这一位是 0。它前面正攒着 cnt = 3 个连续的 1,这一段该结算了。
- 12这一位是 0,前面攒了 3 个连续的 1。一串两个以上的 1,与其一个个减,不如加 1 让它整体进位,只花 1 次操作。进位产生的 1 顶到当前这个 0 的位置上,成为新连续段的起点,所以 cnt 归 1,ans 变成 1。绿色从那一长串挪到了这一格。
- 13指针移到从右数第 4 位,这一位是 0。它前面正攒着 cnt = 1 个连续的 1,这一段该结算了。
- 14这一位是 0,前面只攒了 1 个 1。孤零零一个 1,最划算的就是直接减掉它对应的那个 2 的幂,花 1 次操作,ans 变成 2,连续段清零。
- 15指针移到从右数第 5 位,这一位是 1。1 能接上前面那串连续的 1,把连续段拉长,先攒着不动手。
- 16这一位是 1,连续段又长了一格,cnt 变成 1。绿色就是当前这串连续的 1,攒着等碰到 0 再一次性算账。
- 17所有位都扫完了,但手上还攒着 cnt = 1 一个单独的 1。循环里只有碰到 0 才结算,最高位上面没有更高的 0 来触发,所以要在这里补一次:这个单独的 1 减掉,ans 变成 3。这就是最终答案。
- 18换第二个例子 n = 54,二进制是 110110。套路你已经熟了,这一遍走快一点。重点看低位那段连续 1 进位后带上去的 1,会接上最高处的两个 1,在顶端汇成 cnt = 3 的一段,扫完再对这一段补 2 步。
- 19这一位是 0,手上也没攒着连续段,什么都不做,指针继续往左移。
- 20这一位是 1,连续段又长了一格,cnt 变成 1。绿色就是当前这串连续的 1,攒着等碰到 0 再一次性算账。
- 21这一位是 1,连续段又长了一格,cnt 变成 2。绿色就是当前这串连续的 1,攒着等碰到 0 再一次性算账。
- 22这一位是 0,前面攒了 2 个连续的 1。一串两个以上的 1,与其一个个减,不如加 1 让它整体进位,只花 1 次操作。进位产生的 1 顶到当前这个 0 的位置上,成为新连续段的起点,所以 cnt 归 1,ans 变成 1。绿色从那一长串挪到了这一格。
- 23这一位是 1,连续段又长了一格,cnt 变成 2。绿色就是当前这串连续的 1,攒着等碰到 0 再一次性算账。
- 24这一位是 1,连续段又长了一格,cnt 变成 3。绿色就是当前这串连续的 1,攒着等碰到 0 再一次性算账。
- 25所有位都扫完了,手上还攒着 cnt = 3 个连续的 1。这一段在最高处,先加 1 进位把它合并成更高位的一个 1(1 次),这个单独的 1 再减掉(1 次),一共补 2 次,ans 变成 3。这就是最终答案。
⚠️ 容易写错的地方
✗ 错:把每个 1 都单独减掉
✓ 对:连续 ≥ 2 个 1 用一次进位合并更省
三个连续的 1 单独减要 3 步,进位合并后只需 2 步,越长的连续段省得越多
✗ 错:忘了扫完还要结算最高处剩下的连续段
✓ 对:循环结束后按 cnt 是 1 还是 ≥ 2 补 1 步或 2 步
最高段上面没有更高的 0 来触发结算,漏掉这一步答案会偏小
✗ 错:以为答案就等于二进制里 1 的个数
✓ 对:连续的 1 会被进位合并,答案通常更小
39 的二进制有 4 个 1,答案却是 3,因为末尾三个连续 1 只花了 2 步
✗ 错:从高位往低位扫
✓ 对:必须从最低位起往高位扫
进位是往高位方向传的,方向反了,低位连续段对高位的影响就算不对
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def minOperations(self, n: int) -> int:
ans = cnt = 0
while n:
if n & 1:
cnt += 1
elif cnt:
ans += 1
cnt = 0 if cnt == 1 else 1
n >>= 1
if cnt == 1:
ans += 1
elif cnt > 1:
ans += 2
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int minOperations(int n) {
int ans = 0, cnt = 0;
for (; n > 0; n >>= 1) {
if ((n & 1) == 1) {
++cnt;
} else if (cnt > 0) {
++ans;
cnt = cnt == 1 ? 0 : 1;
}
}
ans += cnt == 1 ? 1 : 0;
ans += cnt > 1 ? 2 : 0;
return ans;
}
};Java
import java.util.*;
class Solution {
public int minOperations(int n) {
int ans = 0, cnt = 0;
for (; n > 0; n >>= 1) {
if ((n & 1) == 1) {
++cnt;
} else if (cnt > 0) {
++ans;
cnt = cnt == 1 ? 0 : 1;
}
}
ans += cnt == 1 ? 1 : 0;
ans += cnt > 1 ? 2 : 0;
return ans;
}
}复杂度
时间
O(log n)
只遍历 n 的二进制位,位数是 log n 量级;n 最大 10 的 5 次方,也就十七位左右。循环外再补一次结算是常数,整体随位数线性,不随 n 的数值大小成比例增长
空间
O(1)
全程只用 cnt 和 ans 两个整数变量,不开数组、不用递归栈,占用与 n 无关,是常数空间
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 将整数减少到零需要的最少操作数 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
答案为什么不等于二进制里 1 的个数?+
1 的个数是「每个 1 各减一次」这种做法的操作数,它只是一个上界。连续成串的 1 有更省的走法:加一个 2 的幂让整段进位,1 次顶掉一串,再花 1 次处理顶出来的 1。所以每段长度大于等于 2 的连续 1 只贡献 2 次操作,而不是段长那么多次。39 有 4 个 1,答案却是 3,省的就在低三位那串。
一次加法只加一个数,怎么能消掉一整串 1?+
加的是这段最低那位对应的 2 的幂。逢二进一是加法自带的连锁:最低位满 2 往左顶 1,顶到的那位又是 1、又满 2 继续往左,一路把整段 1 清成 0,最后停在段顶上方的 0 处落下一个 1。这整串连锁属于同一次加法,操作数只记 1。39 的低三位 111 加 1 变成 1000,就是这么来的。
不用按位贪心,这题还能怎么写?+
可以写成记忆化搜索(算过的结果存起来、下次直接取):n 是偶数就右移一位交给子问题;n 是奇数就在减 1 和加 1 之间取更省的一边、再记 1 次操作;递归到 n 为 0 返回 0。两种写法答案一致,复杂度都是对数级,但按位贪心不用递归、两个变量扫一遍就完,更好写也更好讲。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 将整数减少到零需要的最少操作数 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。