Skip to content

🤖 Daily Challenge: Problem #1404#58

Open
github-actions[bot] wants to merge 1 commit intomainfrom
daily-challenge/1404
Open

🤖 Daily Challenge: Problem #1404#58
github-actions[bot] wants to merge 1 commit intomainfrom
daily-challenge/1404

Conversation

@github-actions
Copy link

LeetCode 1404. Number of Steps to Reduce a Number in Binary Representation to One

题目信息

属性 内容
题目链接 https://leetcode.com/problems/number-of-steps-to-reduce-a-number-in-binary-representation-to-one/
难度 Medium
标签 String, Bit Manipulation, Simulation

文件位置

  • 头文件:include/leetcode/problems/number-of-steps-to-reduce-a-number-in-binary-representation-to-one.h
  • 源文件:src/leetcode/problems/number-of-steps-to-reduce-a-number-in-binary-representation-to-one.cpp
  • 测试文件:test/leetcode/problems/number-of-steps-to-reduce-a-number-in-binary-representation-to-one.cpp

我刚看到这道题时,脑子里闪过的第一个念头是:"这不就是个简单模拟吗?" 把二进制字符串转成整数,然后按照规则一步步算,直到变成 1,计数器++,完事。

但等等,瞟一眼约束条件——字符串长度最长 500。500 位的二进制数!这比 $2^{500}$ 还大,连 unsigned long long 都得哭出声。好吧,这题在故意堵死我们的捷径。我们必须得在字符串上直接操作,像手工算二进制那样。

这题到底在问什么?

本质上,我们要模拟一个极其特定的计算过程:

  • 如果最低位是 0(偶数),砍掉它(除以 2)——1 步
  • 如果最低位是 1(奇数),加 1,这可能会引发一长串进位,然后再砍掉(除以 2)——2 步(先加后除)

但这里的陷阱是,那个"加 1"操作在二进制里不是 O(1) 的。想象一下 1111 + 1,会变成 10000,进位像多米诺骨牌一样一路向左推。

第一次尝试:从右往左的直觉

既然不能转成数字,那我们就在字符串上模拟。我本能地想:从字符串末尾(最低位)开始往左看,维护一个 carry(进位)标志。

让我画个图看看状态。假设我们在处理某一位 s[i],同时有个来自低位的进位 carry

当前位的真实值 = (s[i] - '0') + carry

情况 A: 真实值是 0 (偶数)
  -> 除以 2,就是扔掉这一位
  -> 步数 +1
  -> carry 保持 0

情况 B: 真实值是 1 (奇数)  
  -> 必须先加 1 变成 2 (二进制 10)
  -> 然后除以 2,实际上就是把这一位变成 0,并向高位进 1
  -> 步数 +2 (加 1 一步,除 2 一步)
  -> carry 变成 1

这看起来对。等等,如果 carry 是 1,而当前位也是 1,那真实值就是 2(偶数)?不对,二进制位只能是 0 或 1,加上进位 1,真实值可以是 0、1、2。

让我重新梳理。当 carry=1 时,意味着低位加法给我们留了个"欠债",当前位实际上被加了 1。

具体走一遍 "1101"(十进制的 13)

真实的手算过程应该是:

  1. 1101 (13, 奇数) → 加 1 → 1110 (14) → 步数 1
  2. 1110 (14, 偶数) → 除 2 → 111 (7) → 步数 2
  3. 111 (7, 奇数) → 加 1 → 1000 (8) → 步数 3
  4. 1000 (8, 偶数) → 除 2 → 100 (4) → 步数 4
  5. 100 (4, 偶数) → 除 2 → 10 (2) → 步数 5
  6. 10 (2, 偶数) → 除 2 → 1 (1) → 步数 6

总共 6 步。

现在用我的"从右往左+进位"思路模拟。注意,我们是从字符串末尾向开头遍历,但逻辑上是在模拟"当前数字"的演变。

初始化 carry = 0, steps = 0

字符串: 1 1 0 1
索引:   0 1 2 3

从右往左(除了最左边的 '1',因为我们要变成 1,不是 0):

  • i=3 (最右的 '1'):

    • 原始位 1 + carry 0 = 1 (奇数)
    • 奇数操作:+1 (变 2), /2 (变 1 并进位)
    • steps += 2 → steps=2
    • carry = 1 (因为 1+1=10,进位了)
  • i=2 ('0'):

    • 原始位 0 + carry 1 = 1 (奇数)
    • 等等,这不对。如果当前位是 0,但有个进位 1,那实际值确实是 1(奇数)。
    • 但逻辑上,这个 0 是因为低位加 1 变成的 0,而进位传上来了。
    • 奇数操作:+1 (变 2), /2 (变 1 并进位)
    • steps += 2 → steps=4
    • carry = 1
  • i=1 ('1'):

    • 原始位 1 + carry 1 = 2 (偶数!)
    • 偶数操作:/2
    • steps += 1 → steps=5
    • carry = 1?等等,1+1=2,除以 2 是 1,余 0?不对。

这里我搞混了。当真实值是 2(二进制 10),除以 2 应该得到 1,没有余数,但会产生一个向高位的进位 1?不,除以 2 就是右移,就是把这一位去掉,把进位留给下一位。

让我重新思考进位的含义。当我们说"加 1 后除以 2",对于奇数 ...x1

  • 加 1 变成 ...(x+1)0
  • 除以 2 变成 ...(x+1)(右移)

所以原来这一位的 1 变成 0 被扔掉了,而高一位的 x 变成了 x+1。

因此,当处理第 i 位时,如果它是奇数(考虑进位后),我们需要:

  1. 步数 +1(用于加 1)
  2. 步数 +1(用于除 2,即右移)
  3. 向高位(i-1)传递一个进位(因为那一 effectively 被加了 1)

如果它是偶数:

  1. 步数 +1(用于除 2)
  2. 不进位

修正后的 walkthrough for "1101":

从右到左遍历,i 从 len-1 到 1(不包括最左边的位,因为我们要留一个 1):

carry = 0, steps = 0

i=3, s[3]='1':
  bit = 1 + 0 = 1 (odd)
  steps += 2  // 加1, 除2
  carry = 1   // 向高位进位
  
i=2, s[2]='0':
  bit = 0 + 1 = 1 (odd)  
  steps += 2  // 这一位因为进位变成了1,需要加1变2再除2
  carry = 1   // 继续进位
  
i=1, s[1]='1':
  bit = 1 + 1 = 2 (even)
  steps += 1  // 只需要除2
  carry = 1   // 等等,1+1=2,除以2得1,余0?不,2的二进制是10,除以2是1,进位?
  
  不对。如果 bit=2,意味着这一位实际上是0,同时向更高位进1?
  让我们想:这一位原本是1,加上进位1变成2(二进制10)。
  右移一位(除以2)后,这一位的0被丢弃,留下进位1给更高位。
  所以 carry 保持 1,步数只加1(除2操作)。
  
steps = 5, carry = 1

现在处理最高位(i=0, s[0]='1'):

  • 这一位是 1,加上 carry 1,变成 2。
  • 目标是变成 1。
  • 2 是偶数,需要除以 2 变成 1。
  • 所以还需要 1 步。

总步数 = 5 + 1 = 6。✓

Ah-ha! 那个微妙的边界

但是等等,我在第一次写代码时踩了个坑。看这段逻辑:

// 处理最高位
int lastBit = (s[0] - '0') + carry;
if (lastBit > 1) {
    steps += 2;  // 错误!
}

如果 lastBit 是 2(二进制 10),我们需要:

  • 加 1?不,它已经是偶数了。
  • 直接除以 2 变成 1。

所以只需要 1 步,不是 2 步!

只有当最高位加进位后变成 1(即 lastBit == 1),我们才不需要任何步骤,因为它已经是 1 了。

最终代码的构建

现在代码的结构清晰了。我们从右往左遍历到 index 1,根据当前位加进位的奇偶性决定步数,并更新进位。

int numSteps(string s) {
    int steps = 0;
    int carry = 0;
    
    // 从最低位向高位走,但不包括最高位(index 0)
    for (int i = s.size() - 1; i > 0; i--) {
        int bit = (s[i] - '0') + carry;
        
        if (bit % 2 == 0) {
            // 偶数:直接除以 2(右移)
            steps += 1;
            // carry 保持原样(0),因为 bit 是 0 或 2
            // 如果 bit 是 2,除以 2 后产生新的 carry 1?
            // 等等,bit=2 意味着 1+1,除以 2 得 1 余 0,所以 carry 应该是 1?
            // 不,在之前的迭代里,carry 已经代表了"这一位被加 1 了"
            // 如果 bit=2(即原始 1 + carry 1),说明这一位变成 0,向高位进 1
            // 所以 carry 保持 1
            if (bit == 2) carry = 1;  // 实际上是 bit / 2
            else carry = 0;
        } else {
            // 奇数:加 1(变偶数),然后除以 2
            steps += 2;
            // 加 1 后 bit 变成 bit+1(偶数),除以 2
            // 例如 1+1=2,/2=1,余 0?不,我们是右移
            // 1 (01) + 1 = 2 (10),右移得 1,进位 1
            carry = 1;
        }
    }
    
    // 处理最左边的 '1'
    int lastBit = (s[0] - '0') + carry;
    if (lastBit == 2) {
        steps += 1;  // 只需要一次除 2
    }
    // 如果 lastBit == 1,已经是 1 了,不需要步骤
    
    return steps;
}

等等,上面的偶数分支逻辑有点乱。让我简化。实际上,我们可以统一处理:

如果 bit 是 0:偶数,1 步,carry 变 0。
如果 bit 是 1:奇数,2 步,carry 变 1(因为 1+1=2,进位)。
如果 bit 是 2:偶数(1+1),1 步,carry 变 1(因为 2/2=1,进位到更高位)。

哦,原来 bit == 2 时,carry 应该保持 1!因为这一位虽然被处理了,但产生的 1 要传给更高位。

所以更简洁的写法:

for (int i = s.size() - 1; i > 0; i--) {
    int bit = (s[i] - '0') + carry;
    
    if (bit == 0) {
        steps += 1;      // 除 2
        carry = 0;
    } else if (bit == 1) {
        steps += 2;      // 加 1,然后除 2
        carry = 1;       // 加 1 产生进位
    } else if (bit == 2) {
        steps += 1;      // 除 2(因为 1+1=2 是偶数)
        carry = 1;       // 除 2 后产生进位 1
    }
}

或者更简洁,因为 bit 只能是 0、1、2:

for (int i = s.size() - 1; i > 0; i--) {
    int bit = (s[i] - '0') + carry;
    if (bit % 2 == 0) {
        steps += 1;
        carry = (bit == 2) ? 1 : 0;  // 2/2=1 进位,0/2=0 无进位
    } else {
        steps += 2;
        carry = 1;  // (1+1)/2 = 1 进位
    }
}

魔鬼细节:为什么要单独处理最高位?

想象一下字符串 "1"。按照循环 i > 0,循环不会执行。lastBit = 1 + 0 = 1,不需要额外步骤。返回 0。正确。

想象 "10"(2):

  • i=1, s[1]='0', bit=0: steps=1, carry=0
  • lastBit = 1+0 = 1: 不需要步骤
  • 总步数 1。正确(2->1 只需一步)。

想象 "11"(3):

  • i=1, s[1]='1', bit=1: steps=2, carry=1(3->4->2,两步)
  • lastBit = 1+1 = 2: steps += 1(2->1,一步)
  • 总步数 3。正确(3->4->2->1)。

如果我们在循环里不小心把 i=0 也处理了,会发生什么?对于 "1",我们会试图处理它,然后可能错误地加步骤。或者对于 "11",我们可能在处理完 i=0 后,还试图 lastBit 处理,导致重复计算。

这就是为什么我们要在循环条件里写 i > 0,把最高位留出来做特殊判断。

那道进位的幽灵

这题最精妙的部分在于理解:当我们对一个奇数做"加 1 再除 2"的操作时,实际上等价于:

  1. 当前位变 0(被移出)
  2. 向高位进 1(因为 1+1=2,2/2=1)

这个进位可能会连锁反应。比如遇到 ...0111

  • 处理最后一个 1:变 0,进位 1,+2 步
  • 处理倒数第二个 1(现在变成 2):变 0,进位 1,+1 步(偶数操作)
  • 处理倒数第三个 1(现在变成 2):变 0,进位 1,+1 步
  • 处理 0(现在变成 1):变 0,进位 1,+2 步

这种连锁反应正好被我们的 carry 变量优雅地捕捉了,而不需要真的去修改字符串。

** takeaway:反向思考与状态机**

这道题教会我们什么?当正向模拟(从高位到低位,或从数字本身出发)遇到困难(数字太大)或过于复杂(频繁的字符串修改)时,试试反向思考(从低位到高位),并引入状态(carry)来记录"历史遗留问题"。

很多字符串/数字处理问题都有这个模式:看起来需要原地修改数组,实际上只需要一个变量记录"欠账"或"余量",就能在 O(1) 空间内解决。

本报告由 AI 自动生成。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant