diff --git "a/Leetocde\351\242\230\350\247\243.md" "b/Leetocde\351\242\230\350\247\243.md" index 203e2e8fef..03ebe04517 100644 --- "a/Leetocde\351\242\230\350\247\243.md" +++ "b/Leetocde\351\242\230\350\247\243.md" @@ -47,8 +47,6 @@ * [Trie](#trie) * [图](#图) * [位运算](#位运算) -* [其它](#其它) - * [注意细节](#注意细节) * [参考资料](#参考资料) @@ -4883,18 +4881,6 @@ public int maxProduct(String[] words) { } ``` -# 其它 - -## 注意细节 - -- 从功能测试、边界测试和负面测试来考虑输入。 - -- 两个浮点数比较是否相等不直接使用 == - -```java -Math.abs(a - b) <= 0; -``` - # 参考资料 - [Leetcode](https://leetcode.com/problemset/algorithms/?status=Todo) diff --git a/README.md b/README.md index 5340691e7d..73f3ddc205 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ -# 笔记 +# 数据结构与算法 -- [Leetocde题解](https://github.com/CyC2018/CodeInterview/blob/master/Leetocde%E9%A2%98%E8%A7%A3.md) +整理自《算法 第四版》,只整理了前三章,因为面试常问的内容主要集中在排序和树,之后的内容春招结束再整理。 -- [剑指offer题解](https://github.com/CyC2018/CodeInterview/blob/master/%E5%89%91%E6%8C%87offer%E9%A2%98%E8%A7%A3.md) +# Leetocde题解 -- [2016校招真题题解-未完成](https://github.com/CyC2018/CodeInterview/blob/master/2016%E6%A0%A1%E6%8B%9B%E7%9C%9F%E9%A2%98%E9%A2%98%E8%A7%A3.md) +对题目做了一个分类,并对每种题型的解题思想做了总结。 -# OJ 地址 +已经整理了 300+ 的题目,基本涵盖所有经典题目,持续整理中。 -- 牛客网 : https://www.nowcoder.com/activity/oj +# 剑指offer题解 + +目录按《剑指 Offer 第二版》编排,在牛客网的在线编程中出现的题目都已经 AC。 + +# 2016校招真题题解 + +未完成 -- Leetcode : https://leetcode.com/problemset/algorithms/ diff --git "a/\345\211\221\346\214\207offer\351\242\230\350\247\243.md" "b/\345\211\221\346\214\207offer\351\242\230\350\247\243.md" index 0813cb93b1..070431a1e6 100644 --- "a/\345\211\221\346\214\207offer\351\242\230\350\247\243.md" +++ "b/\345\211\221\346\214\207offer\351\242\230\350\247\243.md" @@ -1,5 +1,6 @@ * [第二章 面试需要的基础知识](#第二章-面试需要的基础知识) + * [2. 实现 Singleton](#2-实现-singleton) * [3. 数组中重复的数字](#3-数组中重复的数字) * [4. 二维数组中的查找](#4-二维数组中的查找) * [5. 替换空格](#5-替换空格) @@ -14,9 +15,11 @@ * [11. 旋转数组的最小数字](#11-旋转数组的最小数字) * [12. 矩阵中的路径](#12-矩阵中的路径) * [13. 机器人的运动范围](#13-机器人的运动范围) + * [14. 剪绳子](#14-剪绳子) * [15. 二进制中 1 的个数](#15-二进制中-1-的个数) * [第三章 高质量的代码](#第三章-高质量的代码) * [16. 数值的整数次方](#16-数值的整数次方) + * [18. 删除链表中重复的结点](#18-删除链表中重复的结点) * [19. 正则表达式匹配](#19-正则表达式匹配) * [20. 表示数值的字符串](#20-表示数值的字符串) * [21. 调整数组顺序使奇数位于偶数前面](#21-调整数组顺序使奇数位于偶数前面) @@ -27,11 +30,14 @@ * [26. 树的子结构](#26-树的子结构) * [第四章 解决面试题的思路](#第四章-解决面试题的思路) * [27. 二叉树的镜像](#27-二叉树的镜像) - * [28. 对称的二叉树](#28-对称的二叉树) + * [28.1 对称的二叉树](#281-对称的二叉树) + * [28.2 平衡二叉树](#282-平衡二叉树) * [29. 顺时针打印矩阵](#29-顺时针打印矩阵) * [30. 包含 min 函数的栈](#30-包含-min-函数的栈) * [31. 栈的压入、弹出序列](#31-栈的压入弹出序列) - * [32. 从上往下打印二叉树](#32-从上往下打印二叉树) + * [32.1 从上往下打印二叉树](#321-从上往下打印二叉树) + * [32.3 把二叉树打印成多行](#323--把二叉树打印成多行) + * [32.3 按之字形顺序打印二叉树](#323-按之字形顺序打印二叉树) * [33. 二叉搜索树的后序遍历序列](#33-二叉搜索树的后序遍历序列) * [34. 二叉树中和为某一值的路径](#34-二叉树中和为某一值的路径) * [35. 复杂链表的复制](#35-复杂链表的复制) @@ -41,6 +47,8 @@ * [第五章 优化时间和空间效率](#第五章-优化时间和空间效率) * [39. 数组中出现次数超过一半的数字](#39-数组中出现次数超过一半的数字) * [40. 最小的 K 个数](#40-最小的-k-个数) + * [41.1 数据流中的中位数](#411-数据流中的中位数) + * [14.2 字符流中第一个不重复的字符](#142-字符流中第一个不重复的字符) * [42. 连续子数组的最大和](#42-连续子数组的最大和) * [43. 从 1 到 n 整数中 1 出现的次数](#43-从-1-到-n-整数中-1-出现的次数) * [45. 把数组排成最小的数](#45-把数组排成最小的数) @@ -53,29 +61,86 @@ * [54. 二叉搜索树的第 k 个结点](#54-二叉搜索树的第-k-个结点) * [55 二叉树的深度](#55-二叉树的深度) * [56. 数组中只出现一次的数字](#56-数组中只出现一次的数字) - * [57. 和为 S 的两个数字](#57-和为-s-的两个数字) + * [57.1 和为 S 的两个数字](#571-和为-s-的两个数字) + * [57.2 和为 S 的连续正数序列](#572-和为-s-的连续正数序列) * [58.1 翻转单词顺序列](#581-翻转单词顺序列) * [58.2 左旋转字符串](#582-左旋转字符串) + * [59. 滑动窗口的最大值](#59-滑动窗口的最大值) * [61. 扑克牌顺子](#61-扑克牌顺子) * [62. 圆圈中最后剩下的数](#62-圆圈中最后剩下的数) +* [63. 股票的最大利润](#63-股票的最大利润) * [64. 求 1+2+3+...+n](#64-求-123n) * [65. 不用加减乘除做加法](#65-不用加减乘除做加法) * [66. 构建乘积数组](#66-构建乘积数组) * [第七章 两个面试案例](#第七章-两个面试案例) * [67. 把字符串转换成整数](#67-把字符串转换成整数) -* [未分类](#未分类) - * [平衡二叉树](#平衡二叉树) - * [和为 S 的连续正数序列](#和为-s-的连续正数序列) - * [ 字符流中第一个不重复的字符](#-字符流中第一个不重复的字符) - * [ 删除链表中重复的结点](#-删除链表中重复的结点) - * [按之字形顺序打印二叉树](#按之字形顺序打印二叉树) - * [ 把二叉树打印成多行](#-把二叉树打印成多行) - * [ 二叉搜索树的第 k 个结点](#-二叉搜索树的第-k-个结点) - * [滑动窗口的最大值](#滑动窗口的最大值) + * [68. 树中两个节点的最低公共祖先](#68-树中两个节点的最低公共祖先) # 第二章 面试需要的基础知识 +## 2. 实现 Singleton + +**经典实现** + +以下实现中,私有静态变量被延迟化实例化,这样做的好处是,如果没有用到该类,那么就不会创建该私有静态变量,从而节约资源。这个实现在多线程环境下是不安全的,因为多个线程能够同时进入 if(uniqueInstance == null) 内的语句块,那么就会多次实例化 uniqueInstance 私有静态变量。 + +```java +public class Singleton { + private static Singleton uniqueInstance; + private Singleton() { + } + public static Singleton getUniqueInstance() { + if (uniqueInstance == null) { + uniqueInstance = new Singleton(); + } + return uniqueInstance; + } +} +``` + +**线程不安全问题的解决方案一** + +只需要对 getUniqueInstance() 方法加锁,就能让该方法一次只能一个线程访问,从而避免了对 uniqueInstance 变量进行多次实例化的问题。但是这样有一个问题是一次只能一个线程进入,性能上会有一定的浪费。 + +```java +public static synchronized Singleton getUniqueInstance() { + if (uniqueInstance == null) { + uniqueInstance = new Singleton(); + } + return uniqueInstance; +} +``` +**线程不安全问题的解决方案二** + +不用延迟实例化,采用直接实例化。 + +```java +private static Singleton uniqueInstance = new Singleton(); +``` + +**线程不安全问题的解决方案三** + +考虑第一个解决方案,它是直接对 getUniqueInstance() 方法进行加锁,而实际上只需要对 uniqueInstance = new Singleton(); 这条语句加锁即可。使用两个条件语句来判断 uniqueInstance 是否已经实例化,如果没有实例化才需要加锁。 + +```java +public class Singleton { + private volatile static Singleton uniqueInstance; + private Singleton() { + } + public static synchronized Singleton getUniqueInstance() { + if (uniqueInstance == null) { + synchronized (Singleton.class) { + if (uniqueInstance == null) { + uniqueInstance = new Singleton(); + } + } + } + return uniqueInstance; + } +} +``` + ## 3. 数组中重复的数字 **题目描述** @@ -131,7 +196,7 @@ public boolean Find(int target, int [][] array) { **题目描述** -请实现一个函数,将一个字符串中的空格替换成“%20”。例如,当字符串为We Are Happy.则经过替换之后的字符串为We%20Are%20Happy。 +请实现一个函数,将一个字符串中的空格替换成“%20”。例如,当字符串为 We Are Happy. 则经过替换之后的字符串为 We%20Are%20Happy。 **题目要求** @@ -346,7 +411,7 @@ public int RectCover(int target) { **题目描述** -把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。 输入一个非递减排序的数组的一个旋转,输出旋转数组的最小元素。 例如数组{3,4,5,1,2}为{1,2,3,4,5}的一个旋转,该数组的最小值为1。 NOTE:给出的所有元素都大于0,若数组大小为0,请返回0。 +把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。 输入一个非递减排序的数组的一个旋转,输出旋转数组的最小元素。 例如数组 {3, 4, 5, 1, 2} 为 {1, 2, 3, 4, 5} 的一个旋转,该数组的最小值为 1。 NOTE:给出的所有元素都大于 0,若数组大小为 0,请返回 0。 ```java public int minNumberInRotateArray(int[] array) { @@ -362,7 +427,7 @@ public int minNumberInRotateArray(int[] array) { **题目描述** -请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一个格子开始,每一步可以在矩阵中向左,向右,向上,向下移动一个格子。如果一条路径经过了矩阵中的某一个格子,则该路径不能再进入该格子。 例如 a b c e s f c s a d e e 矩阵中包含一条字符串"bcced"的路径,但是矩阵中不包含"abcb"路径,因为字符串的第一个字符b占据了矩阵中的第一行第二个格子之后,路径不能再次进入该格子。 +请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一个格子开始,每一步可以在矩阵中向左,向右,向上,向下移动一个格子。如果一条路径经过了矩阵中的某一个格子,则该路径不能再进入该格子。 例如 a b c e s f c s a d e e 矩阵中包含一条字符串 "bcced" 的路径,但是矩阵中不包含 "abcb" 路径,因为字符串的第一个字符 b 占据了矩阵中的第一行第二个格子之后,路径不能再次进入该格子。 ```java private int[][] next = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}}; @@ -398,11 +463,12 @@ private boolean backtracking(char[][] m, int rows, int cols, char[] str, boolean } ``` + ## 13. 机器人的运动范围 **题目描述** -地上有一个m行和n列的方格。一个机器人从坐标0,0的格子开始移动,每一次只能向左,右,上,下四个方向移动一格,但是不能进入行坐标和列坐标的数位之和大于k的格子。 例如,当k为18时,机器人能够进入方格(35,37),因为3+5+3+7 = 18。但是,它不能进入方格(35,38),因为3+5+3+8 = 19。请问该机器人能够达到多少个格子? +地上有一个 m 行和 n 列的方格。一个机器人从坐标 0, 0 的格子开始移动,每一次只能向左,右,上,下四个方向移动一格,但是不能进入行坐标和列坐标的数位之和大于 k 的格子。 例如,当 k 为 18 时,机器人能够进入方格(35, 37),因为 3+5+3+7 = 18。但是,它不能进入方格(35, 38),因为 3+5+3+8 = 19。请问该机器人能够达到多少个格子? ```java private int cnt = 0; @@ -444,6 +510,28 @@ private void initDigitSum(int rows, int cols) { } ``` +## 14. 剪绳子 + +**题目描述** + +把一根绳子剪成多段,并且使得每段的长度乘积最大。 + +**解题思路** + +尽可能多得剪长度为 3 的绳子,并且不允许有长度为 1 的绳子出现,如果出现了,就从已经切好长度为 3 的绳子中拿出一段与长度为 1 的绳子重新组合,把它们切成两段长度为 2 的绳子。 + +```java +int maxProductAfterCuttin(int length) { + if (length < 2) return 0; + if (length == 2) return 1; + if (length == 3) return 2; + int timesOf3 = length / 3; + if (length - timesOf3 * 3 == 1) timesOf3--; + int timesOf2 = (length - timesOf3 * 3) / 2; + return (int) (Math.pow(3, timesOf3)) * (int) (Math.pow(2, timesOf2)); +} +``` + ## 15. 二进制中 1 的个数 ```java @@ -485,11 +573,30 @@ public double Power(double base, int exponent) { } ``` +## 18. 删除链表中重复的结点 + +```java +public ListNode deleteDuplication(ListNode pHead) { + if (pHead == null) return null; + if (pHead.next == null) return pHead; + if (pHead.val == pHead.next.val) { + ListNode next = pHead.next; + while (next != null && pHead.val == next.val) { + next = next.next; + } + return deleteDuplication(next); + } else { + pHead.next = deleteDuplication(pHead.next); + return pHead; + } +} +``` + ## 19. 正则表达式匹配 **题目描述** -请实现一个函数用来匹配包括'.'和'\*'的正则表达式。模式中的字符'.'表示任意一个字符,而'\*'表示它前面的字符可以出现任意次(包含0次)。 在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串"aaa"与模式"a.a"和"ab\*ac\*a"匹配,但是与"aa.a"和"ab\*a"均不匹配 +请实现一个函数用来匹配包括 '.' 和 '\*' 的正则表达式。模式中的字符 '.' 表示任意一个字符,而 '\*' 表示它前面的字符可以出现任意次(包含 0 次)。 在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串 "aaa" 与模式 "a.a" 和 "ab\*ac\*a" 匹配,但是与 "aa.a" 和 "ab\*a" 均不匹配 ```java public boolean match(char[] str, char[] pattern) { @@ -514,6 +621,10 @@ public boolean match(char[] str, char[] pattern) { ## 20. 表示数值的字符串 +**题目描述** + +请实现一个函数用来判断字符串是否表示数值(包括整数和小数)。例如,字符串 "+100","5e2","-123","3.1416" 和 "-1E-16" 都表示数值。 但是 "12e","1a3.14","1.2.3","+-5" 和 "12e+4.3" 都不是。 + ```java public boolean isNumeric(char[] str) { String string = String.valueOf(str); @@ -544,6 +655,22 @@ public void reOrderArray(int[] array) { } ``` +时间复杂度 : O(n) +空间复杂度 : O(n) + +```java +public void reOrderArray(int[] array) { + int oddCnt = 0; + for (int num : array) if (num % 2 == 1) oddCnt++; + int[] copy = array.clone(); + int i = 0, j = oddCnt; + for (int num : copy) { + if (num % 2 == 1) array[i++] = num; + else array[j++] = num; + } +} +``` + ## 22. 链表中倒数第 k 个结点 ```java @@ -561,21 +688,7 @@ public ListNode FindKthToTail(ListNode head, int k) { } ``` -时间复杂度 : O(n) -空间复杂度 : O(n) -```java -public void reOrderArray(int[] array) { - int oddCnt = 0; - for (int num : array) if (num % 2 == 1) oddCnt++; - int[] copy = array.clone(); - int i = 0, j = oddCnt; - for (int num : copy) { - if (num % 2 == 1) array[i++] = num; - else array[j++] = num; - } -} -``` ## 23. 链表中环的入口结点 @@ -668,7 +781,7 @@ public void Mirror(TreeNode root) { } ``` -## 28. 对称的二叉树 +## 28.1 对称的二叉树 ```java boolean isSymmetrical(TreeNode pRoot) { @@ -684,6 +797,25 @@ boolean isSymmetrical(TreeNode t1, TreeNode t2) { } ``` +## 28.2 平衡二叉树 + +```java +private boolean isBalanced = true; + +public boolean IsBalanced_Solution(TreeNode root) { + height(root); + return isBalanced; +} + +private int height(TreeNode root) { + if (root == null) return 0; + int left = height(root.left); + int right = height(root.right); + if (Math.abs(left - right) > 1) isBalanced = false; + return 1 + Math.max(left, right); +} +``` + ## 29. 顺时针打印矩阵 ```java @@ -746,7 +878,7 @@ public boolean IsPopOrder(int[] pushA, int[] popA) { } ``` -## 32. 从上往下打印二叉树 +## 32.1 从上往下打印二叉树 ```java public ArrayList PrintFromTopToBottom(TreeNode root) { @@ -767,6 +899,60 @@ public ArrayList PrintFromTopToBottom(TreeNode root) { } ``` +## 32.3 把二叉树打印成多行 + +```java +ArrayList> Print(TreeNode pRoot) { + ArrayList> ret = new ArrayList<>(); + if (pRoot == null) return ret; + Queue queue = new LinkedList<>(); + queue.add(pRoot); + while (!queue.isEmpty()) { + int cnt = queue.size(); + ArrayList list = new ArrayList<>(); + for (int i = 0; i < cnt; i++) { + TreeNode node = queue.poll(); + list.add(node.val); + if (node.left != null) queue.add(node.left); + if (node.right != null) queue.add(node.right); + } + ret.add(list); + } + return ret; +} +``` + +## 32.3 按之字形顺序打印二叉树 + +```java +public ArrayList> Print(TreeNode pRoot) { + ArrayList> ret = new ArrayList<>(); + if (pRoot == null) return ret; + Queue queue = new LinkedList<>(); + queue.add(pRoot); + boolean reverse = false; + while (!queue.isEmpty()) { + int cnt = queue.size(); + ArrayList list = new ArrayList<>(); + for (int i = 0; i < cnt; i++) { + TreeNode node = queue.poll(); + list.add(node.val); + if (node.left != null) queue.add(node.left); + if (node.right != null) queue.add(node.right); + } + if (reverse) { + Collections.reverse(list); + reverse = false; + } else { + reverse = true; + } + ret.add(list); + } + return ret; +} +``` + + ## 33. 二叉搜索树的后序遍历序列 ```java @@ -816,6 +1002,10 @@ private void dfs(TreeNode node, int target, int curSum, ArrayList path) ## 35. 复杂链表的复制 +**题目描述** + +输入一个复杂链表(每个节点中有节点值,以及两个指针,一个指向下一个节点,另一个特殊指针指向任意一个节点),返回结果为复制后复杂链表的 head。(注意,输出结果中请不要返回参数中的节点引用,否则判题程序会直接返回空) + 第一步,在每个节点的后面插入复制的节点。 ![](https://github.com/00000H/notes/blob/master/pics/f8b12555-967b-423d-a84e-bc9eff104b8b.jpg) @@ -863,6 +1053,10 @@ public RandomListNode Clone(RandomListNode pHead) { ## 36. 二叉搜索树与双向链表 +**题目描述** + +输入一棵二叉搜索树,将该二叉搜索树转换成一个排序的双向链表。要求不能创建任何新的结点,只能调整树中结点指针的指向。 + ```java private TreeNode pre = null; public TreeNode Convert(TreeNode pRootOfTree) { @@ -917,6 +1111,10 @@ private TreeNode Deserialize() { ## 38. 字符串的排列 +**题目描述** + +输入一个字符串 , 按字典序打印出该字符串中字符的所有排列。例如输入字符串 abc, 则打印出由字符 a, b, c 所能排列出来的所有字符串 abc, acb, bac, bca, cab 和 cba。 + ```java private ArrayList ret = new ArrayList<>(); @@ -1047,6 +1245,67 @@ private boolean less(int v, int w) { } ``` +## 41.1 数据流中的中位数 + + +**题目描述** + +如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。 + +```java +private PriorityQueue maxHeap = new PriorityQueue<>((o1, o2) -> o2-o1); // 实现左边部分 +private PriorityQueue minHeep = new PriorityQueue<>(); // 实现右边部分,右边部分所有元素大于左边部分 +private int cnt = 0; + +public void Insert(Integer num) { + // 插入要保证两个堆存于平衡状态 + if(cnt % 2 == 0) { + // 为偶数的情况下插入到最小堆,先经过最大堆筛选,这样就能保证最大堆中的元素都小于最小堆中的元素 + maxHeap.add(num); + minHeep.add(maxHeap.poll()); + } else { + minHeep.add(num); + maxHeap.add(minHeep.poll()); + } + cnt++; +} + +public Double GetMedian() { + if(cnt % 2 == 0) { + return (maxHeap.peek() + minHeep.peek()) / 2.0; + } else { + return (double) minHeep.peek(); + } +} +``` + +## 14.2 字符流中第一个不重复的字符 + +**题目描述** + +请实现一个函数用来找出字符流中第一个只出现一次的字符。例如,当从字符流中只读出前两个字符 "go" 时,第一个只出现一次的字符是 "g"。当从该字符流中读出前六个字符“google" 时,第一个只出现一次的字符是 "l"。 + +```java +//Insert one char from stringstream +private int[] cnts = new int[256]; +private Queue queue = new LinkedList<>(); + +public void Insert(char ch) { + cnts[ch]++; + queue.add(ch); + while (!queue.isEmpty() && cnts[queue.peek()] > 1) { + queue.poll(); + } +} + +//return the first appearence once char in current stringstream +public char FirstAppearingOnce() { + if (queue.isEmpty()) return '#'; + return queue.peek(); +} +``` + + ## 42. 连续子数组的最大和 ```java @@ -1080,6 +1339,10 @@ public int NumberOf1Between1AndN_Solution(int n) { ## 45. 把数组排成最小的数 +**题目描述** + +输入一个正整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个。例如输入数组 {3,32,321},则打印出这三个数字能排成的最小数字为 321323。 + ```java public String PrintMinNumber(int[] numbers) { int n = numbers.length; @@ -1094,6 +1357,10 @@ public String PrintMinNumber(int[] numbers) { ## 49. 丑数 +**题目描述** + +把只包含因子 2、3 和 5 的数称作丑数(Ugly Number)。例如 6、8 都是丑数,但 14 不是,因为它包含因子 7。 习惯上我们把 1 当做是第一个丑数。求按从小到大的顺序的第 N 个丑数。 + ```java public int GetUglyNumber_Solution(int index) { if (index <= 6) return index; @@ -1181,6 +1448,8 @@ public ListNode FindFirstCommonNode(ListNode pHead1, ListNode pHead2) { ## 53 数字在排序数组中出现的次数 + + ```java public int GetNumberOfK(int[] array, int k) { int l = 0, h = array.length - 1; @@ -1246,7 +1515,7 @@ public void FindNumsAppearOnce(int[] array, int num1[], int num2[]) { } ``` -## 57. 和为 S 的两个数字 +## 57.1 和为 S 的两个数字 ```java public ArrayList FindNumbersWithSum(int[] array, int sum) { @@ -1261,6 +1530,37 @@ public ArrayList FindNumbersWithSum(int[] array, int sum) { } ``` +## 57.2 和为 S 的连续正数序列 + +```java +public ArrayList> FindContinuousSequence(int sum) { + ArrayList> ret = new ArrayList<>(); + int start = 1, end = 2; + int mid = sum / 2; + int curSum = 3; + while (start <= mid && end < sum) { + if (curSum > sum) { + curSum -= start; + start++; + } else if (curSum < sum) { + end++; + curSum += end; + } else { + ArrayList list = new ArrayList<>(); + for (int i = start; i <= end; i++) { + list.add(i); + } + ret.add(list); + curSum -= start; + start++; + end++; + curSum += end; + } + } + return ret; +} +``` + ## 58.1 翻转单词顺序列 ```java @@ -1314,6 +1614,24 @@ private void reverse(char[] c, int i, int j) { } ``` +## 59. 滑动窗口的最大值 + +```java +public ArrayList maxInWindows(int[] num, int size) { + ArrayList ret = new ArrayList<>(); + PriorityQueue heap = new PriorityQueue((o1, o2) -> o2 - o1); + if (size > num.length || size < 1) return ret; + for (int i = 0; i < size; i++) heap.add(num[i]); + ret.add(heap.peek()); + for (int i = 1; i + size - 1 < num.length; i++) { + heap.remove(num[i - 1]); + heap.add(num[i + size - 1]); + ret.add(heap.peek()); + } + return ret; +} +``` + ## 61. 扑克牌顺子 ```java @@ -1350,6 +1668,22 @@ public int LastRemaining_Solution(int n, int m) { } ``` +# 63. 股票的最大利润 + +```java +public int maxProfit(int[] prices) { + int n = prices.length; + if(n == 0) return 0; + int soFarMin = prices[0]; + int max = 0; + for(int i = 1; i < n; i++) { + if(soFarMin > prices[i]) soFarMin = prices[i]; + else max = Math.max(max, prices[i] - soFarMin); + } + return max; +} +``` + ## 64. 求 1+2+3+...+n ```java @@ -1414,192 +1748,12 @@ public int StrToInt(String str) { } ``` -# 未分类 - -## 平衡二叉树 +## 68. 树中两个节点的最低公共祖先 ```java -private boolean isBalanced = true; - -public boolean IsBalanced_Solution(TreeNode root) { - height(root); - return isBalanced; -} - -private int height(TreeNode root) { - if (root == null) return 0; - int left = height(root.left); - int right = height(root.right); - if (Math.abs(left - right) > 1) isBalanced = false; - return 1 + Math.max(left, right); -} -``` - -## 和为 S 的连续正数序列 - -```java -public ArrayList> FindContinuousSequence(int sum) { - ArrayList> ret = new ArrayList<>(); - int start = 1, end = 2; - int mid = sum / 2; - int curSum = 3; - while (start <= mid && end < sum) { - if (curSum > sum) { - curSum -= start; - start++; - } else if (curSum < sum) { - end++; - curSum += end; - } else { - ArrayList list = new ArrayList<>(); - for (int i = start; i <= end; i++) { - list.add(i); - } - ret.add(list); - curSum -= start; - start++; - end++; - curSum += end; - } - } - return ret; -} -``` - -## 字符流中第一个不重复的字符 - -```java -//Insert one char from stringstream -private int[] cnts = new int[256]; -private Queue queue = new LinkedList<>(); - -public void Insert(char ch) { - cnts[ch]++; - queue.add(ch); - while (!queue.isEmpty() && cnts[queue.peek()] > 1) { - queue.poll(); - } -} - -//return the first appearence once char in current stringstream -public char FirstAppearingOnce() { - if (queue.isEmpty()) return '#'; - return queue.peek(); -} -``` - - - -## 删除链表中重复的结点 - -```java -public ListNode deleteDuplication(ListNode pHead) { - if (pHead == null) return null; - if (pHead.next == null) return pHead; - if (pHead.val == pHead.next.val) { - ListNode next = pHead.next; - while (next != null && pHead.val == next.val) { - next = next.next; - } - return deleteDuplication(next); - } else { - pHead.next = deleteDuplication(pHead.next); - return pHead; - } -} -``` - -## 按之字形顺序打印二叉树 - -```java -public ArrayList> Print(TreeNode pRoot) { - ArrayList> ret = new ArrayList<>(); - if (pRoot == null) return ret; - Queue queue = new LinkedList<>(); - queue.add(pRoot); - boolean reverse = false; - while (!queue.isEmpty()) { - int cnt = queue.size(); - ArrayList list = new ArrayList<>(); - for (int i = 0; i < cnt; i++) { - TreeNode node = queue.poll(); - list.add(node.val); - if (node.left != null) queue.add(node.left); - if (node.right != null) queue.add(node.right); - } - if (reverse) { - Collections.reverse(list); - reverse = false; - } else { - reverse = true; - } - ret.add(list); - } - return ret; -} -``` - -## 把二叉树打印成多行 - -```java -ArrayList> Print(TreeNode pRoot) { - ArrayList> ret = new ArrayList<>(); - if (pRoot == null) return ret; - Queue queue = new LinkedList<>(); - queue.add(pRoot); - while (!queue.isEmpty()) { - int cnt = queue.size(); - ArrayList list = new ArrayList<>(); - for (int i = 0; i < cnt; i++) { - TreeNode node = queue.poll(); - list.add(node.val); - if (node.left != null) queue.add(node.left); - if (node.right != null) queue.add(node.right); - } - ret.add(list); - } - return ret; -} -``` - -## 二叉搜索树的第 k 个结点 - -```java -TreeNode ret; -int cnt = 0; -TreeNode KthNode(TreeNode pRoot, int k) -{ - inorder(pRoot, k); - return ret; -} - -private void inorder(TreeNode root, int k) { - if(root == null) return; - if(cnt > k) return; - inorder(root.left, k); - cnt++; - if(cnt == k) ret = root; - inorder(root.right, k); -} -``` - -## 滑动窗口的最大值 - -```java -public ArrayList maxInWindows(int[] num, int size) { - ArrayList ret = new ArrayList<>(); - PriorityQueue heap = new PriorityQueue((o1, o2) -> o2 - o1); - if (size > num.length || size < 1) return ret; - for (int i = 0; i < size; i++) heap.add(num[i]); - ret.add(heap.peek()); - for (int i = 1; i + size - 1 < num.length; i++) { - heap.remove(num[i - 1]); - heap.add(num[i + size - 1]); - ret.add(heap.peek()); - } - return ret; +public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { + if(root.val > p.val && root.val > q.val) return lowestCommonAncestor(root.left, p, q); + if(root.val < p.val && root.val < q.val) return lowestCommonAncestor(root.right, p, q); + return root; } ``` - - - diff --git "a/\346\225\260\346\215\256\347\273\223\346\236\204\344\270\216\347\256\227\346\263\225.md" "b/\346\225\260\346\215\256\347\273\223\346\236\204\344\270\216\347\256\227\346\263\225.md" new file mode 100644 index 0000000000..f40086c78f --- /dev/null +++ "b/\346\225\260\346\215\256\347\273\223\346\236\204\344\270\216\347\256\227\346\263\225.md" @@ -0,0 +1,1623 @@ + +* [第一章 基础](#第一章-基础) + * [栈](#栈) + * [栈的数组实现](#栈的数组实现) + * [栈的链表实现](#栈的链表实现) + * [队列](#队列) + * [算法分析](#算法分析) + * [函数转换](#函数转换) + * [数学模型](#数学模型) + * [ThreeSum](#threesum) + * [倍率实验](#倍率实验) + * [注意事项](#注意事项) + * [union-find](#union-find) + * [quick-find 算法](#quick-find-算法) + * [quick-union 算法](#quick-union-算法) + * [加权 quick-union 算法](#加权-quick-union-算法) + * [路径压缩的加权 quick-union 算法](#路径压缩的加权-quick-union-算法) + * [各种 union-find 算法的比较](#各种-union-find-算法的比较) +* [第二章 排序](#第二章-排序) + * [初级排序算法](#初级排序算法) + * [约定](#约定) + * [选择排序](#选择排序) + * [插入排序](#插入排序) + * [选择排序和插入排序的比较](#选择排序和插入排序的比较) + * [希尔排序](#希尔排序) + * [归并排序](#归并排序) + * [归并方法](#归并方法) + * [自顶向下归并排序](#自顶向下归并排序) + * [自底向上归并排序](#自底向上归并排序) + * [快速排序](#快速排序) + * [基本算法](#基本算法) + * [切分](#切分) + * [性能分析](#性能分析) + * [算法改进](#算法改进) + * [切换到插入排序](#切换到插入排序) + * [三取样](#三取样) + * [三向切分](#三向切分) + * [优先队列](#优先队列) + * [堆](#堆) + * [上浮和下沉](#上浮和下沉) + * [插入元素](#插入元素) + * [删除最大元素](#删除最大元素) + * [堆排序](#堆排序) + * [分析](#分析) + * [应用](#应用) + * [排序算法的比较](#排序算法的比较) + * [Java 的排序算法实现](#java-的排序算法实现) + * [基于切分的快速选择算法](#基于切分的快速选择算法) +* [第三章 查找](#第三章-查找) + * [符号表](#符号表) + * [API](#api) + * [有序符号表](#有序符号表) + * [无序链表实现](#无序链表实现) + * [有序数组的二分查找](#有序数组的二分查找) + * [对二分查找的分析](#对二分查找的分析) + * [二叉查找树](#二叉查找树) + * [get()](#get) + * [put()](#put) + * [分析](#分析) + * [floor()](#floor) + * [rank()](#rank) + * [min()](#min) + * [deleteMin()](#deletemin) + * [delete()](#delete) + * [keys()](#keys) + * [性能分析](#性能分析) + * [平衡查找树](#平衡查找树) + * [2-3 查找树](#2-3-查找树) + * [插入操作](#插入操作) + * [性质](#性质) + * [红黑二叉查找树](#红黑二叉查找树) + * [左旋转](#左旋转) + * [右旋转](#右旋转) + * [颜色转换](#颜色转换) + * [插入](#插入) + * [删除最小键](#删除最小键) + * [分析](#分析) + * [散列表](#散列表) + * [散列函数](#散列函数) + * [基于拉链法的散列表](#基于拉链法的散列表) + * [基于线性探测法的散列表](#基于线性探测法的散列表) + * [查找](#查找) + * [插入](#插入) + * [删除](#删除) + * [调整数组大小](#调整数组大小) + * [应用](#应用) + * [各种符号表实现的比较](#各种符号表实现的比较) + * [Java 的符号表实现](#java-的符号表实现) + * [集合类型](#集合类型) + * [稀疏向量乘法](#稀疏向量乘法) + + +# 第一章 基础 + +## 栈 + +### 栈的数组实现 + +```java +public class ResizeArrayStack implements Iterable { + + private Item[] a = (Item[]) new Object[1]; + private int N = 0; + + public void push(Item item) { + if (N >= a.length) { + resize(2 * a.length); + } + a[N++] = item; + } + + public Item pop() { + Item item = a[--N]; + if (N <= a.length / 4) { + resize(a.length / 2); + } + return item; + } + + // 调整数组大小 + private void resize(int size) { + Item[] tmp = (Item[]) new Object[size]; + for (int i = 0; i < N; i++) { + tmp[i] = a[i]; + } + a = tmp; + } + + public boolean isEmpty() { + return N == 0; + } + + public int size() { + return N; + } + + @Override + public Iterator iterator() { + return new ReverseArrayIterator(); + } + + private class ReverseArrayIterator implements Iterator { + + private int i = N; + + @Override + public boolean hasNext() { + return i > 0; + } + + @Override + public Item next() { + return a[--i]; + } + } +} +``` + +### 栈的链表实现 + +需要使用链表的头插法来实现,因为头插法中最后压入栈的元素再链表开头,它的 next 指针指向前一个压入栈的元素,在弹出元素使就可以让前一个压入栈的元素称为栈顶元素。 + +```java +public class Stack { + + private Node top = null; + private int N = 0; + + private class Node { + Item item; + Node next; + } + + public boolean isEmpty() { + return N == 0; + } + + public int size() { + return N; + } + + public void push(Item item) { + Node newTop = new Node(); + newTop.item = item; + newTop.next = top; + top = newTop; + N++; + } + + public Item pop() { + Item item = top.item; + top = top.next; + N--; + return item; + } +} +``` + +## 队列 + +出队列操作需要让队首元素的下一个元素成为队首,那么就需要容易获取下一个元素。让链表的头部存储队首元素,那么就可以通过队首元素的 next 指针快速获取下一个元素。 + +```java +public class Queue { + private Node first; + private Node last; + int N = 0; + private class Node{ + Item item; + Node next; + } + + public boolean isEmpty(){ + return N==0; + } + + public int size(){ + return N; + } + + public void enqueue(Item item){ + Node newNode = new Node(); + newNode.item = item; + newNode.next = null; + if(isEmpty()){ + last = newNode; + first = newNode; + } else{ + last.next = newNode; + last = newNode; + } + N++; + } + + public Item dequeue(){ + Node node = first; + first = first.next; + N--; + return node.item; + } +} +``` + +## 算法分析 + +### 函数转换 + +指数函数可以转换为线性函数,从而在函数图像上显示的更直观。 + +T(N)=aN3 转换为 lg(T(N))=3lgN+lga + +![](https://github.com/00000H/notes/blob/master/pics/5510045a-8f32-487f-a756-463e51a6dab0.png) + +### 数学模型 + +**近似** + +使用 \~f(N) 来表示所有随着 N 的增大除以 f(N) 的结果趋近于 1 的函数 , 例如 N3/6-N2/2+N/3 \~ N3/6。 + +![](https://github.com/00000H/notes/blob/master/pics/ca3a793e-06e5-4ff3-b28e-a9c20540d164.png) + +**内循环** + +执行最频繁的指令决定了程序执行的总时间,把这些指令称为程序的内循环。 + +**成本模型** + +使用成本模型来评估算法,例如数组的访问次数就是一种成本模型。 + +**增长数量级** + +增长数量级将算法与它的实现隔离开来,一个算法的增长数量级为 N3 与它是否用 Java 实现,是否运行与特定计算机上无关。 + +![](https://github.com/00000H/notes/blob/master/pics/1ea4dc9a-c4dd-46b5-bb11-49f98d57ded1.png) + +### ThreeSum + +ThreeSum 程序用于统计一个数组中三元组的和为 0 的数量。 + +```java +public class ThreeSum { + public static int count(int[] a) { + int N = a.length; + int cnt = 0; + for (int i = 0; i < N; i++) { + for (int j = i + 1; j < N; j++) { + for (int k = j + 1; k < N; k++) { + if (a[i] + a[j] + a[k] == 0) { + cnt++; + } + } + } + } + return cnt; + } +} +``` + +该程序的内循环为 if (a[i] + a[j] + a[k] == 0) 语句,总共执行的次数为 N3/6-N2/2+N/3,因此它的近似执行次数为 \~N3/6,增长数量级为 N3。 + +**改进** + +通过将数组先排序,对两个元素求和,并用二分查找方法查找是否存在该和的相反数,如果存在,就说明存在三元组的和为 0。 + +该方法可以将 ThreeSum 算法增长数量级降低为 N2logN。 + +```java +public class ThreeSumFast { + public static int count(int[] a) { + Arrays.sort(a); + int N = a.length; + int cnt = 0; + for (int i = 0; i < N; i++) { + for (int j = i + 1; j < N; j++) { + for (int k = j + 1; k < N; k++) { + if (BinarySearch.rank(-a[i] - a[j], a) > j) { + cnt++; + } + } + } + } + return cnt; + } +} +``` + +### 倍率实验 + +如果 T(N) \~ aNblgN,那么 T(2N)/T(N) \~ 2b,例如对于暴力方法的 ThreeSum 算法,近似时间为 \~N3/6,对它进行倍率实验得到如下结果: + +![](https://github.com/00000H/notes/blob/master/pics/6f5ed46f-86d7-4852-a34f-c1cf1b6343a0.png) + +可见 T(2N)/T(N)\~23,也就是 b 为 3。 + +### 注意事项 + +**大常数** + +如果低级项的常数系数很大,那么近似的结果就是错误的。 + +**缓存** + +计算机系统会使用缓存技术来组织内存,访问数组相邻的元素会比访问不相邻的元素快很多。 + +**对最坏情况下的性能的保证** + +在核反应堆、心脏起搏器或者刹车控制器中的软件,最坏情况下的性能是十分重要的。 + +**随机化算法** + +通过打乱输入,去除算法对输入的依赖。 + +**均摊分析** + +将所有操作的总成本所以操作总数来将成本均摊。例如对一个空栈进行 N 次连续的 push() 调用需要访问数组的元素为 N+4+8+16+...+2N=5N-4(N 是向数组写入元素,其余的都是调整数组大小需要进行的访问数组操作),均摊后每次操作访问数组的平均次数为常数。 + +## union-find + +**概览** + +![](https://github.com/00000H/notes/blob/master/pics/5d387d02-6f96-44d6-b5d0-4538349f868e.png) + +用于解决动态连通性问题,能动态连接两个点,并且判断两个点是否连接。 + +**API** + +![](https://github.com/00000H/notes/blob/master/pics/a9b91b7d-65d7-4aa3-8ef6-21876b05ad16.png) + +使用 id 数组来保存点的连接信息。 + +```java +public class UF { + private int[] id; + + public UF(int N) { + id = new int[N]; + for (int i = 0; i < N; i++) { + id[i] = i; + } + } + + public boolean connected(int p, int q) { + return find(p) == find(q); + } +} +``` + +### quick-find 算法 + +保证在同一连通分量的所有触点的 id 值相等。 + +这种方法可以快速取得一个触点的 id 值,并且判断两个触点是否连通,但是 union 的操作代价却很高。 + +```java + public int find(int p) { + return id[p]; + } + public void union(int p, int q) { + int pID = find(p); + int qID = find(q); + + if (pID == qID) return; + for (int i = 0; i < id.length; i++) { + if (id[i] == pID) id[i] = qID; + } + } +``` + +### quick-union 算法 + +在 union 时只将触点的 id 值指向另一个触点 id 值,根节点指向自己,可以用树形结构来表示这种方法。 + +![](https://github.com/00000H/notes/blob/master/pics/9192dc0a-a7cd-4030-8df6-e388600644cf.jpg) + +```java + public int find(int p) { + while (p != id[p]) p = id[p]; + return p; + } + + public void union(int p, int q) { + int pRoot = find(p); + int qRoot = find(q); + if (pRoot == qRoot) return; + id[pRoot] = qRoot; + } +``` + +这种方法可以快速进行 union 操作,但是 find 操作和树高成正比,最坏的情况下树的高度为触点的数目。 + +![](https://github.com/00000H/notes/blob/master/pics/d206d090-d911-4263-a1fe-d6f63f5d1776.png) + +### 加权 quick-union 算法 + +为了解决 quick-union 的树通常会很高的问题,加权 quick-union 在 union 操作会让较小的树连接较大的树上面。 + +理论研究证明,加权 quick-union 算法构造的树深度最多不超过 lgN。 + +![](https://github.com/00000H/notes/blob/master/pics/8d6af5ac-74eb-4e07-99aa-654b9f21f1d3.jpg) + +```java +public class WeightedQuickUnionUF { + private int[] id; + private int[] sz; + + public WeightedQuickUnionUF(int N) { + id = new int[N]; + sz = new int[N]; + for (int i = 0; i < N; i++) { + id[i] = i; + sz[i] = 1; + } + } + + public boolean connected(int p, int q) { + return find(p) == find(q); + } + + public int find(int p) { + while (p != id[p]) p = id[p]; + return p; + } + + public void union(int p, int q) { + int i = find(p); + int j = find(q); + if (i == j) return; + if (sz[i] < sz[j]) { + id[i] = j; + sz[j] += sz[i]; + } else { + id[j] = i; + sz[i] += sz[j]; + } + } +} +``` + +### 路径压缩的加权 quick-union 算法 + +在检查节点的同时将它们直接链接到根节点,只需要在 find 中添加一个循环即可。 + +### 各种 union-find 算法的比较 + +![](https://github.com/00000H/notes/blob/master/pics/e5baeb38-0ec9-4ad7-8374-1cdb0dba74a6.jpg) + +# 第二章 排序 + +## 初级排序算法 + +### 约定 + +待排序的元素需要实现 Java 的 Comparable 接口,该接口有 compareTo() 方法。 + +研究排序算法的成本模型时,计算的是比较和交换的次数。 + +使用辅助函数 less() 和 exch() 来进行比较和交换的操作,使得代码的可读性和可移植性更好。 + +```java +private static boolean less(Comparable v, Comparable w){ + return v.compareTo(w) < 0; +} + +private void exch(Comparable[] a, int i, int j){ + Comparable t = a[i]; + a[i] = a[j]; + a[j] = t; +} +``` + +### 选择排序 + +找到数组中的最小元素,然后将它与数组的第一个元素交换位置。然后再从剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。不断进行这样的操作,直到将整个数组排序。 + +![](https://github.com/00000H/notes/blob/master/pics/222768a7-914f-4d64-b874-d98f3b926fb6.jpg) + +```java +public class Selection { + public static void sort(Comparable[] a) { + int N = a.length; + for (int i = 0; i < N; i++) { + int min = i; + for (int j = i + 1; j < N; j++) { + if (less(a[j], a[min])) min = j; + } + exch(a, i, min); + } + } +} +``` + +选择排序需要 \~N2/2 次比较和 \~N 次交换,它的运行时间与输入无关,这个特点使得它对一个已经排序的数组也需要这么多的比较和交换操作。 + +### 插入排序 + +将一个元素插入到已排序的数组中,使得插入之后的数组也是有序的。插入排序从左到右插入每个元素,每次插入之后左部的子数组是有序的。 + +![](https://github.com/00000H/notes/blob/master/pics/065c3bbb-3ea0-4dbf-8f26-01d0e0ba7db7.png) + +```java +public class Insertion { + public static void sort(Comparable[] a) { + int N = a.length; + for (int i = 1; i < N; i++) { + for (int j = i; j > 0 && less(a[j], a[j - 1]); j--) { + exch(a, j, j - 1); + } + } + } +} +``` + +插入排序的复杂度取决于数组的初始顺序,如果数组已经部分有序了,那么插入排序会很快。平均情况下插入排序需要 \~N2/4 比较以及 \~N2/4 次交换,最坏的情况下需要 \~N2/2 比较以及 \~N2/2 次交换,最坏的情况是数组是逆序的;而最好的情况下需要 N-1 次比较和 0 次交换,最好的情况就是数组已经有序了。 + +插入排序对于部分有序数组和小规模数组特别高效。 + +### 选择排序和插入排序的比较 + +对于随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级别的,两者之比是一个较小的常数。 + +### 希尔排序 + +对于大规模的数组,插入排序很慢,因为它只能交换相邻的元素,如果要把元素从一端移到另一端,就需要很多次操作。 + +希尔排序的出现就是为了改进插入排序的这种局限性,它通过交换不相邻的元素,使得元素更快的移到正确的位置上。 + +希尔排序使用插入排序对间隔 h 的序列进行排序,如果 h 很大,那么元素就能很快的移到很远的地方。通过不断减小 h,最后令 h=1,就可以使得整个数组是有序的。 + +![](https://github.com/00000H/notes/blob/master/pics/8320bad6-3f91-4a15-8e3d-68e8f39649b5.png) + +```java +public class Shell { + public static void sort(Comparable[] a) { + int N = a.length; + int h = 1; + while (h < N / 3) { + h = 3 * h + 1;// 1, 4, 13, 40, ... + } + while (h >= 1) { + for (int i = h; i < N; i++) { + for (int j = i; j >= h && less(a[j], a[j - h]); j -= h) { + exch(a, j, j - h); + } + } + h = h / 3; + } + } +} +``` + +希尔排序的运行时间达不到平方级别,使用递增序列 1, 4, 13, 40, ... 的希尔排序所需要的比较次数不会超过 N 的若干倍乘于递增序列的长度。后面介绍的高级排序算法只会比希尔排序快两倍左右。 + +## 归并排序 + +归并排序的思想是将数组分成两部分,分别进行排序,然后归并起来。 + +![](https://github.com/00000H/notes/blob/master/pics/dcf265ad-fe35-424d-b4b7-d149cdf239f4.png) + +### 归并方法 + +```java +public class MergeSort { + private static Comparable[] aux; + + private static void merge(Comparable[] a, int lo, int mid, int hi) { + int i = lo, j = mid + 1; + + for (int k = lo; k <= hi; k++) { + aux[k] = a[k]; // 将数据复制到辅助数组 + } + + for (int k = lo; k <= hi; k++) { + if (i > mid) a[k] = aux[j++]; + else if (j > hi) a[k] = aux[i++]; + else if (aux[i].compareTo(a[j]) < 0) a[k] = aux[i++]; // 先进行这一步,保证稳定性 + else a[k] = aux[j++]; + } + } +} +``` + +### 自顶向下归并排序 + +```java + public static void sort(Comparable[] a) { + aux = new Comparable[a.length]; + sort(a, 0, a.length - 1); + } + + private static void sort(Comparable[] a, int lo, int hi) { + if (hi <= lo) return; + int mid = lo + (hi - lo) / 2; + sort(a, lo, mid); + sort(a, mid + 1, hi); + merge(a, lo, mid, hi); + } +``` + +![](https://github.com/00000H/notes/blob/master/pics/6468a541-3a9a-4008-82b6-03a0fe941d2a.png) + +![](https://github.com/00000H/notes/blob/master/pics/c7665f73-c52f-4ce4-aed3-592bbd76265b.png) + +很容易看出该排序算法的时间复杂度为 O(NlgN)。 + +因为小数组的递归操作会过于频繁,因此使用插入排序来处理小数组将会获得更高的性能。 + +### 自底向上归并排序 + +先归并那些微型数组,然后成对归并得到的子数组。 + +![](https://github.com/00000H/notes/blob/master/pics/c7b9b4c8-83d1-4eb0-8408-ea6576a9ed90.png) + +```java + public static void busort(Comparable[] a) { + int N = a.length; + aux = new Comparable[N]; + for (int sz = 1; sz < N; sz += sz) { + for (int lo = 0; lo < N - sz; lo += sz + sz) { + merge(a, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1)); + } + } + } +``` + +## 快速排序 + +### 基本算法 + +归并排序将数组分为两个子数组分别排序,并将有序的子数组归并使得整个数组排序;快速排序通过一个切分元素将数组分为两个子数组,左子数组小于等于切分元素,右子数组大于等于切分元素,将这两个子数组排序也就将整个数组排序了。 + +![](https://github.com/00000H/notes/blob/master/pics/61b4832d-71f3-413c-84b6-237e219b9fdc.png) + +```java +public class QuickSort { + public static void sort(Comparable[] a) { + shuffle(a); + sort(a, 0, a.length - 1); + } + + private static void sort(Comparable[] a, int lo, int hi) { + if (hi <= lo) return; + int j = partition(a, lo, hi); + sort(a, lo, j - 1); + sort(a, j + 1, hi); + } +} +``` + +### 切分 + +取 a[lo] 作为切分元素,然后从数组的左端向右扫描直到找到第一个大于等于它的元素,再从数组的右端向左扫描找到第一个小于等于它的元素,交换这两个元素,并不断继续这个过程,就可以保证左指针的左侧元素都不大于切分元素,右指针 j 的右侧元素都不小于切分元素。当两个指针相遇时,将切分元素 a[lo] 和左子数组最右侧的元素 a[j] 交换然后返回 j 即可。 + +![](https://github.com/00000H/notes/blob/master/pics/e198c201-f386-4491-8ad6-f7e433bf992d.png) + +```java + private static int partition(Comparable[] a, int lo, int hi) { + int i = lo, j = hi + 1; + Comparable v = a[lo]; + while (true) { + while (less(a[++i], v)) if (i == hi) break; + while (less(v, a[--j])) if (j == lo) break; + if (i >= j) break; + exch(a, i, j); + } + exch(a, lo, j); + return j; + } +``` + +### 性能分析 + +快速排序是原地排序,不需要辅助数组,但是递归调用需要辅助栈。 + +快速排序最好的情况下是每次都正好能将数组对半分,这样递归调用次数才是最少的。这种情况下比较次数为 CN=2CN/2+N,也就是复杂度为 O(NlgN)。 + +最坏的情况下,第一次从最小的元素切分,第二次从第二小的元素切分,如此这般。因此最坏的情况下需要比较 N2/2。为了防止数组最开始就是有序的,在进行快速排序时需要随机打乱数组。 + +### 算法改进 + +#### 切换到插入排序 + +因为快速排序在小数组中也会调用自己,对于小数组,插入排序比快速排序的性能更好,因此在小数组中可以切换到插入排序。 + +#### 三取样 + +最好的情况下是每次都能取数组的中位数作为切分元素,但是计算中位数的代价很高。人们发现取 3 个元素并将大小居中的元素作为切分元素的效果最好。 + +#### 三向切分 + +对于有大量重复元素的数组,可以将数组切分为三部分,分别对应小于、等于和大于切分元素。 + +三向切分快速排序对于只有若干不同主键的随机数组可以在线性时间内完成排序。 + +![](https://github.com/00000H/notes/blob/master/pics/9d2226dc-c4a3-40ec-9b3e-a46bf86af499.png) + +```java +public class Quick3Way { + public static void sort(Comparable[] a, int lo, int hi) { + if (hi <= lo) return; + int lt = lo, i = lo + 1, gt = hi; + Comparable v = a[lo]; + while (i <= gt) { + int cmp = a[i].compareTo(v); + if (cmp < 0) exch(a, lt++, i++); + else if (cmp > 0) exch(a, i, gt--); + else i++; + } + sort(a, lo, lt - 1); + sort(a, gt + 1, hi); + } +} +``` + +## 优先队列 + +优先队列主要用于处理最大元素。 + +### 堆 + +定义:一颗二叉树的每个节点都大于等于它的两个子节点。 + +堆可以用数组来表示,因为堆是一种完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1。这里我们不使用数组索引为 0 的位置,是为了更清晰地理解节点的关系。 + +![](https://github.com/00000H/notes/blob/master/pics/a9b6c1db-0f4a-4e91-8ac8-6b19bd106b51.png) + +```java +public class MaxPQ { + private Key[] pq; + private int N = 0; + + public MaxPQ(int maxN) { + pq = (Key[]) new Comparable[maxN + 1]; + } + + public boolean isEmpty() { + return N == 0; + } + + public int size() { + return N; + } + + private boolean less(int i, int j) { + return pq[i].compareTo(pq[j]) < 0; + } + + private void exch(int i, int j) { + Key t = pq[i]; + pq[i] = pq[j]; + pq[j] = t; + } +} +``` + +### 上浮和下沉 + +在堆中,当一个节点比父节点大,那么需要交换这个两个节点。交换后还可能比它新的父节点大,因此需要不断地进行比较和交换操作。把这种操作称为上浮。 + +```java +private void swim(int k) { + while (k > 1 && less(k / 2, k)) { + exch(k / 2, k); + k = k / 2; + } +} +``` + +类似地,当一个节点比子节点来得小,也需要不断的向下比较和交换操作,把这种操作称为下沉。一个节点有两个子节点,应当与两个子节点中最大那么节点进行交换。 + +```java +private void sink(int k) { + while (2 * k <= N) { + int j = 2 * k; + if (j < N && less(j, j + 1)) j++; + if (!less(k, j)) break; + exch(k, j); + k = j; + } +} +``` + +### 插入元素 + +将新元素放到数组末尾,然后上浮到合适的位置。 + +```java +public void insert(Key v) { + pq[++N] = v; + swim(N); +} +``` + +### 删除最大元素 + +从数组顶端删除最大的元素,并将数组的最后一个元素放到顶端,并让这个元素下沉到合适的位置。 + +```java +public Key delMax() { + Key max = pq[1]; + exch(1, N--); + pq[N + 1] = null; + sink(1); + return max; +} +``` + +### 堆排序 + +由于堆可以很容易得到最大的元素并删除它,不断地进行这种操作可以得到一个递减序列。如果把最大元素和当前堆中数组的最后一个元素交换位置,并且不删除它,那么就可以得到一个从尾到头的递减序列,从正向来看就是一个递增序列。因此很容易使用堆来进行排序,并且堆排序是原地排序,不占用额外空间。 + +堆排序要分两个阶段,第一个阶段是把无序数组建立一个堆;第二个阶段是交换最大元素和当前堆的数组最后一个元素,并且进行下沉操作维持堆的有序状态。 + +无序数组建立堆最直接的方法是从左到右遍历数组,然后进行上浮操作。一个更高效的方法是从右至左进行下沉操作,如果一个节点的两个节点都已经是堆有序,那么进行下沉操作可以使得这个节点为根节点的堆有序。叶子节点不需要进行下沉操作,因此可以忽略叶子节点的元素,因此只需要遍历一半的元素即可。 + +![](https://github.com/00000H/notes/blob/master/pics/a2670745-a7b1-497b-90a4-dbddc4e2006d.jpg) + +```java +public static void sort(Comparable[] a){ + int N = a.length; + for(int k = N/2; k >= 1; k--){ + sink(a, k, N); + } + while(N > 1){ + exch(a, 1, N--); + sink(a, 1, N); + } +} +``` + +### 分析 + +一个堆的高度为 lgN,因此在堆中插入元素和删除最大元素的复杂度都为 lgN。 + +对于堆排序,由于要对 N 个节点进行下沉操作,因此复杂度为 NlgN。 + +堆排序时一种原地排序,没有利用额外的空间。 + +现代操作系统很少使用堆排序,因为它无法利用缓存,也就是数组元素很少和相邻的元素进行比较。 + +## 应用 + +### 排序算法的比较 + +![](https://github.com/00000H/notes/blob/master/pics/be53c00b-2534-4dc6-ad03-c55995c47db9.jpg) + +快速排序时最快的通用排序算法,它的内循环的指令很少,而且它还能利用缓存,因为它总是顺序地访问数据。它的运行时间增长数量级为 $\~cNlgN$,这里的 c 比其他线性对数级别的排序算法都要小。使用三向切分之后,实际应用中可能出现的某些分布的输入能够达到 线性级别,而其它排序算法仍然需要线性对数时间。 + +### Java 的排序算法实现 + +Java 系统库中的主要排序方法为 java.util.Arrays.sort(),对于原始数据类型使用三向切分的快速排序,对于引用类型使用归并排序。 + +### 基于切分的快速选择算法 + +快速排序的 partition() 方法,会将数组的 a[lo] 至 a[hi] 重新排序并返回一个整数 j 使得 a[lo..j-1] 小于等于 a[j],且 a[j+1..hi] 大于等于 a[j]。那么如果 j=k,a[j] 就是第 k 个数。 + +该算法是线性级别的,因为每次正好将数组二分,那么比较的总次数为 (N+N/2+N/4+..),直到找到第 k 个元素,这个和显然小于 2N。 + +```java + public static Comparable select(Comparable[] a, int k) { + int lo = 0, hi = a.length - 1; + while (hi > lo) { + int j = partion(a, lo, hi); + if (j == k) return a[k]; + else if (j > k) hi = j - 1; + else lo = j + 1; + } + return a[k]; + } +``` + + +# 第三章 查找 + +本章使用三种经典的数据类型来实现高效的符号表:二叉查找树、红黑树和散列表。 + +## 符号表 + +### API + +![](https://github.com/00000H/notes/blob/master/pics/b69d7184-ab62-4957-ba29-fb4fa25f9b65.jpg) + +当一个键的值为 null 时,表示不存在这个键,因此可以使用 put(key, null) 作为 delete(key) 的一种延迟实现。 + +### 有序符号表 + +![](https://github.com/00000H/notes/blob/master/pics/ba6ae411-82da-4d86-a434-6776d1731e8e.jpg) + +有序符号表的键需要实现 Comparable 接口。 + +查找的成本模型:键的比较次数,在不进行比较时使用数组的访问次数。 + +### 无序链表实现 + +复杂度:向一个空表中插入 N 个不同的键需要 \~N2/2 次比较。 + +### 有序数组的二分查找 + +使用一对平行数组,一个存储键一个存储值。 + +需要创建一个 Key 类型的 Comparable 对象数组和一个 Value 类型的 Object 对象数组。 + +rank() 方法至关重要,当键在表中时,它能够知道该键的位置;当键不在表中时,它也能知道在何处插入新键。 + +```java +public class BinarySearchST, Value> { + private Key[] keys; + private Value[] values; + private int N; + + public BinarySearchST(int capacity) { + keys = (Key[]) new Comparable[capacity]; + values = (Value[]) new Object[capacity]; + } + + public int size() { + return N; + } + + public Value get(Key key) { + int i = rank(key); + if (i < N && keys[i].compareTo(key) == 0) { + return values[i]; + } + return null; + } + + public int rank(Key key) { + int lo = 0, hi = N - 1; + while (lo <= hi) { + int mid = lo + (hi - lo) / 2; + int cmp = key.compareTo(keys[mid]); + if (cmp == 0) return mid; + else if (cmp < 0) hi = mid - 1; + else lo = mid + 1; + } + return lo; + } + + public void put(Key key, Value value) { + int i = rank(key); + if (i < N && keys[i].compareTo(key) == 0) { + values[i] = value; + return; + } + for (int j = N; j > i; j--) { + keys[j] = keys[j - 1]; + values[j] = values[j - 1]; + } + keys[i] = key; + values[i] = value; + N++; + } + + public Key ceiling(Key key){ + int i = rank(key); + return keys[i]; + } +} +``` + +### 对二分查找的分析 + +复杂度:二分查找最多需要 lgN+1 次比较,使用二分查找实现的符号表的查找操作所需要的时间最多是对数级别的。但是插入操作需要移动数组元素,是线性级别的。 + +## 二叉查找树 + +定义:二叉树定义为一个空链接,或者是一个有左右两个链接的节点,每个链接都指向一颗子二叉树。二叉查找树(BST)是一颗二叉树,并且每个节点的键都大于其左子树中的任意节点的键而小于右子树的任意节点的键。 + +![](https://github.com/00000H/notes/blob/master/pics/25226bb2-92cc-40cb-9e7f-c44e79fbb64a.jpg) + +二叉查找树的查找操作每次迭代都会让区间减少一半,和二分查找类似。 + +```java +public class BST, Value> { + private Node root; + + private class Node { + private Key key; + private Value val; + private Node left, right; + private int N; // 以该节点为根的子树中节点总数 + + public Node(Key key, Value val, int N) { + this.key = key; + this.val = val; + this.N = N; + } + } + + public int size() { + return size(root); + } + + private int size(Node x) { + if (x == null) return 0; + return x.N; + } +} +``` + +### get() + +如果树是空的,则查找未命中;如果被查找的键和根节点的键相等,查找命中,否则递归地在子树中查找:如果被查找的键较小就在左子树中查找,较大就在右子树中查找。 + +```java +public Value get(Key key) { + return get(root, key); +} +private Value get(Node x, Key key) { + if (x == null) return null; + int cmp = key.compareTo(x.key); + if (cmp == 0) return x.val; + else if (cmp < 0) return get(x.left, key); + else return get(x.right, key); +} +``` + +### put() + +当插入的键不存在于树中,需要创建一个新节点,并且更新上层节点的链接使得该节点正确链接到树中。 + +```java +public void put(Key key, Value val) { + root = put(root, key, val); +} +private Node put(Node x, Key key, Value val) { + if (x == null) return new Node(key, val, 1); + int cmp = key.compareTo(x.key); + if (cmp == 0) x.val = val; + else if (cmp < 0) x.left = put(x.left, key, val); + else x.right = put(x.right, key, val); + x.N = size(x.left) + size(x.right) + 1; + return x; +} +``` + +### 分析 + +二叉查找树的算法运行时间取决于树的形状,而树的形状又取决于键被插入的先后顺序。最好的情况下树是完全平衡的,每条空链接和根节点的距离都为 lgN。在最坏的情况下,树的高度为 N。 + +![](https://github.com/00000H/notes/blob/master/pics/73a3983d-dd18-4373-897e-64b706a7e370.jpg) + +复杂度:查找和插入操作都为对数级别。 + +### floor() + +如果 key 小于根节点的 key,那么小于等于 key 的最大键节点一定在左子树中;如果 key 大于根节点的 key,只有当根节点右子树中存在小于等于 key 的节点,小于等于 key 的最大键节点才在右子树中,否则根节点就是小于等于 key 的最大键节点。 + +```java +public Key floor(Key key) { + Node x = floor(root, key); + if (x == null) return null; + return x.key; +} +private Node floor(Node x, Key key) { + if (x == null) return null; + int cmp = key.compareTo(x.key); + if (cmp == 0) return x; + if (cmp < 0) return floor(x.left, key); + Node t = floor(x.right, key); + if (t != null) { + return t; + } else { + return x; + } +} +``` + +### rank() + +```java +public int rank(Key key) { + return rank(key, root); +} +private int rank(Key key, Node x) { + if (x == null) return 0; + int cmp = key.compareTo(x.key); + if (cmp == 0) return size(x.left); + else if (cmp < 0) return rank(key, x.left); + else return 1 + size(x.left) + rank(key, x.right); +} +``` + +### min() + +```java +private Node min(Node x) { + if (x.left == null) return x; + return min(x.left); +} +``` + +### deleteMin() + +令指向最小节点的链接指向最小节点的右子树。 + +![](https://github.com/00000H/notes/blob/master/pics/6e2cb20a-8d2a-46fe-9ac7-68a2126b7bd5.jpg) + +```java +public void deleteMin() { + root = deleteMin(root); +} +public Node deleteMin(Node x) { + if (x.left == null) return x.right; + x.left = deleteMin(x.left); + x.N = size(x.left) + size(x.right) + 1; + return x; +} +``` + +### delete() + +如果待删除的节点只有子树,那么只需要让指向节点的链接指向唯一的子树即可;否则,让右子树的最小节点替换该节点。 + +![](https://github.com/00000H/notes/blob/master/pics/b488282d-bfe0-464f-9e91-1f5b83a975bd.jpg) + +```java +public void delete(Key key) { + root = delete(root, key); +} +private Node delete(Node x, Key key) { + if (x == null) return null; + int cmp = key.compareTo(x.key); + if (cmp < 0) x.left = delete(x.left, key); + else if (cmp > 0) x.right = delete(x.right, key); + else { + if (x.right == null) return x.left; + if (x.left == null) return x.right; + Node t = x; + x = min(t.right); + x.right = deleteMin(t.right); + x.left = t.left; + } + x.N = size(x.left) + size(x.right) + 1; + return x; +} +``` + +### keys() + +利用二叉查找树中序遍历的结果为有序序列的特点。 + +```java +public Iterable keys(Key lo, Key hi) { + Queue queue = new LinkedList<>(); + keys(root, queue, lo, hi); + return queue; +} +private void keys(Node x, Queue queue, Key lo, Key hi) { + if (x == null) return; + int cmpLo = lo.compareTo(x.key); + int cmpHi = hi.compareTo(x.key); + if (cmpLo < 0) keys(x.left, queue, lo, hi); + if (cmpLo <= 0 && cmpHi >= 0) queue.add(x.key); + if (cmpHi > 0) keys(x.right, queue, lo, hi); +} +``` + +### 性能分析 + +复杂度:二叉查找树所有操作在最坏的情况下所需要的时间都和树的高度成正比。 + +## 平衡查找树 + +### 2-3 查找树 + +![](https://github.com/00000H/notes/blob/master/pics/2548f2ec-7b00-4ec7-b286-20fc3022e084.jpg) + +一颗完美平衡的 2-3 查找树的所有空链接到根节点的距离应该是相同的。 + +#### 插入操作 + +当插入之后产生一个临时 4- 节点时,需要将 4- 节点分裂成 3 个 2- 节点,并将中间的 2- 节点移到上层节点中,如果上移操作继续产生临时 4- 节点则一直进行分裂上移,直到不存在临时 4- 节点。 + +![](https://github.com/00000H/notes/blob/master/pics/912174d8-0786-4222-b7ef-a611d36e5db9.jpg) + +#### 性质 + +2-3 查找树插入操作的变换都是局部的,除了相关的节点和链接之外不必修改或者检查树的其它部分,而这些局部变换不会影响树的全局有序性和平衡性。 + +2-3 查找树的查找和插入操作复杂度和插入顺序无关,在最坏的情况下查找和插入操作访问的节点必然不超过 logN 个。含有 10 亿个节点的 2-3 查找树最多只需要访问 30 个节点就能进行任意的查找和插入操作。 + +### 红黑二叉查找树 + +2-3 查找树需要用到 2- 节点和 3- 节点,红黑树使用红链接来实现 3- 节点。指向一个节点的链接颜色如果为红色,那么这个节点和上层节点表示的是一个 3- 节点,而黑色则是普通链接。 + +![](https://github.com/00000H/notes/blob/master/pics/7080a928-06ba-4e10-9792-b8dd190dc8e2.jpg) + +红黑树具有以下性质: + +1. 红链接都为左链接; +2. 完美黑色平衡,即任意空链接到根节点的路径上的黑链接数量相同。 + +画红黑树时可以将红链接画平。 + +![](https://github.com/00000H/notes/blob/master/pics/62077f5d-a06d-4129-9b43-78715b82cb03.png) + +```java +public class RedBlackBST, Value> { + private Node root; + private static final boolean RED = true; + private static final boolean BLACK = false; + + private class Node { + Key key; + Value val; + Node left, right; + int N; + boolean color; + + Node(Key key, Value val, int n, boolean color) { + this.key = key; + this.val = val; + N = n; + this.color = color; + } + } + + private boolean isRed(Node x) { + if (x == null) return false; + return x.color == RED; + } +} +``` + +#### 左旋转 + +因为合法的红链接都为左链接,如果出现右链接为红链接,那么就需要进行左旋转操作。 + +![](https://github.com/00000H/notes/blob/master/pics/33a4e822-2dd0-481e-ac89-7f6161034402.jpg) + +![](https://github.com/00000H/notes/blob/master/pics/5e0cef33-4087-4f21-a428-16d5fddda671.jpg) + +```java +public Node rotateLeft(Node h) { + Node x = h.right; + h.right = x.left; + x.left = h; + x.color = h.color; + h.color = RED; + x.N = h.N; + h.N = 1 + size(h.left) + size(h.right); + return x; +} +``` + +#### 右旋转 + +进行右旋转是为了转换两个连续的左红链接,这会在之后的插入过程中探讨。 + +![](https://github.com/00000H/notes/blob/master/pics/dfd078b2-aa4f-4c50-8319-232922d822b8.jpg) + +![](https://github.com/00000H/notes/blob/master/pics/3f8d8c9d-a9a9-4d7a-813c-2de05ee5a97e.jpg) + +```java +public Node rotateRight(Node h) { + Node x = h.left; + h.left = x.right; + x.color = h.color; + h.color = RED; + x.N = h.N; + h.N = 1 + size(h.left) + size(h.right); + return x; +} +``` + +#### 颜色转换 + +一个 4- 节点在红黑树中表现为一个节点的左右子节点都是红色的。分裂 4- 节点除了需要将子节点的颜色由红变黑之外,同时需要将父节点的颜色由黑变红,从 2-3 树的角度看就是将中间节点移到上层节点。 + +![](https://github.com/00000H/notes/blob/master/pics/de7c5a31-55f5-4e9d-92ec-4ed5b2ec3828.jpg) + +![](https://github.com/00000H/notes/blob/master/pics/e5ad625e-729d-4a8d-923a-7c3df5773e1c.jpg) + +```java +void flipColors(Node h){ + h.color = RED; + h.left.color = BLACK; + h.right.color = BLACK; +} +``` + +#### 插入 + +插入算法: + +- 如果右子节点是红色的而左子节点是黑色的,进行左旋转; +- 如果左子节点是红色的且它的左子节点也是红色的,进行右旋转; +- 如果左右子节点均为红色的,进行颜色转换。 + +![](https://github.com/00000H/notes/blob/master/pics/40639782-5df2-4e96-a4f3-f9dd664d0ca1.jpg) + +```java +public void put(Key key, Value val) { + root = put(root, key, val); + root.color = BLACK; +} + +private Node put(Node x, Key key, Value val) { + if (x == null) return new Node(key, val, 1, RED); + int cmp = key.compareTo(x.key); + if (cmp == 0) x.val = val; + else if (cmp < 0) x.left = put(x.left, key, val); + else x.right = put(x.right, key, val); + + if (isRed(x.right) && !isRed(x.left)) x = rotateLeft(x); + if (isRed(x.left) && isRed(x.left.left)) x = rotateRight(x); + if (isRed(x.left) && isRed(x.right)) flipColors(x); + + x.N = size(x.left) + size(x.right) + 1; + return x; +} +``` + +可以看到该插入操作和 BST 的插入操作类似,只是在最后加入了旋转和颜色变换操作即可。 + +根节点一定为黑色,因为根节点没有上层节点,也就没有上层节点的左链接指向根节点。flipColors() 有可能会使得根节点的颜色变为红色,每当根节点由红色变成黑色时树的黑链接高度加 1. + +#### 删除最小键 + +如果最小键在一个 2- 节点中,那么删除该键会留下一个空链接,就破坏了平衡性,因此要确保最小键不在 2- 节点中。将 2- 节点转换成 3- 节点或者 4- 节点有两种方法,一种是向上层节点拿一个 key,或者向兄弟节点拿一个 key。如果上层节点是 2- 节点,那么就没办法从上层节点拿 key 了,因此要保证删除路径上的所有节点都不是 2- 节点。在向下删除的过程中,保证以下情况之一发生: + +1. 如果当前节点的左子节点不是 2- 节点,完成; +2. 如果当前节点的左子节点是 2- 节点而它的兄弟节点不是 2- 节点,向兄弟节点拿一个 key 过来; +3. 如果当前节点的左子节点和它的兄弟节点都是 2- 节点,将左子节点、父节点中的最小键和最近的兄弟节点合并为一个 4- 节点。 + +![](https://github.com/00000H/notes/blob/master/pics/b001fa64-307c-49af-b4b2-2043fc26154e.png) + +最后得到一个含有最小键的 3- 节点或者 4- 节点,直接从中删除。然后再从头分解所有临时的 4- 节点。 + +![](https://github.com/00000H/notes/blob/master/pics/70b66757-755c-4e17-a7b7-5ce808023643.png) + +#### 分析 + +一颗大小为 N 的红黑树的高度不会超过 2lgN。最坏的情况下是它所对应的 2-3 树中构成最左边的路径节点全部都是 3- 节点而其余都是 2- 节点。 + +红黑树大多数的操作所需要的时间都是对数级别的。 + +## 散列表 + +散列表类似于数组,可以把散列表的散列值看成数组的索引值。访问散列表和访问数组元素一样快速,它可以在常数时间内实现查找和插入的符号表。 + +由于无法通过散列值知道键的大小关系,因此散列表无法实现有序性操作。 + +### 散列函数 + +对于一个大小为 M 的散列表,散列函数能够把任意键转换为 [0, M-1] 内的正整数,该正整数即为 hash 值。 + +散列表有冲突的存在,也就是两个不同的键可能有相同的 hash 值。 + +散列函数应该满足以下三个条件: + +1. 一致性:相等的键应当有相等的 hash 值。 +2. 高效性:计算应当简便,有必要的话可以把 hash 值缓存起来,在调用 hash 函数时直接返回。 +3. 均匀性:所有键的 hash 值应当均匀地分布到 [0, M-1] 之间,这个条件至关重要,直接影响到散列表的性能。 + +除留余数法可以将整数散列到 [0, M-1] 之间,例如一个正整数 k,计算 k%M 既可得到一个 [0, M-1] 之间的 hash 值。注意 M 必须是一个素数,否则无法利用键包含的所有信息。例如 M 为 10k,那么只能利用键的后 k 位。 + +对于其它数,可以将其转换成整数的形式,然后利用除留余数法。例如对于浮点数,可以将其表示成二进制形式,然后使用二进制形式的整数值进行除留余数法。 + +对于有多部分组合的键,每部分都需要计算 hash 值,并且最后合并时需要让每部分 hash 值都具有同等重要的地位。可以将该键看成 R 进制的整数,键中每部分都具有不同的权值。 + +例如,字符串的散列函数实现如下 + +```java +int hash = 0; +for(int i = 0; i < s.length(); i++) + hash = (R * hash + s.charAt(i)) % M; +``` + +再比如,拥有多个成员的自定义类的哈希函数如下 + +```java +int hash = (((day * R + month) % M) * R + year) % M; +``` + +R 的值不是很重要,通常取 31。 + +Java 中的 hashCode() 实现了 hash 函数,但是默认使用对象的内存地址值。在使用 hashCode() 函数时,应当结合除留余数法来使用。因为内存地址是 32 位整数,我们只需要 31 位的非负整数,因此应当屏蔽符号位之后再使用除留余数法。 + +```java +int hash = (x.hashCode() & 0x7fffffff) % M; +``` + +使用 Java 自带的 HashMap 等自带的哈希表实现时,只需要去实现 Key 类型的 hashCode() 函数即可,因此也就不需要考虑 M 的大小等。Java 规定 hashCode() 能够将键均匀分布于所有的 32 位整数,Java 中的 String、Integer 等对象的 hashCode() 都能实现这一点。以下展示了自定义类型如何实现 hashCode()。 + +```java +public class Transaction{ + private final String who; + private final Date when; + private final double amount; + + public int hashCode(){ + int hash = 17; + hash = 31 * hash + who.hashCode(); + hash = 31 * hash + when.hashCode(); + hash = 31 * hash + ((Double) amount).hashCode(); + return hash; + } +} +``` + +### 基于拉链法的散列表 + +拉链法使用链表来存储 hash 值相同的键,从而解决冲突。此时查找需要分两步,首先查找 Key 所在的链表,然后在链表中顺序查找。 + +![](https://github.com/00000H/notes/blob/master/pics/540133af-aaaf-4208-8f7f-33cb89ac9621.png) + +对于 N 个键,M 条链表 (N>M),如果哈希函数能够满足均匀性的条件,每条链表的大小趋向于 N/M,因此未命中的查找和插入操作所需要的比较次数为 \~N/M。 + +### 基于线性探测法的散列表 + +线性探测法使用空位来解决冲突,当冲突发生时,向前探测一个空位来存储冲突的键。使用线程探测法,数组的大小 M 应当大于键的个数 N(M>N)。 + +![](https://github.com/00000H/notes/blob/master/pics/2b3410f1-9559-4dd1-bc3d-e3e572247be2.png) + +```java +public class LinearProbingHashST { + private int N; + private int M = 16; + private Key[] keys; + private Value[] vals; + + public LinearProbingHashST() { + init(); + } + + public LinearProbingHashST(int M) { + this.M = M; + init(); + } + + private void init() { + keys = (Key[]) new Object[M]; + vals = (Value[]) new Object[M]; + } + + private int hash(Key key) { + return (key.hashCode() & 0x7fffffff) % M; + } +} +``` + +#### 查找 + +```java +public Value get(Key key) { + for (int i = hash(key); keys[i] != null; i = (i + 1) % M) { + if (keys[i].equals(key)) { + return vals[i]; + } + } + return null; +} +``` + +#### 插入 + +```java +public void put(Key key, Value val) { + int i; + for (i = hash(key); keys[i] != null; i = (i + 1) % M) { + if (keys[i].equals(key)) { + vals[i] = val; + return; + } + } + keys[i] = key; + vals[i] = val; + N++; + resize(); +} +``` + +#### 删除 + +删除操作应当将右侧所有相邻的键值重新插入散列表中。 + +```java +public void delete(Key key) { + if (!contains(key)) return; + int i = hash(key); + while (!key.equals(keys[i])) { + i = (i + 1) % M; + } + keys[i] = null; + vals[i] = null; + i = (i + 1) % M; + while (keys[i] != null) { + Key keyToRedo = keys[i]; + Value valToRedo = vals[i]; + keys[i] = null; + vals[i] = null; + N--; + put(keyToRedo, valToRedo); + i = (i + 1) % M; + } + N--; + resize(); +} +``` + +#### 调整数组大小 + +线性探测法的成本取决于连续条目的长度,连续条目也叫聚簇。当聚簇很长时,在查找和插入时也需要进行很多次探测。 + +α = N/M,把 α 称为利用率。理论证明,当 α 小于 1/2 时探测的预计次数只在 1.5 到 2.5 之间。 + +![](https://github.com/00000H/notes/blob/master/pics/0ddebc5c-7c24-46b1-98db-4fa5e54db16b.png) + +为了保证散列表的性能,应当调整数组的大小,使得 α 在 [1/4, 1/2] 之间。 + +```java +private void resize() { + if (N >= M / 2) resize(2 * M); + else if (N <= M / 8) resize(M / 2); +} + +private void resize(int cap) { + LinearProbingHashST t = new LinearProbingHashST<>(cap); + for (int i = 0; i < M; i++) { + if (keys[i] != null) { + t.put(keys[i], vals[i]); + } + } + keys = t.keys; + vals = t.vals; + M = t.M; +} +``` + +虽然每次重新调整数组都需要重新把每个键值对插入到散列表,但是从摊还分析的角度来看,所需要的代价却是很小的。从下图可以看出,每次数组长度加倍后,累计平均值都会增加 1,因为表中每个键都需要重新计算散列值,但是随后平均值会下降。 + +![](https://github.com/00000H/notes/blob/master/pics/01658047-0d86-4a7a-a8ca-7ea20fa1fdde.png) + +## 应用 + +### 各种符号表实现的比较 + +![](https://github.com/00000H/notes/blob/master/pics/9ee83c8c-1165-476c-85a6-e6e434e5307a.jpg) + +应当优先考虑散列表,当需要有序性操作时使用红黑树。 + +### Java 的符号表实现 + +Java 的 java.util.TreeMap 和 java.util.HashMap 分别是基于红黑树和拉链法的散列表的符号表实现。 + +### 集合类型 + +除了符号表,集合类型也经常使用,它只有键没有值,可以用集合类型来存储一系列的键然后判断一个键是否在集合中。 + +### 稀疏向量乘法 + +向量运算涉及到 N 次乘法,当向量为稀疏向量时,可以使用符号表来存储向量中的非 0 索引和值,使得乘法运算只需要对那些非 0 元素进行即可。 + +```java +import java.util.HashMap; + +public class SparseVector { + private HashMap hashMap; + + public SparseVector(double[] vector) { + hashMap = new HashMap<>(); + for (int i = 0; i < vector.length; i++) { + if (vector[i] != 0) { + hashMap.put(i, vector[i]); + } + } + } + + public double get(int i) { + return hashMap.getOrDefault(i, 0.0); + } + + public double dot(SparseVector other) { + double sum = 0; + for (int i : hashMap.keySet()) { + sum += this.get(i) * other.get(i); + } + return sum; + } +} +```