12、数据结构的汇编实现

数据结构的汇编实现

在应用程序编程中,数据结构的应用十分广泛。它们常被用于算法目的,实现诸如栈、队列和堆等结构,也可用于基于键的数据存储,也就是所谓的“字典”。本文将探讨如何在汇编中实现链表、哈希表、双向链表和二叉树。

1. 链表

链表是由一系列节点组成的结构。以下是一个链表的示例:

I ·I 4 1  
I 21 191 +

这个链表包含4个节点,每个节点有一个数据值和一个指向下一个节点的指针。链表的最后一个节点的指针为NULL(值为0),用实心圆表示。链表本身由一个指针表示。我们可以将链表的第一个指针放在一个框中并命名,这样能更完整地展示链表。

这个链表中的数据值没有明显的顺序,可能是无序的,也可能按插入时间排序。由于在链表头部插入新节点非常容易,所以链表可能按插入时间降序排列。

链表通过存储在标记为 list 的内存位置的指针来引用。维护和使用链表的代码中,链表上的节点没有特定的标签,只能通过链表中的指针来访问这些节点。

1.1 链表节点结构

链表节点包含两个字段:一个数据值和一个指向下一个节点的指针。使用YASM的结构定义如下:

struc node
    n_value resq 1
    n_next resq 1
    align 8
ends true

虽然在这个结构中有两个四字时不需要对齐指令,但它可以避免后续可能的混淆。

1.2 创建空链表

设计容器结构时,首先要决定如何表示空容器。在这个链表设计中,我们选择使用NULL指针来表示空链表。尽管这种方法很简单,但编写一个创建空链表的函数可能会更方便。

newlist:
    xor eax, eax
    ret
1.3 向链表中插入数字

将空链表实现为NULL指针在插入操作时会带来一个小问题。每次插入都在链表头部进行,这意味着每次插入后链表的起始指针都会存储一个新的指针。有两种方法可以处理这个问题:一种是将指针的地址传递给插入函数;另一种是让插入函数返回新的指针,由插入代码在返回时分配新指针。为了避免指针地址的问题,下面是插入代码:

insert:
    .list equ 0
    .k equ 8
    push rbp
    mov rbp, rsp
    sub rsp, 16
    mov [rsp + .list], rdi
    mov [rsp + .k], rsi
    mov edi, node_size
    call malloc
    mov r8, [rsp + .list]
    mov [rax + n_next], r8
    mov r9, [rsp + .k]
    mov [rax + n_value], r9
    leave
    ret
1.4 遍历链表

遍历链表需要使用类似 mov rbx, [rbx + n_next] 的指令,从一个节点的指针移动到下一个节点的指针。我们首先检查指针是否为NULL,如果不是则进入循环。处理完一个节点后,移动指针,如果指针不为NULL则继续循环。下面的打印函数遍历链表并打印每个数据项:

print:
    segment .data
        .print_fmt: db "%ld ", 0
        .newline: db 0x0a, 0
    segment .text
        .rbx equ 0
        .more:
        .done:
        push rbp
        mov rbp, rsp
        sub rsp, 16
        mov [rsp + .rbx], rbx
        cmp rdi, 0
        je .done
        mov rbx, rdi
        lea rdi, [.print_fmt]
        mov rsi, [rbx + n_value]
        xor eax, eax
        call printf
        mov rbx, [rbx + n_next]
        cmp rbx, 0
        jne .more
        lea rdi, [.newline]
        xor eax, eax
        call printf
        mov rbx, [rsp + .rbx]
        leave
        ret

最后,我们有一个主函数,它创建一个链表,使用 scanf 读取值,将这些值插入链表,并在每次插入后打印链表:

main:
    .list equ 0
    .k equ 8
    segment .data
        .scanf_fmt: db "%ld", 0
    segment .text
    push rbp
    mov rbp, rsp
    sub rsp, 16
    call newlist
    mov [rsp + .list], rax
.more:
    lea rdi, [.scanf_fmt]
    lea rsi, [rsp + .k]
    xor eax, eax
    call scanf
    cmp rax, 1
    jne .done
    mov rdi, [rsp + .list]
    mov rsi, [rsp + .k]
    call insert
    mov [rsp + .list], rax
    mov rdi, rax
    call print
    jmp .more
.done:
    leave
    ret

以下是使用该程序输入1到5的示例会话:

1
1
2
2 1
3
3 2 1
4
4 3 2 1
5
5 4 3 2 1

可以看到,最后打印的数字位于链表的开头。通过添加一个获取并移除(弹出)链表第一个元素的函数,我们可以将这个链表变成一个栈。

2. 双向链表

双向链表的每个节点有两个指针:一个指向下一个节点,另一个指向前一个节点。如果将双向链表设计为循环链表,并在链表开头保留一个未使用的节点,那么管理双向链表会变得非常简单。以下是一个包含4个数据节点的双向链表示例:

list
X
4
12
16
19
c
-

变量 list 指向链表的第一个节点,即“头节点”。头节点有一个值,但我们不会使用这个值。每个节点的上指针指向下一个节点,下指针指向前一个节点。头节点的前指针指向链表的最后一个节点。这种设计使得双向链表可以实现栈(后进先出)、队列(先进先出)或双端队列。其主要优点是链表永远不会真正为空,虽然在逻辑上可能为空,但头节点始终存在。而且,一旦链表创建完成,头节点的指针就不会改变。

2.1 双向链表节点结构

双向链表节点包含三个字段:一个数据值、一个指向下一个节点的指针和一个指向前一个节点的指针。使用YASM的结构定义如下:

struc node
    n_value resq 1
    n_next resq 1
    n_prev resq 1
    align 8
ends true
2.2 创建新链表

创建新双向链表的代码会分配一个新节点,并将其下一个和前一个指针都指向自身。调用函数会收到一个在程序执行期间不会改变的指针。以下是创建代码:

newlist:
    push rbp
    mov rbp, rsp
    mov edi, node_size
    call malloc
    mov [rax + n_next], rax
    mov [rax + n_prev], rax
    leave
    ret

返回时,空链表的样子如下:

list
2.3 在链表头部插入节点

要在链表头部插入一个新节点,需要将头节点的下一个指针放入新节点的下一个槽中,并将头节点下一个节点的前一个指针放入新节点的前一个槽中。完成这些操作后,让头节点指向新节点,头节点原来的下一个节点指向新节点。插入代码如下:

insert:
    .list equ 0
    .k equ 8
    push rbp
    mov rbp, rsp
    sub rsp, 16
    mov [rsp + .list], rdi
    mov [rsp + .k], rsi
    mov edi, node_size
    call malloc
    mov r8, [rsp + .list]
    mov r9, [r8 + n_next]
    mov [rax + n_next], r9
    mov [rax + n_prev], r8
    mov [r8 + n_next], rax
    mov [r9 + n_prev], rax
    mov r9, [rsp + .k]
    mov [rax + n_value], r9
    leave
    ret
2.4 遍历双向链表

双向链表的遍历与单链表有些相似。我们需要跳过头节点,并将当前指针与头节点的指针进行比较,以检测链表的结束。以下是打印链表的代码:

print:
    segment .data
        .print_fmt: db "%ld ", 0
        .newline: db 0x0a, 0
    segment .text
        .list equ 0
        .rbx equ 8
    push rbp
    mov rbp, rsp
    sub rsp, 16
    mov [rsp + .rbx], rbx
    mov [rsp + .list], rdi
    mov rbx, [rdi + n_next]
    cmp rbx, [rsp + .list]
    je .done
.more:
    lea rdi, [.print_fmt]
    mov rsi, [rbx + n_value]
    call printf
    mov rbx, [rbx + n_next]
    cmp rbx, [rsp + .list]
    jne .more
.done:
    lea rdi, [.newline]
    call printf
    mov rbx, [rsp + .rbx]
    leave
    ret
3. 哈希表

哈希表是实现字典的一种高效方式。其基本思想是为字典中的每个键计算一个哈希值,目的是将键均匀分布在一个数组中。理想的哈希函数能将每个键映射到哈希数组中的唯一位置,但这很难实现,因此我们需要处理键的“冲突”。

处理冲突的最简单方法是为哈希数组的每个位置使用一个链表。例如:

0
1
2
3
4
5
6
7
8

在这个哈希表中,键12、4、16和9的哈希值都为1,它们被放在哈希数组位置1的链表中;键13和8的哈希值都为3,它们被放在位置3的链表中;其余键分别映射到5和7。

哈希的一个关键问题是开发一个好的哈希函数。哈希函数应该看起来几乎是随机的,对于同一个键,每次调用时必须计算出相同的值。但哈希值本身并不重要,重要的是键在链表上的分布,我们希望有很多短链表。这意味着数组的大小至少应该和预期的键的数量一样大,这样在使用好的哈希函数时,链表通常会很短。

3.1 整数的哈希函数

一般建议哈希表的大小为质数。但如果用作键的数字没有潜在的模式,这并不是很重要,此时可以简单地使用 n mod t ,其中 n 是键, t 是数组大小。如果存在很多相同数字的倍数这样的模式,使用质数作为 t 是有意义的。以下是示例代码中的哈希函数:

hash:
    mov rax, rdi
    and rax, 0xff
    ret

在示例中,表的大小为256,使用 and 操作实现了 n mod 256

3.2 字符串的哈希函数

对于字符串,一个好的哈希函数是将字符串视为多项式的系数,并为某个质数 n 计算 p(n) 的值。在下面的代码中,我们使用质数191进行计算。计算出多项式的值后,可以使用表的大小进行取模运算(示例代码中表的大小为100000)。

int hash(unsigned char *S)
{
    unsigned long h = 0;
    int i = 0;
    while (S[i]) {
        h = h * 191 + S[i];
        i++;
    }
    return h % 100000;
}
3.3 哈希表节点结构和数组

示例哈希表的大小为256,因此程序开始时需要一个包含256个NULL指针的数组。由于这个数组比较小,可以在数据段中实现。对于更实际的程序,需要一个哈希表创建函数来分配数组并将其初始化为0。以下是数组的声明和每个数组位置的链表的结构定义:

segment .data
    table times 256 dq 0
struc node
    n_value resq 1
    n_next resq 1
    align 8
ends true
3.4 在哈希表中查找值

哈希表的基本目的是存储与键相关的数据。在示例哈希表中,我们只存储键。下面的查找函数会在哈希表中搜索一个键,如果找到则返回包含该键的节点的指针,否则返回0。更实际的程序可能会返回与键相关的数据的指针。

find:
    .n equ 0
    push rbp
    mov rbp, rsp
    sub rsp, 16
    mov [rsp + .n], rdi
    call hash
    mov rax, [table + rax * 8]
    mov rdi, [rsp + .n]
    cmp rax, 0
    je .done
.more:
    cmp rdi, [rax + n_value]
    je .done
    mov rax, [rax + n_next]
    cmp rax, 0
    jne .more
.done:
    leave
    ret
3.5 插入键到哈希表

将键插入哈希表的代码首先调用查找函数,以避免重复插入同一个键。如果找到该键,则跳过插入代码;如果未找到,则调用哈希函数确定要添加键的链表的索引,分配一个新节点的内存,并将其插入链表的头部。

insert:
    .n equ 0
    .h equ 8
    push rbp
    mov rbp, rsp
    sub rsp, 16
    mov [rsp + .n], rdi
    call find
    cmp rax, 0
    jne .found
    mov rdi, [rsp + .n]
    call hash
    mov [rsp + .h], rax
    mov rdi, node_size
    call malloc
    mov r9, [rsp + .h]
    mov r8, [table + r9 * 8]
    mov [rax + n_next], r8
    mov r8, [rsp + .n]
    mov [rax + n_value], r8
    mov [table + r9 * 8], rax
.found:
    leave
    ret
3.6 打印哈希表

打印函数会遍历从0到255的索引,打印索引编号和每个非空链表上的键。它使用寄存器 r12 r13 来安全地存储循环计数器和链表指针,这样比使用在每次 printf 调用时都需要保存和恢复的寄存器更方便。在函数开始和结束时需要压入和弹出这两个寄存器,以保持栈的正确对齐。

print:
    push rbp
    mov rbp, rsp
    push r12
    push r13
    xor r12, r12
.more_table:
    mov r13, [table + r12 * 8]
    cmp r13, 0
    je .empty
    segment .data
        .print1 db "list %3d : ", 0
    segment .text
    lea rdi, [.print1]
    mov rsi, r12
    call printf
.more_list:
    segment .data
        .print2 db "%ld ", 0
    segment .text
    lea rdi, [.print2]
    mov rsi, [r13 + n_value]
    call printf
    mov r13, [r13 + n_next]
    cmp r13, 0
    jne .more_list
    segment .data
        .print3 db 0x0a, 0
    segment .text
    lea rdi, [.print3]
    call printf
.empty:
    inc r12
    cmp r12, 256
    jl .more_table
    pop r13
    pop r12
    leave
    ret
3.7 测试哈希表

哈希表的主函数使用 scanf 读取数字,将它们插入哈希表,并在每次插入后打印哈希表的内容:

main:
    .k equ 0
    segment .data
        .scanf_fmt: db "%ld", 0
    segment .text
    push rbp
    mov rbp, rsp
    sub rsp, 16
.more:
    lea rdi, [.scanf_fmt]
    lea rsi, [rsp + .k]
    call scanf
    cmp rax, 1
    jne .done
    mov rdi, [rsp + .k]
    call insert
    call print
    jmp .more
.done:
    leave
    ret

插入1、2、3、4、5、256、257、258、260、513、1025和1028后,哈希表的打印内容如下:

list  0 : 256
list  1 : 1025 513 257 1
list  2 : 258 2
list  3 : 3
list  4 : 1028 260 4
list  5 : 5
4. 二叉树

二叉树是一种可能包含多个节点的结构,有一个根节点,根节点可以有左子节点、右子节点或两者都有。树中的每个节点都可以有左子节点和右子节点。

通常,二叉树会对节点中的键进行排序。例如,在一个二叉树中,每个节点将键分为小于该节点键的键(在左子树中)和大于该节点键的键(在右子树中)。这种有序的二叉树,通常称为二叉搜索树,使得在快速搜索键的同时,还能按升序或降序遍历节点。

这里我们将介绍一个整数键的二叉树,键的排序规则是较小的键在左边,较大的键在右边。首先是用于树的结构。

综上所述,本文详细介绍了链表、双向链表、哈希表和二叉树等数据结构在汇编中的实现方法,包括节点结构的定义、创建、插入、遍历等操作,这些数据结构在算法和数据存储方面都有重要的应用。

数据结构的汇编实现

4.1 二叉树节点结构

二叉树节点包含三个字段:一个数据值、一个指向左子节点的指针和一个指向右子节点的指针。使用YASM的结构定义如下:

struc node
    n_value resq 1
    n_left resq 1
    n_right resq 1
    align 8
ends true
4.2 创建新节点

创建新二叉树节点的代码会分配一个新节点的内存。以下是创建代码:

new_node:
    push rbp
    mov rbp, rsp
    mov edi, node_size
    call malloc
    mov [rax + n_left], 0
    mov [rax + n_right], 0
    leave
    ret
4.3 插入节点到二叉树

将节点插入二叉树时,需要根据节点键的大小,将其插入到合适的位置。如果键小于当前节点的键,则插入到左子树;如果键大于当前节点的键,则插入到右子树。以下是插入代码:

insert:
    .root equ 0
    .k equ 8
    push rbp
    mov rbp, rsp
    sub rsp, 16
    mov [rsp + .root], rdi
    mov [rsp + .k], rsi
    mov rdi, node_size
    call malloc
    mov r8, [rsp + .k]
    mov [rax + n_value], r8
    mov [rax + n_left], 0
    mov [rax + n_right], 0
    mov rbx, [rsp + .root]
    cmp rbx, 0
    je .return_new
.loop:
    cmp r8, [rbx + n_value]
    jl .left
    jmp .right
.left:
    cmp [rbx + n_left], 0
    je .insert_left
    mov rbx, [rbx + n_left]
    jmp .loop
.right:
    cmp [rbx + n_right], 0
    je .insert_right
    mov rbx, [rbx + n_right]
    jmp .loop
.insert_left:
    mov [rbx + n_left], rax
    jmp .done
.insert_right:
    mov [rbx + n_right], rax
    jmp .done
.return_new:
    mov [rsp + .root], rax
.done:
    mov rax, [rsp + .root]
    leave
    ret
4.4 遍历二叉树

二叉树有多种遍历方式,这里介绍中序遍历(左 - 根 - 右)。中序遍历可以按升序输出节点的键。以下是中序遍历的代码:

inorder:
    .root equ 0
    push rbp
    mov rbp, rsp
    sub rsp, 16
    mov [rsp + .root], rdi
    mov rbx, [rsp + .root]
    cmp rbx, 0
    je .done
    push rbx
    mov rdi, [rbx + n_left]
    call inorder
    pop rbx
    segment .data
       .print_fmt: db "%ld ", 0
    segment .text
    lea rdi, [.print_fmt]
    mov rsi, [rbx + n_value]
    xor eax, eax
    call printf
    mov rdi, [rbx + n_right]
    call inorder
.done:
    leave
    ret
4.5 测试二叉树

以下是一个测试二叉树插入和遍历的主函数:

main:
    .root equ 0
    .k equ 8
    segment .data
       .scanf_fmt: db "%ld", 0
    segment .text
    push rbp
    mov rbp, rsp
    sub rsp, 16
    xor rax, rax
    mov [rsp + .root], rax
.more:
    lea rdi, [.scanf_fmt]
    lea rsi, [rsp + .k]
    xor eax, eax
    call scanf
    cmp rax, 1
    jne .done
    mov rdi, [rsp + .root]
    mov rsi, [rsp + .k]
    call insert
    mov [rsp + .root], rax
    mov rdi, [rsp + .root]
    call inorder
    segment .data
       .newline: db 0x0a, 0
    segment .text
    lea rdi, [.newline]
    xor eax, eax
    call printf
    jmp .more
.done:
    leave
    ret

数据结构操作流程总结

为了更清晰地展示各种数据结构的操作流程,下面通过表格和流程图进行总结。

数据结构操作表格
数据结构 创建操作 插入操作 遍历操作
链表 使用 newlist 函数,将指针置为NULL 使用 insert 函数,在头部插入 使用 print 函数,通过指针移动遍历
双向链表 使用 newlist 函数,分配节点并将前后指针指向自身 使用 insert 函数,在头部插入并调整指针 使用 print 函数,跳过头节点并比较指针遍历
哈希表 初始化数组为NULL指针 使用 insert 函数,先查找避免重复,再插入 使用 print 函数,遍历数组和链表
二叉树 使用 new_node 函数,分配节点并初始化子节点指针 使用 insert 函数,根据键大小插入到合适位置 使用 inorder 函数,中序遍历
链表插入操作流程图
graph TD;
    A[开始] --> B[分配新节点内存];
    B --> C[获取链表指针和插入值];
    C --> D[将新节点的下一个指针指向原链表头];
    D --> E[更新链表头指针为新节点];
    E --> F[结束];
二叉树插入操作流程图
graph TD;
    A[开始] --> B[分配新节点内存并初始化值];
    B --> C[获取根节点指针和插入值];
    C --> D{根节点是否为空};
    D -- 是 --> E[返回新节点作为根节点];
    D -- 否 --> F{插入值 < 当前节点值};
    F -- 是 --> G{左子节点是否为空};
    G -- 是 --> H[插入到左子节点];
    G -- 否 --> I[递归插入到左子树];
    F -- 否 --> J{右子节点是否为空};
    J -- 是 --> K[插入到右子节点];
    J -- 否 --> L[递归插入到右子树];
    H --> M[结束];
    I --> M;
    K --> M;
    L --> M;
    E --> M;

总结

本文详细介绍了链表、双向链表、哈希表和二叉树等数据结构在汇编中的实现。通过具体的代码示例,展示了这些数据结构的节点结构定义、创建、插入和遍历等操作。这些数据结构在算法和数据存储方面有着广泛的应用,不同的数据结构适用于不同的场景。

  • 链表适用于需要频繁插入和删除操作的场景,尤其是在头部插入。
  • 双向链表在支持双向遍历和实现栈、队列等数据结构方面具有优势。
  • 哈希表能够高效地实现字典,通过合理的哈希函数和冲突处理,提高查找和插入的效率。
  • 二叉树,特别是二叉搜索树,在需要有序存储和快速查找时非常有用。

掌握这些数据结构的实现和操作,对于提高程序的性能和效率至关重要。在实际应用中,需要根据具体的需求选择合适的数据结构。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值