刷题总结

这是自己的刷题总结,题目主要包含:剑指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 

 

回溯

解决一个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题:
1、路径:也就是已经做出的选择。
2、选择列表:当前可以做的选择。
2、结束条件:到达决策树底层,无法再做选择的条件。
算法框架:

基础回溯: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

二维回溯:LeetCode 79-单词搜索;LeetCode 130-被围绕的区域

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 435-无重叠区间

 

 

 

双指针

可以分为两类是快慢指针和左右指针;前者解决主要解决环形的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。但在具体题目中是不一定的,因为单向链表无法退,如果可以后退的话也可以使用左右指针,同理数组中也可以使用快慢指针的。更加详细一点还可以分为碰撞指针,滑动窗。

数组+双指针: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-字母异位词分组 

 
 
排序
插入排序:LeetCode 147. 对链表进行插入排序
归并排序:LeetCode  148. 排序链表
 
 
位操作
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0
 

 

posted @ 2020-03-05 09:56  windy杨树  阅读(223)  评论(0编辑  收藏  举报