一.归并
思路
归并的思想就是先拆分,计算子序列的【题目要求计算的目标】,然后存储到一个全局的数据结构中,然后不断地归并,计算更到子序列的【题目要求计算的目标】。大多数的题目在合并的时候子序列的排序是会影响到上一层的,所以在合并之前需要将下一层做好排序,这其中可能会使用到额外的数据结构。
<2101题><315题>
<2101题>
思路:
代码:
class Solution{
int count;
public int reversePairs(int[] nums) {
this.count = 0;
merge(nums, 0, nums.length - 1);
return count;
}
public void merge(int[] nums, int left, int right) {
int mid = left + ((right - left) >> 1);
if (left < right) {
merge(nums, left, mid);
merge(nums, mid + 1, right);
mergeSort(nums, left, mid, right);
}
}
public void mergeSort(int[] nums, int left, int mid, int right) {
int[] temparr = new int[right - left + 1];
int index = 0;
int temp1 = left, temp2 = mid + 1;
while (temp1 <= mid && temp2 <= right) {
if (nums[temp1] <= nums[temp2]) {
temparr[index++] = nums[temp1++];
} else {
//用来统计逆序对的个数
count += (mid - temp1 + 1);
temparr[index++] = nums[temp2++];
}
}
//把左边剩余的数移入数组
while (temp1 <= mid) {
temparr[index++] = nums[temp1++];
}
//把右边剩余的数移入数组
while (temp2 <= right) {
temparr[index++] = nums[temp2++];
}
//把新数组中的数覆盖nums数组
for (int k = 0; k < temparr.length; k++) {
nums[k + left] = temparr[k];
}
}
}
<315题>
思路:
1.将nums的看成是由大到小的排序是正常的排序,每一次拆分的时候,子序列也看成是由大到小的。
2.因为由小的子序列合并成大的子序列时已经计算过小的子子序列的【右侧小于当前元素的个数】了,所以为了保证子序列是排好序的,这时需要一个数组来不断地排序,即 helper[k];
3.helper数组里面每次存储的都是目前这两个子序列按照有大到小排序完,元素之前在nums中的下标。
并通过index[k] = helper[k];来更新排完序之后的下标。
class Solution {
private int[] index;
private int[] helper;
private int[] count;
public List<Integer> countSmaller(int[] nums) {
List<Integer> res = new ArrayList<>(nums.length);
index = new int[nums.length];
helper = new int[nums.length];
count = new int[nums.length];
for (int i = 0; i < index.length; i++) {
index[i] = i;
}
merge(nums, 0, nums.length - 1);
for (int i = 0; i < count.length; i++) {
res.add(i, count[i]);
}
return res;
}
private void merge(int[] nums, int s, int e) {
if (s == e || s > e) return;
int mid = (s + e) >> 1;
if (s < mid) {
merge(nums, s, mid);
}
if (mid + 1 < e) {
merge(nums, mid + 1, e);
}
int i = s, j = mid + 1;
int hi = s;
//helper里面存储的是按照数字大小排列从大到小的元素下标
while (i <= mid && j <= e) {
if (nums[index[i]] <= nums[index[j]]) {
// 右侧出
helper[hi++] = index[j++];
} else {
// 左侧出 计数
count[index[i]] += e - j + 1;
helper[hi++] = index[i++];
}
}
//左右元素数量不是平均分的情况下,将没遍历到的元素下标直接补到helper后面
while (i <= mid) {
//左侧出
helper[hi++] = index[i++];
}
while (j <= e) {
// 右侧出
helper[hi++] = index[j++];
}
for (int k = s; k <= e; k++) {
index[k] = helper[k];
}
}
}
二.回溯
https://mp.weixin.qq.com/s/nrTpZ9b9RvfNsaEkJoHMvg
三类问题:
1.子集 78题
2.求组合 77题
3.求全排列 44题
模板套路
代码模板:
1.子集
List<List<Integer>> res = new LinkedList<>();
// 记录回溯算法的递归路径
LinkedList<Integer> track = new LinkedList<>();
// 主函数
public List<List<Integer>> subsets(int[] nums) {
backtrack(nums, 0);
return res;
}
// 回溯算法核心函数,遍历子集问题的回溯树
void backtrack(int[] nums, int start) {
// 前序位置,每个节点的值都是一个子集
res.add(new LinkedList<>(track));
// 回溯算法标准框架
for (int i = start; i < nums.length; i++) {
// 做选择
track.addLast(nums[i]);
// 通过 start 参数控制树枝的遍历,避免产生重复的子集
backtrack(nums, i + 1);
// 撤销选择
track.removeLast();
}
}
2.组合问题
题目描述:
思路:
在子集的问题上修改递归的出口条件,当递归深度到k时,将路径收集起来返回。
List<List<Integer>> res = new LinkedList<>();
// 记录回溯算法的递归路径
LinkedList<Integer> track = new LinkedList<>();
// 主函数
public List<List<Integer>> combine(int n, int k) {
backtrack(1, n, k);
return res;
}
void backtrack(int start, int n, int k) {
// base case
if (k == track.size()) {
// 遍历到了第 k 层,收集当前节点的值
res.add(new LinkedList<>(track));
return;
}
// 回溯算法标准框架
for (int i = start; i <= n; i++) {
// 选择
track.addLast(i);
// 通过 start 参数控制树枝的遍历,避免产生重复的子集
backtrack(i + 1, n, k);
// 撤销选择
track.removeLast();
}
}
3.全排列
思路:
每选择下一个元素的时候都要从for循环第一个元素开始,还要判别是否前面的路径中已经添加了这个元素,可以通过一个数组boolean[ ]记录当前元素是不是已经存在了,如果存在了直接continue,不存在就添加。
List<List<Integer>> res = new LinkedList<>();
// 记录回溯算法的递归路径
LinkedList<Integer> track = new LinkedList<>();
// track 中的元素会被标记为 true
boolean[] used;
/* 主函数,输入一组不重复的数字,返回它们的全排列 */
public List<List<Integer>> permute(int[] nums) {
used = new boolean[nums.length];
backtrack(nums);
return res;
}
// 回溯算法核心函数
void backtrack(int[] nums) {
// base case,到达叶子节点
if (track.size() == nums.length) {
// 收集叶子节点上的值
res.add(new LinkedList(track));
return;
}
// 回溯算法标准框架
for (int i = 0; i < nums.length; i++) {
// 已经存在 track 中的元素,不能重复选择
if (used[i]) {
continue;
}
// 做选择
used[i] = true;
track.addLast(nums[i]);
// 进入下一层回溯树
backtrack(nums);
// 取消选择
track.removeLast();
used[i] = false;
}
}
<46>
1.后进先出;
2.每次拆分成的子问题又可以拆成多个子问题
3.向上的过程需要恢复的步骤(失败的尝试需要恢复到以前的状态,再去尝试其他的步骤)。
<140>
通过path回溯,每走过一种path,并走到头,就证明这个路径是好使的,把它加入到最终结果中res中。
对比代码进行解析:
/**
* s[0:len) 如果可以拆分成 wordSet 中的单词,把递归求解的结果加入 res 中
*
* @param s
* @param len 长度为 len 的 s 的前缀子串
* @param wordSet 单词集合,已经加入哈希表
* @param dp 预处理得到的 dp 数组
* @param path 从叶子结点到根结点的路径
* @param res 保存所有结果的变量
*/
private void dfs(String s, int len, Set<String> wordSet, boolean[] dp, Deque<String> path, List<String> res) {
if (len == 0) {
res.add(String.join(" ",path));
return;
}
// 可以拆分的左边界从 len - 1 依次枚举到 0
for (int i = len - 1; i >= 0; i--) {
String suffix = s.substring(i, len);
if (wordSet.contains(suffix) && dp[i]) {
path.addFirst(suffix);
dfs(s, i, wordSet, dp, path, res);
path.removeFirst();
}
}
}
三.前缀树/字典树
前缀树的应用:
1、前缀匹配
2、字符串检索
3、词频统计
4、字符串排序
java模板代码
class Trie {
class TireNode {
private boolean isEnd;
TireNode[] next;
public TireNode() {
isEnd = false;
next = new TireNode[26];
}
}
private TireNode root;
public Trie() {
root = new TireNode();
}
public void insert(String word) {
TireNode node = root;
for (char c : word.toCharArray()) {
if (node.next[c - 'a'] == null) {
node.next[c - 'a'] = new TireNode();
}
node = node.next[c - 'a'];
}
node.isEnd = true;
}
public boolean search(String word) {
TireNode node = root;
for (char c : word.toCharArray()) {
node = node.next[c - 'a'];
if (node == null) {
return false;
}
}
return node.isEnd;
}
public boolean startsWith(String prefix) {
TireNode node = root;
for (char c : prefix.toCharArray()) {
node = node.next[c - 'a'];
if (node == null) {
return false;
}
}
return true;
}
}
前缀树的构建、方法:
1.插入
描述:向 Trie 中插入一个单词 word
实现:这个操作和构建链表很像。首先从根结点的子结点开始与 word 第一个字符进行匹配,一直匹配到前缀链上没有对应的字符,这时开始不断开辟新的结点,直到插入完 word 的最后一个字符,同时还要将最后一个结点isEnd = true;,表示它是一个单词的末尾。
void insert(string word) {
Trie* node = this;
for (char c : word) {
if (node->next[c-'a'] == NULL) {
node->next[c-'a'] = new Trie();
}
node = node->next[c-'a'];
}
node->isEnd = true;
}
2.查找
描述:查找 Trie 中是否存在单词 word
实现:从根结点的子结点开始,一直向下匹配即可,如果出现结点值为空就返回 false,如果匹配到了最后一个字符,那我们只需判断 node->isEnd即可。
bool search(string word) {
Trie* node = this;
for (char c : word) {
node = node->next[c - 'a'];
if (node == NULL) {
return false;
}
}
return node->isEnd;
}
3.前缀匹配
描述:判断 Trie 中是或有以 prefix 为前缀的单词
实现:和 search 操作类似,只是不需要判断最后一个字符结点的isEnd,因为既然能匹配到最后一个字符,那后面一定有单词是以它为前缀的。
bool startsWith(string prefix) {
Trie* node = this;
for (char c : prefix) {
node = node->next[c-'a'];
if (node == NULL) {
return false;
}
}
return true;
}
4.总结
通过以上介绍和代码实现我们可以总结出 Trie 的几点性质:
Trie 的形状和单词的插入或删除顺序无关,也就是说对于任意给定的一组单词,Trie 的形状都是唯一的。
查找或插入一个长度为 L 的单词,访问 next 数组的次数最多为 L+1,和 Trie 中包含多少个单词无关。
Trie 的每个结点中都保留着一个字母表,这是很耗费空间的。如果 Trie 的高度为 n,字母表的大小为 m,最坏的情况是 Trie 中还不存在前缀相同的单词,那空间复杂度就为 O(m^n)O(m ^n)。
最后,关于 Trie 的应用场景,希望你能记住 8 个字:一次建树,多次查询。
《212题》
四.动态规划
1.最长递增子序列
题目
备忘录的更新问题