尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C语言实现回文判断:队列与栈的经典应用与数据结构理解

C语言实现回文判断:队列与栈的经典应用与数据结构理解 1. 项目缘起从一道经典面试题说起最近在整理一些基础的数据结构题目发现“回文判断”这个老伙计又冒了出来。这题太经典了经典到几乎每个学过数据结构的人都被它“拷打”过。常规解法无非是双指针从两头往中间扫或者用个数组存起来再比较简单直接。但这次我想玩点不一样的——题目要求用队列和栈来实现。为什么非得用这俩直接strrev一下再strcmp不香吗这里面的门道恰恰是面试官想考察的核心你是否真正理解了队列FIFO和栈LIFO这两种抽象数据类型的特性并能将其组合起来解决实际问题。这不仅仅是写个能跑的程序更是对数据结构本质理解的一次检验。用数组或指针你操作的是连续的内存和索引而用队列和栈你操作的是“先进先出”和“后进先出”这两个抽象规则代码的语义层次立刻就上来了。这个项目非常适合正在夯实C语言基础、准备技术面试的朋友。通过它你不仅能复习队列和栈的基本操作入队、出队、入栈、出栈更能深刻体会到如何将现实问题判断对称性映射到合适的数据结构上。下面我就带你从零开始手把手实现一个健壮、高效且讲解透彻的C语言回文判断程序。2. 核心数据结构队列与栈的C语言实现抉择在动手之前我们得先解决一个根本问题在C语言里队列和栈怎么表示C不像C有STL也不像Java有现成的集合框架一切都需要我们从底层搭建。这里主要有两种主流思路选择哪一种直接决定了后续代码的复杂度和性能特征。2.1 方案一基于数组的静态实现这是最直观、也是新手最常用的方法。我们预先分配一个固定大小的字符数组作为存储区。对于栈需要一个栈顶指针比如top初始为-1。push操作就是data[top] ch;pop操作就是return data[top--];。对于队列需要两个指针队头front和队尾rear。入队rear后移出队front后移。这里有个经典问题——“假溢出”即rear走到数组末尾但前面还有空位。所以通常采用循环队列的技巧利用取模运算(rear1)%MAX_SIZE来实现数组空间的复用。静态实现的优缺点分析优点实现简单内存连续访问速度快没有动态内存管理的开销。缺点容量固定MAX_SIZE。如果我要判断的字符串长度超过MAX_SIZE程序就挂了。你得事先预估一个足够大的值但这既不优雅也可能浪费内存。提示如果采用静态数组务必在入队/入栈前检查是否已满rear1)%MAX_SIZE front或top MAX_SIZE-1并在出队/出栈前检查是否为空。这是健壮性代码的基本要求。2.2 方案二基于链表的动态实现为了突破固定容量的限制我们可以用链表来动态存储每个字符。对于栈实现一个单链表push就是在链表头部插入新节点pop就是删除并返回头节点。栈顶指针就是链表的头指针。对于队列同样实现一个单链表但需要维护头尾两个指针。入队enqueue在尾节点后插入新节点并更新尾指针出队dequeue则删除头节点并更新头指针。动态实现的优缺点分析优点理论上容量无限只受限于内存动态增长非常灵活。缺点实现稍复杂每个字符都需要一个节点结构体包含数据和指向下一个节点的指针内存开销大多了指针的存储且频繁的malloc和free可能带来性能损耗和内存碎片。2.3 我的选择与理由对于“回文判断”这个具体场景我的选择是使用基于数组的静态循环队列和静态栈。理由如下问题规模明确回文判断的输入是一个字符串。在C语言中我们通常以\0结尾的字符数组来处理字符串其长度在判断前是可知的用strlen获取。这意味着我们不需要应对无限增长的流式数据。性能考量动态内存管理malloc/free是有成本的。对于一个可能被频繁调用的字符串处理函数避免不必要的堆内存操作可以提升性能。实现简洁性静态实现代码更短逻辑更集中更适合教学和展示核心算法思想。我们可以通过一个简单的技巧解决容量问题在初始化队列和栈时根据待检测字符串的长度len来分配len1大小的数组1是为了循环队列中区分空和满的一种常见实现方式或者简单起见直接分配len大小并仔细处理边界。这样既避免了浪费也保证了够用。所以接下来的代码我们将采用静态实现。我们会先获取字符串长度然后为此长度“量身定做”一个队列和一个栈。3. 算法设计双剑合璧的判断逻辑有了数据结构接下来就是设计算法流程。如何利用队列的FIFO和栈的LIFO特性来判断回文核心思想可以用一句话概括队列保证顺序栈保证逆序两者结合即可进行正反对比。具体步骤如下数据准备遍历输入的字符串忽略空格和标点根据题目要求有时需要有时不需要。我们以实现一个忽略大小写和非字母数字的通用版本为目标将需要比较的字符同时进行以下两个操作入队将字符放入队列尾部。入栈将字符压入栈顶。 这个过程完成后队列里保存的是字符串的正序序列栈里保存的是逆序序列因为最后入栈的在栈顶相当于反向。逐字符比对循环进行以下操作直到队列或栈为空出队从队列头部取出一个字符这是正序的下一个字符。出栈从栈顶弹出一个字符这是逆序的下一个字符。比较比较这两个字符。如果任何一次比较不相等则立即断定不是回文结束程序。如果所有字符都相等则是回文。这个算法的精妙之处在于它的对称性。队列像是一个传送带按顺序送出字符栈像是一个弹簧单高跷按反序弹出字符。两者同步工作完美地实现了首尾对照的检查。时间复杂度分析设字符串有效长度为 n。遍历字符串入队入栈O(n)。逐个出队出栈比较O(n)。总时间复杂度为 O(n)这是最优解。空间复杂度分析我们为队列和栈各分配了大约 n 的空间所以空间复杂度也是 O(n)。4. 手把手代码实现从定义到测试理论讲完是时候上代码了。我会分模块讲解并提供完整的、可编译运行的代码。4.1 数据结构定义与初始化首先我们定义队列和栈的结构体以及它们的操作接口。#include stdio.h #include stdlib.h #include string.h #include ctype.h // 用于字符处理 // 栈的结构定义数组实现 typedef struct { char *data; // 指向存储数组的指针 int top; // 栈顶索引初始为-1 int capacity; // 栈的容量 } Stack; // 队列的结构定义循环数组实现 typedef struct { char *data; // 指向存储数组的指针 int front; // 队头索引 int rear; // 队尾索引 int capacity; // 队列的容量 } Queue; // 栈的操作函数声明 Stack* createStack(int capacity); void freeStack(Stack* s); int isStackEmpty(Stack* s); int isStackFull(Stack* s); void push(Stack* s, char ch); char pop(Stack* s); // 队列的操作函数声明 Queue* createQueue(int capacity); void freeQueue(Queue* q); int isQueueEmpty(Queue* q); int isQueueFull(Queue* q); void enqueue(Queue* q, char ch); char dequeue(Queue* q);接下来是初始化函数。注意我们根据传入的capacity动态分配数组内存这样可以在主函数中根据字符串长度灵活指定大小。// 栈的创建与销毁 Stack* createStack(int capacity) { Stack* s (Stack*)malloc(sizeof(Stack)); if (!s) return NULL; s-data (char*)malloc(capacity * sizeof(char)); if (!s-data) { free(s); return NULL; } s-top -1; s-capacity capacity; return s; } void freeStack(Stack* s) { if (s) { free(s-data); free(s); } } // 队列的创建与销毁 Queue* createQueue(int capacity) { Queue* q (Queue*)malloc(sizeof(Queue)); if (!q) return NULL; q-data (char*)malloc(capacity * sizeof(char)); if (!q-data) { free(q); return NULL; } q-front 0; q-rear 0; // 循环队列rear指向下一个可插入位置 q-capacity capacity; return q; } void freeQueue(Queue* q) { if (q) { free(q-data); free(q); } }4.2 核心操作实现入栈、出栈、入队、出队这是数据结构的心脏部分务必保证逻辑正确尤其是循环队列的判空判满条件。// 栈的基本操作 int isStackEmpty(Stack* s) { return s-top -1; } int isStackFull(Stack* s) { return s-top s-capacity - 1; } void push(Stack* s, char ch) { if (isStackFull(s)) { printf(Stack Overflow!\n); return; } s-data[(s-top)] ch; } char pop(Stack* s) { if (isStackEmpty(s)) { printf(Stack Underflow!\n); return \0; // 返回空字符表示错误 } return s-data[(s-top)--]; } // 队列的基本操作循环队列 int isQueueEmpty(Queue* q) { return q-front q-rear; // 队头追上队尾为空 } int isQueueFull(Queue* q) { return (q-rear 1) % q-capacity q-front; // 队尾的下一个位置是队头为满 } void enqueue(Queue* q, char ch) { if (isQueueFull(q)) { printf(Queue is Full!\n); return; } q-data[q-rear] ch; q-rear (q-rear 1) % q-capacity; // 循环移动 } char dequeue(Queue* q) { if (isQueueEmpty(q)) { printf(Queue is Empty!\n); return \0; } char ch q-data[q-front]; q-front (q-front 1) % q-capacity; // 循环移动 return ch; }这里有一个关键细节我实现的循环队列rear指向下一个可插入的位置并且我们牺牲了一个存储单元来区分队空和队满(rear1)%capacity front表示满。这是一种非常经典且可靠的实现方式。你也可以用size变量来记录元素个数从而利用所有空间但上面的方法更常见于教科书和面试中。4.3 回文判断主逻辑实现现在我们将算法步骤转化为代码。我写一个增强版的isPalindrome函数它可以过滤非字母数字字符并统一转换为小写进行比较这样能判断更广泛的“回文短语”比如“A man, a plan, a canal: Panama”。// 判断字符是否为字母或数字 int isAlphanumeric(char ch) { return isalpha(ch) || isdigit(ch); } // 核心回文判断函数 int isPalindrome(const char* str) { if (!str) return 0; // 处理空指针 int len strlen(str); if (len 0) return 1; // 空字符串算回文 // 估算最大可能需要的容量最坏情况下字符串全是有效字符 // 为简单起见我们直接使用字符串长度作为容量。循环队列会浪费一个单元但影响不大。 int capacity len 1; // 多给一个确保循环队列逻辑清晰 Stack* stack createStack(capacity); Queue* queue createQueue(capacity); if (!stack || !queue) { // 内存分配失败处理 if (stack) freeStack(stack); if (queue) freeQueue(queue); return -1; // 用-1表示内部错误 } // 第一阶段过滤并填充栈和队列 for (int i 0; str[i] ! \0; i) { char ch str[i]; if (isAlphanumeric(ch)) { char lowerCh tolower(ch); // 统一转小写 push(stack, lowerCh); enqueue(queue, lowerCh); } // 忽略非字母数字字符 } // 第二阶段逐个比较 int isPal 1; // 假设是回文 while (!isStackEmpty(stack) !isQueueEmpty(queue)) { char fromStack pop(stack); char fromQueue dequeue(queue); if (fromStack ! fromQueue) { isPal 0; // 发现不匹配不是回文 break; } } // 注意循环结束后栈和队列应该同时为空。如果不同时为空说明有效字符处理逻辑有问题但通常不会发生。 // 清理资源 freeStack(stack); freeQueue(queue); return isPal; }4.4 完整测试代码与用例分析最后我们写一个main函数来全面测试我们的程序。int main() { // 测试用例数组 const char* testCases[] { racecar, // 经典回文 hello, // 非回文 A, // 单字符 , // 空字符串 , // 只有空格 A man, a plan, a canal: Panama, // 复杂回文带标点和空格 12321, // 数字回文 race a car, // 非回文短语 No x in Nixon, // 另一个经典回文忽略标点 abba, // 偶数长度回文 abcba, // 奇数长度回文 }; int numTests sizeof(testCases) / sizeof(testCases[0]); printf(回文判断测试结果\n); printf(\n); for (int i 0; i numTests; i) { const char* str testCases[i]; int result isPalindrome(str); if (result 1) { printf(%s \t-- 是回文\n, str); } else if (result 0) { printf(%s \t-- 不是回文\n, str); } else { printf(%s \t-- 判断出错内存分配失败\n, str); } } // 附加测试长字符串 printf(\n附加测试长回文串\n); // 构造一个长回文串例如“abc...cba” char longStr[1000]; longStr[0] \0; strcpy(longStr, abcdefghijklmnopqrstuvwxyz); // 这里为了演示我们简单复制一份反转的字符串实际构造略复杂 // 更严谨的测试可以自己构造一个确切的回文。 printf(长字符串测试非严谨回文: %d\n, isPalindrome(longStr)); return 0; }将以上所有代码段按顺序组合在一个.c文件中注意头文件包含用gcc或你喜欢的任何C编译器编译运行就能看到测试结果了。5. 深度探讨边界条件、陷阱与优化代码能跑通只是第一步。一个健壮的程序必须考虑各种边界情况和潜在陷阱。下面是我在实现和思考过程中总结的几个关键点。5.1 内存管理防泄漏与错误处理我们的createStack和createQueue函数动态分配了两次内存一次是结构体本身一次是内部的data数组。在free函数中我们必须先释放data再释放结构体顺序不能错。更重要的是在isPalindrome函数中如果创建队列或栈时任何一个失败返回NULL我们必须立即清理已成功分配的资源并返回错误码就像我代码中做的那样。这是防止内存泄漏的基本素养。一个常见的坑在isPalindrome的循环比较中如果提前break发现不是回文也必须执行最后的freeStack和freeQueue。我的代码将释放操作放在函数末尾无论是否提前break都会执行到这是正确的。5.2 循环队列的判空判满逻辑这是数据结构细节的魔鬼。我采用的“牺牲一个存储单元”的判满方法(rear1)%capacity front非常普遍。你必须理解其原理初始化时front rear 0队列为空。入队时元素放在rear位置然后rear (rear1)%capacity。出队时从front位置取元素然后front (front1)%capacity。当rear“绕了一圈”快要追上front时中间隔一个空位就认为队列满了。为什么牺牲一个单元如果不牺牲那么队空和队满的条件就都是front rear无法区分。你也可以用一个额外的变量size来记录元素个数这样就能用满capacity个空间但判断条件会稍有不同。在面试中能清晰解释你采用的任何一种方法及其原因才是加分项。5.3 字符预处理大小写与标点处理我的isPalindrome函数包含了过滤和转换逻辑isAlphanumeric和tolower。这带来了灵活性但也引入了讨论点是否应该修改原字符串我的做法是在处理每个字符时动态转换没有修改原字符串这是更安全的方式。预处理的开销对于每个字符我们调用了isalpha、isdigit、tolower等函数。这些函数调用有开销。在性能敏感的场合可以自己写内联的判断逻辑或者如果确定输入是干净的纯字母字符串可以去掉这些处理。本地化问题isalpha和tolower的行为依赖于当前的C语言本地化设置locale。在默认的“C” locale下它们只处理ASCII字符。如果你需要处理带重音符号的字母如é就需要使用宽字符wchar_t或支持Unicode的库这大大增加了复杂度。在面试或作业中通常明确说明只考虑ASCII字符。5.4 算法优化思考空间与时间的权衡我们的算法需要O(n)的额外空间。有没有可能只用O(1)的额外空间当然有那就是经典的双指针法。但题目要求使用队列和栈所以我们遵守规则。不过我们可以在“用队列和栈”的前提下进行优化空间优化我们为队列和栈各分配了len1的空间。实际上我们只需要总共len个空间来存储有效字符。我们可以让队列和栈共享同一个大数组吗理论上可以但管理起来非常复杂容易出错得不偿失。目前的实现清晰度优先。时间优化在填充栈和队列后我们比较了所有字符。但回文是对称的其实比较一半就够了。我们可以在填充时计数有效字符数count然后在比较时只循环count/2次。但需要注意出栈和出队的操作次数必须相等否则数据结构的状态会被破坏一个空了一个没空。实现稍显别扭代码可读性会下降。对于教学和一般应用完整的比较更直观。6. 从项目到面试如何展示你的理解如果你在面试或项目答辩中被问到这个问题仅仅给出能运行的代码是远远不够的。面试官想看到的是你思考的过程和知识的深度。你可以按照以下层次来阐述需求澄清首先确认输入是什么纯字符串需要忽略大小写和标点吗、输出是什么布尔值还是打印结果。这体现了你的沟通能力和严谨性。数据结构选型解释为什么选择队列和栈对比其他方法如数组反转、双指针的异同突出你对FIFO和LIFO特性的理解。具体实现方案阐述你是用数组还是链表实现以及为什么。如果是数组解释循环队列如何解决假溢出。清晰地说明front、rear、top等指针的初始状态和变化规则。算法步骤描述用自然语言或流程图描述“先同时入队入栈再逐个出队出栈比较”的过程。代码实现要点展示关键代码片段特别是判空判满、循环队列的取模操作、内存分配与释放。强调你的错误处理如内存分配失败、空指针判断。复杂度分析明确说出时间复杂度和空间复杂度都是O(n)并解释n是什么有效字符长度。测试与边界列举你考虑的测试用例空串、单字符、带标点回文、非回文、长字符串等证明你思维的全面性。扩展讨论主动提出可以改进的地方比如支持Unicode、用双指针法对比空间复杂度、或者如何处理超长流式数据这时链表实现可能更好。通过这样层次分明的阐述你展示的不仅仅是一个编程作业而是一个工程师系统化解决问题的能力。7. 常见问题与排错指南在实际编写和运行过程中你可能会遇到以下问题问题1程序运行时崩溃提示“Segmentation fault”。可能原因1在pop或dequeue时没有检查栈/队列是否为空导致访问了非法内存。排查在pop和dequeue函数开始处添加if (isStackEmpty(s))和if (isQueueEmpty(q))的判断并返回一个错误值或直接报错退出。可能原因2在isPalindrome函数中对createStack或createQueue的返回值没有做NULL检查直接使用。排查就像我代码中那样添加if (!stack || !queue)的判断并进行资源清理。问题2对于某些明显是回文的字符串如“A man, a plan”程序判断为不是回文。可能原因字符预处理逻辑有问题。例如没有正确过滤空格和标点或者大小写转换失败。排查在填充栈和队列的循环中打印出每个被处理push和enqueue的字符看看是不是你期望的小写字母。检查isAlphanumeric函数。isalpha和isdigit在ctype.h中它们只对unsigned char或EOF有定义明确的行为。确保传入的字符值在有效范围内或者先将char转换为unsigned char再判断尤其是在某些编译器上char默认为有符号时处理大于127的字符可能会出问题。不过对于ASCII回文判断这通常不是问题。问题3程序判断“abba”是回文但判断“abcba”不是。可能原因栈或队列的pop/dequeue逻辑错误导致取出的字符顺序不对。排查在比较循环中打印出每次从栈弹出的字符fromStack和从队列取出的字符fromQueue观察它们的顺序。对于栈push(a); push(b);然后pop()应该得到b。对于队列enqueue(a); enqueue(b);然后dequeue()应该得到a。验证你的栈顶指针top和队列头尾指针front/rear的移动逻辑是否正确。问题4内存泄漏长时间运行后程序占用内存越来越大。可能原因malloc的内存没有free。确保isPalindrome函数在return之前无论走哪个分支都调用了freeStack和freeQueue。使用valgrind等工具可以很好地检测内存泄漏。把这个项目吃透你收获的将不仅仅是“用队列和栈判断回文”这个技能点更是对两种基础数据结构从定义、实现到应用的一次完整演练以及编写健壮C代码的宝贵经验。下次面试官再问你队列和栈的区别你大可以把这个例子抛出来相信一定能给他留下深刻印象。
返回列表