LeetCode 108
本题是用的树的中序遍历,和剑指offer中 剑指Offer(33) 二叉搜索树的后续遍历序列 类似,不过本题用的是后续遍历,但都是递归思想。
1 | /** |
比较难的 二叉树和为某一值的路径
本题使用递归来实现。
做为树的递归题目是非常有套路可循的,因为树有两个分支,所以在递归里也有两个分支,一般是通过 递归 A(||,&&)递归 B 来实现分支的。只要明白了这一点,递归函数就不会很难设计。
1 | /** |
给定一个二叉树和一个目标和,判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和。
说明: 叶子节点是指没有子节点的节点。
示例:
给定如下二叉树,以及目标和 sum = 22,
5 / \ 4 8 / / \ 11 13 4 / \ \7 2 1
返回 true, 因为存在目标和为 22 的根节点到叶子节点的路径 5->4->11->2。
LeetCode 103
或者叫 剑指offer第三题 之字形打印二叉树
给定一个二叉树,返回其节点值的锯齿形层次遍历。(即先从左往右,再从右往左进行下一层遍历,以此类推,层与层之间交替进行)。
例如: 给定二叉树 [3,9,20,null,null,15,7],
3
/ \
9 20
/ \
15 7
返回锯齿形层次遍历如下:
[
[3],
[20,9],
[15,7]
]
1 | /** |
LeetCode 114
本题还有其他两种解法 @windliang,我使用先序遍历,用栈存储右孩子节点的方法避免,右指针丢失。
变体的先序遍历,这题如果用正常的先序遍历的话,会丢失右孩子,为了更好的控制算法,用先序遍历的迭代形式,正常的先序遍历代码如下:
1 | public static void preOrderStack(TreeNode root) { |
还有一种特殊的先序遍历,提前将右孩子保存到栈中,我们利用这种遍历方式就可以防止右孩子的丢失了。由于栈是先进后出,所以我们先将右节点入栈。
1 | public void preorder(TreeNode root) { |
之前我们的思路如下:
题目其实就是将二叉树通过右指针,组成一个链表。
1 -> 2 -> 3 -> 4 -> 5 -> 6
我们知道题目给定的遍历顺序其实就是先序遍历的顺序,所以我们可以利用先序遍历的代码,每遍历一个节点,就将上一个节点的右指针更新为当前节点。
先序遍历的顺序是 1 2 3 4 5 6。
遍历到 2,把 1 的右指针指向 2。1 -> 2 3 4 5 6。
遍历到 3,把 2 的右指针指向 3。1 -> 2 -> 3 4 5 6。
因为我们用栈保存了右孩子,所以不需要担心右孩子丢失了。用一个 pre 变量保存上次遍历的节点。修改的代码如下:
1 | /** |
给定一个二叉树,原地将它展开为链表。
例如,给定二叉树
1
/ \
2 5
/ \ \
3 4 6
将其展开为:
1
\
2
\
3
\
4
\
5
\
6
LeetCode 109
使用中序遍历,递归的方法 方法 3:中序遍历模拟
1 | /** |
给定一个单链表,其中的元素按升序排序,将其转换为高度平衡的二叉搜索树。
本题中,一个高度平衡二叉树是指一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1。
示例:
给定的有序链表: [-10, -3, 0, 5, 9],
一个可能的答案是:[0, -3, 9, -10, null, 5], 它可以表示下面这个高度平衡二叉搜索树:
0/ \
-3 9
/ /
-10 5
这里使用树的中序遍历方法,参考树的中序-非递归遍历
注意一个错误 else分支中 cur = cur.right 是错误的!!!
1 | /** |
给定一个二叉树,判断其是否是一个有效的二叉搜索树。
假设一个二叉搜索树具有如下特征:
节点的左子树只包含小于当前节点的数。 节点的右子树只包含大于当前节点的数。 所有左子树和右子树自身必须也是二叉搜索树。
示例 1:
输入:
2
/ \
1 3
输出: true
示例 2:
输入:
5
/ \
1 4
/ \
3 6
输出: false
解释: 输入为: [5,1,4,null,null,3,6]。
根节点的值为 5 ,但是其右子节点值为 4 。
有一个背包,背包的总承载重量是Wkg,现在有n个物品,每个物品的重量不等,并且不可分割。我们期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?
0-1背包问题的回溯实现技巧:
第 13 行的递归调用表示不选择当前物品,直接考虑下一个(第 i+1 个),故 cw 不更新
第 15 行的递归调用表示选择了当前物品,故考虑下一个时,cw 通过入参更新为 cw + items[i]
函数入口处的 if 分支表明递归结束条件,并保证 maxW 跟踪所有选择中的最大值
1 | public class PkgDemo { |
1 | package com.ludepeng.datastruct.algorithm.backtrackingAlgorithm.packageZeroOne; |
1 | package com.ludepeng.datastruct.algorithm.backtrackingAlgorithm.packageZeroOne; |
「为了有规律地枚举所有可能的解,避免遗漏和重复,把问题求解的过程分为多个阶段。每个阶段,都会面对一个岔路口,先随意选择一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另一种走法继续走。」
反复理解下:递归是一种栈结构的形式,最后一个入栈的最先执行完,然后返回上一层栈桢继续执行(对照八皇后的代码实现)。
八皇后是怎么打印出所有的解的??
理解是
代码可以看成 第一行的循环*第二行的循环*第三行的循环* … *第八行的循环
下面代码中回溯算法非常隐蔽,其实是在 result[row] = col; 这一步,每次之前的值都会被替换。
1 | /** |
https://www.geeksforgeeks.org/n-queen-problem-backtracking-3/
1 | /* Java program to solve N Queen Problem using |
剑指offer中的实现,最长不含重复字符的子字符串,采用的是动态规划。
这里用另一种思路,哈希表存储最后出现的字符的下标。
用ascii码值来当做key。
开始使用 长度为26的数组当做哈希表,但是发现这里还有空格,所以使用了 长度为256的数组。
1 |
|
1 |
|
请你写一个函数StrToInt,实现把字符串转换成整数这个功能。当然,不能使用atoi或者其他类似的库函数。
参考leetcode 008
1 |
|
LeetCode 8
请你来实现一个 atoi 函数,使其能将字符串转换成整数。
首先,该函数会根据需要丢弃无用的开头空格字符,直到寻找到第一个非空格的字符为止。
当我们寻找到的第一个非空字符为正或者负号时,则将该符号与之后面尽可能多的连续数字组合起来,作为该整数的正负号;假如第一个非空字符是数字,则直接将其与之后连续的数字字符组合起来,形成整数。
该字符串除了有效的整数部分之后也可能会存在多余的字符,这些字符可以被忽略,它们对于函数不应该造成影响。
注意:假如该字符串中的第一个非空格字符不是一个有效整数字符、字符串为空或字符串仅包含空白字符时,则你的函数不需要进行转换。
在任何情况下,若函数不能进行有效的转换时,请返回 0。
说明:
假设我们的环境只能存储 32 位大小的有符号整数,那么其数值范围为 [−231, 231 − 1]。如果数值超过这个范围,请返回 INT_MAX (2^31 − 1) 或 INT_MIN (−2^31) 。
示例 1:
输入: “42”
输出: 42
示例 2:
输入: “ -42”
输出: -42
解释: 第一个非空白字符为 ‘-‘, 它是一个负号。
我们尽可能将负号与后面所有连续出现的数字组合起来,最后得到 -42 。
示例 3:
输入: “4193 with words”
输出: 4193
解释: 转换截止于数字 ‘3’ ,因为它的下一个字符不为数字。
示例 4:
输入: “words and 987”
输出: 0
解释: 第一个非空字符是 ‘w’, 但它不是数字或正、负号。
因此无法执行有效的转换。
示例 5:
输入: “-91283472332”
输出: -2147483648
解释: 数字 “-91283472332” 超过 32 位有符号整数范围。
因此返回 INT_MIN (−231) 。
1 | package algo09.string.leetcode_008; |
1 | for (; i < chars.length; i++) { |
LeetCode 151
给定一个字符串,逐个翻转字符串中的每个单词。
示例:
输入: “the sky is blue”,
输出: “blue is sky the”.
说明:
无空格字符构成一个单词。 输入字符串可以在前面或者后面包含多余的空格,但是反转后的字符不能包括。 如果两个单词间有多余的空格,将反转后单词间的空格减少到只含一个。 进阶: 请选用C语言的用户尝试使用 O(1) 空间复杂度的原地解法。
1 | package algo09.string.leetcode_151; |
多模式串匹配算法
AC自动机适合大量文本中多模式串的精确匹配查找,可以到O(n)
AC自动机实际上就是在Trie树之上,加了类似KMP的next数组,只不过此处的next数组是构建在树上罢了。
1 | public class AcNode { |
AC自动机的构建,包含两个操作:
1 | package com.ludepeng.datastruct.base.datastruct.charMath.ahoCorasick; |
单模式串匹配有 BF、RK、naive-BM和KMP这四种算法。
本节实现的是一种 naive-Trie
Trie树适合多模式串公共前缀较多的匹配(O(n*k))或者 根据公共前缀进行查找 O(k)的经典场景,比如搜索框的自动补全提示。
针对一组字符串中查找字符串的问题,在工程中,更倾向于用散列表或者红黑树。Trie树不适合精确匹配查找。
Trie树比较适合的是查找前缀匹配的字符串。
1 | package com.ludepeng.datastruct.base.datastruct.charMath.trie; |
KMP算法 文章讲的比较系统了。
next函数,这个文章有助于看懂next函数是怎么一回事。
适合所有场景,整体实现起来也比BM简单,O(n+m),仅需要一个next数组的O(n)额外空间;但统计意义下似乎BM更快,原因不明。
最难理解的地方是
k = next[k]
因为前一个的最长串的下一个字符不与最后一个相等,需要找前一个的次长串,问题就变成了求0到next[k]的最长串,如果下个字符与最后一个不等,也就是下一个next[k],直到找到,或者完全没有。
1 | package com.ludepeng.datastruct.base.datastruct.charMath.diffculty.kmp; |
放上单元测试的代码
1 | package com.ludepeng.datastruct.base.datastruct.charMath.diffculty.kmp.demo; |
1 | package com.ludepeng.datastruct.base.datastruct.charMath.diffculty.kmp.demo; |
模式串最好不要太长(因为预处理较重),比如IDE编辑器里的查找场景;预处理O(m*m),匹配O(n),实现较复杂,需要较多额外空间。
从后往前逐位比较模式串与主串的字符,当找到不匹配的坏字符时,记录模式串的下标值 si ,并找到坏字符在模式串中,位于下标 si前的最近位置 xi (若无则记为-1),si - xi 即为向后滑动距离。 但是坏字符规则向后滑动的步幅还不够大,于是需要好后缀规则。
将模式串中的每个字符及其下标都存在 哈希表中,这样可以快速找到坏字符在模式串的位置下标。相同的模式串字符,仅记录最后的位置。
从后往前逐位比较模式串和主串的字符,当出现坏字符时停止。 若已经存在匹配成功的子串 {u}, 那么模式串的 {u} 前面找到最近的 {u},记为 {u’}。 再将模式串后裔,是的模式串的 {u’} 与主串的 {u} 重叠。
若不存在 {u’},则直接把模式串移到主串的 {u} 后面。
为了没有遗漏,还需要找到最长的、能够跟模式串的最长前缀子串匹配的,好后缀的后缀子串(同时也是模式串的后缀子串)。然后把模式串向后移动到其左边界,与这个好后缀个后缀子串在主串的中的左边界对齐。
好后缀的处理规则中最核心的内容:
技巧:
每次执行好后缀原则时,都会计算多次能够与模式串前缀子串相匹配的好后缀的最长后缀子串。为了提高效率,可以预先计算模式串的所有后缀子串,在模式串与之匹配的另一个子串的位置。同时预计算模式串中(同长度)后缀子串与前缀子串是否匹配并记录。在具体操作中直接使用,大大提高效率。
如何快速记录模式串后缀子串匹配的另一个子串位置,以及模式串(相同长度)前缀与后缀子串是否匹配呢? 先用一个suffix数组,下标值k为后缀子串的长度,从模式串下标为
i (0 ~ m-2)的字符为最后一个字符,查找这个子串是否与后缀子串匹配,若匹配则将子串起始位置的下标值j赋给suffix[k]。 若j为0,说明这个匹配子串的起始位置为模式串的起始位置,则用一个数组prefix,将prefix[k]设为true,否则设为false。k从0到m(模式串的长度)于是就得到了模式串所有前缀与后缀子串的匹配情况。
1 | package com.ludepeng.datastruct.base.datastruct.charMath.diffculty.bm; |
1 | package com.ludepeng.datastruct.base.datastruct.charMath.diffculty.bm; |
比如生成suffix和prefix数组的这段代码的测试
1 | package com.ludepeng.datastruct.base.datastruct.charMath.diffculty.bm.demo; |
1 | package com.ludepeng.datastruct.base.datastruct.charMath.diffculty.bm.demo; |
通过对主串中 n-m+1 个子串分别求hash值,然后逐个与模式串的哈希值比较大小。 在对主串构建的时候,就对比是不是一样的,一样就不继续计算后面的hash值。
一种简单的hash算法,a~z这26个英文字母,对应的数字相加,得到的和作为hash值,为了解决hash碰撞的问题在,哈希值相等的时候,再对比一下子串和模式串本身。
字符集范围不要太长且模式串不要太长,否则hash值可能冲突,O(n)
1 | package com.ludepeng.datastruct.base.datastruct.charMath.simple.rk; |
1 | package com.ludepeng.datastruct.base.datastruct.charMath.simple.rk; |
1 | package com.ludepeng.datastruct.base.datastruct.charMath.simple.rk; |
简单场景,主串和模式串都不太长,O(m*n)
1 | /** |
1 | package com.ludepeng.datastruct.base.datastruct.charMath.simple.bf; |
最近看源码比较多,从源码中收获很多。 其中Iterator 的方法建议忘记了再跟一些源码,看看是怎么来的。
不废话,直接上代码。
1 | package algo09.hashmap; |
动态规划(Dynamic Programming, DP)是一项虽简单但较难掌握的技术,一个容易识别和求解DP问题的方法时通过求解尽可能多的问题。
“Programming”一词并不是指编程,而是填充表格(类似线性规划)。
尽管递归问题五花八门,但题型大都类似。
一个问题是不是递归的,就看它能不能分解成子问题进行求解。
当你听到问题是这么开头的:“设计一个算法,计算第n个……”,“编写代码列出前n个……”,“实现一个方法,计算所有……”等等,那么这个问题基本就是一个递归问题。
递归的解法,根据定义,就是从较小的子问题逐渐逼近原始问题。
很多时候,只要在f(n-1)的解法中 加入、移除某些东西或者稍作修改就能计算出f(n)。而在其他情况下,答案可能更为复杂。
你应该双管齐下,自下而上和自上而下两种递归解法都要考虑。简单构造法对递归问题就很奏效。
自下而上的递归往往最为直观。首先要知道如何解决简单情况下的问题,比如,只有一个元素的列表,找出有两个、三个元素的列表的解法,依此类推。这种接法的关键在于,如何从先前解出来的答案,构建出后续情况的答案。
自上而下的递归可能比较复杂,不过对某些问题很有必要。遇到此类问题时,我们要思考如何才能将情况N下的问题分解成多个子问题。同时注意子问题是否重叠了。
分治(Divide and Conquer)法递归地将问题分解成两个或多个同类型的子问题,直到这些子问题简单到能够直接求解,然后将这些子问题的解合成为原始问题的解。
分治一般包括如下步骤:
分治法递归地求解子问题,所有问题一般按照递归进行定义,用 主定理(Master theorem) 容易求得这些递归问题的时间复杂度。
如果听到问题是求一个最优解(通常是求最大值或最小值),而且该问题能够分解成若干子问题,并且子问题之间还有重叠的更小的子问题,就可以考虑用动态规划来解决这个问题。
对于分治法,子问题是相互独立的,而在动态规划中子问题可能是重叠的,通过使用备忘录(用一个表来保存已解决子问题的答案),对于大部分问题,动态规划能够将待求解问题的复杂度由指数级降低为多项式级。
动态规划主要包含以下两个部分:
动态规划 = 递归 + 备忘录
贪婪算法将问题分为多个阶段。在每一个阶段,选取当前状态下的最优决策,而不考虑对后续决策的影响。这意味着算法在执行过程中会选取某些 局部最优解。贪婪算法假设通过局部最优解可以获得全局最优解。
全局最优解可以通过寻找局部最优解获得(贪婪),局部最优解的选择可能依赖于之前的决策。通过迭代方式算法进行一个个贪婪选择,将原问题简化为规模更小的问题。
如果原问题的最优解包含子问题的最优解,则认为该问题具有最优子结构。这意味着可以对子问题求解并构建规模更大的解。
优点:
直观,易于理解和编程实现。当前的决策不会对已经计算出的结果有任何影响,因此不需要再对已有的局部解进行检查。
缺点:
选择局部最优不是对于所有问题都是用,所以贪婪算法并不总能得到最优解。在许多情况下,无法保证最优解能够产生局部最优解。
通常需要用数学的方式来证明贪婪选择是正确的。
参考剑指Offer(55) 题目二 平衡二叉树判定的思路。
LeetCode 110 英文版
LeetCode 110 中文版
1 | /** |
给定一个二叉树,判断它是否是高度平衡的二叉树。
本题中,一棵高度平衡二叉树定义为:
一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过1。
示例 1:
给定二叉树 [3,9,20,null,null,15,7]
3
/ \
9 20
/ \
15 7
返回 true 。
示例 2:
给定二叉树 [1,2,2,3,3,null,null,4,4]
1 / \ 2 2/ \
3 3
/ \
4 4
返回 false 。
参考剑指Offer(55) 题目一 二叉树的深度的思路。
通过debug跟踪发现,
对于树
0
1 2
3 4 5 6
7
一直递归计算left_height,直到7的左右子节点,然后返回0,然后递归栈向上返回一层,计算3的左右子树高度,于是得到 max{1, 0}=1;
然后递归栈返回计算1的左右子树,左子树已经计算出来了,递归计算右子树4的高度,依次类推。。。
可以用 com.ludepeng.datastruct.base.datastruct.tree.leetcode104.DepthOfTree 进行查看。
1 | /** |
给定一个二叉树,找出其最大深度。
二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。
说明: 叶子节点是指没有子节点的节点。
示例: 给定二叉树 [3,9,20,null,null,15,7],
3
/ \
9 20
/ \
15 7
返回它的最大深度 3 。
时间复杂度:我们每个结点只访问一次,因此时间复杂度为 O(N)
其中 N 是结点的数量。
空间复杂度:在最糟糕的情况下,树是完全不平衡的,例如每个结点只剩下左子结点,递归将会被调用 N 次(树的高度),因此保持调用栈的存储将是 O(N) 但在最好的情况下(树是完全平衡的),树的高度将是 log(N)。因此,在这种情况下的空间复杂度将是 O(log(N))。
注意区分和剑指Offer(27) 二叉树的镜像的区别,另一种迭代解法待完成。
1 | /** |
翻转一棵二叉树。
示例:
输入:
4
/ \
2 7
/ \ / \
1 3 6 9
输出:
4
/ \
7 2
/ \ / \
9 6 3 1
备注: 这个问题是受到 Max Howell 的 原问题 启发的 :
谷歌:我们90%的工程师使用您编写的软件(Homebrew),但是您却无法在面试时在白板上写出翻转二叉树这道题,这太糟糕了。
既然树中的每个节点都只被访问一次,那么时间复杂度就是 O(n),其中 n 是树中节点的个数。在反转之前,不论怎样我们至少都得访问每个节点至少一次,因此这个问题无法做地比 O(n)更好了。
本方法使用了递归,在最坏情况下栈内需要存放 O(h)个方法调用,其中 h 是树的高度。由于 h\in O(n)h∈O(n),可得出空间复杂度为 O(n)。
本题和 剑指Offer(28) 对称的二叉树 相似,请完成迭代解法。
1 | /** |
给定一个二叉树,检查它是否是镜像对称的。
例如,二叉树 [1,2,2,3,4,4,3] 是对称的。
1
/ \
2 2
/ \ / \
3 4 4 3
但是下面这个 [1,2,2,null,3,null,3] 则不是镜像对称的:
1
/ \
2 2
\ \
3 3
说明:
如果你可以运用递归和迭代两种方法解决这个问题,会很加分。
如果一个树的左子树与右子树镜像对称,那么这个树是对称的。
因此,该问题可以转化为:两个树在什么情况下互为镜像?
如果同时满足下面的条件,两个树互为镜像:
时间复杂度:O(n),因为我们遍历整个输入树一次,所以总的运行时间为 O(n),其中 n 是树中结点的总数。
空间复杂度:递归调用的次数受树的高度限制。在最糟糕情况下,树是线性的,其高度为 O(n)。因此,在最糟糕的情况下,由栈上的递归调用造成的空间复杂度为 O(n))。
思考:本题使用迭代法如何做?
1 | ** |
给定两个二叉树,编写一个函数来检验它们是否相同。
如果两个树在结构上相同,并且节点具有相同的值,则认为它们是相同的。
示例 1:
输入: 1 1
/ \ / \
2 3 2 3
[1,2,3], [1,2,3]
输出: true
示例 2:
输入: 1 1
/ \
2 2
[1,2], [1,null,2]
输出: false
示例 3:
输入: 1 1
/ \ / \
2 1 1 2
[1,2,1], [1,1,2]
输出: false
最简单的策略是使用递归。首先判断 p 和 q 是不是 null,然后判断它们的值是否相等。
时间复杂度 : O(N)O(N),其中 N 是树的结点数,因为每个结点都访问一次。
空间复杂度 : 最优情况(完全平衡二叉树)时为 O(\log(N))O(log(N)),最坏情况下(完全不平衡二叉树)时为 {O}(N)O(N),用于维护递归栈。
7) 二叉树的下一个结点
8) 用两个栈实现队列
9) 斐波那契数列及青蛙跳台阶问题
10) 旋转数组的最小数字
11) 矩阵中的路径
12) 机器人的运动范围
13) 剪绳子
14) 二进制中1的个数
15) 数值的整数次方
16) 打印1到最大的n位数
17) 在O(1)时间删除链表结点
18) 删除链表中重复的结点
19) 正则表达式匹配
20) 表示数值的字符串
21) 调整数组顺序使奇数位于偶数前面
22) 链表中倒数第k个结点
23) 链表中环的入口结点
24) 反转链表
25) 合并两个排序的链表
26) 树的子结构
27) 二叉树的镜像
28) 对称的二叉树
29) 顺时针打印矩阵
30) 包含min函数的栈
31) 栈的压入、弹出序列
32) 从上往下打印二叉树
33) 二叉搜索树的后序遍历序列
34) 二叉树中和为某一值的路径
35) 复杂链表的复制
36) 二叉搜索树与双向链表
37) 序列化二叉树
38) 字符串的排列
39) 数组中出现次数超过一半的数字
40) 最小的k个数
41) 数据流中的中位数
42) 连续子数组的最大和
43) 从1到n整数中1出现的次数
44) 数字序列中某一位的数字
45) 把数组排成最小的数
46) 把数字翻译成字符串
47) 礼物的最大价值
48) 最长不含重复字符的子字符串
50-1) 字符串中第一个只出现一次的字符
50-2) 字符流中第一个只出现一次的字符
51)数组中的逆序对
52) 两个链表的第一个公共结点
53-1) 数字在排序数组中出现的次数
53-2) 0到n-1中缺失的数字
53-3) 数组中数值和下标相等的元素
54) 二叉搜索树的第k个结点
55-1) 二叉树的深度
55-2) 平衡二叉树
56-1) 数组中只出现一次的两个数字
56-2) 数组中唯一只出现一次的数字
57-1) 和为s的两个数字
57-2) 为s的连续正数序列
58-1) 翻转单词顺序
58-2) 左旋转字符串
59-1) 滑动窗口的最大值
59-2) 队列的最大值
60) n个骰子的点数
61) 扑克牌的顺子
62) 圆圈中最后剩下的数字
63) 股票的最大利润
64) 求1+2+…+n
65) 不用加减乘除做加法
66) 构建乘积数组
67) 把字符串转换成整数
68) 树中两个结点的最低公共祖先
通过Scanner类可以后去用户输入,创建Scanner对象的基本语法如下:
Scanner sc = new Scanner(System.in);
System.in代表标准输入,即键盘输入,但这个标准输入流是 InputStream 类的实例,使用不太方便,而且键盘输入内容都是文本内容,所以可以使用 InputStreamReader 将其转换为字符输入流,普通的 Reader 读取输入内容依然不太方便,可以将普通的 Reader 再次包装成BufferedReader,利用 BufferedReader 的 readLine() 方法可以一次读取一行内容。
1 | public class KeyInTest { |
nextInt()、next()和nextLine():
nextInt(): it only reads the int value, nextInt() places the cursor(光标) in the same line after reading the input.
nextInt()只读取数值,剩下”\n”还没有读取,并将cursor放在本行中。
next(): read the input only till the space. It can’t read two words separated by space. Also, next() places the cursor in the same line after reading the input.(next()只读空格之前的数据,并且cursor指向本行)
next() 方法遇见第一个有效字符(非空格,非换行符)时,开始扫描,当遇见第一个分隔符或结束符(空格或换行符)时,结束扫描,获取扫描到的内容,即获得第一个扫描到的不含空格、换行符的单个字符串。
nextLine(): reads input including space between the words (that is, it reads till the end of line \n). Once the input is read, nextLine() positions the cursor in the next line.
nextLine()时,则可以扫描到一行内容并作为一个字符串而被获取到。
下面摘自:《疯狂java讲义 第四版》 p857
当程序使用反射方式为指定接口生成系列动态代理对象时,这些动态代理对象的实现类实现了一个或多个接口。动态代理对象就需要实现一个或多个接口里定义的所有方法,但问题是:系统怎么知道如何实现这些方法? 这个时候就轮到 InvocationHandler 对象登场了,当执行动态代理对象里的方法时,实际上会替换成调用 InvocationHandler 对象的 invoke 方法。
在 Java 中,动态代理类的生成主要涉及对 ClassLoader 的使用。以 CGLIB 为例,使用 CGLIB 生成动态代理,首先需要生成 Enhancer 类实例,并指定用于处理代理业务的回调类。在 Enhancer.create() 方法中,会使用 DefaultGeneratorStrategy.Generate() 方法生成动态代理类的字节码,并保存在 byte 数组中。接着使用 ReflectUtils.defineClass() 方法,通过反射,调用 ClassLoader.defineClass() 方法,将字节码装载到 ClassLoader 中,完成类的加载。最后使用 ReflectUtils.newInstance() 方法,通过反射,生成动态类的实例,并返回该实例。基本流程是根据指定的回调类生成 Class 字节码—通过 defineClass() 将字节码定义为类—使用反射机制生成该类的实例。
程序中可以采用先生成一个动态代理类,然后通过动态代理类来创建代理对象的方式生成一个动态代理对象。代码如下:
1 | // 创建一个InvocationHandler对象 |
上面的代码也可以简化成如下代码:
1 | // 创建一个InvocationHandler对象 |
下面的代码来自于《精通Spring 4.x企业应用开发实战》 P224
业务逻辑实现类的代码,省去ForumService接口类和PerformanceMonitor的代码。
1 | public class ForumServiceImpl implements ForumService { |
将业务类中性能监视横切代码移除后,放置到InvocationHandler中,代码如下。
1 | import java.lang.reflect.Method; |
invoke(Object proxy, Method method, Object[] args)方法,其中,proxy是最终生成的代理实例,一般不会用到;method是被代理目标实例的某个具体方法,通过它可以发起目标实例方法的反射调用;args是被代理实例某个方法的入参,在方法反射时调用。
其次,在构造参数里通过target传入希望被代理的目标对象,在接口方法invoke(Object proxy, Method method, Object[] args)里,将目标实例传递给method.invoke()方法,并调用目标实例的方法。
proxy代表动态代理对象,method代表正在执行的方法,args代表调用目标方法是传入的实参。
下面通过Proxy结合PerformanceHandler创建ForumService接口的代理实例。
1 | import java.lang.reflect.Proxy; |
Proxy.newProxyInstance() 方法的第一个入参为类加载器;第二个入参为创建代理实例所需实现的一组接口;第三个入参是整合了业务逻辑和横切逻辑的编织器对象。
以下代码来自于 Java EE 互联网轻量级框架整合开发
在动态代理中必须使用接口,CGLib不需要。
下面的代码分别是简单的接口和被代理类的定义。
1 | // 接口 |
要实现动态代理要两个步骤,首先,建立起代理对象和被代理对象的关系(将目标业务类和横切代码编织到一起),然后实现代理逻辑。
1 | import java.lang.reflect.Proxy; |
1 | public class JdkProxyExampleTest { |
bind方法同时完成了两步。
使用JDK创建代理有一个限制,即只能为接口创建代理。Proxy的接口方法中newProxyInstance(ClassLoader loder, Class[] interfaces, InvocationHandler hander),第二个入参就是需要代理实例实现的接口列表。
假如对一个简单业务表的操作也需要创建5个类(领域对象、DAO接口、DAO实现类、Service接口和Service实现类)吗?
对于没有通过接口定义业务方法的类,可以使用CGLib动态创建代理实例。
CGLib采用底层的字节码技术,可以为一个类创建子类,在子类中采用方法拦截的技术拦截父类方法的调用并顺势织入横切逻辑。
值得一提的是,由于CGLib采用动态创建子类的方式生成代理对象,所以不能对目标类中的final或private方法进行代理。
下面代码可以创建,为任何类织入性能监视横切逻辑代理对象的代理创建器。
1 | import java.lang.reflect.Method; |
用户可以通过getProxy(Class cla)方法动态创建一个动态代理类。
1 | import java.lang.reflect.Proxy; |
1 | public interface Interceptor { |
1 | public class MyInterceptor implements Interceptor { |
1 | public class InterceptorJdkProxy implements InvocationHandler { |
输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如序列1、2、3、4、5是某栈的压栈序列,序列4、5、3、2、1是该压栈序列对应的一个弹出序列,但4、3、5、1、2就不可能是该压栈序列的弹出序列。
建立一个栈,按照压栈序列依次进行入栈操作,按出栈序列的顺序依次弹出数字。在出栈时,若下一个要出栈的数字与栈顶数字相同则弹出。如果压栈序列中的所有数字都入栈后没有完全出栈成功则代表两个序列不匹配,返回false。
功能测试(两个数组长度不同;两个数组对应;两个数组不对应)
特殊测试(数组为空;null;一个数字的数组)
1 | /** |
定义栈的数据结构,请在该类型中实现一个能够得到栈的最小元素的min函数。在该栈中,调用min、push及pop的时间复杂度都是O(1)。
最初想法是定义一个成员变量min来存放最小元素,但是当最小元素弹出后,min就需要相应改变,所以必须把每次的最小值都存储下来。考虑采用一个辅助栈来存放最小值:
栈 3,4,2,5,1
辅助栈 3, 3,2,2,1
(压入时,把每次的最小元素(之前最小元素与新入栈元素的较小值)保存起来放到辅助栈中)
1 | /** |
把n个骰子扔在地上,所有骰子朝上一面的点数之和为s。输入n,打印出s的所有可能的值出现的概率。
对于n个骰子,要计算出每种点数和的概率,我们知道投掷n个骰子的总情况一共有6^n种,因此只需要计算出某点数和的情况一共有几种,即可求出该点数之和的概率。
方法一:基于递归的方法,效率较低
易知,点数之和s的最小值为n,最大值为6n,因此我们考虑用一个大小为(6n-n+1)的数组存放不同点数之和的情况个数,那么,如果点数之和为x,那么把它出现的情况总次数放入数组种下标为x-n的元素里。
确定如何存放不同点数之和的次数后,我们要计算出这些次数。我们把n个骰子分为1个骰子和n-1个骰子,这1
个骰子可能出现1~6个点数,由该骰子的点数与后面n-1个骰子的点数可以计算出总点数;而后面的n-1个骰子又可以分为1个和n-2个,把上次的点数,与现在这个骰子的点数相加,再和剩下的n-2个骰子的点数相加可以得到总点数……,即可以用递归实现。在获得最后一个骰子的点数后可以计算出几个骰子的总点数,令数组中该总点数的情况次数+1,即可结束遍历。
方法二:基于循环求骰子点数,时间性能好
用数组存放每种骰子点数和出现的次数。令数组中下标为n的元素存放点数和为n的次数。我们设置循环,每个循环多投掷一个骰子,假设某一轮循环中,我们已知了各种点数和出现的次数;在下一轮循环时,我们新投掷了一个骰子,那么此时点数和为n的情况出现的次数就等于上一轮点数和为n-1,n-2,n-3,n-4,n-5,n-6的情况出现次数的总和。从第一个骰子开始,循环n次,就可以求得第n个骰子时各种点数和出现的次数。
我们这里用两个数组来分别存放本轮循环与下一轮循环的各种点数和出现的次数,不断交替使用。
功能测试(1,2,3,4个骰子)
特殊测试(0个)
性能测试(11个)
1 | import java.text.NumberFormat; |
例如:double ratio = (double)probabilities[i]/totalP;
1 | NumberFormat format = NumberFormat.getPercentInstance(); |
第二种方法,不是骰子点数的角度出发,而是从点数之和出发,点数之和有:f(n)=f(n-1)+……f(n-6),非常巧妙。
用两个数组交替存放,学会使用变量flag,flag=1-flag。
代码中没有把骰子的最大点数硬编码为6,而是用变量maxValue来表示,具有可拓展性。以后自己编程时也要注意这些量是否可以不用硬编码,从而提高扩展性。
提高数学建模能力,不管采取哪种思路,都要先想到用数组来存放n个骰子的每个点数和出现的次数。
请定义一个队列并实现函数max得到队列里的最大值,要求函数max、push_back和pop_front的时间复杂度都是O(1)。
与滑动窗口的最大值一题相似,利用一个双端队列来存储当前队列里的最大值以及之后可能的最大值。
在定义题目要求功能的队列时,除了定义一个队列data存储数值,还需额外用一个队列maxmium存储可能的最大值;此外,还要定义一个数据结构,用于存放数据以及当前的index值,用于删除操作时确定是否删除maxmium中最大值。
尾部插入不同大小数字,删除头部数字。插入删除同时获取最大值。
1 | /** |
给定一个数组和滑动窗口的大小,请找出所有滑动窗口里的最大值。例如,如果输入数组{2, 3, 4, 2, 6, 2, 5, 1}及滑动窗口的大小3,那么一共存在6个滑动窗口,它们的最大值分别为{4, 4, 6, 6, 6, 5}
蛮力直接在每个滑动窗口依次比较找出最大值,时间复杂度太高。
我们考虑把每个可能成为最大值的数字记录下来,就可以快速的得到最大值。
思路:建立一个两端开口的队列,放置所有可能是最大值的数字(存放的其实是对应的下标),且最大值位于队列开头。从头开始扫描数组,
如果遇到的数字比队列中所有的数字都大,那么它就是最大值,其它数字不可能是最大值了,将队列中的所有数字清空,放入该数字,该数字位于队列头部;
如果遇到的数字比队列中的所有数字都小,那么它还有可能成为之后滑动窗口的最大值,放入队列的末尾;
如果遇到的数字比队列中最大值小,最小值大,那么将比它小数字不可能成为最大值了,删除较小的数字,放入该数字。
由于滑动窗口有大小,因此,队列头部的数字如果其下标离滑动窗口末尾的距离大于窗口大小,那么也删除队列头部的数字。
注:队列中存放的是下标,以上讲的 队列头部的数字 均指 队列头部的下标所指向的数字。写代码时不要弄混了。
功能测试(数组数字递增、递减、无序)
边界值测试(滑动窗口大小位0、1、大于或者等于数组长度)
特殊输入测试(null)
1 | /** |
输入一个英文句子,翻转句子中单词的顺序,但单词内字符的顺序不变。为简单起见,标点符号和普通字母一样处理。例如输入字符串”I am a student. “,则输出”student. a am I”。
一开始自己觉得要用split()方法,但这要开辟新的数组,占内存空间,不行。
首先实现翻转整个句子:只需要在首尾两端各放置一个指针,交换指针所指的数字,两端指针往中间移动即可。之后根据空格的位置,对每个单词使用同样的方法翻转即可。
功能测试(句子中有一个/多个单词,空格在开头、中间、结尾)
边界值测试(null,空字符串,句子全为空格)
1 | /** |
reverseSub(chars, start, end - 1);
注意这里不是end 因为end是空格的index
字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。请定义一个函数实现字符串左旋转操作的功能。比如输入字符串”abcdefg”和数字2,该函数将返回左旋转2位得到的结果”cdefgab”。
本题思路和上一道题翻转单词顺序的原理一模一样,只是上一道题有空格,这道题没空格,其实这道题还更简单。先分别翻转前半部分字符串和后半部分字符串,最后翻转整个字符串即可。
功能测试(对长度为n的字符串,左旋转-1,0,1,2,n-1,n,n+1位)
边界值测试(null)
1 | /** |
输入一个递增排序的数组和一个数字s,在数组中查找两个数,使得它们的和正好是s。如果有多对数字的和等于s,输出任意一对即可。
从头开始遍历数字,确定一个数字后,对后面的数字遍历,判断和是否为s,这种方法复杂度为O(n^2),效率太低。
我们考虑到,如果一个数字比较小,那么另一个数字一定比较大,同时数字为递增排列;所以,我们设置两个指针,一个指针small从第一个数字(最小)出发,另一个指针big从最后一个数字(最大)出发:
当small加big的和小于s时,只需要将small指向后一个数字(更大),继续判断;
当small加big的和大于s时,只需要将big指向前一个数字(更小),继续判断;
当small加big的和等于s时,求解完成。
由于是从两边往中间移动,所以不会有跳过的情况,时间复杂度为O(n)。
功能测试(存在/不存在和为s的一对数字)
特殊输入测试(null)
1 | /** |
输入一个正数s,打印出所有和为s的连续正数序列(至少含有两个数)。例如输入15,由于1+2+3+4+5=4+5+6=7+8=15,所以结果打印出3个连续序列1~5、4~6和7~8。
指针法:
类似(57-1) 和为s的两个数字的方法,用两个指针small和big分别代表序列的最大值和最小值。令small从1开始,big从2开始。
当从small到big的序列的和小于s时,增加big,使序列包含更多数字;(记得更新序列之和)
当从small到big的序列的和大于s时,增加small,使序列去掉较小的数字;(记得更新序列之和)
当从small到big的序列的和等于s时,此时得到一个满足题目要求的序列,输出,然后继续将small增大,往后面找新的序列。
序列最少两个数字,因此,当small到了s/2时,就可以结束判断了。
数学分析法:
参考自牛客网,丁满历险记的答案。
对于一个长度为n的连续序列,如果它们的和等于s,有:
1)当n为奇数时,s/n恰好是连续序列最中间的数字,即n满足 (n&1)==1 && s%n==0
2)当n为偶数时,s/n恰好是连续序列中间两个数字的平均值,小数部分为0.5,即n满足 (s%n)*2==n (判断条件中包含了n为偶数的判断)
得到满足条件的n后,相当于得到了序列的中间数字s/n,所以可以得到第一个数字为 (s / n) - (n - 1) / 2,结合长度n可以得到所有数字。
此外,在什么范围内找n呢?我们知道n至少等于2,那至多等于多少?n最大时,序列从1开始,根据等差数列的求和公式根据等差数列的求和公式:S = (1 + n) * n / 2,可以得到n应该小于sqrt(2s),所以只需要从n=2到sqrt(2s)来判断满足条件的n,继而输出序列。
功能测试(存在/不存在和为s的序列)
边界值测试(s=3)
1 | /** |
还是利用两个指针,这个技巧要学会
代码中求连续序列的和,并没有每次遍历计算,而是根据每次操作的情况而在之前的结果上进行加减,可以提高效率,值得学习
题目57-1) 和为s的两个数字中的指针是从两端开始,本题指针从1,2开始,注意指针的初始设置。
方法二中,当s/n的余数为0.5时,s%n的结果是n/2,而不是1。
题目一:数组中只出现一次的两个数字
一个整型数组里除了两个数字之外,其他的数字都出现了两次。请写程序找出这两个只出现一次的数字。要求时间复杂度是O(n),空间复杂度是O(1)。
记住:两个相同的数字异或等于0.
如果数组中只有一个数字只出现一次,我们从头到尾异或每个数字,那么最终的结果刚好是那个只出现一次的数字。
而本题里数组中有两个数字只出现一次,如果能够将数组分为两部分,两部分中都只有一个数字只出现一次,那么就可以解决该问题了。
求解方法:
我们依旧从头到尾异或每个数字,那么最终的结果就是这两个只出现一次的数字的异或结果,由于两个数不同,因此这个结果数字中一定有一位为1,把结果中第一个1的位置记为第n位。因为是两个只出现一次的数字的异或结果,所以这两个数字在第n位上的数字一定是1和0。
接下来我们根据数组中每个数字的第n位上的数字是否为1来进行分组,恰好能将数组分为两个都只有一个数字只出现一次的数组,对两个数组从头到尾异或,就可以得到这两个数了。
1 | /** |
当一个数字出现两次(或者偶数次)时,用异或^ 可以进行消除。一定要牢记 异或的这个功能!
将一组数字分为两组,可以根据某位上是否为1来进行分组,即根据和1相与(&1)的结果来进行分组。
判断某个数x的第n位(如第3位)上是否为1,
1)通过 x&00000100 的结果是否为0 来判断。(不能根据是否等于1来判断)
2)通过(x>>3)&1 是否为0 来判断
在一个数组中除了一个数字只出现一次之外,其他数字都出现了三次。请找出那个只出现一次的数字。
这道题中数字出现了三次,无法像56-1) 数组中只出现一次的两个数字一样通过利用异或位运算进行消除相同个数字。但是仍然可以沿用位运算的思路。
将所有数字的二进制表示的对应位都加起来,如果某一位能被三整除,那么只出现一次的数字在该位为0;反之,为1。
1 | /** |
1)通过 x&00000100 的结果是否为0 来判断。(不能根据是否等于1来判断)
2)通过(x>>3)&1 是否为0 来判断
1 | int number = 100; |
1 | int result = 0; |
题目一:二叉树的深度
题目二:平衡二叉树
输入一棵二叉树的根结点,求该树的深度。从根结点到叶结点依次经过的/结点(含根、叶结点)形成树的一条路径,最长路径的长度为树的深度。
简洁理解:
树的深度=max(左子树深度,右子树深度)+1,采用递归实现。
功能测试(左斜树、右斜树、普通树)
边界值测试(一个结点)
特殊测试(null)
1 | /** |
输入一棵二叉树的根结点,判断该树是不是平衡二叉树。如果某二叉树中任意结点的左右子树的深度相差不超过1,那么它就是一棵平衡二叉树。
在(55-1) 二叉树的深度基础上修改:计算树的深度,树的深度=max(左子树深度,右子树深度)+1。在遍历过程中,判断左右子树深度相差是否超过1,如果不平衡,则令树的深度=-1,用来表示树不平衡。最终根据树的深度是否等于-1来确定是否为平衡树。
功能测试(左斜树、右斜树、平衡或者不平衡树)
特殊测试(一个结点,null)
1 | /** |
给定一棵二叉搜索树,请找出其中的第k小的结点。
设置全局变量index=0,对BST进行中序遍历,每遍历一个结点,index+1,当index=k时,该结点即为所求结点。
功能测试(左斜树、右斜树、普通树)
边界值测试(k=1,k=结点数目)
特殊测试(null,k<=0,k>结点数目)
1 | /** |
熟练掌握二叉搜索树和中序遍历。
用中序遍历实现功能时,一定要注意返回值是否满足要求。
假设一个单调递增的数组里的每个元素都是整数并且是唯一的。请编程实现一个函数找出数组中任意一个数值等于其下标的元素。例如,在数组{-3, -1,1, 3, 5}中,数字3和它的下标相等。
同53-1和53-2一样,不再从头到尾遍历,由于是排序数组,我们继续考虑使用二分查找算法:
1)当中间数字等于其下标时,中间数字即为所求数字;
2)当中间数字大于其下标时,在左半部分区域寻找;
2)当中间数字小于其下标时,在右半部分区域寻找;
功能测试(包含/不包含与下标相等的数字)
边界值测试(数字位于数组开头、中间或者结尾;仅一个数字数组)
特殊测试(null)
1 | /** |
一个长度为n-1的递增排序数组中的所有数字都是唯一的,并且每个数字都在范围0到n-1之内。在范围0到n-1的n个数字中有且只有一个数字不在该数组中,请找出这个数字。
如果从头到尾依次比较值与小标是否相等,时间复杂度为O(n),效率低。
由于是排序数组,我们继续考虑使用二分查找算法,结合上图可知:
当中间数字等于其下标时,我们在后半部分查找;
当中间数字不等于其下标时,
1)如果中间数字的前一个数字也不等于其下标,则在前半部分查找;
2)如果中间数字的前一个数字等于其下标,则说明中间数字的下标即为我们所要找的数字。
功能测试(缺失数字位于数组开头、中间或者结尾)
边界值测试(数字只有0或1)
特殊测试(null)
1 | /** |
统计一个数字在排序数组中出现的次数。例如输入排序数组{1, 2, 3, 3,3, 3, 4, 5}和数字3,由于3在这个数组中出现了4次,因此输出4。
分析:对于例子来说,如果采用二分法找到某一个3后,再往前遍历和往后遍历到第一个和最后一个3,在长度为n的数组中有可能出现O(n)个3,因此这样的扫描方法时间复杂度为O(n),效率与从头到尾扫描一样,速度太慢。
这题关键是找到第一个和最后一个3,因此我们尝试改进二分法:中间数字比3大或者小的情况与之前类似,关键是中间数字等于3的情况,这时可以分类讨论如下:
1)如果中间数字的前一个数字也等于3,说明第一个3在前面,继续在前半段查找第一个3;
2)如果中间数字的前一个数字不等于3,说明该位置是第一个3;
3)如果中间数字的后一个数字也等于3,说明最后一个3在后面,继续在后半段查找最后一个3;
2)如果中间数字的后一个数字不等于3,说明该位置是最后一个3;
功能测试(数字出现次数为0、1、2等)
边界值测试(数组只有一个数字,查找数字为第一个或者最后一个)
特殊测试(null)
非递归写法,可以参考文章二分查找的一种变型中查找第一个与key相等的元素、查找最后一个与key相等的元素。
1 | /** |
剑指offer上的写法,不够简洁。
1 | /** |
输入两个链表,找出它们的第一个公共结点。
蛮力法:遍历第一个链表的结点,每到一个结点,就在第二个链表上遍历每个结点,判断是否相等。时间复杂度为O(m*n),效率低;
使用栈:由于公共结点出现在尾部,所以用两个栈分别放入两个链表中的结点,从尾结点开始出栈比较。时间复杂度O(m+n),空间复杂度O(m+n)。
利用长度关系:计算两个链表的长度之差,长链表先走相差的步数,之后长短链表同时遍历,找到的第一个相同的结点就是第一个公共结点。
利用两个指针:一个指针顺序遍历list1和list2,另一个指针顺序遍历list2和list1,(这样两指针能够保证最终同时走到尾结点),两个指针找到的第一个相同结点就是第一个公共结点。
功能测试(有/无公共结点;公共结点分别在链表的中间,头结点和尾结点)
特殊测试(头结点为null)
1 | /** |
1.由于有共同结点时,后面的链表是重合的,所以这道题关键是要保证最后同时遍历到达尾结点,因此就有了后面三种方法:
利用栈的先进后出实现同时到达;
利用长度关系,长链表先行几步,实现同时到达;
两个指针同时遍历两个链表,一个先list1后list2,另一个则相反,也可以实现同时到达。
在数组中的两个数字如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
如果遍历数组,对每个数字都和后面的数字比较大小,时间复杂度为O(n^2),效率太低。
利用归并排序的思想,先将数组分解成为n个长度为1的子数组,然后进行两两合并同时排好顺序。
在对两个子区域合并排序时,记左边区域(下标为start~mid)的指针为i,右边区域(下标为mid+1~end)的指针为j,两个指针都指向该区域内最大的数字,排序时:
(1)如果i指向的数字大于j指向的数字,说明:逆序对有j-mid个,我们把i指向的数字放入临时创建的排序数组中,然后令i-1,指向该区域前一个数字,继续进行排序;
(2)如果i指向的数字小于等于j指向的数字,说明暂时不存在逆序对,将j指向的数字放入临时创建的排序数组中,然后令j-1,指向该区域前一个数字,继续进行排序;
(3)某一子区域数字都放入排序数组后,将另一个子区域剩下的数字放入排序数组中,完成排序;
(4)最后将排序好的数字按顺序赋值给原始数组的两个子区域,以便合并后的区域与别的区域合并。
功能测试(普通数组,递增数组,递减数组,含重复数字)
边界值测试(数组只有两个数字,只有一个数字)
特殊测试(null)
1 | /** |
在字符串中找出第一个只出现一次的字符。如输入”abaccdeff”,则输出’b’。
创建哈希表,键值key为字符,值value为出现次数。第一遍扫描:对每个扫描到的字符的次数加一;第二遍扫描:对每个扫描到的字符通过哈希表查询次数,第一个次数为1的字符即为符合要求的输出。
由于字符(char)是长度为8的数据类型,共有256中可能,因此哈希表可以用一个长度为256的数组来代替,数组的下标相当于键值key,对应字符的ASCII码值;数组的值相当于哈希表的值value,用于存放对应字符出现的次数。
功能测试(存在/不存在只出现一次的字符;全部都为只出现一次的字符)
特殊测试(null)
1 | /** |
如果需要创建哈希表,键值为 字符,值为 数字时,可以考虑用数组(length=256)来替代,数组下标表示为字符的ASCII码值。
哈希表的时间复杂度为O(1),要求有较高的查找速度时,可以考虑使用哈希表(Java中可以使用HashMap)
如果需要判断多个字符是否在某个字符串中出现过,或者统计多个字符在某个字符串中出现的次数,可以考虑基于数组创建一个简单的哈希表,这样可以用很小的空间消耗换来时间效率的提升。
请实现一个函数用来找出字符流中第一个只出现一次的字符。例如,当从字符流中只读出前两个字符”go”时,第一个只出现一次的字符是’g’。当从该字符流中读出前六个字符”google”时,第一个只出现一次的字符是’l’。
字符只能一个一个从字符流中读出来,因此要定义一个容器来保存字符以及其在字符流中的位置。
为尽可能高效解决问题,要在O(1)时间内往数据容器中插入字符,及其对应的位置,因此这个数据容器可以用哈希表来实现,以字符的ASCII码作为哈希表的键值key,字符对应的位置作为哈希表的值value。
开始时,哈希表的值都初始化为-1,当读取到某个字符时,将位置存入value中,如果之前读取过该字符(即value>=0),将value赋值为-2,代表重复出现过。最后对哈希表遍历,在value>=0的键值对中找到最小的value,该value即为第一个只出现一次的字符,ASCII码为key的字符即为所求字符。
功能测试(读入一个字符;读入多个字符;所有字符都唯一;所有字符重复)
特殊测试(读入0个字符)
1 | /** |
流和串的区别:
1)串:字符串已经保存下来了,能够读取遍历,因此在字符串中第一个只出现一次的字符中,只需要存下每个字符出现的个数,然后直接在字符串中遍历;
2)流:字符流没有存下来,无法进行遍历,因此在本题中,只能在数据容器哈希表中遍历,而且哈希表中存放的是对应字符的位置,而不是个数。
记得会用构造函数来初始化参数;
Integer.MAX_VALUE=2^31-1,是32位操作系统(4字节)中最大的符号型整型常量。
分清楚:字符与ASCII码的转化,以及 字符形式的数字和整形数字之间的转化。
1 | public static void main(String[] args) { |
我们把只包含因子2、3和5的数称作丑数(Ugly Number)。求按从小到大的顺序的第1500个丑数。例如6、8都是丑数,但14不是,因为它包含因子7。习惯上我们把1当做第一个丑数。
直观思路:逐一判断每个整数是否为丑数,效率太低。
空间换时间的解法:
创建数组存放已经排序好的丑数,这将消耗一定的内存开销。根据丑数的定义,丑数应该是另一个丑数的2、3或者5倍的结果,因此,我们从数组中已有的丑数里找到三个丑数T2、T3、T5,它们分别和2、3、5相乘得到的值恰好比已有的最大丑数大,三个乘积中最小的一个就是下一个丑数,存放入数组中,同时更新T2、T3、T5,使它们仍然保持与2、3、5的乘积恰好比已有的最大丑数大。
功能测试(2,3,4,5等)
特殊测试(0,1)
性能测试(1500等)
1 | /** |
1 | /** |
1 | private boolean isUgly(int number) { |
请从字符串中找出一个最长的不包含重复字符的子字符串,计算该最长子字符串的长度。假设字符串中只包含从’a’到’z’的字符。
动态规划法:定义函数f(i)为:以第i个字符为结尾的不含重复字符的子字符串的最大长度。
(1)当第i个字符之前未出现过,则有:f(i)=f(i-1)+1
(2)当第i个字符之前出现过,记该字符与上次出现的位置距离为d
1)如果d<=f(i-1),则有f(i)=d;
2)如果d>f(i-1),则有f(i)=f(i-1)+1;
我们从第一个字符开始遍历,定义两个int变量preLength和curLength来分别代表f(i-1)和f(i),再创建一个长度为26的pos数组来存放26个字母上次出现的位置,即可根据上述说明进行求解。
注意:每次最大长度和字母出现位置要记得更新。
另一种思路:遍历每个字符,把当前字符看成子字符串的末尾结点,同时更新开头结点,详细代码见Longest Substring Without Repeating Characters
功能测试(一个或者多个字符,全部字符不同/相同)
特殊测试(null,空字符串)
1 | /** |
函数f(i)为:以第i个字符为结尾的不含重复字符的子字符串的最大长度。而不是以第i个字符作为开头。第i个字符作为结尾可以方便与下一个字符进行联系。
学会用长度为26的数组来存放26个字母所在的位置下标。
请从字符串中找出一个最长的不包含重复字符的子字符串,计算该最长子字符串的长度。假设字符串中只包含从’a’到’z’的字符。
动态规划法:定义函数f(i)为:以第i个字符为结尾的不含重复字符的子字符串的最大长度。
(1)当第i个字符之前未出现过,则有:f(i)=f(i-1)+1
(2)当第i个字符之前出现过,记该字符与上次出现的位置距离为d
1)如果d<=f(i-1),则有f(i)=d;
2)如果d>f(i-1),则有f(i)=f(i-1)+1;
我们从第一个字符开始遍历,定义两个int变量preLength和curLength来分别代表f(i-1)和f(i),再创建一个长度为26的pos数组来存放26个字母上次出现的位置,即可根据上述说明进行求解。
注意:每次最大长度和字母出现位置要记得更新。
另一种思路:遍历每个字符,把当前字符看成子字符串的末尾结点,同时更新开头结点,代码见leetcode03中。
这道题有一个很大的限制条件,字符串的取值为 a~z,所以用一个数组当 hash表足够,但是如果有空格就不行了。
所以可以采用一个更大的哈希表存储,最后一次出现的字符的位置 new int[256];
功能测试(一个或者多个字符,全部字符不同/相同)
特殊测试(null,空字符串)
1 | /** |
函数f(i)为:以第i个字符为结尾的不含重复字符的子字符串的最大长度。而不是以第i个字符作为开头。第i个字符作为结尾可以方便与下一个字符进行联系。
学会用长度为26的数组来存放26个字母所在的位置下标。 即哈希表。
在一个m×n的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于0)。你可以从棋盘的左上角开始拿格子里的礼物,并每次向左或者向下移动一格直到到达棋盘的右下角。给定一个棋盘及其上面的礼物,请计算你最多能拿到多少价值的礼物?
动态规划:定义f(i,j)为到达(i,j)位置格子时能拿到的礼物总和的最大值,显然有边界条件,f(i, 0) = arr[0][0] + arr[i][0], f(0, j) = arr[0][0] + arr[0][j]。则有状态转移方程:f(i,j)=max{f(i,j),f(i,j)} + arr(i,j)。
同上道题一样,如果直接使用递归会产生大量的重复计算,因此,创建辅助的数组来保存中间计算结果。
辅助数组不用和m*n的二维数组一样大,只需要保存上一层的最大值就可以。代码中使用长度为列数n的一位数组作为辅助数组,注释部分为二维辅助数组。
功能测试(多行多列,一行多列,多行一列,一行一列)
特殊测试(null)
1 |
|
分析见剑指offer书本,以及debug模式。
1 | /** |
动态规划问题,用公式来表示清楚。
动态规划如果有大量重复计算,可以用循环+辅助空间来提高效率。
这道题不用二维数组,只需要用一维数组作为辅助空间即可,以后遇到对中间结果的保存问题,看看能否优化辅助空间。
给定一个数字,我们按照如下规则把它翻译为字符串:0翻译成”a”,1翻译成”b”,……,11翻译成”l”,……,25翻译成”z”。一个数字可能有多个翻译。例如12258有5种不同的翻译,它们分别”bccfi”, “bwfi”, “bczi”, “mcfi” 和”mzi” 。请编程实现一个函数用来计算一个数字有多少种不同的翻译方法。
看到题目,很容易想到使用递归:用f(i)来表示从第i位开始的不同翻译数目,可以得到有:f(i)=f(i+1)+g(i,i+1)*f(i+2)。i和i+1位数字拼起来在10~25范围内时g(i,i+1)的值为1,否则为0。
但是存在重复的子问题,所以递归并非最佳方法,我们从数字的末尾开始计算f(i),自下而上解决问题,就可以消除重复的子问题了。先算f(len-1),f(len-2),再根据公式f(i)=f(i+1)+g(i,i+1)*f(i+2)往前逐步推导到f(0),这就是最终要求的结果。
功能测试(1个数字;多个数字)
特殊测试(负数,0,含25、26等)
1 | /** |
递归方法,我们试着用公式描述会比较清晰
递归是自上而下解决问题,如果遇到重复的子问题时,考虑自下而上求解,不用递归
g(i,i+1)不仅要判断<=25,还要判断>=10,别漏了
输入一个正整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个。例如输入数组{3, 32, 321},则打印出这3个数字能排成的最小数字321323。
不好的方法:求出所有全排列(类似字符串的排列 ),将数字拼起来,最后求出所有的最小值。这效率太低,且没有考虑到大数问题。
好的方法:观察规律,自行定义一种排序规则。
对于数字m和n,可以拼接成mn和nm,如果mn<nm,我们定义m小于n。反之则相反。利用这个排序规则,从小排到大即可实现题目要求。
拼接m和n时,要考虑到大数问题,因此将m和n拼接起来的数字转换成字符串处理。因为mn和nm的字符串位数相同,因此它们的大小只需要按照字符串大小的比较规则就可以了。
具体实现:将数字存入ArrayList中,通过利用Collections.sort(List
功能测试(1个数字;多个数字;数字数位有重复)
特殊测试(null)
1 | ** |
记住Collections.(List
小心大数问题,用字符串解决大数问题。
遇到类似排序问题,想想自定排序规则是否更加方便
数字以0123456789101112131415…的格式序列化到一个字符序列中。在这个序列中,第5位(从0开始计数)是5,第13位是1,第19位是4,等等。请写一个函数求任意位对应的数字。
逐一枚举数字,计算每个数字的位数相加,效率太低。
观察规律:
个位数的个数一共有10个,即0~9,共占了10*1位数字;
两位数的个数一共有90个,即10~99,每个数字占两位,共占了90*2位数字;
……
m位数的个数一共有910^(m-1)个,每个数字占m位,占了910^(m-1)*m位数字。
判断第n个对的数字是属于几位数,再从几位数中进行寻找。
功能测试(输入19、1000等)
边界值测试(输入0、1等)
1 | /** |
输入一个整数n,求从1到n这n个整数的十进制表示中1出现的次数。例如输入12,从1到12这些整数中包含1 的数字有1,10,11和12,1一共出现了5次。
如果是从头到尾遍历(n次),对每一个数字都计算其1的个数(lgn次),则时间复杂度为O(nlogn),运算效率太低。因此必须总结规律,提高效率。
总结规律如下(思路比《剑指OFFER》一书简单):
对于整数n,我们将这个整数分为三部分:当前位数字cur,更高位数字high,更低位数字low,如:对于n=21034,当位数是十位时,cur=3,high=210,low=4。
我们从个位到最高位 依次计算每个位置出现1的次数:
1)当前位的数字等于0时,例如n=21034,在百位上的数字cur=0,百位上是1的情况有:00100~00199,01100~01199,……,20100~20199。一共有21100种情况,即high100;
2)当前位的数字等于1时,例如n=21034,在千位上的数字cur=1,千位上是1的情况有:01000~01999,11000~11999,21000~21034。一共有21000+(34+1)种情况,即high1000+(low+1)。
3)当前位的数字大于1时,例如n=21034,在十位上的数字cur=3,十位上是1的情况有:00010~00019,……,21010~21019。一共有(210+1)10种情况,即(high+1)10。
这个方法只需要遍历每个位数,对于整数n,其位数一共有lgn个,所以时间复杂度为O(logn)。
功能测试(3,45,180等)
边界值测试(0,1等)
性能测试(输入较大的数字,如1000000等)
1 | /** |
找规律要耐心!欲速则不达。
学会提取不同位置的数字,以及更高、更低位置的数字;学会遍历每个位数的循环。
本题的动态规划解法,待完成。
输入一个整型数组,数组里有正数也有负数。数组中一个或连续的多个整/数组成一个子数组。求所有子数组的和的最大值。要求时间复杂度为O(n)。
分析规律,从第一个数字开始累加,若走到某一个数字时,前面的累加和为负数,说明不能继续累加了,要从当前数字重新开始累加。在累加过程中,将每次累加和的最大值记录下来,遍历完成后,返回该数字。
功能测试(输入数组有正有负,全负数,全正数)
特殊输入测试(null)
1 |
|
如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。
所谓数据流,就是不会一次性读入所有数据,只能一个一个读取,每一步都要求能计算中位数。
将读入的数据分为两部分,一部分数字小,另一部分大。小的一部分采用大顶堆存放,大的一部分采用小顶堆存放。当总个数为偶数时,使两个堆的数目相同,则中位数=大顶堆的最大数字与小顶堆的最小数字的平均值;而总个数为奇数时,使小顶堆的个数比大顶堆多一,则中位数=小顶堆的最小数字。
因此,插入的步骤如下:
1. 若已读取的个数为偶数(包括0)时,两个堆的数目已经相同,将新读取的数插入到小顶堆中,从而实现小顶堆的个数多一。但是,如果新读取的数字比大顶堆中最大的数字还小,就不能直接插入到小顶堆中了 ,此时必须将新数字插入到大顶堆中,而将大顶堆中的最大数字插入到小顶堆中,从而实现小顶堆的个数多一。
2. 若已读取的个数为奇数时,小顶堆的个数多一,所以要将新读取数字插入到大顶堆中,此时方法与上面类似。
功能测试(读入奇/偶数个数字)
边界值测试(读入0个、1个、2个数字)
1 | /** |
1 | PriorityQueue<Integer> maxHeap = new PriorityQueue<Integer>(11, new Comparator<Integer>(){ |
PriorityQueue的常用方法有: poll()、 offer(Object)、 size()、 peek()等。
关于堆排序,堆的下沉操作,圆满解决。
输入n个整数,找出其中最小的k个数。例如输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。
思路一:同剑指offer(39) 数组中出现次数超过一半的数字中使用partition()方法,基于数组的第k个数字调整,使得更小的k个数字都在数组左边即可。
思路二:依次遍历n个整数,用一个容器存放最小的k个数字,每遇到比容器中最大的数字还小的数字时,将最大值替换为该数字。容器可以使用最大堆或者红黑树来实现。本文根据堆排序的原理来实现。
功能测试(数组中存在/不存在重复数字)
边界值测试(k=1或者等于数组长度)
特殊测试(null、k<1、k大于数组长度)
1 | /** |
1 | /** |
本题就是对快速排序和堆排序的延伸。
k小于等于0的情况别忘记了
方法二,只需要在原始数组中进行读入操作,而所有的写操作和判断都是在容器中进行的,不用反复读取原始数组,思想非常好。
记得要弄清楚是否可以改变原始输入的数组。
partition函数:即是快速排序的基础,也可以用来查找n个数中第k大的数字。
当涉及到频繁查找和替换最大最小值时,二叉树是非常合适的数据结构,要能想到堆和二叉树。
数组中有一个数字出现的次数超过数组长度的一半,请找出这个数字。例如输入一个长度为9的数组{1, 2, 3, 2, 2, 2, 5, 4, 2}。由于数字2在数组中出现了5次,超过数组长度的一半,因此输出2。
思路一:数字次数超过一半,则说明:排序之后数组中间的数字一定就是所求的数字。
利用partition()函数获得某一随机数字,其余数字按大小排在该数字的左右。若该数字下标刚好为n/2,则该数字即为所求数字;若小于n/2,则在右边部分继续查找;反之,左边部分查找。
思路二:数字次数超过一半,则说明:该数字出现的次数比其他数字之和还多
遍历数组过程中保存两个值:一个是数组中某一数字,另一个是次数。遍历到下一个数字时,若与保存数字相同,则次数加1,反之减1。若次数=0,则保存下一个数字,次数重新设置为1。由于要找的数字出现的次数比其他数字之和还多,那么要找的数字肯定是最后一次把次数设置为1的数字。
也可以这样理解(来源:牛客网 cm问前程):
采用阵地攻守的思想:
第一个数字作为第一个士兵,守阵地;count = 1;
遇到相同元素,count++;
遇到不相同元素,即为敌人,同归于尽,count–;当遇到count为0的情况,又以新的i值作为守阵地的士兵,继续下去,到最后还留在阵地上的士兵,有可能是主元素。
再加一次循环,记录这个士兵的个数看是否大于数组一般即可。
功能测试(存在或者不存在超过数组长度一半的数字)
特殊测试(null、1个数字)
1 | /** |
length/2 用 length>>1 来代替,具有更高的效率
本题中,找到了所求数字,别忘记判断该数字的次数是否超过一半,感觉很容易忘记进行判断。
题目所要求的返回值为int,所以如果数组不满足要求时,无法通过返回值来告知是否出错,所以这道题设置了一个全局变量来进行判断。调用该方法时,需要记得对全局变量进行检查。
方法一中,采用了partition()函数,该函数会改变修改的数组,因此在面试的时候,需要和面试官讨论是否可以修改数组。
两种方法的时间复杂度均为O(n)。
输入一个字符串,打印出该字符串中字符的所有排列。例如输入字符串abc,则打印出由字符a、b、c所能排列出来的所有字符串abc、acb、bac、bca、cab和cba。(本文代码采用ArrayList
将字符串看成两部分,一部分是第一个字符,另一部分是后面的所有字符。
首先确定第一个字符,该字符可以是字符串中的任意一个;固定第一个字符后,求出后面所有字符的排列(相同步骤,采用递归)。
实现第一个字符的改变,只需要将第一个字符和后面所有字符交换即可。要记得字符串输出后,要将字符交换回来,变成原始的字符串。
使用递归每次处理一个位置,第一个位置有n种选择,第二个位置有n-1种选择
假设当前位置是index,需要把其他位置的元素放到index上,则可以将该元素和index位置上的元素交换,这样原来index位置上的元素可以作为下一轮递归函数的index候选之一
再一次循环中,交换完元素,调用递归函数,最后还需要再交换刚才的两个元素,相当于复原了当前递归函数中的str,在下一轮循环中考虑该index位置上的其他可能的选项。
功能测试(有多个重复字母的字符串、所有字符相同的字符串、一个字符或者多个字符的普通字符串)
特殊测试(字符串为null、“”)
1 | /** |
要对字符串进行修改,可以将字符串转化为字符数组进行修改,也可以考虑使用StringBuilder类。
list.contains()方法可以直接判断是否有重复字符串;Collections.sort(list)可以将list中的字符串进行排序。
字符串和字符数组间的转化:str.toCharArray() String.valueOf(strArray)
数组在递归过程中进行了交换后,最终要记得交换回来(代码最后几行)
请实现两个函数,分别用来序列化和反序列化二叉树。
一般情况下,需要采用前/后序遍历和中序遍历才能确定一个二叉树,但是其实可以只采用前序遍历(从根节点开始),将空节点(null)输出为一个特殊符号(如“$”),就可以确定一个二叉树了。
将二叉树序列化为字符串,就是前序遍历的过程,遇见空结点时,序列化为“$”,每个结点间使用逗号分隔开。
将字符串反序列化为二叉树,也使用前序遍历,遇见一个新数字(或者$)就建立一个新结点,不过需要注意的是,数字可能不只是个位数字,因此创建了一个全局Int变量index(在字符串上的移动的指针),以便于截取字符串中当前的结点值。(详见代码)
1 | /** |
记住这种序列化的方式,用于表示二叉树时非常方便。
字符串中有分割符号时,可以对字符串采用split()方法,变为字符串数组,但是自己觉得数组的保存会消耗一定的空间,因此自己定义了全局变量index,通过substring()方法来截取每一部分的字符串。
字符串的比较以后尽量用equal来比较。在对某字符串采用substring()方法得到的字符串用==判断会返回false。substring的==与equal()使用
String 转int 类型采用 int i = Integer.parseInt( s ); 不能用Integer.valueOf(s),这返回的是Integer对象。
index++的位置一定不能放错
输入一棵二叉搜索树,将该二叉搜索树转换成一个排序的双向链表,要求不能创建任何新的节点,只能调整树中节点指针。
二叉搜索树、排序链表,想到使用中序遍历。
要实现双向链表,必须知道当前节点的前一个节点。根据中序遍历,可以知道,当遍历到根节点的时候,左子树已经转化成了一个排序的链表了,根节点的前一节点就是该链表的最后一个节点(这个节点必须记录下来,将遍历函数的返回值设置为该节点即可),连接根节点和前一个节点,此时链表最后一个节点就是根节点了。再处理右子树,遍历右子树,将右子树的最小节点和根节点连接起来即可。左右子树的转化采用递归即可。
首先想象一下中序遍历的大概代码结构(先处理左子树,再处理根节点,之后处理右子树),假设左子树处理完了,就要处理根节点,而根节点必须知道左子树的最大节点,所以要用函数返回值记录下来;之后处理右子树,右子树的最小节点(也是用中序遍历得到)要和根节点链接。
1 | /** |
上面中序遍历里面if else嵌套过多,不便于阅读,进行如下修改。
1 |
|
//TODO
请实现函数ComplexListNode Clone(ComplexListNode pHead),复制一个复杂链表。在复杂链表中,每个结点除了有一个m_pNext指针指向下一个点外,还有一个m_pSibling 指向链表中的任意结点或者nullptr。
思路1:先复制结点,用next链接,最后根据原始结点的sibling指针确定该sibling结点距离头结点的位置,从而对复制结点设置sibling指针。但是该思路对于n个结点的链表,每个结点的sibling都需要O(n)个时间步才能找到,所以时间复杂度为O(n^2)
思路2:复制原始结点N创建N’,用next链接。将<N,N’>的配对信息存放入一个哈希表中;在设置sibling时,通过哈希表,只需要用O(1)的时间即可找到复制结点的sibling。该方法的时间复杂度为O(n),但空间复杂度为O(n)。
思路3:复制原始结点N创建N’,将N’链接到N的后面;根据原始结点N的sibling可以快速设置N’结点的sibling,最后将这个长链表拆分成原始链表和复制链表(根据奇偶位置)
1 | /** |
输入一棵二叉树和一个整数,打印出二叉树中节点值的和为输入整数的所有路径。从树的根节点开始一直到也借点所经过的节点形成一条路径。
假设找到了其中一条路径,达到叶结点后,由于没有指向父节点的指针,所以必须 提前创建一个链表 存储前面经过的节点。
由于从根节点出发,所以要想用到使用前序遍历。
利用链表存储节点,在该节点完成左右子树的路径搜索后(即递归函数结束,返回到其父节点后),要删除 该节点,从而记录别的路径。
具体实现:
通过前序遍历,从根节点出发,每次在链表中存储便利到的节点,若到达叶子节点,则根据所有节点的和是否等于输入的整数,判断是否打印输出。在当前节点访问结束后,递归函数将会返回到它的父节点,所以在函数退出之前,要删除链表中的当前节点,以确保返回父节点是,储存的路径刚好是从根节点到父节点。
改进:书中的代码是根据所有结点的和是否等于输入的整数,判断是否打印输出。其实没有这个必要,只需要在每次遍历到一个结点时,令目标整数等于自己减去当前结点的值,若到达根结点时,最终的目标整数等于0就可以打印输出。(描述得不是很清楚,就是相当于每个结点的目标整数不同,详见代码)
1 |
|
1 | /* |
5,7,6, 9,11,10, 8
显然左子树都比最后一个根节点8小,右子树都比根节点8大,然后是递归判断左子树和右子树。
先找到右子树的根节点即可。
输入一个整数数组,判断该数组是不是某二叉搜索树的后序遍历的结果。如果是则返回true,否则返回false。假设输入的数组的任意两个数字都互不相同。
二叉树后续遍历数组的最后一个数为根节点,剩余数字中,小于根节点的数字(即左子树部分)都排在前面,大于根节点的数字(即右子树部分)都排在后面。
根据遍历数组的这个特性,可以编写一个递归函数,用于实现题目所要求的判断功能。
1 | /** |
寻找出序列规律,就能较快得到思路。此题如果改为BST的前序遍历也是相同的思路。
对于要求处理二叉树序列的问题:找到根结点后,拆分出左右子树,对左右子树可以进行递归处理。
右子树的后续遍历序列中,父节点的下标是end-1,不是end。
(一)从上往下打印出二叉树的每一个节点,每一层的节点按照从左到右的顺序打印。
不分行从上到下打印二叉树,即二叉树的层序遍历,节点满足先进先出的原则,采用队列。每从队列取出头部节点并打印,若有子节点,把子节点放入队列尾部,直到所有节点打印完毕。
1 | /** |
(二)从上到下按层打印二叉树,同一层的结点按从左到右的顺序打印,每一层打印到一行。
同样使用队列,但是比第一题增加两个变量来给你:当前层节点数目pCount,下一层节点数目nextCount。根据当前成节点数目来打印当前层节点,同时计算下一层节点数目,之后令pCount等于nextCount,重复循环,直到打印完毕。
1 | ** |
(三)请实现一个函数按照之字形顺序打印二叉树,即第一行按照从左到右的顺序打印,第二层按照从右到左的顺序打印,第三行再按照从左到右的顺序打印,其他行以此类推。
采用两个栈,对于不同层的节点,一个栈用于正向存储,一个栈用于逆向存储,打印出来就正好是相反方向。
1 | /** |
实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。
还是画图分析,不用分析根结点,只需要分析左右子树。可以看出,左右子树刚好是呈镜像的两颗二叉树,所以:对左子树采用(父-左-右)的前序遍历,右子树采用(父-右-左)的前序遍历,遍历时判断两个结点位置的值是否相等即可。
也可以这样理解:左树的左子树等于右树的右子树,左树的右子树等于右树的左子树,对应位置刚好相反,判断两子树相反位置上的值是否相等即可。
使用递归。
1 | /** |
请完成一个函数,输入一个二叉树,该函数输出它的镜像。
先画图,可以看到用递归很容易求解:先前序遍历,对每个节点交换左右子节点。
递归使用的3个条件:1)一个问题的解可以分解成几个问题的解;2)这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样;3)存在递归终止条件。
1 | /** |
4. PriorityQueue with Generic Type (优先队列-泛型)
重点掌握大顶堆的下沉操作,尤其函数 downAdjust(int[] arr, int parentIndex, int length)的实现。
“求top K 问题”抽象成两类。一类是针对静态数据集合,也就是说数据集合事先确定,不会再变。另一类是针对静态数据集合,也就是说数据集合事先并不确定,有数据动态地加入到集合中。
针对静态数据,如何在一个包含n个数据的数组中,查找前K大数据呢?
可以维护一个大小为K的大顶堆,顺序遍历数组,从数组中去除数据与堆顶元素比较。如果比堆顶元素大,就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前K大数据了。
遍历数组需要O(n)的时间复杂度,一次堆化操作需要O(logK)的时间复杂度,所以最坏情况下,n个元素都入堆一次,时间复杂度就是O(nlogK)。
针对动态数据求得Top K就是实时Top K。一个数据集合中有两个操作,一个是添加数据,另一个询问当前的前K大数据。
如果每次询问前K大数据,我们都给予当前的数据重新计算的话,那时间复杂度就是O(nlogK),n表示当前的数据的大小。实际上,可以一直都维护一个K大小的小顶堆,当有数据被添加到集合中是,就拿它与堆顶的元素对比。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前K大数据,都可以立刻发那会给他。
动态的求数据集合的中位数。
这一小节只记录一些概念性的知识,对于具体内容查看极客时间专栏。
如果数据的个数是奇数,把数据从小到大排列,那第 n/2 + 1个数据就是中位数;如果数据是偶数的话,那么处于中间未知的数据有两个,第n/2个和第n/2 + 1个数据,这是随意取一个作为中位数。
99百分位响应时间。如果有n个数据,将数据从小到大排列之后,99百分位数大约就是第n * 99%个数据。
下面实现的是大顶堆。
堆有自我调整的操作,对于二叉堆,有如下几种操作:
下面证明,对于完全二叉树来说,下标从n/2 + 1 到 n的节点都是叶子节点? 使用反证法证明即可:
使用数组存储表示完全二叉树时,从 数组下标为1开始存储数据,数组下标为i的节点,左子节点为2i, 右子节点为2i + 1. 这个结论很重要(可以用数学归纳法证明),将此结论记为『原理1』,以下证明会用到这个原理。
如果下标为n/2 + 1的节点不是叶子节点,即它存在子节点,按照『原理1』,它的左子节点为:2(n/2 + 1) = n + 2,大家明显可以看出,这个数字已经大于n + 1,超出了实现完全二叉树所用数组的大小(数组下标从1开始记录数据,对于n个节点来说,数组大小是n + 1),左子节点都已经超出了数组容量,更何况右子节点。以此类推,很容易得出:下标大于n/2 + 1的节点肯定都是也叶子节点了,故而得出结论:对于完全二叉树来说,下标从n/2 + 1 到 n的节点都是叶子节点
数组下标为0开始存储数据,数组下标为i的节点,左子节点为2i + 1, 右子节点为2i + 2。下标为n/2 到 n - 1的节点都是叶子节点,那么最后一个非叶子节点是n/2 - 1
1 | /** |
优先级队列还是一个队列,队列的最大特性仍是先进先出,不过在优先级队列中,数据的出对顺序不是按照先进先出,而是按照优先级来,优先级最高的最先出队。
最大优先级队列,无论入队顺序如何,都是当前最大的元素优先出队。
最小优先级队列,无论入队顺序如何,都是当前最小的元素优先出队。
如何实现一个优先级队列?方法有很对,但是用堆来实现是最直接、最高效的。这是因为,堆和优先级队列非常相似,一个堆就可以看作一个优先级队列。很多时候,它们只是概念上的区分而已。
往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中去除优先级最高的元素,就相当于取出堆顶元素。
优先级队应用场景非常多,比如,哈夫曼编码、图的最短路径、最小生成树算法等等。
下面是两个应用优先级队列的例子。(选自极客时间-王争-数据结构与算法之美)
假设我们有100个小文件,每个文件的大小时100MB,每个文件中存储的都是有序的字符串。我们希望将这些100个小文件合并成一个有序的大文件。这里就会用到 优先级队列。
整体思路有点像归并排序中的合并函数。我们从这100个文件中,各取第一个字符,放入数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除。
假设,最小的字符串来自于13.txt这个小文件,我们就再从这个小文件取下一个字符串,放到数组中,重新比较大小,并且选择最小的放入合并后的大文件,将它从数组中删除。以此类推,知道所有的文件中的数据都放大文件为止。
这里我们用到数组这种数据结构,来存储从小文件中取出来的字符串。每次从数组中取最小字符串,都需要循环遍历整个数组,这种做法是低效的。
这里就可一个用到优先级队列,也就是说堆。我们将小文件中取出来的字符串放入到小顶堆中,把堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。讲这个字符串放入到大文件中,并将其从堆中删除,然后再从小文件中去除下一个字符串,放入到堆中。循环这个过程就可以把100个小文件中的数据依次入到大文件中。
删除堆顶数据和往堆中插入数据的时间复杂度都是O(logn),n表示堆中的数据个数,这里就是100,比原来的数组存储的方式高效多了。
假设有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个要触发指向的时间点。定时器美国一个很小的单位时间(比如1秒),就扫描一边任务,看是否有任务到达设定的执行时间,如果到达就拿出来执行。
但是这样每过1秒就扫描一遍任务列表的做法比较低效,主要原因有两点:第一,任务的约定执行时间离当前时间可能还有很久,这样前面很多次扫描其实都是徒劳的;第二,每次都要扫描整个任务列表,如果任务列表很大的话,势必会比较耗时。
针对这些问题,就可以用优先级队列来解决。我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。
这样,定时器就不需要每隔1秒就扫描一遍任务列表了。拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔T。
这个时间间隔T就是,从当前时间开始,需要等待多久,才会有第一个任务需要被执行。这样,定时器就可以设定在T秒之后,再来执行任务。从当前时间点到(T-1)秒时间里,定时器都不需要做任何事情。
当T秒时间过后,定时器取优先级队列中投队首的任务执行。然后再计算新的队首任务的执行时间点与当前时间点的差值,把这个值设置为定时器执行I行啊一个任务需要等待的时间。
这样,定时器既不用间隔1秒就轮询一次,也不用遍历整个任务列表,性能就提高了。
优先队列主要有入队、出队和扩容操作组成:
1 |
|
优先队列是一种特殊的队列,优先级高的数据先出队,而不再像普通的队列那样,先进先出。
实际上,堆就可以看作优先级队列,只是称谓不一样。
二叉树的遍历操作复杂度,跟节点的个数n成正比,也就是说二叉树遍历的时间复杂度是O(n)。
二叉树本身是递归定义的,相应的遍历很自然就成为一种递归问题。
写递归代码的关键,就是看能不能写出递推公式,而写递推公式的关键就是,如果解决问题A,就假设子问题B、C已经解决,然后再来看如何利用B、C来解决A。所以可以把前、中、后序遍历的递推公式都写出来。
1 | //前序遍历的递推公式: |
递归遍历操作的关键点是递归体和递归出口:
基于递归的遍历算法易于编写,操作简单,但可读性差,系统需要维护相应的工作栈,效率不是很高。
递归转化为非递归的基本思想是如何实现原本是系统完成的递归工作栈,为此,可以仿照递归执行过程中工作栈状态变化而得到。
对二叉树进行前序、中序和后序遍历时都开始于根节点或结束于根节点,经由路线也相同。彼此差别在于对节点访问时机的选择不同。三种遍历方式都是沿着左子树不断深入下去,当到达二叉树左下节点而无法往下深入时,就向上逐一返回,行进到最近深入时曾遇到节点的右子树,然后进行同样的深入和返回,直到最终从根节点的右子树返回到根节点。
这样,遍历时返回顺序与深入节点顺序恰好相反,因此可以在实现二叉树遍历过程中,使用一个工作栈来保存当前深入到的节点信息,以供后面返回需要时使用。
树的节点的定义:
1 | public class TreeNode { |
二叉树分为根节点、左子树和右子树,分别表示为 +、1、2。
遍历顺序为: 1+2 可以递增顺序显示BST中所有节点。
1 | public void inorder() { |
中序遍历的黄金口诀:当前节点(current=root)不为空,压栈,当前节点向左移动;当前节点为空,从栈中弹出一个元素,并打印该节点,当前节点向右移动;
1 | public void inorder() { |
+12 深度优先遍历法(depth-first traversal)与前序遍历法相同。
1 | public void preorder() { |
根节点入栈,栈不为空,则循环:出栈并打印节点值,右孩子节点进栈,左孩子节点进栈。
1 | public void preorder() { |
1 2 +
1 | public void postorder() { |
需要两个栈,一个栈用来模拟后续遍历顺序,另一个栈用来存储后续遍历打印顺序。
根节点入栈1,栈1不为空则循环:栈1出栈,将出栈元素存到栈2,出栈节点左孩子节点进栈,右孩子节点进栈; 打印栈2的元素
1 | public void postorder() { |
根节点先入队,然后队列不空,取出对头元素,如果左孩子存在就入队列,否则什么也不做,右孩子同理。知道队列为空,则表示树层次遍历结束。树的层次遍历,其实也是广度优先的遍历算法。
1 | public void breadthFirstTraversal() { |
输入两棵二叉树A和B,判断B是不是A的子结构。
1 | ** |
输入两个递增排序的链表,合并这两个链表并使新链表中的结点仍然是按照递增排序的。
递归实现: 合并过程中,每次都是从两个链表中找出较小的一个来链接,因此可以采用递归来实现:当任意一个链表为null时,直接连接另一个链表即可;其余情况只需要在两个链表中找出一个较小的节点进行连接,该节点的next值继续通过递归函数来链接。
非递归实现: 参考leetcode 21. Merge Two Sorted Lists 进行分类讨论即可。
1 | /** |
定义一个函数,输入一个链表的头结点,反转该链表并输出反转后链表的头结点。
参考 leetcode 206. Reverse Linked List
1 | /** |
一个链表中包含环,如何找出环的入口结点?
本题和 leetcode 141. Linked List Cycle 1 及 leetcode 142. Linked List Cycle 2相同
1 | /** |
输入一个链表,输出该链表中倒数第k个结点。为了符合大多数人的习惯,本题从1开始计数,即链表的尾结点是倒数第1个结点。例如一个链表有6个结点,从头结点开始它们的值依次是1、2、3、4、5、6。这个链表的倒数第3个结点是值为4的结点。
本题和 leetcode 19. Remove Nth Node From End of List 是同一道题,和书中是同一个思路:设置两个指针,第一个指针先遍历k-1步;从第k步开始,第二个指针指向头结点,两个节点同时往后遍历,当第一个指针到达最后一个节点时,第二个指针指向的正好是倒数第k个节点。
1 | public class KthNodeFromEnd { |
输入一个整数数组,实现一个函数来调整该数组中数字的顺序,使得所有奇数位于数组的前半部分,所有偶数位于数组的后半部分。
对于任意一个整数数组,设置一个left指针,从前往后走,如果遇到奇数则指针后移,遇到偶数时,指针停止;设置一个right指针,从前往后走,遇到偶数时指针前移,遇到奇数是,可以喝前面的指针所指的偶数进行调换。
1 | public class ReorderArray { |
如果题目附加要求:保证调整后的数组中,奇数和奇数之间,偶数和偶数之间的相对位置不变。
此时用上面的方法无法实现该功能,可以采用类似于“直接插入排序”的方法:从头开始遍历,遇到奇数时,将该奇数插入到该奇数前面的偶数之前。(如:从头开始遍历246183,遇到奇数1时,将1插入到246之前,变为:124683;该插入的实质是:奇数前面的所有偶数往后移一位,空出的位置放入该奇数)
1 | public void reorderArray1(int[] arr) { |
请实现一个函数用来判断字符串是否表示数值(包括整数和小数)。例如,字符串“+100”、“5e2”、“-123”、“3.1416”及“-1E-16”都表示数值,但“12e”、“1a3.14”、“1.2.3”、“+-5”及“12e+5.4”都不是。
具体思路参考剑指offer,需要将数字总结出规律,(A.B E/e A),按顺序进行判断,(A代表有符号整数,B代表无符号整数)
另一种思路:借助几个flag从头到尾遍历,leetcode 65. Valid Number
1 | public class NumericStrings{ |
实现一个函数用来匹配包含’.’和 ‘*‘ 的正则表达式。模式中的字符’.’表示任意一个字符,而 ‘*‘ 表示塔前面的字符可以出现忍一次(含0次)。在本题中,匹配是指字符穿的所有字符匹配整个模式。例如,字符串”aaa”与模式”a.a”和”ab*ac*a”匹配,但与”aa.a”和”ab*a”均不匹配。
使用函数matchCore(char[] str, int indexOfStr, char[] pat, int indexOfPat)来实现每一步的比较(递归)。
1 | /** |
在给定单向链表的头指针和一个节点指针,定义一个函数在O(1)时间内删除该节点。
本题缺陷,要求O(1)时间删除,相当于隐藏了一个假设:待删除的节点的确在链表中。
在单向链表中,节点没有指向前一个节点的指针,所以只好从链表的链表的头结点开始顺序查找。这样的时间复杂度为O(n),要在O(1)的时间删除节点,可以这样实现:
将待删除节点的next节点 j 的值赋值给 i ,再把 i 的指针指向 j 的下一个节点,最后删除 j ,效果等同于删除 j 。
全面考虑其他情况:
功能测试 (多个节点链表,删除头结点、中间节点和尾节点;单个节点链表)
特殊测试 (头结点或者删除节点为null)
1 | /** |
在一个排序的链表中,如何删除重复的节点?
设置一个 pre ,用于记录当前节点的前一个节点,再设置一个布尔变量 needDelete ,如果说当前节点和后一结点的值相同(记该值为dupVal),needDelete赋值为真。
当 needDelete 为真时,通过寻魂往后找到第一个不为 dupVal 的节点,把该节点设置为当前节点,并赋值给 pre.next, 即相当于完成了删除操作;而当 needDelete 为假时,把当前节点和 pre 往后移一位即可。
1 | /** |
输入数字n,按顺序打印出从1最大的n位十进制数。比如输入1,则打印出1、2、3一直到最大的3位数即999
陷阱:n过大时是大数问题,不能简单用int或者long数据输出,需要采用字符串或者数组表达大数。
解决方法:通过字符数组char[]来进行输出数字。
方法一:
在字符串表达的数字上模拟加法;
把字符串表达的数字打印出来。
方法二:
采用递归将每一位都从0到9排列出来;
把字符串表达的数字打印出来。
功能测试(输入1、2、3)
特殊测试(输入0、-1)
1 | public static void main(String[] args) { |
每次加一并打印这个数,直到最大的n位数并终止递增
1 | /** |
如果在数字前面补0.就会发现n位所有十进制其实就是n个从0到9的全排列。也就是说,我们把数字的每一位都从0到9排列一遍,就得到了所有的十进制数。
只是在打印的时候,排在前面的0不打印出来。
全排列用递归很容易表达,数字的每一位都可能是0~9中的一个数,然后设置下一位。
递归终止条件是我们已经设置了数字的最后一位。
1 | public class Print1ToMaxOfNDigit { |
1 | public class Print1ToMaxOfNDigit { |
本章开始将关注代码的质量,关注功能测试、边界测试和负面(错误测试),写出完整的代码。
实现double Power(double base, int exponent),求base的exponent次方,不得使用库函数,同时不需要考虑大数问题。
这道题很容易实现,但是需要注意以下陷阱:1)0的负数次方不存在;2)0的0次方没有数学意义;3)要考虑exponent为负数的情况。所以可以对exponent进行分类讨论,再对base是否为0进行讨论。
1 | /** |
上面的powerCore()方法可改写成如下:
1 | private double powerCore(double base, int exponent) { |
Summary of Sorting Algos(排序总结)
Last Modified: 2019/5/30 20:36 by dp
01. Implement Queue by Array(用数组实现队列)
02. Implement Queue by LinkedList(用链表实现队列)
03. Implement Queue using Stacks(用栈实现队列)
04. Implement Circular Queue using Array(用数组实现循环队列)
05. Design Circular Deque(设计一个双端队列)
设计实现双端队列。
你的实现需要支持以下操作:
MyCircularDeque(k):构造函数,双端队列的大小为k。
insertFront():将一个元素添加到双端队列头部。 如果操作成功返回 true。
insertLast():将一个元素添加到双端队列尾部。如果操作成功返回 true。
deleteFront():从双端队列头部删除一个元素。 如果操作成功返回 true。
deleteLast():从双端队列尾部删除一个元素。如果操作成功返回 true。
getFront():从双端队列头部获得一个元素。如果双端队列为空,返回 -1。
getRear():获得双端队列的最后一个元素。 如果双端队列为空,返回 -1。
isEmpty():检查双端队列是否为空。
isFull():检查双端队列是否满了。
示例:
MyCircularDeque circularDeque = new MycircularDeque(3); // 设置容量大小为3
circularDeque.insertLast(1); // 返回 true
circularDeque.insertLast(2); // 返回 true
circularDeque.insertFront(3); // 返回 true
circularDeque.insertFront(4); // 已经满了,返回 false
circularDeque.getRear(); // 返回 2
circularDeque.isFull(); // 返回 true
circularDeque.deleteLast(); // 返回 true
circularDeque.insertFront(4); // 返回 true
circularDeque.getFront(); // 返回 4
提示:
所有值的范围为 [1, 1000]
操作次数的范围为 [1, 1000]
请不要使用内置的双端队列库。
1 | /** |
队列跟栈一样,也是操作受限的线性表结构。 队列最基本的操作也是两个:入队enqueue(),放一个数据到队列尾部;出队dequeue(),从队列头部取一个元素。**
队列的应用非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列。他们在很多偏底层系统、框架、中间件的开发中,骑着关键性的作用。比如高性能队列Disruptor、Linux环形缓存,都用到了循环并发队列;Java concurrent并发包利用ArrayBlockingQueue来实现公平锁等。
实际的项目,不可能从零实现一个队列,甚至都不会直接用到。而具有特殊特性的队列应用却比较广泛,比如阻塞队列和并发队列。
阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,知道队列中有空闲位置后再插入数据,然后再返回。
线程安全的队列叫做 并发队列,对简单直接的实现方式就是直接在enqueue()、dequeue()方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操所。实际上,基于数组的循环队列,利用CAS原子操作,可以实现非常高效的并发队列。
这也是循环队列比链式队列应用更加广泛的原因。
线程池没有空闲线程时,新的任务请求线程资源是,线程池该如何处理?这种处理策略又是如何实现的呢?
一半有两种处理策略。第一种是非阻塞的处理方式,直接拒绝任务请求;另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,去除排队的请求继续处理。
基于链表的实现方式,可以实现一个支持无限排队的无界队列,但是可能会导致过多的请求队列等待,请求处理的相应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。
而基于数组实现的有界队列,队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就像对更加合理。
分布式应用中的消息队列,也是一种队列结构。
考虑使用CAS实现无锁队列,在入队前,获取tail位置,入队是比较tail是否发生变化,如果否,则允许入队,反之,本次入队失败,出队则是获取head位置,进行cas。
对于队列,需要两个指针:一个是head指针,指向对头;一个是tail指针,指向队尾。随着不停地进行入队、出队操作,head和tail都会持续往后移动。当tail移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。
一种方法是,对于入队enqueue(),进行判断队列末尾是否有空间,然后将数据整体搬移一次。
1 | public boolean enqueue(String item) { |
1 | /** |
基于链表的实现,同样需要两个指针:head指针和tail指针。分别指向链表的第一个节点和最后一个节点。入队时,tail.next = newNode, tail = tail.next; 出队时,head = head.next;
1 | /** |
在用数组实现队列的时候,在tail=n时,会有数据搬移,这样入队操作性能就会受到影响。
循环队列最关键的是,确定好队空和队满的判定条件。
对于数组实现的非循环队列中,队满的判断条件是tail == n,队空的判断条件时head == tail。
针对循环队列,队空的判断条件是head == tail。但是队满的判断条件是 (tail + 1) % n == head 。
队满时tail指向的位置实际上是没有存储数据的,所有,循环队列会浪费一个数组空间。
1 | /** |
1 | package Queue; |
Last Modified: 2019/5/30 20:36 by dp
01. Implement Stack by Array(数组实现栈)
02. Implement Stack by LinkedList(链表实现栈)
03. Implement Sample Browser by Stack(用栈模拟浏览器)
05. Longest Valid Parentheses(最长有效括号)
06. Evaluate Reverse Polish Notatio(逆波兰表达式求值)
根据逆波兰表示法,求表达式的值。
有效的运算符包括 +, -, *, / 。每个运算对象可以是整数,也可以是另一个逆波兰表达式。
说明:
整数除法只保留整数部分。 给定逆波兰表达式总是有效的。换句话说,表达式总会得出有效数值且不存在除数为 0 的情况。 示例 1:
输入: [“2”, “1”, “+”, “3”, ““]
输出: 9
解释: ((2 + 1) 3) = 9
示例 2:
输入: [“4”, “13”, “5”, “/“, “+”]
输出: 6
解释: (4 + (13 / 5)) = 6
示例 3:
输入: [“10”, “6”, “9”, “3”, “+”, “-11”, ““, “/“, ““, “17”, “+”, “5”, “+”]
输出: 22
解释:
((10 (6 / ((9 + 3) -11))) + 17) + 5
= ((10 (6 / (12 -11))) + 17) + 5
= ((10 (6 / -132)) + 17) + 5
= ((10 0) + 17) + 5
= (0 + 17) + 5
= 17 + 5
= 22
1 | /** |
给定一个只包含 ‘(‘ 和 ‘)’ 的字符串,找出最长的包含有效括号的子串的长度。
示例 1:
输入: “(()”
输出: 2
解释: 最长有效括号子串为 “()”
示例 2:
输入: “)()())”
输出: 4
解释: 最长有效括号子串为 “()()”
使用栈,本题用了栈的三个操作。
要记住,字符串等价于字符数组,字符串的每个字符都是可以等价于字符数组,所以可以利用数组的性质。
1 | ** |
写完代码读一下,检查一下错误,这次有两处错误,1)s.charAt(i) == ‘()’
2)max = Math.max(max, i - peek());
给定一个只包括 ‘(‘,’)’,’{‘,’}’,’[‘,’]’ 的字符串,判断字符串是否有效。
有效字符串需满足:
左括号必须用相同类型的右括号闭合。 左括号必须以正确的顺序闭合。 注意空字符串可被认为是有效字符串。
示例 1:
输入: “()”
输出: true
示例 2:
输入: “()[]{}”
输出: true
示例 3:
输入: “(]”
输出: false
示例 4:
输入: “([)]”
输出: false
示例 5:
输入: “{[]}”
输出: true
1 | /** |
浏览器的前进、后退功能。
每次一次访问完一串页面a-b-c后,点击浏览器的后退按钮,就可以查看之前浏览过的页面b和a。当你后退到页面a,点击前进按钮,就可以重新查看页面b和c。但是,如果你后退到页面b后,点击了新的页面d,那就无法再通过前进、头腿功能查看页面c了。
现在来实现这个功能,使用链式栈,用backStack、currentPage、forwardStack来存储这些页面。
1 | ** |
当某个数据集合致设计在一端插入和删除数据,并且满足后进先出、先进后出的特性,我们就应该首选“栈”这种数据结构。
栈主要包括两个操作,入栈和出栈,在栈顶插入一个数据和从栈顶删除一个数据。
栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,就做顺序栈,用链表实现的栈,叫链式栈。
不管是顺序栈还是链式栈,我们存储数据只需要一个大小为n的数组就够了。在入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是O(1)。
注意,这里存储数据需要一个大小为n的数组,并不是说空间复杂度就是O(n)。因为这个空间是必须的,无法省掉。所以我们说空间复杂度的时候,是指除了原本的数据存储空间外,算法运行还需要额外的存储空间。
栈的一些应用:
关于用函数调用栈来保存临时变量,为什么函数调用要用“栈”来保存变量呢?用其他数据结构不行吗?
解答: 其实,不一定非要栈来保存临时变量,只不过如果这个函数调用符合后进先出的特性,用栈这种数据结构来实现,是最顺理成章的选择。
从调用函数进入被调用函数,对于数据来说,变化的是什么?是作用域。所以根本上,只要能保证每进入一个新的函数,都是一个新的作用域就可以。而要实现这个,用栈就非常方便。在进入被调用函数的时候,分配一段栈空间给这个函数的变量,在函数结束的时候,将栈顶复位,正好回到调用函数的作用域内。
支持动态扩容的顺序栈,底层依赖一个支持动态扩容的数组就可以了。当栈满了,就申请一个更大的数组,将原来的数据搬移到新数组中。
实际上,支持动态扩容的顺序栈,平时开发并不常用到。
出栈的时间复杂度是O(1),那入栈操作的 均摊时间复杂度是O(1)。 关于分析,参考极客时间08|栈
1 | package Stack; |
1 | package Stack; |
1 | package Stack; |
Last Modified: 2019/5/28 09:24 by dp
02. Linked List Cycley Ⅰ(检测链表是否有环Ⅰ)
03. Linked List Cycley Ⅱ(检测链表是否有环Ⅱ)
04. Merge Two Sorted Lists(合并两个有序链表)
05. Reverse Nodes in k-Group(以 k 为一组反转链表)
06. Remove Nth Node From End of List(删除链表的倒数第N个节点)
07. Middle of the Linked List(链表的中间节点)
08. Merge k Sorted Lists(合并 k 个排序链表)
合并 k 个排序链表,返回合并后的排序链表。请分析和描述算法的复杂度。
示例:
输入:
[
1->4->5,
1->3->4,
2->6
]
输出: 1->1->2->3->4->4->5->6
1 |
|
失误的地方
给定一个带有头结点 head 的非空单链表,返回链表的中间结点。
如果有两个中间结点,则返回第二个中间结点。
示例 1:
输入:[1,2,3,4,5]
输出:此列表中的结点 3 (序列化形式:[3,4,5])
返回的结点值为 3 。 (测评系统对该结点序列化表述是 [3,4,5])。
注意,我们返回了一个 ListNode 类型的对象 ans,这样:
ans.val = 3, ans.next.val = 4, ans.next.next.val = 5, 以及 ans.next.next.next = NULL.
示例 2:
输入:[1,2,3,4,5,6]
输出:此列表中的结点 4 (序列化形式:[4,5,6])
由于该列表有两个中间结点,值分别为 3 和 4,我们返回第二个结点。
提示:
给定链表的结点数介于 1 和 100 之间。
头结点误导人
快慢指针: 1.慢指针一次走一步,快指针一次走2步,快指针走到末端,慢指针正好指向中间结点; 2.这里分两种情况:
快指针的next为null,慢指针正好指向中间结点,链表结点数为偶数;
快指针为null,慢指针正好指向第二个中间结点,链表结点数为奇数;
1 | /** |
给定一个链表,删除链表的倒数第 n 个节点,并且返回链表的头结点。
示例:
给定一个链表: 1->2->3->4->5, 和 n = 2.
当删除了倒数第二个节点后,链表变为 1->2->3->5.
说明:
给定的 n 保证是有效的。
进阶:
你能尝试使用一趟扫描实现吗?
使用fast指针先远离头结点 n-1 的位置,然后slow指针和fast再同时移动到链表尾部,此时slow指针的位置就是倒数第n个节点,提前保存slow的prev节点,然后prev.next = prev.next.next即可完成任务。
注意:
1 | /** |
给出一个链表,每 k 个节点一组进行翻转,并返回翻转后的链表。
k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么将最后剩余节点保持原有顺序。
示例 :
给定这个链表:1->2->3->4->5
当 k = 2 时,应当返回: 2->1->4->3->5
当 k = 3 时,应当返回: 3->2->1->4->5
说明 :
你的算法只能使用常数的额外空间。 你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
思路见注释
1 | /** |
将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例:
输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4
1 | ** |
脑袋要清楚知道指针是如何移动的!
给定一个链表,判断链表中是否有环。
为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。
示例 1:
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
示例 2:
输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。
示例 3:
输入:head = [1], pos = -1
输出:false
解释:链表中没有环。
进阶:
你能用 O(1)(即,常量)内存解决此问题吗?
例如,我们还没有考虑过快速跑步者落后慢跑者两到三步的情况。 这很简单,因为在下一次或下次的下一次迭代中,这种情况将简化为上面的情形A。
证明方法采用了,归纳法。
1 | /** |
给定一个链表,判断链表中是否有环。
为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。
说明:不允许修改给定的链表。
示例 1:
输入:head = [3,2,0,-4], pos = 1
输出:tail connects to node index 1
解释:链表中有一个环,其尾部连接到第二个节点。
示例 2:
输入:head = [1,2], pos = 0
输出:tail connects to node index 0
解释:链表中有一个环,其尾部连接到第一个节点。
示例 3:
输入:head = [1], pos = -1
输出:no cycle
解释:链表中没有环。
进阶:
你能用 O(1)(即,常量)内存解决此问题吗?
分两个步骤,首先通过快慢指针的方法判断链表是否有环;接下来如果有环,则寻找入环的第一个节点。
具体的方法为,首先假定链表起点到入环的第一个节点A的长度为a【未知】,到快慢指针相遇的节点B的长度为(a + b)【这个长度是已知的】。
现在我们想知道a的值,注意到快指针 fast 始终是慢指针 slow 走过长度的2倍,所以慢指针 slow 从B继续走(a + b)又能回到B点,如果只走a个长度就能回到节点A。
但是a的值是不知道的,解决思路是曲线救国,注意到起点到A的长度是a,那么可以用一个从起点开始的新指针head和从节点B开始的慢指针slow同步走,相遇的地方必然是入环的第一个节点A。
画个图就一目了然了~
1 | /** |
反转一个单链表。
示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
进阶: 你可以迭代或递归地反转链表。你能否用两种方法解决这道题?
有很多种写法:
1)迭代方式的解答
为了完成这个任务,需要记录链表中三个连续的节点:reverse、head、second。在每一轮迭代中,从原始链表中提取节点head并将它插入到逆链表的开头。我们需要一直保持head指向原链表中所有剩余节点的首节点,second指向原链表中所有剩余节点的第二个节点,reverse指向结果链表中的首节点。
2)假设链表有N个节点,首先递归颠倒最后N-1个节点,然后小心地将原链表的首节点插入到结果链表的末端。
迭代和递归写法,见代码注释。
1 | /** |
自己实现动态数组:
1 | /** |
1 | /** |
Last Modified: 2019/5/27 11:09 by dp
03. Majority Element(在数组中出现次数超过一半的数)
05. Merge Sorted Array(合并两个有序数组)
06. First Missing Positive(寻找缺失的最小正数)
07. Kth Largest Element in an Array(查找第K大的数)
09. Find All Duplicates in an Array(数组中重复的数字)
给定一个整数数组 a,其中1 ≤ a[i] ≤ n (n为数组长度), 其中有些元素出现两次而其他元素出现一次。
找到所有出现两次的元素。
你可以不用到任何额外空间并在O(n)时间复杂度内解决这个问题吗?
示例:
输入:
[4,3,2,7,8,2,3,1]
输出:
[2,3]
1 | /** |
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
说明:
你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?
示例 1:
输入: [2,2,1]
输出: 1
示例 2:
输入: [4,1,2,1,2]
输出: 4
1 |
|
在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例 1:
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5
示例 2:
输入: [3,2,3,1,2,4,5,5,6] 和 k = 4
输出: 4
说明:
你可以假设 k 总是有效的,且 1 ≤ k ≤ 数组的长度。
1 | /** |
给定一个未排序的整数数组,找出其中没有出现的最小的正整数。
示例 1:
输入: [3,4,-1,1]
输出: 2
示例 3:
输入: [7,8,9,11,12]
输出: 1
说明:
你的算法的时间复杂度应为O(n),并且只能使用常数级别的空间。
整个的思路就是把nums[i]存储的数放到,下标为nums[i]的位置,处理小于等于0和大于数组长度的nums[i],交换nums[i]到下标nums[i]是做一下取负值处理,离开的位置i不做处理,把整个数组处理一遍后,遍历数组找到第一个正数所对应的位置,就是第一个缺失的正数。
1 |
|
给定两个有序整数数组 nums1 和 nums2,将 nums2 合并到 nums1 中,使得 num1 成为一个有序数组。
说明:
初始化 nums1 和 nums2 的元素数量分别为 m 和 n。
你可以假设 nums1 有足够的空间(空间大小大于或等于 m + n)来保存 nums2 中的元素。
示例:
输入:
nums1 = [1,2,3,0,0,0], m = 3
nums2 = [2,5,6], n = 3
输出: [1,2,2,3,5,6]
1 | /** |
写一个程序,输出从 1 到 n 数字的字符串表示。
如果 n 是3的倍数,输出“Fizz”;
如果 n 是5的倍数,输出“Buzz”;
3.如果 n 同时是3和5的倍数,输出 “FizzBuzz”。
示例:
n = 15,
返回:
[
“1”,
“2”,
“Fizz”,
“4”,
“Buzz”,
“Fizz”,
“7”,
“8”,
“Fizz”,
“Buzz”,
“11”,
“Fizz”,
“13”,
“14”,
“FizzBuzz”
]
1 | /** |
给定一个大小为 n 的数组,找到其中的众数。众数是指在数组中出现次数大于 ⌊ n/2 ⌋ 的元素。
你可以假设数组是非空的,并且给定的数组总是存在众数。
示例 1:
输入: [3,2,3]
输出: 3
示例 2:
输入: [2,2,1,1,1,2,2]
输出: 2
1 | ** |
O(n) time and O(n) space
O(n) time and O(1) space 使用 major 变量记录众数,count 记录遇到 major +1,非 major -1,最终 count 会大于0,major 即代表众数。
给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0?找出所有满足条件且不重复的三元组。
注意:答案中不可以包含重复的三元组。
例如, 给定数组 nums = [-1, 0, 1, 2, -1, -4],
满足要求的三元组集合为: [ [-1, 0, 1], [-1, -1, 2] ]
1 | import java.util.ArrayList; |
给定一个整数数组nums和一个目标值target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。
1 | import java.util.*; |
下面的代码来自于 精通Spring 4.x企业应用开发实战 P224
业务逻辑实现类的代码,省去ForumService接口类和PerformanceMonitor的代码。
1 | public class ForumServiceImpl implements ForumService { |
将业务类中性能监视横切代码移除后,放置到InvocationHandler中,代码如下。
1 | import java.lang.reflect.Method; |
invoke(Object proxy, Method method, Object[] args)方法,其中,proxy是最终生成的代理实例,一般不会用到;method是被代理目标实例的某个具体方法,通过它可以发起目标实例方法的反射调用;args是被代理实例某个方法的入参,在方法反射时调用。
其次,在构造参数里通过target传入希望被代理的目标对象,在接口方法invoke(Object proxy, Method method, Object[] args)里,将目标实例传递给method.invoke()方法,并调用目标实例的方法。
下面通过Proxy结合PerformanceHandler创建ForumService接口的代理实例。
1 | import java.lang.reflect.Proxy; |
Proxy.newProxyInstance() 方法的第一个入参为类加载器;第二个入参为创建代理实例所需实现的一组接口;第三个入参是整合了业务逻辑和横切逻辑的编织器对象。
以下代码来自于 Java EE 互联网轻量级框架整合开发
在动态代理中必须使用接口,CGLib不需要。
下面的代码分别是简单的接口和被代理类的定义。
1 | // 接口 |
要实现动态代理要两个步骤,首先,建立起代理对象和被代理对象的关系(将目标业务类和横切代码编织到一起),然后实现代理逻辑。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26import java.lang.reflect.Proxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class JdkProxyExample implements InvocationHandler {
private Object target = null;
public Object bind(Object target) {
this.target = target;
return Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().gerInterfaces(),
this);
}
public Object invoke(Object proxy, Method method,
Object[] args) throws Throwable {
System.out.println("before...");
Object obj = method.invoke(target, args);
System.out.println("after...");
return obj;
}
}
1 | public class JdkProxyExampleTest { |
bind方法同时完成了两步。
使用JDK创建代理有一个限制,即只能为接口创建代理。Proxy的接口方法中newProxyInstance(ClassLoader loder, Class[] interfaces, InvocationHandler hander),第二个入参就是需要代理实例实现的接口列表。
假如对一个简单业务表的操作也需要创建5个类(领域对象、DAO接口、DAO实现类、Service接口和Service实现类)吗?
对于没有通过接口定义业务方法的类,可以使用CGLib动态创建代理实例。
CGLib采用底层的字节码技术,可以为一个类创建子类,在子类中采用方法拦截的技术拦截父类方法的调用并顺势织入横切逻辑。
值得一提的是,由于CGLib采用动态创建子类的方式生成代理对象,所以不能对目标类中的final或private方法进行代理。
下面代码可以创建,为任何类织入性能监视横切逻辑代理对象的代理创建器。
1 | import java.lang.reflect.Method; |
用户可以通过getProxy(Class cla)方法动态创建一个动态代理类。
1 | import java.lang.reflect.Proxy; |
1 | public interface Interceptor { |
1 | public class MyInterceptor implements Interceptor { |
1 | public class InterceptorJdkProxy implements InvocationHandler { |
其实二进制的位运算不是很难掌握,因为位运算总共只有7种运算:与、或、非(取反)、异或、左移位、带符号位右移位和无符号位右移位。
位操作符仅适用于整数类型(byte、short、int和long)。位操作涉及的字符将转换为整数。所有的位操作符可以构成位赋值操作符,如 &=, |=, ~=, ^=, <<=, >>=, >>>=。
对于复杂一点的位操作,可以使用若干技巧来解决。假定操作数的位宽为4。
(1)0110 + 0110
相当于 0110 * 2,也就是将0110左移1位。
(2)0100 0011
0100相当于4,上面就等于 4 0011,也就是2^2,于是将0011左移2位得到1100。
**一个数与2^n相乘,相当于将这个数左移n位。
(3)1101 ^ (~1101)
逐比特分解这个操作。一个比特与对它去烦的数值做异或操作,结果总是1。因此 x ^ (~x)的结果是一串1。
(4)1011 & (~0 << 2)
类似 x & (~0 << n)的操作会将x最右边的n位清零。 ~0的值是一串1(0在内存中为0x00000000,故取反后为一串1),将它左移n位后的结果为一串1后面跟n个0。将这个数与x进行“位与”操作,相当于将x最右边的n位清零。
处理位操作问题时,理解下面的原理会有很大帮助。下面的示例中,“1s”和“0s”分别表示一串1和一串0。
异或
x ^ 0s = x x ^ 1s = ~x x ^ x = 0
与
x & 0s = 0 x & 1s = x x & x = x
或
x | 0s = x x | 1s = 1s x | x = x
要理解这些表达式的含义,需要记住 所有操作都是按位进行的,某一位的运算结果不会影响其余位。
清零取数要用与,某位置一可用或
若要取反和交换,轻轻松松用异或
常见操作有:获取、设置、清除及更新位数据
以下这些位操作很重要,不过不要死记硬背,否则会滋生一些难以觉察的错误,相反,要吃透这些操作方法,学会举一反三,灵活处理问题。
该方法将1左移i位,接着,对这个值与num执行“位与”操作,从而将i位之外的所有位清零。最后,检查该结果是否为零。不为零说明i位为1,否则,i位为0。
n & (1 << (k - 1)) 第k位置为1
1 | boolean getBit(int num, int i) { |
将1左移i位,然后对这个值和num执行“位或”操作,这样只会改变i位的数据。该掩码i位除外的位均为零,孤儿不会影响num的其余位。
1 | int setBit(int num, int i) { |
将给定操作数n的第k位清零,可以用表达式 n & ~(1 << (k-1))
将掩码和num执行位与操作,这样只会清零num的i位,其余位保持不变。1
2
3
4int clearBit(int num, int i) {
int mask = ~(1 << i);
return num & mask;
}
将num最高位至i位(含)清零的做法如下:1
2
3
4int clearBitsMSBthroughI(int num, int i) {
int mask = (1 << i) - 1;
return num & mask;
}
将i位至0位(含)清零的做法:1
2
3
4int clearBitsIthrough0(int num, int i) {
int mask = ~((1 << (i + 1)) - 1);
return num & mask;
}
这个方法将setBit与clearBit合二为一。首先,用诸如11101111的掩码将num的第i位清零。接着,将带写入值val左移i位,得到一个i位为val但其余位都为0的数。最后,对之前去的的两个结果执行“位或”操作
,val为1则将num的i位更新为1,否则该位仍为0。
1 | int updateBit(int num, int i, int val) { |
(A & B) == 0 的含义
意思是,A和B二进制表示的同一位置绝不会同时为1。
因此,如果n & (n - 1) == 0,则 n 和 n-1 就不会有共同的1。
表达式 (n & (n - 1) == 0) 是用来检查n是否为2的某次方(或者检查n是否为0)。
请实现一个函数,输入一个整数,输出该数二进制表示中1的个数。例如把9表示成二进制是1001,有2位是1。因此如果输入9,该函数输出2。
遇到与二进制有关的题目,应该想到位运算(与、或、异或、左移、右移)。
方法一:“位与”有一个性质:通过与对应位上为1,其余位为0的数进行与运算,可以判断某一整数指定位上的值是否为1。
这道题中,先把整数n与1做与运算,判断最低位是否为1;接着把1左移一位,与n做与运算,可以判断次低位是否为1……反复左移,即可对每一个位置都进行判断,从而可以获得1的个数。这种方法需要循环判断32次。
方法二(better):
如果一个整数不为0,把这个整数减1,那么原来处在整数最右边的1就会变为0,原来在1后面的所有的0都会变成1。其余所有位将不会受到影响。
再把原来的整数和减去1之后的结果做与运算,从原来整数最右边一个1那一位开始所有位都会变成0。因此,把一个整数减1,再和原来的整数做与运算,会把该整数最右边的1变成0。
这种方法,整数中有几个1,就只需要循环判断几次。
1.正数(包括边界值1、0x7FFFFFFF)
2.负数(包括边界值0x80000000、0xFFFFFFFF)
3.0
1 | public class NumberOf1InBinary { |
把一个整数减1,再和原来的整数做与运算,会把该整数最右边的1变成0。这种方法一定要牢牢记住,很多情况下都可能用到,例如:
与数字操作有关的题目,测试时注意边界值的问题。对于32位数字,其正数的边界值为1、0x7FFFFFFF,负数的边界值为0x80000000、0xFFFFFFFF。
编写一个函数,确定需要改变几个位,才能将整数A转成整数B。
这道题可以分两步解决:第一步求这两个数的异或;第二步统计异或结果中1的位数。
只要输出a^b有几个位为1即可。1
2
3
4
5
6
7int bitSwapRequired(int a, int b) {
int count = 0;
for (int c = a ^ b; c != 0; c >>= 1) {
count += c & 1;
}
return count;
}
上面的代码的做法是不断对c执行移位操作,然后检查最低有效位,但是其实可以不断翻转最低有效位,计算要多少次才会变成0。操作c = c & (c - 1)会清楚c的最低有效位。
1 | public static int bitSwapRequired(int a, int b) { |
给你一根长度为n绳子,请把绳子剪成m段(m、n都是整数,n>1并且m≥1)。
每段的绳子的长度记为k[0]、k[1]、……、k[m]。k[0]·k[1]·…·k[m]可能的最大乘积是多少?例如当绳子的长度是8时,我们把它剪成长度分别为2、3、3的三段,此时得到最大的乘积18。
本题采用动态规划或者贪婪算法可以实现。一开始没有思路时,可以从简单的情况开始想,试着算以下比较短的绳子是如何剪的。
当n=1时,最大乘积只能为0;
当n=2时,最大乘积只能为1;
当n=3时,最大乘积只能为2;
当n=4时,可以分为如下几种情况:1·1·1·1,1·2·1,1·3,2·2,最大乘积为4;
往下推时,发现n≥4时,可以把问题变成几个小问题,即:如果把长度n绳子的最大乘积记为f(n),则有:f(n)=max(f(i) · f(n - 1)),0 < i < n。
所以思路就很容易出来了:自底向上,先算小的问题,再算大的问题,大的问题通过寻找小问题的最优组合得到。
贪婪算法依赖于数学证明,当绳子大于5时,尽量多地剪出长度为3的绳子是最优解。
1 | public class CuttingRope { |
最优解问题,经常使用动态规划法,关键要刻画最优解的结构特征(本题的f(n)),从下往上计算最优解的值,没有思路时,从简单情况先算一下。
动态规划法中,子问题的最优解一般存放于一个数组中。
本题贪婪规划的代码中,timeOf2别忘记等于1的情况。
复习时补充:
动态规划法可以直接令 f(n)=max{f(n-2)2,f(n-3)3} 就可以了。
贪婪算法,核心部分可改为
1 | int timesOf3 = n / 3; |
地上有一个m行n列的方格。一个机器人从坐标(0, 0)的格子开始移动,它每一次可以向左、右、上、下移动一格,但不能进入行坐标和列坐标的数位之和大于k的格子。
例如,当k为18时,机器人能够进入方格(35, 37),因为3+5+3+7=18。但它不能进入方格(35, 38),因为3+5+3+8=19。请问该机器人能够到达多少个格子?
与剑指offer(12) 矩阵中的路径类似,也采用回溯法,先判断机器人能否进入(i,j),再判断周围4个格子。
不同的是,这题返回的是int值。
递归回溯本质上是一种枚举法,可以看成蛮力法的升级版。回溯法用于多个步骤,每个步骤都有多个选项的问题:若当前步骤满足条件,给定一个标记,当发现之后的步骤不满足条件时,去除标记。
已验证代码正确性,测试部分去除。
1 | public class RobotMove { |
请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。
路径可以从矩阵中任意一格开始,每一步可以在矩阵中向左、右、上、下移动一格。如果一条路径经过了矩阵的某一格,那么该路径不能再次进入该格子。
例如在下面的3×4的矩阵中包含一条字符串“bfce”的路径(路径中的字母用下划线标出)。但矩阵中不包含字符串“abfb”的路径,因为字符串的第一个字符b占据了矩阵中的第一行第二个格子之后,路径不能再次进入这个格子。
A B T G
C F C S
J D E H
首先对整个矩阵遍历,找到第一个字符,然后向上下左右查找下一个字符,由于每个字符都是相同的判断方法(先判断当前字符是否相等,再向四周查找),因此采用 递归回溯。
由于字符查找过后不能重复进入,所有还要定义一个字符矩阵相同大小的 布尔值矩阵,进入过的格子标记为true。如果不满足的情况下,需要进行 回溯,此时,要将当前位置的布尔值标记回false。
(所谓的回溯无非就是对使用过的字符进行标记和对处理后的字符去标记)
递归回溯本质上是一种枚举法,可以看成蛮力法的升级版。回溯法用于多个步骤,内个步骤都有多个选项的问题:若当前步骤满足条件,给定一个标记,当发现之后的步骤不满足条件时,去除标记。
1 | public class StringPathInMatrix { |
tips:
在面试时,如果面试官要求实现一个排序算法,那么一定要问清楚这个排序应用的环境是什么、有哪些约束条件。
数组在一定程度上是排序的,很容易分析出:可以采用二分法来寻找最小数字。
把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。
输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。
例如数组 {3, 4, 5, 1, 2} 为 {1, 2, 3, 4, 5} 的一个旋转,该数组的最小值为1。
数组在一定程度上是排序的,很容易分析出:可以采用二分法来寻找最小数字。
但是这里面有一些陷阱:
递增排序数组的本身是自己的旋转,则最小数字是第一个数字
中间数字 与 首尾数字 大小相等,如 {1, 0, 1, 1, 1, 1} 和 {1, 1, 1, 1, 0, 1},无法采用二分法,只能顺序查找。
1 | public class MinNumberInRotateArray { |
下面是测试代码。
1 | public class MinNumberInRotateArray { |
1 | public class Solution{ |
不好的地方:
该程序在array[mid] = array[high]时直接顺序查找。但其实这还有可能可以用二分法的,除非还满足array[mid] = array[low],才只能使用顺序查找。
所以可以先排除掉必须顺序查找的情况(类似自己上面的程序,提前判断掉),之后就可以直接删除else if(array[mid] == array[high]){high = high - 1;这两行了。
缺少null的判断。
如果我们需要重复地多次计算相同的问题,则通常可以选择用递归或循环两种不同的方法。 递归就是把问题层层分解,直到程序出口处。而循环则是通过设置计算的初始值及终止条件,在一个范围内重复运算。
递归虽然有简洁的优点,但它同时也有显著的缺点。递归由于是函数调用自身,而函数调用是有时间和空间的消耗的:每一次函数调用,都需要在内存栈中分配空间以保存参数、返回地址及临时变量,而且往栈里压入数据和弹出数据都需要时间。
另外,递归中有可能很多计算都是重复的,从而对性能带来很大的负面影响。递归的本质是把一个问题分解成两个或者多个小问题。如果多个小问题存在相互重叠的部分,就存在重复的计算。
通常应用动态规划解决问题时,我们都是用递归的思路分析问题,但由于递归分解的子问题中存在大量的重复,因此我们总是用自上而下的循环来实现代码。
除了效率,递归还有可能引起更严重的问题:调用栈溢出。每一次函数调用在内存栈中分配空间,而每个进程的栈的容量是有限的。当递归调用层级太多时,就会超出栈的容量,从而导致调用栈溢出。
如果直接写递归函数,由于会出现很多重复计算,效率非常底,不采用。
要避免重复计算,采用从下往上计算,可以把计算过了的保存起来,下次要计算时就不必重复计算了:先由f(0)和f(1)计算f(2),再由f(1)和f(2)计算f(3)……以此类推就行了,计算第n个时,只要保存第n-1和第n-2项就可以了。
时间复杂度为O(n)
1 | public class Fibonacci { |
1 | public class Solution { |
一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
将跳法总数记为f(n),可以知道f(1)=1,f(2)=2。
当n>2时,第一次跳1级的话,还有f(n-1)种跳法;第一次跳2级的话,还有f(n-2)种跳法,所以可以推得f(n)=f(n-1)+f(n-2),即为斐波那契数列。
一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
解法1:
当n=1时,f(1)=1。
当n大于1时,归纳总结可知:跳上n级台阶,第一次跳1级的话,有f(n-1)种方法;第一次跳2级的话,有f(n-2)种方法……第一次跳n-1级的话,有f(1)种方法;直接跳n级的话,有1种方法,所以可以得到如下公式:
f(n) = f(n-1)+f(n-2)+……f(1)+1 (n≥2)
f(n-1) = f(n-2)+f(n-3)+…..f(1)+1 (n>2)
由上面两式相减可得,f(n)-f(n-1)=f(n-1),即f(n) = 2*f(n-1) (n>2)
最终结合f(1)和f(2),可以推得:f(n)=2^(n-1)
解法2:
假设跳到第n级总共需要k次,说明要在中间n-1级台阶中选出任意k-1个台阶,即C(n-1,k-1)种方法。
所以:跳1次就跳上n级台阶,需要C(n-1,0)种方法;跳2次需要C(n-1,1)种方法……跳n次需要C(n-1,n-1)种方法
总共需要跳C(n-1,0)+C(n-1,1)+C(n-1,2)+……C(n-1,n-1)=2^(n-1)种方法。
解法3:
除了必须到达最后一级台阶,第1级到第n-1级台阶都可以有选择的跳,也就是说对于这n-1个台阶来说,每个台阶都有跳上和不跳上2种情况,所以一共有2^(n-1)种方法。
用n个2 · 1的小矩形无重叠地覆盖一个2 · n的大矩形,总共有多少种方法?
当n = 1时,有一种方法。
当n = 2时,有两种方法。
当n >= 3时,和斐波那契数列类似。第一步竖着放,有f(n-1)种方法;第一步横着放,有f(n-2)种方法。所以f(n)=f(n-1)+f(n-2)。
求n次方时,可以利用递归来降低时间复杂度
当遇到涉及n的问题时(类似青蛙跳台阶问题),不要紧张,可以进行归纳分析,特别注意f(n)与f(n-1)、f(n-2)等的关联,从而找出规律,进行合理建模。
return (int)Math.pow(2,target-1);
1) 转int类型
如果题目中要求在排序的数组(或者部分排序的数组)中查找一个数字或者统计某个数字出现的次数,那么都可以尝试用二分查找算法。
哈希表和二叉树炸找的重点在于考查对应的数据结构而不是算法。
每次移动left和right指针的时候,需要在mid的基础上+1或者-1, 防止出现死循环, 程序也就能够正确的运行。
注意:代码中的判断条件必须是while (left <= right),否则的话判断条件不完整,比如:array[3] = {1, 3, 5};待查找的键为5,此时在(low < high)条件下就会找不到,因为low和high相等时,指向元素5,但是此时条件不成立,没有进入while()中。
1 | /** |
关于二分查找,如果条件稍微变换一下,比如:数组之中的数据可能可以重复,要求返回匹配的数据的最小(或最大)的下标;更近一步, 需要找出数组中第一个大于key的元素(也就是最小的大于key的元素的)下标,等等。 这些,虽然只有一点点的变化,实现的时候确实要更加的细心。
二分查找的变种和二分查找原理一样,主要就是变换判断条件(也就是边界条件),如果想直接看如何记忆这些变种的窍门,请直接翻到本文最后。下面来看几种二分查找变种的代码:
查找第一个相等的元素,也就是说等于查找key值的元素有好多个,返回这些元素最左边的元素下标。
当执行到right == left == mid后,此时会执行,if的 if (arr[mid] >= key) 分支,于是right = left - 1,于是跳出while循环,所以最后时 left是 arr[key]最有左边的值,而right正好卡在left左边。
1 | /** |
1 | /** |
查找最后一个相等的元素,也就是说等于查找key值的元素有好多个,返回这些元素最右边的元素下标。
根据上面的分析,left == right == mid 的时候,left = mid + 1,于是left卡在了right右边,所以应该返回right值。
1 | /** |
查找最后一个等于或小于key的元素,也就是说等于查找key值的元素有好多个,返回这些元素最右边的元素下标;如果没有等于key值的元素,则返回小于key的最右边元素下标。
1 | /** |
查找第一个等于或者大于key的元素,也就是说等于查找key值的元素有好多个,返回这些元素最左边的元素下标;如果没有等于key值的元素,则返回大于key的最左边元素下标。
1 | /** |
查找最后一个小于key的元素,也就是说返回小于key的最右边元素下标。
1 | public static int findLastSmaller(int[] arr, int key) { |
查找第一个等于key的元素,也就是说返回大于key的最左边元素下标。
1 | public static int findFirstLarger(int[] arr, int key) { |
1 | while (left <= right) { |
因为最后跳出 while (left <= right) 循环条件是 right < left 且 right = left - 1。最后right和left一定卡在“边界值”的左右两边,如果比较值是key,查找小于等于(或是小于)key的元素,则边界值就是等于key的所有元素的最左边那个,其实应该返回left。
1 | int mid = (left + right) / 2; |
也就是这里的if (array[mid] ? key) 中的判断符号,结合步骤1和给出的条件,如果是查找小于等于key的元素,则知道应该使用判断符号>=,因为是要返回left,所以如果array[mid]等于或者大于key,就应该使用>=,以下是完整代码
1 | int mid = (left + right) / 2; |
常见的快速排序、归并排序、堆排序、冒泡排序等排序算法属于比较排序,在排序的最终结果里,元素之间的次序依赖于它们之间的比较。每个数都必须和其他数进行比较,才能确定自己的位置。在冒泡排序之类的排序中,问题规模为n,又因为需要比较n次,所以平均时间复杂度为O(n²)。在归并排序、快速排序之类的排序中,问题规模通过分治法消减为logN次,所以时间复杂度平均O(nlogn)。
比较排序的优势是,适用于各种规模的数据,也不在乎数据的分布,都能进行排序。可以说,比较排序适用于一切需要排序的情况。
计数排序、基数排序、桶排序则属于非比较排序。因为数据本身包含了定位特征,所有才能不通过比较来确定元素的位置。
非比较排序是通过确定每个元素之前,应该有多少个元素来排序。针对数组arr,计算arr之前有多少个元素,则唯一确定了arr在排序后数组中的位置。
非比较排序只要确定每个元素之前的已有的元素个数即可,所以一次遍历即可解决。算法时间复杂度O(n)。
非比较排序时间复杂度低,但由于非比较排序需要占用空间来确定唯一位置。所以对数据规模和数据分布有一定的要求。
比较排序的时间复杂度通常为O(n²)或者O(nlogn),比较排序的时间复杂度下界就是O(nlogn),而非比较排序的时间复杂度可以达到O(n),但是都需要额外的空间开销。
比较排序时间复杂度为O(nlogn)的证明:
a1, a2, a3, ……, an数列的所有排序有n!种,所以满足要求的排序a1’, a2’, a3’, ……, an’(其中a1’<=a2’<=a3’……<=an’)的概率为1/n!。
基于输入元素的比较排序,每一次比较的返回不是0就是1,这恰好可以作为决策树的一个决策将一个事件分成两个分支。
比如冒泡排序时通过比较a1和a2两个数的大小,可以把序列分成a1, a2, ……, an与a2, a1, ……, an(气泡a2上升一个身位)两种不同的结果,因此比较排序也可以构造决策树。根节点代表原始序列a1, a2, a3, ……, an,所有叶子节点都是这个序列的重排(共有n!个,其中有一个就是我们排序的结果a1’, a2’, a3’, ……, an’)。
如果每次比较的结果都是等概率的话(恰好划分为概率空间相等的两个事件),那么二叉树就是高度平衡的,深度至少是log(n!)。
又因为:
n! < n^n ,两边取对数就得到log(n!) < nlog(n),所以 log(n!) = O(nlogn)。
n! = n(n-1)(n-2)(n-3)…1 > (n/2)^(n/2) 两边取对数得到 log(n!) > (n/2)log(n/2) = Ω(nlogn),所以 log(n!) = Ω(nlogn)。
因此log(n!)的增长速度与 nlogn 相同,即 log(n!) = Θ(nlogn),这就是通用排序算法的最低时间复杂度为 O(nlogn) 的依据。
选择排序 (Selection Sort)— O(n²)
希尔排序 (Shell Sort)— O(nlogn)
快速排序 (Quick Sort)— O(nlogn) 平均时间, O(n²)最坏情况;对于大的、乱序串列一般认为是最快的已知排序。
堆排序 (Heap Sort)— O(nlogn)
基数排序 (Radix Sort)— O(n·k); 需要 O(n) 额外存储空间 (K为特征个数)
插入排序 (Insertion Sort)— O(n²)
冒泡排序 (Bubble Sort) — O(n²)
归并排序 (Merge Sort)— O(nlogn); 需要 O(n) 额外存储空间
二叉树排序(Binary Tree Sort) — O(nlogn); 需要 O(n) 额外存储空间
计数排序 (Counting Sort) — O(n+k); 需要 O(n+k) 额外存储空间,k为序列中 Max-Min+1
桶排序 (Bucket Sort)— O(n); 需要 O(k) 额外存储空间
选择排序的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
遍历数组,遍历到i时,a0, a1, …, ai-1是已经排好序的,然后从i到n选择出最小的,记录下位置,如果不是第i个,则和第i个元素交换。此时第i个元素可能会排到相等元素之后,造成排序的不稳定。
1 | public static void selectionSort(int[] arr) { |
插入排序的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
遍历数组,遍历到i时,a0,a1…ai-1是已经排好序的,取出ai,从ai-1开始向前和每个比较大小,如果小于,则将此位置元素向后移动,继续先前比较,如果不小于,则放到正在比较的元素之后。
可见相等元素比较是,原来靠后的还是排在后边,所以插入排序是稳定的。
当待排序的数据基本有序时,插入排序的效率比较高,只需要进行很少的数据移动。
1 | public static void insertionSort(int[] arr) { |
希尔排序又叫缩小增量排序,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。
希尔排序是对插入排序的优化,基于以下两个认识:1.数据量较小时插入排序速度较快,因为n和n²差距很小;2.数据基本有序时插入排序效率很高,因为比较和移动的数据量少。
因此,希尔排序的基本思想是,将需要排序的序列划分成为若干个较小的子序列,对子序列进行插入排序,通过插入排序能够使得原来序列成为基本有序。这样通过对较小的序列进行插入排序,然后对基本有序的数列进行插入排序,能够提高插入排序算法的效率。
希尔排序的划分子序列不是像归并排序那种的二分,而是采用的叫做增量的技术,例如有十个元素的数组进行希尔排序,首先选择增量为10/2=5,此时第1个元素和第(1+5)个元素配对成子序列使用插入排序进行排序,第2和(2+5)个元素组成子序列,完成后增量继续减半为2,此时第1个元素、第(1+2)、第(1+4)、第(1+6)、第(1+8)个元素组成子序列进行插入排序。这种增量选择方法的好处是可以使数组整体均匀有序,尽可能的减少比较和移动的次数。
二分法中即使前一半数据有序,后一半中如果有比较小的数据,还是会造成大量的比较和移动,因此这种增量的方法和插入排序的配合更佳。
希尔排序的时间复杂度和增量的选择策略有关,上述增量方法造成希尔排序的不稳定性。
1 | public static int[] shellSort(int[] arr) { |
冒泡排序,重复地走访要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
冒泡排序的名字很形象,实际实现是相邻两节点进行比较,大的向后移一个,经过第一轮两两比较和移动,最大的元素移动到了最后,第二轮次大的位于倒数第二个,依次进行。这是最基本的冒泡排序,还可以进行一些优化。
优化一:如果某一轮两两比较中没有任何元素交换,这说明已经都排好序了,算法结束,可以使用一个isSorted做标记,默认为true,如果发生交换则置为false,每轮结束时检测isSorted,如果为false则继续,如果为true则返回。
优化二:某一轮结束位置为j,但是这一轮的最后一次交换发生在lastExchangedIndex的位置,则lastExchangedIndex到j之间是排好序的,下一轮的结束点就不必是j–了,而直接到lastExchangedIndex即可。
1 | public static void bubbleSort(int[] arr) { |
1 | public static void bubbleSort(int[] arr) { |
回顾冒泡排序的思想:
冒泡排序的每一个元素都可以像一个小气泡一样,根据自身大小,一点一点向着数组的一侧移动。算法的每一轮都是 从左到右比较元素,进行单向的位置交换。
那么鸡尾酒排序做了怎样的优化呢?
鸡尾酒排序的元素比较和交换过程是 双向的。
1 | public static void cockTailSort(int[] arr) { |
归并排序是采用分治法(Divide and Conquer)的一个非常典型的应用。
将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
首先考虑下如何将二个有序数列合并。这个非常简单,只要从比较两个数列的第一个数,谁小就先取谁,取了后就在对应数列中删除这个数。然后再进行比较,如果有数列为空,那直接将另一个数列的数据依次取出即可。这需要将待排序序列中的所有记录扫描一遍,因此耗费O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行 logn次,因此,总的时间复杂度为O(nlogn)。
归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果,因此空间复杂度为O(n)。
归并算法需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。
1 | public static void mergeSort(int[] arr, int left, int right) { |
快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。其中,最重要的partition主要有两种方法:
A.先把选定为pivot的元素放到最后,然后设定指针low和指针high,low指针左移,high指针右移,当两个指针相撞后停止移动。期间如果符合交换条件,两元素交换。最后把pivot元素放到中间。
B.类似冒泡排序的思路,把比pivot大的元素“往下沉”,把比pivot小的元素“往上浮”。
快速排序是目前被认为最好的一种内部排序方法。快速排序算法处理的最好情况指每次都是将待排序数列划分为均匀的两部分,通常认为快速排序的平均时间复杂度是O(nlogn)。
但是,快速排序的最差情况就是基本逆序或者基本有序的情况,那么此时快速排序将蜕化成冒泡排序,其时间复杂度为O(n^2)
1 | public static void quickSort(int[] arr, int startIndex, int endIndex) { |
1 | public static quickSort(int[] arr, int startIndex, int endIndex) { |
1 | public static void quickSort(int[] arr, int startIndex, int endIndex) { |
如果通过比较进行排序,那么复杂度的下界是O(nlogn),但是如果数据本身有可以利用的特征,可以不通过比较进行排序,就能使时间复杂度降低到O(n)。
计数排序要求待排序的数组元素都是整数,有很多地方都要求是 0-K 的正整数,其实负整数也可以通过都加一个偏移量解决的。
计数排序的思想是,考虑待排序数组中的某一个元素a,如果数组中比a小的元素有s个,那么a在最终排好序的数组中的位置将会是s+1,如何知道比a小的元素有多少个,肯定不是通过比较去觉得,而是通过数字本身的属性,即累加数组中最小值到a之间的每个数字出现的次数(未出现则为0),而每个数字出现的次数可以通过扫描一遍数组获得。
计数排序的步骤:
1.找出待排序的数组中最大和最小的元素(计数数组C的长度为max-min+1,其中位置0存放min,依次填充到最后一个位置存放max)
2.统计数组中每个值为i的元素出现的次数,存入数组C的第i项
3.对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
4.反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1(反向填充是为了保证稳定性)
计数排序适合数据分布集中的排序,如果数据太分散,会造成空间的大量浪费,假设数据为(1,2,3,1000000),这就需要1000000的额外空间,并且有大量的空间浪费和时间浪费。
1 | public static int[] countSort(int[] arr) { |
假设有一组长度为N的待排关键字序列K[1….n]。首先将这个序列划分成M个的子区间(桶) 。然后基于某种映射函数 ,将待排序列的关键字k映射到第i个桶中(即桶数组B的下标i) ,那么该关键字k就作为B[i]中的元素(每个桶B[i]都是一组大小为N/M的序列)。接着对每个桶B[i]中的所有元素进行比较排序(可以使用快排)。然后依次枚举输出B[0]….B[M]中的全部内容即是一个有序序列。
桶排序利用函数的映射关系,减少了计划所有的比较操作,是一种Hash的思想,可以用在海量数据处理中。
计数排序也可以看作是桶排序的特例,数组关键字范围为N,划分为N个桶。
1 | public static double[] bucketSort(double[] arr) { |
堆排序是把数组看作堆,第i个结点的孩子结点为第2i + 1和2i + 2个结点(不超出数组长度前提下),堆排序的第一步是建堆,然后是取堆顶元素然后调整堆。建堆的过程是自底向上不断调整达成的,这样当调整某个结点时,其左节点和右结点已经是满足条件的,此时如果两个子结点不需要动,则整个子树不需要动,如果调整,则父结点交换到子结点位置,再以此结点继续调整。
下述代码使用的大顶堆,建立好堆后堆顶元素为最大值,此时取堆顶元素即使堆顶元素和最后一个元素交换,最大的元素处于数组最后,此时调整小了一个长度的堆,然后再取堆顶和倒数第二个元素交换,依次类推,完成数据的非递减排序。
堆排序的主要时间花在初始建堆期间,建好堆后,堆这种数据结构以及它奇妙的特征,使得找到数列中最大的数字这样的操作只需要O(1)的时间复杂度,维护需要logn的时间复杂度。堆排序不适宜于记录数较少的文件。
1 | //堆排序 arr为待调整的堆 |
创建一个200万int值存储在一个名为largedata.dat的二进制文件中。使用下面的程序创建:
1 | import java.io.*; |
重复将数据从文件读入数组,并使用内部排序算法堆数组排序,然后将数据从数组输出到一个临时文件中。
下面的代码给出了一个方法,它从文件中读取每个数据段,并对分段进行排序,然后将排好序的分段存在一个心文件中。该方法返回分段的个数。
1 | private static int initializeSegments |
MAX_ARRAY_SIZE,数组的最大尺寸依赖于操作系统分配给JVM的内存大小。
假定数组的最大尺寸为100 000个int值,那么在临时文件中就是对每100 000个int值进行的排序。将它们标记为S1,S2,…,Sk,最后一段包含的数值可能会少于100 000个。
将每对有序分段(比如S1,S2,…,Sk)归并到一个大一些的有序分段中,并将新分段存储到新的临时文件中。继续同样的过程直到得到仅仅一个有序分段。
每步归并都将两个有序分段归并成一个新分段。新段的元素数目是原来的两倍,因此,每次归并后分段的个数减少一半。
如果一个分段太大,它将不能放到内存的数组中。为了实现归并步骤,要将文件f1.dat中的一半数目的分段复制到临时文件f2.dat中。然后,将f1.dat中剩下的收割分段与f2.dat中的首个分段归并到名为f3.dat的临时文件中。
复制前半部分的分段1
2
3
4
5
6private static void copyHalfToF2(int numberOfSegments, int segmentSize,
DataInputStream f1, DataOutputStream f2) throws Exception {
for (int i = 0; i < (numberOfSegments / 2) * segmentSize; i++) {
f2.writeInt(f1.readInt());
}
}
归并所有分段1
2
3
4
5
6
7
8
9
10
11private static void mergeSegments(int numberOfSegments,
int segmentSize, DataInputStream f1, DataInputStream f2, DataOutputStream f3)
throws Exception {
for (int i = 0; i < numberOfSegments; i++) {
mergeTwoSegments(segmentSize, f1, f2, f3);
}
while (f1.available() > 0) {
f3.writeInt(f1.readInt());
}
}
归并两个阶段1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37private static void mergeTwoSegments(int segmentSize, DataInputStream f1,
DataInputStream f2, DataOutputStream f3) throws Exception {
int intFromF1 = f1.readInt();
int intFromF2 = f2.readInt();
int f1Count = 1;
int f2Count = 1;
while (true) {
if (intFromF1 < intFromF2) {
f3.writeInt(intFromF1);
if (f1.available() == 0 || f1Count++ >= segmentSize) {
f3.writeInt(intFromF2);
break;
}
else {
intFromF1 = f1.readInt();
}
}
else {
f3.writeInt(intFromF2);
if (f2.available() == 0 || f2Count++ >= segmentSize) {
f3.writeInt(intFromF1);
break;
}
else {
intFromF2 = f2.readInt();
}
}
}
while (f1.available() > 0 && f1Count++ <segmentSize) {
f3.writeInt(f1.readInt());
}
while (f2.available() > 0 && f2Count++ < segmentSize) {
f3.writeInt(f2.readInt());
}
}
完整代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153package sorting;
import java.io.*;
public class SortLargeFile {
public static final int MAX_ARRAY_SIZE = 43;
public static final int BUFFER_SIZE = 100000;
public static void main(String[] args) throws Exception {
sort("largedata.dat", "sortedfile.dat");
displayFile("sortedfile.dat");
}
public static void sort(String sourcefile, String targetfile) throws Exception {
int numberOfSegments = initializeSegments(MAX_ARRAY_SIZE, sourcefile, "f1.dat");
merge(numberOfSegments, MAX_ARRAY_SIZE,
"f1.dat", "f2.dat", "f3.dat", targetfile);
}
private static int initializeSegments
(int segmentSize, String originalFile, String f1) throws Exception {
int[] list = new int[segmentSize];
DataInputStream input = new DataInputStream(
new BufferedInputStream(new FileInputStream(originalFile)));
DataOutputStream output = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(f1)));
int numberOfSegments = 0;
while (input.available() > 0) {
numberOfSegments++;
int i = 0;
for ( ; input.available() > 0 && i < segmentSize; i++) {
list[i] = input.readInt();
}
java.util.Arrays.sort(list, 0, i);
for (int j = 0; j < i; j++) {
output.writeInt(list[j]);
}
}
input.close();
output.close();
return numberOfSegments;
}
private static void merge(int numberOfSegments, int segmentSize,
String f1, String f2, String f3, String targetfile) throws Exception {
if (numberOfSegments > 1) {
mergeOneStep(numberOfSegments, segmentSize, f1, f2, f3);
merge((numberOfSegments + 1) / 2, segmentSize * 2, f3, f1, f2, targetfile);
}
else {
File sortedFile = new File(targetfile);
if (sortedFile.exists()) sortedFile.delete();
new File(f1).renameTo(sortedFile);
}
}
private static void mergeOneStep(int numberOfSegments, int segmentSize,
String f1, String f2, String f3) throws Exception {
DataInputStream f1Input = new DataInputStream(
new BufferedInputStream(new FileInputStream(f1), BUFFER_SIZE));
DataOutputStream f2Output = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(f2), BUFFER_SIZE));
copyHalfToF2(numberOfSegments, segmentSize, f1Input, f2Output);
f2Output.close();
DataInputStream f2Input = new DataInputStream(
new BufferedInputStream(new FileInputStream(f2), BUFFER_SIZE));
DataOutputStream f3Output = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(f3), BUFFER_SIZE));
mergeSegments(numberOfSegments / 2, segmentSize, f1Input, f2Input, f3Output);
f1Input.close();
f2Input.close();
f3Output.close();
}
private static void copyHalfToF2(int numberOfSegments, int segmentSize,
DataInputStream f1, DataOutputStream f2) throws Exception {
for (int i = 0; i < (numberOfSegments / 2) * segmentSize; i++) {
f2.writeInt(f1.readInt());
}
}
private static void mergeSegments(int numberOfSegments,
int segmentSize, DataInputStream f1, DataInputStream f2, DataOutputStream f3)
throws Exception {
for (int i = 0; i < numberOfSegments; i++) {
mergeTwoSegments(segmentSize, f1, f2, f3);
}
while (f1.available() > 0) {
f3.writeInt(f1.readInt());
}
}
private static void mergeTwoSegments(int segmentSize, DataInputStream f1,
DataInputStream f2, DataOutputStream f3) throws Exception {
int intFromF1 = f1.readInt();
int intFromF2 = f2.readInt();
int f1Count = 1;
int f2Count = 1;
while (true) {
if (intFromF1 < intFromF2) {
f3.writeInt(intFromF1);
if (f1.available() == 0 || f1Count++ >= segmentSize) {
f3.writeInt(intFromF2);
break;
}
else {
intFromF1 = f1.readInt();
}
}
else {
f3.writeInt(intFromF2);
if (f2.available() == 0 || f2Count++ >= segmentSize) {
f3.writeInt(intFromF1);
break;
}
else {
intFromF2 = f2.readInt();
}
}
}
while (f1.available() > 0 && f1Count++ <segmentSize) {
f3.writeInt(f1.readInt());
}
while (f2.available() > 0 && f2Count++ < segmentSize) {
f3.writeInt(f2.readInt());
}
}
public static void displayFile(String filename) {
try {
DataInputStream input =
new DataInputStream(new FileInputStream(filename));
for (int i = 0; i < 100; i++) {
System.out.print(input.readInt() + " ");
}
input.close();
}
catch (IOException ex) {
ex.printStackTrace();
}
}
}
在外部排序中,主要开销是在IO上。假设n是文件中要排序的元素个数。在阶段1,从原始文件读取元素个数n,然后将它输出给一个临时文件。因此,阶段1的IO复杂度为O(n)。
对于阶段2,在第一个合并步骤之前,排好序的分段的个数为 n/c,其中c是MAX_ARRAY_SIZE。每一个合并步骤都会使分段的个数减半。因此,在第一次合并步骤之后,分段个数为 n/2c。
在第二次合并步骤之后,分段个数为 n/4c。 在第三次合并步骤之后,分段个数为 n/8c。
在第log(n/c)次合并步骤之后,分段个数减到1。因此,合并步骤的总数为log(n/c)。
在每次合并步骤中,从文件f1读取一半数量的分段,然后将它们写入到一个临时文件f2。合并f1中剩余的分段和f2中的分段。每一个合并步骤中IO的次数为O(n)。因为合并步骤的总数是log(n/c),IO的总数是 O(n) * log(n/c) = O(nlogn)。
因此外部排序的复杂度是O(nlogn)
二叉堆本质上是一种完全二叉树,它分为两个类型:
1.最大堆
2.最小堆
完全二叉树定义:如果一棵二叉树的每一层都是满的,或者最后一层可以不填满并且最后一层的叶子都是靠左放置的,这可二叉树是完全的。
堆排序使用的是二叉堆(binary heap),二叉堆是一棵具有如下属性的二叉树:
形状属性:它是一棵完全二叉树。
堆属性:
什么是最大堆呢?最大堆任何一个父节点的值,都 大于等于它左右孩子节点的值。
什么是最小堆呢?最小堆任何一个父节点的值,都 小于等于它左右孩子节点的值。
二叉堆的根节点叫做堆顶。
最大堆和最小堆的特点,决定了在最大堆的堆顶是整个堆中的 最大元素;最小堆的堆顶是整个堆中的 最小元素。
对于二叉堆,如下有几种操作:
构建二叉堆,也就是把一个无序的完全二叉树调整为二叉堆,本质上就是让所有非叶子节点依次下沉。
二叉堆虽然是一颗完全二叉树,但它的存储方式并不是链式存储,而是顺序存储。换句话说,二叉堆的所有节点都存储在数组当中。
数组中,在没有左右指针的情况下,如何定位到一个父节点的左孩子和右孩子呢?
可以依靠数组下标来计算。假设父节点的下标是parent,那么它的左孩子下标就是 2parent+1;它的右孩子下标就是 2parent+2 。
1 | public class HeapOperator { |
代码中有一个优化的点,就是父节点和孩子节点做连续交换时,并不一定要真的交换,只需要先把交换一方的值存入temp变量,做单向覆盖,循环结束后,再把temp的值存入交换后的最终位置。
我们每一次删除旧堆顶,调整后的新堆顶都是大小仅次于旧堆顶的节点。那么我们只要反复删除堆顶,反复调节二叉堆,所得到的集合就成为了一个有序集合。
二叉堆和最大堆的特性:
当我们删除一个最大堆的堆顶(并不是完全删除,而是替换到最后面),经过自我调节,第二大的元素就会被交换上来,成为最大堆的新堆顶。
由此,我们可以归纳出堆排序算法的步骤:
1 | public class HeapSort{ |
算法复杂度:
时间复杂度(平均): O(nlogn)
时间复杂度(最坏): O(nlogn)
时间复杂度(最好): O(nlogn)
空间复杂度: O(1)
堆排序是不稳定的排序算法。
堆排序的空间复杂度毫无疑问是O(1),因为没有开辟额外的集合空间。
对于时间复杂度:
二叉堆的节点下沉调整(downAdjust 方法)是堆排序算法的基础,这个调节操作本身的时间复杂度是多少呢?
假设二叉堆总共有n个元素,那么下沉调整的最坏时间复杂度就等同于二叉堆的高度,也就是O(logn)。
我们再来回顾一下堆排序算法的步骤:
第一步,把无序数组构建成二叉堆,需要进行n/2次循环。每次循环调用一次 downAdjust 方法,所以第一步的计算规模是 n/2 * logn,时间复杂度 O(nlogn)。
第二步,需要进行n-1次循环。每次循环调用一次 downAdjust 方法,所以第二步的计算规模是 (n-1) * logn ,时间复杂度 O(nlogn)。
两个步骤是并列关系,所以整体的时间复杂度同样是 O(nlogn)。
一个长度为20的doule类型数组,取值范围从0到10,要求用最快的速度把这20个double类型元素从小到大进行排序。
当数列取值范围过大,或者不是整数时,不能适用计数排序。到那时可以使用桶排序来解决问题。
桶排序同样是一种线性时间的排序算法,类似于计数排序所创建的统计数组,桶排序需要创若干个 桶来协助排序。
计数排序:
计数排序需要根据原始数列的取值范围,创建一个统计数组,用来统计原始数列中每一个可能的整数值所出现的次数。
原始数列中的整数值,和统计数组的下标是一一对应的,以数列的最小值作为偏移量,比如原始数列的最小值是90,那么整数对应的统计数组下标就是95-90=5。
桶排序当中的桶的概念:
每一个桶(bucket)代表一个区间范围,里面可以承载一个或多个元素。
桶排序的第一步,就是创建这些桶,确定每一个桶的区间范围。
4.5 0.84 3.25 2.18 0.5
[0.5, 1.5) [1.5, 2.5) [2.5, 3.5) [3.5, 4.5) [4.5, 4.5]
具体建立多少个桶,如何确定桶的区间范围,有很多不同的方式。
这里创建的桶数量等于原始数列的元素数量,除了最后一个桶只包含数列最大值,前面各个桶的区间按照比例确定。
区间跨度 = (最大值-最小值)/ (桶的数量 - 1)
第二步,遍历原始数列,把元素对号入座放入各个桶中:
0.84 0.5 2.18 3.25 4.5
[0.5, 1.5) [1.5, 2.5) [2.5, 3.5) [3.5, 4.5) [4.5, 4.5]
第三步,每个桶内部的元素分别排序(显然,只有第一个桶需要排序):
第四步,遍历所有的桶,输出所有元素:
0.5, 0.84, 2.18, 3.25, 4.5
到此为止,排序结束。
1 | public class BucketSort { |
代码中,所有的桶保存在ArrayList集合当中,每一个桶被定义成一个链表(LinkedList
定位元素属于第几个桶,是按照比例来定位:
(array[i] - min) * (bucketNum-1) / d
同时,代码使用了JDK的集合工具类Collections.sort来为桶内部的元素进行排序。Collections.sort底层采用的是归并排序或Timsort,小伙伴们可以简单地把它们当做是一种时间复杂度 O(nlogn)的排序。
基数排序
基数排序也可以看作一种桶排序,不断的使用不同的标准对数据划分到桶中,最终实现有序。基数排序的思想是对数据选择多种基数,对每一种基数依次使用桶排序。
基数排序的步骤:以整数为例,将整数按十进制位划分,从低位到高位执行以下过程。
从个位开始,根据0~9的值将数据分到10个桶桶,例如12会划分到2号桶中。
将0~9的10个桶中的数据顺序放回到数组中。
重复上述过程,一直到最高位。
上述方法称为LSD(Least significant digital),还可以从高位到低位,称为MSD。
算法复杂度:
时间复杂度(平均): O(n+m+n(logn-logm))
时间复杂度(最坏): O(nlogn)
时间复杂度(最好): O(n)
空间复杂度: O(m+n)
桶排序也是稳定的排序算法。
假设原始数列有n个元素,分成m个桶(我们采用分桶方式m=n),平均每个桶的元素个数为 n/m
下面逐步分析算法复杂度
第一步,求数列最大最小值,运算量为n。
第二步,创建空桶,运算量为m。
第三步,遍历原始数列,运算量为n。
第四步在每个桶内部做排序,由于使用了O(nlogn)的排序算法,所以运算量为 n/m · log(n/m ) · m。
第五步,输出排序数列,运算量为n。
加起来,总的运算量为3n+m+ n/m · log(n/m ) · m = 3n+m+n(logn-logm)
去掉系数,时间复杂度为:
O(n+m+n(logn-logm))
至于空间复杂度就很明显了:
空桶占用的空间 + 数列在桶中占用的空间 = O(m+n)
桶排序在性能上并非绝对稳定。理想情况下,桶中的元素均匀分布,当n=m时,时间复杂度可以达到O(n);但是,如果桶内元素的分布极不均匀,极端情况下第一个桶中有n-1个元素,,最后一个桶中有1个元素。此时的时间复杂度将退化成为O(nlogn),还白白创建了许多空桶。
如果通过比较进行排序,那么复杂度的下界是O(nlogn),但是如果数据本身有可以利用的特征,可以不通过比较进行排序,就能使时间复杂度降低到O(n)。
计数排序要求待排序的数组元素都是整数,有很多地方都要求是 0-K 的正整数,其实负整数也可以通过都加一个偏移量解决的。
计数排序的思想是,考虑待排序数组中的某一个元素a,如果数组中比a小的元素有s个,那么a在最终排好序的数组中的位置将会是s+1,如何知道比a小的元素有多少个,肯定不是通过比较去觉得,而是通过数字本身的属性,即累加数组中最小值到a之间的每个数字出现的次数(未出现则为0),而每个数字出现的次数可以通过扫描一遍数组获得。
计数排序的步骤:
1.找出待排序的数组中最大和最小的元素(计数数组C的长度为max-min+1,其中位置0存放min,依次填充到最后一个位置存放max)
2.统计数组中每个值为i的元素出现的次数,存入数组C的第i项
3.对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
4.反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1(反向填充是为了保证稳定性)
计数排序适合数据分布集中的排序,如果数据太分散,会造成空间的大量浪费,假设数据为(1,2,3,1000000),这就需要1000000的额外空间,并且有大量的空间浪费和时间浪费。
数组里有20个随机数,取值范围从0到10,要求用最快的速度把这20个整数从小到大进行排序。
随机整数的取值范围从0到10,这些整数取值范围为0-10这11个数字。根据这个整数取值范围,建立一个长度为11的数组。数组下标从0到10,元素初始值全为0。
假定20个随机整数的值如下:
9,3,5,4,9,1,2,7,8,1,3,6,5,3,4,0,10,9 ,7,9
如何给这些无序的随机整数排序呢?
非常简单,让我们遍历这个无序的随机数列,每一个整数按照其值对号入座,对应数组下标的元素进行加1操作。
最终,数列遍历完毕时,数组的状态如下:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
1 | 2 | 1 | 3 | 2 | 2 | 1 | 2 | 1 | 4 | 1 |
数组每一个下标位置的值,代表了数列中对应整数出现的次数。
有了这个“统计结果”,排序就很简单了。直接遍历数组,输出数组元素的下标值,元素的值是几,就输出几次:
0,1,1,2,3,3,3,4,4,5,5,6,7,7,8,9,9,9,9,10
显然,这个输出的数列已经是有序的了。
这就是计数排序的基本过程,它适用于一定范围的整数排序。在取值范围不是很大的情况下,它的性能甚至超过那些O(nlogn)的排序。
下面代码在一开头补充了一个步骤,就是求得数列的最大整数值max。后面创建的统计数组countArray,长度就是max + 1,以此保证数组的最后一个下标是max。
代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32public class CountSort {
public static int[] countSort(int[] arr) {
// 1. 得到数列的最大值
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > arr[0]) {
max = arr[i];
}
}
// 2.根据数列最大值确定统计数组的长度
int[] countArray = new int[max + 1];
// 3. 遍历数列,填充统计数组
for (int i = 0; i < arr.length; i++) {
countArray[arr[i]]++;
}
// 4. 遍历统计数组,输出结果
int index = 0;
int[] sortedArray = new int[arr.length];
for (int i = 0; i < countArray.length; i++) {
for (int j = 0; j < countArray[i]; j++) {
sortedArray[index++] = i;
}
}
return sortedArray;
}
public static void main(String[] args) {
int[] array = { 4, 4, 6, 5, 3, 2, 8, 1, 7, 5, 6, 0, 10 };
int[] sortedArray = countSort(array);
System.out.println(Arrays.toString(sortedArray));
}
}
上面的初步实现,从功能角度,可以实现整数的排序,但是存在一些问题,只以数列的最大值来决定统计数组的长度,其实并不严谨。比如下面的数列:
95,94,91,98,99,90,99,93,91,92
这个数列的最大值是99,但最小值的整数是90.如果创建长度为100的数组,前面从0到89的空间位置都浪费了。
解决这个问题,很简单,不再以(输入数列的最大值 + 1)作为统计数组的长度,而是以(数列最大值和最小值的差 + 1)作为统计数组的长度。
同时,数列的最小值作为一个偏移量,用于统计数组的对号入座。
以刚才的数列为例,统计数组的长度为 99-90+1 = 10 偏移量等于数列的最小值90.
对于第一个整数95,对应的统计数组下标是 95-90=5
另外一方面,朴素版的计数排序只是简单地按照统计数组的下标输出了元素值,并没有真正给原始数列进行排序。
如果只是单纯的给整数排序,这样没有问题,但是如果放在业务代码里,比如给学生的考试分数排序,遇到相同的分数就会分不清谁是谁。
姓名 | 成绩 |
---|---|
小灰 | 90 |
大黄 | 99 |
小红 | 95 |
小白 | 94 |
小绿 | 95 |
给定一个学生的成绩表,要求按成绩从低到高排序,如果成绩相同,则 遵循原表固有顺序。
那么,当我们填充统计数组以后,我们只知道有两个成绩并列95分的小伙伴,却不知道哪一个是小红,哪一个是小绿:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 0 | 0 | 0 | 1 | 2 | 0 | 0 | 0 | 1 |
变型后:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 1 | 1 | 1 | 2 | 4 | 4 | 4 | 4 | 5 |
这是如何变形的呢?统计数组从第二个元素开始,每一个元素都加上前面所有元素之和。
为什么要相加呢?
这样相加的目的,是让统计数组存储的元素值,等于相应整数的最终排序位置。比如下标是9的元素值为5,代表原始数列的整数9,最终的排序是在第5位。
接下来,我们创建输出数组sortedArray,长度和输入数列一致。然后从后向前遍历输入数列:
第一步,我们遍历成绩表最后一行的小绿:
小绿是95分,我们找到countArray下标是5的元素,值是4,代表小绿的成绩排名位置在第4位。
同时,我们给countArray下标是5的元素值减1,从4变成3,,代表着下次再遇到95分的成绩时,最终排名是第3。
第二步,我们遍历成绩表倒数第二行的小白:
小白是94分,我们找到countArray下标是4的元素,值是2,代表小白的成绩排名位置在第2位。
同时,我们给countArray下标是4的元素值减1,从2变成1,,代表着下次再遇到94分的成绩时(实际上已经遇不到了),最终排名是第1。
第三步,我们遍历成绩表倒数第三行的小红:
小红是95分,我们找到countArray下标是5的元素,值是3(最初是4,减1变成了3),代表小红的成绩排名位置在第3位。
同时,我们给countArray下标是5的元素值减1,从3变成2,,代表着下次再遇到95分的成绩时(实际上已经遇不到了),最终排名是第2。
这样一来,同样是95分的小红和小绿就能够清楚地排出顺序了,也正因此,优化版本的计数排序属于稳定排序。
其中关键的地方有两个:
第一,在于理解计算max和min之后,需要使用原数组每一个元素减去min的转换值统计词频,特定情况下能节省存储空间,这样做的另一个好处是可以兼容负数的情况,因为每一个元素减去最小值之后,结果必定是大于等于0;
第二,在于理解为什么采用词频求和的方式 + 倒序遍历原始数组的方式,能保证排序算法的稳定性。
这里必须从后向前遍历,只有这样出现重复的元素,才会保持顺序的把最后面的重复元素,永远放在最右边。从而保证排序的稳定性,如果从前向后排序,重复元素的顺序,刚好相反,所以就不是稳定的算法。
1 | public class CountSort { |
算法复杂度:
时间复杂度(平均): O(n+m)
时间复杂度(最坏): O(n+m)
时间复杂度(最好): O(n+m)
空间复杂度: O(m)
计数排序是稳定的排序算法。
如果原始数列的规模是N,最大最小整数的差值是M,计数排序的时间复杂度和空间复杂度。
代码第1, 2, 4步都涉及到遍历原数列,运算量都是N,第3步遍历统计数列,运算量是M,所以总体运算量是3N + M,去掉系数,时间复杂度是O(n+m)。
至于空间复杂度,如果不考虑结果数组,只考虑统计数组 countArray 大小的话,空间复杂度是O(m)。
计数排序存在它的局限性:
当数列最大值最小值差距过大时,并不使用计数排序。
比如给定20个随机整数,范围在0到1亿之间,这时候如果使用计数排序,需要创建长度1亿的数组。不但严重浪费空间,而且时间复杂度也随之升高。
当数列元素不是整数,并不适用计数排序。
如果数列中的元素都是小数,比如25.213,或是0.00000001这样子,则无法创建对应的统计数组。这样显然无法进行计数排序。
基于这些局限性,另一种线性时间排序算法对此做出了弥补,这种排序算法叫做 桶排序。
快速排序是从冒泡排序演变而来的算法,但是使用了 分治法,比冒泡排序要高效得多,所以叫快速排序。
同冒泡排序一样,快速排序也属于交换排序,通过元素之间的比较和交换位置来达到排序的目的。
不同的是,冒泡排序在每一轮只把一个元素冒泡到数列的一端,而快速排序 在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成了两个部分。
这种思路就叫做分治法。
在分治法的思想下,原数列在每一轮被拆成两部分,每一部分在下一轮又分别被拆成两部分,直到不可再分为止。
基准元素的选择:最简单的方式是选择数列的第一个元素。
但是假如有一个原本逆序的数列,期望排序成顺序数列,这样数列每一轮仅仅确定了基准元素的位置。 第一个元素要么是最小值,要么是最大值,根本无法发挥分治法的优势。 在这种极端情况下,快速排序需要进行n轮,时间复杂度退化成了O(n^2)。
如何避免上述情况的发生,最简单的方法,不选择数列的第一个元素,而是随机选择一个元素作为基准元素。 这样一来,即使在数列完全逆序的情况下,也可以有效地将数列分成两部分。当然,即使是随机选择基准元素,每一次也有极小的几率选到数列的最大值或最小值,同样会影响到分治的效果。
快速排序是目前被认为最好的一种内部排序方法。快速排序算法处理的最好情况指每次都是将待排序数列划分为均匀的两部分,通常认为快速排序的平均时间复杂度是O(nlogn)。
但是,快速排序的最差情况就是基本逆序或者基本有序的情况,那么此时快速排序将蜕化成冒泡排序,其时间复杂度为O(n^2)
选定了基准元素,要做的就是把其他元素当中小于基准元素的都移动到基准元素一边,大于基准元素的都移动到基准元素另一边。
有两种方法:
1 | public class QuickSort { |
和挖坑法相比,指针交换法在partition方法中进行的元素交换次数更少。
对于数列 {4, 7, 6, 5, 3, 2, 8, 1 }
由于left一开始指向的是基准元素,判断肯定相等,所以left右移一位。
进入第四次循环,right移动到元素3停止,这时候请注意,left和right指针已经重合在了一起。
当left和right指针重合之时,我们让pivot元素和left与right重合点的元素进行交换。此时数列左边的元素都小于4,数列右边的元素都大于4,这一轮交换终告结束。
1 | public class QuickSort { |
无论是挖坑法还是指针交换法,都是一层循环内嵌一层循环,从数组的两边交替遍历元素,代码不够简洁。
单边循环法只从数组的一边对元素进行遍历和交换。
1 | public static void quickSort(int[] arr, int startIndex, int endIndex) { |
上面的代码都是依靠递归来实现的,绝大多数用递归来实现的问题,都可以用栈的方式来代替。
因为我们代码中一层一层的方法调用,本身就是一个函数栈。每次进入一个新方法,就相当于入栈;每次有方法返回,就相当于出栈。
所以,我们可以把原本的递归实现转化成一个栈的实现,在栈当中存储每一次方法调用的参数:
1 | public class QuickSort { |
和刚才的递归实现相比,代码的变动仅仅在quickSort方法当中。该方法中引入了一个存储Map类型元素的栈,用于存储每一次交换时的起始下标和结束下标。
每一次循环,都会让栈顶元素出栈,进行排序,并且按照基准元素的位置分成左右两部分,左右两部分再分别入栈。当栈为空时,说明排序已经完毕,退出循环。
在线性表中的第一个元素、中间元素和最后一个元素中选择一个 中位数作主元。
1 | public static int median(int first, int middle, int last) { |
算法复杂度:
时间复杂度(平均): O(nlogn)
时间复杂度(最坏): O(n^2)
时间复杂度(最好): O(nlogn)
空间复杂度: O(nlogn)
快速排序是不稳定的排序算法。
快速排序有两个方向,左边的left指针一直往右走,当arr[left] <= pivot。而右边的right指针一直往左走,当arr[right] > pivot。如果left和right都走不动了,left <= right,交换arr[left]和arr[right],重复上面的过程,直到left > right。 交换arr[left]和arr[startIndex],完成一趟快速排序。
在中枢元素和a[left]交换的时候,很有可能把前面的元素的稳定性打乱,比如序列为5 3 3 4 3 8 9 10 11,现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱,所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和arr[left] 交换的时刻。
归并排序和快速排序都使用了分而治之法。
对于归并排序,大量的工作是将两个子线性表进行归并,归并是在两个子线性表都 排好序后进行的。
对于快速排序,大量的工作是将线性表划分成两个子线性表,划分是在子线性表 排好序前进行的。
最差的情况下,归并排序的效率高于快速排序,但是,在平均情况下,两者效率相同。归并排序在归并两个数组是需要一个临时数组,而快速排序不需要额外的数组空间。因此,快速排序的空间效率高于归并排序。
相同点:堆排序和快速排序的平均时间复杂度都是O(nlogn),并且都是 不稳定排序。
不同点:快速排序的最坏时间复杂度是O(n^2),而堆排序最坏时间复杂度稳定在O(nlogn)。
此外,快速排序的递归和非递归的空间复杂度都是O(n),而堆排序的空间复杂度是O(1)。
归并排序是采用分治法(Divide and Conquer)的一个非常典型的应用。
将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
在这里我们只不过是利用了递归的思想,将数组每一次都分解为原来的一半大小的两个子数组,当分解到了右边界比左边界还大的时候,不再分解,开始排序。然后将排序好的子数组逐级合并,最后得到的结果就是排序好的数组。
首先考虑下如何将两个有序数列合并。这个非常简单,只要从比较两个数列的第一个数,谁小就先取谁,取了后就在对应数列中删除这个数。然后再进行比较,如果有数列为空,那直接将另一个数列的数据依次取出即可。这需要将待排序序列中的所有记录扫描一遍,因此耗费O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行 logn次,因此,总的时间复杂度为O(nlogn)。
归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果,因此空间复杂度为O(n)。
归并算法需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。
所谓递归,指的是程序直接或间接调用本身的一种方法,它通常把一个大型的、复杂的问题不直接解决,而是转化成为一个与原问题相似的、规模较小的问题来解决。
简单来说, 递归就是把问题层层分解,直到程序出口处。
任何递归都必须有递归调用的结束条件,否则,程序将会陷入无限递归而无法结束,而这个结束条件满足时,一定不会调用本身,否则递归调用将无法结束。
归并排序的算法伪代码:1
2
3
4
5
6
7
8public static void mergeSort(int[] arr) {
if (arr.length > 1) {
mergeSort(arr[0 ... arr.length/2]);
mergeSort(arr[arr.length/2 + 1 ... arr.length]);
merge arr[0 ... arr.length / 2] with
arr[arr.length/2 + 1 ... arr.length];
}
}
对数列 {2, 9, 5, 4, 8, 1, 6, 7} 进行归并排序。
先进行 拆分数列,直到数列只有一个元素为止,然后,再将其 归并为一个新的有序数列。
递归调用持续将数组划分为子数组,直到每个子数组只包含一个元素。然后,该算法将这些小的子数组归并为稍大的有序子数组,直到最后形成一个有序的数组。
归并排序利用的是分治的思想,对于给定的一组数据,利用递归与分治技术将数据序列划分成为越来越小的子序列,之后对子序列排序,后再用递归方法将排好序的子序列合并成为有序序列。
合并两个子序列时,需要申请两个子序列加起来长度的内存,临时存储新的生成序列,再将新生成的序列赋值到原数组相应的位置。
MergeSort方法在分解过程中创建两个临时数组,将数组前半部分和后半部分复制到临时数组中,对临时数组排序,然后将它们归并到原始数组中,这样产生很多额外的空间开销。
代码如下:
1 | public class MergeSort { |
在方法二中,递归地对数组的前半部分和后半部分进行排序,而不创建新的临时数组,然后把两个数组归并到一个临时数组中并将它的内容复制到初始数组中。
1 | public class MergeSort { |
可以在Eclipse的debug中设置断点,查看递归调用和返回的次序,注意观察变量值的变化。
算法复杂度:
时间复杂度(平均): O(nlogn)
时间复杂度(最坏): O(nlogn)
时间复杂度(最好): O(nlogn)
空间复杂度: O(n)
在这里我们只不过是利用了递归的思想,将数组每一次都分解为原来的一半大小的两个子数组,当分解到了右边界比左边界还大的时候,不再分解,开始排序。然后将排序好的子数组逐级合并,最后得到的结果就是排序好的数组。
原数组的长度为n,则细分得最大深度为logn,每一层需要排序的元素为n;则归并排序的时间复杂度为O(nlogn)。
稳定性:因为交换元素时,可以在相等的情况下做出不移动的限制,所以归并排序是可以稳定的。
回顾冒泡排序的思想:
冒泡排序的每一个元素都可以像一个小气泡一样,根据自身大小,一点一点向着数组的一侧移动。算法的每一轮都是 从左到右比较元素,进行单向的位置交换。
那么鸡尾酒排序做了怎样的优化呢?
鸡尾酒排序的元素比较和交换过程是 双向的。
举个例子:
有8个数组成一个无序数列 {2, 3, 4, 5, 6, 7, 8, 1},希望从小到大排序
冒泡排序过程省略,可以看出来,需要将1进行7轮排序。
按照冒泡排序,事实上,2到8已经是有序了,只有元素1的位置不对,却还要进行7轮排序!!这明显不合理,需要改进。
而鸡尾酒排序正是要解决这种问题。
那么鸡尾酒排序又是什么样的?下面看看详细过程:
数列{2, 3, 4, 5, 6, 7, 8, 1}
第一轮(和冒泡排序一样,8和1交换):
交换后 {2, 3, 4, 5, 6, 7, 1, 8}
第二轮:
反过来 从右往左比较和交换:
第三轮(虽然已经有序,但是流程并没有结束):
以上就是鸡尾酒排序的思路。排序过程就像钟摆一样,第一轮从左到右,第二轮从右到左,第三轮再从左到右……
下面这段代码是鸡尾酒排序的原始实现。
代码外层的大循环控制着所有排序回合,大循环内包含两个小循环,第一个循环从左向右比较并交换元素,第二个循环从右向左比较并交换元素。
1 | public class CockTailSort { |
在将冒泡排序的时候,有一种针对有序区的优化,鸡尾酒排序也可以根据这个思路来进行优化。
回顾一下冒泡排序针对有序区的优化思路:
原始的冒泡排序,有序区的长度和排序的轮数是相等的。比如第一轮排序过后的有序区长度是1,第二轮排序过后的有序区长度是2……
要想优化,我们可以在每一轮排序的最后,记录下最后一次元素交换的位置,那个位置也就是无序数列的边界,再往后就是有序区了。
对于单向的冒泡排序,我们需要设置一个边界值,对于 双向的鸡尾酒排序,我们需要设置两个边界值。
1 | public class CockTailSort { |
代码中使用了左右两个边界值,rightSortBorder 代表右边界,leftSortBorder代表左边界。
在比较和交换元素时,奇数轮从 leftSortBorder 遍历到 rightSortBorder 位置,偶数轮从 rightSortBorder 遍历到 leftSortBorder 位置。
鸡尾酒排序的优点是能够在特定条件下,减少排序的回合数;
缺点是,代码量几乎扩大了一倍。
至于能发挥出优势的场景,就是在 大部分元素已经有序 的情况下,比冒泡完美版还要好。
但是鸡尾酒排序即使优化了,时间复杂度也是O(n^2),和冒泡排序的是时间复杂度相同。
冒泡排序算法需要遍历几次数组。在每次遍历中,比较连续相邻的元素。如果某一堆元素是降序,则互换他们的值;否则,保持不变。
由于较小的值像“气泡”一样逐渐浮向顶部,而较大的值沉向底部,由此得名冒泡排序(bubble sort)或下沉排序(sinking sort)。
冒泡排序的名字很形象,实际实现是相邻两节点进行比较,大的向后移一个,经过第一轮两两比较和移动,最大的元素移动到了最后,第二轮次大的位于倒数第二个,依次进行。这是最基本的冒泡排序,还可以进行一些优化。
优化一: 如果某一轮两两比较中没有任何元素交换,这说明已经都排好序了,算法结束,可以使用一个isSorted做标记,默认为true,如果发生交互则置为false,每轮结束时检测isSorted,如果为false则继续,如果为true则返回。
优化二: 某一轮结束位置为j,但是这一轮的最后一次交换发生在lastExchangedIndex的位置,则lastExchangedIndex到j之间是排好序的,下一轮的结束点就不必是j–了,而直接到lastExchangedIndex即可。
第一次遍历后,最后一个元素称为数组中的最大数。第二次遍历后,倒数第二个元素成为数组中的第二大数。整个过程持续到所有元素都已排好序。
第k次遍历时,不需要考虑最后k-1个元素,因为它们已经排好序了。
朴素版本伪代码描述:1
2
3
4
5
6for (int k = 1; k < arr.length; k++) {
for (int i = 0; i < arr.length - k; i++) {
if (arr[i] > arr[i + 1])
swap arr[i] with arr[i + 1];
}
}
注意到如果在某次遍历中没有发生交换,那么就不必进行下一次遍历,因为所有的元素已经排好序了。可以用下面的伪代码描述needNextPass版本:
如果某一轮两两比较中没有任何元素交换,这说明已经都排好序了,算法结束,可以使用一个needNextPass做标记,默认为false,如果发生交互则置为true,每轮结束时检测needNextPass,如果为true则继续,如果为false则返回。
1 | boolean needNextPass = true; |
代码非常简单,使用双循环来进行排序。外部循环控制所有的回合,内部循环代表每一轮冒泡处理,先进行元素比较,再进行元素交换。这种写法不会拿到offer的。
1 | public class BubbleSort { |
很明显可以看出,自从经过第六轮排序,整个数列已然是有序的了。可是我们的排序算法仍然“兢兢业业”地继续执行第七轮、第八轮。
这种情况下,如果我们能判断出数列已经有序,并且做出标记,剩下的几轮排序就可以不必执行,提早结束工作。
第一步优化,可以使用needNextPass版本或者isSorted版本。这两个版本的含义,从两个flag的字面就能理解其作用。本质上是一样的。
如果某一轮两两比较中没有任何元素交换,这说明已经都排好序了,那么就不必进行下一次遍历,因为所有的元素都已排好序,算法结束。
可以使用一个needNextPass做标记,默认为false,如果发生交换则置为true,每轮结束时检测needNextPass,如果为true则继续,如果为false则返回。
1 | public class BubbleSort { |
这一版代码做了小小的改动,利用布尔变量isSorted作为标记。如果在本轮排序中,元素有交换,则说明数列无序;如果没有元素交换,说明数列已然有序,直接跳出大循环。
如果某一轮两两比较中没有任何元素交换,这说明已经都排好序了,算法结束,可以使用一个isSorted做标记,默认为true,如果发生交换则置为false,每轮结束时检测isSorted,如果为false则继续,如果为true则返回。
1 | public class BubbleSort { |
为了说明问题,用下面的数列为例(在纸上演示一下):
{3, 4, 2, 1, 5, 6, 7, 8}
这个数组的特点是前半部分{3, 4, 2, 1}无序,后半部分{5, 6, 7, 8}有序,并且后半部分的元素已经是数列最大值。
按照冒泡排序的思路来排序:
第一轮:
此时数列:{3, 2, 1, 4, 5, 6, 7, 8}
但是接下来:
第一轮结束,数列有序区包含一个元素: 8
{3, 2, 1, 4, 5, 6, 7, 8}
第二轮:
此时数列:{2, 1, 3, 4, 5, 6, 7, 8}
但是接下来
第二轮结束,数列有序区包含一个元素: 7, 8
{2, 1, 3, 4, 5, 6, 7, 8}
由上面两轮分析,发现问题:右面的许多元素已经是有序了,可是每一轮还是白白比较了许多次。这正是冒泡排序当中另一个需要优化的点。
接下来的讨论,在代码实现部分进行。
这个问题的关键点在哪里呢?关键在于对数列有序区的界定。
按照现有的逻辑,有序区的长度和排序的轮数是相等的。比如第一轮排序过后的有序区长度是1,第二轮排序过后的有序区长度是2,……
实际上,数列真正的有序区可能会大于这个长度,比如例子中仅仅第二轮,后面5个元素实际都已经属于有序区。因此后面的许多次元素比较是没有意义的。
如何避免这种情况呢?我们可以在每一轮排序的最后,记录下最后一次元素交换的位置,那个位置也就是无序数列的边界,再往后就是有序区了。
1 | public class BubbleSort { |
这一版代码中,sortBorder就是无序数列的边界。每一轮排序过程中,sortBorder之后的元素就完全不需要比较了,肯定是有序的。
其实这样的实现,仍然不是最优,有一种排序算法叫做 鸡尾酒排序,是基于冒泡排序的一种升级。具体见博客鸡尾酒排序。
算法复杂度:
时间复杂度(平均): O(n^2)
时间复杂度(最坏): O(n^2)
时间复杂度(最好): O(n)
空间复杂度: O(1)
冒泡排序把小元素往前调或者把大元素往后调,在相邻的两个元素间比较和交换。
如果两个元素相等且相邻,它们不会进行交换;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换。
所以相同元素的前后顺序并没有改变,冒泡排序是一种 稳定排序算法。
因此,希尔排序的基本思想是,将需要排序的序列划分成为若干个较小的子序列,对子序列进行插入排序,通过插入排序能够使得原来序列成为基本有序。这样通过对较小的序列进行插入排序,然后对基本有序的数列进行插入排序,能够提高插入排序算法的效率。
希尔排序的划分子序列不是像归并排序那种的二分,而是采用的叫做增量的技术,例如有十个元素的数组进行希尔排序,首先选择增量为10/2=5,此时第1个元素和第(1+5)个元素配对成子序列使用插入排序进行排序,第2和(2+5)个元素组成子序列,完成后增量继续减半为2,此时第1个元素、第(1+2)、第(1+4)、第(1+6)、第(1+8)个元素组成子序列进行插入排序。这种增量选择方法的好处是可以使数组整体均匀有序,尽可能的减少比较和移动的次数。
二分法中即使前一半数据有序,后一半中如果有比较小的数据,还是会造成大量的比较和移动,因此这种增量的方法和插入排序的配合更佳。
在此我们选择增量gap=length/2,缩小增量继续以gap = gap/2的方式,这种增量选择我们可以用一个序列来表示,{n/2, (n/2)/2, …, 1},称为增量序列。希尔排序的增量序列的选择与证明是个数学难题,我们选择的这个增量序列是比较常用的,也是希尔建议的增量,称为希尔增量,但其实这个增量序列不是最优的。
希尔排序的时间复杂度和增量的选择策略有关,上述增量方法造成希尔排序的不稳定性。
因为直接插入排序在元素基本有序的情况下,效率是很高的,因此希尔排序在时间效率上有很大提高。
无序序列:int a[] = {3, 1, 5, 7, 2, 4, 9, 6};
第一趟时:
n=8; gap = n/2 = 4; 把整个序列共分成了4个子序列{3, 2}、{1, 4}、{5, 9}、{7, 6}
第一趟结束时,数列为:{2, 1, 5, 6, 3, 4, 9, 7};
第二趟时:
gap = gap/2 = 2; 把整个序列共分成了2个子序列{2, 5, 3, 9}、{1, 6, 4, 7}
第一趟结束时,数列为:{2, 1, 3, 4, 5, 6, 9, 7};
第三趟时:
gap = gap/2 = 1; 对整个序列进行 插入排序
##代码实现1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23public class ShellSort {
public static int[] shellSort(int[] arr) {
for (int gap = arr.length / 2; gap >= 1; gap /= 2) {
// 对子序列插入排序
for (int i = gap; i < arr.length; i++) {
int j = i;
int currentElement = arr[i];
while (j - gap >= 0 && arr[j - gap] > currentElement) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = currentElement;
}
}
return arr;
}
public static void main(String[] args) {
int[] testList = new int[] { -6, -3, -2, 7, -15, 1, 2, 2 };
int[] test = shellSort(testList);
System.out.println(Arrays.toString(test));
}
}
时间复杂度(平均): O(n^1.3)
时间复杂度(最坏): O(n^2)
时间复杂度(最好): O(n)
空间复杂度: O(1)
Shell排序通过将数据分成不同的组,先对每一组进行排序,然后再对所有的元素进行一次插入排序,以减少数据交换和移动的次数。
希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小,插入排序对于有序的序列效率很高。
由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以,Shell排序是 不稳定的。
例如,有100个整数需要排序:
第一趟排序,先把它分成50组,每组2个整数,分别排序。
第二趟排序,再把经过第一趟排序后的100个整数分成25组,每组4个整数,分别排序。
第三趟排序,再把前一次排序后的数分成12组,第组8个整数,分别排序。
照这样子分下去,最后一趟分成100组,每组一个整数,这就相当于一次插入排序。
由于开始时每组只有很少整数,所以排序很快。之后每组含有的整数越来越多,但是由于这些数也越来越有序,所以排序速度也很快。
希尔排序平均效率是O(nlogn),其中分组的合理性会对算法产生重要的影响。
Shell排序比冒泡排序快5倍,比插入排序大致快2倍。
Shell排序比起QuickSort,MergeSort,HeapSort慢很多。但是它相对比较简单,它适合于数据量在5000以下并且速度并不是特别重要的场合。它对于数据量较小的数列重复排序是非常好的。
由于开始时每组只有很少整数,所以排序很快。之后每组含有的整数越来越多,但是由于这些数也越来越有序,所以排序速度也很快。
然而,情况并不总是这么理想的,在一些特定(但并不算罕见)的情况下,虽然经过了很多趟排序但是数据却没有变得更有序。例如,如果用上面的算法对下面这些数进行排序:
1, 9, 2, 10, 3, 11, 4, 12, 5, 13, 6, 14, 7, 15, 8, 16
在gap = 1之前的每一趟排序都在浪费时间!
这种坏情形是可以避免的,就是把上面的增量数列(1, 2, 4, 8)改成Hibbard增量(1, 3, 5, 7)由此可见,增量数列的选择对希尔排序的性能有着极大的影响。
Mark Allen Weiss指出,最好的增量序列是Sedgewick提出的 (1, 5, 19, 41, 109, …),该序列的项来自 9 4^i - 9 2^i + 1 和 4^i - 3 * 2^i + 1 这两个算式。
遍历数组,遍历到i时,a0,a1,…,ai-1是已经排好序的,取出ai,从ai-1开始向前和每个比较大小,如果小于,则将此位置元素向后移动,继续先前比较,如果不小于,则放到正在比较的元素之后。
可见相等元素比较是,原来靠后的还是排在后边,所以插入排序是稳定的。
当待排序的数据基本有序时,插入排序的效率比较高,只需要进行很少的数据移动。
插入排序的一个重要的特点是,如果原始数据的大部分元素已经排序,那么插入排序的速度很快(因为需要移动的元素很少)。从这个事实我们可以想到,如果原始数据只有很少元素,那么排序的速度也很快。
--希尔排序就是基于这两点对插入排序作出了改进。
对数列{2, 9, 5, 4, 8, 1, 6}进行排序,可以自己模拟对未排序的数列{9, 5, 4, 8, 1, 6}插入排序,直到数列排好序。
这个算法可以描述为:1
2
3for (int i = 1; i < arr.length; i++) {
将arr[i]插入到已排好序的只线性表中,这样arr[0 ... i]也是排好序的
}
插入arr[i]到arr[0, …, i-1]中有下面的过程:
1.将arr[i]存储在一个名为currentElement的临时变量中;
2.如果arr[i - 1] > currentElement,就将arr[i - 1]移到arr[i]中;
3.如果arr[i - 2] > currentElement,就将arr[i - 2]移到arr[i - 1]中;
4.依此类推,直到arr[i - k] <= currentElement 或者 k > i(传递的是排好序的数列的第一个元素), 将currentElement赋值给arr[i - k + 1]。
插入排序的过程很好理解,代码如下:
1 | public class InsertionSort { |
内外层循环的作用:
外层循环(循环控制变量i)的迭代是为了获取已排好序的子线性表,其范围是arr[0] 到arr[i]。
内层循环(循环控制变量k)将arr[i]插入到arr[0]到arr[i-1]的子线性表。
时间复杂度(平均): O(n^2)
时间复杂度(最坏): O(n^2)
时间复杂度(最好): O(n)
空间复杂度: O(1)
因为在有序部分元素和待插入元素相等的时候,可以将待插入的元素放在前面,所以插入排序是 稳定的。
假设要按照升序排列一个数列 {2, 9, 5, 4, 8, 1, 6}。
选择排序法首先找到数列中最小的数,然后将它和第一个元素交换。接下来,在剩下的数中找到最小数,将它和第二个元素交换,以此类推,直到数列中仅剩一个数为止。
可以在纸上模拟一下具体选择排序过程。
开始编写第一次迭代的代码,找出数列中的最大数,将其与最后一个元素互换,然后观察第二次迭代与第一次的不同之处,接着是第三次,以此类推。通过这样的观察可以写出推广到所有迭代的循环。
1 | for (int i = 0; i < arr.length - 1; i++) { |
代码实现过程比较简单,如下:
1 | public class SelectionSort { |
上面的选择排序法重复地在当前数组中找到最小值,然后将这个最小值与该数组中的第一个数进行交换。
修改成:
重复地选取当前数组中最大值,然后将这个最大值与该数组中的最后一个数进行交换,直到数组中的第一个元素。
1 | public class SelectionSort{ |
选择排序复杂度:
时间复杂度(平均): O(n^2)
时间复杂度(最坏): O(n^2)
时间复杂度(最好): O(n^2)
空间复杂度: O(1)
选择排序是不稳定的算法。
在待排序的数据中,存在多个相同的数据,经过排序之后,他们的对相对顺序依旧保持不变,实际上就是说 array[i] = array[j], i < j
就是array[i]在array[j]之前,那么经过排序之后array[i]依旧在array[j]之前,那么这个排序算法稳定,否则,这个排序算法不稳定
也就是说,只要能举出一个反例来说明这个算法不稳定,那么这个算法就是不稳定的
针对选择排序算法,如下反例:
数列 {5, 8, 5, 2, 9}
这个在执行选择排序的时候,第一遍,肯定会将array[0]=5,交换到2所在的位置:
也就是 {2, 8, 5, 5, 9}
那么很显然,之后的排序我们就会发现,array[2]中的5会出现在原先的array[0]之前,所以选择排序不是一个稳定的排序。
用两个栈实现一个队列。队列的声明如下,请实现它的两个函数appendTail和deleteHead,分别完成在队列尾部插入结点和在队列头部删除结点的功能。
插入删除的过程(在草稿纸上动手画一下):
1 | import java.util.Stack; |
队列和栈的主要区别在于元素进出顺序,因此,需要修改peek()和pop(),以相反的顺序执行。
利用第二个栈反转元素的次序(弹出s1的元素,压入s2)。
在这种实现中,每当执行peek()和pop()操作时,就要将s1的所有元素弹出,压入s2中,然后执行peek()和pop()操作,再将元素压入s1.
但是若连续执行两次peek()和pop()操作,那么,所有元素移来移去,重复移动。 可以延迟元素的移动,即让元素一直留在s2中,只有必须反转元素次序是才移动元素。
stackNew顶端为最新元素,stackOld顶端为最旧元素。在将一个元素出列是,我们希望先移除最旧元素,因此先将元素从stackOld将元素出列。若stackOld为空,在将stackNew中所有元素以相反的顺序移到stackOld中。如果要插入元素,就将其压入stackNew,因为最新元素位于它的顶端。
1 | import java.util.Stack; |
给定一棵二叉树和其中的一个结点,如何找出中序遍历顺序的下一个结点? 树中的结点除了有两个分别指向左右子结点的指针以外,还有一个指向父结点的指针。
首先自己在草稿纸上画图,进行分析(不再展开)。可以发现下一个结点的规律为:
1 | public class NextNodeInBinaryTrees { |
下面是测试代码:
1 | public class NextNodeInBinaryTrees { |
已知二叉树先序遍历序列是A-B-C-D-E-F-G,中序遍历序列是C-B-D-A-E-G-F。由这两个序列可唯一确定一颗二叉树。
从先序遍历序列第一个节点可知二叉树根节点是A。
由节点A在中序遍历序列里位置可知该根点左子树包含节点 B-C-D,右子树包含节点 E-G-F。
由先序序列片段 B-C-D可知,B是A左子树根节点,再结合中序序列片段 C-B-D可知,C和D分别是B的左右子节点。
由先序序列片段E-F-G可知,E是A的右子节点,结合中序序列片段E-F-G可知,G和F均是E的右子树节点。
再由先序序列片段F-G和中序序列片段G-F可知,F是E的右子节点,并且G是F的左子结点。
输入某二叉树的前序遍历和中序遍历的结果,请重建出该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。 例如输入前序遍历序列{1, 2, 4, 7, 3, 5, 6, 8}和中序遍历序列{4, 7, 2, 1, 5, 3, 8, 6},则重建出其二叉树并输出它的头结点。
前序遍历第一个值就是根结点的值,根据该值在中序遍历的位置,可以轻松找出该根结点左右子树的前序遍历和中序遍历,之后又可以用同样方法构建左右子树,所以该题可以采用递归的方法完成。
刚开始思考的时候,想的是构建一个遍历函数,输入为前序和中序遍历的数组,输出为根结点。但是这样的话每次都需要构建子树的数组,非常麻烦。
之后想到,该函数的输入不一定要用数组,因为最初的前序和中序遍历数组已经有了,就直接用该数组的下标来表示子树的数组即可。
即构建函数 construct(int[] pre, int[] in, int pStart, int pEnd, int iStart, int iEnd) ,pre和in始终用最初前序遍历和中序遍历的数组代入,pStart、pEnd代表当前树的前序数组开始和结束位置,iStart、iEnd代表中序数组开始和结束位置。
1 | public class ConstructBinaryTree { |
测试部分代码,如下:
1 | public class ConstructBinaryTree { |
Sass Bootstrap
ES6语法
根据实际情况来定义
根据实际情况来定义
node npm安装速度慢,包之间的依赖关系很难搞
输入一个链表的头结点,从尾到头反过来打印出每个结点的值。节点定义如下:
class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
}
}
输入一个链表的头结点,从尾到头反过来打印出每个结点的值。对于“后进先出”问题,要快速想到”栈“,也同时想到递归。
结点遍历顺序只能从头到尾,但是输出的顺序却为从尾到头,是典型的“后进先出”问题,这就要联想到使用栈,从而也可以联想到使用递归。
1 | import java.util.Stack; |
测试代码:
1 | public class PrintListInReversedOrder { |
递归部分代码也可以像下面这样写,注意体会不同的递归写法
1 | public void printListReversingly_Recursively(Node node) { |
采用的递归,非常简洁,很值得学习。
1 | /** |
##
##
##
本文有两道题。
第一道题,请实现一个函数,把字符串中的每个空格替换成”%20”。例如输入“We are happy.”,则输出“We%20are%20happy.”。
第二道题,实现对一组无序的字母进行从小到大排序(区分大小写),当两个字母相同时,小写字母放在大写字母前。要求时间复杂度为O(n)。
请实现一个函数,把字符串中的每个空格替换成”%20”。例如输入“We are happy.”,则输出“We%20are%20happy.”。
首先要询问面试官是新建一个字符串还是在原有的字符串上修改,本题要求在原字符串上进行修改。
若从前往后依次替换,在每次遇到空格字符时,都需要移动后面O(n)个字符,对于含有O(n)个空格字符的字符串而言,总的时间效率为O(n^2)。
转变思路:先计算需要的总长度,然后从后往前进行复制和替换,则每个字符只需要复制一次即可。时间效率为O(n)。
根据牛客网的编程练习参考,方法的输入为StringBuffer(String无法改变长度,所以采用StringBuffer),输出为String。
1 | public class ReplaceSpaces { |
在同一个类中,与上面的函数拆开的测试代码,为了函数更加简洁。
1 | public class ReplaceSpaces { |
因为java字符串是不可变的,所以也可以使用字符数组来解决这个问题。
处理字符串的时候,常见做法是从尾部开始编辑,从后往前反向操作。这种做法很有用,因为字符串尾部有额外的缓冲,可以直接修改,不必担心会覆写原有数据。
采用上面的做法,进行两次扫描,第一次扫描先数出字符串中有多少空格,从而计算出最终的字符串有多长。第二次扫描才真正开始反向编辑字符串。检测到空格则将%20复制到下一个位置,若不是空白,就复制原先的字符。
1 | public void replaceSpace(char[] str, int length) { |
1 | // 这里用了 for (int i = 0; i < length; i++) { |
如果在从前往后进行复制时,需要多次移动数据,则可以考虑从后往前复制,从而减小移动次数,提高效率。
Java 打印输出字符数组 Java 中,char 类型数组可以直接使用数组名打印。char 类型的数组就相当于一个字符串。 输出流 System.out 是 PrintStream 对象,PrintStream 有多个重载的 println 方法,其中一个就是 public void println(char[ ] x)它会直接调用这个方法来打印字符数组。因此可以直接打印出数组内容,而不是地址。
不要使用 i % 2 == 1 来判断是否是奇数,因为i为负奇数时不成立,请 使用 i % 2 != 0 来判断是否是奇数,或使用 高效式 (i & 1) != 0来判断奇数,奇数与1相与必为1。。
生成char array的方法
1 | //生成char Array |
1 | int[] arr = {3, 4, 9}; |
本题不属于剑指offer。
实现对一组无序的字母进行从小到大排序(区分大小写),当两个字母相同时,小写字母放在大写字母前。要求时间复杂度为O(n)。
使用排序算法在最好的情况下的时间复杂度都在O(nlogn),不满足题目要求。
通常字母为26个,当区分大小写后,变成26*2=52个,所以申请长度为52的int型数组,按照aAbB…zZ(小写字母保存在下标为偶数的位置,大写字母保存在下标为奇数的位置)的顺序一次记录各个字母出现的次数,当记录完成后,就可以遍历这个数组按照各个字母出现的次数来重组排序后的数组。
1 | public class SortCharacters { |
在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
查找整数时,如果从左上角开始查找,情况较为复杂,可以转换思路,从右上角开始查找:左边数字比较小,下边数字比较大,容易进行判断。
当我们需要解决一个复杂的问题是,一个很有效的办法就是从一个具体的问题入手,通过分析简单具体的例子,寻找普遍规律。
1 | public class FindInPartiallySortedMatrix { |
在同一个类中,与上面的函数拆开的测试代码,为了函数更加简洁。
1 | public class FindInPartiallySortedMatrix { |
上面代码考虑了数组数字大小不符合规则的情况,较为繁琐。下面为剑指Offer4 二维数组中的查找(简化版):
1 | public class Solution { |
题外话,UML类图中类之间的关系有:
泛化 = 实现 > 组合 > 聚合 > 关联 > 依赖
软件分析与设计是编码前的2个阶段,分析仅与业务有关,而与技术无关,设计以分析为基础,与具体技术有关。
紧耦合 类间耦合过重 接口封装过度 类间解耦,弱耦合
单一职责原则的英文名称是Single Responsibility Principle,简称是SRP。这个原则存在争议之处在对职责的定义,什么是类的职责,以及怎么划分类的职责。
RBAC模式(Role-Based Access Control,基于角色的访问控制,通过分配和取消角色来完成用户权限的授予和取消,使动作主体(用户)与资源的行为(权限)分离)。
把用户的信息抽取成一个BO(Business Object, 业务对象),把行为抽取成一个Biz(Business Logic,业务逻辑)。
SRP的定义是:应该有且仅有一个原因引起类的变更。
SRP的原话解释:
There should never be more than one reason for a class to change.
就一个类而言,应该只有一个引起它变化的原因。
单一职责原则的优点:
单一职责原则要求一个接口或类只有一个原因引起变化,也就是一个接口或类只有一个职责,它就负责一件事。一个职责就是一个接口。
对于接口,我们在设计的时候一定要做到单一,但是对于实现类就需要多方面考虑了,可能不会满足单一职责原则。
单一职责适用于接口、类,同时也适用于方法。一个方法尽可能做一件事情。
在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美。
里氏替换原则(LSP:Liskov Substitution Principle)的定义:
继承的优点:
继承的缺点:
在类中调用其他类时务必要使用父类或接口,如果不能使用负累或接口,则说明类的设计已经违背了LSP原则。
里氏替换原则包含了4层含义:
如果是覆写,父类和子类的同名方法的输入参数相同,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义。
如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的,参考上面讲的前置条件。
依赖正置就是类间的依赖是实实在在的实现类间的依赖。
依赖倒置原则(Dependence Inversion Principle, DIP)的定义:
High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.
翻译过来,有三重含义:
高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。在Java中,抽象是指接口或抽象类,两者都不是不能直接被实例化的;细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化。依赖倒置原则在Java语言中的表现就是:
更加精髓的定义就是 “面向接口编程”——面向对象设计的精髓之一。
采用依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并发开发引起的风险,提高代码的可读性和可维护性。
依赖是可以传递的。只要做到抽象依赖,即使是多层的依赖传递也无所畏惧。
依赖倒置原则要求我们在程序代码中传递参数时或在关联关系中,尽量 引用层次高的抽象层类,即使用 接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。
最佳实践:
对象的依赖关系又三种方式来传递:
接口的两种类型:
接口隔离原则(ISP:Interface Segregation Principle)定义:
使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。
根据接口隔离原则,当一个接口太大时,我们需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。
看到这里好像接口隔离原则与单一职责原则是相同的。其实接口隔离原则与单一职责原则的审视角度是不相同的,单一职责原则要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分,而接口隔离原则要求接口的方法尽量少。
接口隔离原则是对接口进行规范约束,其包含的以下4层含义:
接口要尽量小
这是接口隔离原则的核心定义。但是”小”是有限度的,首先就是不能违反单一职责原则,已经做到单一职责的接口不应该再分。即,根据接口隔离原则拆分接口时,首先必须满足单一职责原则。
接口要高内聚
高内聚就是提高接口、类、模块的处理能力,减少对外的交互。具体到接口隔离原则就是,要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越少,同时也有利于降低成本。
定制服务
定制服务就是单独为一个个体提供优良的服务。要求就是:只提供访问者需要的方法。
接口设计是有限度的
接口的设计粒度越小,系统越灵活。但是,灵活的同时也带来了结构的复杂化,开发难度增加,可维护性降低。所以接口设计一定要注意适度。
最佳实践:
最小知识原则(Least Knowledge Principle,LKP):
一个对象应该对其他对象有最少的了解。
通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少。
最小知识原则包含以下4层含义:
类和类之间的关系是建立在类间的,而不是方法间。
朋友类:出现在在成员变量、方法的输入输出参数中的类成为成员朋友类,而出现在方法内部的类不属于朋友类。
朋友间也是有距离的
朋友类之间也不应该暴露太多方法。
尽量不要对外公布太多的public和非静态的public变量,尽量内敛,多使用protected、package-private、protected等访问权限。
是自己的就是自己的
如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,就放置在本类中。
谨慎使用Serializable
可能会因为对类的更改未在服务器和客户端之间同步而引起序列化失败问题。
迪米特法则的核心观念就是类间解耦,弱耦合
开闭原则(OCP:Open-Closed Principle)的定义:
Software entities like classes, modules and functions should be open for extension but closed for modifications.(一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。)
一个软件实体(模块、类、接口、方法)应该通过 扩展来实现变化,而不是通过修改已有的代码来实现变化。
开闭原则是最基本的原则,是其他原则和设计模式的精神。
开闭原则的重要性:
开闭原则对测试的影响
所有已经投产的代码都是有意义的,并且都不受系统规则的约束。如果通过修改而不是扩展代码来应对需求变化,需要重新测试已经修改的代码。
开闭原则可以提高复用性
在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑。
开闭原则可以提高可维护性
需求变化的三种类型:
逻辑变化
可以通过修改原有类中的方法的方式来完成
子模块变化
通过扩展来应对
可见视图变化
可见视图是提供给客户使用的界面,该部分的变化一般会引起连锁反应,但还是可以通过扩展来完成变化,这就要看原来的设计是否灵活。
应对需求变化的原则: 保持历史的纯洁性,不去修改历史。
应对需求变化的三种方法:
修改接口
不可靠的,该方案应该被直接否定。
修改实现类
该方法在项目有明确的章程或优良的架构设计时,是一个非常优秀的方法,但是仍有缺陷。
通过扩展实现变化
好方法,修改少,风险小。
最佳实践:
抽象约束
首先通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;第二,参数类型,引用对象尽量使用接口或者抽象类,而不是实现类;第三,抽象层尽量保持稳定,一旦确定既不允许修改。
元数据(metadata)控制模块行为
尽量使用元数据(用来描述环境和数据的数据,通俗的说就是配置参数)来控制程序的行为,减少重复开发。
制定项目章程
对于项目来说,约定优于配置。
封装变化
第一,将相同的变化封装到一个接口或抽象类中;第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。
在一个长度为n的数组里的所有数字都在0到n-1的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。例如,如果输入长度为7的数组 {2, 3, 1, 0, 2, 5, 3} ,那么对应的输出是重复的数字2或者3。
从哈希表的思路拓展,重排数组:把扫描的每个数字(如数字m)放到其对应下标(m下标)的位置上,若同一位置有重复,则说明该数字重复。
(在动手写代码前应该先想好测试用例)
复杂度:
时间复杂度: O(n)
空间复杂度: O(1)
尽管有两重循环,但是每个数字最多只要交换两次就能找到属于它的位置,因此钟的时间按复杂度是O(n)。
另外所有操作时在输入数组上进行的,不需要分配内存,空间复杂度是O(1)。
1 | public class FindDuplicateNumber1 { |
在同一个类中,与上面的函数拆开的测试代码,为了函数更加简洁。
1 | public class FindDuplicateNumber1 { |
这里的代码为牛客网上通过的代码,如下:
1 | public class Solution { |
在一个长度为n+1的数组里的所有数字都在1到n的范围内,所以数组中至少有一个数字是重复的。请找出数组中任意一个重复的数字,但不能修改输入的数组。例如,如果输入长度为8的数组{2, 3, 5, 4, 3, 2, 6, 7},那么对应的输出是重复的数字2或者3。
数组长度为n+1,而数字只从1到n, —说明必定有重复数字—。
可以由二分查找法拓展:把1~n的数字从中间数字m分成两部分,若前一半1~m的数字数目超过m个,说明重复数字在前一半区间,否则,在后半区间m+1~n。每次在区间中都一分为二,知道找到重复数字。
更简单的思路:把该数组看作一个链表,下标代表当前结点,值代表next指针。
时间复杂度说明:函数countRange()将被调用O(logn)次,每次需要O(n)的时间。
时间复杂度:O(nlogn) (while循环为O(logn),coutRange()函数为O(n))
空间复杂度:O(1)
1 | public class FindDuplicateNumber2 { |
在同一个类中,与上面的函数拆开的测试代码,为了函数更加简洁。
1 | public class FindDuplicateNumber2 { |
如果单例初始值是null,还未构建,则构建单例对象并返回。这个写法属于单例模式当中的懒汉模式。
如果单例对象一开始就被new Singleton()主动构建,则不再需要判空操作,这种写法属于饿汉模式。
1 | public class Singleton { |
1 | public class Singleton { |
两次判空的机制叫做双重检测机制。 但是不是绝对的安全!!!1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class Singleton {
private static Singleton singleton = null;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) { //双重检测
synchronized (Singleton.class) { //同步锁
if (singleton == null) { //双重检测
singleton = new Singleton();
}
}
}
return singleton;
}
}
!!!!隐藏的漏洞
假设这样的场景,当两个线程一先一后访问getInstance方法的时候,当A线程正在构建对象,B线程刚刚进入方法。
这种情况表面看似没什么问题,要么singleton还没被线程A构建,线程B执行 if(singleton == null)的时候得到true;要么singleton已经被线程A构建完成,线程B执行 if(singleton == null)的时候得到false。
真的如此吗?答案是否定的。这里涉及到了JVM编译器的 指令重排。
指令重排是什么意思呢?比如java中简单的一句 singleton = new Singleton,会被编译器编译成如下JVM指令:
memory =allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
singleton =memory; //3:设置instance指向刚分配的内存地址
但是这些指令顺序并非一成不变,有可能会经过JVM和CPU的优化,指令重排成下面的顺序:
memory =allocate(); //1:分配对象的内存空间
singleton =memory; //3:设置instance指向刚分配的内存地址
ctorInstance(memory); //2:初始化对象
当线程A执行完1,3,时,singleton对象还未完成初始化,但已经不再指向null。此时如果线程B抢占到CPU资源,执行 if(singleton == null)的结果会是false,从而返回一个 没有初始化完成的singleton对象。
如何避免这一情况呢?我们需要在instance对象前面增加一个 修饰符volatile。
1 | public class Singleton { |
经过volatile的修饰,当线程A执行singleton = new Singleton的时候,JVM执行顺序是什么样?始终保证是下面的顺序:
memory =allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
singleton =memory; //3:设置instance指向刚分配的内存地址
如此在线程B看来,singleton对象的引用要么指向null,要么指向一个初始化完毕的Singleton,而不会出现某个中间态,保证了安全。
1 | public class Singleton { |
这里有几个需要注意的点:
从外部无法访问静态内部类LazyHolder,只有当调用Singleton.getInstance方法的时候,才能得到单例对象SINGLETON。
SINGLETON对象初始化的时机并不是在单例类Singleton被加载的时候,而是在调用getInstance方法,使得静态内部类LazyHolder被加载的时候。因此这种实现方式是利用 classloader的加载机制来实现懒加载,并保证构建单例的线程安全。
!!!!缺点:无法防止利用反射来重复构建对象。 这也是单例模式共同的问题。
1 | public static Singleton { |
1 | public class SingeltonFactory { |
1 | //获得构造器 |
代码可以简单归纳为三个步骤:
第一步,获得单例类的构造器。
第二步,把构造器设置为可访问。
第三步,使用newInstance方法构造对象。
最后为了确认这两个对象是否真的是不同的对象,我们使用equals方法进行比较。毫无疑问,比较结果是false。
1 | public enum SingletonEnum { |
让我们来做一个实验,仍然执行刚才的反射代码:
1 | //获得构造器 |
执行获得构造器这一步的时候,抛出了异常。
唯一的缺点是,并非适用懒加载,其单例对象是在枚举类被加载的时候进行初始化的。
单例模式实现 | 是否线程安全 | 是否懒加载 | 是否防止反射构建 |
---|---|---|---|
双重锁检测(第三版) | 是 | 是 | 否 |
静态内部类 | 是 | 是 | 否 |
枚举 | 是 | 否 | 是 |
几点补充:
C/C++ 的枚举类型是int类型常量值,不安全。
java在1.5 加入枚举。
Java内存模型简称JMM(Java Memory Model),是Java虚拟机所定义的一种抽象规范,用来屏蔽不同硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果。
主内存(Main Memory)
主内存可以简单理解为计算机当中的内存,但又不完全等同。主内存被所有的线程所共享,对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。
工作内存(Working Memory)
工作内存可以简单理解为计算机当中的CPU高速缓存,但又不完全等同。每一个线程拥有自己的工作内存,对于一个共享变量来说,工作内存当中存储了它的“副本”。
线程对共享变量的所有操作都必须在工作内存进行,不能直接读写主内存中的变量。不同线程之间也无法访问彼此的工作内存,变量值的传递只能通过主内存来进行。
volatile关键字具有许多特性,其中最重要的特性就是保证了 用volatile修饰的变量对所有线程的可见性。
为什么volatile关键字可以有这样的特性?这得益于java语言的先行发生原则(happens-before)。在计算机科学中,先行发生原则是两个事件的结果之间的关系,如果一个事件发生在另一个事件之前,结果必须反映,即使这些事件实际上是乱序执行的(通常是优化程序流程)。
这里所谓的事件,实际上就是各种指令操作,比如读操作、写操作、初始化操作、锁操作等等。先行发生原则作用于很多场景下,包括同步锁、线程启动、线程终止、volatile。
volatile关键字只能保证变量的可见性,并不能保证变量的原子性。 不能保证线程安全!
因此,什么时候适合用volatile呢?
指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。
指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。
然而,指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,会影响到多线程的执行结果。
内存屏障(Memory Barrier)是一种CPU指令。
内存屏障也称为内存栅栏或栅栏指令,是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。 这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行。
内存屏障共分为四种类型:
在一个变量被volatile修饰后,JVM会为我们做两件事:
在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。
volatile特性之一:
保证变量在线程之间的可见性。可见性的保证是基于CPU的内存屏障指令,被JSR-133抽象为happens-before原则。
volatile特性之二:
阻止编译时和运行时的指令重排。编译时JVM编译器遵循内存屏障的约束,运行时依靠CPU屏障指令来阻止重排。
几点补充:
动态规划(dynamic programming)是通过组合子问题而解决整个问题的解。 分治法是将问题划分成一些独立的子问题,递归地求解各子问题,然后合并子问题的解。 动态规划适用于子问题不是独立的情况,也就是各子问题包含公共的子子问题。
此时,分治法会做许多不必要的工作,即重复地求解公共的子问题。动态规划算法对每个子问题只求解一次,将其结果保存起来,从而避免每次遇到各个子问题时重新计算答案。
最优性原理体现为问题的最优子结构特性。当一个问题的最优解中包含了子问题的最优解时,则称该问题具有最优子结构特性。
最优性原理是动态规划的基础。任何一个问题,如果失去了这个最优性原理的支持,就不可能用动态规划设计求解。
所谓无后效性是指:“下一时刻的状态只与当前状态有关,而和当前状态之前的状态无关,当前状态是对以往决策的总结”。
首先,动态规划方法适合的题型4个基本特点是:
两种方法:
一般分为两个步骤:
有三个核心元素:
核心: 最优子结构、边界条件、状态转移方程
解题步骤: 1.建立数学模型 2.写代码求解问题
如何建模?先写出所求问题的最优子结构,进而分析出边界和状态转移方程,数学模型即这2者的组合,对于2输入维度动态规划,画表格帮助分析,行列分别代表1个输入维度
如何求解?
建好模后,根据方程组写出自底向上的动态规划代码,一维输入就是1个for循环,二维输入就是2个for循环,如果方程组比较抽象,可以画表格帮助分析
寻找一条从左上角(arr[0][0])到右下角(arr[m - 1][n - 1])的路线,使得沿途经过的数组中的整数和最小。
从右下角倒着分析,最后一步到达arr[m - 1][n - 1]只有两条路径,即通过arr[m - 2][n - 1]或arr[m - 1][n - 2]到达。
推广到一半的情况,假设到达arr[i - 1][j]与arr[i][j - 1]的最短路径的和为f(i - 1, j)和f(i, j - 1),那么到达arr[i][j]的路径上所有数字和的最小值为 f(i, j) = min{f(i - 1, j), f(i, j - 1)} + arr[i][j]
递归方法实现效率太低,有大量重复计算过程。
动态规划其实是一种空间换时间的算法,通过缓存计算的中间值,减少重复计算的次数,从而提高算法的效率。
递归从arr[m - 1][n - 1]开始逆向通过递归来求解,采用动态规划可以自底向上求解,以便使用前面计算出来的结果。
对于本题而言,显然有边界条件,f(i, 0) = arr[0][0] + arr[i][0], f(0, j) = arr[0][0] + arr[0][j]。
状态转移方程: f(i, j) = min{f(i - 1, j), f(i, j - 1)} + arr[i][j]
可以把遍历过程中求出所有的f(i, j)的值,保存到另一个二维数组中供后续使用。
1 | public class Test { |
对二维数组遍历一次,时间复杂度为O(mn),申请了一个二维数组来保存中间结果,空间复杂度为O(mn)。
知道 i-1 座金矿的最大产量就一定能知道 i 座金矿的最大产量,这是 最优子结构,每个人要知道i座金矿的最大产量就必须知道知道 i-1 座金矿的最大产量,这是 子问题重叠,最终当考虑第 1 座金矿的最大产量时,只要看是否有足够人手开采第 1 座金矿,有的话,答案是已探明的储量,没有的话就是0,然后答案汇报到上级,上级再得出第 2 座金矿开采与不开采得出的较大产量,再往上汇报…,这就是 边界,而每个人从上级得到的前提都是不同的,上级决定开不开采,再将这个前提之一告诉下属,而下属不需要考虑上级给另一个下属什么前提,这是 子问题独立。
把金矿数量设为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]);
把状态转移方程翻译成递归程序,递归结束条件是方程中的边界。 因为每个状态有两个最优子结构,所以递归的执行流程类似于一棵高度为N的二叉树。 时间复杂度为O(2^n)。
1 | public static int getMostGold(int n, int w, int[] g, int[] p) { |
画表格分析,表格第一列代表给定前1-5做金矿的情况,也就是N的取值。表格第一行代表给定的工人数,也就是w的取值。
其余空白格表示,给定n和w值对应的黄金获得数,也就是F(n,w)。
1工人 | 2工人 | 3工人 | 4工人 | 5工人 | 6工人 | 7工人 | 8工人 | 9工人 | 10工人 | |
---|---|---|---|---|---|---|---|---|---|---|
1金矿 | ||||||||||
2金矿 | ||||||||||
3金矿 | ||||||||||
4金矿 | ||||||||||
5金矿 |
第一个金矿的信息:400金,5工人
1工人 | 2工人 | 3工人 | 4工人 | 5工人 | 6工人 | 7工人 | 8工人 | 9工人 | 10工人 | |
---|---|---|---|---|---|---|---|---|---|---|
1金矿 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
2金矿 | ||||||||||
3金矿 | ||||||||||
4金矿 | ||||||||||
5金矿 |
第二个金矿的信息:500金,5工人
根据F(n,w) = Max(F(n-1, w), F(n-1, w-5) + 500), 5-9格子为500,第2行第10个格子,n=2,w=10 F(n-1, w-5) = 400 Max(400, 400+500) = 900
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 | 500 | 500 | 500 | 500 | 500 | 900 |
3金矿 | ||||||||||
4金矿 | ||||||||||
5金矿 |
第三个金矿的信息:200金,3工人
根据F(n,w) = Max(F(n-1, w), F(n-1, w-5) + 200)
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 | 500 | 500 | 500 | 500 | 500 | 900 |
3金矿 | 0 | 0 | 200 | 200 | 500 | 500 | 500 | 700 | 700 | 900 |
4金矿 | ||||||||||
5金矿 |
第四个金矿的信息:300金,4工人
根据F(n,w) = Max(F(n-1, w), F(n-1, w-5) + 300)
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 | 500 | 500 | 500 | 500 | 500 | 900 |
3金矿 | 0 | 0 | 200 | 200 | 500 | 500 | 500 | 700 | 700 | 900 |
4金矿 | 0 | 0 | 200 | 300 | 500 | 500 | 500 | 700 | 800 | 900 |
5金矿 |
第五个金矿的信息:350金,3工人
根据F(n,w) = Max(F(n-1, w), F(n-1, w-5) + 350)
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 | 500 | 500 | 500 | 500 | 500 | 900 |
3金矿 | 0 | 0 | 200 | 200 | 500 | 500 | 500 | 700 | 700 | 900 |
4金矿 | 0 | 0 | 200 | 300 | 500 | 500 | 500 | 700 | 800 | 900 |
5金矿 | 0 | 0 | 350 | 350 | 500 | 550 | 650 | 850 | 850 | 900 |
上述表格,比如5金矿10工人的结果,来自于4金矿7工人和4金矿10工人, Max(900, 500+350)=900
不需要存储整个表格,只需要存储前一行的结果,就可以推导出新的一行。使用动态规划如下:
1 | int getMostGold(int n, int w, int[] g, int[] p) { |
上述方法利用两层迭代,外层迭代对表格每一行的迭代过程中,会保留上一行的结果数组preResults,并循环计算当前的结果数组results。
方法的时间复杂度为O(n*w),空间复杂度是O(w)。
当金矿更多的时候,动态规划的优势就能体现出来。
然而,当工人为1000时,动态规划的时间复杂度为5 * 1000 = 5000,开辟1000单位的空间。 递归的时间复杂度是O(2^n),需要计算32次,开辟5单位(递归深度)的空间。
动态规划方法的时间和空间都和w成正比,而简单递归和w无关,所以工人很多的时候,动规反而不如递归。
所以说,每一种算法都没有绝对的好与坏,关键看应用场景。
1 | // 该内部类对象用于备忘录算法中作为HashMap存储的键 |
二叉搜索树(Binary Search Tree, BST)可以用一个链式节点的集合来表示二叉树。 每个节点都包含一个数值和两个称为left和right的链接,分别指向左孩子和右孩子。
1 | /** This inner class is static, because it does not access |
变量root指向根节点。如果树为空,root的值为null。
二叉树分为根节点、左子树和右子树,分别表示为 +、1、2。
二叉树本身是递归定义的,相应的遍历很自然就成为一种递归问题。
递归遍历操作的关键点是递归体和递归出口:
基于递归的遍历算法易于编写,操作简单,但可读性差,系统需要维护相应的工作栈,效率不是很高。递归转化为非递归的基本思想是如何实现原本是系统完成的递归工作栈,为此,可以仿照递归执行过程中工作栈状态变化而得到。
对二叉树进行前序、中序和后序遍历时都开始于根节点或结束于根节点,经由路线也相同。彼此差别在于对节点访问时机的选择不同。三种遍历方式都是沿着左子树不断深入下去,当到达二叉树左下节点而无法往下深入时,就向上逐一返回,行进到最近深入时曾遇到节点的右子树,然后进行同样的深入和返回,直到最终从根节点的右子树返回到根节点。
这样,遍历时返回顺序与深入节点顺序恰好相反,因此可以在实现二叉树遍历过程中,使用一个工作栈来保存当前深入到的节点信息,以供后面返回需要时使用。
遍历顺序为: 1+2 可以递增顺序显示BST中所有节点。
中序遍历的黄金口诀:当前节点为空,从栈中弹出一个元素,当前节点向右移动;当前节点不为空,压栈,当前节点向左移动
1 | public void inorder() { |
1 | public void inorder() { |
+12 深度优先遍历法(depth-first traversal)与前序遍历法相同。
1 | public void preorder() { |
1 | public void preorder() { |
12+
1 | public void postorder() { |
1 | public void postorder() { |
1 | public void breadthFirstTraversal() { |
二叉搜索树中搜索一个元素,可以从根节点向下扫描,知道找到匹配元素,或者达到一棵空子树为止。1
2
3
4
5
6
7
8
9
10
11
12
13public boolean search(E e) {
TreeNode<E> current = root; // 当前指针指向根节点
while (current != null) {
if (e.compareTo(current.element) < 0) {
current = current.left; // 比当前指针的元素小,则往左
} else if (e.compareTo(current.element) > 0) {
current = current.right; // 比当前指针的元素大,则往右
} else { // 元素匹配
return true; // 找到元素 return true
}
}
return false;
}
BST中插入一个元素,需要确定在书中插入的位置,关键思路是确定新节点的父节点所在的位置。
1 | public boolean insert(E e) { |
为了从一棵二叉搜索树中删除一个元素,首先需要定位该元素位置,然后再删除该元素以及重新连接树前,考虑两种情况–该节点有或者没有左子节点。
情况1:当前节点没有左子结点。只需将该节点的父节点和该节点的右子节点相连。如果当前节点是叶子节点,属于情况1;
情况2:当前节点有左子结点。假设rightMost指向包含current节点的左子树中的最大元素的节点,而parentOfRightMost指向rightMost节点的父节点。使用rightMost节点中的元素替代current节点中的元素值,将parentOfRightMost节点和rightMost节点的左子节点相连,然后删除rightMost节点。 rightMost作为最大值不能有右节点,但是可能会有左子节点!1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56public boolean delete(E e) {
// 如果current为root,那么parent为null
TreeNode<E> parent = null; // 指向current节点的父节点
TreeNode<E> current = root; // 指向二叉搜索树中包含该元素的节点
while (current != null) { // 递归寻找current节点
if (e.compareTo(current.element) < 0) {
parent = current;
current = current.left;
} else if (e.compareTo(current.element) > 0) {
parent = current;
current = current.right;
} else {
break; // 找到包含e的current节点
}
}
if (current == null) {
return false; // 元素不在树内
}
// 情况1:当前节点没有左子节点
if (current.left == null) {
//只需将该节点的父节点和该节点的右子节点相连
if (parent == null) {
root = current.right;
} else {
if (e.compareTo(parent.element) < 0) {
parent.left = current.right; // e是父节点的左子结点
} else {
parent.right = current.right; // e是父节点的右子结点
}
}
} else {
// 情况2:当前节点有左子节点
TreeNode<E> parentOfRightMost = current; // rightMost节点的父节点
TreeNode<E> rightMost = current.left; // 当前节点的左子树最右端的节点
while (rightMost.right != null) {
parentOfRightMost = rightMost;
rightMost = rightMost.right; // 向右不断递归
}
// 用rightMost节点的内容替换current中的内容
current.element = rightMost.element;
// rightMost的父节点和rightMost的左子节点相连
if (parentOfRightMost.right == rightMost) {
// rightMost是右子节点
parentOfRightMost.right = rightMost.left;
} else {
// rightMost是左子节点
parentOfRightMost.left = rightMost.left;
}
}
size--;
return true;
}
1 | public int getNumberOfLeaves() { |
1 | public int getNumberOfNonLeaves() { |
1 | public boolean equals(BST<E> tree) { |
1 | public BST<E> clone() throws CloneNotSupportedException { |
使用一个Tree的接口来定义树的所有常用操作,提供AbstractTree的抽象类部分实现了Tree,最后实现了BST类。
1 | public interface Tree<E> extends Iterable<E>{ |
1 | public abstract class AbstractTree<E> implements Tree<E> { |
1 | import java.util.ArrayList; |
1 | import java.util.Iterator; |
堆排序(heap sort)使用二叉堆(binary heap),它是一棵 完全二叉树,每个节点大于或等于它的任意一个孩子。
如果一颗二叉树的每一层都是满的,或者最后一层可以不填满并且最后一层的叶子都是靠左放置的,那么这棵二叉树就是完全的(complete)。
如果堆的大小是事先知道的,那么可将堆存储在一个ArrayList或一个数组中。树根在位置0处,它的两个子节点在位置1和位置2处。
对于位置i处的节点,它的:
给堆添加一个新结点,首先将它添加到堆的末尾,然后按如下方式重建这棵树:1
2
3
4
5
6
7
8
9
10
11
12Let the last node be the current node;
while (the current node is greater than its parent) {
Swap the current node with its parent;
Now the current node is one level up;
}
令最后一个节点的那个做当前节点;
while (当前节点大于他的父节点) {
将当前节点和它的父节点交换;
现在当前节点往上面进了一个层级;
}
经常需要从堆中删除最大的元素,也就是这个堆中的根节点。在删除根节点之后,就必须重建这棵树以保持堆的属性。重建该树的算法如下所示:
1 | Move the last node to replace the root; |
Comparable可以认为是一个内比较器,实现了Comparable接口的类有一个特点,就是这些类是可以和自己比较的,至于具体和另一个实现了Comparable接口的类如何比较,则依赖compareTo方法的实现,compareTo方法也被称为自然比较方法。如果开发者进入一个Collection的对象想要Collections的sort方法帮你自动进行排序的话,那么这个对象必须实现Comparable接口。compareTo方法的返回值是int,有三种情况:
Comparator可以认为是是一个外比较器,个人认为有两种情况可以使用实现Comparator接口的方式:
1)一个对象不支持自己和自己比较(没有实现Comparable接口),但是又想对两个对象进行比较;2)一个对象实现了Comparable接口,但是开发者认为compareTo方法中的比较方式并不是自己想要的那种比较方式
Comparator接口里面有一个compare方法,方法有两个参数T o1和T o2,是泛型的表示方式,分别表示待比较的两个对象,方法返回值和Comparable接口一样是int,有三种情况:
1 | import java.util.Comparator; |
1 | import java.util.ArrayList; |
使用Comparator需要编写测试用例,实现Comparator接口的的GeomatricObjectComparator,以及抽象父类GeometricObject,抽象方法getArea,在子类Circle和Rectangle类中实现。排序算法为HeapSort。
1 | import java.util.Comparator; |
1 | import java.io.Serializable; |
1 | import java.util.Date; |
1 | public class Circle extends GeometricObject { |
1 | public class Rectangle extends GeometricObject { |
设h表示包含n个元素的堆的高度。 堆的高度为O(logn)。
由于add方法会追踪从叶子节点到根节点的路径,因此向堆中添加一个新元素最多需要h步。所以建立一个包含n个元素的数组的初始堆需要O(nlogn)时间。
由于remove方法要跟踪从根节点到叶子节点的路径,因此从堆中删除根节点后,重建堆最多需要h步。由于要调用n次remove方法,所以产生一个有序数组需要的总时间为O(nlogn)。
堆排序不需要额外的数组空间,空间效率高于归并排序。
1 | import java.util.Comparator; |
你的设计需要支持以下的几个功能:
1 | public class Twitter { |
Java集合框架定义了java.util.Set接口来对集合建模。三种具体的实现是HashSet、LinkedHashSet和TreeSet,HashSet采用散列实现,LinkedHashSet采用LinkedList实现,TreeSet采用红黑树实现。
1 | public interface MySet<E> extends Iterable<E> { |
1 | import java.util.LinkedList; |
1 | public class TestMyHashSet { |
java.util.HashMap
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
HashMap和Hashtable有什么区别?
一般在不需要并发的时候使用HashMap,并发的时候使用锁粒度更细的ConcurrentHashMap。
迭代HashMap使用了快速失败机制,fail-fast,是Java集合的一种错误检测机制。当多个线程对集合进行结构上的改变操作时,就有可能产生fail-fast事件。假如线程1和线程2,当线程1通过iterator遍历集合A中的元素时,如果线程2修改了集合A的结构(删除、增加新元素),程序就会抛出ConcurrentModificationException异常,从而产生fail-fast事件。
遍历HashMap的四种方法:
keySet需要首先把key转换成itaretor,然后根据key在map中取出value,需要两个操作,而entrySet只一次操作就把key和value都取到entry中来,效率更高。foreach和itaretor是等价的。
1 | public static void traversal(Map<String, String> map) { |
1 | public static void traversal(Map<String, Integer> map) { |
1 | public static void traversal(Map<String, String> map) { |
1 | public static void traversal(Map<String, String> map) { |
byte、short、int、char类型的搜索键,简单地转换为int。
long类型的散列码: hashCode = (int)(key ^ (key >> 32));
double类型: long bits = Double.doubleToLongBits(key);
int hashCode = (int)(bits ^ (bits >> 32));
假设散列表的索引处于0~N-1 之间。设N为2的幂值。
h(hashCode) = hashCode % N;
h(hashCode) = hashCode & (N - 1);
为了保证散列码是均匀分布的,java.util.HashMap采用了补充的散列函数与主散列函数一起使用。
(…((s0 * b) + s1)b + s2)b + … + sn-2)b + sn-1
在String中,b取值31,来计算上述多项式,以达到最小化冲突,其中si = s.charAt(i)。
除此之外,还有平方取中法,数字分析法等等。
当两个键映射到散列表中的同一个索引上时,冲突发生。链地址法将具有同样的散列索引的条目都放在同一个位置,而不是寻找一个新位置。链地址法的每个位置使用一个桶来放置多个条目。
可以使用数组,ArrayList或LinkedList来实现一个桶。
使用LinkedListl来实现一个映射表。 此处实现与jdk中的实现不同,只是为了演示理解。
1 | import java.util.Set; |
1 | import java.util.*; |
开放地址法(open addressing)是在冲突发生时,在散列表中找到一个开放位置的过程。
按照顺序找到下一个可用的位置,如果冲突发生在hashTable[k % N],则检查hashTable[(k+1) % N],依次类推。
查找时,依次检查k,k+1,…,直到达到一个空单元,或者找到。
缺点:会形成一次簇(cluster),从而降低查找时间。
1 | import java.util.ArrayList; |
二次探测法从索引 (k + j*j) % N位置的单元开始审查。
1 | import java.util.ArrayList; |
1 | import java.util.ArrayList; |
1 | class Solution { |
LeetCode 334
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组char[]的形式给出。不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用O(1)的额外空间解决这一问题。
示例 1:
输入: “hello” 输出: “olleh” 示例 2:
输入: “A man, a plan, a canal: Panama” 输出: “amanaP :lanac a ,nalp a ,nam A”
1 |
|
In each loop, We caculate cur[i], which represent the sum of Uniq() for all substrings whose last char is S.charAt(i).
For example,
S = ‘ABCBD’
When i = 2, cur[2] = Uniq(‘ABC’) + Uniq(‘BC’) + Uniq(‘C’)
When i = 3, cur[3] = Uniq(‘ABCB’) + Uniq(‘BCB’) + Uniq(‘CB’) + Uniq(‘B’)
Notice, we append char ‘B’ into each previous substrings. Only in substrings ‘CB’ and ‘B’, the char ‘B’ can be identified as uniq. The contribution of ‘B’ from cur[2] to cur[3] is i - showLastPosition[‘B’]. At the same time, in substrings ‘ABCB’, ‘BCB’, the char ‘B’ can‘t’ be identified as uniq any more, the previous contribution of ‘B’ should be removed.
So we have’cur[i] = cur[i - 1] - contribution[S.charAt(i)] + (i - showLastPosition[S.charAt(i)])
Then the new contribution[S.charAt(i)] = i - showLastPosition[S.charAt(i)]
The final result is the sum of all cur[i].
1 | class Solution { |
将数组线性表定义为栈类中的数据域,而不是使用继承ArrayList的方法是因为,一般来说加强或扩展类的功能时才使用继承的方式。
AbstractList <— Vector <— Stack
ArrayList和Vector类是一样的。如果不需要同步使用ArrayList类。
Stack方法:
1 | import java.util.ArrayList; |
1 | public class TestGenericStack { |
java API中java.util.Queue是一个接口
可以用Queue作为父类引用,LinkedList作为实例
java.util.Queue<TreeNode
Queue是LinkedList的父类接口
继承关系:
Collection <— Queue <— Deque <— LinkedList
Queue <— AbstractQueue <— PriorityQueue
Queue的方法:
使用继承和组合的方式实现队列和栈都是可行的,但是组合更好一点,因为可以定义一个全新的栈类和队列类,而不需要继承ArrayList和LinkedList中不必要和不合适的方法。
类仅在它们需要被加强或修改时,才会使用继承!!!!
1 | import java.util.LinkedList; |
1 | public class GenericQueue<E> extends java.util.LinkedList<E> { |
对于使用Comparable和Comparator实现的Heap类见文章堆排序
普通队列是一种先进先出的数据结构,元素在rear添加,在front删除。在优先队列中,元素被赋予优先级,当访问元素时,具有最高优先级的元素最先删除。
Largest-in, first-Out 高进先出
在java API中继承顺序如下:
Queue <— AbstractQueue <— PriorityQueue
可以使用堆实现优先队列,其中根节点是队列中具有最高优先级的对象。
使用Comparable接口
1 | public class MyPriorityQueue<E extends Comparable<E>> { |
1 | import java.util.Comparator; |
由于ArrayList是用数组实现的,所以 get(int index) 和 set(int index, E e) 方法可以通过下标访问和修改元素,也可以用 add(E e) 方法在线性表末尾添加元素,它们是高效的。但是 add(int index, E e) 和 remove(int index) 方法的效率很低,因为需要移动潜在的大量元素。
为了提高在表中开始位置添加和删除元素的效率,可以采用链式结构来实现线性表。
每个节点都包含元素和一个名为next的数据域,next指向下一个元素。如果节点是线性表中的最后一个,那么它的指针数据域next所包含的值是null。
1 | // This class is only used in LinkedList, so it is private. |
1 | public void addFirst(E e) { |
1 | public void addLast(E e) { |
1 | public void add(int index, E e) { |
1 | public E removeFirst() { |
1 | public E removeLast() { |
1 | public E remove(int index) { |
1 |
|
1 | public interface MyList<E> extends Iterable<E> { |
1 | public abstract class MyAbstractList<E> implements MyList<E> { |
常用的List方法:
常用的List实现类有:
可以使用ArrayList和LinkedList来存储线性表。使用数组实现ArrayList,使用链表实现LinkedList,前者开销比后者小。但是如果需要在线性表的开始位置插入和删除元素,那么LinkedList的效率会高一点。下表总结了ArrayList和LinkedList中方法的时间复杂度。
方法 | ArrayList | LinkedList |
---|---|---|
add(e: E) | O(1) | O(1) |
add(index: int, e: E) | O(n) | O(n) |
clear() | O(1) | O(1) |
contains(e: E) | O(n) | O(n) |
get(index: int) | O(1)** | O(n) |
indexOf(e: E) | O(n) | O(n) |
isEmpty() | O(1) | O(1) |
lastIndexOf(e: E) | O(n) | O(n) |
remove(e: E) | O(n) | O(n) |
size() | O(1) | O(1) |
remove(index: int) | O(n) | O(n) |
set(index: int, e: E) | O(n) | O(n) |
addFirst(e: E) | O(n) | O(1)** |
removeFirst() | O(n) | O(1)** |
使用数组来实现动态数据结构,处理方法是:当数组不能再存储线性表的新元素时,创建一个更大的新数组来替换当前数组。
1 | public void add(int index, E e) { |
通过创建一个新数组并且将其复制给data,老的数组和保存在数组中的数据变成垃圾,将自动被JVM回收。1
2
3
4public void clear() {
data = (E[]) new Object[INITIAL_CAPACITY];
size = 0;
}
最后一个元素不再使用,设置为null。1
2
3
4
5
6
7
8
9public E remove(int index) {
checkIndex(index);
E e = data[index];
for (int j = index; j < size - 1; j++)
data[j] = data[j + 1];
data[size - 1] = null;
size--;
return e;
}
采用集合的实现模式,在MyList接口中提供通用的操作,抽象类部分实现了包括集合操作的addAll、removeAll和containsAll等方法,最后在MyArrayList类中实现了数组线性表。
为了便于手机查看,采用倒序的显示方式。将接口、抽象类和测试用例放在最后。
1 | package algorithms.arrayList; |
1 | package algorithms.arrayList; |
1 | package algorithms.arrayList; |
1 | package algorithms.arrayList; |
第一步,基调,文章以基调为“王”。基调对了,事半功倍;基调错了,哪怕你传递的信息再重要,我也不会关注。
正确的基调能传递出文章真正的价值,也就是你的价值。职场写作可以分成四大类,那么每一类都需要传递你的什么价值呢?
如果明白了要呈现自己什么价值,那你拿捏起文章的基调来,正确的概率就会很高。
以常见的六种具体文案为例,探讨一下它们的标志性的基调。
不要写成一份业绩的流水账——因为你的工作结果,领导在看你的总结之前就知道。可他为什么还要看?
他要读到你的分析能力;他希望你能从对工作感性的认知,上升到理性的规律性的总结;他需要你协助他,对未来做出更正确的决定。
由此来看,年终总结不是回顾,而是行动指南。好的年终总结应该:
在进展汇报里,不能只提问题,向领导讨教,而应该给出方案,向领导汇报。讨教的公式是:您告诉我该怎么做。汇报的公式是:我这样做,您同意吗?
这里我想提醒你,哪怕领导没有要求你写进展汇报,你也要养成定期向他汇报的习惯。
你需要将解决问题过程中的一些重要发现、关键结论、阶段性成果等小胜利,实时呈现给他,让他读出你的认真,让他看到你不断提高工作能力的过程。
在收集充分的事实资料后,你的措辞是“资料显示、数字表明”,而不是“我认为、我估计”。这种报告才能展现事情的真相,才会有让人惊喜的收获。
有的人这么写:
“客户投诉如下,请指示。”
不能出现这样的措辞:“似属可行” “酌情办理”。
不能仅仅写行为。一个项目的成功,不是行动了就可以,而是众多可交付结果的总和。
这里我再和你多说一点。最好的项目计划书,是按照 OKR目标管理工具来写的。O:Objective 目标,KR:Key Results 关键结果。
简单地说,计划就是由目标和关键结果构成。其中,大目标可以分解成小目标,关键结果是用来衡量这些目标有没有完成的。按照这个工具来写,你的基调自然就是可交付的结果。
以上就是构思的第一步——基调。
TCS的第二步,C:content,内容。
你意识到了吗?我们在学校里的写作,读者是教授,他读你的文章的时候是有薪水拿的。在职场上的写作,可不是这样。
职场上的读者,大多是“甲方读者”,比如领导、客户、重要合作伙伴,等等。也就是说,他们没有义务读完你的文章,他们可以随时终止阅读。
怎么才能让这些甲方读者被你的内容吸引呢?请遵照这个原则:
先从“作者逻辑”切换到“读者逻辑”,提供“所有必需信息”,然后按照“要话先说”的顺序排列。
举个例子,如果要写一份给投资人的商业计划书,俗称项目BP(Business Plan)。我们先做发散思维,想一想在初次接触时,投资人需要哪些信息来判断是否对项目有信心,是否愿意投资参与该项目?然后,为这些信息排序。
我最担心你一上来就急着展示自己公司有多厉害,这是作者逻辑。没搭好台就展示自己,操之过急。相反,文章应该从分析整个行业下手,彰显市场空缺。搭建好舞台后,才有机会介绍自己。
风险投资人也是要最大限度地规避风险的。而先选择行业,再选择个体,是他规避风险的方式,是读者逻辑。
按照这个逻辑,文章怎样写呢?
第一部分,写 “项目愿景”。 也就是,你要做一件什么样的大事。
这部分需要有一句高度精炼的话,比如黄太吉的“打造以煎饼果子为核心的中式时尚快餐连锁品牌”。
对方会问,这件事为什么值得做?于是你给出 “市场痛点”。
比如,摩拜单车是为了解决最后一公里的交通痛点。如果投资人根本不认为这是个痛点,后面一切都白写了。
再往下,是 “解决方案”。
论证的是,我们正在做的这个事业,真正能解决得了这个痛点,这把钥匙能开得了这把锁。OK,读者表示认同。
然后,他会想,“市场潜力”大吗?
这时,你该展示市场调查结果了,包括市场规模、用户画像、竞品分析等等。
那他会接着问,为什么要投你们,而不是别人呢?
你要阐述自己的 “独特优势”。包括行业经验、核心技术、牛人团队,等等。
OK, 这时读者对你有点儿感觉了。
下一部分,趁他对你有感觉的时候,拉着他一块儿画饼,你要写 “发展规划”了。
写清楚盈利模式和发展路径。也许你觉得奇怪,为什么这个内容现在才写?其实投资人很有经验,他只要决定和你一块儿干,他会在后期帮你一起完善盈利模式,清晰发展路径。
最后,做 “财务分析”,告诉他,你们缺多少钱。
为什么把融资额度放最后写呢?
你想想看,有经验的销售想把东西卖给你的时候,通常不会一上来就报价的。他们都是把商品的价值点全部讲完以后,才把价格说出来,这个时候出现的数字,会让对方觉得是个合理的数字。
至于 “退出机制” “利润分红”这些内容,在初次接触中并不重要,它们都有谈判的空间,所以可以不写。
在“要话先说”的顺序下,文章环环相扣,你前一部分阐述得精彩,读者才有兴趣问下一个问题,了解下一个部分。
进入构思的第三步,TCS的S,structure结构。在你的文章里,可以用向下想三层的逻辑搭建金字塔结构。金字塔的塔尖,是你鲜明的观点或建议,而下面两层,是证据。
在这个金字塔里,塔尖,你鲜明的观点或建议,是 “投钱给我们吧”。
读者自然会问:为什么?
金字塔第二层,两方面:因为有市场,因为我们强。
读者又问:有市场?——哪块市场?你们强?——强在哪里?
金字塔进入第三层的阐述。
这种顺序,让读者先读到塔尖,这样,不仅他能理解你更快,甚至认同你都更快。
因为塔尖就像一个熠熠生辉的宝石,抢先占据了他的阅读记忆,也许他会惴惴不安,也许他会兴奋不已,于是,在他接下来的阅读中,他会自己有意无意地将下面的证据与塔尖做链接,这个被说服过程由他自己来完成了。
这就是先想再写的TCS构思法。如果不这么做,文章可能会一点一点地偏离方向,最后主张模糊、脉络不明。而修改一份粗糙的文案,花费时间和力气更多。
写完以后,还有一个步骤,压缩,删掉三分之一的文字。相信我,你可以做得到。 商务写作谨遵KISS原则:keep it short and simple。 句中不能有多余的词,段落中不能有多余的句子。
你可能会问我:文章到底是要简洁还是详实呢?
文字表达要简洁,内容提供要详实。
深度阐述和实例细节,会让文章更长,但不是冗长。 “用最少的字表达最多的意思”,这是我们追求的语言审美境界。
可以用这三个方法来做到 “减无可减”:
简洁的文字体现自律。
第一讲要结束了。在这一讲中,你要养成先想再写,然后再删的写作习惯。
写之前,做好TCS三步构思——基调、内容、结构。
第一步, 基调为王,用正确的基调传递出4类文案的真正价值;
第二步, 内容,提供“所有必需信息”,从“作者逻辑”切换到“读者逻辑”,并要话先说;
第三步, 结构,用向下想三层的金字塔结构,把你的假设,变成结论,用结论去说服他人。
最后, 商务写作的KISS原则要求我们压缩文章,我教了你三个方法。
戴愫老师和很多学员做过深度交流,有三类职场人士:
很多人有以下困惑:
书面沟通是一种单向沟通。你写,发给他,他读;或他写,发给你,你读。这是它最大的缺陷!
没有一个正常成年人喜欢“被通知、被命令”,大家都喜欢“被商量、被探讨”。这意味着,我们作为作者,要通过“单向沟通的形式”,让读者读出“双向沟通的幻觉”。
制造这种幻觉很重要。为什么?我们来看第二点。
这个标准是:
你明白我的意思了,或者我明白你的意思了?不,这只是60分的沟通。
我们把这件事儿办成了?这是80分。为什么只是80分,如果说这件事情是办成了,但我以后再也不想与你合作了,这就是赢了这场战役,失去了整个战争嘛,不行的。
那怎么做到100分呢?100分沟通的标准是,我和你理性、感性都达成一致了。注意,感性也要求达成一致。
职场上永远不要以为把事情搞定有多么难,真正难搞定的是“人”。哪怕是个纯技术问题,也是“人”的作为和不作为。
是“信任”。 信任是超越一切沟通形式、一切沟通技巧最基础的前提。
汉字的“信”,左边一个人,右边一个言,我们正常的顺序是,先相信这个人,再相信他说的话。这个顺序有可能放在每次书面沟通中吗?
没可能。很多时候,你和读者没有见过面,哪怕你们见过面,他对你的人格也不一定了解。
所以,我们需要学习:怎样在文章中大量使用信息化语言,在文字上与读者建立起信任,让他在不认识你,不熟知你的前提下,直接相信你写的话。
作为写作者的你要用大量的信息化语言,在文字上与读者建立起信任(这是基本前提),并且,让读者通过单向沟通的形式,读出双向沟通的幻觉(这可以通过写作内容和文字表达来实现),最后,双方理性、感性都达成一致(这是100分沟通的衡量标准)。
重复一遍,书面沟通这项技能本质上就是:
作为写作者的你要用大量的信息化语言,在文字上与读者建立起信任,让读者通过单向沟通的形式,读出双向沟通的幻觉,最后双方理性、感性都达成一致。
戴愫老师曾经用这门课,在线下教授了上万名职场人士,他们反馈:
在写作时,有效表达只是最基本的层次,有效沟通才是终极目标。
每一次写作,并不是自己做了一次信息传递,每一次写作,你都在为读者创造一次阅读体验。
一篇好文章,不会让读者读完后困惑地问“so what”(所以呢?),而是发出“wow”(diao a!)的赞叹。
Spring框架是Java应用最广的框架,它优秀的理念包括 IoC (Inversion of Control, 控制反转)和 AOP (Aspect Oriented Programming, 面向切面编程)。
Spring IoC(Inversion of Control,控制反转)承担了一个资源管理、整合、即插即拔的功能。举个例子,在Java中我们为国家插座设计两种接口,那我们就可以为两种插座分别new两个对象,但是如果要更改上千次这种插座,难道要new很多对象吗?所以不用new的方式创建对象,而是使用配置的方式,然后使用配置的方式,然后让Spring IoC容器自己通过配置去找到插座。
不需要去找资源(Bean),只要向Spring IoC容器描述所需资源,Spring IoC自己会找到你所需要的资源,这就是Spring IoC的理念。这样就把Bean之间的依赖关系解耦了,更容易写出结构清晰的程序。除此之外,Spring IoC还提供对Java Bean生命周期的管理,可以延迟加载,可以在其生命周期内定义一些行为等,更加有效地使用和管理Java资源。
如果使用new的方式来使用插座,代码如下。1
2
3
4User user = new User();
Socket socket = new Socket1();
user.setSocket(socket);
user.useSocket();
这样会有一个弊端,如果使用其他插座,就需要修改代码。可以使用配置的方式代替new的方式创建对象,让Spring IoC容器通过配置去找到插座。1
2
3
4<bean id="socket" class="Socket1" />
<bean id="user" class="xxx.User">
<spanroperty name="socket" ref="socket" />
</bean>
只需要修改XML配置文件,就可以切换:1
2
3
4
5- <bean id="socket" class="Socket1" />
+ <bean id="socket" class="Socket2" />
<bean id="user" class="xxx.User">
<spanroperty name="socket" ref="socket" />
</bean>
显然,IoC的目标就是为了管理Bean而存在的。
IoC的目标就是为了管理Bean,而Bean是Java面向对象(OOP)的基础设计,比如声明一个用户类、插座类等都是基于面向对象的概念。
有些情况是面向对象没办法处理的。
举个例子,生产部门的订单、生产部门、财务部门三者符合OOP的设计理念。订单发出,生产部门审批通过准备付款,但是财务部门发现订单的价格超支了,需要取消订单。 显然超支限定已经不只是影响财务部门了,还会影响生产部门之前所做的审批,需要把它们作废。把预算超支这个条件称为切面,它影响了订单、生产部门和财务部门3个OOP对象。在现实中,这样的切面条件跨越了3个甚至更多的对象,并且影响了它们的协作。所以只用OOP并不完善,还需要面向切面的编程,通过它去管理在切面上的某些对象之间的协作。
Spring AOP常用于数据库事务的编程,很多情况都如同上而的例子,我们在做完第一步数据库数据更新后,不知道下一步是否会成功,如果下一步失收,会使用数据库事务的回滚功能去回滚事务,使得第一步的数据库更新也作废。
在Spring AOP实现的数据库事务管理中,是以异常作为消息的。在默认的情况下(可以通过Spring的配置修改),只要Spring接收到了异常信息,它就会将数据库的事务回滚,从而保证数据的一致性。这样我们就知道在Spring的事务管理中只要让它接收到异常信息,它就会回滚事务,而不需要通过代码来实现这个过程。
比如上面的例子,可用一段伪代码来进行一些必要的说明。1
2
3
4
5
6
7
8
9
10private void proceed(Order order) {
//判断生产部门是否通过订单,数据库记录订单
boolean pflag = productionDept.isPass(order);
if(pflag) {//如果生产部门通过进行财务部门审批
if (financialDept.isOverBudget(order)) {//财务审批是否超限
//抛出异常回滚事务,之前的订单操作也会被回滚
throw new RuntimeException("预算超限!!");
}
}
}
Spring AOP的编程屏蔽了数据库代码,只需关注业务代码,知道只要发生了一场异常,Spring会回滚事务就足够了。
MyBatis的前身是Apache的开源项目iBatis,是一个基于 Java的持久层框架。2010年这个项目由Apache software foundation迁移到Google code,并更名为MyBatis。2013年11月,MyBatis迁移到GitHub上,目前由GitHub提供维护。
MyBatis的优势在于灵活,它几乎可以代替JDBC,同时提供了接口编程。目前MyBatis的数据访问层 DAO(Data Access Objects)是不需要实现类的,它只需要一个接口和XML(或者注解)。MyBatis提供自动映射、动态SQL、级联、缓存、注解、代码和SQL分离等特性,使用方便,同时也可以对SQL进行优化。因为其具有封装少、映射多样化、支持存储过程、可以进行SQL优化等特点,使得它取代了Hibernate成为了Java互联网中首选的持久框架。
Hibernate作为一种十分流行的框架,它有其无可替代的优势,这里我们有必要讨论一下它和MyBatis的区别。由于MyBatis和Hibernate都是持久层框架,都会涉及数据库,所以首先定义一个数据库表一角色表(t_role)。1
2
3
4
5create table t_role(
编号 int(12) primary key,
角色名称 varchar(60),
备注 varchar(1024)
);
用一个POJO(Plain Ordinary Java Object)和这张表定义的字段对应起来。1
2
3
4
5
6
7
8
9package com.learn.chapter1.pojo;
public class Role implements java.io.Serializable {
private Integer id;
private String roleName;
private String note;
/**
* setter and getter
**/
}
无论是MyBatis还是Hibernate都是依靠某种方法,将数据库的表和POJO映射起来的,这样就可以操作POJO来完成相关的逻辑了。
映射规则
语言 | 映射方法 |
---|---|
MyBatis | 使用注解方式会受到一定的限制,通常使用XML方式实现映射关系 |
Hibernate | XML和注解提供映射规则 |
把POJO对象和数据库表相互映射的框架称为对象关系映射(Object Relational Mapping,ORM,或O/RM,或O/R mapping)框架。Hibernate的设计理念是完全面向POJO的,不需要编写SQL就可以通过映射关系来操作数据库,是一种全表映射的体现;MyBatis需要提供SQL去运行。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<hibernate-mapping>
<class name="com.learn.chapter1.pojo.Role" tbale="t_role">
<id name="id" type="java.lang.Integer">
<column name="id" />
<generator class="identity" />
</id>
<spanroperty name="roleName" type="string">
<column name="role_name" length="60" not-null="true" />
</property>
<spanroperty name="note" type="string">
<column name="note" length="512" />
</property>
</class>
</hibernate-mapping>
首先,对POJO和表t_role进行了映射配置,把两者映射起来了。然后,对POJO进行操作,从而影响t_role表的数据,比如对其增删改查可以按照如下操作。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30Session session = null;
Transaction tx = null;
try {
//打开Session
session = HibernateUtil.getSessionFactory().openSession();
//事务
tx = session.beginTransaction();
//POJO
Role role = new Role();
role.setId(1);
role.setRoleName("rolename1");
role.setNote("note1"); //保存
Role role2 = (Role) session.get(Role.class, 1); //查询
role2.setNote("修改备注");
session.update(role2); //更新
System.err.println(role2.getRoleName());
session.delete(role2); //删除
tx.commit(); //提交事务
}
catch (Exception ex) {
if (tx != null && tx.isActive()) {
tx.rollback(); //回滚事务
}
ex.printStackTrace();
}
finally {
if (session != null && session.isOpen()) {
session.close();
}
}
这里没有SQL,因为Hibernate会根据映射关系来生成对应的SQL。
可以自己拟定SQL规则,能精确定义SQL,从而符合移动互联网高并发、大数据、高性能、高响应的需求。MyBatis也需要映射文件把POJO和数据库的表对应起来。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<mapper namespace="com.learn.chapter1.mapper.RoleMapper">
<resultMap id="roleMap" type="com.learn.chaper1.pojo.Role">
<id property="id" column="id" />
<result property="roleName" column="role_name" />
<result property="note" column="note" />
</resultMap>
<select id="getRole" resultMap="roleMap">
select id, role_name, note from t_rle where id = #{id}
</select>
<delete id="deleteRole" parameterType="int">
delete from t_role where id = #{id}
</delete>
<insert id="insertRole" parameterType="com.learn.chapter1.pojo.Role">
insert into t_role(role_name, note) values(#{roleName}, #{note})
</insert>
<update id="updateRole" parameterType="com.learn.chapter1.pojo.Role">
update t_role set
role_name = #{roleName},
note = #{note}
where id = #{id}
</update>
</mapper>
这里的resultMap元素用于定义映射规则,而实际上MyBatis在满足一定的规则下,完成自动映射,而增删改查对应着insert、delete、select、update四个元素。mapper元素中的namespace属性,要和一个接口的全限定名保持一致,而里面的SQL的id也需要和接口定义的方法完全保持一致,定义MyBatis映射文件。1
2
3
4
5
6
7
8
9package com.learn.chapter1.mapper;
import com.learn.chapter1.pojo.Role;
public interface RoleMapper {
public Role getRole(Integer id);
public int deleteRole(Integer id);
public int insertROle(Role role);
public int updateROle(Role role);
}
定义了MyBatis映射文件,不需要定义一个实现类。
显然MyBatis在业务逻辑上和Hibernate是大同小异的。其区别在于,MyBatis需要提供接口和SQL,这意味着工作量会比较大,但是由于自定义SQL、映射关系,所以灵活性、可优化性超过了Hibernate。互联网可优化性、灵活性是十分重要的,因为一条SQL的性能可能相差十几倍到几十倍。
Hibernate和MyBatis的增、删、改、查,对于业务逻辑层来说大同小异,对于映射层而言Hibernate的配置不需要接口和SQL,相反MyBatis是需要的。对于Hibernate而言,不需要编写大量的SQL,就可以完全映射,同时提供了日志、缓存、级联(级联比MyBatis强大)等特性,此外还提供HQL( Hibernate Query Language)对POJO进行操作,使用十分方便,但是它也有致命的缺陷。
由于无须SQL,当多表关联超过3个的时候,通过Hibernate的级联会造成太多性能的丢失,又或者我现在访问一个财务的表,然后它会关联财产信息表,财产又分为机械、原料等,显然机械和原料的字段是不一样的,这样关联字段只能根据特定的条件变化而变化而Hibernate无法支持这样的变化。遇到存储过程,Hibernate只能作罢。更为关键的是性能,在管理系统的时代,对于性能的要求不是那么苛刻,但是在互联网时代性能就是系统的根本,响应过慢就会丧失客户,试想一下谁会去用一个经常需要等待超过10秒以上的应用呢?
以上的问题MyBatis都可以解决,MyBatis可以自由书写SQL、支持动态SQL、处理列表、动态生成表名、支持存储过程。这样就可以灵活地定义查询语句,满足各类需求和性能优化的需要,这些在互联网系统中是十分重要的。
但MyBatis也有缺陷。首先,它要编写SQL和映射规则,其工作量稍微大于Hibernate。 其次,它支持的工具也很有限,不能像Hibernate那样有许多的插件可以帮助生成映射代码和关联关系,而即使使用生成工具,往往也需要开发者进一步简化,MyBatis通过手工编码,工作量相对大些。所以对于性能要求不太苛刻的系统,比如管理系统、ERP等推荐使用Hibernate;而对于性能要求高、响应快、灵活的系统则推荐使用MyBatis。
也许你还在问为什么使用Spring MVC,Struts 2.x不才是主流吗?看SSH的概念多火!其实很多初学者都混淆了一个概念,SSH时间上指的是Struts 1.x + Spring + Hibernate,这个概念已经有十几年的历史了。在Structs 1.x的时代,Structs1.x是当之无愧的MVC框架的霸主,但是在新的MVC框架涌现的时代,形式已经完全不是这样的了,Structs 2.x借助了Structs 1.x的好名声,让国内开发者认为Structs 2.x是霸主继任者(其实两者在技术上没有任何关系),导致国内的很多程序员大多数学习基于Structs 2.x的框架,有一个貌似很火的概念出来了S2SH(Struts 2.x + Spring + Hibernate)整合开发。
根据JRebel厂商统计,Spring MVC的市场占有率是40%,而Structs 2.x只有可怜的6%。Spring MVC是目前Java Web框架当之无愧的霸主。
Spring MVC和三层架构是什么关系,可能很多读者会抢答:
MVC:Model + View + Controller (数据模型+视图+控制器)
三层架构:Prensentation tier + Application tier + Data tier(展现层+应用层+数据访问层)
那MVC和三层架构有什么关系呢?但是实际上MVC只存在三层架构的展现层,M实际上是数据模型,是包含数据的对象。在Spring MVC里,有一个专门的类叫Model,用来和V之间的数据交互、传值;V指的是视图页面,包含JSP、freeMarker、Velocity、Thymeleaf、Tile等;C当然就是控制器(Spring MVC的注解@Controller的类)。
而三层架构是整个应用的架构,是由Spring框架负责管理的。一般项目中会有Service层、DAO层,这两个反馈在应用层和数据访问层。
经典的Java EE架构大致上都可以分为如下几层:
NoSQL(Not Only SQL)存储的数据是半结构化的,Redis成为主要的NoSQL工具。
在Java Web中,以Spring + Spring MVC + MyBatis(SSM)作为主流框架,SSM+Redis的结构框图如下:
在后面会讲解这些技术的使用方法、原理和优化方法。