赞
踩
数据结构 + 算法 = 程序。
无论从事前端还是后端,无论使用什么编程语言,
只要想进大厂,都绕不开考核数据结构与算法这道坎,
本文中的代码均用Java编写,当然,也可以使用其他语言来实现。
其实编程语言并不是最重要的,思想才是,逻辑思维才是。
本文大部分内容并不是作者原创,
只是记录作者在学习算法与数据结构过程中的点点滴滴,
如有侵权,请在评论中及时指出。
本文理论上是对[新手/小白]友好的,
如果有不明白的地方可以评论指出。
本文内容较多,
建议先纵览一下文章顶部的目录,
再结合左侧目录阅读。
先把基本的数据结构弄明白,例如:数组,链表,哈希表等等。
之后再去看一些基本的算法,例如:枚举,递归,二分查找等等。
再循序渐进。
注意事项:
[堆栈]作为计算机科学中的一个专有词语,操作系统中的[堆栈]和数据结构中的[堆栈]不是一个概念;
基本概念:
数据结构可以优化算法效率,同一个算法用不同的数据结构,
可能会带来完全不一样的时间和空间(时间与空间互换)。
例如:平衡二叉树与数组都能实现查找功能,
但两者的时间复杂度分别是:O(n),O(logn)。
涉及到时间复杂度的地方,如果读者不懂,
可以先跃迁至本文的“算法”部分,里面有个小章节有专门讲解。
不管是什么数据结构,在内存中的存储中方只有两种:
数组(顺序存储);
链表(链式存储);
[常用/常见]数据结构:
数组
;链表
;二叉树
;以下是最简单的十个数据结构,按照难易程度排序:
线性表
线性表是最容易理解的数据结构,这是一切开始的地方,
线性表是指存储了多个数据元素的数据结构,
其中的元素是按照线性顺序排列的,
元素具有相邻关系,每个元素最多只有一个前驱和一个后继,
可以按照一定顺序进行存储和访问。
常见的线性表有:数组、链表、队列和栈等。
数组
数组在内存中是紧凑连续的存储,可以随机访问,
通过[索引/index]可以快速找到对应元素,
而且相对节约存储空间。
但正因为连续存储,所以内存空间必须一次性分配够,
如果数组要扩容,则需要重新分配一块更大的空间,
再把数据全部复制过去,时间复杂度为 O(N)。
如果想在数组中间进行插入和删除,
每次必须移动后面的所有数据以保持连续,时间复杂度为 O(N)。
这是一个简单的数组的使用示例,
通过定义数组,赋值,遍历等方式来说明数组的使用方法:
public class ArrayExample { public static void main(String[] args) { // 定义数组并初始化 int[] numbers = new int[]{1, 2, 3, 4, 5}; // 输出数组内容 for (int i = 0; i < numbers.length; i++) { System.out.println(numbers[i]); } // 修改数组内容 numbers[2] = 6; // 再次输出数组内容 for (int i = 0; i < numbers.length; i++) { System.out.println(numbers[i]); } } }
树状数组:
树状数组是一种数组。它是一种用于处理序列上的信息的数据结构,
由于其维护的信息的树形结构,因此得名为树状数组。
树状数组用于解决问题,例如查询区间和,单点修改等。
它可以通过使用树形结构加速查询和修改操作,从而提高处理效率。
树状数组的算法复杂度通常是 O(log n),
因此在很多场景下可以高效地处理数据。
链表
链表是一种通过链式关系存储数据元素的线性表,
其中每个元素都是一个节点,
每个节点都存储了该元素的数据和一个指向下一个元素的指针。
链表是线性表的一种重要的实现方式,
它比数组等其他存储结构具有更好的灵活性和可扩展性。
链表因为元素不连续,而是靠指针指向下一个元素的位置,
所以不存在数组的扩容问题。
如果知道某一元素的前驱和后驱,
操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。
但是正因为存储空间不连续,
你无法根据一个[索引/index]算出对应元素的地址,所以不能随机访问。
而且由于每个元素必须存储指向前后元素位置的指针,
会消耗相对更多的储存空间。
// 定义链表节点类 class ListNode { int val; ListNode next; public ListNode(int val) { this.val = val; } } public class LinkedListExample { public static void main(String[] args) { // 初始化链表 ListNode head = new ListNode(1); head.next = new ListNode(2); head.next.next = new ListNode(3); // 遍历链表 ListNode current = head; while (current != null) { System.out.println(current.val); current = current.next; } } }
其中,ListNode 类定义了链表的节点,val 表示该节点的值,
next 表示该节点的下一个节点。
在主函数中,通过连接多个节点初始化链表,
并通过循环遍历输出链表中的值。
单向链表:
双向链表:
在链表的基础上,每个节点还包含一个指向前一个节点的指针。
循环链表:
在链表的基础上,最后一个节点指向第一个节点,形成一个环。
循环链表是一种特殊的链表,
其中最后一个元素的指针指向第一个元素,
从而形成一个环。
循环链表可以在遍历链表元素时使用,
并且可以在没有特殊情况的情况下无限循环。
跳表:
跳表是一种有序的数据结构,它通过在数据间增加跳转指针来提高查询效率。
跳表中的每个节点都包含一个键值和一些指向其他节点的指针。
这些指针分别指向当前节点在更高层次中的下一个节点。
通过使用跳转指针,可以减少搜索的节点数,从而加快查询速度。
跳表是一种优化的链表数据结构,其时间复杂度为O(logN),
非常适合在数据量较大的情况下对有序数据进行查询、插入和删除操作。
栈与队列
栈,Stack,又名堆栈,是[后进先出/先进后出]的线性表数据结构,
其只有一端(栈顶)可以任意进出元素
,
而另一端(栈底)则无法进行任何操作。
它是一种运算受限的线性表。
其限制是仅允许在表的一端进行插入和删除运算。
这一端被称为栈顶,相对地,把另一端称为栈底。
向一个栈插入新元素又称作[进栈/入栈/压栈],
它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素。
从一个栈删除元素又称作[出栈/退栈],它是把栈顶元素删除掉,
使其相邻的元素成为新的栈顶元素。
其运行方式如下:
使用[栈]主要记住的方法:
push(),压入;
pop(),弹出;
import java.util.Stack; public class StackExample { public static void main(String[] args) { // 创建一个空栈 Stack<Integer> stack = new Stack<>(); // 向栈顶加入元素 stack.push(1); stack.push(2); stack.push(3); // 栈内元素数量 System.out.println(stack.size()); // 打印栈内元素 System.out.println("栈内元素:" + stack); // 输出:栈内元素:[1, 2, 3] while(!stack.isEmpty()){ // 访问栈顶元素 System.out.println("栈顶元素1:" + stack.peek()); // 输出:栈顶元素:3 // 访问并删除栈顶元素 System.out.println("栈顶元素2:" + stack.pop()); } } }
栈的使用场景:
队列是[先进先出]的线性表数据结构,其中元素只能从队尾进,从队首出。
如下图所示,元素依次入队又依次出队。
另外,有标记的位置分别代表队首与队尾,其中左边为队首。
队列是一种特殊的线性表,
特殊之处在于它只允许在表的前端(front)进行删除操作,
而在表的后端(rear)进行插入操作,和栈一样,
队列是一种操作受限制的线性表。
进行插入操作的端称为队尾,进行删除操作的端称为队头。
队列中没有元素时,称为空队列。
使用[队列]主要记住的方法:
offer(),在队列尾部添加一个元素,但不会抛出异常,而是返回false(如果队列已满)或true(如果添加成功);
add(),在队列尾部添加一个元素,如果队列已满,则抛出IllegalStateException异常;
poll(),从队列的头部删除并返回第一个元素。如果队列为空,则返回null;
remove(),从队列的头部删除并返回第一个元素。如果队列为空,则抛出NoSuchElementException异常;
import java.util.LinkedList; import java.util.Queue; public class QueueExample { public static void main(String[] args) { // 创建队列 Queue<Integer> queue = new LinkedList<>(); // 向队列中加入数据 queue.offer(1); queue.offer(2); queue.offer(3); // 从队列头部取出数据 System.out.println(queue.poll()); // 输出:1 // 检查队列是否为空 System.out.println(queue.isEmpty()); // 输出:false // 检查队列大小 System.out.println(queue.size()); // 输出:2 // 从队列头部取出数据,若队列为空,则返回null System.out.println(queue.peek()); // 输出:2 // 清空队列 queue.clear(); // 检查队列是否为空 System.out.println(queue.isEmpty()); // 输出:true } }
双端队列:具有队列和栈双重性质的线性表数据结构。
单调栈:
在基础栈上稍加改动所形成的[单调栈]算法,
该算法与[单调队列]组成了算法题中最常考察的线性数据结构。
顾名思义,[单调栈]就是栈内元素满足单调性的栈结构。
此处的单调性分为[单调递增]与[单调递减],
为了便于描述,接下来以[单调递增栈]为例进行讲解。
[单调递增栈]就是栈内元素满足单调递增,
假设当前元素为x,若栈顶元素≤x,则将x入栈,
否则不断弹出栈顶元素,直至栈顶元素≤x。
我们仍以3,1,4,5,2,7为例,其[单调递增栈]具体过程如下图所示。
不难发现,入栈结束后,栈中仅保留了1,2,7,
其中3由于比1大被弹出,而4,5则由于比2大被弹出。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WWEWe4Qe-1684831719293)(evernotecid://BCE3D193-8584-4CB1-94B3-46FF37A1AC6C/appyinxiangcom/12192613/ENResource/p4012)]
在栈中维护单调性究竟有什么用呢?
要回答这个问题,
我们首先来观察一下上述示例中2为当前元素时的状态,如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ySImYhpY-1684831719293)(evernotecid://BCE3D193-8584-4CB1-94B3-46FF37A1AC6C/appyinxiangcom/12192613/ENResource/p4013)]
在该状态中,栈顶元素为5,而当前元素为2,由于5比2大,
即此时若将2放入栈内,则不满足单调递增,因此需要将5弹出栈。
这时请大家思考,2和5之间是否有什么更深层的关系,
所以才导致了5最终被2弹出?
仔细观察原始序列3,1,4,5,2,7,
我们可以发现2是5右边第一个比它小的数。
基于这个发现,我们再次回顾「栈顶元素被弹出,当且仅当栈顶元素 > 当前元素」这一条件,
因此我们可以得知对于单调递增栈,若栈顶元素被弹出,
则当前元素为其右边第一个比它小的数。
2弹出5后,栈顶变为4,此时4仍比2大,
因此4也被弹出,这时可确定2是4右边第一个比它小的数。
4被弹出后,栈顶变为1,1≤2,因此2被放入栈,此时栈内状态如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VhIFzm4P-1684831719298)(evernotecid://BCE3D193-8584-4CB1-94B3-46FF37A1AC6C/appyinxiangcom/12192613/ENResource/p4014)]
按照刚才的思路,我们继续思考为何最终状态下2的左边是1,
这两个数之间又有何关联?
继续观察原始序列3,1,4,5,2,7,
可以发现1是2左边第一个小于等于它的数,稍加思考后,
我们可以得知当一个数字被放入单调递增栈时,
其栈内左边的数是它在原始序列中,左边第一个小于等于它的数。
至此我们可以解答最开始的疑问,
单调栈的根本作用在于求得每一个数字在原始序列中[左/右]边第一个[大于/小于]它自身的数字,
并且由于每一个数字只会入栈一次且最多出栈一次,
因此总的时间复杂度为 O(n)。
另外需要注意,一次「单调递增栈」的过程,
可以求得每个数字左边第一个小于等于它的数,
以及右边第一个小于它的数,此处需注意「小于等于」和「小于」的区别。
除此之外,「单调递减栈」将上述的「小于」改为「大于」即可成立。
单调队列:
[单调队列]在[数据结构]题中的分布较为广泛,
且常被当作优化[动态规划]的一种重要手段。
顾名思义,[单调队列]就是队列内元素满足单调性的队列结构。
且为了满足队列内元素的单调性,队尾也可弹出元素。
此处的单调性分为单调递增与单调递减,
为了便于描述,接下来以[单调递增队列]为例进行讲解。
[单调递增队列]中[队尾]的操作与[单调递增栈]中[栈顶]的操作一致,
即假设当前元素为x,若队尾元素<=x,则将x入队,
否则不断弹出队尾元素,直至队尾元素<=x。
例如以3,1,4,5,2,7,为例,
若[队首]始终不弹出元素,则其具体过程如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QDJVXdv9-1684831719299)(evernotecid://BCE3D193-8584-4CB1-94B3-46FF37A1AC6C/appyinxiangcom/12192613/ENResource/p4015)]
由此可知,[单调队列]与[单调栈]的最大区别就在于[队首]的操作,
何时将队首元素出队是[单调队列]算法的关键。
然而[队首]的操作往往具有多样性,并非一成不变。
串是一种特殊的字符序列,它是通过一系列字符组成的,
每个字符具有[前驱/后继]关系。因此,串可以看作是字符线性表,
其中的字符元素按照其在串中的顺序确定。
在计算机科学中,串是一个重要的数据结构,
广泛用于文本处理、字符串匹配等问题的解决方案。
记住一些常用方法:
显示字符串长度是length();
获取字符串中的某一个字符用charAt(),如果字符串为"",调用该方法会抛异常;
public class StringExample { public static void main(String[] args) { // 创建字符串 String str1 = "Hello"; String str2 = new String("World"); char[] array = new char[]{'a', 'b', 'c', 'd', 'e'}; String str3 = new String(array, 0, 3); // 获得字符串中下标为0的字符 char c = str1.charAt(0); // 拼接字符串 String str3 = str1 + " " + str2; System.out.println(str3); // Hello World // 获取字符串长度 int length = str3.length(); System.out.println("字符串长度:" + length); // 11 // 判断字符串是否相等 boolean equals = str1.equals(str2); System.out.println("字符串是否相等:" + equals); // false // 查找子字符串 int index = str3.indexOf("World"); System.out.println("子字符串索引:" + index); // 6 // 截取字符串 String subString = str3.substring(6); System.out.println("截取后的字符串:" + subString); // World String str = "abcdefg"; System.out.println("---获得字符串任意下标的字符---"); System.out.println(str.charAt(0)); System.out.println(str.charAt(1)); System.out.println(str.charAt(2)); System.out.println("---字符串转换成char数组---"); char[] chars = str.toCharArray(); for(int i = 0; i < chars.length; i++){ System.out.println(chars[i]); } System.out.println("---小驼峰转蛇形---"); String s = "orderOptionId"; String zz = s.replaceAll("[A-Z]", "_$0").toLowerCase(); System.out.println(zz); System.out.println("---获取字符下标---"); String x = "ababc"; System.out.println(x.lastIndexOf("ab")); System.out.println(x.indexOf("ab")); String aa = "aaaalimitbbbb"; System.out.println(aa.indexOf("limit"));//4 System.out.println(aa.substring(aa.indexOf("limit")+5, aa.length()));//bbbb System.out.println(aa.substring(aa.indexOf("limit")+5));//bbbb System.out.println(aa.substring(0,aa.indexOf("limit")+5));//aaaalimit System.out.println(aa.substring(aa.indexOf("l")));//limitbbbb System.out.println(aa.substring(aa.indexOf("limit")));//limitbbbb String aaa = "SELECT gender,key_id,user_name,phone FROM t_user ".toLowerCase(); System.out.println(aaa.substring(aaa.indexOf("select")+6, aaa.indexOf("from")).trim()); //gender,key_id,user_name,phone } }
字典树:
未完待续
后缀树:
未完待续
树是一种非线性数据结构,由n个节点组成,其中一个节点称为根节点,
剩余节点分为m个互不相交的子树。
每个子树又是一棵树,
即每个子树都可以看作是一个节点和它的所有子节点组成的树。
树的节点包含一个数据元素以及指向其子节点的指针或引用。
每个节点可以有任意数量的子节点,
但每个节点只有一个父节点,除了根节点没有父节点,叶节点没有子节点。
树的常见应用包括文件系统、数据库索引、HTML文档、编译器语法树等。
常见的树结构包括二叉树、平衡树、B树、红黑树等。
树的遍历方式(如果有子树,则先走子树,再走自己):
一些概念:
叶子节点:度为0的节点;
// 树节点类 class TreeNode { int val; TreeNode left; TreeNode right; public TreeNode(int val) { this.val = val; } } public class TreeExample { public static void main(String[] args) { // 创建根节点 TreeNode root = new TreeNode(1); // 左子树 root.left = new TreeNode(2); root.left.left = new TreeNode(4); root.left.right = new TreeNode(5); // 右子树 root.right = new TreeNode(3); root.right.right = new TreeNode(6); // 遍历树 preOrder(root); } // 先序遍历 public static void preOrder(TreeNode node) { if (node == null) { return; } System.out.print(node.val + " "); preOrder(node.left); preOrder(node.right); } }
最近公共祖先:
在树形数据结构中,最近公共祖先(Lowest Common Ancestor, LCA)是指两个节点在树形结构中的公共祖先中,
距离两个节点最近的节点。
最近公共祖先在很多情况下都是需要求解的问题,
例如:在计算机科学中求两个文件的公共祖先;
在生物学中求两个物种的公共祖先;在图论中求两个节点的公共祖先。
在求解最近公共祖先的问题时,通常使用多种算法,
例如:暴力递归算法,倍增算法,树剖算法等。
每种算法的时间复杂度和空间复杂度都有所不同,
因此在实际应用中应根据需求选择适当的算法。
并查集是一种树型的数据结构,
常用于处理一些不相交集(Disjoint Sets)问题。
它用于维护一组不相交的集合,每个集合用一个树来表示。
在并查集中,每个元素最开始都是一个单独的集合。
并查集提供两个主要的操作:
查询两个元素是否在同一个集合中,以及合并两个元素所在的集合。
并查集的优点在于,它可以高效地处理动态集合问题,
并且可以在线性时间内查询两个元素是否在同一个集合中。
并查集通常用于以下应用场景:联通块问题,动态图的生成树等。
它的算法复杂度为 O(α(n)),
其中 α(n) 是一个极慢增长的逆反 Ackermann 函数,
因此并查集的时间复杂度在实际情况中近似于常数。
二叉树
前中后序遍历
二叉排序树和二叉搜索树是同一个概念,指一种特殊的二叉树,
对于树中的任意一个节点,左子树中的所有节点的值都小于该节点的值,
右子树中的所有节点的值都大于该节点的值。
[二叉排序树/二叉搜索树]是一种非常有效的数据结构,
这种特殊的结构容易进行查询、插入和删除操作,
可以广泛用于实现字典和搜索引擎等应用。
二叉排序树的一个重要应用是在查找和排序算法中。
由于其有序性质,二叉排序树可以快速地查找一个元素,
时间复杂度为O(log n)。
二叉搜索树的左子树永远是比根节点小,
而它的右子树则永远比根节点大。
8
/ \
3 10
/ \ \
1 6 14
/ \ /
4 7 13
比较适合找树中的[最大值节点/最小值节点],
最小值在树的最最最左侧,最大值在树的最最最右侧。
红黑树
红黑树,Black Red Tree,是一种自平衡的[二叉排序树/二叉搜索树]。
它具有[二叉排序树/二叉搜索树]的性质,
即:每个节点的左子树都小于它,右子树都大于它。
普通的[二叉排序树/二叉搜索树]在极端的情况下可退化成链表,
此时的查找效率会比较低下。
为了避免这种情况,就出现了一些自平衡的[二叉排序树/二叉搜索树],
比如AVL、红黑树等。
但是,红黑树除了二叉搜索树的性质外,还具有特殊的颜色规则,
这使得它在保证平衡的同时还能保证。。。
它们通过定义一些性质,将任意结点的左右子树高度差控制在固定范围内,
以达到平衡状态。红黑树需要满足如下五条性质:
这五条性质约束了红黑树,可以通过数学来证明,
满足这五条性质的二叉树,
就可以保证任意节点到其每个叶子节点路径最长不会超过最短路径的2倍。
证明如下:
当某条路径最短时,这条路径比如都是黑色节点构成。
当某条路径长度最长时,这条路径必然是由红色和黑色节点相间构成。
而又限定了从任一节点到其每个叶子节点的所有路径必须包含相同数量的黑色节点。
此时,在路径最长的情况下,路径上红色节点数量 = 黑色节点数量。
该路径长度为黑色节点数量的2倍,也就是最短路径长度的2倍。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oMtTxOCS-1684831719302)(evernotecid://BCE3D193-8584-4CB1-94B3-46FF37A1AC6C/appyinxiangcom/12192613/ENResource/p4019)]
其特殊的颜色规则和大小关系保证了树的高度最多为O(log n),
从而保证了查询、插入和删除操作的最坏复杂度为O(log n)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gSVvymgQ-1684831719305)(evernotecid://BCE3D193-8584-4CB1-94B3-46FF37A1AC6C/appyinxiangcom/12192613/ENResource/p4018)]
因此,红黑树是一种特殊的二叉树,具有二叉树的一些性质,
但也具有自己独特的性质,并且广泛应用于许多算法中,
例如在操作系统内存管理、数据库索引等方面。
红黑树的基本操作与其他树的操作一样,有查找、插入和删除等操作。
由于查找与其他树的操作一样,比较简单,而插入、删除操作比较复杂,
这里主要就是接受插入、删除操作。
旋转操作:
由于插入、删除的过程中都要涉及到旋转,
这里首先介绍一下旋转这个基本操作,旋转操作分为左旋转和右旋转。
左旋转的过程如下:
A B
/ \ / \
T1 B --->(left rotate) A T3
/ \ / \
T2 T3 T1 T2
右旋转的过程如下:
A B
/ \ / \
B T3 --->(right rotate) T1 A
/ \ / \
T1 T2 T2 T3
插入操作:
红黑树的插入过程和[二叉排序树/二叉搜索树]的插入过程基本类似,
不同的地方在于,红黑树插入新节点后,需要进行调整,
以满足红黑树的性质。
在讨论红黑树的插入操作之前必须要明白,
任何一个即将插入的新节点的初始颜色都为红色。
原因很简单,引入插入黑色的节点会增加某条路径上黑节点的数目,
从而导致整棵树黑高度的不平衡。
但如果插入的节点是红色的,此时所有路径上的黑色节点数量不变,
仅可能会出现两个连续的红色节点的情况。
这种情况下,通过变色和旋转进行调整即可,比插入黑色的简单多了。
红黑树的插入可能遇到如下几种情况:
感觉这里有问题,刚开始的叔叔节点不是黑色
);删除操作:
相对于插入操作,红黑树的删除操作更为复杂。
同样,这里我们也分为几种情况进行分析:
未完待续
堆
堆是一种特殊的二叉树数据结构。
它是一种完全二叉树,具有以下特殊性质:
因此,堆是一种特殊的二叉树,具有二叉树的一些性质,
但也具有自己独特的性质。
堆的应用非常广泛,例如堆排序,最小生成树等。
堆常用于构建优先队列等问题的解决方案。
堆(heap)也被称为优先队列,
队列中允许的操作是 先进先出(FIFO),
在队尾插入元素,在队头取出元素。
而堆也是一样,在堆底插入元素,在堆顶取出元素。
二叉树的衍生,有最小堆最大堆的两个概念,
将根节点最大的堆叫做[最大堆/大根堆],
根节点最小的堆叫做[最小堆/小根堆]。
常见的堆有二叉堆、斐波那契堆等。
B-树(balance tree)
B树是一种多路平衡查找树,它的每一个节点最多包含n个子节点,n被称为b树的阶,
B-树的高度比二叉查找树要低,在索引的层面,相当于磁盘io减少,可以提升查找性能。
例如:mongoDB的索引就是用了B-树的数据结构。
Alex:
1.B-树并不是一个二叉树,而是多叉树。
2.B-树的每个节点中包含了多个元素,并且元素是从小到大排列。
3.叶子节点中还是左小右大,中间则是被包含的关系。
4.B-树的插入特别麻烦,会造成多节点连锁反应,但保证了自平衡,高度相对较小。
B-树的删除需要左旋。
5.节点中的元素多也不怕,因为每个节点的大小与磁盘页大小相同,
磁盘读取数据有预读的功能,可以把一个节点里的所有元素都读出来,在内存层面的比较是相当快的。
一个m阶的b树(下面的k相当于是一个临时变量):
1.根节点至少有2个子节点。
2.每个中间节点都包含k-1个元素和k个子节点,其中m/2 <= k <= m
(例如4阶的B-树,中间节点最多包含4个子节点,3个元素,最少包含2个子节点,1个元素)
所以这里是不是应该改成ceil(m/2),向上取整?
3.每一个叶子节点都包含k-1个元素,其中m/2 <= k <= m.
4.所有的叶子节点都位于同一层。
5.每个节点中的元素从小到大排序,节点当中k-1个元素正好是k个孩子包含的元素的值域划分。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Jr34IlRU-1684831719316)(evernotecid://BCE3D193-8584-4CB1-94B3-46FF37A1AC6C/appyinxiangcom/12192613/ENResource/p376)]
B+树是B-树的一种变体,有着比B-树更高的查询性能。
相对于B-树的优势:
在数据结构中,图(Graph)是由一组节点(Vertex)和一组边(Edge)组成的一种数据结构。
节点表示图中的实体,边表示节点之间的关系。
图可以用 G=(V,E) 表示,其中 V 表示节点的集合,E 表示边的集合。
如果边有方向,称为有向图(Directed Graph),
如果边没有方向,则称为无向图(Undirected Graph)。
图可以用多种方式来表示,
最常见的方式是使用邻接表(Adjacency List)或邻接矩阵(Adjacency Matrix)。
邻接表用一个数组来存储每个节点的相邻节点列表,
对于有向图,每个节点都会有一个出边列表和一个入边列表;
邻接矩阵用一个二维数组来表示节点之间的关系,
数组中的元素表示两个节点之间是否有边相连,如果有,则为 1,否则为 0。
图可以用来解决许多实际问题,比如路线规划、社交网络分析、网络安全等。
在算法设计中,图论也是一个重要的分支,
许多经典的算法和数据结构都基于图的概念,
比如最短路径算法、最小生成树算法、拓扑排序算法、网络流算法等等。
因此,学习图论对于掌握算法和数据结构的核心概念非常重要。
图是由一系列节点和边组成的数据结构,其中节点代表图中的对象,
边代表两个节点之间的关系。
图是一种复杂的数据结构,可以用于模拟各种实际问题,
如网络,地图等。图中的节点和边没有固定顺序。
数据结构中的图是一种抽象的数学模型,表示对象之间的关系,
通常用于描述网络结构。
它由节点(也称为顶点)和边组成,边表示两个节点之间的关系。
图论是一门研究图的学科,研究图的概念、性质、算法等。
它是数学、计算机科学和工程学中的一个重要领域,
在网络、图像处理、遗传学、社会网络分析等领域有着广泛的应用。
import java.util.*; // 图的节点类 class GraphNode { int val; List<GraphNode> neighbors; GraphNode(int val) { this.val = val; this.neighbors = new ArrayList<>(); } } // 有向图类 class DirectedGraph { List<GraphNode> nodes; DirectedGraph() { this.nodes = new ArrayList<>(); } // 添加节点 public void addNode(GraphNode node) { nodes.add(node); } // 添加边 public void addEdge(GraphNode from, GraphNode to) { from.neighbors.add(to); } // 深度优先搜索遍历 public void dfs(GraphNode node, Set<GraphNode> visited) { if (node == null || visited.contains(node)) { return; } visited.add(node); System.out.print(node.val + " "); for (GraphNode neighbor : node.neighbors) { dfs(neighbor, visited); } } } public class Main { public static void main(String[] args) { // 创建节点 GraphNode node0 = new GraphNode(0); GraphNode node1 = new GraphNode(1); GraphNode node2 = new GraphNode(2); GraphNode node3 = new GraphNode(3); // 创建图 DirectedGraph graph = new DirectedGraph(); graph.addNode(node0); graph.addNode(node1); graph.addNode(node2); graph.addNode(node3); // 添加边 graph.addEdge(node0, node1); graph.addEdge(node0, node2); graph.addEdge(node1, node2); graph.addEdge(node2, node0); graph.addEdge(node2, node3); graph.addEdge(node3, node3); // 深度优先搜索遍历 Set<GraphNode> visited = new HashSet<>(); System.out.print("DFS Traversal: "); graph.dfs(node2, visited); } }
[散列表/哈希表]
哈希表通常是由数组和链表组成的。
哈希表使用哈希函数将键映射到数组的某个位置,
如果发生冲突(即两个不同的键被映射到同一个位置),
则使用链表将冲突的元素链接在一起。
这种组合的数据结构提供了一种高效的方法,
可以在O(1)的时间复杂度内执行插入、查询和删除操作。
散列表是一种特殊的数组,
它利用一个哈希函数将数据映射到一个固定的数组索引,
然后在该位置存储数据。
通过将数据映射到哈希表中的桶来组织数据,
从而实现快速查询和插入操作。
由于散列表内部的元素没有固定顺序,并且仅仅与哈希函数的结果相关,
因此散列表不属于线性表数据结构。
散列表具有快速查找,插入,删除操作的特点,
因此常用于解决查找问题和存储问题等。
哈希表通过使用散列函数将键映射到数组中的桶,
从而实现快速查找和插入操作。
它允许您以 O(1) 的复杂度在表中查找、插入和删除元素。
哈希表是一种基于哈希函数实现的数据结构,
它使用关键字(key)和值(value)的映射关系进行快速查找。
哈希表中的元素是通过哈希函数映射到一组桶中的,
桶之间没有顺序关系,
它可以实现非常快速的查找和插入操作,是很多算法和数据结构的重要基础。
import java.util.HashMap; public class HashMapExample { public static void main(String[] args) { // 创建一个哈希表 HashMap<String, Integer> map = new HashMap<>(); // 向哈希表中添加键值对 map.put("A", 1); map.put("B", 2); map.put("C", 3); // 获取哈希表中的值 int value = map.get("B"); System.out.println("Value for key 'B': " + value); // 判断哈希表是否包含特定的键 boolean exists = map.containsKey("C"); System.out.println("Key 'C' exists: " + exists); // 移除哈希表中的键值对 map.remove("A"); // 输出哈希表中的所有键值对 System.out.println("All values in the map: " + map.values()); Map<Integer, String> map = new HashMap(); map.put(1, "a"); map.put(2, "b"); map.put(3, "c"); // 没有key对应的话就返回个默认值value System.out.println(map.getOrDefault(4, "x"));; System.out.println(map.getOrDefault(3, "x"));; } }
哈希树是一种结合了哈希表和树形结构的数据结构,
它对键值进行散列映射,以实现快速的插入、查询和删除操作。
这种数据结构也被称为散列树、散列索引树或TrieHash等。
它通过对键值进行散列,将其映射到散列表的索引位置,
以实现快速的插入、查询和删除操作。
哈希树是哈希表的变种,它将链式哈希结构改为了树形结构,
同时保留了哈希表的快速查询特点。
哈希树使用的是内存分配策略,
使得哈希树的大小可以根据存储的数据动态变化,提高了内存利用率。
同时,哈希树在索引节点上附加了红黑树或B树等数据结构,
以保证排序、查询的效率。
哈希树在数据结构领域有广泛的应用,
例如在数据库、内存数据库、计算机网络等方面。
Trie树(又称字典树)是一种用于检索字符串的树形结构,
是一种哈希树的变种。Trie树以字符串为键,
它的每一个节点代表了一个字符串的一个前缀,
从根节点到某一个节点的路径所代表的字符串即为该节点的前缀。
Trie树的主要用途是检索字符串,
它可以快速的找出某个字符串是否存在于Trie树中,
同时也可以快速的找出某个前缀对应的所有字符串。
由于它具有高效的查询性能,因此在许多领域,
例如字符串匹配、字典树、自动补全等,都有广泛的应用。
《算法,第4版》
https://algs4.cs.princeton.edu/
[常用/常见]算法:
递归;
回溯算法;
贪心算法;
分治算法;
动态规划;
深度优先搜索;
按照难易程度排序的最简单的10个算法:
我们用算法需要执行的时间来衡量1个算法的复杂度,
表示为:T(n)。
n is the size of the input.
T(n) is the time it takes to run on inputs of size n.
It may take different times for different inputs of the same size n.
In general we mean the worst-case running time.
(Sometimes we’re interested in the average running time.)
We don’t mean exact running time (that depends on the implementation and machine)
but a measure of the number of elementary computation steps.
How to measure input and time?
Strictly speaking we should measure:
• input size by the number of bits the input takes in memory
• running time by the number of basic hardware operations executed
In complexity theory we’re usually more relaxed
• input size is often measured by memory locations, for example lists are measured by their lengths, trees by the number of nodes
• running time is measured by assuming that certain elementary operations (for example arithmetic, logical, pointer operations) take constant time (which is usually false!)
Important is not the exact running time, but the Complexity Class: the algorithms runs in linear, or quadratic, … or exponential time
The notation f (n) = O(g(n)) intuitively means that the function f (n) grows at most as g (n)
时间复杂度
时间复杂度是计算算法执行时间的一种度量方式。
它描述了算法随着输入数据规模的增长,算法的执行时间将如何变化。
通常,我们使用大 O 复杂度表示法来表示一个算法的时间复杂度,
例如,O(n) 表示该算法的时间复杂度随着输入数据规模 n 的增长而线性增长。
算法的时间复杂度对算法的效率有着重要的影响,
因此,选择合适的算法以及优化算法的实现是软件工程中非常重要的。
时间复杂度是衡量[代码/算法]好坏的重要指标,
代码功能实现了还不够,还要看时间复杂度是不是很高。
时间复杂度是描述算法执行时间的一个函数,
类似于一个耗时的趋势,并不代表具体值。
衡量[代码/算法]好坏的指标:
2段代码运行相同的功能需要时间和内存:
A代码运行一次需要10毫秒,内存5MB。
B代码运行一次需要1秒,内存500MB。
显然,B的代码要比A差的多。
虽然我们在算法运行前,
无法准确的估算出代码运行所花时间,
但我们可以估算出代码的执行次数
。
我们把算法需要执行的运算次数,用输入大小为n的函数来表示,即:T(n)。
大写的N,代表某个特定数值。
小写的n,代表函数的变量。
一般情况下,随着输入规模n的增大,T(n)增长最慢的算法为最优算法。
执行次数是常量的,T(n) = 2:
void do1(int n){
System.out.println("执行一次");
System.out.println("执行一次");
}
执行次数是线性的,一般包含非嵌套循环,随着问题规模n的扩大,对应执行次数呈直线增长,T(n) = 2n:
void do2(int n){
for(int i = 0 ; i < n ; i++){
System.out.println("执行一次");
System.out.println("执行一次");
}
}
执行次数是对数的(对数用log表示),以2为底,例如:log8 = 3,T(n) = 3logn:
void do3(int n){
for(int i = 1; i < n ; i *= 2){
System.out.println("执行一次");
System.out.println("执行一次");
System.out.println("执行一次");
}
}
执行次数是平方的,T(n) = n^2:
void do4(int n){
for(int i = 0; i < n; i++){
for(int j = 0; j < n; j++){
System.out.println("执行一次");
}
}
}
执行次数是一个多项式的,用到等差数列求和公式,T(n) = 0.5n^2 + 0.5n:
void do5(int n){ for(int i = 0; i < n; i++){ for(int j = 0; j < i; j++){ System.out.println("执行一次"); } System.out.println("执行一次"); } } void do6(int n){ /** * 当 i = 0 时,内循环执行 n 次运算,当 i = 1 时,内循环执行 n - 1 次运算……当 i = n - 1 时,内循环执行 1 次运算。 所以,执行次数 T(n) = n + (n - 1) + (n - 2)……+ 1 = n(n + 1) / 2 = n^2 / 2 + n / 2。 根据上文说的大O推导法可以知道,此时时间复杂度为 O(n^2)。 * **/ for(int i = 0; i < n; i++){ for(int j = i; j < n; j++){ System.out.println("执行一次"); } } }
假设n=3,则共执行6次:
等差数列求和公式为:S = n/2 * (a1 + an)。
数列头:1
数列尾:n
n/2 * (1 + n) = 0.5n + 0.5n^2
有了基本操作执行次数的函数T(n),
是否就可以分析和比较一段代码的运行时间了呢?
还是有一定的困难。
比如算法A的相对时间是T(n) = 100n,
算法B的相对时间是T(n) = 5n^2,
这两个到底谁的运行时间更长一些?
这就要看n的取值,随着n的变化,两个算法的执行次数会可能会发生逆转。
所以,这时候有了渐进时间复杂度(asymptotic time complectiy)的概念,官方的定义如下:
若存在函数f(n),使得当n趋近于无穷大时,
T(n) / f(n)的极限值为非零常数,
则称f(n)是T(n)的同数量级函数。
记作T(n) = O(f(n)),称O(f(n)),
为算法的渐进时间复杂度,简称时间复杂度。
渐进时间复杂度用大写O来表示,
所以也被称为大O表示法。
得到执行次数函数T(n)后,如何推导时间复杂度?
如果执行次数是常数量级,用1表示,因为常数对坐标轴中函数的曲线波动影响较小,如果函数中不仅存在常数,则把常数省略;
只保留时间函数中的最高阶项(最高次幂),因为低阶项对坐标轴中函数的曲线波动影响较小;
如果最高阶项存在,则省去最高阶项前面的系数,因为系数对坐标轴中函数的曲线波动影响较小;
例如:
分类:
时间复杂度 | 例子 | 描述 |
---|---|---|
O(1) | 12345 | 常数阶 |
O(logn) | 2logn+4 | 对数阶 |
O(n) | 2n + 8 | 线性阶 |
O(nlogn) | 3n + 4nlogn + 13 | 线性对数阶 |
O(n^2) | 3n^2 + 4n + 5 | 平方阶 |
O(n^3) | n^3 + 5n^2 + 8 | 立方阶 |
O(n^k) | n^15 | k方阶,一般控制k的大小,否则就和指数阶一样了 |
O(2^n) | 2^n + 8 | 指数阶,性能差 |
从[代码/算法]中推导出T(n)是困难的,
相当于我们需要拥有[把一堆代码抽象成一个数学函数]的能力。
一些推导规律:
我们查找一个有n个随机数字数组中的某个数字,
最好的情况是第一个数字就是,那么算法的时间复杂度为O(1),
但也有可能这个数字就在最后一个位置,那么时间复杂度为O(n)。
平均运行时间是期望的运行时间。
最坏运行时间是一种保证。在应用中,这是一种最重要的需求,
通常除非特别指定,我们提到的运行时间都是最坏情况的运行时间。
https://www.cnblogs.com/huangbw/p/7398418.html
https://blog.csdn.net/u012617661/article/details/80135019
https://blog.csdn.net/sustzc/article/details/80612758
https://blog.csdn.net/liao_hb/article/details/81029536
https://baike.baidu.com/item/%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6/1894057?fr=aladdin
https://blog.csdn.net/u013467442/article/details/53411257
未完待续
转自:https://www.jianshu.com/p/88a1c8ed6254
我们在写代码时,完全可以用空间来换去时间。
举个例子说,要判断某年是不是闰年,你可能会花一点心思来写一个算法,
每给一个年份,就可以通过这个算法计算得到是否闰年的结果。
另外一种方法是,事先建立一个有2050个元素的数组,
然后把所有的年份按下标的数字对应,如果是闰年,
则此数组元素的值是1,如果不是元素的值则为0。这样,
所谓的判断某一年是否为闰年就变成了查找这个数组某一个元素的值的问题。
第一种方法相比起第二种来说很明显非常节省空间,
但每一次查询都需要经过一系列的计算才能知道是否为闰年。
第二种方法虽然需要在内存里存储2050个元素的数组,
但是每次查询只需要一次索引判断即可。
这就是通过一笔空间上的开销来换取计算时间开销的小技巧。
到底哪一种方法好?其实还是要看你用在什么地方。
定义:
算法的空间复杂度通过计算算法所需的存储空间实现,
算法的空间复杂度的计算公式记作:S(n)=O(f(n)),
其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。
通常,我们都是用“时间复杂度”来指运行时间的需求,
是用“空间复杂度”指空间需求。
当直接要让我们求“复杂度”时,通常指的是时间复杂度。
显然对时间复杂度的追求更是属于算法的潮流!
在数据结构与算法中,
空间复杂度是指算法在执行过程中所需要的内存空间大小,
通常用大O表示法来描述。
空间复杂度与时间复杂度一样,也是衡量算法效率的重要指标之一。
一个算法的空间复杂度取决于它所需要的存储空间大小,
这个大小通常与输入数据规模有关。
在进行算法设计时,需要尽量优化空间复杂度,避免无谓的内存浪费。
在实际应用中,空间复杂度较高的算法可能会因为内存不足而无法运行,
因此空间复杂度也是算法设计中需要考虑的重要因素之一。
当你编写一个算法时,它需要占用计算机内存的一定量空间来存储数据结构和中间结果。这个算法所需要的空间大小,就是空间复杂度。举个例子,假设你需要编写一个算法来解决某个问题,你设计的算法需要使用一个大小为n的数组来存储中间结果,那么它的空间复杂度就是O(n)。如果你使用了递归,递归深度为k,每次递归需要开辟一个大小为m的数组,那么它的空间复杂度就是O(km)。另外,还有一些空间复杂度比较特殊的算法,例如原地排序算法(如快速排序和堆排序),它们只需要很少的额外空间,空间复杂度为O(1)。
未完待续
全排列问题是指对一个给定的序列(或者是集合),
求出该序列所有可能的排列方案。
比如说,对于一个序列 [1, 2, 3],
它的全排列有三个: [1, 2, 3]、[1, 3, 2] 和 [2, 1, 3]。
这个问题常见于算法设计和其他相关领域,
主要用于搜索和组合数学等领域。
“背包"指的是一类问题,通常称为"0/1背包"或"完全背包”。
它是一个典型的动态规划问题,也是计算机科学中一个常见的问题。
在背包问题中,有一个固定容量的背包,和一些物品,
每个物品有一个重量和一个价值。
问题是如何选择一些物品,
使得这些物品的重量总和不超过背包容量,并且总价值最大。
八皇后问题是一个经典的问题,是回溯算法的典型案例。
八皇后问题是指在8×8的国际象棋棋盘上放置八个皇后,
使得任意两个皇后都不在同一行、同一列或同一对角线上(如果皇后在中间的话,会有4条对角线)。
总共有92种不同的放置方案。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3x7sZm8X-1684831719318)(evernotecid://BCE3D193-8584-4CB1-94B3-46FF37A1AC6C/appyinxiangcom/12192613/ENResource/p4031)]
八皇后问题的解决方案是一种回溯算法,
它尝试在每一行都放置一个皇后,
并且检查每一行放置的皇后是否与前面放置的皇后冲突。
如果没有冲突,程序将继续执行下一行。
如果冲突,则回溯并在当前行放置另一个皇后,
直到找到可行的解决方案。
千万不要误会,这里的枚举与Java里的枚举完全不是一个东西。
在数据结构与算法中,枚举(Enumeration)是一种算法思想,
它通过遍历所有可能的情况来解决问题。
而在Java中,枚举(Enum)是一种特殊的数据类型,
它表示一组固定的常量值。
枚举,Enumeration,是一种常见的算法思想,
它通过遍历所有可能的情况来解决问题。
枚举通常用于在有限范围内搜索特定的解,
例如在密码破解中破解密码、在游戏中寻找最优策略等。
枚举的基本思路是通过循环遍历所有可能的情况来搜索解决方案。
例如,在密码破解中,可以使用一个循环来遍历所有可能的密码组合,
直到找到正确的密码为止。
尽管枚举算法通常可以找到解决方案,
但由于它必须遍历所有可能的情况,因此通常需要很长时间来执行。
此外,在搜索空间很大时,枚举可能不太实用。
因此,在实际应用中,枚举通常与其他算法一起使用,
以便在搜索空间较小时找到最优解决方案。
需要注意的是,枚举算法不适用于所有类型的问题。
对于某些问题,搜索空间可能太大,无法通过枚举来解决。
在这种情况下,需要使用其他算法来寻找最优解决方案。
枚举算法,Enumeration algorithm,是一种穷举的方法,
通过不断地枚举每一种情况来解决问题。
枚举算法通常在求解组合问题,搜索最优解等方面被广泛使用。
枚举算法需要耗费大量的时间和计算资源,
因此在实际应用中需要注意问题的规模。
枚举法(Brute Force)是指将所有可能的情况枚举出来,
再判断是否满足条件,如果满足条件则记录下答案。
这是一种简单粗暴的解决问题的方法,最简单的例子就是穷举法,
例如对于一个数的n次方的答案,将每一位的n次方暴力穷举。
这种方法的复杂度很高,但对于一些简单的问题,
有时也是一种不错的解决方法。
穷举法和枚举法是同一个概念,
表示通过试错、枚举来寻找某种问题的解决方案。
递归
在计算机科学中,递归指的是一种解决问题的方法,
它把一个问题分解为规模更小但本质上与原问题相同的子问题,
逐个解决子问题,最后将它们合并成原问题的解。
递归需要满足两个条件:
是的,递归是一种常见的编程技巧,递归调用自身的函数时需要设置停止条件,否则会导致无限递归,最终耗尽系统的栈空间,引发栈溢出错误。需要注意递归调用的层数不要过深,否则也会出现类似的问题。
递归可以用来解决许多问题:
树的遍历;
例如:输入一个整数n,输出n的阶乘:
public class Factorial { public static int factorial(int n) { if (n == 0 || n == 1) { // 基准情况 return 1; } else { // 递推公式 return n * factorial(n - 1); } } public static void main(String[] args) { int n = 5; System.out.println(factorial(n)); } }
[贪心算法]是一种在每一步选择中都选择当前最优(最优解)的算法,
并且假设在未来的步骤中能够维持最优性质,以达到全局最优解的算法。
它的目的是通过局部最优解得到全局最优解。
下面的代码演示了一个简单的[背包问题],它有三个物品,
每个物品都有2个属性:重量、价值。
最大价值是在所有物品中选择价值/重量比最大的物品,
直到背包装满为止。
代码中的贪心策略是将所有物品按价值/重量比排序,
然后从最优的物品开始装入背包。
import java.util.ArrayList; import java.util.Comparator; import java.util.List; public class Knapsack { static class Item { int weight; int value; Item(int weight, int value) { this.weight = weight; this.value = value; } } static int getMaxValue(int capacity, List<Item> items) { items.sort(Comparator.comparingDouble(a -> (double) a.value / a.weight)); int currentWeight = 0; int currentValue = 0; for (Item item : items) { if (currentWeight + item.weight <= capacity) { currentWeight += item.weight; currentValue += item.value; } else { int remain = capacity - currentWeight; currentValue += item.value * ((double) remain / item.weight); break; } } return currentValue; } public static void main(String[] args) { List<Item> items = new ArrayList<>(); items.add(new Item(10, 60)); items.add(new Item(20, 100)); items.add(new Item(30, 120)); int capacity = 50; System.out.println(getMaxValue(capacity, items)); } }
滑动窗口,Sliding Window,是一种常用的算法技巧,
用于解决字符串或数组相关的问题,基于双指针。
它通过维护一个窗口(一般是连续的子数组或子字符串),
在数据结构中进行滑动并进行相应的操作。
滑动窗口算法通常用于解决满足一定条件的连续子序列或子数组的问题,
例如求解最小/最大值、求解和等。
它的基本思想是通过滑动窗口在数据结构中移动,
同时根据问题的要求进行窗口的扩展或收缩,
并在每次移动后更新窗口内的状态。
以下是滑动窗口算法的基本步骤:
通过不断移动窗口,滑动窗口算法能够在O(n)的时间复杂度内解决很多子序列或子数组相关的问题,相比于暴力求解的方法具有更高的效率。
需要注意的是,滑动窗口算法并不是适用于所有问题,而是针对一类特定的问题设计的算法技巧。
在使用滑动窗口算法解决问题时,
需要根据具体的问题进行窗口的扩展和收缩操作,并确保窗口的移动规则正确和高效。
public class SlidingWindowExample { public static int maxSubarraySum(int[] nums) { int windowStart = 0; // 窗口起始位置 int windowSum = 0; // 窗口内元素的和 int maxSum = Integer.MIN_VALUE; // 最大子数组和 for (int windowEnd = 0; windowEnd < nums.length; windowEnd++) { // 扩展窗口:将当前元素添加到窗口内 windowSum += nums[windowEnd]; // 如果窗口内元素的和大于最大子数组和,则更新最大子数组和 if (windowSum > maxSum) { maxSum = windowSum; } // 收缩窗口:如果窗口内元素的和为负数,则从窗口起始位置开始收缩窗口 if (windowSum < 0) { windowSum = 0; windowStart++; } } return maxSum; } public static void main(String[] args) { int[] nums = {2, -5, 3, 1, -2, 4, -3}; int maxSum = maxSubarraySum(nums); System.out.println("最大子数组和为: " + maxSum); } }
定义:
可以用于对树的每一层(横向)进行操作
;两种算法的主要区别在于遍历的顺序。
DFS每次遍历都是尽可能深入,
直到到达叶子节点,再回溯到上一层继续搜索,
而BFS是从起点开始,按层遍历图中的所有节点。
以下是一个简单的例子,展示了深度优先搜索算法是如何遍历一个无向图的。
0
/ \
1 - 2
\ /
3
在上图中,数字表示节点的编号,线表示节点之间的边。
我们从节点0开始进行深度优先搜索遍历,
遍历过程中使用[栈]来保存待访问
的节点,
初始时将起始节点0放入[栈]中:
Stack: [0]
Visited: [0]
然后从栈顶取出节点0,访问它的邻居节点1和2,并将它们压入栈顶:
Stack: [1, 2]
Visited: [0, 1, 2]
接下来从栈顶取出节点2,访问它的邻居节点1和3,但由于节点1已经被访问过了,因此不再重复访问。将邻居节点3压入栈顶:
Stack: [1, 3]
Visited: [0, 1, 2, 3]
接下来从栈顶取出节点3,它没有任何邻居节点,因此将从栈中弹出它,接下来回溯到上一个节点1,访问它的邻居节点0和2,但由于它们都已经被访问过了,因此不再重复访问:
Stack: [1]
Visited: [0, 1, 2, 3]
最后从栈顶取出节点1,它没有其他邻居节点,因此遍历结束。
可以看出,深度优先搜索算法按照深度优先的方式遍历图,
最终遍历了整个图,并输出了遍历顺序。
以下是一个简单的例子,展示了广度优先搜索算法是如何遍历一个无向图的。
0
/ \
1 - 2
\ /
3
在上图中,数字表示节点的编号,线表示节点之间的边。
我们从节点 0 开始进行广度优先搜索遍历,
遍历顺序应该为 0、1、2、3。
遍历过程中使用[队列]来保存待访问的节点,
初始时将起始节点 0 放入[队列]中:
Queue: [0]
Visited: [0]
然后从队列头部取出节点 0,
访问它的邻居节点 1 和 2,并将它们加入[队列]尾部:
Queue: [1, 2]
Visited: [0, 1, 2]
接下来从队列头部取出节点 1,访问它的邻居节点 0 和 3,
但由于节点 0 已经被访问过了,因此不再重复访问。
将邻居节点 3 加入队列尾部:
Queue: [2, 3]
Visited: [0, 1, 2, 3]
现在从队列头部取出节点 2,访问它的邻居节点 0 和 3,
但由于它们都已经被访问过了,因此不再重复访问:
Queue: [3]
Visited: [0, 1, 2, 3]
最后从队列头部取出节点 3,它没有任何邻居节点,因此遍历结束。
可以看出,广度优先搜索算法按照与起始节点的距离逐层扩展,
最终遍历了整个图,并输出了遍历顺序。
分治算法,Divide and conquer algorithm,
是一种利用分治的思想,将一个大问题分成两个或更多的子问题,
再把子问题分成更小的问题,直到最后子问题可以简单的直接求解,
原问题的解即子问题的解的合并。
分治算法通常用于处理大规模问题,是一种很有效的算法。
常见的例子有:快速排序
,归并排序,汉诺塔问题等。
以下是一个使用分治算法实现快速排序,
其中利用分治思想将数组划分为两个子数组,
然后对子数组进行递归排序。
快速排序的核心思想是选取一个枢纽元素,
将数组划分为两个子数组,
使得左子数组中的所有元素都小于等于枢纽元素,
右子数组中的所有元素都大于等于枢纽元素。
然后对左右子数组分别进行递归排序,最终得到排序好的数组。
public class QuickSort { public static void quickSort(int[] arr, int left, int right) { if (left < right) { // 划分子数组 int partitionIndex = partition(arr, left, right); // 递归排序左子数组 quickSort(arr, left, partitionIndex - 1); // 递归排序右子数组 quickSort(arr, partitionIndex + 1, right); } } private static int partition(int[] arr, int left, int right) { // 选取中间元素为枢纽元素 int pivot = arr[left + (right - left) / 2]; int i = left; int j = right; // 对数组进行划分 while (i <= j) { while (arr[i] < pivot) { i++; } while (arr[j] > pivot) { j--; } if (i <= j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; i++; j--; } } // 返回枢纽元素的下标 return i; } public static void main(String[] args) { int[] arr = {5, 2, 9, 1, 5, 6}; quickSort(arr, 0, arr.length - 1); System.out.println(Arrays.toString(arr)); } }
回溯算法,Backtracking Algorithm,是一种分治算法,
用于在有限的搜索空间内寻找所有可行的解。
它是一种通用的解决组合优化问题的算法,
可以适用于找出所有的可能的解,或者在找到第一个合法解时终止。
回溯算法的基本思想是:
在当前层次选择一个可行的解,并在下一层继续试探下一个可行解。
如果在下一层无法找到任何合法解,则回溯到上一层,
选择另一个可行解继续试探。
回溯算法是一种递归的算法,用于求解在一组可能的解中找出所有符合条件的解的问题。
在八皇后问题中,回溯算法的基本思路是从第一行开始尝试放置皇后,
当某一行不能放置皇后时,回溯到上一行重新尝试,
直到找到所有符合条件的解或者所有可能的解都已经被尝试完毕。
使用回溯算法求解八皇后问题的时间复杂度为 O(n!),
其中 n 是皇后的数量。因为在最坏情况下,需要枚举所有的可能性,
而八皇后问题的解空间大小为 8! = 40320,因此时间复杂度非常高。
这个过程在直到找到最终的合法解,或者所有的搜索空间都已被搜索过为止。
回溯算法常常用于解决NP问题(如N-皇后问题,八数码问题等),
它是一种高效的算法,其时间复杂度取决于找到合法解的数量。
https://mp.weixin.qq.com/s/puk7IAZkSe6FCkZnt0jnSA
递归回溯,本质上是一种[枚举法]。
这种方法从棋盘的第一行开始尝试摆放第一个皇后,摆放成功后,递归一层,再遵循规则在棋盘第二行来摆放第二个皇后。
如果当前位置无法摆放,则向右移动一格再次尝试,
如果摆放成功,则继续递归一层,摆放第三个皇后…
如果某一层看遍了所有格子,都无法成功摆放,则回溯到上一个皇后,
让上一个皇后右移一格,再进行递归。
如果八个皇后都摆放完毕且符合规则,那么就得到了其中一种正确的解法。
public class TempClass { public static void main(String[] args) { QueenProblem.backtrack(0); System.out.println("共有" + QueenProblem.count + "种合法的放置方案。"); } } class QueenProblem { static final int N = 8; static int[] x = new int[N]; // x[i]表示第i行皇后的放置列 // x数组用来记录每一行皇后的位置,比如x[3]=5表示第3行的皇后在第5列。 static int count = 0; // 记录满足条件的方案数 // 检查当前位置是否满足条件,一组操作时,每次进来row是固定的 static boolean check(int row, int column) { System.out.println("执行check,row="+row+",column="+column); for (int i = 0; i < row; i++) { System.out.println("仔细检查第"+i+"行,第"+column+"列。"); // 检查行冲突,每一行的列,只要是跟被占的列冲突就不行。 if (x[i] == column) { return false; } // 检查对角线冲突,要查询的行与已被占的行间距 = 要查询的列与已被占的列间距,意味着是斜线,可以自己画个图。 if (Math.abs(x[i] - column) == Math.abs(i - row)) { return false; } } return true; } // 递归搜索所有的放置方案,如果找到了一种合法的方案就把计数器加一。 static void backtrack(int row) { System.out.println("执行backtrack,row="+row); // 已经搜索到最后一行,说明已经找到一种合法的方案 if (row == N) { count++; return; } for (int i = 0; i < N; i++) { // 循环检查每个列,看当前行的哪个列符合标准 if (check(row, i)) { x[row] = i; // 把当前皇后放在当前行的第i列 backtrack(row + 1); // 回溯 x[row] = 0; } } } }
倍增算法,Double Dividing Algorithm,
是一种分治算法的变形。
倍增算法通过不断地将问题的规模减半来解决问题,
从而提高算法的效率。
倍增算法常用于解决有关序列、树等数据结构的问题,
特别是处理有关序列上的查找、搜索问题时,
其时间复杂度通常为 O(log n),非常高效。
动态规划
动态规划问题一直是大厂面试时最频繁出现的算法题,
主要原因在于此类问题灵活度高,思维难度大,没有很明显的套路做法。
动态规划,Dynamic Programming,
是一种在数学、计算机科学、经济学等领域中应用的优化方法。
它是一种分阶段求解决策问题的数学思想,
通过将问题分解为若干个子问题,
再通过子问题的解推导出问题的解(大事化小,小事化了),
从而避免重复计算,提高算法的效率。
将一个复杂的问题分阶段进行简化,逐步转化成简单问题。
它通常用于解决最优化问题,即在满足某些限制条件下,找到最优解的问题。
动态规划将问题分解成若干个阶段,每个阶段需要做出一次决策,
并基于前面的决策结果,推导出当前阶段的最优解。
通过对每个阶段的最优解进行存储,
动态规划能够避免重复计算,提高计算效率。
动态规划通常需要满足以下3个条件:
动态规划算法通常包含两个主要步骤:状态设计和状态转移方程。
状态设计定义了问题的状态,即在每个阶段需要计算和存储的信息。
状态转移方程则定义了如何从前一个状态推导出当前状态的信息。
通常,状态转移方程可以通过对问题的最优子结构性质进行推导得出,
也可以通过暴力枚举法推导得出,并通过存储中间结果避免重复计算。
动态规划算法中,递归的函数就像是《盗梦空间》中的"梦中梦",
一层套一层,又渐次展开,很难整体把控。
动态规划算法的重点是抓住[状态转移公式/状态转移方程],
只处理两个状态之间的过渡和[边界条件],慢慢"大事化小,小事化了";
https://mp.weixin.qq.com/s/3h9iqU4rdH3EIy5m6AzXsg
https://mp.weixin.qq.com/s/RqkrgzQsulFc-PHmB3znmA
https://mp.weixin.qq.com/s/YeebGBcc5dv2fRYQacgCuQ
https://mp.weixin.qq.com/s/ssLawuRYafAGd9hVyuUJIg
例如,在[爬楼梯]问题中,
我们可以通过把最后一步拆分成两种情况来依次逐渐拆分子步骤。
问题建模:
F(1) = 1;
F(2) = 2;
F(n) = F(n-1) + F(n-2)(n>=3)
三个重要概念:
F(10) = F(9) + F(8),则F(9)和F(8)是F(10)的[最优子结构];
F(1)和F(2)是问题的[边界],可以直接得出结果,无需再进行简化,如果一个问题没有边界,将永远无法得到有限的结果;
F(n) = F(n-1) + F(n-2)是阶段与阶段之间的[状态转移公式/状态转移方程],它决定了问题的当前阶段与下一阶段的关系;
求解问题:
方法1,递归求解,时间复杂度为O(2^N):
int getClimbingWays(int n){
if(n < 1){
return 0;
}
if(n == 1 || n == 2){
return n;
}
return getClimbingWays(n-1) + getClimbingWays(n-2);
}
方法2,备忘录算法,时间复杂度为O(N),空间复杂度为O(N):
方法1中存在大量重复计算的工作量,为了优化步骤,
我们可以使用缓存,先创建一个哈希表,
每次把不同参数的计算结果存入哈希,当遇到相同参数时,
再从哈希表中取出,从而避免重复计算。
int getClimbingWays(int n, HashMap<Integer, Integer> map){ if(n < 1){ return 0; } if(n == 1 || n == 2){ return n; } if(map.containsKey(n)){ return map.get(n); } else { int value = getClimbingWays(n-1, map) + getClimbingWays(n-2, map); map.put(n, value); return value; } }
方法3,动态规划求解,时间复杂度为O(N),空间复杂度为O(1),
逆向思维,从下往上,不去递归,用循环代替:
int getClimbingWays(int n){ if(n < 1){ return 0; } if(n == 1 || n == 2){ return n; } int a = 1; int b = 2; int temp = 0; for(int i = 3; i <= n; i++){ temp = a + b; a = b; b = temp; } return temp; }
共10名工人,
200金/3人,
300金/4人,
350金/3人,
400金/5人,
500金/5人。
建模,能根据题干推导出公式是灵魂所在:
最优子结构,第5个金矿存在挖与不挖两种选择:
设:
状态转移公式:
F(5, 10) = MAX(F(4, 10), F(4, 10-P[4]) + G[4]);
边界,当只剩下一座金矿时,如果工人总数够或不够:
[状态转移公式/状态转移方程]:
F(N,W) = 0 (n<=1, w<p[0]);
F(N,W) = G[0] (n==1, w>=p[0]);
F(N,W) = F(N-1,W) (n>1, w<p[n-1]);(有疑问)
F(N,W) = MAX(F(n-1,w), F(n-1,w-p[n-1])+g[n-1]) (n>1, w>=p[n-1]);
方法1,排列组合,时间复杂度为O(2^N):
每一座金矿都有挖与不挖两种选择,如果有N座金矿,
排列组合起来就有2^N种选择。
对所有可能性做遍历,排除那些使用工人数超过10的选择,
在剩下的选择里找出获得金币数最多的选择。
写法略。
方法2,简单递归:
把状态转移方程式翻译成递归程序,
递归的结束的条件就是方程式当中的边界。
因为每个状态有两个最优子结构,
所以递归的执行流程类似于一颗高度为N的二叉树。
方法的时间复杂度是O(2^N)。
方法3:备忘录算法
在简单递归的基础上增加一个HashMap备忘录,用来存储中间结果。HashMap的Key是一个包含金矿数N和工人数W的对象,
Value是最优选择获得的黄金数。
方法的时间复杂度和空间复杂度相同,都等同于备忘录中不同Key的数量。
方法4:动态规划
未完待续
共10名工人,
第1座矿:400金/5人,
第2座矿:500金/5人,
200金/3人,
300金/4人,
350金/3人,
表格第一列为N的取值,第一行为W的取值,其余部分为N和W对应的黄金获得数F(N,W):
1个人 | 2个人 | 3个人 | 4个人 | 5个人 | 6个人 | 7个人 | 8个人 | 9个人 | 10个人 | |
---|---|---|---|---|---|---|---|---|---|---|
1个矿 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
2个矿 | 0 | 0 | 0 | 0 | ||||||
3个矿 | ||||||||||
4个矿 | ||||||||||
5个矿 |
共10名工人,
第1座矿:400金/5人,
第2座矿:500金/5人,
只有1个矿时,第一座矿是400金/5人,因此少于5个人都没有收益,所以第1行的前几列都是0,而由于只有1个矿,来100个人也是400金收益,因此后面都是400,这个是问题的边界;
只有2个矿时,第二座矿是500金/5人,前面4格计算方式:W<5,F(N,W) = F(N-1,W) = 0;
金矿总数量:N;
工人总数量:W;
黄金量:G[];
用工量:P[];
F(N,W) = 0 (n<=1, w<p[0]);
F(N,W) = G[0] (n==1, w>=p[0]);
F(N,W) = F(N-1,W) (n>1, w<p[n-1]);(有疑问)
F(N,W) = MAX(F(n-1,w), F(n-1,w-p[n-1])+g[n-1]) (n>1, w>=p[n-1]);
我的思路,跟那个背包问题类似,先算出每个金矿的性价比,
再排序出高低,再把高的拿出来,
然后减少人数,等人数到0时结束。时间复杂度是O(N);
未完待续;
最长子序列?
计数问题?
[查找/搜索]
在数据结构与算法中,最简单的搜索算法是线性搜索(Linear Search),也被称为顺序搜索(Sequential Search)或遍历搜索(Traversal Search)。线性搜索算法的时间复杂度是O(n),其中n是要搜索的元素数量。
线性搜索算法通过遍历数组中的每个元素来查找目标元素。具体来说,它从数组的第一个元素开始,一直遍历到数组的最后一个元素,逐一比较每个元素是否与目标元素相等,如果找到目标元素则返回其位置,否则返回不存在。
虽然线性搜索算法的实现非常简单,但是当要搜索的元素数量很大时,它的时间复杂度会变得很高,效率不高。在实际应用中,通常会采用更加高效的搜索算法,如二分搜索、哈希表等。这些算法在特定条件下可以更快地找到目标元素。
[二分算法/二分查找/二分搜索/折半查找]
[二分算法/二分查找/二分搜索/折半查找],Binary Search algorithm,
是一种在有序数组或列表中查找某一特定元素的搜索算法。
该算法通过不断地将查询范围缩小一半来确定目标元素的位置,
从而降低查找时间的复杂度。
它的基本思想是:
二分算法的时间复杂度是O(log n),
它比线性查找的时间复杂度O(n)更优秀。
特点:
public class BinarySearch { public static int binarySearch(int[] arr, int target) { // 左边界 int left = 0; // 右边界 int right = arr.length - 1; while (left <= right) { int mid = (left + right) / 2; if (arr[mid] == target) { return mid; } else if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; } public static void main(String[] args) { int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9}; int target = 5; int result = binarySearch(arr, target); if (result == -1) { System.out.println("Element not present"); } else { System.out.println("Element found at index " + result); } } }
用计算机程序输出斐波那契數列的前N个数是一个非常简单的问题,
许多初学者都可以轻易写出如下函数:
def fab(max):
n, a, b = 0, 0, 1
while n < max:
print b
a, b = b, a + b
n = n + 1
fab(5)
稠密索引
分块索引
倒排索引
排序算法
Bubble Sort,基于比较的排序算法,时间复杂度均为O(n^2)。
冒泡排序通过不断交换相邻的元素来进行排序。
具体来说,它从左往右遍历数组,比较相邻两个元素的大小,
如果前一个元素大于后一个元素,则交换它们的位置。
不断进行这个比较和交换的过程,直到数组完全有序。
public static String sortArray(String params){ int temp ; String[] args = params.split(","); String result = ""; for(int x = 0;x <args.length;x++){ for(int y = x;y <args.length-1;y++){ int arg1 = Integer.parseInt(args[x]); int arg2 = Integer.parseInt(args[y+1]); if(arg2<arg1){ temp = arg2; arg2 = arg1; arg1 = temp; } args[x] = arg1+""; args[y+1] = arg2+""; } if(x==args.length-1){ result += args[x]; }else{ result += args[x]+","; } } return result; } 第二种for循环方式 int s = 0; // int[] a = new int[]{3,54,12,412,42}; // for(int i = a.length - 1;i>=1;i--){ // for(int j = 0;j<i;j++){ // if(a[j] > a[j+1]){ // s = a[j+1]; // a[j+1] = a[j]; // a[j] = s; // } // } // }
Selection Sort,基于比较的排序算法,时间复杂度均为O(n^2)。
选择排序通过不断选择数组中最小的元素来进行排序。
具体来说,它从左往右遍历数组,找到最小的元素,
然后将它与数组的第一个元素交换位置。
接着在剩余的未排序部分中继续寻找最小的元素,
并将它与已排序部分的后一个元素交换位置。
不断进行这个选择和交换的过程,直到数组完全有序。
快速排序
非常非常非常重要的知识点,
快速排序(Quicksort)是一种常见的基于比较的排序算法,
它是一种分治算法,通过不断地把待排序的序列分割成两个子序列,
然后对这些子序列进行递归排序,
并将小于[基准值]的元素移动到序列的左边,
大于[基准值]的元素移动到序列的右边,
最终使得整个序列有序。
在实际实现中,快速排序通常使用in-place排序方法,
即将待排序的序列直接在原数组上进行操作,避免了空间的浪费。
快速排序的时间复杂度为
O
(
n
log
n
)
O(n \log n)
O(nlogn),其中n是待排序序列的长度。
在最坏情况下,即待排序序列本身就是有序或接近有序的情况下,
快速排序的时间复杂度退化为
O
(
n
2
)
O(n^2)
O(n2)。
因此,在实际应用中,需要对快速排序进行优化,
例如随机选择基准元素、使用三数取中法选择基准元素等。
具体来说,快速排序的过程可以概括为以下3个步骤:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EfnFpy11-1684831719319)(evernotecid://BCE3D193-8584-4CB1-94B3-46FF37A1AC6C/appyinxiangcom/12192613/ENResource/p4047)]
特点:
[快速排序]和[二分查找]都是常见的算法,它们有以下相同之处:
public class TempClass { public static void main(String[] args) { int[] nums = {5, 3, 6, 2, 1, 4}; quickSort(nums, 0, nums.length - 1); System.out.println(Arrays.toString(nums)); } public static void quickSort(int[] arr, int left, int right) { int a = (int)(Math.random() * 9 + 1) * 100000; System.out.println(a); if (left < right) { System.out.println(a); int pivot = partition(arr, left, right); System.out.println(a); quickSort(arr, left, pivot - 1); System.out.println(a); quickSort(arr, pivot + 1, right); System.out.println(a); } } /** * partition方法来划分序列,它采用了双指针的思想, * 先选取左边第一个元素作为基准值,然后使用 i 和 j 两个指针分别从左往右和从右往左扫描序列, * 找到需要交换位置的元素,最终将基准值放到正确的位置上。 */ private static int partition(int[] arr, int left, int right) { int pivot = arr[left]; // 选取左边第一个元素作为基准值 int i = left + 1, j = right; while (i <= j) { if (arr[i] <= pivot) { // 左边元素小于等于pivot,i右移 i++; } else if (arr[j] > pivot) { // 右边元素大于pivot,j左移 j--; } else if (i < j) { // 左边元素大于pivot,右边元素小于等于pivot,交换i和j位置的元素 swap(arr, i, j); // 交换两个元素的位置,因为既不能往左移,也不能往右移,说明2个位置是矛盾的,应该互相调转枪头 } } swap(arr, left, j); // 将pivot与j互换,因为此时j+1是大于pivot的,这样的交换是符合顺序的 return j; } private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } }
未完待续
跳舞链是一种链表排序算法,在解决大量数据的排序问题时,
它是一种有效的方法。
跳舞链是通过不断地在链表上跳跃来排序元素的。
跳舞链是对链表排序算法的优化,
它通过使用指针和移动链表中的元素来实现排序。
这个算法的特殊之处在于,它使用了两个指针,一个指针指向链表的头,
另一个指针指向链表的尾。
两个指针不断地交替移动,并在每次移动后比较两个元素的大小。
如果元素不在正确的位置,它们将被交换。
算法继续重复这个过程,直到所有元素都在正确的位置。
跳舞链是一种高效的排序算法,因为它在一次遍历中完成了排序。
这种算法在排序大量数据时特别有效。
刷题!大家都重视到算法刷题对冲击大厂的重要性!
个人建议每天至少刷3道题。
刷题技巧
刷题之前,我们需要知道怎么去刷,按什么顺序刷题,应该根据需求来:
如果是为了拿大厂offer,那么在力扣上刷够大概200道经典题, 注意不是重复刷简单题,是分门别类的刷,可以先刷《剑指Offer》
https://leetcode.cn/problem-list/xb9nqhhg/,再刷《热题100》https://leetcode.cn/problem-list/2cktkvj/,最后刷《前200题》https://leetcode.cn/problemset/all/;如果你有数据结构与算法基础,比如考研或者平时数据结构学的还不错,
常见数据结构与算法原理明白能够实现部分,又或者有部分刷题经验,
那么我推荐你直接顺序着刷就完了。
如果你是真的小白,那你就要为自己手动找到一条可行走的路,
那我推荐你可以按照一些专题去各个击破。
因为你是小白如果顺序刷这个题不会,学了,再刷下一题,
又学了个完全陌生的新东西,短期内学习太多比较陌生的新东西很难吸收,
很容易忘,就会陷入怎么都学不会的苦恼中。
所以你可以把刷题当成一个台阶,一层一层往上爬,
刚开始找easy简单那种a+b类型的题,
后面慢慢增加难度,
对于数据结构方面的题,
从链表开始先学透单链表、双链表、循环链表各种插入删除实现,
然后在题库中找链表相关题进行逐个攻破(链表中的也可细分链表插入、删除、反转、合并、查找、排序等等),
链表大专题之后二叉树大专题、哈希表专题等。
这样你短期内学习某一个数据结构或者算法技巧,
多去刷题巩固吸收效果比较好!
在这种情况切勿觉得简单就草草下一个,你不敲代码,
可能不会知道自己会出现什么问题。
新手也可以按此顺序:
数组
;链表
;哈希表
;栈与队列
;字符串
;实战练习非常重要,不经过实战练习,理论仅仅是纸上谈兵。
比如,不经过大量练习:
程序员说到底也是手艺人,这一个字符的改动,
正是 “台上一分钟,台下十年功” 的体现,
需要在大量的练习中才能理解两者之间的不同作用。
对于初学者:
自己给自己设置时间限制,不要让自己一直思考下去,如果遇到一个题目 40 分钟还没有想出来解题思路,建议先放弃。
等到后面熟悉了,大脑形成记忆了再去解题。就好比乘法口诀表,熟悉之后才能运用到各个场景不是嘛;有很多题存在固定的通用的模版,背下模版可以节省大量时间,例如:双指针,BFS,DFS等等;
为什么见到一个题没思路?
简单题很容易懂实现起来很难?
看了很多题解为啥还是不会刷题?
可以看题解,看了自己要能完全写出来才行
,如果刷了1000+题,你看了题解不刷没问题,看个思路过了被卡的地方就行。但如果刷了 100 不到,那你看懂题解后还是老老实实按照别人的逻辑闭卷式的复现一遍。拿到一道题的处理流程:
首先可以确定下题型大类型,是图论?二叉树?字符串?然后要拿这个类型的题目往这个范畴常见算法上靠。
比如给个数组数据让你查找计算,有可能是双指针,有可能是哈希,有可能还是位运算,还可能是动态规划,还可能是要贪心处理。不过大部分题是在各个经典算法的经典问题上进行一些变化,要知道经典算法处理了哪些经典问题。如果能确定考察点,可以想想细节开始实现;如果确定不了考察点,没思路,先别直接看题解,看看题目"标签"的提示。
有时你看一个题可能说:这题啥方法啊我只会暴搜,有的确实就是搜索剪枝;除了标签,还要看"数据范围"!数据范围内的数据都是可能出现的,不同数据范围可能使用方法不同(这点数组题较多,有些题巧用哈希、原地置换对数据有要求)。如果自己看了标签想想来灵感那最好,如果还是没有灵感,那点一下题解。可以从题解标题中看看能不能有灵感,有不少题解会给足够多的暗示有些人看到就能明白了。如果还是不会那就老老实实点进去看看别人的思路,有的是视频,有的是图文,看懂为止,要是还自己看不懂,那就请教一下别人吧!
有的算法题逻辑想不明白,把代码加断点,加输出打印,一步一步走,用笔用纸,一步一步画,最好用铅笔橡皮,实在想不通的地方,就先pass掉,把被卡住的地方记录下来,去问更厉害的人,不要在一道题上卡太长时间,一道题的时间大概为40分钟;
编写代码、测试:
编写代码的过程不要有任何参考!编写代码的过程不要有任何参考!重要的话说两遍,思路可以看,别人的代码也可以看,你自己写代码不要参考和 ctrl c + ctrl v,工程项目能跑起来就行,为了效率都是 cv 大法,但是面试笔试题基本要你闭卷,有的还要你用在线IDE连提示都不全的;
方法、结果对比:
不要以为ac了就OK了,你要看看自己时间上超越了多少人,
推荐从这两个维度来衡量自己的代码:要超越70%以上的人(根据自己要求适当提高):大部分题超越70%说明你的方法上是没问题的,可能有些小的方面可以进行优化。比如StringBuilder替代String进行字符串拼接,使用char[]数组替代String进行遍历枚举等等;巩固提高:
其中,暴力枚举、贪心算法容易理解,可以很快上手。
数论相关的算法需要用到一些数学技巧,
包括位运算、幂函数、求模等等性质。
二分算法和深度优先搜索算法相对有些技巧性,好在他们都有固定的模板。
另外,不得不提的是,[深度优先搜索算法]的思想非常重要,
而且深度优先搜索是动态规划、分治和回溯的基础,需要重点掌握。
先快速识别出题干的主要数据结构,再相应对策略。
综合:
如果求[最小值/最大值],先为这个值创建变量,再通过[Math.max()/Max.min()]来解决问题,可用于数组,树
遍历:
递归:
数组:
链表:
哈希表:
反复检查某个容器中是否存在某个值需要使用HashSet,需要O(1)的时间,而对于其他数据结构,则需要O(n)的时间。选择正确的数据结构是解决这些问题的关键部分;
树:
其实你可以把它们背下来的。
双指针
双指针是一种常见的算法技巧,在处理[数组/链表]问题时特别有用,
使用双指针技巧可以大大降低时间复杂度。
特点:
一些常见的使用场景包括:
需要注意的是,使用双指针并不一定能解决所有问题,
有时可能需要结合其他算法技巧进行处理。
只有一个输入, 从两端开始遍历:
public int fn(int[] arr) { int left = 0; int right = arr.length - 1; int ans = 0; while (left < right) { // 一些根据 letf 和 right 相关的代码补充 if (CONDITION) { left++; } else { right--; } } return ans; }
有两个输入, 两个都需要遍历完:
public int fn(int[] arr1, int[] arr2) { int i = 0, j = 0, ans = 0; while (i < arr1.length && j < arr2.length) { // 根据题意补充代码 if (CONDITION) { i++; } else { j++; } } while (i < arr1.length) { // 根据题意补充代码 i++; } while (j < arr2.length) { // 根据题意补充代码 j++; } return ans; }
普通:
public int fn(int[] arr, int target) { int left = 0; int right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { // 根据题意补充代码 return mid; } if (arr[mid] > target) { right = mid - 1; } else { left = mid + 1; } } // left 是插入点 return left; }
对树的每一层进行操作,主要用队列的size来遍历属于相同层的节点。
二叉树:
public int fn(TreeNode root) { Queue<TreeNode> queue = new LinkedList<>(); queue.add(root); int ans = 0; while (!queue.isEmpty()) { int currentLength = queue.size(); // 做一些当前层的操作 for (int i = 0; i < currentLength; i++) { TreeNode node = queue.remove(); // 根据题意补充代码 if (node.left != null) { queue.add(node.left); } if (node.right != null) { queue.add(node.right); } } } return ans; }
二叉树(迭代):
public int dfs(TreeNode root) { Stack<TreeNode> stack = new Stack<>(); stack.push(root); int ans = 0; while (!stack.empty()) { TreeNode node = stack.pop(); // 根据题意补充代码 if (node.left != null) { stack.push(node.left); } if (node.right != null) { stack.push(node.right); } } return ans; }
二叉树(递归):
public int dfs(TreeNode root) {
if (root == null) {
return 0;
}
int ans = 0;
// 根据题意补充代码
dfs(root.left);
dfs(root.right);
return ans;
}
如果题干中出现二叉搜索树,那我们可以快速得知一些套路,
我们可以快速地找出树中的某个节点以及从根节点到该节点的路径,
假设我们需要找到节点x,我们从根节点开始遍历,
如果当前节点就是x,那么成功地找到了节点。
如果当前节点的值大于x的值,说明x应该在当前节点的左子树,
因此将当前节点移动到它的左子节点。
如果当前节点的值小于x的值,说明x应该在当前节点的右子树,
因此将当前节点移动到它的右子节点。
在寻找节点的过程中,我们可以顺便记录经过的节点,
这样就得到了从根节点到被寻找节点的路径。
public List<TreeNode> getPath(TreeNode root, TreeNode target) {
List<TreeNode> path = new ArrayList<TreeNode>();
TreeNode node = root;
while (node != target) {
path.add(node);
if (target.val < node.val) {
node = node.left;
} else {
node = node.right;
}
}
path.add(node);
return path;
}
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。