前言:本篇涉及到的所有代码都用C++实现,不同语言只是语法不同,但思想都是一样的。
在计算机领域里,算法是一系列程序指令,用于处理特定的运算和逻辑问题,我们当然可以简单理解其为要解决目标问题的方法,实际上,解决某一问题有着多种方法,区别在于不同方法在解决过程中耗费的资源不同以致最终解决问题的效率不同,同理,不同算法的时间复杂度和空间复杂度不同,执行效率就会不同,我们学习算法的主要目的不仅仅在于学习解决某类问题的多种思路,更在于掌握解决这类问题的最优方法,以便我们在实际应用过程中提高代码效率,高效产出。算法是我们在未来工作中需要的必备知识,因此在面试时也考察的比较多,那主要会从哪些方面进行考察呢?可按由易到难的层次划分为以下三类:
- 考察以特殊数据结构为载体的算法题:如,字符串、数组、链表、堆、栈、队列、二叉树、hash等。
- 考察常见算法思想基础的算法题:如,动归、贪心、分治、回溯、排序、查找等。
- 考察1和2的综合或基于某种场景下的1或2
事实上,我们通过反复训练掌握1和2并不困难,3要基于对1和2的扎实掌握以及一定的项目经验,因此会难一点,但只要能掌握前二者,就已经拥有了良好的算法基础,面试基本够用,接下来我会总结《剑指offer》中经典的算法题目,可以通过学习这些题目来提高我们的算法能力,应对基本的算法笔试面试。
考点1: 利用数组特性把握时间复杂度
题目:在一个二维数组中,每一个一维数组的长度相等,每一行按照从左到右递增的顺序排列,每一列按照从上到下递增的顺序排列。请完成这样一个函数,使得输入一个二维数组和一个整数,判断数组中是否含有该整数。
题目分析:
思路一:遍历这个二维数组,遍历过程中挨个与该整数比较,若符合相等的条件,返回真,遍历完了都没找到,就是不存在,返回假。【挨个遍历的思想,一次只能排除一个,且没有用到题目给出数据排列特点的条件,都能想到,不推荐】
思路二:本题实际上是用查找的思想,任何查找的过程实际上就是排除的过程,而每次排除数据的多少,就会影响算法的复杂度,思路一中,一次只能排除一个,时间复杂度较高,那我们可不可以一次排除多个呢?这就要用到题目给出的关键信息:每一行按照从左到右递增的顺序排列,每一列按照从上到下递增的顺序排列,根据此信息我们可以知道这个二维数组中任意一个矩阵的右上角的值都是所在行的最大值,所在列的最小值;左下角的值都是所在行的最小值,所在列的最大值。通过这两个值中任意一个与目标数对比,我们一次可以排除一行或一列的数据,一次排除的数据增多,时间复杂度降低。【优于思路一,为最优解】
题目链接:
正确代码如下:【需掌握vector的基本用法】
class Solution { public: bool Find(int target, vector<vector<int> > array) { //本题我用右上角的值来比较 //二维数据用vector存储 int i=0;//右上角数据所在行数 int j=array[0].size()-1;//右上角数据所在列数 while(i<array.size()&&j>=0)//循环的条件是,行数<=总行数,列数>=0 { if(target<array[i][j])//array[i][j]是当前列最小的,比它小,肯定不在当前列 { j--;//排除列 } else if(target>array[i][j])//array[i][j]是当前行最大的,比它大,肯定不在当前行 { i++;//排除行 } else { return true;//找到了 } } return false; } };
考点二:数组融合二分查找的思想【重点:临界条件的判定】
题目:把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。 输入一个非递减排序的数组的一个旋转,输出旋转数组的最小元素。 例如数组{3,4,5,1,2}为{1,2,3,4,5}的一个旋转,该数组的最小值为1。【NOTE:给出的所有元素都大于0,若数组大小为0,请返回0】
题目分析:
思路一:直接遍历数组找最小值,太矬了,肯定不符合面试要求。
思路二:题目给出了条件是原数组是非递减的,旋转之后,就有可能出现递减的部分(还要考虑数组中数据全相等的情况),根据其特点,我们发现,若出现递减,这个引起递减的数就是数组的最小值。【寻找引起递减的数,本质还是要遍历,当数组很长但旋转次数很少时,和法一耗费时间相近,但思想还是不错的,面试可以提一下,但不是最优解】
思路三:在之前学习中,我们学过一个特别牛逼的算法,二分查找,这种算法一次可以排除一半的数据,具体思想可以去百度一下,经过观察,我们发现此题同样可利用实现二分查找的思路来实现。基于前面的分析,我们可以观察到旋转之前的部分和旋转之后的部分都是非递减序列,定义旋转数组的第一个元素下标为left,最后一个元素下标为right,定义中间元素下标为mid,若mid处值大于或等于left处值,则认为mid在没旋转的部分,此时将left更新为mid,如果mid处值小于left处值,则认为mid在旋转后的部分,此时将ringt更新为mid,直到left+1=right,此时right一定是旋转后部分的第一个数,即最小值,循环条件为left处值>=right处值,需考虑一种特殊情况,left处值等于right处值且left处值等于mid处值,此时无法判断mid处值属于哪一部分,只能通过线性比较的方式来找最小值。【注意left,mid,right都是下标,以三者之间的关系对应的旋转情况应考虑全面,这种算法也是一次排除一半,查找效率高,时间复杂度低,面试时重点说】
题目链接:
正确代码如下:
思路二:
class Solution { public: int minNumberInRotateArray(vector<int> rotateArray) { if(rotateArray.empty()) { return 0; } for(int i = 0; i < rotateArray.size()-1; i++) { if(rotateArray[i] > rotateArray[i+1]) { return rotateArray[i+1]; } } return rotateArray[0]; } };
思路三:
class Solution { public: int minNumberInRotateArray(vector<int> rotateArray) { //首先判断一下是否为空 if(rotateArray.empty()) { return 0; } int left=0; int right=rotateArray.size()-1; int mid=0; while(rotateArray[left]>=rotateArray[right]) { if(left+1==right) { mid=right; break; } mid=(left+right)/2; //特殊情况线性判断 if(rotateArray[mid]==rotateArray[left]&&rotateArray[right]==rotateArray[left]) { int result=rotateArray[left]; for(int i=left+1;i<right;i++) { if(rotateArray[i]<result) { result=rotateArray[i]; } } return result; } if(rotateArray[mid]>=rotateArray[left]) { left=mid; } if(rotateArray[mid]<rotateArray[left]) { right=mid; } } return rotateArray[mid]; } };
考点三:数组应用排序思想的相关操作
题目:输入一个整数数组,实现一个函数来调整该数组中数字的顺序,使得所有的奇数位于数组的前半部分,所有的偶数位于数组的后半部分,并保证奇数和奇数,偶数和偶数之间的相对位置不变。
题目分析:
该题目其实是一个变形,原题没有要求相对位置不变,做法较多,在面试中也有可能被问到,我们也来实现一下,主要思路有:1.开两个新数组,遍历原数组,遇偶数放入数组1,遇奇数放入数组2,最后再放回原数组【空间复杂度为O(N),不推荐】;2.定义左右指针,左指针从前往后遍历,遇到偶数停,右指针从后往前遍历,遇到奇数停,交换两位置数据,继续重复,直到二者相遇【较优思路】。
代码如下:
void resort(int a[],int num) { int left=0; int right=num-1; while(left<right) { while(left<right&& (a[left] & 1)) { left++; } while(left<right&& !(a[right] & 1)) { right--; } if(left<right) { int tmp=0 tmp=a[left]; a[left]=a[right]; a[right]=tmp; } } }
以上是不要求奇数偶数相对位置不变的较优实现,如果要求了,我们的分析如下:
思路一:新开数组不仅要利用额外的空间,而且还涉及数据的反复挪动,不推荐也不细讲。
思路二:从左往右遍历数组,遇到偶数,记录当前下标为k,继续向右,直到遇到奇数,记录这个奇数,将从k到当前下标的所有数据向右移动1,将这个奇数放到下标为k的位置处,一次完成,循环此步骤直至遍历数组完成,各个数据按要求排序完成。
题目链接:
正确代码如下:
class Solution { public: void reOrderArray(vector<int> &array) { int k=0; for(int i=0;i<array.size();i++) { if(array[i]&1)//从左向右,每次遇到的,都是最前面的奇数,一定将来要被放在k下标处 { int tmp=array[i];//现将当前奇数保存起来 int j=i; while(j>k)//将该奇数之前的内容(偶数序列),整体后移一个位置 { array[j]=array[j-1]; j--; } array[k++]=tmp;//将奇数保存在它将来改在的位置,因为我们是从左往右放的,没有跨越奇 数,所以一定是相对位置不变的 } } } };
考点四:数组使用,简单算法的设计
题目:数组中有一个数字出现的次数超过数组长度的一半,请找出这个数字。例如输入一个长度为9的数组 {1,2,3,2,2,2,5,4,2}。由于数字2在数组中出现了5次,超过数组长度的一半,因此输出2。如果不存在则输出0。
题目分析:
思路一:定义map,使用<数字,次数>的映射关系,最后统计每个字符出现的次数【利用容器,较优】
思路二:题目描述的数组若存在,则排序后中间的数一定就是该数,故先排序找中间位置的数,再统计该数在数组中出现的次数是否多于总数的一半【若面试中应用此法,大多侧重于考察题目本身思想,排序以及统计次数的方法都不是重点,可根据实际情况考虑用现有的接口还是自己实现】
思路三:题目描述的数组若存在,每次去掉两个不同的数,最后能剩下的一个数,或两个相同的数,即为目标值,故可以先确定最后剩下的数,再统计该数在数组中出现的次数是否多于总数的一半【确定目标数的方法有很多,这时候就要综合考虑选出最优,涉及到先删除后统计,因此我们既要保证删除效果,又要保证原数组不变】,可以考虑新开数组,拷贝原数组,在新数组中删除,原数组中统计【看似简单,实际在写删除逻辑时并不好写,不推荐】,此思想实现的最优方法就是用一个新定义的变量操作来等效数据的删除。
题目链接:
正确代码如下:
思路一:
class Solution { public: int MoreThanHalfNum_Solution(vector<int> numbers) { unordered_map<int, int> map; int half = numbers.size()/2; for(int i = 0; i < numbers.size(); i++){ auto it = map.find(numbers[i]); //如果已经在map中,进行自增,如果不在,插入,首次出现 if( it != map.end() ){ map[numbers[i]]++; } else{ map.insert(make_pair(numbers[i], 1)); } //自增或者插入一个,直接进行判定。注意,这里要考虑测试用例为{1}的情况 //走到这里,对应的key val一定存在 if(map[numbers[i]] > half){ return numbers[i]; } } //走到这里,说明没有找到 return 0; } };
思路二:
class Solution { public: int MoreThanHalfNum_Solution(vector<int> numbers) { sort(numbers.begin(), numbers.end()); int target = numbers[numbers.size()/2]; int count = 0; for(int i = 0; i < numbers.size(); i++){ if(target == numbers[i]){ count++; } } if(count > numbers.size()/2){ return target; } return 0; } };
思路三:
class Solution { public: int MoreThanHalfNum_Solution(vector<int> numbers) { int half=numbers.size()/2; int number=numbers[0]; int times=1; for(int i=1;i<numbers.size();i++) { if(times==0)//times==0,之前的数据可两两为不同的数而抵消 { number=numbers[i]; times=1; } if(numbers[i]==number)//与当前值相等,次数累加 { times++; } else //与当前值不等,可抵消一次 { times--; } } int count=0; for(int i=0;i<numbers.size();i++)//最后的number是目标值,且times>1 { if(numbers[i]==number) count++; } if(count>half) return number; else return 0; } };
考点五:字符串相关替换问题
题目:请实现一个函数,将一个字符串中的每个空格替换成“%20”。例如,当字符串为We Are Happy.则经过替换之后的字符串为We%20Are%20Happy。
题目分析:虽然是替换问题,但是生成的字符串整体变长了,因替换内容比被替换内容长,所以,一定涉及到字符串中字符的移动问题,移动方向一定是向后移动,所以现在的问题就是移动多少的问题 ,因为是 ' ' -> "%20",是1换3,所以可以先统计原字符串中空格的个数(设为n),然后可以计算出新字符串的长度 所以:new_length = old_length + 2*n 最后,定义新老索引(或者指针),各自指向新老空间的结尾,然后进行old->new的移动,如果是空格,就连续放入“%20”,其他平移即可。当然,C++和Java都有很多容器,也可以从前往后通过开辟空间来进行解决。也就是使用空间来换取时间。但是,我们最好不要在当前场景下这么做。
题目链接:
正确代码如下:
class Solution { public: void replaceSpace(char *str,int length) { int count = 0; char *start = str; while(*start!='\0')//统计空格个数 { if(isspace(*start)) { count++; } start++; } char* _old=str+length; char* _new=_old+count*2; while(_old>=str&&_new>=str) { if(*_old!=' ') { *_new=*_old; _new--; _old--; } else { *_new--='0'; *_new--='2'; *_new--='%'; _old--; } } } };
考点六:链表相关,多结构混合使用,递归
题目:输入一个链表,按链表从尾到头的顺序返回一个ArrayList。
题目分析:
思路一:利用栈后进先出的特性先将此链表元素依次入栈,再依次出栈打印。【会存在栈溢出的问题,线上测试可能过不了】
代码如下:
class Solution { public: vector<int> printListFromTailToHead(ListNode* head) { stack<int> st; vector<int> vct; while(head) { st.push(head->val); } while(!st.empty()) { vct.push_back(st.top()); st.pop(); } return vct; } };
思路二:将数据保存到一个数组,再将数组逆序【可以直接用vector的reverse接口】
代码如下:
class Solution { public: vector<int> printListFromTailToHead(ListNode* head) { vector<int> v; while(head){ v.push_back(head->val); head = head->next; } reverse(v.begin(), v.end()); return v; } };
思路三:递归,子问题为每次打印当前结点值时,先按同样的要求打印当前节点后面的值
代码如下:
class Solution { public: void printListFromTailToHeadCore(ListNode* head, vector<int>& v)//注意这里是同一个数组 { if(head==nullptr) { return ; } printListFromTailToHeadCore(head->next,v); v.push_back(head->val); } vector<int> printListFromTailToHead(ListNode* head) { vector<int> v; printListFromTailToHeadCore(head, v); return v; } };
考点七:二叉树的重建【遍历理解、递归、边界计算】
题目:
输入某二叉树的前序遍历和中序遍历的结果,请重建出该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。例如输入前序遍历序列{1,2,4,7,3,5,6,8} 和中序遍历序列 {4,7,2,1,5,3,8,6} ,则重建二叉树并返回。题目分析:1,2表示根节点;3表示左子树中序遍历;4表示右子树中序遍历;5表示左子树前序遍历;6表示右子树前序遍历。通过以上分析可以得出子问题:重建二叉树即找到根节点,重建左子树,重建右子树 。
题目链接:
正确代码如下:
/** * Definition for binary tree * struct TreeNode { * int val; * TreeNode *left; * TreeNode *right; * TreeNode(int x) : val(x), left(NULL), right(NULL) {} * }; */ class Solution { public: TreeNode* reConstructBinaryTreeCore(vector<int> pre,int preStart,int preEnd,vector<int> vin, int vinStart, int vinEnd) { if(preStart > preEnd || vinStart > vinEnd) { return nullptr; } TreeNode* root=new TreeNode(pre[preStart]); for(auto i=vinStart;i<=vinEnd;i++) { if(pre[preStart] == vin[i]) { root->left = reConstructBinaryTreeCore(pre,preStart+1, i-vinStart+preStart,vin, vinStart, i-1); root->right = reConstructBinaryTreeCore(pre, i-vinStart+preStart+1,preEnd,vin, i+1,vinEnd); break; } } return root; } TreeNode* reConstructBinaryTree(vector<int> pre,vector<int> vin) { if (pre.empty() || vin.empty()) { return nullptr; } //理论上,可以新建数组,保存前序,中序子序列,但是就需要花费额外空间 //所以,我们采取在原数组内进行操作 //使用闭区间限定数组范围 return reConstructBinaryTreeCore(pre, 0, pre.size()-1, vin, 0, vin.size()-1); } };
考点八:空间复杂度,fib理解,剪枝重复计算,动归问题
题目:大家都知道斐波那契数列,现在要求输入一个整数n,请你输出斐波那契数列的第n项(从0开始,第0项为0,第1项是1)。
题目链接:
题目分析:
斐波那契数列:1 1 2 3 5 8 13 21....
思路一:分治思想【递归】,第n个斐波那契数是第n-1个斐波那契数+第n-2个斐波那契数,控制返回条件就能用递归的问题解决。【这个方法很容易想到,但一方面存在大量重复计算,时间复杂度高;另一方面当递归次数过多时,可能会出现满栈情况导致运行失败,空间复杂度高】。
代码如下:
class Solution { public: int Fibonacci(int n) { if(n<=2) { return 1; } return Fibonacci(n-1)+Fibonacci(n-2); } };
上述代码有递归,常常存在的很多重复计算,我们可以通过定义一个unordered_map容器来进行剪枝重复运算来优化,代码如下:
class Solution { private: unordered_map<int, int> filter; public: int Fibonacci(int n) { if(n<=2) { return 1; } int ppre = 0;//第n-2个斐波那契数 if(filter.find(n-2) == filter.end()) { ppre = Fibonacci(n-2); filter.insert({n-2, ppre}); } else { ppre = filter[n-2]; } int pre = 0;//第n-1个斐波那契数 if(filter.find(n-1) == filter.end())//如果之前没计算过,就计算,然后把这个数存到容器中 { pre = Fibonacci(n-1); filter.insert({n-1, pre}); } else//容器中找到,说明之前已经计算过了,直接用就行 { pre = filter[n-1]; } return pre + ppre; } };
思路二:动归思想,斐波那契数列可以当做是简单的动归问题,动归问题分析方法为三步,1.确定状态;2.列出状态方程;3.设置初始值【动归思想很重要,斐波那契数列问题只是一种比较简单的应用,后续会详细总结】按照3步骤我们来分析此题如下:
代码如下:
class Solution { public: int Fibonacci(int n) { if(n==1) { return 0; } if(n==2) { return 1; } int first=1; int second=1; int third=1; for(int i=3;i<=n;i++) { third=first+second; first=second; second=third; } return third; } };
衍生题目1:
一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法(先后次序不同算不同的结果)。题目分析:分析跳到第n级台阶只能是从n-1级或第n-2级台阶上跳上来,那么跳上第n级台阶的总方法数就等于跳上第n-1级台阶的总方法数加上跳上第n-2级台阶的总方法数,发现还是斐波那契数列问题,代入上述任一方法都可以解决,以下列出的是动归的解决方法。class Solution { public: int jumpFloor(int number) { int* dp=new int[number+1]; dp[0]=1; dp[1]=1; for(int i=2;i<=number;i++) { dp[i]=dp[i-1]+dp[i-2]; } int num = dp[number]; delete dp; return num; } };
衍生题目2:
我们可以用 2*1 的小矩形横着或者竖着去覆盖更大的矩形。请问用 n 个 2*1 的小矩形无重叠地覆盖一个 2*n 的大矩形,总共有多少种方法?题目分析:
每次放置的时候其实只有两种方法,竖着放1个或横着放两个,放置n个矩形总方法数就是放置前n-1个矩形的总方法数+放置前n-2个矩形的总方法数,仍是斐波那契数列问题,代入上述任一方法都可以解决,以下列出的是动归的解决方法。
class Solution { public: int rectCover(int number) { if(number < 2){ //这里要充分考虑number是[0,1]时的情况,OJ一般测试用例设计的比较全面,会有0,1传进来,这个时候,后续的dp[1] = 1;就可能报错 return number; } //f(n) = f(n-1)+f(n-2) int *dp = new int[number+1]; dp[1] = 1; dp[2] = 2; for( int i = 3; i <= number; i++){ dp[i] = dp[i-1]+dp[i-2]; } int num = dp[number]; delete dp; return num; } };
很多题目会包裹很多现实问题,解决问题的第一步往往是从实际问题中提炼出我们的解决问题的数学模型【例如本类型的数学模型是斐波那契数列】,然后解决这里也可以使用多种方法解决,不过我们这里重点用dp ,不是说这个是最优的,但思想是很好地。
考点九:二进制计算
题目:
输入一个整数,输出该数二进制表示中 1 的个数。其中负数用补码表示。题目链接:题目分析:思路一:比较容易想到的就是这个整数32位的每一位和数字1做按位与,结果为1计数结果就加1,计数结果是几,就有几个1,又1用二进制表示也是一个32位的二进制数,想实现上述操作,每次还要对1做移位操作【虽然可以计算结果,但谁都能想到的方法肯定不会是突出的,要想一下击中面试官的心,我们还要再优化一下】思路一代码:class Solution { public: int NumberOf1(int n) { int count=0; for(int i=0;i<32;i++) { if(n&(1<<i)) count++; } return count; } };
思路二:在使用上述方法时,无论要判断的整数是多少,我们都进行了32次循环,实际上对我们最终结果有效的操作只发生在当前位为1的情况下,因此在一个数的二进制表示中有大量的1时,逐位判断才看起来好一点,但当0较多时,这种方法的效率就不那么高了,因此我们就有了另一种思路,我们可以不断让当前的 nnn与 n−1n - 1n−1做位与运算,直到 nnn的二进制全部变为 0 停止。因为每次运算会使得 nnn 的最低位的 1 被翻转成0,因此运算次数就等于 nnn 的二进制位中 1 的个数,由此统计1的个数。图解如下:
代码如下:
public class Solution { public int NumberOf1(int n) { int count = 0; //当n为0时停止比较 while(n){ n &= n - 1; count++; } return res; } }
考点十:前后指针在链表中的使用【边界检测】
题目:
输入一个链表,输出该链表中倒数第 k 个结点。题目链接:题目分析:较优思路:题目给出的是单链表那就无法用从后往前找第k个的方法去找,解决这一题目较优方法就是用双指针【快慢指针】,定义两个指针均指向头结点,让一个指针(定义为快指针)先走k步,另一个指针(定义为慢指针)还在头结点,随后两指针一起向后走,他们之间始终有k个结点,快指针指向空时,慢指针指向的位置就是链表中倒数第k个结点。【当然也可以让快慢指针差k-1个,让快指针走到尾结点是,慢指针指向的结点也是目标位置,可自行控制循环条件来实现】代码如下:/* struct ListNode { int val; struct ListNode *next; ListNode(int x) : val(x), next(NULL) { } };*/ class Solution { public: ListNode* FindKthToTail(ListNode* pListHead, unsigned int k) { if(pListHead==nullptr||k<=0) return nullptr;//头指针为空指针或k<=0,该节点不存在返回空 ListNode* fast=pListHead; ListNode* slow=pListHead; while(k--) { if(fast)//k大于链表长度,该节点不存在,返回空 fast=fast->next; else return nullptr; } while(fast) { fast=fast->next; slow=slow->next; } return slow; } };
时间复杂度:O(n),不管如何,都只遍历一次单链表
空间复杂度:O(1)
考点十一:翻转单链表
题目:
给定一个单链表的头结点pHead(该头节点是有值的),长度为n,反转该链表后,返回新链表的表头。题目链接:题目分析:【注意画图理解!!】思路一:可以定义三个指针,从前往后去改变链接关系,特别要注意最后临界情况的处理。【三指针走到最后容易丢掉最后两节点的处理】,图解思路如下:代码如下:
/* struct ListNode { int val; struct ListNode *next; ListNode(int x) : val(x), next(NULL) { } };*/ class Solution { public: ListNode* ReverseList(ListNode* pHead) { //首先判断两种特殊情况 if(pHead==nullptr||pHead->next==nullptr) { return pHead; } //定义三个指针,并初始化 ListNode* prve=pHead; ListNode* mid=prve->next; ListNode* last=mid->next; while(last) { //改变链接关系 mid->next=prve; //向后推进 prve=mid; mid=last; last=last->next; } //处理最后两个结点 mid->next=prve; //定义新的头结点 ListNode* new_pHead=mid; //处理原头结点 pHead->next=nullptr; return new_pHead; } };
思路二:原链表中从前往后取结点,删结点,将结点依次头插到新链表,返回新链表头结点,图解思路如下:代码如下:
class Solution { public: ListNode* ReverseList(ListNode* pHead) { //还是判断两种特殊情况 if(nullptr==pHead||nullptr==pHead->next) { return pHead; } ListNode* head=nullptr; ListNode* tail=nullptr; while(pHead) { ListNode*p=pHead; pHead=pHead->next;//原链表头删 if(head==nullptr)//新链表第一次头插 { head=tail=p; } else { p->next=head; head=p;//更新头结点 } } tail->next=nullptr;//尾结点指向空 return head; } };
考点十二: 链表合并
题目:
输入两个单调递增的链表,输出两个链表合成后的链表,当然我们需要合成后的链表满足单调不减规则。题目链接:题目分析:思路一:一个一个结点逐个归并/* struct ListNode { int val; struct ListNode *next; ListNode(int x) : val(x), next(NULL) { } };*/ class Solution { public: ListNode* Merge(ListNode* pHead1, ListNode* pHead2) { //排除有链表为空的情况,下面两个if也能处理都为空的情况 if(pHead1==nullptr) { return pHead2; } if(pHead2==nullptr) { return pHead1; } //创建新链表 ListNode*new_Head=nullptr; ListNode*new_tail=nullptr; //两链表都不为空,则可以一直按照此逻辑取小进行尾插 while(pHead1!=nullptr&&pHead2!=nullptr) { if(pHead1->val<pHead2->val) { if(new_Head==nullptr) { new_Head=new_tail=pHead1; pHead1=pHead1->next; } else { new_tail->next=pHead1; new_tail=new_tail->next; pHead1=pHead1->next; } } else { if(new_Head==nullptr) { new_Head=new_tail=pHead2; pHead2=pHead2->next; } else { new_tail->next=pHead2; new_tail=new_tail->next; pHead2=pHead2->next; } } } //此时一定有至少一个链表走完了,分开判断就行 if(pHead1) { new_tail->next=pHead1; } if(pHead2) { new_tail->next=pHead2; } return new_Head; } };
思路二:递归完成class Solution { public: ListNode* Merge(ListNode* pHead1, ListNode* pHead2) { //递归出口 if(pHead1==nullptr) { return pHead2; } if(pHead2==nullptr) { return pHead1; } ListNode*new_head=nullptr; if(pHead1->val<pHead2->val) { new_head=pHead1; pHead1=pHead1->next; } else { new_head=pHead2; pHead2=pHead2->next; } new_head->next=Merge(pHead1,pHead2); return new_head; } };
考点十三: 二叉树遍历【二叉树问题的递归解决】
题目:
输入两棵二叉树 A , B ,判断 B 是不是 A 的子结构。( ps :我们约定空树不是任意一个树的子结构)。题目链接:
题目分析:
二叉树都是递归定义的,所以递归操作是比较常见的做法 。首先明白:子结构怎么理解,可以理解成子结构是原树的子树( 或者一部分 ),也就是说,B 要是 A 的子结构, B 的根节点 + 左子树 + 右子树,都在 A 中存在且构成树形结构,比较的过程要分为两步1. 先确定起始位置 2. 在确定从该位置开始,后续的左右子树的内容是否一致。代码如下:/* struct TreeNode { int val; struct TreeNode *left; struct TreeNode *right; TreeNode(int x) : val(x), left(NULL), right(NULL) { } };*/ class Solution { public: //判断从当前节点开始,B树是A树的子结构 bool issametree(TreeNode* pRoot1, TreeNode* pRoot2) { //B树找到尾,说明找完了,返回真 if(pRoot2==nullptr) { return true; } //走到这一步说明B树还没找完,此时A树走到尾,返回假 if(pRoot1==nullptr) { return false; } //当前节点值不相等,直接返回假 if(pRoot1->val!=pRoot2->val) { return false; } //走到这一步说明根节点值相等,再比较左右子树分别是否相等即可 return issametree(pRoot1->left, pRoot2->left) &&issametree(pRoot1->right, pRoot2->right); } bool HasSubtree(TreeNode* pRoot1, TreeNode* pRoot2) { //其中一颗数为空就没有子树的概念,直接返回假 if(pRoot1==nullptr||pRoot2==nullptr) { return false; } //定义一个布尔变量result bool result=false; //根节点相同 if(pRoot1->val==pRoot2->val) { //比较左右子树是否相等 result=issametree(pRoot1,pRoot2); } //result为真说明根节点相同且左右子树也相同 //result为假,则说明当前根节点的树没有子树结构,先从左树找 if(!result) { result=HasSubtree(pRoot1->left,pRoot2); } //result仍为假,说明左树中也没有,再从右子树找 if(!result) { result=HasSubtree(pRoot1->right,pRoot2); } //右树也找完了,此时result的结果就是整个过程的结果 return result; } };
考点十四: 二叉树操作
题目:
操作给定的二叉树,将其变换为源二叉树的镜像。题目链接:题目分析: 仔细观察可以发现,所谓的二叉树镜像本质是自顶向下(or 自底向上 ) 进行左右子树交换的过程代码如下:/** * struct TreeNode { * int val; * struct TreeNode *left; * struct TreeNode *right; * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} * }; */ class Solution { public: TreeNode* Mirror(TreeNode* pRoot) { if(pRoot==nullptr) { return nullptr; } TreeNode* tmp=pRoot->left; pRoot->left=pRoot->right; pRoot->right=tmp; Mirror(pRoot->left); Mirror(pRoot->right); return pRoot; } };
考点十五: 链表操作,临界条件检查,特殊情况处理
题目:
在一个排序的链表中,存在重复的结点,请删除该链表中重复的结点,重复的结点不保留,返回链表头指针。 例如,链表1->2->3->3->4->4->5 处理后为 1->2->5。
题目链接:
题目分析:
最简单的方法还是用双指针来找重复范围并去重
考点十六:栈的规则性设计
题目:
定义栈的数据结构,请在该类型中实现一个能够得到栈中所含最小元素的 min 函数(时间复杂度应为 O (1))。 注意:保证测试中不会当栈为空的时候,对栈调用pop() 或者 min() 或者 top() 方法。题目链接:题目分析:
考虑用两个栈结构一起来实现有如上功能的栈,定义一个数据栈_data作为主结构,再定义一个辅助栈_min用于得到最小元素,数据入_data栈时同时将当前栈中最小的值入_min栈,_data栈每出一个数据,_min栈也出一个数据,如此操作即可保证,_data栈和_min栈中元素个数始终相等,且_min栈栈顶元素永远是当前数据栈的最小元素。
代码如下:
class Solution { private: stack<int> _data; stack<int> _min; public: void push(int value) { //首先要入数据栈 _data.push(value); //其次更新_min栈 if(_min.empty()||value<_min.top())//_min为空或新入栈的元素小于当前_min栈栈顶元素 { _min.push(value);//入_min栈,_min栈栈顶元素为数据栈最小元素 } else //否则就是新元素大于_min栈栈顶元素 { _min.push(_min.top());//数据栈当前最小元素仍为_min栈栈顶元素,_min栈顶元素再次入栈 } } void pop() { //如果有一个栈为空,就直接返回[这里是个细节,有时候能看出来你是否严谨] if(_data.empty()||_min.empty()) { return; } _data.pop(); _min.pop(); } int top() { return _data.top(); } int min() { return _min.top(); } };
考点十七:对栈的理解
题目:
输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否可能为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如序列1,2,3,4,5 是某栈的压入顺序,序列 4,5,3,2,1 是该压栈序列对应的一个弹出序列,但4,3,5,1,2就不可能是该压栈序列的弹出序列。(注意:这两个序列的长度是相等的)题目链接:题目分析:要判定第二个序列是否可能是第一个入栈序列的弹出序列,就要使用指定的入栈顺序模拟出来对应的弹栈序列,我们设入栈顺序序列式pushV , 可能为出栈序列 popV,popV的第一个元素,一定是最后入栈,最先弹栈的 , 而我们的入栈顺序是一定的也就决定了,我们必须一直入栈,直到碰到popV 的第一个元素,然后开始弹栈,最后在循环这个过程,如果符合要求,最后栈结构一定是空的。代码如下:class Solution { public: bool IsPopOrder(vector<int> pushV,vector<int> popV) { //某个序列是空或元素个数不相等直接返回假 if(pushV.empty()||popV.empty()||pushV.size()!=popV.size()) { return false; } //定义一个栈按照两序列来模拟入栈出栈操作 stack<int> st; int i=0;//遍历pushV的下标 int j=0;//遍历popV的下标 for(i=0;i<pushV.size();i++) { st.push(pushV[i]); //向后遍历popV,如果和栈顶元素相等就一直删,否则就再遍历pushV继续入栈新元素 while(!st.empty()&&st.top()==popV[j]) { st.pop(); j++; } } return st.empty(); } };
考点十八:二叉树层序遍历
题目:
从上往下打印出二叉树的每个节点,同层节点从左至右打印
题目链接:
题目分析:
本题较为合适的方法就是借助队列来实现,根据题目描述,我们可以知道的是,首先我们都会访问根节点,根节点所在为第一层,第二层是第一层的子节点,访问的第二层元素一定要通过第一层来找到,借助队列我们如何来实现呢?将根节点入队列,访问队列的头然后
代码如下:
/* struct TreeNode { int val; struct TreeNode *left; struct TreeNode *right; TreeNode(int x) : val(x), left(NULL), right(NULL) { } };*/ class Solution { public: vector<int> PrintFromTopToBottom(TreeNode* root) { //若一个结点都没有,直接返回一个vector<int>的默认构造 if(root==nullptr) { return vector<int>(); } //创建result用于存放层序遍历数值 vector<int> result; //定义一个队列用于实现层序遍历 queue<TreeNode*> q; //先把根节点插入队列,才能继续进入循环 q.push(root); //实现顺序为记录q的头元素,q中删除该元素,访问该元素数值并将其尾插到result,直至q为空,说明全部元素都访问完成 while(!q.empty()) { TreeNode* front=q.front(); q.pop(); result.push_back(front->val); //这里需要注意,只有左右不为空指针时才插入,一定要有这个判断 if(front->left) q.push(front->left); if(front->right) q.push(front->right); } return result; } };
考点十九:BST特征的理解【BST是搜索二叉树】
题目:
输入一个非空整数数组,判断该数组是不是某二叉搜索树的后序遍历的结果。如果是则输出 Yes, 否则输出 No 。假设输入的数组的任意两个数字都互不相同。题目链接:
题目分析:
根据搜索二叉树的性质以及后续遍历结果的特点我们可知,该序列最后一个元素为搜索二叉树的根节点,除根节点外,前面的元素一定可以分成左树节点和右树结点两个区间,并且满足所有左树结点值都小于根节点值,所有右树结点值都大于根节点值,仅仅满足这个还不够,其左右子树也要分别满足这样的特点,才符合要求,也就是需要看成子问题,因此可以根据这样的特点来设计我们的代码,需要注意的细节见代码注释。
代码如下:
class Solution { public: //注意数组传的是引用,否则递归过程中就会出问题,就是整个过程我们处理的都是同一个数组 bool _VerifySquenceOfBST(vector<int>&sequence,int start,int end) { //如果只剩一个元素了,说明满足要求,直接返回true if(start>=end) { return true; } //先拿根节点 int root=sequence[end]; //遍历数组确定左树区间 int i =start; while(i<end&&sequence[i]<root) { i++; } //遍历剩下的是不是都大于根,有小于的就说明不符合要求直接返回false for(int j=i;j<end;j++) { if(sequence[j]<root) { return false; } } //子问题 return _VerifySquenceOfBST(sequence,start,i-1)&& _VerifySquenceOfBST(sequence,i,end-1); } bool VerifySquenceOfBST(vector<int> sequence) { //题目要求空树不是搜索二叉树,就要提前先做处理 if(sequence.empty()) { return false; } //不是空树则按照搜索二叉树的性质判断 return _VerifySquenceOfBST(sequence,0,sequence.size()-1); } };
考点二十:简单回溯法的使用
题目:
输入一颗二叉树的根节点和一个整数,打印出二叉树中结点值的和为输入整数的所有路径。路径定义为从树的根结点开始往下一直到叶结点所经过的结点形成一条路径。题目链接:
题目分析:
整体分析来看我们需要遍历二叉树的每条路径,遍历过程中同时要完成当前路径结点值的相加,将结果与目标值相比较,相等就将当前路径放入最终结果集,不相等就继续处理下一条路径,要实现这样的功能,我们需要用到一个重要的算法:回溯法,回溯法类的问题该怎么判断及分析解决呢,我接下来会基于本道题来总结回溯法一般的解决思路?
代码如下:
/* struct TreeNode { int val; struct TreeNode *left; struct TreeNode *right; TreeNode(int x) : val(x), left(NULL), right(NULL) { } };*/ class Solution { public: void FindPathDFS(TreeNode* root,int expectNumber,vector<vector<int>>&result,vector<int>&list) { //如果是空,直接返回即可 if(root==nullptr) { return; } //添加数值到待选结果 list.push_back(root->val); expectNumber-=root->val; //条件判断 if(expectNumber==0&&root->left==nullptr&&root->right==nullptr) { //若满足则添加待选结果到结果集 result.push_back(list); } //DFS FindPathDFS(root->left,expectNumber,result,list); FindPathDFS(root->right,expectNumber,result,list); //回退 list.pop_back(); } vector<vector<int>> FindPath(TreeNode* root,int expectNumber) { //定义结果集和待选结果 vector<vector<int>> result; vector<int> list; FindPathDFS(root,expectNumber,result,list); return result; } };
考点二十一:全排列问题(DFS)
题目:
输入一个字符串 , 按字典序打印出该字符串中字符的所有排列。例如输入字符串 abc, 则打印出由字符 a,b,c 所能排列出来的所有字符串 abc,acb,bac,bca,cab 和 cba 。题目链接:
题目分析:
解决这个问题的思路很多,这里总结一种不错的方法:利用深度优先遍历来解决。
平常练习中,我们观察到,凡是涉及到DFS问题的,一般都是解决二叉树问题的,因此当给出的实际模型不是树形结构,我们不太好想到利用DFS,我个人认为分析问题的时候画图能够很好的将我们思维过程可视化,这个时候有助于我们去判断解决问题的思想,以本题为例,分析如下:
可视化为树形结构,接下来就可以利用回溯法的四步来完善我们的代码了
代码如下:
class Solution { public: void recursion(vector<string> &res, string &str, string &temp, vector<int> &vis){ //递归出口,临时字符串满了加入结果集 if(temp.length() == str.length()){ res.push_back(temp); return; } //遍历所有元素选取一个加入 for(int i = 0; i < str.length(); i++){ //如果该元素已经被加入了则不需要再加入了 if(vis[i]) continue; //当前的元素str[i]与同一层的前一个元素str[i-1]相同且str[i-1]已经用过了 if(i > 0 && str[i - 1] == str[i] && !vis[i - 1]) continue; //现在要使用了,先标记为使用过 vis[i] = 1; //加入待选结果 temp.push_back(str[i]); recursion(res, str, temp, vis); //回溯 vis[i] = 0; temp.pop_back(); } } vector<string> Permutation(string str) { //先按字典序排序,使重复字符串相邻 sort(str.begin(), str.end()); //标记每个位置的字符是否被使用过 vector<int> vis(str.size(), 0); //结果集 vector<string> res; //待选结果 string temp; //递归获取 recursion(res, str, temp, vis); return res; } };
考点二十二:TopK问题
题目:
输入 n 个整数,找出其中最小的 K 个数。例如输入 4,5,1,6,2,7,3,8 这 8 个数字,则最小的 4 个数字是 1,2,3,4, 。题目链接:
题目分析:
本题比较好的思路是先假设前K个数是最小的,对这K个数排序,然后遍历剩下的数字,比最大值小的就用该值代替最大值,比最大值大则跳过继续往后遍历,那么最后剩下的数字一定是最小的K个。
可以采用最大堆,这里使用 C++ priority_queue or java PriorityQueue 优先级队列进行处理(底层原理类似堆). 这里核心思路在于实现 topk ,我们使用现成的解决方案,自己写一个堆出来太麻烦了。如果需要了解堆实现,可以自行了解一下。最小堆(小根堆):树中每个非叶子结点都不大于其左右孩子结点的值,也就是根节点最小的堆 .最大堆(大根堆):树中每个非叶子结点大于其左右孩子结点的值,也就是根节点最大的堆代码如下:
struct comp { bool operator()(const int& a, const int& b) { return a < b; //我们需要最大堆,所以我们采用降序排序 } }; class Solution { public: vector<int> GetLeastNumbers_Solution(vector<int> input, int k) { vector<int> list; if (input.size() == 0 || k <= 0 || k > input.size()) { return list; } priority_queue<int, vector<int>, comp> queue; //采用指定容器实现最大堆 for (int i = 0; i < input.size(); i++) { if ( i < k) { //前k个元素,直接放入,priority_queue内部会降序排序 queue.push(input[i]); } else { if (input[i] < queue.top()) { //如果新的数据,小于queue首部元素(最大值),进行更新 queue.pop(); queue.push(input[i]); } } } for (int i = 0; i < k; i++) { list.push_back(queue.top()); queue.pop(); } return list; } };
动归问题【简单】
题目:
HZ 偶尔会拿些专业问题来忽悠那些非计算机专业的同学。今天测试组开完会后 , 他又发话了 : 在古老的一维模式识别中 ,常常需要计算连续子向量的最大和, 当向量全为正数的时候 , 问题很好解决。但是 , 如果向量中包含负数 , 是否应该包含某个负数, 并期望旁边的正数会弥补它呢?例如 :{6,-3,-2,7,-15,1,2,2}, 连续子向量的最大和为 8( 从第 0 个开始 , 到第 3 个为止) 。给一个数组,返回它的最大连续子序列的和,你会不会被他忽悠住? ( 子向量的长度至少是 1 )题目链接:
题目分析:
给一个数组,返回它的最大连续子序列的和是经典的动归类题目,我们根据分析递归问题的三步来分析此题:
(1)定义状态# f(i): 以i下标结尾的最大连续子序列的和(2)状态递推:f(i) = max(f(i-1)+array[i], array[i]) 【这里一定要注意连续关键字】(3)状态初始化:f(0) = array[0], max = array[0]代码如下:
class Solution { public: int FindGreatestSumOfSubArray(vector<int> array) { //创建一个数组来记录以i为下标的最大连续值序列的和值 int*dp =new int[array.size()]; dp[0]=array[0]; int max_value=array[0]; for(int i=1;i<array.size();i++) { dp[i]=max(dp[i-1]+array[i],array[i]); if(max_value<dp[i]) { max_value=dp[i]; } } delete dp; return max_value; } };
优化:
class Solution { public: int FindGreatestSumOfSubArray(vector<int> array) { int total=array[0]; int max_value=array[0]; for(int i=1;i<array.size();i++) { if(total>=0) { total+=array[i]; } else { total=array[i]; } if(max_value<total) { max_value=total; } } return max_value; } };