《剑指Offer》题二十一~题三十
二十一、调整数组顺序使奇数位于偶数前面
题目:输入一个整数数组,实现一个函数来调整该数组中数字的顺序,使得所有奇数位于数组的前半部分,所有偶数位于数组的后半部分。
测试用例:
- 功能测试:输入数组中的奇数、偶数交替出现;输入的数组中所有偶数都出现在奇数的前面;输入的数组中所有奇数都出现在偶数的前面。
- 特殊输入测试:输入nullptr指针;输入的数组只包含一个数字。
只完成功能的解法:
void record_odd_before_even(int *pData, int length) { if(pData == nullptr || length == 0) return; int *pBegin = pData; int *pEnd = pData + length - 1; while(pBegin < pEnd) { if(pBegin < pEnd && !is_even(*pBegin)) pBegin++; if(pBegin < pEnd && is_even(*pEnd)) pEnd--; if(pBegin < pEnd) { int temp = *pBegin; *pBegin = *pEnd; *pEnd = temp; } } } /* 判断n是否为偶数 */ bool is_even(int n) { bool isEven = ((n & 0x1) == 0); return isEven; }
分析:上段代码在扫描传入的数组时,如果发现有偶数出现在奇数的前面,则交换它们的顺序。所以,它维护两个指针,pBegin初始化时指向数组的第一个数字,它只向后移动;第二个指针初始化时指向数组的最后一个数字,它只向前移动。
二十二、链表中倒数第K个节点
题目:输入一个链表,输出该链表中倒数第K个节点。例如,一个链表有6个节点,从头节点开始,它们的值依次是1、2、3、4、5、6。这个链表的倒数第3个节点是值为4的节点。
分析:为了得到倒数第K个节点,很自然地想到先遍历链表,从而得到链表的节点数n,于是获悉倒数第K个节点就是从头节点开始的第n-k+1个节点。
遍历链表两次的解法:
ListNode* get_kth_from_tail(ListNode *pHead, unsigned int k) { // 鲁棒性测试点1 if(pHead == nullptr || k == 0) return nullptr; int cnt = 0; ListNode *pNode = pHead; while(pNode != nullptr) { // 第一次遍历链表 pNode = pNode->next; ++cnt; } // 鲁棒性测试点2 if(k > cnt) return nullptr; pNode = pHead; while(cnt != k) { // 第二次遍历链表 pNode = pNode->next; --cnt; } return pNode; }
分析:如果面试官告诉我们只需遍历链表一次,我们应该想点办法了。可以定义两个指针,第一个指针pAhead从链表的头指针开始遍历向前走K-1步,第二个指针pBehind保持不动;从第K步开始,第二个指针pBehind也开始从链表的头指针开始遍历。由于两个指针的距离保持在k-1,当pAhead指针到达链表的尾节点时,pBehind指针正好指向倒数第K个节点。
遍历链表一次的解法:
ListNode* get_kth_from_tail(ListNode *pHead, unsigned int k) { if(pHead == nullptr || k == 0) return nullptr; ListNode *pAhead = pHead; ListNode *pAhead = pHead; for(int i = 0; i < k - 1; ++i) { // 向前走k-1步 if(pAhead->next != nullptr) pAhead = pAhead->next; else // 链表节点数小于k return nullptr; } while(pAhead->next != nullptr) { pAhead = pAhead->next; pBehind = pBehind->next; } return pBehind; }
二十三、链表中环的入口节点
题目:如果一个链表中包含环,如何找出环的入口节点?
分析:①判断一个链表中是否包含环;②如果有环,就要找到环的入口。
解法:
/* 返回环的入口节点 */ ListNode* entry_node_of_loop(ListNode *pHead) { if(pHead == nullptr) return nullptr; // 判断是否有环,如果有,则返回一个环中的节点 ListNode *meetNode = meet_node(pHead); if(meetNode == nullptr) return nullptr; // 计算环中的节点数 int nodeNumOfLoop = 1; ListNode *pCurrent = meetNode; while(pCurrent->next != meetNode){ pCurrent = pCurrent->next; nodeNumOfLoop++; } // 找出环的入口 ListNode *pAhead = pHead; ListNode *pBehind = pHead; for(int i = 0; i < nodeNumOfCode; ++i) { pAhead = pAhead->next; } while(pAhead != pBehind) { pAhead = pAhead->next; pBehind = pBehind->next; } return pAhead; } /* 判断链表中是否包含环 */ /* 如果有环,则返回两个指针相遇的节点 */ ListNode meet_node(ListNode *pHead) { ListNode *pSlow = pHead->next; // 一次走一步 if (pSlow == nullptr) return nullptr; ListNode *pFast = pSlow->next; // 一次走两步 while(pFast != nullptr) { if(pSlow == pFast) // 如果相遇即有环 return pFast; // 相遇的节点一定在环中 pSlow = pSlow->next; pFast = pFast->next; if(pFast != nullptr) pFast = pFast->next; } return nullptr; }
小结:利用两个指针pFast和pSlow来判断链表中是否包含环,如果有,就返回它们相遇的节点,显然这个节点是环中的一个节点。根据环中的一个节点,我们就可以遍历该环以得到环中的节点数。根据节点数,结合面试题22,可以利用两个指针来得到环的入口节点。
二十四、反转链表
题目:定义一个函数,输入一个链表的头节点,反转该链表并输出反转后链表的头节点。
测试用例:
- 输入的链表头指针是nullptr。
- 输入的链表只有一个节点。
- 输入的链表有多个节点。
解法:
ListNode* reverse_list(ListNode *pHead) { if(pHead == nullptr) return nullptr; ListNode *pRevHead = nullptr; // 反转后的链表的头节点 ListNode *pPre = nullptr; ListNode *pCur = pHead; while(pCur != nullptr) { ListNode *pNext = pCur->next; // 在反转后当前节点和之后的节点会断开,故把后面的节点保存下来 if(pNext == nullptr) pRevHead = pCur; pCur->next = pPre; pPre = pCur; pCur = pNext; } return pRevHead; }
分析:反转一个链表,需要调整链表中指针的方向,显然需要3个指针。其中,最重要的是保存当前要调整指针方向的节点的下一个节点pNext,即pCur->next。
拓展:用递归实现同样的反转链表的功能。
二十五、合并两个排序的链表
题目:输入两个递增排序的链表,合并这两个链表并使新链表中的节点仍然是递增排序的。
测试用例:
- 功能测试:输入的两个链表有多个节点;节点的值互不相同或者存在值相等的多个节点。
- 特殊输入测试:两个链表的一个或者两个头节点为nullptr指针;两个链表中只有一个节点。
递归解法:
ListNode* Merge(ListNode *pHead1, ListNode *pHead2) { if(pHead1 == nullptr) return pHead2; if(pHead2 == nullptr) return pHead1; ListNode *pMergedHead = nullptr; if(pHead1->value < pHead2->value) { pMergedHead = pHead1; pMergedHead->next = Merge(pHead1->next, pHead2); } else { pMergedHead = pHead2; pMergedHead->next = Merge(pHead1, pHead2->next); } return pMergedHead; }
分析:每次比较两个链表的头节点,较小的被加入到合并的链表中,并更新原来链表的头节点,然后再次比较两个链表的头节点。本题需要注意的是如果传入的是空链表时,我们应该如何处理。
二十六、树的子结构
题目:输入两棵二叉树A和B,判断B是不是A的子结构。
分析:要查找树A中是否存在和树B一样的子树,需要①在树A中找到和树B的根节点的值一样的节点R,②判断树A中以R为根节点的子树是不是包含和树B一样的结构。
测试用例:
- 功能测试:树B是或者不是树A的子结构。
- 特殊输入测试:两棵二叉树的一个或者两个根节点为nullptr指针;二叉树的所有节点都没有左子树或者右子树。
二十七、二叉树的镜像
题目:请完成一个函数,输入一棵二叉树,该函数输出它的镜像。
题意:求树的镜像的过程,其实就是在遍历树的同时交换非叶节点的左、右子节点。具体来说,就是先前序遍历这棵树的每个节点,如果遍历到的节点有子节点,就交换它的两个子节点。
测试用例:
- 功能测试:普通的二叉树;二叉树的所有节点都没有左子树或者右子树;只有一个节点的二叉树。
- 特殊输入测试:二叉树的根节点为nullptr指针。
解法:
void mirror_recursive(BinaryTreeNode *pRoot) { if(pRoot == nullptr) return; if(pRoot->lChild == nullptr && pRoot->rChild == nullptr) return; BinaryTreeNode *pTemp = pRoot->lChild; pRoot->lChild = pRoot->rChild; pRoot->rChild = pTemp; if(pRoot->lChild) mirror_recursive(pRoot->lChild); if(pRoot->rChild) mirror_recursive(pRoot->rChild); }
扩展:上面的代码是用递归实现的。如果要求用循环,则该如何实现?
二十八、对称的二叉树
题目:请实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。
分析:可以先得到该二叉树的镜像,然后来比较该镜像和原二叉树是否相同,但上题只是完成二叉树的镜像,因为我还不会如何去复制一个二叉树,并且这个方法的效率也不高。
前序遍历和自定义的对称前序遍历解法:
bool is_symmetrical(BinaryTreeNode *pRoot) { return is_symmetrical(pRoot, pRoot); } bool is_symmetrical(BinaryTreeNode *pRoot1, BinaryTreeNode *pRoot2) { if(pRoot1 == nullptr && pRoot2 == nullptr) return true; if(pRoot1 == nullptr || pRoot2 == nullptr) return false; if(pRoot1->value != pRoot2->value) return false; return is_symmetrical(pRoot1->lChild, pRoot2->rChild) && is_symmetrical(pRoot1->rChild, pRoot2->lChild); }
小结:针对前序遍历定义一种对称的遍历算法,即先遍历父节点,再遍历它的右子节点,最后遍历它的左子节点。我们可以通过比较二叉树的前序遍历序列和对称前序遍历序列来判断二叉树是不是对称的。
二十九、顺时针打印矩阵
题目:输入一个矩阵,按照从外向里以顺时针的顺序依次打印出每一个数字。
分析:此题可借助图形来帮助思考,我们可以把矩阵想象成若干个圈。我们可以用一个循环来打印矩阵,每次打印矩阵中的一个圈。要注意的是,我们应理清循环继续的条件以及打印时每一步的前提条件。
解法:
void PrintMatrixClockwisely(int** numbers, int columns, int rows) { if(numbers == nullptr || columns <= 0 || rows <= 0) return; int start = 0; while(columns > start * 2 && rows > start * 2) { PrintMatrixInCircle(numbers, columns, rows, start); ++start; } } void PrintMatrixInCircle(int** numbers, int columns, int rows, int start) { int endX = columns - 1 - start; int endY = rows - 1 - start; // 从左到右打印一行 for(int i = start; i <= endX; ++i) { int number = numbers[start][i]; printNumber(number); } // 从上到下打印一列 if(start < endY) { for(int i = start + 1; i <= endY; ++i) { int number = numbers[i][endX]; printNumber(number); } } // 从右到左打印一行 if(start < endX && start < endY) { for(int i = endX - 1; i >= start; --i) { int number = numbers[endY][i]; printNumber(number); } } // 从下到上打印一行 if(start < endX && start < endY - 1) { for(int i = endY - 1; i >= start + 1; --i) { int number = numbers[i][start]; printNumber(number); } } } void printNumber(int number) { printf("%d\t", number); }
三十、包含min函数的栈
题目:定义栈的数据结构,请在该类型中实现一个能够得到栈的最小元素的min函数。在该栈中,调用min、push及pop的时间复杂度都是O(1)。
分析:初看本题时,会发现无从下手。既要push/pop的时间为O(1),又要min的时间为O(1),看上去像是不可能的事。但可以借助两个栈来实现本题的要求,其中数据栈实现push/pop的功能,而辅助栈实现min的功能,而关键之处是维持辅助栈的内容。
解法:
stack<int> dataSck; // 数据栈 stack<int> minSck; // 辅助栈 void push(int val) { dataSck.push(val); if(!minSck.empty()) { if(val < minSck.top()) minSck.push(val); else minSck.push(minSck.top()); } else { minSck.push(val); } } void pop() { assert(dataSck.size() > 0 && minSck.size() > 0); dataSck.pop(); minSck.pop(); } void min() { assert(dataSck.size() > 0 && minSck.size() > 0); return minSck.top(); }
小结:①把每次的最小元素(之前的最小元素和新压入栈的元素两者的较小值)都保存起来放到辅助栈中,要保证数据栈和辅助栈的元素个数一致;②保证辅助栈的栈顶一直都是最小元素;③要想到这个思路,其实只要举个例子,多做几次入栈、出栈的操作就能看出问题,并想到也要把最小元素用另外的辅助栈保存。