文章目录
数据结构
定义
数据结构(Data Structure)是计算机科学中的一个重要概念,指的是组织和存储数据的方式。在计算机科学中,数据通常是以某种形式存储在内存或磁盘中,并被程序访问和操作。数据结构可以用于解决各种计算问题,例如搜索、排序、图形算法等。
常见的数据结构包括:数组、链表、栈、队列、树、图等。每种数据结构都有其特定的用途和优缺点。例如,数组具有随机访问和常数时间的访问和更新,但在插入和删除时效率较低;链表具有动态添加和删除元素的能力,但访问元素的时间复杂度为 O(n)。
数据结构的设计和选择通常需要考虑以下几个方面:
- 数据的特性:数据的类型、规模、密度等因素都会影响数据结构的设计和选择。
- 访问模式:程序对数据的访问方式也会影响数据结构的选择,例如顺序访问、随机访问、插入删除等操作。
- 算法的复杂度:不同的数据结构对于不同的算法可能会有不同的时间和空间复杂度,需要根据具体情况选择适合的数据结构。
总之,数据结构是计算机科学中非常重要的一部分,合适的数据结构可以使算法的效率更高,更快地解决问题
数据结构可以按照不同的特点进行分类,常见的分类方式包括以下几种:
- 线性结构:数据元素之间存在一对一的线性关系,最常见的有数组、链表、栈和队列等。
- 非线性结构:数据元素之间存在一对多或多对多的关系,最常见的有树和图等。
- 静态结构:在程序运行时,数据元素个数是固定的,不可修改,例如数组。
- 动态结构:在程序运行时,数据元素的个数是可以动态变化的,例如链表。
- 存储结构:数据在内存中的存储方式,最常见的有顺序存储和链式存储两种。
- 算法适用性:数据结构适用的算法不同,例如用于搜索和排序的数据结构不同。
- 抽象程度:数据结构可以按照其所表示的实体的抽象程度进行分类,例如线性表、树和图等。
常见数据结构
- 数组(Array) 数组是一种线性数据结构,由一组相同类型的元素组成,每个元素都可以通过一个索引访问。数组的特点是访问元素速度快,但插入和删除元素的效率较低。
- 链表(Linked List) 链表也是一种线性数据结构,但每个元素都指向下一个元素。它的插入和删除元素的效率高,但访问元素的效率较低。
- 栈(Stack) 栈是一种后进先出(LIFO)的数据结构,只能在一端插入和删除元素。可以使用栈来实现函数调用的跟踪、括号匹配等功能。
- 队列(Queue) 队列是一种先进先出(FIFO)的数据结构,可以在队列的一端插入元素,在另一端删除元素。可以使用队列来实现广度优先搜索等功能。
- 树(Tree) 树是一种非线性数据结构,由节点和边组成。每个节点可以有多个子节点,但只有一个父节点。树的常用应用包括二叉树、平衡树、堆、哈夫曼树等。
- 图(Graph) 图也是一种非线性数据结构,由节点和边组成。与树不同的是,图中的节点可以有多个父节点和多个子节点。图的常用应用包括最短路径、最小生成树等。
- 哈希表(Hash Table) 哈希表是一种基于哈希函数实现的数据结构,它可以高效地进行插入、删除和查找操作。哈希表的常用应用包括字典、缓存等。
复杂度分析
当我们选择一种数据结构时,需要考虑其时间复杂度和空间复杂度。以下是常见数据结构的时间复杂度和空间复杂度分析:
- 数组(Array) 时间复杂度:
- 访问元素:O(1)
- 插入元素:最坏O(n),平均O(n/2)
- 删除元素:最坏O(n),平均O(n/2)
空间复杂度:O(n)
- 链表(Linked List) 时间复杂度:
- 访问元素:O(n)
- 插入元素:O(1)
- 删除元素:O(1)
空间复杂度:O(n)
- 栈(Stack) 时间复杂度:
- 入栈:O(1)
- 出栈:O(1)
空间复杂度:O(n)
- 队列(Queue) 时间复杂度:
- 入队:O(1)
- 出队:O(1)
空间复杂度:O(n)
- 树(Tree) 时间复杂度:
- 遍历树:O(n)
- 查找节点:O(log n)
- 插入节点:O(log n)
- 删除节点:O(log n)
空间复杂度:O(n)
- 图(Graph) 时间复杂度:
- 遍历图:O(V+E)
- 查找节点:O(1) - O(V)
- 插入节点:O(1) - O(V)
- 删除节点:O(1) - O(V)
空间复杂度:O(V+E)
- 哈希表(Hash Table) 时间复杂度:
- 插入元素:平均O(1),最坏O(n)
- 查找元素:平均O(1),最坏O(n)
- 删除元素:平均O(1),最坏O(n)
空间复杂度:O(n)
以上时间复杂度和空间复杂度只是给出了一个大致的参考。在实际应用中,复杂度可能会因为具体实现和算法优化而有所不同。因此,在实际应用中,我们需要结合具体情况进行分析和优化。
算法与排序
public static void BubbleSort(int[] arr)
{
int n = arr.Length;
for (int i = 0; i < n - 1; i++)
{
for (int j = 0; j < n - i - 1; j++)
{
// 如果前面的数比后面的数大,则交换位置
if (arr[j] > arr[j + 1])
{
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
public static void SelectionSort(int[] arr)
{
int n = arr.Length;
for (int i = 0; i < n - 1; i++)
{
int minIndex = i;
for (int j = i + 1; j < n; j++)
{
// 找到未排序区间中最小元素的下标
if (arr[j] < arr[minIndex])
{
minIndex = j;
}
}
// 将最小元素放到已排序区间的末尾
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
public static void InsertionSort(int[] arr)
{
int n = arr.Length;
for (int i = 1; i < n; i++)
{
int key = arr[i];
int j = i - 1;
// 将比 key 大的元素向后移动一位
while (j >= 0 && arr[j] > key)
{
arr[j + 1] = arr[j];
j--;
}
// 将 key 插入到合适的位置
arr[j + 1] = key;
}
}
public static void MergeSort(int[] arr, int left, int right)
{
if (left < right)
{
int mid = (left + right) / 2;
// 分治递归,将数组一分为二
MergeSort(arr, left, mid);
MergeSort(arr, mid + 1, right);
// 合并两个有序数组
Merge(arr, left, mid, right);
}
}
public static void Merge(int[] arr, int left, int mid, int right)
{
int[] temp = new int[arr.Length];
int i = left, j = mid + 1, k = left;
while (i <= mid && j <= right)
{
if (arr[i] <= arr[j])
{
temp[k++] = arr[i++];
}
else
{
temp[k++] = arr[j++];
}
}
while (i <= mid)
{
temp[k++] = arr[i++];
}
while (j <= right)
{
temp[k++] = arr[j++];
}
// 将临时数组的元素复制回原数组
for (int l = left; l <= right; l++)
{
arr[l] = temp[l];
}
}
public static void HeapSort(int[] arr)
{
int n = arr.Length;
// 构建大根堆
for (int i = n / 2 - 1; i >= 0; i--)
{
Heapify(arr, n, i);
}
// 依次将堆顶元素放到末尾,然后重新调整堆
for (int i = n - 1; i >= 0; i--)
{
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
Heapify(arr, i, 0);
}
}
public static void Heapify(int[] arr, int n, int i)
{
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
// 找到三个节点中的最大值
if (left < n && arr[left] > arr[largest])
{
largest = left;
}
if (right < n && arr[right] > arr[largest])
{
largest = right;
}
// 如果最大值不是当前节点,则交换位置,并继续调整堆
if (largest != i)
{
int temp = arr[i];
arr[i] = arr[largest];
arr[largest] = temp;
Heapify(arr, n, largest);
}
}
public static void CountingSort(int[] arr)
{
int n = arr.Length;
int max = arr[0], min = arr[0];
// 找到数组中的最大值和最小值
for (int i = 1; i < n; i++)
{
if (arr[i] > max)
{
max = arr[i];
}
if (arr[i] < min)
{
min = arr[i];
}
}
// 统计每个元素出现的次数
int[] count = new int[max - min + 1];
for (int i = 0; i < n; i++)
{
count[arr[i] - min]++;
}
// 对统计数组进行变形,每个元素存储前面所有元素的累加和
for (int i = 1; i < count.Length; i++)
{
count[i] += count[i - 1];
}
// 遍历原始数组,从统计数组中找到正确的位置,并输出到临时数组中
int[] temp = new int[n];
for (int i = n - 1; i >= 0; i--)
{
temp[--count[arr[i] - min]] = arr[i];
}
// 将临时数组的元素复制回原数组
for (int i = 0; i < n; i++)
{
arr[i] = temp[i];
}
}
public static void RadixSort(int[] arr)
{
int n = arr.Length;
if (n <= 1)
{
return;
}
// 找到最大值,确定要排序几次
int max = arr[0];
for (int i = 1; i < n; i++)
{
if (arr[i] > max)
{
max = arr[i];
}
}
int exp = 1;
// 对每一位进行排序
while (max / exp > 0)
{
// 计数排序的过程
int[] count = new int[10];
int[] output = new int[n];
for (int i = 0; i < n; i++)
{
count[(arr[i] / exp) % 10]++;
}
for (int i = 1; i < 10; i++)
{
count[i] += count[i - 1];
}
for (int i = n - 1; i >= 0; i--)
{
output[count[(arr[i] / exp) % 10] - 1] = arr[i];
count[(arr[i] / exp) % 10]--;
}
for (int i = 0; i < n; i++)
{
arr[i] = output[i];
}
exp *= 10;
}
}
public class TrieNode
{
public TrieNode[] children = new TrieNode[26];
public bool isWord = false;
}
public class Trie
{
private TrieNode root;
public Trie()
{
root = new TrieNode();
}
public void Insert(string word)
{
TrieNode node = root;
for (int i = 0; i < word.Length; i++)
{
char c = word[i];
if (node.children[c - 'a'] == null)
{
node.children[c - 'a'] = new TrieNode();
}
node = node.children[c - 'a'];
}
node.isWord = true;
}
public bool Search(string word)
{
TrieNode node = root;
for (int i = 0; i < word.Length; i++)
{
char c = word[i];
if (node.children[c - 'a'] == null)
{
return false;
}
node = node.children[c - 'a'];
}
return node.isWord;
}
public bool StartsWith(string prefix)
{
TrieNode node = root;
for (int i = 0; i < prefix.Length; i++)
{
char c = prefix[i];
if (node.children[c - 'a'] == null)
{
return false;
}
node = node.children[c - 'a'];
}
return true;
}
}
public static void BucketSort(int[] arr)
{
int n = arr.Length;
// 1. 创建 n 个桶
List<int>[] buckets = new List<int>[n];
for (int i = 0; i < n; i++)
{
buckets[i] = new List<int>();
}
// 2. 将数组元素分配到对应的桶中
for (int i = 0; i < n; i++)
{
int bucketIndex = (int)((double)n * arr[i] / (n + 1));
buckets[bucketIndex].Add(arr[i]);
}
// 3. 对每个桶内的元素进行排序
for (int i = 0; i < n; i++)
{
buckets[i].Sort();
}
// 4. 将所有桶内的元素依次放入原数组中
int index = 0;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < buckets[i].Count; j++)
{
arr[index++] = buckets[i][j];
}
}
}
public static void QuickSort(int[] arr, int left, int right)
{
if (left < right)
{
// 1. 选取基准元素
int pivotIndex = left;
int pivotValue = arr[pivotIndex];
// 2. 分割数组
int i = left, j = right;
while (i < j)
{
while (i < j && arr[j] >= pivotValue) j--;
if (i < j) arr[i++] = arr[j];
while (i < j && arr[i] < pivotValue) i++;
if (i < j) arr[j--] = arr[i];
}
arr[i] = pivotValue;
// 3. 递归排序左右子数组
QuickSort(arr, left, i - 1);
QuickSort(arr, i + 1, right);
}
}
以下是这十大常见算法的常见应用场景:
- 冒泡排序:适用于小数据量的排序,简单易懂,但时间复杂度较高,不适用于大规模数据排序。
- 选择排序:同样适用于小数据量的排序,时间复杂度较冒泡排序略低,但还是不适合大规模数据排序。
- 插入排序:适用于对小规模数据的排序,具有稳定性和高效性,可以在数据已经部分有序的情况下提高效率。
- 快速排序:适用于大规模数据排序,快速、高效、灵活,是常用的排序算法之一。
- 归并排序:同样适用于大规模数据排序,稳定、高效,比快速排序多了一步合并操作,但相对复杂一些。
- 堆排序:适用于大规模数据排序,相对于归并排序、快速排序等算法,堆排序的常数项小,但实现稍微复杂一些。
- 计数排序:适用于数据范围不大的整数排序,时间复杂度为O(n+k),其中k为数据范围,但是需要额外的存储空间。
- 桶排序:适用于数据范围分布比较均匀的情况下,时间复杂度为O(n),但是需要额外的存储空间。
- 基数排序:适用于数据范围较小的排序,时间复杂度为O(dn),其中d为数字位数,但需要较多的额外存储空间。
- Trie树:适用于字符串的存储和查找,常用于搜索提示和字符串匹配等场景。
以下是十大常见算法的时间复杂度和空间复杂度:
算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定排序 |
---|---|---|---|---|
冒泡排序 | O(n^2) | O(n^2) | O(1) | 是 |
选择排序 | O(n^2) | O(n^2) | O(1) | 否 |
插入排序 | O(n^2) | O(n^2) | O(1) | 是 |
快速排序 | O(nlogn) | O(n^2) | O(logn) | 否 |
归并排序 | O(nlogn) | O(nlogn) | O(n) | 是 |
堆排序 | O(nlogn) | O(nlogn) | O(1) | 否 |
计数排序 | O(n+k) | O(n+k) | O(k) | 是 |
桶排序 | O(n) | O(n^2) | O(n+k) | 是 |
基数排序 | O(d(n+k)) | O(d(n+k)) | O(n+k) | 是 |
Trie树 | O(mn) | O(mn) | O(mn) | N/A |
其中,n为数据规模,k为数据范围,m为字符串长度,d为数字位数。需要注意的是,算法的时间复杂度和空间复杂度是一个理论上的概念,具体的时间和空间消耗可能会受到多种因素的影响,如具体实现、硬件环境等。
数组与链表
数组和链表都是线性数据结构,可以用于存储一系列相同类型的数据。它们的主要区别在于存储方式和性能方面。
数组(Array)是一种固定大小的线性数据结构,它由连续的内存地址组成,并且所有元素具有相同的数据类型。数组中每个元素都可以通过索引来访问,这个索引通常是整数类型。数组可以用于实现各种算法和数据结构,例如矩阵、堆栈、队列和哈希表等。
数组的优点是访问元素的时间复杂度是 O(1),因为数组中的元素都是连续存储的,可以通过简单的数学运算来计算出元素的内存地址。但是,数组的缺点是插入和删除元素的时间复杂度是 O(n),因为在数组中插入或删除元素需要将后续元素向前或向后移动,这是一个很费时间的过程。
链表(Linked List)是一种动态数据结构,它的每个元素由一个值和一个指向下一个元素的指针组成。由于链表中的元素可以存储在任何地方,因此它们不必是连续的。链表有单向链表、双向链表和循环链表等多种形式。
链表的优点是插入和删除元素的时间复杂度是 O(1),因为只需要调整相邻元素的指针即可。但是,访问元素的时间复杂度是 O(n),因为在链表中没有索引,需要遍历链表来找到元素。
总之,数组和链表都有自己的优点和缺点,在实际应用中需要根据具体需求来选择合适的数据结构。如果需要随机访问元素或元素个数是固定的,使用数组更合适;如果需要频繁插入或删除元素或元素个数可能变化,使用链表更合适。
数组常用操作
创建数组
int[] array = new int[10];
访问元素
int[] array = new int[10];
array[0] = 1;
int firstElement = array[0];
遍历数组
int[] array = new int[10];
for (int i = 0; i < array.Length; i++)
{
Console.WriteLine(array[i]);
}
插入元素
int[] array = new int[10];
array[0] = 1;
for (int i = array.Length - 1; i > 0; i--)
{
array[i] = array[i - 1];
}
array[0] = 0;
删除元素
int[] array = new int[10];
for (int i = 0; i < array.Length; i++)
{
if (array[i] == 5)
{
for (int j = i; j < array.Length - 1; j++)
{
array[j] = array[j + 1];
}
array[array.Length - 1] = 0;
break;
}
}
链表常用操作
创建链表
public class ListNode
{
public int val;
public ListNode next;
public ListNode(int val = 0, ListNode next = null)
{
this.val = val;
this.next = next;
}
}
ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;
访问节点
ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;
int firstElement = head.val;
int secondElement = head.next.val;
遍历链表
ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;
ListNode current = head;
while (current != null)
{
Console.WriteLine(current.val);
current = current.next;
}
插入节点
ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;
ListNode newNode = new ListNode(4);
newNode.next = node1.next;
node1.next = newNode;
删除节点
ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;
ListNode current = head;
while (current.next != null)
{
if (current.next.val == 2)
{
current.next = current.next.next;
break;
}
current = current.next;
}
练习题
数组
- 将一个数组中的元素按照反转的顺序重新排列,例如给定数组[1, 2, 3, 4, 5],则反转后的数组为[5, 4, 3, 2, 1]。
- 给定两个有序整数数组nums1和nums2,将nums2合并到nums1中,使得nums1成为一个有序数组,假定nums1有足够的空间来容纳nums2中的元素。
- 给定一个数组,其中的元素表示每个台阶的高度,假定你在地面上,每次可以跳跃的高度为1或2个台阶,请问跳到最后一个台阶所需要的最少步数是多少?
链表
- 给定一个单向链表,反转该链表。
- 给定一个单向链表和一个整数k,将该链表分成若干个长度为k的链表,并将每个链表翻转。
- 给定两个单向链表,表示两个非负整数,将它们相加并以链表的形式返回结果。
数组
- 反转数组:
public void ReverseArray(int[] nums)
{
int left = 0;
int right = nums.Length - 1;
while (left < right)
{
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
left++;
right--;
}
}
- 合并有序数组:
public void MergeSortedArray(int[] nums1, int m, int[] nums2, int n)
{
int i = m - 1;
int j = n - 1;
int k = m + n - 1;
while (i >= 0 && j >= 0)
{
if (nums1[i] > nums2[j])
{
nums1[k--] = nums1[i--];
}
else
{
nums1[k--] = nums2[j--];
}
}
while (j >= 0)
{
nums1[k--] = nums2[j--];
}
}
- 跳跃游戏:
public int JumpGame(int[] nums)
{
int steps = 0;
int maxPos = 0;
int end = 0;
for (int i = 0; i < nums.Length - 1; i++)
{
maxPos = Math.Max(maxPos, i + nums[i]);
if (i == end)
{
end = maxPos;
steps++;
}
}
return steps;
}
1.给出两个非空的链表,表示两个非负整数。其中,它们各自的位数是按照逆序的方式存储的,并且它们的每个节点只能存储一位数字。
将这两个数相加起来,则会返回一个新的链表来表示它们的和。
你可以假设除了数字 0 之外,这两个数都不会以 0 开头。
示例:
输入:(2 -> 4 -> 3) + (5 -> 6 -> 4) 输出:7 -> 0 -> 8 原因:342 + 465 = 807
思路:
从链表的头结点开始遍历两个链表,将对应位置上的节点值相加,同时记录进位。
需要注意的是,当一个链表比另一个链表短时,应该在短链表的高位补零。
public class ListNode {
public int val; // 当前节点的值
public ListNode next; // 下一个节点
public ListNode(int x) { val = x; }
}
public class Solution {
public ListNode AddTwoNumbers(ListNode l1, ListNode l2) {
// 创建虚拟头节点,并用curr指向链表的最后一个节点
ListNode dummyHead = new ListNode(0);
ListNode p = l1, q = l2, curr = dummyHead;
int carry = 0; // 进位值
while (p != null || q != null) {
int x = (p != null) ? p.val : 0; // 取出l1当前节点的值,如果为空则取0
int y = (q != null) ? q.val : 0; // 取出l2当前节点的值,如果为空则取0
int sum = carry + x + y; // 当前节点的值
carry = sum / 10; // 进位值
curr.next = new ListNode(sum % 10); // 创建新节点并插入到新链表尾部
curr = curr.next; // 更新指针
if (p != null) p = p.next; // 如果l1不为空,则指针后移
if (q != null) q = q.next; // 如果l2不为空,则指针后移
}
if (carry > 0) { // 如果进位值不为0,则需要在新链表的尾部添加一个节点
curr.next = new ListNode(carry);
}
return dummyHead.next; // 返回虚拟头节点的下一个节点
}
}
时间复杂度:O(max(m, n)),其中 m 和 n 分别为两个链表的长度。
空间复杂度:O(max(m, n)),需要创建一个新的链表来存储结果。
在线练习网站
- LeetCode(https://leetcode.com/problemset/all/)
- HackerRank(https://www.hackerrank.com/domains/data-structures)
- Codility(https://app.codility.com/programmers/lessons/)
- GeeksforGeeks(https://www.geeksforgeeks.org/practice-questions-for-linked-list-and-recursion/)
这些网站都提供了大量的数据结构和算法练习题,可以帮助你巩固和提升自己的编程能力。