141. 环形链表
分析
链表找环
有环意味着从前往后遍历会遇到重复的点(重复是指地址重复)
最多只有1个环, 如果有很多环
图中一个蓝色点会有2个next, 分叉出去, 一个向下走, 一个向右走
其实每个点只有一个指针, 因此有环的话就只有1个环
开个hash表存下地址, 然后遍历的过程中判断有无出现重复的点, 如果有重复点, 说明有环
时间O(n), 空间O(n)
题目要求空间O(1)
快慢指针:
如果这样走
每次迭代 快指针与慢指针之间的距离k(图中绿色段)会-1, 所以迭代k次, 快指针会追上慢指针
笔记:(包含时间复杂度)
最坏快指针与慢指针距离n - x, 总的步数 3*(n - x ) + 2x + x = 3n, 还是O(n)
code
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
bool hasCycle(ListNode *head) {
if (!head || !head->next) return false;
auto s = head, f = head->next;
while (f){
s = s->next, f = f->next; // 快慢同时走一步
if (!f) return false; // 如果不能走, 直接返回
f = f->next; // 可以的话, 继续走一步
if (s == f) return true; // 如果相遇, 那就有环
}
return false;
}
};
142. 环形链表 II
分析
返回环的入口
首先假设第一次相遇的时候慢指针走过的节点个数为i,设链表头部到环的起点的长度为m,环的长度为L,相遇的位置与起点与环的起点位置距离为k。
于是有:
i = m + a * L + k
其中a为慢指针走的圈数。
因为快指针的速度是慢指针的2倍,于是又可以得到另一个式子:
2 * i = m + b * L + k
其中b为快指针走的圈数。
两式相减得:
i = ( b - a ) * L
也就是说i是圈长的整数倍。
这时a节点放在s起点,b处于N相遇位置,然后a,b同时向前走m步时,此时从头部走的指针在m位置。
b而应该在距离起点i+m,i为圈长整数倍,则该指针也应该在距离起点为m的位置,即环的起点。
我们可以理解为b节点从起点开始走,走到环起点,然后绕环转了几圈。
————————————————
版权声明:本文为CSDN博主「清水雅然君」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/liuhao192/article/details/106356382
注意1
相遇点不一定在环的入口
相遇之后, 设入口b和相遇点c距离y, 考虑时间倒流, 直到把慢指针从c退到b, 那么快指针会回退到c’点
此时慢指针走了x步, 快指针2x到达c’
因此可以发现从b点走x步会走到c’(这x步可能转很多圈)
将起点b偏移到c(偏移y), 终点c’偏移到b(偏移y)
那么从c点开始走x步会到达b
然后让一个指针在起点, 一个指针在相遇点c一起走x步骤,
那么第一个指针到达的点就是环的入口
yxc: 我再讲一次
注意2
赋初值的时候, 快慢指针的位置
其实他俩起点是在慢指针的前一个节点(虚拟点的位置)开始走的, 因此最后在第2次赋初值找入口的时候, 应该让点回到虚拟点的位置.
但代码中不用创建虚拟点, 只需要让快指针往后多走一步, 因为前面一个指针多走了一步, 快指针再走一步, 弥补一下
code
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
if (!head || !head->next) return NULL;
auto s = head, f = head->next;
while (f){
s = s->next, f = f->next;
if (!f) return NULL;
f = f->next;
if (s == f){
s = head, f = f->next; // 快指针错1位
// while条件 s != f是精髓, 因为s重新赋值后, 在走x步之前, 不可能与f相遇, 当且仅当走了x步, 才相遇
while (s != f) // 慢指针回到起始点开始走, 其实这里是起始点 + 1, 然后快指针已经+1, 弥补过了
s = s->next, f = f->next;
return s;
}
}
return NULL;
}
};
143. 重排链表
分析
把1个链表, 交错相排, 交错的部分是倒着的后半部分
可以先将后半部分链表反向
然后分别从两个链表的头节点开始走, 边走边交错
考虑细节
有可能是奇数, 让中间点归到左边
所以找中点的时候, 应该是
⌈
n
2
⌉
=
⌊
n
+
1
2
⌋
\lceil \frac{n}{2} \rceil = \lfloor \frac{n + 1}{2}\rfloor
⌈2n⌉=⌊2n+1⌋
所以先找到图中两点, 再让相邻点指针翻转
yxc的备课代码更好
code
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
void reorderList(ListNode* head) {
if (!head) return ;
int n = 0;
for (auto p = head; p; p = p->next) n ++;
auto mid = head;
for (int i = 0; i < (n + 1) / 2 - 1; i ++ ) mid = mid->next; // 找中点
auto tail = mid;
for (auto p = mid, q = mid->next; q;){
// 从中点, 中点后一个点开始翻转,
// 从中点和后一个点开始翻转的原因是, 下一步循环结束条件, q!=mid, 往回找到mid截止
auto next = q->next;
q->next = p, p = q, q = next;
tail = p;
}
for (auto p = head, q = tail; q != mid;){
auto next = q->next;
q->next = p->next;
p->next = q;
p = p->next->next, q = next;
}
if (n % 2) mid->next = NULL;
else mid->next->next = NULL; // 如果个数为偶数, 上面翻转完后的结果将会是mid->mid+1, 因此需要对mid+1->NULL
}
};
144. 二叉树的前序遍历
分析
根左右
如果有当前点有右儿子的话, 那么将当前点的左链压入栈中
跟中序遍历很像
在遍历根节点的时候
中序遍历是先遍历完左子树后, 再遍历根节点
前序遍历是先遍根节点, 再遍历左子树
将当前节点压入栈中, 意味着我们要遍历左子树了, 在此之前,我们先遍历当前节点, 也就是自身
左儿子全部遍历完后, 根节点要去回溯的时候, 要回溯到它的后继节点
此时栈顶元素就是后继节点
迭代
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;
stack<TreeNode*> stk;
while (root || stk.size()){
while (root){ // 只要当前节点不空, 就将当前节点压入栈中
// 压入栈中, 意味着要遍历左子树了, 在此之前, 先遍历当前点
res.push_back(root->val);
stk.push(root);
root = root->left;
}
// 此时左子树遍历完了, 回溯到后继节点, 也就是上一个节点, stk.top(), 因为根节点遍历过了
// 所以直接跳到right
root = stk.top()->right; stk.pop();
}
return res;
}
};
code(递归)
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
vector<int> ans;
vector<int> preorderTraversal(TreeNode* root) {
dfs(root);
return ans;
}
void dfs(TreeNode* root){
if (!root) return ;
ans.push_back(root->val);
dfs(root->left);
dfs(root->right);
}
};
145. 二叉树的后序遍历
分析
如果按照前序遍历和中序遍历的做法去做, 会发现比较麻烦
需要想办法记录额外信息
前序遍历在将当前点压入栈中的之前, 对应的是递归左子树, 对应到递归函数里, 相当于递归左子树
如果前序遍历的话, 在这个操作之前, 遍历根节点
如果中序遍历的话, 在栈里弹出这个点的时候, 遍历根节点, 就得到中序遍历
但是后序遍历不一样, 因为当前无法知道何时遍历完整棵子树了, 可以通过记录额外信息来获得遍历完子树的时间
但这题有一个简单的做法
一旦知道前序遍历的话
偷鸡法
如果我们按照前序遍历的方式 根 右 左的方式遍历, 得到的顺序是后序遍历左 右 根镜像的顺序
code
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
vector<int> res;
stack<TreeNode*> stk;
while (root || stk.size()){
while (root){
res.push_back(root->val);
stk.push(root);
root = root->right;
}
root = stk.top()->left; stk.pop();
}
reverse(res.begin(), res.end());
return res;
}
};
146. LRU 缓存机制
分析
可以把缓存看成hash表
对于put 和get 操作, 我们可以用hash表来维护缓存, 因为hash表增删改查都是O(1)的
要实现LRU机制, hash表中不仅要记录下来每个key对应的value是多少, 还需要记录下来哪个k的时间戳最小
要去修改每个位置的时间戳, 同时要快速找到时间戳最小的值 而且要O(1)时间
这里就要用到一个常用的数据结构—链表/或者可以理解为(队列)
比方说用了某个位置上的数的话, 就把这个位置上的数删掉,然后把它放到队头, 那么队尾元素一定是没有被用过的数
所以要支持两个操作, 快速的删掉一个位置上的数, 另外一个是将一个节点插入到最左侧
有一个数据结构叫做双链表
单链表不可以, 因为无法在O(1)时间删除, 因为我们删除的话, 需要知道它的上一个点, 对于单链表来说, 这个点是无法知道上一个点的, 所以不能O(1)的时间办到
所以这题我们用hash表维护key-value, 双链表维护时间戳, 相当于是用双链表做了一个队列, 每次用一个数, 就用这个节点上的数从双链表上删掉, 删完之后插到双链表的最左侧, 那这样的话, 双链表其实是一个排序的结果, 越往左越新, 越往右越旧, 每次如果爆掉缓存的话, 我们就把最后一个位置删掉
插入
删除
容量爆掉的话, 只会发生在put里的else分支
code
class LRUCache {
public:
struct Node{
int key, val;
Node* left, *right;
Node(int _key, int _val) : key(_key), val(_val), left(NULL), right(NULL) {}
}*L, *R; // 注意这里的L是虚拟头节点, R是虚拟尾节点, 都是不放任何信息, L右侧才存Node, R左侧才存Node
unordered_map<int, Node*> hash; // hash表维护节点Node信息
int n;
void remove(Node* p){
p->right->left = p->left;
p->left->right = p->right;
}
void insert(Node* p){
p->right = L->right;
p->left = L;
L->right->left = p;
L->right = p;
}
LRUCache(int capacity) {
n = capacity;
L = new Node(-1, -1), R = new Node(-1, -1);
L->right = R, R->left = L;
}
int get(int key) {
if (hash.count(key) == 0) return -1;
auto p = hash[key];
remove(p);
insert(p);
return p->val;
}
void put(int key, int value) {
if (hash.count(key)){
auto p = hash[key];
p->val = value;
remove(p);
insert(p);
}else {
if (hash.size() == n){
auto p = R->left;
remove(p);
hash.erase(p->key);
delete p;
}
auto p = new Node(key, value);
hash[key] = p;
insert(p);
}
}
};
/**
* Your LRUCache object will be instantiated and called as such:
* LRUCache* obj = new LRUCache(capacity);
* int param_1 = obj->get(key);
* obj->put(key,value);
*/
147. 对链表进行插入排序
分析
类似打扑克,
6 5 3 1 8 7 2 4
第1个数6
6
第2个数5, 发现5应该放在6的前面
5 6
第3个数 3, 应该放在5的前面
3 5 6
第4个数1, 1的话应该放到3的前面
1 3 5 6
第5个8, 应该放到6的后面
1 3 5 6 8
第6个数7, 应该放到8的前面, 和6的后面
1 3 5 6 7 8
第7个数2, 应该放到1和3的中间
1 2 3 5 6 7 8
第8个数4, 应该放到3和5的中间
1 2 3 4 5 6 7 8
因此, 每次需要找到当前数应该插入的位置, 怎么找呢
也就是说找到第1个大于它的位置, 然后查到它的前面就可以了
因为前面维护的链表是有序的, 然后找到第一个比它大的节点, 停下来就可以
当有有下一个点的时候, 并且下一个点的值比当前值小, 那么就可以一直往后走
总结:
code
记住: 要先开一个链表, 表示已经排好序了
然后遍历原链表,
再从维护好的链表中找到第1个大于当前值的位置, 在此之前(保存在当前点的next)
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* insertionSortList(ListNode* head) {
auto dummy = new ListNode(-1);
for (auto p = head; p;){
auto cur = dummy, next = p->next; // 已经排序好的链表dummy
// 因为要将p插入到正确的位置, p->next会改变, 保存下, 遍历的时候需要找回
while (cur->next && cur->next->val <= p->val) cur = cur->next;
// 这里结束循环, 表示cur->next比p大
p->next = cur->next;
cur->next = p;
p = next;
}
return dummy->next;
}
};
148. 排序链表
分析
快排 时间复杂度: 期望O(nlogn) 空间: 需要递归, 需要系统栈O(logn)
归并 时间复杂度: O(nlogn), 空间: 递归O(logn), 迭代的O(1)
堆排序 时间复杂度O(nlogn), 空间:O(1) (堆的存储方式) (链表不能用堆的存储方式(二叉树), 因此链表是不能存成堆的)
堆排序通过简单的交换就能把数据就地排成堆,不需要辅助空间。
所以这题只能用迭代的归并排序
从底下往上做, 就可以省掉递归栈的空间了O(1)
为了方便, 可以假设链表长度是2^n, 假如不是2^n(2的整数幂), 补充成2^n, 然后补充的部分就当不存在
code
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* sortList(ListNode* head) {
int n = 0;
for (auto p = head; p; p = p->next) n ++;
// i = 1的时候表示 长度为1的区间合并 1 1合并成2, 那么当i = n的时候, 表示已经合并完成, 不需要合并成2n, 链表长度才n啊
for (int i = 1; i < n; i *= 2 ){
auto dummy = new ListNode(-1), cur = dummy; // 新建一个来存合并完的链表
for (int j = 1; j <= n; j += i * 2){ // j <= n而不是j + i <= n, 是因为最后一段不足i的也要保存
auto p = head, q = p; // 先找到成对的第2个长度为i的区间的头部
for (int k = 0; k < i && q; k ++ ) q = q->next;
auto o = q; // 保存 下一个长度为i的区间的头
for (int k = 0; k < i && o; k ++ ) o = o->next;
int l = 0, r = 0;
while (l < i && r < i && p && q){
if (p->val <= q->val ) cur = cur->next = p, p = p->next, l ++;
else cur = cur->next = q, q = q->next, r ++ ;
}
// 因为会有长度不足2^n的区间(比如最后一段), 所以要加两个while
while (l < i && p) cur = cur->next = p, p = p->next, l ++ ;
while (r < i && q) cur = cur->next = q, q = q->next, r ++ ;
head = o;
}
cur->next = NULL; // 将合并完的区间段尾节点置空
head = dummy->next; // 已经合并成2*i的区间, 让head指向合并完成的头部
}
return head;
}
};
149. 直线上最多的点数
分析
先想下暴力做法
- 先枚举直线 O(n^2): 枚举两个点对形成的直线
- 判断直线上有多少个点 O(n)
判断直线上有多少个点, 可以用直线方程, 叉积, 点积, 很多方式可以求
O(n^3)
可以优化
先枚举中心点O(n), 然后枚举下集合中其他点和该中心点的斜率O(n), 用hash表记录斜率为k的直线上的点的数量.
遍历完当前中心点后, 统计下最大值,
O(n^2)
code
vs : 垂直x轴的直线上点的数量
ss : 与中心点重合的点的数量
class Solution {
public:
int maxPoints(vector<vector<int>>& points) {
typedef long double LD;
int res = 0;
for (auto& p : points){
unordered_map<LD, int> cnt;
int ss = 0, vs = 0;
for (auto& q : points){
if (p == q) ss ++;
else if (p[0] == q[0]) vs ++;
else {
LD k = (LD) (q[1] - p[1]) / (LD)(q[0] - p[0]);
cnt[k] ++;
}
}
int c = vs; // 垂直的也算斜率
for (auto [k , t] : cnt) c = max(c, t); // 因此要拿垂直的直线与 其他斜率的直线比较点数
res = max(res, c + ss);
}
return res;
}
};
150. 逆波兰表达式求值
分析
逆波兰表达式定义就是不断将字符压入栈中, 遇到操作符就将栈顶2个计算下, 再压入栈中
从前往后遍历下
- 如果是操作符, 取出栈顶2个
- 否则, 即不是操作符 push(stoi(s))
stoi --> string to int
code
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> stk;
for (auto& s : tokens){
if (s == "+" || s == "-" || s == "*" || s == "/"){
auto b = stk.top(); stk.pop(); // 注意 第1个top的是b
auto a = stk.top(); stk.pop(); // 第2个才是a啊
if (s == "+") a += b;
else if (s == "-") a -= b;
else if (s == "*") a *= b;
else a /= b;
stk.push(a);
}else stk.push(stoi(s));
}
return stk.top();
}
};