☰
C语言数据结构实战:栈实现数制转换(2~36进制)与避坑指南
2026/10/14 3:42:07 网站建设 项目流程

简介:面向C语言初学者与数据结构学习者,这份资料聚焦数制转换在顺序栈上的实现,将十进制转换为八进制或其他进制。资源共1个文件,为PDF格式,大小仅47KB,内容收录完整可运行的C代码,覆盖顺序栈结构定义、动态扩容初始化、入栈、出栈、判空等核心操作,并逐段给出注释与算法说明。学习时可对照代码理解conversion函数中“取余入栈、商循环更新”的核心步骤,直观看到余数逐层入栈的处理过程,体会栈的后进先出特性如何用于逆序输出每一位结果。目前已有894人学习浏览,适合课内同步练习或考前复习。通过这份实例,读者不仅能直接参考甚至动手调试完整的数制转换代码,还能举一反三地掌握不同进制间的转换逻辑,为后续学习栈与递归、表达式求值等数据结构知识打下基础。

1. 数制转换不是一道背诵题:C 语言数据结构课为什么拿它讲栈

数制转换在 C 语言数据结构课里出现频率极高,以至于不少人把它当成一道“背代码”的练习题。真正值钱的其实是背后的栈:十进制转二进制用除基取余法,余数先算出来,输出却要从最后一位开始,这个“先进后出”的顺序,正好踩在栈的定义上。接下来我会用 C 语言从零写一套数制转换实例代码,覆盖二进制、八进制、十六进制以及任意 2~36 进制,把栈的数组实现、结构体封装、负数处理和常见翻车点一次讲清楚。适合正在学数据结构、准备期末考或机试的人,也适合想找一个干净栈范例来对照的开发者。

2. 为什么一定是栈:除基取余法的逆序输出和 LIFO 的天然匹配

2.1 手工演算:13 转二进制为什么不能顺着打印

拿 13 转二进制演算一遍:13 除以 2,商 6 余 1;6 除以 2,商 3 余 0;3 除以 2,商 1 余 1;1 除以 2,商 0 余 1。结果从下往上读,是 1101。

注意余数产生的顺序是 1、0、1、1,如果你一边算一边按产生顺序打印,得到的是 1011——这不是 13 的二进制,是 13 的二进制按位反过来之后的结果。问题就出在这里:计算机执行除法运算只能从上往下算,算完第一个余数就知道它是结果的最低位,但最低位要放到输出序列的最后面,程序不知道后面还有多少位,所以必须先把余数存起来。

“先存起来,再倒着拿”就是栈的 LIFO(后进先出)行为。越早产生的余数越晚出栈,越晚产生的余数越早出栈,出栈顺序自动变成正确的书写顺序。这不是玄学,是除基取余法的数学性质和栈的结构特性刚好对上,所以数据结构教材几乎都把数制转换列为栈的第一个完整应用实例。

2.2 顺序栈还是链栈:教学代码怎么选

写数制转换的实例代码,选顺序栈还是链栈,取决于你想让读者看到什么。我一般写顺序栈,理由很直接:数组模拟栈视觉上最透明,top 就是数组下标,压栈就是先加下标再赋值,出栈就是取值再减下标,两行语句就能看懂。链栈要额外引入结点结构体、头插法、指针操作,代码量翻一倍,读者的注意力很容易从“数制转换”滑到“链表指针改对了没有”。

选型优点缺点适用场景
顺序栈代码量小、内存连续、教学直观容量固定,数据量大可能溢出课程作业、机试准备、数据量可预估
链栈动态增长,没有容量上限每次 push 要分配内存、代码更长生产环境、数据量不可预估

如果题目明确要求链栈实现,那是另一个训练目标。单论数制转换这个需求,顺序栈的简洁性没有对手。还有一种折中是动态数组栈,容量不够就 realloc,兼顾简洁和弹性,但初学者看到 realloc 又是一层负担,教学阶段没必要。数组栈唯一要留意的就是满栈检查,这个边界问题我会在第 5 章专门展开。

2.3 递归和倒序数组为什么不是主线

有同学会写递归版,代码确实短:

void dec2bin(int n) { if (n > 1) dec2bin(n / 2); printf("%d", n % 2); }

这段能跑通,但它把栈藏进了函数调用机制:递归每次调用都压一个栈帧,n / 2 递归到最深处才开始返回,打印顺序自动变成逆序。对初学者来说,这个隐式栈看不见摸不着,结果对了,栈的概念还是没学会。数据结构课要训练的是显式地使用栈,而不是让系统调用栈替你完成任务。

不用栈、直接倒序数组也能写:

int buf[32], cnt = 0; while (n > 0) { buf[cnt++] = n % 2; n /= 2; } for (int i = cnt - 1; i >= 0; i--) { printf("%d", buf[i]); }

这段代码本质上就是在手动模拟栈:cnt 是栈顶指针,存余数是入栈,倒序循环是出栈。既然算法本身就是栈的形态,不如把 push 和 pop 明明白白写出来,让栈成为代码里的主角。这也是本文从第 3 章开始坚持写显式栈版本的原因——学的是数据结构,不是解题技巧。

3. 最小可运行版本:用数组模拟栈,把十进制转成二进制

3.1 完整代码:数组当栈,十转二 30 行跑通

第 3 章先给一个最小可运行版本,目标是让读者在本地把程序跑起来,看到输入输出,再逐步加深。我用一个定长数组充当栈,把所有逻辑收在一个函数里,不需要结构体,也不需要额外的头文件:

#include <stdio.h> #define STACK_SIZE 64 void dec_to_bin(unsigned int n) { int stack[STACK_SIZE]; int top = -1; // top = -1 表示空栈 if (n == 0) { // 0 的特殊处理,否则循环进不去 printf("0\n"); return; } while (n > 0) { stack[++top] = n % 2; // 余数入栈,先移动 top 再赋值 n /= 2; // 更新商 } while (top >= 0) { // 栈非空就继续出栈 printf("%d", stack[top--]); } printf("\n"); } int main(void) { unsigned int n; while (scanf("%u", &n) == 1) { dec_to_bin(n); } return 0; }

压栈用stack[++top]是先移动栈顶指针再写入,保证第一个余数落到stack[0],最后一个余数在最高下标。出栈用stack[top--]是先取当前栈顶再下移指针,正好把最后压进去的余数先打出来。这两个表达式是数组栈的固定写法,顺序写反就是数组越界和输出错乱,建议直接背下来。

scanf("%u", &n)读入的是无符号整数。选%u而不是%d是为了在入口拦住负数——C 语言对负数的除法取模行为并不直观,本章先不碰这个雷区,完整负数处理放到第 4 章。输入 13 会输出 1101,输入 255 会输出 11111111,输入 0 会输出 0。

3.2 三个必调参数:STACK_SIZE、top 初始值和 0 的特判

第一个参数是STACK_SIZE。unsigned int 在主流平台上占 32 位,十进制转二进制最多产生 32 个余数,64 已经留了一倍余量。如果你把输入类型换成 unsigned long long,最多 64 位余数,这个常数要跟着改成 128。容量定小了,压栈时没人拦你,数据写穿数组,属于典型的静默内存破坏,初学者最容易在这上面翻车。

第二个参数是 top 的初始值。top = -1代表空栈,压栈前要先++top让下标回到 0;如果初始化成 0,第一个余数会写到stack[1],栈顶判断和容量判断全部错位,输出结果往往前面多出一个随机值。top 的语义是“当前栈顶元素的下标”,不是“下一个可用位置”,这个约定从始至终不要变。

第三个参数是 0 的特判。while (n > 0)对 0 直接不执行,如果不做处理,函数会跳过整个转换逻辑,最终只打印一个换行。很多人在测试 1、2、13 全部通过之后,一输入 0 就发现输出是空的,就是漏了这个分支。测试时建议固定跑 0、1、255、65535 四个数:0 覆盖空栈路径,1 覆盖单余数路径,255 是全 1 边界,65535 是 16 个 1,能看出位宽是否正确。

注意:数组栈的容量检查在最小版本里被省略了,因为 64 对 unsigned int 绝对够用。一旦你把它推广到任意进制或更大整数,就必须在压栈前加满栈判断,第 5.1 节会给出完整写法。

4. 升级到 2~36 进制:结构体封装栈,顺带处理负数和零

4.1 为什么封装:Stack 结构体和 push / pop / is_empty

第 3 章的代码把栈裸露在函数内部,问题在写多个转换函数时会暴露:十进制转二进制、转八进制、转十六进制,如果每个函数都重新声明一遍数组和 top,重复代码会失控。常见做法是先把栈抽象成结构体和三个操作函数,让转换逻辑只关心“入栈、出栈、判断空”这三个动作,不关心数组下标怎么动。

#include <stdio.h> #define STACK_SIZE 64 typedef struct { int data[STACK_SIZE]; int top; } Stack; void push(Stack *s, int v) { s->data[++s->top] = v; } int pop(Stack *s) { return s->data[s->top--]; } int is_empty(const Stack *s) { return s->top == -1; }

push 和 pop 直接操作结构体指针,函数内部依然沿用++top先移动、--top后移动的约定。is_empty 只读不写,所以参数用const Stack *,这个小细节能让接口语义更清楚。封装之后,转换函数里不再出现数组下标,逻辑可读性明显上升。

注意我这里依然省略了满栈判断。教学代码为了保持简洁,可以先假设容量足够,但心里必须清楚这是简化版。第 5 章会讲到生产代码里 push 之前要做什么检查,以及省略检查会带来什么后果。

4.2 十六进制字母表:查表代替 if-else

十进制的余数范围是 0~9,直接打印数字就行;十六进制余数会到 10~15,需要映射成 A~F。最常见也最不容易错的做法是维护一张字符表,用余数当下标直接取字符,比写一长串 if-else 干净得多:

void convert(long long n, int base) { static const char digits[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; Stack s; s.top = -1; if (base < 2 || base > 36) { fprintf(stderr, "base must be between 2 and 36\n"); return; } unsigned long long abs_n; if (n < 0) { putchar('-'); abs_n = (unsigned long long)(-(n + 1)) + 1; } else { abs_n = (unsigned long long)n; } if (abs_n == 0) { printf("0\n"); return; } while (abs_n > 0) { push(&s, (int)(abs_n % (unsigned long long)base)); abs_n /= (unsigned long long)base; } while (!is_empty(&s)) { putchar(digits[pop(&s)]); } putchar('\n'); } int main(void) { convert(255, 16); // 输出 FF convert(-255, 16); // 输出 -FF convert(13, 2); // 输出 1101 convert(255, 8); // 输出 377 convert(0, 8); // 输出 0 convert(4294967295LL, 16); // 输出 FFFFFFFF return 0; }

digits表长度是 36 个字符加结尾的'\0',下标 0~35 分别对应 0~35 进制的单个数字。余数最大是 35,正好落在表内;超出 36 的进制在函数开头就被拦下,不会出现越界访问。查表法把“数字到字符”的映射变成一条语句,这是数制转换代码里最值得抄的写法。

转换主循环的逻辑和第 3 章完全一致:取余、入栈、整除更新。区别只在出栈时用digits[pop(&s)]取字符,余数 10 会输出 A,余数 15 会输出 F。main 里的六个测试数据覆盖了普通数、负数、零、大数四条路径,前五个是人工可验证的固定答案,第六个 4294967295 转十六进制正好是八个 F,用来检查高位的进位是否正确。

4.3 负数怎么处理:避开 abs 和取模的隐藏雷区

负数处理的常规思路是先输出负号,再取绝对值做转换。但直接用abs(n)或n = -n有一个隐蔽问题:C 标准里对最负整数取负是未定义行为。long long 的最小值是 -9223372036854775808,它的绝对值 9223372036854775808 超出了 long long 能表示的最大值 9223372036854775807,直接取负会溢出,程序行为变得不可预测。

稳妥做法是避开溢出点:-(n + 1)先给 n 加 1,得到 -9223372036854775807,取负后是 9223372036854775807,正好是 long long 的最大值,不溢出;转成 unsigned long long 后再加 1,补回刚才减掉的 1,最终得到正确的绝对值。这个写法对任何负数都成立,也是处理整数最小值唯一安全的办法。

除法的类型也要统一。abs_n 是 unsigned long long,取余和整除都要转成 unsigned long long 再运算,否则混用有符号和无符号整数会触发隐式转换,结果难以预料。0 的特判从第 3 章保留下来,只是这次判断的是 abs_n。整套逻辑跑下来,负数、零、大数、任意进制四条路径都闭合了。如果你要转换的数超过 unsigned long long 的范围,那就进入大数运算的领域,需要自己实现除法模拟,不在本文讨论范围内。

5. 数制转换避坑清单:翻车率最高的五个细节

5.1 栈顶指针越界:满栈和空栈判断错位

现象:压栈压到数组末尾之后程序还在继续写入,运行结果不稳定,有时输出正确,有时崩溃,有时数据被莫名覆盖。

原因:定长数组栈没有满栈检查,push 函数无条件执行s->data[++s->top] = v,top 超过数组边界后写入的是栈外内存。另一个常见变体是把 top 初始化成 0,空栈判断写成top == 0,导致栈空时误判为非空,出栈取出未初始化的内存。

解决:统一 top 的语义为“栈顶元素下标”,空栈初始化成 -1,满栈判断在压栈前执行:

int is_full(const Stack *s) { return s->top >= STACK_SIZE - 1; } void push(Stack *s, int v) { if (is_full(s)) { fprintf(stderr, "stack overflow\n"); return; } s->data[++s->top] = v; }

生产代码里满栈应该由调用方提前检查,而不是在 push 里静默丢弃数据,但教学版本里至少要让程序在溢出时给出明确报错,而不是无声写穿内存。

5.2 输入 0 输出空:循环进不去,函数直接哑火

现象:跑完所有测试数据都正常,唯独输入 0 时输出一行空白,没有报错。

原因:while (n > 0)在 n 为 0 时循环体一次都不执行,栈里没有任何余数,出栈循环也直接跳过。这是除基取余法的一个天然盲区,数学上 0 的二进制就是 0,但算法流程根本走不到这一步。

解决:在转换函数开头加特判,if (n == 0) { printf("0\n"); return; }。有人用 do-while 改写循环想省掉特判,结果 0 被压栈后打印一个 0,但其他数字的末尾会多出一次无意义的循环——每次除法后商变成 0 还要再算一轮,输出结果前面多一个 0。特判是最简单且不会引入副作用的做法。

5.3 十六进制输出 10 而不是 A:忘了查表

现象:十六进制转换结果里出现 10、11、15 这样的“数字”,而不是 A、B、F。看起来像十进制和十六进制混在一起。

原因:出栈时直接用printf("%d", stack[top--])打印余数,余数 10 打印出来就是字符 “10”,没有映射到字母。

解决:维护字符表const char digits[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",出栈时用putchar(digits[pop(&s)])。需要注意表长度必须覆盖到 35,且余数在使用前先校验进制范围,否则余数一旦超过 35 就会越界读表。当初我就在这里吃过亏,先把进制校验写在转换逻辑后面,结果非法输入一路算到底,最后读了一个越界的字符。

5.4 负数转出来是空的:整数除法向零取整的锅

现象:输入 -13,程序输出空行,或者输出一串包含负号的乱码。原因是负数根本进不了转换循环。

原因:C 语言里整数除法向零取整,-13 / 2 的结果是 -6,余数是 -1,不是 1。while (n > 0)对负数自然不成立,整个循环被跳过。即使改成 do-while,压进栈的余数也是负的,查表时用负值做下标直接越界。

解决:先输出负号,再用无符号绝对值做转换。绝对值用(unsigned long long)(-(n + 1)) + 1安全计算,避免最负整数取负溢出。一个容易忽略的细节是:负数判断要在 zero 特判之前,否则 -0 的概念会干扰逻辑——虽然 C 语言里 -0 等于 0,但先处理符号会让代码意图更清楚。

5.5 输出反了:没搞清“后进先出”是给谁看的

现象:13 转二进制输出 1011,十六进制输出 51 而不是 33,越大的数错得越离谱。

原因:把“产生余数”和“打印余数”做在了同一个循环里,一边除一边打,完全没有出栈环节。这是新手最常犯的错误,本质是没理解除基取余法的结果要“从下往上读”。

解决:强行分成两个循环。第一个循环只做除法和压栈,第二个循环只做弹栈和打印,中间不掺杂其他操作。这个结构一旦固定,输出顺序就被栈的 LIFO 保证了,永远不会因为手滑写反。检查方法也简单:用 13 转二进制跑一次,预期输出 1101,如果得到 1011,说明第二个循环的弹栈顺序被写成了正向遍历数组。

6. 跑通之后怎么验证:对拍、字符串化和逆运算回环

6.1 用 sprintf 对拍:让系统输出当裁判

手动测试几个固定答案只能证明“这几个数对了”,覆盖不了全部路径。更可靠的做法是拿 C 标准库的格式化输出当裁判:printf("%X", n)本身就是十进制转十六进制,直接和我的 convert 函数对比,一千个数只要跑一遍就能自动核对。前提是先把转换结果写进字符串,而不是直接打印到屏幕。

6.2 字符串化:把转换结果返回给调用方

把打印函数改成输出到缓冲区,是这个代码能复用的关键一步。函数签名可以是这样:

int to_base(long long n, int base, char out[], size_t size);

函数内部逻辑和第 4 章完全一样,只是把putchar(digits[...])改成写入out的下一个位置,最后补'\0'。size参数用来做缓冲区长度检查,这是和 printf 一样的纪律——转换结果最长不超过 64 个字符加一个负号和一个结尾符,缓冲区给 128 足够,但函数内部仍要判断写入位置不越过size - 1。

有了to_base,对拍循环就顺理成章了:

char expect[64], got[64]; for (int n = 0; n < 10000; n++) { sprintf(expect, "%X", n); to_base(n, 16, got, sizeof(got)); if (strcmp(expect, got) != 0) { printf("mismatch at %d: %s vs %s\n", n, expect, got); } }

跑一遍,所有不一致的输入会逐行打印出来,比肉眼对答案高效得多。

6.3 逆运算回环:转出去再转回来

对拍依赖标准库支持十六进制和八进制,二进制没有原生格式化输出。这时候可以用逆运算回环:写一个from_base把任意进制字符串转回十进制,再验证from_base(to_base(n, b), b) == n对所有 n 成立。逆运算就是加权求和,核心只有三行:

long long from_base(const char *s, int base) { long long v = 0; while (*s) { int d = (*s >= 'A') ? (*s - 'A' + 10) : (*s - '0'); v = v * base + d; s++; } return v; }

对拍负责和标准库对照,回环负责覆盖标准库管不到的二进制和 2~36 任意进制。两种验证配合起来,转换代码的正确性就能在一个大样本空间上被自动证明,而不是靠几个手算答案撑场面。

我自己的习惯是每写完一版转换函数,先跑 0、1、7、8、15、16 这组边界值,再跑负数和最大整数,最后做一轮对拍和回环。某次我把 top 初始值写成 0,输出全部多了一个前导数字,排查到深夜才发现是空栈约定没统一;还有一次直接对 LLONG_MIN 取负,程序行为变得像玄学,从那以后再也不敢跳过最小值测试。这套验证流程帮我挡掉了至少三次类似的血泪教训,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询