刷题总结
这是自己的刷题总结,题目主要包含:剑指offer、程序员代码面试指南、LeetCode,对于一些解题方法和思路除却上面三块外,还有在网上找的各种比较好的解决方法。
目录
数据结构总览
算法总览
二分查找
递归
树
回溯
双指针
滑动窗
链表+递归
数据结构的组合
数据结构总览
数据结构有散列表、栈、队列、堆、树、图等等各种数据结构,但归根结底这些数据结构只有两种存储方式,数组(顺序存储)和链表(链式存储)。
队列、栈这两种数据结构既可以使链表也可以使用数组实现。数组实现,就要处理扩容缩容的问题;链表实现,没有这个问题,但需要更多的内存空间存储节点指针。
图的两种表示方法法,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并且可以进矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但是很多操作的效率上肯定比不过邻接矩阵。
散列表就是通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些。
树,用数组实现就是堆,因为堆是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单;用链表实现就是很常见的那种树,因为不一定是完全二叉树,所以不适合用数组存储。为此,在这种链表树结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL树、红黑树、区间树、B 树等等,以应对不同的问题。
数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,并且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);并且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 O(N)。
链表因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某个元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;并且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间。
数据结构的操作
所有的操作归根结底无非就是:增、删、改、查四种操作,对于数组而言这四种操作都是比较简单的,对于链表可能稍微复杂,在记忆的时候不是记忆的代码而是记忆的过程。
增加节点:
删除节点:
遍历节点:
在遍历时具体操作可以分为两种:迭代和递归
while(cur != null){ cur = cur.next; }
public void traverse(ListNode head){ traverse(head); }
迭代遍历相对简单,递归在后面会进行更详细的讲解。
算法总览
算法就是一些优秀的具体操作的集合。算法包含:滑动窗口、双指针、快慢指针、区间合并、循环排序、原地反转链表、树上的BFS、树上的DFS、双堆、子集、变种二分、最大前K个元素、K-路归并、 拓扑排序等等,根据不同的情况可能又分为多种方法。单独而言每种方法都是比较简单的,困难的地方在于一道题目中可能是多个算法和数据结构的组合,这就导致了题目做不出来。
二分查找
二分查找:常用于寻找某个特定的数,时间复杂度为O(logN)。利用最简单的数据结构:数组,可以快速实现该算法。
public int binarySearch(int[] arr,int target){ //返回-1表示失败 if (arr == null || arr.length == 0) return -1; int left = 0,right = arr.length-1; while (left <= right){ int mid = left+(right-left)/2; if (arr[mid] == target){ return mid; }else if (arr[mid] < target){ left = mid+1; }else { right = mid-1; } } return -1; }
上面的代码实现的是返回特定元素的索引,当有重复元素时,这样做并不能保证查找到的是哪一个,因此有重复元素时不适用,但是如果来判断是否有该元素是可以使用的。对上述代码有几处要注意的地方:
1:当left与right相等时也要进行循环,是因为可能会有下面的情况,直接原因是在一开始声明的时候声明的是闭区间[left,right],如果right=num.length便不会包含了。
2:求mid当遇到偶数的情况,是偏向前面的
3:因为left与right的位置是可以遍历的到的,所以当mid处不是结果时便可以放心移动前或后一个位置
4:二分查找对数据的重复性是很敏感的,当题目特别指出没有重复元素时要好好考虑下了。
注:二分查找与二分法是不同的,二分法也称为分治算法是一种抽象的算法,其思路是当一个原问题复杂难以解决时,可以拆成小问题,把一个个小问题解决了,原问题也就解决了。
在注意到上述几点后,结合不同的题目修改代码就可以了,举例题目:
LeetCode367-有效的完全平方数;LeetCode162-寻找峰值;LeetCode153-寻找旋转排序数组中的最小值;LeetCode154-寻找旋转排序数组中的最小值||;
在二分查找有了上面的基础理解后,二分查找还有两个应用:查找左边界、查找右边界。
查找左边界
public int leftBound(int[] nums,int target){ if (nums == null ||nums.length == 0) return -1; int left = 0; int right = nums.length;//这里和查找位置时不同 while (left < right){//对应于right的初始值情况,循环的终止条件也要修改 int mid = left+(right-left)/2; if (nums[mid] == target){ right = mid;//当相等时,不立即返回而是向左侧收缩 }else if (nums[mid] < target){ //因为初始定义的范围[left,right),所以当mid被检测到时应该分为[left,mid)和[mid+1,right) left = mid+1; }else if (nums[mid] > target){ right = mid; } } return left;//因为while终止的时候两者是相等的,返回谁都一样 }
查找右边界
public int rightBound(int[] nums,int target){ if (nums == null ||nums.length == 0) return -1; int left = 0,right = nums.length; while (left < right){//对应于right的初始值情况,循环的终止条件也要修改 int mid = left+(right-left)/2; if (nums[mid] == target){ left = mid+1;//当相等时,向右侧收 }else if (nums[mid] < target){ //因为初始定义的范围[left,right),所以当mid被检测到时应该分为[left,mid)和[mid+1,right) left = mid+1; }else if (nums[mid] > target){ right = mid; } } //因为while终止的时候两者是相等的,返回谁都一样 //之所以要减1是因为当相等时left=mid+1,这样可能导致更新后就不一定相等了 //其实在左边界也是一样的,因为左边界时right是取不到的,就相当于取到了right-1处位置 return left-1; }
为了格式更加的统一化,可以进行修改
public int leftBound(int[] nums,int target){ if (nums == null ||nums.length == 0) return -1; int left = 0,right = nums.length-1; while (left <= right){ int mid = left+(right-left)/2; if (nums[mid] == target) { right = mid-1;//当相等时,不立即返回而是向左侧收缩 } else if (nums[mid] < target){ //因为初始定义的范围[left,right),所以当mid被检测到时应该分为[left,mid)和[mid+1,right) left = mid+1; }else if (nums[mid] > target){ right = mid-1; } } //需要处理下标越界的情况 if (left == nums.length) return -1; return nums[left] == target ? left : -1;//有可能target不在数组中,这时要判断下 } public int rightBound(int[] nums,int target){ if (nums == null ||nums.length == 0) return -1; int left = 0,right = nums.length-1; while (left <= right){//对应于right的初始值情况,循环的终止条件也要修改 int mid = left+(right-left)/2; if (nums[mid] == target){ left = mid+1;//当相等时,向右侧收 }else if (nums[mid] < target){ left = mid+1; }else if (nums[mid] > target){ right = mid-1; } } if (right < 0) return -1;//下标越界,这里就是不写-1直接写right也是-1的 return nums[right] == target ? right : -1; }
相关题目:LeetCode34-在排序数组中查找元素的第一个和最后一个位置
递归
递归的基本思想是某个函数直接或者间接地调自身,这样就把原问题的求解转换为许多性质相同但是规模更小的子问题。我们只需要关注如何把原问题划分成符合条件的子问题,而不需要去研究这个子问题是如何被解决的。递归和枚举的区别在于:枚举是横向地把问题划分,然后依次求解子问题,而递归是把问题逐级分解,是纵向的拆分。
递归运用最成功的是什么?我认为是数学归纳法。一个使用场景大概是:我们推不出来某个求和公式,但是我们试了几个比较小的数,似乎发现了一点规律,然后编了一个公式,看起来应该是正确答案。但是数学是很严谨的,你哪怕穷举了一万个数都是正确的,但不能保证第一万零一个数正确。可以假设我们编的这个公式在第 k 个数时成立,如果证明在第 k + 1 时也成立,那么我们编的这个公式就是正确的。
写递归的技巧:
跳出细节,从整体上看问题;明白一个函数的作用并相信它能完成这个任务,千万不要试图跳进细节。千万不要跳进这个函数里面企图探究更多细节,否则就会陷无穷的细节无法自拔
基础问题:程序员代码面试指南第四章:斐波那契系列问题的递归和动态规划(只看递归部分)、汉诺塔问题。递归与树是紧密相连的,因此这里把递归,树,回溯三者放在一起,就是为了在看的时候三部分一起看。
树
树:树是掌握递归的最好途径。在为了更深入的理解树,对于一些问题可以采用两种方法:递归和迭代。对于树自身也有一些性质需要掌握如:完全二叉树,完美二叉树,平衡二叉树,搜索二叉树等,有些题目中需要在基础上加上性质才开以做出来。
基础递归:LeetCode-104-二叉树的最大深度;LeetCode-111-二叉树的最小深度;LeetCode-100-相同的树;LeetCode-101-对称二叉树;LeetCode-112-路径总和;LeetCode-404-左叶子之和;LeetCode 257-二叉树的所有路径;LeetCode-230-二叉树的最近公共祖先
递归+遍历:LeetCode 116. 填充每个节点的下一个右侧节点指针;LeetCode 117. 填充每个节点的下一个右侧节点指针 II;
树的性质:LeetCode-110-平衡二叉树;LeetCode-222-完全二叉树的节点个数;LeetCode 235-二叉搜索树的最近公共祖先;LeetCode 98-验证二叉搜索树;LeetCode-450-删除二叉搜索树中的节点;LeetCode-236-二叉搜索树中第K小的元素
树的构造+树的性质+区间拆分:LeetCode-108-将有序数组转换为二叉搜索树;LeetCode105. 从前序与中序遍历序列构造二叉树;LeetCode106. 从中序与后序遍历序列构造二叉树;
复杂递归+树:LeetCode 437-路径总和 III
回溯
基础回溯:LeetCode 17. 电话号码的字母组合;LeetCode 22. 括号生成;LeetCode 113-路径总和 II ;LeetCode-129-求根到叶子节点数字之和;LeetCode 93-复原IP地址;LeetCode 131-分割回文串;LeetCode 46-全排列;LeetCode 47-全排列 II;LeetCode 77-组合;LeetCode 39-组合总和;LeetCode 40-组合总和 II;
floodfill算法:LeetCode 200-岛屿数量
背包问题相关: LeetCode 416-分割等和子集;LeetCode 377-组合总和 Ⅳ;LeetCode 474-一和零
动态规划
基础动态规划:LeetCode 53. 最大子序和;LeetCode 70-爬楼梯;LeetCode 120-三角形最小路径和;LeetCode 343-整数拆分;LeetCode 198-打家劫舍
动态规划+限制条件:LeetCode 91-解码方法;LeetCode 63-不同路径 II;LeetCode 213-打家劫舍 II
二维动态规划: LeetCode 62-不同路径;LeetCode 64-最小路径和;LeetCode 115. 不同的子序列;
二叉树+动态规划:LeetCode 337-打家劫舍 III
背包问题相关:LeetCode 377-组合总和 Ⅳ;LeetCode 474-一和零;LeetCode 416-分割等和子集;LeetCode 139-单词拆分;LeetCode 494-目标和;LeetCode 1240. 铺瓷砖
贪心
基础:LeetCode 455-分发饼干;LeetCode 121. 买卖股票的最佳时机;LeetCode 122. 买卖股票的最佳时机 II
双指针
可以分为两类是快慢指针和左右指针;前者解决主要解决环形的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。但在具体题目中是不一定的,因为单向链表无法退,如果可以后退的话也可以使用左右指针,同理数组中也可以使用快慢指针的。更加详细一点还可以分为碰撞指针,滑动窗。
数组+双指针:LeetCode-88-合并两个有序数组;LeetCode 26. 删除排序数组中的重复项;LeetCode167-两数之和;LeetCode-283-移动零;LeetCode-26-删除排序数组中的重复项;LeetCode-27-移除元素;LeetCode 11. 盛最多水的容器;
数组+双(多)指针+排序:LeetCode 15. 三数之和;LeetCode 16. 最接近的三数之和
字符串+双指针:LeetCode-125-验证回文串;LeetCode-344-反转字符串 ;LeetCode-345-反转字符串中的元音字母;LeetCode 392-判断子序列
链表的常规操作:LeetCode203-移除链表元素;LeetCode-83-删除排序链表中的重复元素;LeetCode-237-删除链表中的节点;LeetCode-24-两两交换链表中的节点
链表一些特殊操作:LeetCode 138. 复制带随机指针的链表;
链表+双指针(多指针):LeetCode-234-回文链表;LeetCode-19-删除链表的倒数第N个节点;LeetCode-61-旋转链表;LeetCode141-环形链表;LeetCode-143-重排链表;LeetCode142-环形链表||;LeetCode160-相交链表;LeetCode-86-分隔链表;LeetCode-21-合并两个有序链表;LeetCode-2-两数相加;LeetCode-328-奇偶链表;LeetCode-92-反转链表||;LeetCode-82-删除排序链表中的重复元素||;
链表+双(多)指针+排序:本质上还是利用几个指针的移动,不过在移动的前提上加上了排序的思想。
LeetCode-147-对链表进行插入排序;
滑动窗技术
滑动窗的抽象思想
LeetCode53. 最大子序和;LeetCode-209-长度最小的子数组;LeetCode-3-无重复字符的最长子串;LeetCode-438-找到字符串中所有字母异位词;LeetCode-76-最小覆盖子串
链表+递归
LeetCode203-移除链表元素
在链表中创建出一个虚拟头结点,在针对删除插入操作时特别方便。链表是链式存储的,迭代是最直接的方便的方法,但是对于一些基础操作要会使用递归。
以LeetCode206-反转链表为例
public ListNode reverseList(ListNode head) { if (head == null || head.next == null ) return head; ListNode last = reverseList(head.next); head.next.next = head; head.next = null; return last; }
在分析递归的时不要陷入细节,整体分析: reverseList(head.next)
执行完后是如下的情况:
用遍历last接收最后一个节点。head.next.next = head;则是修改链表的指向,链表的末尾要指向 null,所以才有了head.next = null。
数据结构的组合
栈: LeetCode-20-有效括号;LeetCode-150-逆波兰表达式求值
树+队列:LeetCode-102-二叉树的层次遍历;LeetCode-107-二叉树的层次遍历||
Set与Map:LeetCode-217-存在重复元素;LeetCode-349-两个数组的交集;LeetCode-350-两个数组的交集||;LeetCode-242-有效的字母异位词;LeetCode-202-快乐数;LeetCode-205-同构字符串;LeetCode-1-两数之和;LeetCode-454-四数相加||;LeetCode-447-回旋镖的数量
双指针(滑动窗)+查找表:LeetCode-219-存在重复元素||;LeetCode-220-存在重复元素|||
查找表+排序:LeetCode-49-字母异位词分组