news 2026/10/5 4:15:08

C++ bitset完全指南:从位数组到位运算实战优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++ bitset完全指南:从位数组到位运算实战优化

先问个现实问题:你写程序时有没有遇到过这种情况——要记录一组只有“是/否”两种状态的数据,比如某个数字有没有出现过、某个副本BOSS今天打没打、某个用户有没有领取过奖励。新手的第一反应通常是开一个bool数组,觉得简单直观。但稍微有点经验的人会多想一步:一个bool在 C++ 里实际占用 1 个字节,你只存 0 和 1,却花了 8 个 bit 的空间,这笔账怎么算都不划算。这就是bitset存在的意义。

bitset是 C++ 标准库里专门用来表示“一堆位”的容器,长度在编译期固定,内部每个元素只占 1 bit,内存直接压缩为bool数组的八分之一。更关键的是,它原生支持位运算,一次就能对整组数据进行与、或、异或、移位等操作,无论是对刷算法题、写底层库,还是做业务里的标志位管理,都是非常顺手的一个工具。这篇文章我不打算给你念手册,而是把bitset从构造、访问、运算到工程实战的完整用法总结一遍,顺带把我踩过的坑、查过的源码、优化过的性能方案都交代清楚,给正在学 C++ 或者在项目里需要处理位数据的朋友一个可以直接抄作业的参考。

1. 先搞清楚 bitset 到底是啥

1.1 一句话本质:编译期定长的位数组

bitset的完整声明是std::bitset<N>,其中N是一个编译期就能确定的常量表达式,它决定了这个 bitset 一共能存放多少位。你可以把bitset理解成一个“特化”的数组,只不过数组的每个元素不再是int或者bool,而是真正意义上的一个二进制位。

这里有个容易忽略的点:N必须是编译期常量。也就是说你不能写:

int n = 100; std::bitset<n> b; // 编译错误

因为模板参数必须在编译阶段就确定,而后者的n是运行时变量。如果非要根据运行时的长度来动态分配位集合,那应该选std::vector<bool>(虽然这个特化容器也有自己的坑)或者boost::dynamic_bitset。绝大多数情况下,我们在编码时其实已经能预估位的数量,比如一个 IP 地址段、一年的天数、一个枚举的取值个数,这时bitset就是最优解。

1.2 它解决了什么痛点

先算一笔内存账。假设你要存储 100 万个标志位,用bool数组:

bool flags[1000000]; // 通常占用 1MB,某些实现可能更大

换成std::bitset<1000000>:

std::bitset<1000000> flags; // 占用约 125KB

空间直接省下 87.5%,而且当 bitset 内部按底层整数块(一般是unsigned long或unsigned long long)存储时,CPU 做位运算的粒度非常友好,性能也很可观。不要小看这一点,在处理大规模位图、状态压缩、缓存标记这类场景中,内存占用往往就是瓶颈,少用几个字节可能就能把数据从磁盘换到内存、从 L3 缓存换到 L1 缓存,效果立竿见影。

bitset解决的第二个痛点是“批量操作”。普通数组你要把 100 万个元素逐个置 1,用循环一个个赋值。bitset只用一行:

flags.set();

类似于“把一整块内存的位全部翻转”这种需求,bitset几乎可以交给硬件指令级别去完成,这也是bitset在算法竞赛和底层库里出镜率极高的原因。

1.3 什么场景不该用 bitset

bitset虽好,但不是万能的。首先,长度固定这一点就限制了很多场景;其次,如果你只是需要记录几十个状态,bool数组反而更直观,没必要为了省几个字节把代码可读性搭进去;再一个,bitset的to_ulong()这类转换在处理超过内置整数位宽时需要注意溢出,有些初学者在这里踩坑踩得怀疑人生,这个我后面详细展开。

2. 30分钟上手:构造、访问与基础操作

2.1 三种常用构造方式

bitset的构造方式在设计上很贴近人的直觉,你可以构造一个全 0 的 bitset、用一个整数来初始化、或者直接传一个由'0'和'1'组成的字符串。给你看一段实际可运行的代码:

#include <bitset> #include <iostream> using namespace std; int main() { bitset<8> b1; // 全 0:00000000 bitset<8> b2(0b10100101); // 用整数初始化:10100101 bitset<8> b3(255); // 全 1:11111111,传十进制整数也可以 bitset<8> b4(string("1100")); // 字符串长度小于 8,高位补 0:00001100 bitset<8> b5(string("1010101010")); // 字符串长度 10,超过 8,取前 8 位:10101010 cout << b1 << '\n'; cout << b2 << '\n'; cout << b3 << '\n'; cout << b4 << '\n'; cout << b5 << '\n'; return 0; }

有一个细节很多人第一次接触时会犯迷糊:用字符串构造 bitset 时,字符串的最左边一个字符对应 bitset 的最高位。比如bitset<8> b("00000001"),输出是00000001,但如果你访问b[0],得到的是 1,而不是 0。这个顺序问题我们下一节细讲,因为它是所有混乱的源头。

2.2 下标访问与位的方向

bitset支持用operator[]随机访问某一位,索引 0 对应的是最低位(也就是二进制表示中最右边的那一位)。这一点和数组的习惯完全相反:数组下标 0 通常代表“第一个元素”,而 bitset 的下标 0 代表“第 0 位”,位序从右往左数。

bitset<8> b(string("10000001")); cout << b[0] << '\n'; // 1,最低位 cout << b[7] << '\n'; // 1,最高位 cout << b[1] << '\n'; // 0

这个设计其实和计算机内部整数的存储方式是一致的:整数的第 0 位就是二进制的个位。如果非要从左往右访问,你可以通过b[size() - 1 - i]来“反向”取,不过一般没必要,习惯位序之后反而更顺手。

2.3 成员函数速查:set、reset、flip、test、count

bitset的核心成员函数非常精简,但每个都很实用。我把它整理成一个速查表,方便随时翻阅。

函数作用示例结果
set()所有位置 1b.set()00000000 → 11111111
set(pos)第 pos 位置 1b.set(3)00000000 → 00001000
set(pos, val)第 pos 位设为 valb.set(0, false)00000001 → 00000000
reset()所有位置 0b.reset()11111111 → 00000000
reset(pos)第 pos 位置 0b.reset(3)00001000 → 00000000
flip()所有位取反b.flip()11110000 → 00001111
flip(pos)第 pos 位取反b.flip(0)00000001 → 00000000
test(pos)返回第 pos 位是否为 1b.test(3)true/false
count()统计 1 的个数b.count()01001101 → 4
any()是否存在至少一个 1b.any()00000000 → false
none()是否全为 0b.none()00000000 → true
all()是否全为 1b.all()11111111 → true
size()返回位数(编译期常量)b.size()8

这里我想特别强调一下test()和operator[]的区别:test()会进行边界检查,越界时抛出out_of_range异常;operator[]不做边界检查,越界行为属于未定义。在写严谨代码时建议用test(),在追求极致性能或确定不越界时可以用operator[]。我见过不少线上事故就是operator[]访问越界导致的,虽然概率不高,但一旦发生很难排查。

3. 向量级位运算:这才是 bitset 的杀手锏

3.1 支持哪些运算符

bitset几乎完整复刻了整数上的位运算符,区别在于它是“按位整体操作”,不再是单个整数之间的运算。支持的操作包括:

  • &:按位与
  • |:按位或
  • ^:按位异或
  • ~:按位取反
  • <</>>:左移 / 右移
  • ==/!=:判断两个 bitset 是否完全相等

配合复合赋值运算符&=、|=、^=、<<=、>>=也能用,使用时注意常规的位运算优先级问题(后面有一节专门讲这个坑)。来个直观的例子:

bitset<8> a(string("11001100")); bitset<8> b(string("10101010")); cout << (a & b) << '\n'; // 10001000 cout << (a | b) << '\n'; // 11101110 cout << (a ^ b) << '\n'; // 01100110 cout << (~a) << '\n'; // 00110011 cout << (a << 2) << '\n'; // 00110000,左侧两位被移出丢弃 cout << (b >> 1) << '\n'; // 01010101,右侧一位被移出丢弃

移位的规则很好理解:左移往高位方向移动,低位补 0,超出范围的高位直接丢掉;右移则反过来。利用移位可以非常方便地做“把一个 bit 挪到指定位置”的操作。

3.2 用位运算做集合操作

如果你把 bitset 的每一位理解为“某个元素是否属于集合”,那么位运算天然就是集合运算:

  • a & b= 交集
  • a | b= 并集
  • a & ~b= 差集,即属于 a 但不属于 b
  • (a & b).count()= 交集的元素个数
  • (a & ~b).none()= a 是否是 b 的子集

举个实际例子。假设一个在线教育系统里,每个学生学习过一组课程,课程编号是 0~63。判断两个学生的“共同课程”和“互补课程”,用 bitset 写出来非常优雅:

bitset<64> studentA(string("10101010")); bitset<64> studentB(string("11001100")); auto common = studentA & studentB; // 共同课程 auto onlyA = studentA & ~studentB; // A 学过但 B 没学的课程 cout << "共同课程数: " << common.count() << '\n';

如果用传统的vector<int>来管理课程,求交集就得先排序再双指针,或者搞一个哈希表。在课程数量固定且不超过 64 的情况下,bitset 的写法复杂度是 O(1),代码量也少一个量级。

3.3 优先级陷阱:位运算符的优先级低到离谱

bitset的位运算符语义没问题,但它们的优先级坑人。C++ 的位运算符优先级低于相等运算符和关系运算符,这意味着你写a & b == c时,编译器会先算b == c,再拿结果和a做与运算,这完全不是你期望的。

bitset<8> a(string("00001111")); bitset<8> b(string("11110000")); bitset<8> c(string("11110000")); // 你以为:(a & b) == c → true // 实际:a & (b == c) → 00001111 & 00000001? 不,bool转换后参与运算,结果非常诡异 if (a & b == c) { cout << "你以为会进这里?\n"; }

这种代码在编译时通常不报错,但运行结果完全不符合预期,是典型的“沉默的错误”。我的习惯是,只要在条件表达式里用了位运算符,一律加括号,不为省两个字符去赌自己和同事的优先级记忆。类似的坑在八股文里也经常被拿出来考,但实际工程里往往比面试题更隐蔽,因为没人会专门写一行这么抽象的表达式,通常是嵌套在函数调用里。

4. 类型转换与高阶实用技巧

4.1 to_string、to_ulong、to_ullong

bitset提供了三个常用的转换函数:to_string()转成std::string,to_ulong()转成unsigned long,to_ullong()转成unsigned long long。三者各有应用场景。

bitset<8> b(string("10100101")); string s = b.to_string(); // "10100101" unsigned long ul = b.to_ulong(); // 165 unsigned long long ull = b.to_ullong(); // 165 cout << s << '\n'; cout << ul << '\n';

很多算法题里需要你把一个整数的二进制表示打印出来,直接bitset<32>(num).to_string()就能搞定,比自己写循环移位再拼接字符串清爽得多。同样,解析二进制字符串时,直接构造 bitset 再转成整数,也避免了手写二进制的pow累加。

有个大坑必须提醒:当 bitset 的位数超过unsigned long或unsigned long long能表达的范围时,调用to_ulong()或to_ullong()会抛出overflow_error异常。比如bitset<100>里有一个 1 恰好在第 80 位,to_ulong()就会抛异常。这不是编译错误,而是运行时错误,如果不捕获,程序直接崩溃。

bitset<100> b; b[80] = 1; try { unsigned long x = b.to_ulong(); // 抛出 overflow_error } catch (const std::overflow_error& e) { cerr << "溢出: " << e.what() << '\n'; }

处理大位宽 bitset 时,优先考虑to_string(),或者提前判断高位是否有值。如果你是做底层协议的,建议把“是否溢出”当成一个显式分支处理,不要赌数据一定在合法范围内。

4.2 用 bitset 完成二进制位段的截取

位操作里经常需要“截取一个整数的某一段位”,比如拿一个 32 位整数的高 8 位、低 12 位。传统做法是(num >> start) & mask,需要手动算掩码,容易出错。用 bitset 之后,可以先转成 bitset,再配合移位和掩码操作:

uint32_t num = 0xABCD1234; bitset<32> b(num); // 取低 8 位 bitset<32> low8 = b & bitset<32>(0xFF); cout << bitset<8>(low8.to_ulong()) << '\n'; // 00110100 // 取第 16~23 位(从 0 开始) bitset<32> mid8 = (b >> 16) & bitset<32>(0xFF); cout << mid8.to_ulong() << '\n'; // 0xAB

这种写法在可读性上比一堆魔数掩码好太多了,尤其是当你需要和同事评审代码时,bitset的语义一目了然。

4.3 I/O 与格式化输出

bitset直接支持流输出,默认打印的是从最高位到最低位的字符串。它不支持指定进制,但配合to_string()可以做自定义格式化。比如想打印“每 4 位加一个空格”,可以这样做:

bitset<16> b(string("1010111100001100")); string s = b.to_string(); for (size_t i = 0; i < s.size(); ++i) { if (i % 4 == 0 && i != 0) cout << ' '; cout << s[i]; } cout << '\n'; // 1010 1111 0000 1100

这种格式化输出在调试二进制协议、日志排查时非常有用,建议封装成一个小的工具函数放进你的公共代码库。

5. 实战:bitset 在算法与工程中的几种玩法

5.1 素数筛优化:用 bitset 代替 bool 数组

判断质数、筛素数,是 C++ 学习路上绕不开的话题。传统埃氏筛用bool数组标记合数,内存开销大,而且循环改写bool数组里的“跳过标记”时cache miss也多。用bitset做筛法,代码更短,内存更小,还顺带把“初始化”这个动作变得更安全(bitset默认全 0,而局部bool数组默认值是随机的)。

#include <bitset> #include <iostream> using namespace std; const int N = 100000000; bitset<N> isNotPrime; // 默认全 0,表示都是质数 void sieve() { isNotPrime[0] = isNotPrime[1] = 1; for (int i = 2; i * i < N; ++i) { if (!isNotPrime[i]) { // i 是质数,筛掉 i 的倍数 for (int j = i * i; j < N; j += i) { isNotPrime[j] = 1; } } } }

这里isNotPrime是全局变量,如果是局部变量且 N 很大(比如 1 亿),要注意栈空间可能不够,建议放全局区或堆区。每次写isNotPrime[j] = 1和bool数组差不多,但因为内存占用只有后者的八分之一,整个 1 亿规模的筛法内存不过 12.5MB,在评测机或者嵌入式环境里优势非常明显。

判断质数优化这块,面试时也经常考:单个数判质数用试除法,批量筛质数用埃氏筛或线性筛,而bitset是埃氏筛的“内存优化版”标配,能把这道题从“会写”提到“会优化”的层次。

5.2 状态压缩 DP:子集枚举的利器

状态压缩动态规划(状压 DP)是算法竞赛和面试里比较高阶的内容,核心思路是把“某个集合里的元素是否被选”压缩成一个整数的二进制位。用bitset做状态集合,最大的好处是语义清晰,并且位运算直接对应集合操作。

举个简单的例子,假设有 N 个任务,每个任务有一个前置依赖集合,你要判断某个任务在当前状态下能否执行:

const int MAXN = 20; int taskDep[MAXN]; // 用整数的位表示依赖,第 i 位为 1 表示依赖任务 i bitset<MAXN> state; // 当前已完成的任务集 bool canRun(int task) { return ((state & bitset<MAXN>(taskDep[task])) == bitset<MAXN>(taskDep[task])); }

如果不依赖 bitset,你得写(state & depMask) == depMask,可以但不够直观。用 bitset 把这个判断写成“当前状态是否包含所有依赖”,配合count()检查集合大小,代码可读性上升一个档次。

当然,在真正的竞赛场景里,由于bitset<MAXN>在做==比较时没有原生整数快,很多选手还是直接用uint32_t来压状态。但如果你是做工程而不是极限优化,bitset的语义优势远大于那一点点性能差距。

5.3 布隆过滤器的位数组实现

布隆过滤器(Bloom Filter)是一种概率性数据结构,用来判断“一个元素一定不存在”或“可能存在”,底层就是多个哈希函数映射到一个位数组上。手动实现一个极简版布隆过滤器,用bitset做位数组再合适不过。

class SimpleBloomFilter { private: bitset<1024> bits; // 1K 位,实际中根据预期数据量调整 size_t hash1(const string& s) const { size_t h = 0; for (char c : s) h = h * 131 + c; return h % 1024; } size_t hash2(const string& s) const { size_t h = 0; for (char c : s) h = h * 31 + c * 17; return h % 1024; } public: void add(const string& s) { bits.set(hash1(s)); bits.set(hash2(s)); } bool contains(const string& s) const { return bits.test(hash1(s)) && bits.test(hash2(s)); } };

大家注意,真实布隆过滤器位数组大小、哈希函数个数要根据误判率来设计,这里仅演示bitset如何当位数组用。bitset的set()和test()天然支持任意位位置的随机访问,不需要手动去管理动态数组,代码非常干净。

5.4 业务系统的标志位管理

回到工程场景,bitset最朴素也最实用的用法就是“标志位管理”。比如一年 365 天的每日签到记录,用bitset<365>存储,每签到一天把对应位置 1;统计连续签到天数、总签到天数都可以高效实现。

bitset<365> checkIn; checkIn[100] = 1; // 第 101 天签到过 // 统计总签到天数 int total = checkIn.count(); // 判断某天是否连续签到 7 天(假设当天是第 d 天) int d = 200; bitset<365> last7; last7.set(); last7 <<= 357; // 这个写法只是为了演示移位,实际上我们可以换种思路

不过业务系统里如果要用一段连续的位表示状态,建议封装一个WeekMask或者MonthMask类,内部用bitset<7>或bitset<31>,暴露mark()、clear()、isMarked()等语义化接口,别让裸的bitset泄露到业务层,否则维护起来很痛苦。

6. 常见坑与排查实录

6.1 模板参数必须是编译期常量

这个是新手最容易踩的坑。std::bitset<N>的N一定得是编译期常量表达式,不能是运行时变量。很多人写一个函数,希望根据参数创建不同大小的 bitset:

void process(int n) { std::bitset<n> b; // 编译失败:n 不是常量表达式 }

这种需求本身就不适合bitset。解决方案有几种:

  • 把n变成模板参数,process<N>(),但这样编译期会把所有可能实例化出来;
  • 使用std::vector<bool>,它是动态大小,但性能和行为有差异;
  • 使用boost::dynamic_bitset<>,几乎完美的动态位集,但需要引入 Boost 依赖;
  • 自己维护一个vector<uint64_t>,在空间换使用时灵活,但要自己写位操作。

我的经验是:如果 N 的取值范围有限且边界明确,优先用模板参数;如果确实需要完全动态,直接用vector<bool>或者封装好的动态位集类,不要硬刚。

6.2 to_ulong 溢出没捕获

前面说过,to_ulong()在值超过unsigned long范围时抛overflow_error。实际项目里我遇到过同事在一段处理 IPv4 地址的代码里用了bitset<32>().to_ulong(),一直运行正常,直到某天数据异常,程序直接崩溃。崩溃点离出问题的调用隔了好几层,排查了很久。建议凡是可能出现大数的位宽,统一走to_ullong(),并且try-catch包住。

6.3 operator[] 越界

operator[]不检查边界,越界行为未定义。如果你依赖test()抛异常来发现问题,那没事;但如果你用operator[]且索引是从外部输入传进来的,务必加边界校验,否则可能读到了错误的内存数据,却不会立刻崩溃。这种 bug 最阴间,因为崩溃点远在写入之后。

6.4 大 bitset 的栈上分配问题

全局变量、静态变量、堆对象的bitset没有太大问题。但如果你在函数内部定义一个很大的局部bitset:

void foo() { std::bitset<100000000> big; // 约 12.5MB,默认栈大小通常只有 8MB // 轻则栈溢出,重则程序启动直接崩溃 }

这非常容易触发栈溢出。解决办法是把大对象放在全局、静态区,或者用new动态分配一个std::unique_ptr<bitset<...>>。如果你用的是std::unique_ptr,注意模板参数要写完整:

auto p = std::make_unique<std::bitset<100000000>>(); p->set(99999999);

6.5 位运算符优先级再强调一遍

本文最值得记下来的一条:位运算符的优先级比==和!=低。判断两个 bitset 是否相等时,如果同时做位运算,一定要加括号:

if ((a & b) == c) { ... } // 正确 if (a & b == c) { ... } // 错误

这个错误不仅是初学者犯,很多写了多年的工程师也会一不留神写出来。建议团队在代码规范里强制“条件表达式中的位运算必须用括号包裹”,并配合clang-tidy静态检查。

6.6 常见问题速查表

问题现象可能原因解决方案
编译报错:N不是常量用了变量做模板参数改模板、用vector<bool>或dynamic_bitset
运行时抛overflow_errorto_ulong()/to_ullong()溢出捕获异常,或改用to_string()
程序莫名崩溃,栈回溯不清operator[]越界改用test()并加边界校验
函数内定义大 bitset 崩溃栈空间不足放全局/静态区,或堆分配
if (a & b == c)结果诡异位运算符优先级问题加括号明确语义
字符串构造 bitset 顺序不对字符串左字符是最高位记住:最左是最高位,最右是第 0 位

我个人在实际工程中的体会是,bitset是那种“看起来不起眼、用对了能让代码量少一半”的工具。它不像智能指针、STL 容器那样被频繁提及,但只要你遇到“状态只有 0/1”的场景,第一反应从bool数组换成bitset,代码质量和运行效率都会有肉眼可见的提升。最后再分享一个小技巧:凡是涉及位操作拿不准的地方,先写一个最小的测试用例,把 bitset 打印出来看一眼,比任何推理都管用。位这个东西,眼见为实。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 4:14:16

OpenShell 开始菜单替换工具:从安装配置到深度定制与问题排查

1. 从零认识 OpenShell&#xff1a;它到底解决什么问题第一次听到 OpenShell 这个名字&#xff0c;很多人会下意识以为它跟 Linux 的 shell 脚本或者某个终端工具有关。实际上&#xff0c;OpenShell 是一个面向 Windows 平台的开始菜单替代工具&#xff0c;最早由社区开发者发起…

作者头像 李华
网站建设 2026/10/5 4:12:52

自建Secure Boot密钥体系:从密钥生成到引导器签名完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 4:12:32

代码人生:从DLL报错到量化交易,编程思维重塑世界观

凌晨一点半&#xff0c;我盯着屏幕上最后二十行报错日志&#xff0c;咖啡已经凉透了&#xff0c;编译器还在等我一个决定。说实话&#xff0c;那一刻脑子里冒出来的不是“怎么改”&#xff0c;而是“我为什么要坐在这里和一串英文字母较劲”。这种念头对程序员来说太常见了——…

作者头像 李华
网站建设 2026/10/5 4:12:32

线性回归身高预测实战:从数据清洗到SHAP解释的完整流程

简介&#xff1a;这份资源面向机器学习入门者与需要掌握回归建模的开发者&#xff0c;围绕身高预测这一典型连续值估算场景&#xff0c;讲解线性回归从原理到落地的完整思路。压缩包共2个文件&#xff0c;包含1个xlsx数据表与1个py脚本&#xff0c;整体约80KB&#xff0c;前者用…

作者头像 李华
网站建设 2026/10/5 4:11:43

C++嵌入Python完整指南:虚拟环境配置与pybind11实践

1. 为什么要把Python塞进C里&#xff1a;动机与场景先聊点实际的。很多做C服务端或桌面客户端的团队&#xff0c;都会遇到一个共同的痛点&#xff1a;业务逻辑迭代太快&#xff0c;C的编译-链接-部署链路太重了。今天改个策略参数&#xff0c;明天调个推荐规则&#xff0c;每次…

作者头像 李华
网站建设 2026/10/5 4:11:29

二维卡尔曼滤波位置速度融合:从原理到工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华