Skip to content

🤖 Daily Challenge: Problem #1356#57

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

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

Conversation

@github-actions
Copy link

LeetCode 1356. Sort Integers by The Number of 1 Bits

题目信息

属性 内容
题目链接 https://leetcode.com/problems/sort-integers-by-the-number-of-1-bits/
难度 Easy
标签 Array, Bit Manipulation, Sorting, Counting

文件位置

  • 头文件:include/leetcode/problems/sort-integers-by-the-number-of-1-bits.h
  • 源文件:src/leetcode/problems/sort-integers-by-the-number-of-1-bits.cpp
  • 测试文件:test/leetcode/problems/sort-integers-by-the-number-of-1-bits.cpp

当我第一次看到这道题的时候,我盯着屏幕愣了三秒钟。LeetCode 1356,"根据数字二进制下 1 的数目排序"。这听起来就像是对标准库的一次简单调用,对吧?但等等,让我们先别急着写 std::sort,把题意嚼碎了看看到底在问什么。

我们要做的,是给定一个整数数组,按照两个关键字排序:

  1. 主关键字:数字二进制表示中 1 的个数(popcount)
  2. 次关键字:如果 1 的个数相同,就按数值大小排

比如 [0,1,2,3,4,5,6,7,8],0 有 0 个 1,1/2/4/8 各有一个 1,3/5/6 各有两个 1,7 有三个 1。所以输出是 [0,1,2,4,8,3,5,6,7]

看起来 straightforward,但这种"二级排序"往往是陷阱的开始。

最初的直觉:我能不能不用库函数?

我的第一个念头是叛逆的——"我要手动实现这个排序吗?" 毕竟,如果只是为了比较两个数,我们好像需要一种特殊的比较逻辑。

我脑海中浮现出这样的画面:

arr = [3, 5, 1]

3 的二进制: 011 (两个 1)
5 的二进制: 101 (两个 1)  
1 的二进制: 001 (一个 1)

所以 1 应该在最前面,然后 3 和 5 相比,3 < 5,所以是 [1, 3, 5]

如果我自己写快排,比较函数大概长这样:

bool compare(int a, int b) {
    int countA = countBits(a);  // 某种计算 1 的个数的方法
    int countB = countBits(b);
    
    if (countA != countB)
        return countA < countB;
    return a < b;
}

但这感觉像是在重复造轮子。C++ 的 std::sort 已经够快了(通常是 introsort,O(N log N) 且常数极小),我为什么要自己写快排?更重要的是,我需要预先把所有数的 popcount 算出来存起来吗?

这就是第一个认知陷阱:我本能地想创建一个 vector<pair<int, int>>,存 (popcount, value),然后排序,最后把 value 提取出来。代码可能是:

vector<pair<int, int>> temp;
for (int x : arr) {
    temp.push_back({countBits(x), x});
}
sort(temp.begin(), temp.end());
// 再提取...

这能工作,但看起来有点笨重。我需要额外的 O(N) 空间,而且创建了这么多 pair,真的有必要吗?

洞察时刻:比较器即策略

然后我突然意识到——排序的本质是定义序关系(ordering),而不必是数据预处理

std::sort 允许我传入一个自定义的比较器(comparator)。这个比较器是一个函数,它只回答一个问题:"a 应该在 b 前面吗?" 我不需要预先计算并存储所有值,我只需要在比较的那一瞬间,实时计算出 popcount 即可。

这就像是问:"为了比较两本书的厚度,我需要先把图书馆所有书的厚度都量一遍吗?" 不,我只需要在比较这两本的时候,拿尺子量一下它们就行了。

于是,算法的骨架变得清晰了:

sort(arr.begin(), arr.end(), [](int a, int b) {
    int ca = countBits(a);
    int cb = countBits(b);
    return ca == cb ? a < b : ca < cb;
});

核心逻辑就这几行。但这里有个微妙的问题:countBits 怎么实现?

构建:位操作的优雅

现在我们来搞定那个 countBits 函数。最朴素的方法是一个位一个位地检查:

int countBits(int n) {
    int count = 0;
    while (n) {
        count += (n & 1);
        n >>= 1;
    }
    return count;
}

这没问题,但总觉得不够酷。你知道那个著名的位操作技巧吗?n & (n-1) 会把 n 的最低位的 1 变成 0。

n     = 1011000
n-1   = 1010111
n&(n-1)=1010000  <- 最右边的那个 1 被干掉了

这意味着,我们可以通过不断执行 n &= n-1 直到 n 变成 0,来统计 1 的个数。这在稀疏的数(1 很少)时特别快:

int countBits(int n) {
    int count = 0;
    while (n) {
        n &= n - 1;  // 清除最低位的 1
        count++;
    }
    return count;
}

但等等,如果你用的是 GCC 或 Clang,其实可以直接用内置指令:

int countBits(int n) {
    return __builtin_popcount(n);  // 直接调用 CPU 指令,O(1) 神速度
}

这就是工程实践中的权衡:你要写可移植的算法,还是要榨干硬件性能?对于 LeetCode 来说,用内置函数通常是最爽的,但理解 n & (n-1) 的原理能让我们在面试中加分。

魔鬼细节:当比较器遇上稳定性

现在我们把完整代码拼起来,但这里藏着一个坑。看这段代码:

class Solution {
public:
    vector<int> sortByBits(vector<int>& arr) {
        auto countBits = [](int n) {
            int c = 0;
            while (n) {
                n &= n - 1;
                c++;
            }
            return c;
        };
        
        sort(arr.begin(), arr.end(), [&](int a, int b) {
            int ca = countBits(a);
            int cb = countBits(b);
            if (ca != cb) return ca < cb;
            return a < b;
        });
        
        return arr;
    }
};

看起来完美,对吧?但让我问你:如果两个数完全相等,这个比较器会返回什么?

在 C++ 的严格弱序(strict weak ordering)要求中,比较器必须满足:

  • comp(a, b)comp(b, a) 不能同时为 true
  • comp(a, a) 必须为 false

在我们的实现中,如果 a == b,那么 ca == cb,于是我们进入第二个判断 a < b,返回 false。很好,满足条件。但要是我们不小心写成了 return ca <= cb; 呢?那就灾难了,排序会崩溃。

还有个小细节:0 的处理。0 的二进制没有 1,我们的 countBits 处理 0 时会直接返回 0,因为 while(n) 根本不会执行。这正好符合题意。

完整 walkthrough

让我们用示例 2 来 debugger 一下:

输入:[1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1]

这些都是 2 的幂,二进制都只有一个 1。所以 countBits 对所有数都返回 1。

排序时,比较器发现所有数的 popcount 都一样,于是退回到数值比较。原数组是降序的,我们要变成升序:

1024 vs 512: popcount 都是 1,1024 > 512,所以 512 在前
...
最终: [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024]

完美。

这到底教会了我们什么?

回过头看,这道题其实是个绝佳的隐喻:复杂问题的表面往往掩盖了简单的抽象

我们一开始可能会被"二进制"、"1 的个数"这些词汇吓到,想着要不要建个哈希表预处理,要不要写个复杂的桶排序。但最终的解法只是在标准排序上加了一层"视角转换"——我们不比较数本身的大小,而是比较它们在某个特征空间(feature space)中的投影

在机器学习中,这就像是核技巧(kernel trick):我们在原始空间不好分离数据,就映射到另一个空间去做排序。在这里,这个映射函数就是 popcount

而且,那个 n & (n-1) 的小技巧提醒我们:对数据表示的深刻理解能带来算法效率的质变。如果你不知道位操作,你可能要写一个循环 32 次的函数;你知道了,平均可能只需要循环 3-4 次(取决于 1 的密度)。

最后,关于代码组织——lambda 捕获和自定义比较器是现代 C++ 的利器。它让我们能把逻辑局部化,不用在类里定义一堆静态函数,保持了代码的紧凑和可读性。

所以下次当你看到"按某某特征排序"时,记得:不需要复杂的数据结构,只需要一个聪明的 comparator,和一点点位操作的魔法。

// 最终代码,简洁而有力
class Solution {
public:
    vector<int> sortByBits(vector<int>& arr) {
        auto popcount = [](int x) {
            int c = 0;
            for (; x; x &= x - 1) c++;
            return c;
        };
        
        sort(arr.begin(), arr.end(), [&](int a, int b) {
            int pa = popcount(a), pb = popcount(b);
            return pa == pb ? a < b : pa < pb;
        });
        
        return arr;
    }
};

本报告由 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