数据结构:数组和链表
数组
数组基础
数组是一种数据结构,它在计算机内存中占据一段连续的空间,并由一系列元素组成,这些元素的类型相同。在数组中,每个元素都可以通过数组索引(通常是整数)快速访问,索引通常从 0 开始。数组的特点是其大小(即可以容纳的元素数量)在被创建时就已经确定,并且在整个使用周期内保持固定。
vector向量
array
数组和vector
数组的区别:
- 固定大小vs动态大小:
std::array
是一个固定大小的数组容器,其大小在编译时已经确认,不可改变;std::vector
是一个动态大小的数组容器,可以在运行时添加或删除元素,自动管理内存,并根据需要调整容量; - 内存分配机制不同:
std::array
大小固定,所以通常在栈上分配内存(也可以在堆上分配),访问速度相对较快,但是对于非常大的数组会因为栈空间限制而不适用;std:vector
在堆上存储元素,使得可以根据需要扩展大小,但是会引来更多内存操作开销,如分配和释放内存; - 性能开销不同:
std::array
性能开销小,速度快;std:vector
提供了灵活的动态扩展功能,添加元素可能要重新分配内存和复制现有元素到新的空间,性能开销大; - API功能:
std::vector
的API功能更为丰富。
vector<int>
是 C++ 标准模板库(STL)中的一个重要组成部分,它是一个动态数组,能够根据需要自动调整大小。模板类表示 vector
可以存储任意类型的元素,在这个例子中是 int
类型。与普通数组相比,vector
提供了更多的灵活性和功能,如自动管理内存、动态调整大小、提供插入和删除元素的操作等。
底层结构:
- 动态数组:
vector
在底层使用动态数组来存储元素。这意味着它使用一块连续的内存空间来存放数据,从而可以很高效地通过索引访问各个元素。 - 容量和大小:
vector
维护了它的容量(capacity)和大小(size)这两个属性。大小表示当前存储在vector
中的元素数量,而容量表示目前分配的内存可以容纳多少元素。当元素被添加到vector
中,如果大小超过了当前容量,vector
就会自动重新分配一块更大的内存区域来存储元素,同时复制原有元素到新的内存区域,并释放原有的内存。 - 自动扩容: 当向
vector
添加新元素时,如果现有容量不够,vector
会进行扩容。扩容的过程中,通常会申请更大一块连续的内存空间,拷贝原来的元素到新空间,并释放旧空间。这个过程涉及到内存的分配和释放,因此可能比较消耗性能。为了优化性能,vector
在扩容时通常会将容量增加一定比例(比如加倍),这样可以减少扩容的频率,提高性能。 - 随机访问: 由于
vector
使用连续内存存储数据,因此它支持高效的随机访问。可以通过索引直接访问任何元素,时间复杂度为 O(1)。 - 内存管理:
vector
自动管理其存储空间的分配和释放。当vector
被销毁时,或者需要重新分配内存时(比如在扩容或通过resize()
、clear()
等操作更改vector
的大小),它会自动释放已分配的内存。 - 迭代器:
vector
提供了迭代器,能够迭代访问其中的元素。迭代器是一个抽象的指针,提供了访问和遍历容器元素的能力。
vector<int>
提供了一种灵活、动态地存储整数数组的方法,通过自动管理内存和提供高级操作,使得数据的处理变得更加高效和方便。
vector的迭代器机制
vector
提供了随机访问迭代器(Random Access Iterator)。这种迭代器支持所有标准的迭代器操作,包括但不限于:
- 迭代器的递增和递减(即可以前进和后退)。
- 通过迭代器访问元素(解引用)。
- 迭代器间的距离计算。
- 迭代器的随机访问(通过加上或者减去一个整数,例如
vec.begin() + 2
也是一个迭代器)。
这意味着你可以非常灵活地使用 vector
的迭代器,几乎像使用普通数组指针一样。
迭代器示例:
std::vector<int> v = {1, 2, 3, 4, 5};
auto it_begin = v.begin(); // 迭代器指向第一个元素
auto it_end = v.end(); // 迭代器指向最后一个元素之后的位置
for (auto it = v.begin(); it != v.end(); ++it) { // auto用于自动类型推导,简化代码,提高可维护性
std::cout << *it << " "; // 使用解引用操作符访问元素(迭代器的解引用)
}
for (auto it = v.begin(); it != v.end(); ++it) {
*it *= 2; // 将每个元素的值翻倍
}
// C++11的新特性:基于范围的 for 循环(range-based for loop),它可以自动遍历容器中的每个元素而无需直接使用迭代器:
for (int elem : v) {
std::cout << elem << " ";
}
vector模板类的示例代码:
#include <iostream>
#include <algorithm> // 主要为了 std::copy
#include <cassert> // 为了 assert
template <typename T>
class SimpleVector {
public:
SimpleVector() : data_(nullptr), size_(0), capacity_(0) {} // 构造函数
~SimpleVector() { delete[] data_; } // 析构函数,删除data_指向的数组;
void push_back(const T& value) {
if (size_ == capacity_) expand_capacity(); // 扩容机制;
data_[size_++] = value;
}
// 删除最后一个元素,简单实现,并未处理容量调整
void pop_back() {
if (size_ == 0) return;
size_--;
}
T& operator[](size_t index) { // 操作符重载;
assert(index < size_); // 是否越界判断;
return data_[index];
}
size_t size() const { return size_; }
size_t capacity() const { return capacity_; }
private:
T* data_; // 指针;
size_t size_; // 数组中元素个数;
size_t capacity_; // 数组容量;
void expand_capacity() { // 扩容机制;
size_t new_capacity = capacity_ == 0 ? 1 : capacity_ * 2; // 初次扩容设为1,之后每次加倍(扩容策略)
T* new_data = new T[new_capacity]; // 申请一个新的数组空间;
std::copy(data_, data_ + size_, new_data); // 将旧数据复制到新的内存区域
delete[] data_; // 删除原数组空间;
data_ = new_data; // 改变原数组指针指向;
capacity_ = new_capacity; // 改变容量;
}
};
析构函数:析构函数的主要任务是释放 SimpleVector
内部动态分配的内存,以避免内存泄漏。析构函数是类资源管理的重要组成部分,确保了资源的有效释放和对象安全销毁的机制。通过在类名前加上 ~
来定义析构函数
析构函数的工作原理
析构函数的调用时机是由 C++ 的运行时环境(Runtime)管理的,其调用时机主要取决于对象的存储周期:
- 对于局部对象,当控制流离开对象定义所在的作用域时,其析构函数被自动调用。
- 对于动态分配的对象(使用 new 关键字分配的对象),需要显式通过 delete 操作来触发析构函数。
- 对于全局或静态存储周期的对象,其析构函数会在 main 函数结束之后,程序关闭之前调用。
Vector常用API
下面表格概括了 vector
的一些常用 API,以及它们的使用方法、示例和底层原理等重要信息。
方法 | 使用方法 | 使用案例 | 底层原理 |
---|---|---|---|
push_back() | 在 vector 的末尾添加一个元素 | v.push_back(10); | 如果当前容量足够,直接在末尾添加元素。若容量不足,先扩容(通常是当前大小的两倍),再添加元素。扩容涉及内存分配和元素拷贝。 |
pop_back() | 删除 vector 的最后一个元素 | v.pop_back(); | 直接移除末尾元素,不会减少容量。 |
size() | 返回 vector 当前的元素个数 | auto n = v.size(); | 返回内部维护的元素计数变量。 |
empty() | 检查 vector 是否为空 | if (v.empty()) {...} | 检查 size() 是否返回 0。 |
resize() | 改变 vector 的大小 | v.resize(10); | 如果新大小大于当前大小,会添加默认初始化的元素。如果小于当前大小,会移除多余的元素。 |
reserve() | 改变 vector 的容量但不改变大小 | v.reserve(100); | 为 vector 分配足够的空间以容纳指定数量的元素,用于优化添加大量元素的性能。 |
operator[] | 通过索引访问 vector 中的元素 | int x = v[2]; | 提供对指定位置元素的直接访问。不检查索引有效性。 |
at() | 通过索引访问 vector 中的元素,并进行越界检查 | int x = v.at(2); | 类似 operator[] ,但会检查索引是否有效,如果无效会抛出 std::out_of_range 异常。 |
front() | 返回 vector 的第一个元素 | int first = v.front(); | 直接返回首元素的引用。类比 operator[] ,但是无需指定索引。 |
back() | 返回 vector 的最后一个元素 | int last = v.back(); | 直接返回尾元素=的引用。类比 operator[] ,但是无需指定索引。 |
insert() | 在指定位置插入元素 | v.insert(v.begin() + 3, 10); | 如果容量足够,从插入点开始,后续元素都向后移动一位,然后将新元素插入。如果容量不足,先扩容后插入。涉及元素移动和可能的内存分配。 |
erase() | 移除指定位置的元素或一段元素 | v.erase(v.begin() + 2); | 移除元素后,后续元素向前移动填补空缺。 |
clear() | 清除 vector 的所有元素 | v.clear(); | 移除所有元素,但不会减少容量。 |
emplace_back() | 在 vector 的末尾直接构造一个元素 | v.emplace_back(10); | 类似 push_back() ,但是更高效,因为它直接在容器尾部构造元素,而不是复制或移动元素。 |
emplace() | 在指定位置直接构造元素 | v.emplace(v.begin() + 1, 10); | 类似 insert() ,但省略了元素拷贝或移动的步骤,直接在指定位置构造元素。 |
注意事项
- 容量与大小:
vector
的大小(size()
)指的是实际存储的元素数量,而容量(通过capacity()
获取)是当前分配的存储空间能容纳元素的数量。reserve()
可以增加容量,resize()
改变大小。 - 性能考虑:
push_back()
和emplace_back()
添加元素时,如果超出当前容量,vector
需要进行内存分配和旧元素到新空间的拷贝或移动,这可能导致性能损失。因此,如果提前知道需要存储的元素数量,使用reserve()
预分配足够的空间可以优化性能。 - 安全访问:使用
at()
方法访问元素比使用operator[]
更安全,因为前者会进行越界检查。
数组中常见算法
二分查找
二分查找Leetcode:必须是有序数组才可以使用二分查找;
二分法使用的重点:区间一致性原则;
区间的处理方法不只是二分查找算法中的关键,而且是许多算法设计和实现中的基本原则之一。这种处理方法体现了一种重要的算法思想——区间一致性原则。此原则不仅适用于二分法,还适用于其他需要区间操作的算法,比如快速排序、二叉搜索树的操作、以及其他需要对区间进行递归或迭代处理的场景。
区间一致性原则
区间一致性原则简单来说,是指在算法的整个处理过程中,对区间的定义和操作应保持一致。这包括:
- 区间的定义方式,是左闭右闭(
[left, right]
)还是左闭右开([left, right)
)。 - 区间的更新和操作方式,应遵循初始的区间定义方式,确保算法的逻辑一致,避免出现边界条件错误。
为什么区间一致性原则重要
- 减少错误:坚持一种区间处理方法可以减少因为区间边界处理不当而引发的错误。
- 提升可读性:代码中对区间的操作如果保持一致,会使得代码更容易被阅读和理解。
- 简化实现:特别是在复杂算法中,一致的区间处理方式能避免不必要的复杂性,简化实现。
举例说明
在二分查找算法中,假设我们开始时定义区间为左闭右闭[left, right]
,那么在更新 left 或 right 边界时,我们应该保持这种闭区间的方式不变。例如,在更新左边界时,应设置left = mid + 1
,更新右边界时应设置right = mid
,其中mid
是当前区间的中点。(即应该取到左边界,取到右边界)
相反,如果定义区间为左闭右开[left, right)
,那么更新 left 时应为left = mid + 1
,更新右边界时应为right = mid
。在这种情况下,mid
不会被包含在新的区间内,这符合右开区间的定义。(即应该取到左边界,取不到右边界)
应用
这种思考区间的方式不仅应用于二分查找,还涉及到其他算法的实现,如某些分治算法、动态规划中的区间处理等。无论何时遇到需要处理区间的问题,应首先明确你的区间定义(左闭右闭或左闭右开),然后始终遵循这一定义进行后续的所有操作,以确保算法的正确性和清晰性。
总结
区间一致性原则是算法设计中的一种重要思想,它要求我们在处理区间时保持定义和操作的一致性。遵循这一原则可以帮助我们减少逻辑错误,提升代码的可读性和实现的简洁性。在实践中,这要求我们在算法实现之初就明确区间的定义,并在整个算法过程中坚持这一定义。
双指针法
双指针法是一种重要的算法思想,它使用两个指针协同完成任务,提高算法的空间和时间效率。双指针法主要有两类:头尾双指针和快慢双指针。这两种指针虽然操作方式不同,但共享双指针法的核心思想。
快慢双指针
快慢双指针一般用于处理线性结构(如链表、数组),通过两个速度不同的指针来解决问题,例如检测循环、确定中点、删除特定元素等。
举例说明:移除特定元素
移除数组中的特定元素 val
,并返回移除后数组的新长度,要求原地修改数组,不使用额外的空间。
这里可以使用快慢指针法。快指针fast
用于遍历数组,慢指针slow
指向下一个将要替换的位置。
int removeElement(vector<int>& nums, int val) {
int slow = 0;
for (int fast = 0; fast < nums.size(); ++fast) {
if (nums[fast] != val) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
在这个例子中,当快指针fast
指向的元素不是val
时,我们将它赋值给slow
指针的位置,然后slow
向前移动一位。这样,数组前端就聚集了所有非val
的元素,实现了原地修改数组而无需额外空间。
双指针法的重要思想与适用条件
重要思想:
- 空间优化:双指针法通常不需要额外的空间,可以在原数据结构上操作。
- 时间优化:通过协同操作两个指针,减少不必要的遍历次数,提高效率。
适用条件:
- 线性结构:数组或链表等线性结构是双指针法处理的关键对象。
- 有序性:特别是头尾双指针,在处理有序数组(或链表)寻找元素对、判断条件时尤其有效。
- 特定目标:如需要查找、删除或替换符合特定条件的元素时。
区分使用场景:
- 头尾双指针:主要适用于有序数组的双向搜索,如二分查找、滑动窗口等。
- 快慢双指针:主要用于需要区分两种类型的元素或需要检查循环等场景,比如链表中检测环、数组中移除特定元素。
总的来说,双指针法提供了一种在保持空间效率的同时提高时间效率的方式,对于处理线性数据结构的特定问题非常有效。在算法设计时,识别问题是否适合应用双指针法是提高效率的关键。
滑动窗口
滑动窗口是双指针法的一种特殊应用形式,它通过不断调整子序列的起始和终止位置,在遍历过程中解决特定问题。滑动窗口的核心优势在于它能够将时间复杂度从 (O(n^2)) 的暴力解方式降低至 (O(n)),这对于处理大规模数据问题尤其重要。
滑动窗口的功能
滑动窗口主要用于解决数组或字符串的子区间问题,特别是当问题要求我们找到满足特定条件的最短或最长的子区间时。通过动态调整窗口的大小和位置,我们可以有效地找到问题的解。
使用场景
滑动窗口技术的典型使用场景包括:
- 固定窗口大小的问题:比如求所有长度为 k 的子数组的最大值。
- 可变窗口大小的问题:比如找出数组或字符串中满足条件的最小或最大长度的子区间。例如求最小覆盖子串、连续子数组的最大和等。
使用方法
虽然滑动窗口的具体实现可能因问题而异,但其基本步鑿是相似的,通常遵循以下模式:
- 初始化两个指针:一个指向窗口的开始位置,一个指向窗口的结束位置,最初两者都指向序列的起始位置。
- 扩展窗口:通过移动结束位置的指针来扩展窗口,直到窗口包含了一个满足条件的解。
- 收缩窗口:在找到一个有效解后,尝试通过移动开始位置的指针来收缩窗口,去掉窗口起始部分的元素,以探索是否存在更优的解,直到窗口不再满足条件。
- 记录结果并调整:在每次找到一个满足条件的窗口时记录当前最优解,并根据问题需求调整窗口的起始和结束位置,直到遍历完整个序列。
首先递归结束位置的指针找到一个满足条件的解,然后递归开始位置的指针找到更优解,最终遍历完整个序列找到全局最优解;
示例
让我们通过一个简单的例子来说明滑动窗口的使用:给定一个整数数组和一个数 k,寻找所有大小为 k 的连续子数组的最大和。
这个问题可以通过滑动窗口技术以 (O(n)) 的时间复杂度解决:
- 初始化:两个指针同时指向数组的起始位置,窗口和初始化为 0。
- 扩展窗口:结束位置的指针向右移动,扩大窗口直到窗口的大小为 k。
- 计算并记录结果:每次窗口到达大小为 k 时,计算窗口内元素的和,并与之前的最大值比较更新。
- 收缩窗口:开始位置的指针向右移动一个位置,窗口大小保持为 k。继续步骤 3 直到结束。
总的来说,滑动窗口是一个非常强大的工具,特别适用于解决需要动态调整区间或寻找最优区间问题的场景。掌握其使用方法和思想,可以大大提高解决问题的效率和效果。
模拟
螺旋矩阵II:可以发现这里的边界条件非常多,在一个循环中,如此多的边界条件,如果不按照固定规则来遍历,那就是一进循环深似海,从此offer是路人。
模拟顺时针画矩阵的过程:
- 填充上行从左到右
- 填充右列从上到下
- 填充下行从右到左
- 填充左列从下到上
模拟时应该坚持固定规则,即区间一致性原则;
链表
链表基础
链表的结构体定义:
// 单链表
struct ListNode {
int val; // 节点上存储的元素
ListNode *next; // 指向下一个节点的指针
ListNode(int x) : val(x), next(NULL) {} // 节点的构造函数(没有的话编译器会给一个默认构造函数)
};
// 初始化
ListNode* head = new ListNode(5);
ListNode* head = new ListNode();
head->val = 5;
构造函数写法:
ListNode(int x) : val(x), next(NULL) {}
这个构造函数的写法是使用了C++的初始化列表(initializer list)。这种写法允许我们在构造函数中直接初始化成员变量,而不是在函数体内赋值。它的一般形式如下:
constructor_name(type1 arg1, type2 arg2, ... 参数列表) : member1(arg1), member2(arg2), ...多个初始化表达式 {
// 构造函数体,如果需要的话
}
这种初始化方式有几个优点:
- 直接初始化:成员变量在构造函数体执行前就已被初始化,这对于 const 成员或引用成员特别重要,因为它们必须在初始化列表中初始化。
- 效率更高:对于一些类型(尤其是复杂类型),使用初始化列表可能比在构造函数体内赋值更有效率,因为避免了额外的赋值操作。
- 清晰简洁:初始化列表提供了一种清晰、简洁地初始化成员变量的方式。
链表的基本操作
class MyLinkedList {
public:
// 定义链表节点结构体
struct LinkedNode {
int val;
LinkedNode* next;
LinkedNode(int val):val(val), next(nullptr){}
};
// 初始化链表
MyLinkedList() {
_dummyHead = new LinkedNode(0); // 这里定义的头结点 是一个虚拟头结点,而不是真正的链表头结点
_size = 0;
}
// 获取到第index个节点数值,如果index是非法数值直接返回-1, 注意index是从0开始的,第0个节点就是头结点
int get(int index) {
if (index > (_size - 1) || index < 0) {
return -1;
}
LinkedNode* cur = _dummyHead->next;
while(index--){ // 如果--index 就会陷入死循环
cur = cur->next;
}
return cur->val;
}
// 在链表最前面插入一个节点,插入完成后,新插入的节点为链表的新的头结点
void addAtHead(int val) {
LinkedNode* newNode = new LinkedNode(val);
newNode->next = _dummyHead->next;
_dummyHead->next = newNode;
_size++;
}
// 在链表最后面添加一个节点
void addAtTail(int val) {
LinkedNode* newNode = new LinkedNode(val);
LinkedNode* cur = _dummyHead;
while(cur->next != nullptr){
cur = cur->next;
}
cur->next = newNode;
_size++;
}
// 在第index个节点之前插入一个新节点,例如index为0,那么新插入的节点为链表的新头节点。
// 如果index 等于链表的长度,则说明是新插入的节点为链表的尾结点
// 如果index大于链表的长度,则返回空
// 如果index小于0,则在头部插入节点
void addAtIndex(int index, int val) {
if(index > _size) return;
if(index < 0) index = 0;
LinkedNode* newNode = new LinkedNode(val);
LinkedNode* cur = _dummyHead;
while(index--) {
cur = cur->next;
}
newNode->next = cur->next;
cur->next = newNode;
_size++;
}
// 删除第index个节点,如果index 大于等于链表的长度,直接return,注意index是从0开始的
void deleteAtIndex(int index) {
if (index >= _size || index < 0) {
return;
}
LinkedNode* cur = _dummyHead;
while(index--) {
cur = cur ->next;
}
LinkedNode* tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
//delete命令指示释放了tmp指针原本所指的那部分内存,
//被delete后的指针tmp的值(地址)并非就是NULL,而是随机值。也就是被delete后,
//如果不再加上一句tmp=nullptr,tmp会成为乱指的野指针
//如果之后的程序不小心使用了tmp,会指向难以预想的内存空间
tmp=nullptr;
_size--;
}
// 打印链表
void printLinkedList() {
LinkedNode* cur = _dummyHead;
while (cur->next != nullptr) {
cout << cur->next->val << " ";
cur = cur->next;
}
cout << endl;
}
private:
int _size;
LinkedNode* _dummyHead;
};
链表中常用技巧
虚拟头结点法
虚拟头结点法也被称为哑结点或伪头结点法;可以统一头结点和非头结点的逻辑;
如果要删除单链表中的节点,需要分两类考虑,一类是非头结点,删除非头结点只需要将结点的上一个结点指向该结点的下一个结点;另一类是删除头结点,只需要返回头结点的下一个结点;
所以为了删除单链表中的任意节点,我们需要写两套删除逻辑,并且加上判断语句。
如果头结点不那么特殊,我们就不需要写两套逻辑,所以可以加上虚拟头结点来使得头结点的处理逻辑和非头结点的处理逻辑一致;
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummyHead = new ListNode(0); // 设置一个虚拟头结点
dummyHead->next = head; // 将虚拟头结点指向head,这样方便后面做删除操作
ListNode* cur = dummyHead;
while (cur->next != NULL) {
if(cur->next->val == val) {
ListNode* tmp = cur->next;
cur->next = cur->next->next;
delete tmp;
} else {
cur = cur->next;
}
}
head = dummyHead->next;
delete dummyHead;
return head;
}
};
虚拟节点的初始化:
ListNode* dummyHead = new ListNode(0);
dummyHead->next = originalHead;
在初始化虚拟节点之后就可以统一处理所有结点;
返回结果:(注意返回虚拟头结点的next,并且释放内存空间)
ListNode* realHead = dummyHead->next;
delete dummyHead; // 删除虚拟头结点
dummyHead = nullptr; // 避免野指针
return realHead; // 返回真实头结点
注意删除节点之后的内存空间管理;(用delete
来释放节点的内存)
如果有必要,可以将已经被删除的节点设置为nullptr
;
delete temp;
temp = nullptr;
双指针法
和数组的双指针法类似,设置快慢双指针或者头尾双指针辅助处理;
双指针法在链表操作中有着广泛的应用,以下是一些使用双指针法处理链表的典型例子:
- 链表反转:使用两个指针
prev
和curr
遍历链表,prev
指向当前节点curr
的前一个节点。在遍历过程中,逐个改变节点的next
指向,直到将整个链表反向。 - 查找链表中点:使用快慢双指针,快指针每次移动两步,慢指针每次移动一步。当快指针到达链表末尾时,慢指针刚好位于链表的中间位置。
- 检测环形链表:类似于查找中点,快慢双指针移动速度不同。如果链表中存在环,快指针最终会追上慢指针;如果不存在环,快指针会先到达链表的结尾。
- 删除链表倒数第 N 个节点:首先使用快指针移动 N 步,然后同时移动快慢指针,直至快指针到达链表末尾。此时慢指针指向的即为要删除的节点的前一个节点,进行删除操作即可。
- 链表的k-group反转:对于每k个节点为一组的链表反转问题,可以使用两个指针
start
和end
定位到每个子链表的开始和结束位置,通过局部反转和连接的方式实现k-group反转。
反转链表:用双指针法实现链表方向的反转;
也可以使用递归法,但是思想是一致的;
环形链表II:判断链表是否有环并且获取环的起点;
判断链表是否有环很简单,使用快慢指针,如果指针最终相遇,则说明有环;如果到终点没有相遇,则说明无环;
关键难点在于判断环的起始位置;
方法1:快慢指针法;
- 思路:使用快慢指针,快指针每次移动两步,慢指针每次移动一步。当两指针相遇时,此时在环中,让一个指针从链表头开始和另一个指针继续前进,它们再次相遇的位置即为环的起始位置。
- 优点:算法简单,并且空间复杂度低。
- 缺点:无法直接确定环的起始位置,需要额外步骤确定起始位置。
关键理解“让一个指针从链表头开始和另一个指针继续前进,它们再次相遇的位置即为环的起始位置”这句话,需要数学推导;(Floyd判圈算法(龟兔赛跑)+ 数学推导)
假设链表头到环入口节点的距离是 x
,环入口到快慢指针相遇点的距离是 y
,然后从相遇点再到环入口的距离是 z
。
故环的长度是y + z
,链表总长度为x + y + z
;
当快慢指针相遇时,慢指针肯定没有走完环,假设它走了 d
的距离。此时快指针已经走了 2d
的距离(快指针速度是慢指针的2倍)。由于快指针可能已经在环内走了多圈,设它比慢指针多走了 n
圈,则有:2d = d + n * (y + z)
把 d
替换为慢指针走的实际路径 x + y
:2(x + y) = x + y + n * (y + z)
这可以简化为:x + y = n * (y + z)
这表明慢指针走的距离是环周长的整数倍。
我们的目标是求环的起点,即x
的大小;
现在设置指针ptr1
从头结点开始走,速度和慢指针一致;则ptr1
走了x
距离(ptr1
到达环的起点)之后,慢指针也走了x
距离,慢指针在环中走,之前走了y
距离,所以慢指针在环中走的总距离为y + x
,停下的位置为x + (x + y) % (y + z)
;
带入之前得到的x + y = n * (y + z)
,得到x + (x + y) % (y + z) => x + (n * (y + z) ) % (y + z) => x
,所以慢指针也停在了x
位置,慢指针和ptr1
相遇在x
位置;
而我们的目标就是求出x
,所以找到ptr1
和慢指针相遇的地方就找到了环的起点;
方法2:哈希表法
- 思路:遍历链表,将每个节点都存储在哈希表中,当遇到重复节点时,即为环的起始位置。
- 优点:能够直接找到环的起始位置。
- 缺点:需要额外的哈希表存储节点,空间复杂度较高。
#include <unordered_set>
ListNode *detectCycle(ListNode *head) {
std::unordered_set<ListNode*> visited; // 创建一个哈希表来存储遍历过的节点
ListNode *current = head; // 从头开始遍历链表
while (current != nullptr) {
if (visited.find(current) != visited.end()) { // 没有找到最后就返回,说明命中哈希
// 如果当前节点已经在访问过的节点集中,那么它就是环的起始位置
return current;
}
visited.insert(current); // 将当前节点添加到访问集
current = current->next; // 前往下一个节点
}
return nullptr; // 如果没有环,返回nullptr
}
归并法(多指针)
- 利用快慢指针分割链表
- 初始化一快一慢两个指针,快指针一次移动两步,慢指针一次移动一步
- 当快指针到达链表终点(最后一个节点的next为null)时,慢指针恰好处于链表中点位置
- 通过切断慢指针的next指针,将原始链表分割为两个部分,左侧子链表和右侧子链表
- 递归分治
- 对分割后的左侧子链表和右侧子链表分别递归执行归并排序
- 当子链表为空或只有一个节点时,视为有序,作为递归基准情况返回
- 合并有序子链表
- 通过另一个merge函数,合并已经排好序的两个子链表
- 设置两个指针分别指向两个子链表头部
- 比较两指针指向节点的值大小,较小值的节点链接到新链表后面,指针移动到下一个
- 合并完成后,剩余的子链表直接链接到新链表后面即可
- 终止递归并返回
- 当两个子链表都被合并成一个有序链表后,递归函数自底向上返回
- 最终返回的是排序完成的有序链表头节点
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
// 根据快慢指针切分链表;
ListNode* split(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head->next;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
ListNode* temp = slow->next; // 断开原链表
slow->next = nullptr; // 新链表的头节点
return temp; // 原链表的后半部分
}
// 合并两个链表;
ListNode* merge(ListNode* l1, ListNode* l2) {
ListNode dummy(0); // 哑节点
ListNode* p = &dummy;
while (l1 && l2) {
if (l1->val < l2->val) {
p->next = l1;
l1 = l1->next;
} else {
p->next = l2;
l2 = l2->next;
}
p = p->next;
}
p->next = l1 ? l1 : l2; // 剩余部分直接链接到结果链表后面
return dummy.next;
}
// 归并排序
ListNode* sortList(ListNode* head) {
if (!head || !head->next) return head; // 空链表或只有一个节点的链表
ListNode* mid = split(head); // 拆分
ListNode* left = sortList(head); // 左半部分进行排序
ListNode* right = sortList(mid); // 右半部分进行排序
return merge(left, right); // 合并左右两个有序链表
}
交换节点的值
在链表中,有时候我们需要交换两个节点的值而不是实际交换节点本身。这种方法会省去节点指针的调整,只需要进行节点值的交换操作,使得看起来好像交换了节点一样。
例如,如果我们需要交换链表中相邻两个节点的值,我们可以直接操作节点的数值而不是改变节点的指针。这种方式可以减少指针的操作,提高代码的简洁性和效率。
链表实现栈或者队列
// 下面是使用 C++ 实现链表作为栈(Stack)和队列(Queue)的基本操作示例代码:
#include <iostream>
// 定义链表节点类
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
// 栈的实现
class Stack {
private:
ListNode* top;
public:
Stack() : top(nullptr) {}
void push(int val) {
ListNode* newNode = new ListNode(val);
newNode->next = top;
top = newNode;
}
int pop() {
if (top == nullptr) {
std::cout << "Stack is empty." << std::endl;
return -1; // 返回一个错误值
}
int poppedVal = top->val;
ListNode* temp = top;
top = top->next;
delete temp;
return poppedVal;
}
};
// 队列的实现
class Queue {
private:
ListNode* front;
ListNode* rear;
public:
Queue() : front(nullptr), rear(nullptr) {}
void enqueue(int val) {
ListNode* newNode = new ListNode(val);
if (rear == nullptr) {
front = rear = newNode;
} else {
rear->next = newNode;
rear = newNode;
}
}
int dequeue() {
if (front == nullptr) {
std::cout << "Queue is empty." << std::endl;
return -1; // 返回一个错误值
}
int dequeuedVal = front->val;
ListNode* temp = front;
front = front->next;
if (front == nullptr) {
rear = nullptr; // 队列为空时更新 rear
}
delete temp;
return dequeuedVal;
}
};
int main() {
Stack stack;
stack.push(1);
stack.push(2);
stack.push(3);
std::cout << "Stack popped values: " << stack.pop() << " "; // 3
std::cout << stack.pop() << " "; // 2
std::cout << stack.pop() << std::endl; // 1
Queue queue;
queue.enqueue(1);
queue.enqueue(2);
queue.enqueue(3);
std::cout << "Queue dequeue values: " << queue.dequeue() << " "; // 1
std::cout << queue.dequeue() << " "; // 2
std::cout << queue.dequeue() << std::endl; // 3
return 0;
}